[
  {
    "path": ".codeclimate.yml",
    "content": "# @link https://docs.codeclimate.com/docs/default-analysis-configuration#sample-codeclimateyml\n\nversion: '2'\n\nchecks:\n  argument-count:\n    enabled: true\n    config:\n      threshold: 4\n  complex-logic:\n    enabled: true\n    config:\n      threshold: 4\n  file-lines:\n    enabled: true\n    config:\n      threshold: 300\n  method-complexity:\n    enabled: true\n    config:\n      threshold: 5\n  method-count:\n    enabled: true\n    config:\n      threshold: 20\n  method-lines:\n    enabled: true\n    config:\n      threshold: 300\n  nested-control-flow:\n    enabled: true\n    config:\n      threshold: 4\n  return-statements:\n    enabled: true\n    config:\n      threshold: 4\n  similar-code:\n    enabled: false\n    config:\n      threshold: 5\n  identical-code:\n    enabled: false\n    config:\n      threshold: 5\n\n# plugins:\n#   eslint:\n#     enabled: true\n#     channel: \"eslint-7\"\n\nexclude_patterns:\n  - 'docs/'\n  - '**/node_modules/'\n  - '**/config/'\n  - '**/*.config.js'\n  - '**/dist/'\n  - '**/scripts/'\n  - '**/__tests__/'\n  - '**/*.test.js'\n  - '**/*.test.jsx'\n  - '**/*.test.ts'\n  - '**/*.test.tsx'\n  - '**/*.d.ts'\n  - '**/*.seed.ts'\n  - '**/seed.ts'\n  - '**/.mesh/'\n"
  },
  {
    "path": ".dockerignore",
    "content": "# All node_modules directories\n**/node_modules\n**/dist\n**/.next\n\n# All secrets\n**/.env.local\n**/.env.*.local\n\n# By default all git files\n.git\n**/.gitignore\n.gitattributes\n.github\n\n# Tools caches\n.cache\n**/*.tsbuildinfo\n**/.eslintcache\n\n# eslint\n**/.eslintrc.cjs\n**/lint-staged.config.js\n\n# npm\n!.npmrc\n\n# Docker related\n.dockerignore\ndockers\n*Dockerfile*\n*docker-compose*\n\n# Log files\nlogs\n**/*.log\n\n# Temp files\ntmp\n*.tmp\n\n# IDE related\n.idea\n.vscode\n\n# other\n**/db\n!packages/v2/adapter-repository-postgres/src/db\n!packages/v2/adapter-repository-postgres/src/db/**\n**/.assets\n**/.temporary\n**.DS_Store\ndocs\n**/*.md\n"
  },
  {
    "path": ".gitattributes",
    "content": "## Source: https://github.com/alexkaratarakis/gitattributes\n## Modified * text=auto to * text eol=lf to force LF endings.\n\n## GITATTRIBUTES FOR WEB PROJECTS\n#\n# These settings are for any web project.\n#\n# Details per file setting:\n#   text    These files should be normalized (i.e. convert CRLF to LF).\n#   binary  These files are binary and should be left untouched.\n#\n# Note that binary is a macro for -text -diff.\n######################################################################\n\n# Auto detect\n##   Force LF line endings automatically for files detected as\n##   text and leave all files detected as binary untouched.\n##   This will handle all files NOT defined below.\n*                 text eol=lf\n\n# Source code\n*.bash            text eol=lf\n*.bat             text eol=crlf\n*.cmd             text eol=crlf\n*.coffee          text\n*.css             text\n*.htm             text diff=html\n*.html            text diff=html\n*.inc             text\n*.ini             text\n*.js              text\n*.json            text\n*.jsx             text\n*.less            text\n*.ls              text\n*.map             text -diff\n*.od              text\n*.onlydata        text\n*.php             text diff=php\n*.pl              text\n*.ps1             text eol=crlf\n*.py              text diff=python\n*.rb              text diff=ruby\n*.sass            text\n*.scm             text\n*.scss            text diff=css\n*.sh              text eol=lf\n*.sql             text\n*.styl            text\n*.tag             text\n*.ts              text\n*.tsx             text\n*.xml             text\n*.xhtml           text diff=html\n\n# Docker\nDockerfile        text\n\n# Documentation\n*.ipynb           text\n*.markdown        text\n*.md              text\n*.mdwn            text\n*.mdown           text\n*.mkd             text\n*.mkdn            text\n*.mdtxt           text\n*.mdtext          text\n*.txt             text\nAUTHORS           text\nCHANGELOG         text\nCHANGES           text\nCONTRIBUTING      text\nCOPYING           text\ncopyright         text\n*COPYRIGHT*       text\nINSTALL           text\nlicense           text\nLICENSE           text\nNEWS              text\nreadme            text\n*README*          text\nTODO              text\n\n# Templates\n*.dot             text\n*.ejs             text\n*.haml            text\n*.handlebars      text\n*.hbs             text\n*.hbt             text\n*.jade            text\n*.latte           text\n*.mustache        text\n*.njk             text\n*.phtml           text\n*.tmpl            text\n*.tpl             text\n*.twig            text\n*.vue             text\n\n# Configs\n*.cnf             text\n*.conf            text\n*.config          text\n.editorconfig     text\n.env              text\n.gitattributes    text\n.gitconfig        text\n.htaccess         text\n*.lock            text -diff\npackage-lock.json text -diff\n*.toml            text\n*.yaml            text\n*.yml             text\nbrowserslist      text\nMakefile          text\nmakefile          text\n\n# Heroku\nProcfile          text\n\n# Graphics\n*.ai              binary\n*.bmp             binary\n*.eps             binary\n*.gif             binary\n*.gifv            binary\n*.glb             binary\n*.ico             binary\n*.jng             binary\n*.jp2             binary\n*.jpg             binary\n*.jpeg            binary\n*.jpx             binary\n*.jxr             binary\n*.pdf             binary\n*.png             binary\n*.psb             binary\n*.psd             binary\n# SVG treated as an asset (binary) by default.\n*.svg             text\n# If you want to treat it as binary,\n# use the following line instead.\n# *.svg           binary\n*.svgz            binary\n*.tif             binary\n*.tiff            binary\n*.wbmp            binary\n*.webp            binary\n*.avif            binary\n*.icns            binary\n\n\n# Audio\n*.kar             binary\n*.m4a             binary\n*.mid             binary\n*.midi            binary\n*.mp3             binary\n*.ogg             binary\n*.ra              binary\n\n# Video\n*.3gpp            binary\n*.3gp             binary\n*.as              binary\n*.asf             binary\n*.asx             binary\n*.fla             binary\n*.flv             binary\n*.m4v             binary\n*.mng             binary\n*.mov             binary\n*.mp4             binary\n*.mpeg            binary\n*.mpg             binary\n*.ogv             binary\n*.swc             binary\n*.swf             binary\n*.webm            binary\n\n# Archives\n*.7z              binary\n*.gz              binary\n*.jar             binary\n*.rar             binary\n*.tar             binary\n*.zip             binary\n\n# Fonts\n*.ttf             binary\n*.eot             binary\n*.otf             binary\n*.woff            binary\n*.woff2           binary\n\n# Executables\n*.exe             binary\n*.pyc             binary\n\n# RC files (like .babelrc or .eslintrc)\n*.*rc             text\n\n# Ignore files (like .npmignore or .gitignore)\n*.*ignore         text"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: teableio\nko_fi: teable\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n** Client (please complete the following information):**\n\n- OS: [e.g. iOS]\n- Browser [e.g. chrome, safari]\n- Version [e.g. 22]\n\n**Platform (Please tell us which deployment version you are using)**\n[eg. teable.ai, docker-standalone, docker-swarm, docker-cluster]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/actions/docker-build-push/action.yml",
    "content": "name: 'Docker build push (app)'\ndescription: 'Build and push Docker images with Buildx'\ninputs:\n  context:\n    description: \"Build's context is the set of files located in the specified PATH or URL\"\n    required: false\n    default: '.'\n  dockerfile:\n    description: 'Path to the Dockerfile'\n    required: false\n    default: 'Dockerfile'\n  push:\n    description: 'Push is a shorthand for --output=type=registry'\n    required: false\n    default: 'true'\n  push-images:\n    description: 'List of Docker images to use as base name for tags'\n    required: true\n  push-tags:\n    description: 'List of tags as key-value pair attributes'\n    required: false\n    default: |\n      type=ref,event=branch\n      type=semver,pattern={{version}}\n      type=semver,pattern={{major}}.{{minor}}\n      type=sha\n      # set latest tag for default branch\n      type=raw,value=latest,enable={{is_default_branch}}\n  platforms:\n    description: 'List of target platforms for build'\n    required: false\n  cache-from:\n    description: 'List of external cache sources for buildx (e.g., user/app:cache, type=local,src=path/to/dir)'\n    required: false\n  cache-to:\n    description: 'List of cache export destinations for buildx (e.g., user/app:cache, type=local,dest=path/to/dir)'\n    required: false\n\nruns:\n  using: 'composite'\n\n  steps:\n    - name: ⚙️ Docker meta\n      id: meta\n      uses: docker/metadata-action@v5\n      with:\n        # list of Docker images to use as base name for tags\n        images: ${{ inputs.push-images }}\n        # generate Docker tags based on the following events/attributes\n        tags: ${{ inputs.push-tags }}\n\n    - name: ⚙️ Set up QEMU\n      uses: docker/setup-qemu-action@v3\n    - name: ⚙️ Set up Docker Buildx\n      uses: docker/setup-buildx-action@v3\n\n    - name: 📦 Build and push（Dockerfile）\n      uses: docker/build-push-action@v5\n      env:\n        GITHUB_ACTIONS: $env:GITHUB_ACTIONS\n        GITHUB_REF_TYPE: $GITHUB_REF_TYPE\n        GITHUB_RUN_NUMBER: GITHUB_RUN_NUMBER\n        GITHUB_SHA: GITHUB_SHA\n      with:\n        context: ${{ inputs.context }}\n        file: ${{ inputs.dockerfile }}\n        push: ${{ inputs.push }}\n        tags: ${{ steps.meta.outputs.tags }}\n        # platforms: linux/amd64,linux/arm64\n        platforms: ${{ inputs.platforms }}\n        cache-from: ${{ inputs.cache-from }}\n        cache-to: ${{ inputs.cache-to }}\n"
  },
  {
    "path": ".github/actions/pnpm-install/action.yml",
    "content": "#######################################################################################\n# \"pnpm install\" composite action                                                      #\n########################################################################################\n\nname: 'Monorepo install (pnpm)'\ndescription: 'Run pnpm install with node_modules linker and cache enabled'\ninputs:\n  cwd:\n    description: \"Changes node's process.cwd() if the project is not located on the root. Default to process.cwd()\"\n    required: false\n    default: '.'\n  cache-prefix:\n    description: 'Add a specific cache-prefix'\n    required: false\n    default: 'default'\n  cache-pnpm-cache:\n    description: 'Cache npm global cache folder often used by node-gyp, prebuild binaries (invalidated on lock/os/node-version)'\n    required: false\n    default: 'true'\n  enable-corepack:\n    description: 'Enable corepack'\n    required: false\n    default: 'true'\n\nruns:\n  using: 'composite'\n\n  steps:\n    - name: ⚙️ Enable Corepack\n      if: inputs.enable-corepack == 'true'\n      shell: bash\n      working-directory: ${{ inputs.cwd }}\n      run: corepack enable\n\n    - name: ⚙️ Expose pnpm config as \"$GITHUB_OUTPUT\"\n      id: pnpm-config\n      shell: bash\n      run: |\n        echo \"STORE_PATH=$(pnpm store path | tr -d '\\n')\" >> $GITHUB_OUTPUT\n\n    - name: ⚙️ Cache rotation keys\n      id: cache-rotation\n      shell: bash\n      run: |\n        echo \"YEAR_MONTH=$(/bin/date -u \"+%Y%m\")\" >> $GITHUB_OUTPUT\n        echo \"YEAR_WEEK=$(/bin/date -u \"+%Y%W\")\" >> $GITHUB_OUTPUT\n\n    - name: ♻️ Restore pnpm cache\n      id: pnpm-store-cache\n      uses: actions/cache@v4\n      with:\n        path: ${{ steps.pnpm-config.outputs.STORE_PATH }}\n        key: ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_WEEK }}-${{ hashFiles('**/pnpm-lock.yaml') }}\n        restore-keys: |\n          ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_WEEK }}-\n\n    - name: 📥 Install dependencies\n      shell: bash\n      run: pnpm install --frozen-lockfile\n      env:\n        # Other environment variables\n        HUSKY: '0' # By default do not run HUSKY install\n"
  },
  {
    "path": ".github/workflows/docker-push.yml",
    "content": "name: Build and Push to Docker Registry\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\non:\n  push:\n    branches:\n      - develop\n    tags:\n      - 'v*'\n    paths:\n      - 'apps/nestjs-backend/**'\n      - 'apps/nextjs-app/**'\n      - 'packages/**'\n      - '.github/**'\n      - 'scripts/**'\n\njobs:\n  build-push:\n    strategy:\n      matrix:\n        target: [app, db-migrate]\n        arch: [amd64, arm64]\n        include:\n          - target: app\n            file: Dockerfile\n            image: teable-community\n          - target: db-migrate\n            file: Dockerfile.db-migrate\n            image: teable-db-migrate-community\n          - arch: amd64\n            runner: ubuntu-latest\n          - arch: arm64\n            runner: ubuntu-24.04-arm\n    runs-on: ${{ matrix.runner }}\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Login to GitHub container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.PACKAGES_KEY }}\n\n      - name: Login to Docker Hub registry\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_NAME }}\n          password: ${{ secrets.DOCKER_HUB_AK }}\n\n      - name: Login to Ali container registry\n        uses: docker/login-action@v3\n        with:\n          registry: registry.cn-shenzhen.aliyuncs.com\n          username: ${{ secrets.ALI_DOCKER_USERNAME }}\n          password: ${{ secrets.ALI_DOCKER_PASSWORD }}\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22.18.0\n      - name: ⚙️ Install zx\n        run: npm install -g zx\n\n      - name: ⚙️ Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            registry.cn-shenzhen.aliyuncs.com/teable/${{ matrix.image }}\n            ghcr.io/teableio/${{ matrix.image }}\n            docker.io/teableio/${{ matrix.image }}\n          tags: |\n            type=sha,format=long\n            type=raw,value=latest\n\n      - name: 📦 Build and push\n        run: |\n          zx scripts/build-image.mjs --file=dockers/teable/${{ matrix.file }} \\\n              --build-arg=\"ENABLE_CSP=false\" \\\n              --tag=\"${{ steps.meta.outputs.tags }}\" \\\n              --platform=\"linux/${{ matrix.arch }}\" \\\n              --push\n\n  create-manifest:\n    needs: build-push\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        target: [app, db-migrate]\n        include:\n          - target: app\n            image: teable-community\n          - target: db-migrate\n            image: teable-db-migrate-community\n\n    steps:\n      - name: Login to GitHub container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.PACKAGES_KEY }}\n\n      - name: Login to Docker Hub registry\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_NAME }}\n          password: ${{ secrets.DOCKER_HUB_AK }}\n\n      - name: Login to Ali container registry\n        uses: docker/login-action@v3\n        with:\n          registry: registry.cn-shenzhen.aliyuncs.com\n          username: ${{ secrets.ALI_DOCKER_USERNAME }}\n          password: ${{ secrets.ALI_DOCKER_PASSWORD }}\n\n      - name: Create and push manifest\n        run: |\n          REGISTRIES=(\"registry.cn-shenzhen.aliyuncs.com/teable\" \"ghcr.io/teableio\" \"docker.io/teableio\")\n          TAGS=(\"latest\" \"sha-${{ github.sha }}\")\n\n          for REGISTRY in \"${REGISTRIES[@]}\"; do\n            for TAG in \"${TAGS[@]}\"; do\n              docker manifest create $REGISTRY/${{ matrix.image }}:$TAG \\\n                $REGISTRY/${{ matrix.image }}:${TAG}-amd64 \\\n                $REGISTRY/${{ matrix.image }}:${TAG}-arm64\n              \n              docker manifest push $REGISTRY/${{ matrix.image }}:$TAG\n            done\n          done\n"
  },
  {
    "path": ".github/workflows/integration-tests.yml",
    "content": "name: Integration Tests\n\non:\n  push:\n    branches:\n      - develop\n  pull_request:\n    branches:\n      - develop\n    paths:\n      - 'apps/nestjs-backend/**'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    name: Integration Tests - ${{ matrix.e2e.database-type }} ${{ matrix.e2e.shard }} ${{ matrix.runtime.mode }}\n\n    strategy:\n      fail-fast: false\n      matrix:\n        node-version: [22.18.0]\n        runtime:\n          - mode: v1\n            force-v2-all: ''\n            computed-update-mode: ''\n          - mode: v2\n            force-v2-all: 'true'\n            computed-update-mode: 'sync'\n        e2e:\n          - database-type: postgres\n            shard: 1/4\n          - database-type: postgres\n            shard: 2/4\n          - database-type: postgres\n            shard: 3/4\n          - database-type: postgres\n            shard: 4/4\n    env:\n      CI: 1\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n\n      - name: 🧪 Run Tests\n        env:\n          CI: 1\n          FORCE_V2_ALL: ${{ matrix.runtime.force-v2-all }}\n          V2_COMPUTED_UPDATE_MODE: ${{ matrix.runtime.computed-update-mode }}\n          VITEST_MAX_THREADS: 2\n          VITEST_MIN_THREADS: 1\n          VITEST_SHARD: ${{ matrix.e2e.shard }}\n          VITEST_REPORTER: blob\n        run: |\n          make ${{ matrix.e2e.database-type }}.integration.test\n          pnpm -F \"@teable/backend\" test-unit-cover\n          pnpm -F \"@teable/backend\" merge-cover\n          pnpm -F \"@teable/backend\" generate-cover\n\n      - name: Coveralls Parallel\n        uses: coverallsapp/github-action@v2\n        with:\n          flag-name: run-${{ join(matrix.*, '-') }}\n          file: apps/nestjs-backend/coverage/nestjs-backend/clover.xml\n          parallel: true\n\n  finish:\n    needs: test\n    runs-on: ubuntu-latest\n    steps:\n      - name: Coveralls Finished\n        uses: coverallsapp/github-action@v2\n        with:\n          parallel-finished: true\n"
  },
  {
    "path": ".github/workflows/issue-id-check.yml",
    "content": "name: Issue ID Check\n\non:\n  pull_request:\n    types: [opened, synchronize, edited, reopened]\n    branches:\n      - develop\n\npermissions:\n  pull-requests: write\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  check-issue-ids:\n    runs-on: ubuntu-latest\n    name: Check Issue IDs\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: 🔍 Extract Issue IDs from PR\n        id: extract-issues\n        env:\n          PR_TITLE: ${{ github.event.pull_request.title }}\n          PR_BODY: ${{ github.event.pull_request.body }}\n          BASE_SHA: ${{ github.event.pull_request.base.sha }}\n          HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n        run: |\n          echo \"🔍 Checking for Issue IDs (pattern: T followed by numbers)...\"\n\n          # Extract Issue IDs from PR title\n          echo \"📝 PR Title: $PR_TITLE\"\n          TITLE_ISSUES=$(echo \"$PR_TITLE\" | grep -oE 'T[0-9]+' || true)\n\n          # Extract Issue IDs from PR body/description\n          echo \"📝 PR Body:\"\n          echo \"$PR_BODY\"\n          BODY_ISSUES=$(echo \"$PR_BODY\" | grep -oE 'T[0-9]+' || true)\n\n          # Extract Issue IDs from all commit messages (including body)\n          echo \"📝 Commit Messages:\"\n          COMMIT_MESSAGES=$(git log --format=\"%B\" $BASE_SHA..$HEAD_SHA 2>/dev/null || git log --format=\"%B\" -n 20)\n          echo \"$COMMIT_MESSAGES\"\n          COMMIT_ISSUES=$(echo \"$COMMIT_MESSAGES\" | grep -oE 'T[0-9]+' || true)\n\n          # Combine all Issue IDs and remove duplicates\n          ALL_ISSUES=$(echo -e \"$TITLE_ISSUES\\n$BODY_ISSUES\\n$COMMIT_ISSUES\" | grep -E '^T[0-9]+$' | sort -u | tr '\\n' ' ' | xargs)\n\n          echo \"📋 Found Issue IDs: $ALL_ISSUES\"\n\n          if [ -z \"$ALL_ISSUES\" ]; then\n            echo \"❌ No Issue IDs found!\"\n            echo \"issue_ids=\" >> $GITHUB_OUTPUT\n            echo \"has_issues=false\" >> $GITHUB_OUTPUT\n          else\n            echo \"✅ Found Issue IDs: $ALL_ISSUES\"\n            echo \"issue_ids=$ALL_ISSUES\" >> $GITHUB_OUTPUT\n            echo \"has_issues=true\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: ❌ Fail if no Issue IDs found\n        if: steps.extract-issues.outputs.has_issues == 'false'\n        run: |\n          echo \"::error::No Issue IDs found in PR title, body, or commit messages.\"\n          echo \"Please include at least one Issue ID (format: T followed by numbers, e.g., T1263) in:\"\n          echo \"  - PR title\"\n          echo \"  - PR description/body\"\n          echo \"  - Commit message (including body)\"\n          exit 1\n\n      - name: 🔗 Verify Issue IDs exist in Teable\n        if: steps.extract-issues.outputs.has_issues == 'true'\n        id: verify-issues\n        env:\n          TEABLE_API_TOKEN: ${{ secrets.APP_TEABLE_AI_TOKEN }}\n          ISSUE_IDS: ${{ steps.extract-issues.outputs.issue_ids }}\n          PR_URL: ${{ github.event.pull_request.html_url }}\n        run: |\n          echo \"🔗 Verifying Issue IDs in Teable: $ISSUE_IDS\"\n\n          # Build filter for multiple Issue IDs\n          FILTER_SET=\"\"\n          for ISSUE_ID in $ISSUE_IDS; do\n            if [ -n \"$FILTER_SET\" ]; then\n              FILTER_SET=\"$FILTER_SET,\"\n            fi\n            FILTER_SET=\"$FILTER_SET{\\\"fieldId\\\":\\\"Issue_ID\\\",\\\"operator\\\":\\\"is\\\",\\\"value\\\":\\\"$ISSUE_ID\\\"}\"\n          done\n\n          FILTER=\"{\\\"conjunction\\\":\\\"or\\\",\\\"filterSet\\\":[$FILTER_SET]}\"\n          ENCODED_FILTER=$(echo \"$FILTER\" | jq -sRr @uri)\n\n          echo \"📤 Querying Teable API...\"\n\n          RESPONSE=$(curl -s -w \"\\n%{http_code}\" -X GET \\\n            \"https://app.teable.ai/api/table/tblNHimLUhUDtC3K7Jk/record?fieldKeyType=dbFieldName&viewId=viwBK7iTy1604XbFdYh&filter=$ENCODED_FILTER\" \\\n            -H \"Authorization: Bearer $TEABLE_API_TOKEN\" \\\n            -H \"Accept: application/json\")\n\n          HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n          BODY=$(echo \"$RESPONSE\" | sed '$d')\n\n          echo \"📥 API Response Code: $HTTP_CODE\"\n\n          if [ \"$HTTP_CODE\" != \"200\" ]; then\n            echo \"::error::Failed to query Teable API. HTTP Code: $HTTP_CODE\"\n            echo \"Response: $BODY\"\n            exit 1\n          fi\n\n          # Check if records exist\n          RECORD_COUNT=$(echo \"$BODY\" | jq '.records | length')\n          echo \"📊 Found $RECORD_COUNT matching records in Teable\"\n\n          if [ \"$RECORD_COUNT\" -eq 0 ]; then\n            echo \"::error::No matching Issue IDs found in Teable. Please ensure the Issue IDs ($ISSUE_IDS) exist.\"\n            exit 1\n          fi\n\n          # Extract record IDs and their statuses for updating\n          echo \"$BODY\" | jq -c '.records[] | {id: .id, status: .fields.status}' > /tmp/records.json\n\n          RECORD_IDS=$(echo \"$BODY\" | jq -r '.records[].id')\n          echo \"record_ids<<EOF\" >> $GITHUB_OUTPUT\n          echo \"$RECORD_IDS\" >> $GITHUB_OUTPUT\n          echo \"EOF\" >> $GITHUB_OUTPUT\n          echo \"record_count=$RECORD_COUNT\" >> $GITHUB_OUTPUT\n\n          # Save full response for status checking\n          echo \"$BODY\" > /tmp/teable_response.json\n\n          echo \"✅ All Issue IDs verified successfully!\"\n\n      - name: 📝 Update Teable records (Community_PR & Status)\n        if: steps.extract-issues.outputs.has_issues == 'true' && steps.verify-issues.outputs.record_count > 0\n        env:\n          TEABLE_API_TOKEN: ${{ secrets.APP_TEABLE_AI_TOKEN }}\n          PR_URL: ${{ github.event.pull_request.html_url }}\n        run: |\n          echo \"📝 Updating Teable records...\"\n\n          # Status values that should be updated to \"Entered development workflow\"\n          STATUSES_TO_UPDATE=(\"\" \"Need more information\" \"Added to backlog\")\n\n          # Read records from saved response\n          cat /tmp/teable_response.json | jq -c '.records[]' | while read -r record; do\n            RECORD_ID=$(echo \"$record\" | jq -r '.id')\n            CURRENT_STATUS=$(echo \"$record\" | jq -r '.fields.status // \"\"')\n            \n            echo \"Processing record: $RECORD_ID (current status: '$CURRENT_STATUS')\"\n            \n            # Determine if status should be updated\n            SHOULD_UPDATE_STATUS=\"false\"\n            for status in \"${STATUSES_TO_UPDATE[@]}\"; do\n              if [ \"$CURRENT_STATUS\" == \"$status\" ]; then\n                SHOULD_UPDATE_STATUS=\"true\"\n                break\n              fi\n            done\n            \n            # Build update payload\n            if [ \"$SHOULD_UPDATE_STATUS\" == \"true\" ]; then\n              echo \"  → Status will be updated to 'Entered development workflow'\"\n              UPDATE_PAYLOAD=\"{\\\"fieldKeyType\\\":\\\"dbFieldName\\\",\\\"record\\\":{\\\"fields\\\":{\\\"Community_PR\\\":\\\"$PR_URL\\\",\\\"status\\\":\\\"Entered development workflow\\\"}}}\"\n            else\n              echo \"  → Status will not be changed (current: '$CURRENT_STATUS')\"\n              UPDATE_PAYLOAD=\"{\\\"fieldKeyType\\\":\\\"dbFieldName\\\",\\\"record\\\":{\\\"fields\\\":{\\\"Community_PR\\\":\\\"$PR_URL\\\"}}}\"\n            fi\n            \n            # Send update request\n            UPDATE_RESPONSE=$(curl -s -w \"\\n%{http_code}\" -X PATCH \\\n              \"https://app.teable.ai/api/table/tblNHimLUhUDtC3K7Jk/record/$RECORD_ID\" \\\n              -H \"Authorization: Bearer $TEABLE_API_TOKEN\" \\\n              -H \"Content-Type: application/json\" \\\n              -H \"Accept: application/json\" \\\n              -d \"$UPDATE_PAYLOAD\")\n            \n            HTTP_CODE=$(echo \"$UPDATE_RESPONSE\" | tail -n1)\n            BODY=$(echo \"$UPDATE_RESPONSE\" | sed '$d')\n            \n            if [ \"$HTTP_CODE\" != \"200\" ]; then\n              echo \"::warning::Failed to update record $RECORD_ID. HTTP Code: $HTTP_CODE\"\n              echo \"Response: $BODY\"\n            else\n              echo \"✅ Successfully updated record $RECORD_ID\"\n            fi\n          done\n\n          echo \"✅ Teable records update completed!\"\n\n      - name: 📝 Append Issue IDs to PR description\n        if: steps.extract-issues.outputs.has_issues == 'true' && steps.verify-issues.outputs.record_count > 0\n        env:\n          GH_TOKEN: ${{ github.token }}\n          ISSUE_IDS: ${{ steps.extract-issues.outputs.issue_ids }}\n          PR_NUMBER: ${{ github.event.pull_request.number }}\n          PR_BODY: ${{ github.event.pull_request.body }}\n        run: |\n          echo \"📝 Checking if Issue IDs need to be appended to PR description...\"\n\n          # Create Issue IDs reference line\n          ISSUE_IDS_LINE=\"**Related Issues:** $ISSUE_IDS\"\n\n          # Check if Issue IDs are already in the PR body\n          ISSUES_ALREADY_IN_BODY=\"true\"\n          for ISSUE_ID in $ISSUE_IDS; do\n            if ! echo \"$PR_BODY\" | grep -q \"$ISSUE_ID\"; then\n              ISSUES_ALREADY_IN_BODY=\"false\"\n              break\n            fi\n          done\n\n          # Check if the reference line already exists\n          if echo \"$PR_BODY\" | grep -q \"^\\*\\*Related Issues:\\*\\*\"; then\n            echo \"✅ Related Issues line already exists in PR description, skipping update\"\n            exit 0\n          fi\n\n          # If all Issue IDs are already in the body but not in the reference format, we still want to add the reference line\n          # This ensures consistency and makes it easier to parse\n\n          echo \"📝 Appending Issue IDs to PR description...\"\n\n          # Append Issue IDs to PR body\n          if [ -z \"$PR_BODY\" ]; then\n            NEW_BODY=\"$ISSUE_IDS_LINE\"\n          else\n            NEW_BODY=\"$PR_BODY\n\n          ---\n          $ISSUE_IDS_LINE\"\n          fi\n\n          # Update PR description using GitHub CLI\n          gh pr edit \"$PR_NUMBER\" --body \"$NEW_BODY\"\n\n          echo \"✅ PR description updated with Issue IDs!\"\n\n      - name: ✅ Check Complete\n        if: steps.extract-issues.outputs.has_issues == 'true'\n        run: |\n          echo \"✅ Issue ID check completed successfully!\"\n          echo \"📋 Verified Issue IDs: ${{ steps.extract-issues.outputs.issue_ids }}\"\n          echo \"🔗 PR URL and status updated in Teable records\"\n          echo \"📝 Issue IDs appended to PR description for squash merge\"\n"
  },
  {
    "path": ".github/workflows/linting.yml",
    "content": "name: Linting and Types\n\non:\n  pull_request:\n    branches:\n      - develop\n    paths:\n      - 'apps/**'\n      - 'packages/**'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\njobs:\n  build:\n    runs-on: ubuntu-latest\n    name: Linting and Types\n\n    strategy:\n      matrix:\n        node-version: [22.18.0]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n\n      - name: 🧩 Generate Prisma Client\n        working-directory: packages/db-main-prisma\n        run: |\n          pnpm -F @teable/db-main-prisma prisma-generate --schema ./prisma/postgres/schema.prisma\n\n      - name: 🏗 Run build\n        run: |\n          pnpm -F \"./packages/**\" run build\n\n      - name: 🕵️ Typecheck\n        run: |\n          pnpm g:typecheck\n\n      - name: 🔬 Linter\n        run: |\n          pnpm g:lint\n          pnpm g:lint-styles\n"
  },
  {
    "path": ".github/workflows/manual-preview.yml",
    "content": "name: Preview PR\n\npermissions:\n  contents: read\n  pull-requests: write\n\non:\n  pull_request:\n    types:\n      - opened\n      - synchronize\n      - reopened\n      - labeled\n      - unlabeled\n\nenv:\n  NAMESPACE: 38puz7wo\n  INSTANCE_NAME: pr-${{ github.event.pull_request.number }}\n  INSTANCE_DOMAIN: pr-${{ github.event.pull_request.number }}\n  DISPLAY_NAME: 'teable-pr-${{ github.event.pull_request.number }}'\n  MAIN_IMAGE_REPOSITORY: registry.cn-shenzhen.aliyuncs.com/teable/teable\n  IMAGE_TAG: ${{ github.sha }}-amd64\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\njobs:\n  check-pr:\n    runs-on: ubuntu-latest\n    outputs:\n      should_deploy: ${{ steps.check.outputs.should_deploy }}\n    steps:\n      - name: Check PR labels\n        id: check\n        uses: actions/github-script@v6\n        with:\n          script: |\n            const hasPreviewLabel = context.payload.pull_request.labels.some(\n              label => label.name === 'preview'\n            );\n            console.log('Has preview label:', hasPreviewLabel);\n            core.setOutput('should_deploy', hasPreviewLabel.toString());\n            return hasPreviewLabel;\n\n  build-push:\n    needs: check-pr\n    if: needs.check-pr.outputs.should_deploy == 'true'\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        include:\n          - image: teable\n            file: Dockerfile\n          - image: teable-db-migrate\n            file: Dockerfile.db-migrate\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Login to Ali container registry\n        uses: docker/login-action@v3\n        with:\n          registry: registry.cn-shenzhen.aliyuncs.com\n          username: ${{ secrets.ALI_DOCKER_USERNAME }}\n          password: ${{ secrets.ALI_DOCKER_PASSWORD }}\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22.18.0\n      - name: ⚙️ Install zx\n        run: npm install -g zx\n\n      - name: ⚙️ Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            registry.cn-shenzhen.aliyuncs.com/teable/${{ matrix.image }}\n          tags: |\n            type=raw,value=alpha-pr-${{ github.event.pull_request.number }}\n            type=raw,value=${{ github.sha }}\n      - name: ⚙️ Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: 📦 Build and push\n        run: |\n          zx scripts/build-image.mjs --file=dockers/teable/${{ matrix.file }} \\\n              --build-arg=\"ENABLE_CSP=false\" \\\n              --tag=\"${{ steps.meta.outputs.tags }}\" \\\n              --platform=\"linux/amd64\" \\\n              --push\n\n  deploy:\n    needs: [check-pr, build-push]\n    if: needs.check-pr.outputs.should_deploy == 'true'\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Create deployment YAML\n        run: |\n          cp .github/workflows/templates/preview-template.yaml deploy.yaml\n          sed -i \"s#__NAMESPACE__#${{ env.NAMESPACE }}#g\" deploy.yaml\n          sed -i \"s#__INSTANCE_NAME__#${{ env.INSTANCE_NAME }}#g\" deploy.yaml\n          sed -i \"s#__INSTANCE_DOMAIN__#${{ env.INSTANCE_DOMAIN }}#g\" deploy.yaml\n          sed -i \"s#__MAIN_IMAGE_REPOSITORY__#${{ env.MAIN_IMAGE_REPOSITORY }}#g\" deploy.yaml\n          sed -i \"s#__IMAGE_TAG__#${{ env.IMAGE_TAG }}#g\" deploy.yaml\n          sed -i \"s#__DISPLAY_NAME__#${{ env.DISPLAY_NAME }}#g\" deploy.yaml\n\n      - name: Apply deploy job\n        uses: actions-hub/kubectl@master\n        env:\n          KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}\n        with:\n          args: apply -f deploy.yaml\n\n      - name: Rollout status\n        uses: actions-hub/kubectl@master\n        env:\n          KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}\n        with:\n          args: rollout status deployment/teable-${{ env.INSTANCE_NAME }} --timeout=300s\n\n      - name: Wait for application health check\n        uses: actions-hub/kubectl@master\n        env:\n          KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}\n        with:\n          args: exec deployment/teable-${{ env.INSTANCE_NAME }} -- curl -f --retry 30 --retry-delay 5 --retry-connrefused http://localhost:3000/health\n\n      - name: Create deployment status comment\n        if: always()\n        env:\n          JOB_STATUS: ${{ job.status }}\n        uses: actions/github-script@v6\n        with:\n          script: |\n            const success = process.env.JOB_STATUS === 'success';\n            const deploymentUrl = `https://${process.env.INSTANCE_DOMAIN}.sealoshzh.site`;\n            const status = success ? '✅ Success' : '❌ Failed';\n            console.log(process.env.JOB_STATUS);\n\n            const commentBody = `**Deployment Status: ${status}**\n            ${success ? `🔗 Preview URL: ${deploymentUrl}` : ''}`;\n\n            await github.rest.issues.createComment({\n              ...context.repo,\n              issue_number: context.payload.pull_request.number,\n              body: commentBody\n            });\n"
  },
  {
    "path": ".github/workflows/preview-cleanup.yml",
    "content": "name: Cleanup Preview Environment\n\non:\n  pull_request:\n    types: [closed]\n\nenv:\n  NAMESPACE: 38puz7wo\n  INSTANCE_NAME: pr-${{ github.event.pull_request.number }}\n  INSTANCE_DOMAIN: pr-${{ github.event.pull_request.number }}\n  DISPLAY_NAME: \"teable-pr-${{ github.event.pull_request.number }}\"\n  MAIN_IMAGE_REPOSITORY: registry.cn-shenzhen.aliyuncs.com/teable/teable\n  IMAGE_TAG: ${{ github.sha }}-amd64\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Create deployment YAML\n        run: |\n          cp .github/workflows/templates/preview-template.yaml deploy.yaml\n          sed -i \"s#__NAMESPACE__#${{ env.NAMESPACE }}#g\" deploy.yaml\n          sed -i \"s#__INSTANCE_NAME__#${{ env.INSTANCE_NAME }}#g\" deploy.yaml\n          sed -i \"s#__INSTANCE_DOMAIN__#${{ env.INSTANCE_DOMAIN }}#g\" deploy.yaml\n          sed -i \"s#__MAIN_IMAGE_REPOSITORY__#${{ env.MAIN_IMAGE_REPOSITORY }}#g\" deploy.yaml\n          sed -i \"s#__IMAGE_TAG__#${{ env.IMAGE_TAG }}#g\" deploy.yaml\n          sed -i \"s#__DISPLAY_NAME__#${{ env.DISPLAY_NAME }}#g\" deploy.yaml\n\n      - name: Delete deployment\n        uses: actions-hub/kubectl@master\n        env:\n          KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}\n        with:\n          args: delete -f deploy.yaml --ignore-not-found=true\n\n      - name: Create cleanup status comment\n        uses: actions/github-script@v6\n        with:\n          script: |\n            const prNumber = context.payload.pull_request.number;\n            const mergeStatus = context.payload.pull_request.merged ? 'Merged' : 'Closed';\n            \n            const commentBody = `## 🧹 Preview Environment Cleanup\n            * PR #${prNumber} has been ${mergeStatus}\n            * Preview environment has been deleted\n            * Cleanup time: ${new Date().toISOString()}`;\n\n            await github.rest.issues.createComment({\n              ...context.repo,\n              issue_number: prNumber,\n              body: commentBody\n            });\n"
  },
  {
    "path": ".github/workflows/templates/preview-template.yaml",
    "content": "apiVersion: app.sealos.io/v1\nkind: Instance\nmetadata:\n  name: teable-__INSTANCE_NAME__\n  labels:\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  gitRepo: https://github.com/teableio/teable\n  templateType: inline\n  categories:\n    - database\n    - low-code\n  defaults:\n    app_host:\n      type: string\n      value: __INSTANCE_DOMAIN__\n    app_name:\n      type: string\n      value: teable-__INSTANCE_NAME__\n    jwt_secret:\n      type: string\n      value: exdpbfxmlqhjnqxu\n    session_secret:\n      type: string\n      value: lvgxahpasprcclii\n  inputs: null\n  title: teable\n  url: teable.cn\n  author: Sealos\n  description: >-\n    Teable is a Super fast, Real-time, Professional, Developer friendly, No-code\n    database built on Postgres.\n  readme: https://cdn.jsdelivr.net/gh/teableio/teable@develop/README.md\n  icon: https://framerusercontent.com/images/x9gZmjwbtvaGd95qbfUmsZ8Jc.png\n\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: teable-__INSTANCE_NAME__\n  annotations:\n    originImageName: >-\n      __MAIN_IMAGE_REPOSITORY__:__IMAGE_TAG__\n    deploy.cloud.sealos.io/minReplicas: '1'\n    deploy.cloud.sealos.io/maxReplicas: '1'\n  labels:\n    cloud.sealos.io/app-deploy-manager: teable-__INSTANCE_NAME__\n    app: teable-__INSTANCE_NAME__\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  replicas: 1\n  revisionHistoryLimit: 1\n  minReadySeconds: 10\n  selector:\n    matchLabels:\n      app: teable-__INSTANCE_NAME__\n  template:\n    metadata:\n      labels:\n        app: teable-__INSTANCE_NAME__\n    spec:\n      terminationGracePeriodSeconds: 10\n      automountServiceAccountToken: false\n      initContainers:\n        - name: db-migrate\n          image: >-\n            __MAIN_IMAGE_REPOSITORY__:__IMAGE_TAG__\n          args: ['migrate-only']\n          env:\n            - name: PG_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-pg-conn-credential\n                  key: password\n            - name: PG_PORT\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-pg-conn-credential\n                  key: port\n            - name: PRISMA_DATABASE_URL\n              value: >-\n                postgresql://postgres:$(PG_PASSWORD)@teable-__INSTANCE_NAME__-pg-postgresql.ns-__NAMESPACE__.svc:$(PG_PORT)/teable\n            - name: PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING\n              value: '1'\n          resources:\n            requests:\n              cpu: 100m\n              memory: 102Mi\n            limits:\n              cpu: 1000m\n              memory: 1024Mi\n      containers:\n        - name: teable-__INSTANCE_NAME__\n          image: >-\n            __MAIN_IMAGE_REPOSITORY__:__IMAGE_TAG__\n          args: ['skip-migrate']\n          env:\n            - name: PG_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-pg-conn-credential\n                  key: password\n            - name: PG_PORT\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-pg-conn-credential\n                  key: port\n            - name: PRISMA_DATABASE_URL\n              value: >-\n                postgresql://postgres:$(PG_PASSWORD)@teable-__INSTANCE_NAME__-pg-postgresql.ns-__NAMESPACE__.svc:$(PG_PORT)/teable\n            - name: PUBLIC_ORIGIN\n              value: https://__INSTANCE_DOMAIN__.sealoshzh.site\n            - name: BACKEND_JWT_SECRET\n              value: exdpbfxmlqhjnqxu\n            - name: BACKEND_SESSION_SECRET\n              value: lvgxahpasprcclii\n            - name: BACKEND_STORAGE_PROVIDER\n              value: minio\n            - name: BACKEND_STORAGE_PUBLIC_BUCKET\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key-__NAMESPACE__-teable-__INSTANCE_NAME__-public\n                  key: bucket\n            - name: BACKEND_STORAGE_PRIVATE_BUCKET\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key-__NAMESPACE__-teable-__INSTANCE_NAME__-private\n                  key: bucket\n            - name: BACKEND_STORAGE_MINIO_ENDPOINT\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key\n                  key: external\n            - name: BACKEND_STORAGE_MINIO_INTERNAL_ENDPOINT\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key\n                  key: internal\n            - name: BACKEND_STORAGE_MINIO_ACCESS_KEY\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key\n                  key: accessKey\n            - name: BACKEND_STORAGE_MINIO_SECRET_KEY\n              valueFrom:\n                secretKeyRef:\n                  name: object-storage-key\n                  key: secretKey\n            - name: BACKEND_STORAGE_MINIO_PORT\n              value: '443'\n            - name: BACKEND_STORAGE_MINIO_INTERNAL_PORT\n              value: '80'\n            - name: BACKEND_STORAGE_MINIO_USE_SSL\n              value: 'true'\n            - name: STORAGE_PREFIX\n              value: https://$(BACKEND_STORAGE_MINIO_ENDPOINT)\n            - name: BACKEND_CACHE_PROVIDER\n              value: redis\n            - name: REDIS_HOST\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-redis-conn-credential\n                  key: host\n            - name: REDIS_PORT\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-redis-conn-credential\n                  key: port\n            - name: REDIS_USERNAME\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-redis-conn-credential\n                  key: username\n            - name: REDIS_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-redis-conn-credential\n                  key: password\n            - name: BACKEND_CACHE_REDIS_URI\n              value: >-\n                redis://$(REDIS_USERNAME):$(REDIS_PASSWORD)@$(REDIS_HOST).ns-__NAMESPACE__.svc:$(REDIS_PORT)/1\n          resources:\n            requests:\n              cpu: 200m\n              memory: 400Mi\n            limits:\n              cpu: 1000m\n              memory: 1024Mi\n          ports:\n            - containerPort: 3000\n          imagePullPolicy: IfNotPresent\n          livenessProbe:\n            httpGet:\n              path: /health\n              port: 3000\n            initialDelaySeconds: 30\n            periodSeconds: 30\n            timeoutSeconds: 3\n      securityContext:\n        fsGroup: 1000\n\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: teable-__INSTANCE_NAME__\n  labels:\n    cloud.sealos.io/app-deploy-manager: teable-__INSTANCE_NAME__\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  ports:\n    - port: 3000\n  selector:\n    app: teable-__INSTANCE_NAME__\n\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: teable-__INSTANCE_NAME__\n  labels:\n    cloud.sealos.io/app-deploy-manager: teable-__INSTANCE_NAME__\n    cloud.sealos.io/app-deploy-manager-domain: __INSTANCE_DOMAIN__\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  annotations:\n    kubernetes.io/ingress.class: nginx\n    nginx.ingress.kubernetes.io/proxy-body-size: 32m\n    nginx.ingress.kubernetes.io/server-snippet: |\n      client_header_buffer_size 64k;\n      large_client_header_buffers 4 128k;\n    nginx.ingress.kubernetes.io/ssl-redirect: 'false'\n    nginx.ingress.kubernetes.io/backend-protocol: HTTP\n    nginx.ingress.kubernetes.io/client-body-buffer-size: 64k\n    nginx.ingress.kubernetes.io/proxy-buffer-size: 64k\n    nginx.ingress.kubernetes.io/proxy-send-timeout: '300'\n    nginx.ingress.kubernetes.io/proxy-read-timeout: '300'\nspec:\n  rules:\n    - host: __INSTANCE_DOMAIN__.sealoshzh.site\n      http:\n        paths:\n          - pathType: Prefix\n            path: /\n            backend:\n              service:\n                name: teable-__INSTANCE_NAME__\n                port:\n                  number: 3000\n  tls:\n    - hosts:\n        - __INSTANCE_DOMAIN__.sealoshzh.site\n      secretName: wildcard-cert\n\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-pg\n\n---\napiVersion: apps.kubeblocks.io/v1alpha1\nkind: Cluster\nmetadata:\n  finalizers:\n    - cluster.kubeblocks.io/finalizer\n  labels:\n    clusterdefinition.kubeblocks.io/name: postgresql\n    clusterversion.kubeblocks.io/name: postgresql-14.8.0\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-pg\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  annotations: {}\n  name: teable-__INSTANCE_NAME__-pg\nspec:\n  affinity:\n    nodeLabels: {}\n    podAntiAffinity: Preferred\n    tenancy: SharedNode\n    topologyKeys: []\n  clusterDefinitionRef: postgresql\n  clusterVersionRef: postgresql-14.8.0\n  componentSpecs:\n    - componentDefRef: postgresql\n      monitor: true\n      name: postgresql\n      replicas: 1\n      resources:\n        limits:\n          cpu: 500m\n          memory: 512Mi\n        requests:\n          cpu: 100m\n          memory: 102Mi\n      serviceAccountName: teable-__INSTANCE_NAME__-pg\n      switchPolicy:\n        type: Noop\n      volumeClaimTemplates:\n        - name: data\n          spec:\n            accessModes:\n              - ReadWriteOnce\n            resources:\n              requests:\n                storage: 1Gi\n  terminationPolicy: Delete\n  tolerations: []\n\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-pg\nrules:\n  - apiGroups:\n      - '*'\n    resources:\n      - '*'\n    verbs:\n      - '*'\n\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-pg\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-pg\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: teable-__INSTANCE_NAME__-pg\nsubjects:\n  - kind: ServiceAccount\n    name: teable-__INSTANCE_NAME__-pg\n\n---\napiVersion: batch/v1\nkind: Job\nmetadata:\n  name: teable-__INSTANCE_NAME__-init\n  labels:\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  completions: 1\n  template:\n    spec:\n      containers:\n        - name: pgsql-init\n          image: senzing/postgresql-client:2.2.4\n          env:\n            - name: PG_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: teable-__INSTANCE_NAME__-pg-conn-credential\n                  key: password\n            - name: DATABASE_URL\n              value: >-\n                postgresql://postgres:$(PG_PASSWORD)@teable-__INSTANCE_NAME__-pg-postgresql.ns-__NAMESPACE__.svc:5432\n          command:\n            - /bin/sh\n            - '-c'\n            - >\n              until psql ${DATABASE_URL} -c 'CREATE DATABASE teable;'\n              &>/dev/null; do sleep 1; done\n      restartPolicy: Never\n  backoffLimit: 0\n  ttlSecondsAfterFinished: 300\n\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-redis\n\n---\napiVersion: apps.kubeblocks.io/v1alpha1\nkind: Cluster\nmetadata:\n  finalizers:\n    - cluster.kubeblocks.io/finalizer\n  labels:\n    clusterdefinition.kubeblocks.io/name: redis\n    clusterversion.kubeblocks.io/name: redis-7.0.6\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-redis\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  annotations: {}\n  name: teable-__INSTANCE_NAME__-redis\nspec:\n  affinity:\n    nodeLabels: {}\n    podAntiAffinity: Preferred\n    tenancy: SharedNode\n    topologyKeys: []\n  clusterDefinitionRef: redis\n  clusterVersionRef: redis-7.0.6\n  componentSpecs:\n    - componentDefRef: redis\n      monitor: true\n      name: redis\n      replicas: 1\n      resources:\n        limits:\n          cpu: 500m\n          memory: 512Mi\n        requests:\n          cpu: 100m\n          memory: 102Mi\n      serviceAccountName: teable-__INSTANCE_NAME__-redis\n      switchPolicy:\n        type: Noop\n      volumeClaimTemplates:\n        - name: data\n          spec:\n            accessModes:\n              - ReadWriteOnce\n            resources:\n              requests:\n                storage: 1Gi\n    - componentDefRef: redis-sentinel\n      monitor: true\n      name: redis-sentinel\n      replicas: 1\n      resources:\n        limits:\n          cpu: 100m\n          memory: 100Mi\n        requests:\n          cpu: 100m\n          memory: 100Mi\n      serviceAccountName: teable-__INSTANCE_NAME__-redis\n  terminationPolicy: Delete\n  tolerations: []\n\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-redis\nrules:\n  - apiGroups:\n      - '*'\n    resources:\n      - '*'\n    verbs:\n      - '*'\n\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  labels:\n    sealos-db-provider-cr: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/instance: teable-__INSTANCE_NAME__-redis\n    app.kubernetes.io/managed-by: kbcli\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\n  name: teable-__INSTANCE_NAME__-redis\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: teable-__INSTANCE_NAME__-redis\nsubjects:\n  - kind: ServiceAccount\n    name: teable-__INSTANCE_NAME__-redis\n    namespace: ns-__NAMESPACE__\n\n---\napiVersion: objectstorage.sealos.io/v1\nkind: ObjectStorageBucket\nmetadata:\n  name: teable-__INSTANCE_NAME__-private\n  labels:\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  policy: private\n\n---\napiVersion: objectstorage.sealos.io/v1\nkind: ObjectStorageBucket\nmetadata:\n  name: teable-__INSTANCE_NAME__-public\n  labels:\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  policy: publicRead\n\n---\napiVersion: app.sealos.io/v1\nkind: App\nmetadata:\n  name: teable-__INSTANCE_NAME__\n  labels:\n    cloud.sealos.io/app-deploy-manager: teable-__INSTANCE_NAME__\n    cloud.sealos.io/deploy-on-sealos: teable-__INSTANCE_NAME__\nspec:\n  data:\n    url: https://__INSTANCE_DOMAIN__.sealoshzh.site\n  displayType: normal\n  icon: https://framerusercontent.com/images/x9gZmjwbtvaGd95qbfUmsZ8Jc.png\n  name: __DISPLAY_NAME__\n  type: link\n"
  },
  {
    "path": ".github/workflows/trigger-sync-to-ee.yml",
    "content": "name: Trigger Sync to EE\n\non:\n  push:\n    branches:\n      - develop\n\njobs:\n  check-and-trigger:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Get latest commit info\n        id: commit-info\n        run: |\n          COMMIT_MSG=$(git log -1 --format=\"%s\")\n          COMMIT_AUTHOR=$(git log -1 --format=\"%an\")\n          \n          echo \"message=$COMMIT_MSG\" >> $GITHUB_OUTPUT\n          echo \"author=$COMMIT_AUTHOR\" >> $GITHUB_OUTPUT\n\n      - name: Check if should trigger sync\n        id: check-trigger\n        run: |\n          COMMIT_MSG=\"${{ steps.commit-info.outputs.message }}\"\n          COMMIT_AUTHOR=\"${{ steps.commit-info.outputs.author }}\"\n          \n          # Skip if commit message contains [sync] marker or author is the sync bot\n          # This prevents circular sync loops\n          if [[ \"$COMMIT_MSG\" == *\"[sync]\"* ]] || [[ \"$COMMIT_AUTHOR\" == \"teable-bot\" ]]; then\n            echo \"trigger=false\" >> $GITHUB_OUTPUT\n            echo \"⏭️ Skipping trigger: commit is from sync workflow\"\n          else\n            echo \"trigger=true\" >> $GITHUB_OUTPUT\n            echo \"✅ Will trigger sync to EE\"\n          fi\n\n      - name: Trigger sync to EE\n        if: steps.check-trigger.outputs.trigger == 'true'\n        run: |\n          curl -X POST \\\n            -H \"Accept: application/vnd.github.v3+json\" \\\n            -H \"Authorization: token ${{ secrets.BOT_TOKEN }}\" \\\n            https://api.github.com/repos/teableio/teable-ee/dispatches \\\n            -d '{\"event_type\": \"sync-from-opensource\"}'\n          \n          echo \"✅ Triggered sync workflow in teable-ee\"\n\n      - name: Skipped\n        if: steps.check-trigger.outputs.trigger == 'false'\n        run: echo \"⏭️ Sync trigger skipped to avoid circular dependency\"\n\n"
  },
  {
    "path": ".github/workflows/unit-tests.yml",
    "content": "name: Unit Tests\n\non:\n  push:\n    branches:\n      - develop\n  pull_request:\n    branches:\n      - develop\n    paths:\n      - 'apps/nextjs-app/**'\n      - 'packages/core/**'\n      - 'packages/sdk/**'\n      - 'packages/openapi/**'\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\njobs:\n  test:\n    runs-on: ubuntu-latest\n    name: Unit Tests\n\n    strategy:\n      matrix:\n        node-version: [22.18.0]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n\n      - name: 🧩 Generate Prisma Client\n        working-directory: packages/db-main-prisma\n        run: |\n          pnpm -F @teable/db-main-prisma prisma-generate --schema ./prisma/postgres/schema.prisma\n\n      - name: 🏗 Run build\n        run: |\n          pnpm -F \"./packages/**\" run build\n\n      - name: 🧪 Run Tests\n        run: |\n          pnpm -F \"!@teable/backend\" -r --parralel test-unit\n"
  },
  {
    "path": ".github/workflows/v2-benchmark-tests.yml",
    "content": "name: V2 Benchmarks\n\non:\n  workflow_dispatch:\n  pull_request:\n    branches:\n      - develop\n    paths:\n      - 'packages/v2/**'\n      - '.github/workflows/v2-benchmark-tests.yml'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  bench:\n    runs-on: ubuntu-latest\n    name: V2 Benchmarks\n    env:\n      CI: 1\n      TESTCONTAINERS_REUSE_ENABLE: 'false'\n\n    strategy:\n      matrix:\n        node-version: [22.18.0]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n\n      - name: 🧪 Run v2 benchmarks\n        run: |\n          pnpm -C packages/v2/benchmark-node bench\n"
  },
  {
    "path": ".github/workflows/v2-core-tests.yml",
    "content": "name: V2 Tests\n\non:\n  pull_request:\n    branches:\n      - develop\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  # Unit tests - run each package in parallel\n  unit-tests:\n    runs-on: ubuntu-latest\n    name: V2 Unit Tests (${{ matrix.package }})\n    env:\n      CI: 1\n      TESTCONTAINERS_REUSE_ENABLE: 'false'\n\n    strategy:\n      fail-fast: false\n      max-parallel: 6\n      matrix:\n        package:\n          - '@teable/v2-adapter-db-postgres-pg'\n          - '@teable/v2-adapter-repository-postgres'\n          - '@teable/v2-adapter-table-repository-postgres'\n          - '@teable/v2-core'\n          - '@teable/v2-formula-sql-pg'\n          - '@teable/v2-test-node'\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js 22.18.0\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22.18.0\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n        with:\n          filter: ${{ matrix.package }}\n\n      - name: 🧪 Run unit tests (${{ matrix.package }})\n        run: |\n          pnpm -F \"${{ matrix.package }}\" --if-present test-unit-cover\n\n  # E2E tests - use sharding for parallel execution (the slowest tests)\n  e2e-tests:\n    runs-on: ubuntu-latest\n    name: V2 E2E Tests (Shard ${{ matrix.shard }}/4)\n    env:\n      CI: 1\n      TESTCONTAINERS_REUSE_ENABLE: 'false'\n\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1, 2, 3, 4]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js 22.18.0\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22.18.0\n\n      - name: 📥 Monorepo install\n        uses: ./.github/actions/pnpm-install\n        with:\n          filter: '@teable/v2-e2e'\n\n      - name: 🧪 Run E2E tests with coverage (shard ${{ matrix.shard }}/4)\n        run: |\n          pnpm -C packages/v2/e2e test-unit-cover -- --shard=${{ matrix.shard }}/4 --reporter=json --reporter=default --outputFile=e2e-report-${{ matrix.shard }}.json\n\n      - name: 📊 Upload test report\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: e2e-report-shard-${{ matrix.shard }}\n          path: packages/v2/e2e/e2e-report-${{ matrix.shard }}.json\n          retention-days: 7\n\n      - name: 📈 Upload coverage artifact\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: e2e-coverage-shard-${{ matrix.shard }}\n          path: packages/v2/e2e/coverage/\n          retention-days: 7\n\n  # Merge coverage from all e2e shards\n  e2e-coverage-merge:\n    needs: e2e-tests\n    runs-on: ubuntu-latest\n    name: V2 E2E Coverage Report\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js 22.18.0\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22.18.0\n\n      - name: 📥 Download all coverage artifacts\n        uses: actions/download-artifact@v4\n        with:\n          pattern: e2e-coverage-shard-*\n          path: coverage-parts\n          merge-multiple: false\n\n      - name: 📥 Install nyc for merging coverage\n        run: npm install -g nyc\n\n      - name: 📊 Merge coverage reports\n        run: |\n          mkdir -p merged-coverage\n          # Copy all lcov.info files to merged-coverage with unique names\n          for dir in coverage-parts/e2e-coverage-shard-*; do\n            shard=$(basename $dir | sed 's/e2e-coverage-shard-//')\n            if [ -f \"$dir/lcov.info\" ]; then\n              cp \"$dir/lcov.info\" \"merged-coverage/lcov-$shard.info\"\n            fi\n          done\n          # Merge lcov files using lcov command (available on ubuntu)\n          sudo apt-get install -y lcov\n          lcov -a merged-coverage/lcov-1.info \\\n               -a merged-coverage/lcov-2.info \\\n               -a merged-coverage/lcov-3.info \\\n               -a merged-coverage/lcov-4.info \\\n               -o merged-coverage/lcov.info || true\n\n      - name: 📈 Upload merged coverage to Coveralls\n        if: ${{ hashFiles('merged-coverage/lcov.info') != '' }}\n        uses: coverallsapp/github-action@v2\n        with:\n          file: merged-coverage/lcov.info\n          flag-name: v2-e2e\n          parallel: false\n          allow-empty: true\n          fail-on-error: false\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n# and https://github.com/github/gitignore for examples\n\n# local env files (followinf dotenv-flow / nextjs convention)\n\n.env.local\n.env.*.local\n\n# security: atlassian/changeset\n\n**/.netrc\n\n# dependencies\nnode_modules\n.pnpm-store/\n.pnp.*\n\n# testing\n/coverage\n.out/\n\n# Debug\n\n**/.debug\n\n# Build directories (next.js...)\n/.next/\n/out/\n/build\n/dist/\n\n# v2 packages build output\npackages/v2/**/dist/\n\n# Next.js auto-generated type definitions\n**/next-env.d.ts\n\n# Cache\n*.tsbuildinfo\n**/.eslintcache\n.cache/*\n.swc/\napps/playground/src/routeTree.gen.ts\n\n# Misc\n.DS_Store\n*.pem\n.worktrees/\n\n# Debug\nnpm-debug.log*\npnpm-debug.log*\n\n\n# IDE\n**/.idea/*\n!**/.idea/modules.xml\n!**/.idea/*.iml\n.project\n.classpath\n*.launch\n*.sublime-workspace\n\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n!.vscode/*.code-snippets\n\n# Docker overrides\n\n./docker-compose.override.yml\n\n# Deployment platforms\n\n.vercel\n\n# LocalStorage assets\n\n**/.assets\n"
  },
  {
    "path": ".gitpod.yml",
    "content": "tasks:\n  - init: pnpm install\n    command: make sqlite-mode && cd apps/nestjs-backend && pnpm dev\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "pnpm commitlint --edit $1"
  },
  {
    "path": ".husky/install.mjs",
    "content": "// Skip Husky install in production and CI\nif (process.env.NODE_ENV === 'production' || process.env.CI === 'true') {\n  process.exit(0);\n}\nconst husky = (await import('husky')).default;\nconsole.log(husky());\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm g:lint-staged-files --debug"
  },
  {
    "path": ".idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/packages/common-i18n/.idea/common-i18n.iml\" filepath=\"$PROJECT_DIR$/packages/common-i18n/.idea/common-i18n.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/core/.idea/core.iml\" filepath=\"$PROJECT_DIR$/packages/core/.idea/core.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/db-main-prisma/.idea/db-main-prisma.iml\" filepath=\"$PROJECT_DIR$/packages/db-main-prisma/.idea/db-main-prisma.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/eslint-config-bases/.idea/eslint-config-bases.iml\" filepath=\"$PROJECT_DIR$/packages/eslint-config-bases/.idea/eslint-config-bases.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/icons/.idea/icons.iml\" filepath=\"$PROJECT_DIR$/packages/icons/.idea/icons.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/apps/nestjs-backend/.idea/nestjs-backend.iml\" filepath=\"$PROJECT_DIR$/apps/nestjs-backend/.idea/nestjs-backend.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/apps/nextjs-app/.idea/nextjs-app.iml\" filepath=\"$PROJECT_DIR$/apps/nextjs-app/.idea/nextjs-app.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/openapi/.idea/openapi.iml\" filepath=\"$PROJECT_DIR$/packages/openapi/.idea/openapi.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/plugins/.idea/plugins.iml\" filepath=\"$PROJECT_DIR$/plugins/.idea/plugins.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/sdk/.idea/sdk.iml\" filepath=\"$PROJECT_DIR$/packages/sdk/.idea/sdk.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/teable.iml\" filepath=\"$PROJECT_DIR$/.idea/teable.iml\" />\n      <module fileurl=\"file://$PROJECT_DIR$/packages/ui-lib/.idea/ui-lib.iml\" filepath=\"$PROJECT_DIR$/packages/ui-lib/.idea/ui-lib.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": ".idea/teable.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n    <orderEntry type=\"module\" module-name=\"nestjs-backend\" />\n    <orderEntry type=\"module\" module-name=\"nextjs-app\" />\n    <orderEntry type=\"module\" module-name=\"plugins\" />\n    <orderEntry type=\"module\" module-name=\"common-i18n\" />\n    <orderEntry type=\"module\" module-name=\"core\" />\n    <orderEntry type=\"module\" module-name=\"db-main-prisma\" />\n    <orderEntry type=\"module\" module-name=\"eslint-config-bases\" />\n    <orderEntry type=\"module\" module-name=\"icons\" />\n    <orderEntry type=\"module\" module-name=\"openapi\" />\n    <orderEntry type=\"module\" module-name=\"sdk\" />\n    <orderEntry type=\"module\" module-name=\"ui-lib\" />\n  </component>\n</module>"
  },
  {
    "path": ".ncurc.yml",
    "content": "# npm-check-updates configuration used by yarn deps:check && yarn deps:update\n# convenience scripts.\n# @link https://github.com/raineorshine/npm-check-updates\n\n# Add here exclusions on packages if any\nreject: [\n    'vite-plugin-svgr',\n\n    # Too early cause in esm\n    'is-port-reachable',\n    'nanoid',\n    'node-fetch',\n  ]\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\nstrict-peer-dependencies=false\nauto-install-peers=true\nlockfile=true\n# force use npmjs.org registry\nregistry=https://registry.npmjs.org/\nuse-node-version=22.18.0\nsave-prefix=''\n"
  },
  {
    "path": ".prettierignore",
    "content": ".idea/\n.vscode/\npnpm-lock.yaml\n**/.next\n**/.out\n**/dist\n**/build\n**/.tmp\n**/.cache\napps/playground/src/routeTree.gen.ts\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "// @ts-check\n\nconst { getPrettierConfig } = require('@teable/eslint-config-bases/helpers');\n\nconst { overrides = [], ...prettierConfig } = getPrettierConfig();\n\n/**\n * @type {import('prettier').Config}\n */\nconst config = {\n  ...prettierConfig,\n  overrides: [\n    ...overrides,\n    ...[\n      {\n        files: '*.md',\n        options: {\n          singleQuote: false,\n          quoteProps: 'preserve',\n        },\n      },\n    ],\n  ],\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"bradlc.vscode-tailwindcss\"]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"vitest e2e nest backend\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/apps/nestjs-backend\",\n      \"runtimeExecutable\": \"sh\",\n      \"autoAttachChildProcesses\": true,\n      \"program\": \"./node_modules/.bin/vitest\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest-e2e.config.ts\",\n        \"--hideSkippedTests\"\n      ],\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"Debug vitest e2e nest backend\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/apps/nestjs-backend\",\n      \"runtimeExecutable\": \"node\",\n      \"program\": \"${workspaceFolder}/apps/nestjs-backend/node_modules/vitest/vitest.mjs\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest-e2e.config.ts\",\n        \"--hideSkippedTests\",\n        \"--no-file-parallelism\",\n        \"--reporter\",\n        \"verbose\"\n      ],\n      \"autoAttachChildProcesses\": true,\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"vitest nest backend\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/apps/nestjs-backend\",\n      \"runtimeExecutable\": \"sh\",\n      \"autoAttachChildProcesses\": true,\n      \"program\": \"./node_modules/.bin/vitest\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest.config.ts\"\n      ],\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"vitest next app\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/apps/nextjs-app\",\n      \"runtimeExecutable\": \"sh\",\n      \"autoAttachChildProcesses\": true,\n      \"program\": \"./node_modules/.bin/vitest\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest.config.ts\"\n      ],\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"vitest core\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/packages/core\",\n      \"runtimeExecutable\": \"sh\",\n      \"autoAttachChildProcesses\": true,\n      \"program\": \"./node_modules/.bin/vitest\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest.config.ts\"\n      ],\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"vitest sdk\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/packages/sdk\",\n      \"runtimeExecutable\": \"sh\",\n      \"autoAttachChildProcesses\": true,\n      \"program\": \"./node_modules/.bin/vitest\",\n      \"args\": [\n        \"run\",\n        \"${workspaceFolder}/${relativeFile}\",\n        \"--config\",\n        \"./vitest.config.ts\"\n      ],\n      \"smartStep\": true,\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"internalConsoleOptions\": \"neverOpen\"\n    },\n    {\n      \"name\": \"Debug nest backend\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"pnpm\",\n      \"args\": [\n        \"apps/nestjs-backend/src/index.ts\"\n      ],\n      \"runtimeArgs\": [\n        \"start-debug\"\n      ],\n      \"outFiles\": [\n        \"${workspaceFolder}/**/*.js\",\n        \"!**/node_modules/**\"\n      ],\n      \"cwd\": \"${workspaceFolder}/apps/nestjs-backend\",\n      \"internalConsoleOptions\": \"openOnSessionStart\",\n      \"sourceMaps\": true,\n      \"console\": \"internalConsole\",\n      \"outputCapture\": \"std\"\n    },\n  ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"cSpell.words\": [\n    \"antlr\",\n    \"AUTOINCREMENT\",\n    \"COUNTALL\",\n    \"DATETIME\",\n    \"gantt\",\n    \"ILIKE\",\n    \"Localstorage\",\n    \"minio\",\n    \"nextjs\",\n    \"nonstrict\",\n    \"OPENAI\",\n    \"openapi\",\n    \"shadcn\",\n    \"sharedb\",\n    \"signin\",\n    \"signout\",\n    \"sonarjs\",\n    \"sonner\",\n    \"teable\",\n    \"teableio\",\n    \"testid\",\n    \"topo\",\n    \"trgm\",\n    \"umami\",\n    \"univer\",\n    \"zustand\"\n  ],\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": \"explicit\"\n  },\n  \"eslint.format.enable\": true,\n  \"eslint.alwaysShowStatus\": true,\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    \"typescript\",\n    \"typescriptreact\"\n  ],\n  \"[javascript]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[javascriptreact]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[typescript]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"[typescriptreact]\": {\n    \"editor.formatOnSave\": false\n  },\n  \"eslint.workingDirectories\": [\n    {\n      \"pattern\": \"./apps/*/\"\n    },\n    {\n      \"pattern\": \"./packages/*/\"\n    },\n    {\n      \"pattern\": \"./packages/v2/*/\"\n    }\n  ],\n  \"vitest.maximumConfigs\": 50,\n  \"vitest.nodeEnv\": {\n    \"DOCKER_HOST\": \"unix:///Users/nichenqin/.colima/default/docker.sock\",\n    \"TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE\": \"/var/run/docker.sock\",\n    \"TESTCONTAINERS_HOST_OVERRIDE\": \"127.0.0.1\"\n  }\n}"
  },
  {
    "path": "AGPL_LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n\nAdditional Terms under GNU Affero General Public License Version 3 (AGPLv3)\n\nIn accordance with Section 7 of the GNU Affero General Public License Version 3,\nthe following additional terms apply to this software:\n\nBrand Protection (Under Section 7(e)):\n\nThe Teable brand assets (including but not limited to the Teable name, logo,\nicons, and visual identity elements) are protected intellectual property and\nare not covered by the AGPLv3 license. While the software code may be modified\nunder the terms of AGPL, any modification, replacement, or removal of these\nbrand assets is explicitly prohibited.\n\nSpecifically:\n1. You may not modify or replace the Teable brand assets\n2. You may not remove the Teable brand assets\n3. You may not use the brand assets in a way that suggests endorsement\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nsupport (at) teable.ai.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\n[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available\nat [https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThe base branch is **`develop`**.\n\n## Development Setup\n\n> **Note**\n> The following commands are for Linux/Mac environments. For Windows, please use WSL2.\n\n### 1. Initial Setup\n\n```bash\n# Enable the Package Manager\ncorepack enable\n\n# Install project dependencies\npnpm install\n```\n\n### 2. Database Selection\nWe support SQLite (dev only) and PostgreSQL. PostgreSQL is recommended and requires Docker installed.\n\n```bash\n# Switch between SQLite and PostgreSQL\nmake switch-db-mode\n```\n\n### 3. Environment Configuration (Optional)\n```bash\ncd apps/nextjs-app\ncp .env.development .env.development.local\n```\n\n### 4. Start Development Server\n```bash\ncd apps/nestjs-backend\npnpm dev\n```\nThis will automatically start both backend and frontend servers with hot reload enabled.\n\n## Continuous Development\n\nAfter pulling the latest code, ensure your development environment stays up-to-date:\n\n```bash\n# Update dependencies to latest versions\npnpm install\n\n# Update database schema to latest version\nmake switch-db-mode\n```\n\n### Known Issues\n\nPort conflict: In dev mode, code changes trigger hot reloading. If changes affect app/nestjs-backend (packages/core, packages/db-main-prisma), nodejs may restart, potentially causing port conflicts.\nIf backend code changes seem ineffective, check if the port is occupied with `lsof -i:3000`. If so, kill the old process with `kill -9 [pid]` and restart the application with `pnpm dev`.\n\nWebsocket: In development, Next.js occupies port 3000 for websocket to trigger hot reloading. To avoid conflicts, the application's websocket uses port 3001. That's why you see SOCKET_PORT=3001 in .env.development.local, while in production, port 3000 is used by default for websocket requests.\n\n## Database Migration Workflow\n\nTeable uses Prisma as ORM for database management. Follow these steps for schema changes:\n\n1. Modify `packages/db-main-prisma/prisma/template.prisma`\n\n2. Generate Prisma schemas:\n```bash\nmake gen-prisma-schema\n```\nThis generates both SQLite and PostgreSQL schemas and TypeScript definitions.\n\n3. Create migrations file:\n```bash\nmake db-migration\n```\n\n4. Apply migrations:\n```bash\nmake switch-db-mode\n```\n\n> **Note**\n> If you need to modify the schema after applying migrations, you need to delete the latest migration file and run `pnpm prisma-migrate-reset` in `packages/db-main-prisma` to reset the database. (Make sure you run it in the development database.)\n\n## Testing\n\n### E2E Tests\nLocated in `apps/nestjs-backend`:\n\n```bash\n# First-time setup\npnpm pre-test-e2e\n\n# Run all E2E tests\npnpm test-e2e\n\n# Run specific test file\npnpm test-e2e [test-file]\n```\n\n### Unit Tests\n```bash\n# Run all unit tests\npnpm g:test-unit\n\n# Run tests in specific package\ncd packages/[package-name]\npnpm test-unit\n\n# Run specific test file\npnpm test-unit [test-file]\n```\n\n### IDE Integration\nUsing VSCode/Cursor:\n1. For E2E tests in `apps/nestjs-backend`:\n   - Switch to test file (e.g. `apps/nestjs-backend/test/record.e2e-spec.ts`)\n   - Select \"vitest e2e nest backend\" in Debug panel\n\n2. For unit tests in different packages:\n   - For `packages/core`: \n     - Switch to test file (e.g. `packages/core/src/utils/date.spec.ts`)\n     - Select \"vitest core\" in Debug panel\n   - For other packages, select their corresponding debug configuration\n\nEach package has its own debug configuration in VSCode/Cursor, make sure to select the matching one for the package you're testing.\n\n## Git Commit Convention\n\nThis repo follows [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format.\n\n### Common Prefixes\n- **feat**: New feature\n- **fix**: Bug fix\n- **docs**: Documentation changes\n- **test**: Adding or modifying tests\n- **refactor**: Code changes that neither fix bugs nor add features\n- **style**: Changes to styling/CSS\n- **chore**: Changes to build process or tools\n\n> **Note**\n> Full configuration can be found in [commitlint.config.js](https://github.com/teableio/teable/blob/main/commitlint.config.js)\n\n## Docker Build\n\n### Building Images Locally\n- `teable`: The main application image\n\n#### Build the Application Image\n> **Note**\n> You should run this command in the root directory.\n\n```bash\n# Build the main application image\ndocker build -f dockers/teable/Dockerfile -t teable:latest .\n\n# Build for a specific platform (e.g., amd64)\ndocker build --platform linux/amd64 -f dockers/teable/Dockerfile -t teable:latest .\n```\n\n### Pushing to Docker Hub\n\n```bash\n# Tag your local image\ndocker tag teable:latest your-username/teable:latest\n\n# Login to Docker Hub\ndocker login\n\n# Push the image\ndocker push your-username/teable:latest\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2023-2025 Teable, Inc.\n\nTeable Project Licensing\n\nThis project is a combination of components under different licenses to balance open source principles with the ability to provide commercial services:\n\n1. Core Applications:\n   - apps/nestjs-backend\n   - apps/nextjs-app\n   These core applications are licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).\n   The full text of this license can be found at ./AGPL_LICENSE\n\n2. SDK and Utility Packages:\n   All packages under the 'packages' directory are licensed under the MIT License.\n   For the full text of the MIT License, please see the individual LICENSE files in each package directory.\n\nAs a whole, this project is primarily under the AGPL-3.0 license, with the exception of the packages in the 'packages' directory, which are available under the more permissive MIT License to facilitate wider adoption and integration.\n\nFor any questions regarding licensing, please contact support@teable.ai\n\nAdditional Terms under GNU Affero General Public License Version 3 (AGPLv3)\n\nIn accordance with Section 7 of the GNU Affero General Public License Version 3,\nthe following additional terms apply to this software:\n\nBrand Protection (Under Section 7(e)):\n\nThe Teable brand assets (including but not limited to the Teable name, logo,\nicons, and visual identity elements) are protected intellectual property and\nare not covered by the AGPLv3 license. While the software code may be modified\nunder the terms of AGPL, any modification, replacement, or removal of these\nbrand assets is explicitly prohibited.\n\nSpecifically:\n1. You may not modify or replace the Teable brand assets\n2. You may not remove the Teable brand assets\n3. You may not use the brand assets in a way that suggests endorsement\n"
  },
  {
    "path": "Makefile",
    "content": "SHELL := /usr/bin/env bash\n\n# define standard colors\nifneq (,$(findstring xterm,${TERM}))\n\tBLACK        := $(shell tput -Txterm setaf 0)\n\tRED          := $(shell tput -Txterm setaf 1)\n\tGREEN        := $(shell tput -Txterm setaf 2)\n\tYELLOW       := $(shell tput -Txterm setaf 3)\n\tLIGHTPURPLE  := $(shell tput -Txterm setaf 4)\n\tPURPLE       := $(shell tput -Txterm setaf 5)\n\tBLUE         := $(shell tput -Txterm setaf 6)\n\tWHITE        := $(shell tput -Txterm setaf 7)\n\tRESET := $(shell tput -Txterm sgr0)\nelse\n\tBLACK        := \"\"\n\tRED          := \"\"\n\tGREEN        := \"\"\n\tYELLOW       := \"\"\n\tLIGHTPURPLE  := \"\"\n\tPURPLE       := \"\"\n\tBLUE         := \"\"\n\tWHITE        := \"\"\n\tRESET        := \"\"\nendif\n\nENV_PATH ?= ./apps/nextjs-app\n\nDOCKER_COMPOSE ?= docker compose\n\nDOCKER_COMPOSE_ENV_FILE := $(wildcard ./dockers/.env)\nCOMPOSE_FILES := $(wildcard ./dockers/*.yml)\nCOMPOSE_FILE_ARGS := --env-file $(DOCKER_COMPOSE_ENV_FILE) $(foreach yml,$(COMPOSE_FILES),-f $(yml))\n\nNETWORK_MODE ?= teablenet\nCI_JOB_ID ?= 0\nCI ?= 0\n\n# Timeout used to await services to become healthy\nTIMEOUT ?= 300\n\nSCRATCH ?= /tmp\n\nUNAME_S := $(shell uname -s)\n\n# prisma database url defaults\nSQLITE_PRISMA_DATABASE_URL ?= file:../../db/main.db\n# set param statement_cache_size=1 to avoid query error `ERROR: cached plan must not change result type` after alter column type (modify field type)\nPOSTGES_PRISMA_DATABASE_URL ?= postgresql://teable:teable\\@127.0.0.1:5432/teable?schema=public\\&statement_cache_size=1\n\n# If the first make argument is \"start\", \"stop\"...\nifeq (docker.start,$(firstword $(MAKECMDGOALS)))\n    SERVICE_TARGET = true\nelse ifeq (docker.stop,$(firstword $(MAKECMDGOALS)))\n    SERVICE_TARGET = true\nelse ifeq (docker.restart,$(firstword $(MAKECMDGOALS)))\n    SERVICE_TARGET = true\nelse ifeq (docker.up,$(firstword $(MAKECMDGOALS)))\n    SERVICE_TARGET = true\nelse ifeq (docker.await,$(firstword $(MAKECMDGOALS)))\n    SERVICE_TARGET = true\nelse ifeq (docker.run,$(firstword $(MAKECMDGOALS)))\n    RUN_TARGET = true\nelse ifeq (docker.integration,$(firstword $(MAKECMDGOALS)))\n    INTEGRATION_TARGET = true\nendif\n\nifdef SERVICE_TARGET\n    # .. then use the rest as arguments for the make target\n    SERVICE := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))\n    # ...and turn them into do-nothing targets\n    $(eval $(SERVICE):;@:)\nelse ifdef RUN_TARGET\n    # Isolate second argument as service, the rest is arguments for run command\n    SERVICE := $(wordlist 2, 2, $(MAKECMDGOALS))\n    SERVICE_ARGS := $(wordlist 3, $(words $(MAKECMDGOALS)),$(MAKECMDGOALS))\nelse ifdef INTEGRATION_TARGET\n    # Isolate second argument as integration module, the rest as arguments\n    INTEGRATION_MODULE := $(wordlist 2, 2, $(MAKECMDGOALS))\n     $(eval $(INTEGRATION_MODULE):;@:)\n    INTEGRATION_ARGS := $(wordlist 3, $(words $(MAKECMDGOALS)),$(MAKECMDGOALS))\n    $(eval $(INTEGRATION_ARGS):;@:)\nendif\n\n#\n# Never use the network=host mode when running CI jobs, and add extra\n# distinguishing identifiers to the network name and container names to\n# prevent collisions with jobs from the same project running at the same\n# time.\n#\nifneq ($(CI_JOB_ID),)\n    NETWORK_MODE := teablenet-$(CI_JOB_ID)\nendif\n\nifeq ($(CI),0)\n    export NODE_ENV = development\nendif\n\n\nifeq ($(UNAME_S),Linux)\n\tDOCKER_GID ?= $(shell getent group docker | cut -d: -f 3)\nelse ifeq ($(UNAME_S),Darwin)\n\tDOCKER_GID ?= $(shell id -g)\nelse\n    $(error Sorry, '${UNAME_S}' is not supported yet)\nendif\n\n\nDOCKER_COMPOSE_ARGS := DOCKER_UID=$(shell id -u) \\\n\tDOCKER_GID=$(DOCKER_GID) \\\n    NETWORK_MODE=$(NETWORK_MODE)\n\n\ndefine print_db_mode_options\n@echo -e \"\\nSelect a database to start.\\n\"\n@echo -e \"1)sqlite\t\t\tLightweight embedded, ideal for mobile and embedded systems, simple, resource-efficient, easy integration (default database)\"\n@echo -e \"2)postges(pg)\t\t\tPowerful and scalable, suitable for complex enterprise needs, highly customizable, rich community support\\n\"\nendef\n\ndefine print_db_push_options\n@echo -e \"The 'db pull' command connects to your database and adds Prisma models to your Prisma schema that reflect the current database schema.\\n\"\n@echo -e \"1) sqlite\"\n@echo -e \"2) postges(pg)\\n\"\nendef\n\n.PHONY: db-mode sqlite.mode postgres.mode gen-prisma-schema gen-sqlite-prisma-schema gen-postgres-prisma-schema\n.DEFAULT_GOAL := help\n\ndocker.create.network:\nifneq ($(NETWORK_MODE),host)\n\t@docker network inspect $(NETWORK_MODE) &> /dev/null || ([ $$? -ne 0 ] && docker network create $(NETWORK_MODE))\n\t$(info ${GREEN}network $(NETWORK_MODE) create success${RESET})\nendif\n\ndocker.rm.network:\nifneq ($(NETWORK_MODE),host)\n\t@docker network inspect $(NETWORK_MODE) &> /dev/null && ([ $$? -eq 0 ] && docker network rm $(NETWORK_MODE)) || true\n\t$(warning ${GREEN}network $(NETWORK_MODE) removed${RESET})\nendif\n\n\ndocker.run: docker.create.network\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -T --no-deps --rm $(SERVICE) $(SERVICE_ARGS)\n\ndocker.up: docker.create.network\n\t@$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) up --no-recreate -d $(SERVICE)\n\ndocker.down: docker.rm.network\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) down\n\ndocker.start:\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) start $(SERVICE)\n\ndocker.stop:\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) stop $(SERVICE)\n\ndocker.restart:\n\tmake docker.stop $(SERVICE)\n\tmake docker.start $(SERVICE)\n\nTIME := 0\ndocker.await: ## max timeout of 300\n\t@time=$(TIME); \\\n\tfor i in $(SERVICE); do \\\n\t\tcurrent_service=$$($(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) ps -q $${i}); \\\n\t\tif [ -z \"$${current_service}\" ]; then \\\n\t\t\tcontinue; \\\n\t\tfi; \\\n\t\tservice_has_health=$$(docker inspect -f '{{.State.Health.Status}}' $${current_service}); \\\n\t\tif [ -z \"$${service_has_health}\" ]; then \\\n\t\t\tcontinue; \\\n\t\tfi; \\\n\t\twhile [ \"$$(docker inspect -f '{{.State.Health.Status}}' $${current_service})\" != \"healthy\" ] ; do \\\n\t\t\tsleep 1; \\\n\t\t\ttime=$$(expr $$time + 1); \\\n\t\t\tif [ $${time} -gt $(TIMEOUT) ]; then \\\n\t\t\t\techo \"${YELLOW}Timeout reached waiting for $${i} to become healthy${RESET}\"; \\\n\t\t\t\tdocker logs $${i}; \\\n\t\t\t\texit 1; \\\n\t\t\tfi; \\\n\t\tdone; \\\n\t\techo \"${GREEN}Service $${i} is healthy${RESET}\"; \\\n\tdone\n\ndocker.status:\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) ps\n\ndocker.images:\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) images\n\n\nbuild.app:\n\t@zx --version || pnpm add -g zx; \\\n  \tzx scripts/build-image.mjs --file=dockers/teable/Dockerfile \\\n\t\t  --tag=teable:develop\n\nbuild.db-migrate:\n\t@zx --version || pnpm add -g zx; \\\n  \tzx scripts/build-image.mjs --file=dockers/teable/Dockerfile.db-migrate \\\n\t\t  --tag=teable-db-migrate:develop\n\n\nsqlite.integration.test:\n\t@export PRISMA_DATABASE_URL='file:../../db/main.db'; \\\n\texport CALC_CHUNK_SIZE=400; \\\n\tmake sqlite.mode; \\\n\tpnpm -F \"./packages/**\" run build; \\\n\tpnpm g:test-e2e-cover\n\npostgres.integration.test: docker.create.network\n\t@TEST_PG_CONTAINER_NAME=teable-postgres-$(CI_JOB_ID); \\\n\tdocker rm -fv $$TEST_PG_CONTAINER_NAME | true; \\\n\t$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -p 25432:5432 -d -T --no-deps --rm --name $$TEST_PG_CONTAINER_NAME teable-postgres; \\\n\tchmod +x scripts/wait-for; \\\n\tscripts/wait-for 127.0.0.1:25432 --timeout=15 -- echo 'pg database started successfully' && \\\n\t\texport PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\\&statement_cache_size=1\\&connection_limit=20 && \\\n\t\tmake postgres.mode && \\\n\t\tpnpm -F \"./packages/**\" run build && \\\n\t\tpnpm g:test-e2e-cover && \\\n\t\tdocker rm -fv $$TEST_PG_CONTAINER_NAME\n\ngen-sqlite-prisma-schema:\n\t@cd ./packages/db-main-prisma; \\\n\t\techo '{ \"PRISMA_PROVIDER\": \"sqlite\" }' | pnpm mustache - ./prisma/template.prisma > ./prisma/sqlite/schema.prisma\n\t@echo 'generate【 prisma/sqlite/schema.prisma 】success.'\n\ngen-postgres-prisma-schema:\n\t@cd ./packages/db-main-prisma; \\\n\t\techo '{ \"PRISMA_PROVIDER\": \"postgres\" }' | pnpm mustache - ./prisma/template.prisma > ./prisma/postgres/schema.prisma\n\t@echo 'generate【 prisma/postgres/schema.prisma 】success.'\n\ngen-prisma-schema: gen-sqlite-prisma-schema gen-postgres-prisma-schema\t\t## Generate 'schema.prisma' files for all versions of the system\n\nsqlite-db.push:\t\t## db.push by sqlite\n\t@cd ./packages/db-main-prisma; \\\n\t\tpnpm prisma-db-push --schema ./prisma/sqlite/schema.prisma\n\npostgres-db.push:\t\t## db.push by postgres\n\t@cd ./packages/db-main-prisma; \\\n\t\tpnpm prisma-db-push --schema ./prisma/postgres/schema.prisma\n\ndb.push:\t\t## connects to your database and adds Prisma models to your Prisma schema that reflect the current database schema.\n\t$(print_db_push_options)\n\t@read -p \"Enter a command: \" command; \\\n    if [ \"$$command\" = \"1\" ] || [ \"$$command\" = \"sqlite\" ]; then \\\n      make gen-sqlite-prisma-schema; \\\n      make sqlite-db.push; \\\n    elif [ \"$$command\" = \"2\" ] || [ \"$$command\" = \"postges\" ] || [ \"$$command\" = \"pg\" ]; then \\\n      \tmake gen-postgres-prisma-schema; \\\n\t\tmake postgres-db.push; \\\n    else echo \"Unknown command.\";  fi\n\nsqlite-db-migration:\n\t@_MIGRATION_NAME=$(if $(_MIGRATION_NAME),$(_MIGRATION_NAME),`read -p \"Enter name of the migration (sqlite): \" migration_name; echo $$migration_name`); \\\n\tmake gen-sqlite-prisma-schema; \\\n\tPRISMA_DATABASE_URL=file:../../db/.shadow/main.db \\\n\tpnpm -F @teable/db-main-prisma prisma-migrate dev --schema ./prisma/sqlite/schema.prisma --name $$_MIGRATION_NAME\n\npostgres-db-migration:\n\t@_MIGRATION_NAME=$(if $(_MIGRATION_NAME),$(_MIGRATION_NAME),`read -p \"Enter name of the migration (postgres): \" migration_name; echo $$migration_name`); \\\n\tmake gen-postgres-prisma-schema; \\\n\tPRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:5432/teable?schema=shadow \\\n\tpnpm -F @teable/db-main-prisma prisma-migrate dev --schema ./prisma/postgres/schema.prisma --name $$_MIGRATION_NAME\n\ndb-migration:\t\t## Reruns the existing migration history in the shadow database in order to detect schema drift (edited or deleted migration file, or a manual changes to the database schema)\n\t@read -p \"Enter name of the migration: \" migration_name; \\\n  \tmake sqlite-db-migration _MIGRATION_NAME=$$migration_name; \\\n  \tmake postgres-db-migration _MIGRATION_NAME=$$migration_name\n\nsqlite.mode:\t\t## sqlite.mode\n\t@cd ./packages/db-main-prisma; \\\n\t\tpnpm prisma-generate --schema ./prisma/sqlite/schema.prisma; \\\n\t\tpnpm prisma-migrate deploy --schema ./prisma/sqlite/schema.prisma\n\npostgres.mode:\t\t## postgres.mode\n\t@cd ./packages/db-main-prisma; \\\n\t\tpnpm prisma-generate --schema ./prisma/postgres/schema.prisma; \\\n\t\tpnpm prisma-migrate deploy --schema ./prisma/postgres/schema.prisma\n# Override environment variable files based on variables\nRUN_DB_MODE ?= sqlite\nFILE_ENV_PATHS = $(ENV_PATH)/.env.development* $(ENV_PATH)/.env.test*\nswitch.prisma.env:\nifeq ($(CI)-$(RUN_DB_MODE),0-sqlite)\n\t@for file in $(FILE_ENV_PATHS); do \\\n\t\techo $$file; \\\n\t\tperl -i -pe 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(SQLITE_PRISMA_DATABASE_URL)~' $$file; \\\n\t\tif ! grep -q '^CALC_CHUNK_SIZE=' $$file; then \\\n\t\t\techo \"CALC_CHUNK_SIZE=400\" >> $$file; \\\n\t\telse \\\n\t\t\tperl -i -pe 's~^CALC_CHUNK_SIZE=.*~CALC_CHUNK_SIZE=400~' $$file; \\\n\t\tfi; \\\n\tdone\nelse ifeq ($(CI)-$(RUN_DB_MODE),0-postges)\n\t@for file in $(FILE_ENV_PATHS); do \\\n\t\techo $$file; \\\n\t\tperl -i -pe 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(POSTGES_PRISMA_DATABASE_URL)~' $$file; \\\n\tdone\nendif\n\nswitch-db-mode:\t\t## Switch Database environment\n\t$(print_db_mode_options)\n\t@read -p \"Enter a command: \" command; \\\n    if [ \"$$command\" = \"1\" ] || [ \"$$command\" = \"sqlite\" ]; then \\\n\t\tmake switch.prisma.env RUN_DB_MODE=sqlite; \\\n      \tmake sqlite.mode; \\\n    elif [ \"$$command\" = \"2\" ] || [ \"$$command\" = \"postges\" ] || [ \"$$command\" = \"pg\" ]; then \\\n      \tmake switch.prisma.env RUN_DB_MODE=postges; \\\n\t\tmake docker.up teable-postgres; \\\n    \tmake docker.await teable-postgres; \\\n    \tmake postgres.mode; \\\n    else \\\n      \techo \"Unknown command.\";  fi\n\nhelp:   ## show this help\n\t@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = \":.*?## \"}; {printf \"\\033[36m%-30s\\033[0m %s\\n\", $$1, $$2}'\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <h1 align=\"center\">\n    <picture>\n      <source media=\"(prefers-color-scheme: dark)\" srcset=\"static/assets/images/teable-vertical-dark.png\">\n      <img alt=\"teable logo\" height=\"150\" src=\"static/assets/images/teable-vertical-light.png\">\n    </picture>\n  </h1>\n  <h3 align=\"center\"><strong>Manage Your Data & Connect Your Team</strong></h3>\n  <p>Teable uses a simple, spreadsheet-like interface to create powerful database applications. Collaborate with your team in real-time, and scale to millions of rows\n  <p>Try out Teable using our hosted version at <a href=\"https://teable.ai\">teable.ai</a></p>\n</div>\n\n<div align=\"center\">\n<a href=\"https://trendshift.io/repositories/8516\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/8516\" alt=\"teableio%2Fteable | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</div>\n\n<p align=\"center\">\n  <a target=\"_blank\" href=\"https://teable.ai\">Home</a> | <a target=\"_blank\" href=\"https://help.teable.ai\">Help</a> | <a target=\"_blank\" href=\"https://teable.ai/blog\">Blog</a> | <a target=\"_blank\" href=\"https://teable.ai/templates\">Template</a> | <a target=\"_blank\" href=\"https://help.teable.ai/en/api-doc/token\">API</a> | <a target=\"_blank\" href=\"https://community.teable.ai\">Community</a> | <a target=\"_blank\" href=\"https://twitter.com/teableio\">Twitter</a>\n</p>\n\n<p align=\"center\">\n  <a aria-label=\"Build\" href=\"https://github.com/teableio/teable/actions?query=Build%20and%20Push%20to%20Docker%20Registry\">\n    <img alt=\"build\" src=\"https://img.shields.io/github/actions/workflow/status/teableio/teable/docker-push.yml?label=Build&logo=github&style=flat-quare&labelColor=000000\" />\n  </a>\n  <a aria-label=\"Coverage Status\" href=\"https://coveralls.io/github/teableio/teable?branch=develop\">\n    <img alt=\"Coverage\" src=\"https://coveralls.io/repos/github/teableio/teable/badge.svg?branch=develop\" />\n  </a>\n  <a aria-label=\"Codacy grade\" href=\"https://www.codacy.com/gh/teableio/teable/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=teableio/teable&amp;utm_campaign=Badge_Grade\">\n    <img alt=\"Codacy grade\" src=\"https://img.shields.io/codacy/grade/dff9c944af284a0fad4e165eb1727467?logo=codacy&style=flat-square&labelColor=000&label=Codacy\">\n  </a>\n  <a aria-label=\"Top language\" href=\"https://github.com/teableio/teable/search?l=typescript\">\n    <img alt=\"GitHub top language\" src=\"https://img.shields.io/github/languages/top/teableio/teable?style=flat-square&labelColor=000&color=blue\">\n  </a>\n  <a aria-label=\"Gurubase\" href=\"https://gurubase.io/g/teable\">\n    <img alt=\"Gurubase\" src=\"https://img.shields.io/badge/Gurubase-Ask%20Teable%20Guru-006BFF\" />\n  </a>\n</p>\n  <h1 align=\"center\">\n    <picture>\n      <source media=\"(prefers-color-scheme: dark)\" srcset=\"static/assets/images/teable-interface-dark.png\">\n      <img alt=\"teable interface\" width=\"100%\" src=\"static/assets/images/teable-interface-light.png\">\n    </picture>\n  </h1>\n\n## Quick Guide\n\n1. Looking for a quick experience? Select a scenario from the [template center](https://app.teable.ai/public/template) and click \"Use this template\".\n2. Seeking high performance? Try the [1 million rows demo](https://app.teable.ai/share/shrVgdLiOvNQABtW0yX/view) to feel the speed of Teable.\n3. Interested in deploying it yourself? Click [Deploy on Railway](https://railway.app/template/wada5e?referralCode=rE4BjB)\n\n## ✨Features\n\n### 🍺 Feature Packed\n\nEverything you need, right out of the box:\n\n- [x] Aggregation\n- [x] Attachments Preview\n- [x] Batch Editing\n- [x] Charts\n- [x] Comments\n- [x] Custom Columns\n- [x] Field Conversion\n- [x] Filtering\n- [x] Formatting\n- [x] Formula Support\n- [x] Grouping\n- [x] History\n- [x] Import/Export\n- [x] Millions of Rows\n- [x] Plugins\n- [x] Real-time\n- [x] Search\n- [x] Sorting\n- [x] SQL Query\n- [x] Undo/Redo\n- [x] Validation\n\n### 🏞️ Multiple Views\n\nVisualize and interact with data in various ways best suited for their specific tasks.\n\n- [x] Grid View\n- [x] Form View\n- [x] Kanban View\n- [x] Gallery View\n- [x] Calendar View\n\n<table align=\"center\" style=\"width: 100%;\">\n  <tr>\n    <td width=\"50%\"><img alt=\"Grid View\" src=\"static/assets/images/view-grid.png\"></td>\n    <td width=\"50%\"><img alt=\"Search\" src=\"static/assets/images/search.png\"></td>\n  </tr>\n  <tr>\n    <td width=\"50%\"><img alt=\"Calendar View\" src=\"static/assets/images/view-calendar.png\"></td>\n    <td width=\"50%\"><img alt=\"Gallery View\" src=\"static/assets/images/view-gallery.png\"></td>\n  </tr>\n  <tr>\n    <td width=\"50%\"><img alt=\"Kanban View\" src=\"static/assets/images/view-kanban.png\"></td>\n    <td width=\"50%\"><img alt=\"Form View\" src=\"static/assets/images/view-form.png\"></td>\n  </tr>\n  <tr>\n    <td width=\"50%\"><img alt=\"Comments\" src=\"static/assets/images/comments.png\"></td>\n    <td width=\"50%\"><img alt=\"Record history\" src=\"static/assets/images/record-history.png\"></td>\n  </tr>\n</table>\n\nMore features have been added. See our <a target=\"_blank\" href=\"https://help.teable.ai/en/changelog\">Changelog</a>.\n\n---\n\n# Structure\n\n[![Open in Gitpod](https://img.shields.io/badge/Open%20In-Gitpod.io-%231966D2?style=for-the-badge&logo=gitpod)](https://gitpod.io/#https://github.com/teableio/teable)\n\n```\n.\n├── apps (AGPL 3.0)\n│   ├── nextjs-app          (front-end)\n│   └── nestjs-backend      (backend)\n├── packages (MIT)\n│   ├── common-i18n         (locales)\n│   ├── core                (share code and interface)\n│   ├── sdk                 (sdk for extensions)\n│   ├── db-main-prisma      (schema, migrations, prisma client)\n│   ├── eslint-config-bases (to shared eslint configs)\n│   └── ui-lib              (ui component)\n└── plugins (AGPL 3.0)      (custom plugins)\n\n```\n\n## Deploy\n\n### Deploy With Docker\n\n```sh\ncd dockers/examples/standalone/\ndocker-compose up -d\n```\n\nfor more details, see [install teable](https://help.teable.ai/en/deploy/docker)\n\n### One Click Deployment\n\nThese platforms are easy to deploy with one click and come with free credits.\n\n[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/wada5e?referralCode=rE4BjB)\n\n[![Deploy on Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://template.sealos.io/deploy?templateName=teable)\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/QF8695)\n\n[![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=273)\n\n[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/teable)\n\n[![Deploy on AlibabaCloud ComputeNest](https://service-info-public.oss-cn-hangzhou.aliyuncs.com/computenest-en.svg)](https://computenest.console.aliyun.com/service/instance/create/default?ServiceName=Teable%20%E7%A4%BE%E5%8C%BA%E7%89%88)\n\n\n## Development\n\n#### 1. Initialize\n\n```sh\n# Enabling the Help Management Package Manager\ncorepack enable\n\n# Install project dependencies\npnpm install\n```\n\n#### 2. Select Database\n\nwe currently support `sqlite` (dev only) and `postgres`, you can switch between them by running the following command\n\n```sh\nmake switch-db-mode\n```\n\n#### 3. Custom Environment Variables（Optional）\n\n```sh\ncd apps/nextjs-app\ncp .env.development .env.development.local\n```\n\n#### 4. Run Dev Server\n\nyou just need to start backend, it will start next server for frontend automatically, file change will be auto reload\n\n```sh\ncd apps/nestjs-backend\npnpm dev\n```\n\nBy default, the plugin development server is not started. To preview and develop plugins, run:\n```sh\n# build packages\npnpm build:packages\n\n# start plugin development server\ncd plugins\npnpm dev\n```\nThis will start the plugin development server on port 3002.\n\n\n## Why Teable?\n\nNo-code tools have significantly speed up how we get things done, allowing non-tech users to build amazing apps and changing the way many work and live. People like using spreadsheet-like UI to handle their data because it's easy, flexible, and great for team collaboration. They also prefer designing their app screens without being stuck with clunky templates.\n\nGiving non-techy people the ability to create their software sounds exciting. But that's just the start:\n\n- As businesses expand, their data needs intensify. No one wishes to hear that once their orders reach 100k, they'll outgrow their current interface. Yet, many no-code platforms falter at such scales.\n- Most no-code platforms are cloud-based. This means your important data sits with the provider, and switching to another platform can be a headache.\n- Sometimes, no-code tools can't do what you want because of their limitations, leaving users stuck.\n- If a tool becomes essential, you'll eventually need some tech expertise. But developers often find these platforms tricky.\n- Maintaining systems with complex setups can be hard for developers, especially if these aren't built using common software standards.\n- Systems that don't use these standards might need revamping or replacing, costing more in the long run. It might even mean ditching the no-code route and going back to traditional coding.\n\n#### What We Think the Future Of No-code Products Look Like\n\n- An interface that anyone can use to build applications easily.\n- Easy access to data, letting users grab, move, and reuse their information as they wish.\n- Data privacy and choice, whether that's in the cloud, on-premise, or even just on your local.\n- It needs to work for developers too, not just non-tech users.\n- It should handle lots of data, so it can grow with your business.\n- Flexibility to integrate with other software, combining strengths to get the job done.\n- Last, native AI integration to takes usability to the next level.\n\nIn essence, Teable isn't just another no-code solution, it's a comprehensive answer to the evolving demands of modern software development, ensuring that everyone, regardless of their technical proficiency, has a platform tailored to their needs.\n\n# License\n\nTeable Community Edition (CE) is free for self-hosting under the AGPL license. See [./LICENSE](./LICENSE) for details.\n\nTeable Enterprise Edition (EE) includes advanced features such as AI, authority matrix, automation and advanced admin. For detailed information and pricing, please visit [pricing](https://app.teable.ai/public/pricing?host=self-hosted&billing=year).\n"
  },
  {
    "path": "agents.md",
    "content": "# Teable v2 agent guide\n\nDDD/domain-model guidance has moved to the skill `teable-ddd-domain-model` in `.codex/skills/teable-ddd-domain-model`. Use that skill for any v2/core domain, specification, or aggregate changes.\n\n## Git hygiene\n\n- Ignore git changes that you did not make by default; never revert unknown/unrelated modifications unless explicitly instructed.\n\n## v2 API contracts (HTTP)\n\nFor HTTP-ish integrations, keep framework-independent contracts/mappers in `packages/v2/contract-http`:\n\n- Define API paths (e.g. `/tables`) as constants.\n- Use action-style paths with camelCase action names (e.g. `/tables/create`, `/tables/get`, `/tables/rename`); avoid RESTful nested resources like `/bases/{baseId}/tables/{tableId}`.\n- Re-export command input schemas (zod) for route-level validation if needed.\n- Keep DTO types + domain-to-DTO mappers here.\n- Router packages (e.g. `@teable/v2-contract-http-express`, `@teable/v2-contract-http-fastify`) should be thin adapters that only:\n  - parse JSON/body\n  - create a container\n  - resolve handlers\n  - call the endpoint executor/mappers from `@teable/v2-contract-http`\n- OpenAPI is generated from the ts-rest contract via `@teable/v2-contract-http-openapi`.\n\n## UI components (frontend)\n\n- In app UIs (e.g. `apps/playground`), use shadcn wrappers from `apps/playground/src/components/ui/*` (or `@teable/ui-lib`) instead of importing Radix primitives directly.\n- If a shadcn wrapper is missing, add it under `apps/playground/src/components/ui` before using the primitive.\n\n## Dependency injection (DI)\n\n- Do not import `tsyringe` / `reflect-metadata` directly anywhere; use `@teable/v2-di`.\n- Do not use DI inside `v2/core/src/domain/**`; DI is only for application wiring (e.g. `v2/core/src/commands/**`).\n- Prefer constructor injection with explicit tokens for ports (interfaces).\n- Provide environment-level composition roots as separate packages (e.g. `@teable/v2-container-node`, `@teable/v2-container-browser`) that register all port implementations.\n\n## Build tooling (v2)\n\n- v2 packages build with `tsdown` (not `tsc` emit). `tsc` is used only for `typecheck` (`--noEmit`).\n- Each v2 package has a local `tsdown.config.ts` that extends the shared base config from `@teable/v2-tsdown-config`.\n- Outputs are written to `dist/` (ESM `.js` + `.d.ts`), and workspace deps (`@teable/v2-*`) are kept external (no bundling across packages).\n\n## Source visibility (v2 packages)\n\n**All v2 packages must support source visibility** to allow consumers to reference TypeScript sources without building `dist/` outputs. This is required for development workflows, testing, and tools like Vitest/Vite that can consume TypeScript directly.\n\n**Required configuration:**\n\n- In `package.json`:\n  - Set `types` field to `\"src/index.ts\"` (not `\"dist/index.d.ts\"`)\n  - Set `exports[\".\"].types` to `\"./src/index.ts\"` (not `\"./dist/index.d.ts\"`)\n  - Set `exports[\".\"].import` to `\"./src/index.ts\"` (not `\"./dist/index.js\"`) to allow Vite/Vitest to use source files directly\n  - Keep `exports[\".\"].require` pointing to `\"./dist/index.cjs\"` for CommonJS compatibility\n  - Include `\"src\"` in the `files` array (in addition to `\"dist\"`)\n- In `tsconfig.json`:\n  - Map workspace dependencies to their `src` paths in `compilerOptions.paths` (e.g. `\"@teable/v2-core\": [\"../core/src\"]`)\n  - Include those source paths in the `include` array\n\n**Example `package.json` configuration:**\n```json\n{\n  \"types\": \"src/index.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./src/index.ts\",\n      \"import\": \"./src/index.ts\",\n      \"require\": \"./dist/index.cjs\"\n    }\n  },\n  \"files\": [\"dist\", \"src\"]\n}\n```\n\n**Note:** Since v2 packages are workspace-only (`\"private\": true`) and not published to npm, pointing `import` to source files is safe. Vite/Vitest can process TypeScript files directly, enabling faster development cycles without requiring `dist/` to be built first.\n"
  },
  {
    "path": "apps/nestjs-backend/.eslintrc.js",
    "content": "/**\n * Specific eslint rules for this app/package, extends the base rules\n * @see https://github.com/teableio/teable/blob/main/docs/about-linters.md\n */\n\n// Workaround for https://github.com/eslint/eslint/issues/3458 (re-export of @rushstack/eslint-patch)\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns()],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    '@teable/eslint-config-bases/sonar',\n    '@teable/eslint-config-bases/regexp',\n    '@teable/eslint-config-bases/jest',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    {\n      files: ['src/event-emitter/events/**/*.event.ts'],\n      rules: {\n        '@typescript-eslint/naming-convention': 'off',\n      },\n    },\n    {\n      // Disable consistent-type-imports for files with decorators (NestJS controllers/services)\n      // See: https://typescript-eslint.io/blog/changes-to-consistent-type-imports-with-decorators\n      files: ['src/**/*.controller.ts'],\n      rules: {\n        '@typescript-eslint/consistent-type-imports': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/.gitignore",
    "content": "# build\nbuild\ndist\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n.assets\n.temporary\n.webpack-cache\n"
  },
  {
    "path": "apps/nestjs-backend/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/nestjs-backend.iml\" filepath=\"$PROJECT_DIR$/.idea/nestjs-backend.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "apps/nestjs-backend/.idea/nestjs-backend.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "apps/nestjs-backend/README.md",
    "content": "# NestJS backend for teable\n\nTODO:\nremove @valibot/to-json-schema in ai-sdk6\nremove effect in ai-sdk6\nremove @ai-sdk/provider-utils in ai-sdk6\n"
  },
  {
    "path": "apps/nestjs-backend/nest-cli.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/nest-cli\",\n  \"collection\": \"@nestjs/schematics\",\n  \"sourceRoot\": \"src\",\n  \"entryFile\": \"index\",\n  \"flat\": true,\n  \"compilerOptions\": {\n    \"builder\": \"webpack\"\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/package.json",
    "content": "{\n  \"name\": \"@teable/backend\",\n  \"version\": \"1.10.0\",\n  \"license\": \"AGPL-3.0\",\n  \"private\": true,\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": \"./dist\"\n  },\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"apps/nestjs-backend\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.3%\",\n      \"not ie 11\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"scripts\": {\n    \"build\": \"nest build\",\n    \"clean\": \"rimraf ./out ./coverage ./main ./dist ./tsconfig.tsbuildinfo ./node_modules/.cache .webpack-cache\",\n    \"dev\": \"nest start --webpackPath ./webpack.dev.js -w\",\n    \"dev:swc\": \"nest start --webpackPath ./webpack.swc.js -w\",\n    \"start\": \"nest start\",\n    \"check-dist\": \"es-check -v\",\n    \"start-debug\": \"nest start --webpackPath ./webpack.dev.js --debug -w\",\n    \"check-size\": \"size-limit --highlight-less\",\n    \"test\": \"run-s test-unit test-e2e\",\n    \"test-unit:watch\": \"vitest --watch\",\n    \"test-unit\": \"vitest run --silent --bail 1\",\n    \"test-unit-cover\": \"pnpm test-unit --coverage ${VITEST_SHARD:+--shard=$VITEST_SHARD}\",\n    \"pre-test-e2e\": \"cross-env NODE_ENV=test pnpm -F @teable/db-main-prisma prisma-db-seed -- --e2e\",\n    \"test-e2e\": \"pnpm pre-test-e2e && vitest run --config ./vitest-e2e.config.ts --silent\",\n    \"test-e2e-cover\": \"pnpm test-e2e --coverage --bail 1 ${VITEST_SHARD:+--shard=$VITEST_SHARD}\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\",\n    \"lint\": \"eslint . --ext .ts,.js,.cjs,.mjs,.mdx --cache --cache-location ../../.cache/eslint/nestjs-backend.eslintcache\",\n    \"fix-all-files\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs,.mdx --fix\",\n    \"flamegraph-home\": \"npx 0x --output-dir './.debug/flamegraph/{pid}.0x' --on-port 'autocannon http://localhost:$PORT --duration 20' -- node ../../node_modules/.bin/next start\",\n    \"merge-cover\": \"istanbul-merge --out ./coverage/nestjs-backend/coverage-final.json ./coverage/e2e/coverage-final.json ./coverage/unit/coverage-final.json\",\n    \"generate-cover\": \"nyc report --report-dir=coverage/nestjs-backend --temp-dir=coverage/nestjs-backend -r text -r html -r clover\"\n  },\n  \"devDependencies\": {\n    \"@faker-js/faker\": \"8.4.1\",\n    \"@nestjs/cli\": \"10.3.2\",\n    \"@nestjs/testing\": \"10.3.5\",\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/archiver\": \"6.0.3\",\n    \"@types/bcrypt\": \"5.0.2\",\n    \"@types/cookie\": \"0.6.0\",\n    \"@types/cookie-parser\": \"1.4.7\",\n    \"@types/cors\": \"2.8.17\",\n    \"@types/express\": \"4.17.21\",\n    \"@types/express-session\": \"1.18.0\",\n    \"@types/fs-extra\": \"11.0.4\",\n    \"@types/lodash\": \"4.17.0\",\n    \"@types/markdown-it\": \"13.0.7\",\n    \"@types/mime-types\": \"2.1.4\",\n    \"@types/ms\": \"0.7.34\",\n    \"@types/multer\": \"1.4.11\",\n    \"@types/node\": \"22.18.0\",\n    \"@types/node-fetch\": \"2.6.11\",\n    \"@types/nodemailer\": \"6.4.14\",\n    \"@types/oauth2orize\": \"1.11.5\",\n    \"@types/oauth2orize-pkce\": \"0.1.2\",\n    \"@types/papaparse\": \"5.3.14\",\n    \"@types/passport\": \"1.0.16\",\n    \"@types/passport-github2\": \"1.2.9\",\n    \"@types/passport-google-oauth20\": \"2.0.14\",\n    \"@types/passport-jwt\": \"4.0.1\",\n    \"@types/passport-local\": \"1.0.38\",\n    \"@types/passport-oauth2-client-password\": \"0.1.5\",\n    \"@types/passport-openidconnect\": \"0.1.3\",\n    \"@types/pause\": \"0.1.3\",\n    \"@types/pg\": \"8.16.0\",\n    \"@types/sharedb\": \"5.1.0\",\n    \"@types/sockjs\": \"0.3.36\",\n    \"@types/sockjs-client\": \"1.5.4\",\n    \"@types/stream-json\": \"1.7.8\",\n    \"@types/through2\": \"2.0.41\",\n    \"@types/unzipper\": \"0.10.11\",\n    \"@types/ws\": \"8.18.1\",\n    \"@vitest/coverage-v8\": \"4.0.17\",\n    \"copy-webpack-plugin\": \"12.0.2\",\n    \"cross-env\": \"7.0.3\",\n    \"dotenv-flow\": \"4.1.0\",\n    \"dotenv-flow-cli\": \"1.1.1\",\n    \"es-check\": \"7.1.1\",\n    \"eslint\": \"8.57.0\",\n    \"eslint-config-next\": \"15.5.9\",\n    \"get-tsconfig\": \"4.7.3\",\n    \"istanbul-merge\": \"2.0.0\",\n    \"npm-run-all2\": \"6.1.2\",\n    \"nyc\": \"15.1.0\",\n    \"pg-mem\": \"3.0.5\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"sockjs-client\": \"1.6.1\",\n    \"sql-formatter\": \"^15.3.1\",\n    \"swc-loader\": \"0.2.6\",\n    \"symlink-dir\": \"5.2.1\",\n    \"sync-directory\": \"6.0.5\",\n    \"ts-loader\": \"9.5.1\",\n    \"ts-node\": \"10.9.2\",\n    \"typescript\": \"5.4.3\",\n    \"unplugin-swc\": \"1.4.4\",\n    \"vite-tsconfig-paths\": \"4.3.2\",\n    \"vitest\": \"4.0.17\",\n    \"vitest-mock-extended\": \"2.0.2\",\n    \"webpack\": \"5.91.0\"\n  },\n  \"dependencies\": {\n    \"@ai-sdk/amazon-bedrock\": \"4.0.69\",\n    \"@ai-sdk/anthropic\": \"3.0.50\",\n    \"@ai-sdk/azure\": \"3.0.38\",\n    \"@ai-sdk/cohere\": \"3.0.22\",\n    \"@ai-sdk/deepseek\": \"2.0.21\",\n    \"@ai-sdk/google\": \"3.0.34\",\n    \"@ai-sdk/mistral\": \"3.0.21\",\n    \"@ai-sdk/openai\": \"3.0.37\",\n    \"@ai-sdk/openai-compatible\": \"2.0.31\",\n    \"@ai-sdk/togetherai\": \"2.0.35\",\n    \"@ai-sdk/xai\": \"3.0.60\",\n    \"@an-epiphany/websocket-json-stream\": \"1.2.0\",\n    \"@aws-sdk/client-s3\": \"3.609.0\",\n    \"@aws-sdk/lib-storage\": \"3.609.0\",\n    \"@aws-sdk/s3-request-presigner\": \"3.609.0\",\n    \"@keyv/redis\": \"2.8.4\",\n    \"@keyv/sqlite\": \"3.6.7\",\n    \"@nestjs-modules/mailer\": \"1.11.2\",\n    \"@nestjs/axios\": \"3.0.2\",\n    \"@nestjs/bullmq\": \"11.0.4\",\n    \"@nestjs/common\": \"10.3.5\",\n    \"@nestjs/config\": \"3.2.1\",\n    \"@nestjs/core\": \"10.3.5\",\n    \"@nestjs/event-emitter\": \"2.0.4\",\n    \"@nestjs/jwt\": \"10.2.0\",\n    \"@nestjs/passport\": \"10.0.3\",\n    \"@nestjs/platform-express\": \"10.3.5\",\n    \"@nestjs/platform-ws\": \"10.3.5\",\n    \"@nestjs/swagger\": \"7.3.0\",\n    \"@nestjs/terminus\": \"10.2.3\",\n    \"@nestjs/websockets\": \"10.3.5\",\n    \"@openrouter/ai-sdk-provider\": \"2.2.3\",\n    \"@opentelemetry/api\": \"1.9.0\",\n    \"@opentelemetry/context-async-hooks\": \"2.5.0\",\n    \"@opentelemetry/exporter-logs-otlp-http\": \"0.201.1\",\n    \"@opentelemetry/exporter-metrics-otlp-http\": \"0.201.1\",\n    \"@opentelemetry/exporter-trace-otlp-http\": \"0.201.1\",\n    \"@opentelemetry/instrumentation-express\": \"0.50.0\",\n    \"@opentelemetry/instrumentation-http\": \"0.201.1\",\n    \"@opentelemetry/instrumentation-ioredis\": \"0.49.0\",\n    \"@opentelemetry/instrumentation-nestjs-core\": \"0.49.0\",\n    \"@opentelemetry/instrumentation-pg\": \"0.49.0\",\n    \"@opentelemetry/instrumentation-pino\": \"0.49.0\",\n    \"@opentelemetry/instrumentation-runtime-node\": \"0.24.0\",\n    \"@opentelemetry/resources\": \"2.0.1\",\n    \"@opentelemetry/sdk-node\": \"0.201.1\",\n    \"@opentelemetry/sdk-trace-base\": \"2.0.1\",\n    \"@opentelemetry/semantic-conventions\": \"1.34.0\",\n    \"@orpc/nest\": \"1.13.0\",\n    \"@prisma/client\": \"6.2.1\",\n    \"@prisma/instrumentation\": \"6.2.1\",\n    \"@sentry/nestjs\": \"10.22.0\",\n    \"@sentry/opentelemetry\": \"10.22.0\",\n    \"@sentry/profiling-node\": \"10.22.0\",\n    \"@smithy/node-http-handler\": \"^3.1.1\",\n    \"@teable/common-i18n\": \"workspace:^\",\n    \"@teable/core\": \"workspace:^\",\n    \"@teable/db-main-prisma\": \"workspace:^\",\n    \"@teable/openapi\": \"workspace:^\",\n    \"@teable/v2-adapter-db-postgres-pg\": \"workspace:*\",\n    \"@teable/v2-adapter-undo-redo-keyv\": \"workspace:*\",\n    \"@teable/v2-adapter-realtime-sharedb\": \"workspace:*\",\n    \"@teable/v2-container-node\": \"workspace:*\",\n    \"@teable/v2-contract-http\": \"workspace:*\",\n    \"@teable/v2-contract-http-implementation\": \"workspace:*\",\n    \"@teable/v2-contract-http-openapi\": \"workspace:*\",\n    \"@teable/v2-core\": \"workspace:*\",\n    \"@teable/v2-di\": \"workspace:*\",\n    \"@teable/v2-import\": \"workspace:*\",\n    \"@valibot/to-json-schema\": \"1.3.0\",\n    \"ai\": \"6.0.105\",\n    \"ajv\": \"8.12.0\",\n    \"archiver\": \"7.0.1\",\n    \"axios\": \"1.7.7\",\n    \"bcrypt\": \"5.1.1\",\n    \"bullmq\": \"5.66.5\",\n    \"class-transformer\": \"0.5.1\",\n    \"class-validator\": \"0.14.1\",\n    \"cookie\": \"0.6.0\",\n    \"cookie-parser\": \"1.4.6\",\n    \"cors\": \"2.8.5\",\n    \"csv-parser\": \"3.2.0\",\n    \"csv-stringify\": \"6.5.2\",\n    \"date-fns-tz\": \"3.2.0\",\n    \"dayjs\": \"1.11.10\",\n    \"effect\": \"3.19.1\",\n    \"esbuild\": \"0.23.0\",\n    \"express\": \"4.21.1\",\n    \"express-session\": \"1.18.0\",\n    \"fs-extra\": \"11.2.0\",\n    \"handlebars\": \"4.7.8\",\n    \"helmet\": \"7.1.0\",\n    \"http-proxy-middleware\": \"3.0.3\",\n    \"ioredis\": \"5.9.1\",\n    \"is-port-reachable\": \"3.1.0\",\n    \"joi\": \"17.12.2\",\n    \"jschardet\": \"3.1.3\",\n    \"keyv\": \"4.5.4\",\n    \"knex\": \"3.1.0\",\n    \"lodash\": \"4.17.21\",\n    \"mime-types\": \"2.1.35\",\n    \"minio\": \"7.1.3\",\n    \"ms\": \"2.1.3\",\n    \"multer\": \"1.4.5-lts.1\",\n    \"nanoid\": \"3.3.7\",\n    \"nest-knexjs\": \"0.0.22\",\n    \"nestjs-cls\": \"4.3.0\",\n    \"nestjs-i18n\": \"10.5.1\",\n    \"nestjs-pino\": \"4.4.1\",\n    \"nestjs-redoc\": \"2.2.2\",\n    \"next\": \"16.1.6\",\n    \"node-fetch\": \"2.7.0\",\n    \"node-sql-parser\": \"5.3.8\",\n    \"nodemailer\": \"6.9.13\",\n    \"oauth2orize\": \"1.12.0\",\n    \"oauth2orize-pkce\": \"0.1.2\",\n    \"object-sizeof\": \"2.6.4\",\n    \"ollama-ai-provider-v2\": \"3.0.2\",\n    \"papaparse\": \"5.4.1\",\n    \"passport\": \"0.7.0\",\n    \"passport-github2\": \"0.1.12\",\n    \"passport-google-oauth20\": \"2.0.0\",\n    \"passport-jwt\": \"4.0.1\",\n    \"passport-local\": \"1.0.0\",\n    \"passport-oauth2-client-password\": \"0.1.2\",\n    \"passport-openidconnect\": \"0.1.2\",\n    \"pause\": \"0.1.0\",\n    \"pg\": \"8.11.5\",\n    \"pino-http\": \"10.5.0\",\n    \"pino-pretty\": \"11.0.0\",\n    \"react\": \"18.3.1\",\n    \"react-dom\": \"18.3.1\",\n    \"redlock\": \"5.0.0-beta.2\",\n    \"reflect-metadata\": \"0.2.1\",\n    \"rxjs\": \"7.8.1\",\n    \"sharedb\": \"5.2.2\",\n    \"sharp\": \"0.33.3\",\n    \"sockjs\": \"0.3.24\",\n    \"stream-json\": \"1.9.1\",\n    \"through2\": \"4.0.2\",\n    \"transliteration\": \"2.3.5\",\n    \"ts-pattern\": \"5.0.8\",\n    \"unzipper\": \"0.12.3\",\n    \"ws\": \"8.18.3\",\n    \"xlsx\": \"https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz\",\n    \"zod\": \"4.1.8\",\n    \"zod-validation-error\": \"4.0.2\"\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/app.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { BullModule } from '@nestjs/bullmq';\nimport type { ModuleMetadata } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport { ConditionalModule, ConfigService } from '@nestjs/config';\nimport { SentryModule } from '@sentry/nestjs/setup';\nimport Redis from 'ioredis';\nimport type { ICacheConfig } from './configs/cache.config';\nimport { ConfigModule } from './configs/config.module';\nimport { AccessTokenModule } from './features/access-token/access-token.module';\nimport { AggregationOpenApiModule } from './features/aggregation/open-api/aggregation-open-api.module';\nimport { AiModule } from './features/ai/ai.module';\nimport { AttachmentsModule } from './features/attachments/attachments.module';\nimport { AuthModule } from './features/auth/auth.module';\nimport { BaseModule } from './features/base/base.module';\nimport { BaseNodeModule } from './features/base-node/base-node.module';\nimport { BuiltinAssetsInitModule } from './features/builtin-assets-init';\nimport { CanaryModule } from './features/canary';\nimport { ChatModule } from './features/chat/chat.module';\nimport { CollaboratorModule } from './features/collaborator/collaborator.module';\nimport { CommentOpenApiModule } from './features/comment/comment-open-api.module';\nimport { DashboardModule } from './features/dashboard/dashboard.module';\nimport { ExportOpenApiModule } from './features/export/open-api/export-open-api.module';\nimport { FieldOpenApiModule } from './features/field/open-api/field-open-api.module';\nimport { HealthModule } from './features/health/health.module';\nimport { ImportOpenApiModule } from './features/import/open-api/import-open-api.module';\nimport { IntegrityModule } from './features/integrity/integrity.module';\nimport { InvitationModule } from './features/invitation/invitation.module';\nimport { MailSenderOpenApiModule } from './features/mail-sender/open-api/mail-sender-open-api.module';\nimport { MailSenderMergeModule } from './features/mail-sender/open-api/mail-sender.merge.module';\nimport { NextModule } from './features/next/next.module';\nimport { NotificationModule } from './features/notification/notification.module';\nimport { OAuthModule } from './features/oauth/oauth.module';\nimport { OrganizationModule } from './features/organization/organization.module';\nimport { PinModule } from './features/pin/pin.module';\nimport { PluginChartModule } from './features/plugin/official/chart/plugin-chart.module';\nimport { PluginModule } from './features/plugin/plugin.module';\nimport { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module';\nimport { PluginPanelModule } from './features/plugin-panel/plugin-panel.module';\nimport { SelectionModule } from './features/selection/selection.module';\nimport { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';\nimport { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';\nimport { BaseShareModule } from './features/base-share/base-share.module';\nimport { ShareModule } from './features/share/share.module';\nimport { SpaceModule } from './features/space/space.module';\nimport { TemplateOpenApiModule } from './features/template/template-open-api.module';\nimport { TrashModule } from './features/trash/trash.module';\nimport { UndoRedoModule } from './features/undo-redo/open-api/undo-redo.module';\nimport { UserModule } from './features/user/user.module';\nimport { V2Module } from './features/v2/v2.module';\nimport { GlobalModule } from './global/global.module';\nimport { InitBootstrapProvider } from './global/init-bootstrap.provider';\nimport { LoggerModule } from './logger/logger.module';\nimport { ObservabilityModule } from './observability/observability.module';\nimport { WsModule } from './ws/ws.module';\n\n// In CI or test environments, use a longer timeout for ConditionalModule\n// to avoid sporadic timeout errors when resources are under pressure\nconst isTestOrCI = process.env.CI || process.env.NODE_ENV === 'test' || process.env.VITEST;\nconst CONDITIONAL_MODULE_TIMEOUT = isTestOrCI ? 60000 : 5000;\n\nexport const appModules = {\n  imports: [\n    SentryModule.forRoot(),\n    LoggerModule.register(),\n    MailSenderOpenApiModule,\n    MailSenderMergeModule,\n    HealthModule,\n    NextModule,\n    FieldOpenApiModule,\n    TemplateOpenApiModule,\n    BaseModule,\n    BaseNodeModule,\n    IntegrityModule,\n    ChatModule,\n    AttachmentsModule,\n    WsModule,\n    SelectionModule,\n    UndoRedoModule,\n    AggregationOpenApiModule,\n    UserModule,\n    AuthModule,\n    SpaceModule,\n    CollaboratorModule,\n    InvitationModule,\n    ShareModule,\n    BaseShareModule,\n    NotificationModule,\n    AccessTokenModule,\n    ImportOpenApiModule,\n    ExportOpenApiModule,\n    PinModule,\n    AdminOpenApiModule,\n    CanaryModule,\n    SettingOpenApiModule,\n    OAuthModule,\n    TrashModule,\n    DashboardModule,\n    CommentOpenApiModule,\n    OrganizationModule,\n    AiModule,\n    PluginModule,\n    PluginPanelModule,\n    PluginContextMenuModule,\n    PluginChartModule,\n    ObservabilityModule,\n    BuiltinAssetsInitModule,\n    V2Module,\n  ],\n  providers: [InitBootstrapProvider],\n};\n\n@Module({\n  ...appModules,\n  imports: [\n    GlobalModule,\n    ...appModules.imports,\n    ConditionalModule.registerWhen(\n      BullModule.forRootAsync({\n        imports: [ConfigModule],\n        useFactory: async (configService: ConfigService) => {\n          const redisUri = configService.get<ICacheConfig>('cache')?.redis.uri;\n          if (!redisUri) {\n            throw new Error('Redis URI is not defined');\n          }\n          const redis = new Redis(redisUri, { lazyConnect: true, maxRetriesPerRequest: null });\n          await redis.connect();\n\n          return {\n            connection: redis,\n          };\n        },\n        inject: [ConfigService],\n      }),\n      (env) => {\n        return Boolean(env.BACKEND_CACHE_REDIS_URI);\n      },\n      { timeout: CONDITIONAL_MODULE_TIMEOUT }\n    ),\n  ],\n  controllers: [],\n})\nexport class AppModule {\n  static register(customModuleMetadata: ModuleMetadata) {\n    return {\n      module: AppModule,\n      ...customModuleMetadata,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/bootstrap.ts",
    "content": "import 'dayjs/plugin/timezone';\nimport 'dayjs/plugin/utc';\nimport type { INestApplication } from '@nestjs/common';\nimport { ValidationPipe } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { NestFactory } from '@nestjs/core';\nimport { json, urlencoded } from 'express';\nimport helmet from 'helmet';\nimport isPortReachable from 'is-port-reachable';\nimport { Logger } from 'nestjs-pino';\nimport { AppModule } from './app.module';\nimport type { IBaseConfig } from './configs/base.config';\nimport type { ISecurityWebConfig, IApiDocConfig } from './configs/bootstrap.config';\nimport { GlobalExceptionFilter } from './filter/global-exception.filter';\nimport { setupSwagger } from './swagger';\n\nconst host = 'localhost';\n\nexport async function setUpAppMiddleware(app: INestApplication, configService: ConfigService) {\n  app.useGlobalFilters(new GlobalExceptionFilter(configService));\n  app.useGlobalPipes(\n    new ValidationPipe({ transform: true, stopAtFirstError: true, forbidUnknownValues: false })\n  );\n  // HSTS is configured at the WAF level. Disable it here to avoid sending duplicate\n  // `Strict-Transport-Security` headers with potentially different max-age values.\n  app.use(helmet({ hsts: false }));\n  app.use(json({ limit: '50mb' }));\n  app.use(urlencoded({ limit: '50mb', extended: true }));\n\n  const apiDocConfig = configService.get<IApiDocConfig>('apiDoc');\n  const securityWebConfig = configService.get<ISecurityWebConfig>('security.web');\n  const baseConfig = configService.get<IBaseConfig>('base');\n  if (!apiDocConfig?.disabled) {\n    await setupSwagger(app, baseConfig?.publicOrigin ?? '', apiDocConfig?.enabledSnippet ?? false);\n  }\n\n  if (securityWebConfig?.cors.enabled) {\n    app.enableCors();\n  }\n}\n\nexport async function bootstrap() {\n  const app = await NestFactory.create(AppModule, { bufferLogs: true });\n  const configService = app.get(ConfigService);\n\n  const logger = app.get(Logger);\n  app.useLogger(logger);\n  app.flushLogs();\n\n  app.enableShutdownHooks();\n\n  await setUpAppMiddleware(app, configService);\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any\n  // app.getHttpServer().on('upgrade', async function (req: any, socket: any, head: any) {\n  //   if (req.url.startsWith('/_next')) {\n  //     console.log('upgrade: ', req.url);\n  //     const server = app.get(NextService).server;\n  //     return server.getUpgradeHandler()(req, socket, head);\n  //   }\n  // });\n\n  const port = await getAvailablePort(configService.get<string>('PORT') as string);\n  process.env.PORT = port.toString();\n\n  await app.listen(port);\n\n  const now = new Date();\n  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n  logger.log(`> NODE_ENV is ${process.env.NODE_ENV}`);\n  logger.log(`> Ready on http://${host}:${port}`);\n  logger.log(`> System Time Zone: ${timeZone}`);\n  logger.log(`> Current System Time: ${now.toString()}`);\n\n  process.on('unhandledRejection', (reason: string, promise: Promise<unknown>) => {\n    logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);\n    throw reason;\n  });\n\n  process.on('uncaughtException', (error) => {\n    logger.error(error);\n  });\n  return app;\n}\n\nasync function getAvailablePort(dPort: number | string): Promise<number> {\n  let port = Number(dPort);\n  while (await isPortReachable(port, { host })) {\n    console.log(`> Fail on http://${host}:${port} Trying on ${port + 1}`);\n    port++;\n  }\n  return port;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/cache/cache.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { ConfigurableModuleBuilder, type DynamicModule, Module } from '@nestjs/common';\nimport { CacheProvider } from './cache.provider';\n\nexport interface CacheModuleOptions {\n  global?: boolean;\n}\n\nexport const { ConfigurableModuleClass: CacheModuleClass, OPTIONS_TYPE } =\n  new ConfigurableModuleBuilder<CacheModuleOptions>().build();\n\n@Module({\n  providers: [CacheProvider],\n  exports: [CacheProvider],\n})\nexport class CacheModule extends CacheModuleClass {\n  static register(options: typeof OPTIONS_TYPE): DynamicModule {\n    return {\n      global: options.global,\n      ...super.register(options),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/cache/cache.provider.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport path from 'path';\nimport KeyvRedis from '@keyv/redis';\nimport KeyvSqlite from '@keyv/sqlite';\nimport type { Provider } from '@nestjs/common';\nimport { Logger } from '@nestjs/common';\nimport * as fse from 'fs-extra';\nimport Keyv from 'keyv';\nimport { match } from 'ts-pattern';\nimport type { ICacheConfig } from '../configs/cache.config';\nimport { cacheConfig } from '../configs/cache.config';\nimport { CacheService } from './cache.service';\n\nexport const CacheProvider: Provider = {\n  provide: CacheService,\n  inject: [cacheConfig.KEY],\n  useFactory: async (config: ICacheConfig) => {\n    const { provider, sqlite, redis } = config;\n\n    Logger.log(`[Cache Manager Adapter]: ${provider}`);\n\n    const store = match(provider)\n      .with('memory', () => new Map())\n      .with('sqlite', () => {\n        const uri = sqlite.uri.replace(/^sqlite:\\/\\//, '');\n        fse.ensureFileSync(uri);\n\n        Logger.log(`[Cache Manager File Path]: ${path.resolve(uri)}`);\n\n        return new KeyvSqlite({\n          ...sqlite,\n          uri,\n        });\n      })\n      .with('redis', () => new KeyvRedis(redis, { useRedisSets: false }))\n      .exhaustive();\n\n    const keyv = new Keyv({ namespace: 'teable_cache', store: store });\n    keyv.on('error', (error) => {\n      error && Logger.error(error, 'Cache Manager Connection Error');\n    });\n\n    Logger.log(`[Cache Manager Namespace]: ${keyv.opts.namespace}`);\n    return new CacheService(keyv);\n  },\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/cache/cache.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { getRandomInt } from '@teable/core';\nimport type { Redis } from 'ioredis';\nimport Keyv from 'keyv';\nimport { second } from '../utils/second';\nimport type { ICacheStore } from './types';\n\n@Injectable()\nexport class CacheService<T extends ICacheStore = ICacheStore> {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  constructor(private readonly cacheManager: Keyv<any>) {}\n  private readonly logger = new Logger(CacheService.name);\n\n  getKeyv(): Keyv<any> {\n    return this.cacheManager;\n  }\n\n  /**\n   * Get the underlying Redis client if available\n   * Returns undefined if not using Redis\n   */\n  private getRedisClient(): Redis | undefined {\n    try {\n      // KeyvRedis stores the Redis client in store.redis\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const store = this.cacheManager.opts?.store as any;\n      return store?.redis || store?.client;\n    } catch {\n      return undefined;\n    }\n  }\n\n  /**\n   * Atomic set-if-not-exists operation (Redis SETNX with EX)\n   * Returns true if the key was set, false if it already existed\n   * @param key - The key to set\n   * @param value - The value to set\n   * @param ttlSeconds - TTL in seconds\n   */\n  async setnx<TKey extends keyof T>(\n    key: TKey,\n    value: T[TKey],\n    ttlSeconds: number\n  ): Promise<boolean> {\n    const redis = this.getRedisClient();\n    if (!redis) {\n      // Fallback for non-Redis: not truly atomic, but better than nothing\n      const existing = await this.get(key);\n      if (existing !== undefined) {\n        return false;\n      }\n      await this.setDetail(key, value, ttlSeconds);\n      return true;\n    }\n\n    // Use Redis SET with NX and EX for atomic operation\n    const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;\n    const serializedValue = JSON.stringify(value);\n    const result = await redis.set(fullKey, serializedValue, 'EX', ttlSeconds, 'NX');\n    return result === 'OK';\n  }\n\n  /**\n   * Atomic increment operation (Redis INCR with optional EX)\n   * Returns the new value after increment\n   * @param key - The key to increment\n   * @param ttlSeconds - Optional TTL in seconds (only set on first increment)\n   */\n  async incr<TKey extends keyof T>(key: TKey, ttlSeconds?: number): Promise<number> {\n    const redis = this.getRedisClient();\n    if (!redis) {\n      // Fallback for non-Redis: not truly atomic\n      const current = (await this.get(key)) as number | undefined;\n      const newValue = (current || 0) + 1;\n      await this.setDetail(key, newValue as T[TKey], ttlSeconds);\n      return newValue;\n    }\n\n    const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;\n    const newValue = await redis.incr(fullKey);\n\n    // Set TTL only if provided and this is the first increment (value is 1)\n    if (ttlSeconds && newValue === 1) {\n      await redis.expire(fullKey, ttlSeconds);\n    }\n\n    return newValue;\n  }\n\n  private warnNotSetTTL(key: string, ttl?: number) {\n    if (!ttl || Number.isNaN(ttl) || ttl <= 0) {\n      this.logger.warn(`[Cache Service] Not set ttl for key: ${key}`);\n    }\n  }\n\n  async get<TKey extends keyof T>(key: TKey): Promise<T[TKey] | undefined> {\n    return this.cacheManager.get(key as string);\n  }\n\n  async set<TKey extends keyof T>(\n    key: TKey,\n    value: T[TKey],\n    // seconds, and will add random 20-60 seconds\n    ttl?: number | string\n  ): Promise<void> {\n    const numberTTL = typeof ttl === 'string' ? second(ttl) : ttl;\n    this.warnNotSetTTL(key as string, numberTTL);\n    await this.cacheManager.set(\n      key as string,\n      value,\n      numberTTL ? (numberTTL + getRandomInt(20, 60)) * 1000 : undefined\n    );\n  }\n\n  // no add random ttl\n  async setDetail<TKey extends keyof T>(\n    key: TKey,\n    value: T[TKey],\n    ttl?: number | string // seconds\n  ): Promise<void> {\n    const numberTTL = typeof ttl === 'string' ? second(ttl) : ttl;\n    this.warnNotSetTTL(key as string, numberTTL);\n    await this.cacheManager.set(key as string, value, numberTTL ? numberTTL * 1000 : undefined);\n  }\n\n  async del<TKey extends keyof T>(key: TKey): Promise<void> {\n    await this.cacheManager.delete(key as string);\n  }\n\n  async getMany<TKey extends keyof T>(keys: TKey[]): Promise<Array<T[TKey] | undefined>> {\n    return this.cacheManager.get(keys as string[]);\n  }\n\n  /**\n   * Update the TTL of an existing key without reading/writing data\n   * Returns true if the key exists and TTL was updated\n   */\n  async expire<TKey extends keyof T>(key: TKey, ttl: number | string): Promise<boolean> {\n    const ttlSeconds = typeof ttl === 'string' ? second(ttl) : ttl;\n    const redis = this.getRedisClient();\n    if (!redis) {\n      // Fallback for non-Redis: get and re-set\n      const value = await this.get(key);\n      if (value !== undefined) {\n        await this.setDetail(key, value, ttlSeconds);\n        return true;\n      }\n      return false;\n    }\n\n    const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;\n    const result = await redis.expire(fullKey, ttlSeconds);\n    return result === 1;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/cache/types.ts",
    "content": "import type { IColumnMeta, IFieldVo, IOtOperation, IViewPropertyKeys, IViewVo } from '@teable/core';\nimport type { IRecord, MailType } from '@teable/openapi';\nimport type { ICellContext } from '../features/calculation/utils/changes';\nimport type { IOpsMap } from '../features/calculation/utils/compose-maps';\nimport type { ISendMailOptions } from '../features/mail-sender/mail-helpers';\nimport type { ISessionData } from '../types/session';\n\n/* eslint-disable @typescript-eslint/naming-convention */\nexport interface ICacheStore {\n  [key: `attachment:signature:${string}`]: IAttachmentSignatureCache;\n  [key: `attachment:upload:${string}`]: IAttachmentUploadCache;\n  [key: `attachment:local-signature:${string}`]: IAttachmentLocalTokenCache;\n  [key: `attachment:preview:${string}`]: IAttachmentPreviewCache;\n  [key: `auth:session-store:${string}`]: ISessionData;\n  [key: `auth:session-user:${string}`]: Record<string, number>;\n  [key: `auth:session-expire:${string}`]: boolean;\n  [key: `oauth2:${string}`]: IOauth2State;\n  [key: `reset-password-email:${string}`]: IResetPasswordEmailCache;\n  [key: `workflow:running:${string}`]: string;\n  [key: `workflow:repeatKey:${string}`]: string;\n  [key: `oauth:code:${string}`]: IOAuthCodeState;\n  [key: `oauth:txn:${string}`]: IOAuthTxnStore;\n  // userId:tableId:windowId\n  [key: `operations:undo:${string}:${string}:${string}`]: IUndoRedoOperation[];\n  [key: `operations:redo:${string}:${string}:${string}`]: IUndoRedoOperation[];\n  [key: `plugin:auth-code:${string}`]: IPluginAuthStore;\n  [key: `signin:attempts:${string}`]: number;\n  [key: `signin:lockout:${string}`]: boolean;\n  [key: `query-params:${string}`]: Record<string, unknown>;\n  [key: `mail-sender:notify-mail-merge:${string}`]: (ISendMailOptions & {\n    mailType: MailType;\n  })[];\n  [key: `waitlist:invite-code:${string}`]: number;\n  [key: `send-mail-rate-limit:${string}`]: boolean;\n  [key: `oauth:token-rate:${string}:${string}`]: number;\n  [key: `automation:email:rate:${string}:${number}`]: number;\n  // Distributed lock keys\n  [key: `lock:${string}`]: string;\n  [key: `import:result:manifest:${string}`]: {\n    successCount: number;\n    failedCount: number;\n    errorFilePaths: string[];\n    fieldNames: string[];\n    maxWidth: number;\n    errorReportUrl?: string;\n  };\n  [key: `import:latest-job:${string}`]: string;\n  // trash cleanup: per-item backoff after failed cleanup attempts\n  [key: `trash-cleanup:skipped:${string}`]: { attempts: number; retryAfter: number };\n}\n\nexport interface IAttachmentSignatureCache {\n  path: string;\n  bucket: string;\n  hash?: string;\n}\n\nexport interface IAttachmentUploadCache {\n  mimetype: string;\n  hash: string;\n  size: number;\n}\n\nexport interface IAttachmentLocalTokenCache {\n  expiresDate: number;\n  contentLength: number;\n  contentType: string;\n}\n\nexport interface IAttachmentPreviewCache {\n  url: string;\n  expiresIn: number;\n}\n\nexport interface IOauth2State {\n  redirectUri?: string;\n}\n\nexport interface IResetPasswordEmailCache {\n  userId: string;\n}\n\nexport interface IOAuthCodeState {\n  scopes: string[];\n  redirectUri: string;\n  clientId: string;\n  user: {\n    id: string;\n    name: string;\n    email: string;\n  };\n  codeChallenge?: string;\n  codeChallengeMethod?: 'S256';\n}\n\nexport interface IOAuthTxnStore {\n  redirectURI: string;\n  clientId: string;\n  type: string;\n  scopes: string[];\n  userId: string;\n  state?: string;\n  codeChallenge?: string;\n  codeChallengeMethod?: string;\n}\n\nexport enum OperationName {\n  CreateView = 'createView',\n  DeleteView = 'deleteView',\n  UpdateView = 'updateView',\n  CreateRecords = 'createRecords',\n  DeleteRecords = 'deleteRecords',\n  UpdateRecords = 'updateRecords',\n  UpdateRecordsOrder = 'updateRecordsOrder',\n  CreateFields = 'createFields',\n  ConvertField = 'convertField',\n  ConvertFieldV2 = 'convertFieldV2',\n  DeleteFields = 'deleteFields',\n  PasteSelection = 'pasteSelection',\n}\n\nexport interface IUndoRedoOperationBase {\n  name: OperationName;\n  params: Record<string, unknown>;\n  result?: unknown;\n  userId?: string;\n  operationId?: string;\n}\n\nexport interface IUpdateRecordsOperation extends IUndoRedoOperationBase {\n  name: OperationName.UpdateRecords;\n  params: {\n    tableId: string;\n    recordIds: string[];\n    fieldIds: string[];\n  };\n  result: {\n    cellContexts?: ICellContext[];\n    ordersMap?: {\n      [recordId: string]: {\n        newOrder?: Record<string, number>;\n        oldOrder?: Record<string, number>;\n      };\n    };\n  };\n}\n\nexport interface IUpdateRecordsOrderOperation extends IUndoRedoOperationBase {\n  name: OperationName.UpdateRecordsOrder;\n  params: {\n    tableId: string;\n    viewId: string;\n    recordIds: string[];\n  };\n  result: {\n    ordersMap?: {\n      [recordId: string]: {\n        newOrder?: Record<string, number>;\n        oldOrder?: Record<string, number>;\n      };\n    };\n  };\n}\n\nexport interface ICreateRecordsOperation extends IUndoRedoOperationBase {\n  name: OperationName.CreateRecords;\n  params: {\n    tableId: string;\n  };\n  result: {\n    records: (IRecord & { order?: Record<string, number> })[];\n  };\n}\n\nexport interface IDeleteRecordsOperation extends Omit<ICreateRecordsOperation, 'name'> {\n  name: OperationName.DeleteRecords;\n}\n\nexport interface IConvertFieldOperation extends IUndoRedoOperationBase {\n  name: OperationName.ConvertField;\n  params: {\n    tableId: string;\n  };\n  result: {\n    oldField: IFieldVo;\n    newField: IFieldVo;\n    modifiedOps?: IOpsMap;\n    references?: string[];\n    supplementChange?: {\n      tableId: string;\n      newField: IFieldVo;\n      oldField: IFieldVo;\n    };\n  };\n}\n\nexport interface IConvertFieldV2Operation extends IUndoRedoOperationBase {\n  name: OperationName.ConvertFieldV2;\n  params: {\n    tableId: string;\n  };\n  result: {\n    oldField: IFieldVo;\n    newField: IFieldVo;\n    modifiedOps?: IOpsMap;\n    references?: string[];\n  };\n}\n\nexport interface ICreateFieldsOperation extends IUndoRedoOperationBase {\n  name: OperationName.CreateFields;\n  params: {\n    tableId: string;\n  };\n  result: {\n    fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];\n    records?: {\n      id: string;\n      fields: Record<string, unknown>;\n    }[];\n  };\n}\n\nexport interface IDeleteFieldsOperation extends Omit<ICreateFieldsOperation, 'name'> {\n  name: OperationName.DeleteFields;\n}\n\nexport interface IPasteSelectionOperation extends IUndoRedoOperationBase {\n  name: OperationName.PasteSelection;\n  params: {\n    tableId: string;\n  };\n  result: {\n    updateRecords?: {\n      recordIds: string[];\n      fieldIds: string[];\n      cellContexts: ICellContext[];\n    };\n    newFields?: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];\n    newRecords?: (IRecord & { order?: Record<string, number> })[];\n  };\n}\n\nexport interface ICreateViewOperation extends IUndoRedoOperationBase {\n  name: OperationName.CreateView;\n  params: {\n    tableId: string;\n  };\n  result: {\n    view: IViewVo;\n  };\n}\n\nexport interface IDeleteViewOperation extends IUndoRedoOperationBase {\n  name: OperationName.DeleteView;\n  params: {\n    tableId: string;\n    viewId: string;\n  };\n}\n\nexport interface IUpdateViewOperation extends IUndoRedoOperationBase {\n  name: OperationName.UpdateView;\n  params: {\n    tableId: string;\n    viewId: string;\n  };\n  result: {\n    byKey?: {\n      key: IViewPropertyKeys;\n      newValue: unknown;\n      oldValue: unknown;\n    };\n    byOps?: IOtOperation[];\n  };\n}\n\nexport type IUndoRedoOperation =\n  | IUpdateRecordsOperation\n  | ICreateRecordsOperation\n  | IDeleteRecordsOperation\n  | IUpdateRecordsOrderOperation\n  | ICreateFieldsOperation\n  | IDeleteFieldsOperation\n  | IConvertFieldOperation\n  | IConvertFieldV2Operation\n  | IPasteSelectionOperation\n  | ICreateViewOperation\n  | IDeleteViewOperation\n  | IUpdateViewOperation;\nexport interface IPluginAuthStore {\n  baseId: string;\n  pluginId: string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/auth.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nconst getCookieSecure = (value: string | undefined) => {\n  if (!value) {\n    return undefined;\n  }\n  if (value === 'auto') {\n    return 'auto' as const;\n  }\n  return value === 'true';\n};\n\nexport const authConfig = registerAs('auth', () => ({\n  jwt: {\n    secret:\n      process.env.BACKEND_JWT_SECRET ?? process.env.SECRET_KEY ?? '533Cr3tK3yF0rH4sh1nGJ4W773k3n$',\n    expiresIn: process.env.BACKEND_JWT_EXPIRES_IN ?? '20d',\n  },\n  session: {\n    secret:\n      process.env.BACKEND_SESSION_SECRET ??\n      process.env.SECRET_KEY ??\n      'dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932',\n    expiresIn: process.env.BACKEND_SESSION_EXPIRES_IN ?? '7d',\n    cookie: {\n      secure: getCookieSecure(process.env.BACKEND_SESSION_COOKIE_SECURE),\n    },\n  },\n  accessToken: {\n    prefix: 'teable',\n    encryption: {\n      algorithm: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc',\n      key: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx9',\n      iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4',\n    },\n  },\n  resetPasswordEmailExpiresIn:\n    process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ??\n    process.env.BACKEND_RESET_PASSWORD_EMAIL_EXPIRES_IN ??\n    '30m',\n  signupVerificationExpiresIn:\n    process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ??\n    process.env.BACKEND_SIGNUP_VERIFICATION_EXPIRES_IN ??\n    '30m',\n  socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [],\n  github: {\n    clientID: process.env.BACKEND_GITHUB_CLIENT_ID,\n    clientSecret: process.env.BACKEND_GITHUB_CLIENT_SECRET,\n    callbackURL: process.env.BACKEND_GITHUB_CALLBACK_URL,\n  },\n  google: {\n    clientID: process.env.BACKEND_GOOGLE_CLIENT_ID,\n    clientSecret: process.env.BACKEND_GOOGLE_CLIENT_SECRET,\n    callbackURL: process.env.BACKEND_GOOGLE_CALLBACK_URL,\n  },\n  oidc: {\n    issuer: process.env.BACKEND_OIDC_ISSUER,\n    authorizationURL: process.env.BACKEND_OIDC_AUTHORIZATION_URL,\n    tokenURL: process.env.BACKEND_OIDC_TOKEN_URL,\n    userInfoURL: process.env.BACKEND_OIDC_USER_INFO_URL,\n    clientID: process.env.BACKEND_OIDC_CLIENT_ID,\n    clientSecret: process.env.BACKEND_OIDC_CLIENT_SECRET,\n    callbackURL: process.env.BACKEND_OIDC_CALLBACK_URL,\n    other: process.env.BACKEND_OIDC_OTHER ? JSON.parse(process.env.BACKEND_OIDC_OTHER) : {},\n  },\n  signin: {\n    maxLoginAttempts: process.env.SIGNIN_MAX_LOGIN_ATTEMPTS\n      ? Number(process.env.SIGNIN_MAX_LOGIN_ATTEMPTS)\n      : undefined,\n    accountLockoutMinutes: process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES\n      ? Number(process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES)\n      : undefined,\n  },\n}));\n\nexport const AuthConfig = () => Inject(authConfig.KEY);\n\nexport type IAuthConfig = ConfigType<typeof authConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/base.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const baseConfig = registerAs('base', () => ({\n  isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD',\n  publicOrigin: process.env.PUBLIC_ORIGIN,\n  storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN,\n  secretKey: process.env.SECRET_KEY ?? 'defaultSecretKey',\n  publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY,\n  defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20),\n  templateSpaceId: process.env.TEMPLATE_SPACE_ID,\n  recordHistoryDisabled: process.env.RECORD_HISTORY_DISABLED === 'true',\n  pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002',\n  enableEmailCodeConsole: process.env.ENABLE_EMAIL_CODE_CONSOLE === 'true',\n  emailCodeExpiresIn: process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ?? '30m',\n  chatContextAttachmentSize: Number(process.env.CHAT_CONTEXT_ATTACHMENT_SIZE ?? 10),\n}));\n\nexport const BaseConfig = () => Inject(baseConfig.KEY);\n\nexport type IBaseConfig = ConfigType<typeof baseConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/bootstrap.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const nextJsConfig = registerAs('nextJs', () => ({\n  dir: process.env.NEXTJS_DIR ?? '../nextjs-app',\n}));\n\nexport const securityWebConfig = registerAs('security.web', () => ({\n  cors: {\n    enabled: true,\n  },\n}));\n\nexport const tracingConfig = registerAs('tracing', () => ({\n  enabled: process.env.TRACING_ENABLED === 'true',\n}));\n\nexport const apiDocConfig = registerAs('apiDoc', () => ({\n  disabled: process.env.API_DOC_DISENABLED === 'true',\n  enabledSnippet: process.env.API_DOC_ENABLED_SNIPPET === 'true',\n}));\n\nexport type INextJsConfig = ConfigType<typeof nextJsConfig>;\nexport type ISecurityWebConfig = ConfigType<typeof securityWebConfig>;\nexport type IApiDocConfig = ConfigType<typeof apiDocConfig>;\nexport const bootstrapConfigs = [nextJsConfig, securityWebConfig, apiDocConfig];\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/cache.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const cacheConfig = registerAs('cache', () => ({\n  provider: (process.env.BACKEND_CACHE_PROVIDER ?? 'sqlite') as 'memory' | 'sqlite' | 'redis',\n  sqlite: {\n    uri: process.env.BACKEND_CACHE_SQLITE_URI ?? 'sqlite://.assets/.cache.db',\n  },\n  redis: {\n    uri: process.env.BACKEND_CACHE_REDIS_URI,\n  },\n}));\n\nexport const CacheConfig = () => Inject(cacheConfig.KEY);\n\nexport type ICacheConfig = ConfigType<typeof cacheConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/config.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport path from 'path';\nimport type { DynamicModule } from '@nestjs/common';\nimport { Logger, Module } from '@nestjs/common';\nimport { ConfigModule as BaseConfigModule } from '@nestjs/config';\nimport { authConfig } from './auth.config';\nimport { baseConfig } from './base.config';\nimport { bootstrapConfigs, nextJsConfig } from './bootstrap.config';\nimport { cacheConfig } from './cache.config';\nimport { envValidationSchema } from './env.validation.schema';\nimport { loggerConfig } from './logger.config';\nimport { mailConfig } from './mail.config';\nimport { oauthConfig } from './oauth.config';\nimport { storageConfig } from './storage';\nimport { thresholdConfig } from './threshold.config';\nimport { trashConfig } from './trash.config';\n\nconst configurations = [\n  ...bootstrapConfigs,\n  loggerConfig,\n  mailConfig,\n  authConfig,\n  baseConfig,\n  storageConfig,\n  thresholdConfig,\n  cacheConfig,\n  oauthConfig,\n  trashConfig,\n];\n\n@Module({})\nexport class ConfigModule {\n  static register(): DynamicModule {\n    return BaseConfigModule.forRoot({\n      isGlobal: true,\n      cache: true,\n      expandVariables: true,\n      load: configurations,\n      envFilePath: ['.env.development.local', '.env.development', '.env'].map((str) => {\n        const nextJsDir = nextJsConfig().dir;\n        const envDir = nextJsDir ? path.join(process.cwd(), nextJsDir, str) : str;\n\n        Logger.attachBuffer();\n        Logger.log(`[Env File Path]: ${envDir}`);\n        Logger.detachBuffer();\n        return envDir;\n      }),\n      validationSchema: envValidationSchema,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/config.spec.ts",
    "content": "import { ConfigService } from '@nestjs/config';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { vi } from 'vitest';\n\ntype IMockConfigService = Partial<ConfigService>;\n\nexport const createMockConfigService = (\n  mockValues: Record<string, unknown> = {}\n): IMockConfigService => {\n  return {\n    get: vi.fn().mockImplementation((key: string) => mockValues[key]),\n  };\n};\n\ndescribe('ConfigService', () => {\n  let configService: ConfigService;\n\n  beforeAll(async () => {\n    const mockConfigService = createMockConfigService({ PORT: 3001 });\n\n    const app: TestingModule = await Test.createTestingModule({\n      providers: [\n        {\n          provide: ConfigService,\n          useValue: mockConfigService,\n        },\n      ],\n    }).compile();\n\n    configService = app.get<ConfigService>(ConfigService);\n  });\n\n  it('should be defined', () => {\n    expect(configService).toBeDefined();\n  });\n\n  it('should return port value', () => {\n    expect(configService.get<number>('PORT')).toStrictEqual(3001);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/env.validation.schema.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport Joi from 'joi';\n\nexport const envValidationSchema = Joi.object({\n  NODE_ENV: Joi.string().valid('test', 'development', 'production').default('development'),\n  PORT: Joi.number().default(3000),\n\n  NEXTJS_DIR: Joi.string(),\n\n  SWAGGER_DISABLED: Joi.string().equal('true').optional(),\n\n  // logger\n  LOG_LEVEL: Joi.string().valid('fatal', 'error', 'warn', 'info', 'debug', 'trace').default('info'),\n\n  // database_url\n  PRISMA_DATABASE_URL: Joi.string().required(),\n\n  STORAGE_PREFIX: Joi.string().uri().optional(),\n\n  PUBLIC_ORIGIN: Joi.string().uri().required(),\n\n  // cache\n  BACKEND_CACHE_PROVIDER: Joi.string().valid('memory', 'sqlite', 'redis').default('sqlite'),\n  // cache-sqlite\n  BACKEND_CACHE_SQLITE_URI: Joi.when('BACKEND_CACHE_PROVIDER', {\n    is: 'sqlite',\n    then: Joi.string()\n      .pattern(/^sqlite:\\/\\//)\n      .message('Cache `sqlite` the URI must start with the protocol `sqlite://`'),\n  }),\n  // cache-redis\n  BACKEND_CACHE_REDIS_URI: Joi.when('BACKEND_CACHE_PROVIDER', {\n    is: 'redis',\n    then: Joi.string()\n      .pattern(/^(redis:\\/\\/|rediss:\\/\\/)/)\n      .message('Cache `redis` the URI must start with the protocol `redis://` or `rediss://`'),\n  }),\n  // github auth\n  BACKEND_GITHUB_CLIENT_ID: Joi.when('SOCIAL_AUTH_PROVIDERS', {\n    is: Joi.string()\n      .regex(/(^|,)(github)(,|$)/)\n      .required(),\n    then: Joi.string().required().messages({\n      'any.required':\n        'The `BACKEND_GITHUB_CLIENT_ID` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',\n    }),\n  }),\n  BACKEND_GITHUB_CLIENT_SECRET: Joi.when('SOCIAL_AUTH_PROVIDERS', {\n    is: Joi.string()\n      .regex(/(^|,)(github)(,|$)/)\n      .required(),\n    then: Joi.string().required().messages({\n      'any.required':\n        'The `BACKEND_GITHUB_CLIENT_SECRET` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',\n    }),\n  }),\n\n  PASSWORD_LOGIN_DISABLED: Joi.string().equal('true').optional(),\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/logger.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const loggerConfig = registerAs('logger', () => ({\n  level: process.env.LOG_LEVEL ?? 'info',\n  enableGlobalErrorLogging: process.env.ENABLE_GLOBAL_ERROR_LOGGING === 'true',\n}));\n\nexport const LoggerConfig = () => Inject(loggerConfig.KEY);\n\nexport type ILoggerConfig = ConfigType<typeof loggerConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/mail.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const mailConfig = registerAs('mail', () => {\n  const host = process.env.BACKEND_MAIL_HOST;\n  const authUser = process.env.BACKEND_MAIL_AUTH_USER;\n  const authPass = process.env.BACKEND_MAIL_AUTH_PASS;\n\n  // Check if mail is properly configured (host, user, and pass are all required)\n  const isConfigured = Boolean(host && authUser && authPass);\n\n  return {\n    origin: process.env.PUBLIC_ORIGIN ?? 'https://teable.ai',\n    host: host ?? 'smtp.teable.ai',\n    port: parseInt(process.env.BACKEND_MAIL_PORT ?? '465', 10),\n    secure: Object.is(process.env.BACKEND_MAIL_SECURE ?? 'true', 'true'),\n    sender: process.env.BACKEND_MAIL_SENDER ?? 'noreply.teable.ai',\n    senderName: process.env.BACKEND_MAIL_SENDER_NAME ?? 'Teable',\n    auth: {\n      user: authUser,\n      pass: authPass,\n    },\n    isConfigured,\n    connectionTimeout: parseInt(process.env.BACKEND_MAIL_CONNECTION_TIMEOUT ?? '10000', 10),\n    greetingTimeout: parseInt(process.env.BACKEND_MAIL_GREETING_TIMEOUT ?? '10000', 10),\n    dnsTimeout: parseInt(process.env.BACKEND_MAIL_DNS_TIMEOUT ?? '5000', 10),\n    encryption: {\n      algorithm: 'aes-128-cbc',\n      key: process.env.BACKEND_MAIL_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx1',\n      iv: process.env.BACKEND_MAIL_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf1',\n      encoding: 'base64' as BufferEncoding,\n    },\n  };\n});\n\nexport const MailConfig = () => Inject(mailConfig.KEY);\n\nexport type IMailConfig = ConfigType<typeof mailConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/oauth.config.ts",
    "content": "import { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const oauthConfig = registerAs('oauth', () => ({\n  accessTokenExpireIn: process.env.BACKEND_OAUTH_ACCESS_TOKEN_EXPIRE_IN || '10m',\n  refreshTokenExpireIn: process.env.BACKEND_OAUTH_REFRESH_TOKEN_EXPIRE_IN || '30d',\n  transactionExpireIn: process.env.BACKEND_OAUTH_TRANSACTION_EXPIRE_IN || '5m',\n  codeExpireIn: process.env.BACKEND_OAUTH_CODE_EXPIRE_IN || '5m',\n  authorizedExpireIn: process.env.BACKEND_OAUTH_AUTHORIZED_EXPIRE_IN || '7d',\n  tokenRateLimit: Number(process.env.BACKEND_OAUTH_TOKEN_RATE_LIMIT || 30),\n  tokenRateWindow: process.env.BACKEND_OAUTH_TOKEN_RATE_WINDOW || '15m',\n}));\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const OAuthConfig = () => Inject(oauthConfig.KEY);\n\nexport type IOAuthConfig = ConfigType<typeof oauthConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/storage.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const storageConfig = registerAs('storage', () => ({\n  provider: (process.env.BACKEND_STORAGE_PROVIDER ?? 'local') as\n    | 'local'\n    | 'minio'\n    | 's3'\n    | 'aliyun',\n  local: {\n    path: process.env.BACKEND_STORAGE_LOCAL_PATH ?? '.assets/uploads',\n  },\n  publicUrl: process.env.BACKEND_STORAGE_PUBLIC_URL,\n  publicBucket: process.env.BACKEND_STORAGE_PUBLIC_BUCKET || 'public',\n  privateBucket: process.env.BACKEND_STORAGE_PRIVATE_BUCKET || 'private',\n  privateBucketEndpoint: process.env.BACKEND_STORAGE_PRIVATE_BUCKET_ENDPOINT,\n  minio: {\n    endPoint: process.env.BACKEND_STORAGE_MINIO_ENDPOINT,\n    internalEndPoint: process.env.BACKEND_STORAGE_MINIO_INTERNAL_ENDPOINT,\n    internalPort: Number(process.env.BACKEND_STORAGE_MINIO_INTERNAL_PORT ?? 9000),\n    port: Number(process.env.BACKEND_STORAGE_MINIO_PORT ?? 9000),\n    useSSL: process.env.BACKEND_STORAGE_MINIO_USE_SSL === 'true',\n    accessKey: process.env.BACKEND_STORAGE_MINIO_ACCESS_KEY,\n    secretKey: process.env.BACKEND_STORAGE_MINIO_SECRET_KEY,\n    region: process.env.BACKEND_STORAGE_MINIO_REGION,\n  },\n  s3: {\n    region: process.env.BACKEND_STORAGE_S3_REGION!,\n    endpoint: process.env.BACKEND_STORAGE_S3_ENDPOINT,\n    internalEndpoint: process.env.BACKEND_STORAGE_S3_INTERNAL_ENDPOINT,\n    accessKey: process.env.BACKEND_STORAGE_S3_ACCESS_KEY!,\n    secretKey: process.env.BACKEND_STORAGE_S3_SECRET_KEY!,\n    maxSockets: Number(process.env.BACKEND_STORAGE_S3_MAX_SOCKETS ?? 100),\n  },\n  uploadMethod: process.env.BACKEND_STORAGE_UPLOAD_METHOD ?? 'put',\n  encryption: {\n    algorithm: process.env.BACKEND_STORAGE_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc',\n    key: process.env.BACKEND_STORAGE_ENCRYPTION_KEY ?? '73b00476e456323e',\n    iv: process.env.BACKEND_STORAGE_ENCRYPTION_IV ?? '8c9183e4c175f63c',\n  },\n  // must be less than 7 days\n  tokenExpireIn: process.env.BACKEND_STORAGE_TOKEN_EXPIRE_IN ?? '6d',\n  urlExpireIn: process.env.BACKEND_STORAGE_URL_EXPIRE_IN ?? '6d',\n}));\n\nexport const StorageConfig = () => Inject(storageConfig.KEY);\n\nexport type IStorageConfig = ConfigType<typeof storageConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/threshold.config.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Inject } from '@nestjs/common';\nimport type { ConfigType } from '@nestjs/config';\nimport { registerAs } from '@nestjs/config';\n\nexport const thresholdConfig = registerAs('threshold', () => ({\n  maxCopyCells: Number(process.env.MAX_COPY_CELLS ?? 50_000),\n  maxResetCells: Number(process.env.MAX_RESET_CELLS ?? 50_000),\n  maxPasteCells: Number(process.env.MAX_PASTE_CELLS ?? 50_000),\n  maxReadRows: Number(process.env.MAX_READ_ROWS ?? 10_000),\n  maxDeleteRows: Number(process.env.MAX_DELETE_ROWS ?? 1_000),\n  maxSyncUpdateCells: Number(process.env.MAX_SYNC_UPDATE_CELLS ?? 10_000),\n  maxGroupPoints: Number(process.env.MAX_GROUP_POINTS ?? 5_000),\n  calcChunkSize: Number(process.env.CALC_CHUNK_SIZE ?? 1_000),\n  maxFreeRowLimit: Number(process.env.MAX_FREE_ROW_LIMIT ?? 0),\n  estimateCalcCelPerMs: Number(process.env.ESTIMATE_CALC_CEL_PER_MS ?? 3),\n  maxUndoStackSize: Number(process.env.MAX_UNDO_STACK_SIZE ?? 200),\n  undoExpirationTime: Number(process.env.UNDO_EXPIRATION_TIME ?? 86400),\n  bigTransactionTimeout: Number(\n    process.env.BIG_TRANSACTION_TIMEOUT ?? 10 * 60 * 1000 /* 10 mins */\n  ),\n  automationGap: Number(process.env.AUTOMATION_GAP ?? 200),\n  maxAttachmentUploadSize: Number(process.env.MAX_ATTACHMENT_UPLOAD_SIZE ?? Infinity),\n  maxOpenapiAttachmentUploadSize: Number(\n    process.env.MAX_OPENAPI_ATTACHMENT_UPLOAD_SIZE ?? Infinity\n  ),\n  webhook: {\n    bodyLimitBytes: Number(process.env.WEBHOOK_BODY_LIMIT_BYTES ?? 4 * 1024 * 1024),\n    baseRateLimit: Number(process.env.WEBHOOK_BASE_RATE_LIMIT ?? 50),\n    workflowRateLimit: Number(process.env.WEBHOOK_WORKFLOW_RATE_LIMIT ?? 2),\n  },\n  dbDeadlock: {\n    maxRetries: Number(process.env.BACKEND_DB_DEADLOCK_MAX_RETRIES ?? 3),\n    initialBackoff: Number(process.env.BACKEND_DB_DEADLOCK_INITIAL_BACKOFF ?? 100),\n    jitter: Number(process.env.BACKEND_DB_DEADLOCK_JITTER ?? 1.0),\n  },\n  baseNodeMaxFolderDepth: Number(process.env.BASE_NODE_MAX_FOLDER_DEPTH ?? 2),\n  changeEmailSendCodeMailRate: Number(process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE ?? 30),\n  resetPasswordSendMailRate: Number(process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE ?? 30),\n  signupVerificationSendCodeMailRate: Number(\n    process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS ??\n      process.env.BACKEND_SIGNUP_VERIFICATION_SEND_CODE_MAIL_RATE ??\n      30\n  ),\n  billing: {\n    automationRunGracePeriod: process.env.BILLING_AUTOMATION_RUN_GRACE_PERIOD ?? '3d',\n    automationRunNotifyInterval: process.env.BILLING_AUTOMATION_RUN_NOTIFY_INTERVAL ?? '6h',\n  },\n  automation: {\n    maxEmailsPerPoll: Number(process.env.AUTOMATION_MAX_EMAILS_PER_POLL ?? 100),\n    maxEmailDedupWindowSize: Number(process.env.AUTOMATION_MAX_EMAIL_DEDUP_WINDOW_SIZE ?? 500),\n  },\n}));\n\nexport const ThresholdConfig = () => Inject(thresholdConfig.KEY);\n\nexport type IThresholdConfig = ConfigType<typeof thresholdConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/configs/trash.config.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\n\nimport { Inject } from '@nestjs/common';\nimport { registerAs } from '@nestjs/config';\nimport ms from 'ms';\n\nexport const trashConfig = registerAs('trash', () => ({\n  /**\n   * Retention period for trashed resources before permanent deletion.\n   * Supports ms library format: '30d', '7d', '24h', etc.\n   * Set to '0' to disable automatic cleanup.\n   * Default: 30 days\n   */\n  retention: ms((process.env.TRASH_RETENTION as string) ?? '30d'),\n  /**\n   * Interval between trash cleanup scans.\n   * Supports ms library format: '1h', '30m', '2d', etc.\n   * Default: 1 hour\n   */\n  scanInterval: ms((process.env.TRASH_SCAN_INTERVAL as string) ?? '1h'),\n}));\n\nexport const TrashConfig = () => Inject(trashConfig.KEY);\n\nexport type ITrashConfig = ReturnType<typeof trashConfig>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/const.ts",
    "content": "export const X_REQUEST_ID = 'X-Request-Id';\nexport const AUTH_SESSION_COOKIE_NAME = 'auth_session';\n"
  },
  {
    "path": "apps/nestjs-backend/src/custom.exception.ts",
    "content": "import { HttpException, HttpStatus } from '@nestjs/common';\nimport type { ICustomHttpExceptionData } from '@teable/core';\nimport { ErrorCodeToStatusMap, HttpErrorCode } from '@teable/core';\nimport type { Path } from 'nestjs-i18n';\nimport type { I18nTranslations } from './types/i18n.generated';\n\nexport class CustomHttpException extends HttpException {\n  code: string;\n  data?: ICustomHttpExceptionData;\n\n  constructor(\n    message: string,\n    code: HttpErrorCode,\n    data?: ICustomHttpExceptionData<Path<I18nTranslations['sdk']>>\n  ) {\n    super(message, ErrorCodeToStatusMap[code]);\n    this.code = code;\n    this.data = data;\n  }\n}\n\nexport const getDefaultCodeByStatus = (status: HttpStatus) => {\n  switch (status) {\n    case HttpStatus.BAD_REQUEST:\n      return HttpErrorCode.VALIDATION_ERROR;\n    case HttpStatus.UNAUTHORIZED:\n      return HttpErrorCode.UNAUTHORIZED;\n    case HttpStatus.PAYMENT_REQUIRED:\n      return HttpErrorCode.PAYMENT_REQUIRED;\n    case HttpStatus.FORBIDDEN:\n      return HttpErrorCode.RESTRICTED_RESOURCE;\n    case HttpStatus.NOT_FOUND:\n      return HttpErrorCode.NOT_FOUND;\n    case HttpStatus.CONFLICT:\n      return HttpErrorCode.CONFLICT;\n    case HttpStatus.INTERNAL_SERVER_ERROR:\n      return HttpErrorCode.INTERNAL_SERVER_ERROR;\n    case HttpStatus.SERVICE_UNAVAILABLE:\n      return HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE;\n    case HttpStatus.REQUEST_TIMEOUT:\n      return HttpErrorCode.REQUEST_TIMEOUT;\n    case HttpStatus.TOO_MANY_REQUESTS:\n      return HttpErrorCode.TOO_MANY_REQUESTS;\n    case HttpStatus.PAYLOAD_TOO_LARGE:\n      return HttpErrorCode.PAYLOAD_TOO_LARGE;\n    case HttpStatus.GATEWAY_TIMEOUT:\n      return HttpErrorCode.GATEWAY_TIMEOUT;\n    default:\n      return HttpErrorCode.UNKNOWN_ERROR_CODE;\n  }\n};\n\nexport class TemplateAppTokenNotAllowedException extends HttpException {\n  constructor() {\n    super(\n      {\n        message: 'Template preview app token operation not allowed',\n      },\n      200\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.abstract.ts",
    "content": "import { InternalServerErrorException } from '@nestjs/common';\nimport type { FieldCore } from '@teable/core';\nimport { StatisticsFunc } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryAggregateContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { IAggregationFunctionInterface } from './aggregation-function.interface';\n\nexport abstract class AbstractAggregationFunction implements IAggregationFunctionInterface {\n  protected tableColumnRef: string;\n\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly field: FieldCore,\n    readonly context?: IRecordQueryAggregateContext\n  ) {\n    const { dbFieldName, id } = field;\n\n    const selection = context?.selectionMap.get(id);\n    if (selection) {\n      this.tableColumnRef = selection as string;\n    } else {\n      this.tableColumnRef = dbFieldName;\n    }\n  }\n\n  get dbTableName() {\n    return this.context?.tableDbName;\n  }\n\n  get tableAlias() {\n    return this.context?.tableAlias;\n  }\n\n  compiler(builderClient: Knex.QueryBuilder, aggFunc: StatisticsFunc, alias: string | undefined) {\n    const functionHandlers = {\n      [StatisticsFunc.Count]: this.count,\n      [StatisticsFunc.Empty]: this.empty,\n      [StatisticsFunc.Filled]: this.filled,\n      [StatisticsFunc.Unique]: this.unique,\n      [StatisticsFunc.Max]: this.max,\n      [StatisticsFunc.Min]: this.min,\n      [StatisticsFunc.Sum]: this.sum,\n      [StatisticsFunc.Average]: this.average,\n      [StatisticsFunc.Checked]: this.checked,\n      [StatisticsFunc.UnChecked]: this.unChecked,\n      [StatisticsFunc.PercentEmpty]: this.percentEmpty,\n      [StatisticsFunc.PercentFilled]: this.percentFilled,\n      [StatisticsFunc.PercentUnique]: this.percentUnique,\n      [StatisticsFunc.PercentChecked]: this.percentChecked,\n      [StatisticsFunc.PercentUnChecked]: this.percentUnChecked,\n      [StatisticsFunc.EarliestDate]: this.earliestDate,\n      [StatisticsFunc.LatestDate]: this.latestDate,\n      [StatisticsFunc.DateRangeOfDays]: this.dateRangeOfDays,\n      [StatisticsFunc.DateRangeOfMonths]: this.dateRangeOfMonths,\n      [StatisticsFunc.TotalAttachmentSize]: this.totalAttachmentSize,\n    };\n    const chosenHandler = functionHandlers[aggFunc].bind(this);\n\n    if (!chosenHandler) {\n      throw new InternalServerErrorException(`Unknown function ${aggFunc} for aggregation`);\n    }\n\n    const { id: fieldId, isMultipleCellValue } = this.field;\n\n    let rawSql: string = chosenHandler();\n\n    const ignoreMcvFunc = [\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.UnChecked,\n      StatisticsFunc.Filled,\n      StatisticsFunc.Checked,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentUnChecked,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentChecked,\n      // Special-case: compute per-row then sum across group without MCV join\n      StatisticsFunc.TotalAttachmentSize,\n    ];\n\n    if (isMultipleCellValue && !ignoreMcvFunc.includes(aggFunc)) {\n      const joinTable = `${fieldId}_mcv`;\n\n      builderClient.with(`${fieldId}_mcv`, this.knex.raw(rawSql));\n      builderClient.joinRaw(`, ${this.knex.ref(joinTable)}`);\n\n      rawSql = `MAX(${this.knex.ref(`${joinTable}.value`)})`;\n    }\n\n    return builderClient.select(\n      this.knex.raw(`${rawSql} AS ??`, [alias ?? `${fieldId}_${aggFunc}`])\n    );\n  }\n\n  count(): string {\n    return this.knex.raw(`COUNT(*)`).toQuery();\n  }\n\n  empty(): string {\n    return this.knex.raw(`COUNT(*) - COUNT(${this.tableColumnRef})`).toQuery();\n  }\n\n  filled(): string {\n    return this.knex.raw(`COUNT(${this.tableColumnRef})`).toQuery();\n  }\n\n  unique(): string {\n    return this.knex.raw(`COUNT(DISTINCT ${this.tableColumnRef})`).toQuery();\n  }\n\n  max(): string {\n    return this.knex.raw(`MAX(${this.tableColumnRef})`).toQuery();\n  }\n\n  min(): string {\n    return this.knex.raw(`MIN(${this.tableColumnRef})`).toQuery();\n  }\n\n  sum(): string {\n    return this.knex.raw(`SUM(${this.tableColumnRef})`).toQuery();\n  }\n\n  average(): string {\n    return this.knex.raw(`AVG(${this.tableColumnRef})`).toQuery();\n  }\n\n  checked(): string {\n    return this.filled();\n  }\n\n  unChecked(): string {\n    return this.empty();\n  }\n\n  abstract percentEmpty(): string;\n\n  abstract percentFilled(): string;\n\n  abstract percentUnique(): string;\n\n  abstract percentChecked(): string;\n\n  abstract percentUnChecked(): string;\n\n  earliestDate(): string {\n    return this.min();\n  }\n\n  latestDate(): string {\n    return this.max();\n  }\n\n  abstract dateRangeOfDays(): string;\n\n  abstract dateRangeOfMonths(): string;\n\n  abstract totalAttachmentSize(): string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.interface.ts",
    "content": "export type IAggregationFunctionHandler = () => string;\n\nexport interface IAggregationFunctionInterface {\n  count: IAggregationFunctionHandler;\n  empty: IAggregationFunctionHandler;\n  filled: IAggregationFunctionHandler;\n  unique: IAggregationFunctionHandler;\n  max: IAggregationFunctionHandler;\n  min: IAggregationFunctionHandler;\n  sum: IAggregationFunctionHandler;\n  average: IAggregationFunctionHandler;\n  checked: IAggregationFunctionHandler;\n  unChecked: IAggregationFunctionHandler;\n  percentEmpty: IAggregationFunctionHandler;\n  percentFilled: IAggregationFunctionHandler;\n  percentUnique: IAggregationFunctionHandler;\n  percentChecked: IAggregationFunctionHandler;\n  percentUnChecked: IAggregationFunctionHandler;\n  earliestDate: IAggregationFunctionHandler;\n  latestDate: IAggregationFunctionHandler;\n  dateRangeOfDays: IAggregationFunctionHandler;\n  dateRangeOfMonths: IAggregationFunctionHandler;\n  totalAttachmentSize: IAggregationFunctionHandler;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.abstract.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport type { FieldCore } from '@teable/core';\nimport { CellValueType, DbFieldType, getValidStatisticFunc, StatisticsFunc } from '@teable/core';\nimport type { IAggregationField } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryAggregateContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { IAggregationQueryExtra } from '../db.provider.interface';\nimport type { AbstractAggregationFunction } from './aggregation-function.abstract';\nimport type { IAggregationQueryInterface } from './aggregation-query.interface';\n\nexport abstract class AbstractAggregationQuery implements IAggregationQueryInterface {\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fields?: { [fieldId: string]: FieldCore },\n    protected readonly aggregationFields?: IAggregationField[],\n    protected readonly extra?: IAggregationQueryExtra,\n    protected readonly context?: IRecordQueryAggregateContext\n  ) {}\n\n  get dbTableName() {\n    return this.context?.tableDbName;\n  }\n\n  get tableAlias() {\n    return this.context?.tableAlias;\n  }\n\n  appendBuilder(): Knex.QueryBuilder {\n    const queryBuilder = this.originQueryBuilder;\n\n    if (!this.aggregationFields || !this.aggregationFields.length) {\n      return queryBuilder;\n    }\n\n    this.validAggregationField(this.aggregationFields, this.extra);\n\n    this.aggregationFields.forEach(({ fieldId, statisticFunc, alias }) => {\n      // TODO: handle all func type\n      if (statisticFunc === StatisticsFunc.Count && fieldId === '*') {\n        const field = Object.values(this.fields ?? {})[0];\n        if (!field) {\n          return queryBuilder;\n        }\n        this.getAggregationAdapter(field).compiler(queryBuilder, statisticFunc, alias);\n        return;\n      }\n      const field = this.fields && this.fields[fieldId];\n      if (!field) {\n        return queryBuilder;\n      }\n\n      this.getAggregationAdapter(field).compiler(queryBuilder, statisticFunc, alias);\n    });\n\n    // Emit GROUP BY and grouped select columns when requested via extra.groupBy\n    if (this.extra?.groupBy && this.extra.groupBy.length > 0) {\n      const groupByExprs = this.extra.groupBy\n        .map((fieldId) => {\n          const mapped = this.context?.selectionMap.get(fieldId) as string | undefined;\n          if (mapped) return mapped;\n          const dbFieldName = this.fields?.[fieldId]?.dbFieldName;\n          if (!dbFieldName) return null;\n          return this.tableAlias ? `\"${this.tableAlias}\".\"${dbFieldName}\"` : `\"${dbFieldName}\"`;\n        })\n        .filter(Boolean) as string[];\n\n      for (const expr of groupByExprs) {\n        queryBuilder.groupByRaw(expr);\n      }\n\n      for (const fieldId of this.extra.groupBy) {\n        const field = this.fields?.[fieldId];\n        if (!field) continue;\n        const mapped =\n          (this.context?.selectionMap.get(fieldId) as string | undefined) ??\n          (this.tableAlias\n            ? `\"${this.tableAlias}\".\"${field.dbFieldName}\"`\n            : `\"${field.dbFieldName}\"`);\n        queryBuilder.select(this.knex.raw(`${mapped} AS ??`, [field.dbFieldName]));\n      }\n\n      // Ensure no stray ORDER BY (e.g., inherited from view default sort) remains after grouping\n      queryBuilder.clearOrder();\n    }\n\n    return queryBuilder;\n  }\n\n  private validAggregationField(\n    aggregationFields: IAggregationField[],\n    _extra?: IAggregationQueryExtra\n  ) {\n    aggregationFields\n      .filter(({ fieldId }) => !!fieldId && fieldId !== '*')\n      .forEach(({ fieldId, statisticFunc }) => {\n        const field = this.fields && this.fields[fieldId];\n\n        if (!field) {\n          throw new BadRequestException(`field: '${fieldId}' is invalid`);\n        }\n\n        const validStatisticFunc = getValidStatisticFunc(field);\n        if (statisticFunc && !validStatisticFunc.includes(statisticFunc)) {\n          throw new BadRequestException(\n            `field: '${fieldId}', aggregation func: '${statisticFunc}' is invalid, Only the following func are allowed: [${validStatisticFunc}]`\n          );\n        }\n      });\n  }\n\n  private getAggregationAdapter(field: FieldCore): AbstractAggregationFunction {\n    const { dbFieldType } = field;\n    switch (field.cellValueType) {\n      case CellValueType.Boolean:\n        return this.booleanAggregation(field);\n      case CellValueType.Number:\n        return this.numberAggregation(field);\n      case CellValueType.DateTime:\n        return this.dateTimeAggregation(field);\n      case CellValueType.String: {\n        if (dbFieldType === DbFieldType.Json) {\n          return this.jsonAggregation(field);\n        }\n        return this.stringAggregation(field);\n      }\n    }\n  }\n\n  abstract booleanAggregation(field: FieldCore): AbstractAggregationFunction;\n\n  abstract numberAggregation(field: FieldCore): AbstractAggregationFunction;\n\n  abstract dateTimeAggregation(field: FieldCore): AbstractAggregationFunction;\n\n  abstract stringAggregation(field: FieldCore): AbstractAggregationFunction;\n\n  abstract jsonAggregation(field: FieldCore): AbstractAggregationFunction;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.interface.ts",
    "content": "import type { Knex } from 'knex';\n\nexport interface IAggregationQueryInterface {\n  appendBuilder(): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/postgres/__tests__/multiple-value-aggregation.adapter.spec.ts",
    "content": "import type { FieldCore } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport knex from 'knex';\nimport { describe, expect, it } from 'vitest';\nimport type { IRecordQueryAggregateContext } from '../../../../features/record/query-builder/record-query-builder.interface';\nimport { MultipleValueAggregationAdapter } from '../multiple-value/multiple-value-aggregation.adapter';\n\nconst knexClient = knex({ client: 'pg' });\n\nconst createAdapter = () => {\n  const field = {\n    id: 'fldNumericArray',\n    dbFieldName: '\"values\"',\n    isMultipleCellValue: true,\n    type: FieldType.Number,\n  } as unknown as FieldCore;\n\n  const context: IRecordQueryAggregateContext = {\n    selectionMap: new Map([[field.id, '\"alias\".\"values\"']]),\n    tableDbName: 'public.test_table',\n    tableAlias: 'alias',\n  };\n\n  return new MultipleValueAggregationAdapter(knexClient, field, context);\n};\n\ndescribe('MultipleValueAggregationAdapter numeric coercion', () => {\n  it.each([\n    ['sum', (adapter: MultipleValueAggregationAdapter) => adapter.sum()],\n    ['average', (adapter: MultipleValueAggregationAdapter) => adapter.average()],\n    ['max', (adapter: MultipleValueAggregationAdapter) => adapter.max()],\n    ['min', (adapter: MultipleValueAggregationAdapter) => adapter.min()],\n  ])('renders %s aggregation without integer casts', (_, getSql) => {\n    const adapter = createAdapter();\n    const sql = getSql(adapter);\n    expect(sql).toContain('::double precision');\n    expect(sql).toContain('REGEXP_REPLACE');\n    expect(sql.toUpperCase()).not.toContain('::INTEGER');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-function.postgres.ts",
    "content": "import { NotImplementedException } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport { AbstractAggregationFunction } from '../aggregation-function.abstract';\n\nexport class AggregationFunctionPostgres extends AbstractAggregationFunction {\n  unique(): string {\n    const { type, isMultipleCellValue } = this.field;\n    if (\n      ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||\n      isMultipleCellValue\n    ) {\n      return super.unique();\n    }\n\n    return this.knex.raw(`COUNT(DISTINCT ${this.tableColumnRef} ->> 'id')`).toQuery();\n  }\n\n  percentUnique(): string {\n    const { type, isMultipleCellValue } = this.field;\n    if (\n      ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||\n      isMultipleCellValue\n    ) {\n      return this.knex\n        .raw(`(COUNT(DISTINCT ${this.tableColumnRef}) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)\n        .toQuery();\n    }\n\n    return this.knex\n      .raw(`(COUNT(DISTINCT ${this.tableColumnRef} ->> 'id') * 1.0 / GREATEST(COUNT(*), 1)) * 100`)\n      .toQuery();\n  }\n\n  dateRangeOfDays(): string {\n    throw new NotImplementedException();\n  }\n\n  dateRangeOfMonths(): string {\n    throw new NotImplementedException();\n  }\n\n  totalAttachmentSize(): string {\n    // Sum sizes per row, then sum across the current scope (respects GROUP BY)\n    return this.knex\n      .raw(\n        `SUM(COALESCE((SELECT SUM((e.value ->> 'size')::INTEGER)\n          FROM jsonb_array_elements(COALESCE(${this.tableColumnRef}, '[]'::jsonb)) AS e), 0))`\n      )\n      .toQuery();\n  }\n\n  percentEmpty(): string {\n    return this.knex\n      .raw(`((COUNT(*) - COUNT(${this.tableColumnRef})) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)\n      .toQuery();\n  }\n\n  percentFilled(): string {\n    return this.knex\n      .raw(`(COUNT(${this.tableColumnRef}) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)\n      .toQuery();\n  }\n\n  checked(): string {\n    return this.knex\n      .raw(`SUM(CASE WHEN ${this.tableColumnRef} = true THEN 1 ELSE 0 END)`)\n      .toQuery();\n  }\n\n  unChecked(): string {\n    return this.knex\n      .raw(\n        `SUM(CASE WHEN ${this.tableColumnRef} = false OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END)`\n      )\n      .toQuery();\n  }\n\n  percentChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} = true THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n\n  percentUnChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} = false OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-query.postgres.ts",
    "content": "import type { FieldCore } from '@teable/core';\nimport { AbstractAggregationQuery } from '../aggregation-query.abstract';\nimport type { AggregationFunctionPostgres } from './aggregation-function.postgres';\nimport { MultipleValueAggregationAdapter } from './multiple-value/multiple-value-aggregation.adapter';\nimport { SingleValueAggregationAdapter } from './single-value/single-value-aggregation.adapter';\n\nexport class AggregationQueryPostgres extends AbstractAggregationQuery {\n  private coreAggregation(field: FieldCore): AggregationFunctionPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleValueAggregationAdapter(this.knex, field, this.context);\n    }\n    return new SingleValueAggregationAdapter(this.knex, field, this.context);\n  }\n\n  booleanAggregation(field: FieldCore): AggregationFunctionPostgres {\n    return this.coreAggregation(field);\n  }\n\n  numberAggregation(field: FieldCore): AggregationFunctionPostgres {\n    return this.coreAggregation(field);\n  }\n\n  dateTimeAggregation(field: FieldCore): AggregationFunctionPostgres {\n    return this.coreAggregation(field);\n  }\n\n  stringAggregation(field: FieldCore): AggregationFunctionPostgres {\n    return this.coreAggregation(field);\n  }\n\n  jsonAggregation(field: FieldCore): AggregationFunctionPostgres {\n    return this.coreAggregation(field);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/postgres/multiple-value/multiple-value-aggregation.adapter.ts",
    "content": "import { AggregationFunctionPostgres } from '../aggregation-function.postgres';\n\nexport class MultipleValueAggregationAdapter extends AggregationFunctionPostgres {\n  private toNumericSafe(columnExpression: string): string {\n    const textExpr = `(${columnExpression})::text`;\n    const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;\n    return `NULLIF(${sanitized}, '')::double precision`;\n  }\n\n  unique(): string {\n    return this.knex\n      .raw(\n        `SELECT COUNT(DISTINCT \"value\") AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  max(): string {\n    return this.knex\n      .raw(\n        `SELECT MAX(${this.toNumericSafe('\"value\"')}) AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  min(): string {\n    return this.knex\n      .raw(\n        `SELECT MIN(${this.toNumericSafe('\"value\"')}) AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  sum(): string {\n    return this.knex\n      .raw(\n        `SELECT SUM(${this.toNumericSafe('\"value\"')}) AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  average(): string {\n    return this.knex\n      .raw(\n        `SELECT AVG(${this.toNumericSafe('\"value\"')}) AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  percentUnique(): string {\n    return this.knex\n      .raw(\n        `SELECT (COUNT(DISTINCT \"value\") * 1.0 / GREATEST(COUNT(*), 1)) * 100 AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  dateRangeOfDays(): string {\n    return this.knex\n      .raw(\n        `SELECT extract(DAY FROM (MAX(\"value\"::TIMESTAMPTZ) - MIN(\"value\"::TIMESTAMPTZ)))::INTEGER AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  dateRangeOfMonths(): string {\n    return this.knex\n      .raw(\n        `SELECT CONCAT(MAX(\"value\"::TIMESTAMPTZ), ',', MIN(\"value\"::TIMESTAMPTZ)) AS \"value\" FROM ?? as \"${this.tableAlias}\", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  checked(): string {\n    return this.knex\n      .raw(`SUM(CASE WHEN ${this.tableColumnRef} @> '[true]'::jsonb THEN 1 ELSE 0 END)`)\n      .toQuery();\n  }\n\n  unChecked(): string {\n    return this.knex\n      .raw(\n        `SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT (${this.tableColumnRef} @> '[true]'::jsonb) THEN 1 ELSE 0 END)`\n      )\n      .toQuery();\n  }\n\n  percentChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} @> '[true]'::jsonb THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n\n  percentUnChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT (${this.tableColumnRef} @> '[true]'::jsonb) THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/postgres/single-value/single-value-aggregation.adapter.ts",
    "content": "import { AggregationFunctionPostgres } from '../aggregation-function.postgres';\n\nexport class SingleValueAggregationAdapter extends AggregationFunctionPostgres {\n  dateRangeOfDays(): string {\n    return this.knex\n      .raw(`extract(DAY FROM (MAX(${this.tableColumnRef}) - MIN(${this.tableColumnRef})))::INTEGER`)\n      .toQuery();\n  }\n\n  dateRangeOfMonths(): string {\n    return this.knex\n      .raw(`CONCAT(MAX(${this.tableColumnRef}), ',', MIN(${this.tableColumnRef}))`)\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-function.sqlite.ts",
    "content": "import { NotImplementedException } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport { AbstractAggregationFunction } from '../aggregation-function.abstract';\n\nexport class AggregationFunctionSqlite extends AbstractAggregationFunction {\n  unique(): string {\n    const { type, isMultipleCellValue } = this.field;\n    if (\n      ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||\n      isMultipleCellValue\n    ) {\n      return super.unique();\n    }\n\n    return this.knex.raw(`COUNT(DISTINCT json_extract(${this.tableColumnRef}, '$.id'))`).toQuery();\n  }\n\n  percentUnique(): string {\n    const { type, isMultipleCellValue } = this.field;\n    if (\n      ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||\n      isMultipleCellValue\n    ) {\n      return this.knex\n        .raw(`(COUNT(DISTINCT ${this.tableColumnRef}) * 1.0 / MAX(COUNT(*), 1)) * 100`)\n        .toQuery();\n    }\n\n    return this.knex\n      .raw(\n        `(COUNT(DISTINCT json_extract(${this.tableColumnRef}, '$.id')) * 1.0 / MAX(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n  dateRangeOfDays(): string {\n    throw new NotImplementedException();\n  }\n\n  dateRangeOfMonths(): string {\n    throw new NotImplementedException();\n  }\n\n  totalAttachmentSize(): string {\n    // Sum sizes per row, then sum across the current scope (respects GROUP BY)\n    return this.knex\n      .raw(\n        `SUM(COALESCE((SELECT SUM(json_extract(j.value, '$.size'))\n          FROM json_each(COALESCE(${this.tableColumnRef}, '[]')) AS j), 0))`\n      )\n      .toQuery();\n  }\n\n  percentEmpty(): string {\n    return this.knex\n      .raw(`((COUNT(*) - COUNT(${this.tableColumnRef})) * 1.0 / MAX(COUNT(*), 1)) * 100`)\n      .toQuery();\n  }\n\n  percentFilled(): string {\n    return this.knex\n      .raw(`(COUNT(${this.tableColumnRef}) * 1.0 / MAX(COUNT(*), 1)) * 100`)\n      .toQuery();\n  }\n\n  checked(): string {\n    return this.knex.raw(`SUM(CASE WHEN ${this.tableColumnRef} = 1 THEN 1 ELSE 0 END)`).toQuery();\n  }\n\n  unChecked(): string {\n    return this.knex\n      .raw(\n        `SUM(CASE WHEN ${this.tableColumnRef} = 0 OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END)`\n      )\n      .toQuery();\n  }\n\n  percentChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} = 1 THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n\n  percentUnChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} = 0 OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-query.sqlite.ts",
    "content": "import type { FieldCore } from '@teable/core';\nimport { AbstractAggregationQuery } from '../aggregation-query.abstract';\nimport type { AggregationFunctionSqlite } from './aggregation-function.sqlite';\nimport { MultipleValueAggregationAdapter } from './multiple-value/multiple-value-aggregation.adapter';\nimport { SingleValueAggregationAdapter } from './single-value/single-value-aggregation.adapter';\n\nexport class AggregationQuerySqlite extends AbstractAggregationQuery {\n  private coreAggregation(field: FieldCore): AggregationFunctionSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleValueAggregationAdapter(this.knex, field, this.context);\n    }\n    return new SingleValueAggregationAdapter(this.knex, field, this.context);\n  }\n\n  booleanAggregation(field: FieldCore): AggregationFunctionSqlite {\n    return this.coreAggregation(field);\n  }\n\n  numberAggregation(field: FieldCore): AggregationFunctionSqlite {\n    return this.coreAggregation(field);\n  }\n\n  dateTimeAggregation(field: FieldCore): AggregationFunctionSqlite {\n    return this.coreAggregation(field);\n  }\n\n  stringAggregation(field: FieldCore): AggregationFunctionSqlite {\n    return this.coreAggregation(field);\n  }\n\n  jsonAggregation(field: FieldCore): AggregationFunctionSqlite {\n    return this.coreAggregation(field);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/multiple-value/multiple-value-aggregation.adapter.ts",
    "content": "import { AggregationFunctionSqlite } from '../aggregation-function.sqlite';\n\nexport class MultipleValueAggregationAdapter extends AggregationFunctionSqlite {\n  unique(): string {\n    return this.knex\n      .raw(\n        `SELECT COUNT(DISTINCT json_each.value) as value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  max(): string {\n    return this.knex\n      .raw(\n        `SELECT MAX(json_each.value) as value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  min(): string {\n    return this.knex\n      .raw(\n        `SELECT MIN(json_each.value) as value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  sum(): string {\n    return this.knex\n      .raw(\n        `SELECT SUM(json_each.value) as value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  average(): string {\n    return this.knex\n      .raw(\n        `SELECT AVG(json_each.value) as value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  percentUnique(): string {\n    return this.knex\n      .raw(\n        `SELECT (COUNT(DISTINCT json_each.value) * 1.0 / MAX(COUNT(*), 1)) * 100 AS value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  dateRangeOfDays(): string {\n    return this.knex\n      .raw(\n        `SELECT CAST(julianday(MAX(json_each.value)) - julianday(MIN(json_each.value)) AS INTEGER) AS value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  dateRangeOfMonths(): string {\n    return this.knex\n      .raw(\n        `SELECT MAX(json_each.value) || ',' || MIN(json_each.value) AS value FROM ?? as \"${this.tableAlias}\", json_each(${this.tableColumnRef})`,\n        [this.dbTableName]\n      )\n      .toQuery();\n  }\n\n  checked(): string {\n    return this.knex\n      .raw(\n        `SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END)`\n      )\n      .toQuery();\n  }\n\n  unChecked(): string {\n    return this.knex\n      .raw(\n        `SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END)`\n      )\n      .toQuery();\n  }\n\n  percentChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n\n  percentUnChecked(): string {\n    return this.knex\n      .raw(\n        `(SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/single-value/single-value-aggregation.adapter.ts",
    "content": "import { AggregationFunctionSqlite } from '../aggregation-function.sqlite';\n\nexport class SingleValueAggregationAdapter extends AggregationFunctionSqlite {\n  dateRangeOfDays(): string {\n    return this.knex\n      .raw(\n        `CAST(julianday(MAX(${this.tableColumnRef})) - julianday(MIN(${this.tableColumnRef})) as INTEGER)`\n      )\n      .toQuery();\n  }\n\n  dateRangeOfMonths(): string {\n    return this.knex\n      .raw(`MAX(${this.tableColumnRef}) || ',' || MIN(${this.tableColumnRef})`)\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/base-query/abstract.ts",
    "content": "import type { Knex } from 'knex';\n\nexport abstract class BaseQueryAbstract {\n  constructor(protected readonly knex: Knex) {}\n\n  abstract jsonSelect(\n    queryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    alias: string\n  ): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/base-query/base-query.postgres.ts",
    "content": "import type { Knex } from 'knex';\nimport { BaseQueryAbstract } from './abstract';\n\nexport class BaseQueryPostgres extends BaseQueryAbstract {\n  constructor(protected readonly knex: Knex) {\n    super(knex);\n  }\n\n  jsonSelect(\n    queryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    alias: string\n  ): Knex.QueryBuilder {\n    // dbFieldName is a pre-quoted qualified identifier path\n    return queryBuilder.select(this.knex.raw(`MAX(${dbFieldName}::text) AS ??`, [alias]));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/base-query/base-query.sqlite.ts",
    "content": "import type { Knex } from 'knex';\nimport { BaseQueryAbstract } from './abstract';\n\nexport class BaseQuerySqlite extends BaseQueryAbstract {\n  constructor(protected readonly knex: Knex) {\n    super(knex);\n  }\n\n  jsonSelect(\n    queryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    alias: string\n  ): Knex.QueryBuilder {\n    return queryBuilder.select(this.knex.raw(`MAX(??) AS ??`, [dbFieldName, alias]));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.interface.ts",
    "content": "import type { TableDomain } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IDbProvider } from '../db.provider.interface';\n\n/**\n * Context interface for database column creation\n */\nexport interface ICreateDatabaseColumnContext {\n  /** Knex table builder instance */\n  table: Knex.CreateTableBuilder;\n  tableDomain: TableDomain;\n  /** Field ID */\n  fieldId: string;\n  /** the Field instance to add */\n  field: IFieldInstance;\n  /** Database field name */\n  dbFieldName: string;\n  /** Whether the field is unique */\n  unique?: boolean;\n  /** Whether the field is not null */\n  notNull?: boolean;\n  /** Database provider for formula conversion */\n  dbProvider?: IDbProvider;\n  /** Whether this is a new table creation (affects SQLite generated columns) */\n  isNewTable?: boolean;\n  /** Current table ID (for link field foreign key creation) */\n  tableId: string;\n  /** Current table name (for link field foreign key creation) */\n  tableName: string;\n  /** Knex instance (for link field foreign key creation) */\n  knex: Knex;\n  /** Table name mapping for foreign key creation (tableId -> dbTableName) */\n  tableNameMap: Map<string, string>;\n  /** Whether this is a symmetric field (should not create foreign key structures) */\n  isSymmetricField?: boolean;\n  /** When true, do not create the base column for Link fields (FK/junction only). */\n  skipBaseColumnCreation?: boolean;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts",
    "content": "import type {\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  CheckboxFieldCore,\n  CreatedByFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  FormulaFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  LinkFieldCore,\n  LongTextFieldCore,\n  MultipleSelectFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  RollupFieldCore,\n  ConditionalRollupFieldCore,\n  SingleLineTextFieldCore,\n  SingleSelectFieldCore,\n  UserFieldCore,\n  IFieldVisitor,\n  FieldCore,\n  ILinkFieldOptions,\n  ButtonFieldCore,\n} from '@teable/core';\nimport { DbFieldType, Relationship } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { AutoNumberFieldDto } from '../../features/field/model/field-dto/auto-number-field.dto';\nimport type { CreatedByFieldDto } from '../../features/field/model/field-dto/created-by-field.dto';\nimport type { CreatedTimeFieldDto } from '../../features/field/model/field-dto/created-time-field.dto';\nimport type { FormulaFieldDto } from '../../features/field/model/field-dto/formula-field.dto';\nimport type { LastModifiedByFieldDto } from '../../features/field/model/field-dto/last-modified-by-field.dto';\nimport type { LastModifiedTimeFieldDto } from '../../features/field/model/field-dto/last-modified-time-field.dto';\nimport type { LinkFieldDto } from '../../features/field/model/field-dto/link-field.dto';\nimport { SchemaType } from '../../features/field/util';\nimport type { IFormulaConversionContext } from '../../features/record/query-builder/sql-conversion.visitor';\nimport { GeneratedColumnQuerySupportValidatorPostgres } from '../generated-column-query/postgres/generated-column-query-support-validator.postgres';\nimport type { ICreateDatabaseColumnContext } from './create-database-column-field-visitor.interface';\nimport { validateGeneratedColumnSupport } from './create-database-column-field.util';\n\n/**\n * PostgreSQL implementation of database column visitor.\n */\nexport class CreatePostgresDatabaseColumnFieldVisitor implements IFieldVisitor<void> {\n  private sql: string[] = [];\n\n  constructor(private readonly context: ICreateDatabaseColumnContext) {}\n\n  getSql(): string[] {\n    return this.sql;\n  }\n\n  private getSchemaType(dbFieldType: DbFieldType): SchemaType {\n    switch (dbFieldType) {\n      case DbFieldType.Blob:\n        return SchemaType.Binary;\n      case DbFieldType.Integer:\n        return SchemaType.Integer;\n      case DbFieldType.Json:\n        // PostgreSQL supports native JSONB\n        return SchemaType.Jsonb;\n      case DbFieldType.Real:\n        return SchemaType.Double;\n      case DbFieldType.Text:\n        return SchemaType.Text;\n      case DbFieldType.DateTime:\n        return SchemaType.Datetime;\n      case DbFieldType.Boolean:\n        return SchemaType.Boolean;\n      default:\n        throw new Error(`Unsupported DbFieldType: ${dbFieldType}`);\n    }\n  }\n\n  private createStandardColumn(field: FieldCore): void {\n    const schemaType = this.getSchemaType(field.dbFieldType);\n    const column = this.context.table[schemaType](this.context.dbFieldName);\n\n    if (this.context.notNull) {\n      column.notNullable();\n    }\n\n    if (this.context.unique) {\n      column.unique();\n    }\n  }\n\n  private createFormulaColumns(field: FormulaFieldCore): void {\n    const formulaFieldDto = this.context.field as FormulaFieldDto;\n    const clearPersistedGeneratedMeta = () => {\n      formulaFieldDto.meta = undefined;\n    };\n    // Never persist lookup formulas as generated columns; they may be multi-valued (JSON)\n    // and/or depend on link/lookup resolution logic not suitable for generated columns.\n    if (field.isLookup || field.isMultipleCellValue) {\n      clearPersistedGeneratedMeta();\n      this.createStandardColumn(field);\n      return;\n    }\n    if (this.context.dbProvider) {\n      const generatedColumnName = field.getGeneratedColumnName();\n      const columnType = this.getPostgresColumnType(field.dbFieldType);\n\n      const expression = field.getExpression();\n\n      // Skip if no expression\n      if (!expression) {\n        // Fallback to a standard column if no expression\n        clearPersistedGeneratedMeta();\n        this.createStandardColumn(field);\n        return;\n      }\n\n      // Check if the formula is supported for generated columns\n      const supportValidator = new GeneratedColumnQuerySupportValidatorPostgres();\n      const isSupported = validateGeneratedColumnSupport(\n        field,\n        supportValidator,\n        this.context.tableDomain\n      );\n\n      if (isSupported) {\n        const conversionContext: IFormulaConversionContext = {\n          table: this.context.tableDomain,\n          isGeneratedColumn: true, // Mark this as a generated column context\n        };\n\n        const conversionResult = this.context.dbProvider.convertFormulaToGeneratedColumn(\n          expression,\n          conversionContext\n        );\n\n        // Create generated column using specificType\n        // PostgreSQL syntax: GENERATED ALWAYS AS (expression) STORED\n        const generatedColumnDefinition = `${columnType} GENERATED ALWAYS AS (${conversionResult.sql}) STORED`;\n\n        this.context.table.specificType(generatedColumnName, generatedColumnDefinition);\n        (this.context.field as FormulaFieldDto).setMetadata({ persistedAsGeneratedColumn: true });\n        return;\n      }\n    }\n    // Fallback: create a standard column when not supported as generated\n    clearPersistedGeneratedMeta();\n    this.createStandardColumn(field);\n  }\n\n  private getPostgresColumnType(dbFieldType: DbFieldType): string {\n    switch (dbFieldType) {\n      case DbFieldType.Text:\n        return 'TEXT';\n      case DbFieldType.Integer:\n        return 'INTEGER';\n      case DbFieldType.Real:\n        return 'DOUBLE PRECISION';\n      case DbFieldType.Boolean:\n        return 'BOOLEAN';\n      case DbFieldType.DateTime:\n        return 'TIMESTAMP';\n      case DbFieldType.Json:\n        return 'JSONB';\n      case DbFieldType.Blob:\n        return 'BYTEA';\n      default:\n        return 'TEXT';\n    }\n  }\n\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitSingleLineTextField(field: SingleLineTextFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitLongTextField(field: LongTextFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitAttachmentField(field: AttachmentFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitCheckboxField(field: CheckboxFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitDateField(field: DateFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitRatingField(field: RatingFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitAutoNumberField(_field: AutoNumberFieldCore): void {\n    this.context.table.specificType(\n      this.context.dbFieldName,\n      'INTEGER GENERATED ALWAYS AS (__auto_number) STORED'\n    );\n    (this.context.field as AutoNumberFieldDto).setMetadata({\n      persistedAsGeneratedColumn: true,\n    });\n  }\n\n  visitLinkField(field: LinkFieldCore): void {\n    // Determine potential conflicts with FK column names (including inferred defaults)\n    const opts = field.options as ILinkFieldOptions;\n    const conflictNames = new Set<string>();\n    const rel = opts?.relationship;\n    const inferredFkName =\n      opts?.foreignKeyName ??\n      (rel === Relationship.ManyOne || rel === Relationship.OneOne\n        ? this.context.dbFieldName\n        : undefined);\n    const inferredSelfName =\n      opts?.selfKeyName ??\n      (rel === Relationship.OneMany && opts?.isOneWay === false\n        ? this.context.dbFieldName\n        : undefined);\n    if (inferredFkName) conflictNames.add(inferredFkName);\n    if (inferredSelfName) conflictNames.add(inferredSelfName);\n\n    // Create underlying base column only if no conflict with FK/self columns\n    if (!this.context.skipBaseColumnCreation && !conflictNames.has(this.context.dbFieldName)) {\n      this.createStandardColumn(field);\n    }\n\n    // For real link structures, create FK/junction artifacts on non-symmetric side\n    if (field.isLookup) return;\n    if (this.context.isSymmetricField || this.isSymmetricField(field)) return;\n    this.createForeignKeyForLinkField(field);\n  }\n\n  private isSymmetricField(_field: LinkFieldCore): boolean {\n    // A field is symmetric if it has a symmetricFieldId that points to an existing field\n    // In practice, when creating symmetric fields, they are created after the main field\n    // So we can check if this field's symmetricFieldId exists in the database\n    // For now, we'll rely on the isSymmetricField context flag\n    return false;\n  }\n\n  private createForeignKeyForLinkField(field: LinkFieldCore): void {\n    const options = field.options as ILinkFieldOptions;\n    const { relationship, fkHostTableName, selfKeyName, foreignKeyName, isOneWay, foreignTableId } =\n      options;\n\n    if (\n      !this.context.knex ||\n      !this.context.tableId ||\n      !this.context.tableName ||\n      !this.context.tableNameMap\n    ) {\n      return;\n    }\n\n    // Get table names from context\n    const dbTableName = this.context.tableName;\n    const foreignDbTableName = this.context.tableNameMap.get(foreignTableId);\n\n    if (!foreignDbTableName) {\n      throw new Error(`Foreign table not found: ${foreignTableId}`);\n    }\n\n    let alterTableSchema: Knex.SchemaBuilder | undefined;\n\n    if (relationship === Relationship.ManyMany) {\n      alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {\n        table.increments('__id').primary();\n        table\n          .string(selfKeyName)\n          .references('__id')\n          .inTable(dbTableName)\n          .withKeyName(`fk_${selfKeyName}`);\n        table\n          .string(foreignKeyName)\n          .references('__id')\n          .inTable(foreignDbTableName)\n          .withKeyName(`fk_${foreignKeyName}`);\n        // Add order column for maintaining insertion order\n        table.integer('__order').nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n        table\n          .string(foreignKeyName)\n          .references('__id')\n          .inTable(foreignDbTableName)\n          .withKeyName(`fk_${foreignKeyName}`);\n        // Add order column for maintaining insertion order\n        table.integer(`${foreignKeyName}_order`).nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (relationship === Relationship.OneMany) {\n      if (isOneWay) {\n        alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {\n          table.increments('__id').primary();\n          table\n            .string(selfKeyName)\n            .references('__id')\n            .inTable(dbTableName)\n            .withKeyName(`fk_${selfKeyName}`);\n          table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);\n          table.unique([selfKeyName, foreignKeyName], {\n            indexName: `index_${selfKeyName}_${foreignKeyName}`,\n          });\n        });\n      } else {\n        alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n          table\n            .string(selfKeyName)\n            .references('__id')\n            .inTable(dbTableName)\n            .withKeyName(`fk_${selfKeyName}`);\n          // Add order column for maintaining insertion order\n          table.integer(`${selfKeyName}_order`).nullable();\n        });\n        // Set metadata to indicate this field has order column\n        (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n      }\n    }\n\n    // assume options is from the main field (user created one)\n    if (relationship === Relationship.OneOne) {\n      alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n        if (foreignKeyName === '__id') {\n          throw new Error('can not use __id for foreignKeyName');\n        }\n        table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);\n        table.unique([foreignKeyName], {\n          indexName: `index_${foreignKeyName}`,\n        });\n        // Add order column for maintaining insertion order\n        table.integer(`${foreignKeyName}_order`).nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (!alterTableSchema) {\n      throw new Error('alterTableSchema is undefined');\n    }\n\n    // Store the SQL queries to be executed later\n    for (const sql of alterTableSchema.toSQL()) {\n      // skip sqlite pragma\n      if (sql.sql.startsWith('PRAGMA')) {\n        continue;\n      }\n      this.sql.push(sql.sql);\n    }\n  }\n\n  visitRollupField(field: RollupFieldCore): void {\n    // Always create an underlying base column for rollup fields\n    this.createStandardColumn(field);\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  // Select field types\n  visitSingleSelectField(field: SingleSelectFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitButtonField(field: ButtonFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  // Formula field types\n  visitFormulaField(field: FormulaFieldCore): void {\n    this.createFormulaColumns(field);\n  }\n\n  visitCreatedTimeField(field: CreatedTimeFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    this.context.table.specificType(\n      this.context.dbFieldName,\n      'TIMESTAMP GENERATED ALWAYS AS (__created_time) STORED'\n    );\n    (this.context.field as CreatedTimeFieldDto).setMetadata({\n      persistedAsGeneratedColumn: true,\n    });\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    const trackAll = field.isTrackAll();\n\n    if (trackAll) {\n      this.context.table.specificType(\n        this.context.dbFieldName,\n        'TIMESTAMP GENERATED ALWAYS AS (__last_modified_time) STORED'\n      );\n      (this.context.field as LastModifiedTimeFieldDto).setMetadata({\n        persistedAsGeneratedColumn: true,\n      });\n      return;\n    }\n\n    this.context.table.timestamp(this.context.dbFieldName, { useTz: true });\n    (this.context.field as LastModifiedTimeFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n\n  // User field types\n  visitUserField(field: UserFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitCreatedByField(field: CreatedByFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    // Persist as a JSON column (stores collaborator payload)\n    this.createStandardColumn(field);\n    (this.context.field as CreatedByFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n\n  visitLastModifiedByField(field: LastModifiedByFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    // Persist as a JSON column (stores collaborator payload)\n    this.createStandardColumn(field);\n    (this.context.field as LastModifiedByFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.sqlite.ts",
    "content": "import type {\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  CheckboxFieldCore,\n  CreatedByFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  FormulaFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  LinkFieldCore,\n  LongTextFieldCore,\n  MultipleSelectFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  RollupFieldCore,\n  ConditionalRollupFieldCore,\n  SingleLineTextFieldCore,\n  SingleSelectFieldCore,\n  UserFieldCore,\n  IFieldVisitor,\n  FieldCore,\n  ILinkFieldOptions,\n  ButtonFieldCore,\n} from '@teable/core';\nimport { DbFieldType, Relationship } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { AutoNumberFieldDto } from '../../features/field/model/field-dto/auto-number-field.dto';\nimport type { CreatedByFieldDto } from '../../features/field/model/field-dto/created-by-field.dto';\nimport type { CreatedTimeFieldDto } from '../../features/field/model/field-dto/created-time-field.dto';\nimport type { FormulaFieldDto } from '../../features/field/model/field-dto/formula-field.dto';\nimport type { LastModifiedByFieldDto } from '../../features/field/model/field-dto/last-modified-by-field.dto';\nimport type { LastModifiedTimeFieldDto } from '../../features/field/model/field-dto/last-modified-time-field.dto';\nimport type { LinkFieldDto } from '../../features/field/model/field-dto/link-field.dto';\nimport { SchemaType } from '../../features/field/util';\nimport type { IFormulaConversionContext } from '../../features/record/query-builder/sql-conversion.visitor';\nimport { GeneratedColumnQuerySupportValidatorSqlite } from '../generated-column-query/sqlite/generated-column-query-support-validator.sqlite';\nimport type { ICreateDatabaseColumnContext } from './create-database-column-field-visitor.interface';\nimport { validateGeneratedColumnSupport } from './create-database-column-field.util';\n\n/**\n * SQLite implementation of database column visitor.\n */\nexport class CreateSqliteDatabaseColumnFieldVisitor implements IFieldVisitor<void> {\n  private sql: string[] = [];\n\n  constructor(private readonly context: ICreateDatabaseColumnContext) {}\n\n  getSql(): string[] {\n    return this.sql;\n  }\n\n  private getSchemaType(dbFieldType: DbFieldType): SchemaType {\n    switch (dbFieldType) {\n      case DbFieldType.Blob:\n        return SchemaType.Binary;\n      case DbFieldType.Integer:\n        return SchemaType.Integer;\n      case DbFieldType.Json:\n        // SQLite stores JSON as TEXT\n        return SchemaType.Text;\n      case DbFieldType.Real:\n        return SchemaType.Double;\n      case DbFieldType.Text:\n        return SchemaType.Text;\n      case DbFieldType.DateTime:\n        return SchemaType.Datetime;\n      case DbFieldType.Boolean:\n        return SchemaType.Boolean;\n      default:\n        throw new Error(`Unsupported DbFieldType: ${dbFieldType}`);\n    }\n  }\n\n  private createStandardColumn(field: FieldCore): void {\n    const schemaType = this.getSchemaType(field.dbFieldType);\n    const column = this.context.table[schemaType](this.context.dbFieldName);\n\n    if (this.context.notNull) {\n      column.notNullable();\n    }\n\n    if (this.context.unique) {\n      column.unique();\n    }\n  }\n\n  private createFormulaColumns(field: FormulaFieldCore): void {\n    const formulaFieldDto = this.context.field as FormulaFieldDto;\n    const clearPersistedGeneratedMeta = () => {\n      formulaFieldDto.meta = undefined;\n    };\n    if (this.context.dbProvider) {\n      const generatedColumnName = field.getGeneratedColumnName();\n      const columnType = this.getSqliteColumnType(field.dbFieldType);\n\n      // Use original expression since expansion logic has been moved\n      const expressionToConvert = field.options.expression;\n      // Skip if no expression\n      if (!expressionToConvert) {\n        // Fallback to a standard column if no expression\n        clearPersistedGeneratedMeta();\n        this.createStandardColumn(field);\n        return;\n      }\n\n      // Check if the formula is supported for generated columns\n      const supportValidator = new GeneratedColumnQuerySupportValidatorSqlite();\n      const isSupported = validateGeneratedColumnSupport(\n        field,\n        supportValidator,\n        this.context.tableDomain\n      );\n\n      if (isSupported) {\n        const conversionContext: IFormulaConversionContext = {\n          table: this.context.tableDomain,\n          isGeneratedColumn: true, // Mark this as a generated column context\n        };\n\n        const conversionResult = this.context.dbProvider.convertFormulaToGeneratedColumn(\n          expressionToConvert,\n          conversionContext\n        );\n\n        // Create generated column using specificType\n        // SQLite syntax: GENERATED ALWAYS AS (expression) VIRTUAL/STORED\n        // Note: For ALTER TABLE operations, SQLite doesn't support STORED generated columns\n        const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';\n        const notNullClause = this.context.notNull ? ' NOT NULL' : '';\n        const generatedColumnDefinition = `${columnType} GENERATED ALWAYS AS (${conversionResult.sql}) ${storageType}${notNullClause}`;\n\n        this.context.table.specificType(generatedColumnName, generatedColumnDefinition);\n        (this.context.field as FormulaFieldDto).setMetadata({ persistedAsGeneratedColumn: true });\n        return;\n      }\n    }\n    // Fallback: create a standard column when not supported as generated\n    clearPersistedGeneratedMeta();\n    this.createStandardColumn(field);\n  }\n\n  private getSqliteColumnType(dbFieldType: DbFieldType): string {\n    switch (dbFieldType) {\n      case DbFieldType.Text:\n        return 'TEXT';\n      case DbFieldType.Integer:\n        return 'INTEGER';\n      case DbFieldType.Real:\n        return 'REAL';\n      case DbFieldType.Boolean:\n        return 'INTEGER'; // SQLite uses INTEGER for boolean\n      case DbFieldType.DateTime:\n        return 'TEXT'; // SQLite stores datetime as TEXT\n      case DbFieldType.Json:\n        return 'TEXT'; // SQLite stores JSON as TEXT\n      case DbFieldType.Blob:\n        return 'BLOB';\n      default:\n        return 'TEXT';\n    }\n  }\n\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitSingleLineTextField(field: SingleLineTextFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitLongTextField(field: LongTextFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitAttachmentField(field: AttachmentFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitCheckboxField(field: CheckboxFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitDateField(field: DateFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitRatingField(field: RatingFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitAutoNumberField(_field: AutoNumberFieldCore): void {\n    // SQLite syntax: GENERATED ALWAYS AS (expression) STORED/VIRTUAL\n    // For ALTER TABLE operations, SQLite doesn't support STORED generated columns, so use VIRTUAL\n    const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';\n    this.context.table.specificType(\n      this.context.dbFieldName,\n      `INTEGER GENERATED ALWAYS AS (__auto_number) ${storageType}`\n    );\n    (this.context.field as AutoNumberFieldDto).setMetadata({\n      persistedAsGeneratedColumn: true,\n    });\n  }\n\n  visitLinkField(field: LinkFieldCore): void {\n    // Ensure underlying column representation for link fields unless conflicts with FK column names\n    const opts = field.options as ILinkFieldOptions;\n    const conflictNames = new Set<string>();\n    const rel = opts?.relationship;\n    const inferredFkName =\n      opts?.foreignKeyName ??\n      (rel === Relationship.ManyOne || rel === Relationship.OneOne\n        ? this.context.dbFieldName\n        : undefined);\n    const inferredSelfName =\n      opts?.selfKeyName ??\n      (rel === Relationship.OneMany && opts?.isOneWay === false\n        ? this.context.dbFieldName\n        : undefined);\n    if (inferredFkName) conflictNames.add(inferredFkName);\n    if (inferredSelfName) conflictNames.add(inferredSelfName);\n\n    if (!this.context.skipBaseColumnCreation && !conflictNames.has(this.context.dbFieldName)) {\n      this.createStandardColumn(field);\n    }\n\n    if (field.isLookup) return;\n    if (this.context.isSymmetricField || this.isSymmetricField(field)) return;\n    this.createForeignKeyForLinkField(field);\n  }\n\n  private isSymmetricField(_field: LinkFieldCore): boolean {\n    // A field is symmetric if it has a symmetricFieldId that points to an existing field\n    // In practice, when creating symmetric fields, they are created after the main field\n    // So we can check if this field's symmetricFieldId exists in the database\n    // For now, we'll rely on the isSymmetricField context flag\n    return false;\n  }\n\n  private createForeignKeyForLinkField(field: LinkFieldCore): void {\n    const options = field.options as ILinkFieldOptions;\n    const { relationship, fkHostTableName, selfKeyName, foreignKeyName, isOneWay, foreignTableId } =\n      options;\n\n    if (\n      !this.context.knex ||\n      !this.context.tableId ||\n      !this.context.tableName ||\n      !this.context.tableNameMap\n    ) {\n      return;\n    }\n\n    // Get table names from context\n    const dbTableName = this.context.tableName;\n    const foreignDbTableName = this.context.tableNameMap.get(foreignTableId);\n\n    if (!foreignDbTableName) {\n      throw new Error(`Foreign table not found: ${foreignTableId}`);\n    }\n\n    let alterTableSchema: Knex.SchemaBuilder | undefined;\n\n    if (relationship === Relationship.ManyMany) {\n      alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {\n        table.increments('__id').primary();\n        table\n          .string(selfKeyName)\n          .references('__id')\n          .inTable(dbTableName)\n          .withKeyName(`fk_${selfKeyName}`);\n        table\n          .string(foreignKeyName)\n          .references('__id')\n          .inTable(foreignDbTableName)\n          .withKeyName(`fk_${foreignKeyName}`);\n        // Add order column for maintaining insertion order\n        table.integer('__order').nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n        table\n          .string(foreignKeyName)\n          .references('__id')\n          .inTable(foreignDbTableName)\n          .withKeyName(`fk_${foreignKeyName}`);\n        // Add order column for maintaining insertion order\n        table.integer(`${foreignKeyName}_order`).nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (relationship === Relationship.OneMany) {\n      if (isOneWay) {\n        alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {\n          table.increments('__id').primary();\n          table\n            .string(selfKeyName)\n            .references('__id')\n            .inTable(dbTableName)\n            .withKeyName(`fk_${selfKeyName}`);\n          table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);\n          table.unique([selfKeyName, foreignKeyName], {\n            indexName: `index_${selfKeyName}_${foreignKeyName}`,\n          });\n        });\n      } else {\n        alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n          table\n            .string(selfKeyName)\n            .references('__id')\n            .inTable(dbTableName)\n            .withKeyName(`fk_${selfKeyName}`);\n          // Add order column for maintaining insertion order\n          table.integer(`${selfKeyName}_order`).nullable();\n        });\n        // Set metadata to indicate this field has order column\n        (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n      }\n    }\n\n    // assume options is from the main field (user created one)\n    if (relationship === Relationship.OneOne) {\n      alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {\n        if (foreignKeyName === '__id') {\n          throw new Error('can not use __id for foreignKeyName');\n        }\n        table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);\n        table.unique([foreignKeyName], {\n          indexName: `index_${foreignKeyName}`,\n        });\n        // Add order column for maintaining insertion order\n        table.integer(`${foreignKeyName}_order`).nullable();\n      });\n      // Set metadata to indicate this field has order column\n      (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });\n    }\n\n    if (!alterTableSchema) {\n      throw new Error('alterTableSchema is undefined');\n    }\n\n    // Store the SQL queries to be executed later\n    for (const sqlObj of alterTableSchema.toSQL()) {\n      // skip sqlite pragma\n      if (sqlObj.sql.startsWith('PRAGMA')) {\n        continue;\n      }\n      this.sql.push(sqlObj.sql);\n    }\n  }\n\n  visitRollupField(field: RollupFieldCore): void {\n    // Always create an underlying base column for rollup fields\n    this.createStandardColumn(field);\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  // Select field types\n  visitSingleSelectField(field: SingleSelectFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  // Formula field types\n  visitFormulaField(field: FormulaFieldCore): void {\n    this.createFormulaColumns(field);\n  }\n\n  visitButtonField(field: ButtonFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitCreatedTimeField(field: CreatedTimeFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';\n    this.context.table.specificType(\n      this.context.dbFieldName,\n      `TEXT GENERATED ALWAYS AS (__created_time) ${storageType}`\n    );\n    (this.context.field as CreatedTimeFieldDto).setMetadata({\n      persistedAsGeneratedColumn: true,\n    });\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    const trackAll = field.isTrackAll();\n    if (trackAll) {\n      const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';\n      this.context.table.specificType(\n        this.context.dbFieldName,\n        `TEXT GENERATED ALWAYS AS (__last_modified_time) ${storageType}`\n      );\n      (this.context.field as LastModifiedTimeFieldDto).setMetadata({\n        persistedAsGeneratedColumn: true,\n      });\n      return;\n    }\n\n    this.createStandardColumn(field);\n    (this.context.field as LastModifiedTimeFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n\n  // User field types\n  visitUserField(field: UserFieldCore): void {\n    this.createStandardColumn(field);\n  }\n\n  visitCreatedByField(field: CreatedByFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    // Persist as a JSON column (stores collaborator payload)\n    this.createStandardColumn(field);\n    (this.context.field as CreatedByFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n\n  visitLastModifiedByField(field: LastModifiedByFieldCore): void {\n    if (field.isLookup) {\n      this.createStandardColumn(field);\n      return;\n    }\n    // Persist as a JSON column (stores collaborator payload)\n    this.createStandardColumn(field);\n    (this.context.field as LastModifiedByFieldDto).setMetadata({\n      persistedAsGeneratedColumn: false,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field.util.ts",
    "content": "import type { FormulaFieldCore, TableDomain } from '@teable/core';\nimport type { IGeneratedColumnQuerySupportValidator } from '../../features/record/query-builder/sql-conversion.visitor';\n\nexport function validateGeneratedColumnSupport(\n  _field: FormulaFieldCore,\n  _supportValidator: IGeneratedColumnQuerySupportValidator,\n  _tableDomain: TableDomain\n): boolean {\n  // Temporarily disable persisting formulas as generated columns to avoid\n  // PostgreSQL restrictions (e.g., subqueries) that surface during field\n  // creation/duplication. All formulas should be computed via the runtime\n  // pipeline instead of database generated columns.\n  return false;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/create-database-column-query/index.ts",
    "content": "export * from './create-database-column-field-visitor.interface';\nexport * from './create-database-column-field-visitor.postgres';\nexport * from './create-database-column-field-visitor.sqlite';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/db.provider.interface.ts",
    "content": "import type {\n  DriverClient,\n  FieldCore,\n  FieldType,\n  IFilter,\n  ILookupLinkOptionsVo,\n  ISortItem,\n  TableDomain,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../features/field/model/factory';\nimport type { DateFieldDto } from '../features/field/model/field-dto/date-field.dto';\nimport type { IFieldSelectName } from '../features/record/query-builder/field-select.type';\nimport type {\n  IRecordQueryFilterContext,\n  IRecordQuerySortContext,\n  IRecordQueryGroupContext,\n  IRecordQueryAggregateContext,\n} from '../features/record/query-builder/record-query-builder.interface';\nimport type {\n  IFormulaConversionContext,\n  IFormulaConversionResult,\n  IGeneratedColumnQueryInterface,\n  ISelectFormulaConversionContext,\n  ISelectQueryInterface,\n} from '../features/record/query-builder/sql-conversion.visitor';\nimport type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';\nimport type { BaseQueryAbstract } from './base-query/abstract';\nimport type { DropColumnOperationType } from './drop-database-column-query/drop-database-column-field-visitor.interface';\nimport type { DuplicateTableQueryAbstract } from './duplicate-table/abstract';\nimport type { DuplicateAttachmentTableQueryAbstract } from './duplicate-table/duplicate-attachment-table-query.abstract';\nimport type { IFilterQueryInterface } from './filter-query/filter-query.interface';\nimport type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';\nimport type { IndexBuilderAbstract } from './index-query/index-abstract-builder';\nimport type { IntegrityQueryAbstract } from './integrity-query/abstract';\nimport type { ISortQueryInterface } from './sort-query/sort-query.interface';\n\nexport type IFilterQueryExtra = {\n  withUserId?: string;\n\n  [key: string]: unknown;\n};\n\nexport type ISortQueryExtra = {\n  [key: string]: unknown;\n};\n\nexport type IAggregationQueryExtra = { filter?: IFilter; groupBy?: string[] } & IFilterQueryExtra;\n\nexport type ICalendarDailyCollectionQueryProps = {\n  startDate: string;\n  endDate: string;\n  startField: DateFieldDto;\n  endField: DateFieldDto;\n  dbTableName: string;\n};\n\nexport interface IDbProvider {\n  driver: DriverClient;\n\n  createSchema(schemaName: string): string[] | undefined;\n\n  dropSchema(schemaName: string): string | undefined;\n\n  generateDbTableName(baseId: string, name: string): string;\n\n  renameTableName(oldTableName: string, newTableName: string): string[];\n\n  getForeignKeysInfo(dbTableName: string): string;\n\n  dropTable(tableName: string): string;\n\n  renameColumn(tableName: string, oldName: string, newName: string): string[];\n\n  dropColumn(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> },\n    operationType?: DropColumnOperationType\n  ): string[];\n\n  updateJsonColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string;\n\n  updateJsonArrayColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string;\n\n  // sql response format: { name: string }[], name for columnName.\n  columnInfo(tableName: string): string;\n\n  checkColumnExist(\n    tableName: string,\n    columnName: string,\n    prisma: Prisma.TransactionClient\n  ): Promise<boolean>;\n\n  checkTableExist(tableName: string): string;\n\n  dropColumnAndIndex(tableName: string, columnName: string, indexName: string): string[];\n\n  modifyColumnSchema(\n    tableName: string,\n    oldFieldInstance: IFieldInstance,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> }\n  ): string[];\n\n  createColumnSchema(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    isNewTable: boolean,\n    tableId: string,\n    tableNameMap: Map<string, string>,\n    isSymmetricField?: boolean,\n    skipBaseColumnCreation?: boolean\n  ): string[];\n\n  duplicateTable(\n    fromSchema: string,\n    toSchema: string,\n    tableName: string,\n    withData?: boolean\n  ): string;\n\n  alterAutoNumber(tableName: string): string[];\n\n  batchInsertSql(tableName: string, insertData: ReadonlyArray<unknown>): string;\n\n  splitTableName(tableName: string): string[];\n\n  joinDbTableName(schemaName: string, dbTableName: string): string;\n\n  executeUpdateRecordsSqlList(params: {\n    dbTableName: string;\n    tempTableName: string;\n    idFieldName: string;\n    dbFieldNames: string[];\n    data: { id: string; values: { [key: string]: unknown } }[];\n  }): { insertTempTableSql: string; updateRecordSql: string };\n\n  updateFromSelectSql(params: {\n    dbTableName: string;\n    idFieldName: string;\n    subQuery: Knex.QueryBuilder;\n    dbFieldNames: string[];\n    returningDbFieldNames?: string[];\n    restrictRecordIds?: string[];\n  }): string;\n\n  lockRecordsSql?(params: {\n    dbTableName: string;\n    idFieldName: string;\n    recordIds: string[];\n  }): string | undefined;\n\n  aggregationQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    aggregationFields?: IAggregationField[],\n    extra?: IAggregationQueryExtra,\n    context?: IRecordQueryAggregateContext\n  ): IAggregationQueryInterface;\n\n  filterQuery(\n    originKnex: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    filter?: IFilter,\n    extra?: IFilterQueryExtra,\n    context?: IRecordQueryFilterContext\n  ): IFilterQueryInterface;\n\n  sortQuery(\n    originKnex: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    sortObjs?: ISortItem[],\n    extra?: ISortQueryExtra,\n    context?: IRecordQuerySortContext\n  ): ISortQueryInterface;\n\n  groupQuery(\n    originKnex: Knex.QueryBuilder,\n    fieldMap?: { [fieldId: string]: FieldCore },\n    groupFieldIds?: string[],\n    extra?: IGroupQueryExtra,\n    context?: IRecordQueryGroupContext\n  ): IGroupQueryInterface;\n\n  searchQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchFields: IFieldInstance[],\n    tableIndex: TableIndex[],\n    search: [string, string?, boolean?],\n    context?: IRecordQueryFilterContext\n  ): Knex.QueryBuilder;\n\n  searchIndexQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbTableName: string,\n    searchField: IFieldInstance[],\n    searchIndexRo: Partial<ISearchIndexByQueryRo>,\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext,\n    baseSortIndex?: string,\n    setFilterQuery?: (qb: Knex.QueryBuilder) => void,\n    setSortQuery?: (qb: Knex.QueryBuilder) => void\n  ): Knex.QueryBuilder;\n\n  searchCountQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchField: IFieldInstance[],\n    search: [string, string?, boolean?],\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext\n  ): Knex.QueryBuilder;\n\n  searchIndex(): IndexBuilderAbstract;\n\n  duplicateTableQuery(queryBuilder: Knex.QueryBuilder): DuplicateTableQueryAbstract;\n\n  duplicateAttachmentTableQuery(\n    queryBuilder: Knex.QueryBuilder\n  ): DuplicateAttachmentTableQueryAbstract;\n\n  shareFilterCollaboratorsQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    isMultipleCellValue?: boolean | null\n  ): void;\n\n  baseQuery(): BaseQueryAbstract;\n\n  integrityQuery(): IntegrityQueryAbstract;\n\n  calendarDailyCollectionQuery(\n    qb: Knex.QueryBuilder,\n    props: ICalendarDailyCollectionQueryProps\n  ): Knex.QueryBuilder;\n\n  lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string;\n\n  optionsQuery(type: FieldType, optionsKey: string, value: string): string;\n\n  searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder;\n\n  getTableIndexes(dbTableName: string): string;\n\n  generatedColumnQuery(): IGeneratedColumnQueryInterface;\n\n  convertFormulaToGeneratedColumn(\n    expression: string,\n    context: IFormulaConversionContext\n  ): IFormulaConversionResult;\n\n  selectQuery(): ISelectQueryInterface;\n\n  convertFormulaToSelectQuery(\n    expression: string,\n    context: ISelectFormulaConversionContext\n  ): IFieldSelectName;\n\n  generateDatabaseViewName(tableId: string): string;\n  createDatabaseView(\n    table: TableDomain,\n    qb: Knex.QueryBuilder,\n    options?: { materialized?: boolean }\n  ): string[];\n  recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[];\n  dropDatabaseView(tableId: string): string[];\n  refreshDatabaseView(tableId: string, options?: { concurrently?: boolean }): string | undefined;\n\n  createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string;\n  dropMaterializedView(tableId: string): string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/db.provider.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Provider } from '@nestjs/common';\nimport { Inject } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getDriverName } from '../utils/db-helpers';\nimport { PostgresProvider } from './postgres.provider';\nimport { SqliteProvider } from './sqlite.provider';\n\nexport const DB_PROVIDER_SYMBOL = Symbol('DB_PROVIDER');\n\nexport const InjectDbProvider = () => Inject(DB_PROVIDER_SYMBOL);\n\nexport const DbProvider: Provider = {\n  provide: DB_PROVIDER_SYMBOL,\n  useFactory: (knex: Knex) => {\n    const driverClient = getDriverName(knex);\n    switch (driverClient) {\n      case DriverClient.Sqlite:\n        return new SqliteProvider(knex);\n      case DriverClient.Pg:\n        return new PostgresProvider(knex);\n    }\n  },\n  inject: ['CUSTOM_KNEX'],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.interface.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Knex } from 'knex';\n\n/**\n * Operation types for database column dropping\n */\nexport enum DropColumnOperationType {\n  /** Complete field deletion - remove field and all related foreign keys/tables */\n  DELETE_FIELD = 'DELETE_FIELD',\n  /** Field type conversion - only remove field columns, preserve foreign key relationships */\n  CONVERT_FIELD = 'CONVERT_FIELD',\n  /** Delete symmetric field in bidirectional to unidirectional conversion - preserve foreign keys for main field */\n  DELETE_SYMMETRIC_FIELD = 'DELETE_SYMMETRIC_FIELD',\n}\n\n/**\n * Context interface for database column dropping\n */\nexport interface IDropDatabaseColumnContext {\n  /** Table name */\n  tableName: string;\n  /** Knex instance for building queries */\n  knex: Knex;\n  /** Link context for link field operations */\n  linkContext?: { tableId: string; tableNameMap: Map<string, string> };\n  /** Operation type to determine deletion strategy */\n  operationType?: DropColumnOperationType;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.postgres.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Relationship } from '@teable/core';\nimport type {\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  CheckboxFieldCore,\n  CreatedByFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  FormulaFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  LinkFieldCore,\n  LongTextFieldCore,\n  MultipleSelectFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  RollupFieldCore,\n  ConditionalRollupFieldCore,\n  SingleLineTextFieldCore,\n  SingleSelectFieldCore,\n  UserFieldCore,\n  IFieldVisitor,\n  FieldCore,\n  ILinkFieldOptions,\n  ButtonFieldCore,\n} from '@teable/core';\nimport { DropColumnOperationType } from './drop-database-column-field-visitor.interface';\nimport type { IDropDatabaseColumnContext } from './drop-database-column-field-visitor.interface';\n\n/**\n * PostgreSQL implementation of database column drop visitor.\n */\nexport class DropPostgresDatabaseColumnFieldVisitor implements IFieldVisitor<string[]> {\n  constructor(private readonly context: IDropDatabaseColumnContext) {}\n\n  private dropStandardColumn(field: FieldCore): string[] {\n    // Get all column names for this field\n    const columnNames = field.dbFieldNames;\n    const queries: string[] = [];\n\n    for (const columnName of columnNames) {\n      // Use CASCADE to automatically drop dependent objects (like generated columns)\n      // This is safe because we handle application-level dependencies separately\n      const dropQuery = this.context.knex\n        .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [\n          this.context.tableName,\n          columnName,\n        ])\n        .toQuery();\n\n      queries.push(dropQuery);\n    }\n\n    return queries;\n  }\n\n  private dropFormulaColumns(field: FormulaFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  private dropForeignKeyForLinkField(field: LinkFieldCore): string[] {\n    const options = field.options as ILinkFieldOptions;\n    const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options;\n    const queries: string[] = [];\n\n    // Check operation type - only drop foreign keys for complete field deletion\n    const operationType = this.context.operationType || DropColumnOperationType.DELETE_FIELD;\n\n    // For field conversion or symmetric field deletion, preserve foreign key relationships\n    // as they may still be needed by other fields\n    if (\n      operationType === DropColumnOperationType.CONVERT_FIELD ||\n      operationType === DropColumnOperationType.DELETE_SYMMETRIC_FIELD\n    ) {\n      return queries; // Return empty array - don't drop foreign keys\n    }\n\n    // Helper function to drop table\n    const dropTable = (tableName: string): string => {\n      return this.context.knex.raw('DROP TABLE IF EXISTS ?? CASCADE', [tableName]).toQuery();\n    };\n\n    // Helper function to drop column with index and order column\n    const dropColumn = (tableName: string, columnName: string): string[] => {\n      const dropQueries: string[] = [];\n\n      // Drop index first\n      dropQueries.push(\n        this.context.knex.raw('DROP INDEX IF EXISTS ??', [`index_${columnName}`]).toQuery()\n      );\n\n      // Drop main column\n      dropQueries.push(\n        this.context.knex\n          .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [tableName, columnName])\n          .toQuery()\n      );\n\n      // Drop order column if it exists\n      dropQueries.push(\n        this.context.knex\n          .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [\n            tableName,\n            `${columnName}_order`,\n          ])\n          .toQuery()\n      );\n\n      return dropQueries;\n    };\n\n    // Handle different relationship types - only for complete field deletion\n    if (relationship === Relationship.ManyMany && fkHostTableName.includes('junction_')) {\n      queries.push(dropTable(fkHostTableName));\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      queries.push(...dropColumn(fkHostTableName, foreignKeyName));\n    }\n\n    if (relationship === Relationship.OneMany) {\n      if (isOneWay && fkHostTableName.includes('junction_')) {\n        queries.push(dropTable(fkHostTableName));\n      } else if (!isOneWay) {\n        // For non-one-way OneMany relationships, drop the selfKeyName column and its order column\n        queries.push(...dropColumn(fkHostTableName, selfKeyName));\n      }\n    }\n\n    if (relationship === Relationship.OneOne) {\n      const columnToDrop = foreignKeyName === '__id' ? selfKeyName : foreignKeyName;\n      queries.push(...dropColumn(fkHostTableName, columnToDrop));\n    }\n\n    return queries;\n  }\n\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitSingleLineTextField(field: SingleLineTextFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLongTextField(field: LongTextFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitAttachmentField(field: AttachmentFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitCheckboxField(field: CheckboxFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitDateField(field: DateFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitRatingField(field: RatingFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitAutoNumberField(field: AutoNumberFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLinkField(field: LinkFieldCore): string[] {\n    const opts = field.options as ILinkFieldOptions;\n    const rel = opts?.relationship;\n    const inferredFkName =\n      opts?.foreignKeyName ??\n      (rel === Relationship.ManyOne || rel === Relationship.OneOne ? field.dbFieldName : undefined);\n    const inferredSelfName =\n      opts?.selfKeyName ??\n      (rel === Relationship.OneMany && opts?.isOneWay === false ? field.dbFieldName : undefined);\n    const conflictNames = new Set<string>();\n    if (inferredFkName) conflictNames.add(inferredFkName);\n    if (inferredSelfName) conflictNames.add(inferredSelfName);\n\n    const queries: string[] = [];\n    // Drop the separate base column only if it does not conflict with FK columns\n    if (!conflictNames.has(field.dbFieldName)) {\n      queries.push(...this.dropStandardColumn(field));\n    }\n\n    // Always drop FK/junction artifacts for link fields\n    queries.push(...this.dropForeignKeyForLinkField(field));\n    return queries;\n  }\n\n  visitRollupField(field: RollupFieldCore): string[] {\n    // Drop underlying base column for rollup fields\n    return this.dropStandardColumn(field);\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // Select field types\n  visitSingleSelectField(field: SingleSelectFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitButtonField(field: ButtonFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // Formula field types\n  visitFormulaField(field: FormulaFieldCore): string[] {\n    return this.dropFormulaColumns(field);\n  }\n\n  visitCreatedTimeField(field: CreatedTimeFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // User field types\n  visitUserField(field: UserFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitCreatedByField(field: CreatedByFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLastModifiedByField(field: LastModifiedByFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.sqlite.ts",
    "content": "import { Relationship } from '@teable/core';\nimport type {\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  CheckboxFieldCore,\n  CreatedByFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  FormulaFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  LinkFieldCore,\n  LongTextFieldCore,\n  MultipleSelectFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  RollupFieldCore,\n  ConditionalRollupFieldCore,\n  SingleLineTextFieldCore,\n  SingleSelectFieldCore,\n  UserFieldCore,\n  IFieldVisitor,\n  FieldCore,\n  ILinkFieldOptions,\n  ButtonFieldCore,\n} from '@teable/core';\nimport type { IDropDatabaseColumnContext } from './drop-database-column-field-visitor.interface';\nimport { DropColumnOperationType } from './drop-database-column-field-visitor.interface';\n\n/**\n * SQLite implementation of database column drop visitor.\n */\nexport class DropSqliteDatabaseColumnFieldVisitor implements IFieldVisitor<string[]> {\n  constructor(private readonly context: IDropDatabaseColumnContext) {}\n\n  private dropStandardColumn(field: FieldCore): string[] {\n    // Get all column names for this field\n    const columnNames = field.dbFieldNames;\n    const queries: string[] = [];\n\n    for (const columnName of columnNames) {\n      const dropQuery = this.context.knex\n        .raw('ALTER TABLE ?? DROP COLUMN ??', [this.context.tableName, columnName])\n        .toQuery();\n\n      queries.push(dropQuery);\n    }\n\n    return queries;\n  }\n\n  private dropFormulaColumns(field: FormulaFieldCore): string[] {\n    // Align with Postgres: drop the physical column representing the formula\n    // regardless of whether it was persisted as a generated column or not.\n    return this.dropStandardColumn(field);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private dropForeignKeyForLinkField(field: LinkFieldCore): string[] {\n    const options = field.options as ILinkFieldOptions;\n    const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options;\n    const queries: string[] = [];\n\n    // Check operation type - only drop foreign keys for complete field deletion\n    const operationType = this.context.operationType || DropColumnOperationType.DELETE_FIELD;\n\n    // For field conversion or symmetric field deletion, preserve foreign key relationships\n    // as they may still be needed by other fields\n    if (\n      operationType === DropColumnOperationType.CONVERT_FIELD ||\n      operationType === DropColumnOperationType.DELETE_SYMMETRIC_FIELD\n    ) {\n      return queries; // Return empty array - don't drop foreign keys\n    }\n\n    // Helper function to drop table\n    const dropTable = (tableName: string): string => {\n      return this.context.knex.raw('DROP TABLE IF EXISTS ??', [tableName]).toQuery();\n    };\n\n    // Helper function to drop column with index\n    const dropColumn = (tableName: string, columnName: string): string[] => {\n      const dropQueries: string[] = [];\n\n      // Drop index first\n      dropQueries.push(\n        this.context.knex.raw('DROP INDEX IF EXISTS ??', [`index_${columnName}`]).toQuery()\n      );\n\n      // Drop column\n      dropQueries.push(\n        this.context.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, columnName]).toQuery()\n      );\n\n      return dropQueries;\n    };\n\n    // Handle different relationship types\n    if (relationship === Relationship.ManyMany && fkHostTableName.includes('junction_')) {\n      queries.push(dropTable(fkHostTableName));\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      queries.push(...dropColumn(fkHostTableName, foreignKeyName));\n    }\n\n    if (relationship === Relationship.OneMany) {\n      if (isOneWay) {\n        if (fkHostTableName.includes('junction_')) {\n          queries.push(dropTable(fkHostTableName));\n        }\n      } else {\n        queries.push(...dropColumn(fkHostTableName, selfKeyName));\n      }\n    }\n\n    if (relationship === Relationship.OneOne) {\n      const columnToDrop = foreignKeyName === '__id' ? selfKeyName : foreignKeyName;\n      queries.push(...dropColumn(fkHostTableName, columnToDrop));\n    }\n\n    return queries;\n  }\n\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitSingleLineTextField(field: SingleLineTextFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLongTextField(field: LongTextFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitAttachmentField(field: AttachmentFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitCheckboxField(field: CheckboxFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitDateField(field: DateFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitRatingField(field: RatingFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitAutoNumberField(field: AutoNumberFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLinkField(field: LinkFieldCore): string[] {\n    const opts = field.options as ILinkFieldOptions;\n    const rel = opts?.relationship;\n    const inferredFkName =\n      opts?.foreignKeyName ??\n      (rel === Relationship.ManyOne || rel === Relationship.OneOne ? field.dbFieldName : undefined);\n    const inferredSelfName =\n      opts?.selfKeyName ??\n      (rel === Relationship.OneMany && opts?.isOneWay === false ? field.dbFieldName : undefined);\n    const conflictNames = new Set<string>();\n    if (inferredFkName) conflictNames.add(inferredFkName);\n    if (inferredSelfName) conflictNames.add(inferredSelfName);\n\n    const queries: string[] = [];\n    if (!conflictNames.has(field.dbFieldName)) {\n      queries.push(...this.dropStandardColumn(field));\n    }\n    queries.push(...this.dropForeignKeyForLinkField(field));\n    return queries;\n  }\n\n  visitRollupField(field: RollupFieldCore): string[] {\n    // Drop underlying base column for rollup fields\n    return this.dropStandardColumn(field);\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // Select field types\n  visitSingleSelectField(field: SingleSelectFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitButtonField(field: ButtonFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // Formula field types\n  visitFormulaField(field: FormulaFieldCore): string[] {\n    return this.dropFormulaColumns(field);\n  }\n\n  visitCreatedTimeField(field: CreatedTimeFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  // User field types\n  visitUserField(field: UserFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitCreatedByField(field: CreatedByFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n\n  visitLastModifiedByField(field: LastModifiedByFieldCore): string[] {\n    return this.dropStandardColumn(field);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/drop-database-column-query/index.ts",
    "content": "export * from './drop-database-column-field-visitor.interface';\nexport * from './drop-database-column-field-visitor.postgres';\nexport * from './drop-database-column-field-visitor.sqlite';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts",
    "content": "import type { Knex } from 'knex';\n\nexport abstract class DuplicateTableQueryAbstract {\n  constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}\n\n  abstract duplicateTableData(\n    sourceTable: string,\n    targetTable: string,\n    newColumns: string[],\n    oldColumns: string[],\n    crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]\n  ): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.abstract.ts",
    "content": "import type { Knex } from 'knex';\n\nexport abstract class DuplicateAttachmentTableQueryAbstract {\n  constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}\n\n  abstract duplicateAttachmentTable(\n    sourceTableId: string,\n    targetTableId: string,\n    sourceFieldId: string,\n    targetFieldId: string,\n    userId: string\n  ): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.postgres.ts",
    "content": "import type { Knex } from 'knex';\nimport { DuplicateAttachmentTableQueryAbstract } from './duplicate-attachment-table-query.abstract';\n\nexport class DuplicateAttachmentTableQueryPostgres extends DuplicateAttachmentTableQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(queryBuilder: Knex.QueryBuilder) {\n    super(queryBuilder);\n    this.knex = queryBuilder.client;\n  }\n\n  duplicateAttachmentTable(\n    sourceTableId: string,\n    targetTableId: string,\n    sourceFieldId: string,\n    targetFieldId: string,\n    userId: string\n  ) {\n    const attachmentTableDbName = 'attachments_table';\n    const targetColumns = [\n      'id',\n      'attachment_id',\n      'name',\n      'token',\n      'record_id',\n      'table_id',\n      'field_id',\n      'created_by',\n    ];\n\n    const sourceColumns = [\n      this.knex.raw(\n        `(\n        'cm' || \n        substr(md5(random()::text || clock_timestamp()::text), 1, 8) || \n        substr(md5(random()::text), 1, 15)\n      )`\n      ),\n      'attachment_id',\n      'name',\n      'token',\n      'record_id',\n      this.knex.raw(`'${targetTableId}' AS table_id`),\n      this.knex.raw(`'${targetFieldId}' AS field_id`),\n      this.knex.raw(`'${userId}' AS created_by`),\n    ];\n\n    const newColumnList = targetColumns.map((col) => `\"${col}\"`).join(', ');\n    const oldColumnList = sourceColumns\n      .map((col) => {\n        return typeof col === 'string' ? `\"${col}\"` : col;\n      })\n      .join(', ');\n    return this.knex.raw(\n      `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? WHERE field_id = ? and table_id = ?`,\n      [attachmentTableDbName, attachmentTableDbName, sourceFieldId, sourceTableId]\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.sqlite.ts",
    "content": "import type { Knex } from 'knex';\nimport { DuplicateAttachmentTableQueryAbstract } from './duplicate-attachment-table-query.abstract';\n\nexport class DuplicateAttachmentTableQuerySqlite extends DuplicateAttachmentTableQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(queryBuilder: Knex.QueryBuilder) {\n    super(queryBuilder);\n    this.knex = queryBuilder.client;\n  }\n\n  duplicateAttachmentTable(\n    sourceTableId: string,\n    targetTableId: string,\n    sourceFieldId: string,\n    targetFieldId: string,\n    userId: string\n  ) {\n    const attachmentTableDbName = 'attachments_table';\n    const targetColumns = [\n      'id',\n      'attachment_id',\n      'name',\n      'token',\n      'record_id',\n      'table_id',\n      'field_id',\n      'created_by',\n    ];\n\n    const sourceColumns = [\n      this.knex.raw(`(\n        'cm' || \n        substr(hex(randomblob(4)), 1, 8) || \n        substr(hex(randomblob(8)), 1, 15)\n      )`),\n      'attachment_id',\n      'name',\n      'token',\n      'record_id',\n      this.knex.raw(`'${targetTableId}' AS table_id`),\n      this.knex.raw(`'${targetFieldId}' AS field_id`),\n      this.knex.raw(`'${userId}' AS created_by`),\n    ];\n\n    const newColumnList = targetColumns.map((col) => `\"${col}\"`).join(', ');\n    const oldColumnList = sourceColumns\n      .map((col) => {\n        return typeof col === 'string' ? `\"${col}\"` : col;\n      })\n      .join(', ');\n    return this.knex.raw(\n      `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? WHERE field_id = ? and table_id = ?`,\n      [attachmentTableDbName, attachmentTableDbName, sourceFieldId, sourceTableId]\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts",
    "content": "import type { Knex } from 'knex';\nimport { DuplicateTableQueryAbstract } from './abstract';\n\nexport class DuplicateTableQueryPostgres extends DuplicateTableQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(queryBuilder: Knex.QueryBuilder) {\n    super(queryBuilder);\n    this.knex = queryBuilder.client;\n  }\n\n  duplicateTableData(\n    sourceTable: string,\n    targetTable: string,\n    newColumns: string[],\n    oldColumns: string[],\n    crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]\n  ) {\n    const newColumnList = newColumns.map((col) => `\"${col}\"`).join(', ');\n    const oldColumnList = oldColumns\n      .map((col) => {\n        if (col === '__version') {\n          return '1 AS \"__version\"';\n        }\n        // cross base link field should transform to text from json\n        if (crossBaseLinkDbFieldNames.map(({ dbFieldName }) => dbFieldName).includes(col)) {\n          const isMultipleCellValue = crossBaseLinkDbFieldNames.find(\n            ({ dbFieldName }) => dbFieldName === col\n          )?.isMultipleCellValue;\n          return !isMultipleCellValue\n            ? `\"${col}\" ->> 'title' as \"${col}\"`\n            : `CASE\n           WHEN \"${col}\" IS NULL THEN NULL\n           ELSE (SELECT string_agg(elem ->> 'title', ', ')\n                 FROM json_array_elements(CAST(\"${col}\" AS json)) AS elem)\n           END as \"${col}\"`;\n        }\n        return `\"${col}\"`;\n      })\n      .join(', ');\n    return this.knex.raw(\n      `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? ORDER BY __auto_number`,\n      [targetTable, sourceTable]\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts",
    "content": "import type { Knex } from 'knex';\nimport { DuplicateTableQueryAbstract } from './abstract';\n\nexport class DuplicateTableQuerySqlite extends DuplicateTableQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(queryBuilder: Knex.QueryBuilder) {\n    super(queryBuilder);\n    this.knex = queryBuilder.client;\n  }\n\n  duplicateTableData(\n    sourceTable: string,\n    targetTable: string,\n    newColumns: string[],\n    oldColumns: string[],\n    crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]\n  ) {\n    const newColumnList = newColumns.map((col) => `\"${col}\"`).join(', ');\n    const oldColumnList = oldColumns\n      .map((col) => {\n        if (col === '__version') {\n          return '1 AS \"__version\"';\n        }\n        // cross base link field should transform to text from json\n        if (crossBaseLinkDbFieldNames.map(({ dbFieldName }) => dbFieldName).includes(col)) {\n          const isMultipleCellValue = crossBaseLinkDbFieldNames.find(\n            ({ dbFieldName }) => dbFieldName === col\n          )?.isMultipleCellValue;\n          return !isMultipleCellValue\n            ? `json_extract(\"${col}\", '$.title') as \"${col}\"`\n            : `CASE\n              WHEN \"${col}\" IS NULL THEN NULL\n              ELSE (\n                SELECT group_concat(json_extract(value, '$.title'), ',')\n                FROM json_each(\"${col}\")\n              )\n            END as \"${col}\"`;\n        }\n        return `\"${col}\"`;\n      })\n      .join(', ');\n    return this.knex.raw(\n      `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? ORDER BY __auto_number`,\n      [targetTable, sourceTable]\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport {\n  CellValueType,\n  CheckboxFieldCore,\n  DateFieldCore,\n  DateFormattingPreset,\n  DriverClient,\n  FieldType,\n  NumberFieldCore,\n  SingleLineTextFieldCore,\n  TimeFormatting,\n  UserFieldCore,\n  defaultUserFieldOptions,\n  filterSchema,\n  hasAnyOf,\n  is,\n  isExactly,\n} from '@teable/core';\nimport type { FieldCore, IFilter } from '@teable/core';\nimport knex from 'knex';\nimport type { IDbProvider } from '../../db.provider.interface';\nimport { FilterQueryPostgres } from '../postgres/filter-query.postgres';\n\ntype FieldPair = {\n  label: string;\n  field: FieldCore;\n  reference: FieldCore;\n  expectedSql: RegExp;\n};\n\nconst knexBuilder = knex({ client: 'pg' });\n\nconst dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider;\n\nfunction assignBaseField<T extends FieldCore>(\n  field: T,\n  params: {\n    id: string;\n    dbFieldName: string;\n    type: FieldType;\n    cellValueType: CellValueType;\n    options: T['options'];\n    isMultipleCellValue?: boolean;\n  }\n): T {\n  field.id = params.id;\n  field.name = params.id;\n  field.dbFieldName = params.dbFieldName;\n  field.type = params.type;\n  field.options = params.options;\n  field.cellValueType = params.cellValueType;\n  field.isMultipleCellValue = params.isMultipleCellValue ?? false;\n  field.isLookup = false;\n  field.updateDbFieldType();\n  return field;\n}\n\nfunction createNumberField(id: string, dbFieldName: string): NumberFieldCore {\n  return assignBaseField(new NumberFieldCore(), {\n    id,\n    dbFieldName,\n    type: FieldType.Number,\n    cellValueType: CellValueType.Number,\n    options: NumberFieldCore.defaultOptions(),\n  });\n}\n\nfunction createNumberArrayField(id: string, dbFieldName: string): NumberFieldCore {\n  const field = createNumberField(id, dbFieldName);\n  field.isMultipleCellValue = true;\n  return field;\n}\n\nfunction createTextField(id: string, dbFieldName: string): SingleLineTextFieldCore {\n  return assignBaseField(new SingleLineTextFieldCore(), {\n    id,\n    dbFieldName,\n    type: FieldType.SingleLineText,\n    cellValueType: CellValueType.String,\n    options: SingleLineTextFieldCore.defaultOptions(),\n  });\n}\n\nfunction createDateField(id: string, dbFieldName: string): DateFieldCore {\n  const options = DateFieldCore.defaultOptions();\n  options.formatting = {\n    date: DateFormattingPreset.ISO,\n    time: TimeFormatting.None,\n    timeZone: 'UTC',\n  };\n  return assignBaseField(new DateFieldCore(), {\n    id,\n    dbFieldName,\n    type: FieldType.Date,\n    cellValueType: CellValueType.DateTime,\n    options,\n  });\n}\n\nfunction createCheckboxField(id: string, dbFieldName: string): CheckboxFieldCore {\n  return assignBaseField(new CheckboxFieldCore(), {\n    id,\n    dbFieldName,\n    type: FieldType.Checkbox,\n    cellValueType: CellValueType.Boolean,\n    options: CheckboxFieldCore.defaultOptions(),\n  });\n}\n\nfunction createUserField(\n  id: string,\n  dbFieldName: string,\n  isMultipleCellValue: boolean\n): UserFieldCore {\n  return assignBaseField(new UserFieldCore(), {\n    id,\n    dbFieldName,\n    type: FieldType.User,\n    cellValueType: CellValueType.String,\n    options: { ...defaultUserFieldOptions, isMultiple: isMultipleCellValue },\n    isMultipleCellValue,\n  });\n}\n\nconst cases: FieldPair[] = [\n  {\n    label: 'number field',\n    field: createNumberField('fld_number', 'number_col'),\n    reference: createNumberField('fld_number_ref', 'number_ref'),\n    expectedSql: /\"main\".\"number_col\" = \"main\".\"number_ref\"/i,\n  },\n  {\n    label: 'single line text field',\n    field: createTextField('fld_text', 'text_col'),\n    reference: createTextField('fld_text_ref', 'text_ref'),\n    expectedSql: /\"main\".\"text_col\" = \"main\".\"text_ref\"/i,\n  },\n  {\n    label: 'date field',\n    field: createDateField('fld_date', 'date_col'),\n    reference: createDateField('fld_date_ref', 'date_ref'),\n    expectedSql:\n      /DATE_TRUNC\\('day', \\(\"main\"\\.\"date_col\"\\) AT TIME ZONE 'UTC'\\) = DATE_TRUNC\\('day', \\(\"main\"\\.\"date_ref\"\\) AT TIME ZONE 'UTC'\\)/,\n  },\n  {\n    label: 'checkbox field',\n    field: createCheckboxField('fld_checkbox', 'checkbox_col'),\n    reference: createCheckboxField('fld_checkbox_ref', 'checkbox_ref'),\n    expectedSql: /\"main\".\"checkbox_col\" = \"main\".\"checkbox_ref\"/i,\n  },\n  {\n    label: 'user field',\n    field: createUserField('fld_user', 'user_col', false),\n    reference: createUserField('fld_user_ref', 'user_ref', false),\n    expectedSql:\n      /jsonb_extract_path_text\\(\"main\"\\.\"user_col\"::jsonb, 'id'\\) = jsonb_extract_path_text\\(\"main\"\\.\"user_ref\"::jsonb, 'id'\\)/i,\n  },\n];\n\ndescribe('field reference filters', () => {\n  it.each(cases)('supports field reference for %s', ({ field, reference, expectedSql }) => {\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: is.value,\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const parseResult = filterSchema.safeParse(filter);\n    expect(parseResult.success).toBe(true);\n\n    const qb = knexBuilder('main_table as main');\n\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const selectionMap = new Map(selectionEntries);\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap,\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toMatch(expectedSql);\n  });\n\n  it('supports hasAnyOf against multi-user field references', () => {\n    const field = createUserField('fld_multi_user', 'multi_user_col', true);\n    const reference = createUserField('fld_multi_user_ref', 'multi_user_ref_col', true);\n\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: hasAnyOf.value,\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const qb = knexBuilder('main_table as main');\n\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap: new Map(selectionEntries),\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toContain('jsonb_exists_any');\n    expect(sql).toContain('\"main\".\"multi_user_col\"');\n    expect(sql).toContain('\"main\".\"multi_user_ref_col\"');\n  });\n\n  it('supports isExactly against multi-user field references', () => {\n    const field = createUserField('fld_multi_user_exact', 'multi_user_exact_col', true);\n    const reference = createUserField('fld_multi_user_exact_ref', 'multi_user_exact_ref_col', true);\n\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: isExactly.value,\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const qb = knexBuilder('main_table as main');\n\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap: new Map(selectionEntries),\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toContain('jsonb_path_query_array(COALESCE(\"main\".\"multi_user_exact_col\"');\n    expect(sql).toContain('@> jsonb_path_query_array(COALESCE(\"main\".\"multi_user_exact_ref_col\"');\n    expect(sql).toContain('jsonb_path_query_array(COALESCE(\"main\".\"multi_user_exact_ref_col\"');\n    expect(sql).toContain('@> jsonb_path_query_array(COALESCE(\"main\".\"multi_user_exact_col\"');\n  });\n\n  it('supports numeric array comparisons against field references', () => {\n    const field = createNumberArrayField('fld_number_array', 'number_array_col');\n    const reference = createNumberField('fld_threshold_ref', 'threshold_ref_col');\n\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: is.value,\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const qb = knexBuilder('main_table as main');\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap: new Map(selectionEntries),\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toContain(\n      'jsonb_exists_any(COALESCE(\"main\".\"number_array_col\", ' + \"'[]'::jsonb), COALESCE\"\n    );\n  });\n\n  it('supports numeric array inequality comparisons against field references', () => {\n    const field = createNumberArrayField('fld_number_array_gt', 'number_array_gt_col');\n    const reference = createNumberField('fld_threshold_gt', 'threshold_gt_col');\n\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: 'isGreater',\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const qb = knexBuilder('main_table as main');\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap: new Map(selectionEntries),\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toContain('jsonb_array_elements_text(COALESCE(\"main\".\"number_array_gt_col\"');\n    expect(sql).toMatch(/::numeric >/);\n  });\n\n  it('supports numeric array negation comparisons against field references', () => {\n    const field = createNumberArrayField('fld_number_array_not', 'number_array_not_col');\n    const reference = createNumberField('fld_exclude_ref', 'exclude_ref_col');\n\n    const filter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: field.id,\n          operator: 'isNot',\n          value: { type: 'field', fieldId: reference.id },\n        },\n      ],\n    } as const;\n\n    const qb = knexBuilder('main_table as main');\n    const selectionEntries: [string, string][] = [\n      [field.id, `\"main\".\"${field.dbFieldName}\"`],\n      [reference.id, `\"main\".\"${reference.dbFieldName}\"`],\n    ];\n\n    const filterQuery = new FilterQueryPostgres(\n      qb,\n      {\n        [field.id]: field,\n        [reference.id]: reference,\n      },\n      filter,\n      undefined,\n      dbProviderStub,\n      {\n        selectionMap: new Map(selectionEntries),\n        fieldReferenceSelectionMap: new Map(selectionEntries),\n        fieldReferenceFieldMap: new Map<FieldCore['id'], FieldCore>([\n          [field.id, field],\n          [reference.id, reference],\n        ]),\n      }\n    );\n\n    expect(() => filterQuery.appendQueryBuilder()).not.toThrow();\n    const sql = qb.toQuery().replace(/\\s+/g, ' ');\n    expect(sql).toContain(\n      'NOT jsonb_exists_any(COALESCE(COALESCE(\"main\".\"number_array_not_col\",' +\n        \" '[]'::jsonb), '[]'::jsonb), COALESCE\"\n    );\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.abstract.ts",
    "content": "import {\n  BadRequestException,\n  InternalServerErrorException,\n  NotImplementedException,\n} from '@nestjs/common';\nimport {\n  CellValueType,\n  contains,\n  dateFilterSchema,\n  DateFormattingPreset,\n  DateUtil,\n  doesNotContain,\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  isNotExactly,\n  is,\n  isAfter,\n  isAnyOf,\n  isBefore,\n  isEmpty,\n  isExactly,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isNoneOf,\n  isNot,\n  isNotEmpty,\n  isOnOrAfter,\n  isOnOrBefore,\n  isWithIn,\n  literalValueListSchema,\n  isFieldReferenceComparable,\n  isFieldReferenceValue,\n  TimeFormatting,\n} from '@teable/core';\nimport type {\n  FieldCore,\n  IDateFieldOptions,\n  IDateFilter,\n  IFilterOperator,\n  IFilterValue,\n  IFieldReferenceValue,\n} from '@teable/core';\nimport type { Dayjs } from 'dayjs';\nimport dayjs from 'dayjs';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport { escapeLikeWildcards } from '../../utils/sql-like-escape';\nimport type { IDbProvider } from '../db.provider.interface';\nimport type { ICellValueFilterInterface } from './cell-value-filter.interface';\n\nexport class FieldReferenceCompatibilityException extends BadRequestException {\n  static readonly CODE = 'FIELD_REFERENCE_INCOMPATIBLE';\n\n  constructor(sourceField: string, referenceField: string) {\n    super({\n      errorCode: FieldReferenceCompatibilityException.CODE,\n      message: `Field '${referenceField}' is not compatible with '${sourceField}' for filter comparisons`,\n      sourceField,\n      referenceField,\n    });\n  }\n}\n\nexport abstract class AbstractCellValueFilter implements ICellValueFilterInterface {\n  protected tableColumnRef: string;\n\n  constructor(\n    protected readonly field: FieldCore,\n    readonly context?: IRecordQueryFilterContext\n  ) {\n    const { dbFieldName, id } = field;\n\n    const selection = context?.selectionMap.get(id);\n    if (selection) {\n      this.tableColumnRef = selection as string;\n    } else {\n      this.tableColumnRef = dbFieldName;\n    }\n  }\n\n  protected ensureLiteralValue(value: IFilterValue, operator: IFilterOperator): void {\n    if (isFieldReferenceValue(value)) {\n      throw new BadRequestException(\n        `Operator '${operator}' does not support comparing against another field`\n      );\n    }\n  }\n\n  protected resolveFieldReference(value: IFieldReferenceValue): string {\n    this.getComparableReferenceField(value);\n\n    const referenceMap = this.context?.fieldReferenceSelectionMap;\n    if (!referenceMap) {\n      throw new BadRequestException('Field reference comparisons are not available here');\n    }\n    const reference = referenceMap.get(value.fieldId);\n    if (!reference) {\n      throw new BadRequestException(\n        `Field '${value.fieldId}' is not available for reference comparisons`\n      );\n    }\n    return reference;\n  }\n\n  protected getFieldReferenceMetadata(fieldId: string): FieldCore | undefined {\n    return this.context?.fieldReferenceFieldMap?.get(fieldId);\n  }\n\n  protected getComparableReferenceField(value: IFieldReferenceValue): FieldCore {\n    const referenceField = this.getFieldReferenceMetadata(value.fieldId);\n    if (!referenceField) {\n      throw new BadRequestException(\n        `Field '${value.fieldId}' is not available for reference comparisons`\n      );\n    }\n\n    if (!isFieldReferenceComparable(this.field, referenceField)) {\n      const sourceName = this.field.name ?? this.field.id;\n      const referenceName = referenceField.name ?? referenceField.id;\n      throw new FieldReferenceCompatibilityException(sourceName, referenceName);\n    }\n\n    return referenceField;\n  }\n\n  compiler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ) {\n    const operatorHandlers = {\n      [is.value]: this.isOperatorHandler,\n      [isExactly.value]: this.isExactlyOperatorHandler,\n      [isNot.value]: this.isNotOperatorHandler,\n      [contains.value]: this.containsOperatorHandler,\n      [doesNotContain.value]: this.doesNotContainOperatorHandler,\n      [isGreater.value]: this.isGreaterOperatorHandler,\n      [isAfter.value]: this.isGreaterOperatorHandler,\n      [isGreaterEqual.value]: this.isGreaterEqualOperatorHandler,\n      [isOnOrAfter.value]: this.isGreaterEqualOperatorHandler,\n      [isLess.value]: this.isLessOperatorHandler,\n      [isBefore.value]: this.isLessOperatorHandler,\n      [isLessEqual.value]: this.isLessEqualOperatorHandler,\n      [isOnOrBefore.value]: this.isLessEqualOperatorHandler,\n      [isAnyOf.value]: this.isAnyOfOperatorHandler,\n      [hasAnyOf.value]: this.isAnyOfOperatorHandler,\n      [isNoneOf.value]: this.isNoneOfOperatorHandler,\n      [hasNoneOf.value]: this.isNoneOfOperatorHandler,\n      [hasAllOf.value]: this.hasAllOfOperatorHandler,\n      [isNotExactly.value]: this.isNotExactlyOperatorHandler,\n      [isWithIn.value]: this.isWithInOperatorHandler,\n      [isEmpty.value]: this.isEmptyOperatorHandler,\n      [isNotEmpty.value]: this.isNotEmptyOperatorHandler,\n    };\n    const chosenHandler = operatorHandlers[operator].bind(this);\n\n    if (!chosenHandler) {\n      throw new InternalServerErrorException(`Unknown operator ${operator} for filter`);\n    }\n\n    return chosenHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);\n      return builderClient;\n    }\n\n    const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isExactlyOperatorHandler(\n    _builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    throw new NotImplementedException();\n  }\n\n  abstract isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder;\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, contains.value);\n    const escapedValue = escapeLikeWildcards(String(value));\n    builderClient.whereRaw(`${this.tableColumnRef} LIKE ? ESCAPE '\\\\'`, [`%${escapedValue}%`]);\n    return builderClient;\n  }\n\n  abstract doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder;\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} > ${ref}`);\n      return builderClient;\n    }\n    const { cellValueType } = this.field;\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`${this.tableColumnRef} > ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} >= ${ref}`);\n      return builderClient;\n    }\n    const { cellValueType } = this.field;\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`${this.tableColumnRef} >= ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} < ${ref}`);\n      return builderClient;\n    }\n    const { cellValueType } = this.field;\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`${this.tableColumnRef} < ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} <= ${ref}`);\n      return builderClient;\n    }\n    const { cellValueType } = this.field;\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`${this.tableColumnRef} <= ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, isAnyOf.value);\n    const valueList = literalValueListSchema.parse(value);\n\n    builderClient.whereRaw(\n      `${this.tableColumnRef} in (${this.createSqlPlaceholders(valueList)})`,\n      valueList\n    );\n    return builderClient;\n  }\n\n  abstract isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder;\n\n  hasAllOfOperatorHandler(\n    _builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    throw new NotImplementedException();\n  }\n\n  isNotExactlyOperatorHandler(\n    _builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    throw new NotImplementedException();\n  }\n\n  isWithInOperatorHandler(\n    _builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    throw new NotImplementedException();\n  }\n\n  isEmptyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const tableColumnRef = this.tableColumnRef;\n    const { cellValueType, isStructuredCellValue, isMultipleCellValue } = this.field;\n\n    builderClient.where(function () {\n      this.whereRaw(`${tableColumnRef} is null`);\n\n      if (\n        cellValueType === CellValueType.String &&\n        !isStructuredCellValue &&\n        !isMultipleCellValue\n      ) {\n        this.orWhereRaw(`${tableColumnRef} = ''`);\n      }\n    });\n    return builderClient;\n  }\n\n  isNotEmptyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    _value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { cellValueType, isStructuredCellValue, isMultipleCellValue } = this.field;\n\n    builderClient.whereRaw(`${this.tableColumnRef} is not null`);\n    if (cellValueType === CellValueType.String && !isStructuredCellValue && !isMultipleCellValue) {\n      builderClient.whereRaw(`${this.tableColumnRef} != ''`);\n    }\n    return builderClient;\n  }\n\n  protected createSqlPlaceholders(values: unknown[]): string {\n    return values.map(() => '?').join(',');\n  }\n\n  protected getFilterDateTimeRange(\n    dateFieldOptions: IDateFieldOptions,\n    filterValue: IDateFilter\n  ): [string, string] {\n    const filterValueByDate = dateFilterSchema.parse(filterValue);\n\n    const { mode, numberOfDays, exactDate } = filterValueByDate;\n    const {\n      formatting: { timeZone, date: dateFormat, time: timeFormat },\n    } = dateFieldOptions;\n\n    // Check if the field has time format configured (not None)\n    const hasTimeFormat = timeFormat && timeFormat !== TimeFormatting.None;\n\n    const dateUtil = new DateUtil(timeZone);\n\n    // Helper function to calculate date range for fixed days like today, tomorrow, etc.\n    const computeDateRangeForFixedDays = (\n      methodName:\n        | 'date'\n        | 'tomorrow'\n        | 'yesterday'\n        | 'lastWeek'\n        | 'nextWeek'\n        | 'lastMonth'\n        | 'nextMonth'\n    ): [Dayjs, Dayjs] => {\n      return [dateUtil[methodName]().startOf('day'), dateUtil[methodName]().endOf('day')];\n    };\n\n    // Helper function to calculate date range for offset days from current date.\n    const calculateDateRangeForOffsetDays = (isPast: boolean): [Dayjs, Dayjs] => {\n      if (!numberOfDays) {\n        throw new BadRequestException('Number of days must be entered');\n      }\n      const offsetDays = isPast ? -numberOfDays : numberOfDays;\n      return [\n        dateUtil.offsetDay(offsetDays).startOf('day'),\n        dateUtil.offsetDay(offsetDays).endOf('day'),\n      ];\n    };\n\n    // Helper function to determine date range for a given exact date.\n    const determineDateRangeForExactDate = (): [Dayjs, Dayjs] => {\n      if (!exactDate) {\n        throw new BadRequestException('Exact date must be entered');\n      }\n\n      return [dateUtil.date(exactDate).startOf('day'), dateUtil.date(exactDate).endOf('day')];\n    };\n\n    // Helper function to determine date range for a given exact formatted date.\n    const determineDateRangeForExactFormatDate = (): [Dayjs, Dayjs] => {\n      if (!exactDate) {\n        throw new BadRequestException('Exact date must be entered');\n      }\n\n      const parsedDate = dateUtil.date(exactDate);\n\n      switch (dateFormat) {\n        case DateFormattingPreset.Y:\n          return [parsedDate.startOf('year'), parsedDate.endOf('year')];\n        case DateFormattingPreset.YM:\n        case DateFormattingPreset.M:\n          return [parsedDate.startOf('month'), parsedDate.endOf('month')];\n        case DateFormattingPreset.MD:\n        case DateFormattingPreset.D:\n        default:\n          return [parsedDate.startOf('day'), parsedDate.endOf('day')];\n      }\n    };\n\n    // Helper function to generate offset date range for a given unit (day, week, month, year).\n    const generateOffsetDateRange = (\n      isPast: boolean,\n      unit: 'day' | 'week' | 'month' | 'year',\n      numberOfDays?: number\n    ): [Dayjs, Dayjs] => {\n      if (numberOfDays === undefined || numberOfDays === null) {\n        throw new BadRequestException('Number of days must be entered');\n      }\n\n      const currentDate = dateUtil.date();\n      const startOfDay = currentDate.startOf('day');\n      const endOfDay = currentDate.endOf('day');\n\n      const startDate = isPast\n        ? dateUtil.offset(unit, -numberOfDays, endOfDay).startOf('day')\n        : startOfDay;\n      const endDate = isPast\n        ? endOfDay\n        : dateUtil.offset(unit, numberOfDays, startOfDay).endOf('day');\n\n      return [startDate, endDate];\n    };\n\n    const generateRelativeDateFromCurrentDateRange = (\n      mode: 'current' | 'next' | 'last',\n      unit: 'week' | 'month' | 'year'\n    ): [Dayjs, Dayjs] => {\n      dayjs.locale(dayjs.locale(), {\n        weekStart: 1,\n      });\n      let cursorDate;\n      switch (mode) {\n        case 'current':\n          cursorDate = dateUtil.date();\n          break;\n        case 'next':\n          cursorDate = dateUtil.date().add(1, unit);\n          break;\n        case 'last':\n          cursorDate = dateUtil.date().subtract(1, unit);\n          break;\n        default:\n          cursorDate = dateUtil.date();\n      }\n      return [cursorDate.startOf(unit).startOf('day'), cursorDate.endOf(unit).endOf('day')];\n    };\n\n    // Helper function to determine date range for a custom date range (from exactDate to exactDateEnd).\n    const determineDateRangeForDateRange = (): [Dayjs, Dayjs] => {\n      if (!exactDate) {\n        throw new BadRequestException('Start date must be entered for date range');\n      }\n      const exactDateEnd = filterValueByDate.exactDateEnd;\n      if (!exactDateEnd) {\n        throw new BadRequestException('End date must be entered for date range');\n      }\n\n      const startDate = dateUtil.date(exactDate);\n      const endDate = dateUtil.date(exactDateEnd);\n\n      // Validate that start date is not after end date\n      if (startDate.isAfter(endDate)) {\n        throw new BadRequestException('Start date cannot be after end date');\n      }\n\n      // If field has time format, use exact time from frontend; otherwise use start/end of day\n      if (hasTimeFormat) {\n        return [startDate, endDate];\n      }\n      return [startDate.startOf('day'), endDate.endOf('day')];\n    };\n\n    // Map of operation functions based on date mode.\n    const operationMap: Record<string, () => [Dayjs, Dayjs]> = {\n      today: () => computeDateRangeForFixedDays('date'),\n      tomorrow: () => computeDateRangeForFixedDays('tomorrow'),\n      yesterday: () => computeDateRangeForFixedDays('yesterday'),\n      oneWeekAgo: () => computeDateRangeForFixedDays('lastWeek'),\n      oneWeekFromNow: () => computeDateRangeForFixedDays('nextWeek'),\n      oneMonthAgo: () => computeDateRangeForFixedDays('lastMonth'),\n      oneMonthFromNow: () => computeDateRangeForFixedDays('nextMonth'),\n      daysAgo: () => calculateDateRangeForOffsetDays(true),\n      daysFromNow: () => calculateDateRangeForOffsetDays(false),\n      exactDate: () => determineDateRangeForExactDate(),\n      exactFormatDate: () => determineDateRangeForExactFormatDate(),\n      dateRange: () => determineDateRangeForDateRange(),\n      currentWeek: () => generateRelativeDateFromCurrentDateRange('current', 'week'),\n      currentMonth: () => generateRelativeDateFromCurrentDateRange('current', 'month'),\n      currentYear: () => generateRelativeDateFromCurrentDateRange('current', 'year'),\n      lastWeek: () => generateRelativeDateFromCurrentDateRange('last', 'week'),\n      lastMonth: () => generateRelativeDateFromCurrentDateRange('last', 'month'),\n      lastYear: () => generateRelativeDateFromCurrentDateRange('last', 'year'),\n      nextWeekPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'week'),\n      nextMonthPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'month'),\n      nextYearPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'year'),\n      pastWeek: () => generateOffsetDateRange(true, 'week', 1),\n      pastMonth: () => generateOffsetDateRange(true, 'month', 1),\n      pastYear: () => generateOffsetDateRange(true, 'year', 1),\n      nextWeek: () => generateOffsetDateRange(false, 'week', 1),\n      nextMonth: () => generateOffsetDateRange(false, 'month', 1),\n      nextYear: () => generateOffsetDateRange(false, 'year', 1),\n      pastNumberOfDays: () => generateOffsetDateRange(true, 'day', numberOfDays),\n      nextNumberOfDays: () => generateOffsetDateRange(false, 'day', numberOfDays),\n    };\n    const [startDate, endDate] = operationMap[mode]();\n\n    // Return the start and end date in ISO 8601 date format.\n    return [startDate.toISOString(), endDate.toISOString()];\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.interface.ts",
    "content": "import type { IFilterOperator, IFilterValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../db.provider.interface';\n\nexport type ICellValueFilterHandler = (\n  builderClient: Knex.QueryBuilder,\n  operator: IFilterOperator,\n  value: IFilterValue,\n  dbProvider: IDbProvider\n) => Knex.QueryBuilder;\n\nexport interface ICellValueFilterInterface {\n  isOperatorHandler: ICellValueFilterHandler;\n  isExactlyOperatorHandler: ICellValueFilterHandler;\n  isNotOperatorHandler: ICellValueFilterHandler;\n  isNotExactlyOperatorHandler: ICellValueFilterHandler;\n  containsOperatorHandler: ICellValueFilterHandler;\n  doesNotContainOperatorHandler: ICellValueFilterHandler;\n  isGreaterOperatorHandler: ICellValueFilterHandler;\n  isGreaterEqualOperatorHandler: ICellValueFilterHandler;\n  isLessOperatorHandler: ICellValueFilterHandler;\n  isLessEqualOperatorHandler: ICellValueFilterHandler;\n  isAnyOfOperatorHandler: ICellValueFilterHandler;\n  isNoneOfOperatorHandler: ICellValueFilterHandler;\n  hasAllOfOperatorHandler: ICellValueFilterHandler;\n  isWithInOperatorHandler: ICellValueFilterHandler;\n  isEmptyOperatorHandler: ICellValueFilterHandler;\n  isNotEmptyOperatorHandler: ICellValueFilterHandler;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport type {\n  FieldCore,\n  IConjunction,\n  IDateTimeFieldOperator,\n  IFilter,\n  IFilterItem,\n  IFilterOperator,\n  IFilterSet,\n  ILiteralValueList,\n  IFieldReferenceValue,\n} from '@teable/core';\nimport {\n  CellValueType,\n  DbFieldType,\n  FieldType,\n  getFilterOperatorMapping,\n  getValidFilterSubOperators,\n  HttpErrorCode,\n  isEmpty,\n  isMeTag,\n  isNotEmpty,\n  isFieldReferenceValue,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport { includes, invert, isObject } from 'lodash';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { IDbProvider, IFilterQueryExtra } from '../db.provider.interface';\nimport type { AbstractCellValueFilter } from './cell-value-filter.abstract';\nimport { FieldReferenceCompatibilityException } from './cell-value-filter.abstract';\nimport type { IFilterQueryInterface } from './filter-query.interface';\n\nexport abstract class AbstractFilterQuery implements IFilterQueryInterface {\n  private logger = new Logger(AbstractFilterQuery.name);\n\n  constructor(\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fields?: { [fieldId: string]: FieldCore },\n    protected readonly filter?: IFilter,\n    protected readonly extra?: IFilterQueryExtra,\n    protected readonly dbProvider?: IDbProvider,\n    protected readonly context?: IRecordQueryFilterContext\n  ) {}\n\n  appendQueryBuilder(): Knex.QueryBuilder {\n    this.preProcessRemoveNullAndReplaceMe(this.filter);\n\n    return this.parseFilters(this.originQueryBuilder, this.filter);\n  }\n\n  private parseFilters(\n    queryBuilder: Knex.QueryBuilder,\n    filter?: IFilter,\n    parentConjunction?: IConjunction\n  ): Knex.QueryBuilder {\n    if (!filter || !filter.filterSet) {\n      return queryBuilder;\n    }\n    const { filterSet, conjunction } = filter;\n    queryBuilder.where((filterBuilder) => {\n      filterSet.forEach((filterItem) => {\n        if ('fieldId' in filterItem) {\n          this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction);\n        } else {\n          filterBuilder = filterBuilder[parentConjunction || conjunction];\n          filterBuilder.where((builder) => {\n            this.parseFilters(builder, filterItem as IFilterSet, conjunction);\n          });\n        }\n      });\n    });\n\n    return queryBuilder;\n  }\n\n  private parseFilter(\n    queryBuilder: Knex.QueryBuilder,\n    filterMeta: IFilterItem,\n    conjunction: IConjunction\n  ) {\n    const { fieldId, operator, value, isSymbol } = filterMeta;\n\n    const field = this.fields && this.fields[fieldId];\n    if (!field) {\n      return queryBuilder;\n    }\n\n    let convertOperator = operator;\n    const filterOperatorMapping = getFilterOperatorMapping(field);\n    const validFilterOperators = Object.keys(filterOperatorMapping);\n    if (isSymbol) {\n      convertOperator = invert(filterOperatorMapping)[operator] as IFilterOperator;\n    }\n\n    if (!includes(validFilterOperators, convertOperator)) {\n      let referenceFieldId: string | undefined;\n      if (isFieldReferenceValue(value)) {\n        referenceFieldId = value.fieldId;\n      } else if (Array.isArray(value)) {\n        referenceFieldId = (\n          value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined\n        )?.fieldId;\n      }\n\n      if (referenceFieldId) {\n        const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId;\n        const sourceName = field.name ?? field.id;\n        throw new FieldReferenceCompatibilityException(sourceName, referenceName);\n      }\n\n      throw new CustomHttpException(\n        `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following types are allowed: [${validFilterOperators}]`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.filterInvalidOperator',\n          },\n        }\n      );\n    }\n\n    const validFilterSubOperators = getValidFilterSubOperators(\n      field.type,\n      convertOperator as IDateTimeFieldOperator\n    );\n\n    if (\n      validFilterSubOperators &&\n      isObject(value) &&\n      'mode' in value &&\n      !includes(validFilterSubOperators, value.mode)\n    ) {\n      throw new CustomHttpException(\n        `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following subtypes are allowed: [${validFilterSubOperators}]`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.filterInvalidOperatorMode',\n          },\n        }\n      );\n    }\n\n    queryBuilder = queryBuilder[conjunction];\n\n    this.getFilterAdapter(field).compiler(\n      queryBuilder,\n      convertOperator as IFilterOperator,\n      value,\n      this.dbProvider!\n    );\n    return queryBuilder;\n  }\n\n  private getFilterAdapter(field: FieldCore): AbstractCellValueFilter {\n    const { dbFieldType } = field;\n    switch (field.cellValueType) {\n      case CellValueType.Boolean:\n        return this.booleanFilter(field, this.context);\n      case CellValueType.Number:\n        return this.numberFilter(field, this.context);\n      case CellValueType.DateTime:\n        return this.dateTimeFilter(field, this.context);\n      case CellValueType.String: {\n        if (dbFieldType === DbFieldType.Json) {\n          return this.jsonFilter(field, this.context);\n        }\n        return this.stringFilter(field, this.context);\n      }\n    }\n  }\n\n  private preProcessRemoveNullAndReplaceMe(filter?: IFilter) {\n    if (!filter || !Object.keys(filter).length) {\n      return;\n    }\n\n    const replaceUserId = this.extra?.withUserId;\n\n    filter.filterSet = filter.filterSet.filter((filterItem) => {\n      if ('filterSet' in filterItem) {\n        this.preProcessRemoveNullAndReplaceMe(filterItem as IFilter);\n        return true;\n      }\n\n      return this.processFilterItem(filterItem, replaceUserId);\n    });\n  }\n\n  private processFilterItem(filterItem: IFilterItem, replaceUserId?: string): boolean {\n    const { fieldId, operator, value } = filterItem;\n    const field = this.fields?.[fieldId];\n    if (!field) return false;\n\n    this.replaceMeTagInValue(filterItem, field, replaceUserId);\n\n    return this.shouldKeepFilterItem(value, field, operator);\n  }\n\n  private replaceMeTagInValue(\n    filterItem: IFilterItem,\n    field: FieldCore,\n    replaceUserId?: string\n  ): void {\n    const { value } = filterItem;\n\n    if (\n      [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type) &&\n      replaceUserId\n    ) {\n      filterItem.value = Array.isArray(value)\n        ? (value.map((v) => (isMeTag(v as string) ? replaceUserId : v)) as ILiteralValueList)\n        : isMeTag(value as string)\n          ? replaceUserId\n          : value;\n    }\n  }\n\n  private shouldKeepFilterItem(value: unknown, field: FieldCore, operator: string): boolean {\n    return (\n      value !== null ||\n      field.cellValueType === CellValueType.Boolean ||\n      ([isEmpty.value, isNotEmpty.value] as string[]).includes(operator)\n    );\n  }\n\n  abstract booleanFilter(\n    field: FieldCore,\n    context?: IRecordQueryFilterContext\n  ): AbstractCellValueFilter;\n\n  abstract numberFilter(\n    field: FieldCore,\n    context?: IRecordQueryFilterContext\n  ): AbstractCellValueFilter;\n\n  abstract dateTimeFilter(\n    field: FieldCore,\n    context?: IRecordQueryFilterContext\n  ): AbstractCellValueFilter;\n\n  abstract stringFilter(\n    field: FieldCore,\n    context?: IRecordQueryFilterContext\n  ): AbstractCellValueFilter;\n\n  abstract jsonFilter(\n    field: FieldCore,\n    context?: IRecordQueryFilterContext\n  ): AbstractCellValueFilter;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/filter-query.interface.ts",
    "content": "import type { Knex } from 'knex';\n\nexport interface IFilterQueryInterface {\n  appendQueryBuilder(): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/cell-value-filter.postgres.ts",
    "content": "import type { IFilterOperator, IFilterValue } from '@teable/core';\nimport {\n  CellValueType,\n  doesNotContain,\n  isFieldReferenceValue,\n  isNoneOf,\n  literalValueListSchema,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../db.provider.interface';\nimport { AbstractCellValueFilter } from '../../cell-value-filter.abstract';\n\nexport class CellValueFilterPostgres extends AbstractCellValueFilter {\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { cellValueType } = this.field;\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ${ref}`);\n      return builderClient;\n    }\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n    builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [parseValue]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, doesNotContain.value);\n    builderClient.whereRaw(`COALESCE(${this.tableColumnRef}, '') NOT LIKE ?`, [`%${value}%`]);\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, isNoneOf.value);\n    const valueList = literalValueListSchema.parse(value);\n\n    const sql = `COALESCE(${this.tableColumnRef}, '') NOT IN (${this.createSqlPlaceholders(valueList)})`;\n    builderClient.whereRaw(sql, valueList);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/index.ts",
    "content": "export * from './single-value/boolean-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-boolean-cell-value-filter.adapter';\n\nexport * from './single-value/number-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-number-cell-value-filter.adapter';\n\nexport * from './single-value/datetime-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-datetime-cell-value-filter.adapter';\n\nexport * from './single-value/string-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-string-cell-value-filter.adapter';\n\nexport * from './single-value/json-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-json-cell-value-filter.adapter';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, IFilterValue } from '@teable/core';\nimport { isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class MultipleBooleanCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const tableColumnRef = this.tableColumnRef;\n\n    if (value) {\n      // Filter for checked/true: match JSONB arrays that contain at least one true value\n      builderClient.whereRaw(`${tableColumnRef} @> '[true]'::jsonb`);\n    } else {\n      // Filter for unchecked/false: match records that do NOT contain any true value\n      // This includes: null, empty arrays, or arrays with only false/null values\n      builderClient.where(function () {\n        this.whereRaw(`${tableColumnRef} is null`);\n        this.orWhereRaw(`NOT (${tableColumnRef} @> '[true]'::jsonb)`);\n      });\n    }\n\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport type { IDateFieldOptions, IDateFilter, IFilterOperator, IFilterValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ >= \"${dateTimeRange[0]}\" && @ <= \"${dateTimeRange[1]}\")'`\n    );\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `(NOT ${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ >= \"${dateTimeRange[0]}\" && @ <= \"${dateTimeRange[1]}\")' OR ${this.tableColumnRef} IS NULL)`\n    );\n\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ > \"${dateTimeRange[1]}\")'`\n    );\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ >= \"${dateTimeRange[0]}\")'`\n    );\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ < \"${dateTimeRange[0]}\")'`\n    );\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ <= \"${dateTimeRange[1]}\")'`\n    );\n    return builderClient;\n  }\n\n  isWithInOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ >= \"${dateTimeRange[0]}\" && @ <= \"${dateTimeRange[1]}\")'`\n    );\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts",
    "content": "import type {\n  FieldCore,\n  IFieldReferenceValue,\n  IFilterOperator,\n  ILiteralValue,\n  ILiteralValueList,\n} from '@teable/core';\nimport { FieldType, isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { isUserOrLink } from '../../../../../utils/is-user-or-link';\nimport { escapeJsonbRegex, escapePostgresRegex } from '../../../../../utils/postgres-regex-escape';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      builderClient.whereRaw(\n        `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`\n      );\n      return builderClient;\n    }\n\n    const { type } = this.field;\n    const literalValues: ILiteralValueList = Array.isArray(value)\n      ? (value as ILiteralValueList)\n      : ([value] as ILiteralValueList);\n\n    if (isUserOrLink(type)) {\n      return this.isAnyOfOperatorHandler(builderClient, _operator, literalValues, _dbProvider);\n    }\n\n    if (type === FieldType.Link) {\n      const parseValue = JSON.stringify({ title: literalValues[0] });\n\n      builderClient.whereRaw(`${this.tableColumnRef}::jsonb @> ?::jsonb`, [parseValue]);\n    } else {\n      const escapedValue = escapePostgresRegex(String(literalValues[0]));\n      builderClient.whereRaw(\n        `EXISTS (\n        SELECT 1 FROM jsonb_array_elements_text(${this.tableColumnRef}::jsonb) as elem\n        WHERE elem ~* ?\n      )`,\n        [`^${escapedValue}$`]\n      );\n    }\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      builderClient.whereRaw(\n        `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`\n      );\n      return builderClient;\n    }\n\n    const { type } = this.field;\n    const literalValues: ILiteralValueList = Array.isArray(value)\n      ? (value as ILiteralValueList)\n      : ([value] as ILiteralValueList);\n\n    if (isUserOrLink(type)) {\n      return this.isNoneOfOperatorHandler(builderClient, _operator, literalValues, _dbProvider);\n    }\n\n    if (type === FieldType.Link) {\n      const parseValue = JSON.stringify({ title: literalValues[0] });\n\n      builderClient.whereRaw(`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @> ?::jsonb`, [\n        parseValue,\n      ]);\n    } else {\n      const escapedValue = escapePostgresRegex(String(literalValues[0]));\n      builderClient.whereRaw(\n        `NOT EXISTS (\n          SELECT 1 FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem\n          WHERE elem ~* ?\n        )`,\n        [`^${escapedValue}$`]\n      );\n    }\n    return builderClient;\n  }\n\n  isExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      builderClient.whereRaw(\n        `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`\n      );\n      return builderClient;\n    }\n\n    const { type } = this.field;\n    const sqlPlaceholders = this.createSqlPlaceholders(value);\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id')`,\n        [...value, ...value]\n      );\n    } else {\n      builderClient.whereRaw(\n        `${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> ${this.tableColumnRef}::jsonb`,\n        [...value, ...value]\n      );\n    }\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);\n      return builderClient;\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `jsonb_exists_any(jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id'), ?::text[])`,\n        [value]\n      );\n    } else {\n      builderClient.whereRaw(`jsonb_exists_any(${this.tableColumnRef}::jsonb, ?::text[])`, [value]);\n    }\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`\n      );\n      return builderClient;\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id'), ?::text[])`,\n        [value]\n      );\n    } else {\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(COALESCE(${this.tableColumnRef}, '[]')::jsonb, ?::text[])`,\n        [value]\n      );\n    }\n    return builderClient;\n  }\n\n  hasAllOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      builderClient.whereRaw(`${selfArray} @> ${referenceArray}`);\n      return builderClient;\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `jsonb_exists_all(jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id'), ?::text[])`,\n        [value]\n      );\n    } else {\n      builderClient.whereRaw(`jsonb_exists_all(${this.tableColumnRef}::jsonb, ?::text[])`, [value]);\n    }\n    return builderClient;\n  }\n\n  isNotExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      builderClient.whereRaw(\n        `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`\n      );\n      return builderClient;\n    }\n\n    const { type } = this.field;\n    const sqlPlaceholders = this.createSqlPlaceholders(value);\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `(NOT (jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id')) OR ${this.tableColumnRef} IS NULL)`,\n        [...value, ...value]\n      );\n    } else {\n      builderClient.whereRaw(\n        `(NOT (COALESCE(${this.tableColumnRef}, '[]')::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> COALESCE(${this.tableColumnRef}, '[]')::jsonb) OR ${this.tableColumnRef} IS NULL)`,\n        [...value, ...value]\n      );\n    }\n\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n    const escapedValue = escapeJsonbRegex(String(value));\n\n    if (type === FieldType.Link) {\n      builderClient.whereRaw(\n        `${this.tableColumnRef}::jsonb @\\\\? '$[*].title \\\\? (@ like_regex \"${String(escapedValue)}\" flag \"i\")'`\n      );\n    } else {\n      builderClient.whereRaw(\n        `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ like_regex \"${String(escapedValue)}\" flag \"i\")'`\n      );\n    }\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n    const escapedValue = escapeJsonbRegex(String(value));\n\n    if (type === FieldType.Link) {\n      builderClient.whereRaw(\n        `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\\\? '$[*].title \\\\? (@ like_regex \"${String(escapedValue)}\" flag \"i\")'`\n      );\n    } else {\n      builderClient.whereRaw(\n        `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\\\? '$[*] \\\\? (@ like_regex \"${String(escapedValue)}\" flag \"i\")'`\n      );\n    }\n    return builderClient;\n  }\n\n  private buildReferenceJsonArray(value: IFieldReferenceValue): string {\n    const referenceExpression = this.resolveFieldReference(value);\n    const referenceField = this.getComparableReferenceField(value);\n    return this.buildJsonArrayExpression(referenceExpression, referenceField);\n  }\n\n  private buildJsonArrayExpression(columnExpression: string, field?: FieldCore): string {\n    const targetField = field ?? this.field;\n    const fallback = targetField.isMultipleCellValue ? \"'[]'::jsonb\" : \"'null'::jsonb\";\n    return `jsonb_path_query_array(COALESCE(${columnExpression}, ${fallback}), ${this.getJsonPath(\n      targetField\n    )})`;\n  }\n\n  private buildTextArrayExpression(jsonArrayExpression: string): string {\n    return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;\n  }\n\n  private getJsonPath(field: FieldCore): string {\n    if (isUserOrLink(field.type)) {\n      return field.isMultipleCellValue ? \"'$[*].id'\" : \"'$.id'\";\n    }\n    return field.isMultipleCellValue ? \"'$[*]'\" : \"'$'\";\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts",
    "content": "import type {\n  FieldCore,\n  IFieldReferenceValue,\n  IFilterOperator,\n  ILiteralValue,\n  ILiteralValueList,\n} from '@teable/core';\nimport { isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class MultipleNumberCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);\n      return builderClient;\n    }\n\n    builderClient.whereRaw(`${this.tableColumnRef}::jsonb @> jsonb_build_array(?::numeric)`, [\n      Number(value),\n    ]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`\n      );\n      return builderClient;\n    }\n\n    builderClient.whereRaw(\n      `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @> jsonb_build_array(?::numeric)`,\n      [Number(value)]\n    );\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '>'));\n      return builderClient;\n    }\n\n    builderClient.whereRaw(\n      `\n      EXISTS (\n        SELECT 1\n        FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem\n        WHERE elem::numeric > ?::numeric\n      )\n      `,\n      [Number(value)]\n    );\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '>='));\n      return builderClient;\n    }\n\n    builderClient.whereRaw(\n      `\n      EXISTS (\n        SELECT 1\n        FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem\n        WHERE elem::numeric >= ?::numeric\n      )\n      `,\n      [Number(value)]\n    );\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '<'));\n      return builderClient;\n    }\n\n    builderClient.whereRaw(\n      `\n      EXISTS (\n        SELECT 1\n        FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem\n        WHERE elem::numeric < ?::numeric\n      )\n      `,\n      [Number(value)]\n    );\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '<='));\n      return builderClient;\n    }\n\n    builderClient.whereRaw(\n      `\n      EXISTS (\n        SELECT 1\n        FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem\n        WHERE elem::numeric <= ?::numeric\n      )\n      `,\n      [Number(value)]\n    );\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);\n      return builderClient;\n    }\n\n    const numericList = (value as ILiteralValueList).map((entry) => Number(entry));\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb \\\\?| ARRAY[${this.createSqlPlaceholders(numericList)}]`,\n      numericList\n    );\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`\n      );\n      return builderClient;\n    }\n\n    const numericList = (value as ILiteralValueList).map((entry) => Number(entry));\n    builderClient.whereRaw(\n      `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb \\\\?| ARRAY[${this.createSqlPlaceholders(numericList)}]`,\n      numericList\n    );\n    return builderClient;\n  }\n\n  hasAllOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(`jsonb_exists_all(${selfArray}, ${referenceTextArray})`);\n      return builderClient;\n    }\n\n    const numericList = (value as ILiteralValueList).map((entry) => Number(entry));\n    builderClient.whereRaw(\n      `jsonb_exists_all(${this.tableColumnRef}::jsonb, ARRAY[${this.createSqlPlaceholders(numericList)}])`,\n      numericList\n    );\n    return builderClient;\n  }\n\n  isExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(\n        `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`\n      );\n      return builderClient;\n    }\n\n    const numericList = (value as ILiteralValueList).map((entry) => Number(entry));\n    const placeholders = this.createSqlPlaceholders(numericList);\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${placeholders}]) AND to_jsonb(ARRAY[${placeholders}]) @> ${this.tableColumnRef}::jsonb`,\n      [...numericList, ...numericList]\n    );\n    return builderClient;\n  }\n\n  isNotExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceArray = this.buildReferenceJsonArray(value);\n      builderClient.whereRaw(\n        `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`\n      );\n      return builderClient;\n    }\n\n    const numericList = (value as ILiteralValueList).map((entry) => Number(entry));\n    const placeholders = this.createSqlPlaceholders(numericList);\n    builderClient.whereRaw(\n      `(NOT (${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${placeholders}]) AND to_jsonb(ARRAY[${placeholders}]) @> ${this.tableColumnRef}::jsonb) OR ${this.tableColumnRef} IS NULL)`,\n      [...numericList, ...numericList]\n    );\n    return builderClient;\n  }\n\n  private buildJsonArrayExpression(columnExpression: string, field: FieldCore): string {\n    if (field.isMultipleCellValue) {\n      return `COALESCE(${columnExpression}, '[]'::jsonb)`;\n    }\n    return `jsonb_build_array(${columnExpression})`;\n  }\n\n  private buildReferenceJsonArray(value: IFieldReferenceValue): string {\n    const referenceExpression = this.resolveFieldReference(value);\n    const referenceField = this.getComparableReferenceField(value);\n    return this.buildJsonArrayExpression(referenceExpression, referenceField);\n  }\n\n  private buildTextArrayExpression(jsonArrayExpression: string): string {\n    return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;\n  }\n\n  private buildComparisonSql(\n    selfArray: string,\n    referenceArray: string,\n    operator: '>' | '>=' | '<' | '<='\n  ): string {\n    return `EXISTS (\n      SELECT 1\n      FROM jsonb_array_elements_text(${selfArray}) AS self_elem(value)\n      CROSS JOIN jsonb_array_elements_text(${referenceArray}) AS ref_elem(value)\n      WHERE (self_elem.value)::numeric ${operator} (ref_elem.value)::numeric\n    )`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { escapeJsonbRegex } from '../../../../../utils/postgres-regex-escape';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    builderClient.whereRaw(`${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ == \"${value}\")'`);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    builderClient.whereRaw(\n      `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\\\? '$[*] \\\\? (@ == \"${value}\")'`\n    );\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const escapedValue = escapeJsonbRegex(String(value));\n    this.ensureLiteralValue(value, _operator);\n    builderClient.whereRaw(\n      `${this.tableColumnRef}::jsonb @\\\\? '$[*] \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'`\n    );\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const escapedValue = escapeJsonbRegex(String(value));\n    this.ensureLiteralValue(value, _operator);\n    builderClient.whereRaw(\n      `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\\\? '$[*] \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'`\n    );\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts",
    "content": "import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class BooleanCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const tableColumnRef = this.tableColumnRef;\n\n    if (value) {\n      // Filter for checked/true: match exactly true values\n      builderClient.whereRaw(`${tableColumnRef} = true`);\n    } else {\n      // Filter for unchecked/false: match false values OR null values\n      // This handles both formula fields (which return false) and checkbox fields (which store null)\n      builderClient.where(function () {\n        this.whereRaw(`${tableColumnRef} = false`);\n        this.orWhereRaw(`${tableColumnRef} is null`);\n      });\n    }\n\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport {\n  DateFormattingPreset,\n  isFieldReferenceValue,\n  type IDateFieldOptions,\n  type IDateFilter,\n  type IDatetimeFormatting,\n  type IFilterOperator,\n  type IFilterValue,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class DatetimeCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceEquality(builderClient, ref, 'is');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef} BETWEEN ?::timestamptz AND ?::timestamptz`,\n      dateTimeRange\n    );\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceEquality(builderClient, ref, 'isNot');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n\n    // Wrap conditions in a nested `.whereRaw()` to ensure proper SQL grouping with parentheses,\n    // generating `WHERE (\"data\" NOT BETWEEN ... OR \"data\" IS NULL) AND other_query`.\n    builderClient.whereRaw(\n      `(${this.tableColumnRef} NOT BETWEEN ?::timestamptz AND ?::timestamptz OR ${this.tableColumnRef} IS NULL)`,\n      dateTimeRange\n    );\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceComparison(builderClient, ref, 'gt');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} > ?::timestamptz`, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceComparison(builderClient, ref, 'gte');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} >= ?::timestamptz`, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceComparison(builderClient, ref, 'lt');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} < ?::timestamptz`, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      return this.applyFieldReferenceComparison(builderClient, ref, 'lte');\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} <= ?::timestamptz`, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isWithInOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `${this.tableColumnRef} BETWEEN ?::timestamptz AND ?::timestamptz`,\n      dateTimeRange\n    );\n    return builderClient;\n  }\n\n  private extractFormatting(): IDatetimeFormatting | undefined {\n    const options = this.field.options as { formatting?: IDatetimeFormatting } | undefined;\n    return options?.formatting;\n  }\n\n  private determineDateUnit(formatting?: IDatetimeFormatting): 'day' | 'month' | 'year' {\n    const dateFormat = formatting?.date as DateFormattingPreset | undefined;\n    switch (dateFormat) {\n      case DateFormattingPreset.Y:\n        return 'year';\n      case DateFormattingPreset.YM:\n      case DateFormattingPreset.M:\n        return 'month';\n      default:\n        return 'day';\n    }\n  }\n\n  private wrapWithTimeZone(expr: string, formatting?: IDatetimeFormatting): string {\n    const tz = (formatting?.timeZone || 'UTC').replace(/'/g, \"''\");\n    return `(${expr}) AT TIME ZONE '${tz}'`;\n  }\n\n  private applyFieldReferenceEquality(\n    builderClient: Knex.QueryBuilder,\n    referenceExpression: string,\n    mode: 'is' | 'isNot'\n  ): Knex.QueryBuilder {\n    const formatting = this.extractFormatting();\n    const unit = this.determineDateUnit(formatting);\n\n    const left = this.buildTruncatedExpression(this.tableColumnRef, unit, formatting);\n    const right = this.buildTruncatedExpression(referenceExpression, unit, formatting);\n\n    if (mode === 'is') {\n      builderClient.whereRaw(`${left} = ${right}`);\n    } else {\n      builderClient.whereRaw(`${left} IS DISTINCT FROM ${right}`);\n    }\n\n    return builderClient;\n  }\n\n  private applyFieldReferenceComparison(\n    builderClient: Knex.QueryBuilder,\n    referenceExpression: string,\n    comparator: 'gt' | 'gte' | 'lt' | 'lte'\n  ): Knex.QueryBuilder {\n    const formatting = this.extractFormatting();\n    const unit = this.determineDateUnit(formatting);\n\n    const left = this.buildTruncatedExpression(this.tableColumnRef, unit, formatting);\n    const right = this.buildTruncatedExpression(referenceExpression, unit, formatting);\n\n    const comparatorMap = {\n      gt: '>',\n      gte: '>=',\n      lt: '<',\n      lte: '<=',\n    } as const;\n\n    builderClient.whereRaw(`${left} ${comparatorMap[comparator]} ${right}`);\n    return builderClient;\n  }\n\n  private buildTruncatedExpression(\n    expression: string,\n    unit: 'day' | 'month' | 'year',\n    formatting?: IDatetimeFormatting\n  ): string {\n    return `DATE_TRUNC('${unit}', ${this.wrapWithTimeZone(expression, formatting)})`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/json-cell-value-filter.adapter.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type {\n  FieldCore,\n  IFieldReferenceValue,\n  IFilterOperator,\n  IFilterValue,\n  ILiteralValue,\n  ILiteralValueList,\n} from '@teable/core';\nimport { FieldType, isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { isUserOrLink } from '../../../../../utils/is-user-or-link';\nimport { escapeJsonbRegex } from '../../../../../utils/postgres-regex-escape';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class JsonCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n\n      if (isUserOrLink(type)) {\n        const referenceField = this.getComparableReferenceField(value);\n        if (referenceField.isMultipleCellValue) {\n          const leftIdExpr = `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id')`;\n          const refArrayExpr = `jsonb_path_query_array(COALESCE(${ref}, '[]'::jsonb), '$[*].id')`;\n          builderClient.whereRaw(\n            `EXISTS (SELECT 1 FROM jsonb_array_elements_text(${refArrayExpr}) AS ref_id WHERE ref_id = ${leftIdExpr})`\n          );\n          return builderClient;\n        }\n        builderClient.whereRaw(\n          `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') = jsonb_extract_path_text(${ref}::jsonb, 'id')`\n        );\n        return builderClient;\n      }\n\n      return super.isOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(`jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') = ?`, [\n        value,\n      ]);\n    } else {\n      builderClient.whereRaw(\n        `jsonb_path_exists(${this.tableColumnRef}::jsonb, ?::jsonpath, jsonb_build_object('value', to_jsonb(?::text)))`,\n        ['$[*] ? (@ like_regex $value flag \"i\")', value]\n      );\n    }\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n\n      if (isUserOrLink(type)) {\n        const referenceField = this.getComparableReferenceField(value);\n        if (referenceField.isMultipleCellValue) {\n          const leftIdExpr = `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id')`;\n          const refArrayExpr = `jsonb_path_query_array(COALESCE(${ref}, '[]'::jsonb), '$[*].id')`;\n          builderClient.whereRaw(\n            `NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(${refArrayExpr}) AS ref_id WHERE ref_id = ${leftIdExpr})`\n          );\n          return builderClient;\n        }\n        builderClient.whereRaw(\n          `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') IS DISTINCT FROM jsonb_extract_path_text(${ref}::jsonb, 'id')`\n        );\n        return builderClient;\n      }\n\n      return super.isNotOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `jsonb_extract_path_text(COALESCE(${this.tableColumnRef}, '{}'::jsonb), 'id') IS DISTINCT FROM ?`,\n        [value]\n      );\n    } else {\n      builderClient.whereRaw(\n        `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '[]')::jsonb, ?::jsonpath, jsonb_build_object('value', to_jsonb(?::text)))`,\n        ['$[*] ? (@ like_regex $value flag \"i\")', value]\n      );\n    }\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);\n      return builderClient;\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') IN (${this.createSqlPlaceholders(value)})`,\n        value\n      );\n    } else {\n      builderClient.whereRaw(\n        `${this.tableColumnRef}::jsonb \\\\?| ARRAY[${this.createSqlPlaceholders(value)}]`,\n        value\n      );\n    }\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValueList | IFieldReferenceValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n\n    if (isFieldReferenceValue(value)) {\n      const referenceArray = this.buildReferenceJsonArray(value);\n      const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);\n      const referenceTextArray = this.buildTextArrayExpression(referenceArray);\n      builderClient.whereRaw(\n        `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`\n      );\n      return builderClient;\n    }\n\n    if (isUserOrLink(type)) {\n      builderClient.whereRaw(\n        `COALESCE(jsonb_extract_path_text(COALESCE(${this.tableColumnRef}, '{}')::jsonb, 'id'), '') NOT IN (${this.createSqlPlaceholders(\n          value\n        )})`,\n        value\n      );\n    } else {\n      builderClient.whereRaw(\n        `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb \\\\?| ARRAY[${this.createSqlPlaceholders(value)}]`,\n        value\n      );\n    }\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n    const escapedValue = escapeJsonbRegex(String(value));\n\n    if (type === FieldType.Link) {\n      builderClient.whereRaw(\n        `jsonb_path_exists(${this.tableColumnRef}::jsonb, '$.title \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'::jsonpath)`\n      );\n    } else {\n      builderClient.whereRaw(\n        `jsonb_path_exists(${this.tableColumnRef}::jsonb, '$[*] \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'::jsonpath)`\n      );\n    }\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    const { type } = this.field;\n    const escapedValue = escapeJsonbRegex(String(value));\n\n    if (type === FieldType.Link) {\n      builderClient.whereRaw(\n        `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '{}')::jsonb, '$.title \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'::jsonpath)`\n      );\n    } else {\n      builderClient.whereRaw(\n        `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*] \\\\? (@ like_regex \"${escapedValue}\" flag \"i\")'::jsonpath)`\n      );\n    }\n    return builderClient;\n  }\n\n  private buildReferenceJsonArray(value: IFieldReferenceValue): string {\n    const referenceExpression = this.resolveFieldReference(value);\n    const referenceField = this.getComparableReferenceField(value);\n    return this.buildJsonArrayExpression(referenceExpression, referenceField);\n  }\n\n  private buildJsonArrayExpression(columnExpression: string, field?: FieldCore): string {\n    const targetField = field ?? this.field;\n    const fallback = targetField.isMultipleCellValue ? \"'[]'::jsonb\" : \"'null'::jsonb\";\n    return `jsonb_path_query_array(COALESCE(${columnExpression}, ${fallback}), ${this.getJsonPath(\n      targetField\n    )})`;\n  }\n\n  private buildTextArrayExpression(jsonArrayExpression: string): string {\n    return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;\n  }\n\n  private getJsonPath(field: FieldCore): string {\n    if (isUserOrLink(field.type)) {\n      return field.isMultipleCellValue ? \"'$[*].id'\" : \"'$.id'\";\n    }\n    return field.isMultipleCellValue ? \"'$[*]'\" : \"'$'\";\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/number-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class NumberCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isGreaterOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isGreaterEqualOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isLessOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isLessEqualOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts",
    "content": "import {\n  CellValueType,\n  isFieldReferenceValue,\n  type IFieldReferenceValue,\n  type IFilterOperator,\n  type ILiteralValue,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport { escapeLikeWildcards } from '../../../../../utils/sql-like-escape';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterPostgres } from '../cell-value-filter.postgres';\n\nexport class StringCellValueFilterAdapter extends CellValueFilterPostgres {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);\n      return builderClient;\n    }\n    const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;\n    builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { cellValueType } = this.field;\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ${ref}`);\n      return builderClient;\n    }\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n    builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [parseValue]);\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const escapedValue = escapeLikeWildcards(String(value));\n    builderClient.whereRaw(`${this.tableColumnRef} iLIKE ? ESCAPE '\\\\'`, [`%${escapedValue}%`]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const escapedValue = escapeLikeWildcards(String(value));\n    builderClient.whereRaw(\n      `LOWER(COALESCE(${this.tableColumnRef}, '')) NOT LIKE LOWER(?) ESCAPE '\\\\'`,\n      [`%${escapedValue}%`]\n    );\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/postgres/filter-query.postgres.ts",
    "content": "import type { FieldCore, IFilter } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface';\nimport type { IDbProvider, IFilterQueryExtra } from '../../db.provider.interface';\nimport { AbstractFilterQuery } from '../filter-query.abstract';\nimport {\n  BooleanCellValueFilterAdapter,\n  DatetimeCellValueFilterAdapter,\n  JsonCellValueFilterAdapter,\n  MultipleBooleanCellValueFilterAdapter,\n  MultipleDatetimeCellValueFilterAdapter,\n  MultipleJsonCellValueFilterAdapter,\n  MultipleNumberCellValueFilterAdapter,\n  MultipleStringCellValueFilterAdapter,\n  NumberCellValueFilterAdapter,\n  StringCellValueFilterAdapter,\n} from './cell-value-filter';\nimport type { CellValueFilterPostgres } from './cell-value-filter/cell-value-filter.postgres';\n\nexport class FilterQueryPostgres extends AbstractFilterQuery {\n  constructor(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    filter?: IFilter,\n    extra?: IFilterQueryExtra,\n    dbProvider?: IDbProvider,\n    context?: IRecordQueryFilterContext\n  ) {\n    super(originQueryBuilder, fields, filter, extra, dbProvider, context);\n  }\n  booleanFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleBooleanCellValueFilterAdapter(field, context);\n    }\n    return new BooleanCellValueFilterAdapter(field, context);\n  }\n\n  numberFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleNumberCellValueFilterAdapter(field, context);\n    }\n    return new NumberCellValueFilterAdapter(field, context);\n  }\n\n  dateTimeFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleDatetimeCellValueFilterAdapter(field, context);\n    }\n    return new DatetimeCellValueFilterAdapter(field, context);\n  }\n\n  stringFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleStringCellValueFilterAdapter(field, context);\n    }\n    return new StringCellValueFilterAdapter(field, context);\n  }\n\n  jsonFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleJsonCellValueFilterAdapter(field, context);\n    }\n    return new JsonCellValueFilterAdapter(field, context);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/cell-value-filter.sqlite.ts",
    "content": "import type { FieldCore, IFilterOperator, IFilterValue } from '@teable/core';\nimport {\n  CellValueType,\n  contains,\n  doesNotContain,\n  FieldType,\n  isFieldReferenceValue,\n  isNoneOf,\n  literalValueListSchema,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport { escapeLikeWildcards } from '../../../../utils/sql-like-escape';\nimport type { IDbProvider } from '../../../db.provider.interface';\nimport { AbstractCellValueFilter } from '../../cell-value-filter.abstract';\n\nexport class CellValueFilterSqlite extends AbstractCellValueFilter {\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { cellValueType } = this.field;\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') != ${ref}`);\n      return builderClient;\n    }\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n\n    builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') != ?`, [parseValue]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, doesNotContain.value);\n    const escapedValue = escapeLikeWildcards(String(value));\n    builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') not like ? ESCAPE '\\\\'`, [\n      `%${escapedValue}%`,\n    ]);\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, isNoneOf.value);\n    const valueList = literalValueListSchema.parse(value);\n\n    const sql = `ifnull(${this.tableColumnRef}, '') not in (${this.createSqlPlaceholders(valueList)})`;\n    builderClient.whereRaw(sql, [...valueList]);\n    return builderClient;\n  }\n\n  protected getJsonQueryColumn(field: FieldCore, operator: IFilterOperator): string {\n    const defaultJsonColumn = 'json_each.value';\n    if (field.type === FieldType.Link) {\n      const object = field.isMultipleCellValue ? defaultJsonColumn : field.dbFieldName;\n      const path = ([contains.value, doesNotContain.value] as string[]).includes(operator)\n        ? '$.title'\n        : '$.id';\n\n      return `json_extract(${object}, '${path}')`;\n    }\n    if ([FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)) {\n      const object = field.isMultipleCellValue ? defaultJsonColumn : field.dbFieldName;\n      const path = '$.id';\n\n      return `json_extract(${object}, '${path}')`;\n    } else if (field.type === FieldType.Attachment) {\n      return defaultJsonColumn;\n    }\n    return defaultJsonColumn;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/index.ts",
    "content": "export * from './single-value/boolean-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-boolean-cell-value-filter.adapter';\n\nexport * from './single-value/number-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-number-cell-value-filter.adapter';\n\nexport * from './single-value/datetime-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-datetime-cell-value-filter.adapter';\n\nexport * from './single-value/string-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-string-cell-value-filter.adapter';\n\nexport * from './single-value/json-cell-value-filter.adapter';\nexport * from './multiple-value/multiple-json-cell-value-filter.adapter';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, IFilterValue } from '@teable/core';\nimport { isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class MultipleBooleanCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const tableColumnRef = this.tableColumnRef;\n\n    if (value) {\n      // Filter for checked/true: match JSON arrays that contain at least one true value (stored as 1)\n      // Use json_each to check if any element equals 1 (true in SQLite)\n      builderClient.whereRaw(\n        `EXISTS (SELECT 1 FROM json_each(${tableColumnRef}) WHERE json_each.value = 1)`\n      );\n    } else {\n      // Filter for unchecked/false: match records that do NOT contain any true value\n      // This includes: null, empty arrays, or arrays with only false/null values\n      builderClient.where(function () {\n        this.whereRaw(`${tableColumnRef} is null`);\n        this.orWhereRaw(\n          `NOT EXISTS (SELECT 1 FROM json_each(${tableColumnRef}) WHERE json_each.value = 1)`\n        );\n      });\n    }\n\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport type { IDateFieldOptions, IDateFilter, IFilterOperator, IFilterValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value between ? and ?\n    )`;\n    builderClient.whereRaw(sql, [...dateTimeRange]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `not exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value between ? and ?\n    )`;\n    builderClient.whereRaw(sql, [...dateTimeRange]);\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value > ?\n    )`;\n    builderClient.whereRaw(sql, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value >= ?\n    )`;\n    builderClient.whereRaw(sql, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value < ?\n    )`;\n    builderClient.whereRaw(sql, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value <= ?\n    )`;\n    builderClient.whereRaw(sql, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isWithInOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value between ? and ?\n    )`;\n    builderClient.whereRaw(sql, [...dateTimeRange]);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue, ILiteralValueList } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { size } from 'lodash';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class MultipleJsonCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const isOfSql = `exists (select 1 from json_each(${this.tableColumnRef}) where lower(${jsonColumn}) = lower(?))`;\n    builderClient.whereRaw(isOfSql, [value]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const isNotOfSql = `not exists (select 1 from json_each(${this.tableColumnRef}) where lower(${jsonColumn}) = lower(?))`;\n    builderClient.whereRaw(isNotOfSql, [value]);\n    return builderClient;\n  }\n\n  isExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const isExactlySql = `(\n      select count(${jsonColumn}) from \n        json_each(${this.tableColumnRef}) \n      where ${jsonColumn} in (${this.createSqlPlaceholders(value)})\n    ) >= ?`;\n\n    const isFullMatchSql = `(\n      select count(distinct ${jsonColumn}) from \n        json_each(${this.tableColumnRef})\n    ) = ?`;\n\n    builderClient\n      .whereRaw(isExactlySql, [...value, value.length])\n      .whereRaw(isFullMatchSql, [value.length]);\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const hasAnyOfSql = `exists (\n      select 1 from \n        json_each(${this.tableColumnRef})\n      where ${jsonColumn} in (${this.createSqlPlaceholders(value)})\n    )`;\n    builderClient.whereRaw(hasAnyOfSql, [...value]);\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const hasNoneOfSql = `not exists (\n      select 1 from \n        json_each(${this.tableColumnRef})\n      where ${jsonColumn} in (${this.createSqlPlaceholders(value)})\n    )`;\n    builderClient.whereRaw(hasNoneOfSql, [...value]);\n    return builderClient;\n  }\n\n  hasAllOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const hasAllSql = `(\n      select count(distinct json_each.value) from \n        json_each(${this.tableColumnRef}) \n      where ${jsonColumn} in (${this.createSqlPlaceholders(value)})\n    ) = ?`;\n    builderClient.whereRaw(hasAllSql, [...value, size(value)]);\n    return builderClient;\n  }\n\n  isNotExactlyOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const isNotExactlySql = `NOT ((\n      select count(${jsonColumn}) from \n        json_each(${this.tableColumnRef}) \n      where ${jsonColumn} in (${this.createSqlPlaceholders(value)})\n    ) >= ? AND (\n      select count(distinct ${jsonColumn}) from \n        json_each(${this.tableColumnRef})\n    ) = ?)`;\n\n    builderClient.whereRaw(isNotExactlySql, [...value, value.length, value.length]);\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists (\n      select 1 from\n        json_each(${this.tableColumnRef})\n      where ${this.getJsonQueryColumn(this.field, operator)} like ?\n    )`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `not exists (\n      select 1 from\n        json_each(${this.tableColumnRef})\n      where ${this.getJsonQueryColumn(this.field, operator)} like ?\n    )`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class MultipleNumberCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value in (?)\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `not exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value in (?)\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value > ?\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value >= ?\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value < ?\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value <= ?\n    )`;\n    builderClient.whereRaw(sql, [Number(value)]);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class MultipleStringCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value in (?)\n    )`;\n    builderClient.whereRaw(sql, [value]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const sql = `not exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value in (?)\n    )`;\n    builderClient.whereRaw(sql, [value]);\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const sql = `exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value like ?\n    )`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    const sql = `not exists ( \n      select 1 from \n        json_each(${this.tableColumnRef}) \n      where json_each.value like ?\n    )`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts",
    "content": "import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class BooleanCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const tableColumnRef = this.tableColumnRef;\n\n    if (value) {\n      // Filter for checked/true: match exactly true values (stored as 1 in SQLite)\n      builderClient.whereRaw(`${tableColumnRef} = 1`);\n    } else {\n      // Filter for unchecked/false: match false values OR null values\n      // This handles both formula fields (which return false/0) and checkbox fields (which store null)\n      builderClient.where(function () {\n        this.whereRaw(`${tableColumnRef} = 0`);\n        this.orWhereRaw(`${tableColumnRef} is null`);\n      });\n    }\n\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport {\n  isFieldReferenceValue,\n  type IDateFieldOptions,\n  type IDateFilter,\n  type IFilterOperator,\n  type IFilterValue,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class DatetimeCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} BETWEEN ? AND ?`, dateTimeRange);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isNotOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(\n      `(${this.tableColumnRef} NOT BETWEEN ? AND ? OR ${this.tableColumnRef} IS NULL)`,\n      dateTimeRange\n    );\n    return builderClient;\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isGreaterOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} > ?`, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isGreaterEqualOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} >= ?`, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isLessOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} < ?`, [dateTimeRange[0]]);\n    return builderClient;\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isLessEqualOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} <= ?`, [dateTimeRange[1]]);\n    return builderClient;\n  }\n\n  isWithInOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: IFilterValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      return super.isOperatorHandler(builderClient, _operator, value, dbProvider);\n    }\n\n    const { options } = this.field;\n\n    const dateTimeRange = this.getFilterDateTimeRange(\n      options as IDateFieldOptions,\n      value as IDateFilter\n    );\n    builderClient.whereRaw(`${this.tableColumnRef} BETWEEN ? AND ?`, dateTimeRange);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/json-cell-value-filter.adapter.ts",
    "content": "import type {\n  IFieldReferenceValue,\n  IFilterOperator,\n  IFilterValue,\n  ILiteralValue,\n  ILiteralValueList,\n} from '@teable/core';\nimport { FieldType, isFieldReferenceValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class JsonCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n\n      if (\n        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(\n          this.field.type\n        )\n      ) {\n        const referenceField = this.getComparableReferenceField(value);\n        if (referenceField.isMultipleCellValue) {\n          const refColumn = \"json_extract(json_each.value, '$.id')\";\n          builderClient.whereRaw(\n            `exists (select 1 from json_each(${ref}) where lower(${refColumn}) = lower(${jsonColumn}))`\n          );\n          return builderClient;\n        }\n        const refColumn = `json_extract(${ref}, '$.id')`;\n        builderClient.whereRaw(`lower(${jsonColumn}) = lower(${refColumn})`);\n        return builderClient;\n      }\n\n      return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const sql = `lower(${jsonColumn}) = lower(?)`;\n    builderClient.whereRaw(sql, [value]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n\n      if (\n        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(\n          this.field.type\n        )\n      ) {\n        const referenceField = this.getComparableReferenceField(value);\n        if (referenceField.isMultipleCellValue) {\n          const refColumn = \"json_extract(json_each.value, '$.id')\";\n          builderClient.whereRaw(\n            `not exists (select 1 from json_each(${ref}) where lower(${refColumn}) = lower(${jsonColumn}))`\n          );\n          return builderClient;\n        }\n        const refColumn = `json_extract(${ref}, '$.id')`;\n        builderClient.whereRaw(`lower(ifnull(${jsonColumn}, '')) != lower(${refColumn})`);\n        return builderClient;\n      }\n\n      return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);\n    }\n\n    const sql = `lower(ifnull(${jsonColumn}, '')) != lower(?)`;\n    builderClient.whereRaw(sql, [value]);\n    return builderClient;\n  }\n\n  isAnyOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const sql = `${jsonColumn} in (${this.createSqlPlaceholders(value)})`;\n    builderClient.whereRaw(sql, [...value]);\n    return builderClient;\n  }\n\n  isNoneOfOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValueList\n  ): Knex.QueryBuilder {\n    const jsonColumn = this.getJsonQueryColumn(this.field, operator);\n    const sql = `ifnull(${jsonColumn}, '') not in (${this.createSqlPlaceholders(value)})`;\n    builderClient.whereRaw(sql, [...value]);\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    const sql = `${this.getJsonQueryColumn(this.field, operator)} like ?`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: IFilterValue\n  ): Knex.QueryBuilder {\n    const sql = `ifnull(${this.getJsonQueryColumn(this.field, operator)}, '') not like ?`;\n    builderClient.whereRaw(sql, [`%${value}%`]);\n    return builderClient;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/number-cell-value-filter.adapter.ts",
    "content": "import type { IFilterOperator, ILiteralValue } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class NumberCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isGreaterOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isGreaterOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isGreaterEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isGreaterEqualOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isLessOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isLessOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n\n  isLessEqualOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    return super.isLessEqualOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/string-cell-value-filter.adapter.ts",
    "content": "import {\n  CellValueType,\n  isFieldReferenceValue,\n  type IFieldReferenceValue,\n  type IFilterOperator,\n  type ILiteralValue,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db.provider.interface';\nimport { CellValueFilterSqlite } from '../cell-value-filter.sqlite';\n\nexport class StringCellValueFilterAdapter extends CellValueFilterSqlite {\n  isOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);\n      return builderClient;\n    }\n    const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;\n    builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);\n    return builderClient;\n  }\n\n  isNotOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue | IFieldReferenceValue,\n    _dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    const { cellValueType } = this.field;\n    if (isFieldReferenceValue(value)) {\n      const ref = this.resolveFieldReference(value);\n      builderClient.whereRaw(`${this.tableColumnRef} != ${ref}`);\n      return builderClient;\n    }\n    const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;\n    builderClient.whereRaw(`${this.tableColumnRef} != ?`, [parseValue]);\n    return builderClient;\n  }\n\n  containsOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    _operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, _operator);\n    return super.containsOperatorHandler(builderClient, _operator, value, dbProvider);\n  }\n\n  doesNotContainOperatorHandler(\n    builderClient: Knex.QueryBuilder,\n    operator: IFilterOperator,\n    value: ILiteralValue,\n    dbProvider: IDbProvider\n  ): Knex.QueryBuilder {\n    this.ensureLiteralValue(value, operator);\n    return super.doesNotContainOperatorHandler(builderClient, operator, value, dbProvider);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/filter-query/sqlite/filter-query.sqlite.ts",
    "content": "import type { FieldCore, IFilter } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface';\nimport type { IDbProvider, IFilterQueryExtra } from '../../db.provider.interface';\nimport type { AbstractCellValueFilter } from '../cell-value-filter.abstract';\nimport { AbstractFilterQuery } from '../filter-query.abstract';\nimport {\n  BooleanCellValueFilterAdapter,\n  DatetimeCellValueFilterAdapter,\n  JsonCellValueFilterAdapter,\n  MultipleBooleanCellValueFilterAdapter,\n  MultipleDatetimeCellValueFilterAdapter,\n  MultipleJsonCellValueFilterAdapter,\n  MultipleNumberCellValueFilterAdapter,\n  MultipleStringCellValueFilterAdapter,\n  NumberCellValueFilterAdapter,\n  StringCellValueFilterAdapter,\n} from './cell-value-filter';\nimport type { CellValueFilterSqlite } from './cell-value-filter/cell-value-filter.sqlite';\n\nexport class FilterQuerySqlite extends AbstractFilterQuery {\n  constructor(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    filter?: IFilter,\n    extra?: IFilterQueryExtra,\n    dbProvider?: IDbProvider,\n    context?: IRecordQueryFilterContext\n  ) {\n    super(originQueryBuilder, fields, filter, extra, dbProvider, context);\n  }\n  booleanFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleBooleanCellValueFilterAdapter(field, context);\n    }\n    return new BooleanCellValueFilterAdapter(field, context);\n  }\n\n  numberFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleNumberCellValueFilterAdapter(field, context);\n    }\n    return new NumberCellValueFilterAdapter(field, context);\n  }\n\n  dateTimeFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleDatetimeCellValueFilterAdapter(field, context);\n    }\n    return new DatetimeCellValueFilterAdapter(field, context);\n  }\n\n  stringFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleStringCellValueFilterAdapter(field, context);\n    }\n    return new StringCellValueFilterAdapter(field, context);\n  }\n\n  jsonFilter(field: FieldCore, context?: IRecordQueryFilterContext): AbstractCellValueFilter {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleJsonCellValueFilterAdapter(field, context);\n    }\n    return new JsonCellValueFilterAdapter(field, context);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/formula-query.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayCompact function 1`] = `\"ARRAY(SELECT x FROM UNNEST(column_a) AS x WHERE x IS NOT NULL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayFlatten function 1`] = `\"ARRAY(SELECT UNNEST(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 1`] = `\"ARRAY_TO_STRING(column_a, ', ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 2`] = `\"ARRAY_TO_STRING(column_a, ' | ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayUnique function 1`] = `\"ARRAY(SELECT DISTINCT UNNEST(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement count function 1`] = `\"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_c IS NOT NULL THEN 1 ELSE 0 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countA function 1`] = `\"(CASE WHEN column_a IS NULL OR COALESCE(NULLIF((column_a)::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN column_b IS NULL OR COALESCE(NULLIF((column_b)::text, ''), '') = '' THEN 0 ELSE 1 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countAll function 1`] = `\"CASE WHEN column_a IS NULL THEN 0 ELSE 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 1`] = `\"CASE WHEN (SUM(a, b) > 100) THEN ROUND((a / b)::numeric, 2::integer) ELSE (UPPER(c) || ' - ' || LOWER(d)) END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 2`] = `\"CASE WHEN ((a + b) > 100) THEN ROUND((a / b), 2) ELSE (COALESCE(UPPER(c), '') || COALESCE(' - ', '') || COALESCE(LOWER(d), '')) END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle deeply nested expressions 1`] = `\"(((((base)))))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 1`] = `\"SUM()\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 2`] = `\"SUM(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 3`] = `\"'test''quote'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 4`] = `\"'test\"double'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 5`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 6`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 2`] = `\"column_a\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 3`] = `\"'test''quote'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 4`] = `\"'test\"double'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 5`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 6`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 1`] = `\"\"column_a\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 2`] = `\"\\`column_a\\`\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement createdTime function 1`] = `\"__created_time__\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement dateAdd function with parameters 1`] = `\"column_a::timestamp + INTERVAL 'days' * 5::integer\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datestr function with parameters 1`] = `\"column_a::date::text\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeDiff function with parameters 1`] = `\"EXTRACT(DAY FROM column_b::timestamp - column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `\"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `\"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\\\d{4}\\\\-\\\\d{2}\\\\-\\\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `\"EXTRACT(DAY FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement hour function 1`] = `\"EXTRACT(HOUR FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 1`] = `\"column_a::timestamp = column_b::timestamp\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 2`] = `\"DATE_TRUNC('day', column_a::timestamp) = DATE_TRUNC('day', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 3`] = `\"DATE_TRUNC('month', column_a::timestamp) = DATE_TRUNC('month', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 4`] = `\"DATE_TRUNC('year', column_a::timestamp) = DATE_TRUNC('year', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement lastModifiedTime function 1`] = `\"__last_modified_time__\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement minute function 1`] = `\"EXTRACT(MINUTE FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement month function 1`] = `\"EXTRACT(MONTH FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement now function 1`] = `\"NOW()\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement second function 1`] = `\"EXTRACT(SECOND FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement today function 1`] = `\"CURRENT_DATE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekNum function 1`] = `\"EXTRACT(WEEK FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekday function 1`] = `\"EXTRACT(DOW FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workday function with parameters 1`] = `\"column_a::date + INTERVAL '1 day' * 5::integer\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workdayDiff function with parameters 1`] = `\"column_b::date - column_a::date\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement year function 1`] = `\"EXTRACT(YEAR FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should handle field references 1`] = `\"\"column_a\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should set and use context 1`] = `\"\"test_column\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 1`] = `\"TRUE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 2`] = `\"FALSE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement nullLiteral 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 1`] = `\"42\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 2`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 1`] = `\"'hello'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 2`] = `\"'it''s'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 1`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 2`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 1`] = `\"((condition1) AND NOT (condition2)) OR (NOT (condition1) AND (condition2))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 2`] = `\"(condition1 + condition2 + condition3) % 2 = 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement and function 1`] = `\"(condition1 AND condition2 AND condition3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement blank function 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement if function 1`] = `\"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement isError function 1`] = `\"CASE WHEN column_a IS NULL THEN TRUE ELSE FALSE END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement not function 1`] = `\"NOT (condition)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement or function 1`] = `\"(condition1 OR condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement abs function 1`] = `\"ABS(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement average function 1`] = `\"AVG(column_a, column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement ceiling function 1`] = `\"CEIL(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement even function 1`] = `\"CASE WHEN column_a::integer % 2 = 0 THEN column_a::integer ELSE column_a::integer + 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement exp function 1`] = `\"EXP(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement floor function 1`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement int function 1`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement log function 1`] = `\"LN(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement max function 1`] = `\"GREATEST(column_a, column_b, 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement min function 1`] = `\"LEAST(column_a, column_b, 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement mod function with parameters 1`] = `\"MOD(column_a::numeric, 3::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement odd function 1`] = `\"CASE WHEN column_a::integer % 2 = 1 THEN column_a::integer ELSE column_a::integer + 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement power function with parameters 1`] = `\"POWER(column_a::numeric, 2::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 1`] = `\"ROUND(column_a::numeric, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 2`] = `\"ROUND(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 1`] = `\"FLOOR(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 2`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 1`] = `\"CEIL(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 2`] = `\"CEIL(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sqrt function 1`] = `\"SQRT(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sum function 1`] = `\"SUM(column_a, column_b, 10)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement value function 1`] = `\"column_a::numeric\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 1`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 2`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement abs function for SQLite 1`] = `\"ABS(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement average function for SQLite 1`] = `\"((column_a + column_b) / 2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 1`] = `\"1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 2`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToBoolean function for SQLite 1`] = `\"CAST(column_a AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToDate function for SQLite 1`] = `\"DATETIME(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToNumber function for SQLite 1`] = `\"CAST(column_a AS REAL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToString function for SQLite 1`] = `\"CAST(column_a AS TEXT)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement ceiling function for SQLite 1`] = `\"CAST(CEIL(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement concatenate function for SQLite 1`] = `\"(COALESCE(column_a, '') || COALESCE(' - ', '') || COALESCE(column_b, ''))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement count function for SQLite 1`] = `\"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement day function for SQLite 1`] = `\"CAST(STRFTIME('%d', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement exp function for SQLite 1`] = `\"EXP(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement fieldReference function for SQLite 1`] = `\"\\`column_a\\`\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 1`] = `\"INSTR(column_a, 'text')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 2`] = `\"CASE WHEN INSTR(SUBSTR(column_a, 5), 'text') > 0 THEN INSTR(SUBSTR(column_a, 5), 'text') + 5 - 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement floor function for SQLite 1`] = `\"CAST(FLOOR(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement if function for SQLite 1`] = `\"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement isError function for SQLite 1`] = `\"CASE WHEN column_a IS NULL THEN 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement left function for SQLite 1`] = `\"SUBSTR(column_a, 1, 5)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement len function for SQLite 1`] = `\"LENGTH(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement log function for SQLite 1`] = `\"LN(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement lower function for SQLite 1`] = `\"LOWER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement max function for SQLite 1`] = `\"MAX(MAX(column_a, column_b), 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mid function for SQLite 1`] = `\"SUBSTR(column_a, 2, 5)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement min function for SQLite 1`] = `\"MIN(MIN(column_a, column_b), 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mod function for SQLite 1`] = `\"(column_a % 3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement month function for SQLite 1`] = `\"CAST(STRFTIME('%m', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement now function for SQLite 1`] = `\"DATETIME('now')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement power function for SQLite 1`] = `\"POWER(column_a, 2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement right function for SQLite 1`] = `\"SUBSTR(column_a, -3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 1`] = `\"ROUND(column_a, 2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 2`] = `\"ROUND(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 1`] = `\"CAST(FLOOR(column_a * POWER(10, 2)) / POWER(10, 2) AS REAL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 2`] = `\"CAST(FLOOR(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 1`] = `\"CAST(CEIL(column_a * POWER(10, 2)) / POWER(10, 2) AS REAL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 2`] = `\"CAST(CEIL(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 1`] = `\"INSTR(UPPER(column_a), UPPER('text'))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 2`] = `\"CASE WHEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) > 0 THEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) + 3 - 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sqrt function for SQLite 1`] = `\"SQRT(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement substitute function for SQLite 1`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sum function for SQLite 1`] = `\"(column_a + column_b + 10)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement today function for SQLite 1`] = `\"DATE('now')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement trim function for SQLite 1`] = `\"TRIM(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement upper function for SQLite 1`] = `\"UPPER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement year function for SQLite 1`] = `\"CAST(STRFTIME('%Y', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement autoNumber function 1`] = `\"__auto_number\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement recordId function 1`] = `\"__id\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement textAll function 1`] = `\"ARRAY_TO_STRING(column_a, ', ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement concatenate function 1`] = `\"(column_a || ' - ' || column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement encodeUrlComponent function 1`] = `\"encode(column_a::bytea, 'escape')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 1`] = `\"POSITION('text' IN column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 2`] = `\"POSITION('text' IN SUBSTRING(column_a FROM 5::integer)) + 5::integer - 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement left function 1`] = `\"LEFT(column_a, 5::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement len function 1`] = `\"LENGTH(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement lower function 1`] = `\"LOWER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement mid function 1`] = `\"SUBSTRING(column_a FROM 2::integer FOR 5::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement regexpReplace function 1`] = `\"REGEXP_REPLACE((column_a)::text, ('pattern')::text, ('replacement')::text, 'g')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement replace function 1`] = `\"OVERLAY(column_a PLACING 'new' FROM 2::integer FOR 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement rept function 1`] = `\"REPEAT(column_a, 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement right function 1`] = `\"RIGHT(column_a, 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 1`] = `\"POSITION(UPPER('text') IN UPPER(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 2`] = `\"POSITION(UPPER('text') IN UPPER(SUBSTRING(column_a FROM 3::integer))) + 3::integer - 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 1`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 2`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement t function 1`] = `\"CASE WHEN column_a IS NULL THEN '' ELSE column_a::text END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement trim function 1`] = `\"TRIM(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement upper function 1`] = `\"UPPER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement add operation 1`] = `\"(column_a + column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement bitwiseAnd operation 1`] = `\"(column_a & column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToBoolean operation 1`] = `\"column_a::boolean\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToDate operation 1`] = `\"column_a::timestamp\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToNumber operation 1`] = `\"column_a::numeric\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToString operation 1`] = `\"column_a::text\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement divide operation 1`] = `\"(column_a / column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement equal operation 1`] = `\"(column_a = column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThan operation 1`] = `\"(column_a > 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThanOrEqual operation 1`] = `\"(column_a >= 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThan operation 1`] = `\"(column_a < 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThanOrEqual operation 1`] = `\"(column_a <= 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalAnd operation 1`] = `\"(condition1 AND condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalOr operation 1`] = `\"(condition1 OR condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement modulo operation 1`] = `\"(column_a % column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement multiply operation 1`] = `\"(column_a * column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement notEqual operation 1`] = `\"(column_a <> column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement parentheses operation 1`] = `\"(expression)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement subtract operation 1`] = `\"(column_a - column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement unaryMinus operation 1`] = `\"(-column_a)\"`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/generated-column-query.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayCompact function 1`] = `\"ARRAY(SELECT x FROM UNNEST(column_a) AS x WHERE x IS NOT NULL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayFlatten function 1`] = `\"ARRAY(SELECT UNNEST(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 1`] = `\"ARRAY_TO_STRING(column_a, ', ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 2`] = `\"ARRAY_TO_STRING(column_a, ' | ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayUnique function 1`] = `\"ARRAY(SELECT DISTINCT UNNEST(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement count function 1`] = `\"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_c IS NOT NULL THEN 1 ELSE 0 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countA function 1`] = `\"(CASE WHEN column_a IS NULL OR COALESCE(NULLIF((column_a)::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN column_b IS NULL OR COALESCE(NULLIF((column_b)::text, ''), '') = '' THEN 0 ELSE 1 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countAll function 1`] = `\"CASE WHEN column_a IS NULL THEN 0 ELSE 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 1`] = `\"CASE WHEN ((a + b) > 100) THEN ROUND((a / b)::numeric, 2::integer) ELSE (COALESCE(UPPER(c)::text, '') || COALESCE(' - '::text, '') || COALESCE(LOWER(d)::text, '')) END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 2`] = `\"CASE WHEN ((a + b) > 100) THEN ROUND((a / b), 2) ELSE (COALESCE(UPPER(c), '') || COALESCE(' - ', '') || COALESCE(LOWER(d), '')) END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle deeply nested expressions 1`] = `\"(((((base)))))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 1`] = `\"()\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 2`] = `\"(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 3`] = `\"'test''quote'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 4`] = `\"'test\"double'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 5`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 6`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 2`] = `\"column_a\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 3`] = `\"'test''quote'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 4`] = `\"'test\"double'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 5`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 6`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 1`] = `\"\"column_a\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 2`] = `\"\\`column_a\\`\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement createdTime function 1`] = `\"\"__created_time\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement dateAdd function with parameters 1`] = `\"column_a::timestamp + INTERVAL 'days' * 5::integer\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datestr function with parameters 1`] = `\"column_a::date::text\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeDiff function with parameters 1`] = `\"EXTRACT(DAY FROM column_b::timestamp - column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `\"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `\"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\\\d{4}\\\\-\\\\d{2}\\\\-\\\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `\"EXTRACT(DAY FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement hour function 1`] = `\"EXTRACT(HOUR FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 1`] = `\"column_a::timestamp = column_b::timestamp\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 2`] = `\"DATE_TRUNC('day', column_a::timestamp) = DATE_TRUNC('day', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 3`] = `\"DATE_TRUNC('month', column_a::timestamp) = DATE_TRUNC('month', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 4`] = `\"DATE_TRUNC('year', column_a::timestamp) = DATE_TRUNC('year', column_b::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement lastModifiedTime function 1`] = `\"\"__last_modified_time\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement minute function 1`] = `\"EXTRACT(MINUTE FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement month function 1`] = `\"EXTRACT(MONTH FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement now function 1`] = `\"NOW()\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement second function 1`] = `\"EXTRACT(SECOND FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement today function 1`] = `\"CURRENT_DATE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekNum function 1`] = `\"EXTRACT(WEEK FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekday function 1`] = `\"EXTRACT(DOW FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workday function with parameters 1`] = `\"column_a::date + INTERVAL '1 day' * 5::integer\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workdayDiff function with parameters 1`] = `\"column_b::date - column_a::date\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement year function 1`] = `\"EXTRACT(YEAR FROM column_a::timestamp)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should handle field references 1`] = `\"\"column_a\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should set and use context 1`] = `\"\"test_column\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 1`] = `\"TRUE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 2`] = `\"FALSE\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement nullLiteral 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 1`] = `\"42\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 2`] = `\"-3.14\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 1`] = `\"'hello'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 2`] = `\"'it''s'\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 1`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 2`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 1`] = `\"((condition1) AND NOT (condition2)) OR (NOT (condition1) AND (condition2))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 2`] = `\"(condition1 + condition2 + condition3) % 2 = 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement and function 1`] = `\"(condition1 AND condition2 AND condition3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement blank function 1`] = `\"NULL\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement if function 1`] = `\"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement isError function 1`] = `\"CASE WHEN column_a IS NULL THEN TRUE ELSE FALSE END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement not function 1`] = `\"NOT (condition)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement or function 1`] = `\"(condition1 OR condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement abs function 1`] = `\"ABS(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement average function 1`] = `\"(column_a + column_b) / 2\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement ceiling function 1`] = `\"CEIL(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement even function 1`] = `\"CASE WHEN column_a::integer % 2 = 0 THEN column_a::integer ELSE column_a::integer + 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement exp function 1`] = `\"EXP(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement floor function 1`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement int function 1`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement log function 1`] = `\"LN(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement max function 1`] = `\"GREATEST(column_a, column_b, 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement min function 1`] = `\"LEAST(column_a, column_b, 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement mod function with parameters 1`] = `\"MOD(column_a::numeric, 3::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement odd function 1`] = `\"CASE WHEN column_a::integer % 2 = 1 THEN column_a::integer ELSE column_a::integer + 1 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement power function with parameters 1`] = `\"POWER(column_a::numeric, 2::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 1`] = `\"ROUND(column_a::numeric, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 2`] = `\"ROUND(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 1`] = `\"FLOOR(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 2`] = `\"FLOOR(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 1`] = `\"CEIL(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 2`] = `\"CEIL(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sqrt function 1`] = `\"SQRT(column_a::numeric)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sum function 1`] = `\"(column_a + column_b + 10)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement value function 1`] = `\"column_a::numeric\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 1`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 2`] = `\"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement abs function for SQLite 1`] = `\"ABS(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement average function for SQLite 1`] = `\"((column_a + column_b) / 2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 1`] = `\"1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 2`] = `\"0\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToBoolean function for SQLite 1`] = `\"CAST(column_a AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToDate function for SQLite 1`] = `\"DATETIME(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToNumber function for SQLite 1`] = `\"CAST(column_a AS REAL)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToString function for SQLite 1`] = `\"CAST(column_a AS TEXT)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement ceiling function for SQLite 1`] = `\"CAST(CEIL(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement concatenate function for SQLite 1`] = `\"(COALESCE(column_a, '') || COALESCE(' - ', '') || COALESCE(column_b, ''))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement count function for SQLite 1`] = `\"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement day function for SQLite 1`] = `\"CAST(STRFTIME('%d', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement exp function for SQLite 1`] = `\"EXP(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement fieldReference function for SQLite 1`] = `\"\\`column_a\\`\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 1`] = `\"INSTR(column_a, 'text')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 2`] = `\"CASE WHEN INSTR(SUBSTR(column_a, 5), 'text') > 0 THEN INSTR(SUBSTR(column_a, 5), 'text') + 5 - 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement floor function for SQLite 1`] = `\"CAST(FLOOR(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement if function for SQLite 1`] = `\"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement isError function for SQLite 1`] = `\"CASE WHEN column_a IS NULL THEN 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement left function for SQLite 1`] = `\"SUBSTR(column_a, 1, 5)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement len function for SQLite 1`] = `\"LENGTH(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement log function for SQLite 1`] = `\"LN(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement lower function for SQLite 1`] = `\"LOWER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement max function for SQLite 1`] = `\"MAX(MAX(column_a, column_b), 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mid function for SQLite 1`] = `\"SUBSTR(column_a, 2, 5)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement min function for SQLite 1`] = `\"MIN(MIN(column_a, column_b), 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mod function for SQLite 1`] = `\"(column_a % 3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement month function for SQLite 1`] = `\"CAST(STRFTIME('%m', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement now function for SQLite 1`] = `\"DATETIME('now')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement power function for SQLite 1`] = `\n\"(\n      CASE\n        WHEN 2 = 0 THEN 1\n        WHEN 2 = 1 THEN column_a\n        WHEN 2 = 2 THEN column_a * column_a\n        WHEN 2 = 3 THEN column_a * column_a * column_a\n        WHEN 2 = 4 THEN column_a * column_a * column_a * column_a\n        WHEN 2 = 0.5 THEN\n          -- Square root case using Newton's method\n          CASE\n            WHEN column_a <= 0 THEN 0\n            ELSE (column_a / 2.0 + column_a / (column_a / 2.0)) / 2.0\n          END\n        ELSE 1\n      END\n    )\"\n`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement right function for SQLite 1`] = `\"SUBSTR(column_a, -3)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 1`] = `\"ROUND(column_a, 2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 2`] = `\"ROUND(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 1`] = `\n\"CAST(FLOOR(column_a * (\n        CASE\n          WHEN 2 = 0 THEN 1\n          WHEN 2 = 1 THEN 10\n          WHEN 2 = 2 THEN 100\n          WHEN 2 = 3 THEN 1000\n          WHEN 2 = 4 THEN 10000\n          ELSE 1\n        END\n      )) / (\n        CASE\n          WHEN 2 = 0 THEN 1\n          WHEN 2 = 1 THEN 10\n          WHEN 2 = 2 THEN 100\n          WHEN 2 = 3 THEN 1000\n          WHEN 2 = 4 THEN 10000\n          ELSE 1\n        END\n      ) AS REAL)\"\n`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 2`] = `\"CAST(FLOOR(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 1`] = `\n\"CAST(CEIL(column_a * (\n        CASE\n          WHEN 2 = 0 THEN 1\n          WHEN 2 = 1 THEN 10\n          WHEN 2 = 2 THEN 100\n          WHEN 2 = 3 THEN 1000\n          WHEN 2 = 4 THEN 10000\n          ELSE 1\n        END\n      )) / (\n        CASE\n          WHEN 2 = 0 THEN 1\n          WHEN 2 = 1 THEN 10\n          WHEN 2 = 2 THEN 100\n          WHEN 2 = 3 THEN 1000\n          WHEN 2 = 4 THEN 10000\n          ELSE 1\n        END\n      ) AS REAL)\"\n`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 2`] = `\"CAST(CEIL(column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 1`] = `\"INSTR(UPPER(column_a), UPPER('text'))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 2`] = `\"CASE WHEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) > 0 THEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) + 3 - 1 ELSE 0 END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sqrt function for SQLite 1`] = `\n\"(\n      CASE\n        WHEN column_a <= 0 THEN 0\n        ELSE (column_a / 2.0 + column_a / (column_a / 2.0)) / 2.0\n      END\n    )\"\n`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement substitute function for SQLite 1`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sum function for SQLite 1`] = `\"(column_a + column_b + 10)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement today function for SQLite 1`] = `\"DATE('now')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement trim function for SQLite 1`] = `\"TRIM(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement upper function for SQLite 1`] = `\"UPPER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement year function for SQLite 1`] = `\"CAST(STRFTIME('%Y', column_a) AS INTEGER)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement autoNumber function 1`] = `\"\"__auto_number\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement recordId function 1`] = `\"\"__id\"\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement textAll function 1`] = `\"ARRAY_TO_STRING(column_a, ', ')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement concatenate function 1`] = `\"(COALESCE(column_a::text, '') || COALESCE(' - '::text, '') || COALESCE(column_b::text, ''))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement encodeUrlComponent function 1`] = `\"encode(column_a::bytea, 'escape')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 1`] = `\"POSITION('text' IN column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 2`] = `\"POSITION('text' IN SUBSTRING(column_a FROM 5::integer)) + 5::integer - 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement left function 1`] = `\"LEFT(column_a, 5::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement len function 1`] = `\"LENGTH(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement lower function 1`] = `\"LOWER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement mid function 1`] = `\"SUBSTRING(column_a FROM 2::integer FOR 5::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement regexpReplace function 1`] = `\"REGEXP_REPLACE((column_a)::text, ('pattern')::text, ('replacement')::text, 'g')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement replace function 1`] = `\"OVERLAY(column_a PLACING 'new' FROM 2::integer FOR 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement rept function 1`] = `\"REPEAT(column_a, 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement right function 1`] = `\"RIGHT(column_a, 3::integer)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 1`] = `\"POSITION(UPPER('text') IN UPPER(column_a))\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 2`] = `\"POSITION(UPPER('text') IN UPPER(SUBSTRING(column_a FROM 3::integer))) + 3::integer - 1\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 1`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 2`] = `\"REPLACE(column_a, 'old', 'new')\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement t function 1`] = `\"CASE WHEN column_a IS NULL THEN '' ELSE column_a::text END\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement trim function 1`] = `\"TRIM(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement upper function 1`] = `\"UPPER(column_a)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement add operation 1`] = `\"(column_a + column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement bitwiseAnd operation 1`] = `\n\"(\n      CASE\n        WHEN column_a::text ~ '^-?[0-9]+$' AND column_a::text != '' THEN column_a::integer\n        ELSE 0\n      END &\n      CASE\n        WHEN column_b::text ~ '^-?[0-9]+$' AND column_b::text != '' THEN column_b::integer\n        ELSE 0\n      END\n    )\"\n`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToBoolean operation 1`] = `\"column_a::boolean\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToDate operation 1`] = `\"column_a::timestamp\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToNumber operation 1`] = `\"column_a::numeric\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToString operation 1`] = `\"column_a::text\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement divide operation 1`] = `\"(column_a / column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement equal operation 1`] = `\"(column_a = column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThan operation 1`] = `\"(column_a > 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThanOrEqual operation 1`] = `\"(column_a >= 0)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThan operation 1`] = `\"(column_a < 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThanOrEqual operation 1`] = `\"(column_a <= 100)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalAnd operation 1`] = `\"(condition1 AND condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalOr operation 1`] = `\"(condition1 OR condition2)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement modulo operation 1`] = `\"(column_a % column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement multiply operation 1`] = `\"(column_a * column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement notEqual operation 1`] = `\"(column_a <> column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement parentheses operation 1`] = `\"(expression)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement subtract operation 1`] = `\"(column_a - column_b)\"`;\n\nexports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement unaryMinus operation 1`] = `\"(-column_a)\"`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/sql-conversion.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 1`] = `\n{\n  \"dependencies\": [\n    \"numField\",\n  ],\n  \"sql\": \"(\"num_col\" + \"num_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 2`] = `\n{\n  \"dependencies\": [\n    \"textField\",\n  ],\n  \"sql\": \"(\"text_col\" || \"text_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 3`] = `\n{\n  \"dependencies\": [\n    \"textField\",\n    \"numField\",\n  ],\n  \"sql\": \"(\"text_col\" || \"num_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 4`] = `\n{\n  \"dependencies\": [\n    \"numField\",\n    \"textField\",\n  ],\n  \"sql\": \"(\"num_col\" || \"text_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 5`] = `\n{\n  \"dependencies\": [\n    \"boolField\",\n    \"numField\",\n  ],\n  \"sql\": \"(\"bool_col\" + \"num_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 6`] = `\n{\n  \"dependencies\": [\n    \"dateField\",\n    \"textField\",\n  ],\n  \"sql\": \"(\"date_col\" || \"text_col\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for \"test string\" 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"'test string'\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for ({fld1} + {fld2}) 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld2\",\n  ],\n  \"sql\": \"((\"column_a\" || \"column_b\"))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} != {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" <> \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} % {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" % \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} & {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" & \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} * {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" * \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} / {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" / \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} < {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" < \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} <= {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" <= \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} <> {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"\"column_a\"\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} = {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" = \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} > {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" > \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} >= {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" >= \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} - {fld3} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld3\",\n  ],\n  \"sql\": \"(\"column_a\" - \"column_c\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld5} && {fld1} > 0 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\"column_e\" AND (\"column_a\" > 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld5} || {fld1} > 0 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\"column_e\" OR (\"column_a\" > 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for -{fld1} 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"(-\"column_a\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for 3.14 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"3.14\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for 42 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"42\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for FALSE 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"FALSE\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for TRUE 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"TRUE\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function CREATED_TIME() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__created_time__\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function DAY({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(DAY FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function DAY({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%d', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function HOUR({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(HOUR FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function HOUR({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%H', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function IS_SAME({fld6}, NOW(), \"day\") for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"DATE_TRUNC('day', \"column_f\"::timestamp) = DATE_TRUNC('day', NOW()::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function LAST_MODIFIED_TIME() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__last_modified_time__\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MINUTE({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(MINUTE FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MINUTE({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%M', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MONTH({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(MONTH FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MONTH({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%m', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function SECOND({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(SECOND FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function SECOND({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%S', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function TODAY() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"CURRENT_DATE\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function TODAY() for SQLite 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"DATE('now')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WEEKDAY({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(DOW FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WEEKNUM({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(WEEK FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WORKDAY({fld6}, 5) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"\"column_f\"::date + INTERVAL '1 day' * 5::integer\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WORKDAY_DIFF({fld6}, NOW()) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"NOW()::date - \"column_f\"::date\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function YEAR({fld6}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"EXTRACT(YEAR FROM \"column_f\"::timestamp)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function YEAR({fld6}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld6\",\n  ],\n  \"sql\": \"CAST(STRFTIME('%Y', \\`column_f\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ABS({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ABS(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ABS({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ABS(\\`column_a\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function CEILING({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CEIL(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function CEILING({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CAST(CEIL(\\`column_a\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EVEN({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \"column_a\"::integer % 2 = 0 THEN \"column_a\"::integer ELSE \"column_a\"::integer + 1 END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EXP({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"EXP(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EXP({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"EXP(\\`column_a\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function FLOOR({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"FLOOR(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function FLOOR({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CAST(FLOOR(\\`column_a\\`) AS INTEGER)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function INT({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"FLOOR(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function LOG({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"LN(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function LOG({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"LN(\\`column_a\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function MOD({fld1}, 3) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"MOD(\"column_a\"::numeric, 3::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function MOD({fld1}, 3) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"(\\`column_a\\` % 3)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ODD({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \"column_a\"::integer % 2 = 1 THEN \"column_a\"::integer ELSE \"column_a\"::integer + 1 END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function POWER({fld1}, 2) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"POWER(\"column_a\"::numeric, 2::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function POWER({fld1}, 2) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"POWER(\\`column_a\\`, 2)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDDOWN({fld1}, 1) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"FLOOR(\"column_a\"::numeric * POWER(10, 1::integer)) / POWER(10, 1::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDDOWN({fld1}, 1) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CAST(FLOOR(\\`column_a\\` * POWER(10, 1)) / POWER(10, 1) AS REAL)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDUP({fld1}, 2) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CEIL(\"column_a\"::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDUP({fld1}, 2) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CAST(CEIL(\\`column_a\\` * POWER(10, 2)) / POWER(10, 2) AS REAL)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function SQRT({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"SQRT(\"column_a\"::numeric)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function SQRT({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"SQRT(\\`column_a\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function VALUE({fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"\"column_b\"::numeric\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AND({fld5}, {fld1} > 0) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\"column_e\" AND (\"column_a\" > 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AND({fld5}, {fld1} > 0) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\\`column_e\\` AND (\\`column_a\\` > 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_COMPACT({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY(SELECT x FROM UNNEST(\"column_a\") AS x WHERE x IS NOT NULL)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_FLATTEN({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY(SELECT UNNEST(\"column_a\"))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_JOIN({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY_TO_STRING(\"column_a\", ', ')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_JOIN({fld1}, \" | \") for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY_TO_STRING(\"column_a\", ' | ')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_UNIQUE({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY(SELECT DISTINCT UNNEST(\"column_a\"))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AUTO_NUMBER() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__auto_number\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AUTO_NUMBER() for SQLite 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__auto_number\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function BLANK() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"NULL\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function BLANK() for SQLite 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"NULL\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNT({fld1}, {fld2}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld2\",\n  ],\n  \"sql\": \"(CASE WHEN \\`column_a\\` IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN \\`column_b\\` IS NOT NULL THEN 1 ELSE 0 END)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNT({fld1}, {fld2}, {fld3}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld2\",\n    \"fld3\",\n  ],\n  \"sql\": \"(CASE WHEN \"column_a\" IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN \"column_b\" IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN \"column_c\" IS NOT NULL THEN 1 ELSE 0 END)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNTA({fld1}, {fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n    \"fld2\",\n  ],\n  \"sql\": \"(CASE WHEN \\\"column_a\\\" IS NULL OR COALESCE(NULLIF((\\\"column_a\\\")::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN \\\"column_b\\\" IS NULL OR COALESCE(NULLIF((\\\"column_b\\\")::text, ''), '') = '' THEN 0 ELSE 1 END)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNTALL({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \"column_a\" IS NULL THEN 0 ELSE 1 END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function IS_ERROR({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \"column_a\" IS NULL THEN TRUE ELSE FALSE END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function IS_ERROR({fld1}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \\`column_a\\` IS NULL THEN 1 ELSE 0 END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function NOT({fld5}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n  ],\n  \"sql\": \"NOT (\"column_e\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function NOT({fld5}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n  ],\n  \"sql\": \"NOT (\\`column_e\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function OR({fld5}, {fld1} < 0) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\"column_e\" OR (\"column_a\" < 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function OR({fld5}, {fld1} < 0) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"(\\`column_e\\` OR (\\`column_a\\` < 0))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function RECORD_ID() for PostgreSQL 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__id\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function RECORD_ID() for SQLite 1`] = `\n{\n  \"dependencies\": [],\n  \"sql\": \"__id\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function TEXT_ALL({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"ARRAY_TO_STRING(\"column_a\", ', ')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function XOR({fld5}, {fld1} > 0) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld5\",\n    \"fld1\",\n  ],\n  \"sql\": \"((\"column_e\") AND NOT ((\"column_a\" > 0))) OR (NOT (\"column_e\") AND ((\"column_a\" > 0)))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND(\"test\", {fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"POSITION('test' IN \"column_b\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND(\"test\", {fld2}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"INSTR(\\`column_b\\`, 'test')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND(\"test\", {fld2}, 5) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"POSITION('test' IN SUBSTRING(\"column_b\" FROM 5::integer)) + 5::integer - 1\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEFT({fld2}, 3) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"LEFT(\"column_b\", 3::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEFT({fld2}, 3) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"SUBSTR(\\`column_b\\`, 1, 3)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEN({fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"LENGTH(\"column_b\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEN({fld2}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"LENGTH(\\`column_b\\`)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function MID({fld2}, 2, 5) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"SUBSTRING(\"column_b\" FROM 2::integer FOR 5::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function MID({fld2}, 2, 5) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"SUBSTR(\\`column_b\\`, 2, 5)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function REPLACE({fld2}, 1, 2, \"new\") for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"OVERLAY(\"column_b\" PLACING 'new' FROM 1::integer FOR 2::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function REPT({fld2}, 3) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"REPEAT(\"column_b\", 3::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function RIGHT({fld2}, 3) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"RIGHT(\"column_b\", 3::integer)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function RIGHT({fld2}, 3) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"SUBSTR(\\`column_b\\`, -3)\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SEARCH(\"test\", {fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"POSITION(UPPER('test') IN UPPER(\"column_b\"))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SEARCH(\"test\", {fld2}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"INSTR(UPPER(\\`column_b\\`), UPPER('test'))\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SUBSTITUTE({fld2}, \"old\", \"new\") for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"REPLACE(\"column_b\", 'old', 'new')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SUBSTITUTE({fld2}, \"old\", \"new\") for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"REPLACE(\\`column_b\\`, 'old', 'new')\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function T({fld1}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld1\",\n  ],\n  \"sql\": \"CASE WHEN \"column_a\" IS NULL THEN '' ELSE \"column_a\"::text END\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function TRIM({fld2}) for PostgreSQL 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"TRIM(\"column_b\")\",\n}\n`;\n\nexports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function TRIM({fld2}) for SQLite 1`] = `\n{\n  \"dependencies\": [\n    \"fld2\",\n  ],\n  \"sql\": \"TRIM(\\`column_b\\`)\",\n}\n`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query-support-validator.spec.ts",
    "content": "import { GeneratedColumnQuerySupportValidatorPostgres } from './postgres/generated-column-query-support-validator.postgres';\nimport { GeneratedColumnQuerySupportValidatorSqlite } from './sqlite/generated-column-query-support-validator.sqlite';\n\ndescribe('GeneratedColumnQuerySupportValidator', () => {\n  let postgresValidator: GeneratedColumnQuerySupportValidatorPostgres;\n  let sqliteValidator: GeneratedColumnQuerySupportValidatorSqlite;\n\n  beforeEach(() => {\n    postgresValidator = new GeneratedColumnQuerySupportValidatorPostgres();\n    sqliteValidator = new GeneratedColumnQuerySupportValidatorSqlite();\n  });\n\n  describe('PostgreSQL Support Validator', () => {\n    it('should support basic numeric functions', () => {\n      expect(postgresValidator.sum(['a', 'b'])).toBe(true);\n      expect(postgresValidator.average(['a', 'b'])).toBe(true);\n      expect(postgresValidator.max(['a', 'b'])).toBe(true);\n      expect(postgresValidator.min(['a', 'b'])).toBe(true);\n      expect(postgresValidator.round('a', '2')).toBe(true);\n      expect(postgresValidator.abs('a')).toBe(true);\n      expect(postgresValidator.sqrt('a')).toBe(true);\n      expect(postgresValidator.power('a', 'b')).toBe(true);\n    });\n\n    it('should support basic text functions', () => {\n      expect(postgresValidator.concatenate(['a', 'b'])).toBe(true);\n      expect(postgresValidator.upper('a')).toBe(false); // Requires collation in PostgreSQL\n      expect(postgresValidator.lower('a')).toBe(false); // Requires collation in PostgreSQL\n      expect(postgresValidator.trim('a')).toBe(true);\n      expect(postgresValidator.len('a')).toBe(true);\n      expect(postgresValidator.regexpReplace('a', 'b', 'c')).toBe(false); // Not supported in generated columns\n    });\n\n    it('should not support array functions due to technical limitations', () => {\n      expect(postgresValidator.arrayJoin('a', ',')).toBe(false);\n      expect(postgresValidator.arrayUnique(['a'])).toBe(false);\n      expect(postgresValidator.arrayFlatten(['a'])).toBe(false);\n      expect(postgresValidator.arrayCompact(['a'])).toBe(false);\n    });\n\n    it('should support basic time functions but not time-dependent ones', () => {\n      expect(postgresValidator.now()).toBe(true);\n      expect(postgresValidator.today()).toBe(true);\n      expect(postgresValidator.lastModifiedTime()).toBe(false);\n      expect(postgresValidator.createdTime()).toBe(false);\n      expect(postgresValidator.fromNow('a')).toBe(false);\n      expect(postgresValidator.toNow('a')).toBe(false);\n    });\n\n    it('should support system functions', () => {\n      expect(postgresValidator.recordId()).toBe(false);\n      expect(postgresValidator.autoNumber()).toBe(false);\n    });\n\n    it('should support basic date functions but not complex ones', () => {\n      expect(postgresValidator.dateAdd('a', 'b', 'c')).toBe(false);\n      expect(postgresValidator.datetimeDiff('a', 'b', 'c')).toBe(false); // Not immutable in PostgreSQL\n      expect(postgresValidator.year('a')).toBe(false); // Not immutable in PostgreSQL\n      expect(postgresValidator.month('a')).toBe(false); // Not immutable in PostgreSQL\n      expect(postgresValidator.day('a')).toBe(false); // Not immutable in PostgreSQL\n      expect(postgresValidator.workday('a', 'b')).toBe(false);\n      expect(postgresValidator.workdayDiff('a', 'b')).toBe(false);\n    });\n  });\n\n  describe('SQLite Support Validator', () => {\n    it('should support basic numeric functions', () => {\n      expect(sqliteValidator.sum(['a', 'b'])).toBe(true);\n      expect(sqliteValidator.average(['a', 'b'])).toBe(true);\n      expect(sqliteValidator.max(['a', 'b'])).toBe(true);\n      expect(sqliteValidator.min(['a', 'b'])).toBe(true);\n      expect(sqliteValidator.round('a', '2')).toBe(true);\n      expect(sqliteValidator.abs('a')).toBe(true);\n    });\n\n    it('should not support advanced numeric functions', () => {\n      expect(sqliteValidator.sqrt('a')).toBe(true); // SQLite SQRT is implemented\n      expect(sqliteValidator.power('a', 'b')).toBe(true); // SQLite POWER is implemented\n      expect(sqliteValidator.exp('a')).toBe(false);\n      expect(sqliteValidator.log('a', 'b')).toBe(false);\n    });\n\n    it('should support basic text functions', () => {\n      expect(sqliteValidator.concatenate(['a', 'b'])).toBe(true);\n      expect(sqliteValidator.upper('a')).toBe(true);\n      expect(sqliteValidator.lower('a')).toBe(true);\n      expect(sqliteValidator.trim('a')).toBe(true);\n      expect(sqliteValidator.len('a')).toBe(true);\n    });\n\n    it('should not support advanced text functions', () => {\n      expect(sqliteValidator.regexpReplace('a', 'b', 'c')).toBe(false);\n      expect(sqliteValidator.rept('a', '3')).toBe(false);\n      expect(sqliteValidator.encodeUrlComponent('a')).toBe(false);\n    });\n\n    it('should not support array functions', () => {\n      expect(sqliteValidator.arrayJoin('a', ',')).toBe(false);\n      expect(sqliteValidator.arrayUnique(['a'])).toBe(false);\n      expect(sqliteValidator.arrayFlatten(['a'])).toBe(false);\n      expect(sqliteValidator.arrayCompact(['a'])).toBe(false);\n    });\n\n    it('should support basic time functions but not time-dependent ones', () => {\n      expect(sqliteValidator.now()).toBe(true);\n      expect(sqliteValidator.today()).toBe(true);\n      expect(sqliteValidator.lastModifiedTime()).toBe(false);\n      expect(sqliteValidator.createdTime()).toBe(false);\n      expect(sqliteValidator.fromNow('a')).toBe(false);\n      expect(sqliteValidator.toNow('a')).toBe(false);\n    });\n\n    it('should support system functions', () => {\n      expect(sqliteValidator.recordId()).toBe(false);\n      expect(sqliteValidator.autoNumber()).toBe(false);\n    });\n\n    it('should not support complex date functions', () => {\n      expect(sqliteValidator.workday('a', 'b')).toBe(false);\n      expect(sqliteValidator.workdayDiff('a', 'b')).toBe(false);\n      expect(sqliteValidator.datetimeParse('a', 'b')).toBe(false);\n    });\n\n    it('should support basic date functions', () => {\n      expect(sqliteValidator.dateAdd('a', 'b', 'c')).toBe(false);\n      expect(sqliteValidator.datetimeDiff('a', 'b', 'c')).toBe(true);\n      expect(sqliteValidator.year('a')).toBe(false); // Not immutable in SQLite\n      expect(sqliteValidator.month('a')).toBe(false); // Not immutable in SQLite\n      expect(sqliteValidator.day('a')).toBe(false); // Not immutable in SQLite\n    });\n  });\n\n  describe('Comparison between PostgreSQL and SQLite', () => {\n    it('should show PostgreSQL has more capabilities than SQLite', () => {\n      // Functions that PostgreSQL supports but SQLite doesn't\n      const postgresOnlyFunctions = [\n        // Note: sqrt and power are now supported in both PostgreSQL and SQLite\n        // regexpReplace, encodeUrlComponent, and datetimeParse are not supported in PostgreSQL generated columns\n        () => postgresValidator.exp('a') && !sqliteValidator.exp('a'),\n        () => postgresValidator.log('a', 'b') && !sqliteValidator.log('a', 'b'),\n        () => postgresValidator.rept('a', '3') && !sqliteValidator.rept('a', '3'),\n      ];\n\n      postgresOnlyFunctions.forEach((testFn) => {\n        expect(testFn()).toBe(true);\n      });\n    });\n\n    it('should have same restrictions for error handling and unpredictable time functions', () => {\n      // Both should reject these functions\n      const restrictedFunctions = [\n        'fromNow',\n        'toNow',\n        'error',\n        'isError',\n        'workday',\n        'workdayDiff',\n        'arrayJoin',\n        'arrayUnique',\n        'arrayFlatten',\n        'arrayCompact',\n      ] as const;\n\n      restrictedFunctions.forEach((funcName) => {\n        const arg = funcName.startsWith('array') && funcName !== 'arrayJoin' ? ['test'] : 'test';\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const postgresResult = (postgresValidator as any)[funcName](arg);\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const sqliteResult = (sqliteValidator as any)[funcName](arg);\n        expect(postgresResult).toBe(false);\n        expect(sqliteResult).toBe(false);\n        expect(postgresResult).toBe(sqliteResult);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query.abstract.ts",
    "content": "import type { IFormulaParamMetadata } from '@teable/core';\nimport type {\n  IFormulaConversionContext,\n  IGeneratedColumnQueryInterface,\n} from '../../features/record/query-builder/sql-conversion.visitor';\n\n/**\n * Abstract base class for generated column query implementations\n * Provides common functionality and default implementations for converting\n * Teable formula expressions to database-specific SQL suitable for generated columns\n */\nexport abstract class GeneratedColumnQueryAbstract implements IGeneratedColumnQueryInterface {\n  /** Current conversion context */\n  protected context?: IFormulaConversionContext;\n  protected currentCallMetadata?: IFormulaParamMetadata[];\n\n  /** Set the conversion context */\n  setContext(context: IFormulaConversionContext): void {\n    this.context = context;\n  }\n\n  setCallMetadata(metadata?: IFormulaParamMetadata[]): void {\n    this.currentCallMetadata = metadata;\n  }\n\n  /** Check if we're in a generated column context */\n  protected get isGeneratedColumnContext(): boolean {\n    return this.context?.isGeneratedColumn ?? false;\n  }\n  // Numeric Functions\n  abstract sum(params: string[]): string;\n  abstract average(params: string[]): string;\n  abstract max(params: string[]): string;\n  abstract min(params: string[]): string;\n  abstract round(value: string, precision?: string): string;\n  abstract roundUp(value: string, precision?: string): string;\n  abstract roundDown(value: string, precision?: string): string;\n  abstract ceiling(value: string): string;\n  abstract floor(value: string): string;\n  abstract even(value: string): string;\n  abstract odd(value: string): string;\n  abstract int(value: string): string;\n  abstract abs(value: string): string;\n  abstract sqrt(value: string): string;\n  abstract power(base: string, exponent: string): string;\n  abstract exp(value: string): string;\n  abstract log(value: string, base?: string): string;\n  abstract mod(dividend: string, divisor: string): string;\n  abstract value(text: string): string;\n\n  // Text Functions\n  abstract concatenate(params: string[]): string;\n  abstract stringConcat(left: string, right: string): string;\n  abstract find(searchText: string, withinText: string, startNum?: string): string;\n  abstract search(searchText: string, withinText: string, startNum?: string): string;\n  abstract mid(text: string, startNum: string, numChars: string): string;\n  abstract left(text: string, numChars: string): string;\n  abstract right(text: string, numChars: string): string;\n  abstract replace(oldText: string, startNum: string, numChars: string, newText: string): string;\n  abstract regexpReplace(text: string, pattern: string, replacement: string): string;\n  abstract substitute(text: string, oldText: string, newText: string, instanceNum?: string): string;\n  abstract lower(text: string): string;\n  abstract upper(text: string): string;\n  abstract rept(text: string, numTimes: string): string;\n  abstract trim(text: string): string;\n  abstract len(text: string): string;\n  abstract t(value: string): string;\n  abstract encodeUrlComponent(text: string): string;\n\n  // DateTime Functions\n  abstract now(): string;\n  abstract today(): string;\n  abstract dateAdd(date: string, count: string, unit: string): string;\n  abstract datestr(date: string): string;\n  abstract datetimeDiff(startDate: string, endDate: string, unit: string): string;\n  abstract datetimeFormat(date: string, format: string): string;\n  abstract datetimeParse(dateString: string, format?: string): string;\n  abstract day(date: string): string;\n  abstract fromNow(date: string, unit?: string): string;\n  abstract hour(date: string): string;\n  abstract isAfter(date1: string, date2: string): string;\n  abstract isBefore(date1: string, date2: string): string;\n  abstract isSame(date1: string, date2: string, unit?: string): string;\n  abstract lastModifiedTime(): string;\n  abstract minute(date: string): string;\n  abstract month(date: string): string;\n  abstract second(date: string): string;\n  abstract timestr(date: string): string;\n  abstract toNow(date: string, unit?: string): string;\n  abstract weekNum(date: string): string;\n  abstract weekday(date: string, startDayOfWeek?: string): string;\n  abstract workday(startDate: string, days: string, holidayStr?: string): string;\n  abstract workdayDiff(startDate: string, endDate: string): string;\n  abstract year(date: string): string;\n  abstract createdTime(): string;\n\n  // Logical Functions\n  abstract if(condition: string, valueIfTrue: string, valueIfFalse: string): string;\n  abstract and(params: string[]): string;\n  abstract or(params: string[]): string;\n  abstract not(value: string): string;\n  abstract xor(params: string[]): string;\n  abstract blank(): string;\n  abstract error(message: string): string;\n  abstract isError(value: string): string;\n  abstract switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string;\n\n  // Array Functions\n  abstract count(params: string[]): string;\n  abstract countA(params: string[]): string;\n  abstract countAll(value: string): string;\n  abstract arrayJoin(array: string, separator?: string): string;\n  abstract arrayUnique(arrays: string[]): string;\n  abstract arrayFlatten(arrays: string[]): string;\n  abstract arrayCompact(arrays: string[]): string;\n\n  // System Functions\n  abstract recordId(): string;\n  abstract autoNumber(): string;\n  abstract textAll(value: string): string;\n\n  // Binary Operations - Common implementations\n  add(left: string, right: string): string {\n    return `(${left} + ${right})`;\n  }\n\n  subtract(left: string, right: string): string {\n    return `(${left} - ${right})`;\n  }\n\n  multiply(left: string, right: string): string {\n    return `(${left} * ${right})`;\n  }\n\n  divide(left: string, right: string): string {\n    return `(${left} / ${right})`;\n  }\n\n  modulo(left: string, right: string): string {\n    return `(${left} % ${right})`;\n  }\n\n  // Comparison Operations - Common implementations\n  equal(left: string, right: string): string {\n    return `(${left} = ${right})`;\n  }\n\n  notEqual(left: string, right: string): string {\n    return `(${left} <> ${right})`;\n  }\n\n  greaterThan(left: string, right: string): string {\n    return `(${left} > ${right})`;\n  }\n\n  lessThan(left: string, right: string): string {\n    return `(${left} < ${right})`;\n  }\n\n  greaterThanOrEqual(left: string, right: string): string {\n    return `(${left} >= ${right})`;\n  }\n\n  lessThanOrEqual(left: string, right: string): string {\n    return `(${left} <= ${right})`;\n  }\n\n  // Logical Operations - Common implementations\n  logicalAnd(left: string, right: string): string {\n    return `(${left} AND ${right})`;\n  }\n\n  logicalOr(left: string, right: string): string {\n    return `(${left} OR ${right})`;\n  }\n\n  bitwiseAnd(left: string, right: string): string {\n    return `(${left} & ${right})`;\n  }\n\n  // Unary Operations - Common implementations\n  unaryMinus(value: string): string {\n    return `(-${value})`;\n  }\n\n  // Field Reference - Common implementation\n  abstract fieldReference(fieldId: string, columnName: string): string;\n\n  // Literals - Common implementations\n  stringLiteral(value: string): string {\n    return `'${value.replace(/'/g, \"''\")}'`;\n  }\n\n  numberLiteral(value: number): string {\n    return value.toString();\n  }\n\n  booleanLiteral(value: boolean): string {\n    return value ? 'TRUE' : 'FALSE';\n  }\n\n  nullLiteral(): string {\n    return 'NULL';\n  }\n\n  // Utility methods - Common implementations\n  castToNumber(value: string): string {\n    return `CAST(${value} AS NUMERIC)`;\n  }\n\n  castToString(value: string): string {\n    return `CAST(${value} AS TEXT)`;\n  }\n\n  castToBoolean(value: string): string {\n    return `CAST(${value} AS BOOLEAN)`;\n  }\n\n  castToDate(value: string): string {\n    return `CAST(${value} AS TIMESTAMP)`;\n  }\n\n  // Handle null values\n  isNull(value: string): string {\n    return `(${value} IS NULL)`;\n  }\n\n  coalesce(params: string[]): string {\n    return `COALESCE(${params.join(', ')})`;\n  }\n\n  // Parentheses for grouping\n  parentheses(expression: string): string {\n    return `(${expression})`;\n  }\n\n  // Helper method to escape SQL identifiers\n  protected escapeIdentifier(identifier: string): string {\n    return `\"${identifier.replace(/\"/g, '\"\"')}\"`;\n  }\n\n  // Helper method to handle array parameters\n  protected joinParams(params: string[], separator = ', '): string {\n    return params.join(separator);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/index.ts",
    "content": "import { DriverClient } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { GeneratedColumnQuerySupportValidatorPostgres } from './postgres/generated-column-query-support-validator.postgres';\nimport { GeneratedColumnQuerySupportValidatorSqlite } from './sqlite/generated-column-query-support-validator.sqlite';\n\nexport function createGeneratedColumnQuerySupportValidator(driver: DriverClient) {\n  return match(driver)\n    .with(DriverClient.Pg, () => new GeneratedColumnQuerySupportValidatorPostgres())\n    .with(DriverClient.Sqlite, () => new GeneratedColumnQuerySupportValidatorSqlite())\n    .exhaustive();\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query-support-validator.postgres.ts",
    "content": "import type {\n  IFormulaConversionContext,\n  IGeneratedColumnQuerySupportValidator,\n} from '../../../features/record/query-builder/sql-conversion.visitor';\n\n/**\n * PostgreSQL-specific implementation for validating generated column function support\n * Returns true for functions that can be safely converted to PostgreSQL SQL expressions\n * suitable for use in generated columns, false for unsupported functions.\n */\nexport class GeneratedColumnQuerySupportValidatorPostgres\n  implements IGeneratedColumnQuerySupportValidator\n{\n  private context?: IFormulaConversionContext;\n\n  setContext(context: IFormulaConversionContext): void {\n    this.context = context;\n  }\n\n  setCallMetadata(): void {\n    // No-op for validator\n  }\n\n  // Numeric Functions - PostgreSQL supports all basic numeric functions\n  sum(_params: string[]): boolean {\n    // Use addition instead of SUM() aggregation function\n    return true;\n  }\n\n  average(_params: string[]): boolean {\n    // Use addition and division instead of AVG() aggregation function\n    return true;\n  }\n\n  max(_params: string[]): boolean {\n    return true;\n  }\n\n  min(_params: string[]): boolean {\n    return true;\n  }\n\n  round(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  roundUp(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  roundDown(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  ceiling(_value: string): boolean {\n    return true;\n  }\n\n  floor(_value: string): boolean {\n    return true;\n  }\n\n  even(_value: string): boolean {\n    return true;\n  }\n\n  odd(_value: string): boolean {\n    return true;\n  }\n\n  int(_value: string): boolean {\n    return true;\n  }\n\n  abs(_value: string): boolean {\n    return true;\n  }\n\n  sqrt(_value: string): boolean {\n    return true;\n  }\n\n  power(_base: string, _exponent: string): boolean {\n    return true;\n  }\n\n  exp(_value: string): boolean {\n    return true;\n  }\n\n  log(_value: string, _base?: string): boolean {\n    return true;\n  }\n\n  mod(_dividend: string, _divisor: string): boolean {\n    return true;\n  }\n\n  value(_text: string): boolean {\n    return true;\n  }\n\n  // Text Functions - PostgreSQL supports most text functions\n  concatenate(_params: string[]): boolean {\n    return true;\n  }\n\n  stringConcat(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  find(_searchText: string, _withinText: string, _startNum?: string): boolean {\n    // POSITION function requires collation in PostgreSQL\n    return false;\n  }\n\n  search(_searchText: string, _withinText: string, _startNum?: string): boolean {\n    // POSITION function requires collation in PostgreSQL\n    return false;\n  }\n\n  mid(_text: string, _startNum: string, _numChars: string): boolean {\n    return true;\n  }\n\n  left(_text: string, _numChars: string): boolean {\n    return true;\n  }\n\n  right(_text: string, _numChars: string): boolean {\n    return true;\n  }\n\n  replace(_oldText: string, _startNum: string, _numChars: string, _newText: string): boolean {\n    return true;\n  }\n\n  regexpReplace(_text: string, _pattern: string, _replacement: string): boolean {\n    // REGEXP_REPLACE is not supported in generated columns\n    return false;\n  }\n\n  substitute(_text: string, _oldText: string, _newText: string, _instanceNum?: string): boolean {\n    // REPLACE function requires collation in PostgreSQL\n    return false;\n  }\n\n  lower(_text: string): boolean {\n    // LOWER function requires collation for string literals in PostgreSQL\n    // Only supported when used with column references\n    return false;\n  }\n\n  upper(_text: string): boolean {\n    // UPPER function requires collation for string literals in PostgreSQL\n    // Only supported when used with column references\n    return false;\n  }\n\n  rept(_text: string, _numTimes: string): boolean {\n    return true;\n  }\n\n  trim(_text: string): boolean {\n    return true;\n  }\n\n  len(_text: string): boolean {\n    return true;\n  }\n\n  t(_value: string): boolean {\n    // T function implementation doesn't work correctly in PostgreSQL\n    return false;\n  }\n\n  encodeUrlComponent(_text: string): boolean {\n    // URL encoding is not supported in PostgreSQL generated columns\n    return false;\n  }\n\n  // DateTime Functions - Most are supported, some have limitations but are still usable\n  now(): boolean {\n    // now() is supported but results are fixed at creation time\n    return true;\n  }\n\n  today(): boolean {\n    // today() is supported but results are fixed at creation time\n    return true;\n  }\n\n  dateAdd(_date: string, _count: string, _unit: string): boolean {\n    // DATE_ADD relies on timestamp input parsing which is not immutable in PostgreSQL\n    // (casts depend on DateStyle/TimeZone). Treat as unsupported for generated columns.\n    return false;\n  }\n\n  datestr(_date: string): boolean {\n    // DATESTR with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  datetimeDiff(_startDate: string, _endDate: string, _unit: string): boolean {\n    // DATETIME_DIFF is not immutable in PostgreSQL\n    return false;\n  }\n\n  datetimeFormat(_date: string, _format: string): boolean {\n    // DATETIME_FORMAT is not immutable in PostgreSQL\n    return false;\n  }\n\n  datetimeParse(_dateString: string, _format?: string): boolean {\n    // DATETIME_PARSE is not immutable in PostgreSQL\n    return false;\n  }\n\n  day(_date: string): boolean {\n    // DAY with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  fromNow(_date: string): boolean {\n    // fromNow results are unpredictable due to fixed creation time\n    return false;\n  }\n\n  hour(_date: string): boolean {\n    // HOUR with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  isAfter(_date1: string, _date2: string): boolean {\n    // IS_AFTER is not immutable in PostgreSQL\n    return false;\n  }\n\n  isBefore(_date1: string, _date2: string): boolean {\n    // IS_BEFORE is not immutable in PostgreSQL\n    return false;\n  }\n\n  isSame(_date1: string, _date2: string, _unit?: string): boolean {\n    // IS_SAME is not immutable in PostgreSQL\n    return false;\n  }\n\n  lastModifiedTime(): boolean {\n    return false;\n  }\n\n  minute(_date: string): boolean {\n    // MINUTE with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  month(_date: string): boolean {\n    // MONTH with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  second(_date: string): boolean {\n    // SECOND with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  timestr(_date: string): boolean {\n    // TIMESTR with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  toNow(_date: string): boolean {\n    // toNow results are unpredictable due to fixed creation time\n    return false;\n  }\n\n  weekNum(_date: string): boolean {\n    // WEEKNUM with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  weekday(_date: string): boolean {\n    // WEEKDAY with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  workday(_startDate: string, _days: string): boolean {\n    // Complex weekend-skipping logic not implemented\n    return false;\n  }\n\n  workdayDiff(_startDate: string, _endDate: string): boolean {\n    // Complex business day calculation not implemented\n    return false;\n  }\n\n  year(_date: string): boolean {\n    // YEAR with column references is not immutable in PostgreSQL\n    return false;\n  }\n\n  createdTime(): boolean {\n    return false;\n  }\n\n  // Logical Functions - IF fallback to computed evaluation (not immutable-safe).\n  // Example: `IF({LinkField}, 1, 0)` dereferences JSON arrays from link cells and\n  // needs runtime truthiness checks; the generated expression is not immutable,\n  // so we force evaluation in the computed path instead of a generated column.\n  if(_condition: string, _valueIfTrue: string, _valueIfFalse: string): boolean {\n    return false;\n  }\n\n  and(_params: string[]): boolean {\n    return true;\n  }\n\n  or(_params: string[]): boolean {\n    return true;\n  }\n\n  not(_value: string): boolean {\n    return true;\n  }\n\n  xor(_params: string[]): boolean {\n    return true;\n  }\n\n  blank(): boolean {\n    return true;\n  }\n\n  error(_message: string): boolean {\n    // Cannot throw errors in generated column definitions\n    return false;\n  }\n\n  isError(_value: string): boolean {\n    // Cannot detect runtime errors in generated columns\n    return false;\n  }\n\n  switch(\n    _expression: string,\n    _cases: Array<{ case: string; result: string }>,\n    _defaultResult?: string\n  ): boolean {\n    return true;\n  }\n\n  // Array Functions - PostgreSQL supports basic array operations\n  count(_params: string[]): boolean {\n    return true;\n  }\n\n  countA(_params: string[]): boolean {\n    return true;\n  }\n\n  countAll(_value: string): boolean {\n    return true;\n  }\n\n  arrayJoin(_array: string, _separator?: string): boolean {\n    // JSONB vs Array type mismatch issue\n    return false;\n  }\n\n  arrayUnique(_arrays: string[]): boolean {\n    // Uses subqueries not allowed in generated columns\n    return false;\n  }\n\n  arrayFlatten(_arrays: string[]): boolean {\n    // Uses subqueries not allowed in generated columns\n    return false;\n  }\n\n  arrayCompact(_arrays: string[]): boolean {\n    // Uses subqueries not allowed in generated columns\n    return false;\n  }\n\n  // System Functions - Supported (reference system columns)\n  recordId(): boolean {\n    return false;\n  }\n\n  autoNumber(): boolean {\n    return false;\n  }\n\n  textAll(_value: string): boolean {\n    // textAll with non-array types causes function mismatch\n    return false;\n  }\n\n  // Binary Operations - All supported\n  add(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  subtract(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  multiply(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  divide(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  modulo(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Comparison Operations - All supported\n  equal(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  notEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  greaterThan(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  lessThan(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  greaterThanOrEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  lessThanOrEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Logical Operations - All supported\n  logicalAnd(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  logicalOr(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  bitwiseAnd(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Unary Operations - All supported\n  unaryMinus(_value: string): boolean {\n    return true;\n  }\n\n  // Field Reference - Supported\n  fieldReference(_fieldId: string, _columnName: string): boolean {\n    return true;\n  }\n\n  // Literals - All supported\n  stringLiteral(_value: string): boolean {\n    return true;\n  }\n\n  numberLiteral(_value: number): boolean {\n    return true;\n  }\n\n  booleanLiteral(_value: boolean): boolean {\n    return true;\n  }\n\n  nullLiteral(): boolean {\n    return true;\n  }\n\n  // Utility methods - All supported\n  castToNumber(_value: string): boolean {\n    return true;\n  }\n\n  castToString(_value: string): boolean {\n    return true;\n  }\n\n  castToBoolean(_value: string): boolean {\n    return true;\n  }\n\n  castToDate(_value: string): boolean {\n    return true;\n  }\n\n  // Handle null values and type checking - All supported\n  isNull(_value: string): boolean {\n    return true;\n  }\n\n  coalesce(_params: string[]): boolean {\n    return true;\n  }\n\n  // Parentheses for grouping - Supported\n  parentheses(_expression: string): boolean {\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.spec.ts",
    "content": "import { DbFieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\n\nimport { GeneratedColumnQueryPostgres } from './generated-column-query.postgres';\n\ndescribe('GeneratedColumnQueryPostgres if', () => {\n  it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({} as unknown as never);\n    query.setCallMetadata([\n      { type: 'string', isFieldReference: false },\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldJsonNumeric',\n          isMultiple: true,\n          isLookup: true,\n          dbFieldName: '__json_numeric',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'number',\n        },\n      },\n      { type: 'number', isFieldReference: false },\n    ] as unknown as never);\n\n    const sql = query.if('__cond', '\"__json_numeric\"', '0');\n    expect(sql).toContain('to_jsonb(\"__json_numeric\")');\n    expect(sql).toContain('jsonb_array_elements_text');\n    expect(sql).toContain('double precision');\n  });\n\n  it('counts multi-value json field elements in COUNTALL', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({} as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldMulti',\n          isMultiple: true,\n          isLookup: false,\n          dbFieldName: '__owners',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'string',\n        },\n      },\n    ] as unknown as never);\n\n    const sql = query.countAll('\"__owners\"');\n    expect(sql).toContain('jsonb_array_length');\n    expect(sql).toContain(`NULLIF((\"__owners\")::jsonb, 'null'::jsonb)`);\n  });\n\n  it('keeps scalar COUNTALL behavior for non-json field', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({} as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'number',\n        isFieldReference: true,\n        field: {\n          id: 'fldNumber',\n          isMultiple: false,\n          isLookup: false,\n          dbFieldName: '__number',\n          dbFieldType: DbFieldType.Real,\n          cellValueType: 'number',\n        },\n      },\n    ] as unknown as never);\n\n    expect(query.countAll('\"__number\"')).toBe('CASE WHEN \"__number\" IS NULL THEN 0 ELSE 1 END');\n  });\n});\n\ndescribe('GeneratedColumnQueryPostgres FROMNOW/TONOW', () => {\n  it('applies unit conversion for FROMNOW', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({} as unknown as never);\n\n    const daySql = query.fromNow('NOW()', \"'day'\");\n    const hourSql = query.fromNow('NOW()', \"'hour'\");\n    const secondSql = query.fromNow('NOW()', \"'second'\");\n\n    expect(daySql).toContain('/ 86400');\n    expect(hourSql).toContain('/ 3600');\n    expect(secondSql).not.toContain('/ 86400');\n    expect(secondSql).not.toContain('/ 3600');\n  });\n\n  it('keeps TONOW direction as now minus date for past-positive semantics', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({} as unknown as never);\n\n    const sql = query.toNow('NOW()', \"'day'\");\n    expect(sql).toContain('NOW() -');\n    expect(sql).not.toContain(' - NOW()');\n  });\n});\n\ndescribe('GeneratedColumnQueryPostgres DATETIME_PARSE', () => {\n  it('reparses trusted datetime inputs through explicit formats', () => {\n    const query = new GeneratedColumnQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);\n\n    const sql = query.datetimeParse('column_a', \"'MMYYYY'\");\n\n    expect(sql).toContain('TO_CHAR');\n    expect(sql).toContain('TO_TIMESTAMP');\n    expect(sql).toContain(`AT TIME ZONE 'Asia/Shanghai'`);\n    expect(sql).not.toBe('(column_a)');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable regexp/no-unused-capturing-group */\n/* eslint-disable no-useless-escape */\nimport { DbFieldType } from '@teable/core';\nimport {\n  buildDatetimeFormatSql,\n  buildDatetimeParseGuardRegex,\n  hasDatetimeTimezoneToken,\n  normalizeDatetimeFormatExpression,\n} from '../../utils/datetime-format.util';\nimport { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';\nimport {\n  isBooleanLikeParam,\n  isDatetimeLikeParam,\n  isJsonLikeParam,\n  isTextLikeParam,\n  isTrustedNumeric,\n  resolveFormulaParamInfo,\n} from '../../utils/formula-param-metadata.util';\nimport { GeneratedColumnQueryAbstract } from '../generated-column-query.abstract';\n\n/**\n * PostgreSQL-specific implementation of generated column query functions\n * Converts Teable formula functions to PostgreSQL SQL expressions suitable\n * for use in generated columns. All generated SQL must be immutable.\n */\nexport class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {\n  private isEmptyStringLiteral(value: string): boolean {\n    return value.trim() === \"''\";\n  }\n\n  private isNullLiteral(value: string): boolean {\n    return this.stripOuterParentheses(value).toUpperCase() === 'NULL';\n  }\n\n  private shouldCoalesceNumericComparison(value: string, metadataIndex?: number): boolean {\n    if (this.isNumericLiteral(value)) {\n      return true;\n    }\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    return paramInfo ? isTrustedNumeric(paramInfo) || paramInfo.type === 'number' : false;\n  }\n\n  private normalizeNumericComparisonOperand(value: string, metadataIndex?: number): string {\n    if (!this.shouldCoalesceNumericComparison(value, metadataIndex)) {\n      return value;\n    }\n    return this.collapseNumeric(value, metadataIndex);\n  }\n\n  private hasWrappingParentheses(expr: string): boolean {\n    if (!expr.startsWith('(') || !expr.endsWith(')')) {\n      return false;\n    }\n    let depth = 0;\n    for (let i = 0; i < expr.length; i++) {\n      const ch = expr[i];\n      if (ch === '(') {\n        depth++;\n      } else if (ch === ')') {\n        depth--;\n        if (depth === 0 && i < expr.length - 1) {\n          return false;\n        }\n        if (depth < 0) {\n          return false;\n        }\n      }\n    }\n    return depth === 0;\n  }\n\n  private stripOuterParentheses(expr: string): string {\n    let trimmed = expr.trim();\n    while (trimmed.length > 0 && this.hasWrappingParentheses(trimmed)) {\n      trimmed = trimmed.slice(1, -1).trim();\n    }\n    return trimmed;\n  }\n\n  private getParamInfo(index?: number) {\n    return resolveFormulaParamInfo(this.currentCallMetadata, index);\n  }\n\n  private isNumericLiteral(expr: string): boolean {\n    let trimmed = this.stripOuterParentheses(expr);\n\n    // Peel leading signs while trimming redundant outer parens\n    while (trimmed.startsWith('+') || trimmed.startsWith('-')) {\n      trimmed = trimmed.slice(1).trim();\n      trimmed = this.stripOuterParentheses(trimmed);\n    }\n\n    // Match plain numeric literal, with optional cast to a numeric type\n    const numericWithOptionalCast =\n      /^\\(?\\d+(\\.\\d+)?\\)?(::(double precision|numeric|real|integer|bigint|smallint))?$/i;\n    if (numericWithOptionalCast.test(trimmed)) {\n      return true;\n    }\n\n    // Handle wrapped casts like ((7)::double precision)\n    const wrappedCastMatch = trimmed.match(/^\\((.+)\\)$/);\n    if (wrappedCastMatch) {\n      return this.isNumericLiteral(wrappedCastMatch[1]);\n    }\n\n    return false;\n  }\n\n  private toNumericSafe(expr: string, metadataIndex?: number): string {\n    if (this.isNumericLiteral(expr)) {\n      return `(${expr})::double precision`;\n    }\n    const paramInfo = this.getParamInfo(metadataIndex);\n    const expressionFieldType = this.getExpressionFieldType(expr);\n    if (isBooleanLikeParam(paramInfo)) {\n      const normalizedBoolean = this.normalizeBooleanCondition(expr, metadataIndex ?? 0);\n      return `(CASE WHEN ${normalizedBoolean} THEN 1 ELSE 0 END)::double precision`;\n    }\n    if (\n      paramInfo?.hasMetadata &&\n      isTextLikeParam(paramInfo) &&\n      !paramInfo.isJsonField &&\n      !paramInfo.isMultiValueField\n    ) {\n      return this.looseNumericCoercion(expr);\n    }\n    if (expressionFieldType === DbFieldType.Text) {\n      return this.looseNumericCoercion(expr);\n    }\n    if (paramInfo?.isJsonField || paramInfo?.isMultiValueField) {\n      return this.numericFromJson(expr);\n    }\n    if (expressionFieldType === DbFieldType.Json) {\n      return this.numericFromJson(expr);\n    }\n    if (isTrustedNumeric(paramInfo)) {\n      return `(${expr})::double precision`;\n    }\n    if (\n      !paramInfo?.hasMetadata &&\n      (expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer)\n    ) {\n      return `(${expr})::double precision`;\n    }\n\n    if (!paramInfo && expressionFieldType === undefined) {\n      return `(${expr})::double precision`;\n    }\n\n    return this.looseNumericCoercion(expr);\n  }\n\n  private looseNumericCoercion(expr: string): string {\n    if (this.isNumericLiteral(expr)) {\n      return `(${expr})::double precision`;\n    }\n    const textExpr = `((${expr})::text) COLLATE \"C\"`;\n    const dateLikePattern = `'^[0-9]{1,4}[-/][0-9]{1,2}[-/][0-9]{1,4}( .*){0,1}$'`;\n    const collatedDatePattern = `${dateLikePattern} COLLATE \"C\"`;\n    const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;\n    const cleaned = `NULLIF(${sanitized}, '')`;\n    const collatedClean = `${cleaned} COLLATE \"C\"`;\n    // Avoid \"?\" in the regex so knex.raw doesn't misinterpret it as a binding placeholder.\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN NULL\n      WHEN ${textExpr} ~ ${collatedDatePattern} THEN NULL\n      WHEN ${cleaned} IS NULL THEN NULL\n      WHEN ${collatedClean} ~ ${collatedPattern} THEN ${cleaned}::double precision\n      ELSE NULL\n    END)`;\n  }\n\n  private numericFromJson(expr: string): string {\n    const jsonExpr = `to_jsonb(${expr})`;\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    const arraySum = `(SELECT SUM(CASE WHEN (elem.value COLLATE \"C\") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END) FROM jsonb_array_elements_text(${jsonExpr}) AS elem(value))`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN NULL\n      WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN ${arraySum}\n      ELSE ${this.looseNumericCoercion(expr)}\n    END)`;\n  }\n\n  private numericFromText(expr: string): string {\n    const textExpr = `((${expr})::text) COLLATE \"C\"`;\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN NULL\n      WHEN ${textExpr} ~ ${collatedPattern} THEN ${textExpr}::double precision\n      ELSE NULL\n    END)`;\n  }\n\n  private collapseNumeric(expr: string, metadataIndex?: number): string {\n    const numericValue = this.toNumericSafe(expr, metadataIndex);\n    return `COALESCE(${numericValue}, 0)`;\n  }\n\n  private normalizeBlankComparable(value: string, metadataIndex?: number): string {\n    const comparable = this.coerceToTextComparable(value, metadataIndex);\n    return `COALESCE(NULLIF(${comparable}, ''), '')`;\n  }\n\n  private ensureTextCollation(expr: string): string {\n    return `(${expr})::text`;\n  }\n\n  private buildBlankAwareComparison(\n    operator: '=' | '<>',\n    left: string,\n    right: string,\n    metadataIndexes?: { left?: number; right?: number }\n  ): string {\n    const leftIndex = metadataIndexes?.left;\n    const rightIndex = metadataIndexes?.right;\n    const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);\n    const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);\n    const leftIsText = this.isTextLikeExpression(left, leftIndex);\n    const rightIsText = this.isTextLikeExpression(right, rightIndex);\n    const normalizeText = leftIsEmptyLiteral || rightIsEmptyLiteral || leftIsText || rightIsText;\n\n    const leftIsNumericComparable = this.shouldCoalesceNumericComparison(left, leftIndex);\n    const rightIsNumericComparable = this.shouldCoalesceNumericComparison(right, rightIndex);\n\n    if (!normalizeText && (leftIsNumericComparable || rightIsNumericComparable)) {\n      const normalizedLeft = leftIsNumericComparable\n        ? this.normalizeNumericComparisonOperand(left, leftIndex)\n        : left;\n      const normalizedRight = rightIsNumericComparable\n        ? this.normalizeNumericComparisonOperand(right, rightIndex)\n        : right;\n      return `(${normalizedLeft} ${operator} ${normalizedRight})`;\n    }\n\n    if (!normalizeText) {\n      return `(${left} ${operator} ${right})`;\n    }\n\n    const normalizeOperand = (value: string, isEmptyLiteral: boolean, metadataIndex?: number) =>\n      isEmptyLiteral ? \"''\" : this.normalizeBlankComparable(value, metadataIndex);\n\n    const normalizedLeft = normalizeOperand(left, leftIsEmptyLiteral, leftIndex);\n    const normalizedRight = normalizeOperand(right, rightIsEmptyLiteral, rightIndex);\n\n    return `(${normalizedLeft} ${operator} ${normalizedRight})`;\n  }\n\n  private isTextLikeExpression(value: string, metadataIndex?: number): boolean {\n    const trimmed = this.stripOuterParentheses(value);\n    if (this.isEmptyStringLiteral(trimmed)) {\n      return false;\n    }\n    if (/^'.*'$/.test(trimmed)) {\n      return true;\n    }\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata) {\n      if (\n        paramInfo.fieldDbType === DbFieldType.Real ||\n        paramInfo.fieldDbType === DbFieldType.Integer ||\n        paramInfo.fieldCellValueType === 'number'\n      ) {\n        return false;\n      }\n      if (isTextLikeParam(paramInfo)) {\n        return true;\n      }\n    }\n\n    return this.getExpressionFieldType(value) === DbFieldType.Text;\n  }\n\n  private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {\n    if (this.isNumericLiteral(value)) {\n      return true;\n    }\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata) {\n      if (\n        paramInfo.type === 'number' ||\n        isTrustedNumeric(paramInfo) ||\n        isBooleanLikeParam(paramInfo)\n      ) {\n        return true;\n      }\n      if (\n        paramInfo.fieldDbType === DbFieldType.Real ||\n        paramInfo.fieldDbType === DbFieldType.Integer\n      ) {\n        return true;\n      }\n      if (paramInfo.fieldCellValueType === 'number') {\n        return true;\n      }\n    }\n\n    const expressionFieldType = this.getExpressionFieldType(value);\n    return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;\n  }\n\n  private getExpressionFieldType(value: string): DbFieldType | undefined {\n    const trimmed = this.stripOuterParentheses(value);\n    const columnMatch = trimmed.match(/^\"([^\"]+)\"$/) ?? trimmed.match(/^\"[^\"]+\"\\.\"([^\"]+)\"$/);\n    if (!columnMatch || columnMatch.length < 2) {\n      return undefined;\n    }\n\n    const columnName = columnMatch[1];\n    const table = this.context?.table;\n    const field =\n      table?.fieldList?.find((item) => item.dbFieldName === columnName) ??\n      table?.fields?.ordered?.find((item) => item.dbFieldName === columnName);\n    return field?.dbFieldType as DbFieldType | undefined;\n  }\n\n  private buildJsonScalarCoercion(jsonExpr: string): string {\n    const elementScalar = `CASE\n      WHEN jsonb_typeof(elem.value) = 'object' THEN COALESCE(\n        elem.value->>'title',\n        elem.value->>'name',\n        elem.value #>> '{}'\n      )\n      WHEN jsonb_typeof(elem.value) = 'array' THEN NULL\n      ELSE elem.value #>> '{}'\n    END`;\n\n    return `CASE jsonb_typeof(${jsonExpr})\n      WHEN 'string' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'number' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'boolean' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'null' THEN NULL\n      WHEN 'array' THEN COALESCE((\n        SELECT STRING_AGG(${elementScalar}, ', ' ORDER BY elem.ordinality)\n        FROM jsonb_array_elements(${jsonExpr}) WITH ORDINALITY AS elem(value, ordinality)\n      ), '')\n      WHEN 'object' THEN COALESCE(${jsonExpr}->>'title', ${jsonExpr}->>'name', ${jsonExpr} #>> '{}')\n      ELSE (${jsonExpr})::text\n    END`;\n  }\n\n  private coerceJsonExpressionToText(wrapped: string): string {\n    const doubleWrapped = `(${wrapped})`;\n    const directJsonExpr = `${doubleWrapped}::jsonb`;\n    const fallbackJsonExpr = `to_jsonb${wrapped}`;\n    const jsonTypeGuard = `pg_typeof(${wrapped}) = ANY('{json,jsonb}'::regtype[])`;\n\n    return `(CASE\n      WHEN ${wrapped} IS NULL THEN NULL\n      WHEN ${jsonTypeGuard} THEN\n        ${this.buildJsonScalarCoercion(directJsonExpr)}\n      ELSE\n        ${this.buildJsonScalarCoercion(fallbackJsonExpr)}\n    END)`;\n  }\n\n  private coerceNonJsonExpressionToText(wrapped: string): string {\n    const jsonbValue = `to_jsonb${wrapped}`;\n\n    return `(CASE\n      WHEN ${wrapped} IS NULL THEN NULL\n      ELSE\n        ${this.buildJsonScalarCoercion(jsonbValue)}\n    END)`;\n  }\n\n  private coerceToTextComparable(value: string, metadataIndex?: number): string {\n    const trimmed = this.stripOuterParentheses(value);\n    if (!trimmed) {\n      return this.ensureTextCollation(value);\n    }\n    if (/^'.*'$/.test(trimmed)) {\n      return this.ensureTextCollation(trimmed);\n    }\n    if (trimmed.toUpperCase() === 'NULL') {\n      return 'NULL';\n    }\n\n    const wrapped = `(${value})`;\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    const expressionFieldType = this.getExpressionFieldType(value);\n    const numericField =\n      paramInfo?.fieldDbType === DbFieldType.Real ||\n      paramInfo?.fieldDbType === DbFieldType.Integer ||\n      paramInfo?.fieldCellValueType === 'number' ||\n      expressionFieldType === DbFieldType.Real ||\n      expressionFieldType === DbFieldType.Integer;\n    if (numericField && !paramInfo?.isJsonField && !paramInfo?.isMultiValueField) {\n      return wrapped;\n    }\n    const isJsonParam = paramInfo?.hasMetadata && isJsonLikeParam(paramInfo);\n    const shouldUseSimpleCast =\n      this.isGeneratedColumnContext &&\n      !isJsonParam &&\n      !paramInfo?.isMultiValueField &&\n      expressionFieldType !== DbFieldType.Json;\n\n    if (paramInfo?.hasMetadata) {\n      if (isJsonParam) {\n        if (shouldUseSimpleCast) {\n          return this.ensureTextCollation(`${wrapped}::text`);\n        }\n        const coercedJson = this.coerceJsonExpressionToText(wrapped);\n        return this.ensureTextCollation(coercedJson);\n      }\n\n      if (isTextLikeParam(paramInfo)) {\n        return this.ensureTextCollation(value);\n      }\n\n      if (paramInfo.type && paramInfo.type !== 'unknown') {\n        return this.ensureTextCollation(`${wrapped}::text`);\n      }\n    }\n\n    if (expressionFieldType === DbFieldType.Json) {\n      if (shouldUseSimpleCast) {\n        return this.ensureTextCollation(`${wrapped}::text`);\n      }\n      const coercedJson = this.coerceJsonExpressionToText(wrapped);\n      return this.ensureTextCollation(coercedJson);\n    }\n\n    if (expressionFieldType === DbFieldType.Text) {\n      return this.ensureTextCollation(value);\n    }\n\n    if (shouldUseSimpleCast) {\n      return this.ensureTextCollation(`${wrapped}::text`);\n    }\n\n    const coerced = this.coerceNonJsonExpressionToText(wrapped);\n    return this.ensureTextCollation(coerced);\n  }\n\n  private isHardTextExpression(value: string): boolean {\n    const trimmed = this.stripOuterParentheses(value);\n    if (this.isEmptyStringLiteral(trimmed)) {\n      return false;\n    }\n    if (/^'.+'$/.test(trimmed)) {\n      return true;\n    }\n    return this.getExpressionFieldType(value) === DbFieldType.Text;\n  }\n\n  private isDateLikeOperand(metadataIndex?: number): boolean {\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (!paramInfo?.hasMetadata) {\n      return false;\n    }\n    if (paramInfo.type === 'number') {\n      return false;\n    }\n    const hasFieldDateMetadata =\n      paramInfo.fieldDbType === DbFieldType.DateTime || paramInfo.fieldCellValueType === 'datetime';\n    const typeSaysDatetime =\n      isDatetimeLikeParam(paramInfo) && !paramInfo.fieldDbType && !paramInfo.fieldCellValueType;\n    const looksDatetime = hasFieldDateMetadata || typeSaysDatetime;\n\n    if (!looksDatetime) {\n      return false;\n    }\n\n    return !paramInfo.isJsonField && !paramInfo.isMultiValueField;\n  }\n\n  private buildDayInterval(expr: string, metadataIndex?: number): string {\n    const numeric = this.collapseNumeric(expr, metadataIndex);\n    return `(${numeric}) * INTERVAL '1 day'`;\n  }\n\n  private countANonNullExpression(value: string, metadataIndex?: number): string {\n    if (this.isTextLikeExpression(value, metadataIndex)) {\n      const normalizedComparable = this.normalizeBlankComparable(value, metadataIndex);\n      return `CASE WHEN ${value} IS NULL OR ${normalizedComparable} = '' THEN 0 ELSE 1 END`;\n    }\n\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  override add(left: string, right: string): string {\n    const leftIsDate = this.isDateLikeOperand(0);\n    const rightIsDate = this.isDateLikeOperand(1);\n\n    if (leftIsDate && !rightIsDate) {\n      return `(${this.castToTimestamp(left, 0)} + ${this.buildDayInterval(right, 1)})`;\n    }\n\n    if (!leftIsDate && rightIsDate) {\n      return `(${this.castToTimestamp(right, 1)} + ${this.buildDayInterval(left, 0)})`;\n    }\n\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) + (${r}))`;\n  }\n\n  override subtract(left: string, right: string): string {\n    const leftIsDate = this.isDateLikeOperand(0);\n    const rightIsDate = this.isDateLikeOperand(1);\n\n    if (leftIsDate && !rightIsDate) {\n      return `(${this.castToTimestamp(left, 0)} - ${this.buildDayInterval(right, 1)})`;\n    }\n\n    if (leftIsDate && rightIsDate) {\n      return `(EXTRACT(EPOCH FROM ${this.castToTimestamp(left, 0)} - ${this.castToTimestamp(\n        right,\n        1\n      )}) / 86400)`;\n    }\n\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) - (${r}))`;\n  }\n\n  override multiply(left: string, right: string): string {\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) * (${r}))`;\n  }\n\n  override unaryMinus(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    return `(-(${numericValue}))`;\n  }\n\n  override divide(left: string, right: string): string {\n    const numerator = this.collapseNumeric(left, 0);\n    const denominator = this.toNumericSafe(right, 1);\n    return `(CASE WHEN (${denominator}) IS NULL OR (${denominator}) = 0 THEN NULL ELSE (${numerator} / ${denominator}) END)`;\n  }\n\n  override modulo(left: string, right: string): string {\n    const dividend = this.collapseNumeric(left, 0);\n    const divisor = this.toNumericSafe(right, 1);\n    return `(CASE WHEN (${divisor}) IS NULL OR (${divisor}) = 0 THEN NULL ELSE MOD((${dividend})::numeric, (${divisor})::numeric)::double precision END)`;\n  }\n\n  private isBooleanLikeExpression(value: string, metadataIndex?: number): boolean {\n    const trimmed = this.stripOuterParentheses(value);\n    if (/^(true|false)$/i.test(trimmed)) {\n      return true;\n    }\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata && isBooleanLikeParam(paramInfo)) {\n      return true;\n    }\n\n    return this.getExpressionFieldType(value) === DbFieldType.Boolean;\n  }\n\n  private normalizeBooleanCondition(condition: string, metadataIndex = 0): string {\n    const wrapped = `(${condition})`;\n    if (this.isBooleanLikeExpression(condition, metadataIndex)) {\n      return `COALESCE(${wrapped}::boolean, FALSE)`;\n    }\n\n    const paramInfo = this.getParamInfo(metadataIndex);\n    if (isTrustedNumeric(paramInfo)) {\n      const numericExpr = this.toNumericSafe(condition, metadataIndex);\n      return `(COALESCE(${numericExpr}, 0) <> 0)`;\n    }\n\n    const conditionType = `pg_typeof${wrapped}::text`;\n    const numericTypes = \"('smallint','integer','bigint','numeric','double precision','real')\";\n    const stringTypes = \"('text','character varying','character','varchar','unknown')\";\n    const wrappedText = `(${wrapped})::text`;\n    const booleanTruthyScore = `CASE WHEN LOWER(${wrappedText}) IN ('t','true','1') THEN 1 ELSE 0 END`;\n    const numericTruthyScore = `CASE WHEN ${wrappedText} ~ '^\\\\s*[+-]{0,1}0*(\\\\.0*){0,1}\\\\s*$' THEN 0 ELSE 1 END`;\n    const fallbackTruthyScore = `CASE\n      WHEN COALESCE(${wrappedText}, '') = '' THEN 0\n      WHEN LOWER(${wrappedText}) = 'null' THEN 0\n      ELSE 1\n    END`;\n\n    return `CASE\n      WHEN ${wrapped} IS NULL THEN 0\n      WHEN ${conditionType} = 'boolean' THEN ${booleanTruthyScore}\n      WHEN ${conditionType} IN ${numericTypes} THEN ${numericTruthyScore}\n      WHEN ${conditionType} IN ${stringTypes} THEN ${fallbackTruthyScore}\n      ELSE ${fallbackTruthyScore}\n    END = 1`;\n  }\n\n  // Numeric Functions\n  sum(params: string[]): string {\n    // Use addition instead of SUM() aggregation function for generated columns\n    const numericParams = params.map((param, index) => `(${this.collapseNumeric(param, index)})`);\n    return `(${numericParams.join(' + ')})`;\n  }\n\n  average(params: string[]): string {\n    // Use addition and division instead of AVG() aggregation function for generated columns\n    const numericParams = params.map((param, index) => `(${this.collapseNumeric(param, index)})`);\n    return `(${numericParams.join(' + ')}) / ${params.length}`;\n  }\n\n  max(params: string[]): string {\n    return `GREATEST(${this.joinParams(params)})`;\n  }\n\n  min(params: string[]): string {\n    return `LEAST(${this.joinParams(params)})`;\n  }\n\n  round(value: string, precision?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (precision !== undefined) {\n      const numericPrecision = this.toNumericSafe(precision, 1);\n      return `ROUND(${numericValue}::numeric, ${numericPrecision}::integer)`;\n    }\n    return `ROUND(${numericValue})`;\n  }\n\n  roundUp(value: string, precision?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (precision !== undefined) {\n      const numericPrecision = this.toNumericSafe(precision, 1);\n      const factor = `POWER(10, ${numericPrecision}::integer)`;\n      return `CEIL(${numericValue} * ${factor}) / ${factor}`;\n    }\n    return `CEIL(${numericValue})`;\n  }\n\n  roundDown(value: string, precision?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (precision !== undefined) {\n      const numericPrecision = this.toNumericSafe(precision, 1);\n      const factor = `POWER(10, ${numericPrecision}::integer)`;\n      return `FLOOR(${numericValue} * ${factor}) / ${factor}`;\n    }\n    return `FLOOR(${numericValue})`;\n  }\n\n  ceiling(value: string): string {\n    return `CEIL(${this.toNumericSafe(value, 0)})`;\n  }\n\n  floor(value: string): string {\n    return `FLOOR(${this.toNumericSafe(value, 0)})`;\n  }\n\n  even(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    const intValue = `FLOOR(${numericValue})::integer`;\n    return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 0 THEN ${intValue} ELSE ${intValue} + 1 END`;\n  }\n\n  odd(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    const intValue = `FLOOR(${numericValue})::integer`;\n    return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 1 THEN ${intValue} ELSE ${intValue} + 1 END`;\n  }\n\n  int(value: string): string {\n    return `FLOOR(${this.toNumericSafe(value, 0)})`;\n  }\n\n  abs(value: string): string {\n    return `ABS(${this.toNumericSafe(value, 0)})`;\n  }\n\n  sqrt(value: string): string {\n    return `SQRT(${this.toNumericSafe(value, 0)})`;\n  }\n\n  power(base: string, exponent: string): string {\n    const baseValue = this.toNumericSafe(base, 0);\n    const exponentValue = this.toNumericSafe(exponent, 1);\n    return `POWER(${baseValue}, ${exponentValue})`;\n  }\n\n  exp(value: string): string {\n    return `EXP(${this.toNumericSafe(value, 0)})`;\n  }\n\n  log(value: string, base?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (base !== undefined) {\n      const numericBase = this.toNumericSafe(base, 1);\n      const baseLog = `LN(${numericBase})`;\n      return `(LN(${numericValue}) / NULLIF(${baseLog}, 0))`;\n    }\n    return `LN(${numericValue})`;\n  }\n\n  mod(dividend: string, divisor: string): string {\n    const safeDividend = this.toNumericSafe(dividend, 0);\n    const safeDivisor = this.toNumericSafe(divisor, 1);\n    return `(CASE WHEN (${safeDivisor}) IS NULL OR (${safeDivisor}) = 0 THEN NULL ELSE MOD((${safeDividend})::numeric, (${safeDivisor})::numeric)::double precision END)`;\n  }\n\n  value(text: string): string {\n    return this.toNumericSafe(text, 0);\n  }\n\n  // Text Functions\n  concatenate(params: string[]): string {\n    // Use || operator instead of CONCAT for immutable generated columns\n    // CONCAT is stable, not immutable, which causes issues with generated columns\n    // Treat NULL values as empty strings to mirror client-side evaluation\n    const nullSafeParams = params.map((param) => `COALESCE(${param}::text, '')`);\n    return `(${this.joinParams(nullSafeParams, ' || ')})`;\n  }\n\n  // String concatenation for + operator (treats NULL as empty string)\n  // Use explicit text casting to handle mixed types and NULL values\n  stringConcat(left: string, right: string): string {\n    return `(COALESCE(${left}::text, '') || COALESCE(${right}::text, ''))`;\n  }\n\n  equal(left: string, right: string): string {\n    return this.buildBlankAwareComparison('=', left, right, { left: 0, right: 1 });\n  }\n\n  notEqual(left: string, right: string): string {\n    return this.buildBlankAwareComparison('<>', left, right, { left: 0, right: 1 });\n  }\n\n  greaterThan(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} > ${normalizedRight})`;\n  }\n\n  lessThan(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} < ${normalizedRight})`;\n  }\n\n  greaterThanOrEqual(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} >= ${normalizedRight})`;\n  }\n\n  lessThanOrEqual(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} <= ${normalizedRight})`;\n  }\n\n  // Override bitwiseAnd to handle PostgreSQL-specific type conversion\n  bitwiseAnd(left: string, right: string): string {\n    // Handle cases where operands might not be valid integers\n    // Use CASE to safely convert to integer, defaulting to 0 for invalid values\n    return `(\n      CASE\n        WHEN ${left}::text ~ '^-?[0-9]+$' AND ${left}::text != '' THEN ${left}::integer\n        ELSE 0\n      END &\n      CASE\n        WHEN ${right}::text ~ '^-?[0-9]+$' AND ${right}::text != '' THEN ${right}::integer\n        ELSE 0\n      END\n    )`;\n  }\n\n  find(searchText: string, withinText: string, startNum?: string): string {\n    const normalizedSearch = this.ensureTextCollation(searchText);\n    const normalizedWithin = this.ensureTextCollation(withinText);\n\n    if (startNum) {\n      return `POSITION(${normalizedSearch} IN SUBSTRING(${normalizedWithin} FROM ${startNum}::integer)) + ${startNum}::integer - 1`;\n    }\n    return `POSITION(${normalizedSearch} IN ${normalizedWithin})`;\n  }\n\n  search(searchText: string, withinText: string, startNum?: string): string {\n    const normalizedSearch = this.ensureTextCollation(searchText);\n    const normalizedWithin = this.ensureTextCollation(withinText);\n\n    // PostgreSQL doesn't have case-insensitive POSITION, so we use ILIKE with pattern matching\n    if (startNum) {\n      return `POSITION(UPPER(${normalizedSearch}) IN UPPER(SUBSTRING(${normalizedWithin} FROM ${startNum}::integer))) + ${startNum}::integer - 1`;\n    }\n    return `POSITION(UPPER(${normalizedSearch}) IN UPPER(${normalizedWithin}))`;\n  }\n\n  mid(text: string, startNum: string, numChars: string): string {\n    return `SUBSTRING((${text})::text FROM ${startNum}::integer FOR ${numChars}::integer)`;\n  }\n\n  left(text: string, numChars: string): string {\n    return `LEFT((${text})::text, ${numChars}::integer)`;\n  }\n\n  right(text: string, numChars: string): string {\n    return `RIGHT((${text})::text, ${numChars}::integer)`;\n  }\n\n  replace(oldText: string, startNum: string, numChars: string, newText: string): string {\n    const source = this.ensureTextCollation(oldText);\n    const replacement = this.ensureTextCollation(newText);\n    return `OVERLAY(${source} PLACING ${replacement} FROM ${startNum}::integer FOR ${numChars}::integer)`;\n  }\n\n  regexpReplace(text: string, pattern: string, replacement: string): string {\n    const source = this.ensureTextCollation(text);\n    const regex = this.ensureTextCollation(pattern);\n    const replacementText = this.ensureTextCollation(replacement);\n    return `REGEXP_REPLACE(${source}, ${regex}, ${replacementText}, 'g')`;\n  }\n\n  substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {\n    const source = this.ensureTextCollation(this.coerceToTextComparable(text, 0));\n    const search = this.ensureTextCollation(this.coerceToTextComparable(oldText, 1));\n    const replacement = this.ensureTextCollation(this.coerceToTextComparable(newText, 2));\n    if (instanceNum) {\n      // PostgreSQL doesn't have direct support for replacing specific instance\n      // This is a simplified implementation\n      return `REPLACE(${source}, ${search}, ${replacement})`;\n    }\n    return `REPLACE(${source}, ${search}, ${replacement})`;\n  }\n\n  lower(text: string): string {\n    const operand = this.coerceToTextComparable(text, 0);\n    return `LOWER(${operand})`;\n  }\n\n  upper(text: string): string {\n    const operand = this.coerceToTextComparable(text, 0);\n    return `UPPER(${operand})`;\n  }\n\n  rept(text: string, numTimes: string): string {\n    const operand = this.coerceToTextComparable(text, 0);\n    return `REPEAT(${operand}, ${numTimes}::integer)`;\n  }\n\n  trim(text: string): string {\n    const operand = this.coerceToTextComparable(text, 0);\n    return `TRIM(${operand})`;\n  }\n\n  len(text: string): string {\n    // Force text to prevent LENGTH() from receiving numeric/JSON operands (e.g., auto-number)\n    const operand = this.ensureTextCollation(this.coerceToTextComparable(text, 0));\n    return `LENGTH(${operand})`;\n  }\n\n  t(value: string): string {\n    return `CASE WHEN ${value} IS NULL THEN '' ELSE ${value}::text END`;\n  }\n\n  encodeUrlComponent(text: string): string {\n    // PostgreSQL doesn't have built-in URL encoding, this would need a custom function\n    return `encode(${text}::bytea, 'escape')`;\n  }\n\n  // DateTime Functions\n  now(): string {\n    // For generated columns, use the current timestamp at field creation time\n    if (this.isGeneratedColumnContext) {\n      const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');\n      return `'${currentTimestamp}'::timestamp`;\n    }\n    return 'NOW()';\n  }\n\n  today(): string {\n    // For generated columns, use the current date at field creation time\n    if (this.isGeneratedColumnContext) {\n      const currentDate = new Date().toISOString().split('T')[0];\n      return `'${currentDate}'::date`;\n    }\n    return 'CURRENT_DATE';\n  }\n\n  private normalizeIntervalUnit(\n    unitLiteral: string,\n    options?: { treatQuarterAsMonth?: boolean }\n  ): {\n    unit:\n      | 'millisecond'\n      | 'second'\n      | 'minute'\n      | 'hour'\n      | 'day'\n      | 'week'\n      | 'month'\n      | 'quarter'\n      | 'year';\n    factor: number;\n  } {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return { unit: 'millisecond', factor: 1 };\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return { unit: 'second', factor: 1 };\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return { unit: 'minute', factor: 1 };\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return { unit: 'hour', factor: 1 };\n      case 'week':\n      case 'weeks':\n        return { unit: 'week', factor: 1 };\n      case 'month':\n      case 'months':\n        return { unit: 'month', factor: 1 };\n      case 'quarter':\n      case 'quarters':\n        if (options?.treatQuarterAsMonth === false) {\n          return { unit: 'quarter', factor: 1 };\n        }\n        return { unit: 'month', factor: 3 };\n      case 'year':\n      case 'years':\n        return { unit: 'year', factor: 1 };\n      case 'day':\n      case 'days':\n      default:\n        return { unit: 'day', factor: 1 };\n    }\n  }\n\n  private normalizeDiffUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      default:\n        return 'day';\n    }\n  }\n\n  private normalizeTruncateUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      case 'day':\n      case 'days':\n      default:\n        return 'day';\n    }\n  }\n\n  dateAdd(date: string, count: string, unit: string): string {\n    const { unit: cleanUnit, factor } = this.normalizeIntervalUnit(unit.replace(/^'|'$/g, ''));\n    const numericCount = this.toNumericSafe(count, 1);\n    const scaledCount = factor === 1 ? `(${numericCount})` : `(${numericCount}) * ${factor}`;\n    const timestampExpr = this.castToTimestamp(date, 0);\n    if (cleanUnit === 'quarter') {\n      return `${timestampExpr} + (${scaledCount}) * INTERVAL '1 month'`;\n    }\n    return `${timestampExpr} + (${scaledCount}) * INTERVAL '1 ${cleanUnit}'`;\n  }\n\n  datestr(date: string): string {\n    return `${this.castToTimestamp(date, 0)}::date::text`;\n  }\n\n  private buildMonthDiff(startDate: string, endDate: string): string {\n    const startExpr = this.castToTimestamp(startDate, 0);\n    const endExpr = this.castToTimestamp(endDate, 1);\n    const startYear = `EXTRACT(YEAR FROM ${startExpr})`;\n    const endYear = `EXTRACT(YEAR FROM ${endExpr})`;\n    const startMonth = `EXTRACT(MONTH FROM ${startExpr})`;\n    const endMonth = `EXTRACT(MONTH FROM ${endExpr})`;\n    const startDay = `EXTRACT(DAY FROM ${startExpr})`;\n    const endDay = `EXTRACT(DAY FROM ${endExpr})`;\n    const startLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${startExpr}) + INTERVAL '1 month - 1 day'))`;\n    const endLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${endExpr}) + INTERVAL '1 month - 1 day'))`;\n\n    const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;\n    const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;\n    const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;\n\n    return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;\n  }\n\n  datetimeDiff(startDate: string, endDate: string, unit: string): string {\n    const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));\n    const startExpr = this.castToTimestamp(startDate, 0);\n    const endExpr = this.castToTimestamp(endDate, 1);\n    const diffSeconds = `EXTRACT(EPOCH FROM ${startExpr} - ${endExpr})`;\n    switch (diffUnit) {\n      case 'millisecond':\n        return `(${diffSeconds}) * 1000`;\n      case 'second':\n        return `(${diffSeconds})`;\n      case 'minute':\n        return `(${diffSeconds}) / 60`;\n      case 'hour':\n        return `(${diffSeconds}) / 3600`;\n      case 'week':\n        return `(${diffSeconds}) / (86400 * 7)`;\n      case 'month':\n        return this.buildMonthDiff(startDate, endDate);\n      case 'quarter':\n        return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(startDate, endDate);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `(${diffSeconds}) / 86400`;\n    }\n  }\n\n  datetimeFormat(date: string, format: string): string {\n    return buildDatetimeFormatSql(this.castToTimestamp(date, 0), format);\n  }\n\n  datetimeParse(dateString: string, format?: string): string {\n    const valueExpr = `(${dateString})`;\n    const trustedDatetimeInput = this.hasTrustedDatetimeInput(0);\n\n    if (format == null) {\n      return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);\n    }\n    const trimmedFormat = format.trim();\n    if (!trimmedFormat || trimmedFormat === 'undefined' || trimmedFormat.toLowerCase() === 'null') {\n      return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);\n    }\n    if (trustedDatetimeInput) {\n      const localTimestampExpr = this.castToTimestamp(valueExpr, 0);\n      const formattedExpr = buildDatetimeFormatSql(localTimestampExpr, trimmedFormat);\n      return this.parseDatetimeParseWithFormat(formattedExpr, trimmedFormat);\n    }\n\n    return this.parseDatetimeParseWithFormat(`${valueExpr}::text`, trimmedFormat, valueExpr);\n  }\n\n  day(date: string): string {\n    return `EXTRACT(DAY FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {\n    const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));\n    const diffSeconds = `EXTRACT(EPOCH FROM ${nowExpr} - ${dateExpr})`;\n    const diffMonths = `EXTRACT(MONTH FROM AGE(${nowExpr}, ${dateExpr})) + EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr})) * 12`;\n    const diffYears = `EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr}))`;\n    switch (diffUnit) {\n      case 'millisecond':\n        return `(${diffSeconds}) * 1000`;\n      case 'second':\n        return `(${diffSeconds})`;\n      case 'minute':\n        return `(${diffSeconds}) / 60`;\n      case 'hour':\n        return `(${diffSeconds}) / 3600`;\n      case 'week':\n        return `(${diffSeconds}) / (86400 * 7)`;\n      case 'month':\n        return diffMonths;\n      case 'quarter':\n        return `(${diffMonths}) / 3.0`;\n      case 'year':\n        return diffYears;\n      case 'day':\n      default:\n        return `(${diffSeconds}) / 86400`;\n    }\n  }\n\n  fromNow(date: string, unit = 'day'): string {\n    // For generated columns, use the current timestamp at field creation time\n    const dateExpr = this.castToTimestamp(date, 0);\n    if (this.isGeneratedColumnContext) {\n      const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');\n      return this.buildNowDiffByUnit(`'${currentTimestamp}'::timestamp`, dateExpr, unit);\n    }\n    return this.buildNowDiffByUnit('NOW()', dateExpr, unit);\n  }\n\n  hour(date: string): string {\n    return `EXTRACT(HOUR FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  isAfter(date1: string, date2: string): string {\n    return `${this.castToTimestamp(date1, 0)} > ${this.castToTimestamp(date2, 1)}`;\n  }\n\n  isBefore(date1: string, date2: string): string {\n    return `${this.castToTimestamp(date1, 0)} < ${this.castToTimestamp(date2, 1)}`;\n  }\n\n  isSame(date1: string, date2: string, unit?: string): string {\n    if (unit) {\n      const trimmed = unit.trim();\n      if (trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\")) {\n        const literal = trimmed.slice(1, -1);\n        const normalized = this.normalizeTruncateUnit(literal);\n        const safeUnit = normalized.replace(/'/g, \"''\");\n        return `DATE_TRUNC('${safeUnit}', ${this.castToTimestamp(\n          date1,\n          0\n        )}) = DATE_TRUNC('${safeUnit}', ${this.castToTimestamp(date2, 1)})`;\n      }\n      return `DATE_TRUNC(${unit}, ${this.castToTimestamp(date1, 0)}) = DATE_TRUNC(${unit}, ${this.castToTimestamp(date2, 1)})`;\n    }\n    return `${this.castToTimestamp(date1, 0)} = ${this.castToTimestamp(date2, 1)}`;\n  }\n\n  lastModifiedTime(): string {\n    // This would typically reference a system column\n    return '\"__last_modified_time\"';\n  }\n\n  minute(date: string): string {\n    return `EXTRACT(MINUTE FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  month(date: string): string {\n    return `EXTRACT(MONTH FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  second(date: string): string {\n    return `EXTRACT(SECOND FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  timestr(date: string): string {\n    return `(${this.castToTimestamp(date, 0)})::time::text`;\n  }\n\n  toNow(date: string, unit = 'day'): string {\n    return this.fromNow(date, unit);\n  }\n\n  weekNum(date: string): string {\n    return `EXTRACT(WEEK FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  weekday(date: string, _startDayOfWeek?: string): string {\n    return `EXTRACT(DOW FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  workday(startDate: string, days: string, _holidayStr?: string): string {\n    if (!this.isDateLikeOperand(0)) {\n      return 'NULL';\n    }\n    // Simplified implementation - doesn't account for weekends/holidays\n    return `${this.castToTimestamp(startDate, 0)}::date + INTERVAL '1 day' * ${days}::integer`;\n  }\n\n  workdayDiff(startDate: string, endDate: string): string {\n    if (!this.isDateLikeOperand(0) || !this.isDateLikeOperand(1)) {\n      return 'NULL';\n    }\n    // Simplified implementation - doesn't account for weekends/holidays\n    return `${this.castToTimestamp(endDate, 1)}::date - ${this.castToTimestamp(startDate, 0)}::date`;\n  }\n\n  year(date: string): string {\n    return `EXTRACT(YEAR FROM ${this.castToTimestamp(date, 0)})`;\n  }\n\n  createdTime(): string {\n    // This would typically reference a system column\n    return '\"__created_time\"';\n  }\n\n  // Logical Functions\n  if(condition: string, valueIfTrue: string, valueIfFalse: string): string {\n    const booleanCondition = this.normalizeBooleanCondition(condition, 0);\n    const trueIsBlank = this.isEmptyStringLiteral(valueIfTrue) || this.isNullLiteral(valueIfTrue);\n    const falseIsBlank =\n      this.isEmptyStringLiteral(valueIfFalse) || this.isNullLiteral(valueIfFalse);\n    const resultIsDatetime = this.isDateLikeOperand(1) || this.isDateLikeOperand(2);\n    if (resultIsDatetime) {\n      const trueBranch = trueIsBlank ? 'NULL' : this.castToTimestamp(valueIfTrue, 1);\n      const falseBranch = falseIsBlank ? 'NULL' : this.castToTimestamp(valueIfFalse, 2);\n      return `CASE WHEN (${booleanCondition}) THEN ${trueBranch} ELSE ${falseBranch} END`;\n    }\n    const trueIsText = this.isTextLikeExpression(valueIfTrue, 1);\n    const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);\n    const trueIsHardText = this.isHardTextExpression(valueIfTrue);\n    const falseIsHardText = this.isHardTextExpression(valueIfFalse);\n    const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);\n    const numericWithBlank =\n      (trueIsBlank && !falseIsHardText && !falseIsText) ||\n      (falseIsBlank && !trueIsHardText && !trueIsText);\n    if (numericWithBlank) {\n      const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);\n      const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);\n      return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;\n    }\n    const hasNumericBranch =\n      this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);\n    if (hasNumericBranch && !hasTextBranch) {\n      const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);\n      const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);\n      return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;\n    }\n    const blankPresent = trueIsBlank || falseIsBlank;\n    const hasTextAfterBlank = blankPresent ? false : hasTextBranch;\n    const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;\n    const trueBranch = hasTextAfterBlank\n      ? this.coerceToTextComparable(valueIfTrue, 1)\n      : trueIsBlank && normalizeBlankAsNull\n        ? 'NULL'\n        : valueIfTrue;\n    const falseBranch = hasTextAfterBlank\n      ? this.coerceToTextComparable(valueIfFalse, 2)\n      : falseIsBlank && normalizeBlankAsNull\n        ? 'NULL'\n        : valueIfFalse;\n    return `CASE WHEN (${booleanCondition}) THEN ${trueBranch} ELSE ${falseBranch} END`;\n  }\n\n  and(params: string[]): string {\n    return `(${this.joinParams(params, ' AND ')})`;\n  }\n\n  or(params: string[]): string {\n    return `(${this.joinParams(params, ' OR ')})`;\n  }\n\n  not(value: string): string {\n    return `NOT (${value})`;\n  }\n\n  xor(params: string[]): string {\n    // PostgreSQL doesn't have built-in XOR for multiple values\n    // This is a simplified implementation for two values\n    if (params.length === 2) {\n      return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;\n    }\n    // For multiple values, we need a more complex implementation\n    return `(${this.joinParams(\n      params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`),\n      ' + '\n    )}) % 2 = 1`;\n  }\n\n  blank(): string {\n    return 'NULL';\n  }\n\n  error(_message: string): string {\n    // ERROR function in PostgreSQL generated columns should return NULL\n    // since we can't throw actual errors in generated columns\n    return 'NULL';\n  }\n\n  isError(value: string): string {\n    // PostgreSQL doesn't have a direct ISERROR function\n    // This would need custom error handling logic\n    return `CASE WHEN ${value} IS NULL THEN TRUE ELSE FALSE END`;\n  }\n\n  switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string {\n    const hasTextResult =\n      cases.some((c) => this.isTextLikeExpression(c.result)) ||\n      (defaultResult ? this.isTextLikeExpression(defaultResult) : false);\n\n    const normalizeResult = (value: string) =>\n      hasTextResult ? this.coerceToTextComparable(value) : value;\n\n    const normalizeCaseValue = (value: string) =>\n      hasTextResult ? this.coerceToTextComparable(value) : value;\n\n    const baseExpr = hasTextResult ? this.coerceToTextComparable(expression, 0) : expression;\n\n    let caseStatement = `CASE ${baseExpr}`;\n\n    for (const caseItem of cases) {\n      caseStatement += ` WHEN ${normalizeCaseValue(caseItem.case)} THEN ${normalizeResult(\n        caseItem.result\n      )}`;\n    }\n\n    if (defaultResult) {\n      caseStatement += ` ELSE ${normalizeResult(defaultResult)}`;\n    }\n\n    caseStatement += ' END';\n    return caseStatement;\n  }\n\n  // Array Functions\n  count(params: string[]): string {\n    // Count non-null values\n    return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`).join(' + ')})`;\n  }\n\n  countA(params: string[]): string {\n    // Count non-empty values (including zeros)\n    const blankAwareChecks = params.map((p, index) => this.countANonNullExpression(p, index));\n    return `(${blankAwareChecks.join(' + ')})`;\n  }\n\n  countAll(value: string): string {\n    const paramInfo = this.getParamInfo(0);\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      const normalized = `COALESCE(NULLIF((${value})::jsonb, 'null'::jsonb), '[]'::jsonb)`;\n      return `(CASE\n        WHEN jsonb_typeof(${normalized}) = 'array' THEN jsonb_array_length(${normalized})\n        ELSE 1\n      END)`;\n    }\n\n    // For single values, return 1 if not null, 0 if null.\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  private normalizeJsonbArray(array: string): string {\n    return `(CASE\n      WHEN ${array} IS NULL THEN '[]'::jsonb\n      WHEN jsonb_typeof(to_jsonb(${array})) = 'array' THEN to_jsonb(${array})\n      ELSE jsonb_build_array(to_jsonb(${array}))\n    END)`;\n  }\n\n  private buildJsonArrayUnion(\n    arrays: string[],\n    opts?: { filterNulls?: boolean; withOrdinal?: boolean }\n  ): string {\n    const selects = arrays.map((array, index) => {\n      const normalizedArray = this.normalizeJsonbArray(array);\n      const whereClause = opts?.filterNulls\n        ? \" WHERE elem.value IS NOT NULL AND elem.value != 'null' AND elem.value != ''\"\n        : '';\n      const ordinality = opts?.withOrdinal ? ', ord' : '';\n      return `SELECT elem.value, ${index} AS arg_index${ordinality}\n        FROM jsonb_array_elements_text(${normalizedArray}) WITH ORDINALITY AS elem(value, ord)${whereClause}`;\n    });\n\n    if (selects.length === 0) {\n      return 'SELECT NULL::text AS value, 0 AS arg_index, 0 AS ord WHERE FALSE';\n    }\n\n    return selects.join(' UNION ALL ');\n  }\n\n  arrayJoin(array: string, separator?: string): string {\n    const sep = separator || \"', '\";\n    return `ARRAY_TO_STRING(${array}, ${sep})`;\n  }\n\n  arrayUnique(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });\n    return `ARRAY(\n      SELECT DISTINCT ON (value) value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY value, arg_index, ord\n    )`;\n  }\n\n  arrayFlatten(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });\n    return `ARRAY(\n      SELECT value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY arg_index, ord\n    )`;\n  }\n\n  arrayCompact(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { filterNulls: true, withOrdinal: true });\n    return `ARRAY(\n      SELECT value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY arg_index, ord\n    )`;\n  }\n\n  // System Functions\n  recordId(): string {\n    // Reference the primary key column\n    return '\"__id\"';\n  }\n\n  autoNumber(): string {\n    // Reference the auto-increment column\n    return '\"__auto_number\"';\n  }\n\n  textAll(value: string): string {\n    // Convert array to text representation\n    return `ARRAY_TO_STRING(${value}, ', ')`;\n  }\n\n  // Override some base implementations for PostgreSQL-specific syntax\n  castToNumber(value: string): string {\n    return `${value}::numeric`;\n  }\n\n  castToString(value: string): string {\n    return `${value}::text`;\n  }\n\n  castToBoolean(value: string): string {\n    return `${value}::boolean`;\n  }\n\n  castToDate(value: string): string {\n    return `${value}::timestamp`;\n  }\n\n  // Field Reference - PostgreSQL uses double quotes for identifiers\n  fieldReference(_fieldId: string, columnName: string): string {\n    // For regular field references, return the column reference\n    // Note: Expansion is handled at the expression level, not at individual field reference level\n    return `\"${columnName}\"`;\n  }\n\n  protected escapeIdentifier(identifier: string): string {\n    return `\"${identifier.replace(/\"/g, '\"\"')}\"`;\n  }\n\n  private guardDefaultDatetimeParse(valueExpr: string): string {\n    const textExpr = `${valueExpr}::text`;\n    const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;\n    const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;\n    const pattern = getDefaultDatetimeParsePattern();\n    return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} ~ '${pattern}' THEN ${valueExpr} ELSE NULL END)`;\n  }\n\n  private parseDatetimeParseWithoutFormat(valueExpr: string): string {\n    const textExpr = `${valueExpr}::text`;\n    const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;\n    const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;\n    const pattern = getDefaultDatetimeParsePattern();\n    const hasClockTime = `(${sanitizedExpr} ~ '[ T][0-9]{1,2}:[0-9]{2}')`;\n    const hasExplicitTimeZone = `(${sanitizedExpr} ~* '(Z|[+-][0-9]{2}:[0-9]{2}|[+-][0-9]{4}|[+-][0-9]{2})$')`;\n    const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, \"''\");\n    const localTimestampExpr = `(${sanitizedExpr})::timestamp AT TIME ZONE '${safeTz}'`;\n    const explicitZoneExpr = `(${sanitizedExpr})::timestamptz`;\n\n    return `(CASE\n      WHEN ${valueExpr} IS NULL THEN NULL\n      WHEN ${sanitizedExpr} IS NULL THEN NULL\n      WHEN ${sanitizedExpr} ~ '${pattern}' THEN\n        (CASE\n          WHEN ${hasClockTime} AND NOT ${hasExplicitTimeZone} THEN ${localTimestampExpr}\n          ELSE ${explicitZoneExpr}\n        END)\n      ELSE NULL\n    END)`;\n  }\n\n  private parseDatetimeParseWithFormat(\n    textExpr: string,\n    formatExpr: string,\n    nullGuardExpr: string = textExpr\n  ): string {\n    const normalizedFormat = normalizeDatetimeFormatExpression(formatExpr);\n    const toTimestampExpr = `TO_TIMESTAMP(${textExpr}::text, ${normalizedFormat})`;\n    const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, \"''\");\n    const hasTimezoneToken = hasDatetimeTimezoneToken(formatExpr);\n    const parsedExpr =\n      hasTimezoneToken === false\n        ? `(${toTimestampExpr})::timestamp AT TIME ZONE '${safeTz}'`\n        : toTimestampExpr;\n    const guardPattern = buildDatetimeParseGuardRegex(formatExpr);\n    if (!guardPattern) {\n      return parsedExpr;\n    }\n    const escapedPattern = guardPattern.replace(/'/g, \"''\");\n    return `(CASE WHEN ${nullGuardExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${parsedExpr} ELSE NULL END)`;\n  }\n  private castToTimestamp(date: string, metadataIndex?: number): string {\n    const isTimestampish = (expr: string): boolean => {\n      const trimmed = this.stripOuterParentheses(expr);\n      return (\n        /::timestamp(tz)?\\b/i.test(trimmed) ||\n        /\\bAT\\s+TIME\\s+ZONE\\b/i.test(trimmed) ||\n        /^NOW\\(\\)/i.test(trimmed) ||\n        /^CURRENT_TIMESTAMP/i.test(trimmed)\n      );\n    };\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata && paramInfo.type === 'number') {\n      return 'NULL::timestamp';\n    }\n    const looksDatetime =\n      paramInfo?.hasMetadata &&\n      (isDatetimeLikeParam(paramInfo) ||\n        paramInfo.fieldDbType === DbFieldType.DateTime ||\n        paramInfo.fieldCellValueType === 'datetime');\n\n    if (!looksDatetime && !isTimestampish(date)) {\n      return 'NULL::timestamp';\n    }\n\n    const valueExpr = `(${date})`;\n    const trustedInput =\n      (metadataIndex != null && this.hasTrustedDatetimeInput(metadataIndex)) ||\n      this.getExpressionFieldType(date) === DbFieldType.DateTime;\n\n    if (trustedInput) {\n      return `${valueExpr}::timestamp`;\n    }\n\n    const guarded = this.guardDefaultDatetimeParse(valueExpr);\n    return `${guarded}::timestamp`;\n  }\n\n  private hasTrustedDatetimeInput(index: number): boolean {\n    const paramInfo = this.getParamInfo(index);\n    if (!paramInfo.hasMetadata) {\n      return false;\n    }\n    if (!isDatetimeLikeParam(paramInfo)) {\n      return false;\n    }\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      return false;\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query-support-validator.sqlite.ts",
    "content": "import type {\n  IFormulaConversionContext,\n  IGeneratedColumnQuerySupportValidator,\n} from '../../../features/record/query-builder/sql-conversion.visitor';\n\n/**\n * SQLite-specific implementation for validating generated column function support\n * Returns true for functions that can be safely converted to SQLite SQL expressions\n * suitable for use in generated columns, false for unsupported functions.\n *\n * SQLite has more limitations compared to PostgreSQL, especially for:\n * - Complex array operations\n * - Advanced text functions\n * - Time-dependent functions\n * - Functions requiring subqueries\n */\nexport class GeneratedColumnQuerySupportValidatorSqlite\n  implements IGeneratedColumnQuerySupportValidator\n{\n  protected context?: IFormulaConversionContext;\n\n  setContext(context: IFormulaConversionContext): void {\n    this.context = context;\n  }\n\n  setCallMetadata(): void {\n    // No-op for validator\n  }\n\n  // Numeric Functions - Most are supported\n  sum(_params: string[]): boolean {\n    // Use addition instead of SUM() aggregation function\n    return true;\n  }\n\n  average(_params: string[]): boolean {\n    // Use addition and division instead of AVG() aggregation function\n    return true;\n  }\n\n  max(_params: string[]): boolean {\n    return true;\n  }\n\n  min(_params: string[]): boolean {\n    return true;\n  }\n\n  round(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  roundUp(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  roundDown(_value: string, _precision?: string): boolean {\n    return true;\n  }\n\n  ceiling(_value: string): boolean {\n    // SQLite doesn't have CEIL function, but we can simulate it\n    return true;\n  }\n\n  floor(_value: string): boolean {\n    return true;\n  }\n\n  even(_value: string): boolean {\n    return true;\n  }\n\n  odd(_value: string): boolean {\n    return true;\n  }\n\n  int(_value: string): boolean {\n    return true;\n  }\n\n  abs(_value: string): boolean {\n    return true;\n  }\n\n  sqrt(_value: string): boolean {\n    // SQLite SQRT function implemented using mathematical approximation\n    return true;\n  }\n\n  power(_base: string, _exponent: string): boolean {\n    // SQLite POWER function implemented for common cases using multiplication\n    return true;\n  }\n\n  exp(_value: string): boolean {\n    // SQLite doesn't have EXP function built-in\n    return false;\n  }\n\n  log(_value: string, _base?: string): boolean {\n    // SQLite doesn't have LOG function built-in\n    return false;\n  }\n\n  mod(_dividend: string, _divisor: string): boolean {\n    return true;\n  }\n\n  value(_text: string): boolean {\n    return true;\n  }\n\n  // Text Functions - Most basic ones are supported\n  concatenate(_params: string[]): boolean {\n    return true;\n  }\n\n  stringConcat(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  find(_searchText: string, _withinText: string, _startNum?: string): boolean {\n    // SQLite has limited string search capabilities\n    return true;\n  }\n\n  search(_searchText: string, _withinText: string, _startNum?: string): boolean {\n    // Similar to find, basic support\n    return true;\n  }\n\n  mid(_text: string, _startNum: string, _numChars: string): boolean {\n    return true;\n  }\n\n  left(_text: string, _numChars: string): boolean {\n    return true;\n  }\n\n  right(_text: string, _numChars: string): boolean {\n    return true;\n  }\n\n  replace(_oldText: string, _startNum: string, _numChars: string, _newText: string): boolean {\n    return true;\n  }\n\n  regexpReplace(_text: string, _pattern: string, _replacement: string): boolean {\n    // SQLite has limited regex support\n    return false;\n  }\n\n  substitute(_text: string, _oldText: string, _newText: string, _instanceNum?: string): boolean {\n    return true;\n  }\n\n  lower(_text: string): boolean {\n    return true;\n  }\n\n  upper(_text: string): boolean {\n    return true;\n  }\n\n  rept(_text: string, _numTimes: string): boolean {\n    // SQLite doesn't have a built-in repeat function\n    return false;\n  }\n\n  trim(_text: string): boolean {\n    return true;\n  }\n\n  len(_text: string): boolean {\n    return true;\n  }\n\n  t(_value: string): boolean {\n    return true;\n  }\n\n  encodeUrlComponent(_text: string): boolean {\n    // SQLite doesn't have built-in URL encoding\n    return false;\n  }\n\n  // DateTime Functions - Limited support, some have limitations but are still usable\n  now(): boolean {\n    // now() is supported but results are fixed at creation time\n    return true;\n  }\n\n  today(): boolean {\n    // today() is supported but results are fixed at creation time\n    return true;\n  }\n\n  dateAdd(_date: string, _count: string, _unit: string): boolean {\n    // DATE_ADD relies on SQLite datetime helpers that are not immutable-safe for generated columns\n    return false;\n  }\n\n  datestr(_date: string): boolean {\n    return true;\n  }\n\n  datetimeDiff(_startDate: string, _endDate: string, _unit: string): boolean {\n    return true;\n  }\n\n  datetimeFormat(_date: string, _format: string): boolean {\n    return true;\n  }\n\n  datetimeParse(_dateString: string, _format?: string): boolean {\n    // SQLite has limited date parsing capabilities\n    return false;\n  }\n\n  day(_date: string): boolean {\n    // DAY with column references is not immutable in SQLite\n    return false;\n  }\n\n  fromNow(_date: string): boolean {\n    // fromNow results are unpredictable due to fixed creation time\n    return false;\n  }\n\n  hour(_date: string): boolean {\n    // HOUR with column references is not immutable in SQLite\n    return false;\n  }\n\n  isAfter(_date1: string, _date2: string): boolean {\n    return true;\n  }\n\n  isBefore(_date1: string, _date2: string): boolean {\n    return true;\n  }\n\n  isSame(_date1: string, _date2: string, _unit?: string): boolean {\n    return true;\n  }\n\n  lastModifiedTime(): boolean {\n    return false;\n  }\n\n  minute(_date: string): boolean {\n    // MINUTE with column references is not immutable in SQLite\n    return false;\n  }\n\n  month(_date: string): boolean {\n    // MONTH with column references is not immutable in SQLite\n    return false;\n  }\n\n  second(_date: string): boolean {\n    // SECOND with column references is not immutable in SQLite\n    return false;\n  }\n\n  timestr(_date: string): boolean {\n    return true;\n  }\n\n  toNow(_date: string): boolean {\n    // toNow results are unpredictable due to fixed creation time\n    return false;\n  }\n\n  weekNum(_date: string): boolean {\n    return true;\n  }\n\n  weekday(_date: string): boolean {\n    // WEEKDAY with column references is not immutable in SQLite\n    return false;\n  }\n\n  workday(_startDate: string, _days: string): boolean {\n    // Complex date calculations are limited in SQLite\n    return false;\n  }\n\n  workdayDiff(_startDate: string, _endDate: string): boolean {\n    // Complex date calculations are limited in SQLite\n    return false;\n  }\n\n  year(_date: string): boolean {\n    // YEAR with column references is not immutable in SQLite\n    return false;\n  }\n\n  createdTime(): boolean {\n    return false;\n  }\n\n  // Logical Functions - IF fallback to computed evaluation (not immutable-safe).\n  // Example: `IF({LinkField}, 1, 0)` needs to inspect JSON link arrays at runtime;\n  // SQLite generated columns cannot express that immutably, so we prevent GC usage.\n  if(_condition: string, _valueIfTrue: string, _valueIfFalse: string): boolean {\n    return false;\n  }\n\n  and(_params: string[]): boolean {\n    return true;\n  }\n\n  or(_params: string[]): boolean {\n    return true;\n  }\n\n  not(_value: string): boolean {\n    return true;\n  }\n\n  xor(_params: string[]): boolean {\n    return true;\n  }\n\n  blank(): boolean {\n    return true;\n  }\n\n  error(_message: string): boolean {\n    // Cannot throw errors in generated column definitions\n    return false;\n  }\n\n  isError(_value: string): boolean {\n    // Cannot detect runtime errors in generated columns\n    return false;\n  }\n\n  switch(\n    _expression: string,\n    _cases: Array<{ case: string; result: string }>,\n    _defaultResult?: string\n  ): boolean {\n    return true;\n  }\n\n  // Array Functions - Limited support due to SQLite constraints\n  count(_params: string[]): boolean {\n    return true;\n  }\n\n  countA(_params: string[]): boolean {\n    return true;\n  }\n\n  countAll(_value: string): boolean {\n    return true;\n  }\n\n  arrayJoin(_array: string, _separator?: string): boolean {\n    // Limited support, basic JSON array joining only\n    return false;\n  }\n\n  arrayUnique(_arrays: string[]): boolean {\n    // SQLite generated columns don't support complex operations for uniqueness\n    return false;\n  }\n\n  arrayFlatten(_arrays: string[]): boolean {\n    // SQLite generated columns don't support complex array flattening\n    return false;\n  }\n\n  arrayCompact(_arrays: string[]): boolean {\n    // SQLite generated columns don't support complex filtering without subqueries\n    return false;\n  }\n\n  // System Functions - Supported\n  recordId(): boolean {\n    // recordId is supported\n    return false;\n  }\n\n  autoNumber(): boolean {\n    return false;\n  }\n\n  textAll(_value: string): boolean {\n    // textAll with non-array types causes function mismatch in SQLite\n    return false;\n  }\n\n  // Binary Operations - All supported\n  add(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  subtract(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  multiply(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  divide(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  modulo(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Comparison Operations - All supported\n  equal(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  notEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  greaterThan(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  lessThan(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  greaterThanOrEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  lessThanOrEqual(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Logical Operations - All supported\n  logicalAnd(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  logicalOr(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  bitwiseAnd(_left: string, _right: string): boolean {\n    return true;\n  }\n\n  // Unary Operations - All supported\n  unaryMinus(_value: string): boolean {\n    return true;\n  }\n\n  // Field Reference - Supported\n  fieldReference(_fieldId: string, _columnName: string): boolean {\n    return true;\n  }\n\n  // Literals - All supported\n  stringLiteral(_value: string): boolean {\n    return true;\n  }\n\n  numberLiteral(_value: number): boolean {\n    return true;\n  }\n\n  booleanLiteral(_value: boolean): boolean {\n    return true;\n  }\n\n  nullLiteral(): boolean {\n    return true;\n  }\n\n  // Utility methods - All supported\n  castToNumber(_value: string): boolean {\n    return true;\n  }\n\n  castToString(_value: string): boolean {\n    return true;\n  }\n\n  castToBoolean(_value: string): boolean {\n    return true;\n  }\n\n  castToDate(_value: string): boolean {\n    return true;\n  }\n\n  // Handle null values and type checking - All supported\n  isNull(_value: string): boolean {\n    return true;\n  }\n\n  coalesce(_params: string[]): boolean {\n    return true;\n  }\n\n  // Parentheses for grouping - Supported\n  parentheses(_expression: string): boolean {\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.spec.ts",
    "content": "import { DbFieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\nimport { GeneratedColumnQuerySqlite } from './generated-column-query.sqlite';\n\ndescribe('GeneratedColumnQuerySqlite countAll', () => {\n  it('counts multi-value json field elements in COUNTALL', () => {\n    const query = new GeneratedColumnQuerySqlite();\n    query.setContext({} as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldMulti',\n          isMultiple: true,\n          isLookup: false,\n          dbFieldName: '__owners',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'string',\n        },\n      },\n    ] as unknown as never);\n\n    const sql = query.countAll('`__owners`');\n    expect(sql).toContain('json_array_length');\n    expect(sql).toContain(\"json_type(`__owners`) = 'array'\");\n  });\n\n  it('keeps scalar COUNTALL behavior for non-json field', () => {\n    const query = new GeneratedColumnQuerySqlite();\n    query.setContext({} as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'number',\n        isFieldReference: true,\n        field: {\n          id: 'fldNumber',\n          isMultiple: false,\n          isLookup: false,\n          dbFieldName: '__number',\n          dbFieldType: DbFieldType.Real,\n          cellValueType: 'number',\n        },\n      },\n    ] as unknown as never);\n\n    expect(query.countAll('`__number`')).toBe('CASE WHEN `__number` IS NULL THEN 0 ELSE 1 END');\n  });\n});\n\ndescribe('GeneratedColumnQuerySqlite FROMNOW/TONOW', () => {\n  it('applies unit conversion for FROMNOW', () => {\n    const query = new GeneratedColumnQuerySqlite();\n    query.setContext({} as unknown as never);\n\n    const daySql = query.fromNow('date_col', \"'day'\");\n    const hourSql = query.fromNow('date_col', \"'hour'\");\n    const secondSql = query.fromNow('date_col', \"'second'\");\n\n    expect(daySql).toBe(\"(JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))\");\n    expect(hourSql).toBe(\"((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0\");\n    expect(secondSql).toBe(\"((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0 * 60 * 60\");\n  });\n\n  it('keeps TONOW aligned with FROMNOW direction', () => {\n    const query = new GeneratedColumnQuerySqlite();\n    query.setContext({} as unknown as never);\n\n    const fromNowSql = query.fromNow('date_col', \"'day'\");\n    const toNowSql = query.toNow('date_col', \"'day'\");\n    expect(toNowSql).toBe(fromNowSql);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport { isTextLikeParam, resolveFormulaParamInfo } from '../../utils/formula-param-metadata.util';\nimport { GeneratedColumnQueryAbstract } from '../generated-column-query.abstract';\n\n/**\n * SQLite-specific implementation of generated column query functions\n * Converts Teable formula functions to SQLite SQL expressions suitable\n * for use in generated columns. All generated SQL must be immutable.\n */\nexport class GeneratedColumnQuerySqlite extends GeneratedColumnQueryAbstract {\n  private getParamInfo(index?: number) {\n    return resolveFormulaParamInfo(this.currentCallMetadata, index);\n  }\n\n  private isStringLiteral(value: string): boolean {\n    const trimmed = value.trim();\n    return /^'.*'$/.test(trimmed);\n  }\n\n  private isEmptyStringLiteral(value: string): boolean {\n    return value.trim() === \"''\";\n  }\n\n  private normalizeBlankComparable(value: string): string {\n    // Treat NULL and empty strings as empty text for comparison parity with interpreter\n    return `COALESCE(NULLIF(CAST((${value}) AS TEXT), ''), '')`;\n  }\n\n  private buildBlankAwareComparison(operator: '=' | '<>', left: string, right: string): string {\n    const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);\n    const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);\n    const leftInfo = this.getParamInfo(0);\n    const rightInfo = this.getParamInfo(1);\n    const textComparison =\n      leftIsEmptyLiteral ||\n      rightIsEmptyLiteral ||\n      this.isStringLiteral(left) ||\n      this.isStringLiteral(right) ||\n      isTextLikeParam(leftInfo) ||\n      isTextLikeParam(rightInfo);\n\n    if (!textComparison) {\n      return `(${left} ${operator} ${right})`;\n    }\n\n    const normalize = (value: string, isEmptyLiteral: boolean) =>\n      isEmptyLiteral ? \"''\" : this.normalizeBlankComparable(value);\n\n    return `(${normalize(left, leftIsEmptyLiteral)} ${operator} ${normalize(right, rightIsEmptyLiteral)})`;\n  }\n\n  // Numeric Functions\n  sum(params: string[]): string {\n    if (params.length === 0) {\n      return 'NULL';\n    }\n    if (params.length === 1) {\n      return `${params[0]}`;\n    }\n    // SQLite doesn't have SUM() for multiple values, use addition\n    return `(${this.joinParams(params, ' + ')})`;\n  }\n\n  average(params: string[]): string {\n    if (params.length === 0) {\n      return 'NULL';\n    }\n    if (params.length === 1) {\n      return `${params[0]}`;\n    }\n    // Calculate average as sum divided by count\n    return `((${this.joinParams(params, ' + ')}) / ${params.length})`;\n  }\n\n  max(params: string[]): string {\n    if (params.length === 0) {\n      return 'NULL';\n    }\n    if (params.length === 1) {\n      return `${params[0]}`;\n    }\n    // Use nested MAX functions for multiple values\n    return params.reduce((acc, param) => `MAX(${acc}, ${param})`);\n  }\n\n  min(params: string[]): string {\n    if (params.length === 0) {\n      return 'NULL';\n    }\n    if (params.length === 1) {\n      return `${params[0]}`;\n    }\n    // Use nested MIN functions for multiple values\n    return params.reduce((acc, param) => `MIN(${acc}, ${param})`);\n  }\n\n  round(value: string, precision?: string): string {\n    if (precision) {\n      return `ROUND(${value}, ${precision})`;\n    }\n    return `ROUND(${value})`;\n  }\n\n  roundUp(value: string, precision?: string): string {\n    if (precision) {\n      // Use manual power calculation for 10^precision (common cases)\n      const factor = `(\n        CASE\n          WHEN ${precision} = 0 THEN 1\n          WHEN ${precision} = 1 THEN 10\n          WHEN ${precision} = 2 THEN 100\n          WHEN ${precision} = 3 THEN 1000\n          WHEN ${precision} = 4 THEN 10000\n          ELSE 1\n        END\n      )`;\n      return `CAST(CEIL(${value} * ${factor}) / ${factor} AS REAL)`;\n    }\n    return `CAST(CEIL(${value}) AS INTEGER)`;\n  }\n\n  roundDown(value: string, precision?: string): string {\n    if (precision) {\n      // Use manual power calculation for 10^precision (common cases)\n      const factor = `(\n        CASE\n          WHEN ${precision} = 0 THEN 1\n          WHEN ${precision} = 1 THEN 10\n          WHEN ${precision} = 2 THEN 100\n          WHEN ${precision} = 3 THEN 1000\n          WHEN ${precision} = 4 THEN 10000\n          ELSE 1\n        END\n      )`;\n      return `CAST(FLOOR(${value} * ${factor}) / ${factor} AS REAL)`;\n    }\n    return `CAST(FLOOR(${value}) AS INTEGER)`;\n  }\n\n  ceiling(value: string): string {\n    return `CAST(CEIL(${value}) AS INTEGER)`;\n  }\n\n  floor(value: string): string {\n    return `CAST(FLOOR(${value}) AS INTEGER)`;\n  }\n\n  even(value: string): string {\n    return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 0 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;\n  }\n\n  odd(value: string): string {\n    return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 1 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;\n  }\n\n  int(value: string): string {\n    return `CAST(${value} AS INTEGER)`;\n  }\n\n  abs(value: string): string {\n    return `ABS(${value})`;\n  }\n\n  sqrt(value: string): string {\n    // SQLite doesn't have SQRT function, use Newton's method approximation\n    // One iteration of Newton's method: (x/2 + x/(x/2)) / 2\n    return `(\n      CASE\n        WHEN ${value} <= 0 THEN 0\n        ELSE (${value} / 2.0 + ${value} / (${value} / 2.0)) / 2.0\n      END\n    )`;\n  }\n\n  power(base: string, exponent: string): string {\n    // SQLite doesn't have POWER function, implement for common cases\n    return `(\n      CASE\n        WHEN ${exponent} = 0 THEN 1\n        WHEN ${exponent} = 1 THEN ${base}\n        WHEN ${exponent} = 2 THEN ${base} * ${base}\n        WHEN ${exponent} = 3 THEN ${base} * ${base} * ${base}\n        WHEN ${exponent} = 4 THEN ${base} * ${base} * ${base} * ${base}\n        WHEN ${exponent} = 0.5 THEN\n          -- Square root case using Newton's method\n          CASE\n            WHEN ${base} <= 0 THEN 0\n            ELSE (${base} / 2.0 + ${base} / (${base} / 2.0)) / 2.0\n          END\n        ELSE 1\n      END\n    )`;\n  }\n\n  exp(value: string): string {\n    return `EXP(${value})`;\n  }\n\n  log(value: string, base?: string): string {\n    if (base) {\n      return `(LOG(${value}) / LOG(${base}))`;\n    }\n    // SQLite LOG is base 10, but formula LOG should be natural log (base e)\n    return `LN(${value})`;\n  }\n\n  mod(dividend: string, divisor: string): string {\n    return `(${dividend} % ${divisor})`;\n  }\n\n  value(text: string): string {\n    return `CAST(${text} AS REAL)`;\n  }\n\n  // Text Functions\n  concatenate(params: string[]): string {\n    // Handle NULL values by converting them to empty strings for CONCATENATE function\n    // This mirrors the behavior of the formula evaluation engine\n    const nullSafeParams = params.map((param) => `COALESCE(${param}, '')`);\n    return `(${this.joinParams(nullSafeParams, ' || ')})`;\n  }\n\n  // String concatenation for + operator (treats NULL as empty string)\n  stringConcat(left: string, right: string): string {\n    return `(COALESCE(${left}, '') || COALESCE(${right}, ''))`;\n  }\n\n  equal(left: string, right: string): string {\n    return this.buildBlankAwareComparison('=', left, right);\n  }\n\n  notEqual(left: string, right: string): string {\n    return this.buildBlankAwareComparison('<>', left, right);\n  }\n\n  find(searchText: string, withinText: string, startNum?: string): string {\n    if (startNum) {\n      return `CASE WHEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) > 0 THEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) + ${startNum} - 1 ELSE 0 END`;\n    }\n    return `INSTR(${withinText}, ${searchText})`;\n  }\n\n  search(searchText: string, withinText: string, startNum?: string): string {\n    // SQLite INSTR is case-sensitive, so we use UPPER for case-insensitive search\n    if (startNum) {\n      return `CASE WHEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) > 0 THEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) + ${startNum} - 1 ELSE 0 END`;\n    }\n    return `INSTR(UPPER(${withinText}), UPPER(${searchText}))`;\n  }\n\n  mid(text: string, startNum: string, numChars: string): string {\n    return `SUBSTR(${text}, ${startNum}, ${numChars})`;\n  }\n\n  left(text: string, numChars: string): string {\n    return `SUBSTR(${text}, 1, ${numChars})`;\n  }\n\n  right(text: string, numChars: string): string {\n    return `SUBSTR(${text}, -${numChars})`;\n  }\n\n  replace(oldText: string, startNum: string, numChars: string, newText: string): string {\n    return `SUBSTR(${oldText}, 1, ${startNum} - 1) || ${newText} || SUBSTR(${oldText}, ${startNum} + ${numChars})`;\n  }\n\n  regexpReplace(text: string, pattern: string, replacement: string): string {\n    // SQLite doesn't have built-in regex replace, would need extension\n    return `REPLACE(${text}, ${pattern}, ${replacement})`;\n  }\n\n  substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {\n    // SQLite REPLACE replaces all instances, no direct support for specific instance\n    return `REPLACE(${text}, ${oldText}, ${newText})`;\n  }\n\n  lower(text: string): string {\n    return `LOWER(${text})`;\n  }\n\n  upper(text: string): string {\n    return `UPPER(${text})`;\n  }\n\n  rept(text: string, numTimes: string): string {\n    // SQLite doesn't have REPEAT function, need to use recursive CTE or custom function\n    return `REPLACE(HEX(ZEROBLOB(${numTimes})), '00', ${text})`;\n  }\n\n  trim(text: string): string {\n    return `TRIM(${text})`;\n  }\n\n  len(text: string): string {\n    return `LENGTH(${text})`;\n  }\n\n  t(value: string): string {\n    return `CASE\n      WHEN ${value} IS NULL THEN ''\n      WHEN ${value} = CAST(${value} AS INTEGER) THEN CAST(${value} AS INTEGER)\n      ELSE CAST(${value} AS TEXT)\n    END`;\n  }\n\n  encodeUrlComponent(text: string): string {\n    // SQLite doesn't have built-in URL encoding\n    return `${text}`;\n  }\n\n  // DateTime Functions\n  now(): string {\n    // For generated columns, use the current timestamp at field creation time\n    if (this.isGeneratedColumnContext) {\n      const currentTimestamp = new Date()\n        .toISOString()\n        .replace('T', ' ')\n        .replace('Z', '')\n        .replace(/\\.\\d{3}$/, '');\n      return `'${currentTimestamp}'`;\n    }\n    return \"DATETIME('now')\";\n  }\n\n  today(): string {\n    // For generated columns, use the current date at field creation time\n    if (this.isGeneratedColumnContext) {\n      const currentDate = new Date().toISOString().split('T')[0];\n      return `'${currentDate}'`;\n    }\n    return \"DATE('now')\";\n  }\n\n  private normalizeDateModifier(unitLiteral: string): {\n    unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years';\n    factor: number;\n  } {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return { unit: 'seconds', factor: 0.001 };\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return { unit: 'seconds', factor: 1 };\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return { unit: 'minutes', factor: 1 };\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return { unit: 'hours', factor: 1 };\n      case 'week':\n      case 'weeks':\n        return { unit: 'days', factor: 7 };\n      case 'month':\n      case 'months':\n        return { unit: 'months', factor: 1 };\n      case 'quarter':\n      case 'quarters':\n        return { unit: 'months', factor: 3 };\n      case 'year':\n      case 'years':\n        return { unit: 'years', factor: 1 };\n      case 'day':\n      case 'days':\n      default:\n        return { unit: 'days', factor: 1 };\n    }\n  }\n\n  private normalizeDiffUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      default:\n        return 'day';\n    }\n  }\n\n  private normalizeTruncateFormat(unitLiteral: string): string {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return '%Y-%m-%d %H:%M:%S';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return '%Y-%m-%d %H:%M';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return '%Y-%m-%d %H';\n      case 'week':\n      case 'weeks':\n        return '%Y-%W';\n      case 'month':\n      case 'months':\n        return '%Y-%m';\n      case 'year':\n      case 'years':\n        return '%Y';\n      case 'day':\n      case 'days':\n      default:\n        return '%Y-%m-%d';\n    }\n  }\n\n  dateAdd(date: string, count: string, unit: string): string {\n    const { unit: cleanUnit, factor } = this.normalizeDateModifier(unit);\n    const scaledCount = factor === 1 ? `(${count})` : `(${count}) * ${factor}`;\n    return `DATETIME(${date}, (${scaledCount}) || ' ${cleanUnit}')`;\n  }\n\n  datestr(date: string): string {\n    return `DATE(${date})`;\n  }\n\n  private buildMonthDiff(startDate: string, endDate: string): string {\n    const startYear = `CAST(STRFTIME('%Y', ${startDate}) AS INTEGER)`;\n    const endYear = `CAST(STRFTIME('%Y', ${endDate}) AS INTEGER)`;\n    const startMonth = `CAST(STRFTIME('%m', ${startDate}) AS INTEGER)`;\n    const endMonth = `CAST(STRFTIME('%m', ${endDate}) AS INTEGER)`;\n    const startDay = `CAST(STRFTIME('%d', ${startDate}) AS INTEGER)`;\n    const endDay = `CAST(STRFTIME('%d', ${endDate}) AS INTEGER)`;\n    const startLastDay = `CAST(STRFTIME('%d', DATE(${startDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;\n    const endLastDay = `CAST(STRFTIME('%d', DATE(${endDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;\n\n    const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;\n    const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;\n    const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;\n\n    return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;\n  }\n\n  datetimeDiff(startDate: string, endDate: string, unit: string): string {\n    const baseDiffDays = `(JULIANDAY(${startDate}) - JULIANDAY(${endDate}))`;\n    switch (this.normalizeDiffUnit(unit)) {\n      case 'millisecond':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;\n      case 'second':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60`;\n      case 'minute':\n        return `(${baseDiffDays}) * 24.0 * 60`;\n      case 'hour':\n        return `(${baseDiffDays}) * 24.0`;\n      case 'week':\n        return `(${baseDiffDays}) / 7.0`;\n      case 'month':\n        return this.buildMonthDiff(startDate, endDate);\n      case 'quarter':\n        return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(startDate, endDate);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `${baseDiffDays}`;\n    }\n  }\n\n  datetimeFormat(date: string, format: string): string {\n    // Convert common format patterns to SQLite STRFTIME format\n    const cleanFormat = format.replace(/^'|'$/g, '');\n    const sqliteFormat = cleanFormat\n      .replace(/YYYY/g, '%Y')\n      .replace(/MM/g, '%m')\n      .replace(/DD/g, '%d')\n      .replace(/HH/g, '%H')\n      .replace(/mm/g, '%M')\n      .replace(/ss/g, '%S');\n\n    return `STRFTIME('${sqliteFormat}', ${date})`;\n  }\n\n  datetimeParse(dateString: string, _format?: string): string {\n    // SQLite doesn't have direct parsing with custom format\n    return `DATETIME(${dateString})`;\n  }\n\n  day(date: string): string {\n    return `CAST(STRFTIME('%d', ${date}) AS INTEGER)`;\n  }\n\n  private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {\n    const diffUnit = this.normalizeDiffUnit(unit);\n    const baseDiffDays = `(JULIANDAY(${nowExpr}) - JULIANDAY(${dateExpr}))`;\n    switch (diffUnit) {\n      case 'millisecond':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;\n      case 'second':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60`;\n      case 'minute':\n        return `(${baseDiffDays}) * 24.0 * 60`;\n      case 'hour':\n        return `(${baseDiffDays}) * 24.0`;\n      case 'week':\n        return `(${baseDiffDays}) / 7.0`;\n      case 'month':\n        return this.buildMonthDiff(nowExpr, dateExpr);\n      case 'quarter':\n        return `${this.buildMonthDiff(nowExpr, dateExpr)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(nowExpr, dateExpr);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `${baseDiffDays}`;\n    }\n  }\n\n  fromNow(date: string, unit = 'day'): string {\n    // For generated columns, use the current timestamp at field creation time\n    const dateExpr = `DATETIME(${date})`;\n    if (this.isGeneratedColumnContext) {\n      const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');\n      return this.buildNowDiffByUnit(`'${currentTimestamp}'`, dateExpr, unit);\n    }\n    return this.buildNowDiffByUnit(\"'now'\", dateExpr, unit);\n  }\n\n  hour(date: string): string {\n    return `CAST(STRFTIME('%H', ${date}) AS INTEGER)`;\n  }\n\n  isAfter(date1: string, date2: string): string {\n    return `DATETIME(${date1}) > DATETIME(${date2})`;\n  }\n\n  isBefore(date1: string, date2: string): string {\n    return `DATETIME(${date1}) < DATETIME(${date2})`;\n  }\n\n  isSame(date1: string, date2: string, unit?: string): string {\n    if (unit) {\n      const trimmed = unit.trim();\n      if (trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\")) {\n        const format = this.normalizeTruncateFormat(trimmed.slice(1, -1));\n        return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;\n      }\n      const format = this.normalizeTruncateFormat(unit);\n      return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;\n    }\n    return `DATETIME(${date1}) = DATETIME(${date2})`;\n  }\n\n  lastModifiedTime(): string {\n    return '__last_modified_time';\n  }\n\n  minute(date: string): string {\n    return `CAST(STRFTIME('%M', ${date}) AS INTEGER)`;\n  }\n\n  month(date: string): string {\n    return `CAST(STRFTIME('%m', ${date}) AS INTEGER)`;\n  }\n\n  second(date: string): string {\n    return `CAST(STRFTIME('%S', ${date}) AS INTEGER)`;\n  }\n\n  timestr(date: string): string {\n    return `TIME(${date})`;\n  }\n\n  toNow(date: string, unit = 'day'): string {\n    return this.fromNow(date, unit);\n  }\n\n  weekNum(date: string): string {\n    return `CAST(STRFTIME('%W', ${date}) AS INTEGER)`;\n  }\n\n  weekday(date: string, _startDayOfWeek?: string): string {\n    // Convert SQLite's 0-based weekday (0=Sunday) to 1-based (1=Sunday)\n    return `(CAST(STRFTIME('%w', ${date}) AS INTEGER) + 1)`;\n  }\n\n  workday(startDate: string, days: string, _holidayStr?: string): string {\n    return `DATE(${startDate}, '+' || ${days} || ' days')`;\n  }\n\n  workdayDiff(startDate: string, endDate: string): string {\n    return `CAST(JULIANDAY(${endDate}) - JULIANDAY(${startDate}) AS INTEGER)`;\n  }\n\n  year(date: string): string {\n    return `CAST(STRFTIME('%Y', ${date}) AS INTEGER)`;\n  }\n\n  createdTime(): string {\n    return '__created_time';\n  }\n\n  private normalizeBooleanCondition(condition: string): string {\n    const wrapped = `(${condition})`;\n    const valueType = `TYPEOF${wrapped}`;\n    return `CASE\n      WHEN ${wrapped} IS NULL THEN 0\n      WHEN ${valueType} = 'integer' OR ${valueType} = 'real' THEN (${wrapped}) != 0\n      WHEN ${valueType} = 'text' THEN (${wrapped} != '' AND LOWER(${wrapped}) != 'null')\n      ELSE (${wrapped}) IS NOT NULL AND ${wrapped} != 'null'\n    END`;\n  }\n\n  // Logical Functions\n  if(condition: string, valueIfTrue: string, valueIfFalse: string): string {\n    const booleanCondition = this.normalizeBooleanCondition(condition);\n    return `CASE WHEN (${booleanCondition}) THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;\n  }\n\n  and(params: string[]): string {\n    return `(${this.joinParams(params, ' AND ')})`;\n  }\n\n  or(params: string[]): string {\n    return `(${this.joinParams(params, ' OR ')})`;\n  }\n\n  not(value: string): string {\n    return `NOT (${value})`;\n  }\n\n  xor(params: string[]): string {\n    // SQLite doesn't have built-in XOR for multiple values\n    if (params.length === 2) {\n      return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;\n    }\n    // For multiple values, count true values and check if odd\n    return `(${this.joinParams(\n      params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`),\n      ' + '\n    )}) % 2 = 1`;\n  }\n\n  blank(): string {\n    return 'NULL';\n  }\n\n  error(_message: string): string {\n    // ERROR function in SQLite generated columns should return NULL\n    // since we can't throw actual errors in generated columns\n    return 'NULL';\n  }\n\n  isError(value: string): string {\n    // SQLite doesn't have a direct ISERROR function\n    return `CASE WHEN ${value} IS NULL THEN 1 ELSE 0 END`;\n  }\n\n  switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string {\n    let caseStatement = 'CASE';\n\n    for (const caseItem of cases) {\n      caseStatement += ` WHEN ${expression} = ${caseItem.case} THEN ${caseItem.result}`;\n    }\n\n    if (defaultResult) {\n      caseStatement += ` ELSE ${defaultResult}`;\n    }\n\n    caseStatement += ' END';\n    return caseStatement;\n  }\n\n  // Array Functions\n  count(params: string[]): string {\n    // Count non-null values\n    return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`).join(' + ')})`;\n  }\n\n  countA(params: string[]): string {\n    // Count non-empty values (excluding empty strings)\n    return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL AND ${p} <> '' THEN 1 ELSE 0 END`).join(' + ')})`;\n  }\n\n  countAll(value: string): string {\n    const paramInfo = this.getParamInfo(0);\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      return `CASE\n        WHEN ${value} IS NULL THEN 0\n        WHEN json_valid(${value}) AND json_type(${value}) = 'array' THEN COALESCE(json_array_length(${value}), 0)\n        WHEN json_valid(${value}) AND json_type(${value}) = 'null' THEN 0\n        ELSE 1\n      END`;\n    }\n\n    // For single values, return 1 if not null, 0 if null.\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  private buildJsonArrayUnion(\n    arrays: string[],\n    opts?: { filterNulls?: boolean; withOrdinal?: boolean }\n  ): string {\n    const selects = arrays.map((array, index) => {\n      const base = `SELECT value, ${index} AS arg_index, CAST(key AS INTEGER) AS ord FROM json_each(COALESCE(${array}, '[]'))`;\n      const whereClause = opts?.filterNulls\n        ? \" WHERE value IS NOT NULL AND value != 'null' AND value != ''\"\n        : '';\n      return `${base}${whereClause}`;\n    });\n\n    if (selects.length === 0) {\n      return 'SELECT NULL AS value, 0 AS arg_index, 0 AS ord WHERE 0';\n    }\n\n    return selects.join(' UNION ALL ');\n  }\n\n  arrayJoin(array: string, separator?: string): string {\n    // SQLite generated columns don't support subqueries, so we'll use simple string manipulation\n    // This assumes arrays are stored as JSON strings like [\"a\",\"b\",\"c\"] or [\"a\", \"b\", \"c\"]\n    const sep = separator ? this.stringLiteral(separator) : this.stringLiteral(', ');\n    return `(\n      CASE\n        WHEN json_valid(${array}) AND json_type(${array}) = 'array' THEN\n          REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(${array}, '[', ''), ']', ''), '\"', ''), ', ', ','), ',', ${sep})\n        WHEN ${array} IS NOT NULL THEN CAST(${array} AS TEXT)\n        ELSE NULL\n      END\n    )`;\n  }\n\n  arrayUnique(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true, filterNulls: true });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (\n          SELECT value, ROW_NUMBER() OVER (PARTITION BY value ORDER BY arg_index, ord) AS rn, arg_index, ord\n          FROM (${unionQuery}) AS combined\n        )\n        WHERE rn = 1\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  arrayFlatten(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (${unionQuery}) AS combined\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  arrayCompact(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, {\n      filterNulls: true,\n      withOrdinal: true,\n    });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (${unionQuery}) AS combined\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  // System Functions\n  recordId(): string {\n    return '__id';\n  }\n\n  autoNumber(): string {\n    return '__auto_number';\n  }\n\n  textAll(value: string): string {\n    // Use same logic as t() function to handle integer formatting\n    return `CASE\n      WHEN ${value} = CAST(${value} AS INTEGER) THEN CAST(${value} AS INTEGER)\n      ELSE CAST(${value} AS TEXT)\n    END`;\n  }\n\n  // Field Reference - SQLite uses backticks for identifiers\n  fieldReference(_fieldId: string, columnName: string): string {\n    // For regular field references, return the column reference\n    // Note: Expansion is handled at the expression level, not at individual field reference level\n    return `\\`${columnName}\\``;\n  }\n\n  // Override some base implementations for SQLite-specific syntax\n  castToNumber(value: string): string {\n    return `CAST(${value} AS REAL)`;\n  }\n\n  castToString(value: string): string {\n    return `CAST(${value} AS TEXT)`;\n  }\n\n  castToBoolean(value: string): string {\n    return `CAST(${value} AS INTEGER)`;\n  }\n\n  castToDate(value: string): string {\n    return `DATETIME(${value})`;\n  }\n\n  // SQLite uses square brackets for identifiers with special characters\n  protected escapeIdentifier(identifier: string): string {\n    return `[${identifier.replace(/\\]/g, ']]')}]`;\n  }\n\n  // Override binary operations to handle SQLite-specific behavior\n  modulo(left: string, right: string): string {\n    return `(${left} % ${right})`;\n  }\n\n  // SQLite uses different boolean literals\n  booleanLiteral(value: boolean): string {\n    return value ? '1' : '0';\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/group-query/format-string.ts",
    "content": "import { DateFormattingPreset, TimeFormatting } from '@teable/core';\n\nexport const getPostgresDateTimeFormatString = (\n  date: DateFormattingPreset,\n  time: TimeFormatting\n) => {\n  switch (date) {\n    case DateFormattingPreset.Y:\n      return 'YYYY';\n    case DateFormattingPreset.M:\n    case DateFormattingPreset.YM:\n      return 'YYYY-MM';\n    default:\n      return time !== TimeFormatting.None ? 'YYYY-MM-DD HH24:MI' : 'YYYY-MM-DD';\n  }\n};\n\nexport const getSqliteDateTimeFormatString = (date: DateFormattingPreset, time: TimeFormatting) => {\n  switch (date) {\n    case DateFormattingPreset.Y:\n      return '%Y';\n    case DateFormattingPreset.M:\n    case DateFormattingPreset.YM:\n      return '%Y-%m';\n    default:\n      return time !== TimeFormatting.None ? '%Y-%m-%d %H:%M' : '%Y-%m-%d';\n  }\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/group-query/group-query.abstract.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport type { FieldCore } from '@teable/core';\nimport { CellValueType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { IGroupQueryInterface, IGroupQueryExtra } from './group-query.interface';\n\nexport abstract class AbstractGroupQuery implements IGroupQueryInterface {\n  private logger = new Logger(AbstractGroupQuery.name);\n\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fieldMap?: { [fieldId: string]: FieldCore },\n    protected readonly groupFieldIds?: string[],\n    protected readonly extra?: IGroupQueryExtra,\n    protected readonly context?: IRecordQueryGroupContext\n  ) {}\n\n  appendGroupBuilder(): Knex.QueryBuilder {\n    return this.parseGroups(this.originQueryBuilder, this.groupFieldIds);\n  }\n\n  protected getTableColumnName(field: FieldCore): string {\n    const selection = this.context?.selectionMap.get(field.id);\n    if (selection) {\n      return selection as string;\n    }\n    return field.dbFieldName;\n  }\n\n  private parseGroups(\n    queryBuilder: Knex.QueryBuilder,\n    groupFieldIds?: string[]\n  ): Knex.QueryBuilder {\n    if (!groupFieldIds || !groupFieldIds.length) {\n      return queryBuilder;\n    }\n\n    groupFieldIds.forEach((fieldId) => {\n      const field = this.fieldMap?.[fieldId];\n\n      if (!field) {\n        return queryBuilder;\n      }\n      this.getGroupAdapter(field);\n    });\n\n    return queryBuilder;\n  }\n\n  private getGroupAdapter(field: FieldCore): Knex.QueryBuilder {\n    if (!field) return this.originQueryBuilder;\n    const { cellValueType, isMultipleCellValue, isStructuredCellValue } = field;\n\n    if (isMultipleCellValue) {\n      switch (cellValueType) {\n        case CellValueType.DateTime:\n          return this.multipleDate(field);\n        case CellValueType.Number:\n          return this.multipleNumber(field);\n        case CellValueType.String:\n          if (isStructuredCellValue) {\n            return this.json(field);\n          }\n          return this.string(field);\n        default:\n          return this.originQueryBuilder;\n      }\n    }\n\n    switch (cellValueType) {\n      case CellValueType.DateTime:\n        return this.date(field);\n      case CellValueType.Number:\n        return this.number(field);\n      case CellValueType.Boolean:\n      case CellValueType.String: {\n        if (isStructuredCellValue) {\n          return this.json(field);\n        }\n        return this.string(field);\n      }\n    }\n  }\n\n  abstract string(field: FieldCore): Knex.QueryBuilder;\n\n  abstract date(field: FieldCore): Knex.QueryBuilder;\n\n  abstract number(field: FieldCore): Knex.QueryBuilder;\n\n  abstract json(field: FieldCore): Knex.QueryBuilder;\n\n  abstract multipleDate(field: FieldCore): Knex.QueryBuilder;\n\n  abstract multipleNumber(field: FieldCore): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/group-query/group-query.interface.ts",
    "content": "import type { Knex } from 'knex';\n\nexport interface IGroupQueryInterface {\n  appendGroupBuilder(): Knex.QueryBuilder;\n}\n\nexport interface IGroupQueryExtra {\n  isDistinct?: boolean;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/group-query/group-query.postgres.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INumberFieldOptions, IDateFieldOptions, FieldCore } from '@teable/core';\nimport { DateFormattingPreset, TimeFormatting } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport { isUserOrLink } from '../../utils/is-user-or-link';\nimport { AbstractGroupQuery } from './group-query.abstract';\nimport type { IGroupQueryExtra } from './group-query.interface';\n\nexport class GroupQueryPostgres extends AbstractGroupQuery {\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fieldMap?: { [fieldId: string]: FieldCore },\n    protected readonly groupFieldIds?: string[],\n    protected readonly extra?: IGroupQueryExtra,\n    protected readonly context?: IRecordQueryGroupContext\n  ) {\n    super(knex, originQueryBuilder, fieldMap, groupFieldIds, extra, context);\n  }\n\n  private get isDistinct() {\n    const { isDistinct } = this.extra ?? {};\n    return isDistinct;\n  }\n\n  string(field: FieldCore): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(columnName);\n    }\n    return this.originQueryBuilder\n      .select({ [field.dbFieldName]: this.knex.raw(columnName) })\n      .groupByRaw(columnName);\n  }\n\n  number(field: FieldCore): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { precision = 0 } = (options as INumberFieldOptions).formatting ?? {};\n    const column = this.knex.raw(\n      `ROUND(${columnName}::numeric, ?::int)::float as \"${field.dbFieldName}\"`,\n      [precision]\n    );\n    const groupByColumn = this.knex.raw(`ROUND(${columnName}::numeric, ?::int)::float`, [\n      precision,\n    ]);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  private resolveDateTruncUnit(\n    datePreset: DateFormattingPreset,\n    time: TimeFormatting\n  ): 'year' | 'month' | 'day' | 'minute' {\n    switch (datePreset) {\n      case DateFormattingPreset.Y:\n        return 'year';\n      case DateFormattingPreset.M:\n      case DateFormattingPreset.YM:\n        return 'month';\n      default:\n        return time !== TimeFormatting.None ? 'minute' : 'day';\n    }\n  }\n\n  date(field: FieldCore): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const unit = this.resolveDateTruncUnit(date as DateFormattingPreset, time);\n    const dbFieldAlias = field.dbFieldName.replace(/\"/g, '\"\"');\n\n    // Use timestamptz group keys:\n    // 1) Convert to local timestamp via TIMEZONE(tz, timestamptz)\n    // 2) DATE_TRUNC in local time\n    // 3) Convert back to timestamptz via TIMEZONE(tz, timestamp)\n    const groupExpr = `TIMEZONE(?, DATE_TRUNC(?, TIMEZONE(?, ${columnName})))`;\n    const bindings = [timeZone, unit, timeZone] as const;\n\n    const column = this.knex.raw(`${groupExpr} as \"${dbFieldAlias}\"`, bindings);\n    const groupByColumn = this.knex.raw(groupExpr, bindings);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  json(field: FieldCore): Knex.QueryBuilder {\n    const { type, isMultipleCellValue } = field;\n    const columnName = this.getTableColumnName(field);\n\n    if (this.isDistinct) {\n      if (isUserOrLink(type)) {\n        if (!isMultipleCellValue) {\n          const column = this.knex.raw(`${columnName}::jsonb ->> 'id'`);\n\n          return this.originQueryBuilder.countDistinct(column);\n        }\n\n        const column = this.knex.raw(\n          `jsonb_path_query_array(${columnName}::jsonb, '$[*].id')::text`\n        );\n\n        return this.originQueryBuilder.countDistinct(column);\n      }\n      return this.originQueryBuilder.countDistinct(columnName);\n    }\n\n    if (isUserOrLink(type)) {\n      if (!isMultipleCellValue) {\n        const column = this.knex.raw(\n          `NULLIF(jsonb_build_object(\n            'id', ${columnName}::jsonb ->> 'id',\n            'title', ${columnName}::jsonb ->> 'title'\n          ), '{\"id\":null,\"title\":null}') as \"${field.dbFieldName}\"`\n        );\n        const groupByColumn = this.knex.raw(\n          `${columnName}::jsonb ->> 'id', ${columnName}::jsonb ->> 'title'`\n        );\n\n        return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n      }\n\n      const column = this.knex.raw(\n        `(jsonb_agg(${columnName}::jsonb) -> 0) as \"${field.dbFieldName}\"`\n      );\n      const groupByColumn = this.knex.raw(\n        `jsonb_path_query_array(${columnName}::jsonb, '$[*].id')::text, jsonb_path_query_array(${columnName}::jsonb, '$[*].title')::text`\n      );\n\n      return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n    }\n\n    const column = this.knex.raw(`CAST(${columnName} as text)`);\n    return this.originQueryBuilder.select(column).groupByRaw(columnName);\n  }\n\n  multipleDate(field: FieldCore): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const unit = this.resolveDateTruncUnit(date as DateFormattingPreset, time);\n    const dbFieldAlias = field.dbFieldName.replace(/\"/g, '\"\"');\n\n    const elemExpr = `TIMEZONE(?, DATE_TRUNC(?, TIMEZONE(?, CAST(elem AS timestamp with time zone))))`;\n    const elemBindings = [timeZone, unit, timeZone] as const;\n\n    const column = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(${elemExpr}))\n      FROM jsonb_array_elements_text(${columnName}::jsonb) as elem) as \"${dbFieldAlias}\"\n      `,\n      elemBindings\n    );\n    const groupByColumn = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(${elemExpr}))\n      FROM jsonb_array_elements_text(${columnName}::jsonb) as elem)\n      `,\n      elemBindings\n    );\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  multipleNumber(field: FieldCore): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { precision = 0 } = (options as INumberFieldOptions).formatting ?? {};\n    const column = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${columnName}::jsonb) as elem) as \"${field.dbFieldName}\"\n      `,\n      [precision]\n    );\n    const groupByColumn = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${columnName}::jsonb) as elem)\n      `,\n      [precision]\n    );\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/group-query/group-query.sqlite.ts",
    "content": "import type { DateFormattingPreset, INumberFieldOptions, IDateFieldOptions } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport { isUserOrLink } from '../../utils/is-user-or-link';\nimport { getOffset } from '../search-query/get-offset';\nimport { getSqliteDateTimeFormatString } from './format-string';\nimport { AbstractGroupQuery } from './group-query.abstract';\nimport type { IGroupQueryExtra } from './group-query.interface';\n\nexport class GroupQuerySqlite extends AbstractGroupQuery {\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fieldMap?: { [fieldId: string]: IFieldInstance },\n    protected readonly groupFieldIds?: string[],\n    protected readonly extra?: IGroupQueryExtra,\n    protected readonly context?: IRecordQueryGroupContext\n  ) {\n    super(knex, originQueryBuilder, fieldMap, groupFieldIds, extra, context);\n  }\n\n  private get isDistinct() {\n    const { isDistinct } = this.extra ?? {};\n    return isDistinct;\n  }\n\n  string(field: IFieldInstance): Knex.QueryBuilder {\n    if (!field) return this.originQueryBuilder;\n\n    const columnName = this.getTableColumnName(field);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(columnName);\n    }\n    return this.originQueryBuilder\n      .select({ [field.dbFieldName]: this.knex.raw(columnName) })\n      .groupByRaw(columnName);\n  }\n\n  number(field: IFieldInstance): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    const column = this.knex.raw(`ROUND(${columnName}, ?) as ${columnName}`, [precision]);\n    const groupByColumn = this.knex.raw(`ROUND(${columnName}, ?)`, [precision]);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  date(field: IFieldInstance): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetStr = `${getOffset(timeZone)} hour`;\n    const column = this.knex.raw(`strftime(?, DATETIME(${columnName}, ?)) as ${columnName}`, [\n      formatString,\n      offsetStr,\n    ]);\n    const groupByColumn = this.knex.raw(`strftime(?, DATETIME(${columnName}, ?))`, [\n      formatString,\n      offsetStr,\n    ]);\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  json(field: IFieldInstance): Knex.QueryBuilder {\n    const { type, isMultipleCellValue } = field;\n    const columnName = this.getTableColumnName(field);\n\n    if (this.isDistinct) {\n      if (isUserOrLink(type)) {\n        if (!isMultipleCellValue) {\n          const groupByColumn = this.knex.raw(\n            `json_extract(${columnName}, '$.id') || json_extract(${columnName}, '$.title')`\n          );\n          return this.originQueryBuilder.countDistinct(groupByColumn);\n        }\n        const groupByColumn = this.knex.raw(`json_extract(${columnName}, '$[0].id', '$[0].title')`);\n        return this.originQueryBuilder.countDistinct(groupByColumn);\n      }\n      return this.originQueryBuilder.countDistinct(columnName);\n    }\n\n    if (isUserOrLink(type)) {\n      if (!isMultipleCellValue) {\n        const groupByColumn = this.knex.raw(\n          `json_extract(${columnName}, '$.id') || json_extract(${columnName}, '$.title')`\n        );\n        return this.originQueryBuilder.select(columnName).groupBy(groupByColumn);\n      }\n\n      const groupByColumn = this.knex.raw(`json_extract(${columnName}, '$[0].id', '$[0].title')`);\n      return this.originQueryBuilder.select(columnName).groupBy(groupByColumn);\n    }\n\n    const column = this.knex.raw(`CAST(${columnName} as text) as ${columnName}`);\n    return this.originQueryBuilder.select(column).groupByRaw(columnName);\n  }\n\n  multipleDate(field: IFieldInstance): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n\n    const offsetStr = `${getOffset(timeZone)} hour`;\n    const column = this.knex.raw(\n      `\n      (\n        SELECT json_group_array(strftime(?, DATETIME(value, ?)))\n        FROM json_each(${columnName})\n      ) as ${columnName}\n      `,\n      [formatString, offsetStr]\n    );\n    const groupByColumn = this.knex.raw(\n      `\n      (\n        SELECT json_group_array(strftime(?, DATETIME(value, ?)))\n        FROM json_each(${columnName})\n      )\n      `,\n      [formatString, offsetStr]\n    );\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n\n  multipleNumber(field: IFieldInstance): Knex.QueryBuilder {\n    const columnName = this.getTableColumnName(field);\n    const { options } = field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    const column = this.knex.raw(\n      `\n      (\n        SELECT json_group_array(ROUND(value, ?))\n        FROM json_each(${columnName})\n      ) as ${columnName}\n      `,\n      [precision]\n    );\n    const groupByColumn = this.knex.raw(\n      `\n      (\n        SELECT json_group_array(ROUND(value, ?))\n        FROM json_each(${columnName})\n      )\n      `,\n      [precision]\n    );\n\n    if (this.isDistinct) {\n      return this.originQueryBuilder.countDistinct(groupByColumn);\n    }\n    return this.originQueryBuilder.select(column).groupBy(groupByColumn);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/index-query/index-abstract-builder.ts",
    "content": "import type { IGetAbnormalVo } from '@teable/openapi';\nimport type { IFieldInstance } from '../../features/field/model/factory';\n\nexport abstract class IndexBuilderAbstract {\n  abstract getDropIndexSql(dbTableName: string): string;\n\n  abstract getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[];\n\n  abstract getExistTableIndexSql(dbTableName: string): string;\n\n  abstract getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string;\n\n  abstract getUpdateSingleIndexNameSql(\n    dbTableName: string,\n    oldField: Pick<IFieldInstance, 'id' | 'dbFieldName'>,\n    newField: Pick<IFieldInstance, 'id' | 'dbFieldName'>\n  ): string;\n\n  abstract createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null;\n\n  abstract getIndexInfoSql(dbTableName: string): string;\n\n  abstract getAbnormalIndex(\n    dbTableName: string,\n    fields: IFieldInstance[],\n    existingIndex: unknown[]\n  ): IGetAbnormalVo;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/integrity-query/abstract.ts",
    "content": "import type { Knex } from 'knex';\n\nexport abstract class IntegrityQueryAbstract {\n  constructor(protected readonly knex: Knex) {}\n\n  abstract checkLinks(params: {\n    dbTableName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string;\n\n  abstract fixLinks(params: {\n    dbTableName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string;\n\n  /**\n   * Deprecated: Do NOT use in new code.\n   * Link fields do not persist a display JSON column; their values are derived\n   * from junction tables or foreign key columns. This helper was only used by\n   * legacy tests to mutate a hypothetical JSON display column to simulate\n   * inconsistencies. Prefer modifying the junction/fk data directly.\n   *\n   * @deprecated Use junction table / foreign key mutations instead.\n   */\n  abstract updateJsonField(params: {\n    recordIds: string[];\n    dbTableName: string;\n    field: string;\n    value: string | number | boolean | null;\n    arrayIndex?: number;\n  }): Knex.QueryBuilder;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.postgres.ts",
    "content": "import type { Knex } from 'knex';\nimport { IntegrityQueryAbstract } from './abstract';\n\nexport class IntegrityQueryPostgres extends IntegrityQueryAbstract {\n  constructor(protected readonly knex: Knex) {\n    super(knex);\n  }\n\n  checkLinks({\n    dbTableName,\n    fkHostTableName,\n    selfKeyName,\n    foreignKeyName,\n    linkDbFieldName,\n    isMultiValue,\n  }: {\n    dbTableName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string {\n    // Multi-value relationships (ManyMany, OneMany)\n    if (isMultiValue) {\n      const fkGroupedQuery = this.knex(fkHostTableName)\n        .select({\n          [selfKeyName]: selfKeyName,\n          fk_ids: this.knex.raw(`string_agg(??, ',' ORDER BY ??)`, [\n            this.knex.ref(foreignKeyName),\n            this.knex.ref(foreignKeyName),\n          ]),\n        })\n        .whereNotNull(selfKeyName)\n        .groupBy(selfKeyName)\n        .as('fk_grouped');\n\n      // Always alias main table as t1 to avoid ambiguous identifiers\n      return this.knex(`${dbTableName} as t1`)\n        .leftJoin(fkGroupedQuery, `t1.__id`, `fk_grouped.${selfKeyName}`)\n        .select({ id: 't1.__id' })\n        .where(function () {\n          this.whereNull(`fk_grouped.${selfKeyName}`)\n            .whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`)\n            .orWhere(function () {\n              // Compare aggregated FK ids with ids from JSON array in link column\n              this.whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`).andWhereRaw(\n                `\"fk_grouped\".fk_ids != (\n                  SELECT string_agg(id, ',' ORDER BY id)\n                  FROM (\n                      SELECT (link->>'id')::text as id\n                      FROM jsonb_array_elements((\"t1\".\"${linkDbFieldName}\")::jsonb) as link\n                  ) t\n                )`\n              );\n            });\n        })\n        .toQuery();\n    }\n\n    // Single-value relationships where FK is in the same table as the link field (ManyOne/OneOne on main table)\n    if (fkHostTableName === dbTableName) {\n      return this.knex(`${dbTableName} as t1`)\n        .select({ id: 't1.__id' })\n        .where(function () {\n          this.whereRaw(`\"t1\".\"${foreignKeyName}\" IS NULL`)\n            .whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`)\n            .orWhere(function () {\n              this.whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`).andWhereRaw(\n                `(\"t1\".\"${linkDbFieldName}\"->>'id')::text != \"t1\".\"${foreignKeyName}\"::text`\n              );\n            });\n        })\n        .toQuery();\n    }\n\n    // Single-value relationships where FK is stored in another host table (e.g., OneOne with FK on the other side)\n    return this.knex(`${dbTableName} as t1`)\n      .select({ id: 't1.__id' })\n      .leftJoin(`${fkHostTableName} as t2`, 't2.' + selfKeyName, 't1.__id')\n      .where(function () {\n        this.whereRaw(`\"t2\".\"${foreignKeyName}\" IS NULL`)\n          .whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`)\n          .orWhere(function () {\n            this.whereRaw(`\"t1\".\"${linkDbFieldName}\" IS NOT NULL`).andWhereRaw(\n              `(\"t1\".\"${linkDbFieldName}\"->>'id')::text != \"t2\".\"${foreignKeyName}\"::text`\n            );\n          });\n      })\n      .toQuery();\n  }\n\n  fixLinks({\n    recordIds,\n    dbTableName,\n    foreignDbTableName,\n    fkHostTableName,\n    lookupDbFieldName,\n    selfKeyName,\n    foreignKeyName,\n    linkDbFieldName,\n    isMultiValue,\n  }: {\n    recordIds: string[];\n    dbTableName: string;\n    foreignDbTableName: string;\n    fkHostTableName: string;\n    lookupDbFieldName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string {\n    if (isMultiValue) {\n      return this.knex(dbTableName)\n        .update({\n          [linkDbFieldName]: this.knex\n            .select(\n              this.knex.raw(\"jsonb_agg(jsonb_build_object('id', ??, 'title', ??) ORDER BY ??)\", [\n                `fk.${foreignKeyName}`,\n                `ft.${lookupDbFieldName}`,\n                `fk.${foreignKeyName}`,\n              ])\n            )\n            .from(`${fkHostTableName} as fk`)\n            .join(`${foreignDbTableName} as ft`, `ft.__id`, `fk.${foreignKeyName}`)\n            .where('fk.' + selfKeyName, `${dbTableName}.__id`),\n        })\n        .whereIn('__id', recordIds)\n        .toQuery();\n    }\n\n    if (fkHostTableName === dbTableName) {\n      // Handle self-referential single-value links\n      return this.knex(dbTableName)\n        .update({\n          [linkDbFieldName]: this.knex.raw(\n            `\n            CASE\n              WHEN ?? IS NULL THEN NULL\n              ELSE jsonb_build_object(\n                'id', ??,\n                'title', (SELECT ?? FROM ?? WHERE __id = ??)\n              )\n            END\n          `,\n            [foreignKeyName, foreignKeyName, lookupDbFieldName, foreignDbTableName, foreignKeyName]\n          ),\n        })\n        .whereIn('__id', recordIds)\n        .toQuery();\n    }\n\n    // Handle cross-table single-value links\n    return this.knex(dbTableName)\n      .update({\n        [linkDbFieldName]: this.knex\n          .select(\n            this.knex.raw(\n              `CASE\n              WHEN t2.?? IS NULL THEN NULL\n              ELSE jsonb_build_object('id', t2.??, 'title', t2.??)\n            END`,\n              [foreignKeyName, foreignKeyName, lookupDbFieldName]\n            )\n          )\n          .from(`${fkHostTableName} as t2`)\n          .where(`t2.${foreignKeyName}`, `${dbTableName}.__id`)\n          .limit(1),\n      })\n      .whereIn('__id', recordIds)\n      .toQuery();\n  }\n\n  /**\n   * Deprecated: Do NOT use in new code.\n   * Link fields typically do not persist a display JSON column in Postgres;\n   * their values are computed from junction tables or fk columns. This method\n   * exists only for legacy tests that used to mutate a JSON display column to\n   * create inconsistencies. Prefer changing junction/fk data directly.\n   *\n   * @deprecated Use junction/fk mutations instead of updating a JSON column.\n   */\n  updateJsonField({\n    recordIds,\n    dbTableName,\n    field,\n    value,\n    arrayIndex,\n  }: {\n    recordIds: string[];\n    dbTableName: string;\n    field: string;\n    value: string | number | boolean | null;\n    arrayIndex?: number;\n  }) {\n    return this.knex(dbTableName)\n      .whereIn('__id', recordIds)\n      .update({\n        [field]: this.knex.raw(`jsonb_set(\n          \"${field}\",\n          '${arrayIndex != null ? `{${arrayIndex},id}` : '{id}'}',\n          '${JSON.stringify(value)}'\n        )`),\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.sqlite.ts",
    "content": "import type { Knex } from 'knex';\nimport { IntegrityQueryAbstract } from './abstract';\n\nexport class IntegrityQuerySqlite extends IntegrityQueryAbstract {\n  constructor(protected readonly knex: Knex) {\n    super(knex);\n  }\n\n  checkLinks({\n    dbTableName,\n    fkHostTableName,\n    selfKeyName,\n    foreignKeyName,\n    linkDbFieldName,\n    isMultiValue,\n  }: {\n    dbTableName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string {\n    const thisKnex = this.knex;\n    if (isMultiValue) {\n      const fkGroupedQuery = this.knex(fkHostTableName)\n        .select({\n          [selfKeyName]: selfKeyName,\n          fk_ids: this.knex.raw(`GROUP_CONCAT(??)`, [this.knex.ref(foreignKeyName)]),\n        })\n        .whereNotNull(selfKeyName)\n        .groupBy(selfKeyName)\n        .as('fk_grouped');\n      return this.knex(dbTableName)\n        .leftJoin(fkGroupedQuery, `${dbTableName}.__id`, `fk_grouped.${selfKeyName}`)\n        .select({\n          id: '__id',\n        })\n        .where(function () {\n          this.whereNull(`fk_grouped.${selfKeyName}`)\n            .whereNotNull(linkDbFieldName)\n            .orWhere(function () {\n              this.whereNotNull(linkDbFieldName).andWhereRaw(\n                `\"fk_grouped\".fk_ids != (\n                  SELECT GROUP_CONCAT(id)\n                  FROM (\n                      SELECT json_extract(link.value, '$.id') as id\n                      FROM json_each(?) as link\n                  ) t\n                )`,\n                [thisKnex.ref(linkDbFieldName)]\n              );\n            });\n        })\n        .toQuery();\n    }\n\n    if (fkHostTableName === dbTableName) {\n      return this.knex(dbTableName)\n        .select({\n          id: '__id',\n        })\n        .where(function () {\n          this.whereNull(foreignKeyName)\n            .whereNotNull(linkDbFieldName)\n            .orWhere(function () {\n              this.whereNotNull(linkDbFieldName).andWhereRaw(\n                `json_extract(??, '$.id') != CAST(${foreignKeyName} AS TEXT)`,\n                [thisKnex.ref(linkDbFieldName)]\n              );\n            });\n        })\n        .toQuery();\n    }\n\n    if (dbTableName === fkHostTableName) {\n      return this.knex(`${dbTableName} as t1`)\n        .select({\n          id: 't1.__id',\n        })\n        .leftJoin(`${dbTableName} as t2`, 't2.' + foreignKeyName, 't1.__id')\n        .where(function () {\n          this.whereNull('t2.' + foreignKeyName)\n            .whereNotNull('t1.' + linkDbFieldName)\n            .orWhere(function () {\n              this.whereNotNull('t1.' + linkDbFieldName).andWhereRaw(\n                `json_extract(t1.\"${linkDbFieldName}\", '$.id') != CAST(t2.\"${foreignKeyName}\" AS TEXT)`\n              );\n            });\n        })\n        .toQuery();\n    }\n\n    return this.knex(`${dbTableName} as t1`)\n      .select({\n        id: 't1.__id',\n      })\n      .leftJoin(`${fkHostTableName} as t2`, 't2.' + selfKeyName, 't1.__id')\n      .where(function () {\n        this.whereNull('t2.' + foreignKeyName)\n          .whereNotNull('t1.' + linkDbFieldName)\n          .orWhere(function () {\n            this.whereNotNull('t1.' + linkDbFieldName).andWhereRaw(\n              `json_extract(t1.\"${linkDbFieldName}\", '$.id') != CAST(t2.\"${foreignKeyName}\" AS TEXT)`\n            );\n          });\n      })\n      .toQuery();\n  }\n\n  fixLinks({\n    recordIds,\n    dbTableName,\n    foreignDbTableName,\n    fkHostTableName,\n    lookupDbFieldName,\n    selfKeyName,\n    foreignKeyName,\n    linkDbFieldName,\n    isMultiValue,\n  }: {\n    recordIds: string[];\n    dbTableName: string;\n    foreignDbTableName: string;\n    fkHostTableName: string;\n    lookupDbFieldName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }): string {\n    if (isMultiValue) {\n      return this.knex(dbTableName)\n        .update({\n          [linkDbFieldName]: this.knex\n            .select(\n              this.knex.raw(\n                `json_group_array(\n                  json_object(\n                    'id', fk.${foreignKeyName},\n                    'title', ft.${lookupDbFieldName}\n                  )\n                )`\n              )\n            )\n            .from(`${fkHostTableName} as fk`)\n            .join(`${foreignDbTableName} as ft`, `ft.__id`, `fk.${foreignKeyName}`)\n            .where('fk.' + selfKeyName, `${dbTableName}.__id`)\n            .orderBy(`fk.${foreignKeyName}`),\n        })\n        .whereIn('__id', recordIds)\n        .toQuery();\n    }\n\n    if (fkHostTableName === dbTableName) {\n      // Handle self-referential single-value links\n      return this.knex(dbTableName)\n        .update({\n          [linkDbFieldName]: this.knex.raw(\n            `\n            CASE\n              WHEN ?? IS NULL THEN NULL\n              ELSE json_object(\n                'id', ??,\n                'title', ??\n              )\n            END\n          `,\n            [foreignKeyName, foreignKeyName, lookupDbFieldName]\n          ),\n        })\n        .whereIn('__id', recordIds)\n        .toQuery();\n    }\n\n    // Handle cross-table single-value links\n    return this.knex(dbTableName)\n      .update({\n        [linkDbFieldName]: this.knex\n          .select(\n            this.knex.raw(\n              `CASE\n                WHEN t2.?? IS NULL THEN NULL\n                ELSE json_object('id', t2.??, 'title', t2.??)\n              END`,\n              [foreignKeyName, foreignKeyName, lookupDbFieldName]\n            )\n          )\n          .from(`${fkHostTableName} as t2`)\n          .where(`t2.${foreignKeyName}`, `${dbTableName}.__id`)\n          .limit(1),\n      })\n      .whereIn('__id', recordIds)\n      .toQuery();\n  }\n\n  /**\n   * Deprecated: Do NOT use in new code.\n   * Link fields' display values are derived; avoid updating a JSON column.\n   * This exists only for legacy tests; prefer mutating junction/fk data.\n   *\n   * @deprecated Use junction/fk mutations instead of updating a JSON column.\n   */\n  updateJsonField({\n    recordIds,\n    dbTableName,\n    field,\n    value,\n    arrayIndex,\n  }: {\n    recordIds: string[];\n    dbTableName: string;\n    field: string;\n    value: string | number | boolean | null;\n    arrayIndex?: number;\n  }) {\n    if (arrayIndex != null) {\n      // For array elements, we need to use json_replace with json_extract\n      return this.knex(dbTableName)\n        .whereIn('__id', recordIds)\n        .update({\n          [field]: this.knex.raw(\n            `\n            json_replace(\n              \"${field}\",\n              '$[' || ? || '].id',\n              json(?))\n          `,\n            [arrayIndex, JSON.stringify(value)]\n          ),\n        });\n    }\n\n    // For single value\n    return this.knex(dbTableName)\n      .whereIn('__id', recordIds)\n      .update({\n        [field]: this.knex.raw(\n          `\n          json_replace(\n            \"${field}\",\n            '$.id',\n            json(?))\n        `,\n          [JSON.stringify(value)]\n        ),\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/postgres.provider.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Logger } from '@nestjs/common';\nimport type {\n  IFilter,\n  ILookupLinkOptionsVo,\n  ISortItem,\n  TableDomain,\n  FieldCore,\n} from '@teable/core';\nimport { DriverClient, parseFormulaToSQL, FieldType } from '@teable/core';\nimport type { PrismaClient } from '@teable/db-main-prisma';\nimport type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../features/field/model/factory';\nimport type { IFieldSelectName } from '../features/record/query-builder/field-select.type';\nimport type {\n  IRecordQueryFilterContext,\n  IRecordQuerySortContext,\n  IRecordQueryGroupContext,\n  IRecordQueryAggregateContext,\n} from '../features/record/query-builder/record-query-builder.interface';\nimport type {\n  IGeneratedColumnQueryInterface,\n  IFormulaConversionContext,\n  IFormulaConversionResult,\n  ISelectQueryInterface,\n  ISelectFormulaConversionContext,\n} from '../features/record/query-builder/sql-conversion.visitor';\nimport {\n  GeneratedColumnSqlConversionVisitor,\n  SelectColumnSqlConversionVisitor,\n} from '../features/record/query-builder/sql-conversion.visitor';\nimport type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';\nimport { AggregationQueryPostgres } from './aggregation-query/postgres/aggregation-query.postgres';\nimport type { BaseQueryAbstract } from './base-query/abstract';\nimport { BaseQueryPostgres } from './base-query/base-query.postgres';\nimport type { ICreateDatabaseColumnContext } from './create-database-column-query/create-database-column-field-visitor.interface';\nimport { CreatePostgresDatabaseColumnFieldVisitor } from './create-database-column-query/create-database-column-field-visitor.postgres';\nimport type {\n  IAggregationQueryExtra,\n  ICalendarDailyCollectionQueryProps,\n  IDbProvider,\n  IFilterQueryExtra,\n  ISortQueryExtra,\n} from './db.provider.interface';\nimport type {\n  IDropDatabaseColumnContext,\n  DropColumnOperationType,\n} from './drop-database-column-query/drop-database-column-field-visitor.interface';\nimport { DropPostgresDatabaseColumnFieldVisitor } from './drop-database-column-query/drop-database-column-field-visitor.postgres';\nimport { DuplicateAttachmentTableQueryPostgres } from './duplicate-table/duplicate-attachment-table-query.postgres';\nimport { DuplicateTableQueryPostgres } from './duplicate-table/duplicate-query.postgres';\nimport type { IFilterQueryInterface } from './filter-query/filter-query.interface';\nimport { FilterQueryPostgres } from './filter-query/postgres/filter-query.postgres';\nimport { GeneratedColumnQueryPostgres } from './generated-column-query/postgres/generated-column-query.postgres';\nimport type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';\nimport { GroupQueryPostgres } from './group-query/group-query.postgres';\nimport type { IntegrityQueryAbstract } from './integrity-query/abstract';\nimport { IntegrityQueryPostgres } from './integrity-query/integrity-query.postgres';\nimport { SearchQueryAbstract } from './search-query/abstract';\nimport { IndexBuilderPostgres } from './search-query/search-index-builder.postgres';\nimport {\n  SearchQueryPostgresBuilder,\n  SearchQueryPostgres,\n} from './search-query/search-query.postgres';\nimport { SelectQueryPostgres } from './select-query/postgres/select-query.postgres';\nimport { SortQueryPostgres } from './sort-query/postgres/sort-query.postgres';\nimport type { ISortQueryInterface } from './sort-query/sort-query.interface';\n\nexport class PostgresProvider implements IDbProvider {\n  private readonly logger = new Logger(PostgresProvider.name);\n  constructor(private readonly knex: Knex) {}\n\n  driver = DriverClient.Pg;\n\n  createSchema(schemaName: string) {\n    return [\n      this.knex.raw(`create schema if not exists ??`, [schemaName]).toQuery(),\n      this.knex.raw(`revoke all on schema ?? from public`, [schemaName]).toQuery(),\n    ];\n  }\n\n  dropSchema(schemaName: string): string {\n    return this.knex.raw(`DROP SCHEMA IF EXISTS ?? CASCADE`, [schemaName]).toQuery();\n  }\n\n  generateDbTableName(baseId: string, name: string) {\n    return `${baseId}.${name}`;\n  }\n\n  getForeignKeysInfo(dbTableName: string) {\n    const [schemaName, tableName] = this.splitTableName(dbTableName);\n    return this.knex\n      .raw(\n        `\n      SELECT tc.constraint_name,\n       kcu.column_name,\n       ccu.table_schema AS referenced_table_schema,\n       ccu.table_name   AS referenced_table_name,\n       ccu.column_name  AS referenced_column_name\nFROM information_schema.table_constraints tc\n         JOIN information_schema.key_column_usage kcu\n              ON tc.constraint_name = kcu.constraint_name\n                  AND tc.table_schema = kcu.table_schema\n         JOIN information_schema.constraint_column_usage ccu\n              ON ccu.constraint_name = tc.constraint_name\n                  AND ccu.table_schema = tc.table_schema\nWHERE tc.constraint_type = 'FOREIGN KEY'\n  AND tc.table_schema = ?\n  AND tc.table_name = ?;\n      `,\n        [schemaName, tableName]\n      )\n      .toQuery();\n  }\n\n  renameTableName(oldTableName: string, newTableName: string) {\n    const nameWithoutSchema = this.splitTableName(newTableName)[1];\n    return [\n      this.knex.raw('ALTER TABLE ?? RENAME TO ??', [oldTableName, nameWithoutSchema]).toQuery(),\n    ];\n  }\n\n  dropTable(tableName: string): string {\n    return this.knex.raw('DROP TABLE IF EXISTS ?? CASCADE', [tableName]).toQuery();\n  }\n\n  async checkColumnExist(\n    tableName: string,\n    columnName: string,\n    prisma: PrismaClient\n  ): Promise<boolean> {\n    const [schemaName, dbTableName] = this.splitTableName(tableName);\n    const sql = this.knex\n      .raw(\n        'SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ?) AS exists',\n        [schemaName, dbTableName, columnName]\n      )\n      .toQuery();\n    const res = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(sql);\n    return res[0].exists;\n  }\n\n  checkTableExist(tableName: string): string {\n    const [schemaName, dbTableName] = this.splitTableName(tableName);\n    return this.knex\n      .raw(\n        'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = ? AND table_name = ?) AS exists',\n        [schemaName, dbTableName]\n      )\n      .toQuery();\n  }\n\n  renameColumn(tableName: string, oldName: string, newName: string): string[] {\n    return this.knex.schema\n      .alterTable(tableName, (table) => {\n        table.renameColumn(oldName, newName);\n      })\n      .toSQL()\n      .map((item) => item.sql);\n  }\n\n  dropColumn(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> },\n    operationType?: DropColumnOperationType\n  ): string[] {\n    const context: IDropDatabaseColumnContext = {\n      tableName,\n      knex: this.knex,\n      linkContext,\n      operationType,\n    };\n\n    // Use visitor pattern to drop columns\n    const visitor = new DropPostgresDatabaseColumnFieldVisitor(context);\n    return fieldInstance.accept(visitor);\n  }\n\n  // postgres drop index with column automatically\n  dropColumnAndIndex(tableName: string, columnName: string, _indexName: string): string[] {\n    // Use CASCADE to automatically drop dependent objects (like generated columns)\n    // This is safe because we handle application-level dependencies separately\n    return [\n      this.knex\n        .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [tableName, columnName])\n        .toQuery(),\n    ];\n  }\n\n  columnInfo(tableName: string): string {\n    const [schemaName, dbTableName] = tableName.split('.');\n    return this.knex\n      .select({\n        name: 'column_name',\n      })\n      .from('information_schema.columns')\n      .where({\n        table_schema: schemaName,\n        table_name: dbTableName,\n      })\n      .toQuery();\n  }\n\n  updateJsonColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string {\n    return this.knex(tableName)\n      .where(this.knex.raw(`\"${columnName}\"->>'id' = ?`, [id]))\n      .update({\n        [columnName]: this.knex.raw(\n          `\n        jsonb_set(\n          \"${columnName}\",\n          '{${key}}',\n          to_jsonb(?::text)\n        )\n      `,\n          [value]\n        ),\n      })\n      .toQuery();\n  }\n\n  updateJsonArrayColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string {\n    return this.knex(tableName)\n      .update({\n        [columnName]: this.knex.raw(\n          `\n          (\n            SELECT jsonb_agg(\n              CASE\n                WHEN elem->>'id' = ?\n                THEN jsonb_set(elem, '{${key}}', to_jsonb(?::text))\n                ELSE elem\n              END\n            )\n            FROM jsonb_array_elements(\"${columnName}\") AS elem\n          )\n        `,\n          [id, value]\n        ),\n      })\n      .toQuery();\n  }\n\n  modifyColumnSchema(\n    tableName: string,\n    oldFieldInstance: IFieldInstance,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> }\n  ): string[] {\n    const queries: string[] = [];\n\n    // First, drop ALL columns associated with the field (including generated columns)\n    queries.push(...this.dropColumn(tableName, oldFieldInstance, linkContext));\n\n    // For Link fields, ensure the host base column exists immediately during modify\n    // to guarantee subsequent update-from-select can persist values. Defer FK/junction\n    // creation to FieldConvertingLinkService (we mark as symmetric here to skip FK creation).\n    if (fieldInstance.type === FieldType.Link && !fieldInstance.isLookup) {\n      const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {\n        const createContext: ICreateDatabaseColumnContext = {\n          table,\n          field: fieldInstance,\n          fieldId: fieldInstance.id,\n          dbFieldName: fieldInstance.dbFieldName,\n          unique: fieldInstance.unique,\n          notNull: fieldInstance.notNull,\n          dbProvider: this,\n          tableDomain,\n          tableId: linkContext?.tableId || '',\n          tableName,\n          knex: this.knex,\n          tableNameMap: linkContext?.tableNameMap || new Map(),\n          // Create base column only; skip FK/junction here\n          isSymmetricField: true,\n          skipBaseColumnCreation: false,\n        };\n        const visitor = new CreatePostgresDatabaseColumnFieldVisitor(createContext);\n        fieldInstance.accept(visitor);\n      });\n      const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);\n      queries.push(...alterTableQueries);\n      return queries;\n    }\n\n    const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {\n      const createContext: ICreateDatabaseColumnContext = {\n        table,\n        field: fieldInstance,\n        fieldId: fieldInstance.id,\n        dbFieldName: fieldInstance.dbFieldName,\n        unique: fieldInstance.unique,\n        notNull: fieldInstance.notNull,\n        dbProvider: this,\n        tableDomain,\n        tableId: linkContext?.tableId || '',\n        tableName,\n        knex: this.knex,\n        tableNameMap: linkContext?.tableNameMap || new Map(),\n      };\n\n      // Use visitor pattern to recreate columns\n      const visitor = new CreatePostgresDatabaseColumnFieldVisitor(createContext);\n      fieldInstance.accept(visitor);\n    });\n\n    const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);\n    queries.push(...alterTableQueries);\n\n    return queries;\n  }\n\n  createColumnSchema(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    isNewTable: boolean,\n    tableId: string,\n    tableNameMap: Map<string, string>,\n    isSymmetricField?: boolean,\n    skipBaseColumnCreation?: boolean\n  ): string[] {\n    let visitor: CreatePostgresDatabaseColumnFieldVisitor | undefined = undefined;\n\n    const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {\n      const context: ICreateDatabaseColumnContext = {\n        table,\n        field: fieldInstance,\n        fieldId: fieldInstance.id,\n        dbFieldName: fieldInstance.dbFieldName,\n        unique: fieldInstance.unique,\n        notNull: fieldInstance.notNull,\n        dbProvider: this,\n        tableDomain,\n        isNewTable,\n        tableId,\n        tableName,\n        knex: this.knex,\n        tableNameMap,\n        isSymmetricField,\n        skipBaseColumnCreation,\n      };\n      visitor = new CreatePostgresDatabaseColumnFieldVisitor(context);\n      fieldInstance.accept(visitor);\n    });\n\n    const mainSqls = alterTableBuilder.toSQL().map((item) => item.sql);\n    const additionalSqls =\n      (visitor as CreatePostgresDatabaseColumnFieldVisitor | undefined)?.getSql() ?? [];\n\n    return [...mainSqls, ...additionalSqls].filter(Boolean);\n  }\n\n  splitTableName(tableName: string): string[] {\n    return tableName.split('.');\n  }\n\n  joinDbTableName(schemaName: string, dbTableName: string) {\n    return `${schemaName}.${dbTableName}`;\n  }\n\n  duplicateTable(\n    fromSchema: string,\n    toSchema: string,\n    tableName: string,\n    withData?: boolean\n  ): string {\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    const [_, dbTableName] = this.splitTableName(tableName);\n    return this.knex\n      .raw(`CREATE TABLE ??.?? AS TABLE ??.?? ${withData ? '' : 'WITH NO DATA'}`, [\n        toSchema,\n        dbTableName,\n        fromSchema,\n        dbTableName,\n      ])\n      .toQuery();\n  }\n\n  alterAutoNumber(tableName: string): string[] {\n    const [schema, dbTableName] = this.splitTableName(tableName);\n    const seqName = `${schema}_${dbTableName}_seq`;\n    return [\n      this.knex.raw(`CREATE SEQUENCE ??`, [seqName]).toQuery(),\n      this.knex\n        .raw(`ALTER TABLE ??.?? ALTER COLUMN __auto_number SET DEFAULT nextval('??')`, [\n          schema,\n          dbTableName,\n          seqName,\n        ])\n        .toQuery(),\n      this.knex\n        .raw(`SELECT setval('??', (SELECT MAX(__auto_number) FROM ??.??))`, [\n          seqName,\n          schema,\n          dbTableName,\n        ])\n        .toQuery(),\n    ];\n  }\n\n  batchInsertSql(tableName: string, insertData: ReadonlyArray<unknown>): string {\n    return this.knex.insert(insertData).into(tableName).toQuery();\n  }\n\n  executeUpdateRecordsSqlList(params: {\n    dbTableName: string;\n    tempTableName: string;\n    idFieldName: string;\n    dbFieldNames: string[];\n    data: { id: string; values: { [key: string]: unknown } }[];\n  }) {\n    const { dbTableName, tempTableName, idFieldName, dbFieldNames, data } = params;\n    const insertRowsData = data.map((item) => {\n      return {\n        [idFieldName]: item.id,\n        ...item.values,\n      };\n    });\n\n    // initialize temporary table data\n    const insertTempTableSql = this.knex.insert(insertRowsData).into(tempTableName).toQuery();\n\n    // update data\n    const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((pre, columnName) => {\n      pre[columnName] = this.knex.ref(`${tempTableName}.${columnName}`);\n      return pre;\n    }, {});\n\n    const updateRecordSql = this.knex(dbTableName)\n      .update(updateColumns)\n      .updateFrom(tempTableName)\n      .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${tempTableName}.${idFieldName}`))\n      .toQuery();\n\n    return { insertTempTableSql, updateRecordSql };\n  }\n\n  updateFromSelectSql(params: {\n    dbTableName: string;\n    idFieldName: string;\n    subQuery: Knex.QueryBuilder;\n    dbFieldNames: string[];\n    returningDbFieldNames?: string[];\n    restrictRecordIds?: string[];\n  }): string {\n    const {\n      dbTableName,\n      idFieldName,\n      subQuery,\n      dbFieldNames,\n      returningDbFieldNames,\n      restrictRecordIds,\n    } = params;\n    const alias = '__s';\n    const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((acc, name) => {\n      acc[name] = this.knex.ref(`${alias}.${name}`);\n      return acc;\n    }, {});\n    // bump version on target table; qualify to avoid ambiguity with FROM subquery columns\n    updateColumns['__version'] = this.knex.raw('?? + 1', [`${dbTableName}.__version`]);\n\n    const returningCols = [idFieldName, '__version', ...(returningDbFieldNames || dbFieldNames)];\n    const qualifiedReturning = returningCols.map((c) => this.knex.ref(`${dbTableName}.${c}`));\n    // also return previous version for ShareDB op version alignment\n    const returningAll = [\n      ...qualifiedReturning,\n      // Unqualified reference to target table column to avoid FROM-clause issues\n      this.knex.raw('?? - 1 as __prev_version', [`${dbTableName}.__version`]),\n    ];\n    const recordIdsAlias = 'record_ids';\n    const recordIds = restrictRecordIds ?? [];\n    const hasRestrictRecordIds = recordIds.length > 0;\n    const normalizedRecordIds = hasRestrictRecordIds\n      ? Array.from(new Set(recordIds.filter((id) => typeof id === 'string' && id.length > 0)))\n      : [];\n    const recordIdsCte =\n      normalizedRecordIds.length > 0\n        ? this.knex.raw(\n            `select * from (values ${normalizedRecordIds.map(() => '(?)').join(', ')}) as ??(??)`,\n            [...normalizedRecordIds, recordIdsAlias, idFieldName]\n          )\n        : undefined;\n    const fromRaw =\n      recordIdsCte != null\n        ? this.knex.raw('(?) as ??, ??', [subQuery, alias, recordIdsAlias])\n        : this.knex.raw('(?) as ??', [subQuery, alias]);\n\n    const builder = this.knex(dbTableName)\n      .update(updateColumns)\n      .updateFrom(fromRaw)\n      .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${alias}.${idFieldName}`));\n\n    if (recordIdsCte) {\n      builder\n        .with(recordIdsAlias, recordIdsCte)\n        .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${recordIdsAlias}.${idFieldName}`));\n    } else if (hasRestrictRecordIds) {\n      builder.whereRaw('1 = 0');\n    }\n\n    const query = builder\n      // Returning is supported on Postgres; qualify to avoid ambiguity with FROM subquery\n      .returning(returningAll as unknown as [])\n      .toQuery();\n    this.logger.debug('updateFromSelectSql: ' + query);\n    return query;\n  }\n\n  lockRecordsSql(params: {\n    dbTableName: string;\n    idFieldName: string;\n    recordIds: string[];\n  }): string | undefined {\n    const { dbTableName, idFieldName, recordIds } = params;\n    const normalized = Array.from(\n      new Set(recordIds.filter((id) => typeof id === 'string' && id.length > 0))\n    );\n    if (!normalized.length) {\n      return undefined;\n    }\n    const ordered = normalized.sort();\n    return this.knex(dbTableName)\n      .select(idFieldName)\n      .whereIn(idFieldName, ordered)\n      .orderBy(idFieldName, 'asc')\n      .forUpdate()\n      .toQuery();\n  }\n\n  aggregationQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    aggregationFields?: IAggregationField[],\n    extra?: IAggregationQueryExtra,\n    context?: IRecordQueryAggregateContext\n  ): IAggregationQueryInterface {\n    return new AggregationQueryPostgres(\n      this.knex,\n      originQueryBuilder,\n      fields,\n      aggregationFields,\n      extra,\n      context\n    );\n  }\n\n  filterQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    filter?: IFilter,\n    extra?: IFilterQueryExtra,\n    context?: IRecordQueryFilterContext\n  ): IFilterQueryInterface {\n    return new FilterQueryPostgres(originQueryBuilder, fields, filter, extra, this, context);\n  }\n\n  sortQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    sortObjs?: ISortItem[],\n    extra?: ISortQueryExtra,\n    context?: IRecordQuerySortContext\n  ): ISortQueryInterface {\n    return new SortQueryPostgres(this.knex, originQueryBuilder, fields, sortObjs, extra, context);\n  }\n\n  groupQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fieldMap?: { [fieldId: string]: FieldCore },\n    groupFieldIds?: string[],\n    extra?: IGroupQueryExtra,\n    context?: IRecordQueryGroupContext\n  ): IGroupQueryInterface {\n    return new GroupQueryPostgres(\n      this.knex,\n      originQueryBuilder,\n      fieldMap,\n      groupFieldIds,\n      extra,\n      context\n    );\n  }\n\n  searchQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchFields: IFieldInstance[],\n    tableIndex: TableIndex[],\n    search: [string, string?, boolean?],\n    context?: IRecordQueryFilterContext\n  ) {\n    return SearchQueryAbstract.appendQueryBuilder(\n      SearchQueryPostgres,\n      originQueryBuilder,\n      searchFields,\n      tableIndex,\n      search,\n      context\n    );\n  }\n\n  searchCountQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchField: IFieldInstance[],\n    search: [string, string?, boolean?],\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext\n  ) {\n    return SearchQueryAbstract.buildSearchCountQuery(\n      SearchQueryPostgres,\n      originQueryBuilder,\n      searchField,\n      search,\n      tableIndex,\n      context\n    );\n  }\n\n  searchIndexQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbTableName: string,\n    searchField: IFieldInstance[],\n    searchIndexRo: ISearchIndexByQueryRo,\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext,\n    baseSortIndex?: string,\n    setFilterQuery?: (qb: Knex.QueryBuilder) => void,\n    setSortQuery?: (qb: Knex.QueryBuilder) => void\n  ) {\n    return new SearchQueryPostgresBuilder(\n      originQueryBuilder,\n      dbTableName,\n      searchField,\n      searchIndexRo,\n      tableIndex,\n      context,\n      baseSortIndex,\n      setFilterQuery,\n      setSortQuery\n    ).getSearchIndexQuery();\n  }\n\n  searchIndex() {\n    return new IndexBuilderPostgres();\n  }\n\n  duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {\n    return new DuplicateTableQueryPostgres(queryBuilder);\n  }\n\n  duplicateAttachmentTableQuery(queryBuilder: Knex.QueryBuilder) {\n    return new DuplicateAttachmentTableQueryPostgres(queryBuilder);\n  }\n\n  shareFilterCollaboratorsQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    isMultipleCellValue?: boolean\n  ) {\n    if (isMultipleCellValue) {\n      originQueryBuilder.distinct(\n        this.knex.raw(`jsonb_array_elements(\"${dbFieldName}\")->>'id' AS user_id`)\n      );\n    } else {\n      originQueryBuilder.distinct(\n        this.knex.raw(`jsonb_extract_path_text(\"${dbFieldName}\", 'id') AS user_id`)\n      );\n    }\n  }\n\n  baseQuery(): BaseQueryAbstract {\n    return new BaseQueryPostgres(this.knex);\n  }\n\n  integrityQuery(): IntegrityQueryAbstract {\n    return new IntegrityQueryPostgres(this.knex);\n  }\n\n  calendarDailyCollectionQuery(\n    qb: Knex.QueryBuilder,\n    props: ICalendarDailyCollectionQueryProps\n  ): Knex.QueryBuilder {\n    const { startDate, endDate, startField, endField, dbTableName } = props;\n    const timezone = startField.options.formatting.timeZone;\n\n    return qb\n      .select([\n        this.knex.raw('dates.date'),\n        this.knex.raw('COUNT(*) as count'),\n        this.knex.raw(`(array_agg(?? ORDER BY ??.??))[1:10] as ids`, [\n          '__id',\n          dbTableName,\n          startField.dbFieldName,\n        ]),\n      ])\n      .crossJoin(\n        this.knex.raw(\n          `(SELECT date::date as date\n    FROM generate_series(\n      (?::timestamptz AT TIME ZONE ?)::date,\n      (?::timestamptz AT TIME ZONE ?)::date,\n      '1 day'::interval\n    ) AS date) as dates`,\n          [startDate, timezone, endDate, timezone]\n        )\n      )\n      .where((builder) => {\n        builder\n          .whereRaw(\n            `(??.??::timestamptz AT TIME ZONE ?)::date <= (?::timestamptz AT TIME ZONE ?)::date`,\n            [dbTableName, startField.dbFieldName, timezone, endDate, timezone]\n          )\n          .andWhereRaw(\n            `(COALESCE(??.??::timestamptz, ??.??)::timestamptz AT TIME ZONE ?)::date >= (?::timestamptz AT TIME ZONE ?)::date`,\n            [\n              dbTableName,\n              endField.dbFieldName,\n              dbTableName,\n              startField.dbFieldName,\n              timezone,\n              startDate,\n              timezone,\n            ]\n          )\n          .andWhere((subBuilder) => {\n            subBuilder\n              .whereRaw(`(??.??::timestamptz AT TIME ZONE ?)::date <= dates.date`, [\n                dbTableName,\n                startField.dbFieldName,\n                timezone,\n              ])\n              .andWhereRaw(\n                `(COALESCE(??.??::timestamptz, ??.??)::timestamptz AT TIME ZONE ?)::date >= dates.date`,\n                [dbTableName, endField.dbFieldName, dbTableName, startField.dbFieldName, timezone]\n              );\n          });\n      })\n      .groupBy('dates.date')\n      .orderBy('dates.date', 'asc');\n  }\n\n  // select id and lookup_options for \"field\" table options is a json saved in string format, match optionsKey and value\n  // please use json method in postgres\n  lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string {\n    return this.knex('field')\n      .select({\n        tableId: 'table_id',\n        id: 'id',\n        type: 'type',\n        name: 'name',\n        lookupOptions: 'lookup_options',\n      })\n      .whereNull('deleted_time')\n      .whereRaw(`lookup_options::json->>'${optionsKey}' = ?`, [value])\n      .toQuery();\n  }\n\n  optionsQuery(type: FieldType, optionsKey: string, value: string): string {\n    return this.knex('field')\n      .select({\n        tableId: 'table_id',\n        id: 'id',\n        name: 'name',\n        description: 'description',\n        notNull: 'not_null',\n        unique: 'unique',\n        isPrimary: 'is_primary',\n        dbFieldName: 'db_field_name',\n        isComputed: 'is_computed',\n        isPending: 'is_pending',\n        hasError: 'has_error',\n        dbFieldType: 'db_field_type',\n        isMultipleCellValue: 'is_multiple_cell_value',\n        isLookup: 'is_lookup',\n        lookupOptions: 'lookup_options',\n        type: 'type',\n        options: 'options',\n        cellValueType: 'cell_value_type',\n      })\n      .whereNull('deleted_time')\n      .whereNull('is_lookup')\n      .whereRaw(`options::json->>'${optionsKey}' = ?`, [value])\n      .where('type', type)\n      .toQuery();\n  }\n\n  searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {\n    return qb.where((builder) => {\n      search.forEach(([field, value]) => {\n        builder.orWhere(field, 'ilike', `%${value}%`);\n      });\n    });\n  }\n\n  getTableIndexes(dbTableName: string): string {\n    const [, tableName] = this.splitTableName(dbTableName);\n    return this.knex\n      .raw(\n        `\n        SELECT\n    i.relname AS name,\n    ix.indisunique AS \"isUnique\",\n    CAST(jsonb_agg(a.attname ORDER BY u.attposition) AS TEXT) AS columns\nFROM\n    pg_class t,\n    pg_class i,\n    pg_index ix,\n    pg_attribute a,\n    unnest(ix.indkey) WITH ORDINALITY u(attnum, attposition)\nWHERE\n    t.oid = ix.indrelid\n    AND i.oid = ix.indexrelid\n    AND a.attrelid = t.oid\n    AND a.attnum = u.attnum\n    AND t.relname = ?\nGROUP BY\n    i.relname,\n    ix.indisunique,\n    ix.indisprimary\nORDER BY\n    i.relname;\n      `,\n        [tableName]\n      )\n      .toQuery();\n  }\n\n  generatedColumnQuery(): IGeneratedColumnQueryInterface {\n    return new GeneratedColumnQueryPostgres();\n  }\n\n  convertFormulaToGeneratedColumn(\n    expression: string,\n    context: IFormulaConversionContext\n  ): IFormulaConversionResult {\n    try {\n      const generatedColumnQuery = this.generatedColumnQuery();\n      // Set the context with driver client information\n      const contextWithDriver = { ...context, driverClient: this.driver };\n      generatedColumnQuery.setContext(contextWithDriver);\n\n      const visitor = new GeneratedColumnSqlConversionVisitor(\n        this.knex,\n        generatedColumnQuery,\n        contextWithDriver\n      );\n\n      const sql = parseFormulaToSQL(expression, visitor);\n\n      return visitor.getResult(sql);\n    } catch (error) {\n      throw new Error(`Failed to convert formula: ${(error as Error).message}`);\n    }\n  }\n\n  selectQuery(): ISelectQueryInterface {\n    return new SelectQueryPostgres();\n  }\n\n  convertFormulaToSelectQuery(\n    expression: string,\n    context: ISelectFormulaConversionContext\n  ): IFieldSelectName {\n    try {\n      const selectQuery = this.selectQuery();\n\n      // Set the context with driver client information\n      const contextWithDriver = { ...context, driverClient: this.driver };\n      selectQuery.setContext(contextWithDriver);\n\n      const visitor = new SelectColumnSqlConversionVisitor(\n        this.knex,\n        selectQuery,\n        contextWithDriver\n      );\n\n      return parseFormulaToSQL(expression, visitor);\n    } catch (error) {\n      throw new Error(`Failed to convert formula: ${(error as Error).message}`);\n    }\n  }\n\n  generateDatabaseViewName(tableId: string): string {\n    return tableId + '_view';\n  }\n\n  createDatabaseView(\n    table: TableDomain,\n    qb: Knex.QueryBuilder,\n    options?: { materialized?: boolean }\n  ): string[] {\n    const viewName = this.generateDatabaseViewName(table.id);\n    if (options?.materialized) {\n      // Create MV and add unique index on __id to support concurrent refresh\n      const createMv = this.knex\n        .raw(`CREATE MATERIALIZED VIEW ?? AS ${qb.toQuery()}`, [viewName])\n        .toQuery();\n      const createIndex = `CREATE UNIQUE INDEX IF NOT EXISTS ${viewName}__id_uidx ON \"${viewName}\" (\"__id\")`;\n      return [createMv, createIndex];\n    }\n    return [this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery()];\n  }\n\n  recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {\n    const oldName = this.generateDatabaseViewName(table.id);\n    const newName = `${oldName}_new`;\n    const stmts: string[] = [];\n    // Clean temp and conflicting indexes\n    stmts.push(`DROP INDEX IF EXISTS \"${newName}__id_uidx\"`);\n    stmts.push(`DROP INDEX IF EXISTS \"${oldName}__id_uidx\"`);\n    stmts.push(`DROP MATERIALIZED VIEW IF EXISTS \"${newName}\"`);\n    // Create empty MV and index, then initial non-concurrent populate\n    stmts.push(`CREATE MATERIALIZED VIEW \"${newName}\" AS ${qb.toQuery()} WITH NO DATA`);\n    stmts.push(`CREATE UNIQUE INDEX \"${newName}__id_uidx\" ON \"${newName}\" (\"__id\")`);\n    stmts.push(`REFRESH MATERIALIZED VIEW \"${newName}\"`);\n    // Swap\n    stmts.push(`DROP MATERIALIZED VIEW IF EXISTS \"${oldName}\"`);\n    stmts.push(`ALTER MATERIALIZED VIEW \"${newName}\" RENAME TO \"${oldName}\"`);\n    // Keep index name stable after swap\n    stmts.push(`ALTER INDEX \"${newName}__id_uidx\" RENAME TO \"${oldName}__id_uidx\"`);\n    // Ensure final MV has data (defensive refresh)\n    stmts.push(`REFRESH MATERIALIZED VIEW \"${oldName}\"`);\n    return stmts;\n  }\n\n  dropDatabaseView(tableId: string): string[] {\n    const viewName = this.generateDatabaseViewName(tableId);\n    // Try dropping both MV and normal VIEW to be safe\n    return [\n      this.knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ??`, [viewName]).toQuery(),\n      this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery(),\n    ];\n  }\n\n  refreshDatabaseView(tableId: string, options?: { concurrently?: boolean }): string {\n    const viewName = this.generateDatabaseViewName(tableId);\n    this.logger.debug(\n      'refreshDatabaseView %s with concurrently %s',\n      viewName,\n      options?.concurrently\n    );\n    const concurrently = options?.concurrently ?? true;\n    if (concurrently) {\n      return `REFRESH MATERIALIZED VIEW CONCURRENTLY \"${viewName}\"`;\n    }\n    return `REFRESH MATERIALIZED VIEW \"${viewName}\"`;\n  }\n\n  createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string {\n    const viewName = this.generateDatabaseViewName(table.id);\n    return this.knex.raw(`CREATE MATERIALIZED VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery();\n  }\n\n  dropMaterializedView(tableId: string): string {\n    const viewName = this.generateDatabaseViewName(tableId);\n    return this.knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ??`, [viewName]).toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/abstract.ts",
    "content": "import type { TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { ISearchQueryConstructor } from './types';\n\nexport abstract class SearchQueryAbstract {\n  static appendQueryBuilder(\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    SearchQuery: ISearchQueryConstructor,\n    originQueryBuilder: Knex.QueryBuilder,\n    searchFields: IFieldInstance[],\n    tableIndex: TableIndex[],\n    search: [string, string?, boolean?],\n    context?: IRecordQueryFilterContext\n  ) {\n    if (!search || !searchFields?.length) {\n      return originQueryBuilder;\n    }\n\n    searchFields.forEach((fIns) => {\n      const builder = new SearchQuery(originQueryBuilder, fIns, search, tableIndex, context);\n      builder.appendBuilder();\n    });\n\n    return originQueryBuilder;\n  }\n\n  static buildSearchCountQuery(\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    SearchQuery: ISearchQueryConstructor,\n    queryBuilder: Knex.QueryBuilder,\n    searchField: IFieldInstance[],\n    search: [string, string?, boolean?],\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext\n  ) {\n    const knexInstance = queryBuilder.client;\n\n    const conditions = searchField\n      .map((field) => {\n        const searchQueryBuilder = new SearchQuery(\n          queryBuilder,\n          field,\n          search,\n          tableIndex,\n          context\n        );\n        return searchQueryBuilder.getQuery();\n      })\n      .filter((cond): cond is Knex.Raw => Boolean(cond));\n\n    if (conditions.length === 0) {\n      queryBuilder.select(knexInstance.raw('0 as count'));\n      return queryBuilder;\n    }\n\n    const parts = conditions.map((cond) =>\n      knexInstance.raw('(CASE WHEN (?) THEN 1 ELSE 0 END)', [cond])\n    );\n\n    // Use nested raws to preserve bindings and avoid inlining values into SQL text.\n    queryBuilder.select(\n      knexInstance.raw(`COALESCE(SUM(${parts.map(() => '(?)').join(' + ')}), 0) as count`, parts)\n    );\n\n    return queryBuilder;\n  }\n\n  protected readonly fieldName: string;\n\n  constructor(\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly field: IFieldInstance,\n    protected readonly search: [string, string?, boolean?],\n    protected readonly tableIndex: TableIndex[],\n    protected readonly context?: IRecordQueryFilterContext\n  ) {\n    const { dbFieldName, id } = field;\n\n    const selection = context?.selectionMap.get(id);\n    if (selection !== undefined && selection !== null) {\n      this.fieldName = this.normalizeSelection(selection) ?? this.quoteIdentifier(dbFieldName);\n    } else {\n      this.fieldName = this.quoteIdentifier(dbFieldName);\n    }\n  }\n\n  protected abstract json(): Knex.Raw;\n\n  protected abstract text(): Knex.Raw;\n\n  protected abstract date(): Knex.Raw;\n\n  protected abstract number(): Knex.Raw;\n\n  protected abstract multipleNumber(): Knex.Raw;\n\n  protected abstract multipleDate(): Knex.Raw;\n\n  protected abstract multipleText(): Knex.Raw;\n\n  protected abstract multipleJson(): Knex.Raw;\n\n  abstract getSql(): string | null;\n\n  abstract getQuery(): Knex.Raw | null;\n\n  abstract appendBuilder(): Knex.QueryBuilder;\n\n  private normalizeSelection(selection: unknown): string | undefined {\n    if (typeof selection === 'string') {\n      return selection;\n    }\n    if (selection && typeof (selection as Knex.Raw).toQuery === 'function') {\n      return (selection as Knex.Raw).toQuery();\n    }\n    if (selection && typeof (selection as Knex.Raw).toSQL === 'function') {\n      const { sql } = (selection as Knex.Raw).toSQL();\n      if (sql) {\n        return sql;\n      }\n    }\n    return undefined;\n  }\n\n  private quoteIdentifier(identifier: string): string {\n    if (!identifier) {\n      return identifier;\n    }\n    if (identifier.startsWith('\"') && identifier.endsWith('\"')) {\n      return identifier;\n    }\n    const escaped = identifier.replace(/\"/g, '\"\"');\n    return `\"${escaped}\"`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/get-offset.ts",
    "content": "import dayjs from 'dayjs';\nimport 'dayjs/plugin/utc';\n\nexport function getOffset(timeZone: string) {\n  const offsetMinutes = dayjs().tz(timeZone).utcOffset();\n\n  const offsetHours = offsetMinutes / 60;\n  return offsetHours >= 0 ? `+${offsetHours}` : `${offsetHours}`;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts",
    "content": "/* eslint-disable regexp/no-unused-capturing-group */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { assertNever, CellValueType, FieldType } from '@teable/core';\nimport type { IFieldInstance } from '../../features/field/model/factory';\n\nimport { IndexBuilderAbstract } from '../index-query/index-abstract-builder';\n\ninterface IPgIndex {\n  schemaname: string;\n  tablename: string;\n  indexname: string;\n  tablespace: string;\n  indexdef: string;\n}\n\nconst unSupportCellValueType = [CellValueType.DateTime, CellValueType.Boolean];\n\nexport class FieldFormatter {\n  static getSearchableExpression(field: IFieldInstance, isArray = false): string | null {\n    const { cellValueType, dbFieldName, options, isStructuredCellValue } = field;\n\n    // base expression\n    const baseExpression = (() => {\n      switch (cellValueType) {\n        case CellValueType.Number: {\n          const precision =\n            (options as { formatting?: { precision?: number } })?.formatting?.precision ?? 0;\n          return `ROUND(value::numeric, ${precision})::text`;\n        }\n        case CellValueType.DateTime: {\n          // date type not support full text search\n          return null;\n        }\n        case CellValueType.Boolean: {\n          // date type not support full text search\n          return null;\n        }\n        case CellValueType.String: {\n          if (isStructuredCellValue) {\n            return `\"${dbFieldName}\"::jsonb #>> '{title}'`;\n          }\n          if (field.type === FieldType.LongText) {\n            // chr(13) is carriage return, chr(10) is line feed, chr(9) is tab\n            return `REPLACE(REPLACE(REPLACE(value, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text)`;\n          } else {\n            return `value`;\n          }\n        }\n        default:\n          assertNever(cellValueType);\n      }\n    })();\n\n    if (baseExpression === null) {\n      return null;\n    }\n\n    // handle array type\n    // gin cannot handle any sub-query, so we need to use array_to_string to convert array to stringZ\n    if (isArray) {\n      return `\"${dbFieldName}\"::text`;\n    }\n\n    // handle single value type\n    return baseExpression.replace(/value/g, `\"${dbFieldName}\"`);\n  }\n\n  // expression for generating index\n  static getIndexExpression(field: IFieldInstance): string | null {\n    return this.getSearchableExpression(field, field.isMultipleCellValue);\n  }\n}\n\nexport class IndexBuilderPostgres extends IndexBuilderAbstract {\n  static PG_MAX_INDEX_LEN = 63;\n  static DELIMITER_LEN = 3;\n\n  private getIndexPrefix() {\n    return `idx_trgm`;\n  }\n\n  private getIndexName(table: string, field: Pick<IFieldInstance, 'id' | 'dbFieldName'>): string {\n    const { dbFieldName, id } = field;\n    const prefix = this.getIndexPrefix();\n    const maxTableDbNameLen =\n      IndexBuilderPostgres.PG_MAX_INDEX_LEN -\n      id.length -\n      this.getIndexPrefix().length -\n      IndexBuilderPostgres.DELIMITER_LEN;\n    const tableDbNameLen = maxTableDbNameLen < table.length ? maxTableDbNameLen : table.length;\n    // 3 is space character\n    const dbFieldNameLen =\n      maxTableDbNameLen < table.length\n        ? 0\n        : IndexBuilderPostgres.PG_MAX_INDEX_LEN -\n          id.length -\n          this.getIndexPrefix().length -\n          tableDbNameLen -\n          IndexBuilderPostgres.DELIMITER_LEN;\n    const abbDbFieldName = dbFieldName.slice(0, dbFieldNameLen);\n    return `${prefix}_${table.slice(0, tableDbNameLen)}_${abbDbFieldName}_${id}`;\n  }\n\n  private getSearchFactor() {\n    return this.getIndexPrefix();\n  }\n\n  createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null {\n    const [schema, table] = dbTableName.split('.');\n    const indexName = this.getIndexName(table, field);\n    const expression = FieldFormatter.getIndexExpression(field);\n    if (expression === null) {\n      return null;\n    }\n\n    return `CREATE INDEX IF NOT EXISTS \"${indexName}\" ON \"${schema}\".\"${table}\" USING gin ((${expression}) gin_trgm_ops)`;\n  }\n\n  getDropIndexSql(dbTableName: string): string {\n    const [schema, table] = dbTableName.split('.');\n    const searchFactor = this.getSearchFactor();\n    return `\n      DO $$ \n      DECLARE \n        _index record;\n      BEGIN \n        FOR _index IN \n          SELECT indexname \n          FROM pg_indexes \n          WHERE schemaname = '${schema}' \n          AND tablename = '${table}'\n          AND indexname LIKE '${searchFactor}%'\n        LOOP\n          EXECUTE 'DROP INDEX IF EXISTS \"' || '${schema}' || '\".\"' || _index.indexname || '\"';\n        END LOOP;\n      END $$;\n    `;\n  }\n\n  getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[] {\n    const fieldSql = searchFields\n      .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))\n      .map((field) => {\n        const expression = FieldFormatter.getIndexExpression(field);\n        return expression ? this.createSingleIndexSql(dbTableName, field) : null;\n      })\n      .filter((sql): sql is string => sql !== null);\n\n    fieldSql.unshift(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`);\n    return fieldSql;\n  }\n\n  getExistTableIndexSql(dbTableName: string): string {\n    const [schema, table] = dbTableName.split('.');\n    const searchFactor = this.getSearchFactor();\n    return `\n      SELECT EXISTS (\n        SELECT 1\n        FROM pg_indexes\n        WHERE schemaname = '${schema}'\n        AND tablename = '${table}'\n        AND indexname LIKE '${searchFactor}%'\n      )`;\n  }\n\n  getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string {\n    const [schema, table] = dbTableName.split('.');\n    const indexName = this.getIndexName(table, field);\n\n    return `DROP INDEX IF EXISTS \"${schema}\".\"${indexName}\"`;\n  }\n\n  getUpdateSingleIndexNameSql(\n    dbTableName: string,\n    oldField: Pick<IFieldInstance, 'id' | 'dbFieldName'>,\n    newField: Pick<IFieldInstance, 'id' | 'dbFieldName'>\n  ): string {\n    const [schema, table] = dbTableName.split('.');\n    const oldIndexName = this.getIndexName(table, oldField);\n    const newIndexName = this.getIndexName(table, newField);\n\n    return `\n      ALTER INDEX IF EXISTS \"${schema}\".\"${oldIndexName}\"\n      RENAME TO \"${newIndexName}\"\n    `;\n  }\n\n  getIndexInfoSql(dbTableName: string): string {\n    const [, table] = dbTableName.split('.');\n    const searchFactor = this.getSearchFactor();\n    return `\n      SELECT * FROM pg_indexes \n      WHERE tablename = '${table}'\n      AND indexname like '${searchFactor}%'`;\n  }\n\n  getAbnormalIndex(dbTableName: string, fields: IFieldInstance[], existingIndex: IPgIndex[]) {\n    const [, table] = dbTableName.split('.');\n    const expectExistIndex = fields\n      .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))\n      .map((field) => {\n        return this.getIndexName(table, field);\n      });\n\n    // 1: find the lack or redundant index\n    const lackingIndex = expectExistIndex.filter(\n      (idxName) => !existingIndex.map((idx) => idx.indexname).includes(idxName)\n    );\n    const redundantIndex = existingIndex\n      .map((idx) => idx.indexname)\n      .filter((idxName) => !expectExistIndex.includes(idxName));\n\n    const diffIndex = [...new Set([...redundantIndex, ...lackingIndex])];\n\n    if (diffIndex.length) {\n      return diffIndex.map((idxName) => ({ indexName: idxName }));\n    }\n\n    // 2: find the abnormal index definition\n    const expectIndexDef = fields\n      .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))\n      .map((f) => {\n        return {\n          indexName: this.getIndexName(table, f),\n          indexDef: this.createSingleIndexSql(dbTableName, f) as string,\n        };\n      });\n\n    return expectIndexDef\n      .filter(({ indexDef }) => {\n        const existIndex = existingIndex.map((idx) =>\n          idx.indexdef\n            .toLowerCase()\n            .replace(/[()\\s\"']/g, '')\n            .replace(/::(jsonb|text\\[\\]|text)/g, '')\n        );\n        return !existIndex.includes(\n          indexDef\n            .toLowerCase()\n            .replace(/[()\\s\"']/g, '')\n            .replace(/::(jsonb|text\\[\\]|text)/g, '')\n            .replace(/ifnotexists/g, '')\n        );\n      })\n      .map(({ indexName }) => ({\n        indexName,\n      }));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/search-index-builder.sqlite.ts",
    "content": "/* eslint-disable @typescript-eslint/no-unused-vars */\nimport { CellValueType } from '@teable/core';\nimport type { IGetAbnormalVo } from '@teable/openapi';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport { IndexBuilderAbstract } from '../index-query/index-abstract-builder';\nimport type { ISearchCellValueType } from './types';\n\ntype ISqliteIndex = Record<string, unknown>;\n\nexport class FieldFormatter {\n  static getSearchableExpression(field: IFieldInstance, isArray = false): string {\n    const { cellValueType, dbFieldName, options, isStructuredCellValue } = field;\n\n    // base expression\n    const baseExpression = (() => {\n      switch (cellValueType as ISearchCellValueType) {\n        case CellValueType.Number: {\n          const precision =\n            (options as { formatting?: { precision?: number } })?.formatting?.precision ?? 0;\n          return `ROUND(CAST(value AS REAL), ${precision})`;\n        }\n        case CellValueType.DateTime: {\n          // SQLite doesn't support timezone conversion directly\n          // We'll format the date in a basic format\n          return `strftime('%Y-%m-%d %H:%M', value)`;\n        }\n        case CellValueType.String: {\n          if (isStructuredCellValue) {\n            return `json_extract(value, '$.title')`;\n          }\n          return 'CAST(value AS TEXT)';\n        }\n        default:\n          return 'CAST(value AS TEXT)';\n      }\n    })();\n\n    // handle array type\n    if (isArray) {\n      return `(\n        WITH RECURSIVE split(word, str) AS (\n          SELECT '', json_extract(${dbFieldName}, '$') || ','\n          UNION ALL\n          SELECT\n            substr(str, 0, instr(str, ',')),\n            substr(str, instr(str, ',') + 1)\n          FROM split WHERE str != ''\n        )\n        SELECT group_concat(${baseExpression.replace(/value/g, 'word')}, ', ')\n        FROM split WHERE word != ''\n      )`;\n    }\n\n    // handle single value type\n    return baseExpression.replace(/value/g, dbFieldName);\n  }\n\n  // expression for generating index\n  static getIndexExpression(field: IFieldInstance): string {\n    return this.getSearchableExpression(field, field.isMultipleCellValue);\n  }\n}\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst NO_OPERATION_SQL = '/* no operation */';\n\nexport class IndexBuilderSqlite extends IndexBuilderAbstract {\n  private getIndexName(table: string, dbFieldName: string): string {\n    return `idx_trgm_${table}_${dbFieldName}`;\n  }\n\n  createSingleIndexSql(dbTableName: string, field: IFieldInstance): string {\n    return NO_OPERATION_SQL;\n  }\n\n  getDropIndexSql(dbTableName: string): string {\n    return `SELECT 'DROP TABLE IF EXISTS \"' || name || '\";'\n      FROM sqlite_master \n      WHERE type='table' \n      AND name LIKE 'idx_fts_${dbTableName}_%'`;\n  }\n\n  getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[] {\n    return searchFields.map((field) => this.createSingleIndexSql(dbTableName, field));\n  }\n\n  getExistTableIndexSql(dbTableName: string): string {\n    return `SELECT EXISTS (\n      SELECT 1 \n      FROM sqlite_master \n      WHERE type='table' \n      AND name LIKE 'idx_fts_${dbTableName}_%'\n    )`;\n  }\n\n  getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string {\n    return NO_OPERATION_SQL;\n  }\n\n  getUpdateSingleIndexNameSql(\n    dbTableName: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance\n  ): string {\n    return NO_OPERATION_SQL;\n  }\n\n  getIndexInfoSql(dbTableName: string): string {\n    return NO_OPERATION_SQL;\n  }\n\n  getAbnormalIndex(dbTableName: string, fields: IFieldInstance[], existingIndex: ISqliteIndex[]) {\n    return [] as IGetAbnormalVo;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts",
    "content": "import type { IDateFieldOptions } from '@teable/core';\nimport { CellValueType, FieldType } from '@teable/core';\nimport type { ISearchIndexByQueryRo } from '@teable/openapi';\nimport { TableIndex } from '@teable/openapi';\nimport { type Knex } from 'knex';\nimport { get } from 'lodash';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport { escapePostgresRegex } from '../../utils/postgres-regex-escape';\nimport { escapeLikeWildcards } from '../../utils/sql-like-escape';\nimport { SearchQueryAbstract } from './abstract';\nimport { FieldFormatter } from './search-index-builder.postgres';\nimport type { ISearchCellValueType } from './types';\n\nexport class SearchQueryPostgres extends SearchQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(\n    protected originQueryBuilder: Knex.QueryBuilder,\n    protected field: IFieldInstance,\n    protected search: [string, string?, boolean?],\n    protected tableIndex: TableIndex[],\n    protected context?: IRecordQueryFilterContext\n  ) {\n    super(originQueryBuilder, field, search, tableIndex, context);\n    this.knex = originQueryBuilder.client;\n  }\n\n  appendBuilder() {\n    const { originQueryBuilder } = this;\n    const condition = this.getQuery();\n    condition && this.originQueryBuilder.orWhereRaw(condition);\n    return originQueryBuilder;\n  }\n\n  getSql(): string | null {\n    const condition = this.getQuery();\n    return condition ? condition.toSQL().sql : null;\n  }\n\n  getQuery() {\n    const { field, tableIndex } = this;\n    const { isMultipleCellValue } = field;\n\n    if (tableIndex.includes(TableIndex.search)) {\n      return this.getSearchQueryWithIndex();\n    } else {\n      return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();\n    }\n  }\n\n  protected getSearchQueryWithIndex() {\n    const { search, knex, field } = this;\n    const { isMultipleCellValue } = field;\n    const isSearchAllFields = !search[1];\n    if (isSearchAllFields) {\n      const searchValue = search[0];\n      const escapedSearchValue = escapeLikeWildcards(searchValue);\n      const expression = FieldFormatter.getSearchableExpression(field, isMultipleCellValue);\n      return expression\n        ? knex.raw(`(${expression}) ILIKE ? ESCAPE '\\\\'`, [`%${escapedSearchValue}%`])\n        : null;\n    } else {\n      return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();\n    }\n  }\n\n  protected getSingleCellTypeQuery() {\n    const { field } = this;\n    const { isStructuredCellValue, cellValueType } = field;\n    switch (cellValueType as ISearchCellValueType) {\n      case CellValueType.String: {\n        if (isStructuredCellValue) {\n          return this.json();\n        } else {\n          return this.text();\n        }\n      }\n      case CellValueType.DateTime: {\n        return this.date();\n      }\n      case CellValueType.Number: {\n        return this.number();\n      }\n      default:\n        return this.text();\n    }\n  }\n\n  protected getMultipleCellTypeQuery() {\n    const { field } = this;\n    const { isStructuredCellValue, cellValueType } = field;\n    switch (cellValueType as ISearchCellValueType) {\n      case CellValueType.String: {\n        if (isStructuredCellValue) {\n          return this.multipleJson();\n        } else {\n          return this.multipleText();\n        }\n      }\n      case CellValueType.DateTime: {\n        return this.multipleDate();\n      }\n      case CellValueType.Number: {\n        return this.multipleNumber();\n      }\n      default:\n        return this.multipleText();\n    }\n  }\n\n  protected text() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n\n    if (this.field.type === FieldType.LongText) {\n      return knex.raw(\n        // chr(13) is carriage return, chr(10) is line feed, chr(9) is tab\n        `REPLACE(REPLACE(REPLACE(${this.fieldName}, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text) ILIKE ? ESCAPE '\\\\'`,\n        [`%${escapedSearchValue}%`]\n      );\n    } else {\n      return knex.raw(`${this.fieldName} ILIKE ? ESCAPE '\\\\'`, [`%${escapedSearchValue}%`]);\n    }\n  }\n\n  protected number() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;\n    return knex.raw(`ROUND(${this.fieldName}::numeric, ?::int)::text ILIKE ? ESCAPE '\\\\'`, [\n      precision,\n      `%${escapedSearchValue}%`,\n    ]);\n  }\n\n  protected date() {\n    const {\n      search,\n      knex,\n      field: { options },\n    } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const timeZone = (options as IDateFieldOptions).formatting.timeZone;\n    return knex.raw(\n      `TO_CHAR(TIMEZONE(?, ${this.fieldName}), 'YYYY-MM-DD HH24:MI') ILIKE ? ESCAPE '\\\\'`,\n      [timeZone, `%${escapedSearchValue}%`]\n    );\n  }\n\n  protected json() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    return knex.raw(`(${this.fieldName})::jsonb #>> '{title}' ILIKE ? ESCAPE '\\\\'`, [\n      `%${escapedSearchValue}%`,\n    ]);\n  }\n\n  protected multipleText() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapePostgresRegex(searchValue);\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1\n        FROM (\n          SELECT string_agg(elem::text, ', ') as aggregated\n          FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem\n        ) as sub\n        WHERE sub.aggregated ~* ?\n      )\n    `,\n      [escapedSearchValue]\n    );\n  }\n\n  protected multipleNumber() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT string_agg(ROUND(elem::numeric, ?::int)::text, ', ') as aggregated\n          FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem\n        ) as sub\n        WHERE sub.aggregated ILIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [precision, `%${escapedSearchValue}%`]\n    );\n  }\n\n  protected multipleDate() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT string_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), 'YYYY-MM-DD HH24:MI'), ', ') as aggregated\n          FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem\n        ) as sub\n        WHERE sub.aggregated ILIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [timeZone, `%${escapedSearchValue}%`]\n    );\n  }\n\n  protected multipleJson() {\n    const { search, knex } = this;\n    const searchValue = search[0];\n    const escapedSearchValue = escapePostgresRegex(searchValue);\n    return knex.raw(\n      `\n      EXISTS (\n        WITH RECURSIVE f(e) AS (\n          SELECT ${this.fieldName}::jsonb\n          UNION ALL\n          SELECT jsonb_array_elements(f.e)\n          FROM f\n          WHERE jsonb_typeof(f.e) = 'array'\n        )\n        SELECT 1 FROM (\n          SELECT string_agg((e->>'title')::text, ', ') as aggregated\n          FROM f\n          WHERE jsonb_typeof(e) <> 'array'\n        ) as sub\n        WHERE sub.aggregated ~* ?\n      )\n      `,\n      [escapedSearchValue]\n    );\n  }\n}\n\nexport class SearchQueryPostgresBuilder {\n  constructor(\n    public queryBuilder: Knex.QueryBuilder,\n    public dbTableName: string,\n    public searchFields: IFieldInstance[],\n    public searchIndexRo: ISearchIndexByQueryRo,\n    public tableIndex: TableIndex[],\n    public context?: IRecordQueryFilterContext,\n    public baseSortIndex?: string,\n    public setFilterQuery?: (qb: Knex.QueryBuilder) => void,\n    public setSortQuery?: (qb: Knex.QueryBuilder) => void\n  ) {\n    this.queryBuilder = queryBuilder;\n    this.dbTableName = dbTableName;\n    this.searchFields = searchFields;\n    this.baseSortIndex = baseSortIndex;\n    this.searchIndexRo = searchIndexRo;\n    this.setFilterQuery = setFilterQuery;\n    this.setSortQuery = setSortQuery;\n    this.tableIndex = tableIndex;\n    this.context = context;\n  }\n\n  private getSearchConditions() {\n    const { queryBuilder, searchIndexRo, searchFields, tableIndex, context } = this;\n    const { search } = searchIndexRo;\n\n    if (!search || !searchFields?.length) {\n      return [] as Array<{ field: IFieldInstance; condition: Knex.Raw }>;\n    }\n\n    return searchFields\n      .map((field) => {\n        const searchQueryBuilder = new SearchQueryPostgres(\n          queryBuilder,\n          field,\n          search,\n          tableIndex,\n          context\n        );\n        const condition = searchQueryBuilder.getQuery();\n        return condition ? { field, condition } : undefined;\n      })\n      .filter((item): item is { field: IFieldInstance; condition: Knex.Raw } => Boolean(item));\n  }\n\n  getCaseWhenSqlBy() {\n    const { queryBuilder, searchIndexRo, context } = this;\n    const { search } = searchIndexRo;\n    const isSearchAllFields = !search?.[1];\n    const knexInstance = queryBuilder.client;\n    const conditions = this.getSearchConditions();\n\n    return conditions\n      .filter(({ field }) => {\n        // global search does not support date time and checkbox\n        if (\n          isSearchAllFields &&\n          [CellValueType.DateTime, CellValueType.Boolean].includes(field.cellValueType)\n        ) {\n          return false;\n        }\n        return true;\n      })\n      .map(({ field, condition }) => {\n        // Get the correct field name using the same logic as in SearchQueryAbstract\n        const selection = context?.selectionMap.get(field.id);\n        const fieldName = selection ? (selection as string) : field.dbFieldName;\n\n        return knexInstance.raw('CASE WHEN (?) THEN ? END', [condition, fieldName]);\n      });\n  }\n\n  getSearchIndexQuery() {\n    const {\n      queryBuilder,\n      dbTableName,\n      searchFields: searchField,\n      searchIndexRo,\n      setFilterQuery,\n      setSortQuery,\n      baseSortIndex,\n    } = this;\n\n    const { search, groupBy, orderBy, take, skip } = searchIndexRo;\n    const knexInstance = queryBuilder.client;\n\n    if (!search || !searchField.length) {\n      return queryBuilder;\n    }\n\n    const searchConditions = this.getSearchConditions();\n    const caseWhenConditions = this.getCaseWhenSqlBy();\n\n    queryBuilder.with('search_hit_row', (qb) => {\n      qb.select('*');\n\n      qb.from(dbTableName);\n\n      qb.where((subQb) => {\n        subQb.where((orWhere) => {\n          searchConditions.forEach(({ condition }) => {\n            orWhere.orWhereRaw(condition);\n          });\n        });\n        if (this.searchIndexRo.filter && setFilterQuery) {\n          subQb.andWhere((andQb) => {\n            setFilterQuery?.(andQb);\n          });\n        }\n      });\n\n      if (orderBy?.length || groupBy?.length) {\n        setSortQuery?.(qb);\n      }\n\n      take && qb.limit(take);\n\n      qb.offset(skip ?? 0);\n\n      baseSortIndex && qb.orderBy(baseSortIndex, 'asc');\n    });\n\n    queryBuilder.with('search_field_union_table', (qb) => {\n      qb.select('__id').select(\n        knexInstance.raw(\n          `array_remove(ARRAY [${caseWhenConditions.map(() => '(?)').join(', ')}], NULL) as matched_columns`,\n          caseWhenConditions\n        )\n      );\n\n      qb.from('search_hit_row');\n    });\n\n    queryBuilder\n      .select('__id', 'matched_column')\n      .select(\n        knexInstance.raw(\n          `CASE\n            ${searchField\n              .map((field) => {\n                // Get the correct field name using the same logic as in SearchQueryAbstract\n                const selection = this.context?.selectionMap.get(field.id);\n                const fieldName = selection ? (selection as string) : field.dbFieldName;\n                return knexInstance.raw(`WHEN matched_column = '${fieldName}' THEN ?`, [field.id]);\n              })\n              .join(' ')}\n          END AS \"fieldId\"`\n        )\n      )\n      .fromRaw(\n        `\n        \"search_field_union_table\",\n        LATERAL unnest(matched_columns) AS matched_column\n        `\n      )\n      .whereRaw(`array_length(matched_columns, 1) > 0`);\n\n    return queryBuilder;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/search-query.sqlite.ts",
    "content": "import { CellValueType, type IDateFieldOptions } from '@teable/core';\nimport type { ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { get } from 'lodash';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport { escapeLikeWildcards } from '../../utils/sql-like-escape';\nimport { SearchQueryAbstract } from './abstract';\nimport { getOffset } from './get-offset';\nimport type { ISearchCellValueType } from './types';\n\nexport class SearchQuerySqlite extends SearchQueryAbstract {\n  protected knex: Knex.Client;\n  constructor(\n    protected originQueryBuilder: Knex.QueryBuilder,\n    protected field: IFieldInstance,\n    protected search: [string, string?, boolean?],\n    protected tableIndex: TableIndex[],\n    protected context?: IRecordQueryFilterContext\n  ) {\n    super(originQueryBuilder, field, search, tableIndex, context);\n    this.knex = originQueryBuilder.client;\n  }\n\n  appendBuilder() {\n    const { originQueryBuilder } = this;\n    const condition = this.getQuery();\n    condition && this.originQueryBuilder.orWhereRaw(condition);\n    return originQueryBuilder;\n  }\n\n  getSql(): string | null {\n    return this.getQuery().toSQL().sql;\n  }\n\n  getQuery() {\n    const { field } = this;\n    const { isMultipleCellValue } = field;\n\n    return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();\n  }\n\n  protected getSearchQueryWithIndex() {\n    return this.originQueryBuilder;\n  }\n\n  protected getMultipleCellTypeQuery() {\n    const { field } = this;\n    const { isStructuredCellValue, cellValueType } = field;\n    switch (cellValueType as ISearchCellValueType) {\n      case CellValueType.String: {\n        if (isStructuredCellValue) {\n          return this.multipleJson();\n        } else {\n          return this.multipleText();\n        }\n      }\n      case CellValueType.DateTime: {\n        return this.multipleDate();\n      }\n      case CellValueType.Number: {\n        return this.multipleNumber();\n      }\n      default:\n        return this.multipleText();\n    }\n  }\n\n  protected getSingleCellTypeQuery() {\n    const { field } = this;\n    const { isStructuredCellValue, cellValueType } = field;\n    switch (cellValueType as ISearchCellValueType) {\n      case CellValueType.String: {\n        if (isStructuredCellValue) {\n          return this.json();\n        } else {\n          return this.text();\n        }\n      }\n      case CellValueType.DateTime: {\n        return this.date();\n      }\n      case CellValueType.Number: {\n        return this.number();\n      }\n      default:\n        return this.text();\n    }\n  }\n\n  protected text() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    return knex.raw(\n      `REPLACE(REPLACE(REPLACE(${this.fieldName}, CHAR(13), ' '), CHAR(10), ' '), CHAR(9), ' ') LIKE ? ESCAPE '\\\\'`,\n      [`%${escapedSearchValue}%`]\n    );\n  }\n\n  protected json() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    return knex.raw(`json_extract(${this.fieldName}, '$.title') LIKE ? ESCAPE '\\\\'`, [\n      `%${escapedSearchValue}%`,\n    ]);\n  }\n\n  protected date() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;\n    return knex.raw(`DATETIME(${this.fieldName}, ?) LIKE ? ESCAPE '\\\\'`, [\n      `${getOffset(timeZone)} hour`,\n      `%${escapedSearchValue}%`,\n    ]);\n  }\n\n  protected number() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;\n    return knex.raw(`ROUND(${this.fieldName}, ?) LIKE ? ESCAPE '\\\\'`, [\n      precision,\n      `%${escapedSearchValue}%`,\n    ]);\n  }\n\n  protected multipleText() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT group_concat(je.value, ', ') as aggregated\n          FROM json_each(${this.fieldName}) as je\n          WHERE je.key != 'title'\n        )\n        WHERE aggregated LIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [`%${escapedSearchValue}%`]\n    );\n  }\n\n  protected multipleJson() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT group_concat(json_extract(je.value, '$.title'), ', ') as aggregated\n          FROM json_each(${this.fieldName}) as je\n        )\n        WHERE aggregated LIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [`%${escapedSearchValue}%`]\n    );\n  }\n\n  protected multipleNumber() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT group_concat(ROUND(je.value, ?), ', ') as aggregated\n          FROM json_each(${this.fieldName}) as je\n        )\n        WHERE aggregated LIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [precision, `%${escapedSearchValue}%`]\n    );\n  }\n\n  protected multipleDate() {\n    const { search, knex } = this;\n    const [searchValue] = search;\n    const escapedSearchValue = escapeLikeWildcards(searchValue);\n    const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;\n    return knex.raw(\n      `\n      EXISTS (\n        SELECT 1 FROM (\n          SELECT group_concat(DATETIME(je.value, ?), ', ') as aggregated\n          FROM json_each(${this.fieldName}) as je\n        )\n        WHERE aggregated LIKE ? ESCAPE '\\\\'\n      )\n      `,\n      [`${getOffset(timeZone)} hour`, `%${escapedSearchValue}%`]\n    );\n  }\n}\n\nexport class SearchQuerySqliteBuilder {\n  constructor(\n    public queryBuilder: Knex.QueryBuilder,\n    public dbTableName: string,\n    public searchField: IFieldInstance[],\n    public searchIndexRo: ISearchIndexByQueryRo,\n    public tableIndex: TableIndex[],\n    public context?: IRecordQueryFilterContext,\n    public baseSortIndex?: string,\n    public setFilterQuery?: (qb: Knex.QueryBuilder) => void,\n    public setSortQuery?: (qb: Knex.QueryBuilder) => void\n  ) {\n    this.queryBuilder = queryBuilder;\n    this.dbTableName = dbTableName;\n    this.searchField = searchField;\n    this.baseSortIndex = baseSortIndex;\n    this.searchIndexRo = searchIndexRo;\n    this.setFilterQuery = setFilterQuery;\n    this.setSortQuery = setSortQuery;\n    this.context = context;\n  }\n\n  private getSearchConditions() {\n    const { queryBuilder, searchIndexRo, searchField, tableIndex, context } = this;\n    const { search } = searchIndexRo;\n\n    if (!search || !searchField?.length) {\n      return [] as Array<{ field: IFieldInstance; condition: Knex.Raw }>;\n    }\n\n    return searchField.map((field) => {\n      const searchQueryBuilder = new SearchQuerySqlite(\n        queryBuilder,\n        field,\n        search,\n        tableIndex,\n        context\n      );\n      return { field, condition: searchQueryBuilder.getQuery() };\n    });\n  }\n\n  getSearchIndexQuery() {\n    const {\n      queryBuilder,\n      searchIndexRo,\n      dbTableName,\n      searchField,\n      baseSortIndex,\n      setFilterQuery,\n      setSortQuery,\n    } = this;\n    const { search, filter, orderBy, groupBy, skip, take } = searchIndexRo;\n    const knexInstance = queryBuilder.client;\n\n    if (!search || !searchField?.length) {\n      return queryBuilder;\n    }\n\n    const searchConditions = this.getSearchConditions();\n\n    queryBuilder.with('search_hit_row', (qb) => {\n      qb.select('*');\n\n      qb.from(dbTableName);\n\n      qb.where((subQb) => {\n        subQb.where((orWhere) => {\n          searchConditions.forEach(({ condition }) => {\n            orWhere.orWhereRaw(condition);\n          });\n        });\n        if (this.searchIndexRo.filter && setFilterQuery) {\n          subQb.andWhere((andQb) => {\n            setFilterQuery?.(andQb);\n          });\n        }\n      });\n\n      if (orderBy?.length || groupBy?.length) {\n        setSortQuery?.(qb);\n      }\n\n      take && qb.limit(take);\n\n      qb.offset(skip ?? 0);\n\n      baseSortIndex && qb.orderBy(baseSortIndex, 'asc');\n    });\n\n    queryBuilder.with('search_field_union_table', (qb) => {\n      for (let index = 0; index < searchConditions.length; index++) {\n        const { field, condition } = searchConditions[index];\n\n        // Get the correct field name using the same logic as in SearchQueryAbstract\n        const selection = this.context?.selectionMap.get(field.id);\n        const fieldName = selection ? (selection as string) : field.dbFieldName;\n\n        // boolean field or new field which does not support search should be skipped\n        if (!fieldName) {\n          continue;\n        }\n\n        if (index === 0) {\n          qb.select('*', knexInstance.raw(`? as matched_column`, [fieldName]))\n            .whereRaw(condition)\n            .from('search_hit_row');\n        } else {\n          qb.unionAll(function () {\n            this.select('*', knexInstance.raw(`? as matched_column`, [fieldName]))\n              .whereRaw(condition)\n              .from('search_hit_row');\n          });\n        }\n      }\n    });\n\n    queryBuilder\n      .select('__id', '__auto_number', 'matched_column')\n      .select(\n        knexInstance.raw(\n          `CASE\n            ${searchField\n              .map((field) => {\n                // Get the correct field name using the same logic as in SearchQueryAbstract\n                const selection = this.context?.selectionMap.get(field.id);\n                const fieldName = selection ? (selection as string) : field.dbFieldName;\n                return `WHEN matched_column = '${fieldName}' THEN '${field.id}'`;\n              })\n              .join(' ')}\n          END AS \"fieldId\"`\n        )\n      )\n      .from('search_field_union_table');\n\n    if (orderBy?.length || groupBy?.length) {\n      setSortQuery?.(queryBuilder);\n    }\n\n    if (filter) {\n      setFilterQuery?.(queryBuilder);\n    }\n\n    baseSortIndex && queryBuilder.orderBy(baseSortIndex, 'asc');\n\n    const cases = searchField.map((field, index) => {\n      // Get the correct field name using the same logic as in SearchQueryAbstract\n      const selection = this.context?.selectionMap.get(field.id);\n      const fieldName = selection ? (selection as string) : field.dbFieldName;\n\n      return knexInstance.raw(`CASE WHEN ?? = ? THEN ? END`, [\n        'matched_column',\n        fieldName,\n        index + 1,\n      ]);\n    });\n    cases.length && queryBuilder.orderByRaw(cases.join(','));\n\n    return queryBuilder;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/search-query/types.ts",
    "content": "import type { CellValueType } from '@teable/core';\nimport type { TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../../features/field/model/factory';\nimport type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { SearchQueryAbstract } from './abstract';\n\nexport type ISearchCellValueType = Exclude<CellValueType, CellValueType.Boolean>;\n\nexport type ISearchQueryConstructor = {\n  new (\n    originQueryBuilder: Knex.QueryBuilder,\n    field: IFieldInstance,\n    search: [string, string?, boolean?],\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext\n  ): SearchQueryAbstract;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/index.ts",
    "content": "// Abstract base class\nexport { SelectQueryAbstract } from './select-query.abstract';\n\n// PostgreSQL implementation\nexport { SelectQueryPostgres } from './postgres/select-query.postgres';\n\n// SQLite implementation\nexport { SelectQuerySqlite } from './sqlite/select-query.sqlite';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { DbFieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\n\nimport { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';\nimport { SelectQueryPostgres } from './select-query.postgres';\n\ndescribe('SelectQueryPostgres tzWrap', () => {\n  it('sanitizes text-like datetime inputs even when SQL contains timestamp tokens', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([{ type: 'string', isFieldReference: false }] as unknown as never);\n\n    const expr =\n      \"CONCAT(TO_CHAR(TIMEZONE('Etc/GMT-8', (col)::timestamptz), 'YYYY-MM-DD'), ' ', col2)\";\n    const sql = query.datetimeFormat(expr, \"'HH:mm:ss'\");\n\n    expect(sql).toContain('BTRIM');\n    expect(sql).toContain('CASE WHEN');\n    expect(sql).toContain(getDefaultDatetimeParsePattern());\n  });\n\n  it('does not sanitize trusted datetime inputs', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);\n\n    const sql = query.datetimeFormat('col', \"'HH:mm:ss'\");\n    expect(sql).not.toContain('BTRIM');\n  });\n\n  it('reparses trusted datetime inputs through custom formats instead of returning the original value', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);\n\n    const sql = query.datetimeParse('col', \"'MMYYYY'\");\n\n    expect(sql).toContain('TO_CHAR');\n    expect(sql).toContain('TO_TIMESTAMP');\n    expect(sql).toContain(`AT TIME ZONE 'Asia/Shanghai'`);\n    expect(sql).not.toBe('(col)');\n  });\n});\n\ndescribe('SelectQueryPostgres truthinessScore', () => {\n  it('casts boolean-like expressions before COALESCE to avoid text/boolean type errors', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([{ type: 'boolean', isFieldReference: false }] as unknown as never);\n\n    const sql = query.if(\"('true')::text\", \"'yes'\", \"'no'\");\n    expect(sql).toContain(\"COALESCE((('true')::text)::boolean, FALSE)\");\n  });\n\n  it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({\n      timeZone: 'Asia/Shanghai',\n      targetDbFieldType: DbFieldType.Real,\n    } as unknown as never);\n    query.setCallMetadata([\n      { type: 'string', isFieldReference: false },\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldJsonNumeric',\n          isMultiple: true,\n          isLookup: true,\n          dbFieldName: '__json_numeric',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'number',\n        },\n      },\n      { type: 'number', isFieldReference: false },\n    ] as unknown as never);\n\n    const sql = query.if('__cond', '\"__json_numeric\"', '0');\n    expect(sql).toContain('to_jsonb(\"__json_numeric\")');\n    expect(sql).toContain('jsonb_array_elements_text');\n    expect(sql).toContain('double precision');\n  });\n});\n\ndescribe('SelectQueryPostgres countAll', () => {\n  it('counts JSON array length for multi-value field references', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ tableAlias: 't' } as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldUsers',\n          isMultiple: true,\n          isLookup: false,\n          dbFieldName: '__users',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'string',\n        },\n      },\n    ] as unknown as never);\n\n    const sql = query.countAll('(SELECT json_agg(x) FROM x)');\n    expect(sql).toContain('jsonb_array_length');\n    expect(sql).toContain(`\"t\".\"__users\"`);\n  });\n\n  it('uses scalar null-check semantics for non-json fields', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ tableAlias: 't' } as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'number',\n        isFieldReference: true,\n        field: {\n          id: 'fldNum',\n          isMultiple: false,\n          isLookup: false,\n          dbFieldName: '__num',\n          dbFieldType: DbFieldType.Real,\n          cellValueType: 'number',\n        },\n      },\n    ] as unknown as never);\n\n    expect(query.countAll('\"t\".\"__num\"')).toBe('CASE WHEN \"t\".\"__num\" IS NULL THEN 0 ELSE 1 END');\n  });\n});\n\ndescribe('SelectQueryPostgres FROMNOW/TONOW', () => {\n  it('applies unit conversion for FROMNOW', () => {\n    const query = new SelectQueryPostgres();\n\n    const daySql = query.fromNow('NOW()', \"'day'\");\n    const hourSql = query.fromNow('NOW()', \"'hour'\");\n    const secondSql = query.fromNow('NOW()', \"'second'\");\n\n    expect(daySql).toContain('/ 86400');\n    expect(hourSql).toContain('/ 3600');\n    expect(secondSql).not.toContain('/ 86400');\n    expect(secondSql).not.toContain('/ 3600');\n  });\n\n  it('keeps TONOW direction as now minus date for past-positive semantics', () => {\n    const query = new SelectQueryPostgres();\n\n    const sql = query.toNow('date_col', \"'day'\");\n    expect(sql).toContain('NOW() -');\n    expect(sql).not.toContain('date_col::timestamp - NOW()');\n  });\n});\n\ndescribe('SelectQueryPostgres workday', () => {\n  it('uses interval multiplication for dynamic day-count expressions', () => {\n    const query = new SelectQueryPostgres();\n    query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);\n    query.setCallMetadata([\n      { type: 'datetime', isFieldReference: true },\n      { type: 'number', isFieldReference: true },\n    ] as unknown as never);\n\n    const sql = query.workday('\"t\".\"Date\"', '\"t\".\"Number\"');\n    expect(sql).toContain(`INTERVAL '1 day' * (\"t\".\"Number\")::double precision`);\n    expect(sql).not.toContain(\" days'\");\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts",
    "content": "/* eslint-disable regexp/no-unused-capturing-group */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport { DateFormattingPreset, DbFieldType, TimeFormatting } from '@teable/core';\nimport type { IDatetimeFormatting } from '@teable/core';\nimport type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';\nimport {\n  buildDatetimeFormatSql,\n  buildDatetimeParseGuardRegex,\n  hasDatetimeTimezoneToken,\n  normalizeDatetimeFormatExpression,\n} from '../../utils/datetime-format.util';\nimport { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';\nimport {\n  isBooleanLikeParam,\n  isDatetimeLikeParam,\n  isJsonLikeParam,\n  isTextLikeParam,\n  isTrustedNumeric,\n  resolveFormulaParamInfo,\n} from '../../utils/formula-param-metadata.util';\nimport { SelectQueryAbstract } from '../select-query.abstract';\n\n/**\n * PostgreSQL-specific implementation of SELECT query functions\n * Converts Teable formula functions to PostgreSQL SQL expressions suitable\n * for use in SELECT statements. Unlike generated columns, these can use\n * mutable functions and have different optimization strategies.\n */\nexport class SelectQueryPostgres extends SelectQueryAbstract {\n  private get tableAlias(): string | undefined {\n    const ctx = this.context as ISelectFormulaConversionContext | undefined;\n    return ctx?.tableAlias;\n  }\n\n  private qualifySystemColumn(column: string): string {\n    const quoted = `\"${column}\"`;\n    const alias = this.tableAlias;\n    return alias ? `\"${alias}\".${quoted}` : quoted;\n  }\n\n  private hasWrappingParentheses(expr: string): boolean {\n    if (!expr.startsWith('(') || !expr.endsWith(')')) {\n      return false;\n    }\n    let depth = 0;\n    for (let i = 0; i < expr.length; i++) {\n      const ch = expr[i];\n      if (ch === '(') {\n        depth++;\n      } else if (ch === ')') {\n        depth--;\n        if (depth === 0 && i < expr.length - 1) {\n          return false;\n        }\n        if (depth < 0) {\n          return false;\n        }\n      }\n    }\n    return depth === 0;\n  }\n\n  private stripOuterParentheses(expr: string): string {\n    let trimmed = expr.trim();\n    while (trimmed.length > 0 && this.hasWrappingParentheses(trimmed)) {\n      trimmed = trimmed.slice(1, -1).trim();\n    }\n    return trimmed;\n  }\n\n  private getParamInfo(index?: number) {\n    return resolveFormulaParamInfo(this.currentCallMetadata, index);\n  }\n\n  private isNumericLiteral(expr: string): boolean {\n    let trimmed = this.stripOuterParentheses(expr);\n\n    // Peel leading signs while trimming redundant outer parens\n    while (trimmed.startsWith('+') || trimmed.startsWith('-')) {\n      trimmed = trimmed.slice(1).trim();\n      trimmed = this.stripOuterParentheses(trimmed);\n    }\n\n    // Match plain numeric literal, with optional cast to a numeric type\n    const numericWithOptionalCast =\n      /^\\(?\\d+(\\.\\d+)?\\)?(::(double precision|numeric|real|integer|bigint|smallint))?$/i;\n    if (numericWithOptionalCast.test(trimmed)) {\n      return true;\n    }\n\n    // Handle wrapped casts like ((7)::double precision)\n    const wrappedCastMatch = trimmed.match(/^\\((.+)\\)$/);\n    if (wrappedCastMatch) {\n      return this.isNumericLiteral(wrappedCastMatch[1]);\n    }\n\n    return false;\n  }\n\n  private toNumericSafe(\n    expr: string,\n    metadataIndex?: number,\n    opts?: { collate?: boolean; guardDateLike?: boolean }\n  ): string {\n    if (this.isNumericLiteral(expr)) {\n      return `(${expr})::double precision`;\n    }\n    const paramInfo = this.getParamInfo(metadataIndex);\n    const expressionFieldType = this.getExpressionFieldType(expr);\n    const targetDbType = (this.context as ISelectFormulaConversionContext | undefined)\n      ?.targetDbFieldType;\n\n    if (isBooleanLikeParam(paramInfo)) {\n      const boolScore = this.truthinessScore(expr, metadataIndex);\n      return `(${boolScore})::double precision`;\n    }\n    if (\n      paramInfo?.hasMetadata &&\n      isTextLikeParam(paramInfo) &&\n      !paramInfo.isJsonField &&\n      !paramInfo.isMultiValueField\n    ) {\n      return this.looseNumericCoercion(expr, opts);\n    }\n    if (expressionFieldType === DbFieldType.Text) {\n      return this.looseNumericCoercion(expr, opts);\n    }\n    if (paramInfo?.isJsonField || paramInfo?.isMultiValueField) {\n      return this.numericFromJson(expr);\n    }\n    if (expressionFieldType === DbFieldType.Json) {\n      return this.numericFromJson(expr);\n    }\n    if (isTrustedNumeric(paramInfo)) {\n      return `(${expr})::double precision`;\n    }\n    if (\n      !paramInfo?.hasMetadata &&\n      (expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer)\n    ) {\n      return `(${expr})::double precision`;\n    }\n    if (\n      !paramInfo?.hasMetadata &&\n      (targetDbType === DbFieldType.Real || targetDbType === DbFieldType.Integer)\n    ) {\n      return `(${expr})::double precision`;\n    }\n\n    return this.looseNumericCoercion(expr, opts);\n  }\n\n  private looseNumericCoercion(\n    expr: string,\n    opts?: { collate?: boolean; guardDateLike?: boolean }\n  ): string {\n    // Safely coerce any scalar to a floating-point number:\n    // - Strip everything except digits, sign, decimal point\n    // - Map empty string to NULL to avoid casting errors\n    // Cast to DOUBLE PRECISION so pg driver returns JS numbers (not strings as with NUMERIC)\n    if (this.isNumericLiteral(expr)) {\n      return `(${expr})::double precision`;\n    }\n    const shouldCollate = opts?.collate !== false;\n    const textExpr = shouldCollate ? `((${expr})::text) COLLATE \"C\"` : `((${expr})::text)`;\n    // Avoid treating obvious date-like strings (e.g., 2024/12/03) as numbers\n    const dateLikePattern = `'^[0-9]{1,4}[-/][0-9]{1,2}[-/][0-9]{1,4}( .*){0,1}$'`;\n    const collatedDatePattern = `${dateLikePattern} COLLATE \"C\"`;\n    const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;\n    const cleaned = `NULLIF(${sanitized}, '')`;\n    // Avoid \"?\" in the regex so knex.raw doesn't misinterpret it as a binding placeholder.\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const matchClause = shouldCollate\n      ? `${cleaned} COLLATE \"C\" ~ ${numericPattern} COLLATE \"C\"`\n      : `${cleaned} ~ ${numericPattern}`;\n    const guards = [`WHEN ${cleaned} IS NULL THEN NULL`];\n    if (opts?.guardDateLike) {\n      const datePattern = shouldCollate ? collatedDatePattern : dateLikePattern;\n      const dateGuardExpr = `${textExpr} ~ ${datePattern}`;\n      guards.push(`WHEN ${dateGuardExpr} THEN NULL`);\n    }\n    guards.push(`WHEN ${matchClause} THEN ${cleaned}::double precision`);\n    guards.push('ELSE NULL');\n    return `(CASE ${guards.join(' ')} END)`;\n  }\n\n  private numericFromJson(expr: string): string {\n    const jsonExpr = `to_jsonb(${expr})`;\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    const arraySum = `(SELECT SUM(CASE WHEN (elem.value COLLATE \"C\") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END) FROM jsonb_array_elements_text(${jsonExpr}) AS elem(value))`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN NULL\n      WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN ${arraySum}\n      ELSE ${this.looseNumericCoercion(expr)}\n    END)`;\n  }\n\n  private buildNumericArrayAggregation(expr: string): { sum: string; count: string } {\n    const arrayExpr = this.normalizeAnyToJsonArray(expr);\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    const numericValue = `(CASE WHEN (elem.value COLLATE \"C\") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END)`;\n    const numericCount = `(CASE WHEN (elem.value COLLATE \"C\") ~ ${collatedPattern} THEN 1 ELSE 0 END)`;\n\n    const sumExpr = `(SELECT SUM(${numericValue}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;\n    const countExpr = `(SELECT SUM(${numericCount}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;\n    return { sum: sumExpr, count: countExpr };\n  }\n\n  private buildNumericArrayExtremum(expr: string, op: 'max' | 'min'): string {\n    const arrayExpr = this.normalizeAnyToJsonArray(expr);\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    const collatedPattern = `${numericPattern} COLLATE \"C\"`;\n    const numericValue = `(CASE WHEN (elem.value COLLATE \"C\") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END)`;\n    const agg = op === 'max' ? 'MAX' : 'MIN';\n    return `(SELECT ${agg}(${numericValue}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;\n  }\n\n  private collapseNumeric(expr: string, metadataIndex?: number): string {\n    const numericValue = this.toNumericSafe(expr, metadataIndex);\n    return `COALESCE(${numericValue}, 0)`;\n  }\n\n  private isDateLikeOperand(metadataIndex?: number): boolean {\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (!paramInfo?.hasMetadata) {\n      return false;\n    }\n    if (paramInfo.type === 'number') {\n      return false;\n    }\n    const hasFieldDateMetadata =\n      paramInfo.fieldDbType === DbFieldType.DateTime || paramInfo.fieldCellValueType === 'datetime';\n    const typeSaysDatetime =\n      isDatetimeLikeParam(paramInfo) && !paramInfo.fieldDbType && !paramInfo.fieldCellValueType;\n    const looksDatetime = hasFieldDateMetadata || typeSaysDatetime;\n\n    if (!looksDatetime) {\n      return false;\n    }\n\n    return !paramInfo.isJsonField && !paramInfo.isMultiValueField;\n  }\n\n  private buildDayInterval(expr: string, metadataIndex?: number): string {\n    const numeric = this.collapseNumeric(expr, metadataIndex);\n    return `(${numeric}) * INTERVAL '1 day'`;\n  }\n\n  private isEmptyStringLiteral(value: string): boolean {\n    return value.trim() === \"''\";\n  }\n\n  private isNullLiteral(value: string): boolean {\n    return this.stripOuterParentheses(value).toUpperCase() === 'NULL';\n  }\n\n  private shouldCoalesceNumericComparison(value: string, metadataIndex?: number): boolean {\n    if (this.isNumericLiteral(value)) {\n      return true;\n    }\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    return paramInfo ? isTrustedNumeric(paramInfo) || paramInfo.type === 'number' : false;\n  }\n\n  private normalizeNumericComparisonOperand(value: string, metadataIndex?: number): string {\n    if (!this.shouldCoalesceNumericComparison(value, metadataIndex)) {\n      return value;\n    }\n    const numericValue = this.toNumericSafe(value, metadataIndex);\n    return `COALESCE(${numericValue}, 0)`;\n  }\n\n  private normalizeBlankComparable(value: string, metadataIndex?: number): string {\n    const comparable = this.coerceToTextComparable(value, metadataIndex);\n    // Force text comparison so numeric fields compared against '' won't cast '' to double precision\n    const textComparable = this.ensureTextCollation(comparable);\n    return `COALESCE(NULLIF(${textComparable}, ''), '')`;\n  }\n\n  private ensureTextCollation(expr: string): string {\n    return `(${expr})::text`;\n  }\n\n  private isTextLikeExpression(value: string, metadataIndex?: number): boolean {\n    const trimmed = this.stripOuterParentheses(value);\n    if (this.isEmptyStringLiteral(trimmed)) {\n      return false;\n    }\n    if (/^'.*'$/.test(trimmed)) {\n      return true;\n    }\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata) {\n      if (\n        paramInfo.fieldDbType === DbFieldType.Real ||\n        paramInfo.fieldDbType === DbFieldType.Integer ||\n        paramInfo.fieldCellValueType === 'number'\n      ) {\n        return false;\n      }\n      if (isTextLikeParam(paramInfo)) {\n        return true;\n      }\n    }\n\n    return this.getExpressionFieldType(value) === DbFieldType.Text;\n  }\n\n  private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {\n    if (this.isNumericLiteral(value)) {\n      return true;\n    }\n\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata) {\n      if (\n        paramInfo.type === 'number' ||\n        isTrustedNumeric(paramInfo) ||\n        isBooleanLikeParam(paramInfo)\n      ) {\n        return true;\n      }\n      if (\n        paramInfo.fieldDbType === DbFieldType.Real ||\n        paramInfo.fieldDbType === DbFieldType.Integer\n      ) {\n        return true;\n      }\n      if (paramInfo.fieldCellValueType === 'number') {\n        return true;\n      }\n    }\n\n    const expressionFieldType = this.getExpressionFieldType(value);\n    return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;\n  }\n\n  private getExpressionFieldType(value: string): DbFieldType | undefined {\n    const trimmed = this.stripOuterParentheses(value);\n    const columnMatch = trimmed.match(/^\"([^\"]+)\"$/) ?? trimmed.match(/^\"[^\"]+\"\\.\"([^\"]+)\"$/);\n    if (!columnMatch || columnMatch.length < 2) {\n      return undefined;\n    }\n\n    const columnName = columnMatch[1];\n    const table = this.context?.table;\n    const field =\n      table?.fieldList?.find((item) => item.dbFieldName === columnName) ??\n      table?.fields?.ordered?.find((item) => item.dbFieldName === columnName);\n    if (field) {\n      return field.dbFieldType as DbFieldType | undefined;\n    }\n\n    // Handle CTE-projected lookup/rollup aliases like \"lookup_<fieldId>\" that aren't part of the\n    // base table's dbFieldName list but still correspond to concrete field metadata.\n    const lookupMatch = columnName.match(/^(lookup|rollup)_(fld[A-Za-z0-9]+)$/);\n    if (lookupMatch && typeof table?.getField === 'function') {\n      const byId = table.getField(lookupMatch[2]);\n      return byId?.dbFieldType as DbFieldType | undefined;\n    }\n\n    return undefined;\n  }\n\n  private isHardTextExpression(value: string): boolean {\n    const trimmed = this.stripOuterParentheses(value);\n    if (this.isEmptyStringLiteral(trimmed)) {\n      return false;\n    }\n    if (/^'.+'$/.test(trimmed)) {\n      return true;\n    }\n    return this.getExpressionFieldType(value) === DbFieldType.Text;\n  }\n\n  private coerceArrayLikeToText(expr: string, metadataIndex?: number): string {\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    const shouldFlatten = paramInfo?.isJsonField || paramInfo?.isMultiValueField;\n\n    if (!shouldFlatten) {\n      return this.ensureTextCollation(expr);\n    }\n\n    const textExpr = `((${expr})::text)`;\n    const safeJsonExpr = `(CASE WHEN ${expr} IS NULL THEN NULL ELSE to_jsonb(${expr}) END)`;\n\n    const flattened = `(CASE\n      WHEN ${expr} IS NULL THEN NULL\n      WHEN ${safeJsonExpr} IS NULL THEN ${textExpr}\n      WHEN jsonb_typeof(${safeJsonExpr}) = 'array' THEN (\n        SELECT STRING_AGG(elem.value, ', ' ORDER BY elem.ordinality)\n        FROM jsonb_array_elements_text(${safeJsonExpr}) WITH ORDINALITY AS elem(value, ordinality)\n      )\n      WHEN jsonb_typeof(${safeJsonExpr}) = 'object' THEN COALESCE(\n        ${safeJsonExpr}->>'title',\n        ${safeJsonExpr}->>'name',\n        ${safeJsonExpr} #>> '{}'\n      )\n      ELSE ${safeJsonExpr} #>> '{}'\n    END)`;\n\n    return this.ensureTextCollation(flattened);\n  }\n\n  private buildJsonScalarCoercion(jsonExpr: string): string {\n    const elementScalar = `CASE\n      WHEN jsonb_typeof(elem.value) = 'object' THEN COALESCE(\n        elem.value->>'title',\n        elem.value->>'name',\n        elem.value #>> '{}'\n      )\n      WHEN jsonb_typeof(elem.value) = 'array' THEN NULL\n      ELSE elem.value #>> '{}'\n    END`;\n\n    return `CASE jsonb_typeof(${jsonExpr})\n      WHEN 'string' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'number' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'boolean' THEN (${jsonExpr}) #>> '{}'\n      WHEN 'null' THEN NULL\n      WHEN 'array' THEN COALESCE((\n        SELECT STRING_AGG(${elementScalar}, ', ' ORDER BY elem.ordinality)\n        FROM jsonb_array_elements(${jsonExpr}) WITH ORDINALITY AS elem(value, ordinality)\n      ), '')\n      WHEN 'object' THEN COALESCE(${jsonExpr}->>'title', ${jsonExpr}->>'name', ${jsonExpr} #>> '{}')\n      ELSE (${jsonExpr})::text\n    END`;\n  }\n\n  private coerceJsonExpressionToText(wrapped: string, metadataIndex?: number): string {\n    void metadataIndex;\n    const jsonExpr = `to_jsonb${wrapped}`;\n    return `(CASE\n      WHEN ${wrapped} IS NULL THEN NULL\n      ELSE ${this.buildJsonScalarCoercion(jsonExpr)}\n    END)`;\n  }\n\n  private coerceNonJsonExpressionToText(wrapped: string): string {\n    const jsonbValue = `to_jsonb${wrapped}`;\n\n    return `(CASE\n      WHEN ${wrapped} IS NULL THEN NULL\n      ELSE\n        ${this.buildJsonScalarCoercion(jsonbValue)}\n    END)`;\n  }\n\n  private coerceToTextComparable(value: string, metadataIndex?: number): string {\n    const trimmed = this.stripOuterParentheses(value);\n    if (!trimmed) {\n      return this.ensureTextCollation(value);\n    }\n    const isStringLiteral = /^'.*'$/.test(trimmed);\n    if (isStringLiteral) {\n      return trimmed;\n    }\n    if (trimmed.toUpperCase() === 'NULL') {\n      return 'NULL';\n    }\n\n    const wrapped = `(${value})`;\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    const expressionFieldType = this.getExpressionFieldType(value);\n    const numericField =\n      paramInfo?.fieldDbType === DbFieldType.Real ||\n      paramInfo?.fieldDbType === DbFieldType.Integer ||\n      paramInfo?.fieldCellValueType === 'number' ||\n      expressionFieldType === DbFieldType.Real ||\n      expressionFieldType === DbFieldType.Integer;\n    if (numericField && !paramInfo?.isJsonField && !paramInfo?.isMultiValueField) {\n      // Cast numeric operands to text so blank comparisons (e.g. field = '') don't try to\n      // coerce '' into double precision and raise 22P02.\n      return this.ensureTextCollation(wrapped);\n    }\n    if (paramInfo?.hasMetadata) {\n      if (isJsonLikeParam(paramInfo)) {\n        const coercedJson = this.coerceJsonExpressionToText(wrapped, metadataIndex);\n        return this.ensureTextCollation(coercedJson);\n      }\n\n      if (isTextLikeParam(paramInfo)) {\n        return this.isNumericLiteral(trimmed) ? this.ensureTextCollation(wrapped) : wrapped;\n      }\n\n      if (paramInfo.type && paramInfo.type !== 'unknown') {\n        return this.ensureTextCollation(`${wrapped}::text`);\n      }\n    }\n\n    // Heuristic: treat CASE/COALESCE/text-cast expressions as text without json wrapping to prevent\n    // runaway query growth in nested IF chains.\n    if (/^CASE\\b/i.test(trimmed) || /::text\\b/i.test(trimmed) || /\\bCOALESCE\\b/i.test(trimmed)) {\n      return this.ensureTextCollation(wrapped);\n    }\n\n    const jsonbValue = `to_jsonb${wrapped}`;\n    const flattenedArray = `(SELECT STRING_AGG(elem.value, ', ' ORDER BY elem.ordinality)\n      FROM jsonb_array_elements_text(${jsonbValue}) WITH ORDINALITY AS elem(value, ordinality))`;\n    const coerced = `(CASE\n      WHEN ${wrapped} IS NULL THEN NULL\n      ELSE\n        CASE jsonb_typeof(${jsonbValue})\n          WHEN 'string' THEN ${jsonbValue} #>> '{}'\n          WHEN 'number' THEN ${jsonbValue} #>> '{}'\n          WHEN 'boolean' THEN ${jsonbValue} #>> '{}'\n          WHEN 'null' THEN NULL\n          WHEN 'array' THEN COALESCE(${flattenedArray}, '')\n          ELSE ${jsonbValue}::text\n        END\n    END)`;\n    return this.ensureTextCollation(coerced);\n  }\n\n  private countANonNullExpression(value: string, metadataIndex?: number): string {\n    if (this.isTextLikeExpression(value, metadataIndex)) {\n      const normalizedComparable = this.normalizeBlankComparable(value, metadataIndex);\n      return `CASE WHEN ${value} IS NULL OR ${normalizedComparable} = '' THEN 0 ELSE 1 END`;\n    }\n\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  private normalizeIntervalUnit(\n    unitLiteral: string,\n    options?: { treatQuarterAsMonth?: boolean }\n  ): {\n    unit:\n      | 'millisecond'\n      | 'second'\n      | 'minute'\n      | 'hour'\n      | 'day'\n      | 'week'\n      | 'month'\n      | 'quarter'\n      | 'year';\n    factor: number;\n  } {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return { unit: 'millisecond', factor: 1 };\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return { unit: 'second', factor: 1 };\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return { unit: 'minute', factor: 1 };\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return { unit: 'hour', factor: 1 };\n      case 'week':\n      case 'weeks':\n        return { unit: 'week', factor: 1 };\n      case 'month':\n      case 'months':\n        return { unit: 'month', factor: 1 };\n      case 'quarter':\n      case 'quarters':\n        if (options?.treatQuarterAsMonth === false) {\n          return { unit: 'quarter', factor: 1 };\n        }\n        return { unit: 'month', factor: 3 };\n      case 'year':\n      case 'years':\n        return { unit: 'year', factor: 1 };\n      case 'day':\n      case 'days':\n      default:\n        return { unit: 'day', factor: 1 };\n    }\n  }\n\n  private normalizeDiffUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      default:\n        return 'day';\n    }\n  }\n\n  private normalizeTruncateUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      case 'day':\n      case 'days':\n      default:\n        return 'day';\n    }\n  }\n\n  private buildBlankAwareComparison(\n    operator: '=' | '<>',\n    left: string,\n    right: string,\n    metadataIndexes?: { left?: number; right?: number }\n  ): string {\n    const leftIndex = metadataIndexes?.left;\n    const rightIndex = metadataIndexes?.right;\n    const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);\n    const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);\n    const leftIsNullLiteral = this.isNullLiteral(left);\n    const rightIsNullLiteral = this.isNullLiteral(right);\n    const leftIsText = this.isTextLikeExpression(left, leftIndex);\n    const rightIsText = this.isTextLikeExpression(right, rightIndex);\n    const normalizeText =\n      leftIsEmptyLiteral ||\n      rightIsEmptyLiteral ||\n      leftIsNullLiteral ||\n      rightIsNullLiteral ||\n      leftIsText ||\n      rightIsText;\n\n    const leftIsNumericComparable = this.shouldCoalesceNumericComparison(left, leftIndex);\n    const rightIsNumericComparable = this.shouldCoalesceNumericComparison(right, rightIndex);\n\n    if (!normalizeText && (leftIsNumericComparable || rightIsNumericComparable)) {\n      const normalizedLeft = leftIsNumericComparable\n        ? this.normalizeNumericComparisonOperand(left, leftIndex)\n        : left;\n      const normalizedRight = rightIsNumericComparable\n        ? this.normalizeNumericComparisonOperand(right, rightIndex)\n        : right;\n      return `(${normalizedLeft} ${operator} ${normalizedRight})`;\n    }\n\n    if (!normalizeText) {\n      return `(${left} ${operator} ${right})`;\n    }\n\n    const normalizeOperand = (\n      value: string,\n      isEmptyLiteral: boolean,\n      isNullLiteral: boolean,\n      metadataIndex?: number\n    ) =>\n      isEmptyLiteral || isNullLiteral ? \"''\" : this.normalizeBlankComparable(value, metadataIndex);\n\n    const normalizedLeft = normalizeOperand(left, leftIsEmptyLiteral, leftIsNullLiteral, leftIndex);\n    const normalizedRight = normalizeOperand(\n      right,\n      rightIsEmptyLiteral,\n      rightIsNullLiteral,\n      rightIndex\n    );\n\n    return `(${normalizedLeft} ${operator} ${normalizedRight})`;\n  }\n\n  private sanitizeTimestampInput(date: string): string {\n    const trimmed = `NULLIF(BTRIM((${date})::text), '')`;\n    const pattern = getDefaultDatetimeParsePattern().replace(/'/g, \"''\");\n    return `CASE WHEN ${trimmed} IS NULL THEN NULL WHEN LOWER(${trimmed}) IN ('null', 'undefined') THEN NULL WHEN ${trimmed} ~ '${pattern}' THEN ${trimmed} ELSE NULL END`;\n  }\n\n  private isTrustedDatetime(expr: string, metadataIndex?: number): boolean {\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    if (paramInfo?.hasMetadata) {\n      const looksDatetime =\n        isDatetimeLikeParam(paramInfo) ||\n        paramInfo.fieldDbType === DbFieldType.DateTime ||\n        paramInfo.fieldCellValueType === 'datetime';\n      if (looksDatetime && !paramInfo.isJsonField && !paramInfo.isMultiValueField) {\n        return true;\n      }\n      return false;\n    }\n    return false;\n  }\n\n  private isTimestampish(expr: string): boolean {\n    const trimmed = this.stripOuterParentheses(expr);\n    return (\n      /::timestamp(tz)?\\b/i.test(trimmed) ||\n      /\\bAT\\s+TIME\\s+ZONE\\b/i.test(trimmed) ||\n      /^NOW\\(\\)/i.test(trimmed) ||\n      /^CURRENT_TIMESTAMP/i.test(trimmed)\n    );\n  }\n\n  private shouldTreatAsDatetime(expr: string, metadataIndex?: number): boolean {\n    const paramInfo = this.getParamInfo(metadataIndex);\n    if (paramInfo?.hasMetadata) {\n      // Explicit numeric/boolean metadata should not be coerced into datetime even if the expression\n      // happens to contain timestamp-ish tokens (e.g. nested EXTRACT(... AT TIME ZONE ...)).\n      if (paramInfo.type === 'number' || paramInfo.type === 'boolean') {\n        return false;\n      }\n      const looksDatetime =\n        isDatetimeLikeParam(paramInfo) ||\n        paramInfo.fieldDbType === DbFieldType.DateTime ||\n        paramInfo.fieldCellValueType === 'datetime';\n      if (looksDatetime) {\n        return true;\n      }\n    }\n    return this.isTimestampish(expr);\n  }\n\n  private tzWrap(date: string, metadataIndex?: number): string {\n    const tz = this.context?.timeZone as string | undefined;\n    const shouldTreat = this.shouldTreatAsDatetime(date, metadataIndex);\n    const trusted = shouldTreat && this.isTrustedDatetime(date, metadataIndex);\n    const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;\n    const isTextLike = Boolean(paramInfo?.hasMetadata && isTextLikeParam(paramInfo));\n    const alreadyTimestamp = !isTextLike && this.isTimestampish(date);\n    const needsSanitize = !(trusted || alreadyTimestamp);\n    const baseExpr = needsSanitize ? this.sanitizeTimestampInput(date) : `(${date})`;\n    const wrappedBase = needsSanitize ? `(${baseExpr})` : baseExpr;\n\n    if (!tz) {\n      return `${wrappedBase}::timestamp`;\n    }\n    // Sanitize single quotes to prevent SQL issues\n    const safeTz = tz.replace(/'/g, \"''\");\n    return `${wrappedBase}::timestamptz AT TIME ZONE '${safeTz}'`;\n  }\n\n  private buildTimezoneOffsetSql(localTimestampSql: string): string {\n    const tz = this.context?.timeZone as string | undefined;\n    if (!tz) {\n      return \"'+00:00'\";\n    }\n\n    const safeTz = tz.replace(/'/g, \"''\");\n    const offsetMinutesSql = `ROUND(EXTRACT(EPOCH FROM (((${localTimestampSql}) AT TIME ZONE 'UTC') - ((${localTimestampSql}) AT TIME ZONE '${safeTz}'))) / 60)::int`;\n\n    return `(CASE WHEN ${offsetMinutesSql} >= 0 THEN '+' ELSE '-' END || LPAD((ABS(${offsetMinutesSql}) / 60)::int::text, 2, '0') || ':' || LPAD((ABS(${offsetMinutesSql}) % 60)::int::text, 2, '0'))`;\n  }\n\n  private getDatePattern(date: DateFormattingPreset | string): string {\n    const presetValues = Object.values(DateFormattingPreset) as string[];\n    const normalizedPreset = presetValues.includes(date)\n      ? (date as DateFormattingPreset)\n      : DateFormattingPreset.ISO;\n\n    switch (normalizedPreset) {\n      case DateFormattingPreset.US:\n        return 'FMMM/FMDD/YYYY';\n      case DateFormattingPreset.European:\n        return 'FMDD/FMMM/YYYY';\n      case DateFormattingPreset.Asian:\n        return 'YYYY/MM/DD';\n      case DateFormattingPreset.YM:\n        return 'YYYY-MM';\n      case DateFormattingPreset.MD:\n        return 'MM-DD';\n      case DateFormattingPreset.Y:\n        return 'YYYY';\n      case DateFormattingPreset.M:\n        return 'MM';\n      case DateFormattingPreset.D:\n        return 'DD';\n      case DateFormattingPreset.ISO:\n      default:\n        return 'YYYY-MM-DD';\n    }\n  }\n\n  private getTimePattern(time?: TimeFormatting): string | null {\n    switch (time ?? TimeFormatting.None) {\n      case TimeFormatting.Hour24:\n        return 'HH24:MI';\n      case TimeFormatting.Hour12:\n        return 'HH12:MI AM';\n      default:\n        return null;\n    }\n  }\n\n  private buildDatetimeFormatting(formatting?: Partial<IDatetimeFormatting>): {\n    pattern: string;\n    timeZone: string;\n  } {\n    const datePattern = this.getDatePattern(formatting?.date ?? DateFormattingPreset.ISO);\n    const timePreset = formatting?.time as TimeFormatting | undefined;\n    const timePattern = this.getTimePattern(timePreset);\n    const pattern = (timePattern ? `${datePattern} ${timePattern}` : datePattern).replace(\n      /'/g,\n      \"''\"\n    );\n    const timeZone = (formatting?.timeZone ?? this.context?.timeZone ?? 'UTC').replace(/'/g, \"''\");\n    return { pattern, timeZone };\n  }\n\n  private normalizeAnyToJsonArray(expr: string): string {\n    const base = `(${expr})`;\n    const jsonExpr = `to_jsonb${base}`;\n    return `(CASE\n      WHEN ${base} IS NULL THEN '[]'::jsonb\n      WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN COALESCE(${jsonExpr}, '[]'::jsonb)\n      ELSE jsonb_build_array(${jsonExpr})\n    END)`;\n  }\n\n  private extractFirstScalarFromMultiValue(expr: string): string {\n    const arrayExpr = this.normalizeAnyToJsonArray(expr);\n    return `(SELECT elem #>> '{}'\n      FROM jsonb_array_elements(${arrayExpr}) AS elem\n      WHERE jsonb_typeof(elem) NOT IN ('array','object')\n      LIMIT 1\n    )`;\n  }\n\n  private formatDatetimeOperandForSlice(expr: string, metadataIndex: number): string | null {\n    const paramInfo = this.getParamInfo(metadataIndex);\n    const cellValueType = paramInfo.fieldCellValueType?.toLowerCase();\n    let isDatetimeParam =\n      isDatetimeLikeParam(paramInfo) ||\n      cellValueType === 'datetime' ||\n      paramInfo.fieldDbType === DbFieldType.DateTime;\n\n    let formatting: IDatetimeFormatting | undefined;\n    let timeZoneSource: string | undefined;\n\n    if (paramInfo.hasMetadata) {\n      const fieldId = this.currentCallMetadata?.[metadataIndex]?.field?.id;\n      const field =\n        fieldId && this.context?.table ? this.context.table.getField(fieldId) : undefined;\n      formatting = (field as { options?: { formatting?: IDatetimeFormatting } } | undefined)\n        ?.options?.formatting;\n      timeZoneSource = formatting?.timeZone ?? this.context?.timeZone;\n    } else if (this.context?.table) {\n      const trimmed = this.stripOuterParentheses(expr);\n      const columnMatch = trimmed.match(/^\"[^\"]+\"\\.\"([^\"]+)\"$/) ?? trimmed.match(/^\"([^\"]+)\"$/);\n      const dbName = columnMatch?.[1];\n      if (dbName) {\n        const field =\n          this.context.table.fieldList?.find((item) => item.dbFieldName === dbName) ??\n          this.context.table.fields?.ordered?.find((item) => item.dbFieldName === dbName);\n        if (field?.dbFieldType === DbFieldType.DateTime) {\n          isDatetimeParam = true;\n          formatting = (field as { options?: { formatting?: IDatetimeFormatting } } | undefined)\n            ?.options?.formatting;\n          timeZoneSource = formatting?.timeZone ?? this.context?.timeZone;\n        }\n      }\n    }\n\n    if (!isDatetimeParam) {\n      return null;\n    }\n\n    if (paramInfo.isMultiValueField) {\n      const normalizedArray = this.normalizeAnyToJsonArray(expr);\n      const { pattern, timeZone } = this.buildDatetimeFormatting({\n        ...(formatting ?? {}),\n        timeZone: timeZoneSource ?? this.context?.timeZone ?? 'UTC',\n      });\n      const scalar = `(CASE\n        WHEN jsonb_typeof(elem) = 'object' THEN COALESCE(elem->>'title', elem->>'name', elem #>> '{}')\n        ELSE elem #>> '{}'\n      END)`;\n      const sanitized = this.sanitizeTimestampInput(scalar);\n      const formatted = `TO_CHAR(((${sanitized}))::timestamptz AT TIME ZONE '${timeZone}', '${pattern}')`;\n      return `(SELECT string_agg(${formatted}, ', ' ORDER BY ord)\n        FROM jsonb_array_elements(${normalizedArray}) WITH ORDINALITY AS t(elem, ord)\n      )`;\n    }\n\n    let normalizedExpr = expr;\n    if (paramInfo.isMultiValueField) {\n      normalizedExpr = this.extractFirstScalarFromMultiValue(expr);\n    }\n\n    const { pattern, timeZone } = this.buildDatetimeFormatting({\n      ...(formatting ?? {}),\n      timeZone: timeZoneSource ?? this.context?.timeZone ?? 'UTC',\n    });\n    const sanitized = this.sanitizeTimestampInput(normalizedExpr);\n    return `TO_CHAR((${sanitized})::timestamptz AT TIME ZONE '${timeZone}', '${pattern}')`;\n  }\n\n  private buildSliceOperand(expr: string, metadataIndex: number): string {\n    const formattedDatetime = this.formatDatetimeOperandForSlice(expr, metadataIndex);\n    if (formattedDatetime) {\n      return `(${formattedDatetime})`;\n    }\n    return `(${expr})::text`;\n  }\n  // Numeric Functions\n  sum(params: string[]): string {\n    if (params.length === 0) {\n      return '0';\n    }\n\n    const terms = params.map((param, index) => {\n      const paramInfo = this.getParamInfo(index);\n      if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n        const { sum } = this.buildNumericArrayAggregation(param);\n        return `COALESCE(${sum}, 0)`;\n      }\n      return this.collapseNumeric(param, index);\n    });\n    if (terms.length === 1) {\n      return terms[0];\n    }\n    return `(${terms.join(' + ')})`;\n  }\n\n  average(params: string[]): string {\n    if (params.length === 0) {\n      return '0';\n    }\n    const sumTerms: string[] = [];\n    const countTerms: string[] = [];\n\n    params.forEach((param, index) => {\n      const paramInfo = this.getParamInfo(index);\n      if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n        const { sum, count } = this.buildNumericArrayAggregation(param);\n        sumTerms.push(`COALESCE(${sum}, 0)`);\n        countTerms.push(`COALESCE(${count}, 0)`);\n      } else {\n        const numericValue = this.toNumericSafe(param, index);\n        sumTerms.push(`COALESCE(${numericValue}, 0)`);\n        countTerms.push('1');\n      }\n    });\n\n    const numerator = sumTerms.length === 1 ? sumTerms[0] : `(${sumTerms.join(' + ')})`;\n    const hasDynamicCount = countTerms.some((c) => c !== '1');\n    if (!hasDynamicCount) {\n      return `(${numerator}) / ${params.length}`;\n    }\n    const denominator = countTerms.length === 1 ? countTerms[0] : `(${countTerms.join(' + ')})`;\n    return `(CASE WHEN ${denominator} = 0 THEN NULL ELSE (${numerator}) / ${denominator} END)`;\n  }\n\n  max(params: string[]): string {\n    const mapped = params.map((param, index) => {\n      const paramInfo = this.getParamInfo(index);\n      if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n        return this.buildNumericArrayExtremum(param, 'max');\n      }\n      return this.toNumericSafe(param, index);\n    });\n    return `GREATEST(${this.joinParams(mapped)})`;\n  }\n\n  min(params: string[]): string {\n    const mapped = params.map((param, index) => {\n      const paramInfo = this.getParamInfo(index);\n      if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n        return this.buildNumericArrayExtremum(param, 'min');\n      }\n      return this.toNumericSafe(param, index);\n    });\n    return `LEAST(${this.joinParams(mapped)})`;\n  }\n\n  round(value: string, precision?: string): string {\n    if (precision) {\n      return `ROUND(${value}::numeric, ${precision}::integer)`;\n    }\n    return `ROUND(${value}::numeric)`;\n  }\n\n  roundUp(value: string, precision?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (precision !== undefined) {\n      const numericPrecision = this.toNumericSafe(precision, 1);\n      const factor = `POWER(10, ${numericPrecision}::integer)`;\n      return `CEIL(${numericValue} * ${factor}) / ${factor}`;\n    }\n    return `CEIL(${numericValue})`;\n  }\n\n  roundDown(value: string, precision?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (precision !== undefined) {\n      const numericPrecision = this.toNumericSafe(precision, 1);\n      const factor = `POWER(10, ${numericPrecision}::integer)`;\n      return `FLOOR(${numericValue} * ${factor}) / ${factor}`;\n    }\n    return `FLOOR(${numericValue})`;\n  }\n\n  ceiling(value: string): string {\n    return `CEIL(${this.toNumericSafe(value, 0)})`;\n  }\n\n  floor(value: string): string {\n    return `FLOOR(${this.toNumericSafe(value, 0)})`;\n  }\n\n  even(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    const intValue = `FLOOR(${numericValue})::integer`;\n    return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 0 THEN ${intValue} ELSE ${intValue} + 1 END`;\n  }\n\n  odd(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    const intValue = `FLOOR(${numericValue})::integer`;\n    return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 1 THEN ${intValue} ELSE ${intValue} + 1 END`;\n  }\n\n  int(value: string): string {\n    return `FLOOR(${this.toNumericSafe(value, 0)})`;\n  }\n\n  abs(value: string): string {\n    return `ABS(${this.toNumericSafe(value, 0)})`;\n  }\n\n  sqrt(value: string): string {\n    return `SQRT(${this.toNumericSafe(value, 0)})`;\n  }\n\n  power(base: string, exponent: string): string {\n    const baseValue = this.toNumericSafe(base, 0);\n    const exponentValue = this.toNumericSafe(exponent, 1);\n    return `POWER(${baseValue}, ${exponentValue})`;\n  }\n\n  exp(value: string): string {\n    return `EXP(${this.toNumericSafe(value, 0)})`;\n  }\n\n  log(value: string, base?: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    if (base !== undefined) {\n      const numericBase = this.toNumericSafe(base, 1);\n      const baseLog = `LN(${numericBase})`;\n      return `(LN(${numericValue}) / NULLIF(${baseLog}, 0))`;\n    }\n    return `LN(${numericValue})`;\n  }\n\n  mod(dividend: string, divisor: string): string {\n    const safeDividend = this.toNumericSafe(dividend, 0);\n    const safeDivisor = this.toNumericSafe(divisor, 1);\n    return `(CASE WHEN (${safeDivisor}) IS NULL OR (${safeDivisor}) = 0 THEN NULL ELSE MOD((${safeDividend})::numeric, (${safeDivisor})::numeric)::double precision END)`;\n  }\n\n  value(text: string): string {\n    return this.toNumericSafe(text, 0, { collate: true });\n  }\n\n  // Text Functions\n  concatenate(params: string[]): string {\n    return `CONCAT(${this.joinParams(params.map((p, idx) => this.coerceArrayLikeToText(p, idx)))})`;\n  }\n\n  stringConcat(left: string, right: string): string {\n    return `CONCAT(${this.coerceArrayLikeToText(left, 0)}, ${this.coerceArrayLikeToText(\n      right,\n      1\n    )})`;\n  }\n\n  find(searchText: string, withinText: string, startNum?: string): string {\n    const normalizedSearch = this.ensureTextCollation(searchText);\n    const normalizedWithin = this.ensureTextCollation(withinText);\n\n    if (startNum) {\n      return `POSITION(${normalizedSearch} IN SUBSTRING(${normalizedWithin} FROM ${startNum}::integer)) + ${startNum}::integer - 1`;\n    }\n    return `POSITION(${normalizedSearch} IN ${normalizedWithin})`;\n  }\n\n  search(searchText: string, withinText: string, startNum?: string): string {\n    const normalizedSearch = this.ensureTextCollation(searchText);\n    const normalizedWithin = this.ensureTextCollation(withinText);\n\n    // Similar to find but case-insensitive\n    if (startNum) {\n      return `POSITION(UPPER(${normalizedSearch}) IN UPPER(SUBSTRING(${normalizedWithin} FROM ${startNum}::integer))) + ${startNum}::integer - 1`;\n    }\n    return `POSITION(UPPER(${normalizedSearch}) IN UPPER(${normalizedWithin}))`;\n  }\n\n  mid(text: string, startNum: string, numChars: string): string {\n    const operand = this.buildSliceOperand(text, 0);\n    return `SUBSTRING(${operand} FROM ${startNum}::integer FOR ${numChars}::integer)`;\n  }\n\n  left(text: string, numChars: string): string {\n    const operand = this.buildSliceOperand(text, 0);\n    return `LEFT(${operand}, ${numChars}::integer)`;\n  }\n\n  right(text: string, numChars: string): string {\n    const operand = this.buildSliceOperand(text, 0);\n    return `RIGHT(${operand}, ${numChars}::integer)`;\n  }\n\n  replace(oldText: string, startNum: string, numChars: string, newText: string): string {\n    const source = this.buildSliceOperand(oldText, 0);\n    const replacement = this.buildSliceOperand(newText, 3);\n    return `OVERLAY(${source} PLACING ${replacement} FROM ${startNum}::integer FOR ${numChars}::integer)`;\n  }\n\n  regexpReplace(text: string, pattern: string, replacement: string): string {\n    const source = this.ensureTextCollation(text);\n    const regex = this.ensureTextCollation(pattern);\n    const replacementText = this.ensureTextCollation(replacement);\n    return `REGEXP_REPLACE(${source}, ${regex}, ${replacementText}, 'g')`;\n  }\n\n  substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {\n    const source = this.coerceArrayLikeToText(text, 0);\n    const search = this.coerceArrayLikeToText(oldText, 1);\n    const replacement = this.coerceArrayLikeToText(newText, 2);\n    if (instanceNum) {\n      // PostgreSQL doesn't have direct support for replacing specific instance\n      // This is a simplified implementation\n      return `REPLACE(${source}, ${search}, ${replacement})`;\n    }\n    return `REPLACE(${source}, ${search}, ${replacement})`;\n  }\n\n  lower(text: string): string {\n    const operand = this.coerceArrayLikeToText(text, 0);\n    return `LOWER(${operand})`;\n  }\n\n  upper(text: string): string {\n    const operand = this.coerceArrayLikeToText(text, 0);\n    return `UPPER(${operand})`;\n  }\n\n  rept(text: string, numTimes: string): string {\n    const operand = this.coerceArrayLikeToText(text, 0);\n    return `REPEAT(${operand}, ${numTimes}::integer)`;\n  }\n\n  trim(text: string): string {\n    const operand = this.coerceArrayLikeToText(text, 0);\n    return `TRIM(${operand})`;\n  }\n\n  len(text: string): string {\n    // Cast to text to avoid calling LENGTH() on numeric types (e.g., auto-number)\n    const operand = this.ensureTextCollation(this.coerceToTextComparable(text, 0));\n    return `LENGTH(${operand})`;\n  }\n\n  t(value: string): string {\n    return `CASE WHEN ${value} IS NULL THEN '' ELSE ${value}::text END`;\n  }\n\n  encodeUrlComponent(text: string): string {\n    const textExpr = `(${text})::text`;\n    const encodedSql = `(SELECT string_agg(\n      CASE\n        WHEN byte_val BETWEEN 48 AND 57\n          OR byte_val BETWEEN 65 AND 90\n          OR byte_val BETWEEN 97 AND 122\n          OR byte_val IN (45, 95, 46, 33, 126, 42, 39, 40, 41)\n        THEN chr(byte_val)\n        ELSE '%' || UPPER(LPAD(to_hex(byte_val), 2, '0'))\n      END,\n      ''\n      ORDER BY ord\n    )\n    FROM (\n      SELECT ord, get_byte(src.bytes, ord) AS byte_val\n      FROM (SELECT convert_to(${textExpr}, 'UTF8') AS bytes) AS src\n      CROSS JOIN generate_series(0, octet_length(src.bytes) - 1) AS ord\n    ) AS utf8_bytes)`;\n\n    return `(CASE WHEN ${text} IS NULL THEN NULL ELSE COALESCE(${encodedSql}, '') END)`;\n  }\n\n  // DateTime Functions - These can use mutable functions in SELECT context\n  now(): string {\n    return `NOW()`;\n  }\n\n  today(): string {\n    return `CURRENT_DATE`;\n  }\n\n  dateAdd(date: string, count: string, unit: string): string {\n    const { unit: cleanUnit, factor } = this.normalizeIntervalUnit(unit.replace(/^'|'$/g, ''));\n    const countExpr = `(${count})`;\n    const scaledCount = factor === 1 ? `${countExpr}` : `${countExpr} * ${factor}`;\n    const tsExpr = this.tzWrap(date, 0);\n    if (cleanUnit === 'quarter') {\n      return `${tsExpr} + (${scaledCount}) * INTERVAL '1 month'`;\n    }\n    return `${tsExpr} + (${scaledCount}) * INTERVAL '1 ${cleanUnit}'`;\n  }\n\n  datestr(date: string): string {\n    return `(${this.tzWrap(date, 0)})::date::text`;\n  }\n\n  private buildMonthDiff(startDate: string, endDate: string): string {\n    const startExpr = this.tzWrap(startDate, 0);\n    const endExpr = this.tzWrap(endDate, 1);\n    const startYear = `EXTRACT(YEAR FROM ${startExpr})`;\n    const endYear = `EXTRACT(YEAR FROM ${endExpr})`;\n    const startMonth = `EXTRACT(MONTH FROM ${startExpr})`;\n    const endMonth = `EXTRACT(MONTH FROM ${endExpr})`;\n    const startDay = `EXTRACT(DAY FROM ${startExpr})`;\n    const endDay = `EXTRACT(DAY FROM ${endExpr})`;\n    const startLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${startExpr}) + INTERVAL '1 month - 1 day'))`;\n    const endLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${endExpr}) + INTERVAL '1 month - 1 day'))`;\n\n    const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;\n    const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;\n    const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;\n\n    return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;\n  }\n\n  datetimeDiff(startDate: string, endDate: string, unit: string): string {\n    const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));\n    const diffSeconds = `EXTRACT(EPOCH FROM (${this.tzWrap(startDate, 0)} - ${this.tzWrap(\n      endDate,\n      1\n    )}))`;\n    switch (diffUnit) {\n      case 'millisecond':\n        return `(${diffSeconds}) * 1000`;\n      case 'second':\n        return `(${diffSeconds})`;\n      case 'minute':\n        return `(${diffSeconds}) / 60`;\n      case 'hour':\n        return `(${diffSeconds}) / 3600`;\n      case 'week':\n        return `(${diffSeconds}) / (86400 * 7)`;\n      case 'month':\n        return this.buildMonthDiff(startDate, endDate);\n      case 'quarter':\n        return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(startDate, endDate);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `(${diffSeconds}) / 86400`;\n    }\n  }\n\n  datetimeFormat(date: string, format: string): string {\n    const timestampExpr = this.tzWrap(date, 0);\n    return buildDatetimeFormatSql(\n      timestampExpr,\n      format,\n      this.buildTimezoneOffsetSql(timestampExpr)\n    );\n  }\n\n  datetimeParse(dateString: string, format?: string): string {\n    const valueExpr = `(${dateString})`;\n    const trustedDatetimeInput = this.hasTrustedDatetimeInput(0);\n\n    if (format == null) {\n      return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);\n    }\n    const trimmedFormat = format.trim();\n    if (!trimmedFormat || trimmedFormat === 'undefined' || trimmedFormat.toLowerCase() === 'null') {\n      return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);\n    }\n    if (trustedDatetimeInput) {\n      const localTimestampExpr = this.tzWrap(valueExpr, 0);\n      const formattedExpr = buildDatetimeFormatSql(\n        localTimestampExpr,\n        trimmedFormat,\n        this.buildTimezoneOffsetSql(localTimestampExpr)\n      );\n      return this.parseDatetimeParseWithFormat(formattedExpr, trimmedFormat);\n    }\n\n    return this.parseDatetimeParseWithFormat(`${valueExpr}::text`, trimmedFormat, valueExpr);\n  }\n\n  day(date: string): string {\n    return `EXTRACT(DAY FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {\n    const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));\n    const diffSeconds = `EXTRACT(EPOCH FROM (${nowExpr} - ${dateExpr}))`;\n    const diffMonths = `EXTRACT(MONTH FROM AGE(${nowExpr}, ${dateExpr})) + EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr})) * 12`;\n    const diffYears = `EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr}))`;\n    switch (diffUnit) {\n      case 'millisecond':\n        return `(${diffSeconds}) * 1000`;\n      case 'second':\n        return `(${diffSeconds})`;\n      case 'minute':\n        return `(${diffSeconds}) / 60`;\n      case 'hour':\n        return `(${diffSeconds}) / 3600`;\n      case 'week':\n        return `(${diffSeconds}) / (86400 * 7)`;\n      case 'month':\n        return diffMonths;\n      case 'quarter':\n        return `(${diffMonths}) / 3.0`;\n      case 'year':\n        return diffYears;\n      case 'day':\n      default:\n        return `(${diffSeconds}) / 86400`;\n    }\n  }\n\n  fromNow(date: string, unit = 'day'): string {\n    const tz = this.context?.timeZone?.replace(/'/g, \"''\");\n    if (tz) {\n      return this.buildNowDiffByUnit(`(NOW() AT TIME ZONE '${tz}')`, this.tzWrap(date, 0), unit);\n    }\n    return this.buildNowDiffByUnit('NOW()', `${date}::timestamp`, unit);\n  }\n\n  hour(date: string): string {\n    return `EXTRACT(HOUR FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  isAfter(date1: string, date2: string): string {\n    return `${this.tzWrap(date1, 0)} > ${this.tzWrap(date2, 1)}`;\n  }\n\n  isBefore(date1: string, date2: string): string {\n    return `${this.tzWrap(date1, 0)} < ${this.tzWrap(date2, 1)}`;\n  }\n\n  isSame(date1: string, date2: string, unit?: string): string {\n    if (unit) {\n      const trimmed = unit.trim();\n      if (trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\")) {\n        const literal = trimmed.slice(1, -1);\n        const normalizedUnit = this.normalizeTruncateUnit(literal);\n        const safeUnit = normalizedUnit.replace(/'/g, \"''\");\n        return `DATE_TRUNC('${safeUnit}', ${this.tzWrap(date1, 0)}) = DATE_TRUNC('${safeUnit}', ${this.tzWrap(date2, 1)})`;\n      }\n      return `DATE_TRUNC(${unit}, ${this.tzWrap(date1, 0)}) = DATE_TRUNC(${unit}, ${this.tzWrap(\n        date2,\n        1\n      )})`;\n    }\n    return `${this.tzWrap(date1, 0)} = ${this.tzWrap(date2, 1)}`;\n  }\n\n  lastModifiedTime(): string {\n    // This would typically reference a system column\n    return this.qualifySystemColumn('__last_modified_time');\n  }\n\n  minute(date: string): string {\n    return `EXTRACT(MINUTE FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  month(date: string): string {\n    return `EXTRACT(MONTH FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  second(date: string): string {\n    return `EXTRACT(SECOND FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  timestr(date: string): string {\n    return `(${this.tzWrap(date, 0)})::time::text`;\n  }\n\n  toNow(date: string, unit = 'day'): string {\n    return this.fromNow(date, unit);\n  }\n\n  weekNum(date: string): string {\n    return `EXTRACT(WEEK FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  weekday(date: string, startDayOfWeek?: string): string {\n    const weekdaySql = `EXTRACT(DOW FROM ${this.tzWrap(date, 0)})::int`;\n    if (!startDayOfWeek) {\n      return weekdaySql;\n    }\n\n    const normalizedStartDay = `LOWER(BTRIM(COALESCE((${startDayOfWeek})::text, '')))`;\n    return `CASE WHEN ${normalizedStartDay} = 'monday' THEN ((${weekdaySql} + 6) % 7) ELSE ${weekdaySql} END`;\n  }\n\n  workday(startDate: string, days: string, holidayStr?: string): string {\n    if (!this.isDateLikeOperand(0)) {\n      return 'NULL';\n    }\n    const startDateSql = `(${this.tzWrap(startDate, 0)})::date`;\n    const dayCountSql = `COALESCE((${this.toNumericSafe(days, 1)})::integer, 0)`;\n    const holidayTextSql = holidayStr ? `COALESCE((${holidayStr})::text, '')` : `''`;\n\n    return `(\n      WITH params AS (\n        SELECT ${startDateSql} AS start_date, ${dayCountSql} AS day_count, ${holidayTextSql} AS holiday_text\n      ),\n      holiday_parts AS (\n        SELECT BTRIM(part) AS holiday_part\n        FROM params p\n        CROSS JOIN LATERAL regexp_split_to_table(p.holiday_text, ',') AS part\n      ),\n      holiday_dates AS (\n        SELECT DISTINCT TO_DATE(LEFT(holiday_part, 10), 'YYYY-MM-DD') AS holiday_date\n        FROM holiday_parts\n        WHERE holiday_part <> ''\n          AND holiday_part ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'\n          AND TO_CHAR(TO_DATE(LEFT(holiday_part, 10), 'YYYY-MM-DD'), 'YYYY-MM-DD') = LEFT(holiday_part, 10)\n      ),\n      candidates AS (\n        SELECT\n          (p.start_date + CASE WHEN p.day_count >= 0 THEN seq.n ELSE -seq.n END)::date AS candidate_date,\n          seq.n\n        FROM params p\n        CROSS JOIN LATERAL generate_series(1, ABS(p.day_count) * 7 + 366) AS seq(n)\n      ),\n      workdays AS (\n        SELECT c.candidate_date, c.n\n        FROM candidates c\n        LEFT JOIN holiday_dates h ON h.holiday_date = c.candidate_date\n        WHERE EXTRACT(DOW FROM c.candidate_date)::int NOT IN (0, 6)\n          AND h.holiday_date IS NULL\n        ORDER BY c.n\n      )\n      SELECT CASE\n        WHEN p.day_count = 0 THEN p.start_date::timestamp\n        ELSE (\n          SELECT w.candidate_date::timestamp\n          FROM workdays w\n          OFFSET ABS(p.day_count) - 1\n          LIMIT 1\n        )\n      END\n      FROM params p\n    )`;\n  }\n\n  workdayDiff(startDate: string, endDate: string): string {\n    if (!this.isDateLikeOperand(0) || !this.isDateLikeOperand(1)) {\n      return 'NULL';\n    }\n    // Simplified implementation with timezone-aware, sanitized inputs\n    const start = `(${this.tzWrap(startDate, 0)})`;\n    const end = `(${this.tzWrap(endDate, 1)})`;\n    return `${end}::date - ${start}::date`;\n  }\n\n  year(date: string): string {\n    return `EXTRACT(YEAR FROM ${this.tzWrap(date, 0)})::int`;\n  }\n\n  createdTime(): string {\n    // This would typically reference a system column\n    return this.qualifySystemColumn('__created_time');\n  }\n\n  // Logical Functions\n  private truthinessScore(value: string, metadataIndex?: number): string {\n    const normalizedValue = this.stripOuterParentheses(value);\n    const wrapped = `(${normalizedValue})`;\n    const paramInfo = this.getParamInfo(metadataIndex);\n\n    if (isBooleanLikeParam(paramInfo)) {\n      // Prefer the simplest form when the operand is a real boolean column to keep generated SQL\n      // readable and stable for tests; otherwise cast to boolean to avoid COALESCE type errors\n      // when the operand is boolean-ish text (e.g. 'true'/'false') in raw projection contexts.\n      const boolExpr =\n        paramInfo.isFieldReference && paramInfo.fieldDbType === DbFieldType.Boolean\n          ? wrapped\n          : `${wrapped}::boolean`;\n      return `CASE WHEN COALESCE(${boolExpr}, FALSE) THEN 1 ELSE 0 END`;\n    }\n\n    if (\n      paramInfo?.isJsonField ||\n      paramInfo?.isMultiValueField ||\n      paramInfo?.fieldDbType === DbFieldType.Json\n    ) {\n      return `CASE\n        WHEN ${wrapped} IS NULL THEN 0\n        WHEN (${wrapped})::text IN ('null', '[]', '{}', '') THEN 0\n        ELSE 1\n      END`;\n    }\n\n    if (isTrustedNumeric(paramInfo)) {\n      const numericExpr = this.toNumericSafe(normalizedValue, metadataIndex);\n      return `CASE WHEN COALESCE(${numericExpr}, 0) <> 0 THEN 1 ELSE 0 END`;\n    }\n\n    const conditionType = `pg_typeof${wrapped}::text`;\n    const numericTypes = \"('smallint','integer','bigint','numeric','double precision','real')\";\n    const wrappedText = `(${wrapped})::text`;\n    const booleanTruthyScore = `CASE WHEN LOWER(${wrappedText}) IN ('t','true','1') THEN 1 ELSE 0 END`;\n    const numericTruthyScore = `CASE WHEN ${wrappedText} ~ '^\\\\s*[+-]{0,1}0*(\\\\.0*){0,1}\\\\s*$' THEN 0 ELSE 1 END`;\n    const fallbackTruthyScore = `CASE\n      WHEN COALESCE(${wrappedText}, '') = '' THEN 0\n      WHEN LOWER(${wrappedText}) = 'null' THEN 0\n      ELSE 1\n    END`;\n    return `CASE\n      WHEN ${wrapped} IS NULL THEN 0\n      WHEN ${conditionType} = 'boolean' THEN ${booleanTruthyScore}\n      WHEN ${conditionType} IN ${numericTypes} THEN ${numericTruthyScore}\n      ELSE ${fallbackTruthyScore}\n    END`;\n  }\n\n  if(condition: string, valueIfTrue: string, valueIfFalse: string): string {\n    const truthinessScore = this.truthinessScore(condition, 0);\n    const trueIsBlank = this.isEmptyStringLiteral(valueIfTrue) || this.isNullLiteral(valueIfTrue);\n    const falseIsBlank =\n      this.isEmptyStringLiteral(valueIfFalse) || this.isNullLiteral(valueIfFalse);\n    const targetType = (this.context as ISelectFormulaConversionContext | undefined)\n      ?.targetDbFieldType;\n    const resultIsDatetime =\n      targetType === DbFieldType.DateTime || this.isDateLikeOperand(1) || this.isDateLikeOperand(2);\n    if (resultIsDatetime) {\n      const trueBranch = trueIsBlank ? 'NULL' : this.tzWrap(valueIfTrue, 1);\n      const falseBranch = falseIsBlank ? 'NULL' : this.tzWrap(valueIfFalse, 2);\n      return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranch} ELSE ${falseBranch} END`;\n    }\n    const trueIsText = this.isTextLikeExpression(valueIfTrue, 1);\n    const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);\n    const trueIsHardText = this.isHardTextExpression(valueIfTrue);\n    const falseIsHardText = this.isHardTextExpression(valueIfFalse);\n    const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);\n    const numericWithBlank =\n      (trueIsBlank && !falseIsHardText && !falseIsText) ||\n      (falseIsBlank && !trueIsHardText && !trueIsText);\n    if (numericWithBlank) {\n      const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);\n      const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);\n      return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;\n    }\n    const targetIsNumeric = targetType === DbFieldType.Real || targetType === DbFieldType.Integer;\n    const hasNumericBranch =\n      this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);\n    if (targetIsNumeric || (hasNumericBranch && !hasTextBranch)) {\n      const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);\n      const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);\n      return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;\n    }\n    const blankPresent = trueIsBlank || falseIsBlank;\n    const hasTextAfterBlank = blankPresent ? false : hasTextBranch;\n    const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;\n    const trueBranch = hasTextAfterBlank\n      ? this.coerceToTextComparable(valueIfTrue, 1)\n      : trueIsBlank && normalizeBlankAsNull\n        ? 'NULL'\n        : valueIfTrue;\n    const falseBranch = hasTextAfterBlank\n      ? this.coerceToTextComparable(valueIfFalse, 2)\n      : falseIsBlank && normalizeBlankAsNull\n        ? 'NULL'\n        : valueIfFalse;\n    return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranch} ELSE ${falseBranch} END`;\n  }\n\n  and(params: string[]): string {\n    return `(${params.map((p) => `(${p})`).join(' AND ')})`;\n  }\n\n  or(params: string[]): string {\n    return `(${params.map((p) => `(${p})`).join(' OR ')})`;\n  }\n\n  not(value: string): string {\n    return `NOT (${value})`;\n  }\n\n  xor(params: string[]): string {\n    // PostgreSQL doesn't have XOR, implement using AND/OR logic\n    if (params.length === 2) {\n      return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;\n    }\n    // For multiple params, use modulo approach\n    return `(${params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`).join(' + ')}) % 2 = 1`;\n  }\n\n  blank(): string {\n    return 'NULL';\n  }\n\n  error(_message: string): string {\n    // In SELECT context, we can use functions that raise errors\n    return `(SELECT pg_catalog.pg_advisory_unlock_all() WHERE FALSE)`;\n  }\n\n  isError(_value: string): string {\n    // Check if value would cause an error - simplified implementation\n    return `FALSE`;\n  }\n\n  switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string {\n    const hasTextResult =\n      cases.some((c) => this.isTextLikeExpression(c.result)) ||\n      (defaultResult ? this.isTextLikeExpression(defaultResult) : false);\n\n    const normalizeResult = (value: string) =>\n      hasTextResult ? this.coerceToTextComparable(value) : value;\n\n    const normalizeCaseValue = (value: string) =>\n      hasTextResult ? this.coerceToTextComparable(value) : value;\n\n    const baseExpr = hasTextResult ? this.coerceToTextComparable(expression, 0) : expression;\n    let sql = `CASE ${baseExpr}`;\n    for (const caseItem of cases) {\n      sql += ` WHEN ${normalizeCaseValue(caseItem.case)} THEN ${normalizeResult(caseItem.result)}`;\n    }\n    if (defaultResult) {\n      sql += ` ELSE ${normalizeResult(defaultResult)}`;\n    }\n    sql += ` END`;\n    return sql;\n  }\n\n  // Array Functions - More flexible in SELECT context\n  count(params: string[]): string {\n    const countChecks = params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`);\n    return `(${countChecks.join(' + ')})`;\n  }\n\n  countA(params: string[]): string {\n    const blankAwareChecks = params.map((p, index) => this.countANonNullExpression(p, index));\n    return `(${blankAwareChecks.join(' + ')})`;\n  }\n\n  countAll(value: string): string {\n    const paramInfo = this.getParamInfo(0);\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      const baseExpr =\n        paramInfo.isFieldReference && paramInfo.fieldDbName\n          ? this.tableAlias\n            ? `\"${this.tableAlias}\".\"${paramInfo.fieldDbName}\"`\n            : `\"${paramInfo.fieldDbName}\"`\n          : value;\n      const normalized = `COALESCE(NULLIF((${baseExpr})::jsonb, 'null'::jsonb), '[]'::jsonb)`;\n      return `(CASE\n        WHEN jsonb_typeof(${normalized}) = 'array' THEN jsonb_array_length(${normalized})\n        ELSE 1\n      END)`;\n    }\n\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  private normalizeJsonbArray(array: string): string {\n    return `(\n      CASE\n        WHEN ${array} IS NULL THEN '[]'::jsonb\n        WHEN jsonb_typeof(to_jsonb(${array})) = 'array' THEN to_jsonb(${array})\n        ELSE jsonb_build_array(to_jsonb(${array}))\n      END\n    )`;\n  }\n\n  private buildJsonbArrayUnion(\n    arrays: string[],\n    opts?: { filterNulls?: boolean; withOrdinal?: boolean }\n  ): string {\n    const selects = arrays.map((array, index) => {\n      const normalizedArray = this.normalizeJsonbArray(array);\n      const whereClause = opts?.filterNulls\n        ? \" WHERE elem.value IS NOT NULL AND elem.value != 'null' AND elem.value != ''\"\n        : '';\n      const ordinality = opts?.withOrdinal ? ', ord' : '';\n      return `SELECT elem.value, ${index} AS arg_index${ordinality}\n        FROM jsonb_array_elements_text(${normalizedArray}) WITH ORDINALITY AS elem(value, ord)${whereClause}`;\n    });\n\n    if (selects.length === 0) {\n      return 'SELECT NULL::text AS value, 0 AS arg_index, 0 AS ord WHERE FALSE';\n    }\n\n    return selects.join(' UNION ALL ');\n  }\n\n  arrayJoin(array: string, separator?: string): string {\n    const sep = separator || `','`;\n    const normalizedArray = this.normalizeJsonbArray(array);\n    return `(\n      SELECT string_agg(\n        elem.value,\n        ${sep}\n      )\n      FROM jsonb_array_elements_text(${normalizedArray}) AS elem(value)\n    )`;\n  }\n\n  arrayUnique(arrays: string[]): string {\n    const unionQuery = this.buildJsonbArrayUnion(arrays, { withOrdinal: true });\n    return `ARRAY(\n      SELECT DISTINCT ON (value) value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY value, arg_index, ord\n    )`;\n  }\n\n  arrayFlatten(arrays: string[]): string {\n    const unionQuery = this.buildJsonbArrayUnion(arrays, { withOrdinal: true });\n    return `ARRAY(\n      SELECT value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY arg_index, ord\n    )`;\n  }\n\n  arrayCompact(arrays: string[]): string {\n    const unionQuery = this.buildJsonbArrayUnion(arrays, { filterNulls: true, withOrdinal: true });\n    return `ARRAY(\n      SELECT value\n      FROM (${unionQuery}) AS combined(value, arg_index, ord)\n      ORDER BY arg_index, ord\n    )`;\n  }\n\n  // System Functions\n  recordId(): string {\n    // This would typically reference the primary key\n    return this.qualifySystemColumn('__id');\n  }\n\n  autoNumber(): string {\n    // This would typically reference an auto-increment column\n    return this.qualifySystemColumn('__auto_number');\n  }\n\n  textAll(value: string): string {\n    return `${value}::text`;\n  }\n\n  // Binary Operations\n  add(left: string, right: string): string {\n    const leftIsDate = this.isDateLikeOperand(0);\n    const rightIsDate = this.isDateLikeOperand(1);\n\n    if (leftIsDate && !rightIsDate) {\n      return `(${this.tzWrap(left, 0)} + ${this.buildDayInterval(right, 1)})`;\n    }\n\n    if (!leftIsDate && rightIsDate) {\n      return `(${this.tzWrap(right, 1)} + ${this.buildDayInterval(left, 0)})`;\n    }\n\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) + (${r}))`;\n  }\n\n  subtract(left: string, right: string): string {\n    const leftIsDate = this.isDateLikeOperand(0);\n    const rightIsDate = this.isDateLikeOperand(1);\n\n    if (leftIsDate && !rightIsDate) {\n      return `(${this.tzWrap(left, 0)} - ${this.buildDayInterval(right, 1)})`;\n    }\n\n    if (leftIsDate && rightIsDate) {\n      return `(EXTRACT(EPOCH FROM (${this.tzWrap(left, 0)} - ${this.tzWrap(right, 1)})) / 86400)`;\n    }\n\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) - (${r}))`;\n  }\n\n  multiply(left: string, right: string): string {\n    const l = this.collapseNumeric(left, 0);\n    const r = this.collapseNumeric(right, 1);\n    return `((${l}) * (${r}))`;\n  }\n\n  divide(left: string, right: string): string {\n    const numerator = this.collapseNumeric(left, 0);\n    const denominator = this.toNumericSafe(right, 1);\n    return `(CASE WHEN (${denominator}) IS NULL OR (${denominator}) = 0 THEN NULL ELSE (${numerator} / ${denominator}) END)`;\n  }\n\n  modulo(left: string, right: string): string {\n    const dividend = this.collapseNumeric(left, 0);\n    const divisor = this.toNumericSafe(right, 1);\n    return `(CASE WHEN (${divisor}) IS NULL OR (${divisor}) = 0 THEN NULL ELSE MOD((${dividend})::numeric, (${divisor})::numeric)::double precision END)`;\n  }\n\n  // Comparison Operations\n  equal(left: string, right: string): string {\n    return this.buildBlankAwareComparison('=', left, right, { left: 0, right: 1 });\n  }\n\n  notEqual(left: string, right: string): string {\n    return this.buildBlankAwareComparison('<>', left, right, { left: 0, right: 1 });\n  }\n\n  greaterThan(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} > ${normalizedRight})`;\n  }\n\n  lessThan(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} < ${normalizedRight})`;\n  }\n\n  greaterThanOrEqual(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} >= ${normalizedRight})`;\n  }\n\n  lessThanOrEqual(left: string, right: string): string {\n    const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);\n    const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);\n    return `(${normalizedLeft} <= ${normalizedRight})`;\n  }\n\n  // Logical Operations\n  logicalAnd(left: string, right: string): string {\n    return `(${left} AND ${right})`;\n  }\n\n  logicalOr(left: string, right: string): string {\n    return `(${left} OR ${right})`;\n  }\n\n  bitwiseAnd(left: string, right: string): string {\n    // Handle cases where operands might not be valid integers\n    // Use COALESCE and NULLIF to safely convert to integer, defaulting to 0 for invalid values\n    return `(\n      COALESCE(\n        CASE\n          WHEN ${left}::text ~ '^-?[0-9]+$' THEN\n            NULLIF(${left}::text, '')::integer\n          ELSE NULL\n        END,\n        0\n      ) &\n      COALESCE(\n        CASE\n          WHEN ${right}::text ~ '^-?[0-9]+$' THEN\n            NULLIF(${right}::text, '')::integer\n          ELSE NULL\n        END,\n        0\n      )\n    )`;\n  }\n\n  // Unary Operations\n  unaryMinus(value: string): string {\n    const numericValue = this.toNumericSafe(value, 0);\n    return `(-(${numericValue}))`;\n  }\n\n  // Field Reference\n  fieldReference(_fieldId: string, columnName: string): string {\n    return `\"${columnName}\"`;\n  }\n\n  // Literals\n  stringLiteral(value: string): string {\n    return `'${value.replace(/'/g, \"''\")}'`;\n  }\n\n  numberLiteral(value: number): string {\n    return value.toString();\n  }\n\n  booleanLiteral(value: boolean): string {\n    return value ? 'TRUE' : 'FALSE';\n  }\n\n  nullLiteral(): string {\n    return 'NULL';\n  }\n\n  // Utility methods for type conversion and validation\n  castToNumber(value: string): string {\n    return `${value}::numeric`;\n  }\n\n  castToString(value: string): string {\n    return `${value}::text`;\n  }\n\n  castToBoolean(value: string): string {\n    return `${value}::boolean`;\n  }\n\n  castToDate(value: string): string {\n    return `${value}::timestamp`;\n  }\n\n  // Handle null values and type checking\n  isNull(value: string): string {\n    return `${value} IS NULL`;\n  }\n\n  coalesce(params: string[]): string {\n    return `COALESCE(${this.joinParams(params)})`;\n  }\n\n  // Parentheses for grouping\n  parentheses(expression: string): string {\n    return `(${expression})`;\n  }\n\n  private guardDefaultDatetimeParse(valueExpr: string): string {\n    const textExpr = `${valueExpr}::text`;\n    const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;\n    const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;\n    const pattern = getDefaultDatetimeParsePattern();\n    return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} ~ '${pattern}' THEN ${valueExpr} ELSE NULL END)`;\n  }\n\n  private parseDatetimeParseWithoutFormat(valueExpr: string): string {\n    const textExpr = `${valueExpr}::text`;\n    const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;\n    const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;\n    const pattern = getDefaultDatetimeParsePattern();\n    const hasClockTime = `(${sanitizedExpr} ~ '[ T][0-9]{1,2}:[0-9]{2}')`;\n    const hasExplicitTimeZone = `(${sanitizedExpr} ~* '(Z|[+-][0-9]{2}:[0-9]{2}|[+-][0-9]{4}|[+-][0-9]{2})$')`;\n    const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, \"''\");\n    const localTimestampExpr = `(${sanitizedExpr})::timestamp AT TIME ZONE '${safeTz}'`;\n    const explicitZoneExpr = `(${sanitizedExpr})::timestamptz`;\n\n    return `(CASE\n      WHEN ${valueExpr} IS NULL THEN NULL\n      WHEN ${sanitizedExpr} IS NULL THEN NULL\n      WHEN ${sanitizedExpr} ~ '${pattern}' THEN\n        (CASE\n          WHEN ${hasClockTime} AND NOT ${hasExplicitTimeZone} THEN ${localTimestampExpr}\n          ELSE ${explicitZoneExpr}\n        END)\n      ELSE NULL\n    END)`;\n  }\n\n  private parseDatetimeParseWithFormat(\n    textExpr: string,\n    formatExpr: string,\n    nullGuardExpr: string = textExpr\n  ): string {\n    const normalizedFormat = normalizeDatetimeFormatExpression(formatExpr);\n    const toTimestampExpr = `TO_TIMESTAMP(${textExpr}::text, ${normalizedFormat})`;\n    const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, \"''\");\n    const hasTimezoneToken = hasDatetimeTimezoneToken(formatExpr);\n    const parsedExpr =\n      hasTimezoneToken === false\n        ? `(${toTimestampExpr})::timestamp AT TIME ZONE '${safeTz}'`\n        : toTimestampExpr;\n    const guardPattern = buildDatetimeParseGuardRegex(formatExpr);\n    if (!guardPattern) {\n      return parsedExpr;\n    }\n    const escapedPattern = guardPattern.replace(/'/g, \"''\");\n    return `(CASE WHEN ${nullGuardExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${parsedExpr} ELSE NULL END)`;\n  }\n\n  private hasTrustedDatetimeInput(index: number): boolean {\n    const paramInfo = this.getParamInfo(index);\n    if (!paramInfo.hasMetadata) {\n      return false;\n    }\n    if (!isDatetimeLikeParam(paramInfo)) {\n      return false;\n    }\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      return false;\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/select-query.abstract.ts",
    "content": "import type { IFormulaParamMetadata } from '@teable/core';\nimport type {\n  ISelectQueryInterface,\n  IFormulaConversionContext,\n} from '../../features/record/query-builder/sql-conversion.visitor';\n\n/**\n * Abstract base class for SELECT query implementations\n * Provides common functionality and default implementations for converting\n * Teable formula expressions to database-specific SQL suitable for SELECT statements\n *\n * Unlike generated columns, SELECT queries can:\n * - Use mutable functions (NOW(), RANDOM(), etc.)\n * - Have different performance characteristics\n * - Support more complex expressions that might not be allowed in generated columns\n * - Use subqueries and window functions more freely\n */\nexport abstract class SelectQueryAbstract implements ISelectQueryInterface {\n  /** Current conversion context */\n  protected context?: IFormulaConversionContext;\n  protected currentCallMetadata?: IFormulaParamMetadata[];\n\n  /** Set the conversion context */\n  setContext(context: IFormulaConversionContext): void {\n    this.context = context;\n  }\n\n  setCallMetadata(metadata?: IFormulaParamMetadata[]): void {\n    this.currentCallMetadata = metadata;\n  }\n\n  /** Check if we're in a SELECT query context (always true for this class) */\n  protected get isSelectQueryContext(): boolean {\n    return true;\n  }\n\n  /** Helper method to join parameters with commas */\n  protected joinParams(params: string[]): string {\n    return params.join(', ');\n  }\n\n  /** Helper method to wrap expression in parentheses if needed */\n  protected wrapInParentheses(expression: string): string {\n    return `(${expression})`;\n  }\n\n  /** Helper method to handle null values in expressions */\n  protected handleNullValue(expression: string, defaultValue: string = 'NULL'): string {\n    return `COALESCE(${expression}, ${defaultValue})`;\n  }\n\n  // Numeric Functions\n  abstract sum(params: string[]): string;\n  abstract average(params: string[]): string;\n  abstract max(params: string[]): string;\n  abstract min(params: string[]): string;\n  abstract round(value: string, precision?: string): string;\n  abstract roundUp(value: string, precision?: string): string;\n  abstract roundDown(value: string, precision?: string): string;\n  abstract ceiling(value: string): string;\n  abstract floor(value: string): string;\n  abstract even(value: string): string;\n  abstract odd(value: string): string;\n  abstract int(value: string): string;\n  abstract abs(value: string): string;\n  abstract sqrt(value: string): string;\n  abstract power(base: string, exponent: string): string;\n  abstract exp(value: string): string;\n  abstract log(value: string, base?: string): string;\n  abstract mod(dividend: string, divisor: string): string;\n  abstract value(text: string): string;\n\n  // Text Functions\n  abstract concatenate(params: string[]): string;\n  abstract stringConcat(left: string, right: string): string;\n  abstract find(searchText: string, withinText: string, startNum?: string): string;\n  abstract search(searchText: string, withinText: string, startNum?: string): string;\n  abstract mid(text: string, startNum: string, numChars: string): string;\n  abstract left(text: string, numChars: string): string;\n  abstract right(text: string, numChars: string): string;\n  abstract replace(oldText: string, startNum: string, numChars: string, newText: string): string;\n  abstract regexpReplace(text: string, pattern: string, replacement: string): string;\n  abstract substitute(text: string, oldText: string, newText: string, instanceNum?: string): string;\n  abstract lower(text: string): string;\n  abstract upper(text: string): string;\n  abstract rept(text: string, numTimes: string): string;\n  abstract trim(text: string): string;\n  abstract len(text: string): string;\n  abstract t(value: string): string;\n  abstract encodeUrlComponent(text: string): string;\n\n  // DateTime Functions\n  abstract now(): string;\n  abstract today(): string;\n  abstract dateAdd(date: string, count: string, unit: string): string;\n  abstract datestr(date: string): string;\n  abstract datetimeDiff(startDate: string, endDate: string, unit: string): string;\n  abstract datetimeFormat(date: string, format: string): string;\n  abstract datetimeParse(dateString: string, format?: string): string;\n  abstract day(date: string): string;\n  abstract fromNow(date: string, unit?: string): string;\n  abstract hour(date: string): string;\n  abstract isAfter(date1: string, date2: string): string;\n  abstract isBefore(date1: string, date2: string): string;\n  abstract isSame(date1: string, date2: string, unit?: string): string;\n  abstract lastModifiedTime(): string;\n  abstract minute(date: string): string;\n  abstract month(date: string): string;\n  abstract second(date: string): string;\n  abstract timestr(date: string): string;\n  abstract toNow(date: string, unit?: string): string;\n  abstract weekNum(date: string): string;\n  abstract weekday(date: string, startDayOfWeek?: string): string;\n  abstract workday(startDate: string, days: string, holidayStr?: string): string;\n  abstract workdayDiff(startDate: string, endDate: string): string;\n  abstract year(date: string): string;\n  abstract createdTime(): string;\n\n  // Logical Functions\n  abstract if(condition: string, valueIfTrue: string, valueIfFalse: string): string;\n  abstract and(params: string[]): string;\n  abstract or(params: string[]): string;\n  abstract not(value: string): string;\n  abstract xor(params: string[]): string;\n  abstract blank(): string;\n  abstract error(message: string): string;\n  abstract isError(value: string): string;\n  abstract switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string;\n\n  // Array Functions\n  abstract count(params: string[]): string;\n  abstract countA(params: string[]): string;\n  abstract countAll(value: string): string;\n  abstract arrayJoin(array: string, separator?: string): string;\n  abstract arrayUnique(arrays: string[]): string;\n  abstract arrayFlatten(arrays: string[]): string;\n  abstract arrayCompact(arrays: string[]): string;\n\n  // System Functions\n  abstract recordId(): string;\n  abstract autoNumber(): string;\n  abstract textAll(value: string): string;\n\n  // Binary Operations\n  abstract add(left: string, right: string): string;\n  abstract subtract(left: string, right: string): string;\n  abstract multiply(left: string, right: string): string;\n  abstract divide(left: string, right: string): string;\n  abstract modulo(left: string, right: string): string;\n\n  // Comparison Operations\n  abstract equal(left: string, right: string): string;\n  abstract notEqual(left: string, right: string): string;\n  abstract greaterThan(left: string, right: string): string;\n  abstract lessThan(left: string, right: string): string;\n  abstract greaterThanOrEqual(left: string, right: string): string;\n  abstract lessThanOrEqual(left: string, right: string): string;\n\n  // Logical Operations\n  abstract logicalAnd(left: string, right: string): string;\n  abstract logicalOr(left: string, right: string): string;\n  abstract bitwiseAnd(left: string, right: string): string;\n\n  // Unary Operations\n  abstract unaryMinus(value: string): string;\n\n  // Field Reference\n  abstract fieldReference(fieldId: string, columnName: string): string;\n\n  // Literals\n  abstract stringLiteral(value: string): string;\n  abstract numberLiteral(value: number): string;\n  abstract booleanLiteral(value: boolean): string;\n  abstract nullLiteral(): string;\n\n  // Utility methods for type conversion and validation\n  abstract castToNumber(value: string): string;\n  abstract castToString(value: string): string;\n  abstract castToBoolean(value: string): string;\n  abstract castToDate(value: string): string;\n\n  // Handle null values and type checking\n  abstract isNull(value: string): string;\n  abstract coalesce(params: string[]): string;\n\n  // Parentheses for grouping\n  abstract parentheses(expression: string): string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { DbFieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\n\nimport { SelectQuerySqlite } from './select-query.sqlite';\n\ndescribe('SelectQuerySqlite unit-aware date helpers', () => {\n  const query = new SelectQuerySqlite();\n\n  const dateAddCases: Array<{ literal: string; unit: string; factor: number }> = [\n    { literal: 'millisecond', unit: 'seconds', factor: 0.001 },\n    { literal: 'milliseconds', unit: 'seconds', factor: 0.001 },\n    { literal: 'ms', unit: 'seconds', factor: 0.001 },\n    { literal: 'second', unit: 'seconds', factor: 1 },\n    { literal: 'seconds', unit: 'seconds', factor: 1 },\n    { literal: 'sec', unit: 'seconds', factor: 1 },\n    { literal: 'secs', unit: 'seconds', factor: 1 },\n    { literal: 'minute', unit: 'minutes', factor: 1 },\n    { literal: 'minutes', unit: 'minutes', factor: 1 },\n    { literal: 'min', unit: 'minutes', factor: 1 },\n    { literal: 'mins', unit: 'minutes', factor: 1 },\n    { literal: 'hour', unit: 'hours', factor: 1 },\n    { literal: 'hours', unit: 'hours', factor: 1 },\n    { literal: 'h', unit: 'hours', factor: 1 },\n    { literal: 'hr', unit: 'hours', factor: 1 },\n    { literal: 'hrs', unit: 'hours', factor: 1 },\n    { literal: 'day', unit: 'days', factor: 1 },\n    { literal: 'days', unit: 'days', factor: 1 },\n    { literal: 'week', unit: 'days', factor: 7 },\n    { literal: 'weeks', unit: 'days', factor: 7 },\n    { literal: 'month', unit: 'months', factor: 1 },\n    { literal: 'months', unit: 'months', factor: 1 },\n    { literal: 'quarter', unit: 'months', factor: 3 },\n    { literal: 'quarters', unit: 'months', factor: 3 },\n    { literal: 'year', unit: 'years', factor: 1 },\n    { literal: 'years', unit: 'years', factor: 1 },\n  ];\n\n  it.each(dateAddCases)(\n    'dateAdd normalizes unit \"%s\" to SQLite modifier \"%s\"',\n    ({ literal, unit, factor }) => {\n      const sql = query.dateAdd('date_col', 'count_expr', `'${literal}'`);\n      const scaled = factor === 1 ? '(count_expr)' : `(count_expr) * ${factor}`;\n      expect(sql).toBe(`DATETIME(date_col, (${scaled}) || ' ${unit}')`);\n    }\n  );\n\n  const datetimeDiffCases: Array<{ literal: string; expected: string }> = [\n    {\n      literal: 'millisecond',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',\n    },\n    {\n      literal: 'milliseconds',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',\n    },\n    {\n      literal: 'ms',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',\n    },\n    {\n      literal: 's',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',\n    },\n    {\n      literal: 'second',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',\n    },\n    {\n      literal: 'seconds',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',\n    },\n    {\n      literal: 'sec',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',\n    },\n    {\n      literal: 'secs',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',\n    },\n    {\n      literal: 'minute',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',\n    },\n    {\n      literal: 'minutes',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',\n    },\n    {\n      literal: 'min',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',\n    },\n    {\n      literal: 'mins',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',\n    },\n    {\n      literal: 'hour',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',\n    },\n    {\n      literal: 'hours',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',\n    },\n    {\n      literal: 'h',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',\n    },\n    {\n      literal: 'hr',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',\n    },\n    {\n      literal: 'hrs',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',\n    },\n    {\n      literal: 'week',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) / 7.0',\n    },\n    {\n      literal: 'weeks',\n      expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) / 7.0',\n    },\n    { literal: 'day', expected: '(JULIANDAY(date_start) - JULIANDAY(date_end))' },\n    { literal: 'days', expected: '(JULIANDAY(date_start) - JULIANDAY(date_end))' },\n  ];\n\n  it.each(datetimeDiffCases)('datetimeDiff normalizes unit \"%s\"', ({ literal, expected }) => {\n    const sql = query.datetimeDiff('date_start', 'date_end', `'${literal}'`);\n    expect(sql).toBe(expected);\n  });\n\n  const isSameCases: Array<{ literal: string; format: string }> = [\n    { literal: 'millisecond', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'milliseconds', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'ms', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 's', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'second', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'seconds', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'sec', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'secs', format: '%Y-%m-%d %H:%M:%S' },\n    { literal: 'minute', format: '%Y-%m-%d %H:%M' },\n    { literal: 'minutes', format: '%Y-%m-%d %H:%M' },\n    { literal: 'min', format: '%Y-%m-%d %H:%M' },\n    { literal: 'mins', format: '%Y-%m-%d %H:%M' },\n    { literal: 'hour', format: '%Y-%m-%d %H' },\n    { literal: 'hours', format: '%Y-%m-%d %H' },\n    { literal: 'h', format: '%Y-%m-%d %H' },\n    { literal: 'hr', format: '%Y-%m-%d %H' },\n    { literal: 'hrs', format: '%Y-%m-%d %H' },\n    { literal: 'day', format: '%Y-%m-%d' },\n    { literal: 'days', format: '%Y-%m-%d' },\n    { literal: 'week', format: '%Y-%W' },\n    { literal: 'weeks', format: '%Y-%W' },\n    { literal: 'month', format: '%Y-%m' },\n    { literal: 'months', format: '%Y-%m' },\n    { literal: 'year', format: '%Y' },\n    { literal: 'years', format: '%Y' },\n  ];\n\n  it.each(isSameCases)('isSame normalizes unit \"%s\"', ({ literal, format }) => {\n    const sql = query.isSame('date_a', 'date_b', `'${literal}'`);\n    expect(sql).toBe(`STRFTIME('${format}', date_a) = STRFTIME('${format}', date_b)`);\n  });\n\n  describe('numeric aggregate rewrites', () => {\n    it('sum rewrites multiple params to addition with numeric coercion', () => {\n      const sql = query.sum(['column_a', 'column_b', '10']);\n      expect(sql).toBe(\n        '(COALESCE(CAST((column_a) AS REAL), 0) + COALESCE(CAST((column_b) AS REAL), 0) + COALESCE(CAST((10) AS REAL), 0))'\n      );\n    });\n\n    it('average divides the rewritten sum by parameter count', () => {\n      const sql = query.average(['column_a', '10']);\n      expect(sql).toBe(\n        '((COALESCE(CAST((column_a) AS REAL), 0) + COALESCE(CAST((10) AS REAL), 0))) / 2'\n      );\n    });\n  });\n});\n\ndescribe('SelectQuerySqlite countAll', () => {\n  it('counts JSON array length for multi-value field references', () => {\n    const query = new SelectQuerySqlite();\n    query.setContext({ tableAlias: 't' } as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'string',\n        isFieldReference: true,\n        field: {\n          id: 'fldUsers',\n          isMultiple: true,\n          isLookup: false,\n          dbFieldName: '__users',\n          dbFieldType: DbFieldType.Json,\n          cellValueType: 'string',\n        },\n      },\n    ] as unknown as never);\n\n    const sql = query.countAll('(SELECT json_group_array(x) FROM x)');\n    expect(sql).toContain('json_array_length');\n    expect(sql).toContain('\"t\".\"__users\"');\n  });\n\n  it('uses scalar null-check semantics for non-json fields', () => {\n    const query = new SelectQuerySqlite();\n    query.setContext({ tableAlias: 't' } as unknown as never);\n    query.setCallMetadata([\n      {\n        type: 'number',\n        isFieldReference: true,\n        field: {\n          id: 'fldNum',\n          isMultiple: false,\n          isLookup: false,\n          dbFieldName: '__num',\n          dbFieldType: DbFieldType.Real,\n          cellValueType: 'number',\n        },\n      },\n    ] as unknown as never);\n\n    expect(query.countAll('\"t\".\"__num\"')).toBe('CASE WHEN \"t\".\"__num\" IS NULL THEN 0 ELSE 1 END');\n  });\n});\n\ndescribe('SelectQuerySqlite FROMNOW/TONOW', () => {\n  it('applies unit conversion for FROMNOW', () => {\n    const query = new SelectQuerySqlite();\n\n    const daySql = query.fromNow('date_col', \"'day'\");\n    const hourSql = query.fromNow('date_col', \"'hour'\");\n    const secondSql = query.fromNow('date_col', \"'second'\");\n\n    expect(daySql).toBe(\"(JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))\");\n    expect(hourSql).toBe(\"((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0\");\n    expect(secondSql).toBe(\"((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0 * 60 * 60\");\n  });\n\n  it('keeps TONOW aligned with FROMNOW direction', () => {\n    const query = new SelectQuerySqlite();\n\n    const fromNowSql = query.fromNow('date_col', \"'day'\");\n    const toNowSql = query.toNow('date_col', \"'day'\");\n    expect(toNowSql).toBe(fromNowSql);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.ts",
    "content": "import type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';\nimport { isTextLikeParam, resolveFormulaParamInfo } from '../../utils/formula-param-metadata.util';\nimport { SelectQueryAbstract } from '../select-query.abstract';\n\n/**\n * SQLite-specific implementation of SELECT query functions\n * Converts Teable formula functions to SQLite SQL expressions suitable\n * for use in SELECT statements. Unlike generated columns, these can use\n * more functions and have different optimization strategies.\n */\nexport class SelectQuerySqlite extends SelectQueryAbstract {\n  private get tableAlias(): string | undefined {\n    const ctx = this.context as ISelectFormulaConversionContext | undefined;\n    return ctx?.tableAlias;\n  }\n\n  private getParamInfo(index?: number) {\n    return resolveFormulaParamInfo(this.currentCallMetadata, index);\n  }\n\n  private isStringLiteral(value: string): boolean {\n    const trimmed = value.trim();\n    return /^'.*'$/.test(trimmed);\n  }\n\n  private qualifySystemColumn(column: string): string {\n    const quoted = `\"${column}\"`;\n    const alias = this.tableAlias;\n    return alias ? `\"${alias}\".${quoted}` : quoted;\n  }\n\n  private isEmptyStringLiteral(value: string): boolean {\n    return value.trim() === \"''\";\n  }\n\n  private normalizeBlankComparable(value: string): string {\n    return `COALESCE(NULLIF(CAST((${value}) AS TEXT), ''), '')`;\n  }\n\n  private buildBlankAwareComparison(operator: '=' | '<>', left: string, right: string): string {\n    const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);\n    const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);\n    const leftInfo = this.getParamInfo(0);\n    const rightInfo = this.getParamInfo(1);\n    const shouldNormalize =\n      leftIsEmptyLiteral ||\n      rightIsEmptyLiteral ||\n      this.isStringLiteral(left) ||\n      this.isStringLiteral(right) ||\n      isTextLikeParam(leftInfo) ||\n      isTextLikeParam(rightInfo);\n\n    if (!shouldNormalize) {\n      return `(${left} ${operator} ${right})`;\n    }\n\n    const normalize = (value: string, isEmptyLiteral: boolean) =>\n      isEmptyLiteral ? \"''\" : this.normalizeBlankComparable(value);\n\n    return `(${normalize(left, leftIsEmptyLiteral)} ${operator} ${normalize(right, rightIsEmptyLiteral)})`;\n  }\n\n  private coalesceNumeric(expr: string): string {\n    return `COALESCE(CAST((${expr}) AS REAL), 0)`;\n  }\n\n  // Numeric Functions\n  sum(params: string[]): string {\n    if (params.length === 0) {\n      return '0';\n    }\n    const terms = params.map((param) => this.coalesceNumeric(param));\n    if (terms.length === 1) {\n      return terms[0];\n    }\n    return `(${terms.join(' + ')})`;\n  }\n\n  average(params: string[]): string {\n    if (params.length === 0) {\n      return '0';\n    }\n    const numerator = this.sum(params);\n    return `(${numerator}) / ${params.length}`;\n  }\n\n  max(params: string[]): string {\n    return `MAX(${this.joinParams(params)})`;\n  }\n\n  min(params: string[]): string {\n    return `MIN(${this.joinParams(params)})`;\n  }\n\n  round(value: string, precision?: string): string {\n    if (precision) {\n      return `ROUND(${value}, ${precision})`;\n    }\n    return `ROUND(${value})`;\n  }\n\n  roundUp(value: string, precision?: string): string {\n    // SQLite doesn't have CEIL with precision, implement manually\n    if (precision) {\n      return `CAST(CEIL(${value} * POWER(10, ${precision})) / POWER(10, ${precision}) AS REAL)`;\n    }\n    return `CAST(CEIL(${value}) AS INTEGER)`;\n  }\n\n  roundDown(value: string, precision?: string): string {\n    // SQLite doesn't have FLOOR with precision, implement manually\n    if (precision) {\n      return `CAST(FLOOR(${value} * POWER(10, ${precision})) / POWER(10, ${precision}) AS REAL)`;\n    }\n    return `CAST(FLOOR(${value}) AS INTEGER)`;\n  }\n\n  ceiling(value: string): string {\n    return `CAST(CEIL(${value}) AS INTEGER)`;\n  }\n\n  floor(value: string): string {\n    return `CAST(FLOOR(${value}) AS INTEGER)`;\n  }\n\n  even(value: string): string {\n    return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 0 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;\n  }\n\n  odd(value: string): string {\n    return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 1 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;\n  }\n\n  int(value: string): string {\n    return `CAST(${value} AS INTEGER)`;\n  }\n\n  abs(value: string): string {\n    return `ABS(${value})`;\n  }\n\n  sqrt(value: string): string {\n    return `SQRT(${value})`;\n  }\n\n  power(base: string, exponent: string): string {\n    return `POWER(${base}, ${exponent})`;\n  }\n\n  exp(value: string): string {\n    return `EXP(${value})`;\n  }\n\n  log(value: string, base?: string): string {\n    if (base) {\n      // SQLite LOG is base-10, convert to natural log: ln(value) / ln(base)\n      return `(LOG(${value}) * 2.302585092994046 / (LOG(${base}) * 2.302585092994046))`;\n    }\n    // SQLite LOG is base-10, convert to natural log: LOG(value) * ln(10)\n    return `(LOG(${value}) * 2.302585092994046)`;\n  }\n\n  mod(dividend: string, divisor: string): string {\n    return `(${dividend} % ${divisor})`;\n  }\n\n  value(text: string): string {\n    return `CAST(${text} AS REAL)`;\n  }\n\n  // Text Functions\n  concatenate(params: string[]): string {\n    return `(${params.map((p) => `COALESCE(${p}, '')`).join(' || ')})`;\n  }\n\n  stringConcat(left: string, right: string): string {\n    return `(COALESCE(${left}, '') || COALESCE(${right}, ''))`;\n  }\n\n  find(searchText: string, withinText: string, startNum?: string): string {\n    if (startNum) {\n      return `CASE WHEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) > 0 THEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) + ${startNum} - 1 ELSE 0 END`;\n    }\n    return `INSTR(${withinText}, ${searchText})`;\n  }\n\n  search(searchText: string, withinText: string, startNum?: string): string {\n    // Case-insensitive search\n    if (startNum) {\n      return `CASE WHEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) > 0 THEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) + ${startNum} - 1 ELSE 0 END`;\n    }\n    return `INSTR(UPPER(${withinText}), UPPER(${searchText}))`;\n  }\n\n  mid(text: string, startNum: string, numChars: string): string {\n    return `SUBSTR(${text}, ${startNum}, ${numChars})`;\n  }\n\n  left(text: string, numChars: string): string {\n    return `SUBSTR(${text}, 1, ${numChars})`;\n  }\n\n  right(text: string, numChars: string): string {\n    return `SUBSTR(${text}, -${numChars})`;\n  }\n\n  replace(oldText: string, startNum: string, numChars: string, newText: string): string {\n    return `(SUBSTR(${oldText}, 1, ${startNum} - 1) || ${newText} || SUBSTR(${oldText}, ${startNum} + ${numChars}))`;\n  }\n\n  regexpReplace(text: string, pattern: string, replacement: string): string {\n    // SQLite has limited regex support, use REPLACE for simple cases\n    return `REPLACE(${text}, ${pattern}, ${replacement})`;\n  }\n\n  substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {\n    // SQLite doesn't support replacing specific instances easily\n    return `REPLACE(${text}, ${oldText}, ${newText})`;\n  }\n\n  lower(text: string): string {\n    return `LOWER(${text})`;\n  }\n\n  upper(text: string): string {\n    return `UPPER(${text})`;\n  }\n\n  rept(text: string, numTimes: string): string {\n    // SQLite doesn't have REPEAT, implement with recursive CTE or simple approach\n    return `REPLACE(HEX(ZEROBLOB(${numTimes})), '00', ${text})`;\n  }\n\n  trim(text: string): string {\n    return `TRIM(${text})`;\n  }\n\n  len(text: string): string {\n    return `LENGTH(${text})`;\n  }\n\n  t(value: string): string {\n    // SQLite T function should return numbers as numbers, not strings\n    return `CASE WHEN ${value} IS NULL THEN '' WHEN typeof(${value}) = 'text' THEN ${value} ELSE ${value} END`;\n  }\n\n  encodeUrlComponent(text: string): string {\n    // SQLite doesn't have built-in URL encoding\n    return `${text}`;\n  }\n\n  // DateTime Functions - More flexible in SELECT context\n  now(): string {\n    return `DATETIME('now')`;\n  }\n\n  private normalizeDateModifier(unitLiteral: string): {\n    unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years';\n    factor: number;\n  } {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return { unit: 'seconds', factor: 0.001 };\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return { unit: 'seconds', factor: 1 };\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return { unit: 'minutes', factor: 1 };\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return { unit: 'hours', factor: 1 };\n      case 'week':\n      case 'weeks':\n        return { unit: 'days', factor: 7 };\n      case 'month':\n      case 'months':\n        return { unit: 'months', factor: 1 };\n      case 'quarter':\n      case 'quarters':\n        return { unit: 'months', factor: 3 };\n      case 'year':\n      case 'years':\n        return { unit: 'years', factor: 1 };\n      case 'day':\n      case 'days':\n      default:\n        return { unit: 'days', factor: 1 };\n    }\n  }\n\n  private normalizeDiffUnit(\n    unitLiteral: string\n  ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n        return 'millisecond';\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return 'second';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return 'minute';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return 'hour';\n      case 'week':\n      case 'weeks':\n        return 'week';\n      case 'month':\n      case 'months':\n        return 'month';\n      case 'quarter':\n      case 'quarters':\n        return 'quarter';\n      case 'year':\n      case 'years':\n        return 'year';\n      default:\n        return 'day';\n    }\n  }\n\n  private normalizeTruncateFormat(unitLiteral: string): string {\n    const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();\n    switch (normalized) {\n      case 'millisecond':\n      case 'milliseconds':\n      case 'ms':\n      case 'second':\n      case 'seconds':\n      case 's':\n      case 'sec':\n      case 'secs':\n        return '%Y-%m-%d %H:%M:%S';\n      case 'minute':\n      case 'minutes':\n      case 'min':\n      case 'mins':\n        return '%Y-%m-%d %H:%M';\n      case 'hour':\n      case 'hours':\n      case 'h':\n      case 'hr':\n      case 'hrs':\n        return '%Y-%m-%d %H';\n      case 'week':\n      case 'weeks':\n        return '%Y-%W';\n      case 'month':\n      case 'months':\n        return '%Y-%m';\n      case 'year':\n      case 'years':\n        return '%Y';\n      case 'day':\n      case 'days':\n      default:\n        return '%Y-%m-%d';\n    }\n  }\n\n  today(): string {\n    return `DATE('now')`;\n  }\n\n  dateAdd(date: string, count: string, unit: string): string {\n    const { unit: modifierUnit, factor } = this.normalizeDateModifier(unit);\n    const scaledCount = factor === 1 ? `(${count})` : `(${count}) * ${factor}`;\n    return `DATETIME(${date}, (${scaledCount}) || ' ${modifierUnit}')`;\n  }\n\n  datestr(date: string): string {\n    return `DATE(${date})`;\n  }\n\n  private buildMonthDiff(startDate: string, endDate: string): string {\n    const startYear = `CAST(STRFTIME('%Y', ${startDate}) AS INTEGER)`;\n    const endYear = `CAST(STRFTIME('%Y', ${endDate}) AS INTEGER)`;\n    const startMonth = `CAST(STRFTIME('%m', ${startDate}) AS INTEGER)`;\n    const endMonth = `CAST(STRFTIME('%m', ${endDate}) AS INTEGER)`;\n    const startDay = `CAST(STRFTIME('%d', ${startDate}) AS INTEGER)`;\n    const endDay = `CAST(STRFTIME('%d', ${endDate}) AS INTEGER)`;\n    const startLastDay = `CAST(STRFTIME('%d', DATE(${startDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;\n    const endLastDay = `CAST(STRFTIME('%d', DATE(${endDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;\n\n    const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;\n    const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;\n    const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;\n\n    return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;\n  }\n\n  datetimeDiff(startDate: string, endDate: string, unit: string): string {\n    const baseDiffDays = `(JULIANDAY(${startDate}) - JULIANDAY(${endDate}))`;\n    switch (this.normalizeDiffUnit(unit)) {\n      case 'millisecond':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;\n      case 'second':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60`;\n      case 'minute':\n        return `(${baseDiffDays}) * 24.0 * 60`;\n      case 'hour':\n        return `(${baseDiffDays}) * 24.0`;\n      case 'week':\n        return `(${baseDiffDays}) / 7.0`;\n      case 'month':\n        return this.buildMonthDiff(startDate, endDate);\n      case 'quarter':\n        return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(startDate, endDate);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `${baseDiffDays}`;\n    }\n  }\n\n  datetimeFormat(date: string, format: string): string {\n    return `STRFTIME(${format}, ${date})`;\n  }\n\n  datetimeParse(dateString: string, _format?: string): string {\n    // SQLite doesn't have direct parsing with custom formats\n    return `DATETIME(${dateString})`;\n  }\n\n  day(date: string): string {\n    return `CAST(STRFTIME('%d', ${date}) AS INTEGER)`;\n  }\n\n  private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {\n    const baseDiffDays = `(JULIANDAY(${nowExpr}) - JULIANDAY(${dateExpr}))`;\n    switch (this.normalizeDiffUnit(unit)) {\n      case 'millisecond':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;\n      case 'second':\n        return `(${baseDiffDays}) * 24.0 * 60 * 60`;\n      case 'minute':\n        return `(${baseDiffDays}) * 24.0 * 60`;\n      case 'hour':\n        return `(${baseDiffDays}) * 24.0`;\n      case 'week':\n        return `(${baseDiffDays}) / 7.0`;\n      case 'month':\n        return this.buildMonthDiff(nowExpr, dateExpr);\n      case 'quarter':\n        return `${this.buildMonthDiff(nowExpr, dateExpr)} / 3.0`;\n      case 'year': {\n        const monthDiff = this.buildMonthDiff(nowExpr, dateExpr);\n        return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;\n      }\n      case 'day':\n      default:\n        return `${baseDiffDays}`;\n    }\n  }\n\n  fromNow(date: string, unit = 'day'): string {\n    return this.buildNowDiffByUnit(\"'now'\", `DATETIME(${date})`, unit);\n  }\n\n  hour(date: string): string {\n    return `CAST(STRFTIME('%H', ${date}) AS INTEGER)`;\n  }\n\n  isAfter(date1: string, date2: string): string {\n    return `DATETIME(${date1}) > DATETIME(${date2})`;\n  }\n\n  isBefore(date1: string, date2: string): string {\n    return `DATETIME(${date1}) < DATETIME(${date2})`;\n  }\n\n  isSame(date1: string, date2: string, unit?: string): string {\n    if (unit) {\n      const trimmed = unit.trim();\n      if (trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\")) {\n        const format = this.normalizeTruncateFormat(trimmed.slice(1, -1));\n        return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;\n      }\n      const format = this.normalizeTruncateFormat(unit);\n      return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;\n    }\n    return `DATETIME(${date1}) = DATETIME(${date2})`;\n  }\n\n  lastModifiedTime(): string {\n    return this.qualifySystemColumn('__last_modified_time');\n  }\n\n  minute(date: string): string {\n    return `CAST(STRFTIME('%M', ${date}) AS INTEGER)`;\n  }\n\n  month(date: string): string {\n    return `CAST(STRFTIME('%m', ${date}) AS INTEGER)`;\n  }\n\n  second(date: string): string {\n    return `CAST(STRFTIME('%S', ${date}) AS INTEGER)`;\n  }\n\n  timestr(date: string): string {\n    return `TIME(${date})`;\n  }\n\n  toNow(date: string, unit = 'day'): string {\n    return this.fromNow(date, unit);\n  }\n\n  weekNum(date: string): string {\n    return `CAST(STRFTIME('%W', ${date}) AS INTEGER)`;\n  }\n\n  weekday(date: string, startDayOfWeek?: string): string {\n    // SQLite STRFTIME('%w') returns 0-6 (Sunday=0), but we need 1-7 (Sunday=1)\n    const weekdaySql = `CAST(STRFTIME('%w', ${date}) AS INTEGER) + 1`;\n    if (!startDayOfWeek) {\n      return weekdaySql;\n    }\n\n    const normalizedStartDay = `LOWER(TRIM(COALESCE(CAST(${startDayOfWeek} AS TEXT), '')))`;\n    const mondayWeekdaySql = `(CASE WHEN (${weekdaySql}) = 1 THEN 7 ELSE (${weekdaySql}) - 1 END)`;\n    return `CASE WHEN ${normalizedStartDay} = 'monday' THEN ${mondayWeekdaySql} ELSE ${weekdaySql} END`;\n  }\n\n  workday(startDate: string, days: string, holidayStr?: string): string {\n    const dayCountSql = `CAST(${this.coalesceNumeric(days)} AS INTEGER)`;\n    const holidayTextSql = holidayStr ? `COALESCE(CAST(${holidayStr} AS TEXT), '')` : `''`;\n\n    return `(\n      WITH RECURSIVE\n      params AS (\n        SELECT DATE(${startDate}) AS start_date, ${dayCountSql} AS day_count, ${holidayTextSql} AS holiday_text\n      ),\n      split(rest, part) AS (\n        SELECT (SELECT holiday_text FROM params), ''\n        UNION ALL\n        SELECT\n          CASE WHEN INSTR(rest, ',') = 0 THEN '' ELSE SUBSTR(rest, INSTR(rest, ',') + 1) END,\n          TRIM(CASE WHEN INSTR(rest, ',') = 0 THEN rest ELSE SUBSTR(rest, 1, INSTR(rest, ',') - 1) END)\n        FROM split\n        WHERE rest <> ''\n      ),\n      holiday_dates AS (\n        SELECT DISTINCT DATE(SUBSTR(part, 1, 10)) AS holiday_date\n        FROM split\n        WHERE part <> ''\n          AND part GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'\n          AND DATE(SUBSTR(part, 1, 10)) = SUBSTR(part, 1, 10)\n      ),\n      seq(n) AS (\n        SELECT 1\n        UNION ALL\n        SELECT n + 1\n        FROM seq\n        WHERE n < (SELECT ABS(day_count) * 7 + 366 FROM params)\n      ),\n      candidates AS (\n        SELECT\n          DATE(\n            p.start_date,\n            PRINTF('%+d day', CASE WHEN p.day_count >= 0 THEN seq.n ELSE -seq.n END)\n          ) AS candidate_date,\n          seq.n\n        FROM params p\n        CROSS JOIN seq\n      ),\n      workdays AS (\n        SELECT c.candidate_date, c.n\n        FROM candidates c\n        LEFT JOIN holiday_dates h ON h.holiday_date = c.candidate_date\n        WHERE CAST(STRFTIME('%w', c.candidate_date) AS INTEGER) NOT IN (0, 6)\n          AND h.holiday_date IS NULL\n        ORDER BY c.n\n      )\n      SELECT CASE\n        WHEN p.day_count = 0 THEN p.start_date\n        ELSE (\n          SELECT w.candidate_date\n          FROM workdays w\n          LIMIT 1 OFFSET ABS(p.day_count) - 1\n        )\n      END\n      FROM params p\n    )`;\n  }\n\n  workdayDiff(startDate: string, endDate: string): string {\n    return `CAST((JULIANDAY(${endDate}) - JULIANDAY(${startDate})) AS INTEGER)`;\n  }\n\n  year(date: string): string {\n    return `CAST(STRFTIME('%Y', ${date}) AS INTEGER)`;\n  }\n\n  createdTime(): string {\n    return this.qualifySystemColumn('__created_time');\n  }\n\n  // Logical Functions\n  private truthinessScore(value: string): string {\n    const wrapped = `(${value})`;\n    const valueType = `TYPEOF${wrapped}`;\n    return `CASE\n      WHEN ${wrapped} IS NULL THEN 0\n      WHEN ${valueType} = 'integer' OR ${valueType} = 'real' THEN (${wrapped}) != 0\n      WHEN ${valueType} = 'text' THEN (${wrapped} != '' AND LOWER(${wrapped}) != 'null')\n      ELSE (${wrapped}) IS NOT NULL AND ${wrapped} != 'null'\n    END`;\n  }\n\n  if(condition: string, valueIfTrue: string, valueIfFalse: string): string {\n    const truthiness = this.truthinessScore(condition);\n    return `CASE WHEN (${truthiness}) = 1 THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;\n  }\n\n  and(params: string[]): string {\n    return `(${params.map((p) => `(${p})`).join(' AND ')})`;\n  }\n\n  or(params: string[]): string {\n    return `(${params.map((p) => `(${p})`).join(' OR ')})`;\n  }\n\n  not(value: string): string {\n    return `NOT (${value})`;\n  }\n\n  xor(params: string[]): string {\n    if (params.length === 2) {\n      return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;\n    }\n    return `(${params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`).join(' + ')}) % 2 = 1`;\n  }\n\n  blank(): string {\n    // SQLite BLANK function should return null instead of empty string\n    return `NULL`;\n  }\n\n  error(_message: string): string {\n    // SQLite doesn't have a direct error function, use a failing expression\n    return `(1/0)`;\n  }\n\n  isError(_value: string): string {\n    return `0`;\n  }\n\n  switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): string {\n    let sql = `CASE ${expression}`;\n    for (const caseItem of cases) {\n      sql += ` WHEN ${caseItem.case} THEN ${caseItem.result}`;\n    }\n    if (defaultResult) {\n      sql += ` ELSE ${defaultResult}`;\n    }\n    sql += ` END`;\n    return sql;\n  }\n\n  // Array Functions - Limited in SQLite\n  count(params: string[]): string {\n    return `COUNT(${this.joinParams(params)})`;\n  }\n\n  countA(params: string[]): string {\n    return `COUNT(${this.joinParams(params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 END`))})`;\n  }\n\n  countAll(value: string): string {\n    const paramInfo = this.getParamInfo(0);\n    if (paramInfo.isJsonField || paramInfo.isMultiValueField) {\n      const baseExpr =\n        paramInfo.isFieldReference && paramInfo.fieldDbName\n          ? this.tableAlias\n            ? `\"${this.tableAlias}\".\"${paramInfo.fieldDbName}\"`\n            : `\"${paramInfo.fieldDbName}\"`\n          : value;\n      return `CASE\n        WHEN ${baseExpr} IS NULL THEN 0\n        WHEN json_valid(${baseExpr}) AND json_type(${baseExpr}) = 'array' THEN COALESCE(json_array_length(${baseExpr}), 0)\n        WHEN json_valid(${baseExpr}) AND json_type(${baseExpr}) = 'null' THEN 0\n        ELSE 1\n      END`;\n    }\n\n    return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;\n  }\n\n  private buildJsonArrayUnion(\n    arrays: string[],\n    opts?: { filterNulls?: boolean; withOrdinal?: boolean }\n  ): string {\n    const selects = arrays.map((array, index) => {\n      const base = `SELECT value, ${index} AS arg_index, CAST(key AS INTEGER) AS ord FROM json_each(COALESCE(${array}, '[]'))`;\n      const whereClause = opts?.filterNulls\n        ? \" WHERE value IS NOT NULL AND value != 'null' AND value != ''\"\n        : '';\n      return `${base}${whereClause}`;\n    });\n\n    if (selects.length === 0) {\n      return 'SELECT NULL AS value, 0 AS arg_index, 0 AS ord WHERE 0';\n    }\n\n    return selects.join(' UNION ALL ');\n  }\n\n  arrayJoin(array: string, separator?: string): string {\n    const sep = separator || ',';\n    // SQLite JSON array join using json_each with stable ordering by key\n    return `(SELECT GROUP_CONCAT(value, ${sep}) FROM json_each(${array}) ORDER BY key)`;\n  }\n\n  arrayUnique(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true, filterNulls: true });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (\n          SELECT value, ROW_NUMBER() OVER (PARTITION BY value ORDER BY arg_index, ord) AS rn, arg_index, ord\n          FROM (${unionQuery}) AS combined\n        )\n        WHERE rn = 1\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  arrayFlatten(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (${unionQuery}) AS combined\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  arrayCompact(arrays: string[]): string {\n    const unionQuery = this.buildJsonArrayUnion(arrays, {\n      filterNulls: true,\n      withOrdinal: true,\n    });\n    return `COALESCE(\n      '[' || (\n        SELECT GROUP_CONCAT(json_quote(value))\n        FROM (${unionQuery}) AS combined\n        ORDER BY arg_index, ord\n      ) || ']',\n      '[]'\n    )`;\n  }\n\n  // System Functions\n  recordId(): string {\n    return this.qualifySystemColumn('__id');\n  }\n\n  autoNumber(): string {\n    return this.qualifySystemColumn('__auto_number');\n  }\n\n  textAll(value: string): string {\n    return `CAST(${value} AS TEXT)`;\n  }\n\n  // Binary Operations\n  add(left: string, right: string): string {\n    return `(${left} + ${right})`;\n  }\n\n  subtract(left: string, right: string): string {\n    return `(${left} - ${right})`;\n  }\n\n  multiply(left: string, right: string): string {\n    return `(${left} * ${right})`;\n  }\n\n  divide(left: string, right: string): string {\n    return `(${left} / ${right})`;\n  }\n\n  modulo(left: string, right: string): string {\n    return `(${left} % ${right})`;\n  }\n\n  // Comparison Operations\n  equal(left: string, right: string): string {\n    return this.buildBlankAwareComparison('=', left, right);\n  }\n\n  notEqual(left: string, right: string): string {\n    return this.buildBlankAwareComparison('<>', left, right);\n  }\n\n  greaterThan(left: string, right: string): string {\n    return `(${left} > ${right})`;\n  }\n\n  lessThan(left: string, right: string): string {\n    return `(${left} < ${right})`;\n  }\n\n  greaterThanOrEqual(left: string, right: string): string {\n    return `(${left} >= ${right})`;\n  }\n\n  lessThanOrEqual(left: string, right: string): string {\n    return `(${left} <= ${right})`;\n  }\n\n  // Logical Operations\n  logicalAnd(left: string, right: string): string {\n    return `(${left} AND ${right})`;\n  }\n\n  logicalOr(left: string, right: string): string {\n    return `(${left} OR ${right})`;\n  }\n\n  bitwiseAnd(left: string, right: string): string {\n    return `(${left} & ${right})`;\n  }\n\n  // Unary Operations\n  unaryMinus(value: string): string {\n    return `(-${value})`;\n  }\n\n  // Field Reference\n  fieldReference(_fieldId: string, columnName: string): string {\n    return `\"${columnName}\"`;\n  }\n\n  // Literals\n  stringLiteral(value: string): string {\n    return `'${value.replace(/'/g, \"''\")}'`;\n  }\n\n  numberLiteral(value: number): string {\n    return value.toString();\n  }\n\n  booleanLiteral(value: boolean): string {\n    return value ? '1' : '0';\n  }\n\n  nullLiteral(): string {\n    return 'NULL';\n  }\n\n  // Utility methods for type conversion and validation\n  castToNumber(value: string): string {\n    return `CAST(${value} AS REAL)`;\n  }\n\n  castToString(value: string): string {\n    return `CAST(${value} AS TEXT)`;\n  }\n\n  castToBoolean(value: string): string {\n    return `CASE WHEN ${value} THEN 1 ELSE 0 END`;\n  }\n\n  castToDate(value: string): string {\n    return `DATETIME(${value})`;\n  }\n\n  // Handle null values and type checking\n  isNull(value: string): string {\n    return `${value} IS NULL`;\n  }\n\n  coalesce(params: string[]): string {\n    return `COALESCE(${this.joinParams(params)})`;\n  }\n\n  // Parentheses for grouping\n  parentheses(expression: string): string {\n    return `(${expression})`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.abstract.ts",
    "content": "import { InternalServerErrorException } from '@nestjs/common';\nimport type { FieldCore } from '@teable/core';\nimport { SortFunc } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';\nimport type { ISortFunctionInterface } from './sort-function.interface';\n\nexport abstract class AbstractSortFunction implements ISortFunctionInterface {\n  protected columnName?: string;\n\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly field: FieldCore,\n    protected readonly context?: IRecordQuerySortContext\n  ) {\n    const { dbFieldName, id } = field;\n\n    const selection = context?.selectionMap.get(id);\n    const normalizedSelection =\n      selection !== undefined && selection !== null\n        ? this.normalizeSelection(selection)\n        : undefined;\n    if (this.isNullConstant(normalizedSelection)) {\n      this.columnName = undefined;\n      return;\n    }\n    if (normalizedSelection) {\n      this.columnName = normalizedSelection;\n      return;\n    }\n    const quotedIdentifier = this.quoteIdentifier(dbFieldName);\n    this.columnName = this.isNullConstant(quotedIdentifier) ? undefined : quotedIdentifier;\n  }\n\n  compiler(builderClient: Knex.QueryBuilder, sortFunc: SortFunc) {\n    const functionHandlers = {\n      [SortFunc.Asc]: this.asc,\n      [SortFunc.Desc]: this.desc,\n    };\n    const chosenHandler = functionHandlers[sortFunc].bind(this);\n\n    if (!chosenHandler) {\n      throw new InternalServerErrorException(`Unknown function ${sortFunc} for sort`);\n    }\n\n    return chosenHandler(builderClient);\n  }\n\n  generateSQL(sortFunc: SortFunc): string | undefined {\n    const functionHandlers = {\n      [SortFunc.Asc]: this.getAscSQL,\n      [SortFunc.Desc]: this.getDescSQL,\n    };\n    const chosenHandler = functionHandlers[sortFunc].bind(this);\n\n    if (!chosenHandler) {\n      throw new InternalServerErrorException(`Unknown function ${sortFunc} for sort`);\n    }\n\n    return chosenHandler();\n  }\n\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();\n  }\n\n  protected createSqlPlaceholders(values: unknown[]): string {\n    return values.map(() => '?').join(',');\n  }\n\n  private normalizeSelection(selection: unknown): string | undefined {\n    if (typeof selection === 'string') {\n      return selection;\n    }\n    if (selection && typeof (selection as Knex.Raw).toQuery === 'function') {\n      return (selection as Knex.Raw).toQuery();\n    }\n    if (selection && typeof (selection as Knex.Raw).toSQL === 'function') {\n      const { sql } = (selection as Knex.Raw).toSQL();\n      if (sql) {\n        return sql;\n      }\n    }\n    return undefined;\n  }\n\n  private quoteIdentifier(identifier: string): string {\n    if (!identifier) {\n      return identifier;\n    }\n    if (identifier.startsWith('\"') && identifier.endsWith('\"')) {\n      return identifier;\n    }\n    const escaped = identifier.replace(/\"/g, '\"\"');\n    return `\"${escaped}\"`;\n  }\n\n  private isNullConstant(selection?: string): boolean {\n    if (!selection) {\n      return false;\n    }\n    const trimmed = selection.trim().toUpperCase();\n    if (trimmed === 'NULL') {\n      return true;\n    }\n    return trimmed.startsWith('NULL::');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.interface.ts",
    "content": "import type { Knex } from 'knex';\n\nexport type ISortFunctionHandler = (builderClient: Knex.QueryBuilder) => Knex.QueryBuilder;\n\nexport interface ISortFunctionInterface {\n  asc: ISortFunctionHandler;\n  desc: ISortFunctionHandler;\n  getAscSQL: () => string | undefined;\n  getDescSQL: () => string | undefined;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-datetime-sort.adapter.ts",
    "content": "import { TimeFormatting, type DateFormattingPreset, type IDateFieldOptions } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getPostgresDateTimeFormatString } from '../../../group-query/format-string';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class MultipleDateTimeSortAdapter extends SortFunctionPostgres {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    let orderByColumn;\n    if (time === TimeFormatting.None) {\n      orderByColumn = this.knex.raw(\n        `\n        (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        ASC NULLS FIRST,\n        (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        ASC NULLS FIRST\n        `,\n        [timeZone, formatString, timeZone, formatString]\n      );\n    } else {\n      orderByColumn = this.knex.raw(\n        `\n        (SELECT to_jsonb(array_agg(elem))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        ASC NULLS FIRST,\n        (SELECT to_jsonb(array_agg(elem))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        ASC NULLS FIRST\n        `\n      );\n    }\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    let orderByColumn;\n    if (time === TimeFormatting.None) {\n      orderByColumn = this.knex.raw(\n        `\n        (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        DESC NULLS LAST,\n        (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        DESC NULLS LAST\n        `,\n        [timeZone, formatString, timeZone, formatString]\n      );\n    } else {\n      orderByColumn = this.knex.raw(\n        `\n        (SELECT to_jsonb(array_agg(elem))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        DESC NULLS LAST,\n        (SELECT to_jsonb(array_agg(elem))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        DESC NULLS LAST\n        `\n      );\n    }\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(\n          `\n          (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n          ASC NULLS FIRST,\n          (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n          ASC NULLS FIRST\n          `,\n          [timeZone, formatString, timeZone, formatString]\n        )\n        .toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `\n          (SELECT to_jsonb(array_agg(elem))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n          ASC NULLS FIRST,\n          (SELECT to_jsonb(array_agg(elem))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n          ASC NULLS FIRST\n          `\n        )\n        .toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(\n          `\n          (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n          DESC NULLS LAST,\n          (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n          DESC NULLS LAST\n          `,\n          [timeZone, formatString, timeZone, formatString]\n        )\n        .toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `\n          (SELECT to_jsonb(array_agg(elem))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n          DESC NULLS LAST,\n          (SELECT to_jsonb(array_agg(elem))\n          FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n          DESC NULLS LAST\n          `\n        )\n        .toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-json-sort.adapter.ts",
    "content": "import type { ISelectFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { isUserOrLink } from '../../../../utils/is-user-or-link';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class MultipleJsonSortAdapter extends SortFunctionPostgres {\n  /**\n   * Use the first choice (array[0]) to compute choice index.\n   * If not an array, fall back to comparing the raw scalar text.\n   */\n  private firstChoiceIndexExpr(optionSets: string[]) {\n    const arrayLiteral = `ARRAY[${this.createSqlPlaceholders(optionSets)}]`;\n    const sql = `CASE\n      WHEN ${this.columnName} IS NULL THEN NULL\n      WHEN jsonb_typeof(${this.columnName}::jsonb) = 'array'\n        THEN ARRAY_POSITION(${arrayLiteral}, jsonb_path_query_first(${this.columnName}::jsonb, '$[0]') #>> '{}')\n      ELSE ARRAY_POSITION(${arrayLiteral}, ${this.columnName}::text)\n    END`;\n    // arrayLiteral is used twice, so duplicate the bindings to satisfy both occurrences\n    const bindings = [...optionSets, ...optionSets];\n    return { sql, bindings };\n  }\n\n  private orderByMultiSelect(\n    builderClient: Knex.QueryBuilder,\n    direction: 'ASC' | 'DESC',\n    nulls: 'FIRST' | 'LAST'\n  ) {\n    if (!this.columnName) return builderClient;\n    const { choices } = this.field.options as ISelectFieldOptions;\n    if (!choices.length) return builderClient;\n    const optionSets = choices.map(({ name }) => name);\n    const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);\n    builderClient.orderByRaw(`${sql} ${direction} NULLS ${nulls}`, bindings);\n    // Stable tie-breaker to make ordering deterministic when min index is equal\n    builderClient.orderByRaw(`${this.columnName}::jsonb::text ${direction} NULLS ${nulls}`);\n    return builderClient;\n  }\n\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(\n        `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text ASC NULLS FIRST`\n      );\n    } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {\n      return this.orderByMultiSelect(builderClient, 'ASC', 'FIRST');\n    } else {\n      builderClient.orderByRaw(\n        `${this.columnName}::jsonb ->> 0 ASC NULLS FIRST, jsonb_array_length(${this.columnName}::jsonb) ASC`\n      );\n    }\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(\n        `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text DESC NULLS LAST`\n      );\n    } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {\n      return this.orderByMultiSelect(builderClient, 'DESC', 'LAST');\n    } else {\n      builderClient.orderByRaw(\n        `${this.columnName}::jsonb ->> 0 DESC NULLS LAST, jsonb_array_length(${this.columnName}::jsonb) DESC`\n      );\n    }\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex\n        .raw(\n          `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text ASC NULLS FIRST`\n        )\n        .toQuery();\n    } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {\n      const { choices } = this.field.options as ISelectFieldOptions;\n      const optionSets = choices.map(({ name }) => name);\n      const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);\n      return this.knex.raw(`${sql} ASC NULLS FIRST`, bindings).toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `${this.columnName}::jsonb ->> 0 ASC NULLS FIRST, jsonb_array_length(${this.columnName}::jsonb) ASC`\n        )\n        .toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex\n        .raw(\n          `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text DESC NULLS LAST`\n        )\n        .toQuery();\n    } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {\n      const { choices } = this.field.options as ISelectFieldOptions;\n      const optionSets = choices.map(({ name }) => name);\n      const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);\n      return this.knex.raw(`${sql} DESC NULLS LAST`, bindings).toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `${this.columnName}::jsonb ->> 0 DESC NULLS LAST, jsonb_array_length(${this.columnName}::jsonb) DESC`\n        )\n        .toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-number-sort.adapter.ts",
    "content": "import type { INumberFieldOptions } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class MultipleNumberSortAdapter extends SortFunctionPostgres {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n\n    const orderByColumn = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n      ASC NULLS FIRST,\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n      ASC NULLS FIRST\n      `,\n      [precision, precision]\n    );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n\n    const orderByColumn = this.knex.raw(\n      `\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n      DESC NULLS LAST,\n      (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n      FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n      DESC NULLS LAST\n      `,\n      [precision, precision]\n    );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n\n    return this.knex\n      .raw(\n        `\n        (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        ASC NULLS FIRST,\n        (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        ASC NULLS FIRST\n        `,\n        [precision, precision]\n      )\n      .toQuery();\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n\n    return this.knex\n      .raw(\n        `\n        (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0\n        DESC NULLS LAST,\n        (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))\n        FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)\n        DESC NULLS LAST\n        `,\n        [precision, precision]\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/date-sort.adapter.ts",
    "content": "import { type IDateFieldOptions, type DateFormattingPreset, TimeFormatting } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getPostgresDateTimeFormatString } from '../../../group-query/format-string';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class DateSortAdapter extends SortFunctionPostgres {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      builderClient.orderByRaw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) ASC NULLS FIRST`, [\n        timeZone,\n        formatString,\n      ]);\n    } else {\n      builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);\n    }\n\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      builderClient.orderByRaw(\n        `TO_CHAR(TIMEZONE(?, ${(this, this.columnName)}), ?) DESC NULLS LAST`,\n        [timeZone, formatString]\n      );\n    } else {\n      builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);\n    }\n\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) ASC NULLS FIRST`, [\n          timeZone,\n          formatString,\n        ])\n        .toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) DESC NULLS LAST`, [\n          timeZone,\n          formatString,\n        ])\n        .toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/json-sort.adapter.ts",
    "content": "import type { Knex } from 'knex';\nimport { isUserOrLink } from '../../../../utils/is-user-or-link';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class JsonSortAdapter extends SortFunctionPostgres {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(`${this.columnName}::jsonb ->> 'title' ASC NULLS FIRST`);\n    } else {\n      builderClient.orderByRaw(`${this.columnName}::jsonb ASC NULLS FIRST`);\n    }\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(`${this.columnName}::jsonb ->> 'title' DESC NULLS LAST`);\n    } else {\n      builderClient.orderByRaw(`${this.columnName}::jsonb DESC NULLS LAST`);\n    }\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex.raw(`${this.columnName}::jsonb ->> 'title' ASC NULLS FIRST`).toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName}::jsonb ASC NULLS FIRST`).toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex.raw(`${this.columnName}::jsonb ->> 'title' DESC NULLS LAST`).toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName}::jsonb DESC NULLS LAST`).toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/string-sort.adapter.ts",
    "content": "import type { ISelectFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { SortFunctionPostgres } from '../sort-query.function';\n\nexport class StringSortAdapter extends SortFunctionPostgres {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.asc(builderClient);\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    if (!choices.length) return builderClient;\n\n    const optionSets = choices.map(({ name }) => name);\n    builderClient.orderByRaw(\n      `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) ASC NULLS FIRST`,\n      [...optionSets]\n    );\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.desc(builderClient);\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    if (!choices.length) return builderClient;\n\n    const optionSets = choices.map(({ name }) => name);\n    builderClient.orderByRaw(\n      `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) DESC NULLS LAST`,\n      [...optionSets]\n    );\n    return builderClient;\n  }\n\n  getAscSQL() {\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.getAscSQL();\n    }\n    if (!this.columnName) {\n      return undefined;\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    return this.knex\n      .raw(\n        `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) ASC NULLS FIRST`,\n        [...optionSets]\n      )\n      .toQuery();\n  }\n\n  getDescSQL() {\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.getDescSQL();\n    }\n    if (!this.columnName) {\n      return undefined;\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    return this.knex\n      .raw(\n        `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) DESC NULLS LAST`,\n\n        [...optionSets]\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.function.ts",
    "content": "import { DbFieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { AbstractSortFunction } from '../function/sort-function.abstract';\n\nexport class SortFunctionPostgres extends AbstractSortFunction {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { dbFieldType } = this.field;\n\n    builderClient.orderByRaw(\n      `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} ASC NULLS FIRST`\n    );\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { dbFieldType } = this.field;\n\n    builderClient.orderByRaw(\n      `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} DESC NULLS LAST`\n    );\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { dbFieldType } = this.field;\n\n    return this.knex\n      .raw(\n        `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} ASC NULLS FIRST`\n      )\n      .toQuery();\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { dbFieldType } = this.field;\n\n    return this.knex\n      .raw(\n        `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} DESC NULLS LAST`\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.postgres.ts",
    "content": "import type { FieldCore } from '@teable/core';\nimport type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';\nimport { AbstractSortQuery } from '../sort-query.abstract';\nimport { MultipleDateTimeSortAdapter } from './multiple-value/multiple-datetime-sort.adapter';\nimport { MultipleJsonSortAdapter } from './multiple-value/multiple-json-sort.adapter';\nimport { MultipleNumberSortAdapter } from './multiple-value/multiple-number-sort.adapter';\nimport { DateSortAdapter } from './single-value/date-sort.adapter';\nimport { JsonSortAdapter } from './single-value/json-sort.adapter';\nimport { StringSortAdapter } from './single-value/string-sort.adapter';\nimport { SortFunctionPostgres } from './sort-query.function';\n\nexport class SortQueryPostgres extends AbstractSortQuery {\n  booleanSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {\n    return new SortFunctionPostgres(this.knex, field, context);\n  }\n\n  numberSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleNumberSortAdapter(this.knex, field, context);\n    }\n    return new SortFunctionPostgres(this.knex, field, context);\n  }\n\n  dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleDateTimeSortAdapter(this.knex, field, context);\n    }\n    return new DateSortAdapter(this.knex, field, context);\n  }\n\n  stringSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new SortFunctionPostgres(this.knex, field, context);\n    }\n    return new StringSortAdapter(this.knex, field, context);\n  }\n\n  jsonSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleJsonSortAdapter(this.knex, field, context);\n    }\n    return new JsonSortAdapter(this.knex, field, context);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sort-query.abstract.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport type { FieldCore, ISortItem } from '@teable/core';\nimport { CellValueType, DbFieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQuerySortContext } from '../../features/record/query-builder/record-query-builder.interface';\nimport type { ISortQueryExtra } from '../db.provider.interface';\nimport type { AbstractSortFunction } from './function/sort-function.abstract';\nimport type { ISortQueryInterface } from './sort-query.interface';\n\nexport abstract class AbstractSortQuery implements ISortQueryInterface {\n  private logger = new Logger(AbstractSortQuery.name);\n\n  constructor(\n    protected readonly knex: Knex,\n    protected readonly originQueryBuilder: Knex.QueryBuilder,\n    protected readonly fields?: { [fieldId: string]: FieldCore },\n    protected readonly sortObjs?: ISortItem[],\n    protected readonly extra?: ISortQueryExtra,\n    protected readonly context?: IRecordQuerySortContext\n  ) {}\n\n  appendSortBuilder(): Knex.QueryBuilder {\n    return this.parseSorts(this.originQueryBuilder, this.sortObjs);\n  }\n\n  getRawSortSQLText(): string {\n    return this.genSortSQL(this.sortObjs);\n  }\n\n  private genSortSQL(sortObjs?: ISortItem[]) {\n    const defaultSortSql = this.knex.raw(`?? ASC`, ['__auto_number']).toQuery();\n    if (!sortObjs?.length) {\n      return defaultSortSql;\n    }\n    const sortClauses = sortObjs\n      .map(({ fieldId, order }) => {\n        const field = this.fields && this.fields[fieldId];\n        if (!field) {\n          return undefined;\n        }\n        return this.getSortAdapter(field).generateSQL(order);\n      })\n      .filter((clause): clause is string => typeof clause === 'string' && clause.length > 0);\n\n    if (!sortClauses.length) {\n      return defaultSortSql;\n    }\n\n    sortClauses.push(defaultSortSql);\n    return sortClauses.join(', ');\n  }\n\n  private parseSorts(queryBuilder: Knex.QueryBuilder, sortObjs?: ISortItem[]): Knex.QueryBuilder {\n    if (!sortObjs || !sortObjs.length) {\n      return queryBuilder;\n    }\n\n    sortObjs.forEach(({ fieldId, order }) => {\n      const field = this.fields && this.fields[fieldId];\n      if (!field) {\n        return queryBuilder;\n      }\n\n      this.getSortAdapter(field).compiler(queryBuilder, order);\n    });\n\n    return queryBuilder;\n  }\n\n  private getSortAdapter(field: FieldCore): AbstractSortFunction {\n    const { dbFieldType } = field;\n    switch (field.cellValueType) {\n      case CellValueType.Boolean:\n        return this.booleanSort(field, this.context);\n      case CellValueType.Number:\n        return this.numberSort(field, this.context);\n      case CellValueType.DateTime:\n        return this.dateTimeSort(field, this.context);\n      case CellValueType.String: {\n        if (dbFieldType === DbFieldType.Json) {\n          return this.jsonSort(field, this.context);\n        }\n        return this.stringSort(field, this.context);\n      }\n    }\n  }\n\n  abstract booleanSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;\n\n  abstract numberSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;\n\n  abstract dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;\n\n  abstract stringSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;\n\n  abstract jsonSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sort-query.interface.ts",
    "content": "import type { Knex } from 'knex';\n\nexport interface ISortQueryInterface {\n  appendSortBuilder(): Knex.QueryBuilder;\n  getRawSortSQLText(): string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-datetime-sort.adapter.ts",
    "content": "import { TimeFormatting, type DateFormattingPreset, type IDateFieldOptions } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getSqliteDateTimeFormatString } from '../../../group-query/format-string';\nimport { getOffset } from '../../../search-query/get-offset';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class MultipleDateTimeSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    const orderByColumn =\n      time === TimeFormatting.None\n        ? this.knex.raw(\n            `\n      (\n        SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')\n        FROM json_each(${this.columnName}) as elem\n      ) ASC NULLS FIRST\n      `,\n            [formatString, offsetString]\n          )\n        : this.knex.raw(\n            `\n      (\n        SELECT group_concat(elem.value, ', ')\n        FROM json_each(${this.columnName}) as elem\n      ) ASC NULLS FIRST\n      `\n          );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    const orderByColumn =\n      time === TimeFormatting.None\n        ? this.knex.raw(\n            `\n      (\n        SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')\n        FROM json_each(${this.columnName}) as elem\n      ) DESC NULLS LAST\n      `,\n            [formatString, offsetString]\n          )\n        : this.knex.raw(\n            `\n      (\n        SELECT group_concat(elem.value, ', ')\n        FROM json_each(${this.columnName}) as elem\n      ) DESC NULLS LAST\n      `\n          );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(\n          `\n        (\n          SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')\n          FROM json_each(${this.columnName}) as elem\n        ) ASC NULLS FIRST\n        `,\n          [formatString, offsetString]\n        )\n        .toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `\n        (\n          SELECT group_concat(elem.value, ', ')\n          FROM json_each(${this.columnName}) as elem\n        ) ASC NULLS FIRST\n        `\n        )\n        .toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(\n          `\n        (\n          SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')\n          FROM json_each(${this.columnName}) as elem\n        ) DESC NULLS LAST\n        `,\n          [formatString, offsetString]\n        )\n        .toQuery();\n    } else {\n      return this.knex\n        .raw(\n          `\n        (\n          SELECT group_concat(elem.value, ', ')\n          FROM json_each(${this.columnName}) as elem\n        ) DESC NULLS LAST\n        `\n        )\n        .toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-json-sort.adapter.ts",
    "content": "import type { Knex } from 'knex';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class MultipleJsonSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    builderClient.orderByRaw(\n      `\n      json_extract(${this.columnName}, '$[0]') ASC NULLS FIRST,\n      json_array_length${this.columnName} ASC NULLS FIRST\n      `\n    );\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    builderClient.orderByRaw(\n      `\n      json_extract(${this.columnName}, '$[0]') DESC NULLS LAST,\n      json_array_length(${this.columnName}) DESC NULLS LAST\n      `\n    );\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    return this.knex\n      .raw(\n        `\n        json_extract(${this.columnName}, '$[0]') ASC NULLS FIRST,\n        json_array_length(${this.columnName}) ASC NULLS FIRST\n        `\n      )\n      .toQuery();\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    return this.knex\n      .raw(\n        `\n        json_extract(${this.columnName}, '$[0]') DESC NULLS LAST,\n        json_array_length(${this.columnName}) DESC NULLS LAST\n        `\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-number-sort.adapter.ts",
    "content": "import type { INumberFieldOptions } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class MultipleNumberSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    const orderByColumn = this.knex.raw(\n      `\n      (\n        SELECT group_concat(ROUND(elem.value, ?))\n        FROM json_each(${this.columnName}) as elem\n      ) ASC NULLS FIRST\n      `,\n      [precision]\n    );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    const orderByColumn = this.knex.raw(\n      `\n      (\n        SELECT group_concat(ROUND(elem.value, ?))\n        FROM json_each(${this.columnName}) as elem\n      ) DESC NULLS LAST\n      `,\n      [precision]\n    );\n    builderClient.orderByRaw(orderByColumn);\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    return this.knex\n      .raw(\n        `\n      (\n        SELECT group_concat(ROUND(elem.value, ?))\n        FROM json_each(${this.columnName}) as elem\n      ) ASC NULLS FIRST\n      `,\n        [precision]\n      )\n      .toQuery();\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { precision } = (options as INumberFieldOptions).formatting;\n    return this.knex\n      .raw(\n        `\n      (\n        SELECT group_concat(ROUND(elem.value, ?))\n        FROM json_each(${this.columnName}) as elem\n      ) DESC NULLS LAST\n      `,\n        [precision]\n      )\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/date-sort.adapter.ts",
    "content": "import { type IDateFieldOptions, type DateFormattingPreset, TimeFormatting } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getSqliteDateTimeFormatString } from '../../../group-query/format-string';\nimport { getOffset } from '../../../search-query/get-offset';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class DateSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      builderClient.orderByRaw('strftime(?, DATETIME(${this.columnName}, ?)) ASC NULLS FIRST', [\n        formatString,\n        offsetString,\n      ]);\n    } else {\n      builderClient.orderByRaw('${this.columnName} ASC NULLS FIRST');\n    }\n\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      builderClient.orderByRaw(`strftime(?, DATETIME(${this.columnName}, ?)) DESC NULLS LAST`, [\n        formatString,\n        offsetString,\n      ]);\n    } else {\n      builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);\n    }\n\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(`strftime(?, DATETIME(${this.columnName}, ?)) ASC NULLS FIRST`, [\n          formatString,\n          offsetString,\n        ])\n        .toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { options } = this.field;\n    const { date, time, timeZone } = (options as IDateFieldOptions).formatting;\n    const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);\n    const offsetString = `${getOffset(timeZone)} hour`;\n\n    if (time === TimeFormatting.None) {\n      return this.knex\n        .raw(`strftime(?, DATETIME(${this.columnName}, ?)) DESC NULLS LAST`, [\n          formatString,\n          offsetString,\n        ])\n        .toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/json-sort.adapter.ts",
    "content": "import type { Knex } from 'knex';\nimport { isUserOrLink } from '../../../../utils/is-user-or-link';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class JsonSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(`json_extract(${this.columnName}, '$.title') ASC NULLS FIRST`);\n    } else {\n      builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);\n    }\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      builderClient.orderByRaw(`json_extract(${this.columnName}, '$.title') DESC NULLS LAST`);\n    } else {\n      builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);\n    }\n    return builderClient;\n  }\n\n  getAscSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex.raw(`json_extract(${this.columnName}, '$.title') ASC NULLS FIRST`).toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();\n    }\n  }\n\n  getDescSQL() {\n    if (!this.columnName) {\n      return undefined;\n    }\n    const { type } = this.field;\n\n    if (isUserOrLink(type)) {\n      return this.knex.raw(`json_extract(${this.columnName}, '$.title') DESC NULLS LAST`).toQuery();\n    } else {\n      return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/string-sort.adapter.ts",
    "content": "import type { ISelectFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { SortFunctionSqlite } from '../sort-query.function';\n\nexport class StringSortAdapter extends SortFunctionSqlite {\n  asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.asc(builderClient);\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    builderClient.orderByRaw(\n      `${this.generateOrderByCase(optionSets, this.columnName)} ASC NULLS FIRST`\n    );\n    return builderClient;\n  }\n\n  desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {\n    if (!this.columnName) {\n      return builderClient;\n    }\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.desc(builderClient);\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    builderClient.orderByRaw(\n      `${this.generateOrderByCase(optionSets, this.columnName)} DESC NULLS LAST`\n    );\n    return builderClient;\n  }\n\n  getAscSQL() {\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.getAscSQL();\n    }\n    if (!this.columnName) {\n      return undefined;\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    return this.knex\n      .raw(`${this.generateOrderByCase(optionSets, this.columnName)} ASC NULLS FIRST`)\n      .toQuery();\n  }\n\n  getDescSQL() {\n    const { type, options } = this.field;\n\n    if (type !== FieldType.SingleSelect) {\n      return super.getDescSQL();\n    }\n    if (!this.columnName) {\n      return undefined;\n    }\n\n    const { choices } = options as ISelectFieldOptions;\n\n    const optionSets = choices.map(({ name }) => name);\n    return this.knex\n      .raw(`${this.generateOrderByCase(optionSets, this.columnName)} DESC NULLS LAST`)\n      .toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.function.ts",
    "content": "import { AbstractSortFunction } from '../function/sort-function.abstract';\n\nexport class SortFunctionSqlite extends AbstractSortFunction {\n  generateOrderByCase(keys: string[], columnName: string): string {\n    const cases = keys.map((key, index) => `WHEN '${key}' THEN ${index + 1}`).join(' ');\n    return `CASE ${columnName} ${cases} ELSE -1 END`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.sqlite.ts",
    "content": "import type { FieldCore } from '@teable/core';\nimport type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';\nimport { AbstractSortQuery } from '../sort-query.abstract';\nimport { MultipleDateTimeSortAdapter } from './multiple-value/multiple-datetime-sort.adapter';\nimport { MultipleJsonSortAdapter } from './multiple-value/multiple-json-sort.adapter';\nimport { MultipleNumberSortAdapter } from './multiple-value/multiple-number-sort.adapter';\nimport { DateSortAdapter } from './single-value/date-sort.adapter';\nimport { JsonSortAdapter } from './single-value/json-sort.adapter';\nimport { StringSortAdapter } from './single-value/string-sort.adapter';\nimport { SortFunctionSqlite } from './sort-query.function';\n\nexport class SortQuerySqlite extends AbstractSortQuery {\n  booleanSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {\n    return new SortFunctionSqlite(this.knex, field, context);\n  }\n\n  numberSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleNumberSortAdapter(this.knex, field, context);\n    }\n    return new SortFunctionSqlite(this.knex, field, context);\n  }\n\n  dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleDateTimeSortAdapter(this.knex, field, context);\n    }\n    return new DateSortAdapter(this.knex, field, context);\n  }\n\n  stringSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new SortFunctionSqlite(this.knex, field, context);\n    }\n    return new StringSortAdapter(this.knex, field, context);\n  }\n  jsonSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {\n    const { isMultipleCellValue } = field;\n    if (isMultipleCellValue) {\n      return new MultipleJsonSortAdapter(this.knex, field, context);\n    }\n    return new JsonSortAdapter(this.knex, field, context);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/sqlite.provider.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Logger } from '@nestjs/common';\nimport type {\n  IFilter,\n  ILookupLinkOptionsVo,\n  ISortItem,\n  FieldCore,\n  TableDomain,\n} from '@teable/core';\nimport { DriverClient, parseFormulaToSQL, FieldType } from '@teable/core';\nimport type { PrismaClient } from '@teable/db-main-prisma';\nimport type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldInstance } from '../features/field/model/factory';\nimport type {\n  IRecordQueryFilterContext,\n  IRecordQuerySortContext,\n  IRecordQueryGroupContext,\n  IRecordQueryAggregateContext,\n} from '../features/record/query-builder/record-query-builder.interface';\nimport type {\n  IGeneratedColumnQueryInterface,\n  IFormulaConversionContext,\n  IFormulaConversionResult,\n  ISelectQueryInterface,\n  ISelectFormulaConversionContext,\n} from '../features/record/query-builder/sql-conversion.visitor';\nimport {\n  GeneratedColumnSqlConversionVisitor,\n  SelectColumnSqlConversionVisitor,\n} from '../features/record/query-builder/sql-conversion.visitor';\nimport type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';\nimport { AggregationQuerySqlite } from './aggregation-query/sqlite/aggregation-query.sqlite';\nimport type { BaseQueryAbstract } from './base-query/abstract';\nimport { BaseQuerySqlite } from './base-query/base-query.sqlite';\nimport type { ICreateDatabaseColumnContext } from './create-database-column-query/create-database-column-field-visitor.interface';\nimport { CreateSqliteDatabaseColumnFieldVisitor } from './create-database-column-query/create-database-column-field-visitor.sqlite';\nimport type {\n  IAggregationQueryExtra,\n  ICalendarDailyCollectionQueryProps,\n  IDbProvider,\n  IFilterQueryExtra,\n  ISortQueryExtra,\n} from './db.provider.interface';\nimport type {\n  IDropDatabaseColumnContext,\n  DropColumnOperationType,\n} from './drop-database-column-query/drop-database-column-field-visitor.interface';\nimport { DropSqliteDatabaseColumnFieldVisitor } from './drop-database-column-query/drop-database-column-field-visitor.sqlite';\nimport { DuplicateAttachmentTableQuerySqlite } from './duplicate-table/duplicate-attachment-table-query.sqlite';\nimport { DuplicateTableQuerySqlite } from './duplicate-table/duplicate-query.sqlite';\nimport type { IFilterQueryInterface } from './filter-query/filter-query.interface';\nimport { FilterQuerySqlite } from './filter-query/sqlite/filter-query.sqlite';\nimport { GeneratedColumnQuerySqlite } from './generated-column-query/sqlite/generated-column-query.sqlite';\nimport type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';\nimport { GroupQuerySqlite } from './group-query/group-query.sqlite';\nimport type { IntegrityQueryAbstract } from './integrity-query/abstract';\nimport { IntegrityQuerySqlite } from './integrity-query/integrity-query.sqlite';\nimport { SearchQueryAbstract } from './search-query/abstract';\nimport { getOffset } from './search-query/get-offset';\nimport { IndexBuilderSqlite } from './search-query/search-index-builder.sqlite';\nimport { SearchQuerySqliteBuilder, SearchQuerySqlite } from './search-query/search-query.sqlite';\nimport { SelectQuerySqlite } from './select-query/sqlite/select-query.sqlite';\nimport type { ISortQueryInterface } from './sort-query/sort-query.interface';\nimport { SortQuerySqlite } from './sort-query/sqlite/sort-query.sqlite';\n\nexport class SqliteProvider implements IDbProvider {\n  private readonly logger = new Logger(SqliteProvider.name);\n\n  constructor(private readonly knex: Knex) {}\n\n  driver = DriverClient.Sqlite;\n\n  createSchema(_schemaName: string) {\n    return undefined;\n  }\n\n  dropSchema(_schemaName: string) {\n    return undefined;\n  }\n\n  generateDbTableName(baseId: string, name: string) {\n    return `${baseId}_${name}`;\n  }\n\n  // make no-sense\n  getForeignKeysInfo(_tableName: string): string {\n    return this.knex\n      .raw(\n        'SELECT NULL as constraint_name, NULL as column_name, NULL as referenced_column_name, NULL as referenced_table_schema, NULL as referenced_table_name WHERE 1=0'\n      )\n      .toQuery();\n  }\n\n  renameTableName(oldTableName: string, newTableName: string) {\n    return [this.knex.raw('ALTER TABLE ?? RENAME TO ??', [oldTableName, newTableName]).toQuery()];\n  }\n\n  dropTable(tableName: string): string {\n    return this.knex.raw('DROP TABLE IF EXISTS ??', [tableName]).toQuery();\n  }\n\n  async checkColumnExist(\n    tableName: string,\n    columnName: string,\n    prisma: PrismaClient\n  ): Promise<boolean> {\n    const sql = this.columnInfo(tableName);\n    const columns = await prisma.$queryRawUnsafe<{ name: string }[]>(sql);\n    return columns.some((column) => column.name === columnName);\n  }\n\n  checkTableExist(tableName: string): string {\n    return this.knex\n      .raw(\n        `SELECT EXISTS (\n          SELECT 1 FROM sqlite_master \n          WHERE type='table' AND name = ?\n        ) as \"exists\"`,\n        [tableName]\n      )\n      .toQuery();\n  }\n\n  renameColumn(tableName: string, oldName: string, newName: string): string[] {\n    return [\n      this.knex\n        .raw('ALTER TABLE ?? RENAME COLUMN ?? TO ??', [tableName, oldName, newName])\n        .toQuery(),\n    ];\n  }\n\n  modifyColumnSchema(\n    tableName: string,\n    oldFieldInstance: IFieldInstance,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> }\n  ): string[] {\n    const queries: string[] = [];\n\n    // First, drop ALL columns associated with the field (including generated columns)\n    queries.push(...this.dropColumn(tableName, oldFieldInstance, linkContext));\n\n    // For Link fields, delegate creation to link service to avoid double creation\n    if (fieldInstance.type === FieldType.Link && !fieldInstance.isLookup) {\n      return queries;\n    }\n\n    const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {\n      const createContext: ICreateDatabaseColumnContext = {\n        table,\n        field: fieldInstance,\n        fieldId: fieldInstance.id,\n        dbFieldName: fieldInstance.dbFieldName,\n        unique: fieldInstance.unique,\n        notNull: fieldInstance.notNull,\n        dbProvider: this,\n        tableDomain,\n        tableId: linkContext?.tableId || '',\n        tableName,\n        knex: this.knex,\n        tableNameMap: linkContext?.tableNameMap || new Map(),\n      };\n\n      // Use visitor pattern to recreate columns\n      const visitor = new CreateSqliteDatabaseColumnFieldVisitor(createContext);\n      fieldInstance.accept(visitor);\n    });\n\n    const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);\n    queries.push(...alterTableQueries);\n\n    return queries;\n  }\n\n  createColumnSchema(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    tableDomain: TableDomain,\n    isNewTable: boolean,\n    tableId: string,\n    tableNameMap: Map<string, string>,\n    isSymmetricField?: boolean,\n    skipBaseColumnCreation?: boolean\n  ): string[] {\n    let visitor: CreateSqliteDatabaseColumnFieldVisitor | undefined = undefined;\n    const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {\n      const context: ICreateDatabaseColumnContext = {\n        table,\n        field: fieldInstance,\n        fieldId: fieldInstance.id,\n        dbFieldName: fieldInstance.dbFieldName,\n        unique: fieldInstance.unique,\n        notNull: fieldInstance.notNull,\n        dbProvider: this,\n        tableDomain,\n        isNewTable,\n        tableId,\n        tableName,\n        knex: this.knex,\n        tableNameMap,\n        isSymmetricField,\n        skipBaseColumnCreation,\n      };\n      visitor = new CreateSqliteDatabaseColumnFieldVisitor(context);\n      fieldInstance.accept(visitor);\n    });\n\n    const mainSqls = alterTableBuilder.toSQL().map((item) => item.sql);\n    const additionalSqls =\n      (visitor as CreateSqliteDatabaseColumnFieldVisitor | undefined)?.getSql() ?? [];\n\n    return [...mainSqls, ...additionalSqls];\n  }\n\n  splitTableName(tableName: string): string[] {\n    return tableName.split('_');\n  }\n\n  joinDbTableName(schemaName: string, dbTableName: string) {\n    return `${schemaName}_${dbTableName}`;\n  }\n\n  dropColumn(\n    tableName: string,\n    fieldInstance: IFieldInstance,\n    linkContext?: { tableId: string; tableNameMap: Map<string, string> },\n    operationType?: DropColumnOperationType\n  ): string[] {\n    const context: IDropDatabaseColumnContext = {\n      tableName,\n      knex: this.knex,\n      linkContext,\n      operationType,\n    };\n\n    // Use visitor pattern to drop columns\n    const visitor = new DropSqliteDatabaseColumnFieldVisitor(context);\n    return fieldInstance.accept(visitor);\n  }\n\n  dropColumnAndIndex(tableName: string, columnName: string, indexName: string): string[] {\n    return [\n      this.knex.raw(`DROP INDEX IF EXISTS ??`, [indexName]).toQuery(),\n      this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, columnName]).toQuery(),\n    ];\n  }\n\n  columnInfo(tableName: string): string {\n    return this.knex.raw(`PRAGMA table_info(??)`, [tableName]).toQuery();\n  }\n\n  updateJsonColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string {\n    return this.knex(tableName)\n      .where(this.knex.raw(`json_extract(${columnName}, '$.id') = ?`, [id]))\n      .update({\n        [columnName]: this.knex.raw(\n          `\n          json_patch(${columnName}, json_object(?, ?))\n        `,\n          [key, value]\n        ),\n      })\n      .toQuery();\n  }\n\n  updateJsonArrayColumn(\n    tableName: string,\n    columnName: string,\n    id: string,\n    key: string,\n    value: string\n  ): string {\n    return this.knex(tableName)\n      .update({\n        [columnName]: this.knex.raw(\n          `\n          json(\n            (\n              SELECT json_group_array(\n                json(\n                  CASE\n                    WHEN json_extract(value, '$.id') = ?\n                    THEN json_patch(value, json_object(?, ?))\n                    ELSE value\n                  END\n                )\n              )\n              FROM json_each(${columnName})\n            )\n          )\n        `,\n          [id, key, value]\n        ),\n      })\n      .toQuery();\n  }\n\n  duplicateTable(\n    fromSchema: string,\n    toSchema: string,\n    tableName: string,\n    withData?: boolean\n  ): string {\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    const [_, dbTableName] = this.splitTableName(tableName);\n    return this.knex\n      .raw(`CREATE TABLE ?? AS SELECT * FROM ?? ${withData ? '' : 'WHERE 1=0'}`, [\n        this.joinDbTableName(toSchema, dbTableName),\n        this.joinDbTableName(fromSchema, dbTableName),\n      ])\n      .toQuery();\n  }\n\n  alterAutoNumber(_tableName: string): string[] {\n    return [];\n  }\n\n  batchInsertSql(tableName: string, insertData: ReadonlyArray<unknown>): string {\n    // to-do: The code doesn't taste good because knex utilizes the \"select-stmt\" mode to construct SQL queries for SQLite batchInsert.\n    //  This is a temporary solution, and I'm actively keeping an eye on this issue for further developments.\n    const builder = this.knex.client.queryBuilder();\n    builder.insert(insertData).into(tableName).toSQL();\n\n    const { _single } = builder;\n    const compiler = this.knex.client.queryCompiler(builder);\n\n    const insertValues = _single.insert || [];\n    const sql = `insert into ${compiler.tableName} `;\n    const body = compiler._insertBody(insertValues);\n    const bindings = compiler.bindings;\n    return this.knex.raw(sql + body, bindings).toQuery();\n  }\n\n  executeUpdateRecordsSqlList(params: {\n    dbTableName: string;\n    tempTableName: string;\n    idFieldName: string;\n    dbFieldNames: string[];\n    data: { id: string; values: { [key: string]: unknown } }[];\n  }) {\n    const { dbTableName, tempTableName, idFieldName, dbFieldNames, data } = params;\n    const insertRowsData = data.map((item) => {\n      return {\n        [idFieldName]: item.id,\n        ...item.values,\n      };\n    });\n\n    // initialize temporary table data\n    const insertTempTableSql = this.batchInsertSql(tempTableName, insertRowsData);\n\n    // update data\n    const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((pre, columnName) => {\n      pre[columnName] = this.knex.ref(`${tempTableName}.${columnName}`);\n      return pre;\n    }, {});\n    let updateRecordSql = this.knex(dbTableName).update(updateColumns).toQuery();\n    updateRecordSql += ` FROM \\`${tempTableName}\\` WHERE ${dbTableName}.${idFieldName} = ${tempTableName}.${idFieldName}`;\n\n    return { insertTempTableSql, updateRecordSql };\n  }\n\n  updateFromSelectSql(params: {\n    dbTableName: string;\n    idFieldName: string;\n    subQuery: Knex.QueryBuilder;\n    dbFieldNames: string[];\n    returningDbFieldNames?: string[];\n    restrictRecordIds?: string[];\n  }): string {\n    const {\n      dbTableName,\n      idFieldName,\n      subQuery,\n      dbFieldNames,\n      returningDbFieldNames,\n      restrictRecordIds,\n    } = params;\n    const subQuerySql = subQuery.toQuery();\n    const wrap = (id: string) => this.knex.client.wrapIdentifier(id);\n    const setClauses = dbFieldNames.map(\n      (c) =>\n        `${wrap(c)} = (SELECT s.${wrap(c)} FROM (${subQuerySql}) AS s WHERE s.${wrap(\n          idFieldName\n        )} = ${dbTableName}.${wrap(idFieldName)})`\n    );\n    const wrappedVersion = wrap('__version');\n    // Always bump __version so published ShareDB ops stay aligned with DB state\n    setClauses.push(`${wrappedVersion} = ${dbTableName}.${wrappedVersion} + 1`);\n    const setClause = setClauses.join(', ');\n    const returningColumns = [\n      wrap(idFieldName),\n      wrappedVersion,\n      `${dbTableName}.${wrappedVersion} - 1 as ${wrap('__prev_version')}`,\n      ...(returningDbFieldNames || dbFieldNames).map((c) => wrap(c)),\n    ];\n    const returning = returningColumns.join(', ');\n    const restrictClause =\n      restrictRecordIds && restrictRecordIds.length\n        ? ` AND ${dbTableName}.${wrap(idFieldName)} IN (${restrictRecordIds\n            .map((id) => `'${id.replace(/'/g, \"''\")}'`)\n            .join(', ')})`\n        : '';\n    return `UPDATE ${dbTableName} SET ${setClause} WHERE EXISTS (SELECT 1 FROM (${subQuerySql}) AS s WHERE s.${wrap(\n      idFieldName\n    )} = ${dbTableName}.${wrap(idFieldName)})${restrictClause} RETURNING ${returning}`;\n  }\n\n  aggregationQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    aggregationFields?: IAggregationField[],\n    extra?: IAggregationQueryExtra,\n    context?: IRecordQueryAggregateContext\n  ): IAggregationQueryInterface {\n    return new AggregationQuerySqlite(\n      this.knex,\n      originQueryBuilder,\n      fields,\n      aggregationFields,\n      extra,\n      context\n    );\n  }\n\n  filterQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [p: string]: FieldCore },\n    filter?: IFilter,\n    extra?: IFilterQueryExtra,\n    context?: IRecordQueryFilterContext\n  ): IFilterQueryInterface {\n    return new FilterQuerySqlite(originQueryBuilder, fields, filter, extra, this, context);\n  }\n\n  sortQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fields?: { [fieldId: string]: FieldCore },\n    sortObjs?: ISortItem[],\n    extra?: ISortQueryExtra,\n    context?: IRecordQuerySortContext\n  ): ISortQueryInterface {\n    return new SortQuerySqlite(this.knex, originQueryBuilder, fields, sortObjs, extra, context);\n  }\n\n  groupQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    fieldMap?: { [fieldId: string]: IFieldInstance },\n    groupFieldIds?: string[],\n    extra?: IGroupQueryExtra,\n    context?: IRecordQueryGroupContext\n  ): IGroupQueryInterface {\n    return new GroupQuerySqlite(\n      this.knex,\n      originQueryBuilder,\n      fieldMap,\n      groupFieldIds,\n      extra,\n      context\n    );\n  }\n\n  searchQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchFields: IFieldInstance[],\n    tableIndex: TableIndex[],\n    search: [string, string?, boolean?],\n    context?: IRecordQueryFilterContext\n  ) {\n    return SearchQueryAbstract.appendQueryBuilder(\n      SearchQuerySqlite,\n      originQueryBuilder,\n      searchFields,\n      tableIndex,\n      search,\n      context\n    );\n  }\n\n  searchCountQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    searchField: IFieldInstance[],\n    search: [string, string?, boolean?],\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext\n  ) {\n    return SearchQueryAbstract.buildSearchCountQuery(\n      SearchQuerySqlite,\n      originQueryBuilder,\n      searchField,\n      search,\n      tableIndex,\n      context\n    );\n  }\n\n  searchIndexQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbTableName: string,\n    searchField: IFieldInstance[],\n    searchIndexRo: ISearchIndexByQueryRo,\n    tableIndex: TableIndex[],\n    context?: IRecordQueryFilterContext,\n    baseSortIndex?: string,\n    setFilterQuery?: (qb: Knex.QueryBuilder) => void,\n    setSortQuery?: (qb: Knex.QueryBuilder) => void\n  ) {\n    return new SearchQuerySqliteBuilder(\n      originQueryBuilder,\n      dbTableName,\n      searchField,\n      searchIndexRo,\n      tableIndex,\n      context,\n      baseSortIndex,\n      setFilterQuery,\n      setSortQuery\n    ).getSearchIndexQuery();\n  }\n\n  searchIndex() {\n    return new IndexBuilderSqlite();\n  }\n\n  duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {\n    return new DuplicateTableQuerySqlite(queryBuilder);\n  }\n\n  duplicateAttachmentTableQuery(queryBuilder: Knex.QueryBuilder) {\n    return new DuplicateAttachmentTableQuerySqlite(queryBuilder);\n  }\n\n  shareFilterCollaboratorsQuery(\n    originQueryBuilder: Knex.QueryBuilder,\n    dbFieldName: string,\n    isMultipleCellValue?: boolean | null\n  ) {\n    if (isMultipleCellValue) {\n      originQueryBuilder\n        .distinct(this.knex.raw(`json_extract(json_each.value, '$.id') AS user_id`))\n        .crossJoin(this.knex.raw(`json_each(${dbFieldName})`));\n    } else {\n      originQueryBuilder.distinct(this.knex.raw(`json_extract(${dbFieldName}, '$.id') AS user_id`));\n    }\n  }\n\n  baseQuery(): BaseQueryAbstract {\n    return new BaseQuerySqlite(this.knex);\n  }\n\n  integrityQuery(): IntegrityQueryAbstract {\n    return new IntegrityQuerySqlite(this.knex);\n  }\n\n  calendarDailyCollectionQuery(\n    qb: Knex.QueryBuilder,\n    props: ICalendarDailyCollectionQueryProps\n  ): Knex.QueryBuilder {\n    const { startDate, endDate, startField, endField } = props;\n    const timezone = startField.options.formatting.timeZone;\n    const offsetStr = `${getOffset(timezone)} hour`;\n\n    const datesSubquery = this.knex.raw(\n      `WITH RECURSIVE dates(date) AS (\n        SELECT date(datetime(?, ?)) as date\n        UNION ALL\n        SELECT date(datetime(date, ?))\n        FROM dates\n        WHERE date < date(datetime(?, ?))\n      )\n      SELECT date FROM dates`,\n      [startDate, offsetStr, '+1 day', endDate, offsetStr]\n    );\n\n    return qb\n      .select([\n        this.knex.raw('d.date'),\n        this.knex.raw('COUNT(*) as count'),\n        this.knex.raw('GROUP_CONCAT(??) as ids', ['__id']),\n      ])\n      .crossJoin(datesSubquery.wrap('(', ') as d'))\n      .where((builder) => {\n        builder\n          .whereRaw(`date(datetime(??, ?)) <= date(datetime(?, ?))`, [\n            startField.dbFieldName,\n            offsetStr,\n            endDate,\n            offsetStr,\n          ])\n          .andWhere(\n            this.knex.raw(`date(datetime(COALESCE(??, ??), ?))`, [\n              endField.dbFieldName,\n              startField.dbFieldName,\n              offsetStr,\n            ]),\n            '>=',\n            this.knex.raw(`date(datetime(?, ?))`, [startDate, offsetStr])\n          );\n      })\n      .andWhere((builder) => {\n        builder.whereRaw(\n          `date(datetime(??, ?)) <= d.date AND date(datetime(COALESCE(??, ??), ?)) >= d.date`,\n          [\n            startField.dbFieldName,\n            offsetStr,\n            endField.dbFieldName,\n            startField.dbFieldName,\n            offsetStr,\n          ]\n        );\n      })\n      .groupBy('d.date')\n      .orderBy('d.date', 'asc');\n  }\n\n  // select id and lookup_options for \"field\" table options is a json saved in string format, match optionsKey and value\n  // please use json method in sqlite\n  lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string {\n    return this.knex('field')\n      .select({\n        tableId: 'table_id',\n        id: 'id',\n        type: 'type',\n        name: 'name',\n        lookupOptions: 'lookup_options',\n      })\n      .whereNull('deleted_time')\n      .whereRaw(`json_extract(lookup_options, '$.\"${optionsKey}\"') = ?`, [value])\n      .toQuery();\n  }\n\n  optionsQuery(type: FieldType, optionsKey: string, value: string): string {\n    return this.knex('field')\n      .select({\n        tableId: 'table_id',\n        id: 'id',\n        name: 'name',\n        description: 'description',\n        notNull: 'not_null',\n        unique: 'unique',\n        isPrimary: 'is_primary',\n        dbFieldName: 'db_field_name',\n        isComputed: 'is_computed',\n        isPending: 'is_pending',\n        hasError: 'has_error',\n        dbFieldType: 'db_field_type',\n        isMultipleCellValue: 'is_multiple_cell_value',\n        isLookup: 'is_lookup',\n        lookupOptions: 'lookup_options',\n        type: 'type',\n        options: 'options',\n        cellValueType: 'cell_value_type',\n      })\n      .where('type', type)\n      .whereNull('is_lookup')\n      .whereNull('deleted_time')\n      .whereRaw(`json_extract(options, '$.\"${optionsKey}\"') = ?`, [value])\n      .toQuery();\n  }\n\n  searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {\n    return qb.where((builder) => {\n      search.forEach(([field, value]) => {\n        builder.orWhereRaw('LOWER(??) LIKE LOWER(?)', [field, `%${value}%`]);\n      });\n    });\n  }\n\n  getTableIndexes(dbTableName: string): string {\n    return this.knex\n      .raw(\n        `SELECT\n    s.name AS name,\n    (SELECT \"unique\" FROM pragma_index_list(s.tbl_name) WHERE name = s.name) AS isUnique,\n    (SELECT json_group_array(name) FROM pragma_index_info(s.name) ORDER BY seqno) AS columns\nFROM\n    sqlite_schema AS s\nWHERE\n    s.type = 'index'\n    AND s.tbl_name = ?\nORDER BY\n    s.name;`,\n        [dbTableName]\n      )\n      .toQuery();\n  }\n\n  generatedColumnQuery(): IGeneratedColumnQueryInterface {\n    return new GeneratedColumnQuerySqlite();\n  }\n  convertFormulaToGeneratedColumn(\n    expression: string,\n    context: IFormulaConversionContext\n  ): IFormulaConversionResult {\n    try {\n      const generatedColumnQuery = this.generatedColumnQuery();\n      // Set the context with driver client information\n      const contextWithDriver = { ...context, driverClient: this.driver };\n      generatedColumnQuery.setContext(contextWithDriver);\n\n      const visitor = new GeneratedColumnSqlConversionVisitor(\n        this.knex,\n        generatedColumnQuery,\n        contextWithDriver\n      );\n\n      const sql = parseFormulaToSQL(expression, visitor);\n\n      return visitor.getResult(sql);\n    } catch (error) {\n      throw new Error(`Failed to convert formula: ${(error as Error).message}`);\n    }\n  }\n\n  selectQuery(): ISelectQueryInterface {\n    return new SelectQuerySqlite();\n  }\n\n  convertFormulaToSelectQuery(\n    expression: string,\n    context: ISelectFormulaConversionContext\n  ): string {\n    try {\n      const selectQuery = this.selectQuery();\n      // Set the context with driver client information\n      const contextWithDriver = { ...context, driverClient: this.driver };\n      selectQuery.setContext(contextWithDriver);\n\n      const visitor = new SelectColumnSqlConversionVisitor(\n        this.knex,\n        selectQuery,\n        contextWithDriver\n      );\n\n      return parseFormulaToSQL(expression, visitor);\n    } catch (error) {\n      throw new Error(`Failed to convert formula: ${(error as Error).message}`);\n    }\n  }\n\n  generateDatabaseViewName(tableId: string): string {\n    return tableId + '_view';\n  }\n\n  createDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {\n    const viewName = this.generateDatabaseViewName(table.id);\n    return [this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery()];\n  }\n\n  recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {\n    const viewName = this.generateDatabaseViewName(table.id);\n    return [\n      this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery(),\n      this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery(),\n    ];\n  }\n\n  dropDatabaseView(tableId: string): string[] {\n    const viewName = this.generateDatabaseViewName(tableId);\n    return [this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery()];\n  }\n\n  // SQLite views are not materialized; nothing to refresh\n  refreshDatabaseView(_tableId: string): string | undefined {\n    return undefined;\n  }\n\n  createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string {\n    const viewName = this.generateDatabaseViewName(table.id);\n    return this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery();\n  }\n\n  dropMaterializedView(tableId: string): string {\n    const viewName = this.generateDatabaseViewName(tableId);\n    return this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/utils/datetime-format.util.ts",
    "content": "export {\n  DATETIME_FORMAT_SQL_BUILDERS,\n  DATETIME_FORMAT_TOKEN_TO_POSTGRES,\n  DEFAULT_DATETIME_FORMAT_EXPR,\n  DEFAULT_DATETIME_FORMAT_LITERAL,\n  LOCALIZED_DATETIME_FORMAT_MAP,\n  buildDatetimeFormatSql,\n  buildDatetimeParseGuardRegex,\n  expandLocalizedDatetimeFormat,\n  hasDatetimeTimezoneToken,\n  normalizeDatetimeFormatExpression,\n  type ILocalizedDatetimeFormatToken,\n  type ISupportedDatetimeFormatToken,\n} from '@teable/formula';\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getDefaultDatetimeParsePattern } from './default-datetime-parse-pattern';\n\ndescribe('default datetime parse pattern', () => {\n  it('accepts 1-digit hour in ISO-like datetimes', () => {\n    const pattern = new RegExp(getDefaultDatetimeParsePattern());\n    expect(pattern.test('2025-11-01 8:40')).toBe(true);\n    expect(pattern.test('2025-11-01 08:40')).toBe(true);\n  });\n\n  it('accepts single-digit month and day', () => {\n    const pattern = new RegExp(getDefaultDatetimeParsePattern());\n    // Single-digit month\n    expect(pattern.test('2026-9-15')).toBe(true);\n    expect(pattern.test('2026-1-15')).toBe(true);\n    // Single-digit day\n    expect(pattern.test('2026-09-5')).toBe(true);\n    expect(pattern.test('2026-12-1')).toBe(true);\n    // Both single-digit\n    expect(pattern.test('2026-9-5')).toBe(true);\n    expect(pattern.test('2026-1-1')).toBe(true);\n    // Double-digit (still works)\n    expect(pattern.test('2026-09-15')).toBe(true);\n    expect(pattern.test('2026-12-31')).toBe(true);\n  });\n\n  it('treats blank strings as invalid', () => {\n    const pattern = new RegExp(getDefaultDatetimeParsePattern());\n    expect(pattern.test('')).toBe(false);\n    expect(pattern.test(' ')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts",
    "content": "/**\n * Shared default pattern used to guard DATETIME_PARSE inputs.\n * The expression must not contain any literal '?' characters because Knex\n * would misinterpret them as parameter placeholders when embedding the regex.\n */\nexport const DEFAULT_DATETIME_PARSE_PATTERN = (() => {\n  const optional = (expr: string) => `(${expr}|)`;\n  const digitPair = '[0-9]{2}';\n  const hour = '[0-9]{1,2}';\n  const fractionalSeconds = '[.][0-9]{1,6}';\n  const secondSegment = ':' + digitPair + optional(fractionalSeconds);\n  const timeZoneSegment = `(Z|[+-]${digitPair}|[+-]${digitPair}${digitPair}|[+-]${digitPair}:${digitPair})`;\n  const timePart = `[ T]${hour}:${digitPair}` + optional(secondSegment) + optional(timeZoneSegment);\n\n  // Support both single-digit (e.g., 2026-9-15) and double-digit (e.g., 2026-09-15) month/day\n  return '^' + '[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}' + optional(timePart) + '$';\n})();\n\nexport const getDefaultDatetimeParsePattern = (): string => DEFAULT_DATETIME_PARSE_PATTERN;\n"
  },
  {
    "path": "apps/nestjs-backend/src/db-provider/utils/formula-param-metadata.util.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { DbFieldType } from '@teable/core';\nimport type { FormulaParamType, IFormulaParamMetadata } from '@teable/core';\n\nexport interface IResolvedFormulaParamInfo {\n  hasMetadata: boolean;\n  type?: FormulaParamType;\n  isFieldReference: boolean;\n  isMultiValueField: boolean;\n  isJsonField: boolean;\n  fieldDbName?: string;\n  fieldDbType?: DbFieldType;\n  fieldCellValueType?: string;\n}\n\nconst EMPTY_INFO: IResolvedFormulaParamInfo = {\n  hasMetadata: false,\n  type: undefined,\n  isFieldReference: false,\n  isMultiValueField: false,\n  isJsonField: false,\n  fieldDbName: undefined,\n  fieldDbType: undefined,\n  fieldCellValueType: undefined,\n};\n\nexport function resolveFormulaParamInfo(\n  metadataList: IFormulaParamMetadata[] | undefined,\n  index?: number\n): IResolvedFormulaParamInfo {\n  if (index == null || !metadataList) {\n    return EMPTY_INFO;\n  }\n\n  const metadata = metadataList[index];\n  if (!metadata) {\n    return EMPTY_INFO;\n  }\n\n  const field = metadata.field;\n  const info: IResolvedFormulaParamInfo = {\n    hasMetadata: true,\n    type: metadata.type && metadata.type !== 'unknown' ? metadata.type : undefined,\n    isFieldReference: Boolean(metadata.isFieldReference && field),\n    isMultiValueField: Boolean(field?.isMultiple),\n    isJsonField: field?.dbFieldType === DbFieldType.Json,\n    fieldDbName: field?.dbFieldName,\n    fieldDbType: field?.dbFieldType,\n    fieldCellValueType: field?.cellValueType,\n  };\n\n  if (field?.isLookup && field.dbFieldType === DbFieldType.Json) {\n    info.isJsonField = true;\n    info.isMultiValueField = true;\n  }\n\n  if (!info.type) {\n    info.type = inferTypeFromField(field);\n  }\n\n  if (info.isJsonField && !info.type) {\n    info.type = 'string';\n  }\n\n  return info;\n}\n\nexport function isTrustedNumeric(info: IResolvedFormulaParamInfo): boolean {\n  return info.type === 'number' && !info.isJsonField && !info.isMultiValueField;\n}\n\nexport function isTextLikeParam(info: IResolvedFormulaParamInfo): boolean {\n  if (info.type !== 'string') {\n    return false;\n  }\n  if (!info.isJsonField) {\n    return true;\n  }\n  if (info.isMultiValueField) {\n    return false;\n  }\n  if (info.fieldCellValueType && info.fieldCellValueType !== 'string') {\n    return false;\n  }\n  return true;\n}\n\nexport function isDatetimeLikeParam(info: IResolvedFormulaParamInfo): boolean {\n  return info.type === 'datetime';\n}\n\nexport function isBooleanLikeParam(info: IResolvedFormulaParamInfo): boolean {\n  if (info.isJsonField) {\n    return false;\n  }\n\n  return (\n    info.type === 'boolean' ||\n    info.fieldDbType === DbFieldType.Boolean ||\n    info.fieldCellValueType === 'boolean'\n  );\n}\n\nexport function isJsonLikeParam(info: IResolvedFormulaParamInfo): boolean {\n  return info.isJsonField || info.isMultiValueField;\n}\n\nfunction inferTypeFromField(field?: IFormulaParamMetadata['field']): FormulaParamType | undefined {\n  if (!field || field.isMultiple) {\n    return undefined;\n  }\n\n  const byDbType = mapDbFieldType(field.dbFieldType);\n  if (byDbType) {\n    return byDbType;\n  }\n\n  if (!field.cellValueType) {\n    return undefined;\n  }\n\n  switch (field.cellValueType) {\n    case 'number':\n      return 'number';\n    case 'boolean':\n      return 'boolean';\n    case 'datetime':\n      return 'datetime';\n    case 'string':\n      return 'string';\n    default:\n      return undefined;\n  }\n}\n\nfunction mapDbFieldType(dbFieldType?: DbFieldType): FormulaParamType | undefined {\n  switch (dbFieldType) {\n    case DbFieldType.Integer:\n    case DbFieldType.Real:\n      return 'number';\n    case DbFieldType.Boolean:\n      return 'boolean';\n    case DbFieldType.DateTime:\n      return 'datetime';\n    case DbFieldType.Text:\n      return 'string';\n    default:\n      return undefined;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { SetMetadata, UseInterceptors } from '@nestjs/common';\nimport type { Events } from '../events';\nimport { EventMiddleware } from '../interceptor/event.Interceptor';\n\nexport const EMIT_EVENT_NAME = 'EMIT_EVENT_NAME';\n\nexport function EmitControllerEvent(name: Events): MethodDecorator {\n  return (target: any, key: string | symbol, descriptor: TypedPropertyDescriptor<any>) => {\n    SetMetadata(EMIT_EVENT_NAME, name)(target, key, descriptor);\n    UseInterceptors(EventMiddleware)(target, key, descriptor);\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-emitter.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { DynamicModule } from '@nestjs/common';\nimport { ConfigurableModuleBuilder, Module } from '@nestjs/common';\nimport { EventEmitterModule as BaseEventEmitterModule } from '@nestjs/event-emitter';\nimport { AttachmentsTableModule } from '../features/attachments/attachments-table.module';\nimport { NotificationModule } from '../features/notification/notification.module';\nimport { RecordModule } from '../features/record/record.module';\nimport { ShareDbModule } from '../share-db/share-db.module';\nimport { EventEmitterService } from './event-emitter.service';\nimport { ActionTriggerListener } from './listeners/action-trigger.listener';\nimport { AttachmentListener } from './listeners/attachment.listener';\nimport { BasePermissionUpdateListener } from './listeners/base-permission-update.listener';\nimport { CollaboratorNotificationListener } from './listeners/collaborator-notification.listener';\nimport { PinListener } from './listeners/pin.listener';\nimport { RecordHistoryListener } from './listeners/record-history.listener';\nimport { TrashListener } from './listeners/trash.listener';\n\nexport interface EventEmitterModuleOptions {\n  global?: boolean;\n}\n\nexport const { ConfigurableModuleClass: EventEmitterModuleClass, OPTIONS_TYPE } =\n  new ConfigurableModuleBuilder<EventEmitterModuleOptions>().build();\n\n@Module({})\nexport class EventEmitterModule extends EventEmitterModuleClass {\n  static register(options?: typeof OPTIONS_TYPE): DynamicModule {\n    const { global } = options || {};\n\n    const module = BaseEventEmitterModule.forRoot({\n      wildcard: true,\n      delimiter: '.',\n    });\n\n    return {\n      imports: [module, ShareDbModule, NotificationModule, AttachmentsTableModule, RecordModule],\n      module: EventEmitterModule,\n      global,\n      providers: [\n        EventEmitterService,\n        ActionTriggerListener,\n        CollaboratorNotificationListener,\n        AttachmentListener,\n        BasePermissionUpdateListener,\n        PinListener,\n        RecordHistoryListener,\n        TrashListener,\n      ],\n      exports: [EventEmitterService],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-emitter.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport type {\n  ICreateOpBuilder,\n  IOpBuilder,\n  IOpContextBase,\n  IOtOperation,\n  IRecord,\n} from '@teable/core';\nimport {\n  FieldOpBuilder,\n  IdPrefix,\n  RecordOpBuilder,\n  TableOpBuilder,\n  ViewOpBuilder,\n} from '@teable/core';\nimport { get, isEmpty, omit, set } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { GroupedObservable, Observable } from 'rxjs';\nimport { catchError, EMPTY, from, groupBy, map, mergeMap, toArray } from 'rxjs';\nimport type { CreateOp, DeleteOp, EditOp } from 'sharedb';\nimport { match, P } from 'ts-pattern';\nimport type { IRawOpMap } from '../share-db/interface';\nimport { RawOpType } from '../share-db/interface';\nimport type { IClsStore } from '../types/cls';\nimport { Timing } from '../utils/timing';\nimport type { IChangeRecord, OpEvent, RecordCreateEvent, RecordUpdateEvent } from './events';\nimport {\n  Events,\n  FieldEventFactory,\n  RecordEventFactory,\n  TableEventFactory,\n  ViewEventFactory,\n} from './events';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\ntype DocType = IdPrefix.Table | IdPrefix.Field | IdPrefix.View | IdPrefix.Record;\n\n@Injectable()\nexport class EventEmitterService {\n  private readonly logger = new Logger(EventEmitterService.name);\n\n  private readonly eventNameMapping = {\n    [RawOpType.Create]: {\n      [IdPrefix.Table]: Events.TABLE_CREATE,\n      [IdPrefix.Field]: Events.TABLE_FIELD_CREATE,\n      [IdPrefix.View]: Events.TABLE_VIEW_CREATE,\n      [IdPrefix.Record]: Events.TABLE_RECORD_CREATE,\n    },\n    [RawOpType.Del]: {\n      [IdPrefix.Table]: Events.TABLE_DELETE,\n      [IdPrefix.Field]: Events.TABLE_FIELD_DELETE,\n      [IdPrefix.View]: Events.TABLE_VIEW_DELETE,\n      [IdPrefix.Record]: Events.TABLE_RECORD_DELETE,\n    },\n    [RawOpType.Edit]: {\n      [IdPrefix.Table]: Events.TABLE_UPDATE,\n      [IdPrefix.Field]: Events.TABLE_FIELD_UPDATE,\n      [IdPrefix.View]: Events.TABLE_VIEW_UPDATE,\n      [IdPrefix.Record]: Events.TABLE_RECORD_UPDATE,\n    },\n  };\n\n  private getPropertyCategoryForType = {\n    [IdPrefix.Table]: 'table',\n    [IdPrefix.View]: 'view',\n    [IdPrefix.Field]: 'field',\n    [IdPrefix.Record]: 'record',\n  };\n\n  constructor(\n    public readonly eventEmitter: EventEmitter2,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  emit<T extends unknown | unknown[]>(event: string, data: T): boolean {\n    return this.eventEmitter.emit(event, data);\n  }\n\n  emitAsync<T extends unknown | unknown[]>(event: string, data: T): Promise<boolean[]> {\n    return this.eventEmitter.emitAsync(event, data);\n  }\n\n  @Timing()\n  async ops2Event(rawOpMaps?: IRawOpMap[]): Promise<void> {\n    const generatedEvents = this.collectEventsFromRawOpMap(rawOpMaps);\n    if (!generatedEvents) {\n      return;\n    }\n    const observable = from(Array.from(generatedEvents.values()));\n\n    observable\n      .pipe(\n        groupBy((event) => {\n          const tableId = get(event, 'payload.tableId');\n          return tableId ? `${tableId}_${event.name}` : event.name;\n        }),\n        mergeMap((project) => this.aggregateEventsByGroup(project))\n      )\n      .subscribe((next) => this.handleEventResult(next));\n  }\n\n  private aggregateEventsByGroup(project: GroupedObservable<string, OpEvent>): Observable<OpEvent> {\n    return project.pipe(\n      toArray(),\n      map((groupedEvents) => this.combineEvents(groupedEvents)),\n      catchError((error) => {\n        this.logger.error(`push event stream error: ${error.message}`, error?.stack);\n        return EMPTY;\n      })\n    );\n  }\n\n  private combineEvents(groupedEvents: OpEvent[]): OpEvent {\n    if (groupedEvents.length <= 1) return groupedEvents[0];\n    return groupedEvents.reduce((combinedEvent, event, index) => {\n      const mergePropertyName = this.getMergePropertyName(event);\n\n      if (index === 0) {\n        combinedEvent = this.initAcc(event, mergePropertyName);\n      }\n\n      const changes = this.aggregateEventChanges(combinedEvent, mergePropertyName, event);\n      set(combinedEvent, `payload.${mergePropertyName}`, changes);\n      return combinedEvent;\n    }, {} as OpEvent);\n  }\n\n  private getMergePropertyName(event: OpEvent): string {\n    return match(event)\n      .with(\n        P.union({ name: Events.TABLE_VIEW_CREATE }, { name: Events.TABLE_VIEW_UPDATE }),\n        () => 'view'\n      )\n      .with({ name: Events.TABLE_VIEW_DELETE }, () => 'viewId')\n      .with(\n        P.union({ name: Events.TABLE_FIELD_CREATE }, { name: Events.TABLE_FIELD_UPDATE }),\n        () => 'field'\n      )\n      .with({ name: Events.TABLE_FIELD_DELETE }, () => 'fieldId')\n      .with(\n        P.union({ name: Events.TABLE_RECORD_CREATE }, { name: Events.TABLE_RECORD_UPDATE }),\n        () => 'record'\n      )\n      .with({ name: Events.TABLE_RECORD_DELETE }, () => 'recordId')\n      .otherwise(() => '');\n  }\n\n  private initAcc(event: OpEvent, mergePropertyName: string): OpEvent {\n    return {\n      ...(omit(event, `payload.${mergePropertyName}`) as OpEvent),\n      isBulk: true,\n    };\n  }\n\n  private aggregateEventChanges(combinedEvent: OpEvent, mergePropertyName: string, event: OpEvent) {\n    const changes = get(combinedEvent, ['payload', mergePropertyName]) || [];\n    changes.push(get(event, ['payload', mergePropertyName]));\n    return changes;\n  }\n\n  private handleEventResult(result: OpEvent): void {\n    // this.logger.debug({ eventName: result.name, eventList: result });\n    this.emitAsync(result.name, result);\n  }\n\n  private collectEventsFromRawOpMap(rawOpMaps?: IRawOpMap[]) {\n    if (!rawOpMaps?.length) {\n      return;\n    }\n\n    return rawOpMaps.reduce((pre, cur) => {\n      this.generateEventsFromRawOps(cur, pre);\n      return pre;\n    }, new Map<string, OpEvent>());\n  }\n\n  private generateEventsFromRawOps(rawOpMap: IRawOpMap, eventManager: Map<string, OpEvent>) {\n    for (const collection in rawOpMap) {\n      const [docType, docId] = collection.split('_') as [DocType, string];\n      const data = rawOpMap[collection];\n\n      for (const id in data) {\n        const rawOp = data[id] as CreateOp | DeleteOp | EditOp;\n        const extendPlainContext = this.createExtendPlainContext(docId, id);\n\n        const opType = this.getOpType(rawOp);\n        if (opType === null) continue;\n\n        const plainContext = this.convertOpsToClassPlain(docType, opType, {\n          nodeId: id,\n          opCreateData: rawOp.create?.data,\n          ops: rawOp?.op,\n        }) as OpEvent;\n        const event = this.createEvent(docType, opType, {\n          ...extendPlainContext,\n          ...plainContext,\n          context: {\n            ...extendPlainContext.context,\n            ...plainContext?.context,\n          },\n        });\n\n        if (event) {\n          this.mergeEventsForUpdate(eventManager, id, event);\n        }\n      }\n    }\n  }\n\n  private createExtendPlainContext(docId: string, id: string) {\n    const user = this.cls.get('user');\n    const entry = this.cls.get('entry');\n    return {\n      baseId: docId,\n      tableId: id.startsWith(IdPrefix.Table) ? id : docId,\n      viewId: id,\n      fieldId: id,\n      recordId: id,\n      context: {\n        user,\n        entry,\n      },\n    };\n  }\n\n  private getOpType(rawOp: CreateOp | DeleteOp | EditOp): RawOpType | null {\n    if ('create' in rawOp) return RawOpType.Create;\n    if ('op' in rawOp) return RawOpType.Edit;\n    if ('del' in rawOp) return RawOpType.Del;\n    return null;\n  }\n\n  private mergeEventsForUpdate(\n    eventManager: Map<string, OpEvent>,\n    id: string,\n    event: OpEvent\n  ): void {\n    const existingEvent = eventManager.get(id);\n\n    if (!existingEvent) {\n      eventManager.set(id, event);\n      return;\n    }\n\n    const { rawOpType } = existingEvent;\n\n    if (\n      [RawOpType.Create, RawOpType.Edit].includes(rawOpType) &&\n      event.name === Events.TABLE_RECORD_UPDATE\n    ) {\n      const fields = this.getUpdateFieldsFromEvent(event as RecordUpdateEvent, rawOpType);\n      event = this.combineUpdateEvents(existingEvent as RecordCreateEvent, fields);\n    }\n\n    eventManager.set(id, event);\n  }\n\n  private getUpdateFieldsFromEvent(\n    event: RecordUpdateEvent,\n    existedRawOpType: RawOpType\n  ): { [key: string]: unknown } {\n    const { payload } = event;\n    const fields = (payload.record as IChangeRecord).fields;\n\n    if (existedRawOpType === RawOpType.Edit) {\n      return fields;\n    }\n\n    return Object.entries(fields).reduce(\n      (acc, [key, value]) => {\n        acc[key] = value.newValue;\n        return acc;\n      },\n      {} as { [key: string]: unknown }\n    );\n  }\n\n  private combineUpdateEvents(\n    existingEvent: RecordCreateEvent,\n    fields: { [key: string]: unknown }\n  ): OpEvent {\n    return {\n      ...existingEvent,\n      payload: {\n        ...existingEvent.payload,\n        record: {\n          ...existingEvent.payload.record,\n          fields: {\n            ...(existingEvent.payload.record as IRecord).fields,\n            ...fields,\n          },\n        },\n      },\n    };\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private createEvent(docType: DocType, action: RawOpType, plain: any) {\n    const { context, ...payload } = plain;\n    const eventName = this.eventNameMapping[action]?.[docType];\n    if (!eventName) return undefined;\n\n    const oldField = this.cls.get('oldField');\n\n    if (eventName === Events.TABLE_RECORD_UPDATE) {\n      payload.oldField = oldField;\n    }\n\n    return match(docType)\n      .with(IdPrefix.Table, () => TableEventFactory.create(eventName, payload, context))\n      .with(IdPrefix.Field, () => FieldEventFactory.create(eventName, payload, context))\n      .with(IdPrefix.View, () => ViewEventFactory.create(eventName, payload, context))\n      .with(IdPrefix.Record, () => RecordEventFactory.create(eventName, payload, context))\n      .exhaustive();\n  }\n\n  private getOpBuilder(docType: DocType) {\n    return match(docType)\n      .with(IdPrefix.Table, () => TableOpBuilder)\n      .with(IdPrefix.Field, () => FieldOpBuilder)\n      .with(IdPrefix.View, () => ViewOpBuilder)\n      .with(IdPrefix.Record, () => RecordOpBuilder)\n      .exhaustive();\n  }\n\n  private convertOpsToClassPlain(\n    docType: DocType,\n    rawOpType: RawOpType,\n    params: {\n      nodeId: string;\n      opCreateData?: unknown;\n      ops?: IOtOperation[];\n    }\n  ) {\n    const { nodeId, opCreateData, ops = [] } = params;\n    const opBuilder = this.getOpBuilder(docType);\n    const initData = this.initData(docType, nodeId, opBuilder?.creator, opCreateData);\n\n    const ops2Contexts = opBuilder?.ops2Contexts(ops) || [];\n    const correctedData = ops2Contexts.reduce((acc, cur) => {\n      this.applyOperation(docType, rawOpType, acc, cur, nodeId, opBuilder?.editor);\n\n      set(acc, ['context', 'opMeta', 'name'], cur.name);\n      set(acc, ['context', 'opMeta', 'propertyKey'], get(cur, 'key'));\n      return acc;\n    }, {});\n\n    return isEmpty(correctedData) ? initData : correctedData;\n  }\n\n  private initData(\n    docType: DocType,\n    nodeId: string,\n    createBuilder?: ICreateOpBuilder,\n    opCreateData?: unknown\n  ) {\n    if (createBuilder?.name === 'addRecord' && !opCreateData) {\n      opCreateData = { id: nodeId };\n    }\n\n    if (opCreateData && createBuilder) {\n      const buildData = createBuilder.build(opCreateData);\n      const propertyCategory = this.getPropertyCategoryForType[docType];\n\n      const pre = { [propertyCategory]: buildData };\n      set(pre, ['context', 'opMeta', 'name'], createBuilder.name);\n      return pre;\n    }\n  }\n\n  private applyOperation(\n    docType: DocType,\n    rawOpType: RawOpType,\n    acc: object,\n    cur: IOpContextBase,\n    nodeId: string,\n    editorBuilders?: { [key: string]: IOpBuilder }\n  ) {\n    if (!editorBuilders) return;\n\n    const opBuilder = editorBuilders[cur.name as keyof typeof editorBuilders];\n    if (!opBuilder) return;\n\n    const propertyCategory = this.getPropertyCategoryForType[docType];\n    const otOperation = opBuilder.build(cur);\n    if (!otOperation) return;\n\n    this.buildAndApplyOp(otOperation, acc, propertyCategory, nodeId, rawOpType);\n  }\n\n  private buildAndApplyOp(\n    otOperation: IOtOperation,\n    acc: object,\n    propertyCategory: string,\n    nodeId: string,\n    rawOpType: RawOpType\n  ) {\n    const { p, oi: newValue, od: oldValue } = otOperation;\n    set(acc, [propertyCategory, 'id'], nodeId);\n\n    const [propertyName, changeNodeId] = p;\n    const updateProperty = (key: string | number | null, value: unknown) => {\n      const propertyPath = [propertyCategory, propertyName, key].filter(Boolean) as (\n        | string\n        | number\n      )[];\n      set(acc, propertyPath, value);\n    };\n\n    if (p.length === 1) {\n      const value = rawOpType === RawOpType.Edit ? { oldValue, newValue } : newValue;\n      updateProperty(null, value);\n    } else if (p.length === 2) {\n      const changeProperty = get(acc, [propertyCategory, propertyName], {});\n      changeProperty[changeNodeId] =\n        rawOpType === RawOpType.Edit ? { oldValue, newValue } : newValue;\n      updateProperty(changeNodeId, changeProperty[changeNodeId]);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts",
    "content": "import { BullModule } from '@nestjs/bullmq';\nimport type { NestWorkerOptions } from '@nestjs/bullmq/dist/interfaces/worker-options.interface';\nimport type { DynamicModule } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport { ConditionalModule } from '@nestjs/config';\nimport { ConfigModule } from '../../configs/config.module';\nimport { FallbackQueueModule } from './fallback/fallback-queue.module';\n\nconst queueOptions: NestWorkerOptions = {\n  removeOnComplete: {\n    count: 2000,\n  },\n  removeOnFail: {\n    count: 5000,\n  },\n};\n\n@Module({\n  imports: [ConfigModule],\n})\nexport class EventJobModule {\n  static async registerQueue(name: string): Promise<DynamicModule> {\n    const [bullQueue, fallbackQueue] = await Promise.all([\n      ConditionalModule.registerWhen(\n        BullModule.registerQueue({\n          name,\n          ...queueOptions,\n        }),\n        (env) => Boolean(env.BACKEND_CACHE_REDIS_URI)\n      ),\n      ConditionalModule.registerWhen(\n        FallbackQueueModule.registerQueue(name),\n        (env) => !env.BACKEND_CACHE_REDIS_URI\n      ),\n    ]);\n\n    return {\n      module: EventJobModule,\n      imports: [bullQueue, fallbackQueue],\n      exports: [bullQueue, fallbackQueue],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-job/fallback/event-emitter.ts",
    "content": "import EventEmitter from 'events';\n\nexport const localQueueEventEmitter = new EventEmitter();\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.module.ts",
    "content": "import type { DynamicModule } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport { DiscoveryService } from '@nestjs/core';\nimport { FallbackQueueService } from './fallback-queue.service';\nimport { createLocalQueueProvider } from './local-queue.provider';\n\n@Module({})\nexport class FallbackQueueModule {\n  static registerQueue(name: string): DynamicModule {\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    const LocalQueueProvider = createLocalQueueProvider(name);\n    return {\n      module: FallbackQueueModule,\n      providers: [FallbackQueueService, DiscoveryService, LocalQueueProvider],\n      exports: [LocalQueueProvider],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.service.ts",
    "content": "import type { OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { Reflector, DiscoveryService } from '@nestjs/core';\nimport type { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';\nimport type { Job } from 'bullmq';\nimport { localQueueEventEmitter } from './event-emitter';\n\nexport const PROCESSOR_METADATA = 'bullmq:processor_metadata';\n\n@Injectable()\nexport class FallbackQueueService implements OnModuleInit {\n  private logger = new Logger(FallbackQueueService.name);\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly discoveryService: DiscoveryService\n  ) {}\n\n  async onModuleInit() {\n    this.logger.debug('FallbackQueueService init');\n    this.collectionProcess();\n  }\n\n  collectionProcess() {\n    const providers: InstanceWrapper[] = this.discoveryService\n      .getProviders()\n      .filter((wrapper: InstanceWrapper) => {\n        const target =\n          !wrapper.metatype || wrapper.inject ? wrapper.instance?.constructor : wrapper.metatype;\n        if (!target) {\n          return false;\n        }\n        return !!this.reflector.get(PROCESSOR_METADATA, target);\n      });\n\n    providers.forEach((wrapper: InstanceWrapper) => {\n      const { instance, metatype } = wrapper;\n      if (!wrapper.isDependencyTreeStatic()) {\n        return;\n      }\n\n      const { name: queueName } = this.reflector.get(\n        PROCESSOR_METADATA,\n        instance.constructor || metatype\n      );\n      localQueueEventEmitter.removeAllListeners(`handle-listener-${queueName}`);\n      localQueueEventEmitter.on(`handle-listener-${queueName}`, (job: Job<unknown>) => {\n        if (job.queueName !== queueName) {\n          return;\n        }\n        this.handleListener(wrapper, job);\n      });\n    });\n  }\n\n  private async handleListener(\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    wrapper: InstanceWrapper,\n    job: Job<unknown>\n  ) {\n    const { instance } = wrapper;\n    const methodName = 'process';\n    if (!instance[methodName]) {\n      this.logger.warn(`${instance.constructor.name} has no method ${methodName}`);\n      return;\n    }\n    try {\n      await instance[methodName].call(instance, job);\n    } catch (error) {\n      this.logger.error(`Error processing job ${job.name}:`, error);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/event-job/fallback/local-queue.provider.ts",
    "content": "import { getQueueToken } from '@nestjs/bullmq';\nimport type { Provider } from '@nestjs/common';\nimport { getRandomString } from '@teable/core';\nimport type { JobsOptions } from 'bullmq';\nimport { localQueueEventEmitter } from './event-emitter';\n\nexport const createLocalQueueProvider = (queueName: string): Provider => ({\n  provide: getQueueToken(queueName),\n  useFactory: async () => {\n    return {\n      add: (name: string, data: unknown, opts?: JobsOptions) => {\n        localQueueEventEmitter.emit(`handle-listener-${queueName}`, {\n          id: getRandomString(10),\n          name,\n          data,\n          opts,\n          queueName,\n        });\n      },\n      addBulk: (jobs: JobsOptions[]) => {\n        jobs.forEach((job) => {\n          localQueueEventEmitter.emit(`handle-listener-${queueName}`, job);\n        });\n      },\n    };\n  },\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/app/app.event.ts",
    "content": "import { match } from 'ts-pattern';\nimport type { IEventContext } from '../core-event';\nimport { CoreEvent } from '../core-event';\nimport { Events } from '../event.enum';\n\ninterface IAppVo {\n  id: string;\n  name: string;\n}\n\ntype IAppCreatePayload = { baseId: string; app: IAppVo };\ntype IAppDeletePayload = { baseId: string; appId: string; permanent?: boolean };\ntype IAppUpdatePayload = { baseId: string; app: IAppVo };\n\nexport class AppCreateEvent extends CoreEvent<IAppCreatePayload> {\n  public readonly name = Events.APP_CREATE;\n\n  constructor(payload: IAppCreatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class AppDeleteEvent extends CoreEvent<IAppDeletePayload> {\n  public readonly name = Events.APP_DELETE;\n  constructor(payload: IAppDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class AppUpdateEvent extends CoreEvent<IAppUpdatePayload> {\n  public readonly name = Events.APP_UPDATE;\n\n  constructor(payload: IAppUpdatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class AppEventFactory {\n  static create(\n    name: string,\n    payload: IAppCreatePayload | IAppDeletePayload | IAppUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.APP_CREATE, () => {\n        const { baseId, app } = payload as IAppCreatePayload;\n        return new AppCreateEvent({ baseId, app }, context);\n      })\n      .with(Events.APP_UPDATE, () => {\n        const { baseId, app } = payload as IAppUpdatePayload;\n        return new AppUpdateEvent({ baseId, app }, context);\n      })\n      .with(Events.APP_DELETE, () => {\n        const { baseId, appId, permanent } = payload as IAppDeletePayload;\n        return new AppDeleteEvent({ baseId, appId, permanent }, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/base/base-node.event.ts",
    "content": "import { BaseNodeResourceType, type IBaseNodeVo, type IDeleteBaseNodeVo } from '@teable/openapi';\nimport { match } from 'ts-pattern';\nimport { AppEventFactory } from '../app/app.event';\nimport type { IEventContext } from '../core-event';\nimport { DashboardEventFactory } from '../dashboard/dashboard.event';\nimport { Events } from '../event.enum';\nimport { WorkflowEventFactory } from '../workflow/workflow.event';\nimport { BaseFolderEventFactory } from './folder/base.folder.event';\n\ntype IBaseNodeCreatePayload = { baseId: string; node: IBaseNodeVo };\ntype IBaseNodeDeletePayload = { baseId: string; node: IDeleteBaseNodeVo };\ntype IBaseNodeUpdatePayload = IBaseNodeCreatePayload;\n\n// base node event to resource event(folder, dashboard, workflow, app); table event is handled by ops2Event;\nexport class BaseNodeEventFactory {\n  static create(\n    name: string,\n    payload: IBaseNodeCreatePayload | IBaseNodeDeletePayload | IBaseNodeUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.BASE_NODE_CREATE, () => {\n        const { baseId, node } = payload as IBaseNodeCreatePayload;\n        const { resourceId, resourceType, resourceMeta } = node;\n        switch (resourceType) {\n          case BaseNodeResourceType.Folder:\n            return BaseFolderEventFactory.create(\n              Events.BASE_FOLDER_CREATE,\n              {\n                baseId,\n                folder: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.Dashboard:\n            return DashboardEventFactory.create(\n              Events.DASHBOARD_CREATE,\n              {\n                baseId,\n                dashboard: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.Workflow:\n            return WorkflowEventFactory.create(\n              Events.WORKFLOW_CREATE,\n              {\n                baseId,\n                workflow: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.App:\n            return AppEventFactory.create(\n              Events.APP_CREATE,\n              {\n                baseId,\n                app: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n\n          default:\n            return null;\n        }\n      })\n      .with(Events.BASE_NODE_UPDATE, () => {\n        const { baseId, node } = payload as IBaseNodeUpdatePayload;\n        const { resourceId, resourceType, resourceMeta } = node;\n        switch (resourceType) {\n          case BaseNodeResourceType.Folder:\n            return BaseFolderEventFactory.create(\n              Events.BASE_FOLDER_UPDATE,\n              {\n                baseId,\n                folder: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.Dashboard:\n            return DashboardEventFactory.create(\n              Events.DASHBOARD_UPDATE,\n              {\n                baseId,\n                dashboard: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.Workflow:\n            return WorkflowEventFactory.create(\n              Events.WORKFLOW_UPDATE,\n              {\n                baseId,\n                workflow: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n          case BaseNodeResourceType.App:\n            return AppEventFactory.create(\n              Events.APP_UPDATE,\n              {\n                baseId,\n                app: {\n                  id: resourceId,\n                  ...resourceMeta,\n                },\n              },\n              context\n            );\n\n          default:\n            return null;\n        }\n      })\n      .with(Events.BASE_NODE_DELETE, () => {\n        const { baseId, node } = payload as IBaseNodeDeletePayload;\n        const { resourceId, resourceType, permanent } = node;\n        switch (resourceType) {\n          case BaseNodeResourceType.Folder:\n            return BaseFolderEventFactory.create(\n              Events.BASE_FOLDER_DELETE,\n              { baseId, folderId: resourceId },\n              context\n            );\n          case BaseNodeResourceType.Dashboard:\n            return DashboardEventFactory.create(\n              Events.DASHBOARD_DELETE,\n              { baseId, dashboardId: resourceId },\n              context\n            );\n          case BaseNodeResourceType.Workflow:\n            return WorkflowEventFactory.create(\n              Events.WORKFLOW_DELETE,\n              { baseId, workflowId: resourceId, permanent },\n              context\n            );\n          case BaseNodeResourceType.App:\n            return AppEventFactory.create(\n              Events.APP_DELETE,\n              { baseId, appId: resourceId, permanent },\n              context\n            );\n          default:\n            return null;\n        }\n      })\n\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/base/base.event.ts",
    "content": "import type { ICreateBaseVo } from '@teable/openapi';\nimport { match } from 'ts-pattern';\nimport type { IEventContext } from '../core-event';\nimport { CoreEvent } from '../core-event';\nimport { Events } from '../event.enum';\n\ntype IBaseCreatePayload = { base: ICreateBaseVo };\ntype IBaseDeletePayload = { baseId: string; permanent?: boolean };\ntype IBaseUpdatePayload = IBaseCreatePayload;\ntype IBasePermissionUpdatePayload = { baseId: string };\n\nexport class BaseCreateEvent extends CoreEvent<IBaseCreatePayload> {\n  public readonly name = Events.BASE_CREATE;\n\n  constructor(base: ICreateBaseVo, context: IEventContext) {\n    super({ base }, context);\n  }\n}\n\nexport class BaseDeleteEvent extends CoreEvent<IBaseDeletePayload> {\n  public readonly name = Events.BASE_DELETE;\n  constructor(payload: IBaseDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class BaseUpdateEvent extends CoreEvent<IBaseUpdatePayload> {\n  public readonly name = Events.BASE_UPDATE;\n\n  constructor(base: ICreateBaseVo, context: IEventContext) {\n    super({ base }, context);\n  }\n}\n\nexport class BasePermissionUpdateEvent extends CoreEvent<IBasePermissionUpdatePayload> {\n  public readonly name = Events.BASE_PERMISSION_UPDATE;\n\n  constructor(baseId: string, context: IEventContext) {\n    super({ baseId }, context);\n  }\n}\n\nexport class BaseEventFactory {\n  static create(\n    name: string,\n    payload: IBaseCreatePayload | IBaseDeletePayload | IBaseUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.BASE_CREATE, () => {\n        const { base } = payload as IBaseCreatePayload;\n        return new BaseCreateEvent(base, context);\n      })\n      .with(Events.BASE_DELETE, () => {\n        const { baseId, permanent } = payload as IBaseDeletePayload;\n        return new BaseDeleteEvent({ baseId, permanent }, context);\n      })\n      .with(Events.BASE_UPDATE, () => {\n        const { base } = payload as IBaseUpdatePayload;\n        return new BaseUpdateEvent(base, context);\n      })\n      .with(Events.BASE_PERMISSION_UPDATE, () => {\n        const { baseId } = payload as IBasePermissionUpdatePayload;\n        return new BasePermissionUpdateEvent(baseId, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts",
    "content": "import { match } from 'ts-pattern';\nimport type { IEventContext } from '../../core-event';\nimport { CoreEvent } from '../../core-event';\nimport { Events } from '../../event.enum';\n\ntype IBaseFolder = {\n  id: string;\n  name: string;\n};\n\ntype IBaseFolderCreatePayload = { baseId: string; folder: IBaseFolder };\ntype IBaseFolderDeletePayload = { baseId: string; folderId: string };\ntype IBaseFolderUpdatePayload = IBaseFolderCreatePayload;\n\nexport class BaseFolderCreateEvent extends CoreEvent<IBaseFolderCreatePayload> {\n  public readonly name = Events.BASE_FOLDER_CREATE;\n\n  constructor(payload: IBaseFolderCreatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class BaseFolderDeleteEvent extends CoreEvent<IBaseFolderDeletePayload> {\n  public readonly name = Events.BASE_FOLDER_DELETE;\n  constructor(payload: IBaseFolderDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class BaseFolderUpdateEvent extends CoreEvent<IBaseFolderUpdatePayload> {\n  public readonly name = Events.BASE_FOLDER_UPDATE;\n\n  constructor(payload: IBaseFolderUpdatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class BaseFolderEventFactory {\n  static create(\n    name: string,\n    payload: IBaseFolderCreatePayload | IBaseFolderDeletePayload | IBaseFolderUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.BASE_FOLDER_CREATE, () => {\n        const { baseId, folder } = payload as IBaseFolderCreatePayload;\n        return new BaseFolderCreateEvent({ baseId, folder }, context);\n      })\n      .with(Events.BASE_FOLDER_DELETE, () => {\n        const { baseId, folderId } = payload as IBaseFolderDeletePayload;\n        return new BaseFolderDeleteEvent({ baseId, folderId }, context);\n      })\n      .with(Events.BASE_FOLDER_UPDATE, () => {\n        const { baseId, folder } = payload as IBaseFolderUpdatePayload;\n        return new BaseFolderUpdateEvent({ baseId, folder }, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/core-event.ts",
    "content": "import type { IncomingHttpHeaders } from 'http';\nimport type { OpName } from '@teable/core';\nimport type { IUserInfoVo } from '@teable/openapi';\nimport { nanoid } from 'nanoid';\nimport type { Events } from './event.enum';\n\nexport interface IEventContext {\n  user?: {\n    id: string;\n    name: string;\n    email: string;\n  };\n  entry?: {\n    type: string;\n    id: string;\n  };\n  headers?: Record<string, string | undefined> | IncomingHttpHeaders;\n  opMeta?: {\n    name: OpName;\n    propertyKey?: string;\n  };\n}\n\nexport interface IEventRawContext {\n  reqUser?: IUserInfoVo;\n  reqHeaders: Record<string, unknown>;\n  reqParams?: unknown;\n  reqQuery?: unknown;\n  reqBody?: unknown;\n  resolveData: unknown;\n}\n\nexport abstract class CoreEvent<Payload extends object = object> {\n  abstract name: Events;\n\n  constructor(\n    public readonly payload: Payload,\n    public readonly context: IEventContext,\n    public readonly isBulk = false,\n    public readonly id = nanoid()\n  ) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts",
    "content": "import type { ICreateDashboardVo } from '@teable/openapi';\nimport { match } from 'ts-pattern';\nimport type { IEventContext } from '../core-event';\nimport { CoreEvent } from '../core-event';\nimport { Events } from '../event.enum';\n\ntype IDashboardCreatePayload = { baseId: string; dashboard: ICreateDashboardVo };\ntype IDashboardUpdatePayload = { baseId: string; dashboard: ICreateDashboardVo };\ntype IDashboardDeletePayload = { baseId: string; dashboardId: string; permanent?: boolean };\n\nexport class DashboardCreateEvent extends CoreEvent<IDashboardCreatePayload> {\n  public readonly name = Events.DASHBOARD_CREATE;\n\n  constructor(payload: IDashboardCreatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class DashboardDeleteEvent extends CoreEvent<IDashboardDeletePayload> {\n  public readonly name = Events.DASHBOARD_DELETE;\n  constructor(payload: IDashboardDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class DashboardUpdateEvent extends CoreEvent<IDashboardUpdatePayload> {\n  public readonly name = Events.DASHBOARD_UPDATE;\n\n  constructor(payload: IDashboardUpdatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class DashboardEventFactory {\n  static create(\n    name: string,\n    payload: IDashboardCreatePayload | IDashboardDeletePayload | IDashboardUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.DASHBOARD_CREATE, () => {\n        return new DashboardCreateEvent(payload as IDashboardCreatePayload, context);\n      })\n      .with(Events.DASHBOARD_DELETE, () => {\n        return new DashboardDeleteEvent(payload as IDashboardDeletePayload, context);\n      })\n      .with(Events.DASHBOARD_UPDATE, () => {\n        return new DashboardUpdateEvent(payload as IDashboardUpdatePayload, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/event.enum.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nexport enum Events {\n  SPACE_CREATE = 'space.create',\n  SPACE_DELETE = 'space.delete',\n  SPACE_UPDATE = 'space.update',\n\n  BASE_CREATE = 'base.create',\n  BASE_DELETE = 'base.delete',\n  BASE_UPDATE = 'base.update',\n  BASE_PERMISSION_UPDATE = 'base.permission.update',\n  // BASE_CLONE = 'base.clone',\n  // BASE_MOVE = 'base.move',\n\n  BASE_NODE_CREATE = 'base.node.create',\n  BASE_NODE_DELETE = 'base.node.delete',\n  BASE_NODE_UPDATE = 'base.node.update',\n\n  TABLE_CREATE = 'table.create',\n  TABLE_DELETE = 'table.delete',\n  TABLE_UPDATE = 'table.update',\n\n  TABLE_FIELD_CREATE = 'table.field.create',\n  TABLE_FIELD_DELETE = 'table.field.delete',\n  TABLE_FIELD_UPDATE = 'table.field.update',\n\n  TABLE_RECORD_CREATE = 'table.record.create',\n  TABLE_RECORD_DELETE = 'table.record.delete',\n  TABLE_RECORD_UPDATE = 'table.record.update',\n\n  TABLE_BUTTON_CLICK = 'table.button.click',\n\n  TABLE_VIEW_CREATE = 'table.view.create',\n  TABLE_VIEW_DELETE = 'table.view.delete',\n  TABLE_VIEW_UPDATE = 'table.view.update',\n\n  OPERATION_RECORDS_CREATE = 'operation.records.create',\n  OPERATION_RECORDS_DELETE = 'operation.records.delete',\n  OPERATION_RECORDS_UPDATE = 'operation.records.update',\n  OPERATION_RECORDS_ORDER_UPDATE = 'operation.records.order.update',\n  OPERATION_FIELDS_CREATE = 'operation.fields.create',\n  OPERATION_FIELDS_DELETE = 'operation.fields.delete',\n  OPERATION_FIELD_CONVERT = 'operation.field.convert',\n  OPERATION_PASTE_SELECTION = 'operation.paste.selection',\n  OPERATION_VIEW_DELETE = 'operation.view.delete',\n  OPERATION_VIEW_CREATE = 'operation.view.create',\n  OPERATION_VIEW_UPDATE = 'operation.view.update',\n  OPERATION_PUSH = 'operation.push',\n\n  TABLE_USER_RENAME_COMPLETE = 'table.user.rename.complete',\n\n  SHARED_VIEW_CREATE = 'shared.view.create',\n  SHARED_VIEW_DELETE = 'shared.view.delete',\n  SHARED_VIEW_UPDATE = 'shared.view.update',\n\n  USER_SIGNIN = 'user.signin',\n  USER_SIGNUP = 'user.signup',\n  USER_RENAME = 'user.rename',\n  USER_SIGNOUT = 'user.signout',\n  USER_DELETE = 'user.delete',\n\n  // USER_PASSWORD_RESET = 'user.password.reset',\n  USER_PASSWORD_CHANGE = 'user.password.change',\n  // USER_PASSWORD_FORGOT = 'user.password.forgot'\n  USER_EMAIL_CHANGE = 'user.email.change',\n\n  COLLABORATOR_CREATE = 'collaborator.create',\n  COLLABORATOR_DELETE = 'collaborator.delete',\n  COLLABORATOR_UPDATE = 'collaborator.update',\n\n  BASE_FOLDER_CREATE = 'base.folder.create',\n  BASE_FOLDER_DELETE = 'base.folder.delete',\n  BASE_FOLDER_UPDATE = 'base.folder.update',\n\n  DASHBOARD_CREATE = 'dashboard.create',\n  DASHBOARD_DELETE = 'dashboard.delete',\n  DASHBOARD_UPDATE = 'dashboard.update',\n\n  WORKFLOW_CREATE = 'workflow.create',\n  WORKFLOW_DELETE = 'workflow.delete',\n  WORKFLOW_UPDATE = 'workflow.update',\n  WORKFLOW_ACTIVATE = 'workflow.activate',\n  WORKFLOW_DEACTIVATE = 'workflow.deactivate',\n\n  APP_CREATE = 'app.create',\n  APP_DELETE = 'app.delete',\n  APP_UPDATE = 'app.update',\n\n  CROP_IMAGE = 'crop.image',\n  CROP_IMAGE_COMPLETE = 'crop.image.complete',\n\n  RECORD_HISTORY_CREATE = 'record.history.create',\n\n  // following make no sense just for testing\n  BASE_EXPORT_COMPLETE = 'base.export.complete',\n\n  LAST_VISIT_CLEAR = 'last.visit.clear',\n  LAST_VISIT_UPDATE = 'last.visit.update',\n\n  AUDIT_LOG_SAVED = 'audit-log.saved',\n\n  NOTIFY_MAIL_MERGE = 'notify.mail.merge',\n\n  // record source\n  TABLE_RECORD_CREATE_RELATIVE = 'table.record.create.relative',\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/index.ts",
    "content": "export * from './event.enum';\nexport * from './core-event';\nexport * from './op-event';\nexport * from './base/base.event';\nexport * from './base/folder/base.folder.event';\nexport * from './space/space.event';\nexport * from './space/collaborator.event';\nexport * from './table';\nexport * from './dashboard/dashboard.event';\nexport * from './workflow/workflow.event';\nexport * from './app/app.event';\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/last-visit/last-visit.event.ts",
    "content": "import type { IUpdateUserLastVisitRo } from '@teable/openapi';\nimport { Events } from '../event.enum';\n\nexport class LastVisitUpdateEvent {\n  public readonly name = Events.LAST_VISIT_UPDATE;\n\n  constructor(public readonly payload: IUpdateUserLastVisitRo) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/op-event.ts",
    "content": "import type { RawOpType } from '../../share-db/interface';\nimport { CoreEvent } from './core-event';\n\nexport interface IChangeValue {\n  oldValue: unknown | undefined;\n  newValue: unknown;\n}\n\nexport abstract class OpEvent<Payload extends object = object> extends CoreEvent<Payload> {\n  abstract rawOpType: RawOpType;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts",
    "content": "import { Events } from '../event.enum';\n\nexport class CollaboratorCreateEvent {\n  public readonly name = Events.COLLABORATOR_CREATE;\n\n  constructor(public readonly spaceId: string) {}\n}\n\nexport class CollaboratorDeleteEvent {\n  public readonly name = Events.COLLABORATOR_DELETE;\n\n  constructor(public readonly spaceId: string) {}\n}\n\nexport class CollaboratorUpdateEvent {\n  public readonly name = Events.COLLABORATOR_UPDATE;\n\n  constructor(public readonly spaceId: string) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/space/space.event.ts",
    "content": "import type { ICreateSpaceVo } from '@teable/openapi';\nimport { match } from 'ts-pattern';\nimport type { IEventContext } from '../core-event';\nimport { CoreEvent } from '../core-event';\nimport { Events } from '../event.enum';\n\ntype ISpaceCreatePayload = { space: ICreateSpaceVo };\ntype ISpaceDeletePayload = { spaceId: string; permanent?: boolean };\ntype ISpaceUpdatePayload = ISpaceCreatePayload;\n\nexport class SpaceCreateEvent extends CoreEvent<ISpaceCreatePayload> {\n  public readonly name = Events.SPACE_CREATE;\n\n  constructor(space: ICreateSpaceVo, context: IEventContext) {\n    super({ space }, context);\n  }\n}\n\nexport class SpaceDeleteEvent extends CoreEvent<ISpaceDeletePayload> {\n  public readonly name = Events.SPACE_DELETE;\n\n  constructor(payload: ISpaceDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class SpaceUpdateEvent extends CoreEvent<ISpaceUpdatePayload> {\n  public readonly name = Events.SPACE_UPDATE;\n\n  constructor(space: ICreateSpaceVo, context: IEventContext) {\n    super({ space }, context);\n  }\n}\n\nexport class SpaceEventFactory {\n  static create(\n    name: string,\n    payload: ISpaceCreatePayload | ISpaceDeletePayload | ISpaceUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.SPACE_CREATE, () => {\n        const { space } = payload as ISpaceCreatePayload;\n        return new SpaceCreateEvent(space, context);\n      })\n      .with(Events.SPACE_DELETE, () => {\n        const { spaceId, permanent } = payload as ISpaceDeletePayload;\n        return new SpaceDeleteEvent({ spaceId, permanent }, context);\n      })\n      .with(Events.SPACE_UPDATE, () => {\n        const { space } = payload as ISpaceUpdatePayload;\n        return new SpaceUpdateEvent(space, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/button.event.ts",
    "content": "import type { IRecord } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { CoreEvent, type IEventContext } from '../core-event';\nimport { Events } from '../event.enum';\n\ntype IButtonClickEventPayload = {\n  tableId: string;\n  fieldId: string;\n  record: IRecord;\n};\n\nexport class ButtonClickEvent extends CoreEvent<IButtonClickEventPayload> {\n  public readonly name = Events.TABLE_BUTTON_CLICK;\n\n  constructor(payload: IButtonClickEventPayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class ButtonEventFactory {\n  static create(name: string, payload: IButtonClickEventPayload, context: IEventContext) {\n    return match(name)\n      .with(Events.TABLE_BUTTON_CLICK, () => {\n        return new ButtonClickEvent(payload, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/field.event.ts",
    "content": "import type { IFieldPropertyKey, IFieldVo } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { RawOpType } from '../../../share-db/interface';\nimport type { IEventContext } from '../core-event';\nimport { Events } from '../event.enum';\nimport type { IChangeValue } from '../op-event';\nimport { OpEvent } from '../op-event';\n\nexport type IChangeField = Record<IFieldPropertyKey, IChangeValue> & { id: string };\n\ntype IFieldCreatePayload = { tableId: string; field: IFieldVo | IFieldVo[] };\ntype IFieldDeletePayload = { tableId: string; fieldId: string | string[] };\ntype IFieldUpdatePayload = {\n  tableId: string;\n  field: IChangeField | IChangeField[];\n};\n\nexport class FieldCreateEvent extends OpEvent<IFieldCreatePayload> {\n  public readonly name = Events.TABLE_FIELD_CREATE;\n  public readonly rawOpType = RawOpType.Create;\n\n  constructor(tableId: string, field: IFieldVo | IFieldVo[], context: IEventContext) {\n    super({ tableId, field }, context, Array.isArray(field));\n  }\n}\n\nexport class FieldDeleteEvent extends OpEvent<IFieldDeletePayload> {\n  public readonly name = Events.TABLE_FIELD_DELETE;\n  public readonly rawOpType = RawOpType.Del;\n  public isBulk = false;\n\n  constructor(tableId: string, fieldId: string | string[], context: IEventContext) {\n    super({ tableId, fieldId }, context, Array.isArray(fieldId));\n  }\n}\n\nexport class FieldUpdateEvent extends OpEvent<IFieldUpdatePayload> {\n  public readonly name = Events.TABLE_FIELD_UPDATE;\n  public readonly rawOpType = RawOpType.Edit;\n  public isBulk = false;\n\n  constructor(tableId: string, field: IChangeField | IChangeField[], context: IEventContext) {\n    super({ tableId, field }, context, Array.isArray(field));\n  }\n}\n\nexport class FieldEventFactory {\n  static create(\n    name: string,\n    payload: IFieldCreatePayload | IFieldDeletePayload | IFieldUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.TABLE_FIELD_CREATE, () => {\n        const { tableId, field } = payload as IFieldCreatePayload;\n        return new FieldCreateEvent(tableId, field, context);\n      })\n      .with(Events.TABLE_FIELD_DELETE, () => {\n        const { tableId, fieldId } = payload as IFieldDeletePayload;\n        return new FieldDeleteEvent(tableId, fieldId, context);\n      })\n      .with(Events.TABLE_FIELD_UPDATE, () => {\n        const { tableId, field } = payload as IFieldUpdatePayload;\n        return new FieldUpdateEvent(tableId, field, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/index.ts",
    "content": "export * from './table.event';\nexport * from './field.event';\nexport * from './view.event';\nexport * from './record.event';\nexport * from './button.event';\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/record.event.ts",
    "content": "import type { IFieldVo, IRecord } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { RawOpType } from '../../../share-db/interface';\nimport type { IEventContext } from '../core-event';\nimport { Events } from '../event.enum';\nimport type { IChangeValue } from '../op-event';\nimport { OpEvent } from '../op-event';\n\nexport type IChangeRecord = Record<keyof Pick<IRecord, 'fields'>, Record<string, IChangeValue>> & {\n  id: string;\n};\n\ntype IRecordCreatePayload = { tableId: string; record: IRecord | IRecord[] };\ntype IRecordDeletePayload = { tableId: string; recordId: string | string[] };\ntype IRecordUpdatePayload = {\n  tableId: string;\n  record: IChangeRecord | IChangeRecord[];\n  oldField: IFieldVo | undefined;\n};\n\nexport function getFieldIdsFromRecord(record: IRecord | IRecord[]) {\n  const records = Array.isArray(record) ? record : [record];\n  const fieldIds: string[] = [];\n  for (const r of records) {\n    if (r?.fields) {\n      fieldIds.push(...Object.keys(r.fields));\n    }\n  }\n  return fieldIds;\n}\n\nexport class RecordCreateEvent extends OpEvent<IRecordCreatePayload> {\n  public readonly name = Events.TABLE_RECORD_CREATE;\n  public readonly rawOpType = RawOpType.Create;\n\n  constructor(tableId: string, record: IRecord | IRecord[], context: IEventContext) {\n    super({ tableId, record }, context, Array.isArray(record));\n  }\n}\n\nexport class RecordDeleteEvent extends OpEvent<IRecordDeletePayload> {\n  public readonly name = Events.TABLE_RECORD_DELETE;\n  public readonly rawOpType = RawOpType.Del;\n\n  constructor(tableId: string, recordId: string | string[], context: IEventContext) {\n    super({ tableId, recordId }, context, Array.isArray(recordId));\n  }\n}\n\nexport class RecordUpdateEvent extends OpEvent<IRecordUpdatePayload> {\n  public readonly name = Events.TABLE_RECORD_UPDATE;\n  public readonly rawOpType = RawOpType.Edit;\n\n  constructor(\n    tableId: string,\n    record: IChangeRecord | IChangeRecord[],\n    oldField: IFieldVo | undefined,\n    context: IEventContext\n  ) {\n    super({ tableId, record, oldField }, context, Array.isArray(record));\n  }\n}\n\nexport class RecordEventFactory {\n  static create(\n    name: string,\n    payload: IRecordCreatePayload | IRecordDeletePayload | IRecordUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.TABLE_RECORD_CREATE, () => {\n        const { tableId, record } = payload as IRecordCreatePayload;\n        return new RecordCreateEvent(tableId, record, context);\n      })\n      .with(Events.TABLE_RECORD_DELETE, () => {\n        const { tableId, recordId } = payload as IRecordDeletePayload;\n        return new RecordDeleteEvent(tableId, recordId, context);\n      })\n      .with(Events.TABLE_RECORD_UPDATE, () => {\n        const { tableId, record, oldField } = payload as IRecordUpdatePayload;\n        return new RecordUpdateEvent(tableId, record, oldField, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/table.event.ts",
    "content": "import type { ITableOp } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { RawOpType } from '../../../share-db/interface';\nimport type { IEventContext } from '../core-event';\nimport { Events } from '../event.enum';\nimport type { IChangeValue } from '../op-event';\nimport { OpEvent } from '../op-event';\n\nexport type IChangeTable = Record<keyof Omit<ITableOp, 'id' | 'lastModifiedTime'>, IChangeValue> & {\n  id: string;\n};\n\ntype ITableCreatePayload = { baseId: string; table: ITableOp };\ntype ITableDeletePayload = { baseId: string; tableId: string; permanent?: boolean };\ntype ITableUpdatePayload = {\n  baseId: string;\n  table: IChangeTable;\n};\n\nexport class TableCreateEvent extends OpEvent<ITableCreatePayload> {\n  public readonly name = Events.TABLE_CREATE;\n  public readonly rawOpType = RawOpType.Create;\n\n  constructor(payload: ITableCreatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class TableDeleteEvent extends OpEvent<ITableDeletePayload> {\n  public readonly name = Events.TABLE_DELETE;\n  public readonly rawOpType = RawOpType.Del;\n\n  constructor(payload: ITableDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class TableUpdateEvent extends OpEvent<ITableUpdatePayload> {\n  public readonly name = Events.TABLE_UPDATE;\n  public readonly rawOpType = RawOpType.Edit;\n\n  constructor(payload: ITableUpdatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class TableEventFactory {\n  static create(\n    name: string,\n    payload: ITableCreatePayload | ITableDeletePayload | ITableUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.TABLE_CREATE, () => {\n        return new TableCreateEvent(payload as ITableCreatePayload, context);\n      })\n      .with(Events.TABLE_DELETE, () => {\n        return new TableDeleteEvent(payload as ITableDeletePayload, context);\n      })\n      .with(Events.TABLE_UPDATE, () => {\n        return new TableUpdateEvent(payload as ITableUpdatePayload, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/table/view.event.ts",
    "content": "import type { IViewVo } from '@teable/core';\nimport { match } from 'ts-pattern';\nimport { RawOpType } from '../../../share-db/interface';\nimport type { IEventContext } from '../core-event';\nimport { Events } from '../event.enum';\nimport type { IChangeValue } from '../op-event';\nimport { OpEvent } from '../op-event';\n\nexport type IChangeView = Record<\n  keyof Omit<\n    IViewVo,\n    | 'id'\n    | 'type'\n    | 'columnMeta'\n    | 'createdBy'\n    | 'lastModifiedBy'\n    | 'createdTime'\n    | 'lastModifiedTime'\n  >,\n  IChangeValue\n> & {\n  columnMeta: Record<string, IChangeValue>;\n  id: string;\n};\n\ntype IViewCreatePayload = { tableId: string; view: IViewVo | IViewVo[] };\ntype IViewDeletePayload = { tableId: string; viewId: string };\ntype IViewUpdatePayload = {\n  tableId: string;\n  view: IChangeView;\n};\n\nexport class ViewCreateEvent extends OpEvent<IViewCreatePayload> {\n  public readonly name = Events.TABLE_VIEW_CREATE;\n  public readonly rawOpType = RawOpType.Create;\n\n  constructor(tableId: string, view: IViewVo | IViewVo[], context: IEventContext) {\n    super({ tableId, view }, context, Array.isArray(view));\n  }\n}\n\nexport class ViewDeleteEvent extends OpEvent<IViewDeletePayload> {\n  public readonly name = Events.TABLE_VIEW_DELETE;\n  public readonly rawOpType = RawOpType.Del;\n\n  constructor(tableId: string, viewId: string, context: IEventContext) {\n    super({ tableId, viewId }, context);\n  }\n}\n\nexport class ViewUpdateEvent extends OpEvent<IViewUpdatePayload> {\n  public readonly name = Events.TABLE_VIEW_UPDATE;\n  public readonly rawOpType = RawOpType.Edit;\n\n  constructor(tableId: string, view: IChangeView, context: IEventContext) {\n    super({ tableId, view }, context);\n  }\n}\n\nexport class ViewEventFactory {\n  static create(\n    name: string,\n    payload: IViewCreatePayload | IViewDeletePayload | IViewUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.TABLE_VIEW_CREATE, () => {\n        const { tableId, view } = payload as IViewCreatePayload;\n        return new ViewCreateEvent(tableId, view, context);\n      })\n      .with(Events.TABLE_VIEW_DELETE, () => {\n        const { tableId, viewId } = payload as IViewDeletePayload;\n        return new ViewDeleteEvent(tableId, viewId, context);\n      })\n      .with(Events.TABLE_VIEW_UPDATE, () => {\n        const { tableId, view } = payload as IViewUpdatePayload;\n        return new ViewUpdateEvent(tableId, view, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/user/user.event.ts",
    "content": "import { Events } from '../event.enum';\n\nexport class UserSignUpEvent {\n  public readonly name = Events.USER_SIGNUP;\n\n  constructor(public readonly userId: string) {}\n}\n\nexport class UserEmailChangeEvent {\n  public readonly name = Events.USER_EMAIL_CHANGE;\n\n  constructor(\n    public readonly userId: string,\n    public readonly oldEmail: string,\n    public readonly newEmail: string\n  ) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts",
    "content": "import { match } from 'ts-pattern';\nimport type { IEventContext } from '../core-event';\nimport { CoreEvent } from '../core-event';\nimport { Events } from '../event.enum';\n\ninterface IWorkflowVo {\n  id: string;\n  name: string;\n}\n\ntype IWorkflowCreatePayload = { baseId: string; workflow: IWorkflowVo };\ntype IWorkflowDeletePayload = { baseId: string; workflowId: string; permanent?: boolean };\ntype IWorkflowUpdatePayload = IWorkflowCreatePayload;\n\nexport class WorkflowCreateEvent extends CoreEvent<IWorkflowCreatePayload> {\n  public readonly name = Events.WORKFLOW_CREATE;\n\n  constructor(payload: IWorkflowCreatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class WorkflowDeleteEvent extends CoreEvent<IWorkflowDeletePayload> {\n  public readonly name = Events.WORKFLOW_DELETE;\n  constructor(payload: IWorkflowDeletePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class WorkflowUpdateEvent extends CoreEvent<IWorkflowUpdatePayload> {\n  public readonly name = Events.WORKFLOW_UPDATE;\n\n  constructor(payload: IWorkflowUpdatePayload, context: IEventContext) {\n    super(payload, context);\n  }\n}\n\nexport class WorkflowEventFactory {\n  static create(\n    name: string,\n    payload: IWorkflowCreatePayload | IWorkflowDeletePayload | IWorkflowUpdatePayload,\n    context: IEventContext\n  ) {\n    return match(name)\n      .with(Events.WORKFLOW_CREATE, () => {\n        return new WorkflowCreateEvent(payload as IWorkflowCreatePayload, context);\n      })\n      .with(Events.WORKFLOW_DELETE, () => {\n        return new WorkflowDeleteEvent(payload as IWorkflowDeletePayload, context);\n      })\n      .with(Events.WORKFLOW_UPDATE, () => {\n        return new WorkflowUpdateEvent(payload as IWorkflowUpdatePayload, context);\n      })\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport type { Request } from 'express';\nimport type { Observable } from 'rxjs';\nimport { tap } from 'rxjs';\nimport { match, P } from 'ts-pattern';\nimport { EMIT_EVENT_NAME } from '../decorators/emit-controller-event.decorator';\nimport { EventEmitterService } from '../event-emitter.service';\nimport type { IEventContext } from '../events';\nimport {\n  Events,\n  BaseEventFactory,\n  SpaceEventFactory,\n  DashboardEventFactory,\n  AppEventFactory,\n  WorkflowEventFactory,\n} from '../events';\nimport { BaseNodeEventFactory } from '../events/base/base-node.event';\n\n@Injectable()\nexport class EventMiddleware implements NestInterceptor {\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {\n    const req = context.switchToHttp().getRequest<Request>();\n    const emitEventName = this.reflector.get<Events>(EMIT_EVENT_NAME, context.getHandler());\n\n    return next.handle().pipe(\n      tap((data) => {\n        const interceptContext = this.interceptContext(req, data);\n\n        const event = this.createEvent(emitEventName, interceptContext);\n        event\n          ? this.eventEmitterService.emitAsync(event.name, event)\n          : this.eventEmitterService.emitAsync(emitEventName, interceptContext);\n      })\n    );\n  }\n\n  private interceptContext(req: Request, resolveData: any) {\n    return {\n      reqUser: req?.user as any,\n      reqHeaders: req?.headers,\n      reqParams: req?.params,\n      reqQuery: req?.query,\n      reqBody: req?.body,\n      resolveData,\n    };\n  }\n\n  private createEvent(\n    eventName: Events,\n    interceptContext: ReturnType<typeof this.interceptContext>\n  ) {\n    const { reqUser, reqHeaders, reqParams, resolveData } = interceptContext;\n\n    const eventContext: IEventContext = {\n      user: reqUser,\n      headers: reqHeaders,\n    };\n\n    return match(eventName)\n      .with(Events.BASE_DELETE, () =>\n        BaseEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)\n      )\n      .with(P.union(Events.BASE_CREATE, Events.BASE_UPDATE, Events.BASE_PERMISSION_UPDATE), () =>\n        BaseEventFactory.create(eventName, { base: resolveData, ...reqParams }, eventContext)\n      )\n      .with(Events.SPACE_DELETE, () =>\n        SpaceEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)\n      )\n      .with(P.union(Events.SPACE_CREATE, Events.SPACE_UPDATE), () =>\n        SpaceEventFactory.create(eventName, { space: resolveData, ...reqParams }, eventContext)\n      )\n      .with(Events.WORKFLOW_DELETE, () =>\n        WorkflowEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)\n      )\n      .with(P.union(Events.WORKFLOW_CREATE, Events.WORKFLOW_UPDATE), () =>\n        WorkflowEventFactory.create(\n          eventName,\n          { baseId: reqParams.baseId, workflow: resolveData, ...reqParams },\n          eventContext\n        )\n      )\n      .with(Events.APP_DELETE, () =>\n        AppEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)\n      )\n      .with(P.union(Events.APP_CREATE, Events.APP_UPDATE), () =>\n        AppEventFactory.create(\n          eventName,\n          { baseId: reqParams.baseId, app: resolveData, ...reqParams },\n          eventContext\n        )\n      )\n      .with(Events.DASHBOARD_DELETE, () =>\n        DashboardEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)\n      )\n      .with(P.union(Events.DASHBOARD_CREATE, Events.DASHBOARD_UPDATE), () =>\n        DashboardEventFactory.create(\n          eventName,\n          { baseId: reqParams.baseId, dashboard: resolveData, ...reqParams },\n          eventContext\n        )\n      )\n\n      .with(\n        P.union(Events.BASE_NODE_CREATE, Events.BASE_NODE_UPDATE, Events.BASE_NODE_DELETE),\n        () => {\n          const { baseId } = reqParams;\n          return BaseNodeEventFactory.create(\n            eventName,\n            { baseId, node: resolveData },\n            eventContext\n          );\n        }\n      )\n\n      .otherwise(() => null);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport type { ITableActionKey, IGridColumn, IViewActionKey } from '@teable/core';\nimport { getActionTriggerChannel, OpName } from '@teable/core';\nimport { isEmpty } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { match } from 'ts-pattern';\nimport { getV2CreateTableLegacyEventsFlag } from '../../features/v2/v2-create-table-compat.constants';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport type { IClsStore } from '../../types/cls';\nimport type {\n  RecordCreateEvent,\n  RecordDeleteEvent,\n  RecordUpdateEvent,\n  ViewUpdateEvent,\n  FieldUpdateEvent,\n  FieldCreateEvent,\n  FieldDeleteEvent,\n} from '../events';\nimport { Events } from '../events';\n\ntype IViewEvent = ViewUpdateEvent;\ntype IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent;\ntype IListenerEvent =\n  | IViewEvent\n  | IRecordEvent\n  | FieldUpdateEvent\n  | FieldCreateEvent\n  | FieldDeleteEvent;\n\nexport interface IActionTriggerData {\n  actionKey: ITableActionKey | IViewActionKey;\n  payload?: Record<string, unknown>;\n}\n\n@Injectable()\nexport class ActionTriggerListener {\n  private readonly logger = new Logger(ActionTriggerListener.name);\n\n  constructor(\n    private readonly shareDbService: ShareDbService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @OnEvent(Events.TABLE_VIEW_UPDATE, { async: true })\n  @OnEvent(Events.TABLE_FIELD_UPDATE, { async: true })\n  @OnEvent(Events.TABLE_FIELD_CREATE, { async: true })\n  @OnEvent(Events.TABLE_FIELD_DELETE, { async: true })\n  @OnEvent('table.record.*', { async: true })\n  private async listener(listenerEvent: IListenerEvent): Promise<void> {\n    if (\n      getV2CreateTableLegacyEventsFlag(this.cls) &&\n      (this.isTableFieldCreateEvent(listenerEvent) || this.isTableRecordEvent(listenerEvent))\n    ) {\n      return;\n    }\n\n    // Handling table view update events\n    if (this.isTableViewUpdateEvent(listenerEvent)) {\n      await this.handleTableViewUpdate(listenerEvent as ViewUpdateEvent);\n    }\n\n    // Handling table field update events\n    if (this.isTableFieldUpdateEvent(listenerEvent)) {\n      await this.handleTableFieldUpdate(listenerEvent as FieldUpdateEvent);\n    }\n\n    // Handling table field create events\n    if (this.isTableFieldCreateEvent(listenerEvent)) {\n      await this.handleTableFieldCreate(listenerEvent as FieldCreateEvent);\n    }\n\n    // Handling table field delete events\n    if (this.isTableFieldDeleteEvent(listenerEvent)) {\n      await this.handleTableFieldDelete(listenerEvent as FieldDeleteEvent);\n    }\n\n    // Handling table record events (create, delete, update)\n    if (this.isTableRecordEvent(listenerEvent)) {\n      await this.handleTableRecordEvent(listenerEvent as IRecordEvent);\n    }\n  }\n\n  private async handleTableViewUpdate(event: ViewUpdateEvent): Promise<void> {\n    if (!this.isValidViewUpdateOperation(event)) {\n      return;\n    }\n\n    const { view } = event.payload;\n    const { id: viewId, filter, columnMeta, group } = view;\n\n    const buffer: IViewActionKey[] = [];\n    filter && buffer.push('applyViewFilter');\n    group && buffer.push('applyViewGroup');\n\n    if (columnMeta != null) {\n      Object.entries(columnMeta)?.forEach(([_fieldId, { oldValue, newValue }]) => {\n        const oldColumn = oldValue as IGridColumn;\n        const newColumn = newValue as IGridColumn;\n\n        const shouldShow = !newColumn?.hidden && oldColumn?.hidden !== newColumn?.hidden;\n        const shouldApplyStatFunc = oldColumn?.statisticFunc !== newColumn?.statisticFunc;\n\n        if (shouldShow) {\n          buffer.push('showViewField');\n        }\n        if (shouldApplyStatFunc) {\n          buffer.push('applyViewStatisticFunc');\n        }\n      });\n    }\n\n    if (!isEmpty(buffer)) {\n      this.emitActionTrigger(\n        viewId,\n        buffer.map((actionKey) => ({ actionKey }))\n      );\n    }\n  }\n\n  private async handleTableFieldUpdate(event: FieldUpdateEvent): Promise<void> {\n    if (!this.isValidFieldUpdateOperation(event)) {\n      return;\n    }\n\n    const { tableId } = event.payload;\n    return this.emitActionTrigger(tableId, [{ actionKey: 'setField', payload: event.payload }]);\n  }\n\n  private async handleTableFieldCreate(event: FieldCreateEvent): Promise<void> {\n    const { tableId } = event.payload;\n    return this.emitActionTrigger(tableId, [{ actionKey: 'addField', payload: event.payload }]);\n  }\n\n  private async handleTableFieldDelete(event: FieldDeleteEvent): Promise<void> {\n    const { tableId } = event.payload;\n    return this.emitActionTrigger(tableId, [{ actionKey: 'deleteField', payload: event.payload }]);\n  }\n\n  private async handleTableRecordEvent(event: IRecordEvent): Promise<void> {\n    const { tableId } = event.payload;\n\n    const buffer = match(event)\n      .returnType<ITableActionKey[]>()\n      .with({ name: Events.TABLE_RECORD_CREATE }, () => ['addRecord'])\n      .with({ name: Events.TABLE_RECORD_UPDATE }, () => ['setRecord'])\n      .with({ name: Events.TABLE_RECORD_DELETE }, () => ['deleteRecord'])\n      .otherwise(() => []);\n\n    if (!isEmpty(buffer)) {\n      this.emitActionTrigger(\n        tableId,\n        buffer.map((actionKey) => ({ actionKey }))\n      );\n    }\n  }\n\n  private isTableViewUpdateEvent(event: IListenerEvent): boolean {\n    return Events.TABLE_VIEW_UPDATE === event.name;\n  }\n\n  private isTableFieldUpdateEvent(event: IListenerEvent): boolean {\n    return Events.TABLE_FIELD_UPDATE === event.name;\n  }\n\n  private isTableFieldCreateEvent(event: IListenerEvent): boolean {\n    return Events.TABLE_FIELD_CREATE === event.name;\n  }\n\n  private isTableFieldDeleteEvent(event: IListenerEvent): boolean {\n    return Events.TABLE_FIELD_DELETE === event.name;\n  }\n\n  private isValidViewUpdateOperation(event: ViewUpdateEvent): boolean | undefined {\n    const propertyKeys = ['filter', 'group'];\n    const { name, propertyKey } = event.context.opMeta || {};\n    return name === OpName.UpdateViewColumnMeta || propertyKeys.includes(propertyKey as string);\n  }\n\n  private isValidFieldUpdateOperation(event: FieldUpdateEvent): boolean | undefined {\n    const propertyKeys = ['options', 'dbFieldType'];\n    const { propertyKey } = event.context.opMeta || {};\n    return propertyKeys.includes(propertyKey as string);\n  }\n\n  private isTableRecordEvent(event: IListenerEvent): boolean {\n    const recordEvents = [\n      Events.TABLE_RECORD_CREATE,\n      Events.TABLE_RECORD_DELETE,\n      Events.TABLE_RECORD_UPDATE,\n    ];\n    return recordEvents.includes(event.name);\n  }\n\n  private emitActionTrigger(tableIdOrViewId: string, data: IActionTriggerData[]) {\n    const channel = getActionTriggerChannel(tableIdOrViewId);\n\n    const presence = this.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create(tableIdOrViewId);\n    localPresence.submit(data, (error) => {\n      error && this.logger.error(error);\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/attachment.listener.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { AttachmentsTableService } from '../../features/attachments/attachments-table.service';\nimport {\n  Events,\n  FieldDeleteEvent,\n  RecordDeleteEvent,\n  RecordCreateEvent,\n  RecordUpdateEvent,\n} from '../events';\n\n@Injectable()\nexport class AttachmentListener {\n  constructor(private readonly attachmentsTableService: AttachmentsTableService) {}\n\n  @OnEvent(Events.TABLE_RECORD_CREATE, { async: true })\n  async recordCreateListener(listenerEvent: RecordCreateEvent) {\n    const {\n      payload: { record, tableId },\n      context,\n    } = listenerEvent;\n    await this.attachmentsTableService.createRecords(\n      context.user!.id,\n      tableId,\n      Array.isArray(record) ? record : [record]\n    );\n  }\n\n  @OnEvent(Events.TABLE_RECORD_DELETE, { async: true })\n  async recordDeleteListener(listenerEvent: RecordDeleteEvent) {\n    const {\n      payload: { tableId, recordId },\n    } = listenerEvent;\n    await this.attachmentsTableService.deleteRecords(\n      tableId,\n      Array.isArray(recordId) ? recordId : [recordId]\n    );\n  }\n\n  @OnEvent(Events.TABLE_RECORD_UPDATE, { async: true })\n  async recordUpdateListener(listenerEvent: RecordUpdateEvent) {\n    const {\n      payload: { tableId, record },\n      context,\n    } = listenerEvent;\n    await this.attachmentsTableService.updateRecords(\n      context.user!.id,\n      tableId,\n      Array.isArray(record) ? record : [record]\n    );\n  }\n\n  @OnEvent(Events.TABLE_FIELD_DELETE, { async: true })\n  async fieldDeleteListener(listenerEvent: FieldDeleteEvent) {\n    const {\n      payload: { tableId, fieldId },\n    } = listenerEvent;\n\n    await this.attachmentsTableService.deleteFields(\n      tableId,\n      Array.isArray(fieldId) ? fieldId : [fieldId]\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/base-permission-update.listener.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { getBasePermissionUpdateChannel } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport { EventEmitterService } from '../event-emitter.service';\nimport { Events, BasePermissionUpdateEvent } from '../events';\nimport { CollaboratorUpdateEvent } from '../events/space/collaborator.event';\n\n@Injectable()\nexport class BasePermissionUpdateListener {\n  private readonly logger = new Logger(BasePermissionUpdateListener.name);\n\n  constructor(\n    private readonly shareDbService: ShareDbService,\n    private readonly prismaService: PrismaService,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  @OnEvent(Events.BASE_PERMISSION_UPDATE, { async: true })\n  async basePermissionUpdateListener(listenerEvent: BasePermissionUpdateEvent) {\n    const {\n      payload: { baseId },\n      context: { user },\n    } = listenerEvent;\n    const space = await this.prismaService.base.findUnique({\n      where: {\n        id: baseId,\n      },\n      select: {\n        spaceId: true,\n      },\n    });\n\n    if (space?.spaceId) {\n      this.eventEmitterService.emitAsync(\n        Events.COLLABORATOR_UPDATE,\n        new CollaboratorUpdateEvent(space.spaceId)\n      );\n    }\n\n    const channel = getBasePermissionUpdateChannel(baseId);\n    const presence = this.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create();\n\n    // Include the operator user ID in the message to allow filtering on the client side\n    localPresence.submit(user?.id, (error) => {\n      error && this.logger.error(error);\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport type { IRecord, IUserCellValue } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { has, intersection, isEmpty, keyBy, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { NotificationService } from '../../features/notification/notification.service';\nimport { RecordService } from '../../features/record/record.service';\nimport type { IChangeRecord, IChangeValue, RecordCreateEvent, RecordUpdateEvent } from '../events';\nimport { Events } from '../events';\n\ntype IListenerEvent = RecordCreateEvent | RecordUpdateEvent;\n\ntype IUserField = {\n  baseId: string;\n  tableName: string;\n  fieldId: string;\n  fieldName: string;\n  fieldOptions: string;\n};\n\n// Maximum number of record titles to fetch for notification display\nconst maxRecordTitles = 10;\n\n@Injectable()\nexport class CollaboratorNotificationListener {\n  private readonly logger = new Logger(CollaboratorNotificationListener.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly notificationService: NotificationService,\n    private readonly recordService: RecordService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  @OnEvent(Events.TABLE_RECORD_CREATE, { async: true })\n  @OnEvent(Events.TABLE_RECORD_UPDATE, { async: true })\n  private async listener(listenerEvent: IListenerEvent): Promise<void> {\n    const { tableId, record } = listenerEvent.payload;\n\n    const userFieldData = await this.fetchUserFields(tableId);\n    if (isEmpty(userFieldData)) {\n      return;\n    }\n\n    const userFields = keyBy(userFieldData, 'fieldId');\n    const userFieldIds = Object.keys(userFields);\n\n    if (!this.hasRelevantFields(record, userFieldIds)) {\n      return;\n    }\n\n    await this.updateTableRecord(listenerEvent, userFieldIds, userFields);\n  }\n\n  private hasRelevantFields(\n    record: IRecord | IChangeRecord | (IRecord | IChangeRecord)[],\n    userFieldIds: string[]\n  ): boolean {\n    const fields = this.getRecordFields(record);\n    return !isEmpty(intersection(userFieldIds, fields));\n  }\n\n  private getRecordFields(record: IRecord | IChangeRecord | (IRecord | IChangeRecord)[]): string[] {\n    const records = Array.isArray(record) ? record : [record];\n    return records.filter(Boolean).flatMap((r) => Object.keys(r.fields || {}));\n  }\n\n  private async updateTableRecord(\n    event: RecordCreateEvent | RecordUpdateEvent,\n    userFieldIds: string[],\n    userFields: Record<string, IUserField>\n  ): Promise<void> {\n    const {\n      payload: { tableId, record: eventRecords },\n      context: { user },\n    } = event;\n    const recordSets = (Array.isArray(eventRecords) ? eventRecords : [eventRecords]).filter(\n      Boolean\n    ) as (IRecord | IChangeRecord)[];\n\n    const notificationData = this.extractNotificationData(recordSets, userFieldIds);\n\n    // Collect record IDs that need titles (limited to maxRecordTitles per user)\n    const recordIdsNeedingTitles = uniq(\n      Object.values(notificationData).flatMap((data) => data.recordIds.slice(0, maxRecordTitles))\n    );\n    const recordTitles =\n      recordIdsNeedingTitles.length > 0\n        ? await this.recordService.getRecordsHeadWithIds(tableId, recordIdsNeedingTitles)\n        : [];\n    const recordTitlesMap = keyBy(recordTitles, 'id');\n\n    for (const userId in notificationData) {\n      const { fieldId, recordIds } = notificationData[userId];\n      const field = userFields[fieldId];\n      const recordIdsForTitles = recordIds.slice(0, maxRecordTitles);\n\n      await this.notificationService.sendCollaboratorNotify({\n        fromUserId: user?.id || '',\n        toUserId: userId,\n        refRecord: {\n          baseId: field.baseId,\n          tableId: tableId,\n          tableName: field.tableName,\n          fieldName: field.fieldName,\n          recordIds: recordIds,\n          recordTitles: recordIdsForTitles.map((id) => recordTitlesMap[id]).filter(Boolean),\n        },\n      });\n    }\n  }\n\n  private extractNotificationData(\n    records: (IRecord | IChangeRecord)[],\n    userFieldIds: string[]\n  ): Record<string, { fieldId: string; recordIds: string[] }> {\n    return records.reduce<Record<string, { fieldId: string; recordIds: string[] }>>(\n      (acc, record) => {\n        const { id: recordId, fields: changeFields } = record;\n\n        if (!recordId || !changeFields) {\n          return acc;\n        }\n\n        Object.entries(changeFields).forEach(([fieldId, value]) => {\n          const cellValue = has(value, 'newValue') ? (value as IChangeValue).newValue : value;\n\n          if (userFieldIds.includes(fieldId) && cellValue) {\n            const collaborators = Array.isArray(cellValue) ? cellValue : [cellValue];\n\n            collaborators.forEach((collaborator: IUserCellValue) => {\n              const userId = collaborator.id;\n              if (!acc[userId]) {\n                acc[userId] = { fieldId, recordIds: [recordId] };\n              } else {\n                acc[userId].recordIds.push(recordId);\n              }\n            });\n          }\n        });\n        return acc;\n      },\n      {}\n    );\n  }\n\n  private async fetchUserFields(tableId: string) {\n    const getTableAllUserFieldSql = this.knex\n      .select({\n        baseId: 'tm.base_id',\n        tableName: 'tm.name',\n        fieldId: 'f.id',\n        fieldName: 'f.name',\n        fieldOptions: 'f.options',\n      })\n      .from(this.knex.ref('table_meta').as('tm'))\n      .join(this.knex.ref('field').as('f'), (clause) => {\n        clause.on('tm.id', 'f.table_id').andOnNull('tm.deleted_time').andOnNull('f.deleted_time');\n      })\n      .where('f.table_id', tableId)\n      .andWhere('f.type', FieldType.User);\n\n    const userFieldRaws = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<IUserField[]>(getTableAllUserFieldSql.toQuery());\n\n    // Filtering member fields that don't need to be notified based on `options.shouldNotify`\n    return userFieldRaws.filter(({ fieldOptions }) => {\n      const options = JSON.parse(fieldOptions);\n      return options && options?.shouldNotify;\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/pin.listener.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { SpaceDeleteEvent, BaseDeleteEvent } from '../events';\nimport { Events } from '../events';\n\n@Injectable()\nexport class PinListener {\n  private readonly logger = new Logger(PinListener.name);\n\n  constructor(private readonly prismaService: PrismaService) {}\n\n  @OnEvent(Events.BASE_DELETE, { async: true })\n  @OnEvent(Events.SPACE_DELETE, { async: true })\n  async spaceAndBaseDelete(listenerEvent: SpaceDeleteEvent | BaseDeleteEvent) {\n    let id: string = '';\n    if (listenerEvent.name === Events.SPACE_DELETE) {\n      id = listenerEvent.payload.spaceId;\n    }\n    if (listenerEvent.name === Events.BASE_DELETE) {\n      id = listenerEvent.payload.baseId;\n    }\n\n    if (!id) {\n      return;\n    }\n\n    await this.prismaService.pinResource.deleteMany({\n      where: {\n        resourceId: id,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport type { ISelectFieldOptions } from '@teable/core';\nimport { FieldType, generateRecordHistoryId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Field } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { isEqual, isObject, isString } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { BaseConfig, IBaseConfig } from '../../configs/base.config';\nimport { DataLoaderService } from '../../features/data-loader/data-loader.service';\nimport { rawField2FieldObj } from '../../features/field/model/factory';\nimport { EventEmitterService } from '../event-emitter.service';\nimport { Events, RecordUpdateEvent } from '../events';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst SELECT_FIELD_TYPE_SET = new Set([FieldType.SingleSelect, FieldType.MultipleSelect]);\n\n@Injectable()\nexport class RecordHistoryListener {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly eventEmitterService: EventEmitterService,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly dataLoaderService: DataLoaderService\n  ) {}\n\n  @OnEvent(Events.TABLE_RECORD_UPDATE, { async: true })\n  async recordUpdateListener(event: RecordUpdateEvent) {\n    if (this.baseConfig.recordHistoryDisabled) {\n      return;\n    }\n\n    const { payload, context } = event;\n    const { user } = context;\n    const { tableId, oldField: _oldField } = payload;\n    const userId = user?.id;\n    const payloadRecord = payload.record;\n    const records = !Array.isArray(payloadRecord) ? [payloadRecord] : payloadRecord;\n\n    const fieldIdSet = new Set<string>();\n\n    records.forEach((record) => {\n      const { fields } = record;\n\n      Object.keys(fields).forEach((fieldId) => {\n        fieldIdSet.add(fieldId);\n      });\n    });\n\n    const fieldIds = Array.from(fieldIdSet);\n\n    const fields = await this.dataLoaderService.field.load(tableId, {\n      id: fieldIds,\n    });\n\n    const fieldMap = new Map(fields.map((field) => [field.id, rawField2FieldObj(field)]));\n\n    const batchSize = 5000;\n    const totalCount = records.length;\n\n    for (let i = 0; i < totalCount; i += batchSize) {\n      const batch = records.slice(i, i + batchSize);\n      const recordHistoryList: {\n        id: string;\n        table_id: string;\n        record_id: string;\n        field_id: string;\n        before: string;\n        after: string;\n        created_by: string;\n      }[] = [];\n\n      batch.forEach((record) => {\n        const { id: recordId, fields } = record;\n        Object.entries(fields).forEach(([fieldId, changeValue]) => {\n          const field = fieldMap.get(fieldId);\n\n          if (!field || !changeValue || !isObject(changeValue)) {\n            return null;\n          }\n\n          if (!('oldValue' in changeValue) || !('newValue' in changeValue)) {\n            return null;\n          }\n\n          const oldField = _oldField ?? field;\n          const { type, name, cellValueType, isComputed } = field;\n          const { oldValue, newValue } = changeValue;\n\n          // Skip no-op changes to avoid duplicate history entries\n          if (isEqual(oldValue, newValue)) {\n            return null;\n          }\n\n          if (oldField.isComputed && isComputed) {\n            return null;\n          }\n\n          recordHistoryList.push({\n            id: generateRecordHistoryId(),\n            table_id: tableId,\n            record_id: recordId,\n            field_id: fieldId,\n            before: JSON.stringify({\n              meta: {\n                type: oldField.type,\n                name: oldField.name,\n                options: this.minimizeFieldOptions(oldValue, oldField),\n                cellValueType: oldField.cellValueType,\n              },\n              data: oldValue,\n            }),\n            after: JSON.stringify({\n              meta: {\n                type,\n                name,\n                options: this.minimizeFieldOptions(newValue, field),\n                cellValueType,\n              },\n              data: newValue,\n            }),\n            created_by: userId as string,\n          });\n        });\n      });\n\n      if (recordHistoryList.length) {\n        const query = this.knex.insert(recordHistoryList).into('record_history').toQuery();\n\n        await this.prismaService.$executeRawUnsafe(query);\n      }\n    }\n\n    this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, {\n      recordIds: records.map((record) => record.id),\n    });\n  }\n\n  private minimizeFieldOptions(\n    value: unknown,\n    field: Pick<Field, 'type'> & {\n      options: Record<string, unknown> | null;\n    }\n  ) {\n    const { type, options: _options } = field;\n\n    if (SELECT_FIELD_TYPE_SET.has(type as FieldType)) {\n      const options = _options as ISelectFieldOptions;\n      const { choices } = options;\n\n      if (value == null) {\n        return { ...options, choices: [] };\n      }\n\n      if (isString(value)) {\n        return { ...options, choices: choices.filter(({ name }) => name === value) };\n      }\n\n      if (Array.isArray(value)) {\n        const valueSet = new Set(value);\n        return { ...options, choices: choices.filter(({ name }) => valueSet.has(name)) };\n      }\n    }\n\n    return _options;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/event-emitter/listeners/trash.listener.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ResourceType } from '@teable/openapi';\nimport type {\n  SpaceDeleteEvent,\n  BaseDeleteEvent,\n  TableDeleteEvent,\n  AppDeleteEvent,\n  WorkflowDeleteEvent,\n} from '../events';\nimport { Events } from '../events';\n\n@Injectable()\nexport class TrashListener {\n  constructor(private readonly prismaService: PrismaService) {}\n\n  @OnEvent(Events.SPACE_DELETE, { async: true })\n  @OnEvent(Events.BASE_DELETE, { async: true })\n  @OnEvent(Events.TABLE_DELETE, { async: true })\n  @OnEvent(Events.APP_DELETE, { async: true })\n  @OnEvent(Events.WORKFLOW_DELETE, { async: true })\n  async onEvent(\n    event:\n      | SpaceDeleteEvent\n      | BaseDeleteEvent\n      | TableDeleteEvent\n      | AppDeleteEvent\n      | WorkflowDeleteEvent\n  ) {\n    const { name, payload } = event;\n    const { user } = event.context;\n    let resourceId: string;\n    let resourceType: ResourceType;\n    let deletedTime: Date | undefined | null;\n    let parentId: string | undefined;\n\n    if ('permanent' in payload && payload.permanent) {\n      return;\n    }\n\n    switch (name) {\n      case Events.SPACE_DELETE: {\n        resourceId = payload.spaceId;\n        resourceType = ResourceType.Space;\n        const space = await this.prismaService.space.findUnique({\n          where: { id: resourceId },\n          select: { id: true, deletedTime: true },\n        });\n        deletedTime = space?.deletedTime;\n        break;\n      }\n      case Events.BASE_DELETE: {\n        resourceId = payload.baseId;\n        resourceType = ResourceType.Base;\n        const base = await this.prismaService.base.findUnique({\n          where: { id: resourceId },\n          select: { id: true, spaceId: true, deletedTime: true },\n        });\n        deletedTime = base?.deletedTime;\n        parentId = base?.spaceId;\n        break;\n      }\n      case Events.TABLE_DELETE: {\n        resourceId = payload.tableId;\n        resourceType = ResourceType.Table;\n        const table = await this.prismaService.tableMeta.findUnique({\n          where: { id: resourceId },\n          select: { id: true, baseId: true, deletedTime: true },\n        });\n        deletedTime = table?.deletedTime;\n        parentId = table?.baseId;\n        break;\n      }\n      case Events.APP_DELETE: {\n        resourceId = payload.appId;\n        resourceType = ResourceType.App;\n        const app = await this.prismaService.app.findUnique({\n          where: { id: resourceId },\n          select: { id: true, baseId: true, deletedTime: true },\n        });\n        deletedTime = app?.deletedTime;\n        parentId = app?.baseId;\n        break;\n      }\n      case Events.WORKFLOW_DELETE: {\n        resourceId = payload.workflowId;\n        resourceType = ResourceType.Workflow;\n        const workflow = await this.prismaService.workflow.findUnique({\n          where: { id: resourceId },\n          select: { id: true, baseId: true, deletedTime: true },\n        });\n        deletedTime = workflow?.deletedTime;\n        parentId = workflow?.baseId;\n        break;\n      }\n    }\n\n    if (!deletedTime) return;\n\n    await this.prismaService.trash.create({\n      data: {\n        resourceId,\n        resourceType,\n        parentId,\n        deletedTime,\n        deletedBy: user?.id as string,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { AccessTokenController } from './access-token.controller';\n\ndescribe('AccessTokenController', () => {\n  let controller: AccessTokenController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [AccessTokenController],\n    }).compile();\n\n    controller = module.get<AccessTokenController>(AccessTokenController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';\nimport type {\n  CreateAccessTokenVo,\n  GetAccessTokenVo,\n  ListAccessTokenVo,\n  RefreshAccessTokenVo,\n  UpdateAccessTokenVo,\n} from '@teable/openapi';\nimport {\n  CreateAccessTokenRo,\n  createAccessTokenRoSchema,\n  refreshAccessTokenRoSchema,\n  UpdateAccessTokenRo,\n  updateAccessTokenRoSchema,\n  RefreshAccessTokenRo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AccessTokenService } from './access-token.service';\n\n@Controller('api/access-token')\nexport class AccessTokenController {\n  constructor(private readonly accessTokenService: AccessTokenService) {}\n\n  @Post()\n  async createAccessToken(\n    @Body(new ZodValidationPipe(createAccessTokenRoSchema)) body: CreateAccessTokenRo\n  ): Promise<CreateAccessTokenVo> {\n    return await this.accessTokenService.createAccessToken(body);\n  }\n\n  @Put(':accessTokenId')\n  async updateAccessToken(\n    @Param('accessTokenId') accessTokenId: string,\n    @Body(new ZodValidationPipe(updateAccessTokenRoSchema)) body: UpdateAccessTokenRo\n  ): Promise<UpdateAccessTokenVo> {\n    return await this.accessTokenService.updateAccessToken(accessTokenId, body);\n  }\n\n  @Delete(':accessTokenId')\n  async deleteAccessToken(@Param('accessTokenId') accessTokenId: string) {\n    return await this.accessTokenService.deleteAccessToken(accessTokenId);\n  }\n\n  @Post('/:accessTokenId/refresh')\n  @HttpCode(200)\n  async refreshAccessToken(\n    @Param('accessTokenId') accessTokenId: string,\n    @Body(new ZodValidationPipe(refreshAccessTokenRoSchema)) body: RefreshAccessTokenRo\n  ): Promise<RefreshAccessTokenVo> {\n    return await this.accessTokenService.refreshAccessToken(accessTokenId, body);\n  }\n\n  @Get()\n  async getAccessTokens(): Promise<ListAccessTokenVo> {\n    return await this.accessTokenService.listAccessToken();\n  }\n\n  @Get(':accessTokenId')\n  async getAccessToken(@Param('accessTokenId') accessTokenId: string): Promise<GetAccessTokenVo> {\n    return await this.accessTokenService.getAccessToken(accessTokenId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.encryptor.ts",
    "content": "import { authConfig } from '../../configs/auth.config';\nimport { Encryptor } from '../../utils/encryptor';\n\ninterface ITokenEncryptor {\n  sign: string;\n}\n\nlet accessTokenEncryptor: Encryptor<ITokenEncryptor>;\n\nconst getAccessTokenEncryptor = () => {\n  if (!accessTokenEncryptor) {\n    accessTokenEncryptor = new Encryptor<ITokenEncryptor>({\n      ...authConfig().accessToken.encryption,\n      encoding: 'base64',\n    });\n  }\n  return accessTokenEncryptor;\n};\n\nexport const getAccessToken = (accessTokenId: string, sign: string) => {\n  return `${authConfig().accessToken.prefix}_${accessTokenId}_${getAccessTokenEncryptor().encrypt({\n    sign,\n  })}`;\n};\n\nexport const splitAccessToken = (accessToken: string) => {\n  const [prefix = '', accessTokenId = '', encryptedSign = ''] = accessToken.split('_');\n  if (!accessTokenId) {\n    return null;\n  }\n  if (prefix !== authConfig().accessToken.prefix) {\n    return null;\n  }\n  const { sign } = getAccessTokenEncryptor().decrypt(encryptedSign);\n  if (!sign) {\n    return null;\n  }\n  return { prefix, accessTokenId, sign };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AccessTokenController } from './access-token.controller';\nimport { AccessTokenService } from './access-token.service';\n\n@Module({\n  providers: [AccessTokenService],\n  controllers: [AccessTokenController],\n  exports: [AccessTokenService],\n})\nexport class AccessTokenModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { UnauthorizedException } from '@nestjs/common';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { GlobalModule } from '../../global/global.module';\nimport { AccessTokenModel } from '../model/access-token';\nimport { AccessTokenModule } from './access-token.module';\nimport { AccessTokenService } from './access-token.service';\n\ndescribe('AccessTokenService', () => {\n  let accessTokenService: AccessTokenService;\n  const prismaService = mockDeep<PrismaService>();\n  const accessTokenModel = mockDeep<AccessTokenModel>();\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, AccessTokenModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaService)\n      .overrideProvider(AccessTokenModel)\n      .useValue(accessTokenModel)\n      .compile();\n\n    accessTokenService = module.get<AccessTokenService>(AccessTokenService);\n\n    prismaService.txClient.mockImplementation(() => {\n      return prismaService;\n    });\n\n    prismaService.$tx.mockImplementation(async (fn, _options) => {\n      return await fn(prismaService);\n    });\n  });\n\n  afterEach(() => {\n    vitest.resetAllMocks();\n    mockReset(prismaService);\n  });\n\n  it('should be defined', () => {\n    expect(accessTokenService).toBeDefined();\n  });\n\n  describe('validate', () => {\n    it('should validate access token successfully', async () => {\n      // Mock data\n      const accessTokenId = '123';\n      const sign = 'SIGN';\n      const expiredTime = new Date(Date.now() + 2000); // Expires in 2 seconds\n      // Mock PrismaService response\n      accessTokenModel.getAccessTokenRawById.mockResolvedValue({\n        userId: 'user123',\n        id: accessTokenId,\n        sign,\n        expiredTime,\n      } as any);\n\n      // Call the validate method\n      const result = await accessTokenService.validate({ accessTokenId, sign });\n\n      // Validate the result\n      expect(result.userId).toEqual('user123');\n      expect(result.accessTokenId).toEqual(accessTokenId);\n\n      // Validate that accessToken.update was called with the correct arguments\n      expect(prismaService.txClient().accessToken.update).toHaveBeenCalledWith({\n        where: { id: accessTokenId },\n        data: { lastUsedTime: expect.any(String) }, // It updates lastUsedTime to current time\n      });\n    });\n\n    it('should throw UnauthorizedException for invalid sign', async () => {\n      // Mock data\n      const accessTokenId = '123';\n      const sign = 'INVALID_SIGN';\n\n      // Mock PrismaService response\n      accessTokenModel.getAccessTokenRawById.mockResolvedValue({\n        userId: 'user123',\n        id: accessTokenId,\n        sign: 'VALID_SIGN',\n        expiredTime: new Date(),\n      } as any);\n\n      // Call the validate method and expect it to throw UnauthorizedException\n      await expect(accessTokenService.validate({ accessTokenId, sign })).rejects.toThrowError(\n        new UnauthorizedException('sign error')\n      );\n\n      // Ensure accessToken.update is not called in this case\n      expect(prismaService.txClient().accessToken.update).not.toHaveBeenCalled();\n    });\n\n    it('should throw UnauthorizedException for expired token', async () => {\n      // Mock data\n      const accessTokenId = '123';\n      const sign = 'VALID_SIGN';\n      const expiredTime = new Date(Date.now() - 1500); // Expired 1 second ago\n\n      // Mock PrismaService response\n      accessTokenModel.getAccessTokenRawById.mockResolvedValue({\n        userId: 'user123',\n        id: accessTokenId,\n        sign,\n        expiredTime,\n      } as any);\n\n      // Call the validate method and expect it to throw UnauthorizedException\n      await expect(accessTokenService.validate({ accessTokenId, sign })).rejects.toThrowError(\n        new UnauthorizedException('token expired')\n      );\n\n      // Ensure accessToken.update is not called in this case\n      expect(prismaService.txClient().accessToken.update).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/access-token/access-token.service.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport type { Action } from '@teable/core';\nimport { generateAccessTokenId, getRandomString } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  CreateAccessTokenRo,\n  RefreshAccessTokenRo,\n  UpdateAccessTokenRo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { PerformanceCacheService } from '../../performance-cache';\nimport { generateAccessTokenCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { AccessTokenModel } from '../model/access-token';\nimport { getAccessToken } from './access-token.encryptor';\n\n@Injectable()\nexport class AccessTokenService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly accessTokenModel: AccessTokenModel,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  private transformAccessTokenEntity<\n    T extends {\n      description?: string | null;\n      scopes: string;\n      spaceIds: string | null;\n      baseIds: string | null;\n      createdTime?: Date;\n      lastUsedTime?: Date | null;\n      expiredTime?: Date;\n      hasFullAccess?: boolean | null;\n    },\n  >(accessTokenEntity: T) {\n    const {\n      scopes,\n      spaceIds,\n      baseIds,\n      createdTime,\n      lastUsedTime,\n      expiredTime,\n      description,\n      hasFullAccess,\n    } = accessTokenEntity;\n    return {\n      ...accessTokenEntity,\n      description: description || undefined,\n      scopes: JSON.parse(scopes) as Action[],\n      spaceIds: spaceIds ? (JSON.parse(spaceIds) as string[]) : undefined,\n      baseIds: baseIds ? (JSON.parse(baseIds) as string[]) : undefined,\n      createdTime: createdTime?.toISOString(),\n      lastUsedTime: lastUsedTime?.toISOString(),\n      expiredTime: expiredTime?.toISOString(),\n      hasFullAccess: hasFullAccess ?? undefined,\n    };\n  }\n\n  async validate(splitAccessTokenObj: { accessTokenId: string; sign: string }) {\n    const { accessTokenId, sign } = splitAccessTokenObj;\n    const accessTokenEntity = await this.accessTokenModel.getAccessTokenRawById(accessTokenId);\n    if (!accessTokenEntity) {\n      throw new UnauthorizedException('token not found');\n    }\n    if (sign !== accessTokenEntity.sign) {\n      throw new UnauthorizedException('sign error');\n    }\n    // expiredTime 1ms tolerance\n    if (\n      accessTokenEntity.expiredTime &&\n      new Date(accessTokenEntity.expiredTime).getTime() < Date.now() + 1000\n    ) {\n      throw new UnauthorizedException('token expired');\n    }\n    await this.prismaService.accessToken.update({\n      where: { id: accessTokenId },\n      data: { lastUsedTime: new Date().toISOString() },\n    });\n\n    return {\n      userId: accessTokenEntity.userId,\n      accessTokenId: accessTokenEntity.id,\n    };\n  }\n\n  async listAccessToken() {\n    const userId = this.cls.get('user.id');\n    const list = await this.prismaService.accessToken.findMany({\n      where: { userId, clientId: null },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        hasFullAccess: true,\n        createdTime: true,\n        expiredTime: true,\n        lastUsedTime: true,\n      },\n      orderBy: { createdTime: 'desc' },\n    });\n    return list.map(this.transformAccessTokenEntity);\n  }\n\n  async createAccessToken(\n    createAccessToken: CreateAccessTokenRo & { clientId?: string; userId?: string }\n  ) {\n    const userId = createAccessToken.userId ?? this.cls.get('user.id')!;\n    const { name, description, scopes, spaceIds, baseIds, expiredTime, clientId, hasFullAccess } =\n      createAccessToken;\n    const id = generateAccessTokenId();\n    const sign = getRandomString(16);\n    const accessTokenEntity = await this.prismaService.txClient().accessToken.create({\n      data: {\n        id,\n        name,\n        description,\n        scopes: JSON.stringify(scopes),\n        spaceIds: spaceIds === null ? null : JSON.stringify(spaceIds),\n        baseIds: baseIds === null ? null : JSON.stringify(baseIds),\n        userId,\n        sign,\n        clientId,\n        expiredTime: new Date(expiredTime).toISOString(),\n        hasFullAccess,\n      },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        expiredTime: true,\n        createdTime: true,\n        lastUsedTime: true,\n        hasFullAccess: true,\n      },\n    });\n    return {\n      ...this.transformAccessTokenEntity(accessTokenEntity),\n      token: getAccessToken(id, sign),\n    };\n  }\n\n  async deleteAccessToken(id: string) {\n    const userId = this.cls.get('user.id');\n    await this.prismaService.accessToken.delete({\n      where: { id, userId },\n    });\n  }\n\n  async refreshAccessToken(id: string, refreshAccessTokenRo?: RefreshAccessTokenRo) {\n    const userId = this.cls.get('user.id');\n\n    const sign = getRandomString(16);\n    const expiredTime = refreshAccessTokenRo?.expiredTime;\n    const accessTokenEntity = await this.prismaService.accessToken.update({\n      where: { id, userId },\n      data: {\n        sign,\n        expiredTime: expiredTime ? new Date(expiredTime).toISOString() : undefined,\n      },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        expiredTime: true,\n        lastUsedTime: true,\n      },\n    });\n    await this.performanceCacheService.del(generateAccessTokenCacheKey(id));\n    return {\n      ...this.transformAccessTokenEntity(accessTokenEntity),\n      token: getAccessToken(id, sign),\n    };\n  }\n\n  async updateAccessToken(id: string, updateAccessToken: UpdateAccessTokenRo) {\n    const userId = this.cls.get('user.id');\n    const { name, description, scopes, spaceIds, baseIds, hasFullAccess } = updateAccessToken;\n    const accessTokenEntity = await this.prismaService.accessToken.update({\n      where: { id, userId },\n      data: {\n        name,\n        description,\n        scopes: JSON.stringify(scopes),\n        spaceIds: spaceIds === null ? null : JSON.stringify(spaceIds),\n        baseIds: baseIds === null ? null : JSON.stringify(baseIds),\n        hasFullAccess,\n      },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        hasFullAccess: true,\n      },\n    });\n    await this.performanceCacheService.del(generateAccessTokenCacheKey(id));\n    return this.transformAccessTokenEntity(accessTokenEntity);\n  }\n\n  async getAccessToken(accessTokenId: string) {\n    const userId = this.cls.get('user.id');\n    const item = await this.prismaService.accessToken.findFirstOrThrow({\n      where: { userId, id: accessTokenId },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        createdTime: true,\n        expiredTime: true,\n        lastUsedTime: true,\n        hasFullAccess: true,\n      },\n    });\n    const res = this.transformAccessTokenEntity(item);\n    // filter deleted spaceIds and baseIds\n    const { spaceIds, baseIds } = res;\n    let filteredSpaceIds: string[] | undefined;\n    let filteredBaseIds: string[] | undefined;\n    if (spaceIds) {\n      const spaces = await this.prismaService.space.findMany({\n        where: { id: { in: spaceIds }, deletedTime: null },\n        select: { id: true },\n      });\n      filteredSpaceIds = spaces.map((space) => space.id);\n    }\n    if (baseIds) {\n      const bases = await this.prismaService.base.findMany({\n        where: { id: { in: baseIds }, deletedTime: null },\n        select: { id: true },\n      });\n      filteredBaseIds = bases.map((base) => base.id);\n    }\n    return {\n      ...res,\n      spaceIds: filteredSpaceIds,\n      baseIds: filteredBaseIds,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { RecordQueryBuilderModule } from '../record/query-builder';\nimport { RecordPermissionService } from '../record/record-permission.service';\nimport { RecordModule } from '../record/record.module';\nimport { TableIndexService } from '../table/table-index.service';\nimport { AggregationService } from './aggregation.service';\nimport { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';\n\n@Module({\n  imports: [RecordModule, RecordQueryBuilderModule],\n  providers: [\n    DbProvider,\n    TableIndexService,\n    RecordPermissionService,\n    AggregationService,\n    {\n      provide: AGGREGATION_SERVICE_SYMBOL,\n      useClass: AggregationService,\n      // useClass: AggregationService,\n    },\n  ],\n  exports: [AGGREGATION_SERVICE_SYMBOL, AggregationService],\n})\nexport class AggregationModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts",
    "content": "import type { IFilter, IGroup, StatisticsFunc } from '@teable/core';\nimport type {\n  IAggregationField,\n  IQueryBaseRo,\n  IRawAggregationValue,\n  IRawAggregations,\n  IRawRowCountValue,\n  IGroupPointsRo,\n  IGroupPoint,\n  ICalendarDailyCollectionRo,\n  ICalendarDailyCollectionVo,\n  ISearchIndexByQueryRo,\n  ISearchCountRo,\n  IRecordIndexRo,\n  IRecordIndexVo,\n} from '@teable/openapi';\nimport type { IFieldInstance } from '../field/model/factory';\n\n/**\n * Interface for aggregation service operations\n * This interface defines the public API for aggregation-related functionality\n */\nexport interface IAggregationService {\n  /**\n   * Perform aggregation operations on table data\n   * @param params - Parameters for aggregation including tableId, field IDs, view settings, and search\n   * @returns Promise<IRawAggregationValue> - The aggregation results\n   */\n  performAggregation(params: {\n    tableId: string;\n    withFieldIds?: string[];\n    withView?: IWithView;\n    search?: [string, string?, boolean?];\n    useQueryModel?: boolean;\n  }): Promise<IRawAggregationValue>;\n\n  /**\n   * Perform grouped aggregation operations\n   * @param params - Parameters for grouped aggregation\n   * @returns Promise<IRawAggregations> - The grouped aggregation results\n   */\n  performGroupedAggregation(params: {\n    aggregations: IRawAggregations;\n    statisticFields: IAggregationField[] | undefined;\n    tableId: string;\n    filter?: IFilter;\n    search?: [string, string?, boolean?];\n    groupBy?: IGroup;\n    dbTableName: string;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n    withView?: IWithView;\n  }): Promise<IRawAggregations>;\n\n  /**\n   * Get row count for a table with optional filtering\n   * @param tableId - The table ID\n   * @param queryRo - Query parameters for filtering\n   * @returns Promise<IRawRowCountValue> - The row count result\n   */\n  performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise<IRawRowCountValue>;\n\n  /**\n   * Get field data for a table\n   * @param tableId - The table ID\n   * @param fieldIds - Optional array of field IDs to filter\n   * @param withName - Whether to include field names in the mapping\n   * @returns Promise with field instances and field instance map\n   */\n  getFieldsData(\n    tableId: string,\n    fieldIds?: string[],\n    withName?: boolean\n  ): Promise<{\n    fieldInstances: IFieldInstance[];\n    fieldInstanceMap: Record<string, IFieldInstance>;\n  }>;\n\n  /**\n   * Get group points for a table\n   * @param tableId - The table ID\n   * @param query - Optional query parameters\n   * @returns Promise with group points data\n   */\n  getGroupPoints(\n    tableId: string,\n    query?: IGroupPointsRo,\n    useQueryModel?: boolean\n  ): Promise<IGroupPoint[]>;\n\n  /**\n   * Get search count for a table\n   * @param tableId - The table ID\n   * @param queryRo - Search query parameters\n   * @param projection - Optional field projection\n   * @returns Promise with search count result\n   */\n  getSearchCount(\n    tableId: string,\n    queryRo: ISearchCountRo,\n    projection?: string[]\n  ): Promise<{ count: number }>;\n\n  /**\n   * Get record index by search order\n   * @param tableId - The table ID\n   * @param queryRo - Search index query parameters\n   * @param projection - Optional field projection\n   * @returns Promise with search index results\n   */\n  getRecordIndexBySearchOrder(\n    tableId: string,\n    queryRo: ISearchIndexByQueryRo,\n    projection?: string[]\n  ): Promise<\n    | {\n        index: number;\n        fieldId: string;\n        recordId: string;\n      }[]\n    | null\n  >;\n\n  /**\n   * Get the 0-based index of a specific record in the current query context\n   * @param tableId - The table ID\n   * @param queryRo - Query parameters including recordId and optional view/filter/sort\n   * @returns Promise<IRecordIndexVo> - The record index or null if not found\n   */\n  getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise<IRecordIndexVo>;\n\n  /**\n   * Get calendar daily collection data\n   * @param tableId - The table ID\n   * @param query - Calendar collection query parameters\n   * @returns Promise<ICalendarDailyCollectionVo> - The calendar collection data\n   */\n  getCalendarDailyCollection(\n    tableId: string,\n    query: ICalendarDailyCollectionRo\n  ): Promise<ICalendarDailyCollectionVo>;\n}\n\n/**\n * Interface for view-related parameters used in aggregation operations\n */\nexport interface IWithView {\n  viewId?: string;\n  groupBy?: IGroup;\n  customFilter?: IFilter;\n  customFieldStats?: ICustomFieldStats[];\n}\n\n/**\n * Interface for custom field statistics configuration\n */\nexport interface ICustomFieldStats {\n  fieldId: string;\n  statisticFunc?: StatisticsFunc;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.service.provider.ts",
    "content": "import { Inject } from '@nestjs/common';\nimport { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';\n\n/**\n * Decorator for injecting the aggregation service\n * Use this decorator instead of directly injecting the AggregationService class\n *\n * @example\n * ```typescript\n * constructor(\n *   @InjectAggregationService() private readonly aggregationService: IAggregationService\n * ) {}\n * ```\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const InjectAggregationService = () => Inject(AGGREGATION_SERVICE_SYMBOL);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { AggregationModule } from './aggregation.module';\nimport { AggregationService } from './aggregation.service';\n\ndescribe('AggregateService', () => {\n  let service: AggregationService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, AggregationModule],\n    }).compile();\n\n    service = module.get<AggregationService>(AggregationService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.service.symbol.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/**\n * Injection token for the aggregation service\n * This symbol is used for dependency injection to avoid direct class references\n */\nexport const AGGREGATION_SERVICE_SYMBOL = Symbol('AGGREGATION_SERVICE');\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/aggregation.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';\nimport {\n  CellValueType,\n  HttpErrorCode,\n  extractFieldIdsFromFilter,\n  identify,\n  IdPrefix,\n  mergeWithDefaultFilter,\n  nullsToUndefined,\n  ViewType,\n} from '@teable/core';\nimport type { IGridColumnMeta, IFilter, IGroup } from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { StatisticsFunc } from '@teable/openapi';\nimport type {\n  IAggregationField,\n  IQueryBaseRo,\n  IRawAggregationValue,\n  IRawAggregations,\n  IRawRowCountValue,\n  IGroupPointsRo,\n  IGroupPoint,\n  ICalendarDailyCollectionRo,\n  ICalendarDailyCollectionVo,\n  ISearchIndexByQueryRo,\n  ISearchCountRo,\n  IGetRecordsRo,\n  IRecordIndexRo,\n  IRecordIndexVo,\n} from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport { Knex } from 'knex';\nimport { groupBy, isDate, isEmpty, isString, keyBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../types/cls';\nimport { convertValueToStringify, string2Hash } from '../../utils';\nimport { createFieldInstanceByRaw, type IFieldInstance } from '../field/model/factory';\nimport type { DateFieldDto } from '../field/model/field-dto/date-field.dto';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder';\nimport { RecordPermissionService } from '../record/record-permission.service';\nimport { RecordService } from '../record/record.service';\nimport { TableIndexService } from '../table/table-index.service';\nimport type {\n  IAggregationService,\n  ICustomFieldStats,\n  IWithView,\n} from './aggregation.service.interface';\n\ntype IStatisticsData = {\n  viewId?: string;\n  filter?: IFilter;\n  statisticFields?: IAggregationField[];\n};\n/**\n * Version 2 implementation of the aggregation service\n * This is a placeholder implementation that will be developed in the future\n * All methods currently throw NotImplementedException\n */\n@Injectable()\nexport class AggregationService implements IAggregationService {\n  private logger = new Logger(AggregationService.name);\n  constructor(\n    private readonly recordService: RecordService,\n    private readonly tableIndexService: TableIndexService,\n    private readonly prisma: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly recordPermissionService: RecordPermissionService,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder\n  ) {}\n  /**\n   * Perform aggregation operations on table data\n   * @param params - Parameters for aggregation including tableId, field IDs, view settings, and search\n   * @returns Promise<IRawAggregationValue> - The aggregation results\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n  async performAggregation(params: {\n    tableId: string;\n    withFieldIds?: string[];\n    withView?: IWithView;\n    search?: [string, string?, boolean?];\n    useQueryModel?: boolean;\n  }): Promise<IRawAggregationValue> {\n    const { tableId, withFieldIds, withView, search, useQueryModel } = params;\n    // Retrieve the current user's ID to build user-related query conditions\n    const currentUserId = this.cls.get('user.id');\n\n    const { statisticsData, fieldInstanceMap } = await this.fetchStatisticsParams({\n      tableId,\n      withView,\n      withFieldIds,\n    });\n\n    const dbTableName = await this.getDbTableName(this.prisma, tableId);\n\n    const { filter, statisticFields } = statisticsData;\n    const groupBy = withView?.groupBy;\n    const rawAggregationData = await this.handleAggregation({\n      dbTableName,\n      fieldInstanceMap,\n      tableId,\n      filter,\n      search,\n      statisticFields,\n      withUserId: currentUserId,\n      withView,\n      useQueryModel,\n    });\n\n    const aggregationResult = rawAggregationData && rawAggregationData[0];\n\n    const aggregations: IRawAggregations = [];\n    if (aggregationResult) {\n      for (const [key, value] of Object.entries(aggregationResult)) {\n        // Match by alias to ensure uniqueness across different functions of the same field\n        const statisticField = statisticFields?.find(\n          (item) => item.alias === key || item.fieldId === key\n        );\n        if (!statisticField) {\n          continue;\n        }\n        const { fieldId, statisticFunc: aggFunc } = statisticField;\n\n        const convertValue = this.formatConvertValue(value, aggFunc);\n\n        if (fieldId) {\n          aggregations.push({\n            fieldId,\n            total: aggFunc ? { value: convertValue, aggFunc: aggFunc } : null,\n          });\n        }\n      }\n    }\n\n    const aggregationsWithGroup = await this.performGroupedAggregation({\n      aggregations,\n      statisticFields,\n      tableId,\n      filter,\n      search,\n      groupBy,\n      dbTableName,\n      fieldInstanceMap,\n      withView,\n      useQueryModel,\n    });\n\n    return { aggregations: aggregationsWithGroup };\n  }\n\n  private formatConvertValue = (currentValue: unknown, aggFunc?: StatisticsFunc) => {\n    let convertValue = this.convertValueToNumberOrString(currentValue);\n\n    if (!aggFunc) {\n      return convertValue;\n    }\n\n    if (aggFunc === StatisticsFunc.DateRangeOfMonths && typeof currentValue === 'string') {\n      convertValue = this.calculateDateRangeOfMonths(currentValue);\n    }\n\n    const defaultToZero = [\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentUnique,\n      StatisticsFunc.PercentChecked,\n      StatisticsFunc.PercentUnChecked,\n    ];\n\n    if (defaultToZero.includes(aggFunc)) {\n      convertValue = convertValue ?? 0;\n    }\n    return convertValue;\n  };\n\n  private convertValueToNumberOrString(currentValue: unknown): number | string | null {\n    if (typeof currentValue === 'bigint' || typeof currentValue === 'number') {\n      return Number(currentValue);\n    }\n    if (isDate(currentValue)) {\n      return currentValue.toISOString();\n    }\n    return currentValue?.toString() ?? null;\n  }\n\n  private calculateDateRangeOfMonths(currentValue: string): number {\n    const [maxTime, minTime] = currentValue.split(',');\n    return maxTime && minTime ? dayjs(maxTime).diff(minTime, 'month') : 0;\n  }\n  private async handleAggregation(params: {\n    dbTableName: string;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n    tableId: string;\n    filter?: IFilter;\n    groupBy?: IGroup;\n    search?: [string, string?, boolean?];\n    statisticFields?: IAggregationField[];\n    withUserId?: string;\n    withView?: IWithView;\n    useQueryModel?: boolean;\n  }) {\n    const {\n      dbTableName,\n      fieldInstanceMap,\n      filter,\n      search,\n      statisticFields,\n      withUserId,\n      groupBy,\n      withView,\n      tableId,\n      useQueryModel,\n    } = params;\n\n    if (!statisticFields?.length) {\n      return;\n    }\n\n    const { viewId } = withView || {};\n\n    // Probe permission to get enabled field IDs for CTE projection\n    const permissionProbe = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      { viewId }\n    );\n    const allowedFieldIds = permissionProbe.enabledFieldIds;\n\n    const searchFields = await this.recordService.getSearchFields(\n      fieldInstanceMap,\n      search,\n      viewId,\n      allowedFieldIds\n    );\n\n    const projection = this.resolveAggregationProjection({\n      statisticFields,\n      groupBy,\n      filter,\n      searchFields,\n      allowedFieldIds,\n    });\n\n    // Build aggregate query using the permission-aware builder so the CTE is preserved\n    const { qb, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(\n      permissionProbe.viewCte ?? dbTableName,\n      {\n        tableId,\n        viewId,\n        filter,\n        aggregationFields: statisticFields,\n        groupBy,\n        currentUserId: withUserId,\n        // Limit link/lookup CTEs to enabled fields so denied fields resolve to NULL\n        projection,\n        useQueryModel,\n        builder: permissionProbe.builder,\n      }\n    );\n\n    if (search && search[2] && searchFields?.length) {\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      qb.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });\n      });\n    }\n\n    if (groupBy?.length) {\n      qb.limit(this.thresholdConfig.maxGroupPoints);\n    }\n\n    const aggSql = qb.toQuery();\n    this.logger.debug('handleAggregation aggSql: %s', aggSql);\n    return this.prisma.$queryRawUnsafe<{ [field: string]: unknown }[]>(aggSql);\n  }\n  /**\n   * Perform grouped aggregation operations\n   * @param params - Parameters for grouped aggregation\n   * @returns Promise<IRawAggregations> - The grouped aggregation results\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n\n  async performGroupedAggregation(params: {\n    aggregations: IRawAggregations;\n    statisticFields: IAggregationField[] | undefined;\n    tableId: string;\n    filter?: IFilter;\n    search?: [string, string?, boolean?];\n    groupBy?: IGroup;\n    dbTableName: string;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n    withView?: IWithView;\n    useQueryModel?: boolean;\n  }) {\n    const {\n      dbTableName,\n      aggregations,\n      statisticFields,\n      filter,\n      groupBy,\n      search,\n      fieldInstanceMap,\n      withView,\n      tableId,\n      useQueryModel,\n    } = params;\n\n    if (!groupBy || !statisticFields) return aggregations;\n\n    const currentUserId = this.cls.get('user.id');\n    const aggregationByFieldId = keyBy(aggregations, 'fieldId');\n\n    const groupByFields = groupBy.map(({ fieldId }) => {\n      return {\n        fieldId,\n        dbFieldName: fieldInstanceMap[fieldId].dbFieldName,\n      };\n    });\n\n    for (let i = 0; i < groupBy.length; i++) {\n      const rawGroupedAggregationData = (await this.handleAggregation({\n        dbTableName,\n        fieldInstanceMap,\n        tableId,\n        filter,\n        groupBy: groupBy.slice(0, i + 1),\n        search,\n        statisticFields,\n        withUserId: currentUserId,\n        withView,\n        useQueryModel,\n      }))!;\n\n      const currentGroupFieldId = groupByFields[i].fieldId;\n\n      for (const groupedAggregation of rawGroupedAggregationData) {\n        const groupByValueString = groupByFields\n          .slice(0, i + 1)\n          .map(({ dbFieldName }) => {\n            const groupByValue = groupedAggregation[dbFieldName];\n            return convertValueToStringify(groupByValue);\n          })\n          .join('_');\n        const flagString = `${currentGroupFieldId}_${groupByValueString}`;\n        const groupId = String(string2Hash(flagString));\n\n        for (const statisticField of statisticFields) {\n          const { fieldId, statisticFunc, alias } = statisticField;\n          // Use unique alias to read the correct aggregated column\n          const aggKey = alias ?? `${fieldId}_${statisticFunc}`;\n          const curFieldAggregation = aggregationByFieldId[fieldId]!;\n          const convertValue = this.formatConvertValue(groupedAggregation[aggKey], statisticFunc);\n\n          if (!curFieldAggregation.group) {\n            aggregationByFieldId[fieldId].group = {\n              [groupId]: { value: convertValue, aggFunc: statisticFunc },\n            };\n          } else {\n            aggregationByFieldId[fieldId]!.group![groupId] = {\n              value: convertValue,\n              aggFunc: statisticFunc,\n            };\n          }\n        }\n      }\n    }\n\n    return Object.values(aggregationByFieldId);\n  }\n\n  /**\n   * Determine required projection for aggregation query.\n   */\n  private resolveAggregationProjection(params: {\n    statisticFields?: IAggregationField[];\n    groupBy?: IGroup;\n    filter?: IFilter;\n    searchFields?: IFieldInstance[];\n    allowedFieldIds?: string[];\n  }): string[] | undefined {\n    const { statisticFields, groupBy, filter, searchFields, allowedFieldIds } = params;\n\n    const projectionSet = new Set<string>();\n\n    statisticFields?.forEach(({ fieldId }) => {\n      if (fieldId && fieldId !== '*') {\n        projectionSet.add(fieldId);\n      }\n    });\n\n    groupBy?.forEach(({ fieldId }) => {\n      if (fieldId) {\n        projectionSet.add(fieldId);\n      }\n    });\n\n    if (filter) {\n      for (const fieldId of extractFieldIdsFromFilter(filter)) {\n        projectionSet.add(fieldId);\n      }\n    }\n\n    searchFields?.forEach((fieldInstance) => {\n      projectionSet.add(fieldInstance.id);\n    });\n\n    if (projectionSet.size === 0) {\n      return allowedFieldIds && allowedFieldIds.length\n        ? Array.from(new Set(allowedFieldIds))\n        : undefined;\n    }\n\n    const projectionArray = Array.from(projectionSet);\n\n    if (!allowedFieldIds || allowedFieldIds.length === 0) {\n      return projectionArray;\n    }\n\n    const allowedSet = new Set(allowedFieldIds);\n    const filtered = projectionArray.filter((fieldId) => allowedSet.has(fieldId));\n\n    return filtered.length > 0 ? filtered : Array.from(allowedSet);\n  }\n\n  /**\n   * Get row count for a table with optional filtering\n   * @param tableId - The table ID\n   * @param queryRo - Query parameters for filtering\n   * @returns Promise<IRawRowCountValue> - The row count result\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n  async performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise<IRawRowCountValue> {\n    const {\n      viewId,\n      ignoreViewQuery,\n      filterLinkCellCandidate,\n      filterLinkCellSelected,\n      selectedRecordIds,\n      search,\n    } = queryRo;\n    // Retrieve the current user's ID to build user-related query conditions\n    const currentUserId = this.cls.get('user.id');\n\n    const { statisticsData, fieldInstanceMap } = await this.fetchStatisticsParams({\n      tableId,\n      withView: {\n        viewId: ignoreViewQuery ? undefined : viewId,\n        customFilter: queryRo.filter,\n      },\n    });\n\n    const dbTableName = await this.getDbTableName(this.prisma, tableId);\n\n    const { filter } = statisticsData;\n\n    const rawRowCountData = await this.handleRowCount({\n      tableId,\n      dbTableName,\n      fieldInstanceMap,\n      filter,\n      filterLinkCellCandidate,\n      filterLinkCellSelected,\n      selectedRecordIds,\n      search,\n      withUserId: currentUserId,\n      viewId: queryRo?.viewId,\n    });\n\n    return {\n      rowCount: Number(rawRowCountData?.[0]?.count ?? 0),\n    };\n  }\n\n  private async getDbTableName(prisma: Prisma.TransactionClient, tableId: string) {\n    const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return tableMeta.dbTableName;\n  }\n  private async handleRowCount(params: {\n    tableId: string;\n    dbTableName: string;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n    filter?: IFilter;\n    filterLinkCellCandidate?: IGetRecordsRo['filterLinkCellCandidate'];\n    filterLinkCellSelected?: IGetRecordsRo['filterLinkCellSelected'];\n    selectedRecordIds?: IGetRecordsRo['selectedRecordIds'];\n    search?: [string, string?, boolean?];\n    withUserId?: string;\n    viewId?: string;\n  }) {\n    const {\n      tableId,\n      dbTableName,\n      fieldInstanceMap,\n      filter,\n      filterLinkCellCandidate,\n      filterLinkCellSelected,\n      selectedRecordIds,\n      search,\n      withUserId,\n      viewId,\n    } = params;\n\n    const restrictRecordIds =\n      selectedRecordIds && !filterLinkCellCandidate ? selectedRecordIds : undefined;\n\n    const wrap = await this.recordPermissionService.wrapView(tableId, this.knex.queryBuilder(), {\n      viewId,\n      keepPrimaryKey: Boolean(filterLinkCellSelected),\n    });\n\n    const { qb, alias, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(\n      wrap.viewCte ?? dbTableName,\n      {\n        tableId,\n        viewId,\n        currentUserId: withUserId,\n        filter,\n        aggregationFields: [\n          {\n            fieldId: '*',\n            statisticFunc: StatisticsFunc.Count,\n            alias: 'count',\n          },\n        ],\n        restrictRecordIds,\n        useQueryModel: true,\n        builder: wrap.builder,\n      }\n    );\n\n    if (search && search[2]) {\n      const searchFields = await this.recordService.getSearchFields(\n        fieldInstanceMap,\n        search,\n        viewId\n      );\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      qb.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });\n      });\n    }\n\n    if (selectedRecordIds) {\n      filterLinkCellCandidate\n        ? qb.whereNotIn(`${alias}.__id`, selectedRecordIds)\n        : qb.whereIn(`${alias}.__id`, selectedRecordIds);\n    }\n\n    if (filterLinkCellCandidate) {\n      await this.recordService.buildLinkCandidateQuery(qb, tableId, filterLinkCellCandidate);\n    }\n\n    if (filterLinkCellSelected) {\n      await this.recordService.buildLinkSelectedQuery(\n        qb,\n        tableId,\n        dbTableName,\n        alias,\n        filterLinkCellSelected\n      );\n    }\n\n    const rawQuery = qb.toQuery();\n\n    this.logger.debug('handleRowCount raw query: %s', rawQuery);\n    return await this.prisma.$queryRawUnsafe<{ count: number }[]>(rawQuery);\n  }\n\n  private async fetchStatisticsParams(params: {\n    tableId: string;\n    withView?: IWithView;\n    withFieldIds?: string[];\n  }): Promise<{\n    statisticsData: IStatisticsData;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n  }> {\n    const { tableId, withView, withFieldIds } = params;\n\n    const viewRaw = await this.findView(tableId, withView);\n\n    const { fieldInstances, fieldInstanceMap } = await this.getFieldsData(tableId);\n    const filteredFieldInstances = this.filterFieldInstances(\n      fieldInstances,\n      withView,\n      withFieldIds\n    );\n\n    const statisticsData = this.buildStatisticsData(filteredFieldInstances, viewRaw, withView);\n\n    return { statisticsData, fieldInstanceMap };\n  }\n\n  private async findView(tableId: string, withView?: IWithView) {\n    if (!withView?.viewId) {\n      return undefined;\n    }\n\n    return nullsToUndefined(\n      await this.prisma.view.findFirst({\n        select: {\n          id: true,\n          type: true,\n          filter: true,\n          group: true,\n          options: true,\n          columnMeta: true,\n        },\n        where: {\n          tableId,\n          ...(withView?.viewId ? { id: withView.viewId } : {}),\n          type: {\n            in: [ViewType.Grid, ViewType.Kanban, ViewType.Gallery, ViewType.Calendar],\n          },\n          deletedTime: null,\n        },\n      })\n    );\n  }\n\n  private filterFieldInstances(\n    fieldInstances: IFieldInstance[],\n    withView?: IWithView,\n    withFieldIds?: string[]\n  ) {\n    const targetFieldIds =\n      withView?.customFieldStats?.map((field) => field.fieldId) ?? withFieldIds;\n\n    return targetFieldIds?.length\n      ? fieldInstances.filter((instance) => targetFieldIds.includes(instance.id))\n      : fieldInstances;\n  }\n\n  private buildStatisticsData(\n    filteredFieldInstances: IFieldInstance[],\n    viewRaw:\n      | {\n          id: string | undefined;\n          columnMeta: string | undefined;\n          filter: string | undefined;\n          group: string | undefined;\n        }\n      | undefined,\n    withView?: IWithView\n  ) {\n    let statisticsData: IStatisticsData = {\n      viewId: viewRaw?.id,\n    };\n\n    if (viewRaw?.filter || withView?.customFilter) {\n      const filter = mergeWithDefaultFilter(viewRaw?.filter, withView?.customFilter);\n      statisticsData = { ...statisticsData, filter };\n    }\n\n    if (viewRaw?.id || withView?.customFieldStats) {\n      const statisticFields = this.getStatisticFields(\n        filteredFieldInstances,\n        viewRaw?.columnMeta && JSON.parse(viewRaw.columnMeta),\n        withView?.customFieldStats\n      );\n      statisticsData = { ...statisticsData, statisticFields };\n    }\n    return statisticsData;\n  }\n\n  private getStatisticFields(\n    fieldInstances: IFieldInstance[],\n    columnMeta?: IGridColumnMeta,\n    customFieldStats?: ICustomFieldStats[]\n  ) {\n    let calculatedStatisticFields: IAggregationField[] | undefined;\n    const customFieldStatsGrouped = groupBy(customFieldStats, 'fieldId');\n\n    fieldInstances.forEach((fieldInstance) => {\n      const { id: fieldId } = fieldInstance;\n      const viewColumnMeta = columnMeta ? columnMeta[fieldId] : undefined;\n      const customFieldStats = customFieldStatsGrouped[fieldId];\n\n      if (viewColumnMeta || customFieldStats) {\n        const { hidden, statisticFunc } = viewColumnMeta || {};\n        const statisticFuncList = customFieldStats\n          ?.filter((item) => item.statisticFunc)\n          ?.map((item) => item.statisticFunc) as StatisticsFunc[];\n\n        const funcList = !isEmpty(statisticFuncList)\n          ? statisticFuncList\n          : statisticFunc && [statisticFunc];\n\n        if (hidden !== true && funcList && funcList.length) {\n          const statisticFieldList = funcList.map((item) => {\n            return {\n              fieldId,\n              statisticFunc: item,\n              // Ensure unique alias per function to avoid collisions in result set\n              alias: `${fieldId}_${item}`,\n            };\n          });\n          (calculatedStatisticFields = calculatedStatisticFields ?? []).push(...statisticFieldList);\n        }\n      }\n    });\n    return calculatedStatisticFields;\n  }\n  /**\n   * Get field data for a table\n   * @param tableId - The table ID\n   * @param fieldIds - Optional array of field IDs to filter\n   * @param withName - Whether to include field names in the mapping\n   * @returns Promise with field instances and field instance map\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n\n  async getFieldsData(tableId: string, fieldIds?: string[], withName?: boolean) {\n    const fieldsRaw = await this.prisma.field.findMany({\n      where: { tableId, ...(fieldIds ? { id: { in: fieldIds } } : {}), deletedTime: null },\n    });\n\n    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));\n    const fieldInstanceMap = fieldInstances.reduce(\n      (map, field) => {\n        map[field.id] = field;\n        if (withName || withName === undefined) {\n          map[field.name] = field;\n        }\n        return map;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n    return { fieldInstances, fieldInstanceMap };\n  } /**\n   * Get group points for a table\n   * @param tableId - The table ID\n   * @param query - Optional query parameters\n   * @returns Promise with group points data\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n  async getGroupPoints(\n    tableId: string,\n    query?: IGroupPointsRo,\n    useQueryModel = false\n  ): Promise<IGroupPoint[]> {\n    const { groupPoints } = await this.recordService.getGroupRelatedData(\n      tableId,\n      query,\n      useQueryModel\n    );\n    return groupPoints;\n  }\n\n  /**\n   * Get search count for a table\n   * @param tableId - The table ID\n   * @param queryRo - Search query parameters\n   * @param projection - Optional field projection\n   * @returns Promise with search count result\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n\n  public async getSearchCount(tableId: string, queryRo: ISearchCountRo, projection?: string[]) {\n    const { search, viewId, ignoreViewQuery } = queryRo;\n    const dbFieldName = await this.getDbTableName(this.prisma, tableId);\n    const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);\n\n    if (!search) {\n      throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.aggregation.searchQueryRequired',\n        },\n      });\n    }\n\n    const searchFields = await this.recordService.getSearchFields(\n      fieldInstanceMap,\n      search,\n      ignoreViewQuery ? undefined : viewId,\n      projection\n    );\n\n    if (searchFields?.length === 0) {\n      return { count: 0 };\n    }\n    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n    const queryBuilder = this.knex(dbFieldName);\n\n    const selectionMap = new Map(\n      Object.values(fieldInstanceMap).map((f) => [f.id, `\"${f.dbFieldName}\"`])\n    );\n    this.dbProvider.searchCountQuery(queryBuilder, searchFields, search, tableIndex, {\n      selectionMap,\n    });\n    this.dbProvider\n      .filterQuery(\n        queryBuilder,\n        fieldInstanceMap,\n        queryRo?.filter,\n        {\n          withUserId: this.cls.get('user.id'),\n        },\n        { selectionMap }\n      )\n      .appendQueryBuilder();\n\n    const sql = queryBuilder.toQuery();\n\n    const result = await this.prisma.$queryRawUnsafe<{ count: number }[] | null>(sql);\n\n    return {\n      count: result ? Number(result[0]?.count) : 0,\n    };\n  }\n\n  public async getRecordIndexBySearchOrder(\n    tableId: string,\n    queryRo: ISearchIndexByQueryRo,\n    projection?: string[]\n  ) {\n    const {\n      search,\n      take,\n      skip,\n      orderBy,\n      filter,\n      groupBy,\n      viewId,\n      ignoreViewQuery,\n      projection: queryProjection,\n    } = queryRo;\n    const dbTableName = await this.getDbTableName(this.prisma, tableId);\n    const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);\n\n    if (take > 1000) {\n      throw new CustomHttpException(\n        'The maximum search index result is 1000',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.maxSearchIndexResult',\n          },\n        }\n      );\n    }\n\n    if (!search) {\n      throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.aggregation.searchQueryRequired',\n        },\n      });\n    }\n\n    const finalProjection = queryProjection\n      ? projection\n        ? projection.filter((fieldId) => queryProjection.includes(fieldId))\n        : queryProjection\n      : projection;\n\n    const searchFields = await this.recordService.getSearchFields(\n      fieldInstanceMap,\n      search,\n      ignoreViewQuery ? undefined : viewId,\n      finalProjection\n    );\n\n    if (searchFields.length === 0) {\n      return null;\n    }\n\n    const selectionMap = new Map(\n      Object.values(fieldInstanceMap).map((f) => [f.id, `\"${f.dbFieldName}\"`])\n    );\n\n    const basicSortIndex = await this.recordService.getBasicOrderIndexField(dbTableName, viewId);\n\n    const filterQuery = (qb: Knex.QueryBuilder) => {\n      this.dbProvider\n        .filterQuery(\n          qb,\n          fieldInstanceMap,\n          filter,\n          {\n            withUserId: this.cls.get('user.id'),\n          },\n          { selectionMap }\n        )\n        .appendQueryBuilder();\n    };\n\n    const sortQuery = (qb: Knex.QueryBuilder) => {\n      this.dbProvider\n        .sortQuery(qb, fieldInstanceMap, [...(groupBy ?? []), ...(orderBy ?? [])], undefined, {\n          selectionMap,\n        })\n        .appendSortBuilder();\n    };\n\n    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n\n    const { viewCte, builder } = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      {\n        viewId,\n        keepPrimaryKey: Boolean(queryRo.filterLinkCellSelected),\n      }\n    );\n\n    const queryBuilder = this.dbProvider.searchIndexQuery(\n      builder,\n      viewCte || dbTableName,\n      searchFields,\n      queryRo,\n      tableIndex,\n      { selectionMap },\n      basicSortIndex,\n      filterQuery,\n      sortQuery\n    );\n\n    const sql = queryBuilder.toQuery();\n\n    this.logger.debug('getRecordIndexBySearchOrder sql: %s', sql);\n\n    try {\n      return await this.prisma.$tx(async (prisma) => {\n        const result = await prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql);\n\n        // no result found\n        if (result?.length === 0) {\n          return null;\n        }\n\n        const recordIds = result;\n\n        if (search[2]) {\n          const baseSkip = skip ?? 0;\n          const accRecord: string[] = [];\n          return recordIds.map((rec) => {\n            if (!accRecord?.includes(rec.__id)) {\n              accRecord.push(rec.__id);\n            }\n            return {\n              index: baseSkip + accRecord?.length,\n              fieldId: rec.fieldId,\n              recordId: rec.__id,\n            };\n          });\n        }\n\n        const { queryBuilder: viewRecordsQB, alias } =\n          await this.recordService.buildFilterSortQuery(tableId, queryRo, true);\n        // step 2. find the index in current view\n        const indexQueryBuilder = this.knex\n          .with('t', viewRecordsQB.from({ [alias]: viewCte || dbTableName }))\n          .with('t1', (db) => {\n            db.select('__id').select(this.knex.raw('ROW_NUMBER() OVER () as row_num')).from('t');\n          })\n          .select('t1.row_num')\n          .select('t1.__id')\n          .from('t1')\n          .whereIn('t1.__id', [...new Set(recordIds.map((record) => record.__id))]);\n\n        const indexSql = indexQueryBuilder.toQuery();\n        this.logger.debug('getRecordIndexBySearchOrder indexSql: %s', indexSql);\n        const indexResult =\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          await this.prisma.$queryRawUnsafe<{ row_num: number; __id: string }[]>(indexSql);\n\n        if (indexResult?.length === 0) {\n          return null;\n        }\n\n        const indexResultMap = keyBy(indexResult, '__id');\n\n        return result.map((item) => {\n          const index = Number(indexResultMap[item.__id]?.row_num);\n          if (isNaN(index)) {\n            throw new CustomHttpException('Index not found', HttpErrorCode.NOT_FOUND, {\n              localization: {\n                i18nKey: 'httpErrors.aggregation.indexNotFound',\n              },\n            });\n          }\n          return {\n            index,\n            fieldId: item.fieldId,\n            recordId: item.__id,\n          };\n        });\n      });\n    } catch (error) {\n      if (error instanceof PrismaClientKnownRequestError && error.code === 'P2028') {\n        throw new CustomHttpException(`${error.message}`, HttpErrorCode.REQUEST_TIMEOUT, {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.searchTimeOut',\n          },\n        });\n      }\n      throw error;\n    }\n  }\n  async getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise<IRecordIndexVo> {\n    const { recordId } = queryRo;\n\n    const { queryBuilder: viewRecordsQB, alias } = await this.recordService.buildFilterSortQuery(\n      tableId,\n      { ...queryRo, skip: undefined, take: undefined },\n      true\n    );\n\n    const dbTableName = await this.getDbTableName(this.prisma, tableId);\n\n    const { viewCte } = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      { viewId: queryRo.viewId }\n    );\n\n    const indexQueryBuilder = this.knex\n      .with('t', viewRecordsQB.from({ [alias]: viewCte || dbTableName }))\n      .with('t1', (db) => {\n        db.select('__id').select(this.knex.raw('ROW_NUMBER() OVER () as row_num')).from('t');\n      })\n      .select('t1.row_num')\n      .from('t1')\n      .where('t1.__id', recordId);\n\n    const sql = indexQueryBuilder.toQuery();\n    this.logger.debug('getRecordIndex sql: %s', sql);\n\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    const result = await this.prisma.$queryRawUnsafe<{ row_num: number }[]>(sql);\n\n    if (!result?.length) {\n      return null;\n    }\n\n    return { index: Number(result[0].row_num) - 1 };\n  }\n\n  /**\n   * Get calendar daily collection data\n   * @param tableId - The table ID\n   * @param query - Calendar collection query parameters\n   * @returns Promise<ICalendarDailyCollectionVo> - The calendar collection data\n   * @throws NotImplementedException - This method is not yet implemented\n   */\n\n  public async getCalendarDailyCollection(\n    tableId: string,\n    query: ICalendarDailyCollectionRo\n  ): Promise<ICalendarDailyCollectionVo> {\n    const {\n      startDate,\n      endDate,\n      startDateFieldId,\n      endDateFieldId,\n      filter,\n      search,\n      ignoreViewQuery,\n    } = query;\n\n    if (identify(tableId) !== IdPrefix.Table) {\n      throw new CustomHttpException(\n        'query collection must be table id',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.queryCollectionMustBeTableId',\n          },\n        }\n      );\n    }\n\n    const fields = await this.recordService.getFieldsByProjection(tableId);\n    const fieldMap = fields.reduce(\n      (map, field) => {\n        map[field.id] = field;\n        return map;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n\n    const startField = fieldMap[startDateFieldId];\n    if (\n      !startField ||\n      startField.cellValueType !== CellValueType.DateTime ||\n      startField.isMultipleCellValue\n    ) {\n      throw new CustomHttpException('Invalid start date field id', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.aggregation.invalidStartDateFieldId',\n        },\n      });\n    }\n\n    const endField = endDateFieldId ? fieldMap[endDateFieldId] : startField;\n\n    if (\n      !endField ||\n      endField.cellValueType !== CellValueType.DateTime ||\n      endField.isMultipleCellValue\n    ) {\n      throw new CustomHttpException('Invalid end date field id', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.aggregation.invalidEndDateFieldId',\n        },\n      });\n    }\n\n    const viewId = ignoreViewQuery ? undefined : query.viewId;\n    const dbTableName = await this.getDbTableName(this.prisma, tableId);\n    const { viewCte, builder: queryBuilder } = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      {\n        viewId,\n      }\n    );\n    queryBuilder.from(viewCte || dbTableName);\n    const viewRaw = await this.findView(tableId, { viewId });\n    const filterStr = viewRaw?.filter;\n    const mergedFilter = mergeWithDefaultFilter(filterStr, filter);\n    const currentUserId = this.cls.get('user.id');\n    const selectionMap = new Map(Object.values(fieldMap).map((f) => [f.id, `\"${f.dbFieldName}\"`]));\n\n    if (mergedFilter) {\n      this.dbProvider\n        .filterQuery(\n          queryBuilder,\n          fieldMap,\n          mergedFilter,\n          { withUserId: currentUserId },\n          { selectionMap }\n        )\n        .appendQueryBuilder();\n    }\n\n    if (search) {\n      const searchFields = await this.recordService.getSearchFields(\n        fieldMap,\n        search,\n        query?.viewId\n      );\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      queryBuilder.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search);\n      });\n    }\n    this.dbProvider.calendarDailyCollectionQuery(queryBuilder, {\n      startDate,\n      endDate,\n      startField: startField as DateFieldDto,\n      endField: endField as DateFieldDto,\n      dbTableName: viewCte || dbTableName,\n    });\n    const result = await this.prisma\n      .txClient()\n      .$queryRawUnsafe<\n        { date: Date | string; count: number; ids: string[] | string }[]\n      >(queryBuilder.toQuery());\n\n    const countMap = result.reduce(\n      (map, item) => {\n        const key = isString(item.date) ? item.date : item.date.toISOString().split('T')[0];\n        map[key] = Number(item.count);\n        return map;\n      },\n      {} as Record<string, number>\n    );\n    let recordIds = result\n      .map((item) => (isString(item.ids) ? item.ids.split(',') : item.ids))\n      .flat();\n    recordIds = Array.from(new Set(recordIds));\n\n    if (!recordIds.length) {\n      return {\n        countMap,\n        records: [],\n      };\n    }\n\n    const { records } = await this.recordService.getRecordsById(tableId, recordIds);\n\n    return {\n      countMap,\n      records,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/index.ts",
    "content": "export type {\n  IAggregationService,\n  IWithView,\n  ICustomFieldStats,\n} from './aggregation.service.interface';\nexport { AggregationService } from './aggregation.service';\nexport { AggregationModule } from './aggregation.module';\nexport { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';\nexport { InjectAggregationService } from './aggregation.service.provider';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { vi } from 'vitest';\nimport { AggregationService } from '../aggregation.service';\nimport { AGGREGATION_SERVICE_SYMBOL } from '../aggregation.service.symbol';\nimport { AggregationOpenApiController } from './aggregation-open-api.controller';\nimport { AggregationOpenApiService } from './aggregation-open-api.service';\n\ndescribe('AggregationOpenApiController', () => {\n  let controller: AggregationOpenApiController;\n\n  beforeAll(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [AggregationOpenApiController],\n      providers: [\n        AggregationOpenApiService,\n        AggregationService,\n        {\n          provide: AGGREGATION_SERVICE_SYMBOL,\n          useClass: AggregationService,\n        },\n      ],\n    })\n      .useMocker((token) => {\n        if (token === PrismaService) {\n          return vi.fn();\n        }\n      })\n      .compile();\n\n    controller = module.get<AggregationOpenApiController>(AggregationOpenApiController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Controller, Get, Param, Query } from '@nestjs/common';\nimport type { IFilter } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IAggregationVo,\n  ICalendarDailyCollectionVo,\n  IGroupPointsVo,\n  IRowCountVo,\n  ISearchCountVo,\n  ISearchIndexVo,\n  ITaskStatusCollectionVo,\n  IRecordIndexVo,\n} from '@teable/openapi';\nimport {\n  aggregationRoSchema,\n  calendarDailyCollectionRoSchema,\n  groupPointsRoSchema,\n  IAggregationRo,\n  IGroupPointsRo,\n  IQueryBaseRo,\n  searchCountRoSchema,\n  ISearchCountRo,\n  queryBaseSchema,\n  ICalendarDailyCollectionRo,\n  ISearchIndexByQueryRo,\n  searchIndexByQueryRoSchema,\n  IRecordIndexRo,\n  recordIndexRoSchema,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { PerformanceCacheService } from '../../../performance-cache';\nimport { generateAggCacheKey } from '../../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../../types/cls';\nimport { filterHasMe } from '../../../utils/filter-has-me';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { TqlPipe } from '../../record/open-api/tql.pipe';\nimport { AggregationOpenApiService } from './aggregation-open-api.service';\n\n@Controller('api/table/:tableId/aggregation')\n@AllowAnonymous()\nexport class AggregationOpenApiController {\n  constructor(\n    private readonly aggregationOpenApiService: AggregationOpenApiService,\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  private async getAggregationWithCache<T>(\n    cacheKeyPrefix: string,\n    tableId: string,\n    query: { filter?: IFilter; viewId?: string } | undefined,\n    fn: () => Promise<T>\n  ) {\n    const table = await this.prismaService.tableMeta.findUniqueOrThrow({\n      where: {\n        id: tableId,\n      },\n      select: {\n        lastModifiedTime: true,\n      },\n    });\n    const viewId = query?.viewId;\n    let viewFilter: string | null = null;\n    if (viewId) {\n      const view = await this.prismaService.view.findUniqueOrThrow({\n        where: {\n          id: viewId,\n        },\n        select: {\n          filter: true,\n        },\n      });\n      viewFilter = view.filter;\n    }\n    const cacheQuery =\n      filterHasMe(query?.filter) || filterHasMe(viewFilter)\n        ? { ...query, currentUserId: this.cls.get('user.id') }\n        : query;\n\n    const cacheKey = generateAggCacheKey(\n      cacheKeyPrefix,\n      tableId,\n      table.lastModifiedTime?.getTime().toString() ?? '0',\n      cacheQuery\n    );\n    return this.performanceCacheService.wrap(\n      cacheKey,\n      () => {\n        return fn();\n      },\n      {\n        ttl: 60 * 60, // 1 hour\n      }\n    );\n  }\n\n  @Get()\n  @Permissions('table|read')\n  async getAggregation(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(aggregationRoSchema), TqlPipe) query?: IAggregationRo\n  ): Promise<IAggregationVo> {\n    return await this.getAggregationWithCache('aggregation', tableId, query, () =>\n      this.aggregationOpenApiService.getAggregation(tableId, query)\n    );\n  }\n\n  @Get('/row-count')\n  @Permissions('table|read')\n  async getRowCount(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(queryBaseSchema), TqlPipe) query?: IQueryBaseRo\n  ): Promise<IRowCountVo> {\n    return await this.getAggregationWithCache('row_count', tableId, query, () =>\n      this.aggregationOpenApiService.getRowCount(tableId, query)\n    );\n  }\n\n  @Get('/record-index')\n  @Permissions('table|read')\n  async getRecordIndex(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(recordIndexRoSchema), TqlPipe) query: IRecordIndexRo\n  ): Promise<IRecordIndexVo> {\n    return await this.getAggregationWithCache('record_index', tableId, query, () =>\n      this.aggregationOpenApiService.getRecordIndex(tableId, query)\n    );\n  }\n\n  @Get('/search-count')\n  @Permissions('table|read')\n  async getSearchCount(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(searchCountRoSchema), TqlPipe) query: ISearchCountRo\n  ): Promise<ISearchCountVo> {\n    return await this.getAggregationWithCache('search_count', tableId, query, () =>\n      this.aggregationOpenApiService.getSearchCount(tableId, query)\n    );\n  }\n\n  @Get('/search-index')\n  @Permissions('table|read')\n  async getSearchIndex(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(searchIndexByQueryRoSchema), TqlPipe) query: ISearchIndexByQueryRo\n  ): Promise<ISearchIndexVo> {\n    return await this.getAggregationWithCache('search_index', tableId, query, () =>\n      this.aggregationOpenApiService.getRecordIndexBySearchOrder(tableId, query)\n    );\n  }\n\n  @Get('/group-points')\n  @Permissions('table|read')\n  async getGroupPoints(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(groupPointsRoSchema), TqlPipe) query?: IGroupPointsRo\n  ): Promise<IGroupPointsVo> {\n    return await this.getAggregationWithCache('group_points', tableId, query, () =>\n      this.aggregationOpenApiService.getGroupPoints(tableId, query, true)\n    );\n  }\n\n  @Get('/calendar-daily-collection')\n  @Permissions('table|read')\n  async getCalendarDailyCollection(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(calendarDailyCollectionRoSchema), TqlPipe)\n    query: ICalendarDailyCollectionRo\n  ): Promise<ICalendarDailyCollectionVo> {\n    return await this.getAggregationWithCache('calendar_daily_collection', tableId, query, () =>\n      this.aggregationOpenApiService.getCalendarDailyCollection(tableId, query)\n    );\n  }\n\n  @Get('/task-status-collection')\n  @Permissions('table|read')\n  async getTaskStatusCollection(\n    @Param('tableId') _tableId: string\n  ): Promise<ITaskStatusCollectionVo> {\n    return {\n      fieldMap: {},\n      cells: [],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AggregationModule } from '../aggregation.module';\nimport { AggregationOpenApiController } from './aggregation-open-api.controller';\nimport { AggregationOpenApiService } from './aggregation-open-api.service';\n\n@Module({\n  controllers: [AggregationOpenApiController],\n  imports: [AggregationModule],\n  providers: [AggregationOpenApiService],\n  exports: [AggregationOpenApiService],\n})\nexport class AggregationOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { AggregationOpenApiModule } from './aggregation-open-api.module';\nimport { AggregationOpenApiService } from './aggregation-open-api.service';\n\ndescribe('AggregationOpenApiService', () => {\n  let service: AggregationOpenApiService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, AggregationOpenApiModule],\n    }).compile();\n\n    service = module.get<AggregationOpenApiService>(AggregationOpenApiService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts",
    "content": "import { BadRequestException, Injectable } from '@nestjs/common';\nimport type { StatisticsFunc } from '@teable/core';\nimport { getValidStatisticFunc } from '@teable/core';\nimport type {\n  ISearchIndexByQueryRo,\n  IAggregationRo,\n  IAggregationVo,\n  ICalendarDailyCollectionRo,\n  ICalendarDailyCollectionVo,\n  IGroupPointsRo,\n  IGroupPointsVo,\n  IQueryBaseRo,\n  IRowCountVo,\n  ISearchCountRo,\n  IRecordIndexRo,\n  IRecordIndexVo,\n} from '@teable/openapi';\nimport { forIn, isEmpty, map } from 'lodash';\nimport { IAggregationService } from '../aggregation.service.interface';\nimport type { IWithView } from '../aggregation.service.interface';\nimport { InjectAggregationService } from '../aggregation.service.provider';\n\n@Injectable()\nexport class AggregationOpenApiService {\n  constructor(\n    @InjectAggregationService() private readonly aggregationService: IAggregationService\n  ) {}\n\n  async getAggregation(tableId: string, query?: IAggregationRo): Promise<IAggregationVo> {\n    const {\n      viewId,\n      filter: customFilter,\n      field: aggregationFields,\n      groupBy,\n      ignoreViewQuery,\n    } = query || {};\n\n    let withView: IWithView = {\n      viewId: ignoreViewQuery ? undefined : viewId,\n      customFilter,\n      groupBy,\n    };\n\n    const fieldStatistics: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];\n\n    forIn(aggregationFields, (value: string[], key) => {\n      const fieldStats = map(value, (item) => ({\n        fieldId: item,\n        statisticFunc: key as StatisticsFunc,\n      }));\n\n      fieldStatistics.push(...fieldStats);\n    });\n\n    const validFieldStats = await this.validFieldStats(tableId, fieldStatistics);\n    if (validFieldStats) {\n      withView = { ...withView, customFieldStats: validFieldStats };\n    }\n\n    const result = await this.aggregationService.performAggregation({\n      tableId: tableId,\n      withView,\n      search: query?.search,\n      useQueryModel: true,\n    });\n    return { aggregations: result?.aggregations };\n  }\n\n  async getRowCount(tableId: string, query: IQueryBaseRo = {}): Promise<IRowCountVo> {\n    const result = await this.aggregationService.performRowCount(tableId, query);\n    return {\n      rowCount: result.rowCount,\n    };\n  }\n\n  async getGroupPoints(\n    tableId: string,\n    query?: IGroupPointsRo,\n    useQueryModel = true\n  ): Promise<IGroupPointsVo> {\n    return await this.aggregationService.getGroupPoints(tableId, query, useQueryModel);\n  }\n\n  async getCalendarDailyCollection(\n    tableId: string,\n    query: ICalendarDailyCollectionRo\n  ): Promise<ICalendarDailyCollectionVo> {\n    return await this.aggregationService.getCalendarDailyCollection(tableId, query);\n  }\n\n  async getRecordIndex(tableId: string, query: IRecordIndexRo): Promise<IRecordIndexVo> {\n    return await this.aggregationService.getRecordIndex(tableId, query);\n  }\n\n  private async validFieldStats(\n    tableId: string,\n    fieldStatistics: Array<{ fieldId: string; statisticFunc: StatisticsFunc }>\n  ) {\n    if (isEmpty(fieldStatistics)) {\n      return;\n    }\n    let result: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> | undefined;\n\n    const fieldIds = fieldStatistics.map((item) => item.fieldId);\n    const { fieldInstanceMap } = await this.aggregationService.getFieldsData(tableId, fieldIds);\n\n    fieldStatistics.forEach(({ fieldId, statisticFunc }) => {\n      const fieldInstance = fieldInstanceMap[fieldId];\n      if (!fieldInstance) {\n        throw new BadRequestException(`field: '${fieldId}' is invalid`);\n      }\n\n      const validStatisticFunc = getValidStatisticFunc(fieldInstance);\n      if (!validStatisticFunc.includes(statisticFunc)) {\n        throw new BadRequestException(\n          `field: '${fieldId}', aggregation func: '${statisticFunc}' is invalid, Only the following func are allowed: [${validStatisticFunc}]`\n        );\n      }\n\n      (result = result ?? []).push({ fieldId, statisticFunc });\n    });\n    return result;\n  }\n\n  public async getSearchCount(tableId: string, queryRo: ISearchCountRo, projection?: string[]) {\n    return await this.aggregationService.getSearchCount(tableId, queryRo, projection);\n  }\n\n  public async getRecordIndexBySearchOrder(\n    tableId: string,\n    queryRo: ISearchIndexByQueryRo,\n    projection?: string[]\n  ) {\n    return await this.aggregationService.getRecordIndexBySearchOrder(tableId, queryRo, projection);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/ai/ai.controller.ts",
    "content": "import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';\nimport { aiGenerateRoSchema, IAiGenerateRo } from '@teable/openapi';\nimport { Response } from 'express';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { TablePipe } from '../table/open-api/table.pipe';\nimport { AiService } from './ai.service';\n\n@Controller('api/:baseId/ai')\nexport class AiController {\n  constructor(private readonly aiService: AiService) {}\n\n  @Post('/generate-stream')\n  @Permissions('base|read')\n  async generateStream(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(aiGenerateRoSchema), TablePipe) aiGenerateRo: IAiGenerateRo,\n    @Res() res: Response\n  ) {\n    await this.aiService.generateStream(baseId, aiGenerateRo, res);\n  }\n\n  @Get('/config')\n  @Permissions('base|read')\n  async getAIConfig(@Param('baseId') baseId: string) {\n    return await this.aiService.getSimplifiedAIConfig(baseId);\n  }\n\n  @Get('/disable-ai-actions')\n  @Permissions('base|read')\n  async getAIDisableAIActions(@Param('baseId') baseId: string) {\n    return await this.aiService.getAIDisableAIActions(baseId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/ai/ai.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SettingModule } from '../setting/setting.module';\nimport { AiController } from './ai.controller';\nimport { AiService } from './ai.service';\n\n@Module({\n  imports: [SettingModule],\n  controllers: [AiController],\n  providers: [AiService],\n  exports: [AiService],\n})\nexport class AiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/ai/ai.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { OpenAIProvider } from '@ai-sdk/openai';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  IntegrationType,\n  LLMProviderType,\n  SettingKey,\n  Task,\n  convertGatewayApiModel,\n  normalizeGatewayPricing,\n} from '@teable/openapi';\nimport type {\n  IAIConfig,\n  IAiGenerateRo,\n  IChatModelAbility,\n  IGatewayApiModel,\n  IGatewayApiModelRaw,\n  IGetAIConfig,\n  GatewayModelTag,\n  LLMProvider,\n} from '@teable/openapi';\nimport type { ImageModel, LanguageModel } from 'ai';\nimport { createGateway, generateText, streamText } from 'ai';\nimport axios from 'axios';\nimport type { Response } from 'express';\nimport { BaseConfig, IBaseConfig } from '../../configs/base.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { PerformanceCacheService } from '../../performance-cache';\nimport { SettingService } from '../setting/setting.service';\nimport { getAdaptedProviderOptions, getTaskModelKey, modelProviders } from './util';\n\n// Fixed name for AI Gateway provider in modelKey (format: aiGateway@<modelId>@teable)\nexport const AI_GATEWAY_PROVIDER_NAME = 'teable';\n\nexport type ILanguageModelV2 = Exclude<LanguageModel, string>;\n\n// In-memory cache for Gateway models (TTL: 10 minutes)\nconst gatewayModelsCacheTtl = 10 * 60 * 1000;\n\ninterface IGatewayModelsCache {\n  data: IGatewayApiModel[];\n  expiresAt: number;\n}\n\n@Injectable()\nexport class AiService {\n  private readonly logger = new Logger(AiService.name);\n\n  // In-memory cache for Gateway models API - faster than Redis for static data\n  private gatewayModelsCache: IGatewayModelsCache | null = null;\n\n  constructor(\n    private readonly settingService: SettingService,\n    private readonly prismaService: PrismaService,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  public parseModelKey(modelKey: string) {\n    const [type, model, name] = modelKey.split('@');\n    return { type, model, name };\n  }\n\n  /**\n   * Check if modelKey is an AI Gateway model\n   * Format: aiGateway@<modelId>@teable\n   */\n  public isGatewayModel(modelKey: string): boolean {\n    const { type, name } = this.parseModelKey(modelKey);\n    return (\n      type?.toLowerCase() === LLMProviderType.AI_GATEWAY.toLowerCase() &&\n      name?.toLowerCase() === AI_GATEWAY_PROVIDER_NAME.toLowerCase()\n    );\n  }\n\n  /**\n   * Build a gateway modelKey from a gateway model ID\n   * @param modelId Gateway model ID (e.g., \"anthropic/claude-sonnet-4\")\n   */\n  public buildGatewayModelKey(modelId: string): string {\n    return `${LLMProviderType.AI_GATEWAY}@${modelId}@${AI_GATEWAY_PROVIDER_NAME}`;\n  }\n\n  /**\n   * Parse owner/provider from gateway model ID\n   * @param modelId Gateway model ID (e.g., \"anthropic/claude-sonnet-4\" -> \"anthropic\")\n   */\n  private parseOwnerFromModelId(modelId: string): string | undefined {\n    const parts = modelId.split('/');\n    return parts.length > 1 ? parts[0].toLowerCase() : undefined;\n  }\n\n  // modelKey-> type@model@name\n  async getModelConfig(modelKey: string, llmProviders: LLMProvider[] = []) {\n    const { type, model, name } = this.parseModelKey(modelKey);\n\n    // Special handling for AI Gateway models\n    if (this.isGatewayModel(modelKey)) {\n      const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n\n      if (!aiConfig?.aiGatewayApiKey) {\n        throw new CustomHttpException(\n          'AI Gateway API key is not configured',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.ai.gatewayApiKeyNotSet',\n            },\n          }\n        );\n      }\n\n      return {\n        type: LLMProviderType.AI_GATEWAY,\n        model, // This is the gateway modelId (e.g., \"anthropic/claude-sonnet-4\")\n        baseUrl: aiConfig.aiGatewayBaseUrl || undefined,\n        apiKey: aiConfig.aiGatewayApiKey,\n      };\n    }\n\n    // Standard provider lookup\n    const providerConfig = llmProviders.find(\n      (p) =>\n        p.name.toLowerCase() === name.toLowerCase() && p.type.toLowerCase() === type.toLowerCase()\n    );\n\n    if (!providerConfig) {\n      throw new CustomHttpException(\n        'AI provider configuration is not set',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.ai.providerConfigurationNotSet',\n          },\n        }\n      );\n    }\n\n    const { baseUrl, apiKey } = providerConfig;\n\n    return {\n      type,\n      model,\n      baseUrl,\n      apiKey,\n    };\n  }\n\n  async getModelInstance(\n    modelKey: string,\n    llmProviders: LLMProvider[],\n    isImageGeneration: true\n  ): Promise<ReturnType<OpenAIProvider['image']>>;\n  async getModelInstance(\n    modelKey: string,\n    llmProviders?: LLMProvider[],\n    isImageGeneration?: false\n  ): Promise<ILanguageModelV2>;\n  async getModelInstance(\n    modelKey: string,\n    llmProviders: LLMProvider[] = [],\n    isImageGeneration = false\n  ): Promise<ILanguageModelV2 | ImageModel> {\n    const { type, model, baseUrl, apiKey } = await this.getModelConfig(modelKey, llmProviders);\n\n    // For AI Gateway models, use official gateway provider from AI SDK\n    // See: https://ai-sdk.dev/providers/ai-sdk-providers/ai-gateway\n    // baseUrl is optional - SDK uses its default if not provided\n    if (type === LLMProviderType.AI_GATEWAY) {\n      if (!apiKey) {\n        throw new CustomHttpException(\n          'AI configuration is not set',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.ai.configurationNotSet',\n            },\n          }\n        );\n      }\n      const gatewayProvider = createGateway({\n        apiKey,\n        ...(baseUrl && { baseURL: baseUrl }),\n      });\n      // Return appropriate model type based on isImageGeneration flag\n      // Image models (e.g., bfl/flux-pro) use gatewayProvider.imageModel()\n      // Language models (including Gemini image via generateText) use gatewayProvider()\n      return isImageGeneration ? gatewayProvider.imageModel(model) : gatewayProvider(model);\n    }\n\n    // For standard providers, both baseUrl and apiKey are required\n    if (!baseUrl || !apiKey) {\n      throw new CustomHttpException('AI configuration is not set', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.ai.configurationNotSet',\n        },\n      });\n    }\n\n    const effectiveType = type;\n    const effectiveModel = model;\n\n    const provider = Object.entries(modelProviders).find(\n      ([key]) => effectiveType.toLowerCase() === key.toLowerCase()\n    )?.[1];\n\n    if (!provider) {\n      throw new CustomHttpException(\n        `Unsupported AI provider: ${effectiveType}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.ai.unsupportedProvider',\n            context: {\n              type: effectiveType,\n            },\n          },\n        }\n      );\n    }\n\n    const providerOptions = getAdaptedProviderOptions(effectiveType as LLMProviderType, {\n      name: effectiveModel,\n      baseURL: baseUrl,\n      apiKey,\n    });\n    const modelProvider = provider(providerOptions as never) as OpenAIProvider;\n\n    return isImageGeneration\n      ? (modelProvider.image(effectiveModel) as ReturnType<OpenAIProvider['image']>)\n      : modelProvider(effectiveModel);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async getAIConfig(baseId: string) {\n    const { spaceId } = await this.prismaService.base.findUniqueOrThrow({\n      where: { id: baseId },\n    });\n    const aiIntegration = await this.prismaService.integration.findFirst({\n      where: { resourceId: spaceId, type: IntegrationType.AI, enable: true },\n    });\n\n    const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null;\n    const { aiConfig } = await this.settingService.getSetting();\n\n    const hasInstanceAIConfig =\n      aiConfig &&\n      (aiConfig.enable ||\n        aiConfig.chatModel?.lg ||\n        aiConfig.llmProviders?.length > 0 ||\n        aiConfig.aiGatewayApiKey);\n    if (!aiIntegrationConfig && !hasInstanceAIConfig) {\n      throw new CustomHttpException('AI configuration is not set', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.ai.configurationNotSet',\n        },\n      });\n    }\n\n    let config: IAIConfig;\n\n    if (!aiIntegrationConfig) {\n      const lg = aiConfig?.chatModel?.lg;\n      const sm = aiConfig?.chatModel?.sm;\n      const md = aiConfig?.chatModel?.md;\n      const ability = aiConfig?.chatModel?.ability;\n\n      config = {\n        ...aiConfig,\n        llmProviders: aiConfig?.llmProviders.map((provider) => ({\n          ...provider,\n          isInstance: true,\n        })),\n        chatModel: {\n          sm: sm || lg,\n          md: md || lg,\n          lg: lg,\n          ability,\n        },\n      } as IAIConfig;\n    } else if (!aiConfig?.chatModel?.lg) {\n      config = aiIntegrationConfig as IAIConfig;\n    } else {\n      const lg = aiIntegrationConfig.chatModel?.lg || aiConfig.chatModel.lg;\n      const sm = aiIntegrationConfig.chatModel?.sm;\n      const md = aiIntegrationConfig.chatModel?.md;\n      const ability = aiIntegrationConfig.chatModel?.ability || aiConfig.chatModel.ability;\n      config = {\n        ...aiIntegrationConfig,\n        // Include gateway models from admin config (space config doesn't have gateway models)\n        gatewayModels: aiConfig.gatewayModels,\n        llmProviders: [\n          ...aiIntegrationConfig.llmProviders,\n          ...aiConfig.llmProviders.map((provider) => ({\n            ...provider,\n            isInstance: true,\n          })),\n        ],\n        chatModel: {\n          sm: sm || lg,\n          md: md || lg,\n          lg: lg,\n          ability,\n        },\n        isSpaceChatModel: Boolean(aiIntegrationConfig.chatModel?.lg),\n      } as IAIConfig;\n    }\n\n    // Fetch tags for the lg chat model and include in response\n    const lgModelKey = config.chatModel?.lg;\n    if (lgModelKey) {\n      try {\n        const tags = await this.getModelTags(lgModelKey, config.llmProviders);\n        if (tags.length > 0) {\n          // Add tags to chatModel response (IGetAIConfig extends IAIConfig with tags)\n          return {\n            ...config,\n            chatModel: {\n              ...config.chatModel,\n              tags,\n            },\n          } as IGetAIConfig;\n        }\n      } catch (error) {\n        this.logger.warn(`[getAIConfig] Failed to get tags for chat model ${lgModelKey}: ${error}`);\n      }\n    }\n\n    return config as IGetAIConfig;\n  }\n\n  async getAIDisableAIActions(baseId: string) {\n    const { spaceId } = await this.prismaService.base.findUniqueOrThrow({\n      where: { id: baseId },\n      select: { spaceId: true },\n    });\n    // get space ai setting\n    const aiIntegration = await this.prismaService.integration.findUnique({\n      where: { resourceId: spaceId, type: IntegrationType.AI },\n    });\n\n    const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null;\n    const disableAIActionsFromSpaceIntegration =\n      aiIntegrationConfig?.capabilities?.disableActions ?? [];\n\n    // get instance ai setting\n    const { aiConfig } = await this.settingService.getSetting();\n    const disableAIActionsFromInstanceAiSetting = aiConfig?.capabilities?.disableActions ?? [];\n\n    // merge both: instance-level disableActions should always be respected\n    const merged = [\n      ...disableAIActionsFromInstanceAiSetting,\n      ...disableAIActionsFromSpaceIntegration,\n    ];\n    return {\n      disableActions: [...new Set(merged)],\n    };\n  }\n\n  async getToolApiKeys(baseId: string) {\n    const { appConfig } = await this.settingService.getSetting([SettingKey.APP_CONFIG]);\n    const { spaceId } = await this.prismaService.base.findUniqueOrThrow({\n      where: { id: baseId },\n    });\n    const aiIntegration = await this.prismaService.integration.findFirst({\n      where: { resourceId: spaceId, type: IntegrationType.AI },\n    });\n    const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null;\n    return {\n      v0ApiKey: aiIntegrationConfig?.appConfig?.apiKey || appConfig?.apiKey,\n    };\n  }\n\n  async getSimplifiedAIConfig(baseId: string) {\n    try {\n      const config = await this.getAIConfig(baseId);\n      return {\n        ...config,\n        llmProviders: config.llmProviders.map(\n          ({ type, name, models, isInstance, modelConfigs }) => ({\n            type,\n            name,\n            models,\n            isInstance,\n            modelConfigs,\n          })\n        ),\n      };\n    } catch {\n      return null;\n    }\n  }\n\n  private async getGenerationModelInstance(baseId: string, aiGenerateRo: IAiGenerateRo) {\n    const { modelKey: _modelKey, task = Task.Coding } = aiGenerateRo;\n    const config = await this.getAIConfig(baseId);\n    const modelKey = _modelKey ?? getTaskModelKey(config, task);\n    if (!modelKey) {\n      throw new Error('Model key is not set');\n    }\n    return await this.getModelInstance(modelKey, config.llmProviders);\n  }\n\n  async generateStream(\n    baseId: string,\n    aiGenerateRo: IAiGenerateRo,\n    response: Response\n  ): Promise<void> {\n    const { prompt } = aiGenerateRo;\n    const modelInstance = await this.getGenerationModelInstance(baseId, aiGenerateRo);\n\n    const result = streamText({\n      model: modelInstance,\n      prompt: prompt,\n    });\n\n    result.pipeTextStreamToResponse(response);\n  }\n\n  async generateText(baseId: string, aiGenerateRo: IAiGenerateRo) {\n    const { prompt } = aiGenerateRo;\n    const modelInstance = await this.getGenerationModelInstance(baseId, aiGenerateRo);\n\n    const { text } = await generateText({\n      model: modelInstance,\n      prompt: prompt,\n    });\n    return text;\n  }\n\n  async getInstanceAIConfig() {\n    if (!this.baseConfig.isCloud) return null;\n\n    const { aiConfig } = await this.settingService.getSetting();\n\n    if (!aiConfig?.chatModel?.lg) return null;\n\n    return aiConfig;\n  }\n\n  findModelInProviders(modelKey: string, llmProviders: LLMProvider[]): boolean {\n    const { type, model, name } = this.parseModelKey(modelKey);\n\n    const providerConfig = llmProviders.find(\n      (p) =>\n        p.name.toLowerCase() === name.toLowerCase() &&\n        p.type.toLowerCase() === type.toLowerCase() &&\n        p.models.includes(model)\n    );\n    return !!providerConfig;\n  }\n\n  /**\n   * Check if a gateway model should be billed\n   * All AI Gateway models should be billed as long as aiGatewayApiKey is configured\n   * The gatewayModels list is just for recommended/displayed models, not a billing whitelist\n   */\n  async findModelInGateway(modelKey: string): Promise<boolean> {\n    if (!this.isGatewayModel(modelKey)) {\n      this.logger.debug(`[findModelInGateway] ${modelKey} is not a gateway model`);\n      return false;\n    }\n\n    const { model: modelId } = this.parseModelKey(modelKey);\n    const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n\n    // Check if gateway is configured - if yes, all gateway models should be billed\n    if (!aiConfig?.aiGatewayApiKey) {\n      this.logger.warn(\n        `[findModelInGateway] No aiGatewayApiKey configured, model ${modelId} will not be billed`\n      );\n      return false;\n    }\n\n    this.logger.debug(\n      `[findModelInGateway] AI Gateway configured, model ${modelId} will be billed`\n    );\n    return true;\n  }\n\n  async checkInstanceAIModel(modelKey: string): Promise<boolean> {\n    // Check gateway models first\n    if (this.isGatewayModel(modelKey)) {\n      return this.findModelInGateway(modelKey);\n    }\n\n    const aiConfig = await this.getInstanceAIConfig();\n    if (!aiConfig) return false;\n\n    return this.findModelInProviders(modelKey, aiConfig.llmProviders);\n  }\n\n  async getChatModelInstance(baseId: string) {\n    const { chatModel, llmProviders } = await this.getAIConfig(baseId);\n    if (!chatModel?.lg) {\n      throw new CustomHttpException('AI chat model lg is not set', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.ai.chatModelLgNotSet',\n        },\n      });\n    }\n\n    // Check if lg model is a gateway model\n    const isGateway = this.isGatewayModel(chatModel.lg);\n    let isInstance = false;\n\n    if (isGateway) {\n      // Gateway models are instance-level (from admin config)\n      isInstance = true;\n    } else {\n      // Standard provider lookup\n      const { type, model, name } = this.parseModelKey(chatModel?.lg);\n      const lgProvider = llmProviders.find(\n        (p) =>\n          p.name.toLowerCase() === name.toLowerCase() &&\n          p.type.toLowerCase() === type.toLowerCase() &&\n          p.models.includes(model)\n      );\n      if (!lgProvider) {\n        throw new CustomHttpException(\n          'AI chat model lg provider is not set',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.ai.chatModelLgProviderNotSet',\n            },\n          }\n        );\n      }\n      isInstance = !!lgProvider.isInstance;\n    }\n\n    if (!chatModel?.sm) {\n      throw new CustomHttpException('AI chat model sm is not set', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.ai.chatModelSmNotSet',\n        },\n      });\n    }\n    if (!chatModel?.md) {\n      throw new CustomHttpException('AI chat model md is not set', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.ai.chatModelMdNotSet',\n        },\n      });\n    }\n\n    return {\n      sm: await this.getModelInstance(chatModel?.sm, llmProviders),\n      md: await this.getModelInstance(chatModel?.md, llmProviders),\n      lg: await this.getModelInstance(chatModel?.lg, llmProviders),\n      ability: chatModel?.ability,\n      isInstance,\n      lgModelKey: chatModel.lg,\n    };\n  }\n\n  /**\n   * Get gateway model configuration by modelId\n   * First checks local gatewayModels config, then falls back to API\n   */\n  async getGatewayModelConfig(modelId: string) {\n    // First check local config (admin-configured models)\n    const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n    const gatewayModels = aiConfig?.gatewayModels ?? [];\n    const localModel = gatewayModels.find((m) => m.id === modelId);\n    if (localModel) {\n      return localModel;\n    }\n\n    // If not found locally, fetch from API (for custom-selected models)\n    const apiModel = await this.getGatewayApiModel(modelId);\n    if (apiModel) {\n      // Convert API model format to local model format\n      return {\n        ...apiModel,\n        label: apiModel.name || apiModel.id,\n        enabled: true,\n      };\n    }\n\n    return undefined;\n  }\n\n  /**\n   * Get model capability tags for any model (AI Gateway or custom provider)\n   * This is the unified method to determine model capabilities like vision, file-input, etc.\n   *\n   * Priority:\n   * 1. AI Gateway: from getGatewayModelConfig().tags\n   * 2. Custom Provider: from modelConfigs[model].tags\n   * 3. Fallback: convert deprecated ability field to tags (backward compatibility)\n   *\n   * @param modelKey - Model key in format: type@model@name\n   * @param llmProviders - List of configured LLM providers (required for custom providers)\n   */\n  async getModelTags(modelKey: string, llmProviders: LLMProvider[]): Promise<GatewayModelTag[]> {\n    const { type, model, name } = this.parseModelKey(modelKey);\n\n    // AI Gateway models: get tags from gateway config\n    if (type === LLMProviderType.AI_GATEWAY) {\n      try {\n        const gatewayModel = await this.getGatewayModelConfig(model);\n        if (gatewayModel?.tags?.length) {\n          const tags = [...gatewayModel.tags];\n          // Patch: Google models with image-generation capability also support vision (image-to-image)\n          // This is because Gemini image models can accept images as input for image generation\n          if (\n            model.startsWith('google/') &&\n            tags.includes('image-generation') &&\n            !tags.includes('vision')\n          ) {\n            tags.push('vision');\n          }\n          return tags;\n        }\n      } catch (error) {\n        this.logger.warn(`[getModelTags] Failed to get gateway config for ${model}: ${error}`);\n      }\n      return [];\n    }\n\n    // Custom providers: get tags from modelConfigs\n    const provider = llmProviders.find((p) => p.type === type && p.name === name);\n    const modelConfig = provider?.modelConfigs?.[model];\n\n    // Priority 1: Use tags if available\n    if (modelConfig?.tags?.length) {\n      return modelConfig.tags;\n    }\n\n    // Priority 2: Fallback to converting deprecated ability to tags\n    if (modelConfig?.ability) {\n      return this.abilityToTags(modelConfig.ability);\n    }\n\n    return [];\n  }\n\n  /**\n   * Convert deprecated IChatModelAbility to GatewayModelTag[]\n   * Used for backward compatibility with old ability format\n   */\n  private abilityToTags(ability: IChatModelAbility): GatewayModelTag[] {\n    const tags: GatewayModelTag[] = [];\n    if (ability.image) tags.push('vision');\n    if (ability.pdf) tags.push('file-input');\n    if (ability.toolCall) tags.push('tool-use');\n    if (ability.reasoning) tags.push('reasoning');\n    if (ability.imageGeneration) tags.push('image-generation');\n    return tags;\n  }\n\n  /**\n   * Get gateway model pricing for billing calculation\n   * First checks local gatewayModels config, then falls back to API\n   */\n  async getGatewayModelPricing(modelId: string) {\n    // First check local config (admin-configured models)\n    const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n    const gatewayModels = aiConfig?.gatewayModels ?? [];\n    const localModel = gatewayModels.find((m) => m.id === modelId);\n\n    if (localModel?.pricing) {\n      // Normalize handles both camelCase (admin UI) and snake_case (legacy stored data)\n      const pricing = normalizeGatewayPricing(localModel.pricing);\n      this.logger.debug(\n        `[getGatewayModelPricing] Found local pricing for ${modelId}: ${JSON.stringify(pricing)}`\n      );\n      return pricing;\n    }\n\n    // If not found locally, fetch from API (already normalized by convertGatewayApiModel)\n    try {\n      const apiModel = await this.getGatewayApiModel(modelId);\n      if (apiModel?.pricing) {\n        this.logger.debug(\n          `[getGatewayModelPricing] Found API pricing for ${modelId}: ${JSON.stringify(apiModel.pricing)}`\n        );\n        return apiModel.pricing;\n      }\n    } catch (error) {\n      this.logger.warn(`[getGatewayModelPricing] Failed to fetch API pricing for ${modelId}`);\n    }\n\n    this.logger.debug(\n      `[getGatewayModelPricing] No pricing found for ${modelId}, will use default rates`\n    );\n    return undefined;\n  }\n\n  /**\n   * Get a specific model from Gateway API\n   * Uses Redis cached data if available\n   */\n  private async getGatewayApiModel(modelId: string): Promise<IGatewayApiModel | undefined> {\n    const models = await this.fetchGatewayModelsFromApi();\n    return models.find((m) => m.id === modelId);\n  }\n\n  /**\n   * Fetch all models from AI Gateway API with in-memory caching\n   * This method is also used by setting-open-api.service.ts\n   * Cache TTL: 10 minutes (static data, doesn't change frequently)\n   */\n  async fetchGatewayModelsFromApi(): Promise<IGatewayApiModel[]> {\n    // Check in-memory cache first\n    if (this.gatewayModelsCache && Date.now() < this.gatewayModelsCache.expiresAt) {\n      return this.gatewayModelsCache.data;\n    }\n\n    try {\n      const response = await axios.get<{ data: IGatewayApiModelRaw[] }>(\n        'https://ai-gateway.vercel.sh/v1/models',\n        { timeout: 10000 }\n      );\n\n      // Convert snake_case API response to camelCase\n      const models = (response.data?.data || []).map(convertGatewayApiModel);\n\n      // Update in-memory cache\n      this.gatewayModelsCache = {\n        data: models,\n        expiresAt: Date.now() + gatewayModelsCacheTtl,\n      };\n\n      return models;\n    } catch (error) {\n      // If fetch fails but we have stale cache, return it\n      if (this.gatewayModelsCache) {\n        this.logger.warn(\n          `[fetchGatewayModelsFromApi] Failed to refresh, using stale cache: ${error}`\n        );\n        return this.gatewayModelsCache.data;\n      }\n\n      const errorMessage = error instanceof Error ? error.message : String(error);\n      throw new Error(`Failed to fetch AI Gateway models: ${errorMessage}`);\n    }\n  }\n\n  /**\n   * Get attachment transfer mode from aiConfig\n   * @returns 'url' (default) or 'base64'\n   */\n  async getAttachmentTransferMode(): Promise<'url' | 'base64'> {\n    const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n    return aiConfig?.attachmentTransferMode || 'url';\n  }\n\n  /**\n   * Find the first model that supports vision capability from configured models.\n   * Searches in order: gateway models (enabled), then custom llm providers.\n   * Returns complete model info to avoid redundant lookups.\n   *\n   * @param llmProviders - List of configured LLM providers\n   * @returns Complete vision model info, or undefined if none found\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async findFirstVisionModel(llmProviders: LLMProvider[]): Promise<\n    | {\n        modelKey: string;\n        modelInstance: ILanguageModelV2;\n        isInstance: boolean;\n        tags: GatewayModelTag[];\n      }\n    | undefined\n  > {\n    const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);\n\n    // 1. Check gateway models first (they are typically more capable)\n    const gatewayModels = aiConfig?.gatewayModels ?? [];\n    for (const model of gatewayModels) {\n      if (!model.enabled) continue;\n\n      if (model.tags?.includes('vision')) {\n        const modelKey = this.buildGatewayModelKey(model.id);\n        const modelInstance = await this.getModelInstance(modelKey, llmProviders);\n        return {\n          modelKey,\n          modelInstance,\n          isInstance: true, // Gateway models are always instance-level\n          tags: model.tags,\n        };\n      }\n    }\n\n    // 2. Check custom LLM providers\n    for (const provider of llmProviders) {\n      const models = provider.models?.split(',').map((m) => m.trim()) ?? [];\n      for (const model of models) {\n        const modelConfig = provider.modelConfigs?.[model];\n        if (!modelConfig) continue;\n\n        // Check tags (new format) or ability (backward compatibility)\n        const hasVision = modelConfig.tags?.includes('vision') || modelConfig.ability?.image;\n        if (hasVision) {\n          const modelKey = `${provider.type}@${model}@${provider.name}`;\n          const modelInstance = await this.getModelInstance(modelKey, llmProviders);\n          // Convert ability to tags for backward compatibility\n          const tags: GatewayModelTag[] =\n            modelConfig.tags ?? this.abilityToTags(modelConfig.ability ?? {});\n          return {\n            modelKey,\n            modelInstance,\n            isInstance: !!provider.isInstance,\n            tags,\n          };\n        }\n      }\n    }\n\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/ai/constant.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Task } from '@teable/openapi';\n\nexport const TASK_MODEL_MAP = {\n  [Task.Coding]: 'chatModel.lg',\n  [Task.Embedding]: 'embeddingModel',\n  [Task.Translation]: 'translationModel',\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/ai/util.ts",
    "content": "import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';\nimport { createAnthropic } from '@ai-sdk/anthropic';\nimport { createAzure } from '@ai-sdk/azure';\nimport { createCohere } from '@ai-sdk/cohere';\nimport { createDeepSeek } from '@ai-sdk/deepseek';\nimport { createGoogleGenerativeAI } from '@ai-sdk/google';\nimport { createMistral } from '@ai-sdk/mistral';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { createOpenAICompatible } from '@ai-sdk/openai-compatible';\nimport { createTogetherAI } from '@ai-sdk/togetherai';\nimport { createXai } from '@ai-sdk/xai';\nimport { createOpenRouter } from '@openrouter/ai-sdk-provider';\nimport type { IAIConfig, Task } from '@teable/openapi';\nimport { LLMProviderType } from '@teable/openapi';\nimport { get } from 'lodash';\nimport { createOllama } from 'ollama-ai-provider-v2';\nimport { TASK_MODEL_MAP } from './constant';\n\n/**\n * Fix non-standard OpenAI compatible API streaming response.\n * Some API proxies return `role: \"\"` instead of proper format.\n * This uses regex replacement which is simpler and more robust than parsing.\n */\nconst fixStreamText = (text: string): string => {\n  // Replace \"role\":\"\" with nothing (remove the field)\n  // This regex handles the field whether it's first, middle, or last in the object\n  // comma followed by role (if last field)\n\n  return text\n    .replace(/\"role\":\"\",/g, '') // role followed by comma\n    .replace(/,\"role\":\"\"/g, '');\n};\n\n/**\n * Custom fetch wrapper that fixes non-standard OpenAI compatible API responses.\n * Some API proxies return invalid format like `role: \"\"` instead of `role: \"assistant\"`.\n * This wrapper transforms the streaming response to fix such issues.\n */\nconst createFixingFetch = (): typeof fetch => {\n  return async (input, init) => {\n    const response = await fetch(input, init);\n\n    // Only transform if there's a body (streaming responses)\n    if (!response.body) {\n      return response;\n    }\n\n    const reader = response.body.getReader();\n    const decoder = new TextDecoder();\n    const encoder = new TextEncoder();\n\n    const transformedStream = new ReadableStream({\n      async pull(controller) {\n        const { done, value } = await reader.read();\n\n        if (done) {\n          controller.close();\n          return;\n        }\n\n        const text = decoder.decode(value, { stream: true });\n        const fixedText = fixStreamText(text);\n\n        controller.enqueue(encoder.encode(fixedText));\n      },\n    });\n\n    return new Response(transformedStream, {\n      status: response.status,\n      statusText: response.statusText,\n      headers: response.headers,\n    });\n  };\n};\n\n/**\n * Wrapper for OpenAI compatible providers that:\n * 1. Forces Chat Completions API instead of Responses API\n * 2. Uses custom fetch to fix non-standard API responses\n */\nconst createOpenAICompatibleWrapper = (\n  options: Parameters<typeof createOpenAICompatible>[0]\n): ReturnType<typeof createOpenAICompatible> => {\n  return createOpenAICompatible({\n    ...options,\n    // Use custom fetch to fix non-standard responses\n    fetch: createFixingFetch(),\n  });\n};\n\nexport const modelProviders = {\n  [LLMProviderType.OPENAI]: createOpenAI,\n  [LLMProviderType.ANTHROPIC]: createAnthropic,\n  [LLMProviderType.GOOGLE]: createGoogleGenerativeAI,\n  [LLMProviderType.AZURE]: createAzure,\n  [LLMProviderType.COHERE]: createCohere,\n  [LLMProviderType.MISTRAL]: createMistral,\n  [LLMProviderType.DEEPSEEK]: createDeepSeek,\n  [LLMProviderType.QWEN]: createOpenAICompatible,\n  [LLMProviderType.ZHIPU]: createOpenAICompatible,\n  [LLMProviderType.LINGYIWANWU]: createOpenAICompatible,\n  [LLMProviderType.XAI]: createXai,\n  [LLMProviderType.TOGETHERAI]: createTogetherAI,\n  [LLMProviderType.OLLAMA]: createOllama,\n  [LLMProviderType.AMAZONBEDROCK]: createAmazonBedrock,\n  [LLMProviderType.OPENROUTER]: createOpenRouter,\n  [LLMProviderType.OPENAI_COMPATIBLE]: createOpenAICompatibleWrapper,\n  // AI_GATEWAY is handled separately in ai.service.ts using createGateway from 'ai'\n} as const;\n\nexport const getAdaptedProviderOptions = (\n  type: LLMProviderType,\n  originalOptions: {\n    name: string;\n    baseURL: string;\n    apiKey: string;\n  }\n) => {\n  const { name, baseURL: originalBaseURL, apiKey: originalApiKey } = originalOptions;\n  switch (type) {\n    case LLMProviderType.AMAZONBEDROCK: {\n      const [region, accessKeyId, secretAccessKey] = originalApiKey.split('.');\n      return {\n        name,\n        region,\n        secretAccessKey: secretAccessKey,\n        accessKeyId: accessKeyId,\n        baseURL: originalBaseURL,\n      };\n    }\n    case LLMProviderType.OLLAMA:\n      return { name, baseURL: originalBaseURL };\n    case LLMProviderType.OPENAI_COMPATIBLE:\n      return { ...originalOptions, includeUsage: true };\n    case LLMProviderType.AI_GATEWAY:\n      // AI Gateway - use official gateway provider options\n      // Gateway handles provider routing via modelId format (e.g., \"google/gemini-3-pro-image\")\n      // See: https://ai-sdk.dev/providers/ai-sdk-providers/ai-gateway\n      // SDK default baseURL: https://ai-gateway.vercel.sh/v1/ai\n      return {\n        baseURL: originalBaseURL || undefined,\n        apiKey: originalApiKey,\n      };\n    default: {\n      return originalOptions;\n    }\n  }\n};\n\nexport const getTaskModelKey = (aiConfig: IAIConfig, task: Task): string | undefined => {\n  const modelKey = TASK_MODEL_MAP[task];\n  return get(aiConfig, modelKey) as string | undefined;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-crop.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventJobModule } from '../../event-emitter/event-job/event-job.module';\nimport {\n  ATTACHMENTS_CROP_QUEUE,\n  AttachmentsCropQueueProcessor,\n} from './attachments-crop.processor';\nimport { AttachmentsStorageModule } from './attachments-storage.module';\n\n@Module({\n  providers: [AttachmentsCropQueueProcessor],\n  imports: [EventJobModule.registerQueue(ATTACHMENTS_CROP_QUEUE), AttachmentsStorageModule],\n  exports: [AttachmentsCropQueueProcessor],\n})\nexport class AttachmentsCropModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-crop.processor.ts",
    "content": "import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';\nimport type { NestWorkerOptions } from '@nestjs/bullmq/dist/interfaces/worker-options.interface';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Queue } from 'bullmq';\nimport type { Job } from 'bullmq';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\n\ninterface IRecordImageJob {\n  bucket: string;\n  token: string;\n  path: string;\n  mimetype: string;\n  height?: number | null;\n}\n\nexport const ATTACHMENTS_CROP_QUEUE = 'attachments-crop-queue';\n\nconst queueOptions: NestWorkerOptions = {\n  removeOnComplete: {\n    count: 2000,\n  },\n  removeOnFail: {\n    count: 2000,\n  },\n};\n@Injectable()\n@Processor(ATTACHMENTS_CROP_QUEUE, queueOptions)\nexport class AttachmentsCropQueueProcessor extends WorkerHost {\n  private logger = new Logger(AttachmentsCropQueueProcessor.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly attachmentsStorageService: AttachmentsStorageService,\n    private readonly eventEmitterService: EventEmitterService,\n    @InjectQueue(ATTACHMENTS_CROP_QUEUE) public readonly queue: Queue<IRecordImageJob>\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IRecordImageJob>) {\n    await this.handleCropImage(job);\n    await this.eventEmitterService.emitAsync(Events.CROP_IMAGE_COMPLETE, {\n      token: job.data.token,\n    });\n  }\n\n  private async handleCropImage(job: Job<IRecordImageJob>) {\n    const { bucket, token, path, mimetype, height } = job.data;\n    if (mimetype.startsWith('image/') && height) {\n      const existingThumbnailPath = await this.prismaService.attachments.findUnique({\n        where: { token },\n        select: { thumbnailPath: true },\n      });\n      if (existingThumbnailPath?.thumbnailPath) {\n        this.logger.log(`path(${path}) image already has thumbnail`);\n        return;\n      }\n      const { lgThumbnailPath, smThumbnailPath } =\n        await this.attachmentsStorageService.cropTableImage(bucket, path, height);\n      await this.prismaService.attachments.update({\n        where: {\n          token,\n        },\n        data: {\n          thumbnailPath: JSON.stringify({\n            lg: lgThumbnailPath,\n            sm: smThumbnailPath,\n          }),\n        },\n      });\n      this.logger.log(`path(${path}) crop thumbnails success`);\n      return;\n    }\n    this.logger.log(`path(${path}) is not a image`);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-storage.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsStorageService } from './attachments-storage.service';\nimport { StorageModule } from './plugins/storage.module';\n\n@Module({\n  providers: [AttachmentsStorageService],\n  imports: [StorageModule],\n  exports: [AttachmentsStorageService],\n})\nexport class AttachmentsStorageModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-storage.service.ts",
    "content": "import { BadRequestException, Injectable, Logger } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport { CacheService } from '../../cache/cache.service';\nimport { IStorageConfig, StorageConfig } from '../../configs/storage';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport {\n  generateTableThumbnailPath,\n  getTableThumbnailToken,\n} from '../../utils/generate-thumbnail-path';\nimport { second } from '../../utils/second';\nimport { ATTACHMENT_LG_THUMBNAIL_HEIGHT, ATTACHMENT_SM_THUMBNAIL_HEIGHT } from './constant';\nimport StorageAdapter from './plugins/adapter';\nimport { InjectStorageAdapter } from './plugins/storage';\nimport type { IRespHeaders } from './plugins/types';\n\n@Injectable()\nexport class AttachmentsStorageService {\n  private readonly urlExpireIn: number;\n  private readonly logger = new Logger(AttachmentsStorageService.name);\n\n  constructor(\n    private readonly cacheService: CacheService,\n    private readonly prismaService: PrismaService,\n    private readonly eventEmitterService: EventEmitterService,\n    @StorageConfig() private readonly storageConfig: IStorageConfig,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter\n  ) {\n    this.urlExpireIn = second(this.storageConfig.urlExpireIn);\n  }\n\n  async getPreviewUrl<T extends string | string[] = string | string[]>(\n    bucket: string,\n    token: T,\n    meta?: { expiresIn?: number }\n  ): Promise<T> {\n    const { expiresIn = this.urlExpireIn } = meta ?? {};\n    const isArray = Array.isArray(token);\n    if (isArray && token.length === 0) {\n      return [] as unknown as T;\n    }\n    if (!isArray && !token) {\n      return '' as T;\n    }\n    const attachment = await this.prismaService.txClient().attachments.findMany({\n      where: {\n        token: isArray ? { in: token } : token,\n        deletedTime: null,\n      },\n      select: {\n        path: true,\n        token: true,\n        mimetype: true,\n      },\n    });\n    if (!attachment) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const urlArray: string[] = [];\n    for (const item of attachment) {\n      const { path, token, mimetype } = item;\n      const url = await this.getPreviewUrlByPath(bucket, path, token, expiresIn, {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': mimetype,\n      });\n      urlArray.push(url);\n    }\n    return (isArray ? urlArray : urlArray[0]) as T;\n  }\n\n  async getPreviewUrlByPath(\n    bucket: string,\n    path: string,\n    token: string,\n    expiresIn: number = this.urlExpireIn,\n    respHeaders?: IRespHeaders\n  ) {\n    // Use 50% of URL expiration time for cache TTL to ensure URLs are refreshed\n    // before they expire, preventing stale URLs after deployments\n    const cacheTtl = Math.floor(expiresIn * 0.5);\n    const previewCache = await this.cacheService.get(`attachment:preview:${token}`);\n    let url = previewCache?.url;\n    if (!url) {\n      url = await this.storageAdapter.getPreviewUrl(bucket, path, expiresIn, respHeaders);\n      await this.cacheService.set(\n        `attachment:preview:${token}`,\n        {\n          url,\n          expiresIn,\n        },\n        cacheTtl\n      );\n    }\n    return url;\n  }\n\n  async getTableThumbnailUrl(path: string, mimetype: string) {\n    return this.getPreviewUrlByPath(\n      StorageAdapter.getBucket(UploadType.Table),\n      path,\n      getTableThumbnailToken(path),\n      undefined,\n      {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': mimetype,\n      }\n    );\n  }\n\n  async cropTableImage(bucket: string, path: string, height: number) {\n    const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(path);\n    const cutSmThumbnailPath =\n      height > ATTACHMENT_SM_THUMBNAIL_HEIGHT\n        ? await this.storageAdapter.cropImage(\n            bucket,\n            path,\n            undefined,\n            ATTACHMENT_SM_THUMBNAIL_HEIGHT,\n            smThumbnailPath\n          )\n        : undefined;\n    const cutLgThumbnailPath =\n      height > ATTACHMENT_LG_THUMBNAIL_HEIGHT\n        ? await this.storageAdapter.cropImage(\n            bucket,\n            path,\n            undefined,\n            ATTACHMENT_LG_THUMBNAIL_HEIGHT,\n            lgThumbnailPath\n          )\n        : undefined;\n    this.eventEmitterService.emit(Events.CROP_IMAGE, {\n      bucket,\n      path,\n    });\n    return {\n      smThumbnailPath: cutSmThumbnailPath,\n      lgThumbnailPath: cutLgThumbnailPath,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-table.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsStorageModule } from './attachments-storage.module';\nimport { AttachmentsTableService } from './attachments-table.service';\n\n@Module({\n  providers: [AttachmentsTableService],\n  imports: [AttachmentsStorageModule],\n  exports: [AttachmentsTableService],\n})\nexport class AttachmentsTableModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-table.service.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { IAttachmentCellValue, IRecord } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Mock } from 'vitest';\nimport { vi } from 'vitest';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport type { IChangeRecord } from '../../event-emitter/events';\nimport { GlobalModule } from '../../global/global.module';\nimport { AttachmentsTableModule } from './attachments-table.module';\nimport { AttachmentsTableService } from './attachments-table.service';\n\ndescribe('AttachmentsService', () => {\n  let service: AttachmentsTableService;\n  const updateManyError = 'updateMany error';\n  const prismaService = mockDeep<PrismaService>();\n  const mockAttachmentCellValue: IAttachmentCellValue = [\n    {\n      id: 'atc1',\n      name: 'attachmentName',\n      path: 'attachmentPath',\n      token: 'attachmentToken',\n      size: 100,\n      mimetype: 'image/jpeg',\n    },\n    {\n      id: 'atc2',\n      name: 'attachmentName',\n      path: 'attachmentPath',\n      token: 'attachmentToken',\n      size: 100,\n      mimetype: 'image/jpeg',\n    },\n  ];\n  const mockAttachmentFields = [{ id: 'field1' }, { id: 'field2' }];\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, AttachmentsTableModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaService)\n\n      .compile();\n\n    service = module.get<AttachmentsTableService>(AttachmentsTableService);\n    prismaService.txClient.mockImplementation(() => {\n      return prismaService;\n    });\n    prismaService.$tx.mockImplementation(async (cb) => {\n      await cb(prismaService);\n    });\n  });\n\n  afterEach(() => {\n    mockReset(prismaService);\n    vi.clearAllMocks();\n  });\n\n  it('should create unique key', () => {\n    expect(service['createUniqueKey']('1', '2', '3', '4')).toEqual('1-2-3-4');\n  });\n\n  describe('getAttachmentFields', () => {\n    it('should retrieve attachment fields from Prisma', async () => {\n      // Mock data\n      const tableId = 'table123';\n\n      // Mock Prisma response\n      prismaService.field.findMany.mockResolvedValue(mockAttachmentFields as any);\n\n      // Call the method\n      const result = await service['getAttachmentFields'](tableId);\n\n      // Verify that Prisma method was called with the correct parameters\n      expect(prismaService.txClient().field.findMany).toHaveBeenCalledWith({\n        where: { tableId, type: FieldType.Attachment, isLookup: null, deletedTime: null },\n        select: { id: true },\n      });\n\n      // Verify the result\n      expect(result).toEqual(mockAttachmentFields);\n    });\n  });\n\n  describe('createRecords', () => {\n    it('should create new attachments', async () => {\n      // Mock data\n      const userId = 'user123';\n      const tableId = 'table123';\n      const records: IRecord[] = [\n        {\n          id: 'record1',\n          fields: {},\n        },\n        {\n          id: 'record2',\n          fields: {\n            field1: mockAttachmentCellValue,\n          },\n        },\n      ];\n\n      vi.spyOn(service as any, 'getAttachmentFields').mockResolvedValue(mockAttachmentFields);\n      await service.createRecords(userId, tableId, records);\n\n      expect(prismaService.attachmentsTable.createMany).toBeCalled();\n    });\n  });\n\n  describe('updateRecords', () => {\n    it('should update records with new attachments', async () => {\n      // Mock data\n      const userId = 'user123';\n      const tableId = 'table123';\n      const records: IChangeRecord[] = [\n        {\n          id: 'record1',\n          fields: {\n            field1: {\n              newValue: mockAttachmentCellValue,\n              oldValue: null,\n            },\n          },\n        },\n      ];\n\n      vi.spyOn(service as any, 'getAttachmentFields').mockResolvedValue(mockAttachmentFields);\n      vi.spyOn(service, 'delete').mockResolvedValue();\n\n      // Call the method\n      await service.updateRecords(userId, tableId, records);\n\n      expect(prismaService.txClient().attachmentsTable.createMany).toBeCalled();\n      expect(service.delete).toHaveBeenCalledTimes(0);\n    });\n\n    it('should delete attachments for records with old attachments', async () => {\n      // Mock data\n      const userId = 'user123';\n      const tableId = 'table123';\n      const mockOldAttachmentCellValue: IAttachmentCellValue = [\n        {\n          id: 'atc-old1',\n          name: 'attachmentName',\n          path: 'attachmentPath',\n          token: 'attachmentToken',\n          size: 100,\n          mimetype: 'image/jpeg',\n        },\n        {\n          id: 'atc-old2',\n          name: 'attachmentName',\n          path: 'attachmentPath',\n          token: 'attachmentToken',\n          size: 100,\n          mimetype: 'image/jpeg',\n        },\n      ];\n      const records: IChangeRecord[] = [\n        {\n          id: 'record1',\n          fields: {\n            field1: {\n              newValue: mockAttachmentCellValue.slice(0, 1),\n              oldValue: mockOldAttachmentCellValue.slice(0, 1),\n            },\n          },\n        },\n        {\n          id: 'record2',\n          fields: {\n            field2: {\n              newValue: mockAttachmentCellValue.slice(1),\n              oldValue: mockOldAttachmentCellValue.slice(1),\n            },\n          },\n        },\n      ];\n\n      vi.spyOn(service as any, 'getAttachmentFields').mockResolvedValue(mockAttachmentFields);\n      vi.spyOn(service, 'delete').mockResolvedValue();\n\n      await service.updateRecords(userId, tableId, records);\n\n      expect(prismaService.txClient().attachmentsTable.createMany).toBeCalled();\n      expect(service.delete).toHaveBeenCalledWith([\n        {\n          tableId,\n          recordId: 'record1',\n          fieldId: 'field1',\n          attachmentId: 'atc-old1',\n        },\n        {\n          tableId,\n          recordId: 'record2',\n          fieldId: 'field2',\n          attachmentId: 'atc-old2',\n        },\n      ]);\n    });\n  });\n\n  describe('delete', () => {\n    const queries = [\n      {\n        tableId: 'tableId',\n        recordId: 'recordId',\n        fieldId: 'fieldId',\n        attachmentId: 'attachmentId',\n      },\n    ];\n\n    it('should delete records', async () => {\n      await service.delete(queries);\n      expect(prismaService.attachmentsTable.deleteMany).toBeCalledTimes(queries.length);\n    });\n\n    it('should throw error if updateMany fails', async () => {\n      (prismaService.attachmentsTable.deleteMany as Mock).mockRejectedValueOnce(\n        new Error(updateManyError)\n      );\n      await expect(service.delete(queries)).rejects.toThrow(updateManyError);\n      expect(prismaService.attachmentsTable.deleteMany).toBeCalled();\n    });\n  });\n\n  describe('deleteRecords', () => {\n    it('should delete attachments for specified records', async () => {\n      // Mock data\n      const tableId = 'table123';\n      const recordIds = ['record1', 'record2'];\n\n      // Call the method\n      await service.deleteRecords(tableId, recordIds);\n\n      // Verify that Prisma method was called with the correct parameters\n      expect(prismaService.txClient().attachmentsTable.deleteMany).toHaveBeenCalledWith({\n        where: { tableId, recordId: { in: recordIds } },\n      });\n    });\n\n    // Add more test cases for different scenarios\n  });\n\n  describe('deleteFields', () => {\n    it('should delete attachments for specified fields', async () => {\n      // Mock data\n      const tableId = 'table123';\n      const fieldIds = ['field1', 'field2'];\n\n      // Call the method\n      await service.deleteFields(tableId, fieldIds);\n\n      // Verify that Prisma method was called with the correct parameters\n      expect(prismaService.txClient().attachmentsTable.deleteMany).toHaveBeenCalledWith({\n        where: { tableId, fieldId: { in: fieldIds } },\n      });\n    });\n  });\n\n  describe('deleteTable', () => {\n    it('should delete all attachments for the specified table', async () => {\n      // Mock data\n      const tableId = 'table123';\n\n      // Call the method\n      await service.deleteTable(tableId);\n\n      // Verify that Prisma method was called with the correct parameters\n      expect(prismaService.txClient().attachmentsTable.deleteMany).toHaveBeenCalledWith({\n        where: { tableId },\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments-table.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport type { IAttachmentCellValue, IRecord } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport type { IChangeRecord } from '../../event-emitter/events';\n\n@Injectable()\nexport class AttachmentsTableService {\n  constructor(private readonly prismaService: PrismaService) {}\n\n  private createUniqueKey(\n    tableId: string,\n    fieldId: string,\n    recordId: string,\n    attachmentId: string\n  ) {\n    return `${tableId}-${fieldId}-${recordId}-${attachmentId}`;\n  }\n\n  private async getAttachmentFields(tableId: string) {\n    return await this.prismaService.txClient().field.findMany({\n      where: { tableId, type: FieldType.Attachment, isLookup: null, deletedTime: null },\n      select: { id: true },\n    });\n  }\n\n  async createRecords(userId: string, tableId: string, records: IRecord[]) {\n    const fieldRaws = await this.getAttachmentFields(tableId);\n    const newAttachments: Prisma.AttachmentsTableCreateInput[] = [];\n    records.forEach((record) => {\n      const { id: recordId, fields } = record;\n      fieldRaws.forEach(({ id }) => {\n        const attachments = fields[id] as IAttachmentCellValue;\n        attachments?.forEach((attachment) => {\n          newAttachments.push({\n            tableId,\n            recordId,\n            name: attachment.name,\n            fieldId: id,\n            token: attachment.token,\n            attachmentId: attachment.id,\n            createdBy: userId,\n          });\n        });\n      });\n    });\n    if (!newAttachments.length) {\n      return;\n    }\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.attachmentsTable.createMany({\n        data: newAttachments,\n      });\n    });\n  }\n\n  async updateRecords(userId: string, tableId: string, records: IChangeRecord[]) {\n    const fieldRaws = await this.getAttachmentFields(tableId);\n    const newAttachments: Prisma.AttachmentsTableCreateInput[] = [];\n    const needDelete: {\n      tableId: string;\n      fieldId: string;\n      recordId: string;\n      attachmentId: string;\n    }[] = [];\n    records.forEach((record) => {\n      const { id: recordId, fields } = record;\n      fieldRaws.forEach(({ id: fieldId }) => {\n        const { newValue, oldValue } = fields[fieldId] || {};\n        const newAttachmentsValue = newValue as IAttachmentCellValue;\n        const newAttachmentsMap = new Map<string, boolean>();\n        const oldAttachmentsValue = oldValue as IAttachmentCellValue;\n        const oldAttachmentsMap = new Map<string, boolean>();\n        newAttachmentsValue?.forEach((attachment) => {\n          newAttachmentsMap.set(\n            this.createUniqueKey(tableId, fieldId, recordId, attachment.id),\n            true\n          );\n        });\n        oldAttachmentsValue?.forEach((attachment) => {\n          oldAttachmentsMap.set(\n            this.createUniqueKey(tableId, fieldId, recordId, attachment.id),\n            true\n          );\n        });\n        oldAttachmentsValue?.forEach((attachment) => {\n          const uniqueKey = this.createUniqueKey(tableId, fieldId, recordId, attachment.id);\n          if (newAttachmentsMap.has(uniqueKey)) {\n            return;\n          }\n          needDelete.push({\n            tableId,\n            fieldId,\n            recordId,\n            attachmentId: attachment.id,\n          });\n        });\n        newAttachmentsValue?.forEach((attachment) => {\n          const uniqueKey = this.createUniqueKey(tableId, fieldId, recordId, attachment.id);\n          if (oldAttachmentsMap.has(uniqueKey)) {\n            return;\n          } else {\n            newAttachments.push({\n              tableId,\n              recordId,\n              name: attachment.name,\n              fieldId,\n              token: attachment.token,\n              attachmentId: attachment.id,\n              createdBy: userId,\n            });\n          }\n        });\n      });\n    });\n\n    if (!needDelete.length && !newAttachments.length) {\n      return;\n    }\n\n    await this.prismaService.$tx(async (prisma) => {\n      needDelete.length && (await this.delete(needDelete));\n      if (newAttachments.length) {\n        await prisma.attachmentsTable.createMany({\n          data: newAttachments,\n        });\n      }\n    });\n  }\n\n  async delete(\n    query: {\n      tableId: string;\n      recordId: string;\n      fieldId: string;\n      attachmentId?: string;\n    }[]\n  ) {\n    if (!query.length) {\n      return;\n    }\n\n    await this.prismaService.txClient().attachmentsTable.deleteMany({\n      where: { OR: query },\n    });\n  }\n\n  async deleteRecords(tableId: string, recordIds: string[]) {\n    await this.prismaService.txClient().attachmentsTable.deleteMany({\n      where: { tableId, recordId: { in: recordIds } },\n    });\n  }\n\n  async deleteFields(tableId: string, fieldIds: string[]) {\n    await this.prismaService.txClient().attachmentsTable.deleteMany({\n      where: { tableId, fieldId: { in: fieldIds } },\n    });\n  }\n\n  async deleteTable(tableId: string) {\n    await this.prismaService.txClient().attachmentsTable.deleteMany({\n      where: { tableId },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { vi } from 'vitest';\nimport { AttachmentsController } from './attachments.controller';\nimport { AttachmentsModule } from './attachments.module';\n\ndescribe('AttachmentsController', () => {\n  let controller: AttachmentsController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [AttachmentsController],\n      imports: [AttachmentsModule],\n    })\n      .useMocker((token) => {\n        if (token === PrismaService) {\n          return vi.fn();\n        }\n      })\n      .compile();\n\n    controller = module.get<AttachmentsController>(AttachmentsController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments.controller.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport {\n  Body,\n  Controller,\n  Get,\n  Param,\n  Post,\n  Put,\n  Query,\n  Req,\n  Res,\n  StreamableFile,\n  UseGuards,\n} from '@nestjs/common';\nimport { SignatureRo, signatureRoSchema } from '@teable/openapi';\nimport type { INotifyVo, SignatureVo } from '@teable/openapi';\nimport { Response, Request } from 'express';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { AuthGuard } from '../auth/guard/auth.guard';\nimport { AttachmentsService } from './attachments.service';\nimport { DynamicAuthGuardFactory } from './guard/auth.guard';\n\n@Controller('api/attachments')\n@Public()\nexport class AttachmentsController {\n  constructor(private readonly attachmentsService: AttachmentsService) {}\n\n  @Put('/upload/:token')\n  async uploadFilePut(@Req() req: Request, @Param('token') token: string) {\n    await this.attachmentsService.upload(req, token);\n    return null;\n  }\n\n  @Post('/upload/:token')\n  async uploadFilePost(@Req() req: Request, @Param('token') token: string) {\n    await this.attachmentsService.upload(req, token);\n    return null;\n  }\n\n  @Get('/read/:path(*)')\n  async read(\n    @Res({ passthrough: true }) res: Response,\n    @Req() req: Request,\n    @Param('path') path: string,\n    @Query('token') token: string,\n    @Query('response-content-disposition') responseContentDisposition?: string\n  ) {\n    const hasCache = this.attachmentsService.localFileConditionalCaching(path, req.headers, res);\n    if (hasCache) {\n      res.status(304);\n      return;\n    }\n    const { fileStream, headers } = await this.attachmentsService.readLocalFile(path, token);\n    if (responseContentDisposition) {\n      const fileNameMatch =\n        responseContentDisposition.match(/filename\\*=UTF-8''([^;]+)/) ||\n        responseContentDisposition.match(/filename=\"?([^\"]+)\"?/);\n      if (fileNameMatch) {\n        const fileName = fileNameMatch[1] as string;\n        headers['Content-Disposition'] =\n          `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`;\n      } else {\n        headers['Content-Disposition'] = responseContentDisposition;\n      }\n    }\n    headers['Cross-Origin-Resource-Policy'] = 'unsafe-none';\n    headers['Content-Security-Policy'] = '';\n    res.set(headers);\n    return new StreamableFile(fileStream);\n  }\n\n  @UseGuards(AuthGuard, DynamicAuthGuardFactory)\n  @Post('/signature')\n  async signature(\n    @Body(new ZodValidationPipe(signatureRoSchema)) body: SignatureRo\n  ): Promise<SignatureVo> {\n    return await this.attachmentsService.signature(body);\n  }\n\n  @UseGuards(AuthGuard, DynamicAuthGuardFactory)\n  @Post('/notify/:token')\n  async notify(\n    @Param('token') token: string,\n    @Query('filename') filename?: string\n  ): Promise<INotifyVo> {\n    return await this.attachmentsService.notify(token, filename);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AuthModule } from '../auth/auth.module';\nimport { ShareAuthModule } from '../share/share-auth.module';\nimport { AttachmentsCropModule } from './attachments-crop.module';\nimport { AttachmentsStorageModule } from './attachments-storage.module';\nimport { AttachmentsController } from './attachments.controller';\nimport { AttachmentsService } from './attachments.service';\nimport { DynamicAuthGuardFactory } from './guard/auth.guard';\nimport { StorageModule } from './plugins/storage.module';\n\n@Module({\n  providers: [AttachmentsService, DynamicAuthGuardFactory],\n  controllers: [AttachmentsController],\n  imports: [\n    StorageModule,\n    AttachmentsStorageModule,\n    ShareAuthModule,\n    AuthModule,\n    AttachmentsCropModule,\n  ],\n  exports: [AttachmentsService],\n})\nexport class AttachmentsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport { vi } from 'vitest';\nimport { GlobalModule } from '../../global/global.module';\nimport { AttachmentsModule } from './attachments.module';\nimport { AttachmentsService } from './attachments.service';\n\ndescribe('AttachmentsService', () => {\n  let service: AttachmentsService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [AttachmentsModule, GlobalModule],\n    })\n      .useMocker((token) => {\n        if (token === ClsService || token === PrismaService) {\n          return vi.fn();\n        }\n      })\n      .compile();\n\n    service = module.get<AttachmentsService>(AttachmentsService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/attachments.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport fs from 'fs';\nimport type { IncomingHttpHeaders } from 'http';\nimport { tmpdir } from 'os';\nimport { dirname, join } from 'path';\nimport { Readable } from 'stream';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { HttpErrorCode, type IAttachmentItem } from '@teable/core';\nimport { generateAttachmentId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  axios,\n  UploadType,\n  type INotifyVo,\n  type SignatureRo,\n  type SignatureVo,\n} from '@teable/openapi';\nimport type { Request, Response } from 'express';\nimport fse from 'fs-extra';\nimport mimeTypes from 'mime-types';\nimport { nanoid } from 'nanoid';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../cache/cache.service';\nimport { StorageConfig, IStorageConfig } from '../../configs/storage';\nimport { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { FileUtils } from '../../utils';\nimport { second } from '../../utils/second';\nimport { AttachmentsCropQueueProcessor } from './attachments-crop.processor';\nimport { AttachmentsStorageService } from './attachments-storage.service';\nimport StorageAdapter from './plugins/adapter';\nimport type { LocalStorage } from './plugins/local';\nimport { InjectStorageAdapter } from './plugins/storage';\nimport { getExtensionPreview } from './utils';\n@Injectable()\nexport class AttachmentsService {\n  private logger = new Logger(AttachmentsService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly cacheService: CacheService,\n    private readonly attachmentsStorageService: AttachmentsStorageService,\n    private readonly attachmentsCropQueueProcessor: AttachmentsCropQueueProcessor,\n    @StorageConfig() readonly storageConfig: IStorageConfig,\n    @ThresholdConfig() readonly thresholdConfig: IThresholdConfig,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter\n  ) {}\n  /**\n   * Local upload\n   */\n  async upload(req: Request, token: string) {\n    const tokenCache = await this.cacheService.get(`attachment:signature:${token}`);\n    const localStorage = this.storageAdapter as LocalStorage;\n    if (!tokenCache) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const { path, bucket } = tokenCache;\n    const file = await localStorage.saveTemporaryFile(req);\n    await localStorage.validateToken(token, file);\n    const hash = await FileUtils.getHash(file.path);\n    await localStorage.save(file.path, join(bucket, path));\n\n    await this.cacheService.set(\n      `attachment:upload:${token}`,\n      { mimetype: file.mimetype, hash, size: file.size },\n      second(this.storageConfig.tokenExpireIn)\n    );\n  }\n\n  async readLocalFile(path: string, token?: string) {\n    const localStorage = this.storageAdapter as LocalStorage;\n    let respHeaders: Record<string, string> = {};\n\n    if (!path) {\n      throw new CustomHttpException('Could not find attachment', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.notFound',\n        },\n      });\n    }\n    const { bucket, token: tokenInPath } = localStorage.parsePath(path);\n    if (token && !StorageAdapter.isPublicBucket(bucket)) {\n      respHeaders = localStorage.verifyReadToken(token).respHeaders ?? {};\n    } else {\n      const attachment = await this.prismaService\n        .txClient()\n        .attachments.findUnique({ where: { token: tokenInPath, deletedTime: null } });\n      if (!attachment) {\n        throw new CustomHttpException('Invalid path', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.attachment.invalidPath',\n          },\n        });\n      }\n      respHeaders['Content-Type'] = getExtensionPreview(attachment.mimetype);\n    }\n\n    const headers: Record<string, string> = respHeaders ?? {};\n    const fileStream = localStorage.read(path);\n\n    return { headers, fileStream };\n  }\n\n  localFileConditionalCaching(path: string, reqHeaders: IncomingHttpHeaders, res: Response) {\n    const ifModifiedSince = reqHeaders['if-modified-since'];\n    const localStorage = this.storageAdapter as LocalStorage;\n    const lastModifiedTimestamp = localStorage.getLastModifiedTime(path);\n    if (!lastModifiedTimestamp) {\n      throw new CustomHttpException('Could not find attachment', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidPath',\n        },\n      });\n    }\n    // Comparison of accuracy in seconds\n    if (\n      !ifModifiedSince ||\n      Math.floor(new Date(ifModifiedSince).getTime() / 1000) <\n        Math.floor(lastModifiedTimestamp / 1000)\n    ) {\n      res.set('Last-Modified', new Date(lastModifiedTimestamp).toUTCString());\n      return false;\n    }\n    return true;\n  }\n\n  async signature(signatureRo: SignatureRo & { internal?: boolean }): Promise<SignatureVo> {\n    const { type, ...presignedParams } = signatureRo;\n    const contentLength = signatureRo.contentLength;\n    const MAX_FILE_SIZE = this.thresholdConfig.maxAttachmentUploadSize;\n    if (contentLength > MAX_FILE_SIZE) {\n      const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);\n      throw new CustomHttpException(\n        `File size exceeds the maximum limit of ${maxSize} MB`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',\n            context: {\n              maxSize: `${maxSize}MB`,\n            },\n          },\n        }\n      );\n    }\n    const hash = presignedParams.hash;\n    const dir = StorageAdapter.getDir(type);\n    const bucket = StorageAdapter.getBucket(type);\n    const res = await this.storageAdapter.presigned(bucket, dir, {\n      ...presignedParams,\n    });\n    const { path, token } = res;\n    await this.cacheService.set(\n      `attachment:signature:${token}`,\n      { path, bucket, hash },\n      signatureRo.expiresIn ?? second(this.storageConfig.tokenExpireIn)\n    );\n    return res;\n  }\n\n  async notify(token: string, filename?: string): Promise<INotifyVo> {\n    const tokenCache = await this.cacheService.get(`attachment:signature:${token}`);\n    if (!tokenCache) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const userId = this.cls.get('user.id');\n    const { path, bucket } = tokenCache;\n    const { hash, size, mimetype, width, height, url } = await this.storageAdapter.getObjectMeta(\n      bucket,\n      path,\n      token\n    );\n    const attachment = await this.prismaService.txClient().attachments.create({\n      data: {\n        hash,\n        size,\n        mimetype,\n        token,\n        path,\n        width,\n        height,\n        createdBy: userId,\n      },\n      select: {\n        token: true,\n        size: true,\n        mimetype: true,\n        width: true,\n        height: true,\n        path: true,\n      },\n    });\n    await this.attachmentsCropQueueProcessor.queue.add('attachment_crop_image', {\n      token: attachment.token,\n      path: attachment.path,\n      mimetype: attachment.mimetype,\n      height: attachment.height,\n      bucket,\n    });\n    const filenameHeader = filename\n      ? {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,\n        }\n      : {};\n    return {\n      ...attachment,\n      size: Number(attachment.size),\n      width: attachment.width ?? undefined,\n      height: attachment.height ?? undefined,\n      url,\n      presignedUrl: await this.attachmentsStorageService.getPreviewUrlByPath(\n        bucket,\n        path,\n        token,\n        undefined,\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        { 'Content-Type': mimetype, ...filenameHeader }\n      ),\n    };\n  }\n\n  private async notifyToAttachmentItem(token: string, filename: string): Promise<IAttachmentItem> {\n    const notifyVo = await this.notify(token, filename);\n    return {\n      ...notifyVo,\n      id: generateAttachmentId(),\n      name: filename,\n    };\n  }\n\n  async uploadFile(file: Express.Multer.File): Promise<IAttachmentItem> {\n    const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize;\n    if (file.size > MAX_FILE_SIZE) {\n      const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);\n      throw new CustomHttpException(\n        `File size exceeds the maximum limit of ${maxSize} MB`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',\n            context: {\n              maxSize: `${maxSize}MB`,\n            },\n          },\n        }\n      );\n    }\n\n    const contentType =\n      file.mimetype === 'application/octet-stream'\n        ? mimeTypes.lookup(file.originalname) || file.mimetype\n        : file.mimetype;\n    const contentLength = file.size;\n\n    const { token, url } = await this.signature({\n      type: UploadType.Table,\n      contentLength,\n      contentType,\n      internal: true,\n    });\n    const fileStream = Readable.from(file.buffer);\n    const filename = Buffer.from(file.originalname, 'latin1').toString('utf-8');\n    this.logger.log(\n      `Uploading file: ${filename}, size: ${contentLength} bytes, mimetype: ${contentType}`\n    );\n\n    await this.uploadStreamToStorage(url, fileStream, contentType, contentLength);\n\n    return await this.notifyToAttachmentItem(token, filename);\n  }\n\n  async uploadFromLocalFile(filePath: string, filename: string): Promise<IAttachmentItem> {\n    const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize;\n    const stat = await fs.promises.stat(filePath);\n    const contentLength = stat.size;\n\n    if (contentLength > MAX_FILE_SIZE) {\n      const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);\n      throw new CustomHttpException(\n        `File size exceeds the maximum limit of ${maxSize} MB`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',\n            context: {\n              maxSize: `${maxSize}MB`,\n            },\n          },\n        }\n      );\n    }\n\n    const contentType = mimeTypes.lookup(filename) || 'application/octet-stream';\n    const { token, url } = await this.signature({\n      type: UploadType.Table,\n      contentLength,\n      contentType,\n      internal: true,\n    });\n\n    try {\n      await this.uploadStreamToStorage(\n        url,\n        fs.createReadStream(filePath),\n        contentType,\n        contentLength\n      );\n      return await this.notifyToAttachmentItem(token, filename);\n    } finally {\n      await fs.promises.unlink(filePath);\n      // Clean up temp subdirectory (created by email attachment saver)\n      const dir = dirname(filePath);\n      try {\n        await fs.promises.rmdir(dir);\n      } catch {\n        /* directory not empty or already removed */\n      }\n    }\n  }\n\n  async uploadFromUrl(\n    fileUrl: string,\n    uploadType: UploadType = UploadType.Table\n  ): Promise<IAttachmentItem> {\n    const MAX_FILE_SIZE = this.thresholdConfig.maxOpenapiAttachmentUploadSize;\n\n    const { contentLength, contentType, tempFilePath } = await this.getFileInfo(\n      fileUrl,\n      MAX_FILE_SIZE\n    );\n\n    if (contentLength > MAX_FILE_SIZE) {\n      const maxSize = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(2);\n      throw new CustomHttpException(\n        `File size exceeds the maximum limit of ${maxSize} MB`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',\n            context: {\n              maxSize: `${maxSize}MB`,\n            },\n          },\n        }\n      );\n    }\n\n    const filename = this.getFilenameFromUrl(fileUrl);\n    const { token, url } = await this.signature({\n      type: uploadType,\n      contentLength,\n      contentType,\n      internal: true,\n    });\n\n    try {\n      await this.uploadFileContent(url, tempFilePath, contentType, contentLength, fileUrl);\n      return await this.notifyToAttachmentItem(token, filename);\n    } catch (error) {\n      console.error('uploadFromUrl:upload', error);\n      throw new CustomHttpException('Url reject', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.urlReject',\n        },\n      });\n    } finally {\n      if (tempFilePath) {\n        fs.unlinkSync(tempFilePath);\n      }\n    }\n  }\n\n  private async getFileInfo(\n    fileUrl: string,\n    maxFileSize: number\n  ): Promise<{ contentLength: number; contentType: string; tempFilePath: string | null }> {\n    let contentLength: number | undefined;\n    let contentType: string | undefined;\n    let tempFilePath: string | null = null;\n\n    try {\n      const headResponse = await axios.head(fileUrl);\n      contentLength =\n        headResponse.headers['content-length'] && parseInt(headResponse.headers['content-length']);\n      contentType = mimeTypes.lookup(fileUrl) || headResponse.headers['content-type'];\n      this.logger.log(\n        `HEAD request successful. Content-Length: ${contentLength}, Content-Type: ${contentType}`\n      );\n    } catch (error) {\n      console.warn('HEAD request failed, falling back to GET:', error);\n    }\n\n    if (!contentLength) {\n      this.logger.log('Content length not available from HEAD request. Downloading file...');\n      const tempFileName = `temp-${nanoid()}`;\n      tempFilePath = join(tmpdir(), tempFileName);\n\n      const { contentType: contentTypeFromDownLoad } = await this.downloadFile(\n        fileUrl,\n        tempFilePath,\n        maxFileSize\n      );\n      // why do not get from downloadFile function causing mismatch size when call it in different environment.\n      contentLength = fs.statSync(tempFilePath).size;\n      this.logger.log(`File downloaded. Size: ${contentLength} bytes`);\n\n      if (!contentType) {\n        contentType =\n          mimeTypes.lookup(fileUrl) || contentTypeFromDownLoad || 'application/octet-stream';\n      }\n    }\n\n    return {\n      contentLength,\n      contentType: contentType as string,\n      tempFilePath,\n    };\n  }\n\n  private async uploadFileContent(\n    url: string,\n    tempFilePath: string | null,\n    contentType: string,\n    contentLength: number,\n    fileUrl: string\n  ): Promise<void> {\n    if (tempFilePath) {\n      await this.uploadStreamToStorage(\n        url,\n        fs.createReadStream(tempFilePath),\n        contentType,\n        contentLength\n      );\n      this.logger.log('Upload from temporary file completed');\n    } else {\n      this.logger.log(`Downloading and uploading from URL: ${fileUrl}`);\n      const response = await axios.get(fileUrl, { responseType: 'stream' });\n      await this.uploadStreamToStorage(url, response.data, contentType, contentLength);\n    }\n  }\n\n  private async uploadStreamToStorage(\n    url: string,\n    stream: Readable,\n    contentType: string,\n    contentLength: number\n  ): Promise<void> {\n    try {\n      await axios.put(url, stream, {\n        headers: {\n          'Content-Type': contentType,\n          'Content-Length': contentLength,\n        },\n      });\n    } catch (error) {\n      stream.destroy();\n      throw error;\n    }\n  }\n\n  private getFilenameFromUrl(url: string): string {\n    const urlParts = new URL(url);\n    const pathParts = urlParts.pathname.split('/');\n    return pathParts[pathParts.length - 1] || 'downloaded_file';\n  }\n\n  private async downloadFile(\n    url: string,\n    filePath: string,\n    maxSize: number\n  ): Promise<{\n    contentType: string;\n  }> {\n    let downloadedBytes = 0;\n\n    const response = await axios({\n      method: 'get',\n      url: url,\n      responseType: 'stream',\n    });\n\n    return new Promise((resolve, reject) => {\n      const writer = fs.createWriteStream(filePath);\n      const cleanup = () => {\n        writer.removeAllListeners();\n        writer.destroy();\n        response.data?.removeAllListeners();\n        response.data?.destroy?.();\n        fse.removeSync(filePath);\n      };\n      try {\n        response.data.on('data', (chunk: Buffer) => {\n          downloadedBytes += chunk.length;\n          if (downloadedBytes > maxSize) {\n            cleanup();\n            throw new CustomHttpException(\n              `File size exceeds the maximum limit of ${(maxSize / (1024 * 1024)).toFixed(2)} MB`,\n              HttpErrorCode.VALIDATION_ERROR,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.attachment.fileSizeExceedsMaximumLimit',\n                  context: {\n                    maxSize: `${(maxSize / (1024 * 1024)).toFixed(2)}MB`,\n                  },\n                },\n              }\n            );\n          }\n        });\n\n        response.data.on('error', (error: unknown) => {\n          cleanup();\n          reject(error);\n        });\n\n        response.data.pipe(writer);\n\n        writer.on('finish', () => {\n          resolve({\n            contentType: response?.headers?.['content-type'],\n          });\n        });\n        writer.on('error', (error: unknown) => {\n          cleanup();\n          reject(error);\n        });\n      } catch (error) {\n        cleanup();\n        reject(error);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/constant.ts",
    "content": "export const ATTACHMENT_SM_THUMBNAIL_HEIGHT = 56;\nexport const ATTACHMENT_LG_THUMBNAIL_HEIGHT = 525;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/guard/auth.guard.ts",
    "content": "import type { CanActivate, ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { AuthGuard } from '../../auth/guard/auth.guard';\nimport { ShareAuthGuard } from '../../share/guard/auth.guard';\n\n@Injectable()\nexport class DynamicAuthGuardFactory implements CanActivate {\n  constructor(\n    private readonly shareAuthGuard: ShareAuthGuard,\n    private readonly authGuard: AuthGuard,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n  canActivate(context: ExecutionContext) {\n    const shareId = context.switchToHttp().getRequest().headers['tea-share-id'];\n    if (shareId) {\n      this.cls.set('shareViewId', shareId);\n      return this.shareAuthGuard.validate(context, shareId);\n    }\n    return this.authGuard.validate(context);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/adapter.ts",
    "content": "import type { Readable as ReadableStream } from 'node:stream';\nimport { resolve } from 'path';\nimport { BadRequestException } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { UploadType } from '@teable/openapi';\nimport { storageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IObjectMeta, IPresignParams, IPresignRes } from './types';\n\nexport default abstract class StorageAdapter {\n  static readonly TEMPORARY_DIR = resolve(process.cwd(), '.temporary');\n\n  static readonly getBucket = (type: UploadType) => {\n    switch (type) {\n      case UploadType.Table:\n      case UploadType.Import:\n      case UploadType.ExportBase:\n      case UploadType.Comment:\n      case UploadType.App:\n      case UploadType.ChatFile:\n      case UploadType.Automation:\n        return storageConfig().privateBucket;\n      case UploadType.Avatar:\n      case UploadType.OAuth:\n      case UploadType.Form:\n      case UploadType.Plugin:\n      case UploadType.Logo:\n      case UploadType.Template:\n      case UploadType.ChatDataVisualizationCode:\n        return storageConfig().publicBucket;\n      default:\n        throw new CustomHttpException('Invalid upload type', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.attachment.invalidUploadType',\n          },\n        });\n    }\n  };\n\n  static readonly getDir = (type: UploadType): string => {\n    switch (type) {\n      case UploadType.Table:\n        return 'table';\n      case UploadType.Avatar:\n        return 'avatar';\n      case UploadType.Form:\n        return 'form';\n      case UploadType.OAuth:\n        return 'oauth';\n      case UploadType.Import:\n        return 'import';\n      case UploadType.Plugin:\n        return 'plugin';\n      case UploadType.Comment:\n        return 'comment';\n      case UploadType.Logo:\n        return 'logo';\n      case UploadType.ExportBase:\n        return 'export-base';\n      case UploadType.Template:\n        return 'template';\n      case UploadType.ChatDataVisualizationCode:\n        return 'chat-data-visualization-code';\n      case UploadType.App:\n        return 'app';\n      case UploadType.ChatFile:\n        return 'chat-file';\n      case UploadType.Automation:\n        return 'automation';\n      default:\n        throw new CustomHttpException('Invalid upload type', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.attachment.invalidUploadType',\n          },\n        });\n    }\n  };\n\n  static readonly isPublicBucket = (bucket: string) => {\n    return bucket === storageConfig().publicBucket;\n  };\n\n  /**\n   * generate presigned url\n   * @param bucket bucket name\n   * @param dir storage dir\n   * @param params presigned params, limit presigned url upload file\n   * @returns presigned url and upload params\n   */\n  abstract presigned(bucket: string, dir: string, params: IPresignParams): Promise<IPresignRes>;\n\n  /**\n   * get object meta\n   * @param bucket bucket name\n   * @param path path name\n   * @param token presigned token\n   * @returns object meta\n   */\n  abstract getObjectMeta(bucket: string, path: string, token: string): Promise<IObjectMeta>;\n\n  /**\n   * get preview url\n   * @param bucket bucket name\n   * @param path path name\n   * @param respHeaders response headers, example: { 'Content-Type': 'images/png' }\n   */\n  abstract getPreviewUrl(\n    bucket: string,\n    path: string,\n    expiresIn?: number,\n    respHeaders?: {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      [key: string]: any;\n    }\n  ): Promise<string>;\n\n  /**\n   * uploadFile with file path\n   * @param bucket bucket name\n   * @param path path name\n   * @param filePath file path\n   * @param metadata Metadata of the object.\n   */\n  abstract uploadFileWidthPath(\n    bucket: string,\n    path: string,\n    filePath: string,\n    metadata: Record<string, unknown>\n  ): Promise<{ hash: string; path: string }>;\n\n  /**\n   * uploadFile with file stream\n   * @param bucket bucket name\n   * @param path path name\n   * @param stream file stream\n   * @param metadata Metadata of the object.\n   */\n  abstract uploadFile(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    metadata?: Record<string, unknown>\n  ): Promise<{ hash: string; path: string }>;\n\n  abstract uploadFileStream(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    metadata?: Record<string, unknown>\n  ): Promise<{ hash: string; path: string }>;\n\n  /**\n   * cut image\n   * @param bucket bucket name\n   * @param path path name\n   * @param width width\n   * @param height height\n   * @param newPath save as new path\n   * @returns cut image url\n   */\n  abstract cropImage(\n    bucket: string,\n    path: string,\n    width?: number,\n    height?: number,\n    newPath?: string\n  ): Promise<string>;\n\n  abstract downloadFile(bucket: string, path: string): Promise<ReadableStream>;\n\n  abstract deleteDir(bucket: string, path: string, throwError?: boolean): Promise<void>;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/aliyun.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\nimport { Injectable } from '@nestjs/common';\nimport { NodeHttpHandler } from '@smithy/node-http-handler';\nimport { IStorageConfig, StorageConfig } from '../../../configs/storage';\nimport { second } from '../../../utils/second';\nimport type StorageAdapter from './adapter';\nimport { S3Storage } from './s3';\nimport type { IRespHeaders } from './types';\n\n@Injectable()\nexport class AliyunStorage extends S3Storage implements StorageAdapter {\n  private aliyunClient: S3Client;\n\n  constructor(@StorageConfig() readonly config: IStorageConfig) {\n    super(config);\n    const { endpoint, region, accessKey, secretKey, maxSockets } = this.config.s3;\n    const requestHandler = maxSockets\n      ? new NodeHttpHandler({\n          httpsAgent: {\n            maxSockets: maxSockets,\n          },\n        })\n      : undefined;\n    this.aliyunClient = new S3Client({\n      region,\n      endpoint,\n      requestHandler,\n      credentials: {\n        accessKeyId: accessKey,\n        secretAccessKey: secretKey,\n      },\n    });\n  }\n\n  private replacePrivateBucketEndpoint(url: string, bucket: string) {\n    const { privateBucketEndpoint, privateBucket } = this.config;\n    if (privateBucketEndpoint && bucket === privateBucket) {\n      const resUrl = new URL(url);\n      const newUrl = new URL(privateBucketEndpoint);\n      resUrl.protocol = newUrl.protocol;\n      resUrl.hostname = newUrl.hostname;\n      resUrl.port = newUrl.port;\n      return resUrl.toString();\n    }\n    return url;\n  }\n\n  async getPreviewUrl(\n    bucket: string,\n    path: string,\n    expiresIn: number = second(this.config.urlExpireIn),\n    respHeaders?: IRespHeaders\n  ): Promise<string> {\n    const command = new GetObjectCommand({\n      Bucket: bucket,\n      Key: path,\n      ResponseContentDisposition: respHeaders?.['Content-Disposition'],\n    });\n\n    const res = await getSignedUrl(this.aliyunClient, command, {\n      expiresIn: expiresIn ?? second(this.config.tokenExpireIn),\n    });\n    return this.replacePrivateBucketEndpoint(res, bucket);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/local.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport * as fs from 'fs';\nimport { join, resolve } from 'path';\nimport { Test } from '@nestjs/testing';\nimport type { TestingModule } from '@nestjs/testing';\nimport * as fse from 'fs-extra';\nimport { vi } from 'vitest';\nimport { getError } from '../../../../test/utils/get-error';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { IAttachmentLocalTokenCache } from '../../../cache/types';\nimport { baseConfig } from '../../../configs/base.config';\nimport { storageConfig } from '../../../configs/storage';\nimport { GlobalModule } from '../../../global/global.module';\nimport { LocalStorage } from './local';\nimport { StorageModule } from './storage.module';\nimport type { ILocalFileUpload } from './types';\n\nvi.mock('fs-extra');\nvi.mock('fs');\n\ndescribe('LocalStorage', () => {\n  let storage: LocalStorage;\n  const imageType = 'image/png';\n  const imageMeta = {\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    'Content-Type': imageType,\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    'Content-Length': 1024,\n  };\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const mockConfig: any = {\n    local: {\n      path: '/mock/path',\n    },\n    encryption: {\n      algorithm: 'aes-128-cbc',\n      key: '73b00476e456323e',\n      iv: '8c9183e4c175f63c',\n    },\n    tokenExpireIn: '7d',\n    urlExpireIn: '7d',\n  };\n\n  const mockBaseConfig: any = {\n    storagePrefix: 'https://example.com',\n  };\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  const mockRespHeaders = { 'Content-Type': imageType };\n\n  const mockCacheService = {\n    set: vi.fn(),\n    get: vi.fn(),\n  };\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [StorageModule, GlobalModule],\n      providers: [\n        LocalStorage,\n        {\n          provide: CacheService,\n          useValue: mockCacheService,\n        },\n        {\n          provide: storageConfig.KEY,\n          useValue: mockConfig,\n        },\n        {\n          provide: baseConfig.KEY,\n          useValue: mockBaseConfig,\n        },\n      ],\n    }).compile();\n\n    storage = module.get<LocalStorage>(LocalStorage);\n  });\n\n  describe('presigned', () => {\n    it('should generate presigned URL', async () => {\n      const mockDir = '/mock/dir';\n      const mockParams = {\n        contentType: imageType,\n        contentLength: 1024,\n        hash: 'mock-hash',\n      };\n\n      const result = await storage.presigned('bucket', mockDir, mockParams);\n\n      expect(mockCacheService.set).toHaveBeenCalled();\n      expect(result).toHaveProperty('token');\n      expect(result).toHaveProperty('path', '/mock/dir/mock-hash');\n      expect(result).toHaveProperty('url');\n      expect(result).toHaveProperty('uploadMethod', 'PUT');\n      expect(result).toHaveProperty('requestHeaders', imageMeta);\n    });\n  });\n\n  describe('validateToken', () => {\n    const localSignatureCache: IAttachmentLocalTokenCache = {\n      expiresDate: Math.floor(Date.now() / 1000) + 100000,\n      contentLength: imageMeta['Content-Length'],\n      contentType: imageMeta['Content-Type'],\n    };\n    const uploadMeta: ILocalFileUpload = {\n      path: '',\n      size: imageMeta['Content-Length'],\n      mimetype: imageMeta['Content-Type'],\n    };\n    it('should throw BadRequestException for invalid token', async () => {\n      mockCacheService.get.mockResolvedValue(null);\n\n      const error = await getError(() => storage.validateToken('invalid-token', uploadMeta));\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Invalid token');\n      expect(error?.status).toBe(400);\n    });\n\n    it('should throw BadRequestException for expired token', async () => {\n      const expiredTokenMeta = {\n        ...localSignatureCache,\n        expiresDate: 1000,\n      };\n\n      mockCacheService.get.mockResolvedValue(expiredTokenMeta);\n\n      const error = await getError(() => storage.validateToken('expired-token', uploadMeta));\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Token has expired');\n      expect(error?.status).toBe(400);\n    });\n\n    it('should throw BadRequestException for size mismatch', async () => {\n      mockCacheService.get.mockResolvedValue(localSignatureCache);\n\n      const error = await getError(() =>\n        storage.validateToken('valid-token', {\n          ...uploadMeta,\n          size: 2048,\n        })\n      );\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Size mismatch');\n      expect(error?.status).toBe(400);\n    });\n\n    it('should throw BadRequestException for mimetype mismatch', async () => {\n      mockCacheService.get.mockResolvedValue(localSignatureCache);\n\n      const error = await getError(() =>\n        storage.validateToken('valid-token', {\n          ...uploadMeta,\n          mimetype: 'image/jpeg',\n        })\n      );\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Not allow upload image/jpeg file');\n      expect(error?.status).toBe(400);\n    });\n\n    it('should not throw error for valid token', async () => {\n      mockCacheService.get.mockResolvedValue(localSignatureCache);\n\n      await expect(storage.validateToken('valid-token', uploadMeta)).resolves.not.toThrow();\n    });\n  });\n\n  describe('saveTemporaryFile', () => {\n    it('should save temporary file', async () => {\n      const mockRequest = {\n        on: vi.fn(),\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'content-type': imageType,\n        },\n      };\n\n      vi.spyOn(storage as any, 'deleteFile').mockResolvedValueOnce(undefined);\n      vi.spyOn(fs, 'createWriteStream').mockReturnValue({\n        write: vi.fn(),\n        end: vi.fn(),\n        on: vi.fn().mockImplementation((event, callback) => {\n          if (event === 'finish') {\n            callback();\n          }\n        }),\n      } as any);\n      mockRequest.on.mockImplementation((event, callback) => {\n        if (event === 'data') {\n          callback('mock-data');\n        } else if (event === 'end') {\n          callback();\n        }\n      });\n\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const result = await storage.saveTemporaryFile(mockRequest as any);\n\n      expect(result).toHaveProperty('size', 'mock-data'.length);\n      expect(result).toHaveProperty('mimetype', imageType);\n      expect(result).toHaveProperty('path');\n    });\n  });\n\n  describe('save', () => {\n    it('should save file to storage', async () => {\n      const mockFilePath = '/mock/temp/path';\n\n      const mockRename = 'mock-rename.png';\n      const mockDistPath = resolve(storage.storageDir, mockRename);\n      vi.spyOn(fse, 'copy').mockResolvedValueOnce(undefined);\n      vi.spyOn(fs, 'unlinkSync').mockResolvedValueOnce(undefined);\n\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const result = await storage.save(mockFilePath, mockRename);\n\n      expect(fse.copy).toHaveBeenCalledWith(mockFilePath, mockDistPath);\n      expect(fs.unlinkSync).toHaveBeenCalledWith(mockFilePath);\n      expect(result).toBe(join(storage.path, mockRename));\n    });\n  });\n\n  describe('read', () => {\n    it('should create read stream', async () => {\n      const mockPath = '/mock/file/path';\n\n      vi.spyOn(fs, 'createReadStream').mockResolvedValueOnce(undefined as any);\n      storage.read(mockPath);\n      expect(fs.createReadStream).toHaveBeenCalledWith(resolve(storage.storageDir, mockPath));\n    });\n  });\n\n  describe('getFileMate', () => {\n    it('should get file metadata', async () => {\n      const mockPath = '/mock/file/path';\n      vi.mock('sharp', () => {\n        return {\n          default: () => ({\n            metadata: () => ({\n              width: 100,\n              height: 200,\n            }),\n          }),\n        };\n      });\n      const result = await storage.getFileMate(mockPath);\n\n      expect(result).toEqual({ width: 100, height: 200 });\n    });\n  });\n\n  describe('getObject', () => {\n    it('should get object metadata', async () => {\n      const mockBucket = 'mock-bucket';\n      const mockPath = 'mock/file/path';\n      const mockToken = 'mock-token';\n      const mockCacheValue = {\n        mimetype: imageType,\n        hash: 'mock-hash',\n        size: 1024,\n      };\n      const mockUrl = 'url';\n\n      vi.spyOn(mockCacheService, 'get').mockResolvedValueOnce(mockCacheValue);\n      vi.spyOn(storage, 'getFileMate').mockResolvedValueOnce({\n        width: 100,\n        height: 200,\n      });\n      vi.spyOn(storage as any, 'getUrl').mockReturnValue(mockUrl);\n\n      const result = await storage.getObjectMeta(mockBucket, mockPath, mockToken);\n\n      expect(mockCacheService.get).toHaveBeenCalledWith(`attachment:upload:${mockToken}`);\n      expect(storage.getFileMate).toHaveBeenCalledWith(\n        resolve(storage.storageDir, mockBucket, mockPath)\n      );\n      expect(storage['getUrl']).toHaveBeenCalledWith(mockBucket, mockPath, {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        respHeaders: mockRespHeaders,\n        expiresDate: -1,\n      });\n      expect(result).toEqual({\n        hash: 'mock-hash',\n        mimetype: imageType,\n        size: 1024,\n        url: mockUrl,\n        width: 100,\n        height: 200,\n      });\n    });\n\n    it('should get object metadata not image', async () => {\n      const mockBucket = 'mock-bucket';\n      const mockPath = 'mock/file/path';\n      const mockToken = 'mock-token';\n      const mockCacheValue = {\n        mimetype: 'text/plain',\n        hash: 'mock-hash',\n        size: 1024,\n      };\n      const mockUrl = 'url';\n\n      vi.spyOn(mockCacheService, 'get').mockResolvedValueOnce(mockCacheValue);\n      vi.spyOn(storage as any, 'getUrl').mockReturnValue(mockUrl);\n\n      const result = await storage.getObjectMeta(mockBucket, mockPath, mockToken);\n\n      expect(mockCacheService.get).toHaveBeenCalledWith(`attachment:upload:${mockToken}`);\n      expect(storage['getUrl']).toHaveBeenCalledWith(mockBucket, mockPath, {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        respHeaders: { 'Content-Type': 'text/plain' },\n        expiresDate: -1,\n      });\n      expect(result).toEqual({\n        hash: 'mock-hash',\n        mimetype: 'text/plain',\n        size: 1024,\n        url: mockUrl,\n      });\n    });\n\n    it('should throw BadRequestException for invalid token', async () => {\n      vi.spyOn(mockCacheService, 'get').mockResolvedValueOnce(null);\n\n      const error = await getError(() =>\n        storage.getObjectMeta('mock-bucket', 'mock/file/path', 'invalid-token')\n      );\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Invalid token');\n      expect(error?.status).toBe(400);\n    });\n  });\n\n  describe('getPreviewUrl', () => {\n    it('should get preview URL', async () => {\n      const mockBucket = 'mock-bucket';\n      const mockPath = 'mock/file/path';\n      const mockExpiresIn = 3600;\n\n      vi.spyOn(storage.expireTokenEncryptor, 'encrypt').mockReturnValueOnce('mock-token');\n\n      const result = await storage.getPreviewUrl(\n        mockBucket,\n        mockPath,\n        mockExpiresIn,\n        mockRespHeaders\n      );\n\n      expect(storage.expireTokenEncryptor.encrypt).toHaveBeenCalledWith({\n        expiresDate: Math.floor(Date.now() / 1000) + mockExpiresIn,\n        respHeaders: mockRespHeaders,\n      });\n      expect(result).toBe('/api/attachments/read/mock-bucket/mock/file/path?token=mock-token');\n    });\n  });\n\n  describe('verifyReadToken', () => {\n    const expiresDate = Math.floor(Date.now() / 1000) + 100000;\n    it('should verify read token', () => {\n      vi.spyOn(storage.expireTokenEncryptor, 'decrypt').mockReturnValueOnce({\n        expiresDate,\n        respHeaders: mockRespHeaders,\n      });\n\n      const result = storage.verifyReadToken('mock-token');\n\n      expect(storage.expireTokenEncryptor.decrypt).toHaveBeenCalledWith('mock-token');\n\n      expect(result).toEqual({\n        respHeaders: mockRespHeaders,\n      });\n    });\n\n    it('should throw BadRequestException for expired token', async () => {\n      vi.spyOn(storage.expireTokenEncryptor, 'decrypt').mockReturnValueOnce({\n        expiresDate: 1,\n      });\n\n      const error = await getError(() => storage.verifyReadToken('expired-token'));\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Token has expired');\n      expect(error?.status).toBe(400);\n    });\n\n    it('should throw BadRequestException for invalid token', async () => {\n      vi.spyOn(storage.expireTokenEncryptor, 'decrypt').mockImplementationOnce(() => {\n        throw new Error();\n      });\n\n      const error = await getError(() => storage.verifyReadToken('invalid-token'));\n      expect(error).toBeDefined();\n      expect(error?.message).toBe('Invalid token');\n      expect(error?.status).toBe(400);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/local.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { createReadStream, createWriteStream, unlinkSync, existsSync, rmSync } from 'fs';\nimport { type Readable as ReadableStream } from 'node:stream';\nimport { join, resolve } from 'path';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { getRandomString, HttpErrorCode } from '@teable/core';\nimport { READ_PATH } from '@teable/openapi';\nimport type { Request } from 'express';\nimport * as fse from 'fs-extra';\nimport { ClsService } from 'nestjs-cls';\nimport sharp from 'sharp';\nimport { CacheService } from '../../../cache/cache.service';\nimport { BaseConfig, IBaseConfig } from '../../../configs/base.config';\nimport { IStorageConfig, StorageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { FileUtils } from '../../../utils';\nimport { Encryptor } from '../../../utils/encryptor';\nimport { second } from '../../../utils/second';\nimport StorageAdapter from './adapter';\nimport type { ILocalFileUpload, IObjectMeta, IPresignParams, IRespHeaders } from './types';\n\ninterface ITokenEncryptor {\n  expiresDate: number;\n  respHeaders?: IRespHeaders;\n}\n\n@Injectable()\nexport class LocalStorage implements StorageAdapter {\n  private logger = new Logger(LocalStorage.name);\n  path: string;\n  storageDir: string;\n  expireTokenEncryptor: Encryptor<ITokenEncryptor>;\n  static readPath = READ_PATH;\n\n  constructor(\n    @StorageConfig() readonly config: IStorageConfig,\n    @BaseConfig() readonly baseConfig: IBaseConfig,\n    private readonly cacheService: CacheService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    this.expireTokenEncryptor = new Encryptor(this.config.encryption);\n    this.path = this.config.local.path;\n    this.storageDir = resolve(process.cwd(), this.path);\n    fse.ensureDirSync(StorageAdapter.TEMPORARY_DIR);\n    fse.ensureDirSync(this.storageDir);\n  }\n\n  private getUploadUrl(token: string, internal?: boolean) {\n    const baseUrl = internal ? `http://localhost:${process.env.PORT}` : '';\n    return `${baseUrl}/api/attachments/upload/${token}`;\n  }\n\n  private deleteFile(filePath: string) {\n    try {\n      unlinkSync(filePath);\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      if (error?.code === 'ENOENT') {\n        return;\n      }\n      throw error;\n    }\n  }\n\n  private getUrl(bucket: string, path: string, params: ITokenEncryptor) {\n    const token = this.expireTokenEncryptor.encrypt(params);\n    const responseContentDisposition = params.respHeaders?.['Content-Disposition'];\n    return `${join(LocalStorage.readPath, bucket, path)}?token=${token}${responseContentDisposition ? `&response-content-disposition=${responseContentDisposition}` : ''}`;\n  }\n\n  parsePath(path: string) {\n    const parts = path.split('/');\n    return {\n      bucket: parts[0],\n      token: parts[parts.length - 1],\n    };\n  }\n\n  async presigned(_bucket: string, dir: string, params: IPresignParams) {\n    const { contentType, contentLength, hash, internal } = params;\n    const token = getRandomString(12);\n    const filename = hash ?? token;\n    const expiresIn = params?.expiresIn ?? second(this.config.tokenExpireIn);\n    await this.cacheService.set(\n      `attachment:local-signature:${token}`,\n      {\n        expiresDate: Math.floor(Date.now() / 1000) + expiresIn,\n        contentLength,\n        contentType,\n      },\n      expiresIn\n    );\n\n    const path = join(dir, filename);\n    return {\n      token,\n      path,\n      url: this.getUploadUrl(token, internal),\n      uploadMethod: 'PUT',\n      requestHeaders: {\n        'Content-Type': contentType,\n        'Content-Length': contentLength,\n      },\n    };\n  }\n\n  async validateToken(token: string, file: ILocalFileUpload) {\n    const validateMeta = await this.cacheService.get(`attachment:local-signature:${token}`);\n    if (!validateMeta) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const { expiresDate, contentLength, contentType } = validateMeta;\n\n    const { size, mimetype } = file;\n    if (Math.floor(Date.now() / 1000) > expiresDate) {\n      throw new CustomHttpException('Token has expired', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.tokenExpired',\n        },\n      });\n    }\n    if (contentLength && contentLength !== size) {\n      throw new CustomHttpException('Size mismatch', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.sizeMismatch',\n        },\n      });\n    }\n    if (mimetype && mimetype !== contentType) {\n      throw new CustomHttpException(\n        `Not allow upload ${mimetype} file`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.notAllowUploadFileType',\n            context: {\n              mimetype,\n            },\n          },\n        }\n      );\n    }\n  }\n\n  async saveTemporaryFile(req: Request) {\n    const name = getRandomString(12);\n    const path = resolve(StorageAdapter.TEMPORARY_DIR, name);\n    let size = 0;\n    return new Promise<ILocalFileUpload>((resolve, reject) => {\n      try {\n        const fileStream = createWriteStream(path);\n        req.on('data', (chunk) => {\n          fileStream.write(chunk);\n          size += chunk.length;\n        });\n\n        req.on('end', () => {\n          fileStream.end();\n        });\n        req.on('error', (err) => {\n          fileStream.end();\n          reject(err.message);\n        });\n\n        fileStream.on('error', (err) => {\n          reject(err.message);\n        });\n\n        fileStream.on('finish', () => {\n          resolve({\n            size,\n            mimetype: req.headers['content-type'] as string,\n            path,\n          });\n        });\n      } catch (error) {\n        this.logger.error('saveTemporaryFile error', error);\n        this.deleteFile(path);\n        reject(error);\n      }\n    });\n  }\n\n  async save(filePath: string, rename: string, isDelete: boolean = true) {\n    const distPath = resolve(this.storageDir);\n    const newFilePath = resolve(distPath, rename);\n    await fse.copy(filePath, newFilePath);\n    if (isDelete) {\n      this.deleteFile(filePath);\n    }\n    return join(this.path, rename);\n  }\n\n  read(path: string) {\n    return createReadStream(resolve(this.storageDir, path));\n  }\n\n  getLastModifiedTime(path: string) {\n    const url = resolve(this.storageDir, path);\n    if (!fse.existsSync(url)) {\n      return;\n    }\n    return fse.statSync(url).mtimeMs;\n  }\n\n  async getFileMate(path: string) {\n    try {\n      const info = await sharp(path).metadata();\n      return {\n        width: info.width,\n        height: info.height,\n      };\n    } catch (error) {\n      return {};\n    }\n  }\n\n  async getObjectMeta(bucket: string, path: string, token: string): Promise<IObjectMeta> {\n    const uploadCache = await this.cacheService.get(`attachment:upload:${token}`);\n    if (!uploadCache) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const { mimetype, hash, size } = uploadCache;\n\n    const meta = {\n      hash,\n      mimetype,\n      size,\n      url: this.getUrl(bucket, path, {\n        respHeaders: { 'Content-Type': mimetype },\n        expiresDate: -1,\n      }),\n    };\n\n    if (!mimetype?.startsWith('image/')) {\n      return meta;\n    }\n    return {\n      ...meta,\n      ...(await this.getFileMate(resolve(this.storageDir, bucket, path))),\n    };\n  }\n\n  async getPreviewUrl(\n    bucket: string,\n    path: string,\n    expiresIn: number = second(this.config.urlExpireIn),\n    respHeaders?: IRespHeaders\n  ): Promise<string> {\n    const url = this.getUrl(bucket, path, {\n      expiresDate: Math.floor(Date.now() / 1000) + expiresIn,\n      respHeaders,\n    });\n    const origin = this.cls.get('origin');\n    const prefix = origin?.byApi ? this.baseConfig.storagePrefix : '';\n    return prefix + join('/', url);\n  }\n\n  verifyReadToken(token: string) {\n    let payload: ITokenEncryptor;\n    try {\n      payload = this.expireTokenEncryptor.decrypt(token);\n    } catch (error) {\n      throw new CustomHttpException('Invalid token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidToken',\n        },\n      });\n    }\n    const { expiresDate, respHeaders } = payload;\n    if (expiresDate > 0 && Math.floor(Date.now() / 1000) > expiresDate) {\n      throw new CustomHttpException('Token has expired', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.tokenExpired',\n        },\n      });\n    }\n    return { respHeaders };\n  }\n\n  async uploadFileWidthPath(\n    bucket: string,\n    path: string,\n    filePath: string,\n    _metadata: Record<string, unknown>\n  ) {\n    const hash = await FileUtils.getHash(filePath);\n    await this.save(filePath, join(bucket, path), false);\n    return {\n      hash,\n      path,\n    };\n  }\n\n  async uploadFile(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    _metadata?: Record<string, unknown>\n  ) {\n    const name = getRandomString(12);\n    const temPath = resolve(StorageAdapter.TEMPORARY_DIR, name);\n    if (stream instanceof Buffer) {\n      await fse.writeFile(temPath, stream);\n    } else {\n      const writer = createWriteStream(temPath);\n      await new Promise<void>((resolve, reject) => {\n        stream.pipe(writer);\n        stream.on('error', reject);\n        writer.on('finish', resolve);\n        writer.on('error', reject);\n      }).catch((err) => {\n        this.deleteFile(temPath);\n        throw err;\n      });\n    }\n    const hash = await FileUtils.getHash(temPath);\n    await this.save(temPath, join(bucket, path));\n    return {\n      hash,\n      path,\n    };\n  }\n\n  async uploadFileStream(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    _metadata?: Record<string, unknown>\n  ) {\n    return await this.uploadFile(bucket, path, stream, _metadata);\n  }\n\n  async cropImage(\n    bucket: string,\n    path: string,\n    width?: number,\n    height?: number,\n    _newPath?: string\n  ) {\n    const newPath = _newPath || `${path}_${width ?? 0}_${height ?? 0}`;\n    const resizedImagePath = resolve(this.storageDir, bucket, newPath);\n    if (fse.existsSync(resizedImagePath)) {\n      return newPath;\n    }\n\n    const imagePath = resolve(this.storageDir, bucket, path);\n    const image = sharp(imagePath, { failOn: 'none', unlimited: true });\n    const metadata = await image.metadata();\n    if (!metadata.width || !metadata.height) {\n      throw new CustomHttpException('Invalid image', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidImage',\n        },\n      });\n    }\n    const resizedImage = image.resize(width, height);\n    await resizedImage.toFile(resizedImagePath);\n    return newPath;\n  }\n\n  async downloadFile(bucket: string, path: string): Promise<ReadableStream> {\n    return createReadStream(resolve(this.storageDir, bucket, path));\n  }\n\n  async deleteDir(bucket: string, path: string, throwError: boolean = true) {\n    const dirPath = resolve(this.storageDir, bucket, path);\n    try {\n      if (existsSync(dirPath)) {\n        rmSync(dirPath, { recursive: true, force: true });\n      } else {\n        this.logger.error('delete dir failed: no such dir', dirPath);\n      }\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      if (error?.code === 'ENOENT' || !throwError) {\n        return;\n      }\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/minio.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Readable as ReadableStream } from 'node:stream';\nimport { join, resolve } from 'path';\nimport { BadRequestException, Injectable } from '@nestjs/common';\nimport { getRandomString, HttpErrorCode } from '@teable/core';\nimport * as fse from 'fs-extra';\nimport * as minio from 'minio';\nimport sharp from 'sharp';\nimport { IStorageConfig, StorageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { second } from '../../../utils/second';\nimport StorageAdapter from './adapter';\nimport type { IPresignParams, IPresignRes, IRespHeaders } from './types';\n\n@Injectable()\nexport class MinioStorage implements StorageAdapter {\n  minioClient: minio.Client;\n  minioClientPrivateNetwork: minio.Client;\n\n  constructor(@StorageConfig() readonly config: IStorageConfig) {\n    const { endPoint, internalEndPoint, internalPort, port, useSSL, accessKey, secretKey, region } =\n      this.config.minio;\n    this.minioClient = new minio.Client({\n      endPoint: endPoint!,\n      port: port!,\n      useSSL: useSSL!,\n      accessKey: accessKey!,\n      secretKey: secretKey!,\n      region: region,\n    });\n    this.minioClientPrivateNetwork = internalEndPoint\n      ? new minio.Client({\n          endPoint: internalEndPoint,\n          port: internalPort,\n          useSSL: false,\n          accessKey: accessKey!,\n          secretKey: secretKey!,\n          region: region,\n        })\n      : this.minioClient;\n    fse.ensureDirSync(StorageAdapter.TEMPORARY_DIR);\n  }\n\n  async presigned(\n    bucket: string,\n    dir: string,\n    presignedParams: IPresignParams\n  ): Promise<IPresignRes> {\n    const { tokenExpireIn, uploadMethod } = this.config;\n    const { expiresIn, contentLength, contentType, hash, internal } = presignedParams;\n    const token = getRandomString(12);\n    const filename = hash ?? token;\n    const path = join(dir, filename);\n    const requestHeaders = {\n      'Content-Type': contentType,\n      'Content-Length': contentLength,\n      'response-cache-control': 'max-age=31536000, immutable',\n    };\n    try {\n      const client = internal ? this.minioClientPrivateNetwork : this.minioClient;\n      const url = await client.presignedUrl(\n        uploadMethod,\n        bucket,\n        path,\n        expiresIn ?? second(tokenExpireIn),\n        requestHeaders\n      );\n      return {\n        url,\n        path,\n        token,\n        uploadMethod,\n        requestHeaders,\n      };\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `Minio presigned error${e?.message ? `: ${e.message}` : ''}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.presignedError',\n          },\n        }\n      );\n    }\n  }\n\n  private async getShape(bucket: string, objectName: string) {\n    const stream = await this.minioClientPrivateNetwork.getObject(bucket, objectName);\n    try {\n      const metaReader = sharp();\n      const sharpReader = stream.pipe(metaReader);\n      const { width, height } = await sharpReader.metadata();\n\n      return {\n        width,\n        height,\n      };\n    } catch (e) {\n      return {};\n    } finally {\n      stream.removeAllListeners();\n      stream.destroy();\n    }\n  }\n\n  async getObjectMeta(bucket: string, path: string, _token: string) {\n    const objectName = path;\n    const {\n      metaData,\n      size,\n      etag: hash,\n    } = await this.minioClientPrivateNetwork.statObject(bucket, objectName);\n    const mimetype = metaData['content-type'] as string;\n    const url = `/${bucket}/${objectName}`;\n    if (!mimetype?.startsWith('image/')) {\n      return {\n        hash,\n        size,\n        mimetype,\n        url,\n      };\n    }\n    const sharpMeta = await this.getShape(bucket, objectName);\n    return {\n      ...sharpMeta,\n      hash,\n      size,\n      mimetype,\n      url,\n    };\n  }\n\n  async getPreviewUrl(\n    bucket: string,\n    path: string,\n    expiresIn: number = second(this.config.urlExpireIn),\n    respHeaders?: IRespHeaders\n  ) {\n    const { 'Content-Disposition': contentDisposition, ...headers } = respHeaders ?? {};\n    return this.minioClient.presignedGetObject(bucket, path, expiresIn, {\n      ...headers,\n      'response-content-disposition': contentDisposition,\n    });\n  }\n\n  async uploadFileWidthPath(\n    bucket: string,\n    path: string,\n    filePath: string,\n    metadata: Record<string, string | number>\n  ) {\n    const { etag: hash } = await this.minioClientPrivateNetwork.fPutObject(\n      bucket,\n      path,\n      filePath,\n      metadata\n    );\n    return {\n      hash,\n      path,\n    };\n  }\n\n  async uploadFile(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    metadata: Record<string, string | number>\n  ) {\n    const { etag: hash } = await this.minioClientPrivateNetwork.putObject(\n      bucket,\n      path,\n      stream,\n      undefined,\n      metadata\n    );\n    return {\n      hash,\n      path,\n    };\n  }\n\n  async uploadFileStream(\n    bucket: string,\n    path: string,\n    stream: Buffer | ReadableStream,\n    metadata: Record<string, string | number>\n  ) {\n    return await this.uploadFile(bucket, path, stream, metadata);\n  }\n\n  // minio file exists\n  private async fileExists(bucket: string, path: string) {\n    try {\n      await this.minioClientPrivateNetwork.statObject(bucket, path);\n      return true;\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (err: any) {\n      if (err.code === 'NoSuchKey' || err.code === 'NotFound') {\n        return false;\n      }\n      throw err;\n    }\n  }\n\n  async cropImage(\n    bucket: string,\n    path: string,\n    width?: number,\n    height?: number,\n    _newPath?: string\n  ) {\n    const newPath = _newPath || `${path}_${width ?? 0}_${height ?? 0}`;\n    const resizedImagePath = resolve(\n      StorageAdapter.TEMPORARY_DIR,\n      encodeURIComponent(join(bucket, newPath))\n    );\n    if (await this.fileExists(bucket, newPath)) {\n      return newPath;\n    }\n\n    const objectName = path;\n    const { metaData } = await this.minioClientPrivateNetwork.statObject(bucket, objectName);\n    const mimetype = metaData['content-type'] as string;\n    if (!mimetype?.startsWith('image/')) {\n      throw new CustomHttpException('Invalid image', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidImage',\n        },\n      });\n    }\n    const sourceFilePath = resolve(StorageAdapter.TEMPORARY_DIR, encodeURIComponent(path));\n    // stream save in sourceFilePath\n    const writeStream = fse.createWriteStream(sourceFilePath);\n    try {\n      await new Promise((resolve, reject) => {\n        this.minioClientPrivateNetwork\n          .getObject(bucket, objectName)\n          .then((stream) => {\n            stream.pipe(writeStream);\n            writeStream.on('finish', () => resolve(null));\n            writeStream.on('error', reject);\n            stream.on('error', reject);\n          })\n          .catch(reject);\n      });\n    } catch (e) {\n      fse.removeSync(sourceFilePath);\n      throw e;\n    } finally {\n      writeStream.removeAllListeners();\n      writeStream.destroy();\n    }\n    const metaReader = sharp(sourceFilePath, { failOn: 'none', unlimited: true }).resize(\n      width,\n      height\n    );\n    await metaReader.toFile(resizedImagePath);\n    // delete source file\n    fse.removeSync(sourceFilePath);\n\n    const upload = await this.uploadFileWidthPath(bucket, newPath, resizedImagePath, {\n      'Content-Type': mimetype,\n    });\n    // delete resized image\n    fse.removeSync(resizedImagePath);\n    return upload.path;\n  }\n\n  async downloadFile(bucket: string, path: string): Promise<ReadableStream> {\n    return this.minioClientPrivateNetwork.getObject(bucket, path);\n  }\n\n  async deleteDir(bucket: string, path: string, throwError: boolean = true): Promise<void> {\n    try {\n      const prefix = path.endsWith('/') ? path : `${path}/`;\n\n      const objectsList: string[] = [];\n      const objectsStream = this.minioClientPrivateNetwork.listObjects(bucket, prefix, true);\n\n      await new Promise((resolve, reject) => {\n        objectsStream.on('data', (obj) => {\n          if (obj.name) {\n            objectsList.push(obj.name);\n          }\n        });\n\n        objectsStream.on('end', resolve);\n        objectsStream.on('error', reject);\n      });\n\n      if (objectsList.length === 0) {\n        return;\n      }\n\n      await this.minioClientPrivateNetwork.removeObjects(bucket, objectsList);\n    } catch (error) {\n      if (!throwError) {\n        return;\n      }\n      throw new CustomHttpException(\n        `Failed to delete directory \"${path}\" in bucket \"${bucket}\": ${error instanceof Error ? error.message : 'Unknown error'}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.failedToDeleteDirectory',\n          },\n        }\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/s3.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport https from 'https';\nimport { join, resolve } from 'path';\nimport type { Readable } from 'stream';\nimport {\n  DeleteObjectsCommand,\n  GetObjectCommand,\n  HeadObjectCommand,\n  ListObjectsV2Command,\n  PutObjectCommand,\n  S3Client,\n} from '@aws-sdk/client-s3';\nimport { Upload } from '@aws-sdk/lib-storage';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { NodeHttpHandler } from '@smithy/node-http-handler';\nimport { getRandomString, HttpErrorCode } from '@teable/core';\nimport * as fse from 'fs-extra';\nimport ms from 'ms';\nimport sharp from 'sharp';\nimport { IStorageConfig, StorageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { second } from '../../../utils/second';\nimport StorageAdapter from './adapter';\nimport type { IPresignParams, IPresignRes, IObjectMeta, IRespHeaders } from './types';\n\n@Injectable()\nexport class S3Storage implements StorageAdapter {\n  private s3Client: S3Client;\n  private s3ClientPrivateNetwork: S3Client;\n  private httpsAgent: https.Agent;\n  private s3ClientPreSigner: S3Client;\n  private logger = new Logger(S3Storage.name);\n\n  constructor(@StorageConfig() readonly config: IStorageConfig) {\n    const { endpoint, region, accessKey, secretKey, maxSockets } = this.config.s3;\n    this.checkConfig();\n    this.httpsAgent = new https.Agent({\n      maxSockets,\n      keepAlive: true,\n    });\n    const requestHandler = maxSockets\n      ? new NodeHttpHandler({\n          httpsAgent: this.httpsAgent,\n        })\n      : undefined;\n    this.s3Client = new S3Client({\n      region,\n      endpoint,\n      requestHandler,\n      credentials: {\n        accessKeyId: accessKey,\n        secretAccessKey: secretKey,\n      },\n    });\n    this.s3ClientPrivateNetwork = this.s3Client;\n    fse.ensureDirSync(StorageAdapter.TEMPORARY_DIR);\n\n    this.s3ClientPreSigner = this.config.privateBucketEndpoint\n      ? new S3Client({\n          region,\n          endpoint,\n          bucketEndpoint: true,\n          requestHandler,\n          credentials: {\n            accessKeyId: accessKey,\n            secretAccessKey: secretKey,\n          },\n        })\n      : this.s3Client;\n\n    const logS3ConnectionsRate = Number(process.env.LOG_S3_CONNECTIONS_RATE);\n    if (Number.isNaN(logS3ConnectionsRate)) {\n      this.logger.log('LOG_S3_CONNECTIONS_RATE not set, skipping log');\n      return;\n    }\n    this.logger.log(`Logging S3 connections rate every ${logS3ConnectionsRate} milliseconds`);\n    setInterval(() => {\n      const countRecords: Record<\n        string,\n        { socketsCount: number; freeSocketsCount: number; requestsCount: number }\n      > = {};\n      Object.entries(this.httpsAgent.sockets).forEach(([key, sockets]) => {\n        if (sockets) {\n          const currentCountRecord = countRecords[key] ?? {};\n          countRecords[key] = {\n            ...countRecords[key],\n            socketsCount: (currentCountRecord?.socketsCount ?? 0) + sockets.length,\n          };\n        }\n      });\n      Object.entries(this.httpsAgent.freeSockets).forEach(([key, sockets]) => {\n        if (sockets) {\n          const currentCountRecord = countRecords[key] ?? {};\n          countRecords[key] = {\n            ...countRecords[key],\n            freeSocketsCount: (currentCountRecord?.freeSocketsCount ?? 0) + sockets.length,\n          };\n        }\n      });\n      Object.entries(this.httpsAgent.requests).forEach(([key, requests]) => {\n        if (requests) {\n          const currentCountRecord = countRecords[key] ?? {};\n          countRecords[key] = {\n            ...countRecords[key],\n            requestsCount: (currentCountRecord?.requestsCount ?? 0) + requests.length,\n          };\n        }\n      });\n      this.logger.log(`httpsAgent connections: ${JSON.stringify(countRecords, null, 2)}`);\n    }, logS3ConnectionsRate);\n  }\n\n  private checkConfig() {\n    const { tokenExpireIn } = this.config;\n    if (ms(tokenExpireIn) >= ms('7d')) {\n      throw new CustomHttpException(\n        'Token expire in must be more than 7 days',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.tokenExpireInTooLong',\n          },\n        }\n      );\n    }\n    if (!this.config.s3.region) {\n      throw new CustomHttpException('S3 region is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.s3RegionRequired',\n        },\n      });\n    }\n    if (!this.config.s3.endpoint) {\n      throw new CustomHttpException('S3 endpoint is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.s3EndpointRequired',\n        },\n      });\n    }\n    if (!this.config.s3.accessKey) {\n      throw new CustomHttpException('S3 access key is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.s3AccessKeyRequired',\n        },\n      });\n    }\n    if (!this.config.s3.secretKey) {\n      throw new CustomHttpException('S3 secret key is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.s3SecretKeyRequired',\n        },\n      });\n    }\n    if (this.config.uploadMethod.toLocaleLowerCase() !== 'put') {\n      throw new CustomHttpException(\n        'S3 upload method must be put',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.s3UploadMethodMustBePut',\n          },\n        }\n      );\n    }\n  }\n\n  private replaceBucketEndpoint(bucket: string, internal?: boolean) {\n    const { privateBucketEndpoint, privateBucket } = this.config;\n    if (privateBucketEndpoint && bucket === privateBucket && !internal) {\n      return privateBucketEndpoint;\n    }\n    return bucket;\n  }\n\n  async presigned(bucket: string, dir: string, params: IPresignParams): Promise<IPresignRes> {\n    try {\n      const { tokenExpireIn, uploadMethod } = this.config;\n      const { expiresIn, contentLength, contentType, hash, internal } = params;\n\n      const token = getRandomString(12);\n      const filename = hash ?? token;\n      const path = join(dir, filename);\n\n      const command = new PutObjectCommand({\n        Bucket: bucket,\n        Key: path,\n        ContentType: contentType,\n        ContentLength: contentLength,\n      });\n\n      const url = await getSignedUrl(\n        internal ? this.s3ClientPrivateNetwork : this.s3Client,\n        command,\n        {\n          expiresIn: expiresIn ?? second(tokenExpireIn),\n        }\n      );\n\n      const requestHeaders = {\n        'Content-Type': contentType,\n        'Content-Length': contentLength,\n      };\n\n      return {\n        url,\n        path,\n        token,\n        uploadMethod,\n        requestHeaders,\n      };\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `S3 presigned error${e?.message ? `: ${e.message}` : ''}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.presignedError',\n          },\n        }\n      );\n    }\n  }\n  async getObjectMeta(bucket: string, path: string): Promise<IObjectMeta> {\n    const url = `/${bucket}/${path}`;\n    const command = new HeadObjectCommand({\n      Bucket: bucket,\n      Key: path,\n    });\n    const {\n      ContentLength: size,\n      ContentType: s3Mimetype = 'application/octet-stream',\n      ETag: hash,\n    } = await this.s3ClientPrivateNetwork.send(command);\n    const mimetype = s3Mimetype || 'application/octet-stream';\n    if (!size || !mimetype || !hash) {\n      throw new CustomHttpException('Invalid object meta', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidObjectMeta',\n        },\n      });\n    }\n    if (!mimetype?.startsWith('image/')) {\n      return {\n        hash,\n        size,\n        mimetype,\n        url,\n      };\n    }\n    const metaReader = sharp();\n    const getObjectCommand = new GetObjectCommand({\n      Bucket: bucket,\n      Key: path,\n    });\n    const { Body } = await this.s3ClientPrivateNetwork.send(getObjectCommand);\n    const stream = Body as Readable;\n    if (!stream) {\n      throw new CustomHttpException('Invalid image stream', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidImageStream',\n        },\n      });\n    }\n    try {\n      const sharpReader = stream.pipe(metaReader);\n      const { width, height } = await sharpReader.metadata();\n      return {\n        hash,\n        url,\n        size,\n        mimetype,\n        width,\n        height,\n      };\n    } catch (error) {\n      throw new CustomHttpException(\n        `Calculate image size failed: ${(error as Error).message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.attachment.calculateImageSizeFailed',\n          },\n        }\n      );\n    } finally {\n      stream?.destroy();\n    }\n  }\n  async getPreviewUrl(\n    bucket: string,\n    path: string,\n    expiresIn: number = second(this.config.urlExpireIn),\n    respHeaders?: IRespHeaders\n  ): Promise<string> {\n    const command = new GetObjectCommand({\n      Bucket: this.replaceBucketEndpoint(bucket),\n      Key: path,\n      ResponseContentDisposition: respHeaders?.['Content-Disposition'],\n    });\n\n    return getSignedUrl(this.s3ClientPreSigner, command, {\n      expiresIn: expiresIn ?? second(this.config.tokenExpireIn),\n    });\n  }\n  uploadFileWidthPath(\n    bucket: string,\n    path: string,\n    filePath: string,\n    metadata: Record<string, unknown>\n  ) {\n    const readStream = fse.createReadStream(filePath);\n    const command = new PutObjectCommand({\n      Bucket: bucket,\n      Key: path,\n      Body: readStream,\n      ContentType: metadata['Content-Type'] as string,\n      ContentLength: metadata['Content-Length'] as number,\n      ContentDisposition: metadata['Content-Disposition'] as string,\n      ContentEncoding: metadata['Content-Encoding'] as string,\n      ContentLanguage: metadata['Content-Language'] as string,\n      ContentMD5: metadata['Content-MD5'] as string,\n    });\n    return this.s3ClientPrivateNetwork\n      .send(command)\n      .then((res) => ({\n        hash: res.ETag!,\n        path,\n      }))\n      .finally(() => {\n        readStream.removeAllListeners();\n        readStream.destroy();\n      });\n  }\n\n  uploadFile(\n    bucket: string,\n    path: string,\n    stream: Buffer | Readable,\n    metadata?: Record<string, unknown>\n  ) {\n    return this.uploadFileStream(bucket, path, stream, metadata);\n  }\n\n  async uploadFileStream(\n    bucket: string,\n    path: string,\n    stream: Buffer | Readable,\n    metadata?: Record<string, unknown>\n  ) {\n    const upload = new Upload({\n      client: this.s3ClientPrivateNetwork,\n      params: {\n        Bucket: bucket,\n        Key: path,\n        Body: stream,\n        ContentType: metadata?.['Content-Type'] as string,\n        ContentLength: metadata?.['Content-Length'] as number,\n        ContentDisposition: metadata?.['Content-Disposition'] as string,\n        ContentEncoding: metadata?.['Content-Encoding'] as string,\n        ContentLanguage: metadata?.['Content-Language'] as string,\n        ContentMD5: metadata?.['Content-MD5'] as string,\n      },\n    });\n\n    return upload\n      .done()\n      .then((res) => ({\n        hash: res.ETag!,\n        path,\n      }))\n      .catch((error) => {\n        if (stream && typeof stream !== 'string' && 'destroy' in stream) {\n          (stream as Readable)?.removeAllListeners?.();\n          (stream as Readable)?.destroy?.();\n        }\n        throw new CustomHttpException(\n          `S3 upload failed: ${error?.message || 'Unknown error'}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.attachment.uploadFailed',\n            },\n          }\n        );\n      })\n      .finally(() => {\n        if (stream && typeof stream !== 'string' && 'destroy' in stream) {\n          (stream as Readable)?.removeAllListeners?.();\n          (stream as Readable).destroy?.();\n        }\n      });\n  }\n\n  // s3 file exists\n  private async fileExists(bucket: string, path: string): Promise<boolean> {\n    try {\n      const command = new HeadObjectCommand({\n        Bucket: bucket,\n        Key: path,\n      });\n      await this.s3ClientPrivateNetwork.send(command);\n      return true;\n    } catch (error) {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      if ((error as any).name === 'NotFound') {\n        return false;\n      }\n      throw error;\n    }\n  }\n\n  async cropImage(\n    bucket: string,\n    path: string,\n    width?: number,\n    height?: number,\n    _newPath?: string\n  ) {\n    const newPath = _newPath || `${path}_${width ?? 0}_${height ?? 0}`;\n    const resizedImagePath = resolve(\n      StorageAdapter.TEMPORARY_DIR,\n      encodeURIComponent(join(bucket, newPath))\n    );\n    if (await this.fileExists(bucket, newPath)) {\n      return newPath;\n    }\n    const command = new GetObjectCommand({\n      Bucket: bucket,\n      Key: path,\n    });\n    const { Body: stream, ContentType: mimetype } = await this.s3ClientPrivateNetwork.send(command);\n    if (!mimetype?.startsWith('image/')) {\n      (stream as Readable)?.destroy?.();\n      throw new CustomHttpException('Invalid image', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidImage',\n        },\n      });\n    }\n    if (!stream) {\n      throw new CustomHttpException(\"can't get image stream\", HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.cantGetImageStream',\n        },\n      });\n    }\n    const sourceFilePath = resolve(StorageAdapter.TEMPORARY_DIR, encodeURIComponent(path));\n    await new Promise((resolve, reject) => {\n      const writeStream = fse.createWriteStream(sourceFilePath);\n      (stream as Readable).pipe(writeStream);\n      writeStream.on('finish', () => resolve(null));\n      writeStream.on('error', reject);\n      (stream as Readable).on('error', reject);\n    });\n    const metaReader = sharp(sourceFilePath, { failOn: 'none', unlimited: true }).resize(\n      width,\n      height\n    );\n    await metaReader.toFile(resizedImagePath);\n    fse.removeSync(sourceFilePath);\n    const upload = await this.uploadFileWidthPath(bucket, newPath, resizedImagePath, {\n      'Content-Type': mimetype,\n    });\n    // delete resized image\n    fse.removeSync(resizedImagePath);\n    return upload.path;\n  }\n\n  async downloadFile(bucket: string, path: string): Promise<Readable> {\n    const command = new GetObjectCommand({\n      Bucket: bucket,\n      Key: path,\n    });\n    const { Body: stream } = await this.s3ClientPrivateNetwork.send(command);\n    return stream as Readable;\n  }\n\n  async deleteDir(bucket: string, path: string, throwError: boolean = true) {\n    const prefix = path.endsWith('/') ? path : `${path}/`;\n\n    const { Contents } = await this.s3ClientPrivateNetwork.send(\n      new ListObjectsV2Command({\n        Bucket: bucket,\n        Prefix: prefix,\n      })\n    );\n\n    if (!Contents || Contents.length === 0) return;\n\n    try {\n      await this.s3ClientPrivateNetwork.send(\n        new DeleteObjectsCommand({\n          Bucket: bucket,\n          Delete: {\n            Objects: Contents.map((obj) => ({ Key: obj.Key! })),\n          },\n        })\n      );\n    } catch (error) {\n      if (!throwError) {\n        return;\n      }\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/storage.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { storageAdapterProvider } from './storage';\n\n@Module({\n  providers: [storageAdapterProvider],\n  exports: [storageAdapterProvider],\n})\nexport class StorageModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/storage.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Provider } from '@nestjs/common';\nimport { Inject, Logger } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../../cache/cache.service';\nimport { baseConfig, type IBaseConfig } from '../../../configs/base.config';\nimport type { IStorageConfig } from '../../../configs/storage';\nimport { storageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { AliyunStorage } from './aliyun';\nimport { LocalStorage } from './local';\nimport { MinioStorage } from './minio';\nimport { S3Storage } from './s3';\n\nconst StorageAdapterProvider = Symbol.for('ObjectStorage');\n\nexport const InjectStorageAdapter = () => Inject(StorageAdapterProvider);\n\nexport const storageAdapterProvider: Provider = {\n  provide: StorageAdapterProvider,\n  useFactory: (\n    config: IStorageConfig,\n    baseConfig: IBaseConfig,\n    cacheService: CacheService,\n    cls: ClsService<IClsStore>\n  ) => {\n    Logger.log(`[Storage provider]: ${config.provider}`);\n    switch (config.provider) {\n      case 'local':\n        return new LocalStorage(config, baseConfig, cacheService, cls);\n      case 'minio':\n        return new MinioStorage(config);\n      case 's3':\n        return new S3Storage(config);\n      case 'aliyun':\n        return new AliyunStorage(config);\n      default:\n        throw new CustomHttpException('Invalid storage provider', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.attachment.invalidProvider',\n          },\n        });\n    }\n  },\n  inject: [storageConfig.KEY, baseConfig.KEY, CacheService, ClsService],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/types.ts",
    "content": "export interface IPresignParams {\n  contentType: string;\n  contentLength: number;\n  expiresIn?: number;\n  hash?: string;\n  internal?: boolean;\n}\n\nexport interface IPresignRes {\n  token: string;\n  path: string;\n  url: string;\n  uploadMethod: string;\n  requestHeaders: Record<string, unknown>;\n}\n\nexport interface IObjectMeta {\n  size: number;\n  mimetype: string;\n  hash: string;\n  url: string;\n  width?: number;\n  height?: number;\n}\n\nexport interface ILocalFileUpload {\n  path: string;\n  size: number;\n  mimetype: string;\n}\n\nexport type IRespHeaders = {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  [key: string]: any;\n};\n\nexport enum ThumbnailSize {\n  SM = 'sm',\n  LG = 'lg',\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/plugins/utils.ts",
    "content": "import { getPublicFullStorageUrl as getPublicFullStorageUrlOpenApi } from '@teable/openapi';\nimport { baseConfig } from '../../../configs/base.config';\nimport { storageConfig } from '../../../configs/storage';\nimport type { ThumbnailSize } from './types';\n\n/**\n * public bucket storage url path\n */\nexport const getPublicFullStorageUrl = (path: string) => {\n  const { storagePrefix } = baseConfig();\n  const { provider, publicUrl, publicBucket } = storageConfig();\n\n  return getPublicFullStorageUrlOpenApi(\n    { publicUrl, prefix: storagePrefix, provider, publicBucket },\n    path\n  );\n};\n\nexport const generateCropImagePath = (path: string, size: ThumbnailSize) => {\n  return `${path}_${size}`;\n};\n\n/**\n * resolve storage url to full url\n */\nexport const resolveStorageUrl = (url: string) => {\n  const { storagePrefix } = baseConfig();\n  const { provider } = storageConfig();\n  if (provider === 'local' && storagePrefix) {\n    return new URL(url, storagePrefix).toString();\n  }\n\n  return url;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/attachments/utils.ts",
    "content": "export const getExtensionPreview = (contentType: string) => {\n  const imageExtensions = [\n    'jif',\n    'jfif',\n    'apng',\n    'avif',\n    'svg',\n    'webp',\n    'bmp',\n    'ico',\n    'jpg',\n    'jpe',\n    'jpeg',\n    'gif',\n    'png',\n    'heic',\n  ];\n  const textExtensions = ['pdf', 'txt', 'json'];\n  const audioExtensions = ['wav', 'mp3', 'alac', 'aiff', 'dsd', 'pcm'];\n  const videoExtensions = [\n    'mp4',\n    'avi',\n    'mpg',\n    'webm',\n    'mov',\n    'flv',\n    'mkv',\n    'wmv',\n    'avchd',\n    'mpeg-4',\n  ];\n\n  if (imageExtensions.includes(contentType)) {\n    return contentType;\n  }\n  if (textExtensions.includes(contentType)) {\n    return contentType;\n  }\n  if (audioExtensions.includes(contentType)) {\n    return contentType;\n  }\n  if (videoExtensions.includes(contentType)) {\n    return contentType;\n  }\n  return 'application/octet-stream';\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/auth.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { AuthController } from './auth.controller';\n\ndescribe('AuthController', () => {\n  let controller: AuthController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [AuthController],\n    }).compile();\n\n    controller = module.get<AuthController>(AuthController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/auth.controller.ts",
    "content": "import { Controller, Delete, Get, HttpCode, Post, Query, Req, Res } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport {\n  deleteUserSchemaRo,\n  IDeleteUserSchema,\n  type IGetTempTokenVo,\n  type IUserMeVo,\n} from '@teable/openapi';\nimport { Response } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport { AUTH_SESSION_COOKIE_NAME } from '../../const';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { DeleteUserService } from '../user/delete-user/delete-user.service';\nimport { AuthService } from './auth.service';\nimport { AllowAnonymous, AllowAnonymousType } from './decorators/allow-anonymous.decorator';\nimport { TokenAccess } from './decorators/token.decorator';\nimport { SessionService } from './session/session.service';\n\n@Controller('api/auth')\nexport class AuthController {\n  constructor(\n    private readonly authService: AuthService,\n    private readonly sessionService: SessionService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly deleteUserService: DeleteUserService\n  ) {}\n\n  @Post('signout')\n  @HttpCode(200)\n  @EmitControllerEvent(Events.USER_SIGNOUT)\n  async signout(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n\n  @AllowAnonymous(AllowAnonymousType.USER)\n  @Get('/user/me')\n  async me(@Req() request: Express.Request) {\n    return {\n      ...request.user,\n      organization: this.cls.get('organization'),\n    };\n  }\n\n  @Get('/user')\n  @TokenAccess()\n  async user(@Req() request: Express.Request) {\n    return this.authService.getUserInfo(request.user as IUserMeVo);\n  }\n\n  @Get('temp-token')\n  async tempToken(): Promise<IGetTempTokenVo> {\n    return this.authService.getTempToken();\n  }\n\n  @Delete('user')\n  async deleteUser(\n    @Req() req: Express.Request,\n    @Res({ passthrough: true }) res: Response,\n    @Query(new ZodValidationPipe(deleteUserSchemaRo)) query: IDeleteUserSchema\n  ) {\n    if (query.confirm !== 'DELETE') {\n      throw new CustomHttpException('Invalid confirm', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.invalidConfirm',\n        },\n      });\n    }\n    await this.deleteUserService.deleteUser();\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/auth.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Module } from '@nestjs/common';\nimport { ConditionalModule } from '@nestjs/config';\nimport { JwtModule } from '@nestjs/jwt';\nimport { PassportModule } from '@nestjs/passport';\nimport { authConfig, type IAuthConfig } from '../../configs/auth.config';\nimport { AccessTokenModule } from '../access-token/access-token.module';\nimport { DeleteUserModule } from '../user/delete-user/delete-user.module';\nimport { UserModule } from '../user/user.module';\nimport { AuthController } from './auth.controller';\nimport { AuthService } from './auth.service';\nimport { AuthGuard } from './guard/auth.guard';\nimport { LocalAuthModule } from './local-auth/local-auth.module';\nimport { PermissionModule } from './permission.module';\nimport { SessionStoreService } from './session/session-store.service';\nimport { SessionModule } from './session/session.module';\nimport { SessionSerializer } from './session/session.serializer';\nimport { SocialModule } from './social/social.module';\nimport { AccessTokenStrategy } from './strategies/access-token.strategy';\nimport { AnonymousStrategy } from './strategies/anonymous/anonymous.strategy';\nimport { JwtStrategy } from './strategies/jwt.strategy';\nimport { SessionStrategy } from './strategies/session.strategy';\nimport { TurnstileModule } from './turnstile/turnstile.module';\n\nconst CONDITIONAL_MODULE_TIMEOUT = process.env.CI ? 30000 : 5000;\n\n@Module({\n  imports: [\n    UserModule,\n    PassportModule.register({ session: true }),\n    SessionModule,\n    AccessTokenModule,\n    ConditionalModule.registerWhen(\n      LocalAuthModule,\n      (env) => {\n        return Boolean(env.PASSWORD_LOGIN_DISABLED !== 'true');\n      },\n      { timeout: CONDITIONAL_MODULE_TIMEOUT }\n    ),\n    SocialModule,\n    PermissionModule,\n    TurnstileModule,\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n    DeleteUserModule,\n  ],\n  providers: [\n    AuthService,\n    SessionStrategy,\n    AuthGuard,\n    SessionSerializer,\n    SessionStoreService,\n    AccessTokenStrategy,\n    JwtStrategy,\n    AnonymousStrategy,\n  ],\n  exports: [AuthService, AuthGuard],\n  controllers: [AuthController],\n})\nexport class AuthModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/auth.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { AuthModule } from './auth.module';\nimport { AuthService } from './auth.service';\n\ndescribe('AuthService', () => {\n  let service: AuthService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, AuthModule],\n    }).compile();\n\n    service = module.get<AuthService>(AuthService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/auth.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { type IUserInfoVo, type IUserMeVo } from '@teable/openapi';\nimport { omit, pick } from 'lodash';\nimport ms from 'ms';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { PermissionService } from './permission.service';\nimport { JwtAuthInternalType } from './strategies/types';\nimport type { IJwtAuthInternalInfo, IJwtAuthInfo } from './strategies/types';\n\n@Injectable()\nexport class AuthService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly permissionService: PermissionService,\n    private readonly jwtService: JwtService\n  ) {}\n\n  async getUserInfo(user: IUserMeVo): Promise<IUserInfoVo> {\n    const res = pick(user, ['id', 'email', 'avatar', 'name']);\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (!accessTokenId) {\n      return res;\n    }\n    const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n    if (!scopes.includes('user|email_read')) {\n      return omit(res, 'email');\n    }\n    return res;\n  }\n\n  async validateJwtToken(token: string) {\n    try {\n      return await this.jwtService.verifyAsync<IJwtAuthInfo>(token);\n    } catch {\n      throw new UnauthorizedException();\n    }\n  }\n\n  async getTempToken() {\n    const payload: IJwtAuthInfo = {\n      userId: this.cls.get('user.id'),\n    };\n    const expiresIn = '10m';\n    return {\n      accessToken: await this.jwtService.signAsync(payload, { expiresIn }),\n      expiresTime: new Date(Date.now() + ms(expiresIn)).toISOString(),\n    };\n  }\n\n  async getTempInternalToken(\n    baseId: string,\n    type: JwtAuthInternalType,\n    expiresIn: string = '10m',\n    context?: IJwtAuthInternalInfo['context']\n  ) {\n    // For User type tokens, userId is required\n    const userId = this.cls.get('user.id');\n    if (type === JwtAuthInternalType.User && !userId) {\n      throw new UnauthorizedException('User identity is required for User type tokens');\n    }\n\n    const payload: IJwtAuthInternalInfo = {\n      type,\n      baseId,\n      // Include userId for User type tokens to maintain user identity\n      ...(type === JwtAuthInternalType.User ? { userId } : {}),\n      ...(context ? { context } : {}),\n    };\n    return {\n      accessToken: await this.jwtService.signAsync(payload, { expiresIn }),\n      expiresTime: new Date(Date.now() + ms(expiresIn)).toISOString(),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/allow-anonymous.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport enum AllowAnonymousType {\n  RESOURCE = 'resource',\n  USER = 'user',\n  PUBLIC = 'public',\n}\n\nexport const IS_ALLOW_ANONYMOUS = 'isAllowAnonymous';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const AllowAnonymous = (type: AllowAnonymousType = AllowAnonymousType.RESOURCE) =>\n  SetMetadata(IS_ALLOW_ANONYMOUS, type);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/base-node-permissions.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\nimport type { BaseNodeAction } from '../../base-node/types';\n\nexport const BASE_NODE_PERMISSIONS_KEY = 'baseNodePermissions';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const BaseNodePermissions = (...permissions: BaseNodeAction[]) =>\n  SetMetadata(BASE_NODE_PERMISSIONS_KEY, permissions);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/disabled-permission.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const IS_DISABLED_PERMISSION = 'isDisabledPermission';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const DisabledPermission = () => SetMetadata(IS_DISABLED_PERMISSION, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/ensure-login.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const ENSURE_LOGIN = 'ensureLogin';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const EnsureLogin = () => SetMetadata(ENSURE_LOGIN, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/permissions.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\nimport type { Action } from '@teable/core';\n\nexport const PERMISSIONS_KEY = 'permissions';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const Permissions = (...permissions: Action[]) => SetMetadata(PERMISSIONS_KEY, permissions);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/public.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const IS_PUBLIC_KEY = 'isPublic';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const Public = () => SetMetadata(IS_PUBLIC_KEY, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/resource_meta.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport type IResourceMeta = {\n  type: 'spaceId' | 'baseId' | 'tableId';\n  position: 'query' | 'params' | 'body';\n};\n\nexport const RESOURCE_META = 'resourceMeta';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ResourceMeta = (type: IResourceMeta['type'], position: IResourceMeta['position']) =>\n  SetMetadata(RESOURCE_META, { type, position });\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/decorators/token.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const IS_TOKEN_ACCESS = 'isTokenAccess';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const TokenAccess = () => SetMetadata(IS_TOKEN_ACCESS, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/auth.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { AuthGuard as PassportAuthGuard } from '@nestjs/passport';\nimport { isAnonymous } from '@teable/core';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';\nimport { ENSURE_LOGIN } from '../decorators/ensure-login.decorator';\nimport { IS_PUBLIC_KEY } from '../decorators/public.decorator';\nimport {\n  ACCESS_TOKEN_STRATEGY_NAME,\n  ANONYMOUS_STRATEGY_NAME,\n  JWT_TOKEN_STRATEGY_NAME,\n} from '../strategies/constant';\n\n@Injectable()\nexport class AuthGuard extends PassportAuthGuard([\n  'session',\n  ACCESS_TOKEN_STRATEGY_NAME,\n  JWT_TOKEN_STRATEGY_NAME,\n  ANONYMOUS_STRATEGY_NAME,\n]) {\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super();\n  }\n\n  async validate(context: ExecutionContext) {\n    const result = (await super.canActivate(context)) as boolean;\n    const isAllowAnonymous = this.reflector.getAllAndOverride<boolean>(IS_ALLOW_ANONYMOUS, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n    if (!isAllowAnonymous && isAnonymous(this.cls.get('user.id'))) {\n      throw new UnauthorizedException();\n    }\n    return result;\n  }\n\n  async canActivate(context: ExecutionContext) {\n    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n\n    if (isPublic) {\n      return true;\n    }\n\n    try {\n      return await this.validate(context);\n    } catch (error) {\n      const ensureLogin = this.reflector.getAllAndOverride<boolean>(ENSURE_LOGIN, [\n        context.getHandler(),\n        context.getClass(),\n      ]);\n      const res = context.switchToHttp().getResponse();\n      const req = context.switchToHttp().getRequest();\n      if (ensureLogin) {\n        return res.redirect(`/auth/login?redirect=${encodeURIComponent(req.url)}`);\n      }\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { BaseNodeResourceType } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport {\n  checkBaseNodePermission,\n  checkBaseNodePermissionCreate,\n} from '../../base-node/base-node.permission.helper';\nimport type { IBaseNodePermissionContext } from '../../base-node/types';\nimport { BaseNodeAction } from '../../base-node/types';\nimport { BASE_NODE_PERMISSIONS_KEY } from '../decorators/base-node-permissions.decorator';\nimport { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';\nimport { PermissionService } from '../permission.service';\nimport { PermissionGuard } from './permission.guard';\n\n@Injectable()\nexport class BaseNodePermissionGuard extends PermissionGuard {\n  constructor(\n    private readonly reflectorInner: Reflector,\n    private readonly clsInner: ClsService<IClsStore>,\n    private readonly permissionServiceInner: PermissionService,\n    private readonly prismaService: PrismaService\n  ) {\n    super(reflectorInner, clsInner, permissionServiceInner);\n  }\n\n  async canActivate(context: ExecutionContext) {\n    const superResult = await super.canActivate(context);\n    if (!superResult) {\n      return false;\n    }\n\n    // disabled check\n    const isDisabledPermission = this.reflectorInner.getAllAndOverride<boolean>(\n      IS_DISABLED_PERMISSION,\n      [context.getHandler(), context.getClass()]\n    );\n\n    if (isDisabledPermission) {\n      return true;\n    }\n\n    const baseId = this.getBaseId(context);\n    if (!baseId) {\n      throw new CustomHttpException('Base ID is required', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.baseIdIsRequired',\n        },\n      });\n    }\n    const permissionContext = await this.getPermissionContext();\n    return this.checkActivate(context, baseId, permissionContext);\n  }\n\n  async checkActivate(\n    context: ExecutionContext,\n    baseId: string,\n    permissionContext: IBaseNodePermissionContext\n  ) {\n    const baseNodePermissions = this.reflectorInner.getAllAndOverride<BaseNodeAction[] | undefined>(\n      BASE_NODE_PERMISSIONS_KEY,\n      [context.getHandler(), context.getClass()]\n    );\n\n    if (!baseNodePermissions?.length) {\n      return true;\n    }\n    const nodeId = this.getNodeId(context);\n    const node = await this.getNode(baseId, nodeId);\n    const checkCreate = checkBaseNodePermissionCreate(\n      node ?? { resourceType: this.getNodeResourceType(context), resourceId: '' },\n      baseNodePermissions,\n      permissionContext\n    );\n\n    if (!checkCreate) {\n      return false;\n    }\n\n    const baseNodePermissionsWithoutCreate = baseNodePermissions.filter(\n      (permission: BaseNodeAction) => permission !== BaseNodeAction.Create\n    );\n    if (!baseNodePermissionsWithoutCreate.length) {\n      return true;\n    }\n\n    if (!nodeId) {\n      throw new CustomHttpException('Node ID is required', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.nodeIdIsRequired',\n        },\n      });\n    }\n\n    if (!node) {\n      throw new CustomHttpException('Node not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.notFound',\n        },\n      });\n    }\n\n    return baseNodePermissionsWithoutCreate.every((permission: BaseNodeAction) =>\n      checkBaseNodePermission(node, permission, permissionContext)\n    );\n  }\n\n  getBaseId(context: ExecutionContext): string | undefined {\n    const request = context.switchToHttp().getRequest();\n    const defaultBaseId = request.params ?? {};\n    return super.getResourceId(context) || defaultBaseId.baseId;\n  }\n\n  getNodeId(context: ExecutionContext): string | undefined {\n    const req = context.switchToHttp().getRequest();\n    return req.params.nodeId;\n  }\n\n  getNodeResourceType(context: ExecutionContext): BaseNodeResourceType {\n    const req = context.switchToHttp().getRequest();\n    return req.body.resourceType;\n  }\n\n  async getNode(baseId: string, nodeId?: string) {\n    if (!nodeId) {\n      return;\n    }\n    const node = await this.prismaService.baseNode.findFirst({\n      where: { baseId, id: nodeId },\n      select: {\n        id: true,\n        resourceType: true,\n        resourceId: true,\n      },\n    });\n\n    if (node) {\n      return {\n        resourceType: node.resourceType as BaseNodeResourceType,\n        resourceId: node.resourceId,\n      };\n    }\n  }\n\n  private async getPermissionContext() {\n    const permissions = this.clsInner.get('permissions');\n    const permissionSet = new Set(permissions);\n    return { permissionSet };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/github.guard.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { AuthGuard } from '@nestjs/passport';\n\n@Injectable()\nexport class GithubGuard extends AuthGuard('github') {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/google.guard.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { AuthGuard } from '@nestjs/passport';\n\n@Injectable()\nexport class GoogleGuard extends AuthGuard('google') {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/local-auth.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { AuthGuard } from '@nestjs/passport';\n\n@Injectable()\nexport class LocalAuthGuard extends AuthGuard('local') {\n  async canActivate(context: ExecutionContext): Promise<boolean> {\n    const result = (await super.canActivate(context)) as boolean;\n    await super.logIn(context.switchToHttp().getRequest());\n    return result;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/oidc.guard.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { AuthGuard } from '@nestjs/passport';\n\n@Injectable()\nexport class OIDCGuard extends AuthGuard('openidconnect') {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/permission.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { ANONYMOUS_USER_ID, HttpErrorCode, isAnonymous, type Action } from '@teable/core';\nimport cookie from 'cookie';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { AllowAnonymousType, IS_ALLOW_ANONYMOUS } from '../decorators/allow-anonymous.decorator';\nimport { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';\nimport { PERMISSIONS_KEY } from '../decorators/permissions.decorator';\nimport { IS_PUBLIC_KEY } from '../decorators/public.decorator';\nimport type { IResourceMeta } from '../decorators/resource_meta.decorator';\nimport { RESOURCE_META } from '../decorators/resource_meta.decorator';\nimport { IS_TOKEN_ACCESS } from '../decorators/token.decorator';\nimport { PermissionService } from '../permission.service';\nimport { getTemplateHeader, getBaseShareHeader } from '../utils';\n\nconst i18nKeyCheckIdNotExist = 'httpErrors.permission.checkIdNotExist';\n\n@Injectable()\nexport class PermissionGuard {\n  private readonly logger = new Logger(PermissionGuard.name);\n\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly permissionService: PermissionService\n  ) {}\n\n  protected defaultResourceId(context: ExecutionContext): string | undefined {\n    const req = context.switchToHttp().getRequest();\n    // before check baseId, as users can be individually invited into the base.\n    return req.params.baseId || req.params.spaceId || req.params.tableId;\n  }\n\n  protected getResourceId(context: ExecutionContext): string | undefined {\n    const resourceMeta = this.reflector.getAllAndOverride<IResourceMeta | undefined>(\n      RESOURCE_META,\n      [context.getHandler(), context.getClass()]\n    );\n    const req = context.switchToHttp().getRequest();\n\n    if (resourceMeta) {\n      const { type, position } = resourceMeta;\n      return req?.[position]?.[type];\n    }\n  }\n\n  /**\n   * Space creation permissions are more specific and only pertain to users,\n   * but tokens can be disallowed from being created.\n   */\n  private async permissionCreateSpace() {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n      return scopes.includes('space|create');\n    }\n    return true;\n  }\n\n  private async permissionBaseReadAll() {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n      return scopes.includes('base|read_all');\n    }\n    return true;\n  }\n\n  private async permissionSpaceRead() {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n      return scopes.includes('space|read');\n    }\n    return true;\n  }\n\n  private async permissionUserIntegrations() {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n      return scopes.includes('user|integrations');\n    }\n    return true;\n  }\n\n  protected async templatePermissionCheck(context: ExecutionContext, templateHeader?: string) {\n    if (templateHeader) {\n      const templateId = this.permissionService.getTemplateIdByHeader(templateHeader);\n      if (!templateId) {\n        throw new CustomHttpException(\n          `Template header is invalid`,\n          this.isAnonymous() ? HttpErrorCode.UNAUTHORIZED : HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.permission.templateHeaderInvalid',\n            },\n          }\n        );\n      }\n    }\n    const resourceId = this.getResourceId(context) || this.defaultResourceId(context);\n    if (!resourceId) {\n      throw new CustomHttpException(\n        `Template permission check ID does not exist`,\n        this.isAnonymous() ? HttpErrorCode.UNAUTHORIZED : HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: i18nKeyCheckIdNotExist,\n          },\n        }\n      );\n    }\n    const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n    if (!permissions?.length) {\n      throw new ForbiddenException('Template permissions are required');\n    }\n    const ownPermissions = await this.permissionService.validTemplatePermissions(\n      resourceId,\n      permissions\n    );\n    this.cls.set('permissions', ownPermissions);\n    return true;\n  }\n\n  protected async baseSharePermissionCheck(context: ExecutionContext, shareId: string) {\n    await this.ensureBaseShareAuth(context, shareId);\n    const resourceId = this.getResourceId(context) || this.defaultResourceId(context);\n    if (!resourceId) {\n      throw new CustomHttpException(\n        `Base share permission check ID does not exist`,\n        this.isAnonymous() ? HttpErrorCode.UNAUTHORIZED : HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: i18nKeyCheckIdNotExist,\n          },\n        }\n      );\n    }\n    const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n    if (!permissions?.length) {\n      throw new ForbiddenException('Base share permissions are required');\n    }\n    const ownPermissions = await this.permissionService.validBaseSharePermissions(\n      shareId,\n      resourceId,\n      permissions\n    );\n    // Set user to anonymous for share context\n    this.cls.set('user', {\n      id: ANONYMOUS_USER_ID,\n      name: ANONYMOUS_USER_ID,\n      email: '',\n    });\n    this.cls.set('permissions', ownPermissions);\n    return true;\n  }\n\n  private async ensureBaseShareAuth(context: ExecutionContext, shareId: string) {\n    const requirePassword = await this.permissionService.baseShareRequiresPassword(shareId);\n    if (!requirePassword) {\n      return;\n    }\n    const req = context.switchToHttp().getRequest();\n    const cookies = cookie.parse(req.headers.cookie ?? '');\n    const token = cookies[shareId];\n    if (!token) {\n      throw new CustomHttpException('Unauthorized', HttpErrorCode.UNAUTHORIZED_SHARE);\n    }\n    const valid = await this.permissionService.validateBaseSharePasswordToken(shareId, token);\n    if (!valid) {\n      throw new CustomHttpException('Unauthorized', HttpErrorCode.UNAUTHORIZED_SHARE);\n    }\n  }\n\n  private async resourcePermission(resourceId: string | undefined, permissions: Action[]) {\n    if (!resourceId) {\n      throw new CustomHttpException(\n        `Permission check ID does not exist`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: i18nKeyCheckIdNotExist,\n          },\n        }\n      );\n    }\n    const accessTokenId = this.cls.get('accessTokenId');\n    const ownPermissions = await this.permissionService.validPermissions(\n      resourceId,\n      permissions,\n      accessTokenId\n    );\n    this.cls.set('permissions', ownPermissions);\n    return true;\n  }\n\n  protected async instancePermissionChecker(action: Action) {\n    const isAdmin = this.cls.get('user.isAdmin');\n\n    if (!isAdmin) {\n      throw new CustomHttpException(`User is not an admin`, HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.permission.userNotAdmin',\n        },\n      });\n    }\n\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      const { scopes } = await this.permissionService.getAccessToken(accessTokenId);\n      const allowConfig = scopes.includes(action);\n      if (!allowConfig) {\n        throw new CustomHttpException(\n          `Access token does not have ${action} permission`,\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.permission.accessTokenNoPermission',\n            },\n          }\n        );\n      }\n    }\n    return true;\n  }\n\n  protected async permissionCheck(context: ExecutionContext) {\n    const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n    const resourceId = this.getResourceId(context) || this.defaultResourceId(context);\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId && !permissions?.length) {\n      // Pre-checking of tokens\n      // The token can only access interfaces that are restricted by permissions or have a token access indicator.\n      return this.reflector.getAllAndOverride<boolean>(IS_TOKEN_ACCESS, [\n        context.getHandler(),\n        context.getClass(),\n      ]);\n    }\n\n    if (!permissions?.length) {\n      return true;\n    }\n    // instance permission check\n    if (permissions?.includes('instance|update')) {\n      return this.instancePermissionChecker('instance|update');\n    }\n    if (permissions?.includes('instance|read')) {\n      return this.instancePermissionChecker('instance|read');\n    }\n    if (permissions?.includes('space|create')) {\n      return await this.permissionCreateSpace();\n    }\n    if (permissions?.includes('base|read_all')) {\n      return await this.permissionBaseReadAll();\n    }\n    if (!resourceId && permissions?.includes('space|read')) {\n      return await this.permissionSpaceRead();\n    }\n\n    if (permissions?.includes('user|integrations')) {\n      return await this.permissionUserIntegrations();\n    }\n\n    // resource permission check\n    return await this.resourcePermission(resourceId, permissions);\n  }\n\n  private isAnonymous() {\n    return isAnonymous(this.cls.get('user.id'));\n  }\n\n  /**\n   * Try to perform base share permission check if shareId can be extracted from header.\n   * @returns true if check passed, undefined if no valid shareId found in header\n   */\n  private async tryBaseSharePermissionCheck(\n    context: ExecutionContext,\n    baseShareHeader: string | undefined\n  ): Promise<boolean | undefined> {\n    if (!baseShareHeader) {\n      return undefined;\n    }\n    const shareId = this.permissionService.getBaseShareIdByHeader(baseShareHeader);\n    if (!shareId) {\n      return undefined;\n    }\n    return await this.baseSharePermissionCheck(context, shareId);\n  }\n\n  /**\n   * Resolve RESOURCE-level permission using resource-specific auth (base share > template).\n   * @returns true if resolved, undefined if no valid auth header found\n   */\n  private async resolveResourcePermission(\n    context: ExecutionContext,\n    baseShareHeader: string | undefined,\n    templateHeader: string | undefined\n  ): Promise<boolean | undefined> {\n    if (baseShareHeader) {\n      const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader);\n      if (result !== undefined) return result;\n    }\n    if (templateHeader) {\n      return this.templatePermissionCheck(context, templateHeader);\n    }\n    return undefined;\n  }\n\n  /**\n   * Resolve permission for anonymous users.\n   * Falls back to template check or allows USER-level anonymous access.\n   */\n  private async resolveAnonymousPermission(\n    context: ExecutionContext,\n    allowAnonymousType: AllowAnonymousType | undefined\n  ): Promise<boolean> {\n    if (allowAnonymousType === AllowAnonymousType.PUBLIC) {\n      return this.templatePermissionCheck(context);\n    }\n    if (allowAnonymousType === AllowAnonymousType.USER) {\n      return true;\n    }\n    throw new UnauthorizedException();\n  }\n\n  /**\n   * Fallback permission check for PUBLIC endpoints when normal check fails.\n   * Tries base share first, then template, re-throws original error if all fail.\n   */\n  private async resolvePublicFallback(\n    context: ExecutionContext,\n    baseShareHeader: string | undefined,\n    originalError: unknown\n  ): Promise<boolean> {\n    const baseShareResult = await this.tryBaseShareFallback(context, baseShareHeader);\n    if (baseShareResult !== undefined) return baseShareResult;\n\n    this.logger.log('Fallback to template permission check');\n    try {\n      return await this.templatePermissionCheck(context);\n    } catch (e: unknown) {\n      const error = e as Error;\n      this.logger.error(`Template fallback failed: ${error.message}`, error.stack);\n      throw originalError;\n    }\n  }\n\n  /**\n   * Try base share as a fallback, swallowing errors (returns undefined on failure).\n   */\n  private async tryBaseShareFallback(\n    context: ExecutionContext,\n    baseShareHeader: string | undefined\n  ): Promise<boolean | undefined> {\n    if (!baseShareHeader) return undefined;\n    const shareId = this.permissionService.getBaseShareIdByHeader(baseShareHeader);\n    if (!shareId) return undefined;\n\n    this.logger.log('Fallback to base share permission check');\n    try {\n      return await this.baseSharePermissionCheck(context, shareId);\n    } catch (e) {\n      this.logger.error(`Base share fallback failed: ${e}`);\n      return undefined;\n    }\n  }\n\n  /**\n   * Permission check with public/share/template fallback.\n   *\n   * Priority flow:\n   *   1. RESOURCE-level: exclusively use resource-specific auth (base share > template)\n   *   2. Early base share check for PUBLIC or anonymous requests when header is present\n   *   3. Anonymous user handling (template / USER-level)\n   *   4. Authenticated user: standard check, with fallback for PUBLIC endpoints\n   */\n  protected async permissionCheckWithPublicFallback(\n    context: ExecutionContext,\n    permissionCheck: () => Promise<boolean>\n  ) {\n    const req = context.switchToHttp().getRequest();\n    const templateHeader = getTemplateHeader(req);\n    const baseShareHeader = getBaseShareHeader(req);\n    const allowAnonymousType = this.reflector.getAllAndOverride<AllowAnonymousType | undefined>(\n      IS_ALLOW_ANONYMOUS,\n      [context.getHandler(), context.getClass()]\n    );\n\n    // 1. RESOURCE-level: exclusively use resource-specific auth (base share > template)\n    if (allowAnonymousType === AllowAnonymousType.RESOURCE) {\n      const result = await this.resolveResourcePermission(context, baseShareHeader, templateHeader);\n      if (result !== undefined) return result;\n      // No valid resource auth header — fall through to normal checks\n    }\n\n    // 2. Early base share check for PUBLIC or anonymous requests\n    const shouldTryBaseShareEarly =\n      baseShareHeader && (allowAnonymousType === AllowAnonymousType.PUBLIC || this.isAnonymous());\n    if (shouldTryBaseShareEarly) {\n      const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader);\n      if (result !== undefined) return result;\n    }\n\n    // 3. Anonymous user handling\n    if (this.isAnonymous()) {\n      return this.resolveAnonymousPermission(context, allowAnonymousType);\n    }\n\n    // 4. Authenticated user: standard check, with fallback for PUBLIC endpoints\n    try {\n      return await permissionCheck();\n    } catch (error) {\n      if (allowAnonymousType !== AllowAnonymousType.PUBLIC) throw error;\n      return this.resolvePublicFallback(context, baseShareHeader, error);\n    }\n  }\n\n  /**\n   * permission step:\n   * 1. public decorator sign\n   *    full public interface\n   * 2. token decorator sign\n   *    The token can only access interfaces that are restricted by permissions or have a token access indicator.\n   * 3. permissions decorator sign\n   *    Decorate what permissions are needed to operate the interface,\n   *    if none then it means just logging in is sufficient\n   * 4. space create permission check\n   *    The space create permission is special, it has nothing to do with resources, but only with users.\n   * 5. resource permission check\n   *    Because the token is user-generated, the permissions will only be less than the current user,\n   *    so first determine the current user permissions\n   *    5.1. by user for space\n   *    5.2. by access token if exists\n   */\n  async canActivate(context: ExecutionContext) {\n    // public check\n    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n\n    if (isPublic) {\n      return true;\n    }\n\n    // disabled check\n    const isDisabledPermission = this.reflector.getAllAndOverride<boolean>(IS_DISABLED_PERMISSION, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n\n    if (isDisabledPermission) {\n      return true;\n    }\n\n    return await this.permissionCheckWithPublicFallback(context, async () => {\n      return await this.permissionCheck(context);\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/guard/social.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class SocialGuard {\n  async canActivate(context: ExecutionContext): Promise<boolean> {\n    const req = context.switchToHttp().getRequest();\n    const res = context.switchToHttp().getResponse();\n    if (req?.query?.error === 'access_denied') {\n      res.redirect('/auth/login');\n      return false;\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts",
    "content": "import { Body, Controller, Get, HttpCode, Patch, Post, Req, Res, UseGuards } from '@nestjs/common';\nimport type {\n  IUserMeVo,\n  IWaitlistInviteCodeVo,\n  IJoinWaitlistVo,\n  IGetWaitlistVo,\n  IInviteWaitlistVo,\n} from '@teable/openapi';\nimport {\n  IAddPasswordRo,\n  IChangePasswordRo,\n  IResetPasswordRo,\n  ISendResetPasswordEmailRo,\n  ISignup,\n  addPasswordRoSchema,\n  changePasswordRoSchema,\n  resetPasswordRoSchema,\n  sendResetPasswordEmailRoSchema,\n  sendSignupVerificationCodeRoSchema,\n  signupSchema,\n  ISendSignupVerificationCodeRo,\n  changeEmailRoSchema,\n  IChangeEmailRo,\n  sendChangeEmailCodeRoSchema,\n  ISendChangeEmailCodeRo,\n  joinWaitlistSchemaRo,\n  IJoinWaitlistRo,\n  IWaitlistInviteCodeRo,\n  waitlistInviteCodeRoSchema,\n  inviteWaitlistRoSchema,\n  IInviteWaitlistRo,\n} from '@teable/openapi';\nimport { Response, Request } from 'express';\nimport { AUTH_SESSION_COOKIE_NAME } from '../../../const';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { Permissions } from '../decorators/permissions.decorator';\nimport { Public } from '../decorators/public.decorator';\nimport { LocalAuthGuard } from '../guard/local-auth.guard';\nimport { SessionService } from '../session/session.service';\nimport { pickUserMe } from '../utils';\nimport { LocalAuthService } from './local-auth.service';\n\n@Controller('api/auth')\nexport class LocalAuthController {\n  constructor(\n    private readonly sessionService: SessionService,\n    private readonly authService: LocalAuthService\n  ) {}\n\n  @Public()\n  @UseGuards(LocalAuthGuard)\n  @HttpCode(200)\n  @Post('signin')\n  async signin(@Req() req: Request): Promise<IUserMeVo> {\n    return req.user as IUserMeVo;\n  }\n\n  @Public()\n  @Post('signup')\n  async signup(\n    @Body(new ZodValidationPipe(signupSchema)) body: ISignup,\n    @Res({ passthrough: true }) res: Response,\n    @Req() req: Request\n  ): Promise<IUserMeVo> {\n    const remoteIp =\n      req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);\n    const user = pickUserMe(await this.authService.signup(body, remoteIp));\n    // set cookie, passport login\n    await new Promise<void>((resolve, reject) => {\n      req.login(user, (err) => (err ? reject(err) : resolve()));\n    });\n    return user;\n  }\n\n  @Public()\n  @Post('join-waitlist')\n  async joinWaitlist(\n    @Body(new ZodValidationPipe(joinWaitlistSchemaRo)) ro: IJoinWaitlistRo\n  ): Promise<IJoinWaitlistVo> {\n    await this.authService.joinWaitlist(ro.email);\n    return ro;\n  }\n\n  @Post('invite-waitlist')\n  @Permissions('instance|update')\n  async inviteWaitlist(\n    @Body(new ZodValidationPipe(inviteWaitlistRoSchema)) ro: IInviteWaitlistRo\n  ): Promise<IInviteWaitlistVo> {\n    return await this.authService.inviteWaitlist(ro.list);\n  }\n\n  @Get('waitlist')\n  @Permissions('instance|read')\n  async getWaitlist(): Promise<IGetWaitlistVo> {\n    return await this.authService.getWaitlist();\n  }\n\n  @Post('waitlist-invite-code')\n  @Permissions('instance|update')\n  async genWaitlistInviteCode(\n    @Body(new ZodValidationPipe(waitlistInviteCodeRoSchema)) ro: IWaitlistInviteCodeRo\n  ): Promise<IWaitlistInviteCodeVo> {\n    const list: IWaitlistInviteCodeVo = [];\n    const times = Math.max(ro.times ?? 1, 1);\n    for (let i = 0; i < ro.count; i++) {\n      const code = await this.authService.genWaitlistInviteCode(times);\n      list.push({\n        code,\n        times,\n      });\n    }\n    return list;\n  }\n\n  @Public()\n  @Post('send-signup-verification-code')\n  @HttpCode(200)\n  async sendSignupVerificationCode(\n    @Body(new ZodValidationPipe(sendSignupVerificationCodeRoSchema))\n    body: ISendSignupVerificationCodeRo,\n    @Req() req: Request\n  ) {\n    const remoteIp =\n      req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);\n\n    return this.authService.sendSignupVerificationCodeWithTurnstile(\n      body.email,\n      body.turnstileToken,\n      remoteIp\n    );\n  }\n\n  @Patch('/change-password')\n  async changePassword(\n    @Body(new ZodValidationPipe(changePasswordRoSchema)) changePasswordRo: IChangePasswordRo,\n    @Req() req: Request,\n    @Res({ passthrough: true }) res: Response\n  ) {\n    await this.authService.changePassword(changePasswordRo);\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n\n  @Post('/send-reset-password-email')\n  @Public()\n  async sendResetPasswordEmail(\n    @Body(new ZodValidationPipe(sendResetPasswordEmailRoSchema)) body: ISendResetPasswordEmailRo\n  ) {\n    return this.authService.sendResetPasswordEmail(body.email);\n  }\n\n  @Post('/reset-password')\n  @Public()\n  async resetPassword(\n    @Res({ passthrough: true }) res: Response,\n    @Req() req: Request,\n    @Body(new ZodValidationPipe(resetPasswordRoSchema)) body: IResetPasswordRo\n  ) {\n    await this.authService.resetPassword(body.code, body.password);\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n\n  @Post('/add-password')\n  async addPassword(\n    @Res({ passthrough: true }) res: Response,\n    @Req() req: Request,\n    @Body(new ZodValidationPipe(addPasswordRoSchema)) body: IAddPasswordRo\n  ) {\n    await this.authService.addPassword(body.password);\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n\n  @Patch('/change-email')\n  async changeEmail(\n    @Body(new ZodValidationPipe(changeEmailRoSchema)) body: IChangeEmailRo,\n    @Res({ passthrough: true }) res: Response,\n    @Req() req: Request\n  ) {\n    await this.authService.changeEmail(body.email, body.token, body.code);\n    await this.sessionService.signout(req);\n    res.clearCookie(AUTH_SESSION_COOKIE_NAME);\n  }\n\n  @Post('/send-change-email-code')\n  @HttpCode(200)\n  async sendChangeEmailCode(\n    @Body(new ZodValidationPipe(sendChangeEmailCodeRoSchema)) body: ISendChangeEmailCodeRo\n  ) {\n    return this.authService.sendChangeEmailCode(body.email, body.password);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/local-auth/local-auth.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport type { IAuthConfig } from '../../../configs/auth.config';\nimport { authConfig } from '../../../configs/auth.config';\nimport { MailSenderModule } from '../../mail-sender/mail-sender.module';\nimport { SettingModule } from '../../setting/setting.module';\nimport { UserModule } from '../../user/user.module';\nimport { SessionStoreService } from '../session/session-store.service';\nimport { SessionModule } from '../session/session.module';\nimport { LocalStrategy } from '../strategies/local.strategy';\nimport { TurnstileModule } from '../turnstile/turnstile.module';\nimport { LocalAuthController } from './local-auth.controller';\nimport { LocalAuthService } from './local-auth.service';\n\n@Module({\n  imports: [\n    TurnstileModule,\n    SettingModule,\n    UserModule,\n    SessionModule,\n    MailSenderModule.register(),\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n  ],\n  providers: [LocalStrategy, LocalAuthService, SessionStoreService],\n  controllers: [LocalAuthController],\n  exports: [LocalAuthService],\n})\nexport class LocalAuthModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { BadRequestException, Injectable, Logger } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { generateUserId, getRandomString, HttpErrorCode, RandomType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { EmailVerifyCodeType, MailTransporterType, MailType } from '@teable/openapi';\nimport type { IChangePasswordRo, IInviteWaitlistVo, ISignup } from '@teable/openapi';\nimport * as bcrypt from 'bcrypt';\nimport { isEmpty } from 'lodash';\nimport ms from 'ms';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../../cache/cache.service';\nimport { AuthConfig, type IAuthConfig } from '../../../configs/auth.config';\nimport { BaseConfig, IBaseConfig } from '../../../configs/base.config';\nimport { MailConfig, type IMailConfig } from '../../../configs/mail.config';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport {\n  UserSignUpEvent,\n  UserEmailChangeEvent,\n} from '../../../event-emitter/events/user/user.event';\nimport type { IClsStore } from '../../../types/cls';\nimport { second } from '../../../utils/second';\nimport { MailSenderService } from '../../mail-sender/mail-sender.service';\nimport { SettingService } from '../../setting/setting.service';\nimport { UserService } from '../../user/user.service';\nimport { SessionStoreService } from '../session/session-store.service';\nimport { TurnstileService } from '../turnstile/turnstile.service';\n\n@Injectable()\nexport class LocalAuthService {\n  private readonly logger = new Logger(LocalAuthService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly userService: UserService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly sessionStoreService: SessionStoreService,\n    private readonly mailSenderService: MailSenderService,\n    private readonly cacheService: CacheService,\n    private readonly eventEmitterService: EventEmitterService,\n    @AuthConfig() private readonly authConfig: IAuthConfig,\n    @MailConfig() private readonly mailConfig: IMailConfig,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly jwtService: JwtService,\n    private readonly settingService: SettingService,\n    private readonly turnstileService: TurnstileService\n  ) {}\n\n  private async encodePassword(password: string) {\n    const salt = await bcrypt.genSalt(10);\n    const hashPassword = await bcrypt.hash(password, salt);\n    return { salt, hashPassword };\n  }\n\n  private async comparePassword(\n    password: string,\n    hashPassword: string | null,\n    salt: string | null\n  ) {\n    const _hashPassword = await bcrypt.hash(password || '', salt || '');\n    return _hashPassword === hashPassword;\n  }\n\n  private async getUserByIdOrThrow(userId: string) {\n    const user = await this.userService.getUserById(userId);\n    if (!user) {\n      throw new CustomHttpException(`User not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.user.notFound',\n        },\n      });\n    }\n    return user;\n  }\n\n  async validateUserByEmail(email: string, pass: string) {\n    const user = await this.userService.getUserByEmail(email);\n    if (!user || (user.accounts.length === 0 && user.password == null)) {\n      throw new CustomHttpException(`${email} not registered`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.emailNotRegistered',\n        },\n      });\n    }\n\n    if (!user.password) {\n      throw new CustomHttpException(`Password is not set`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.passwordNotSet',\n        },\n      });\n    }\n\n    if (user.isSystem) {\n      throw new CustomHttpException(`User is system user`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.systemUser',\n        },\n      });\n    }\n\n    const { password, salt, ...result } = user;\n    return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;\n  }\n\n  /**\n   * Validate user by email and password with Turnstile verification\n   */\n  async validateUserByEmailWithTurnstile(\n    email: string,\n    pass: string,\n    turnstileToken?: string,\n    remoteIp?: string\n  ) {\n    // Validate Turnstile token if enabled\n    await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);\n\n    // Proceed with normal user validation\n    return this.validateUserByEmail(email, pass);\n  }\n\n  private jwtSignupCode(email: string, code: string) {\n    return this.jwtService.signAsync(\n      { email, code },\n      { expiresIn: this.authConfig.signupVerificationExpiresIn }\n    );\n  }\n\n  private jwtVerifySignupCode(token: string) {\n    return this.jwtService.verifyAsync<{ email: string; code: string }>(token).catch(() => {\n      throw new CustomHttpException('Verification code is invalid', HttpErrorCode.INVALID_CAPTCHA);\n    });\n  }\n\n  private async verifySignup(body: ISignup) {\n    const setting = await this.settingService.getSetting();\n    if (!setting?.enableEmailVerification) {\n      return;\n    }\n    const { email, verification } = body;\n    if (!verification) {\n      const { token, expiresTime } = await this.sendSignupVerificationCode(email);\n      throw new CustomHttpException(\n        'Verification is required',\n        HttpErrorCode.UNPROCESSABLE_ENTITY,\n        {\n          token,\n          expiresTime,\n        }\n      );\n    }\n    const { code, email: _email } = await this.jwtVerifySignupCode(verification.token);\n    if (_email !== email || code !== verification.code) {\n      throw new CustomHttpException('Verification code is invalid', HttpErrorCode.INVALID_CAPTCHA);\n    }\n  }\n\n  private isRegisteredValidate(user: Awaited<ReturnType<typeof this.userService.getUserByEmail>>) {\n    if (user && (user.password !== null || user.accounts.length > 0)) {\n      throw new CustomHttpException(\n        `User ${user.email} is already registered`,\n        HttpErrorCode.CONFLICT,\n        {\n          localization: {\n            i18nKey: 'httpErrors.auth.alreadyRegistered',\n          },\n        }\n      );\n    }\n    if (user && user.isSystem) {\n      throw new CustomHttpException(\n        `User ${user.email} is system user`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.auth.systemUser',\n          },\n        }\n      );\n    }\n  }\n\n  /**\n   * Validate Turnstile token if Turnstile is enabled\n   */\n  private async validateTurnstileIfEnabled(\n    turnstileToken?: string,\n    remoteIp?: string\n  ): Promise<void> {\n    const isTurnstileEnabled = this.turnstileService.isTurnstileEnabled();\n\n    this.logger.log(\n      `Turnstile validation check - enabled: ${isTurnstileEnabled}, hasToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`\n    );\n\n    if (!isTurnstileEnabled) {\n      return;\n    }\n\n    if (!turnstileToken) {\n      this.logger.error(\n        `Turnstile token is missing - enabled: ${isTurnstileEnabled}, remoteIp: ${remoteIp}`\n      );\n      throw new BadRequestException('Turnstile token is required');\n    }\n\n    const validation = await this.turnstileService.validateTurnstileTokenWithRetry(\n      turnstileToken,\n      remoteIp\n    );\n\n    if (!validation.valid) {\n      this.logger.warn('Turnstile validation failed', {\n        reason: validation.reason,\n        remoteIp,\n      });\n\n      let errorMessage = 'Verification failed. Please try again.';\n\n      switch (validation.reason) {\n        case 'turnstile_disabled':\n          errorMessage = 'Verification service is not available';\n          break;\n        case 'invalid_token_format':\n        case 'token_too_long':\n          errorMessage = 'Invalid verification token';\n          break;\n        case 'turnstile_failed':\n          errorMessage = 'Verification failed. Please refresh and try again.';\n          break;\n        case 'api_error':\n        case 'internal_error':\n        case 'max_retries_exceeded':\n          errorMessage = 'Verification service temporarily unavailable. Please try again.';\n          break;\n      }\n\n      throw new BadRequestException(errorMessage);\n    }\n  }\n\n  async signup(body: ISignup, remoteIp?: string) {\n    const { email, password, defaultSpaceName, refMeta, inviteCode, turnstileToken } = body;\n\n    this.logger.log(\n      `Signup attempt - email: ${email}, hasPassword: ${!!password}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, hasVerification: ${!!body.verification}, remoteIp: ${remoteIp}`\n    );\n\n    await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);\n\n    await this.verifySignup(body);\n\n    const user = await this.userService.getUserByEmail(email);\n    this.isRegisteredValidate(user);\n    const { salt, hashPassword } = await this.encodePassword(password);\n    const res = await this.prismaService.$tx(async (prisma) => {\n      if (user) {\n        return await prisma.user.update({\n          where: { id: user.id, deletedTime: null },\n          data: {\n            salt,\n            password: hashPassword,\n            lastSignTime: new Date().toISOString(),\n            refMeta: refMeta ? JSON.stringify(refMeta) : undefined,\n          },\n        });\n      }\n      return await this.userService.createUserWithSettingCheck(\n        {\n          id: generateUserId(),\n          name: email.split('@')[0],\n          email,\n          salt,\n          password: hashPassword,\n          lastSignTime: new Date().toISOString(),\n          refMeta: isEmpty(refMeta) ? undefined : JSON.stringify(refMeta),\n        },\n        undefined,\n        defaultSpaceName,\n        inviteCode\n      );\n    });\n    this.eventEmitterService.emitAsync(Events.USER_SIGNUP, new UserSignUpEvent(res.id));\n    return res;\n  }\n\n  async sendSignupVerificationCodeWithTurnstile(\n    email: string,\n    turnstileToken?: string,\n    remoteIp?: string\n  ) {\n    this.logger.log(\n      `Send verification code attempt - email: ${email}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`\n    );\n\n    // Validate Turnstile token if enabled\n    await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);\n    return this.sendSignupVerificationCode(email);\n  }\n\n  async sendSignupVerificationCode(email: string) {\n    return await this.mailSenderService.checkSendMailRateLimit(\n      {\n        email,\n        rateLimitKey: 'signup-verification',\n        rateLimit: this.thresholdConfig.signupVerificationSendCodeMailRate,\n      },\n      async () => {\n        const code = getRandomString(4, RandomType.Number);\n        const token = await this.jwtSignupCode(email, code);\n\n        const user = await this.userService.getUserByEmail(email);\n        this.isRegisteredValidate(user);\n\n        // Log verification code sending\n        this.logger.log(\n          `Sending signup verification code - email: ${email}, timestamp: ${new Date().toISOString()}`\n        );\n\n        const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({\n          code,\n          expiresIn: this.authConfig.signupVerificationExpiresIn,\n          type: EmailVerifyCodeType.Signup,\n        });\n\n        await this.mailSenderService.sendMail(\n          {\n            to: email,\n\n            ...emailOptions,\n          },\n          {\n            type: MailType.VerifyCode,\n            transporterName: MailTransporterType.Notify,\n          }\n        );\n        return {\n          token,\n          expiresTime: new Date(\n            ms(this.authConfig.signupVerificationExpiresIn) + Date.now()\n          ).toISOString(),\n        };\n      }\n    );\n  }\n\n  async changePassword({ password, newPassword }: IChangePasswordRo) {\n    const userId = this.cls.get('user.id');\n    const user = await this.getUserByIdOrThrow(userId);\n\n    const { password: currentHashPassword, salt } = user;\n    if (!(await this.comparePassword(password, currentHashPassword, salt))) {\n      throw new CustomHttpException(`Password is incorrect`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.passwordIncorrect',\n        },\n      });\n    }\n    const { salt: newSalt, hashPassword: newHashPassword } = await this.encodePassword(newPassword);\n    await this.prismaService.txClient().user.update({\n      where: { id: userId, deletedTime: null },\n      data: {\n        password: newHashPassword,\n        salt: newSalt,\n      },\n    });\n    // clear session\n    await this.sessionStoreService.clearByUserId(userId);\n  }\n\n  async sendResetPasswordEmail(email: string) {\n    return await this.mailSenderService.checkSendMailRateLimit(\n      {\n        email,\n        rateLimitKey: 'send-reset-password-email',\n        rateLimit: this.thresholdConfig.resetPasswordSendMailRate,\n      },\n      async () => {\n        const user = await this.userService.getUserByEmail(email);\n        if (!user || (user.accounts.length === 0 && user.password == null)) {\n          throw new CustomHttpException(`${email} not registered`, HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.auth.emailNotRegistered',\n            },\n          });\n        }\n\n        const resetPasswordCode = getRandomString(30);\n\n        const url = `${this.mailConfig.origin}/auth/reset-password?code=${resetPasswordCode}`;\n        const resetPasswordEmailOptions = await this.mailSenderService.resetPasswordEmailOptions({\n          name: user.name,\n          email: user.email,\n          resetPasswordUrl: url,\n        });\n        await this.mailSenderService.sendMail(\n          {\n            to: user.email,\n            ...resetPasswordEmailOptions,\n          },\n          {\n            type: MailType.ResetPassword,\n            transporterName: MailTransporterType.Notify,\n          }\n        );\n        await this.cacheService.set(\n          `reset-password-email:${resetPasswordCode}`,\n          { userId: user.id },\n          second(this.authConfig.resetPasswordEmailExpiresIn)\n        );\n      }\n    );\n  }\n\n  async resetPassword(code: string, newPassword: string) {\n    const resetPasswordEmail = await this.cacheService.get(`reset-password-email:${code}`);\n    if (!resetPasswordEmail) {\n      throw new CustomHttpException(`Token is invalid`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.tokenInvalid',\n        },\n      });\n    }\n    const { userId } = resetPasswordEmail;\n    const { salt, hashPassword } = await this.encodePassword(newPassword);\n    await this.prismaService.txClient().user.update({\n      where: { id: userId, deletedTime: null },\n      data: {\n        password: hashPassword,\n        salt,\n      },\n    });\n    await this.cacheService.del(`reset-password-email:${code}`);\n    // clear session\n    await this.sessionStoreService.clearByUserId(userId);\n  }\n\n  async addPassword(newPassword: string) {\n    const userId = this.cls.get('user.id');\n    const user = await this.getUserByIdOrThrow(userId);\n\n    if (user.password) {\n      throw new CustomHttpException(`Password is already set`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.passwordAlreadyExists',\n        },\n      });\n    }\n    const { salt, hashPassword } = await this.encodePassword(newPassword);\n    await this.prismaService.txClient().user.update({\n      where: { id: userId, deletedTime: null, password: null },\n      data: {\n        password: hashPassword,\n        salt,\n      },\n    });\n    // clear session\n    await this.sessionStoreService.clearByUserId(userId);\n  }\n\n  async changeEmail(email: string, token: string, code: string) {\n    const currentEmail = this.cls.get('user.email');\n    const {\n      code: _code,\n      email: _currentEmail,\n      newEmail,\n    } = await this.jwtService\n      .verifyAsync<{ email: string; code: string; newEmail: string }>(token)\n      .catch(() => {\n        throw new CustomHttpException(\n          'Verification code is invalid',\n          HttpErrorCode.INVALID_CAPTCHA\n        );\n      });\n    if (\n      newEmail.toLowerCase() !== email.toLowerCase() ||\n      _currentEmail !== currentEmail ||\n      _code !== code\n    ) {\n      throw new CustomHttpException('Verification code is invalid', HttpErrorCode.INVALID_CAPTCHA, {\n        localization: {\n          i18nKey: 'httpErrors.auth.verificationCodeInvalid',\n        },\n      });\n    }\n    const user = this.cls.get('user');\n    const normalizedEmail = newEmail.toLowerCase();\n    await this.prismaService.txClient().user.update({\n      where: { id: user.id, deletedTime: null, deactivatedTime: null },\n      data: { email: normalizedEmail },\n    });\n    this.eventEmitterService.emitAsync(\n      Events.USER_EMAIL_CHANGE,\n      new UserEmailChangeEvent(user.id, currentEmail, normalizedEmail)\n    );\n    // clear session\n    await this.sessionStoreService.clearByUserId(user.id);\n  }\n\n  async sendChangeEmailCode(newEmail: string, password: string) {\n    const email = this.cls.get('user.email');\n    if (newEmail.toLowerCase() === email.toLowerCase()) {\n      throw new CustomHttpException(\n        'New email is the same as the current email',\n        HttpErrorCode.CONFLICT,\n        {\n          localization: {\n            i18nKey: 'httpErrors.auth.newEmailSameAsCurrentEmail',\n          },\n        }\n      );\n    }\n    const invalidPasswordError = new CustomHttpException(\n      'Password is incorrect',\n      HttpErrorCode.INVALID_CREDENTIALS,\n      {\n        localization: {\n          i18nKey: 'httpErrors.auth.passwordIncorrect',\n        },\n      }\n    );\n\n    return await this.mailSenderService.checkSendMailRateLimit(\n      {\n        email: newEmail,\n        rateLimitKey: 'send-change-email-code',\n        rateLimit: this.thresholdConfig.changeEmailSendCodeMailRate,\n      },\n      async () => {\n        const user = await this.validateUserByEmail(email, password).catch(() => {\n          throw invalidPasswordError;\n        });\n        if (!user) {\n          throw invalidPasswordError;\n        }\n        const userByNewEmail = await this.userService.getUserByEmail(newEmail);\n        if (userByNewEmail) {\n          throw new CustomHttpException(`New email is already registered`, HttpErrorCode.CONFLICT, {\n            localization: {\n              i18nKey: 'httpErrors.auth.emailAlreadyRegistered',\n            },\n          });\n        }\n        const code = getRandomString(4, RandomType.Number);\n        const token = await this.jwtService.signAsync(\n          { email, newEmail, code },\n          { expiresIn: this.baseConfig.emailCodeExpiresIn }\n        );\n        const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({\n          code,\n          expiresIn: this.baseConfig.emailCodeExpiresIn,\n          type: EmailVerifyCodeType.ChangeEmail,\n        });\n        await this.mailSenderService.sendMail(\n          {\n            to: newEmail,\n            ...emailOptions,\n          },\n          {\n            type: MailType.VerifyCode,\n            transporterName: MailTransporterType.Notify,\n          }\n        );\n        return { token };\n      }\n    );\n  }\n\n  async joinWaitlist(email: string) {\n    const setting = await this.settingService.getSetting();\n    if (!setting?.enableWaitlist) {\n      throw new CustomHttpException(`Waitlist is not enabled`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.auth.waitlistNotEnabled',\n        },\n      });\n    }\n    const user = await this.userService.getUserByEmail(email);\n    if (user) {\n      throw new CustomHttpException(`Email already registered`, HttpErrorCode.CONFLICT, {\n        localization: {\n          i18nKey: 'httpErrors.auth.emailAlreadyRegistered',\n        },\n      });\n    }\n    const find = await this.prismaService.txClient().waitlist.findFirst({\n      where: { email },\n    });\n    if (find) {\n      return find;\n    }\n    return await this.prismaService.txClient().waitlist.create({\n      data: { email },\n    });\n  }\n\n  async getWaitlist() {\n    return await this.prismaService.txClient().waitlist.findMany({\n      orderBy: { createdTime: 'desc' },\n    });\n  }\n\n  async inviteWaitlist(emails: string[]) {\n    const list = await this.prismaService.txClient().waitlist.findMany({\n      where: { email: { in: emails } },\n    });\n\n    const updateList = list.filter((item) => !item.invite);\n\n    if (updateList.length === 0) {\n      return [];\n    }\n\n    await this.prismaService.txClient().waitlist.updateMany({\n      where: { email: { in: updateList.map((item) => item.email) } },\n      data: { invite: true, inviteTime: new Date().toISOString() },\n    });\n\n    const res: IInviteWaitlistVo = [];\n    for (const item of updateList) {\n      const times = 10;\n      const code = await this.genWaitlistInviteCode(times);\n      const mailOptions = await this.mailSenderService.waitlistInviteEmailOptions({\n        email: item.email,\n        code,\n        times,\n        name: 'Guest',\n        waitlistInviteUrl: `${this.mailConfig.origin}/auth/signup?inviteCode=${code}`,\n      });\n      res.push({\n        email: item.email,\n        code,\n        times,\n      });\n      this.mailSenderService.sendMail(\n        {\n          to: item.email,\n          ...mailOptions,\n        },\n        {\n          transporterName: MailTransporterType.Notify,\n          type: MailType.WaitlistInvite,\n        }\n      );\n    }\n\n    return res;\n  }\n\n  async genWaitlistInviteCode(limit: number) {\n    const code = `${getRandomString(4)}-${getRandomString(4)}`;\n    await this.cacheService.set(`waitlist:invite-code:${code}`, limit, '30d');\n    return code;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/oauth/oauth.store.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { getRandomString } from '@teable/core';\nimport type { Request } from 'express';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { IOauth2State } from '../../../cache/types';\nimport { second } from '../../../utils/second';\n\n@Injectable()\nexport class OauthStoreService {\n  key: string = 'oauth2:';\n\n  constructor(private readonly cacheService: CacheService) {}\n\n  async store(req: Request, callback: (err: unknown, stateId: string) => void, ...args: unknown[]) {\n    if (args.length === 3 && typeof args[2] === 'function') {\n      callback = args[2] as (err: unknown, stateId: string) => void;\n    }\n    const random = getRandomString(16);\n    await this.cacheService.set(\n      `oauth2:${random}`,\n      {\n        redirectUri: req.query.redirect_uri as string,\n      },\n      second('12h')\n    );\n    callback(null, random);\n  }\n\n  async verify(\n    _req: unknown,\n    stateId: string,\n    callback: (err: unknown, ok: boolean, state: IOauth2State | string) => void\n  ) {\n    const state = await this.cacheService.get(`oauth2:${stateId}`);\n    if (state) {\n      await this.cacheService.del(`oauth2:${stateId}`);\n      callback(null, true, state);\n    } else {\n      callback(null, false, 'Invalid authorization request state');\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/permission.module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport { authConfig, type IAuthConfig } from '../../configs/auth.config';\nimport { PermissionGuard } from './guard/permission.guard';\nimport { PermissionService } from './permission.service';\n\n@Global()\n@Module({\n  imports: [\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n  ],\n  providers: [PermissionService, PermissionGuard],\n  exports: [PermissionService, PermissionGuard],\n})\nexport class PermissionModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/permission.service.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ForbiddenException } from '@nestjs/common';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { Action } from '@teable/core';\nimport { Role, getPermissions } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { noop } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { DeepMockProxy } from 'vitest-mock-extended';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { getError } from '../../../test/utils/get-error';\nimport { GlobalModule } from '../../global/global.module';\nimport type { IClsStore } from '../../types/cls';\nimport { PermissionModule } from './permission.module';\nimport { PermissionService } from './permission.service';\n\ndescribe('PermissionService', () => {\n  let service: PermissionService;\n  let prismaServiceMock: DeepMockProxy<PrismaService>;\n  let clsServiceMock: DeepMockProxy<ClsService<IClsStore>>;\n\n  beforeEach(async () => {\n    prismaServiceMock = mockDeep<PrismaService>();\n    clsServiceMock = mockDeep<ClsService<IClsStore>>();\n\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, PermissionModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaServiceMock)\n      .overrideProvider(ClsService)\n      .useValue(clsServiceMock)\n      .compile();\n\n    service = module.get<PermissionService>(PermissionService);\n  });\n\n  afterEach(() => {\n    mockReset(prismaServiceMock);\n    mockReset(clsServiceMock);\n  });\n\n  describe('getRoleBySpaceId', () => {\n    it('should return a SpaceRole', async () => {\n      const spaceId = 'space-id';\n      const roleName = 'space-role';\n      prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);\n      prismaServiceMock.space.findFirst.mockResolvedValue({ deletedTime: null } as any);\n      const result = await service['getRoleBySpaceId'](spaceId);\n      expect(result).toBe(roleName);\n    });\n\n    it('should throw a ForbiddenException if collaborator is not found', async () => {\n      const spaceId = 'space-id1';\n      prismaServiceMock.collaborator.findMany.mockResolvedValue([]);\n      prismaServiceMock.space.findFirst.mockResolvedValue({ deletedTime: null } as any);\n      const res = await service['getRoleBySpaceId'](spaceId);\n      expect(res).toBeNull();\n    });\n  });\n\n  describe('getRoleByBaseId', () => {\n    it('should return a BaseRole', async () => {\n      const baseId = 'base-id';\n      const roleName = 'base-role';\n      prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);\n      const result = await service['getRoleByBaseId'](baseId);\n      expect(result).toBe(roleName);\n    });\n\n    it('should return null if collaborator is not found', async () => {\n      const baseId = 'base-id1';\n      prismaServiceMock.collaborator.findMany.mockResolvedValue([]);\n      const result = await service['getRoleByBaseId'](baseId);\n      expect(result).toBeNull();\n    });\n  });\n\n  describe('getPermissionsByResourceId', () => {\n    it('should return permissions for a space resource', async () => {\n      const resourceId = 'spcxxxxxxxx';\n      vi.spyOn(service as any, 'getPermissionBySpaceId').mockImplementation(noop);\n      await service.getPermissionsByResourceId(resourceId);\n      expect(service['getPermissionBySpaceId']).toHaveBeenCalledWith(resourceId, undefined);\n    });\n\n    it('should return permissions for a base resource', async () => {\n      const resourceId = 'bsexxxxxx';\n      vi.spyOn(service as any, 'getPermissionByBaseId').mockImplementation(noop);\n      await service.getPermissionsByResourceId(resourceId);\n      expect(service['getPermissionByBaseId']).toHaveBeenCalledWith(resourceId, undefined);\n    });\n\n    it('should return permissions for a table resource', async () => {\n      const resourceId = 'tblxxxxxxx';\n      vi.spyOn(service as any, 'getPermissionByTableId').mockImplementation(noop);\n      await service.getPermissionsByResourceId(resourceId);\n      expect(service['getPermissionByTableId']).toHaveBeenCalledWith(resourceId, undefined);\n    });\n\n    it('should throw an error if resource is not found', async () => {\n      const resourceId = 'invalid-id';\n      const error = await getError(\n        async () => await service.getPermissionsByResourceId(resourceId)\n      );\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(403);\n      expect(error?.message).toBe('Request path is not valid');\n    });\n  });\n\n  describe('getUpperIdByBaseId', () => {\n    it('should return spaceId when valid baseId is provided', async () => {\n      const baseId = 'bsexxxxxxxx';\n      const spaceId = 'spcxxxxxxxxx';\n\n      prismaServiceMock.base.findFirst.mockResolvedValueOnce({ spaceId } as any);\n      const result = await service['getUpperIdByBaseId'](baseId);\n      expect(result).toEqual({ spaceId });\n    });\n\n    it('should throw NotFoundException when invalid baseId is provided', async () => {\n      const baseId = 'bsexxxxxxxx';\n\n      prismaServiceMock.base.findFirst.mockResolvedValueOnce(null);\n\n      const error = await getError(async () => await service['getUpperIdByBaseId'](baseId));\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(404);\n      expect(error?.message).toBe('Base not found');\n    });\n  });\n\n  describe('isBaseIdAllowedForResource', () => {\n    it('should return true when baseId is allowed for the resource', async () => {\n      const baseId = 'bsexxxxxxxxx';\n      const spaceIds = ['spcxxxxxxx'];\n      const baseIds = ['bsexxxxxxxxx'];\n\n      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({\n        spaceId: 'spcxxxxxxx',\n      });\n\n      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when baseId is not allowed for the resource', async () => {\n      const baseId = 'invalidBaseId';\n      const spaceIds = ['spcxxxxxxx'];\n      const baseIds = ['bsexxxxxxxxx'];\n\n      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({\n        spaceId: 'spc222222222',\n      });\n\n      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when baseIds is undefined', async () => {\n      const baseId = 'bsexxxxxxxxx';\n      const spaceIds = ['spcxxxxxxx'];\n      const baseIds = undefined;\n\n      vi.spyOn(service as any, 'getUpperIdByBaseId').mockResolvedValueOnce({\n        spaceId: 'spcxxxxxxx',\n      });\n\n      const result = await service['isBaseIdAllowedForResource'](baseId, spaceIds, baseIds);\n\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('isTableIdAllowedForResource', () => {\n    it('should return true when tableId is allowed for the resource', async () => {\n      const tableId = 'validTableId';\n      const spaceIds = ['spcxxxxxx'];\n      const baseIds = ['bsexxxxxx'];\n\n      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({\n        spaceId: 'spcxxxxxx',\n        baseId: 'bsexxxxxx',\n      });\n\n      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when tableId is not allowed for the resource', async () => {\n      const tableId = 'invalidTableId';\n      const spaceIds = ['spcxxxxxx'];\n      const baseIds = ['bsexxxxxx'];\n\n      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({\n        spaceId: 'spc11111111',\n        baseId: 'bse1111111',\n      });\n\n      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when baseIds is undefined', async () => {\n      const tableId = 'tblxxxxxx';\n      const spaceIds = ['spcxxxxxx'];\n      const baseIds = undefined;\n\n      vi.spyOn(service as any, 'getUpperIdByTableId').mockResolvedValueOnce({\n        spaceId: 'spcxxxxxx',\n        baseId: 'bsexxxxxxx',\n      });\n\n      const result = await service['isTableIdAllowedForResource'](tableId, spaceIds, baseIds);\n\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('getPermissionsByAccessToken', () => {\n    it('should return scopes when resourceId is a valid spaceId and allowed', async () => {\n      const resourceId = 'spcxxxxxxx';\n      const accessTokenId = 'validAccessTokenId';\n      const scopes: Action[] = ['table|create', 'table|update'];\n      const spaceIds = ['spcxxxxxxx'];\n\n      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({\n        scopes,\n        spaceIds,\n        baseIds: undefined,\n        hasFullAccess: undefined,\n      });\n\n      const result = await service.getPermissionsByAccessToken(resourceId, accessTokenId);\n\n      expect(result).toEqual(scopes);\n    });\n\n    it('should throw ForbiddenException when resourceId is a valid spaceId but not allowed', async () => {\n      const resourceId = 'invalidSpaceId';\n      const accessTokenId = 'validAccessTokenId';\n      const spaceIds = ['spcxxxxxxx'];\n\n      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({\n        scopes: ['table|update'],\n        spaceIds,\n        baseIds: undefined,\n        hasFullAccess: undefined,\n      });\n\n      const error = await getError(\n        async () => await service.getPermissionsByAccessToken(resourceId, accessTokenId)\n      );\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(403);\n    });\n\n    it('should throw ForbiddenException when resourceId is a valid baseId but not allowed', async () => {\n      const resourceId = 'bsexxxxxx';\n      const accessTokenId = 'validAccessTokenId';\n      const baseIds = ['bsexxxxxx1'];\n\n      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({\n        scopes: ['table|read'],\n        baseIds,\n        spaceIds: undefined,\n        hasFullAccess: undefined,\n      });\n\n      vi.spyOn(service as any, 'isBaseIdAllowedForResource').mockResolvedValueOnce(false);\n\n      const error = await getError(\n        async () => await service.getPermissionsByAccessToken(resourceId, accessTokenId)\n      );\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(403);\n    });\n\n    it('should throw ForbiddenException when resourceId is a valid tableId but not allowed', async () => {\n      const resourceId = 'invalidTableId';\n      const accessTokenId = 'validAccessTokenId';\n      const baseIds = ['bsexxxxxx'];\n      const spaceIds = ['spcxxxxxxx'];\n\n      vi.spyOn(service, 'getAccessToken').mockResolvedValueOnce({\n        scopes: ['table|read'],\n        spaceIds,\n        baseIds,\n      });\n\n      const error = await getError(\n        async () => await service.getPermissionsByAccessToken(resourceId, accessTokenId)\n      );\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(403);\n    });\n  });\n\n  describe('getPermissions', () => {\n    it('should return permissions for a user', async () => {\n      const resourceId = 'bsexxxxxx';\n      vi.spyOn(service, 'getPermissionsByResourceId').mockResolvedValue(\n        getPermissions(Role.Editor)\n      );\n      const result = await service.getPermissions(resourceId);\n      expect(result.includes('view|create')).toEqual(true);\n      expect(result.includes('space|create')).toEqual(false);\n    });\n\n    it('should return permissions for access token', async () => {\n      const resourceId = 'bsexxxxxx';\n      vi.spyOn(service, 'getPermissionsByResourceId').mockResolvedValue(\n        getPermissions(Role.Editor)\n      );\n      vi.spyOn(service, 'getPermissionsByAccessToken').mockResolvedValue([\n        'view|create',\n        'space|delete',\n      ]);\n      const result = await service.getPermissions(resourceId, 'access-token-id');\n      expect(result.includes('view|create')).toEqual(true);\n      expect(result.includes('space|delete')).toEqual(false);\n      expect(result.includes('view|delete')).toEqual(false);\n    });\n  });\n\n  describe('validPermissions', () => {\n    it('should return true if user has all required permissions', async () => {\n      const permissions = getPermissions(Role.Creator);\n      vi.spyOn(service, 'getPermissions').mockResolvedValue(permissions);\n      const resourceId = 'bsexxxxxx';\n      const result = await service.validPermissions(resourceId, ['base|create']);\n      expect(result).toEqual(permissions);\n    });\n\n    it('should throw an error if user does not have all required permissions', async () => {\n      vi.spyOn(service, 'getPermissions').mockResolvedValue(getPermissions(Role.Editor));\n      const resourceId = 'bsexxxxxx';\n      await expect(service.validPermissions(resourceId, ['space|create'])).rejects.toThrow(\n        `not allowed to operate space|create on ${resourceId}`\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/permission.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport type { IBaseRole, Action } from '@teable/core';\nimport {\n  HttpErrorCode,\n  IdPrefix,\n  TemplatePermissions,\n  getPermissions,\n  isAnonymous,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType } from '@teable/openapi';\nimport { intersection, union } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException, TemplateAppTokenNotAllowedException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { getMaxLevelRole } from '../../utils/get-max-level-role';\nimport { CollaboratorModel } from '../model/collaborator';\nimport { TemplateModel } from '../model/template';\n\ninterface IBaseNodeCacheItem {\n  id: string;\n  parentId: string | null;\n  resourceType: string;\n  resourceId: string | null;\n}\n\nconst notAllowedOperationI18nKey = 'httpErrors.permission.notAllowedOperation';\n\n@Injectable()\nexport class PermissionService {\n  private readonly logger = new Logger(PermissionService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly collaboratorModel: CollaboratorModel,\n    private readonly templateModel: TemplateModel,\n    private readonly jwtService: JwtService\n  ) {}\n\n  private getDepartmentIds() {\n    const departments = this.cls.get('organization.departments');\n    return departments?.map((department) => department.id) || [];\n  }\n\n  async getSpaceCollaborators(spaceId: string, principalId: string[]) {\n    const collaborators = await this.collaboratorModel.getCollaboratorRawByResourceId(spaceId);\n    return collaborators.filter((collaborator) => principalId.includes(collaborator.principalId));\n  }\n\n  async getBaseCollaborators(baseId: string, principalId: string[]) {\n    const collaborators = await this.collaboratorModel.getCollaboratorRawByResourceId(baseId);\n    return collaborators.filter((collaborator) => principalId.includes(collaborator.principalId));\n  }\n\n  async getRoleBySpaceId(spaceId: string, includeInactiveResource?: boolean) {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.getDepartmentIds();\n    const collaborators = await this.getSpaceCollaborators(spaceId, [...departmentIds, userId]);\n    const space = await this.prismaService.space.findFirst({\n      where: {\n        id: spaceId,\n      },\n    });\n    if (!space) {\n      throw new CustomHttpException(\n        `space ${spaceId} is not found`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.notFound',\n          },\n        }\n      );\n    }\n    if (space?.deletedTime && !includeInactiveResource) {\n      throw new CustomHttpException(\n        `space ${spaceId} is deleted`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.deleted',\n          },\n        }\n      );\n    }\n    if (!collaborators.length) {\n      return null;\n    }\n    return getMaxLevelRole(collaborators);\n  }\n\n  async getRoleByBaseId(baseId: string) {\n    const departmentIds = this.getDepartmentIds();\n    const userId = this.cls.get('user.id');\n\n    const collaborators = await this.getBaseCollaborators(baseId, [...departmentIds, userId]);\n    if (!collaborators.length) {\n      return null;\n    }\n    return getMaxLevelRole(collaborators) as IBaseRole;\n  }\n\n  async getOAuthAccessBy(userId: string) {\n    const departmentIds = this.getDepartmentIds();\n    const collaborators = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: { in: [...departmentIds, userId] },\n      },\n      select: { roleName: true, resourceId: true, resourceType: true },\n    });\n\n    const spaceIds: string[] = [];\n    const baseIds: string[] = [];\n    collaborators.forEach(({ resourceId, resourceType }) => {\n      if (resourceType === CollaboratorType.Base) {\n        baseIds.push(resourceId);\n      } else if (resourceType === CollaboratorType.Space) {\n        spaceIds.push(resourceId);\n      }\n    });\n\n    return { spaceIds, baseIds };\n  }\n\n  async getAccessToken(accessTokenId: string) {\n    const {\n      scopes: stringifyScopes,\n      spaceIds,\n      baseIds,\n      clientId,\n      userId,\n      hasFullAccess,\n    } = await this.prismaService.accessToken.findFirstOrThrow({\n      where: { id: accessTokenId },\n      select: {\n        scopes: true,\n        spaceIds: true,\n        baseIds: true,\n        clientId: true,\n        userId: true,\n        hasFullAccess: true,\n      },\n    });\n    const scopes = JSON.parse(stringifyScopes) as Action[];\n    if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) {\n      const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } =\n        await this.getOAuthAccessBy(userId);\n      return {\n        scopes: scopes.concat('base|read_all'),\n        spaceIds: spaceIdsByOAuth,\n        baseIds: baseIdsByOAuth,\n      };\n    }\n    return {\n      scopes,\n      spaceIds: spaceIds ? JSON.parse(spaceIds) : undefined,\n      baseIds: baseIds ? JSON.parse(baseIds) : undefined,\n      hasFullAccess: hasFullAccess ?? undefined,\n    };\n  }\n\n  async getUpperIdByTableId(\n    tableId: string,\n    includeInactiveResource?: boolean\n  ): Promise<{ spaceId: string; baseId: string }> {\n    const table = await this.prismaService.txClient().tableMeta.findFirst({\n      where: {\n        id: tableId,\n        ...(includeInactiveResource ? {} : { deletedTime: null }),\n      },\n      select: {\n        base: true,\n      },\n    });\n    const baseId = table?.base.id;\n    const spaceId = table?.base?.spaceId;\n    if (!spaceId || !baseId) {\n      throw new CustomHttpException(`Invalid tableId: ${tableId}`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.table.notFound',\n        },\n      });\n    }\n    this.cls.set('spaceId', spaceId);\n    return { baseId, spaceId };\n  }\n\n  async getUpperIdByBaseId(\n    baseId: string,\n    includeInactiveResource?: boolean\n  ): Promise<{ spaceId: string }> {\n    const base = await this.prismaService.base.findFirst({\n      where: {\n        id: baseId,\n        ...(includeInactiveResource ? {} : { deletedTime: null }),\n      },\n      select: {\n        spaceId: true,\n      },\n    });\n    const spaceId = base?.spaceId;\n    if (!spaceId) {\n      throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.base.notFound',\n        },\n      });\n    }\n    this.cls.set('spaceId', spaceId);\n    return { spaceId };\n  }\n  private async isBaseIdAllowedForResource(\n    baseId: string,\n    spaceIds: string[] | undefined,\n    baseIds: string[] | undefined,\n    includeInactiveResource?: boolean\n  ) {\n    const upperId = await this.getUpperIdByBaseId(baseId, includeInactiveResource);\n    return spaceIds?.includes(upperId.spaceId) || baseIds?.includes(baseId);\n  }\n\n  private async isTableIdAllowedForResource(\n    tableId: string,\n    spaceIds: string[] | undefined,\n    baseIds: string[] | undefined,\n    includeInactiveResource?: boolean\n  ) {\n    const { spaceId, baseId } = await this.getUpperIdByTableId(tableId, includeInactiveResource);\n    return spaceIds?.includes(spaceId) || baseIds?.includes(baseId);\n  }\n\n  async getPermissionsByAccessToken(\n    resourceId: string,\n    accessTokenId: string,\n    includeInactiveResource?: boolean\n  ) {\n    const { scopes, spaceIds, baseIds, hasFullAccess } = await this.getAccessToken(accessTokenId);\n\n    if (hasFullAccess) {\n      return scopes;\n    }\n\n    if (\n      !resourceId.startsWith(IdPrefix.Space) &&\n      !resourceId.startsWith(IdPrefix.Base) &&\n      !resourceId.startsWith(IdPrefix.Table)\n    ) {\n      throw new CustomHttpException(\n        `Resource ${resourceId} is not valid`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.invalidResource',\n          },\n        }\n      );\n    }\n\n    if (resourceId.startsWith(IdPrefix.Space) && !spaceIds?.includes(resourceId)) {\n      throw new CustomHttpException(\n        `You are not allowed to access space ${resourceId}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.notAllowedSpace',\n          },\n        }\n      );\n    }\n\n    // set the spaceId to the cls when the user operate in a space\n    if (resourceId.startsWith(IdPrefix.Space)) {\n      this.cls.set('spaceId', resourceId);\n    }\n\n    if (\n      resourceId.startsWith(IdPrefix.Base) &&\n      !(await this.isBaseIdAllowedForResource(\n        resourceId,\n        spaceIds,\n        baseIds,\n        includeInactiveResource\n      ))\n    ) {\n      throw new CustomHttpException(\n        `You are not allowed to access base ${resourceId}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.notAllowedBase',\n          },\n        }\n      );\n    }\n\n    if (\n      resourceId.startsWith(IdPrefix.Table) &&\n      !(await this.isTableIdAllowedForResource(\n        resourceId,\n        spaceIds,\n        baseIds,\n        includeInactiveResource\n      ))\n    ) {\n      throw new CustomHttpException(\n        `You are not allowed to access table ${resourceId}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.notAllowedTables',\n            context: {\n              tableIds: resourceId,\n            },\n          },\n        }\n      );\n    }\n\n    return scopes;\n  }\n\n  private async getPermissionBySpaceId(spaceId: string, includeInactiveResource?: boolean) {\n    const role = await this.getRoleBySpaceId(spaceId, includeInactiveResource);\n    if (!role) {\n      throw new CustomHttpException(\n        `you have no permission to access this space`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.notAllowedSpace',\n          },\n        }\n      );\n    }\n    this.cls.set('spaceId', spaceId);\n    return getPermissions(role);\n  }\n\n  private async getPermissionByBaseId(baseId: string, includeInactiveResource?: boolean) {\n    const tempAuthBaseId = this.cls.get('tempAuthBaseId');\n    if (tempAuthBaseId === baseId) {\n      const template = await this.templateModel.getTemplateRawByBaseId(baseId);\n      if (template) {\n        this.cls.set('template', {\n          id: template.id,\n          baseId: template.snapshot.baseId,\n        });\n        return TemplatePermissions;\n      } else {\n        return getPermissions('owner');\n      }\n    }\n    const role = await this.getRoleByBaseId(baseId);\n    const spaceRole = await this.getRoleBySpaceId(\n      (await this.getUpperIdByBaseId(baseId, includeInactiveResource)).spaceId,\n      includeInactiveResource\n    );\n    if (!role && !spaceRole) {\n      throw new CustomHttpException(\n        `you have no permission to access this base`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.notAllowedBase',\n          },\n        }\n      );\n    }\n    const basePermissions = role ? getPermissions(role) : [];\n    const spacePermissions = spaceRole ? getPermissions(spaceRole) : [];\n    // In the presence of an organization, a user can have concurrent permissions at both space and base levels,\n    // requiring a merge operation to determine the highest applicable permission level\n    return union(basePermissions, spacePermissions);\n  }\n\n  private async getPermissionByTableId(tableId: string, includeInactiveResource?: boolean) {\n    const baseId = (await this.getUpperIdByTableId(tableId, includeInactiveResource)).baseId;\n    return this.getPermissionByBaseId(baseId, includeInactiveResource);\n  }\n\n  async getPermissionsByResourceId(resourceId: string, includeInactiveResource?: boolean) {\n    if (resourceId.startsWith(IdPrefix.Space)) {\n      return await this.getPermissionBySpaceId(resourceId, includeInactiveResource);\n    } else if (resourceId.startsWith(IdPrefix.Base)) {\n      return await this.getPermissionByBaseId(resourceId, includeInactiveResource);\n    } else if (resourceId.startsWith(IdPrefix.Table)) {\n      return await this.getPermissionByTableId(resourceId, includeInactiveResource);\n    } else {\n      throw new CustomHttpException(\n        `Request path is not valid`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.invalidRequestPath',\n          },\n        }\n      );\n    }\n  }\n\n  async getPermissions(\n    resourceId: string,\n    accessTokenId?: string,\n    includeInactiveResource?: boolean\n  ) {\n    const userPermissions = await this.getPermissionsByResourceId(\n      resourceId,\n      includeInactiveResource\n    );\n\n    if (accessTokenId) {\n      const accessTokenPermission = await this.getPermissionsByAccessToken(\n        resourceId,\n        accessTokenId,\n        includeInactiveResource\n      );\n      return intersection(userPermissions, accessTokenPermission);\n    }\n    return userPermissions;\n  }\n\n  async validPermissions(\n    resourceId: string,\n    permissions: Action[],\n    accessTokenId?: string,\n    includeInactiveResource?: boolean\n  ) {\n    const ownPermissions = await this.getPermissions(\n      resourceId,\n      accessTokenId,\n      includeInactiveResource\n    );\n    if (permissions.every((permission) => ownPermissions.includes(permission))) {\n      return ownPermissions;\n    }\n    // for app token operation not allowed in template preview app\n    if (\n      this.cls.get('template') &&\n      this.cls.get('tempAuthBaseId') === this.cls.get('template.baseId')\n    ) {\n      throw new TemplateAppTokenNotAllowedException();\n    }\n    throw new CustomHttpException(\n      `not allowed to operate ${permissions.join(', ')} on ${resourceId}`,\n      HttpErrorCode.RESTRICTED_RESOURCE,\n      {\n        localization: {\n          i18nKey: notAllowedOperationI18nKey,\n        },\n      }\n    );\n  }\n\n  private isAnonymous() {\n    return isAnonymous(this.cls.get('user.id'));\n  }\n\n  async getTemplatePermissions(resourceId: string) {\n    const deniedResourceError = new CustomHttpException(\n      `Template access denied, template not found for ${resourceId}`,\n      this.isAnonymous() ? HttpErrorCode.UNAUTHORIZED : HttpErrorCode.RESTRICTED_RESOURCE,\n      {\n        localization: {\n          i18nKey: 'httpErrors.base.templateNotFound',\n        },\n      }\n    );\n    if (resourceId.startsWith(IdPrefix.Base)) {\n      const template = await this.templateModel.getTemplateRawByBaseId(resourceId);\n      if (!template?.id) {\n        this.logger.error(`Template access denied, template not found for ${resourceId}`);\n        throw deniedResourceError;\n      }\n      this.cls.set('template', {\n        id: template.id,\n        baseId: template.snapshot.baseId,\n      });\n    } else if (resourceId.startsWith(IdPrefix.Table)) {\n      const table = await this.prismaService.txClient().tableMeta.findUnique({\n        where: {\n          id: resourceId,\n          deletedTime: null,\n          base: { deletedTime: null },\n        },\n        select: {\n          baseId: true,\n        },\n      });\n      if (!table) {\n        this.logger.error(`Template access denied, table not found for ${resourceId}`);\n        throw deniedResourceError;\n      }\n      const template = await this.templateModel.getTemplateRawByBaseId(table.baseId);\n      if (!template) {\n        this.logger.error(`Template access denied, template not found for ${resourceId}`);\n        throw deniedResourceError;\n      }\n      this.cls.set('template', {\n        id: template.id,\n        baseId: template.snapshot.baseId,\n      });\n    } else {\n      throw new CustomHttpException(\n        `Resource ${resourceId} is not valid for template`,\n        this.isAnonymous() ? HttpErrorCode.UNAUTHORIZED : HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.invalidResource',\n          },\n        }\n      );\n    }\n    return TemplatePermissions;\n  }\n\n  async validTemplatePermissions(resourceId: string, permissions: Action[]) {\n    const template = this.cls.get('template');\n    const templatePermissions = template\n      ? TemplatePermissions\n      : await this.getTemplatePermissions(resourceId);\n    if (permissions.every((permission) => templatePermissions.includes(permission))) {\n      return templatePermissions;\n    }\n    throw new CustomHttpException(\n      `Template access denied, not allowed to operate ${permissions.join(', ')} on ${resourceId}`,\n      HttpErrorCode.RESTRICTED_RESOURCE,\n      {\n        localization: {\n          i18nKey: notAllowedOperationI18nKey,\n        },\n      }\n    );\n  }\n\n  getTemplateIdByHeader(templateHeader: string) {\n    try {\n      return this.jwtService.verify<{ templateId: string }>(templateHeader).templateId;\n    } catch {\n      return null;\n    }\n  }\n\n  generateTemplateHeader(templateId: string) {\n    return this.jwtService.sign({ templateId }, { expiresIn: '1d' });\n  }\n\n  // Base share permission methods\n  async getBaseShareInfo(shareId: string) {\n    const baseShare = await this.prismaService.baseShare.findFirst({\n      where: { shareId, enabled: true },\n    });\n    if (!baseShare) {\n      return null;\n    }\n    return baseShare;\n  }\n\n  async baseShareRequiresPassword(shareId: string) {\n    const baseShare = await this.prismaService.baseShare.findFirst({\n      where: { shareId, enabled: true },\n      select: { password: true },\n    });\n    return !!baseShare?.password;\n  }\n\n  async validateBaseSharePasswordToken(shareId: string, token: string) {\n    try {\n      const payload = await this.jwtService.verifyAsync<{ shareId: string; password: string }>(\n        token\n      );\n      if (payload.shareId !== shareId) {\n        return false;\n      }\n      const baseShare = await this.prismaService.baseShare.findFirst({\n        where: { shareId, enabled: true },\n        select: { password: true },\n      });\n      if (!baseShare?.password) {\n        return false;\n      }\n      return payload.password === baseShare.password;\n    } catch {\n      return false;\n    }\n  }\n\n  async getBaseSharePermissions(shareId: string, resourceId: string) {\n    const baseShare = await this.getBaseShareInfo(shareId);\n    if (!baseShare) {\n      throw new CustomHttpException(\n        `Base share ${shareId} is not found`,\n        HttpErrorCode.RESTRICTED_RESOURCE\n      );\n    }\n\n    const { baseId, nodeId } = baseShare;\n\n    if (!nodeId) {\n      throw new CustomHttpException(\n        `Base share ${shareId} has no nodeId`,\n        HttpErrorCode.RESTRICTED_RESOURCE\n      );\n    }\n\n    this.logger.debug(\n      `[BaseShare] Checking permission for resource ${resourceId}, shareId: ${shareId}, baseId: ${baseId}, nodeId: ${nodeId}`\n    );\n\n    const resourceBelongsToShare = await this.checkResourceBelongsToShare(\n      resourceId,\n      baseId,\n      nodeId\n    );\n\n    if (!resourceBelongsToShare) {\n      this.logger.warn(\n        `[BaseShare] Resource ${resourceId} is not accessible via share ${shareId}, baseId: ${baseId}, nodeId: ${nodeId}`\n      );\n      throw new CustomHttpException(\n        `Resource ${resourceId} is not accessible via share ${shareId}`,\n        HttpErrorCode.RESTRICTED_RESOURCE\n      );\n    }\n\n    // Set base share in cls for downstream services to use\n    this.cls.set('baseShare', { baseId, nodeId });\n\n    // Return template permissions (read-only), with record|copy if allowCopy is enabled\n    const permissions = [...TemplatePermissions];\n    if (baseShare.allowCopy) {\n      permissions.push('record|copy');\n    }\n    return permissions;\n  }\n\n  /**\n   * Check if a resource belongs to the shared base.\n   * Dispatches to specific check methods based on resource type.\n   */\n  private async checkResourceBelongsToShare(\n    resourceId: string,\n    baseId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    const prefix = resourceId.substring(0, 3);\n\n    switch (prefix) {\n      case IdPrefix.Base:\n        return resourceId === baseId;\n      case IdPrefix.Table:\n        return this.checkTableBelongsToShare(resourceId, baseId, nodeId);\n      case IdPrefix.View:\n        return this.checkViewBelongsToShare(resourceId, baseId, nodeId);\n      case IdPrefix.Field:\n        return this.checkFieldBelongsToShare(resourceId, baseId, nodeId);\n      case IdPrefix.App:\n        return this.checkAppBelongsToShare(resourceId, baseId, nodeId);\n      default:\n        return false;\n    }\n  }\n\n  /**\n   * Check if a table belongs to the shared base and is allowed by nodeId.\n   */\n  private async checkTableBelongsToShare(\n    tableId: string,\n    baseId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    const table = await this.prismaService.tableMeta.findUnique({\n      where: { id: tableId, deletedTime: null },\n      select: { baseId: true },\n    });\n\n    this.logger.debug(\n      `[BaseShare] Table ${tableId} baseId: ${table?.baseId}, share baseId: ${baseId}`\n    );\n\n    if (!table || table.baseId !== baseId) {\n      return false;\n    }\n\n    const result = await this.isTableAllowedByNodeId(baseId, tableId, nodeId);\n    if (result) {\n      this.logger.debug(`[BaseShare] Table belongs check: nodeId=${nodeId}, result=${result}`);\n      return true;\n    }\n\n    // Fallback: check if the table is a foreign table of a link field in a shared table.\n    // This allows link field targets to be accessible even when they are outside the shared node.\n    const linkedResult = await this.isTableLinkedFromSharedNode(baseId, tableId, nodeId);\n    this.logger.debug(\n      `[BaseShare] Table linked from shared node check: tableId=${tableId}, result=${linkedResult}`\n    );\n    return linkedResult;\n  }\n\n  /**\n   * Check if a table is referenced as a foreign table by any link field\n   * in the shared node's tables. This allows link field foreign tables\n   * to be accessible even if they're not directly under the shared node.\n   */\n  private async isTableLinkedFromSharedNode(\n    baseId: string,\n    foreignTableId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    // Get all nodes (cached)\n    const allNodes = await this.getBaseNodesWithCache(baseId);\n    const allowedNodeIds = this.collectDescendantNodeIds(allNodes, nodeId);\n\n    // Collect table IDs that are under the shared node\n    const sharedTableIds: string[] = [];\n    for (const node of allNodes) {\n      if (\n        allowedNodeIds.has(node.id) &&\n        node.resourceType.toLowerCase() === 'table' &&\n        node.resourceId\n      ) {\n        sharedTableIds.push(node.resourceId);\n      }\n    }\n\n    if (sharedTableIds.length === 0) {\n      return false;\n    }\n\n    // Find link fields in shared tables\n    const linkFields = await this.prismaService.field.findMany({\n      where: {\n        tableId: { in: sharedTableIds },\n        type: 'link',\n        deletedTime: null,\n      },\n      select: {\n        options: true,\n      },\n    });\n\n    // Check if any link field references the target foreign table\n    return linkFields.some((field) => {\n      try {\n        const options = field.options ? JSON.parse(field.options) : null;\n        return options?.foreignTableId === foreignTableId;\n      } catch {\n        return false;\n      }\n    });\n  }\n\n  /**\n   * Check if a view belongs to the shared base and is allowed by nodeId.\n   */\n  private async checkViewBelongsToShare(\n    viewId: string,\n    baseId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    const view = await this.prismaService.view.findUnique({\n      where: { id: viewId, deletedTime: null },\n      select: { tableId: true },\n    });\n\n    if (!view) {\n      return false;\n    }\n\n    return this.checkTableBelongsToShare(view.tableId, baseId, nodeId);\n  }\n\n  /**\n   * Check if a field belongs to the shared base and is allowed by nodeId.\n   */\n  private async checkFieldBelongsToShare(\n    fieldId: string,\n    baseId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    const field = await this.prismaService.field.findUnique({\n      where: { id: fieldId, deletedTime: null },\n      select: { tableId: true },\n    });\n\n    if (!field) {\n      return false;\n    }\n\n    return this.checkTableBelongsToShare(field.tableId, baseId, nodeId);\n  }\n\n  /**\n   * Check if an app belongs to the shared base and is allowed by nodeId.\n   */\n  private async checkAppBelongsToShare(\n    appId: string,\n    baseId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    const appNode = await this.prismaService.baseNode.findFirst({\n      where: {\n        baseId,\n        resourceType: { equals: 'app', mode: 'insensitive' },\n        resourceId: appId,\n      },\n    });\n\n    this.logger.debug(`[BaseShare] App ${appId} node found: ${!!appNode}, share baseId: ${baseId}`);\n\n    if (!appNode) {\n      return false;\n    }\n\n    const result = await this.isNodeAllowedByNodeId(baseId, appNode.id, nodeId);\n    this.logger.debug(`[BaseShare] App belongs check: nodeId=${nodeId}, result=${result}`);\n    return result;\n  }\n\n  /**\n   * Get base nodes with caching within the same request cycle.\n   * Uses cls to cache node data to avoid repeated database queries.\n   */\n  private async getBaseNodesWithCache(baseId: string) {\n    // Check if we have cached nodes for this base\n    const cache = this.cls.get('baseShareNodeCache') ?? new Map<string, IBaseNodeCacheItem[]>();\n    if (cache.has(baseId)) {\n      return cache.get(baseId)!;\n    }\n\n    // Query and cache the nodes\n    const allNodes = await this.prismaService.baseNode.findMany({\n      where: { baseId },\n      select: {\n        id: true,\n        parentId: true,\n        resourceType: true,\n        resourceId: true,\n      },\n    });\n\n    cache.set(baseId, allNodes);\n    this.cls.set('baseShareNodeCache', cache);\n    return allNodes;\n  }\n\n  /**\n   * Collect all descendant node IDs from a given nodeId (including the nodeId itself).\n   * Returns a Set of allowed node IDs.\n   */\n  private collectDescendantNodeIds(\n    allNodes: { id: string; parentId: string | null }[],\n    nodeId: string\n  ): Set<string> {\n    const allowedNodeIds = new Set<string>();\n    const collectDescendants = (currentNodeId: string) => {\n      allowedNodeIds.add(currentNodeId);\n      for (const node of allNodes) {\n        if (node.parentId === currentNodeId) {\n          collectDescendants(node.id);\n        }\n      }\n    };\n    collectDescendants(nodeId);\n    return allowedNodeIds;\n  }\n\n  /**\n   * Check if a node (by its BaseNode id) is allowed by nodeId (the shared node and its descendants).\n   * This determines if a resource is accessible via a base share with a specific nodeId.\n   */\n  private async isNodeAllowedByNodeId(\n    baseId: string,\n    targetNodeId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    this.logger.log(\n      `[BaseShare] isNodeAllowedByNodeId: targetNodeId=${targetNodeId}, nodeId=${nodeId}`\n    );\n\n    // Get all nodes in the base (with caching)\n    const allNodes = await this.getBaseNodesWithCache(baseId);\n\n    // Collect all descendant node IDs from the shared nodeId\n    const allowedNodeIds = this.collectDescendantNodeIds(allNodes, nodeId);\n\n    this.logger.log(\n      `[BaseShare] Allowed node IDs (shared + descendants): ${JSON.stringify([...allowedNodeIds])}`\n    );\n\n    // Check if the target node is in the allowed list\n    if (allowedNodeIds.has(targetNodeId)) {\n      this.logger.log(`[BaseShare] targetNodeId found in allowed nodes`);\n      return true;\n    }\n\n    this.logger.log(`[BaseShare] targetNodeId not found in allowed nodes`);\n    return false;\n  }\n\n  /**\n   * Check if a table is allowed by the given nodeId (the shared node and its descendants).\n   * nodeId is a base node ID (bno...) which have a mapping to tableIds via base_node.resourceId\n   */\n  private async isTableAllowedByNodeId(\n    baseId: string,\n    tableId: string,\n    nodeId: string\n  ): Promise<boolean> {\n    this.logger.log(`[BaseShare] isTableAllowedByNodeId: tableId=${tableId}, nodeId=${nodeId}`);\n\n    // Get all nodes in the base (with caching)\n    const allNodes = await this.getBaseNodesWithCache(baseId);\n\n    // Build a map for quick lookup\n    const nodeMap = new Map(allNodes.map((n) => [n.id, n]));\n\n    // Collect all descendant node IDs from the shared nodeId\n    const allowedNodeIds = this.collectDescendantNodeIds(allNodes, nodeId);\n\n    this.logger.log(\n      `[BaseShare] Allowed node IDs (shared + descendants): ${JSON.stringify([...allowedNodeIds])}`\n    );\n\n    // Check if the shared node itself is a table with the target tableId\n    const sharedNode = nodeMap.get(nodeId);\n    if (\n      sharedNode &&\n      sharedNode.resourceType.toLowerCase() === 'table' &&\n      sharedNode.resourceId === tableId\n    ) {\n      this.logger.log(`[BaseShare] Shared node is the target table`);\n      return true;\n    }\n\n    // Check if tableId belongs to any of the allowed nodes\n    for (const allowedId of allowedNodeIds) {\n      const node = nodeMap.get(allowedId);\n      if (node && node.resourceType.toLowerCase() === 'table' && node.resourceId === tableId) {\n        this.logger.log(`[BaseShare] tableId found in allowed descendant nodes`);\n        return true;\n      }\n    }\n\n    this.logger.log(`[BaseShare] tableId not found in allowed nodes`);\n    return false;\n  }\n\n  async validBaseSharePermissions(shareId: string, resourceId: string, permissions: Action[]) {\n    const sharePermissions = await this.getBaseSharePermissions(shareId, resourceId);\n    if (permissions.every((permission) => sharePermissions.includes(permission))) {\n      return sharePermissions;\n    }\n    throw new CustomHttpException(\n      `Base share access denied, not allowed to operate ${permissions.join(', ')} on ${resourceId}`,\n      HttpErrorCode.RESTRICTED_RESOURCE,\n      {\n        localization: {\n          i18nKey: notAllowedOperationI18nKey,\n        },\n      }\n    );\n  }\n\n  /**\n   * Extract the shareId from the X-Tea-Base-Share header.\n   * The header contains the plain shareId set by the frontend (initAxios / SsrApi).\n   *\n   * Note: Password authentication is handled separately via JWT cookie:\n   * - When a share has a password, the user authenticates via POST /share/:shareId/base/auth\n   * - A JWT cookie containing { shareId, password } is set for 7 days\n   * - On subsequent requests, ensureBaseShareAuth validates the cookie by comparing the\n   *   password in the JWT with the current DB password (see validateBaseSharePasswordToken).\n   * - If the admin changes the password, the old JWT cookie's password won't match,\n   *   causing the user to be redirected to the auth page automatically.\n   */\n  getBaseShareIdByHeader(shareHeader: string): string | null {\n    if (!shareHeader || !shareHeader.startsWith('shr')) {\n      return null;\n    }\n    return shareHeader;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session-handle.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SessionHandleService } from './session-handle.service';\nimport { SessionStoreService } from './session-store.service';\n\n@Module({\n  providers: [SessionStoreService, SessionHandleService],\n  exports: [SessionHandleService],\n})\nexport class SessionHandleModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session-handle.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { Request, RequestHandler } from 'express';\nimport session from 'express-session';\nimport ms from 'ms';\nimport { AuthConfig, IAuthConfig } from '../../../configs/auth.config';\nimport { AUTH_SESSION_COOKIE_NAME } from '../../../const';\nimport { SessionStoreService } from './session-store.service';\n\n@Injectable()\nexport class SessionHandleService {\n  sessionMiddleware: RequestHandler;\n  constructor(\n    private readonly sessionStoreService: SessionStoreService,\n    @AuthConfig() private readonly authConfig: IAuthConfig\n  ) {\n    this.sessionMiddleware = session({\n      name: AUTH_SESSION_COOKIE_NAME,\n      secret: this.authConfig.session.secret,\n      resave: false,\n      saveUninitialized: false,\n      cookie: {\n        maxAge: ms('1y'),\n        secure: this.authConfig.session.cookie.secure,\n      },\n      store: this.sessionStoreService,\n    });\n  }\n\n  async getSessionIdFromRequest(request: Request) {\n    return new Promise<string>((resolve, reject) => {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      this.sessionMiddleware(request, {} as any, (err) => {\n        if (err) {\n          return reject(err);\n        }\n        resolve(request.sessionID);\n      });\n    });\n  }\n\n  async getUserId(sessionId: string) {\n    return new Promise<string | undefined>((resolve, reject) => {\n      this.sessionStoreService.get(sessionId, (err, session) => {\n        if (err) {\n          return reject(err);\n        }\n        if (!session) {\n          return resolve(undefined);\n        }\n        resolve(session.passport.user.id);\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session-store.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { IAuthConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport { GlobalModule } from '../../../global/global.module';\nimport type { ISessionData } from '../../../types/session';\nimport { SessionStoreService } from './session-store.service';\nimport { SessionModule } from './session.module';\n\ndescribe('SessionStoreService', () => {\n  let sessionStoreService: SessionStoreService;\n  const cacheService = mockDeep<CacheService>();\n  const authConfig = mockDeep<IAuthConfig>({\n    session: { expiresIn: '1d' },\n  });\n  const sid = 'session-id';\n  const sessionData = { passport: { user: { id: 'user-id' } } } as ISessionData;\n  const callbackMock = vitest.fn();\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, SessionModule],\n    })\n      .overrideProvider(SessionStoreService)\n      .useValue(sessionStoreService)\n      .overrideProvider(CacheService)\n      .useValue(cacheService)\n      .overrideProvider(AuthConfig)\n      .useValue(authConfig)\n      .compile();\n\n    sessionStoreService = module.get<SessionStoreService>(SessionStoreService);\n  });\n\n  afterEach(() => {\n    vitest.resetAllMocks();\n    mockReset(cacheService);\n    mockReset(authConfig);\n    callbackMock.mockReset();\n  });\n\n  it('should be defined', () => {\n    expect(sessionStoreService).toBeDefined();\n  });\n\n  describe('setCache', () => {\n    it('should set cache correctly', async () => {\n      cacheService.get.mockResolvedValue({ 'session-id': 1234567890 });\n\n      await sessionStoreService['setCache'](sid, sessionData);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-user:user-id`,\n        {\n          'session-id': Math.floor(Date.now() / 1000) + sessionStoreService['userSessionExpire'],\n        },\n        expect.any(Number)\n      );\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-store:${sid}`,\n        sessionData,\n        expect.any(Number)\n      );\n    });\n\n    it('should set cache correctly when userSessions is undefined', async () => {\n      cacheService.get.mockResolvedValue(undefined);\n\n      await sessionStoreService['setCache'](sid, sessionData);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-user:user-id`,\n        {\n          'session-id': Math.floor(Date.now() / 1000) + sessionStoreService['userSessionExpire'],\n        },\n        expect.any(Number)\n      );\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-store:${sid}`,\n        sessionData,\n        expect.any(Number)\n      );\n    });\n\n    it('should delete user session correctly when session is expired', async () => {\n      const userSessions = {\n        'session-id': 1234567890,\n        'session-id-2': 1,\n        'session-id-3':\n          Math.floor(Date.now() / 1000) + sessionStoreService['userSessionExpire'] + 1000,\n      };\n      cacheService.get.mockResolvedValue(userSessions);\n\n      await sessionStoreService['setCache'](sid, sessionData);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-user:user-id`,\n        {\n          'session-id': Math.floor(Date.now() / 1000) + sessionStoreService['userSessionExpire'],\n          'session-id-3': userSessions['session-id-3'],\n        },\n        expect.any(Number)\n      );\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-store:${sid}`,\n        sessionData,\n        expect.any(Number)\n      );\n    });\n  });\n\n  describe('getCache', () => {\n    it('should return null if expire flag is set', async () => {\n      // Mock the necessary cacheService methods\n      cacheService.get.mockResolvedValueOnce(true);\n\n      const result = await sessionStoreService['getCache'](sid);\n\n      // Verify that cacheService.get was called with the expected parameter\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`);\n      // Verify that the result is null when the expire flag is set\n      expect(result).toBeNull();\n    });\n\n    it('should return null if session is not found', async () => {\n      // Mock the necessary cacheService methods\n      cacheService.get.mockResolvedValueOnce(undefined);\n      cacheService.get.mockResolvedValueOnce(undefined);\n\n      const result = await sessionStoreService['getCache'](sid);\n\n      // Verify that cacheService.get was called with the expected parameters\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      // Verify that the result is null when session is not found\n      expect(result).toBeNull();\n    });\n\n    it('should return undefined and delete session if user session is not found', async () => {\n      // Mock the necessary cacheService methods\n      cacheService.get.mockResolvedValueOnce(undefined);\n      cacheService.get.mockResolvedValueOnce(sessionData);\n      cacheService.get.mockResolvedValueOnce(undefined);\n      cacheService.del.mockResolvedValueOnce();\n\n      const result = await sessionStoreService['getCache'](sid);\n\n      // Verify that cacheService.get and cacheService.del were called with the expected parameters\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      // Verify that the result is null and session is deleted when user session is not found\n      expect(result).toBeNull();\n    });\n\n    it('should return undefined and delete session if user session is expired', async () => {\n      // Mock the necessary cacheService methods\n      const nowSec = Math.floor(Date.now() / 1000);\n      cacheService.get.mockResolvedValueOnce(false);\n      cacheService.get.mockResolvedValueOnce(sessionData);\n      cacheService.get.mockResolvedValueOnce({ [sid]: nowSec - 1, 'session-id-x': nowSec + 22 }); // Expired user session\n      cacheService.del.mockResolvedValueOnce();\n      cacheService.del.mockResolvedValueOnce();\n      cacheService.set.mockResolvedValueOnce();\n\n      const result = await sessionStoreService['getCache'](sid);\n\n      // Verify that cacheService.get, cacheService.del, and cacheService.set were called with the expected parameters\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      cacheService.del.mockResolvedValueOnce();\n\n      expect(cacheService.set).toHaveBeenCalledWith(\n        `auth:session-user:user-id`,\n        { 'session-id-x': nowSec + 22 },\n        expect.any(Number)\n      );\n      // Verify that the result is null and session is deleted when user session is expired\n      expect(result).toBeNull();\n    });\n\n    it('should return session if user session is valid', async () => {\n      // Mock the necessary cacheService methods\n      const nowSec = Math.floor(Date.now() / 1000);\n      cacheService.get.mockResolvedValueOnce(undefined);\n      cacheService.get.mockResolvedValueOnce(sessionData);\n      cacheService.get.mockResolvedValueOnce({ [sid]: nowSec + 1 }); // Valid user session\n\n      const result = await sessionStoreService['getCache'](sid);\n\n      // Verify that cacheService.get was called with the expected parameters\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-expire:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:user-id`);\n      // Verify that the result is the expected session when user session is valid\n      expect(result).toEqual(sessionData);\n    });\n  });\n\n  describe('get', () => {\n    it('should get session and invoke callback with null error and session', async () => {\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'getCache').mockResolvedValueOnce(sessionData);\n\n      await sessionStoreService.get(sid, callbackMock);\n\n      // Verify that getCache method was called with the expected parameter\n      expect(sessionStoreService['getCache']).toHaveBeenCalledWith(sid);\n      // Verify that the callback was invoked with null error and the expected session\n      expect(callbackMock).toHaveBeenCalledWith(null, sessionData);\n    });\n\n    it('should handle getCache error and invoke callback with error', async () => {\n      const error = new Error('Get cache error');\n\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'getCache').mockRejectedValueOnce(error);\n\n      await sessionStoreService.get(sid, callbackMock);\n\n      // Verify that getCache method was called with the expected parameter\n      expect(sessionStoreService['getCache']).toHaveBeenCalledWith(sid);\n      // Verify that the callback was invoked with the expected error\n      expect(callbackMock).toHaveBeenCalledWith(error);\n    });\n  });\n\n  describe('set', () => {\n    const callbackMock = vitest.fn();\n\n    afterEach(() => {\n      callbackMock.mockReset();\n    });\n\n    it('should set cache and call callback', async () => {\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'setCache').mockResolvedValueOnce(true);\n\n      await sessionStoreService.set(sid, sessionData, callbackMock);\n\n      // Verify that setCache method was called with the expected parameters\n      expect(sessionStoreService['setCache']).toHaveBeenCalledWith(sid, sessionData);\n      // Verify that the callback was called\n      expect(callbackMock).toHaveBeenCalled();\n    });\n\n    it('should handle setCache error and call callback with error', async () => {\n      const error = new Error('Set cache error');\n\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'setCache').mockRejectedValueOnce(error);\n\n      await sessionStoreService.set(sid, sessionData, callbackMock);\n\n      // Verify that setCache method was called with the expected parameters\n      expect(sessionStoreService['setCache']).toHaveBeenCalledWith(sid, sessionData);\n      // Verify that the callback was called with the error\n      expect(callbackMock).toHaveBeenCalledWith(error);\n    });\n  });\n\n  describe('destroy', () => {\n    it('should delete session from cache and call callback', async () => {\n      // Mock the necessary methods\n      cacheService.del.mockResolvedValueOnce();\n\n      await sessionStoreService.destroy(sid, callbackMock);\n\n      // Verify that cacheService.del method was called with the expected parameter\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      // Verify that the callback was called\n      expect(callbackMock).toHaveBeenCalled();\n    });\n\n    it('should handle cacheService.del error and call callback with error', async () => {\n      const error = new Error('Cache service del error');\n\n      // Mock the necessary methods\n      cacheService.del.mockRejectedValueOnce(error);\n\n      await sessionStoreService.destroy(sid, callbackMock);\n\n      // Verify that cacheService.del method was called with the expected parameter\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:${sid}`);\n      // Verify that the callback was called with the error\n      expect(callbackMock).toHaveBeenCalledWith(error);\n    });\n  });\n  describe('touch', () => {\n    it('should touch session, set it, and call callback', async () => {\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'getCache').mockResolvedValueOnce(sessionData);\n      vitest.spyOn(sessionStoreService as any, 'setCache').mockResolvedValueOnce(null);\n\n      await sessionStoreService.touch(sid, sessionData, callbackMock);\n\n      // Verify that getCache and set methods were called with the expected parameters\n      expect(sessionStoreService['getCache']).toHaveBeenCalledWith(sid);\n      expect(sessionStoreService['setCache']).toHaveBeenCalledWith(sid, sessionData);\n      // Verify that the callback was called\n      expect(callbackMock).toHaveBeenCalled();\n    });\n\n    it('should handle getCache undefined and call callback with error', async () => {\n      const error = new Error('Session not found');\n\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'getCache').mockResolvedValueOnce(undefined);\n\n      await sessionStoreService.touch(sid, sessionData, callbackMock);\n\n      // Verify that getCache method was called with the expected parameter\n      expect(sessionStoreService['getCache']).toHaveBeenCalledWith(sid);\n      // Verify that the callback was called with the error\n      expect(callbackMock).toHaveBeenCalledWith(error);\n    });\n\n    it('should handle getCache error and call callback with error', async () => {\n      const error = new Error('Get cache error');\n\n      // Mock the necessary methods\n      vitest.spyOn(sessionStoreService as any, 'getCache').mockRejectedValueOnce(error);\n\n      await sessionStoreService.touch(sid, sessionData, callbackMock);\n\n      // Verify that getCache method was called with the expected parameter\n      expect(sessionStoreService['getCache']).toHaveBeenCalledWith(sid);\n      // Verify that the callback was called with the error\n      expect(callbackMock).toHaveBeenCalledWith(error);\n    });\n  });\n\n  describe('clearByUserId', () => {\n    const userId = 'user-id';\n\n    it('should clear user sessions and set expire flag', async () => {\n      // Mock the necessary methods\n      cacheService.get.mockResolvedValueOnce({ 'session-id': 123 });\n      cacheService.set.mockResolvedValueOnce();\n      cacheService.del.mockResolvedValueOnce();\n\n      await sessionStoreService.clearByUserId(userId);\n\n      // Verify that cacheService.get, set, and del methods were called with the expected parameters\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:${userId}`);\n      expect(cacheService.set).toHaveBeenCalledWith(`auth:session-expire:session-id`, true, 60);\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-store:session-id`);\n      expect(cacheService.del).toHaveBeenCalledWith(`auth:session-user:${userId}`);\n    });\n\n    it('should handle empty user sessions in clearByUserId method', async () => {\n      // Mock the necessary methods\n      cacheService.get.mockResolvedValueOnce(undefined);\n\n      await sessionStoreService.clearByUserId(userId);\n\n      // Verify that cacheService.get was called with the expected parameter\n      expect(cacheService.get).toHaveBeenCalledWith(`auth:session-user:${userId}`);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session-store.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { Store } from 'express-session';\nimport { pick } from 'lodash';\nimport { CacheService } from '../../../cache/cache.service';\nimport { AuthConfig, IAuthConfig } from '../../../configs/auth.config';\nimport type { ISessionData } from '../../../types/session';\nimport { second } from '../../../utils/second';\n\nconst SESSION_STORE_KEYS = ['passport', 'cookie'] as const;\n\n@Injectable()\nexport class SessionStoreService extends Store {\n  private readonly ttl: number;\n  private readonly userSessionExpire: number;\n  private readonly logger = new Logger(SessionStoreService.name);\n\n  constructor(\n    private readonly cacheService: CacheService,\n    @AuthConfig() private readonly authConfig: IAuthConfig\n  ) {\n    super();\n    this.ttl = second(this.authConfig.session.expiresIn);\n    this.userSessionExpire = this.ttl + 60 * 2;\n  }\n\n  private async setCache(sid: string, session: ISessionData) {\n    const userId = session.passport.user.id;\n    const userSessions = (await this.cacheService.get(`auth:session-user:${userId}`)) ?? {};\n    // The expiration time is greater than the session cache time,\n    // so that the user session does not expire while the session is still alive.\n    const nowSec = Math.floor(Date.now() / 1000);\n    userSessions[sid] = nowSec + this.userSessionExpire;\n    // Maintain userSession, remove expired keys\n    for (const [key, value] of Object.entries(userSessions)) {\n      if (value < nowSec) {\n        delete userSessions[key];\n      }\n    }\n    await this.cacheService.set(`auth:session-user:${userId}`, userSessions, this.ttl);\n    await this.cacheService.set(`auth:session-store:${sid}`, session, this.ttl);\n  }\n\n  private async getCache(sid: string) {\n    const expire = await this.cacheService.get(`auth:session-expire:${sid}`);\n    if (expire) {\n      this.logger.log(`Session ${sid} is expired`);\n      return null;\n    }\n    const session = await this.cacheService.get(`auth:session-store:${sid}`);\n    if (!session) {\n      this.logger.log(`Session ${sid} not found`);\n      return null;\n    }\n    const userId = session.passport.user.id;\n    const userSessions = (await this.cacheService.get(`auth:session-user:${userId}`)) ?? {};\n    if (!userSessions[sid]) {\n      this.logger.log(`Session ${sid} not found in userSessions`);\n      await this.cacheService.del(`auth:session-store:${sid}`);\n      return null;\n    }\n    // The expiration time is greater than the session cache time,\n    // so that the user session does not expire while the session is still alive.\n    const nowSec = Math.floor(Date.now() / 1000);\n    if (userSessions[sid] < nowSec) {\n      delete userSessions[sid];\n      await this.cacheService.del(`auth:session-store:${sid}`);\n      await this.cacheService.set(`auth:session-user:${userId}`, userSessions, this.ttl);\n      this.logger.log(`Session ${sid} expired, remove from userSessions`);\n      return null;\n    }\n    return session;\n  }\n\n  async get(\n    sid: string,\n    callback: (err: unknown, session?: ISessionData | null | undefined) => void\n  ): Promise<void> {\n    try {\n      const session = await this.getCache(sid);\n      callback(null, session);\n    } catch (error) {\n      callback(error);\n    }\n  }\n\n  async set(sid: string, session: ISessionData, callback?: ((err?: unknown) => void) | undefined) {\n    try {\n      // Avoid redundant keys on req.session objects\n      await this.setCache(sid, pick(session, SESSION_STORE_KEYS));\n      callback?.();\n    } catch (error) {\n      callback?.(error);\n    }\n  }\n\n  async destroy(sid: string, callback?: ((err?: unknown) => void) | undefined) {\n    try {\n      await this.cacheService.del(`auth:session-store:${sid}`);\n      callback?.();\n    } catch (error) {\n      callback?.(error);\n    }\n  }\n\n  async touch(\n    sid: string,\n    session: ISessionData,\n    callback?: ((err?: unknown) => void) | undefined\n  ) {\n    try {\n      const sessionCache = await this.getCache(sid);\n      if (sessionCache) {\n        await this.setCache(sid, session);\n        callback?.();\n        return;\n      }\n      callback?.(new Error('Session not found'));\n    } catch (error) {\n      callback?.(error);\n    }\n  }\n\n  async clearByUserId(userId: string) {\n    const userSessions = (await this.cacheService.get(`auth:session-user:${userId}`)) ?? {};\n    for (const sid of Object.keys(userSessions)) {\n      // Preventing competition\n      await this.cacheService.set(`auth:session-expire:${sid}`, true, 60);\n      await this.cacheService.del(`auth:session-store:${sid}`);\n    }\n    await this.cacheService.del(`auth:session-user:${userId}`);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session.module.ts",
    "content": "import type { NestModule, MiddlewareConsumer } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport passport from 'passport';\nimport { SessionHandleModule } from './session-handle.module';\nimport { SessionHandleService } from './session-handle.service';\nimport { SessionStoreService } from './session-store.service';\nimport { SessionService } from './session.service';\n\n@Module({\n  imports: [SessionHandleModule],\n  providers: [SessionService, SessionStoreService],\n  exports: [SessionService],\n})\nexport class SessionModule implements NestModule {\n  constructor(private readonly sessionHandleService: SessionHandleService) {}\n\n  configure(consumer: MiddlewareConsumer) {\n    consumer\n      .apply(this.sessionHandleService.sessionMiddleware, passport.initialize())\n      .forRoutes('/api/*');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session.serializer.ts",
    "content": "/* eslint-disable @typescript-eslint/ban-types */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { Injectable } from '@nestjs/common';\nimport { PassportSerializer } from '@nestjs/passport';\n\n@Injectable()\nexport class SessionSerializer extends PassportSerializer {\n  constructor() {\n    super();\n  }\n\n  serializeUser(user: any, done: Function) {\n    done(null, { id: user.id });\n  }\n\n  async deserializeUser(payload: any, done: Function) {\n    done(null, payload);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/session/session.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class SessionService {\n  async signout(req: Express.Request) {\n    await new Promise<void>((resolve, reject) => {\n      req.session.destroy(function (err) {\n        // cannot access session here\n        if (err) {\n          reject(err);\n          return;\n        }\n        resolve();\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/controller.adapter.ts",
    "content": "import type { Response } from 'express';\nimport type { IOauth2State } from '../../../cache/types';\n\nexport class ControllerAdapter {\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  async authenticate() {}\n\n  async callback(req: Express.Request, res: Response, defaultRedirectUri?: string) {\n    const user = req.user!;\n    // set cookie, passport login\n    await new Promise<void>((resolve, reject) => {\n      req.login(user, (err) => (err ? reject(err) : resolve()));\n    });\n    const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;\n    if (redirectUri) {\n      return res.redirect(redirectUri);\n    }\n    return res.redirect(defaultRedirectUri || '/');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/github/github.controller.ts",
    "content": "import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Public } from '../../decorators/public.decorator';\nimport { GithubGuard } from '../../guard/github.guard';\nimport { SocialGuard } from '../../guard/social.guard';\nimport { ControllerAdapter } from '../controller.adapter';\n\n@Controller('api/auth')\nexport class GithubController extends ControllerAdapter {\n  @Get('/github')\n  @Public()\n  @UseGuards(GithubGuard)\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  async githubAuthenticate() {\n    return super.authenticate();\n  }\n\n  @Get('/github/callback')\n  @Public()\n  @UseGuards(SocialGuard, GithubGuard)\n  async githubCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {\n    return super.callback(req, res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/github/github.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { UserModule } from '../../../user/user.module';\nimport { OauthStoreService } from '../../oauth/oauth.store';\nimport { GithubStrategy } from '../../strategies/github.strategy';\nimport { GithubController } from './github.controller';\n\n@Module({\n  imports: [UserModule],\n  providers: [GithubStrategy, OauthStoreService],\n  exports: [],\n  controllers: [GithubController],\n})\nexport class GithubModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/google/google.controller.ts",
    "content": "import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Public } from '../../decorators/public.decorator';\nimport { GoogleGuard } from '../../guard/google.guard';\nimport { SocialGuard } from '../../guard/social.guard';\nimport { ControllerAdapter } from '../controller.adapter';\n\n@Controller('api/auth')\nexport class GoogleController extends ControllerAdapter {\n  @Get('/google')\n  @Public()\n  @UseGuards(GoogleGuard)\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  async googleAuthenticate() {\n    return super.authenticate();\n  }\n\n  @Get('/google/callback')\n  @Public()\n  @UseGuards(SocialGuard, GoogleGuard)\n  async googleCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {\n    return super.callback(req, res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/google/google.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { UserModule } from '../../../user/user.module';\nimport { OauthStoreService } from '../../oauth/oauth.store';\nimport { GoogleStrategy } from '../../strategies/google.strategy';\nimport { GoogleController } from './google.controller';\n\n@Module({\n  imports: [UserModule],\n  providers: [GoogleStrategy, OauthStoreService],\n  exports: [],\n  controllers: [GoogleController],\n})\nexport class GoogleModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/oidc/oidc.controller.ts",
    "content": "import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Public } from '../../decorators/public.decorator';\nimport { OIDCGuard } from '../../guard/oidc.guard';\nimport { SocialGuard } from '../../guard/social.guard';\nimport { ControllerAdapter } from '../controller.adapter';\n\n@Controller('api/auth')\nexport class OIDCController extends ControllerAdapter {\n  @Get('/oidc')\n  @Public()\n  @UseGuards(OIDCGuard)\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  async oidcAuthenticate() {\n    return super.authenticate();\n  }\n\n  @Get('/oidc/callback')\n  @Public()\n  @UseGuards(SocialGuard, OIDCGuard)\n  async oidcCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {\n    return super.callback(req, res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/oidc/oidc.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { UserModule } from '../../../user/user.module';\nimport { OauthStoreService } from '../../oauth/oauth.store';\nimport { OIDCStrategy } from '../../strategies/oidc.strategy';\nimport { OIDCController } from './oidc.controller';\n\n@Module({\n  imports: [UserModule],\n  providers: [OIDCStrategy, OauthStoreService],\n  exports: [],\n  controllers: [OIDCController],\n})\nexport class OIDCModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/social/social.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Module } from '@nestjs/common';\nimport { ConditionalModule } from '@nestjs/config';\nimport { GithubModule } from './github/github.module';\nimport { GoogleModule } from './google/google.module';\nimport { OIDCModule } from './oidc/oidc.module';\n\nconst CONDITIONAL_MODULE_TIMEOUT = process.env.CI ? 30000 : 5000;\n\n@Module({\n  imports: [\n    ConditionalModule.registerWhen(\n      GithubModule,\n      (env) => {\n        return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('github'));\n      },\n      { timeout: CONDITIONAL_MODULE_TIMEOUT }\n    ),\n    ConditionalModule.registerWhen(\n      GoogleModule,\n      (env) => {\n        return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('google'));\n      },\n      { timeout: CONDITIONAL_MODULE_TIMEOUT }\n    ),\n    ConditionalModule.registerWhen(\n      OIDCModule,\n      (env) => {\n        return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('oidc'));\n      },\n      { timeout: CONDITIONAL_MODULE_TIMEOUT }\n    ),\n  ],\n})\nexport class SocialModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/access-token.passport.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { DeserializeUserFunction } from 'passport';\nimport { Strategy } from 'passport';\nimport { splitAccessToken } from '../../access-token/access-token.encryptor';\nimport { ACCESS_TOKEN_STRATEGY_NAME } from './constant';\nimport type { IFromExtractor } from './types';\n\ninterface IAccessTokenStrategyOptions {\n  accessTokenFromRequest?: IFromExtractor;\n}\n\nexport class PassportAccessTokenStrategy extends Strategy {\n  public name: string;\n  private accessTokenFromRequest?: IFromExtractor;\n  private _deserializeUser: DeserializeUserFunction;\n\n  constructor(options?: IAccessTokenStrategyOptions, deserializeUser?: DeserializeUserFunction) {\n    super();\n    this.name = ACCESS_TOKEN_STRATEGY_NAME;\n    this.accessTokenFromRequest = options?.accessTokenFromRequest;\n    this._deserializeUser = deserializeUser!;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  authenticate(req: any): void {\n    const { success, fail } = this;\n    const accessToken = this?.accessTokenFromRequest?.(req);\n    if (!accessToken) {\n      fail('No access token');\n      return;\n    }\n    const accessTokenObj = splitAccessToken(accessToken);\n    if (!accessTokenObj) {\n      fail('Invalid access token');\n      return;\n    }\n    this._deserializeUser(accessTokenObj, req, function (err, user) {\n      if (err) {\n        return fail(err);\n      }\n      if (!user) {\n        fail('No user found');\n      } else {\n        success(user);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { HttpErrorCode } from '@teable/core';\nimport type { Request } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { AccessTokenService } from '../../access-token/access-token.service';\nimport { UserService } from '../../user/user.service';\nimport { pickUserMe } from '../utils';\nimport { PassportAccessTokenStrategy } from './access-token.passport';\nimport type { IFromExtractor } from './types';\n\n@Injectable()\nexport class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStrategy) {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private readonly userService: UserService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly accessTokenService: AccessTokenService\n  ) {\n    super({\n      accessTokenFromRequest: fromExtractors([fromAuthHeaderAsBearerToken]),\n    });\n  }\n\n  async validate(payload: { accessTokenId: string; sign: string }) {\n    const { userId, accessTokenId } = await this.accessTokenService.validate(payload);\n    const user = await this.userService.getUserById(userId);\n    if (!user) {\n      throw new CustomHttpException(`User not found`, HttpErrorCode.UNAUTHORIZED, {\n        localization: {\n          i18nKey: 'httpErrors.user.notFound',\n        },\n      });\n    }\n    if (user.deactivatedTime) {\n      throw new CustomHttpException(\n        `Your account has been deactivated by the administrator`,\n        HttpErrorCode.UNAUTHORIZED,\n        {\n          localization: {\n            i18nKey: 'httpErrors.auth.accountDeactivated',\n          },\n        }\n      );\n    }\n\n    this.cls.set('user.id', user.id);\n    this.cls.set('user.name', user.name);\n    this.cls.set('user.email', user.email);\n    this.cls.set('user.isAdmin', user.isAdmin);\n    this.cls.set('accessTokenId', accessTokenId);\n    return pickUserMe(user);\n  }\n}\n\nconst fromExtractors = (extractors: IFromExtractor[]) => {\n  if (!Array.isArray(extractors)) {\n    throw new TypeError('extractors.fromExtractors expects an array');\n  }\n\n  return function (request: Request) {\n    let token = null;\n    let index = 0;\n    while (!token && index < extractors.length) {\n      token = extractors[index](request);\n      index++;\n    }\n    return token;\n  };\n};\n\nconst fromAuthHeaderAsBearerToken = (req: Request) => {\n  const authHeader = req.headers.authorization;\n  if (authHeader) {\n    const [bearer, token] = authHeader.split(' ');\n    if (bearer === 'Bearer' && token) {\n      return token;\n    }\n  }\n  return null;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/anonymous/anonymous.passport.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { DeserializeUserFunction } from 'passport';\nimport { Strategy } from 'passport';\nimport { ANONYMOUS_STRATEGY_NAME } from '../constant';\n\nexport class PassportAnonymousStrategy extends Strategy {\n  public name: string;\n  private _deserializeUser: DeserializeUserFunction;\n\n  constructor(deserializeUser?: DeserializeUserFunction) {\n    super();\n    this.name = ANONYMOUS_STRATEGY_NAME;\n    this._deserializeUser = deserializeUser!;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  authenticate(req: any): void {\n    const { success, fail } = this;\n    this._deserializeUser(undefined, req, function (err, user) {\n      if (err) {\n        return fail(err?.message || 'No template user found');\n      }\n      if (!user) {\n        fail('No template user found');\n      } else {\n        success(user);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/anonymous/anonymous.strategy.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { ANONYMOUS_USER } from '@teable/core';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../../types/cls';\nimport { PassportAnonymousStrategy } from './anonymous.passport';\n\n@Injectable()\nexport class AnonymousStrategy extends PassportStrategy(PassportAnonymousStrategy) {\n  constructor(private readonly cls: ClsService<IClsStore>) {\n    super();\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  async validate() {\n    this.cls.set('user', ANONYMOUS_USER);\n    return ANONYMOUS_USER;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/constant.ts",
    "content": "export const ACCESS_TOKEN_STRATEGY_NAME = 'access-token';\n\nexport const JWT_TOKEN_STRATEGY_NAME = 'auth-jwt-token';\n\nexport const TEMPLATE_STRATEGY_NAME = 'template';\n\nexport const ANONYMOUS_STRATEGY_NAME = 'anonymous';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts",
    "content": "import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport type { Profile } from 'passport-github2';\nimport { Strategy } from 'passport-github2';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { UserService } from '../../user/user.service';\nimport { OauthStoreService } from '../oauth/oauth.store';\nimport { pickUserMe } from '../utils';\n\n@Injectable()\nexport class GithubStrategy extends PassportStrategy(Strategy, 'github') {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private userService: UserService,\n    oauthStoreService: OauthStoreService\n  ) {\n    const { clientID, clientSecret, callbackURL } = config.github;\n    super({\n      clientID,\n      clientSecret,\n      state: true,\n      store: oauthStoreService,\n      callbackURL,\n      scope: ['user:email'],\n    });\n  }\n\n  async validate(_accessToken: string, _refreshToken: string, profile: Profile) {\n    const { id, emails, displayName, photos } = profile;\n    const email = emails?.[0].value;\n    if (!email) {\n      throw new UnauthorizedException('No email provided from GitHub');\n    }\n    const user = await this.userService.findOrCreateUser({\n      name: displayName,\n      email,\n      provider: 'github',\n      providerId: id,\n      type: 'oauth',\n      avatarUrl: photos?.[0].value,\n    });\n    if (!user) {\n      throw new UnauthorizedException('Failed to create user from GitHub profile');\n    }\n    if (user.deactivatedTime) {\n      throw new BadRequestException('Your account has been deactivated by the administrator');\n    }\n    await this.userService.refreshLastSignTime(user.id);\n    return pickUserMe(user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts",
    "content": "import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport type { Profile } from 'passport-google-oauth20';\nimport { Strategy } from 'passport-google-oauth20';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { UserService } from '../../user/user.service';\nimport { OauthStoreService } from '../oauth/oauth.store';\nimport { pickUserMe } from '../utils';\n\n@Injectable()\nexport class GoogleStrategy extends PassportStrategy(Strategy, 'google') {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private userService: UserService,\n    oauthStoreService: OauthStoreService\n  ) {\n    const { clientID, clientSecret, callbackURL } = config.google;\n    super({\n      clientID,\n      clientSecret,\n      state: true,\n      store: oauthStoreService,\n      scope: ['profile', 'email'],\n      callbackURL,\n    });\n  }\n\n  async validate(_accessToken: string, _refreshToken: string, profile: Profile) {\n    const { id, emails, displayName, photos } = profile;\n    const email = emails?.[0].value;\n    if (!email) {\n      throw new UnauthorizedException('No email provided from Google');\n    }\n    const user = await this.userService.findOrCreateUser({\n      name: displayName,\n      email,\n      provider: 'google',\n      providerId: id,\n      type: 'oauth',\n      avatarUrl: photos?.[0].value,\n    });\n    if (!user) {\n      throw new UnauthorizedException('Failed to create user from Google profile');\n    }\n    if (user.deactivatedTime) {\n      throw new BadRequestException('Your account has been deactivated by the administrator');\n    }\n    await this.userService.refreshLastSignTime(user.id);\n    return pickUserMe(user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/jwt.strategy.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { AUTOMATION_ROBOT_USER, APP_ROBOT_USER } from '@teable/core';\nimport type { Request } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport { ExtractJwt, Strategy } from 'passport-jwt';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { IClsStore } from '../../../types/cls';\nimport { UserService } from '../../user/user.service';\nimport { pickUserMe } from '../utils';\nimport { JWT_TOKEN_STRATEGY_NAME } from './constant';\nimport type { IJwtAuthInternalInfo, IJwtAuthInfo } from './types';\nimport { JwtAuthInternalType } from './types';\n\n@Injectable()\nexport class JwtStrategy extends PassportStrategy(Strategy, JWT_TOKEN_STRATEGY_NAME) {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private readonly userService: UserService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super({\n      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),\n      ignoreExpiration: false,\n      secretOrKey: config.jwt.secret,\n      passReqToCallback: true,\n    });\n  }\n\n  async validate(req: Request, payload: IJwtAuthInfo | IJwtAuthInternalInfo) {\n    if ('baseId' in payload) {\n      return this.validateInternalToken(payload, req);\n    }\n    return this.validateUserToken(payload);\n  }\n\n  private async validateInternalToken(payload: IJwtAuthInternalInfo, req: Request) {\n    this.cls.set('tempAuthBaseId', payload.baseId);\n\n    // Handle User type tokens - use real user identity\n    if (payload.type === JwtAuthInternalType.User) {\n      if (!payload.userId) {\n        throw new UnauthorizedException('User ID is required for User type tokens');\n      }\n      const user = await this.userService.getUserById(payload.userId);\n      if (!user) {\n        throw new UnauthorizedException();\n      }\n      if (user.deactivatedTime) {\n        throw new UnauthorizedException('Your account has been deactivated by the administrator');\n      }\n      if (user.isSystem) {\n        throw new UnauthorizedException('User is system user');\n      }\n      this.cls.set('user.id', user.id);\n      this.cls.set('user.name', user.name);\n      this.cls.set('user.email', user.email);\n      this.cls.set('user.isAdmin', user.isAdmin);\n      return pickUserMe(user);\n    }\n\n    // Handle App and Automation type tokens - use robot users\n    const user = payload.type === JwtAuthInternalType.App ? APP_ROBOT_USER : AUTOMATION_ROBOT_USER;\n    this.cls.set('user', user);\n    this.cls.set('tempAuthBaseId', payload.baseId);\n\n    if (payload.type === JwtAuthInternalType.App) {\n      await this.setAppIdFromToken(req);\n    }\n    if (payload.type === JwtAuthInternalType.Automation) {\n      this.cls.set('workflowContext', payload.context);\n    }\n\n    return user;\n  }\n\n  protected async setAppIdFromToken(_req: Request) {\n    // This method is overridden in enterprise edition to support app authentication\n    // Community edition does not have app model, so this is a no-op\n  }\n\n  private async validateUserToken(payload: IJwtAuthInfo) {\n    const user = await this.userService.getUserById(payload.userId);\n    if (!user) {\n      throw new UnauthorizedException();\n    }\n    if (user.deactivatedTime) {\n      throw new UnauthorizedException('Your account has been deactivated by the administrator');\n    }\n\n    if (user.isSystem) {\n      throw new UnauthorizedException('User is system user');\n    }\n\n    this.cls.set('user.id', user.id);\n    this.cls.set('user.name', user.name);\n    this.cls.set('user.email', user.email);\n    this.cls.set('user.isAdmin', user.isAdmin);\n    return pickUserMe(user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { Request } from 'express';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { CacheService } from '../../../cache/cache.service';\nimport { GlobalModule } from '../../../global/global.module';\nimport { UserModule } from '../../user/user.module';\nimport { LocalAuthService } from '../local-auth/local-auth.service';\nimport { LocalStrategy } from './local.strategy';\n\ndescribe('LocalStrategy', () => {\n  let localStrategy: LocalStrategy;\n  const authService = mockDeep<LocalAuthService>();\n  const cacheService = mockDeep<CacheService>();\n  const testEmail = 'test@test.com';\n  const testPassword = '12345678a';\n  const mokeReq = {\n    ip: '127.0.0.1',\n    connection: {\n      remoteAddress: '127.0.0.1',\n    },\n    headers: {\n      'x-forwarded-for': '127.0.0.1',\n    },\n  } as unknown as Request;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, UserModule],\n      providers: [LocalStrategy, LocalAuthService],\n    })\n      .overrideProvider(LocalAuthService)\n      .useValue(authService)\n      .overrideProvider(CacheService)\n      .useValue(cacheService)\n      .compile();\n\n    localStrategy = module.get<LocalStrategy>(LocalStrategy);\n  });\n\n  afterEach(() => {\n    vitest.resetAllMocks();\n    mockReset(authService);\n    mockReset(cacheService);\n  });\n\n  it('should throw error when lockout is disabled', async () => {\n    authService.validateUserByEmail.mockRejectedValue(new Error());\n    localStrategy['authConfig'].signin = {\n      maxLoginAttempts: 0,\n      accountLockoutMinutes: 0,\n    };\n    await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow(\n      'Email or password is incorrect'\n    );\n  });\n\n  it('should throw error when account is already locked', async () => {\n    authService.validateUserByEmail.mockRejectedValue(new Error());\n    localStrategy['authConfig'].signin = {\n      maxLoginAttempts: 5,\n      accountLockoutMinutes: 10,\n    };\n    cacheService.get.mockImplementation(async (key) => {\n      if (key === `signin:lockout:${testEmail}`) return true;\n      return undefined;\n    });\n\n    await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow(\n      'Your account has been locked out, please try again after 10 minutes'\n    );\n  });\n\n  it('should increment attempt count and throw error', async () => {\n    authService.validateUserByEmail.mockRejectedValue(new Error());\n    localStrategy['authConfig'].signin = {\n      maxLoginAttempts: 5,\n      accountLockoutMinutes: 10,\n    };\n    cacheService.get.mockResolvedValue(undefined);\n    cacheService.incr.mockResolvedValue(3);\n\n    await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({\n      response: 'Email or password is incorrect',\n    });\n    expect(cacheService.incr).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 30);\n  });\n\n  it('should lock account when max attempts reached', async () => {\n    authService.validateUserByEmail.mockRejectedValue(new Error());\n    localStrategy['authConfig'].signin = {\n      maxLoginAttempts: 4,\n      accountLockoutMinutes: 10,\n    };\n    cacheService.get.mockResolvedValue(undefined);\n    cacheService.incr.mockResolvedValue(4);\n\n    await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({\n      response: 'Your account has been locked out, please try again after 10 minutes',\n    });\n    expect(cacheService.set).toHaveBeenCalledWith(`signin:lockout:${testEmail}`, true, 10);\n    expect(cacheService.del).toHaveBeenCalledWith(`signin:attempts:${testEmail}`);\n  });\n\n  it('should handle first failed attempt', async () => {\n    authService.validateUserByEmail.mockRejectedValue(new Error());\n    localStrategy['authConfig'].signin = {\n      maxLoginAttempts: 5,\n      accountLockoutMinutes: 10,\n    };\n    cacheService.get.mockResolvedValue(undefined);\n    cacheService.incr.mockResolvedValue(1);\n\n    await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({\n      response: 'Email or password is incorrect',\n    });\n    expect(cacheService.incr).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 30);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { HttpErrorCode } from '@teable/core';\nimport type { Request } from 'express';\nimport { Strategy } from 'passport-local';\nimport { CacheService } from '../../../cache/cache.service';\nimport { AuthConfig, IAuthConfig } from '../../../configs/auth.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { UserService } from '../../user/user.service';\nimport { LocalAuthService } from '../local-auth/local-auth.service';\nimport { pickUserMe } from '../utils';\n\n@Injectable()\nexport class LocalStrategy extends PassportStrategy(Strategy) {\n  constructor(\n    private readonly userService: UserService,\n    private readonly authService: LocalAuthService,\n    private readonly cacheService: CacheService,\n    @AuthConfig() private readonly authConfig: IAuthConfig\n  ) {\n    super({\n      usernameField: 'email',\n      passwordField: 'password',\n      passReqToCallback: true,\n    });\n  }\n\n  async validate(req: Request, email: string, password: string) {\n    try {\n      const turnstileToken = req.body?.turnstileToken;\n      const remoteIp =\n        req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);\n      const user = await this.authService.validateUserByEmailWithTurnstile(\n        email,\n        password,\n        turnstileToken,\n        remoteIp\n      );\n      if (!user) {\n        throw new CustomHttpException(\n          'Email or password is incorrect',\n          HttpErrorCode.INVALID_CREDENTIALS,\n          {\n            localization: {\n              i18nKey: 'httpErrors.auth.emailOrPasswordIncorrect',\n            },\n          }\n        );\n      }\n      if (user.deactivatedTime) {\n        throw new CustomHttpException(\n          `Your account has been deactivated by the administrator`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.auth.accountDeactivated',\n            },\n          }\n        );\n      }\n      await this.userService.refreshLastSignTime(user.id);\n      return pickUserMe(user);\n    } catch (error) {\n      const { maxLoginAttempts, accountLockoutMinutes } = this.authConfig.signin;\n      const hasLockout = maxLoginAttempts && accountLockoutMinutes;\n      const isLockout = await this.cacheService.get(`signin:lockout:${email}`);\n      if (!hasLockout) {\n        throw new CustomHttpException(\n          `Email or password is incorrect`,\n          HttpErrorCode.INVALID_CREDENTIALS,\n          {\n            localization: {\n              i18nKey: 'httpErrors.auth.emailOrPasswordIncorrect',\n            },\n          }\n        );\n      }\n      const lockError = new CustomHttpException(\n        `Your account has been locked out, please try again after ${accountLockoutMinutes} minutes`,\n        HttpErrorCode.TOO_MANY_REQUESTS,\n        {\n          minutes: accountLockoutMinutes,\n          localization: {\n            i18nKey: 'httpErrors.auth.accountLockedOut',\n          },\n        }\n      );\n      if (isLockout) {\n        throw lockError;\n      }\n      // Use atomic increment to prevent race conditions\n      const attempts = await this.cacheService.incr(`signin:attempts:${email}`, 30);\n      if (attempts >= maxLoginAttempts) {\n        await this.cacheService.set(`signin:lockout:${email}`, true, accountLockoutMinutes);\n        await this.cacheService.del(`signin:attempts:${email}`);\n        throw lockError;\n      }\n      throw new CustomHttpException(\n        'Email or password is incorrect',\n        HttpErrorCode.INVALID_CREDENTIALS,\n        {\n          attempts,\n          localization: {\n            i18nKey: 'httpErrors.auth.emailOrPasswordIncorrect',\n          },\n        }\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/oidc.strategy.ts",
    "content": "import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport type { Profile } from 'passport-openidconnect';\nimport { Strategy } from 'passport-openidconnect';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { UserService } from '../../user/user.service';\nimport { OauthStoreService } from '../oauth/oauth.store';\nimport { pickUserMe } from '../utils';\n\n@Injectable()\nexport class OIDCStrategy extends PassportStrategy(Strategy, 'openidconnect') {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private usersService: UserService,\n    oauthStoreService: OauthStoreService\n  ) {\n    const { other, ...rest } = config.oidc;\n    super({\n      ...rest,\n      state: true,\n      store: oauthStoreService,\n      ...other,\n    });\n  }\n\n  async validate(_issuer: string, profile: Profile) {\n    const { id, emails, displayName, photos } = profile;\n    const email = emails?.[0].value;\n    if (!email) {\n      throw new UnauthorizedException('No email provided from OIDC');\n    }\n    const user = await this.usersService.findOrCreateUser({\n      name: displayName,\n      email,\n      provider: 'oidc',\n      providerId: id,\n      type: 'oauth',\n      avatarUrl: photos?.[0].value,\n    });\n\n    if (!user) {\n      throw new UnauthorizedException('Failed to create user from OIDC profile');\n    }\n\n    if (user.deactivatedTime) {\n      throw new BadRequestException('Your account has been deactivated by the administrator');\n    }\n    await this.usersService.refreshLastSignTime(user.id);\n    return pickUserMe(user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/session.passport.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { SessionStrategyOptions, DeserializeUserFunction } from 'passport';\nimport { Strategy } from 'passport';\nimport pause from 'pause';\n\nexport class PassportSessionStrategy extends Strategy {\n  public name: string;\n  private _key: string;\n  private _deserializeUser: DeserializeUserFunction;\n\n  constructor(options?: SessionStrategyOptions, deserializeUser?: DeserializeUserFunction) {\n    if (typeof options === 'function') {\n      deserializeUser = options;\n      options = undefined;\n    }\n    super();\n    this.name = 'session';\n    this._key = options?.key || 'passport';\n    this._deserializeUser = deserializeUser!;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  authenticate(req: any, options?: { pauseStream?: boolean }): void {\n    if (!req.session) {\n      return this.error(new Error('No session'));\n    }\n    options = options || {};\n\n    const { success, fail, _key, _deserializeUser } = this;\n    const user: any = req.session?.[_key]?.user;\n\n    if (user) {\n      const paused = options.pauseStream ? pause(req) : null;\n\n      _deserializeUser(user, req, function (err, user) {\n        if (err) {\n          return fail(err);\n        }\n        if (!user) {\n          delete req.session[_key].user;\n          fail('No user session found');\n        } else {\n          const property = req._userProperty || 'user';\n          req[property] = user;\n          success(user);\n        }\n        if (paused) {\n          paused.resume();\n        }\n      });\n    } else {\n      fail('No user');\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { ClsService } from 'nestjs-cls';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { IClsStore } from '../../../types/cls';\nimport { UserService } from '../../user/user.service';\nimport { pickUserMe } from '../utils';\nimport { PassportSessionStrategy } from './session.passport';\nimport type { IPayloadUser } from './types';\n\n@Injectable()\nexport class SessionStrategy extends PassportStrategy(PassportSessionStrategy) {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private readonly userService: UserService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super();\n  }\n\n  async validate(payload: IPayloadUser) {\n    const user = await this.userService.getUserById(payload.id);\n    if (!user) {\n      throw new UnauthorizedException();\n    }\n    if (user.deactivatedTime) {\n      throw new UnauthorizedException('Your account has been deactivated by the administrator');\n    }\n\n    if (user.isSystem) {\n      throw new UnauthorizedException('User is system user');\n    }\n\n    this.cls.set('user.id', user.id);\n    this.cls.set('user.name', user.name);\n    this.cls.set('user.email', user.email);\n    this.cls.set('user.isAdmin', user.isAdmin);\n    return pickUserMe(user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/strategies/types.ts",
    "content": "import { z } from '@teable/openapi';\nimport type { Request } from 'express';\n\nexport interface IPayloadUser {\n  id: string;\n}\n\nexport type IFromExtractor = (req: Request) => string | null;\n\nexport interface IJwtAuthInfo {\n  userId: string;\n}\n\nexport enum JwtAuthInternalType {\n  Automation = 'automation',\n  App = 'app',\n  User = 'user',\n}\n\nconst workflowContextSchema = z.object({\n  actionId: z.string().optional(),\n});\n\nexport type IWorkflowContext = z.infer<typeof workflowContextSchema>;\n\nconst jwtAuthInternalBaseInfoSchema = z.object({\n  baseId: z.string(),\n  userId: z.string().optional(),\n  context: z.unknown().optional(),\n});\n\nexport const jwtAuthInternalInfoSchema = jwtAuthInternalBaseInfoSchema.and(\n  z.discriminatedUnion('type', [\n    z.object({\n      type: z.literal(JwtAuthInternalType.Automation),\n      context: workflowContextSchema.optional(),\n    }),\n    z.object({\n      type: z.literal(JwtAuthInternalType.App),\n    }),\n    z.object({\n      type: z.literal(JwtAuthInternalType.User),\n    }),\n  ])\n);\n\nexport type IJwtAuthInternalInfo = z.infer<typeof jwtAuthInternalInfoSchema>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/turnstile/turnstile.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { TurnstileService } from './turnstile.service';\n\n@Module({\n  providers: [TurnstileService],\n  exports: [TurnstileService],\n})\nexport class TurnstileModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\n\ninterface ITurnstileValidationResponse {\n  success: boolean;\n  'error-codes'?: string[];\n  challenge_ts?: string;\n  hostname?: string;\n  action?: string;\n  cdata?: string;\n  metadata?: {\n    ephemeral_id?: string;\n  };\n}\n\ninterface ITurnstileValidationRequest {\n  secret: string;\n  response: string;\n  remoteip?: string;\n  idempotency_key?: string;\n}\n\n@Injectable()\nexport class TurnstileService {\n  private readonly logger = new Logger(TurnstileService.name);\n  private readonly turnstileSecretKey: string;\n  private readonly turnstileSiteKey: string;\n  private readonly isEnabled: boolean;\n\n  constructor(private readonly configService: ConfigService) {\n    this.turnstileSecretKey = this.configService.get<string>('TURNSTILE_SECRET_KEY') || '';\n    this.turnstileSiteKey = this.configService.get<string>('TURNSTILE_SITE_KEY') || '';\n    this.isEnabled = Boolean(this.turnstileSiteKey && this.turnstileSecretKey);\n\n    this.logger.log(\n      `Turnstile Service Initialization - isEnabled: ${this.isEnabled}, hasSiteKey: ${!!this.turnstileSiteKey}, hasSecretKey: ${!!this.turnstileSecretKey}, siteKeyLength: ${this.turnstileSiteKey?.length}, secretKeyLength: ${this.turnstileSecretKey?.length}`\n    );\n\n    if (this.isEnabled) {\n      this.logger.log('Turnstile validation is enabled');\n    } else {\n      this.logger.warn('Turnstile validation is disabled - missing site key or secret key');\n    }\n  }\n\n  /**\n   * Check if Turnstile is enabled based on environment configuration\n   */\n  isTurnstileEnabled(): boolean {\n    return this.isEnabled;\n  }\n\n  /**\n   * Get the Turnstile site key for client-side rendering\n   */\n  getTurnstileSiteKey(): string | null {\n    return this.isEnabled ? this.turnstileSiteKey : null;\n  }\n\n  /**\n   * Validate Turnstile token with Cloudflare's siteverify API\n   */\n  async validateTurnstileToken(\n    token: string,\n    remoteIp?: string,\n    expectedAction?: string,\n    expectedHostname?: string\n  ): Promise<{ valid: boolean; reason?: string; data?: ITurnstileValidationResponse }> {\n    if (!this.isEnabled) {\n      this.logger.warn('Turnstile validation attempted but service is not enabled');\n      return { valid: false, reason: 'turnstile_disabled' };\n    }\n\n    if (!token || typeof token !== 'string') {\n      return { valid: false, reason: 'invalid_token_format' };\n    }\n\n    if (token.length > 2048) {\n      return { valid: false, reason: 'token_too_long' };\n    }\n\n    const requestData: ITurnstileValidationRequest = {\n      secret: this.turnstileSecretKey,\n      response: token,\n    };\n\n    if (remoteIp) {\n      requestData.remoteip = remoteIp;\n    }\n\n    try {\n      const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify(requestData),\n      });\n\n      if (!response.ok) {\n        this.logger.error(`Turnstile API returned ${response.status}: ${response.statusText}`);\n        return { valid: false, reason: 'api_error' };\n      }\n\n      const result: ITurnstileValidationResponse = await response.json();\n\n      if (!result.success) {\n        this.logger.warn('Turnstile validation failed', {\n          errorCodes: result['error-codes'],\n          token: token.substring(0, 20) + '...',\n        });\n        return {\n          valid: false,\n          reason: 'turnstile_failed',\n          data: result,\n        };\n      }\n\n      // Log action and hostname for monitoring (but don't reject)\n      if (expectedAction && result.action && result.action !== expectedAction) {\n        this.logger.debug('Turnstile action info', {\n          expected: expectedAction,\n          received: result.action,\n        });\n      }\n\n      if (expectedHostname && result.hostname && result.hostname !== expectedHostname) {\n        this.logger.debug('Turnstile hostname info', {\n          expected: expectedHostname,\n          received: result.hostname,\n        });\n      }\n\n      // Check token age (warn if older than 4 minutes)\n      if (result.challenge_ts) {\n        const challengeTime = new Date(result.challenge_ts);\n        const now = new Date();\n        const ageMinutes = (now.getTime() - challengeTime.getTime()) / (1000 * 60);\n\n        if (ageMinutes > 4) {\n          this.logger.warn(`Turnstile token is ${ageMinutes.toFixed(1)} minutes old`);\n        }\n      }\n\n      this.logger.debug('Turnstile validation successful', {\n        hostname: result.hostname,\n        action: result.action,\n        challengeTs: result.challenge_ts,\n      });\n\n      return { valid: true, data: result };\n    } catch (error) {\n      this.logger.error('Turnstile validation error', error);\n      return { valid: false, reason: 'internal_error' };\n    }\n  }\n\n  /**\n   * Validate Turnstile token with retry logic\n   */\n  async validateTurnstileTokenWithRetry(\n    token: string,\n    remoteIp?: string,\n    expectedAction?: string,\n    expectedHostname?: string,\n    maxRetries: number = 3\n  ): Promise<{ valid: boolean; reason?: string; data?: ITurnstileValidationResponse }> {\n    for (let attempt = 1; attempt <= maxRetries; attempt++) {\n      const result = await this.validateTurnstileToken(\n        token,\n        remoteIp,\n        expectedAction,\n        expectedHostname\n      );\n\n      // If validation succeeded or failed for non-retryable reasons, return immediately\n      if (result.valid || (result.reason !== 'api_error' && result.reason !== 'internal_error')) {\n        return result;\n      }\n\n      // If this is the last attempt, return the error\n      if (attempt === maxRetries) {\n        return result;\n      }\n\n      // Wait before retrying (exponential backoff)\n      const delay = Math.pow(2, attempt - 1) * 1000;\n      await new Promise((resolve) => setTimeout(resolve, delay));\n\n      this.logger.warn(`Turnstile validation attempt ${attempt} failed, retrying in ${delay}ms`);\n    }\n\n    return { valid: false, reason: 'max_retries_exceeded' };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/auth/utils.ts",
    "content": "import type { Prisma } from '@teable/db-main-prisma';\nimport { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER, type IUserMeVo } from '@teable/openapi';\nimport type { Request } from 'express';\nimport { pick } from 'lodash';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\n\nexport type IPickUserMe = Pick<\n  Prisma.UserGetPayload<null>,\n  'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta' | 'isAdmin' | 'lang'\n>;\n\nexport const pickUserMe = (user: IPickUserMe): IUserMeVo => {\n  return {\n    ...pick(user, 'id', 'name', 'phone', 'email', 'isAdmin', 'lang'),\n    notifyMeta: typeof user.notifyMeta === 'object' ? user.notifyMeta : JSON.parse(user.notifyMeta),\n    avatar:\n      user.avatar && !user.avatar?.startsWith('http')\n        ? getPublicFullStorageUrl(user.avatar)\n        : user.avatar,\n    hasPassword: user.password !== null,\n  };\n};\n\nexport const getTemplateHeader = (request: Request): string | undefined => {\n  const templateHeader =\n    request.headers[IS_TEMPLATE_HEADER.toLowerCase()] || request.headers[IS_TEMPLATE_HEADER];\n  return typeof templateHeader === 'string' ? templateHeader : undefined;\n};\n\nexport const getBaseShareHeader = (request: Request): string | undefined => {\n  const baseShareHeader =\n    request.headers[BASE_SHARE_ID_HEADER.toLowerCase()] || request.headers[BASE_SHARE_ID_HEADER];\n  return typeof baseShareHeader === 'string' ? baseShareHeader : undefined;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/BatchProcessor.class.ts",
    "content": "import type { TransformCallback } from 'stream';\nimport { Transform } from 'stream';\n\nexport class BatchProcessor<T> extends Transform {\n  private buffer: T[] = [];\n  private totalProcessed = 0;\n  public static BATCH_SIZE = 1000;\n\n  constructor(private readonly handler: (chunk: T[]) => Promise<void>) {\n    super({ objectMode: true });\n  }\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  async _transform(chunk: T, encoding: BufferEncoding, callback: TransformCallback) {\n    this.buffer.push(chunk);\n    this.totalProcessed++;\n\n    if (this.buffer.length >= BatchProcessor.BATCH_SIZE) {\n      const currentBatch = [...this.buffer];\n      this.buffer = [];\n\n      try {\n        await this.handler(currentBatch);\n        this.emit('progress', { processed: this.totalProcessed });\n        callback();\n      } catch (err: unknown) {\n        callback(err as Error);\n      }\n    } else {\n      callback();\n    }\n  }\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  async _flush(callback: TransformCallback) {\n    if (this.buffer.length > 0) {\n      try {\n        await this.handler(this.buffer);\n        this.emit('progress', { processed: this.totalProcessed });\n        callback();\n      } catch (err: unknown) {\n        callback(err as Error);\n      }\n    } else {\n      callback();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-duplicate.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { BaseDuplicateService } from './base-duplicate.service';\nimport { BaseModule } from './base.module';\n\ndescribe('BaseDuplicateService', () => {\n  let service: BaseDuplicateService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, BaseModule],\n    }).compile();\n\n    service = module.get<BaseDuplicateService>(BaseDuplicateService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-duplicate.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  BaseDuplicateMode,\n  CreateRecordAction,\n  type ICreateBaseFromTemplateRo,\n  type IDuplicateBaseRo,\n} from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { groupBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport { ComputedOrchestratorService } from '../record/computed/services/computed-orchestrator.service';\nimport { TableDuplicateService } from '../table/table-duplicate.service';\nimport { BaseExportService } from './base-export.service';\nimport { BaseImportService } from './base-import.service';\nimport { mergeLinkFieldTableMaps } from './utils';\n\n@Injectable()\nexport class BaseDuplicateService {\n  private logger = new Logger(BaseDuplicateService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly tableDuplicateService: TableDuplicateService,\n    private readonly baseExportService: BaseExportService,\n    private readonly baseImportService: BaseImportService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly computedOrchestrator: ComputedOrchestratorService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  async duplicateBase(\n    duplicateBaseRo: IDuplicateBaseRo,\n    allowCrossBase: boolean = true,\n    duplicateMode: BaseDuplicateMode = BaseDuplicateMode.Normal\n  ) {\n    const { fromBaseId, spaceId, withRecords, name, baseId, nodes } = duplicateBaseRo;\n\n    // For CopyShareBase mode, don't collect parent nodes - the shared node becomes the root\n    const skipParentNodes = duplicateMode === BaseDuplicateMode.CopyShareBase;\n\n    const { base, tableIdMap, fieldIdMap, viewIdMap, ...rest } = await this.duplicateStructure(\n      fromBaseId,\n      spaceId,\n      name,\n      allowCrossBase,\n      baseId,\n      nodes,\n      duplicateMode\n    );\n\n    const crossBaseLinkFieldTableMap = allowCrossBase\n      ? ({} as Record<\n          string,\n          {\n            dbFieldName: string;\n            selfKeyName: string;\n            isMultipleCellValue: boolean;\n          }[]\n        >)\n      : await this.getCrossBaseLinkFieldTableMap(tableIdMap);\n\n    const disconnectedLinkFieldTableMap = await this.getDisconnectedLinkFieldTableMap(\n      tableIdMap,\n      fromBaseId,\n      nodes,\n      skipParentNodes\n    );\n\n    const mergedLinkFieldTableMap = mergeLinkFieldTableMaps(\n      crossBaseLinkFieldTableMap,\n      disconnectedLinkFieldTableMap\n    );\n\n    const disconnectedLinkFieldIds = await this.getDisconnectedLinkFieldIds(\n      tableIdMap,\n      fromBaseId,\n      nodes,\n      skipParentNodes\n    );\n\n    let recordsLength = 0;\n    if (withRecords) {\n      recordsLength = await this.duplicateTableData(\n        tableIdMap,\n        fieldIdMap,\n        viewIdMap,\n        mergedLinkFieldTableMap\n      );\n      await this.duplicateAttachments(tableIdMap, fieldIdMap);\n      await this.duplicateLinkJunction(\n        tableIdMap,\n        fieldIdMap,\n        allowCrossBase,\n        disconnectedLinkFieldIds\n      );\n\n      // Persist computed/link/lookup/rollup columns for duplicated data so that\n      // reads via useQueryModel (tableCache/raw table) return correct values.\n      // This mirrors what the computed pipeline does during regular record writes.\n      await this.recomputeComputedColumnsForDuplicatedBase(tableIdMap);\n    }\n\n    return { base, tableIdMap, fieldIdMap, viewIdMap, recordsLength, ...rest };\n  }\n\n  private async getDisconnectedLinkFieldIds(\n    tableIdMap: Record<string, string>,\n    fromBaseId: string,\n    nodes?: string[],\n    skipParentNodes: boolean = false\n  ) {\n    const { excludedTableIds } = await this.collectNodesAndResourceIds(\n      fromBaseId,\n      nodes,\n      skipParentNodes\n    );\n    if (!excludedTableIds?.length) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n    const allFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: { in: Object.keys(tableIdMap) },\n        deletedTime: null,\n      },\n    });\n\n    const fields = allFieldRaws.map((f) => createFieldInstanceByRaw(f));\n\n    return fields\n      .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n      .filter((f) => excludedTableIds.includes((f.options as ILinkFieldOptions)?.foreignTableId))\n      .map((f) => f.id);\n  }\n\n  private async duplicateStructure(\n    fromBaseId: string,\n    spaceId: string,\n    baseName?: string,\n    allowCrossBase?: boolean,\n    baseId?: string,\n    nodes?: string[],\n    duplicateMode: BaseDuplicateMode = BaseDuplicateMode.Normal\n  ) {\n    const prisma = this.prismaService.txClient();\n    const baseRaw = await prisma.base.findUniqueOrThrow({\n      where: {\n        id: fromBaseId,\n        deletedTime: null,\n      },\n    });\n    baseRaw.name = baseName || `${baseRaw.name} (Copy)`;\n\n    // For CopyShareBase mode, don't collect parent nodes - the shared node becomes the root\n    const skipParentNodes = duplicateMode === BaseDuplicateMode.CopyShareBase;\n\n    // Get included table IDs if includeNodes is provided\n    const {\n      finalIncludeNodes,\n      includedTableIds,\n      includedFolderIds,\n      includedDashboardIds,\n      includedWorkflowIds,\n      includedAppIds,\n      excludedTableIds,\n    } = await this.collectNodesAndResourceIds(fromBaseId, nodes, skipParentNodes);\n\n    const rootNodeIds = skipParentNodes ? [...(nodes || [])] : undefined;\n\n    const tableRaws = await prisma.tableMeta.findMany({\n      where: {\n        baseId: fromBaseId,\n        deletedTime: null,\n        ...(includedTableIds !== undefined ? { id: { in: includedTableIds } } : {}),\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n    const tableIds = tableRaws.map(({ id }) => id);\n    const fieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: {\n          in: tableIds,\n        },\n        deletedTime: null,\n      },\n    });\n    const viewRaws = await prisma.view.findMany({\n      where: {\n        tableId: {\n          in: tableIds,\n        },\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    const structure = await this.baseExportService.generateBaseStructConfig({\n      baseRaw,\n      tableRaws,\n      fieldRaws,\n      viewRaws,\n      allowCrossBase,\n      includeNodes: finalIncludeNodes,\n      includedFolderIds,\n      includedDashboardIds,\n      includedWorkflowIds,\n      includedAppIds,\n      excludedTableIds,\n      rootNodeIds,\n    });\n\n    this.logger.log(`base-duplicate-service: Start to getting base structure config successfully`);\n\n    const {\n      base: newBase,\n      tableIdMap,\n      fieldIdMap,\n      viewIdMap,\n      ...rest\n    } = await this.baseImportService.createBaseStructure(\n      spaceId,\n      structure,\n      baseId,\n      undefined,\n      duplicateMode\n    );\n\n    return { base: newBase, tableIdMap, fieldIdMap, viewIdMap, ...rest };\n  }\n\n  /**\n   * Collect nodes and their resource IDs by type\n   * This method processes the selected nodes and collects all their parent nodes (unless skipParentNodes is true)\n   * Then extracts resource IDs grouped by resource type\n   *\n   * @param fromBaseId - The base ID to collect nodes from\n   * @param nodes - The selected node IDs\n   * @param skipParentNodes - If true, don't collect parent nodes (used for share base copy)\n   */\n  private async collectNodesAndResourceIds(\n    fromBaseId: string,\n    nodes: string[] | undefined,\n    skipParentNodes: boolean = false\n  ) {\n    const prisma = this.prismaService.txClient();\n    let includedTableIds: string[] | undefined;\n    let includedFolderIds: string[] | undefined;\n    let includedDashboardIds: string[] | undefined;\n    let includedWorkflowIds: string[] | undefined;\n    let includedAppIds: string[] | undefined;\n    let finalIncludeNodes: string[] | undefined;\n\n    let excludedTableIds: string[] | undefined;\n    let excludedFolderIds: string[] | undefined;\n    let excludedDashboardIds: string[] | undefined;\n    let excludedWorkflowIds: string[] | undefined;\n    let excludedAppIds: string[] | undefined;\n\n    if (nodes && nodes.length > 0) {\n      // Get all nodes in the base to build parent-child relationships\n      const allNodes = await prisma.baseNode.findMany({\n        where: {\n          baseId: fromBaseId,\n        },\n        select: {\n          id: true,\n          parentId: true,\n          resourceId: true,\n          resourceType: true,\n        },\n      });\n\n      // Build a map for quick lookup\n      const nodeMap = new Map(allNodes.map((node) => [node.id, node]));\n\n      // Function to recursively collect parent nodes\n      const collectParentNodes = (nodeId: string, collected: Set<string>) => {\n        if (collected.has(nodeId)) return;\n        collected.add(nodeId);\n\n        const node = nodeMap.get(nodeId);\n        if (node?.parentId) {\n          collectParentNodes(node.parentId, collected);\n        }\n      };\n\n      // Function to recursively collect descendant nodes (children)\n      const collectDescendantNodes = (nodeId: string, collected: Set<string>) => {\n        // Find all children of this node and collect them\n        for (const node of allNodes) {\n          if (node.parentId === nodeId && !collected.has(node.id)) {\n            collected.add(node.id);\n            collectDescendantNodes(node.id, collected);\n          }\n        }\n      };\n\n      // Collect selected nodes, all their parent nodes (unless skipParentNodes), and all their descendant nodes\n      const allIncludedNodeIds = new Set<string>();\n      for (const nodeId of nodes) {\n        if (skipParentNodes) {\n          // Only add the node itself, no parent collection\n          allIncludedNodeIds.add(nodeId);\n        } else {\n          // Collect the node itself and its parents (for folder structure)\n          // Note: collectParentNodes already adds the nodeId itself\n          collectParentNodes(nodeId, allIncludedNodeIds);\n        }\n        // Collect all descendants (children, grandchildren, etc.)\n        collectDescendantNodes(nodeId, allIncludedNodeIds);\n      }\n\n      finalIncludeNodes = Array.from(allIncludedNodeIds);\n\n      // Extract resource IDs by type\n      const includedNodeDetails = allNodes.filter((node) => allIncludedNodeIds.has(node.id));\n\n      includedTableIds = includedNodeDetails\n        .filter((node) => node.resourceType === 'table')\n        .map((node) => node.resourceId);\n\n      includedFolderIds = includedNodeDetails\n        .filter((node) => node.resourceType === 'folder')\n        .map((node) => node.resourceId);\n\n      includedDashboardIds = includedNodeDetails\n        .filter((node) => node.resourceType === 'dashboard')\n        .map((node) => node.resourceId);\n\n      includedWorkflowIds = includedNodeDetails\n        .filter((node) => node.resourceType === 'workflow')\n        .map((node) => node.resourceId);\n\n      includedAppIds = includedNodeDetails\n        .filter((node) => node.resourceType === 'app')\n        .map((node) => node.resourceId);\n\n      excludedTableIds = allNodes\n        .filter((node) => !allIncludedNodeIds.has(node.id))\n        .map((node) => node.resourceId);\n      excludedFolderIds = allNodes\n        .filter((node) => !allIncludedNodeIds.has(node.id))\n        .map((node) => node.resourceId);\n      excludedDashboardIds = allNodes\n        .filter((node) => !allIncludedNodeIds.has(node.id))\n        .map((node) => node.resourceId);\n      excludedWorkflowIds = allNodes\n        .filter((node) => !allIncludedNodeIds.has(node.id))\n        .map((node) => node.resourceId);\n      excludedAppIds = allNodes\n        .filter((node) => !allIncludedNodeIds.has(node.id))\n        .map((node) => node.resourceId);\n    }\n\n    return {\n      finalIncludeNodes,\n      includedTableIds,\n      includedFolderIds,\n      includedDashboardIds,\n      includedWorkflowIds,\n      includedAppIds,\n\n      excludedTableIds,\n      excludedFolderIds,\n      excludedDashboardIds,\n      excludedWorkflowIds,\n      excludedAppIds,\n    };\n  }\n\n  private async getDisconnectedLinkFieldTableMap(\n    tableIdMap: Record<string, string>,\n    fromBaseId: string,\n    nodes?: string[],\n    skipParentNodes: boolean = false\n  ) {\n    const tableId2DbFieldNameMap: Record<\n      string,\n      { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]\n    > = {};\n    const { excludedTableIds } = await this.collectNodesAndResourceIds(\n      fromBaseId,\n      nodes,\n      skipParentNodes\n    );\n\n    if (!nodes?.length || !excludedTableIds?.length) {\n      return tableId2DbFieldNameMap;\n    }\n\n    const prisma = this.prismaService.txClient();\n    const allFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: { in: Object.keys(tableIdMap) },\n        deletedTime: null,\n      },\n    });\n\n    const disconnectedLinkFields = allFieldRaws\n      .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n      .map((f) => ({ ...createFieldInstanceByRaw(f), tableId: f.tableId }))\n      .filter((f) => excludedTableIds.includes((f.options as ILinkFieldOptions)?.foreignTableId));\n\n    // relative fields\n    // const disconnectedLinkRelativeFields = allFieldRaws\n    //   .map((f) => ({ ...createFieldInstanceByRaw(f), tableId: f.tableId }))\n    //   .filter(\n    //     ({ type, isLookup }) =>\n    //       isLookup || type === FieldType.Rollup || type === FieldType.ConditionalRollup\n    //   )\n    //   .filter(({ lookupOptions }) => {\n    //     if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n    //       return false;\n    //     }\n    //     return disconnectedLinkFields.map(({ id }) => id).includes(lookupOptions.linkFieldId);\n    //   });\n\n    const groupedDisconnectedLinkFields = groupBy([...disconnectedLinkFields], 'tableId');\n\n    Object.entries(groupedDisconnectedLinkFields).map(([tableId, fields]) => {\n      tableId2DbFieldNameMap[tableId] = fields.map(\n        ({ dbFieldName, options, isMultipleCellValue }) => {\n          return {\n            dbFieldName,\n            selfKeyName: (options as ILinkFieldOptions).selfKeyName,\n            isMultipleCellValue: !!isMultipleCellValue,\n          };\n        }\n      );\n\n      tableId2DbFieldNameMap[tableIdMap[tableId]] = fields.map(\n        ({ dbFieldName, options, isMultipleCellValue }) => {\n          return {\n            dbFieldName,\n            selfKeyName: (options as ILinkFieldOptions).selfKeyName,\n            isMultipleCellValue: !!isMultipleCellValue,\n          };\n        }\n      );\n\n      return {\n        tableId2DbFieldNameMap,\n      };\n    });\n\n    return tableId2DbFieldNameMap;\n  }\n\n  private async getCrossBaseLinkFieldTableMap(tableIdMap: Record<string, string>) {\n    const tableId2DbFieldNameMap: Record<\n      string,\n      { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]\n    > = {};\n    const prisma = this.prismaService.txClient();\n    const allFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: { in: Object.keys(tableIdMap) },\n        deletedTime: null,\n      },\n    });\n\n    const crossBaseLinkFields = allFieldRaws\n      .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n      .map((f) => ({ ...createFieldInstanceByRaw(f), tableId: f.tableId }))\n      .filter((f) => (f.options as ILinkFieldOptions).baseId);\n\n    const groupedCrossBaseLinkFields = groupBy(crossBaseLinkFields, 'tableId');\n\n    Object.entries(groupedCrossBaseLinkFields).map(([tableId, fields]) => {\n      tableId2DbFieldNameMap[tableId] = fields.map(\n        ({ dbFieldName, options, isMultipleCellValue }) => {\n          return {\n            dbFieldName,\n            selfKeyName: (options as ILinkFieldOptions).selfKeyName,\n            isMultipleCellValue: !!isMultipleCellValue,\n          };\n        }\n      );\n      tableId2DbFieldNameMap[tableIdMap[tableId]] = fields.map(\n        ({ dbFieldName, options, isMultipleCellValue }) => {\n          return {\n            dbFieldName,\n            selfKeyName: (options as ILinkFieldOptions).selfKeyName,\n            isMultipleCellValue: !!isMultipleCellValue,\n          };\n        }\n      );\n    });\n\n    return tableId2DbFieldNameMap;\n  }\n\n  private async duplicateTableData(\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    viewIdMap: Record<string, string>,\n    crossBaseLinkFieldTableMap: Record<\n      string,\n      { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]\n    >\n  ): Promise<number> {\n    const prisma = this.prismaService.txClient();\n    const tableId2DbTableNameMap: Record<string, string> = {};\n    const allTableId = Object.keys(tableIdMap).concat(Object.values(tableIdMap));\n    const sourceTableRaws = await prisma.tableMeta.findMany({\n      where: { id: { in: allTableId }, deletedTime: null },\n      select: {\n        id: true,\n        dbTableName: true,\n      },\n    });\n    const targetTableRaws = await prisma.tableMeta.findMany({\n      where: { id: { in: allTableId }, deletedTime: null },\n      select: {\n        id: true,\n        dbTableName: true,\n      },\n    });\n    sourceTableRaws.forEach((tableRaw) => {\n      tableId2DbTableNameMap[tableRaw.id] = tableRaw.dbTableName;\n    });\n\n    const oldTableId = Object.keys(tableIdMap);\n\n    const dbTableNames = targetTableRaws.map((tableRaw) => tableRaw.dbTableName);\n\n    // Query total records count from all source tables before duplicating\n    let totalRecordsCount = 0;\n    for (const tableId of oldTableId) {\n      const sourceDbTableName = tableId2DbTableNameMap[tableId];\n      const countQuery = this.knex(sourceDbTableName).count('*', { as: 'count' }).toQuery();\n      const countResult = await prisma.$queryRawUnsafe<[{ count: bigint | number }]>(countQuery);\n      totalRecordsCount += Number(countResult[0]?.count || 0);\n    }\n\n    const allForeignKeyInfos = [] as {\n      constraint_name: string;\n      column_name: string;\n      referenced_table_schema: string;\n      referenced_table_name: string;\n      referenced_column_name: string;\n      dbTableName: string;\n    }[];\n\n    // delete foreign keys if(exist) then duplicate table data\n    for (const dbTableName of dbTableNames) {\n      const foreignKeysInfoSql = this.dbProvider.getForeignKeysInfo(dbTableName);\n      const foreignKeysInfo = await this.prismaService.txClient().$queryRawUnsafe<\n        {\n          constraint_name: string;\n          column_name: string;\n          referenced_table_schema: string;\n          referenced_table_name: string;\n          referenced_column_name: string;\n        }[]\n      >(foreignKeysInfoSql);\n      const newForeignKeyInfos = foreignKeysInfo.map((info) => ({\n        ...info,\n        dbTableName,\n      }));\n      allForeignKeyInfos.push(...newForeignKeyInfos);\n    }\n\n    for (const { constraint_name, column_name, dbTableName } of allForeignKeyInfos) {\n      const dropForeignKeyQuery = this.knex.schema\n        .alterTable(dbTableName, (table) => {\n          table.dropForeign(column_name, constraint_name);\n        })\n        .toQuery();\n\n      await prisma.$executeRawUnsafe(dropForeignKeyQuery);\n    }\n\n    for (const tableId of oldTableId) {\n      const newTableId = tableIdMap[tableId];\n      const oldDbTableName = tableId2DbTableNameMap[tableId];\n      const newDbTableName = tableId2DbTableNameMap[newTableId];\n      try {\n        await this.tableDuplicateService.duplicateTableData(\n          oldDbTableName,\n          newDbTableName,\n          viewIdMap,\n          fieldIdMap,\n          crossBaseLinkFieldTableMap[tableId] || []\n        );\n      } catch (error) {\n        this.logger.error(\n          `exc duplicate table data error: ${(error as Error)?.message}`,\n          (error as Error)?.stack\n        );\n        throw error;\n      }\n    }\n\n    for (const {\n      constraint_name: constraintName,\n      column_name: columnName,\n      referenced_table_schema: referencedTableSchema,\n      referenced_table_name: referencedTableName,\n      referenced_column_name: referencedColumnName,\n      dbTableName,\n    } of allForeignKeyInfos) {\n      const addForeignKeyQuerySql = this.knex.schema\n        .alterTable(dbTableName, (table) => {\n          table\n            .foreign(columnName, constraintName)\n            .references(referencedColumnName)\n            .inTable(`${referencedTableSchema}.${referencedTableName}`);\n        })\n        .toQuery();\n\n      await prisma.$executeRawUnsafe(addForeignKeyQuerySql);\n    }\n\n    return totalRecordsCount;\n  }\n\n  private async duplicateAttachments(\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>\n  ) {\n    for (const [sourceTableId, targetTableId] of Object.entries(tableIdMap)) {\n      await this.tableDuplicateService.duplicateAttachments(\n        sourceTableId,\n        targetTableId,\n        fieldIdMap\n      );\n    }\n  }\n\n  private async duplicateLinkJunction(\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    allowCrossBase: boolean = true,\n    disconnectedLinkFieldIds?: string[]\n  ) {\n    await this.tableDuplicateService.duplicateLinkJunction(\n      tableIdMap,\n      fieldIdMap,\n      allowCrossBase,\n      disconnectedLinkFieldIds\n    );\n  }\n\n  /**\n   * After duplicating raw table rows and link junctions, recompute and persist\n   * values for computed fields (Lookup/Rollup/Formula when persisted) and Link\n   * display columns on all duplicated tables. This ensures immediate consistency\n   * when reading via table cache or raw table without CTEs (useQueryModel=true).\n   */\n  private async recomputeComputedColumnsForDuplicatedBase(tableIdMap: Record<string, string>) {\n    const prisma = this.prismaService.txClient();\n    const targetTableIds = Object.values(tableIdMap);\n    if (!targetTableIds.length) return;\n\n    // Collect candidate fields on the duplicated tables: include link fields and\n    // any computed fields so their values are (re)materialized into physical columns.\n    const fields = await prisma.field.findMany({\n      where: {\n        tableId: { in: targetTableIds },\n        deletedTime: null,\n      },\n      select: { id: true, tableId: true, type: true, isLookup: true, isComputed: true },\n    });\n\n    // Group by table and select fields that should be persisted via updateFromSelect\n    const byTable = new Map<string, string[]>();\n    for (const f of fields) {\n      // Link fields (non-lookup) have persisted display JSON; include them\n      const isLink = f.type === FieldType.Link && !f.isLookup;\n      // Computed fields (lookup/rollup/formula-not-generated) are marked isComputed\n      const isComputed = !!f.isComputed;\n      if (!isLink && !isComputed) continue;\n      const list = byTable.get(f.tableId) || [];\n      list.push(f.id);\n      byTable.set(f.tableId, list);\n    }\n\n    if (!byTable.size) return;\n\n    const sources = Array.from(byTable.entries()).map(([tableId, fieldIds]) => ({\n      tableId,\n      fieldIds,\n    }));\n\n    // No-op update; we only want to evaluate and persist computed values.\n    await this.computedOrchestrator.computeCellChangesForFieldsAfterCreate(sources, async () => {\n      return;\n    });\n  }\n\n  async emitBaseDuplicateAuditLog(baseId: string, recordsLength?: number) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId!);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.BaseDuplicate,\n        resourceId: baseId,\n        recordCount: recordsLength,\n      });\n    });\n  }\n\n  async emitBaseTemplateApplyAuditLog(\n    baseId: string,\n    templateApplyRo: ICreateBaseFromTemplateRo,\n    recordsLength?: number\n  ) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId!);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.TemplateApply,\n        resourceId: baseId,\n        recordCount: recordsLength,\n      });\n    });\n  }\n\n  async emitShareBaseCopyAuditLog(baseId: string, shareId: string, recordsLength?: number) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId!);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.ShareBaseCopy,\n        resourceId: baseId,\n        recordCount: recordsLength,\n        params: { shareId },\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-export.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Readable, PassThrough } from 'stream';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport * as Sentry from '@sentry/nestjs';\nimport type {\n  ILinkFieldOptions,\n  ILocalization,\n  IConditionalRollupFieldOptions,\n  IConditionalLookupOptions,\n} from '@teable/core';\nimport { FieldType, getRandomString, ViewType, isLinkLookupOptions } from '@teable/core';\nimport type { Field, View, TableMeta, Base } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PluginPosition, UploadType } from '@teable/openapi';\nimport type { BaseNodeResourceType, IBaseJson } from '@teable/openapi';\nimport archiver from 'archiver';\nimport { stringify } from 'csv-stringify/sync';\nimport { Knex } from 'knex';\nimport { omit, pick } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { IStorageConfig, StorageConfig } from '../../configs/storage';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport type { I18nPath } from '../../types/i18n.generated';\nimport { second } from '../../utils/second';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../attachments/plugins/storage';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport { NotificationService } from '../notification/notification.service';\nimport { createViewVoByRaw } from '../view/model/factory';\nimport { EXCLUDE_SYSTEM_FIELDS } from './constant';\n@Injectable()\nexport class BaseExportService {\n  public static CSV_CHUNK = 500;\n  public static FILE_SUFFIX = 'tea';\n  public static EXPORT_FIELD_COLUMNS = [\n    'id',\n    'name',\n    'description',\n    'options',\n    'type',\n    'dbFieldName',\n    'notNull',\n    'unique',\n    'isPrimary',\n    'hasError',\n    'order',\n    'lookupOptions',\n    'isLookup',\n    'isConditionalLookup',\n    'aiConfig',\n    'meta',\n    // for formula field\n    'dbFieldType',\n    'cellValueType',\n    'isMultipleCellValue',\n  ];\n  private logger = new Logger(BaseExportService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly notificationService: NotificationService,\n    private readonly eventEmitterService: EventEmitterService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @StorageConfig() private readonly storageConfig: IStorageConfig,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  private captureExportError(\n    error: unknown,\n    context: {\n      stage: 'fetchBase' | 'processExport';\n      baseId: string;\n      includeData: boolean;\n      baseName?: string;\n    }\n  ) {\n    const err = error instanceof Error ? error : new Error(String(error));\n    const userId = this.cls.get('user.id');\n\n    Sentry.withScope((scope) => {\n      scope.setTag('feature', 'base-export');\n      scope.setTag('export.stage', context.stage);\n      scope.setContext('base-export', {\n        baseId: context.baseId,\n        baseName: context.baseName,\n        includeData: context.includeData,\n        userId,\n      });\n      scope.setLevel?.('error');\n      Sentry.captureException(err);\n    });\n\n    this.logger.error(\n      `export base zip failed at ${context.stage}: ${err.message}`,\n      err.stack ?? undefined\n    );\n  }\n\n  private generateExportFolderId() {\n    return `${getRandomString(12)}`;\n  }\n\n  /**\n   * Download a single file and append it to archive with timeout and error handling\n   * @returns true on success, false on failure\n   */\n  async appendFileToArchive(\n    archive: archiver.Archiver,\n    bucket: string,\n    s3Path: string,\n    archivePath: string,\n    timeoutMs: number = 10 * 60 * 1000,\n    chatId?: string\n  ): Promise<boolean> {\n    try {\n      const stream = await this.storageAdapter.downloadFile(bucket, s3Path);\n\n      await new Promise<void>((resolve, reject) => {\n        archive.append(stream, { name: archivePath });\n\n        const timeout = setTimeout(() => {\n          stream.destroy();\n          reject(new Error(`File stream timeout after ${timeoutMs}ms: ${archivePath}`));\n        }, timeoutMs);\n\n        stream.on('error', (err) => {\n          clearTimeout(timeout);\n          stream.destroy();\n          reject(err);\n        });\n\n        stream.on('end', () => {\n          clearTimeout(timeout);\n          stream.destroy();\n          resolve();\n        });\n      });\n\n      return true;\n    } catch (err) {\n      this.logger.error(\n        `Failed to export file ${s3Path} to ${archivePath}: ${err instanceof Error ? err.message : String(err)}`\n      );\n      return false;\n    }\n  }\n\n  async exportBaseZip(baseId: string, includeData = true) {\n    let baseName: string | undefined;\n    try {\n      ({ name: baseName } = await this.prismaService.base.findFirstOrThrow({\n        where: {\n          id: baseId,\n        },\n        select: {\n          name: true,\n        },\n      }));\n    } catch (error) {\n      this.captureExportError(error, {\n        stage: 'fetchBase',\n        baseId,\n        includeData,\n      });\n      throw error;\n    }\n\n    // create a stream pass through, ready to fill data\n    const passThrough = new PassThrough();\n\n    const archive = archiver('zip', {\n      zlib: { level: 9 },\n    });\n\n    archive.on('warning', function (err) {\n      if (err.code === 'ENOENT') {\n        // log warning\n      } else {\n        // throw error\n        throw err;\n      }\n    });\n\n    archive.on('error', function (err) {\n      passThrough.emit('error', err);\n      throw err;\n    });\n\n    archive.pipe(passThrough);\n\n    const token = this.generateExportFolderId();\n    const bucket = StorageAdapter.getBucket(UploadType.ExportBase);\n    const pathDir = StorageAdapter.getDir(UploadType.ExportBase);\n\n    // Critical: Start upload first to ensure passThrough has a consumer, preventing backpressure blocking\n    // If uploadFileStream is called after finalize(), large files will hang in append\n    // Note: This occupies sockets, recommend setting BACKEND_STORAGE_S3_UPLOAD_QUEUE_SIZE=1 to control upload concurrency to 1\n    const uploadPromise = this.storageAdapter.uploadFileStream(\n      bucket,\n      `${pathDir}/${token}.${BaseExportService.FILE_SUFFIX}`,\n      passThrough,\n      {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': 'application/zip',\n      }\n    );\n\n    try {\n      await this.prismaService.$tx(\n        async (prisma) => {\n          await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY');\n          await this.pipeArchive(archive, baseId, includeData);\n        },\n        {\n          isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,\n          timeout: this.thresholdConfig.bigTransactionTimeout,\n        }\n      );\n      archive.finalize();\n      const uploadResult = await uploadPromise;\n      const { path } = uploadResult;\n      const name = `${baseName}.${BaseExportService.FILE_SUFFIX}`;\n      const previewUrl = await this.storageAdapter.getPreviewUrl(\n        StorageAdapter.getBucket(UploadType.ExportBase),\n        path,\n        second(this.storageConfig.tokenExpireIn),\n        {\n          // eslint-disable-next-line\n          'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,\n        }\n      );\n      const message: ILocalization<I18nPath> = {\n        i18nKey: 'common.email.templates.notify.exportBase.success.message',\n        context: {\n          baseName,\n          previewUrl,\n          name,\n        },\n      };\n      this.notifyExportResult(baseId, message, previewUrl);\n    } catch (e) {\n      this.captureExportError(e, {\n        stage: 'processExport',\n        baseId,\n        baseName,\n        includeData,\n      });\n      if (e instanceof Error) {\n        const message: ILocalization<I18nPath> = {\n          i18nKey: 'common.email.templates.notify.exportBase.failed.message',\n          context: {\n            baseName,\n            errorMessage: e.message,\n          },\n        };\n        this.notifyExportResult(baseId, message);\n      }\n    }\n  }\n\n  async pipeArchive(archive: archiver.Archiver, baseId: string, includeData: boolean) {\n    await this.processExportBaseZip(baseId, includeData, archive);\n  }\n\n  async processExportBaseZip(baseId: string, includeData: boolean, archive: archiver.Archiver) {\n    const prisma = this.prismaService.txClient();\n    //  1. get all raw info\n    const baseRaw = await prisma.base.findUniqueOrThrow({\n      where: {\n        id: baseId,\n        deletedTime: null,\n      },\n    });\n    const tableRaws = await prisma.tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n    const tableIds = tableRaws.map(({ id }) => id);\n    const fieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: {\n          in: tableIds,\n        },\n        deletedTime: null,\n      },\n    });\n    const viewRaws = await prisma.view.findMany({\n      where: {\n        tableId: {\n          in: tableIds,\n        },\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    // 2. generate base structure json\n    const structure = await this.generateBaseStructConfig({\n      baseRaw,\n      tableRaws,\n      fieldRaws,\n      viewRaws,\n    });\n    const jsonString = JSON.stringify(structure, null, 2);\n    const jsonStream = Readable.from(jsonString);\n\n    // 3. export structure json\n    archive.append(jsonStream, { name: 'structure.json' });\n\n    // 4 export data\n    if (includeData) {\n      this.logger.log(`export base ${baseRaw.id}/${baseRaw.name}: Start exporting attachments`);\n      // 4.0 export attachments\n      await this.appendAttachments('attachments', tableRaws, archive);\n      this.logger.log(\n        `export base ${baseRaw.id}/${baseRaw.name}: End exporting attachments data csv`\n      );\n\n      // 4.1 export attachments data .csv\n      this.logger.log(\n        `export base ${baseRaw.id}/${baseRaw.name}: Start exporting attachments data csv`\n      );\n      await this.appendAttachmentsDataCsv('attachments', tableRaws, archive);\n      this.logger.log(\n        `export base ${baseRaw.id}/${baseRaw.name}: End exporting attachments data csv`\n      );\n\n      this.logger.log(`export base ${baseRaw.id}/${baseRaw.name}: Start exporting table data csv`);\n\n      // 4.2 export table data csv\n      const crossBaseRelativeFields = this.getCrossBaseFields(fieldRaws, false);\n      const crossBaseRelativeFieldIds = new Set(crossBaseRelativeFields.map(({ id }) => id));\n      const crossBaseRelativeFieldsRaws = fieldRaws.filter(({ id }) =>\n        crossBaseRelativeFieldIds.has(id)\n      );\n\n      for (const tableRaw of tableRaws) {\n        const crossBaseFieldRaws = crossBaseRelativeFieldsRaws.filter(\n          ({ tableId }) => tableId === tableRaw.id\n        );\n        const buttonDbFieldNames = fieldRaws\n          .filter(\n            ({ type, isLookup, tableId }) =>\n              type === FieldType.Button && !isLookup && tableId === tableRaw.id\n          )\n          .map((f) => f.dbFieldName);\n\n        const excludeDbFieldNames = [...EXCLUDE_SYSTEM_FIELDS, ...buttonDbFieldNames];\n        await this.appendTableDataCsv(\n          archive,\n          'tables',\n          tableRaw,\n          crossBaseFieldRaws,\n          excludeDbFieldNames\n        );\n      }\n\n      const linkFieldInstances = fieldRaws\n        .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n        .filter(({ id }) => !crossBaseRelativeFieldIds.has(id))\n        .map((f) => createFieldInstanceByRaw(f));\n\n      // 5. export junction csv for link fields\n      const junctionTableName = [] as string[];\n      for (const linkField of linkFieldInstances) {\n        const { options } = linkField;\n        const { fkHostTableName, selfKeyName, foreignKeyName } = options as ILinkFieldOptions;\n        if (fkHostTableName.includes('junction_') && !junctionTableName.includes(fkHostTableName)) {\n          await this.appendJunctionCsv(\n            'tables',\n            fkHostTableName,\n            selfKeyName,\n            foreignKeyName,\n            archive\n          );\n        }\n      }\n\n      this.logger.log(`export base ${baseRaw.id}/${baseRaw.name}: End exporting table data csv`);\n    }\n  }\n\n  async generateBaseStructConfig({\n    baseRaw,\n    tableRaws,\n    fieldRaws,\n    viewRaws,\n    // whether support cross base link fields\n    allowCrossBase = false,\n    includeNodes,\n    includedFolderIds,\n    includedDashboardIds,\n    excludedTableIds,\n    // for enterprise version, do not delete these properties\n    includedAppIds,\n    includedWorkflowIds,\n    // Root node IDs - nodes that should have their parentId set to null\n    rootNodeIds,\n  }: {\n    baseRaw: Base;\n    tableRaws: TableMeta[];\n    fieldRaws: Field[];\n    viewRaws: View[];\n    allowCrossBase?: boolean;\n    includeNodes?: string[];\n    includedFolderIds?: string[];\n    includedDashboardIds?: string[];\n    includedAppIds?: string[];\n    includedWorkflowIds?: string[];\n    excludedTableIds?: string[];\n    rootNodeIds?: string[];\n  }) {\n    const { name: baseName, icon: baseIcon, id: baseId } = baseRaw;\n    const tables = [] as IBaseJson['tables'];\n    for (const table of tableRaws) {\n      const { name, description, order, id, icon, dbTableName } = table;\n      const realDbTableName = dbTableName?.split('.')?.pop();\n      const tableObject = {\n        id,\n        name,\n        order,\n        description,\n        icon,\n        dbTableName: realDbTableName,\n      } as IBaseJson['tables'][number];\n      const currentTableFields = fieldRaws.filter(({ tableId }) => tableId === id);\n      tableObject.fields = this.generateFieldConfig(\n        currentTableFields,\n        allowCrossBase,\n        excludedTableIds\n      );\n      tableObject.views = this.generateViewConfig(viewRaws.filter(({ tableId }) => tableId === id));\n      tables.push(tableObject);\n    }\n\n    const plugins = await this.generatePluginConfig(baseId, includedDashboardIds);\n    const folders = await this.generateFolderConfig(baseId, includedFolderIds);\n    const nodes = await this.generateNodeConfig(baseId, includeNodes, rootNodeIds);\n\n    return {\n      id: baseId,\n      name: baseName,\n      icon: baseIcon,\n      version: process.env.NEXT_PUBLIC_BUILD_VERSION!,\n      tables,\n      plugins,\n      folders,\n      nodes,\n    };\n  }\n\n  private async appendAttachments(\n    filePath: string,\n    tableRaws: TableMeta[],\n    archive: archiver.Archiver\n  ) {\n    const tableIds = tableRaws.map(({ id }) => id);\n    const prisma = this.prismaService.txClient();\n    const attachmentTokenRaws = await prisma.attachmentsTable.findMany({\n      where: {\n        tableId: {\n          in: tableIds,\n        },\n      },\n      select: {\n        token: true,\n        name: true,\n      },\n    });\n    const attachments = (\n      await prisma.attachments.findMany({\n        where: {\n          token: {\n            in: attachmentTokenRaws.map(({ token }) => token),\n          },\n        },\n        select: {\n          token: true,\n          path: true,\n          mimetype: true,\n          thumbnailPath: true,\n        },\n      })\n    ).map((att) => ({\n      ...att,\n      name: attachmentTokenRaws.find(({ token }) => token === att.token)?.name,\n    }));\n    const bucket = StorageAdapter.getBucket(UploadType.Table);\n    for (const { token, path, name } of attachments) {\n      const archivePath = `${filePath}/${token}.${name?.split('.').pop()}`;\n      await this.appendFileToArchive(archive, bucket, path, archivePath);\n    }\n\n    const thumbnailAttachments = attachments.filter(({ thumbnailPath }) => thumbnailPath);\n    const prefix = `${filePath}/thumbnail__`;\n\n    for (const { thumbnailPath, name } of thumbnailAttachments) {\n      const suffix = name?.split('.').pop() || 'jpg';\n      const {\n        lg: thumbnailLgPath,\n        md: thumbnailMdPath,\n        sm: thumbnailSmPath,\n      } = JSON.parse(thumbnailPath as string);\n\n      if (thumbnailLgPath) {\n        const fileName = thumbnailLgPath.split('/').pop();\n        await this.appendFileToArchive(\n          archive,\n          bucket,\n          thumbnailLgPath,\n          `${prefix}${fileName}.${suffix}`\n        );\n      }\n\n      if (thumbnailMdPath) {\n        const fileName = thumbnailMdPath.split('/').pop();\n        await this.appendFileToArchive(\n          archive,\n          bucket,\n          thumbnailMdPath,\n          `${prefix}${fileName}.${suffix}`\n        );\n      }\n\n      if (thumbnailSmPath) {\n        const fileName = thumbnailSmPath.split('/').pop();\n        await this.appendFileToArchive(\n          archive,\n          bucket,\n          thumbnailSmPath,\n          `${prefix}${fileName}.${suffix}`\n        );\n      }\n    }\n  }\n\n  private async appendTableDataCsv(\n    archive: archiver.Archiver,\n    filePath: string,\n    tableRaw: TableMeta,\n    crossBaseRelativeFields: Field[],\n    excludeDbFieldNames: string[]\n  ) {\n    const { dbTableName, id } = tableRaw;\n    const csvStream = new PassThrough();\n    const prisma = this.prismaService.txClient();\n    const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);\n    const columnInfo = await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);\n\n    // 1. set csv header\n    const convertLinkFields = crossBaseRelativeFields.filter(({ type }) => type === FieldType.Link);\n    const fkNames = convertLinkFields\n      .filter(({ type }) => type === FieldType.Link)\n      .map(({ id }) => `__fk_${id}`);\n    const columnHeader = columnInfo\n      .map(({ name }) => name)\n      // exclude system fields\n      .filter((name) => !excludeDbFieldNames.includes(name))\n      // exclude fk fields which are cross base link fields\n      .filter((name) => !fkNames.includes(name));\n    // write the column header\n    const headerRow = columnHeader.join(',');\n    csvStream.write(`${headerRow}\\n`);\n\n    let offset = 0;\n    let hasMoreData = true;\n    archive.append(csvStream, { name: `${filePath}/${id}.csv` });\n\n    csvStream.on('error', (err) => {\n      this.logger.error(`CSV Stream error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    csvStream.on('end', () => {\n      console.log('CSV Stream ended');\n    });\n\n    csvStream.on('finish', () => {\n      console.log('CSV Stream finished');\n    });\n\n    archive.on('error', (err) => {\n      this.logger.error(`CSV Stream archive error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    // 2. write csv content\n    while (hasMoreData) {\n      const csvChunk = await this.getCsvChunk(\n        dbTableName,\n        offset,\n        crossBaseRelativeFields,\n        excludeDbFieldNames\n      );\n      if (csvChunk.length === 0) {\n        hasMoreData = false;\n        break;\n      }\n      const csvString = stringify(csvChunk, {\n        columns: columnHeader,\n      });\n      csvStream.write(csvString);\n      offset += BaseExportService.CSV_CHUNK;\n    }\n    csvStream.end();\n  }\n\n  private async appendAttachmentsDataCsv(\n    filePath: string,\n    tableRaws: TableMeta[],\n    archive: archiver.Archiver\n  ) {\n    const csvStream = new PassThrough();\n    const prisma = this.prismaService.txClient();\n\n    const tokens = await prisma.attachmentsTable.findMany({\n      where: {\n        tableId: {\n          in: tableRaws.map(({ id }) => id),\n        },\n      },\n      select: {\n        token: true,\n      },\n    });\n\n    const attachments = await prisma.attachments.findMany({\n      where: {\n        token: {\n          in: tokens.map(({ token }) => token),\n        },\n        deletedTime: null,\n      },\n    });\n\n    if (!attachments.length) {\n      return;\n    }\n\n    const columnInfo = Object.keys(attachments[0]);\n\n    // 1. set csv header\n    const columnHeader = columnInfo\n      // exclude system fields\n      .filter((name) => !EXCLUDE_SYSTEM_FIELDS.includes(name));\n\n    const headerRow = columnHeader.join(',');\n    csvStream.write(`${headerRow}\\n`);\n\n    archive.append(csvStream, { name: `${filePath}/attachments.csv` });\n\n    csvStream.on('error', (err) => {\n      this.logger.error(`CSV Stream error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    csvStream.on('end', () => {\n      console.log('CSV Stream ended');\n    });\n\n    csvStream.on('finish', () => {\n      console.log('CSV Stream finished');\n    });\n\n    archive.on('error', (err) => {\n      this.logger.error(`CSV Stream archive error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    const csvString = stringify(\n      attachments.map((att) => ({\n        ...pick(att, columnHeader),\n        size: Number(att.size),\n      })),\n      {\n        columns: columnHeader,\n      }\n    );\n    csvStream.write(csvString);\n\n    csvStream.end();\n  }\n\n  private async appendJunctionCsv(\n    filePath: string,\n    fkHostTableName: string,\n    selfKeyName: string,\n    foreignKeyName: string,\n    archive: archiver.Archiver\n  ) {\n    const csvStream = new PassThrough();\n    const prisma = this.prismaService.txClient();\n    const columnInfoQuery = this.dbProvider.columnInfo(fkHostTableName);\n    const columnInfo = await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);\n\n    // 1. set csv header\n    const columnHeader = columnInfo\n      .map(({ name }) => name)\n      // exclude id column\n      .filter((name) => name !== '__id');\n    // write the column header\n    const headerRow = columnHeader.join(',');\n    csvStream.write(`${headerRow}\\n`);\n\n    let offset = 0;\n    let hasMoreData = true;\n    archive.append(csvStream, { name: `${filePath}/${fkHostTableName}.csv` });\n\n    csvStream.on('error', (err) => {\n      this.logger.error(`CSV Stream error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    csvStream.on('end', () => {\n      console.log('CSV Stream ended');\n    });\n\n    csvStream.on('finish', () => {\n      console.log('CSV Stream finished');\n    });\n\n    archive.on('error', (err) => {\n      this.logger.error(`CSV Stream archive error: ${err.message}`, err.stack);\n      throw err;\n    });\n\n    // 2. write csv content\n    while (hasMoreData) {\n      const csvChunk = await this.getJunctionChunk(\n        fkHostTableName,\n        offset,\n        [selfKeyName, foreignKeyName],\n        ['__id']\n      );\n      if (csvChunk.length === 0) {\n        hasMoreData = false;\n        break;\n      }\n      const csvString = stringify(csvChunk, {\n        columns: columnHeader,\n      });\n      csvStream.write(csvString);\n      offset += BaseExportService.CSV_CHUNK;\n    }\n    csvStream.end();\n  }\n\n  private async getCsvChunk(\n    dbTableName: string,\n    offset: number,\n    crossBaseRelativeFields: Field[],\n    excludeFieldNames: string[]\n  ) {\n    const rawRecords = await this.getChunkRecords(dbTableName, offset);\n    // 1. clear unless fields\n    const records = rawRecords.map((record) => omit(record, excludeFieldNames));\n    // 2. convert to csv value\n    return records.map((record) =>\n      this.transformConvertFieldsCellValue(record, crossBaseRelativeFields)\n    );\n  }\n\n  private async getJunctionChunk(\n    fkHostTableName: string,\n    offset: number,\n    convertFields: [string, string],\n    excludeFieldNames: string[]\n  ) {\n    const prisma = this.prismaService.txClient();\n    const recordsQuery = await this.knex(fkHostTableName)\n      .select('*')\n      .limit(BaseExportService.CSV_CHUNK)\n      .offset(offset)\n      .toQuery();\n    const rawRecords = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(recordsQuery);\n    // 1. clear unless fields\n    const records = rawRecords.map((record) => omit(record, excludeFieldNames));\n\n    return records.map((record) => {\n      if (!record) {\n        return record;\n      }\n\n      const newRecord = {} as Record<string, unknown>;\n\n      Object.entries(record).forEach(([key, value]) => {\n        newRecord[key] = value;\n      });\n\n      return newRecord;\n    });\n  }\n\n  private async getChunkRecords(dbTableName: string, offset: number) {\n    const prisma = this.prismaService.txClient();\n    const recordsQuery = await this.knex(dbTableName)\n      .select('*')\n      .limit(BaseExportService.CSV_CHUNK)\n      .offset(offset)\n      .orderBy('__auto_number', 'asc')\n      .toQuery();\n    return await prisma.$queryRawUnsafe<Record<string, unknown>[]>(recordsQuery);\n  }\n\n  /**\n   * @description convert the cell value to the csv value\n   * @param value - the cell value\n   * @param dbFieldName - the db field name\n   * @param convertFields - the fields which cross base link fields and relative fields (formula or lookup) need to be convert to single line text\n   * @returns the csv value\n   */\n  private transformConvertFieldsCellValue(\n    value: Record<string, unknown>,\n    crossBaseRelativeFields: Field[]\n  ) {\n    if (!value) {\n      return value;\n    }\n\n    const newRecord = {} as Record<string, unknown>;\n\n    const crossBaseRelativeDbFieldNames = crossBaseRelativeFields.map(\n      ({ dbFieldName }) => dbFieldName\n    );\n\n    Object.entries(value).forEach(([key, value]) => {\n      let newValue = value;\n      const fieldRaw = crossBaseRelativeFields.find(({ dbFieldName }) => dbFieldName === key);\n      if (crossBaseRelativeDbFieldNames.includes(key) && value && fieldRaw) {\n        const fieldIns = createFieldInstanceByRaw(fieldRaw);\n        newValue = fieldIns.cellValue2String(newValue);\n      }\n\n      // convert date to iso string\n      if (value instanceof Date) {\n        newValue = value.toISOString();\n      }\n\n      newRecord[key] = newValue;\n    });\n\n    return newRecord;\n  }\n\n  // cross base link field and relative fields should convert to text as well\n  private generateFieldConfig(\n    fieldRaws: Field[],\n    allowCrossBase = false,\n    excludedTableIds?: string[]\n  ) {\n    const fields = fieldRaws.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n    const createdTimeMap = fieldRaws.reduce(\n      (acc, field) => {\n        acc[field.id] = field.createdTime.toISOString();\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n\n    const crossBaseRelativeFields = this.getCrossBaseFields(fieldRaws, allowCrossBase);\n\n    const disconnectedFields = this.getDisconnectedFields(\n      fieldRaws,\n      crossBaseRelativeFields.map(({ id }) => id),\n      excludedTableIds\n    );\n\n    const otherFields = fields\n      .filter(\n        ({ id }) =>\n          !crossBaseRelativeFields.map(({ id }) => id).includes(id) &&\n          !disconnectedFields.map(({ id }) => id).includes(id)\n      )\n      .map((field, index) => ({\n        ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n        createdTime: createdTimeMap[field.id],\n        order: fieldRaws[index].order,\n      }));\n\n    return [\n      ...otherFields,\n      ...crossBaseRelativeFields,\n      ...disconnectedFields,\n    ] as IBaseJson['tables'][number]['fields'];\n  }\n\n  private getDisconnectedFields(\n    fieldRaws: Field[],\n    crossBaseRelativeFields: string[],\n    excludedTableIds?: string[]\n  ) {\n    const restFields = fieldRaws.filter(({ id }) => !crossBaseRelativeFields?.includes(id));\n    if (!excludedTableIds?.length) {\n      return [];\n    }\n\n    const fields = restFields.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n    const createdTimeMap = restFields.reduce(\n      (acc, field) => {\n        acc[field.id] = field.createdTime.toISOString();\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n\n    const disconnectedLinkFields = fields\n      .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n      .filter(({ options }) =>\n        excludedTableIds.includes((options as ILinkFieldOptions)?.foreignTableId)\n      )\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n        };\n\n        return omit(res, [\n          'options',\n          'lookupOptions',\n          'isLookup',\n          'isConditionalLookup',\n          'isMultipleCellValue',\n        ]);\n      });\n\n    // fields which rely on the disconnected link fields (link-based lookup/rollup)\n    const disconnectedRelativeFields = fields\n      .filter(\n        ({ type, isLookup }) =>\n          isLookup || type === FieldType.Rollup || type === FieldType.ConditionalRollup\n      )\n      .filter(({ lookupOptions }) => {\n        if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n          return false;\n        }\n        return disconnectedLinkFields.map(({ id }) => id).includes(lookupOptions.linkFieldId);\n      })\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n          dbFieldType: 'TEXT',\n          cellValueType: 'string',\n        };\n\n        return omit(res, [\n          'options',\n          'lookupOptions',\n          'isLookup',\n          'isConditionalLookup',\n          'isMultipleCellValue',\n        ]);\n      });\n\n    const alreadyHandledIds = new Set([\n      ...disconnectedLinkFields.map(({ id }) => id),\n      ...disconnectedRelativeFields.map(({ id }) => id),\n    ]);\n\n    // Conditional fields (ConditionalLookup/ConditionalRollup) that directly reference excluded tables\n    // These don't go through a link field, so they aren't caught by the link-based check above\n    const disconnectedConditionalFields = fields\n      .filter(({ id }) => !alreadyHandledIds.has(id))\n      .filter(\n        ({ type, isLookup, isConditionalLookup }) =>\n          (isLookup && isConditionalLookup) || type === FieldType.ConditionalRollup\n      )\n      .filter((field) => {\n        const { type, isLookup, isConditionalLookup, lookupOptions, options } = field;\n\n        if (isLookup && isConditionalLookup) {\n          const conditionalOptions = lookupOptions as IConditionalLookupOptions | undefined;\n          return (\n            conditionalOptions?.foreignTableId &&\n            excludedTableIds.includes(conditionalOptions.foreignTableId)\n          );\n        }\n\n        if (type === FieldType.ConditionalRollup) {\n          const conditionalOptions = options as IConditionalRollupFieldOptions | undefined;\n          return (\n            conditionalOptions?.foreignTableId &&\n            excludedTableIds.includes(conditionalOptions.foreignTableId)\n          );\n        }\n\n        return false;\n      })\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n          dbFieldType: 'TEXT',\n          cellValueType: 'string',\n        };\n\n        return omit(res, [\n          'options',\n          'lookupOptions',\n          'isLookup',\n          'isConditionalLookup',\n          'isMultipleCellValue',\n        ]);\n      });\n\n    return [\n      ...disconnectedLinkFields,\n      ...disconnectedRelativeFields,\n      ...disconnectedConditionalFields,\n    ] as IBaseJson['tables'][number]['fields'];\n  }\n\n  private getCrossBaseFields(fieldRaws: Field[], allowCrossBase = false) {\n    const fields = fieldRaws.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n    const createdTimeMap = fieldRaws.reduce(\n      (acc, field) => {\n        acc[field.id] = field.createdTime.toISOString();\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n    const crossBaseLinkFields = fields\n      .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n      .filter(({ options }) => Boolean((options as ILinkFieldOptions)?.baseId))\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: allowCrossBase ? field.type : FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n        };\n\n        return allowCrossBase\n          ? res\n          : omit(res, [\n              'options',\n              'lookupOptions',\n              'isLookup',\n              'isConditionalLookup',\n              'isMultipleCellValue',\n            ]);\n      });\n\n    // fields which rely on the cross base link fields (link-based lookup/rollup)\n    const relativeFields = fields\n      .filter(\n        ({ type, isLookup }) =>\n          isLookup || type === FieldType.Rollup || type === FieldType.ConditionalRollup\n      )\n      .filter((field) => {\n        const { lookupOptions, type, options } = field;\n\n        // Case 1: lookup field that is itself a cross-base link (type === 'link' && isLookup && options.baseId)\n        // This happens when you lookup a cross-base link field through a local link field\n        if (type === FieldType.Link && (options as ILinkFieldOptions)?.baseId) {\n          return true;\n        }\n\n        // Case 2: lookup/rollup field that depends on a cross-base link field\n        if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n          return false;\n        }\n        return crossBaseLinkFields.map(({ id }) => id).includes(lookupOptions.linkFieldId);\n      })\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: allowCrossBase ? field.type : FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n          dbFieldType: allowCrossBase ? field.dbFieldType : 'TEXT',\n          cellValueType: allowCrossBase ? field.cellValueType : 'string',\n        };\n\n        return allowCrossBase\n          ? res\n          : omit(res, [\n              'options',\n              'lookupOptions',\n              'isLookup',\n              'isConditionalLookup',\n              'isMultipleCellValue',\n            ]);\n      });\n\n    const alreadyHandledIds = new Set([\n      ...crossBaseLinkFields.map(({ id }) => id),\n      ...relativeFields.map(({ id }) => id),\n    ]);\n\n    // Conditional fields (ConditionalLookup/ConditionalRollup) that are cross-base\n    // These don't use a link field as intermediary, so they have their own baseId\n    const conditionalCrossBaseFields = fields\n      .filter(({ id }) => !alreadyHandledIds.has(id))\n      .filter(\n        ({ type, isLookup, isConditionalLookup }) =>\n          (isLookup && isConditionalLookup) || type === FieldType.ConditionalRollup\n      )\n      .filter((field) => {\n        const { type, isLookup, isConditionalLookup, lookupOptions, options } = field;\n\n        if (isLookup && isConditionalLookup) {\n          const conditionalOptions = lookupOptions as IConditionalLookupOptions | undefined;\n          return Boolean(conditionalOptions?.baseId);\n        }\n\n        if (type === FieldType.ConditionalRollup) {\n          const conditionalOptions = options as IConditionalRollupFieldOptions | undefined;\n          return Boolean(conditionalOptions?.baseId);\n        }\n\n        return false;\n      })\n      .map((field, index) => {\n        const res = {\n          ...pick(field, BaseExportService.EXPORT_FIELD_COLUMNS),\n          type: allowCrossBase ? field.type : FieldType.SingleLineText,\n          createdTime: createdTimeMap[field.id],\n          order: fieldRaws[index].order,\n          dbFieldType: allowCrossBase ? field.dbFieldType : 'TEXT',\n          cellValueType: allowCrossBase ? field.cellValueType : 'string',\n        };\n\n        return allowCrossBase\n          ? res\n          : omit(res, [\n              'options',\n              'lookupOptions',\n              'isLookup',\n              'isConditionalLookup',\n              'isMultipleCellValue',\n            ]);\n      });\n\n    return [\n      ...crossBaseLinkFields,\n      ...relativeFields,\n      ...conditionalCrossBaseFields,\n    ] as IBaseJson['tables'][number]['fields'];\n  }\n\n  private generateViewConfig(viewRaws: View[]): IBaseJson['tables'][number]['views'] {\n    return (\n      viewRaws\n        // .filter(({ type }) => type !== ViewType.Plugin)\n        .map((viewRaw) => createViewVoByRaw(viewRaw))\n        .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))\n        .map((view, index) => ({\n          ...pick(view, [\n            'id',\n            'name',\n            'description',\n            'type',\n            'sort',\n            'filter',\n            'group',\n            'options',\n            'columnMeta',\n            'enableShare',\n            'shareMeta',\n            'shareId',\n            'isLocked',\n          ]),\n          order: index,\n        })) as IBaseJson['tables'][number]['views']\n    );\n  }\n\n  async generateFolderConfig(\n    baseId: string,\n    includedFolderIds?: string[]\n  ): Promise<IBaseJson['folders']> {\n    // If includedFolderIds is an empty array, return empty array (user filtered but no folders selected)\n    if (includedFolderIds !== undefined && includedFolderIds.length === 0) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n    const folderRaws = await prisma.baseNodeFolder.findMany({\n      where: {\n        baseId,\n        ...(includedFolderIds && includedFolderIds.length > 0\n          ? { id: { in: includedFolderIds } }\n          : {}),\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n\n    return folderRaws.map((folderRaw) => ({\n      id: folderRaw.id,\n      name: folderRaw.name,\n    }));\n  }\n\n  /**\n   * Generate node configuration for base export/duplicate\n   *\n   * @param baseId - The base ID to get nodes from\n   * @param includeNodes - Optional array of node IDs to include\n   * @param rootNodeIds - Optional array of node IDs that should become root nodes (parentId = null)\n   */\n  async generateNodeConfig(\n    baseId: string,\n    includeNodes?: string[],\n    rootNodeIds?: string[]\n  ): Promise<IBaseJson['nodes']> {\n    // If includeNodes is an empty array, return empty array (user filtered but no nodes selected)\n    if (includeNodes !== undefined && includeNodes.length === 0) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n    const nodeRaws = await prisma.baseNode.findMany({\n      where: {\n        baseId,\n        ...(includeNodes && includeNodes.length > 0 ? { id: { in: includeNodes } } : {}),\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n      select: {\n        id: true,\n        parentId: true,\n        resourceId: true,\n        resourceType: true,\n        order: true,\n      },\n    });\n\n    const rootNodeIdSet = rootNodeIds ? new Set(rootNodeIds) : null;\n\n    return nodeRaws.map((nodeRaw) => {\n      // Set parentId to null if:\n      // 1. This node is in rootNodeIds, or\n      // 2. The parent node is not in includeNodes\n      const parentId =\n        rootNodeIdSet?.has(nodeRaw.id) ||\n        (includeNodes && nodeRaw.parentId && !includeNodes.includes(nodeRaw.parentId))\n          ? null\n          : nodeRaw.parentId;\n\n      return {\n        id: nodeRaw.id,\n        parentId,\n        resourceId: nodeRaw.resourceId,\n        resourceType: nodeRaw.resourceType as BaseNodeResourceType,\n        order: nodeRaw.order,\n      };\n    });\n  }\n\n  async generatePluginConfig(baseId: string, includedDashboardIds?: string[]) {\n    const pluginJson = {} as IBaseJson['plugins'];\n\n    pluginJson[PluginPosition.Dashboard] = await this.generateDashboard(\n      baseId,\n      includedDashboardIds\n    );\n\n    pluginJson[PluginPosition.Panel] = await this.generatePluginPanel(baseId);\n\n    pluginJson[PluginPosition.View] = await this.generatePluginView(baseId);\n\n    return pluginJson;\n  }\n\n  private async generatePluginView(baseId: string) {\n    const tableIds = await this.prismaService.txClient().tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n    });\n\n    const prisma = this.prismaService.txClient();\n\n    const viewPluginRaws = await prisma.view.findMany({\n      where: {\n        tableId: {\n          in: tableIds.map(({ id }) => id),\n        },\n        type: ViewType.Plugin,\n        deletedTime: null,\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n    });\n\n    const viewPluginInstallRaws = await prisma.pluginInstall.findMany({\n      where: {\n        positionId: {\n          in: viewPluginRaws.map(({ id }) => id),\n        },\n      },\n    });\n\n    return viewPluginRaws.map((viewRaw) => {\n      const pluginInstall = viewPluginInstallRaws.find(\n        ({ positionId }) => positionId === viewRaw.id\n      )!;\n\n      return {\n        ...pick(viewRaw, ['id', 'name', 'description', 'type', 'isLocked', 'tableId', 'order']),\n        columnMeta: viewRaw.columnMeta ? JSON.parse(viewRaw.columnMeta) : null,\n        options: viewRaw.options ? JSON.parse(viewRaw.options) : null,\n        filter: viewRaw.filter ? JSON.parse(viewRaw.filter) : null,\n        group: viewRaw.group ? JSON.parse(viewRaw.group) : null,\n        shareMeta: viewRaw.shareMeta ? JSON.parse(viewRaw.shareMeta) : null,\n        pluginInstall: {\n          ...pick(pluginInstall, ['id', 'pluginId', 'baseId', 'name', 'positionId', 'position']),\n          storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : null,\n        },\n      };\n    }) as unknown as IBaseJson['plugins'][PluginPosition.View];\n  }\n\n  private async generatePluginPanel(baseId: string) {\n    const prisma = this.prismaService.txClient();\n    const tableIds = await prisma.tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const pluginPanelRaws = await prisma.pluginPanel.findMany({\n      where: {\n        tableId: {\n          in: tableIds.map(({ id }) => id),\n        },\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n      select: {\n        id: true,\n        name: true,\n        layout: true,\n        tableId: true,\n      },\n    });\n\n    const panelInstallPluginRaws = await prisma.pluginInstall.findMany({\n      where: {\n        positionId: {\n          in: pluginPanelRaws.map(({ id }) => id),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        positionId: true,\n        position: true,\n        storage: true,\n      },\n    });\n\n    return pluginPanelRaws.map(({ id, name, layout, tableId }) => {\n      const panelConfig = {\n        id,\n        name,\n        layout: layout ? JSON.parse(layout) : null,\n        tableId,\n      } as unknown as IBaseJson['plugins'][PluginPosition.Panel][number];\n\n      panelConfig.pluginInstall = panelInstallPluginRaws\n        .filter(({ positionId }) => positionId === id)\n        .map(({ id, pluginId, positionId, position, name, storage }) => ({\n          id,\n          pluginId,\n          positionId,\n          position,\n          name,\n          storage: storage ? JSON.parse(storage) : null,\n        })) as unknown as IBaseJson['plugins'][PluginPosition.Panel][number]['pluginInstall'];\n\n      return panelConfig;\n    });\n  }\n\n  private async generateDashboard(baseId: string, includedDashboardIds?: string[]) {\n    // If includedDashboardIds is an empty array, return empty array (user filtered but no dashboards selected)\n    if (includedDashboardIds !== undefined && includedDashboardIds.length === 0) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n    const dashboardRaws = await prisma.dashboard.findMany({\n      where: {\n        baseId,\n        ...(includedDashboardIds && includedDashboardIds.length > 0\n          ? { id: { in: includedDashboardIds } }\n          : {}),\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n      select: {\n        id: true,\n        name: true,\n        layout: true,\n      },\n    });\n\n    const dashboardInstallPluginRaws = await prisma.pluginInstall.findMany({\n      where: {\n        positionId: {\n          in: dashboardRaws.map(({ id }) => id),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        positionId: true,\n        position: true,\n        storage: true,\n      },\n    });\n\n    return dashboardRaws.map(({ id, name, layout }) => {\n      const dashboardConfig = {\n        id,\n        name,\n        layout: layout ? JSON.parse(layout) : null,\n      } as unknown as IBaseJson['plugins'][PluginPosition.Dashboard][number];\n\n      dashboardConfig.pluginInstall = dashboardInstallPluginRaws\n        .filter(({ positionId }) => positionId === id)\n        .map(({ id, pluginId, positionId, position, name, storage }) => ({\n          id,\n          pluginId,\n          positionId,\n          position,\n          name,\n          storage: storage ? JSON.parse(storage) : null,\n        })) as unknown as IBaseJson['plugins'][PluginPosition.Dashboard][number]['pluginInstall'];\n\n      return dashboardConfig;\n    });\n  }\n\n  private async notifyExportResult(\n    baseId: string,\n    message: string | ILocalization<I18nPath>,\n    previewUrl?: string\n  ) {\n    const userId = this.cls.get('user.id');\n    await this.eventEmitterService.emit(Events.BASE_EXPORT_COMPLETE, {\n      previewUrl,\n    });\n    await this.notificationService.sendExportBaseResultNotify({\n      baseId: baseId,\n      toUserId: userId,\n      message: message,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-attachments-csv.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport {\n  BaseImportAttachmentsCsvQueueProcessor,\n  BASE_IMPORT_ATTACHMENTS_CSV_QUEUE,\n} from './base-import-attachments-csv.processor';\n\n@Module({\n  providers: [BaseImportAttachmentsCsvQueueProcessor],\n  imports: [EventJobModule.registerQueue(BASE_IMPORT_ATTACHMENTS_CSV_QUEUE), StorageModule],\n  exports: [BaseImportAttachmentsCsvQueueProcessor],\n})\nexport class BaseImportAttachmentsCsvModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-attachments-csv.processor.ts",
    "content": "import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { Attachments } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport type { Job } from 'bullmq';\nimport { Queue } from 'bullmq';\nimport * as csvParser from 'csv-parser';\nimport * as unzipper from 'unzipper';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { BatchProcessor } from '../BatchProcessor.class';\n\ninterface IBaseImportAttachmentsCsvJob {\n  path: string;\n  userId: string;\n}\n\nexport const BASE_IMPORT_ATTACHMENTS_CSV_QUEUE = 'base-import-attachments-csv-queue';\n\n@Injectable()\n@Processor(BASE_IMPORT_ATTACHMENTS_CSV_QUEUE)\nexport class BaseImportAttachmentsCsvQueueProcessor extends WorkerHost {\n  private logger = new Logger(BaseImportAttachmentsCsvQueueProcessor.name);\n\n  private processedJobs = new Set<string>();\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(BASE_IMPORT_ATTACHMENTS_CSV_QUEUE)\n    public readonly queue: Queue<IBaseImportAttachmentsCsvJob>\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IBaseImportAttachmentsCsvJob>) {\n    const jobId = String(job.id);\n    if (this.processedJobs.has(jobId)) {\n      this.logger.log(`Job ${jobId} already processed, skipping`);\n      return;\n    }\n\n    this.processedJobs.add(jobId);\n\n    try {\n      await this.handleBaseImportAttachmentsCsv(job);\n    } catch (error) {\n      this.logger.error(\n        `Process base import attachment csv failed: ${(error as Error)?.message}`,\n        (error as Error)?.stack\n      );\n    }\n  }\n\n  private async handleBaseImportAttachmentsCsv(job: Job<IBaseImportAttachmentsCsvJob>) {\n    const { path, userId } = job.data;\n    const csvStream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n\n    const parser = unzipper.Parse();\n    csvStream.pipe(parser);\n\n    return new Promise<{ success: boolean }>((resolve, reject) => {\n      parser.on('entry', (entry) => {\n        const filePath = entry.path;\n\n        const fileSuffix = filePath.split('.').pop();\n\n        if (\n          filePath.startsWith('attachments/') &&\n          entry.type !== 'Directory' &&\n          fileSuffix === 'csv'\n        ) {\n          const batchProcessor = new BatchProcessor<Attachments>((chunk) =>\n            this.handleChunk(chunk, userId)\n          );\n\n          entry\n            .pipe(\n              csvParser.default({\n                // strict: true,\n                mapValues: ({ value }) => {\n                  return value;\n                },\n                mapHeaders: ({ header }) => {\n                  return header;\n                },\n              })\n            )\n            .pipe(batchProcessor)\n            .on('error', (error: Error) => {\n              this.logger.error(\n                `process csv attachments import error: ${error.message}`,\n                error.stack\n              );\n              reject(error);\n            })\n            .on('end', () => {\n              this.logger.log(`attachments csv finished`);\n              resolve({ success: true });\n            });\n        } else {\n          entry.autodrain();\n        }\n      });\n\n      parser.on('close', () => {\n        this.logger.log('import csv completed');\n        resolve({ success: true });\n      });\n\n      parser.on('error', (error) => {\n        this.logger.error(`ZIP parser error: ${error.message}`, error.stack);\n        reject(error);\n      });\n    });\n  }\n\n  private async handleChunk(results: Attachments[], userId: string) {\n    for (const result of results) {\n      const att = await this.prismaService.attachments.findUnique({\n        where: {\n          id: result.id,\n        },\n      });\n\n      if (att) {\n        continue;\n      }\n\n      await this.prismaService.attachments.create({\n        data: {\n          id: result.id,\n          token: result.token,\n          hash: result.hash,\n          size: Number(result.size),\n          mimetype: result.mimetype,\n          path: result.path,\n          width: result.width ? Number(result.width) : null,\n          height: result.height ? Number(result.height) : null,\n          thumbnailPath: result.thumbnailPath,\n          createdBy: userId,\n        },\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-attachments.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { BaseImportAttachmentsCsvModule } from './base-import-attachments-csv.module';\nimport {\n  BaseImportAttachmentsCsvQueueProcessor,\n  BASE_IMPORT_ATTACHMENTS_CSV_QUEUE,\n} from './base-import-attachments-csv.processor';\nimport {\n  BASE_IMPORT_ATTACHMENTS_QUEUE,\n  BaseImportAttachmentsQueueProcessor,\n} from './base-import-attachments.processor';\n@Module({\n  providers: [BaseImportAttachmentsQueueProcessor, BaseImportAttachmentsCsvQueueProcessor],\n  imports: [\n    EventJobModule.registerQueue(BASE_IMPORT_ATTACHMENTS_QUEUE),\n    EventJobModule.registerQueue(BASE_IMPORT_ATTACHMENTS_CSV_QUEUE),\n    StorageModule,\n    BaseImportAttachmentsCsvModule,\n  ],\n  exports: [BaseImportAttachmentsQueueProcessor, BaseImportAttachmentsCsvQueueProcessor],\n})\nexport class BaseImportAttachmentsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-attachments.processor.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { PassThrough } from 'stream';\nimport { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport { Queue, Job } from 'bullmq';\nimport * as unzipper from 'unzipper';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport {\n  BASE_IMPORT_ATTACHMENTS_CSV_QUEUE,\n  BaseImportAttachmentsCsvQueueProcessor,\n} from './base-import-attachments-csv.processor';\n\ninterface IBaseImportJob {\n  path: string;\n  userId: string;\n}\n\nexport const BASE_IMPORT_ATTACHMENTS_QUEUE = 'base-import-attachments-queue';\n\n@Injectable()\n@Processor(BASE_IMPORT_ATTACHMENTS_QUEUE)\nexport class BaseImportAttachmentsQueueProcessor extends WorkerHost {\n  private logger = new Logger(BaseImportAttachmentsQueueProcessor.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly baseImportAttachmentsCsvQueueProcessor: BaseImportAttachmentsCsvQueueProcessor,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(BASE_IMPORT_ATTACHMENTS_QUEUE) public readonly queue: Queue<IBaseImportJob>\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IBaseImportJob>) {\n    try {\n      await this.handleBaseImportAttachments(job);\n    } catch (error) {\n      this.logger.error(\n        `[base import attachment] Process base import attachments failed: ${(error as Error)?.message}`,\n        (error as Error)?.stack\n      );\n    }\n  }\n\n  getFileMimeType = (extension: string): string => {\n    const ext = extension.toLowerCase().replace(/^\\./, '');\n\n    const extensionToMimeType: Record<string, string> = {\n      jpg: 'image/jpeg',\n      jpeg: 'image/jpeg',\n      png: 'image/png',\n      gif: 'image/gif',\n      bmp: 'image/bmp',\n      webp: 'image/webp',\n      svg: 'image/svg+xml',\n\n      mp3: 'audio/mpeg',\n      wav: 'audio/wav',\n      ogg: 'audio/ogg',\n      flac: 'audio/x-flac',\n\n      mp4: 'video/mp4',\n      avi: 'video/x-msvideo',\n      mkv: 'video/x-matroska',\n      ogv: 'video/ogg',\n      webm: 'video/webm',\n\n      pdf: 'application/pdf',\n      doc: 'application/msword',\n      docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n      xls: 'application/vnd.ms-excel',\n      xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n      ppt: 'application/vnd.ms-powerpoint',\n      pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n      txt: 'text/plain',\n      csv: 'text/csv',\n\n      zip: 'application/zip',\n      rar: 'application/x-rar-compressed',\n\n      json: 'application/json',\n      xml: 'application/xml',\n      html: 'text/html',\n      htm: 'text/html',\n      css: 'text/css',\n      js: 'text/javascript',\n\n      md: 'text/markdown',\n    };\n\n    return extensionToMimeType[ext] || 'application/octet-stream';\n  };\n\n  private async handleBaseImportAttachments(job: Job<IBaseImportJob>) {\n    const { path } = job.data;\n    const zipStream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n    const parser = unzipper.Parse({ forceStream: true });\n    zipStream.pipe(parser);\n    const bucket = StorageAdapter.getBucket(UploadType.Table);\n\n    try {\n      for await (const entry of parser.pipe(new PassThrough({ objectMode: true }))) {\n        await this.processAttachmentEntry(entry, bucket);\n      }\n\n      this.logger.log(`[base import attachment] all finished`);\n    } finally {\n      zipStream.destroy();\n    }\n  }\n\n  private async processAttachmentEntry(entry: unzipper.Entry, bucket: string) {\n    const filePath = entry.path;\n    const fileSuffix = filePath.split('.').pop() ?? '';\n\n    if (\n      !filePath.startsWith('attachments/') ||\n      entry.type === 'Directory' ||\n      fileSuffix === 'csv'\n    ) {\n      entry.autodrain();\n      return;\n    }\n\n    let passThrough: PassThrough | undefined;\n    try {\n      const token = filePath.replace('attachments/', '').split('.')[0];\n      const isThumbnail = token.includes('thumbnail__');\n      const mimeType = this.getFileMimeType(fileSuffix);\n      const pathDir = StorageAdapter.getDir(UploadType.Table);\n      const finalPath = isThumbnail\n        ? `table/${token.split('__')[1].split('.')[0]}`\n        : `${pathDir}/${token}`;\n      const finalToken = isThumbnail ? token.split('__')[1].split('.')[0] : token;\n\n      this.logger.log(`[base import attachment] start upload: ${token}`);\n\n      const existing = await this.prismaService.txClient().attachments.findUnique({\n        where: { token: finalToken },\n        select: { id: true },\n      });\n\n      if (existing) {\n        this.logger.log(`[base import attachment]  already exists: ${token}`);\n        entry.autodrain();\n        return;\n      }\n\n      passThrough = new PassThrough();\n      entry.pipe(passThrough);\n\n      await this.storageAdapter.uploadFileStream(bucket, finalPath, passThrough, {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': mimeType,\n      });\n\n      this.logger.log(`[base import attachment] ${token} finished: ${token}`);\n    } catch (err) {\n      this.logger.error(`[base import attachment] upload  error: ${(err as Error).message}`);\n      if (passThrough) {\n        passThrough.resume();\n      } else {\n        entry.autodrain();\n      }\n    }\n  }\n\n  @OnWorkerEvent('completed')\n  async onCompleted(job: Job) {\n    const { path, userId } = job.data;\n    this.baseImportAttachmentsCsvQueueProcessor.queue.add(\n      BASE_IMPORT_ATTACHMENTS_CSV_QUEUE,\n      {\n        path,\n        userId,\n      },\n      {\n        jobId: `import_attachments_csv_${path}_${userId}`,\n      }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventEmitterModule } from '@nestjs/event-emitter';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { BASE_IMPORT_ATTACHMENTS_CSV_QUEUE } from './base-import-attachments-csv.processor';\nimport { BASE_IMPORT_CSV_QUEUE, BaseImportCsvQueueProcessor } from './base-import-csv.processor';\nimport { BaseImportJunctionCsvModule } from './base-import-junction-csv.module';\n\n@Module({\n  providers: [BaseImportCsvQueueProcessor],\n  imports: [\n    EventJobModule.registerQueue(BASE_IMPORT_CSV_QUEUE),\n    EventJobModule.registerQueue(BASE_IMPORT_ATTACHMENTS_CSV_QUEUE),\n    StorageModule,\n    BaseImportJunctionCsvModule,\n    EventEmitterModule,\n  ],\n  exports: [BaseImportCsvQueueProcessor],\n})\nexport class BaseImportCsvModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { IAttachmentCellValue } from '@teable/core';\nimport { DbFieldType, FieldType, generateAttachmentId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IBaseJson, ImportBaseRo } from '@teable/openapi';\nimport { CreateRecordAction, UploadType } from '@teable/openapi';\nimport { Queue, Job } from 'bullmq';\nimport * as csvParser from 'csv-parser';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport * as unzipper from 'unzipper';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { BatchProcessor } from '../BatchProcessor.class';\nimport { EXCLUDE_SYSTEM_FIELDS } from '../constant';\nimport { BaseImportJunctionCsvQueueProcessor } from './base-import-junction.processor';\ninterface IBaseImportCsvJob {\n  path: string;\n  userId: string;\n  baseId: string;\n  origin?: {\n    ip: string;\n    byApi: boolean;\n    userAgent: string;\n    referer: string;\n  };\n  tableIdMap: Record<string, string>;\n  fieldIdMap: Record<string, string>;\n  viewIdMap: Record<string, string>;\n  fkMap: Record<string, string>;\n  structure: IBaseJson;\n  importBaseRo: ImportBaseRo;\n  logId: string;\n}\n\nexport const BASE_IMPORT_CSV_QUEUE = 'base-import-csv-queue';\n\n@Injectable()\n@Processor(BASE_IMPORT_CSV_QUEUE)\nexport class BaseImportCsvQueueProcessor extends WorkerHost {\n  private logger = new Logger(BaseImportCsvQueueProcessor.name);\n\n  private processedJobs = new Set<string>();\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly baseImportJunctionCsvQueueProcessor: BaseImportJunctionCsvQueueProcessor,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(BASE_IMPORT_CSV_QUEUE) public readonly queue: Queue<IBaseImportCsvJob>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IBaseImportCsvJob>) {\n    const jobId = String(job.id);\n    if (this.processedJobs.has(jobId)) {\n      this.logger.log(`Job ${jobId} already processed, skipping`);\n      return;\n    }\n\n    this.processedJobs.add(jobId);\n\n    try {\n      await this.handleBaseImportCsv(job);\n      this.logger.log('import csv parser job completed');\n    } catch (error) {\n      this.logger.error(\n        `Process base import csv failed: ${(error as Error)?.message}`,\n        (error as Error)?.stack\n      );\n    }\n  }\n\n  private async handleBaseImportCsv(job: Job<IBaseImportCsvJob>): Promise<void> {\n    const { path, userId, tableIdMap, fieldIdMap, viewIdMap, structure, fkMap } = job.data;\n    const csvStream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n\n    const parser = unzipper.Parse();\n    csvStream.pipe(parser);\n    let totalRecordsCount = 0;\n\n    return new Promise<void>((resolve, reject) => {\n      parser.on('entry', (entry) => {\n        const filePath = entry.path;\n        const isTable = filePath.startsWith('tables/') && entry.type !== 'Directory';\n        const isJunction = filePath.includes('junction_');\n\n        if (isTable && !isJunction) {\n          const tableId = filePath.replace('tables/', '').split('.')[0];\n          const table = structure.tables.find((table) => table.id === tableId);\n          const attachmentsFields =\n            table?.fields\n              ?.filter(({ type }) => type === FieldType.Attachment)\n              .map(({ dbFieldName, id }) => ({\n                dbFieldName,\n                id,\n              })) || [];\n\n          const buttonFields =\n            table?.fields\n              ?.filter(({ type }) => type === FieldType.Button)\n              .map(({ dbFieldName, id }) => ({\n                dbFieldName,\n                id,\n              })) || [];\n\n          const computedFields =\n            table?.fields\n              ?.filter(({ type }) =>\n                [\n                  FieldType.Formula,\n                  FieldType.Rollup,\n                  // FieldType.ConditionalRollup,\n                  FieldType.CreatedTime,\n                  FieldType.LastModifiedTime,\n                  FieldType.CreatedBy,\n                  FieldType.LastModifiedBy,\n                  FieldType.AutoNumber,\n                ].includes(type)\n              )\n              .map(({ dbFieldName, id }) => ({\n                dbFieldName,\n                id,\n              })) || [];\n\n          const buttonDbFieldNames = buttonFields.map(({ dbFieldName }) => dbFieldName);\n          const computedDbFieldNames = computedFields.map(({ dbFieldName }) => dbFieldName);\n          const excludeDbFieldNames = [\n            ...EXCLUDE_SYSTEM_FIELDS,\n            ...buttonDbFieldNames,\n            ...computedDbFieldNames,\n          ];\n\n          const notNullFieldMap = new Map<\n            string,\n            { dbFieldType: string; isMultipleCellValue: boolean }\n          >();\n          table?.fields?.forEach(({ dbFieldName, notNull, dbFieldType, isMultipleCellValue }) => {\n            if (notNull) {\n              notNullFieldMap.set(dbFieldName, {\n                dbFieldType,\n                isMultipleCellValue: Boolean(isMultipleCellValue),\n              });\n            }\n          });\n\n          const batchProcessor = new BatchProcessor<Record<string, unknown>>(async (chunk) => {\n            totalRecordsCount += chunk.length;\n            await this.handleChunk(\n              chunk,\n              {\n                tableId: tableIdMap[tableId],\n                userId,\n                fieldIdMap,\n                viewIdMap,\n                fkMap,\n                attachmentsFields,\n                notNullFieldMap,\n              },\n              excludeDbFieldNames\n            );\n            // Update audit log after each chunk is written to database\n            await this.emitBaseImportAuditLog(job, totalRecordsCount);\n          });\n\n          entry\n            .pipe(\n              csvParser.default({\n                // strict: true,\n                mapValues: ({ value }) => {\n                  return value;\n                },\n                mapHeaders: ({ header }) => {\n                  if (header.startsWith('__row_') && viewIdMap[header.slice(6)]) {\n                    return `__row_${viewIdMap[header.slice(6)]}`;\n                  }\n\n                  // special case for cross base link fields, there is no map causing the old error link config\n                  if (header.startsWith('__fk_')) {\n                    return fieldIdMap[header.slice(5)]\n                      ? `__fk_${fieldIdMap[header.slice(5)]}`\n                      : fkMap[header] || header;\n                  }\n\n                  return header;\n                },\n              })\n            )\n            .pipe(batchProcessor)\n            .on('error', (error: Error) => {\n              this.logger.error(`import csv import error: ${error.message}`, error.stack);\n              reject(error);\n            })\n            .on('end', () => {\n              this.logger.log(\n                `csv ${tableId} finished, total records so far: ${totalRecordsCount}`\n              );\n            });\n        } else {\n          entry.autodrain();\n        }\n      });\n\n      parser.on('close', () => {\n        this.logger.log(`import csv parser completed, total records: ${totalRecordsCount}`);\n        resolve();\n      });\n\n      parser.on('error', (error) => {\n        this.logger.error(`ZIP parser error: ${error.message}`, error.stack);\n        reject(error);\n      });\n    });\n  }\n\n  private async handleChunk(\n    results: Record<string, unknown>[],\n    config: {\n      tableId: string;\n      userId: string;\n      fieldIdMap: Record<string, string>;\n      viewIdMap: Record<string, string>;\n      fkMap: Record<string, string>;\n      attachmentsFields: { dbFieldName: string; id: string }[];\n      notNullFieldMap: Map<string, { dbFieldType: string; isMultipleCellValue: boolean }>;\n    },\n    excludeDbFieldNames: string[]\n  ) {\n    const { tableId, userId, fieldIdMap, attachmentsFields, fkMap, notNullFieldMap } = config;\n    const { dbTableName } = await this.prismaService.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: {\n        dbTableName: true,\n      },\n    });\n\n    const allForeignKeyInfos = [] as {\n      constraint_name: string;\n      column_name: string;\n      referenced_table_schema: string;\n      referenced_table_name: string;\n      referenced_column_name: string;\n      dbTableName: string;\n    }[];\n\n    await this.prismaService.$tx(async (prisma) => {\n      // delete foreign keys if(exist) then duplicate table data\n      const foreignKeysInfoSql = this.dbProvider.getForeignKeysInfo(dbTableName);\n      const foreignKeysInfo = await prisma.$queryRawUnsafe<\n        {\n          constraint_name: string;\n          column_name: string;\n          referenced_table_schema: string;\n          referenced_table_name: string;\n          referenced_column_name: string;\n        }[]\n      >(foreignKeysInfoSql);\n      const newForeignKeyInfos = foreignKeysInfo.map((info) => ({\n        ...info,\n        dbTableName,\n      }));\n      allForeignKeyInfos.push(...newForeignKeyInfos);\n\n      for (const { constraint_name, column_name, dbTableName } of allForeignKeyInfos) {\n        const dropForeignKeyQuery = this.knex.schema\n          .alterTable(dbTableName, (table) => {\n            table.dropForeign(column_name, constraint_name);\n          })\n          .toQuery();\n\n        await prisma.$executeRawUnsafe(dropForeignKeyQuery);\n      }\n\n      const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);\n      const columnInfo = await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);\n\n      const attachmentsTableData = [] as {\n        attachmentId: string;\n        name: string;\n        token: string;\n        tableId: string;\n        recordId: string;\n        fieldId: string;\n      }[];\n\n      const newResult = [...results].map((res) => {\n        const newRes = { ...res };\n\n        excludeDbFieldNames.forEach((header) => {\n          delete newRes[header];\n        });\n\n        return newRes;\n      });\n\n      const attachmentsDbFieldNames = attachmentsFields.map(({ dbFieldName }) => dbFieldName);\n\n      const fkColumns = columnInfo\n        .filter(({ name }) => name.startsWith('__fk_'))\n        .map(({ name }) => {\n          return fieldIdMap[name.slice(5)]\n            ? `__fk_${fieldIdMap[name.slice(5)]}`\n            : fkMap[name] || name;\n        });\n\n      const recordsToInsert = newResult.map((result) => {\n        const res = { ...result };\n        Object.entries(res).forEach(([key, value]) => {\n          if (res[key] === '') {\n            const notNullInfo = notNullFieldMap.get(key);\n            if (notNullInfo) {\n              res[key] = this.getNotNullDefault(\n                notNullInfo.dbFieldType,\n                notNullInfo.isMultipleCellValue\n              );\n            } else {\n              res[key] = null;\n            }\n          }\n\n          // filter unnecessary columns\n          if (key.startsWith('__fk_') && !fkColumns.includes(key)) {\n            delete res[key];\n          }\n\n          // attachment field should add info to attachments table\n          if (attachmentsDbFieldNames.includes(key) && value) {\n            const attValues = JSON.parse(value as string) as IAttachmentCellValue;\n            const fieldId = attachmentsFields.find(({ dbFieldName }) => dbFieldName === key)?.id;\n            attValues.forEach((att) => {\n              const attachmentId = generateAttachmentId();\n              attachmentsTableData.push({\n                attachmentId,\n                name: att.name,\n                token: att.token,\n                tableId: tableId,\n                recordId: res['__id'] as string,\n                fieldId: fieldIdMap[fieldId!],\n              });\n            });\n          }\n        });\n\n        // default value set\n        res['__created_by'] = userId;\n        res['__version'] = 1;\n        return res;\n      });\n\n      // add lacking view order field\n      if (recordsToInsert.length) {\n        const sourceColumns = Object.keys(recordsToInsert[0]);\n        const lackingColumns = sourceColumns\n          .filter((column) => !columnInfo.map(({ name }) => name).includes(column))\n          .filter((name) => name.startsWith('__row_'));\n\n        for (const name of lackingColumns) {\n          const sql = this.knex.schema\n            .alterTable(dbTableName, (table) => {\n              table.double(name);\n            })\n            .toQuery();\n          await prisma.$executeRawUnsafe(sql);\n        }\n      }\n\n      const sql = this.knex.table(dbTableName).insert(recordsToInsert).toQuery();\n      await prisma.$executeRawUnsafe(sql);\n      await this.updateAttachmentTable(userId, attachmentsTableData);\n    });\n\n    // restore foreign keys with NOT VALID\n    for (const {\n      constraint_name,\n      column_name,\n      dbTableName,\n      referenced_table_schema: referencedTableSchema,\n      referenced_table_name: referencedTableName,\n      referenced_column_name: referencedColumnName,\n    } of allForeignKeyInfos) {\n      const [schema, tableName] = dbTableName.split('.');\n      const addForeignKeyQuery = this.knex\n        .raw(\n          'ALTER TABLE ??.?? ADD CONSTRAINT ?? FOREIGN KEY (??) REFERENCES ??.??(??) NOT VALID',\n          [\n            schema,\n            tableName,\n            constraint_name,\n            column_name,\n            referencedTableSchema,\n            referencedTableName,\n            referencedColumnName,\n          ]\n        )\n        .toQuery();\n      await this.prismaService.$executeRawUnsafe(addForeignKeyQuery);\n    }\n  }\n\n  private getNotNullDefault(dbFieldType: string, isMultipleCellValue: boolean): unknown {\n    switch (dbFieldType) {\n      case DbFieldType.Integer:\n      case DbFieldType.Real:\n        return 0;\n      case DbFieldType.Boolean:\n        return false;\n      case DbFieldType.DateTime:\n        return new Date(0).toISOString();\n      case DbFieldType.Json:\n        return isMultipleCellValue ? '[]' : '{}';\n      case DbFieldType.Text:\n      default:\n        return 'null';\n    }\n  }\n\n  // when insert table data relative to attachment, we need to update the attachment table\n  private async updateAttachmentTable(\n    userId: string,\n    attachmentsTableData: {\n      attachmentId: string;\n      name: string;\n      token: string;\n      tableId: string;\n      recordId: string;\n      fieldId: string;\n    }[]\n  ) {\n    await this.prismaService.txClient().attachmentsTable.createMany({\n      data: attachmentsTableData.map((a) => ({\n        ...a,\n        createdBy: userId,\n      })),\n    });\n  }\n\n  @OnWorkerEvent('completed')\n  async onCompleted(job: Job) {\n    const { fieldIdMap, path, structure, userId } = job.data;\n    await this.baseImportJunctionCsvQueueProcessor.queue.add(\n      'import_base_junction_csv',\n      {\n        fieldIdMap,\n        path,\n        structure,\n      },\n      {\n        jobId: `import_base_junction_csv_${path}_${userId}`,\n        delay: 2000,\n      }\n    );\n  }\n\n  private async emitBaseImportAuditLog(job: Job<IBaseImportCsvJob>, recordsLength: number) {\n    const { origin, userId, baseId, logId } = job.data;\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.BaseImport,\n        resourceId: baseId,\n        recordCount: recordsLength,\n        logId,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction-csv.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport {\n  BaseImportJunctionCsvQueueProcessor,\n  BASE_IMPORT_JUNCTION_CSV_QUEUE,\n} from './base-import-junction.processor';\n\n@Module({\n  providers: [BaseImportJunctionCsvQueueProcessor],\n  imports: [EventJobModule.registerQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE), StorageModule],\n  exports: [BaseImportJunctionCsvQueueProcessor],\n})\nexport class BaseImportJunctionCsvModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction.processor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport {\n  PrismaClientKnownRequestError,\n  PrismaClientUnknownRequestError,\n} from '@prisma/client/runtime/library';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IBaseJson } from '@teable/openapi';\nimport { UploadType } from '@teable/openapi';\nimport type { Job } from 'bullmq';\nimport { Queue } from 'bullmq';\nimport * as csvParser from 'csv-parser';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport * as unzipper from 'unzipper';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { createFieldInstanceByRaw } from '../../field/model/factory';\nimport { BatchProcessor } from '../BatchProcessor.class';\n\ninterface IBaseImportJunctionCsvJob {\n  path: string;\n  fieldIdMap: Record<string, string>;\n  structure: IBaseJson;\n}\n\nexport const BASE_IMPORT_JUNCTION_CSV_QUEUE = 'base-import-junction-csv-queue';\n\n@Injectable()\n@Processor(BASE_IMPORT_JUNCTION_CSV_QUEUE)\nexport class BaseImportJunctionCsvQueueProcessor extends WorkerHost {\n  private logger = new Logger(BaseImportJunctionCsvQueueProcessor.name);\n  private processedJobs = new Set<string>();\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE)\n    public readonly queue: Queue<IBaseImportJunctionCsvJob>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IBaseImportJunctionCsvJob>) {\n    const jobId = String(job.id);\n    if (this.processedJobs.has(jobId)) {\n      this.logger.log(`Job ${jobId} already processed, skipping`);\n      return;\n    }\n\n    this.processedJobs.add(jobId);\n\n    const { path, fieldIdMap, structure } = job.data;\n\n    try {\n      await this.importJunctionChunk(path, fieldIdMap, structure);\n    } catch (error) {\n      this.logger.error(\n        `Process base import junction csv failed: ${(error as Error)?.message}`,\n        (error as Error)?.stack\n      );\n    }\n  }\n\n  private async importJunctionChunk(\n    path: string,\n    fieldIdMap: Record<string, string>,\n    structure: IBaseJson\n  ) {\n    const csvStream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n\n    const sourceLinkFields = structure.tables\n      .map(({ fields }) => fields)\n      .flat()\n      .filter((f) => f.type === FieldType.Link && !f.isLookup);\n\n    const linkFieldRaws = await this.prismaService.field.findMany({\n      where: {\n        id: {\n          in: Object.values(fieldIdMap),\n        },\n        type: FieldType.Link,\n        isLookup: null,\n      },\n    });\n\n    const junctionDbTableNameMap = {} as Record<\n      string,\n      {\n        sourceSelfKeyName: string;\n        sourceForeignKeyName: string;\n        targetSelfKeyName: string;\n        targetForeignKeyName: string;\n        targetFkHostTableName: string;\n      }\n    >;\n\n    const linkFieldInstances = linkFieldRaws.map((f) => createFieldInstanceByRaw(f));\n\n    for (const sourceField of sourceLinkFields) {\n      const { options: sourceOptions } = sourceField;\n      const {\n        fkHostTableName: sourceFkHostTableName,\n        selfKeyName: sourceSelfKeyName,\n        foreignKeyName: sourceForeignKeyName,\n      } = sourceOptions as ILinkFieldOptions;\n      const targetField = linkFieldInstances.find((f) => f.id === fieldIdMap[sourceField.id])!;\n      const { options: targetOptions } = targetField;\n      const {\n        fkHostTableName: targetFkHostTableName,\n        selfKeyName: targetSelfKeyName,\n        foreignKeyName: targetForeignKeyName,\n      } = targetOptions as ILinkFieldOptions;\n      if (sourceFkHostTableName.includes('junction_')) {\n        junctionDbTableNameMap[sourceFkHostTableName] = {\n          sourceSelfKeyName,\n          sourceForeignKeyName,\n          targetSelfKeyName,\n          targetForeignKeyName,\n          targetFkHostTableName,\n        };\n      }\n    }\n\n    const parser = unzipper.Parse();\n    csvStream.pipe(parser);\n\n    const processedFiles = new Set<string>();\n\n    return new Promise<{ success: boolean }>((resolve, reject) => {\n      parser.on('entry', (entry) => {\n        const filePath = entry.path;\n\n        if (processedFiles.has(filePath)) {\n          entry.autodrain();\n          return;\n        }\n        processedFiles.add(filePath);\n\n        if (\n          filePath.startsWith('tables/') &&\n          entry.type !== 'Directory' &&\n          filePath.includes('junction_')\n        ) {\n          const name = filePath.replace('tables/', '').split('.');\n          name.pop();\n          const junctionTableName = name.join('.');\n          const junctionInfo = junctionDbTableNameMap[junctionTableName];\n\n          const {\n            sourceForeignKeyName,\n            targetForeignKeyName,\n            sourceSelfKeyName,\n            targetSelfKeyName,\n            targetFkHostTableName,\n          } = junctionInfo;\n\n          const batchProcessor = new BatchProcessor<Record<string, unknown>>((chunk) =>\n            this.handleJunctionChunk(chunk, targetFkHostTableName)\n          );\n\n          entry\n            .pipe(\n              csvParser.default({\n                // strict: true,\n                mapValues: ({ value }) => {\n                  // deal with old junction order case\n                  return value === '' ? null : value;\n                },\n                mapHeaders: ({ header }) => {\n                  return header\n                    .replaceAll(sourceForeignKeyName, targetForeignKeyName)\n                    .replaceAll(sourceSelfKeyName, targetSelfKeyName);\n                },\n              })\n            )\n            .pipe(batchProcessor)\n            .on('error', (error: Error) => {\n              this.logger.error(`process csv import error: ${error.message}`, error.stack);\n              reject(error);\n            })\n            .on('end', () => {\n              this.logger.log(`csv ${junctionTableName} finished`);\n              resolve({ success: true });\n            });\n        } else {\n          entry.autodrain();\n        }\n      });\n\n      parser.on('close', () => {\n        this.logger.log('import csv junction completed');\n        resolve({ success: true });\n      });\n\n      parser.on('error', (error) => {\n        this.logger.error(`import csv junction parser error: ${error.message}`, error.stack);\n        reject(error);\n      });\n    });\n  }\n\n  private async handleJunctionChunk(\n    results: Record<string, unknown>[],\n    targetFkHostTableName: string\n  ) {\n    const allForeignKeyInfos = [] as {\n      constraint_name: string;\n      column_name: string;\n      referenced_table_schema: string;\n      referenced_table_name: string;\n      referenced_column_name: string;\n      dbTableName: string;\n    }[];\n\n    await this.prismaService.$tx(async (prisma) => {\n      // delete foreign keys if(exist) then duplicate table data\n      const foreignKeysInfoSql = this.dbProvider.getForeignKeysInfo(targetFkHostTableName);\n      const foreignKeysInfo = await prisma.$queryRawUnsafe<\n        {\n          constraint_name: string;\n          column_name: string;\n          referenced_table_schema: string;\n          referenced_table_name: string;\n          referenced_column_name: string;\n        }[]\n      >(foreignKeysInfoSql);\n      const newForeignKeyInfos = foreignKeysInfo.map((info) => ({\n        ...info,\n        dbTableName: targetFkHostTableName,\n      }));\n      allForeignKeyInfos.push(...newForeignKeyInfos);\n\n      for (const { constraint_name, column_name, dbTableName } of allForeignKeyInfos) {\n        const dropForeignKeyQuery = this.knex.schema\n          .alterTable(dbTableName, (table) => {\n            table.dropForeign(column_name, constraint_name);\n          })\n          .toQuery();\n\n        await prisma.$executeRawUnsafe(dropForeignKeyQuery);\n      }\n\n      const sql = this.knex.table(targetFkHostTableName).insert(results).toQuery();\n      try {\n        await prisma.$executeRawUnsafe(sql);\n      } catch (error) {\n        if (error instanceof PrismaClientKnownRequestError) {\n          this.logger.error(\n            `exc junction import task known error: (${error.code}): ${error.message}`,\n            error.stack\n          );\n        } else if (error instanceof PrismaClientUnknownRequestError) {\n          this.logger.error(\n            `exc junction import task unknown error: ${error.message}`,\n            error.stack\n          );\n        } else {\n          this.logger.error(\n            `exc junction import task error: ${(error as Error)?.message}`,\n            (error as Error)?.stack\n          );\n        }\n      }\n\n      // add foreign keys with NOT VALID to skip existing data validation\n      for (const {\n        constraint_name,\n        column_name,\n        dbTableName,\n        referenced_table_schema: referencedTableSchema,\n        referenced_table_name: referencedTableName,\n        referenced_column_name: referencedColumnName,\n      } of allForeignKeyInfos) {\n        const [schema, tableName] = dbTableName.split('.');\n        const addForeignKeyQuery = this.knex\n          .raw(\n            'ALTER TABLE ??.?? ADD CONSTRAINT ?? FOREIGN KEY (??) REFERENCES ??.??(??) NOT VALID',\n            [\n              schema,\n              tableName,\n              constraint_name,\n              column_name,\n              referencedTableSchema,\n              referencedTableName,\n              referencedColumnName,\n            ]\n          )\n          .toQuery();\n        await prisma.$executeRawUnsafe(addForeignKeyQuery);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-import.service.ts",
    "content": "import type { Readable } from 'stream';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport {\n  FieldType,\n  generateBaseId,\n  generateBaseNodeFolderId,\n  generateBaseNodeId,\n  generateDashboardId,\n  generateLogId,\n  generatePluginInstallId,\n  generatePluginPanelId,\n  generateShareId,\n  getUniqName,\n  ViewType,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  ICreateBaseVo,\n  IBaseJson,\n  ImportBaseRo,\n  IFieldWithTableIdJson,\n} from '@teable/openapi';\nimport {\n  UploadType,\n  PluginPosition,\n  BaseNodeResourceType,\n  BaseDuplicateMode,\n} from '@teable/openapi';\n\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport streamJson from 'stream-json';\nimport streamValues from 'stream-json/streamers/StreamValues';\nimport * as unzipper from 'unzipper';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../types/cls';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../attachments/plugins/storage';\nimport { FieldDuplicateService } from '../field/field-duplicate/field-duplicate.service';\nimport { TableService } from '../table/table.service';\nimport { ViewOpenApiService } from '../view/open-api/view-open-api.service';\nimport { BaseImportAttachmentsQueueProcessor } from './base-import-processor/base-import-attachments.processor';\nimport { BaseImportCsvQueueProcessor } from './base-import-processor/base-import-csv.processor';\nimport { replaceStringByMap } from './utils';\n\n@Injectable()\nexport class BaseImportService {\n  private logger = new Logger(BaseImportService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly tableService: TableService,\n    private readonly fieldDuplicateService: FieldDuplicateService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly baseImportAttachmentsQueueProcessor: BaseImportAttachmentsQueueProcessor,\n    private readonly baseImportCsvQueueProcessor: BaseImportCsvQueueProcessor,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly eventEmitter: EventEmitter2\n  ) {}\n\n  private async getMaxOrder(spaceId: string) {\n    const spaceAggregate = await this.prismaService.txClient().base.aggregate({\n      where: { spaceId, deletedTime: null },\n      _max: { order: true },\n    });\n    return spaceAggregate._max.order || 0;\n  }\n\n  private async createBase(spaceId: string, name: string, icon?: string) {\n    const userId = this.cls.get('user.id');\n\n    return this.prismaService.$tx(async (prisma) => {\n      const order = (await this.getMaxOrder(spaceId)) + 1;\n\n      const base = await prisma.base.create({\n        data: {\n          id: generateBaseId(),\n          name: name || 'Untitled Base',\n          spaceId,\n          order,\n          icon,\n          createdBy: userId,\n        },\n        select: {\n          id: true,\n          name: true,\n          icon: true,\n          spaceId: true,\n        },\n      });\n\n      const sqlList = this.dbProvider.createSchema(base.id);\n      if (sqlList) {\n        for (const sql of sqlList) {\n          await prisma.$executeRawUnsafe(sql);\n        }\n      }\n\n      return base;\n    });\n  }\n\n  async importBase(\n    importBaseRo: ImportBaseRo,\n    onProgress?: (phase: string, detail?: string) => void\n  ) {\n    const {\n      notify: { path },\n    } = importBaseRo;\n\n    onProgress?.('parsing_structure');\n\n    // 1. create base structure from json\n    const structureStream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n\n    const { base, tableIdMap, viewIdMap, fieldIdMap, fkMap, structure, ...rest } =\n      await this.prismaService.$tx(\n        async () => {\n          return await this.processStructure(structureStream, importBaseRo, onProgress);\n        },\n        {\n          timeout: this.thresholdConfig.bigTransactionTimeout,\n        }\n      );\n\n    // Structure created successfully, notify with baseId\n    onProgress?.('structure_created', base.id);\n\n    // 2. upload attachments (queued)\n    onProgress?.('queuing_attachments');\n    this.uploadAttachments(path);\n\n    // 3. create import table data task (queued)\n    onProgress?.('queuing_data_import');\n    this.appendTableData(\n      base.id,\n      importBaseRo,\n      path,\n      tableIdMap,\n      fieldIdMap,\n      viewIdMap,\n      fkMap,\n      structure\n    );\n\n    return {\n      base,\n      tableIdMap,\n      fieldIdMap,\n      viewIdMap,\n      ...rest,\n    } as {\n      base: ICreateBaseVo;\n      tableIdMap: Record<string, string>;\n      fieldIdMap: Record<string, string>;\n      viewIdMap: Record<string, string>;\n    } & {\n      [key: string]: Record<string, string>;\n    };\n  }\n\n  private async processStructure(\n    zipStream: Readable,\n    importBaseRo: ImportBaseRo,\n    onProgress?: (phase: string, detail?: string) => void\n  ): Promise<{\n    base: ICreateBaseVo;\n    tableIdMap: Record<string, string>;\n    fieldIdMap: Record<string, string>;\n    viewIdMap: Record<string, string>;\n    fkMap: Record<string, string>;\n    structure: IBaseJson;\n  }> {\n    const { spaceId } = importBaseRo;\n    const parser = unzipper.Parse();\n    zipStream.pipe(parser);\n    return new Promise((resolve, reject) => {\n      parser.on('entry', (entry) => {\n        const filePath = entry.path;\n        if (filePath === 'structure.json') {\n          const parser = streamJson.parser();\n          const pipeline = entry.pipe(parser).pipe(streamValues.streamValues());\n\n          let structureObject: IBaseJson | null = null;\n          pipeline\n            .on('data', (data: { key: number; value: IBaseJson }) => {\n              structureObject = data.value;\n            })\n            .on('end', async () => {\n              if (!structureObject) {\n                reject(new Error('import base structure.json resolve error'));\n              }\n\n              try {\n                const result = await this.createBaseStructure(\n                  spaceId,\n                  structureObject!,\n                  undefined,\n                  undefined,\n                  undefined,\n                  onProgress\n                );\n                resolve(result);\n              } catch (error) {\n                reject(error);\n              }\n            })\n            .on('error', (err: Error) => {\n              parser.destroy(new Error(`resolve structure.json error: ${err.message}`));\n              reject(Error);\n            });\n        } else {\n          entry.autodrain();\n        }\n      });\n    });\n  }\n\n  private async uploadAttachments(path: string) {\n    const userId = this.cls.get('user.id');\n    await this.baseImportAttachmentsQueueProcessor.queue.add(\n      'import_base_attachments',\n      {\n        path,\n        userId,\n      },\n      {\n        jobId: `import_attachments_${path}_${userId}`,\n      }\n    );\n  }\n\n  private async appendTableData(\n    baseId: string,\n    importBaseRo: ImportBaseRo,\n    path: string,\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    viewIdMap: Record<string, string>,\n    fkMap: Record<string, string>,\n    structure: IBaseJson\n  ): Promise<string> {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n    // Generate a unique logId for upsert to ensure only one audit log\n    const logId = generateLogId();\n\n    await this.baseImportCsvQueueProcessor.queue.add(\n      'base_import_csv',\n      {\n        baseId,\n        path,\n        userId,\n        origin,\n        tableIdMap,\n        fieldIdMap,\n        viewIdMap,\n        fkMap,\n        structure,\n        importBaseRo,\n        logId,\n      },\n      {\n        jobId: `import_csv_${path}_${userId}`,\n      }\n    );\n\n    return logId;\n  }\n\n  async createBaseStructure(\n    spaceId: string,\n    structure: IBaseJson,\n    baseId?: string,\n    skipCreateBaseNodes?: boolean,\n    duplicateMode: BaseDuplicateMode = BaseDuplicateMode.Normal,\n    onProgress?: (phase: string, detail?: string) => void\n  ) {\n    const { name, icon, tables, plugins, folders } = structure;\n\n    const isCopyToExistingBase = !!baseId && duplicateMode === BaseDuplicateMode.CopyShareBase;\n\n    // create base\n    onProgress?.('creating_base', name);\n    const newBase = baseId\n      ? await this.prismaService.base.findUniqueOrThrow({\n          where: { id: baseId },\n          select: {\n            id: true,\n            name: true,\n            icon: true,\n            spaceId: true,\n          },\n        })\n      : await this.createBase(spaceId, name, icon || undefined);\n    this.logger.log(`base-duplicate-service: Duplicate base successfully`);\n\n    // update base icon and name (skip when copying into an existing base)\n    if (baseId && !isCopyToExistingBase) {\n      await this.prismaService.txClient().base.update({\n        where: { id: baseId },\n        data: {\n          name,\n          icon,\n        },\n      });\n    }\n\n    // When copying into an existing base, strip dbTableName to avoid conflicts\n    const effectiveTables = isCopyToExistingBase\n      ? tables.map(({ dbTableName: _, ...rest }) => rest)\n      : tables;\n\n    // Skip computed field evaluation during structure creation — tables have no records yet,\n    // and calculations will run when data is actually imported/copied.\n    this.cls.set('skipFieldComputation', true);\n\n    let tableIdMap: Record<string, string>;\n    let fieldIdMap: Record<string, string>;\n    let viewIdMap: Record<string, string>;\n    let fkMap: Record<string, string>;\n\n    try {\n      // create table\n      ({ tableIdMap, fieldIdMap, viewIdMap, fkMap } = await this.createTables(\n        newBase.id,\n        effectiveTables as IBaseJson['tables'],\n        onProgress\n      ));\n    } finally {\n      this.cls.set('skipFieldComputation', false);\n    }\n\n    this.logger.log(`base-duplicate-service: Duplicate base tables successfully`);\n\n    // create plugins\n    const hasPlugins = Object.values(plugins).some((arr) => Array.isArray(arr) && arr.length > 0);\n    if (hasPlugins) {\n      onProgress?.('creating_plugins');\n    }\n    const { dashboardIdMap } = await this.createPlugins(\n      newBase.id,\n      plugins,\n      tableIdMap,\n      fieldIdMap,\n      viewIdMap\n    );\n    this.logger.log(`base-duplicate-service: Duplicate base plugins successfully`);\n\n    // create folders\n    if (Array.isArray(folders) && folders.length > 0) {\n      onProgress?.('creating_folders');\n    }\n    const { folderIdMap } = await this.createFolders(newBase.id, folders, isCopyToExistingBase);\n    this.logger.log(`base-duplicate-service: Duplicate base folders successfully`);\n\n    let nodeIdMap: Record<string, string> = {};\n\n    // create base nodes\n    if (!skipCreateBaseNodes) {\n      nodeIdMap = await this.createBaseNodes(\n        newBase.id,\n        structure.nodes,\n        {\n          folderIdMap,\n          tableIdMap,\n          dashboardIdMap,\n        },\n        isCopyToExistingBase\n      );\n    }\n\n    const baseIdMap = {\n      [structure.id]: newBase.id,\n    };\n\n    return {\n      base: newBase,\n      tableIdMap,\n      fieldIdMap,\n      viewIdMap,\n      structure,\n      fkMap,\n      folderIdMap,\n      dashboardIdMap,\n      nodeIdMap,\n      baseIdMap,\n    };\n  }\n\n  private async createTables(\n    baseId: string,\n    tables: IBaseJson['tables'],\n    onProgress?: (phase: string, detail?: string) => void\n  ) {\n    const tableIdMap: Record<string, string> = {};\n    // Build a name lookup: oldTableId → tableName\n    const tableNameMap: Record<string, string> = {};\n\n    for (const table of tables) {\n      const { name, icon, description, id: tableId, dbTableName } = table;\n      tableNameMap[tableId] = name;\n      onProgress?.('creating_table', name);\n      const newTableVo = await this.tableService.createTable(baseId, {\n        name,\n        icon,\n        description,\n        dbTableName,\n      });\n      tableIdMap[tableId] = newTableVo.id;\n      this.logger.log(`base-duplicate-service: duplicate table item successfully`);\n    }\n\n    const { fieldMap: fieldIdMap, fkMap } = await this.createFields(\n      tables,\n      tableIdMap,\n      tableNameMap,\n      onProgress\n    );\n    this.logger.log(`base-duplicate-service: Duplicate table fields successfully`);\n\n    const viewIdMap = await this.createViews(tables, tableIdMap, fieldIdMap, onProgress);\n    this.logger.log(`base-duplicate-service: Duplicate table views successfully`);\n\n    await this.fieldDuplicateService.repairFieldOptions(tables, tableIdMap, fieldIdMap, viewIdMap);\n\n    return { tableIdMap, fieldIdMap, viewIdMap, fkMap };\n  }\n\n  private async createFields(\n    tables: IBaseJson['tables'],\n    tableIdMap: Record<string, string>,\n    tableNameMap?: Record<string, string>,\n    onProgress?: (phase: string, detail?: string) => void\n  ) {\n    const fieldMap: Record<string, string> = {};\n    const fkMap: Record<string, string> = {};\n\n    const allFields = tables\n      .reduce((acc, cur) => {\n        const fieldWithTableId = cur.fields.map((field) => ({\n          ...field,\n          sourceTableId: cur.id,\n          targetTableId: tableIdMap[cur.id],\n        }));\n        return [...acc, ...fieldWithTableId];\n      }, [] as IFieldWithTableIdJson[])\n      .sort((a, b) => a.createdTime.localeCompare(b.createdTime));\n\n    const nonCommonFieldTypes = [\n      FieldType.Link,\n      FieldType.Rollup,\n      FieldType.ConditionalRollup,\n      FieldType.Formula,\n      FieldType.Button,\n    ];\n\n    const commonFields = allFields.filter(\n      ({ type, isLookup, aiConfig }) =>\n        !nonCommonFieldTypes.includes(type) && !isLookup && !aiConfig\n    );\n\n    // the primary formula which rely on other fields\n    const primaryFormulaFields = allFields.filter(\n      ({ type, isLookup }) => type === FieldType.Formula && !isLookup\n    );\n\n    // link fields\n    const linkFields = allFields.filter(\n      ({ type, isLookup }) => type === FieldType.Link && !isLookup\n    );\n\n    const buttonFields = allFields.filter(\n      ({ type, isLookup }) => type === FieldType.Button && !isLookup\n    );\n\n    // rest fields, like formula, rollup, lookup fields\n    const dependencyFields = allFields.filter(\n      ({ id }) =>\n        ![...primaryFormulaFields, ...linkFields, ...commonFields, ...buttonFields]\n          .map(({ id }) => id)\n          .includes(id)\n    );\n\n    // helper: emit per-table progress with field names\n    const emitFieldProgress = (\n      phase: string,\n      fields: { sourceTableId: string; name: string }[]\n    ) => {\n      if (!fields.length || !onProgress) return;\n      const byTable = new Map<string, string[]>();\n      for (const f of fields) {\n        const tableName = tableNameMap?.[f.sourceTableId] ?? f.sourceTableId;\n        if (!byTable.has(tableName)) byTable.set(tableName, []);\n        byTable.get(tableName)!.push(f.name);\n      }\n      for (const [table, fieldNames] of byTable) {\n        onProgress(phase, JSON.stringify({ table, fields: fieldNames.join(', ') }));\n      }\n    };\n\n    emitFieldProgress('creating_common_fields', commonFields);\n    await this.fieldDuplicateService.createCommonFields(commonFields, fieldMap);\n\n    emitFieldProgress('creating_button_fields', buttonFields);\n    await this.fieldDuplicateService.createButtonFields(buttonFields, fieldMap);\n\n    emitFieldProgress('creating_formula_fields', primaryFormulaFields);\n    await this.fieldDuplicateService.createTmpPrimaryFormulaFields(primaryFormulaFields, fieldMap);\n\n    // main fix formula dbField type\n    await this.fieldDuplicateService.repairPrimaryFormulaFields(primaryFormulaFields, fieldMap);\n\n    emitFieldProgress('creating_link_fields', linkFields);\n    await this.fieldDuplicateService.createLinkFields(linkFields, tableIdMap, fieldMap, fkMap);\n\n    emitFieldProgress('creating_lookup_fields', dependencyFields);\n    await this.fieldDuplicateService.createDependencyFields(dependencyFields, tableIdMap, fieldMap);\n\n    // fix formula expression' field map\n    await this.fieldDuplicateService.repairPrimaryFormulaFields(primaryFormulaFields, fieldMap);\n\n    const formulaFields = allFields.filter(\n      ({ type, isLookup }) => type === FieldType.Formula && !isLookup\n    );\n\n    // fix formula reference\n    await this.fieldDuplicateService.repairFormulaReference(formulaFields, fieldMap);\n\n    return { fieldMap, fkMap };\n  }\n\n  /* eslint-disable sonarjs/cognitive-complexity */\n  private async createViews(\n    tables: IBaseJson['tables'],\n    tableIdMap: Record<string, string>,\n    fieldMap: Record<string, string>,\n    onProgress?: (phase: string, detail?: string) => void\n  ) {\n    const viewMap: Record<string, string> = {};\n    for (const table of tables) {\n      const { views: originalViews, id: tableId, name: tableName } = table;\n      const views = originalViews.filter((view) => view.type !== ViewType.Plugin);\n      if (views.length) {\n        const viewNames = views.map((v) => v.name).join(', ');\n        onProgress?.(\n          'creating_table_views',\n          JSON.stringify({ table: tableName, fields: viewNames })\n        );\n      }\n      for (const view of views) {\n        const {\n          name,\n          type,\n          id: viewId,\n          description,\n          enableShare,\n          isLocked,\n          order,\n          columnMeta,\n          shareMeta,\n          shareId,\n        } = view;\n\n        const keys = ['options', 'columnMeta', 'filter', 'group', 'sort'] as (keyof typeof view)[];\n        const obj = {} as Record<string, unknown>;\n\n        for (const key of keys) {\n          const keyString = replaceStringByMap(view[key], { fieldMap });\n          const newValue = keyString ? JSON.parse(keyString) : null;\n          obj[key] = newValue;\n        }\n        const newViewVo = await this.viewOpenApiService.createView(tableIdMap[tableId], {\n          name,\n          type,\n          description,\n          enableShare,\n          isLocked,\n          ...obj,\n        });\n\n        viewMap[viewId] = newViewVo.id;\n\n        await this.prismaService.txClient().view.update({\n          where: {\n            id: newViewVo.id,\n          },\n          data: {\n            order,\n            columnMeta: columnMeta ? replaceStringByMap(columnMeta, { fieldMap }) : columnMeta,\n            shareId: shareId ? generateShareId() : undefined,\n            shareMeta: shareMeta ? JSON.stringify(shareMeta) : undefined,\n            enableShare,\n            isLocked,\n          },\n        });\n      }\n    }\n\n    return viewMap;\n  }\n\n  private async createFolders(\n    baseId: string,\n    folders: IBaseJson['folders'],\n    copyToExistingBase: boolean = false\n  ) {\n    const folderIdMap: Record<string, string> = {};\n    if (!Array.isArray(folders) || folders.length === 0) {\n      return { folderIdMap };\n    }\n    const prisma = this.prismaService.txClient();\n    const userId = this.cls.get('user.id');\n\n    const existingNames: string[] = [];\n    if (copyToExistingBase) {\n      const existingFolders = await prisma.baseNodeFolder.findMany({\n        where: { baseId },\n        select: { name: true },\n      });\n      existingNames.push(...existingFolders.map((f) => f.name));\n    }\n\n    for (const folder of folders) {\n      const { id, name } = folder;\n      const uniqueName = copyToExistingBase ? getUniqName(name, existingNames) : name;\n      if (copyToExistingBase) {\n        existingNames.push(uniqueName);\n      }\n\n      const newFolderId = generateBaseNodeFolderId();\n      await prisma.baseNodeFolder.create({\n        data: { id: newFolderId, name: uniqueName, baseId, createdBy: userId },\n      });\n      folderIdMap[id] = newFolderId;\n    }\n    return { folderIdMap };\n  }\n\n  async createBaseNodes(\n    baseId: string,\n    nodes: IBaseJson['nodes'],\n    idMapContext: {\n      folderIdMap?: Record<string, string>;\n      tableIdMap?: Record<string, string>;\n      dashboardIdMap?: Record<string, string>;\n      workflowIdMap?: Record<string, string>;\n      appIdMap?: Record<string, string>;\n    },\n    copyToExistingBase: boolean = false\n  ) {\n    if (!Array.isArray(nodes) || nodes.length === 0) {\n      return {} as Record<string, string>;\n    }\n\n    const prisma = this.prismaService.txClient();\n    const userId = this.cls.get('user.id');\n    const {\n      folderIdMap = {},\n      tableIdMap = {},\n      dashboardIdMap = {},\n      workflowIdMap = {},\n      appIdMap = {},\n    } = idMapContext;\n\n    const allNodeIdMap = nodes.reduce(\n      (acc, cur) => {\n        acc[cur.id] = generateBaseNodeId();\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n\n    const allTypeNodeIdMap = nodes.reduce(\n      (acc, cur) => {\n        const { resourceType, resourceId } = cur;\n        acc[resourceType] = acc[resourceType] ?? {};\n        switch (resourceType) {\n          case BaseNodeResourceType.Folder:\n            acc[resourceType][resourceId] = folderIdMap[resourceId];\n            break;\n          case BaseNodeResourceType.Table:\n            acc[resourceType][resourceId] = tableIdMap[resourceId];\n            break;\n          case BaseNodeResourceType.Dashboard:\n            acc[resourceType][resourceId] = dashboardIdMap[resourceId];\n            break;\n          case BaseNodeResourceType.Workflow:\n            acc[resourceType][resourceId] = workflowIdMap[resourceId];\n            break;\n          case BaseNodeResourceType.App:\n            acc[resourceType][resourceId] = appIdMap[resourceId];\n            break;\n          default:\n            break;\n        }\n        return acc;\n      },\n      {} as Record<BaseNodeResourceType, Record<string, string>>\n    );\n    // Sort nodes by parent-child relationship (topological sort)\n    // Ensure parent nodes are created before child nodes\n    const sortedNodes: typeof nodes = [];\n    const nodeMap = new Map(nodes.map((node) => [node.id, node]));\n    const visited = new Set<string>();\n\n    const visit = (node: (typeof nodes)[0]) => {\n      if (visited.has(node.id)) return;\n      if (node.parentId && nodeMap.has(node.parentId)) {\n        visit(nodeMap.get(node.parentId)!);\n      }\n      visited.add(node.id);\n      sortedNodes.push(node);\n    };\n\n    for (const node of nodes) {\n      visit(node);\n    }\n\n    // Deduplicate nodes by (resourceType, newResourceId) to avoid unique constraint violations\n    const createdResourceKeys = new Set<string>();\n\n    let rootOrderOffset = 0;\n    if (copyToExistingBase) {\n      const maxOrderResult = await prisma.baseNode.aggregate({\n        where: { baseId, parentId: null },\n        _max: { order: true },\n      });\n      rootOrderOffset = (maxOrderResult._max.order ?? 0) + 1;\n    }\n\n    for (const node of sortedNodes) {\n      const { id, parentId, resourceId, resourceType, order } = node;\n      const newId = allNodeIdMap[id];\n      const newParentId = parentId && allNodeIdMap[parentId] ? allNodeIdMap[parentId] : null;\n      const newResourceId =\n        allTypeNodeIdMap[resourceType] && allTypeNodeIdMap[resourceType][resourceId]\n          ? allTypeNodeIdMap[resourceType][resourceId]\n          : null;\n      if (!newResourceId) {\n        this.logger.error(\n          `base-import-service: create base node failed, nodeId: ${id}, resourceId: ${resourceId}, resourceType: ${resourceType}`\n        );\n        continue;\n      }\n\n      // Check if this (baseId, resourceType, resourceId) combination already exists in this batch\n      const resourceKey = `${baseId}:${resourceType}:${newResourceId}`;\n      if (createdResourceKeys.has(resourceKey)) {\n        this.logger.warn(\n          `base-import-service: skipping duplicate node in batch, baseId: ${baseId}, resourceType: ${resourceType}, resourceId: ${newResourceId}`\n        );\n        continue;\n      }\n\n      const effectiveOrder = newParentId ? order : order + rootOrderOffset;\n\n      // Check if node already exists in database (could be created by prepareNodeList self-healing)\n      const existingNode = await prisma.baseNode.findFirst({\n        where: {\n          baseId,\n          resourceType,\n          resourceId: newResourceId,\n        },\n      });\n\n      if (existingNode && copyToExistingBase) {\n        await prisma.baseNode.update({\n          where: { id: existingNode.id },\n          data: { parentId: newParentId, order: effectiveOrder },\n        });\n        allNodeIdMap[id] = existingNode.id;\n        createdResourceKeys.add(resourceKey);\n        continue;\n      }\n\n      if (existingNode) {\n        this.logger.warn(\n          `base-import-service: node already exists in database, baseId: ${baseId}, resourceType: ${resourceType}, resourceId: ${newResourceId}`\n        );\n        createdResourceKeys.add(resourceKey);\n        continue;\n      }\n\n      await prisma.baseNode.create({\n        data: {\n          id: newId,\n          parentId: newParentId,\n          resourceId: newResourceId,\n          resourceType,\n          baseId,\n          createdBy: userId,\n          order: effectiveOrder,\n        },\n      });\n\n      createdResourceKeys.add(resourceKey);\n    }\n\n    return allNodeIdMap;\n  }\n\n  private async createPlugins(\n    baseId: string,\n    plugins: IBaseJson['plugins'],\n    tableIdMap: Record<string, string>,\n    fieldMap: Record<string, string>,\n    viewIdMap: Record<string, string>\n  ) {\n    const { dashboardIdMap } = await this.createDashboard(\n      baseId,\n      plugins[PluginPosition.Dashboard],\n      tableIdMap,\n      fieldMap\n    );\n    await this.createPanel(baseId, plugins[PluginPosition.Panel], tableIdMap, fieldMap);\n    await this.createPluginViews(\n      baseId,\n      plugins[PluginPosition.View],\n      tableIdMap,\n      fieldMap,\n      viewIdMap\n    );\n    return { dashboardIdMap };\n  }\n\n  async createDashboard(\n    baseId: string,\n    plugins: IBaseJson['plugins'][PluginPosition.Dashboard],\n    tableMap: Record<string, string>,\n    fieldMap: Record<string, string>\n  ) {\n    const dashboardMap: Record<string, string> = {};\n    const pluginInstallMap: Record<string, string> = {};\n    const userId = this.cls.get('user.id');\n    const prisma = this.prismaService.txClient();\n    const pluginInstalls = plugins.map(({ pluginInstall }) => pluginInstall).flat();\n\n    for (const plugin of plugins) {\n      const { id, name } = plugin;\n      const newDashBoardId = generateDashboardId();\n      await prisma.dashboard.create({\n        data: {\n          id: newDashBoardId,\n          baseId,\n          name,\n          createdBy: userId,\n        },\n      });\n      dashboardMap[id] = newDashBoardId;\n    }\n\n    for (const pluginInstall of pluginInstalls) {\n      const { id, pluginId, positionId, position, name, storage } = pluginInstall;\n      const newPluginInstallId = generatePluginInstallId();\n      const newStorage = replaceStringByMap(storage, { tableMap, fieldMap });\n      await prisma.pluginInstall.create({\n        data: {\n          id: newPluginInstallId,\n          createdBy: userId,\n          baseId,\n          pluginId,\n          name,\n          positionId: dashboardMap[positionId],\n          position,\n          storage: newStorage,\n        },\n      });\n      pluginInstallMap[id] = newPluginInstallId;\n    }\n\n    // replace pluginId in layout with new pluginInstallId\n    for (const plugin of plugins) {\n      const { id, layout } = plugin;\n      const newLayout = replaceStringByMap(layout, { pluginInstallMap });\n      await prisma.dashboard.update({\n        where: { id: dashboardMap[id] },\n        data: {\n          layout: newLayout,\n        },\n      });\n    }\n\n    return {\n      dashboardIdMap: dashboardMap,\n    };\n  }\n\n  async createPanel(\n    baseId: string,\n    plugins: IBaseJson['plugins'][PluginPosition.Panel],\n    tableMap: Record<string, string>,\n    fieldMap: Record<string, string>\n  ) {\n    const panelMap: Record<string, string> = {};\n    const pluginInstallMap: Record<string, string> = {};\n    const userId = this.cls.get('user.id');\n    const prisma = this.prismaService.txClient();\n    const pluginInstalls = plugins.map(({ pluginInstall }) => pluginInstall).flat();\n\n    for (const plugin of plugins) {\n      const { id, name, tableId } = plugin;\n      const newPluginPanelId = generatePluginPanelId();\n      await prisma.pluginPanel.create({\n        data: {\n          id: newPluginPanelId,\n          tableId: tableMap[tableId],\n          name,\n          createdBy: userId,\n        },\n      });\n      panelMap[id] = newPluginPanelId;\n    }\n\n    for (const pluginInstall of pluginInstalls) {\n      const { id, pluginId, positionId, position, name, storage } = pluginInstall;\n      const newPluginInstallId = generatePluginInstallId();\n      const newStorage = replaceStringByMap(storage, { tableMap, fieldMap });\n      await prisma.pluginInstall.create({\n        data: {\n          id: newPluginInstallId,\n          createdBy: userId,\n          baseId,\n          pluginId,\n          name,\n          positionId: panelMap[positionId],\n          position,\n          storage: newStorage,\n        },\n      });\n      pluginInstallMap[id] = newPluginInstallId;\n    }\n\n    // replace pluginId in layout with new pluginInstallId\n    for (const plugin of plugins) {\n      const { id, layout } = plugin;\n      const newLayout = replaceStringByMap(layout, { pluginInstallMap });\n      await prisma.pluginPanel.update({\n        where: { id: panelMap[id] },\n        data: {\n          layout: newLayout,\n        },\n      });\n    }\n\n    return {\n      panelMap,\n    };\n  }\n\n  private async createPluginViews(\n    baseId: string,\n    pluginViews: IBaseJson['plugins'][PluginPosition.View],\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    viewIdMap: Record<string, string>\n  ) {\n    const prisma = this.prismaService.txClient();\n\n    for (const pluginView of pluginViews) {\n      const {\n        id,\n        name,\n        description,\n        enableShare,\n        shareMeta,\n        isLocked,\n        tableId,\n        pluginInstall,\n        order,\n      } = pluginView;\n      const { pluginId } = pluginInstall;\n      const { viewId: newViewId, pluginInstallId } = await this.viewOpenApiService.pluginInstall(\n        tableIdMap[tableId],\n        {\n          name,\n          pluginId,\n        }\n      );\n      viewIdMap[id] = newViewId;\n\n      await prisma.view.update({\n        where: { id: newViewId },\n        data: {\n          order,\n        },\n      });\n\n      // 1. update view options\n      const configProperties = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;\n      const updateConfig = {} as Record<(typeof configProperties)[number], string>;\n      for (const property of configProperties) {\n        const result = replaceStringByMap(pluginView[property], {\n          tableIdMap,\n          fieldIdMap,\n          viewIdMap,\n        });\n\n        if (result) {\n          updateConfig[property] = result;\n        }\n      }\n      await prisma.view.update({\n        where: { id: newViewId },\n        data: {\n          description,\n          isLocked,\n          enableShare,\n          shareMeta: shareMeta ? JSON.stringify(shareMeta) : undefined,\n          ...updateConfig,\n        },\n      });\n\n      // 2. update plugin install\n      const newStorage = replaceStringByMap(pluginInstall.storage, {\n        tableIdMap,\n        fieldIdMap,\n        viewIdMap,\n      });\n      await prisma.pluginInstall.update({\n        where: { id: pluginInstallId },\n        data: {\n          storage: newStorage,\n        },\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/base-query.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { IAttachmentCellValue } from '@teable/core';\nimport { CellFormat, FieldType, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi';\nimport type { IBaseQueryJoin, IBaseQuery, IBaseQueryVo, IBaseQueryColumn } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../../types/cls';\nimport { FieldService } from '../../field/field.service';\nimport {\n  convertFieldInstanceToFieldVo,\n  createFieldInstanceByVo,\n  type IFieldInstance,\n} from '../../field/model/factory';\nimport { RecordService } from '../../record/record.service';\nimport { QueryAggregation } from './parse/aggregation';\nimport { QueryFilter } from './parse/filter';\nimport { QueryGroup } from './parse/group';\nimport { QueryOrder } from './parse/order';\nimport { QuerySelect } from './parse/select';\nimport { getQueryColumnTypeByFieldInstance } from './parse/utils';\n\n@Injectable()\nexport class BaseQueryService {\n  private logger = new Logger(BaseQueryService.name);\n\n  constructor(\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n\n    private readonly fieldService: FieldService,\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly recordService: RecordService\n  ) {}\n\n  private getQueryColumnName(field: IFieldInstance): string {\n    return field.dbFieldName;\n  }\n\n  // Quote an identifier if not already quoted\n  private quoteIdentifier(name: string): string {\n    if (!name) return name as unknown as string;\n    if (name.includes('.')) {\n      return name\n        .split('.')\n        .filter((part) => part.length > 0)\n        .map((part) => this.quoteIdentifier(part))\n        .join('.');\n    }\n    const trimmed = name.replace(/^\"+|\"+$/g, '');\n    const escaped = trimmed.replace(/\"/g, '\"\"');\n    return `\"${escaped}\"`;\n  }\n\n  // Quote a composite table name like schema.table\n  private quoteDbTableName(dbTableName: string): string {\n    return dbTableName\n      .split('.')\n      .filter((part) => part.length > 0)\n      .map((part) => this.quoteIdentifier(part))\n      .join('.');\n  }\n\n  private convertFieldMapToColumn(fieldMap: Record<string, IFieldInstance>): IBaseQueryColumn[] {\n    return Object.values(fieldMap).map((field) => {\n      const type = getQueryColumnTypeByFieldInstance(field);\n\n      return {\n        column: type === BaseQueryColumnType.Field ? this.getQueryColumnName(field) : field.id,\n        name: field.name,\n        type,\n        fieldSource:\n          type === BaseQueryColumnType.Field ? convertFieldInstanceToFieldVo(field) : undefined,\n      };\n    });\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async dbRows2Rows(\n    rows: Record<string, unknown>[],\n    columns: IBaseQueryColumn[],\n    cellFormat: CellFormat\n  ) {\n    const resRows: Record<string, unknown>[] = [];\n    for (const row of rows) {\n      const resRow: Record<string, unknown> = {};\n      for (const field of columns) {\n        if (!field.fieldSource) {\n          const value = row[field.column];\n          resRow[field.column] = row[field.column];\n          // handle bigint\n          if (typeof value === 'bigint') {\n            resRow[field.column] = Number(value);\n          } else {\n            resRow[field.column] = value;\n          }\n          continue;\n        }\n        const dbCellValue = row[field.column];\n        const fieldInstance = createFieldInstanceByVo(field.fieldSource);\n        const cellValue = fieldInstance.convertDBValue2CellValue(dbCellValue);\n\n        // number no need to convert string\n        if (typeof cellValue === 'number') {\n          resRow[field.column] = cellValue;\n          continue;\n        }\n        if (cellValue != null) {\n          resRow[field.column] =\n            cellFormat === CellFormat.Text ? fieldInstance.cellValue2String(cellValue) : cellValue;\n        }\n        if (fieldInstance.type === FieldType.Attachment) {\n          resRow[field.column] = await this.recordService.getAttachmentPresignedCellValue(\n            cellValue as IAttachmentCellValue\n          );\n        }\n      }\n      resRows.push(resRow);\n    }\n    return resRows;\n  }\n\n  async baseQuery(\n    baseId: string,\n    baseQuery: IBaseQuery,\n    cellFormat: CellFormat = CellFormat.Json\n  ): Promise<IBaseQueryVo> {\n    const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery, 0);\n    const query = queryBuilder.toQuery();\n    this.logger.log('baseQuery SQL: ', query);\n    const rows = await this.prismaService\n      .$queryRawUnsafe<{ [key in string]: unknown }[]>(query)\n      .catch((e) => {\n        this.logger.error(e);\n        throw new CustomHttpException('Query failed', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.baseQuery.queryFailed',\n            context: {\n              query,\n              message: e.message,\n            },\n          },\n        });\n      });\n    const columns = this.convertFieldMapToColumn(fieldMap);\n    return {\n      rows: await this.dbRows2Rows(rows, columns, cellFormat),\n      columns,\n    };\n  }\n\n  async parseBaseQuery(\n    baseId: string,\n    baseQuery: IBaseQuery,\n    depth: number = 0\n  ): Promise<{ queryBuilder: Knex.QueryBuilder; fieldMap: Record<string, IFieldInstance> }> {\n    if (typeof baseQuery.from === 'string') {\n      const dbTableName = await this.getDbTableName(baseId, baseQuery.from);\n      const queryBuilder = this.knex(dbTableName);\n      const fieldMap = await this.getFieldMap(baseQuery.from, dbTableName);\n      return this.parseBaseQueryFromTable(baseQuery, {\n        fieldMap,\n        queryBuilder,\n        baseId,\n        dbTableName,\n      });\n    }\n    const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery.from, depth + 1);\n    const alias = 'source_query';\n    return this.parseBaseQueryFromTable(baseQuery, {\n      fieldMap: Object.keys(fieldMap).reduce(\n        (acc, key) => {\n          const original = fieldMap[key];\n          const lastSegment = (original.dbFieldName ?? '').split('.').pop() as string;\n          const isAggregation =\n            getQueryColumnTypeByFieldInstance(original) === BaseQueryColumnType.Aggregation;\n          acc[key] = createFieldInstanceByVo({\n            ...original,\n            // 对于聚合字段，外层应按聚合别名排序/筛选，因此只保留别名本身，避免再加表别名导致歧义\n            dbFieldName: isAggregation\n              ? this.quoteIdentifier(lastSegment)\n              : `${this.quoteIdentifier(alias)}.${this.quoteIdentifier(lastSegment)}`,\n          });\n          return acc;\n        },\n        {} as Record<string, IFieldInstance>\n      ),\n      queryBuilder: this.knex(queryBuilder.as(alias)),\n      baseId,\n      dbTableName: alias,\n    });\n  }\n\n  async parseBaseQueryFromTable(\n    baseQuery: IBaseQuery,\n    context: {\n      baseId: string;\n      fieldMap: Record<string, IFieldInstance>;\n      queryBuilder: Knex.QueryBuilder;\n      dbTableName: string;\n    }\n  ): Promise<{ queryBuilder: Knex.QueryBuilder; fieldMap: Record<string, IFieldInstance> }> {\n    const { fieldMap, baseId, queryBuilder, dbTableName } = context;\n    let currentQueryBuilder = queryBuilder;\n    let currentFieldMap = fieldMap;\n    if (baseQuery.join) {\n      const { queryBuilder: joinedQueryBuilder, fieldMap: joinedFieldMap } = await this.joinTable(\n        baseQuery.join,\n        { baseId, fieldMap, queryBuilder }\n      );\n      currentQueryBuilder = joinedQueryBuilder;\n      currentFieldMap = joinedFieldMap;\n    }\n\n    const { fieldMap: filteredFieldMap, queryBuilder: filteredQueryBuilder } =\n      new QueryFilter().parse(baseQuery.where, {\n        dbProvider: this.dbProvider,\n        queryBuilder: currentQueryBuilder,\n        fieldMap: currentFieldMap,\n        currentUserId: this.cls.get('user.id'),\n      });\n    currentFieldMap = filteredFieldMap;\n    currentQueryBuilder = filteredQueryBuilder;\n\n    const { queryBuilder: groupedQueryBuilder, fieldMap: groupedFieldMap } = new QueryGroup().parse(\n      baseQuery.groupBy,\n      {\n        dbProvider: this.dbProvider,\n        queryBuilder: currentQueryBuilder,\n        fieldMap: currentFieldMap,\n        knex: this.knex,\n      }\n    );\n    currentFieldMap = groupedFieldMap;\n    currentQueryBuilder = groupedQueryBuilder;\n\n    // max limit 1000\n    currentQueryBuilder.limit(\n      baseQuery.limit && baseQuery.limit > 0 ? Math.min(baseQuery.limit, 1000) : 1000\n    );\n\n    if (baseQuery.offset) {\n      currentQueryBuilder.offset(baseQuery.offset);\n    }\n    // clear select before aggregation and clear select in group by\n    queryBuilder.clear('select');\n    const { queryBuilder: aggregatedQueryBuilder, fieldMap: aggregatedFieldMap } =\n      new QueryAggregation().parse(baseQuery.aggregation, {\n        queryBuilder: currentQueryBuilder,\n        fieldMap: currentFieldMap,\n        dbTableName,\n        dbProvider: this.dbProvider,\n      });\n    currentFieldMap = aggregatedFieldMap;\n    currentQueryBuilder = aggregatedQueryBuilder;\n\n    const { queryBuilder: orderedQueryBuilder, fieldMap: orderedFieldMap } = new QueryOrder().parse(\n      baseQuery.orderBy,\n      {\n        dbProvider: this.dbProvider,\n        queryBuilder: currentQueryBuilder,\n        fieldMap: currentFieldMap,\n      }\n    );\n    currentFieldMap = orderedFieldMap;\n    currentQueryBuilder = orderedQueryBuilder;\n\n    const { queryBuilder: selectedQueryBuilder, fieldMap: selectedFieldMap } =\n      new QuerySelect().parse(baseQuery.select, {\n        queryBuilder: currentQueryBuilder,\n        fieldMap: currentFieldMap,\n        // column must appear in the GROUP BY clause or be used in an aggregate function\n        aggregation: baseQuery.aggregation,\n        groupBy: baseQuery.groupBy,\n        knex: this.knex,\n        dbProvider: this.dbProvider,\n      });\n\n    return { queryBuilder: selectedQueryBuilder, fieldMap: selectedFieldMap };\n  }\n\n  async joinTable(\n    joins: IBaseQueryJoin[],\n    context: {\n      baseId: string;\n      fieldMap: Record<string, IFieldInstance>;\n      queryBuilder: Knex.QueryBuilder;\n    }\n  ) {\n    const { baseId, fieldMap, queryBuilder } = context;\n    let resFieldMap = { ...fieldMap };\n\n    const unquotePath = (ref: string) => ref.replace(/\"/g, '');\n    for (const join of joins) {\n      const joinTable = join.table;\n      const joinDbTableName = await this.getDbTableName(baseId, joinTable);\n      const joinFieldMap = await this.getFieldMap(joinTable, joinDbTableName);\n      const joinedField = fieldMap[join.on[0]];\n      const joinField = joinFieldMap[join.on[1]];\n      resFieldMap = { ...resFieldMap, ...joinFieldMap };\n      switch (join.type) {\n        case BaseQueryJoinType.Inner:\n          queryBuilder.innerJoin(\n            joinDbTableName,\n            this.knex.raw('?? = ??', [\n              unquotePath(joinedField.dbFieldName),\n              unquotePath(joinField.dbFieldName),\n            ])\n          );\n          break;\n        case BaseQueryJoinType.Left:\n          queryBuilder.leftJoin(\n            joinDbTableName,\n            this.knex.raw('?? = ??', [\n              unquotePath(joinedField.dbFieldName),\n              unquotePath(joinField.dbFieldName),\n            ])\n          );\n          break;\n        case BaseQueryJoinType.Right:\n          queryBuilder.rightJoin(\n            joinDbTableName,\n            this.knex.raw('?? = ??', [\n              unquotePath(joinedField.dbFieldName),\n              unquotePath(joinField.dbFieldName),\n            ])\n          );\n          break;\n        case BaseQueryJoinType.Full:\n          queryBuilder.fullOuterJoin(\n            joinDbTableName,\n            this.knex.raw('?? = ??', [\n              unquotePath(joinedField.dbFieldName),\n              unquotePath(joinField.dbFieldName),\n            ])\n          );\n          break;\n        default:\n          throw new CustomHttpException('Invalid join type', HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.baseQuery.invalidJoinType',\n              context: {\n                joinType: join.type,\n              },\n            },\n          });\n      }\n    }\n    return { queryBuilder, fieldMap: resFieldMap };\n  }\n\n  async getFieldMap(tableId: string, dbTableName?: string) {\n    const fields = await this.fieldService.getFieldInstances(tableId, {});\n    return fields.reduce(\n      (acc, field) => {\n        if (dbTableName) {\n          const qualifiedTable = this.quoteDbTableName(dbTableName);\n          const rawFieldName = field.dbFieldName ?? '';\n          const columnSegment = rawFieldName.split('.').pop() ?? rawFieldName;\n          const isSimpleIdentifier =\n            !!columnSegment && /^[\\w\"]+$/.test(columnSegment.replace(/^\"+|\"+$/g, ''));\n          field.dbFieldName =\n            columnSegment && isSimpleIdentifier\n              ? `${qualifiedTable}.${this.quoteIdentifier(columnSegment)}`\n              : rawFieldName;\n        }\n        acc[field.id] = field;\n        return acc;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n  }\n\n  private async getDbTableName(baseId: string, tableId: string) {\n    const tableMeta = await this.prismaService\n      .txClient()\n      .tableMeta.findUniqueOrThrow({\n        where: { id: tableId, baseId },\n        select: { dbTableName: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Table not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseQuery.tableNotFound',\n            context: {\n              tableId,\n              baseId,\n            },\n          },\n        });\n      });\n    return tableMeta.dbTableName;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/aggregation.ts",
    "content": "import { BaseQueryColumnType, type IQueryAggregation } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport type { IFieldInstance } from '../../../field/model/factory';\nimport { createBaseQueryFieldInstance } from './utils';\n\nexport class QueryAggregation {\n  parse(\n    aggregation: IQueryAggregation | undefined,\n    content: {\n      dbTableName: string;\n      dbProvider: IDbProvider;\n      queryBuilder: Knex.QueryBuilder;\n      fieldMap: Record<string, IFieldInstance>;\n    }\n  ): {\n    queryBuilder: Knex.QueryBuilder;\n    fieldMap: Record<string, IFieldInstance>;\n  } {\n    if (!aggregation) {\n      return { queryBuilder: content.queryBuilder, fieldMap: content.fieldMap };\n    }\n    const { queryBuilder, dbTableName, fieldMap, dbProvider } = content;\n    const notFieldMap: Record<string, IFieldInstance> = {};\n\n    aggregation.forEach((item) => {\n      notFieldMap[`${item.column}_${item.statisticFunc}`] = createBaseQueryFieldInstance(\n        BaseQueryColumnType.Aggregation,\n        {\n          id: `${item.column}_${item.statisticFunc}`,\n          name: `${fieldMap[item.column].name}.${item.statisticFunc}`,\n          dbFieldName: fieldMap[item.column].dbFieldName,\n        }\n      );\n    });\n\n    const fieldInstanceMap = { ...fieldMap, ...notFieldMap };\n    dbProvider\n      .aggregationQuery(\n        queryBuilder,\n        fieldInstanceMap,\n        aggregation.map((v) => ({\n          fieldId: v.column,\n          statisticFunc: v.statisticFunc,\n        })),\n        undefined,\n        { tableAlias: 'main_table', selectionMap: new Map(), tableDbName: dbTableName }\n      )\n      .appendBuilder();\n    return {\n      queryBuilder,\n      fieldMap: fieldInstanceMap,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/filter.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport { HttpErrorCode, type IFilter, type IFilterSet } from '@teable/core';\nimport { type IBaseQueryFilter } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { CustomHttpException } from '../../../../custom.exception';\nimport type { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport type { IFieldInstance } from '../../../field/model/factory';\n\nexport class QueryFilter {\n  parse(\n    filter: IBaseQueryFilter | undefined,\n    content: {\n      dbProvider: IDbProvider;\n      queryBuilder: Knex.QueryBuilder;\n      fieldMap: Record<string, IFieldInstance>;\n      currentUserId: string;\n    }\n  ): {\n    queryBuilder: Knex.QueryBuilder;\n    fieldMap: Record<string, IFieldInstance>;\n  } {\n    if (!filter) {\n      return {\n        queryBuilder: content.queryBuilder,\n        fieldMap: content.fieldMap,\n      };\n    }\n    const { queryBuilder, dbProvider, currentUserId, fieldMap } = content;\n    // baseQuery filter to filterQuery filter\n    const { filter: filterQuery } = this.convertQueryFilterToFilter(filter, fieldMap);\n\n    dbProvider\n      .filterQuery(queryBuilder, fieldMap, filterQuery, { withUserId: currentUserId })\n      .appendQueryBuilder();\n    return {\n      queryBuilder,\n      fieldMap,\n    };\n  }\n\n  private convertQueryFilterToFilter(\n    filter: IBaseQueryFilter,\n    fieldMap: Record<string, IFieldInstance>\n  ): {\n    filter: IFilter;\n  } {\n    if (!filter) {\n      return { filter: null };\n    }\n    // convert baseQuery filter to filterQuery filter\n    const filterSets: IFilterSet['filterSet'] = [];\n    filter.filterSet.forEach((item) => {\n      if ('filterSet' in item) {\n        const { filter } = this.convertQueryFilterToFilter(item, fieldMap);\n        filter && filterSets.push(filter);\n      } else {\n        const field = fieldMap[item.column];\n        if (!field) {\n          throw new CustomHttpException(`Field ${item.column} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.field.notFound',\n            },\n          });\n        }\n        filterSets.push({\n          isSymbol: false,\n          fieldId: item.column,\n          operator: item.operator,\n          value: item.value,\n        });\n      }\n    });\n\n    return {\n      filter: {\n        filterSet: filterSets,\n        conjunction: filter.conjunction,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/group.ts",
    "content": "import { BaseQueryColumnType, type IBaseQueryGroupBy } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport type { IFieldInstance } from '../../../field/model/factory';\n\nexport class QueryGroup {\n  parse(\n    group: IBaseQueryGroupBy | undefined,\n    content: {\n      dbProvider: IDbProvider;\n      queryBuilder: Knex.QueryBuilder;\n      fieldMap: Record<string, IFieldInstance>;\n      knex: Knex;\n    }\n  ): {\n    queryBuilder: Knex.QueryBuilder;\n    fieldMap: Record<string, IFieldInstance>;\n  } {\n    if (!group) {\n      return { queryBuilder: content.queryBuilder, fieldMap: content.fieldMap };\n    }\n    const { queryBuilder, fieldMap, dbProvider, knex } = content;\n    const fieldGroup = group.filter((v) => v.type === BaseQueryColumnType.Field);\n    const aggregationGroup = group.filter((v) => v.type === BaseQueryColumnType.Aggregation);\n    dbProvider\n      .groupQuery(\n        queryBuilder,\n        fieldMap,\n        fieldGroup.map((v) => v.column),\n        undefined,\n        undefined\n      )\n      .appendGroupBuilder();\n    aggregationGroup.forEach((v) => {\n      // Group by the aggregation column alias, quoted to preserve case\n      queryBuilder.groupBy(knex.ref(v.column));\n    });\n    return {\n      queryBuilder,\n      fieldMap,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/order.ts",
    "content": "import { type IBaseQueryOrderBy } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport type { IFieldInstance } from '../../../field/model/factory';\n\nexport class QueryOrder {\n  parse(\n    order: IBaseQueryOrderBy | undefined,\n    content: {\n      dbProvider: IDbProvider;\n      queryBuilder: Knex.QueryBuilder;\n      fieldMap: Record<string, IFieldInstance>;\n    }\n  ): {\n    queryBuilder: Knex.QueryBuilder;\n    fieldMap: Record<string, IFieldInstance>;\n  } {\n    const { queryBuilder, fieldMap, dbProvider } = content;\n    if (!order) {\n      return { queryBuilder, fieldMap };\n    }\n\n    dbProvider\n      .sortQuery(\n        queryBuilder,\n        fieldMap,\n        order.map((item) => ({\n          fieldId: item.column,\n          order: item.order,\n        })),\n        undefined,\n        undefined\n      )\n      .appendSortBuilder();\n    return { queryBuilder, fieldMap };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/select.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { BaseQueryColumnType } from '@teable/openapi';\nimport type { IQueryAggregation, IBaseQuerySelect, IBaseQueryGroupBy } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { cloneDeep, isEmpty } from 'lodash';\nimport type { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport { isUserOrLink } from '../../../../utils/is-user-or-link';\nimport type { IFieldInstance } from '../../../field/model/factory';\nimport { getQueryColumnTypeByFieldInstance } from './utils';\n\nexport class QuerySelect {\n  parse(\n    select: IBaseQuerySelect[] | undefined,\n    content: {\n      knex: Knex;\n      queryBuilder: Knex.QueryBuilder;\n      fieldMap: Record<string, IFieldInstance>;\n      aggregation: IQueryAggregation | undefined;\n      groupBy: IBaseQueryGroupBy | undefined;\n      dbProvider: IDbProvider;\n    }\n  ): { queryBuilder: Knex.QueryBuilder; fieldMap: Record<string, IFieldInstance> } {\n    const { queryBuilder, fieldMap, groupBy, aggregation, knex, dbProvider } = content;\n    let currentFieldMap = cloneDeep(fieldMap);\n\n    // column must appear in the GROUP BY clause or be used in an aggregate function\n    const groupFieldMap = this.selectGroup(queryBuilder, {\n      knex,\n      groupBy,\n      fieldMap: currentFieldMap,\n      dbProvider,\n    });\n    const allowSelectColumnIds = this.allowSelectedColumnIds(currentFieldMap, groupBy, aggregation);\n    if (aggregation?.length || groupBy?.length) {\n      currentFieldMap = Object.entries(currentFieldMap).reduce(\n        (acc, current) => {\n          const [key, value] = current;\n          if (allowSelectColumnIds.includes(key)) {\n            acc[key] = value;\n          }\n          return acc;\n        },\n        {} as Record<string, IFieldInstance>\n      );\n    }\n\n    const aggregationColumn = aggregation?.map((v) => `${v.column}_${v.statisticFunc}`) || [];\n    if (select) {\n      select.forEach((cur) => {\n        const field = currentFieldMap[cur.column];\n        if (field && getQueryColumnTypeByFieldInstance(field) === BaseQueryColumnType.Field) {\n          const alias = (cur.alias ? cur.alias : field.id).replace(/\\?/g, '_');\n          // Use raw to avoid knex double-quoting an already quoted identifier\n          queryBuilder.select(knex.raw(`${field.dbFieldName} as ??`, [alias]));\n          currentFieldMap[cur.column].name = alias;\n          currentFieldMap[cur.column].dbFieldName = alias;\n        } else if (field && !aggregationColumn.includes(cur.column)) {\n          // filter aggregation column, because aggregation column has selected when parse aggregation\n          // quote alias to preserve case for aggregated columns coming from subqueries\n          queryBuilder.select(knex.raw('??', [cur.column]));\n        } else if (field) {\n          // aggregation field id as alias\n          currentFieldMap[cur.column].dbFieldName = cur.column;\n        }\n      });\n    } else {\n      Object.values(currentFieldMap).forEach((cur) => {\n        if (getQueryColumnTypeByFieldInstance(cur) === BaseQueryColumnType.Field) {\n          const alias = cur.id;\n          queryBuilder.select(knex.raw(`${cur.dbFieldName} as ??`, [alias]));\n          currentFieldMap[cur.id].dbFieldName = alias;\n        } else {\n          // aggregation field id as alias\n          currentFieldMap[cur.id].dbFieldName = cur.id;\n          !aggregationColumn.includes(cur.id) && queryBuilder.select(knex.raw('??', [cur.id]));\n        }\n      });\n    }\n    // delete not selected field from fieldMap\n    // tips: The current query has an aggregation and cannot be deleted. ( select * count(fld) as fld_count from xxxxx) => fld_count cannot be deleted\n    if (select) {\n      Object.keys(currentFieldMap).forEach((key) => {\n        if (!select.find((s) => s.column === key)) {\n          if (aggregationColumn.includes(key)) {\n            // aggregation field id as alias\n            currentFieldMap[key].dbFieldName = key;\n            return;\n          }\n          delete currentFieldMap[key];\n        }\n      });\n    }\n    return {\n      queryBuilder,\n      fieldMap: {\n        ...currentFieldMap,\n        ...groupFieldMap,\n      },\n    };\n  }\n\n  allowSelectedColumnIds(\n    fieldMap: Record<string, IFieldInstance>,\n    groupBy: IBaseQueryGroupBy | undefined,\n    aggregation: IQueryAggregation | undefined\n  ) {\n    if (!aggregation && !groupBy) {\n      return Object.keys(fieldMap);\n    }\n    return aggregation?.map((v) => `${v.column}_${v.statisticFunc}`) || [];\n  }\n\n  private extractGroupByColumnMap(\n    queryBuilder: Knex.QueryBuilder,\n    fieldMap: Record<string, IFieldInstance>\n  ): Record<string, any> {\n    const groupByStatements = (queryBuilder as any)._statements.filter(\n      (statement: any) => statement.grouping === 'group'\n    );\n\n    // get the outermost GROUP BY columns\n    const currentGroupByColumns = groupByStatements.flatMap((statement: any) => statement.value);\n    const fieldIdDbFieldNamesMap = Object.values(fieldMap).reduce(\n      (acc, cur) => {\n        acc[cur.dbFieldName] = cur.id;\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n    const fieldDbFieldNames = Object.keys(fieldIdDbFieldNamesMap);\n    // Also build a map from field id to dbFieldName for easier matching when GROUP BY uses aliases\n    const fieldIdToDbFieldNameMap = Object.values(fieldMap).reduce(\n      (acc, cur) => {\n        acc[cur.id] = cur.dbFieldName;\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n    return currentGroupByColumns.reduce(\n      (acc: Record<string, any>, column: any) => {\n        let matchedFieldId: string | undefined;\n\n        if (typeof column === 'string') {\n          // Case 1: GROUP BY uses a plain alias/id (e.g., aggregation alias like fldX_sum)\n          if (fieldIdToDbFieldNameMap[column]) {\n            matchedFieldId = column;\n          } else {\n            // Case 2: GROUP BY uses the full qualified dbFieldName\n            const dbFieldName = fieldDbFieldNames.find((name) => column === name);\n            if (dbFieldName) {\n              matchedFieldId = fieldIdDbFieldNamesMap[dbFieldName];\n            }\n          }\n        } else {\n          // knex may store complex refs as objects; try matching by dbFieldName occurrence\n          const dbFieldName = fieldDbFieldNames.find(\n            (name) => column.sql?.includes(name) || column.bindings?.includes(name)\n          );\n          if (dbFieldName) {\n            matchedFieldId = fieldIdDbFieldNamesMap[dbFieldName];\n          }\n        }\n\n        if (matchedFieldId) {\n          acc[matchedFieldId] = column;\n        }\n        return acc;\n      },\n      {} as Record<string, any>\n    );\n  }\n\n  selectGroup(\n    queryBuilder: Knex.QueryBuilder,\n    content: {\n      groupBy: IBaseQueryGroupBy | undefined;\n      fieldMap: Record<string, IFieldInstance>;\n      knex: Knex;\n      dbProvider: IDbProvider;\n    }\n  ): Record<string, IFieldInstance> | undefined {\n    const { groupBy, fieldMap, knex, dbProvider } = content;\n    if (!groupBy) {\n      return;\n    }\n    const groupFieldMap = Object.values(fieldMap).reduce(\n      (acc, field) => {\n        if (groupBy?.map((v) => v.column).includes(field.id)) {\n          acc[field.id] = field;\n        }\n        return acc;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n    const groupByColumnMap = this.extractGroupByColumnMap(queryBuilder, groupFieldMap);\n    Object.entries(groupByColumnMap).forEach(([fieldId, column]) => {\n      if (isUserOrLink(fieldMap[fieldId].type)) {\n        dbProvider.baseQuery().jsonSelect(queryBuilder, fieldMap[fieldId].dbFieldName, fieldId);\n        return;\n      }\n      queryBuilder.select(\n        typeof column === 'string'\n          ? knex.raw(`${column} as ??`, [fieldId])\n          : knex.raw(`${column.sql} as ??`, [\n              ...(Array.isArray((column as any).bindings) ? (column as any).bindings : []),\n              fieldId,\n            ])\n      );\n    });\n\n    // Ensure aggregation aliases used in GROUP BY are also selected even if not detected above\n    if (groupBy && groupBy.length) {\n      const aggregationIds = groupBy\n        .filter((v) => v.type === BaseQueryColumnType.Aggregation)\n        .map((v) => v.column);\n      aggregationIds.forEach((id) => {\n        if (!groupByColumnMap[id]) {\n          queryBuilder.select(knex.raw('?? as ??', [id, id]));\n        }\n      });\n    }\n\n    const res = cloneDeep(groupFieldMap);\n    Object.values(res).forEach((field) => {\n      field.dbFieldName = field.id;\n    });\n    return res;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base-query/parse/utils.ts",
    "content": "import {\n  CellValueType,\n  DbFieldType,\n  FieldType,\n  getRandomString,\n  NumberFieldCore,\n} from '@teable/core';\nimport { BaseQueryColumnType } from '@teable/openapi';\nimport type { IFieldInstance } from '../../../field/model/factory';\nimport { createFieldInstanceByVo } from '../../../field/model/factory';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst AGGREGATION_FIELD_INSTANCE_DESC = getRandomString(10);\n\nexport const getQueryColumnTypeByFieldInstance = (field: IFieldInstance): BaseQueryColumnType => {\n  if (field.description === AGGREGATION_FIELD_INSTANCE_DESC) {\n    return BaseQueryColumnType.Aggregation;\n  }\n  return BaseQueryColumnType.Field;\n};\n\nexport const createBaseQueryFieldInstance = (\n  type: BaseQueryColumnType,\n  {\n    id,\n    name,\n    dbFieldName,\n  }: {\n    id: string;\n    name: string;\n    dbFieldName: string;\n  }\n): IFieldInstance => {\n  if (type === BaseQueryColumnType.Aggregation) {\n    return createFieldInstanceByVo({\n      id: id,\n      dbFieldName,\n      name,\n      description: AGGREGATION_FIELD_INSTANCE_DESC,\n      options: NumberFieldCore.defaultOptions(),\n      type: FieldType.Number,\n      cellValueType: CellValueType.Number,\n      dbFieldType: DbFieldType.Real,\n    });\n  }\n  throw new Error(`Not implemented(createBaseQueryFieldInstance) type: ${type}`);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Res } from '@nestjs/common';\nimport type { IBaseRole } from '@teable/core';\nimport {\n  createBaseRoSchema,\n  duplicateBaseRoSchema,\n  ICreateBaseRo,\n  IUpdateBaseRo,\n  updateBaseRoSchema,\n  IDuplicateBaseRo,\n  createBaseFromTemplateRoSchema,\n  ICreateBaseFromTemplateRo,\n  updateOrderRoSchema,\n  IUpdateOrderRo,\n  createBaseInvitationLinkRoSchema,\n  CreateBaseInvitationLinkRo,\n  updateBaseInvitationLinkRoSchema,\n  emailBaseInvitationRoSchema,\n  updateBaseCollaborateRoSchema,\n  EmailBaseInvitationRo,\n  UpdateBaseCollaborateRo,\n  UpdateBaseInvitationLinkRo,\n  CollaboratorType,\n  listBaseCollaboratorRoSchema,\n  ListBaseCollaboratorRo,\n  deleteBaseCollaboratorRoSchema,\n  DeleteBaseCollaboratorRo,\n  addBaseCollaboratorRoSchema,\n  AddBaseCollaboratorRo,\n  listBaseCollaboratorUserRoSchema,\n  IListBaseCollaboratorUserRo,\n  ImportBaseRo,\n  importBaseRoSchema,\n  moveBaseRoSchema,\n  IMoveBaseRo,\n  publishBaseRoSchema,\n  IPublishBaseRo,\n} from '@teable/openapi';\nimport type {\n  CreateBaseInvitationLinkVo,\n  EmailInvitationVo,\n  IBaseErdVo,\n  ICreateBaseVo,\n  IDbConnectionVo,\n  IGetBaseAllVo,\n  IGetBasePermissionVo,\n  IGetBaseVo,\n  IGetSharedBaseVo,\n  IImportBaseVo,\n  IListBaseCollaboratorUserVo,\n  IUpdateBaseVo,\n  ListBaseCollaboratorVo,\n  ListBaseInvitationLinkVo,\n  UpdateBaseInvitationLinkVo,\n  ICreateBaseFromTemplateVo,\n} from '@teable/openapi';\nimport { Response as ExpressResponse } from 'express';\nimport { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../event-emitter/events';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { ResourceMeta } from '../auth/decorators/resource_meta.decorator';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { InvitationService } from '../invitation/invitation.service';\nimport { BaseExportService } from './base-export.service';\nimport { BaseImportService } from './base-import.service';\nimport { BaseService } from './base.service';\nimport { DbConnectionService } from './db-connection.service';\n\n@Controller('api/base/')\nexport class BaseController {\n  constructor(\n    private readonly baseService: BaseService,\n    private readonly baseExportService: BaseExportService,\n    private readonly baseImportService: BaseImportService,\n    private readonly dbConnectionService: DbConnectionService,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly invitationService: InvitationService\n  ) {}\n\n  @Post()\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  @EmitControllerEvent(Events.BASE_CREATE)\n  async createBase(\n    @Body(new ZodValidationPipe(createBaseRoSchema))\n    createBaseRo: ICreateBaseRo\n  ) {\n    return await this.baseService.createBase(createBaseRo);\n  }\n\n  @Post('import')\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  @EmitControllerEvent(Events.BASE_CREATE)\n  async importBase(\n    @Body(new ZodValidationPipe(importBaseRoSchema))\n    importBaseRo: ImportBaseRo\n  ): Promise<IImportBaseVo> {\n    return await this.baseImportService.importBase(importBaseRo);\n  }\n\n  @Post('import-stream')\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  async importBaseStream(\n    @Body(new ZodValidationPipe(importBaseRoSchema))\n    importBaseRo: ImportBaseRo,\n    @Res() res: ExpressResponse\n  ) {\n    const sseHeartbeatMs = 15_000;\n    res.setHeader('Content-Type', 'text/event-stream');\n    res.setHeader('Cache-Control', 'no-cache, no-transform');\n    res.setHeader('Connection', 'keep-alive');\n    res.setHeader('X-Accel-Buffering', 'no');\n    res.flushHeaders();\n\n    const isStreamClosed = () => res.writableEnded || res.destroyed;\n    const sendEvent = (data: unknown) => {\n      if (isStreamClosed()) return;\n      res.write(`data: ${JSON.stringify(data)}\\n\\n`);\n      (res as ExpressResponse & { flush?: () => void }).flush?.();\n    };\n    const heartbeat = setInterval(() => {\n      if (isStreamClosed()) return;\n      res.write(': ping\\n\\n');\n      (res as ExpressResponse & { flush?: () => void }).flush?.();\n    }, sseHeartbeatMs);\n    res.on('close', () => clearInterval(heartbeat));\n\n    try {\n      const result = await this.baseImportService.importBase(\n        importBaseRo,\n        (phase: string, detail?: string) => {\n          sendEvent({ type: 'progress', phase, detail });\n        }\n      );\n\n      sendEvent({ type: 'done', data: result });\n    } catch (error) {\n      sendEvent({\n        type: 'error',\n        message: error instanceof Error ? error.message : 'Unknown import error',\n      });\n    } finally {\n      clearInterval(heartbeat);\n      res.end();\n    }\n  }\n\n  @Post('duplicate')\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  @EmitControllerEvent(Events.BASE_CREATE)\n  async duplicateBase(\n    @Body(new ZodValidationPipe(duplicateBaseRoSchema))\n    duplicateBaseRo: IDuplicateBaseRo\n  ): Promise<ICreateBaseVo> {\n    return await this.baseService.duplicateBase(duplicateBaseRo);\n  }\n\n  @Post('create-from-template')\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  @EmitControllerEvent(Events.BASE_CREATE)\n  async createBaseFromTemplate(\n    @Body(new ZodValidationPipe(createBaseFromTemplateRoSchema))\n    createBaseFromTemplateRo: ICreateBaseFromTemplateRo\n  ): Promise<ICreateBaseFromTemplateVo> {\n    return await this.baseService.createBaseFromTemplate(createBaseFromTemplateRo);\n  }\n\n  @Patch(':baseId')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.BASE_UPDATE)\n  async updateBase(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(updateBaseRoSchema))\n    updateBaseRo: IUpdateBaseRo\n  ): Promise<IUpdateBaseVo> {\n    return await this.baseService.updateBase(baseId, updateBaseRo);\n  }\n\n  @Put(':baseId/order')\n  @Permissions('base|update')\n  async updateOrder(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo\n  ) {\n    return await this.baseService.updateOrder(baseId, updateOrderRo);\n  }\n\n  @Get('shared-base')\n  async getSharedBase(): Promise<IGetSharedBaseVo> {\n    return this.collaboratorService.getSharedBase();\n  }\n\n  @Permissions('base|read')\n  @Get(':baseId')\n  @AllowAnonymous(AllowAnonymousType.PUBLIC)\n  async getBaseById(@Param('baseId') baseId: string): Promise<IGetBaseVo> {\n    return await this.baseService.getBaseById(baseId);\n  }\n\n  @Permissions('base|read_all')\n  @Get('access/all')\n  async getAllBase(): Promise<IGetBaseAllVo> {\n    return this.baseService.getAllBaseList();\n  }\n\n  @Delete(':baseId')\n  @Permissions('base|delete')\n  @EmitControllerEvent(Events.BASE_DELETE)\n  async deleteBase(@Param('baseId') baseId: string) {\n    return await this.baseService.deleteBase(baseId);\n  }\n\n  @Permissions('base|db_connection')\n  @Post(':baseId/connection')\n  async createDbConnection(@Param('baseId') baseId: string): Promise<IDbConnectionVo | null> {\n    return await this.dbConnectionService.create(baseId);\n  }\n\n  @Permissions('base|db_connection')\n  @Get(':baseId/connection')\n  async getDBConnection(@Param('baseId') baseId: string): Promise<IDbConnectionVo | null> {\n    return await this.dbConnectionService.retrieve(baseId);\n  }\n\n  @Permissions('base|db_connection')\n  @Delete(':baseId/connection')\n  async deleteDbConnection(@Param('baseId') baseId: string) {\n    await this.dbConnectionService.remove(baseId);\n    return null;\n  }\n\n  @Permissions('base|read')\n  @Get(':baseId/collaborators')\n  async listCollaborator(\n    @Param('baseId') baseId: string,\n    @Query(new ZodValidationPipe(listBaseCollaboratorRoSchema)) options: ListBaseCollaboratorRo\n  ): Promise<ListBaseCollaboratorVo> {\n    return {\n      collaborators: await this.collaboratorService.getListByBase(baseId, options),\n      total: await this.collaboratorService.getTotalBase(baseId, options),\n    };\n  }\n\n  @Permissions('base|read')\n  @Get(':baseId/permission')\n  @AllowAnonymous(AllowAnonymousType.PUBLIC)\n  async getPermission(): Promise<IGetBasePermissionVo> {\n    return await this.baseService.getPermission();\n  }\n\n  @Permissions('base|invite_link')\n  @Post(':baseId/invitation/link')\n  async createInvitationLink(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(createBaseInvitationLinkRoSchema))\n    baseInvitationLinkRo: CreateBaseInvitationLinkRo\n  ): Promise<CreateBaseInvitationLinkVo> {\n    const res = await this.invitationService.generateInvitationLink({\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n      role: baseInvitationLinkRo.role,\n    });\n    return {\n      ...res,\n      role: res.role as IBaseRole,\n    };\n  }\n\n  @Permissions('base|invite_link')\n  @Delete(':baseId/invitation/link/:invitationId')\n  async deleteInvitationLink(\n    @Param('baseId') baseId: string,\n    @Param('invitationId') invitationId: string\n  ): Promise<void> {\n    return this.invitationService.deleteInvitationLink({\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n      invitationId,\n    });\n  }\n\n  @Permissions('base|invite_link')\n  @Patch(':baseId/invitation/link/:invitationId')\n  async updateInvitationLink(\n    @Param('baseId') baseId: string,\n    @Param('invitationId') invitationId: string,\n    @Body(new ZodValidationPipe(updateBaseInvitationLinkRoSchema))\n    updateSpaceInvitationLinkRo: UpdateBaseInvitationLinkRo\n  ): Promise<UpdateBaseInvitationLinkVo> {\n    const res = await this.invitationService.updateInvitationLink({\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n      invitationId,\n      role: updateSpaceInvitationLinkRo.role,\n    });\n\n    return {\n      ...res,\n      role: res.role as IBaseRole,\n    };\n  }\n\n  @Permissions('base|invite_link')\n  @Get(':baseId/invitation/link')\n  async listInvitationLink(@Param('baseId') baseId: string): Promise<ListBaseInvitationLinkVo> {\n    const res = this.invitationService.getInvitationLink(baseId, CollaboratorType.Base);\n    return res as unknown as ListBaseInvitationLinkVo;\n  }\n\n  @Permissions('base|invite_email')\n  @Post(':baseId/invitation/email')\n  async emailInvitation(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(emailBaseInvitationRoSchema))\n    emailBaseInvitationRo: EmailBaseInvitationRo\n  ): Promise<EmailInvitationVo> {\n    return this.invitationService.emailInvitationByBase(baseId, emailBaseInvitationRo);\n  }\n\n  @Patch(':baseId/collaborators')\n  async updateCollaborator(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(updateBaseCollaborateRoSchema))\n    updateBaseCollaborateRo: UpdateBaseCollaborateRo\n  ): Promise<void> {\n    await this.collaboratorService.updateCollaborator({\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n      ...updateBaseCollaborateRo,\n    });\n  }\n\n  @Delete(':baseId/collaborators')\n  async deleteCollaborator(\n    @Param('baseId') baseId: string,\n    @Query(new ZodValidationPipe(deleteBaseCollaboratorRoSchema))\n    deleteBaseCollaboratorRo: DeleteBaseCollaboratorRo\n  ): Promise<void> {\n    await this.collaboratorService.deleteCollaborator({\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n      ...deleteBaseCollaboratorRo,\n    });\n  }\n\n  @Delete(':baseId/permanent')\n  @EmitControllerEvent(Events.BASE_DELETE)\n  async permanentDeleteBase(@Param('baseId') baseId: string) {\n    await this.baseService.permanentDeleteBase(baseId);\n    return { baseId, permanent: true };\n  }\n\n  @Post(':baseId/collaborator')\n  async addCollaborators(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(addBaseCollaboratorRoSchema))\n    addBaseCollaboratorRo: AddBaseCollaboratorRo\n  ) {\n    return await this.collaboratorService.addBaseCollaborators(baseId, addBaseCollaboratorRo);\n  }\n\n  @Permissions('base|read')\n  @Get(':baseId/collaborators/users')\n  async getUserCollaborators(\n    @Param('baseId') baseId: string,\n    @Query(new ZodValidationPipe(listBaseCollaboratorUserRoSchema))\n    listBaseCollaboratorUserRo: IListBaseCollaboratorUserRo\n  ): Promise<IListBaseCollaboratorUserVo> {\n    return {\n      users: await this.collaboratorService.getUserCollaborators(\n        baseId,\n        listBaseCollaboratorUserRo\n      ),\n      total: await this.collaboratorService.getUserCollaboratorsTotal(\n        baseId,\n        listBaseCollaboratorUserRo\n      ),\n    };\n  }\n\n  @Permissions('base|read')\n  @Get(':baseId/export')\n  async exportBase(@Param('baseId') baseId: string, @Query('includeData') includeData?: string) {\n    const includeDataValue =\n      includeData === undefined ? true : !['false', '0'].includes(includeData.toLowerCase());\n    return await this.baseExportService.exportBaseZip(baseId, includeDataValue);\n  }\n\n  @Put(':baseId/move')\n  @Permissions('space|update')\n  async moveBase(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(moveBaseRoSchema)) moveBaseRo: IMoveBaseRo\n  ) {\n    await this.baseService.moveBase(baseId, moveBaseRo);\n  }\n\n  @Permissions('base|update')\n  @Get(':baseId/erd')\n  async generateBaseErd(@Param('baseId') baseId: string): Promise<IBaseErdVo> {\n    return await this.baseService.generateBaseErd(baseId);\n  }\n\n  @Permissions('base|update')\n  @Post(':baseId/publish')\n  async publishBase(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(publishBaseRoSchema)) publishBaseRo: IPublishBaseRo\n  ) {\n    return await this.baseService.publishBase(baseId, publishBaseRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { AttachmentsStorageModule } from '../attachments/attachments-storage.module';\nimport { StorageModule } from '../attachments/plugins/storage.module';\nimport { CanaryModule } from '../canary';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { FieldDuplicateModule } from '../field/field-duplicate/field-duplicate.module';\nimport { FieldModule } from '../field/field.module';\nimport { FieldOpenApiModule } from '../field/open-api/field-open-api.module';\nimport { GraphModule } from '../graph/graph.module';\nimport { InvitationModule } from '../invitation/invitation.module';\nimport { NotificationModule } from '../notification/notification.module';\nimport { ComputedModule } from '../record/computed/computed.module';\nimport { RecordModule } from '../record/record.module';\nimport { TableOpenApiModule } from '../table/open-api/table-open-api.module';\nimport { TableDuplicateService } from '../table/table-duplicate.service';\nimport { TableModule } from '../table/table.module';\nimport { ViewOpenApiModule } from '../view/open-api/view-open-api.module';\nimport { BaseDuplicateService } from './base-duplicate.service';\nimport { BaseExportService } from './base-export.service';\nimport { BaseImportAttachmentsCsvModule } from './base-import-processor/base-import-attachments-csv.module';\nimport { BaseImportAttachmentsModule } from './base-import-processor/base-import-attachments.module';\nimport { BaseImportCsvModule } from './base-import-processor/base-import-csv.module';\nimport { BaseImportService } from './base-import.service';\nimport { BaseQueryService } from './base-query/base-query.service';\nimport { BaseController } from './base.controller';\nimport { BaseService } from './base.service';\nimport { DbConnectionService } from './db-connection.service';\n\n@Module({\n  controllers: [BaseController],\n  imports: [\n    CanaryModule,\n    CollaboratorModule,\n    FieldModule,\n    FieldOpenApiModule,\n    FieldDuplicateModule,\n    TableModule,\n    ViewOpenApiModule,\n    InvitationModule,\n    TableOpenApiModule,\n    RecordModule,\n    ComputedModule,\n    StorageModule,\n    AttachmentsStorageModule,\n    NotificationModule,\n    BaseImportAttachmentsModule,\n    BaseImportCsvModule,\n    BaseImportAttachmentsCsvModule,\n    GraphModule,\n  ],\n  providers: [\n    DbProvider,\n    BaseService,\n    BaseExportService,\n    BaseImportService,\n    DbConnectionService,\n    BaseDuplicateService,\n    BaseQueryService,\n    TableDuplicateService,\n  ],\n  exports: [\n    BaseService,\n    DbConnectionService,\n    BaseDuplicateService,\n    BaseExportService,\n    BaseImportService,\n    BaseQueryService,\n  ],\n})\nexport class BaseModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { BaseModule } from './base.module';\nimport { BaseService } from './base.service';\n\ndescribe('BaseService', () => {\n  let service: BaseService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, BaseModule],\n    }).compile();\n\n    service = module.get<BaseService>(BaseService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/base.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport {\n  ActionPrefix,\n  actionPrefixMap,\n  generateBaseId,\n  HttpErrorCode,\n  Role,\n  generateTemplateId,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IBaseErdVo,\n  ICreateBaseFromTemplateRo,\n  ICreateBaseFromTemplateVo,\n  ICreateBaseRo,\n  IDuplicateBaseRo,\n  IGetBasePermissionVo,\n  IMoveBaseRo,\n  IPublishBaseRo,\n  IUpdateBaseRo,\n  IUpdateOrderRo,\n} from '@teable/openapi';\nimport {\n  CollaboratorType,\n  ResourceType,\n  BaseNodeResourceType,\n  BaseDuplicateMode,\n  UploadType,\n} from '@teable/openapi';\nimport { isNumber, keyBy, pick, uniq } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../types/cls';\nimport { getMaxLevelRole } from '../../utils/get-max-level-role';\nimport { updateOrder } from '../../utils/update-order';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport { ATTACHMENT_LG_THUMBNAIL_HEIGHT } from '../attachments/constant';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { PermissionService } from '../auth/permission.service';\nimport { CanaryService } from '../canary';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { GraphService } from '../graph/graph.service';\nimport { TableOpenApiService } from '../table/open-api/table-open-api.service';\nimport { BaseDuplicateService } from './base-duplicate.service';\nimport { replaceDefaultUrl } from './utils';\n\n@Injectable()\nexport class BaseService {\n  private logger = new Logger(BaseService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly baseDuplicateService: BaseDuplicateService,\n    private readonly permissionService: PermissionService,\n    private readonly tableOpenApiService: TableOpenApiService,\n    private readonly graphService: GraphService,\n    private readonly attachmentsStorageService: AttachmentsStorageService,\n    private readonly canaryService: CanaryService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  private async getRoleByBaseId(baseId: string, spaceId: string) {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n\n    const collaborators = await this.prismaService.collaborator.findMany({\n      where: {\n        resourceId: { in: [baseId, spaceId] },\n        principalId: { in: [userId, ...(departmentIds || [])] },\n      },\n    });\n\n    if (!collaborators.length) {\n      throw new CustomHttpException('Cannot access base', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.base.cannotAccess',\n          context: {\n            baseId,\n          },\n        },\n      });\n    }\n    const role = getMaxLevelRole(collaborators);\n    const collaborator = collaborators.find((c) => c.roleName === role);\n    return {\n      role: role,\n      collaboratorType: collaborator?.resourceType as CollaboratorType,\n    };\n  }\n\n  async getBaseById(baseId: string) {\n    const base = await this.prismaService.base\n      .findFirstOrThrow({\n        select: {\n          id: true,\n          name: true,\n          icon: true,\n          spaceId: true,\n          createdBy: true,\n        },\n        where: {\n          id: baseId,\n          deletedTime: null,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.base.notFound',\n          },\n        });\n      });\n    const template = await this.cls.get('template');\n    const baseShare = await this.cls.get('baseShare');\n    const { role, collaboratorType } =\n      template || baseShare\n        ? { role: Role.Viewer, collaboratorType: CollaboratorType.Base }\n        : await this.getRoleByBaseId(baseId, base.spaceId);\n\n    // Check if this base's space is in canary release\n    const isCanary = await this.canaryService.isSpaceInCanary(base.spaceId);\n\n    return {\n      ...base,\n      role,\n      collaboratorType,\n      template:\n        template?.baseId === baseId\n          ? { id: template.id, headers: this.permissionService.generateTemplateHeader(template.id) }\n          : undefined,\n      isCanary: isCanary || undefined, // Only include if true\n    };\n  }\n\n  async getAllBaseList() {\n    const { spaceIds, baseIds, roleMap } =\n      await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();\n    const baseList = await this.prismaService.base.findMany({\n      select: {\n        id: true,\n        name: true,\n        order: true,\n        spaceId: true,\n        icon: true,\n        createdBy: true,\n        createdTime: true,\n        lastModifiedTime: true,\n      },\n      where: {\n        deletedTime: null,\n        OR: [{ id: { in: baseIds } }, { spaceId: { in: spaceIds }, space: { deletedTime: null } }],\n      },\n      orderBy: [{ spaceId: 'asc' }, { order: 'asc' }],\n    });\n\n    if (!baseList.length) {\n      return [];\n    }\n\n    const baseSpaceIds = uniq(baseList.map((base) => base.spaceId));\n    const { validCreatorSet, spaceOwnerMap } =\n      await this.collaboratorService.buildSpaceOwnerContext(baseSpaceIds);\n\n    const allUserIds = uniq([...baseList.map((base) => base.createdBy), ...spaceOwnerMap.values()]);\n    const userList = await this.prismaService.user.findMany({\n      where: { id: { in: allUserIds } },\n      select: { id: true, name: true, avatar: true },\n    });\n\n    const userMap = keyBy(userList, 'id');\n\n    return baseList.map((base) => {\n      const isCreatorInSpace = validCreatorSet.has(`${base.spaceId}:${base.createdBy}`);\n      const displayUserId = isCreatorInSpace ? base.createdBy : spaceOwnerMap.get(base.spaceId);\n      const displayUser = displayUserId ? userMap[displayUserId] : undefined;\n\n      return {\n        ...base,\n        role: roleMap[base.id] || roleMap[base.spaceId],\n        lastModifiedTime: base.lastModifiedTime?.toISOString(),\n        createdTime: base.createdTime?.toISOString(),\n        createdUser: displayUser\n          ? {\n              ...displayUser,\n              avatar: displayUser.avatar && getPublicFullStorageUrl(displayUser.avatar),\n            }\n          : undefined,\n      };\n    });\n  }\n\n  private async getMaxOrder(spaceId: string) {\n    const spaceAggregate = await this.prismaService.base.aggregate({\n      where: { spaceId, deletedTime: null },\n      _max: { order: true },\n    });\n    return spaceAggregate._max.order || 0;\n  }\n\n  async createBase(createBaseRo: ICreateBaseRo) {\n    const userId = this.cls.get('user.id');\n    const { name, spaceId, icon } = createBaseRo;\n\n    return this.prismaService.$transaction(async (prisma) => {\n      const order = (await this.getMaxOrder(spaceId)) + 1;\n\n      const base = await prisma.base.create({\n        data: {\n          id: generateBaseId(),\n          name: name || 'Untitled Base',\n          spaceId,\n          order,\n          icon,\n          createdBy: userId,\n        },\n        select: {\n          id: true,\n          name: true,\n          icon: true,\n          spaceId: true,\n        },\n      });\n\n      const sqlList = this.dbProvider.createSchema(base.id);\n      if (sqlList) {\n        for (const sql of sqlList) {\n          await prisma.$executeRawUnsafe(sql);\n        }\n      }\n\n      return base;\n    });\n  }\n\n  async updateBase(baseId: string, updateBaseRo: IUpdateBaseRo) {\n    const userId = this.cls.get('user.id');\n\n    return this.prismaService.base.update({\n      data: {\n        ...updateBaseRo,\n        lastModifiedBy: userId,\n      },\n      select: {\n        id: true,\n        name: true,\n        spaceId: true,\n        icon: true,\n      },\n      where: {\n        id: baseId,\n        deletedTime: null,\n      },\n    });\n  }\n\n  async shuffle(spaceId: string) {\n    const bases = await this.prismaService.base.findMany({\n      where: { spaceId, deletedTime: null },\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n\n    this.logger.log(`lucky base shuffle! ${spaceId}`, 'shuffle');\n\n    await this.prismaService.$tx(async (prisma) => {\n      for (let i = 0; i < bases.length; i++) {\n        const base = bases[i];\n        await prisma.base.update({\n          data: { order: i },\n          where: { id: base.id },\n        });\n      }\n    });\n  }\n\n  async updateOrder(baseId: string, orderRo: IUpdateOrderRo) {\n    const { anchorId, position } = orderRo;\n\n    const base = await this.prismaService.base\n      .findFirstOrThrow({\n        select: { spaceId: true, order: true, id: true },\n        where: { id: baseId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.base.notFound',\n          },\n        });\n      });\n\n    const anchorBase = await this.prismaService.base\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { spaceId: base.spaceId, id: anchorId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Anchor base not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.base.anchorNotFound',\n            context: {\n              anchorId,\n            },\n          },\n        });\n      });\n\n    await updateOrder({\n      query: base.spaceId,\n      position,\n      item: base,\n      anchorItem: anchorBase,\n      getNextItem: async (whereOrder, align) => {\n        return this.prismaService.base.findFirst({\n          select: { order: true, id: true },\n          where: {\n            spaceId: base.spaceId,\n            deletedTime: null,\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (_, id, data) => {\n        await this.prismaService.base.update({\n          data: { order: data.newOrder },\n          where: { id },\n        });\n      },\n      shuffle: this.shuffle.bind(this),\n    });\n  }\n\n  async deleteBase(baseId: string) {\n    const userId = this.cls.get('user.id');\n\n    await this.prismaService.base.update({\n      data: { deletedTime: new Date(), lastModifiedBy: userId },\n      where: { id: baseId, deletedTime: null },\n    });\n  }\n\n  async duplicateBase(duplicateBaseRo: IDuplicateBaseRo) {\n    const { fromBaseId } = duplicateBaseRo;\n\n    // Regular permission check, base update permission\n    await this.checkBaseUpdatePermission(fromBaseId);\n\n    this.logger.log(`base-duplicate-service: Start to duplicating base: ${fromBaseId}`);\n\n    return await this.prismaService.$tx(\n      async () => {\n        const result = await this.baseDuplicateService.duplicateBase(duplicateBaseRo);\n        return result.base;\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n  }\n\n  private async checkBaseUpdatePermission(baseId: string) {\n    // First check if the user has the base read permission\n    await this.permissionService.validPermissions(baseId, ['base|update']);\n\n    // Then check the token permissions if the request was made with a token\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      await this.permissionService.validPermissions(baseId, ['base|update'], accessTokenId);\n    }\n  }\n\n  private async checkBaseCreatePermission(spaceId: string) {\n    await this.permissionService.validPermissions(spaceId, ['base|create']);\n\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (accessTokenId) {\n      await this.permissionService.validPermissions(spaceId, ['base|create'], accessTokenId);\n    }\n  }\n\n  async createBaseFromTemplate(\n    createBaseFromTemplateRo: ICreateBaseFromTemplateRo\n  ): Promise<ICreateBaseFromTemplateVo> {\n    const { spaceId, templateId, withRecords, baseId } = createBaseFromTemplateRo;\n    const template = await this.prismaService.template.findUniqueOrThrow({\n      where: { id: templateId },\n      select: {\n        snapshot: true,\n        name: true,\n        publishInfo: true,\n      },\n    });\n\n    if (baseId) {\n      // check the base update permission\n      await this.checkBaseUpdatePermission(baseId);\n\n      const base = await this.prismaService.base.findUniqueOrThrow({\n        where: { id: baseId, deletedTime: null },\n        select: {\n          spaceId: true,\n        },\n      });\n\n      if (base.spaceId !== spaceId) {\n        throw new CustomHttpException(\n          'BaseId and spaceId mismatch',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.base.baseAndSpaceMismatch',\n              context: {\n                baseId,\n                spaceId,\n              },\n            },\n          }\n        );\n      }\n    }\n\n    const { baseId: fromBaseId = '' } = template?.snapshot ? JSON.parse(template.snapshot) : {};\n\n    if (!template || !fromBaseId) {\n      throw new CustomHttpException('Template not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.base.templateNotFound',\n          context: {\n            templateId,\n          },\n        },\n      });\n    }\n\n    return await this.prismaService.$tx(\n      async () => {\n        const res = await this.baseDuplicateService.duplicateBase(\n          {\n            name: template.name!,\n            fromBaseId,\n            spaceId,\n            withRecords,\n            baseId,\n          },\n          false,\n          BaseDuplicateMode.ApplyTemplate\n        );\n        await this.prismaService.txClient().template.update({\n          where: { id: templateId },\n          data: { usageCount: { increment: 1 } },\n        });\n\n        // Emit template apply audit log\n        await this.baseDuplicateService.emitBaseTemplateApplyAuditLog(\n          res.base.id,\n          createBaseFromTemplateRo,\n          res.recordsLength\n        );\n\n        // Get defaultUrl from publishInfo\n        const publishInfo = template.publishInfo as { defaultUrl?: string } | null;\n        const defaultUrl = publishInfo?.defaultUrl;\n\n        // If defaultUrl exists, replace the snapshot baseId with the new baseId\n        if (defaultUrl) {\n          const maps = this.getUrlMap(res as unknown as Record<string, string>);\n          const newDefaultUrl = replaceDefaultUrl(defaultUrl, {\n            ...maps,\n            baseMap: {\n              [fromBaseId]: res.base.id,\n            },\n          });\n          return {\n            ...res.base,\n            defaultUrl: newDefaultUrl,\n          };\n        }\n\n        return res.base;\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  protected getUrlMap(res: Record<string, string>) {\n    const maps = pick(res, ['tableIdMap', 'viewIdMap', 'dashboardIdMap']);\n    return {\n      ...maps,\n    } as unknown as Record<string, Record<string, string>>;\n  }\n\n  async getPermission() {\n    const permissions = this.cls.get('permissions');\n    return [\n      ...actionPrefixMap[ActionPrefix.Table],\n      ...actionPrefixMap[ActionPrefix.Base],\n      ...actionPrefixMap[ActionPrefix.Automation],\n      ...actionPrefixMap[ActionPrefix.App],\n      ...actionPrefixMap[ActionPrefix.TableRecordHistory],\n    ].reduce((acc, action) => {\n      acc[action] = permissions.includes(action);\n      return acc;\n    }, {} as IGetBasePermissionVo);\n  }\n\n  async permanentDeleteBase(baseId: string, ignorePermissionCheck: boolean = false) {\n    if (!ignorePermissionCheck) {\n      const accessTokenId = this.cls.get('accessTokenId');\n      await this.permissionService.validPermissions(baseId, ['base|delete'], accessTokenId, true);\n    }\n\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        const tables = await prisma.tableMeta.findMany({\n          where: { baseId },\n          select: { id: true },\n        });\n        const tableIds = tables.map(({ id }) => id);\n\n        await this.dropBase(baseId, tableIds);\n        await this.tableOpenApiService.cleanReferenceFieldIds(tableIds);\n        await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds);\n        await this.cleanBaseRelatedData(baseId);\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  private async permanentEmptyBaseRelatedData(baseId: string) {\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        const tables = await prisma.tableMeta.findMany({\n          where: { baseId },\n          select: { id: true },\n        });\n        const tableIds = tables.map(({ id }) => id);\n\n        await this.dropBaseTable(tableIds);\n        await this.tableOpenApiService.cleanReferenceFieldIds(tableIds);\n        await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds);\n        await this.cleanBaseRelatedDataWithoutBase(baseId);\n        await this.cleanRelativeNodesData(baseId);\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  private async cleanBaseRelatedDataWithoutBase(baseId: string) {\n    // delete collaborators for base\n    await this.prismaService.txClient().collaborator.deleteMany({\n      where: { resourceId: baseId, resourceType: CollaboratorType.Base },\n    });\n\n    // delete invitation for base\n    await this.prismaService.txClient().invitation.deleteMany({\n      where: { baseId },\n    });\n\n    // delete invitation record for base\n    await this.prismaService.txClient().invitationRecord.deleteMany({\n      where: { baseId },\n    });\n\n    // delete trash for base\n    await this.prismaService.txClient().trash.deleteMany({\n      where: {\n        resourceId: baseId,\n        resourceType: ResourceType.Base,\n      },\n    });\n  }\n\n  private async cleanRelativeNodesData(baseId: string) {\n    const prisma = this.prismaService.txClient();\n    await prisma.baseNode.deleteMany({\n      where: { baseId },\n    });\n    await prisma.baseNodeFolder.deleteMany({\n      where: { baseId },\n    });\n  }\n\n  async dropBase(baseId: string, tableIds: string[]) {\n    const sql = this.dbProvider.dropSchema(baseId);\n    if (sql) {\n      return await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n    await this.tableOpenApiService.dropTables(tableIds);\n  }\n\n  async dropBaseTable(tableIds: string[]) {\n    await this.tableOpenApiService.dropTables(tableIds);\n  }\n\n  async cleanBaseRelatedData(baseId: string) {\n    // delete collaborators for base\n    await this.prismaService.txClient().collaborator.deleteMany({\n      where: { resourceId: baseId, resourceType: CollaboratorType.Base },\n    });\n\n    // delete invitation for base\n    await this.prismaService.txClient().invitation.deleteMany({\n      where: { baseId },\n    });\n\n    // delete invitation record for base\n    await this.prismaService.txClient().invitationRecord.deleteMany({\n      where: { baseId },\n    });\n\n    // delete base\n    await this.prismaService.txClient().base.delete({\n      where: { id: baseId },\n    });\n\n    // delete trash for base\n    await this.prismaService.txClient().trash.deleteMany({\n      where: {\n        resourceId: baseId,\n        resourceType: ResourceType.Base,\n      },\n    });\n\n    await this.cleanRelativeNodesData(baseId);\n  }\n\n  async moveBase(baseId: string, moveBaseRo: IMoveBaseRo) {\n    const { spaceId } = moveBaseRo;\n    // check if has the permission to create base in the target space\n    await this.checkBaseCreatePermission(spaceId);\n    await this.prismaService.base.update({\n      where: { id: baseId },\n      data: { spaceId },\n    });\n  }\n\n  async generateBaseErd(baseId: string): Promise<IBaseErdVo> {\n    return await this.graphService.generateBaseErd(baseId);\n  }\n\n  private async generateDefaultUrlForNode(\n    snapshotBaseId: string,\n    snapshotNodeId: string | null\n  ): Promise<string | null> {\n    if (!snapshotNodeId) {\n      return null;\n    }\n\n    const prisma = this.prismaService.txClient();\n\n    const node = await prisma.baseNode.findFirst({\n      where: { baseId: snapshotBaseId, id: snapshotNodeId },\n      select: { resourceType: true, resourceId: true },\n    });\n\n    if (!node) {\n      return null;\n    }\n\n    const { resourceType, resourceId } = node;\n\n    switch (resourceType) {\n      case BaseNodeResourceType.Table: {\n        const table = await prisma.tableMeta.findFirst({\n          where: { id: resourceId, deletedTime: null },\n          select: { id: true },\n        });\n        if (!table) {\n          return `/base/${snapshotBaseId}`;\n        }\n        const defaultView = await prisma.view.findFirst({\n          where: { tableId: resourceId, deletedTime: null },\n          orderBy: { order: 'asc' },\n          select: { id: true },\n        });\n        if (defaultView) {\n          return `/base/${snapshotBaseId}/table/${resourceId}/${defaultView.id}`;\n        }\n        return `/base/${snapshotBaseId}/table/${resourceId}`;\n      }\n      case BaseNodeResourceType.Dashboard:\n        return `/base/${snapshotBaseId}/dashboard/${resourceId}`;\n      case BaseNodeResourceType.Workflow:\n        return `/base/${snapshotBaseId}/automation/${resourceId}`;\n      case BaseNodeResourceType.App:\n        return `/base/${snapshotBaseId}/app/${resourceId}`;\n      default:\n        return `/base/${snapshotBaseId}`;\n    }\n  }\n\n  async publishBase(baseId: string, publishBaseRo: IPublishBaseRo) {\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        const template = await prisma.template.findFirst({\n          where: { baseId },\n          select: { id: true, snapshot: true },\n        });\n        const { title, description, cover, nodes, includeData } = publishBaseRo;\n\n        const snapshotBaseId = template?.snapshot\n          ? JSON.parse(template.snapshot).baseId\n          : undefined;\n\n        const snapshot = await this.createSnapshot(baseId, nodes, includeData, snapshotBaseId);\n\n        // Calculate snapshotActiveNodeId and defaultUrl\n        const snapshotActiveNodeId = publishBaseRo.defaultActiveNodeId\n          ? snapshot.nodeIdMap?.[publishBaseRo.defaultActiveNodeId] || null\n          : null;\n        const defaultUrl = await this.generateDefaultUrlForNode(\n          snapshot.baseId,\n          snapshotActiveNodeId\n        );\n\n        const publishInfo = {\n          nodes: publishBaseRo.nodes,\n          includeData: publishBaseRo.includeData,\n          defaultActiveNodeId: publishBaseRo.defaultActiveNodeId,\n          snapshotActiveNodeId,\n          defaultUrl,\n        };\n\n        // Generate thumbnail for template cover image\n        if (cover) {\n          const coverThumbnail = await this.cropTemplateCoverImage(cover);\n\n          if (coverThumbnail?.lgThumbnailPath && coverThumbnail?.smThumbnailPath) {\n            cover.thumbnailPath = {\n              lg: coverThumbnail.lgThumbnailPath,\n              sm: coverThumbnail.smThumbnailPath,\n            };\n          }\n        }\n\n        // if already published, update template\n        if (template) {\n          const updatedTemplate = await prisma.template.update({\n            where: { id: template.id },\n            data: {\n              name: title,\n              description,\n              cover: cover ? JSON.stringify(cover) : undefined,\n              snapshot: JSON.stringify({\n                baseId: snapshot.baseId,\n                snapshotTime: new Date().toISOString(),\n                spaceId: snapshot.spaceId,\n                name: snapshot.name,\n              }),\n              publishInfo,\n              lastModifiedBy: this.cls.get('user.id'),\n            },\n            select: {\n              id: true,\n            },\n          });\n\n          return {\n            baseId: snapshot.baseId,\n            defaultUrl,\n            permalink: `/t/${updatedTemplate.id}`,\n          };\n        }\n\n        // if the base is not published, create a template\n        // publish snapshot\n        const newTemplate = await this.createTemplateBySnapshot(\n          baseId,\n          snapshot,\n          publishBaseRo,\n          publishInfo\n        );\n\n        return {\n          baseId: snapshot.baseId,\n          defaultUrl,\n          permalink: `/t/${newTemplate.id}`,\n        };\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  private async createSnapshot(\n    baseId: string,\n    nodes?: string[],\n    includeData?: boolean,\n    existedBaseId?: string\n  ) {\n    const prisma = this.prismaService.txClient();\n    const { id: templateSpaceId } = await prisma.space.findFirstOrThrow({\n      where: {\n        isTemplate: true,\n      },\n      select: {\n        id: true,\n      },\n    });\n    const base = await prisma.base.findUniqueOrThrow({\n      where: { id: baseId, deletedTime: null },\n      select: {\n        name: true,\n      },\n    });\n\n    if (existedBaseId) {\n      // delete some related data\n      await this.cleanTemplateRelatedData(existedBaseId);\n    }\n\n    const {\n      base: { id, spaceId, name },\n      nodeIdMap,\n    } = await this.baseDuplicateService.duplicateBase(\n      {\n        fromBaseId: baseId,\n        spaceId: templateSpaceId,\n        withRecords: includeData ?? true,\n        name: base?.name,\n        nodes,\n        baseId: existedBaseId,\n      },\n      false,\n      BaseDuplicateMode.CreateTemplate\n    );\n\n    return {\n      baseId: id,\n      spaceId,\n      name,\n      nodeIdMap,\n    };\n  }\n\n  async cleanTemplateRelatedData(baseId: string) {\n    await this.permanentEmptyBaseRelatedData(baseId);\n  }\n\n  /**\n   * Generate thumbnail for template cover image\n   * Template only has one cover image, so we generate thumbnail synchronously (no queue needed)\n   */\n  private async cropTemplateCoverImage(cover: {\n    path: string;\n    mimetype?: string;\n    height?: number;\n  }) {\n    const { path, mimetype, height } = cover;\n\n    // Only process images with height info\n    if (!mimetype?.startsWith('image/') || !height) {\n      return;\n    }\n\n    // Only generate thumbnail if the image is larger than the thumbnail size\n    if (height <= ATTACHMENT_LG_THUMBNAIL_HEIGHT) {\n      return;\n    }\n\n    try {\n      const bucket = StorageAdapter.getBucket(UploadType.Template);\n      const result = await this.attachmentsStorageService.cropTableImage(bucket, path, height);\n      const { lgThumbnailPath, smThumbnailPath } = result;\n      this.logger.log(`Template cover thumbnail generated for path: ${path}`);\n      return {\n        lgThumbnailPath,\n        smThumbnailPath,\n      };\n    } catch (error) {\n      // Log error but don't fail the publish operation\n      this.logger.error(`Failed to generate template cover thumbnail: ${(error as Error).message}`);\n    }\n  }\n\n  private async createTemplateBySnapshot(\n    sourceBaseId: string,\n    snapshot: {\n      baseId: string;\n      spaceId: string;\n      name: string;\n      nodeIdMap: Record<string, string>;\n    },\n    publishBaseRo: IPublishBaseRo,\n    publishInfo: {\n      nodes?: string[];\n      includeData?: boolean;\n      defaultActiveNodeId?: string | null;\n      snapshotActiveNodeId: string | null;\n      defaultUrl: string | null;\n    }\n  ) {\n    const { title, description, cover } = publishBaseRo;\n    const prisma = this.prismaService.txClient();\n    const templateId = generateTemplateId();\n    const { baseId, spaceId, name } = snapshot;\n\n    const order = await this.prismaService.template.aggregate({\n      _max: {\n        order: true,\n      },\n    });\n\n    const userId = this.cls.get('user.id');\n\n    const finalOrder = isNumber(order._max.order) ? order._max.order + 1 : 1;\n\n    return await prisma.template.create({\n      data: {\n        id: templateId,\n        name: title,\n        description,\n        cover: cover ? JSON.stringify(cover) : undefined,\n        createdBy: userId,\n        order: finalOrder,\n        isPublished: true,\n        baseId: sourceBaseId,\n        snapshot: JSON.stringify({\n          baseId: baseId,\n          snapshotTime: new Date().toISOString(),\n          spaceId,\n          name,\n        }),\n        publishInfo,\n      },\n      select: {\n        id: true,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/constant.ts",
    "content": "export const EXCLUDE_SYSTEM_FIELDS = [\n  '__auto_number',\n  '__created_time',\n  '__last_modified_time',\n  '__last_modified_by',\n  '__created_by',\n  '__version',\n];\n\nexport const DEFAULT_EXPRESSION = `\"TRIM('')\"`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/db-connection.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { BaseModule } from './base.module';\nimport { DbConnectionService } from './db-connection.service';\n\ndescribe('DbConnectionService', () => {\n  let service: DbConnectionService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, BaseModule],\n    }).compile();\n\n    service = module.get<DbConnectionService>(DbConnectionService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/db-connection.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { IDsn } from '@teable/core';\nimport { DriverClient, HttpErrorCode, parseDsn } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IDbConnectionVo } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { nanoid } from 'nanoid';\nimport { InjectModel } from 'nest-knexjs';\nimport { BaseConfig, type IBaseConfig } from '../../configs/base.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\n\n@Injectable()\nexport class DbConnectionService {\n  private readonly logger = new Logger(DbConnectionService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly configService: ConfigService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @BaseConfig() private readonly baseConfig: IBaseConfig\n  ) {}\n\n  private getUrlFromDsn(dsn: IDsn): string {\n    const { driver, host, port, db, user, pass, params } = dsn;\n    if (driver !== DriverClient.Pg) {\n      throw new CustomHttpException('Unsupported database driver', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.dbConnection.unsupportedDriver',\n          context: {\n            driver,\n          },\n        },\n      });\n    }\n\n    const paramString =\n      Object.entries(params as Record<string, unknown>)\n        .map(([key, value]) => `${key}=${value}`)\n        .join('&') || '';\n\n    return `postgresql://${user}:${pass}@${host}:${port}/${db}?${paramString}`;\n  }\n\n  async remove(baseId: string) {\n    if (this.dbProvider.driver !== DriverClient.Pg) {\n      throw new CustomHttpException('Unsupported database driver', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.dbConnection.unsupportedDriver',\n          context: {\n            driver: this.dbProvider.driver,\n          },\n        },\n      });\n    }\n\n    const readOnlyRole = `read_only_role_${baseId}`;\n    const schemaName = baseId;\n    return this.prismaService.$tx(async (prisma) => {\n      // Verify if the base exists and if the user is the owner\n      await prisma.base\n        .findFirstOrThrow({\n          where: { id: baseId, deletedTime: null },\n        })\n        .catch(() => {\n          throw new CustomHttpException(\n            'Only the base owner can remove a db connection',\n            HttpErrorCode.RESTRICTED_RESOURCE,\n            {\n              localization: {\n                i18nKey: 'httpErrors.dbConnection.onlyOwnerCanRemove',\n                context: {\n                  baseId,\n                },\n              },\n            }\n          );\n        });\n\n      // Revoke permissions from the role for the schema\n      await prisma.$executeRawUnsafe(\n        this.knex.raw('REVOKE USAGE ON SCHEMA ?? FROM ??', [schemaName, readOnlyRole]).toQuery()\n      );\n\n      await prisma.$executeRawUnsafe(\n        this.knex\n          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [\n            schemaName,\n            readOnlyRole,\n          ])\n          .toQuery()\n      );\n\n      // Revoke permissions from the role for the tables in schema\n      await prisma.$executeRawUnsafe(\n        this.knex\n          .raw('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA ?? FROM ??', [\n            schemaName,\n            readOnlyRole,\n          ])\n          .toQuery()\n      );\n\n      // drop the role\n      await prisma.$executeRawUnsafe(\n        this.knex.raw('DROP ROLE IF EXISTS ??', [readOnlyRole]).toQuery()\n      );\n\n      await prisma.base.update({\n        where: { id: baseId },\n        data: { schemaPass: null },\n      });\n    });\n  }\n\n  private async roleExits(role: string): Promise<boolean> {\n    const roleExists = await this.prismaService.$queryRaw<\n      { count: bigint }[]\n    >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`;\n    return Boolean(roleExists[0].count);\n  }\n\n  private async getConnectionCount(role: string): Promise<number> {\n    const roleExists = await this.prismaService.$queryRaw<\n      { count: bigint }[]\n    >`SELECT COUNT(*) FROM pg_stat_activity WHERE usename=${role}`;\n    return Number(roleExists[0].count);\n  }\n\n  async retrieve(baseId: string): Promise<IDbConnectionVo | null> {\n    if (this.dbProvider.driver !== DriverClient.Pg) {\n      return null;\n    }\n\n    const readOnlyRole = `read_only_role_${baseId}`;\n    const publicDatabaseProxy = this.baseConfig.publicDatabaseProxy;\n    if (!publicDatabaseProxy) {\n      this.logger.error('PUBLIC_DATABASE_PROXY is not found in env');\n      return null;\n    }\n\n    const { hostname: dbHostProxy, port: dbPortProxy } = new URL(`https://${publicDatabaseProxy}`);\n\n    // Check if the base exists and the user is the owner\n    const base = await this.prismaService.base.findFirst({\n      where: { id: baseId, deletedTime: null },\n      select: { id: true, schemaPass: true },\n    });\n\n    if (!base?.schemaPass) {\n      return null;\n    }\n\n    // Check if the read-only role already exists\n    if (!(await this.roleExits(readOnlyRole))) {\n      throw new CustomHttpException('Role does not exist', HttpErrorCode.INTERNAL_SERVER_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.dbConnection.roleNotExist',\n          context: {\n            role: readOnlyRole,\n          },\n        },\n      });\n    }\n\n    const currentConnections = await this.getConnectionCount(readOnlyRole);\n\n    const databaseUrl = this.configService.getOrThrow<string>('PRISMA_DATABASE_URL');\n    const { db } = parseDsn(databaseUrl);\n\n    // Construct the DSN for the read-only role\n    const dsn: IDbConnectionVo['dsn'] = {\n      driver: DriverClient.Pg,\n      host: dbHostProxy,\n      port: Number(dbPortProxy),\n      db: db,\n      user: readOnlyRole,\n      pass: base.schemaPass,\n      params: {\n        schema: baseId,\n      },\n    };\n\n    // Get the URL from the DSN\n    const url = this.getUrlFromDsn(dsn);\n\n    return {\n      dsn,\n      connection: {\n        max: this.baseConfig.defaultMaxBaseDBConnections,\n        current: currentConnections,\n      },\n      url,\n    };\n  }\n\n  /**\n   * public a schema specify and readonly connection\n   *\n   * check role is empty, if not, throw badRequest\n   *\n   * create a readonly role\n   *\n   * limit role to only access the schema\n   */\n  async create(baseId: string) {\n    if (this.dbProvider.driver === DriverClient.Pg) {\n      const readOnlyRole = `read_only_role_${baseId}`;\n      const schemaName = baseId;\n      const password = nanoid();\n      const publicDatabaseProxy = this.baseConfig.publicDatabaseProxy;\n      if (!publicDatabaseProxy) {\n        this.logger.error('PUBLIC_DATABASE_PROXY is not found in env');\n        return null;\n      }\n\n      const { hostname: dbHostProxy, port: dbPortProxy } = new URL(\n        `https://${publicDatabaseProxy}`\n      );\n      const databaseUrl = this.configService.getOrThrow<string>('PRISMA_DATABASE_URL');\n      const { db } = parseDsn(databaseUrl);\n\n      return this.prismaService.$tx(async (prisma) => {\n        await prisma.base\n          .findFirstOrThrow({\n            where: { id: baseId, deletedTime: null },\n          })\n          .catch(() => {\n            throw new CustomHttpException(\n              'Only base owner can create db connection',\n              HttpErrorCode.RESTRICTED_RESOURCE,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.dbConnection.onlyOwnerCanCreate',\n                  context: {\n                    baseId,\n                  },\n                },\n              }\n            );\n          });\n\n        await prisma.base.update({\n          where: { id: baseId },\n          data: { schemaPass: password },\n        });\n\n        // Create a read-only role\n        await prisma.$executeRawUnsafe(\n          this.knex\n            .raw(\n              `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION CONNECTION LIMIT ?`,\n              [readOnlyRole, password, this.baseConfig.defaultMaxBaseDBConnections]\n            )\n            .toQuery()\n        );\n\n        await prisma.$executeRawUnsafe(\n          this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [schemaName, readOnlyRole]).toQuery()\n        );\n\n        await prisma.$executeRawUnsafe(\n          this.knex\n            .raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [schemaName, readOnlyRole])\n            .toQuery()\n        );\n\n        await prisma.$executeRawUnsafe(\n          this.knex\n            .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [\n              schemaName,\n              readOnlyRole,\n            ])\n            .toQuery()\n        );\n\n        const dsn: IDbConnectionVo['dsn'] = {\n          driver: DriverClient.Pg,\n          host: dbHostProxy,\n          port: Number(dbPortProxy),\n          db: db,\n          user: readOnlyRole,\n          pass: password,\n          params: {\n            schema: baseId,\n          },\n        };\n\n        return {\n          dsn,\n          connection: {\n            max: this.baseConfig.defaultMaxBaseDBConnections,\n            current: 0,\n          },\n          url: this.getUrlFromDsn(dsn),\n        };\n      });\n    }\n\n    throw new CustomHttpException('Unsupported database driver', HttpErrorCode.VALIDATION_ERROR, {\n      localization: {\n        i18nKey: 'httpErrors.dbConnection.unsupportedDriver',\n        context: {\n          driver: this.dbProvider.driver,\n        },\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/utils.spec.ts",
    "content": "import { replaceExpressionFieldIds, replaceJsonStringFieldIds } from './utils';\n\ndescribe('replaceFieldIds function', () => {\n  it('replaces fieldIds in the expression with their mapped values', () => {\n    const old2NewFieldMap = {\n      fld123: 'newFld456',\n      fld789: 'newFld101112',\n    };\n    const expression = 'This is a test with {fld123} and also {fld789}.';\n    const expectedResult = 'This is a test with {newFld456} and also {newFld101112}.';\n\n    expect(replaceExpressionFieldIds(expression, old2NewFieldMap)).toEqual(expectedResult);\n  });\n\n  it('does not replace non-existent fieldIds', () => {\n    const old2NewFieldMap = {\n      fld123: 'newFld456',\n    };\n    const expression = 'This is a test with {fld123} and also {fldNonExistent}.';\n    const expectedResult = 'This is a test with {newFld456} and also {fldNonExistent}.';\n\n    expect(replaceExpressionFieldIds(expression, old2NewFieldMap)).toEqual(expectedResult);\n  });\n\n  it('correctly ignores invalid fieldId formats', () => {\n    const old2NewFieldMap = {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      '1fldInvalid': 'newFld456',\n    };\n    const expression = 'Check {1fldInvalid} and {fld123}.';\n    const expectedResult = 'Check {1fldInvalid} and {fld123}.'; // Assuming fld123 is not in the map, and 1fldInvalid is ignored due to invalid format\n\n    expect(replaceExpressionFieldIds(expression, old2NewFieldMap)).toEqual(expectedResult);\n  });\n});\n\ndescribe('replaceJsonStringFieldIds', () => {\n  it('should replace fieldIds in jsonString correctly', () => {\n    const jsonString =\n      '{\"exampleFieldId\": \"fld1234567890abcdef\", \"nested\": {\"fld234567890abcdefg\": \"someValue\"}}';\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldNew1234567890abcd',\n      fld234567890abcdefg: 'fldNew234567890abcde',\n    };\n\n    const expectedResult =\n      '{\"exampleFieldId\": \"fldNew1234567890abcd\", \"nested\": {\"fldNew234567890abcde\": \"someValue\"}}';\n\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n\n    expect(result).toBe(expectedResult);\n  });\n\n  it('should not modify jsonString if no fieldIds match', () => {\n    const jsonString = '{\"unrelatedKey\": \"unrelatedValue\", \"anotherKey\": 123}';\n    const old2NewFieldMap = {\n      fldDoesNotExist: 'fldNewValue',\n    };\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(jsonString);\n  });\n\n  it('should handle jsonString with empty fieldId map', () => {\n    const jsonString = '{\"exampleFieldId\": \"fld1234567890abcdef\"}';\n    const old2NewFieldMap = {};\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(jsonString); // Expect no change since the map is empty\n  });\n\n  it('should correctly replace fieldIds when they appear as values', () => {\n    const jsonString = '{\"key\": \"fld1234567890abcdef\"}';\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldReplacement',\n    };\n    const expectedResult = '{\"key\": \"fldReplacement\"}';\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(expectedResult);\n  });\n\n  it('should correctly replace fieldIds when they appear as keys', () => {\n    const jsonString = '{\"fld1234567890abcdef\": \"someValue\"}';\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldNewKey',\n    };\n    const expectedResult = '{\"fldNewKey\": \"someValue\"}';\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(expectedResult);\n  });\n\n  it('should handle jsonString with multiple and nested fieldIds', () => {\n    const jsonString =\n      '{\"fld1234567890abcdef\": \"value1\", \"nested\": {\"fld4561237890abcdef\": \"value2\"}}';\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldNew4567890abcdef',\n      fld4561237890abcdef: 'fldNew1237890abcdef',\n    };\n    const expectedResult =\n      '{\"fldNew4567890abcdef\": \"value1\", \"nested\": {\"fldNew1237890abcdef\": \"value2\"}}';\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(expectedResult);\n  });\n\n  it('should return original jsonString for empty input', () => {\n    const jsonString = '';\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldReplacement',\n    };\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(jsonString);\n  });\n\n  it('should return null jsonString for null input', () => {\n    const jsonString = null;\n    const old2NewFieldMap = {\n      fld1234567890abcdef: 'fldReplacement',\n    };\n    const result = replaceJsonStringFieldIds(jsonString, old2NewFieldMap);\n    expect(result).toBe(null);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base/utils.ts",
    "content": "function escapeRegExp(string: string): string {\n  return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function replaceExpressionFieldIds(\n  expression: string,\n  fieldIdMap: { [oldFieldId: string]: string }\n): string {\n  const regex = /\\{([a-z][a-z\\d]*)\\}/gi;\n  return expression.replace(regex, (match, fieldId) => {\n    return fieldIdMap[fieldId] ? `{${fieldIdMap[fieldId]}}` : match;\n  });\n}\n\nexport function replaceJsonStringFieldIds(\n  jsonString: string | null,\n  old2NewFieldMap: { [key: string]: string }\n): string | null {\n  const regex = /\"fld[A-Za-z\\d]{16}\"/g;\n  if (!jsonString) return jsonString;\n\n  return jsonString.replace(regex, (match) => {\n    const fieldId = match.slice(1, -1);\n    const newFieldId = old2NewFieldMap[fieldId];\n    return newFieldId ? `\"${newFieldId}\"` : match;\n  });\n}\n\nexport function replaceStringByMap(\n  config: unknown,\n  maps: Record<string, Record<string, string>>\n): string | undefined;\nexport function replaceStringByMap(\n  config: unknown,\n  maps: Record<string, Record<string, string>>,\n  returnJSONString: false\n): unknown;\nexport function replaceStringByMap(\n  config: unknown,\n  maps: Record<string, Record<string, string>>,\n  returnJSONString: boolean = true\n): string | undefined | unknown {\n  if (!config) {\n    return;\n  }\n\n  let newConfigStr = JSON.stringify(config);\n\n  for (const [, value] of Object.entries(maps)) {\n    if (value) {\n      Object.entries(value).forEach(([mapKey, mapValue]) => {\n        newConfigStr = newConfigStr.replaceAll(new RegExp(escapeRegExp(mapKey), 'gi'), mapValue);\n      });\n    }\n  }\n\n  return returnJSONString ? newConfigStr : JSON.parse(newConfigStr);\n}\n\nexport const replaceDefaultUrl = (\n  defaultUrl: string,\n  maps: Record<string, Record<string, string>>\n) => {\n  if (!defaultUrl) return defaultUrl;\n\n  let newDefaultUrl = defaultUrl;\n\n  for (const [, value] of Object.entries(maps)) {\n    if (value) {\n      Object.entries(value).forEach(([mapKey, mapValue]) => {\n        newDefaultUrl = newDefaultUrl.replaceAll(mapKey, mapValue);\n      });\n    }\n  }\n\n  return newDefaultUrl;\n};\n\nexport const mergeLinkFieldTableMaps = (\n  map1: Record<\n    string,\n    { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]\n  >,\n  map2: Record<string, { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]>\n) => {\n  const merged = { ...map1 };\n  Object.entries(map2).forEach(([tableId, fields]) => {\n    merged[tableId] = [...(merged[tableId] || []), ...fields];\n  });\n  return merged;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Headers,\n  Param,\n  Post,\n  Put,\n  Res,\n  UseGuards,\n} from '@nestjs/common';\nimport {\n  BaseNodeResourceType,\n  moveBaseNodeRoSchema,\n  createBaseNodeRoSchema,\n  duplicateBaseNodeRoSchema,\n  ICreateBaseNodeRo,\n  IDuplicateBaseNodeRo,\n  IMoveBaseNodeRo,\n  updateBaseNodeRoSchema,\n  IUpdateBaseNodeRo,\n  type IBaseNodeTreeVo,\n  type IBaseNodeVo,\n  type IDeleteBaseNodeVo,\n} from '@teable/openapi';\nimport type { Response } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-anonymous.decorator';\nimport { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';\nimport {\n  X_TEABLE_V2_FEATURE_HEADER,\n  X_TEABLE_V2_HEADER,\n  X_TEABLE_V2_REASON_HEADER,\n} from '../canary/interceptors/v2-indicator.interceptor';\nimport { checkBaseNodePermission } from './base-node.permission.helper';\nimport { BaseNodeService } from './base-node.service';\nimport { BaseNodeAction } from './types';\n\n@Controller('api/base/:baseId/node')\n@UseGuards(BaseNodePermissionGuard)\n@AllowAnonymous(AllowAnonymousType.RESOURCE)\nexport class BaseNodeController {\n  protected static readonly createTableV2Feature = 'createTable';\n  protected static readonly deleteTableV2Feature = 'deleteTable';\n\n  constructor(\n    private readonly baseNodeService: BaseNodeService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Get('list')\n  @Permissions('base|read')\n  async getList(@Param('baseId') baseId: string): Promise<IBaseNodeVo[]> {\n    const permissionContext = await this.getPermissionContext(baseId);\n    const nodeList = await this.baseNodeService.getList(baseId);\n    const allowedNodeIds = this.getAllowedNodeIds(nodeList, permissionContext.shareNodeId);\n    return nodeList.filter((node) => this.filterNode(node, permissionContext, allowedNodeIds));\n  }\n\n  @Get('tree')\n  @Permissions('base|read')\n  async getTree(@Param('baseId') baseId: string): Promise<IBaseNodeTreeVo> {\n    const permissionContext = await this.getPermissionContext(baseId);\n    const tree = await this.baseNodeService.getTree(baseId);\n    const allowedNodeIds = this.getAllowedNodeIds(tree.nodes, permissionContext.shareNodeId);\n    return {\n      ...tree,\n      nodes: tree.nodes.filter((node) => this.filterNode(node, permissionContext, allowedNodeIds)),\n    };\n  }\n\n  private filterNode(\n    node: IBaseNodeVo,\n    permissionContext: { permissionSet: Set<string>; shareNodeId?: string },\n    allowedNodeIds?: Set<string>\n  ): boolean {\n    if (allowedNodeIds && !allowedNodeIds.has(node.id)) {\n      return false;\n    }\n\n    // Then check standard permissions\n    return checkBaseNodePermission(\n      { resourceType: node.resourceType, resourceId: node.resourceId },\n      BaseNodeAction.Read,\n      permissionContext\n    );\n  }\n\n  protected getAllowedNodeIds(nodes: IBaseNodeVo[], shareNodeId?: string) {\n    if (!shareNodeId) {\n      return undefined;\n    }\n    const nodeIds = new Set(nodes.map((node) => node.id));\n    if (!nodeIds.has(shareNodeId)) {\n      return new Set<string>();\n    }\n    const childrenByParent = new Map<string, string[]>();\n    for (const node of nodes) {\n      if (!node.parentId) {\n        continue;\n      }\n      const current = childrenByParent.get(node.parentId) ?? [];\n      current.push(node.id);\n      childrenByParent.set(node.parentId, current);\n    }\n    const allowed = new Set<string>();\n    const queue = [shareNodeId];\n    while (queue.length) {\n      const current = queue.shift();\n      if (!current || allowed.has(current)) {\n        continue;\n      }\n      allowed.add(current);\n      const children = childrenByParent.get(current) ?? [];\n      for (const childId of children) {\n        if (!allowed.has(childId)) {\n          queue.push(childId);\n        }\n      }\n    }\n    return allowed;\n  }\n\n  @Get(':nodeId')\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Read)\n  async getNode(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string\n  ): Promise<IBaseNodeVo> {\n    return this.baseNodeService.getNodeVo(baseId, nodeId);\n  }\n\n  @Post()\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Create)\n  @EmitControllerEvent(Events.BASE_NODE_CREATE)\n  async create(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(createBaseNodeRoSchema)) ro: ICreateBaseNodeRo,\n    @Headers('x-window-id') windowId: string | undefined,\n    @Res({ passthrough: true }) response: Response\n  ): Promise<IBaseNodeVo> {\n    await this.prepareCreateTableCanary(baseId, ro, response, windowId);\n    return this.baseNodeService.create(baseId, ro);\n  }\n\n  @Post(':nodeId/duplicate')\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Read, BaseNodeAction.Create)\n  @EmitControllerEvent(Events.BASE_NODE_CREATE)\n  async duplicate(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string,\n    @Body(new ZodValidationPipe(duplicateBaseNodeRoSchema)) ro: IDuplicateBaseNodeRo\n  ): Promise<IBaseNodeVo> {\n    return this.baseNodeService.duplicate(baseId, nodeId, ro);\n  }\n\n  @Put(':nodeId')\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Update)\n  @EmitControllerEvent(Events.BASE_NODE_UPDATE)\n  async update(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string,\n    @Body(new ZodValidationPipe(updateBaseNodeRoSchema)) ro: IUpdateBaseNodeRo\n  ): Promise<IBaseNodeVo> {\n    return this.baseNodeService.update(baseId, nodeId, ro);\n  }\n\n  @Put(':nodeId/move')\n  @Permissions('base|update')\n  async move(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string,\n    @Body(new ZodValidationPipe(moveBaseNodeRoSchema)) ro: IMoveBaseNodeRo\n  ): Promise<IBaseNodeVo> {\n    return this.baseNodeService.move(baseId, nodeId, ro);\n  }\n\n  @Delete(':nodeId')\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Delete)\n  @EmitControllerEvent(Events.BASE_NODE_DELETE)\n  async delete(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string,\n    @Headers('x-window-id') windowId: string | undefined,\n    @Res({ passthrough: true }) response: Response\n  ): Promise<IDeleteBaseNodeVo> {\n    await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);\n    return this.baseNodeService.delete(baseId, nodeId);\n  }\n\n  @Delete(':nodeId/permanent')\n  @Permissions('base|read')\n  @BaseNodePermissions(BaseNodeAction.Delete)\n  @EmitControllerEvent(Events.BASE_NODE_DELETE)\n  async permanentDelete(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string,\n    @Headers('x-window-id') windowId: string | undefined,\n    @Res({ passthrough: true }) response: Response\n  ): Promise<IDeleteBaseNodeVo> {\n    await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);\n    const result = await this.baseNodeService.delete(baseId, nodeId, true);\n    return { ...result, permanent: true };\n  }\n\n  protected async prepareDeleteTableCanary(\n    baseId: string,\n    nodeId: string,\n    response: Response,\n    windowId?: string\n  ): Promise<void> {\n    if (windowId) {\n      this.cls.set('windowId', windowId);\n    }\n\n    const node = await this.baseNodeService.getNode(baseId, nodeId);\n    if (node.resourceType !== BaseNodeResourceType.Table) {\n      return;\n    }\n\n    const decision = await this.baseNodeService.getDeleteTableV2Decision(baseId, nodeId);\n    if (!decision) {\n      return;\n    }\n\n    this.cls.set('useV2', decision.useV2);\n    this.cls.set('v2Feature', BaseNodeController.deleteTableV2Feature);\n    this.cls.set('v2Reason', decision.reason);\n\n    response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');\n    response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.deleteTableV2Feature);\n    response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);\n  }\n\n  protected async prepareCreateTableCanary(\n    baseId: string,\n    createRo: ICreateBaseNodeRo,\n    response: Response,\n    windowId?: string\n  ): Promise<void> {\n    if (windowId) {\n      this.cls.set('windowId', windowId);\n    }\n\n    if (createRo.resourceType !== BaseNodeResourceType.Table) {\n      return;\n    }\n\n    const decision = await this.baseNodeService.getCreateTableV2Decision(baseId);\n    if (!decision) {\n      return;\n    }\n\n    this.cls.set('useV2', decision.useV2);\n    this.cls.set('v2Feature', BaseNodeController.createTableV2Feature);\n    this.cls.set('v2Reason', decision.reason);\n\n    response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');\n    response.setHeader(X_TEABLE_V2_FEATURE_HEADER, BaseNodeController.createTableV2Feature);\n    response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);\n  }\n\n  protected async getPermissionContext(_baseId: string) {\n    const permissions = this.cls.get('permissions');\n    const permissionSet = new Set(permissions);\n    const baseShare = this.cls.get('baseShare');\n    return {\n      permissionSet,\n      shareNodeId: baseShare?.nodeId,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.listener.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IBaseNodePresenceFlushPayload } from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { LocalPresence } from 'sharedb/lib/client';\nimport type {\n  BaseFolderUpdateEvent,\n  BaseFolderDeleteEvent,\n  TableDeleteEvent,\n  TableUpdateEvent,\n  TableCreateEvent,\n  BaseFolderCreateEvent,\n} from '../../event-emitter/events';\nimport type {\n  AppCreateEvent,\n  AppDeleteEvent,\n  AppUpdateEvent,\n} from '../../event-emitter/events/app/app.event';\nimport type { BaseDeleteEvent } from '../../event-emitter/events/base/base.event';\nimport type {\n  DashboardCreateEvent,\n  DashboardDeleteEvent,\n  DashboardUpdateEvent,\n} from '../../event-emitter/events/dashboard/dashboard.event';\nimport { Events } from '../../event-emitter/events/event.enum';\nimport type {\n  WorkflowCreateEvent,\n  WorkflowDeleteEvent,\n  WorkflowUpdateEvent,\n} from '../../event-emitter/events/workflow/workflow.event';\nimport { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';\nimport { PerformanceCacheService } from '../../performance-cache/service';\nimport type { IPerformanceCacheStore } from '../../performance-cache/types';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport type { IClsStore } from '../../types/cls';\nimport { presenceHandler } from './helper';\n\ntype IResourceCreateEvent =\n  | BaseFolderCreateEvent\n  | TableCreateEvent\n  | WorkflowCreateEvent\n  | DashboardCreateEvent\n  | AppCreateEvent;\n\ntype IResourceDeleteEvent =\n  | BaseDeleteEvent\n  | BaseFolderDeleteEvent\n  | TableDeleteEvent\n  | WorkflowDeleteEvent\n  | DashboardDeleteEvent\n  | AppDeleteEvent;\n\ntype IResourceUpdateEvent =\n  | BaseFolderUpdateEvent\n  | TableUpdateEvent\n  | WorkflowUpdateEvent\n  | DashboardUpdateEvent\n  | AppUpdateEvent;\n\n@Injectable()\nexport class BaseNodeListener {\n  private readonly logger = new Logger(BaseNodeListener.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,\n    private readonly shareDbService: ShareDbService,\n    private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>\n  ) {}\n\n  private getIgnoreBaseNodeListener() {\n    return this.cls.get('ignoreBaseNodeListener');\n  }\n\n  @OnEvent(Events.BASE_FOLDER_CREATE, { async: true })\n  @OnEvent(Events.TABLE_CREATE, { async: true })\n  @OnEvent(Events.DASHBOARD_CREATE, { async: true })\n  @OnEvent(Events.WORKFLOW_CREATE, { async: true })\n  @OnEvent(Events.APP_CREATE, { async: true })\n  async onResourceCreate(event: IResourceCreateEvent) {\n    const ignoreBaseNodeListener = this.getIgnoreBaseNodeListener();\n    if (ignoreBaseNodeListener) {\n      return;\n    }\n\n    const { baseId, resourceType, resourceId } = this.prepareResourceCreate(event);\n    if (!baseId || !resourceType || !resourceId) {\n      this.logger.error('Invalid resource create event', event);\n      return;\n    }\n\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'flush',\n      });\n    });\n  }\n\n  private prepareResourceCreate(event: IResourceCreateEvent) {\n    let baseId: string;\n    let resourceType: BaseNodeResourceType | undefined;\n    let resourceId: string | undefined;\n    let name: string | undefined;\n    let icon: string | undefined;\n    switch (event.name) {\n      case Events.BASE_FOLDER_CREATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Folder;\n        resourceId = event.payload.folder.id;\n        name = event.payload.folder.name;\n        break;\n      case Events.TABLE_CREATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Table;\n        // get the table id from the table op\n        resourceId = (event.payload.table as unknown as { id: string }).id;\n        name = event.payload.table.name;\n        icon = event.payload.table.icon;\n        break;\n      case Events.WORKFLOW_CREATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Workflow;\n        resourceId = event.payload.workflow.id;\n        name = event.payload.workflow.name;\n        break;\n      case Events.DASHBOARD_CREATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Dashboard;\n        resourceId = event.payload.dashboard.id;\n        name = event.payload.dashboard.name;\n        break;\n      case Events.APP_CREATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.App;\n        resourceId = event.payload.app.id;\n        name = event.payload.app.name;\n        break;\n    }\n    return {\n      baseId,\n      resourceType,\n      resourceId,\n      name,\n      icon,\n      userId: event.context.user?.id,\n    };\n  }\n\n  @OnEvent(Events.BASE_FOLDER_UPDATE, { async: true })\n  @OnEvent(Events.TABLE_UPDATE, { async: true })\n  @OnEvent(Events.DASHBOARD_UPDATE, { async: true })\n  @OnEvent(Events.WORKFLOW_UPDATE, { async: true })\n  @OnEvent(Events.APP_UPDATE, { async: true })\n  async onResourceUpdate(event: IResourceUpdateEvent) {\n    const ignoreBaseNodeListener = this.getIgnoreBaseNodeListener();\n    if (ignoreBaseNodeListener) {\n      return;\n    }\n\n    const { baseId, resourceType, resourceId } = this.prepareResourceUpdate(event);\n    if (baseId && resourceType && resourceId) {\n      this.presenceHandler(baseId, (presence) => {\n        presence.submit({\n          event: 'flush',\n        });\n      });\n    }\n  }\n\n  private prepareResourceUpdate(event: IResourceUpdateEvent) {\n    let baseId: string;\n    let resourceType: BaseNodeResourceType | undefined;\n    let resourceId: string | undefined;\n    let name: string | undefined;\n    let icon: string | undefined;\n    switch (event.name) {\n      case Events.TABLE_UPDATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Table;\n        resourceId = event.payload.table.id;\n        name = event.payload.table?.name?.newValue as string;\n        icon = event.payload.table?.icon?.newValue as string;\n        break;\n      case Events.WORKFLOW_UPDATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Workflow;\n        resourceId = event.payload.workflow.id;\n        name = event.payload.workflow.name;\n        break;\n      case Events.DASHBOARD_UPDATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Dashboard;\n        resourceId = event.payload.dashboard.id;\n        name = event.payload.dashboard.name;\n        break;\n      case Events.APP_UPDATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.App;\n        resourceId = event.payload.app.id;\n        name = event.payload.app.name;\n        break;\n      case Events.BASE_FOLDER_UPDATE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Folder;\n        resourceId = event.payload.folder.id;\n        name = event.payload.folder.name;\n        break;\n    }\n    return {\n      baseId,\n      resourceType,\n      resourceId,\n      name,\n      icon,\n    };\n  }\n\n  @OnEvent(Events.BASE_DELETE, { async: true })\n  @OnEvent(Events.BASE_FOLDER_DELETE, { async: true })\n  @OnEvent(Events.TABLE_DELETE, { async: true })\n  @OnEvent(Events.DASHBOARD_DELETE, { async: true })\n  @OnEvent(Events.WORKFLOW_DELETE, { async: true })\n  @OnEvent(Events.APP_DELETE, { async: true })\n  async onResourceDelete(event: IResourceDeleteEvent) {\n    const ignoreBaseNodeListener = this.getIgnoreBaseNodeListener();\n    if (ignoreBaseNodeListener) {\n      return;\n    }\n\n    const { baseId, resourceType, resourceId } = this.prepareResourceDelete(event);\n    if (!baseId) {\n      return;\n    }\n    if (event.name === Events.BASE_DELETE) {\n      await this.prismaService.baseNode.deleteMany({\n        where: { baseId },\n      });\n      return;\n    }\n    if (!resourceType || !resourceId) {\n      this.logger.error('Invalid resource delete event', event);\n      return;\n    }\n\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'flush',\n      });\n    });\n  }\n\n  private prepareResourceDelete(event: IResourceDeleteEvent) {\n    let baseId: string;\n    let resourceType: BaseNodeResourceType | undefined;\n    let resourceId: string | undefined;\n    switch (event.name) {\n      case Events.BASE_DELETE:\n        baseId = event.payload.baseId;\n        break;\n      case Events.TABLE_DELETE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Table;\n        resourceId = event.payload.tableId;\n        break;\n      case Events.WORKFLOW_DELETE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Workflow;\n        resourceId = event.payload.workflowId;\n        break;\n      case Events.DASHBOARD_DELETE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Dashboard;\n        resourceId = event.payload.dashboardId;\n        break;\n      case Events.APP_DELETE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.App;\n        resourceId = event.payload.appId;\n        break;\n      case Events.BASE_FOLDER_DELETE:\n        baseId = event.payload.baseId;\n        resourceType = BaseNodeResourceType.Folder;\n        resourceId = event.payload.folderId;\n        break;\n    }\n    return {\n      baseId,\n      resourceType,\n      resourceId,\n    };\n  }\n\n  private presenceHandler<T = IBaseNodePresenceFlushPayload>(\n    baseId: string,\n    handler: (presence: LocalPresence<T>) => void\n  ) {\n    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));\n    // Skip if ShareDB connection is already closed (e.g., during shutdown)\n    if (this.shareDbService.shareDbAdapter.closed) {\n      this.logger.error('ShareDB connection is already closed, presence handler skipped');\n      return;\n    }\n    presenceHandler(baseId, this.shareDbService, handler);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../../share-db/share-db.module';\nimport { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';\nimport { CanaryModule } from '../canary/canary.module';\nimport { DashboardModule } from '../dashboard/dashboard.module';\nimport { FieldDuplicateModule } from '../field/field-duplicate/field-duplicate.module';\nimport { FieldOpenApiModule } from '../field/open-api/field-open-api.module';\nimport { TableOpenApiModule } from '../table/open-api/table-open-api.module';\nimport { TableModule } from '../table/table.module';\nimport { BaseNodeController } from './base-node.controller';\nimport { BaseNodeListener } from './base-node.listener';\nimport { BaseNodeService } from './base-node.service';\nimport { BaseNodeFolderModule } from './folder/base-node-folder.module';\n\n@Module({\n  imports: [\n    BaseNodeFolderModule,\n    ShareDbModule,\n    CanaryModule,\n    DashboardModule,\n    TableOpenApiModule,\n    TableModule,\n    FieldOpenApiModule,\n    FieldDuplicateModule,\n  ],\n  controllers: [BaseNodeController],\n  providers: [BaseNodePermissionGuard, BaseNodeService, BaseNodeListener],\n  exports: [BaseNodePermissionGuard, BaseNodeService],\n})\nexport class BaseNodeModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.permission.helper.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { TableAction, AppAction, AutomationAction } from '@teable/core';\nimport { HttpErrorCode } from '@teable/core';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IBaseNodePermissionContext } from './types';\nimport { BaseNodeAction } from './types';\n\nconst map: Record<BaseNodeResourceType, Record<BaseNodeAction, string>> = {\n  [BaseNodeResourceType.Folder]: {\n    [BaseNodeAction.Read]: 'base|read',\n    [BaseNodeAction.Create]: 'base|update',\n    [BaseNodeAction.Update]: 'base|update',\n    [BaseNodeAction.Delete]: 'base|update',\n  },\n  [BaseNodeResourceType.Table]: {\n    [BaseNodeAction.Read]: 'table|read',\n    [BaseNodeAction.Create]: 'table|create',\n    [BaseNodeAction.Update]: 'table|update',\n    [BaseNodeAction.Delete]: 'table|delete',\n  },\n  [BaseNodeResourceType.Dashboard]: {\n    [BaseNodeAction.Read]: 'base|read',\n    [BaseNodeAction.Create]: 'base|update',\n    [BaseNodeAction.Update]: 'base|update',\n    [BaseNodeAction.Delete]: 'base|update',\n  },\n  [BaseNodeResourceType.Workflow]: {\n    [BaseNodeAction.Read]: 'automation|read',\n    [BaseNodeAction.Create]: 'automation|create',\n    [BaseNodeAction.Update]: 'automation|update',\n    [BaseNodeAction.Delete]: 'automation|delete',\n  },\n  [BaseNodeResourceType.App]: {\n    [BaseNodeAction.Read]: 'app|read',\n    [BaseNodeAction.Create]: 'app|create',\n    [BaseNodeAction.Update]: 'app|update',\n    [BaseNodeAction.Delete]: 'app|delete',\n  },\n};\n\nexport const checkBaseNodePermission = (\n  node: { resourceType: BaseNodeResourceType; resourceId: string },\n  action: BaseNodeAction,\n  permissionContext: IBaseNodePermissionContext\n): boolean => {\n  const { resourceType } = node;\n  const { resourceId } = node;\n  const { tablePermissionMap, permissionSet, appPermissionMap, workflowPermissionMap } =\n    permissionContext;\n  const checkAction = map[resourceType][action];\n  if (resourceType === BaseNodeResourceType.Table && tablePermissionMap) {\n    return tablePermissionMap[resourceId]?.includes(checkAction as TableAction) ?? false;\n  }\n  if (resourceType === BaseNodeResourceType.App && appPermissionMap) {\n    return appPermissionMap[resourceId]?.includes(checkAction as AppAction) ?? false;\n  }\n  if (resourceType === BaseNodeResourceType.Workflow && workflowPermissionMap) {\n    return workflowPermissionMap[resourceId]?.includes(checkAction as AutomationAction) ?? false;\n  }\n  return permissionSet.has(checkAction);\n};\n\nexport const checkBaseNodePermissionCreate = (\n  node: { resourceType: BaseNodeResourceType; resourceId: string },\n  baseNodePermissions: BaseNodeAction[],\n  permissionContext: IBaseNodePermissionContext\n): boolean => {\n  const checkCreate = baseNodePermissions.includes(BaseNodeAction.Create);\n  if (!checkCreate) {\n    return true;\n  }\n  const { resourceType } = node;\n  if (!resourceType) {\n    throw new CustomHttpException(\n      'Cannot create base node with empty resource type',\n      HttpErrorCode.VALIDATION_ERROR,\n      {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.invalidResourceType',\n        },\n      }\n    );\n  }\n\n  return checkBaseNodePermission(node, BaseNodeAction.Create, permissionContext);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { Knex } from 'knex';\nimport { GlobalModule } from '../../global/global.module';\nimport { BaseNodeModule } from './base-node.module';\nimport { BaseNodeService } from './base-node.service';\nimport { buildBatchUpdateSql } from './helper';\n\ndescribe('BaseNodeService', () => {\n  let service: BaseNodeService;\n  let knex: Knex;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, BaseNodeModule],\n    }).compile();\n\n    service = module.get<BaseNodeService>(BaseNodeService);\n    knex = module.get<Knex>('CUSTOM_KNEX');\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('buildBatchUpdateSql', () => {\n    it('should return null for empty data', () => {\n      const result = buildBatchUpdateSql(knex, []);\n      expect(result).toBeNull();\n    });\n\n    it('should return null for data with empty values', () => {\n      const result = buildBatchUpdateSql(knex, [{ id: 'node1', values: {} }]);\n      expect(result).toBeNull();\n    });\n\n    it('should build SQL for single record with single field', () => {\n      const result = buildBatchUpdateSql(knex, [{ id: 'node1', values: { order: 1 } }]);\n\n      expect(result).not.toBeNull();\n      expect(result).toContain('update \"base_node\"');\n      expect(result).toContain('\"order\"');\n      expect(result).toContain(`CASE WHEN \"id\" = 'node1' THEN 1 ELSE \"order\" END`);\n      expect(result).toContain(`where \"id\" in ('node1')`);\n    });\n\n    it('should build SQL for single record with multiple fields', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'node1', values: { parentId: null, order: 5 } },\n      ]);\n\n      expect(result).not.toBeNull();\n      expect(result).toContain('\"parent_id\"'); // camelCase -> snake_case\n      expect(result).toContain('\"order\"');\n      expect(result).toContain(`CASE WHEN \"id\" = 'node1' THEN NULL ELSE \"parent_id\" END`);\n      expect(result).toContain(`CASE WHEN \"id\" = 'node1' THEN 5 ELSE \"order\" END`);\n    });\n\n    it('should build SQL for multiple records with same fields', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'node1', values: { order: 1 } },\n        { id: 'node2', values: { order: 2 } },\n        { id: 'node3', values: { order: 3 } },\n      ]);\n\n      expect(result).not.toBeNull();\n      // Should have multiple WHEN clauses in single CASE\n      expect(result).toContain(`WHEN \"id\" = 'node1' THEN 1`);\n      expect(result).toContain(`WHEN \"id\" = 'node2' THEN 2`);\n      expect(result).toContain(`WHEN \"id\" = 'node3' THEN 3`);\n      expect(result).toContain(`where \"id\" in ('node1', 'node2', 'node3')`);\n    });\n\n    it('should build SQL for multiple records with different fields', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'node1', values: { parentId: 'folder1', order: 1 } },\n        { id: 'node2', values: { order: 2 } }, // only order\n        { id: 'node3', values: { parentId: null } }, // only parentId\n      ]);\n\n      expect(result).not.toBeNull();\n      // parentId CASE should have node1 and node3\n      expect(result).toMatch(/CASE.*node1.*node3.*parent_id.*END/s);\n      // order CASE should have node1 and node2\n      expect(result).toMatch(/CASE.*node1.*node2.*order.*END/s);\n      // All ids in WHERE clause\n      expect(result).toContain(`where \"id\" in ('node1', 'node2', 'node3')`);\n    });\n\n    it('should handle string values correctly', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'node1', values: { resourceType: 'table' } },\n      ]);\n\n      expect(result).not.toBeNull();\n      expect(result).toContain('\"resource_type\"');\n      expect(result).toContain(\"'table'\");\n    });\n\n    it('should convert camelCase keys to snake_case columns', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'node1', values: { parentId: 'p1', resourceType: 'dashboard', createdBy: 'user1' } },\n      ]);\n\n      expect(result).not.toBeNull();\n      expect(result).toContain('\"parent_id\"');\n      expect(result).toContain('\"resource_type\"');\n      expect(result).toContain('\"created_by\"');\n      // Should not contain camelCase versions (without quotes)\n      expect(result).not.toMatch(/[^\"]parentId[^\"]/);\n      expect(result).not.toMatch(/[^\"]resourceType[^\"]/);\n      expect(result).not.toMatch(/[^\"]createdBy[^\"]/);\n    });\n\n    it('should build complete SQL for multiple records with multiple fields', () => {\n      const result = buildBatchUpdateSql(knex, [\n        { id: 'bnod001', values: { parentId: null, order: 10 } },\n        { id: 'bnod002', values: { parentId: 'folder1', order: 20 } },\n        { id: 'bnod003', values: { parentId: 'folder2', order: 30 } },\n      ]);\n\n      expect(result).not.toBeNull();\n\n      // Verify complete SQL structure\n      const expectedSql =\n        'update \"base_node\" set ' +\n        `\"parent_id\" = CASE WHEN \"id\" = 'bnod001' THEN NULL WHEN \"id\" = 'bnod002' THEN 'folder1' WHEN \"id\" = 'bnod003' THEN 'folder2' ELSE \"parent_id\" END, ` +\n        `\"order\" = CASE WHEN \"id\" = 'bnod001' THEN 10 WHEN \"id\" = 'bnod002' THEN 20 WHEN \"id\" = 'bnod003' THEN 30 ELSE \"order\" END ` +\n        `where \"id\" in ('bnod001', 'bnod002', 'bnod003')`;\n\n      expect(result).toBe(expectedSql);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/base-node.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { generateBaseNodeId, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IMoveBaseNodeRo,\n  IBaseNodeVo,\n  IBaseNodeTreeVo,\n  ICreateBaseNodeRo,\n  IDuplicateBaseNodeRo,\n  IDuplicateTableRo,\n  ICreateDashboardRo,\n  ICreateFolderNodeRo,\n  IDuplicateDashboardRo,\n  IUpdateBaseNodeRo,\n  IBaseNodeResourceMeta,\n  IBaseNodeResourceMetaWithId,\n  ICreateTableRo,\n  IBaseNodePresenceCreatePayload,\n  IBaseNodePresenceDeletePayload,\n  IBaseNodePresenceUpdatePayload,\n  IBaseNodeTableResourceMeta,\n} from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { isString, keyBy, omit } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport type { LocalPresence } from 'sharedb/lib/client';\nimport { type IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport {\n  generateBaseNodeListCacheKey,\n  generateBaseShareListCacheKey,\n} from '../../performance-cache/generate-keys';\nimport { PerformanceCacheService } from '../../performance-cache/service';\nimport type { IPerformanceCacheStore } from '../../performance-cache/types';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport type { IClsStore } from '../../types/cls';\nimport { updateOrder } from '../../utils/update-order';\nimport type { IV2Decision } from '../canary/canary.service';\nimport { CanaryService } from '../canary/canary.service';\nimport { DashboardService } from '../dashboard/dashboard.service';\nimport { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service';\nimport { TableOpenApiService } from '../table/open-api/table-open-api.service';\nimport { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';\nimport { TableDuplicateService } from '../table/table-duplicate.service';\nimport { BaseNodeFolderService } from './folder/base-node-folder.service';\nimport { buildBatchUpdateSql, presenceHandler } from './helper';\n\ntype IBaseNodeEntry = {\n  id: string;\n  baseId: string;\n  parentId: string | null;\n  resourceType: string;\n  resourceId: string;\n  order: number;\n  children: { id: string; order: number }[];\n  parent: { id: string } | null;\n};\n\n@Injectable()\nexport class BaseNodeService {\n  private readonly logger = new Logger(BaseNodeService.name);\n  constructor(\n    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,\n    private readonly shareDbService: ShareDbService,\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>,\n    private readonly baseNodeFolderService: BaseNodeFolderService,\n    private readonly canaryService: CanaryService,\n    private readonly tableOpenApiService: TableOpenApiService,\n    private readonly tableOpenApiV2Service: TableOpenApiV2Service,\n    private readonly tableDuplicateService: TableDuplicateService,\n    private readonly dashboardService: DashboardService\n  ) {}\n\n  private get userId() {\n    return this.cls.get('user.id');\n  }\n\n  /**\n   * max depth is maxFolderDepth + 1\n   */\n  private get maxFolderDepth() {\n    return this.thresholdConfig.baseNodeMaxFolderDepth;\n  }\n\n  private setIgnoreBaseNodeListener() {\n    this.cls.set('ignoreBaseNodeListener', true);\n  }\n\n  /**\n   * Delete all share records for a node and invalidate cache\n   */\n  private async deleteNodeShares(baseId: string, nodeId: string): Promise<void> {\n    const deleted = await this.prismaService.baseShare.deleteMany({\n      where: { baseId, nodeId },\n    });\n\n    // Invalidate cache if any shares were deleted\n    if (deleted.count > 0) {\n      await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId));\n    }\n  }\n\n  private getSelect() {\n    return {\n      id: true,\n      baseId: true,\n      parentId: true,\n      resourceType: true,\n      resourceId: true,\n      order: true,\n      children: {\n        select: { id: true, order: true },\n        orderBy: { order: 'asc' as const },\n      },\n      parent: {\n        select: { id: true },\n      },\n    };\n  }\n\n  async getDeleteTableV2Decision(baseId: string, nodeId: string): Promise<IV2Decision | undefined> {\n    const node = await this.prismaService.baseNode.findFirst({\n      where: { baseId, id: nodeId },\n      select: { resourceType: true },\n    });\n\n    if (node?.resourceType !== BaseNodeResourceType.Table) {\n      return undefined;\n    }\n\n    const base = await this.prismaService.txClient().base.findUnique({\n      where: { id: baseId, deletedTime: null },\n      select: { spaceId: true },\n    });\n\n    if (!base?.spaceId) {\n      return { useV2: false, reason: 'disabled' };\n    }\n\n    return this.canaryService.shouldUseV2WithReason(base.spaceId, 'deleteTable');\n  }\n\n  async getCreateTableV2Decision(baseId: string): Promise<IV2Decision | undefined> {\n    const base = await this.prismaService.txClient().base.findUnique({\n      where: { id: baseId, deletedTime: null },\n      select: { spaceId: true },\n    });\n\n    if (!base?.spaceId) {\n      return { useV2: false, reason: 'disabled' };\n    }\n\n    return this.canaryService.shouldUseV2WithReason(base.spaceId, 'createTable');\n  }\n\n  private generateDefaultUrl(\n    baseId: string,\n    resourceType: BaseNodeResourceType,\n    resourceId: string,\n    resourceMeta?: IBaseNodeResourceMeta\n  ): string {\n    switch (resourceType) {\n      case BaseNodeResourceType.Table: {\n        const tableMeta = resourceMeta as IBaseNodeTableResourceMeta | undefined;\n        const viewId = tableMeta?.defaultViewId;\n        if (viewId) {\n          return `/base/${baseId}/table/${resourceId}/${viewId}`;\n        }\n        return `/base/${baseId}/table/${resourceId}`;\n      }\n      case BaseNodeResourceType.Dashboard:\n        return `/base/${baseId}/dashboard/${resourceId}`;\n      case BaseNodeResourceType.Workflow:\n        return `/base/${baseId}/automation/${resourceId}`;\n      case BaseNodeResourceType.App:\n        return `/base/${baseId}/app/${resourceId}`;\n      case BaseNodeResourceType.Folder:\n        return `/base/${baseId}`;\n      default:\n        return `/base/${baseId}`;\n    }\n  }\n\n  private async entry2vo(\n    entry: IBaseNodeEntry,\n    resource?: IBaseNodeResourceMeta\n  ): Promise<IBaseNodeVo> {\n    const resourceMeta =\n      resource ||\n      (\n        await this.getNodeResource(entry.baseId, entry.resourceType as BaseNodeResourceType, [\n          entry.resourceId,\n        ])\n      )[0];\n    const resourceMetaWithoutId = resource ? resource : omit(resourceMeta, 'id');\n\n    const defaultUrl = this.generateDefaultUrl(\n      entry.baseId,\n      entry.resourceType as BaseNodeResourceType,\n      entry.resourceId,\n      resourceMetaWithoutId\n    );\n\n    return {\n      ...entry,\n      resourceType: entry.resourceType as BaseNodeResourceType,\n      resourceMeta: resourceMetaWithoutId,\n      defaultUrl,\n    };\n  }\n\n  protected getTableResources(baseId: string, ids?: string[]) {\n    return this.prismaService.tableMeta.findMany({\n      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },\n      select: {\n        id: true,\n        name: true,\n        icon: true,\n      },\n    });\n  }\n\n  protected getDashboardResources(baseId: string, ids?: string[]) {\n    return this.prismaService.dashboard.findMany({\n      where: { baseId, id: { in: ids ? ids : undefined } },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  protected getFolderResources(baseId: string, ids?: string[]) {\n    return this.prismaService.baseNodeFolder.findMany({\n      where: { baseId, id: { in: ids ? ids : undefined } },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  protected async getNodeResource(\n    baseId: string,\n    type: BaseNodeResourceType,\n    ids?: string[]\n  ): Promise<IBaseNodeResourceMetaWithId[]> {\n    switch (type) {\n      case BaseNodeResourceType.Folder:\n        return this.getFolderResources(baseId, ids);\n      case BaseNodeResourceType.Table:\n        return this.getTableResources(baseId, ids);\n      case BaseNodeResourceType.Dashboard:\n        return this.getDashboardResources(baseId, ids);\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  protected getResourceTypes(): BaseNodeResourceType[] {\n    return [\n      BaseNodeResourceType.Folder,\n      BaseNodeResourceType.Table,\n      BaseNodeResourceType.Dashboard,\n    ];\n  }\n\n  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {\n    const resourceTypes = this.getResourceTypes();\n    const resourceResults = await Promise.all(\n      resourceTypes.map((type) => this.getNodeResource(baseId, type))\n    );\n\n    const resources = resourceResults.flatMap((list, index) =>\n      list.map((r) => ({ ...r, type: resourceTypes[index] }))\n    );\n\n    const resourceMap = keyBy(resources, (r) => `${r.type}_${r.id}`);\n    const resourceKeys = new Set(resources.map((r) => `${r.type}_${r.id}`));\n\n    const nodes = await this.prismaService.baseNode.findMany({\n      where: { baseId },\n      select: this.getSelect(),\n      orderBy: { order: 'asc' },\n    });\n\n    const nodeKeys = new Set(nodes.map((n) => `${n.resourceType}_${n.resourceId}`));\n\n    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));\n    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));\n    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));\n    const orphans = nodes.filter(\n      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)\n    );\n\n    if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {\n      return nodes.map((entry) => {\n        const key = `${entry.resourceType}_${entry.resourceId}`;\n        const resource = resourceMap[key];\n        const resourceMeta = omit(resource, 'id');\n        const defaultUrl = this.generateDefaultUrl(\n          baseId,\n          entry.resourceType as BaseNodeResourceType,\n          entry.resourceId,\n          resourceMeta\n        );\n        return {\n          ...entry,\n          resourceType: entry.resourceType as BaseNodeResourceType,\n          resourceMeta,\n          defaultUrl,\n        };\n      });\n    }\n\n    const finalMenus = await this.prismaService.$tx(async (prisma) => {\n      // Delete redundant\n      if (toDelete.length > 0) {\n        await prisma.baseNode.deleteMany({\n          where: { id: { in: toDelete.map((m) => m.id) } },\n        });\n      }\n\n      // Prepare for create and update\n      let nextOrder = 0;\n      if (toCreate.length > 0 || orphans.length > 0) {\n        const maxOrderAgg = await prisma.baseNode.aggregate({\n          where: { baseId },\n          _max: { order: true },\n        });\n        nextOrder = (maxOrderAgg._max.order ?? 0) + 1;\n      }\n\n      // Create missing\n      if (toCreate.length > 0) {\n        await prisma.baseNode.createMany({\n          data: toCreate.map((r) => ({\n            id: generateBaseNodeId(),\n            baseId,\n            resourceType: r.type,\n            resourceId: r.id,\n            order: nextOrder++,\n            parentId: null,\n            createdBy: this.userId,\n          })),\n        });\n      }\n\n      // Reset orphans to root level with new order\n      if (orphans.length > 0) {\n        await this.batchUpdateBaseNodes(\n          orphans.map((orphan, index) => ({\n            id: orphan.id,\n            values: { parentId: null, order: nextOrder + index },\n          }))\n        );\n      }\n      return prisma.baseNode.findMany({\n        where: { baseId },\n        select: this.getSelect(),\n        orderBy: { order: 'asc' },\n      });\n    });\n\n    return await Promise.all(\n      finalMenus.map(async (entry) => {\n        const key = `${entry.resourceType}_${entry.resourceId}`;\n        const resource = resourceMap[key];\n        return await this.entry2vo(entry, omit(resource, 'id'));\n      })\n    );\n  }\n\n  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {\n    return this.performanceCacheService.wrap(\n      generateBaseNodeListCacheKey(baseId),\n      () => this.prepareNodeList(baseId),\n      {\n        ttl: 60 * 60, // 1 hour\n        statsType: 'base-node-list',\n      }\n    );\n  }\n\n  async getList(baseId: string): Promise<IBaseNodeVo[]> {\n    return this.getNodeListWithCache(baseId);\n  }\n\n  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {\n    const nodes = await this.getNodeListWithCache(baseId);\n\n    return {\n      nodes,\n      maxFolderDepth: this.maxFolderDepth,\n    };\n  }\n\n  async getNode(baseId: string, nodeId: string) {\n    const node = await this.prismaService.baseNode\n      .findFirstOrThrow({\n        where: { baseId, id: nodeId },\n        select: this.getSelect(),\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.notFound',\n          },\n        });\n      });\n    return {\n      ...node,\n      resourceType: node.resourceType as BaseNodeResourceType,\n    };\n  }\n\n  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {\n    const node = await this.getNode(baseId, nodeId);\n    return this.entry2vo(node);\n  }\n\n  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {\n    this.setIgnoreBaseNodeListener();\n\n    const { resourceType, parentId } = ro;\n    const resource = await this.createResource(baseId, ro);\n    const resourceId = resource.id;\n\n    const maxOrder = await this.getMaxOrder(baseId);\n    const entry = await this.prismaService.baseNode.create({\n      data: {\n        id: generateBaseNodeId(),\n        baseId,\n        resourceType,\n        resourceId,\n        order: maxOrder + 1,\n        parentId,\n        createdBy: this.userId,\n      },\n      select: this.getSelect(),\n    });\n\n    const vo = await this.entry2vo(entry, omit(resource, 'id'));\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'create',\n        data: { ...vo },\n      });\n    });\n\n    return vo;\n  }\n\n  protected async createResource(\n    baseId: string,\n    createRo: ICreateBaseNodeRo\n  ): Promise<IBaseNodeResourceMetaWithId> {\n    const { resourceType, parentId, ...ro } = createRo;\n    const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;\n    if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {\n      throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.parentMustBeFolder',\n        },\n      });\n    }\n\n    if (parentNode && resourceType === BaseNodeResourceType.Folder) {\n      await this.assertFolderDepth(baseId, parentNode.id);\n    }\n\n    switch (resourceType) {\n      case BaseNodeResourceType.Folder: {\n        const folder = await this.baseNodeFolderService.createFolder(\n          baseId,\n          ro as ICreateFolderNodeRo\n        );\n        return { id: folder.id, name: folder.name };\n      }\n      case BaseNodeResourceType.Table: {\n        const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);\n        const table = this.cls.get('useV2')\n          ? await this.tableOpenApiV2Service.createTable(baseId, preparedRo)\n          : await this.tableOpenApiService.createTable(baseId, preparedRo);\n\n        return {\n          id: table.id,\n          name: table.name,\n          icon: table.icon,\n          defaultViewId: table.defaultViewId,\n        };\n      }\n      case BaseNodeResourceType.Dashboard: {\n        const dashboard = await this.dashboardService.createDashboard(\n          baseId,\n          ro as ICreateDashboardRo\n        );\n        return { id: dashboard.id, name: dashboard.name };\n      }\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {\n    this.setIgnoreBaseNodeListener();\n\n    const anchor = await this.prismaService.baseNode\n      .findFirstOrThrow({\n        where: { baseId, id: nodeId },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.notFound',\n          },\n        });\n      });\n    const { resourceType, resourceId } = anchor;\n\n    if (resourceType === BaseNodeResourceType.Folder) {\n      throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',\n        },\n      });\n    }\n\n    const resource = await this.duplicateResource(\n      baseId,\n      resourceType as BaseNodeResourceType,\n      resourceId,\n      ro\n    );\n    const { entry } = await this.prismaService.$tx(async (prisma) => {\n      const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);\n      const newNodeId = generateBaseNodeId();\n      const entry = await prisma.baseNode.create({\n        data: {\n          id: newNodeId,\n          baseId,\n          resourceType,\n          resourceId: resource.id,\n          order: maxOrder + 1,\n          parentId: anchor.parentId,\n          createdBy: this.userId,\n        },\n        select: this.getSelect(),\n      });\n\n      await updateOrder({\n        query: baseId,\n        position: 'after',\n        item: entry,\n        anchorItem: anchor,\n        getNextItem: async (whereOrder, align) => {\n          return prisma.baseNode.findFirst({\n            where: {\n              baseId,\n              parentId: anchor.parentId,\n              order: whereOrder,\n              id: { not: newNodeId },\n            },\n            select: { order: true, id: true },\n            orderBy: { order: align },\n          });\n        },\n        update: async (_, id, data) => {\n          await prisma.baseNode.update({\n            where: { id },\n            data: { parentId: anchor.parentId, order: data.newOrder },\n          });\n        },\n        shuffle: async () => {\n          await this.shuffleOrders(baseId, anchor.parentId);\n        },\n      });\n\n      return {\n        entry,\n      };\n    });\n\n    const vo = await this.entry2vo(entry, omit(resource, 'id'));\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'create',\n        data: { ...vo },\n      });\n    });\n    return vo;\n  }\n\n  protected async duplicateResource(\n    baseId: string,\n    type: BaseNodeResourceType,\n    id: string,\n    duplicateRo: IDuplicateBaseNodeRo\n  ): Promise<IBaseNodeResourceMetaWithId> {\n    switch (type) {\n      case BaseNodeResourceType.Table: {\n        const table = await this.tableDuplicateService.duplicateTable(\n          baseId,\n          id,\n          duplicateRo as IDuplicateTableRo\n        );\n\n        return {\n          id: table.id,\n          name: table.name,\n          icon: table.icon ?? undefined,\n          defaultViewId: table.defaultViewId,\n        };\n      }\n      case BaseNodeResourceType.Dashboard: {\n        const dashboard = await this.dashboardService.duplicateDashboard(\n          baseId,\n          id,\n          duplicateRo as IDuplicateDashboardRo\n        );\n        return { id: dashboard.id, name: dashboard.name };\n      }\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {\n    this.setIgnoreBaseNodeListener();\n\n    const node = await this.prismaService.baseNode\n      .findFirstOrThrow({\n        where: { baseId, id: nodeId },\n        select: this.getSelect(),\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.notFound',\n          },\n        });\n      });\n\n    await this.updateResource(\n      baseId,\n      node.resourceType as BaseNodeResourceType,\n      node.resourceId,\n      ro\n    );\n\n    const vo = await this.entry2vo(node);\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'update',\n        data: { ...vo },\n      });\n    });\n    return vo;\n  }\n\n  protected async updateResource(\n    baseId: string,\n    type: BaseNodeResourceType,\n    id: string,\n    updateRo: IUpdateBaseNodeRo\n  ): Promise<void> {\n    const { name, icon } = updateRo;\n    switch (type) {\n      case BaseNodeResourceType.Folder:\n        if (name) {\n          await this.baseNodeFolderService.renameFolder(baseId, id, { name });\n        }\n        break;\n      case BaseNodeResourceType.Table:\n        if (name) {\n          await this.tableOpenApiService.updateName(baseId, id, name);\n        }\n        if (icon) {\n          await this.tableOpenApiService.updateIcon(baseId, id, icon);\n        }\n        break;\n      case BaseNodeResourceType.Dashboard:\n        if (name) {\n          await this.dashboardService.renameDashboard(baseId, id, name);\n        }\n        break;\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async delete(baseId: string, nodeId: string, permanent?: boolean) {\n    this.setIgnoreBaseNodeListener();\n\n    const node = await this.prismaService.baseNode\n      .findFirstOrThrow({\n        where: { baseId, id: nodeId },\n        select: { resourceType: true, resourceId: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.notFound',\n          },\n        });\n      });\n    if (node.resourceType === BaseNodeResourceType.Folder) {\n      const children = await this.prismaService.baseNode.findMany({\n        where: { baseId, parentId: nodeId },\n      });\n      if (children.length > 0) {\n        throw new CustomHttpException(\n          'Cannot delete folder because it is not empty',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',\n            },\n          }\n        );\n      }\n    }\n\n    // Clean up share records for this node before deletion\n    await this.deleteNodeShares(baseId, nodeId);\n\n    await this.deleteResource(\n      baseId,\n      node.resourceType as BaseNodeResourceType,\n      node.resourceId,\n      permanent\n    );\n    await this.prismaService.baseNode.delete({\n      where: { id: nodeId },\n    });\n\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'delete',\n        data: { id: nodeId },\n      });\n    });\n    return node;\n  }\n\n  protected async deleteResource(\n    baseId: string,\n    type: BaseNodeResourceType,\n    id: string,\n    permanent?: boolean\n  ) {\n    switch (type) {\n      case BaseNodeResourceType.Folder:\n        await this.baseNodeFolderService.deleteFolder(baseId, id);\n        break;\n      case BaseNodeResourceType.Table:\n        if (this.cls.get('useV2')) {\n          await this.tableOpenApiV2Service.deleteTable(\n            baseId,\n            id,\n            permanent ? 'permanent' : undefined\n          );\n          break;\n        }\n        if (permanent) {\n          await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);\n        } else {\n          await this.tableOpenApiService.deleteTable(baseId, id);\n        }\n        break;\n      case BaseNodeResourceType.Dashboard:\n        await this.dashboardService.deleteDashboard(baseId, id);\n        break;\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {\n    this.setIgnoreBaseNodeListener();\n\n    const { parentId, anchorId, position } = ro;\n\n    const node = await this.prismaService.baseNode\n      .findFirstOrThrow({\n        where: { baseId, id: nodeId },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.notFound',\n          },\n        });\n      });\n\n    if (isString(parentId) && isString(anchorId)) {\n      throw new CustomHttpException(\n        'Only one of parentId or anchorId must be provided',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',\n          },\n        }\n      );\n    }\n\n    if (parentId === nodeId) {\n      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',\n        },\n      });\n    }\n\n    if (anchorId === nodeId) {\n      throw new CustomHttpException(\n        'Cannot move node to its own child (circular reference)',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',\n          },\n        }\n      );\n    }\n\n    let newNode: IBaseNodeEntry;\n    if (anchorId) {\n      newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });\n    } else if (parentId === null) {\n      newNode = await this.moveNodeToRoot(baseId, node.id);\n    } else if (parentId) {\n      newNode = await this.moveNodeToFolder(baseId, node.id, parentId);\n    } else {\n      throw new CustomHttpException(\n        'At least one of parentId or anchorId must be provided',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',\n          },\n        }\n      );\n    }\n\n    const vo = await this.entry2vo(newNode);\n    this.presenceHandler(baseId, (presence) => {\n      presence.submit({\n        event: 'update',\n        data: { ...vo },\n      });\n    });\n\n    return vo;\n  }\n\n  private async moveNodeToRoot(baseId: string, nodeId: string) {\n    return this.prismaService.$tx(async (prisma) => {\n      const maxOrder = await this.getMaxOrder(baseId);\n      return prisma.baseNode.update({\n        where: { id: nodeId },\n        select: this.getSelect(),\n        data: {\n          parentId: null,\n          order: maxOrder + 1,\n          lastModifiedBy: this.userId,\n        },\n      });\n    });\n  }\n\n  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {\n    return this.prismaService.$tx(async (prisma) => {\n      const node = await prisma.baseNode\n        .findFirstOrThrow({\n          where: { baseId, id: nodeId },\n        })\n        .catch(() => {\n          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.notFound',\n            },\n          });\n        });\n\n      const parentNode = await prisma.baseNode\n        .findFirstOrThrow({\n          where: { baseId, id: parentId },\n        })\n        .catch(() => {\n          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.parentNotFound',\n            },\n          });\n        });\n\n      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {\n        throw new CustomHttpException(\n          `Parent ${parentId} is not a folder`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',\n            },\n          }\n        );\n      }\n\n      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {\n        await this.assertFolderDepth(baseId, parentId);\n      }\n\n      // Check for circular reference\n      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);\n      if (isCircular) {\n        throw new CustomHttpException(\n          'Cannot move node to its own child (circular reference)',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.circularReference',\n            },\n          }\n        );\n      }\n\n      const maxOrder = await this.getMaxOrder(baseId);\n      return prisma.baseNode.update({\n        where: { id: nodeId },\n        select: this.getSelect(),\n        data: {\n          parentId,\n          order: maxOrder + 1,\n          lastModifiedBy: this.userId,\n        },\n      });\n    });\n  }\n\n  private async moveNodeTo(\n    baseId: string,\n    nodeId: string,\n    ro: Pick<IMoveBaseNodeRo, 'anchorId' | 'position'>\n  ): Promise<IBaseNodeEntry> {\n    const { anchorId, position } = ro;\n    return this.prismaService.$tx(async (prisma) => {\n      const node = await prisma.baseNode\n        .findFirstOrThrow({\n          where: { baseId, id: nodeId },\n        })\n        .catch(() => {\n          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.notFound',\n            },\n          });\n        });\n\n      const anchor = await prisma.baseNode\n        .findFirstOrThrow({\n          where: { baseId, id: anchorId },\n        })\n        .catch(() => {\n          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.anchorNotFound',\n            },\n          });\n        });\n\n      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {\n        await this.assertFolderDepth(baseId, anchor.parentId);\n      }\n\n      await updateOrder({\n        query: baseId,\n        position: position ?? 'after',\n        item: node,\n        anchorItem: anchor,\n        getNextItem: async (whereOrder, align) => {\n          return prisma.baseNode.findFirst({\n            where: {\n              baseId,\n              parentId: anchor.parentId,\n              order: whereOrder,\n            },\n            select: { order: true, id: true },\n            orderBy: { order: align },\n          });\n        },\n        update: async (_, id, data) => {\n          await prisma.baseNode.update({\n            where: { id },\n            data: { parentId: anchor.parentId, order: data.newOrder },\n          });\n        },\n        shuffle: async () => {\n          await this.shuffleOrders(baseId, anchor.parentId);\n        },\n      });\n\n      return prisma.baseNode.findFirstOrThrow({\n        where: { baseId, id: nodeId },\n        select: this.getSelect(),\n      });\n    });\n  }\n\n  async getMaxOrder(baseId: string, parentId?: string | null) {\n    const prisma = this.prismaService.txClient();\n    const aggregate = await prisma.baseNode.aggregate({\n      where: { baseId, parentId },\n      _max: { order: true },\n    });\n\n    return aggregate._max.order ?? 0;\n  }\n\n  private async shuffleOrders(baseId: string, parentId: string | null) {\n    const prisma = this.prismaService.txClient();\n    const siblings = await prisma.baseNode.findMany({\n      where: { baseId, parentId },\n      orderBy: { order: 'asc' },\n    });\n\n    for (const [index, sibling] of siblings.entries()) {\n      await prisma.baseNode.update({\n        where: { id: sibling.id },\n        data: { order: index + 10, lastModifiedBy: this.userId },\n      });\n    }\n  }\n\n  private async getParentNodeOrThrow(id: string) {\n    const entry = await this.prismaService.baseNode.findFirst({\n      where: { id },\n      select: {\n        id: true,\n        parentId: true,\n        resourceType: true,\n        resourceId: true,\n      },\n    });\n    if (!entry) {\n      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.notFound',\n        },\n      });\n    }\n    return entry;\n  }\n\n  private async assertFolderDepth(baseId: string, id: string) {\n    const folderDepth = await this.getFolderDepth(baseId, id);\n    if (folderDepth >= this.maxFolderDepth) {\n      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',\n        },\n      });\n    }\n  }\n\n  private async getFolderDepth(baseId: string, id: string) {\n    const prisma = this.prismaService.txClient();\n    const allFolders = await prisma.baseNode.findMany({\n      where: { baseId, resourceType: BaseNodeResourceType.Folder },\n      select: { id: true, parentId: true },\n    });\n\n    let depth = 0;\n    if (allFolders.length === 0) {\n      return depth;\n    }\n\n    const folderMap = keyBy(allFolders, 'id');\n    let current = id;\n    while (current) {\n      depth++;\n      const folder = folderMap[current];\n      if (!folder) {\n        throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.baseNode.folderNotFound',\n          },\n        });\n      }\n      if (folder.parentId === id) {\n        throw new CustomHttpException(\n          'A folder cannot be its own parent',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.circularReference',\n            },\n          }\n        );\n      }\n      current = folder.parentId ?? '';\n    }\n    return depth;\n  }\n\n  private async isCircularReference(\n    baseId: string,\n    nodeId: string,\n    parentId: string\n  ): Promise<boolean> {\n    const knex = this.knex;\n\n    // Non-recursive query: Start with the parent node\n    const nonRecursiveQuery = knex\n      .select('id', 'parent_id', 'base_id')\n      .from('base_node')\n      .where('id', parentId)\n      .andWhere('base_id', baseId);\n\n    // Recursive query: Traverse up the parent chain\n    const recursiveQuery = knex\n      .select('bn.id', 'bn.parent_id', 'bn.base_id')\n      .from('base_node as bn')\n      .innerJoin('ancestors as a', function () {\n        // Join condition: bn.id = a.parent_id (get parent of current ancestor)\n        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));\n      });\n\n    // Combine non-recursive and recursive queries\n    const cteQuery = nonRecursiveQuery.union(recursiveQuery);\n\n    // Build final query with recursive CTE\n    const finalQuery = knex\n      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)\n      .select('id')\n      .from('ancestors')\n      .where('id', nodeId)\n      .limit(1)\n      .toQuery();\n\n    // Execute query\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);\n\n    return result.length > 0;\n  }\n\n  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {\n    const sql = buildBatchUpdateSql(this.knex, data);\n    if (!sql) {\n      return;\n    }\n    await this.prismaService.txClient().$executeRawUnsafe(sql);\n  }\n\n  private presenceHandler<\n    T =\n      | IBaseNodePresenceCreatePayload\n      | IBaseNodePresenceUpdatePayload\n      | IBaseNodePresenceDeletePayload,\n  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {\n    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));\n    // Skip if ShareDB connection is already closed (e.g., during shutdown)\n    if (this.shareDbService.shareDbAdapter.closed) {\n      this.logger.error('ShareDB connection is already closed, presence handler skipped');\n      return;\n    }\n    presenceHandler(baseId, this.shareDbService, handler);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/folder/base-node-folder.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Controller, Post, Patch, Delete, Param, Body } from '@nestjs/common';\nimport type { ICreateBaseNodeFolderVo, IUpdateBaseNodeFolderVo } from '@teable/openapi';\nimport {\n  createBaseNodeFolderRoSchema,\n  ICreateBaseNodeFolderRo,\n  updateBaseNodeFolderRoSchema,\n  IUpdateBaseNodeFolderRo,\n} from '@teable/openapi';\nimport { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../../event-emitter/events';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { BaseNodeFolderService } from './base-node-folder.service';\n\n@Controller('api/base/:baseId/node/folder')\nexport class BaseNodeFolderController {\n  constructor(private readonly baseNodeFolderService: BaseNodeFolderService) {}\n\n  @Post()\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.BASE_FOLDER_CREATE)\n  async createFolder(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(createBaseNodeFolderRoSchema)) ro: ICreateBaseNodeFolderRo\n  ): Promise<ICreateBaseNodeFolderVo> {\n    return this.baseNodeFolderService.createFolder(baseId, ro);\n  }\n\n  @Patch(':folderId')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.BASE_FOLDER_UPDATE)\n  async renameFolder(\n    @Param('baseId') baseId: string,\n    @Param('folderId') folderId: string,\n    @Body(new ZodValidationPipe(updateBaseNodeFolderRoSchema)) ro: IUpdateBaseNodeFolderRo\n  ): Promise<IUpdateBaseNodeFolderVo> {\n    return this.baseNodeFolderService.renameFolder(baseId, folderId, ro);\n  }\n\n  @Delete(':folderId')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.BASE_FOLDER_DELETE)\n  async deleteFolder(@Param('baseId') baseId: string, @Param('folderId') folderId: string) {\n    return this.baseNodeFolderService.deleteFolder(baseId, folderId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/folder/base-node-folder.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { BaseNodeFolderController } from './base-node-folder.controller';\nimport { BaseNodeFolderService } from './base-node-folder.service';\n\n@Module({\n  imports: [],\n  providers: [BaseNodeFolderService],\n  exports: [BaseNodeFolderService],\n  controllers: [BaseNodeFolderController],\n})\nexport class BaseNodeFolderModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/folder/base-node-folder.service.ts",
    "content": "import { Logger, Injectable } from '@nestjs/common';\nimport { generateBaseNodeFolderId, getUniqName, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ICreateBaseNodeFolderRo, IUpdateBaseNodeFolderRo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\n\n@Injectable()\nexport class BaseNodeFolderService {\n  private readonly logger = new Logger(BaseNodeFolderService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  private get userId() {\n    return this.cls.get('user.id');\n  }\n\n  async createFolder(baseId: string, ro: ICreateBaseNodeFolderRo) {\n    const { name } = ro;\n    const uniqueName = await this.getUniqueName(baseId, name);\n    return this.prismaService.txClient().baseNodeFolder.create({\n      data: {\n        id: generateBaseNodeFolderId(),\n        baseId,\n        name: uniqueName,\n        createdBy: this.userId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  async renameFolder(baseId: string, folderId: string, body: IUpdateBaseNodeFolderRo) {\n    const { name } = body;\n\n    return this.prismaService.$tx(async (prisma) => {\n      const find = await prisma.baseNodeFolder.findFirst({\n        where: { baseId, name, id: { not: folderId } },\n      });\n      if (find) {\n        throw new CustomHttpException(\n          'Folder name already exists',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseNode.nameAlreadyExists',\n            },\n          }\n        );\n      }\n\n      return prisma.baseNodeFolder.update({\n        where: { id: folderId },\n        data: { name, lastModifiedBy: this.userId },\n        select: {\n          id: true,\n          name: true,\n        },\n      });\n    });\n  }\n\n  async deleteFolder(baseId: string, folderId: string) {\n    await this.prismaService.txClient().baseNodeFolder.delete({\n      where: { baseId, id: folderId },\n    });\n  }\n\n  private async getUniqueName(baseId: string, name: string) {\n    const list = await this.prismaService.baseNodeFolder.findMany({\n      where: { baseId },\n      select: { name: true },\n    });\n    const names = list.map((item) => item.name);\n    return getUniqName(name, names);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/helper.ts",
    "content": "import { getBaseNodeChannel } from '@teable/core';\nimport type {\n  IBaseNodePresenceFlushPayload,\n  IBaseNodePresenceCreatePayload,\n  IBaseNodePresenceUpdatePayload,\n  IBaseNodePresenceDeletePayload,\n} from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { snakeCase } from 'lodash';\nimport type { LocalPresence } from 'sharedb/lib/client';\nimport type { ShareDbService } from '../../share-db/share-db.service';\n\nexport const buildBatchUpdateSql = (\n  knex: Knex,\n  data: { id: string; values: { [key: string]: unknown } }[]\n): string | null => {\n  if (data.length === 0) {\n    return null;\n  }\n\n  const caseStatements: Record<string, { when: string; then: unknown }[]> = {};\n  for (const { id, values } of data) {\n    for (const [key, value] of Object.entries(values)) {\n      if (!caseStatements[key]) {\n        caseStatements[key] = [];\n      }\n      caseStatements[key].push({ when: id, then: value });\n    }\n  }\n\n  const updatePayload: Record<string, Knex.Raw> = {};\n  for (const [key, statements] of Object.entries(caseStatements)) {\n    if (statements.length === 0) {\n      continue;\n    }\n    const column = snakeCase(key);\n    const whenClauses: string[] = [];\n    const caseBindings: unknown[] = [];\n    for (const { when, then } of statements) {\n      whenClauses.push('WHEN ?? = ? THEN ?');\n      caseBindings.push('id', when, then);\n    }\n    const caseExpression = `CASE ${whenClauses.join(' ')} ELSE ?? END`;\n    const rawExpression = knex.raw(caseExpression, [...caseBindings, column]);\n    updatePayload[column] = rawExpression;\n  }\n\n  if (Object.keys(updatePayload).length === 0) {\n    return null;\n  }\n\n  const idsToUpdate = data.map((item) => item.id);\n  return knex('base_node').update(updatePayload).whereIn('id', idsToUpdate).toQuery();\n};\n\nexport const presenceHandler = <\n  T =\n    | IBaseNodePresenceFlushPayload\n    | IBaseNodePresenceCreatePayload\n    | IBaseNodePresenceUpdatePayload\n    | IBaseNodePresenceDeletePayload,\n>(\n  baseId: string,\n  shareDbService: ShareDbService,\n  handler: (presence: LocalPresence<T>) => void\n) => {\n  const channel = getBaseNodeChannel(baseId);\n  const presence = shareDbService.connect().getPresence(channel);\n  const localPresence = presence.create(channel);\n  handler(localPresence);\n  localPresence.destroy();\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-node/types.ts",
    "content": "import type { AppAction, AutomationAction, TableAction } from '@teable/core';\n\nexport enum BaseNodeAction {\n  Read = 'base_node|read',\n  Create = 'base_node|create',\n  Update = 'base_node|update',\n  Delete = 'base_node|delete',\n}\n\nexport type IBaseNodePermissionContext = {\n  tablePermissionMap?: Record<string, TableAction[]>;\n  permissionSet: Set<string>;\n  appPermissionMap?: Record<string, AppAction[]>;\n  workflowPermissionMap?: Record<string, AutomationAction[]>;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CustomHttpException } from '../../custom.exception';\n\nexport interface IBaseShareInfo {\n  shareId: string;\n  baseId: string;\n  nodeId: string;\n  allowSave: boolean | null;\n  allowCopy: boolean | null;\n}\n\nexport interface IJwtBaseShareInfo {\n  shareId: string;\n  password: string;\n}\n\n@Injectable()\nexport class BaseShareAuthService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly jwtService: JwtService\n  ) {}\n\n  async validateJwtToken(token: string) {\n    try {\n      return await this.jwtService.verifyAsync<IJwtBaseShareInfo>(token);\n    } catch {\n      throw new UnauthorizedException();\n    }\n  }\n\n  async authBaseShare(shareId: string, pass: string): Promise<string | null> {\n    const share = await this.prismaService.baseShare.findUnique({\n      where: { shareId },\n      select: { shareId: true, password: true, enabled: true },\n    });\n\n    if (!share || !share.enabled) {\n      return null;\n    }\n\n    const password = share.password;\n    if (!password) {\n      throw new CustomHttpException(\n        'Password restriction is not enabled',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareAuth.passwordRestrictionNotEnabled',\n          },\n        }\n      );\n    }\n    return pass === password ? shareId : null;\n  }\n\n  async authToken(jwtShareInfo: IJwtBaseShareInfo) {\n    return await this.jwtService.signAsync(jwtShareInfo);\n  }\n\n  async getBaseShareInfo(shareId: string): Promise<IBaseShareInfo> {\n    const share = await this.prismaService.baseShare.findUnique({\n      where: { shareId },\n    });\n\n    if (!share || !share.enabled) {\n      throw new CustomHttpException('Base share not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.baseShare.notFound',\n        },\n      });\n    }\n\n    if (!share.nodeId) {\n      throw new CustomHttpException('Base share has no nodeId', HttpErrorCode.NOT_FOUND);\n    }\n\n    return {\n      shareId: share.shareId,\n      baseId: share.baseId,\n      nodeId: share.nodeId,\n      allowSave: share.allowSave,\n      allowCopy: share.allowCopy,\n    };\n  }\n\n  async hasPassword(shareId: string): Promise<boolean> {\n    const share = await this.prismaService.baseShare.findUnique({\n      where: { shareId },\n      select: { password: true, enabled: true },\n    });\n\n    if (!share || !share.enabled) {\n      return false;\n    }\n\n    return !!share.password;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts",
    "content": "import { Body, Controller, Get, HttpCode, Post, Res, UseGuards, Request } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  BaseDuplicateMode,\n  copyBaseShareRoSchema,\n  ICopyBaseShareRo,\n  type IGetBaseShareVo,\n  type IBaseShareAuthVo,\n  type ICopyBaseShareVo,\n} from '@teable/openapi';\nimport { Response } from 'express';\nimport { CustomHttpException } from '../../custom.exception';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AllowAnonymous } from '../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { ResourceMeta } from '../auth/decorators/resource_meta.decorator';\nimport { PermissionGuard } from '../auth/guard/permission.guard';\nimport { PermissionService } from '../auth/permission.service';\nimport { BaseDuplicateService } from '../base/base-duplicate.service';\nimport type { IBaseShareInfo } from './base-share-auth.service';\nimport { BaseShareAuthService } from './base-share-auth.service';\nimport { BaseShareAuthLocalGuard } from './guard/base-share-auth-local.guard';\nimport { BaseShareAuthGuard } from './guard/base-share-auth.guard';\n\n@Controller('api/share')\nexport class BaseShareOpenController {\n  constructor(\n    private readonly baseShareAuthService: BaseShareAuthService,\n    private readonly prismaService: PrismaService,\n    private readonly baseDuplicateService: BaseDuplicateService,\n    private readonly permissionService: PermissionService\n  ) {}\n\n  @HttpCode(200)\n  @Public()\n  @UseGuards(BaseShareAuthLocalGuard)\n  @Post('/:shareId/base/auth')\n  async auth(\n    @Request() req: Express.Request & { shareId: string; password: string },\n    @Res({ passthrough: true }) res: Response\n  ): Promise<IBaseShareAuthVo> {\n    const shareId = req.shareId;\n    const password = req.password;\n    const token = await this.baseShareAuthService.authToken({ shareId, password });\n    res.cookie(shareId, token, {\n      httpOnly: true,\n      secure: process.env.NODE_ENV === 'production',\n      sameSite: 'lax',\n      maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days\n    });\n    return { token };\n  }\n\n  @Public()\n  @UseGuards(BaseShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/base')\n  async getBaseShare(\n    @Request() req: Express.Request & { baseShareInfo: IBaseShareInfo }\n  ): Promise<IGetBaseShareVo> {\n    const shareInfo = req.baseShareInfo;\n    const { baseId, nodeId, allowSave, allowCopy } = shareInfo;\n\n    // Build default URL for redirect\n    const defaultUrl = await this.buildDefaultUrl(baseId, nodeId);\n\n    return {\n      baseId,\n      shareMeta: {\n        password: await this.baseShareAuthService.hasPassword(shareInfo.shareId),\n        nodeId,\n        allowSave,\n        allowCopy,\n      },\n      defaultUrl,\n    };\n  }\n\n  /**\n   * Build the default URL for share redirect.\n   * Returns a URL like \"/base/xxx/table/yyy/zzz\" or \"/base/xxx/dashboard/yyy\"\n   */\n  private async buildDefaultUrl(baseId: string, nodeId: string): Promise<string | undefined> {\n    // Get all nodes in the base\n    const allNodes = await this.prismaService.baseNode.findMany({\n      where: { baseId },\n      select: {\n        id: true,\n        parentId: true,\n        resourceType: true,\n        resourceId: true,\n        order: true,\n      },\n      orderBy: { order: 'asc' },\n    });\n\n    if (allNodes.length === 0) {\n      return undefined;\n    }\n\n    let targetNode: { resourceType: string; resourceId: string } | null = null;\n\n    // Find the shared node\n    const sharedNode = allNodes.find((n) => n.id === nodeId);\n    if (sharedNode) {\n      // If the shared node is a folder, find the first accessible non-folder child\n      if (sharedNode.resourceType.toLowerCase() === 'folder') {\n        targetNode = this.findFirstAccessibleNode(allNodes, nodeId);\n      } else {\n        targetNode = { resourceType: sharedNode.resourceType, resourceId: sharedNode.resourceId };\n      }\n    }\n\n    if (!targetNode) {\n      return undefined;\n    }\n\n    // Build URL based on resource type\n    const resourceType = targetNode.resourceType.toLowerCase();\n    const resourceId = targetNode.resourceId;\n\n    switch (resourceType) {\n      case 'table':\n        return `/base/${baseId}/table/${resourceId}`;\n      case 'dashboard':\n        return `/base/${baseId}/dashboard/${resourceId}`;\n      case 'workflow':\n        return `/base/${baseId}/automation/${resourceId}`;\n      case 'app':\n        return `/base/${baseId}/app/${resourceId}`;\n      default:\n        return undefined;\n    }\n  }\n\n  @HttpCode(200)\n  @UseGuards(BaseShareAuthGuard, PermissionGuard)\n  @Permissions('base|create')\n  @ResourceMeta('spaceId', 'body')\n  @Post('/:shareId/base/copy')\n  async copyBaseShare(\n    @Request() req: Express.Request & { baseShareInfo: IBaseShareInfo },\n    @Body(new ZodValidationPipe(copyBaseShareRoSchema)) body: ICopyBaseShareRo\n  ): Promise<ICopyBaseShareVo> {\n    const { baseId: fromBaseId, nodeId, allowSave } = req.baseShareInfo;\n    const { spaceId, name, withRecords = true, baseId: targetBaseId } = body;\n\n    // Check if share allows saving\n    if (!allowSave) {\n      throw new CustomHttpException(\n        'This share does not allow copying',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseShare.copyNotAllowed',\n          },\n        }\n      );\n    }\n\n    // Validate target base if copying into an existing base\n    if (targetBaseId) {\n      const targetBase = await this.prismaService.base.findFirst({\n        where: { id: targetBaseId, deletedTime: null },\n        select: { spaceId: true },\n      });\n\n      if (!targetBase) {\n        throw new CustomHttpException('Target base not found', HttpErrorCode.VALIDATION_ERROR);\n      }\n\n      if (targetBase.spaceId !== spaceId) {\n        throw new CustomHttpException(\n          'Target base does not belong to the specified space',\n          HttpErrorCode.VALIDATION_ERROR\n        );\n      }\n\n      await this.permissionService.validPermissions(targetBaseId, ['base|update']);\n    }\n\n    // Copy the base using BaseDuplicateService\n    // allowCrossBase = false to disconnect cross-base links\n    // duplicateMode = CopyShareBase to handle node relationships correctly\n    const { base, recordsLength } = await this.baseDuplicateService.duplicateBase(\n      {\n        fromBaseId,\n        spaceId,\n        name,\n        withRecords,\n        nodes: [nodeId],\n        baseId: targetBaseId,\n      },\n      false, // allowCrossBase = false\n      BaseDuplicateMode.CopyShareBase\n    );\n\n    // Emit audit log for share base copy\n    await this.baseDuplicateService.emitShareBaseCopyAuditLog(\n      base.id,\n      req.baseShareInfo.shareId,\n      recordsLength\n    );\n\n    return {\n      id: base.id,\n      name: base.name,\n      spaceId: base.spaceId,\n    };\n  }\n\n  /**\n   * Find the first accessible non-folder node within a folder hierarchy.\n   * Uses depth-first search with order-based sorting.\n   * @param parentNodeId - null means find from root level\n   */\n  private findFirstAccessibleNode(\n    allNodes: Array<{\n      id: string;\n      parentId: string | null;\n      resourceType: string;\n      resourceId: string;\n      order: number;\n    }>,\n    parentNodeId: string | null\n  ): { resourceType: string; resourceId: string } | null {\n    const children = allNodes\n      .filter((n) => n.parentId === parentNodeId)\n      .sort((a, b) => a.order - b.order);\n\n    for (const child of children) {\n      if (child.resourceType.toLowerCase() !== 'folder') {\n        return { resourceType: child.resourceType, resourceId: child.resourceId };\n      }\n      const found = this.findFirstAccessibleNode(allNodes, child.id);\n      if (found) return found;\n    }\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/base-share.controller.ts",
    "content": "import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';\nimport type { IBaseShareVo } from '@teable/openapi';\nimport {\n  createBaseShareRoSchema,\n  updateBaseShareRoSchema,\n  ICreateBaseShareRo,\n  IUpdateBaseShareRo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { PermissionGuard } from '../auth/guard/permission.guard';\nimport { BaseShareService } from './base-share.service';\n\n@Controller('api/base/:baseId/share')\n@UseGuards(PermissionGuard)\nexport class BaseShareController {\n  constructor(private readonly baseShareService: BaseShareService) {}\n\n  @Post()\n  // eslint-disable-next-line sonarjs/no-duplicate-string\n  @Permissions('base|update')\n  async create(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(createBaseShareRoSchema)) data: ICreateBaseShareRo\n  ): Promise<IBaseShareVo> {\n    return this.baseShareService.createBaseShare(baseId, data);\n  }\n\n  @Get()\n  @Permissions('base|read')\n  async list(@Param('baseId') baseId: string): Promise<{ nodeId: string }[]> {\n    return this.baseShareService.getBaseShareList(baseId);\n  }\n\n  @Get('node/:nodeId')\n  @Permissions('base|read')\n  async getByNodeId(\n    @Param('baseId') baseId: string,\n    @Param('nodeId') nodeId: string\n  ): Promise<IBaseShareVo | null> {\n    return this.baseShareService.getBaseShareByNodeId(baseId, nodeId);\n  }\n\n  @Patch(':shareId')\n  @Permissions('base|update')\n  async update(\n    @Param('baseId') baseId: string,\n    @Param('shareId') shareId: string,\n    @Body(new ZodValidationPipe(updateBaseShareRoSchema)) data: IUpdateBaseShareRo\n  ): Promise<IBaseShareVo> {\n    return this.baseShareService.updateBaseShare(baseId, shareId, data);\n  }\n\n  @Delete(':shareId')\n  @Permissions('base|update')\n  async delete(@Param('baseId') baseId: string, @Param('shareId') shareId: string): Promise<void> {\n    return this.baseShareService.deleteBaseShare(baseId, shareId);\n  }\n\n  @Post(':shareId/refresh')\n  @Permissions('base|update')\n  async refresh(\n    @Param('baseId') baseId: string,\n    @Param('shareId') shareId: string\n  ): Promise<IBaseShareVo> {\n    return this.baseShareService.refreshBaseShareId(baseId, shareId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/base-share.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport { authConfig } from '../../configs/auth.config';\nimport { AuthModule } from '../auth/auth.module';\nimport { PermissionModule } from '../auth/permission.module';\nimport { BaseModule } from '../base/base.module';\nimport { FieldModule } from '../field/field.module';\nimport { ViewModule } from '../view/view.module';\nimport { BaseShareAuthService } from './base-share-auth.service';\nimport { BaseShareOpenController } from './base-share-open.controller';\nimport { BaseShareController } from './base-share.controller';\nimport { BaseShareService } from './base-share.service';\nimport { BaseShareAuthLocalGuard } from './guard/base-share-auth-local.guard';\nimport { BaseShareAuthGuard } from './guard/base-share-auth.guard';\nimport { BaseShareJwtStrategy } from './strategies/jwt.strategy';\n\n@Module({\n  imports: [\n    AuthModule,\n    PermissionModule,\n    BaseModule,\n    FieldModule,\n    ViewModule,\n    JwtModule.registerAsync({\n      useFactory: () => ({\n        secret: authConfig().jwt.secret,\n        signOptions: {\n          expiresIn: '7d',\n        },\n      }),\n    }),\n  ],\n  controllers: [BaseShareController, BaseShareOpenController],\n  providers: [\n    BaseShareService,\n    BaseShareAuthService,\n    BaseShareJwtStrategy,\n    BaseShareAuthGuard,\n    BaseShareAuthLocalGuard,\n  ],\n  exports: [BaseShareService, BaseShareAuthService],\n})\nexport class BaseShareModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/base-share.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { generateShareId, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateBaseShareListCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\n\nconst baseShareNotFoundMessage = 'Base share not found';\nconst baseShareNotFoundKey = 'httpErrors.baseShare.notFound';\nconst baseShareAlreadyExistsKey = 'httpErrors.baseShare.alreadyExists';\n\n@Injectable()\nexport class BaseShareService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  private async invalidateBaseShareListCache(baseId: string): Promise<void> {\n    await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId));\n  }\n\n  private formatBaseShareVo(share: {\n    baseId: string;\n    shareId: string;\n    password: string | null;\n    nodeId: string;\n    allowSave: boolean | null;\n    allowCopy: boolean | null;\n    enabled: boolean;\n  }): IBaseShareVo {\n    return {\n      baseId: share.baseId,\n      shareId: share.shareId,\n      password: share.password != null, // Only return if password is set, not the actual value\n      nodeId: share.nodeId,\n      allowSave: share.allowSave,\n      allowCopy: share.allowCopy,\n      enabled: share.enabled,\n    };\n  }\n\n  async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise<IBaseShareVo> {\n    const userId = this.cls.get('user.id');\n\n    // Check if a share already exists for this node\n    const existingShare = await this.prismaService.baseShare.findFirst({\n      where: { baseId, nodeId: data.nodeId },\n    });\n    if (existingShare) {\n      // If existing share is disabled, re-enable it\n      if (!existingShare.enabled) {\n        const updated = await this.prismaService.baseShare.update({\n          where: { id: existingShare.id },\n          data: {\n            enabled: true,\n            password: data.password || existingShare.password,\n            allowSave: data.allowSave ?? existingShare.allowSave,\n            allowCopy: data.allowCopy ?? existingShare.allowCopy,\n          },\n        });\n        // Invalidate cache when re-enabling share\n        await this.invalidateBaseShareListCache(baseId);\n        return this.formatBaseShareVo(updated);\n      }\n      throw new CustomHttpException(\n        'A share already exists for this node',\n        HttpErrorCode.CONFLICT,\n        {\n          localization: {\n            i18nKey: baseShareAlreadyExistsKey,\n          },\n        }\n      );\n    }\n\n    const shareId = generateShareId();\n    const share = await this.prismaService.baseShare.create({\n      data: {\n        baseId,\n        shareId,\n        password: data.password || null,\n        nodeId: data.nodeId,\n        allowSave: data.allowSave,\n        allowCopy: data.allowCopy,\n        createdBy: userId,\n      },\n    });\n\n    // Invalidate cache when creating new share\n    await this.invalidateBaseShareListCache(baseId);\n\n    return this.formatBaseShareVo(share);\n  }\n\n  @PerformanceCache({\n    ttl: 24 * 60 * 60, // 24 hours\n    keyGenerator: generateBaseShareListCacheKey,\n    statsType: 'base-share',\n  })\n  async getBaseShareList(baseId: string): Promise<{ nodeId: string }[]> {\n    return this.prismaService.baseShare.findMany({\n      where: {\n        baseId,\n        enabled: true,\n      },\n      orderBy: { createdTime: 'desc' },\n      select: {\n        nodeId: true,\n      },\n    });\n  }\n\n  async getBaseShareByNodeId(baseId: string, nodeId: string): Promise<IBaseShareVo | null> {\n    const share = await this.prismaService.baseShare.findFirst({\n      where: { baseId, nodeId, enabled: true },\n    });\n\n    if (!share) {\n      return null;\n    }\n\n    return this.formatBaseShareVo(share);\n  }\n\n  async updateBaseShare(\n    baseId: string,\n    shareId: string,\n    data: IUpdateBaseShareRo\n  ): Promise<IBaseShareVo> {\n    const share = await this.prismaService.baseShare.findFirst({\n      where: { baseId, shareId, enabled: true },\n    });\n\n    if (!share) {\n      throw new CustomHttpException(baseShareNotFoundMessage, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: baseShareNotFoundKey,\n        },\n      });\n    }\n\n    const updated = await this.prismaService.baseShare.update({\n      where: { id: share.id },\n      data: {\n        password: data.password !== undefined ? data.password : share.password,\n        allowSave: data.allowSave !== undefined ? data.allowSave : share.allowSave,\n        allowCopy: data.allowCopy !== undefined ? data.allowCopy : share.allowCopy,\n        enabled: data.enabled !== undefined ? data.enabled : share.enabled,\n      },\n    });\n\n    // Invalidate cache if enabled status changed\n    if (data.enabled !== undefined && data.enabled !== share.enabled) {\n      await this.invalidateBaseShareListCache(baseId);\n    }\n\n    return this.formatBaseShareVo(updated);\n  }\n\n  async deleteBaseShare(baseId: string, shareId: string): Promise<void> {\n    const share = await this.prismaService.baseShare.findFirst({\n      where: { baseId, shareId, enabled: true },\n    });\n\n    if (!share) {\n      throw new CustomHttpException(baseShareNotFoundMessage, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: baseShareNotFoundKey,\n        },\n      });\n    }\n\n    // Soft delete: set enabled to false instead of deleting the record\n    await this.prismaService.baseShare.update({\n      where: { id: share.id },\n      data: { enabled: false },\n    });\n\n    // Invalidate cache when deleting share\n    await this.invalidateBaseShareListCache(baseId);\n  }\n\n  async refreshBaseShareId(baseId: string, shareId: string): Promise<IBaseShareVo> {\n    const share = await this.prismaService.baseShare.findFirst({\n      where: { baseId, shareId, enabled: true },\n    });\n\n    if (!share) {\n      throw new CustomHttpException(baseShareNotFoundMessage, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: baseShareNotFoundKey,\n        },\n      });\n    }\n\n    const newShareId = generateShareId();\n    const updated = await this.prismaService.baseShare.update({\n      where: { id: share.id },\n      data: { shareId: newShareId },\n    });\n\n    return this.formatBaseShareVo(updated);\n  }\n\n  async getByShareId(shareId: string) {\n    const share = await this.prismaService.baseShare.findUnique({\n      where: { shareId },\n    });\n\n    if (!share || !share.enabled) {\n      throw new CustomHttpException(baseShareNotFoundMessage, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: baseShareNotFoundKey,\n        },\n      });\n    }\n\n    return share;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/guard/base-share-auth-local.guard.ts",
    "content": "import type { CanActivate, ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { BaseShareAuthService } from '../base-share-auth.service';\n\n@Injectable()\nexport class BaseShareAuthLocalGuard implements CanActivate {\n  constructor(private readonly baseShareAuthService: BaseShareAuthService) {}\n\n  async canActivate(context: ExecutionContext) {\n    const req = context.switchToHttp().getRequest();\n    const shareId = req.params.shareId;\n    const password = req.body.password;\n    const authShareId = await this.baseShareAuthService.authBaseShare(shareId, password);\n    req.shareId = authShareId;\n    req.password = password;\n    if (!authShareId) {\n      throw new CustomHttpException('Incorrect password.', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.share.incorrectPassword',\n        },\n      });\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/guard/base-share-auth.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { AuthGuard as PassportAuthGuard } from '@nestjs/passport';\nimport { ANONYMOUS_USER_ID, HttpErrorCode } from '@teable/core';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { BaseShareAuthService } from '../base-share-auth.service';\nimport { BASE_SHARE_JWT_STRATEGY } from './constant';\n\n@Injectable()\nexport class BaseShareAuthGuard extends PassportAuthGuard([BASE_SHARE_JWT_STRATEGY]) {\n  constructor(\n    private readonly baseShareAuthService: BaseShareAuthService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super();\n  }\n\n  async validate(context: ExecutionContext, shareId: string) {\n    const req = context.switchToHttp().getRequest();\n\n    try {\n      const shareInfo = await this.baseShareAuthService.getBaseShareInfo(shareId);\n      req.baseShareInfo = shareInfo;\n\n      // Only set anonymous user if no user is already authenticated\n      // This allows copy operations to preserve the logged-in user's identity\n      const currentUserId = this.cls.get('user.id');\n      if (!currentUserId) {\n        this.cls.set('user', {\n          id: ANONYMOUS_USER_ID,\n          name: ANONYMOUS_USER_ID,\n          email: '',\n        });\n      }\n\n      // Check if password is required\n      const hasPassword = await this.baseShareAuthService.hasPassword(shareId);\n      if (hasPassword) {\n        return (await super.canActivate(context)) as boolean;\n      }\n      return true;\n    } catch (err) {\n      // Re-throw NOT_FOUND errors (share doesn't exist or is disabled)\n      if (err instanceof CustomHttpException && err.code === HttpErrorCode.NOT_FOUND) {\n        throw err;\n      }\n      // Other errors are treated as unauthorized (e.g., password required)\n      throw new CustomHttpException('Unauthorized', HttpErrorCode.UNAUTHORIZED_SHARE);\n    }\n  }\n\n  async canActivate(context: ExecutionContext) {\n    const req = context.switchToHttp().getRequest();\n    const shareId = req.params.shareId;\n    return this.validate(context, shareId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/guard/constant.ts",
    "content": "export const BASE_SHARE_JWT_STRATEGY = 'base-share-jwt';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-share/strategies/jwt.strategy.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport cookie from 'cookie';\nimport type { Request } from 'express';\nimport { ExtractJwt, Strategy } from 'passport-jwt';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport type { IJwtBaseShareInfo } from '../base-share-auth.service';\nimport { BaseShareAuthService } from '../base-share-auth.service';\nimport { BASE_SHARE_JWT_STRATEGY } from '../guard/constant';\n\n@Injectable()\nexport class BaseShareJwtStrategy extends PassportStrategy(Strategy, BASE_SHARE_JWT_STRATEGY) {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private readonly baseShareAuthService: BaseShareAuthService\n  ) {\n    super({\n      jwtFromRequest: ExtractJwt.fromExtractors([BaseShareJwtStrategy.fromAuthCookieAsToken]),\n      ignoreExpiration: false,\n      secretOrKey: config.jwt.secret,\n    });\n  }\n\n  public static fromAuthCookieAsToken(req: Request): string | null {\n    const shareId = req.params.shareId || (req.headers['tea-share-id'] as string);\n    const cookieObj = cookie.parse(req.headers.cookie ?? '');\n    return cookieObj?.[shareId] ?? null;\n  }\n\n  async validate(payload: IJwtBaseShareInfo) {\n    const { shareId, password } = payload;\n    const authShareId = await this.baseShareAuthService.authBaseShare(shareId, password);\n    if (!authShareId) {\n      throw new UnauthorizedException();\n    }\n    return authShareId;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { BaseSqlExecutorService } from './base-sql-executor.service';\n\n@Module({\n  providers: [BaseSqlExecutorService],\n  exports: [BaseSqlExecutorService],\n})\nexport class BaseSqlExecutorModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { IDsn } from '@teable/core';\nimport { DriverClient, HttpErrorCode, parseDsn } from '@teable/core';\nimport { Prisma, PrismaService, PrismaClient } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { CustomHttpException } from '../../custom.exception';\nimport { BASE_READ_ONLY_ROLE_PREFIX, BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME } from './const';\nimport { checkTableAccess, validateRoleOperations } from './utils';\n\n@Injectable()\nexport class BaseSqlExecutorService {\n  private db?: PrismaClient;\n  private readonly dsn: IDsn;\n  readonly driver: DriverClient;\n  private hasPgReadAllDataRole?: boolean;\n  private readonly logger = new Logger(BaseSqlExecutorService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly configService: ConfigService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {\n    this.dsn = parseDsn(this.getDatabaseUrl());\n    this.driver = this.dsn.driver as DriverClient;\n  }\n\n  private getDatabaseUrl() {\n    return (\n      this.configService.get<string>('PRISMA_DATABASE_URL_FOR_SQL_EXECUTOR') ||\n      this.configService.getOrThrow<string>('PRISMA_DATABASE_URL')\n    );\n  }\n\n  private getDisablePreSqlExecutorCheck() {\n    return this.configService.get<string>('DISABLE_PRE_SQL_EXECUTOR_CHECK') === 'true';\n  }\n\n  private async getReadOnlyDatabaseConnectionConfig(): Promise<string | undefined> {\n    if (this.driver === DriverClient.Sqlite) {\n      return;\n    }\n    if (!this.hasPgReadAllDataRole) {\n      return;\n    }\n    const isExistReadOnlyRole = await this.roleExits(BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME);\n    if (!isExistReadOnlyRole) {\n      await this.prismaService.$tx(async (prisma) => {\n        try {\n          await prisma.$executeRawUnsafe(\n            this.knex\n              .raw(\n                `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`,\n                [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME, this.dsn.pass]\n              )\n              .toQuery()\n          );\n          await prisma.$executeRawUnsafe(\n            this.knex\n              .raw(`GRANT pg_read_all_data TO ??`, [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME])\n              .toQuery()\n          );\n        } catch (error) {\n          if (\n            error instanceof Prisma.PrismaClientKnownRequestError &&\n            (error?.meta?.code === '42710' ||\n              error?.meta?.code === '23505' ||\n              error?.meta?.code === 'XX000')\n          ) {\n            this.logger.warn(\n              `read only role ${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME} already exists or concurrent update detected, error code: ${error?.meta?.code}`\n            );\n            return;\n          }\n          throw error;\n        }\n      });\n    }\n    return `postgresql://${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME}:${this.dsn.pass}@${this.dsn.host}:${this.dsn.port}/${this.dsn.db}${\n      this.dsn.params\n        ? `?${Object.entries(this.dsn.params)\n            .map(([key, value]) => `${key}=${value}`)\n            .join('&')}`\n        : ''\n    }`;\n  }\n\n  async onModuleInit() {\n    if (this.driver !== DriverClient.Pg) {\n      return;\n    }\n    if (this.getDisablePreSqlExecutorCheck()) {\n      return;\n    }\n    // if pg_read_all_data role not exist, no need to create read only role\n    this.hasPgReadAllDataRole = await this.roleExits('pg_read_all_data');\n    if (!this.hasPgReadAllDataRole) {\n      return;\n    }\n    this.db = await this.createConnection();\n  }\n\n  async onModuleDestroy() {\n    await this.db?.$disconnect();\n  }\n\n  private async createConnection(): Promise<PrismaClient | undefined> {\n    if (this.db) {\n      return this.db;\n    }\n    const connectionConfig = await this.getReadOnlyDatabaseConnectionConfig();\n    if (!connectionConfig) {\n      return;\n    }\n    const connection = new PrismaClient({\n      datasources: {\n        db: {\n          url: connectionConfig,\n        },\n      },\n    });\n    await connection.$connect();\n\n    // validate connection\n    try {\n      await connection.$queryRawUnsafe('SELECT 1');\n      return connection;\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      await connection.$disconnect();\n      throw new CustomHttpException(\n        `database connection failed: ${error.message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseSqlExecutor.databaseConnectionFailed',\n            context: {\n              message: error.message,\n            },\n          },\n        }\n      );\n    }\n  }\n\n  private getReadOnlyRoleName(baseId: string) {\n    return `${BASE_READ_ONLY_ROLE_PREFIX}${baseId}`;\n  }\n\n  async createReadOnlyRole(baseId: string) {\n    const roleName = this.getReadOnlyRoleName(baseId);\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex\n          .raw(\n            `CREATE ROLE ?? WITH NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION`,\n            [roleName]\n          )\n          .toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex\n          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [\n            baseId,\n            roleName,\n          ])\n          .toQuery()\n      );\n  }\n\n  async dropReadOnlyRole(baseId: string) {\n    const roleName = this.getReadOnlyRoleName(baseId);\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex.raw(`REVOKE USAGE ON SCHEMA ?? FROM ??`, [baseId, roleName]).toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex\n          .raw(`REVOKE SELECT ON ALL TABLES IN SCHEMA ?? FROM ??`, [baseId, roleName])\n          .toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex\n          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [\n            baseId,\n            roleName,\n          ])\n          .toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(this.knex.raw(`DROP ROLE IF EXISTS ??`, [roleName]).toQuery());\n  }\n\n  async grantReadOnlyRole(baseId: string) {\n    const roleName = this.getReadOnlyRoleName(baseId);\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex\n          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [\n            baseId,\n            roleName,\n          ])\n          .toQuery()\n      );\n  }\n\n  private async roleExits(role: string): Promise<boolean> {\n    const roleExists = await this.prismaService.$queryRaw<\n      { count: bigint }[]\n    >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`;\n    return Boolean(roleExists[0].count);\n  }\n\n  private async roleCheckAndCreate(baseId: string) {\n    if (this.driver !== DriverClient.Pg) {\n      return;\n    }\n    const roleName = this.getReadOnlyRoleName(baseId);\n    if (!(await this.roleExits(roleName))) {\n      try {\n        await this.createReadOnlyRole(baseId);\n      } catch (error) {\n        // Handle race condition: another concurrent request may have already created the role\n        if (\n          error instanceof Prisma.PrismaClientKnownRequestError &&\n          (error?.meta?.code === '42710' || error?.meta?.code === '23505')\n        ) {\n          this.logger.warn(\n            `read only role ${roleName} already exists (concurrent creation), skipping`\n          );\n          return;\n        }\n        throw error;\n      }\n    }\n  }\n\n  private async setRole(prisma: Prisma.TransactionClient, baseId: string) {\n    const roleName = this.getReadOnlyRoleName(baseId);\n    await prisma.$executeRawUnsafe(this.knex.raw(`SET ROLE ??`, [roleName]).toQuery());\n  }\n\n  private async resetRole(prisma: Prisma.TransactionClient) {\n    await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery());\n  }\n\n  private async readonlyExecuteSql(sql: string) {\n    return this.db?.$queryRawUnsafe(sql);\n  }\n\n  /**\n   * check sql is safe\n   * 1. role operations validation\n   * 2. parse sql to valid table names\n   * 3. read only role check table access\n   */\n  private async safeCheckSql(\n    baseId: string,\n    sql: string,\n    opts?: { projectionTableDbNames?: string[]; projectionTableIds?: string[] }\n  ) {\n    const { projectionTableDbNames = [] } = opts ?? {};\n    // 1. role operations keywords validation, only pg support\n    if (this.driver == DriverClient.Pg) {\n      validateRoleOperations(sql);\n    }\n    let tableNames = projectionTableDbNames;\n    if (!projectionTableDbNames.length) {\n      const tables = await this.prismaService.tableMeta.findMany({\n        where: {\n          baseId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n      tableNames = tables.map((table) => table.dbTableName);\n    }\n    // 2. parse sql to valid table names\n    checkTableAccess(sql, {\n      tableNames,\n      database: this.driver,\n    });\n    // 3. read only role check table access, only pg and pg version > 14 support\n    try {\n      await this.readonlyExecuteSql(sql);\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      throw new CustomHttpException(\n        `read only check failed: ${error?.meta?.message || error?.message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseSqlExecutor.readOnlyCheckFailed',\n            context: {\n              message: error?.meta?.message || error?.message,\n            },\n          },\n        }\n      );\n    }\n  }\n\n  async executeQuerySql<T = unknown>(\n    baseId: string,\n    sql: string,\n    opts?: {\n      projectionTableDbNames?: string[];\n      projectionTableIds?: string[];\n    }\n  ) {\n    await this.safeCheckSql(baseId, sql, opts);\n    await this.roleCheckAndCreate(baseId);\n    return this.prismaService.$tx(async (prisma) => {\n      try {\n        await this.setRole(prisma, baseId);\n        return await prisma.$queryRawUnsafe<T>(sql);\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      } catch (error: any) {\n        throw new CustomHttpException(\n          `execute query sql failed: ${error?.meta?.message || error?.message}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.baseSqlExecutor.executeQuerySqlFailed',\n              context: {\n                message: error?.meta?.message || error?.message,\n              },\n            },\n          }\n        );\n      } finally {\n        await this.resetRole(prisma).catch((error) => {\n          console.log('resetRole error', error);\n        });\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-sql-executor/const.ts",
    "content": "export const BASE_READ_ONLY_ROLE_PREFIX = 'base_read_only_role_';\nexport const BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME = 'base_schema_table_read_only_role';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-sql-executor/utils.spec.ts",
    "content": "import { DriverClient } from '@teable/core';\nimport { validateRoleOperations, checkTableAccess } from './utils';\n\ndescribe('base sql executor utils', () => {\n  describe('validateRoleOperations', () => {\n    it('should throw an error if the sql contains set role', () => {\n      expect(() => validateRoleOperations('set role xxx')).toThrow();\n    });\n\n    it('should throw an error if the sql contains set role with semicolon', () => {\n      expect(() => validateRoleOperations('set role xxx;')).toThrow();\n    });\n\n    it('should throw an error if the sql contains set role with line break', () => {\n      expect(() =>\n        validateRoleOperations(`set \n        role xxx`)\n      ).toThrow();\n    });\n\n    it('should throw an error if the sql contains set role with line break', () => {\n      expect(() =>\n        validateRoleOperations(`set \n        \n          \\t role xxx`)\n      ).toThrow();\n    });\n\n    it('should throw an error if the sql contains reset role', () => {\n      expect(() => validateRoleOperations('reset role')).toThrow();\n    });\n\n    it('should throw an error if the sql contains set session', () => {\n      expect(() => validateRoleOperations('set session')).toThrow();\n    });\n\n    it('should not throw an error if the sql does not contain set role', () => {\n      expect(() => validateRoleOperations('select * from users')).not.toThrow();\n    });\n\n    it('should not throw an error if the sql contains set role in the beginning and end with whitespace', () => {\n      expect(() =>\n        validateRoleOperations(\"select * from users where name = 'set role'\")\n      ).not.toThrow();\n    });\n  });\n\n  describe('checkTableAccess', () => {\n    it('check table access', () => {\n      const sql = 'with a as (select * from b) select * from a where name = (select * from c)';\n      checkTableAccess(sql, {\n        tableNames: ['b', 'c'],\n        database: DriverClient.Pg,\n      });\n      checkTableAccess(sql, {\n        tableNames: ['a', 'b', 'c'],\n        database: DriverClient.Pg,\n      });\n      expect(() =>\n        checkTableAccess(sql, {\n          tableNames: ['a', 'c'],\n          database: DriverClient.Pg,\n        })\n      ).toThrow();\n    });\n\n    it('check table access with pg schema', () => {\n      const sql = 'select * from \"bsexxXxxxxx\".\"shop_order\"';\n      checkTableAccess(sql, {\n        tableNames: ['bsexxXxxxxx.shop_order'],\n        database: DriverClient.Pg,\n      });\n    });\n\n    it('deep with', () => {\n      const sql = 'with a as (with b as (select * from c) select * from b) select * from a';\n      checkTableAccess(sql, {\n        tableNames: ['c'],\n        database: DriverClient.Pg,\n      });\n    });\n\n    it('should report invalid table names when using display name instead of db table name', () => {\n      const sql = 'SELECT \"Biao_Ti\" FROM \"bseXXX\".\"xxx\" ORDER BY \"Ri_Qi\" DESC LIMIT 1';\n      expect(() =>\n        checkTableAccess(sql, {\n          tableNames: ['bseXXX.actual_db_table_name'],\n          database: DriverClient.Pg,\n        })\n      ).toThrow(/Table 'xxx' not found/);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/base-sql-executor/utils.ts",
    "content": "import { DriverClient, HttpErrorCode } from '@teable/core';\nimport type { AST } from 'node-sql-parser';\nimport { Parser } from 'node-sql-parser';\nimport { CustomHttpException } from '../../custom.exception';\n\nexport const validateRoleOperations = (sql: string) => {\n  const removeQuotedContent = (sql: string) => {\n    return sql.replace(/'[^']*'|\"[^\"]*\"/g, ' ');\n  };\n\n  const normalizedSql = sql.toLowerCase().replace(/\\s+/g, ' ');\n  const sqlWithoutQuotes = removeQuotedContent(normalizedSql);\n\n  const roleOperationPatterns = [/set\\s+role/, /reset\\s+role/, /set\\s+session/];\n\n  for (const pattern of roleOperationPatterns) {\n    if (pattern.test(sqlWithoutQuotes)) {\n      throw new CustomHttpException(\n        `not allowed to execute sql with keyword ${pattern.source}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.baseSqlExecutor.notAllowedToExecuteSqlWithKeyword',\n            context: {\n              keyword: pattern.source,\n            },\n          },\n        }\n      );\n    }\n  }\n};\n\nconst databaseTypeMap = {\n  [DriverClient.Pg]: 'postgresql',\n  [DriverClient.Sqlite]: 'sqlite',\n};\n\nconst collectWithNames = (ast?: AST) => {\n  if (!ast) {\n    return [];\n  }\n  const withNames: string[] = [];\n  if (ast.type === 'select' && ast.with) {\n    ast.with.forEach((withItem) => {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const names = (withItem.stmt as any) ? collectWithNames(withItem.stmt as any) : [];\n      withNames.push(...names, withItem.name.value);\n    });\n  }\n  return withNames;\n};\n\nexport const checkTableAccess = (\n  sql: string,\n  {\n    tableNames,\n    database,\n  }: {\n    tableNames: string[];\n    database: DriverClient;\n  }\n) => {\n  const parser = new Parser();\n  const opt = {\n    database: databaseTypeMap[database],\n  };\n  const { ast } = parser.parse(sql, opt);\n  const withNames = Array.isArray(ast) ? ast.map(collectWithNames).flat() : collectWithNames(ast);\n  const allWithNames = new Set([...withNames, ...tableNames]);\n  const whiteColumnList = Array.from(allWithNames).map((table) => {\n    const [schema, tableName] = table.includes('.') ? table.split('.') : [null, table];\n    return `select::${schema}::${tableName}`;\n  });\n\n  try {\n    const error = parser.whiteListCheck(sql, whiteColumnList, opt);\n    if (error) {\n      throw error;\n    }\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  } catch (error: any) {\n    const sqlTableList = parser.tableList(sql, opt);\n    const invalidEntries = sqlTableList.filter((t: string) => !whiteColumnList.includes(t));\n    const invalidTableNames = invalidEntries.map((t: string) => {\n      const parts = t.split('::');\n      return parts[parts.length - 1];\n    });\n\n    const message =\n      invalidTableNames.length > 0\n        ? `Table ${invalidTableNames.map((n: string) => `'${n}'`).join(', ')} not found. Please use the db table name (dbTableName from get-tables-meta) instead of the display table name for SQL queries.`\n        : (error?.message as string);\n\n    throw new CustomHttpException(\n      `An error occurred while checking table access: ${message}`,\n      HttpErrorCode.VALIDATION_ERROR,\n      {\n        localization: {\n          i18nKey: 'httpErrors.baseSqlExecutor.whiteListCheckError',\n          context: {\n            message,\n          },\n        },\n      }\n    );\n  }\n};\n\nexport const getTableNames = (sql: string) => {\n  const parser = new Parser();\n  const opt = {\n    database: databaseTypeMap[DriverClient.Pg],\n  };\n  return parser.tableList(sql, opt);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ConfigModule } from '@nestjs/config';\nimport { StorageModule } from '../attachments/plugins/storage.module';\nimport { BuiltinAssetsInitService } from './builtin-assets-init.service';\n\n@Module({\n  imports: [StorageModule, ConfigModule],\n  providers: [BuiltinAssetsInitService],\n  exports: [BuiltinAssetsInitService],\n})\nexport class BuiltinAssetsInitModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/builtin-assets-init/builtin-assets-init.service.ts",
    "content": "import { join, resolve, extname } from 'path';\nimport { Injectable, Logger, type OnModuleInit } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { AUTOMATION_ROBOT_ID, APP_ROBOT_ID, ANONYMOUS_USER_ID } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport { createReadStream, stat } from 'fs-extra';\nimport mime from 'mime-types';\nimport sharp from 'sharp';\nimport { CacheService } from '../../cache/cache.service';\nimport type { ICacheConfig } from '../../configs/cache.config';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../attachments/plugins/storage';\n\n/**\n * Built-in assets configuration interface\n */\nexport interface IBuiltinAssetConfig {\n  /**\n   * Unique identifier for the asset (e.g., 'automation-robot', 'chart-logo')\n   */\n  id: string;\n  /**\n   * Path to the source file relative to process.cwd()\n   */\n  filePath: string;\n  /**\n   * Upload type (determines bucket and directory)\n   */\n  uploadType: UploadType;\n}\n\n/**\n * Lock configuration\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst LOCK_KEY = 'lock:builtin-assets-init' as const;\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst LOCK_TTL = 300; // 5 minutes\n\n/**\n * Static asset paths\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst AUTOMATION_ROBOT_AVATAR_PATH = 'static/system/automation-robot.png';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst ANONYMOUS_USER_AVATAR_PATH = 'static/system/anonymous.png';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst EMAIL_LOGO_PATH = 'static/system/email-logo.png';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const EMAIL_LOGO_TOKEN = 'email-logo';\n\n/**\n * BuiltinAssetsInitService\n *\n * Unified service for initializing built-in assets (logos, avatars, etc.)\n * - Acquires Redis lock to ensure only one instance runs initialization\n * - Falls back to running without lock if Redis is not available\n * - Designed to be extended by EE version for additional assets\n *\n * This service consolidates all built-in asset uploads from:\n * - UserInitService (system user avatars)\n * - And any additional assets added by EE version\n */\n@Injectable()\nexport class BuiltinAssetsInitService implements OnModuleInit {\n  protected readonly logger = new Logger(BuiltinAssetsInitService.name);\n  private lockValue: string;\n\n  constructor(\n    protected readonly prismaService: PrismaService,\n    @InjectStorageAdapter() protected readonly storageAdapter: StorageAdapter,\n    protected readonly cacheService: CacheService,\n    protected readonly configService: ConfigService\n  ) {\n    // Generate unique lock value per instance\n    this.lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n  }\n\n  async onModuleInit() {\n    if (process.env.NODE_ENV === 'test') {\n      this.logger.debug('Skipping builtin assets initialization in test environment');\n      return;\n    }\n\n    // Run initialization in background to avoid blocking app startup\n    setImmediate(() => {\n      this.runInitialization().catch((error) => {\n        this.logger.error('Builtin assets initialization failed', error);\n      });\n    });\n  }\n\n  /**\n   * Run the initialization process with distributed lock\n   */\n  private async runInitialization(): Promise<void> {\n    const hasLock = await this.tryAcquireLock();\n    if (!hasLock) {\n      this.logger.log('Another instance is handling builtin assets initialization, skipping...');\n      return;\n    }\n\n    try {\n      this.logger.log('Starting builtin assets initialization...');\n      await this.initializeAssets();\n      this.logger.log('Builtin assets initialization completed');\n    } finally {\n      await this.releaseLock();\n    }\n  }\n\n  onModuleDestroy() {\n    this.releaseLock().catch((error) => {\n      this.logger.error('Failed to release lock on module destroy', error);\n    });\n  }\n\n  /**\n   * Try to acquire a distributed lock using Redis\n   * Returns true if lock acquired or Redis is not available (fallback to run)\n   */\n  protected async tryAcquireLock(): Promise<boolean> {\n    const cacheProvider = this.configService.get<ICacheConfig>('cache')?.provider;\n\n    // If not using Redis, skip lock and allow execution\n    if (cacheProvider !== 'redis') {\n      this.logger.debug('Redis not available, proceeding without distributed lock');\n      return true;\n    }\n\n    try {\n      // Use atomic setnx operation to acquire lock\n      const acquired = await this.cacheService.setnx(LOCK_KEY, this.lockValue, LOCK_TTL);\n\n      if (acquired) {\n        this.logger.debug('Acquired distributed lock for builtin assets initialization');\n        return true;\n      }\n\n      return false;\n    } catch (error) {\n      // If Redis fails, proceed without lock\n      this.logger.warn('Failed to acquire Redis lock, proceeding anyway', error);\n      return true;\n    }\n  }\n\n  /**\n   * Release the distributed lock\n   */\n  protected async releaseLock(): Promise<void> {\n    const cacheProvider = this.configService.get<ICacheConfig>('cache')?.provider;\n\n    if (cacheProvider !== 'redis') {\n      return;\n    }\n\n    try {\n      // Only delete if we own the lock\n      const currentLock = await this.cacheService.get(LOCK_KEY);\n      if (currentLock === this.lockValue) {\n        await this.cacheService.del(LOCK_KEY);\n        this.logger.debug('Released distributed lock');\n      }\n    } catch (error) {\n      this.logger.warn('Failed to release Redis lock', error);\n    }\n  }\n\n  /**\n   * Main initialization method - override in subclass to add more initialization logic\n   */\n  protected async initializeAssets(): Promise<void> {\n    const assets = this.getBuiltinAssets();\n\n    for (const asset of assets) {\n      try {\n        await this.uploadBuiltinAsset(asset);\n      } catch (error) {\n        this.logger.error(`Failed to upload builtin asset: ${asset.id}`, error);\n        // Continue with other assets\n      }\n    }\n  }\n\n  /**\n   * Get the list of builtin assets to initialize\n   * Override this method in EE subclass to add more assets\n   *\n   * This method consolidates assets from:\n   * - System user avatars (automation robot, app robot, anonymous user, AI robot)\n   * - Plugin assets will be handled by OfficialPluginInitService which calls uploadStatic\n   */\n  protected getBuiltinAssets(): IBuiltinAssetConfig[] {\n    return [\n      // System user avatars (from UserInitService)\n      {\n        id: AUTOMATION_ROBOT_ID,\n        filePath: AUTOMATION_ROBOT_AVATAR_PATH,\n        uploadType: UploadType.Avatar,\n      },\n      {\n        id: APP_ROBOT_ID,\n        filePath: AUTOMATION_ROBOT_AVATAR_PATH,\n        uploadType: UploadType.Avatar,\n      },\n      {\n        id: 'aiRobot',\n        filePath: AUTOMATION_ROBOT_AVATAR_PATH,\n        uploadType: UploadType.Avatar,\n      },\n      {\n        id: ANONYMOUS_USER_ID,\n        filePath: ANONYMOUS_USER_AVATAR_PATH,\n        uploadType: UploadType.Avatar,\n      },\n      {\n        id: EMAIL_LOGO_TOKEN,\n        filePath: EMAIL_LOGO_PATH,\n        uploadType: UploadType.Logo,\n      },\n      {\n        id: 'actTestImage',\n        filePath: 'static/test/test-image.png',\n        uploadType: UploadType.ChatFile,\n      },\n      {\n        id: 'actTestPDF',\n        filePath: 'static/test/test-pdf.pdf',\n        uploadType: UploadType.ChatFile,\n      },\n    ];\n  }\n\n  /**\n   * Upload a single builtin asset\n   */\n  async uploadBuiltinAsset(config: IBuiltinAssetConfig): Promise<string> {\n    const { id, filePath, uploadType } = config;\n    return this.uploadStatic(id, filePath, uploadType);\n  }\n\n  /**\n   * Core upload logic - reusable by other services\n   * This method can be called by other services (like OfficialPluginInitService)\n   * to upload their assets using the same logic\n   *\n   * Supports both image files (jpg, png, etc.) and non-image files (pdf, xlsx, csv, etc.)\n   */\n  async uploadStatic(id: string, filePath: string, type: UploadType): Promise<string> {\n    if (process.env.NODE_ENV === 'test') {\n      return `/${join(StorageAdapter.getDir(type), id)}`;\n    }\n\n    const fullPath = resolve(process.cwd(), filePath);\n    const path = join(StorageAdapter.getDir(type), id);\n    const bucket = StorageAdapter.getBucket(type);\n\n    // Get file metadata based on file type\n    const { size, width, height, mimetype } = await this.getFileMetadata(fullPath);\n\n    const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, fullPath, {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      'Content-Type': mimetype,\n    });\n\n    await this.prismaService.txClient().attachments.upsert({\n      create: {\n        token: id,\n        path,\n        size,\n        width,\n        height,\n        hash,\n        mimetype,\n        createdBy: 'system',\n      },\n      update: {\n        size,\n        width,\n        height,\n        hash,\n        mimetype,\n        lastModifiedBy: 'system',\n      },\n      where: {\n        token: id,\n        deletedTime: null,\n      },\n    });\n\n    return `/${path}`;\n  }\n\n  /**\n   * Get file metadata (size, dimensions, mimetype)\n   * Uses sharp for images, fs.stat for other file types\n   */\n  private async getFileMetadata(\n    fullPath: string\n  ): Promise<{ size: number; width?: number; height?: number; mimetype: string }> {\n    const ext = extname(fullPath).toLowerCase();\n    const mimetypeFromExt = mime.lookup(ext) || 'application/octet-stream';\n\n    // Check if it's an image format that sharp can handle\n    const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.avif', '.heif'];\n    const isImage = imageExtensions.includes(ext);\n\n    if (isImage) {\n      try {\n        const fileStream = createReadStream(fullPath);\n        const metaReader = sharp();\n        const sharpReader = fileStream.pipe(metaReader);\n        const metadata = await sharpReader.metadata();\n        return {\n          size: metadata.size || 0,\n          width: metadata.width,\n          height: metadata.height,\n          mimetype: mimetypeFromExt,\n        };\n      } catch {\n        // Fall back to basic file stats if sharp fails\n        this.logger.warn(`Sharp failed to process image: ${fullPath}, falling back to basic stats`);\n      }\n    }\n\n    // For non-image files or if sharp failed, use fs.stat\n    const fileStat = await stat(fullPath);\n    return {\n      size: fileStat.size,\n      width: undefined,\n      height: undefined,\n      mimetype: mimetypeFromExt,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/builtin-assets-init/index.ts",
    "content": "export * from './builtin-assets-init.module';\nexport * from './builtin-assets-init.service';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/README.md",
    "content": "我有这样的一个数据结构定义，id 为 fieldId, dependency 是当前 field 所依赖的 field 的 id。\nIFieldMap 包括了所有 field 对应的信息的 map\n\n```ts\ninterface ITopologicalItem {\n  id: string;\n  dependencies: string[];\n}\n\ninterface IField {\n  type: \"other\" | \"link\";\n}\n\ntype IFieldMap = { [id: string]: IField };\n```\n\n一个 record 中的某个 field 变化了之后，就会触发相关的依赖计算。也就是说，我们提供一个 recordId 然后按照拓扑排序中的顺序，依次计算每个 field 的值。\n\nlink 字段在这里会导致 一个 record 变化，引发其他多个 record 变化的情况，比如\n\nrecordA1 和 recordA2 的 fieldY 都引用 recordB1 的 fieldX 的值，其中 fieldY 为 type 为 link 的字段\n\n```ts\nrecordA1[fieldBLinkB] = recordB1[fieldB];\nrecordA2[fieldBLinkB] = recordB1[fieldB];\n```\n\n那么，当 recordB1 的 fieldX 变化的时候，就会触发 recordA1 和 recordA2 的 fieldY 的变化。\n也就是说，当传入 recordB1 的 id 进入拓扑排序遍历的时候，经过 fieldY 字段时，需要裂变成 recordA1、recordA2 两个 id，然后继续遍历计算\n\n请问，我如何一次性查询出，从 recordB1 出发，最终会发生变化的 record, 以及他们与各自 field 之间的关系\n简单场景\nb1[fieldB] 变化\nfieldB -> fieldLinkB\n\n```ts\n[\n  { id: \"fieldB\", dependencies: [], recordId: [\"b1\"] },\n  { id: \"fieldLinkB\", dependencies: [\"fieldB\"], recordId: [\"b1\"], targetRecordId: [\"a1\", \"a2\"] }, // formula({fieldB})\n];\n```\n\nlink 字段的计算，是带入关联表中的 recordId 以及对应的 recordData 计算的，而计算后的值，将存入当前表的 targetRecordId 中对应的 link 字段下。\n\n```ts\n[\n  { id: \"fieldB\", dependencies: [], recordId: [\"b1\"] },\n  { id: \"fieldLinkB\", dependencies: [\"fieldB\"], recordId: [\"b1\"], targetRecordId: [\"a1\", \"a2\"] },\n  { id: \"fieldA\", dependencies: [\"fieldLinkB\"], recordId: [\"a1\", \"a2\"] },\n  { id: \"fieldLinkA\", dependencies: [\"fieldA\"], recordId: [\"a1\", \"a2\"], targetRecordId: [\"b1\"] },\n];\n```\n\nfieldA -> FieldLinkA -> formula。\n拓扑排序只包含从变更入口开始的有向图\n\n```ts\n[\n  { id: \"fieldA\", dependencies: [], recordId: [\"a1\"] },\n  // { id: \"fieldC\", dependencies: [] },\n  { id: \"FieldLinkA\", dependencies: [\"fieldA\"], recordId: [\"a1\", \"a2\"], targetRecordId: [\"b1\"] },\n  // { id: \"FieldLinkC\", dependencies: ['fieldC'], recordId: ['c1', 'c2'], targetRecordId: ['b1'] },\n  { id: \"formula\", dependencies: [\"FieldLinkA\", \"FieldLinkC\"] },\n];\n```\n\n## 单次找出所有 recordId\n\n我有以下数据结构, 这是一组拓扑排序后的数据结果。代表着 fieldA -> fieldLinkA -> fieldLinkB 这样的一张有向无环图关系。\ntableName 代表表名称\nfieldName 代表字段名称\ntargetLinkField 代表外键的字段名称，该字段存储了关联的 record\nlinkedTable 代表了这个 targetLinkField 关联的表名称\ndependencies 代表关联后，他们需要查询 record 中哪个 field 的值\n\n```ts\nconst topologicalOrder = [\n  { tableName: \"A\", fieldName: \"fieldA\", dependencies: [] },\n  {\n    tableName: \"B\",\n    fieldName: \"fieldLinkA\",\n    targetLinkField: \"__fk_fieldLinkA\",\n    linkedTable: \"A\",\n    dependencies: [\"fieldA\"],\n  },\n  {\n    tableName: \"C\",\n    fieldName: \"fieldLinkB\",\n    targetLinkField: \"__fk_fieldLinkB\",\n    linkedTable: \"B\",\n    dependencies: [\"fieldLinkA\"],\n  },\n];\n```\n\ntable 中的 record 数据用 json 表达如下\nA 表\n\n```ts\n[\n  { id: \"idA1\", fieldA: \"A1\" },\n  { id: \"idA2\", fieldA: \"A2\" },\n];\n```\n\nB 表\n\n```ts\n[\n  { id: \"idB1\", fieldB: \"B1\", fieldLinkA: \"A1\", __fk_fieldLinkA: \"idA1\" },\n  { id: \"idB2\", fieldB: \"B2\", fieldLinkA: \"A1\", __fk_fieldLinkA: \"idA1\" },\n];\n```\n\nC 表\n\n```ts\n[\n  { id: \"idC1\", fieldC: \"C1\", fieldLinkB: \"A1\", __fk_fieldLinkB: \"idB1\" },\n  { id: \"idC2\", fieldC: \"C2\", fieldLinkB: \"A1\", __fk_fieldLinkB: \"idB1\" },\n  { id: \"idC3\", fieldC: \"C3\", fieldLinkB: \"A1\", __fk_fieldLinkB: \"idB2\" },\n];\n```\n\n根据上述表述，如果 'idA1' 中的 fieldA 发生了变化，既可以推导出 ['idB1', 'idB2'] 和 ['idC1', 'idC2', 'idC3'] 都会受到关联关系的影响发生变化\n\n我如何通过 SQL 一次性查找出这些 recordId 呢？ 为了实现动态的查询需求，我会将 idA1 和 topologicalOrder 作为参数传入\n\n## 一对多关系，反向引用计算链\n\nA 表\n\n```ts\n{ __id: 'idA1', fieldA: 'A1', oneToManyB: ['C1,C2', 'C3'] }\n```\n\nB 表\n\n```ts\n{ __id: 'idB1', fieldB: 'C1,C2', manyToOneA: 'A1', __fk_manyToOneA: 'idA1', oneToManyC: ['C1', 'C2'] }\n{ __id: 'idB2', fieldB: 'C3', manyToOneA: 'A1', __fk_manyToOneA: 'idA1', oneToManyC: ['C3'] }\n```\n\nC 表\n\n```ts\n{ __id: 'idC1', fieldC: 'C1', manyToOneB: 'C1,C2', __fk_manyToOneB: 'idB1' },\n{ __id: 'idC2', fieldC: 'C2', manyToOneB: 'C1,C2', __fk_manyToOneB: 'idB1' },\n{ __id: 'idC3', fieldC: 'C3', manyToOneB: 'C3', __fk_manyToOneB: 'idB2' },\n```\n\n引用关系拓扑排序\n\n```ts\nconst topoOrder = [\n  {\n    dbTableName: \"B\",\n    fieldName: \"oneToManyC\",\n    foreignKeyField: \"__fk_manyToOneB\",\n    relationship: Relationship.OneMany,\n    linkedTable: \"C\",\n  },\n  {\n    dbTableName: \"A\",\n    fieldName: \"oneToManyB\",\n    foreignKeyField: \"__fk_manyToOneA\",\n    relationship: Relationship.OneMany,\n    linkedTable: \"B\",\n  },\n  {\n    dbTableName: \"C\",\n    fieldName: \"manyToOneB\",\n    foreignKeyField: \"__fk_manyToOneB\",\n    relationship: Relationship.ManyOne,\n    linkedTable: \"B\",\n  },\n];\n```\n\n我们看到。\nA 表中的 idA1.oneToManyB: ['C1,C2', 'C3'] 的值，是从 C 表中**id 为 'idB1', 'idB2' 对应的 fieldB 字段中得到，并形成数组的。\n同理：\nB 表中\nidB1.oneToManyC: ['C1', 'C2'] 的值，是从 C 表中**id 为 'idC1', 'idC2' 对应的 fieldC 字段中得到，并形成数组的。\nidB2.oneToManyC: ['C3'] 的值，是从 C 表中**id 为 'idC3' 对应的 fieldC 字段中得到，并形成数组的。\nC 表中\nidC1.manyToOneB: 'C1,C2' 的值，是从 B 表中**id 为 'idB1' 对应的 fieldB 字段中引用得到。\nidC2.manyToOneB: 'C1,C2' 的值，是从 B 表中\\_\\_id 为 'idB1' 对应的 fieldB 字段中引用得到。\n\n### 问题 1\n\nC.idC1.fieldC 的值发生了变化，从 C1 变成了 C11, 怎么更新 A 表和 B 表的对应值？\n首先我们用循环遍历的代码来实现，先定义一下最终的输出结构，者个结构由后续参与计算的时候，如何进行多值 lookup 合并来决定。\n什么是多值 lookup 呢？ 比如 B.oneToManyC 的值是 ['C1', 'C2']，那么我们需要从 C 表中找到 \\_\\_id 为 'idC1', 'idC2' 的记录，然后将他们的 fieldC 字段的值合并成一个数组，最终得到 ['C1', 'C2']。\n\n### 问题 2\n\n假设，我们现在 A.oneToManyB 以及 B.oneToManyC 中的值都为空。我们怎么通过 topoOrder 中给出的关系，利用 SQL 查询加上 ts 计算，得到他们的值呢？\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/batch.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { BatchService } from './batch.service';\nimport { CalculationModule } from './calculation.module';\n\ndescribe('BatchService', () => {\n  let service: BatchService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, CalculationModule],\n    }).compile();\n\n    service = module.get<BatchService>(BatchService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/batch.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { HttpErrorCode, IdPrefix, RecordOpBuilder, FieldType } from '@teable/core';\nimport type { IOtOperation, IRecord, TableDomain } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { groupBy, isEmpty, keyBy } from 'lodash';\nimport { customAlphabet } from 'nanoid';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { bufferCount, concatMap, from, lastValueFrom } from 'rxjs';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IRawOp, IRawOpMap } from '../../share-db/interface';\nimport { RawOpType } from '../../share-db/interface';\nimport type { IClsStore } from '../../types/cls';\nimport { handleDBValidationErrors } from '../../utils/db-validation-error';\nimport { Timing } from '../../utils/timing';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByRaw, fieldCore2FieldInstance } from '../field/model/factory';\nimport { dbType2knexFormat, SchemaType } from '../field/util';\nimport { RecordQueryService } from '../record/record-query.service';\nimport { TableDomainQueryService } from '../table-domain/table-domain-query.service';\nimport { IOpsMap } from './utils/compose-maps';\n\nexport interface IOpsData {\n  recordId: string;\n  updateParam: {\n    [dbFieldName: string]: unknown;\n  };\n  version: number;\n}\n\n@Injectable()\nexport class BatchService {\n  private logger = new Logger(BatchService.name);\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly recordQueryService: RecordQueryService,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  private async completeMissingCtx(\n    opsMap: IOpsMap,\n    fieldMap: { [fieldId: string]: IFieldInstance } = {},\n    tableId2DbTableName: { [tableId: string]: string } = {}\n  ) {\n    const tableIds = Object.keys(opsMap);\n\n    const missingFieldIds = Array.from(\n      tableIds.reduce<Set<string>>((pre, id) => {\n        Object.values(opsMap[id]).forEach((ops) =>\n          ops.forEach((op) => {\n            const fieldId = RecordOpBuilder.editor.setRecord.detect(op)?.fieldId;\n            if (fieldId) {\n              pre.add(fieldId);\n            }\n          })\n        );\n        return pre;\n      }, new Set())\n    );\n\n    if (!missingFieldIds.length) {\n      return { fieldMap, tableId2DbTableName };\n    }\n\n    const tableRaw = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: tableIds }, deletedTime: null },\n      select: { id: true, dbTableName: true },\n    });\n\n    const fieldsRaw = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: missingFieldIds }, deletedTime: null },\n    });\n\n    const fields = fieldsRaw.map(createFieldInstanceByRaw);\n\n    const extraFieldsMap = keyBy(fields, 'id');\n\n    const extraTableId2DbTableName = tableRaw.reduce<{ [tableId: string]: string }>(\n      (pre, { id, dbTableName }) => {\n        pre[id] = dbTableName;\n        return pre;\n      },\n      {}\n    );\n\n    return {\n      tableId2DbTableName: { ...tableId2DbTableName, ...extraTableId2DbTableName },\n      fieldMap: { ...fieldMap, ...extraFieldsMap },\n    };\n  }\n\n  private async updateRecordsTask(\n    tableId: string,\n    dbTableName: string,\n    fieldMap: { [fieldId: string]: IFieldInstance },\n    opsPair: [recordId: string, IOtOperation[]][]\n  ) {\n    const raw = await this.fetchRawData(\n      dbTableName,\n      opsPair.map(([recordId]) => recordId)\n    );\n    const versionGroup = keyBy(raw, '__id');\n\n    opsPair.map(([recordId]) => {\n      if (!versionGroup[recordId]) {\n        throw new CustomHttpException(\n          `Record ${recordId} not found in ${tableId}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.recordNotFound',\n              context: {\n                recordId,\n                tableId,\n              },\n            },\n          }\n        );\n      }\n    });\n\n    const opsData = this.buildRecordOpsData(opsPair, versionGroup);\n    if (!opsData.length) return;\n\n    await this.executeUpdateRecords(dbTableName, fieldMap, opsData);\n\n    const opDataList = opsPair.map(([recordId, ops]) => {\n      return { docId: recordId, version: versionGroup[recordId].__version, data: ops };\n    });\n\n    await this.saveRawOps(tableId, RawOpType.Edit, IdPrefix.Record, opDataList);\n  }\n\n  @Timing()\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async updateRecords(\n    opsMap: IOpsMap,\n    fieldMap: { [fieldId: string]: IFieldInstance } = {},\n    tableId2DbTableName: { [tableId: string]: string } = {},\n    tableDomains?: Map<string, TableDomain>\n  ): Promise<{ [tableId: string]: { [recordId: string]: IRecord } }> {\n    const tableIds = Object.keys(opsMap);\n\n    const domainCache = new Map<string, TableDomain>(tableDomains || []);\n    const missingDomainIds = tableIds.filter((id) => !domainCache.has(id));\n    if (missingDomainIds.length) {\n      const fetched = await this.tableDomainQueryService.getTableDomainsByIds(missingDomainIds);\n      for (const [tid, domain] of fetched) {\n        domainCache.set(tid, domain);\n      }\n    }\n\n    // Prefill table/db mapping and field instances from domains to reduce follow-up lookups\n    for (const [tid, domain] of domainCache) {\n      tableId2DbTableName[tid] ||= domain.dbTableName;\n      for (const field of domain.fieldList) {\n        if (!fieldMap[field.id]) {\n          fieldMap[field.id] = fieldCore2FieldInstance(field);\n        }\n      }\n    }\n\n    const result = await this.completeMissingCtx(opsMap, fieldMap, tableId2DbTableName);\n    fieldMap = result.fieldMap;\n    tableId2DbTableName = result.tableId2DbTableName;\n\n    // Get old records before updating\n    const oldRecords: { [tableId: string]: { [recordId: string]: IRecord } } = {};\n\n    for (const tableId in opsMap) {\n      const recordIds = Object.keys(opsMap[tableId]);\n      if (recordIds.length === 0) continue;\n\n      try {\n        const domain = domainCache.get(tableId);\n        if (!domain) {\n          this.logger.warn(`TableDomain not found for table ${tableId}, skip snapshot read`);\n          oldRecords[tableId] = {};\n          continue;\n        }\n        const snapshots = await this.recordQueryService.getSnapshotBulk(domain, recordIds);\n        oldRecords[tableId] = {};\n        for (const snapshot of snapshots) {\n          oldRecords[tableId][snapshot.id] = snapshot.data;\n        }\n      } catch (error) {\n        this.logger.warn(`Failed to get old records for table ${tableId}: ${error}`);\n        oldRecords[tableId] = {};\n      }\n    }\n\n    // Perform the actual updates\n    for (const tableId in opsMap) {\n      const dbTableName = tableId2DbTableName[tableId];\n      const recordOpsMap = opsMap[tableId];\n      if (isEmpty(recordOpsMap)) {\n        continue;\n      }\n      const opsPair = Object.entries(recordOpsMap);\n\n      const taskFunction = async (opp: [recordId: string, IOtOperation[]][]) =>\n        this.updateRecordsTask(tableId, dbTableName, fieldMap, opp);\n\n      await lastValueFrom(\n        from(opsPair).pipe(\n          bufferCount(this.thresholdConfig.calcChunkSize),\n          concatMap((opsPair) => from(taskFunction(opsPair)))\n        )\n      );\n    }\n\n    return oldRecords;\n  }\n\n  // @Timing()\n  private async fetchRawData(dbTableName: string, recordIds: string[]) {\n    const querySql = this.knex(dbTableName)\n      .whereIn('__id', recordIds)\n      .select('__id', '__version', '__last_modified_time', '__last_modified_by')\n      .toQuery();\n\n    return this.prismaService.txClient().$queryRawUnsafe<\n      {\n        __version: number;\n        __id: string;\n      }[]\n    >(querySql);\n  }\n\n  private buildRecordOpsData(\n    opsPair: [recordId: string, IOtOperation[]][],\n    versionGroup: {\n      [recordId: string]: {\n        __version: number;\n        __id: string;\n      };\n    }\n  ) {\n    const opsData: IOpsData[] = [];\n\n    for (const [recordId, ops] of opsPair) {\n      const updateParam = ops.reduce<{ [fieldId: string]: unknown }>((pre, op) => {\n        const opContext = RecordOpBuilder.editor.setRecord.detect(op);\n        if (!opContext) {\n          throw new CustomHttpException(\n            `illegal op ${JSON.stringify(op)} found when build record ops data`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.custom.invalidOperation',\n              },\n            }\n          );\n        }\n        pre[opContext.fieldId] = opContext.newCellValue;\n        return pre;\n      }, {});\n\n      const version = versionGroup[recordId].__version;\n\n      opsData.push({\n        recordId,\n        version,\n        updateParam,\n      });\n    }\n\n    return opsData;\n  }\n\n  @Timing()\n  private async executeUpdateRecords(\n    dbTableName: string,\n    fieldMap: { [fieldId: string]: IFieldInstance },\n    opsData: IOpsData[]\n  ) {\n    if (!opsData.length) return;\n\n    const opsDataGroup = groupBy(opsData, (d) => {\n      return Object.keys(d.updateParam).join();\n    });\n\n    // group by fieldIds before apply\n    for (const groupKey in opsDataGroup) {\n      await this.executeUpdateRecordsInner(dbTableName, fieldMap, opsDataGroup[groupKey]);\n    }\n  }\n\n  async batchUpdateDB(\n    dbTableName: string,\n    idFieldName: string,\n    schemas: { schemaType: SchemaType; dbFieldName: string }[],\n    data: { id: string; values: { [key: string]: unknown } }[]\n  ) {\n    const tempTableName = `temp_` + customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)();\n    // 1.create temporary table structure\n    const createTempTableSchema = this.knex.schema.createTable(tempTableName, (table) => {\n      table.string(idFieldName).primary();\n      schemas.forEach(({ dbFieldName, schemaType }) => {\n        table[schemaType](dbFieldName);\n      });\n    });\n\n    const createTempTableSql = createTempTableSchema\n      .toQuery()\n      .replace('create table', 'create temporary table');\n\n    const { insertTempTableSql, updateRecordSql } = this.dbProvider.executeUpdateRecordsSqlList({\n      dbTableName,\n      tempTableName,\n      idFieldName,\n      dbFieldNames: schemas.map((s) => s.dbFieldName),\n      data,\n    });\n    const dropTempTableSql = this.knex.schema.dropTable(tempTableName).toQuery();\n\n    const validDbFieldNames = schemas.map((s) => s.dbFieldName).filter((f) => !f.startsWith('__'));\n\n    await this.prismaService.$tx(async (tx) => {\n      // temp table should in one transaction\n      await tx.$executeRawUnsafe(createTempTableSql);\n      // 2.initialize temporary table data\n      await tx.$executeRawUnsafe(insertTempTableSql);\n      // 3.update data\n      await handleDBValidationErrors({\n        fn: async () => {\n          await tx.$executeRawUnsafe(updateRecordSql);\n        },\n        handleUniqueError: async () => {\n          const tables = await this.prismaService.tableMeta.findMany({\n            where: { dbTableName },\n            select: { id: true, name: true },\n          });\n          const table = tables[0];\n          const fieldRaws = await this.prismaService.field.findMany({\n            where: {\n              tableId: table.id,\n              dbFieldName: { in: validDbFieldNames },\n              unique: true,\n              deletedTime: null,\n            },\n            select: { id: true, name: true },\n          });\n\n          throw new CustomHttpException(\n            `Fields ${fieldRaws.map((f) => f.id).join(', ')} unique validation failed`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.custom.fieldValueDuplicate',\n                context: {\n                  tableName: table.name,\n                  fieldName: fieldRaws.map((f) => f.name).join(', '),\n                },\n              },\n            }\n          );\n        },\n        handleNotNullError: async () => {\n          const tables = await this.prismaService.tableMeta.findMany({\n            where: { dbTableName },\n            select: { id: true, name: true },\n          });\n          const table = tables[0];\n          const fieldRaws = await this.prismaService.field.findMany({\n            where: {\n              tableId: table.id,\n              dbFieldName: { in: validDbFieldNames },\n              notNull: true,\n              deletedTime: null,\n            },\n            select: { id: true, name: true },\n          });\n\n          throw new CustomHttpException(\n            `Fields ${fieldRaws.map((f) => f.id).join(', ')} not null validation failed`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.custom.fieldValueNotNull',\n                context: {\n                  tableName: table.name,\n                  fieldName: fieldRaws.map((f) => f.name).join(', '),\n                },\n              },\n            }\n          );\n        },\n      });\n      // 4.delete temporary table\n      await tx.$executeRawUnsafe(dropTempTableSql);\n    });\n  }\n\n  private async executeUpdateRecordsInner(\n    dbTableName: string,\n    fieldMap: { [fieldId: string]: IFieldInstance },\n    opsData: IOpsData[]\n  ) {\n    if (!opsData.length) {\n      return;\n    }\n\n    const fieldIds = Array.from(new Set(opsData.flatMap((d) => Object.keys(d.updateParam))))\n      .filter((id) => fieldMap[id])\n      .filter((id) => !fieldMap[id].isComputed)\n      .filter((id) => fieldMap[id].type !== FieldType.Link);\n    const data = opsData.map((data) => {\n      const { recordId, updateParam, version } = data;\n\n      return {\n        id: recordId,\n        values: {\n          ...Object.entries(updateParam).reduce<{ [dbFieldName: string]: unknown }>(\n            (pre, [fieldId, value]) => {\n              const field = fieldMap[fieldId];\n              if (!field) {\n                return pre;\n              }\n              if (field.isComputed || field.type === FieldType.Link) {\n                return pre;\n              }\n              const { dbFieldName } = field;\n              pre[dbFieldName] = field.convertCellValue2DBValue(value);\n              return pre;\n            },\n            {}\n          ),\n          __version: version + 1,\n        },\n      };\n    });\n\n    const schemas = [\n      ...fieldIds.map((id) => {\n        const { dbFieldName, dbFieldType } = fieldMap[id];\n        return { dbFieldName, schemaType: dbType2knexFormat(this.knex, dbFieldType) };\n      }),\n      { dbFieldName: '__version', schemaType: SchemaType.Integer },\n    ];\n\n    await this.batchUpdateDB(dbTableName, '__id', schemas, data);\n  }\n\n  @Timing()\n  saveRawOps(\n    collectionId: string,\n    opType: RawOpType,\n    docType: IdPrefix,\n    dataList: { docId: string; version: number; data?: unknown }[]\n  ) {\n    const collection = `${docType}_${collectionId}`;\n    const rawOpMap: IRawOpMap = { [collection]: {} };\n\n    const baseRaw = {\n      src: this.cls.getId() || 'unknown',\n      seq: 1,\n      m: {\n        ts: Date.now(),\n      },\n    };\n\n    this.logger.verbose(`saveOp: ${baseRaw.src}-${collection}`);\n\n    dataList.forEach(({ docId, version, data }) => {\n      let rawOp: IRawOp;\n      if (opType === RawOpType.Create) {\n        rawOp = {\n          ...baseRaw,\n          create: {\n            type: 'json0',\n            data,\n          },\n          v: version,\n        };\n      } else if (opType === RawOpType.Del) {\n        rawOp = {\n          ...baseRaw,\n          del: true,\n          v: version,\n        };\n      } else if (opType === RawOpType.Edit) {\n        rawOp = {\n          ...baseRaw,\n          op: data as IOtOperation[],\n          v: version,\n        };\n      } else {\n        throw new CustomHttpException(\n          `unknown raw op type ${opType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.invalidOperation',\n            },\n          }\n        );\n      }\n      rawOpMap[collection][docId] = rawOp;\n      return { rawOp, docId };\n    });\n\n    const prevMap = this.cls.get('tx.rawOpMaps') || [];\n    prevMap.push(rawOpMap);\n    this.cls.set('tx.rawOpMaps', prevMap);\n    return rawOpMap;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/calculation.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { RecordQueryBuilderModule } from '../record/query-builder';\nimport { RecordQueryService } from '../record/record-query.service';\nimport { TableDomainQueryModule } from '../table-domain';\nimport { BatchService } from './batch.service';\nimport { FieldCalculationService } from './field-calculation.service';\nimport { LinkService } from './link.service';\nimport { ReferenceService } from './reference.service';\nimport { SystemFieldService } from './system-field.service';\n\n@Module({\n  imports: [RecordQueryBuilderModule, TableDomainQueryModule],\n  providers: [\n    DbProvider,\n    RecordQueryService,\n    BatchService,\n    ReferenceService,\n    LinkService,\n    FieldCalculationService,\n    SystemFieldService,\n  ],\n  exports: [\n    BatchService,\n    ReferenceService,\n    LinkService,\n    FieldCalculationService,\n    SystemFieldService,\n    RecordQueryService,\n  ],\n})\nexport class CalculationModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/field-calculation.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { CalculationModule } from './calculation.module';\nimport { FieldCalculationService } from './field-calculation.service';\n\ndescribe('FieldCalculationService', () => {\n  let service: FieldCalculationService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, CalculationModule],\n    }).compile();\n\n    service = module.get<FieldCalculationService>(FieldCalculationService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/field-calculation.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldType, type IRecord } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { concatMap, lastValueFrom, map, range, toArray } from 'rxjs';\nimport { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';\nimport { Timing } from '../../utils/timing';\nimport type { IFieldInstance, IFieldMap } from '../field/model/factory';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder';\nimport type { IFkRecordMap } from './link.service';\nimport { ReferenceService } from './reference.service';\nimport type { IGraphItem, ITopoItem } from './utils/dfs';\nimport { getTopoOrders, prependStartFieldIds } from './utils/dfs';\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\n\nexport interface ITopoOrdersContext {\n  fieldMap: IFieldMap;\n  allFieldIds: string[];\n  startFieldIds: string[];\n  directedGraph: IGraphItem[];\n  fieldId2DbTableName: { [fieldId: string]: string };\n  topoOrders: ITopoItem[];\n  tableId2DbTableName: { [tableId: string]: string };\n  dbTableName2fields: { [dbTableName: string]: IFieldInstance[] };\n  fieldId2TableId: { [fieldId: string]: string };\n  fkRecordMap?: IFkRecordMap;\n}\n\n@Injectable()\nexport class FieldCalculationService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly referenceService: ReferenceService,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  async getTopoOrdersContext(\n    fieldIds: string[],\n    customGraph?: IGraphItem[]\n  ): Promise<ITopoOrdersContext> {\n    const directedGraph = customGraph || (await this.referenceService.getFieldGraphItems(fieldIds));\n\n    // get all related field by undirected graph\n    const rawAllFieldIds = uniq(this.referenceService.flatGraph(directedGraph).concat(fieldIds));\n\n    // prepare all related data\n    const {\n      fieldMap,\n      fieldId2TableId,\n      dbTableName2fields,\n      fieldId2DbTableName,\n      tableId2DbTableName,\n    } = await this.referenceService.createAuxiliaryData(rawAllFieldIds);\n\n    // Ignore reference edges that point to soft-deleted fields/tables. Auxiliary data only loads\n    // active metadata, so keeping stale nodes here would later desync the graph and field map.\n    const validFieldIds = new Set(Object.keys(fieldMap));\n    const filteredGraph = directedGraph.filter(\n      ({ fromFieldId, toFieldId }) => validFieldIds.has(fromFieldId) && validFieldIds.has(toFieldId)\n    );\n    const startFieldIds = fieldIds.filter((fieldId) => validFieldIds.has(fieldId));\n    const allFieldIds = uniq(this.referenceService.flatGraph(filteredGraph).concat(startFieldIds));\n\n    // topological sorting\n    const topoOrders = prependStartFieldIds(getTopoOrders(filteredGraph), startFieldIds);\n\n    return {\n      startFieldIds,\n      allFieldIds,\n      fieldMap,\n      directedGraph: filteredGraph,\n      topoOrders,\n      tableId2DbTableName,\n      fieldId2DbTableName,\n      dbTableName2fields,\n      fieldId2TableId,\n    };\n  }\n\n  private async getRecordsByPage(\n    dbTableName: string,\n    tableId: string,\n    fields: IFieldInstance[],\n    page: number,\n    chunkSize: number\n  ) {\n    const { qb } = await this.recordQueryBuilder.createRecordQueryBuilder(dbTableName, {\n      tableId,\n      viewId: undefined,\n      useQueryModel: true,\n    });\n    const query = qb\n      .where((builder) => {\n        fields\n          .filter((field) => !field.isComputed && field.type !== FieldType.Link)\n          .forEach((field, index) => {\n            const dbName = field.dbFieldName;\n            if (index === 0) {\n              builder.whereNotNull(dbName);\n            } else {\n              builder.orWhereNotNull(dbName);\n            }\n          });\n      })\n      .orderBy('__auto_number')\n      .limit(chunkSize)\n      .offset(page * chunkSize)\n      .toQuery();\n    return this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(query);\n  }\n\n  async getRecordsBatchByFields(\n    dbTableName2fields: { [dbTableName: string]: IFieldInstance[] },\n    dbTableName2tableId: { [dbTableName: string]: string }\n  ): Promise<{\n    [dbTableName: string]: IRecord[];\n  }> {\n    const results: {\n      [dbTableName: string]: IRecord[];\n    } = {};\n    const chunkSize = this.thresholdConfig.calcChunkSize;\n    for (const dbTableName in dbTableName2fields) {\n      // deduplication is needed\n      const rowCount = await this.getRowCount(dbTableName);\n      const totalPages = Math.ceil(rowCount / chunkSize);\n      const fields = dbTableName2fields[dbTableName];\n      const tableId = dbTableName2tableId[dbTableName];\n\n      const records = await lastValueFrom(\n        range(0, totalPages).pipe(\n          concatMap((page) => this.getRecordsByPage(dbTableName, tableId, fields, page, chunkSize)),\n          toArray(),\n          map((records) => records.flat())\n        )\n      );\n\n      results[dbTableName] = records.map((record) =>\n        this.referenceService.recordRaw2Record(fields, record)\n      );\n    }\n    return results;\n  }\n\n  @Timing()\n  async getRowCount(dbTableName: string) {\n    const query = this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery();\n    const [{ count }] = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ count: bigint }[]>(query);\n    return Number(count);\n  }\n\n  // Legacy bulk recalculation helpers removed\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/link.service.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { CalculationModule } from './calculation.module';\nimport { LinkService } from './link.service';\n\ndescribe('LinkService', () => {\n  let service: LinkService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, CalculationModule],\n    }).compile();\n\n    service = module.get<LinkService>(LinkService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  // describe('getCellMutation', () => {\n  //   let fieldMapByTableId: IFieldMapByTableId = {};\n  //   beforeEach(() => {\n  //     fieldMapByTableId = {\n  //       tableA: {\n  //         'ManyOne-LinkB': {\n  //           id: 'ManyOne-LinkB',\n  //           type: FieldType.Link,\n  //           dbFieldName: 'ManyOne-LinkB',\n  //           options: {\n  //             relationship: Relationship.ManyOne,\n  //             foreignTableId: 'tableB',\n  //             lookupFieldId: 'fieldB',\n  //             dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //             symmetricFieldId: 'OneMany-LinkA',\n  //           },\n  //         } as LinkFieldDto,\n  //       },\n  //       tableB: {\n  //         'OneMany-LinkA': {\n  //           id: 'OneMany-LinkA',\n  //           type: FieldType.Link,\n  //           dbFieldName: 'OneMany-LinkA',\n  //           options: {\n  //             relationship: Relationship.OneMany,\n  //             foreignTableId: 'tableA',\n  //             lookupFieldId: 'fieldA',\n  //             dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //             symmetricFieldId: 'ManyOne-LinkB',\n  //           },\n  //         } as LinkFieldDto,\n  //       },\n  //     };\n  //   });\n\n  //   it('should create correct ForeignKeyParams when add value for ManyOne field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'A1',\n  //         fieldId: 'ManyOne-LinkB',\n  //         newValue: { id: 'B1' },\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableA',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should create correct ForeignKeyParams when delete value for ManyOne field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'A1',\n  //         fieldId: 'ManyOne-LinkB',\n  //         oldValue: { id: 'B1' },\n  //         newValue: undefined,\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableA',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should create correct ForeignKeyParams when replace value for ManyOne field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'A1',\n  //         fieldId: 'ManyOne-LinkB',\n  //         oldValue: { id: 'B1' },\n  //         newValue: { id: 'B2' },\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableA',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: {\n  //         B1: { fieldB: undefined, 'OneMany-LinkA': undefined },\n  //         B2: { fieldB: undefined, 'OneMany-LinkA': undefined },\n  //       },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B2',\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should create correct ForeignKeyParams when add value for oneMany field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'B1',\n  //         fieldId: 'OneMany-LinkA',\n  //         newValue: [{ id: 'A1' }],\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableB',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should create correct ForeignKeyParams when del value for oneMany field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'B1',\n  //         fieldId: 'OneMany-LinkA',\n  //         oldValue: [{ id: 'A1' }],\n  //         newValue: undefined,\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableB',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should create correct ForeignKeyParams when replace value for oneMany field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'B1',\n  //         fieldId: 'OneMany-LinkA',\n  //         oldValue: [{ id: 'A1' }],\n  //         newValue: [{ id: 'A1' }, { id: 'A2' }],\n  //       },\n  //     ];\n\n  //     const result1 = service['getRecordMapStructAndForeignKeyParams'](\n  //       'tableB',\n  //       fieldMapByTableId,\n  //       ctx1\n  //     );\n\n  //     expect(result1.recordMapByTableId).toEqual({\n  //       tableA: {\n  //         A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //         A2: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined },\n  //       },\n  //       tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } },\n  //     });\n\n  //     expect(result1.updateForeignKeyParams).toEqual([\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A2',\n  //         fRecordId: 'B1',\n  //       },\n  //     ]);\n  //   });\n\n  //   it('should throw error when when illegal value for oneMany field', () => {\n  //     const ctx1: ILinkCellContext[] = [\n  //       {\n  //         recordId: 'B1',\n  //         fieldId: 'OneMany-LinkA',\n  //         oldValue: [{ id: 'A1' }],\n  //         newValue: [{ id: 'A1' }, { id: 'A2' }],\n  //       },\n  //       {\n  //         recordId: 'B2',\n  //         fieldId: 'OneMany-LinkA',\n  //         newValue: [{ id: 'A1' }, { id: 'A2' }],\n  //       },\n  //     ];\n\n  //     expect(() =>\n  //       service['getRecordMapStructAndForeignKeyParams']('tableB', fieldMapByTableId, ctx1)\n  //     ).toThrow();\n  //   });\n\n  //   it('should update foreign key in memory correctly when add value', () => {\n  //     const recordMapByTableId = {\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': undefined,\n  //           '__fk_ManyOne-LinkB': undefined,\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': undefined,\n  //         },\n  //       },\n  //     };\n\n  //     const updateForeignKeyParams = [\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //     ];\n\n  //     const result1 = service['updateForeignKeyInMemory'](\n  //       updateForeignKeyParams,\n  //       recordMapByTableId\n  //     );\n\n  //     expect(result1).toEqual({\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //       },\n  //     });\n  //   });\n\n  //   it('should update foreign key in memory correctly when del value', () => {\n  //     const recordMapByTableId = {\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //       },\n  //     };\n\n  //     const updateForeignKeyParams = [\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //     ];\n\n  //     const result1 = service['updateForeignKeyInMemory'](\n  //       updateForeignKeyParams,\n  //       recordMapByTableId\n  //     );\n\n  //     expect(result1).toEqual({\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': null,\n  //           '__fk_ManyOne-LinkB': null,\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': null,\n  //         },\n  //       },\n  //     });\n  //   });\n\n  //   it('should update foreign key in memory correctly when replace value', () => {\n  //     const recordMapByTableId = {\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //         B2: {\n  //           fieldB: 'B2',\n  //           'OneMany-LinkA': undefined,\n  //         },\n  //       },\n  //     };\n\n  //     const updateForeignKeyParams = [\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B2',\n  //       },\n  //     ];\n\n  //     const result1 = service['updateForeignKeyInMemory'](\n  //       updateForeignKeyParams,\n  //       recordMapByTableId\n  //     );\n\n  //     expect(result1).toEqual({\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B2', title: 'B2' },\n  //           '__fk_ManyOne-LinkB': 'B2',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': null,\n  //         },\n  //         B2: {\n  //           fieldB: 'B2',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //       },\n  //     });\n  //   });\n\n  //   it('should update foreign key in memory correctly when replace multiple value', () => {\n  //     const recordMapByTableId = {\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //         A2: {\n  //           fieldA: 'A2',\n  //           'ManyOne-LinkB': undefined,\n  //           '__fk_ManyOne-LinkB': undefined,\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //       },\n  //     };\n\n  //     const updateForeignKeyParams = [\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A2',\n  //         fRecordId: 'B1',\n  //       },\n  //     ];\n\n  //     const result1 = service['updateForeignKeyInMemory'](\n  //       updateForeignKeyParams,\n  //       recordMapByTableId\n  //     );\n\n  //     expect(result1).toEqual({\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //         A2: {\n  //           fieldA: 'A2',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [\n  //             { id: 'A1', title: 'A1' },\n  //             { id: 'A2', title: 'A2' },\n  //           ],\n  //         },\n  //       },\n  //     });\n  //   });\n\n  //   it('should update foreign key in memory correctly event when illegal value', () => {\n  //     const recordMapByTableId = {\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B1', title: 'B1' },\n  //           '__fk_ManyOne-LinkB': 'B1',\n  //         },\n  //         A2: {\n  //           fieldA: 'A2',\n  //           'ManyOne-LinkB': undefined,\n  //           '__fk_ManyOne-LinkB': undefined,\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': [{ id: 'A1', title: 'A1' }],\n  //         },\n  //         B2: {\n  //           fieldB: 'B2',\n  //           'OneMany-LinkA': undefined,\n  //         },\n  //       },\n  //     };\n\n  //     const updateForeignKeyParams = [\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: null,\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B1',\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A2',\n  //         fRecordId: 'B1',\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A1',\n  //         fRecordId: 'B2',\n  //       },\n  //       {\n  //         tableId: 'tableA',\n  //         foreignTableId: 'tableB',\n  //         mainLinkFieldId: 'ManyOne-LinkB',\n  //         mainTableLookupFieldId: 'fieldA',\n  //         foreignLinkFieldId: 'OneMany-LinkA',\n  //         foreignTableLookupFieldId: 'fieldB',\n  //         dbForeignKeyName: '__fk_ManyOne-LinkB',\n  //         recordId: 'A2',\n  //         fRecordId: 'B2',\n  //       },\n  //     ];\n\n  //     const result1 = service['updateForeignKeyInMemory'](\n  //       updateForeignKeyParams,\n  //       recordMapByTableId\n  //     );\n\n  //     expect(result1).toEqual({\n  //       tableA: {\n  //         A1: {\n  //           fieldA: 'A1',\n  //           'ManyOne-LinkB': { id: 'B2', title: 'B2' },\n  //           '__fk_ManyOne-LinkB': 'B2',\n  //         },\n  //         A2: {\n  //           fieldA: 'A2',\n  //           'ManyOne-LinkB': { id: 'B2', title: 'B2' },\n  //           '__fk_ManyOne-LinkB': 'B2',\n  //         },\n  //       },\n  //       tableB: {\n  //         B1: {\n  //           fieldB: 'B1',\n  //           'OneMany-LinkA': null,\n  //         },\n  //         B2: {\n  //           fieldB: 'B2',\n  //           'OneMany-LinkA': [\n  //             { id: 'A1', title: 'A1' },\n  //             { id: 'A2', title: 'A2' },\n  //           ],\n  //         },\n  //       },\n  //     });\n  //   });\n  // });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/link.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { ILinkCellValue, ILinkFieldOptions, IRecord, TableDomain } from '@teable/core';\nimport { FieldType, HttpErrorCode, Relationship } from '@teable/core';\nimport type { Field } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { cloneDeep, keyBy, difference, groupBy, isEqual, set, uniq, uniqBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { Timing } from '../../utils/timing';\nimport type { IFieldInstance, IFieldMap } from '../field/model/factory';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\nimport { SchemaType } from '../field/util';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder';\nimport { BatchService } from './batch.service';\nimport type { ICellChange, ICellContext } from './utils/changes';\nimport { isLinkCellValue } from './utils/detect-link';\n\nexport interface IFkRecordMap {\n  [fieldId: string]: {\n    [recordId: string]: IFkRecordItem;\n  };\n}\n\nexport interface IFkRecordItem {\n  oldKey: string | string[] | null; // null means record have no foreignKey\n  newKey: string | string[] | null; // null means to delete the foreignKey\n}\n\nexport interface IRecordMapByTableId {\n  [tableId: string]: {\n    [recordId: string]: {\n      [fieldId: string]: unknown;\n    };\n  };\n}\n\nexport interface IFieldMapByTableId {\n  [tableId: string]: {\n    [fieldId: string]: IFieldInstance;\n  };\n}\n\nexport interface ILinkCellContext {\n  recordId: string;\n  fieldId: string;\n  newValue?: { id: string }[] | { id: string };\n  oldValue?: { id: string }[] | { id: string };\n}\n\n@Injectable()\nexport class LinkService {\n  private logger = new Logger(LinkService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly batchService: BatchService,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private validateLinkCell(cell: ILinkCellContext) {\n    if (!Array.isArray(cell.newValue)) {\n      return cell;\n    }\n    const checkSet = new Set<string>();\n    cell.newValue.forEach((v) => {\n      if (checkSet.has(v.id)) {\n        throw new CustomHttpException(\n          `Cannot set duplicate recordId: ${v.id} in the same cell`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.linkCellRecordIdAlreadyExists',\n              context: { recordId: v.id },\n            },\n          }\n        );\n      }\n      checkSet.add(v.id);\n    });\n    return cell;\n  }\n\n  private filterLinkContext(contexts: ILinkCellContext[]): ILinkCellContext[] {\n    return contexts\n      .filter((ctx) => {\n        if (isLinkCellValue(ctx.newValue)) {\n          return true;\n        }\n\n        return isLinkCellValue(ctx.oldValue);\n      })\n      .map((ctx) => {\n        this.validateLinkCell(ctx);\n        return { ...ctx, oldValue: isLinkCellValue(ctx.oldValue) ? ctx.oldValue : undefined };\n      });\n  }\n\n  private buildFieldMapFromTables(\n    fieldIds: string[],\n    tables?: Map<string, TableDomain>\n  ): IFieldMapByTableId | undefined {\n    if (!tables?.size) {\n      return undefined;\n    }\n\n    const fieldMapByTableId: IFieldMapByTableId = {};\n\n    for (const [tableId, domain] of tables) {\n      for (const field of domain.fieldList) {\n        (fieldMapByTableId[tableId] ||= {})[field.id] = field as unknown as IFieldInstance;\n      }\n    }\n\n    const hasAllRequestedFields = fieldIds.every((fieldId) =>\n      Object.values(fieldMapByTableId).some((fields) => Boolean(fields?.[fieldId]))\n    );\n\n    return hasAllRequestedFields ? fieldMapByTableId : undefined;\n  }\n\n  private buildTableId2DbTableNameFromTables(\n    tableIds: string[],\n    tables?: Map<string, TableDomain>\n  ) {\n    if (!tables?.size) {\n      return undefined;\n    }\n\n    const result: { [tableId: string]: string } = {};\n    for (const tableId of tableIds) {\n      const domain = tables.get(tableId);\n      if (domain) {\n        result[tableId] = domain.dbTableName;\n      }\n    }\n\n    return Object.keys(result).length === tableIds.length ? result : undefined;\n  }\n\n  private async getRelatedFieldMap(fieldIds: string[]): Promise<IFieldMapByTableId> {\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fieldIds }, isLookup: null },\n    });\n    const fields = fieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[];\n\n    const symmetricFieldRaws = await this.prismaService.txClient().field.findMany({\n      where: {\n        id: {\n          in: fields\n            .filter((field) => field.options.symmetricFieldId)\n            .map((field) => field.options.symmetricFieldId as string),\n        },\n      },\n    });\n\n    const symmetricFields = symmetricFieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[];\n\n    const lookedFieldRaws = await this.prismaService.txClient().field.findMany({\n      where: {\n        id: {\n          in: fields\n            .map((field) => field.options.lookupFieldId)\n            .concat(symmetricFields.map((field) => field.options.lookupFieldId)),\n        },\n      },\n    });\n    const lookedFields = lookedFieldRaws.map(createFieldInstanceByRaw);\n\n    const instanceMap = keyBy([...fields, ...symmetricFields, ...lookedFields], 'id');\n\n    return [...fieldRaws, ...symmetricFieldRaws, ...lookedFieldRaws].reduce<IFieldMapByTableId>(\n      (acc, field) => {\n        const { tableId, id } = field;\n        if (!acc[tableId]) {\n          acc[tableId] = {};\n        }\n        acc[tableId][id] = instanceMap[id];\n        return acc;\n      },\n      {}\n    );\n  }\n\n  private formatTitleWithField(field: IFieldInstance, value: unknown): string | undefined {\n    try {\n      const formatted = field.cellValue2String(value);\n      if (typeof formatted === 'string' && formatted.trim().length > 0) {\n        return formatted;\n      }\n    } catch {\n      // Swallow formatting issues and fall back to generic extraction logic\n    }\n    return undefined;\n  }\n\n  private extractLinkTitle(value: unknown, field?: IFieldInstance): string | undefined {\n    if (value == null) {\n      return undefined;\n    }\n    if (field) {\n      const formatted = this.formatTitleWithField(field, value);\n      if (formatted) {\n        return formatted;\n      }\n    }\n    if (typeof value === 'string') {\n      return value;\n    }\n    if (typeof value === 'number' || typeof value === 'boolean') {\n      return String(value);\n    }\n    if (Array.isArray(value)) {\n      const titles = value\n        .map((item) => this.extractLinkTitle(item, field))\n        .filter((item): item is string => typeof item === 'string' && item.trim().length > 0);\n      return titles.length ? titles.join(', ') : undefined;\n    }\n    if (typeof value === 'object') {\n      const record = value as Record<string, unknown>;\n      const candidateKeys = ['title', 'name', 'text', 'label', 'email'];\n      for (const key of candidateKeys) {\n        const candidate = record[key];\n        if (typeof candidate === 'string' && candidate.trim()) {\n          return candidate;\n        }\n      }\n      const id = record.id;\n      if (typeof id === 'string' && id.trim()) {\n        return id;\n      }\n    }\n    return undefined;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private updateForeignCellForManyMany(params: {\n    fkItem: IFkRecordItem;\n    recordId: string;\n    symmetricFieldId: string;\n    sourceLookedFieldId: string;\n    sourceRecordMap: IRecordMapByTableId['tableId'];\n    foreignRecordMap: IRecordMapByTableId['tableId'];\n    sourceLookupField?: IFieldInstance;\n  }) {\n    const {\n      fkItem,\n      recordId,\n      symmetricFieldId,\n      sourceLookedFieldId,\n      foreignRecordMap,\n      sourceRecordMap,\n      sourceLookupField,\n    } = params;\n    const oldKey = (fkItem.oldKey || []) as string[];\n    const newKey = (fkItem.newKey || []) as string[];\n\n    const toDelete = difference(oldKey, newKey);\n    const toAdd = difference(newKey, oldKey);\n\n    // Update link cell values for symmetric field of the foreign table\n    if (toDelete.length) {\n      toDelete.forEach((foreignRecordId) => {\n        const foreignCellValue = foreignRecordMap[foreignRecordId][symmetricFieldId] as\n          | ILinkCellValue[]\n          | ILinkCellValue\n          | null;\n\n        if (foreignCellValue) {\n          const filteredCellValue = [foreignCellValue]\n            .flat()\n            .filter((item) => item.id !== recordId);\n          foreignRecordMap[foreignRecordId][symmetricFieldId] = filteredCellValue.length\n            ? filteredCellValue\n            : null;\n        }\n      });\n    }\n\n    if (toAdd.length) {\n      toAdd.forEach((foreignRecordId) => {\n        const lookupValue =\n          sourceLookedFieldId != null\n            ? sourceRecordMap[recordId]?.[sourceLookedFieldId]\n            : undefined;\n        const sourceRecordTitle = this.extractLinkTitle(lookupValue, sourceLookupField);\n        const newForeignRecord = foreignRecordMap[foreignRecordId];\n        if (!newForeignRecord) {\n          throw new CustomHttpException(\n            `Consistency error, recordId ${foreignRecordId} is not exist`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.linkConsistencyError',\n                context: { recordId: foreignRecordId },\n              },\n            }\n          );\n        }\n        const foreignCellValue = newForeignRecord[symmetricFieldId] as\n          | ILinkCellValue[]\n          | ILinkCellValue\n          | null;\n        if (foreignCellValue) {\n          const newForeignCellValue = [foreignCellValue].flat().concat({\n            id: recordId,\n            title: sourceRecordTitle,\n          });\n          newForeignRecord[symmetricFieldId] = uniqBy(newForeignCellValue, 'id');\n        } else {\n          newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }];\n        }\n      });\n    }\n  }\n\n  private updateForeignCellForManyOne(params: {\n    fkItem: IFkRecordItem;\n    recordId: string;\n    symmetricFieldId: string;\n    sourceLookedFieldId: string;\n    sourceRecordMap: IRecordMapByTableId['tableId'];\n    foreignRecordMap: IRecordMapByTableId['tableId'];\n    sourceLookupField?: IFieldInstance;\n  }) {\n    const {\n      fkItem,\n      recordId,\n      symmetricFieldId,\n      sourceLookedFieldId,\n      foreignRecordMap,\n      sourceRecordMap,\n      sourceLookupField,\n    } = params;\n    const oldKey = (fkItem.oldKey || []) as string[];\n    const newKey = fkItem.newKey as string | null;\n\n    // Update link cell values for symmetric field of the foreign table\n    if (oldKey?.length) {\n      oldKey.forEach((foreignRecordId) => {\n        const foreignCellValue = foreignRecordMap[foreignRecordId][symmetricFieldId] as\n          | ILinkCellValue[]\n          | ILinkCellValue\n          | null;\n\n        if (foreignCellValue) {\n          const filteredCellValue = [foreignCellValue]\n            .flat()\n            .filter((item) => item.id !== recordId);\n\n          foreignRecordMap[foreignRecordId][symmetricFieldId] = filteredCellValue.length\n            ? filteredCellValue\n            : null;\n        } else {\n          foreignRecordMap[foreignRecordId][symmetricFieldId] = null;\n        }\n      });\n    }\n\n    if (newKey) {\n      const lookupValue =\n        sourceLookedFieldId != null ? sourceRecordMap[recordId]?.[sourceLookedFieldId] : undefined;\n      const sourceRecordTitle = this.extractLinkTitle(lookupValue, sourceLookupField);\n      const newForeignRecord = foreignRecordMap[newKey];\n      if (!newForeignRecord) {\n        throw new CustomHttpException(\n          `Consistency error, recordId ${newKey} is not exist`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.linkConsistencyError',\n              context: { recordId: newKey },\n            },\n          }\n        );\n      }\n      const foreignCellValue = newForeignRecord[symmetricFieldId] as\n        | ILinkCellValue[]\n        | ILinkCellValue\n        | null;\n      if (foreignCellValue) {\n        const newForeignCellValue = [foreignCellValue].flat().concat({\n          id: recordId,\n          title: sourceRecordTitle,\n        });\n        newForeignRecord[symmetricFieldId] = uniqBy(newForeignCellValue, 'id');\n      } else {\n        newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }];\n      }\n    }\n  }\n\n  private updateForeignCellForOneMany(params: {\n    fkItem: IFkRecordItem;\n    recordId: string;\n    symmetricFieldId: string;\n    sourceLookedFieldId: string;\n    sourceRecordMap: IRecordMapByTableId['tableId'];\n    foreignRecordMap: IRecordMapByTableId['tableId'];\n    sourceLookupField?: IFieldInstance;\n  }) {\n    const {\n      fkItem,\n      recordId,\n      symmetricFieldId,\n      sourceLookedFieldId,\n      foreignRecordMap,\n      sourceRecordMap,\n      sourceLookupField,\n    } = params;\n\n    const oldKey = (fkItem.oldKey || []) as string[];\n    const newKey = (fkItem.newKey || []) as string[];\n\n    const toDelete = difference(oldKey, newKey);\n    const toAdd = difference(newKey, oldKey);\n\n    if (toDelete.length) {\n      toDelete.forEach((foreignRecordId) => {\n        foreignRecordMap[foreignRecordId][symmetricFieldId] = null;\n      });\n    }\n\n    if (toAdd.length) {\n      const lookupValue =\n        sourceLookedFieldId != null ? sourceRecordMap[recordId]?.[sourceLookedFieldId] : undefined;\n      const sourceRecordTitle = this.extractLinkTitle(lookupValue, sourceLookupField);\n\n      toAdd.forEach((foreignRecordId) => {\n        foreignRecordMap[foreignRecordId][symmetricFieldId] = {\n          id: recordId,\n          title: sourceRecordTitle,\n        };\n      });\n    }\n  }\n\n  private updateForeignCellForOneOne(params: {\n    fkItem: IFkRecordItem;\n    recordId: string;\n    symmetricFieldId: string;\n    sourceLookedFieldId: string;\n    sourceRecordMap: IRecordMapByTableId['tableId'];\n    foreignRecordMap: IRecordMapByTableId['tableId'];\n    sourceLookupField?: IFieldInstance;\n  }) {\n    const {\n      fkItem,\n      recordId,\n      symmetricFieldId,\n      sourceLookedFieldId,\n      foreignRecordMap,\n      sourceRecordMap,\n      sourceLookupField,\n    } = params;\n\n    const oldKey = (fkItem.oldKey || []) as string[];\n    const newKey = fkItem.newKey as string | undefined;\n\n    if (oldKey?.length) {\n      oldKey.forEach((foreignRecordId) => {\n        foreignRecordMap[foreignRecordId][symmetricFieldId] = null;\n      });\n    }\n\n    if (newKey) {\n      const lookupValue =\n        sourceLookedFieldId != null ? sourceRecordMap[recordId]?.[sourceLookedFieldId] : undefined;\n      const sourceRecordTitle = this.extractLinkTitle(lookupValue, sourceLookupField);\n\n      foreignRecordMap[newKey][symmetricFieldId] = {\n        id: recordId,\n        title: sourceRecordTitle,\n      };\n    }\n  }\n\n  // update link cellValue title for the user input value of the source table\n  private fixLinkCellTitle(params: {\n    newKey: string | string[] | null;\n    recordId: string;\n    linkFieldId: string;\n    foreignLookedFieldId: string;\n    sourceRecordMap: IRecordMapByTableId['tableId'];\n    foreignRecordMap: IRecordMapByTableId['tableId'];\n    foreignLookupField?: IFieldInstance;\n  }) {\n    const {\n      newKey,\n      recordId,\n      linkFieldId,\n      foreignLookedFieldId,\n      foreignRecordMap,\n      sourceRecordMap,\n      foreignLookupField,\n    } = params;\n\n    if (!newKey) {\n      return;\n    }\n\n    if (Array.isArray(newKey)) {\n      sourceRecordMap[recordId][linkFieldId] = newKey.map((key) => ({\n        id: key,\n        title: this.extractLinkTitle(\n          foreignLookedFieldId != null ? foreignRecordMap[key]?.[foreignLookedFieldId] : undefined,\n          foreignLookupField\n        ),\n      }));\n      return;\n    }\n\n    const lookupValue =\n      foreignLookedFieldId != null ? foreignRecordMap[newKey]?.[foreignLookedFieldId] : undefined;\n    const foreignRecordTitle = this.extractLinkTitle(lookupValue, foreignLookupField);\n    sourceRecordMap[recordId][linkFieldId] = { id: newKey, title: foreignRecordTitle };\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async updateLinkRecord(\n    tableId: string,\n    fkRecordMap: IFkRecordMap,\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    originRecordMapByTableId: IRecordMapByTableId\n  ): Promise<IRecordMapByTableId> {\n    const recordMapByTableId = cloneDeep(originRecordMapByTableId);\n    for (const fieldId in fkRecordMap) {\n      const linkField = fieldMapByTableId[tableId][fieldId] as LinkFieldDto;\n      const linkFieldId = linkField.id;\n      const relationship = linkField.options.relationship;\n      const foreignTableId = linkField.options.foreignTableId;\n      const foreignLookedFieldId = linkField.options.lookupFieldId;\n      const foreignLookupField =\n        foreignLookedFieldId != null\n          ? fieldMapByTableId[foreignTableId]?.[foreignLookedFieldId]\n          : undefined;\n\n      const sourceRecordMap = recordMapByTableId[tableId];\n      const foreignRecordMap = recordMapByTableId[foreignTableId];\n      const symmetricFieldId = linkField.options.symmetricFieldId;\n\n      for (const recordId in fkRecordMap[fieldId]) {\n        const fkItem = fkRecordMap[fieldId][recordId];\n\n        this.fixLinkCellTitle({\n          newKey: fkItem.newKey,\n          recordId,\n          linkFieldId,\n          foreignLookedFieldId,\n          sourceRecordMap,\n          foreignRecordMap,\n          foreignLookupField,\n        });\n\n        if (!symmetricFieldId) {\n          continue;\n        }\n        const symmetricField = fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto;\n        const sourceLookedFieldId = symmetricField.options.lookupFieldId;\n        const sourceLookupField =\n          sourceLookedFieldId != null\n            ? fieldMapByTableId[tableId]?.[sourceLookedFieldId]\n            : undefined;\n        const params = {\n          fkItem,\n          recordId,\n          symmetricFieldId,\n          sourceLookedFieldId,\n          sourceRecordMap,\n          foreignRecordMap,\n          sourceLookupField,\n        };\n        if (relationship === Relationship.ManyMany) {\n          this.updateForeignCellForManyMany(params);\n        }\n        if (relationship === Relationship.ManyOne) {\n          this.updateForeignCellForManyOne(params);\n        }\n        if (relationship === Relationship.OneMany) {\n          this.updateForeignCellForOneMany(params);\n        }\n        if (relationship === Relationship.OneOne) {\n          this.updateForeignCellForOneOne(params);\n        }\n      }\n    }\n    return recordMapByTableId;\n  }\n\n  private async getForeignKeys(\n    recordIds: string[],\n    linkRecordIds: string[],\n    options: ILinkFieldOptions\n  ) {\n    const { fkHostTableName, selfKeyName, foreignKeyName } = options;\n\n    const query = this.knex(fkHostTableName)\n      .select({\n        id: selfKeyName,\n        foreignId: foreignKeyName,\n      })\n      .whereIn(selfKeyName, recordIds)\n      .orWhereIn(foreignKeyName, linkRecordIds)\n      .whereNotNull(selfKeyName)\n      .whereNotNull(foreignKeyName)\n      .toQuery();\n\n    return this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);\n  }\n\n  async getAllForeignKeys(options: ILinkFieldOptions) {\n    const { fkHostTableName, selfKeyName, foreignKeyName } = options;\n\n    const query = this.knex(fkHostTableName)\n      .select({\n        id: selfKeyName,\n        foreignId: foreignKeyName,\n      })\n      .whereNotNull(selfKeyName)\n      .whereNotNull(foreignKeyName)\n      .toQuery();\n\n    return this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);\n  }\n\n  private async getJoinedForeignKeys(linkRecordIds: string[], options: ILinkFieldOptions) {\n    const { fkHostTableName, selfKeyName, foreignKeyName } = options;\n\n    const query = this.knex(fkHostTableName)\n      .select({\n        id: selfKeyName,\n        foreignId: foreignKeyName,\n      })\n      .whereIn(selfKeyName, function () {\n        this.select(selfKeyName)\n          .from(fkHostTableName)\n          .whereIn(foreignKeyName, linkRecordIds)\n          .whereNotNull(selfKeyName);\n      })\n      .whereNotNull(foreignKeyName)\n      .toQuery();\n\n    return this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);\n  }\n\n  /**\n   * Checks if there are duplicate associations in one-to-one and one-to-many relationships.\n   */\n  private checkForIllegalDuplicateLinks(\n    field: LinkFieldDto,\n    recordIds: string[],\n    indexedCellContext: Record<string, ILinkCellContext>\n  ) {\n    const relationship = field.options.relationship;\n    if (relationship === Relationship.ManyMany || relationship === Relationship.ManyOne) {\n      return;\n    }\n    const checkSet = new Set<string>();\n\n    recordIds.forEach((recordId) => {\n      const cellValue = indexedCellContext[`${field.id}-${recordId}`].newValue;\n      if (!cellValue) {\n        return;\n      }\n      if (Array.isArray(cellValue)) {\n        cellValue.forEach((item) => {\n          if (checkSet.has(item.id)) {\n            throw new CustomHttpException(\n              `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${item.id}) more than once`,\n              HttpErrorCode.VALIDATION_ERROR,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.custom.linkFieldValueDuplicate',\n                  context: { fieldName: field.name },\n                },\n              }\n            );\n          }\n          checkSet.add(item.id);\n        });\n        return;\n      }\n      if (checkSet.has(cellValue.id)) {\n        throw new CustomHttpException(\n          `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${cellValue.id}) more than once`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.linkFieldValueDuplicate',\n              context: { fieldName: field.name },\n            },\n          }\n        );\n      }\n      checkSet.add(cellValue.id);\n    });\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private parseFkRecordItem(\n    field: LinkFieldDto,\n    cellContexts: ILinkCellContext[],\n    foreignKeys: {\n      id: string;\n      foreignId: string;\n    }[]\n  ): Record<string, IFkRecordItem> {\n    const relationship = field.options.relationship;\n    const foreignKeysIndexed = groupBy(foreignKeys, 'id');\n    const foreignKeysReverseIndexed =\n      relationship === Relationship.OneMany || relationship === Relationship.OneOne\n        ? groupBy(foreignKeys, 'foreignId')\n        : undefined;\n\n    // eslint-disable-next-line sonarjs/cognitive-complexity\n    return cellContexts.reduce<IFkRecordMap['fieldId']>((acc, cellContext) => {\n      // this two relations only have one key in one recordId\n      const id = cellContext.recordId;\n      const foreignKeys = foreignKeysIndexed[id];\n      if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {\n        const oldCellValue = cellContext.oldValue as ILinkCellValue | ILinkCellValue[] | undefined;\n        const newCellValue = cellContext.newValue as ILinkCellValue | undefined;\n        if (Array.isArray(newCellValue)) {\n          throw new CustomHttpException(\n            `CellValue of ${relationship} link field values cannot be an array`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey:\n                  relationship === Relationship.OneOne\n                    ? 'httpErrors.field.oneOneLinkCellValueCannotBeArray'\n                    : 'httpErrors.field.manyOneLinkCellValueCannotBeArray',\n              },\n            }\n          );\n        }\n\n        if ((foreignKeys?.length ?? 0) > 1) {\n          throw new CustomHttpException(`Foreign key duplicate`, HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.field.foreignKeyDuplicate',\n            },\n          });\n        }\n\n        const oldKey = oldCellValue ? [oldCellValue].flat().map((key) => key.id) : null;\n        const newKey = newCellValue?.id || null;\n        if (oldCellValue && !Array.isArray(oldCellValue) && isEqual(oldCellValue.id, newKey)) {\n          return acc;\n        }\n\n        if (newKey && foreignKeysReverseIndexed?.[newKey]) {\n          throw new CustomHttpException(\n            `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${newKey}) more than once`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.custom.linkFieldValueDuplicate',\n                context: { fieldName: field.name },\n              },\n            }\n          );\n        }\n\n        acc[id] = { oldKey, newKey };\n        return acc;\n      }\n\n      if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) {\n        const newCellValue = cellContext.newValue as ILinkCellValue[] | undefined;\n        if (newCellValue && !Array.isArray(newCellValue)) {\n          throw new CustomHttpException(\n            `CellValue of ${relationship} link field values should be an array`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey:\n                  relationship === Relationship.OneMany\n                    ? 'httpErrors.field.oneManyLinkCellValueShouldBeArray'\n                    : 'httpErrors.field.manyManyLinkCellValueShouldBeArray',\n              },\n            }\n          );\n        }\n\n        const oldKey = foreignKeys?.map((key) => key.foreignId) ?? null;\n        const newKey = newCellValue?.map((item) => item.id) ?? null;\n\n        const extraKey = difference(newKey ?? [], oldKey ?? []);\n\n        extraKey.forEach((key) => {\n          if (foreignKeysReverseIndexed?.[key]) {\n            throw new CustomHttpException(\n              `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${key}) more than once`,\n              HttpErrorCode.VALIDATION_ERROR,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.custom.linkFieldValueDuplicate',\n                  context: { fieldName: field.name },\n                },\n              }\n            );\n          }\n        });\n        acc[id] = {\n          oldKey,\n          newKey,\n        };\n        return acc;\n      }\n      return acc;\n    }, {});\n  }\n\n  /**\n   * Tip: for single source of truth principle, we should only trust foreign key recordId\n   *\n   * 1. get all edited recordId and group by fieldId\n   * 2. get all exist foreign key recordId\n   */\n  private async getFkRecordMap(\n    fieldMap: IFieldMap,\n    cellContexts: ILinkCellContext[]\n  ): Promise<IFkRecordMap> {\n    const fkRecordMap: IFkRecordMap = {};\n\n    const cellGroupByFieldId = groupBy(cellContexts, (ctx) => ctx.fieldId);\n    const indexedCellContext = keyBy(cellContexts, (ctx) => `${ctx.fieldId}-${ctx.recordId}`);\n    for (const fieldId in cellGroupByFieldId) {\n      const field = fieldMap[fieldId];\n      if (!field) {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      }\n\n      if (field.type !== FieldType.Link) {\n        throw new CustomHttpException(\n          `Field ${fieldId} is not link field`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.notFound',\n            },\n          }\n        );\n      }\n\n      const recordIds = cellGroupByFieldId[fieldId].map((ctx) => ctx.recordId);\n      const linkRecordIds = uniq(\n        cellGroupByFieldId[fieldId]\n          .map((ctx) =>\n            [ctx.oldValue, ctx.newValue]\n              .flat()\n              .filter(Boolean)\n              .map((item) => item?.id as string)\n          )\n          .flat()\n      );\n\n      const foreignKeys = await this.getForeignKeys(recordIds, linkRecordIds, field.options);\n      this.checkForIllegalDuplicateLinks(field, recordIds, indexedCellContext);\n\n      fkRecordMap[fieldId] = this.parseFkRecordItem(\n        field,\n        cellGroupByFieldId[fieldId],\n        foreignKeys\n      );\n    }\n\n    return fkRecordMap;\n  }\n\n  // create the key for recordMapByTableId but leave the undefined value for the next step\n  private getRecordMapStruct(\n    tableId: string,\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    cellContexts: ILinkCellContext[]\n  ) {\n    const recordMapByTableId: IRecordMapByTableId = {};\n\n    for (const cellContext of cellContexts) {\n      const { recordId, fieldId, newValue, oldValue } = cellContext;\n      const linkRecordIds = [oldValue, newValue]\n        .flat()\n        .filter(Boolean)\n        .map((item) => item?.id as string);\n      const field = fieldMapByTableId[tableId][fieldId] as LinkFieldDto;\n      const foreignTableId = field.options.foreignTableId;\n      const symmetricFieldId = field.options.symmetricFieldId;\n      const symmetricField = symmetricFieldId\n        ? (fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto)\n        : undefined;\n      const foreignLookedFieldId = field.options.lookupFieldId;\n      const lookedFieldId = symmetricField?.options.lookupFieldId;\n\n      set(recordMapByTableId, [tableId, recordId, fieldId], undefined);\n      lookedFieldId && set(recordMapByTableId, [tableId, recordId, lookedFieldId], undefined);\n\n      // create object key for record in looked field\n      linkRecordIds.forEach((linkRecordId) => {\n        symmetricFieldId &&\n          set(recordMapByTableId, [foreignTableId, linkRecordId, symmetricFieldId], undefined);\n        set(recordMapByTableId, [foreignTableId, linkRecordId, foreignLookedFieldId], undefined);\n      });\n    }\n\n    return recordMapByTableId;\n  }\n\n  private mergeProjectionByTable(\n    recordMapByTableId: IRecordMapByTableId,\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    projectionByTable?: Record<string, string[]>\n  ): Record<string, string[]> | undefined {\n    const result: Record<string, Set<string>> = {};\n\n    for (const tableId in recordMapByTableId) {\n      const recordLookupFieldsMap = recordMapByTableId[tableId];\n      const fromCaller = projectionByTable?.[tableId] ?? [];\n      result[tableId] = new Set(fromCaller);\n\n      Object.values(recordLookupFieldsMap).forEach((lookupFieldMap) => {\n        if (!lookupFieldMap) return;\n        Object.keys(lookupFieldMap).forEach((fieldId) => {\n          if (fieldMapByTableId[tableId]?.[fieldId]) {\n            result[tableId]!.add(fieldId);\n          }\n        });\n      });\n    }\n\n    const finalized = Object.entries(result).reduce<Record<string, string[]>>((acc, [id, set]) => {\n      if (set.size) {\n        acc[id] = Array.from(set);\n      }\n      return acc;\n    }, {});\n\n    return Object.keys(finalized).length ? finalized : undefined;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  @Timing()\n  private async fetchRecordMap(\n    tableId2DbTableName: { [tableId: string]: string },\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    recordMapByTableId: IRecordMapByTableId,\n    cellContexts: ICellContext[],\n    projectionByTable: Record<string, string[]> | undefined,\n    fromReset?: boolean,\n    useQueryModel = false\n  ): Promise<IRecordMapByTableId> {\n    const cellContextGroup = keyBy(cellContexts, (ctx) => `${ctx.recordId}-${ctx.fieldId}`);\n    for (const tableId in recordMapByTableId) {\n      const recordLookupFieldsMap = recordMapByTableId[tableId];\n      const recordIds = Object.keys(recordLookupFieldsMap);\n      const dbFieldName2FieldId: { [dbFieldName: string]: string } = {};\n      const tableProjection = projectionByTable?.[tableId];\n\n      for (const recordId of recordIds) {\n        const lookupFieldMap = recordLookupFieldsMap[recordId];\n        if (!lookupFieldMap) continue;\n        for (const fieldId of Object.keys(lookupFieldMap)) {\n          const field = fieldMapByTableId[tableId]?.[fieldId];\n          if (!field) continue;\n          for (const dbFieldName of field.dbFieldNames) {\n            dbFieldName2FieldId[dbFieldName] = fieldId;\n          }\n        }\n      }\n\n      const { qb } = await this.recordQueryBuilder.createRecordQueryBuilder(\n        tableId2DbTableName[tableId],\n        {\n          tableId,\n          viewId: undefined,\n          projection: tableProjection,\n          rawProjection: true,\n          preferRawFieldReferences: true,\n          useQueryModel,\n        }\n      );\n\n      const nativeQuery = qb.whereIn('__id', recordIds).toQuery();\n      this.logger.debug(`Fetch records with query: ${nativeQuery}`);\n      const recordRaw = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ [dbTableName: string]: unknown }[]>(nativeQuery);\n\n      recordRaw.forEach((record) => {\n        const recordId = record.__id as string;\n        delete record.__id;\n        for (const dbFieldName in record) {\n          const fieldId = dbFieldName2FieldId[dbFieldName];\n          let cellValue = record[dbFieldName];\n\n          // dbForeignName is not exit in fieldMapByTableId\n          if (!fieldId) {\n            recordLookupFieldsMap[recordId][dbFieldName] = cellValue;\n            continue;\n          }\n          const field = fieldMapByTableId[tableId][fieldId];\n          if (fromReset && field.type === FieldType.Link) {\n            continue;\n          }\n\n          // Overlay with new data, especially cellValue in primary field\n          const inputData = cellContextGroup[`${recordId}-${fieldId}`];\n          if (field.type !== FieldType.Link && inputData !== undefined) {\n            recordLookupFieldsMap[recordId][fieldId] = inputData.newValue ?? undefined;\n            continue;\n          }\n\n          cellValue = field.convertDBValue2CellValue(cellValue);\n\n          recordLookupFieldsMap[recordId][fieldId] = cellValue ?? undefined;\n        }\n      }, {});\n    }\n\n    return recordMapByTableId;\n  }\n\n  private async getTableId2DbTableName(tableIds: string[]) {\n    const tableRaws = await this.prismaService.txClient().tableMeta.findMany({\n      where: {\n        id: {\n          in: tableIds,\n        },\n      },\n      select: {\n        id: true,\n        dbTableName: true,\n      },\n    });\n    return tableRaws.reduce<{ [tableId: string]: string }>((acc, cur) => {\n      acc[cur.id] = cur.dbTableName;\n      return acc;\n    }, {});\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private diffLinkCellChange(\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    originRecordMapByTableId: IRecordMapByTableId,\n    updatedRecordMapByTableId: IRecordMapByTableId\n  ): ICellChange[] {\n    const changes: ICellChange[] = [];\n\n    for (const tableId in originRecordMapByTableId) {\n      const originRecords = originRecordMapByTableId[tableId];\n      const updatedRecords = updatedRecordMapByTableId[tableId];\n      const fieldMap = fieldMapByTableId[tableId];\n\n      for (const recordId in originRecords) {\n        const originFields = originRecords[recordId];\n        const updatedFields = updatedRecords[recordId];\n\n        for (const fieldId in originFields) {\n          if (!fieldMap[fieldId]) {\n            continue;\n          }\n          if (fieldMap[fieldId].type !== FieldType.Link) {\n            continue;\n          }\n\n          const oldValue = originFields[fieldId];\n          const newValue = updatedFields[fieldId];\n\n          if (!isEqual(oldValue, newValue)) {\n            changes.push({ tableId, recordId, fieldId, oldValue, newValue });\n          }\n        }\n      }\n    }\n\n    return changes;\n  }\n\n  private async getDerivateByCellContexts(\n    tableId: string,\n    tableId2DbTableName: { [tableId: string]: string },\n    fieldMapByTableId: { [tableId: string]: IFieldMap },\n    linkContexts: ILinkCellContext[],\n    cellContexts: ICellContext[],\n    projectionByTable?: Record<string, string[]>,\n    fromReset?: boolean,\n    persistFk: boolean = true\n  ): Promise<{\n    cellChanges: ICellChange[];\n    fkRecordMap: IFkRecordMap;\n  }> {\n    const fieldMap = fieldMapByTableId[tableId];\n    const recordMapStruct = this.getRecordMapStruct(tableId, fieldMapByTableId, linkContexts);\n    const mergedProjectionByTable = this.mergeProjectionByTable(\n      recordMapStruct,\n      fieldMapByTableId,\n      projectionByTable\n    );\n\n    const fkRecordMap = await this.getFkRecordMap(fieldMap, linkContexts);\n\n    const originRecordMapByTableId = await this.fetchRecordMap(\n      tableId2DbTableName,\n      fieldMapByTableId,\n      recordMapStruct,\n      cellContexts,\n      mergedProjectionByTable,\n      fromReset,\n      true\n    );\n\n    let updatedRecordMapByTableId: IRecordMapByTableId;\n\n    if (persistFk) {\n      await this.saveForeignKeyToDb(fieldMap, fkRecordMap);\n      const refreshedRecordMapStruct = this.getRecordMapStruct(\n        tableId,\n        fieldMapByTableId,\n        linkContexts\n      );\n      updatedRecordMapByTableId = await this.fetchRecordMap(\n        tableId2DbTableName,\n        fieldMapByTableId,\n        refreshedRecordMapStruct,\n        cellContexts,\n        mergedProjectionByTable,\n        fromReset,\n        true\n      );\n    } else {\n      updatedRecordMapByTableId = await this.updateLinkRecord(\n        tableId,\n        fkRecordMap,\n        fieldMapByTableId,\n        originRecordMapByTableId\n      );\n    }\n\n    const cellChanges = this.diffLinkCellChange(\n      fieldMapByTableId,\n      originRecordMapByTableId,\n      updatedRecordMapByTableId\n    );\n    return {\n      cellChanges,\n      fkRecordMap,\n    };\n  }\n\n  private async saveForeignKeyForManyMany(\n    field: LinkFieldDto,\n    fkMap: { [recordId: string]: IFkRecordItem }\n  ) {\n    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;\n\n    const toDelete: [string, string][] = [];\n    const toAdd: [string, string][] = [];\n    const toDeleteAndReinsert: [string, string[]][] = [];\n\n    for (const recordId in fkMap) {\n      const fkItem = fkMap[recordId];\n      const oldKey = (fkItem.oldKey || []) as string[];\n      const newKey = (fkItem.newKey || []) as string[];\n\n      // Check if only order has changed (same elements but different order)\n      const hasOrderChanged =\n        oldKey.length === newKey.length &&\n        oldKey.length > 0 &&\n        newKey.length > 0 &&\n        oldKey.every((key) => newKey.includes(key)) &&\n        newKey.every((key) => oldKey.includes(key)) &&\n        !oldKey.every((key, index) => key === newKey[index]);\n\n      if (hasOrderChanged) {\n        // For order changes only: delete all and re-insert in correct order\n        toDeleteAndReinsert.push([recordId, newKey]);\n      } else {\n        // For add/remove changes: use differential approach\n        difference(oldKey, newKey).forEach((key) => toDelete.push([recordId, key]));\n        difference(newKey, oldKey).forEach((key) => toAdd.push([recordId, key]));\n      }\n    }\n\n    // Handle order changes: delete all existing records for affected recordIds and re-insert\n    if (toDeleteAndReinsert.length) {\n      const recordIdsToDeleteAll = toDeleteAndReinsert.map(([recordId]) => recordId);\n      const deleteAllQuery = this.knex(fkHostTableName)\n        .whereIn(selfKeyName, recordIdsToDeleteAll)\n        .delete()\n        .toQuery();\n      await this.prismaService.txClient().$executeRawUnsafe(deleteAllQuery);\n\n      // Re-insert all records in correct order\n      const reinsertData = toDeleteAndReinsert.flatMap(([recordId, newKeys]) =>\n        newKeys.map((foreignKey, index) => {\n          const data: Record<string, unknown> = {\n            [selfKeyName]: recordId,\n            [foreignKeyName]: foreignKey,\n          };\n          // Add order column if field has order column\n          if (field.getHasOrderColumn()) {\n            const linkField = field as LinkFieldDto;\n            data[linkField.getOrderColumnName()] = index + 1;\n          }\n          return data;\n        })\n      );\n\n      if (reinsertData.length) {\n        const reinsertQuery = this.knex(fkHostTableName).insert(reinsertData).toQuery();\n        await this.prismaService.txClient().$executeRawUnsafe(reinsertQuery);\n      }\n    }\n\n    // Handle regular deletions\n    if (toDelete.length) {\n      const query = this.knex(fkHostTableName)\n        .whereIn([selfKeyName, foreignKeyName], toDelete)\n        .delete()\n        .toQuery();\n      await this.prismaService.txClient().$executeRawUnsafe(query);\n    }\n\n    // Handle regular additions\n    if (toAdd.length) {\n      // Group additions by source record to maintain per-source ordering\n      const sourceGroups = new Map<string, string[]>();\n      for (const [sourceRecordId, targetRecordId] of toAdd) {\n        if (!sourceGroups.has(sourceRecordId)) {\n          sourceGroups.set(sourceRecordId, []);\n        }\n        sourceGroups.get(sourceRecordId)!.push(targetRecordId);\n      }\n\n      const insertData: Array<Record<string, unknown>> = [];\n\n      for (const [sourceRecordId, targetRecordIds] of sourceGroups) {\n        let currentMaxOrder = 0;\n\n        // Get current max order for this source record if field has order column\n        if (field.getHasOrderColumn()) {\n          currentMaxOrder = await this.getMaxOrderForTarget(\n            fkHostTableName,\n            selfKeyName,\n            sourceRecordId,\n            field.getOrderColumnName()\n          );\n        }\n\n        // Add records with incremental order values per source\n        for (let i = 0; i < targetRecordIds.length; i++) {\n          const targetRecordId = targetRecordIds[i];\n          const data: Record<string, unknown> = {\n            [selfKeyName]: sourceRecordId,\n            [foreignKeyName]: targetRecordId,\n          };\n\n          if (field.getHasOrderColumn()) {\n            const linkField = field as LinkFieldDto;\n            data[linkField.getOrderColumnName()] = currentMaxOrder + i + 1;\n          }\n\n          insertData.push(data);\n        }\n      }\n\n      const query = this.knex(fkHostTableName).insert(insertData).toQuery();\n      await this.prismaService.txClient().$executeRawUnsafe(query);\n    }\n  }\n\n  /**\n   * Get the maximum order value for a specific target record in a link relationship\n   */\n  private async getMaxOrderForTarget(\n    tableName: string,\n    foreignKeyColumn: string,\n    targetRecordId: string,\n    orderColumnName: string\n  ): Promise<number> {\n    const maxOrderQuery = this.knex(tableName)\n      .where(foreignKeyColumn, targetRecordId)\n      .max(`${orderColumnName} as maxOrder`)\n      .first()\n      .toQuery();\n\n    const maxOrderResult = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ maxOrder: unknown }[]>(maxOrderQuery);\n    const raw = maxOrderResult[0]?.maxOrder as unknown;\n    // Coerce SQLite BigInt or string results safely into number; default to 0\n    return raw == null ? 0 : Number(raw);\n  }\n\n  private async saveForeignKeyForManyOne(\n    field: LinkFieldDto,\n    fkMap: { [recordId: string]: IFkRecordItem }\n  ) {\n    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;\n\n    const toDelete: [string, string][] = [];\n    const toAdd: [string, string][] = [];\n    for (const recordId in fkMap) {\n      const fkItem = fkMap[recordId];\n      const oldKey = (fkItem.oldKey || []) as string[];\n      const newKey = fkItem.newKey as string | null;\n      oldKey && oldKey.forEach((key) => toDelete.push([recordId, key]));\n      newKey && toAdd.push([recordId, newKey]);\n    }\n\n    const affectedForeignIds = uniq(\n      toDelete.map(([, foreignId]) => foreignId).concat(toAdd.map(([, foreignId]) => foreignId))\n    );\n    await this.lockForeignRecords(field.options.foreignTableId, affectedForeignIds);\n\n    if (toDelete.length) {\n      const updateFields: Record<string, null> = { [foreignKeyName]: null };\n      // Also clear order column if field has order column\n      if (field.getHasOrderColumn()) {\n        updateFields[`${foreignKeyName}_order`] = null;\n      }\n\n      const query = this.knex(fkHostTableName)\n        .update(updateFields)\n        .whereIn([selfKeyName, foreignKeyName], toDelete)\n        .toQuery();\n      await this.prismaService.txClient().$executeRawUnsafe(query);\n    }\n\n    if (toAdd.length) {\n      const dbFields = [{ dbFieldName: foreignKeyName, schemaType: SchemaType.String }];\n      // Add order column if field has order column\n      if (field.getHasOrderColumn()) {\n        dbFields.push({ dbFieldName: `${foreignKeyName}_order`, schemaType: SchemaType.Integer });\n      }\n\n      // Group toAdd by target record to handle order correctly\n      const targetGroups = new Map<string, string[]>();\n      for (const [recordId, foreignRecordId] of toAdd) {\n        if (!targetGroups.has(foreignRecordId)) {\n          targetGroups.set(foreignRecordId, []);\n        }\n        targetGroups.get(foreignRecordId)!.push(recordId);\n      }\n\n      const updateData: Array<{ id: string; values: Record<string, unknown> }> = [];\n\n      for (const [foreignRecordId, recordIds] of targetGroups) {\n        let currentMaxOrder = 0;\n\n        // Get current max order for this target record if field has order column\n        if (field.getHasOrderColumn()) {\n          currentMaxOrder = await this.getMaxOrderForTarget(\n            fkHostTableName,\n            foreignKeyName,\n            foreignRecordId,\n            field.getOrderColumnName()\n          );\n        }\n\n        // Add records with incremental order values\n        for (let i = 0; i < recordIds.length; i++) {\n          const recordId = recordIds[i];\n          const values: Record<string, unknown> = { [foreignKeyName]: foreignRecordId };\n\n          if (field.getHasOrderColumn()) {\n            values[`${foreignKeyName}_order`] = currentMaxOrder + i + 1;\n          }\n\n          updateData.push({\n            id: recordId,\n            values,\n          });\n        }\n      }\n\n      await this.batchService.batchUpdateDB(fkHostTableName, selfKeyName, dbFields, updateData);\n    }\n  }\n\n  private async lockForeignRecords(tableId: string, recordIds: string[]) {\n    if (!recordIds.length) {\n      return;\n    }\n\n    const client = (this.knex.client.config as { client?: string } | undefined)?.client;\n    if (client !== 'pg' && client !== 'postgresql') {\n      return;\n    }\n\n    const tableMeta = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    if (!tableMeta) {\n      return;\n    }\n\n    const lockQuery = this.knex(tableMeta.dbTableName)\n      .select('__id')\n      .whereIn('__id', recordIds)\n      .forUpdate()\n      .toQuery();\n\n    await this.prismaService.txClient().$queryRawUnsafe(lockQuery);\n  }\n\n  private async saveForeignKeyForOneMany(\n    field: LinkFieldDto,\n    fkMap: { [recordId: string]: IFkRecordItem }\n  ) {\n    const { selfKeyName, foreignKeyName, fkHostTableName, isOneWay } = field.options;\n\n    if (isOneWay) {\n      this.saveForeignKeyForManyMany(field, fkMap);\n      return;\n    }\n\n    // Process each record individually to maintain order\n    for (const recordId in fkMap) {\n      const fkItem = fkMap[recordId];\n      const oldKey = (fkItem.oldKey || []) as string[];\n      const newKey = (fkItem.newKey || []) as string[];\n\n      // Check if only order has changed (same elements but different order)\n      const hasOrderChanged =\n        oldKey.length === newKey.length &&\n        oldKey.length > 0 &&\n        newKey.length > 0 &&\n        oldKey.every((key) => newKey.includes(key)) &&\n        newKey.every((key) => oldKey.includes(key)) &&\n        !oldKey.every((key, index) => key === newKey[index]);\n\n      if (hasOrderChanged && field.getHasOrderColumn()) {\n        // For order changes: clear all existing links and re-establish with correct order\n        const clearFields: Record<string, null> = {\n          [selfKeyName]: null,\n          [`${selfKeyName}_order`]: null,\n        };\n\n        const clearQuery = this.knex(fkHostTableName)\n          .update(clearFields)\n          .where(selfKeyName, recordId)\n          .toQuery();\n        await this.prismaService.txClient().$executeRawUnsafe(clearQuery);\n\n        // Re-establish all links with correct order\n        const dbFields = [\n          { dbFieldName: selfKeyName, schemaType: SchemaType.String },\n          { dbFieldName: `${selfKeyName}_order`, schemaType: SchemaType.Integer },\n        ];\n\n        const updateData = newKey.map((foreignRecordId, index) => {\n          const orderValue = index + 1;\n          return {\n            id: foreignRecordId,\n            values: {\n              [selfKeyName]: recordId,\n              [`${selfKeyName}_order`]: orderValue,\n            },\n          };\n        });\n\n        await this.batchService.batchUpdateDB(\n          fkHostTableName,\n          foreignKeyName,\n          dbFields,\n          updateData\n        );\n      } else {\n        // Handle regular add/remove operations\n        const toDelete = difference(oldKey, newKey);\n\n        // Delete old links\n        if (toDelete.length) {\n          const updateFields: Record<string, null> = { [selfKeyName]: null };\n          // Also clear order column if field has order column\n          if (field.getHasOrderColumn()) {\n            updateFields[`${selfKeyName}_order`] = null;\n          }\n\n          const deleteConditions = toDelete.map((key) => [recordId, key]);\n          const query = this.knex(fkHostTableName)\n            .update(updateFields)\n            .whereIn([selfKeyName, foreignKeyName], deleteConditions)\n            .toQuery();\n          await this.prismaService.txClient().$executeRawUnsafe(query);\n        }\n\n        // Add new links and update order for all current links\n        if (newKey.length > 0) {\n          if (field.getHasOrderColumn()) {\n            // Find truly new links that need to be added\n            const toAdd = difference(newKey, oldKey);\n\n            if (toAdd.length > 0) {\n              // Get the current maximum order value for this target record\n              const currentMaxOrder = await this.getMaxOrderForTarget(\n                fkHostTableName,\n                selfKeyName,\n                recordId,\n                field.getOrderColumnName()\n              );\n\n              // Add new links with correct incremental order values\n              const orderColumnName = field.getOrderColumnName();\n              const dbFields = [\n                { dbFieldName: selfKeyName, schemaType: SchemaType.String },\n                { dbFieldName: orderColumnName, schemaType: SchemaType.Integer },\n              ];\n\n              const addData = toAdd.map((foreignRecordId, index) => ({\n                id: foreignRecordId,\n                values: {\n                  [selfKeyName]: recordId,\n                  [orderColumnName]: currentMaxOrder + index + 1,\n                },\n              }));\n\n              await this.batchService.batchUpdateDB(\n                fkHostTableName,\n                foreignKeyName,\n                dbFields,\n                addData\n              );\n            }\n          } else {\n            // One-many without an order column stores the FK directly on the foreign table.\n            // Only update rows where the foreign key actually changes.\n            const toAdd = difference(newKey, oldKey);\n\n            if (toAdd.length > 0) {\n              const dbFields = [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }];\n\n              const addData = toAdd.map((foreignRecordId) => ({\n                id: foreignRecordId,\n                values: {\n                  [selfKeyName]: recordId,\n                },\n              }));\n\n              await this.batchService.batchUpdateDB(\n                fkHostTableName,\n                foreignKeyName,\n                dbFields,\n                addData\n              );\n            }\n          }\n        }\n      }\n    }\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async saveForeignKeyForOneOne(\n    field: LinkFieldDto,\n    fkMap: { [recordId: string]: IFkRecordItem }\n  ) {\n    const { selfKeyName, foreignKeyName, fkHostTableName } = field.options;\n    if (selfKeyName === '__id') {\n      await this.saveForeignKeyForManyOne(field, fkMap);\n    } else {\n      const toDelete: [string, string][] = [];\n      const toAdd: [string, string][] = [];\n      for (const recordId in fkMap) {\n        const fkItem = fkMap[recordId];\n        const oldKey = (fkItem.oldKey || []) as string[];\n        const newKey = fkItem.newKey as string | null;\n\n        oldKey && oldKey.forEach((key) => toDelete.push([recordId, key]));\n        newKey && toAdd.push([recordId, newKey]);\n      }\n\n      if (toDelete.length) {\n        const updateFields: Record<string, null> = { [selfKeyName]: null };\n        // Also clear order column if field has order column\n        if (field.getHasOrderColumn()) {\n          updateFields[`${selfKeyName}_order`] = null;\n        }\n\n        const query = this.knex(fkHostTableName)\n          .update(updateFields)\n          .whereIn([selfKeyName, foreignKeyName], toDelete)\n          .toQuery();\n        await this.prismaService.txClient().$executeRawUnsafe(query);\n      }\n\n      if (toAdd.length) {\n        const dbFields = [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }];\n        // Add order column if field has order column\n        if (field.getHasOrderColumn()) {\n          dbFields.push({ dbFieldName: `${selfKeyName}_order`, schemaType: SchemaType.Integer });\n        }\n\n        await this.batchService.batchUpdateDB(\n          fkHostTableName,\n          foreignKeyName,\n          dbFields,\n          toAdd.map(([recordId, foreignRecordId]) => {\n            const values: Record<string, unknown> = { [selfKeyName]: recordId };\n            // For OneOne relationship, order is always 1 since each record can only link to one target\n            if (field.getHasOrderColumn()) {\n              values[`${selfKeyName}_order`] = 1;\n            }\n            return {\n              id: foreignRecordId,\n              values,\n            };\n          })\n        );\n      }\n    }\n  }\n\n  private async saveForeignKeyToDb(fieldMap: IFieldMap, fkRecordMap: IFkRecordMap) {\n    for (const fieldId in fkRecordMap) {\n      const fkMap = fkRecordMap[fieldId];\n      const field = fieldMap[fieldId] as LinkFieldDto;\n      const relationship = field.options.relationship;\n      if (relationship === Relationship.ManyMany) {\n        await this.saveForeignKeyForManyMany(field, fkMap);\n      }\n      if (relationship === Relationship.ManyOne) {\n        await this.saveForeignKeyForManyOne(field, fkMap);\n      }\n      if (relationship === Relationship.OneMany) {\n        await this.saveForeignKeyForOneMany(field, fkMap);\n      }\n      if (relationship === Relationship.OneOne) {\n        await this.saveForeignKeyForOneOne(field, fkMap);\n      }\n    }\n  }\n\n  /**\n   * strategy\n   * 0: define `main table` is where foreign key located in, `foreign table` is where foreign key referenced to\n   * 1. generate foreign key changes, cache effected recordIds, both main table and foreign table\n   * 2. update foreign key by changes and submit origin op\n   * 3. check and generate op to update main table by cached recordIds\n   * 4. check and generate op to update foreign table by cached recordIds\n   */\n  async getDerivateByLink(\n    tableId: string,\n    cellContexts: ICellContext[],\n    fromReset?: boolean,\n    projectionByTable?: Record<string, string[]>\n  ) {\n    const linkLikeContexts = this.filterLinkContext(cellContexts as ILinkCellContext[]);\n    if (!linkLikeContexts.length) {\n      return;\n    }\n    const fieldIds = linkLikeContexts.map((ctx) => ctx.fieldId);\n    const fieldMapByTableId = await this.getRelatedFieldMap(fieldIds);\n    const fieldMap = fieldMapByTableId[tableId];\n    const linkContexts = linkLikeContexts.filter((ctx) => {\n      if (!fieldMap[ctx.fieldId]) {\n        return false;\n      }\n      if (fieldMap[ctx.fieldId].type !== FieldType.Link || fieldMap[ctx.fieldId].isLookup) {\n        return false;\n      }\n      return true;\n    });\n\n    const tableId2DbTableName = await this.getTableId2DbTableName(Object.keys(fieldMapByTableId));\n\n    return this.getDerivateByCellContexts(\n      tableId,\n      tableId2DbTableName,\n      fieldMapByTableId,\n      linkContexts,\n      cellContexts,\n      projectionByTable,\n      fromReset,\n      true\n    );\n  }\n\n  /**\n   * Plan link derivations without persisting foreign keys.\n   * Returns the same derivation structure as getDerivateByLink but does NOT\n   * call saveForeignKeyToDb. Useful when consumers need to capture old values\n   * for computed events before the FK writes are visible in the same tx.\n   */\n  @Timing()\n  async planDerivateByLink(\n    tableId: string,\n    cellContexts: ICellContext[],\n    fromReset?: boolean,\n    tables?: Map<string, TableDomain>,\n    projectionByTable?: Record<string, string[]>\n  ): Promise<{ cellChanges: ICellChange[]; fkRecordMap: IFkRecordMap } | undefined> {\n    const linkLikeContexts = this.filterLinkContext(cellContexts as ILinkCellContext[]);\n    if (!linkLikeContexts.length) {\n      return undefined;\n    }\n    const fieldIds = linkLikeContexts.map((ctx) => ctx.fieldId);\n    const fieldMapByTableId =\n      this.buildFieldMapFromTables(fieldIds, tables) ?? (await this.getRelatedFieldMap(fieldIds));\n    const fieldMap = fieldMapByTableId[tableId];\n    const linkContexts = linkLikeContexts.filter((ctx) => {\n      if (!fieldMap[ctx.fieldId]) {\n        return false;\n      }\n      if (fieldMap[ctx.fieldId].type !== FieldType.Link || fieldMap[ctx.fieldId].isLookup) {\n        return false;\n      }\n      return true;\n    });\n\n    const tableId2DbTableName =\n      this.buildTableId2DbTableNameFromTables(Object.keys(fieldMapByTableId), tables) ??\n      (await this.getTableId2DbTableName(Object.keys(fieldMapByTableId)));\n\n    const derivate = await this.getDerivateByCellContexts(\n      tableId,\n      tableId2DbTableName,\n      fieldMapByTableId,\n      linkContexts,\n      cellContexts,\n      projectionByTable,\n      fromReset,\n      false\n    );\n\n    return derivate as { cellChanges: ICellChange[]; fkRecordMap: IFkRecordMap };\n  }\n\n  /**\n   * Persist foreign key changes previously planned via planDerivateByLink.\n   * Rebuilds the necessary field map and writes junction table updates.\n   */\n  async commitForeignKeyChanges(\n    tableId: string,\n    fkRecordMap?: IFkRecordMap,\n    tables?: Map<string, TableDomain>\n  ): Promise<void> {\n    if (!fkRecordMap || !Object.keys(fkRecordMap).length) return;\n    const fieldIds = Object.keys(fkRecordMap);\n    const fieldMapByTableId =\n      this.buildFieldMapFromTables(fieldIds, tables) ?? (await this.getRelatedFieldMap(fieldIds));\n    const fieldMap = fieldMapByTableId[tableId];\n    await this.saveForeignKeyToDb(fieldMap, fkRecordMap);\n  }\n\n  private parseFkRecordItemToDelete(\n    options: ILinkFieldOptions,\n    toDeleteRecordIds: string[],\n    foreignKeys: {\n      id: string;\n      foreignId: string;\n    }[]\n  ): Record<string, IFkRecordItem> {\n    const relationship = options.relationship;\n    const foreignKeysIndexed = groupBy(foreignKeys, 'id');\n    const toDeleteSet = new Set(toDeleteRecordIds);\n\n    return Object.keys(foreignKeysIndexed).reduce<IFkRecordMap['fieldId']>((acc, id) => {\n      // this two relations only have one key in one recordId\n      const foreignKeys = foreignKeysIndexed[id];\n      if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {\n        if ((foreignKeys?.length ?? 0) > 1) {\n          throw new CustomHttpException(`Foreign key duplicate`, HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.field.foreignKeyDuplicate',\n            },\n          });\n        }\n\n        const foreignRecordId = foreignKeys?.[0].foreignId;\n        const oldKey = foreignRecordId || null;\n        if (!toDeleteSet.has(foreignRecordId)) {\n          return acc;\n        }\n\n        acc[id] = { oldKey, newKey: null };\n        return acc;\n      }\n\n      if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) {\n        const oldKey = foreignKeys?.map((key) => key.foreignId) ?? null;\n        if (!oldKey) {\n          return acc;\n        }\n\n        const newKey = oldKey.filter((key) => !toDeleteSet.has(key));\n\n        if (newKey.length === oldKey.length) {\n          return acc;\n        }\n\n        acc[id] = {\n          oldKey,\n          newKey: newKey.length ? newKey : null,\n        };\n        return acc;\n      }\n      return acc;\n    }, {});\n  }\n\n  /**\n   * Build cell contexts for record deletion.\n   * @param tableId - The table being deleted from\n   * @param relatedLinkFieldRaws - Link fields from OTHER tables that reference the current table\n   * @param currentTableLinkFields - Link fields belonging to the current table itself\n   * @param records - Records being deleted\n   */\n  private async getContextByDelete(\n    tableId: string,\n    relatedLinkFieldRaws: Field[],\n    currentTableLinkFields: Field[],\n    records: IRecord[]\n  ) {\n    const cellContextsMap: { [tableId: string]: ICellContext[] } = {};\n    const recordIds = records.map((record) => record.id);\n\n    const keyToValue = (key: string | string[] | null) =>\n      key ? (Array.isArray(key) ? key.map((id) => ({ id })) : { id: key }) : null;\n\n    // Process link fields from OTHER tables that reference the current table\n    for (const fieldRaws of relatedLinkFieldRaws) {\n      const options = JSON.parse(fieldRaws.options as string) as ILinkFieldOptions;\n      const fieldTableId = fieldRaws.tableId;\n      const foreignKeys = await this.getJoinedForeignKeys(recordIds, options);\n      const fieldItems = this.parseFkRecordItemToDelete(options, recordIds, foreignKeys);\n      if (!cellContextsMap[fieldTableId]) {\n        cellContextsMap[fieldTableId] = [];\n      }\n      Object.keys(fieldItems).forEach((recordId) => {\n        const { oldKey, newKey } = fieldItems[recordId];\n        cellContextsMap[fieldTableId].push({\n          fieldId: fieldRaws.id,\n          recordId,\n          oldValue: keyToValue(oldKey),\n          newValue: keyToValue(newKey),\n        });\n      });\n    }\n\n    // Process link fields belonging to the current table itself\n    // Query junction tables directly to handle cases where record.fields has null values\n    // but junction table still has data (data inconsistency)\n    for (const linkField of currentTableLinkFields) {\n      const options = JSON.parse(linkField.options as string) as ILinkFieldOptions;\n      const foreignKeys = await this.getDirectForeignKeys(recordIds, options);\n\n      if (foreignKeys.length > 0) {\n        if (!cellContextsMap[tableId]) {\n          cellContextsMap[tableId] = [];\n        }\n\n        // Group foreign keys by record id\n        const fkByRecordId = groupBy(foreignKeys, 'id');\n\n        for (const recordId of Object.keys(fkByRecordId)) {\n          const fks = fkByRecordId[recordId];\n          const oldValue = fks.map((fk) => ({ id: fk.foreignId }));\n\n          cellContextsMap[tableId].push({\n            fieldId: linkField.id,\n            recordId,\n            oldValue: oldValue.length === 1 ? oldValue[0] : oldValue,\n            newValue: null,\n          });\n        }\n      }\n    }\n\n    return cellContextsMap;\n  }\n\n  /**\n   * Get foreign keys from junction table where selfKeyName matches the given record IDs.\n   * This is used for cleaning up junction table data when deleting records from the source table.\n   */\n  private async getDirectForeignKeys(\n    recordIds: string[],\n    options: ILinkFieldOptions\n  ): Promise<{ id: string; foreignId: string }[]> {\n    const { fkHostTableName, selfKeyName, foreignKeyName } = options;\n\n    const query = this.knex(fkHostTableName)\n      .select({\n        id: selfKeyName,\n        foreignId: foreignKeyName,\n      })\n      .whereIn(selfKeyName, recordIds)\n      .whereNotNull(selfKeyName)\n      .whereNotNull(foreignKeyName)\n      .toQuery();\n\n    return this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query);\n  }\n\n  async getRelatedLinkFieldRaws(tableId: string) {\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { id: true },\n    });\n\n    const references = await this.prismaService.txClient().reference.findMany({\n      where: { fromFieldId: { in: fieldRaws.map((f) => f.id) } },\n      select: { toFieldId: true },\n    });\n\n    const referenceFieldIds = references.map((ref) => ref.toFieldId);\n\n    const relatedFieldsByReference = referenceFieldIds.length\n      ? await this.prismaService.txClient().field.findMany({\n          where: {\n            id: { in: referenceFieldIds },\n            type: FieldType.Link,\n            isLookup: null,\n            deletedTime: null,\n          },\n        })\n      : [];\n\n    // Fallback: reference graph might be missing for legacy data, so look for link fields whose\n    // options still point to this table as their foreign target.\n    const knownFieldIds = new Set(relatedFieldsByReference.map((field) => field.id));\n\n    const foreignTableSql = this.dbProvider.optionsQuery(FieldType.Link, 'foreignTableId', tableId);\n    const relatedFieldsByForeignTable = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Field[]>(foreignTableSql);\n\n    const merged = new Map<string, Field>();\n    relatedFieldsByReference.forEach((field) => merged.set(field.id, field));\n    relatedFieldsByForeignTable\n      .filter((field) => !knownFieldIds.has(field.id))\n      .forEach((field) => merged.set(field.id, field));\n\n    return Array.from(merged.values());\n  }\n\n  async getDeleteRecordUpdateContext(tableId: string, records: IRecord[]) {\n    // Get link fields from OTHER tables that reference the current table\n    const relatedLinkFieldRaws = await this.getRelatedLinkFieldRaws(tableId);\n\n    // Get link fields belonging to the current table itself\n    const currentTableLinkFields = await this.prismaService.txClient().field.findMany({\n      where: {\n        tableId,\n        type: FieldType.Link,\n        isLookup: null,\n        deletedTime: null,\n      },\n    });\n\n    return await this.getContextByDelete(\n      tableId,\n      relatedLinkFieldRaws,\n      currentTableLinkFields,\n      records\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/reference.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { IRecord, Relationship } from '@teable/core';\nimport { extractFieldIdsFromFilter } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { difference, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IFieldInstance, IFieldMap } from '../field/model/factory';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport { filterDirectedGraph } from './utils/dfs';\n\n// topo item is for field level reference, all id stands for fieldId;\nexport interface ITopoItem {\n  id: string;\n  dependencies: string[];\n}\n\nexport interface ITopoItemWithRecords extends ITopoItem {\n  recordItemMap?: Record<string, IRecordItem>;\n}\n\nexport interface IGraphItem {\n  fromFieldId: string;\n  toFieldId: string;\n}\n\nexport interface IRecordMap {\n  [recordId: string]: IRecord;\n}\n\nexport interface IRecordItem {\n  record: IRecord;\n  dependencies?: IRecord[];\n}\n\nexport interface IRecordData {\n  id: string;\n  fieldId: string;\n  oldValue?: unknown;\n  newValue: unknown;\n}\n\nexport interface IRelatedRecordItem {\n  toId: string;\n  fromId?: string;\n}\n\nexport interface ITopoLinkOrder {\n  fieldId: string;\n  relationship: Relationship;\n  fkHostTableName: string;\n  selfKeyName: string;\n  foreignKeyName: string;\n}\n\n@Injectable()\nexport class ReferenceService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  private async getLookupFilterFieldMap(fieldMap: IFieldMap) {\n    const fieldIds = Object.keys(fieldMap)\n      .map((fieldId) => {\n        const field = fieldMap[fieldId];\n        if (!field) {\n          return [];\n        }\n        const lookupOptions = field.lookupOptions;\n        if (lookupOptions && lookupOptions.filter) {\n          return extractFieldIdsFromFilter(lookupOptions.filter, true);\n        }\n        return [];\n      })\n      .flat();\n\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fieldIds }, deletedTime: null },\n    });\n\n    return fieldRaws.reduce<{ [fieldId: string]: IFieldInstance }>((pre, f) => {\n      pre[f.id] = createFieldInstanceByRaw(f);\n      return pre;\n    }, {});\n  }\n\n  async createAuxiliaryData(allFieldIds: string[]) {\n    const prisma = this.prismaService.txClient();\n    const fieldRaws = await prisma.field.findMany({\n      where: { id: { in: allFieldIds }, deletedTime: null },\n    });\n\n    // if a field that has been looked up  has changed, the link field should be retrieved as context\n    const extraLinkFieldIds = difference(\n      fieldRaws\n        .filter((field) => field.lookupLinkedFieldId)\n        .map((field) => field.lookupLinkedFieldId as string),\n      allFieldIds\n    );\n\n    const extraLinkFieldRaws = await prisma.field.findMany({\n      where: { id: { in: extraLinkFieldIds }, deletedTime: null },\n    });\n\n    fieldRaws.push(...extraLinkFieldRaws);\n\n    const fieldId2TableId = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {\n      pre[f.id] = f.tableId;\n      return pre;\n    }, {});\n\n    const tableIds = uniq(Object.values(fieldId2TableId));\n    const tableMeta = await prisma.tableMeta.findMany({\n      where: { id: { in: tableIds } },\n      select: { id: true, dbTableName: true },\n    });\n\n    const tableId2DbTableName = tableMeta.reduce<{ [tableId: string]: string }>((pre, t) => {\n      pre[t.id] = t.dbTableName;\n      return pre;\n    }, {});\n\n    const fieldMap = fieldRaws.reduce<IFieldMap>((pre, f) => {\n      pre[f.id] = createFieldInstanceByRaw(f);\n      return pre;\n    }, {});\n\n    const lookupFilterFieldMap = await this.getLookupFilterFieldMap(fieldMap);\n\n    const dbTableName2fields = fieldRaws.reduce<{ [fieldId: string]: IFieldInstance[] }>(\n      (pre, f) => {\n        const dbTableName = tableId2DbTableName[f.tableId];\n        if (pre[dbTableName]) {\n          pre[dbTableName].push(fieldMap[f.id]);\n        } else {\n          pre[dbTableName] = [fieldMap[f.id]];\n        }\n        return pre;\n      },\n      {}\n    );\n\n    const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => {\n      pre[f.id] = tableId2DbTableName[f.tableId];\n      return pre;\n    }, {});\n\n    return {\n      fieldMap: { ...fieldMap, ...lookupFilterFieldMap },\n      fieldId2TableId,\n      fieldId2DbTableName,\n      dbTableName2fields,\n      tableId2DbTableName,\n    };\n  }\n\n  private getQueryColumnName(field: IFieldInstance): string {\n    return field.dbFieldName;\n  }\n\n  recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }): IRecord {\n    const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {\n      const queryColumnName = this.getQueryColumnName(field);\n      const cellValue = field.convertDBValue2CellValue(raw[queryColumnName] as string);\n      acc[field.id] = cellValue;\n      return acc;\n    }, {});\n\n    return {\n      fields: fieldsData,\n      id: raw.__id as string,\n      autoNumber: raw.__auto_number as number,\n      createdTime: (raw.__created_time as Date)?.toISOString(),\n      lastModifiedTime: (raw.__last_modified_time as Date)?.toISOString(),\n      createdBy: raw.__created_by as string,\n      lastModifiedBy: raw.__last_modified_by as string,\n    };\n  }\n\n  async getFieldGraphItems(startFieldIds: string[]): Promise<IGraphItem[]> {\n    const getResult = async (startFieldIds: string[]) => {\n      const _knex = this.knex;\n\n      const nonRecursiveQuery = _knex\n        .select('from_field_id', 'to_field_id')\n        .from('reference')\n        .whereIn('from_field_id', startFieldIds)\n        .orWhereIn('to_field_id', startFieldIds);\n      const recursiveQuery = _knex\n        .select('deps.from_field_id', 'deps.to_field_id')\n        .from('reference as deps')\n        .join('connected_reference as cd', function () {\n          const sql = '?? = ?? AND ?? != ??';\n          const depsFromField = 'deps.from_field_id';\n          const depsToField = 'deps.to_field_id';\n          const cdFromField = 'cd.from_field_id';\n          const cdToField = 'cd.to_field_id';\n          this.on(\n            _knex.raw(sql, [depsFromField, cdFromField, depsToField, cdToField]).wrap('(', ')')\n          );\n          this.orOn(\n            _knex.raw(sql, [depsFromField, cdToField, depsToField, cdFromField]).wrap('(', ')')\n          );\n          this.orOn(\n            _knex.raw(sql, [depsToField, cdFromField, depsFromField, cdToField]).wrap('(', ')')\n          );\n          this.orOn(\n            _knex.raw(sql, [depsToField, cdToField, depsFromField, cdFromField]).wrap('(', ')')\n          );\n        });\n      const cteQuery = nonRecursiveQuery.union(recursiveQuery);\n      const finalQuery = this.knex\n        .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery)\n        .distinct('from_field_id', 'to_field_id')\n        .from('connected_reference')\n        .toQuery();\n\n      return (\n        this.prismaService\n          .txClient()\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery)\n      );\n    };\n\n    const queryResult = await getResult(startFieldIds);\n\n    return filterDirectedGraph(\n      queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })),\n      startFieldIds\n    );\n  }\n\n  flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) {\n    const allNodes = new Set<string>();\n    for (const edge of graph) {\n      allNodes.add(edge.fromFieldId);\n      allNodes.add(edge.toFieldId);\n    }\n    return Array.from(allNodes);\n  }\n\n  /**\n   * Given a list of fieldIds, return unique tableIds related by Reference graph.\n   * The result includes the tables of the start fields and all connected fields\n   * discovered through the reference relationships (transitively), de-duplicated.\n   */\n  async getRelatedTableIdsByFieldIds(startFieldIds: string[]): Promise<string[]> {\n    if (!startFieldIds.length) return [];\n\n    const visitedFieldIds = new Set<string>();\n    const queue: string[] = [...startFieldIds];\n    const tableIds = new Set<string>();\n\n    // Prime map for initial fields → tableId\n    const initialFields = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: startFieldIds }, deletedTime: null },\n      select: { id: true, tableId: true },\n    });\n    for (const f of initialFields) {\n      tableIds.add(f.tableId);\n    }\n\n    while (queue.length) {\n      const fid = queue.shift()!;\n      if (visitedFieldIds.has(fid)) continue;\n      visitedFieldIds.add(fid);\n\n      // 1) Fields (lookup/rollup) whose lookupOptions.lookupFieldId === fid\n      const q1 = this.dbProvider.lookupOptionsQuery('lookupFieldId', fid);\n      const deps1 = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ tableId: string; id: string }[]>(q1);\n      for (const row of deps1) {\n        tableIds.add(row.tableId);\n        queue.push(row.id);\n      }\n\n      // 2) Fields (lookup/rollup) attached to a link: lookupOptions.linkFieldId === fid\n      const q2 = this.dbProvider.lookupOptionsQuery('linkFieldId', fid);\n      const deps2 = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ tableId: string; id: string }[]>(q2);\n      for (const row of deps2) {\n        tableIds.add(row.tableId);\n        queue.push(row.id);\n      }\n    }\n\n    return Array.from(tableIds);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/system-field.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable } from '@nestjs/common';\nimport type { LastModifiedByFieldCore, LastModifiedTimeFieldCore } from '@teable/core';\nimport { FieldKeyType, TableDomain, FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { Timing } from '../../utils/timing';\nimport { UserFieldDto } from '../field/model/field-dto/user-field.dto';\n\n@Injectable()\nexport class SystemFieldService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private async updateSystemField(\n    dbTableName: string,\n    recordIds: string[],\n    userId: string,\n    timeStr: string\n  ) {\n    if (!recordIds.length) return;\n\n    const nativeQuery = this.knex(dbTableName)\n      .update({\n        __last_modified_time: timeStr,\n        __last_modified_by: userId,\n      })\n      .whereIn('__id', recordIds)\n      .toQuery();\n\n    await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);\n  }\n\n  @Timing()\n  async getModifiedSystemOpsMap(\n    table: TableDomain,\n    fieldKeyType: FieldKeyType,\n    records: {\n      fields: Record<string, unknown>;\n      id: string;\n    }[]\n  ): Promise<\n    {\n      fields: Record<string, unknown>;\n      id: string;\n    }[]\n  > {\n    const user = this.cls.get('user');\n    const timeStr = this.cls.get('tx.timeStr') ?? new Date().toISOString();\n    const auditUserValue =\n      user &&\n      UserFieldDto.fullAvatarUrl({\n        id: user.id,\n        title: user.name,\n        email: user.email,\n      });\n    const cloneAuditUserValue = () => (auditUserValue ? { ...auditUserValue } : null);\n    const sanitizeAuditUserValue = () => {\n      const cloned = cloneAuditUserValue();\n      if (cloned && typeof cloned === 'object' && 'avatarUrl' in cloned) {\n        delete (cloned as { avatarUrl?: string }).avatarUrl;\n      }\n      return cloned;\n    };\n\n    const dbTableName = table.dbTableName;\n    const trackedLastModifiedColumnUpdates: Record<string, string[]> = {};\n    const trackedLastModifiedByColumnUpdates: Record<string, string[]> = {};\n\n    await this.updateSystemField(\n      dbTableName,\n      records.map((r) => r.id),\n      user.id,\n      timeStr\n    );\n\n    const lastModifiedFields = table.getLastModifiedFields();\n\n    if (!lastModifiedFields.length) return records;\n\n    const fieldsMap = table.getFieldsMap(fieldKeyType);\n\n    const updatedRecords = records.map((record) => {\n      const changedFieldIds = new Set<string>();\n      for (const key of Object.keys(record.fields ?? {})) {\n        const changedField = fieldsMap.get(key);\n        if (changedField) changedFieldIds.add(changedField.id);\n      }\n\n      const systemRecordFields = lastModifiedFields.reduce<{ [fieldId: string]: unknown }>(\n        (pre, field) => {\n          const type = field.type;\n          if (type === FieldType.LastModifiedTime) {\n            const lmtField = field as LastModifiedTimeFieldCore;\n            const trackedIds = lmtField.getTrackedFieldIds();\n            const validTrackedIds = trackedIds.filter((id) => table.hasField(id));\n            const configTrackAll = lmtField.isTrackAll();\n            const effectiveTrackAll = configTrackAll || validTrackedIds.length === 0;\n            const shouldUpdate =\n              effectiveTrackAll || validTrackedIds.some((id) => changedFieldIds.has(id));\n            if (shouldUpdate) {\n              pre[field[fieldKeyType]] = timeStr;\n              // Persist column when not using generated/system value\n              if (!configTrackAll) {\n                const ids = trackedLastModifiedColumnUpdates[field.dbFieldName] || [];\n                ids.push(record.id);\n                trackedLastModifiedColumnUpdates[field.dbFieldName] = ids;\n              }\n            }\n          }\n\n          if (type === FieldType.LastModifiedBy) {\n            const lmbField = field as LastModifiedByFieldCore;\n            const trackedIds = lmbField.getTrackedFieldIds();\n            const validTrackedIds = trackedIds.filter((id) => table.hasField(id));\n            const configTrackAll = lmbField.isTrackAll();\n            const effectiveTrackAll = configTrackAll || validTrackedIds.length === 0;\n            const shouldUpdate =\n              effectiveTrackAll || validTrackedIds.some((id) => changedFieldIds.has(id));\n            if (shouldUpdate) {\n              const value = sanitizeAuditUserValue();\n              pre[field[fieldKeyType]] = value;\n              // Persist column when not using system column\n              if (!configTrackAll) {\n                const ids = trackedLastModifiedByColumnUpdates[field.dbFieldName] || [];\n                ids.push(record.id);\n                trackedLastModifiedByColumnUpdates[field.dbFieldName] = ids;\n              }\n            }\n          }\n          return pre;\n        },\n        {}\n      );\n\n      return {\n        ...record,\n        fields: {\n          ...record.fields,\n          ...systemRecordFields,\n        },\n      };\n    });\n\n    // Persist tracked Last Modified Time columns that are not generated\n    for (const [columnName, recordIds] of Object.entries(trackedLastModifiedColumnUpdates)) {\n      const nativeQuery = this.knex(dbTableName)\n        .update({\n          [columnName]: timeStr,\n        })\n        .whereIn('__id', recordIds)\n        .toQuery();\n      await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);\n    }\n\n    // Persist tracked Last Modified By columns that are not generated from the system column\n    if (Object.keys(trackedLastModifiedByColumnUpdates).length) {\n      const persistedUserValue = sanitizeAuditUserValue();\n      const serializedUserValue = persistedUserValue ? JSON.stringify(persistedUserValue) : null;\n      for (const [columnName, recordIds] of Object.entries(trackedLastModifiedByColumnUpdates)) {\n        const nativeQuery = this.knex(dbTableName)\n          .update({\n            [columnName]: serializedUserValue,\n          })\n          .whereIn('__id', recordIds)\n          .toQuery();\n        await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);\n      }\n    }\n\n    return updatedRecords;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/changes.spec.ts",
    "content": "import { RecordOpBuilder } from '@teable/core';\nimport { changeToOp, formatChangesToOps, mergeDuplicateChange } from './changes'; // Change './yourFile' to the correct path.\n\ndescribe('changeToOp', () => {\n  it('should create an operation from a cell change', () => {\n    const change = {\n      tableId: 't1',\n      recordId: 'r1',\n      fieldId: 'f1',\n      oldValue: 'A',\n      newValue: 'B',\n    };\n\n    const result = changeToOp(change);\n\n    const expected = RecordOpBuilder.editor.setRecord.build({\n      fieldId: 'f1',\n      oldCellValue: 'A',\n      newCellValue: 'B',\n    });\n\n    expect(result).toEqual(expected);\n  });\n});\n\ndescribe('formatChangesToOps', () => {\n  it('should format multiple changes into operations', () => {\n    const changes = [\n      {\n        tableId: 't1',\n        recordId: 'r1',\n        fieldId: 'f1',\n        oldValue: 'A',\n        newValue: 'B',\n      },\n      {\n        tableId: 't1',\n        recordId: 'r1',\n        fieldId: 'f2',\n        oldValue: 'X',\n        newValue: 'Y',\n      },\n    ];\n\n    const result = formatChangesToOps(changes);\n\n    expect(result).toEqual({\n      t1: {\n        r1: [\n          RecordOpBuilder.editor.setRecord.build({\n            fieldId: 'f1',\n            oldCellValue: 'A',\n            newCellValue: 'B',\n          }),\n          RecordOpBuilder.editor.setRecord.build({\n            fieldId: 'f2',\n            oldCellValue: 'X',\n            newCellValue: 'Y',\n          }),\n        ],\n      },\n    });\n  });\n});\n\ndescribe('mergeDuplicateChange', () => {\n  it('should merge duplicate changes', () => {\n    const changes = [\n      {\n        tableId: 't1',\n        recordId: 'r1',\n        fieldId: 'f1',\n        oldValue: 'A',\n        newValue: 'C',\n      },\n      {\n        tableId: 't1',\n        recordId: 'r1',\n        fieldId: 'f1',\n        oldValue: 'A',\n        newValue: 'D',\n      },\n      {\n        tableId: 't2',\n        recordId: 'r2',\n        fieldId: 'f2',\n        oldValue: 'B',\n        newValue: 'D',\n      },\n    ];\n\n    const result = mergeDuplicateChange(changes);\n\n    expect(result).toEqual([\n      {\n        tableId: 't1',\n        recordId: 'r1',\n        fieldId: 'f1',\n        oldValue: 'A',\n        newValue: 'D',\n      },\n      {\n        tableId: 't2',\n        recordId: 'r2',\n        fieldId: 'f2',\n        oldValue: 'B',\n        newValue: 'D',\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/changes.ts",
    "content": "import type { IOtOperation } from '@teable/core';\nimport { RecordOpBuilder } from '@teable/core';\n\nexport interface ICellChange {\n  tableId: string;\n  recordId: string;\n  fieldId: string;\n  oldValue: unknown;\n  newValue: unknown;\n}\n\nexport interface ICellContext {\n  recordId: string;\n  fieldId: string;\n  newValue?: unknown;\n  oldValue?: unknown;\n}\n\nexport function changeToOp(change: ICellChange) {\n  const { fieldId, oldValue, newValue } = change;\n  return RecordOpBuilder.editor.setRecord.build({\n    fieldId,\n    oldCellValue: oldValue,\n    newCellValue: newValue,\n  });\n}\n\nexport function formatChangesToOps(changes: ICellChange[]) {\n  return changes.reduce<{\n    [tableId: string]: { [recordId: string]: IOtOperation[] };\n  }>((pre, cur) => {\n    const { tableId: curTableId, recordId: curRecordId } = cur;\n    const op = changeToOp(cur);\n\n    if (!pre[curTableId]) {\n      pre[curTableId] = {};\n    }\n    if (!pre[curTableId][curRecordId]) {\n      pre[curTableId][curRecordId] = [];\n    }\n    pre[curTableId][curRecordId].push(op);\n\n    return pre;\n  }, {});\n}\n\n/**\n * when update multi field in a record, there may be duplicate change.\n * see this case, A and B update at the same time\n * A -> C -> E\n * A -> D -> E\n * B -> D -> E\n * D will be calculated twice\n * E will be calculated twice\n * so we need to merge duplicate change to reduce update times\n */\nexport function mergeDuplicateChange(changes: ICellChange[]) {\n  const indexCache: { [key: string]: number } = {};\n  const mergedChanges: ICellChange[] = [];\n\n  for (const change of changes) {\n    const key = `${change.tableId}#${change.fieldId}#${change.recordId}`;\n    if (indexCache[key] !== undefined) {\n      mergedChanges[indexCache[key]].newValue = change.newValue;\n    } else {\n      indexCache[key] = mergedChanges.length;\n      mergedChanges.push(change);\n    }\n  }\n  return mergedChanges;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/compose-maps.spec.ts",
    "content": "import { composeOpMaps } from './compose-maps';\ndescribe('composeMaps', () => {\n  it('should return an empty object when no maps are provided', () => {\n    expect(composeOpMaps([])).toEqual({});\n  });\n\n  it('should merge maps without overlapping keys correctly', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a' }],\n      },\n    };\n    const map2 = {\n      table2: {\n        record2: [{ p: [2], oi: 'b' }],\n      },\n    };\n\n    const expected = {\n      table1: {\n        record1: [{ p: [1], oi: 'a' }],\n      },\n      table2: {\n        record2: [{ p: [2], oi: 'b' }],\n      },\n    };\n    expect(composeOpMaps([map1, map2])).toEqual(expected);\n  });\n\n  it('should overwrite operations with the same \"p\" value in the same record', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a', od: 'x' }],\n      },\n    };\n    const map2 = {\n      table1: {\n        record1: [{ p: [1], oi: 'b', od: 'a' }],\n      },\n    };\n    const expected = {\n      table1: {\n        record1: [{ p: [1], oi: 'b', od: 'x' }],\n      },\n    };\n    expect(composeOpMaps([map1, map2])).toEqual(expected);\n  });\n\n  it('should filter operations with the same oi od in 1 map', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a', od: 'a' }],\n      },\n    };\n    const expected = {};\n    expect(composeOpMaps([map1])).toEqual(expected);\n  });\n\n  it('should filter operations with the same oi od in 2 map', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a', od: 'x' }],\n      },\n    };\n    const map2 = {\n      table1: {\n        record1: [{ p: [1], oi: 'x', od: 'a' }],\n      },\n    };\n    const expected = {};\n    expect(composeOpMaps([map1, map2])).toEqual(expected);\n  });\n\n  it('should overwrite 3 operations with the same \"p\" value in the same record', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a' }],\n      },\n    };\n    const map2 = {\n      table1: {\n        record1: [{ p: [1], oi: 'b' }],\n      },\n    };\n    const map3 = {\n      table1: {\n        record1: [{ p: [1], oi: 'c' }],\n      },\n    };\n    const expected = {\n      table1: {\n        record1: [{ p: [1], oi: 'c' }],\n      },\n    };\n    expect(composeOpMaps([map1, map2, map3])).toEqual(expected);\n  });\n\n  it('should append operations with different \"p\" values in the same record', () => {\n    const map1 = {\n      table1: {\n        record1: [{ p: [1], oi: 'a' }],\n      },\n    };\n    const map2 = {\n      table1: {\n        record1: [{ p: [2], oi: 'b' }],\n      },\n    };\n    const expected = {\n      table1: {\n        record1: [\n          { p: [1], oi: 'a' },\n          { p: [2], oi: 'b' },\n        ],\n      },\n    };\n    expect(composeOpMaps([map1, map2])).toEqual(expected);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/compose-maps.ts",
    "content": "import type { IOtOperation } from '@teable/core';\nimport { isEmpty, isEqual } from 'lodash';\nexport interface IOpsMap {\n  [tableId: string]: {\n    [keyId: string]: IOtOperation[];\n  };\n}\n\nexport function composeOpMaps(opsMaps: (IOpsMap | undefined)[]): IOpsMap {\n  return (opsMaps as IOpsMap[]).filter(Boolean).reduce<IOpsMap>((composedMap, currentMap) => {\n    Object.keys(currentMap).forEach((tableId) => {\n      composedMap[tableId] = composedMap[tableId] || {};\n\n      Object.keys(currentMap[tableId]).forEach((recordId) => {\n        composedMap[tableId][recordId] = composedMap[tableId][recordId] || [];\n\n        const opIndexObj: Record<string, number> = {};\n\n        // indexing\n        composedMap[tableId][recordId].forEach((op, index) => {\n          opIndexObj[op.p.join()] = index;\n        });\n\n        // compose op that has same path\n        currentMap[tableId][recordId].forEach((op) => {\n          const opKey = op.p.join();\n          const existingOpIndex = opIndexObj[opKey];\n          if (existingOpIndex !== undefined) {\n            const oldOp = composedMap[tableId][recordId][existingOpIndex];\n            composedMap[tableId][recordId][existingOpIndex] = {\n              p: op.p,\n              od: oldOp.od,\n              oi: op.oi,\n            };\n          } else {\n            opIndexObj[opKey] = composedMap[tableId][recordId].length;\n            composedMap[tableId][recordId].push(op);\n          }\n        });\n\n        // filter op that has same oi and od\n        composedMap[tableId][recordId] = composedMap[tableId][recordId].filter(\n          (op) => !isEqual(op.oi, op.od)\n        );\n\n        if (!composedMap[tableId][recordId].length) {\n          delete composedMap[tableId][recordId];\n        }\n      });\n\n      if (isEmpty(composedMap[tableId])) {\n        delete composedMap[tableId];\n      }\n    });\n    return composedMap;\n  }, {});\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/detect-link.spec.ts",
    "content": "import { IdPrefix } from '@teable/core';\nimport { isLinkCellValue } from './detect-link';\n\ndescribe('isLinkCellValue', () => {\n  it('should return true for objects that are link cell values', () => {\n    const linkCellItem = { id: IdPrefix.Record + '123' };\n    expect(isLinkCellValue(linkCellItem)).toBe(true);\n  });\n\n  it('should return false for objects that are not link cell values', () => {\n    const nonLinkCellItem = { id: IdPrefix.Table + '123' };\n    expect(isLinkCellValue(nonLinkCellItem)).toBe(false);\n  });\n\n  it('should return true for arrays containing link cell items', () => {\n    const linkCellItem = { id: IdPrefix.Record + '123' };\n    expect(isLinkCellValue([linkCellItem])).toBe(true);\n  });\n\n  it('should return false for arrays not containing link cell items', () => {\n    const nonLinkCellItem = { id: IdPrefix.Table + '123' };\n    expect(isLinkCellValue([nonLinkCellItem])).toBe(false);\n  });\n\n  it('should return false for null values', () => {\n    expect(isLinkCellValue(null)).toBe(false);\n  });\n\n  it('should return false for undefined values', () => {\n    expect(isLinkCellValue(undefined)).toBe(false);\n  });\n\n  it('should return false for primitive values', () => {\n    expect(isLinkCellValue('string')).toBe(false);\n    expect(isLinkCellValue(123)).toBe(false);\n    expect(isLinkCellValue(true)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/detect-link.ts",
    "content": "import { IdPrefix } from '@teable/core';\n\n// for performance, we detect if record contains link by check recordId cellValue\nexport function isLinkCellValue(value: unknown): boolean {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  function isLinkCellItem(item: any): boolean {\n    if (typeof item !== 'object' || item == null) {\n      return false;\n    }\n\n    if ('id' in item && typeof item.id === 'string') {\n      const recordId: string = item.id;\n      return recordId.startsWith(IdPrefix.Record);\n    }\n    return false;\n  }\n\n  if (Array.isArray(value) && isLinkCellItem(value[0])) {\n    return true;\n  }\n  return isLinkCellItem(value);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts",
    "content": "import type { IGraphItem } from './dfs';\nimport { pruneGraph, getTopoOrders, topoOrderWithStart, hasCycle } from './dfs';\n\ndescribe('Graph Processing Functions', () => {\n  describe('getTopoOrders', () => {\n    it('should return correct order for a DAG', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n      ];\n      const result = getTopoOrders(graph);\n      expect(result).toEqual([\n        { id: '1', dependencies: [] },\n        { id: '2', dependencies: ['1'] },\n        { id: '3', dependencies: ['2'] },\n      ]);\n    });\n\n    it('should return correct order for a normal DAG', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n      ];\n      const result = getTopoOrders(graph);\n      expect(result).toEqual([\n        { id: '1', dependencies: [] },\n        { id: '2', dependencies: ['1'] },\n        { id: '3', dependencies: ['2'] },\n      ]);\n    });\n\n    it('should return correct order for a complex DAG', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n        { fromFieldId: '1', toFieldId: '3' },\n        { fromFieldId: '3', toFieldId: '4' },\n      ];\n      const result = getTopoOrders(graph);\n      expect(result).toEqual([\n        { id: '1', dependencies: [] },\n        { id: '2', dependencies: ['1'] },\n        { id: '3', dependencies: ['2', '1'] },\n        { id: '4', dependencies: ['3'] },\n      ]);\n    });\n\n    it('should handle a graph with multiple entry nodes', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '3' },\n        { fromFieldId: '2', toFieldId: '3' },\n      ];\n      const result = getTopoOrders(graph);\n\n      expect(result).toEqual([\n        { id: '1', dependencies: [] },\n        { id: '2', dependencies: [] },\n        { id: '3', dependencies: ['1', '2'] },\n      ]);\n    });\n  });\n\n  describe('hasCycle', () => {\n    it('should return false for an empty graph', () => {\n      expect(hasCycle([])).toBe(false);\n    });\n\n    it('should return true for a single node graph link to self', () => {\n      const graph = [{ fromFieldId: '1', toFieldId: '1' }];\n      expect(hasCycle(graph)).toBe(true);\n    });\n\n    it('should return false for a normal DAG without cycles', () => {\n      const graph = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n      ];\n      expect(hasCycle(graph)).toBe(false);\n    });\n\n    it('should return true for a graph with a cycle', () => {\n      const graph = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n        { fromFieldId: '3', toFieldId: '1' }, // creates a cycle\n      ];\n      expect(hasCycle(graph)).toBe(true);\n    });\n  });\n\n  describe('topoOrderWithStart', () => {\n    it('should return correct order for a normal DAG', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n      ];\n      const result = topoOrderWithStart('1', graph);\n      expect(result).toEqual(['1', '2', '3']);\n    });\n\n    it('should return correct order for a complex DAG', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: '1', toFieldId: '2' },\n        { fromFieldId: '2', toFieldId: '3' },\n        { fromFieldId: '1', toFieldId: '3' },\n        { fromFieldId: '3', toFieldId: '4' },\n      ];\n      const result = topoOrderWithStart('1', graph);\n      expect(result).toEqual(['1', '2', '3', '4']);\n    });\n  });\n\n  describe('pruneGraph', () => {\n    test('returns an empty array for an empty graph', () => {\n      expect(pruneGraph('A', [])).toEqual([]);\n    });\n\n    test('returns correct graph for a single-node graph', () => {\n      const graph: IGraphItem[] = [{ fromFieldId: 'A', toFieldId: 'B' }];\n      expect(pruneGraph('A', graph)).toEqual(graph);\n    });\n\n    test('returns correct graph for a tow-node graph', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: 'A', toFieldId: 'C' },\n        { fromFieldId: 'B', toFieldId: 'C' },\n      ];\n      expect(pruneGraph('C', graph)).toEqual(graph);\n    });\n\n    test('returns correct graph for a multi-node graph', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: 'A', toFieldId: 'B' },\n        { fromFieldId: 'B', toFieldId: 'C' },\n        { fromFieldId: 'C', toFieldId: 'D' },\n        { fromFieldId: 'E', toFieldId: 'F' },\n      ];\n      const expectedResult: IGraphItem[] = [\n        { fromFieldId: 'A', toFieldId: 'B' },\n        { fromFieldId: 'B', toFieldId: 'C' },\n        { fromFieldId: 'C', toFieldId: 'D' },\n      ];\n      expect(pruneGraph('A', graph)).toEqual(expectedResult);\n    });\n\n    test('returns an empty array for a graph with unrelated node', () => {\n      const graph: IGraphItem[] = [\n        { fromFieldId: 'B', toFieldId: 'C' },\n        { fromFieldId: 'C', toFieldId: 'D' },\n      ];\n      expect(pruneGraph('A', graph)).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/dfs.ts",
    "content": "import { HttpErrorCode } from '@teable/core';\nimport { CustomHttpException } from '../../../custom.exception';\n\n// topo item is for field level reference, all id stands for fieldId;\nexport interface ITopoItem {\n  id: string;\n  dependencies: string[];\n}\n\nexport interface IGraphItem {\n  fromFieldId: string;\n  toFieldId: string;\n}\n\nexport function hasCycle(graphItems: IGraphItem[]): boolean {\n  const adjList: Record<string, string[]> = {};\n  const visiting = new Set<string>();\n  const visited = new Set<string>();\n\n  // Build adjacency list\n  graphItems.forEach((item) => {\n    if (!adjList[item.fromFieldId]) {\n      adjList[item.fromFieldId] = [];\n    }\n    adjList[item.fromFieldId].push(item.toFieldId);\n  });\n\n  function dfs(node: string): boolean {\n    if (visiting.has(node)) return true;\n    if (visited.has(node)) return false;\n\n    visiting.add(node);\n\n    if (adjList[node]) {\n      for (const neighbor of adjList[node]) {\n        if (dfs(neighbor)) return true;\n      }\n    }\n\n    visiting.delete(node);\n    visited.add(node);\n\n    return false;\n  }\n\n  // Check for cycles\n  for (const node of Object.keys(adjList)) {\n    if (!visited.has(node) && dfs(node)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nexport function prependStartFieldIds(topoOrders: ITopoItem[], startFieldIds: string[]) {\n  const existFieldIds = new Set(topoOrders.map((item) => item.id));\n  const newTopoOrders = startFieldIds\n    .filter((fieldId) => !existFieldIds.has(fieldId))\n    .map((fieldId) => ({ id: fieldId, dependencies: [] }));\n  return [...newTopoOrders, ...topoOrders];\n}\n\nexport function getTopoOrders(graph: IGraphItem[]): ITopoItem[] {\n  const visitedNodes = new Set<string>();\n  const visitingNodes = new Set<string>();\n  const sortedNodes: ITopoItem[] = [];\n  const allNodes = new Set<string>();\n\n  // Build adjacency list and reverse adjacency list\n  const adjList: Record<string, string[]> = {};\n  const reverseAdjList: Record<string, string[]> = {};\n  for (const edge of graph) {\n    if (!adjList[edge.fromFieldId]) adjList[edge.fromFieldId] = [];\n    adjList[edge.fromFieldId].push(edge.toFieldId);\n\n    if (!reverseAdjList[edge.toFieldId]) reverseAdjList[edge.toFieldId] = [];\n    reverseAdjList[edge.toFieldId].push(edge.fromFieldId);\n\n    // Collect all nodes\n    allNodes.add(edge.fromFieldId);\n    allNodes.add(edge.toFieldId);\n  }\n\n  function visit(node: string) {\n    if (visitingNodes.has(node)) {\n      throw new CustomHttpException(\n        `Detected a cycle: ${node} is part of a circular dependency`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.cycleDetected',\n          },\n        }\n      );\n    }\n\n    if (!visitedNodes.has(node)) {\n      visitingNodes.add(node);\n\n      // Get incoming edges (dependencies)\n      const dependencies = reverseAdjList[node] || [];\n\n      // Process dependencies first\n      for (const dep of dependencies) {\n        if (!visitedNodes.has(dep)) {\n          visit(dep);\n        }\n      }\n\n      visitingNodes.delete(node);\n      visitedNodes.add(node);\n      sortedNodes.push({ id: node, dependencies: dependencies });\n    }\n  }\n\n  // Start with nodes that have no outgoing edges (leaf nodes)\n  const startNodes = Array.from(allNodes).filter(\n    (node) => !adjList[node] || adjList[node].length === 0\n  );\n  for (const node of startNodes) {\n    if (!visitedNodes.has(node)) {\n      visit(node);\n    }\n  }\n\n  // Process remaining nodes\n  for (const node of allNodes) {\n    if (!visitedNodes.has(node)) {\n      visit(node);\n    }\n  }\n\n  return sortedNodes;\n}\n\n/**\n * Generate a topological order with based on the starting node ID.\n */\nexport function topoOrderWithStart(startNodeId: string, graph: IGraphItem[]): string[] {\n  const visitedNodes = new Set<string>();\n  const sortedNodes: string[] = [];\n\n  // Build adjacency list and reverse adjacency list\n  const adjList: Record<string, string[]> = {};\n  const reverseAdjList: Record<string, string[]> = {};\n  for (const edge of graph) {\n    if (!adjList[edge.fromFieldId]) adjList[edge.fromFieldId] = [];\n    adjList[edge.fromFieldId].push(edge.toFieldId);\n\n    if (!reverseAdjList[edge.toFieldId]) reverseAdjList[edge.toFieldId] = [];\n    reverseAdjList[edge.toFieldId].push(edge.fromFieldId);\n  }\n\n  function visit(node: string) {\n    if (!visitedNodes.has(node)) {\n      visitedNodes.add(node);\n\n      // Process outgoing edges\n      if (adjList[node]) {\n        for (const neighbor of adjList[node]) {\n          visit(neighbor);\n        }\n      }\n\n      sortedNodes.push(node);\n    }\n  }\n\n  visit(startNodeId);\n  return sortedNodes.reverse();\n}\n\n/**\n * Returns all relations related to the given fieldIds.\n */\nexport function filterDirectedGraph(\n  undirectedGraph: IGraphItem[],\n  fieldIds: string[]\n): IGraphItem[] {\n  const result: IGraphItem[] = [];\n  const visited: Set<string> = new Set();\n  const addedEdges: Set<string> = new Set(); // 新增：用于存储已添加的边\n\n  // Build adjacency lists for quick look-up\n  const outgoingAdjList: Record<string, IGraphItem[]> = {};\n  const incomingAdjList: Record<string, IGraphItem[]> = {};\n\n  function addEdgeIfNotExists(edge: IGraphItem) {\n    const edgeKey = edge.fromFieldId + '-' + edge.toFieldId;\n    if (!addedEdges.has(edgeKey)) {\n      addedEdges.add(edgeKey);\n      result.push(edge);\n    }\n  }\n\n  for (const item of undirectedGraph) {\n    // Outgoing edges\n    if (!outgoingAdjList[item.fromFieldId]) {\n      outgoingAdjList[item.fromFieldId] = [];\n    }\n    outgoingAdjList[item.fromFieldId].push(item);\n\n    // Incoming edges\n    if (!incomingAdjList[item.toFieldId]) {\n      incomingAdjList[item.toFieldId] = [];\n    }\n    incomingAdjList[item.toFieldId].push(item);\n  }\n\n  function dfs(currentNode: string) {\n    visited.add(currentNode);\n\n    // Add incoming edges related to currentNode\n    if (incomingAdjList[currentNode]) {\n      incomingAdjList[currentNode].forEach((edge) => addEdgeIfNotExists(edge));\n    }\n\n    // Process outgoing edges from currentNode\n    if (outgoingAdjList[currentNode]) {\n      outgoingAdjList[currentNode].forEach((item) => {\n        if (!visited.has(item.toFieldId)) {\n          addEdgeIfNotExists(item);\n          dfs(item.toFieldId);\n        }\n      });\n    }\n  }\n\n  // Run DFS for each specified fieldId\n  for (const fieldId of fieldIds) {\n    if (!visited.has(fieldId)) {\n      dfs(fieldId);\n    }\n  }\n\n  return result;\n}\n\nexport function pruneGraph(node: string, graph: IGraphItem[]): IGraphItem[] {\n  const relatedNodes = new Set<string>();\n  const prunedGraph: IGraphItem[] = [];\n\n  function dfs(currentNode: string) {\n    relatedNodes.add(currentNode);\n    for (const edge of graph) {\n      if (edge.fromFieldId === currentNode && !relatedNodes.has(edge.toFieldId)) {\n        dfs(edge.toFieldId);\n      }\n    }\n  }\n\n  dfs(node);\n\n  for (const edge of graph) {\n    if (relatedNodes.has(edge.fromFieldId) || relatedNodes.has(edge.toFieldId)) {\n      prunedGraph.push(edge);\n      if (!relatedNodes.has(edge.fromFieldId)) {\n        dfs(edge.fromFieldId);\n      }\n      if (!relatedNodes.has(edge.toFieldId)) {\n        dfs(edge.toFieldId);\n      }\n    }\n  }\n\n  return prunedGraph;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/calculation/utils/name-console.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-explicit-any, sonarjs/cognitive-complexity\nfunction replaceFieldIdsWithNames(obj: any, fieldMap: { [fieldId: string]: { name: string } }) {\n  if (typeof obj === 'object' && obj !== null) {\n    for (const key in obj) {\n      // eslint-disable-next-line no-prototype-builtins\n      if (obj.hasOwnProperty(key)) {\n        let newKey = key;\n        if (key.startsWith('fld') && fieldMap[key]) {\n          newKey = fieldMap[key].name;\n        }\n        obj[newKey] = replaceFieldIdsWithNames(obj[key], fieldMap);\n        if (newKey !== key) delete obj[key];\n      }\n    }\n  } else if (typeof obj === 'string' && obj.startsWith('fld') && fieldMap[obj]) {\n    obj = fieldMap[obj].name;\n  }\n  return obj;\n}\n\nexport function nameConsole(\n  key: string,\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  obj: any,\n  fieldMap: { [fieldId: string]: { name: string } }\n) {\n  obj = JSON.parse(JSON.stringify(obj));\n  console.log(key, JSON.stringify(replaceFieldIdsWithNames(obj, fieldMap), null, 2));\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/canary.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SettingModule } from '../setting/setting.module';\nimport { CanaryService } from './canary.service';\nimport { V2FeatureGuard } from './guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from './interceptors/v2-indicator.interceptor';\n\n@Module({\n  imports: [SettingModule],\n  exports: [CanaryService, V2FeatureGuard, V2IndicatorInterceptor],\n  providers: [CanaryService, V2FeatureGuard, V2IndicatorInterceptor],\n})\nexport class CanaryModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/canary.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { ICanaryConfig, V2Feature } from '@teable/openapi';\nimport { SettingKey } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore, V2Reason } from '../../types/cls';\nimport { SettingService } from '../setting/setting.service';\n\nexport interface IV2Decision {\n  useV2: boolean;\n  reason: V2Reason;\n}\n\n@Injectable()\nexport class CanaryService {\n  constructor(\n    private readonly settingService: SettingService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  /**\n   * Get the canary configuration\n   */\n  async getCanaryConfig(): Promise<ICanaryConfig | null> {\n    const setting = await this.settingService.getSetting([SettingKey.CANARY_CONFIG]);\n    return (setting.canaryConfig as ICanaryConfig) ?? null;\n  }\n\n  /**\n   * Check if canary feature is enabled globally (via environment variable)\n   */\n  isCanaryFeatureEnabled(): boolean {\n    return process.env.ENABLE_CANARY_FEATURE === 'true';\n  }\n\n  /**\n   * Check if V2 is forced globally via environment variable (FORCE_V2_ALL=true)\n   * This has the highest priority over all other settings\n   */\n  isForceV2AllEnabled(): boolean {\n    return process.env.FORCE_V2_ALL === 'true';\n  }\n\n  /**\n   * Check if canary is forced via request header (x-canary: true/false)\n   * Returns: true = force enable, false = force disable, undefined = no override\n   */\n  getHeaderCanaryOverride(): boolean | undefined {\n    const canaryHeader = this.cls.get('canaryHeader');\n    if (canaryHeader === 'true') return true;\n    if (canaryHeader === 'false') return false;\n    return undefined;\n  }\n\n  /**\n   * Check if a space is in canary release\n   * Priority:\n   * 1. If canary feature is disabled globally, return false\n   * 2. If x-canary header is set, use header value (true/false)\n   * 3. Otherwise, check space against configured spaceIds\n   *\n   * @param spaceId - The space ID to check (caller should provide this from their context)\n   */\n  async isSpaceInCanary(spaceId: string): Promise<boolean> {\n    // Check if canary feature is enabled globally\n    if (!this.isCanaryFeatureEnabled()) {\n      return false;\n    }\n\n    // Check header override first\n    const headerOverride = this.getHeaderCanaryOverride();\n    if (headerOverride !== undefined) {\n      return headerOverride;\n    }\n\n    const config = await this.getCanaryConfig();\n\n    // Check if canary is enabled in settings\n    if (!config?.enabled) {\n      return false;\n    }\n\n    // Check if space is in the canary list\n    return config.spaceIds?.includes(spaceId) ?? false;\n  }\n\n  /**\n   * Determine if V2 implementation should be used for a specific feature\n   * Priority:\n   * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks)\n   * 2. If canary feature is disabled globally, return false\n   * 3. forceV2All in config (database setting)\n   * 4. x-canary header override\n   * 5. Space in canary list (all V2 features enabled for canary spaces)\n   *\n   * @param spaceId - The space ID to check\n   * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord')\n   */\n  async shouldUseV2(spaceId: string, _feature: V2Feature): Promise<boolean> {\n    // Priority 1: Environment variable FORCE_V2_ALL (highest priority)\n    if (this.isForceV2AllEnabled()) {\n      return true;\n    }\n\n    // Check if canary feature is enabled globally\n    if (!this.isCanaryFeatureEnabled()) {\n      return false;\n    }\n\n    const config = await this.getCanaryConfig();\n\n    // Priority 2: forceV2All in config (database)\n    if (config?.forceV2All) {\n      return true;\n    }\n\n    // Priority 3: Header override\n    const headerOverride = this.getHeaderCanaryOverride();\n    if (headerOverride !== undefined) {\n      return headerOverride;\n    }\n\n    // Priority 4: Space in canary list (all V2 features enabled for canary spaces)\n    if (!config?.enabled) {\n      return false;\n    }\n\n    return config.spaceIds?.includes(spaceId) ?? false;\n  }\n\n  /**\n   * Determine if V2 implementation should be used for a specific feature,\n   * with detailed reason information.\n   *\n   * Priority:\n   * 1. FORCE_V2_ALL env var (highest priority, bypasses all checks)\n   * 2. If canary feature is disabled globally, return false\n   * 3. forceV2All in config (database setting)\n   * 4. x-canary header override\n   * 5. Space in canary list (all V2 features enabled for canary spaces)\n   *\n   * @param spaceId - The space ID to check\n   * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord')\n   */\n  async shouldUseV2WithReason(spaceId: string, _feature: V2Feature): Promise<IV2Decision> {\n    // Priority 1: Environment variable FORCE_V2_ALL (highest priority)\n    if (this.isForceV2AllEnabled()) {\n      return { useV2: true, reason: 'env_force_v2_all' };\n    }\n\n    // Check if canary feature is enabled globally\n    if (!this.isCanaryFeatureEnabled()) {\n      return { useV2: false, reason: 'disabled' };\n    }\n\n    const config = await this.getCanaryConfig();\n\n    // Priority 2: forceV2All in config (database)\n    if (config?.forceV2All) {\n      return { useV2: true, reason: 'config_force_v2_all' };\n    }\n\n    // Priority 3: Header override\n    const headerOverride = this.getHeaderCanaryOverride();\n    if (headerOverride !== undefined) {\n      return { useV2: headerOverride, reason: 'header_override' };\n    }\n\n    // Priority 4: Space in canary list (all V2 features enabled for canary spaces)\n    if (!config?.enabled) {\n      return { useV2: false, reason: 'disabled' };\n    }\n\n    const inCanarySpace = config.spaceIds?.includes(spaceId) ?? false;\n\n    if (inCanarySpace) {\n      return { useV2: true, reason: 'space_feature' };\n    }\n\n    return { useV2: false, reason: 'feature_not_enabled' };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/decorators/use-v2-feature.decorator.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { SetMetadata } from '@nestjs/common';\nimport type { V2Feature } from '@teable/openapi';\n\nexport const USE_V2_FEATURE_KEY = 'useV2Feature';\n\n/**\n * Decorator to mark a controller method as supporting V2 implementation.\n * Used with V2FeatureGuard to determine if V2 should be used based on canary config.\n *\n * @param feature - The V2 feature name (e.g., 'createRecord', 'updateRecord')\n *\n * @example\n * ```typescript\n * @UseV2Feature('createRecord')\n * @Post()\n * async createRecords(...) {}\n * ```\n */\nexport const UseV2Feature = (feature: V2Feature) => SetMetadata(USE_V2_FEATURE_KEY, feature);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/guards/v2-feature.guard.ts",
    "content": "import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { IdPrefix } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { V2Feature } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { CanaryService } from '../canary.service';\nimport { USE_V2_FEATURE_KEY } from '../decorators/use-v2-feature.decorator';\n\n/**\n * Guard that determines if V2 implementation should be used.\n * Works with @UseV2Feature decorator to enable V2 based on canary configuration.\n *\n * The guard:\n * 1. Reads the feature name from @UseV2Feature decorator\n * 2. Extracts spaceId from request (via tableId -> baseId -> spaceId)\n * 3. Calls CanaryService.shouldUseV2() to determine if V2 should be used\n * 4. Stores the result in CLS for the controller to use\n *\n * @example\n * ```typescript\n * @UseGuards(V2FeatureGuard)\n * @Controller('api/table/:tableId/record')\n * export class RecordController {\n *   @UseV2Feature('createRecord')\n *   @Post()\n *   async createRecords(...) {\n *     if (this.cls.get('useV2')) {\n *       return this.v2Service.createRecords(...);\n *     }\n *     return this.v1Service.createRecords(...);\n *   }\n * }\n * ```\n */\n@Injectable()\nexport class V2FeatureGuard implements CanActivate {\n  constructor(\n    private readonly reflector: Reflector,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly canaryService: CanaryService,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  async canActivate(context: ExecutionContext): Promise<boolean> {\n    const req = context.switchToHttp().getRequest();\n\n    // Store windowId from header for undo/redo tracking\n    const windowId = req.headers['x-window-id'] as string | undefined;\n    if (windowId) {\n      this.cls.set('windowId', windowId);\n    }\n\n    // 1. Get the feature name from decorator\n    const feature = this.reflector.getAllAndOverride<V2Feature | undefined>(USE_V2_FEATURE_KEY, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n\n    // No feature marked, default to V1\n    if (!feature) {\n      this.cls.set('useV2', false);\n      this.cls.set('v2Reason', 'no_feature');\n      return true;\n    }\n\n    // 2. Check FORCE_V2_ALL first (highest priority)\n    if (this.canaryService.isForceV2AllEnabled()) {\n      this.cls.set('useV2', true);\n      this.cls.set('v2Feature', feature);\n      this.cls.set('v2Reason', 'env_force_v2_all');\n      return true;\n    }\n\n    // 3. Get spaceId from request context\n    const spaceId = await this.getSpaceIdFromContext(context);\n\n    if (!spaceId) {\n      this.cls.set('useV2', false);\n      this.cls.set('v2Feature', feature);\n      this.cls.set('v2Reason', 'disabled');\n      return true;\n    }\n\n    // 4. Determine if V2 should be used with reason\n    const decision = await this.canaryService.shouldUseV2WithReason(spaceId, feature);\n    this.cls.set('useV2', decision.useV2);\n    this.cls.set('v2Feature', feature);\n    this.cls.set('v2Reason', decision.reason);\n\n    return true;\n  }\n\n  /**\n   * Extract spaceId from request context.\n   * Supports: spaceId (direct), baseId (lookup), tableId (lookup via base)\n   */\n  private async getSpaceIdFromContext(context: ExecutionContext): Promise<string | undefined> {\n    const req = context.switchToHttp().getRequest();\n    const resourceId = req.params.spaceId || req.params.baseId || req.params.tableId;\n\n    if (!resourceId) {\n      return undefined;\n    }\n\n    // Direct spaceId\n    if (resourceId.startsWith(IdPrefix.Space)) {\n      return resourceId;\n    }\n\n    // BaseId -> lookup spaceId\n    if (resourceId.startsWith(IdPrefix.Base)) {\n      const base = await this.prismaService.txClient().base.findUnique({\n        where: { id: resourceId, deletedTime: null },\n        select: { spaceId: true },\n      });\n      return base?.spaceId;\n    }\n\n    // TableId -> lookup baseId -> lookup spaceId\n    if (resourceId.startsWith(IdPrefix.Table)) {\n      const table = await this.prismaService.txClient().tableMeta.findUnique({\n        where: { id: resourceId, deletedTime: null },\n        select: { baseId: true },\n      });\n\n      if (!table) return undefined;\n\n      const base = await this.prismaService.txClient().base.findUnique({\n        where: { id: table.baseId, deletedTime: null },\n        select: { spaceId: true },\n      });\n      return base?.spaceId;\n    }\n\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/index.ts",
    "content": "export * from './canary.module';\nexport * from './canary.service';\nexport * from './decorators/use-v2-feature.decorator';\nexport * from './guards/v2-feature.guard';\nexport * from './interceptors/v2-indicator.interceptor';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/canary/interceptors/v2-indicator.interceptor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport {\n  Injectable,\n  type NestInterceptor,\n  type ExecutionContext,\n  type CallHandler,\n  Logger,\n} from '@nestjs/common';\nimport * as Sentry from '@sentry/nestjs';\nimport { trace } from '@opentelemetry/api';\nimport type { Response } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport type { Observable } from 'rxjs';\nimport { tap } from 'rxjs/operators';\nimport type { IClsStore } from '../../../types/cls';\n\nexport const X_TEABLE_V2_HEADER = 'x-teable-v2';\nexport const X_TEABLE_V2_REASON_HEADER = 'x-teable-v2-reason';\nexport const X_TEABLE_V2_FEATURE_HEADER = 'x-teable-v2-feature';\n\ntype SentryScopeLike = {\n  setTag(key: string, value: string): void;\n};\n\nconst getSentryScopes = (): SentryScopeLike[] => {\n  const sentryApi = Sentry as unknown as {\n    getCurrentScope?: () => SentryScopeLike | undefined;\n    getIsolationScope?: () => SentryScopeLike | undefined;\n    getCurrentHub?: () => { getScope?: () => SentryScopeLike | undefined };\n  };\n\n  const scopes = [\n    sentryApi.getCurrentScope?.(),\n    sentryApi.getIsolationScope?.(),\n    sentryApi.getCurrentHub?.()?.getScope?.(),\n  ].filter((scope): scope is SentryScopeLike => Boolean(scope));\n\n  return [...new Set(scopes)];\n};\n\nconst setSentryTag = (key: string, value: string | undefined) => {\n  if (value == null) {\n    return;\n  }\n\n  for (const scope of getSentryScopes()) {\n    scope.setTag(key, value);\n  }\n};\n\n/**\n * Interceptor that adds V2 indicator to response headers and logs.\n * When a request uses V2 implementation (determined by V2FeatureGuard),\n * this interceptor adds:\n * - Response header: x-teable-v2: true\n * - Response header: x-teable-v2-reason: <reason>\n * - Response header: x-teable-v2-feature: <feature>\n * - Log entry with V2 indicator for tracing\n * - Span attributes for OpenTelemetry tracing\n */\n@Injectable()\nexport class V2IndicatorInterceptor implements NestInterceptor {\n  private readonly logger = new Logger(V2IndicatorInterceptor.name);\n\n  constructor(private readonly cls: ClsService<IClsStore>) {}\n\n  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {\n    const useV2 = this.cls.get('useV2');\n    const v2Reason = this.cls.get('v2Reason');\n    const v2Feature = this.cls.get('v2Feature');\n\n    const response = context.switchToHttp().getResponse<Response>();\n    const request = context.switchToHttp().getRequest();\n\n    // Add V2 indicator headers regardless of useV2 value\n    // This allows clients to understand why V2 was or wasn't used\n    response.setHeader(X_TEABLE_V2_HEADER, useV2 ? 'true' : 'false');\n    if (v2Reason) {\n      response.setHeader(X_TEABLE_V2_REASON_HEADER, v2Reason);\n    }\n    if (v2Feature) {\n      response.setHeader(X_TEABLE_V2_FEATURE_HEADER, v2Feature);\n    }\n\n    // Mirror V2 indicators into Sentry tags so issue search can distinguish v1/v2 requests.\n    setSentryTag('teable.version', useV2 ? 'v2' : 'v1');\n    setSentryTag('teable.v2.enabled', useV2 ? 'true' : 'false');\n    setSentryTag('teable.v2.reason', v2Reason);\n    setSentryTag('teable.v2.feature', v2Feature);\n\n    // Add span attributes for tracing\n    const span = trace.getActiveSpan();\n    if (span) {\n      span.setAttributes({\n        'teable.v2.enabled': useV2 ?? false,\n        ...(v2Reason && { 'teable.v2.reason': v2Reason }),\n        ...(v2Feature && { 'teable.v2.feature': v2Feature }),\n      });\n    }\n\n    if (!useV2) {\n      return next.handle();\n    }\n\n    return next.handle().pipe(\n      tap(() => {\n        // Log V2 usage for tracing\n        this.logger.debug({\n          message: 'V2 implementation used',\n          method: request.method,\n          path: request.path,\n          tableId: request.params?.tableId,\n          useV2: true,\n          v2Reason,\n          v2Feature,\n        });\n      })\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chart-completion.ro.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\n\nexport class CompletionRo {\n  @ApiProperty({\n    description: 'The prompt message.',\n    example: 'List table',\n  })\n  message!: string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chat.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { ChatController } from './chat.controller';\nimport { ChatModule } from './chat.module';\n\ndescribe('ChatController', () => {\n  let controller: ChatController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [ChatController],\n      imports: [ChatModule],\n    }).compile();\n\n    controller = module.get<ChatController>(ChatController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chat.controller.ts",
    "content": "import { Controller, Post, Req, Res } from '@nestjs/common';\nimport { Request, Response } from 'express';\nimport { ChatService } from './chat.service';\n\n@Controller('api/chart')\nexport class ChatController {\n  constructor(private readonly chartService: ChatService) {}\n\n  @Post('completions')\n  async completions(@Req() req: Request, @Res() res: Response) {\n    await this.chartService.completions(req, res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chat.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ConfigModule } from '@nestjs/config';\nimport { ChatController } from './chat.controller';\nimport { ChatService } from './chat.service';\n\n@Module({\n  imports: [ConfigModule],\n  providers: [ChatService],\n  controllers: [ChatController],\n  exports: [ChatService],\n})\nexport class ChatModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chat.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { ChatModule } from './chat.module';\nimport { ChatService } from './chat.service';\n\ndescribe('ChatService', () => {\n  let service: ChatService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [ChatModule],\n    }).compile();\n\n    service = module.get<ChatService>(ChatService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/chat/chat.service.ts",
    "content": "import * as http from 'http';\nimport * as https from 'https';\nimport { HttpException, Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { Response, Request } from 'express';\n\n@Injectable()\nexport class ChatService {\n  constructor(private readonly configService: ConfigService) {}\n  async completions(req: Request, res: Response) {\n    const openAIEndPoint = this.configService.get<string>('OPENAI_API_ENDPOINT');\n    const openAiKey = this.configService.get<string>('OPENAI_API_KEY');\n\n    if (!openAIEndPoint || !openAiKey) {\n      throw new HttpException('OPENAI_API_ENDPOINT or OPENAI_API_KEY is undefined', 500);\n    }\n\n    const [protocol, hostname] = openAIEndPoint.split('://');\n    const options = {\n      method: 'POST',\n      hostname,\n      path: '/v1/chat/completions',\n      headers: {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': 'application/json',\n        Authorization: `Bearer ${openAiKey}`,\n      },\n    };\n\n    const { body } = req;\n\n    const proxyReq = (protocol === 'https' ? https : http).request(options, (proxyRes) => {\n      res.set(proxyRes.headers);\n\n      proxyRes.pipe(res);\n    });\n\n    proxyReq.on('error', (error) => {\n      console.error('Error while proxying request:', error);\n      res.status(500).send('Error while proxying request');\n    });\n\n    proxyReq.write(JSON.stringify(body));\n\n    proxyReq.end();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/collaborator/collaborator.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { CollaboratorController } from './collaborator.controller';\n\ndescribe('CollaboratorController', () => {\n  let controller: CollaboratorController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [CollaboratorController],\n    }).compile();\n\n    controller = module.get<CollaboratorController>(CollaboratorController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/collaborator/collaborator.controller.ts",
    "content": "import { Controller } from '@nestjs/common';\n\n@Controller('collaborator')\nexport class CollaboratorController {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/collaborator/collaborator.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CollaboratorController } from './collaborator.controller';\nimport { CollaboratorService } from './collaborator.service';\n\n@Module({\n  providers: [CollaboratorService],\n  controllers: [CollaboratorController],\n  exports: [CollaboratorService],\n})\nexport class CollaboratorModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/collaborator/collaborator.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { Role, getPermissions } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PrincipalType } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { mockDeep } from 'vitest-mock-extended';\nimport { GlobalModule } from '../../global/global.module';\nimport type { IClsStore } from '../../types/cls';\nimport { CollaboratorModule } from './collaborator.module';\nimport { CollaboratorService } from './collaborator.service';\n\ndescribe('CollaboratorService', () => {\n  const mockUser = { id: 'usr1', name: 'John', email: 'john@example.com' };\n  const mockSpace = { id: 'spcxxxxxxxx', name: 'Test Space' };\n  const prismaService = mockDeep<PrismaService>();\n\n  let collaboratorService: CollaboratorService;\n  let clsService: ClsService<IClsStore>;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [CollaboratorModule, GlobalModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaService)\n      .compile();\n\n    clsService = module.get<ClsService<IClsStore>>(ClsService);\n    collaboratorService = module.get<CollaboratorService>(CollaboratorService);\n\n    prismaService.txClient.mockImplementation(() => {\n      return prismaService;\n    });\n\n    prismaService.$tx.mockImplementation(async (fn, _options) => {\n      return await fn(prismaService);\n    });\n  });\n\n  describe('createSpaceCollaborator', () => {\n    it('should create collaborator correctly', async () => {\n      prismaService.collaborator.count.mockResolvedValue(0);\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      prismaService.base.findMany.mockResolvedValue([{ id: 'base1' }] as any);\n      prismaService.collaborator.deleteMany.mockResolvedValue({ count: 0 });\n      await clsService.runWith(\n        {\n          user: mockUser,\n          tx: {},\n          permissions: getPermissions(Role.Owner),\n          origin: {\n            ip: '127.0.0.1',\n            byApi: false,\n            userAgent: 'test',\n            referer: 'test',\n          },\n        },\n        async () => {\n          await collaboratorService.createSpaceCollaborator({\n            collaborators: [\n              {\n                principalId: mockUser.id,\n                principalType: PrincipalType.User,\n              },\n            ],\n            role: Role.Owner,\n            spaceId: mockSpace.id,\n          });\n        }\n      );\n\n      expect(prismaService.collaborator.deleteMany).toBeCalledWith({\n        where: {\n          OR: [\n            {\n              principalId: mockUser.id,\n              principalType: PrincipalType.User,\n            },\n          ],\n          resourceId: { in: ['base1'] },\n          resourceType: CollaboratorType.Base,\n        },\n      });\n      expect(prismaService.collaborator.createMany).toBeCalled();\n    });\n\n    it('should throw error if exists', async () => {\n      prismaService.collaborator.count.mockResolvedValue(1);\n\n      await expect(\n        collaboratorService.createSpaceCollaborator({\n          collaborators: [\n            {\n              principalId: mockUser.id,\n              principalType: PrincipalType.User,\n            },\n          ],\n          role: Role.Owner,\n          spaceId: mockSpace.id,\n        })\n      ).rejects.toThrow('Collaborator has already existed in space');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/collaborator/collaborator.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport {\n  canManageRole,\n  getRandomString,\n  HttpErrorCode,\n  Role,\n  type IBaseRole,\n  type IRole,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  AddBaseCollaboratorRo,\n  AddSpaceCollaboratorRo,\n  CollaboratorItem,\n  IItemBaseCollaboratorUser,\n  IListBaseCollaboratorUserRo,\n} from '@teable/openapi';\nimport { CollaboratorType, PrincipalType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { difference, keyBy, map } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport {\n  CollaboratorCreateEvent,\n  CollaboratorDeleteEvent,\n  CollaboratorUpdateEvent,\n  Events,\n} from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { getMaxLevelRole } from '../../utils/get-max-level-role';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\n\n@Injectable()\nexport class CollaboratorService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  async createSpaceCollaborator({\n    collaborators,\n    spaceId,\n    role,\n    createdBy,\n  }: {\n    collaborators: {\n      principalId: string;\n      principalType: PrincipalType;\n    }[];\n    spaceId: string;\n    role: IRole;\n    createdBy?: string;\n  }) {\n    const currentUserId = createdBy || this.cls.get('user.id');\n    const exist = await this.prismaService.txClient().collaborator.count({\n      where: {\n        OR: collaborators.map((collaborator) => ({\n          principalId: collaborator.principalId,\n          principalType: collaborator.principalType,\n        })),\n        resourceId: spaceId,\n        resourceType: CollaboratorType.Space,\n      },\n    });\n    if (exist) {\n      throw new CustomHttpException(\n        'Collaborator has already existed in space',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.alreadyExisted',\n          },\n        }\n      );\n    }\n    // if has exist base collaborator, then delete it\n    const bases = await this.prismaService.txClient().base.findMany({\n      where: {\n        spaceId,\n        deletedTime: null,\n      },\n    });\n\n    await this.prismaService.txClient().collaborator.deleteMany({\n      where: {\n        OR: collaborators.map((collaborator) => ({\n          principalId: collaborator.principalId,\n          principalType: collaborator.principalType,\n        })),\n        resourceId: { in: bases.map((base) => base.id) },\n        resourceType: CollaboratorType.Base,\n      },\n    });\n\n    await this.prismaService.txClient().collaborator.createMany({\n      data: collaborators.map((collaborator) => ({\n        id: getRandomString(16),\n        resourceId: spaceId,\n        resourceType: CollaboratorType.Space,\n        roleName: role,\n        principalId: collaborator.principalId,\n        principalType: collaborator.principalType,\n        createdBy: currentUserId!,\n      })),\n    });\n    this.eventEmitterService.emitAsync(\n      Events.COLLABORATOR_CREATE,\n      new CollaboratorCreateEvent(spaceId)\n    );\n  }\n\n  protected async getBaseCollaboratorBuilder(\n    knex: Knex.QueryBuilder,\n    baseId: string,\n    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }\n  ) {\n    const base = await this.prismaService\n      .txClient()\n      .base.findUniqueOrThrow({ select: { spaceId: true }, where: { id: baseId } });\n\n    const builder = knex\n      .from('collaborator')\n      .leftJoin('users', 'collaborator.principal_id', 'users.id')\n      .whereIn('collaborator.resource_id', [baseId, base.spaceId]);\n    const { includeSystem, search, type, role } = options ?? {};\n    if (!includeSystem) {\n      builder.where((db) => {\n        return db.whereNull('users.is_system').orWhere('users.is_system', false);\n      });\n    }\n    if (search) {\n      this.dbProvider.searchBuilder(builder, [\n        ['users.name', search],\n        ['users.email', search],\n      ]);\n    }\n\n    if (role?.length) {\n      builder.whereIn('collaborator.role_name', role);\n    }\n    if (type) {\n      builder.where('collaborator.principal_type', type);\n    }\n  }\n\n  async getTotalBase(\n    baseId: string,\n    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }\n  ) {\n    const builder = this.knex.queryBuilder();\n    await this.getBaseCollaboratorBuilder(builder, baseId, options);\n    const res = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { count: number }[]\n      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());\n    return Number(res[0].count);\n  }\n\n  protected async getListByBaseBuilder(\n    builder: Knex.QueryBuilder,\n    options?: {\n      includeSystem?: boolean;\n      skip?: number;\n      take?: number;\n      search?: string;\n      type?: PrincipalType;\n      orderBy?: 'desc' | 'asc';\n    }\n  ) {\n    const { skip = 0, take = 50 } = options ?? {};\n    builder.offset(skip);\n    builder.limit(take);\n    builder.select({\n      resource_id: 'collaborator.resource_id',\n      role_name: 'collaborator.role_name',\n      created_time: 'collaborator.created_time',\n      resource_type: 'collaborator.resource_type',\n      user_id: 'users.id',\n      user_name: 'users.name',\n      user_email: 'users.email',\n      user_avatar: 'users.avatar',\n      user_is_system: 'users.is_system',\n    });\n    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');\n  }\n\n  async getListByBase(\n    baseId: string,\n    options?: {\n      includeSystem?: boolean;\n      skip?: number;\n      take?: number;\n      search?: string;\n      type?: PrincipalType;\n      role?: IRole[];\n    }\n  ): Promise<CollaboratorItem[]> {\n    const builder = this.knex.queryBuilder();\n    builder.whereNotNull('users.id');\n    await this.getBaseCollaboratorBuilder(builder, baseId, options);\n    await this.getListByBaseBuilder(builder, options);\n    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<\n      {\n        resource_id: string;\n        role_name: string;\n        created_time: Date;\n        resource_type: string;\n        user_id: string;\n        user_name: string;\n        user_email: string;\n        user_avatar: string;\n        user_is_system: boolean | null;\n      }[]\n    >(builder.toQuery());\n\n    return collaborators.map((collaborator) => ({\n      type: PrincipalType.User,\n      userId: collaborator.user_id,\n      userName: collaborator.user_name,\n      email: collaborator.user_email,\n      avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,\n      role: collaborator.role_name as IRole,\n      createdTime: collaborator.created_time.toISOString(),\n      resourceType: collaborator.resource_type as CollaboratorType,\n      isSystem: collaborator.user_is_system || undefined,\n    }));\n  }\n\n  async getUserCollaboratorsByTableId(\n    tableId: string,\n    query: {\n      containsIn: {\n        keys: ('id' | 'name' | 'email' | 'phone')[];\n        values: string[];\n      };\n    }\n  ) {\n    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      select: { baseId: true },\n      where: { id: tableId },\n    });\n\n    const builder = this.knex.queryBuilder();\n    await this.getBaseCollaboratorBuilder(builder, baseId, {\n      includeSystem: true,\n    });\n    if (query.containsIn) {\n      builder.where((db) => {\n        const keys = query.containsIn.keys;\n        const values = query.containsIn.values;\n        keys.forEach((key) => {\n          db.orWhereIn('users.' + key, values);\n        });\n        return db;\n      });\n    }\n    builder.whereNotNull('users.id');\n    builder.select({\n      id: 'users.id',\n      name: 'users.name',\n      email: 'users.email',\n      avatar: 'users.avatar',\n      isSystem: 'users.is_system',\n    });\n\n    return this.prismaService.txClient().$queryRawUnsafe<\n      {\n        id: string;\n        name: string;\n        email: string;\n        avatar: string | null;\n        isSystem: boolean | null;\n      }[]\n    >(builder.toQuery());\n  }\n\n  protected async getSpaceCollaboratorBuilder(\n    knex: Knex.QueryBuilder,\n    spaceId: string,\n    options?: {\n      includeSystem?: boolean;\n      search?: string;\n      includeBase?: boolean;\n      type?: PrincipalType;\n    }\n  ): Promise<{\n    builder: Knex.QueryBuilder;\n    baseMap: Record<string, { name: string; id: string }>;\n  }> {\n    const { includeSystem, search, type, includeBase } = options ?? {};\n\n    let baseIds: string[] = [];\n    let baseMap: Record<string, { name: string; id: string }> = {};\n    if (includeBase) {\n      const bases = await this.prismaService.txClient().base.findMany({\n        where: { spaceId, deletedTime: null, space: { deletedTime: null } },\n      });\n      baseIds = map(bases, 'id') as string[];\n      baseMap = bases.reduce(\n        (acc, base) => {\n          acc[base.id] = { name: base.name, id: base.id };\n          return acc;\n        },\n        {} as Record<string, { name: string; id: string }>\n      );\n    }\n\n    const builder = knex\n      .from('collaborator')\n      .leftJoin('users', 'collaborator.principal_id', 'users.id');\n\n    if (baseIds?.length) {\n      builder.whereIn('collaborator.resource_id', [...baseIds, spaceId]);\n    } else {\n      builder.where('collaborator.resource_id', spaceId);\n    }\n    if (!includeSystem) {\n      builder.where((db) => {\n        return db.whereNull('users.is_system').orWhere('users.is_system', false);\n      });\n    }\n    if (search) {\n      this.dbProvider.searchBuilder(builder, [\n        ['users.name', search],\n        ['users.email', search],\n      ]);\n    }\n    if (type) {\n      builder.where('collaborator.principal_type', type);\n    }\n    return { builder, baseMap };\n  }\n\n  async getTotalSpace(\n    spaceId: string,\n    options?: {\n      includeSystem?: boolean;\n      includeBase?: boolean;\n      search?: string;\n      type?: PrincipalType;\n    }\n  ) {\n    const builder = this.knex.queryBuilder();\n    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);\n    const res = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { count: number }[]\n      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());\n    return Number(res[0].count);\n  }\n\n  async getSpaceCollaboratorStats(\n    spaceId: string,\n    options?: {\n      includeSystem?: boolean;\n      includeBase?: boolean;\n      search?: string;\n      type?: PrincipalType;\n    }\n  ) {\n    // Get total count (existing logic)\n    const builder = this.knex.queryBuilder();\n    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);\n    const res = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { count: number }[]\n      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());\n    const total = Number(res[0].count);\n\n    // Get unique total - distinct users across space and base collaborators\n    const uniqBuilder = this.knex.queryBuilder();\n    await this.getSpaceCollaboratorBuilder(uniqBuilder, spaceId, { ...options, includeBase: true });\n    const uniqRes = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { count: number }[]\n      >(uniqBuilder.select(this.knex.raw('COUNT(DISTINCT users.id) as count')).toQuery());\n    const uniqTotal = Number(uniqRes[0].count);\n\n    return {\n      total,\n      uniqTotal,\n    };\n  }\n\n  // eslint-disable-next-line sonarjs/no-identical-functions\n  protected async getListBySpaceBuilder(\n    builder: Knex.QueryBuilder,\n    options?: {\n      includeSystem?: boolean;\n      includeBase?: boolean;\n      skip?: number;\n      take?: number;\n      search?: string;\n      type?: PrincipalType;\n      orderBy?: 'desc' | 'asc';\n    }\n  ) {\n    const { skip = 0, take = 50 } = options ?? {};\n    builder.offset(skip);\n    builder.limit(take);\n    builder.select({\n      resource_id: 'collaborator.resource_id',\n      role_name: 'collaborator.role_name',\n      created_time: 'collaborator.created_time',\n      resource_type: 'collaborator.resource_type',\n      user_id: 'users.id',\n      user_name: 'users.name',\n      user_email: 'users.email',\n      user_avatar: 'users.avatar',\n      user_is_system: 'users.is_system',\n    });\n    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');\n  }\n\n  async getListBySpace(\n    spaceId: string,\n    options?: {\n      includeSystem?: boolean;\n      includeBase?: boolean;\n      skip?: number;\n      take?: number;\n      search?: string;\n      type?: PrincipalType;\n      orderBy?: 'desc' | 'asc';\n    }\n  ): Promise<CollaboratorItem[]> {\n    const builder = this.knex.queryBuilder();\n    builder.whereNotNull('users.id');\n    const { baseMap } = await this.getSpaceCollaboratorBuilder(builder, spaceId, options);\n    await this.getListBySpaceBuilder(builder, options);\n    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<\n      {\n        resource_id: string;\n        role_name: string;\n        created_time: Date;\n        resource_type: string;\n        user_id: string;\n        user_name: string;\n        user_email: string;\n        user_avatar: string;\n        user_is_system: boolean | null;\n      }[]\n    >(builder.toQuery());\n\n    // Get billable users if not community edition and includeBase is true\n    return collaborators.map((collaborator) => {\n      return {\n        type: PrincipalType.User,\n        resourceType: collaborator.resource_type as CollaboratorType,\n        userId: collaborator.user_id,\n        userName: collaborator.user_name,\n        email: collaborator.user_email,\n        avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,\n        role: collaborator.role_name as IRole,\n        createdTime: collaborator.created_time.toISOString(),\n        base: baseMap[collaborator.resource_id],\n      };\n    });\n  }\n\n  private async getOperatorCollaborators({\n    targetPrincipalId,\n    currentPrincipalId,\n    resourceId,\n    resourceType,\n  }: {\n    resourceId: string;\n    resourceType: CollaboratorType;\n    targetPrincipalId: string;\n    currentPrincipalId: string;\n  }) {\n    const currentUserWhere: {\n      principalId: string;\n      resourceId: string | Record<string, string[]>;\n    } = {\n      principalId: currentPrincipalId,\n      resourceId,\n    };\n    const targetUserWhere: {\n      principalId: string;\n      resourceId: string | Record<string, string[]>;\n    } = {\n      principalId: targetPrincipalId,\n      resourceId,\n    };\n\n    // for space user delete base collaborator\n    if (resourceType === CollaboratorType.Base) {\n      const spaceId = await this.prismaService\n        .txClient()\n        .base.findUniqueOrThrow({\n          where: { id: resourceId, deletedTime: null },\n          select: { spaceId: true },\n        })\n        .then((base) => base.spaceId);\n      currentUserWhere.resourceId = { in: [resourceId, spaceId] };\n    }\n    const colls = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        OR: [currentUserWhere, targetUserWhere],\n      },\n    });\n\n    const currentColl = colls.find((coll) => coll.principalId === currentPrincipalId);\n    const targetColl = colls.find((coll) => coll.principalId === targetPrincipalId);\n    if (!currentColl || !targetColl) {\n      throw new CustomHttpException(\n        'User not found in collaborator',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.userNotFoundInCollaborator',\n          },\n        }\n      );\n    }\n    return { currentColl, targetColl };\n  }\n\n  async isUniqueOwnerUser(spaceId: string, userId: string) {\n    const builder = this.knex('collaborator')\n      .leftJoin('users', 'collaborator.principal_id', 'users.id')\n      .where('collaborator.resource_id', spaceId)\n      .where('collaborator.resource_type', CollaboratorType.Space)\n      .where('collaborator.role_name', Role.Owner)\n      .where('users.is_system', null)\n      .where('users.deleted_time', null)\n      .where('users.deactivated_time', null)\n      .select('collaborator.principal_id');\n    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<\n      {\n        principal_id: string;\n      }[]\n    >(builder.toQuery());\n    return collaborators.length === 1 && collaborators[0].principal_id === userId;\n  }\n\n  async deleteCollaborator({\n    resourceId,\n    resourceType,\n    principalId,\n    principalType,\n  }: {\n    principalId: string;\n    principalType: PrincipalType;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    const currentUserId = this.cls.get('user.id');\n    const { currentColl, targetColl } = await this.getOperatorCollaborators({\n      currentPrincipalId: currentUserId,\n      targetPrincipalId: principalId,\n      resourceId,\n      resourceType,\n    });\n\n    // validate user can operator target user\n    if (\n      currentUserId !== principalId &&\n      currentColl.roleName !== Role.Owner &&\n      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)\n    ) {\n      throw new CustomHttpException(\n        'You do not have permission to delete this collaborator',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.noPermissionToDelete',\n          },\n        }\n      );\n    }\n    const result = await this.prismaService.txClient().collaborator.delete({\n      where: {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        resourceType_resourceId_principalId_principalType: {\n          resourceId: resourceId,\n          resourceType: resourceType,\n          principalId,\n          principalType,\n        },\n      },\n    });\n    let spaceId: string = resourceId;\n    if (resourceType === CollaboratorType.Base) {\n      const space = await this.prismaService\n        .txClient()\n        .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } });\n      spaceId = space.spaceId;\n    }\n    this.eventEmitterService.emitAsync(\n      Events.COLLABORATOR_DELETE,\n      new CollaboratorDeleteEvent(spaceId)\n    );\n    return result;\n  }\n\n  async updateCollaborator({\n    role,\n    principalId,\n    principalType,\n    resourceId,\n    resourceType,\n  }: {\n    role: IRole;\n    principalId: string;\n    principalType: PrincipalType;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    const currentUserId = this.cls.get('user.id');\n    const { currentColl, targetColl } = await this.getOperatorCollaborators({\n      currentPrincipalId: currentUserId,\n      targetPrincipalId: principalId,\n      resourceId,\n      resourceType,\n    });\n\n    // validate user can operator target user\n    if (\n      currentUserId !== principalId &&\n      currentColl.roleName !== targetColl.roleName &&\n      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)\n    ) {\n      throw new CustomHttpException(\n        `You do not have permission to operator this collaborator: ${principalId}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.noPermissionToUpdate',\n          },\n        }\n      );\n    }\n\n    // validate user can operator target role\n    if (role !== currentColl.roleName && !canManageRole(currentColl.roleName as IRole, role)) {\n      throw new CustomHttpException(\n        `You do not have permission to operator this role: ${role}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.noPermissionToOperateRole',\n          },\n        }\n      );\n    }\n\n    const res = await this.prismaService.txClient().collaborator.updateMany({\n      where: {\n        resourceId: resourceId,\n        resourceType: resourceType,\n        principalId: principalId,\n        principalType: principalType,\n      },\n      data: {\n        roleName: role,\n        lastModifiedBy: currentUserId,\n      },\n    });\n\n    let spaceId: string = '';\n    if (resourceType === CollaboratorType.Base) {\n      const space = await this.prismaService\n        .txClient()\n        .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } });\n      spaceId = space.spaceId;\n    } else if (resourceType === CollaboratorType.Space) {\n      spaceId = resourceId;\n    }\n\n    if (spaceId) {\n      this.eventEmitterService.emitAsync(\n        Events.COLLABORATOR_UPDATE,\n        new CollaboratorUpdateEvent(spaceId)\n      );\n    }\n\n    return res;\n  }\n\n  async getCurrentUserCollaboratorsBaseAndSpaceArray(searchRoles?: IRole[]) {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    const collaborators = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: { in: [userId, ...(departmentIds || [])] },\n        ...(searchRoles && searchRoles.length > 0 ? { roleName: { in: searchRoles } } : {}),\n      },\n      select: {\n        roleName: true,\n        resourceId: true,\n        resourceType: true,\n      },\n    });\n    const roleMap: Record<string, IRole> = {};\n    const baseIds = new Set<string>();\n    const spaceIds = new Set<string>();\n    collaborators.forEach(({ resourceId, roleName, resourceType }) => {\n      if (!roleMap[resourceId] || canManageRole(roleName as IRole, roleMap[resourceId])) {\n        roleMap[resourceId] = roleName as IRole;\n      }\n      if (resourceType === CollaboratorType.Base) {\n        baseIds.add(resourceId);\n      } else {\n        spaceIds.add(resourceId);\n      }\n    });\n    return {\n      baseIds: Array.from(baseIds),\n      spaceIds: Array.from(spaceIds),\n      roleMap: roleMap,\n    };\n  }\n\n  async createBaseCollaborator({\n    collaborators,\n    baseId,\n    role,\n    createdBy,\n  }: {\n    collaborators: {\n      principalId: string;\n      principalType: PrincipalType;\n    }[];\n    baseId: string;\n    role: IBaseRole;\n    createdBy?: string;\n  }) {\n    const currentUserId = createdBy || this.cls.get('user.id');\n    const base = await this.prismaService.txClient().base.findUniqueOrThrow({\n      where: { id: baseId },\n    });\n    const exist = await this.prismaService.txClient().collaborator.count({\n      where: {\n        OR: collaborators.map((collaborator) => ({\n          principalId: collaborator.principalId,\n          principalType: collaborator.principalType,\n        })),\n        resourceId: { in: [baseId, base.spaceId] },\n      },\n    });\n    // if has exist space collaborator\n    if (exist) {\n      throw new CustomHttpException(\n        'Collaborator has already existed in base',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.alreadyExistedInBase',\n          },\n        }\n      );\n    }\n\n    const res = await this.prismaService.txClient().collaborator.createMany({\n      data: collaborators.map((collaborator) => ({\n        id: getRandomString(16),\n        resourceId: baseId,\n        resourceType: CollaboratorType.Base,\n        roleName: role,\n        principalId: collaborator.principalId,\n        principalType: collaborator.principalType,\n        createdBy: currentUserId!,\n      })),\n    });\n    this.eventEmitterService.emitAsync(\n      Events.COLLABORATOR_CREATE,\n      new CollaboratorCreateEvent(base.spaceId)\n    );\n    return res;\n  }\n\n  async getSharedBase() {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    const coll = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: { in: [userId, ...(departmentIds || [])] },\n        resourceType: CollaboratorType.Base,\n      },\n      select: {\n        resourceId: true,\n        roleName: true,\n      },\n    });\n\n    if (!coll.length) {\n      return [];\n    }\n\n    const roleMap: Record<string, IRole> = {};\n    const baseIds = coll.map((c) => {\n      if (!roleMap[c.resourceId] || canManageRole(c.roleName as IRole, roleMap[c.resourceId])) {\n        roleMap[c.resourceId] = c.roleName as IRole;\n      }\n      return c.resourceId;\n    });\n    const bases = await this.prismaService.txClient().base.findMany({\n      where: {\n        id: { in: baseIds },\n        deletedTime: null,\n      },\n      include: {\n        space: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    const createdUserList = await this.prismaService.txClient().user.findMany({\n      where: { id: { in: bases.map((base) => base.createdBy) } },\n      select: { id: true, name: true, avatar: true },\n    });\n    const createdUserMap = keyBy(createdUserList, 'id');\n    return bases.map((base) => ({\n      id: base.id,\n      name: base.name,\n      role: roleMap[base.id],\n      icon: base.icon,\n      spaceId: base.spaceId,\n      spaceName: base.space?.name,\n      collaboratorType: CollaboratorType.Base,\n      lastModifiedTime: base.lastModifiedTime?.toISOString(),\n      createdTime: base.createdTime?.toISOString(),\n      createdBy: base.createdBy,\n      createdUser: {\n        ...(createdUserMap[base.createdBy] ?? {}),\n        avatar:\n          createdUserMap[base.createdBy]?.avatar &&\n          getPublicFullStorageUrl(createdUserMap[base.createdBy]?.avatar ?? ''),\n      },\n    }));\n  }\n\n  protected async validateCollaboratorUser(userIds: string[]) {\n    const users = await this.prismaService.txClient().user.findMany({\n      where: {\n        id: { in: userIds },\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n      },\n    });\n    const diffIds = difference(\n      userIds,\n      users.map((u) => u.id)\n    );\n    if (diffIds.length > 0) {\n      throw new CustomHttpException(\n        `User not found: ${diffIds.join(', ')}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.userNotFound',\n            context: { userIds: diffIds.join(', ') },\n          },\n        }\n      );\n    }\n  }\n\n  async addSpaceCollaborators(spaceId: string, collaborator: AddSpaceCollaboratorRo) {\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    await this.validateUserAddRole({\n      departmentIds,\n      userId: this.cls.get('user.id'),\n      addRole: collaborator.role,\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n    });\n    await this.validateCollaboratorUser(\n      collaborator.collaborators\n        .filter((c) => c.principalType === PrincipalType.User)\n        .map((c) => c.principalId)\n    );\n    return this.createSpaceCollaborator({\n      collaborators: collaborator.collaborators,\n      spaceId,\n      role: collaborator.role,\n      createdBy: this.cls.get('user.id'),\n    });\n  }\n\n  async addBaseCollaborators(baseId: string, collaborator: AddBaseCollaboratorRo) {\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    await this.validateUserAddRole({\n      departmentIds,\n      userId: this.cls.get('user.id'),\n      addRole: collaborator.role,\n      resourceId: baseId,\n      resourceType: CollaboratorType.Base,\n    });\n    await this.validateCollaboratorUser(\n      collaborator.collaborators\n        .filter((c) => c.principalType === PrincipalType.User)\n        .map((c) => c.principalId)\n    );\n    return this.createBaseCollaborator({\n      collaborators: collaborator.collaborators,\n      baseId,\n      role: collaborator.role,\n      createdBy: this.cls.get('user.id'),\n    });\n  }\n\n  async validateUserAddRole({\n    departmentIds,\n    userId,\n    addRole,\n    resourceId,\n    resourceType,\n  }: {\n    departmentIds?: string[];\n    userId: string;\n    addRole: IRole;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    let spaceId = resourceType === CollaboratorType.Space ? resourceId : '';\n    if (resourceType === CollaboratorType.Base) {\n      const base = await this.prismaService\n        .txClient()\n        .base.findFirstOrThrow({\n          where: {\n            id: resourceId,\n            deletedTime: null,\n          },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Base not found', HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.collaborator.baseNotFound',\n            },\n          });\n        });\n      spaceId = base.spaceId;\n    }\n    const collaborators = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: departmentIds ? { in: [...departmentIds, userId] } : userId,\n        resourceId: {\n          in: [spaceId, resourceId],\n        },\n      },\n    });\n    if (collaborators.length === 0) {\n      throw new CustomHttpException(\n        'User not found in collaborator',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.userNotFoundInCollaborator',\n          },\n        }\n      );\n    }\n    const userRole = getMaxLevelRole(collaborators);\n\n    if (userRole === addRole) {\n      return;\n    }\n    if (!canManageRole(userRole, addRole)) {\n      throw new CustomHttpException(\n        `You do not have permission to add this role collaborator: ${addRole}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.collaborator.noPermissionToAddRole',\n          },\n        }\n      );\n    }\n  }\n\n  async getUserCollaboratorsTotal(baseId: string, options?: IListBaseCollaboratorUserRo) {\n    return this.getTotalBase(baseId, options);\n  }\n\n  async getUserCollaborators(baseId: string, options?: IListBaseCollaboratorUserRo) {\n    const { skip = 0, take = 50 } = options ?? {};\n    const builder = this.knex.queryBuilder();\n    await this.getBaseCollaboratorBuilder(builder, baseId, options);\n    builder.whereNotNull('users.id');\n    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');\n    builder.offset(skip);\n    builder.limit(take);\n    builder.select({\n      id: 'users.id',\n      name: 'users.name',\n      email: 'users.email',\n      avatar: 'users.avatar',\n    });\n    const res = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<IItemBaseCollaboratorUser[]>(builder.toQuery());\n    return res.map((item) => ({\n      ...item,\n      avatar: item.avatar ? getPublicFullStorageUrl(item.avatar) : null,\n    }));\n  }\n\n  /**\n   * Build space owner context for determining display user\n   * When the creator is no longer in the space, falls back to space owner\n   */\n  async buildSpaceOwnerContext(spaceIds: string[]): Promise<{\n    validCreatorSet: Set<string>;\n    spaceOwnerMap: Map<string, string>;\n  }> {\n    if (!spaceIds.length) {\n      return { validCreatorSet: new Set(), spaceOwnerMap: new Map() };\n    }\n\n    const spaceCollaborators = await this.prismaService.collaborator.findMany({\n      where: {\n        resourceType: CollaboratorType.Space,\n        resourceId: { in: spaceIds },\n        principalType: PrincipalType.User,\n      },\n      select: { resourceId: true, principalId: true, roleName: true },\n    });\n\n    const validCreatorSet = new Set(\n      spaceCollaborators.map((c) => `${c.resourceId}:${c.principalId}`)\n    );\n\n    const spaceOwnerMap = new Map(\n      spaceCollaborators\n        .filter((c) => c.roleName === Role.Owner)\n        .map((c) => [c.resourceId, c.principalId])\n    );\n\n    return { validCreatorSet, spaceOwnerMap };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/comment/comment-open-api.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { CommentOpenApiController } from './comment-open-api.controller';\n\ndescribe('CommentOpenApiController', () => {\n  let controller: CommentOpenApiController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [CommentOpenApiController],\n    }).compile();\n\n    controller = module.get<CommentOpenApiController>(CommentOpenApiController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/comment/comment-open-api.controller.ts",
    "content": "import { Controller, Get, Post, Body, Param, Patch, Delete, Query } from '@nestjs/common';\nimport type { ICommentVo, IGetCommentListVo, ICommentSubscribeVo } from '@teable/openapi';\nimport {\n  getRecordsRoSchema,\n  createCommentRoSchema,\n  ICreateCommentRo,\n  IUpdateCommentRo,\n  updateCommentRoSchema,\n  updateCommentReactionRoSchema,\n  IUpdateCommentReactionRo,\n  getCommentListQueryRoSchema,\n  IGetCommentListQueryRo,\n  IGetRecordsRo,\n  UploadType,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { AllowAnonymous } from '../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { TqlPipe } from '../record/open-api/tql.pipe';\nimport { CommentOpenApiService } from './comment-open-api.service';\n\n@Controller('api/comment/:tableId')\n@AllowAnonymous()\nexport class CommentOpenApiController {\n  constructor(\n    private readonly commentOpenApiService: CommentOpenApiService,\n    private readonly attachmentsStorageService: AttachmentsStorageService\n  ) {}\n\n  @Get('/:recordId/count')\n  @Permissions('view|read')\n  async getRecordCommentCount(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string\n  ) {\n    return this.commentOpenApiService.getRecordCommentCount(tableId, recordId);\n  }\n\n  @Get('/count')\n  @Permissions('view|read')\n  async getTableCommentCount(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo\n  ) {\n    return this.commentOpenApiService.getTableCommentCount(tableId, query);\n  }\n\n  @Get('/:recordId/attachment/:path')\n  // eslint-disable-next-line sonarjs/no-duplicate-string\n  @Permissions('record|read')\n  async getAttachmentPresignedUrl(@Param('path') path: string) {\n    const [, token] = path.split('/');\n    const bucket = StorageAdapter.getBucket(UploadType.Comment);\n    return this.attachmentsStorageService.getPreviewUrlByPath(bucket, path, token);\n  }\n\n  // eslint-disable-next-line sonarjs/no-duplicate-string\n  @Get('/:recordId/subscribe')\n  @Permissions('record|read')\n  async getSubscribeDetail(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string\n  ): Promise<ICommentSubscribeVo | null> {\n    return this.commentOpenApiService.getSubscribeDetail(tableId, recordId);\n  }\n\n  @Post('/:recordId/subscribe')\n  @Permissions('record|read')\n  async subscribeComment(@Param('tableId') tableId: string, @Param('recordId') recordId: string) {\n    return this.commentOpenApiService.subscribeComment(tableId, recordId);\n  }\n\n  @Delete('/:recordId/subscribe')\n  @Permissions('record|read')\n  async unsubscribeComment(@Param('tableId') tableId: string, @Param('recordId') recordId: string) {\n    return this.commentOpenApiService.unsubscribeComment(tableId, recordId);\n  }\n\n  @Get('/:recordId/list')\n  @Permissions('record|read')\n  async getCommentList(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Query(new ZodValidationPipe(getCommentListQueryRoSchema))\n    getCommentListQueryRo: IGetCommentListQueryRo\n  ): Promise<IGetCommentListVo> {\n    return this.commentOpenApiService.getCommentList(tableId, recordId, getCommentListQueryRo);\n  }\n\n  @Post('/:recordId/create')\n  // eslint-disable-next-line sonarjs/no-duplicate-string\n  @Permissions('record|comment')\n  async createComment(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Body(new ZodValidationPipe(createCommentRoSchema)) createCommentRo: ICreateCommentRo\n  ) {\n    return this.commentOpenApiService.createComment(tableId, recordId, createCommentRo);\n  }\n\n  // eslint-disable-next-line sonarjs/no-duplicate-string\n  @Get('/:recordId/:commentId')\n  @Permissions('record|read')\n  async getCommentDetail(@Param('commentId') commentId: string): Promise<ICommentVo | null> {\n    return this.commentOpenApiService.getCommentDetail(commentId);\n  }\n\n  @Patch('/:recordId/:commentId')\n  @Permissions('record|comment')\n  async updateComment(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('commentId') commentId: string,\n    @Body(new ZodValidationPipe(updateCommentRoSchema)) updateCommentRo: IUpdateCommentRo\n  ) {\n    return this.commentOpenApiService.updateComment(tableId, recordId, commentId, updateCommentRo);\n  }\n\n  @Delete('/:recordId/:commentId')\n  @Permissions('record|read')\n  async deleteComment(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('commentId') commentId: string\n  ) {\n    return this.commentOpenApiService.deleteComment(tableId, recordId, commentId);\n  }\n\n  @Delete('/:recordId/:commentId/reaction')\n  @Permissions('record|comment')\n  async deleteCommentReaction(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('commentId') commentId: string,\n    @Body(new ZodValidationPipe(updateCommentReactionRoSchema)) reactionRo: IUpdateCommentReactionRo\n  ) {\n    return this.commentOpenApiService.deleteCommentReaction(\n      tableId,\n      recordId,\n      commentId,\n      reactionRo\n    );\n  }\n\n  @Patch('/:recordId/:commentId/reaction')\n  @Permissions('record|comment')\n  async updateCommentReaction(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('commentId') commentId: string,\n    @Body(new ZodValidationPipe(updateCommentReactionRoSchema)) reactionRo: IUpdateCommentReactionRo\n  ) {\n    return this.commentOpenApiService.createCommentReaction(\n      tableId,\n      recordId,\n      commentId,\n      reactionRo\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/comment/comment-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../../share-db/share-db.module';\nimport { AttachmentsStorageModule } from '../attachments/attachments-storage.module';\nimport { NotificationModule } from '../notification/notification.module';\nimport { RecordOpenApiModule } from '../record/open-api/record-open-api.module';\nimport { RecordModule } from '../record/record.module';\nimport { CommentOpenApiController } from './comment-open-api.controller';\nimport { CommentOpenApiService } from './comment-open-api.service';\n\n@Module({\n  imports: [\n    NotificationModule,\n    RecordOpenApiModule,\n    AttachmentsStorageModule,\n    RecordModule,\n    ShareDbModule,\n  ],\n  controllers: [CommentOpenApiController],\n  providers: [CommentOpenApiService],\n  exports: [CommentOpenApiService],\n})\nexport class CommentOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/comment/comment-open-api.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { ILocalization } from '@teable/core';\nimport {\n  generateCommentId,\n  getCommentChannel,\n  getTableCommentChannel,\n  HttpErrorCode,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  ICreateCommentRo,\n  ICommentVo,\n  IUpdateCommentRo,\n  IGetCommentListQueryRo,\n  ICommentContent,\n  IGetRecordsRo,\n  IParagraphCommentContent,\n  ICommentReaction,\n} from '@teable/openapi';\nimport { CommentNodeType, CommentPatchType, UploadType } from '@teable/openapi';\nimport { uniq } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../cache/cache.service';\nimport { CustomHttpException } from '../../custom.exception';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport type { IClsStore } from '../../types/cls';\nimport type { I18nPath } from '../../types/i18n.generated';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { NotificationService } from '../notification/notification.service';\nimport { RecordService } from '../record/record.service';\n\n@Injectable()\nexport class CommentOpenApiService {\n  private logger = new Logger(CommentOpenApiService.name);\n  constructor(\n    private readonly notificationService: NotificationService,\n    private readonly recordService: RecordService,\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly shareDbService: ShareDbService,\n    private readonly cacheService: CacheService,\n    private readonly attachmentsStorageService: AttachmentsStorageService\n  ) {}\n\n  private async collectionsContext(comment: ICommentContent | null) {\n    if (!comment) {\n      return {\n        imagePaths: [],\n        mentionUserIds: [],\n      };\n    }\n    const imagePaths: string[] = [];\n    const mentionUserIds: string[] = [];\n    comment.forEach((item) => {\n      if (item.type === CommentNodeType.Img) {\n        return imagePaths.push(item.path);\n      }\n      if (item.type === CommentNodeType.Paragraph) {\n        return item.children.forEach((child) => {\n          if (child.type === CommentNodeType.Mention) {\n            return mentionUserIds.push(child.value);\n          }\n        });\n      }\n    });\n    return {\n      imagePaths,\n      mentionUserIds,\n    };\n  }\n\n  private async getUserInfoMap(userIds: string[]) {\n    const res = await this.prismaService.user.findMany({\n      where: {\n        id: {\n          in: userIds,\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        avatar: true,\n      },\n    });\n    return res.reduce(\n      (acc, user) => {\n        acc[user.id] = {\n          id: user.id,\n          name: user.name,\n          avatar: user.avatar ? getPublicFullStorageUrl(user.avatar) : undefined,\n        };\n        return acc;\n      },\n      {} as Record<string, { id: string; name: string; avatar: string | undefined }>\n    );\n  }\n\n  private async getPresignedUrlMap(paths: string[]) {\n    const bucket = StorageAdapter.getBucket(UploadType.Comment);\n    const tokens = paths.map((path) => path.split('/').pop());\n    let urls: string[] = [];\n    if (tokens.length) {\n      const cacheUrls = await this.cacheService.getMany(\n        tokens.map((token) => `attachment:preview:${token}` as const)\n      );\n      urls = cacheUrls.map((url) => url?.url) as string[];\n    }\n    const presignedUrls = await Promise.all(\n      urls.map(async (url, index) => {\n        if (!url) {\n          return this.attachmentsStorageService.getPreviewUrlByPath(\n            bucket,\n            paths[index],\n            tokens[index]!\n          );\n        }\n        return url;\n      })\n    );\n    return presignedUrls.reduce(\n      (acc, url, index) => {\n        acc[paths[index]] = url;\n        return acc;\n      },\n      {} as Record<string, string>\n    );\n  }\n\n  private async additionalContentContext(\n    comment: ICommentContent | null,\n    context: {\n      imagePathMap: Record<string, string>;\n      mentionUserMap: Record<string, { id: string; name: string; avatar: string | undefined }>;\n    }\n  ): Promise<ICommentContent | null> {\n    if (!comment) {\n      return null;\n    }\n    const { imagePathMap, mentionUserMap } = context;\n    return comment.map((item) => {\n      switch (item.type) {\n        case CommentNodeType.Img:\n          return {\n            ...item,\n            url: imagePathMap[item.path],\n          };\n        case CommentNodeType.Paragraph:\n          return {\n            ...item,\n            children: item.children.map((child) => {\n              if (child.type === CommentNodeType.Mention) {\n                return {\n                  ...child,\n                  name: mentionUserMap[child.value].name,\n                  avatar: mentionUserMap[child.value].avatar,\n                };\n              }\n              return child;\n            }),\n          };\n        default:\n          throw new CustomHttpException(\n            `Invalid comment content type: ${(item as IParagraphCommentContent)?.type}`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.comment.invalidContentType',\n              },\n            }\n          );\n      }\n    });\n  }\n\n  async getCommentDetail(commentId: string): Promise<ICommentVo | null> {\n    const rawComment = await this.prismaService.comment.findFirst({\n      where: {\n        id: commentId,\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n        content: true,\n        createdBy: true,\n        createdTime: true,\n        lastModifiedTime: true,\n        deletedTime: true,\n        quoteId: true,\n        reaction: true,\n      },\n    });\n\n    if (!rawComment) {\n      return null;\n    }\n    const { reaction: rawReaction, content: rawContent, quoteId, ...rest } = rawComment;\n    const content = (rawContent ? JSON.parse(rawContent) : null) as ICommentContent;\n    const reaction = rawReaction ? (JSON.parse(rawReaction) as ICommentReaction) : [];\n    const { imagePaths, mentionUserIds } = await this.collectionsContext(content);\n    const imagePathMap = await this.getPresignedUrlMap(imagePaths);\n    const mentionUserMap = await this.getUserInfoMap(\n      Array.from(\n        new Set([...mentionUserIds, rawComment.createdBy, ...reaction.flatMap((item) => item.user)])\n      )\n    );\n    const commentContent = await this.additionalContentContext(content, {\n      imagePathMap,\n      mentionUserMap,\n    });\n\n    const fullReaction = reaction.map((item) => ({\n      reaction: item.reaction,\n      user: item.user.map((id) => mentionUserMap[id]).filter(Boolean),\n    }));\n\n    return {\n      ...rest,\n      quoteId: quoteId || undefined,\n      content: commentContent || [],\n      createdBy: mentionUserMap[rawComment.createdBy],\n      createdTime: rawComment.createdTime.toISOString(),\n      lastModifiedTime: rawComment.lastModifiedTime?.toISOString(),\n      deletedTime: rawComment.deletedTime?.toISOString(),\n      reaction: fullReaction.length ? fullReaction : null,\n    };\n  }\n\n  async getCommentList(\n    tableId: string,\n    recordId: string,\n    getCommentListQuery: IGetCommentListQueryRo\n  ) {\n    const { cursor, take = 20, direction = 'forward', includeCursor = true } = getCommentListQuery;\n\n    if (take > 1000) {\n      throw new CustomHttpException(\n        `take ${take} exceed the max count comment list count 1000`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.comment.listCountExceeded',\n          },\n        }\n      );\n    }\n\n    const takeWithDirection = direction === 'forward' ? -(take + 1) : take + 1;\n\n    const rawComments = await this.prismaService.comment.findMany({\n      where: {\n        recordId,\n        tableId,\n        deletedTime: null,\n      },\n      orderBy: [{ createdTime: 'asc' }],\n      take: takeWithDirection,\n      skip: cursor ? (includeCursor ? 0 : 1) : 0,\n      cursor: cursor ? { id: cursor } : undefined,\n      select: {\n        id: true,\n        content: true,\n        createdBy: true,\n        createdTime: true,\n        lastModifiedTime: true,\n        quoteId: true,\n        reaction: true,\n      },\n    });\n\n    const hasNextPage = rawComments.length > take;\n\n    const nextCursor = hasNextPage\n      ? direction === 'forward'\n        ? rawComments.shift()?.id\n        : rawComments.pop()?.id\n      : null;\n\n    const parsedComments = rawComments.map((comment) => ({\n      ...comment,\n      content: comment.content ? (JSON.parse(comment.content) as ICommentContent) : null,\n      reaction: comment.reaction ? (JSON.parse(comment.reaction) as ICommentReaction) : null,\n    }));\n\n    const imagePaths: Set<string> = new Set();\n    const mentionUserIds: Set<string> = new Set();\n\n    for (let i = 0; i < parsedComments.length; i++) {\n      const { content, reaction, createdBy } = parsedComments[i];\n      const context = await this.collectionsContext(content);\n      mentionUserIds.add(createdBy);\n      context.imagePaths.forEach((path) => imagePaths.add(path));\n      context.mentionUserIds.forEach((id) => mentionUserIds.add(id));\n      reaction?.forEach((item) => {\n        item.user.forEach((id) => mentionUserIds.add(id));\n      });\n    }\n    const imagePathMap = await this.getPresignedUrlMap(Array.from(imagePaths));\n    const mentionUserMap = await this.getUserInfoMap(Array.from(mentionUserIds));\n    const comments: ICommentVo[] = [];\n    for (let i = 0; i < parsedComments.length; i++) {\n      const { createdTime, lastModifiedTime, content, quoteId, reaction, ...rest } =\n        parsedComments[i];\n      const fullContent =\n        (await this.additionalContentContext(content, {\n          imagePathMap,\n          mentionUserMap,\n        })) || [];\n      const fullCreatedBy = mentionUserMap[parsedComments[i].createdBy];\n      comments.push({\n        ...rest,\n        reaction: reaction?.map((item) => ({\n          reaction: item.reaction,\n          user: item.user.map((id) => mentionUserMap[id]).filter(Boolean),\n        })),\n        quoteId: quoteId || undefined,\n        content: fullContent,\n        createdBy: fullCreatedBy,\n        lastModifiedTime: lastModifiedTime?.toISOString(),\n        createdTime: createdTime.toISOString(),\n      });\n    }\n    return {\n      comments,\n      nextCursor,\n    };\n  }\n\n  async filterCommentContent(content: ICommentContent) {\n    return content.map((item) => {\n      if (item.type === CommentNodeType.Img) {\n        const { url, ...rest } = item;\n        return rest;\n      }\n      if (item.type === CommentNodeType.Paragraph) {\n        const { children, ...rest } = item;\n        return {\n          ...rest,\n          children: children.map((child) => {\n            if (child.type === CommentNodeType.Mention) {\n              const { name, avatar, ...rest } = child;\n              return {\n                ...rest,\n              };\n            }\n            return child;\n          }),\n        };\n      }\n      return item;\n    });\n  }\n\n  async createComment(tableId: string, recordId: string, createCommentRo: ICreateCommentRo) {\n    const id = generateCommentId();\n    const content = await this.filterCommentContent(createCommentRo.content);\n    const result = await this.prismaService.comment.create({\n      data: {\n        id,\n        tableId,\n        recordId,\n        content: JSON.stringify(content),\n        createdBy: this.cls.get('user.id'),\n        quoteId: createCommentRo.quoteId,\n        lastModifiedTime: null,\n      },\n    });\n\n    await this.sendCommentNotify(tableId, recordId, id, {\n      content: result.content,\n      quoteId: result.quoteId,\n    });\n\n    this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateComment, result);\n    this.sendTableCommentPatch(tableId, recordId, CommentPatchType.CreateComment);\n\n    return {\n      ...result,\n      content: result.content ? JSON.parse(result.content) : null,\n    };\n  }\n\n  async updateComment(\n    tableId: string,\n    recordId: string,\n    commentId: string,\n    updateCommentRo: IUpdateCommentRo\n  ) {\n    const result = await this.prismaService.comment.update({\n      where: {\n        id: commentId,\n        createdBy: this.cls.get('user.id'),\n      },\n      data: {\n        content: JSON.stringify(updateCommentRo.content),\n        lastModifiedTime: new Date().toISOString(),\n      },\n    });\n\n    this.sendCommentPatch(tableId, recordId, CommentPatchType.UpdateComment, result);\n    await this.sendCommentNotify(tableId, recordId, commentId, {\n      quoteId: result.quoteId,\n      content: result.content,\n    });\n  }\n\n  async deleteComment(tableId: string, recordId: string, commentId: string) {\n    await this.prismaService.comment.update({\n      where: {\n        id: commentId,\n        createdBy: this.cls.get('user.id'),\n      },\n      data: {\n        deletedTime: new Date().toISOString(),\n      },\n    });\n\n    this.sendCommentPatch(tableId, recordId, CommentPatchType.DeleteComment, { id: commentId });\n    this.sendTableCommentPatch(tableId, recordId, CommentPatchType.DeleteComment);\n  }\n\n  async deleteCommentReaction(\n    tableId: string,\n    recordId: string,\n    commentId: string,\n    reactionRo: { reaction: string }\n  ) {\n    const commentRaw = await this.getCommentReactionById(commentId);\n    const { reaction } = reactionRo;\n    let data: ICommentReaction = [];\n\n    if (commentRaw && commentRaw.reaction) {\n      const emojis = JSON.parse(commentRaw.reaction) as NonNullable<ICommentReaction>;\n      const index = emojis.findIndex((item) => item.reaction === reaction);\n      if (index > -1) {\n        const newUser = emojis[index].user.filter((item) => item !== this.cls.get('user.id'));\n        if (newUser.length === 0) {\n          emojis.splice(index, 1);\n        } else {\n          emojis.splice(index, 1, {\n            reaction,\n            user: newUser,\n          });\n        }\n        data = [...emojis];\n      }\n    }\n\n    const result = await this.prismaService.comment.update({\n      where: {\n        id: commentId,\n      },\n      data: {\n        reaction: data.length ? JSON.stringify(data) : null,\n        lastModifiedTime: commentRaw?.lastModifiedTime,\n      },\n    });\n\n    this.sendCommentPatch(tableId, recordId, CommentPatchType.DeleteReaction, result);\n  }\n\n  async createCommentReaction(\n    tableId: string,\n    recordId: string,\n    commentId: string,\n    reactionRo: { reaction: string }\n  ) {\n    const commentRaw = await this.getCommentReactionById(commentId);\n    const { reaction } = reactionRo;\n    let data: ICommentVo['reaction'];\n\n    if (commentRaw && commentRaw.reaction) {\n      const emojis = JSON.parse(commentRaw.reaction) as NonNullable<ICommentVo['reaction']>;\n      const index = emojis.findIndex((item) => item.reaction === reaction);\n      if (index > -1) {\n        emojis.splice(index, 1, {\n          reaction,\n          user: uniq([...emojis[index].user, this.cls.get('user.id')]),\n        });\n      } else {\n        emojis.push({\n          reaction,\n          user: [this.cls.get('user.id')],\n        });\n      }\n      data = [...emojis];\n    } else {\n      data = [\n        {\n          reaction,\n          user: [this.cls.get('user.id')],\n        },\n      ];\n    }\n\n    const result = await this.prismaService.comment.update({\n      where: {\n        id: commentId,\n      },\n      data: {\n        reaction: JSON.stringify(data),\n        lastModifiedTime: commentRaw?.lastModifiedTime,\n      },\n    });\n\n    await this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateReaction, result);\n    await this.sendCommentNotify(tableId, recordId, commentId, {\n      quoteId: result.quoteId,\n      content: result.content,\n    });\n  }\n\n  async getSubscribeDetail(tableId: string, recordId: string) {\n    return this.prismaService.commentSubscription.findUnique({\n      where: {\n        // eslint-disable-next-line\n        tableId_recordId: {\n          tableId,\n          recordId,\n        },\n      },\n      select: {\n        tableId: true,\n        recordId: true,\n        createdBy: true,\n      },\n    });\n  }\n\n  async subscribeComment(tableId: string, recordId: string) {\n    await this.prismaService.commentSubscription.create({\n      data: {\n        tableId,\n        recordId,\n        createdBy: this.cls.get('user.id'),\n      },\n    });\n  }\n\n  async unsubscribeComment(tableId: string, recordId: string) {\n    await this.prismaService.commentSubscription.delete({\n      where: {\n        // eslint-disable-next-line\n        tableId_recordId: {\n          tableId,\n          recordId,\n        },\n      },\n    });\n  }\n\n  async getTableCommentCount(tableId: string, query: IGetRecordsRo) {\n    const docResult = await this.recordService.getDocIdsByQuery(tableId, query, true);\n    const recordsId = docResult.ids;\n\n    const result = await this.prismaService.comment.groupBy({\n      by: ['recordId'],\n      where: {\n        recordId: {\n          in: recordsId,\n        },\n        tableId,\n        deletedTime: null,\n      },\n      _count: {\n        ['recordId']: true,\n      },\n    });\n\n    return result.map(({ _count: { recordId: count }, recordId }) => ({\n      recordId,\n      count,\n    }));\n  }\n\n  async getRecordCommentCount(tableId: string, recordId: string) {\n    const result = await this.prismaService.comment.count({\n      where: {\n        tableId,\n        recordId,\n        deletedTime: null,\n      },\n    });\n\n    return {\n      count: result,\n    };\n  }\n\n  private async getCommentReactionById(commentId: string) {\n    return await this.prismaService.comment.findFirst({\n      where: {\n        id: commentId,\n      },\n      select: {\n        reaction: true,\n        lastModifiedTime: true,\n      },\n    });\n  }\n\n  private async sendCommentNotify(\n    tableId: string,\n    recordId: string,\n    commentId: string,\n    notifyVo: { quoteId: string | null; content: string | null }\n  ) {\n    const { quoteId, content } = notifyVo;\n    const { id: fromUserId, name: fromUserName } = this.cls.get('user');\n    const relativeUsers: string[] = [];\n\n    if (quoteId) {\n      const { createdBy: quoteCommentCreator } =\n        (await this.prismaService.comment.findUnique({\n          where: {\n            id: quoteId,\n          },\n          select: {\n            createdBy: true,\n          },\n        })) || {};\n      quoteCommentCreator && relativeUsers.push(quoteCommentCreator);\n    }\n\n    const mentionUsers = this.getMentionUserByContent(content);\n\n    if (mentionUsers.length) {\n      relativeUsers.push(...mentionUsers);\n    }\n\n    const { baseId, name: tableName } =\n      (await this.prismaService.tableMeta.findFirst({\n        where: {\n          id: tableId,\n        },\n        select: {\n          baseId: true,\n          name: true,\n        },\n      })) || {};\n\n    const { id: fieldId } =\n      (await this.prismaService.field.findFirst({\n        where: {\n          tableId,\n          isPrimary: true,\n        },\n        select: {\n          id: true,\n        },\n      })) || {};\n\n    if (!baseId || !fieldId) {\n      return;\n    }\n\n    const { name: baseName } = await this.prismaService.base.findUniqueOrThrow({\n      where: {\n        id: baseId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    const recordName = await this.recordService.getCellValue(tableId, recordId, fieldId);\n\n    const notifyUsers = await this.prismaService.commentSubscription.findMany({\n      where: {\n        tableId,\n        recordId,\n      },\n      select: {\n        createdBy: true,\n      },\n    });\n\n    const subscribeUsersIds = Array.from(\n      new Set([...notifyUsers.map(({ createdBy }) => createdBy), ...relativeUsers])\n    ).filter((userId) => userId !== fromUserId);\n\n    const message: ILocalization<I18nPath> = {\n      i18nKey: 'common.email.templates.notify.recordComment.message',\n      context: { fromUserName, recordName: recordName ?? '', tableName, baseName },\n    };\n\n    subscribeUsersIds.forEach((userId) => {\n      this.notificationService.sendCommentNotify({\n        baseId,\n        tableId,\n        recordId,\n        commentId,\n        toUserId: userId,\n        message,\n        fromUserId,\n      });\n    });\n  }\n\n  private getMentionUserByContent(commentContentRaw: string | null) {\n    if (!commentContentRaw) {\n      return [];\n    }\n\n    const commentContent = JSON.parse(commentContentRaw) as ICommentContent;\n\n    return commentContent\n      .filter(\n        // so strange that infer automatically error\n        (comment): comment is IParagraphCommentContent => comment.type === CommentNodeType.Paragraph\n      )\n      .flatMap((paragraphNode) => paragraphNode.children)\n      .filter((lineNode) => lineNode.type === CommentNodeType.Mention)\n      .map((mentionNode) => mentionNode.value) as string[];\n  }\n\n  private createCommentPresence(tableId: string, recordId: string) {\n    const channel = getCommentChannel(tableId, recordId);\n    const presence = this.shareDbService.connect().getPresence(channel);\n    return presence.create(channel);\n  }\n\n  private async sendCommentPatch(\n    tableId: string,\n    recordId: string,\n    type: CommentPatchType,\n    data: Record<string, unknown>\n  ) {\n    const localPresence = this.createCommentPresence(tableId, recordId);\n    const commentId = data.id as string;\n    let finalData: ICommentVo | null | { id: string } = null;\n\n    if (\n      [\n        CommentPatchType.CreateComment,\n        CommentPatchType.CreateReaction,\n        CommentPatchType.UpdateComment,\n        CommentPatchType.DeleteReaction,\n      ].includes(type)\n    ) {\n      finalData = await this.getCommentDetail(commentId);\n    }\n\n    if (type === CommentPatchType.DeleteComment) {\n      finalData = {\n        ...finalData,\n        id: commentId,\n      };\n    }\n\n    localPresence.submit(\n      {\n        type: type,\n        data: finalData,\n      },\n      (error) => {\n        error && this.logger.error('Comment patch presence error: ', error);\n      }\n    );\n  }\n\n  private sendTableCommentPatch(tableId: string, recordId: string, type: CommentPatchType) {\n    const channel = getTableCommentChannel(tableId);\n    const presence = this.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create(channel);\n\n    localPresence.submit(\n      {\n        type,\n        data: {\n          recordId,\n        },\n      },\n      (error) => {\n        error && this.logger.error('Comment patch presence error: ', error);\n      }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/dashboard/dashboard.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { DashboardController } from './dashboard.controller';\n\ndescribe('DashboardController', () => {\n  let controller: DashboardController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [DashboardController],\n    }).compile();\n\n    controller = module.get<DashboardController>(DashboardController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';\nimport {\n  createDashboardRoSchema,\n  dashboardInstallPluginRoSchema,\n  ICreateDashboardRo,\n  IRenameDashboardRo,\n  IUpdateLayoutDashboardRo,\n  renameDashboardRoSchema,\n  updateLayoutDashboardRoSchema,\n  IDashboardInstallPluginRo,\n  dashboardPluginUpdateStorageRoSchema,\n  IDashboardPluginUpdateStorageRo,\n  duplicateDashboardRoSchema,\n  IDuplicateDashboardRo,\n  duplicateDashboardInstalledPluginRoSchema,\n  IDuplicateDashboardInstalledPluginRo,\n} from '@teable/openapi';\nimport type {\n  ICreateDashboardVo,\n  IGetDashboardVo,\n  IRenameDashboardVo,\n  IUpdateLayoutDashboardVo,\n  IGetDashboardListVo,\n  IDashboardInstallPluginVo,\n  IDashboardPluginUpdateStorageVo,\n  IGetDashboardInstallPluginVo,\n} from '@teable/openapi';\nimport { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../event-emitter/events';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { DashboardService } from './dashboard.service';\n\n@Controller('api/base/:baseId/dashboard')\nexport class DashboardController {\n  constructor(private readonly dashboardService: DashboardService) {}\n\n  @Get()\n  @Permissions('base|read')\n  getDashboard(@Param('baseId') baseId: string): Promise<IGetDashboardListVo> {\n    return this.dashboardService.getDashboard(baseId);\n  }\n\n  @Get(':id')\n  @Permissions('base|read')\n  getDashboardById(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string\n  ): Promise<IGetDashboardVo> {\n    return this.dashboardService.getDashboardById(baseId, id);\n  }\n\n  @Post()\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.DASHBOARD_CREATE)\n  createDashboard(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(createDashboardRoSchema)) ro: ICreateDashboardRo\n  ): Promise<ICreateDashboardVo> {\n    return this.dashboardService.createDashboard(baseId, ro);\n  }\n\n  @Patch(':id/rename')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.DASHBOARD_UPDATE)\n  updateDashboard(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo\n  ): Promise<IRenameDashboardVo> {\n    return this.dashboardService.renameDashboard(baseId, id, ro.name);\n  }\n\n  @Patch(':id/layout')\n  @Permissions('base|update')\n  updateLayout(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Body(new ZodValidationPipe(updateLayoutDashboardRoSchema)) ro: IUpdateLayoutDashboardRo\n  ): Promise<IUpdateLayoutDashboardVo> {\n    return this.dashboardService.updateLayout(baseId, id, ro.layout);\n  }\n\n  @Delete(':id')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.DASHBOARD_DELETE)\n  deleteDashboard(@Param('baseId') baseId: string, @Param('id') id: string): Promise<void> {\n    return this.dashboardService.deleteDashboard(baseId, id);\n  }\n\n  @Post(':id/duplicate')\n  @Permissions('base|update')\n  @EmitControllerEvent(Events.DASHBOARD_CREATE)\n  duplicateDashboard(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Body(new ZodValidationPipe(duplicateDashboardRoSchema))\n    duplicateDashboardRo: IDuplicateDashboardRo\n  ): Promise<{ id: string; name: string }> {\n    return this.dashboardService.duplicateDashboard(baseId, id, duplicateDashboardRo);\n  }\n\n  @Post(':id/plugin/:pluginInstallId/duplicate')\n  @Permissions('base|update')\n  duplicateDashboardInstalledPlugin(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(duplicateDashboardInstalledPluginRoSchema))\n    duplicateDashboardInstalledPluginRo: IDuplicateDashboardInstalledPluginRo\n  ): Promise<{ id: string; name: string }> {\n    return this.dashboardService.duplicateDashboardInstalledPlugin(\n      baseId,\n      id,\n      pluginInstallId,\n      duplicateDashboardInstalledPluginRo\n    );\n  }\n\n  @Post(':id/plugin')\n  @Permissions('base|update')\n  installPlugin(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Body(new ZodValidationPipe(dashboardInstallPluginRoSchema)) ro: IDashboardInstallPluginRo\n  ): Promise<IDashboardInstallPluginVo> {\n    return this.dashboardService.installPlugin(baseId, id, ro);\n  }\n\n  @Delete(':id/plugin/:pluginInstallId')\n  @Permissions('base|update')\n  removePlugin(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<void> {\n    return this.dashboardService.removePlugin(baseId, id, pluginInstallId);\n  }\n\n  @Patch(':id/plugin/:pluginInstallId/rename')\n  @Permissions('base|update')\n  renamePlugin(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo\n  ): Promise<IRenameDashboardVo> {\n    return this.dashboardService.renamePlugin(baseId, id, pluginInstallId, ro.name);\n  }\n\n  @Patch(':id/plugin/:pluginInstallId/update-storage')\n  @Permissions('base|update')\n  updatePluginStorage(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(dashboardPluginUpdateStorageRoSchema))\n    ro: IDashboardPluginUpdateStorageRo\n  ): Promise<IDashboardPluginUpdateStorageVo> {\n    return this.dashboardService.updatePluginStorage(baseId, id, pluginInstallId, ro.storage);\n  }\n\n  @Get(':id/plugin/:pluginInstallId')\n  @Permissions('base|read')\n  getPluginInstall(\n    @Param('baseId') baseId: string,\n    @Param('id') id: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<IGetDashboardInstallPluginVo> {\n    return this.dashboardService.getPluginInstall(baseId, id, pluginInstallId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/dashboard/dashboard.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { BaseModule } from '../base/base.module';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { DashboardController } from './dashboard.controller';\nimport { DashboardService } from './dashboard.service';\n\n@Module({\n  imports: [CollaboratorModule, BaseModule],\n  providers: [DashboardService],\n  controllers: [DashboardController],\n  exports: [DashboardService],\n})\nexport class DashboardModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/dashboard/dashboard.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { DashboardModule } from './dashboard.module';\nimport { DashboardService } from './dashboard.service';\n\ndescribe('DashboardService', () => {\n  let service: DashboardService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, DashboardModule],\n    }).compile();\n\n    service = module.get<DashboardService>(DashboardService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/dashboard/dashboard.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type { IBaseRole } from '@teable/core';\nimport {\n  generateDashboardId,\n  generatePluginInstallId,\n  getUniqName,\n  HttpErrorCode,\n  Role,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PluginPosition, PluginStatus, PrincipalType } from '@teable/openapi';\nimport type {\n  IBaseJson,\n  ICreateDashboardRo,\n  IDashboardInstallPluginRo,\n  IDuplicateDashboardInstalledPluginRo,\n  IDuplicateDashboardRo,\n  IGetDashboardInstallPluginVo,\n  IGetDashboardListVo,\n  IGetDashboardVo,\n  IUpdateLayoutDashboardRo,\n  IDashboardLayout,\n  IDashboardPluginItem,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { BaseImportService } from '../base/base-import.service';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\n\n@Injectable()\nexport class DashboardService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly baseImportService: BaseImportService\n  ) {}\n\n  async getDashboard(baseId: string): Promise<IGetDashboardListVo> {\n    return this.prismaService.dashboard.findMany({\n      where: {\n        baseId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n    });\n  }\n\n  async getDashboardById(baseId: string, id: string): Promise<IGetDashboardVo> {\n    const dashboard = await this.prismaService.dashboard\n      .findFirstOrThrow({\n        where: {\n          id,\n          baseId,\n        },\n        select: {\n          id: true,\n          name: true,\n          layout: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Dashboard not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.dashboard.notFound',\n          },\n        });\n      });\n\n    const plugins = await this.prismaService.pluginInstall.findMany({\n      where: {\n        positionId: dashboard.id,\n        position: PluginPosition.Dashboard,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        plugin: {\n          select: {\n            url: true,\n          },\n        },\n      },\n    });\n\n    return {\n      ...dashboard,\n      layout: dashboard.layout ? JSON.parse(dashboard.layout) : undefined,\n      pluginMap: plugins.reduce(\n        (acc, plugin) => {\n          acc[plugin.id] = {\n            id: plugin.pluginId,\n            pluginInstallId: plugin.id,\n            name: plugin.name,\n            url: plugin.plugin.url ?? undefined,\n          };\n          return acc;\n        },\n        {} as Record<string, IDashboardPluginItem>\n      ),\n    };\n  }\n\n  async createDashboard(baseId: string, dashboard: ICreateDashboardRo) {\n    const userId = this.cls.get('user.id');\n    return this.prismaService.txClient().dashboard.create({\n      data: {\n        id: generateDashboardId(),\n        baseId,\n        name: dashboard.name,\n        createdBy: userId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  async renameDashboard(baseId: string, id: string, name: string) {\n    return this.prismaService\n      .txClient()\n      .dashboard.update({\n        where: {\n          baseId,\n          id,\n        },\n        data: {\n          name,\n        },\n        select: {\n          id: true,\n          name: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Dashboard not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.dashboard.notFound',\n          },\n        });\n      });\n  }\n\n  async updateLayout(baseId: string, id: string, layout: IUpdateLayoutDashboardRo['layout']) {\n    const ro = await this.prismaService.dashboard\n      .update({\n        where: {\n          baseId,\n          id,\n        },\n        data: {\n          layout: JSON.stringify(layout),\n        },\n        select: {\n          id: true,\n          name: true,\n          layout: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Dashboard not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.dashboard.notFound',\n          },\n        });\n      });\n    return {\n      ...ro,\n      layout: ro.layout ? JSON.parse(ro.layout) : undefined,\n    };\n  }\n\n  async deleteDashboard(baseId: string, id: string) {\n    await this.prismaService\n      .txClient()\n      .dashboard.delete({\n        where: {\n          baseId,\n          id,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Dashboard not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.dashboard.notFound',\n          },\n        });\n      });\n  }\n\n  private async validatePluginPublished(_baseId: string, pluginId: string) {\n    return this.prismaService.plugin\n      .findFirstOrThrow({\n        where: {\n          id: pluginId,\n          OR: [\n            {\n              status: PluginStatus.Published,\n            },\n            {\n              status: { not: PluginStatus.Published },\n              createdBy: this.cls.get('user.id'),\n            },\n          ],\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        });\n      });\n  }\n\n  async installPlugin(baseId: string, id: string, ro: IDashboardInstallPluginRo) {\n    const userId = this.cls.get('user.id');\n    await this.validatePluginPublished(baseId, ro.pluginId);\n\n    return this.prismaService.$tx(async () => {\n      const newInstallPlugin = await this.prismaService.txClient().pluginInstall.create({\n        data: {\n          id: generatePluginInstallId(),\n          baseId,\n          positionId: id,\n          position: PluginPosition.Dashboard,\n          name: ro.name,\n          pluginId: ro.pluginId,\n          createdBy: userId,\n        },\n        select: {\n          id: true,\n          name: true,\n          pluginId: true,\n          plugin: {\n            select: {\n              pluginUser: true,\n            },\n          },\n        },\n      });\n      if (newInstallPlugin.plugin.pluginUser) {\n        // invite pluginUser to base\n        const exist = await this.prismaService.txClient().collaborator.count({\n          where: {\n            principalId: newInstallPlugin.plugin.pluginUser,\n            principalType: PrincipalType.User,\n            resourceId: baseId,\n            resourceType: CollaboratorType.Base,\n          },\n        });\n\n        if (!exist) {\n          await this.collaboratorService.createBaseCollaborator({\n            collaborators: [\n              {\n                principalId: newInstallPlugin.plugin.pluginUser,\n                principalType: PrincipalType.User,\n              },\n            ],\n            baseId,\n            role: Role.Owner as IBaseRole,\n          });\n        }\n      }\n\n      const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({\n        where: {\n          id,\n          baseId,\n        },\n        select: {\n          layout: true,\n        },\n      });\n      const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : [];\n      layout.push({\n        pluginInstallId: newInstallPlugin.id,\n        x: (layout.length * 2) % 12,\n        y: Number.MAX_SAFE_INTEGER, // puts it at the bottom\n        w: 2,\n        h: 2,\n      });\n      await this.prismaService.txClient().dashboard.update({\n        where: {\n          id,\n        },\n        data: {\n          layout: JSON.stringify(layout),\n        },\n      });\n      return {\n        id,\n        pluginId: newInstallPlugin.pluginId,\n        pluginInstallId: newInstallPlugin.id,\n        name: ro.name,\n      };\n    });\n  }\n\n  private async validateDashboard(baseId: string, dashboardId: string) {\n    await this.prismaService\n      .txClient()\n      .dashboard.findFirstOrThrow({\n        where: {\n          baseId,\n          id: dashboardId,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Dashboard not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.dashboard.notFound',\n          },\n        });\n      });\n  }\n\n  async removePlugin(baseId: string, dashboardId: string, pluginInstallId: string) {\n    return this.prismaService.$tx(async () => {\n      await this.prismaService\n        .txClient()\n        .pluginInstall.delete({\n          where: {\n            id: pluginInstallId,\n            baseId,\n            positionId: dashboardId,\n            plugin: {\n              OR: [\n                {\n                  status: PluginStatus.Published,\n                },\n                {\n                  status: { not: PluginStatus.Published },\n                  createdBy: this.cls.get('user.id'),\n                },\n              ],\n            },\n          },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.plugin.notFound',\n            },\n          });\n        });\n      const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({\n        where: {\n          id: dashboardId,\n          baseId,\n        },\n        select: {\n          layout: true,\n        },\n      });\n      const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : [];\n      const index = layout.findIndex((item) => item.pluginInstallId === pluginInstallId);\n      if (index !== -1) {\n        layout.splice(index, 1);\n        await this.prismaService.txClient().dashboard.update({\n          where: {\n            id: dashboardId,\n          },\n          data: {\n            layout: JSON.stringify(layout),\n          },\n        });\n      }\n    });\n  }\n\n  private async validateAndGetPluginInstall(pluginInstallId: string) {\n    return this.prismaService\n      .txClient()\n      .pluginInstall.findFirstOrThrow({\n        where: {\n          id: pluginInstallId,\n          plugin: {\n            OR: [\n              {\n                status: PluginStatus.Published,\n              },\n              {\n                status: { not: PluginStatus.Published },\n                createdBy: this.cls.get('user.id'),\n              },\n            ],\n          },\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        });\n      });\n  }\n\n  async renamePlugin(baseId: string, dashboardId: string, pluginInstallId: string, name: string) {\n    return this.prismaService.$tx(async () => {\n      await this.validateDashboard(baseId, dashboardId);\n      const plugin = await this.validateAndGetPluginInstall(pluginInstallId);\n      await this.prismaService.txClient().pluginInstall.update({\n        where: {\n          id: pluginInstallId,\n        },\n        data: {\n          name,\n        },\n      });\n      return {\n        id: plugin.pluginId,\n        pluginInstallId,\n        name,\n      };\n    });\n  }\n\n  async updatePluginStorage(\n    baseId: string,\n    dashboardId: string,\n    pluginInstallId: string,\n    storage?: Record<string, unknown>\n  ) {\n    return this.prismaService.$tx(async () => {\n      await this.validateDashboard(baseId, dashboardId);\n      await this.validateAndGetPluginInstall(pluginInstallId);\n      const res = await this.prismaService.txClient().pluginInstall.update({\n        where: {\n          id: pluginInstallId,\n        },\n        data: {\n          storage: storage ? JSON.stringify(storage) : null,\n        },\n      });\n      return {\n        baseId,\n        dashboardId,\n        pluginInstallId: res.id,\n        storage: res.storage ? JSON.parse(res.storage) : undefined,\n      };\n    });\n  }\n\n  async getPluginInstall(\n    baseId: string,\n    dashboardId: string,\n    pluginInstallId: string\n  ): Promise<IGetDashboardInstallPluginVo> {\n    await this.validateDashboard(baseId, dashboardId);\n    const plugin = await this.validateAndGetPluginInstall(pluginInstallId);\n    return {\n      name: plugin.name,\n      baseId: plugin.baseId,\n      pluginId: plugin.pluginId,\n      pluginInstallId: plugin.id,\n      storage: plugin.storage ? JSON.parse(plugin.storage) : undefined,\n    };\n  }\n\n  async duplicateDashboard(\n    baseId: string,\n    dashboardId: string,\n    duplicateDashboardRo: IDuplicateDashboardRo\n  ) {\n    const { name } = duplicateDashboardRo;\n    const dashboard = (await this.prismaService.txClient().dashboard.findFirstOrThrow({\n      where: {\n        baseId,\n        id: dashboardId,\n      },\n      select: {\n        id: true,\n        name: true,\n        layout: true,\n      },\n    })) as IBaseJson['plugins'][PluginPosition.Dashboard][number];\n\n    const installedPlugins = await this.prismaService.txClient().pluginInstall.findMany({\n      where: {\n        baseId,\n        positionId: dashboardId,\n        position: PluginPosition.Dashboard,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        storage: true,\n        position: true,\n        positionId: true,\n      },\n    });\n\n    dashboard.pluginInstall = installedPlugins.map((plugin) => ({\n      ...plugin,\n      position: PluginPosition.Dashboard,\n      storage: plugin.storage ? JSON.parse(plugin.storage) : {},\n    }));\n\n    dashboard.layout = dashboard.layout ? JSON.parse(dashboard.layout) : undefined;\n\n    const dashboardList = await this.prismaService.txClient().dashboard.findMany({\n      where: {\n        baseId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    const newName = getUniqName(\n      name ?? dashboard.name,\n      dashboardList.map((item) => item.name)\n    );\n\n    dashboard.name = newName;\n\n    return this.prismaService.$tx(async () => {\n      const { dashboardIdMap } = await this.baseImportService.createDashboard(\n        baseId,\n        [dashboard],\n        {},\n        {}\n      );\n\n      const newDashboardId = dashboardIdMap[dashboardId];\n\n      return {\n        id: newDashboardId,\n        name: newName,\n      };\n    });\n  }\n\n  async duplicateDashboardInstalledPlugin(\n    baseId: string,\n    dashboardId: string,\n    pluginInstallId: string,\n    duplicateDashboardInstalledPluginRo: IDuplicateDashboardInstalledPluginRo\n  ) {\n    return this.prismaService.$tx(async () => {\n      const { name } = duplicateDashboardInstalledPluginRo;\n      const installedPlugins = await this.prismaService.txClient().pluginInstall.findFirstOrThrow({\n        where: {\n          baseId,\n          id: pluginInstallId,\n          position: PluginPosition.Dashboard,\n        },\n      });\n      const names = await this.prismaService.txClient().pluginInstall.findMany({\n        where: {\n          baseId,\n          positionId: dashboardId,\n          position: PluginPosition.Dashboard,\n        },\n        select: {\n          name: true,\n        },\n      });\n\n      const newName = getUniqName(\n        name ?? installedPlugins.name,\n        names.map((item) => item.name)\n      );\n\n      const newPluginInstallId = generatePluginInstallId();\n\n      await this.prismaService.txClient().pluginInstall.create({\n        data: {\n          ...installedPlugins,\n          id: newPluginInstallId,\n          name: newName,\n        },\n      });\n\n      const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({\n        where: {\n          baseId,\n          id: dashboardId,\n        },\n        select: {\n          layout: true,\n        },\n      });\n\n      const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : [];\n      const sourceLayout = layout.find((item) => item.pluginInstallId === pluginInstallId);\n      layout.push({\n        pluginInstallId: newPluginInstallId,\n        x: (layout.length * 2) % 12,\n        y: Number.MAX_SAFE_INTEGER, // puts it at the bottom\n        w: sourceLayout?.w || 2,\n        h: sourceLayout?.h || 2,\n      });\n\n      await this.prismaService.txClient().dashboard.update({\n        where: {\n          id: dashboardId,\n        },\n        data: {\n          layout: JSON.stringify(layout),\n        },\n      });\n\n      return {\n        id: newPluginInstallId,\n        name: newName,\n      };\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/data-loader.module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { DataLoaderService } from './data-loader.service';\nimport { FieldLoaderService } from './resource/field-loader.service';\nimport { TableLoaderService } from './resource/table-loader.service';\nimport { ViewLoaderService } from './resource/view-loader.service';\n\n@Global()\n@Module({\n  providers: [DataLoaderService, TableLoaderService, FieldLoaderService, ViewLoaderService],\n  exports: [DataLoaderService],\n})\nexport class DataLoaderModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/data-loader.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldLoaderService } from './resource/field-loader.service';\nimport { TableLoaderService } from './resource/table-loader.service';\n// import { ViewLoaderService } from './resource/view-loader.service';\n\n@Injectable()\nexport class DataLoaderService {\n  constructor(\n    readonly field: FieldLoaderService,\n    readonly table: TableLoaderService\n    // readonly view: ViewLoaderService\n  ) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/resource/field-loader.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport type { IFieldLoaderData, IFieldLoaderItem } from '../../../types/data-loader';\nimport { TableCommonLoader } from './table-common-loader';\n\n@Injectable()\nexport class FieldLoaderService extends TableCommonLoader<IFieldLoaderItem> {\n  cacheSet = 0;\n  loadCount = 0;\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super({\n      filterDataByParentId: (tableId: string) => this.getFieldsInCache(tableId),\n      getLoaderData: () => this.cls.get('dataLoaderCache.fieldData'),\n      setLoaderData: (data: IFieldLoaderData) => this.cls.set('dataLoaderCache.fieldData', data),\n      findManyByParentId: <K extends keyof IFieldLoaderItem>(\n        tableId: string,\n        keys?: Partial<Record<K, IFieldLoaderItem[K][]>>\n      ) => {\n        this.cacheSet++;\n        return this.prismaService.txClient().field.findMany({\n          where: {\n            tableId,\n            deletedTime: null,\n            ...(keys\n              ? Object.keys(keys).reduce(\n                  (acc, kStr) => {\n                    const key = kStr as K;\n                    const value = keys[key];\n                    if (value) {\n                      if (value.length === 1) {\n                        acc[key] = value[0];\n                      } else {\n                        acc[key] = { in: value };\n                      }\n                    }\n                    return acc;\n                  },\n                  {} as Partial<Record<K, IFieldLoaderItem[K] | { in: IFieldLoaderItem[K][] }>>\n                )\n              : {}),\n          },\n        });\n      },\n      findByIds: (fieldIds: string[]) =>\n        this.prismaService\n          .txClient()\n          .field.findMany({ where: { id: { in: fieldIds }, deletedTime: null } })\n          .then((fields) => {\n            this.cacheSet++;\n            return fields;\n          }),\n      clear: () => this.cls.set('dataLoaderCache.fieldData', undefined),\n      isEnable: () => cls.get('dataLoaderCache.cacheKeys')?.includes('field'),\n    });\n  }\n\n  private getFieldsInCache(tableId: string): IFieldLoaderItem[] {\n    const fieldMap = this.cls.get('dataLoaderCache.fieldData.dataMap');\n    if (!fieldMap?.size) {\n      return [];\n    }\n    return Array.from(fieldMap.values()).filter((field) => field.tableId === tableId);\n  }\n\n  private logStat() {\n    if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') {\n      return;\n    }\n\n    const cacheHits = this.loadCount - this.cacheSet;\n    const hitRate = this.loadCount > 0 ? ((cacheHits / this.loadCount) * 100).toFixed(1) : '0.0';\n\n    console.log(\n      `[FieldLoader] 📊 loads: ${this.loadCount} | db queries: ${this.cacheSet} | cache hits: ${cacheHits} | hit rate: ${hitRate}%`\n    );\n  }\n\n  invalidateTables(tableIds: string | string[]) {\n    if (!this.cls.isActive() || !this.isEnable?.()) {\n      return;\n    }\n\n    const ids = (Array.isArray(tableIds) ? tableIds : [tableIds]).filter(Boolean);\n    if (!ids.length) {\n      return;\n    }\n\n    const loaderData = this.cls.get('dataLoaderCache.fieldData');\n    if (!loaderData) {\n      return;\n    }\n\n    const { dataMap, fullParentIds } = loaderData;\n\n    if (fullParentIds?.length) {\n      loaderData.fullParentIds = fullParentIds.filter((parentId) => !ids.includes(parentId));\n    }\n\n    if (dataMap?.size) {\n      const tableIdSet = new Set(ids);\n      for (const [fieldId, field] of dataMap.entries()) {\n        if (field?.tableId && tableIdSet.has(field.tableId)) {\n          dataMap.delete(fieldId);\n        }\n      }\n    }\n\n    this.cls.set('dataLoaderCache.fieldData', loaderData);\n  }\n\n  resetStat() {\n    this.cacheSet = 0;\n    this.loadCount = 0;\n  }\n\n  override async load(\n    tableId: string,\n    keys?: Partial<Record<keyof IFieldLoaderItem, IFieldLoaderItem[keyof IFieldLoaderItem][]>>\n  ): Promise<IFieldLoaderItem[]> {\n    this.loadCount++;\n    const result = await super.load(tableId, keys);\n    this.logStat();\n    return result;\n  }\n\n  override async loadByIds(ids: string[]): Promise<IFieldLoaderItem[]> {\n    this.loadCount++;\n    const result = await super.loadByIds(ids);\n    this.logStat();\n    return result;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/resource/table-common-loader.ts",
    "content": "import { isEmpty } from 'lodash';\nimport type {\n  IFieldLoaderItem,\n  ITableLoaderItem,\n  IViewLoaderItem,\n} from '../../../types/data-loader';\n\ntype IDataLoaderDataItem = IViewLoaderItem | ITableLoaderItem | IFieldLoaderItem;\n\ninterface ITableCommonLoaderArgs<T extends IDataLoaderDataItem> {\n  filterDataByParentId: (parentId: string) => T[];\n  getLoaderData: () =>\n    | {\n        fullParentIds?: string[];\n        dataMap: Map<string, T>;\n      }\n    | undefined;\n  setLoaderData: ({\n    fullParentIds,\n    dataMap,\n  }: {\n    fullParentIds?: string[];\n    dataMap: Map<string, T>;\n  }) => void;\n  findManyByParentId: <K extends keyof T>(\n    parentId: string,\n    keys?: Partial<Record<K, T[K][]>>\n  ) => Promise<T[]>;\n  findByIds: (ids: string[]) => Promise<T[]>;\n  clear: () => void;\n  isEnable?: () => boolean | undefined;\n}\n\nexport class TableCommonLoader<T extends IDataLoaderDataItem> {\n  private readonly filterDataByParentId: ITableCommonLoaderArgs<T>['filterDataByParentId'];\n  private readonly getLoaderData: ITableCommonLoaderArgs<T>['getLoaderData'];\n  private readonly setLoaderData: ITableCommonLoaderArgs<T>['setLoaderData'];\n  private readonly findManyByParentId: ITableCommonLoaderArgs<T>['findManyByParentId'];\n  private readonly findByIds: ITableCommonLoaderArgs<T>['findByIds'];\n  readonly clear: ITableCommonLoaderArgs<T>['clear'];\n  readonly isEnable: ITableCommonLoaderArgs<T>['isEnable'];\n\n  constructor({\n    filterDataByParentId,\n    getLoaderData,\n    setLoaderData,\n    findManyByParentId,\n    findByIds,\n    clear,\n    isEnable,\n  }: ITableCommonLoaderArgs<T>) {\n    this.filterDataByParentId = filterDataByParentId;\n    this.getLoaderData = getLoaderData;\n    this.setLoaderData = setLoaderData;\n    this.findManyByParentId = findManyByParentId;\n    this.findByIds = findByIds;\n    this.clear = clear;\n    this.isEnable = isEnable;\n  }\n\n  private async sortByOrder(dataArray: T[]) {\n    if (!dataArray.length) {\n      return [];\n    }\n    return dataArray.sort((a, b) => a.order - b.order);\n  }\n\n  private async getData(parentId: string) {\n    const { fullParentIds, dataMap = new Map() } = this.getLoaderData() ?? {};\n    if (fullParentIds?.includes(parentId)) {\n      return this.sortByOrder(this.filterDataByParentId(parentId));\n    }\n\n    const newData = await this.findManyByParentId(parentId);\n\n    newData.forEach((item) => {\n      dataMap.set(item.id, item);\n    });\n\n    this.setLoaderData({\n      dataMap,\n      fullParentIds: [...(fullParentIds ?? []), parentId],\n    });\n    return this.sortByOrder(newData);\n  }\n\n  private filterByKeys<K extends keyof T>(data: T[], keys?: Partial<Record<K, T[K][]>>) {\n    if (isEmpty(keys)) {\n      return data;\n    }\n\n    return data.filter((item) => {\n      return Object.entries(keys).every(([key, values]) => {\n        if (values === undefined) {\n          return true;\n        }\n        if (values && (values as T[K][]).length === 0) {\n          return false;\n        }\n        return (values as T[K][])?.includes(item[key as K]);\n      });\n    });\n  }\n\n  async load<K extends keyof T>(parentId: string, keys?: Partial<Record<K, T[K][]>>): Promise<T[]> {\n    if (!this.isEnable?.()) {\n      return this.findManyByParentId(parentId, keys);\n    }\n    const data = await this.getData(parentId);\n    return this.filterByKeys(data, keys);\n  }\n\n  async loadByIds(ids: string[]): Promise<T[]> {\n    if (!this.isEnable?.()) {\n      return this.findByIds(ids);\n    }\n    const loaderData = this.getLoaderData();\n    const { dataMap = new Map() } = loaderData ?? {};\n\n    const cachedData: T[] = [];\n    const notCachedDataIds: string[] = [];\n    ids.forEach((id) => {\n      const data = dataMap.get(id);\n      if (data) {\n        cachedData.push(data);\n      } else {\n        notCachedDataIds.push(id);\n      }\n    });\n    if (notCachedDataIds.length) {\n      const newData = await this.findByIds(notCachedDataIds);\n      newData.forEach((data) => {\n        dataMap.set(data.id, data);\n      });\n      this.setLoaderData({\n        ...loaderData,\n        dataMap,\n      });\n      return ids.map((id) => dataMap.get(id)).filter(Boolean) as T[];\n    }\n    return cachedData;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/resource/table-loader.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport type { ITableLoaderData, ITableLoaderItem } from '../../../types/data-loader';\nimport { TableCommonLoader } from './table-common-loader';\n\n@Injectable()\nexport class TableLoaderService extends TableCommonLoader<ITableLoaderItem> {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super({\n      filterDataByParentId: (baseId: string) => this.filterTablesByParentId(baseId),\n      getLoaderData: () => this.cls.get('dataLoaderCache.tableData'),\n      setLoaderData: (data: ITableLoaderData) => this.cls.set('dataLoaderCache.tableData', data),\n      findManyByParentId: <K extends keyof ITableLoaderItem>(\n        baseId: string,\n        keys?: Partial<Record<K, ITableLoaderItem[K][]>>\n      ) =>\n        this.prismaService.txClient().tableMeta.findMany({\n          where: { baseId, deletedTime: null },\n          ...(keys\n            ? Object.keys(keys).reduce(\n                (acc, kStr) => {\n                  const key = kStr as K;\n                  const value = keys[key];\n                  if (value && value.length > 0) {\n                    if (value.length === 1) {\n                      acc[key] = value[0];\n                    } else {\n                      acc[key] = { in: value };\n                    }\n                  }\n                  return acc;\n                },\n                {} as Partial<Record<K, ITableLoaderItem[K] | { in: ITableLoaderItem[K][] }>>\n              )\n            : {}),\n        }),\n      findByIds: (tableIds: string[]) =>\n        this.prismaService\n          .txClient()\n          .tableMeta.findMany({ where: { id: { in: tableIds }, deletedTime: null } }),\n      clear: () => this.cls.set('dataLoaderCache.tableData', undefined),\n      isEnable: () => cls.get('dataLoaderCache.cacheKeys')?.includes('table'),\n    });\n  }\n\n  private filterTablesByParentId(baseId: string) {\n    const tableMap = this.cls.get('dataLoaderCache.tableData.dataMap');\n    if (!tableMap?.size) {\n      return [];\n    }\n    return Array.from(tableMap.values()).filter((table) => table.baseId === baseId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/resource/utils.ts",
    "content": "import { isEmpty } from 'lodash';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const filterByKeys = <T extends Record<any, any>>(\n  fields: T[],\n  keys?: Partial<Record<keyof T, T[keyof T][]>>\n): T[] => {\n  if (isEmpty(keys)) {\n    return fields;\n  }\n\n  return fields.filter((field) => {\n    return Object.entries(keys).every(([key, values]) => {\n      return values?.includes(field[key]);\n    });\n  });\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/data-loader/resource/view-loader.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport type { IViewLoaderData, IViewLoaderItem } from '../../../types/data-loader';\nimport { TableCommonLoader } from './table-common-loader';\n\n@Injectable()\nexport class ViewLoaderService extends TableCommonLoader<IViewLoaderItem> {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    super({\n      filterDataByParentId: (tableId: string) => this.getViewsInCache(tableId),\n      getLoaderData: () => this.cls.get('dataLoaderCache.viewData'),\n      setLoaderData: (data: IViewLoaderData) => this.cls.set('dataLoaderCache.viewData', data),\n      findManyByParentId: <K extends keyof IViewLoaderItem>(\n        tableId: string,\n        keys?: Partial<Record<K, IViewLoaderItem[K][]>>\n      ) =>\n        this.prismaService.txClient().view.findMany({\n          where: { tableId, deletedTime: null },\n          ...(keys\n            ? Object.keys(keys).reduce(\n                (acc, kStr) => {\n                  const key = kStr as K;\n                  const value = keys[key];\n                  if (value && value.length > 0) {\n                    if (value.length === 1) {\n                      acc[key] = value[0];\n                    } else {\n                      acc[key] = { in: value };\n                    }\n                  }\n                  return acc;\n                },\n                {} as Partial<Record<K, IViewLoaderItem[K] | { in: IViewLoaderItem[K][] }>>\n              )\n            : {}),\n        }),\n      findByIds: (viewIds: string[]) =>\n        this.prismaService\n          .txClient()\n          .view.findMany({ where: { id: { in: viewIds }, deletedTime: null } }),\n      clear: () => this.cls.set('dataLoaderCache.viewData', undefined),\n      isEnable: () => cls.get('dataLoaderCache.cacheKeys')?.includes('view'),\n    });\n  }\n\n  private getViewsInCache(tableId: string): IViewLoaderItem[] {\n    const viewMap = this.cls.get('dataLoaderCache.viewData.dataMap');\n    if (!viewMap?.size) {\n      return [];\n    }\n    return Array.from(viewMap.values()).filter((view) => view.tableId === tableId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/database-view/database-view.interface.ts",
    "content": "import type { TableDomain } from '@teable/core';\n\nexport interface IDatabaseView {\n  createView(table: TableDomain): Promise<void>;\n  // Recreate view definition safely. For Postgres uses MV swap; SQLite uses regular view replacement\n  recreateView(table: TableDomain): Promise<void>;\n  dropView(tableId: string): Promise<void>;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/database-view/database-view.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { RecordQueryBuilderModule } from '../record/query-builder';\nimport { TableDomainQueryModule } from '../table-domain';\nimport { DatabaseViewService } from './database-view.service';\n\n@Module({\n  imports: [RecordQueryBuilderModule, TableDomainQueryModule, CalculationModule],\n  providers: [DbProvider, DatabaseViewService],\n})\nexport class DatabaseViewModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/database-view/database-view.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { TableDomain } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { ReferenceService } from '../calculation/reference.service';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder';\nimport type { IDatabaseView } from './database-view.interface';\n\n@Injectable()\nexport class DatabaseViewService implements IDatabaseView {\n  constructor(\n    @InjectDbProvider()\n    private readonly dbProvider: IDbProvider,\n    @InjectRecordQueryBuilder()\n    private readonly recordQueryBuilderService: IRecordQueryBuilder,\n    private readonly prisma: PrismaService,\n    private readonly referenceService: ReferenceService\n  ) {}\n\n  public async createView(table: TableDomain) {\n    const { qb } = await this.recordQueryBuilderService.prepareView(table.dbTableName, {\n      tableIdOrDbTableName: table.id,\n    });\n    const sqls = this.dbProvider.createDatabaseView(table, qb, { materialized: true });\n    await this.prisma.$transaction(async (tx) => {\n      for (const sql of sqls) {\n        await tx.$executeRawUnsafe(sql);\n      }\n      const viewName = this.dbProvider.generateDatabaseViewName(table.id);\n      await tx.tableMeta.update({\n        where: { id: table.id },\n        data: { dbViewName: viewName },\n      });\n\n      const refresh = this.dbProvider.refreshDatabaseView(table.id, { concurrently: false });\n      if (refresh) {\n        await tx.$executeRawUnsafe(refresh);\n      }\n    });\n    // persist view name to table meta\n  }\n\n  public async recreateView(table: TableDomain) {\n    const { qb } = await this.recordQueryBuilderService.prepareView(table.dbTableName, {\n      tableIdOrDbTableName: table.id,\n    });\n\n    const sqls = this.dbProvider.recreateDatabaseView(table, qb);\n    await this.prisma.$transaction(sqls.map((s) => this.prisma.$executeRawUnsafe(s)));\n  }\n\n  public async dropView(tableId: string) {\n    const sqls = this.dbProvider.dropDatabaseView(tableId);\n    for (const sql of sqls) {\n      await this.prisma.$executeRawUnsafe(sql);\n    }\n    // clear persisted view name\n    await this.prisma.tableMeta.update({\n      where: { id: tableId },\n      data: { dbViewName: null },\n    });\n  }\n\n  public async refreshView(tableId: string) {\n    const sql = this.dbProvider.refreshDatabaseView(tableId, { concurrently: true });\n    if (sql) {\n      await this.prisma.$executeRawUnsafe(sql);\n    }\n  }\n\n  public async refreshViewsByFieldIds(fieldIds: string[]) {\n    if (!fieldIds?.length) return;\n    const tableIds = await this.referenceService.getRelatedTableIdsByFieldIds(fieldIds);\n    for (const tableId of tableIds) {\n      const sql = this.dbProvider.refreshDatabaseView(tableId, { concurrently: true });\n      if (sql) {\n        await this.prisma.$executeRawUnsafe(sql);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/metrics/export-metrics.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ExportMetricsService } from './export-metrics.service';\nimport { ExportTracingService } from './export-tracing.service';\n\n@Module({\n  providers: [ExportMetricsService, ExportTracingService],\n  exports: [ExportMetricsService, ExportTracingService],\n})\nexport class ExportMetricsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/metrics/export-metrics.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { metrics } from '@opentelemetry/api';\n\n@Injectable()\nexport class ExportMetricsService {\n  private readonly meter = metrics.getMeter('teable-observability');\n\n  private readonly exportTotal = this.meter.createCounter('data.export.total', {\n    description: 'Total number of export tasks',\n  });\n  private readonly exportDuration = this.meter.createHistogram('data.export.duration', {\n    description: 'Export task duration in milliseconds',\n    unit: 'ms',\n    advice: {\n      explicitBucketBoundaries: [\n        1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000, 180000, 300000,\n      ],\n    },\n  });\n  private readonly exportErrors = this.meter.createCounter('data.export.errors', {\n    description: 'Total number of export errors',\n  });\n\n  recordExportStart(format: string): void {\n    this.exportTotal.add(1, { format });\n  }\n\n  recordExportComplete(attrs: { format: string; durationMs: number }): void {\n    this.exportDuration.record(attrs.durationMs, { format: attrs.format });\n  }\n\n  recordExportError(attrs: { format: string; errorType: string }): void {\n    this.exportErrors.add(1, { format: attrs.format, error_type: attrs.errorType });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/metrics/export-tracing.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { BaseTracingService } from '../../../tracing/base-tracing.service';\n\n@Injectable()\nexport class ExportTracingService extends BaseTracingService {\n  setExportAttributes(attrs: { rows: number }): void {\n    this.withActiveSpan((span) => {\n      span.setAttribute('data.export.rows', attrs.rows);\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/open-api/export-open-api.controller.ts",
    "content": "import { Controller, Get, UseGuards, Param, Res, Query } from '@nestjs/common';\nimport { type IExportCsvRo, exportCsvRoSchema } from '@teable/openapi';\nimport { Response } from 'express';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { PermissionGuard } from '../../auth/guard/permission.guard';\nimport { ExportOpenApiService } from './export-open-api.service';\n\n@Controller('api/export')\n@UseGuards(PermissionGuard)\nexport class ExportOpenApiController {\n  constructor(private readonly exportOpenService: ExportOpenApiService) {}\n  @Get(':tableId')\n  @Permissions('table|export', 'view|read')\n  async exportCsvFromTable(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(exportCsvRoSchema)) query: IExportCsvRo,\n    @Res({ passthrough: true }) response: Response\n  ): Promise<void> {\n    return await this.exportOpenService.exportCsvFromTable(response, tableId, query);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/open-api/export-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FieldModule } from '../../field/field.module';\nimport { RecordModule } from '../../record/record.module';\nimport { ExportMetricsModule } from '../metrics/export-metrics.module';\nimport { ExportOpenApiController } from './export-open-api.controller';\nimport { ExportOpenApiService } from './export-open-api.service';\n\n@Module({\n  imports: [RecordModule, FieldModule, ExportMetricsModule],\n  controllers: [ExportOpenApiController],\n  providers: [ExportOpenApiService],\n  exports: [ExportOpenApiService],\n})\nexport class ExportOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/export/open-api/export-open-api.service.ts",
    "content": "import { Readable } from 'stream';\nimport { Injectable, Logger, Optional } from '@nestjs/common';\nimport type { IAttachmentCellValue, IFieldVo } from '@teable/core';\nimport { FieldType, HttpErrorCode, ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IExportCsvRo } from '@teable/openapi';\nimport type { Response } from 'express';\nimport { keyBy, sortBy } from 'lodash';\nimport Papa from 'papaparse';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { FieldService } from '../../field/field.service';\nimport { createFieldInstanceByVo } from '../../field/model/factory';\nimport { RecordService } from '../../record/record.service';\nimport { ExportMetricsService } from '../metrics/export-metrics.service';\nimport { ExportTracingService } from '../metrics/export-tracing.service';\n\n@Injectable()\nexport class ExportOpenApiService {\n  private logger = new Logger(ExportOpenApiService.name);\n  constructor(\n    private readonly fieldService: FieldService,\n    private readonly recordService: RecordService,\n    private readonly prismaService: PrismaService,\n    @Optional() private readonly exportMetrics?: ExportMetricsService,\n    @Optional() private readonly exportTracing?: ExportTracingService\n  ) {}\n  async exportCsvFromTable(response: Response, tableId: string, query?: IExportCsvRo) {\n    const exportStartTime = Date.now();\n    this.exportMetrics?.recordExportStart('csv');\n    const {\n      viewId,\n      filter: queryFilter,\n      orderBy: queryOrderBy,\n      groupBy: queryGroupBy,\n      projection,\n      ignoreViewQuery,\n      columnMeta: queryColumnMeta,\n    } = query ?? {};\n    let count = 0;\n    let isOver = false;\n    const csvStream = new Readable({\n      // eslint-disable-next-line @typescript-eslint/no-empty-function\n      read() {},\n    });\n    let viewRaw = null;\n\n    const tableRaw = await this.prismaService.tableMeta\n      .findUnique({\n        where: { id: tableId, deletedTime: null },\n        select: { name: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Table not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n\n    if (viewId && !ignoreViewQuery) {\n      viewRaw = await this.prismaService.view\n        .findUnique({\n          where: {\n            id: viewId,\n            tableId,\n            deletedTime: null,\n          },\n          select: {\n            id: true,\n            type: true,\n            name: true,\n          },\n        })\n        .catch((e) => {\n          this.logger.error(e?.message, `ExportCsv: ${tableId}`);\n        });\n\n      if (viewRaw?.type !== ViewType.Grid) {\n        throw new CustomHttpException(\n          `${viewRaw?.type} is not support to export`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.export.notSupportViewType',\n              context: {\n                viewType: viewRaw?.type,\n              },\n            },\n          }\n        );\n      }\n    }\n\n    const fileName = tableRaw?.name\n      ? encodeURIComponent(`${tableRaw?.name}${viewRaw?.name ? `_${viewRaw.name}` : ''}`)\n      : 'export';\n\n    response.setHeader('Content-Type', 'text/csv; charset=utf-8');\n    response.setHeader('Content-Disposition', `attachment; filename=${fileName}.csv`);\n\n    csvStream.pipe(response);\n\n    // set headers as first row\n    const viewIdForQuery = ignoreViewQuery ? undefined : viewRaw?.id;\n    let allFields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: viewIdForQuery,\n      filterHidden: Boolean(viewIdForQuery),\n    });\n\n    // Sort fields based on:\n    // 1. If ignoreViewQuery is true and queryColumnMeta is provided, sort by queryColumnMeta order\n    // 2. If viewId is provided (and ignoreViewQuery is false), getFieldsByQuery already sorted by view columnMeta\n    // 3. Otherwise, keep table's original field order\n    allFields = this.sortFieldsByColumnMeta(allFields, ignoreViewQuery, queryColumnMeta);\n\n    const fieldsMap = keyBy(allFields, 'id');\n    // Filter by projection but keep the original field order from view/table\n    const headers = allFields.filter((field) => !projection || projection.includes(field.id));\n    const headerData = Papa.unparse([headers.map((h) => h.name)]);\n\n    const projectionNames = projection\n      ? (projection.map((p) => fieldsMap[p]?.name).filter((p) => Boolean(p)) as string[])\n      : undefined;\n\n    const headersInfoMap = new Map(\n      headers.map((h, index) => [\n        h.name,\n        {\n          index,\n          type: h.type,\n          fieldInstance: createFieldInstanceByVo(h),\n        },\n      ])\n    );\n\n    // add BOM to make sure the csv file can be opened correctly in excel\n    csvStream.push('\\uFEFF');\n    csvStream.push(headerData);\n\n    try {\n      while (!isOver) {\n        const { records } = await this.recordService.getRecords(\n          tableId,\n          {\n            take: 1000,\n            skip: count,\n            viewId: viewIdForQuery,\n            filter: queryFilter,\n            orderBy: queryOrderBy,\n            groupBy: queryGroupBy,\n            ignoreViewQuery,\n            projection: projectionNames,\n          },\n          true\n        );\n\n        if (records.length === 0) {\n          isOver = true;\n          // end the stream\n          csvStream.push(null);\n          this.exportTracing?.setExportAttributes({ rows: count });\n          this.exportMetrics?.recordExportComplete({\n            format: 'csv',\n            durationMs: Date.now() - exportStartTime,\n          });\n          break;\n        }\n\n        const csvData = Papa.unparse(\n          records.map((r) => {\n            const { fields } = r;\n            const recordsArr = Array.from({ length: headers.length });\n            for (const [key, value] of Object.entries(fields)) {\n              const { index: hIndex, type, fieldInstance } = headersInfoMap.get(key) ?? {};\n              if (hIndex !== undefined && type !== undefined) {\n                const finalValue =\n                  type === FieldType.Attachment\n                    ? (value as IAttachmentCellValue)\n                        .map((v) => `${v.name} ${v.presignedUrl}`)\n                        .join(',')\n                    : fieldInstance?.cellValue2String(value);\n                recordsArr[hIndex] = finalValue;\n              }\n            }\n            return recordsArr;\n          })\n        );\n\n        csvStream.push('\\r\\n');\n        csvStream.push(csvData);\n        count += records.length;\n      }\n    } catch (e) {\n      csvStream.push('\\r\\n');\n      csvStream.push(`Export fail reason:, ${(e as Error)?.message}`);\n      this.logger.error((e as Error)?.message, `ExportCsv: ${tableId}`);\n      this.exportMetrics?.recordExportError({\n        format: 'csv',\n        errorType: (e as Error)?.name ?? 'unknown',\n      });\n    }\n  }\n\n  /**\n   * Sort fields based on columnMeta order\n   * @param fields - The fields to sort\n   * @param ignoreViewQuery - Whether to ignore view query\n   * @param queryColumnMeta - The columnMeta from query params for custom sorting\n   * @returns Sorted fields\n   */\n  private sortFieldsByColumnMeta(\n    fields: IFieldVo[],\n    ignoreViewQuery?: boolean,\n    queryColumnMeta?: Record<string, { order: number }>\n  ): IFieldVo[] {\n    // If ignoreViewQuery is true and queryColumnMeta is provided, sort by queryColumnMeta order\n    if (ignoreViewQuery && queryColumnMeta) {\n      return sortBy(fields, (field) => queryColumnMeta[field.id]?.order ?? Infinity);\n    }\n    // Otherwise, keep the order from getFieldsByQuery (either view columnMeta order or table original order)\n    return fields;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/constant.ts",
    "content": "import { FieldType } from '@teable/core';\n\nexport const ID_FIELD_NAME = '__id';\nexport const VERSION_FIELD_NAME = '__version';\nexport const AUTO_NUMBER_FIELD_NAME = '__auto_number';\nexport const CREATED_TIME_FIELD_NAME = '__created_time';\nexport const LAST_MODIFIED_TIME_FIELD_NAME = '__last_modified_time';\nexport const CREATED_BY_FIELD_NAME = '__created_by';\nexport const LAST_MODIFIED_BY_FIELD_NAME = '__last_modified_by';\n\n/* eslint-disable @typescript-eslint/naming-convention */\nexport interface IVisualTableDefaultField {\n  __id: string;\n  __version: number;\n  __auto_number: number;\n  __created_time: Date;\n  __last_modified_time?: Date;\n  __created_by: string;\n  __last_modified_by?: string;\n}\n/* eslint-enable @typescript-eslint/naming-convention */\n\nexport const preservedDbFieldNames = new Set([\n  ID_FIELD_NAME,\n  VERSION_FIELD_NAME,\n  AUTO_NUMBER_FIELD_NAME,\n  CREATED_TIME_FIELD_NAME,\n  LAST_MODIFIED_TIME_FIELD_NAME,\n  CREATED_BY_FIELD_NAME,\n  LAST_MODIFIED_BY_FIELD_NAME,\n]);\n\nexport const systemDbFieldNames = new Set([\n  ID_FIELD_NAME,\n  AUTO_NUMBER_FIELD_NAME,\n  CREATED_TIME_FIELD_NAME,\n  LAST_MODIFIED_TIME_FIELD_NAME,\n  CREATED_BY_FIELD_NAME,\n  LAST_MODIFIED_BY_FIELD_NAME,\n]);\n\nexport const systemFieldTypes = new Set([\n  FieldType.AutoNumber,\n  FieldType.CreatedTime,\n  FieldType.LastModifiedTime,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n]);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-calculate.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { CollaboratorModule } from '../../collaborator/collaborator.module';\nimport { ComputedModule } from '../../record/computed/computed.module';\nimport { TableIndexService } from '../../table/table-index.service';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { ViewModule } from '../../view/view.module';\nimport { FieldModule } from '../field.module';\nimport { FieldConvertingLinkService } from './field-converting-link.service';\nimport { FieldConvertingService } from './field-converting.service';\nimport { FieldCreatingService } from './field-creating.service';\nimport { FieldDeletingService } from './field-deleting.service';\nimport { FieldSupplementService } from './field-supplement.service';\nimport { FieldViewSyncService } from './field-view-sync.service';\nimport { FormulaFieldService } from './formula-field.service';\nimport { LinkFieldQueryService } from './link-field-query.service';\n\n@Module({\n  imports: [\n    FieldModule,\n    CalculationModule,\n    ViewModule,\n    CollaboratorModule,\n    TableDomainQueryModule,\n    ComputedModule,\n  ],\n  providers: [\n    DbProvider,\n    FieldDeletingService,\n    FieldCreatingService,\n    FieldConvertingService,\n    FieldSupplementService,\n    FieldConvertingLinkService,\n    TableIndexService,\n    FieldViewSyncService,\n    FormulaFieldService,\n    LinkFieldQueryService,\n  ],\n  exports: [\n    FieldDeletingService,\n    FieldCreatingService,\n    FieldConvertingService,\n    FieldSupplementService,\n    FieldViewSyncService,\n    FieldConvertingLinkService,\n    FormulaFieldService,\n    LinkFieldQueryService,\n  ],\n})\nexport class FieldCalculateModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { FieldOpenApiModule } from '../open-api/field-open-api.module';\nimport { FieldConvertingLinkService } from './field-converting-link.service';\n\ndescribe('FieldConvertingLinkService', () => {\n  let service: FieldConvertingLinkService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldOpenApiModule],\n    }).compile();\n\n    service = module.get<FieldConvertingLinkService>(FieldConvertingLinkService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { FieldAction, ILinkCellValue, ILinkFieldOptions, IOtOperation } from '@teable/core';\nimport {\n  Relationship,\n  RelationshipRevert,\n  FieldType,\n  RecordOpBuilder,\n  isMultiValueLink,\n  PRIMARY_SUPPORTED_TYPES,\n  HttpErrorCode,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { isEqual } from 'lodash';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { DropColumnOperationType } from '../../../db-provider/drop-database-column-query/drop-database-column-field-visitor.interface';\nimport { FieldCalculationService } from '../../calculation/field-calculation.service';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport { TableDomainQueryService } from '../../table-domain/table-domain-query.service';\nimport type { IFieldInstance } from '../model/factory';\nimport {\n  createFieldInstanceByVo,\n  createFieldInstanceByRaw,\n  rawField2FieldObj,\n} from '../model/factory';\nimport type { LinkFieldDto } from '../model/field-dto/link-field.dto';\nimport { FieldCreatingService } from './field-creating.service';\nimport { FieldDeletingService } from './field-deleting.service';\nimport { FieldSupplementService } from './field-supplement.service';\n\nconst isLink = (field: IFieldInstance): field is LinkFieldDto =>\n  !field.isLookup && field.type === FieldType.Link;\n\n@Injectable()\nexport class FieldConvertingLinkService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly fieldDeletingService: FieldDeletingService,\n    private readonly fieldCreatingService: FieldCreatingService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly fieldCalculationService: FieldCalculationService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  private async symLinkRelationshipChange(newField: LinkFieldDto) {\n    // field options has been modified but symmetricFieldId not change\n    const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: newField.options.symmetricFieldId, deletedTime: null },\n    });\n\n    const newFieldVo = rawField2FieldObj(fieldRaw);\n\n    const options = newFieldVo.options as ILinkFieldOptions;\n    options.relationship = RelationshipRevert[newField.options.relationship];\n    options.fkHostTableName = newField.options.fkHostTableName;\n    options.selfKeyName = newField.options.foreignKeyName;\n    options.foreignKeyName = newField.options.selfKeyName;\n    newFieldVo.isMultipleCellValue = isMultiValueLink(options.relationship) || undefined;\n\n    // return modified changes in foreignTable\n    return {\n      tableId: newField.options.foreignTableId,\n      newField: createFieldInstanceByVo(newFieldVo),\n      oldField: createFieldInstanceByRaw(fieldRaw),\n    };\n  }\n\n  private async alterSymmetricFieldChange(\n    tableId: string,\n    oldField: LinkFieldDto,\n    newField: LinkFieldDto\n  ) {\n    // noting change\n    if (\n      (!newField.options.symmetricFieldId && !oldField.options.symmetricFieldId) ||\n      newField.options.symmetricFieldId === oldField.options.symmetricFieldId\n    ) {\n      return;\n    }\n\n    // delete old symmetric link\n    if (oldField.options.symmetricFieldId) {\n      const { foreignTableId, symmetricFieldId } = oldField.options;\n      const symField = await this.fieldDeletingService.getField(foreignTableId, symmetricFieldId);\n      symField &&\n        (await this.fieldDeletingService.deleteFieldItem(\n          foreignTableId,\n          symField,\n          DropColumnOperationType.DELETE_SYMMETRIC_FIELD\n        ));\n    }\n\n    // create new symmetric link\n    if (newField.options.symmetricFieldId) {\n      const symmetricField = await this.fieldSupplementService.generateSymmetricField(\n        tableId,\n        newField\n      );\n      await this.fieldCreatingService.createFieldItem(\n        newField.options.foreignTableId,\n        symmetricField,\n        undefined,\n        true\n      );\n    }\n  }\n\n  private async linkOptionsChange(tableId: string, newField: LinkFieldDto, oldField: LinkFieldDto) {\n    if (\n      newField.options.foreignTableId === oldField.options.foreignTableId &&\n      newField.options.relationship === oldField.options.relationship &&\n      newField.options.symmetricFieldId === oldField.options.symmetricFieldId\n    ) {\n      return;\n    }\n\n    // change link table, delete link in old table and create link in new table\n    if (newField.options.foreignTableId !== oldField.options.foreignTableId) {\n      // update current field reference\n      await this.prismaService.txClient().reference.deleteMany({\n        where: {\n          toFieldId: newField.id,\n        },\n      });\n      await this.fieldSupplementService.createReference(newField);\n      await this.fieldSupplementService.cleanForeignKey(oldField.options);\n      await this.fieldDeletingService.cleanLookupRollupRef(tableId, newField.id);\n\n      // Create foreign key using dbProvider (handled by visitor)\n      await this.createForeignKeyUsingDbProvider(tableId, newField);\n      // change relationship, alter foreign key\n    } else if (newField.options.relationship !== oldField.options.relationship) {\n      await this.fieldSupplementService.cleanForeignKey(oldField.options);\n      await this.createForeignKeyUsingDbProvider(tableId, newField);\n      // eslint-disable-next-line sonarjs/no-duplicated-branches\n    } else if (newField.options.isOneWay !== oldField.options.isOneWay) {\n      // one-way <-> two-way switch within the same relationship type\n      // drop previous FK/junction and recreate according to new isOneWay\n      await this.fieldSupplementService.cleanForeignKey(oldField.options);\n      await this.createForeignKeyUsingDbProvider(tableId, newField);\n    }\n\n    // change one-way to two-way or two-way to one-way (symmetricFieldId add or delete, symmetricFieldId can not be change)\n    await this.alterSymmetricFieldChange(tableId, oldField, newField);\n  }\n\n  private async otherToLink(tableId: string, newField: LinkFieldDto) {\n    await this.createForeignKeyUsingDbProvider(tableId, newField);\n    await this.fieldSupplementService.createReference(newField);\n    if (newField.options.symmetricFieldId) {\n      const symmetricField = await this.fieldSupplementService.generateSymmetricField(\n        tableId,\n        newField\n      );\n      await this.fieldCreatingService.createFieldItem(\n        newField.options.foreignTableId,\n        symmetricField,\n        undefined,\n        true\n      );\n    }\n  }\n\n  private async createForeignKeyUsingDbProvider(tableId: string, field: LinkFieldDto) {\n    const { foreignTableId } = field.options;\n\n    // Get table information for both current and foreign tables\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: [tableId, foreignTableId] } },\n      select: { id: true, dbTableName: true },\n    });\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n\n    const currentTable = tables.find((table) => table.id === tableId);\n    const foreignTable = tables.find((table) => table.id === foreignTableId);\n\n    if (!currentTable || !foreignTable) {\n      throw new Error(`Table not found: ${tableId} or ${foreignTableId}`);\n    }\n\n    // Create table name mapping for visitor\n    const tableNameMap = new Map<string, string>();\n    tableNameMap.set(tableId, currentTable.dbTableName);\n    tableNameMap.set(foreignTableId, foreignTable.dbTableName);\n\n    const createColumnQueries = this.dbProvider.createColumnSchema(\n      currentTable.dbTableName,\n      field,\n      tableDomain,\n      false,\n      tableId,\n      tableNameMap,\n      false, // This is not a symmetric field in converting context\n      true // Base column is already ensured during modify; create only FK/junction here\n    );\n    // Execute all queries (FK/junction creation, order columns, etc.)\n    for (const query of createColumnQueries) {\n      await this.prismaService.txClient().$executeRawUnsafe(query);\n    }\n  }\n\n  private async linkToOther(tableId: string, oldField: LinkFieldDto) {\n    await this.fieldDeletingService.cleanLookupRollupRef(tableId, oldField.id);\n    await this.fieldSupplementService.cleanForeignKey(oldField.options);\n\n    if (oldField.options.symmetricFieldId) {\n      const { foreignTableId, symmetricFieldId } = oldField.options;\n      const symField = await this.fieldDeletingService.getField(foreignTableId, symmetricFieldId);\n      symField &&\n        (await this.fieldDeletingService.deleteFieldItem(\n          foreignTableId,\n          symField,\n          DropColumnOperationType.DELETE_SYMMETRIC_FIELD\n        ));\n    }\n  }\n\n  /**\n   * 1. switch link table\n   * 2. other field to link field\n   * 3. link field to other field\n   */\n  async deleteOrCreateSupplementLink(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    if (isLink(newField) && isLink(oldField) && !isEqual(newField.options, oldField.options)) {\n      return this.linkOptionsChange(tableId, newField, oldField);\n    }\n\n    if (!isLink(newField) && isLink(oldField)) {\n      return this.linkToOther(tableId, oldField);\n    }\n\n    if (isLink(newField) && !isLink(oldField)) {\n      return this.otherToLink(tableId, newField);\n    }\n  }\n\n  async analysisReference(oldField: IFieldInstance) {\n    if (!isLink(oldField)) {\n      return;\n    }\n\n    // self and symmetricLinkField outgoing reference\n    const linkFieldIds = [oldField.id];\n    if (oldField.options.symmetricFieldId) {\n      linkFieldIds.push(oldField.options.symmetricFieldId);\n    }\n\n    // LookupField and Rollup field witch linkFieldId is self and symmetricLinkField, should also treat as reference\n    const lookupRelatedFields = await this.prismaService.txClient().field.findMany({\n      where: {\n        lookupLinkedFieldId: { in: linkFieldIds },\n        deletedTime: null,\n      },\n      select: { id: true },\n    });\n\n    const references: string[] = lookupRelatedFields.map((field) => field.id);\n\n    const referencesRaw = await this.prismaService.txClient().reference.findMany({\n      where: {\n        fromFieldId: { in: linkFieldIds },\n      },\n      select: {\n        toFieldId: true,\n      },\n    });\n\n    return references.concat(referencesRaw.map((r) => r.toFieldId));\n  }\n\n  async analysisSupplementLink(newField: IFieldInstance, oldField: IFieldInstance) {\n    if (\n      isLink(newField) &&\n      isLink(oldField) &&\n      !isEqual(newField.options, oldField.options) &&\n      newField.options.foreignTableId === oldField.options.foreignTableId &&\n      newField.options.symmetricFieldId &&\n      newField.options.symmetricFieldId === oldField.options.symmetricFieldId &&\n      newField.options.relationship !== oldField.options.relationship\n    ) {\n      return this.symLinkRelationshipChange(newField);\n    }\n  }\n\n  private async getRecords(tableId: string, field: IFieldInstance) {\n    const { dbTableName, name: tableName } = await this.prismaService\n      .txClient()\n      .tableMeta.findFirstOrThrow({\n        where: { id: tableId },\n        select: { dbTableName: true, name: true },\n      });\n\n    const result = await this.fieldCalculationService.getRecordsBatchByFields(\n      {\n        [dbTableName]: [field],\n      },\n      { [dbTableName]: tableId }\n    );\n    const records = result[dbTableName];\n    if (!records) {\n      throw new CustomHttpException(\n        `Can't find recordMap for tableId: ${tableId} and fieldId: ${field.id}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.recordMapNotFound',\n            context: { tableName, fieldName: field.name },\n          },\n        }\n      );\n    }\n\n    return records;\n  }\n\n  async oneWayToTwoWay(oldField: LinkFieldDto, newField: LinkFieldDto) {\n    // Resolve table ids\n    const { foreignTableId, relationship, symmetricFieldId } = newField.options;\n    const sourceFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: oldField.id, deletedTime: null },\n      select: { tableId: true },\n    });\n    const sourceTableId = sourceFieldRaw.tableId;\n\n    // Fetch existing source records and derive mapping directly from cell values\n    const sourceRecords = await this.getRecords(sourceTableId, oldField);\n\n    const targetOpsMap: { [recordId: string]: IOtOperation[] } = {};\n    const sourceOpsMap: { [recordId: string]: IOtOperation[] } = {};\n\n    for (const record of sourceRecords) {\n      const sourceId = record.id;\n      const cell = record.fields[oldField.id] as ILinkCellValue | ILinkCellValue[] | undefined;\n      if (!cell) continue;\n      const links = [cell].flat();\n\n      // source side new value\n      const newSourceValue =\n        relationship === Relationship.OneOne || relationship === Relationship.ManyOne\n          ? { id: links[0].id }\n          : links.map((l) => ({ id: l.id }));\n\n      sourceOpsMap[sourceId] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: newField.id,\n          newCellValue: newSourceValue,\n          oldCellValue: cell,\n        }),\n      ];\n\n      // target side symmetric value\n      for (const l of links) {\n        if (relationship === Relationship.OneOne || relationship === Relationship.OneMany) {\n          targetOpsMap[l.id] = [\n            RecordOpBuilder.editor.setRecord.build({\n              fieldId: symmetricFieldId as string,\n              newCellValue: { id: sourceId },\n              oldCellValue: undefined,\n            }),\n          ];\n        } else {\n          targetOpsMap[l.id] = [\n            RecordOpBuilder.editor.setRecord.build({\n              fieldId: symmetricFieldId as string,\n              newCellValue: [{ id: sourceId }],\n              oldCellValue: undefined,\n            }),\n          ];\n        }\n      }\n    }\n\n    return { [sourceTableId]: sourceOpsMap, [foreignTableId]: targetOpsMap };\n  }\n\n  async modifyLinkOptions(tableId: string, newField: LinkFieldDto, oldField: LinkFieldDto) {\n    if (\n      newField.options.foreignTableId === oldField.options.foreignTableId &&\n      newField.options.relationship === oldField.options.relationship &&\n      newField.options.symmetricFieldId &&\n      !newField.options.isOneWay &&\n      oldField.options.isOneWay\n    ) {\n      return this.oneWayToTwoWay(oldField, newField);\n    }\n    // Preserve source values when converting from TwoWay to OneWay\n    if (\n      newField.options.foreignTableId === oldField.options.foreignTableId &&\n      newField.options.relationship === oldField.options.relationship &&\n      !!oldField.options.symmetricFieldId &&\n      !newField.options.symmetricFieldId &&\n      newField.options.isOneWay &&\n      !oldField.options.isOneWay\n    ) {\n      // Preserve source table link values by copying old values into the updated field\n      const sourceFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n        where: { id: oldField.id, deletedTime: null },\n        select: { tableId: true },\n      });\n      const sourceTableId = sourceFieldRaw.tableId;\n      const sourceRecords = await this.getRecords(sourceTableId, oldField);\n\n      const sourceOpsMap: { [recordId: string]: IOtOperation[] } = {};\n      for (const record of sourceRecords) {\n        const cell = record.fields[oldField.id] as ILinkCellValue | ILinkCellValue[] | undefined;\n        if (cell == null) continue;\n\n        const links = [cell].flat();\n        const relationship = newField.options.relationship;\n        const newValue =\n          relationship === Relationship.OneOne || relationship === Relationship.ManyOne\n            ? { id: links[0].id }\n            : links.map((l) => ({ id: l.id }));\n\n        sourceOpsMap[record.id] = [\n          RecordOpBuilder.editor.setRecord.build({\n            fieldId: newField.id,\n            newCellValue: newValue,\n            // Force reapply after FK/junction cleanup by setting oldCellValue to null\n            oldCellValue: null,\n          }),\n        ];\n      }\n\n      return { [sourceTableId]: sourceOpsMap } as IOpsMap;\n    }\n    if (newField.options.foreignTableId === oldField.options.foreignTableId) {\n      return this.convertLinkOnlyRelationship(tableId, newField, oldField);\n    }\n    return this.convertLink(tableId, newField, oldField);\n  }\n\n  /**\n   * convert oldCellValue to new link field cellValue\n   * if oldCellValue is not in foreignTable, create new record in foreignTable\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async convertLink(tableId: string, newField: LinkFieldDto, oldField: IFieldInstance) {\n    const fieldId = newField.id;\n    const foreignTableId = newField.options.foreignTableId;\n    const lookupFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: newField.options.lookupFieldId, deletedTime: null },\n    });\n    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);\n\n    const records = await this.getRecords(tableId, oldField);\n    // TODO: should not get all records in foreignTable, only get records witch title is not exist in candidate records link cell value title\n    const foreignRecords = await this.getRecords(foreignTableId, lookupField);\n\n    // TODO: maybe have same title in foreignTable, should use id to map\n    const primaryNameToIdMap = foreignRecords.reduce<{ [name: string]: string }>((pre, record) => {\n      const str = lookupField.cellValue2String(record.fields[lookupField.id]);\n      pre[str] = record.id;\n      return pre;\n    }, {});\n\n    const recordOpsMap: IOpsMap = { [tableId]: {}, [foreignTableId]: {} };\n    const globalCheckSet = new Set<string>();\n    // eslint-disable-next-line sonarjs/cognitive-complexity\n    records.forEach((record) => {\n      const oldCellValue = record.fields[fieldId];\n      const recordCheckSet = new Set<string>();\n      if (oldCellValue == null) {\n        return;\n      }\n      let newCellValueTitle: string[];\n      if (newField.isMultipleCellValue) {\n        newCellValueTitle = oldField.isMultipleCellValue\n          ? (oldCellValue as unknown[]).map((item) => oldField.item2String(item))\n          : oldField.item2String(oldCellValue).split(', ');\n      } else {\n        newCellValueTitle = oldField.isMultipleCellValue\n          ? [oldField.item2String((oldCellValue as unknown[])[0])]\n          : [oldField.item2String(oldCellValue).split(', ')[0]];\n      }\n\n      const newCellValue: ILinkCellValue[] = [];\n      function pushNewCellValue(linkCell: ILinkCellValue) {\n        // not allow link to same recordId in one record\n        if (recordCheckSet.has(linkCell.id)) return;\n\n        // OneMany and OneOne relationship only allow link to one same recordId\n        if (\n          newField.options.relationship === Relationship.OneMany ||\n          newField.options.relationship === Relationship.OneOne\n        ) {\n          if (globalCheckSet.has(linkCell.id)) return;\n          globalCheckSet.add(linkCell.id);\n          recordCheckSet.add(linkCell.id);\n          return newCellValue.push(linkCell);\n        }\n        recordCheckSet.add(linkCell.id);\n        return newCellValue.push(linkCell);\n      }\n\n      newCellValueTitle.forEach((title) => {\n        if (primaryNameToIdMap[title]) {\n          pushNewCellValue({ id: primaryNameToIdMap[title], title });\n        }\n      });\n\n      if (!recordOpsMap[tableId][record.id]) {\n        recordOpsMap[tableId][record.id] = [];\n      }\n      recordOpsMap[tableId][record.id].push(\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId,\n          newCellValue: newField.isMultipleCellValue ? newCellValue : newCellValue[0],\n          oldCellValue,\n        })\n      );\n    });\n\n    return recordOpsMap;\n  }\n\n  async convertLinkOnlyRelationship(\n    tableId: string,\n    newField: LinkFieldDto,\n    oldField: LinkFieldDto\n  ) {\n    const fieldId = newField.id;\n    const foreignTableId = newField.options.foreignTableId;\n    const lookupFieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: newField.options.lookupFieldId, deletedTime: null },\n    });\n    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);\n\n    const records = await this.getRecords(tableId, oldField);\n    // TODO: should not get all records in foreignTable, only get records witch title is not exist in candidate records link cell value title\n    const foreignRecords = await this.getRecords(foreignTableId, lookupField);\n\n    const idToTitleMap = foreignRecords.reduce<{ [id: string]: string }>((pre, record) => {\n      const str = lookupField.cellValue2String(record.fields[lookupField.id]);\n      pre[record.id] = str;\n      return pre;\n    }, {});\n\n    const recordOpsMap: IOpsMap = { [tableId]: {}, [foreignTableId]: {} };\n    const globalCheckSet = new Set<string>();\n    records.forEach((record) => {\n      const recordCheckSet = new Set<string>();\n      const oldCellValue = record.fields[fieldId];\n      if (oldCellValue == null) {\n        return;\n      }\n      const oldLinkLinks = [oldCellValue].flat() as ILinkCellValue[];\n      const newCellValue: ILinkCellValue[] = [];\n      // eslint-disable-next-line sonarjs/no-identical-functions\n      function pushNewCellValue(linkCell: ILinkCellValue) {\n        // not allow link to same recordId in one record\n        if (recordCheckSet.has(linkCell.id)) return;\n\n        // OneMany and OneOne relationship only allow link to one same recordId\n        if (\n          newField.options.relationship === Relationship.OneMany ||\n          newField.options.relationship === Relationship.OneOne\n        ) {\n          if (globalCheckSet.has(linkCell.id)) return;\n          globalCheckSet.add(linkCell.id);\n          recordCheckSet.add(linkCell.id);\n          return newCellValue.push(linkCell);\n        }\n        recordCheckSet.add(linkCell.id);\n        return newCellValue.push(linkCell);\n      }\n\n      oldLinkLinks.forEach((link) => {\n        if (idToTitleMap[link.id]) {\n          pushNewCellValue({\n            ...link,\n            title: idToTitleMap[link.id],\n          });\n        }\n      });\n\n      if (!recordOpsMap[tableId][record.id]) {\n        recordOpsMap[tableId][record.id] = [];\n      }\n      recordOpsMap[tableId][record.id].push(\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId,\n          newCellValue: newField.isMultipleCellValue ? newCellValue : newCellValue[0],\n          oldCellValue,\n        })\n      );\n    });\n\n    return recordOpsMap;\n  }\n\n  async planResetLinkFieldLookupFieldId(\n    lookupedTableId: string,\n    lookupedField: IFieldInstance,\n    fieldAction: FieldAction\n  ): Promise<string[]> {\n    if (fieldAction !== 'field|update' && fieldAction !== 'field|delete') {\n      return [];\n    }\n    if (fieldAction === 'field|update' && PRIMARY_SUPPORTED_TYPES.has(lookupedField.type)) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n\n    const lookupedFieldId = lookupedField.id;\n    const refRaws = await prisma.reference.findMany({\n      where: {\n        fromFieldId: lookupedFieldId,\n      },\n    });\n    const toFieldIds = refRaws.map((ref) => ref.toFieldId);\n\n    const lookupedPrimaryField = await prisma.field.findFirst({\n      where: { tableId: lookupedTableId, isPrimary: true },\n      select: { id: true },\n    });\n\n    if (!lookupedPrimaryField) {\n      return [];\n    }\n\n    const fieldRaws = await prisma.field.findMany({\n      where: {\n        id: { in: toFieldIds },\n        type: FieldType.Link,\n        deletedTime: null,\n      },\n    });\n\n    const fieldInstances = fieldRaws\n      .filter((field) => field.type === FieldType.Link && !field.isLookup)\n      .map((field) => createFieldInstanceByRaw(field))\n      .filter((field) => {\n        const option = field.options as ILinkFieldOptions;\n        return (\n          option.foreignTableId === lookupedTableId && option.lookupFieldId === lookupedFieldId\n        );\n      });\n\n    return fieldInstances.map((field) => field.id);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { FieldOpenApiModule } from '../open-api/field-open-api.module';\nimport { FieldConvertingService } from './field-converting.service';\n\ndescribe('FieldConvertingService', () => {\n  let service: FieldConvertingService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldOpenApiModule],\n    }).compile();\n\n    service = module.get<FieldConvertingService>(FieldConvertingService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should return the correct changes', () => {\n    expect(\n      service['getOptionsChanges'](\n        {\n          formatting: 'italic',\n          showAs: 'number',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx01',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx01',\n          visibleFieldIds: ['fldxxxxxxx01'],\n          anotherKey: 'anotherKey',\n        },\n        {\n          formatting: 'bold',\n          showAs: 'text',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx02',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx02',\n          visibleFieldIds: ['fldxxxxxxx02'],\n          otherKey: 'otherKey',\n        }\n      )\n    ).toEqual({\n      anotherKey: 'anotherKey',\n      otherKey: null,\n    });\n\n    expect(\n      service['getOptionsChanges'](\n        {\n          formatting: 'italic',\n          showAs: 'number',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx01',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx01',\n          visibleFieldIds: ['fldxxxxxxx01'],\n          anotherKey: 'anotherKey',\n        },\n        {\n          formatting: 'bold',\n          showAs: 'text',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx02',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx02',\n          visibleFieldIds: ['fldxxxxxxx02'],\n          otherKey: 'otherKey',\n        },\n        true\n      )\n    ).toEqual({\n      anotherKey: 'anotherKey',\n      otherKey: null,\n      formatting: null,\n      showAs: null,\n      filter: null,\n      filterByViewId: null,\n      visibleFieldIds: null,\n      sort: null,\n      limit: null,\n    });\n\n    expect(\n      service['getOptionsChanges'](\n        {\n          formatting: 'italic',\n          showAs: 'number',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx01',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx01',\n          visibleFieldIds: ['fldxxxxxxx01'],\n          otherKey: 'newOtherKey',\n        },\n        {\n          formatting: 'bold',\n          showAs: 'text',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: 'fldxxxxxxx02',\n                operator: 'is',\n                value: 'x',\n              },\n            ],\n          },\n          filterByViewId: 'viewxxxxxxx02',\n          visibleFieldIds: ['fldxxxxxxx02'],\n          otherKey: 'oldOtherKey',\n        }\n      )\n    ).toEqual({\n      otherKey: 'newOtherKey',\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type {\n  IFieldPropertyKey,\n  ILookupOptionsVo,\n  IOtOperation,\n  ISelectFieldChoice,\n  IConvertFieldRo,\n  ILinkFieldOptions,\n  FieldCore,\n  LinkFieldCore,\n} from '@teable/core';\nimport {\n  CellValueType,\n  ColorUtils,\n  DbFieldType,\n  FIELD_VO_PROPERTIES,\n  FieldOpBuilder,\n  FieldType,\n  generateChoiceId,\n  HttpErrorCode,\n  isMultiValueLink,\n  isLinkLookupOptions,\n  PRIMARY_SUPPORTED_TYPES,\n  RecordOpBuilder,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { difference, intersection, isEmpty, isEqual, keyBy, set, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { handleDBValidationErrors } from '../../../utils/db-validation-error';\nimport {\n  majorFieldKeysChanged,\n  majorOptionsKeyChanged,\n  NON_INFECT_OPTION_KEYS,\n} from '../../../utils/major-field-keys-changed';\nimport { BatchService } from '../../calculation/batch.service';\nimport { FieldCalculationService } from '../../calculation/field-calculation.service';\nimport { LinkService } from '../../calculation/link.service';\nimport type { ICellContext } from '../../calculation/utils/changes';\nimport { formatChangesToOps } from '../../calculation/utils/changes';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport { composeOpMaps } from '../../calculation/utils/compose-maps';\nimport { isLinkCellValue } from '../../calculation/utils/detect-link';\nimport { CollaboratorService } from '../../collaborator/collaborator.service';\nimport { ComputedOrchestratorService } from '../../record/computed/services/computed-orchestrator.service';\nimport { TableIndexService } from '../../table/table-index.service';\nimport { FieldService } from '../field.service';\nimport type { IFieldInstance, IFieldMap } from '../model/factory';\nimport { createFieldInstanceByRaw, createFieldInstanceByVo } from '../model/factory';\nimport type { ButtonFieldDto } from '../model/field-dto/button-field.dto';\nimport { ConditionalRollupFieldDto } from '../model/field-dto/conditional-rollup-field.dto';\nimport { FormulaFieldDto } from '../model/field-dto/formula-field.dto';\nimport type { LinkFieldDto } from '../model/field-dto/link-field.dto';\nimport type { MultipleSelectFieldDto } from '../model/field-dto/multiple-select-field.dto';\nimport type { RatingFieldDto } from '../model/field-dto/rating-field.dto';\nimport { RollupFieldDto } from '../model/field-dto/rollup-field.dto';\nimport type { SingleSelectFieldDto } from '../model/field-dto/single-select-field.dto';\nimport type { UserFieldDto } from '../model/field-dto/user-field.dto';\nimport { FieldConvertingLinkService } from './field-converting-link.service';\nimport { FieldSupplementService } from './field-supplement.service';\n\n@Injectable()\nexport class FieldConvertingService {\n  private readonly logger = new Logger(FieldConvertingService.name);\n\n  constructor(\n    private readonly linkService: LinkService,\n    private readonly fieldService: FieldService,\n    private readonly batchService: BatchService,\n    private readonly prismaService: PrismaService,\n    private readonly fieldConvertingLinkService: FieldConvertingLinkService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly fieldCalculationService: FieldCalculationService,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly tableIndexService: TableIndexService,\n    private readonly computedOrchestrator: ComputedOrchestratorService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private fieldOpsMap() {\n    const fieldOpsMap: IOpsMap = {};\n    return {\n      pushOpsMap: (tableId: string, fieldId: string, op: IOtOperation | IOtOperation[]) => {\n        const ops = Array.isArray(op) ? op : [op];\n        if (!fieldOpsMap[tableId]?.[fieldId]) {\n          set(fieldOpsMap, [tableId, fieldId], ops);\n        } else {\n          fieldOpsMap[tableId][fieldId].push(...ops);\n        }\n      },\n      getOpsMap: () => fieldOpsMap,\n    };\n  }\n\n  /**\n   * Mutate field instance directly, because we should update fieldInstance in fieldMap for next field operation\n   */\n  private buildOpAndMutateField(field: IFieldInstance, key: IFieldPropertyKey, value: unknown) {\n    if (isEqual(field[key], value)) {\n      return;\n    }\n    const oldValue = field[key];\n    (field[key] as unknown) = value;\n    return FieldOpBuilder.editor.setFieldProperty.build({ key, oldValue, newValue: value });\n  }\n\n  /**\n   * 1. check if the lookup field is valid, if not mark error\n   * 2. update lookup field properties\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private updateLookupField(field: IFieldInstance, fieldMap: IFieldMap): IOtOperation[] {\n    const ops: (IOtOperation | undefined)[] = [];\n    const lookupOptions = field.lookupOptions;\n    if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n      return [];\n    }\n\n    const linkField = fieldMap[lookupOptions.linkFieldId];\n    const lookupField = fieldMap[lookupOptions.lookupFieldId];\n\n    const linkFieldIsValid =\n      linkField &&\n      !linkField.isLookup &&\n      linkField.type === FieldType.Link &&\n      (linkField.options as ILinkFieldOptions | undefined)?.foreignTableId ===\n        lookupOptions.foreignTableId;\n\n    if (!linkFieldIsValid || !lookupField) {\n      const errorOp = this.buildOpAndMutateField(field, 'hasError', true);\n      if (errorOp) {\n        ops.push(errorOp);\n      }\n      return ops.filter(Boolean) as IOtOperation[];\n    }\n\n    const linkFieldDto = linkField as LinkFieldDto;\n    const { showAs: _, ...inheritableOptions } = lookupField.options as Record<string, unknown>;\n    const {\n      formatting = inheritableOptions.formatting,\n      showAs,\n      ...inheritOptions\n    } = field.options as Record<string, unknown>;\n    const cellValueTypeChanged = field.cellValueType !== lookupField.cellValueType;\n\n    const clearErrorOp = this.buildOpAndMutateField(field, 'hasError', null);\n    if (clearErrorOp) {\n      ops.push(clearErrorOp);\n    }\n\n    if (field.type !== lookupField.type) {\n      ops.push(this.buildOpAndMutateField(field, 'type', lookupField.type));\n    }\n\n    // Only sync link-related lookupOptions when the linked field is still a Link.\n    // If the linked field has been converted to a non-link type, keep the existing\n    // relationship and linkage metadata so clients can still introspect prior config\n    // while the lookup is marked as errored.\n    // eslint-disable-next-line sonarjs/no-collapsible-if\n    if (linkFieldDto.type === FieldType.Link) {\n      if (lookupOptions.relationship !== linkFieldDto.options.relationship) {\n        ops.push(\n          this.buildOpAndMutateField(field, 'lookupOptions', {\n            ...lookupOptions,\n            relationship: linkFieldDto.options.relationship,\n            fkHostTableName: linkFieldDto.options.fkHostTableName,\n            selfKeyName: linkFieldDto.options.selfKeyName,\n            foreignKeyName: linkFieldDto.options.foreignKeyName,\n          } as ILookupOptionsVo)\n        );\n      }\n    }\n\n    if (!isEqual(inheritOptions, inheritableOptions)) {\n      ops.push(\n        this.buildOpAndMutateField(field, 'options', {\n          ...inheritableOptions,\n          ...(formatting ? { formatting } : {}),\n          ...(showAs ? { showAs } : {}),\n        })\n      );\n    }\n\n    if (cellValueTypeChanged) {\n      ops.push(this.buildOpAndMutateField(field, 'cellValueType', lookupField.cellValueType));\n      if (formatting || showAs) {\n        ops.push(this.buildOpAndMutateField(field, 'options', inheritableOptions));\n      }\n    }\n\n    const isMultipleCellValue =\n      lookupField.isMultipleCellValue ||\n      (linkFieldDto.type === FieldType.Link && linkFieldDto.isMultipleCellValue) ||\n      false;\n    if (field.isMultipleCellValue !== isMultipleCellValue) {\n      ops.push(this.buildOpAndMutateField(field, 'isMultipleCellValue', isMultipleCellValue));\n      // clean showAs\n      if (!cellValueTypeChanged && showAs) {\n        ops.push(\n          this.buildOpAndMutateField(field, 'options', {\n            ...inheritableOptions,\n            ...(formatting ? { formatting } : {}),\n          })\n        );\n      }\n    }\n\n    return ops.filter(Boolean) as IOtOperation[];\n  }\n\n  private updateFormulaField(field: FormulaFieldDto, fieldMap: IFieldMap) {\n    const ops: (IOtOperation | undefined)[] = [];\n    const { cellValueType, isMultipleCellValue } = FormulaFieldDto.getParsedValueType(\n      field.options.expression,\n      fieldMap\n    );\n\n    if (field.cellValueType !== cellValueType) {\n      ops.push(this.buildOpAndMutateField(field, 'cellValueType', cellValueType));\n    }\n    if (field.isMultipleCellValue !== isMultipleCellValue) {\n      ops.push(this.buildOpAndMutateField(field, 'isMultipleCellValue', isMultipleCellValue));\n    }\n    return ops.filter(Boolean) as IOtOperation[];\n  }\n\n  private updateRollupField(field: RollupFieldDto, fieldMap: IFieldMap) {\n    const ops: (IOtOperation | undefined)[] = [];\n    const { lookupOptions } = field;\n    if (!isLinkLookupOptions(lookupOptions)) {\n      return ops.filter(Boolean) as IOtOperation[];\n    }\n\n    const { lookupFieldId, relationship } = lookupOptions;\n    const lookupField = fieldMap[lookupFieldId];\n    const { cellValueType, isMultipleCellValue } = RollupFieldDto.getParsedValueType(\n      field.options.expression,\n      lookupField.cellValueType,\n      lookupField.isMultipleCellValue || isMultiValueLink(relationship)\n    );\n\n    if (field.cellValueType !== cellValueType) {\n      ops.push(this.buildOpAndMutateField(field, 'cellValueType', cellValueType));\n    }\n    if (field.isMultipleCellValue !== isMultipleCellValue) {\n      ops.push(this.buildOpAndMutateField(field, 'isMultipleCellValue', isMultipleCellValue));\n    }\n    return ops.filter(Boolean) as IOtOperation[];\n  }\n\n  /**\n   * Update conditional lookup field - validate dependencies and clear/set hasError\n   */\n  private updateConditionalLookupField(field: IFieldInstance, fieldMap: IFieldMap): IOtOperation[] {\n    const ops: IOtOperation[] = [];\n\n    // Get referenced field IDs from the conditional lookup configuration\n    const referencedFieldIds = this.fieldSupplementService\n      .getFieldReferenceIds(field)\n      .filter((id) => !!id && id !== field.id);\n\n    // Check if any referenced field is missing or has error\n    const missingFields = referencedFieldIds.filter((id) => !fieldMap[id]);\n    const erroredFields = referencedFieldIds.filter((id) => fieldMap[id]?.hasError);\n\n    const hasMissingDependency = missingFields.length > 0;\n    const hasErroredDependency = erroredFields.length > 0;\n\n    if (hasMissingDependency || hasErroredDependency) {\n      const op = this.buildOpAndMutateField(field, 'hasError', true);\n      if (op) {\n        ops.push(op);\n      }\n      return ops;\n    }\n\n    // Clear error if all dependencies are valid\n    const clearErrorOp = this.buildOpAndMutateField(field, 'hasError', null);\n    if (clearErrorOp) {\n      ops.push(clearErrorOp);\n    }\n\n    return ops;\n  }\n\n  private updateConditionalRollupField(\n    field: ConditionalRollupFieldDto,\n    fieldMap: IFieldMap\n  ): IOtOperation[] {\n    const ops: IOtOperation[] = [];\n    if (field.isLookup) {\n      return ops;\n    }\n    const lookupFieldId = field.options.lookupFieldId;\n    const referencedFieldIds = this.fieldSupplementService\n      .getFieldReferenceIds(field)\n      .filter((id) => !!id && id !== field.id);\n\n    const hasMissingDependency = !lookupFieldId || referencedFieldIds.some((id) => !fieldMap[id]);\n    const hasErroredDependency = referencedFieldIds.some((id) => fieldMap[id]?.hasError);\n\n    if (hasMissingDependency || hasErroredDependency) {\n      const op = this.buildOpAndMutateField(field, 'hasError', true);\n      if (op) {\n        ops.push(op);\n      }\n      return ops;\n    }\n\n    const lookupField = fieldMap[lookupFieldId];\n    if (!lookupField) {\n      const op = this.buildOpAndMutateField(field, 'hasError', true);\n      if (op) {\n        ops.push(op);\n      }\n      return ops;\n    }\n\n    const clearErrorOp = this.buildOpAndMutateField(field, 'hasError', null);\n    if (clearErrorOp) {\n      ops.push(clearErrorOp);\n    }\n\n    const { cellValueType, isMultipleCellValue } = ConditionalRollupFieldDto.getParsedValueType(\n      field.options.expression,\n      lookupField.cellValueType,\n      true\n    );\n\n    const cellTypeOp = this.buildOpAndMutateField(field, 'cellValueType', cellValueType);\n    if (cellTypeOp) {\n      ops.push(cellTypeOp);\n    }\n    const multiValueOp = this.buildOpAndMutateField(\n      field,\n      'isMultipleCellValue',\n      isMultipleCellValue\n    );\n    if (multiValueOp) {\n      ops.push(multiValueOp);\n    }\n\n    return ops;\n  }\n\n  private updateDbFieldType(field: IFieldInstance) {\n    const ops: IOtOperation[] = [];\n    const dbFieldType = this.fieldSupplementService.getDbFieldType(\n      field.type,\n      field.cellValueType,\n      field.isMultipleCellValue\n    );\n\n    if (field.dbFieldType !== dbFieldType) {\n      const op1 = this.buildOpAndMutateField(field, 'dbFieldType', dbFieldType);\n      op1 && ops.push(op1);\n    }\n    return ops;\n  }\n\n  private async generateReferenceFieldOps(fields: IFieldInstance[]) {\n    const fieldIds = fields.map((field) => field.id);\n\n    const topoOrdersContext = await this.fieldCalculationService.getTopoOrdersContext(fieldIds);\n    const { fieldId2TableId, directedGraph } = topoOrdersContext;\n    const fieldMap = { ...topoOrdersContext.fieldMap, ...keyBy(fields, 'id') };\n\n    // Find affected fields using directedGraph\n    const affectedFields = new Set<string>();\n\n    function findAffectedFields(currentId: string) {\n      for (const { fromFieldId, toFieldId } of directedGraph) {\n        if (fromFieldId === currentId && !affectedFields.has(toFieldId)) {\n          affectedFields.add(toFieldId);\n          findAffectedFields(toFieldId);\n        }\n      }\n    }\n\n    // Start from each initial field\n    fieldIds.forEach((fieldId) => {\n      findAffectedFields(fieldId);\n    });\n\n    // Filter topoOrders to only include affected fields\n    const topoOrders = topoOrdersContext.topoOrders.filter((item) => affectedFields.has(item.id));\n\n    if (!topoOrders.length) {\n      return {};\n    }\n\n    const { pushOpsMap, getOpsMap } = this.fieldOpsMap();\n\n    for (let i = 0; i < topoOrders.length; i++) {\n      const topoOrder = topoOrders[i];\n      const curField = fieldMap[topoOrder.id];\n      const tableId = fieldId2TableId[curField.id];\n\n      if (curField.isLookup) {\n        // For conditional lookup fields, use the dedicated update method\n        if (curField.isConditionalLookup) {\n          pushOpsMap(tableId, curField.id, this.updateConditionalLookupField(curField, fieldMap));\n        } else {\n          pushOpsMap(tableId, curField.id, this.updateLookupField(curField, fieldMap));\n        }\n      } else if (curField.type === FieldType.Formula) {\n        pushOpsMap(tableId, curField.id, this.updateFormulaField(curField, fieldMap));\n      } else if (curField.type === FieldType.Rollup) {\n        pushOpsMap(tableId, curField.id, this.updateRollupField(curField, fieldMap));\n      } else if (curField.type === FieldType.ConditionalRollup) {\n        pushOpsMap(tableId, curField.id, this.updateConditionalRollupField(curField, fieldMap));\n      }\n      pushOpsMap(tableId, curField.id, this.updateDbFieldType(curField));\n    }\n\n    return getOpsMap();\n  }\n\n  /**\n   * get deep deference in options, and return changes\n   * formatting, showAs should be ignore\n   */\n  private getOptionsChanges(\n    newOptions: Record<string, unknown>,\n    oldOptions: Record<string, unknown>,\n    valueTypeChange?: boolean\n  ): Record<string, unknown> {\n    const optionsChanges: Record<string, unknown> = {};\n\n    newOptions = { ...newOptions };\n    oldOptions = { ...oldOptions };\n    const nonInfectKeys = Array.from(NON_INFECT_OPTION_KEYS);\n    nonInfectKeys.forEach((key) => {\n      delete newOptions[key];\n      delete oldOptions[key];\n    });\n\n    const newOptionsKeys = Object.keys(newOptions);\n    const oldOptionsKeys = Object.keys(oldOptions);\n\n    const addedOptionsKeys = difference(newOptionsKeys, oldOptionsKeys);\n    const removedOptionsKeys = difference(oldOptionsKeys, newOptionsKeys);\n    const editedOptionsKeys = intersection(newOptionsKeys, oldOptionsKeys).filter(\n      (key) => !isEqual(oldOptions[key], newOptions[key])\n    );\n\n    addedOptionsKeys.forEach((key) => (optionsChanges[key] = newOptions[key]));\n    editedOptionsKeys.forEach((key) => (optionsChanges[key] = newOptions[key]));\n    removedOptionsKeys.forEach((key) => (optionsChanges[key] = null));\n\n    // clean formatting, showAs when valueType change\n    valueTypeChange && nonInfectKeys.forEach((key) => (optionsChanges[key] = null));\n\n    return optionsChanges;\n  }\n\n  private infectPropertyChanged(newField: IFieldInstance, oldField: FieldCore) {\n    // those key will infect the reference field\n    const infectProperties = ['type', 'cellValueType', 'isMultipleCellValue'] as const;\n    const changedProperties = infectProperties.filter(\n      (key) => !isEqual(newField[key], oldField[key])\n    );\n\n    const valueTypeChanged = changedProperties.some((key) =>\n      ['cellValueType', 'isMultipleCellValue'].includes(key)\n    );\n\n    // options may infect the lookup field\n    const optionsChanges = this.getOptionsChanges(\n      newField.options,\n      oldField.options,\n      valueTypeChanged\n    );\n\n    return Boolean(changedProperties.length || !isEmpty(optionsChanges));\n  }\n\n  // lookupOptions of lookup field and rollup field must be consistent with linkField Settings\n  // And they don't belong in the referenceField\n  private async updateLookupRollupRef(\n    newField: IFieldInstance,\n    oldField: FieldCore\n  ): Promise<IOpsMap | undefined> {\n    if (newField.type !== FieldType.Link || oldField.type !== FieldType.Link) {\n      return;\n    }\n\n    const oldFieldOptions = oldField.options as ILinkFieldOptions;\n    // ignore foreignTableId change\n    if (newField.options.foreignTableId !== oldFieldOptions.foreignTableId) {\n      return;\n    }\n\n    const { relationship, fkHostTableName, foreignKeyName, selfKeyName } = newField.options;\n    if (\n      relationship === oldFieldOptions.relationship &&\n      fkHostTableName === oldFieldOptions.fkHostTableName &&\n      foreignKeyName === oldFieldOptions.foreignKeyName &&\n      selfKeyName === oldFieldOptions.selfKeyName\n    ) {\n      return;\n    }\n\n    const relatedFieldsRaw = await this.prismaService.txClient().field.findMany({\n      where: {\n        lookupLinkedFieldId: newField.id,\n        deletedTime: null,\n      },\n    });\n\n    const relatedFields = relatedFieldsRaw.map(createFieldInstanceByRaw);\n\n    const lookupToFields = await this.prismaService.txClient().field.findMany({\n      where: {\n        id: {\n          in: relatedFields.map((field) => field.lookupOptions?.lookupFieldId as string),\n        },\n      },\n    });\n    const relatedFieldsRawMap = keyBy(relatedFieldsRaw, 'id');\n    const lookupToFieldsMap = keyBy(lookupToFields, 'id');\n\n    const { pushOpsMap, getOpsMap } = this.fieldOpsMap();\n\n    relatedFields.forEach((field) => {\n      const lookupOptions = field.lookupOptions!;\n      const ops: IOtOperation[] = [];\n      ops.push(\n        this.buildOpAndMutateField(field, 'lookupOptions', {\n          ...lookupOptions,\n          relationship,\n          fkHostTableName,\n          foreignKeyName,\n          selfKeyName,\n        })!\n      );\n\n      const lookupToFieldRaw = lookupToFieldsMap[lookupOptions.lookupFieldId];\n\n      if (field.isLookup) {\n        const isMultipleCellValue =\n          newField.isMultipleCellValue || lookupToFieldRaw.isMultipleCellValue || false;\n\n        if (isMultipleCellValue !== field.isMultipleCellValue) {\n          ops.push(this.buildOpAndMutateField(field, 'isMultipleCellValue', isMultipleCellValue)!);\n        }\n\n        const dbFieldType = this.fieldSupplementService.getDbFieldType(\n          field.type,\n          field.cellValueType,\n          isMultipleCellValue\n        );\n        if (dbFieldType !== field.dbFieldType) {\n          ops.push(this.buildOpAndMutateField(field, 'dbFieldType', dbFieldType)!);\n        }\n\n        const newOptions = this.fieldSupplementService.prepareFormattingShowAs(\n          field.options,\n          JSON.parse(lookupToFieldRaw.options as string),\n          field.cellValueType,\n          isMultipleCellValue\n        );\n\n        if (!isEqual(newOptions, field.options)) {\n          ops.push(this.buildOpAndMutateField(field, 'options', newOptions)!);\n        }\n      }\n\n      pushOpsMap(relatedFieldsRawMap[field.id].tableId, field.id, ops);\n    });\n\n    const referenceFieldOpsMap = await this.generateReferenceFieldOps(relatedFields);\n    return composeOpMaps([getOpsMap(), referenceFieldOpsMap]);\n  }\n\n  /**\n   * modify a field will causes the properties of the field that depend on it to change\n   * example：\n   * 1. modify a field's type will cause the the lookup field's type change\n   * 2. cellValueType / isMultipleCellValue change will cause the formula / rollup / lookup field's cellValueType / formatting change\n   * 3. options change will cause the lookup field options change\n   * 4. options in link field change may cause all lookup field run in to error, should mark them as error\n   */\n  private async updateReferencedFields(newField: IFieldInstance, oldField: FieldCore) {\n    if (!this.infectPropertyChanged(newField, oldField)) {\n      return;\n    }\n\n    const refFieldOpsMap = await this.updateLookupRollupRef(newField, oldField);\n\n    const fieldOpsMap = await this.generateReferenceFieldOps([newField]);\n\n    await this.submitFieldOpsMap(composeOpMaps([refFieldOpsMap, fieldOpsMap]));\n  }\n\n  private async updateOptionsFromMultiSelectField(\n    tableId: string,\n    updatedChoiceMap: { [old: string]: string | null },\n    field: MultipleSelectFieldDto\n  ): Promise<IOpsMap | undefined> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const nativeSql = this.knex(dbTableName)\n      .select('__id', field.dbFieldName)\n      .where((builder) => {\n        for (const value of Object.keys(updatedChoiceMap)) {\n          builder.orWhere(\n            this.knex.raw(`CAST(?? AS text)`, [field.dbFieldName]),\n            'LIKE',\n            `%\"${value}\"%`\n          );\n        }\n      })\n      .toSQL()\n      .toNative();\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { __id: string; [dbFieldName: string]: string }[]\n      >(nativeSql.sql, ...nativeSql.bindings);\n\n    for (const row of result) {\n      const oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]) as string[];\n      const newCellValue = oldCellValue.reduce<string[]>((pre, value) => {\n        // if key not in updatedChoiceMap, we should keep it\n        if (!(value in updatedChoiceMap)) {\n          pre.push(value);\n          return pre;\n        }\n\n        const newValue = updatedChoiceMap[value];\n        if (newValue !== null) {\n          pre.push(newValue);\n        }\n        return pre;\n      }, []);\n\n      opsMap[row.__id] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: field.id,\n          oldCellValue,\n          newCellValue,\n        }),\n      ];\n    }\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async updateOptionsFromSingleSelectField(\n    tableId: string,\n    updatedChoiceMap: { [old: string]: string | null },\n    field: SingleSelectFieldDto\n  ): Promise<IOpsMap | undefined> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const nativeSql = this.knex(dbTableName)\n      .select('__id', field.dbFieldName)\n      .where((builder) => {\n        for (const value of Object.keys(updatedChoiceMap)) {\n          builder.orWhere(field.dbFieldName, value);\n        }\n      })\n      .toSQL()\n      .toNative();\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { __id: string; [dbFieldName: string]: string }[]\n      >(nativeSql.sql, ...nativeSql.bindings);\n\n    for (const row of result) {\n      let oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]) as string;\n      if (field.isLookup && Array.isArray(oldCellValue)) {\n        oldCellValue = oldCellValue[0] as string;\n      }\n\n      opsMap[row.__id] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: field.id,\n          oldCellValue,\n          newCellValue: updatedChoiceMap[oldCellValue],\n        }),\n      ];\n    }\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async updateOptionsFromSelectField(\n    tableId: string,\n    updatedChoiceMap: { [old: string]: string | null },\n    field: SingleSelectFieldDto | MultipleSelectFieldDto\n  ): Promise<IOpsMap | undefined> {\n    if (field.type === FieldType.SingleSelect) {\n      return this.updateOptionsFromSingleSelectField(tableId, updatedChoiceMap, field);\n    }\n\n    if (field.type === FieldType.MultipleSelect) {\n      return this.updateOptionsFromMultiSelectField(tableId, updatedChoiceMap, field);\n    }\n    throw new CustomHttpException(\n      `Unsupported field type ${(field as { type: FieldType }).type}`,\n      HttpErrorCode.VALIDATION_ERROR,\n      {\n        localization: {\n          i18nKey: 'httpErrors.field.unsupportedFieldType',\n          context: {\n            type: (field as { type: FieldType }).type,\n          },\n        },\n      }\n    );\n  }\n\n  private async modifySelectOptions(\n    tableId: string,\n    newField: SingleSelectFieldDto | MultipleSelectFieldDto,\n    oldField: SingleSelectFieldDto | MultipleSelectFieldDto\n  ) {\n    const newChoiceMap = keyBy(newField.options.choices, 'id');\n    const updatedChoiceMap: { [old: string]: string | null } = {};\n\n    oldField.options.choices.forEach((item) => {\n      if (!newChoiceMap[item.id]) {\n        updatedChoiceMap[item.name] = null;\n        return;\n      }\n\n      if (newChoiceMap[item.id].name !== item.name) {\n        updatedChoiceMap[item.name] = newChoiceMap[item.id].name;\n      }\n    });\n\n    if (isEmpty(updatedChoiceMap)) {\n      return;\n    }\n\n    return this.updateOptionsFromSelectField(tableId, updatedChoiceMap, oldField);\n  }\n\n  private async updateOptionsFromRatingField(\n    tableId: string,\n    field: RatingFieldDto,\n    oldField: RatingFieldDto\n  ): Promise<IOpsMap | undefined> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const dbFieldName = oldField.dbFieldName;\n\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const newMax = field.options.max;\n\n    const nativeSql = this.knex(dbTableName)\n      .select('__id', dbFieldName)\n      .where(dbFieldName, '>', newMax)\n      .toSQL()\n      .toNative();\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<\n        { __id: string; [dbFieldName: string]: string }[]\n      >(nativeSql.sql, ...nativeSql.bindings);\n\n    for (const row of result) {\n      let oldCellValue = field.convertDBValue2CellValue(row[dbFieldName]) as number;\n      if (field.isLookup && Array.isArray(oldCellValue)) {\n        oldCellValue = oldCellValue[0] as number;\n      }\n\n      opsMap[row.__id] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: field.id,\n          oldCellValue,\n          newCellValue: newMax,\n        }),\n      ];\n    }\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async modifyRatingOptions(\n    tableId: string,\n    newField: RatingFieldDto,\n    oldField: RatingFieldDto\n  ) {\n    const newMax = newField.options.max;\n    const oldMax = oldField.options.max;\n\n    if (newMax >= oldMax) return;\n\n    return await this.updateOptionsFromRatingField(tableId, newField, oldField);\n  }\n\n  private async updateOptionsFromUserField(\n    tableId: string,\n    field: UserFieldDto,\n    oldField: UserFieldDto\n  ): Promise<IOpsMap | undefined> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n    const dbFieldName = oldField.dbFieldName;\n\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const nativeSql = this.knex(dbTableName).select('__id', dbFieldName).whereNotNull(dbFieldName);\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ __id: string; [dbFieldName: string]: string }[]>(nativeSql.toQuery());\n\n    for (const row of result) {\n      const oldCellValue = field.convertDBValue2CellValue(row[dbFieldName]);\n\n      let newCellValue;\n\n      if (field.isMultipleCellValue && !Array.isArray(oldCellValue)) {\n        newCellValue = [oldCellValue];\n      } else if (!field.isMultipleCellValue && Array.isArray(oldCellValue)) {\n        newCellValue = oldCellValue[0];\n      } else {\n        newCellValue = oldCellValue;\n      }\n\n      opsMap[row.__id] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: field.id,\n          oldCellValue,\n          newCellValue: newCellValue,\n        }),\n      ];\n    }\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async modifyUserOptions(tableId: string, newField: UserFieldDto, oldField: UserFieldDto) {\n    const newOption = newField.options.isMultiple;\n    const oldOption = oldField.options.isMultiple;\n\n    if (newOption === oldOption) return;\n\n    return await this.updateOptionsFromUserField(tableId, newField, oldField);\n  }\n\n  private async updateOptionsFromButtonField(tableId: string, field: ButtonFieldDto) {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const nativeSql = this.knex(dbTableName)\n      .select('__id', field.dbFieldName)\n      .whereNotNull(field.dbFieldName);\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ __id: string; [dbFieldName: string]: string }[]>(nativeSql.toQuery());\n    for (const row of result) {\n      const oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]);\n      opsMap[row.__id] = [\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId: field.id,\n          oldCellValue,\n          newCellValue: null,\n        }),\n      ];\n    }\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async modifyButtonOptions(\n    tableId: string,\n    newField: ButtonFieldDto,\n    oldField: ButtonFieldDto\n  ) {\n    const oldWorkflow = oldField.options.workflow;\n    const newWorkflow = newField.options.workflow;\n\n    if (oldWorkflow?.id === newWorkflow?.id) return;\n\n    return await this.updateOptionsFromButtonField(tableId, oldField);\n  }\n\n  private async modifyOptions(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ): Promise<IOpsMap | undefined> {\n    if (newField.isLookup) {\n      return;\n    }\n\n    switch (newField.type) {\n      case FieldType.Link:\n        return await this.fieldConvertingLinkService.modifyLinkOptions(\n          tableId,\n          newField as LinkFieldDto,\n          oldField as LinkFieldDto\n        );\n      case FieldType.SingleSelect:\n      case FieldType.MultipleSelect: {\n        return await this.modifySelectOptions(\n          tableId,\n          newField as SingleSelectFieldDto,\n          oldField as SingleSelectFieldDto\n        );\n      }\n      case FieldType.Rating: {\n        return await this.modifyRatingOptions(\n          tableId,\n          newField as RatingFieldDto,\n          oldField as RatingFieldDto\n        );\n      }\n      case FieldType.User: {\n        return await this.modifyUserOptions(\n          tableId,\n          newField as UserFieldDto,\n          oldField as UserFieldDto\n        );\n      }\n      case FieldType.Button: {\n        return await this.modifyButtonOptions(\n          tableId,\n          newField as ButtonFieldDto,\n          oldField as ButtonFieldDto\n        );\n      }\n    }\n  }\n\n  private getOriginFieldKeys(newField: IFieldInstance, oldField: FieldCore) {\n    return FIELD_VO_PROPERTIES.filter((key) => {\n      // For boolean constraint properties, treat undefined/null/false as equivalent (no constraint)\n      if (key === 'unique' || key === 'notNull') {\n        return Boolean(newField[key]) !== Boolean(oldField[key]);\n      }\n      return !isEqual(newField[key], oldField[key]);\n    });\n  }\n\n  private getOriginFieldOps(newField: IFieldInstance, oldField: FieldCore) {\n    return this.getOriginFieldKeys(newField, oldField).map((key) =>\n      FieldOpBuilder.editor.setFieldProperty.build({\n        key,\n        newValue: newField[key],\n        oldValue: oldField[key],\n      })\n    );\n  }\n\n  private async getDerivateByLink(tableId: string, innerOpsMap: IOpsMap['key']) {\n    const changes: ICellContext[] = [];\n    let fromReset = true;\n    for (const recordId in innerOpsMap) {\n      for (const op of innerOpsMap[recordId]) {\n        const context = RecordOpBuilder.editor.setRecord.detect(op);\n        if (!context) {\n          throw new CustomHttpException(\n            `Invalid operation ${JSON.stringify(op)}, when get derivate by link`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.custom.invalidOperation',\n              },\n            }\n          );\n        }\n\n        // when changing link relationship, old value used to clean link cellValue\n        if (isLinkCellValue(context.oldCellValue)) {\n          fromReset = false;\n        }\n\n        changes.push({\n          recordId,\n          fieldId: context.fieldId,\n          oldValue: isLinkCellValue(context.oldCellValue) ? context.oldCellValue : null,\n          newValue: context.newCellValue,\n        });\n      }\n    }\n\n    const derivate = await this.linkService.getDerivateByLink(tableId, changes, fromReset);\n    const cellChanges = derivate?.cellChanges || [];\n\n    const opsMapByLink = cellChanges.length ? formatChangesToOps(cellChanges) : {};\n\n    return {\n      opsMapByLink,\n      fkRecordMap: derivate?.fkRecordMap,\n    };\n  }\n\n  private buildCellContextsFromOps(opsMap: IOpsMap[string] | undefined) {\n    const contexts: ICellContext[] = [];\n    if (!opsMap) {\n      return contexts;\n    }\n    for (const [recordId, ops] of Object.entries(opsMap)) {\n      for (const op of ops) {\n        const context = RecordOpBuilder.editor.setRecord.detect(op);\n        if (!context) {\n          continue;\n        }\n        contexts.push({\n          recordId,\n          fieldId: context.fieldId,\n          oldValue: context.oldCellValue,\n          newValue: context.newCellValue,\n        });\n      }\n    }\n    return contexts;\n  }\n\n  private buildComputedSources(recordOpsMap: IOpsMap) {\n    return Object.entries(recordOpsMap)\n      .map(([tableId, ops]) => ({\n        tableId,\n        cellContexts: this.buildCellContextsFromOps(ops),\n      }))\n      .filter((source) => source.cellContexts.length);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async calculateAndSaveRecords(\n    tableId: string,\n    field: IFieldInstance,\n    recordOpsMap: IOpsMap | void\n  ) {\n    if (!recordOpsMap || isEmpty(recordOpsMap)) {\n      return;\n    }\n\n    if (field.type === FieldType.Link && !field.isLookup) {\n      const result = await this.getDerivateByLink(tableId, recordOpsMap[tableId]);\n      recordOpsMap = composeOpMaps([recordOpsMap, result.opsMapByLink]);\n\n      // Also derive link updates for any other tables present in the ops map.\n      // This covers scenarios where conversions schedule updates on symmetric link fields\n      // in foreign tables (e.g., one-way → two-way), which need link derivations too.\n      for (const otherTableId of Object.keys(recordOpsMap)) {\n        if (otherTableId === tableId) continue;\n        const opsForOther = recordOpsMap[otherTableId];\n        if (!opsForOther || isEmpty(opsForOther)) continue;\n        try {\n          const r = await this.getDerivateByLink(otherTableId, opsForOther);\n          recordOpsMap = composeOpMaps([recordOpsMap, r.opsMapByLink]);\n        } catch (_) {\n          // Ignore derivation errors for non-link updates; they'll be handled downstream\n        }\n      }\n    }\n\n    const computedSources = this.buildComputedSources(recordOpsMap);\n    if (computedSources.length) {\n      await this.computedOrchestrator.computeCellChangesForRecordsMulti(\n        computedSources,\n        async (tables) => {\n          await this.batchService.updateRecords(recordOpsMap!, undefined, undefined, tables);\n        }\n      );\n    } else {\n      await this.batchService.updateRecords(recordOpsMap);\n    }\n  }\n\n  private async getExistRecords(tableId: string, newField: IFieldInstance) {\n    const { dbTableName, name: tableName } = await this.prismaService\n      .txClient()\n      .tableMeta.findFirstOrThrow({\n        where: { id: tableId },\n        select: { dbTableName: true, name: true },\n      });\n\n    const result = await this.fieldCalculationService.getRecordsBatchByFields(\n      {\n        [dbTableName]: [newField],\n      },\n      { [dbTableName]: tableId }\n    );\n    const records = result[dbTableName];\n    if (!records) {\n      throw new CustomHttpException(\n        `Can't find recordMap for tableId: ${tableId} and fieldId: ${newField.id}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.recordMapNotFound',\n            context: { tableName, fieldName: newField.name },\n          },\n        }\n      );\n    }\n\n    return records;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async convert2Select(\n    tableId: string,\n    newField: SingleSelectFieldDto | MultipleSelectFieldDto,\n    oldField: IFieldInstance\n  ) {\n    const fieldId = newField.id;\n    const records = await this.getExistRecords(tableId, oldField);\n    const choices = newField.options.choices;\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    const choicesMap = keyBy(choices, 'name');\n    const newChoicesSet = new Set<string>();\n    records.forEach((record) => {\n      const oldCellValue = record.fields[fieldId];\n      if (oldCellValue == null) {\n        return;\n      }\n\n      if (!opsMap[record.id]) {\n        opsMap[record.id] = [];\n      }\n\n      const cellStr = oldField.cellValue2String(oldCellValue);\n      const newCellValue = newField.convertStringToCellValue(cellStr, true);\n      if (Array.isArray(newCellValue)) {\n        newCellValue.forEach((item) => {\n          if (!choicesMap[item]) {\n            newChoicesSet.add(item);\n          }\n        });\n      } else if (newCellValue && !choicesMap[newCellValue]) {\n        newChoicesSet.add(newCellValue);\n      }\n      opsMap[record.id].push(\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId,\n          newCellValue,\n          oldCellValue,\n        })\n      );\n    });\n\n    if (newChoicesSet.size) {\n      const colors = ColorUtils.randomColor(\n        choices.map((item) => item.color),\n        newChoicesSet.size\n      );\n      const newChoices = choices.concat(\n        Array.from(newChoicesSet).map<ISelectFieldChoice>((item, i) => ({\n          id: generateChoiceId(),\n          name: item,\n          color: colors[i],\n        }))\n      );\n      // mutate field\n      this.buildOpAndMutateField(newField, 'options', {\n        ...newField.options,\n        choices: newChoices,\n      });\n    }\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async convert2User(tableId: string, newField: UserFieldDto, oldField: IFieldInstance) {\n    const fieldId = newField.id;\n    const records = await this.getExistRecords(tableId, oldField);\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n\n    const oldCvStrArr = records.map((record) => {\n      const oldCellValue = record.fields[fieldId];\n      if (oldCellValue == null) {\n        return;\n      }\n\n      return oldField.cellValue2String(oldCellValue);\n    });\n\n    const oldCvUserStrArr = oldCvStrArr\n      .map((v) => (v ? v.split(',').map((s) => s.trim()) : []))\n      .flat()\n      .filter(Boolean);\n    const tableCollaborators = await this.collaboratorService.getUserCollaboratorsByTableId(\n      tableId,\n      {\n        containsIn: {\n          keys: ['id', 'name', 'email', 'phone'],\n          values: uniq(oldCvUserStrArr),\n        },\n      }\n    );\n\n    records.forEach((record, index) => {\n      const oldCellValue = record.fields[fieldId];\n      if (oldCellValue == null) {\n        return;\n      }\n\n      if (!opsMap[record.id]) {\n        opsMap[record.id] = [];\n      }\n\n      const cellStr = oldCvStrArr[index];\n      if (!cellStr) {\n        return;\n      }\n      const newCellValue = newField.convertStringToCellValue(cellStr, {\n        userSets: tableCollaborators,\n      });\n\n      opsMap[record.id].push(\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId,\n          newCellValue,\n          oldCellValue,\n        })\n      );\n    });\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async basalConvert(tableId: string, newField: IFieldInstance, oldField: IFieldInstance) {\n    // simple value type change is not need to convert\n    if (\n      oldField.type !== FieldType.LongText &&\n      newField.type !== FieldType.Rating &&\n      newField.cellValueType === oldField.cellValueType &&\n      newField.isMultipleCellValue !== true &&\n      oldField.isMultipleCellValue !== true &&\n      newField.dbFieldType !== DbFieldType.Json &&\n      oldField.dbFieldType !== DbFieldType.Json &&\n      newField.dbFieldType === oldField.dbFieldType\n    ) {\n      return;\n    }\n\n    return this.buildBasalOpsMap(tableId, newField, oldField);\n  }\n\n  private async buildBasalOpsMap(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    const fieldId = newField.id;\n    const records = await this.getExistRecords(tableId, oldField);\n    const opsMap: { [recordId: string]: IOtOperation[] } = {};\n    records.forEach((record) => {\n      const oldCellValue = record.fields[fieldId];\n      if (oldCellValue == null) {\n        return;\n      }\n\n      const cellStr = oldField.cellValue2String(oldCellValue);\n      const newCellValue = newField.convertStringToCellValue(cellStr);\n\n      if (!opsMap[record.id]) {\n        opsMap[record.id] = [];\n      }\n      opsMap[record.id].push(\n        RecordOpBuilder.editor.setRecord.build({\n          fieldId,\n          newCellValue,\n          oldCellValue,\n        })\n      );\n    });\n\n    return isEmpty(opsMap) ? undefined : { [tableId]: opsMap };\n  }\n\n  private async modifyType(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ): Promise<IOpsMap | undefined> {\n    if (oldField.isComputed && newField.isComputed) {\n      return;\n    }\n\n    if (!oldField.isComputed && newField.isComputed) {\n      return this.buildBasalOpsMap(tableId, newField, oldField);\n    }\n\n    if (newField.type === FieldType.SingleSelect || newField.type === FieldType.MultipleSelect) {\n      return this.convert2Select(tableId, newField, oldField);\n    }\n\n    if (newField.type === FieldType.Link) {\n      return this.fieldConvertingLinkService.convertLink(tableId, newField, oldField);\n    }\n\n    if (newField.type === FieldType.User) {\n      return this.convert2User(tableId, newField, oldField);\n    }\n\n    return this.basalConvert(tableId, newField, oldField);\n  }\n\n  async updateReference(newField: IFieldInstance, oldField: FieldCore) {\n    if (!this.shouldUpdateReference(newField, oldField)) {\n      return;\n    }\n\n    await this.prismaService.txClient().reference.deleteMany({\n      where: { toFieldId: oldField.id },\n    });\n\n    await this.fieldSupplementService.createReference(newField);\n  }\n\n  private shouldUpdateReference(newField: IFieldInstance, oldField: FieldCore) {\n    const keys = this.getOriginFieldKeys(newField, oldField);\n    if (newField.type === FieldType.Link && !newField.isLookup) {\n      if (\n        keys.includes('options') &&\n        newField.type === oldField.type &&\n        newField.options.lookupFieldId !== (oldField.options as ILinkFieldOptions).lookupFieldId\n      ) {\n        return true;\n      }\n\n      return false;\n    }\n\n    // lookup options change\n    if (newField.isLookup && oldField.isLookup) {\n      return keys.includes('lookupOptions');\n    }\n\n    // major change\n    if (keys.includes('type') || keys.includes('isComputed') || keys.includes('isLookup')) {\n      return true;\n    }\n\n    // for same field with options change\n    if (keys.includes('options')) {\n      return (\n        ((newField.type === FieldType.Rollup || newField.type === FieldType.Formula) &&\n          newField.options.expression !== (oldField as FormulaFieldDto).options.expression) ||\n        newField.type === FieldType.ConditionalRollup\n      );\n    }\n\n    // for same field with lookup options change\n    return keys.includes('lookupOptions');\n  }\n\n  private async generateModifiedOps(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ): Promise<IOpsMap | undefined> {\n    const keys = this.getOriginFieldKeys(newField, oldField);\n\n    if (newField.isLookup && oldField.isLookup) {\n      return;\n    }\n\n    // for field type change, isLookup change, isComputed change\n    if (keys.includes('type') || keys.includes('isComputed') || keys.includes('isLookup')) {\n      return this.modifyType(tableId, newField, oldField);\n    }\n\n    // for same field with options change\n    if (keys.includes('options') && majorOptionsKeyChanged(oldField.options, newField.options)) {\n      return await this.modifyOptions(tableId, newField, oldField);\n    }\n  }\n\n  needCalculate(newField: IFieldInstance, oldField: FieldCore) {\n    if (!newField.isComputed) {\n      return false;\n    }\n\n    if (newField.hasError !== oldField.hasError) {\n      return true;\n    }\n\n    if (majorFieldKeysChanged(oldField, newField)) {\n      return true;\n    }\n\n    if (this.hasConditionalLookupDiff(newField, oldField)) {\n      return true;\n    }\n\n    if (this.hasConditionalRollupDiff(newField, oldField)) {\n      return true;\n    }\n\n    return false;\n  }\n\n  private hasConditionalLookupDiff(newField: IFieldInstance, oldField: FieldCore) {\n    if (!newField.isConditionalLookup) {\n      return false;\n    }\n\n    return !isEqual(newField.lookupOptions, oldField.lookupOptions);\n  }\n\n  private hasConditionalRollupDiff(newField: IFieldInstance, oldField: FieldCore) {\n    if (newField.type !== FieldType.ConditionalRollup) {\n      return false;\n    }\n\n    return !isEqual(newField.options, oldField.options);\n  }\n\n  private async calculateField(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    if (!newField.isComputed) {\n      return;\n    }\n\n    const errorStateChanged = newField.hasError !== oldField.hasError;\n    const hasMajorChange = majorFieldKeysChanged(oldField, newField);\n    const conditionalLookupDiff = this.hasConditionalLookupDiff(newField, oldField);\n    const conditionalRollupDiff = this.hasConditionalRollupDiff(newField, oldField);\n\n    if (!errorStateChanged && !hasMajorChange && !conditionalLookupDiff && !conditionalRollupDiff) {\n      return;\n    }\n\n    this.logger.log(`calculating field: ${newField.name}`);\n\n    await this.fieldService.resolvePending(tableId, [newField.id]);\n  }\n\n  private async submitFieldOpsMap(fieldOpsMap: IOpsMap | undefined) {\n    if (!fieldOpsMap) {\n      return;\n    }\n\n    for (const tableId in fieldOpsMap) {\n      const opData = Object.entries(fieldOpsMap[tableId]).map(([fieldId, ops]) => ({\n        fieldId,\n        ops,\n      }));\n      await this.fieldService.batchUpdateFields(tableId, opData);\n    }\n  }\n\n  // for link ref and create or delete supplement link, (create, delete do not need calculate)\n  async deleteOrCreateSupplementLink(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    await this.fieldConvertingLinkService.deleteOrCreateSupplementLink(tableId, newField, oldField);\n  }\n\n  private needTempleCloseFieldConstraint(newField: IFieldInstance, oldField: IFieldInstance) {\n    return (\n      (majorFieldKeysChanged(oldField, newField) ||\n        newField.dbFieldName !== oldField.dbFieldName) &&\n      (oldField.unique || oldField.notNull)\n    );\n  }\n\n  async alterFieldConstraint(tableId: string, newField: IFieldInstance, oldField: IFieldInstance) {\n    const { dbTableName, name: tableName } = await this.prismaService\n      .txClient()\n      .tableMeta.findUniqueOrThrow({\n        where: { id: tableId },\n        select: { dbTableName: true, name: true },\n      });\n\n    // index do not support date cell value type\n    if (newField.cellValueType !== CellValueType.DateTime) {\n      await this.tableIndexService.createSearchFieldSingleIndex(tableId, newField);\n    }\n\n    if (!this.needTempleCloseFieldConstraint(newField, oldField)) {\n      return;\n    }\n    const { unique, notNull, dbFieldName } = newField;\n    const fieldValidationQuery = this.knex.schema\n      .alterTable(dbTableName, (table) => {\n        if (unique)\n          table.unique([dbFieldName], {\n            indexName: this.fieldService.getFieldUniqueKeyName(\n              dbTableName,\n              dbFieldName,\n              newField.id\n            ),\n          });\n        if (notNull) table.dropNullable(dbFieldName);\n      })\n      .toQuery();\n\n    await handleDBValidationErrors({\n      fn: () => this.prismaService.txClient().$executeRawUnsafe(fieldValidationQuery),\n      handleUniqueError: () => {\n        throw new CustomHttpException(\n          `Field ${oldField.id} unique validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueDuplicate',\n              context: { fieldName: oldField.name, tableName },\n            },\n          }\n        );\n      },\n      handleNotNullError: () => {\n        throw new CustomHttpException(\n          `Field ${oldField.id} not null validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueNotNull',\n              context: { fieldName: oldField.name, tableName },\n            },\n          }\n        );\n      },\n    });\n  }\n\n  async closeConstraint(tableId: string, newField: IFieldInstance, oldField: IFieldInstance) {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n\n    await this.tableIndexService.deleteSearchFieldIndex(tableId, oldField);\n\n    const { unique, notNull, dbFieldName } = oldField;\n\n    if (!this.needTempleCloseFieldConstraint(newField, oldField)) {\n      return;\n    }\n\n    const matchedIndexes = await this.fieldService.findUniqueIndexesForField(\n      dbTableName,\n      dbFieldName\n    );\n\n    const fieldValidationQuery = this.knex.schema\n      .alterTable(dbTableName, (table) => {\n        if (unique) {\n          matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName));\n        }\n        if (notNull) table.setNullable(dbFieldName);\n      })\n      .toSQL();\n\n    const executeSqls = fieldValidationQuery\n      .filter((s) => !s.sql.startsWith('PRAGMA'))\n      .map(({ sql }) => sql);\n\n    for (const sql of executeSqls) {\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n  }\n\n  async stageAnalysis(tableId: string, fieldId: string, updateFieldRo: IConvertFieldRo) {\n    const oldFieldVo = await this.fieldService.getField(tableId, fieldId);\n    const oldField = createFieldInstanceByVo(oldFieldVo);\n\n    if (oldField.isPrimary && !PRIMARY_SUPPORTED_TYPES.has(updateFieldRo.type)) {\n      throw new CustomHttpException(\n        `Field type ${updateFieldRo.type} is not supported as primary field`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.unsupportedPrimaryFieldType',\n            context: { type: updateFieldRo.type },\n          },\n        }\n      );\n    }\n\n    const newFieldVo = await this.fieldSupplementService.prepareUpdateField(\n      tableId,\n      updateFieldRo,\n      oldField\n    );\n\n    const newField = createFieldInstanceByVo(newFieldVo);\n\n    const modifiedOps = await this.generateModifiedOps(tableId, newField, oldField);\n\n    // 2. collect changes effect by the supplement(link) field\n    // supplementChange is only for link relationship change\n    const references = (await this.fieldConvertingLinkService.analysisReference(oldField)) || [];\n    const supplementChange = await this.fieldConvertingLinkService.analysisSupplementLink(\n      newField,\n      oldField\n    );\n    return {\n      newField,\n      oldField,\n      modifiedOps,\n      supplementChange,\n      references: references.concat(fieldId),\n    };\n  }\n\n  async updateAiConfigReference(tableId: string, newField: IFieldInstance, oldField: FieldCore) {\n    if (JSON.stringify(newField.aiConfig) === JSON.stringify(oldField.aiConfig)) return;\n\n    await this.fieldSupplementService.createFieldTaskReference(tableId, newField);\n  }\n\n  async stageAlter(tableId: string, newField: IFieldInstance, oldField: FieldCore) {\n    const ops = this.getOriginFieldOps(newField, oldField);\n\n    if (this.needCalculate(newField, oldField)) {\n      ops.push(\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'isPending',\n          newValue: true,\n          oldValue: undefined,\n        })\n      );\n    }\n\n    // apply current field changes\n    await this.fieldService.batchUpdateFields(tableId, [{ fieldId: newField.id, ops }]);\n\n    await this.updateReference(newField, oldField);\n\n    // apply ai config changes\n    await this.updateAiConfigReference(tableId, newField, oldField);\n\n    // apply referenced fields changes\n    await this.updateReferencedFields(newField, oldField);\n  }\n\n  async stageCalculate(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance,\n    recordOpsMap?: IOpsMap\n  ) {\n    // For two-way -> one-way toggles, we still need to apply recordOpsMap\n    // to persist preserved source link values, but can skip computed field recalculation.\n    const skipComputed = this.isTogglingToOneWay(newField, oldField);\n\n    // calculate and submit records\n    await this.calculateAndSaveRecords(tableId, newField, recordOpsMap);\n\n    // calculate computed fields unless explicitly skipped\n    if (!skipComputed) {\n      await this.calculateField(tableId, newField, oldField);\n    }\n  }\n\n  private isTogglingToOneWay(newField: IFieldInstance, oldField: IFieldInstance): boolean {\n    if (newField.type !== FieldType.Link || newField.isLookup) return false;\n    const newOpts = newField.options as ILinkFieldOptions;\n    const oldOpts = oldField.options as ILinkFieldOptions;\n    return (\n      newOpts.foreignTableId === oldOpts.foreignTableId &&\n      newOpts.relationship === oldOpts.relationship &&\n      Boolean(newOpts.isOneWay) &&\n      !oldOpts.isOneWay\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { FieldOpenApiModule } from '../open-api/field-open-api.module';\nimport { FieldCreatingService } from './field-creating.service';\n\ndescribe('FieldCreatingService', () => {\n  let service: FieldCreatingService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldOpenApiModule],\n    }).compile();\n\n    service = module.get<FieldCreatingService>(FieldCreatingService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { IColumn, IColumnMeta } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ViewService } from '../../view/view.service';\nimport { FieldService } from '../field.service';\nimport type { IFieldInstance } from '../model/factory';\nimport type { LinkFieldDto } from '../model/field-dto/link-field.dto';\nimport { FieldSupplementService } from './field-supplement.service';\n\n@Injectable()\nexport class FieldCreatingService {\n  private logger = new Logger(FieldCreatingService.name);\n\n  constructor(\n    private readonly viewService: ViewService,\n    private readonly fieldService: FieldService,\n    private readonly prismaService: PrismaService,\n    private readonly fieldSupplementService: FieldSupplementService\n  ) {}\n\n  async createFieldItem(\n    tableId: string,\n    field: IFieldInstance,\n    initViewColumnMap?: Record<string, IColumn>,\n    isSymmetricField?: boolean\n  ) {\n    const fieldId = field.id;\n\n    await this.fieldSupplementService.createReference(field);\n    await this.fieldSupplementService.createFieldTaskReference(tableId, field);\n\n    const dbTableName = await this.fieldService.getDbTableName(tableId);\n\n    await this.fieldService.batchCreateFields(tableId, dbTableName, [field], isSymmetricField);\n\n    await this.viewService.initViewColumnMeta(\n      tableId,\n      [fieldId],\n      initViewColumnMap && [initViewColumnMap]\n    );\n  }\n\n  private async createFieldItemsBatch(\n    tableId: string,\n    fieldInstances: IFieldInstance[],\n    initViewColumnMapList?: Array<Record<string, IColumn> | undefined>,\n    isSymmetricField?: boolean\n  ) {\n    if (!fieldInstances.length) return;\n\n    const dbTableName = await this.fieldService.getDbTableName(tableId);\n\n    for (const field of fieldInstances) {\n      await this.fieldSupplementService.createReference(field);\n    }\n    await this.fieldSupplementService.createFieldTaskReferences(tableId, fieldInstances);\n\n    await this.fieldService.batchCreateFields(\n      tableId,\n      dbTableName,\n      fieldInstances,\n      isSymmetricField\n    );\n\n    const fieldIds = fieldInstances.map((field) => field.id);\n    const shouldInit =\n      !!initViewColumnMapList?.length &&\n      initViewColumnMapList.some((m) => m && Object.keys(m).length);\n    const normalizedInitList = shouldInit\n      ? initViewColumnMapList.map((m) => m ?? ({} as Record<string, IColumn>))\n      : undefined;\n\n    await this.viewService.initViewColumnMeta(tableId, fieldIds, normalizedInitList);\n  }\n\n  async createFields(\n    tableId: string,\n    fieldInstances: IFieldInstance[],\n    initViewColumnMap?: Record<string, IColumn>\n  ) {\n    const dbTableName = await this.fieldService.getDbTableName(tableId);\n\n    for (const field of fieldInstances) {\n      await this.fieldSupplementService.createReference(field);\n    }\n    await this.fieldSupplementService.createFieldTaskReferences(tableId, fieldInstances);\n    const fieldIds = fieldInstances.map((field) => field.id);\n    await this.viewService.initViewColumnMeta(\n      tableId,\n      fieldIds,\n      initViewColumnMap && fieldIds.map(() => initViewColumnMap)\n    );\n\n    await this.fieldService.batchCreateFieldsAtOnce(tableId, dbTableName, fieldInstances);\n  }\n\n  async alterCreateFieldsInExistingTable(\n    tableId: string,\n    fields: Array<{ field: IFieldInstance; columnMeta?: Record<string, IColumn> }>\n  ) {\n    if (!fields.length) return [] as { tableId: string; field: IFieldInstance }[];\n\n    const baseFieldInstances = fields.map(({ field }) => field);\n    const initViewColumnMapList = fields.map(({ columnMeta }) => columnMeta);\n\n    await this.createFieldItemsBatch(tableId, baseFieldInstances, initViewColumnMapList);\n\n    const created: { tableId: string; field: IFieldInstance }[] = baseFieldInstances.map(\n      (field) => ({\n        tableId,\n        field,\n      })\n    );\n\n    const linkFields = baseFieldInstances.filter(\n      (field) => field.type === FieldType.Link && !field.isLookup\n    ) as LinkFieldDto[];\n\n    // Generate and create symmetric fields one-by-one so that each subsequent\n    // generateSymmetricField can see the previously created field records and\n    // PostgreSQL columns, avoiding duplicate dbFieldName collisions.\n    for (const linkField of linkFields) {\n      if (!linkField.options.symmetricFieldId) continue;\n      const symmetricField = await this.fieldSupplementService.generateSymmetricField(\n        tableId,\n        linkField\n      );\n      const foreignTableId = linkField.options.foreignTableId;\n      await this.createFieldItemsBatch(foreignTableId, [symmetricField], undefined, true);\n      created.push({ tableId: foreignTableId, field: symmetricField });\n    }\n\n    return created;\n  }\n\n  async alterCreateField(tableId: string, field: IFieldInstance, columnMeta?: IColumnMeta) {\n    const newFields: { tableId: string; field: IFieldInstance }[] = [];\n    if (field.type === FieldType.Link && !field.isLookup) {\n      // Foreign key creation is now handled by the visitor in createFieldItem\n      await this.createFieldItem(tableId, field, columnMeta);\n      newFields.push({ tableId, field });\n\n      if (field.options.symmetricFieldId) {\n        const symmetricField = await this.fieldSupplementService.generateSymmetricField(\n          tableId,\n          field\n        );\n\n        await this.createFieldItem(field.options.foreignTableId, symmetricField, columnMeta, true);\n        newFields.push({ tableId: field.options.foreignTableId, field: symmetricField });\n      }\n\n      return newFields;\n    }\n\n    await this.createFieldItem(tableId, field, columnMeta);\n    return [{ tableId, field: field }];\n  }\n\n  async alterCreateFields(\n    tableId: string,\n    fieldInstances: IFieldInstance[],\n    columnMeta?: IColumnMeta\n  ) {\n    const newFields: { tableId: string; field: IFieldInstance }[] = fieldInstances.map((field) => ({\n      tableId,\n      field,\n    }));\n\n    const primaryField = fieldInstances.find((field) => field.isPrimary)!;\n\n    await this.createFieldItem(tableId, primaryField, columnMeta);\n\n    const linkFields = fieldInstances.filter(\n      (field) => field.type === FieldType.Link && !field.isLookup\n    ) as LinkFieldDto[];\n\n    if (linkFields.length) {\n      const initViewColumnMapList = columnMeta\n        ? linkFields.map(() => columnMeta as unknown as Record<string, IColumn>)\n        : undefined;\n      await this.createFieldItemsBatch(tableId, linkFields, initViewColumnMapList);\n\n      // Generate and create symmetric fields one-by-one to avoid duplicate\n      // dbFieldName collisions when multiple links target the same foreign table.\n      for (const field of linkFields) {\n        if (!field.options.symmetricFieldId) continue;\n        const symmetricField = await this.fieldSupplementService.generateSymmetricField(\n          tableId,\n          field\n        );\n        const foreignTableId = field.options.foreignTableId;\n        await this.createFieldItemsBatch(foreignTableId, [symmetricField], undefined, true);\n        newFields.push({ tableId: foreignTableId, field: symmetricField });\n      }\n    }\n\n    const otherFields = fieldInstances.filter(\n      ({ id, isPrimary }) =>\n        (linkFields.length ? !linkFields.map(({ id }) => id).includes(id) : true) && !isPrimary\n    );\n\n    await this.createFields(tableId, otherFields, columnMeta);\n    return newFields;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { FieldOpenApiModule } from '../open-api/field-open-api.module';\nimport { FieldDeletingService } from './field-deleting.service';\n\ndescribe('FieldDeletingService', () => {\n  let service: FieldDeletingService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldOpenApiModule],\n    }).compile();\n\n    service = module.get<FieldDeletingService>(FieldDeletingService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport { FieldOpBuilder, FieldType, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { difference, keyBy } from 'lodash';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { DropColumnOperationType } from '../../../db-provider/drop-database-column-query/drop-database-column-field-visitor.interface';\nimport { Timing } from '../../../utils/timing';\nimport { FieldCalculationService } from '../../calculation/field-calculation.service';\nimport { TableIndexService } from '../../table/table-index.service';\nimport { FieldService } from '../field.service';\nimport { IFieldInstance, createFieldInstanceByRaw } from '../model/factory';\nimport { FieldSupplementService } from './field-supplement.service';\nimport { FormulaFieldService } from './formula-field.service';\n\n@Injectable()\nexport class FieldDeletingService {\n  private logger = new Logger(FieldDeletingService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly fieldService: FieldService,\n    private readonly tableIndexService: TableIndexService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly fieldCalculationService: FieldCalculationService,\n    private readonly formulaFieldService: FormulaFieldService\n  ) {}\n\n  private async markFieldsAsError(tableId: string, fieldIds: string[]) {\n    const opData = fieldIds.map((fieldId) => ({\n      fieldId,\n      ops: [\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'hasError',\n          oldValue: undefined,\n          newValue: true,\n        }),\n      ],\n    }));\n    await this.fieldService.batchUpdateFields(tableId, opData);\n  }\n\n  async cleanLookupRollupRef(tableId: string, fieldId: string) {\n    const errorLookupFieldIds =\n      await this.fieldSupplementService.deleteLookupFieldReference(fieldId);\n    await this.markFieldsAsError(tableId, errorLookupFieldIds);\n  }\n\n  async resetLinkFieldLookupFieldId(\n    fieldIds: string[],\n    lookupedTableId: string,\n    lookupedFieldId: string\n  ) {\n    const prisma = this.prismaService.txClient();\n    const lookupedPrimaryField = await prisma.field.findFirst({\n      where: { tableId: lookupedTableId, isPrimary: true },\n      select: { id: true },\n    });\n\n    if (!lookupedPrimaryField) {\n      return [];\n    }\n\n    const fieldRaws = await prisma.field.findMany({\n      where: {\n        id: { in: fieldIds },\n        type: FieldType.Link,\n        deletedTime: null,\n      },\n    });\n\n    const toSetLookupFieldId = lookupedPrimaryField.id;\n\n    const fieldRawMap = keyBy(fieldRaws, 'id');\n\n    const fieldInstances = fieldRaws\n      .filter((field) => field.type === FieldType.Link && !field.isLookup)\n      .map((field) => createFieldInstanceByRaw(field))\n      .filter((field) => {\n        const option = field.options as ILinkFieldOptions;\n        return (\n          option.foreignTableId === lookupedTableId && option.lookupFieldId === lookupedFieldId\n        );\n      });\n\n    for (const field of fieldInstances) {\n      const options = field.options as ILinkFieldOptions;\n      const newOption = {\n        ...options,\n        lookupFieldId: toSetLookupFieldId,\n      };\n      const opData = [\n        {\n          fieldId: field.id,\n          ops: [\n            FieldOpBuilder.editor.setFieldProperty.build({\n              key: 'options',\n              oldValue: options,\n              newValue: newOption,\n            }),\n          ],\n        },\n      ];\n\n      await this.fieldService.batchUpdateFields(fieldRawMap[field.id].tableId, opData);\n\n      const reference = await this.prismaService.txClient().reference.findFirst({\n        where: {\n          fromFieldId: toSetLookupFieldId,\n          toFieldId: field.id,\n        },\n      });\n\n      if (!reference) {\n        await this.prismaService.txClient().reference.create({\n          data: {\n            fromFieldId: toSetLookupFieldId,\n            toFieldId: field.id,\n          },\n        });\n      }\n    }\n\n    return fieldInstances.map((field) => field.id);\n  }\n\n  async cleanRef(tableId: string, field: IFieldInstance) {\n    // 2. Delete reference relationships\n    const errorRefFieldIds = await this.fieldSupplementService.deleteReference(field.id);\n\n    // 3. Filter out fields that have already been cascade deleted\n    const remainingErrorFieldIds = errorRefFieldIds;\n\n    const resetLinkFieldIds = await this.resetLinkFieldLookupFieldId(\n      remainingErrorFieldIds,\n      tableId,\n      field.id\n    );\n\n    const errorLookupFieldIds =\n      !field.isLookup &&\n      field.type === FieldType.Link &&\n      (await this.fieldSupplementService.deleteLookupFieldReference(field.id));\n    const errorFieldIds = difference(remainingErrorFieldIds, resetLinkFieldIds).concat(\n      errorLookupFieldIds || []\n    );\n\n    // 4. Mark remaining fields as error\n    if (errorFieldIds.length > 0) {\n      // Additionally, propagate error to downstream formula fields (same table) that depend\n      // on these errored fields (e.g., a -> b -> c; deleting a should set b and c hasError)\n      const transitiveFormulaIds = new Set<string>();\n      for (const fid of errorFieldIds) {\n        try {\n          const deps = await this.formulaFieldService.getDependentFormulaFieldsInOrder(fid);\n          deps.filter((d) => d.tableId === tableId).forEach((d) => transitiveFormulaIds.add(d.id));\n        } catch (e) {\n          this.logger.warn(`Failed to load dependent formulas for field ${fid}: ${e}`);\n        }\n      }\n\n      // Merge direct and transitive ids\n      const allErrorIds = Array.from(new Set<string>([...errorFieldIds, ...transitiveFormulaIds]));\n\n      const fieldRaws = await this.prismaService.txClient().field.findMany({\n        where: { id: { in: allErrorIds } },\n        select: { id: true, tableId: true },\n      });\n\n      for (const fieldRaw of fieldRaws) {\n        const { id, tableId } = fieldRaw;\n        await this.markFieldsAsError(tableId, [id]);\n      }\n    }\n  }\n\n  async deleteFieldItem(\n    tableId: string,\n    field: IFieldInstance,\n    operationType: DropColumnOperationType = DropColumnOperationType.DELETE_FIELD\n  ) {\n    await this.cleanRef(tableId, field);\n    await this.fieldService.batchDeleteFields(tableId, [field.id], operationType);\n  }\n\n  async getField(tableId: string, fieldId: string): Promise<IFieldInstance | null> {\n    const fieldRaw = await this.prismaService.field.findFirst({\n      where: { tableId, id: fieldId, deletedTime: null },\n    });\n    return fieldRaw && createFieldInstanceByRaw(fieldRaw);\n  }\n\n  @Timing()\n  async alterDeleteField(\n    tableId: string,\n    field: IFieldInstance\n  ): Promise<{ tableId: string; fieldId: string }[]> {\n    const { id: fieldId, type, isLookup, isPrimary } = field;\n\n    // forbid delete primary field\n    if (isPrimary) {\n      throw new CustomHttpException(\n        `Forbid delete primary field`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.forbidDeletePrimaryField',\n          },\n        }\n      );\n    }\n\n    // delete index first\n    await this.tableIndexService.deleteSearchFieldIndex(tableId, field);\n\n    if (type === FieldType.Link && !isLookup) {\n      const linkFieldOptions = field.options;\n      const { foreignTableId, symmetricFieldId } = linkFieldOptions;\n      // Foreign key cleanup is handled in the drop visitor during deleteFieldItem\n      // First delete the main field and its FK artifacts\n      await this.deleteFieldItem(tableId, field, DropColumnOperationType.DELETE_FIELD);\n\n      if (symmetricFieldId) {\n        const symmetricField = await this.getField(foreignTableId, symmetricFieldId);\n        // When deleting the symmetric field as part of a bidirectional pair,\n        // preserve FK artifacts that were already dropped when deleting the main field\n        if (symmetricField) {\n          await this.deleteFieldItem(\n            foreignTableId,\n            symmetricField,\n            DropColumnOperationType.DELETE_SYMMETRIC_FIELD\n          );\n        }\n        return [\n          { tableId, fieldId },\n          { tableId: foreignTableId, fieldId: symmetricFieldId },\n        ];\n      }\n      return [{ tableId, fieldId }];\n    }\n\n    await this.deleteFieldItem(tableId, field);\n    return [{ tableId, fieldId }];\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { BadRequestException, Injectable } from '@nestjs/common';\nimport {\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  ButtonFieldCore,\n  CellValueType,\n  CheckboxFieldCore,\n  ColorUtils,\n  ConditionalRollupFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  DbFieldType,\n  extractFieldIdsFromFilter,\n  FieldAIActionType,\n  FieldType,\n  generateChoiceId,\n  generateFieldId,\n  getAiConfigSchema,\n  getDbFieldType,\n  getDefaultFormatting,\n  getFormattingSchema,\n  getRandomString,\n  getShowAsSchema,\n  getUniqName,\n  isMultiValueLink,\n  isConditionalLookupOptions,\n  isLinkLookupOptions,\n  LastModifiedTimeFieldCore,\n  LongTextFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  Relationship,\n  RelationshipRevert,\n  SelectFieldCore,\n  SingleLineTextFieldCore,\n  UserFieldCore,\n  HttpErrorCode,\n} from '@teable/core';\nimport type {\n  IFieldRo,\n  IFieldVo,\n  IFormulaFieldOptions,\n  ILinkFieldOptions,\n  ILinkFieldOptionsRo,\n  ILinkFieldMeta,\n  ILookupOptionsRo,\n  ILookupOptionsVo,\n  IConditionalRollupFieldOptions,\n  IRollupFieldOptions,\n  ISelectFieldOptionsRo,\n  IConvertFieldRo,\n  IUserFieldOptions,\n  ITextFieldCustomizeAIConfig,\n  ITextFieldSummarizeAIConfig,\n  IConditionalLookupOptions,\n  INumberFieldOptions,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { uniq, keyBy, mergeWith } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport type { z } from 'zod';\nimport { fromZodError } from 'zod-validation-error';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { extractFieldReferences } from '../../../utils';\nimport {\n  majorFieldKeysChanged,\n  NON_INFECT_OPTION_KEYS,\n} from '../../../utils/major-field-keys-changed';\nimport { ReferenceService } from '../../calculation/reference.service';\nimport { hasCycle } from '../../calculation/utils/dfs';\nimport { FieldService } from '../field.service';\nimport type { IFieldInstance } from '../model/factory';\nimport { createFieldInstanceByRaw, createFieldInstanceByVo } from '../model/factory';\nimport { ConditionalRollupFieldDto } from '../model/field-dto/conditional-rollup-field.dto';\nimport { FormulaFieldDto } from '../model/field-dto/formula-field.dto';\nimport type { LinkFieldDto } from '../model/field-dto/link-field.dto';\nimport { RollupFieldDto } from '../model/field-dto/rollup-field.dto';\n\ntype LinkFieldReference = Pick<IFieldVo, 'name' | 'isMultipleCellValue'> & {\n  options: Pick<ILinkFieldOptionsRo, 'relationship' | 'foreignTableId'> &\n    Partial<Pick<ILinkFieldOptions, 'fkHostTableName' | 'selfKeyName' | 'foreignKeyName'>>;\n};\n\n@Injectable()\nexport class FieldSupplementService {\n  constructor(\n    private readonly fieldService: FieldService,\n    private readonly prismaService: PrismaService,\n    private readonly referenceService: ReferenceService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private async getDbTableName(tableId: string) {\n    const tableMeta = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return tableMeta.dbTableName;\n  }\n\n  private getForeignKeyFieldName(fieldId: string | undefined) {\n    if (!fieldId) {\n      return `__fk_rad${getRandomString(16)}`;\n    }\n    return `__fk_${fieldId}`;\n  }\n\n  private getDefaultTimeZone(): string {\n    return Intl.DateTimeFormat().resolvedOptions().timeZone;\n  }\n\n  private async getJunctionTableName(\n    tableId: string,\n    fieldId: string,\n    symmetricFieldId: string | undefined\n  ) {\n    const { baseId } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { baseId: true },\n    });\n\n    const junctionTableName = symmetricFieldId\n      ? `junction_${fieldId}_${symmetricFieldId}`\n      : `junction_${fieldId}`;\n    return this.dbProvider.generateDbTableName(baseId, junctionTableName);\n  }\n\n  private async getDefaultLinkName(foreignTableId: string) {\n    const tableRaw = await this.prismaService.txClient().tableMeta.findUnique({\n      where: { id: foreignTableId },\n      select: { name: true },\n    });\n    if (!tableRaw) {\n      throw new CustomHttpException(\n        `foreignTableId ${foreignTableId} is invalid`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.foreignTableIdInvalid',\n            context: { foreignTableId },\n          },\n        }\n      );\n    }\n    return tableRaw.name;\n  }\n\n  private async generateLinkOptionsVo(params: {\n    tableId: string;\n    optionsRo: ILinkFieldOptionsRo;\n    fieldId: string;\n    symmetricFieldId: string | undefined;\n    lookupFieldId: string;\n    dbTableName: string;\n    foreignTableName: string;\n  }): Promise<ILinkFieldOptions> {\n    const {\n      tableId,\n      optionsRo,\n      fieldId,\n      symmetricFieldId,\n      lookupFieldId,\n      dbTableName,\n      foreignTableName,\n    } = params;\n    const { relationship, isOneWay = false } = optionsRo;\n    const common = {\n      ...optionsRo,\n      isOneWay: isOneWay || false,\n      symmetricFieldId,\n      lookupFieldId,\n    };\n\n    if (relationship === Relationship.ManyMany) {\n      const fkHostTableName = await this.getJunctionTableName(tableId, fieldId, symmetricFieldId);\n      return {\n        ...common,\n        fkHostTableName,\n        selfKeyName: this.getForeignKeyFieldName(symmetricFieldId),\n        foreignKeyName: this.getForeignKeyFieldName(fieldId),\n      };\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      return {\n        ...common,\n        fkHostTableName: dbTableName,\n        selfKeyName: '__id',\n        foreignKeyName: this.getForeignKeyFieldName(fieldId),\n      };\n    }\n\n    if (relationship === Relationship.OneMany) {\n      return {\n        ...common,\n        /**\n         * Semantically, one way link should not cause any side effects on the foreign table,\n         * so we should not modify the foreign table when `isOneWay` enable.\n         * Instead, we will create a junction table to store the foreign key.\n         */\n        fkHostTableName: isOneWay\n          ? await this.getJunctionTableName(tableId, fieldId, symmetricFieldId)\n          : foreignTableName,\n        selfKeyName: this.getForeignKeyFieldName(symmetricFieldId),\n        foreignKeyName: isOneWay ? this.getForeignKeyFieldName(fieldId) : '__id',\n      };\n    }\n\n    if (relationship === Relationship.OneOne) {\n      return {\n        ...common,\n        fkHostTableName: dbTableName,\n        selfKeyName: '__id',\n        foreignKeyName: this.getForeignKeyFieldName(fieldId),\n      };\n    }\n\n    throw new CustomHttpException('relationship is invalid', HttpErrorCode.VALIDATION_ERROR, {\n      localization: {\n        i18nKey: 'httpErrors.field.relationshipInvalid',\n        context: { relationship },\n      },\n    });\n  }\n\n  async generateNewLinkOptionsVo(\n    tableId: string,\n    fieldId: string,\n    optionsRo: ILinkFieldOptionsRo\n  ): Promise<ILinkFieldOptions> {\n    const { baseId, foreignTableId, isOneWay } = optionsRo;\n    let lookupFieldId = optionsRo.lookupFieldId;\n    const symmetricFieldId = isOneWay ? undefined : generateFieldId();\n    const dbTableName = await this.getDbTableName(tableId);\n    const foreignTableName = await this.getDbTableName(foreignTableId);\n\n    if (!lookupFieldId) {\n      const labelField = await this.prismaService.txClient().field.findFirst({\n        where: {\n          tableId: foreignTableId,\n          name: 'Label',\n          deletedTime: null,\n        },\n        select: { id: true },\n      });\n\n      if (labelField?.id) {\n        lookupFieldId = labelField.id;\n      } else {\n        const { id: defaultLookupFieldId } = await this.prismaService\n          .txClient()\n          .field.findFirstOrThrow({\n            where: { tableId: foreignTableId, isPrimary: true },\n            select: { id: true },\n          });\n        lookupFieldId = defaultLookupFieldId;\n      }\n    }\n\n    if (baseId) {\n      await this.prismaService\n        .txClient()\n        .tableMeta.findFirstOrThrow({\n          where: { id: foreignTableId, baseId, deletedTime: null },\n          select: { id: true },\n        })\n        .catch(() => {\n          throw new CustomHttpException(\n            `foreignTableId ${foreignTableId} is invalid`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.foreignTableIdInvalid',\n                context: { foreignTableId },\n              },\n            }\n          );\n        });\n    }\n\n    return this.generateLinkOptionsVo({\n      tableId,\n      optionsRo,\n      fieldId,\n      symmetricFieldId,\n      lookupFieldId,\n      dbTableName,\n      foreignTableName,\n    });\n  }\n\n  async generateUpdatedLinkOptionsVo(\n    tableId: string,\n    fieldId: string,\n    oldOptions: ILinkFieldOptions,\n    newOptionsRo: ILinkFieldOptionsRo\n  ): Promise<ILinkFieldOptions> {\n    const { baseId, foreignTableId, isOneWay } = newOptionsRo;\n\n    const dbTableName = await this.getDbTableName(tableId);\n    const foreignTableName = await this.getDbTableName(foreignTableId);\n\n    const symmetricFieldId = (() => {\n      if (isOneWay) {\n        return undefined;\n      }\n\n      if (oldOptions.isOneWay) {\n        return generateFieldId();\n      }\n\n      if (oldOptions.foreignTableId === newOptionsRo.foreignTableId) {\n        return oldOptions.symmetricFieldId;\n      }\n\n      return generateFieldId();\n    })();\n\n    let lookupFieldId = newOptionsRo.lookupFieldId;\n    if (!lookupFieldId) {\n      const sameTable = oldOptions.foreignTableId === foreignTableId;\n      if (sameTable) {\n        lookupFieldId = oldOptions.lookupFieldId;\n      }\n    }\n    if (!lookupFieldId) {\n      const labelField = await this.prismaService.txClient().field.findFirst({\n        where: { tableId: foreignTableId, name: 'Label', deletedTime: null },\n        select: { id: true },\n      });\n      if (labelField?.id) {\n        lookupFieldId = labelField.id;\n      } else {\n        const { id: defaultLookupFieldId } = await this.prismaService\n          .txClient()\n          .field.findFirstOrThrow({\n            where: { tableId: foreignTableId, isPrimary: true, deletedTime: null },\n            select: { id: true },\n          });\n        lookupFieldId = defaultLookupFieldId;\n      }\n    }\n\n    if (baseId) {\n      await this.prismaService\n        .txClient()\n        .tableMeta.findFirstOrThrow({\n          where: { id: foreignTableId, baseId, deletedTime: null },\n          select: { id: true },\n        })\n        .catch(() => {\n          throw new CustomHttpException(\n            `foreignTableId ${foreignTableId} is invalid`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.foreignTableIdInvalid',\n                context: { foreignTableId },\n              },\n            }\n          );\n        });\n    }\n\n    const isSameSymmetricFieldId =\n      (!symmetricFieldId && !oldOptions.symmetricFieldId) ||\n      symmetricFieldId === oldOptions.symmetricFieldId;\n\n    if (\n      newOptionsRo.foreignTableId === oldOptions.foreignTableId &&\n      newOptionsRo.relationship === oldOptions.relationship &&\n      isSameSymmetricFieldId\n    ) {\n      return {\n        ...newOptionsRo,\n        isOneWay: isOneWay || false,\n        symmetricFieldId,\n        lookupFieldId,\n        fkHostTableName: oldOptions.fkHostTableName,\n        selfKeyName: oldOptions.selfKeyName,\n        foreignKeyName: oldOptions.foreignKeyName,\n      };\n    }\n\n    return this.generateLinkOptionsVo({\n      tableId,\n      optionsRo: newOptionsRo,\n      fieldId,\n      symmetricFieldId,\n      lookupFieldId,\n      dbTableName,\n      foreignTableName,\n    });\n  }\n\n  private async prepareLinkField(tableId: string, field: IFieldRo) {\n    let options = field.options as ILinkFieldOptionsRo;\n    const { baseId, relationship, foreignTableId } = options;\n\n    // if link target is in the same base, we should not set baseId\n    if (baseId) {\n      const tableMeta = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n        where: { id: tableId, deletedTime: null },\n        select: { id: true, baseId: true },\n      });\n      if (tableMeta.baseId === baseId) {\n        options = {\n          ...options,\n          baseId: undefined,\n        };\n      }\n    }\n\n    const fieldId = field.id ?? generateFieldId();\n    const optionsVo = await this.generateNewLinkOptionsVo(tableId, fieldId, options);\n\n    return {\n      ...field,\n      id: fieldId,\n      name: field.name ?? (await this.getDefaultLinkName(foreignTableId)),\n      options: optionsVo,\n      isMultipleCellValue: isMultiValueLink(relationship) || undefined,\n      dbFieldType: DbFieldType.Json,\n      cellValueType: CellValueType.String,\n      meta: this.buildLinkFieldMeta(optionsVo),\n    };\n  }\n\n  // only for linkField to linkField\n  private async prepareUpdateLinkField(tableId: string, fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    if (!majorFieldKeysChanged(oldFieldVo, fieldRo)) {\n      return mergeWith({}, oldFieldVo, fieldRo, (_oldValue: unknown, newValue: unknown) => {\n        if (Array.isArray(newValue)) {\n          return newValue;\n        }\n      });\n    }\n\n    const newOptionsRo = fieldRo.options as ILinkFieldOptionsRo;\n    const oldOptions = oldFieldVo.options as ILinkFieldOptions;\n    // isOneWay may be undefined or false, so we should convert it to boolean\n    const oldIsOneWay = Boolean(oldOptions.isOneWay);\n    const newIsOneWay = Boolean(newOptionsRo.isOneWay);\n    if (\n      oldOptions.foreignTableId === newOptionsRo.foreignTableId &&\n      oldOptions.relationship === newOptionsRo.relationship &&\n      oldIsOneWay !== newIsOneWay\n    ) {\n      // Recompute full link options when toggling one-way <-> two-way to ensure\n      // fkHostTableName/selfKeyName/foreignKeyName are correct for the new mode.\n      const optionsVo = await this.generateUpdatedLinkOptionsVo(\n        tableId,\n        oldFieldVo.id,\n        oldOptions,\n        newOptionsRo\n      );\n\n      return {\n        ...oldFieldVo,\n        ...fieldRo,\n        options: optionsVo,\n        isMultipleCellValue: isMultiValueLink(optionsVo.relationship) || undefined,\n        dbFieldType: DbFieldType.Json,\n        cellValueType: CellValueType.String,\n        meta: this.buildLinkFieldMeta(optionsVo),\n      };\n    }\n\n    const fieldId = oldFieldVo.id;\n\n    const optionsVo = await this.generateUpdatedLinkOptionsVo(\n      tableId,\n      fieldId,\n      oldOptions,\n      newOptionsRo\n    );\n\n    return {\n      ...oldFieldVo,\n      ...fieldRo,\n      options: optionsVo,\n      isMultipleCellValue: isMultiValueLink(optionsVo.relationship) || undefined,\n      dbFieldType: DbFieldType.Json,\n      cellValueType: CellValueType.String,\n      meta: this.buildLinkFieldMeta(optionsVo),\n    };\n  }\n\n  private buildLinkFieldMeta(options: ILinkFieldOptions): ILinkFieldMeta {\n    const { relationship, isOneWay } = options;\n    const hasOrderColumn =\n      relationship === Relationship.ManyMany ||\n      relationship === Relationship.ManyOne ||\n      relationship === Relationship.OneOne ||\n      (relationship === Relationship.OneMany && !isOneWay);\n\n    return { hasOrderColumn: Boolean(hasOrderColumn) };\n  }\n\n  private async prepareLookupOptions(field: IFieldRo, batchFieldVos?: IFieldVo[]) {\n    const { lookupOptions } = field;\n    if (!lookupOptions) {\n      throw new CustomHttpException(`lookupOptions is required`, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'editor.lookup.lookupOptionsRequired',\n        },\n      });\n    }\n\n    if (!isLinkLookupOptions(lookupOptions)) {\n      throw new BadRequestException('lookupOptions.linkFieldId is required for lookup fields');\n    }\n\n    const { linkFieldId, lookupFieldId, foreignTableId } = lookupOptions;\n    const linkFieldRaw = await this.prismaService.txClient().field.findFirst({\n      where: { id: linkFieldId, deletedTime: null, type: FieldType.Link },\n      select: { name: true, options: true, isMultipleCellValue: true },\n    });\n\n    const optionsRaw = linkFieldRaw?.options || null;\n    const batchLinkField = batchFieldVos?.find(\n      (candidate) => candidate.id === linkFieldId && candidate.type === FieldType.Link\n    );\n    const linkFieldOptions: LinkFieldReference['options'] | undefined =\n      (optionsRaw && (JSON.parse(optionsRaw as string) as ILinkFieldOptions)) ||\n      (batchLinkField?.options as ILinkFieldOptions | ILinkFieldOptionsRo | undefined);\n\n    const linkFieldReference: LinkFieldReference | undefined =\n      linkFieldRaw && linkFieldOptions\n        ? {\n            name: linkFieldRaw.name,\n            isMultipleCellValue: linkFieldRaw.isMultipleCellValue ?? undefined,\n            options: linkFieldOptions,\n          }\n        : batchLinkField && linkFieldOptions\n          ? {\n              name: batchLinkField.name,\n              isMultipleCellValue:\n                batchLinkField.isMultipleCellValue ??\n                (isMultiValueLink(linkFieldOptions.relationship) || undefined),\n              options: linkFieldOptions,\n            }\n          : undefined;\n\n    if (!linkFieldReference) {\n      throw new CustomHttpException(\n        `linkFieldId ${linkFieldId} is invalid`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.linkFieldIdInvalid',\n            context: { linkFieldId },\n          },\n        }\n      );\n    }\n\n    if (foreignTableId !== linkFieldReference.options.foreignTableId) {\n      throw new CustomHttpException(\n        `foreignTableId ${foreignTableId} is invalid`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.foreignTableIdInvalid',\n            context: { foreignTableId },\n          },\n        }\n      );\n    }\n\n    const lookupFieldRaw = await this.prismaService.txClient().field.findFirst({\n      where: { id: lookupFieldId, deletedTime: null },\n    });\n\n    if (!lookupFieldRaw) {\n      throw new CustomHttpException(\n        `Lookup field ${lookupFieldId} is invalid`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldIdInvalid',\n            context: { lookupFieldId },\n          },\n        }\n      );\n    }\n\n    return {\n      lookupOptions: {\n        ...lookupOptions,\n        relationship: linkFieldReference.options.relationship,\n        fkHostTableName: linkFieldReference.options.fkHostTableName,\n        selfKeyName: linkFieldReference.options.selfKeyName,\n        foreignKeyName: linkFieldReference.options.foreignKeyName,\n      },\n      lookupFieldRaw,\n      linkField: linkFieldReference,\n    };\n  }\n\n  getDbFieldType(\n    fieldType: FieldType,\n    cellValueType: CellValueType,\n    isMultipleCellValue?: boolean\n  ) {\n    return getDbFieldType(fieldType, cellValueType, isMultipleCellValue);\n  }\n\n  prepareFormattingShowAs(\n    options: IFieldRo['options'] = {},\n    sourceOptions: IFieldVo['options'],\n    cellValueType: CellValueType,\n    isMultipleCellValue?: boolean\n  ) {\n    const sourceFormatting = 'formatting' in sourceOptions ? sourceOptions.formatting : undefined;\n    const showAsSchema = getShowAsSchema(cellValueType, isMultipleCellValue);\n    let sourceShowAs = 'showAs' in sourceOptions ? sourceOptions.showAs : undefined;\n\n    // if source showAs is invalid, we should ignore it\n    if (sourceShowAs && !showAsSchema.safeParse(sourceShowAs).success) {\n      sourceShowAs = undefined;\n    }\n\n    const formatting =\n      'formatting' in options\n        ? options.formatting\n        : sourceFormatting\n          ? sourceFormatting\n          : getDefaultFormatting(cellValueType);\n\n    const showAs = 'showAs' in options ? options.showAs : sourceShowAs;\n\n    return {\n      ...sourceOptions,\n      formatting,\n      showAs,\n    };\n  }\n\n  private async prepareLookupField(fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) {\n    if (fieldRo.isConditionalLookup) {\n      return this.prepareConditionalLookupField(fieldRo);\n    }\n\n    const { lookupOptions, lookupFieldRaw, linkField } = await this.prepareLookupOptions(\n      fieldRo,\n      batchFieldVos\n    );\n\n    if (lookupFieldRaw.type !== fieldRo.type) {\n      throw new CustomHttpException(\n        `Current field type ${fieldRo.type} is not equal to lookup field (${lookupFieldRaw.type})`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldTypeNotEqual',\n            context: { fieldType: fieldRo.type, lookupFieldType: lookupFieldRaw.type },\n          },\n        }\n      );\n    }\n\n    const isMultipleCellValue =\n      linkField.isMultipleCellValue || lookupFieldRaw.isMultipleCellValue || false;\n\n    const cellValueType = lookupFieldRaw.cellValueType as CellValueType;\n\n    const options = this.prepareFormattingShowAs(\n      fieldRo.options,\n      JSON.parse(lookupFieldRaw.options as string),\n      cellValueType,\n      isMultipleCellValue\n    );\n\n    return {\n      ...fieldRo,\n      name: fieldRo.name ?? `${lookupFieldRaw.name} (from ${linkField.name})`,\n      options,\n      lookupOptions,\n      isMultipleCellValue,\n      isComputed: true,\n      cellValueType,\n      dbFieldType: this.getDbFieldType(fieldRo.type, cellValueType, isMultipleCellValue),\n    };\n  }\n\n  private async prepareUpdateLookupField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    if (fieldRo.isConditionalLookup) {\n      return this.prepareConditionalLookupField(fieldRo);\n    }\n\n    const newLookupOptions = fieldRo.lookupOptions as ILookupOptionsRo | undefined;\n    const oldLookupOptions = oldFieldVo.lookupOptions as ILookupOptionsVo | undefined;\n\n    if (!newLookupOptions || !isLinkLookupOptions(newLookupOptions)) {\n      return this.prepareLookupField(fieldRo);\n    }\n\n    if (!oldLookupOptions || !isLinkLookupOptions(oldLookupOptions)) {\n      return this.prepareLookupField(fieldRo);\n    }\n    if (\n      oldFieldVo.isLookup &&\n      newLookupOptions.lookupFieldId === oldLookupOptions.lookupFieldId &&\n      newLookupOptions.linkFieldId === oldLookupOptions.linkFieldId &&\n      newLookupOptions.foreignTableId === oldLookupOptions.foreignTableId\n    ) {\n      const showAs = (fieldRo.options as Record<string, unknown> | undefined)?.showAs;\n      return {\n        ...oldFieldVo,\n        ...fieldRo,\n        options: {\n          ...oldFieldVo.options,\n          showAs,\n        },\n        lookupOptions: {\n          ...oldLookupOptions,\n          ...newLookupOptions,\n        },\n      };\n    }\n\n    return this.prepareLookupField(fieldRo);\n  }\n\n  private async prepareFormulaField(fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) {\n    let fieldIds;\n    try {\n      fieldIds = FormulaFieldDto.getReferenceFieldIds(\n        (fieldRo.options as IFormulaFieldOptions).expression\n      );\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `formula expression ${(fieldRo.options as IFormulaFieldOptions).expression} parse error: ${e.message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.formulaExpressionParseError',\n          },\n        }\n      );\n    }\n\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fieldIds }, deletedTime: null },\n    });\n\n    const fields = fieldRaws.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n    const batchFields = batchFieldVos?.map((fieldVo) => createFieldInstanceByVo(fieldVo));\n    const fieldMap = keyBy(fields.concat(batchFields || []), 'id');\n\n    const missingFieldIds = fieldIds.filter((id) => !fieldMap[id]);\n    if (missingFieldIds.length > 0) {\n      // Check if user might have used field names instead of field IDs\n      const looksLikeFieldNames = missingFieldIds.some(\n        (id) => !id.startsWith('fld') || id.length !== 19\n      );\n\n      const errorMessage = looksLikeFieldNames\n        ? `Formula references not found: ${missingFieldIds.join(', ')}. Formulas must use field IDs (fldXXXXXXXXXXXXXXXX format), not field names.`\n        : `Formula field references not found: ${missingFieldIds.join(', ')}. These field IDs do not exist in the table.`;\n\n      throw new CustomHttpException(errorMessage, HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: looksLikeFieldNames\n            ? 'httpErrors.field.formulaReferenceNotFieldId'\n            : 'httpErrors.field.formulaReferenceNotFound',\n          context: {\n            fieldIds: missingFieldIds.join(', '),\n          },\n        },\n      });\n    }\n\n    let cellValueType: CellValueType;\n    let isMultipleCellValue: boolean | undefined;\n\n    try {\n      ({ cellValueType, isMultipleCellValue } = FormulaFieldDto.getParsedValueType(\n        (fieldRo.options as IFormulaFieldOptions).expression,\n        fieldMap\n      ));\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `Parse formula expression ${(fieldRo.options as IFormulaFieldOptions).expression} error: ${\n          e.message\n        }`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.formulaExpressionParseError',\n          },\n        }\n      );\n    }\n\n    const formatting =\n      (fieldRo.options as IFormulaFieldOptions)?.formatting ?? getDefaultFormatting(cellValueType);\n    const timeZone =\n      (fieldRo.options as IFormulaFieldOptions)?.timeZone ?? this.getDefaultTimeZone();\n\n    return {\n      ...fieldRo,\n      name: fieldRo.name ?? 'Calculation',\n      options: {\n        ...fieldRo.options,\n        ...(formatting ? { formatting } : {}),\n        timeZone,\n      },\n      cellValueType,\n      isMultipleCellValue,\n      isComputed: true,\n      dbFieldType: this.getDbFieldType(\n        fieldRo.type,\n        cellValueType as CellValueType,\n        isMultipleCellValue\n      ),\n    };\n  }\n\n  private async prepareUpdateFormulaField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    if (!majorFieldKeysChanged(oldFieldVo, fieldRo)) {\n      return { ...oldFieldVo, ...fieldRo };\n    }\n\n    // For formula field updates, we need to handle a Zod validation edge case:\n    // When the request only specifies partial options (e.g., {timeZone: 'America/New_York'}),\n    // Zod's union schema may incorrectly match to lastModifiedTimeFieldOptionsRoSchema\n    // and add a default expression like 'LAST_MODIFIED_TIME()'.\n    //\n    // To fix this, we preserve the old expression when the new one is a known Zod default.\n    const oldOptions = (oldFieldVo.options ?? {}) as IFormulaFieldOptions;\n    const newOptions = (fieldRo.options ?? {}) as IFormulaFieldOptions;\n\n    // Known Zod default expressions that should not override user's actual expression\n    const zodDefaultExpressions = ['LAST_MODIFIED_TIME()', 'CREATED_TIME()'];\n    const isZodDefault = zodDefaultExpressions.includes(newOptions.expression);\n\n    // Determine which expression to use:\n    // - If new expression is a Zod default and old expression exists, preserve old\n    // - Otherwise use new expression (user explicitly set it)\n    const expression =\n      isZodDefault && oldOptions.expression ? oldOptions.expression : newOptions.expression;\n\n    // Only preserve timeZone from old options. Do NOT preserve formatting/showAs because:\n    // - The expression might change the cellValueType (e.g., Number -> String)\n    // - Old formatting may be incompatible with the new cellValueType\n    // - prepareFormulaField will generate appropriate default formatting based on new cellValueType\n    const mergedOptions: IFormulaFieldOptions = {\n      ...newOptions,\n      expression,\n      // Preserve timeZone if not explicitly set in newOptions\n      timeZone: newOptions.timeZone ?? oldOptions.timeZone,\n    };\n\n    const mergedFieldRo: IFieldRo = {\n      ...fieldRo,\n      options: mergedOptions,\n    };\n\n    return this.prepareFormulaField(mergedFieldRo);\n  }\n\n  private async prepareRollupField(field: IFieldRo, batchFieldVos?: IFieldVo[]) {\n    const { lookupOptions, linkField, lookupFieldRaw } = await this.prepareLookupOptions(\n      field,\n      batchFieldVos\n    );\n    const options = field.options as IRollupFieldOptions;\n    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);\n    if (!options) {\n      throw new CustomHttpException(\n        'rollup field options is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'editor.error.optionsRequired',\n          },\n        }\n      );\n    }\n\n    let valueType;\n    try {\n      valueType = RollupFieldDto.getParsedValueType(\n        options.expression,\n        lookupField.cellValueType,\n        lookupField.isMultipleCellValue || linkField.isMultipleCellValue || false\n      );\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `Parse rollup expression ${options.expression} error: ${e.message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.rollupExpressionParseError',\n          },\n        }\n      );\n    }\n\n    const { cellValueType, isMultipleCellValue } = valueType;\n\n    const formatting = options.formatting ?? getDefaultFormatting(cellValueType);\n\n    return {\n      ...field,\n      name: field.name ?? `${lookupFieldRaw.name} Rollup (from ${linkField.name})`,\n      options: {\n        ...options,\n        ...(formatting ? { formatting } : {}),\n      },\n      lookupOptions,\n      cellValueType,\n      isComputed: true,\n      isMultipleCellValue,\n      dbFieldType: this.getDbFieldType(\n        field.type,\n        cellValueType as CellValueType,\n        isMultipleCellValue\n      ),\n    };\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async prepareConditionalRollupField(field: IFieldRo) {\n    const rawOptions = field.options as IConditionalRollupFieldOptions | undefined;\n    const options = { ...(rawOptions || {}) } as IConditionalRollupFieldOptions | undefined;\n    if (!options) {\n      throw new CustomHttpException(\n        'Conditional rollup field options are required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.conditionalRollupOptionsRequired',\n          },\n        }\n      );\n    }\n\n    if (!options.sort || options.sort.fieldId == null) {\n      delete options.sort;\n    }\n    if (options.limit == null) {\n      delete options.limit;\n    }\n\n    const { foreignTableId, lookupFieldId } = options;\n\n    if (!foreignTableId) {\n      throw new CustomHttpException(\n        'Conditional rollup field foreignTableId is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.foreignTableIdRequired',\n          },\n        }\n      );\n    }\n\n    if (!lookupFieldId) {\n      throw new CustomHttpException(\n        'Conditional rollup field lookupFieldId is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldIdRequired',\n          },\n        }\n      );\n    }\n\n    const lookupFieldRaw = await this.prismaService.txClient().field.findFirst({\n      where: { id: lookupFieldId, deletedTime: null },\n    });\n\n    if (!lookupFieldRaw) {\n      throw new CustomHttpException(\n        `Conditional rollup field ${lookupFieldId} is not exist`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldNotExist',\n            context: { lookupFieldId },\n          },\n        }\n      );\n    }\n\n    if (lookupFieldRaw.tableId !== foreignTableId) {\n      throw new CustomHttpException(\n        `Conditional rollup field ${lookupFieldId} does not belong to table ${foreignTableId}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldNotBelongToTable',\n            context: { lookupFieldId, foreignTableId },\n          },\n        }\n      );\n    }\n\n    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);\n\n    const expression =\n      options.expression ??\n      ConditionalRollupFieldDto.defaultOptions(lookupField.cellValueType).expression!;\n\n    if (!ConditionalRollupFieldCore.supportsOrdering(expression)) {\n      delete options.sort;\n      delete options.limit;\n    }\n\n    let valueType;\n    try {\n      valueType = ConditionalRollupFieldDto.getParsedValueType(\n        expression,\n        lookupField.cellValueType,\n        lookupField.isMultipleCellValue ?? false\n      );\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      throw new CustomHttpException(\n        `Conditional rollup parse error: ${e.message}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.conditionalRollupParseError',\n            context: { message: e.message },\n          },\n        }\n      );\n    }\n\n    const { cellValueType, isMultipleCellValue } = valueType;\n\n    const formatting = options.formatting ?? getDefaultFormatting(cellValueType);\n    const timeZone = options.timeZone ?? this.getDefaultTimeZone();\n\n    const foreignTable = await this.prismaService.txClient().tableMeta.findUnique({\n      where: { id: foreignTableId },\n      select: { name: true },\n    });\n\n    const defaultName = foreignTable?.name\n      ? `${lookupFieldRaw.name} Reference (${foreignTable.name})`\n      : `${lookupFieldRaw.name} Reference`;\n\n    return {\n      ...field,\n      name: field.name ?? defaultName,\n      options: {\n        ...options,\n        ...(formatting ? { formatting } : {}),\n        expression,\n        timeZone,\n        foreignTableId,\n        lookupFieldId,\n      },\n      cellValueType,\n      isComputed: true,\n      isMultipleCellValue,\n      dbFieldType: this.getDbFieldType(\n        field.type,\n        cellValueType as CellValueType,\n        isMultipleCellValue\n      ),\n    };\n  }\n\n  private async prepareConditionalLookupField(field: IFieldRo) {\n    const lookupOptions = field.lookupOptions as ILookupOptionsRo | undefined;\n    const conditionalLookup = isConditionalLookupOptions(lookupOptions)\n      ? (lookupOptions as IConditionalLookupOptions)\n      : undefined;\n    if (!conditionalLookup) {\n      throw new CustomHttpException(\n        'Conditional lookup configuration is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.conditionalLookupOptionsRequired',\n          },\n        }\n      );\n    }\n\n    const { foreignTableId, lookupFieldId } = conditionalLookup;\n\n    if (!foreignTableId) {\n      throw new CustomHttpException(\n        'Conditional lookup foreignTableId is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.foreignTableIdRequired',\n          },\n        }\n      );\n    }\n\n    if (!lookupFieldId) {\n      throw new CustomHttpException(\n        'Conditional lookup lookupFieldId is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldIdRequired',\n          },\n        }\n      );\n    }\n\n    const lookupFieldRaw = await this.prismaService.txClient().field.findFirst({\n      where: { id: lookupFieldId, deletedTime: null },\n    });\n\n    if (!lookupFieldRaw) {\n      throw new CustomHttpException(\n        `Conditional lookup field ${lookupFieldId} is not exist`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldNotExist',\n            context: { lookupFieldId },\n          },\n        }\n      );\n    }\n\n    if (lookupFieldRaw.tableId !== foreignTableId) {\n      throw new CustomHttpException(\n        `Conditional lookup field ${lookupFieldId} does not belong to table ${foreignTableId}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldNotBelongToTable',\n            context: { lookupFieldId, foreignTableId },\n          },\n        }\n      );\n    }\n\n    if (lookupFieldRaw.type !== field.type) {\n      throw new CustomHttpException(\n        `Current field type ${field.type} is not equal to lookup field (${lookupFieldRaw.type})`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.lookupFieldTypeNotMatch',\n            context: { fieldType: field.type, lookupFieldType: lookupFieldRaw.type },\n          },\n        }\n      );\n    }\n\n    const lookupField = createFieldInstanceByRaw(lookupFieldRaw);\n    const cellValueType = lookupField.cellValueType as CellValueType;\n\n    const formatting = this.prepareFormattingShowAs(\n      field.options,\n      JSON.parse(lookupFieldRaw.options as string),\n      cellValueType,\n      true\n    );\n\n    const foreignTable = await this.prismaService.txClient().tableMeta.findUnique({\n      where: { id: foreignTableId },\n      select: { name: true },\n    });\n\n    const defaultName = foreignTable?.name\n      ? `${lookupFieldRaw.name} (${foreignTable.name})`\n      : `${lookupFieldRaw.name} Conditional Lookup`;\n\n    return {\n      ...field,\n      name: field.name ?? defaultName,\n      options: formatting,\n      lookupOptions: {\n        baseId: conditionalLookup.baseId,\n        foreignTableId,\n        lookupFieldId,\n        filter: conditionalLookup.filter,\n        sort: conditionalLookup.sort,\n        limit: conditionalLookup.limit,\n      },\n      isMultipleCellValue: true,\n      isComputed: true,\n      cellValueType,\n      dbFieldType: this.getDbFieldType(field.type, cellValueType, true),\n      // Clear hasError since we validated all required fields exist\n      hasError: undefined,\n    };\n  }\n\n  private async prepareUpdateRollupField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    const newOptions = fieldRo.options as IRollupFieldOptions;\n    const oldOptions = oldFieldVo.options as IRollupFieldOptions;\n\n    if (!majorFieldKeysChanged(oldFieldVo, fieldRo)) {\n      return { ...oldFieldVo, ...fieldRo };\n    }\n\n    const newLookupOptions = fieldRo.lookupOptions as ILookupOptionsRo | undefined;\n    const oldLookupOptions = oldFieldVo.lookupOptions as ILookupOptionsVo | undefined;\n\n    if (\n      !newLookupOptions ||\n      !oldLookupOptions ||\n      !isLinkLookupOptions(newLookupOptions) ||\n      !isLinkLookupOptions(oldLookupOptions)\n    ) {\n      return this.prepareRollupField(fieldRo);\n    }\n    if (\n      newOptions.expression === oldOptions.expression &&\n      newLookupOptions.lookupFieldId === oldLookupOptions.lookupFieldId &&\n      newLookupOptions.linkFieldId === oldLookupOptions.linkFieldId &&\n      newLookupOptions.foreignTableId === oldLookupOptions.foreignTableId\n    ) {\n      return {\n        ...oldFieldVo,\n        ...fieldRo,\n        options: {\n          ...oldOptions,\n          showAs: newOptions.showAs,\n          formatting: newOptions.formatting,\n        },\n        lookupOptions: { ...oldLookupOptions, ...newLookupOptions },\n      };\n    }\n\n    return this.prepareRollupField(fieldRo);\n  }\n\n  private prepareSingleTextField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Label',\n      options: options ?? SingleLineTextFieldCore.defaultOptions(),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Text,\n    };\n  }\n\n  private prepareLongTextField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Notes',\n      options: options ?? LongTextFieldCore.defaultOptions(),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Text,\n    };\n  }\n\n  private prepareNumberField(field: IFieldRo) {\n    const { name, options } = field;\n\n    // Handle empty options object - use default if options is null/undefined OR empty object without formatting\n    const numberOptions = options as INumberFieldOptions | undefined;\n    const needsDefault = !numberOptions || !numberOptions.formatting;\n    const finalOptions = needsDefault\n      ? { ...NumberFieldCore.defaultOptions(), ...numberOptions }\n      : numberOptions;\n\n    return {\n      ...field,\n      name: name ?? 'Number',\n      options: finalOptions,\n      cellValueType: CellValueType.Number,\n      dbFieldType: DbFieldType.Real,\n    };\n  }\n\n  private prepareRatingField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Rating',\n      options: options ?? RatingFieldCore.defaultOptions(),\n      cellValueType: CellValueType.Number,\n      dbFieldType: DbFieldType.Real,\n    };\n  }\n\n  private prepareSelectOptions(options: ISelectFieldOptionsRo, isMultiple: boolean) {\n    const optionsRo = (options ?? SelectFieldCore.defaultOptions()) as ISelectFieldOptionsRo;\n    const nameSet = new Set<string>();\n    const choices = optionsRo.choices.map((choice) => {\n      if (nameSet.has(choice.name)) {\n        throw new CustomHttpException(\n          `choice name ${choice.name} is already exists`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.choiceNameAlreadyExists',\n              context: { name: choice.name },\n            },\n          }\n        );\n      }\n      nameSet.add(choice.name);\n      return {\n        name: choice.name,\n        id: choice.id ?? generateChoiceId(),\n        color: choice.color ?? ColorUtils.randomColor()[0],\n      };\n    });\n\n    const defaultValue = optionsRo.defaultValue\n      ? [optionsRo.defaultValue].flat().filter((name) => nameSet.has(name))\n      : undefined;\n\n    return {\n      ...optionsRo,\n      defaultValue: isMultiple ? defaultValue : defaultValue?.[0],\n      choices,\n    };\n  }\n\n  private prepareSingleSelectField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Select',\n      options: this.prepareSelectOptions(options as ISelectFieldOptionsRo, false),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Text,\n    };\n  }\n\n  private prepareMultipleSelectField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Tags',\n      options: this.prepareSelectOptions(options as ISelectFieldOptionsRo, true),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n      isMultipleCellValue: true,\n    };\n  }\n\n  private prepareAttachmentField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Attachments',\n      options: options ?? AttachmentFieldCore.defaultOptions(),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n      isMultipleCellValue: true,\n    };\n  }\n\n  private async prepareUpdateUserField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    const mergeObj = {\n      ...oldFieldVo,\n      ...fieldRo,\n    };\n\n    return this.prepareUserField(mergeObj);\n  }\n\n  private prepareUserField(field: IFieldRo) {\n    const { name } = field;\n    const options: IUserFieldOptions =\n      (field.options as IUserFieldOptions) || UserFieldCore.defaultOptions();\n    const { isMultiple } = options;\n    const defaultValue = options.defaultValue ? [options.defaultValue].flat() : undefined;\n\n    return {\n      ...field,\n      name: name ?? `Collaborator${isMultiple ? 's' : ''}`,\n      options: {\n        ...options,\n        defaultValue: isMultiple ? defaultValue : defaultValue?.[0],\n      },\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n      isMultipleCellValue: isMultiple || undefined,\n    };\n  }\n\n  private prepareCreatedByField(field: IFieldRo) {\n    const { name, options = {} } = field;\n\n    return {\n      ...field,\n      isComputed: true,\n      name: name ?? `Created by`,\n      options: options,\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n    };\n  }\n\n  private prepareLastModifiedByField(field: IFieldRo) {\n    const { name, options = {} } = field;\n\n    return {\n      ...field,\n      isComputed: true,\n      name: name ?? `Last modified by`,\n      options: options,\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n    };\n  }\n\n  private prepareDateField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Date',\n      options: options ?? DateFieldCore.defaultOptions(),\n      cellValueType: CellValueType.DateTime,\n      dbFieldType: DbFieldType.DateTime,\n    };\n  }\n\n  private prepareAutoNumberField(field: IFieldRo) {\n    const { name } = field;\n    const options = field.options ?? AutoNumberFieldCore.defaultOptions();\n\n    return {\n      ...field,\n      name: name ?? 'ID',\n      options: { ...options, expression: 'AUTO_NUMBER()' },\n      cellValueType: CellValueType.Number,\n      dbFieldType: DbFieldType.Integer,\n      isComputed: true,\n    };\n  }\n\n  private prepareCreatedTimeField(field: IFieldRo) {\n    const { name } = field;\n    const options = field.options ?? CreatedTimeFieldCore.defaultOptions();\n\n    return {\n      ...field,\n      name: name ?? 'Created Time',\n      options: { ...options, expression: 'CREATED_TIME()' },\n      cellValueType: CellValueType.DateTime,\n      dbFieldType: DbFieldType.DateTime,\n      isComputed: true,\n    };\n  }\n\n  private prepareLastModifiedTimeField(field: IFieldRo) {\n    const { name } = field;\n    const options = {\n      ...LastModifiedTimeFieldCore.defaultOptions(),\n      ...(field.options ?? {}),\n    };\n\n    return {\n      ...field,\n      name: name ?? 'Last Modified Time',\n      options: { ...options, expression: 'LAST_MODIFIED_TIME()' },\n      cellValueType: CellValueType.DateTime,\n      dbFieldType: DbFieldType.DateTime,\n      isComputed: true,\n    };\n  }\n\n  private prepareCheckboxField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Done',\n      options: options ?? CheckboxFieldCore.defaultOptions(),\n      cellValueType: CellValueType.Boolean,\n      dbFieldType: DbFieldType.Boolean,\n    };\n  }\n\n  private prepareButtonField(field: IFieldRo) {\n    const { name, options } = field;\n\n    return {\n      ...field,\n      name: name ?? 'Button',\n      options: options ?? ButtonFieldCore.defaultOptions(),\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n    };\n  }\n\n  private async prepareCreateFieldInner(\n    tableId: string,\n    fieldRo: IFieldRo,\n    batchFieldVos?: IFieldVo[]\n  ) {\n    if (fieldRo.isLookup) {\n      return this.prepareLookupField(fieldRo, batchFieldVos);\n    }\n\n    switch (fieldRo.type) {\n      case FieldType.Link:\n        return this.prepareLinkField(tableId, fieldRo);\n      case FieldType.Rollup:\n        return this.prepareRollupField(fieldRo, batchFieldVos);\n      case FieldType.ConditionalRollup:\n        return this.prepareConditionalRollupField(fieldRo);\n      case FieldType.Formula:\n        return this.prepareFormulaField(fieldRo, batchFieldVos);\n      case FieldType.SingleLineText:\n        return this.prepareSingleTextField(fieldRo);\n      case FieldType.LongText:\n        return this.prepareLongTextField(fieldRo);\n      case FieldType.Number:\n        return this.prepareNumberField(fieldRo);\n      case FieldType.Rating:\n        return this.prepareRatingField(fieldRo);\n      case FieldType.SingleSelect:\n        return this.prepareSingleSelectField(fieldRo);\n      case FieldType.MultipleSelect:\n        return this.prepareMultipleSelectField(fieldRo);\n      case FieldType.Attachment:\n        return this.prepareAttachmentField(fieldRo);\n      case FieldType.User:\n        return this.prepareUserField(fieldRo);\n      case FieldType.Date:\n        return this.prepareDateField(fieldRo);\n      case FieldType.AutoNumber:\n        return this.prepareAutoNumberField(fieldRo);\n      case FieldType.CreatedTime:\n        return this.prepareCreatedTimeField(fieldRo);\n      case FieldType.LastModifiedTime:\n        return this.prepareLastModifiedTimeField(fieldRo);\n      case FieldType.CreatedBy:\n        return this.prepareCreatedByField(fieldRo);\n      case FieldType.LastModifiedBy:\n        return this.prepareLastModifiedByField(fieldRo);\n      case FieldType.Checkbox:\n        return this.prepareCheckboxField(fieldRo);\n      case FieldType.Button:\n        return this.prepareButtonField(fieldRo);\n      default:\n        throw new CustomHttpException(\n          `Unsupported field type ${fieldRo.type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.unsupportedFieldType',\n              context: { type: fieldRo.type },\n            },\n          }\n        );\n    }\n  }\n\n  private async prepareUpdateFieldInner(tableId: string, fieldRo: IFieldRo, oldFieldVo: IFieldVo) {\n    const hasMajorChange = majorFieldKeysChanged(oldFieldVo, fieldRo);\n\n    if (fieldRo.type !== oldFieldVo.type) {\n      return this.prepareCreateFieldInner(tableId, fieldRo);\n    }\n\n    if (!hasMajorChange) {\n      const mergedField = { ...oldFieldVo } as IFieldVo;\n      Object.entries(fieldRo).forEach(([key, value]) => {\n        if (value !== undefined && key !== 'options' && key !== 'lookupOptions') {\n          (mergedField as Record<string, unknown>)[key] = value;\n        }\n      });\n      if (fieldRo.options !== undefined) {\n        const oldOptions = (oldFieldVo.options ?? {}) as Record<string, unknown>;\n        const newOptions = fieldRo.options as Record<string, unknown>;\n        const mergedOptions = { ...oldOptions };\n\n        Object.entries(newOptions).forEach(([key, value]) => {\n          if (value === undefined) {\n            delete mergedOptions[key];\n          } else {\n            mergedOptions[key] = value;\n          }\n        });\n\n        Object.keys(oldOptions).forEach((key) => {\n          if (!(key in newOptions) && NON_INFECT_OPTION_KEYS.has(key)) {\n            delete mergedOptions[key];\n          }\n        });\n\n        mergedField.options = mergedOptions as IFieldVo['options'];\n      }\n      if (fieldRo.lookupOptions !== undefined) {\n        const oldLookupOptions = (oldFieldVo.lookupOptions ?? {}) as Record<string, unknown>;\n        const newLookupOptions = fieldRo.lookupOptions as Record<string, unknown>;\n        const mergedLookupOptions = { ...oldLookupOptions };\n\n        Object.entries(newLookupOptions).forEach(([key, value]) => {\n          if (value === undefined) {\n            delete mergedLookupOptions[key];\n          } else {\n            mergedLookupOptions[key] = value;\n          }\n        });\n\n        mergedField.lookupOptions = mergedLookupOptions as IFieldVo['lookupOptions'];\n      }\n      return mergedField;\n    }\n\n    if (fieldRo.isLookup && hasMajorChange) {\n      return this.prepareUpdateLookupField(fieldRo, oldFieldVo);\n    }\n\n    switch (fieldRo.type) {\n      case FieldType.Link: {\n        return this.prepareUpdateLinkField(tableId, fieldRo, oldFieldVo);\n      }\n      case FieldType.Rollup:\n        return this.prepareUpdateRollupField(fieldRo, oldFieldVo);\n      case FieldType.ConditionalRollup:\n        return this.prepareConditionalRollupField(fieldRo);\n      case FieldType.Formula:\n        return this.prepareUpdateFormulaField(fieldRo, oldFieldVo);\n      case FieldType.SingleLineText:\n        return this.prepareSingleTextField(fieldRo);\n      case FieldType.LongText:\n        return this.prepareLongTextField(fieldRo);\n      case FieldType.Number:\n        return this.prepareNumberField(fieldRo);\n      case FieldType.Rating:\n        return this.prepareRatingField(fieldRo);\n      case FieldType.SingleSelect:\n        return this.prepareSingleSelectField(fieldRo);\n      case FieldType.MultipleSelect:\n        return this.prepareMultipleSelectField(fieldRo);\n      case FieldType.Attachment:\n        return this.prepareAttachmentField(fieldRo);\n      case FieldType.User:\n        return this.prepareUpdateUserField(fieldRo, oldFieldVo);\n      case FieldType.Date:\n        return this.prepareDateField(fieldRo);\n      case FieldType.AutoNumber:\n        return this.prepareAutoNumberField(fieldRo);\n      case FieldType.CreatedTime:\n        return this.prepareCreatedTimeField(fieldRo);\n      case FieldType.LastModifiedTime:\n        return this.prepareLastModifiedTimeField(fieldRo);\n      case FieldType.Checkbox:\n        return this.prepareCheckboxField(fieldRo);\n      case FieldType.Button:\n        return this.prepareButtonField(fieldRo);\n      case FieldType.LastModifiedBy:\n        return this.prepareLastModifiedByField(fieldRo);\n      case FieldType.CreatedBy:\n        return this.prepareCreatedByField(fieldRo);\n      default:\n        throw new CustomHttpException(\n          `Unsupported field type ${fieldRo.type}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.unsupportedFieldType',\n              context: { type: fieldRo.type },\n            },\n          }\n        );\n    }\n  }\n\n  private zodParse(name: string, schema: z.Schema, value: unknown) {\n    const result = (schema as z.Schema).safeParse(value);\n\n    if (!result.success) {\n      throw new CustomHttpException(\n        `${name} is invalid: ${fromZodError(result.error)}`,\n        HttpErrorCode.VALIDATION_ERROR\n      );\n    }\n  }\n\n  private validateFormattingShowAs(field: IFieldVo) {\n    const { cellValueType, isMultipleCellValue, type } = field;\n    const showAsSchema = getShowAsSchema(cellValueType, isMultipleCellValue, type);\n\n    const showAs = 'showAs' in field.options ? field.options.showAs : undefined;\n    const formatting = 'formatting' in field.options ? field.options.formatting : undefined;\n\n    if (showAs) {\n      this.zodParse('showAs', showAsSchema, showAs);\n    }\n\n    if (formatting) {\n      const formattingSchema = getFormattingSchema(cellValueType);\n      this.zodParse('formatting', formattingSchema, formatting);\n    }\n  }\n\n  private validateAiConfig(field: IFieldVo) {\n    const { type, aiConfig } = field;\n\n    const aiConfigSchema = getAiConfigSchema(type);\n\n    if (aiConfig) {\n      this.zodParse('aiConfig', aiConfigSchema, aiConfig);\n    }\n  }\n\n  /**\n   * prepare properties for computed field to make sure it's valid\n   * this method do not do any db update\n   */\n  async prepareCreateField(tableId: string, fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) {\n    const field = (await this.prepareCreateFieldInner(tableId, fieldRo, batchFieldVos)) as IFieldVo;\n\n    const fieldId = field.id || generateFieldId();\n    const fieldName = await this.uniqFieldName(tableId, field.name);\n\n    const dbFieldName =\n      fieldRo.dbFieldName ?? (await this.fieldService.generateDbFieldName(tableId, fieldName));\n\n    if (fieldRo.dbFieldName) {\n      const existField = await this.prismaService.txClient().field.findFirst({\n        where: { tableId, dbFieldName: fieldRo.dbFieldName, deletedTime: null },\n        select: { id: true },\n      });\n      if (existField) {\n        throw new CustomHttpException(\n          `Db Field name ${fieldRo.dbFieldName} already exists in this table`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.dbFieldNameAlreadyExists',\n              context: { dbFieldName: fieldRo.dbFieldName },\n            },\n          }\n        );\n      }\n    }\n\n    const fieldVo: IFieldVo = {\n      ...field,\n      id: fieldId,\n      name: fieldName,\n      dbFieldName,\n      isPending: field.isComputed ? true : undefined,\n    };\n\n    this.validateFormattingShowAs(fieldVo);\n    this.validateAiConfig(fieldVo);\n\n    return fieldVo;\n  }\n\n  async prepareCreateFields(tableId: string, fieldRos: IFieldRo[], batchFieldVos?: IFieldVo[]) {\n    // throw error when dbFieldName is duplicated\n    const fieldRoDbFieldNames = fieldRos\n      .map((field) => field.dbFieldName)\n      .filter((name) => name !== undefined && name !== null) as string[];\n\n    if (fieldRoDbFieldNames.length) {\n      const existedField = await this.prismaService.txClient().field.findFirst({\n        where: { tableId, dbFieldName: { in: fieldRoDbFieldNames } },\n        select: { id: true, dbFieldName: true },\n      });\n\n      if (existedField) {\n        throw new CustomHttpException(\n          `Db Field name ${existedField.dbFieldName} already exists in this table`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.dbFieldNameAlreadyExists',\n              context: { dbFieldName: existedField.dbFieldName },\n            },\n          }\n        );\n      }\n    }\n\n    const fields: IFieldVo[] = (await Promise.all(\n      fieldRos.map(\n        async (fieldRo) => await this.prepareCreateFieldInner(tableId, fieldRo, batchFieldVos)\n      )\n    )) as IFieldVo[];\n\n    const uniqFieldNames = await this.uniqFieldNames(\n      tableId,\n      fields.map((field) => field.name)\n    );\n\n    const dbFieldNames = await this.fieldService.generateDbFieldNames(tableId, uniqFieldNames);\n\n    return fieldRos.map((fieldRo, index) => {\n      const field = fields[index];\n      const fieldId = field.id || generateFieldId();\n      const fieldName = uniqFieldNames[index];\n      const dbFieldName = fieldRo.dbFieldName ?? dbFieldNames[index];\n      const fieldVo: IFieldVo = {\n        ...field,\n        id: fieldId,\n        name: fieldName,\n        dbFieldName,\n        isPending: field.isComputed ? true : undefined,\n      };\n      this.validateFormattingShowAs(fieldVo);\n      this.validateAiConfig(fieldVo);\n      return fieldVo;\n    });\n  }\n\n  async prepareUpdateField(\n    tableId: string,\n    fieldRo: IConvertFieldRo,\n    oldFieldVo: IFieldVo\n  ): Promise<IFieldVo> {\n    const normalizedFieldRo: IFieldRo = {\n      ...fieldRo,\n      options: fieldRo.options ?? undefined,\n    };\n\n    const fieldVo = (await this.prepareUpdateFieldInner(\n      tableId,\n      {\n        ...normalizedFieldRo,\n        name: normalizedFieldRo.name ?? oldFieldVo.name,\n        dbFieldName: normalizedFieldRo.dbFieldName ?? oldFieldVo.dbFieldName,\n        description:\n          normalizedFieldRo.description === undefined\n            ? oldFieldVo.description\n            : normalizedFieldRo.description,\n      }, // for convenience, we fallback name adn dbFieldName when it be undefined\n      oldFieldVo\n    )) as IFieldVo;\n    this.validateFormattingShowAs(fieldVo);\n    this.validateAiConfig(fieldVo);\n\n    return {\n      ...fieldVo,\n      id: oldFieldVo.id,\n      isPrimary: oldFieldVo.isPrimary,\n    };\n  }\n\n  async uniqFieldName(tableId: string, fieldName: string) {\n    const fieldRaw = await this.prismaService.txClient().field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { name: true },\n    });\n\n    const names = fieldRaw.map((item) => item.name);\n    const uniqName = getUniqName(fieldName, names);\n    if (uniqName !== fieldName) {\n      return uniqName;\n    }\n    return fieldName;\n  }\n\n  private async uniqFieldNames(tableId: string, fieldNames: string[]) {\n    const fieldRaw = await this.prismaService.txClient().field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { name: true },\n    });\n\n    const names = fieldRaw.map((item) => item.name);\n\n    return fieldNames.map((fieldName) => {\n      const uniqName = getUniqName(fieldName, names);\n      names.push(uniqName);\n      return uniqName;\n    });\n  }\n\n  async generateSymmetricField(tableId: string, field: LinkFieldDto) {\n    if (!field.options.symmetricFieldId) {\n      throw new CustomHttpException(\n        'symmetricFieldId is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.symmetricFieldIdRequired',\n          },\n        }\n      );\n    }\n\n    const prisma = this.prismaService.txClient();\n    const { name: tableName, baseId } = await prisma.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { name: true, baseId: true },\n    });\n\n    const fieldName = await this.uniqFieldName(tableId, tableName);\n\n    // lookup field id is the primary field of the table to which it is linked\n    const { id: lookupFieldId } = await prisma.field.findFirstOrThrow({\n      where: { tableId, isPrimary: true },\n      select: { id: true },\n    });\n\n    const relationship = RelationshipRevert[field.options.relationship];\n    const isMultipleCellValue = isMultiValueLink(relationship) || undefined;\n    const dbFieldName = await this.fieldService.generateDbFieldName(\n      field.options.foreignTableId,\n      fieldName\n    );\n\n    return createFieldInstanceByVo({\n      id: field.options.symmetricFieldId,\n      name: fieldName,\n      dbFieldName,\n      type: FieldType.Link,\n      options: {\n        baseId: field.options.baseId ? baseId : undefined,\n        relationship,\n        foreignTableId: tableId,\n        lookupFieldId,\n        fkHostTableName: field.options.fkHostTableName,\n        selfKeyName: field.options.foreignKeyName,\n        foreignKeyName: field.options.selfKeyName,\n        symmetricFieldId: field.id,\n      },\n      isMultipleCellValue,\n      dbFieldType: DbFieldType.Json,\n      cellValueType: CellValueType.String,\n      meta: {\n        hasOrderColumn: field.getHasOrderColumn(),\n      },\n    } as IFieldVo) as LinkFieldDto;\n  }\n\n  async cleanForeignKey(options: ILinkFieldOptions) {\n    const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options;\n    const dropTable = async (tableName: string) => {\n      // Use provider to generate dialect-correct DROP TABLE SQL\n      const sql = this.dbProvider.dropTable(tableName);\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    };\n\n    const dropColumn = async (tableName: string, columnName: string) => {\n      const sqls = this.dbProvider.dropColumnAndIndex(tableName, columnName, `index_${columnName}`);\n\n      for (const sql of sqls) {\n        await this.prismaService.txClient().$executeRawUnsafe(sql);\n      }\n\n      // Drop the associated order column if it exists\n      const orderColumn = `${columnName}_order`;\n      const exists = await this.dbProvider.checkColumnExist(\n        tableName,\n        orderColumn,\n        this.prismaService.txClient()\n      );\n      if (exists) {\n        const dropOrderSqls = this.dbProvider.dropColumnAndIndex(\n          tableName,\n          orderColumn,\n          `index_${orderColumn}`\n        );\n        for (const sql of dropOrderSqls) {\n          await this.prismaService.txClient().$executeRawUnsafe(sql);\n        }\n      }\n    };\n\n    if (relationship === Relationship.ManyMany && fkHostTableName.includes('junction_')) {\n      await dropTable(fkHostTableName);\n    }\n\n    if (relationship === Relationship.ManyOne) {\n      await dropColumn(fkHostTableName, foreignKeyName);\n    }\n\n    if (relationship === Relationship.OneMany) {\n      if (isOneWay) {\n        fkHostTableName.includes('junction_') && (await dropTable(fkHostTableName));\n      } else {\n        await dropColumn(fkHostTableName, selfKeyName);\n      }\n    }\n\n    if (relationship === Relationship.OneOne) {\n      await dropColumn(fkHostTableName, foreignKeyName === '__id' ? selfKeyName : foreignKeyName);\n    }\n  }\n\n  async createReference(field: IFieldInstance) {\n    if (field.isLookup) {\n      return this.createComputedFieldReference(field);\n    }\n\n    switch (field.type) {\n      case FieldType.Formula:\n      case FieldType.LastModifiedTime:\n      case FieldType.Rollup:\n      case FieldType.ConditionalRollup:\n      case FieldType.Link:\n        return this.createComputedFieldReference(field);\n      default:\n        break;\n    }\n  }\n\n  async deleteReference(fieldId: string): Promise<string[]> {\n    const prisma = this.prismaService.txClient();\n    const refRaw = await prisma.reference.findMany({\n      where: {\n        fromFieldId: fieldId,\n      },\n    });\n\n    await prisma.reference.deleteMany({\n      where: {\n        OR: [{ toFieldId: fieldId }, { fromFieldId: fieldId }],\n      },\n    });\n\n    return refRaw.map((ref) => ref.toFieldId);\n  }\n\n  /**\n   * the lookup field that attach to the deleted, should delete to field reference\n   */\n  async deleteLookupFieldReference(linkFieldId: string): Promise<string[]> {\n    const prisma = this.prismaService.txClient();\n    const fieldsRaw = await prisma.field.findMany({\n      where: { lookupLinkedFieldId: linkFieldId, deletedTime: null },\n      select: { id: true },\n    });\n\n    for (const field of fieldsRaw) {\n      await prisma.field.update({\n        data: { lookupLinkedFieldId: null },\n        where: { id: field.id },\n      });\n    }\n\n    const lookupFieldIds = fieldsRaw.map((field) => field.id);\n\n    // just need delete to field id, because lookup field still exist\n    await prisma.reference.deleteMany({\n      where: {\n        OR: [{ toFieldId: { in: lookupFieldIds } }],\n      },\n    });\n    return lookupFieldIds;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  getFieldReferenceIds(field: IFieldInstance): string[] {\n    if (field.lookupOptions && (field.isLookup || field.type !== FieldType.ConditionalRollup)) {\n      // Lookup/Rollup fields depend on BOTH the target lookup field and the link field.\n      // This ensures when a link cell changes, the dependent lookup/rollup fields are\n      // included in the computed impact and persisted via updateFromSelect.\n      const refs: string[] = [];\n      if (isLinkLookupOptions(field.lookupOptions)) {\n        const { lookupFieldId, linkFieldId } = field.lookupOptions;\n        if (lookupFieldId) refs.push(lookupFieldId);\n        if (linkFieldId) refs.push(linkFieldId);\n        return refs;\n      }\n    }\n\n    if (field.isConditionalLookup) {\n      const refs: string[] = [];\n      const meta = field.getConditionalLookupOptions();\n      const lookupFieldId = meta?.lookupFieldId;\n      if (lookupFieldId) {\n        refs.push(lookupFieldId);\n      }\n      const sortFieldId = meta?.sort?.fieldId;\n      if (sortFieldId) {\n        refs.push(sortFieldId);\n      }\n      const filterRefs = extractFieldIdsFromFilter(meta?.filter, true);\n      filterRefs.forEach((fieldId) => refs.push(fieldId));\n      return refs;\n    }\n\n    if (field.type === FieldType.ConditionalRollup) {\n      const refs: string[] = [];\n      const options = field.options as IConditionalRollupFieldOptions | undefined;\n      const lookupFieldId = options?.lookupFieldId;\n      if (lookupFieldId) {\n        refs.push(lookupFieldId);\n      }\n      const sortFieldId = options?.sort?.fieldId;\n      if (sortFieldId && ConditionalRollupFieldCore.supportsOrdering(options?.expression)) {\n        refs.push(sortFieldId);\n      }\n      const filterRefs = extractFieldIdsFromFilter(options?.filter, true);\n      filterRefs.forEach((fieldId) => refs.push(fieldId));\n      return refs;\n    }\n\n    if (field.type === FieldType.Link) {\n      return [field.options.lookupFieldId];\n    }\n\n    if (field.type === FieldType.Formula) {\n      return (field as FormulaFieldDto).getReferenceFieldIds();\n    }\n\n    if (field.type === FieldType.LastModifiedTime) {\n      const lmtField = field as LastModifiedTimeFieldCore;\n      return lmtField.getTrackedFieldIds();\n    }\n\n    return [];\n  }\n\n  private async createComputedFieldReference(field: IFieldInstance) {\n    const toFieldId = field.id;\n\n    const graphItems = await this.referenceService.getFieldGraphItems([field.id]);\n    let fieldIds = this.getFieldReferenceIds(field);\n\n    // add lookupOptions filter fieldIds to reference\n    if (field?.lookupOptions) {\n      const lookupOptions = field.lookupOptions;\n      if (isLinkLookupOptions(lookupOptions)) {\n        const filterSetFieldIds = extractFieldIdsFromFilter(lookupOptions.filter);\n        filterSetFieldIds.forEach((fieldId) => {\n          fieldIds.push(fieldId);\n        });\n      }\n    }\n\n    const conditionalLookupOptions = field.getConditionalLookupOptions?.();\n    if (conditionalLookupOptions) {\n      const filterFieldIds = extractFieldIdsFromFilter(conditionalLookupOptions.filter, true);\n      filterFieldIds.forEach((fieldId) => {\n        fieldIds.push(fieldId);\n      });\n      if (conditionalLookupOptions.sort?.fieldId) {\n        fieldIds.push(conditionalLookupOptions.sort.fieldId);\n      }\n    }\n\n    if (field.type === FieldType.ConditionalRollup) {\n      const options = field.options as IConditionalRollupFieldOptions | undefined;\n      const filterFieldIds = extractFieldIdsFromFilter(options?.filter, true);\n      filterFieldIds.forEach((fieldId) => {\n        fieldIds.push(fieldId);\n      });\n      if (options?.sort?.fieldId) {\n        fieldIds.push(options.sort.fieldId);\n      }\n    }\n\n    fieldIds = uniq(fieldIds);\n    fieldIds.forEach((fromFieldId) => {\n      graphItems.push({ fromFieldId, toFieldId });\n    });\n\n    if (hasCycle(graphItems)) {\n      throw new CustomHttpException(\n        `Detected a cycle: ${field.id}[${field.name}] is part of a circular dependency`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.cycleDetectedCreateField',\n            context: {\n              id: field.id,\n              name: field.name,\n            },\n          },\n        }\n      );\n    }\n\n    if (fieldIds.length) {\n      await this.prismaService.txClient().reference.createMany({\n        data: fieldIds.map((fromFieldId) => ({\n          fromFieldId,\n          toFieldId,\n        })),\n        skipDuplicates: true,\n      });\n    }\n  }\n\n  async createFieldTaskReference(tableId: string, field: IFieldInstance) {\n    const { id: fieldId, aiConfig } = field;\n\n    await this.prismaService.txClient().taskReference.deleteMany({\n      where: { toFieldId: fieldId },\n    });\n    const existingFieldIds = await this.prismaService.txClient().field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { id: true },\n    });\n\n    const existingFieldIdSet = new Set(existingFieldIds.map(({ id }) => id));\n    const { type } = aiConfig ?? {};\n\n    // Both Customization and ImageCustomization use prompt with {fieldId} syntax\n    if (type === FieldAIActionType.Customization || type === FieldAIActionType.ImageCustomization) {\n      const { prompt } = aiConfig as ITextFieldCustomizeAIConfig;\n      const fieldIds = extractFieldReferences(prompt);\n      const fieldIdsToCreate = fieldIds.filter((id) => existingFieldIdSet.has(id));\n\n      return await this.prismaService.txClient().taskReference.createMany({\n        data: fieldIdsToCreate.map((id) => ({\n          fromFieldId: id,\n          toFieldId: fieldId,\n        })),\n      });\n    }\n\n    const { sourceFieldId } = (aiConfig as ITextFieldSummarizeAIConfig) ?? {};\n    if (!sourceFieldId || !existingFieldIdSet.has(sourceFieldId)) return;\n\n    await this.prismaService.txClient().taskReference.create({\n      data: {\n        fromFieldId: sourceFieldId,\n        toFieldId: fieldId,\n      },\n    });\n  }\n\n  async createFieldTaskReferences(tableId: string, fields: IFieldInstance[]) {\n    if (!fields.length) return;\n\n    const prisma = this.prismaService.txClient();\n    const toFieldIds = fields.map((field) => field.id);\n\n    await prisma.taskReference.deleteMany({\n      where: { toFieldId: { in: toFieldIds } },\n    });\n\n    const existingFieldIds = await prisma.field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { id: true },\n    });\n\n    const existingFieldIdSet = new Set(existingFieldIds.map(({ id }) => id));\n    // Include fields created in this batch so AI references can resolve within the same operation.\n    toFieldIds.forEach((id) => existingFieldIdSet.add(id));\n\n    const rows: Array<{ fromFieldId: string; toFieldId: string }> = [];\n\n    for (const field of fields) {\n      const { id: toFieldId, aiConfig } = field;\n      const { type } = aiConfig ?? {};\n      if (!type) continue;\n\n      // Both Customization and ImageCustomization use prompt with {fieldId} syntax\n      if (\n        type === FieldAIActionType.Customization ||\n        type === FieldAIActionType.ImageCustomization\n      ) {\n        const { prompt } = aiConfig as ITextFieldCustomizeAIConfig;\n        const fieldIds = extractFieldReferences(prompt);\n        const fieldIdsToCreate = fieldIds.filter((id) => existingFieldIdSet.has(id));\n        fieldIdsToCreate.forEach((fromFieldId) => rows.push({ fromFieldId, toFieldId }));\n        continue;\n      }\n\n      const { sourceFieldId } = (aiConfig as ITextFieldSummarizeAIConfig) ?? {};\n      if (!sourceFieldId || !existingFieldIdSet.has(sourceFieldId)) continue;\n      rows.push({ fromFieldId: sourceFieldId, toFieldId });\n    }\n\n    if (!rows.length) return;\n\n    await prisma.taskReference.createMany({\n      data: rows,\n      skipDuplicates: true,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport {\n  getValidFilterOperators,\n  FieldType,\n  ViewOpBuilder,\n  FieldOpBuilder,\n  getValidStatisticFunc,\n  ViewType,\n} from '@teable/core';\nimport type {\n  IFilterSet,\n  ISelectFieldOptionsRo,\n  ISelectFieldOptions,\n  IFilterItem,\n  IFilter,\n  IFilterValue,\n  ILinkFieldOptions,\n  IOtOperation,\n  IColumn,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { isEqual, differenceBy, find, isEmpty } from 'lodash';\nimport { ViewService } from '../../view/view.service';\nimport { FieldService } from '../field.service';\nimport type { IFieldInstance } from '../model/factory';\nimport { FieldConvertingLinkService } from './field-converting-link.service';\nimport { FieldDeletingService } from './field-deleting.service';\n\n/**\n * This service' purpose is to sync the relative data from field to view\n * such as filter, group, sort, columnMeta, etc.\n */\n@Injectable()\nexport class FieldViewSyncService {\n  private readonly logger = new Logger(FieldViewSyncService.name);\n\n  constructor(\n    private readonly viewService: ViewService,\n    private readonly fieldService: FieldService,\n    private readonly prismaService: PrismaService,\n    private readonly fieldDeletingService: FieldDeletingService,\n    private readonly fieldConvertingLinkService: FieldConvertingLinkService\n  ) {}\n\n  async deleteDependenciesByFieldIds(tableId: string, fieldIds: string[]) {\n    await this.viewService.deleteViewRelativeByFields(tableId, fieldIds);\n    await this.deleteLinkOptionsDependenciesByFieldIds(tableId, fieldIds);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async deleteLinkOptionsDependenciesByFieldIds(tableId: string, fieldIds: string[]) {\n    const foreignFields = await this.getLinkForeignFields(tableId);\n    const deletedFieldIdSet = new Set(fieldIds);\n\n    for (const field of foreignFields) {\n      const ops: IOtOperation[] = [];\n      const { id: fieldId, tableId, options: rawOptions } = field;\n      const options = rawOptions ? JSON.parse(rawOptions) : null;\n\n      if (options == null) continue;\n\n      const { filter, visibleFieldIds } = options as ILinkFieldOptions;\n      const newOptions: ILinkFieldOptions = { ...options };\n      let isOptionsChanged = false;\n\n      if (visibleFieldIds?.length) {\n        const newVisibleFieldIds = visibleFieldIds.filter((id) => !deletedFieldIdSet.has(id));\n        if (!isEqual(newVisibleFieldIds, visibleFieldIds)) {\n          newOptions.visibleFieldIds = newVisibleFieldIds?.length ? newVisibleFieldIds : null;\n          isOptionsChanged = true;\n        }\n      }\n\n      const filterString = JSON.stringify(filter);\n      const filteredFieldIds = fieldIds.filter((id) => filterString?.includes(id));\n\n      if (filter != null && filteredFieldIds.length) {\n        let newFilter: IFilterSet | null = filter;\n        filteredFieldIds.forEach((id) => {\n          if (newFilter) {\n            newFilter = this.viewService.getDeletedFilterByFieldId(newFilter, id);\n          }\n        });\n        newOptions.filter = newFilter ? (newFilter?.filterSet?.length ? newFilter : null) : null;\n        isOptionsChanged = true;\n      }\n\n      if (isOptionsChanged) {\n        ops.push(\n          FieldOpBuilder.editor.setFieldProperty.build({\n            key: 'options',\n            newValue: newOptions,\n            oldValue: options,\n          })\n        );\n      }\n\n      if (ops.length) {\n        await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]);\n      }\n    }\n  }\n\n  async deleteLinkOptionsDependenciesByViewId(tableId: string, viewId: string) {\n    const foreignFields = await this.getLinkForeignFields(tableId);\n\n    for (const field of foreignFields) {\n      const { id: fieldId, tableId, options: rawOptions } = field;\n      const options = rawOptions ? JSON.parse(rawOptions) : null;\n\n      if (options == null) continue;\n\n      const { filterByViewId } = options as ILinkFieldOptions;\n\n      if (filterByViewId == null || filterByViewId !== viewId) continue;\n\n      const ops = [\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'options',\n          oldValue: options,\n          newValue: { ...options, filterByViewId: null },\n        }),\n      ];\n      await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]);\n    }\n  }\n\n  async convertDependenciesByFieldIds(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    await this.convertViewDependenciesByFieldIds(tableId, newField, oldField);\n    await this.convertLinkOptionsDependenciesByFieldIds(tableId, newField, oldField);\n    await this.convertLinkLookupFieldId(tableId, newField);\n  }\n\n  async convertLinkLookupFieldId(tableId: string, newField: IFieldInstance) {\n    const prisma = this.prismaService.txClient();\n    const fieldId = newField.id;\n    const resetLinkFieldIds = await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(\n      tableId,\n      newField,\n      'field|update'\n    );\n\n    if (isEmpty(resetLinkFieldIds)) {\n      return;\n    }\n\n    await prisma.reference.deleteMany({\n      where: {\n        fromFieldId: fieldId,\n      },\n    });\n\n    await this.fieldDeletingService.resetLinkFieldLookupFieldId(\n      resetLinkFieldIds,\n      tableId,\n      fieldId\n    );\n  }\n\n  async convertLinkOptionsDependenciesByFieldIds(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    const convertedFieldId = newField.id;\n    const foreignFields = await this.getLinkForeignFields(tableId);\n\n    for (const field of foreignFields) {\n      const { id: fieldId, tableId, options: rawOptions } = field;\n      const options = rawOptions ? JSON.parse(rawOptions) : null;\n\n      if (options == null) continue;\n\n      const ops: IOtOperation[] = [];\n      const { filter } = options as ILinkFieldOptions;\n\n      if (filter == null || !JSON.stringify(filter).includes(convertedFieldId)) continue;\n\n      const newFilter = this.getNewFilterByFieldChanges(filter, newField, oldField);\n      ops.push(\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'options',\n          oldValue: options,\n          newValue: {\n            ...options,\n            filter: newFilter ? (newFilter?.filterSet?.length ? newFilter : null) : null,\n          },\n        })\n      );\n\n      await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]);\n    }\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async convertViewDependenciesByFieldIds(\n    tableId: string,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    const views = await this.prismaService.txClient().view.findMany({\n      select: {\n        filter: true,\n        id: true,\n        type: true,\n        columnMeta: true,\n      },\n      where: { tableId: tableId, deletedTime: null },\n    });\n\n    if (!views?.length) {\n      return;\n    }\n\n    const opsMap: { [viewId: string]: IOtOperation[] } = {};\n    for (let i = 0; i < views.length; i++) {\n      const view = views[i];\n      const viewId = view.id;\n      const filterString = view.filter;\n\n      // if the field is in filter, update the filter\n      if (filterString?.includes(newField.id)) {\n        const filter = JSON.parse(filterString) as NonNullable<IFilter>;\n\n        const newFilter = this.getNewFilterByFieldChanges(filter, newField, oldField);\n\n        const ops = ViewOpBuilder.editor.setViewProperty.build({\n          key: 'filter',\n          newValue: newFilter ? (newFilter?.filterSet?.length ? newFilter : null) : null,\n          oldValue: filter,\n        });\n        opsMap[viewId] = [ops];\n      }\n\n      // clear invalid aggregation statisticFunc from columnMeta\n      const columnMetaString = view?.columnMeta;\n      if (columnMetaString) {\n        const columnMeta = JSON.parse(columnMetaString) as {\n          [fieldId: string]: IColumn | null;\n        };\n        const fieldId = newField.id;\n        const meta = columnMeta[fieldId];\n        if (meta && 'statisticFunc' in meta) {\n          const validFuncs = getValidStatisticFunc(newField);\n          const currentFunc = meta.statisticFunc as unknown;\n          if (\n            currentFunc &&\n            Array.isArray(validFuncs) &&\n            !validFuncs.includes(currentFunc as never)\n          ) {\n            const updateOp = ViewOpBuilder.editor.updateViewColumnMeta.build({\n              fieldId,\n              newColumnMeta: { ...meta, statisticFunc: null },\n              oldColumnMeta: { ...meta },\n            });\n            opsMap[viewId] = [...(opsMap[viewId] || []), updateOp];\n          }\n        }\n\n        // For Form views: enforce visibility when field is not null and no default value\n        if (view.type === ViewType.Form) {\n          const defaultValue = (newField.options as { defaultValue?: string })?.defaultValue;\n          const protectedNew = Boolean(newField.notNull) && !defaultValue;\n          const defaultValueOld = (\n            oldField.options as {\n              defaultValue?: string;\n            }\n          )?.defaultValue;\n          const protectedOld = Boolean(oldField.notNull) && !defaultValueOld;\n\n          if (protectedNew && !protectedOld) {\n            const prev = columnMeta[fieldId] ?? {};\n            const updateOp = ViewOpBuilder.editor.updateViewColumnMeta.build({\n              fieldId,\n              newColumnMeta: { ...prev, visible: true } as IColumn,\n              oldColumnMeta: prev as IColumn,\n            });\n            opsMap[viewId] = [...(opsMap[viewId] || []), updateOp];\n          }\n        }\n      }\n    }\n\n    await this.viewService.batchUpdateViewByOps(tableId, opsMap);\n  }\n\n  async getLinkForeignFields(tableId: string) {\n    const linkFields = await this.prismaService.txClient().field.findMany({\n      where: { tableId, type: FieldType.Link, deletedTime: null },\n    });\n    const foreignFieldIds = linkFields\n      .map(\n        ({ options }) =>\n          ((options ? JSON.parse(options) : null) as ILinkFieldOptions)?.symmetricFieldId\n      )\n      .filter(Boolean) as string[];\n    return await this.prismaService.txClient().field.findMany({\n      where: { id: { in: foreignFieldIds }, type: FieldType.Link, deletedTime: null },\n    });\n  }\n\n  getNewFilterByFieldChanges(\n    originalFilter: IFilter,\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    if (!originalFilter) {\n      return null as IFilter;\n    }\n\n    const fieldId = newField.id;\n    const filter = { ...originalFilter };\n    const oldOperators = getValidFilterOperators(oldField);\n    const newOperators = getValidFilterOperators(newField);\n    /**\n     * there just two cases processed now\n     * 1. select field type\n     *    a.delete old options, delete filter item value is array, delete the item in array\n     *    b.value is string, delete the item\n     * 2. operators or cellValueType or isMultipleCellValue has been changed, delete the filter item\n     * TODO there are more detail cases need to be processed to improve the experience of user\n     */\n    if (\n      newField.type === oldField.type &&\n      [FieldType.SingleSelect, FieldType.MultipleSelect].includes(newField.type) &&\n      !isEqual(\n        (oldField.options as ISelectFieldOptions).choices,\n        (newField.options as ISelectFieldOptionsRo).choices\n      )\n    ) {\n      const fieldId = newField.id;\n      const oldOptions = (oldField.options as ISelectFieldOptions).choices;\n      const newOptions = (newField.options as ISelectFieldOptionsRo).choices;\n\n      const updateNameOptions = newOptions\n        .filter((choice) => {\n          if (!choice.id) return false;\n          const originalChoice = find(oldOptions, ['id', choice.id]);\n          return originalChoice && originalChoice.name !== choice.name;\n        })\n        .map((item) => {\n          const { id, name } = item;\n          return {\n            id,\n            oldName: oldOptions.find((option) => option?.id === id)?.name as string,\n            newName: name,\n          };\n        });\n      const deleteOptions = differenceBy(oldOptions, newOptions, 'id');\n      if (!deleteOptions?.length && !updateNameOptions?.length) {\n        return filter;\n      }\n\n      return this.getFilterBySelectTypeChanges(filter, fieldId, updateNameOptions, deleteOptions);\n    }\n\n    // judge the operator is same groups or cellValueType is same, otherwise delete the filter item\n    if (\n      (newField.type !== oldField.type && !isEqual(oldOperators, newOperators)) ||\n      oldField.cellValueType !== newField.cellValueType ||\n      oldField?.isMultipleCellValue !== newField?.isMultipleCellValue\n    ) {\n      return this.viewService.getDeletedFilterByFieldId(filter, fieldId);\n    }\n\n    // do nothing\n    return filter;\n  }\n\n  getFilterBySelectTypeChanges(\n    originData: IFilterSet,\n    fieldId: string,\n    updateNameOptions: { id?: string; oldName: string; newName: string }[],\n    deleteOptions: ISelectFieldOptions['choices']\n  ) {\n    const data = { ...originData };\n    const updateMap = new Map(updateNameOptions.map((opt) => [opt.oldName, opt.newName]));\n    const deleteSet = new Set(deleteOptions.map((opt) => opt.name));\n\n    const transformValue = (value: unknown): unknown => {\n      if (Array.isArray(value)) {\n        const newValue = value.filter((v) => !deleteSet.has(v)).map((v) => updateMap.get(v) || v);\n        return newValue.length > 0 ? newValue : null;\n      } else if (typeof value === 'string') {\n        if (deleteSet.has(value)) return null;\n        return updateMap.get(value) || value;\n      }\n      return value;\n    };\n\n    const transformFilter = (filter: IFilterSet | IFilterItem): IFilterSet | IFilterItem => {\n      if ('filterSet' in filter) {\n        const newFilterSet = filter.filterSet.map(transformFilter);\n        return {\n          conjunction: filter.conjunction,\n          filterSet: newFilterSet.filter((item) => !isEmpty(item)),\n        };\n      } else {\n        // target item\n        if (filter.fieldId === fieldId && filter.value !== null) {\n          const newValue = transformValue(filter.value) as IFilterValue;\n          return (newValue ? { ...filter, value: newValue } : {}) as IFilterItem;\n        }\n        return {\n          ...filter,\n        };\n      }\n    };\n\n    return transformFilter(data) as IFilterSet;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/formula-field.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';\nimport { FormulaFieldService } from './formula-field.service';\n\ndescribe('FormulaFieldService', () => {\n  let service: FormulaFieldService;\n  let prismaService: PrismaService;\n  let module: TestingModule;\n\n  // Test data IDs - using consistent IDs for easier debugging\n  const testTableId = 'tbl_test_table';\n  const fieldIds = {\n    textA: 'fld_text_a',\n    formulaB: 'fld_formula_b',\n    formulaC: 'fld_formula_c',\n    formulaD: 'fld_formula_d',\n    formulaE: 'fld_formula_e',\n    lookupF: 'fld_lookup_f',\n    textG: 'fld_text_g',\n  };\n\n  beforeAll(async () => {\n    module = await Test.createTestingModule({\n      providers: [\n        FormulaFieldService,\n        {\n          provide: PrismaService,\n          useValue: {\n            txClient: vi.fn(),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<FormulaFieldService>(FormulaFieldService);\n    prismaService = module.get<PrismaService>(PrismaService);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  describe('getDependentFormulaFieldsInOrder', () => {\n    let mockQueryRawUnsafe: any;\n\n    beforeEach(() => {\n      mockQueryRawUnsafe = vi.fn();\n      vi.mocked(prismaService.txClient).mockReturnValue({\n        $queryRawUnsafe: mockQueryRawUnsafe,\n        field: {\n          create: vi.fn(),\n          deleteMany: vi.fn(),\n        },\n        reference: {\n          create: vi.fn(),\n          deleteMany: vi.fn(),\n        },\n      } as any);\n    });\n\n    it('should return empty array when no dependencies exist', async () => {\n      // Mock empty result\n      const mockQueryResult: any[] = [];\n      mockQueryRawUnsafe.mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toEqual([]);\n      expect(mockQueryRawUnsafe).toHaveBeenCalledWith(\n        expect.stringContaining('WITH RECURSIVE dependent_fields'),\n        fieldIds.textA,\n        FieldType.Formula\n      );\n    });\n\n    it('should handle single level dependencies (A → B)', async () => {\n      // Mock result: textA → formulaB\n      const mockQueryResult = [{ id: fieldIds.formulaB, table_id: testTableId, level: 1 }];\n      mockQueryRawUnsafe.mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toEqual([{ id: fieldIds.formulaB, tableId: testTableId, level: 1 }]);\n    });\n\n    it('should handle multi-level dependencies with correct topological order (A → B → C)', async () => {\n      // Mock result: textA → formulaB → formulaC\n      // Should return in deepest-first order (level 2, then level 1)\n      const mockQueryResult = [\n        { id: fieldIds.formulaC, table_id: testTableId, level: 2 },\n        { id: fieldIds.formulaB, table_id: testTableId, level: 1 },\n      ];\n      mockQueryRawUnsafe.mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toEqual([\n        { id: fieldIds.formulaC, tableId: testTableId, level: 2 },\n        { id: fieldIds.formulaB, tableId: testTableId, level: 1 },\n      ]);\n\n      // Verify topological order: deeper levels come first\n      expect(result[0].level).toBeGreaterThan(result[1].level);\n    });\n\n    it('should handle multiple branches (A → B, A → C)', async () => {\n      // Mock result: textA → formulaB, textA → formulaC\n      const mockQueryResult = [\n        { id: fieldIds.formulaB, table_id: testTableId, level: 1 },\n        { id: fieldIds.formulaC, table_id: testTableId, level: 1 },\n      ];\n      mockQueryRawUnsafe.mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toHaveLength(2);\n      expect(result).toEqual(\n        expect.arrayContaining([\n          { id: fieldIds.formulaB, tableId: testTableId, level: 1 },\n          { id: fieldIds.formulaC, tableId: testTableId, level: 1 },\n        ])\n      );\n\n      // All should be at same level\n      expect(result.every((f) => f.level === 1)).toBe(true);\n    });\n\n    it('should handle complex dependency trees (A → B → D, A → C → E)', async () => {\n      // Mock result: Complex tree with multiple paths\n      const mockQueryResult = [\n        { id: fieldIds.formulaD, table_id: testTableId, level: 2 }, // B → D\n        { id: fieldIds.formulaE, table_id: testTableId, level: 2 }, // C → E\n        { id: fieldIds.formulaB, table_id: testTableId, level: 1 }, // A → B\n        { id: fieldIds.formulaC, table_id: testTableId, level: 1 }, // A → C\n      ];\n      mockQueryRawUnsafe.mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toHaveLength(4);\n\n      // Verify topological ordering\n      const level2Fields = result.filter((f) => f.level === 2);\n      const level1Fields = result.filter((f) => f.level === 1);\n\n      expect(level2Fields).toHaveLength(2);\n      expect(level1Fields).toHaveLength(2);\n\n      // Level 2 fields should come before level 1 fields in the result\n      const firstLevel2Index = result.findIndex((f) => f.level === 2);\n      const lastLevel1Index = result.map((f) => f.level).lastIndexOf(1);\n      expect(firstLevel2Index).toBeLessThan(lastLevel1Index);\n    });\n  });\n\n  describe('SQL Query Validation', () => {\n    it('should call $queryRawUnsafe with correct SQL structure', async () => {\n      const mockQueryResult: any[] = [];\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockResolvedValue(mockQueryResult);\n\n      await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      const [sqlQuery, fieldId, fieldType] = vi.mocked(prismaService.txClient().$queryRawUnsafe)\n        .mock.calls[0];\n\n      // Verify SQL structure\n      expect(sqlQuery).toContain('WITH RECURSIVE dependent_fields AS');\n      expect(sqlQuery).toContain('SELECT');\n      expect(sqlQuery).toContain('UNION ALL');\n      expect(sqlQuery).toContain('ORDER BY df.level DESC');\n      expect(sqlQuery).toContain('WHERE df.level < 10'); // Recursion limit\n\n      // Verify parameters\n      expect(fieldId).toBe(fieldIds.textA);\n      expect(fieldType).toBe(FieldType.Formula);\n    });\n\n    it('should include recursion prevention in SQL', async () => {\n      const mockQueryResult: any[] = [];\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockResolvedValue(mockQueryResult);\n\n      await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      const [sqlQuery] = vi.mocked(prismaService.txClient().$queryRawUnsafe).mock.calls[0];\n\n      // Should have recursion limit to prevent infinite loops\n      expect(sqlQuery).toContain('WHERE df.level < 10');\n    });\n\n    it('should filter only formula fields and non-deleted fields', async () => {\n      const mockQueryResult: any[] = [];\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockResolvedValue(mockQueryResult);\n\n      await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      const [sqlQuery] = vi.mocked(prismaService.txClient().$queryRawUnsafe).mock.calls[0];\n\n      // Should filter by field type and deletion status\n      expect(sqlQuery).toContain('WHERE f.type = $2');\n      expect(sqlQuery).toContain('AND f.deleted_time IS NULL');\n    });\n  });\n\n  describe('Edge Cases', () => {\n    it('should handle database errors gracefully', async () => {\n      const dbError = new Error('Database connection failed');\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockRejectedValue(dbError);\n\n      await expect(service.getDependentFormulaFieldsInOrder(fieldIds.textA)).rejects.toThrow(\n        'Database connection failed'\n      );\n    });\n\n    it('should handle malformed database results', async () => {\n      // Mock malformed result (missing required fields)\n      const mockQueryResult = [\n        { id: fieldIds.formulaB }, // Missing table_id and level\n      ];\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toEqual([{ id: fieldIds.formulaB, tableId: undefined, level: undefined }]);\n    });\n\n    it('should handle very deep dependency chains', async () => {\n      // Mock a deep chain (level 9, near the recursion limit)\n      const mockQueryResult = Array.from({ length: 9 }, (_, i) => ({\n        id: `fld_formula_${i + 1}`,\n        table_id: testTableId,\n        level: i + 1,\n      })).reverse(); // Should be ordered deepest first\n\n      vi.mocked(prismaService.txClient().$queryRawUnsafe).mockResolvedValue(mockQueryResult);\n\n      const result = await service.getDependentFormulaFieldsInOrder(fieldIds.textA);\n\n      expect(result).toHaveLength(9);\n      expect(result[0].level).toBe(9); // Deepest first\n      expect(result[8].level).toBe(1); // Shallowest last\n\n      // Verify descending order\n      for (let i = 0; i < result.length - 1; i++) {\n        expect(result[i].level).toBeGreaterThanOrEqual(result[i + 1].level);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/formula-field.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\n\n@Injectable()\nexport class FormulaFieldService {\n  constructor(private readonly prismaService: PrismaService) {}\n\n  /**\n   * Get all formula fields that depend on the given field (including multi-level dependencies)\n   * Uses recursive CTE to find all downstream dependencies in topological order\n   */\n  async getDependentFormulaFieldsInOrder(\n    fieldId: string\n  ): Promise<{ id: string; tableId: string; level: number }[]> {\n    // Use recursive CTE to find all downstream dependencies\n    const recursiveCTE = `\n      WITH RECURSIVE dependent_fields AS (\n        -- Base case: direct dependencies\n        SELECT\n          r.to_field_id as field_id,\n          1 as level\n        FROM reference r\n        WHERE r.from_field_id = $1\n\n        UNION ALL\n\n        -- Recursive case: indirect dependencies\n        SELECT\n          r.to_field_id as field_id,\n          df.level + 1 as level\n        FROM reference r\n        INNER JOIN dependent_fields df ON r.from_field_id = df.field_id\n        WHERE df.level < 10  -- Prevent infinite recursion\n      )\n      SELECT DISTINCT\n        f.id,\n        f.table_id,\n        df.level\n      FROM dependent_fields df\n      INNER JOIN field f ON f.id = df.field_id\n      WHERE f.type = $2\n        AND f.deleted_time IS NULL\n      ORDER BY df.level DESC, f.id  -- Deepest dependencies first (topological order)\n    `;\n\n    const result = await this.prismaService.txClient().$queryRawUnsafe<\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      { id: string; table_id: string; level: number }[]\n    >(recursiveCTE, fieldId, FieldType.Formula);\n\n    return (result || []).map((row) => ({\n      id: row.id,\n      tableId: row.table_id,\n      level: row.level,\n    }));\n  }\n\n  /**\n   * Multi-source variant of getDependentFormulaFieldsInOrder.\n   * Returns the union of dependent formula fields for the provided roots,\n   * ordered by max dependency depth (deepest first).\n   */\n  async getDependentFormulaFieldsInOrderMulti(\n    fieldIds: string[]\n  ): Promise<{ id: string; tableId: string; level: number }[]> {\n    const uniqueIds = Array.from(new Set(fieldIds.filter(Boolean)));\n    if (!uniqueIds.length) return [];\n    if (uniqueIds.length === 1) {\n      return this.getDependentFormulaFieldsInOrder(uniqueIds[0]);\n    }\n\n    const inClause = uniqueIds.map((_, i) => `$${i + 1}`).join(',');\n    const fieldTypeParam = `$${uniqueIds.length + 1}`;\n\n    const recursiveCTE = `\n      WITH RECURSIVE dependent_fields AS (\n        -- Base case: direct dependencies\n        SELECT\n          r.to_field_id as field_id,\n          1 as level\n        FROM reference r\n        WHERE r.from_field_id IN (${inClause})\n\n        UNION ALL\n\n        -- Recursive case: indirect dependencies\n        SELECT\n          r.to_field_id as field_id,\n          df.level + 1 as level\n        FROM reference r\n        INNER JOIN dependent_fields df ON r.from_field_id = df.field_id\n        WHERE df.level < 10  -- Prevent infinite recursion\n      )\n      SELECT\n        f.id,\n        f.table_id,\n        MAX(df.level) as level\n      FROM dependent_fields df\n      INNER JOIN field f ON f.id = df.field_id\n      WHERE f.type = ${fieldTypeParam}\n        AND f.deleted_time IS NULL\n      GROUP BY f.id, f.table_id\n      ORDER BY MAX(df.level) DESC, f.id\n    `;\n\n    const result = await this.prismaService.txClient().$queryRawUnsafe<\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      { id: string; table_id: string; level: number }[]\n    >(recursiveCTE, ...uniqueIds, FieldType.Formula);\n\n    return (result || []).map((row) => ({\n      id: row.id,\n      tableId: row.table_id,\n      level: row.level,\n    }));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-calculate/link-field-query.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IFieldInstance } from '../model/factory';\n\n@Injectable()\nexport class LinkFieldQueryService {\n  constructor(private readonly prismaService: PrismaService) {}\n\n  /**\n   * Get table name mapping for link field operations\n   * @param tableId Current table ID\n   * @param fieldInstances Field instances that may contain link fields\n   * @returns Map of tableId -> dbTableName for all related tables\n   */\n  async getTableNameMapForLinkFields(\n    tableId: string,\n    fieldInstances: IFieldInstance[]\n  ): Promise<Map<string, string>> {\n    const tableIds = new Set<string>([tableId]);\n\n    // Collect all foreign table IDs from link fields\n    for (const field of fieldInstances) {\n      if (field.type === FieldType.Link && !field.isLookup) {\n        const options = field.options as ILinkFieldOptions;\n        if (options.foreignTableId) {\n          tableIds.add(options.foreignTableId);\n        }\n      }\n    }\n\n    // Query all related tables\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: Array.from(tableIds) } },\n      select: { id: true, dbTableName: true },\n    });\n\n    return new Map(tables.map((table) => [table.id, table.dbTableName]));\n  }\n\n  /**\n   * Get table name mapping for a specific table and its link fields\n   * @param tableId Table ID\n   * @returns Map of tableId -> dbTableName for the table and all its foreign tables\n   */\n  async getTableNameMapForTable(tableId: string): Promise<Map<string, string>> {\n    // Get all link fields for this table\n    const linkFields = await this.prismaService.txClient().field.findMany({\n      where: {\n        tableId,\n        type: FieldType.Link,\n        isLookup: null,\n        deletedTime: null,\n      },\n      select: { options: true },\n    });\n\n    const tableIds = new Set<string>([tableId]);\n\n    // Collect foreign table IDs\n    for (const field of linkFields) {\n      if (field.options) {\n        const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n        if (options.foreignTableId) {\n          tableIds.add(options.foreignTableId);\n        }\n      }\n    }\n\n    // Query all related tables\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: Array.from(tableIds) } },\n      select: { id: true, dbTableName: true },\n    });\n\n    return new Map(tables.map((table) => [table.id, table.dbTableName]));\n  }\n\n  /**\n   * Get table ID from database table name\n   * @param dbTableName Database table name\n   * @returns Table ID\n   */\n  async getTableIdFromDbTableName(dbTableName: string): Promise<string | null> {\n    const table = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { dbTableName },\n      select: { id: true },\n    });\n\n    return table?.id || null;\n  }\n\n  /**\n   * Get database table name from table ID\n   * @param tableId Table ID\n   * @returns Database table name\n   */\n  async getDbTableNameFromTableId(tableId: string): Promise<string | null> {\n    const table = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n\n    return table?.dbTableName || null;\n  }\n\n  /**\n   * Check if any field instances contain link fields\n   * @param fieldInstances Field instances to check\n   * @returns True if any link fields are found\n   */\n  hasLinkFields(fieldInstances: IFieldInstance[]): boolean {\n    return fieldInstances.some((field) => field.type === FieldType.Link && !field.isLookup);\n  }\n\n  /**\n   * Get all foreign table IDs from link field instances\n   * @param fieldInstances Field instances\n   * @returns Set of foreign table IDs\n   */\n  getForeignTableIds(fieldInstances: IFieldInstance[]): Set<string> {\n    const foreignTableIds = new Set<string>();\n\n    for (const field of fieldInstances) {\n      if (field.type === FieldType.Link && !field.isLookup) {\n        const options = field.options as ILinkFieldOptions;\n        if (options.foreignTableId) {\n          foreignTableIds.add(options.foreignTableId);\n        }\n      }\n    }\n\n    return foreignTableIds;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { FieldCalculateModule } from '../field-calculate/field-calculate.module';\nimport { FieldOpenApiModule } from '../open-api/field-open-api.module';\nimport { FieldDuplicateService } from './field-duplicate.service';\n\n@Module({\n  imports: [FieldOpenApiModule, FieldCalculateModule, TableDomainQueryModule],\n  providers: [DbProvider, FieldDuplicateService],\n  exports: [FieldDuplicateService],\n})\nexport class FieldDuplicateModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { BadGatewayException, Injectable, Logger } from '@nestjs/common';\nimport type {\n  IFieldVo,\n  IFormulaFieldOptions,\n  ILinkFieldOptions,\n  ILookupOptionsRo,\n  IConditionalRollupFieldOptions,\n  IConditionalLookupOptions,\n  IFilter,\n  IFieldRo,\n} from '@teable/core';\nimport {\n  FieldType,\n  HttpErrorCode,\n  extractFieldIdsFromFilter,\n  isConditionalLookupOptions,\n  isLinkLookupOptions,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IBaseJson, IFieldJson, IFieldWithTableIdJson } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { pick, get } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { extractFieldReferences } from '../../../utils';\nimport { DEFAULT_EXPRESSION } from '../../base/constant';\nimport { replaceStringByMap } from '../../base/utils';\nimport { TableDomainQueryService } from '../../table-domain/table-domain-query.service';\nimport { LinkFieldQueryService } from '../field-calculate/link-field-query.service';\nimport type { IFieldInstance } from '../model/factory';\nimport { createFieldInstanceByRaw } from '../model/factory';\nimport { FieldOpenApiService } from '../open-api/field-open-api.service';\n\n@Injectable()\nexport class FieldDuplicateService {\n  private readonly logger = new Logger(FieldDuplicateService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly linkFieldQueryService: LinkFieldQueryService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  async createCommonFields(fields: IFieldWithTableIdJson[], fieldMap: Record<string, string>) {\n    const byTable = new Map<string, IFieldWithTableIdJson[]>();\n    for (const field of fields) {\n      const list = byTable.get(field.targetTableId) ?? [];\n      list.push(field);\n      byTable.set(field.targetTableId, list);\n    }\n\n    for (const [targetTableId, tableFields] of byTable.entries()) {\n      const fieldRos: IFieldRo[] = tableFields.map(\n        ({ name, type, options, dbFieldName, description }) => ({\n          name,\n          type,\n          options,\n          dbFieldName,\n          description,\n        })\n      );\n\n      const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      for (let index = 0; index < tableFields.length; index++) {\n        const original = tableFields[index];\n        const newFieldVo = newFieldVos[index];\n        await this.replenishmentConstraint(newFieldVo.id, targetTableId, original.order, {\n          notNull: original.notNull,\n          unique: original.unique,\n          dbFieldName: newFieldVo.dbFieldName,\n          isPrimary: original.isPrimary,\n        });\n        fieldMap[original.id] = newFieldVo.id;\n      }\n    }\n  }\n\n  async createButtonFields(fields: IFieldWithTableIdJson[], fieldMap: Record<string, string>) {\n    const newFields = fields.map((field) => {\n      const { options } = field;\n      return {\n        ...field,\n        options: {\n          ...options,\n          workflow: undefined,\n        },\n      };\n    }) as IFieldWithTableIdJson[];\n    return await this.createCommonFields(newFields, fieldMap);\n  }\n\n  async createTmpPrimaryFormulaFields(\n    primaryFormulaFields: IFieldWithTableIdJson[],\n    fieldMap: Record<string, string>\n  ) {\n    const byTable = new Map<string, IFieldWithTableIdJson[]>();\n    for (const field of primaryFormulaFields) {\n      const list = byTable.get(field.targetTableId) ?? [];\n      list.push(field);\n      byTable.set(field.targetTableId, list);\n    }\n\n    for (const [targetTableId, tableFields] of byTable.entries()) {\n      const fieldRos: IFieldRo[] = tableFields.map(\n        ({ type, dbFieldName, description, options, name }) => ({\n          type,\n          dbFieldName,\n          description,\n          options: {\n            expression: DEFAULT_EXPRESSION,\n            timeZone: (options as IFormulaFieldOptions).timeZone,\n          },\n          name,\n        })\n      );\n\n      const newFields = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      for (let index = 0; index < tableFields.length; index++) {\n        const original = tableFields[index];\n        const newField = newFields[index];\n\n        // Ensure meta is present for Postgres generated columns\n        // In duplication flow, we use a safe default expression that is supported as generated column\n        // Explicitly persist meta to satisfy consumers expecting it on error formulas\n        if (newField.meta) {\n          await this.prismaService.txClient().field.update({\n            where: { id: newField.id },\n            data: { meta: JSON.stringify(newField.meta) },\n          });\n        }\n\n        await this.replenishmentConstraint(newField.id, targetTableId, original.order, {\n          notNull: original.notNull,\n          unique: original.unique,\n          dbFieldName: original.dbFieldName,\n          isPrimary: original.isPrimary,\n        });\n        fieldMap[original.id] = newField.id;\n\n        if (original.hasError) {\n          await this.prismaService.txClient().field.update({\n            where: {\n              id: newField.id,\n            },\n            data: {\n              hasError: original.hasError,\n              // error formulas should not be persisted as generated columns\n              meta: null,\n            },\n          });\n        }\n      }\n    }\n  }\n\n  async repairPrimaryFormulaFields(\n    primaryFormulaFields: IFieldWithTableIdJson[],\n    fieldMap: Record<string, string>\n  ) {\n    for (const field of primaryFormulaFields) {\n      const { id, options, dbFieldType, targetTableId, cellValueType, isMultipleCellValue } = field;\n      const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n        where: {\n          id: targetTableId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n      const tableDomain = await this.tableDomainQueryService.getTableDomainById(targetTableId);\n      const newOptions = replaceStringByMap(options, { fieldMap });\n      const { dbFieldType: currentDbFieldType } = await this.prismaService.txClient().field.update({\n        where: {\n          id: fieldMap[id],\n        },\n        data: {\n          options: newOptions,\n          cellValueType,\n        },\n      });\n      if (currentDbFieldType !== dbFieldType) {\n        // Create field instance for the updated field\n        const updatedFieldRaw = await this.prismaService.txClient().field.findUniqueOrThrow({\n          where: { id: fieldMap[id] },\n        });\n        const fieldInstance = createFieldInstanceByRaw({\n          ...updatedFieldRaw,\n          dbFieldType,\n          cellValueType,\n          isMultipleCellValue: isMultipleCellValue ?? null,\n        });\n\n        // Build table name map for link field operations\n        const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(\n          targetTableId,\n          [fieldInstance]\n        );\n\n        // Check if we need link context\n        const needsLinkContext = fieldInstance.type === FieldType.Link && !fieldInstance.isLookup;\n        const linkContext = needsLinkContext ? { tableId: targetTableId, tableNameMap } : undefined;\n\n        const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n          dbTableName,\n          fieldInstance,\n          fieldInstance,\n          tableDomain,\n          linkContext\n        );\n\n        for (const alterTableQuery of modifyColumnSql) {\n          this.logger.debug(\n            \"Executing SQL to modify primary formula field's column: \" + alterTableQuery\n          );\n          await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);\n        }\n        await this.prismaService.txClient().field.update({\n          where: {\n            id: fieldMap[id],\n          },\n          data: {\n            cellValueType,\n            dbFieldType,\n            isMultipleCellValue,\n          },\n        });\n      }\n    }\n  }\n\n  async repairFormulaReference(\n    formulaFields: IFieldWithTableIdJson[],\n    fieldMap: Record<string, string>\n  ) {\n    // [toFieldId, [fromFieldId][]]\n    const referenceFields = [] as [string, string[]][];\n    for (const field of formulaFields) {\n      const formulaOptions = field.options as IFormulaFieldOptions;\n      const expressionFields = extractFieldReferences(formulaOptions.expression);\n      const existedFields = expressionFields\n        .filter((fieldId) => fieldMap[fieldId])\n        .map((fieldId) => fieldMap[fieldId]);\n      const currentFieldId = fieldMap[field.id];\n      if (currentFieldId && existedFields.length > 0) {\n        referenceFields.push([currentFieldId, existedFields]);\n      }\n    }\n\n    const referenceRows = referenceFields\n      .flatMap(([toFieldId, fromFieldIds]) =>\n        fromFieldIds.map((fromFieldId) => ({ fromFieldId, toFieldId }))\n      )\n      .filter(\n        (row, index, list) =>\n          list.findIndex(\n            (other) => other.fromFieldId === row.fromFieldId && other.toFieldId === row.toFieldId\n          ) === index\n      );\n\n    if (referenceRows.length) {\n      await this.prismaService.txClient().reference.createMany({\n        data: referenceRows,\n        skipDuplicates: true,\n      });\n    }\n  }\n\n  async createLinkFields(\n    // filter lookup fields\n    linkFields: IFieldWithTableIdJson[],\n    tableIdMap: Record<string, string>,\n    fieldMap: Record<string, string>,\n    fkMap: Record<string, string>\n  ) {\n    const selfLinkFields = linkFields.filter(\n      ({ options, sourceTableId }) =>\n        (options as ILinkFieldOptions).foreignTableId === sourceTableId\n    );\n\n    // cross base link fields should convert to one-way link field\n    // only for base-duplicate\n    const crossBaseLinkFields = linkFields\n      .filter(({ options }) => Boolean((options as ILinkFieldOptions)?.baseId))\n      .map((f) => ({\n        ...f,\n        options: {\n          ...f.options,\n          isOneWay: true,\n        },\n      })) as IFieldWithTableIdJson[];\n\n    // already converted to text field in export side, prevent unexpected error\n    // if (crossBaseLinkFields.length > 0) {\n    //   throw new BadRequestException('cross base link fields are not supported');\n    // }\n\n    // common cross table link fields\n    const commonLinkFields = linkFields.filter(\n      ({ id }) => ![...selfLinkFields, ...crossBaseLinkFields].map(({ id }) => id).includes(id)\n    );\n\n    await this.createSelfLinkFields(selfLinkFields, fieldMap, fkMap);\n\n    // deal with cross base link fields\n    await this.createCommonLinkFields(crossBaseLinkFields, tableIdMap, fieldMap, fkMap, true);\n\n    await this.createCommonLinkFields(commonLinkFields, tableIdMap, fieldMap, fkMap);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async createSelfLinkFields(\n    fields: IFieldWithTableIdJson[],\n    fieldMap: Record<string, string>,\n    fkMap: Record<string, string>\n  ) {\n    const twoWaySelfLinkFields = fields.filter(\n      ({ options }) => !(options as ILinkFieldOptions).isOneWay\n    );\n\n    const mergedTwoWaySelfLinkFields = [] as [IFieldWithTableIdJson, IFieldWithTableIdJson][];\n\n    twoWaySelfLinkFields.forEach((f) => {\n      // two-way self link field should only create one of it\n      if (!mergedTwoWaySelfLinkFields.some((group) => group.some(({ id: fId }) => fId === f.id))) {\n        const groupField = twoWaySelfLinkFields.find(\n          ({ options }) => get(options, 'symmetricFieldId') === f.id\n        );\n        groupField && mergedTwoWaySelfLinkFields.push([f, groupField]);\n      }\n    });\n\n    const oneWaySelfLinkFields = fields.filter(\n      ({ options }) => (options as ILinkFieldOptions).isOneWay\n    );\n\n    const oneWayByTable = new Map<string, IFieldWithTableIdJson[]>();\n    for (const field of oneWaySelfLinkFields) {\n      const list = oneWayByTable.get(field.targetTableId) ?? [];\n      list.push(field);\n      oneWayByTable.set(field.targetTableId, list);\n    }\n\n    for (const [targetTableId, tableFields] of oneWayByTable.entries()) {\n      const fieldRos: IFieldRo[] = tableFields.map(\n        ({ name, type, options, description, dbFieldName }) => ({\n          name,\n          type,\n          dbFieldName,\n          description,\n          options: {\n            foreignTableId: targetTableId,\n            relationship: (options as ILinkFieldOptions).relationship,\n            isOneWay: true,\n          },\n        })\n      );\n\n      const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n        where: {\n          id: targetTableId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n\n      for (let index = 0; index < tableFields.length; index++) {\n        const original = tableFields[index];\n        const newFieldVo = newFieldVos[index];\n        await this.replenishmentConstraint(\n          newFieldVo.id,\n          targetTableId,\n          original.order,\n          {\n            notNull: original.notNull,\n            unique: original.unique,\n            dbFieldName: newFieldVo.dbFieldName,\n            isPrimary: original.isPrimary,\n          },\n          dbTableName\n        );\n        fieldMap[original.id] = newFieldVo.id;\n        if ((original.options as ILinkFieldOptions).selfKeyName.startsWith('__fk_')) {\n          fkMap[(original.options as ILinkFieldOptions).selfKeyName] = (\n            newFieldVo.options as ILinkFieldOptions\n          ).selfKeyName;\n        }\n      }\n    }\n\n    const twoWayByTable = new Map<\n      string,\n      Array<{ driverField: IFieldWithTableIdJson; groupField: IFieldWithTableIdJson }>\n    >();\n    for (const pair of mergedTwoWaySelfLinkFields) {\n      const index = pair.findIndex((f) => (f.options as ILinkFieldOptions).isOneWay === undefined)!;\n      const passiveIndex = index === -1 ? 0 : index;\n      const driverIndex = passiveIndex === 0 ? 1 : 0;\n\n      const groupField = pair[passiveIndex];\n      const driverField = pair[driverIndex];\n      const list = twoWayByTable.get(driverField.targetTableId) ?? [];\n      list.push({ driverField, groupField });\n      twoWayByTable.set(driverField.targetTableId, list);\n    }\n\n    for (const [targetTableId, pairs] of twoWayByTable.entries()) {\n      const fieldRos: IFieldRo[] = pairs.map(({ driverField }) => {\n        const options = driverField.options as ILinkFieldOptions;\n        return {\n          type: driverField.type as FieldType,\n          dbFieldName: driverField.dbFieldName,\n          name: driverField.name,\n          description: driverField.description,\n          options: {\n            ...pick(options, [\n              'relationship',\n              'isOneWay',\n              'filterByViewId',\n              'filter',\n              'visibleFieldIds',\n            ]),\n            foreignTableId: targetTableId,\n          },\n        };\n      });\n\n      const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n        where: {\n          id: targetTableId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n\n      for (let index = 0; index < pairs.length; index++) {\n        const { driverField, groupField } = pairs[index];\n        const newFieldVo = newFieldVos[index];\n        await this.replenishmentConstraint(\n          newFieldVo.id,\n          targetTableId,\n          driverField.order,\n          {\n            notNull: driverField.notNull,\n            unique: driverField.unique,\n            dbFieldName: newFieldVo.dbFieldName,\n            isPrimary: driverField.isPrimary,\n          },\n          dbTableName\n        );\n        fieldMap[driverField.id] = newFieldVo.id;\n        if ((driverField.options as ILinkFieldOptions).selfKeyName.startsWith('__fk_')) {\n          fkMap[(driverField.options as ILinkFieldOptions).selfKeyName] = (\n            newFieldVo.options as ILinkFieldOptions\n          ).selfKeyName;\n        }\n\n        const symmetricFieldId = (newFieldVo.options as ILinkFieldOptions).symmetricFieldId!;\n        fieldMap[groupField.id] = symmetricFieldId;\n        await this.repairSymmetricField(groupField, targetTableId, symmetricFieldId, dbTableName);\n      }\n    }\n  }\n\n  async createCommonLinkFields(\n    fields: IFieldWithTableIdJson[],\n    tableIdMap: Record<string, string>,\n    fieldMap: Record<string, string>,\n    fkMap: Record<string, string>,\n    allowCrossBase: boolean = false\n  ) {\n    const oneWayFields = fields.filter(({ options }) => (options as ILinkFieldOptions).isOneWay);\n    const twoWayFields = fields.filter(({ options }) => !(options as ILinkFieldOptions).isOneWay);\n\n    const oneWayByTable = new Map<string, IFieldWithTableIdJson[]>();\n    for (const field of oneWayFields) {\n      const list = oneWayByTable.get(field.targetTableId) ?? [];\n      list.push(field);\n      oneWayByTable.set(field.targetTableId, list);\n    }\n\n    for (const [targetTableId, tableFields] of oneWayByTable.entries()) {\n      const fieldRos: IFieldRo[] = tableFields.map(\n        ({ name, type, options, description, dbFieldName }) => {\n          const { foreignTableId, relationship } = options as ILinkFieldOptions;\n          return {\n            name,\n            type,\n            description,\n            dbFieldName,\n            options: {\n              foreignTableId: allowCrossBase ? foreignTableId : tableIdMap[foreignTableId],\n              relationship,\n              isOneWay: true,\n            },\n          };\n        }\n      );\n\n      const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n        where: {\n          id: targetTableId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n\n      for (let index = 0; index < tableFields.length; index++) {\n        const original = tableFields[index];\n        const newFieldVo = newFieldVos[index];\n        fieldMap[original.id] = newFieldVo.id;\n        if ((original.options as ILinkFieldOptions).selfKeyName.startsWith('__fk_')) {\n          fkMap[(original.options as ILinkFieldOptions).selfKeyName] = (\n            newFieldVo.options as ILinkFieldOptions\n          ).selfKeyName;\n        }\n        await this.replenishmentConstraint(\n          newFieldVo.id,\n          targetTableId,\n          original.order,\n          {\n            notNull: original.notNull,\n            unique: original.unique,\n            dbFieldName: newFieldVo.dbFieldName,\n            isPrimary: original.isPrimary,\n          },\n          dbTableName\n        );\n      }\n    }\n\n    const groupedTwoWayFields = [] as [IFieldWithTableIdJson, IFieldWithTableIdJson][];\n\n    twoWayFields.forEach((f) => {\n      // two-way link field should only create one of it\n      if (!groupedTwoWayFields.some((group) => group.some(({ id: fId }) => fId === f.id))) {\n        const symmetricField = twoWayFields.find(\n          ({ options }) => get(options, 'symmetricFieldId') === f.id\n        );\n        symmetricField && groupedTwoWayFields.push([f, symmetricField]);\n      }\n    });\n\n    const twoWayByTable = new Map<\n      string,\n      Array<{ passiveField: IFieldWithTableIdJson; symmetricField: IFieldWithTableIdJson }>\n    >();\n    for (const pair of groupedTwoWayFields) {\n      // fk would like in this table\n      const index = pair.findIndex((f) => (f.options as ILinkFieldOptions).isOneWay === undefined)!;\n      const passiveIndex = index === -1 ? 0 : index;\n      const driverIndex = passiveIndex === 0 ? 1 : 0;\n      const passiveField = pair[passiveIndex];\n      const symmetricField = pair[driverIndex];\n      const list = twoWayByTable.get(passiveField.targetTableId) ?? [];\n      list.push({ passiveField, symmetricField });\n      twoWayByTable.set(passiveField.targetTableId, list);\n    }\n\n    for (const [targetTableId, pairs] of twoWayByTable.entries()) {\n      const fieldRos: IFieldRo[] = pairs.map(({ passiveField }) => {\n        const { foreignTableId, relationship } = passiveField.options as ILinkFieldOptions;\n        return {\n          name: passiveField.name,\n          type: passiveField.type as FieldType,\n          description: passiveField.description,\n          dbFieldName: passiveField.dbFieldName,\n          options: {\n            foreignTableId: tableIdMap[foreignTableId],\n            relationship,\n            isOneWay: false,\n          },\n        };\n      });\n\n      const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos);\n\n      const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n        where: {\n          id: targetTableId,\n        },\n        select: {\n          dbTableName: true,\n        },\n      });\n\n      for (let index = 0; index < pairs.length; index++) {\n        const { passiveField, symmetricField } = pairs[index];\n        const newFieldVo = newFieldVos[index];\n        fieldMap[passiveField.id] = newFieldVo.id;\n        const symmetricFieldId = (newFieldVo.options as ILinkFieldOptions).symmetricFieldId!;\n        fieldMap[symmetricField.id] = symmetricFieldId;\n        if ((passiveField.options as ILinkFieldOptions).selfKeyName.startsWith('__fk_')) {\n          fkMap[(passiveField.options as ILinkFieldOptions).selfKeyName] = (\n            newFieldVo.options as ILinkFieldOptions\n          ).selfKeyName;\n        }\n        await this.replenishmentConstraint(\n          newFieldVo.id,\n          targetTableId,\n          passiveField.order,\n          {\n            notNull: passiveField.notNull,\n            unique: passiveField.unique,\n            dbFieldName: newFieldVo.dbFieldName,\n            isPrimary: passiveField.isPrimary,\n          },\n          dbTableName\n        );\n        await this.repairSymmetricField(\n          symmetricField,\n          (newFieldVo.options as ILinkFieldOptions).foreignTableId,\n          symmetricFieldId\n        );\n      }\n    }\n  }\n\n  // create two-way link, the symmetricFieldId created automatically, and need to update config\n  async repairSymmetricField(\n    symmetricField: IFieldWithTableIdJson,\n    targetTableId: string,\n    newFieldId: string,\n    targetDbTableName?: string\n  ) {\n    const { notNull, unique, dbFieldName, isPrimary, description, name, order } = symmetricField;\n    const { dbTableName: resolvedDbTableName } = targetDbTableName\n      ? { dbTableName: targetDbTableName }\n      : await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n          where: {\n            id: targetTableId,\n          },\n          select: {\n            dbTableName: true,\n          },\n        });\n\n    const { dbFieldName: genDbFieldName } = await this.prismaService\n      .txClient()\n      .field.findUniqueOrThrow({\n        where: {\n          id: newFieldId,\n        },\n        select: {\n          dbFieldName: true,\n        },\n      });\n\n    await this.prismaService.txClient().field.update({\n      where: {\n        id: newFieldId,\n      },\n      data: {\n        dbFieldName,\n        name,\n        description,\n      },\n    });\n\n    if (genDbFieldName !== dbFieldName) {\n      const exists = await this.dbProvider.checkColumnExist(\n        resolvedDbTableName,\n        genDbFieldName,\n        this.prismaService.txClient()\n      );\n      if (exists) {\n        // Debug logging for rename operation to diagnose failures\n        // eslint-disable-next-line no-console\n        console.log('[repairSymmetricField] renameColumn info', {\n          targetDbTableName: resolvedDbTableName,\n          genDbFieldName,\n          desiredDbFieldName: dbFieldName,\n          symmetricFieldId: newFieldId,\n        });\n        const alterTableSql = this.dbProvider.renameColumn(\n          resolvedDbTableName,\n          genDbFieldName,\n          dbFieldName\n        );\n\n        for (const sql of alterTableSql) {\n          // eslint-disable-next-line no-console\n          console.log('[repairSymmetricField] executing SQL', sql);\n          await this.prismaService.txClient().$executeRawUnsafe(sql);\n        }\n      }\n    }\n\n    await this.replenishmentConstraint(\n      newFieldId,\n      targetTableId,\n      order,\n      {\n        notNull,\n        unique,\n        dbFieldName,\n        isPrimary,\n      },\n      resolvedDbTableName\n    );\n  }\n\n  async repairFieldOptions(\n    tables: IBaseJson['tables'],\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    viewIdMap: Record<string, string>\n  ) {\n    const prisma = this.prismaService.txClient();\n\n    const sourceFields = tables.map(({ fields }) => fields).flat();\n\n    const targetFieldRaws = await prisma.field.findMany({\n      where: {\n        id: { in: Object.values(fieldIdMap) },\n      },\n    });\n\n    const targetFields = targetFieldRaws.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n\n    const linkFields = targetFields.filter(\n      (field) => field.type === FieldType.Link && !field.isLookup\n    );\n    const lookupFields = targetFields.filter((field) => field.isLookup);\n    const rollupFields = targetFields.filter((field) => field.type === FieldType.Rollup);\n    const conditionalRollupFields = targetFields.filter(\n      (field) => field.type === FieldType.ConditionalRollup\n    );\n\n    for (const field of linkFields) {\n      const { options, id } = field;\n      const sourceField = sourceFields.find((f) => fieldIdMap[f.id] === id);\n      const { filter, filterByViewId, visibleFieldIds } = sourceField?.options as ILinkFieldOptions;\n      const moreConfigStr = {\n        filter,\n        filterByViewId,\n        visibleFieldIds,\n      };\n\n      const newMoreConfigStr = replaceStringByMap(moreConfigStr, {\n        tableIdMap,\n        fieldIdMap,\n        viewIdMap,\n      });\n\n      const newOptions = {\n        ...options,\n        ...JSON.parse(newMoreConfigStr || '{}'),\n      };\n\n      await prisma.field.update({\n        where: {\n          id,\n        },\n        data: {\n          options: JSON.stringify(newOptions),\n        },\n      });\n    }\n    for (const field of conditionalRollupFields) {\n      const { options, id } = field;\n      const newOptions = replaceStringByMap(options, { tableIdMap, fieldIdMap, viewIdMap }, false);\n\n      await prisma.field.update({\n        where: { id },\n        data: { options: JSON.stringify(newOptions) },\n      });\n    }\n    for (const field of [...lookupFields, ...rollupFields]) {\n      const { lookupOptions, id } = field;\n      const sourceField = sourceFields.find((f) => fieldIdMap[f.id] === id);\n      const { filter } = sourceField?.lookupOptions as ILookupOptionsRo;\n      const moreConfigStr = {\n        filter,\n      };\n\n      const newMoreConfigStr = replaceStringByMap(moreConfigStr, {\n        tableIdMap,\n        fieldIdMap,\n        viewIdMap,\n      });\n\n      const newLookupOptions = {\n        ...lookupOptions,\n        ...JSON.parse(newMoreConfigStr || '{}'),\n      };\n\n      await prisma.field.update({\n        where: {\n          id,\n        },\n        data: {\n          lookupOptions: JSON.stringify(newLookupOptions),\n        },\n      });\n    }\n  }\n\n  /* eslint-disable sonarjs/cognitive-complexity */\n  async createDependencyFields(\n    dependFields: IFieldWithTableIdJson[],\n    tableIdMap: Record<string, string>,\n    fieldMap: Record<string, string>,\n    scope: 'base' | 'table' = 'base'\n  ): Promise<void> {\n    if (!dependFields.length) return;\n\n    const maxCount = dependFields.length * 10;\n\n    const checkedField = [] as IFieldJson[];\n\n    const countMap = {} as Record<string, number>;\n\n    while (dependFields.length) {\n      const curField = dependFields.shift();\n      if (!curField) continue;\n\n      const { sourceTableId, targetTableId } = curField;\n\n      const isChecked = checkedField.some((f) => f.id === curField.id);\n      // InDegree all ready\n      const isInDegreeReady = await this.isInDegreeReady(curField, fieldMap, scope);\n\n      if (isInDegreeReady) {\n        await this.duplicateSingleDependField(\n          sourceTableId,\n          targetTableId,\n          curField,\n          tableIdMap,\n          fieldMap,\n          scope\n        );\n        continue;\n      }\n\n      if (isChecked) {\n        if (curField.hasError) {\n          await this.duplicateSingleDependField(\n            sourceTableId,\n            targetTableId,\n            curField,\n            tableIdMap,\n            fieldMap,\n            scope,\n            true\n          );\n        } else if (!countMap[curField.id] || countMap[curField.id] < maxCount) {\n          dependFields.push(curField);\n          checkedField.push(curField);\n          countMap[curField.id] = (countMap[curField.id] || 0) + 1;\n        } else {\n          throw new CustomHttpException(\n            `Create circular field when create field: ${curField.name}[${curField.id}]`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.cycleDetectedCreateField',\n                context: {\n                  id: curField.id,\n                  name: curField.name,\n                },\n              },\n            }\n          );\n        }\n      } else {\n        dependFields.push(curField);\n        checkedField.push(curField);\n      }\n    }\n  }\n\n  async duplicateSingleDependField(\n    sourceTableId: string,\n    targetTableId: string,\n    field: IFieldWithTableIdJson,\n    tableIdMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>,\n    scope: 'base' | 'table' = 'base',\n    hasError = false\n  ) {\n    const hasFieldError = Boolean(field.hasError);\n    const isAiConfig = field.aiConfig && !field.isLookup;\n    const isLookup = field.isLookup;\n    const isRollup = field.type === FieldType.Rollup && !field.isLookup;\n    const isConditionalRollup = field.type === FieldType.ConditionalRollup;\n    const isFormula = field.type === FieldType.Formula && !field.isLookup;\n    const shouldConvertErroredComputed =\n      scope === 'base' && hasFieldError && (isLookup || isRollup || isConditionalRollup);\n\n    if (shouldConvertErroredComputed) {\n      // During base import, persist errored computed fields as plain text so users keep the data.\n      await this.duplicateErroredComputedFieldAsText(targetTableId, field, sourceToTargetFieldMap);\n      return;\n    }\n\n    switch (true) {\n      case isLookup:\n        await this.duplicateLookupField(\n          sourceTableId,\n          targetTableId,\n          field,\n          tableIdMap,\n          sourceToTargetFieldMap\n        );\n        break;\n      case isAiConfig:\n        await this.duplicateFieldAiConfig(\n          targetTableId,\n          field as unknown as IFieldInstance,\n          sourceToTargetFieldMap\n        );\n        break;\n      case isRollup:\n        await this.duplicateRollupField(\n          sourceTableId,\n          targetTableId,\n          field,\n          tableIdMap,\n          sourceToTargetFieldMap\n        );\n        break;\n      case isConditionalRollup:\n        await this.duplicateConditionalRollupField(\n          sourceTableId,\n          targetTableId,\n          field,\n          tableIdMap,\n          sourceToTargetFieldMap\n        );\n        break;\n      case isFormula:\n        await this.duplicateFormulaField(\n          targetTableId,\n          field,\n          sourceToTargetFieldMap,\n          hasError || hasFieldError\n        );\n    }\n  }\n\n  private async duplicateErroredComputedFieldAsText(\n    targetTableId: string,\n    field: IFieldWithTableIdJson,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const { id, name, description, dbFieldName, order, notNull, unique, isPrimary } = field;\n\n    const createFieldRo: IFieldRo = {\n      type: FieldType.SingleLineText,\n      name,\n      description,\n    };\n\n    if (dbFieldName) {\n      createFieldRo.dbFieldName = dbFieldName;\n    }\n\n    const newField = await this.fieldOpenApiService.createField(targetTableId, createFieldRo);\n\n    await this.replenishmentConstraint(newField.id, targetTableId, order, {\n      notNull,\n      unique,\n      dbFieldName: newField.dbFieldName,\n      isPrimary,\n    });\n\n    sourceToTargetFieldMap[id] = newField.id;\n  }\n\n  async duplicateLookupField(\n    sourceTableId: string,\n    targetTableId: string,\n    field: IFieldWithTableIdJson,\n    tableIdMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const {\n      dbFieldName,\n      name,\n      lookupOptions,\n      id,\n      hasError,\n      options,\n      notNull,\n      unique,\n      description,\n      isPrimary,\n      type: lookupFieldType,\n      isConditionalLookup,\n    } = field;\n\n    const mockFieldId = Object.values(sourceToTargetFieldMap)[0];\n    const { type: mockType } = await this.prismaService.txClient().field.findUniqueOrThrow({\n      where: {\n        id: mockFieldId,\n        deletedTime: null,\n      },\n      select: {\n        type: true,\n      },\n    });\n    let newField;\n\n    const lookupOptionsRo = lookupOptions as ILookupOptionsRo | undefined;\n\n    if (isConditionalLookup) {\n      const conditionalOptions = isConditionalLookupOptions(lookupOptionsRo)\n        ? (lookupOptionsRo as IConditionalLookupOptions)\n        : undefined;\n      const originalForeignTableId = conditionalOptions?.foreignTableId;\n      const originalLookupFieldId = conditionalOptions?.lookupFieldId;\n      const mappedForeignTableId = originalForeignTableId\n        ? originalForeignTableId === sourceTableId\n          ? targetTableId\n          : tableIdMap[originalForeignTableId] || originalForeignTableId\n        : undefined;\n      const mappedLookupFieldId = originalLookupFieldId\n        ? sourceToTargetFieldMap[originalLookupFieldId] || originalLookupFieldId\n        : undefined;\n      const remappedLookupOptions = conditionalOptions\n        ? (replaceStringByMap(\n            conditionalOptions,\n            { tableIdMap, fieldIdMap: sourceToTargetFieldMap },\n            false\n          ) as IConditionalLookupOptions)\n        : undefined;\n\n      if (!mappedForeignTableId || !(hasError || mappedLookupFieldId)) {\n        throw new BadGatewayException(\n          'Unable to resolve conditional lookup references during duplication'\n        );\n      }\n\n      const effectiveLookupFieldId = hasError ? mockFieldId : (mappedLookupFieldId as string);\n\n      newField = await this.fieldOpenApiService.createField(targetTableId, {\n        type: (hasError ? mockType : lookupFieldType) as FieldType,\n        dbFieldName,\n        description,\n        isLookup: true,\n        isConditionalLookup: true,\n        name,\n        options,\n        lookupOptions: {\n          baseId: remappedLookupOptions?.baseId ?? conditionalOptions?.baseId,\n          foreignTableId: remappedLookupOptions?.foreignTableId ?? mappedForeignTableId,\n          lookupFieldId: effectiveLookupFieldId,\n          filter: remappedLookupOptions?.filter ?? conditionalOptions?.filter ?? null,\n          sort: remappedLookupOptions?.sort ?? conditionalOptions?.sort ?? undefined,\n          limit: remappedLookupOptions?.limit ?? conditionalOptions?.limit ?? undefined,\n        },\n      });\n\n      if (hasError) {\n        await this.prismaService.txClient().field.update({\n          where: {\n            id: newField.id,\n          },\n          data: {\n            hasError,\n            type: lookupFieldType,\n            lookupOptions: JSON.stringify({\n              ...newField.lookupOptions,\n              lookupFieldId: conditionalOptions?.lookupFieldId,\n              filter: conditionalOptions?.filter ?? null,\n              sort: conditionalOptions?.sort ?? undefined,\n              limit: conditionalOptions?.limit ?? undefined,\n            }),\n            options: JSON.stringify(options),\n          },\n        });\n      }\n    } else {\n      if (!lookupOptionsRo || !isLinkLookupOptions(lookupOptionsRo)) {\n        throw new BadGatewayException(\n          'Lookup options missing link configuration during duplication'\n        );\n      }\n\n      const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptionsRo;\n      const isSelfLink = foreignTableId === sourceTableId;\n\n      newField = await this.fieldOpenApiService.createField(targetTableId, {\n        type: (hasError ? mockType : lookupFieldType) as FieldType,\n        dbFieldName,\n        description,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId:\n            (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId,\n          linkFieldId: sourceToTargetFieldMap[linkFieldId],\n          lookupFieldId: isSelfLink\n            ? hasError\n              ? mockFieldId\n              : sourceToTargetFieldMap[lookupFieldId]\n            : hasError\n              ? mockFieldId\n              : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId,\n        },\n        name,\n      });\n\n      if (hasError) {\n        await this.prismaService.txClient().field.update({\n          where: {\n            id: newField.id,\n          },\n          data: {\n            hasError,\n            type: lookupFieldType,\n            lookupOptions: JSON.stringify({\n              ...newField.lookupOptions,\n              lookupFieldId,\n            }),\n            options: JSON.stringify(options),\n          },\n        });\n      }\n    }\n    await this.replenishmentConstraint(newField.id, targetTableId, field.order, {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    });\n    sourceToTargetFieldMap[id] = newField.id;\n  }\n\n  async duplicateRollupField(\n    sourceTableId: string,\n    targetTableId: string,\n    fieldInstance: IFieldWithTableIdJson,\n    tableIdMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const {\n      dbFieldName,\n      name,\n      lookupOptions,\n      id,\n      hasError,\n      options,\n      notNull,\n      unique,\n      description,\n      isPrimary,\n      type: lookupFieldType,\n    } = fieldInstance;\n    if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n      throw new BadGatewayException('Rollup field without link lookup options during duplication');\n    }\n    const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptions;\n    const isSelfLink = foreignTableId === sourceTableId;\n\n    const mockFieldId = Object.values(sourceToTargetFieldMap)[0];\n    const newField = await this.fieldOpenApiService.createField(targetTableId, {\n      type: FieldType.Rollup,\n      dbFieldName,\n      description,\n      lookupOptions: {\n        // foreignTableId may are cross base table id, so we need to use tableIdMap to get the target table id\n        foreignTableId: (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId,\n        linkFieldId: sourceToTargetFieldMap[linkFieldId],\n        lookupFieldId: isSelfLink\n          ? hasError\n            ? mockFieldId\n            : sourceToTargetFieldMap[lookupFieldId]\n          : hasError\n            ? mockFieldId\n            : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId,\n      },\n      options,\n      name,\n    });\n    await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    });\n    sourceToTargetFieldMap[id] = newField.id;\n    if (hasError) {\n      await this.prismaService.txClient().field.update({\n        where: {\n          id: newField.id,\n        },\n        data: {\n          hasError,\n          type: lookupFieldType,\n          lookupOptions: JSON.stringify({\n            ...newField.lookupOptions,\n            lookupFieldId: lookupFieldId,\n          }),\n          options: JSON.stringify(options),\n        },\n      });\n    }\n  }\n\n  async duplicateConditionalRollupField(\n    _sourceTableId: string,\n    targetTableId: string,\n    fieldInstance: IFieldWithTableIdJson,\n    tableIdMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const {\n      dbFieldName,\n      name,\n      id,\n      hasError,\n      options,\n      notNull,\n      unique,\n      description,\n      isPrimary,\n      type,\n    } = fieldInstance;\n\n    const referenceOptions = options as IConditionalRollupFieldOptions;\n    const mockFieldId = Object.values(sourceToTargetFieldMap)[0];\n\n    const remappedOptions = replaceStringByMap(\n      {\n        ...referenceOptions,\n        foreignTableId:\n          tableIdMap[referenceOptions.foreignTableId!] || referenceOptions.foreignTableId,\n        lookupFieldId: hasError\n          ? mockFieldId\n          : sourceToTargetFieldMap[referenceOptions.lookupFieldId!] ||\n            referenceOptions.lookupFieldId,\n      },\n      { tableIdMap, fieldIdMap: sourceToTargetFieldMap },\n      false\n    ) as IConditionalRollupFieldOptions;\n\n    const newField = await this.fieldOpenApiService.createField(targetTableId, {\n      type: FieldType.ConditionalRollup,\n      dbFieldName,\n      description,\n      options: remappedOptions,\n      name,\n    });\n\n    await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    });\n\n    sourceToTargetFieldMap[id] = newField.id;\n\n    if (hasError) {\n      await this.prismaService.txClient().field.update({\n        where: { id: newField.id },\n        data: {\n          hasError,\n          type,\n          options: JSON.stringify(options),\n        },\n      });\n    }\n  }\n\n  async duplicateFormulaField(\n    targetTableId: string,\n    fieldInstance: IFieldWithTableIdJson,\n    sourceToTargetFieldMap: Record<string, string>,\n    hasError: boolean = false\n  ) {\n    const {\n      type,\n      dbFieldName,\n      name,\n      options,\n      id,\n      notNull,\n      unique,\n      description,\n      isPrimary,\n      dbFieldType,\n      cellValueType,\n      isMultipleCellValue,\n    } = fieldInstance;\n    const { expression } = options as IFormulaFieldOptions;\n    const newExpression = replaceStringByMap(expression, { sourceToTargetFieldMap });\n    const newField = await this.fieldOpenApiService.createField(targetTableId, {\n      type,\n      dbFieldName,\n      description,\n      options: {\n        ...options,\n        expression: hasError\n          ? DEFAULT_EXPRESSION\n          : newExpression\n            ? JSON.parse(newExpression)\n            : undefined,\n      },\n      name,\n    });\n    await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    });\n    sourceToTargetFieldMap[id] = newField.id;\n\n    if (hasError) {\n      await this.prismaService.txClient().field.update({\n        where: {\n          id: newField.id,\n        },\n        data: {\n          hasError,\n          options: JSON.stringify({\n            ...options,\n            expression: newExpression ? JSON.parse(newExpression) : undefined,\n          }),\n          // error formulas should not be persisted as generated columns\n          meta: null,\n        },\n      });\n    }\n\n    if (dbFieldType !== newField.dbFieldType) {\n      const tableDomain = await this.tableDomainQueryService.getTableDomainById(targetTableId);\n      const { dbTableName } = tableDomain;\n\n      // Create field instance for the updated field\n      const updatedFieldRaw = await this.prismaService.txClient().field.findUniqueOrThrow({\n        where: { id: newField.id },\n      });\n      const fieldInstance = createFieldInstanceByRaw({\n        ...updatedFieldRaw,\n        dbFieldType,\n        cellValueType,\n        isMultipleCellValue: isMultipleCellValue ?? null,\n      });\n\n      // Build table name map for link field operations\n      const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(\n        targetTableId,\n        [fieldInstance]\n      );\n\n      // Check if we need link context\n      const needsLinkContext = fieldInstance.type === FieldType.Link && !fieldInstance.isLookup;\n      const linkContext = needsLinkContext ? { tableId: targetTableId, tableNameMap } : undefined;\n\n      const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n        dbTableName,\n        fieldInstance,\n        fieldInstance,\n        tableDomain,\n        linkContext\n      );\n\n      for (const alterTableQuery of modifyColumnSql) {\n        await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);\n      }\n\n      await this.prismaService.txClient().field.update({\n        where: {\n          id: newField.id,\n        },\n        data: {\n          dbFieldType,\n          cellValueType,\n          isMultipleCellValue,\n        },\n      });\n    }\n  }\n\n  private async duplicateFieldAiConfig(\n    targetTableId: string,\n    fieldInstance: IFieldInstance,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    if (!fieldInstance.aiConfig) return;\n\n    const { type, dbFieldName, name, options, id, notNull, unique, description, isPrimary } =\n      fieldInstance;\n\n    const aiConfig: IFieldVo['aiConfig'] = { ...fieldInstance.aiConfig };\n\n    if ('sourceFieldId' in aiConfig) {\n      aiConfig.sourceFieldId = sourceToTargetFieldMap[aiConfig.sourceFieldId as string];\n    }\n\n    if ('prompt' in aiConfig) {\n      Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {\n        aiConfig.prompt = aiConfig.prompt.replaceAll(key, value);\n      });\n    }\n\n    const newField = await this.fieldOpenApiService.createField(targetTableId, {\n      type,\n      dbFieldName,\n      description,\n      options,\n      aiConfig,\n      name,\n    });\n\n    await this.replenishmentConstraint(newField.id, targetTableId, 1, {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    });\n    sourceToTargetFieldMap[id] = newField.id;\n  }\n\n  // field could not set constraint when create\n  async replenishmentConstraint(\n    fId: string,\n    targetTableId: string,\n    order: number,\n    {\n      notNull,\n      unique,\n      dbFieldName,\n      isPrimary,\n    }: { notNull?: boolean; unique?: boolean; dbFieldName: string; isPrimary?: boolean },\n    dbTableName?: string\n  ) {\n    await this.prismaService.txClient().field.update({\n      where: {\n        id: fId,\n      },\n      data: {\n        order,\n      },\n    });\n    if (!notNull && !unique && !isPrimary) {\n      return;\n    }\n\n    const resolvedDbTableName =\n      dbTableName ??\n      (\n        await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n          where: {\n            id: targetTableId,\n          },\n          select: {\n            dbTableName: true,\n          },\n        })\n      ).dbTableName;\n\n    await this.prismaService.txClient().field.update({\n      where: {\n        id: fId,\n      },\n      data: {\n        notNull: notNull ?? null,\n        unique: unique ?? null,\n        isPrimary: isPrimary ?? null,\n      },\n    });\n\n    if (notNull || unique) {\n      const fieldValidationSqls = this.knex.schema\n        .alterTable(resolvedDbTableName, (table) => {\n          if (unique)\n            table.unique([dbFieldName], {\n              indexName: this.fieldOpenApiService.getFieldUniqueKeyName(\n                resolvedDbTableName,\n                dbFieldName,\n                fId\n              ),\n            });\n          if (notNull) table.dropNullable(dbFieldName);\n        })\n        .toSQL();\n\n      for (const sql of fieldValidationSqls) {\n        // skip sqlite pragma\n        if (sql.sql.startsWith('PRAGMA')) {\n          continue;\n        }\n        await this.prismaService.txClient().$executeRawUnsafe(sql.sql);\n      }\n    }\n  }\n\n  private async isInDegreeReady(\n    field: IFieldWithTableIdJson,\n    fieldMap: Record<string, string>,\n    scope: 'base' | 'table' = 'base'\n  ) {\n    const { isLookup, type, isConditionalLookup } = field;\n    if (field.aiConfig) {\n      const { aiConfig } = field;\n\n      if ('sourceFieldId' in aiConfig) {\n        return Boolean(fieldMap[aiConfig.sourceFieldId]);\n      }\n\n      if ('prompt' in aiConfig) {\n        const { prompt } = aiConfig;\n        const fieldIds = extractFieldReferences(prompt);\n        const keys = Object.keys(fieldMap);\n        return fieldIds.every((field) => keys.includes(field));\n      }\n    }\n\n    if (type === FieldType.Formula && !isLookup) {\n      const formulaOptions = field.options as IFormulaFieldOptions;\n      const referencedFields = this.extractFieldIds(formulaOptions.expression);\n      const keys = Object.keys(fieldMap);\n      return referencedFields.every((field) => keys.includes(field));\n    }\n\n    if (type === FieldType.ConditionalRollup) {\n      const options = field.options as IConditionalRollupFieldOptions | undefined;\n      if (!options) {\n        return false;\n      }\n\n      if (options.baseId) {\n        return true;\n      }\n\n      const dependencies = this.collectConditionalDependencies({\n        lookupFieldId: options.lookupFieldId,\n        filter: options.filter,\n        sortFieldId: options.sort?.fieldId,\n      });\n      return this.areDependenciesResolved(fieldMap, dependencies);\n    }\n\n    if (isLookup && isConditionalLookup) {\n      const lookupOptions = field.lookupOptions as IConditionalLookupOptions | undefined;\n      if (!lookupOptions) {\n        return false;\n      }\n\n      if (lookupOptions.baseId) {\n        return true;\n      }\n\n      const dependencies = this.collectConditionalDependencies({\n        lookupFieldId: lookupOptions.lookupFieldId,\n        filter: lookupOptions.filter,\n        sortFieldId: lookupOptions.sort?.fieldId,\n      });\n      return this.areDependenciesResolved(fieldMap, dependencies);\n    }\n\n    if (isLookup || type === FieldType.Rollup) {\n      const { lookupOptions, sourceTableId } = field;\n      if (!lookupOptions || !isLinkLookupOptions(lookupOptions)) {\n        return false;\n      }\n      const { linkFieldId, lookupFieldId, foreignTableId } = lookupOptions;\n      const isSelfLink = foreignTableId === sourceTableId;\n      const linkField = await this.prismaService.txClient().field.findUnique({\n        where: {\n          id: linkFieldId,\n        },\n        select: {\n          options: true,\n        },\n      });\n\n      // if the cross base relative field is existed, the lookup or rollup field should be ready to create\n      const linkFieldOptions = JSON.parse(\n        linkField?.options || ('{}' as string)\n      ) as ILinkFieldOptions;\n\n      if (linkFieldOptions.baseId) {\n        return true;\n      }\n\n      // duplicate table should not consider lookupFieldId when link field is not self link\n      return scope === 'base' || isSelfLink\n        ? Boolean(fieldMap[lookupFieldId] && fieldMap[linkFieldId])\n        : fieldMap[linkFieldId];\n    }\n\n    return false;\n  }\n\n  private extractFieldIds(expression: string): string[] {\n    const matches = expression.match(/\\{fld[a-zA-Z0-9]+\\}/g);\n\n    if (!matches) {\n      return [];\n    }\n    return matches.map((match) => match.slice(1, -1));\n  }\n\n  private collectConditionalDependencies({\n    lookupFieldId,\n    filter,\n    sortFieldId,\n  }: {\n    lookupFieldId?: string | null;\n    filter?: IFilter | null;\n    sortFieldId?: string | null;\n  }): string[] {\n    const dependencies = new Set<string>();\n\n    if (lookupFieldId) {\n      dependencies.add(lookupFieldId);\n    }\n\n    extractFieldIdsFromFilter(filter || undefined, true).forEach((fieldId) => {\n      dependencies.add(fieldId);\n    });\n\n    if (sortFieldId) {\n      dependencies.add(sortFieldId);\n    }\n\n    return [...dependencies];\n  }\n\n  private areDependenciesResolved(\n    fieldMap: Record<string, string>,\n    dependencies: string[]\n  ): boolean {\n    if (!dependencies.length) {\n      return true;\n    }\n\n    const knownFieldIds = new Set(Object.keys(fieldMap));\n    return dependencies.every((fieldId) => knownFieldIds.has(fieldId));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { TableDomainQueryModule } from '../table-domain';\nimport { FormulaFieldService } from './field-calculate/formula-field.service';\nimport { LinkFieldQueryService } from './field-calculate/link-field-query.service';\nimport { FieldService } from './field.service';\n\n@Module({\n  imports: [CalculationModule, TableDomainQueryModule],\n  providers: [FieldService, DbProvider, FormulaFieldService, LinkFieldQueryService],\n  exports: [FieldService, LinkFieldQueryService],\n})\nexport class FieldModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field.service.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { CellValueType, DbFieldType, FieldType, OpName } from '@teable/core';\nimport type { IFieldVo, INumberFormatting, ISetFieldPropertyOpContext } from '@teable/core';\nimport { GlobalModule } from '../../global/global.module';\nimport { FieldModule } from './field.module';\nimport { FieldService } from './field.service';\nimport { applyFieldPropertyOpsAndCreateInstance } from './model/factory';\n\ndescribe('FieldService', () => {\n  let service: FieldService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldModule],\n    }).compile();\n\n    service = module.get<FieldService>(FieldService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('applyFieldPropertyOpsAndCreateInstance', () => {\n    it('should apply field property operations and return field instance', () => {\n      // Create a mock field VO\n      const mockFieldVo: IFieldVo = {\n        id: 'fld123',\n        name: 'Original Name',\n        type: FieldType.SingleLineText,\n        dbFieldName: 'fld_original',\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        options: {},\n      };\n\n      // Create mock operations\n      const ops: ISetFieldPropertyOpContext[] = [\n        {\n          name: OpName.SetFieldProperty,\n          key: 'name',\n          newValue: 'Updated Name',\n          oldValue: 'Original Name',\n        },\n        {\n          name: OpName.SetFieldProperty,\n          key: 'description',\n          newValue: 'New description',\n          oldValue: undefined,\n        },\n      ];\n\n      // Apply operations\n      const result = applyFieldPropertyOpsAndCreateInstance(mockFieldVo, ops);\n\n      // Verify the result is a field instance\n      expect(result).toBeDefined();\n      expect(result.id).toBe('fld123');\n      expect(result.name).toBe('Updated Name');\n      expect(result.description).toBe('New description');\n      expect(result.type).toBe(FieldType.SingleLineText);\n\n      // Verify original field VO is not modified\n      expect(mockFieldVo.name).toBe('Original Name');\n      expect(mockFieldVo.description).toBeUndefined();\n    });\n\n    it('should handle empty operations array', () => {\n      const mockFieldVo: IFieldVo = {\n        id: 'fld123',\n        name: 'Test Field',\n        type: FieldType.Number,\n        dbFieldName: 'fld_test',\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        options: {\n          formatting: {\n            type: 'decimal',\n            precision: 2,\n          } as INumberFormatting,\n        },\n      };\n\n      const result = applyFieldPropertyOpsAndCreateInstance(mockFieldVo, []);\n\n      expect(result).toBeDefined();\n      expect(result.id).toBe('fld123');\n      expect(result.name).toBe('Test Field');\n      expect(result.type).toBe(FieldType.Number);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/field.service.ts",
    "content": "import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';\nimport {\n  FieldOpBuilder,\n  HttpErrorCode,\n  IdPrefix,\n  OpName,\n  checkFieldUniqueValidationEnabled,\n  checkFieldValidationEnabled,\n  FieldType,\n  isLinkLookupOptions,\n} from '@teable/core';\nimport type {\n  IFieldVo,\n  IGetFieldsQuery,\n  ISnapshotBase,\n  ISetFieldPropertyOpContext,\n  ILookupOptionsVo,\n  IOtOperation,\n  ViewType,\n  FormulaFieldCore,\n} from '@teable/core';\nimport type { Field as RawField, Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { instanceToPlain } from 'class-transformer';\nimport { Knex } from 'knex';\nimport { keyBy, sortBy, omit } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { DropColumnOperationType } from '../../db-provider/drop-database-column-query/drop-database-column-field-visitor.interface';\nimport type { IReadonlyAdapterService } from '../../share-db/interface';\nimport { RawOpType } from '../../share-db/interface';\nimport type { IClsStore } from '../../types/cls';\n\nimport { handleDBValidationErrors } from '../../utils/db-validation-error';\nimport { isNotHiddenField } from '../../utils/is-not-hidden-field';\nimport { convertNameToValidCharacter } from '../../utils/name-conversion';\nimport { BatchService } from '../calculation/batch.service';\n\nimport { DataLoaderService } from '../data-loader/data-loader.service';\nimport { TableDomainQueryService } from '../table-domain/table-domain-query.service';\nimport { FormulaFieldService } from './field-calculate/formula-field.service';\nimport { LinkFieldQueryService } from './field-calculate/link-field-query.service';\n\nimport type { IFieldInstance } from './model/factory';\nimport {\n  createFieldInstanceByVo,\n  createFieldInstanceByRaw,\n  rawField2FieldObj,\n  applyFieldPropertyOpsAndCreateInstance,\n} from './model/factory';\nimport type { FormulaFieldDto } from './model/field-dto/formula-field.dto';\n\ntype IOpContext = ISetFieldPropertyOpContext;\n\n@Injectable()\nexport class FieldService implements IReadonlyAdapterService {\n  private logger = new Logger(FieldService.name);\n  constructor(\n    private readonly batchService: BatchService,\n    private readonly prismaService: PrismaService,\n    private readonly dataLoaderService: DataLoaderService,\n    private readonly cls: ClsService<IClsStore>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n\n    private readonly formulaFieldService: FormulaFieldService,\n    private readonly linkFieldQueryService: LinkFieldQueryService,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  private invalidateFieldLoader(tableIds: string | string[]) {\n    const ids = (Array.isArray(tableIds) ? tableIds : [tableIds]).filter(Boolean);\n    if (!ids.length) {\n      return;\n    }\n    this.dataLoaderService.field.invalidateTables(ids);\n  }\n\n  async generateDbFieldName(tableId: string, name: string): Promise<string> {\n    let dbFieldName = convertNameToValidCharacter(name, 40);\n\n    const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId));\n    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);\n    // fallback logic\n    if (columns.some((column) => column.name === dbFieldName)) {\n      dbFieldName += new Date().getTime();\n    }\n    return dbFieldName;\n  }\n\n  async generateDbFieldNames(tableId: string, names: string[]) {\n    const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId));\n    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);\n    return names\n      .map((name) => convertNameToValidCharacter(name, 40))\n      .map((dbFieldName) => {\n        if (columns.some((column) => column.name === dbFieldName)) {\n          const newDbFieldName = dbFieldName + new Date().getTime();\n          columns.push({ name: newDbFieldName });\n          return (dbFieldName += new Date().getTime());\n        }\n        columns.push({ name: dbFieldName });\n        return dbFieldName;\n      });\n  }\n\n  private async dbCreateField(tableId: string, fieldInstance: IFieldInstance) {\n    const userId = this.cls.get('user.id');\n    const {\n      id,\n      name,\n      dbFieldName,\n      description,\n      type,\n      options,\n      meta,\n      aiConfig,\n      lookupOptions,\n      notNull,\n      unique,\n      isPrimary,\n      isComputed,\n      hasError,\n      dbFieldType,\n      cellValueType,\n      isMultipleCellValue,\n      isLookup,\n      isConditionalLookup,\n    } = fieldInstance;\n\n    const agg = await this.prismaService.txClient().field.aggregate({\n      where: { tableId, deletedTime: null },\n      _max: {\n        order: true,\n      },\n    });\n    const order = agg._max.order == null ? 0 : agg._max.order + 1;\n    const data: Prisma.FieldCreateInput = {\n      id,\n      table: {\n        connect: {\n          id: tableId,\n        },\n      },\n      name,\n      description,\n      type,\n      aiConfig: aiConfig && JSON.stringify(aiConfig),\n      options: JSON.stringify(options),\n      meta: meta && JSON.stringify(meta),\n      notNull,\n      unique,\n      isPrimary,\n      order,\n      version: 1,\n      isComputed,\n      isLookup,\n      hasError,\n      // add lookupLinkedFieldId for indexing\n      lookupLinkedFieldId:\n        lookupOptions && isLinkLookupOptions(lookupOptions) ? lookupOptions.linkFieldId : undefined,\n      lookupOptions: lookupOptions && JSON.stringify(lookupOptions),\n      dbFieldName,\n      dbFieldType,\n      cellValueType,\n      isMultipleCellValue,\n      isConditionalLookup,\n      createdBy: userId,\n    };\n\n    const field = await this.prismaService.txClient().field.upsert({\n      where: { id: data.id },\n      create: data,\n      update: { ...data, deletedTime: null, version: undefined },\n    });\n    this.invalidateFieldLoader(tableId);\n    return field;\n  }\n\n  private async dbCreateFields(tableId: string, fieldInstances: IFieldInstance[]) {\n    const userId = this.cls.get('user.id');\n    const agg = await this.prismaService.txClient().field.aggregate({\n      where: { tableId, deletedTime: null },\n      _max: {\n        order: true,\n      },\n    });\n    const order = agg._max.order == null ? 0 : agg._max.order + 1;\n    const existedFieldIds = (\n      await this.prismaService.txClient().field.findMany({\n        where: { tableId, deletedTime: null },\n        select: { id: true },\n      })\n    ).map(({ id }) => id);\n    const data: Prisma.FieldCreateManyInput[] = fieldInstances\n      .filter(({ id }) => !existedFieldIds.includes(id))\n      .map(\n        (\n          {\n            id,\n            name,\n            dbFieldName,\n            description,\n            type,\n            options,\n            aiConfig,\n            lookupOptions,\n            notNull,\n            unique,\n            isPrimary,\n            isComputed,\n            hasError,\n            dbFieldType,\n            cellValueType,\n            isMultipleCellValue,\n            isLookup,\n            isConditionalLookup,\n            meta,\n          },\n          index\n        ) => ({\n          id,\n          name,\n          description,\n          type,\n          aiConfig: aiConfig ? JSON.stringify(aiConfig) : undefined,\n          options: JSON.stringify(options),\n          notNull,\n          unique,\n          isPrimary,\n          order: order + index,\n          version: 1,\n          isComputed,\n          isLookup,\n          isConditionalLookup,\n          hasError,\n          // add lookupLinkedFieldId for indexing\n          lookupLinkedFieldId:\n            lookupOptions && isLinkLookupOptions(lookupOptions)\n              ? lookupOptions.linkFieldId\n              : undefined,\n          lookupOptions: lookupOptions && JSON.stringify(lookupOptions),\n          dbFieldName,\n          dbFieldType,\n          cellValueType,\n          isMultipleCellValue,\n          createdBy: userId,\n          meta: meta ? JSON.stringify(meta) : undefined,\n          tableId,\n        })\n      );\n\n    const result = await this.prismaService.txClient().field.createMany({\n      data: data,\n    });\n    this.invalidateFieldLoader(tableId);\n    return result;\n  }\n\n  async dbCreateMultipleField(tableId: string, fieldInstances: IFieldInstance[]) {\n    if (!fieldInstances.length) {\n      return [];\n    }\n\n    const prisma = this.prismaService.txClient();\n    const userId = this.cls.get('user.id');\n    const fieldIds = fieldInstances.map((field) => field.id);\n\n    // Determine order base once so inserts/restores keep the same ordering behavior as sequential creates.\n    const agg = await prisma.field.aggregate({\n      where: { tableId, deletedTime: null },\n      _max: { order: true },\n    });\n    const baseOrder = agg._max.order == null ? 0 : agg._max.order + 1;\n\n    // Fast path: if none of the ids exist (including deleted rows), use createMany.\n    const existing = await prisma.field.findMany({\n      where: { id: { in: fieldIds } },\n      select: { id: true },\n    });\n\n    if (!existing.length) {\n      const data: Prisma.FieldCreateManyInput[] = fieldInstances.map((fieldInstance, index) => {\n        const {\n          id,\n          name,\n          description,\n          type,\n          options,\n          aiConfig,\n          lookupOptions,\n          notNull,\n          unique,\n          isPrimary,\n          isComputed,\n          hasError,\n          dbFieldType,\n          cellValueType,\n          isMultipleCellValue,\n          isLookup,\n          isConditionalLookup,\n          meta,\n          dbFieldName,\n        } = fieldInstance;\n        return {\n          id,\n          name,\n          description,\n          type,\n          aiConfig: aiConfig ? JSON.stringify(aiConfig) : undefined,\n          options: JSON.stringify(options),\n          meta: meta ? JSON.stringify(meta) : undefined,\n          notNull,\n          unique,\n          isPrimary,\n          order: baseOrder + index,\n          version: 1,\n          isComputed,\n          isLookup,\n          isConditionalLookup,\n          hasError,\n          lookupLinkedFieldId:\n            lookupOptions && isLinkLookupOptions(lookupOptions)\n              ? lookupOptions.linkFieldId\n              : undefined,\n          lookupOptions: lookupOptions ? JSON.stringify(lookupOptions) : undefined,\n          dbFieldName,\n          dbFieldType,\n          cellValueType,\n          isMultipleCellValue,\n          createdBy: userId,\n          tableId,\n        };\n      });\n\n      await prisma.field.createMany({ data });\n      this.invalidateFieldLoader(tableId);\n      return prisma.field.findMany({ where: { id: { in: fieldIds } } });\n    }\n\n    const multiFieldData: RawField[] = [];\n    for (let i = 0; i < fieldInstances.length; i++) {\n      const fieldInstance = fieldInstances[i];\n      const {\n        id,\n        name,\n        dbFieldName,\n        description,\n        type,\n        options,\n        meta,\n        aiConfig,\n        lookupOptions,\n        notNull,\n        unique,\n        isPrimary,\n        isComputed,\n        hasError,\n        dbFieldType,\n        cellValueType,\n        isMultipleCellValue,\n        isLookup,\n        isConditionalLookup,\n      } = fieldInstance;\n\n      const data: Prisma.FieldCreateInput = {\n        id,\n        table: {\n          connect: {\n            id: tableId,\n          },\n        },\n        name,\n        description,\n        type,\n        aiConfig: aiConfig && JSON.stringify(aiConfig),\n        options: JSON.stringify(options),\n        meta: meta && JSON.stringify(meta),\n        notNull,\n        unique,\n        isPrimary,\n        order: baseOrder + i,\n        version: 1,\n        isComputed,\n        isLookup,\n        hasError,\n        // add lookupLinkedFieldId for indexing\n        lookupLinkedFieldId:\n          lookupOptions && isLinkLookupOptions(lookupOptions)\n            ? lookupOptions.linkFieldId\n            : undefined,\n        lookupOptions: lookupOptions && JSON.stringify(lookupOptions),\n        dbFieldName,\n        dbFieldType,\n        cellValueType,\n        isMultipleCellValue,\n        isConditionalLookup,\n        createdBy: userId,\n      };\n\n      const field = await prisma.field.upsert({\n        where: { id: data.id },\n        create: data,\n        update: { ...data, deletedTime: null, version: undefined },\n      });\n      multiFieldData.push(field);\n    }\n\n    this.invalidateFieldLoader(tableId);\n    return multiFieldData;\n  }\n\n  async dbCreateMultipleFields(tableId: string, fieldInstances: IFieldInstance[]) {\n    return await this.dbCreateFields(tableId, fieldInstances);\n  }\n\n  private async alterTableAddField(\n    tableId: string,\n    dbTableName: string,\n    fieldInstances: IFieldInstance[],\n    isNewTable: boolean = false,\n    isSymmetricField?: boolean\n  ) {\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(\n      tableId,\n      fieldInstances\n    );\n\n    for (const fieldInstance of fieldInstances) {\n      const { dbFieldName, type, isLookup, unique, notNull, id: fieldId, name } = fieldInstance;\n\n      // Early validation: creating a field with NOT NULL is not allowed\n      // Do this before generating/issuing any SQL to avoid DB-level 23502 errors\n      if (notNull) {\n        throw new BadRequestException(\n          `Field type \"${type}\" does not support field validation when creating a new field`\n        );\n      }\n\n      const alterTableQueries = this.dbProvider.createColumnSchema(\n        dbTableName,\n        fieldInstance,\n        tableDomain,\n        isNewTable,\n        tableId,\n        tableNameMap,\n        isSymmetricField,\n        false\n      );\n\n      // Execute all queries (main table alteration + any additional queries like junction tables)\n      for (const query of alterTableQueries) {\n        this.logger.debug(`Executing alter table query: ${query}`);\n        await this.prismaService.txClient().$executeRawUnsafe(query);\n      }\n\n      if (unique) {\n        if (!checkFieldUniqueValidationEnabled(type, isLookup)) {\n          throw new CustomHttpException(\n            `Field ${name}[${fieldId}] does not support field value unique validation`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.uniqueUnsupportedType',\n                context: { name, fieldId },\n              },\n            }\n          );\n        }\n\n        const fieldValidationQuery = this.knex.schema\n          .alterTable(dbTableName, (table) => {\n            table.unique([dbFieldName], {\n              indexName: this.getFieldUniqueKeyName(dbTableName, dbFieldName, fieldId),\n            });\n          })\n          .toQuery();\n        await this.prismaService.txClient().$executeRawUnsafe(fieldValidationQuery);\n      }\n\n      if (notNull) {\n        throw new CustomHttpException(\n          `Field ${name}[${fieldId}] does not support not null validation when creating a new field`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.notNullValidationWhenCreateField',\n              context: { name, fieldId },\n            },\n          }\n        );\n      }\n    }\n  }\n\n  async alterTableDeleteField(\n    dbTableName: string,\n    fieldInstances: IFieldInstance[],\n    operationType: DropColumnOperationType = DropColumnOperationType.DELETE_FIELD\n  ) {\n    // Get table ID from dbTableName\n    const tableId = await this.linkFieldQueryService.getTableIdFromDbTableName(dbTableName);\n    if (!tableId) {\n      throw new Error(`Table not found for dbTableName: ${dbTableName}`);\n    }\n\n    // Build table name map for all related tables\n    const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(\n      tableId,\n      fieldInstances\n    );\n\n    for (const fieldInstance of fieldInstances) {\n      // Only pass link context for link fields\n      const linkContext =\n        fieldInstance.type === FieldType.Link && !fieldInstance.isLookup\n          ? { tableId, tableNameMap }\n          : undefined;\n\n      const alterTableSql = this.dbProvider.dropColumn(\n        dbTableName,\n        fieldInstance,\n        linkContext,\n        operationType\n      );\n\n      for (const alterTableQuery of alterTableSql) {\n        await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);\n      }\n    }\n  }\n\n  private async alterTableModifyFieldName(fieldId: string, newDbFieldName: string) {\n    const { dbFieldName, table } = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: fieldId, deletedTime: null },\n      select: {\n        dbFieldName: true,\n        type: true,\n        isLookup: true,\n        table: { select: { id: true, dbTableName: true } },\n      },\n    });\n\n    const existingField = await this.prismaService.txClient().field.findFirst({\n      where: { tableId: table.id, dbFieldName: newDbFieldName, deletedTime: null },\n      select: { id: true },\n    });\n\n    if (existingField) {\n      throw new CustomHttpException(\n        `Db Field name ${newDbFieldName} already exists in this table`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.dbFieldNameAlreadyExists',\n            context: { dbFieldName: newDbFieldName },\n          },\n        }\n      );\n    }\n\n    // Physically rename the underlying column for all field types, including non-lookup Link fields.\n    // Link fields in Teable maintain a persisted display column on the host table; skipping\n    // the physical rename causes mismatches during computed updates (e.g., UPDATE ... FROM ...).\n    const columnInfoQuery = this.dbProvider.columnInfo(table.dbTableName);\n    const columns = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);\n    const columnNames = new Set(columns.map((column) => column.name));\n\n    if (columnNames.has(newDbFieldName)) {\n      // Column already renamed (e.g. modifyColumnSchema recreated it with the new name)\n      return;\n    }\n\n    if (!columnNames.has(dbFieldName)) {\n      // Nothing left to rename—likely dropped during type conversion before this step ran\n      this.logger.debug(\n        `Skip renaming column for field ${fieldId} (${table.dbTableName}): ` +\n          `missing source column ${dbFieldName}`\n      );\n      return;\n    }\n\n    const alterTableSql = this.dbProvider.renameColumn(\n      table.dbTableName,\n      dbFieldName,\n      newDbFieldName\n    );\n\n    for (const alterTableQuery of alterTableSql) {\n      await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);\n    }\n  }\n\n  private async alterTableModifyFieldType(\n    fieldId: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance\n  ) {\n    const {\n      dbFieldName,\n      name: fieldName,\n      table,\n      tableId,\n    } = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: { id: fieldId, deletedTime: null },\n      select: {\n        dbFieldName: true,\n        name: true,\n        tableId: true,\n        table: { select: { dbTableName: true, name: true } },\n      },\n    });\n\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n    tableDomain.updateField(fieldId, newField);\n\n    const dbTableName = table.dbTableName;\n\n    // Build table name map for link field operations\n    const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(tableId, [\n      oldField,\n      newField,\n    ]);\n\n    // TODO: move to field visitor\n    let resetFieldQuery: string | undefined = '';\n    function shouldUpdateRecords(field: IFieldInstance) {\n      return !field.isComputed && field.type !== FieldType.Link;\n    }\n    if (shouldUpdateRecords(oldField) && shouldUpdateRecords(newField)) {\n      resetFieldQuery = this.knex(dbTableName)\n        .update({ [dbFieldName]: null })\n        .toQuery();\n    }\n\n    // Check if we need link context\n    const needsLinkContext =\n      (oldField.type === FieldType.Link && !oldField.isLookup) ||\n      (newField.type === FieldType.Link && !newField.isLookup);\n\n    const linkContext = needsLinkContext ? { tableId, tableNameMap } : undefined;\n\n    // Use the new modifyColumnSchema method with visitor pattern\n    const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n      dbTableName,\n      oldField,\n      newField,\n      tableDomain,\n      linkContext\n    );\n\n    await handleDBValidationErrors({\n      fn: async () => {\n        if (resetFieldQuery) {\n          await this.prismaService.txClient().$executeRawUnsafe(resetFieldQuery);\n        }\n\n        for (const alterTableQuery of modifyColumnSql) {\n          await this.prismaService.txClient().$executeRawUnsafe(alterTableQuery);\n        }\n      },\n      handleUniqueError: () => {\n        throw new CustomHttpException(\n          `Field ${fieldId} unique validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueDuplicate',\n              context: { tableName: table.name, fieldName },\n            },\n          }\n        );\n      },\n      handleNotNullError: () => {\n        throw new CustomHttpException(\n          `Field ${fieldId} not null validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueNotNull',\n              context: { tableName: table.name, fieldName },\n            },\n          }\n        );\n      },\n    });\n  }\n\n  async findUniqueIndexesForField(dbTableName: string, dbFieldName: string) {\n    const indexesQuery = this.dbProvider.getTableIndexes(dbTableName);\n    const indexes = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ name: string; columns: string; isUnique: boolean }[]>(indexesQuery);\n\n    return indexes\n      .filter((index) => {\n        const { columns, isUnique } = index;\n        const columnsArray = JSON.parse(columns) as string[];\n        return isUnique && columnsArray.includes(dbFieldName);\n      })\n      .map((index) => index.name);\n  }\n\n  private async alterTableModifyFieldValidation(\n    fieldId: string,\n    key: 'unique' | 'notNull',\n    newValue?: boolean\n  ) {\n    const { name, dbFieldName, table, type, isLookup } = await this.prismaService\n      .txClient()\n      .field.findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n        select: {\n          name: true,\n          dbFieldName: true,\n          type: true,\n          isLookup: true,\n          table: { select: { dbTableName: true, name: true } },\n        },\n      });\n\n    if (!checkFieldValidationEnabled(type as FieldType, isLookup)) {\n      throw new CustomHttpException(\n        `Field ${name}[${fieldId}] field validation error`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.fieldValidationError',\n            context: { name, fieldId },\n          },\n        }\n      );\n    }\n\n    const dbTableName = table.dbTableName;\n    const matchedIndexes = await this.findUniqueIndexesForField(dbTableName, dbFieldName);\n\n    const fieldValidationSqls = this.knex.schema\n      .alterTable(dbTableName, (table) => {\n        if (key === 'unique') {\n          newValue\n            ? table.unique([dbFieldName], {\n                indexName: this.getFieldUniqueKeyName(dbTableName, dbFieldName, fieldId),\n              })\n            : matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName));\n        }\n\n        if (key === 'notNull') {\n          newValue ? table.dropNullable(dbFieldName) : table.setNullable(dbFieldName);\n        }\n      })\n      .toSQL();\n\n    const executeSqls = fieldValidationSqls\n      .filter((s) => !s.sql.startsWith('PRAGMA'))\n      .map(({ sql }) => sql);\n\n    await handleDBValidationErrors({\n      fn: () => {\n        return Promise.all(\n          executeSqls.map((sql) => this.prismaService.txClient().$executeRawUnsafe(sql))\n        );\n      },\n      handleUniqueError: () => {\n        throw new CustomHttpException(\n          `Field ${fieldId} unique validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueDuplicate',\n              context: { tableName: table.name, fieldName: name },\n            },\n          }\n        );\n      },\n      handleNotNullError: () => {\n        throw new CustomHttpException(\n          `Field ${fieldId} not null validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueNotNull',\n              context: { tableName: table.name, fieldName: name },\n            },\n          }\n        );\n      },\n    });\n  }\n\n  async getField(tableId: string, fieldId: string): Promise<IFieldVo> {\n    const field = await this.prismaService.txClient().field.findFirst({\n      where: { id: fieldId, tableId, deletedTime: null },\n    });\n    if (!field) {\n      throw new CustomHttpException(\n        `Field ${fieldId} not found in table ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.notFoundInTable',\n            context: { tableId, fieldId },\n          },\n        }\n      );\n    }\n    const fieldVo = rawField2FieldObj(field);\n    // Filter out meta field to prevent it from being sent to frontend\n    return omit(fieldVo, ['meta']) as IFieldVo;\n  }\n\n  async getFieldsByQuery(tableId: string, query?: IGetFieldsQuery): Promise<IFieldVo[]> {\n    const fieldsPlain = await this.prismaService.txClient().field.findMany({\n      where: { tableId, deletedTime: null },\n      orderBy: [\n        {\n          isPrimary: {\n            sort: 'asc',\n            nulls: 'last',\n          },\n        },\n        {\n          order: 'asc',\n        },\n        {\n          createdTime: 'asc',\n        },\n      ],\n    });\n\n    let result = fieldsPlain.map(rawField2FieldObj);\n\n    // filter by projection\n    if (query?.projection) {\n      const fieldIds = query.projection;\n      const fieldMap = keyBy(result, 'id');\n      return fieldIds.map((fieldId) => fieldMap[fieldId]).filter(Boolean);\n    }\n\n    /**\n     * filter by query\n     * filterHidden depends on viewId so only judge viewId\n     */\n    if (query?.viewId) {\n      const { viewId } = query;\n      const curView = await this.prismaService.txClient().view.findFirst({\n        where: { id: viewId, deletedTime: null },\n        select: { id: true, type: true, options: true, columnMeta: true },\n      });\n      if (!curView) {\n        throw new CustomHttpException(`View ${viewId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        });\n      }\n      const view = {\n        id: viewId,\n        type: curView.type as ViewType,\n        options: curView.options ? JSON.parse(curView.options) : curView.options,\n        columnMeta: curView?.columnMeta ? JSON.parse(curView?.columnMeta) : curView?.columnMeta,\n      };\n      if (query?.filterHidden) {\n        result = result.filter((field) => isNotHiddenField(field.id, view));\n      }\n      return sortBy(result, (field) => {\n        return view?.columnMeta?.[field?.id]?.order;\n      });\n    }\n\n    // Filter out meta field to prevent it from being sent to frontend\n    return result.map((field) => omit(field, ['meta']) as IFieldVo);\n  }\n\n  async getFieldInstances(tableId: string, query: IGetFieldsQuery): Promise<IFieldInstance[]> {\n    const fields = await this.getFieldsByQuery(tableId, query);\n    return fields.map((field) => createFieldInstanceByVo(field));\n  }\n\n  async getDbTableName(tableId: string) {\n    const [tableMeta] = await this.dataLoaderService.table.loadByIds([tableId]);\n    if (!tableMeta) {\n      throw new NotFoundException(`Table not found: ${tableId}`);\n    }\n    return tableMeta.dbTableName;\n  }\n\n  async resolvePending(tableId: string, fieldIds: string[]) {\n    await this.batchUpdateFields(\n      tableId,\n      fieldIds.map((fieldId) => ({\n        fieldId,\n        ops: [\n          FieldOpBuilder.editor.setFieldProperty.build({\n            key: 'isPending',\n            newValue: null,\n            oldValue: true,\n          }),\n        ],\n      }))\n    );\n  }\n\n  async markError(tableId: string, fieldIds: string[], hasError: boolean) {\n    await this.batchUpdateFields(\n      tableId,\n      fieldIds.map((fieldId) => ({\n        fieldId,\n        ops: [\n          FieldOpBuilder.editor.setFieldProperty.build({\n            key: 'hasError',\n            newValue: hasError ? true : null,\n            oldValue: hasError ? null : true,\n          }),\n        ],\n      }))\n    );\n  }\n\n  /**\n   * After restoring base fields (e.g., via undo), repair dependent formula fields:\n   * - If dependencies are incomplete, keep hasError=true and skip DB column creation\n   * - If dependencies are complete and formula is persisted as a generated column,\n   *   recreate the underlying generated column via modifyColumnSchema\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async recreateDependentFormulaColumns(tableId: string, fieldIds: string[]) {\n    const uniqueSourceIds = Array.from(new Set((fieldIds ?? []).filter(Boolean)));\n    if (!uniqueSourceIds.length) return;\n\n    const prisma = this.prismaService.txClient();\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n\n    let deps: { id: string; tableId: string; level: number }[] = [];\n    try {\n      deps = await this.formulaFieldService.getDependentFormulaFieldsInOrderMulti(uniqueSourceIds);\n    } catch (e) {\n      this.logger.warn(\n        `recreateDependentFormulaColumns: failed to resolve dependents for ${tableId}: ${String(e)}`\n      );\n\n      // Fallback: preserve existing behavior (per-source query) if multi-root CTE fails\n      const results = await Promise.all(\n        uniqueSourceIds.map((id) =>\n          this.formulaFieldService\n            .getDependentFormulaFieldsInOrder(id)\n            .catch(() => [] as { id: string; tableId: string; level: number }[])\n        )\n      );\n      const merged = new Map<string, { id: string; tableId: string; level: number }>();\n      for (const list of results) {\n        for (const item of list) {\n          const current = merged.get(item.id);\n          if (!current || item.level > current.level) {\n            merged.set(item.id, item);\n          }\n        }\n      }\n      deps = Array.from(merged.values()).sort(\n        (a, b) => b.level - a.level || a.id.localeCompare(b.id)\n      );\n    }\n\n    const formulaIdsInOrder = deps.filter((d) => d.tableId === tableId).map((d) => d.id);\n    if (!formulaIdsInOrder.length) return;\n\n    const formulaRaws = await prisma.field.findMany({\n      where: { id: { in: formulaIdsInOrder }, tableId, deletedTime: null },\n    });\n    if (!formulaRaws.length) return;\n\n    const rawById = new Map(formulaRaws.map((r) => [r.id, r] as const));\n    const referencedIdSet = new Set<string>();\n    const formulas = formulaIdsInOrder\n      .map((id) => {\n        const raw = rawById.get(id);\n        if (!raw) return null;\n        const instance = createFieldInstanceByRaw(raw);\n        if (instance.type !== FieldType.Formula) return null;\n        const core = instance as FormulaFieldDto;\n        const referencedIds = (core.getReferenceFieldIds() || []).filter(Boolean);\n        referencedIds.forEach((fid) => referencedIdSet.add(fid));\n        return { id, rawHasError: raw.hasError === true, core, referencedIds };\n      })\n      .filter(Boolean) as Array<{\n      id: string;\n      rawHasError: boolean;\n      core: FormulaFieldDto;\n      referencedIds: string[];\n    }>;\n\n    if (!formulas.length) return;\n\n    const existingRefSet = new Set<string>();\n    if (referencedIdSet.size) {\n      const existing = await prisma.field.findMany({\n        where: { id: { in: Array.from(referencedIdSet) }, deletedTime: null },\n        select: { id: true },\n      });\n      existing.forEach((row) => existingRefSet.add(row.id));\n    }\n\n    const toMarkErrorTrue: string[] = [];\n    const toMarkErrorFalse: string[] = [];\n    const toRecreate: Array<{ id: string; core: FormulaFieldDto }> = [];\n\n    for (const f of formulas) {\n      const allPresent = f.referencedIds.every((id) => existingRefSet.has(id));\n      if (!allPresent) {\n        if (!f.rawHasError) {\n          toMarkErrorTrue.push(f.id);\n        }\n        continue;\n      }\n\n      if (f.rawHasError) {\n        toMarkErrorFalse.push(f.id);\n      }\n\n      if (f.core.getIsPersistedAsGeneratedColumn()) {\n        toRecreate.push({ id: f.id, core: f.core });\n      }\n    }\n\n    if (toMarkErrorTrue.length) {\n      await this.markError(tableId, toMarkErrorTrue, true);\n    }\n    if (toMarkErrorFalse.length) {\n      await this.markError(tableId, toMarkErrorFalse, false);\n    }\n\n    if (!toRecreate.length) return;\n\n    const tableMeta = await prisma.tableMeta.findUnique({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    if (!tableMeta) return;\n\n    const fieldMap = tableDomain.fields.toFieldMap();\n    const fieldMapObj = Object.fromEntries(fieldMap);\n\n    for (const { id: formulaFieldId, core } of toRecreate) {\n      try {\n        core.recalculateFieldTypes(fieldMapObj);\n        const sqls = this.dbProvider.modifyColumnSchema(\n          tableMeta.dbTableName,\n          core,\n          core,\n          tableDomain\n        );\n        for (const sql of sqls) {\n          await prisma.$executeRawUnsafe(sql);\n        }\n      } catch (e) {\n        this.logger.warn(\n          `recreateDependentFormulaColumns: failed to recreate generated column for ${formulaFieldId} in ${tableId}: ${String(\n            e\n          )}`\n        );\n      }\n    }\n  }\n\n  private async checkFieldName(tableId: string, fieldId: string, name: string) {\n    const fieldRaw = await this.prismaService.txClient().field.findFirst({\n      where: { tableId, id: { not: fieldId }, name, deletedTime: null },\n      select: { id: true },\n    });\n\n    if (fieldRaw) {\n      throw new CustomHttpException(\n        `Field name ${name} already exists in this table`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.fieldNameAlreadyExists',\n            context: { name },\n          },\n        }\n      );\n    }\n  }\n\n  async batchUpdateFields(tableId: string, opData: { fieldId: string; ops: IOtOperation[] }[]) {\n    if (!opData.length) return;\n\n    const fieldRaw = await this.prismaService.txClient().field.findMany({\n      where: { tableId, id: { in: opData.map((data) => data.fieldId) }, deletedTime: null },\n    });\n    const dbTableName = await this.getDbTableName(tableId);\n\n    const fields = fieldRaw.map(createFieldInstanceByRaw);\n    const fieldsRawMap = keyBy(fieldRaw, 'id');\n    const fieldMap = new Map(fields.map((field) => [field.id, field]));\n\n    for (const { fieldId, ops } of opData) {\n      const field = fieldMap.get(fieldId);\n      if (!field) {\n        continue;\n      }\n      const opContext = ops.map((op) => {\n        const ctx = FieldOpBuilder.detect(op);\n        if (!ctx) {\n          throw new CustomHttpException('unknown field editing op', HttpErrorCode.VALIDATION_ERROR);\n        }\n        return ctx as IOpContext;\n      });\n\n      const nameCtx = opContext.find((ctx) => ctx.key === 'name');\n      if (nameCtx) {\n        await this.checkFieldName(tableId, fieldId, nameCtx.newValue as string);\n      }\n\n      await this.update(fieldsRawMap[fieldId].version + 1, tableId, dbTableName, field, opContext);\n    }\n\n    const dataList = opData.map((data) => ({\n      docId: data.fieldId,\n      version: fieldsRawMap[data.fieldId].version,\n      data: data.ops,\n    }));\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Edit, IdPrefix.Field, dataList);\n  }\n\n  async batchDeleteFields(\n    tableId: string,\n    fieldIds: string[],\n    operationType: DropColumnOperationType = DropColumnOperationType.DELETE_FIELD\n  ) {\n    if (!fieldIds.length) return;\n\n    const fieldRaw = await this.prismaService.txClient().field.findMany({\n      where: { tableId, id: { in: fieldIds }, deletedTime: null },\n      select: { id: true, version: true },\n    });\n\n    if (fieldRaw.length !== fieldIds.length) {\n      throw new CustomHttpException(\n        `delete fields ${fieldIds.join(',')} not found in table ${tableId}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.deleteFieldsNotFound',\n            context: { tableId, fieldIds },\n          },\n        }\n      );\n    }\n\n    const fieldRawMap = keyBy(fieldRaw, 'id');\n\n    const dataList = fieldIds.map((fieldId) => ({\n      docId: fieldId,\n      version: fieldRawMap[fieldId].version,\n    }));\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Field, dataList);\n\n    await this.deleteMany(\n      tableId,\n      dataList.map((d) => ({ ...d, version: d.version + 1 })),\n      operationType\n    );\n  }\n\n  async batchCreateFields(\n    tableId: string,\n    dbTableName: string,\n    fields: IFieldInstance[],\n    isSymmetricField?: boolean\n  ) {\n    if (!fields.length) return;\n\n    const dataList = fields.map((field) => {\n      const snapshot = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo;\n      return {\n        docId: field.id,\n        version: 0,\n        data: snapshot,\n      };\n    });\n\n    // 1. alter table with real field in visual table\n    await this.alterTableAddField(tableId, dbTableName, fields, false, isSymmetricField);\n\n    // 2. save field meta in db\n    await this.dbCreateMultipleField(tableId, fields);\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Field, dataList);\n  }\n\n  // write field at once database operation\n  async batchCreateFieldsAtOnce(tableId: string, dbTableName: string, fields: IFieldInstance[]) {\n    if (!fields.length) return;\n\n    const dataList = fields.map((field) => {\n      const snapshot = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo;\n      return {\n        docId: field.id,\n        version: 0,\n        data: snapshot,\n      };\n    });\n\n    // 1. alter table with real field in visual table\n    await this.alterTableAddField(tableId, dbTableName, fields, true); // This is new table creation\n\n    // 2. save field meta in db\n    await this.dbCreateMultipleFields(tableId, fields);\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.Field, dataList);\n  }\n\n  async create(tableId: string, snapshot: IFieldVo) {\n    const fieldInstance = createFieldInstanceByVo(snapshot);\n    const dbTableName = await this.getDbTableName(tableId);\n\n    // 1. alter table with real field in visual table\n    await this.alterTableAddField(tableId, dbTableName, [fieldInstance]);\n\n    // 2. save field meta in db\n    await this.dbCreateMultipleField(tableId, [fieldInstance]);\n  }\n\n  private async deleteMany(\n    tableId: string,\n    fieldData: { docId: string; version: number }[],\n    operationType: DropColumnOperationType = DropColumnOperationType.DELETE_FIELD\n  ) {\n    const userId = this.cls.get('user.id');\n\n    for (const data of fieldData) {\n      const { docId: id, version } = data;\n      await this.prismaService.txClient().field.update({\n        where: { id: id },\n        data: { deletedTime: new Date(), lastModifiedBy: userId, version },\n      });\n    }\n    const dbTableName = await this.getDbTableName(tableId);\n    const fieldIds = fieldData.map((data) => data.docId);\n    const fieldsRaw = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fieldIds } },\n    });\n    const fieldInstances = fieldsRaw.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw));\n    await this.alterTableDeleteField(dbTableName, fieldInstances, operationType);\n    this.invalidateFieldLoader(tableId);\n  }\n\n  async del(version: number, tableId: string, fieldId: string) {\n    await this.deleteMany(tableId, [{ docId: fieldId, version }]);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async handleFieldProperty(\n    tableId: string,\n    dbTableName: string,\n    fieldId: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance,\n    opContext: IOpContext\n  ) {\n    const { key, newValue } = opContext as ISetFieldPropertyOpContext;\n\n    if (key === 'type') {\n      await this.handleFieldTypeChange(tableId, dbTableName, oldField, newField);\n    }\n\n    if (key === 'options') {\n      if (!newValue) {\n        throw new CustomHttpException('field options is required', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'editor.error.optionsRequired',\n          },\n        });\n      }\n\n      // Only handle formula update here for options-only changes.\n      // When converting type (e.g., Text -> Formula), handleFieldTypeChange above\n      // already reconciles the physical schema. Running it again here would\n      // attempt to drop the old column twice and cause: no such column: `...`.\n      if (oldField.type === FieldType.Formula && newField.type === FieldType.Formula) {\n        // Check if this is a formula field options update that affects generated columns\n        await this.handleFormulaUpdate(tableId, dbTableName, oldField, newField);\n      }\n\n      return { options: JSON.stringify(newValue) };\n    }\n\n    if (key === 'aiConfig') {\n      return {\n        aiConfig: newValue ? JSON.stringify(newValue) : null,\n      };\n    }\n\n    if (key === 'meta') {\n      return {\n        meta: newValue ? JSON.stringify(newValue) : null,\n      } as Prisma.FieldUpdateInput;\n    }\n\n    if (key === 'lookupOptions') {\n      return {\n        lookupOptions: newValue ? JSON.stringify(newValue) : null,\n        // update lookupLinkedFieldId for indexing\n        lookupLinkedFieldId: (() => {\n          const nextOptions = newValue as ILookupOptionsVo | null;\n          return nextOptions && isLinkLookupOptions(nextOptions) ? nextOptions.linkFieldId : null;\n        })(),\n      };\n    }\n\n    if (key === 'dbFieldType') {\n      await this.alterTableModifyFieldType(fieldId, oldField, newField);\n    }\n\n    if (key === 'dbFieldName') {\n      await this.alterTableModifyFieldName(fieldId, newValue as string);\n    }\n\n    if (key === 'unique' || key === 'notNull') {\n      await this.alterTableModifyFieldValidation(fieldId, key, newValue as boolean | undefined);\n    }\n\n    return { [key]: newValue ?? null };\n  }\n\n  private async updateStrategies(\n    fieldId: string,\n    tableId: string,\n    dbTableName: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance,\n    opContext: IOpContext\n  ) {\n    const opHandlers = {\n      [OpName.SetFieldProperty]: this.handleFieldProperty.bind(this),\n    };\n\n    const handler = opHandlers[opContext.name];\n\n    if (!handler) {\n      throw new CustomHttpException(\n        `Unknown context ${opContext.name} for field update`,\n        HttpErrorCode.VALIDATION_ERROR\n      );\n    }\n\n    return handler.constructor.name === 'AsyncFunction'\n      ? await handler(tableId, dbTableName, fieldId, oldField, newField, opContext)\n      : handler(tableId, dbTableName, fieldId, oldField, newField, opContext);\n  }\n\n  async update(\n    version: number,\n    tableId: string,\n    dbTableName: string,\n    oldField: IFieldInstance,\n    opContexts: IOpContext[]\n  ) {\n    const fieldId = oldField.id;\n    const newField = applyFieldPropertyOpsAndCreateInstance(oldField, opContexts);\n    const userId = this.cls.get('user.id');\n    // Build result incrementally; set meta after applying update strategies\n    const result: Prisma.FieldUpdateInput = {\n      version,\n      lastModifiedBy: userId,\n    };\n    for (const opContext of opContexts) {\n      const updatedResult = await this.updateStrategies(\n        fieldId,\n        tableId,\n        dbTableName,\n        oldField,\n        newField,\n        opContext\n      );\n      Object.assign(result, updatedResult);\n    }\n\n    // Persist meta after potential schema modifications that may set it (e.g., formula generated columns)\n    if (newField.meta !== undefined) {\n      result.meta = JSON.stringify(newField.meta);\n    } else if (oldField.meta !== undefined) {\n      // Explicitly clear meta when schema updates drop generated columns\n      result.meta = null;\n    }\n\n    await this.prismaService.txClient().field.update({\n      where: { id: fieldId, tableId },\n      data: result,\n    });\n\n    // Handle dependent formula fields after field update\n    await this.handleDependentFormulaFields(tableId, newField, opContexts);\n    this.invalidateFieldLoader(tableId);\n  }\n\n  async getSnapshotBulk(tableId: string, ids: string[]): Promise<ISnapshotBase<IFieldVo>[]> {\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { tableId, id: { in: ids } },\n    });\n    const fields = fieldRaws.map((field) => rawField2FieldObj(field));\n\n    return fieldRaws\n      .map((fieldRaw, i) => {\n        return {\n          id: fieldRaw.id,\n          v: fieldRaw.version,\n          type: 'json0',\n          // Filter out meta field to prevent it from being sent to frontend\n          data: omit(fields[i], ['meta']) as IFieldVo,\n        };\n      })\n      .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));\n  }\n\n  async getDocIdsByQuery(tableId: string, query: IGetFieldsQuery) {\n    const result = await this.getFieldsByQuery(tableId, query);\n    return {\n      ids: result.map((field) => field.id),\n    };\n  }\n\n  getFieldUniqueKeyName(dbTableName: string, dbFieldName: string, fieldId: string) {\n    const [schema, tableName] = this.dbProvider.splitTableName(dbTableName);\n    // unique key suffix\n    const uniqueKeySuffix = `___${fieldId}_unique`;\n    const uniqueKeyPrefix = `${schema}_${tableName}`.slice(0, 63 - uniqueKeySuffix.length);\n    return `${uniqueKeyPrefix.toLowerCase()}${uniqueKeySuffix.toLowerCase()}`;\n  }\n\n  private async handleFieldTypeChange(\n    tableId: string,\n    dbTableName: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance\n  ) {\n    if (oldField.type === newField.type) {\n      return;\n    }\n\n    const usesPersistedGeneratedColumn = (field: IFieldInstance) => {\n      if (field.isLookup) {\n        return false;\n      }\n\n      const persistedAsGeneratedColumn = (\n        field.meta as { persistedAsGeneratedColumn?: boolean } | undefined\n      )?.persistedAsGeneratedColumn;\n\n      if (persistedAsGeneratedColumn !== undefined) {\n        return persistedAsGeneratedColumn === true;\n      }\n\n      if (field.type === FieldType.CreatedTime) {\n        return true;\n      }\n\n      if (field.type === FieldType.LastModifiedTime) {\n        const maybeLastModified = field as unknown as { isTrackAll?: () => boolean };\n        if (typeof maybeLastModified.isTrackAll === 'function') {\n          return maybeLastModified.isTrackAll();\n        }\n      }\n\n      return false;\n    };\n    // If either side is Formula, we must reconcile the physical schema using modifyColumnSchema.\n    // This ensures that converting to Formula creates generated columns (or proper projection),\n    // and converting back from Formula recreates the original physical column.\n    if (oldField.type === FieldType.Formula || newField.type === FieldType.Formula) {\n      const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n      const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n        dbTableName,\n        oldField,\n        newField,\n        tableDomain\n      );\n      for (const sql of modifyColumnSql) {\n        await this.prismaService.txClient().$executeRawUnsafe(sql);\n      }\n      return;\n    }\n\n    // Some field types (e.g., CreatedTime / LastModifiedTime(track all)) are persisted as generated columns\n    // without a dbFieldType change. Converting them to a regular field type (e.g., Date) must recreate the\n    // physical column, otherwise UPDATEs will hit \"cannot update a generated column\".\n    if (oldField.dbFieldType === newField.dbFieldType) {\n      const oldGenerated = usesPersistedGeneratedColumn(oldField);\n      const newGenerated = usesPersistedGeneratedColumn(newField);\n\n      if (oldGenerated || newGenerated) {\n        const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n        const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n          dbTableName,\n          oldField,\n          newField,\n          tableDomain\n        );\n        for (const sql of modifyColumnSql) {\n          await this.prismaService.txClient().$executeRawUnsafe(sql);\n        }\n        return;\n      }\n    }\n\n    await this.handleFormulaUpdate(tableId, dbTableName, oldField, newField);\n  }\n\n  /**\n   * Handle formula field options update that may affect generated columns\n   */\n  private async handleFormulaUpdate(\n    tableId: string,\n    dbTableName: string,\n    oldField: IFieldInstance,\n    newField: IFieldInstance\n  ): Promise<void> {\n    if (newField.type !== FieldType.Formula) {\n      return;\n    }\n\n    // Build field map for formula conversion context\n    // Note: We need to rebuild the field map after the current field update\n    // to ensure dependent formula fields use the latest field information\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n\n    // Use modifyColumnSchema to recreate the field with updated options\n    const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n      dbTableName,\n      oldField,\n      newField,\n      tableDomain\n    );\n\n    // Execute the column modification\n    for (const sql of modifyColumnSql) {\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n  }\n\n  /**\n   * Handle dependent formula fields when updating a regular field\n   * This ensures that formula fields referencing the updated field are properly updated\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async handleDependentFormulaFields(\n    tableId: string,\n    field: IFieldInstance,\n    opContexts: IOpContext[]\n  ): Promise<void> {\n    // Check if any of the operations affect dependent formula fields\n    const affectsDependentFields = opContexts.some((ctx) => {\n      const { key } = ctx as ISetFieldPropertyOpContext;\n      // These property changes can affect dependent formula fields\n      return ['dbFieldType', 'dbFieldName', 'options'].includes(key);\n    });\n\n    if (!affectsDependentFields) {\n      return;\n    }\n\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n\n    try {\n      // Get all formula fields that depend on this field\n      const dependentFields = await this.formulaFieldService.getDependentFormulaFieldsInOrder(\n        field.id\n      );\n\n      if (dependentFields.length === 0) {\n        return;\n      }\n\n      tableDomain.updateField(field.id, field);\n\n      // Process dependent fields in dependency order (deepest first for deletion, then reverse for creation)\n      const fieldsToProcess = [...dependentFields].reverse(); // Reverse to get shallowest first\n\n      // Process each dependent formula field\n      for (const { id: dependentFieldId, tableId: dependentTableId } of fieldsToProcess) {\n        // Get complete field information\n        const dependentFieldRaw = await this.prismaService.txClient().field.findUnique({\n          where: { id: dependentFieldId, tableId: dependentTableId, deletedTime: null },\n        });\n\n        if (!dependentFieldRaw) {\n          continue;\n        }\n\n        const dependentFieldInstance = createFieldInstanceByRaw(dependentFieldRaw);\n        if (dependentFieldInstance.type !== FieldType.Formula) {\n          continue;\n        }\n\n        if (!dependentFieldInstance.getIsPersistedAsGeneratedColumn()) {\n          continue;\n        }\n\n        // Create field instance\n        const fieldInstance = createFieldInstanceByRaw(dependentFieldRaw);\n\n        // Recalculate the field's cellValueType and dbFieldType based on current dependencies\n        if (fieldInstance.type === FieldType.Formula) {\n          // Use the instance method to recalculate field types (including dbFieldType)\n          const fieldMap = tableDomain.fields.toFieldMap();\n          (fieldInstance as FormulaFieldCore).recalculateFieldTypes(Object.fromEntries(fieldMap));\n        }\n\n        // Get table name for dependent field\n        const dependentTableMeta = await this.prismaService.txClient().tableMeta.findUnique({\n          where: { id: dependentTableId },\n          select: { dbTableName: true },\n        });\n\n        if (!dependentTableMeta) {\n          continue;\n        }\n\n        // Use modifyColumnSchema to recreate the dependent formula field\n        const modifyColumnSql = this.dbProvider.modifyColumnSchema(\n          dependentTableMeta.dbTableName,\n          fieldInstance,\n          fieldInstance,\n          tableDomain\n        );\n\n        // Execute the column modification\n        for (const sql of modifyColumnSql) {\n          await this.prismaService.txClient().$executeRawUnsafe(sql);\n        }\n      }\n    } catch (error) {\n      console.warn(`Failed to handle dependent formula fields for field %s:`, field.id, error);\n      // Don't throw error to avoid breaking the field update operation\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/fields-utils.ts",
    "content": "import { FieldKeyType, FieldType } from '@teable/core';\nimport type {\n  CreatedByFieldCore,\n  FieldCore,\n  LastModifiedByFieldCore,\n  IFieldVo,\n  IGetFieldsQuery,\n  IViewVo,\n} from '@teable/core';\nimport { sortBy } from 'lodash';\nimport { isNotHiddenField } from '../../utils/is-not-hidden-field';\n\nexport async function filterFieldsByQuery(\n  fields: IFieldVo[],\n  query?: IGetFieldsQuery & {\n    view?: Pick<IViewVo, 'type' | 'options' | 'columnMeta'>;\n  }\n): Promise<IFieldVo[]> {\n  // filter by projection\n  if (query?.projection) {\n    return filterFieldsByProjection(fields, query.projection);\n  }\n\n  /**\n   * filter by query\n   * filterHidden depends on viewId so only judge viewId\n   */\n  const { view, viewId, filterHidden } = query ?? {};\n\n  if (viewId && view) {\n    return filterFieldsByView(fields, view, { filterHidden, sortByOrder: true });\n  }\n\n  return fields;\n}\n\nexport const filterFieldsByProjection = (\n  fields: IFieldVo[],\n  projection?: string[],\n  fieldKeyType: FieldKeyType = FieldKeyType.Id\n) => {\n  if (!projection) {\n    return fields;\n  }\n  return fields.filter((field) => projection.includes(field[fieldKeyType]));\n};\n\nexport const filterFieldsByView = (\n  fields: IFieldVo[],\n  view?: Pick<IViewVo, 'type' | 'options' | 'columnMeta'>,\n  opts?: {\n    filterHidden?: boolean;\n    sortByOrder?: boolean;\n  }\n) => {\n  if (!view) {\n    return fields;\n  }\n  const { filterHidden, sortByOrder } = opts ?? {};\n  let result = fields;\n  if (filterHidden) {\n    result = result.filter((field) => isNotHiddenField(field.id, view));\n  }\n  if (sortByOrder) {\n    result = sortBy(result, (field) => {\n      return view?.columnMeta[field.id].order;\n    });\n  }\n  return result;\n};\n\nexport function isSystemUserField(\n  field: FieldCore\n): field is CreatedByFieldCore | LastModifiedByFieldCore {\n  return field.type === FieldType.CreatedBy || field.type === FieldType.LastModifiedBy;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/factory.spec.ts",
    "content": "import type { IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\n\nimport { createFieldInstanceByVo } from './factory';\n\nconst baseField = {\n  id: 'fldFactorySpec00001',\n  name: 'Factory Field',\n  dbFieldName: 'factory_field',\n  unique: false,\n  options: {},\n} as const;\n\ndescribe('createFieldInstanceByVo', () => {\n  it('normalizes v2 conditionalLookup using innerType and innerOptions', () => {\n    const field = {\n      ...baseField,\n      type: 'conditionalLookup',\n      isLookup: true,\n      isConditionalLookup: true,\n      options: {\n        innerType: FieldType.Number,\n        innerOptions: {\n          formatting: { type: 'decimal', precision: 1 },\n        },\n      },\n    } as unknown as IFieldVo;\n\n    const instance = createFieldInstanceByVo(field);\n\n    expect(instance.type).toBe(FieldType.Number);\n    expect(instance.isLookup).toBe(true);\n    expect(instance.isConditionalLookup).toBe(true);\n    expect(instance.options).toEqual({\n      formatting: { type: 'decimal', precision: 1 },\n    });\n  });\n\n  it('falls back to singleLineText when conditionalLookup innerType is missing', () => {\n    const field = {\n      ...baseField,\n      type: 'conditionalLookup',\n      options: {},\n    } as unknown as IFieldVo;\n\n    const instance = createFieldInstanceByVo(field);\n\n    expect(instance.type).toBe(FieldType.SingleLineText);\n    expect(instance.isLookup).toBe(true);\n    expect(instance.isConditionalLookup).toBe(true);\n    expect(instance.options).toEqual({});\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/factory.ts",
    "content": "import type {\n  IFieldVo,\n  DbFieldType,\n  CellValueType,\n  ISetFieldPropertyOpContext,\n  FieldCore,\n} from '@teable/core';\nimport { assertNever, FieldType, applyFieldPropertyOps } from '@teable/core';\nimport type { Field } from '@teable/db-main-prisma';\nimport { instanceToPlain, plainToInstance } from 'class-transformer';\nimport { AttachmentFieldDto } from './field-dto/attachment-field.dto';\nimport { AutoNumberFieldDto } from './field-dto/auto-number-field.dto';\nimport { ButtonFieldDto } from './field-dto/button-field.dto';\nimport { CheckboxFieldDto } from './field-dto/checkbox-field.dto';\nimport { ConditionalRollupFieldDto } from './field-dto/conditional-rollup-field.dto';\nimport { CreatedByFieldDto } from './field-dto/created-by-field.dto';\nimport { CreatedTimeFieldDto } from './field-dto/created-time-field.dto';\nimport { DateFieldDto } from './field-dto/date-field.dto';\nimport { FormulaFieldDto } from './field-dto/formula-field.dto';\nimport { LastModifiedByFieldDto } from './field-dto/last-modified-by-field.dto';\nimport { LastModifiedTimeFieldDto } from './field-dto/last-modified-time-field.dto';\nimport { LinkFieldDto } from './field-dto/link-field.dto';\nimport { LongTextFieldDto } from './field-dto/long-text-field.dto';\nimport { MultipleSelectFieldDto } from './field-dto/multiple-select-field.dto';\nimport { NumberFieldDto } from './field-dto/number-field.dto';\nimport { RatingFieldDto } from './field-dto/rating-field.dto';\nimport { RollupFieldDto } from './field-dto/rollup-field.dto';\nimport { SingleLineTextFieldDto } from './field-dto/single-line-text-field.dto';\nimport { SingleSelectFieldDto } from './field-dto/single-select-field.dto';\nimport { UserFieldDto } from './field-dto/user-field.dto';\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nexport function rawField2FieldObj(fieldRaw: Field): IFieldVo {\n  let options = fieldRaw.options && JSON.parse(fieldRaw.options as string);\n  if (\n    fieldRaw.type === FieldType.Link &&\n    options &&\n    typeof options === 'object' &&\n    (options as { isOneWay?: boolean }).isOneWay === true\n  ) {\n    delete (options as { symmetricFieldId?: string }).symmetricFieldId;\n  }\n\n  if (fieldRaw.isLookup && options == null) {\n    options = {};\n  }\n\n  return {\n    id: fieldRaw.id,\n    dbFieldName: fieldRaw.dbFieldName,\n    name: fieldRaw.name,\n    type: fieldRaw.type as FieldType,\n    description: fieldRaw.description || undefined,\n    options,\n    meta: (fieldRaw.meta && JSON.parse(fieldRaw.meta as string)) || undefined,\n    aiConfig: (fieldRaw.aiConfig && JSON.parse(fieldRaw.aiConfig as string)) || undefined,\n    notNull: fieldRaw.notNull || undefined,\n    unique: fieldRaw.unique ?? false,\n    isComputed: fieldRaw.isComputed || undefined,\n    isPrimary: fieldRaw.isPrimary || undefined,\n    isPending: fieldRaw.isPending || undefined,\n    isLookup: fieldRaw.isLookup || undefined,\n    isConditionalLookup: fieldRaw.isConditionalLookup || undefined,\n    hasError: fieldRaw.hasError || undefined,\n    lookupOptions:\n      (fieldRaw.lookupOptions && JSON.parse(fieldRaw.lookupOptions as string)) || undefined,\n    cellValueType: fieldRaw.cellValueType as CellValueType,\n    isMultipleCellValue: fieldRaw.isMultipleCellValue ?? undefined,\n    dbFieldType: fieldRaw.dbFieldType as DbFieldType,\n  };\n}\n\nexport function fieldCore2FieldInstance(field: FieldCore): IFieldInstance {\n  const plain: IFieldVo = {\n    id: field.id,\n    dbFieldName: field.dbFieldName,\n    name: field.name,\n    type: field.type,\n    description: field.description,\n    options: { ...(field.options as object) },\n    meta: field.meta ? { ...field.meta } : undefined,\n    aiConfig: field.aiConfig ? { ...field.aiConfig } : undefined,\n    notNull: field.notNull,\n    unique: field.unique,\n    isComputed: field.isComputed,\n    isPrimary: field.isPrimary,\n    isPending: field.isPending,\n    isLookup: field.isLookup,\n    isConditionalLookup: field.isConditionalLookup,\n    hasError: field.hasError,\n    lookupOptions: field.lookupOptions ? { ...field.lookupOptions } : undefined,\n    cellValueType: field.cellValueType,\n    isMultipleCellValue: field.isMultipleCellValue,\n    dbFieldType: field.dbFieldType,\n    recordRead: field.recordRead,\n    recordCreate: field.recordCreate,\n  };\n\n  return createFieldInstanceByVo(plain);\n}\n\nexport function createFieldInstanceByRaw(fieldRaw: Field) {\n  return createFieldInstanceByVo(rawField2FieldObj(fieldRaw));\n}\n\nconst normalizeConditionalLookupFieldVo = (field: IFieldVo): IFieldVo => {\n  if (field.type !== ('conditionalLookup' as FieldType)) {\n    return field;\n  }\n\n  const options =\n    field.options && typeof field.options === 'object' && !Array.isArray(field.options)\n      ? (field.options as Record<string, unknown>)\n      : {};\n  const innerTypeRaw = options.innerType;\n  const innerOptionsRaw = options.innerOptions;\n  const innerType =\n    typeof innerTypeRaw === 'string' ? (innerTypeRaw as FieldType) : FieldType.SingleLineText;\n  const innerOptions =\n    innerOptionsRaw && typeof innerOptionsRaw === 'object' && !Array.isArray(innerOptionsRaw)\n      ? (innerOptionsRaw as Record<string, unknown>)\n      : {};\n\n  return {\n    ...field,\n    type: innerType,\n    options: innerOptions,\n    isLookup: true,\n    isConditionalLookup: true,\n  };\n};\n\nexport function createFieldInstanceByVo(field: IFieldVo) {\n  const normalizedField = normalizeConditionalLookupFieldVo(field);\n  switch (normalizedField.type) {\n    case FieldType.SingleLineText:\n      return plainToInstance(SingleLineTextFieldDto, normalizedField);\n    case FieldType.LongText:\n      return plainToInstance(LongTextFieldDto, normalizedField);\n    case FieldType.Number:\n      return plainToInstance(NumberFieldDto, normalizedField);\n    case FieldType.SingleSelect:\n      return plainToInstance(SingleSelectFieldDto, normalizedField);\n    case FieldType.MultipleSelect:\n      return plainToInstance(MultipleSelectFieldDto, normalizedField);\n    case FieldType.Link:\n      return plainToInstance(LinkFieldDto, normalizedField);\n    case FieldType.Formula:\n      return plainToInstance(FormulaFieldDto, normalizedField);\n    case FieldType.Attachment:\n      return plainToInstance(AttachmentFieldDto, normalizedField);\n    case FieldType.Date:\n      return plainToInstance(DateFieldDto, normalizedField);\n    case FieldType.Checkbox:\n      return plainToInstance(CheckboxFieldDto, normalizedField);\n    case FieldType.Rollup:\n      return plainToInstance(RollupFieldDto, normalizedField);\n    case FieldType.ConditionalRollup:\n      return plainToInstance(ConditionalRollupFieldDto, normalizedField);\n    case FieldType.Rating:\n      return plainToInstance(RatingFieldDto, normalizedField);\n    case FieldType.AutoNumber:\n      return plainToInstance(AutoNumberFieldDto, normalizedField);\n    case FieldType.CreatedTime:\n      return plainToInstance(CreatedTimeFieldDto, normalizedField);\n    case FieldType.LastModifiedTime:\n      return plainToInstance(LastModifiedTimeFieldDto, normalizedField);\n    case FieldType.User:\n      return plainToInstance(UserFieldDto, normalizedField);\n    case FieldType.CreatedBy:\n      return plainToInstance(CreatedByFieldDto, normalizedField);\n    case FieldType.LastModifiedBy:\n      return plainToInstance(LastModifiedByFieldDto, normalizedField);\n    case FieldType.Button:\n      return plainToInstance(ButtonFieldDto, normalizedField);\n    default:\n      assertNever(normalizedField.type);\n  }\n}\n\nexport type IFieldInstance = ReturnType<typeof createFieldInstanceByVo>;\n\nexport interface IFieldMap {\n  [fieldId: string]: IFieldInstance;\n}\n\nexport function convertFieldInstanceToFieldVo(fieldInstance: IFieldInstance): IFieldVo {\n  return instanceToPlain(fieldInstance, { excludePrefixes: ['_'] }) as IFieldVo;\n}\n\n/**\n * Apply field property operations to a field VO and return a field instance.\n * This function combines the pure applyFieldPropertyOps function with createFieldInstanceByVo.\n *\n * @param fieldVo - The existing field VO to base the new field on\n * @param ops - Array of field property operations to apply\n * @returns A new field instance with the operations applied\n */\nexport function applyFieldPropertyOpsAndCreateInstance(\n  fieldVo: IFieldVo,\n  ops: ISetFieldPropertyOpContext[]\n): IFieldInstance {\n  // Apply operations to get a new field VO\n  const newFieldVo = applyFieldPropertyOps(fieldVo, ops);\n\n  // Create and return a field instance from the modified VO\n  return createFieldInstanceByVo(newFieldVo);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-base.ts",
    "content": "export abstract class FieldBase {\n  // whether the storage structure of the value is a json Object, notice title key in json object is required\n  // example: { title: 'title', id: 'id1' } or [{ title: 'title1', id: 'id1' }, { title: 'title2', id: 'id2' }]\n  abstract get isStructuredCellValue(): boolean;\n\n  abstract convertDBValue2CellValue(value: unknown, context?: unknown): unknown;\n\n  abstract convertCellValue2DBValue(value: unknown): unknown;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/attachment-field.dto.ts",
    "content": "import type { IAttachmentCellValue, IAttachmentItem } from '@teable/core';\nimport { AttachmentFieldCore, generateAttachmentId } from '@teable/core';\nimport { omit } from 'lodash';\nimport type { FieldBase } from '../field-base';\n\nexport class AttachmentFieldDto extends AttachmentFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  static getTokenAndNameByString(value: string): { token: string; name: string } | undefined {\n    const openParenIndex = value.lastIndexOf('(');\n\n    if (openParenIndex === -1) {\n      return;\n    }\n    const name = value.slice(0, openParenIndex).trim();\n    const token = value.slice(openParenIndex + 1, -1).trim();\n    return { name, token };\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    return (\n      value &&\n      JSON.stringify(\n        (value as IAttachmentCellValue).map((item) =>\n          omit(item, ['presignedUrl', 'smThumbnailUrl', 'lgThumbnailUrl'])\n        )\n      )\n    );\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n  }\n\n  override convertStringToCellValue(\n    value: string,\n    attachments?: Omit<IAttachmentItem, 'id' | 'name'>[]\n  ) {\n    // value is ddd.svg (token)\n    if (!attachments?.length || !value) {\n      return null;\n    }\n    const tokensAndNames = value.split(',').map(AttachmentFieldDto.getTokenAndNameByString);\n    return tokensAndNames\n      .map((tokenAndName) => {\n        const { token, name } = tokenAndName || {};\n        if (!token) {\n          return;\n        }\n        const attachment = attachments.find((attachment) => attachment.token === token);\n        if (!attachment) {\n          return;\n        }\n        return {\n          ...attachment,\n          name,\n          id: generateAttachmentId(),\n        };\n      })\n      .filter(Boolean) as IAttachmentItem[];\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/auto-number-field.dto.ts",
    "content": "import { AutoNumberFieldCore } from '@teable/core';\nimport type { IFormulaFieldMeta } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class AutoNumberFieldDto extends AutoNumberFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value;\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/button-field.dto.ts",
    "content": "import { ButtonFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class ButtonFieldDto extends ButtonFieldCore implements FieldBase {\n  get isStructuredCellValue(): boolean {\n    return false;\n  }\n  convertCellValue2DBValue(value: unknown): unknown {\n    return value && JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/checkbox-field.dto.ts",
    "content": "import { CheckboxFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class CheckboxFieldDto extends CheckboxFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value ? true : null;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value ? true : null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/conditional-rollup-field.dto.ts",
    "content": "import { ConditionalRollupFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class ConditionalRollupFieldDto extends ConditionalRollupFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    if (typeof value === 'number' && (isNaN(value) || !isFinite(value))) {\n      return null;\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    if (typeof value === 'bigint') {\n      return Number(value);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/created-by-field.dto.ts",
    "content": "import type { IFormulaFieldMeta, IUserCellValue } from '@teable/core';\nimport { CreatedByFieldCore } from '@teable/core';\nimport { omit } from 'lodash';\nimport type { FieldBase } from '../field-base';\nimport { UserFieldDto } from './user-field.dto';\n\nexport class CreatedByFieldDto extends CreatedByFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return true;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (!value) {\n      return null;\n    }\n\n    this.applyTransformation<IUserCellValue>(value as IUserCellValue | IUserCellValue[], (item) =>\n      omit(item, ['avatarUrl'])\n    );\n    return JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (value === null) return null;\n    const parsedValue: IUserCellValue | IUserCellValue[] =\n      typeof value === 'string' ? JSON.parse(value) : (value as IUserCellValue | IUserCellValue[]);\n    return this.applyTransformation<IUserCellValue>(parsedValue, UserFieldDto.fullAvatarUrl);\n  }\n\n  applyTransformation<T>(value: T | T[], transform: (item: T) => void): T | T[] {\n    if (Array.isArray(value)) {\n      value.forEach(transform);\n    } else {\n      transform(value);\n    }\n    return value;\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/created-time-field.dto.ts",
    "content": "import { CreatedTimeFieldCore } from '@teable/core';\nimport type { IFormulaFieldMeta } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class CreatedTimeFieldDto extends CreatedTimeFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    const normalizeDateValue = (input: unknown) => {\n      if (input instanceof Date) {\n        return input.toISOString();\n      }\n      if (typeof input === 'string') {\n        const hasTimezone = /[zZ]|[+-]\\d{2}:\\d{2}$/.test(input);\n        const parsed = new Date(hasTimezone ? input : `${input}Z`);\n        if (!Number.isNaN(parsed.getTime())) {\n          return parsed.toISOString();\n        }\n      }\n      return input;\n    };\n\n    if (this.isMultipleCellValue) {\n      if (value == null) return value;\n      const parsed = typeof value === 'string' ? JSON.parse(value) : value;\n      if (Array.isArray(parsed)) {\n        return parsed.map(normalizeDateValue);\n      }\n      return parsed;\n    }\n\n    return normalizeDateValue(value);\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/date-field.dto.ts",
    "content": "import { DateFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class DateFieldDto extends DateFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      if (value == null) return value;\n      const arr: unknown[] = Array.isArray(value)\n        ? value\n        : typeof value === 'string'\n          ? (JSON.parse(value) as unknown[])\n          : (value as unknown[]);\n      return arr.map((v) => {\n        if (v instanceof Date) return v.toISOString();\n        if (typeof v === 'number' || typeof v === 'string') {\n          const parsed = new Date(v);\n          return isNaN(parsed.getTime()) ? v : parsed.toISOString();\n        }\n        return v as unknown;\n      });\n    }\n    if (value instanceof Date) {\n      return value.toISOString();\n    }\n\n    if (typeof value === 'string' || typeof value === 'number') {\n      const parsed = new Date(value);\n      return isNaN(parsed.getTime()) ? value : parsed.toISOString();\n    }\n\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/formula-field.dto.ts",
    "content": "import type { IFormulaFieldMeta } from '@teable/core';\nimport { FormulaFieldCore, CellValueType } from '@teable/core';\nimport { match, P } from 'ts-pattern';\nimport type { FieldBase } from '../field-base';\n\nexport class FormulaFieldDto extends FormulaFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    if (typeof value === 'number' && (isNaN(value) || !isFinite(value))) {\n      return null;\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    const ctx = {\n      isMulti: Boolean(this.isMultipleCellValue),\n      isBool: this.cellValueType === CellValueType.Boolean,\n      val: value,\n    };\n\n    return (\n      match(ctx)\n        // Multiple-value formulas: JSON already or null -> return as is\n        .with(\n          { isMulti: true, val: P.when((v) => v == null || typeof v === 'object') },\n          ({ val }) => val\n        )\n        // Multiple-value formulas: stringified JSON -> parse\n        .with({ isMulti: true, val: P.string }, ({ val }) => {\n          try {\n            return JSON.parse(val);\n          } catch {\n            return val;\n          }\n        })\n        // Multiple-value formulas: any other -> return as is\n        .with({ isMulti: true }, ({ val }) => val)\n        // Date -> ISO string\n        .with({ isMulti: false, val: P.instanceOf(Date) }, ({ val }) => (val as Date).toISOString())\n        // BigInt -> number\n        .with({ isMulti: false, val: P.when((v) => typeof v === 'bigint') }, ({ val }) =>\n          Number(val as bigint)\n        )\n        // Boolean formulas: number 0/1 -> boolean\n        .with(\n          { isMulti: false, isBool: true, val: P.when((v) => typeof v === 'number') },\n          ({ val }) => (val as number) === 1\n        )\n        // Boolean formulas: string '0'/'1'/'true'/'false' -> boolean\n        .with(\n          { isMulti: false, isBool: true, val: P.when((v) => typeof v === 'string') },\n          ({ val }) => {\n            const s = (val as string).toLowerCase();\n            if (s === '1' || s === 'true') return true;\n            if (s === '0' || s === 'false') return false;\n            return val;\n          }\n        )\n        // Fallback\n        .otherwise(({ val }) => val)\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/last-modified-by-field.dto.ts",
    "content": "import type { IFormulaFieldMeta, IUserCellValue } from '@teable/core';\nimport { LastModifiedByFieldCore } from '@teable/core';\nimport { omit } from 'lodash';\nimport type { FieldBase } from '../field-base';\nimport { UserFieldDto } from './user-field.dto';\n\nexport class LastModifiedByFieldDto extends LastModifiedByFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return true;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (!value) {\n      return null;\n    }\n\n    this.applyTransformation<IUserCellValue>(value as IUserCellValue | IUserCellValue[], (item) =>\n      omit(item, ['avatarUrl'])\n    );\n    return JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (value === null) return null;\n    const parsedValue: IUserCellValue | IUserCellValue[] =\n      typeof value === 'string' ? JSON.parse(value) : (value as IUserCellValue | IUserCellValue[]);\n    return this.applyTransformation<IUserCellValue>(parsedValue, UserFieldDto.fullAvatarUrl);\n  }\n\n  applyTransformation<T>(value: T | T[], transform: (item: T) => void): T | T[] {\n    if (Array.isArray(value)) {\n      value.forEach(transform);\n    } else {\n      transform(value);\n    }\n    return value;\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/last-modified-time-field.dto.ts",
    "content": "import { LastModifiedTimeFieldCore } from '@teable/core';\nimport type { IFormulaFieldMeta } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class LastModifiedTimeFieldDto extends LastModifiedTimeFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    const normalizeDateValue = (input: unknown) => {\n      if (input instanceof Date) {\n        return input.toISOString();\n      }\n      if (typeof input === 'string') {\n        const hasTimezone = /[zZ]|[+-]\\d{2}:\\d{2}$/.test(input);\n        const parsed = new Date(hasTimezone ? input : `${input}Z`);\n        if (!Number.isNaN(parsed.getTime())) {\n          return parsed.toISOString();\n        }\n      }\n      return input;\n    };\n\n    if (this.isMultipleCellValue) {\n      if (value == null) return value;\n      const parsed = typeof value === 'string' ? JSON.parse(value) : value;\n      if (Array.isArray(parsed)) {\n        return parsed.map(normalizeDateValue);\n      }\n      return parsed;\n    }\n\n    return normalizeDateValue(value);\n  }\n\n  setMetadata(meta: IFormulaFieldMeta) {\n    this.meta = meta;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/link-field.dto.ts",
    "content": "import { LinkFieldCore, Relationship } from '@teable/core';\nimport type { ILinkCellValue, ILinkFieldMeta } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class LinkFieldDto extends LinkFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return true;\n  }\n\n  setMetadata(meta: ILinkFieldMeta) {\n    this.meta = meta;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    return value && JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n  }\n\n  updateCellTitle(\n    value: ILinkCellValue | ILinkCellValue[],\n    title: string | null | (string | null)[]\n  ) {\n    if (this.isMultipleCellValue) {\n      const values = value as ILinkCellValue[];\n      const titles = title as string[];\n      return values.map((v, i) => ({\n        id: v.id,\n        title: titles[i] || undefined,\n      }));\n    }\n    return {\n      id: (value as ILinkCellValue).id,\n      title: (title as string | null) || undefined,\n    };\n  }\n\n  override convertStringToCellValue(value: string): string[] | null {\n    const cellValue = value.split(/[,\\n\\r]\\s*/);\n    if (cellValue.length) {\n      return cellValue;\n    }\n    return null;\n  }\n\n  /**\n   * Get the order column name for this link field based on its relationship type\n   * @returns The order column name to use in database queries and operations\n   */\n  getOrderColumnName(): string {\n    const relationship = this.options.relationship;\n\n    switch (relationship) {\n      case Relationship.ManyMany:\n        // ManyMany relationships use a simple __order column in the junction table\n        return '__order';\n\n      case Relationship.OneMany:\n        // One-way OneMany may reuse legacy ManyMany junction storage where order column is \"__order\".\n        if (this.options.isOneWay && this.getHasOrderColumn()) {\n          return '__order';\n        }\n        // Other OneMany relationships use selfKeyName + _order.\n        return `${this.options.selfKeyName}_order`;\n\n      case Relationship.ManyOne:\n      case Relationship.OneOne:\n        // ManyOne and OneOne relationships use the foreignKeyName (foreign key in current table) + _order\n        return `${this.options.foreignKeyName}_order`;\n\n      default:\n        throw new Error(`Unsupported relationship type: ${relationship}`);\n    }\n  }\n\n  // Use base class getHasOrderColumn() which prefers meta when provided\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/long-text-field.dto.ts",
    "content": "import { LongTextFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class LongTextFieldDto extends LongTextFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/multiple-select-field.dto.ts",
    "content": "import { MultipleSelectFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class MultipleSelectFieldDto extends MultipleSelectFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): string | null {\n    return value == null ? null : JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): string[] {\n    return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/number-field.dto.ts",
    "content": "import { NumberFieldCore, parseStringToNumber } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class NumberFieldDto extends NumberFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      const parsed =\n        value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n      if (Array.isArray(parsed)) {\n        return parsed.map((item) => this.coerceNumber(item));\n      }\n      return parsed;\n    }\n    return this.coerceNumber(value);\n  }\n\n  private coerceNumber(value: unknown): unknown {\n    if (typeof value !== 'string') {\n      return value;\n    }\n    const parsed = parseStringToNumber(value, this.options.formatting);\n    return parsed ?? value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/rating-field.dto.ts",
    "content": "import { RatingFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class RatingFieldDto extends RatingFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/rollup-field.dto.ts",
    "content": "import { RollupFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class RollupFieldDto extends RollupFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    if (typeof value === 'number' && (isNaN(value) || !isFinite(value))) {\n      return null;\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    // Normalize BigInt (from some drivers on aggregate functions like COUNT) to number for JSON compatibility\n    if (typeof value === 'bigint') {\n      return Number(value);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/single-line-text-field.dto.ts",
    "content": "import { SingleLineTextFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\nexport class SingleLineTextFieldDto extends SingleLineTextFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/single-select-field.dto.ts",
    "content": "import { SingleSelectFieldCore } from '@teable/core';\nimport type { FieldBase } from '../field-base';\n\nexport class SingleSelectFieldDto extends SingleSelectFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return false;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null ? value : JSON.stringify(value);\n    }\n    return value;\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (this.isMultipleCellValue) {\n      return value == null || typeof value === 'object' ? value : JSON.parse(value as string);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/model/field-dto/user-field.dto.ts",
    "content": "import type { IUserCellValue } from '@teable/core';\nimport { UserFieldCore } from '@teable/core';\nimport { UploadType } from '@teable/openapi';\nimport { omit } from 'lodash';\nimport StorageAdapter from '../../../attachments/plugins/adapter';\nimport { getPublicFullStorageUrl } from '../../../attachments/plugins/utils';\nimport type { FieldBase } from '../field-base';\n\nexport class UserFieldDto extends UserFieldCore implements FieldBase {\n  get isStructuredCellValue() {\n    return true;\n  }\n\n  convertCellValue2DBValue(value: unknown): unknown {\n    if (!value) {\n      return null;\n    }\n\n    this.applyTransformation<IUserCellValue>(value as IUserCellValue | IUserCellValue[], (item) =>\n      omit(item, ['avatarUrl'])\n    );\n    return JSON.stringify(value);\n  }\n\n  convertDBValue2CellValue(value: unknown): unknown {\n    if (value === null) return null;\n\n    const parsedValue: IUserCellValue | IUserCellValue[] =\n      typeof value === 'string' ? JSON.parse(value) : value;\n    return this.applyTransformation<IUserCellValue>(parsedValue, UserFieldDto.fullAvatarUrl);\n  }\n\n  static fullAvatarUrl(cellValue: IUserCellValue) {\n    if (cellValue?.id) {\n      const path = `${StorageAdapter.getDir(UploadType.Avatar)}/${cellValue.id}`;\n\n      cellValue.avatarUrl = getPublicFullStorageUrl(path);\n    }\n    return cellValue;\n  }\n\n  applyTransformation<T>(value: T | T[], transform: (item: T) => void): T | T[] {\n    if (Array.isArray(value)) {\n      value.forEach(transform);\n    } else {\n      transform(value);\n    }\n    return value;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.spec.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { CellValueType, DbFieldType, getDefaultFormatting, type IFieldVo } from '@teable/core';\nimport { describe, expect, it, vi } from 'vitest';\nimport { FieldOpenApiV2Service } from './field-open-api-v2.service';\n\ntype ITestFieldOpenApiV2Service = {\n  mapLegacyCreateFieldToV2: (\n    ro: Record<string, unknown>,\n    table?: {\n      getField: (\n        predicate: (candidate: {\n          id: () => { equals: (id: unknown) => boolean };\n          relationship: () => { toString: () => string };\n        }) => boolean\n      ) =>\n        | {\n            isErr: () => false;\n            value: { relationship: () => { toString: () => string } };\n          }\n        | {\n            isErr: () => true;\n          };\n    }\n  ) => Record<string, unknown>;\n  mapConvertFieldToV2: (\n    ro: Record<string, unknown>,\n    currentField?: Record<string, unknown>\n  ) => Record<string, unknown>;\n  mapLegacyUpdateFieldToV2: (\n    ro: Record<string, unknown>,\n    currentField?: Record<string, unknown>\n  ) => Record<string, unknown>;\n  normalizeFieldVo: (field: unknown) => IFieldVo;\n  createField: (tableId: string, fieldRo: Record<string, unknown>) => Promise<IFieldVo>;\n  createFields: (tableId: string, fieldRos: Array<Record<string, unknown>>) => Promise<IFieldVo[]>;\n  extractFieldVoFromTableDto: (\n    tableDto: {\n      fields: Array<Record<string, unknown>>;\n    },\n    fieldId: string\n  ) => Promise<IFieldVo>;\n  hasDuplicatedDbFieldName: (\n    table: { getFields: () => Array<unknown> },\n    dbFieldName: string\n  ) => boolean;\n  completeLegacyLinkDbConfigForCreate: (\n    v2Field: Record<string, unknown>,\n    currentTable: {\n      dbTableName: () => {\n        isErr: () => boolean;\n        value: { value: () => { isErr: () => boolean; value: string } };\n      };\n    },\n    tableQueryService: {\n      getById: () => Promise<{\n        isErr: () => boolean;\n        value: {\n          dbTableName: () => {\n            isErr: () => boolean;\n            value: { value: () => { isErr: () => boolean; value: string } };\n          };\n        };\n      }>;\n    },\n    context: Record<string, unknown>\n  ) => Promise<Record<string, unknown>>;\n};\n\nconst createService = () =>\n  new FieldOpenApiV2Service(\n    {} as never,\n    {} as never,\n    {} as never,\n    {} as never,\n    {} as never,\n    {} as never\n  ) as unknown as ITestFieldOpenApiV2Service;\n\ndescribe('FieldOpenApiV2Service mapConvertFieldToV2', () => {\n  it('maps lookup convert options with filter/sort/limit', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'lookup',\n      isLookup: true,\n      lookupOptions: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n        sort: { fieldId: 'fldScore0000000001', order: 'desc' },\n        limit: 5,\n      },\n    });\n\n    expect(mapped).toEqual({\n      type: 'lookup',\n      options: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n        sort: { fieldId: 'fldScore0000000001', order: 'desc' },\n        limit: 5,\n      },\n    });\n  });\n\n  it('clears lookup filter/sort/limit when convert payload omits them', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'number',\n        isLookup: true,\n        lookupOptions: {\n          linkFieldId: 'fldLink000000000001',\n          lookupFieldId: 'fldLookup000000001',\n          foreignTableId: 'tblForeign00000001',\n        },\n      },\n      {\n        type: 'number',\n        isLookup: true,\n        lookupOptions: {\n          linkFieldId: 'fldLink000000000001',\n          lookupFieldId: 'fldLookup000000001',\n          foreignTableId: 'tblForeign00000001',\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n          sort: { fieldId: 'fldScore0000000001', order: 'desc' },\n          limit: 5,\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'lookup',\n      options: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n        filter: undefined,\n        sort: undefined,\n        limit: undefined,\n      },\n    });\n  });\n\n  it('maps rollup convert options with foreignTableId and showAs', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'rollup',\n      options: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n        expression: 'sum({values})',\n        formatting: { type: 'decimal', precision: 2 },\n        showAs: { type: 'bar', color: 'yellowBright', showValue: true, maxValue: 100 },\n        timeZone: 'utc',\n      },\n    });\n\n    expect(mapped).toEqual({\n      type: 'rollup',\n      options: {\n        expression: 'sum({values})',\n        formatting: { type: 'decimal', precision: 2 },\n        showAs: { type: 'bar', color: 'yellowBright', showValue: true, maxValue: 100 },\n        timeZone: 'utc',\n      },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n  });\n\n  it('maps rollup convert config from lookupOptions when options omit link ids', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'rollup',\n      options: {\n        expression: 'countall({values})',\n      },\n      lookupOptions: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n\n    expect(mapped).toEqual({\n      type: 'rollup',\n      options: {\n        expression: 'countall({values})',\n      },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n  });\n\n  it('maps conditionalRollup convert options with showAs', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'conditionalRollup',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        expression: 'array_compact({values})',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n        sort: { fieldId: 'fldScore0000000001', order: 'asc' },\n        limit: 1,\n        showAs: { type: 'email' },\n      },\n      cellValueType: 'string',\n      isMultipleCellValue: true,\n    });\n\n    expect(mapped).toEqual({\n      type: 'conditionalRollup',\n      cellValueType: 'string',\n      isMultipleCellValue: true,\n      options: {\n        expression: 'array_compact({values})',\n        showAs: { type: 'email' },\n      },\n      config: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n          sort: { fieldId: 'fldScore0000000001', order: 'asc' },\n          limit: 1,\n        },\n      },\n    });\n  });\n\n  it('omits incomplete conditionalRollup result type in convert payload', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'conditionalRollup',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        expression: 'sum({values})',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n      },\n      cellValueType: 'number',\n    });\n\n    expect(mapped).toEqual({\n      type: 'conditionalRollup',\n      options: {\n        expression: 'sum({values})',\n      },\n      config: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n      },\n    });\n  });\n\n  it('maps conditional lookup convert with carried result type from current field', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'formula',\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: 'tblForeign00000001',\n          lookupFieldId: 'fldLookup000000001',\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n        options: {\n          expression: 'NOW()',\n        },\n      },\n      {\n        type: 'formula',\n        cellValueType: 'dateTime',\n        isMultipleCellValue: true,\n        lookupOptions: {\n          foreignTableId: 'tblForeign00000001',\n          lookupFieldId: 'fldLookup000000001',\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n          sort: { fieldId: 'fldScore0000000001', order: 'desc' },\n          limit: 1,\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'conditionalLookup',\n      cellValueType: 'dateTime',\n      isMultipleCellValue: true,\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n        innerType: 'formula',\n        innerOptions: {\n          expression: 'NOW()',\n        },\n      },\n    });\n  });\n\n  it('does not carry string result type fallback for formula conditional lookup with formatting', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'formula',\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: 'tblForeign00000001',\n          lookupFieldId: 'fldLookup000000001',\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n        options: {\n          expression: 'NOW()',\n          formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'Asia/Shanghai' },\n        },\n      },\n      {\n        type: 'formula',\n        cellValueType: 'string',\n        isMultipleCellValue: true,\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'conditionalLookup',\n      isMultipleCellValue: true,\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n        innerType: 'formula',\n        innerOptions: {\n          expression: 'NOW()',\n          formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'Asia/Shanghai' },\n        },\n      },\n    });\n  });\n\n  it('omits rollup config when config keys are incomplete', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2({\n      type: 'rollup',\n      options: {\n        expression: 'sum({values})',\n        showAs: { type: 'email' },\n      },\n    });\n\n    expect(mapped).toEqual({\n      type: 'rollup',\n      options: {\n        expression: 'sum({values})',\n        showAs: { type: 'email' },\n      },\n    });\n  });\n\n  it('marks rollup showAs for clearing when options are replaced', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'rollup',\n        options: {\n          expression: 'concatenate({values})',\n        },\n      },\n      {\n        type: 'rollup',\n        options: {\n          showAs: { type: 'email' },\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'rollup',\n      options: {\n        expression: 'concatenate({values})',\n        showAs: null,\n      },\n    });\n  });\n\n  it('marks formula showAs for clearing when options are replaced', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'formula',\n        options: {\n          expression: '\"text\"',\n        },\n      },\n      {\n        type: 'formula',\n        options: {\n          showAs: { type: 'email' },\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'formula',\n      options: {\n        expression: '\"text\"',\n        showAs: null,\n      },\n    });\n  });\n\n  it('marks singleLineText showAs for clearing on default pass-through mapping', () => {\n    const service = createService();\n    const mapped = service.mapConvertFieldToV2(\n      {\n        type: 'singleLineText',\n        options: {},\n      },\n      {\n        type: 'singleLineText',\n        options: {\n          showAs: { type: 'email' },\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'singleLineText',\n      options: {\n        showAs: null,\n      },\n    });\n  });\n\n  it('marks formula showAs for clearing on update mapping', () => {\n    const service = createService();\n    const mapped = service.mapLegacyUpdateFieldToV2(\n      {\n        type: 'formula',\n        options: {\n          expression: '\"text\"',\n        },\n      },\n      {\n        type: 'formula',\n        options: {\n          showAs: { type: 'email' },\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'formula',\n      options: {\n        expression: '\"text\"',\n        showAs: null,\n      },\n    });\n  });\n\n  it('marks singleLineText showAs for clearing on update mapping', () => {\n    const service = createService();\n    const mapped = service.mapLegacyUpdateFieldToV2(\n      {\n        type: 'singleLineText',\n        options: {},\n      },\n      {\n        type: 'singleLineText',\n        options: {\n          showAs: { type: 'email' },\n        },\n      }\n    );\n\n    expect(mapped).toEqual({\n      type: 'singleLineText',\n      options: {\n        showAs: null,\n      },\n    });\n  });\n});\n\ndescribe('FieldOpenApiV2Service mapLegacyCreateFieldToV2', () => {\n  it('applies legacy default names when create payload omits name', () => {\n    const service = createService();\n\n    expect(\n      service.mapLegacyCreateFieldToV2({\n        type: 'singleSelect',\n      })\n    ).toMatchObject({\n      type: 'singleSelect',\n      name: 'Select',\n    });\n\n    expect(\n      service.mapLegacyCreateFieldToV2({\n        type: 'createdTime',\n      })\n    ).toMatchObject({\n      type: 'createdTime',\n      name: 'Created Time',\n    });\n\n    expect(\n      service.mapLegacyCreateFieldToV2({\n        type: 'user',\n        options: { isMultiple: true },\n      })\n    ).toMatchObject({\n      type: 'user',\n      name: 'Collaborators',\n    });\n  });\n\n  it('does not prefill legacy default names for semantic lookup fields', () => {\n    const service = createService();\n\n    expect(\n      service.mapLegacyCreateFieldToV2({\n        type: 'singleLineText',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: 'tblForeign00000001',\n          lookupFieldId: 'fldLookup000000001',\n          linkFieldId: 'fldLink000000000001',\n        },\n      })\n    ).toEqual({\n      id: expect.any(String),\n      type: 'lookup',\n      legacyMultiplicityDerivation: true,\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n  });\n\n  it('passes dbFieldName through create payload', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'singleLineText',\n      name: 'TextField',\n      dbFieldName: 'fldCustomCreateField001',\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'singleLineText',\n      name: 'TextField',\n      dbFieldName: 'fldCustomCreateField001',\n    });\n  });\n\n  it('passes aiConfig through create payload', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'singleLineText',\n      aiConfig: {\n        type: 'summary',\n        sourceFieldId: 'fldSource000000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'singleLineText',\n      aiConfig: {\n        type: 'summary',\n        sourceFieldId: 'fldSource000000001',\n      },\n    });\n  });\n\n  it('does not keep legacy false lookup multiplicity without link relationship context', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'singleLineText',\n      isLookup: true,\n      isMultipleCellValue: false,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'lookup',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n    expect(mapped).not.toHaveProperty('isMultipleCellValue');\n  });\n\n  it('does not derive lookup multiplicity at openapi mapping layer', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'multipleSelect',\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'lookup',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n    expect(mapped).not.toHaveProperty('isMultipleCellValue');\n  });\n\n  it('marks legacy lookup create payload to derive multiplicity in domain layer', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'singleLineText',\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'lookup',\n      legacyMultiplicityDerivation: true,\n    });\n  });\n\n  it('keeps explicit true lookup multiplicity from legacy payload', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'date',\n      isLookup: true,\n      isMultipleCellValue: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'lookup',\n      isMultipleCellValue: true,\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n  });\n\n  it('maps conditional lookup create payload to v2 conditionalLookup input', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'number',\n      isLookup: true,\n      isConditionalLookup: true,\n      options: {\n        formatting: {\n          type: 'currency',\n          precision: 1,\n          symbol: '¥',\n        },\n      },\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'conditionalLookup',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n      },\n    });\n    expect(mapped.id).toEqual(expect.stringMatching(/^fld[\\da-zA-Z]{16}$/));\n  });\n\n  it('omits incomplete conditionalRollup result type in create payload', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'conditionalRollup',\n      cellValueType: 'number',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        expression: 'sum({values})',\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n        },\n      },\n    });\n\n    expect(mapped).toEqual({\n      id: expect.any(String),\n      type: 'conditionalRollup',\n      options: {\n        expression: 'sum({values})',\n      },\n      config: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        condition: {\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: 'fldStatus000000001', operator: 'is', value: 'Active' }],\n          },\n        },\n      },\n    });\n  });\n\n  it('maps rollup create payload and splits config from options', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      id: 'fldCreate0000000001',\n      type: 'rollup',\n      options: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n        expression: 'sum({values})',\n      },\n    });\n\n    expect(mapped).toEqual({\n      id: 'fldCreate0000000001',\n      type: 'rollup',\n      options: {\n        expression: 'sum({values})',\n      },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n  });\n\n  it('keeps link db config fields in create payload', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'link',\n      options: {\n        relationship: 'manyMany',\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        symmetricFieldId: 'fldSymmetric0000001',\n        fkHostTableName: 'bseTestBaseId.junction_custom',\n        selfKeyName: '__fk_fldSymmetric0000001',\n        foreignKeyName: '__fk_fldCreate0000001',\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'link',\n      options: {\n        relationship: 'manyMany',\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n        symmetricFieldId: 'fldSymmetric0000001',\n        fkHostTableName: 'bseTestBaseId.junction_custom',\n        selfKeyName: '__fk_fldSymmetric0000001',\n        foreignKeyName: '__fk_fldCreate0000001',\n      },\n    });\n  });\n\n  it('normalizes UTC to utc in create payload options', () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      type: 'formula',\n      options: {\n        expression: 'NOW()',\n        timeZone: 'UTC',\n        formatting: {\n          date: 'YYYY-MM-DD',\n          time: 'HH:mm',\n          timeZone: 'UTC',\n        },\n      },\n    });\n\n    expect(mapped).toMatchObject({\n      type: 'formula',\n      options: {\n        expression: 'NOW()',\n        timeZone: 'utc',\n        formatting: {\n          date: 'YYYY-MM-DD',\n          time: 'HH:mm',\n          timeZone: 'utc',\n        },\n      },\n    });\n  });\n\n  it('fills link db config for manyOne when legacy payload misses it', async () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      id: 'fldCreate0000000001',\n      type: 'link',\n      options: {\n        relationship: 'manyOne',\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldLookup000000001',\n      },\n    });\n\n    const currentTable = {\n      dbTableName: () => ({\n        isErr: () => false,\n        value: {\n          value: () => ({ isErr: () => false, value: 'bseTestBaseId.tblCurrentTable0001' }),\n        },\n      }),\n    };\n\n    const completed = await service.completeLegacyLinkDbConfigForCreate(\n      mapped,\n      currentTable,\n      {\n        getById: async () => ({\n          isErr: () => true,\n          value: currentTable,\n        }),\n      },\n      {}\n    );\n\n    expect(completed).toMatchObject({\n      type: 'link',\n      options: {\n        relationship: 'manyOne',\n        fkHostTableName: 'bseTestBaseId.tblCurrentTable0001',\n        selfKeyName: '__id',\n        foreignKeyName: '__fk_fldCreate0000000001',\n      },\n    });\n  });\n\n  it('fills link db config for two-way oneMany from foreign table db name', async () => {\n    const service = createService();\n    const mapped = service.mapLegacyCreateFieldToV2({\n      id: 'fldCreate0000000002',\n      type: 'link',\n      options: {\n        relationship: 'oneMany',\n        isOneWay: false,\n        foreignTableId: 'tblAbCdEfGhIjKlMn01',\n        lookupFieldId: 'fldLookup000000002',\n      },\n    });\n\n    const currentTable = {\n      dbTableName: () => ({\n        isErr: () => false,\n        value: {\n          value: () => ({ isErr: () => false, value: 'bseTestBaseId.tblCurrentTable0002' }),\n        },\n      }),\n    };\n\n    const completed = await service.completeLegacyLinkDbConfigForCreate(\n      mapped,\n      currentTable,\n      {\n        getById: async () => ({\n          isErr: () => false,\n          value: {\n            dbTableName: () => ({\n              isErr: () => false,\n              value: {\n                value: () => ({\n                  isErr: () => false,\n                  value: 'bseTestBaseId.tblForeignPhysical0002',\n                }),\n              },\n            }),\n          },\n        }),\n      },\n      {}\n    );\n\n    expect(completed).toMatchObject({\n      type: 'link',\n      options: {\n        relationship: 'oneMany',\n        isOneWay: false,\n        fkHostTableName: 'bseTestBaseId.tblForeignPhysical0002',\n      },\n    });\n    expect((completed.options as { selfKeyName: string }).selfKeyName).toMatch(/^__fk_/);\n    expect((completed.options as { foreignKeyName: string }).foreignKeyName).toBe('__id');\n    expect((completed.options as { symmetricFieldId?: string }).symmetricFieldId).toMatch(/^fld/);\n  });\n});\n\ndescribe('FieldOpenApiV2Service normalizeFieldVo', () => {\n  const createNormalizeService = () =>\n    new FieldOpenApiV2Service(\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never\n    ) as unknown as ITestFieldOpenApiV2Service;\n\n  it('derives cellValueType, dbFieldType for singleLineText field', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000001',\n      name: 'Text Field',\n      type: 'singleLineText',\n      dbFieldName: 'text_field',\n      options: {},\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.String);\n    expect(vo.dbFieldType).toBe(DbFieldType.Text);\n    expect(vo.dbFieldName).toBe('text_field');\n  });\n\n  it('derives cellValueType, dbFieldType for number field', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000002',\n      name: 'Number Field',\n      type: 'number',\n      dbFieldName: 'number_field',\n      options: { formatting: { type: 'decimal', precision: 2 } },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.Number);\n    expect(vo.dbFieldType).toBe(DbFieldType.Real);\n    expect(vo.dbFieldName).toBe('number_field');\n  });\n\n  it('derives cellValueType, dbFieldType for checkbox field', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000003',\n      name: 'Checkbox',\n      type: 'checkbox',\n      dbFieldName: 'checkbox_field',\n      options: {},\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.Boolean);\n    expect(vo.dbFieldType).toBe(DbFieldType.Boolean);\n  });\n\n  it('derives cellValueType, dbFieldType for date field', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000004',\n      name: 'Date',\n      type: 'date',\n      dbFieldName: 'date_field',\n      options: {},\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.DateTime);\n    expect(vo.dbFieldType).toBe(DbFieldType.DateTime);\n  });\n\n  it('derives isMultipleCellValue and JSON dbFieldType for multipleSelect', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000005',\n      name: 'Multi Select',\n      type: 'multipleSelect',\n      dbFieldName: 'multi_select',\n      options: { choices: [] },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.String);\n    expect(vo.isMultipleCellValue).toBe(true);\n    expect(vo.dbFieldType).toBe(DbFieldType.Json);\n  });\n\n  it('derives JSON dbFieldType for link field', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000006',\n      name: 'Link',\n      type: 'link',\n      dbFieldName: 'link_field',\n      options: { foreignTableId: 'tblForeign00000001', relationship: 'manyMany' },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.String);\n    expect(vo.dbFieldType).toBe(DbFieldType.Json);\n  });\n\n  it('preserves cellValueType when already present (formula/rollup)', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000007',\n      name: 'Rollup',\n      type: 'rollup',\n      dbFieldName: 'rollup_field',\n      cellValueType: 'number',\n      isMultipleCellValue: false,\n      options: { expression: 'sum({values})' },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.Number);\n    expect(vo.dbFieldType).toBe(DbFieldType.Real);\n  });\n\n  it('applies legacy number formatting fallback for numeric rollup expressions', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldRollupNormalize0002',\n      name: 'Rollup Numeric Fallback',\n      type: 'rollup',\n      dbFieldName: 'rollup_numeric_fallback',\n      cellValueType: 'string',\n      options: { expression: 'sum({values})' },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n\n    expect((vo.options as Record<string, unknown>).formatting).toEqual(\n      getDefaultFormatting(CellValueType.Number)\n    );\n  });\n\n  it('does not override existing rollup formatting when expression is numeric', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldRollupNormalize0003',\n      name: 'Rollup Keep Formatting',\n      type: 'rollup',\n      dbFieldName: 'rollup_keep_formatting',\n      options: {\n        expression: 'sum({values})',\n        formatting: { type: 'decimal', precision: 5 },\n      },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n\n    expect((vo.options as Record<string, unknown>).formatting).toEqual({\n      type: 'decimal',\n      precision: 5,\n    });\n  });\n\n  it('derives rating field as number type', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000008',\n      name: 'Rating',\n      type: 'rating',\n      dbFieldName: 'rating_field',\n      options: { icon: 'star', color: 'yellowBright', max: 5 },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.Number);\n    expect(vo.dbFieldType).toBe(DbFieldType.Real);\n  });\n\n  it('derives autoNumber field as number/integer type', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000009',\n      name: 'AutoNumber',\n      type: 'autoNumber',\n      dbFieldName: 'auto_number',\n      options: { expression: 'ROW()' },\n    });\n\n    expect(vo.cellValueType).toBe(CellValueType.Number);\n    expect(vo.dbFieldType).toBe(DbFieldType.Integer);\n  });\n\n  it('strips symmetricFieldId from OneWay link fields', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000011',\n      name: 'OneWay Link',\n      type: 'link',\n      dbFieldName: 'oneway_link',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        relationship: 'oneMany',\n        isOneWay: true,\n        symmetricFieldId: 'fldooa6hL67OXgi4cHj',\n      },\n    });\n\n    expect(vo.type).toBe('link');\n    expect((vo.options as Record<string, unknown>).isOneWay).toBe(true);\n    expect((vo.options as Record<string, unknown>).symmetricFieldId).toBeUndefined();\n    expect((vo.options as Record<string, unknown>).foreignTableId).toBe('tblForeign00000001');\n  });\n\n  it('preserves symmetricFieldId for TwoWay link fields', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000012',\n      name: 'TwoWay Link',\n      type: 'link',\n      dbFieldName: 'twoway_link',\n      options: {\n        foreignTableId: 'tblForeign00000001',\n        relationship: 'manyMany',\n        symmetricFieldId: 'fldSymmetric000001',\n      },\n    });\n\n    expect(vo.type).toBe('link');\n    expect((vo.options as Record<string, unknown>).symmetricFieldId).toBe('fldSymmetric000001');\n  });\n\n  it('keeps unique undefined when missing', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldTest0000000010',\n      name: 'Text',\n      type: 'singleLineText',\n      options: {},\n    });\n\n    expect(vo.unique).toBeUndefined();\n  });\n\n  it('omits false isMultipleCellValue for v1 compatibility', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldButtonNormalize0001',\n      name: 'Button',\n      type: 'button',\n      dbFieldName: 'button_field',\n      isMultipleCellValue: false,\n      options: {\n        label: 'Run',\n        color: 'red',\n      },\n    });\n\n    expect(vo.isMultipleCellValue).toBeUndefined();\n  });\n\n  it('omits false isPrimary for v1 compatibility', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldPrimaryNormalize0001',\n      name: 'Secondary Text',\n      type: 'singleLineText',\n      dbFieldName: 'secondary_text',\n      isPrimary: false,\n      options: {},\n    });\n\n    expect(vo.isPrimary).toBeUndefined();\n  });\n\n  it('strips undefined keys from options payload', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldButtonNormalize0002',\n      name: 'Button',\n      type: 'button',\n      dbFieldName: 'button_field_2',\n      options: {\n        label: 'Run',\n        workflow: undefined,\n      },\n    });\n\n    expect(vo.options).toEqual({\n      label: 'Run',\n    });\n  });\n\n  it('omits false isMultipleCellValue for rollup field output compatibility', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldRollupNormalize0001',\n      name: 'Rollup',\n      type: 'rollup',\n      dbFieldName: 'rollup_field',\n      cellValueType: 'number',\n      isMultipleCellValue: false,\n      options: { expression: 'sum({values})' },\n      config: {\n        linkFieldId: 'fldLink000000000001',\n        lookupFieldId: 'fldLookup000000001',\n        foreignTableId: 'tblForeign00000001',\n      },\n    });\n\n    expect(vo.isMultipleCellValue).toBeUndefined();\n    expect(vo.cellValueType).toBe(CellValueType.Number);\n  });\n\n  it('normalizes lookup options to empty object when source options are null', () => {\n    const service = createNormalizeService();\n    const vo = service.normalizeFieldVo({\n      id: 'fldLookupNormalize0001',\n      name: 'Lookup Field',\n      type: 'singleLineText',\n      isLookup: true,\n      options: null,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldSource000000001',\n        linkFieldId: 'fldLink0000000001',\n      },\n    });\n\n    expect(vo.options).toEqual({});\n  });\n\n  it('extracts field vo directly from returned table dto and preserves lookup link metadata', async () => {\n    const service = createNormalizeService();\n    const vo = await service.extractFieldVoFromTableDto(\n      {\n        fields: [\n          {\n            id: 'fldLink000000000001',\n            name: 'Link',\n            type: 'link',\n            options: {\n              relationship: 'manyMany',\n              foreignTableId: 'tblForeign00000001',\n              fkHostTableName: 'bseBase.tblJunction',\n              selfKeyName: '__fk_self',\n              foreignKeyName: '__fk_foreign',\n            },\n          },\n          {\n            id: 'fldLookup000000001',\n            name: 'Lookup',\n            type: 'singleLineText',\n            isLookup: true,\n            lookupOptions: {\n              linkFieldId: 'fldLink000000000001',\n              foreignTableId: 'tblForeign00000001',\n              lookupFieldId: 'fldSource000000001',\n            },\n            options: null,\n          },\n        ],\n      },\n      'fldLookup000000001'\n    );\n\n    expect(vo.lookupOptions).toMatchObject({\n      linkFieldId: 'fldLink000000000001',\n      relationship: 'manyMany',\n      foreignTableId: 'tblForeign00000001',\n      fkHostTableName: 'bseBase.tblJunction',\n      selfKeyName: '__fk_self',\n      foreignKeyName: '__fk_foreign',\n    });\n  });\n});\n\ndescribe('FieldOpenApiV2Service createField', () => {\n  it('reuses the created domain table instead of remapping the full table dto', async () => {\n    const commandBus = {\n      execute: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          table: { kind: 'domainTable' },\n        },\n      }),\n    };\n    const tableQueryService = {\n      getById: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          baseId: () => ({\n            toString: () => 'bseTestBaseId',\n          }),\n        },\n      }),\n    };\n    const service = new FieldOpenApiV2Service(\n      {\n        getContainer: async () => ({\n          resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService),\n        }),\n      } as never,\n      { createContext: async () => ({ requestId: 'reqTestId' }) } as never,\n      { field: { invalidateTables: vi.fn() } } as never,\n      {} as never,\n      {} as never,\n      {} as never\n    ) as unknown as ITestFieldOpenApiV2Service;\n\n    vi.spyOn(service as object, 'hasDuplicatedDbFieldName' as never).mockReturnValue(false);\n    vi.spyOn(service as object, 'completeLegacyLinkDbConfigForCreate' as never).mockImplementation(\n      async (field) => field as Record<string, unknown>\n    );\n\n    const extractFieldVoFromDomainTable = vi\n      .spyOn(service as object, 'extractFieldVoFromDomainTable' as never)\n      .mockResolvedValue({\n        id: 'fldCreated000000001',\n        name: 'Created Field',\n        type: 'singleLineText',\n      } as IFieldVo);\n    const extractFieldVoFromTableDto = vi.spyOn(\n      service as object,\n      'extractFieldVoFromTableDto' as never\n    );\n\n    const createdField = await service.createField('tbl3sYKYH4tDz0IEg91', {\n      type: 'singleLineText',\n      name: 'Created Field',\n    });\n\n    expect(createdField).toMatchObject({\n      id: 'fldCreated000000001',\n      name: 'Created Field',\n      type: 'singleLineText',\n    });\n    expect(commandBus.execute).toHaveBeenCalledTimes(1);\n    expect(extractFieldVoFromDomainTable).toHaveBeenCalledWith(\n      { kind: 'domainTable' },\n      expect.stringMatching(/^fld/),\n      { requestId: 'reqTestId' }\n    );\n    expect(extractFieldVoFromTableDto).not.toHaveBeenCalled();\n  });\n\n  it('falls back to v2 field read for lookup fields to preserve legacy response shape', async () => {\n    const commandBus = {\n      execute: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          table: { kind: 'domainTable' },\n        },\n      }),\n    };\n    const tableQueryService = {\n      getById: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          baseId: () => ({\n            toString: () => 'bseTestBaseId',\n          }),\n        },\n      }),\n    };\n    const service = new FieldOpenApiV2Service(\n      {\n        getContainer: async () => ({\n          resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService),\n        }),\n      } as never,\n      { createContext: async () => ({ requestId: 'reqTestId' }) } as never,\n      { field: { invalidateTables: vi.fn() } } as never,\n      {} as never,\n      {} as never,\n      {} as never\n    ) as unknown as ITestFieldOpenApiV2Service;\n\n    vi.spyOn(service as object, 'hasDuplicatedDbFieldName' as never).mockReturnValue(false);\n    vi.spyOn(service as object, 'completeLegacyLinkDbConfigForCreate' as never).mockImplementation(\n      async () =>\n        ({\n          id: 'fldLookup000000001',\n          type: 'lookup',\n          options: {\n            foreignTableId: 'tblForeign00000001',\n            lookupFieldId: 'fldSource000000001',\n            linkFieldId: 'fldLink000000000001',\n          },\n        }) as Record<string, unknown>\n    );\n\n    vi.spyOn(service as object, 'extractFieldVoFromDomainTable' as never).mockResolvedValue({\n      id: 'fldLookup000000001',\n      name: 'Lookup Field',\n      type: 'singleLineText',\n    } as IFieldVo);\n    const getFieldFromV2 = vi\n      .spyOn(service as object, 'getFieldFromV2' as never)\n      .mockResolvedValue({\n        id: 'fldLookup000000001',\n        name: 'Lookup Field',\n        type: 'singleLineText',\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        isMultipleCellValue: true,\n      } as IFieldVo);\n\n    const createdField = await service.createField('tbl3sYKYH4tDz0IEg91', {\n      type: 'singleLineText',\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign00000001',\n        lookupFieldId: 'fldSource000000001',\n        linkFieldId: 'fldLink000000000001',\n      },\n    });\n\n    expect(getFieldFromV2).toHaveBeenCalledWith('tbl3sYKYH4tDz0IEg91', 'fldLookup000000001', {\n      requestId: 'reqTestId',\n    });\n    expect(createdField).toMatchObject({\n      id: 'fldLookup000000001',\n      isLookup: true,\n      dbFieldType: DbFieldType.Json,\n      isMultipleCellValue: true,\n    });\n  });\n});\n\ndescribe('FieldOpenApiV2Service createFields', () => {\n  it('reuses the created domain table for non-lookup fields and falls back to v2 reads for lookup fields', async () => {\n    const commandBus = {\n      execute: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          table: { kind: 'domainTable' },\n        },\n      }),\n    };\n    const tableQueryService = {\n      getById: vi.fn().mockResolvedValue({\n        isErr: () => false,\n        value: {\n          baseId: () => ({\n            toString: () => 'bseTestBaseId',\n          }),\n        },\n      }),\n    };\n    const service = new FieldOpenApiV2Service(\n      {\n        getContainer: async () => ({\n          resolve: vi.fn().mockReturnValueOnce(commandBus).mockReturnValueOnce(tableQueryService),\n        }),\n      } as never,\n      { createContext: async () => ({ requestId: 'reqTestId' }) } as never,\n      { field: { invalidateTables: vi.fn() } } as never,\n      {} as never,\n      {} as never,\n      {} as never\n    ) as unknown as ITestFieldOpenApiV2Service;\n\n    vi.spyOn(service as object, 'hasDuplicatedDbFieldName' as never).mockReturnValue(false);\n    vi.spyOn(service as object, 'completeLegacyLinkDbConfigForCreate' as never).mockImplementation(\n      async (field) => field as Record<string, unknown>\n    );\n\n    vi.spyOn(service as object, 'extractFieldVoFromDomainTable' as never)\n      .mockResolvedValueOnce({\n        id: 'fldText000000000001',\n        name: 'Text Field',\n        type: 'singleLineText',\n      } as IFieldVo)\n      .mockResolvedValueOnce({\n        id: 'fldLookup000000001',\n        name: 'Lookup Field',\n        type: 'singleLineText',\n      } as IFieldVo);\n    const getFieldFromV2 = vi\n      .spyOn(service as object, 'getFieldFromV2' as never)\n      .mockResolvedValue({\n        id: 'fldLookup000000001',\n        name: 'Lookup Field',\n        type: 'singleLineText',\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        isMultipleCellValue: true,\n      } as IFieldVo);\n\n    const createdFields = await service.createFields('tbl3sYKYH4tDz0IEg91', [\n      {\n        id: 'fldText000000000001',\n        type: 'singleLineText',\n        name: 'Text Field',\n      },\n      {\n        id: 'fldLookup000000001',\n        type: 'number',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: 'tblForeign00000001',\n          lookupFieldId: 'fldSource000000001',\n          linkFieldId: 'fldLink000000000001',\n        },\n      },\n    ]);\n\n    expect(createdFields).toEqual([\n      {\n        id: 'fldText000000000001',\n        name: 'Text Field',\n        type: 'singleLineText',\n      },\n      {\n        id: 'fldLookup000000001',\n        name: 'Lookup Field',\n        type: 'singleLineText',\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        isMultipleCellValue: true,\n      },\n    ]);\n    expect(commandBus.execute).toHaveBeenCalledTimes(1);\n    expect(getFieldFromV2).toHaveBeenCalledWith('tbl3sYKYH4tDz0IEg91', 'fldLookup000000001', {\n      requestId: 'reqTestId',\n    });\n  });\n});\n\ndescribe('FieldOpenApiV2Service hasDuplicatedDbFieldName', () => {\n  it('returns true when dbFieldName already exists in table', () => {\n    const service = createService();\n    const table = {\n      getFields: () => [\n        {\n          dbFieldName: () => ({\n            andThen: (\n              fn: (name: { value: () => { isOk: () => boolean; value: string } }) => unknown\n            ) => fn({ value: () => ({ isOk: () => true, value: 'fld_existing_db_name' }) }),\n          }),\n        },\n      ],\n    };\n\n    expect(service.hasDuplicatedDbFieldName(table, 'fld_existing_db_name')).toBe(true);\n  });\n\n  it('returns false when dbFieldName does not exist in table', () => {\n    const service = createService();\n    const table = {\n      getFields: () => [\n        {\n          dbFieldName: () => ({\n            andThen: (\n              fn: (name: { value: () => { isOk: () => boolean; value: string } }) => unknown\n            ) => fn({ value: () => ({ isOk: () => true, value: 'fld_other_db_name' }) }),\n          }),\n        },\n      ],\n    };\n\n    expect(service.hasDuplicatedDbFieldName(table, 'fld_missing_db_name')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable, HttpException, HttpStatus } from '@nestjs/common';\nimport {\n  CellValueType,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  generateFieldId,\n  generateOperationId,\n  getDefaultFormatting,\n  getDbFieldType,\n  ViewOpBuilder,\n  ViewType,\n  type IConvertFieldRo,\n  type IFieldRo,\n  type IFieldVo,\n  type IGridColumnMeta,\n  type IGridViewOptions,\n  type IOtOperation,\n  type IUpdateFieldRo,\n  type IViewVo,\n} from '@teable/core';\nimport type { IDuplicateFieldRo } from '@teable/openapi';\nimport {\n  mapDomainErrorToHttpError,\n  mapDomainErrorToHttpStatus,\n  mapFieldToDto,\n} from '@teable/v2-contract-http';\nimport {\n  executeDeleteFieldEndpoint,\n  executeDuplicateFieldEndpoint,\n  executeUpdateFieldEndpoint,\n  executeUpdateRecordEndpoint,\n} from '@teable/v2-contract-http-implementation/handlers';\nimport {\n  CreateFieldCommand,\n  type CreateFieldResult,\n  CreateFieldsCommand,\n  type CreateFieldsResult,\n  DeleteFieldsCommand,\n  DbTableName,\n  type Field,\n  FieldId,\n  type ICommandBus,\n  type IExecutionContext,\n  type ITableMapper,\n  LinkFieldConfig,\n  LinkRelationship,\n  TableId,\n  type Table,\n  type TableQueryService,\n  v2CoreTokens,\n} from '@teable/v2-core';\nimport { instanceToPlain } from 'class-transformer';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport { DataLoaderService } from '../../data-loader/data-loader.service';\nimport {\n  V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY,\n  type IV2FieldUpdateAuditContext,\n} from '../../v2/v2-audit-log.constants';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\nimport {\n  V2_FIELD_DELETE_COMPAT_CONTEXT_KEY,\n  type IV2FieldDeleteCompatContext,\n} from '../../v2/v2-field-delete-compat.constants';\nimport {\n  V2_FIELD_CONVERT_UNDO_CONTEXT_KEY,\n  type IV2FieldConvertUndoContext,\n} from '../../v2/v2-undo-redo.constants';\nimport { adjustFrozenField } from '../../view/utils/derive-frozen-fields';\nimport { ViewService } from '../../view/view.service';\nimport { FieldOpenApiService } from './field-open-api.service';\n\nconst internalServerError = 'Internal server error';\n// eslint-disable-next-line @typescript-eslint/naming-convention\ntype ConvertFieldExecutionOptions = {\n  emitOperation?: boolean;\n  suppressWindowId?: boolean;\n  undoRedoMode?: 'undo' | 'redo' | 'normal';\n};\n\ntype IGridViewDeleteSnapshot = {\n  viewId: string;\n  options: IGridViewOptions;\n  columnMeta: IGridColumnMeta;\n};\n\ntype ITableDtoWithFields = {\n  fields: ReadonlyArray<Record<string, unknown>>;\n};\n\ntype IPreparedLegacyCreateField = {\n  v2Field: Record<string, unknown>;\n  hasAiConfig: boolean;\n  nextAiConfig: IFieldVo['aiConfig'] | undefined;\n};\n\n@Injectable()\nexport class FieldOpenApiV2Service {\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory,\n    private readonly dataLoaderService: DataLoaderService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly viewService: ViewService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  private stripUndefinedDeep(value: unknown): unknown {\n    if (Array.isArray(value)) {\n      return value.map((item) => this.stripUndefinedDeep(item));\n    }\n\n    if (!value || typeof value !== 'object') {\n      return value;\n    }\n\n    const result: Record<string, unknown> = {};\n    for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {\n      if (nested === undefined) {\n        continue;\n      }\n      result[key] = this.stripUndefinedDeep(nested);\n    }\n\n    return result;\n  }\n\n  private invalidateFieldLoader(tableIds: ReadonlyArray<string>) {\n    const ids = Array.from(\n      new Set(tableIds.filter((id) => typeof id === 'string' && id.length > 0))\n    );\n    if (!ids.length) return;\n    this.dataLoaderService.field.invalidateTables(ids);\n  }\n\n  private async captureGridViewDeleteSnapshots(\n    tableId: string\n  ): Promise<IGridViewDeleteSnapshot[]> {\n    const views = await this.viewService.getViews(tableId);\n    return views.flatMap((view) => this.toGridViewDeleteSnapshot(view));\n  }\n\n  private toGridViewDeleteSnapshot(view: IViewVo): IGridViewDeleteSnapshot[] {\n    if (view.type !== ViewType.Grid) {\n      return [];\n    }\n\n    const options = (view.options ?? {}) as IGridViewOptions;\n    const columnMeta = (view.columnMeta ?? {}) as IGridColumnMeta;\n    return [\n      {\n        viewId: view.id,\n        options,\n        columnMeta,\n      },\n    ];\n  }\n\n  private buildFrozenFieldDeleteOps(\n    viewSnapshots: ReadonlyArray<IGridViewDeleteSnapshot>,\n    fieldIds: ReadonlyArray<string>\n  ): Record<string, IOtOperation[]> {\n    const columnMetaUpdate = Object.fromEntries(fieldIds.map((fieldId) => [fieldId, null]));\n    const opsMap: Record<string, IOtOperation[]> = {};\n\n    for (const snapshot of viewSnapshots) {\n      const nextOptions = adjustFrozenField(\n        snapshot.options,\n        snapshot.columnMeta,\n        columnMetaUpdate as unknown as IGridColumnMeta\n      );\n      if (!nextOptions) {\n        continue;\n      }\n\n      opsMap[snapshot.viewId] = [\n        ViewOpBuilder.editor.setViewProperty.build({\n          key: 'options',\n          oldValue: snapshot.options,\n          newValue: nextOptions,\n        }),\n      ];\n    }\n\n    return opsMap;\n  }\n\n  private attachDeleteFieldCompatContext(\n    context: IExecutionContext,\n    tableId: string,\n    fieldIds: ReadonlyArray<string>,\n    payload: Awaited<ReturnType<FieldOpenApiService['captureDeleteFieldsLegacyPayload']>>,\n    gridViewSnapshots: ReadonlyArray<IGridViewDeleteSnapshot>\n  ): void {\n    (\n      context as IExecutionContext & {\n        [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]?: IV2FieldDeleteCompatContext;\n      }\n    )[V2_FIELD_DELETE_COMPAT_CONTEXT_KEY] = {\n      tableId,\n      userId: this.cls.get('user.id'),\n      operationId: generateOperationId(),\n      remainingFieldIds: new Set(fieldIds),\n      frozenFieldOps: this.buildFrozenFieldDeleteOps(gridViewSnapshots, fieldIds),\n      legacyDeletePayload: payload,\n    };\n  }\n\n  private throwV2Error(\n    error: {\n      code: string;\n      message: string;\n      tags?: ReadonlyArray<string>;\n      details?: Readonly<Record<string, unknown>>;\n    },\n    status: number\n  ): never {\n    throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {\n      domainCode: error.code,\n      domainTags: error.tags,\n      details: error.details,\n    });\n  }\n\n  private normalizeFieldVo(field: unknown): IFieldVo {\n    const vo = instanceToPlain(field, { excludePrefixes: ['_'] }) as IFieldVo;\n    const raw = vo as Record<string, unknown>;\n\n    // Translate v2 conditionalRollup DTO to v1 API format.\n    // v2 stores config separately: { options: { expression, formatting, ... }, config: { foreignTableId, lookupFieldId, condition: { filter, sort, limit } } }\n    // v1 expects a flat options: { expression, formatting, filter, foreignTableId, lookupFieldId, sort, limit }\n    if (raw.type === 'conditionalRollup') {\n      const config = raw.config as Record<string, unknown> | undefined;\n      if (config) {\n        const condition = config.condition as Record<string, unknown> | undefined;\n        const opts =\n          raw.options && typeof raw.options === 'object' && !Array.isArray(raw.options)\n            ? { ...(raw.options as Record<string, unknown>) }\n            : {};\n        if (config.foreignTableId != null) opts.foreignTableId = config.foreignTableId;\n        if (config.lookupFieldId != null) opts.lookupFieldId = config.lookupFieldId;\n        if (condition) {\n          if (condition.filter !== undefined) opts.filter = condition.filter;\n          if (condition.sort !== undefined) opts.sort = condition.sort;\n          if (condition.limit !== undefined) opts.limit = condition.limit;\n        }\n        raw.options = opts;\n        delete raw.config;\n      }\n    }\n\n    // Translate v2 conditionalLookup DTO to v1 API format.\n    // v2 stores: { type: 'conditionalLookup', options: { foreignTableId, lookupFieldId, condition }, innerType, innerOptions }\n    // v1 expects: { type: innerType, isLookup: true, isConditionalLookup: true, lookupOptions: { foreignTableId, lookupFieldId, filter, sort, limit }, options: innerOptions }\n    if (raw.type === 'conditionalLookup') {\n      vo.isLookup = true;\n      vo.isConditionalLookup = true;\n      const v2Options = raw.options as Record<string, unknown> | undefined;\n      const innerType = raw.innerType as string | undefined;\n      const innerOptions = raw.innerOptions;\n\n      // Build v1 lookupOptions from v2 conditional lookup options\n      if (v2Options) {\n        const condition = v2Options.condition as Record<string, unknown> | undefined;\n        const lookupOptions: Record<string, unknown> = {};\n        if (v2Options.foreignTableId != null)\n          lookupOptions.foreignTableId = v2Options.foreignTableId;\n        if (v2Options.lookupFieldId != null) lookupOptions.lookupFieldId = v2Options.lookupFieldId;\n        if (condition) {\n          if (condition.filter !== undefined) lookupOptions.filter = condition.filter;\n          if (condition.sort !== undefined) lookupOptions.sort = condition.sort;\n          if (condition.limit !== undefined) lookupOptions.limit = condition.limit;\n        }\n        raw.lookupOptions = lookupOptions;\n      }\n\n      // Set the type to the inner field type (e.g., 'singleSelect', 'singleLineText', 'number')\n      if (innerType) {\n        raw.type = innerType;\n      }\n\n      // Set options to the inner field options (e.g., {choices: [...]}, {}, {formatting: {...}})\n      raw.options = innerOptions ?? {};\n\n      // Clean up v2-specific fields\n      delete raw.innerType;\n      delete raw.innerOptions;\n    }\n\n    if (raw.type === FieldType.Rollup) {\n      const config = raw.config as Record<string, unknown> | undefined;\n      if (config) {\n        const lookupOptions =\n          raw.lookupOptions &&\n          typeof raw.lookupOptions === 'object' &&\n          !Array.isArray(raw.lookupOptions)\n            ? { ...(raw.lookupOptions as Record<string, unknown>) }\n            : {};\n\n        if (config.linkFieldId != null) lookupOptions.linkFieldId = config.linkFieldId;\n        if (config.lookupFieldId != null) lookupOptions.lookupFieldId = config.lookupFieldId;\n        if (config.foreignTableId != null) lookupOptions.foreignTableId = config.foreignTableId;\n\n        raw.lookupOptions = lookupOptions;\n        delete raw.config;\n      }\n    }\n\n    if ((raw.type === 'lookup' || vo.isLookup === true) && vo.options == null) {\n      vo.options = {};\n    }\n\n    if (vo.type === FieldType.Link && vo.options && typeof vo.options === 'object') {\n      const linkOpts = vo.options as Record<string, unknown>;\n      if (linkOpts.isOneWay === true) {\n        delete linkOpts.symmetricFieldId;\n      }\n\n      if (raw.meta && typeof raw.meta === 'object') {\n        delete raw.meta;\n      }\n    }\n\n    if (vo.type === FieldType.Button && vo.options && typeof vo.options === 'object') {\n      const buttonOpts = vo.options as Record<string, unknown>;\n      if (buttonOpts.maxCount === 10 || buttonOpts.maxCount === '10') {\n        delete buttonOpts.maxCount;\n      }\n      if (buttonOpts.resetCount === true || buttonOpts.resetCount === 'true') {\n        delete buttonOpts.resetCount;\n      }\n    }\n\n    if (vo.isMultipleCellValue === false) {\n      delete raw.isMultipleCellValue;\n    }\n\n    if (vo.isPrimary === false) {\n      delete raw.isPrimary;\n    }\n\n    if (vo.isComputed === true && raw.isPending == null) {\n      raw.isPending = true;\n    }\n\n    if (raw.options && typeof raw.options === 'object') {\n      raw.options = this.denormalizeLegacyTimeZone(this.stripUndefinedDeep(raw.options));\n    }\n\n    if (raw.lookupOptions && typeof raw.lookupOptions === 'object') {\n      raw.lookupOptions = this.stripUndefinedDeep(raw.lookupOptions);\n    }\n\n    if (raw.aiConfig && typeof raw.aiConfig === 'object') {\n      raw.aiConfig = this.stripUndefinedDeep(raw.aiConfig);\n    }\n\n    if (vo.type === FieldType.AutoNumber) {\n      vo.cellValueType = CellValueType.Number;\n      vo.dbFieldType = DbFieldType.Integer;\n    }\n\n    if (vo.cellValueType == null) {\n      vo.cellValueType = this.deriveCellValueType(vo);\n    }\n\n    if (vo.type === FieldType.Rollup && vo.options && typeof vo.options === 'object') {\n      const options = vo.options as Record<string, unknown>;\n      if (options.formatting == null) {\n        const fallbackCellValueType = this.shouldApplyLegacyRollupNumberFormatting(vo)\n          ? CellValueType.Number\n          : vo.cellValueType;\n        const defaultFormatting =\n          fallbackCellValueType != null ? getDefaultFormatting(fallbackCellValueType) : undefined;\n        if (defaultFormatting) {\n          options.formatting = defaultFormatting;\n        }\n      }\n    }\n\n    // Derive isMultipleCellValue when not present for field types that are always multi-value.\n    if (vo.isMultipleCellValue == null) {\n      const isMultiple = this.deriveIsMultipleCellValue(vo);\n      if (isMultiple) {\n        vo.isMultipleCellValue = true;\n      }\n    }\n\n    // Derive dbFieldType when not present from field type and cellValueType.\n    if (vo.dbFieldType == null && vo.cellValueType != null) {\n      vo.dbFieldType = getDbFieldType(\n        vo.type as FieldType,\n        vo.cellValueType as CellValueType,\n        vo.isMultipleCellValue\n      );\n    }\n\n    return vo;\n  }\n\n  /**\n   * Derive cellValueType from field type.\n   * Mirrors the FieldValueTypeVisitor from v2-core for deterministic field types.\n   */\n  private deriveCellValueType(vo: IFieldVo): CellValueType {\n    switch (vo.type) {\n      case FieldType.Number:\n      case FieldType.Rating:\n      case FieldType.AutoNumber:\n        return CellValueType.Number;\n      case FieldType.Checkbox:\n        return CellValueType.Boolean;\n      case FieldType.Date:\n      case FieldType.CreatedTime:\n      case FieldType.LastModifiedTime:\n        return CellValueType.DateTime;\n      case FieldType.SingleLineText:\n      case FieldType.LongText:\n      case FieldType.SingleSelect:\n      case FieldType.MultipleSelect:\n      case FieldType.Attachment:\n      case FieldType.User:\n      case FieldType.CreatedBy:\n      case FieldType.LastModifiedBy:\n      case FieldType.Link:\n      case FieldType.Button:\n      default:\n        return CellValueType.String;\n    }\n  }\n\n  /**\n   * Derive isMultipleCellValue for field types that are always multi-value.\n   */\n  private deriveIsMultipleCellValue(vo: IFieldVo): boolean {\n    switch (vo.type) {\n      case FieldType.MultipleSelect:\n      case FieldType.Attachment:\n        return true;\n      case FieldType.Link: {\n        const opts = vo.options as Record<string, unknown> | undefined;\n        const relationship = opts?.relationship;\n        return relationship === 'oneMany' || relationship === 'manyMany';\n      }\n      case FieldType.User: {\n        const opts = vo.options as Record<string, unknown> | undefined;\n        return opts?.isMultiple === true;\n      }\n      default:\n        return false;\n    }\n  }\n\n  private shouldApplyLegacyRollupNumberFormatting(vo: IFieldVo): boolean {\n    if (vo.type !== FieldType.Rollup) {\n      return false;\n    }\n    const options =\n      vo.options && typeof vo.options === 'object' && !Array.isArray(vo.options)\n        ? (vo.options as Record<string, unknown>)\n        : undefined;\n    const expression =\n      typeof options?.expression === 'string' ? options.expression.trim().toLowerCase() : '';\n    if (!expression) {\n      return false;\n    }\n    return (\n      expression.startsWith('sum(') ||\n      expression.startsWith('average(') ||\n      expression.startsWith('count(') ||\n      expression.startsWith('counta(') ||\n      expression.startsWith('countall(')\n    );\n  }\n\n  private async getFieldFromV2(\n    tableId: string,\n    fieldId: string,\n    context?: IExecutionContext\n  ): Promise<IFieldVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n    const tableMapper = container.resolve<ITableMapper>(v2CoreTokens.tableMapper);\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);\n    }\n\n    const queryContext = context ?? (await this.v2ContextFactory.createContext());\n    const tableResult = await tableQueryService.getById(queryContext, tableIdResult.value);\n    if (tableResult.isErr()) {\n      const errMsg = tableResult.error.message ?? 'Table not found';\n      const isNotFound =\n        tableResult.error.code === 'table.not_found' || errMsg.includes('not found');\n      throw new HttpException(\n        `v2 getFieldFromV2: ${errMsg}`,\n        isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR\n      );\n    }\n\n    const tableDtoResult = tableMapper.toDTO(tableResult.value);\n    if (tableDtoResult.isErr()) {\n      throw new HttpException(tableDtoResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    return this.extractFieldVoFromTableDto(tableDtoResult.value, fieldId, queryContext);\n  }\n\n  private mapDomainFieldToDto(table: Table, field: Field): Record<string, unknown> {\n    const fieldDtoResult = mapFieldToDto(field, table.primaryFieldId());\n    if (fieldDtoResult.isErr()) {\n      throw new HttpException(fieldDtoResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    return fieldDtoResult.value as Record<string, unknown>;\n  }\n\n  private enrichLookupLinkMetadata(\n    vo: IFieldVo,\n    resolveLinkFieldDto: (linkFieldId: string) => Record<string, unknown> | undefined\n  ): void {\n    // Enrich lookupOptions with link metadata for v1 API compatibility.\n    // v2 stores link metadata (relationship, fkHostTableName, selfKeyName, foreignKeyName) on the\n    // LinkField, not on the LookupField. v1 API consumers expect these in lookupOptions.\n    if (!vo.lookupOptions || !('linkFieldId' in vo.lookupOptions)) {\n      return;\n    }\n\n    const linkFieldDto = resolveLinkFieldDto(\n      (vo.lookupOptions as { linkFieldId: string }).linkFieldId\n    );\n    if (!linkFieldDto?.options || typeof linkFieldDto.options !== 'object') {\n      return;\n    }\n\n    const linkOpts = linkFieldDto.options as Record<string, unknown>;\n    const lookup = vo.lookupOptions as Record<string, unknown>;\n    if (linkOpts.relationship != null) lookup.relationship = linkOpts.relationship;\n    if (lookup.foreignTableId == null && linkOpts.foreignTableId != null) {\n      lookup.foreignTableId = linkOpts.foreignTableId;\n    }\n    if (linkOpts.fkHostTableName != null) lookup.fkHostTableName = linkOpts.fkHostTableName;\n    if (linkOpts.selfKeyName != null) lookup.selfKeyName = linkOpts.selfKeyName;\n    if (linkOpts.foreignKeyName != null) lookup.foreignKeyName = linkOpts.foreignKeyName;\n  }\n\n  private async hydrateLookupFieldVo(\n    vo: IFieldVo,\n    queryContext?: IExecutionContext\n  ): Promise<void> {\n    if (!vo.isLookup || !vo.lookupOptions || typeof vo.lookupOptions !== 'object') {\n      return;\n    }\n\n    const lookupOpts = vo.lookupOptions as Record<string, unknown>;\n    if (lookupOpts.isOneWay === false) {\n      delete lookupOpts.isOneWay;\n    }\n    if (lookupOpts.symmetricFieldId != null) {\n      delete lookupOpts.symmetricFieldId;\n    }\n    const foreignTableId = lookupOpts.foreignTableId;\n    const lookupFieldId = lookupOpts.lookupFieldId;\n    if (typeof foreignTableId === 'string' && typeof lookupFieldId === 'string') {\n      try {\n        const sourceVo = await this.getFieldFromV2(foreignTableId, lookupFieldId, queryContext);\n        // Conditional lookup already exposes innerType via normalizeFieldVo.\n        // Do not overwrite it with foreign lookup source field type.\n        if (!vo.isConditionalLookup && sourceVo.type) {\n          vo.type = sourceVo.type;\n        }\n\n        const sourceOptions =\n          sourceVo.options &&\n          typeof sourceVo.options === 'object' &&\n          !Array.isArray(sourceVo.options)\n            ? (sourceVo.options as Record<string, unknown>)\n            : undefined;\n        const currentOptions =\n          vo.options && typeof vo.options === 'object' && !Array.isArray(vo.options)\n            ? (vo.options as Record<string, unknown>)\n            : undefined;\n\n        if (sourceOptions || currentOptions) {\n          vo.options = {\n            ...(sourceOptions ?? {}),\n            ...(currentOptions ?? {}),\n          } as IFieldVo['options'];\n          vo.options = this.denormalizeLegacyTimeZone(vo.options) as IFieldVo['options'];\n        }\n\n        if (sourceVo.cellValueType != null && vo.cellValueType == null) {\n          vo.cellValueType = sourceVo.cellValueType;\n        }\n      } catch {\n        // If the lookup source field can't be retrieved, we can still return the lookup field with best-effort type inference based on the field definition. This can happen if the foreign table or lookup field has been deleted, or if the user doesn't have access to the foreign table.\n      }\n    }\n\n    if (vo.options == null) {\n      vo.options = {};\n    }\n  }\n\n  private async extractFieldVoFromTableDto(\n    tableDto: ITableDtoWithFields,\n    fieldId: string,\n    queryContext?: IExecutionContext\n  ): Promise<IFieldVo> {\n    const field = tableDto.fields.find((item) => item.id === fieldId);\n    if (!field) {\n      throw new HttpException(`Field ${fieldId} not found`, HttpStatus.NOT_FOUND);\n    }\n\n    const vo = this.normalizeFieldVo(field);\n\n    this.enrichLookupLinkMetadata(vo, (linkFieldId) =>\n      tableDto.fields.find((f) => f.id === linkFieldId)\n    );\n\n    await this.hydrateLookupFieldVo(vo, queryContext);\n\n    return vo;\n  }\n\n  private async extractFieldVoFromDomainTable(\n    table: Table,\n    fieldId: string,\n    queryContext?: IExecutionContext\n  ): Promise<IFieldVo> {\n    const fieldIdResult = FieldId.create(fieldId);\n    if (fieldIdResult.isErr()) {\n      throw new HttpException('Invalid field id', HttpStatus.BAD_REQUEST);\n    }\n\n    const fieldResult = table.getField((candidate) => candidate.id().equals(fieldIdResult.value));\n    if (fieldResult.isErr()) {\n      throw new HttpException(`Field ${fieldId} not found`, HttpStatus.NOT_FOUND);\n    }\n\n    const vo = this.normalizeFieldVo(this.mapDomainFieldToDto(table, fieldResult.value));\n    this.enrichLookupLinkMetadata(vo, (linkFieldId) => {\n      const linkFieldIdResult = FieldId.create(linkFieldId);\n      if (linkFieldIdResult.isErr()) {\n        return undefined;\n      }\n      const linkFieldResult = table.getField((candidate) =>\n        candidate.id().equals(linkFieldIdResult.value)\n      );\n      if (linkFieldResult.isErr()) {\n        return undefined;\n      }\n      return this.mapDomainFieldToDto(table, linkFieldResult.value);\n    });\n\n    await this.hydrateLookupFieldVo(vo, queryContext);\n\n    return vo;\n  }\n\n  private async getCreateFieldContext(tableId: string): Promise<{\n    commandBus: ICommandBus;\n    tableQueryService: TableQueryService;\n    context: IExecutionContext;\n    table: Table;\n  }> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n    const context = await this.v2ContextFactory.createContext();\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);\n    }\n\n    const tableResult = await tableQueryService.getById(context, tableIdResult.value);\n    if (tableResult.isErr()) {\n      const errMsg = tableResult.error.message ?? 'Table not found';\n      const isNotFound =\n        tableResult.error.code === 'table.not_found' || errMsg.includes('not found');\n      throw new HttpException(\n        errMsg,\n        isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR\n      );\n    }\n\n    return {\n      commandBus,\n      tableQueryService,\n      context,\n      table: tableResult.value,\n    };\n  }\n\n  private async prepareLegacyCreateField(\n    fieldRo: IFieldRo,\n    currentTable: Table,\n    tableQueryService: TableQueryService,\n    context: IExecutionContext\n  ): Promise<IPreparedLegacyCreateField> {\n    const rawFieldRo = fieldRo as Record<string, unknown>;\n    const hasAiConfig = Object.prototype.hasOwnProperty.call(rawFieldRo, 'aiConfig');\n    const nextAiConfig = hasAiConfig\n      ? (rawFieldRo.aiConfig as IFieldVo['aiConfig'] | null | undefined) ?? null\n      : undefined;\n    const mappedField = this.mapLegacyCreateFieldToV2(fieldRo);\n    const v2Field = await this.completeLegacyLinkDbConfigForCreate(\n      mappedField,\n      currentTable,\n      tableQueryService,\n      context\n    );\n\n    return {\n      v2Field,\n      hasAiConfig,\n      nextAiConfig,\n    };\n  }\n\n  private collectFieldInvalidateTableIds(\n    tableId: string,\n    v2Fields: ReadonlyArray<Record<string, unknown>>\n  ): string[] {\n    const tableIdsToInvalidate = [tableId];\n\n    for (const v2Field of v2Fields) {\n      const mappedOptions =\n        v2Field.options && typeof v2Field.options === 'object' && !Array.isArray(v2Field.options)\n          ? (v2Field.options as Record<string, unknown>)\n          : undefined;\n      const mappedConfig =\n        v2Field.config && typeof v2Field.config === 'object' && !Array.isArray(v2Field.config)\n          ? (v2Field.config as Record<string, unknown>)\n          : undefined;\n\n      if (typeof mappedOptions?.foreignTableId === 'string') {\n        tableIdsToInvalidate.push(mappedOptions.foreignTableId);\n      }\n      if (typeof mappedConfig?.foreignTableId === 'string') {\n        tableIdsToInvalidate.push(mappedConfig.foreignTableId);\n      }\n    }\n\n    return tableIdsToInvalidate;\n  }\n\n  private async materializeCreatedFieldVo(\n    tableId: string,\n    table: Table,\n    fieldId: string,\n    context: IExecutionContext,\n    options?: {\n      forceCompatLookupRead?: boolean;\n    }\n  ): Promise<IFieldVo> {\n    const createdFieldFromDomain = await this.extractFieldVoFromDomainTable(\n      table,\n      fieldId,\n      context\n    );\n    return options?.forceCompatLookupRead === true || createdFieldFromDomain.isLookup === true\n      ? await this.getFieldFromV2(tableId, fieldId, context)\n      : createdFieldFromDomain;\n  }\n\n  async getField(tableId: string, fieldId: string): Promise<IFieldVo> {\n    const context = await this.v2ContextFactory.createContext();\n    return this.getFieldFromV2(tableId, fieldId, context);\n  }\n\n  private mapLegacyUpdateFieldToV2(\n    ro: IUpdateFieldRo,\n    currentField?: Record<string, unknown>\n  ): Record<string, unknown> {\n    const rawRo = ro as Record<string, unknown>;\n    const mapped = { ...rawRo };\n    const rawOptions = rawRo.options;\n    const inputOptions =\n      rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)\n        ? (rawOptions as Record<string, unknown>)\n        : undefined;\n    const currentOptions =\n      currentField?.options &&\n      typeof currentField.options === 'object' &&\n      !Array.isArray(currentField.options)\n        ? (currentField.options as Record<string, unknown>)\n        : undefined;\n    const currentType =\n      currentField && typeof currentField.type === 'string' ? currentField.type : undefined;\n\n    const supportsShowAsClear =\n      currentType === FieldType.SingleLineText ||\n      currentType === FieldType.Formula ||\n      currentType === FieldType.Rollup ||\n      currentType === 'conditionalRollup';\n\n    if (\n      supportsShowAsClear &&\n      inputOptions &&\n      currentOptions?.showAs != null &&\n      !Object.prototype.hasOwnProperty.call(inputOptions, 'showAs')\n    ) {\n      mapped.options = {\n        ...inputOptions,\n        showAs: null,\n      };\n    }\n\n    return mapped;\n  }\n\n  private normalizeLegacyTimeZone(value: unknown): unknown {\n    if (Array.isArray(value)) {\n      return value.map((item) => this.normalizeLegacyTimeZone(item));\n    }\n    if (!value || typeof value !== 'object') {\n      return value;\n    }\n\n    const normalized: Record<string, unknown> = {};\n    for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n      if (key === 'timeZone' && raw === 'UTC') {\n        normalized[key] = 'utc';\n        continue;\n      }\n      normalized[key] = this.normalizeLegacyTimeZone(raw);\n    }\n    return normalized;\n  }\n\n  private denormalizeLegacyTimeZone(value: unknown): unknown {\n    if (Array.isArray(value)) {\n      return value.map((item) => this.denormalizeLegacyTimeZone(item));\n    }\n    if (!value || typeof value !== 'object') {\n      return value;\n    }\n\n    const normalized: Record<string, unknown> = {};\n    for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n      if (key === 'timeZone' && raw === 'utc') {\n        normalized[key] = 'UTC';\n        continue;\n      }\n      normalized[key] = this.denormalizeLegacyTimeZone(raw);\n    }\n    return normalized;\n  }\n\n  private getResultTypePair(raw: Record<string, unknown>): Record<string, unknown> {\n    const cellValueType = raw.cellValueType;\n    const isMultipleCellValue = raw.isMultipleCellValue;\n\n    if (typeof cellValueType === 'string' && typeof isMultipleCellValue === 'boolean') {\n      return isMultipleCellValue ? { cellValueType, isMultipleCellValue } : { cellValueType };\n    }\n    return {};\n  }\n\n  private getLegacyDefaultCreateFieldName(ro: IFieldRo): string | undefined {\n    if (ro.isLookup || ro.isConditionalLookup) {\n      return undefined;\n    }\n\n    switch (ro.type) {\n      case FieldType.SingleLineText:\n        return 'Label';\n      case FieldType.LongText:\n        return 'Notes';\n      case FieldType.Number:\n        return 'Number';\n      case FieldType.Rating:\n        return 'Rating';\n      case FieldType.SingleSelect:\n        return 'Select';\n      case FieldType.MultipleSelect:\n        return 'Tags';\n      case FieldType.Attachment:\n        return 'Attachments';\n      case FieldType.User: {\n        const options =\n          ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n            ? (ro.options as Record<string, unknown>)\n            : undefined;\n        return options?.isMultiple === true ? 'Collaborators' : 'Collaborator';\n      }\n      case FieldType.Date:\n        return 'Date';\n      case FieldType.AutoNumber:\n        return 'ID';\n      case FieldType.CreatedTime:\n        return 'Created Time';\n      case FieldType.LastModifiedTime:\n        return 'Last Modified Time';\n      case FieldType.Checkbox:\n        return 'Done';\n      case FieldType.Button:\n        return 'Button';\n      case FieldType.CreatedBy:\n        return 'Created By';\n      case FieldType.LastModifiedBy:\n        return 'Last Modified By';\n      case FieldType.Formula:\n        return 'Calculation';\n      default:\n        return undefined;\n    }\n  }\n\n  private mapLegacyCreateFieldToV2(ro: IFieldRo): Record<string, unknown> {\n    const field = ro as Record<string, unknown>;\n    const name = typeof field.name === 'string' && field.name.trim().length > 0 ? field.name : null;\n    const base: Record<string, unknown> = {\n      id: typeof field.id === 'string' ? field.id : generateFieldId(),\n    };\n    if (name != null) {\n      base.name = name;\n    } else {\n      const legacyDefaultName = this.getLegacyDefaultCreateFieldName(ro);\n      if (legacyDefaultName) {\n        base.name = legacyDefaultName;\n      }\n    }\n    if (typeof field.dbFieldName === 'string') {\n      base.dbFieldName = field.dbFieldName;\n    }\n    if (Object.prototype.hasOwnProperty.call(field, 'description')) {\n      base.description = field.description ?? null;\n    }\n    if (field.notNull != null) base.notNull = field.notNull;\n    if (field.unique != null) base.unique = field.unique;\n    if (Object.prototype.hasOwnProperty.call(field, 'aiConfig')) {\n      base.aiConfig = field.aiConfig ?? null;\n    }\n\n    if (field.isConditionalLookup) {\n      const lookupOpts =\n        ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)\n          ? (ro.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const innerOptions =\n        ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n          ? (ro.options as Record<string, unknown>)\n          : undefined;\n      return this.normalizeLegacyTimeZone({\n        ...base,\n        type: 'conditionalLookup',\n        ...(typeof field.isMultipleCellValue === 'boolean'\n          ? { isMultipleCellValue: field.isMultipleCellValue }\n          : {}),\n        options: {\n          ...(lookupOpts?.foreignTableId != null\n            ? { foreignTableId: lookupOpts.foreignTableId }\n            : {}),\n          ...(lookupOpts?.lookupFieldId != null ? { lookupFieldId: lookupOpts.lookupFieldId } : {}),\n          condition: {\n            ...(lookupOpts?.filter ? { filter: lookupOpts.filter } : {}),\n            ...(lookupOpts?.sort ? { sort: lookupOpts.sort } : {}),\n            ...(lookupOpts?.limit != null ? { limit: lookupOpts.limit } : {}),\n          },\n        },\n        ...(innerOptions && Object.keys(innerOptions).length > 0 ? { innerOptions } : {}),\n      }) as Record<string, unknown>;\n    }\n\n    if (field.isLookup) {\n      const lookupOpts =\n        ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)\n          ? (ro.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const innerOptions =\n        ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n          ? (ro.options as Record<string, unknown>)\n          : undefined;\n      return this.normalizeLegacyTimeZone({\n        ...base,\n        type: 'lookup',\n        legacyMultiplicityDerivation: true,\n        ...(field.isMultipleCellValue === true ? { isMultipleCellValue: true } : {}),\n        options: {\n          ...(lookupOpts?.linkFieldId != null ? { linkFieldId: lookupOpts.linkFieldId } : {}),\n          ...(lookupOpts?.lookupFieldId != null ? { lookupFieldId: lookupOpts.lookupFieldId } : {}),\n          ...(lookupOpts?.foreignTableId != null\n            ? { foreignTableId: lookupOpts.foreignTableId }\n            : {}),\n          ...(lookupOpts?.filter ? { filter: lookupOpts.filter } : {}),\n          ...(lookupOpts?.sort ? { sort: lookupOpts.sort } : {}),\n          ...(lookupOpts?.limit != null ? { limit: lookupOpts.limit } : {}),\n        },\n        ...(innerOptions && Object.keys(innerOptions).length > 0 ? { innerOptions } : {}),\n      }) as Record<string, unknown>;\n    }\n\n    if (ro.type === FieldType.Rollup) {\n      const opts = (ro.options ?? {}) as Record<string, unknown>;\n      const lookupOpts =\n        ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)\n          ? (ro.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const linkFieldId = opts.linkFieldId ?? lookupOpts?.linkFieldId;\n      const lookupFieldId = opts.lookupFieldId ?? lookupOpts?.lookupFieldId;\n      const foreignTableId = opts.foreignTableId ?? lookupOpts?.foreignTableId;\n      const shouldIncludeConfig =\n        linkFieldId != null && lookupFieldId != null && foreignTableId != null;\n      return this.normalizeLegacyTimeZone({\n        ...base,\n        type: FieldType.Rollup,\n        ...this.getResultTypePair(field),\n        options: {\n          ...(opts.expression != null ? { expression: opts.expression } : {}),\n          ...(opts.formatting != null ? { formatting: opts.formatting } : {}),\n          ...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),\n          ...(opts.showAs != null ? { showAs: opts.showAs } : {}),\n        },\n        ...(shouldIncludeConfig\n          ? {\n              config: {\n                linkFieldId,\n                lookupFieldId,\n                foreignTableId,\n              },\n            }\n          : {}),\n      }) as Record<string, unknown>;\n    }\n\n    if (ro.type === FieldType.Link) {\n      const opts =\n        ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n          ? (ro.options as Record<string, unknown>)\n          : {};\n\n      return this.normalizeLegacyTimeZone({\n        ...base,\n        type: FieldType.Link,\n        options: {\n          ...(opts.baseId != null ? { baseId: opts.baseId } : {}),\n          ...(opts.relationship != null ? { relationship: opts.relationship } : {}),\n          ...(opts.foreignTableId != null ? { foreignTableId: opts.foreignTableId } : {}),\n          ...(opts.lookupFieldId != null ? { lookupFieldId: opts.lookupFieldId } : {}),\n          ...(opts.fkHostTableName != null ? { fkHostTableName: opts.fkHostTableName } : {}),\n          ...(opts.selfKeyName != null ? { selfKeyName: opts.selfKeyName } : {}),\n          ...(opts.foreignKeyName != null ? { foreignKeyName: opts.foreignKeyName } : {}),\n          ...(opts.isOneWay != null ? { isOneWay: opts.isOneWay } : {}),\n          ...(opts.symmetricFieldId != null ? { symmetricFieldId: opts.symmetricFieldId } : {}),\n          ...(Object.prototype.hasOwnProperty.call(opts, 'filterByViewId')\n            ? { filterByViewId: opts.filterByViewId }\n            : {}),\n          ...(Object.prototype.hasOwnProperty.call(opts, 'visibleFieldIds')\n            ? { visibleFieldIds: opts.visibleFieldIds }\n            : {}),\n          ...(opts.filter != null ? { filter: opts.filter } : {}),\n        },\n      }) as Record<string, unknown>;\n    }\n\n    if (ro.type === 'conditionalRollup') {\n      const opts = (ro.options ?? {}) as Record<string, unknown>;\n      const condition = {\n        ...(opts.filter ? { filter: opts.filter } : {}),\n        ...(opts.sort ? { sort: opts.sort } : {}),\n        ...(opts.limit != null ? { limit: opts.limit } : {}),\n      };\n      const shouldIncludeConfig =\n        opts.foreignTableId != null &&\n        opts.lookupFieldId != null &&\n        Object.keys(condition).length > 0;\n      return this.normalizeLegacyTimeZone({\n        ...base,\n        type: 'conditionalRollup',\n        ...this.getResultTypePair(field),\n        options: {\n          ...(opts.expression != null ? { expression: opts.expression } : {}),\n          ...(opts.formatting != null ? { formatting: opts.formatting } : {}),\n          ...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),\n          ...(opts.showAs != null ? { showAs: opts.showAs } : {}),\n        },\n        ...(shouldIncludeConfig\n          ? {\n              config: {\n                foreignTableId: opts.foreignTableId,\n                lookupFieldId: opts.lookupFieldId,\n                condition,\n              },\n            }\n          : {}),\n      }) as Record<string, unknown>;\n    }\n\n    return this.normalizeLegacyTimeZone({\n      ...base,\n      type: ro.type,\n      ...(ro.options != null ? { options: ro.options } : {}),\n    }) as Record<string, unknown>;\n  }\n\n  private getDbTableNameString(table: Table): string | undefined {\n    const dbTableNameResult = table.dbTableName();\n    if (dbTableNameResult.isErr()) {\n      return undefined;\n    }\n    const valueResult = dbTableNameResult.value.value();\n    if (valueResult.isErr()) {\n      return undefined;\n    }\n    return valueResult.value;\n  }\n\n  private hasDuplicatedDbFieldName(table: Table, dbFieldName: string): boolean {\n    return table.getFields().some((field) => {\n      const existingDbFieldNameResult = field.dbFieldName().andThen((name) => name.value());\n      return existingDbFieldNameResult.isOk() && existingDbFieldNameResult.value === dbFieldName;\n    });\n  }\n\n  private async completeLegacyLinkDbConfigForCreate(\n    v2Field: Record<string, unknown>,\n    currentTable: Table,\n    tableQueryService: TableQueryService,\n    context: IExecutionContext\n  ): Promise<Record<string, unknown>> {\n    if (v2Field.type !== FieldType.Link) {\n      return v2Field;\n    }\n\n    const options =\n      v2Field.options && typeof v2Field.options === 'object' && !Array.isArray(v2Field.options)\n        ? (v2Field.options as Record<string, unknown>)\n        : undefined;\n    if (!options) {\n      return v2Field;\n    }\n\n    const hasAnyDbConfig =\n      options.fkHostTableName != null ||\n      options.selfKeyName != null ||\n      options.foreignKeyName != null;\n    if (hasAnyDbConfig) {\n      return v2Field;\n    }\n\n    const relationshipRaw = options.relationship;\n    const foreignTableIdRaw = options.foreignTableId;\n    if (typeof relationshipRaw !== 'string' || typeof foreignTableIdRaw !== 'string') {\n      return v2Field;\n    }\n\n    const relationshipResult = LinkRelationship.create(relationshipRaw);\n    if (relationshipResult.isErr()) {\n      return v2Field;\n    }\n\n    const relationship = relationshipResult.value.toString();\n    const isOneWay = options.isOneWay === true;\n    if (relationship === 'manyMany' || (relationship === 'oneMany' && isOneWay)) {\n      return v2Field;\n    }\n\n    const fieldIdRaw = v2Field.id;\n    if (typeof fieldIdRaw !== 'string') {\n      return v2Field;\n    }\n\n    let fkHostTableNameValue: string | undefined;\n    if (relationship === 'oneMany') {\n      const foreignTableIdResult = TableId.create(foreignTableIdRaw);\n      if (foreignTableIdResult.isErr()) {\n        return v2Field;\n      }\n      const foreignTableResult = await tableQueryService.getById(\n        context,\n        foreignTableIdResult.value\n      );\n      if (foreignTableResult.isErr()) {\n        return v2Field;\n      }\n      fkHostTableNameValue = this.getDbTableNameString(foreignTableResult.value);\n    } else {\n      fkHostTableNameValue = this.getDbTableNameString(currentTable);\n    }\n\n    if (!fkHostTableNameValue) {\n      return v2Field;\n    }\n\n    const fieldIdResult = FieldId.create(fieldIdRaw);\n    if (fieldIdResult.isErr()) {\n      return v2Field;\n    }\n\n    let symmetricFieldIdRaw =\n      typeof options.symmetricFieldId === 'string' ? options.symmetricFieldId : undefined;\n    if (relationship === 'oneMany' && !isOneWay && !symmetricFieldIdRaw) {\n      symmetricFieldIdRaw = generateFieldId();\n    }\n\n    let symmetricFieldId: FieldId | undefined;\n    if (symmetricFieldIdRaw) {\n      const symmetricFieldIdResult = FieldId.create(symmetricFieldIdRaw);\n      if (symmetricFieldIdResult.isErr()) {\n        return v2Field;\n      }\n      symmetricFieldId = symmetricFieldIdResult.value;\n    }\n\n    const dbTableNameResult = DbTableName.rehydrate(fkHostTableNameValue);\n    if (dbTableNameResult.isErr()) {\n      return v2Field;\n    }\n\n    const dbConfigResult = LinkFieldConfig.buildDbConfig({\n      fkHostTableName: dbTableNameResult.value,\n      relationship: relationshipResult.value,\n      fieldId: fieldIdResult.value,\n      symmetricFieldId,\n      isOneWay,\n    });\n    if (dbConfigResult.isErr()) {\n      return v2Field;\n    }\n\n    const fkHostTableNameResult = dbConfigResult.value.fkHostTableName.value();\n    const selfKeyNameResult = dbConfigResult.value.selfKeyName.value();\n    const foreignKeyNameResult = dbConfigResult.value.foreignKeyName.value();\n    if (\n      fkHostTableNameResult.isErr() ||\n      selfKeyNameResult.isErr() ||\n      foreignKeyNameResult.isErr()\n    ) {\n      return v2Field;\n    }\n\n    return {\n      ...v2Field,\n      options: {\n        ...options,\n        fkHostTableName: fkHostTableNameResult.value,\n        selfKeyName: selfKeyNameResult.value,\n        foreignKeyName: foreignKeyNameResult.value,\n        ...(symmetricFieldIdRaw != null ? { symmetricFieldId: symmetricFieldIdRaw } : {}),\n      },\n    };\n  }\n\n  async createField(tableId: string, fieldRo: IFieldRo): Promise<IFieldVo> {\n    const { commandBus, tableQueryService, context, table } =\n      await this.getCreateFieldContext(tableId);\n    const rawFieldRo = fieldRo as Record<string, unknown>;\n    const rawDbFieldName = rawFieldRo.dbFieldName;\n    if (\n      typeof rawDbFieldName === 'string' &&\n      this.hasDuplicatedDbFieldName(table, rawDbFieldName)\n    ) {\n      throw new CustomHttpException(\n        `Db Field name ${rawDbFieldName} already exists in this table`,\n        getDefaultCodeByStatus(HttpStatus.BAD_REQUEST)\n      );\n    }\n\n    const preparedField = await this.prepareLegacyCreateField(\n      fieldRo,\n      table,\n      tableQueryService,\n      context\n    );\n    const { hasAiConfig, nextAiConfig, v2Field } = preparedField;\n    const legacyOrder =\n      fieldRo && typeof fieldRo === 'object' && 'order' in fieldRo\n        ? (fieldRo.order as\n            | {\n                viewId?: unknown;\n                orderIndex?: unknown;\n              }\n            | undefined)\n        : undefined;\n    const normalizedOrder =\n      typeof legacyOrder?.viewId === 'string' && typeof legacyOrder?.orderIndex === 'number'\n        ? {\n            viewId: legacyOrder.viewId,\n            orderIndex: legacyOrder.orderIndex,\n          }\n        : undefined;\n    const commandResult = CreateFieldCommand.create({\n      baseId: table.baseId().toString(),\n      tableId,\n      field: v2Field,\n      ...(normalizedOrder ? { order: normalizedOrder } : {}),\n    });\n\n    if (commandResult.isErr()) {\n      this.throwV2Error(\n        mapDomainErrorToHttpError(commandResult.error),\n        mapDomainErrorToHttpStatus(commandResult.error)\n      );\n    }\n\n    const result = await commandBus.execute<CreateFieldCommand, CreateFieldResult>(\n      context,\n      commandResult.value\n    );\n\n    if (result.isErr()) {\n      this.throwV2Error(\n        mapDomainErrorToHttpError(result.error),\n        mapDomainErrorToHttpStatus(result.error)\n      );\n    }\n\n    this.invalidateFieldLoader(this.collectFieldInvalidateTableIds(tableId, [v2Field]));\n\n    if (typeof v2Field.id === 'string') {\n      const shouldForceCompatLookupRead =\n        v2Field.type === 'lookup' || v2Field.type === 'conditionalLookup';\n      const createdField = await this.materializeCreatedFieldVo(\n        tableId,\n        result.value.table,\n        v2Field.id,\n        context,\n        {\n          forceCompatLookupRead: shouldForceCompatLookupRead,\n        }\n      );\n\n      if (hasAiConfig) {\n        createdField.aiConfig = nextAiConfig as IFieldVo['aiConfig'];\n      }\n\n      return createdField;\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async createFields(tableId: string, fieldRos: IFieldRo[]): Promise<IFieldVo[]> {\n    if (!fieldRos.length) {\n      return [];\n    }\n\n    const { commandBus, tableQueryService, context, table } =\n      await this.getCreateFieldContext(tableId);\n    const explicitDbFieldNames = new Set<string>();\n    for (const fieldRo of fieldRos) {\n      const rawFieldRo = fieldRo as Record<string, unknown>;\n      const rawDbFieldName = rawFieldRo.dbFieldName;\n      if (typeof rawDbFieldName !== 'string') {\n        continue;\n      }\n      if (\n        explicitDbFieldNames.has(rawDbFieldName) ||\n        this.hasDuplicatedDbFieldName(table, rawDbFieldName)\n      ) {\n        throw new CustomHttpException(\n          `Db Field name ${rawDbFieldName} already exists in this table`,\n          getDefaultCodeByStatus(HttpStatus.BAD_REQUEST)\n        );\n      }\n      explicitDbFieldNames.add(rawDbFieldName);\n    }\n\n    const preparedFields = await Promise.all(\n      fieldRos.map((fieldRo) =>\n        this.prepareLegacyCreateField(fieldRo, table, tableQueryService, context)\n      )\n    );\n    const commandResult = CreateFieldsCommand.create({\n      baseId: table.baseId().toString(),\n      tableId,\n      fields: preparedFields.map((field) => field.v2Field),\n    });\n\n    if (commandResult.isErr()) {\n      this.throwV2Error(\n        mapDomainErrorToHttpError(commandResult.error),\n        mapDomainErrorToHttpStatus(commandResult.error)\n      );\n    }\n\n    const result = await commandBus.execute<CreateFieldsCommand, CreateFieldsResult>(\n      context,\n      commandResult.value\n    );\n\n    if (result.isErr()) {\n      this.throwV2Error(\n        mapDomainErrorToHttpError(result.error),\n        mapDomainErrorToHttpStatus(result.error)\n      );\n    }\n\n    this.invalidateFieldLoader(\n      this.collectFieldInvalidateTableIds(\n        tableId,\n        preparedFields.map((field) => field.v2Field)\n      )\n    );\n\n    return await Promise.all(\n      preparedFields.map(async ({ v2Field, hasAiConfig, nextAiConfig }) => {\n        if (typeof v2Field.id !== 'string') {\n          throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n        }\n\n        const shouldForceCompatLookupRead =\n          v2Field.type === 'lookup' || v2Field.type === 'conditionalLookup';\n\n        const createdField = await this.materializeCreatedFieldVo(\n          tableId,\n          result.value.table,\n          v2Field.id,\n          context,\n          {\n            forceCompatLookupRead: shouldForceCompatLookupRead,\n          }\n        );\n\n        if (hasAiConfig) {\n          createdField.aiConfig = nextAiConfig as IFieldVo['aiConfig'];\n        }\n\n        return createdField;\n      })\n    );\n  }\n\n  async duplicateField(\n    tableId: string,\n    fieldId: string,\n    duplicateFieldRo: IDuplicateFieldRo,\n    _windowId?: string\n  ): Promise<IFieldVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n    const context = await this.v2ContextFactory.createContext();\n\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);\n    }\n\n    const tableResult = await tableQueryService.getById(context, tableIdResult.value);\n    if (tableResult.isErr()) {\n      const errMsg = tableResult.error.message ?? 'Table not found';\n      const isNotFound =\n        tableResult.error.code === 'table.not_found' || errMsg.includes('not found');\n      throw new HttpException(\n        errMsg,\n        isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR\n      );\n    }\n\n    const duplicateResult = await executeDuplicateFieldEndpoint(\n      context,\n      {\n        baseId: tableResult.value.baseId().toString(),\n        tableId,\n        fieldId,\n        includeRecordValues: true,\n        newFieldName: duplicateFieldRo.name,\n        viewId: duplicateFieldRo.viewId,\n      },\n      commandBus\n    );\n\n    if (!(duplicateResult.status === 200 && duplicateResult.body.ok)) {\n      if (!duplicateResult.body.ok) {\n        this.throwV2Error(duplicateResult.body.error, duplicateResult.status);\n      }\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    const duplicatedFieldId = duplicateResult.body.data.newFieldId;\n\n    this.invalidateFieldLoader([tableId]);\n\n    return this.getFieldFromV2(tableId, duplicatedFieldId, context);\n  }\n\n  async deleteField(tableId: string, fieldId: string): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n    const context = await this.v2ContextFactory.createContext();\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);\n    }\n\n    const tableResult = await tableQueryService.getById(context, tableIdResult.value);\n    if (tableResult.isErr()) {\n      const errMsg = tableResult.error.message ?? 'Table not found';\n      const isNotFound =\n        tableResult.error.code === 'table.not_found' || errMsg.includes('not found');\n      throw new HttpException(\n        errMsg,\n        isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR\n      );\n    }\n\n    const [legacyDeletePayload, gridViewSnapshots] = await Promise.all([\n      this.fieldOpenApiService.captureDeleteFieldsLegacyPayload(tableId, [fieldId]),\n      this.captureGridViewDeleteSnapshots(tableId),\n    ]);\n    this.attachDeleteFieldCompatContext(\n      context,\n      tableId,\n      [fieldId],\n      legacyDeletePayload,\n      gridViewSnapshots\n    );\n\n    const result = await executeDeleteFieldEndpoint(\n      context,\n      {\n        baseId: tableResult.value.baseId().toString(),\n        tableId,\n        fieldId,\n      },\n      commandBus\n    );\n\n    if (result.status === 200 && result.body.ok) {\n      this.invalidateFieldLoader([tableId]);\n      return;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async deleteFields(tableId: string, fieldIds: string[]): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n    const context = await this.v2ContextFactory.createContext();\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST);\n    }\n\n    const tableResult = await tableQueryService.getById(context, tableIdResult.value);\n    if (tableResult.isErr()) {\n      const errMsg = tableResult.error.message ?? 'Table not found';\n      const isNotFound =\n        tableResult.error.code === 'table.not_found' || errMsg.includes('not found');\n      throw new HttpException(\n        errMsg,\n        isNotFound ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR\n      );\n    }\n\n    const [legacyDeletePayload, gridViewSnapshots] = await Promise.all([\n      this.fieldOpenApiService.captureDeleteFieldsLegacyPayload(tableId, fieldIds),\n      this.captureGridViewDeleteSnapshots(tableId),\n    ]);\n    this.attachDeleteFieldCompatContext(\n      context,\n      tableId,\n      fieldIds,\n      legacyDeletePayload,\n      gridViewSnapshots\n    );\n\n    const commandResult = DeleteFieldsCommand.create({\n      baseId: tableResult.value.baseId().toString(),\n      tableId,\n      fieldIds,\n    });\n    if (commandResult.isErr()) {\n      this.throwV2Error(\n        {\n          code: commandResult.error.code,\n          message: commandResult.error.message,\n          tags: commandResult.error.tags,\n          details: commandResult.error.details,\n        },\n        HttpStatus.BAD_REQUEST\n      );\n    }\n\n    const result = await commandBus.execute(context, commandResult.value);\n    if (result.isErr()) {\n      this.throwV2Error(\n        {\n          code: result.error.code,\n          message: result.error.message,\n          tags: result.error.tags,\n          details: result.error.details,\n        },\n        result.error.code === 'not_found' ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST\n      );\n    }\n\n    this.invalidateFieldLoader([tableId]);\n  }\n\n  async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n    const currentField = await this.getFieldFromV2(tableId, fieldId, context);\n\n    const v2Input = {\n      tableId,\n      fieldId,\n      field: this.mapLegacyUpdateFieldToV2(updateFieldRo, currentField as Record<string, unknown>),\n    };\n\n    (\n      context as IExecutionContext & {\n        [V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY]?: IV2FieldUpdateAuditContext;\n      }\n    )[V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY] = {\n      tableId,\n      fieldId,\n      oldField: currentField,\n      inputField: { ...v2Input.field },\n    };\n\n    const result = await executeUpdateFieldEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      this.invalidateFieldLoader([tableId]);\n      return this.getFieldFromV2(tableId, fieldId, context);\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async convertField(\n    tableId: string,\n    fieldId: string,\n    convertFieldRo: IConvertFieldRo,\n    executionOptions?: ConvertFieldExecutionOptions\n  ) {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n    const shouldTrackUndoContext =\n      executionOptions?.emitOperation !== false && Boolean(context.windowId && context.actorId);\n    if (executionOptions?.undoRedoMode) {\n      context.undoRedo = { mode: executionOptions.undoRedoMode };\n    }\n    if (executionOptions?.suppressWindowId) {\n      delete context.windowId;\n    }\n    const currentField = await this.getFieldFromV2(tableId, fieldId, context);\n    if (shouldTrackUndoContext) {\n      (\n        context as IExecutionContext & {\n          [V2_FIELD_CONVERT_UNDO_CONTEXT_KEY]?: IV2FieldConvertUndoContext;\n        }\n      )[V2_FIELD_CONVERT_UNDO_CONTEXT_KEY] = {\n        tableId,\n        fieldId,\n        oldField: currentField,\n      };\n    }\n    // v2 uses UpdateFieldCommand for both update and convert\n    const v2Input = {\n      tableId,\n      fieldId,\n      field: {\n        ...this.mapConvertFieldToV2(convertFieldRo, currentField as Record<string, unknown>),\n        replaceOptions: true,\n      },\n    };\n\n    const result = await executeUpdateFieldEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      const updatedField = await this.getFieldFromV2(tableId, fieldId, context);\n\n      if (\n        convertFieldRo.type === FieldType.Link &&\n        typeof convertFieldRo.options === 'object' &&\n        convertFieldRo.options != null &&\n        (convertFieldRo.options as Record<string, unknown>).isOneWay === false &&\n        updatedField.type === FieldType.Link &&\n        updatedField.options &&\n        typeof updatedField.options === 'object'\n      ) {\n        (updatedField.options as Record<string, unknown>).isOneWay = false;\n      }\n\n      const tableIdsToInvalidate = [tableId];\n      const currentOptions =\n        currentField && typeof currentField === 'object'\n          ? ((currentField as { options?: unknown }).options as Record<string, unknown> | undefined)\n          : undefined;\n      const updatedOptions =\n        updatedField && typeof updatedField === 'object'\n          ? ((updatedField as { options?: unknown }).options as Record<string, unknown> | undefined)\n          : undefined;\n      if (typeof currentOptions?.foreignTableId === 'string') {\n        tableIdsToInvalidate.push(currentOptions.foreignTableId);\n      }\n      if (typeof updatedOptions?.foreignTableId === 'string') {\n        tableIdsToInvalidate.push(updatedOptions.foreignTableId);\n      }\n      this.invalidateFieldLoader(tableIdsToInvalidate);\n\n      return updatedField;\n    }\n\n    if (!result.body.ok) {\n      if (result.body.error.message === 'No changes to apply') {\n        return this.getFieldFromV2(tableId, fieldId, context);\n      }\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async replayModifiedOps(\n    modifiedOps: IOpsMap,\n    direction: 'old' | 'new',\n    undoRedoMode: 'undo' | 'redo'\n  ): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n    context.undoRedo = { mode: undoRedoMode };\n    delete context.windowId;\n\n    for (const [tableId, opsByRecordId] of Object.entries(modifiedOps)) {\n      for (const [recordId, ops] of Object.entries(opsByRecordId)) {\n        const fields: Record<string, unknown> = {};\n        for (const op of ops) {\n          if (!Array.isArray(op.p) || op.p[0] !== 'fields') {\n            continue;\n          }\n          const fieldPath = op.p[1];\n          if (typeof fieldPath !== 'string') {\n            continue;\n          }\n          fields[fieldPath] = (direction === 'old' ? op.od : op.oi) ?? null;\n        }\n\n        if (!Object.keys(fields).length) {\n          continue;\n        }\n\n        const result = await executeUpdateRecordEndpoint(\n          context,\n          {\n            tableId,\n            recordId,\n            fields,\n            fieldKeyType: FieldKeyType.Id,\n            typecast: false,\n          },\n          commandBus\n        );\n\n        if (!(result.status === 200 && result.body.ok)) {\n          if (!result.body.ok) {\n            this.throwV2Error(result.body.error, result.status);\n          }\n          throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n        }\n      }\n    }\n  }\n\n  /**\n   * Map v1 IConvertFieldRo to v2 UpdateFieldCommand field input.\n   *\n   * v1 represents conditional lookups/rollups differently from v2:\n   * - v1 conditional lookup: type=innerType + isConditionalLookup + lookupOptions\n   * - v2 conditional lookup: type='conditionalLookup' + options with condition\n   * - v1 rollup: type='rollup' + options with linkFieldId/lookupFieldId/expression\n   * - v2 rollup: type='rollup' + config with linkFieldId/lookupFieldId + options with expression\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private mapConvertFieldToV2(\n    ro: IConvertFieldRo,\n    currentField?: Record<string, unknown>\n  ): Record<string, unknown> {\n    const base: Record<string, unknown> = {};\n    if (ro.name != null) base.name = ro.name;\n    if (Object.prototype.hasOwnProperty.call(ro, 'description')) {\n      base.description = ro.description ?? null;\n    }\n    if (ro.notNull != null) base.notNull = ro.notNull;\n    if (ro.unique != null) base.unique = ro.unique;\n    if ((ro as Record<string, unknown>).dbFieldName != null) {\n      base.dbFieldName = (ro as Record<string, unknown>).dbFieldName;\n    }\n    if (Object.prototype.hasOwnProperty.call(ro, 'aiConfig')) {\n      base.aiConfig = ro.aiConfig ?? null;\n    }\n\n    // Case 1: Conditional Rollup\n    if (ro.type === 'conditionalRollup') {\n      const opts = (ro.options ?? {}) as Record<string, unknown>;\n      const hasShowAs = Object.prototype.hasOwnProperty.call(opts, 'showAs');\n      const shouldClearShowAs =\n        !hasShowAs && currentField?.type === 'conditionalRollup' && currentField?.options != null;\n      const condition: Record<string, unknown> = {\n        ...(opts.filter ? { filter: opts.filter } : {}),\n        ...(opts.sort ? { sort: opts.sort } : {}),\n        ...(opts.limit != null ? { limit: opts.limit } : {}),\n      };\n      const shouldIncludeConfig =\n        opts.foreignTableId != null &&\n        opts.lookupFieldId != null &&\n        Object.keys(condition).length > 0;\n      return {\n        ...base,\n        type: 'conditionalRollup',\n        ...this.getResultTypePair(ro as Record<string, unknown>),\n        options: {\n          ...(opts.expression != null ? { expression: opts.expression } : {}),\n          ...(opts.formatting != null ? { formatting: opts.formatting } : {}),\n          ...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),\n          ...(opts.showAs != null ? { showAs: opts.showAs } : {}),\n          ...(shouldClearShowAs ? { showAs: null } : {}),\n        },\n        ...(shouldIncludeConfig\n          ? {\n              config: {\n                foreignTableId: opts.foreignTableId,\n                lookupFieldId: opts.lookupFieldId,\n                condition,\n              },\n            }\n          : {}),\n      };\n    }\n\n    // Case 2: Conditional Lookup\n    if (ro.isConditionalLookup) {\n      const lookupOpts = ro.lookupOptions as Record<string, unknown> | undefined;\n      const opts =\n        ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n          ? (ro.options as Record<string, unknown>)\n          : {};\n      const roRecord = ro as Record<string, unknown>;\n      const currentLookupOpts =\n        currentField?.lookupOptions &&\n        typeof currentField.lookupOptions === 'object' &&\n        !Array.isArray(currentField.lookupOptions)\n          ? (currentField.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const normalizeConditionalLookupConfig = (value?: Record<string, unknown>) => ({\n        foreignTableId: value?.foreignTableId,\n        lookupFieldId: value?.lookupFieldId,\n        filter: value?.filter ?? null,\n        sort: value?.sort ?? undefined,\n        limit: value?.limit ?? undefined,\n      });\n      const nextLookupConfig = normalizeConditionalLookupConfig(lookupOpts);\n      const prevLookupConfig = normalizeConditionalLookupConfig(currentLookupOpts);\n      const shouldUpdateCondition =\n        JSON.stringify(nextLookupConfig) !== JSON.stringify(prevLookupConfig);\n      const currentCellValueType =\n        typeof currentField?.cellValueType === 'string' ? currentField.cellValueType : undefined;\n      const currentIsMultipleCellValue =\n        typeof currentField?.isMultipleCellValue === 'boolean'\n          ? currentField.isMultipleCellValue\n          : undefined;\n      const shouldSkipFormulaStringFallback =\n        ro.type === FieldType.Formula &&\n        typeof roRecord.cellValueType !== 'string' &&\n        currentCellValueType === CellValueType.String &&\n        opts.formatting != null;\n      return {\n        ...base,\n        type: 'conditionalLookup',\n        ...(typeof roRecord.cellValueType === 'string'\n          ? { cellValueType: roRecord.cellValueType }\n          : currentCellValueType && !shouldSkipFormulaStringFallback\n            ? { cellValueType: currentCellValueType }\n            : {}),\n        ...(typeof roRecord.isMultipleCellValue === 'boolean'\n          ? { isMultipleCellValue: roRecord.isMultipleCellValue }\n          : typeof currentIsMultipleCellValue === 'boolean'\n            ? { isMultipleCellValue: currentIsMultipleCellValue }\n            : {}),\n        options: {\n          ...(lookupOpts && shouldUpdateCondition\n            ? {\n                foreignTableId: lookupOpts.foreignTableId,\n                lookupFieldId: lookupOpts.lookupFieldId,\n                condition: {\n                  ...(lookupOpts.filter ? { filter: lookupOpts.filter } : {}),\n                  ...(lookupOpts.sort ? { sort: lookupOpts.sort } : {}),\n                  ...(lookupOpts.limit != null ? { limit: lookupOpts.limit } : {}),\n                },\n              }\n            : {}),\n          // Keep v1 convert semantics for conditional lookup inner field:\n          // the looked-up field type/options can be updated independently from condition.\n          ...(typeof ro.type === 'string' ? { innerType: ro.type } : {}),\n          ...(Object.keys(opts).length > 0 ? { innerOptions: opts } : {}),\n        },\n      };\n    }\n\n    // Case 3: Regular Lookup (non-conditional)\n    if (ro.isLookup && ro.lookupOptions) {\n      const lookupOpts = ro.lookupOptions as Record<string, unknown>;\n      const currentLookupOpts =\n        currentField?.lookupOptions &&\n        typeof currentField.lookupOptions === 'object' &&\n        !Array.isArray(currentField.lookupOptions)\n          ? (currentField.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const opts =\n        ro.options && typeof ro.options === 'object' && !Array.isArray(ro.options)\n          ? (ro.options as Record<string, unknown>)\n          : undefined;\n      const currentOpts =\n        currentField?.options &&\n        typeof currentField.options === 'object' &&\n        !Array.isArray(currentField.options)\n          ? (currentField.options as Record<string, unknown>)\n          : undefined;\n      const hasShowAs = opts ? Object.prototype.hasOwnProperty.call(opts, 'showAs') : false;\n      const shouldClearShowAs =\n        !hasShowAs && currentField?.isLookup === true && currentOpts?.showAs != null;\n      const hasFilterPatch = Object.prototype.hasOwnProperty.call(lookupOpts, 'filter');\n      const hasSortPatch = Object.prototype.hasOwnProperty.call(lookupOpts, 'sort');\n      const hasLimitPatch = Object.prototype.hasOwnProperty.call(lookupOpts, 'limit');\n      const shouldClearFilter = !hasFilterPatch && currentLookupOpts?.filter !== undefined;\n      const shouldClearSort = !hasSortPatch && currentLookupOpts?.sort !== undefined;\n      const shouldClearLimit = !hasLimitPatch && currentLookupOpts?.limit !== undefined;\n      const lookupOptions: Record<string, unknown> = {\n        ...(lookupOpts.linkFieldId != null ? { linkFieldId: lookupOpts.linkFieldId } : {}),\n        ...(lookupOpts.lookupFieldId != null ? { lookupFieldId: lookupOpts.lookupFieldId } : {}),\n        ...(lookupOpts.foreignTableId != null ? { foreignTableId: lookupOpts.foreignTableId } : {}),\n        ...(hasFilterPatch || shouldClearFilter ? { filter: lookupOpts.filter } : {}),\n        ...(hasSortPatch || shouldClearSort ? { sort: lookupOpts.sort } : {}),\n        ...(hasLimitPatch || shouldClearLimit ? { limit: lookupOpts.limit } : {}),\n        ...(shouldClearShowAs ? { showAs: null } : {}),\n      };\n      return {\n        ...base,\n        type: 'lookup',\n        options: lookupOptions,\n      };\n    }\n\n    // Case 4: Regular Rollup\n    if (ro.type === 'rollup') {\n      const opts = (ro.options ?? {}) as Record<string, unknown>;\n      const lookupOpts =\n        ro.lookupOptions && typeof ro.lookupOptions === 'object' && !Array.isArray(ro.lookupOptions)\n          ? (ro.lookupOptions as Record<string, unknown>)\n          : undefined;\n      const linkFieldId = opts.linkFieldId ?? lookupOpts?.linkFieldId;\n      const lookupFieldId = opts.lookupFieldId ?? lookupOpts?.lookupFieldId;\n      const foreignTableId = opts.foreignTableId ?? lookupOpts?.foreignTableId;\n      const hasShowAs = Object.prototype.hasOwnProperty.call(opts, 'showAs');\n      const shouldClearShowAs =\n        !hasShowAs && currentField?.type === 'rollup' && currentField?.options != null;\n      const shouldIncludeConfig =\n        linkFieldId != null && lookupFieldId != null && foreignTableId != null;\n      return {\n        ...base,\n        type: 'rollup',\n        options: {\n          ...(opts.expression != null ? { expression: opts.expression } : {}),\n          ...(opts.formatting != null ? { formatting: opts.formatting } : {}),\n          ...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),\n          ...(opts.showAs != null ? { showAs: opts.showAs } : {}),\n          ...(shouldClearShowAs ? { showAs: null } : {}),\n        },\n        ...(shouldIncludeConfig\n          ? {\n              config: {\n                linkFieldId,\n                lookupFieldId,\n                foreignTableId,\n              },\n            }\n          : {}),\n      };\n    }\n\n    // Case 5: Formula\n    if (ro.type === 'formula') {\n      const opts = (ro.options ?? {}) as Record<string, unknown>;\n      const currentOpts =\n        currentField?.options && typeof currentField.options === 'object'\n          ? (currentField.options as Record<string, unknown>)\n          : undefined;\n      const hasShowAs = Object.prototype.hasOwnProperty.call(opts, 'showAs');\n      const shouldClearShowAs =\n        !hasShowAs && currentField?.type === 'formula' && currentField?.options != null;\n      const zodDefaultExpressions = new Set(['LAST_MODIFIED_TIME()', 'CREATED_TIME()']);\n      const newExpression = typeof opts.expression === 'string' ? opts.expression : undefined;\n      const currentExpression =\n        currentOpts && typeof currentOpts.expression === 'string'\n          ? currentOpts.expression\n          : undefined;\n      const expression =\n        newExpression && zodDefaultExpressions.has(newExpression) && currentExpression\n          ? currentExpression\n          : newExpression;\n\n      return {\n        ...base,\n        type: 'formula',\n        options: {\n          ...(expression != null ? { expression } : {}),\n          ...(opts.timeZone != null ? { timeZone: opts.timeZone } : {}),\n          ...(opts.formatting != null ? { formatting: opts.formatting } : {}),\n          ...(opts.showAs != null ? { showAs: opts.showAs } : {}),\n          ...(shouldClearShowAs ? { showAs: null } : {}),\n        },\n      };\n    }\n\n    // Case 6: Default pass-through\n    const shouldClearShowAsOnPassThrough =\n      (ro.type === FieldType.SingleLineText || ro.type === FieldType.Number) &&\n      ro.options != null &&\n      typeof ro.options === 'object' &&\n      !Array.isArray(ro.options) &&\n      !Object.prototype.hasOwnProperty.call(ro.options, 'showAs') &&\n      currentField?.type === ro.type &&\n      currentField?.options != null;\n\n    const passThroughOptions =\n      shouldClearShowAsOnPassThrough && ro.options && typeof ro.options === 'object'\n        ? { ...(ro.options as Record<string, unknown>), showAs: null }\n        : ro.options;\n\n    return {\n      ...base,\n      type: ro.type,\n      options: passThroughOptions,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Put,\n  Post,\n  Query,\n  Headers,\n  UseGuards,\n  UseInterceptors,\n} from '@nestjs/common';\nimport type { IFieldVo } from '@teable/core';\nimport {\n  createFieldRoSchema,\n  getFieldsQuerySchema,\n  IFieldRo,\n  IGetFieldsQuery,\n  IConvertFieldRo,\n  convertFieldRoSchema,\n  updateFieldRoSchema,\n  IUpdateFieldRo,\n} from '@teable/core';\nimport {\n  deleteFieldsQuerySchema,\n  fieldDeleteReferencesQuerySchema,\n  IAutoFillFieldRo,\n  autoFillFieldRoSchema,\n  duplicateFieldRoSchema,\n  IDeleteFieldsQuery,\n  IDuplicateFieldRo,\n} from '@teable/openapi';\nimport type {\n  IAutoFillFieldVo,\n  IFieldDeleteReferencesQuery,\n  IFieldDeleteReferencesVo,\n  IGetViewFilterLinkRecordsVo,\n  IPlanFieldConvertVo,\n  IPlanFieldVo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';\nimport { FieldService } from '../field.service';\nimport { FieldOpenApiV2Service } from './field-open-api-v2.service';\nimport { FieldOpenApiService } from './field-open-api.service';\n\n@UseGuards(V2FeatureGuard)\n@UseInterceptors(V2IndicatorInterceptor)\n@Controller('api/table/:tableId/field')\n@AllowAnonymous()\nexport class FieldOpenApiController {\n  constructor(\n    private readonly fieldService: FieldService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly fieldOpenApiV2Service: FieldOpenApiV2Service,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Permissions('field|delete')\n  @Get('delete-references')\n  async getDeleteFieldReferences(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(fieldDeleteReferencesQuerySchema))\n    query: IFieldDeleteReferencesQuery\n  ): Promise<IFieldDeleteReferencesVo> {\n    return this.fieldOpenApiService.getDeleteFieldReferences(tableId, query.fieldIds);\n  }\n\n  @Permissions('field|read')\n  @Get(':fieldId/plan')\n  async planField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IPlanFieldVo> {\n    return await this.fieldOpenApiService.planField(tableId, fieldId);\n  }\n\n  @Permissions('field|read')\n  @Get(':fieldId')\n  async getField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IFieldVo> {\n    const forceV2All = process.env.FORCE_V2_ALL?.toLowerCase() === 'true';\n    if (this.cls.get('useV2') || forceV2All) {\n      const field = await this.fieldOpenApiV2Service.getField(tableId, fieldId);\n      if (field.hasError == null) {\n        try {\n          const legacyField = await this.fieldService.getField(tableId, fieldId);\n          if (legacyField.hasError != null) {\n            field.hasError = legacyField.hasError;\n          }\n        } catch (error) {\n          void error;\n        }\n      }\n      return field;\n    }\n    return await this.fieldService.getField(tableId, fieldId);\n  }\n\n  @Permissions('field|read')\n  @Get()\n  async getFields(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(getFieldsQuerySchema)) query: IGetFieldsQuery\n  ): Promise<IFieldVo[]> {\n    return await this.fieldOpenApiService.getFields(tableId, query);\n  }\n\n  @Permissions('field|create')\n  @Post('/plan')\n  async planFieldCreate(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(createFieldRoSchema)) fieldRo: IFieldRo\n  ): Promise<IPlanFieldVo> {\n    return await this.fieldOpenApiService.planFieldCreate(tableId, fieldRo);\n  }\n\n  @Permissions('field|create')\n  @UseV2Feature('createField')\n  @Post()\n  async createField(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(createFieldRoSchema)) fieldRo: IFieldRo,\n    @Headers('x-window-id') windowId: string\n  ): Promise<IFieldVo> {\n    if (this.cls.get('useV2')) {\n      return await this.fieldOpenApiV2Service.createField(tableId, fieldRo);\n    }\n    return await this.fieldOpenApiService.createField(tableId, fieldRo, windowId);\n  }\n\n  @Permissions('field|update')\n  @Put(':fieldId/plan')\n  async planFieldConvert(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string,\n    @Body(new ZodValidationPipe(convertFieldRoSchema)) updateFieldRo: IConvertFieldRo\n  ): Promise<IPlanFieldConvertVo> {\n    return await this.fieldOpenApiService.planFieldConvert(tableId, fieldId, updateFieldRo);\n  }\n\n  @Permissions('field|update')\n  @UseV2Feature('convertField')\n  @Put(':fieldId/convert')\n  async convertField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string,\n    @Body(new ZodValidationPipe(convertFieldRoSchema)) updateFieldRo: IConvertFieldRo,\n    @Headers('x-window-id') windowId: string\n  ) {\n    if (this.cls.get('useV2')) {\n      return await this.fieldOpenApiV2Service.convertField(tableId, fieldId, updateFieldRo, {\n        emitOperation: Boolean(windowId),\n        suppressWindowId: !windowId,\n      });\n    }\n    return await this.fieldOpenApiService.convertField(tableId, fieldId, updateFieldRo, windowId);\n  }\n\n  @Permissions('field|update')\n  @UseV2Feature('updateField')\n  @Patch(':fieldId')\n  async updateField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string,\n    @Body(new ZodValidationPipe(updateFieldRoSchema)) updateFieldRo: IUpdateFieldRo\n  ) {\n    if (this.cls.get('useV2')) {\n      return await this.fieldOpenApiV2Service.updateField(tableId, fieldId, updateFieldRo);\n    }\n    return await this.fieldOpenApiService.updateField(tableId, fieldId, updateFieldRo);\n  }\n\n  @Permissions('field|delete')\n  @Delete(':fieldId/plan')\n  async planDeleteField(@Param('tableId') tableId: string, @Param('fieldId') fieldId: string) {\n    return await this.fieldOpenApiService.planDeleteField(tableId, fieldId);\n  }\n\n  @Permissions('field|delete')\n  @UseV2Feature('deleteField')\n  @Delete(':fieldId')\n  async deleteField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string,\n    @Headers('x-window-id') windowId: string\n  ) {\n    if (this.cls.get('useV2')) {\n      await this.fieldOpenApiV2Service.deleteField(tableId, fieldId);\n      return;\n    }\n    await this.fieldOpenApiService.deleteField(tableId, fieldId, windowId);\n  }\n\n  @Permissions('field|delete')\n  @UseV2Feature('deleteField')\n  @Delete()\n  async deleteFields(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(deleteFieldsQuerySchema)) query: IDeleteFieldsQuery,\n    @Headers('x-window-id') windowId: string\n  ) {\n    if (this.cls.get('useV2')) {\n      await this.fieldOpenApiV2Service.deleteFields(tableId, query.fieldIds);\n      return;\n    }\n    await this.fieldOpenApiService.deleteFields(tableId, query.fieldIds, windowId);\n  }\n\n  @Permissions('field|update')\n  @Get('/:fieldId/filter-link-records')\n  async getFilterLinkRecords(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IGetViewFilterLinkRecordsVo> {\n    return this.fieldOpenApiService.getFilterLinkRecords(tableId, fieldId);\n  }\n\n  @Permissions('field|read')\n  @Get('/socket/snapshot-bulk')\n  async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) {\n    return this.fieldService.getSnapshotBulk(tableId, ids);\n  }\n\n  @Permissions('field|read')\n  @Get('/socket/doc-ids')\n  async getDocIds(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(getFieldsQuerySchema)) query: IGetFieldsQuery\n  ) {\n    return this.fieldService.getDocIdsByQuery(tableId, query);\n  }\n\n  @Permissions('field|create')\n  @UseV2Feature('duplicateField')\n  @Post('/:fieldId/duplicate')\n  async duplicateField(\n    @Param('tableId') tableId: string,\n    @Param('fieldId') fieldId: string,\n    @Body(new ZodValidationPipe(duplicateFieldRoSchema)) duplicateFieldRo: IDuplicateFieldRo,\n    @Headers('x-window-id') windowId: string\n  ) {\n    if (this.cls.get('useV2')) {\n      return this.fieldOpenApiV2Service.duplicateField(\n        tableId,\n        fieldId,\n        duplicateFieldRo,\n        windowId\n      );\n    }\n    return this.fieldOpenApiService.duplicateField(tableId, fieldId, duplicateFieldRo, windowId);\n  }\n\n  @Permissions('record|update')\n  @Post('/:fieldId/auto-fill')\n  async autoFillField(\n    @Param('tableId') _tableId: string,\n    @Param('fieldId') _fieldId: string,\n    @Body(new ZodValidationPipe(autoFillFieldRoSchema)) _query: IAutoFillFieldRo\n  ): Promise<IAutoFillFieldVo> {\n    return { taskId: null };\n  }\n\n  @Permissions('record|update')\n  @Post('/:fieldId/stop-fill')\n  async stopFillField(@Param('tableId') _tableId: string, @Param('fieldId') _fieldId: string) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { CanaryModule } from '../../canary/canary.module';\nimport { GraphModule } from '../../graph/graph.module';\nimport { ComputedModule } from '../../record/computed/computed.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { RecordQueryBuilderModule } from '../../record/query-builder';\nimport { RecordModule } from '../../record/record.module';\nimport { TableIndexService } from '../../table/table-index.service';\nimport { V2Module } from '../../v2/v2.module';\nimport { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';\nimport { ViewModule } from '../../view/view.module';\nimport { FieldCalculateModule } from '../field-calculate/field-calculate.module';\nimport { FieldModule } from '../field.module';\nimport { FieldOpenApiController } from './field-open-api.controller';\nimport { FieldOpenApiV2Service } from './field-open-api-v2.service';\nimport { FieldOpenApiService } from './field-open-api.service';\n\n@Module({\n  imports: [\n    FieldModule,\n    RecordModule,\n    ViewOpenApiModule,\n    ShareDbModule,\n    CalculationModule,\n    RecordOpenApiModule,\n    FieldCalculateModule,\n    ViewModule,\n    GraphModule,\n    RecordQueryBuilderModule,\n    ComputedModule,\n    V2Module,\n    CanaryModule,\n  ],\n  controllers: [FieldOpenApiController],\n  providers: [DbProvider, FieldOpenApiService, FieldOpenApiV2Service, TableIndexService],\n  exports: [FieldOpenApiService, FieldOpenApiV2Service],\n})\nexport class FieldOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { FieldOpenApiModule } from './field-open-api.module';\nimport { FieldOpenApiService } from './field-open-api.service';\n\ndescribe('FieldOpenApiService', () => {\n  let service: FieldOpenApiService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, FieldOpenApiModule],\n    }).compile();\n\n    service = module.get<FieldOpenApiService>(FieldOpenApiService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';\nimport {\n  CellValueType,\n  ColorConfigType,\n  FieldKeyType,\n  FieldOpBuilder,\n  FieldType,\n  ViewType,\n  generateFieldId,\n  generateOperationId,\n  IFieldRo,\n  StatisticsFunc,\n  isRollupFunctionSupportedForCellValueType,\n  isLinkLookupOptions,\n  isFieldReferenceValue,\n  isFieldReferenceComparable,\n  extractFieldIdsFromFilter,\n} from '@teable/core';\nimport type {\n  IColumn,\n  IFieldVo,\n  IConvertFieldRo,\n  IUpdateFieldRo,\n  IOtOperation,\n  IColumnMeta,\n  ILinkFieldOptions,\n  IConditionalRollupFieldOptions,\n  IConditionalLookupOptions,\n  IRollupFieldOptions,\n  IGetFieldsQuery,\n  IFilter,\n  IFilterItem,\n  IFieldReferenceValue,\n  IGridViewOptions,\n  ISort,\n  IGroup,\n  ICalendarViewOptions,\n  IGalleryViewOptions,\n  IKanbanViewOptions,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IDuplicateFieldRo,\n  IFieldDeleteReferencesItem,\n  IFieldDeleteRefTableSource,\n  IFieldDeleteRefDependentField,\n  IFieldDeleteRefView,\n} from '@teable/openapi';\nimport { instanceToPlain } from 'class-transformer';\nimport { Knex } from 'knex';\nimport { groupBy, isEqual, omit, pick } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config';\nimport { FieldReferenceCompatibilityException } from '../../../db-provider/filter-query/cell-value-filter.abstract';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { Timing } from '../../../utils/timing';\nimport { FieldCalculationService } from '../../calculation/field-calculation.service';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport { GraphService } from '../../graph/graph.service';\nimport { ComputedOrchestratorService } from '../../record/computed/services/computed-orchestrator.service';\nimport { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../../record/query-builder';\nimport { RecordService } from '../../record/record.service';\nimport { TableIndexService } from '../../table/table-index.service';\nimport { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport { ViewService } from '../../view/view.service';\nimport { FieldConvertingService } from '../field-calculate/field-converting.service';\nimport { FieldCreatingService } from '../field-calculate/field-creating.service';\nimport { FieldDeletingService } from '../field-calculate/field-deleting.service';\nimport { FieldSupplementService } from '../field-calculate/field-supplement.service';\nimport { FieldViewSyncService } from '../field-calculate/field-view-sync.service';\nimport { FieldService } from '../field.service';\nimport type { IFieldInstance } from '../model/factory';\nimport {\n  convertFieldInstanceToFieldVo,\n  createFieldInstanceByRaw,\n  createFieldInstanceByVo,\n  rawField2FieldObj,\n} from '../model/factory';\n\ntype FieldDeleteDependencyContext = {\n  tableId: string;\n  sourceFieldIds: string[];\n  sourceFieldIdSet: Set<string>;\n  deletingFieldIdSet: Set<string>;\n  currentTableFields: Array<{ id: string; type: string; options: string | null }>;\n  currentTableFieldIds: string[];\n  currentTableFieldIdSet: Set<string>;\n};\n\ntype LinkReferenceOptions = Pick<\n  ILinkFieldOptions,\n  'foreignTableId' | 'lookupFieldId' | 'visibleFieldIds'\n>;\n\nexport type ILegacyDeleteFieldsPayloadSnapshot = {\n  fields: Array<\n    IFieldVo & {\n      columnMeta: IColumnMeta;\n      references?: string[];\n    }\n  >;\n  records: Awaited<ReturnType<RecordService['getRecordsFields']>> | undefined;\n};\n\n@Injectable()\nexport class FieldOpenApiService {\n  private logger = new Logger(FieldOpenApiService.name);\n  constructor(\n    private readonly graphService: GraphService,\n    private readonly prismaService: PrismaService,\n    private readonly fieldService: FieldService,\n    private readonly viewService: ViewService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly fieldCreatingService: FieldCreatingService,\n    private readonly fieldDeletingService: FieldDeletingService,\n    private readonly fieldConvertingService: FieldConvertingService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly fieldCalculationService: FieldCalculationService,\n    private readonly fieldViewSyncService: FieldViewSyncService,\n    private readonly recordService: RecordService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly tableIndexService: TableIndexService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder,\n    private readonly computedOrchestrator: ComputedOrchestratorService\n  ) {}\n\n  async planField(tableId: string, fieldId: string) {\n    return await this.graphService.planField(tableId, fieldId);\n  }\n\n  private isFieldReferenceCompatibilityError(\n    error: unknown\n  ): error is FieldReferenceCompatibilityException {\n    return error instanceof FieldReferenceCompatibilityException;\n  }\n\n  async planFieldCreate(tableId: string, fieldRo: IFieldRo) {\n    return await this.graphService.planFieldCreate(tableId, fieldRo);\n  }\n\n  // need add delete relative check\n  async planFieldConvert(tableId: string, fieldId: string, updateFieldRo: IConvertFieldRo) {\n    return await this.graphService.planFieldConvert(tableId, fieldId, updateFieldRo);\n  }\n\n  async planDeleteField(tableId: string, fieldId: string) {\n    return await this.graphService.planDeleteField(tableId, fieldId);\n  }\n\n  async getDeleteFieldReferences(\n    tableId: string,\n    fieldIds: string[]\n  ): Promise<Record<string, IFieldDeleteReferencesItem>> {\n    const [viewRefMap, depFieldMap] = await Promise.all([\n      this.getReferencedViewsPerField(tableId, fieldIds),\n      this.getDependentFieldsPerField(tableId, fieldIds),\n    ]);\n\n    const emptyWorkflowNodes: IFieldDeleteReferencesItem['workflowNodes'] = [];\n    const emptyRoles: IFieldDeleteReferencesItem['authorityMatrixRoles'] = [];\n\n    const result: Record<string, IFieldDeleteReferencesItem> = {};\n    for (const fieldId of fieldIds) {\n      result[fieldId] = {\n        workflowNodes: emptyWorkflowNodes,\n        authorityMatrixRoles: emptyRoles,\n        views: viewRefMap.get(fieldId) ?? [],\n        dependentFields: depFieldMap.get(fieldId) ?? [],\n      };\n    }\n    return result;\n  }\n\n  private async getReferencedViewsPerField(tableId: string, fieldIds: string[]) {\n    const [views, tableMeta] = await Promise.all([\n      this.prismaService.view.findMany({\n        where: { tableId, deletedTime: null },\n        select: {\n          id: true,\n          name: true,\n          type: true,\n          filter: true,\n          sort: true,\n          group: true,\n          options: true,\n        },\n      }),\n      this.prismaService.tableMeta.findFirst({\n        where: { id: tableId },\n        select: { id: true, name: true, icon: true, baseId: true },\n      }),\n    ]);\n\n    const result = new Map<string, IFieldDeleteRefView[]>();\n    if (!tableMeta) {\n      return result;\n    }\n\n    const base = await this.prismaService.base.findFirst({\n      where: { id: tableMeta.baseId },\n      select: { id: true, name: true, icon: true },\n    });\n    if (!base) {\n      return result;\n    }\n\n    const source: IFieldDeleteRefTableSource = {\n      id: tableMeta.id,\n      name: tableMeta.name,\n      icon: tableMeta.icon,\n      base: {\n        id: base.id,\n        name: base.name,\n        icon: base.icon,\n      },\n    };\n\n    for (const fieldId of fieldIds) {\n      const matched: IFieldDeleteRefView[] = [];\n      for (const view of views) {\n        if (this.viewReferencesField(view, fieldId)) {\n          matched.push({ id: view.id, name: view.name, type: view.type, source });\n        }\n      }\n      if (matched.length > 0) {\n        result.set(fieldId, matched);\n      }\n    }\n\n    return result;\n  }\n\n  private viewReferencesField(\n    view: {\n      filter: string | null;\n      sort: string | null;\n      group: string | null;\n      options: string | null;\n      type: string;\n    },\n    fieldId: string\n  ): boolean {\n    const filter = this.parseJsonOptions<IFilter>(view.filter);\n    if (filter) {\n      try {\n        const filterRefs = extractFieldIdsFromFilter(filter, true);\n        if (filterRefs.includes(fieldId)) {\n          return true;\n        }\n      } catch {\n        // Ignore malformed historical filter payloads and keep scanning other view properties.\n      }\n    }\n\n    const sort = this.parseJsonOptions<ISort>(view.sort);\n    if (sort?.sortObjs?.some((s) => s.fieldId === fieldId)) {\n      return true;\n    }\n\n    const group = this.parseJsonOptions<IGroup>(view.group);\n    if (Array.isArray(group) && group.some((g) => g.fieldId === fieldId)) {\n      return true;\n    }\n\n    const optionFieldIds = this.extractViewOptionFieldIds(view.type, view.options);\n    if (optionFieldIds.has(fieldId)) {\n      return true;\n    }\n\n    return false;\n  }\n\n  private extractViewOptionFieldIds(viewType: string, rawOptions: string | null): Set<string> {\n    const fieldIds = new Set<string>();\n    const addFieldId = (value?: string | null) => value && fieldIds.add(value);\n\n    switch (viewType) {\n      case ViewType.Grid: {\n        const options = this.parseJsonOptions<IGridViewOptions>(rawOptions);\n        addFieldId(options?.frozenFieldId);\n        break;\n      }\n      case ViewType.Kanban: {\n        const options = this.parseJsonOptions<IKanbanViewOptions>(rawOptions);\n        addFieldId(options?.stackFieldId);\n        addFieldId(options?.coverFieldId);\n        break;\n      }\n      case ViewType.Gallery: {\n        const options = this.parseJsonOptions<IGalleryViewOptions>(rawOptions);\n        addFieldId(options?.coverFieldId);\n        break;\n      }\n      case ViewType.Calendar: {\n        const options = this.parseJsonOptions<ICalendarViewOptions>(rawOptions);\n        addFieldId(options?.startDateFieldId);\n        addFieldId(options?.endDateFieldId);\n        addFieldId(options?.titleFieldId);\n        if (options?.colorConfig?.type === ColorConfigType.Field) {\n          addFieldId(options.colorConfig.fieldId);\n        }\n        break;\n      }\n      default:\n        break;\n    }\n\n    return fieldIds;\n  }\n\n  private parseJsonOptions<T>(raw: string | null): T | null {\n    if (!raw) return null;\n    try {\n      return JSON.parse(raw) as T;\n    } catch {\n      return null;\n    }\n  }\n\n  private createDependentFieldAdder(\n    context: FieldDeleteDependencyContext,\n    depMap: Map<string, Set<string>>\n  ) {\n    return (fromFieldId: string, toFieldId: string) => {\n      const { sourceFieldIdSet, deletingFieldIdSet } = context;\n      if (!sourceFieldIdSet.has(fromFieldId) || deletingFieldIdSet.has(toFieldId)) {\n        return;\n      }\n\n      let depSet = depMap.get(fromFieldId);\n      if (!depSet) {\n        depSet = new Set();\n        depMap.set(fromFieldId, depSet);\n      }\n      depSet.add(toFieldId);\n    };\n  }\n\n  private async buildFieldDeleteDependencyContext(\n    tableId: string,\n    fieldIds: string[]\n  ): Promise<FieldDeleteDependencyContext | null> {\n    // Build a normalized context once so each dependency collector can stay focused.\n    const currentTableFields = await this.prismaService.field.findMany({\n      where: { tableId, deletedTime: null },\n      select: { id: true, type: true, options: true },\n    });\n\n    const currentTableFieldIds = currentTableFields.map((f) => f.id);\n    const currentTableFieldIdSet = new Set(currentTableFieldIds);\n    const sourceFieldIdSet = new Set(fieldIds.filter((id) => currentTableFieldIdSet.has(id)));\n    const sourceFieldIds = [...sourceFieldIdSet];\n\n    if (sourceFieldIds.length === 0) {\n      return null;\n    }\n\n    return {\n      tableId,\n      sourceFieldIds,\n      sourceFieldIdSet,\n      deletingFieldIdSet: new Set(fieldIds),\n      currentTableFields,\n      currentTableFieldIds,\n      currentTableFieldIdSet,\n    };\n  }\n\n  private async collectDirectAndExternalCandidates(\n    context: FieldDeleteDependencyContext,\n    addDep: (fromFieldId: string, toFieldId: string) => void\n  ) {\n    // A single reference scan gives us both:\n    // 1) direct dependencies from deleting fields, and\n    // 2) external link field candidates that may display those deleting fields.\n    const references = await this.prismaService.reference.findMany({\n      where: {\n        fromFieldId: { in: context.currentTableFieldIds },\n        OR: [\n          { fromFieldId: { in: context.sourceFieldIds } },\n          { toFieldId: { notIn: context.currentTableFieldIds } },\n        ],\n      },\n      select: { fromFieldId: true, toFieldId: true },\n    });\n\n    const externalCandidateIdSet = new Set<string>();\n    for (const ref of references) {\n      if (context.sourceFieldIdSet.has(ref.fromFieldId)) {\n        addDep(ref.fromFieldId, ref.toFieldId);\n      }\n      if (!context.currentTableFieldIdSet.has(ref.toFieldId)) {\n        externalCandidateIdSet.add(ref.toFieldId);\n      }\n    }\n\n    return [...externalCandidateIdSet];\n  }\n\n  private collectSymmetricLinkDependencies(\n    context: FieldDeleteDependencyContext,\n    addDep: (fromFieldId: string, toFieldId: string) => void\n  ) {\n    for (const sourceField of context.currentTableFields) {\n      if (!context.sourceFieldIdSet.has(sourceField.id) || sourceField.type !== FieldType.Link) {\n        continue;\n      }\n      const options = this.parseJsonOptions<{ symmetricFieldId?: string }>(sourceField.options);\n      if (options?.symmetricFieldId) {\n        addDep(sourceField.id, options.symmetricFieldId);\n      }\n    }\n  }\n\n  private async collectExternalLinkDisplayDependencies(\n    context: FieldDeleteDependencyContext,\n    externalCandidateIds: string[],\n    addDep: (fromFieldId: string, toFieldId: string) => void\n  ) {\n    if (externalCandidateIds.length === 0) {\n      return;\n    }\n\n    const externalLinkFields = await this.prismaService.field.findMany({\n      where: { id: { in: externalCandidateIds }, type: FieldType.Link, deletedTime: null },\n      select: { id: true, options: true },\n    });\n\n    for (const linkField of externalLinkFields) {\n      const options = this.parseJsonOptions<LinkReferenceOptions>(linkField.options);\n      if (options?.foreignTableId !== context.tableId) continue;\n\n      // One-way link still writes reference edges to the host link field.\n      // We use those candidates here, then inspect lookup/visible config to find exact dependencies.\n      if (options.lookupFieldId && context.sourceFieldIdSet.has(options.lookupFieldId)) {\n        addDep(options.lookupFieldId, linkField.id);\n      }\n\n      if (!options.visibleFieldIds?.length) continue;\n      for (const visibleFieldId of options.visibleFieldIds) {\n        if (context.sourceFieldIdSet.has(visibleFieldId)) {\n          addDep(visibleFieldId, linkField.id);\n        }\n      }\n    }\n  }\n\n  private async hydrateDependentFieldInfos(perFieldDepIds: Map<string, Set<string>>) {\n    // Resolve collected dependency ids into user-facing metadata in one batch.\n    const allDepIds = [...new Set([...perFieldDepIds.values()].flatMap((ids) => [...ids]))];\n    if (allDepIds.length === 0) {\n      return new Map<string, IFieldDeleteRefDependentField[]>();\n    }\n\n    const fields = await this.prismaService.field.findMany({\n      where: { id: { in: allDepIds }, deletedTime: null },\n      select: { id: true, name: true, type: true, tableId: true },\n    });\n\n    const tableIds = [...new Set(fields.map((f) => f.tableId))];\n    const tableSourceMap = await this.buildTableSourceMap(tableIds);\n    const fieldInfoMap = new Map(\n      fields.map((field) => [\n        field.id,\n        {\n          id: field.id,\n          name: field.name,\n          type: field.type,\n          source: tableSourceMap.get(field.tableId),\n        },\n      ])\n    );\n\n    const result = new Map<string, IFieldDeleteRefDependentField[]>();\n    for (const [fromFieldId, depIds] of perFieldDepIds) {\n      const items = [...depIds]\n        .map((depId) => fieldInfoMap.get(depId))\n        .filter((item): item is IFieldDeleteRefDependentField => Boolean(item?.source));\n      if (items.length > 0) {\n        result.set(fromFieldId, items);\n      }\n    }\n\n    return result;\n  }\n\n  private async getDependentFieldsPerField(tableId: string, fieldIds: string[]) {\n    // Orchestration only: build context -> collect dependency edges -> hydrate field info.\n    const context = await this.buildFieldDeleteDependencyContext(tableId, fieldIds);\n    if (!context) {\n      return new Map<string, IFieldDeleteRefDependentField[]>();\n    }\n\n    const perFieldDepIds = new Map<string, Set<string>>();\n    const addDep = this.createDependentFieldAdder(context, perFieldDepIds);\n    const externalCandidateIds = await this.collectDirectAndExternalCandidates(context, addDep);\n    this.collectSymmetricLinkDependencies(context, addDep);\n    await this.collectExternalLinkDisplayDependencies(context, externalCandidateIds, addDep);\n    return await this.hydrateDependentFieldInfos(perFieldDepIds);\n  }\n\n  private async buildTableSourceMap(tableIds: string[]) {\n    if (tableIds.length === 0) {\n      return new Map<string, IFieldDeleteRefTableSource>();\n    }\n\n    const tables = await this.prismaService.tableMeta.findMany({\n      where: { id: { in: tableIds } },\n      select: { id: true, name: true, icon: true, baseId: true },\n    });\n\n    const baseIds = [...new Set(tables.map((table) => table.baseId))];\n    const bases = await this.prismaService.base.findMany({\n      where: { id: { in: baseIds } },\n      select: { id: true, name: true, icon: true },\n    });\n    const baseMap = new Map(bases.map((base) => [base.id, base]));\n\n    const tableSourceMap = new Map<string, IFieldDeleteRefTableSource>();\n    for (const table of tables) {\n      const base = baseMap.get(table.baseId);\n      if (!base) {\n        continue;\n      }\n      tableSourceMap.set(table.id, {\n        id: table.id,\n        name: table.name,\n        icon: table.icon,\n        base: {\n          id: base.id,\n          name: base.name,\n          icon: base.icon,\n        },\n      });\n    }\n    return tableSourceMap;\n  }\n\n  async getFields(tableId: string, query: IGetFieldsQuery) {\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      ...query,\n      filterHidden: query.filterHidden == null ? true : query.filterHidden,\n    });\n\n    return fields.map((field) => {\n      if (field.isMultipleCellValue !== false) {\n        return field;\n      }\n\n      const normalized = { ...field } as IFieldVo & Record<string, unknown>;\n      delete normalized.isMultipleCellValue;\n      return normalized as IFieldVo;\n    });\n  }\n\n  private async validateLookupField(field: IFieldInstance) {\n    if (field.lookupOptions && isLinkLookupOptions(field.lookupOptions)) {\n      const { foreignTableId, lookupFieldId, linkFieldId } = field.lookupOptions;\n      const foreignField = await this.prismaService.txClient().field.findFirst({\n        where: { tableId: foreignTableId, id: lookupFieldId, deletedTime: null },\n        select: { id: true },\n      });\n\n      if (!foreignField) {\n        return false;\n      }\n      const linkField = await this.prismaService.txClient().field.findFirst({\n        where: { id: linkFieldId, deletedTime: null },\n        select: { id: true, options: true, type: true, isLookup: true },\n      });\n      if (!linkField || linkField.type !== FieldType.Link || linkField.isLookup) {\n        return false;\n      }\n      const linkOptions = JSON.parse(linkField?.options as string) as ILinkFieldOptions;\n      return linkOptions.foreignTableId === foreignTableId;\n    }\n    return true;\n  }\n\n  private normalizeCellValueType(rawCellType: unknown): CellValueType {\n    if (\n      typeof rawCellType === 'string' &&\n      Object.values(CellValueType).includes(rawCellType as CellValueType)\n    ) {\n      return rawCellType as CellValueType;\n    }\n    return CellValueType.String;\n  }\n\n  private async isRollupAggregationSupported(params: {\n    expression?: IRollupFieldOptions['expression'];\n    lookupFieldId?: string;\n    foreignTableId?: string;\n  }): Promise<boolean> {\n    const { expression, lookupFieldId, foreignTableId } = params;\n\n    if (!expression || !lookupFieldId || !foreignTableId) {\n      return false;\n    }\n\n    const foreignField = await this.prismaService.txClient().field.findFirst({\n      where: { id: lookupFieldId, tableId: foreignTableId, deletedTime: null },\n      select: { cellValueType: true },\n    });\n\n    if (!foreignField?.cellValueType) {\n      return false;\n    }\n\n    const cellValueType = this.normalizeCellValueType(foreignField.cellValueType);\n    return isRollupFunctionSupportedForCellValueType(expression, cellValueType);\n  }\n\n  private async validateRollupAggregation(field: IFieldInstance): Promise<boolean> {\n    if (!field.lookupOptions || !isLinkLookupOptions(field.lookupOptions)) {\n      return false;\n    }\n\n    const options = field.options as IRollupFieldOptions | undefined;\n    return this.isRollupAggregationSupported({\n      expression: options?.expression,\n      lookupFieldId: field.lookupOptions.lookupFieldId,\n      foreignTableId: field.lookupOptions.foreignTableId,\n    });\n  }\n\n  private async validateConditionalRollupAggregation(hostTableId: string, field: IFieldInstance) {\n    const options = field.options as IConditionalRollupFieldOptions | undefined;\n    const expression = options?.expression;\n    const lookupFieldId = options?.lookupFieldId;\n    const foreignTableId = options?.foreignTableId;\n\n    const aggregationSupported = await this.isRollupAggregationSupported({\n      expression,\n      lookupFieldId,\n      foreignTableId,\n    });\n    if (!aggregationSupported) {\n      return false;\n    }\n\n    if (!foreignTableId) {\n      return false;\n    }\n\n    return await this.validateFilterFieldReferences(hostTableId, foreignTableId, options?.filter);\n  }\n\n  private async validateConditionalLookup(tableId: string, field: IFieldInstance) {\n    const meta = field.getConditionalLookupOptions?.();\n    const lookupFieldId = meta?.lookupFieldId;\n    const foreignTableId = meta?.foreignTableId;\n\n    if (!lookupFieldId || !foreignTableId) {\n      return false;\n    }\n\n    const foreignField = await this.prismaService.txClient().field.findFirst({\n      where: { id: lookupFieldId, tableId: foreignTableId, deletedTime: null },\n      select: { id: true, type: true },\n    });\n\n    if (!foreignField) {\n      return false;\n    }\n\n    if (foreignField.type !== field.type) {\n      return false;\n    }\n\n    return await this.validateFilterFieldReferences(tableId, foreignTableId, meta?.filter);\n  }\n\n  private async isFieldConfigurationValid(\n    tableId: string,\n    field: IFieldInstance\n  ): Promise<boolean> {\n    if (\n      field.lookupOptions &&\n      field.type !== FieldType.ConditionalRollup &&\n      !field.isConditionalLookup\n    ) {\n      const lookupValid = await this.validateLookupField(field);\n      if (!lookupValid) {\n        return false;\n      }\n\n      if (field.type === FieldType.Rollup) {\n        return await this.validateRollupAggregation(field);\n      }\n\n      return true;\n    }\n\n    if (field.isConditionalLookup) {\n      return await this.validateConditionalLookup(tableId, field);\n    }\n\n    if (field.type === FieldType.ConditionalRollup) {\n      return await this.validateConditionalRollupAggregation(tableId, field);\n    }\n\n    return true;\n  }\n\n  private async findConditionalFilterDependentFields(startFieldIds: readonly string[]): Promise<\n    Array<{\n      id: string;\n      tableId: string;\n      type: string;\n      options: string | null;\n      lookupOptions: string | null;\n      isConditionalLookup: boolean;\n    }>\n  > {\n    if (!startFieldIds.length) {\n      return [];\n    }\n\n    const nonRecursive = this.knex\n      .select('from_field_id', 'to_field_id')\n      .from('reference')\n      .whereIn('from_field_id', startFieldIds);\n\n    const recursive = this.knex\n      .select({ from_field_id: 'r.from_field_id', to_field_id: 'r.to_field_id' })\n      .from({ r: 'reference' })\n      .join({ d: 'dep' }, 'r.from_field_id', 'd.to_field_id');\n\n    const query = this.knex\n      .withRecursive('dep', ['from_field_id', 'to_field_id'], nonRecursive.union(recursive))\n      .select({\n        id: 'f.id',\n        table_id: 'f.table_id',\n        type: 'f.type',\n        options: 'f.options',\n        lookup_options: 'f.lookup_options',\n        is_conditional_lookup: 'f.is_conditional_lookup',\n      })\n      .from({ dep: 'dep' })\n      .join({ f: 'field' }, 'dep.to_field_id', 'f.id')\n      .whereNull('f.deleted_time')\n      .andWhere((qb) =>\n        qb.where('f.type', FieldType.ConditionalRollup).orWhere('f.is_conditional_lookup', true)\n      )\n      .distinct();\n\n    const rows = await this.prismaService.txClient().$queryRawUnsafe<\n      Array<{\n        id: string;\n        table_id: string;\n        type: string;\n        options: string | null;\n        lookup_options: string | null;\n        is_conditional_lookup: number | boolean | null;\n      }>\n    >(query.toQuery());\n\n    return rows.map((row) => ({\n      id: row.id,\n      tableId: row.table_id,\n      type: row.type,\n      options: row.options,\n      lookupOptions: row.lookup_options,\n      isConditionalLookup: Boolean(row.is_conditional_lookup),\n    }));\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async syncConditionalFiltersByFieldChanges(\n    newField: IFieldInstance,\n    oldField: IFieldInstance\n  ) {\n    const fieldId = newField.id;\n    if (!fieldId) {\n      return;\n    }\n\n    const selectTypes = new Set([FieldType.SingleSelect, FieldType.MultipleSelect]);\n    if (newField.type !== oldField.type || !selectTypes.has(newField.type)) {\n      return;\n    }\n\n    const dependents = await this.findConditionalFilterDependentFields([fieldId]);\n    if (!dependents.length) {\n      return;\n    }\n\n    const pendingOps: Record<string, { fieldId: string; ops: IOtOperation[] }[]> = {};\n    const enqueueFieldOps = (tableId: string, fieldId: string, ops: IOtOperation[]) => {\n      if (!ops.length) return;\n      (pendingOps[tableId] ||= []).push({ fieldId, ops });\n    };\n    const normalizeFilter = (filter: IFilter | null | undefined) =>\n      filter && filter.filterSet?.length ? filter : null;\n\n    for (const field of dependents) {\n      if (field.type === FieldType.ConditionalRollup) {\n        if (!field.options) continue;\n        let options: IConditionalRollupFieldOptions;\n        try {\n          options = JSON.parse(field.options) as IConditionalRollupFieldOptions;\n        } catch {\n          continue;\n        }\n\n        const originalFilter = options.filter;\n        if (!originalFilter) continue;\n        const filterRefs = extractFieldIdsFromFilter(originalFilter, true);\n        if (!filterRefs.includes(fieldId)) continue;\n\n        const updatedFilter = this.fieldViewSyncService.getNewFilterByFieldChanges(\n          originalFilter,\n          newField,\n          oldField\n        );\n        const normalizedOriginal = normalizeFilter(originalFilter);\n        const normalizedUpdated = normalizeFilter(updatedFilter);\n\n        if (isEqual(normalizedOriginal, normalizedUpdated)) continue;\n\n        const ops = [\n          FieldOpBuilder.editor.setFieldProperty.build({\n            key: 'options',\n            oldValue: options,\n            newValue: { ...options, filter: normalizedUpdated },\n          }),\n        ];\n        enqueueFieldOps(field.tableId, field.id, ops);\n        continue;\n      }\n\n      if (!field.isConditionalLookup) continue;\n      if (!field.lookupOptions) continue;\n\n      let lookupOptions: IConditionalLookupOptions;\n      try {\n        lookupOptions = JSON.parse(field.lookupOptions) as IConditionalLookupOptions;\n      } catch {\n        continue;\n      }\n\n      const originalFilter = lookupOptions.filter;\n      if (!originalFilter) continue;\n      const filterRefs = extractFieldIdsFromFilter(originalFilter, true);\n      if (!filterRefs.includes(fieldId)) continue;\n\n      const updatedFilter = this.fieldViewSyncService.getNewFilterByFieldChanges(\n        originalFilter,\n        newField,\n        oldField\n      );\n      const normalizedOriginal = normalizeFilter(originalFilter);\n      const normalizedUpdated = normalizeFilter(updatedFilter);\n\n      if (isEqual(normalizedOriginal, normalizedUpdated)) continue;\n\n      const ops = [\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'lookupOptions',\n          oldValue: lookupOptions,\n          newValue: { ...lookupOptions, filter: normalizedUpdated },\n        }),\n      ];\n      enqueueFieldOps(field.tableId, field.id, ops);\n    }\n\n    for (const [targetTableId, ops] of Object.entries(pendingOps)) {\n      await this.fieldService.batchUpdateFields(targetTableId, ops);\n    }\n  }\n\n  private async validateFilterFieldReferences(\n    hostTableId: string,\n    foreignTableId: string,\n    filter?: IFilter | null\n  ): Promise<boolean> {\n    if (!filter) {\n      return true;\n    }\n\n    const foreignFieldIds = new Set<string>();\n    const referenceFieldIds = new Set<string>();\n\n    const collectFieldIds = (node: IFilter | IFilterItem) => {\n      if (!node) {\n        return;\n      }\n\n      if ('fieldId' in node) {\n        foreignFieldIds.add(node.fieldId);\n\n        const { value } = node;\n        if (isFieldReferenceValue(value)) {\n          referenceFieldIds.add(value.fieldId);\n        } else if (Array.isArray(value)) {\n          for (const entry of value) {\n            if (isFieldReferenceValue(entry)) {\n              referenceFieldIds.add(entry.fieldId);\n            }\n          }\n        }\n      } else if ('filterSet' in node) {\n        node.filterSet.forEach((child) => collectFieldIds(child));\n      }\n    };\n\n    collectFieldIds(filter);\n\n    if (!referenceFieldIds.size) {\n      return true;\n    }\n\n    const fieldIdsToFetch = Array.from(new Set([...foreignFieldIds, ...referenceFieldIds]));\n    if (!fieldIdsToFetch.length) {\n      return true;\n    }\n\n    const rawFields = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fieldIdsToFetch }, deletedTime: null },\n    });\n\n    const instanceMap = new Map<string, IFieldInstance>();\n    const hostFields = new Map<string, IFieldInstance>();\n    const foreignFields = new Map<string, IFieldInstance>();\n\n    for (const raw of rawFields) {\n      const instance = createFieldInstanceByRaw(raw);\n      instanceMap.set(raw.id, instance);\n\n      if (raw.tableId === hostTableId) {\n        hostFields.set(raw.id, instance);\n      }\n\n      if (raw.tableId === foreignTableId) {\n        foreignFields.set(raw.id, instance);\n      }\n    }\n\n    const resolveReferenceField = (reference: IFieldReferenceValue): IFieldInstance | undefined => {\n      if (reference.tableId) {\n        if (reference.tableId === hostTableId) {\n          return hostFields.get(reference.fieldId);\n        }\n        if (reference.tableId === foreignTableId) {\n          return foreignFields.get(reference.fieldId);\n        }\n      }\n\n      return (\n        hostFields.get(reference.fieldId) ??\n        foreignFields.get(reference.fieldId) ??\n        instanceMap.get(reference.fieldId)\n      );\n    };\n\n    // eslint-disable-next-line sonarjs/cognitive-complexity\n    const validateNode = (node: IFilter | IFilterItem): boolean => {\n      if (!node) {\n        return true;\n      }\n\n      if ('fieldId' in node) {\n        const baseField = foreignFields.get(node.fieldId) ?? instanceMap.get(node.fieldId);\n        if (!baseField) {\n          return false;\n        }\n\n        const references: IFieldReferenceValue[] = [];\n        const { value } = node;\n\n        if (isFieldReferenceValue(value)) {\n          references.push(value);\n        } else if (Array.isArray(value)) {\n          for (const entry of value) {\n            if (isFieldReferenceValue(entry)) {\n              references.push(entry);\n            }\n          }\n        }\n\n        return references.every((reference) => {\n          const referenceField = resolveReferenceField(reference);\n          if (!referenceField) {\n            return false;\n          }\n          return isFieldReferenceComparable(baseField, referenceField);\n        });\n      }\n\n      if ('filterSet' in node) {\n        return node.filterSet.every((child) => validateNode(child));\n      }\n\n      return true;\n    };\n\n    return validateNode(filter);\n  }\n\n  private async markError(tableId: string, field: IFieldInstance, hasError: boolean) {\n    if (hasError) {\n      if (!field.hasError) {\n        await this.fieldService.markError(tableId, [field.id], true);\n      }\n    } else {\n      if (field.hasError) {\n        await this.fieldService.markError(tableId, [field.id], false);\n      }\n    }\n  }\n\n  private async checkAndUpdateError(tableId: string, field: IFieldInstance) {\n    const fieldReferenceIds = this.fieldSupplementService.getFieldReferenceIds(field);\n    // Deduplicate field IDs since the same field can appear multiple times\n    // (e.g., as lookupFieldId and in filter)\n    const uniqueFieldReferenceIds = [...new Set(fieldReferenceIds)];\n\n    const refFields = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: uniqueFieldReferenceIds }, deletedTime: null },\n      select: { id: true },\n    });\n\n    if (refFields.length !== uniqueFieldReferenceIds.length) {\n      await this.markError(tableId, field, true);\n      return;\n    }\n\n    const curReference = await this.prismaService.txClient().reference.findMany({\n      where: {\n        toFieldId: field.id,\n      },\n    });\n    const missingReferenceIds = uniqueFieldReferenceIds.filter(\n      (refId) => !curReference.find((ref) => ref.fromFieldId === refId)\n    );\n\n    if (missingReferenceIds.length) {\n      await this.prismaService.txClient().reference.createMany({\n        data: missingReferenceIds.map((fromFieldId) => ({\n          fromFieldId,\n          toFieldId: field.id,\n        })),\n        skipDuplicates: true,\n      });\n    }\n\n    const isValid = await this.isFieldConfigurationValid(tableId, field);\n    await this.markError(tableId, field, !isValid);\n  }\n\n  async restoreReference(references: string[]) {\n    const fieldRaws = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: references }, deletedTime: null },\n    });\n\n    for (const refFieldRaw of fieldRaws) {\n      const refField = createFieldInstanceByRaw(refFieldRaw);\n      await this.checkAndUpdateError(refFieldRaw.tableId, refField);\n    }\n  }\n\n  private sortCreateFieldsByDependencies<\n    T extends IFieldVo & { columnMeta?: IColumnMeta; references?: string[] },\n  >(tableId: string, fields: T[]): T[] {\n    if (!fields.length) return fields;\n\n    const idSet = new Set(fields.map((f) => f.id));\n    const originalIndex = fields.reduce<Record<string, number>>((acc, field, index) => {\n      acc[field.id] = index;\n      return acc;\n    }, {});\n\n    const depsByFieldId = new Map<string, string[]>();\n    for (const field of fields) {\n      const { columnMeta: _columnMeta, references: _references, ...fieldVo } = field;\n      try {\n        const instance = createFieldInstanceByVo(fieldVo);\n        const deps = this.fieldSupplementService\n          .getFieldReferenceIds(instance)\n          .filter((id): id is string => typeof id === 'string' && idSet.has(id) && id !== field.id);\n        depsByFieldId.set(field.id, deps);\n      } catch (e) {\n        this.logger.warn(\n          `createFields: failed to resolve dependencies for ${field.id} in ${tableId}: ${String(e)}`\n        );\n        return fields;\n      }\n    }\n\n    const indegree = new Map<string, number>();\n    const outgoing = new Map<string, string[]>();\n    for (const field of fields) {\n      indegree.set(field.id, 0);\n      outgoing.set(field.id, []);\n    }\n\n    for (const field of fields) {\n      const deps = depsByFieldId.get(field.id) ?? [];\n      for (const depId of deps) {\n        outgoing.get(depId)?.push(field.id);\n        indegree.set(field.id, (indegree.get(field.id) ?? 0) + 1);\n      }\n    }\n\n    const ready: string[] = [];\n    for (const field of fields) {\n      if ((indegree.get(field.id) ?? 0) === 0) ready.push(field.id);\n    }\n    ready.sort((a, b) => (originalIndex[a] ?? 0) - (originalIndex[b] ?? 0));\n\n    const orderedIds: string[] = [];\n    while (ready.length) {\n      const current = ready.shift()!;\n      orderedIds.push(current);\n      for (const next of outgoing.get(current) ?? []) {\n        const nextDegree = (indegree.get(next) ?? 0) - 1;\n        indegree.set(next, nextDegree);\n        if (nextDegree === 0) {\n          ready.push(next);\n          ready.sort((a, b) => (originalIndex[a] ?? 0) - (originalIndex[b] ?? 0));\n        }\n      }\n    }\n\n    if (orderedIds.length !== fields.length) {\n      this.logger.warn(\n        `createFields: detected a dependency cycle in ${tableId}; falling back to input order`\n      );\n      return fields;\n    }\n\n    const byId = new Map(fields.map((f) => [f.id, f] as const));\n    return orderedIds.map((id) => byId.get(id)!).filter(Boolean);\n  }\n\n  @Timing()\n  async createFields(\n    tableId: string,\n    fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[]\n  ) {\n    if (!fields.length) return;\n\n    const orderedFields = this.sortCreateFieldsByDependencies(tableId, fields);\n\n    // Create fields and compute/publish record changes within the same transaction\n    const createdFields = await this.prismaService.$tx(\n      async () => {\n        const created: { tableId: string; field: IFieldInstance }[] = [];\n        const sourceEntries: Array<{ tableId: string; fieldIds: string[] }> = [];\n        const referencesToRestore = new Set<string>();\n        const pendingByTable = new Map<string, Set<string>>();\n\n        const addSourceField = (tid: string, fieldId: string) => {\n          let entry = sourceEntries.find((s) => s.tableId === tid);\n          if (!entry) {\n            entry = { tableId: tid, fieldIds: [] };\n            sourceEntries.push(entry);\n          }\n          if (!entry.fieldIds.includes(fieldId)) {\n            entry.fieldIds.push(fieldId);\n          }\n        };\n\n        const markPending = (tid: string, fieldId: string) => {\n          let set = pendingByTable.get(tid);\n          if (!set) {\n            set = new Set<string>();\n            pendingByTable.set(tid, set);\n          }\n          set.add(fieldId);\n        };\n\n        const createPayload = orderedFields.map((field) => {\n          const { columnMeta, references, ...fieldVo } = field;\n          if (references?.length) {\n            references.forEach((refId) => referencesToRestore.add(refId));\n          }\n\n          return {\n            field: createFieldInstanceByVo(fieldVo),\n            columnMeta: columnMeta as unknown as Record<string, IColumn>,\n          };\n        });\n\n        await this.computedOrchestrator.computeCellChangesForFieldsAfterCreate(\n          sourceEntries,\n          async () => {\n            const createResult = await this.fieldCreatingService.alterCreateFieldsInExistingTable(\n              tableId,\n              createPayload\n            );\n            created.push(...createResult);\n\n            for (const { tableId: tid, field } of createResult) {\n              addSourceField(tid, field.id);\n              if (field.isComputed) {\n                markPending(tid, field.id);\n              }\n            }\n\n            if (referencesToRestore.size) {\n              await this.restoreReference(Array.from(referencesToRestore));\n            }\n\n            const skipComputation = this.cls.get('skipFieldComputation');\n\n            if (!skipComputation) {\n              // Ensure dependent formula generated columns are recreated BEFORE\n              // evaluating and returning values in the computed pipeline.\n              // This avoids UPDATE ... RETURNING selecting non-existent generated columns\n              // right after restoring base fields.\n              const createdFieldIds = created\n                .filter((nf) => nf.tableId === tableId)\n                .map((nf) => nf.field.id);\n              if (createdFieldIds.length) {\n                try {\n                  await this.fieldService.recreateDependentFormulaColumns(tableId, createdFieldIds);\n                } catch (e) {\n                  this.logger.warn(\n                    `createFields: failed to recreate dependent formulas for ${tableId}: ${String(e)}`\n                  );\n                }\n              }\n            }\n\n            // Resolve pending computed fields in batches per table\n            for (const [tid, ids] of pendingByTable.entries()) {\n              const list = Array.from(ids);\n              if (list.length) {\n                await this.fieldService.resolvePending(tid, list);\n              }\n            }\n          }\n        );\n\n        return created;\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    // Recreate search indexes after schema changes (outside tx boundaries)\n    for (const { tableId: tid, field } of createdFields) {\n      await this.tableIndexService.createSearchFieldSingleIndex(tid, field);\n    }\n  }\n\n  @Timing()\n  async createFieldsByRo(tableId: string, fieldRos: IFieldRo[]): Promise<IFieldVo[]> {\n    if (!fieldRos.length) return [];\n    const fieldVos = await this.fieldSupplementService.prepareCreateFields(tableId, fieldRos);\n    await this.createFields(tableId, fieldVos);\n    return fieldVos;\n  }\n\n  private async getFieldReferenceMap(fieldIds: string[]) {\n    const referencesRaw = await this.prismaService.reference.findMany({\n      where: {\n        fromFieldId: { in: fieldIds },\n      },\n      select: {\n        fromFieldId: true,\n        toFieldId: true,\n      },\n    });\n    return groupBy(referencesRaw, 'fromFieldId');\n  }\n\n  async captureDeleteFieldsLegacyPayload(\n    tableId: string,\n    fieldIds: string[]\n  ): Promise<ILegacyDeleteFieldsPayloadSnapshot> {\n    return await this.prismaService.$tx(async () => {\n      const fieldRaws = await this.prismaService.txClient().field.findMany({\n        where: { tableId, id: { in: fieldIds }, deletedTime: null },\n      });\n      const fieldRawMap = new Map(fieldRaws.map((raw) => [raw.id, raw]));\n\n      if (fieldRawMap.size !== fieldIds.length) {\n        const notExistFieldId = fieldIds.find((id) => !fieldRawMap.has(id));\n        throw new NotFoundException(`Field ${notExistFieldId} not found`);\n      }\n\n      const fieldVos = fieldIds.map((id) => rawField2FieldObj(fieldRawMap.get(id)!));\n      const fieldInstances = fieldVos.map(createFieldInstanceByVo);\n      const nonComputedFields = fieldInstances.filter((field) => !field.isComputed);\n      const projection = nonComputedFields.map((field) => field.id);\n      const records =\n        projection.length === 0\n          ? undefined\n          : await this.recordService.getRecordsFields(\n              tableId,\n              {\n                projection,\n                fieldKeyType: FieldKeyType.Id,\n                take: -1,\n              },\n              true\n            );\n\n      const [columnsMeta, referenceMap] = await Promise.all([\n        this.viewService.getColumnsMetaMap(tableId, fieldIds),\n        this.getFieldReferenceMap(fieldIds),\n      ]);\n\n      return {\n        fields: fieldVos.map((field, i) => ({\n          ...field,\n          columnMeta: columnsMeta[i],\n          references: fieldIds.concat(referenceMap[field.id]?.map((ref) => ref.toFieldId) || []),\n        })),\n        records,\n      };\n    });\n  }\n\n  @Timing()\n  async createField(tableId: string, fieldRo: IFieldRo, windowId?: string) {\n    const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, fieldRo);\n    const fieldInstance = createFieldInstanceByVo(fieldVo);\n    const columnMeta = fieldRo.order && {\n      [fieldRo.order.viewId]: { order: fieldRo.order.orderIndex },\n    };\n    // Create field and compute/publish record changes within the same transaction\n    const newFields = await this.prismaService.$tx(\n      async () => {\n        let created: { tableId: string; field: IFieldInstance }[] = [];\n        const sourceEntries = [{ tableId, fieldIds: [fieldInstance.id] }];\n        await this.computedOrchestrator.computeCellChangesForFieldsAfterCreate(\n          sourceEntries,\n          async () => {\n            created = await this.fieldCreatingService.alterCreateField(\n              tableId,\n              fieldInstance,\n              columnMeta\n            );\n            for (const { tableId: tid, field } of created) {\n              let entry = sourceEntries.find((s) => s.tableId === tid);\n              if (!entry) {\n                entry = { tableId: tid, fieldIds: [] };\n                sourceEntries.push(entry);\n              }\n              if (!entry.fieldIds.includes(field.id)) {\n                entry.fieldIds.push(field.id);\n              }\n              if (field.isComputed) {\n                await this.fieldService.resolvePending(tid, [field.id]);\n              }\n            }\n          }\n        );\n        return created;\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    for (const { tableId: tid, field } of newFields) {\n      await this.tableIndexService.createSearchFieldSingleIndex(tid, field);\n    }\n\n    const referenceMap = await this.getFieldReferenceMap([fieldVo.id]);\n\n    // Prefer emitting a VO converted from the created instance so computed props (e.g. recordRead)\n    // are included consistently with snapshots.\n    const createdMain = newFields.find(\n      (nf) => nf.tableId === tableId && nf.field.id === fieldVo.id\n    );\n    const emitFieldVo = createdMain ? convertFieldInstanceToFieldVo(createdMain.field) : fieldVo;\n\n    this.eventEmitterService.emitAsync(Events.OPERATION_FIELDS_CREATE, {\n      windowId,\n      tableId,\n      userId: this.cls.get('user.id'),\n      fields: [\n        {\n          ...emitFieldVo,\n          columnMeta,\n          references: referenceMap[fieldVo.id]?.map((ref) => ref.toFieldId),\n        },\n      ],\n    });\n\n    return fieldVo;\n  }\n\n  @Timing()\n  async deleteFields(tableId: string, fieldIds: string[], windowId?: string) {\n    const { fields, fieldVos, columnsMeta, referenceMap, records } = await this.prismaService.$tx(\n      async () => {\n        const fieldRaws = await this.prismaService.txClient().field.findMany({\n          where: { tableId, id: { in: fieldIds }, deletedTime: null },\n        });\n        const fieldRawMap = new Map(fieldRaws.map((raw) => [raw.id, raw]));\n\n        if (fieldRawMap.size !== fieldIds.length) {\n          const notExistFieldId = fieldIds.find((id) => !fieldRawMap.has(id));\n          throw new NotFoundException(`Field ${notExistFieldId} not found`);\n        }\n\n        const fieldVoList = fieldIds.map((id) => rawField2FieldObj(fieldRawMap.get(id)!));\n        const fieldInstances = fieldVoList.map(createFieldInstanceByVo);\n\n        const nonComputedFields = fieldInstances.filter((field) => !field.isComputed);\n        const projection = nonComputedFields.map((field) => field.id);\n        const recordSnapshot =\n          projection.length === 0\n            ? undefined\n            : await this.recordService.getRecordsFields(\n                tableId,\n                {\n                  projection,\n                  fieldKeyType: FieldKeyType.Id,\n                  take: -1,\n                },\n                true\n              );\n\n        const columnMetaMap = await this.viewService.getColumnsMetaMap(tableId, fieldIds);\n        const refMap = await this.getFieldReferenceMap(fieldIds);\n\n        // Drop per-field search indexes inside the same transaction boundary\n        for (const field of fieldInstances) {\n          try {\n            await this.tableIndexService.deleteSearchFieldIndex(tableId, field);\n          } catch (e) {\n            this.logger.warn(`deleteFields: drop search index failed for ${field.id}: ${e}`);\n          }\n        }\n\n        const sources = [{ tableId, fieldIds: fieldInstances.map((f) => f.id) }];\n        await this.computedOrchestrator.computeCellChangesForFieldsBeforeDelete(\n          sources,\n          async () => {\n            await this.fieldViewSyncService.deleteDependenciesByFieldIds(\n              tableId,\n              fieldInstances.map((f) => f.id)\n            );\n            for (const field of fieldInstances) {\n              await this.fieldDeletingService.alterDeleteField(tableId, field);\n            }\n          }\n        );\n\n        return {\n          fields: fieldInstances,\n          fieldVos: fieldVoList,\n          columnsMeta: columnMetaMap,\n          referenceMap: refMap,\n          records: recordSnapshot,\n        };\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    this.eventEmitterService.emitAsync(Events.OPERATION_FIELDS_DELETE, {\n      operationId: generateOperationId(),\n      windowId,\n      tableId,\n      userId: this.cls.get('user.id'),\n      fields: fieldVos.map((field, i) => ({\n        ...field,\n        columnMeta: columnsMeta[i],\n        references: fieldIds.concat(referenceMap[field.id]?.map((ref) => ref.toFieldId) || []),\n      })),\n      records,\n    });\n\n    return fields;\n  }\n\n  async deleteField(tableId: string, fieldId: string, windowId?: string) {\n    await this.deleteFields(tableId, [fieldId], windowId);\n  }\n\n  private async updateUniqProperty(\n    tableId: string,\n    fieldId: string,\n    key: 'name' | 'dbFieldName',\n    value: string\n  ) {\n    const result = await this.prismaService.field\n      .findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n        select: { [key]: true },\n      })\n      .catch(() => {\n        throw new NotFoundException(`Field ${fieldId} not found`);\n      });\n\n    const hasDuplicated = await this.prismaService.field.findFirst({\n      where: { tableId, [key]: value, deletedTime: null },\n      select: { id: true },\n    });\n\n    if (hasDuplicated) {\n      throw new BadRequestException(`Field ${key} ${value} already exists`);\n    }\n\n    return FieldOpBuilder.editor.setFieldProperty.build({\n      key,\n      oldValue: result[key],\n      newValue: value,\n    });\n  }\n\n  async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) {\n    const ops: IOtOperation[] = [];\n    if (updateFieldRo.name) {\n      const op = await this.updateUniqProperty(tableId, fieldId, 'name', updateFieldRo.name);\n      ops.push(op);\n    }\n\n    if (updateFieldRo.dbFieldName) {\n      const op = await this.updateUniqProperty(\n        tableId,\n        fieldId,\n        'dbFieldName',\n        updateFieldRo.dbFieldName\n      );\n      const oldField = await this.prismaService.field.findFirstOrThrow({\n        where: {\n          id: fieldId,\n          deletedTime: null,\n        },\n        select: {\n          dbFieldName: true,\n          id: true,\n        },\n      });\n      // do not need in transaction, causing just index name\n      await this.tableIndexService.updateSearchFieldIndexName(tableId, oldField, {\n        id: oldField.id,\n        dbFieldName: updateFieldRo?.dbFieldName ?? oldField.dbFieldName,\n      });\n      ops.push(op);\n    }\n\n    if (updateFieldRo.description !== undefined) {\n      const { description } = await this.prismaService.field\n        .findFirstOrThrow({\n          where: { id: fieldId, deletedTime: null },\n          select: { description: true },\n        })\n        .catch(() => {\n          throw new NotFoundException(`Field ${fieldId} not found`);\n        });\n\n      ops.push(\n        FieldOpBuilder.editor.setFieldProperty.build({\n          key: 'description',\n          oldValue: description,\n          newValue: updateFieldRo.description,\n        })\n      );\n    }\n\n    await this.prismaService.$tx(async () => {\n      await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]);\n    });\n  }\n\n  async performConvertField({\n    tableId,\n    newField,\n    oldField,\n    modifiedOps,\n    supplementChange,\n    dependentFieldIds,\n  }: {\n    tableId: string;\n    newField: IFieldInstance;\n    oldField: IFieldInstance;\n    modifiedOps?: IOpsMap;\n    supplementChange?: {\n      tableId: string;\n      newField: IFieldInstance;\n      oldField: IFieldInstance;\n    };\n    dependentFieldIds?: string[];\n  }): Promise<{ compatibilityIssue: boolean }> {\n    let encounteredCompatibilityIssue = false;\n\n    const runStageCalculate = async (\n      targetTableId: string,\n      targetNewField: IFieldInstance,\n      targetOldField: IFieldInstance,\n      ops?: IOpsMap\n    ) => {\n      try {\n        await this.fieldConvertingService.stageCalculate(\n          targetTableId,\n          targetNewField,\n          targetOldField,\n          ops\n        );\n      } catch (error) {\n        if (this.isFieldReferenceCompatibilityError(error)) {\n          encounteredCompatibilityIssue = true;\n          return;\n        }\n\n        throw error;\n      }\n    };\n\n    const sourceMap = new Map<string, Set<string>>();\n    const shouldRecomputeSelf = this.fieldConvertingService.needCalculate(newField, oldField);\n    const addSource = (tid: string, fieldIds: string[]) => {\n      const set = sourceMap.get(tid) ?? new Set<string>();\n      fieldIds.forEach((id) => set.add(id));\n      sourceMap.set(tid, set);\n    };\n\n    if (shouldRecomputeSelf) {\n      addSource(tableId, [newField.id]);\n    }\n\n    if (dependentFieldIds?.length) {\n      const dependentFields = await this.prismaService.txClient().field.findMany({\n        where: { id: { in: dependentFieldIds }, deletedTime: null },\n        select: { id: true, tableId: true },\n      });\n      dependentFields\n        .filter(\n          ({ id, tableId: depTableId }) =>\n            shouldRecomputeSelf || id !== newField.id || depTableId !== tableId\n        )\n        .forEach(({ id, tableId: depTableId }) => addSource(depTableId, [id]));\n    }\n\n    if (supplementChange) {\n      addSource(supplementChange.tableId, [supplementChange.newField.id]);\n    }\n\n    const sources = Array.from(sourceMap.entries()).map(([tid, ids]) => ({\n      tableId: tid,\n      fieldIds: Array.from(ids),\n    }));\n    const hasSources = sources.length > 0;\n\n    // 1. stage close constraint\n    await this.fieldConvertingService.closeConstraint(tableId, newField, oldField);\n\n    // 2. stage alter + apply record changes and calculate field with computed publishing (atomic)\n    const runCompute = async () => {\n      // Update dependencies and schema first so evaluate() sees new schema\n      await this.fieldViewSyncService.convertDependenciesByFieldIds(tableId, newField, oldField);\n      await this.syncConditionalFiltersByFieldChanges(newField, oldField);\n      if (supplementChange) {\n        const { newField: sNew, oldField: sOld } = supplementChange;\n        await this.syncConditionalFiltersByFieldChanges(sNew, sOld);\n      }\n      await this.fieldConvertingService.deleteOrCreateSupplementLink(tableId, newField, oldField);\n      await this.fieldConvertingService.stageAlter(tableId, newField, oldField);\n      if (supplementChange) {\n        const { tableId: sTid, newField: sNew, oldField: sOld } = supplementChange;\n        await this.fieldConvertingService.stageAlter(sTid, sNew, sOld);\n      }\n\n      // Then apply record changes (base ops) prior to computed publishing\n      await runStageCalculate(tableId, newField, oldField, modifiedOps);\n      if (supplementChange) {\n        const { tableId: sTid, newField: sNew, oldField: sOld } = supplementChange;\n        await runStageCalculate(sTid, sNew, sOld);\n      }\n    };\n\n    if (hasSources) {\n      try {\n        await this.computedOrchestrator.computeCellChangesForFields(sources, runCompute);\n      } catch (error) {\n        if (this.isFieldReferenceCompatibilityError(error)) {\n          encounteredCompatibilityIssue = true;\n        } else {\n          throw error;\n        }\n      }\n    } else {\n      await runCompute();\n    }\n\n    // 4. stage supplement field constraint\n    await this.fieldConvertingService.alterFieldConstraint(tableId, newField, oldField);\n\n    // Persist values for a newly created symmetric link field (if any).\n    // When using tableCache for reads, link values must be materialized in the physical column.\n    try {\n      const newOpts = (newField.options || {}) as {\n        symmetricFieldId?: string;\n        foreignTableId?: string;\n      };\n      const oldOpts = (oldField.options || {}) as { symmetricFieldId?: string };\n      const createdSymmetricId =\n        newOpts.symmetricFieldId && newOpts.symmetricFieldId !== oldOpts.symmetricFieldId;\n      if (newField.type === FieldType.Link && createdSymmetricId && newOpts.foreignTableId) {\n        await this.computedOrchestrator.computeCellChangesForFieldsAfterCreate(\n          [\n            {\n              tableId: newOpts.foreignTableId,\n              fieldIds: [newOpts.symmetricFieldId!],\n            },\n          ],\n          async () => {\n            // no-op; field already created\n          }\n        );\n      }\n    } catch (e) {\n      this.logger.warn(`post-convert symmetric persist failed: ${String(e)}`);\n    }\n\n    return { compatibilityIssue: encounteredCompatibilityIssue };\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async convertField(\n    tableId: string,\n    fieldId: string,\n    updateFieldRo: IConvertFieldRo,\n    windowId?: string\n  ): Promise<IFieldVo> {\n    const { oldFieldVo, newFieldVo, modifiedOps, references, supplementChange } =\n      await this.prismaService.$tx(\n        async () => {\n          // stage analysis and collect field changes\n          const analysisResult = await this.fieldConvertingService.stageAnalysis(\n            tableId,\n            fieldId,\n            updateFieldRo\n          );\n          const { newField, oldField } = analysisResult;\n          this.logger.debug(\n            `convertField stageAnalysis done table=${tableId} field=${fieldId} newType=${newField.type} oldType=${oldField.type}`\n          );\n\n          const dependentRefs = await this.prismaService\n            .txClient()\n            .reference.findMany({ where: { fromFieldId: fieldId }, select: { toFieldId: true } });\n          const dependentFieldIds = Array.from(\n            new Set([\n              ...(analysisResult.references ?? []),\n              ...dependentRefs.map((ref) => ref.toFieldId),\n            ])\n          );\n\n          const shouldRecomputeSelf = this.fieldConvertingService.needCalculate(newField, oldField);\n          const filteredDependentFieldIds = shouldRecomputeSelf\n            ? dependentFieldIds\n            : dependentFieldIds.filter((id) => id !== newField.id);\n\n          const { compatibilityIssue } = await this.performConvertField({\n            tableId,\n            newField,\n            oldField,\n            modifiedOps: analysisResult.modifiedOps,\n            supplementChange: analysisResult.supplementChange,\n            dependentFieldIds: filteredDependentFieldIds,\n          });\n\n          const shouldForceLookupError =\n            oldField.type === FieldType.Link &&\n            !oldField.isLookup &&\n            !newField.isLookup &&\n            (newField.type !== FieldType.Link ||\n              ((newField.options as ILinkFieldOptions | undefined)?.foreignTableId ?? null) !==\n                ((oldField.options as ILinkFieldOptions | undefined)?.foreignTableId ?? null));\n\n          if (filteredDependentFieldIds.length) {\n            await this.restoreReference(filteredDependentFieldIds);\n            const dependentFieldRaws = await this.prismaService.txClient().field.findMany({\n              where: { id: { in: filteredDependentFieldIds }, deletedTime: null },\n            });\n\n            if (dependentFieldRaws.length) {\n              const dependentSourceMap = dependentFieldRaws.reduce<Record<string, Set<string>>>(\n                (acc, field) => {\n                  const set = acc[field.tableId] ?? new Set<string>();\n                  set.add(field.id);\n                  acc[field.tableId] = set;\n                  return acc;\n                },\n                {}\n              );\n              const dependentSources = Object.entries(dependentSourceMap).map(([tid, ids]) => ({\n                tableId: tid,\n                fieldIds: Array.from(ids),\n              }));\n              if (dependentSources.length) {\n                await this.computedOrchestrator.computeCellChangesForFields(\n                  dependentSources,\n                  async () => {\n                    // schema/meta already up to date; nothing additional to run here\n                  }\n                );\n              }\n            }\n\n            for (const raw of dependentFieldRaws) {\n              const instance = createFieldInstanceByRaw(raw);\n              const isValid = await this.isFieldConfigurationValid(raw.tableId, instance);\n              await this.markError(raw.tableId, instance, !isValid);\n            }\n\n            if (shouldForceLookupError) {\n              const lookupFieldsToMark = dependentFieldRaws.filter(\n                (raw) =>\n                  raw.id !== fieldId &&\n                  (raw.isLookup ||\n                    raw.type === FieldType.Rollup ||\n                    raw.type === FieldType.ConditionalRollup)\n              );\n              if (lookupFieldsToMark.length) {\n                const grouped = groupBy(lookupFieldsToMark, 'tableId');\n                for (const [lookupTableId, fields] of Object.entries(grouped)) {\n                  await this.fieldService.markError(\n                    lookupTableId,\n                    fields.map((f) => f.id),\n                    true\n                  );\n                }\n              }\n            }\n          }\n\n          if (\n            compatibilityIssue &&\n            (newField.isConditionalLookup ||\n              newField.isLookup ||\n              newField.type === FieldType.ConditionalRollup)\n          ) {\n            await this.markError(tableId, newField, true);\n          }\n\n          const oldFieldVo = instanceToPlain(oldField, { excludePrefixes: ['_'] }) as IFieldVo;\n          const newFieldVo = instanceToPlain(newField, { excludePrefixes: ['_'] }) as IFieldVo;\n\n          return {\n            oldFieldVo,\n            newFieldVo,\n            modifiedOps: analysisResult.modifiedOps,\n            references: analysisResult.references,\n            supplementChange: analysisResult.supplementChange,\n          };\n        },\n        { timeout: this.thresholdConfig.bigTransactionTimeout }\n      );\n\n    this.cls.set('oldField', oldFieldVo);\n\n    if (windowId) {\n      this.eventEmitterService.emitAsync(Events.OPERATION_FIELD_CONVERT, {\n        windowId,\n        tableId,\n        userId: this.cls.get('user.id'),\n        oldField: oldFieldVo,\n        newField: newFieldVo,\n        modifiedOps,\n        references,\n        supplementChange,\n      });\n    }\n\n    // Keep API response consistent with getField/getFields by filtering out meta\n    return omit(newFieldVo, ['meta']) as IFieldVo;\n  }\n\n  async getFilterLinkRecords(tableId: string, fieldId: string) {\n    const field = await this.fieldService.getField(tableId, fieldId);\n\n    if (field.type === FieldType.Link) {\n      const { filter, foreignTableId } = field.options as ILinkFieldOptions;\n\n      if (!foreignTableId || !filter) {\n        return [];\n      }\n\n      return this.viewOpenApiService.getFilterLinkRecordsByTable(foreignTableId, filter);\n    }\n\n    if (field.type === FieldType.ConditionalRollup) {\n      const { filter, foreignTableId } = field.options as IConditionalRollupFieldOptions;\n\n      if (!foreignTableId || !filter) {\n        return [];\n      }\n\n      return this.viewOpenApiService.getFilterLinkRecordsByTable(foreignTableId, filter);\n    }\n\n    return [];\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async duplicateField(\n    sourceTableId: string,\n    fieldId: string,\n    duplicateFieldRo: IDuplicateFieldRo,\n    windowId?: string\n  ) {\n    const { name, viewId } = duplicateFieldRo;\n    const { newField } = await this.prismaService.$tx(\n      async () => {\n        const prisma = this.prismaService.txClient();\n\n        // throw error if field not found\n        const fieldRaw = await prisma.field.findUniqueOrThrow({\n          where: {\n            id: fieldId,\n            deletedTime: null,\n          },\n        });\n\n        const fieldName = await this.fieldSupplementService.uniqFieldName(sourceTableId, name);\n\n        const dbFieldName = await this.fieldService.generateDbFieldName(sourceTableId, fieldName);\n\n        const fieldInstance = createFieldInstanceByRaw(fieldRaw);\n\n        const newFieldInstance = {\n          ...fieldInstance,\n          name: fieldName,\n          dbFieldName,\n          id: generateFieldId(),\n        } as IFieldInstance;\n\n        delete newFieldInstance.isPrimary;\n        if (newFieldInstance.type === FieldType.Formula) {\n          newFieldInstance.meta = undefined;\n        }\n\n        if (viewId) {\n          const view = await prisma.view.findUniqueOrThrow({\n            where: { id: viewId, deletedTime: null },\n            select: {\n              id: true,\n              columnMeta: true,\n            },\n          });\n          const columnMeta = (view.columnMeta ? JSON.parse(view.columnMeta) : {}) as IColumnMeta;\n          const fieldViewOrder = columnMeta[fieldId]?.order;\n\n          const getterFieldViewOrders = Object.values(columnMeta)\n            .filter(({ order }) => order > fieldViewOrder)\n            .map(({ order }) => order)\n            .sort();\n\n          const targetFieldViewOrder = getterFieldViewOrders?.length\n            ? (getterFieldViewOrders[0] + fieldViewOrder) / 2\n            : fieldViewOrder + 1;\n\n          (newFieldInstance as IFieldRo).order = {\n            viewId,\n            orderIndex: targetFieldViewOrder,\n          };\n        }\n\n        // create field may not support notNull and unique validate\n        delete newFieldInstance.notNull;\n        delete newFieldInstance.unique;\n\n        if (fieldInstance.type === FieldType.Button) {\n          newFieldInstance.options = omit(fieldInstance.options, ['workflow']);\n        }\n\n        if (FieldType.Link === fieldInstance.type && !fieldInstance.isLookup) {\n          newFieldInstance.options = {\n            ...pick(fieldInstance.options, [\n              'filter',\n              'filterByViewId',\n              'foreignTableId',\n              'relationship',\n              'visibleFieldIds',\n              'baseId',\n            ]),\n            // all link field should be one way link\n            isOneWay: true,\n          } as ILinkFieldOptions;\n        }\n\n        if (\n          fieldInstance.isLookup ||\n          fieldInstance.type === FieldType.Rollup ||\n          fieldInstance.type === FieldType.ConditionalRollup\n        ) {\n          const sourceLookupOptions = fieldInstance.lookupOptions;\n          if (sourceLookupOptions) {\n            const normalizedLookupOptions = pick(sourceLookupOptions, [\n              'foreignTableId',\n              'lookupFieldId',\n              'linkFieldId',\n              'filter',\n              'sort',\n              'limit',\n            ]);\n            if (Object.keys(normalizedLookupOptions).length > 0) {\n              newFieldInstance.lookupOptions =\n                normalizedLookupOptions as IFieldInstance['lookupOptions'];\n            } else {\n              delete newFieldInstance.lookupOptions;\n            }\n          } else {\n            delete newFieldInstance.lookupOptions;\n          }\n        }\n\n        // after create field, and add constraint relative\n        const newField = await this.createField(sourceTableId, {\n          ...omit(newFieldInstance, ['notNull', 'unique']),\n        });\n\n        if (!fieldInstance.isComputed && fieldInstance.type !== FieldType.Button) {\n          // Duplicate records synchronously to avoid cross-transaction CLS leaks\n          await this.duplicateFieldData(\n            sourceTableId,\n            newField.id,\n            fieldRaw.dbFieldName,\n            omit(newFieldInstance, 'order') as IFieldInstance,\n            { sourceFieldId: fieldRaw.id }\n          );\n        }\n\n        return { newField };\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    this.eventEmitterService.emitAsync(Events.OPERATION_FIELDS_CREATE, {\n      operationId: generateOperationId(),\n      windowId,\n      tableId: sourceTableId,\n      userId: this.cls.get('user.id'),\n      fields: [newField],\n    });\n\n    return newField;\n  }\n\n  async duplicateFieldData(\n    sourceTableId: string,\n    targetFieldId: string,\n    sourceDbFieldName: string,\n    fieldInstance: IFieldInstance,\n    opts: { sourceFieldId: string }\n  ) {\n    const chunkSize = 1000;\n\n    const dbTableName = await this.fieldService.getDbTableName(sourceTableId);\n\n    // Use the SOURCE field for filtering/counting so we only fetch rows where\n    // the original field has a value. The new field is empty at this point.\n    const sourceFieldId = opts.sourceFieldId;\n    const sourceFieldForFilter = { ...fieldInstance, id: sourceFieldId } as IFieldInstance;\n\n    const count = await this.getFieldRecordsCount(dbTableName, sourceTableId, sourceFieldForFilter);\n\n    if (!count) {\n      if (fieldInstance.notNull || fieldInstance.unique) {\n        await this.convertField(sourceTableId, targetFieldId, {\n          ...fieldInstance,\n          notNull: fieldInstance.notNull,\n          unique: fieldInstance.unique,\n        });\n      }\n      return;\n    }\n\n    const page = Math.ceil(count / chunkSize);\n\n    for (let i = 0; i < page; i++) {\n      const sourceRecords = await this.getFieldRecords(\n        dbTableName,\n        sourceTableId,\n        sourceFieldForFilter,\n        sourceDbFieldName,\n        i,\n        chunkSize\n      );\n\n      if (!fieldInstance.isComputed && fieldInstance.type !== FieldType.Button) {\n        await this.prismaService.$tx(async () => {\n          await this.recordOpenApiService.simpleUpdateRecords(sourceTableId, {\n            fieldKeyType: FieldKeyType.Id,\n            typecast: true,\n            records: sourceRecords.map((record) => ({\n              id: record.id,\n              fields: {\n                [targetFieldId]: record.value,\n              },\n            })),\n          });\n        });\n      }\n    }\n\n    if (fieldInstance.notNull || fieldInstance.unique) {\n      await this.convertField(sourceTableId, targetFieldId, {\n        ...fieldInstance,\n        notNull: fieldInstance.notNull,\n        unique: fieldInstance.unique,\n      });\n    }\n  }\n\n  private async getFieldRecordsCount(dbTableName: string, tableId: string, field: IFieldInstance) {\n    // Build a filter that counts only non-empty values for the field\n    // - For boolean (checkbox) fields: use OR(is true, is false)\n    // - For other fields: use isNotEmpty\n    const filter: IFilter =\n      field.cellValueType === CellValueType.Boolean\n        ? {\n            conjunction: 'or',\n            filterSet: [\n              { fieldId: field.id, operator: 'is', value: true },\n              { fieldId: field.id, operator: 'is', value: false },\n            ],\n          }\n        : {\n            conjunction: 'and',\n            filterSet: [{ fieldId: field.id, operator: 'isNotEmpty', value: null }],\n          };\n\n    const { qb } = await this.recordQueryBuilder.createRecordAggregateBuilder(dbTableName, {\n      tableId,\n      viewId: undefined,\n      filter,\n      aggregationFields: [\n        {\n          // Use Count with '*' so it just counts filtered rows\n          fieldId: '*',\n          statisticFunc: StatisticsFunc.Count,\n          alias: 'count',\n        },\n      ],\n      useQueryModel: true,\n    });\n\n    const query = qb.toQuery();\n    const result = await this.prismaService.txClient().$queryRawUnsafe<{ count: number }[]>(query);\n    return Number(result[0].count);\n  }\n\n  private async getFieldRecords(\n    dbTableName: string,\n    tableId: string,\n    field: IFieldInstance,\n    dbFieldName: string,\n    page: number,\n    chunkSize: number\n  ) {\n    // Align fetching with counting logic: only fetch non-empty values for the field\n    const filter: IFilter =\n      field.cellValueType === CellValueType.Boolean\n        ? {\n            conjunction: 'or',\n            filterSet: [\n              { fieldId: field.id, operator: 'is', value: true },\n              { fieldId: field.id, operator: 'is', value: false },\n            ],\n          }\n        : {\n            conjunction: 'and',\n            filterSet: [{ fieldId: field.id, operator: 'isNotEmpty', value: null }],\n          };\n\n    const { qb } = await this.recordQueryBuilder.createRecordQueryBuilder(dbTableName, {\n      tableId,\n      viewId: undefined,\n      filter,\n      useQueryModel: true,\n    });\n    const query = qb\n      // TODO: handle where now link or lookup cannot use alias\n      // .whereNotNull(dbFieldName)\n      .orderBy('__auto_number')\n      .limit(chunkSize)\n      .offset(page * chunkSize)\n      .toQuery();\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ __id: string; [key: string]: string }[]>(query);\n    this.logger.debug('getFieldRecords: ', result);\n    return result.map((item) => ({\n      id: item.__id,\n      value: item[dbFieldName] as string,\n    }));\n  }\n\n  getFieldUniqueKeyName(dbTableName: string, dbFieldName: string, fieldId: string) {\n    return this.fieldService.getFieldUniqueKeyName(dbTableName, dbFieldName, fieldId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/field/util.ts",
    "content": "import { assertNever, DbFieldType, DriverClient } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getDriverName } from '../../utils/db-helpers';\n\n// from knex define\nexport enum SchemaType {\n  Binary = 'binary',\n  Integer = 'integer',\n  String = 'string',\n  Text = 'text',\n  Json = 'json',\n  Jsonb = 'jsonb',\n  Double = 'double',\n  Datetime = 'datetime',\n  Boolean = 'boolean',\n}\n\n/**\n * @deprecated Use visitor pattern for field creation. This function is kept for legacy field modification operations.\n * Convert DbFieldType to Knex SchemaType for field modification operations.\n * For new field creation, use the visitor pattern instead.\n */\nexport function dbType2knexFormat(knex: Knex, dbFieldType: DbFieldType) {\n  const driverName = getDriverName(knex);\n\n  switch (dbFieldType) {\n    case DbFieldType.Blob:\n      return SchemaType.Binary;\n    case DbFieldType.Integer:\n      return SchemaType.Integer;\n    case DbFieldType.Json: {\n      return driverName === DriverClient.Sqlite ? SchemaType.Text : SchemaType.Jsonb;\n    }\n    case DbFieldType.Real:\n      return SchemaType.Double;\n    case DbFieldType.Text:\n      return SchemaType.Text;\n    case DbFieldType.DateTime:\n      return SchemaType.Datetime;\n    case DbFieldType.Boolean:\n      return SchemaType.Boolean;\n    default:\n      assertNever(dbFieldType);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/graph/graph.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { FieldCalculateModule } from '../field/field-calculate/field-calculate.module';\nimport { FieldModule } from '../field/field.module';\nimport { RecordModule } from '../record/record.module';\nimport { GraphService } from './graph.service';\n\n@Module({\n  imports: [CalculationModule, RecordModule, FieldModule, FieldCalculateModule],\n  providers: [GraphService],\n  exports: [GraphService],\n})\nexport class GraphModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/graph/graph.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { GraphModule } from './graph.module';\nimport { GraphService } from './graph.service';\n\ndescribe('GraphServiceService', () => {\n  let service: GraphService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, GraphModule],\n    }).compile();\n\n    service = module.get<GraphService>(GraphService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/graph/graph.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions, IConvertFieldRo } from '@teable/core';\nimport { FieldType, Relationship, isLinkLookupOptions } from '@teable/core';\nimport type { Field, TableMeta } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IGraphEdge,\n  IGraphNode,\n  IGraphCombo,\n  IPlanFieldVo,\n  IPlanFieldConvertVo,\n  IPlanFieldDeleteVo,\n  IBaseErdTableNode,\n  IBaseErdEdge,\n} from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { groupBy, keyBy, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { majorFieldKeysChanged } from '../../utils/major-field-keys-changed';\nimport { Timing } from '../../utils/timing';\nimport { FieldCalculationService } from '../calculation/field-calculation.service';\nimport { ReferenceService } from '../calculation/reference.service';\nimport type { IGraphItem } from '../calculation/utils/dfs';\nimport { pruneGraph, topoOrderWithStart } from '../calculation/utils/dfs';\nimport { FieldConvertingLinkService } from '../field/field-calculate/field-converting-link.service';\nimport { FieldSupplementService } from '../field/field-calculate/field-supplement.service';\nimport { FieldService } from '../field/field.service';\nimport {\n  createFieldInstanceByVo,\n  type IFieldInstance,\n  type IFieldMap,\n} from '../field/model/factory';\n\ninterface ITinyField {\n  id: string;\n  name: string;\n  type: string;\n  tableId: string;\n  isLookup?: boolean | null;\n  isConditionalLookup?: boolean | null;\n}\n\ninterface ITinyTable {\n  id: string;\n  name: string;\n  dbTableName: string;\n}\n\n@Injectable()\nexport class GraphService {\n  private logger = new Logger(GraphService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly fieldService: FieldService,\n    private readonly referenceService: ReferenceService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly fieldCalculationService: FieldCalculationService,\n    private readonly fieldConvertingLinkService: FieldConvertingLinkService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  private getFieldNodesAndCombos(\n    fieldId: string,\n    fieldRawsMap: Record<string, ITinyField[]>,\n    tableRaws: ITinyTable[],\n    allowedNodeIds?: Set<string>\n  ) {\n    const nodes: IGraphNode[] = [];\n    const combos: IGraphCombo[] = [];\n    tableRaws.forEach(({ id: tableId, name: tableName }) => {\n      combos.push({\n        id: tableId,\n        label: tableName,\n      });\n      fieldRawsMap[tableId].forEach((field) => {\n        if (!allowedNodeIds || allowedNodeIds.has(field.id)) {\n          nodes.push({\n            id: field.id,\n            label: field.name,\n            comboId: tableId,\n            fieldType: field.type,\n            isLookup: field.isLookup,\n            isConditionalLookup: field.isConditionalLookup,\n            isSelected: field.id === fieldId,\n          });\n        }\n      });\n    });\n    return {\n      nodes,\n      combos,\n    };\n  }\n\n  private getEstimateTime(cellCount: number) {\n    return Math.floor(cellCount / this.thresholdConfig.estimateCalcCelPerMs);\n  }\n\n  async planFieldCreate(tableId: string, fieldRo: IFieldRo): Promise<IPlanFieldVo> {\n    const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, fieldRo);\n    const field = createFieldInstanceByVo(fieldVo);\n\n    const referenceFieldIds = this.fieldSupplementService.getFieldReferenceIds(field);\n    const directedGraph = await this.referenceService.getFieldGraphItems(referenceFieldIds);\n    const fromGraph = referenceFieldIds.map((fromFieldId) => ({\n      fromFieldId,\n      toFieldId: field.id,\n    }));\n    directedGraph.push(...fromGraph);\n    const allFieldIds = uniq(\n      directedGraph.map((item) => [item.fromFieldId, item.toFieldId]).flat()\n    );\n    const fieldRaws = await this.prismaService.field.findMany({\n      where: { id: { in: allFieldIds } },\n      select: {\n        id: true,\n        name: true,\n        type: true,\n        isLookup: true,\n        isConditionalLookup: true,\n        tableId: true,\n      },\n    });\n\n    fieldRaws.push({\n      id: field.id,\n      name: field.name,\n      type: field.type,\n      isLookup: field.isLookup || null,\n      isConditionalLookup: field.isConditionalLookup || null,\n      tableId,\n    });\n\n    const tableRaws = await this.prismaService.tableMeta.findMany({\n      where: { id: { in: uniq(fieldRaws.map((item) => item.tableId)) } },\n      select: { id: true, name: true, dbTableName: true },\n    });\n\n    const tableMap = keyBy(tableRaws, 'id');\n    const fieldMap = keyBy(fieldRaws, 'id');\n\n    const fieldRawsMap = groupBy(fieldRaws, 'tableId');\n\n    // Normalize edges for display: dedupe and hide link -> lookup edge\n    const seen = new Set<string>();\n    const filteredGraph = directedGraph.filter(({ fromFieldId, toFieldId }) => {\n      // Hide the link -> lookup edge for readability in graph\n      const lookupOptions = field.lookupOptions;\n      if (\n        toFieldId === field.id &&\n        lookupOptions &&\n        isLinkLookupOptions(lookupOptions) &&\n        fromFieldId === lookupOptions.linkFieldId\n      ) {\n        return false;\n      }\n      const key = `${fromFieldId}->${toFieldId}`;\n      if (seen.has(key)) return false;\n      seen.add(key);\n      return true;\n    });\n\n    const edges = filteredGraph.map<IGraphEdge>((node) => {\n      const f = fieldMap[node.toFieldId];\n      return {\n        source: node.fromFieldId,\n        target: node.toFieldId,\n        label: f.isLookup ? 'lookup' : f.type,\n      };\n    }, []);\n\n    // Only include nodes that appear in edges, plus the host field\n    const nodeIds = new Set<string>([field.id]);\n    for (const e of filteredGraph) {\n      nodeIds.add(e.fromFieldId);\n      nodeIds.add(e.toFieldId);\n    }\n    const { nodes, combos } = this.getFieldNodesAndCombos(\n      field.id,\n      fieldRawsMap,\n      tableRaws,\n      nodeIds\n    );\n    const updateCellCount = await this.affectedCellCount(\n      field.id,\n      [field.id],\n      { [field.id]: field },\n      { [field.id]: tableMap[tableId].dbTableName }\n    );\n    const estimateTime = field.isComputed ? this.getEstimateTime(updateCellCount) : 200;\n    return {\n      graph: {\n        nodes,\n        edges,\n        combos,\n      },\n      updateCellCount,\n      estimateTime,\n    };\n  }\n\n  private async getField(tableId: string, fieldId: string, fieldRo: IConvertFieldRo) {\n    const oldFieldVo = await this.fieldService.getField(tableId, fieldId);\n    const oldField = createFieldInstanceByVo(oldFieldVo);\n    const newFieldVo = await this.fieldSupplementService.prepareUpdateField(\n      tableId,\n      fieldRo,\n      oldField\n    );\n    const newField = createFieldInstanceByVo(newFieldVo);\n    return { oldField, newField };\n  }\n\n  private async getFullTopoOrdersContext(field: IFieldInstance, directedGraph?: IGraphItem[]) {\n    const oldRefernce: string[] = [field.id];\n    const lookupGraph: IGraphItem[] = [];\n    const selfLookupReference = await this.prismaService.field.findMany({\n      where: {\n        lookupLinkedFieldId: field.id,\n        deletedTime: null,\n      },\n      select: { id: true },\n    });\n    oldRefernce.push(...selfLookupReference.map((f) => f.id));\n    lookupGraph.push(\n      ...selfLookupReference.map((f) => ({ fromFieldId: field.id, toFieldId: f.id }))\n    );\n\n    if (field.type === FieldType.Link && !field.isLookup && field.options.symmetricFieldId) {\n      const findSymmetricField = await this.prismaService.field.findUnique({\n        where: {\n          id: field.options.symmetricFieldId,\n          deletedTime: null,\n        },\n        select: { id: true },\n      });\n\n      if (findSymmetricField) {\n        const suplimentLookupRefernce = await this.prismaService.field.findMany({\n          where: {\n            lookupLinkedFieldId: field.options.symmetricFieldId,\n            deletedTime: null,\n          },\n          select: { id: true },\n        });\n        oldRefernce.push(\n          ...suplimentLookupRefernce.map((field) => field.id),\n          field.options.symmetricFieldId\n        );\n        lookupGraph.push(\n          ...suplimentLookupRefernce.map((f) => ({ fromFieldId: field.id, toFieldId: f.id }))\n        );\n        lookupGraph.push({ fromFieldId: field.id, toFieldId: field.options.symmetricFieldId });\n      }\n    }\n\n    const context = await this.fieldCalculationService.getTopoOrdersContext(\n      oldRefernce,\n      directedGraph\n    );\n    return {\n      ...context,\n      allFieldIds: uniq([...context.allFieldIds, ...lookupGraph.map((item) => item.toFieldId)]),\n      directedGraph: context.directedGraph.concat(lookupGraph),\n      fieldMap: {\n        ...context.fieldMap,\n      },\n    };\n  }\n\n  @Timing()\n  private async getUpdateCalculationContext(newField: IFieldInstance) {\n    const fieldId = newField.id;\n\n    const newReference = this.fieldSupplementService.getFieldReferenceIds(newField);\n\n    const incomingGraph = await this.referenceService.getFieldGraphItems(newReference);\n\n    const oldGraph = await this.referenceService.getFieldGraphItems([fieldId]);\n\n    const tempGraph = [\n      ...oldGraph.filter((graph) => graph.toFieldId !== fieldId),\n      ...incomingGraph.filter((graph) => graph.toFieldId !== fieldId),\n      ...newReference.map((id) => ({ fromFieldId: id, toFieldId: fieldId })),\n    ];\n\n    const newDirectedGraph = pruneGraph(fieldId, tempGraph);\n\n    const context = await this.getFullTopoOrdersContext(newField, newDirectedGraph);\n    const fieldMap = {\n      ...context.fieldMap,\n      [newField.id]: newField,\n    };\n\n    return {\n      ...context,\n      fieldMap,\n    };\n  }\n\n  private async generateGraph(params: {\n    fieldId: string;\n    directedGraph: IGraphItem[];\n    allFieldIds: string[];\n    fieldMap: IFieldMap;\n    tableId2DbTableName: Record<string, string>;\n    fieldId2TableId: Record<string, string>;\n  }) {\n    const { fieldId, directedGraph, allFieldIds, fieldMap, tableId2DbTableName, fieldId2TableId } =\n      params;\n\n    // 1) Dedupe edges and hide link -> lookup edge for display\n    const edgeSeen = new Set<string>();\n    const filtered = directedGraph.filter(({ fromFieldId, toFieldId }) => {\n      const to = fieldMap[toFieldId];\n      const lookupOptions = to?.lookupOptions;\n      if (\n        lookupOptions &&\n        isLinkLookupOptions(lookupOptions) &&\n        fromFieldId === lookupOptions.linkFieldId\n      ) {\n        // Hide the link field as a dependency in the display graph\n        return false;\n      }\n      const key = `${fromFieldId}->${toFieldId}`;\n      if (edgeSeen.has(key)) return false;\n      edgeSeen.add(key);\n      return true;\n    });\n\n    const edges = filtered.map<IGraphEdge>((node) => {\n      const field = fieldMap[node.toFieldId];\n      return {\n        source: node.fromFieldId,\n        target: node.toFieldId,\n        label: field.isLookup ? 'lookup' : field.type,\n      };\n    }, []);\n\n    const tableIds = Object.keys(tableId2DbTableName);\n    const tableRaws = await this.prismaService.tableMeta.findMany({\n      where: { id: { in: tableIds } },\n      select: { id: true, name: true },\n    });\n\n    const combos = tableRaws.map<IGraphCombo>((table) => ({\n      id: table.id,\n      label: table.name,\n    }));\n\n    // Nodes: from filtered edges plus ensure host field is present\n    const nodeIdSet = new Set<string>([fieldId]);\n    for (const e of filtered) {\n      nodeIdSet.add(e.fromFieldId);\n      nodeIdSet.add(e.toFieldId);\n    }\n    const nodes = Array.from(nodeIdSet).map<IGraphNode>((id) => {\n      const tableId = fieldId2TableId[id];\n      const field = fieldMap[id];\n      return {\n        id: field.id,\n        label: field.name,\n        comboId: tableId,\n        fieldType: field.type,\n        isLookup: field.isLookup,\n        isSelected: field.id === fieldId,\n      };\n    });\n\n    return {\n      nodes,\n      edges,\n      combos,\n    };\n  }\n\n  async planFieldConvert(\n    tableId: string,\n    fieldId: string,\n    fieldRo: IConvertFieldRo\n  ): Promise<IPlanFieldConvertVo> {\n    const { oldField, newField } = await this.getField(tableId, fieldId, fieldRo);\n    const majorChange = majorFieldKeysChanged(oldField, fieldRo);\n\n    if (!majorChange) {\n      return { skip: true };\n    }\n\n    const context = await this.getUpdateCalculationContext(newField);\n\n    const {\n      directedGraph,\n      allFieldIds,\n      fieldMap,\n      fieldId2DbTableName,\n      tableId2DbTableName,\n      fieldId2TableId,\n    } = context;\n    const topoFieldIds = topoOrderWithStart(fieldId, directedGraph);\n\n    const graph = await this.generateGraph({\n      fieldId,\n      directedGraph,\n      allFieldIds,\n      fieldMap,\n      tableId2DbTableName,\n      fieldId2TableId,\n    });\n\n    const updateCellCount = await this.affectedCellCount(\n      fieldId,\n      topoFieldIds,\n      fieldMap,\n      fieldId2DbTableName\n    );\n\n    const resetLinkFieldLookupFieldIds =\n      await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(\n        tableId,\n        newField,\n        'field|update'\n      );\n\n    return {\n      graph,\n      updateCellCount,\n      estimateTime: this.getEstimateTime(updateCellCount),\n      linkFieldCount: resetLinkFieldLookupFieldIds.length,\n    };\n  }\n\n  async planDeleteField(tableId: string, fieldId: string): Promise<IPlanFieldDeleteVo> {\n    const res = await this.planField(tableId, fieldId);\n    const field = await this.fieldService.getField(tableId, fieldId);\n    const fieldInstance = createFieldInstanceByVo(field);\n    const resetLinkFieldLookupFieldIds =\n      await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(\n        tableId,\n        fieldInstance,\n        'field|delete'\n      );\n\n    return {\n      ...res,\n      linkFieldCount: resetLinkFieldLookupFieldIds.length,\n    };\n  }\n\n  private async affectedCellCount(\n    hostFieldId: string,\n    fieldIds: string[],\n    fieldMap: IFieldMap,\n    fieldId2DbTableName: Record<string, string>\n  ): Promise<number> {\n    const queries = fieldIds.map((fieldId) => {\n      const field = fieldMap[fieldId];\n      const lookupOptions = field.lookupOptions;\n\n      if (field.id !== hostFieldId) {\n        if (field.type === FieldType.Link) {\n          const { relationship, fkHostTableName, selfKeyName, foreignKeyName } =\n            field.options as ILinkFieldOptions;\n          const query =\n            relationship === Relationship.OneOne || relationship === Relationship.ManyOne\n              ? this.knex.count(foreignKeyName, { as: 'count' }).from(fkHostTableName)\n              : this.knex.countDistinct(selfKeyName, { as: 'count' }).from(fkHostTableName);\n\n          return query.toQuery();\n        }\n\n        if (lookupOptions && isLinkLookupOptions(lookupOptions)) {\n          const { relationship, fkHostTableName, selfKeyName, foreignKeyName } = lookupOptions;\n          const query =\n            relationship === Relationship.OneOne || relationship === Relationship.ManyOne\n              ? this.knex.count(foreignKeyName, { as: 'count' }).from(fkHostTableName)\n              : this.knex.countDistinct(selfKeyName, { as: 'count' }).from(fkHostTableName);\n\n          return query.toQuery();\n        }\n      }\n\n      const dbTableName = fieldId2DbTableName[fieldId];\n      return this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery();\n    });\n    // console.log('queries', queries);\n\n    let total = 0;\n    for (const query of queries) {\n      const [{ count }] = await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(query);\n      // console.log('count', count);\n      total += Number(count);\n    }\n    return total;\n  }\n\n  @Timing()\n  async planField(tableId: string, fieldId: string): Promise<IPlanFieldVo> {\n    const field = await this.fieldService.getField(tableId, fieldId);\n    const context = await this.getFullTopoOrdersContext(createFieldInstanceByVo(field));\n\n    const {\n      directedGraph,\n      allFieldIds,\n      fieldMap,\n      fieldId2DbTableName,\n      tableId2DbTableName,\n      fieldId2TableId,\n    } = context;\n\n    const graph = await this.generateGraph({\n      fieldId,\n      directedGraph,\n      allFieldIds,\n      fieldMap,\n      tableId2DbTableName,\n      fieldId2TableId,\n    });\n\n    const updateCellCount = await this.affectedCellCount(\n      fieldId,\n      allFieldIds,\n      fieldMap,\n      fieldId2DbTableName\n    );\n\n    return {\n      graph,\n      updateCellCount,\n      estimateTime: this.getEstimateTime(updateCellCount),\n    };\n  }\n\n  async generateBaseErd(baseId: string) {\n    const tableRaws = await this.prismaService.tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n      select: { id: true, name: true, icon: true },\n    });\n\n    const { tableMap, fieldMap, linkFieldRaws, tableNodes } = await this.getBaseErdContext(\n      tableRaws.map((table) => table.id)\n    );\n\n    const { references, referenceFieldRaws } = await this.getBaseErdReference(\n      Object.keys(fieldMap)\n    );\n\n    const {\n      tableNodes: crossTableNodes,\n      tableMap: crossTableTableMap,\n      fieldMap: crossTableFieldMap,\n      linkFieldRaws: crossBaseLinkFieldRaws,\n    } = await this.getBaseErdContext(\n      referenceFieldRaws.filter((field) => !tableMap[field.tableId]).map((field) => field.tableId),\n      true\n    );\n\n    const edges = await this.generateBaseErdEdges({\n      linkFieldRaws,\n      crossBaseLinkFieldRaws,\n      tableMap,\n      fieldMap,\n      crossBaseTableMap: crossTableTableMap,\n      crossBaseFieldMap: crossTableFieldMap,\n      references,\n    });\n\n    return {\n      baseId,\n      nodes: [...tableNodes, ...crossTableNodes],\n      edges,\n    };\n  }\n\n  private async getBaseErdContext(tableIds: string[], crossBase?: boolean) {\n    if (tableIds.length === 0) {\n      return {\n        tableRaws: [],\n        tableMap: {},\n        fieldRaws: [],\n        fieldMap: {},\n        linkFieldRaws: [],\n        tableNodes: [],\n      };\n    }\n    const tableRaws = await this.prismaService.tableMeta.findMany({\n      where: {\n        id: { in: tableIds },\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n        name: true,\n        icon: true,\n        base: crossBase ? { select: { id: true, name: true } } : undefined,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n    const tableMap = keyBy(tableRaws, 'id');\n\n    const fieldRaws = await this.prismaService.field.findMany({\n      where: {\n        tableId: { in: Object.keys(tableMap) },\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n        tableId: true,\n        name: true,\n        type: true,\n        options: true,\n        isLookup: true,\n        lookupLinkedFieldId: true,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    const fieldMap = keyBy(fieldRaws, 'id');\n\n    const linkFieldRaws = fieldRaws\n      .filter((field) => field.type === FieldType.Link && !field.isLookup)\n      .map((field) => {\n        return {\n          ...field,\n          options: field.options && JSON.parse(field.options as string),\n        };\n      });\n\n    const tableId2fieldRaws = groupBy(fieldRaws, 'tableId');\n\n    const tableNodes = tableRaws.map<IBaseErdTableNode>((table) => {\n      const items = tableId2fieldRaws[table.id] ?? [];\n      return {\n        id: table.id,\n        name: table.name,\n        icon: table.icon ?? undefined,\n        crossBaseId: crossBase ? table.base.id : undefined,\n        crossBaseName: crossBase ? table.base.name : undefined,\n        fields: items.map((field) => ({\n          id: field.id,\n          name: field.name,\n          type: field.type as FieldType,\n          isLookup: field.isLookup ?? undefined,\n        })),\n      };\n    });\n\n    return {\n      tableRaws,\n      tableMap,\n      fieldRaws,\n      fieldMap,\n      linkFieldRaws,\n      tableNodes,\n    };\n  }\n\n  private async getBaseErdReference(allFieldIds: string[]) {\n    const references = await this.prismaService.txClient().reference.findMany({\n      where: {\n        OR: [{ fromFieldId: { in: allFieldIds } }, { toFieldId: { in: allFieldIds } }],\n      },\n      select: {\n        fromFieldId: true,\n        toFieldId: true,\n      },\n    });\n\n    const referenceFieldIds = uniq(\n      references.map((ref) => [ref.fromFieldId, ref.toFieldId]).flat()\n    );\n\n    const referenceFieldRaws = await this.prismaService.txClient().field.findMany({\n      where: {\n        id: { in: referenceFieldIds },\n      },\n      select: {\n        id: true,\n        tableId: true,\n      },\n    });\n\n    return {\n      references,\n      referenceFieldRaws,\n    };\n  }\n\n  /**\n   * if A -> B & B -> A, keep A <-> B\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async generateBaseErdEdges(params: {\n    linkFieldRaws: (Pick<Field, 'id' | 'name' | 'type' | 'tableId'> & {\n      options: ILinkFieldOptions;\n    })[];\n    tableMap: Record<string, Pick<TableMeta, 'id' | 'name' | 'icon'>>;\n    fieldMap: Record<\n      string,\n      Pick<Field, 'id' | 'tableId' | 'name' | 'type' | 'isLookup' | 'lookupLinkedFieldId'>\n    >;\n    crossBaseLinkFieldRaws: (Pick<Field, 'id' | 'name' | 'type' | 'tableId'> & {\n      options: ILinkFieldOptions;\n    })[];\n    crossBaseTableMap: Record<string, Pick<TableMeta, 'id' | 'name' | 'icon'>>;\n    crossBaseFieldMap: Record<\n      string,\n      Pick<Field, 'id' | 'tableId' | 'name' | 'type' | 'isLookup' | 'lookupLinkedFieldId'>\n    >;\n    references: { fromFieldId: string; toFieldId: string }[];\n  }) {\n    const {\n      linkFieldRaws,\n      tableMap,\n      fieldMap,\n      crossBaseLinkFieldRaws,\n      crossBaseTableMap,\n      crossBaseFieldMap,\n      references,\n    } = params;\n\n    const fieldEdgeMap = new Map<string, boolean>();\n    const edges: IBaseErdEdge[] = [];\n    for (const field of [...linkFieldRaws, ...crossBaseLinkFieldRaws]) {\n      const { options } = field;\n      const sourceTable =\n        tableMap[options.foreignTableId] ?? crossBaseTableMap[options.foreignTableId];\n      const sourceFieldId = options.symmetricFieldId ?? options.lookupFieldId;\n      const sourceField = fieldMap[sourceFieldId] ?? crossBaseFieldMap[sourceFieldId];\n\n      const targetTable = tableMap[field.tableId] ?? crossBaseTableMap[field.tableId];\n      const targetField = fieldMap[field.id] ?? crossBaseFieldMap[field.id];\n\n      if (!sourceTable || !targetTable || !sourceField || !targetField) {\n        continue;\n      }\n\n      const edge: IBaseErdEdge = {\n        source: {\n          tableId: sourceTable.id,\n          tableName: sourceTable.name,\n          fieldId: sourceField.id,\n          fieldName: sourceField.name,\n        },\n        target: {\n          tableId: targetTable.id,\n          tableName: targetTable.name,\n          fieldId: targetField.id,\n          fieldName: targetField.name,\n        },\n        relationship: options.relationship,\n        isOneWay: options.isOneWay ?? false,\n        type: field.type as FieldType,\n      };\n      const key = `${sourceField.id}-${targetField.id}`;\n      const reverseKey = `${targetField.id}-${sourceField.id}`;\n      if (fieldEdgeMap.has(reverseKey)) {\n        fieldEdgeMap.set(key, true);\n        continue;\n      }\n      fieldEdgeMap.set(key, false);\n      edges.push(edge);\n    }\n\n    for (const { fromFieldId, toFieldId } of references) {\n      const fromField = fieldMap[fromFieldId] ?? crossBaseFieldMap[fromFieldId];\n      const toField = fieldMap[toFieldId] ?? crossBaseFieldMap[toFieldId];\n\n      if (!fromField || !toField) {\n        continue;\n      }\n\n      const fromTable = tableMap[fromField.tableId] ?? crossBaseTableMap[fromField.tableId];\n      const toTable = tableMap[toField.tableId] ?? crossBaseTableMap[toField.tableId];\n\n      if (!fromTable || !toTable) {\n        continue;\n      }\n\n      const key = `${fromField.id}-${toField.id}`;\n      const reverseKey = `${toField.id}-${fromField.id}`;\n      if (fieldEdgeMap.has(key) || fieldEdgeMap.has(reverseKey)) {\n        continue;\n      }\n\n      if (toField.lookupLinkedFieldId && toField.lookupLinkedFieldId === fromField.id) {\n        continue;\n      }\n\n      const edge: IBaseErdEdge = {\n        source: {\n          tableId: fromTable.id,\n          tableName: fromTable.name,\n          fieldId: fromField.id,\n          fieldName: fromField.name,\n        },\n        target: {\n          tableId: toTable.id,\n          tableName: toTable.name,\n          fieldId: toField.id,\n          fieldName: toField.name,\n        },\n        type: toField.isLookup ? 'lookup' : (toField.type as FieldType),\n      };\n      edges.push(edge);\n      fieldEdgeMap.set(key, true);\n    }\n\n    return edges.map((edge) => {\n      const key = `${edge.source.fieldId}-${edge.target.fieldId}`;\n      const guessOneWay = fieldEdgeMap.get(key) ?? true;\n      return {\n        ...edge,\n        isOneWay: edge.isOneWay ?? guessOneWay,\n      };\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/health/health.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { HealthController } from './health.controller';\n\ndescribe('HealthController', () => {\n  let controller: HealthController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [HealthController],\n    }).compile();\n\n    controller = module.get<HealthController>(HealthController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/health/health.controller.ts",
    "content": "import { Controller, Get, Logger } from '@nestjs/common';\nimport { HealthCheck, HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Public } from '../auth/decorators/public.decorator';\n\n@Controller('health')\n@Public()\nexport class HealthController {\n  private logger = new Logger(HealthController.name);\n  constructor(\n    private readonly health: HealthCheckService,\n    private readonly db: PrismaHealthIndicator,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  @Get()\n  @HealthCheck()\n  check() {\n    try {\n      return this.health.check([() => this.db.pingCheck('database', this.prismaService)]);\n    } catch (error) {\n      this.logger.error(error);\n      throw error;\n    }\n  }\n\n  @Get('memory')\n  memory() {\n    return {\n      memoryUsage: process.memoryUsage(),\n      pod: process.env.HOSTNAME,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/health/health.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { TerminusModule } from '@nestjs/terminus';\nimport { HealthController } from './health.controller';\nimport { HealthService } from './health.service';\n\n@Module({\n  imports: [TerminusModule],\n  providers: [HealthService],\n  controllers: [HealthController],\n})\nexport class HealthModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/health/health.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class HealthService {\n  beforeApplicationShutdown(signal: string) {\n    console.log(`health beforeApplicationShutdown ${signal}`);\n  }\n\n  onApplicationShutdown(signal: string) {\n    console.log(`health onApplicationShutdown ${signal}`);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/metrics/import-metrics.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ImportMetricsService } from './import-metrics.service';\nimport { ImportTracingService } from './import-tracing.service';\n\n@Module({\n  providers: [ImportMetricsService, ImportTracingService],\n  exports: [ImportMetricsService, ImportTracingService],\n})\nexport class ImportMetricsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/metrics/import-metrics.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { metrics } from '@opentelemetry/api';\n\n@Injectable()\nexport class ImportMetricsService {\n  private readonly meter = metrics.getMeter('teable-observability');\n\n  private readonly importTotal = this.meter.createCounter('data.import.total', {\n    description: 'Total number of import tasks queued',\n  });\n  private readonly importDuration = this.meter.createHistogram('data.import.duration', {\n    description: 'Import task processing duration in milliseconds',\n    unit: 'ms',\n    advice: {\n      explicitBucketBoundaries: [\n        1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000, 180000, 300000,\n      ],\n    },\n  });\n  private readonly importErrors = this.meter.createCounter('data.import.errors', {\n    description: 'Total number of import errors',\n  });\n\n  recordImportQueued(attrs: { fileType: string; operationType: string }): void {\n    this.importTotal.add(1, {\n      file_type: attrs.fileType,\n      operation_type: attrs.operationType,\n    });\n  }\n\n  recordImportComplete(attrs: {\n    fileType: string;\n    operationType: string;\n    durationMs: number;\n  }): void {\n    this.importDuration.record(attrs.durationMs, {\n      file_type: attrs.fileType,\n      operation_type: attrs.operationType,\n    });\n  }\n\n  recordImportError(attrs: { fileType: string; operationType: string; errorType: string }): void {\n    this.importErrors.add(1, {\n      file_type: attrs.fileType,\n      operation_type: attrs.operationType,\n      error_type: attrs.errorType,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/metrics/import-tracing.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { BaseTracingService } from '../../../tracing/base-tracing.service';\n\n@Injectable()\nexport class ImportTracingService extends BaseTracingService {\n  setImportAttributes(attrs: { rows: number }): void {\n    this.withActiveSpan((span) => {\n      span.setAttribute('data.import.rows', attrs.rows);\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/NOTICE.md",
    "content": "# Notices for Third-Party Software\n\nThis software includes or uses the following software/components subject to the following licenses:\n\n## SheetJS Community Edition\n\n- Website: https://sheetjs.com/\n- Copyright: Copyright (C) 2012-present SheetJS LLC\n- License: Apache License, Version 2.0\n- License URL: http://www.apache.org/licenses/LICENSE-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n      http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/delimiter-stream.ts",
    "content": "import type { TransformCallback } from 'stream';\nimport { Transform } from 'stream';\n\nconst defaults = {\n  delimiter: '\\n',\n  encoding: 'utf8' as BufferEncoding,\n};\n\ninterface IDelimiterStreamOptions {\n  delimiter?: string;\n  encoding?: BufferEncoding;\n}\n\ninterface IDelimiterStreamInstance extends Transform {\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _delimiter: string;\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _encoding: BufferEncoding;\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _stub: Buffer;\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _delimiterBuffer: Buffer;\n  getLines(chunk: Buffer): Buffer[];\n  dispatchLines(lines: Buffer[]): void;\n}\n\nclass DelimiterStream extends Transform implements IDelimiterStreamInstance {\n  _delimiter: string;\n  _encoding: BufferEncoding;\n  _stub: Buffer;\n  _delimiterBuffer: Buffer;\n\n  constructor(options: IDelimiterStreamOptions = defaults) {\n    super(options);\n\n    this._delimiter = options.delimiter || defaults.delimiter;\n    this._encoding = options.encoding || defaults.encoding;\n\n    this._stub = Buffer.from([]);\n    this._delimiterBuffer = Buffer.from(this._delimiter, this._encoding);\n  }\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _transform(chunk: Buffer, encoding: BufferEncoding, done: TransformCallback): void {\n    const lines = this.getLines(chunk);\n    this.dispatchLines(lines);\n    done();\n  }\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  _flush(done: () => void): void {\n    this.push(this._stub.toString(this._encoding), this._encoding);\n    done();\n  }\n\n  getLines(linesChunk: Buffer): Buffer[] {\n    const delimiterLength = this._delimiterBuffer.length;\n    const lines: Buffer[] = [];\n    let delimiterHits = 0;\n    let lastSplitIndex = 0;\n\n    if (this._stub.length) {\n      linesChunk = Buffer.concat([this._stub, linesChunk]);\n      this._stub = Buffer.from('');\n    }\n\n    for (let charIndex = 0; charIndex < linesChunk.length; charIndex++) {\n      const bufferChar = linesChunk[charIndex];\n      const delimiterChar = this._delimiterBuffer[delimiterHits];\n\n      if (bufferChar === delimiterChar) {\n        delimiterHits++;\n        if (delimiterHits === delimiterLength) {\n          lines.push(linesChunk.slice(lastSplitIndex, charIndex + 1));\n          lastSplitIndex = charIndex + 1;\n          delimiterHits = 0;\n        }\n      } else {\n        delimiterHits = 0;\n      }\n    }\n\n    this._stub = linesChunk.slice(lastSplitIndex);\n    return lines;\n  }\n\n  dispatchLines(lines: Buffer[], lineIndex = 0): void {\n    const _encoding = this._encoding;\n    const line = lines[lineIndex];\n\n    // Check if the line is a _delimiter line => do not add it to the previous chunk!\n    this.push(line, _encoding);\n\n    lineIndex++;\n\n    if (lineIndex < lines.length) {\n      return this.dispatchLines(lines, lineIndex);\n    }\n  }\n}\n\n/**\n * workaround for the issue with the two-byte UTF characters\n * https://github.com/mholt/PapaParse/issues/751\n */\nexport const toLineDelimitedStream = (input: NodeJS.ReadableStream) => {\n  // Two-byte UTF characters (such as \"ä\") can break because the chunk might get\n  // split at the middle of the character, and papaparse parses the byte stream\n  // incorrectly. We can use `DelimiterStream` to fix this, as it parses the\n  // chunks to lines correctly before passing the data to papaparse.\n  const output = new DelimiterStream();\n  input.pipe(output);\n  return output;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-csv-chunk.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventEmitterModule } from '@nestjs/event-emitter';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { NotificationModule } from '../../notification/notification.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { ImportMetricsModule } from '../metrics/import-metrics.module';\nimport {\n  ImportTableCsvChunkQueueProcessor,\n  TABLE_IMPORT_CSV_CHUNK_QUEUE,\n} from './import-csv-chunk.processor';\nimport { ImportTableCsvQueueProcessor, TABLE_IMPORT_CSV_QUEUE } from './import-csv.processor';\nimport {\n  ImportTableResultQueueProcessor,\n  TABLE_IMPORT_RESULT_QUEUE,\n} from './import-result.processor';\n\n@Module({\n  providers: [\n    ImportTableCsvChunkQueueProcessor,\n    ImportTableCsvQueueProcessor,\n    ImportTableResultQueueProcessor,\n  ],\n  imports: [\n    EventJobModule.registerQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE),\n    EventJobModule.registerQueue(TABLE_IMPORT_CSV_QUEUE),\n    EventJobModule.registerQueue(TABLE_IMPORT_RESULT_QUEUE),\n    ShareDbModule,\n    RecordOpenApiModule,\n    NotificationModule,\n    StorageModule,\n    EventEmitterModule,\n    ImportMetricsModule,\n  ],\n  exports: [\n    ImportTableCsvChunkQueueProcessor,\n    ImportTableCsvQueueProcessor,\n    ImportTableResultQueueProcessor,\n  ],\n})\nexport class ImportCsvChunkModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-csv-chunk.processor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport os from 'os';\nimport { PassThrough, Readable } from 'stream';\nimport { Worker } from 'worker_threads';\nimport { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger, Optional } from '@nestjs/common';\nimport type { FieldType, ILocalization } from '@teable/core';\nimport { getRandomString } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport type { IImportOptionRo, IImportColumn, IInplaceImportOptionRo } from '@teable/openapi';\nimport { Job, Queue, QueueEvents } from 'bullmq';\nimport { toNumber } from 'lodash';\nimport { I18nService } from 'nestjs-i18n';\nimport Papa from 'papaparse';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { I18nPath, I18nTranslations } from '../../../types/i18n.generated';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { NotificationService } from '../../notification/notification.service';\nimport { ImportMetricsService } from '../metrics/import-metrics.service';\nimport { ImportTracingService } from '../metrics/import-tracing.service';\nimport type { IChunkImportResult } from './import-csv.processor';\nimport { ImportTableCsvQueueProcessor, TABLE_IMPORT_CSV_QUEUE } from './import-csv.processor';\nimport { classifyImportError, formatClassifiedError } from './import-error-classifier';\nimport type { ITranslateFn } from './import-error-classifier';\nimport {\n  getImportResultManifestKey,\n  IMPORT_RESULT_MANIFEST_TTL_SECONDS,\n  type IImportResultManifest,\n} from './import-result-manifest';\nimport {\n  ImportTableResultQueueProcessor,\n  TABLE_IMPORT_RESULT_QUEUE,\n} from './import-result.processor';\nimport {\n  DEFAULT_IMPORT_CPU_USAGE,\n  getWorkerPath,\n  importerFactory,\n  OVER_PLAN_ROW_COUNT_ERROR_MESSAGE,\n} from './import.class';\n\nconst importCpuUsage = toNumber(process.env.IMPORT_CPU_USAGE ?? DEFAULT_IMPORT_CPU_USAGE);\n\nclass ImportError extends Error {\n  constructor(\n    message: string,\n    public range?: [number, number]\n  ) {\n    super(message);\n    this.name = 'ImportError';\n  }\n}\n\ninterface ITableImportChunkJob {\n  baseId: string;\n  table: {\n    id: string;\n    name: string;\n  };\n  userId: string;\n  origin?: {\n    ip: string;\n    byApi: boolean;\n    userAgent: string;\n    referer: string;\n  };\n  importerParams: Pick<IImportOptionRo, 'attachmentUrl' | 'fileType'> & {\n    maxRowCount?: number;\n  };\n  options: {\n    skipFirstNLines: number;\n    sheetKey: string;\n    notification: boolean;\n  };\n  recordsCal: {\n    columnInfo?: IImportColumn[];\n    fields: { id: string; name?: string; type: FieldType }[];\n    sourceColumnMap?: Record<string, number | null>;\n  };\n  ro: IImportOptionRo | IInplaceImportOptionRo;\n  logId: string;\n}\n\nexport const TABLE_IMPORT_CSV_CHUNK_QUEUE = 'import-table-csv-chunk-queue';\nexport const TABLE_IMPORT_CSV_CHUNK_QUEUE_CONCURRENCY = Math.max(\n  Math.floor(os.cpus().length * importCpuUsage),\n  1\n);\n\n@Injectable()\n@Processor(TABLE_IMPORT_CSV_CHUNK_QUEUE, {\n  concurrency: TABLE_IMPORT_CSV_CHUNK_QUEUE_CONCURRENCY,\n  lockDuration: 600000,\n  lockRenewTime: 300000,\n  stalledInterval: 30000,\n  maxStalledCount: 2,\n})\nexport class ImportTableCsvChunkQueueProcessor extends WorkerHost {\n  public static readonly JOB_ID_PREFIX = 'import-table-csv-chunk';\n\n  private logger = new Logger(ImportTableCsvChunkQueueProcessor.name);\n  private importQueueEvents?: QueueEvents;\n\n  constructor(\n    private readonly notificationService: NotificationService,\n    private readonly importTableCsvQueueProcessor: ImportTableCsvQueueProcessor,\n    private readonly importTableResultQueueProcessor: ImportTableResultQueueProcessor,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(TABLE_IMPORT_CSV_CHUNK_QUEUE) public readonly queue: Queue<ITableImportChunkJob>,\n    private readonly cacheService: CacheService,\n    private readonly i18n: I18nService<I18nTranslations>,\n    private readonly prismaService: PrismaService,\n    @Optional() private readonly importMetrics?: ImportMetricsService,\n    @Optional() private readonly importTracing?: ImportTracingService\n  ) {\n    super();\n    // When BACKEND_CACHE_REDIS_URI is not set, queues are backed by the local\n    // fallback implementation instead of BullMQ. In that case the injected\n    // queue object does not expose BullMQ's `opts.connection`, so we must guard\n    // against it to avoid throwing during application bootstrap (e.g. e2e).\n    const underlyingQueue = this.importTableCsvQueueProcessor.queue as Queue<unknown> & {\n      // `opts` only exists when using the real BullMQ queue\n      opts?: { connection?: unknown };\n    };\n\n    const connection = underlyingQueue?.opts?.connection;\n\n    if (connection) {\n      this.importQueueEvents = new QueueEvents(TABLE_IMPORT_CSV_QUEUE, {\n        // Reuse the Redis connection configuration of the import queue\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        connection: connection as any,\n      });\n    } else {\n      this.logger.log(\n        'ImportTableCsvChunkQueueProcessor initialized without Redis connection; QueueEvents disabled (fallback queue in use).'\n      );\n    }\n  }\n\n  private async getUserLang(userId: string): Promise<string> {\n    try {\n      const user = await this.prismaService.user.findUnique({\n        where: { id: userId, deletedTime: null },\n        select: { lang: true },\n      });\n      return user?.lang ?? 'en';\n    } catch {\n      return 'en';\n    }\n  }\n\n  private createTranslateFn(lang?: string): ITranslateFn {\n    return (key: I18nPath, args?: Record<string, string>) =>\n      this.i18n.t(key, { args, lang: lang ?? 'en' }) as string;\n  }\n\n  private getImportErrorNotification(\n    tableName: string,\n    errorMessage: string\n  ): ILocalization<I18nPath> {\n    if (errorMessage === OVER_PLAN_ROW_COUNT_ERROR_MESSAGE) {\n      return {\n        i18nKey: 'common.email.templates.notify.import.table.planLimitExceeded.message' as I18nPath,\n        context: { tableName },\n      };\n    }\n    return {\n      i18nKey: 'common.email.templates.notify.import.table.failed.message',\n      context: { tableName, errorMessage },\n    };\n  }\n\n  public async process(job: Job<ITableImportChunkJob>) {\n    const {\n      baseId,\n      table,\n      userId,\n      options: { notification },\n    } = job.data;\n    const importStartTime = Date.now();\n    const fileType = job.data.importerParams.fileType;\n    const operationType = job.data.recordsCal.sourceColumnMap ? 'inplace' : 'create_table';\n    const { sourceColumnMap } = job.data.recordsCal;\n\n    try {\n      this.logger.log(\n        `start chunk data job concurrency: ${TABLE_IMPORT_CSV_CHUNK_QUEUE_CONCURRENCY}`\n      );\n      const manifest = await this.resolveDataByWorker(job);\n      this.logger.log(`import data to ${table.id} chunk data job completed`);\n\n      const stats = {\n        success: manifest.successCount,\n        failed: manifest.failedCount,\n        total: manifest.successCount + manifest.failedCount,\n      };\n\n      this.importTracing?.setImportAttributes({ rows: stats.total });\n      this.importMetrics?.recordImportComplete({\n        fileType,\n        operationType,\n        durationMs: Date.now() - importStartTime,\n      });\n\n      const importJobId = String(job.id);\n      await this.cacheService.setDetail(\n        getImportResultManifestKey(importJobId) as `import:result:manifest:${string}`,\n        manifest,\n        IMPORT_RESULT_MANIFEST_TTL_SECONDS\n      );\n\n      await this.importTableResultQueueProcessor.queue.add(\n        TABLE_IMPORT_RESULT_QUEUE,\n        {\n          jobId: importJobId,\n          baseId,\n          table,\n          userId,\n          sourceColumnMap,\n          notification,\n          attachmentUrl: job?.data?.importerParams?.attachmentUrl,\n        },\n        {\n          // Some queue backends reject custom IDs containing \":\".\n          // Keep it derived from parent jobId, but normalize to safe chars.\n          jobId: `${importJobId.replace(/:/g, '_')}_result`,\n          removeOnComplete: 1000,\n          removeOnFail: 1000,\n        }\n      );\n\n      return stats;\n    } catch (error) {\n      this.importMetrics?.recordImportError({\n        fileType,\n        operationType,\n        errorType: error instanceof ImportError ? 'import_error' : 'unknown',\n      });\n      let finalMessage: string | ILocalization<I18nPath> = '';\n      if (error instanceof ImportError && error.range) {\n        const range = error.range;\n        finalMessage = {\n          i18nKey: 'common.email.templates.notify.import.table.aborted.message',\n          context: {\n            tableName: table.name,\n            errorMessage: error.message,\n            range: `${range[0]}, ${range[1]}`,\n          },\n        };\n      } else if (error instanceof Error) {\n        finalMessage = this.getImportErrorNotification(table.name, error.message);\n      }\n\n      if (notification && finalMessage) {\n        this.notificationService.sendImportResultNotify({\n          baseId,\n          tableId: table.id,\n          toUserId: userId,\n          message: finalMessage,\n        });\n      }\n\n      this.logger.error('import csv chunk error: ', error);\n      // throw to @OnWorkerEvent('error')\n      throw error;\n    }\n  }\n\n  private async resolveDataByWorker(\n    job: Job<ITableImportChunkJob>\n  ): Promise<IImportResultManifest> {\n    const jobId = String(job.id);\n    const jobData = job.data;\n    const { importerParams, table, options } = jobData;\n\n    const workerId = `worker_${getRandomString(8)}`;\n    const path = getWorkerPath('parse');\n\n    const { attachmentUrl, fileType, maxRowCount } = importerParams;\n\n    const { skipFirstNLines, sheetKey, notification } = options;\n\n    const importer = importerFactory(fileType, {\n      url: attachmentUrl,\n      type: fileType,\n      maxRowCount,\n    });\n\n    const worker = new Worker(path, {\n      workerData: {\n        config: importer.getConfig(),\n        options: {\n          key: sheetKey,\n          notification: notification,\n          skipFirstNLines: skipFirstNLines,\n        },\n        id: workerId,\n      },\n    });\n\n    let recordCount = 1;\n    let successCount = 0;\n    let failedCount = 0;\n    const errorFilePaths: string[] = [];\n\n    // Build fieldId→name map for resolving field IDs in error messages\n    const { columnInfo, sourceColumnMap, fields } = jobData.recordsCal;\n    const fieldIdToName = new Map(fields.map((f) => [f.id, f.name ?? f.id]));\n\n    const userLang = await this.getUserLang(jobData.userId);\n    const translate = this.createTranslateFn(userLang);\n\n    // Build sparse field names to preserve original CSV column order.\n    const fieldNames: string[] = [];\n    let maxWidth = 1;\n    if (columnInfo?.length) {\n      for (const col of columnInfo) {\n        fieldNames[col.sourceColumnIndex] = col.name;\n        maxWidth = Math.max(maxWidth, col.sourceColumnIndex + 1);\n      }\n    } else if (sourceColumnMap) {\n      for (const [fieldId, sourceIndex] of Object.entries(sourceColumnMap)) {\n        if (sourceIndex !== null) {\n          fieldNames[sourceIndex] = fieldIdToName.get(fieldId) ?? fieldId;\n          maxWidth = Math.max(maxWidth, sourceIndex + 1);\n        }\n      }\n    }\n\n    return new Promise<IImportResultManifest>((resolve, reject) => {\n      worker.on('message', async (result) => {\n        const { type } = result;\n        switch (type) {\n          case 'chunk':\n            ({ recordCount, successCount, failedCount } = await this.handleChunkMessage({\n              result,\n              sheetKey,\n              workerId,\n              jobData,\n              jobId,\n              tableId: table.id,\n              maxWidth,\n              userLang,\n              translate,\n              fieldIdToName,\n              errorFilePaths,\n              recordCount,\n              successCount,\n              failedCount,\n              worker,\n              parentJob: job,\n            }));\n            break;\n          case 'finished':\n            worker.terminate();\n            resolve({\n              successCount,\n              failedCount,\n              errorFilePaths,\n              fieldNames,\n              maxWidth,\n            });\n            break;\n          case 'error':\n            worker.terminate();\n            reject(new Error(result.data as string));\n            break;\n        }\n      });\n      worker.on('error', (e) => {\n        worker.terminate();\n        reject(e);\n      });\n      worker.on('exit', (code) => {\n        this.logger.log(`Worker stopped with exit code ${code}`);\n      });\n    });\n  }\n\n  private async handleChunkMessage(params: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    result: any;\n    sheetKey: string;\n    workerId: string;\n    jobData: ITableImportChunkJob;\n    jobId: string;\n    tableId: string;\n    maxWidth: number;\n    userLang: string;\n    translate: ITranslateFn;\n    fieldIdToName: Map<string, string>;\n    errorFilePaths: string[];\n    recordCount: number;\n    successCount: number;\n    failedCount: number;\n    worker: Worker;\n    parentJob: Job<ITableImportChunkJob>;\n  }): Promise<{ recordCount: number; successCount: number; failedCount: number }> {\n    const {\n      result,\n      sheetKey,\n      workerId,\n      jobData,\n      jobId,\n      tableId,\n      maxWidth,\n      userLang,\n      translate,\n      fieldIdToName,\n      errorFilePaths,\n      worker,\n      parentJob,\n    } = params;\n    let { recordCount, successCount, failedCount } = params;\n    const { data, chunkId, id, lastChunk } = result;\n    const rawRecords = (data as Record<string, unknown>)?.[sheetKey];\n    const records: unknown[][] = Array.isArray(rawRecords)\n      ? (rawRecords.filter((row) => row != null) as unknown[][])\n      : [];\n    recordCount += records.length;\n    if (records.length === 0) {\n      worker.postMessage({ type: 'done', chunkId });\n      return { recordCount, successCount, failedCount };\n    }\n    try {\n      if (workerId === id) {\n        const chunkResult = await this.chunkToFile(\n          jobData,\n          jobId,\n          tableId,\n          [recordCount - records.length, recordCount - 1],\n          records,\n          lastChunk,\n          { maxWidth, userLang }\n        );\n        if (chunkResult) {\n          if (chunkResult.errorFilePath && chunkResult.failedCount > 0) {\n            errorFilePaths.push(chunkResult.errorFilePath);\n          }\n          successCount += chunkResult.successCount;\n          failedCount += chunkResult.failedCount;\n        }\n      }\n      await parentJob.updateProgress({ successCount, failedCount });\n      worker.postMessage({ type: 'done', chunkId });\n      return { recordCount, successCount, failedCount };\n    } catch (e: unknown) {\n      const error = e as Error;\n      const chunkStartRow = recordCount - records.length;\n      this.logger.error(\n        `Chunk [${chunkStartRow}, ${recordCount - 1}] had a catastrophic error: ${error?.message}`,\n        error?.stack\n      );\n      const rawMsg = `Chunk processing failed: ${error?.message ?? String(e)}`;\n      const classified = classifyImportError(rawMsg);\n      const translatedMsg = formatClassifiedError(classified, translate, fieldIdToName);\n      const path = await this.writeCatastrophicChunkErrors(\n        jobId,\n        [chunkStartRow, recordCount - 1],\n        records,\n        translatedMsg,\n        maxWidth\n      );\n      if (path) {\n        errorFilePaths.push(path);\n      }\n      failedCount += records.length;\n      worker.postMessage({ type: 'done', chunkId });\n      return { recordCount, successCount, failedCount };\n    }\n  }\n\n  private async chunkToFile(\n    job: ITableImportChunkJob,\n    jobId: string,\n    tableId: string,\n    range: [number, number],\n    records: unknown[][],\n    lastChunk: boolean,\n    errorReportConfig: { maxWidth: number; userLang: string }\n  ): Promise<IChunkImportResult | undefined> {\n    const { baseId, userId, origin, table, recordsCal, ro, logId } = job;\n\n    const { columnInfo, fields, sourceColumnMap } = recordsCal;\n\n    const bucket = StorageAdapter.getBucket(UploadType.Import);\n\n    // Filter out undefined/null rows that can come from the worker parser\n    // (e.g. trailing empty lines in the source file). Papa.unparse will throw\n    // \"Cannot read properties of undefined (reading 'length')\" on such rows.\n    const cleanRecords = records.filter((row) => row != null);\n\n    if (cleanRecords.length === 0) {\n      return undefined;\n    }\n\n    const csvString = Papa.unparse(cleanRecords);\n\n    // add BOM to make sure the csv file can be opened correctly in excel with UTF-8 encoding\n    const csvWithBOM = '\\uFEFF' + csvString;\n\n    const csvStream = Readable.from(csvWithBOM, { encoding: 'utf8' });\n\n    const pathDir = StorageAdapter.getDir(UploadType.Import);\n\n    const { path } = await this.storageAdapter.uploadFileStream(\n      bucket,\n      `${pathDir}/${jobId}/${tableId}_[${range[0]},${range[1]}].csv`,\n      csvStream,\n      {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': 'text/csv; charset=utf-8',\n      }\n    );\n\n    const chunkJobId = this.importTableCsvQueueProcessor.getChunkImportJobId(jobId, range);\n\n    const jobData = {\n      baseId,\n      userId,\n      origin,\n      path,\n      columnInfo,\n      fields,\n      sourceColumnMap,\n      table,\n      range,\n      notification: false, // Notification now handled by parent after aggregation\n      lastChunk,\n      parentJobId: jobId,\n      ro,\n      logId,\n      errorReportConfig,\n    };\n\n    if (this.importQueueEvents) {\n      // Redis mode: use the queue and wait for the result\n      const importJob = await this.importTableCsvQueueProcessor.queue.add(\n        TABLE_IMPORT_CSV_QUEUE,\n        jobData,\n        {\n          jobId: chunkJobId,\n          removeOnComplete: 1000,\n          removeOnFail: 1000,\n        }\n      );\n\n      // Wait for the current chunk import job to complete before processing the next chunk,\n      // ensuring that all chunks of the same import task are executed sequentially across multiple Pods.\n      return (await importJob.waitUntilFinished(\n        this.importQueueEvents,\n        200000\n      )) as IChunkImportResult;\n    }\n\n    // Fallback (non-Redis) mode: call the processor directly to get the result,\n    // since the local queue's fire-and-forget approach discards return values.\n    const fakeJob = {\n      id: chunkJobId,\n      data: jobData,\n    } as Job;\n    return await this.importTableCsvQueueProcessor.process(fakeJob);\n  }\n\n  private async writeCatastrophicChunkErrors(\n    jobId: string,\n    range: [number, number],\n    rows: unknown[][],\n    translatedMessage: string,\n    maxWidth: number\n  ): Promise<string | undefined> {\n    if (!rows.length) {\n      return undefined;\n    }\n    const bucket = StorageAdapter.getBucket(UploadType.Import);\n    const pathDir = StorageAdapter.getDir(UploadType.Import);\n    const errorPath = `${pathDir}/${jobId}/chunk_errors_[${range[0]},${range[1]}].csv`;\n    const stream = new PassThrough();\n    const uploadPromise = this.storageAdapter.uploadFileStream(bucket, errorPath, stream, {\n      'Content-Type': 'text/csv; charset=utf-8',\n    });\n    for (const row of rows) {\n      const originalCells = Array.isArray(row) ? row : [];\n      const padded = [...originalCells];\n      while (padded.length < maxWidth) padded.push('');\n      const line = Papa.unparse([[...padded, translatedMessage]], { header: false });\n      stream.write(line.endsWith('\\n') ? line : line + '\\n');\n    }\n    stream.end();\n    try {\n      const result = await uploadPromise;\n      return result.path;\n    } catch (error) {\n      this.logger.warn(`Failed to write catastrophic chunk errors for [${range}]`, error);\n      return undefined;\n    }\n  }\n\n  @OnWorkerEvent('error')\n  async onError(job: Job) {\n    if (!job?.data) {\n      this.logger.error('import csv job data is undefined');\n      return;\n    }\n\n    const { table, range } = job.data;\n    const jobId = String(job.id);\n\n    this.logger.error(`import data to ${table.id} chunk data job failed, range: [${range}]`);\n\n    const allJobs = (await this.queue.getJobs(['waiting', 'active'])).filter((job) =>\n      job.id?.startsWith(jobId)\n    );\n\n    for (const relatedJob of allJobs) {\n      try {\n        await relatedJob.remove();\n      } catch (error) {\n        this.logger.warn(`Failed to cancel job ${relatedJob.id}: ${error}`);\n      }\n    }\n\n    const localPresence = this.importTableCsvQueueProcessor.createImportPresence(\n      table.id,\n      'status'\n    );\n    this.importTableCsvQueueProcessor.setImportStatus(localPresence, true);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-csv.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventEmitterModule } from '@nestjs/event-emitter';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { NotificationModule } from '../../notification/notification.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { ImportTableCsvQueueProcessor, TABLE_IMPORT_CSV_QUEUE } from './import-csv.processor';\n\n@Module({\n  providers: [ImportTableCsvQueueProcessor],\n  imports: [\n    EventJobModule.registerQueue(TABLE_IMPORT_CSV_QUEUE),\n    ShareDbModule,\n    NotificationModule,\n    RecordOpenApiModule,\n    StorageModule,\n    EventEmitterModule,\n  ],\n  exports: [ImportTableCsvQueueProcessor],\n})\nexport class ImportCsvModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-csv.processor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { PassThrough } from 'stream';\nimport { text } from 'stream/consumers';\nimport { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport {\n  FieldKeyType,\n  FieldType,\n  getActionTriggerChannel,\n  getRandomString,\n  getTableImportChannel,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CreateRecordAction, UploadType } from '@teable/openapi';\nimport type {\n  ICreateRecordsRo,\n  IImportOptionRo,\n  IImportColumn,\n  IInplaceImportOptionRo,\n} from '@teable/openapi';\nimport { Job, Queue } from 'bullmq';\nimport { chunk as chunkArray, toString } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { I18nService } from 'nestjs-i18n';\nimport Papa from 'papaparse';\nimport type { CreateOp } from 'sharedb';\nimport type { LocalPresence } from 'sharedb/lib/client';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport { ShareDbService } from '../../../share-db/share-db.service';\nimport type { IClsStore } from '../../../types/cls';\nimport type { I18nPath, I18nTranslations } from '../../../types/i18n.generated';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { NotificationService } from '../../notification/notification.service';\nimport { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport { classifyImportError, formatClassifiedError } from './import-error-classifier';\nimport type { ITranslateFn } from './import-error-classifier';\nimport { ImportErrorCollector } from './import-error-collector';\nimport { parseBoolean } from './import.class';\n\ninterface ITableImportCsvJob {\n  baseId: string;\n  userId: string;\n  origin?: {\n    ip: string;\n    byApi: boolean;\n    userAgent: string;\n    referer: string;\n  };\n  path: string;\n  columnInfo?: IImportColumn[];\n  fields: { id: string; name?: string; type: FieldType }[];\n  sourceColumnMap?: Record<string, number | null>;\n  table: { id: string; name: string };\n  range: [number, number];\n  notification?: boolean;\n  lastChunk?: boolean;\n  parentJobId: string;\n  ro: IImportOptionRo | IInplaceImportOptionRo;\n  logId: string;\n  /** Provided by parent so child can write errors to S3 instead of returning them via Redis */\n  errorReportConfig?: {\n    maxWidth: number;\n    userLang: string;\n  };\n}\n\nexport const TABLE_IMPORT_CSV_QUEUE = 'import-table-csv-queue';\nexport const SUB_BATCH_SIZE = 50;\n\nexport interface IChunkImportResult {\n  successCount: number;\n  failedCount: number;\n  /** S3 path to headerless CSV rows of failed records (only set when failedCount > 0) */\n  errorFilePath?: string;\n}\n\n@Injectable()\n@Processor(TABLE_IMPORT_CSV_QUEUE, {\n  concurrency: 1,\n})\nexport class ImportTableCsvQueueProcessor extends WorkerHost {\n  public static readonly JOB_ID_PREFIX = 'import-table-csv';\n\n  private logger = new Logger(ImportTableCsvQueueProcessor.name);\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private presences: LocalPresence<any>[] = [];\n\n  constructor(\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly shareDbService: ShareDbService,\n    private readonly notificationService: NotificationService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    @InjectQueue(TABLE_IMPORT_CSV_QUEUE) public readonly queue: Queue<ITableImportCsvJob>,\n    private readonly i18n: I18nService<I18nTranslations>\n  ) {\n    super();\n  }\n\n  public async process(job: Job<ITableImportCsvJob>): Promise<IChunkImportResult> {\n    const { table, notification, baseId, userId, lastChunk, range } = job.data;\n    const localPresence = this.createImportPresence(table.id, 'status');\n    this.setImportStatus(localPresence, true);\n    try {\n      const errorCollector = await this.handleImportChunkCsv(job);\n      await this.emitImportAuditLog(job, errorCollector.successCount);\n\n      let errorFilePath: string | undefined;\n      if (errorCollector.hasErrors()) {\n        errorFilePath = await this.writeChunkErrorsToStorage(job, errorCollector);\n      }\n\n      const result: IChunkImportResult = {\n        successCount: errorCollector.successCount,\n        failedCount: errorCollector.failedCount,\n        errorFilePath,\n      };\n\n      if (lastChunk) {\n        this.setImportStatus(localPresence, false);\n        localPresence.destroy();\n        this.presences = this.presences.filter(\n          (presence) => presence.presenceId !== localPresence.presenceId\n        );\n      }\n\n      return result;\n    } catch (error) {\n      const err = error as Error;\n      notification &&\n        this.notificationService.sendImportResultNotify({\n          baseId,\n          tableId: table.id,\n          toUserId: userId,\n          message: {\n            i18nKey: 'common.email.templates.notify.import.table.aborted.message',\n            context: {\n              tableName: table.name,\n              errorMessage: err.message,\n              range: `${range[0]}, ${range[1]}`,\n            },\n          },\n        });\n\n      throw err;\n    }\n  }\n\n  private async cleanRelativeTask(parentJobId: string) {\n    const allJobs = (await this.queue.getJobs(['waiting', 'active'])).filter((job) =>\n      job.id?.startsWith(parentJobId)\n    );\n\n    for (const relatedJob of allJobs) {\n      relatedJob.remove();\n    }\n  }\n\n  private async handleImportChunkCsv(job: Job<ITableImportCsvJob>): Promise<ImportErrorCollector> {\n    const errorCollector = new ImportErrorCollector();\n\n    await this.cls.run(async () => {\n      this.cls.set('user.id', job.data.userId);\n      this.cls.set('origin', job.data.origin!);\n      this.cls.set('skipRecordAuditLog', true);\n      const { columnInfo, fields, sourceColumnMap, table, range } = job.data;\n      const currentResult = await this.getChunkData(job);\n\n      // Build records with source metadata for error reporting\n      const recordsWithMeta = currentResult.map((row, index) => {\n        const res: {\n          fields: Record<string, unknown>;\n          __sourceRowIndex: number;\n          __sourceData: unknown[];\n        } = {\n          fields: {},\n          __sourceRowIndex: range[0] + index,\n          __sourceData: Array.isArray(row) ? row : [],\n        };\n        // import new table\n        if (columnInfo) {\n          columnInfo.forEach((col, colIndex) => {\n            const { sourceColumnIndex, type } = col;\n            const value = Array.isArray(row) ? row[sourceColumnIndex] : null;\n            res.fields[fields[colIndex].id] =\n              type === FieldType.Checkbox ? parseBoolean(value) : value?.toString();\n          });\n        }\n        // inplace records\n        if (sourceColumnMap) {\n          for (const [key, value] of Object.entries(sourceColumnMap)) {\n            if (value !== null) {\n              const { type } = fields.find((f) => f.id === key) || {};\n              res.fields[key] = type === FieldType.Link ? toString(row[value]) : row[value];\n            }\n          }\n        }\n        return res;\n      });\n\n      if (recordsWithMeta.length === 0) {\n        return;\n      }\n\n      const createFn: (\n        tableId: string,\n        createRecordsRo: ICreateRecordsRo,\n        ignoreMissingFields?: boolean\n      ) => Promise<unknown> = columnInfo\n        ? (tableId, createRecordsRo) =>\n            this.recordOpenApiService.createRecordsOnlySql(tableId, createRecordsRo)\n        : (tableId, createRecordsRo, ignoreMissingFields = false) =>\n            this.recordOpenApiService.multipleCreateRecords(\n              tableId,\n              createRecordsRo,\n              ignoreMissingFields\n            );\n\n      const fieldIdToName = new Map(fields.map((f) => [f.id, f.name ?? f.id]));\n      const fieldIdToType = new Map(fields.map((f) => [f.id, f.type]));\n\n      // Optimistic: try inserting the entire chunk at once.\n      // In the common case (no bad rows), this is a single INSERT for the whole chunk.\n      const cleanRecords = recordsWithMeta.map(({ fields: f }) => ({ fields: f }));\n      try {\n        await createFn(\n          table.id,\n          { fieldKeyType: FieldKeyType.Id, typecast: true, records: cleanRecords },\n          false\n        );\n        errorCollector.addSuccessCount(recordsWithMeta.length);\n      } catch {\n        // Chunk has bad rows — fall back to sub-batch + binary search to locate them\n        const subBatches = chunkArray(recordsWithMeta, SUB_BATCH_SIZE);\n        for (const subBatch of subBatches) {\n          await this.insertWithBinaryFallback(\n            subBatch,\n            createFn,\n            table.id,\n            errorCollector,\n            fieldIdToName,\n            fieldIdToType\n          );\n          await new Promise((resolve) => setImmediate(resolve));\n        }\n      }\n    });\n\n    return errorCollector;\n  }\n\n  /**\n   * Translate collected errors and write them to S3 as headerless CSV rows.\n   * The parent processor will pipe these rows into the final error report stream.\n   * Returns the S3 path, or undefined if writing fails (errors are logged but not rethrown).\n   */\n  private async writeChunkErrorsToStorage(\n    job: Job<ITableImportCsvJob>,\n    errorCollector: ImportErrorCollector\n  ): Promise<string | undefined> {\n    const { errorReportConfig, parentJobId, range, fields } = job.data;\n    const errors = errorCollector.getErrors();\n    if (errors.length === 0) return undefined;\n\n    const maxWidth = errorReportConfig?.maxWidth ?? 1;\n\n    const fieldIdToName = new Map(fields.map((f) => [f.id, f.name ?? f.id]));\n    const translate = this.createTranslateFn(errorReportConfig?.userLang);\n\n    try {\n      const stream = new PassThrough();\n      const bucket = StorageAdapter.getBucket(UploadType.Import);\n      const pathDir = StorageAdapter.getDir(UploadType.Import);\n      const errorPath = `${pathDir}/${parentJobId}/chunk_errors_[${range[0]},${range[1]}].csv`;\n\n      const uploadPromise = this.storageAdapter.uploadFileStream(bucket, errorPath, stream, {\n        'Content-Type': 'text/csv; charset=utf-8',\n      });\n\n      for (const error of errors) {\n        const classified = classifyImportError(error.errorMessage);\n        const translatedMsg = formatClassifiedError(\n          classified,\n          translate,\n          fieldIdToName,\n          error.failedFieldNames\n        );\n        const originalCells = Array.isArray(error.originalData) ? error.originalData : [];\n        const padded = [...originalCells];\n        while (padded.length < maxWidth) padded.push('');\n        const row = [...padded, translatedMsg];\n        const line = Papa.unparse([row], { header: false });\n        stream.write(line.endsWith('\\n') ? line : line + '\\n');\n      }\n\n      stream.end();\n      const result = await uploadPromise;\n      return result.path;\n    } catch (e) {\n      this.logger.warn(`Failed to write chunk errors to S3 for range [${range}]`, e);\n      return undefined;\n    }\n  }\n\n  private createTranslateFn(lang?: string): ITranslateFn {\n    return (key: I18nPath, args?: Record<string, string>) =>\n      this.i18n.t(key, { args, lang: lang ?? 'en' }) as string;\n  }\n\n  /**\n   * Binary search fallback for fault-tolerant record insertion.\n   *\n   * Tries to insert all records at once. On failure, splits in half and recurses.\n   * When down to a single record that fails, logs the error and continues.\n   *\n   * Performance: For N records with K bad ones, takes O(N/B + K*log(B)) INSERT calls\n   * where B is the sub-batch size, vs O(N) for naive single-record fallback.\n   */\n  private async insertWithBinaryFallback(\n    recordsWithMeta: {\n      fields: Record<string, unknown>;\n      __sourceRowIndex: number;\n      __sourceData: unknown[];\n    }[],\n    createFn: (\n      tableId: string,\n      createRecordsRo: ICreateRecordsRo,\n      ignoreMissingFields?: boolean\n    ) => Promise<unknown>,\n    tableId: string,\n    errorCollector: ImportErrorCollector,\n    fieldIdToName: Map<string, string>,\n    fieldIdToType: Map<string, FieldType>\n  ): Promise<void> {\n    // Strip metadata before passing to createFn\n    const cleanRecords = recordsWithMeta.map(({ fields }) => ({ fields }));\n\n    try {\n      await createFn(\n        tableId,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n          records: cleanRecords,\n        },\n        false\n      );\n      errorCollector.addSuccessCount(recordsWithMeta.length);\n    } catch (e: unknown) {\n      if (recordsWithMeta.length === 1) {\n        const record = recordsWithMeta[0];\n        const rawMessage = e instanceof Error ? e.message : String(e);\n        this.logger.warn(\n          `Import row ${record.__sourceRowIndex} failed: ${rawMessage.slice(0, 200)}`\n        );\n        const failedFieldNames = this.identifyFailingFields(\n          rawMessage,\n          record.fields,\n          fieldIdToName,\n          fieldIdToType\n        );\n        errorCollector.add({\n          rowIndex: record.__sourceRowIndex,\n          originalData: record.__sourceData,\n          errorMessage: rawMessage,\n          failedFieldNames: failedFieldNames.length > 0 ? failedFieldNames : undefined,\n        });\n        return;\n      }\n\n      // Binary split: try each half separately\n      const mid = Math.ceil(recordsWithMeta.length / 2);\n      const firstHalf = recordsWithMeta.slice(0, mid);\n      const secondHalf = recordsWithMeta.slice(mid);\n\n      await this.insertWithBinaryFallback(\n        firstHalf,\n        createFn,\n        tableId,\n        errorCollector,\n        fieldIdToName,\n        fieldIdToType\n      );\n      await this.insertWithBinaryFallback(\n        secondHalf,\n        createFn,\n        tableId,\n        errorCollector,\n        fieldIdToName,\n        fieldIdToType\n      );\n    }\n  }\n\n  private static readonly DATE_FIELD_TYPES = new Set([\n    FieldType.Date,\n    FieldType.CreatedTime,\n    FieldType.LastModifiedTime,\n  ]);\n\n  private static readonly DATE_ERROR_RE =\n    /time zone displacement out of range|date\\/time field value out of range/i;\n\n  // Use atomic-style regex: field IDs are word chars separated by \", \"\n  private static readonly FIELD_VALIDATION_RE =\n    /Fields?\\s+(\\w+(?:,\\s*\\w+)*)\\s+(?:not null|unique) validation/i;\n\n  private identifyFailingFields(\n    rawMessage: string,\n    recordFields: Record<string, unknown>,\n    fieldIdToName: Map<string, string>,\n    fieldIdToType: Map<string, FieldType>\n  ): string[] {\n    if (ImportTableCsvQueueProcessor.DATE_ERROR_RE.test(rawMessage)) {\n      return this.identifyDateFields(rawMessage, recordFields, fieldIdToName, fieldIdToType);\n    }\n\n    const fieldIdMatch = rawMessage.match(ImportTableCsvQueueProcessor.FIELD_VALIDATION_RE);\n    if (fieldIdMatch) {\n      return fieldIdMatch[1].split(/,\\s*/).map((id) => fieldIdToName.get(id.trim()) ?? id.trim());\n    }\n\n    return [];\n  }\n\n  private identifyDateFields(\n    rawMessage: string,\n    recordFields: Record<string, unknown>,\n    fieldIdToName: Map<string, string>,\n    fieldIdToType: Map<string, FieldType>\n  ): string[] {\n    const valueMatch = rawMessage.match(/\"([^\"]+)\"/);\n    const errorValue = valueMatch?.[1] ?? '';\n\n    const dateEntries = Object.entries(recordFields).filter(([fieldId]) =>\n      ImportTableCsvQueueProcessor.DATE_FIELD_TYPES.has(fieldIdToType.get(fieldId)!)\n    );\n\n    // Try exact value match first\n    const exact = dateEntries\n      .filter(([, value]) => value != null && String(value).includes(errorValue))\n      .map(([fieldId]) => fieldIdToName.get(fieldId) ?? fieldId);\n    if (exact.length > 0) return exact;\n\n    // Fallback: all date fields that have non-null values\n    return dateEntries\n      .filter(([, value]) => value != null)\n      .map(([fieldId]) => fieldIdToName.get(fieldId) ?? fieldId);\n  }\n\n  private async getChunkData(job: Job<ITableImportCsvJob>): Promise<unknown[][]> {\n    const { path } = job.data;\n    const stream = await this.storageAdapter.downloadFile(\n      StorageAdapter.getBucket(UploadType.Import),\n      path\n    );\n    // Read full content so PapaParse can correctly handle newlines inside quoted cells.\n    // toLineDelimitedStream would split on ALL newlines (including inside quotes),\n    // causing \"product\\nProduct image\" to become two rows instead of one.\n    const csvString = await text(stream);\n    return new Promise((resolve, reject) => {\n      Papa.parse(csvString, {\n        download: false,\n        dynamicTyping: false,\n        complete: (result) => {\n          resolve(result.data as unknown[][]);\n        },\n        error: (err: Error) => {\n          reject(err);\n        },\n      });\n    });\n  }\n\n  private updateRowCount(tableId: string) {\n    const localPresence = this.createImportPresence(tableId, 'rowCount');\n    localPresence.submit([{ actionKey: 'addRecord' }], (error) => {\n      error && this.logger.error(error);\n    });\n\n    const updateEmptyOps = {\n      src: 'unknown',\n      seq: 1,\n      m: {\n        ts: Date.now(),\n      },\n      create: {\n        type: 'json0',\n        data: undefined,\n      },\n      v: 0,\n    } as CreateOp;\n    this.shareDbService.publishRecordChannel(tableId, updateEmptyOps);\n  }\n\n  // this is for cache refresh\n  private async updateTableLastModified(tableId: string) {\n    await this.prismaService.txClient().tableMeta.update({\n      where: { id: tableId },\n      data: { lastModifiedTime: new Date().toISOString() },\n    });\n  }\n\n  setImportStatus(presence: LocalPresence<unknown>, loading: boolean) {\n    presence.submit(\n      {\n        loading,\n      },\n      (error) => {\n        error && this.logger.error(error);\n      }\n    );\n  }\n\n  createImportPresence(tableId: string, type: 'rowCount' | 'status' = 'status') {\n    const channel =\n      type === 'rowCount' ? getActionTriggerChannel(tableId) : getTableImportChannel(tableId);\n    const existPresence = this.presences.find(({ presence }) => {\n      return presence.channel === channel;\n    });\n    if (existPresence) {\n      return existPresence;\n    }\n    const presence = this.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create(channel);\n    this.presences.push(localPresence);\n    return localPresence;\n  }\n\n  public getChunkImportJobIdPrefix(parentId: string) {\n    return `${parentId}_import_${getRandomString(6)}`;\n  }\n\n  public getChunkImportJobId(jobId: string, range: [number, number]) {\n    const prefix = this.getChunkImportJobIdPrefix(jobId);\n    return `${prefix}_[${range[0]},${range[1]}]`;\n  }\n\n  private async emitImportAuditLog(job: Job<ITableImportCsvJob>, successCount: number) {\n    const { table, origin, userId, logId } = job.data;\n    const { ro } = job.data;\n\n    const actionType =\n      ro && typeof ro === 'object' && 'worksheets' in ro\n        ? CreateRecordAction.Import\n        : CreateRecordAction.InplaceImport;\n\n    // emit event to audit log\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId);\n      this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: actionType,\n        resourceId: table.id,\n        recordCount: successCount,\n        params: { fileType: ro?.fileType },\n        logId,\n      });\n    });\n  }\n\n  @OnWorkerEvent('active')\n  onWorkerEvent(job: Job) {\n    const { table, range } = job.data;\n    this.logger.log(`import data to ${table.id} job started, range: [${range}]`);\n  }\n\n  @OnWorkerEvent('error')\n  async onError(job: Job) {\n    if (!job?.data) {\n      this.logger.error('import csv job data is undefined');\n      return;\n    }\n    const { table, range, parentJobId } = job.data;\n    this.logger.error(`import data to ${table.id} job failed, range: [${range}]`);\n    this.cleanRelativeTask(parentJobId);\n    const localPresence = this.createImportPresence(table.id, 'status');\n    this.setImportStatus(localPresence, false);\n  }\n\n  @OnWorkerEvent('completed')\n  async onCompleted(job: Job) {\n    const { table, range, columnInfo } = job.data;\n    this.logger.log(`import data to ${table.id} job completed, range: [${range}]`);\n    // create new table need update row count and table last modified\n    if (columnInfo) {\n      await this.updateTableLastModified(table.id);\n      this.updateRowCount(table.id);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-error-classifier.ts",
    "content": "import type { I18nPath } from '../../../types/i18n.generated';\n\nexport enum ImportErrorType {\n  DateOutOfRange = 'DATE_OUT_OF_RANGE',\n  PlanRowLimit = 'PLAN_ROW_LIMIT',\n  NotNullValidation = 'NOT_NULL_VALIDATION',\n  UniqueValidation = 'UNIQUE_VALIDATION',\n  RequestTimeout = 'REQUEST_TIMEOUT',\n  ChunkProcessingFailed = 'CHUNK_PROCESSING_FAILED',\n  Unknown = 'UNKNOWN',\n}\n\nexport interface IClassifiedError {\n  type: ImportErrorType;\n  i18nKey: I18nPath;\n  /** Context variables for i18n interpolation (e.g. {{fields}}, {{value}}) */\n  context: Record<string, string>;\n  rawMessage: string;\n}\n\ninterface IErrorMatcher {\n  type: ImportErrorType;\n  pattern: RegExp;\n  i18nKey: I18nPath;\n  extractContext: (match: RegExpMatchArray, raw: string) => Record<string, string>;\n}\n\n/**\n * To add a new error pattern:\n * 1. Add enum value to ImportErrorType\n * 2. Add matcher entry to ERROR_MATCHERS with pattern, i18nKey, context extractor\n * 3. Add i18n translations for the new key in all locale files under \"import.error.*\"\n */\nconst errorMatchers: IErrorMatcher[] = [\n  {\n    type: ImportErrorType.DateOutOfRange,\n    pattern: /time zone displacement out of range|date\\/time field value out of range/i,\n    i18nKey: 'common.import.error.dateOutOfRange' as I18nPath,\n    extractContext: (_match, raw) => {\n      const valueMatch = raw.match(/\"([^\"]+)\"/);\n      return { value: valueMatch?.[1] ?? '' };\n    },\n  },\n  {\n    type: ImportErrorType.PlanRowLimit,\n    pattern: /upgrade your plan to import more records/i,\n    i18nKey: 'common.import.error.planRowLimit' as I18nPath,\n    extractContext: () => ({}),\n  },\n  {\n    type: ImportErrorType.NotNullValidation,\n    pattern: /Fields?\\s+(\\w+(?:\\s*,\\s*\\w+)*)\\s+not null validation failed/i,\n    i18nKey: 'common.import.error.notNullValidation' as I18nPath,\n    extractContext: (match) => ({\n      fieldIds: match[1]?.trim() ?? '',\n    }),\n  },\n  {\n    type: ImportErrorType.UniqueValidation,\n    pattern: /Fields?\\s+(\\w+(?:\\s*,\\s*\\w+)*)\\s+unique validation failed/i,\n    i18nKey: 'common.import.error.uniqueValidation' as I18nPath,\n    extractContext: (match) => ({\n      fieldIds: match[1]?.trim() ?? '',\n    }),\n  },\n  {\n    type: ImportErrorType.RequestTimeout,\n    pattern: /request timeout/i,\n    i18nKey: 'common.import.error.requestTimeout' as I18nPath,\n    extractContext: () => ({}),\n  },\n  {\n    type: ImportErrorType.ChunkProcessingFailed,\n    pattern: /^Chunk processing failed:/i,\n    i18nKey: 'common.import.error.chunkProcessingFailed' as I18nPath,\n    extractContext: (_match, raw) => ({\n      reason: raw.replace(/^Chunk processing failed:\\s*/i, ''),\n    }),\n  },\n];\n\nexport function classifyImportError(rawMessage: string): IClassifiedError {\n  for (const matcher of errorMatchers) {\n    const match = rawMessage.match(matcher.pattern);\n    if (match) {\n      return {\n        type: matcher.type,\n        i18nKey: matcher.i18nKey,\n        context: matcher.extractContext(match, rawMessage),\n        rawMessage,\n      };\n    }\n  }\n  return {\n    type: ImportErrorType.Unknown,\n    i18nKey: 'common.import.error.unknown' as I18nPath,\n    context: { message: rawMessage },\n    rawMessage,\n  };\n}\n\n/**\n * Resolve fieldIds in the classified context to human-readable field names.\n * Mutates the context in-place: replaces \"fieldIds\" key with \"fields\" key.\n */\nexport function resolveClassifiedFieldNames(\n  classified: IClassifiedError,\n  fieldMap: Map<string, string>\n): IClassifiedError {\n  if (!classified.context.fieldIds) {\n    return classified;\n  }\n  const names = classified.context.fieldIds\n    .split(/,\\s*/)\n    .map((id) => fieldMap.get(id.trim()) ?? id.trim())\n    .join(', ');\n  return {\n    ...classified,\n    context: {\n      ...classified.context,\n      fields: names,\n    },\n  };\n}\n\nexport type ITranslateFn = (key: I18nPath, args?: Record<string, string>) => string;\n\n/**\n * Format a classified error into a human-readable localized message.\n * @param classified - output from classifyImportError\n * @param translate - i18n translation function (key, args) => string\n * @param fieldMap - optional map from fieldId to fieldName\n * @param failedFieldNames - optional pre-resolved field names from child processor\n */\nexport function formatClassifiedError(\n  classified: IClassifiedError,\n  translate: ITranslateFn,\n  fieldMap?: Map<string, string>,\n  failedFieldNames?: string[]\n): string {\n  const resolved = fieldMap ? resolveClassifiedFieldNames(classified, fieldMap) : classified;\n\n  // Collect all available field names from both sources, deduplicated\n  const allFieldNames: string[] = [];\n  if (resolved.context.fields) {\n    allFieldNames.push(...resolved.context.fields.split(', '));\n  }\n  if (failedFieldNames?.length) {\n    for (const name of failedFieldNames) {\n      if (!allFieldNames.includes(name)) {\n        allFieldNames.push(name);\n      }\n    }\n  }\n\n  const fieldHint = allFieldNames.length ? `[${allFieldNames.join(', ')}] ` : '';\n\n  const finalContext = { ...resolved.context, fieldHint };\n\n  return translate(resolved.i18nKey, finalContext);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-error-collector.ts",
    "content": "import type { PassThrough, Readable } from 'stream';\nimport Papa from 'papaparse';\n\nexport interface IImportError {\n  rowIndex: number;\n  originalData: unknown[];\n  errorMessage: string;\n  /** Field name(s) that caused the error, identified by the child processor */\n  failedFieldNames?: string[];\n}\n\nexport interface IImportStats {\n  success: number;\n  failed: number;\n  total: number;\n}\n\nexport interface IUploadResult {\n  path: string;\n}\n\nexport class ImportErrorCollector {\n  private errors: IImportError[] = [];\n  private _successCount = 0;\n  private _failedCount = 0;\n  private _fieldNames: string[] = [];\n  private _streamWriter: StreamingErrorReportWriter | null = null;\n\n  constructor(fieldNames?: string[]) {\n    this._fieldNames = fieldNames ?? [];\n  }\n\n  setFieldNames(names: string[]): void {\n    this._fieldNames = names;\n  }\n\n  /**\n   * Enable streaming mode: errors are written to a PassThrough stream as they arrive,\n   * and the stream is uploaded directly to object storage (S3/MinIO). No temp file,\n   * no local disk usage - suitable for serverless and restricted environments.\n   *\n   * @param stream PassThrough stream - we write CSV to it, upload reads from it.\n   * @param maxWidth Maximum number of columns for the CSV header (from field mapping).\n   * @param startUpload Called when the first error arrives - starts the upload. Returns promise with path.\n   */\n  enableStreamingToStorage(\n    stream: PassThrough,\n    maxWidth: number,\n    startUpload: (stream: PassThrough) => Promise<IUploadResult>\n  ): void {\n    this._streamWriter = new StreamingErrorReportWriter(\n      stream,\n      this._fieldNames,\n      maxWidth,\n      startUpload\n    );\n  }\n\n  add(error: IImportError): void {\n    this._failedCount++;\n    if (this._streamWriter) {\n      this._streamWriter.appendError(error);\n    } else {\n      this.errors.push(error);\n    }\n  }\n\n  addSuccessCount(count: number): void {\n    this._successCount += count;\n  }\n\n  addFailedCount(count: number): void {\n    this._failedCount += count;\n  }\n\n  hasErrors(): boolean {\n    return this._failedCount > 0;\n  }\n\n  get successCount(): number {\n    return this._successCount;\n  }\n\n  get failedCount(): number {\n    return this._failedCount;\n  }\n\n  get isTruncated(): boolean {\n    return !this._streamWriter && this._failedCount > this.errors.length;\n  }\n\n  getStats(): IImportStats {\n    return {\n      success: this._successCount,\n      failed: this._failedCount,\n      total: this._successCount + this._failedCount,\n    };\n  }\n\n  getErrors(): readonly IImportError[] {\n    return this.errors;\n  }\n\n  /**\n   * End the stream and return the upload promise. Call when all chunks are processed.\n   * Resolves to the upload result (with path) when the stream has been fully consumed.\n   */\n  async closeStream(): Promise<IUploadResult | undefined> {\n    if (this._streamWriter) {\n      const result = await this._streamWriter.close();\n      this._streamWriter = null;\n      return result;\n    }\n    return undefined;\n  }\n\n  /**\n   * Generate a CSV error report with BOM header for Excel compatibility.\n   * Only used when NOT in streaming mode (errors held in memory).\n   */\n  generateCsvReport(): string {\n    if (this._streamWriter) {\n      throw new Error('generateCsvReport cannot be used in streaming mode');\n    }\n    if (this.errors.length === 0) {\n      return '';\n    }\n\n    const sorted = [...this.errors].sort((a, b) => a.rowIndex - b.rowIndex);\n    const maxWidth = Math.max(\n      this._fieldNames.length,\n      ...sorted.map((e) => (Array.isArray(e.originalData) ? e.originalData.length : 0))\n    );\n    const headers = Array.from(\n      { length: maxWidth },\n      (_, i) => this._fieldNames[i] || `Column ${i + 1}`\n    );\n    const headerRow = [...headers, '__error'];\n    const dataRows = sorted.map((error) => {\n      const originalCells = Array.isArray(error.originalData) ? error.originalData : [];\n      const padded = [...originalCells];\n      while (padded.length < maxWidth) padded.push('');\n      return [...padded, error.errorMessage];\n    });\n    const csvString = Papa.unparse({ fields: headerRow, data: dataRows });\n    return '\\uFEFF' + csvString;\n  }\n\n  /**\n   * Pipe a Readable stream of pre-formatted CSV data rows (no header) into\n   * the error report stream. Backpressure is handled automatically via\n   * stream.pipeline. Avoids buffering the entire chunk error file in memory.\n   */\n  async pipeRawCsvStream(source: Readable, failedCount: number): Promise<void> {\n    this._failedCount += failedCount;\n    if (this._streamWriter) {\n      await this._streamWriter.pipeFrom(source);\n    }\n  }\n\n  merge(other: ImportErrorCollector): void {\n    for (const err of other.getErrors()) {\n      this.add(err);\n    }\n    const otherTruncatedCount = other.failedCount - other.getErrors().length;\n    if (otherTruncatedCount > 0) {\n      this._failedCount += otherTruncatedCount;\n    }\n    this._successCount += other.successCount;\n  }\n\n  reset(): void {\n    this.errors = [];\n    this._successCount = 0;\n    this._failedCount = 0;\n  }\n}\n\n/**\n * Streams error rows to a PassThrough as they arrive. Upload reads from the same stream.\n * S3/MinIO support streaming upload natively - no temp file needed.\n */\nclass StreamingErrorReportWriter {\n  private stream: PassThrough;\n  private fieldNames: string[];\n  private maxWidth: number;\n  private startUpload: (stream: PassThrough) => Promise<IUploadResult>;\n  private uploadPromise: Promise<IUploadResult> | null = null;\n\n  constructor(\n    stream: PassThrough,\n    fieldNames: string[],\n    maxWidth: number,\n    startUpload: (stream: PassThrough) => Promise<IUploadResult>\n  ) {\n    this.stream = stream;\n    this.fieldNames = fieldNames;\n    this.maxWidth = Math.max(maxWidth, 1);\n    this.startUpload = startUpload;\n  }\n\n  appendError(error: IImportError): void {\n    if (!this.uploadPromise) {\n      this.writeHeader();\n      this.uploadPromise = this.startUpload(this.stream);\n    }\n    const originalCells = Array.isArray(error.originalData) ? error.originalData : [];\n    const padded = [...originalCells];\n    while (padded.length < this.maxWidth) padded.push('');\n    const row = [...padded, error.errorMessage];\n    const line = Papa.unparse([row], { header: false });\n    this.stream.write(line.endsWith('\\n') ? line : line + '\\n');\n  }\n\n  /**\n   * Pipe a Readable (e.g. S3 download stream) into the report stream.\n   * Uses `.pipe({ end: false })` so the destination stays open for subsequent chunks.\n   * Backpressure is handled by Node's built-in pipe mechanism.\n   * On source error we unpipe without destroying the destination.\n   */\n  async pipeFrom(source: Readable): Promise<void> {\n    this.ensureHeaderWritten();\n    return new Promise<void>((resolve, reject) => {\n      source.on('end', () => {\n        source.unpipe(this.stream);\n        resolve();\n      });\n      source.on('error', (err) => {\n        source.unpipe(this.stream);\n        reject(err);\n      });\n      source.pipe(this.stream, { end: false });\n    });\n  }\n\n  private ensureHeaderWritten(): void {\n    if (!this.uploadPromise) {\n      this.writeHeader();\n      this.uploadPromise = this.startUpload(this.stream);\n    }\n  }\n\n  private writeHeader(): void {\n    const headers = Array.from(\n      { length: this.maxWidth },\n      (_, i) => this.fieldNames[i] || `Column ${i + 1}`\n    );\n    const headerRow = [...headers, '__error'];\n    const headerLine = '\\uFEFF' + Papa.unparse({ fields: headerRow, data: [] }).trimEnd() + '\\n';\n    this.stream.write(headerLine);\n  }\n\n  async close(): Promise<IUploadResult | undefined> {\n    this.stream.end();\n    return this.uploadPromise ?? undefined;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts",
    "content": "import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { HttpErrorCode } from '@teable/core';\nimport { CreateRecordAction, type IInplaceImportOptionRo } from '@teable/openapi';\nimport {\n  v2CoreTokens,\n  type ICommandBus,\n  ImportRecordsCommand,\n  type ImportRecordsResult,\n} from '@teable/v2-core';\nimport { difference } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { z } from 'zod';\nimport { BaseConfig, type IBaseConfig } from '../../../configs/base.config';\nimport { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\n\n/**\n * V2 Import Open API Service\n *\n * Handles import operations using the V2 architecture via CommandBus.\n */\n@Injectable()\nexport class ImportOpenApiV2Service {\n  private readonly logger = new Logger(ImportOpenApiV2Service.name);\n\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly configService: ConfigService,\n    private readonly eventEmitterService: EventEmitterService,\n    @BaseConfig() private readonly baseConfig: IBaseConfig\n  ) {}\n\n  /**\n   * Resolve a relative URL to an absolute URL.\n   * If the URL is already absolute, return as-is.\n   */\n  private resolveUrl(url: string): string {\n    const trimmedUrl = url.trim();\n    if (z.string().url().safeParse(trimmedUrl).success) {\n      return trimmedUrl;\n    }\n    const storagePrefix =\n      this.baseConfig.storagePrefix ?? process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN;\n    if (storagePrefix) {\n      const normalizedPrefix = storagePrefix.replace(/\\/$/, '');\n      const normalizedPath = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`;\n      return `${normalizedPrefix}${normalizedPath}`;\n    }\n    // For relative URLs, use localhost with the configured port\n    const port = this.configService.get<number>('PORT') || 3000;\n    return `http://localhost:${port}${trimmedUrl}`;\n  }\n\n  private throwV2Error(\n    error: {\n      code: string;\n      message: string;\n      tags?: ReadonlyArray<string>;\n      details?: Readonly<Record<string, unknown>>;\n    },\n    status: number\n  ): never {\n    throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {\n      domainCode: error.code,\n      domainTags: error.tags,\n      details: error.details,\n    });\n  }\n\n  private emitImportAuditLog(tableId: string, recordCount: number, fileType?: string) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n    const appId = this.cls.get('appId');\n\n    // Defer emission to ensure consumers can attach event listeners after the request returns.\n    setImmediate(() => {\n      void this.cls.run(async () => {\n        if (userId) this.cls.set('user.id', userId);\n        if (origin) this.cls.set('origin', origin);\n        if (appId) this.cls.set('appId', appId);\n\n        await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n          action: CreateRecordAction.InplaceImport,\n          resourceId: tableId,\n          recordCount,\n          params: { fileType },\n        });\n      });\n    });\n  }\n\n  /**\n   * Import records using V2 architecture via CommandBus.\n   * Appends records from a file (CSV/Excel) to an existing table.\n   *\n   * The ImportRecordsCommand handler is responsible for:\n   * - Finding the table by ID\n   * - Parsing the import source\n   * - Handling typecast and side effects (new select options)\n   * - Resolving link fields\n   * - Streaming record insertion\n   *\n   * @param baseId - The base ID\n   * @param tableId - The table ID to import into\n   * @param importOptions - Import options (V1 API type for compatibility)\n   * @param maxRowCount - Optional max row count limit\n   * @param projection - Optional field projection for permission check\n   */\n  async importRecords(\n    baseId: string,\n    tableId: string,\n    importOptions: IInplaceImportOptionRo,\n    maxRowCount?: number,\n    projection?: string[]\n  ): Promise<{ totalImported: number }> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n\n    const context = await this.v2ContextFactory.createContext();\n\n    const { attachmentUrl, fileType, insertConfig } = importOptions;\n    const { sourceColumnMap, sourceWorkSheetKey, excludeFirstRow } = insertConfig;\n\n    // Validate field permissions if projection is provided\n    if (projection) {\n      const fieldIds = Object.keys(sourceColumnMap);\n      const noUpdateFields = difference(fieldIds, projection);\n      if (noUpdateFields.length !== 0) {\n        const tips = noUpdateFields.join(',');\n        throw new CustomHttpException(\n          `There is no permission to update these fields: ${tips}`,\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.permission.updateRecordWithDeniedFields',\n              context: {\n                fields: tips,\n              },\n            },\n          }\n        );\n      }\n    }\n\n    // Resolve relative URL to absolute URL\n    const resolvedUrl = this.resolveUrl(attachmentUrl);\n\n    // Align with v1 behavior: treat 0 (or negative) as no limit\n    const normalizedMaxRowCount =\n      maxRowCount !== undefined && maxRowCount > 0 ? maxRowCount : undefined;\n\n    // Create command\n    const commandResult = ImportRecordsCommand.createFromUrl({\n      tableId,\n      url: resolvedUrl,\n      fileType,\n      sourceColumnMap,\n      options: {\n        skipFirstNLines: excludeFirstRow ? 1 : 0,\n        sheetName: sourceWorkSheetKey,\n        typecast: true,\n        batchSize: normalizedMaxRowCount ? Math.min(normalizedMaxRowCount, 500) : 500,\n        maxRowCount: normalizedMaxRowCount,\n      },\n    });\n\n    if (commandResult.isErr()) {\n      throw new HttpException(commandResult.error.message, HttpStatus.BAD_REQUEST);\n    }\n\n    // Execute via CommandBus\n    const result = await commandBus.execute<ImportRecordsCommand, ImportRecordsResult>(\n      context,\n      commandResult.value\n    );\n\n    if (result.isErr()) {\n      this.logger.error('V2 import records failed', result.error);\n\n      // Map domain error to HTTP status\n      const status =\n        result.error.code === 'import.field_not_found' ||\n        result.error.code === 'import.column_index_out_of_range' ||\n        result.error.tags?.includes('validation')\n          ? HttpStatus.BAD_REQUEST\n          : result.error.tags?.includes('not-found')\n            ? HttpStatus.NOT_FOUND\n            : HttpStatus.INTERNAL_SERVER_ERROR;\n\n      this.throwV2Error(result.error, status);\n    }\n\n    this.emitImportAuditLog(tableId, result.value.totalImported, fileType);\n\n    return { totalImported: result.value.totalImported };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-open-api.controller.ts",
    "content": "import {\n  Controller,\n  Get,\n  UseGuards,\n  Query,\n  Post,\n  Body,\n  Param,\n  Patch,\n  UseInterceptors,\n} from '@nestjs/common';\nimport {\n  analyzeRoSchema,\n  IAnalyzeRo,\n  IImportOptionRo,\n  importOptionRoSchema,\n  IInplaceImportOptionRo,\n  inplaceImportOptionRoSchema,\n} from '@teable/openapi';\nimport type { ITableFullVo, IAnalyzeVo, IImportStatusVo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { TokenAccess } from '../../auth/decorators/token.decorator';\nimport { PermissionGuard } from '../../auth/guard/permission.guard';\nimport { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';\nimport { ImportOpenApiV2Service } from './import-open-api-v2.service';\nimport { ImportOpenApiService } from './import-open-api.service';\n\n@Controller('api/import')\n@UseGuards(PermissionGuard, V2FeatureGuard)\n@UseInterceptors(V2IndicatorInterceptor)\nexport class ImportController {\n  constructor(\n    protected readonly importOpenService: ImportOpenApiService,\n    protected readonly importOpenApiV2Service: ImportOpenApiV2Service,\n    protected readonly cls: ClsService<IClsStore>\n  ) {}\n  @Get('/analyze')\n  @TokenAccess()\n  async analyzeSheetFromFile(\n    @Query(new ZodValidationPipe(analyzeRoSchema)) analyzeRo: IAnalyzeRo\n  ): Promise<IAnalyzeVo> {\n    return await this.importOpenService.analyze(analyzeRo);\n  }\n\n  @Get('/status/:tableId')\n  @Permissions('base|table_import')\n  @TokenAccess()\n  async getImportStatus(@Param('tableId') tableId: string): Promise<IImportStatusVo> {\n    return await this.importOpenService.getImportStatus(tableId);\n  }\n\n  @Post(':baseId')\n  @Permissions('base|table_import')\n  @TokenAccess()\n  async createTableFromImport(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(importOptionRoSchema)) importRo: IImportOptionRo\n  ): Promise<ITableFullVo[]> {\n    return await this.importOpenService.createTableFromImport(baseId, importRo);\n  }\n\n  @UseV2Feature('importRecords')\n  @Patch(':baseId/:tableId')\n  @Permissions('table|import')\n  async inplaceImportTable(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(inplaceImportOptionRoSchema))\n    inplaceImportRo: IInplaceImportOptionRo\n  ): Promise<void> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      await this.importOpenApiV2Service.importRecords(baseId, tableId, inplaceImportRo);\n      return;\n    }\n\n    return await this.importOpenService.inplaceImportTable(baseId, tableId, inplaceImportRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { CanaryModule } from '../../canary/canary.module';\nimport { FieldOpenApiModule } from '../../field/open-api/field-open-api.module';\nimport { NotificationModule } from '../../notification/notification.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { TableOpenApiModule } from '../../table/open-api/table-open-api.module';\nimport { V2Module } from '../../v2/v2.module';\nimport { ImportMetricsModule } from '../metrics/import-metrics.module';\nimport { ImportCsvChunkModule } from './import-csv-chunk.module';\nimport { ImportOpenApiV2Service } from './import-open-api-v2.service';\nimport { ImportController } from './import-open-api.controller';\nimport { ImportOpenApiService } from './import-open-api.service';\n\n@Module({\n  imports: [\n    TableOpenApiModule,\n    RecordOpenApiModule,\n    NotificationModule,\n    ShareDbModule,\n    ImportCsvChunkModule,\n    FieldOpenApiModule,\n    V2Module,\n    CanaryModule,\n    ImportMetricsModule,\n  ],\n  controllers: [ImportController],\n  providers: [ImportOpenApiService, ImportOpenApiV2Service],\n  exports: [ImportOpenApiService, ImportOpenApiV2Service],\n})\nexport class ImportOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-open-api.service.ts",
    "content": "import { Injectable, Logger, Optional } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport {\n  FieldType,\n  generateLogId,\n  getRandomString,\n  HttpErrorCode,\n  TimeFormatting,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IAnalyzeRo,\n  IImportOptionRo,\n  IImportStatusVo,\n  IInplaceImportOptionRo,\n  ITableFullVo,\n} from '@teable/openapi';\nimport { chunk, difference } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../../cache/cache.service';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { ShareDbService } from '../../../share-db/share-db.service';\nimport type { IClsStore } from '../../../types/cls';\nimport { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport { NotificationService } from '../../notification/notification.service';\nimport { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport { DEFAULT_VIEWS, DEFAULT_FIELDS } from '../../table/constant';\nimport { TableOpenApiService } from '../../table/open-api/table-open-api.service';\nimport { ImportMetricsService } from '../metrics/import-metrics.service';\nimport {\n  ImportTableCsvChunkQueueProcessor,\n  TABLE_IMPORT_CSV_CHUNK_QUEUE,\n} from './import-csv-chunk.processor';\nimport {\n  getImportLatestJobKey,\n  getImportResultManifestKey,\n  IMPORT_LATEST_JOB_TTL_SECONDS,\n} from './import-result-manifest';\nimport { importerFactory } from './import.class';\n\nconst maxFieldsLength = 500;\nconst maxFieldsChunkSize = 30;\n\n/**\n * System-wide cap on **waiting** (queued but not yet processing) import jobs.\n * This is a global limit across all pods (BullMQ queue is shared via Redis).\n * Active jobs are excluded — they are already consuming workers and will complete.\n * Only the backlog of waiting jobs is capped to prevent unbounded queue growth\n * and excessive user wait times.\n *\n * Default 50 is generous enough for multi-pod deployments (e.g. 5 pods × ~10 each).\n * Tune via IMPORT_MAX_WAITING_JOBS env variable based on cluster size.\n */\nconst maxWaitingImports = Number(process.env.IMPORT_MAX_WAITING_JOBS ?? Infinity);\n\n@Injectable()\nexport class ImportOpenApiService {\n  private logger = new Logger(ImportOpenApiService.name);\n  constructor(\n    private readonly tableOpenApiService: TableOpenApiService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly notificationService: NotificationService,\n    private readonly shareDbService: ShareDbService,\n    private readonly importTableCsvChunkQueueProcessor: ImportTableCsvChunkQueueProcessor,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly cacheService: CacheService,\n    @Optional() private readonly importMetrics?: ImportMetricsService\n  ) {}\n\n  /**\n   * Reject new imports when the global queue backlog (waiting jobs) is too deep.\n   * Active jobs are excluded — they are already being processed by workers.\n   */\n  private async checkImportConcurrencyLimit() {\n    try {\n      const queue = this.importTableCsvChunkQueueProcessor.queue;\n      const waitingJobs = await queue.getJobCountByTypes('waiting');\n\n      if (waitingJobs >= maxWaitingImports) {\n        this.logger.warn(\n          `Import queue backlog limit reached: ${waitingJobs}/${maxWaitingImports} waiting jobs`\n        );\n        throw new CustomHttpException(\n          `Too many import tasks queued (${waitingJobs}/${maxWaitingImports}). Please try again later.`,\n          HttpErrorCode.TOO_MANY_REQUESTS,\n          {\n            localization: {\n              i18nKey: 'httpErrors.import.tooManyConcurrentImports',\n              context: {\n                current: waitingJobs,\n                max: maxWaitingImports,\n              },\n            },\n          }\n        );\n      }\n    } catch (e) {\n      if (e instanceof CustomHttpException) {\n        throw e;\n      }\n      this.logger.warn('Failed to check import queue backlog, allowing import to proceed', e);\n    }\n  }\n\n  async analyze(analyzeRo: IAnalyzeRo) {\n    const { attachmentUrl, fileType } = analyzeRo;\n\n    const importer = importerFactory(fileType, {\n      url: attachmentUrl,\n      type: fileType,\n    });\n\n    return await importer.genColumns();\n  }\n\n  async createTableFromImport(baseId: string, importRo: IImportOptionRo, maxRowCount?: number) {\n    await this.checkImportConcurrencyLimit();\n\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n    const { worksheets, notification = false, tz, fileType, attachmentUrl } = importRo;\n\n    this.importMetrics?.recordImportQueued({ fileType, operationType: 'create_table' });\n\n    // only record base table info, not include records\n    const tableResult = [];\n\n    for (const [sheetKey, value] of Object.entries(worksheets)) {\n      const { importData, useFirstRowAsHeader, columns, name } = value;\n\n      const columnInfo = columns.length ? columns : [...DEFAULT_FIELDS];\n      const fieldsRo = columnInfo.map((col, index) => {\n        const result: IFieldRo & {\n          isPrimary?: boolean;\n        } = {\n          ...col,\n        };\n\n        if (index === 0) {\n          result.isPrimary = true;\n        }\n\n        // Date Field should have default tz\n        if (col.type === FieldType.Date) {\n          result.options = {\n            formatting: {\n              timeZone: tz,\n              date: 'YYYY-MM-DD',\n              time: TimeFormatting.None,\n            },\n          };\n        }\n\n        return result;\n      });\n\n      let table: ITableFullVo;\n\n      try {\n        table = await this.createSingleTable(baseId, name, fieldsRo);\n        tableResult.push(table);\n      } catch (e) {\n        this.logger.error(e);\n        throw e;\n      }\n\n      const { fields } = table;\n\n      const jobId = `${ImportTableCsvChunkQueueProcessor.JOB_ID_PREFIX}:${table.id}:${getRandomString(6)}`;\n\n      const logId = generateLogId();\n\n      if (importData && columns.length) {\n        await this.importTableCsvChunkQueueProcessor.queue.add(\n          `${TABLE_IMPORT_CSV_CHUNK_QUEUE}_job`,\n          {\n            baseId,\n            table: {\n              id: table.id,\n              name: table.name,\n            },\n            userId,\n            origin,\n            importerParams: {\n              attachmentUrl,\n              fileType,\n              maxRowCount,\n            },\n            options: {\n              skipFirstNLines: useFirstRowAsHeader ? 1 : 0,\n              sheetKey,\n              notification,\n            },\n            recordsCal: {\n              fields: fields.map((f) => ({ id: f.id, name: f.name, type: f.type })),\n              columnInfo: columns,\n            },\n            ro: importRo,\n            logId,\n          },\n          {\n            jobId,\n            removeOnComplete: 1000,\n            removeOnFail: 1000,\n          }\n        );\n        await this.cacheService\n          .setDetail(getImportLatestJobKey(table.id), jobId, IMPORT_LATEST_JOB_TTL_SECONDS)\n          .catch((e) => {\n            this.logger.warn(\n              `Failed to set latest import job index for table ${table.id}, job ${jobId}`,\n              e\n            );\n          });\n      }\n    }\n    return tableResult;\n  }\n\n  async createSingleTable(baseId: string, name: string, fieldsRo: IFieldRo[]) {\n    const length = fieldsRo.length;\n\n    if (length > maxFieldsLength) {\n      throw new CustomHttpException(\n        `The number of fields in the table cannot exceed ${maxFieldsLength}, current is ${length}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.import.exceedMaxFieldsLength',\n            context: {\n              length,\n              maxFieldsLength,\n            },\n          },\n        }\n      );\n    }\n\n    const chunkFields = chunk(fieldsRo, maxFieldsChunkSize) as IFieldRo[][];\n\n    let tableId: string | undefined;\n\n    for (const chunk of chunkFields) {\n      if (!tableId) {\n        const table = await this.tableOpenApiService.createTable(baseId, {\n          name,\n          fields: chunk,\n          views: DEFAULT_VIEWS,\n          records: [],\n        });\n        tableId = table.id;\n        continue;\n      }\n\n      await this.fieldOpenApiService.createFieldsByRo(tableId, chunk);\n    }\n\n    const table = (await this.tableOpenApiService.getTable(baseId, tableId!)) as ITableFullVo;\n    const fields = await this.fieldOpenApiService.getFields(tableId!, {});\n\n    table.fields = fields;\n\n    return table;\n  }\n\n  async inplaceImportTable(\n    baseId: string,\n    tableId: string,\n    inplaceImportRo: IInplaceImportOptionRo,\n    maxRowCount?: number,\n    projection?: string[]\n  ) {\n    await this.checkImportConcurrencyLimit();\n\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n    const { attachmentUrl, fileType, insertConfig, notification = false } = inplaceImportRo;\n\n    this.importMetrics?.recordImportQueued({ fileType, operationType: 'inplace' });\n\n    const { sourceColumnMap, sourceWorkSheetKey, excludeFirstRow } = insertConfig;\n\n    const tableRaw = await this.prismaService.tableMeta\n      .findUnique({\n        where: { id: tableId, deletedTime: null },\n        select: { name: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Table not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n\n    const fieldRaws = await this.prismaService.field.findMany({\n      where: { tableId, deletedTime: null, hasError: null },\n      select: {\n        id: true,\n        name: true,\n        type: true,\n      },\n    });\n\n    if (projection) {\n      const inplaceFieldIds = Object.keys(sourceColumnMap);\n      const noUpdateFields = difference(inplaceFieldIds, projection);\n      if (noUpdateFields.length !== 0) {\n        const tips = noUpdateFields.join(',');\n        throw new CustomHttpException(\n          `There is no permission to update there field ${tips}`,\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.permission.updateRecordWithDeniedFields',\n              context: {\n                fields: tips,\n              },\n            },\n          }\n        );\n      }\n    }\n\n    if (!tableRaw || !fieldRaws) {\n      return;\n    }\n\n    const jobId = await this.generateChunkJobId(tableId);\n\n    const logId = generateLogId();\n\n    await this.importTableCsvChunkQueueProcessor.queue.add(\n      `${TABLE_IMPORT_CSV_CHUNK_QUEUE}_job`,\n      {\n        baseId,\n        table: {\n          id: tableId,\n          name: tableRaw.name,\n        },\n        userId,\n        origin,\n        importerParams: {\n          attachmentUrl,\n          fileType,\n          maxRowCount,\n        },\n        options: {\n          skipFirstNLines: excludeFirstRow ? 1 : 0,\n          sheetKey: sourceWorkSheetKey,\n          notification,\n        },\n        recordsCal: {\n          sourceColumnMap,\n          fields: fieldRaws as { id: string; name: string; type: FieldType }[],\n        },\n        ro: inplaceImportRo,\n        logId,\n      },\n      {\n        jobId,\n        removeOnComplete: 1000,\n        removeOnFail: 1000,\n      }\n    );\n    await this.cacheService\n      .setDetail(getImportLatestJobKey(tableId), jobId, IMPORT_LATEST_JOB_TTL_SECONDS)\n      .catch((e) => {\n        this.logger.warn(\n          `Failed to set latest import job index for table ${tableId}, job ${jobId}`,\n          e\n        );\n      });\n  }\n\n  async getImportStatus(tableId: string): Promise<IImportStatusVo> {\n    const queue = this.importTableCsvChunkQueueProcessor.queue;\n    const latestJobId = await this.cacheService.get(getImportLatestJobKey(tableId));\n    if (!latestJobId) {\n      return { tableId, status: 'not_found' };\n    }\n    const job = await queue.getJob(latestJobId);\n    if (!job) {\n      return { tableId, status: 'not_found' };\n    }\n\n    const state = await job.getState();\n    const status = this.mapQueueStateToImportStatus(state);\n    const result: IImportStatusVo = { tableId, status };\n\n    if (status === 'completed' || status === 'failed') {\n      const manifest = await this.cacheService.get(getImportResultManifestKey(latestJobId));\n      this.fillCompletedOrFailedCounts(result, manifest, job.returnvalue);\n    }\n\n    if (status === 'running' || status === 'pending') {\n      this.fillRunningCounts(result, job.progress);\n    }\n\n    if (status === 'failed') {\n      result.message = job.failedReason ?? 'Import failed';\n    }\n\n    return result;\n  }\n\n  async generateChunkJobId(tableId: string) {\n    return `${ImportTableCsvChunkQueueProcessor.JOB_ID_PREFIX}:${tableId}:${getRandomString(6)}`;\n  }\n\n  private mapQueueStateToImportStatus(state: string): IImportStatusVo['status'] {\n    if (state === 'waiting' || state === 'delayed') {\n      return 'pending';\n    }\n    if (state === 'active') {\n      return 'running';\n    }\n    if (state === 'completed') {\n      return 'completed';\n    }\n    if (state === 'failed') {\n      return 'failed';\n    }\n    return 'not_found';\n  }\n\n  private fillCompletedOrFailedCounts(\n    result: IImportStatusVo,\n    manifest: unknown,\n    returnValue: unknown\n  ) {\n    if (manifest && typeof manifest === 'object') {\n      const m = manifest as {\n        successCount?: number;\n        failedCount?: number;\n        errorReportUrl?: string;\n      };\n      result.successCount = m.successCount;\n      result.failedCount = m.failedCount;\n      result.errorReportUrl = m.errorReportUrl;\n      return;\n    }\n\n    if (returnValue && typeof returnValue === 'object') {\n      const rv = returnValue as { success?: number; failed?: number };\n      result.successCount = rv.success;\n      result.failedCount = rv.failed;\n    }\n  }\n\n  private fillRunningCounts(result: IImportStatusVo, progress: unknown) {\n    if (!progress || typeof progress !== 'object') {\n      return;\n    }\n    const p = progress as { successCount?: number; failedCount?: number };\n    result.successCount = p.successCount;\n    result.failedCount = p.failedCount;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-result-manifest.ts",
    "content": "export interface IImportResultManifest {\n  successCount: number;\n  failedCount: number;\n  errorFilePaths: string[];\n  fieldNames: string[];\n  maxWidth: number;\n  errorReportUrl?: string;\n}\n\nexport const IMPORT_RESULT_MANIFEST_TTL_SECONDS = 60 * 60;\nexport const IMPORT_LATEST_JOB_TTL_SECONDS = 60 * 60;\nconst importResultManifestPrefix = 'import:result:manifest:';\nconst importLatestJobPrefix = 'import:latest-job:';\n\nexport const getImportResultManifestKey = (jobId: string): `import:result:manifest:${string}` =>\n  `${importResultManifestPrefix}${jobId}` as `import:result:manifest:${string}`;\n\nexport const getImportLatestJobKey = (tableId: string): `import:latest-job:${string}` =>\n  `${importLatestJobPrefix}${tableId}` as `import:latest-job:${string}`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import-result.processor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport os from 'os';\nimport { join } from 'path';\nimport { PassThrough, type Readable } from 'stream';\nimport { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { UploadType } from '@teable/openapi';\nimport { Queue } from 'bullmq';\nimport type { Job } from 'bullmq';\nimport Papa from 'papaparse';\nimport { CacheService } from '../../../cache/cache.service';\nimport { BaseConfig, type IBaseConfig } from '../../../configs/base.config';\nimport type { I18nPath } from '../../../types/i18n.generated';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { NotificationService } from '../../notification/notification.service';\nimport {\n  getImportResultManifestKey,\n  IMPORT_RESULT_MANIFEST_TTL_SECONDS,\n  type IImportResultManifest,\n} from './import-result-manifest';\n\nexport const TABLE_IMPORT_RESULT_QUEUE = 'import-table-result-queue';\nconst TABLE_IMPORT_RESULT_QUEUE_CONCURRENCY = Math.max(os.cpus().length * 2, 4);\nconst IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX = '[IMPORT_TABLE_ERROR_REPORT]';\n\ninterface IImportResultJobData {\n  jobId: string;\n  baseId: string;\n  table: { id: string; name: string };\n  userId: string;\n  sourceColumnMap?: Record<string, number | null>;\n  notification: boolean;\n  attachmentUrl?: string;\n}\n\n@Injectable()\n@Processor(TABLE_IMPORT_RESULT_QUEUE, {\n  concurrency: TABLE_IMPORT_RESULT_QUEUE_CONCURRENCY,\n})\nexport class ImportTableResultQueueProcessor extends WorkerHost {\n  private readonly logger = new Logger(ImportTableResultQueueProcessor.name);\n\n  constructor(\n    @InjectQueue(TABLE_IMPORT_RESULT_QUEUE) public readonly queue: Queue<IImportResultJobData>,\n    @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter,\n    private readonly notificationService: NotificationService,\n    private readonly cacheService: CacheService,\n    @BaseConfig() private readonly baseConfig: IBaseConfig\n  ) {\n    super();\n  }\n\n  public async process(job: Job<IImportResultJobData>): Promise<void> {\n    const { jobId, baseId, table, userId, sourceColumnMap, notification, attachmentUrl } = job.data;\n    const manifest = (await this.cacheService.get(getImportResultManifestKey(jobId))) as\n      | IImportResultManifest\n      | undefined;\n\n    if (!manifest) {\n      this.logger.warn(\n        `${IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX} Import manifest missing for job ${jobId}, attachmentUrl: ${attachmentUrl}`\n      );\n      await this.cleanupImportDir(jobId);\n      return;\n    }\n\n    try {\n      if (!notification) {\n        return;\n      }\n\n      if (manifest.failedCount === 0 && manifest.successCount > 0) {\n        this.notificationService.sendImportResultNotify({\n          baseId,\n          tableId: table.id,\n          toUserId: userId,\n          message: sourceColumnMap\n            ? {\n                i18nKey: 'common.email.templates.notify.import.table.success.inplace',\n                context: { tableName: table.name },\n              }\n            : {\n                i18nKey: 'common.email.templates.notify.import.table.success.message',\n                context: { tableName: table.name },\n              },\n        });\n        return;\n      }\n\n      if (manifest.successCount + manifest.failedCount === 0) {\n        this.notificationService.sendImportResultNotify({\n          baseId,\n          tableId: table.id,\n          toUserId: userId,\n          message: {\n            i18nKey:\n              'common.email.templates.notify.import.table.noRecordsProcessed.message' as I18nPath,\n            context: {\n              tableName: table.name,\n            },\n          },\n        });\n        return;\n      }\n\n      const errorReportUrl = await this.uploadMergedErrorReport(jobId, manifest);\n      this.logger.log(\n        `${IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX} jobId=${jobId} table=${table.name}(${table.id}) success=${manifest.successCount} failed=${manifest.failedCount} reportUrl=${errorReportUrl ?? 'N/A'} attachmentUrl=${attachmentUrl ?? 'N/A'}`\n      );\n\n      if (errorReportUrl) {\n        manifest.errorReportUrl = errorReportUrl;\n        await this.cacheService\n          .setDetail(\n            getImportResultManifestKey(jobId),\n            manifest,\n            IMPORT_RESULT_MANIFEST_TTL_SECONDS\n          )\n          .catch((e) => {\n            this.logger.warn(\n              `${IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX} Failed to update manifest with errorReportUrl for job ${jobId}`,\n              e\n            );\n          });\n      }\n\n      const message = this.buildFailureNotification(table.name, manifest, errorReportUrl);\n      this.notificationService.sendImportResultNotify({\n        baseId,\n        tableId: table.id,\n        toUserId: userId,\n        message,\n      });\n    } finally {\n      await this.cleanupImportDir(jobId);\n    }\n  }\n\n  private buildFailureNotification(\n    tableName: string,\n    manifest: IImportResultManifest,\n    errorReportUrl?: string\n  ): { i18nKey: I18nPath; context: Record<string, string> } {\n    const hasReport = !!errorReportUrl;\n    const suffix = hasReport ? 'message' : 'messageNoReport';\n    const base = manifest.successCount === 0 ? 'allFailed' : 'partialSuccess';\n    const i18nKey = `common.email.templates.notify.import.table.${base}.${suffix}` as I18nPath;\n\n    const context: Record<string, string> = {\n      tableName,\n      failedCount: String(manifest.failedCount),\n    };\n    if (manifest.successCount > 0) {\n      context.successCount = String(manifest.successCount);\n    }\n    if (hasReport) {\n      context.errorReportUrl = errorReportUrl!;\n    }\n    return { i18nKey, context };\n  }\n\n  private async uploadMergedErrorReport(\n    jobId: string,\n    manifest: IImportResultManifest\n  ): Promise<string | undefined> {\n    if (!manifest.errorFilePaths.length || manifest.failedCount === 0) {\n      return undefined;\n    }\n\n    const bucket = StorageAdapter.getBucket(UploadType.Import);\n    const pathDir = StorageAdapter.getDir(UploadType.Import);\n    const reportPath = `${pathDir}/error_reports/${jobId}/error_report.csv`;\n    const mergedStream = new PassThrough();\n    const uploadPromise = this.storageAdapter.uploadFileStream(bucket, reportPath, mergedStream, {\n      'Content-Type': 'text/csv; charset=utf-8',\n    });\n\n    const headers = Array.from(\n      { length: manifest.maxWidth },\n      (_, i) => manifest.fieldNames[i] || `Column ${i + 1}`\n    );\n    const headerRow = [...headers, '__error'];\n    const headerLine = '\\uFEFF' + Papa.unparse({ fields: headerRow, data: [] }).trimEnd() + '\\n';\n    mergedStream.write(headerLine);\n\n    try {\n      for (const filePath of manifest.errorFilePaths) {\n        const sourceStream = await this.storageAdapter.downloadFile(bucket, filePath);\n        await this.pipeToTarget(sourceStream, mergedStream);\n      }\n      mergedStream.end();\n      const uploadResult = await uploadPromise;\n      let url = await this.storageAdapter.getPreviewUrl(\n        bucket,\n        uploadResult.path,\n        7 * 24 * 60 * 60\n      );\n      if (url.startsWith('/') && this.baseConfig.storagePrefix) {\n        url = this.baseConfig.storagePrefix + url;\n      }\n      return url;\n    } catch (error) {\n      mergedStream.destroy(error as Error);\n      this.logger.error(\n        `${IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX} Failed to merge import error report`,\n        error\n      );\n      return undefined;\n    }\n  }\n\n  private async pipeToTarget(source: Readable, target: PassThrough): Promise<void> {\n    return new Promise<void>((resolve, reject) => {\n      source.on('end', () => {\n        source.unpipe(target);\n        resolve();\n      });\n      source.on('error', (err) => {\n        source.unpipe(target);\n        reject(err);\n      });\n      source.pipe(target, { end: false });\n    });\n  }\n\n  private async cleanupImportDir(jobId: string) {\n    try {\n      const dir = StorageAdapter.getDir(UploadType.Import);\n      const fullPath = join(dir, jobId);\n      await this.storageAdapter.deleteDir(\n        StorageAdapter.getBucket(UploadType.Import),\n        fullPath,\n        false\n      );\n    } catch (error) {\n      this.logger.warn(\n        `${IMPORT_TABLE_ERROR_REPORT_LOG_PREFIX} Failed to clean up import directory for job ${jobId}`,\n        error\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/import/open-api/import.class.ts",
    "content": "import { existsSync } from 'fs';\nimport { join } from 'path';\nimport { PassThrough } from 'stream';\nimport { getUniqName, FieldType, HttpErrorCode } from '@teable/core';\nimport type { IValidateTypes, IAnalyzeVo } from '@teable/openapi';\nimport { SUPPORTEDTYPE, importTypeMap } from '@teable/openapi';\nimport jschardet from 'jschardet';\nimport { zip, toString, intersection, chunk as chunkArray } from 'lodash';\nimport fetch from 'node-fetch';\nimport sizeof from 'object-sizeof';\nimport Papa from 'papaparse';\nimport * as XLSX from 'xlsx';\nimport { z } from 'zod';\nimport type { ZodType } from 'zod';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { exceptionParse } from '../../../utils/exception-parse';\nimport { toLineDelimitedStream } from './delimiter-stream';\n\nexport const DEFAULT_IMPORT_CPU_USAGE = 0.5;\n\nexport const parseBoolean = (value: unknown): boolean => {\n  if (typeof value === 'boolean') return value;\n\n  if (typeof value === 'string') {\n    const lowered = value.replaceAll(\"'\", '').replaceAll('\"', '').toLowerCase();\n    if (lowered === 'true') return true;\n    if (lowered === 'false') return false;\n  }\n\n  return Boolean(value);\n};\n\n/**\n * Whitelist of regex patterns for date-like strings.\n * Only values matching one of these patterns are considered for Date type detection.\n * Avoids false positives from JavaScript's lenient parsing (e.g. \"CC-38716\" → year 38716).\n */\nconst dateFormatPatterns: RegExp[] = [\n  /^\\d{4}-\\d{2}-\\d{2}$/, // YYYY-MM-DD (ISO date)\n  /^\\d{4}-\\d{2}-\\d{2}\\s+\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\.\\d{1,3})?$/, // YYYY-MM-DD HH:mm:ss\n  /^\\d{4}-\\d{2}-\\d{2}T\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:?\\d{2})?$/, // ISO 8601 datetime\n  /^\\d{1,2}-\\d{1,2}-\\d{4}$/, // DD-MM-YYYY or MM-DD-YYYY\n  /^\\d{4}\\/\\d{1,2}\\/\\d{1,2}$/, // YYYY/MM/DD\n  /^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/, // MM/DD/YYYY (US)\n  /^\\d{1,2}\\/\\d{1,2}\\/\\d{4}\\s+\\d{1,2}:\\d{2}(?::\\d{2})?$/, // MM/DD/YYYY HH:mm:ss (US)\n];\n\nconst reasonableYearMin = 1;\nconst reasonableYearMax = 9999;\nconst invalidDateStr = 'Invalid Date';\n\nfunction isValidDateForImport(value: unknown): boolean {\n  if (value === '' || value == null) return false;\n\n  if (typeof value === 'number') {\n    if (!Number.isFinite(value)) return false;\n    const d = new Date(value);\n    if (d.toString() === invalidDateStr) return false;\n    const year = d.getFullYear();\n    return year >= reasonableYearMin && year <= reasonableYearMax;\n  }\n\n  if (typeof value !== 'string') return false;\n\n  const str = value.trim();\n  if (!str) return false;\n  if (!dateFormatPatterns.some((p) => p.test(str))) return false;\n\n  const d = new Date(value);\n  if (d.toString() === invalidDateStr) return false;\n\n  const year = d.getFullYear();\n  return year >= reasonableYearMin && year <= reasonableYearMax;\n}\n\nconst validateZodSchemaMap: Record<IValidateTypes, ZodType> = {\n  [FieldType.Checkbox]: z.union([z.string(), z.boolean()]).refine(\n    (value: unknown) => {\n      if (typeof value === 'boolean') {\n        return true;\n      }\n      if (\n        typeof value === 'string' &&\n        (value.toLowerCase() === 'false' || value.toLowerCase() === 'true')\n      ) {\n        return true;\n      }\n      return false;\n    },\n    { message: 'Invalid checkbox value' }\n  ),\n  [FieldType.Date]: z.any().refine(isValidDateForImport, { message: 'Invalid date' }),\n  [FieldType.Number]: z.any().refine(\n    (value) => {\n      return !isNaN(Number(value));\n    },\n    { message: 'Invalid number' }\n  ),\n  [FieldType.LongText]: z\n    .string()\n    .refine((value) => z.string().safeParse(value) && /\\n/.test(value), {\n      message: 'Invalid long text',\n    }),\n  [FieldType.SingleLineText]: z.string(),\n};\n\nconst encodingSampleSize = 64 * 1024; // 64KB for encoding detection\n\nfunction isUtf8Compatible(encoding: string | null): boolean {\n  const normalized = (encoding || 'utf-8').toLowerCase();\n  return normalized === 'utf-8' || normalized === 'ascii';\n}\n\nfunction detectAndDecode(sample: Buffer): { isUtf8: boolean; encoding: string } {\n  const { encoding } = jschardet.detect(sample);\n  return { isUtf8: isUtf8Compatible(encoding), encoding: encoding || 'utf-8' };\n}\n\nfunction flushSampleAsUtf8(sampleChunks: Buffer[], output: PassThrough, encoding: string) {\n  const decoder = new TextDecoder(encoding, { fatal: false });\n  for (const buf of sampleChunks) {\n    output.write(Buffer.from(decoder.decode(buf, { stream: true })));\n  }\n  return decoder;\n}\n\n/**\n * Detect the encoding of a stream by sampling the first N bytes,\n * then return a UTF-8 stream. If the source is already UTF-8/ASCII,\n * the original bytes are passed through with zero overhead.\n */\nfunction createEncodingConvertStream(input: NodeJS.ReadableStream): NodeJS.ReadableStream {\n  const output = new PassThrough();\n  const sampleChunks: Buffer[] = [];\n  let sampleSize = 0;\n  let detected = false;\n\n  input.on('data', (chunk: Buffer) => {\n    if (detected) return;\n\n    sampleChunks.push(chunk);\n    sampleSize += chunk.length;\n\n    if (sampleSize < encodingSampleSize) return;\n\n    detected = true;\n    const { isUtf8, encoding } = detectAndDecode(Buffer.concat(sampleChunks));\n\n    if (isUtf8) {\n      for (const buf of sampleChunks) output.write(buf);\n      input.on('data', (c: Buffer) => output.write(c));\n    } else {\n      const decoder = flushSampleAsUtf8(sampleChunks, output, encoding);\n      input.on('data', (c: Buffer) => {\n        output.write(Buffer.from(decoder.decode(c, { stream: true })));\n      });\n      input.on('end', () => {\n        const tail = decoder.decode();\n        if (tail) output.write(Buffer.from(tail));\n      });\n    }\n  });\n\n  input.on('end', () => {\n    if (!detected && sampleChunks.length > 0) {\n      const sample = Buffer.concat(sampleChunks);\n      const { isUtf8, encoding } = detectAndDecode(sample);\n\n      if (isUtf8) {\n        output.write(sample);\n      } else {\n        const decoder = new TextDecoder(encoding, { fatal: false });\n        output.write(Buffer.from(decoder.decode(sample)));\n      }\n    }\n    output.end();\n  });\n\n  input.on('error', (err) => output.destroy(err));\n\n  return output;\n}\n\nexport interface IImportConstructorParams {\n  url: string;\n  type: SUPPORTEDTYPE;\n  maxRowCount?: number;\n  fileName?: string;\n}\n\nexport interface IParseResult {\n  [x: string]: unknown[][];\n}\n\nexport const OVER_PLAN_ROW_COUNT_ERROR_MESSAGE = 'Please upgrade your plan to import more records';\n\nexport abstract class Importer {\n  public static DEFAULT_ERROR_MESSAGE = 'unknown error';\n\n  public static OVER_PLAN_ROW_COUNT_ERROR_MESSAGE = OVER_PLAN_ROW_COUNT_ERROR_MESSAGE;\n\n  public static CHUNK_SIZE = 1024 * 1024 * 0.2;\n\n  public static MAX_CHUNK_LENGTH = 500;\n\n  public static DEFAULT_COLUMN_TYPE: IValidateTypes = FieldType.SingleLineText;\n\n  // order make sence\n  public static readonly SUPPORTEDTYPE: IValidateTypes[] = [\n    FieldType.Checkbox,\n    FieldType.Number,\n    FieldType.Date,\n    FieldType.LongText,\n    FieldType.SingleLineText,\n  ];\n\n  constructor(public config: IImportConstructorParams) {}\n\n  abstract parse(\n    ...args: [\n      options?: unknown,\n      chunk?: (\n        chunk: Record<string, unknown[][]>,\n        onFinished?: () => void,\n        onError?: (errorMsg: string) => void\n      ) => Promise<void>,\n    ]\n  ): Promise<IParseResult>;\n\n  private setFileNameFromHeader(fileName: string) {\n    this.config.fileName = fileName;\n  }\n\n  getConfig() {\n    return this.config;\n  }\n\n  async getFile() {\n    const { url: _url, type } = this.config;\n    let url = _url.trim();\n    if (!z.string().url().safeParse(url).success) {\n      url = `http://localhost:${process.env.PORT}${url}`;\n    }\n\n    const { body: stream, headers } = await fetch(url);\n\n    const supportType = importTypeMap[type].accept.split(',');\n\n    const fileFormat = headers\n      .get('content-type')\n      ?.split(';')\n      ?.map((item: string) => item.trim());\n\n    if (fileFormat?.length && !intersection(fileFormat, supportType).length) {\n      throw new CustomHttpException(\n        `File format is not supported, only ${supportType.join(',')} are supported, your file's content type is ${fileFormat.join(';')}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.import.notSupportedFileFormat',\n            context: {\n              supportType: supportType.join(','),\n              fileFormat: fileFormat?.join(';'),\n            },\n          },\n        }\n      );\n    }\n\n    const contentDisposition = headers.get('content-disposition');\n    let fileName = 'Import Table.csv';\n\n    if (contentDisposition) {\n      const fileNameMatch =\n        contentDisposition.match(/filename\\*=UTF-8''([^;]+)/) ||\n        contentDisposition.match(/filename=\"?([^\"]+)\"?/);\n      if (fileNameMatch) {\n        fileName = fileNameMatch[1];\n      }\n    }\n\n    const finalFileName = fileName.split('.').shift() as string;\n\n    this.setFileNameFromHeader(decodeURIComponent(finalFileName));\n\n    // Only apply encoding conversion for text-based formats (CSV).\n    // Binary formats like XLSX handle encoding internally and must not be\n    // piped through a text decoder — doing so would corrupt the data.\n    const finalStream =\n      this.config.type === SUPPORTEDTYPE.CSV ? createEncodingConvertStream(stream) : stream;\n\n    return { stream: finalStream, fileName: finalFileName };\n  }\n\n  async genColumns() {\n    const supportTypes = Importer.SUPPORTEDTYPE;\n    const parseResult = await this.parse();\n    const { fileName, type } = this.config;\n    const result: IAnalyzeVo['worksheets'] = {};\n\n    for (const [sheetName, cols] of Object.entries(parseResult)) {\n      const zipColumnInfo = zip(...cols);\n      const existNames: string[] = [];\n      const calculatedColumnHeaders = zipColumnInfo\n        .map((column, index) => {\n          let isColumnEmpty = true;\n          let validatingFieldTypes = [...supportTypes];\n          for (let i = 0; i < column.length; i++) {\n            if (validatingFieldTypes.length <= 1) {\n              break;\n            }\n\n            // ignore empty value and first row causing first row as header\n            if (column[i] === '' || column[i] == null || i === 0) {\n              continue;\n            }\n\n            // when the whole columns aren't empty should flag\n            isColumnEmpty = false;\n\n            // when one of column's value validates long text, then break;\n            if (validateZodSchemaMap[FieldType.LongText].safeParse(column[i]).success) {\n              validatingFieldTypes = [FieldType.LongText];\n              break;\n            }\n\n            const matchTypes = validatingFieldTypes.filter((type) => {\n              const schema = validateZodSchemaMap[type];\n              return schema.safeParse(column[i]).success;\n            });\n\n            validatingFieldTypes = matchTypes;\n          }\n\n          // empty columns should be default type\n          validatingFieldTypes = !isColumnEmpty\n            ? validatingFieldTypes\n            : [Importer.DEFAULT_COLUMN_TYPE];\n\n          const name = getUniqName(toString(column?.[0]).trim() || `Field ${index}`, existNames);\n\n          existNames.push(name);\n\n          return {\n            type: validatingFieldTypes[0] || Importer.DEFAULT_COLUMN_TYPE,\n            name: name.toString(),\n          };\n        })\n        ?.filter((column) => Boolean(column));\n\n      result[sheetName] = {\n        name: type === SUPPORTEDTYPE.EXCEL ? sheetName : fileName ? fileName : sheetName,\n        columns: calculatedColumnHeaders,\n      };\n    }\n\n    return {\n      worksheets: result,\n    };\n  }\n}\n\nexport class CsvImporter extends Importer {\n  public static readonly CHECK_LINES = 500;\n  public static readonly DEFAULT_SHEETKEY = 'Import Table';\n\n  parse(): Promise<IParseResult>;\n  parse(\n    options: Papa.ParseConfig & { skipFirstNLines: number; key: string },\n    chunk: (chunk: Record<string, unknown[][]>, lastChunk?: boolean) => Promise<void>,\n    onFinished?: () => void,\n    onError?: (errorMsg: string) => void\n  ): Promise<void>;\n  async parse(\n    ...args: [\n      options?: Papa.ParseConfig & { skipFirstNLines: number; key: string },\n      chunkCb?: (chunk: Record<string, unknown[][]>, lastChunk?: boolean) => Promise<void>,\n      onFinished?: () => void,\n      onError?: (errorMsg: string) => void,\n    ]\n  ): Promise<unknown> {\n    const [options, chunkCb, onFinished, onError] = args;\n    const { stream } = await this.getFile();\n\n    // reload function, having chunkCb support chunk, otherwise in one operation.\n    if (options && chunkCb) {\n      return new Promise((resolve, reject) => {\n        let isFirst = true;\n        let recordBuffer: unknown[][] = [];\n        let isAbort = false;\n        let totalRowCount = 0;\n\n        Papa.parse(toLineDelimitedStream(stream), {\n          download: false,\n          dynamicTyping: false,\n          chunk: (chunk, parser) => {\n            (async () => {\n              const newChunk = [...chunk.data] as unknown[][];\n              if (isFirst && options.skipFirstNLines) {\n                newChunk.splice(0, 1);\n                isFirst = false;\n              }\n\n              recordBuffer.push(...newChunk);\n              totalRowCount += newChunk.length;\n\n              if (this.config.maxRowCount && totalRowCount > this.config.maxRowCount) {\n                isAbort = true;\n                recordBuffer = [];\n                onError?.(Importer.OVER_PLAN_ROW_COUNT_ERROR_MESSAGE);\n                parser.abort();\n              }\n\n              if (\n                recordBuffer.length >= Importer.MAX_CHUNK_LENGTH ||\n                sizeof(recordBuffer) > Importer.CHUNK_SIZE\n              ) {\n                parser.pause();\n                try {\n                  await chunkCb({ [CsvImporter.DEFAULT_SHEETKEY]: recordBuffer });\n                } catch (e) {\n                  isAbort = true;\n                  recordBuffer = [];\n                  const error = exceptionParse(e as Error);\n                  onError?.(error?.message || Importer.DEFAULT_ERROR_MESSAGE);\n                  parser.abort();\n                }\n                recordBuffer = [];\n                parser.resume();\n              }\n            })();\n          },\n          complete: () => {\n            (async () => {\n              try {\n                // whatever execute chunkCb, empty recordBuffer\n                await chunkCb({ [CsvImporter.DEFAULT_SHEETKEY]: recordBuffer }, true);\n              } catch (e) {\n                isAbort = true;\n                recordBuffer = [];\n                const error = exceptionParse(e as Error);\n                onError?.(error?.message || Importer.DEFAULT_ERROR_MESSAGE);\n              }\n              !isAbort && onFinished?.();\n              resolve({});\n            })();\n          },\n          error: (e) => {\n            onError?.(e?.message || Importer.DEFAULT_ERROR_MESSAGE);\n            reject(e);\n          },\n        });\n      });\n    } else {\n      return new Promise((resolve, reject) => {\n        Papa.parse(stream, {\n          download: false,\n          dynamicTyping: true,\n          preview: CsvImporter.CHECK_LINES,\n          complete: (result) => {\n            resolve({\n              [CsvImporter.DEFAULT_SHEETKEY]: result.data,\n            });\n          },\n          error: (err) => {\n            reject(err);\n          },\n        });\n      });\n    }\n  }\n\n  async getRawContent({ limit = CsvImporter.CHECK_LINES }: { limit?: number } = {}) {\n    const { stream } = await this.getFile();\n    return new Promise<IParseResult>((resolve, reject) => {\n      Papa.parse(stream, {\n        download: false,\n        dynamicTyping: false,\n        preview: limit,\n        complete: (result) => {\n          resolve({\n            [CsvImporter.DEFAULT_SHEETKEY]: result.data,\n          } as IParseResult);\n        },\n        error: (err) => {\n          reject(err);\n        },\n      });\n    });\n  }\n}\n\nexport class ExcelImporter extends Importer {\n  public static readonly SUPPORTEDTYPE: IValidateTypes[] = [\n    FieldType.Checkbox,\n    FieldType.Number,\n    FieldType.Date,\n    FieldType.SingleLineText,\n    FieldType.LongText,\n  ];\n\n  parse(): Promise<IParseResult>;\n  parse(\n    options: { skipFirstNLines: number; key: string },\n    chunk: (chunk: Record<string, unknown[][]>, lastChunk?: boolean) => Promise<void>,\n    onFinished?: () => void,\n    onError?: (errorMsg: string) => void\n  ): Promise<void>;\n\n  async parse(\n    options?: { skipFirstNLines: number; key: string },\n    chunk?: (chunk: Record<string, unknown[][]>, lastChunk?: boolean) => Promise<void>,\n    onFinished?: () => void,\n    onError?: (errorMsg: string) => void\n  ): Promise<unknown> {\n    const { stream: fileSteam } = await this.getFile();\n\n    const asyncRs = async (stream: NodeJS.ReadableStream): Promise<IParseResult> =>\n      new Promise((res, rej) => {\n        const buffers: Uint8Array[] = [];\n        stream.on('data', function (data) {\n          buffers.push(data);\n        });\n        stream.on('end', function () {\n          const buf = Buffer.concat(buffers);\n          const workbook = XLSX.read(buf, { dense: true });\n          const result: IParseResult = {};\n          Object.keys(workbook.Sheets).forEach((name) => {\n            result[name] = workbook.Sheets[name]['!data']?.map((item) =>\n              item.map((v) => v.w ?? v.v)\n            ) as unknown[][];\n          });\n          res(result);\n        });\n        stream.on('error', (e) => {\n          onError?.(e?.message || Importer.DEFAULT_ERROR_MESSAGE);\n          rej(e);\n        });\n      });\n\n    const parseResult = await asyncRs(fileSteam);\n\n    if (options && chunk) {\n      const { skipFirstNLines, key } = options;\n      const chunks = parseResult[key];\n      const parseResults = chunkArray(chunks, Importer.MAX_CHUNK_LENGTH);\n\n      if (this.config.maxRowCount && chunks.length > this.config.maxRowCount) {\n        onError?.(Importer.OVER_PLAN_ROW_COUNT_ERROR_MESSAGE);\n        return;\n      }\n\n      for (let i = 0; i < parseResults.length; i++) {\n        const currentChunk = parseResults[i];\n        if (i === 0 && skipFirstNLines) {\n          currentChunk.splice(0, 1);\n        }\n        const lastChunk = i === parseResults.length - 1;\n        try {\n          await chunk({ [key]: currentChunk }, lastChunk);\n        } catch (e) {\n          onError?.((e as Error)?.message || Importer.DEFAULT_ERROR_MESSAGE);\n        }\n      }\n      onFinished?.();\n    }\n\n    return parseResult;\n  }\n\n  async getRawContent() {\n    return await this.parse();\n  }\n}\n\nexport const importerFactory = (type: SUPPORTEDTYPE, config: IImportConstructorParams) => {\n  switch (type) {\n    case SUPPORTEDTYPE.CSV:\n      return new CsvImporter(config);\n    case SUPPORTEDTYPE.EXCEL:\n      return new ExcelImporter(config);\n    default:\n      throw new CustomHttpException(\n        'Import file type not supported',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.import.notSupportedFileType',\n          },\n        }\n      );\n  }\n};\n\nexport const getWorkerPath = (fileName: string) => {\n  // there are two possible paths for worker\n  const workerPath = join(__dirname, 'worker', `${fileName}.js`);\n  const workerPath2 = join(process.cwd(), 'dist', 'worker', `${fileName}.js`);\n\n  if (existsSync(workerPath)) {\n    return workerPath;\n  } else {\n    return workerPath2;\n  }\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/foreign-key.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { FieldType, type ILinkFieldOptions } from '@teable/core';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\n\n@Injectable()\nexport class ForeignKeyIntegrityService {\n  private readonly logger = new Logger(ForeignKeyIntegrityService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async getIssues(tableId: string, field: LinkFieldDto): Promise<IIntegrityIssue[]> {\n    const { foreignTableId, fkHostTableName, foreignKeyName, selfKeyName } = field.options;\n    const issues: IIntegrityIssue[] = [];\n\n    const { name: selfTableName, dbTableName: selfTableDbTableName } =\n      await this.prismaService.tableMeta.findFirstOrThrow({\n        where: { id: tableId, deletedTime: null },\n        select: { name: true, dbTableName: true },\n      });\n\n    const { name: foreignTableName, dbTableName: foreignTableDbTableName } =\n      await this.prismaService.tableMeta.findFirstOrThrow({\n        where: { id: foreignTableId, deletedTime: null },\n        select: { name: true, dbTableName: true },\n      });\n\n    // Check self references\n    if (selfTableDbTableName !== fkHostTableName) {\n      const selfIssues = await this.checkInvalidReferences({\n        fkHostTableName,\n        targetTableName: selfTableDbTableName,\n        keyName: selfKeyName,\n        field,\n        referencedTableName: selfTableName,\n        isSelfReference: true,\n      });\n      issues.push(...selfIssues);\n    }\n\n    // Check foreign references\n    if (foreignTableDbTableName !== fkHostTableName) {\n      const foreignIssues = await this.checkInvalidReferences({\n        fkHostTableName,\n        targetTableName: foreignTableDbTableName,\n        keyName: foreignKeyName,\n        field,\n        referencedTableName: foreignTableName,\n        isSelfReference: false,\n      });\n      issues.push(...foreignIssues);\n    }\n\n    return issues;\n  }\n\n  private async checkInvalidReferences({\n    fkHostTableName,\n    targetTableName,\n    keyName,\n    field,\n    referencedTableName,\n    isSelfReference,\n  }: {\n    fkHostTableName: string;\n    targetTableName: string;\n    keyName: string;\n    field: { id: string; name: string };\n    referencedTableName: string;\n    isSelfReference: boolean;\n  }): Promise<IIntegrityIssue[]> {\n    const issues: IIntegrityIssue[] = [];\n\n    const invalidQuery = this.knex(fkHostTableName)\n      .leftJoin(targetTableName, `${fkHostTableName}.${keyName}`, `${targetTableName}.__id`)\n      .whereNull(`${targetTableName}.__id`)\n      .count(`${fkHostTableName}.${keyName} as count`)\n      .first()\n      .toQuery();\n\n    try {\n      const invalidRefs =\n        await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(invalidQuery);\n      const refCount = Number(invalidRefs[0]?.count || 0);\n\n      if (refCount > 0) {\n        const message = isSelfReference\n          ? `Found ${refCount} invalid self references in table ${referencedTableName}`\n          : `Found ${refCount} invalid foreign references to table ${referencedTableName}`;\n        issues.push({\n          type: IntegrityIssueType.MissingRecordReference,\n          fieldId: field.id,\n          message: `${message} (Field Name: ${field.name}, Field ID: ${field.id})`,\n        });\n      }\n    } catch (error) {\n      if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2010') {\n        console.error('error ignored:', error);\n      } else {\n        throw error;\n      }\n    }\n\n    return issues;\n  }\n\n  async fix(fieldId: string): Promise<IIntegrityIssue | undefined> {\n    const field = await this.prismaService.field.findFirstOrThrow({\n      where: { id: fieldId, type: FieldType.Link, isLookup: null, deletedTime: null },\n    });\n\n    const tableId = field.tableId;\n\n    const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n    const { foreignTableId, fkHostTableName, foreignKeyName, selfKeyName } = options;\n    const table = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { id: true, name: true, dbTableName: true },\n    });\n    const foreignTable = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: foreignTableId, deletedTime: null },\n      select: { id: true, name: true, dbTableName: true },\n    });\n\n    let totalFixed = 0;\n\n    // Fix invalid self references\n    if (table.dbTableName !== fkHostTableName) {\n      const selfDeleted = await this.deleteMissingReferences({\n        fkHostTableName,\n        targetTableName: table.dbTableName,\n        keyName: selfKeyName,\n      });\n      totalFixed += selfDeleted;\n    }\n\n    // Fix invalid foreign references\n    if (foreignTable.dbTableName !== fkHostTableName) {\n      const foreignDeleted = await this.deleteMissingReferences({\n        fkHostTableName,\n        targetTableName: foreignTable.dbTableName,\n        keyName: foreignKeyName,\n      });\n      totalFixed += foreignDeleted;\n    }\n\n    if (totalFixed > 0) {\n      return {\n        type: IntegrityIssueType.MissingRecordReference,\n        fieldId,\n        message: `Fixed ${totalFixed} invalid references and inconsistent links for link field (Field Name: ${field.name}, Field ID: ${field.id})`,\n      };\n    }\n  }\n\n  private async deleteMissingReferences({\n    fkHostTableName,\n    targetTableName,\n    keyName,\n  }: {\n    fkHostTableName: string;\n    targetTableName: string;\n    keyName: string;\n  }) {\n    if (!fkHostTableName.split('.')[1].startsWith('junction_')) {\n      throw new Error(`fkHostTableName: ${fkHostTableName} is not a junction table`);\n    }\n\n    const deleteQuery = this.knex(fkHostTableName)\n      .whereNotExists(\n        this.knex\n          .select('__id')\n          .from(targetTableName)\n          .where('__id', this.knex.ref(`${fkHostTableName}.${keyName}`))\n      )\n      .delete()\n      .toQuery();\n    return await this.prismaService.$executeRawUnsafe(deleteQuery);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/integrity.controller.ts",
    "content": "import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';\nimport type { IIntegrityCheckVo, IIntegrityIssue } from '@teable/openapi';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { PermissionGuard } from '../auth/guard/permission.guard';\nimport { LinkIntegrityService } from './link-integrity.service';\n\n@UseGuards(PermissionGuard)\n@Controller('api/integrity')\nexport class IntegrityController {\n  constructor(private readonly linkIntegrityService: LinkIntegrityService) {}\n\n  @Permissions('base|update')\n  @Get('base/:baseId/link-check')\n  async checkBaseIntegrity(\n    @Param('baseId') baseId: string,\n    @Query('tableId') tableId: string\n  ): Promise<IIntegrityCheckVo> {\n    return await this.linkIntegrityService.linkIntegrityCheck(baseId, tableId);\n  }\n\n  @Permissions('base|update')\n  @Post('base/:baseId/link-fix')\n  async fixBaseIntegrity(\n    @Param('baseId') baseId: string,\n    @Query('tableId') tableId: string\n  ): Promise<IIntegrityIssue[]> {\n    return await this.linkIntegrityService.linkIntegrityFix(baseId, tableId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/integrity.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FieldModule } from '../field/field.module';\nimport { TableDomainQueryModule } from '../table-domain';\nimport { ForeignKeyIntegrityService } from './foreign-key.service';\nimport { IntegrityController } from './integrity.controller';\nimport { LinkFieldIntegrityService } from './link-field.service';\nimport { LinkIntegrityService } from './link-integrity.service';\nimport { UniqueIndexService } from './unique-index.service';\n\n@Module({\n  imports: [FieldModule, TableDomainQueryModule],\n  controllers: [IntegrityController],\n  providers: [\n    ForeignKeyIntegrityService,\n    LinkFieldIntegrityService,\n    LinkIntegrityService,\n    UniqueIndexService,\n  ],\n  exports: [LinkIntegrityService],\n})\nexport class IntegrityModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/link-field.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { FieldType, type ILinkFieldOptions } from '@teable/core';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\n\n@Injectable()\nexport class LinkFieldIntegrityService {\n  private readonly logger = new Logger(LinkFieldIntegrityService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  async getIssues(tableId: string, field: LinkFieldDto): Promise<IIntegrityIssue[]> {\n    const table = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { name: true, dbTableName: true },\n    });\n    const { fkHostTableName, foreignKeyName, selfKeyName } = field.options;\n    const inconsistentRecords = await this.checkLinks({\n      dbTableName: table.dbTableName,\n      fkHostTableName,\n      selfKeyName,\n      foreignKeyName,\n      linkDbFieldName: field.dbFieldName,\n      isMultiValue: Boolean(field.isMultipleCellValue),\n    });\n\n    if (inconsistentRecords.length > 0) {\n      return [\n        {\n          type: IntegrityIssueType.InvalidLinkReference,\n          fieldId: field.id,\n          message: `Found ${inconsistentRecords.length} inconsistent links in fkHostTableName ${fkHostTableName} (TableName: ${table.name}, Field Name: ${field.name}, Field ID: ${field.id})`,\n        },\n      ];\n    }\n\n    return [];\n  }\n\n  private async checkLinks(params: {\n    dbTableName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }) {\n    // Some symmetric link fields may not persist a JSON column (depending on\n    // creation path). If the link JSON column does not exist, skip comparison.\n    const linkColumnExists = await this.dbProvider.checkColumnExist(\n      params.dbTableName,\n      params.linkDbFieldName,\n      this.prismaService\n    );\n\n    if (!linkColumnExists) {\n      return [];\n    }\n\n    const query = this.dbProvider.integrityQuery().checkLinks(params);\n    try {\n      return await this.prismaService.$queryRawUnsafe<{ id: string }[]>(query);\n    } catch (error) {\n      if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2010') {\n        this.logger.warn(\n          `Skip link integrity check for field \"${params.linkDbFieldName}\" on table \"${params.dbTableName}\" due to missing column: ${error.meta?.message || error.message}`\n        );\n        return [];\n      }\n      throw error;\n    }\n  }\n\n  private async fixLinks(params: {\n    recordIds: string[];\n    dbTableName: string;\n    foreignDbTableName: string;\n    fkHostTableName: string;\n    lookupDbFieldName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n  }) {\n    // If display column does not exist (link fields are virtual by design), skip update\n    const linkColumnExists = await this.dbProvider.checkColumnExist(\n      params.dbTableName,\n      params.linkDbFieldName,\n      this.prismaService\n    );\n\n    if (!linkColumnExists) {\n      return 0;\n    }\n\n    const query = this.dbProvider.integrityQuery().fixLinks(params);\n    return await this.prismaService.$executeRawUnsafe(query);\n  }\n\n  private async checkAndFix(params: {\n    dbTableName: string;\n    foreignDbTableName: string;\n    fkHostTableName: string;\n    lookupDbFieldName: string;\n    foreignKeyName: string;\n    linkDbFieldName: string;\n    isMultiValue: boolean;\n    selfKeyName: string;\n  }) {\n    try {\n      const inconsistentRecords = await this.checkLinks(params);\n\n      if (inconsistentRecords.length > 0) {\n        const recordIds = inconsistentRecords.map((record) => record.id);\n        const updatedCount = await this.fixLinks({\n          ...params,\n          recordIds,\n        });\n        this.logger.debug(`Updated ${updatedCount} records in ${params.dbTableName}`);\n        return updatedCount;\n      }\n      return 0;\n    } catch (error) {\n      this.logger.error('Error updating inconsistent links:', error);\n      throw error;\n    }\n  }\n\n  async fix(fieldId: string): Promise<IIntegrityIssue | undefined> {\n    const field = await this.prismaService.field.findFirstOrThrow({\n      where: { id: fieldId, type: FieldType.Link, isLookup: null, deletedTime: null },\n    });\n\n    const tableId = field.tableId;\n\n    const table = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const linkField = createFieldInstanceByRaw(field) as LinkFieldDto;\n\n    const lookupField = await this.prismaService.field.findFirstOrThrow({\n      where: { id: linkField.options.lookupFieldId, deletedTime: null },\n      select: { dbFieldName: true },\n    });\n\n    const foreignTable = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: linkField.options.foreignTableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n    const { fkHostTableName, foreignKeyName, selfKeyName } = options;\n\n    let totalFixed = 0;\n\n    // Add table links fixing\n    const linksFixed = await this.checkAndFix({\n      dbTableName: table.dbTableName,\n      foreignDbTableName: foreignTable.dbTableName,\n      fkHostTableName,\n      lookupDbFieldName: lookupField.dbFieldName,\n      foreignKeyName,\n      linkDbFieldName: linkField.dbFieldName,\n      isMultiValue: Boolean(linkField.isMultipleCellValue),\n      selfKeyName,\n    });\n\n    totalFixed += linksFixed;\n\n    if (totalFixed > 0) {\n      return {\n        type: IntegrityIssueType.InvalidLinkReference,\n        fieldId,\n        message: `Fixed ${totalFixed} inconsistent links for link field (Field Name: ${field.name}, Field ID: ${field.id})`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/link-integrity.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable, Logger } from '@nestjs/common';\nimport {\n  FieldType,\n  type ILinkFieldOptions,\n  CellValueType,\n  DbFieldType,\n  Relationship,\n  DriverClient,\n} from '@teable/core';\nimport type { Field } from '@teable/db-main-prisma';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport { IntegrityIssueType, type IIntegrityCheckVo, type IIntegrityIssue } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { LinkFieldQueryService } from '../field/field-calculate/link-field-query.service';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\nimport { TableDomainQueryService } from '../table-domain';\nimport { ForeignKeyIntegrityService } from './foreign-key.service';\nimport { LinkFieldIntegrityService } from './link-field.service';\nimport { UniqueIndexService } from './unique-index.service';\n\n@Injectable()\nexport class LinkIntegrityService {\n  private readonly logger = new Logger(LinkIntegrityService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly foreignKeyIntegrityService: ForeignKeyIntegrityService,\n    private readonly linkFieldIntegrityService: LinkFieldIntegrityService,\n    private readonly uniqueIndexService: UniqueIndexService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly linkFieldQueryService: LinkFieldQueryService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async linkIntegrityCheck(baseId: string, tableId?: string): Promise<IIntegrityCheckVo> {\n    const mainBase = await this.prismaService.base.findFirstOrThrow({\n      where: { id: baseId, deletedTime: null },\n      select: { id: true, name: true },\n    });\n\n    const tables = await this.prismaService.tableMeta.findMany({\n      where: { baseId, deletedTime: null },\n      select: {\n        id: true,\n        name: true,\n        dbTableName: true,\n        fields: {\n          where: { type: FieldType.Link, isLookup: null, deletedTime: null },\n        },\n      },\n    });\n\n    const crossBaseLinkFieldsQuery = this.dbProvider.optionsQuery(FieldType.Link, 'baseId', baseId);\n    const crossBaseLinkFieldsRaw =\n      await this.prismaService.$queryRawUnsafe<Field[]>(crossBaseLinkFieldsQuery);\n\n    const crossBaseLinkFields = crossBaseLinkFieldsRaw.filter(\n      (field) => !tables.find((table) => table.id === field.tableId)\n    );\n\n    const linkFieldIssues: IIntegrityCheckVo['linkFieldIssues'] = [];\n\n    for (const table of tables) {\n      const tableIssues = await this.checkTableLinkFields(table);\n      if (tableIssues.length > 0) {\n        linkFieldIssues.push({\n          baseId: mainBase.id,\n          baseName: mainBase.name,\n          issues: tableIssues,\n        });\n      }\n      const uniqueIndexIssues = await this.uniqueIndexService.checkUniqueIndex(table);\n      if (uniqueIndexIssues.length > 0) {\n        linkFieldIssues.push({\n          baseId: mainBase.id,\n          baseName: mainBase.name,\n          tableId: table.id,\n          tableName: table.name,\n          issues: uniqueIndexIssues,\n        });\n      }\n    }\n\n    for (const field of crossBaseLinkFields) {\n      const table = await this.prismaService.tableMeta.findFirst({\n        where: {\n          id: field.tableId,\n          deletedTime: null,\n          base: { deletedTime: null, space: { deletedTime: null } },\n        },\n        select: { id: true, name: true, baseId: true },\n      });\n\n      if (!table) {\n        continue;\n      }\n\n      const tableIssues = await this.checkTableLinkFields({\n        id: table.id,\n        name: table.name,\n        fields: [field],\n      });\n\n      const base = await this.prismaService.base.findFirstOrThrow({\n        where: { id: table.baseId, deletedTime: null },\n        select: { id: true, name: true },\n      });\n\n      if (tableIssues.length > 0) {\n        linkFieldIssues.push({\n          baseId: base.id,\n          baseName: base.name,\n          issues: tableIssues,\n        });\n      }\n    }\n\n    const referenceFieldIssues = await this.checkReferenceField(baseId);\n    if (referenceFieldIssues.length > 0) {\n      linkFieldIssues.push({\n        baseId: mainBase.id,\n        baseName: mainBase.name,\n        issues: referenceFieldIssues,\n      });\n    }\n\n    if (tableId) {\n      const checkEmptyString = await this.checkEmptyString(tableId);\n\n      if (checkEmptyString.length > 0) {\n        linkFieldIssues.push({\n          baseId: mainBase.id,\n          baseName: mainBase.name,\n          issues: checkEmptyString,\n        });\n      }\n    }\n\n    return {\n      hasIssues: linkFieldIssues.length > 0,\n      linkFieldIssues,\n    };\n  }\n\n  private async checkReferenceField(baseId: string): Promise<IIntegrityIssue[]> {\n    const tables = await this.prismaService.tableMeta.findMany({\n      where: { baseId, deletedTime: null },\n      select: {\n        id: true,\n        name: true,\n        fields: {\n          where: { deletedTime: null },\n          select: { id: true },\n        },\n      },\n    });\n\n    const allFieldIds = tables.reduce<string[]>((acc, table) => {\n      return [...acc, ...table.fields.map((f) => f.id)];\n    }, []);\n\n    const references = await this.prismaService.reference.findMany({\n      where: {\n        OR: [{ fromFieldId: { in: allFieldIds } }, { toFieldId: { in: allFieldIds } }],\n      },\n    });\n\n    const fieldIds = new Set<string>();\n    for (const reference of references) {\n      fieldIds.add(reference.fromFieldId);\n      fieldIds.add(reference.toFieldId);\n    }\n\n    const fields = await this.prismaService.field.findMany({\n      where: { id: { in: Array.from(fieldIds) } },\n      select: { id: true, name: true, deletedTime: true },\n    });\n\n    const deletedFields = fields.filter((f) => f.deletedTime);\n\n    // exist in references but not in fields\n    const cannotFindFields = Array.from(fieldIds).filter((id) => !fields.find((f) => f.id === id));\n\n    const issues: IIntegrityIssue[] = [];\n    for (const field of deletedFields) {\n      issues.push({\n        fieldId: field.id,\n        type: IntegrityIssueType.ReferenceFieldNotFound,\n        message: `Reference field ${field.name} is deleted`,\n      });\n    }\n\n    for (const fieldId of cannotFindFields) {\n      issues.push({\n        fieldId,\n        type: IntegrityIssueType.ReferenceFieldNotFound,\n        message: `Reference field ${fieldId} not found`,\n      });\n    }\n\n    return issues;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async checkTableLinkFields(table: {\n    id: string;\n    name: string;\n    fields: Field[];\n  }): Promise<IIntegrityIssue[]> {\n    const issues: IIntegrityIssue[] = [];\n\n    for (const field of table.fields) {\n      const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n\n      const foreignTable = await this.prismaService.tableMeta.findFirst({\n        where: { id: options.foreignTableId, deletedTime: null },\n        select: { id: true, baseId: true, dbTableName: true },\n      });\n\n      if (!foreignTable) {\n        issues.push({\n          fieldId: field.id,\n          type: IntegrityIssueType.ForeignTableNotFound,\n          message: `Foreign table with ID ${options.foreignTableId} not found for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n        });\n      }\n\n      let canCheckLinks = false;\n      const tableExistsSql = this.dbProvider.checkTableExist(options.fkHostTableName);\n      const tableExists =\n        await this.prismaService.$queryRawUnsafe<{ exists: boolean }[]>(tableExistsSql);\n      const hostTableExists = tableExists[0].exists;\n\n      if (!hostTableExists) {\n        issues.push({\n          fieldId: field.id,\n          type: IntegrityIssueType.ForeignKeyHostTableNotFound,\n          message: `Foreign key host table ${options.fkHostTableName} not found for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n        });\n      } else {\n        const selfKeyExists = await this.dbProvider.checkColumnExist(\n          options.fkHostTableName,\n          options.selfKeyName,\n          this.prismaService\n        );\n\n        const foreignKeyExists = await this.dbProvider.checkColumnExist(\n          options.fkHostTableName,\n          options.foreignKeyName,\n          this.prismaService\n        );\n\n        if (!selfKeyExists) {\n          issues.push({\n            fieldId: field.id,\n            type: IntegrityIssueType.ForeignKeyNotFound,\n            message: `Self key name \"${options.selfKeyName}\" is missing for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n          });\n        }\n\n        if (!foreignKeyExists) {\n          issues.push({\n            fieldId: field.id,\n            type: IntegrityIssueType.ForeignKeyNotFound,\n            message: `Foreign key name \"${options.foreignKeyName}\" is missing for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n          });\n        }\n        canCheckLinks = selfKeyExists && foreignKeyExists;\n      }\n\n      if (options.symmetricFieldId) {\n        const symmetricField = await this.prismaService.field.findFirst({\n          where: { id: options.symmetricFieldId, deletedTime: null },\n        });\n\n        if (!symmetricField) {\n          issues.push({\n            fieldId: field.id,\n            type: IntegrityIssueType.SymmetricFieldNotFound,\n            message: `Symmetric field ID ${options.symmetricFieldId} not found for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n          });\n        }\n      }\n\n      if (!options.isOneWay && !options.symmetricFieldId) {\n        issues.push({\n          fieldId: field.id,\n          type: IntegrityIssueType.SymmetricFieldNotFound,\n          message: `Symmetric is missing for link field (Field Name: ${field.name}, Field ID: ${field.id}) in table ${table.name}`,\n        });\n      }\n\n      if (foreignTable && hostTableExists && canCheckLinks) {\n        const linkField = createFieldInstanceByRaw(field) as LinkFieldDto;\n        const invalidReferences = await this.foreignKeyIntegrityService.getIssues(\n          table.id,\n          linkField\n        );\n        const invalidLinks = await this.linkFieldIntegrityService.getIssues(table.id, linkField);\n\n        if (invalidReferences.length > 0) {\n          issues.push(...invalidReferences);\n        }\n        if (invalidLinks.length > 0) {\n          issues.push(...invalidLinks);\n        }\n      }\n    }\n\n    return issues;\n  }\n\n  async checkEmptyString(tableId: string): Promise<IIntegrityIssue[]> {\n    const prisma = this.prismaService.txClient();\n    const fields = await prisma.field.findMany({\n      where: {\n        tableId,\n        deletedTime: null,\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        isComputed: null,\n      },\n      select: {\n        dbFieldName: true,\n        id: true,\n      },\n    });\n\n    const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const issues: IIntegrityIssue[] = [];\n\n    for (const { dbFieldName, id: fieldId } of fields) {\n      const countSql = await this.knex(dbTableName)\n        .count('*')\n        .whereRaw(`?? = ''`, [dbFieldName])\n        .toQuery();\n      const countResult = await prisma.$queryRawUnsafe<{ count: number }[]>(countSql);\n      const count = Number(countResult[0].count);\n      if (count > 0) {\n        issues.push({\n          type: IntegrityIssueType.EmptyString,\n          fieldId: fieldId,\n          tableId,\n          message: `Empty string cell value found in field: ${dbFieldName}`,\n        });\n      }\n    }\n\n    return issues;\n  }\n\n  private async fixMissingForeignKeyColumns(\n    fieldId: string,\n    issueType?: IntegrityIssueType\n  ): Promise<IIntegrityIssue | undefined> {\n    const prisma = this.prismaService.txClient();\n    const fieldRaw = await prisma.field.findFirst({\n      where: { id: fieldId, type: FieldType.Link, isLookup: null, deletedTime: null },\n    });\n\n    if (!fieldRaw) {\n      return;\n    }\n\n    const linkField = createFieldInstanceByRaw(fieldRaw) as LinkFieldDto;\n    const options = linkField.options;\n    const tableMeta = await prisma.tableMeta.findFirst({\n      where: { id: fieldRaw.tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    if (!tableMeta) {\n      return;\n    }\n\n    if (options.relationship === Relationship.OneOne && options.foreignKeyName === '__id') {\n      // Symmetric OneOne fields do not own the FK column.\n      return;\n    }\n\n    const tableDomain = await this.tableDomainQueryService.getTableDomainById(fieldRaw.tableId);\n    const tableNameMap = await this.linkFieldQueryService.getTableNameMapForLinkFields(\n      fieldRaw.tableId,\n      [linkField]\n    );\n\n    const queries = this.dbProvider.createColumnSchema(\n      tableMeta.dbTableName,\n      linkField,\n      tableDomain,\n      false,\n      fieldRaw.tableId,\n      tableNameMap,\n      false,\n      true\n    );\n\n    const hostExistsResult = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(\n      this.dbProvider.checkTableExist(options.fkHostTableName)\n    );\n    const hostAlreadyExists = hostExistsResult[0]?.exists;\n    const foreignDbTableName = tableNameMap.get(options.foreignTableId);\n\n    if (!foreignDbTableName) {\n      return;\n    }\n\n    const orderColumnName = linkField.getOrderColumnName();\n\n    if (hostAlreadyExists) {\n      const [selfKeyExists, foreignKeyExists, orderColumnExists] = await Promise.all([\n        this.dbProvider.checkColumnExist(options.fkHostTableName, options.selfKeyName, prisma),\n        this.dbProvider.checkColumnExist(options.fkHostTableName, options.foreignKeyName, prisma),\n        orderColumnName\n          ? this.dbProvider.checkColumnExist(options.fkHostTableName, orderColumnName, prisma)\n          : Promise.resolve(true),\n      ]);\n\n      const alterSchema = this.knex.schema.alterTable(options.fkHostTableName, (table) => {\n        switch (options.relationship) {\n          case Relationship.ManyMany: {\n            if (!selfKeyExists) {\n              table\n                .string(options.selfKeyName)\n                .references('__id')\n                .inTable(tableMeta.dbTableName)\n                .withKeyName(`fk_${options.selfKeyName}`);\n            }\n            if (!foreignKeyExists) {\n              table\n                .string(options.foreignKeyName)\n                .references('__id')\n                .inTable(foreignDbTableName)\n                .withKeyName(`fk_${options.foreignKeyName}`);\n            }\n            if (orderColumnName && !orderColumnExists) {\n              table.integer(orderColumnName).nullable();\n            }\n            break;\n          }\n          case Relationship.ManyOne:\n          case Relationship.OneOne: {\n            if (!foreignKeyExists) {\n              table\n                .string(options.foreignKeyName)\n                .references('__id')\n                .inTable(foreignDbTableName)\n                .withKeyName(`fk_${options.foreignKeyName}`);\n              if (options.relationship === Relationship.OneOne) {\n                table.unique([options.foreignKeyName], {\n                  indexName: `index_${options.foreignKeyName}`,\n                });\n              }\n            }\n            if (orderColumnName && !orderColumnExists) {\n              table.integer(orderColumnName).nullable();\n            }\n            break;\n          }\n          case Relationship.OneMany: {\n            if (options.isOneWay) {\n              if (!selfKeyExists) {\n                table\n                  .string(options.selfKeyName)\n                  .references('__id')\n                  .inTable(tableMeta.dbTableName)\n                  .withKeyName(`fk_${options.selfKeyName}`);\n              }\n              if (!foreignKeyExists) {\n                table\n                  .string(options.foreignKeyName)\n                  .references('__id')\n                  .inTable(foreignDbTableName)\n                  .withKeyName(`fk_${options.foreignKeyName}`);\n              }\n              if (!selfKeyExists || !foreignKeyExists) {\n                table.unique([options.selfKeyName, options.foreignKeyName], {\n                  indexName: `index_${options.selfKeyName}_${options.foreignKeyName}`,\n                });\n              }\n            } else {\n              if (!selfKeyExists) {\n                table\n                  .string(options.selfKeyName)\n                  .references('__id')\n                  .inTable(tableMeta.dbTableName)\n                  .withKeyName(`fk_${options.selfKeyName}`);\n              }\n              if (orderColumnName && !orderColumnExists) {\n                table.integer(orderColumnName).nullable();\n              }\n            }\n            break;\n          }\n          default:\n            break;\n        }\n      });\n\n      const alterSqls = alterSchema\n        .toSQL()\n        .map(({ sql }) => sql)\n        .filter((sql) => sql && !sql.startsWith('PRAGMA'));\n\n      for (const sql of alterSqls) {\n        await prisma.$executeRawUnsafe(sql);\n      }\n    } else {\n      const sqls = queries.filter((sql) => sql && !sql.startsWith('PRAGMA'));\n      if (!sqls.length) {\n        return;\n      }\n\n      for (const sql of sqls) {\n        try {\n          await prisma.$executeRawUnsafe(sql);\n        } catch (error) {\n          if (\n            error instanceof Prisma.PrismaClientKnownRequestError &&\n            error.code === 'P2010' &&\n            (error.meta as { code?: string })?.code === '42P07'\n          ) {\n            // Relation already exists; continue with the rest of the fix\n            continue;\n          }\n          throw error;\n        }\n      }\n    }\n\n    await this.backfillForeignKeysFromLinkColumn({\n      dbTableName: tableMeta.dbTableName,\n      linkDbFieldName: linkField.dbFieldName,\n      fkHostTableName: options.fkHostTableName,\n      selfKeyName: options.selfKeyName,\n      foreignKeyName: options.foreignKeyName,\n      relationship: options.relationship,\n      isOneWay: options.isOneWay,\n    });\n\n    return {\n      type: issueType ?? IntegrityIssueType.ForeignKeyNotFound,\n      fieldId,\n      message: `Restored missing foreign key columns for link field (Field Name: ${fieldRaw.name}, Field ID: ${fieldId})`,\n    };\n  }\n\n  private async backfillForeignKeysFromLinkColumn(params: {\n    dbTableName: string;\n    linkDbFieldName: string;\n    fkHostTableName: string;\n    selfKeyName: string;\n    foreignKeyName: string;\n    relationship: Relationship;\n    isOneWay?: boolean;\n  }) {\n    const {\n      dbTableName,\n      linkDbFieldName,\n      fkHostTableName,\n      selfKeyName,\n      foreignKeyName,\n      relationship,\n      isOneWay,\n    } = params;\n    const prisma = this.prismaService.txClient();\n\n    const linkColumnExists = await this.dbProvider.checkColumnExist(\n      dbTableName,\n      linkDbFieldName,\n      prisma\n    );\n    if (!linkColumnExists) {\n      return;\n    }\n\n    const usesJunction =\n      relationship === Relationship.ManyMany ||\n      (relationship === Relationship.OneMany && Boolean(isOneWay));\n\n    if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) {\n      const foreignKeyExists = await this.dbProvider.checkColumnExist(\n        fkHostTableName,\n        foreignKeyName,\n        prisma\n      );\n      if (!foreignKeyExists) {\n        return;\n      }\n\n      const query =\n        this.dbProvider.driver === DriverClient.Pg\n          ? this.knex(fkHostTableName)\n              .update({\n                [foreignKeyName]: this.knex.raw(`NULLIF(??->>'id','')`, [linkDbFieldName]),\n              })\n              .whereNotNull(linkDbFieldName)\n              .whereNull(foreignKeyName)\n              .toQuery()\n          : this.knex(fkHostTableName)\n              .update({\n                [foreignKeyName]: this.knex.raw(`json_extract(??, '$.id')`, [linkDbFieldName]),\n              })\n              .whereNotNull(linkDbFieldName)\n              .whereNull(foreignKeyName)\n              .toQuery();\n\n      await prisma.$executeRawUnsafe(query);\n      return;\n    }\n\n    if (relationship === Relationship.OneMany && !usesJunction) {\n      const selfKeyExists = await this.dbProvider.checkColumnExist(\n        fkHostTableName,\n        selfKeyName,\n        prisma\n      );\n      if (!selfKeyExists) {\n        return;\n      }\n\n      const query =\n        this.dbProvider.driver === DriverClient.Pg\n          ? this.knex\n              .raw(\n                `\n                WITH pairs AS (\n                  SELECT s.__id AS self_id,\n                         (elem->>'id') AS foreign_id\n                  FROM ?? AS s\n                  JOIN LATERAL jsonb_array_elements(??.??) elem ON true\n                  WHERE ??.?? IS NOT NULL\n                ),\n                dedup AS (\n                  SELECT foreign_id, MIN(self_id) AS self_id\n                  FROM pairs\n                  WHERE foreign_id IS NOT NULL\n                  GROUP BY foreign_id\n                )\n                UPDATE ?? AS f\n                SET ?? = d.self_id\n                FROM dedup d\n                WHERE f.__id = d.foreign_id\n                  AND f.?? IS NULL\n                `,\n                [\n                  dbTableName,\n                  's',\n                  linkDbFieldName,\n                  's',\n                  linkDbFieldName,\n                  fkHostTableName,\n                  selfKeyName,\n                  selfKeyName,\n                ]\n              )\n              .toQuery()\n          : this.knex\n              .raw(\n                `\n                WITH pairs AS (\n                  SELECT s.__id AS self_id,\n                         json_extract(j.value, '$.id') AS foreign_id\n                  FROM ?? AS s\n                  JOIN json_each(??.??) j\n                  WHERE ??.?? IS NOT NULL\n                ),\n                dedup AS (\n                  SELECT foreign_id, MIN(self_id) AS self_id\n                  FROM pairs\n                  WHERE foreign_id IS NOT NULL\n                  GROUP BY foreign_id\n                )\n                UPDATE ??\n                SET ?? = (SELECT d.self_id FROM dedup d WHERE d.foreign_id = ??.__id)\n                WHERE __id IN (SELECT foreign_id FROM dedup)\n                  AND ?? IS NULL\n                `,\n                [\n                  dbTableName,\n                  's',\n                  linkDbFieldName,\n                  's',\n                  linkDbFieldName,\n                  fkHostTableName,\n                  selfKeyName,\n                  fkHostTableName,\n                  selfKeyName,\n                ]\n              )\n              .toQuery();\n\n      await prisma.$executeRawUnsafe(query);\n      return;\n    }\n\n    if (!usesJunction) {\n      return;\n    }\n\n    const [selfKeyExists, foreignKeyExists] = await Promise.all([\n      this.dbProvider.checkColumnExist(fkHostTableName, selfKeyName, prisma),\n      this.dbProvider.checkColumnExist(fkHostTableName, foreignKeyName, prisma),\n    ]);\n    if (!selfKeyExists || !foreignKeyExists) {\n      return;\n    }\n\n    const query =\n      this.dbProvider.driver === DriverClient.Pg\n        ? this.knex\n            .raw(\n              `\n              WITH pairs AS (\n                SELECT s.__id AS self_id,\n                       (elem->>'id') AS foreign_id\n                FROM ?? AS s\n                JOIN LATERAL jsonb_array_elements(??.??) elem ON true\n                WHERE ??.?? IS NOT NULL\n              )\n              INSERT INTO ?? (??, ??)\n              SELECT DISTINCT p.self_id, p.foreign_id\n              FROM pairs p\n              WHERE p.foreign_id IS NOT NULL\n                AND NOT EXISTS (\n                  SELECT 1 FROM ?? j\n                  WHERE j.?? = p.self_id AND j.?? = p.foreign_id\n                )\n              `,\n              [\n                dbTableName,\n                's',\n                linkDbFieldName,\n                's',\n                linkDbFieldName,\n                fkHostTableName,\n                selfKeyName,\n                foreignKeyName,\n                fkHostTableName,\n                selfKeyName,\n                foreignKeyName,\n              ]\n            )\n            .toQuery()\n        : this.knex\n            .raw(\n              `\n              WITH pairs AS (\n                SELECT s.__id AS self_id,\n                       json_extract(j.value, '$.id') AS foreign_id\n                FROM ?? AS s\n                JOIN json_each(??.??) j\n                WHERE ??.?? IS NOT NULL\n              )\n              INSERT INTO ?? (??, ??)\n              SELECT DISTINCT p.self_id, p.foreign_id\n              FROM pairs p\n              WHERE p.foreign_id IS NOT NULL\n                AND NOT EXISTS (\n                  SELECT 1 FROM ?? j\n                  WHERE j.?? = p.self_id AND j.?? = p.foreign_id\n                )\n              `,\n              [\n                dbTableName,\n                's',\n                linkDbFieldName,\n                's',\n                linkDbFieldName,\n                fkHostTableName,\n                selfKeyName,\n                foreignKeyName,\n                fkHostTableName,\n                selfKeyName,\n                foreignKeyName,\n              ]\n            )\n            .toQuery();\n\n    await prisma.$executeRawUnsafe(query);\n  }\n\n  async linkIntegrityFix(baseId: string, tableId?: string): Promise<IIntegrityIssue[]> {\n    const checkResult = await this.linkIntegrityCheck(baseId, tableId || '');\n    const fixResults: IIntegrityIssue[] = [];\n    for (const issues of checkResult.linkFieldIssues) {\n      for (const issue of issues.issues) {\n        switch (issue.type) {\n          case IntegrityIssueType.MissingRecordReference: {\n            const result = await this.foreignKeyIntegrityService.fix(issue.fieldId);\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.InvalidLinkReference: {\n            const result = await this.linkFieldIntegrityService.fix(issue.fieldId);\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.ForeignKeyNotFound:\n          case IntegrityIssueType.ForeignKeyHostTableNotFound: {\n            const result = await this.fixMissingForeignKeyColumns(issue.fieldId, issue.type);\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.SymmetricFieldNotFound: {\n            const result = await this.fixOneWayLinkField(issue.fieldId);\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.ReferenceFieldNotFound: {\n            const result = await this.fixReferenceField(issue.fieldId);\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.UniqueIndexNotFound: {\n            const result = await this.uniqueIndexService.fixUniqueIndex(\n              issues.tableId,\n              issue.fieldId\n            );\n            result && fixResults.push(result);\n            break;\n          }\n          case IntegrityIssueType.EmptyString: {\n            const result = await this.fixEmptyString(issue.fieldId, issue.tableId);\n            result && fixResults.push(result);\n            break;\n          }\n          default:\n            break;\n        }\n      }\n    }\n\n    return fixResults;\n  }\n\n  async fixReferenceField(fieldId: string): Promise<IIntegrityIssue | undefined> {\n    const deleted = await this.prismaService.reference.deleteMany({\n      where: {\n        OR: [{ fromFieldId: fieldId }, { toFieldId: fieldId }],\n      },\n    });\n\n    if (deleted.count <= 0) {\n      return;\n    }\n\n    return {\n      type: IntegrityIssueType.InvalidLinkReference,\n      fieldId,\n      message: 'InvalidLinkReference fixed',\n    };\n  }\n\n  async fixOneWayLinkField(fieldId: string): Promise<IIntegrityIssue | undefined> {\n    const field = await this.prismaService.field.findFirstOrThrow({\n      where: { id: fieldId, deletedTime: null },\n    });\n\n    const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n\n    if (!options.isOneWay && !options.symmetricFieldId) {\n      await this.prismaService.field.update({\n        where: { id: fieldId },\n        data: {\n          options: JSON.stringify({\n            ...options,\n            isOneWay: true,\n          }),\n        },\n      });\n    }\n\n    if (options.isOneWay && options.symmetricFieldId) {\n      await this.prismaService.field.update({\n        where: { id: fieldId },\n        data: {\n          options: JSON.stringify({\n            ...options,\n            isOneWay: undefined,\n          }),\n        },\n      });\n    }\n\n    return {\n      type: IntegrityIssueType.SymmetricFieldNotFound,\n      fieldId: field.id,\n      message: `fixed one way link field (Field Name: ${field.name}, Field ID: ${field.id})`,\n    };\n  }\n\n  async fixEmptyString(fieldId: string, tableId?: string): Promise<IIntegrityIssue | undefined> {\n    const prisma = this.prismaService.txClient();\n    if (!tableId) {\n      return;\n    }\n\n    const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const { dbFieldName } = await prisma.field.findFirstOrThrow({\n      where: { id: fieldId, deletedTime: null },\n      select: { dbFieldName: true },\n    });\n\n    const sql = this.knex(dbTableName)\n      .whereRaw('?? = ?', [dbFieldName, ''])\n      .update({\n        [dbFieldName]: null,\n      })\n      .toQuery();\n    await prisma.$executeRawUnsafe(sql);\n\n    return {\n      type: IntegrityIssueType.EmptyString,\n      fieldId,\n      message: 'Empty string cell value fixed',\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/integrity/unique-index.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { IdPrefix } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { FieldService } from '../field/field.service';\n\n@Injectable()\nexport class UniqueIndexService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    private readonly fieldService: FieldService\n  ) {}\n\n  async checkUniqueIndex(table: {\n    id: string;\n    name: string;\n    dbTableName: string;\n  }): Promise<IIntegrityIssue[]> {\n    const issues: IIntegrityIssue[] = [];\n\n    const colId = '__id';\n    const idUniqueIndexExists =\n      (await this.fieldService.findUniqueIndexesForField(table.dbTableName, colId)).length > 0;\n\n    if (!idUniqueIndexExists) {\n      issues.push({\n        fieldId: colId,\n        type: IntegrityIssueType.UniqueIndexNotFound,\n        message: `Unique index ${colId} not found for table ${table.name}`,\n      });\n    }\n\n    const uniqueFields = await this.prismaService.field.findMany({\n      where: { tableId: table.id, deletedTime: null, unique: true },\n      select: { id: true, dbFieldName: true },\n    });\n\n    for (const field of uniqueFields) {\n      const indexNames = await this.fieldService.findUniqueIndexesForField(\n        table.dbTableName,\n        field.dbFieldName\n      );\n      if (indexNames.length === 0) {\n        issues.push({\n          fieldId: field.id,\n          type: IntegrityIssueType.UniqueIndexNotFound,\n          message: `Unique index ${field.id} not found for table ${table.name}`,\n        });\n      }\n    }\n    return issues;\n  }\n\n  async fixUniqueIndex(tableId?: string, fieldId?: string): Promise<IIntegrityIssue | undefined> {\n    if (!tableId || !fieldId) {\n      return;\n    }\n\n    const table = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true, name: true },\n    });\n\n    let sql: string | undefined;\n    if (fieldId.startsWith('__')) {\n      sql = this.knex.schema\n        .alterTable(table.dbTableName, (table) => {\n          table.unique([fieldId]);\n        })\n        .toQuery();\n    } else if (fieldId.startsWith(IdPrefix.Field)) {\n      const field = await this.prismaService.field.findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n        select: { dbFieldName: true },\n      });\n\n      const indexName = this.fieldService.getFieldUniqueKeyName(\n        table.dbTableName,\n        field.dbFieldName,\n        fieldId\n      );\n\n      sql = this.knex.schema\n        .alterTable(table.dbTableName, (table) => {\n          table.unique([field.dbFieldName], {\n            indexName,\n          });\n        })\n        .toQuery();\n    }\n\n    if (!sql) {\n      return;\n    }\n    await this.prismaService.txClient().$executeRawUnsafe(sql);\n\n    return {\n      type: IntegrityIssueType.UniqueIndexNotFound,\n      fieldId,\n      message: `Unique index ${fieldId} fixed for table ${table.name}`,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/invitation/invitation.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { InvitationController } from './invitation.controller';\n\ndescribe('InvitationController', () => {\n  let controller: InvitationController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [InvitationController],\n    }).compile();\n\n    controller = module.get<InvitationController>(InvitationController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/invitation/invitation.controller.ts",
    "content": "import { Body, Controller, Post } from '@nestjs/common';\nimport {\n  AcceptInvitationLinkRo,\n  acceptInvitationLinkRoSchema,\n  type AcceptInvitationLinkVo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { InvitationService } from './invitation.service';\n\n@Controller('api/invitation')\nexport class InvitationController {\n  constructor(private readonly invitationService: InvitationService) {}\n\n  @Post('/link/accept')\n  async acceptLink(\n    @Body(new ZodValidationPipe(acceptInvitationLinkRoSchema)) invitationRo: AcceptInvitationLinkRo\n  ): Promise<AcceptInvitationLinkVo> {\n    return await this.invitationService.acceptInvitationLink(invitationRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/invitation/invitation.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { MailSenderModule } from '../mail-sender/mail-sender.module';\nimport { SettingOpenApiModule } from '../setting/open-api/setting-open-api.module';\nimport { UserModule } from '../user/user.module';\nimport { InvitationController } from './invitation.controller';\nimport { InvitationService } from './invitation.service';\n\n@Module({\n  imports: [SettingOpenApiModule, CollaboratorModule, UserModule, MailSenderModule.register()],\n  providers: [InvitationService],\n  exports: [InvitationService],\n  controllers: [InvitationController],\n})\nexport class InvitationModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/invitation/invitation.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { getPermissions, Role } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PrincipalType } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { vi } from 'vitest';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { getError } from '../../../test/utils/get-error';\nimport { GlobalModule } from '../../global/global.module';\nimport type { IClsStore } from '../../types/cls';\nimport { generateInvitationCode } from '../../utils/code-generate';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { MailSenderService } from '../mail-sender/mail-sender.service';\nimport { InvitationModule } from './invitation.module';\nimport { InvitationService } from './invitation.service';\n\nconst mockInvitationId = 'invxxxxxxxxx';\nconst mockInvitationCode = generateInvitationCode(mockInvitationId);\n\ndescribe('InvitationService', () => {\n  const prismaService = mockDeep<PrismaService>();\n  const mailSenderService = mockDeep<MailSenderService>();\n  const collaboratorService = mockDeep<CollaboratorService>();\n\n  let invitationService: InvitationService;\n  let clsService: ClsService<IClsStore>;\n\n  const mockUser = { id: 'usr1', name: 'John', email: 'john@example.com' };\n  const mockSpace = { id: 'spcxxxxxxxx', name: 'Test Space' };\n  const mockInvitedUser = { id: 'usr2', name: 'Bob', email: 'bob@example.com' };\n  const defaultCls = {\n    user: mockUser,\n    tx: {},\n    origin: {\n      ip: '127.0.0.1',\n      byApi: false,\n      userAgent: 'test',\n      referer: 'test',\n    },\n  };\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [InvitationModule, GlobalModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaService)\n      .overrideProvider(MailSenderService)\n      .useValue(mailSenderService)\n      .overrideProvider(CollaboratorService)\n      .useValue(collaboratorService)\n      .compile();\n\n    clsService = module.get<ClsService<IClsStore>>(ClsService);\n    invitationService = module.get<InvitationService>(InvitationService);\n\n    prismaService.txClient.mockImplementation(() => {\n      return prismaService;\n    });\n\n    prismaService.$tx.mockImplementation(async (fn, _options) => {\n      return await fn(prismaService);\n    });\n  });\n\n  afterEach(() => {\n    mockReset(prismaService);\n  });\n\n  it('generateInvitation', async () => {\n    await clsService.runWith(\n      {\n        ...defaultCls,\n        permissions: getPermissions(Role.Owner),\n      },\n      async () => {\n        await invitationService['generateInvitation']({\n          resourceId: mockSpace.id,\n          resourceType: CollaboratorType.Space,\n          role: Role.Owner,\n          type: 'link',\n        });\n      }\n    );\n\n    expect(prismaService.invitation.create).toHaveBeenCalledWith({\n      data: {\n        id: expect.anything(),\n        invitationCode: expect.anything(),\n        spaceId: mockSpace.id,\n        role: Role.Owner,\n        baseId: null,\n        type: 'link',\n        expiredTime: null,\n        createdBy: mockUser.id,\n      },\n    });\n  });\n\n  describe('emailInvitationBySpace', () => {\n    it('should throw error if space not found', async () => {\n      prismaService.space.findFirst.mockResolvedValue(null);\n\n      await expect(\n        invitationService.emailInvitationBySpace(mockSpace.id, {\n          emails: ['notfound@example.com'],\n          role: Role.Owner,\n        })\n      ).rejects.toThrow('Space not found');\n    });\n\n    it('should send invitation email correctly', async () => {\n      // mock data\n      prismaService.space.findFirst.mockResolvedValue(mockSpace as any);\n      prismaService.user.findMany.mockResolvedValue([mockInvitedUser as any]);\n      vi.spyOn(invitationService as any, 'generateInvitation').mockResolvedValue({\n        id: mockInvitationId,\n        invitationCode: mockInvitationCode,\n      } as any);\n      collaboratorService.validateUserAddRole.mockResolvedValue();\n\n      const result = await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () =>\n          await invitationService.emailInvitationBySpace(mockSpace.id, {\n            emails: [mockInvitedUser.email],\n            role: Role.Owner,\n          })\n      );\n\n      expect(collaboratorService.createSpaceCollaborator).toHaveBeenCalledWith({\n        collaborators: [\n          {\n            principalId: mockInvitedUser.id,\n            principalType: PrincipalType.User,\n          },\n        ],\n        spaceId: mockSpace.id,\n        role: Role.Owner,\n      });\n\n      expect(prismaService.invitationRecord.create).toHaveBeenCalledWith({\n        data: {\n          inviter: mockUser.id,\n          accepter: mockInvitedUser.id,\n          type: 'email',\n          baseId: null,\n          spaceId: mockSpace.id,\n          invitationId: mockInvitationId,\n        },\n      });\n      expect(mailSenderService.sendMail).toHaveBeenCalled();\n      expect(result).toEqual({ [mockInvitedUser.email]: { invitationId: mockInvitationId } });\n    });\n\n    it('should rollback when tx fails', async () => {\n      prismaService.space.findFirst.mockResolvedValue(mockSpace as any);\n      prismaService.user.findMany.mockResolvedValue([mockInvitedUser as any]);\n      prismaService.$tx.mockRejectedValue(new Error('tx error'));\n      collaboratorService.validateUserAddRole.mockResolvedValue();\n      vi.spyOn(invitationService as any, 'checkSpaceInvitation').mockResolvedValue(true);\n\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => {\n          await expect(\n            invitationService.emailInvitationBySpace(mockSpace.id, {\n              emails: [mockInvitedUser.email],\n              role: Role.Owner,\n            })\n          ).rejects.toThrow('tx error');\n        }\n      );\n    });\n  });\n\n  describe('emailInvitationByBase', () => {\n    it('should throw error if base not found', async () => {\n      prismaService.base.findFirst.mockResolvedValue(null);\n\n      await expect(\n        invitationService.emailInvitationByBase('base1', {\n          emails: ['notfound@example.com'],\n          role: Role.Creator,\n        })\n      ).rejects.toThrow('Base not found');\n    });\n\n    it('should send invitation email correctly', async () => {\n      // mock data\n      prismaService.base.findFirst.mockResolvedValue({ id: 'base1' } as any);\n      prismaService.user.findMany.mockResolvedValue([mockInvitedUser as any]);\n      vi.spyOn(invitationService as any, 'generateInvitation').mockResolvedValue({\n        id: mockInvitationId,\n        invitationCode: mockInvitationCode,\n      } as any);\n      collaboratorService.validateUserAddRole.mockResolvedValue();\n\n      const result = await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Creator),\n        },\n        async () =>\n          await invitationService.emailInvitationByBase('base1', {\n            emails: [mockInvitedUser.email],\n            role: Role.Creator,\n          })\n      );\n\n      expect(collaboratorService.createBaseCollaborator).toHaveBeenCalledWith({\n        collaborators: [\n          {\n            principalId: mockInvitedUser.id,\n            principalType: PrincipalType.User,\n          },\n        ],\n        baseId: 'base1',\n        role: Role.Creator,\n      });\n      expect(prismaService.invitationRecord.create).toHaveBeenCalledWith({\n        data: {\n          inviter: mockUser.id,\n          accepter: mockInvitedUser.id,\n          type: 'email',\n          baseId: 'base1',\n          spaceId: null,\n          invitationId: mockInvitationId,\n        },\n      });\n      expect(mailSenderService.sendMail).toHaveBeenCalled();\n      expect(result).toEqual({ [mockInvitedUser.email]: { invitationId: mockInvitationId } });\n    });\n\n    it('should rollback when tx fails', async () => {\n      prismaService.base.findFirst.mockResolvedValue({ id: 'base1' } as any);\n      prismaService.user.findMany.mockResolvedValue([mockInvitedUser as any]);\n      prismaService.$tx.mockRejectedValue(new Error('tx error'));\n      collaboratorService.validateUserAddRole.mockResolvedValue();\n      vi.spyOn(invitationService as any, 'checkSpaceInvitation').mockResolvedValue(true);\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n          origin: {\n            ip: '127.0.0.1',\n            byApi: false,\n            userAgent: 'test',\n            referer: 'test',\n          },\n        },\n        async () => {\n          await expect(\n            invitationService.emailInvitationByBase('base1', {\n              emails: [mockInvitedUser.email],\n              role: Role.Creator,\n            })\n          ).rejects.toThrow('tx error');\n        }\n      );\n    });\n  });\n\n  describe('acceptInvitationLink', () => {\n    const acceptInvitationLinkRo = {\n      invitationCode: mockInvitationCode,\n      invitationId: mockInvitationId,\n    };\n\n    it('should throw BadRequestException for invalid code', async () => {\n      const errorAcceptInvitationLinkRo = {\n        invitationCode: generateInvitationCode('xxxxx'),\n        invitationId: mockInvitationId,\n      };\n\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => {\n          const error = await getError(() =>\n            invitationService.acceptInvitationLink(errorAcceptInvitationLinkRo)\n          );\n          expect(error).toBeDefined();\n          expect(error?.status).toBe(400);\n          expect(error?.message).toBe('Invalid invitation code');\n        }\n      );\n    });\n    it('should throw NotFoundException for not found link invitation', async () => {\n      prismaService.invitation.findFirst.mockResolvedValue(null);\n\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => {\n          const error = await getError(() =>\n            invitationService.acceptInvitationLink(acceptInvitationLinkRo)\n          );\n          expect(error).toBeDefined();\n          expect(error?.status).toBe(404);\n          expect(error?.message).toBe('Invitation link not found');\n        }\n      );\n    });\n    it('should throw ForbiddenException for expired link', async () => {\n      prismaService.invitation.findFirst.mockResolvedValue({\n        id: mockInvitationId,\n        invitationCode: mockInvitationCode,\n        type: 'link',\n        expiredTime: new Date('2022-01-01'),\n        spaceId: mockSpace.id,\n        baseId: null,\n        deletedTime: null,\n        createdTime: new Date('2022-01-02'),\n        role: Role.Owner,\n        createdBy: mockUser.id,\n        lastModifiedBy: null,\n        lastModifiedTime: null,\n      });\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => {\n          const error = await getError(() =>\n            invitationService.acceptInvitationLink(acceptInvitationLinkRo)\n          );\n          expect(error).toBeDefined();\n          expect(error?.status).toBe(400);\n          expect(error?.message).toBe('Invitation link has expired');\n        }\n      );\n    });\n    it('should return success for email', async () => {\n      prismaService.invitation.findFirst.mockResolvedValue({\n        id: mockInvitationId,\n        invitationCode: mockInvitationCode,\n        type: 'email',\n        expiredTime: null,\n        spaceId: mockSpace.id,\n        baseId: null,\n        deletedTime: null,\n        createdTime: new Date(),\n        role: Role.Owner,\n        createdBy: mockUser.id,\n        lastModifiedBy: null,\n        lastModifiedTime: null,\n      });\n      prismaService.collaborator.count.mockImplementation(() => Promise.resolve(0) as any);\n      await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => await invitationService.acceptInvitationLink(acceptInvitationLinkRo)\n      );\n      expect(prismaService.collaborator.count).toHaveBeenCalledTimes(0);\n    });\n    it('exist collaborator', async () => {\n      prismaService.invitation.findFirst.mockResolvedValue({ spaceId: mockSpace.id } as any);\n      prismaService.collaborator.count.mockResolvedValue(1);\n      const result = await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => await invitationService.acceptInvitationLink(acceptInvitationLinkRo)\n      );\n      expect(result.spaceId).toEqual(mockSpace.id);\n    });\n    it('should create collaborator and invitation record', async () => {\n      const mockInvitation = {\n        id: mockInvitationId,\n        invitationCode: mockInvitationCode,\n        type: 'link',\n        expiredTime: null,\n        spaceId: mockSpace.id,\n        baseId: null,\n        deletedTime: null,\n        createdTime: new Date('2022-01-02'),\n        role: Role.Owner,\n        createdBy: 'createdBy',\n        lastModifiedBy: null,\n        lastModifiedTime: null,\n      };\n      prismaService.invitation.findFirst.mockResolvedValue(mockInvitation);\n      prismaService.collaborator.count.mockResolvedValue(0);\n\n      const result = await clsService.runWith(\n        {\n          ...defaultCls,\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => await invitationService.acceptInvitationLink(acceptInvitationLinkRo)\n      );\n\n      expect(prismaService.invitationRecord.create).toHaveBeenCalledWith({\n        data: {\n          invitationId: mockInvitation.id,\n          inviter: mockInvitation.createdBy,\n          accepter: mockUser.id,\n          type: mockInvitation.type,\n          spaceId: mockInvitation.spaceId,\n          baseId: mockInvitation.baseId,\n        },\n      });\n      expect(collaboratorService.createSpaceCollaborator).toHaveBeenCalledWith({\n        collaborators: [\n          {\n            principalId: mockUser.id,\n            principalType: PrincipalType.User,\n          },\n        ],\n        spaceId: mockSpace.id,\n        role: Role.Owner,\n        createdBy: 'createdBy',\n      });\n      expect(result.spaceId).toEqual(mockInvitation.spaceId);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/invitation/invitation.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { IBaseRole, IRole } from '@teable/core';\nimport { generateInvitationId, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  CollaboratorType,\n  MailTransporterType,\n  MailType,\n  PrincipalType,\n  type AcceptInvitationLinkRo,\n  type EmailInvitationVo,\n  type EmailSpaceInvitationRo,\n  type ItemSpaceInvitationLinkVo,\n} from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport { pick } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { IMailConfig } from '../../configs/mail.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { generateInvitationCode } from '../../utils/code-generate';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { MailSenderService } from '../mail-sender/mail-sender.service';\nimport { SettingOpenApiService } from '../setting/open-api/setting-open-api.service';\nimport { UserService } from '../user/user.service';\n\n@Injectable()\nexport class InvitationService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly settingOpenApiService: SettingOpenApiService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly configService: ConfigService,\n    private readonly mailSenderService: MailSenderService,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly userService: UserService\n  ) {}\n\n  private generateInviteUrl(invitationId: string, invitationCode: string) {\n    const mailConfig = this.configService.get<IMailConfig>('mail');\n    return `${mailConfig?.origin}/invite?invitationId=${invitationId}&invitationCode=${invitationCode}`;\n  }\n\n  private async createNotExistedUser(emails: string[]) {\n    const users: { email: string; name: string; id: string }[] = [];\n    for (const email of emails) {\n      const user = await this.userService.createUser({ email });\n      users.push(pick(user, 'id', 'name', 'email'));\n    }\n    return users;\n  }\n\n  private async checkSpaceInvitation() {\n    const user = this.cls.get('user');\n\n    if (!user?.isAdmin) {\n      const setting = await this.settingOpenApiService.getSetting();\n\n      if (setting?.disallowSpaceInvitation) {\n        throw new CustomHttpException(\n          'The current instance disallow space invitation by the administrator',\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.invitation.disallowSpaceInvitation',\n            },\n          }\n        );\n      }\n    }\n  }\n\n  private async emailInvitation({\n    emails,\n    role,\n    resourceId,\n    resourceName,\n    resourceType,\n  }: {\n    emails: string[];\n    role: IRole;\n    resourceId: string;\n    resourceName: string;\n    resourceType: CollaboratorType;\n  }) {\n    const user = { ...this.cls.get('user') };\n\n    await this.checkInvitationLimits();\n\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    await this.collaboratorService.validateUserAddRole({\n      departmentIds,\n      userId: user.id,\n      addRole: role,\n      resourceId,\n      resourceType,\n    });\n    const invitationEmails = emails.map((email) => email.toLowerCase());\n    const sendUsers = await this.prismaService.user.findMany({\n      select: { id: true, name: true, email: true },\n      where: { email: { in: invitationEmails } },\n    });\n\n    const noExistEmails = invitationEmails.filter(\n      (email) => !sendUsers.find((u) => u.email.toLowerCase() === email.toLowerCase())\n    );\n\n    return this.prismaService.$tx(async () => {\n      // create user if not exist\n      const newUsers = await this.createNotExistedUser(noExistEmails);\n      sendUsers.push(...newUsers);\n\n      const result: EmailInvitationVo = {};\n      for (const sendUser of sendUsers) {\n        // create collaborator link\n        if (resourceType === CollaboratorType.Space) {\n          await this.collaboratorService.createSpaceCollaborator({\n            collaborators: [\n              {\n                principalId: sendUser.id,\n                principalType: PrincipalType.User,\n              },\n            ],\n            spaceId: resourceId,\n            role: role as IRole,\n          });\n        } else {\n          await this.collaboratorService.createBaseCollaborator({\n            collaborators: [\n              {\n                principalId: sendUser.id,\n                principalType: PrincipalType.User,\n              },\n            ],\n            baseId: resourceId,\n            role: role as IBaseRole,\n          });\n        }\n        // generate invitation record\n        const { id, invitationCode } = await this.generateInvitation({\n          type: 'email',\n          role,\n          resourceId,\n          resourceType,\n        });\n\n        // save invitation record for audit\n        await this.prismaService.txClient().invitationRecord.create({\n          data: {\n            inviter: user.id,\n            accepter: sendUser.id,\n            type: 'email',\n            spaceId: resourceType === CollaboratorType.Space ? resourceId : null,\n            baseId: resourceType === CollaboratorType.Base ? resourceId : null,\n            invitationId: id,\n          },\n        });\n\n        // get email info\n        const inviteEmailOptions = await this.mailSenderService.inviteEmailOptions({\n          name: user.name,\n          email: user.email,\n          resourceName,\n          resourceType,\n          inviteUrl: this.generateInviteUrl(id, invitationCode),\n        });\n        this.mailSenderService.sendMail(\n          {\n            to: sendUser.email,\n            ...inviteEmailOptions,\n          },\n          {\n            type: MailType.Invite,\n            transporterName: MailTransporterType.Notify,\n          }\n        );\n        result[sendUser.email] = { invitationId: id };\n      }\n      return result;\n    });\n  }\n\n  async emailInvitationBySpace(spaceId: string, data: EmailSpaceInvitationRo) {\n    await this.checkSpaceInvitation();\n\n    const space = await this.prismaService.space.findFirst({\n      select: { name: true },\n      where: { id: spaceId, deletedTime: null },\n    });\n    if (!space) {\n      throw new CustomHttpException('Space not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.space.notFound',\n        },\n      });\n    }\n\n    return this.emailInvitation({\n      emails: data.emails,\n      role: data.role,\n      resourceId: spaceId,\n      resourceName: space.name,\n      resourceType: CollaboratorType.Space,\n    });\n  }\n\n  async emailInvitationByBase(baseId: string, data: EmailSpaceInvitationRo) {\n    await this.checkSpaceInvitation();\n\n    const base = await this.prismaService.base.findFirst({\n      select: { spaceId: true, name: true },\n      where: { id: baseId, deletedTime: null },\n    });\n    if (!base) {\n      throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.base.notFound',\n        },\n      });\n    }\n\n    return this.emailInvitation({\n      emails: data.emails,\n      role: data.role,\n      resourceId: baseId,\n      resourceName: base.name,\n      resourceType: CollaboratorType.Base,\n    });\n  }\n\n  async generateInvitationLink({\n    role,\n    resourceId,\n    resourceType,\n  }: {\n    role: IRole;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }): Promise<ItemSpaceInvitationLinkVo> {\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    await this.collaboratorService.validateUserAddRole({\n      departmentIds,\n      userId: this.cls.get('user.id'),\n      addRole: role,\n      resourceId,\n      resourceType,\n    });\n    const { id, createdBy, createdTime, invitationCode } = await this.generateInvitation({\n      role,\n      resourceId,\n      resourceType,\n      type: 'link',\n    });\n    return {\n      invitationId: id,\n      role: role as IRole,\n      createdBy,\n      createdTime: createdTime.toISOString(),\n      inviteUrl: this.generateInviteUrl(id, invitationCode),\n      invitationCode,\n    };\n  }\n\n  private async generateInvitation({\n    type,\n    role,\n    resourceId,\n    resourceType,\n  }: {\n    type: 'link' | 'email';\n    role: IRole;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    const userId = this.cls.get('user.id');\n    const invitationId = generateInvitationId();\n    return this.prismaService.txClient().invitation.create({\n      data: {\n        id: invitationId,\n        invitationCode: generateInvitationCode(invitationId),\n        spaceId: resourceType === CollaboratorType.Space ? resourceId : null,\n        baseId: resourceType === CollaboratorType.Base ? resourceId : null,\n        role,\n        type,\n        expiredTime:\n          type === 'email' ? dayjs(new Date()).add(1, 'month').toDate().toISOString() : null,\n        createdBy: userId,\n      },\n    });\n  }\n\n  async deleteInvitationLink({\n    invitationId,\n    resourceId,\n    resourceType,\n  }: {\n    invitationId: string;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    await this.prismaService.invitation.update({\n      where: {\n        id: invitationId,\n        type: 'link',\n        [resourceType === CollaboratorType.Space ? 'spaceId' : 'baseId']: resourceId,\n      },\n      data: { deletedTime: new Date().toISOString() },\n    });\n  }\n\n  async updateInvitationLink({\n    invitationId,\n    role,\n    resourceId,\n    resourceType,\n  }: {\n    invitationId: string;\n    role: IRole;\n    resourceId: string;\n    resourceType: CollaboratorType;\n  }) {\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    await this.collaboratorService.validateUserAddRole({\n      departmentIds,\n      userId: this.cls.get('user.id'),\n      addRole: role,\n      resourceId,\n      resourceType,\n    });\n    const { id } = await this.prismaService.invitation.update({\n      where: {\n        id: invitationId,\n        type: 'link',\n        [resourceType === CollaboratorType.Space ? 'spaceId' : 'baseId']: resourceId,\n      },\n      data: {\n        role,\n      },\n    });\n    return {\n      invitationId: id,\n      role,\n    };\n  }\n\n  async getInvitationLink(resourceId: string, resourceType: CollaboratorType) {\n    const data = await this.prismaService.invitation.findMany({\n      select: { id: true, role: true, createdBy: true, createdTime: true, invitationCode: true },\n      where: {\n        [resourceType === CollaboratorType.Space ? 'spaceId' : 'baseId']: resourceId,\n        type: 'link',\n        deletedTime: null,\n      },\n    });\n    return data.map(({ id, role, createdBy, createdTime, invitationCode }) => ({\n      invitationId: id,\n      role: role as IRole,\n      createdBy,\n      createdTime: createdTime.toISOString(),\n      invitationCode,\n      inviteUrl: this.generateInviteUrl(id, invitationCode),\n    }));\n  }\n\n  async acceptInvitationLink(acceptInvitationLinkRo: AcceptInvitationLinkRo) {\n    const currentUserId = this.cls.get('user.id');\n    const { invitationCode, invitationId } = acceptInvitationLinkRo;\n    if (generateInvitationCode(invitationId) !== invitationCode) {\n      throw new CustomHttpException('Invalid invitation code', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.invitation.invalidCode',\n        },\n      });\n    }\n    const linkInvitation = await this.prismaService.invitation.findFirst({\n      where: {\n        id: invitationId,\n        deletedTime: null,\n      },\n    });\n    if (!linkInvitation) {\n      throw new CustomHttpException('Invitation link not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.invitation.linkNotFound',\n        },\n      });\n    }\n\n    const { expiredTime, baseId, spaceId, role, createdBy, type } = linkInvitation;\n\n    if (expiredTime && expiredTime < new Date()) {\n      throw new CustomHttpException('Invitation link has expired', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.invitation.linkExpired',\n        },\n      });\n    }\n\n    if (type === 'email') {\n      return { baseId, spaceId };\n    }\n\n    const resourceId = spaceId || baseId;\n    if (!resourceId) {\n      throw new CustomHttpException(\n        'Invalid invitation link: resourceId not found',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: !spaceId ? 'httpErrors.space.notFound' : 'httpErrors.base.notFound',\n          },\n        }\n      );\n    }\n\n    const resourceType = spaceId ? CollaboratorType.Space : CollaboratorType.Base;\n    let baseSpaceId: string | null = null;\n    if (baseId) {\n      const base = await this.prismaService\n        .txClient()\n        .base.findUniqueOrThrow({\n          where: { id: baseId, deletedTime: null },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.base.notFound',\n            },\n          });\n        });\n      baseSpaceId = base.spaceId;\n    }\n    const exist = await this.prismaService.txClient().collaborator.count({\n      where: {\n        principalId: currentUserId,\n        principalType: PrincipalType.User,\n        resourceId: { in: baseSpaceId ? [baseSpaceId, baseId!] : [spaceId!] },\n      },\n    });\n    if (!exist) {\n      await this.prismaService.$tx(async () => {\n        if (resourceType === CollaboratorType.Space) {\n          await this.collaboratorService.createSpaceCollaborator({\n            collaborators: [\n              {\n                principalId: currentUserId,\n                principalType: PrincipalType.User,\n              },\n            ],\n            spaceId: spaceId!,\n            role: role as IRole,\n            createdBy,\n          });\n        } else {\n          await this.collaboratorService.createBaseCollaborator({\n            collaborators: [\n              {\n                principalId: currentUserId,\n                principalType: PrincipalType.User,\n              },\n            ],\n            baseId: baseId!,\n            role: role as IBaseRole,\n            createdBy,\n          });\n        }\n        // save invitation record for audit\n        await this.prismaService.txClient().invitationRecord.create({\n          data: {\n            invitationId: linkInvitation.id,\n            inviter: createdBy,\n            accepter: currentUserId,\n            type: 'link',\n            spaceId,\n            baseId,\n          },\n        });\n      });\n    }\n    return { baseId, spaceId };\n  }\n\n  private async checkInvitationLimits(): Promise<void> {\n    if (!process.env.MAX_INVITATIONS_PER_HOUR) return;\n\n    const user = this.cls.get('user');\n    const maxInvitationsPerHour = Number(process.env.MAX_INVITATIONS_PER_HOUR);\n    const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);\n    const recentInvitations = await this.prismaService.invitationRecord.count({\n      where: {\n        inviter: user.id,\n        createdTime: { gte: oneHourAgo.toISOString() },\n      },\n    });\n\n    if (Number(recentInvitations) >= maxInvitationsPerHour) {\n      await this.prismaService.user.update({\n        where: { id: user.id },\n        data: {\n          deactivatedTime: new Date().toISOString(),\n        },\n      });\n      throw new CustomHttpException(\n        'You have reached the maximum number of invitations per hour',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.invitation.limitExceeded',\n          },\n        }\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/mail-helpers.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport type { ConfigService } from '@nestjs/config';\nimport type { ISendMailOptions as NestjsSendMailOptions } from '@nestjs-modules/mailer';\nimport type { IMailTransportConfig } from '@teable/openapi';\nimport { createTransport } from 'nodemailer';\n\nexport type ISendMailOptions = NestjsSendMailOptions & { senderName?: string };\n\nexport const helpers = (config: ConfigService) => {\n  const publicOrigin = config.get<string>('PUBLIC_ORIGIN');\n  return {\n    publicOrigin: function () {\n      return publicOrigin;\n    },\n    currentYear: function () {\n      return new Date().getFullYear();\n    },\n  };\n};\n\nexport const verifyTransport = async (config: IMailTransportConfig) => {\n  const transporter = createTransport(config);\n  try {\n    await transporter.verify();\n  } catch (error) {\n    throw new BadRequestException(\n      `Invalid mail transporter: ${error instanceof Error ? error.message : 'Unknown error'}`\n    );\n  }\n  return true;\n};\n\nexport const buildEmailFrom = (sender: string, senderName?: string) => {\n  if (!senderName) {\n    return sender;\n  }\n  return `${senderName} <${sender}>`;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/mail-sender.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport path from 'path';\nimport type { DynamicModule } from '@nestjs/common';\nimport { ConfigurableModuleBuilder, Logger, Module } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { MailerModule } from '@nestjs-modules/mailer';\nimport { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';\nimport { createTransport } from 'nodemailer';\nimport type { IMailConfig } from '../../configs/mail.config';\nimport { SettingOpenApiModule } from '../setting/open-api/setting-open-api.module';\nimport { buildEmailFrom, helpers } from './mail-helpers';\nimport { MailSenderService } from './mail-sender.service';\n\nexport interface MailSenderModuleOptions {\n  global?: boolean;\n}\n\nexport const { ConfigurableModuleClass: MailSenderModuleClass, OPTIONS_TYPE } =\n  new ConfigurableModuleBuilder<MailSenderModuleOptions>().build();\n\n/**\n * Create a no-op transport for when mail is not configured.\n * This transport logs emails instead of sending them and has a proper verify() method\n * that returns a Promise (required by @nestjs-modules/mailer).\n */\nfunction createNoOpTransport() {\n  const transport = createTransport({\n    jsonTransport: true,\n  });\n\n  // Override verify to return a Promise (the original returns false for jsonTransport)\n  // This is needed because @nestjs-modules/mailer calls verify().then() without checking\n  const originalVerify = transport.verify.bind(transport);\n  transport.verify = function (callback?: (err: Error | null, success: boolean) => void) {\n    if (callback) {\n      return originalVerify(callback);\n    }\n    return Promise.resolve(true);\n  } as typeof transport.verify;\n\n  return transport;\n}\n\n@Module({})\nexport class MailSenderModule extends MailSenderModuleClass {\n  static register(): DynamicModule {\n    const module = MailerModule.forRootAsync({\n      inject: [ConfigService],\n      useFactory: (config: ConfigService) => {\n        const mailConfig = config.getOrThrow<IMailConfig>('mail');\n        const templatePagesDir = path.join(__dirname, '/templates/pages');\n        const templatePartialsDir = path.join(__dirname, '/templates/partials');\n\n        Logger.log(`[Mail Template Pages Dir]: ${templatePagesDir}`);\n        Logger.log(`[Mail Template Partials Dir]: ${templatePartialsDir}`);\n\n        // If mail is not configured, use a no-op transport that logs instead of sending\n        // and has a proper verify() method that returns a Promise\n        const transport = mailConfig.isConfigured\n          ? {\n              host: mailConfig.host,\n              port: mailConfig.port,\n              secure: mailConfig.secure,\n              auth: {\n                user: mailConfig.auth.user,\n                pass: mailConfig.auth.pass,\n              },\n            }\n          : createNoOpTransport();\n\n        if (!mailConfig.isConfigured) {\n          Logger.warn(\n            '[MailSenderModule] Mail is not configured. Emails will be logged instead of sent.',\n            'MailSenderModule'\n          );\n        }\n\n        return {\n          transport,\n          defaults: {\n            from: buildEmailFrom(mailConfig.sender, mailConfig.senderName),\n          },\n          template: {\n            dir: templatePagesDir,\n            adapter: new HandlebarsAdapter(helpers(config)),\n            options: {\n              strict: true,\n            },\n          },\n          options: {\n            partials: {\n              dir: templatePartialsDir,\n              options: {\n                strict: true,\n              },\n            },\n          },\n        };\n      },\n    });\n\n    return {\n      imports: [SettingOpenApiModule, module],\n      module: MailSenderModule,\n      providers: [MailSenderService],\n      exports: [MailSenderService],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { MailerService } from '@nestjs-modules/mailer';\nimport { HttpErrorCode } from '@teable/core';\nimport type { IMailTransportConfig } from '@teable/openapi';\nimport {\n  MailType,\n  CollaboratorType,\n  SettingKey,\n  MailTransporterType,\n  EmailVerifyCodeType,\n} from '@teable/openapi';\nimport { isString } from 'lodash';\nimport { I18nService } from 'nestjs-i18n';\nimport { createTransport } from 'nodemailer';\nimport { CacheService } from '../../cache/cache.service';\nimport { BaseConfig, IBaseConfig } from '../../configs/base.config';\nimport { IMailConfig, MailConfig } from '../../configs/mail.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { I18nTranslations } from '../../types/i18n.generated';\nimport { SettingOpenApiService } from '../setting/open-api/setting-open-api.service';\nimport { buildEmailFrom, type ISendMailOptions } from './mail-helpers';\n\n@Injectable()\nexport class MailSenderService {\n  private logger = new Logger(MailSenderService.name);\n  private readonly defaultTransportConfig: IMailTransportConfig;\n  private readonly isMailConfigured: boolean;\n\n  constructor(\n    private readonly mailService: MailerService,\n    @MailConfig() private readonly mailConfig: IMailConfig,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    private readonly settingOpenApiService: SettingOpenApiService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cacheService: CacheService,\n    private readonly i18n: I18nService<I18nTranslations>\n  ) {\n    const { host, port, secure, auth, sender, senderName, isConfigured } = this.mailConfig;\n    this.isMailConfigured = isConfigured;\n    this.defaultTransportConfig = {\n      senderName,\n      sender,\n      host,\n      port,\n      secure,\n      auth: {\n        user: auth.user || '',\n        pass: auth.pass || '',\n      },\n    };\n  }\n\n  /**\n   * Log email content when mail is not configured.\n   * This helps developers debug email sending without actually sending emails.\n   */\n  private logEmailContent(mailOptions: ISendMailOptions, from?: string): void {\n    const emailInfo = {\n      from: from ?? mailOptions.from,\n      to: mailOptions.to,\n      subject: mailOptions.subject,\n      template: mailOptions.template,\n      context: mailOptions.context,\n      body: mailOptions.html ?? mailOptions.text,\n    };\n\n    this.logger.log(\n      `[Mail Not Configured] Would send email:\\n${JSON.stringify(emailInfo, null, 2)}`\n    );\n  }\n\n  async checkSendMailRateLimit<T>(\n    options: { email: string; rateLimitKey: string; rateLimit: number },\n    fn: () => Promise<T>\n  ) {\n    const { email, rateLimitKey: _rateLimitKey, rateLimit: _rateLimit } = options;\n    // If rate limit is 0, skip rate limiting entirely\n    if (_rateLimit <= 0) {\n      return await fn();\n    }\n    const rateLimit = _rateLimit - 2; // 2 seconds for network latency\n    const rateLimitKey = `send-mail-rate-limit:${_rateLimitKey}:${email}` as const;\n    const existingRateLimit = await this.cacheService.get(rateLimitKey);\n    if (existingRateLimit) {\n      throw new CustomHttpException(\n        `Reached the rate limit of sending mail, please try again after ${rateLimit} seconds`,\n        HttpErrorCode.TOO_MANY_REQUESTS,\n        {\n          seconds: _rateLimit,\n        }\n      );\n    }\n    const result = await fn();\n    await this.cacheService.setDetail(rateLimitKey, true, rateLimit);\n    return result;\n  }\n\n  // https://nodemailer.com/smtp#connection-options\n  async createTransporter(config: IMailTransportConfig) {\n    const { connectionTimeout, greetingTimeout, dnsTimeout } = this.mailConfig;\n    const transporter = createTransport({\n      ...config,\n      connectionTimeout,\n      greetingTimeout,\n      dnsTimeout,\n    });\n    const templateAdapter = this.mailService['templateAdapter'];\n    this.mailService['initTemplateAdapter'](templateAdapter, transporter);\n    return transporter;\n  }\n\n  /**\n   * Check if a transport config is valid (has required SMTP settings)\n   */\n  private isTransportConfigValid(config: IMailTransportConfig): boolean {\n    return Boolean(config.host && config.auth?.user && config.auth?.pass);\n  }\n\n  async sendMailByConfig(mailOptions: ISendMailOptions, config: IMailTransportConfig) {\n    // Check if the provided config is valid (could be from env vars or backend settings)\n    if (!this.isTransportConfigValid(config)) {\n      const from =\n        mailOptions.from ??\n        buildEmailFrom(config.sender, mailOptions.senderName ?? config.senderName);\n      this.logEmailContent(mailOptions, from as string);\n      return { messageId: 'mock-message-id-not-configured' };\n    }\n\n    const instance = await this.createTransporter(config);\n    const from =\n      mailOptions.from ??\n      buildEmailFrom(config.sender, mailOptions.senderName ?? config.senderName);\n    return instance.sendMail({ ...mailOptions, from });\n  }\n\n  async getTransportConfigByName(name?: MailTransporterType) {\n    const setting = await this.settingOpenApiService.getSetting([\n      SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG,\n      SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG,\n    ]);\n    const defaultConfig = this.defaultTransportConfig;\n    const notifyConfig = setting[SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG];\n    const automationConfig = setting[SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG];\n\n    const notifyTransport = notifyConfig || defaultConfig;\n    const automationTransport = automationConfig || notifyTransport || defaultConfig;\n\n    let config = defaultConfig;\n    if (name === MailTransporterType.Automation) {\n      config = automationTransport;\n    } else if (name === MailTransporterType.Notify) {\n      config = notifyTransport;\n    }\n\n    return config;\n  }\n\n  async notifyMergeOptions(\n    list: ISendMailOptions & { mailType: MailType }[],\n    brandName: string,\n    brandLogo: string\n  ) {\n    return {\n      subject: this.i18n.t('common.email.templates.notify.subject', {\n        args: { brandName },\n      }),\n      template: 'normal',\n      context: {\n        partialBody: 'notify-merge-body',\n        brandName,\n        brandLogo,\n        list: list.map((item) => ({\n          ...item,\n          mailType: item.mailType,\n        })),\n      },\n    };\n  }\n\n  async sendMailByTransporterName(\n    mailOptions: ISendMailOptions,\n    transporterName?: MailTransporterType,\n    type?: MailType\n  ) {\n    const mergeNotifyType = [MailType.System, MailType.Notify, MailType.Common];\n    const checkNotify =\n      type && transporterName === MailTransporterType.Notify && mergeNotifyType.includes(type);\n    const checkTo = mailOptions.to && isString(mailOptions.to);\n    if (checkNotify && checkTo) {\n      this.eventEmitterService.emit(Events.NOTIFY_MAIL_MERGE, {\n        payload: { ...mailOptions, mailType: type },\n      });\n      return true;\n    }\n    const config = await this.getTransportConfigByName(transporterName);\n    return await this.sendMailByConfig(mailOptions, config);\n  }\n\n  async sendMail(\n    mailOptions: ISendMailOptions,\n    extra?: {\n      shouldThrow?: boolean;\n      type?: MailType;\n      transportConfig?: IMailTransportConfig;\n      transporterName?: MailTransporterType;\n    }\n  ): Promise<boolean> {\n    const { type, transportConfig, transporterName } = extra || {};\n\n    let sender: Promise<boolean>;\n    if (transportConfig) {\n      // Explicit transport config provided - sendMailByConfig will validate it\n      sender = this.sendMailByConfig(mailOptions, transportConfig).then(() => true);\n    } else if (transporterName) {\n      // Named transporter - may have config from backend settings, sendMailByTransporterName will validate\n      sender = this.sendMailByTransporterName(mailOptions, transporterName, type).then(() => true);\n    } else {\n      // No custom config - use default mailer service\n      // If env vars not configured, log the email instead\n      if (!this.isMailConfigured) {\n        const from =\n          mailOptions.from ??\n          buildEmailFrom(\n            this.mailConfig.sender,\n            mailOptions.senderName ?? this.mailConfig.senderName\n          );\n        this.logEmailContent(mailOptions, from as string);\n        return true;\n      }\n\n      const from =\n        mailOptions.from ??\n        buildEmailFrom(\n          this.mailConfig.sender,\n          mailOptions.senderName ?? this.mailConfig.senderName\n        );\n\n      sender = this.mailService.sendMail({ ...mailOptions, from }).then(() => true);\n    }\n\n    if (extra?.shouldThrow) {\n      return sender;\n    }\n\n    return sender.catch((reason) => {\n      if (reason) {\n        console.error(reason);\n        this.logger.error(`Mail sending failed: ${reason.message}`, reason.stack);\n      }\n      return false;\n    });\n  }\n\n  async inviteEmailOptions(info: {\n    name: string;\n    email: string;\n    resourceName: string;\n    resourceType: CollaboratorType;\n    inviteUrl: string;\n  }) {\n    const { name, email, inviteUrl, resourceName, resourceType } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    const resourceAlias = resourceType === CollaboratorType.Space ? 'Space' : 'Base';\n\n    return {\n      subject: this.i18n.t('common.email.templates.invite.subject', {\n        args: { name, email, resourceAlias, resourceName, brandName },\n      }),\n      template: 'normal',\n      context: {\n        name,\n        email,\n        resourceName,\n        resourceAlias,\n        inviteUrl,\n        partialBody: 'invite',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.invite.title'),\n        message: this.i18n.t('common.email.templates.invite.message', {\n          args: { name, email, resourceAlias, resourceName },\n        }),\n        buttonText: this.i18n.t('common.email.templates.invite.buttonText'),\n      },\n    };\n  }\n\n  async collaboratorCellTagEmailOptions(info: {\n    notifyId: string;\n    fromUserName: string;\n    refRecord: {\n      baseId: string;\n      tableId: string;\n      tableName: string;\n      fieldName: string;\n      recordIds: string[];\n      recordTitles: { id: string; title: string }[];\n    };\n  }) {\n    const {\n      notifyId,\n      fromUserName,\n      refRecord: { baseId, tableId, fieldName, tableName, recordIds, recordTitles },\n    } = info;\n    let subject, partialBody;\n    const refLength = recordIds.length;\n\n    const viewRecordUrlPrefix = `${this.mailConfig.origin}/base/${baseId}/table/${tableId}`;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    if (refLength <= 1) {\n      subject = this.i18n.t('common.email.templates.collaboratorCellTag.subject', {\n        args: { fromUserName, fieldName, tableName },\n      });\n      partialBody = 'collaborator-cell-tag';\n    } else {\n      subject = this.i18n.t('common.email.templates.collaboratorMultiRowTag.subject', {\n        args: { fromUserName, refLength, tableName },\n      });\n      partialBody = 'collaborator-multi-row-tag';\n    }\n\n    return {\n      notifyMessage: subject,\n      subject: `${subject} - ${brandName}`,\n      template: 'normal',\n      context: {\n        notifyId,\n        fromUserName,\n        refLength,\n        tableName,\n        fieldName,\n        recordIds,\n        recordTitles: recordTitles.map((r) => {\n          return {\n            ...r,\n            title: r.title || this.i18n.t('sdk.common.unnamedRecord'),\n          };\n        }),\n        viewRecordUrlPrefix,\n        partialBody,\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.collaboratorCellTag.title', {\n          args: { fromUserName, fieldName, tableName },\n        }),\n        buttonText: this.i18n.t('common.email.templates.collaboratorCellTag.buttonText'),\n      },\n    };\n  }\n\n  async htmlEmailOptions(info: {\n    to: string;\n    title: string;\n    message: string;\n    buttonUrl: string;\n    buttonText: string;\n  }) {\n    const { title, message } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      notifyMessage: message,\n      subject: `${title} - ${brandName}`,\n      template: 'normal',\n      context: {\n        partialBody: 'html-body',\n        brandName,\n        brandLogo,\n        ...info,\n      },\n    };\n  }\n\n  async commonEmailOptions(info: {\n    to: string;\n    title: string;\n    message: string;\n    buttonUrl: string;\n    buttonText: string;\n  }) {\n    const { title, message } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      notifyMessage: message,\n      subject: `${title} - ${brandName}`,\n      template: 'normal',\n      context: {\n        partialBody: 'common-body',\n        brandName,\n        brandLogo,\n        ...info,\n      },\n    };\n  }\n\n  async sendTestEmailOptions(info: { message?: string }) {\n    const { message } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      subject: this.i18n.t('common.email.templates.test.subject', {\n        args: { brandName },\n      }),\n      template: 'normal',\n      context: {\n        partialBody: 'html-body',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.test.title'),\n        message: message || this.i18n.t('common.email.templates.test.message'),\n      },\n    };\n  }\n\n  async waitlistInviteEmailOptions(info: {\n    code: string;\n    times: number;\n    name: string;\n    email: string;\n    waitlistInviteUrl: string;\n  }) {\n    const { code, times, name, email, waitlistInviteUrl } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      subject: this.i18n.t('common.email.templates.waitlistInvite.subject', {\n        args: { name, email, brandName },\n      }),\n      template: 'normal',\n      context: {\n        ...info,\n        partialBody: 'common-body',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.waitlistInvite.title'),\n        message: this.i18n.t('common.email.templates.waitlistInvite.message', {\n          args: { brandName, code, times },\n        }),\n        buttonText: this.i18n.t('common.email.templates.waitlistInvite.buttonText'),\n        buttonUrl: waitlistInviteUrl,\n      },\n    };\n  }\n\n  async resetPasswordEmailOptions(info: { name: string; email: string; resetPasswordUrl: string }) {\n    const { resetPasswordUrl } = info;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n\n    return {\n      subject: this.i18n.t('common.email.templates.resetPassword.subject', {\n        args: {\n          brandName,\n        },\n      }),\n      template: 'normal',\n      context: {\n        partialBody: 'reset-password',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.resetPassword.title'),\n        message: this.i18n.t('common.email.templates.resetPassword.message'),\n        buttonText: this.i18n.t('common.email.templates.resetPassword.buttonText'),\n        buttonUrl: resetPasswordUrl,\n      },\n    };\n  }\n\n  async sendEmailVerifyCodeEmailOptions(\n    payload:\n      | {\n          code: string;\n          expiresIn: string;\n          type: EmailVerifyCodeType.Signup | EmailVerifyCodeType.ChangeEmail;\n        }\n      | {\n          domain: string;\n          name: string;\n          code: string;\n          expiresIn: string;\n          type: EmailVerifyCodeType.DomainVerification;\n        }\n  ) {\n    const { type, code, expiresIn } = payload;\n    if (this.baseConfig.enableEmailCodeConsole) {\n      this.logger.log(`${type} Verification code: ${code} expiresIn ${expiresIn}`);\n    }\n    switch (type) {\n      case EmailVerifyCodeType.Signup:\n        return this.sendSignupVerificationEmailOptions(payload);\n      case EmailVerifyCodeType.ChangeEmail:\n        return this.sendChangeEmailCodeEmailOptions(payload);\n      case EmailVerifyCodeType.DomainVerification:\n        return this.sendDomainVerificationEmailOptions(payload);\n    }\n  }\n\n  private async sendSignupVerificationEmailOptions(payload: { code: string; expiresIn: string }) {\n    const { code, expiresIn } = payload;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      subject: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.subject', {\n        args: {\n          brandName,\n        },\n      }),\n      template: 'normal',\n      context: {\n        partialBody: 'email-verify-code',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.title'),\n        message: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.message', {\n          args: {\n            code,\n            expiresIn: parseInt(expiresIn),\n          },\n        }),\n      },\n    };\n  }\n\n  private async sendChangeEmailCodeEmailOptions(payload: { code: string; expiresIn: string }) {\n    const { code, expiresIn } = payload;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      subject: this.i18n.t(\n        'common.email.templates.emailVerifyCode.changeEmailVerification.subject',\n        {\n          args: { brandName },\n        }\n      ),\n      template: 'normal',\n      context: {\n        partialBody: 'email-verify-code',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.emailVerifyCode.changeEmailVerification.title'),\n        message: this.i18n.t(\n          'common.email.templates.emailVerifyCode.changeEmailVerification.message',\n          {\n            args: {\n              code,\n              expiresIn: parseInt(expiresIn),\n            },\n          }\n        ),\n      },\n    };\n  }\n\n  private async sendDomainVerificationEmailOptions(payload: {\n    domain: string;\n    name: string;\n    code: string;\n    expiresIn: string;\n  }) {\n    const { domain, name, code, expiresIn } = payload;\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    return {\n      subject: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.subject', {\n        args: {\n          brandName,\n        },\n      }),\n      template: 'normal',\n      context: {\n        partialBody: 'email-verify-code',\n        brandName,\n        brandLogo,\n        title: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.title', {\n          args: { domain, name },\n        }),\n        message: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.message', {\n          args: {\n            code,\n            expiresIn: parseInt(expiresIn),\n          },\n        }),\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.controller.ts",
    "content": "import { Body, Controller, Post } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { ITestMailTransportConfigRo, testMailTransportConfigRoSchema } from '@teable/openapi';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { MailSenderOpenApiService } from './mail-sender-open-api.service';\n\n@Controller('api/mail-sender')\nexport class MailSenderOpenApiController {\n  constructor(private readonly mailSenderOpenApiService: MailSenderOpenApiService) {}\n\n  @Post('/test-transport-config')\n  async testTransportConfig(\n    @Body(new ZodValidationPipe(testMailTransportConfigRoSchema))\n    testMailTransportConfigRo: ITestMailTransportConfigRo\n  ): Promise<void> {\n    try {\n      await this.mailSenderOpenApiService.testTransportConfig(testMailTransportConfigRo);\n    } catch (error) {\n      throw new CustomHttpException(\n        error instanceof Error ? error.message : 'Mail config error',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.email.testEmailError',\n            context: {\n              message: error instanceof Error ? error.message : 'Mail config error',\n            },\n          },\n        }\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { MailSenderModule } from '../mail-sender.module';\nimport { MailSenderOpenApiController } from './mail-sender-open-api.controller';\nimport { MailSenderOpenApiService } from './mail-sender-open-api.service';\n\n@Module({\n  imports: [MailSenderModule.register()],\n  providers: [MailSenderOpenApiService],\n  exports: [MailSenderOpenApiService],\n  controllers: [MailSenderOpenApiController],\n})\nexport class MailSenderOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender-open-api.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { ITestMailTransportConfigRo } from '@teable/openapi';\nimport { createTransport } from 'nodemailer';\nimport { IMailConfig, MailConfig } from '../../../configs/mail.config';\nimport { MailSenderService } from '../mail-sender.service';\n\n@Injectable()\nexport class MailSenderOpenApiService {\n  constructor(\n    private readonly mailSenderService: MailSenderService,\n    @MailConfig() private readonly mailConfig: IMailConfig\n  ) {}\n\n  async testTransportConfig(testMailTransportConfigRo: ITestMailTransportConfigRo): Promise<void> {\n    const { transportConfig, to, message } = testMailTransportConfigRo;\n    const transport = createTransport(transportConfig);\n    await transport.verify();\n\n    const option = await this.mailSenderService.sendTestEmailOptions({ message });\n    await this.mailSenderService.sendMailByConfig({ to, ...option }, transportConfig);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { EventJobModule } from '../../../event-emitter/event-job/event-job.module';\nimport { SettingOpenApiModule } from '../../setting/open-api/setting-open-api.module';\nimport { MailSenderModule } from '../mail-sender.module';\nimport { MAIL_SENDER_QUEUE, MailSenderMergeProcessor } from './mail-sender.merge.processor';\n\n@Module({\n  imports: [\n    MailSenderModule.register(),\n    EventJobModule.registerQueue(MAIL_SENDER_QUEUE),\n    SettingOpenApiModule,\n  ],\n  providers: [MailSenderMergeProcessor],\n  exports: [MailSenderMergeProcessor],\n})\nexport class MailSenderMergeModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/open-api/mail-sender.merge.processor.ts",
    "content": "import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';\nimport type { NestWorkerOptions } from '@nestjs/bullmq/dist/interfaces/worker-options.interface';\nimport { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { MailTransporterType, MailType } from '@teable/openapi';\nimport { type Job, type Queue } from 'bullmq';\nimport { isUndefined } from 'lodash';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { ICacheStore } from '../../../cache/types';\nimport { Events } from '../../../event-emitter/events';\nimport { SettingOpenApiService } from '../../setting/open-api/setting-open-api.service';\nimport { type ISendMailOptions } from '../mail-helpers';\nimport { MailSenderService } from '../mail-sender.service';\n\nexport const MAIL_SENDER_QUEUE = 'mailSenderQueue';\n\nenum MailSenderJob {\n  NotifyMailMerge = 'notifyMailMerge',\n  NotifyMailMergeSend = 'notifyMailMergeSend',\n}\n\ntype IMailSenderMergePayload = Omit<ISendMailOptions, 'to'> & { mailType: MailType; to: string };\ntype INotifyMailMergeSendPayload = { to: string };\n\ninterface IMailSenderMergeJob {\n  payload: IMailSenderMergePayload | INotifyMailMergeSendPayload;\n}\n\nconst queueOptions: NestWorkerOptions = {\n  removeOnComplete: {\n    count: 1000,\n  },\n  removeOnFail: {\n    count: 1000,\n  },\n};\n\n@Processor(MAIL_SENDER_QUEUE, queueOptions)\n@Injectable()\nexport class MailSenderMergeProcessor extends WorkerHost {\n  constructor(\n    private readonly mailSenderService: MailSenderService,\n    private readonly cacheService: CacheService<ICacheStore>,\n    private readonly settingOpenApiService: SettingOpenApiService,\n    @InjectQueue(MAIL_SENDER_QUEUE)\n    public readonly queue: Queue<IMailSenderMergeJob>\n  ) {\n    super();\n  }\n\n  async process(job: Job<IMailSenderMergeJob>) {\n    if (!job.data) {\n      return;\n    }\n    const { payload } = job.data;\n\n    if (job.name === MailSenderJob.NotifyMailMergeSend) {\n      await this.sendNotifyMailMerge(payload as INotifyMailMergeSendPayload);\n      return;\n    }\n\n    if (job.name === MailSenderJob.NotifyMailMerge) {\n      const shouldSend = await this.checkAndMerge(payload as IMailSenderMergePayload);\n      if (shouldSend) {\n        this.mailSenderService.sendMailByTransporterName(\n          payload,\n          MailTransporterType.Notify,\n          MailType.NotifyMerge\n        );\n      }\n    }\n  }\n\n  @OnEvent(Events.NOTIFY_MAIL_MERGE)\n  async onNotifyMailMerge(event: { payload: IMailSenderMergePayload }) {\n    await this.queue.add(MailSenderJob.NotifyMailMerge, {\n      payload: event.payload,\n    });\n  }\n\n  private async checkAndMerge(payload: IMailSenderMergePayload) {\n    const { to } = payload;\n    const list = await this.cacheService.get(`mail-sender:notify-mail-merge:${to}`);\n    if (isUndefined(list)) {\n      await this.cacheService.set(`mail-sender:notify-mail-merge:${to}`, [], '5m');\n      await this.queue.add(\n        MailSenderJob.NotifyMailMergeSend,\n        {\n          payload: { to },\n        },\n        { delay: 1000 * 60 } // 1 minute\n      );\n      return true;\n    }\n    await this.cacheService.set(`mail-sender:notify-mail-merge:${to}`, [...list, payload], '5m');\n    return false;\n  }\n\n  private async sendNotifyMailMerge(payload: INotifyMailMergeSendPayload) {\n    const { to } = payload;\n    const list = await this.cacheService.get(`mail-sender:notify-mail-merge:${to}`);\n    await this.cacheService.del(`mail-sender:notify-mail-merge:${to}`);\n\n    if (!list || list.length === 0) {\n      return;\n    }\n\n    if (list.length === 1) {\n      this.mailSenderService.sendMailByTransporterName(\n        list[0],\n        MailTransporterType.Notify,\n        MailType.NotifyMerge\n      );\n      return;\n    }\n\n    const { brandName, brandLogo } = await this.settingOpenApiService.getServerBrand();\n    const mailOptions = await this.mailSenderService.notifyMergeOptions(list, brandName, brandLogo);\n    this.mailSenderService.sendMailByTransporterName(\n      {\n        ...mailOptions,\n        to,\n      },\n      MailTransporterType.Notify,\n      MailType.NotifyMerge\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/pages/normal.hbs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <style type=\"text/css\">\n        /* Client-specific styles */\n        #outlook a {\n            padding: 0;\n        }\n\n        .ReadMsgBody {\n            width: 100%;\n        }\n\n        .ExternalClass {\n            width: 100%;\n        }\n\n        .ExternalClass * {\n            line-height: 100%;\n        }\n\n        /* External styles */\n        body {\n            font-family: Arial, sans-serif;\n            margin: 0;\n            padding: 0;\n            -webkit-text-size-adjust: 100%;\n            -ms-text-size-adjust: 100%;\n        }\n\n        body, table, td, a {\n            -webkit-font-smoothing: antialiased;\n        }\n\n        img {\n            border: 0;\n            outline: none;\n            text-decoration: none;\n            -ms-interpolation-mode: bicubic;\n        }\n\n        /* Embedded styles */\n        .email-container {\n            min-width: 320px;\n            max-width: 600px;\n            margin: auto;\n        }\n\n        .button:hover {\n            background-color: #20aa5c !important;\n        }\n\n        /* Responsive styles */\n        @media screen and (max-width: 480px) {\n            .fluid {\n                width: 100% !important;\n                max-width: 100% !important;\n                height: auto !important;\n                margin-left: auto !important;\n                margin-right: auto !important;\n            }\n\n            .stack-column, .stack-column-center {\n                display: block !important;\n                width: 100% !important;\n                max-width: 100% !important;\n                direction: ltr !important;\n            }\n\n            .stack-column-center {\n                text-align: center !important;\n            }\n        }\n    </style>\n</head>\n<body style=\"background-color: #f7f7f7; margin: 0; padding: 0;\">\n<table width=\"100%\" bgcolor=\"#f7f7f7\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n    <tr>\n        <td>\n            <table align=\"center\" class=\"email-container\" bgcolor=\"#ffffff\"\n                   style=\"background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);\"\n                   border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n                <!-- Header -->\n                {{> header }}\n                <!-- Boby -->\n                {{> (lookup . 'partialBody') }}\n                <!-- Footer -->\n                {{> footer }}\n            </table>\n        </td>\n    </tr>\n</table>\n</body>\n</html>\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/collaborator-cell-tag.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <p style='margin: 18px 0;'>\n      {{{title}}}\n    </p>\n    <a\n      href='{{viewRecordUrlPrefix}}?recordId={{lookup recordIds 0}}&fromNotify={{notifyId}}'\n      class='button'\n      style='\n        padding: 12px 24px;\n        margin: 20px 0;\n        background-color: rgb(24, 24, 27);\n        color: rgb(250, 250, 250);\n        border-radius: 5px;\n        text-decoration: none;\n        font-weight: bold;\n        display: inline-block;\n        '\n    >{{buttonText}}\n    </a>\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/collaborator-multi-row-tag.hbs",
    "content": "<tr>\n    <td style=\"padding: 30px; text-align: center;\">\n        <p style=\"margin: 18px 0 24px 0;\">\n            {{{title}}}:\n        </p>\n        <div style=\"display: inline-block; max-width: 560px; width: 100%; text-align: left;\">\n            {{#each recordTitles}}\n                <a\n                    href=\"{{../viewRecordUrlPrefix}}?recordId={{this.id}}&fromNotify={{../notifyId}}\"\n                    target=\"_blank\"\n                    style=\"\n                        display: inline-block;\n                        width: calc(50% - 24px);\n                        padding: 6px 8px;\n                        margin: 6px 8px;\n                        background-color: #f4f4f5;\n                        color: #3276dc;\n                        border-radius: 6px;\n                        text-decoration: none;\n                        font-weight: 500;\n                        font-size: 14px;\n                        border: 1px solid #e4e4e7;\n                        transition: background-color 0.2s ease;\n                        text-align: center;\n                        white-space: nowrap;\n                        overflow: hidden;\n                        text-overflow: ellipsis;\n                        box-sizing: border-box;\n                        vertical-align: top;\n                    \"\n                    onmouseover=\"this.style.backgroundColor='#e4e4e7'\"\n                    onmouseout=\"this.style.backgroundColor='#f4f4f5'\"\n                    title=\"{{this.title}}\"\n                >{{this.title}}</a>\n            {{/each}}\n        </div>\n    </td>\n</tr>\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/common-body.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <h2 style='margin: 0;'>{{{title}}}</h2>\n    <p style='margin: 18px 0;text-align: left;'>{{{message}}}</p>\n    <a\n      href='{{buttonUrl}}'\n      class='button'\n      style='background-color: rgb(24, 24, 27); color: rgb(250, 250, 250); padding: 12px 24px; border-radius: 5px; text-decoration: none; font-weight: bold; display: inline-block; margin: 20px 0;'\n    >{{buttonText}}</a>\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/email-verify-code.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <h2 style='margin: 0;'>{{{title}}}</h2>\n    <p style='margin: 18px 0;text-align: left;'>{{{message}}}</p>\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/footer.hbs",
    "content": "<!-- Footer -->\n<tr>\n    <td style=\"background-color: #f3f3f3; padding: 15px; text-align: center; color: #555;\">\n        <p style=\"margin: 0;\">&copy;{{currentYear}} {{brandName}}</p>\n        <a href=\"{{publicOrigin}}\" style=\"color: #3276dc;\">Help Center</a>\n    </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/header.hbs",
    "content": "<!-- Header -->\n<tr>\n    <td style=\"background: linear-gradient(135deg, #7b4397, #dc2430); padding: 20px; text-align: center;\">\n        <img src=\"{{brandLogo}}\" alt=\"{{brandName}} Logo\" style=\"height: 50px; width: auto;\"/>\n    </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/html-body.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <h2 style='margin: 0;'>{{title}}</h2>\n    {{{message}}}\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/invite.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <h2 style='margin: 0;'>{{{title}}}</h2>\n    <p style='margin: 18px 0;'>\n      {{{message}}}\n    </p>\n    <a\n      href='{{inviteUrl}}'\n      class='button'\n      style='background-color: rgb(24, 24, 27); color: rgb(250, 250, 250); padding: 12px 24px; border-radius: 5px; text-decoration: none; font-weight: bold; display: inline-block; margin: 20px 0;'\n    >{{buttonText}}</a>\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/notify-merge-body.hbs",
    "content": "\n{{#each list}}\n  {{#with context}}\n    {{> (lookup . 'partialBody') }}\n  {{/with}}\n{{/each}}"
  },
  {
    "path": "apps/nestjs-backend/src/features/mail-sender/templates/partials/reset-password.hbs",
    "content": "<tr>\n  <td style='padding: 30px; text-align: center;'>\n    <h2 style='margin: 0;'>{{{title}}}</h2>\n    <p style='margin: 18px 0;text-align: left;'>{{{message}}}</p>\n    <a\n      href='{{buttonUrl}}'\n      class='button'\n      style='background-color: rgb(24, 24, 27); color: rgb(250, 250, 250); padding: 12px 24px; border-radius: 5px; text-decoration: none; font-weight: bold; display: inline-block; margin: 20px 0;'\n    >{{buttonText}}</a>\n  </td>\n</tr>"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/access-token.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateAccessTokenCacheKey } from '../../performance-cache/generate-keys';\nimport { dateToIso } from '../../utils/date-to-iso';\n\n@Injectable()\nexport class AccessTokenModel {\n  constructor(\n    private readonly prismaService: PrismaService,\n    protected readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  @PerformanceCache({\n    ttl: 30,\n    keyGenerator: generateAccessTokenCacheKey,\n    statsType: 'access-token',\n  })\n  async getAccessTokenRawById(id: string) {\n    const res = await this.prismaService.txClient().accessToken.findUnique({\n      where: { id },\n    });\n    if (!res) {\n      return null;\n    }\n    return dateToIso(res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/collaborator.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IPerformanceCacheStore } from '../../performance-cache';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateCollaboratorCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { dateToIso } from '../../utils/date-to-iso';\nimport { clearCache } from './helper';\n\n@Injectable()\nexport class CollaboratorModel {\n  constructor(\n    private readonly prismaService: PrismaService,\n    protected readonly performanceCacheService: PerformanceCacheService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    this.prismaService.$use(async (params, next) => {\n      const clearCacheKeys: (keyof IPerformanceCacheStore)[] = [];\n      if (\n        params.model === 'Collaborator' &&\n        (params.action.includes('update') || params.action.includes('delete'))\n      ) {\n        const resourceId = params.args?.where?.resourceId;\n        if (typeof resourceId === 'string') {\n          clearCacheKeys.push(generateCollaboratorCacheKey(resourceId));\n        } else if (typeof resourceId === 'object' && 'in' in resourceId) {\n          const resourceIds = resourceId.in as string[];\n          clearCacheKeys.push(...resourceIds.map(generateCollaboratorCacheKey));\n        }\n        const compositeResourceId =\n          params.args?.where?.resourceType_resourceId_principalId_principalType?.resourceId;\n        if (compositeResourceId) {\n          clearCacheKeys.push(generateCollaboratorCacheKey(compositeResourceId));\n        }\n      }\n\n      if (params.model === 'Collaborator' && params.action.includes('create')) {\n        const createData = params.args?.data;\n        if (Array.isArray(createData)) {\n          clearCacheKeys.push(\n            ...createData.map(({ resourceId }) => generateCollaboratorCacheKey(resourceId))\n          );\n        } else {\n          clearCacheKeys.push(generateCollaboratorCacheKey(createData.resourceId));\n        }\n      }\n      await clearCache(params, clearCacheKeys, this.performanceCacheService, this.cls);\n      return next(params);\n    });\n  }\n\n  @PerformanceCache({\n    ttl: 60 * 5,\n    statsType: 'collaborator',\n    keyGenerator: generateCollaboratorCacheKey,\n  })\n  async getCollaboratorRawByResourceId(resourceId: string) {\n    const res = await this.prismaService.collaborator.findMany({\n      where: {\n        resourceId: resourceId,\n      },\n    });\n    return res.map((item) => dateToIso(item));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/helper.ts",
    "content": "import type { Prisma } from '@teable/db-main-prisma';\nimport type { ClsService } from 'nestjs-cls';\nimport type { IPerformanceCacheStore, PerformanceCacheService } from '../../performance-cache';\nimport type { IClsStore } from '../../types/cls';\n\nexport const clearCache = async (\n  params: Prisma.MiddlewareParams,\n  clearCacheKeys: (keyof IPerformanceCacheStore)[],\n  performanceCacheService: PerformanceCacheService,\n  cls: ClsService<IClsStore>\n) => {\n  if (!clearCacheKeys.length) {\n    return;\n  }\n  if (!params.runInTransaction) {\n    await Promise.all(clearCacheKeys.map((key) => performanceCacheService.del(key)));\n    return;\n  }\n\n  if (cls.isActive()) {\n    const currentClearCacheKeys = cls.get('clearCacheKeys') || [];\n    cls.set('clearCacheKeys', [...currentClearCacheKeys, ...clearCacheKeys]);\n  }\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/model.module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { PrismaModule } from '@teable/db-main-prisma';\nimport { AccessTokenModel } from './access-token';\nimport { CollaboratorModel } from './collaborator';\nimport { SettingModel } from './setting';\nimport { TemplateModel } from './template';\nimport { UserModel } from './user';\n\n@Global()\n@Module({\n  imports: [PrismaModule],\n  providers: [UserModel, CollaboratorModel, AccessTokenModel, SettingModel, TemplateModel],\n  exports: [UserModel, CollaboratorModel, AccessTokenModel, SettingModel, TemplateModel],\n})\nexport class ModelModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/setting.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IPerformanceCacheStore } from '../../performance-cache';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateSettingCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { clearCache } from './helper';\n\n@Injectable()\nexport class SettingModel {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly performanceCacheService: PerformanceCacheService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    this.prismaService.$use(async (params, next) => {\n      const clearCacheKeys: (keyof IPerformanceCacheStore)[] = [];\n      if (\n        params.model === 'Setting' &&\n        (params.action.includes('update') ||\n          params.action.includes('delete') ||\n          params.action.includes('upsert') ||\n          params.action.includes('create'))\n      ) {\n        clearCacheKeys.push(generateSettingCacheKey());\n      }\n\n      await clearCache(params, clearCacheKeys, this.performanceCacheService, this.cls);\n      return next(params);\n    });\n  }\n\n  @PerformanceCache({\n    ttl: 60 * 60 * 24, // 1 day\n    keyGenerator: generateSettingCacheKey,\n    statsType: 'instance:setting',\n  })\n  async getSetting() {\n    return await this.prismaService.setting.findMany();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/template.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITemplateVo } from '@teable/openapi';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateTemplateCacheKeyByBaseId } from '../../performance-cache/generate-keys';\n\n@Injectable()\nexport class TemplateModel {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  @PerformanceCache({\n    ttl: 60 * 60 * 24, // 1 day\n    keyGenerator: (baseId: string) => generateTemplateCacheKeyByBaseId(baseId),\n    statsType: 'template',\n  })\n  async getTemplateRawByBaseId(baseId: string) {\n    const res = await this.prismaService.txClient().template.findFirst({\n      where: { snapshot: { contains: baseId } },\n    });\n    if (!res) {\n      return null;\n    }\n    return {\n      ...res,\n      snapshot: JSON.parse(res.snapshot!) as ITemplateVo['snapshot'],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/model/user.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { IPerformanceCacheStore } from '../../performance-cache';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateUserCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { dateToIso } from '../../utils/date-to-iso';\nimport { clearCache } from './helper';\n\n@Injectable()\nexport class UserModel {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly performanceCacheService: PerformanceCacheService,\n    private readonly cls: ClsService<IClsStore>\n  ) {\n    this.prismaService.$use(async (params, next) => {\n      const clearCacheKeys: (keyof IPerformanceCacheStore)[] = [];\n      if (\n        params.model === 'User' &&\n        (params.action.includes('update') || params.action.includes('delete'))\n      ) {\n        const whereId = params.args?.where?.id;\n        whereId && clearCacheKeys.push(generateUserCacheKey(whereId));\n      }\n      await clearCache(params, clearCacheKeys, this.performanceCacheService, this.cls);\n      return next(params);\n    });\n  }\n\n  @PerformanceCache({\n    ttl: 30,\n    keyGenerator: generateUserCacheKey,\n    preventConcurrent: false,\n    statsType: 'user',\n  })\n  async getUserRawById(id: string) {\n    const res = await this.prismaService.txClient().user.findUnique({\n      where: { id, deletedTime: null },\n    });\n    if (!res) {\n      return null;\n    }\n    return dateToIso(res);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/next.controller.ts",
    "content": "import { All, Body, Controller, Get, Next, Post, Req, Res } from '@nestjs/common';\nimport { ApiExcludeEndpoint } from '@nestjs/swagger';\nimport type { IQueryParamsVo } from '@teable/openapi';\nimport { IQueryParamsRo, queryParamsRoSchema } from '@teable/openapi';\nimport { NextFunction, Request, Response } from 'express';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { NextService } from './next.service';\n\n@Controller('/')\nexport class NextController {\n  constructor(private nextService: NextService) {}\n\n  /**\n   * StreamSaver mitm.html needs relaxed CSP to allow inline scripts\n   * The default CSP blocks inline scripts which prevents Service Worker registration\n   */\n  @ApiExcludeEndpoint()\n  @Public()\n  @Get('streamsaver/mitm.html')\n  public async streamSaverMitm(@Req() req: Request, @Res() res: Response) {\n    if (!this.nextService.server) {\n      return res.status(404).send('Not Found');\n    }\n    // Allow inline scripts for mitm.html (required for StreamSaver to work)\n    res.setHeader(\n      'Content-Security-Policy',\n      \"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors *\"\n    );\n    await this.nextService.server.getRequestHandler()(req, res);\n  }\n\n  /**\n   * Service Worker file needs special headers for registration\n   * - Content-Type must be application/javascript\n   * - Service-Worker-Allowed header to allow broader scope\n   */\n  @ApiExcludeEndpoint()\n  @Public()\n  @Get('streamsaver/sw.js')\n  public async serviceWorker(@Req() req: Request, @Res() res: Response) {\n    if (!this.nextService.server) {\n      return res.status(404).send('Not Found');\n    }\n    res.setHeader('Content-Type', 'application/javascript; charset=utf-8');\n    res.setHeader('Service-Worker-Allowed', '/');\n    await this.nextService.server.getRequestHandler()(req, res);\n  }\n\n  @ApiExcludeEndpoint()\n  @Public()\n  @Get([\n    '/',\n    'favicon.ico',\n    '_next/*',\n    '__nextjs*',\n    'images/*',\n    'streamsaver/*',\n    'home',\n    '404/*',\n    '403/?*',\n    '402/?*',\n    'space/?*',\n    'auth/?*',\n    'waitlist/?*',\n    'base/?*',\n    'invite/?*',\n    'share/?*',\n    'setting/?*',\n    'admin/?*',\n    'oauth/?*',\n    'developer/?*',\n    'public/?*',\n    'enterprise/?*',\n    'unsubscribe/?*',\n    'integrations/authorize/?*',\n    't/?*',\n  ])\n  public async home(@Req() req: Request, @Res() res: Response) {\n    await this.nextService.server.getRequestHandler()(req, res);\n  }\n\n  @ApiExcludeEndpoint()\n  @Public()\n  @All(['socket', 'socket/*'])\n  public async socket(@Req() req: Request, @Res() res: Response, @Next() next: NextFunction) {\n    if (!this.nextService.server) {\n      return next();\n    }\n    await this.nextService.server.getRequestHandler()(req, res);\n  }\n\n  @Post('api/query-params')\n  async saveQueryParams(\n    @Body(new ZodValidationPipe(queryParamsRoSchema)) saveQueryParamsRo: IQueryParamsRo\n  ): Promise<IQueryParamsVo> {\n    return await this.nextService.saveQueryParams(saveQueryParamsRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/next.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { NextController } from './next.controller';\nimport { NextService } from './next.service';\nimport { NextPluginModule } from './plugin/plugin.module';\n@Module({\n  imports: [NextPluginModule],\n  providers: [NextService],\n  controllers: [NextController],\n})\nexport class NextModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/next.service.ts",
    "content": "import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { generateQueryId } from '@teable/core';\nimport type { IQueryParamsRo, IQueryParamsVo } from '@teable/openapi';\nimport createServer from 'next';\nimport { CacheService } from '../../cache/cache.service';\nimport type { ICacheStore } from '../../cache/types';\n\n@Injectable()\nexport class NextService implements OnModuleInit, OnModuleDestroy {\n  private logger = new Logger(NextService.name);\n  public server!: ReturnType<typeof createServer>;\n  constructor(\n    private configService: ConfigService,\n    private readonly cacheService: CacheService<ICacheStore>\n  ) {}\n\n  private async startNEXTjs() {\n    const nodeEnv = this.configService.get<string>('NODE_ENV');\n    const port = this.configService.get<number>('PORT');\n    const nextJsDir = this.configService.get<string>('NEXTJS_DIR');\n    try {\n      this.server = createServer({\n        dev: nodeEnv !== 'production',\n        port: port,\n        dir: nextJsDir,\n        hostname: 'localhost',\n        turbopack: true,\n      });\n      await this.server.prepare();\n    } catch (error) {\n      this.logger.error(error);\n    }\n  }\n\n  async onModuleInit() {\n    if (process.env.BACKEND_SKIP_NEXT_START !== 'true') {\n      await this.startNEXTjs();\n    }\n  }\n\n  async onModuleDestroy() {\n    await this.server?.close();\n  }\n\n  async saveQueryParams(queryParamsRo: IQueryParamsRo): Promise<IQueryParamsVo> {\n    const { params } = queryParamsRo;\n    const ttl = 60;\n    const queryId = generateQueryId();\n    const cacheKey = `query-params:${queryId}` as const;\n\n    await this.cacheService.setDetail(cacheKey, params, ttl);\n\n    return { queryId };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/plugin/plugin-proxy.middleware.ts",
    "content": "// proxy.middleware.ts\nimport type { NestMiddleware } from '@nestjs/common';\nimport type { Request, Response } from 'express';\nimport type { RequestHandler } from 'http-proxy-middleware';\nimport { createProxyMiddleware } from 'http-proxy-middleware';\nimport { BaseConfig, IBaseConfig } from '../../../configs/base.config';\n\nexport class PluginProxyMiddleware implements NestMiddleware {\n  private proxy: RequestHandler;\n\n  constructor(@BaseConfig() private readonly baseConfig: IBaseConfig) {\n    this.proxy = createProxyMiddleware({\n      target: `http://localhost:${baseConfig.pluginServerPort}`,\n    });\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  async use(req: Request, res: Response, next: () => void): Promise<any> {\n    this.proxy(req, res, next);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/plugin/plugin-proxy.module.ts",
    "content": "import type { MiddlewareConsumer, NestModule } from '@nestjs/common';\nimport { Module, RequestMethod } from '@nestjs/common';\nimport { PluginProxyMiddleware } from './plugin-proxy.middleware';\n@Module({\n  providers: [],\n  imports: [],\n})\nexport class PluginProxyModule implements NestModule {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  configure(consumer: MiddlewareConsumer): any {\n    consumer.apply(PluginProxyMiddleware).forRoutes({\n      method: RequestMethod.ALL,\n      path: 'plugin/?*',\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/next/plugin/plugin.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PluginProxyModule } from './plugin-proxy.module';\n@Module({\n  imports: [PluginProxyModule],\n  providers: [],\n  controllers: [],\n})\nexport class NextPluginModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/notification/notification.controller.ts",
    "content": "import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';\nimport type { INotificationUnreadCountVo, INotificationVo } from '@teable/openapi';\nimport {\n  getNotifyListQuerySchema,\n  IGetNotifyListQuery,\n  IUpdateNotifyStatusRo,\n  updateNotifyStatusRoSchema,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { NotificationService } from './notification.service';\n\n@Controller('api/notifications')\nexport class NotificationController {\n  constructor(\n    private readonly notificationService: NotificationService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Get()\n  async getNotifyList(\n    @Query(new ZodValidationPipe(getNotifyListQuerySchema)) query: IGetNotifyListQuery\n  ): Promise<INotificationVo> {\n    const currentUserId = this.cls.get('user.id');\n    return this.notificationService.getNotifyList(currentUserId, query);\n  }\n\n  @Get('/unread-count')\n  async unreadCount(): Promise<INotificationUnreadCountVo> {\n    const currentUserId = this.cls.get('user.id');\n    return this.notificationService.unreadCount(currentUserId);\n  }\n\n  @Patch(':notificationId/status')\n  async updateNotifyStatus(\n    @Param('notificationId') notificationId: string,\n    @Body(new ZodValidationPipe(updateNotifyStatusRoSchema))\n    updateNotifyStatusRo: IUpdateNotifyStatusRo\n  ): Promise<void> {\n    const currentUserId = this.cls.get('user.id');\n    return this.notificationService.updateNotifyStatus(\n      currentUserId,\n      notificationId,\n      updateNotifyStatusRo\n    );\n  }\n\n  @Patch('/read-all')\n  async markAllAsRead(): Promise<void> {\n    const currentUserId = this.cls.get('user.id');\n    return this.notificationService.markAllAsRead(currentUserId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/notification/notification.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../../share-db/share-db.module';\nimport { MailSenderModule } from '../mail-sender/mail-sender.module';\nimport { UserModule } from '../user/user.module';\nimport { NotificationController } from './notification.controller';\nimport { NotificationService } from './notification.service';\n\n@Module({\n  imports: [ShareDbModule, UserModule, MailSenderModule.register()],\n  controllers: [NotificationController],\n  providers: [NotificationService],\n  exports: [NotificationService],\n})\nexport class NotificationModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/notification/notification.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { ILocalization, INotificationBuffer, INotificationUrl } from '@teable/core';\nimport {\n  generateNotificationId,\n  getUserNotificationChannel,\n  NotificationStatesEnum,\n  NotificationTypeEnum,\n  notificationUrlSchema,\n  userIconSchema,\n  SYSTEM_USER_ID,\n  assertNever,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { MailTransporterType, MailType } from '@teable/openapi';\nimport {\n  type IGetNotifyListQuery,\n  type INotificationUnreadCountVo,\n  type INotificationVo,\n  type IUpdateNotifyStatusRo,\n} from '@teable/openapi';\nimport { keyBy } from 'lodash';\nimport { I18nContext, I18nService } from 'nestjs-i18n';\nimport { IMailConfig, MailConfig } from '../../configs/mail.config';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport type { I18nPath, I18nTranslations } from '../../types/i18n.generated';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { MailSenderService } from '../mail-sender/mail-sender.service';\nimport { UserService } from '../user/user.service';\n\n@Injectable()\nexport class NotificationService {\n  private readonly logger = new Logger(NotificationService.name);\n  private readonly mailTypeMap: Record<NotificationTypeEnum, MailType> = {\n    [NotificationTypeEnum.System]: MailType.System,\n    [NotificationTypeEnum.CollaboratorCellTag]: MailType.CollaboratorCellTag,\n    [NotificationTypeEnum.CollaboratorMultiRowTag]: MailType.CollaboratorMultiRowTag,\n    [NotificationTypeEnum.Comment]: MailType.Common,\n    [NotificationTypeEnum.ExportBase]: MailType.ExportBase,\n  };\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly shareDbService: ShareDbService,\n    private readonly mailSenderService: MailSenderService,\n    private readonly userService: UserService,\n    @MailConfig() private readonly mailConfig: IMailConfig,\n    private readonly i18n: I18nService<I18nTranslations>\n  ) {}\n\n  getUserLang(lang?: string | null) {\n    return lang ?? I18nContext.current()?.lang;\n  }\n\n  getMessage(text: string | ILocalization<I18nPath>, lang?: string) {\n    return typeof text === 'string'\n      ? text\n      : (this.i18n.t(text.i18nKey, {\n          args: text.context,\n          lang: lang ?? I18nContext.current()?.lang,\n        }) as string);\n  }\n\n  /**\n   * notification message i18n use common prefix, so we need to remove it to save db\n   */\n  getMessageI18n(localization: string | ILocalization<I18nPath>) {\n    return typeof localization === 'string'\n      ? undefined\n      : JSON.stringify({\n          // remove common prefix\n          // eg: common.email.templates -> email.templates\n          i18nKey: localization.i18nKey.replace(/^common\\./, ''),\n          context: localization.context,\n        });\n  }\n\n  async sendCollaboratorNotify(params: {\n    fromUserId: string;\n    toUserId: string;\n    refRecord: {\n      baseId: string;\n      tableId: string;\n      tableName: string;\n      fieldName: string;\n      recordIds: string[];\n      recordTitles: { id: string; title: string }[];\n    };\n  }): Promise<void> {\n    const { fromUserId, toUserId, refRecord } = params;\n    const [fromUser, toUser] = await Promise.all([\n      this.userService.getUserById(fromUserId),\n      this.userService.getUserById(toUserId),\n    ]);\n\n    if (!fromUser || !toUser || fromUserId === toUserId) {\n      return;\n    }\n\n    const notifyId = generateNotificationId();\n\n    const userIcon = userIconSchema.parse({\n      userId: fromUser.id,\n      userName: fromUser.name,\n      userAvatarUrl: fromUser?.avatar && getPublicFullStorageUrl(fromUser.avatar),\n    });\n\n    const urlMeta = notificationUrlSchema.parse({\n      baseId: refRecord.baseId,\n      tableId: refRecord.tableId,\n      ...(refRecord.recordIds.length === 1 ? { recordId: refRecord.recordIds[0] } : {}),\n    });\n    const type =\n      refRecord.recordIds.length > 1\n        ? NotificationTypeEnum.CollaboratorMultiRowTag\n        : NotificationTypeEnum.CollaboratorCellTag;\n\n    const notifyPath = this.generateNotifyPath(type as NotificationTypeEnum, urlMeta);\n\n    let message: string | ILocalization<I18nPath> = '';\n    if (refRecord.recordIds.length <= 1) {\n      message = {\n        i18nKey: 'common.email.templates.collaboratorCellTag.subject',\n        context: {\n          fromUserName: fromUser.name,\n          fieldName: refRecord.fieldName,\n          tableName: refRecord.tableName,\n        },\n      };\n    } else {\n      message = {\n        i18nKey: 'common.email.templates.collaboratorMultiRowTag.subject',\n        context: {\n          fromUserName: fromUser.name,\n          refLength: refRecord.recordIds.length.toString(),\n          tableName: refRecord.tableName,\n        },\n      };\n    }\n    const data: Prisma.NotificationCreateInput = {\n      id: notifyId,\n      fromUserId,\n      toUserId,\n      type,\n      message: this.getMessage(message, 'en'),\n      messageI18n: this.getMessageI18n(message),\n      urlPath: notifyPath,\n      createdBy: fromUserId,\n    };\n    const notifyData = await this.createNotify(data);\n\n    const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;\n\n    const socketNotification = {\n      notification: {\n        id: notifyData.id,\n        message: notifyData.message,\n        messageI18n: notifyData.messageI18n,\n        notifyIcon: userIcon,\n        notifyType: notifyData.type as NotificationTypeEnum,\n        url: this.mailConfig.origin + notifyPath,\n        isRead: false,\n        createdTime: notifyData.createdTime.toISOString(),\n      },\n      unreadCount: unreadCount,\n    };\n\n    this.sendNotifyBySocket(toUser.id, socketNotification);\n\n    const emailOptions = await this.mailSenderService.collaboratorCellTagEmailOptions({\n      notifyId,\n      fromUserName: fromUser.name,\n      refRecord,\n    });\n    if (toUser.notifyMeta && toUser.notifyMeta.email) {\n      this.mailSenderService.sendMail(\n        {\n          to: toUser.email,\n          ...emailOptions,\n        },\n        {\n          type: this.mailTypeMap[type],\n          transporterName: MailTransporterType.Notify,\n        }\n      );\n    }\n  }\n\n  async sendHtmlContentNotify(\n    params: {\n      path: string;\n      fromUserId?: string;\n      toUserId: string;\n      message: string | ILocalization<I18nPath>;\n      emailConfig?: {\n        title: string | ILocalization<I18nPath>;\n        message: string | ILocalization<I18nPath>;\n        buttonUrl?: string;\n        buttonText?: string | ILocalization<I18nPath>;\n      };\n    },\n    type = NotificationTypeEnum.System\n  ) {\n    const { toUserId, emailConfig, path, fromUserId = SYSTEM_USER_ID } = params;\n    const notifyId = generateNotificationId();\n    const toUser = await this.userService.getUserById(toUserId);\n    if (!toUser) {\n      return;\n    }\n\n    const data: Prisma.NotificationCreateInput = {\n      id: notifyId,\n      fromUserId: fromUserId,\n      toUserId,\n      type,\n      urlPath: path,\n      createdBy: fromUserId,\n      message: this.getMessage(params.message, 'en'),\n      messageI18n: this.getMessageI18n(params.message),\n    };\n    const notifyData = await this.createNotify(data);\n\n    const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;\n\n    const rawUsers = await this.prismaService.user.findMany({\n      select: { id: true, name: true, avatar: true },\n      where: { id: fromUserId },\n    });\n    const fromUserSets = keyBy(rawUsers, 'id');\n\n    const systemNotifyIcon = this.generateNotifyIcon(\n      notifyData.type as NotificationTypeEnum,\n      fromUserId,\n      fromUserSets\n    );\n\n    const socketNotification = {\n      notification: {\n        id: notifyData.id,\n        message: notifyData.message,\n        messageI18n: notifyData.messageI18n,\n        notifyType: type,\n        url: path,\n        notifyIcon: systemNotifyIcon,\n        isRead: false,\n        createdTime: notifyData.createdTime.toISOString(),\n      },\n      unreadCount: unreadCount,\n    };\n\n    this.sendNotifyBySocket(toUser.id, socketNotification);\n\n    if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {\n      const lang = this.getUserLang(toUser.lang);\n      const emailOptions = await this.mailSenderService.htmlEmailOptions({\n        ...emailConfig,\n        title: this.getMessage(emailConfig.title, lang),\n        message: this.getMessage(emailConfig.message, lang),\n        to: toUserId,\n        buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,\n        buttonText: emailConfig.buttonText\n          ? this.getMessage(emailConfig.buttonText, lang)\n          : this.i18n.t('common.email.templates.notify.buttonText'),\n      });\n      this.mailSenderService.sendMail(\n        {\n          to: toUser.email,\n          ...emailOptions,\n        },\n        {\n          type: this.mailTypeMap[type],\n          transporterName: MailTransporterType.Notify,\n        }\n      );\n    }\n  }\n\n  async sendCommonNotify(\n    params: {\n      path: string;\n      fromUserId?: string;\n      toUserId: string;\n      message: string | ILocalization<I18nPath>;\n      emailConfig?: {\n        title: string | ILocalization<I18nPath>;\n        message: string | ILocalization<I18nPath>;\n        buttonUrl?: string; // use path as default\n        buttonText?: string | ILocalization<I18nPath>; // use 'View' as default\n      };\n    },\n    type = NotificationTypeEnum.System\n  ) {\n    const { toUserId, emailConfig, path, fromUserId = SYSTEM_USER_ID } = params;\n    const notifyId = generateNotificationId();\n    const toUser = await this.userService.getUserById(toUserId);\n    if (!toUser) {\n      return;\n    }\n\n    const data: Prisma.NotificationCreateInput = {\n      id: notifyId,\n      fromUserId: fromUserId,\n      toUserId,\n      type,\n      urlPath: path,\n      createdBy: fromUserId,\n      message: this.getMessage(params.message, 'en'),\n      messageI18n: this.getMessageI18n(params.message),\n    };\n    const notifyData = await this.createNotify(data);\n\n    const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;\n\n    const rawUsers = await this.prismaService.user.findMany({\n      select: { id: true, name: true, avatar: true },\n      where: { id: fromUserId },\n    });\n    const fromUserSets = keyBy(rawUsers, 'id');\n\n    const systemNotifyIcon = this.generateNotifyIcon(\n      notifyData.type as NotificationTypeEnum,\n      fromUserId,\n      fromUserSets\n    );\n\n    const socketNotification = {\n      notification: {\n        id: notifyData.id,\n        message: notifyData.message,\n        messageI18n: notifyData.messageI18n,\n        notifyType: type,\n        url: path,\n        notifyIcon: systemNotifyIcon,\n        isRead: false,\n        createdTime: notifyData.createdTime.toISOString(),\n      },\n      unreadCount: unreadCount,\n    };\n\n    this.sendNotifyBySocket(toUser.id, socketNotification);\n\n    if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {\n      const lang = this.getUserLang(toUser.lang);\n      const emailOptions = await this.mailSenderService.commonEmailOptions({\n        ...emailConfig,\n        title: this.getMessage(emailConfig.title, lang),\n        message: this.getMessage(emailConfig.message, lang),\n        to: toUserId,\n        buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,\n        buttonText: emailConfig.buttonText\n          ? this.getMessage(emailConfig.buttonText, lang)\n          : this.i18n.t('common.email.templates.notify.buttonText'),\n      });\n      this.mailSenderService.sendMail(\n        {\n          to: toUser.email,\n          ...emailOptions,\n        },\n        {\n          type: this.mailTypeMap[type],\n          transporterName: MailTransporterType.Notify,\n        }\n      );\n    }\n  }\n\n  async sendImportResultNotify(params: {\n    tableId: string;\n    baseId: string;\n    toUserId: string;\n    message: string | ILocalization<I18nPath>;\n  }) {\n    const { toUserId, tableId, message, baseId } = params;\n    const toUser = await this.userService.getUserById(toUserId);\n    if (!toUser) {\n      return;\n    }\n    const type = NotificationTypeEnum.System;\n    const urlMeta = notificationUrlSchema.parse({\n      baseId: baseId,\n      tableId: tableId,\n    });\n    const notifyPath = this.generateNotifyPath(type, urlMeta);\n\n    this.sendCommonNotify({\n      path: notifyPath,\n      toUserId,\n      message,\n      emailConfig: {\n        title: { i18nKey: 'common.email.templates.notify.import.title' },\n        message,\n      },\n    });\n  }\n\n  async sendExportBaseResultNotify(params: {\n    baseId: string;\n    toUserId: string;\n    message: string | ILocalization<I18nPath>;\n  }) {\n    const { toUserId, message } = params;\n    const toUser = await this.userService.getUserById(toUserId);\n    if (!toUser) {\n      return;\n    }\n    const type = NotificationTypeEnum.ExportBase;\n\n    this.sendHtmlContentNotify(\n      {\n        path: '',\n        toUserId,\n        message,\n        emailConfig: {\n          title: { i18nKey: 'common.email.templates.notify.exportBase.title' },\n          message: message,\n        },\n      },\n      type\n    );\n  }\n\n  async sendCommentNotify(params: {\n    baseId: string;\n    tableId: string;\n    recordId: string;\n    commentId: string;\n    toUserId: string;\n    message: string | ILocalization<I18nPath>;\n    fromUserId: string;\n  }) {\n    const { toUserId, tableId, message, baseId, commentId, recordId, fromUserId } = params;\n    const toUser = await this.userService.getUserById(toUserId);\n    if (!toUser) {\n      return;\n    }\n    const type = NotificationTypeEnum.Comment;\n    const urlMeta = notificationUrlSchema.parse({\n      baseId: baseId,\n      tableId: tableId,\n      recordId: recordId,\n      commentId: commentId,\n    });\n    const notifyPath = this.generateNotifyPath(type, urlMeta);\n\n    this.sendCommonNotify(\n      {\n        path: notifyPath,\n        fromUserId,\n        toUserId,\n        message,\n        emailConfig: {\n          title: { i18nKey: 'common.email.templates.notify.recordComment.title' },\n          message: message,\n        },\n      },\n      type\n    );\n  }\n\n  async getNotifyList(userId: string, query: IGetNotifyListQuery): Promise<INotificationVo> {\n    const { notifyStates, cursor } = query;\n    const limit = 10;\n\n    const data = await this.prismaService.notification.findMany({\n      where: {\n        toUserId: userId,\n        isRead: notifyStates === NotificationStatesEnum.Read,\n      },\n      take: limit + 1,\n      cursor: cursor ? { id: cursor } : undefined,\n      orderBy: {\n        createdTime: 'desc',\n      },\n    });\n\n    // Doesn't seem like a good way\n    const fromUserIds = data.map((v) => v.fromUserId);\n    const rawUsers = await this.prismaService.user.findMany({\n      select: { id: true, name: true, avatar: true },\n      where: { id: { in: fromUserIds } },\n    });\n    const fromUserSets = keyBy(rawUsers, 'id');\n\n    const notifications = data.map((v) => {\n      const notifyIcon = this.generateNotifyIcon(\n        v.type as NotificationTypeEnum,\n        v.fromUserId,\n        fromUserSets\n      );\n      return {\n        id: v.id,\n        notifyIcon: notifyIcon,\n        notifyType: v.type as NotificationTypeEnum,\n        url: this.mailConfig.origin + v.urlPath,\n        message: v.message,\n        messageI18n: v.messageI18n,\n        isRead: v.isRead,\n        createdTime: v.createdTime.toISOString(),\n      };\n    });\n\n    let nextCursor: typeof cursor | undefined = undefined;\n    if (notifications.length > limit) {\n      const nextItem = notifications.pop();\n      nextCursor = nextItem!.id;\n    }\n    return {\n      notifications,\n      nextCursor,\n    };\n  }\n\n  private generateNotifyIcon(\n    notifyType: NotificationTypeEnum,\n    fromUserId: string,\n    fromUserSets: Record<string, { id: string; name: string; avatar: string | null }>\n  ) {\n    const origin = this.mailConfig.origin;\n\n    switch (notifyType) {\n      case NotificationTypeEnum.System:\n      case NotificationTypeEnum.ExportBase:\n        return { iconUrl: `${origin}/images/favicon/favicon.svg` };\n      case NotificationTypeEnum.Comment:\n      case NotificationTypeEnum.CollaboratorCellTag:\n      case NotificationTypeEnum.CollaboratorMultiRowTag: {\n        const { id, name, avatar } = fromUserSets[fromUserId];\n\n        return {\n          userId: id,\n          userName: name,\n          userAvatarUrl: avatar && getPublicFullStorageUrl(avatar),\n        };\n      }\n      default:\n        throw assertNever(notifyType);\n    }\n  }\n\n  private generateNotifyPath(notifyType: NotificationTypeEnum, urlMeta: INotificationUrl) {\n    switch (notifyType) {\n      case NotificationTypeEnum.System: {\n        const { baseId, tableId } = urlMeta || {};\n        return `/base/${baseId}/table/${tableId}`;\n      }\n      case NotificationTypeEnum.Comment: {\n        const { baseId, tableId, recordId, commentId } = urlMeta || {};\n\n        return `/base/${baseId}/table/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`;\n      }\n      case NotificationTypeEnum.CollaboratorCellTag:\n      case NotificationTypeEnum.CollaboratorMultiRowTag: {\n        const { baseId, tableId, recordId } = urlMeta || {};\n\n        return `/base/${baseId}/table/${tableId}${recordId ? `?recordId=${recordId}` : ''}`;\n      }\n      case NotificationTypeEnum.ExportBase: {\n        const { downloadUrl } = urlMeta || {};\n        return downloadUrl as string;\n      }\n      default:\n        throw assertNever(notifyType);\n    }\n  }\n\n  async unreadCount(userId: string): Promise<INotificationUnreadCountVo> {\n    const unreadCount = await this.prismaService.notification.count({\n      where: {\n        toUserId: userId,\n        isRead: false,\n      },\n    });\n    return { unreadCount };\n  }\n\n  async updateNotifyStatus(\n    userId: string,\n    notificationId: string,\n    updateNotifyStatusRo: IUpdateNotifyStatusRo\n  ): Promise<void> {\n    const { isRead } = updateNotifyStatusRo;\n\n    await this.prismaService.notification.updateMany({\n      where: {\n        id: notificationId,\n        toUserId: userId,\n      },\n      data: {\n        isRead: isRead,\n      },\n    });\n  }\n\n  async markAllAsRead(userId: string): Promise<void> {\n    await this.prismaService.notification.updateMany({\n      where: {\n        toUserId: userId,\n        isRead: false,\n      },\n      data: {\n        isRead: true,\n      },\n    });\n  }\n\n  private async createNotify(data: Prisma.NotificationCreateInput) {\n    return this.prismaService.notification.create({ data });\n  }\n\n  private async sendNotifyBySocket(toUserId: string, data: INotificationBuffer) {\n    const channel = getUserNotificationChannel(toUserId);\n\n    const presence = this.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create(data.notification.id);\n\n    return new Promise((resolve) => {\n      localPresence.submit(data, (error) => {\n        error && this.logger.error(error);\n        resolve(data);\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/guard/oauth2-client.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { AuthGuard } from '@nestjs/passport';\n\n@Injectable()\nexport class OAuthClientGuard extends AuthGuard(['oauth2-client-password', 'oauth2-pkce-client']) {\n  async canActivate(context: ExecutionContext): Promise<boolean> {\n    const result = (await super.canActivate(context)) as boolean;\n    await super.logIn(context.switchToHttp().getRequest());\n    return result;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth-server.controller.ts",
    "content": "import { Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';\nimport type { DecisionInfoGetVo } from '@teable/openapi';\nimport { Request, Response } from 'express';\nimport { EnsureLogin } from '../auth/decorators/ensure-login.decorator';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { OAuthClientGuard } from './guard/oauth2-client.guard';\nimport { OAuthServerService } from './oauth-server.service';\n\n@Controller('/api/oauth')\nexport class OAuthServerController {\n  constructor(private readonly oauthServerService: OAuthServerService) {}\n\n  @EnsureLogin()\n  @Get('authorize')\n  async authorize(@Res({ passthrough: true }) res: Response, @Req() req: Request) {\n    await this.oauthServerService.authorize(req, res);\n  }\n\n  @Post('access_token')\n  @UseGuards(OAuthClientGuard)\n  @Public()\n  async accessToken(@Res({ passthrough: true }) res: Response, @Req() req: Request) {\n    await this.oauthServerService.token(req, res);\n  }\n\n  @EnsureLogin()\n  @Post('decision')\n  async decision(@Res() res: Response, @Req() req: Request) {\n    return this.oauthServerService.decision(req, res);\n  }\n\n  @Get('decision/:transactionId')\n  async transaction(\n    @Req() req: Request,\n    @Param('transactionId') transactionId: string\n  ): Promise<DecisionInfoGetVo> {\n    return this.oauthServerService.getDecisionInfo(req, transactionId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth-server.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { BadRequestException, UnauthorizedException } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Mock, MockInstance } from 'vitest';\nimport { mockDeep } from 'vitest-mock-extended';\nimport { CacheService } from '../../cache/cache.service';\nimport { CustomHttpException } from '../../custom.exception';\nimport { GlobalModule } from '../../global/global.module';\nimport { OAuthServerService } from './oauth-server.service';\nimport { OAuthModule } from './oauth.module';\n\ndescribe('OAuthServerService', () => {\n  let service: OAuthServerService;\n  const prismaService = mockDeep<PrismaService>();\n  const cacheService = mockDeep<CacheService>();\n  const jwtService = mockDeep<JwtService>();\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, OAuthModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(prismaService)\n      .overrideProvider(CacheService)\n      .useValue(cacheService)\n      .overrideProvider(JwtService)\n      .useValue(jwtService)\n      .compile();\n\n    service = module.get<OAuthServerService>(OAuthServerService);\n\n    prismaService.txClient.mockImplementation(() => {\n      return prismaService;\n    });\n\n    prismaService.$tx.mockImplementation(async (fn) => {\n      return await fn(prismaService);\n    });\n\n    // Default: rate limit not exceeded\n    cacheService.incr.mockResolvedValue(1);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('authorizeValidate', () => {\n    let done: Mock;\n    beforeEach(() => {\n      done = vitest.fn();\n      // // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      vitest.spyOn(service as any, 'getOAuthApp').mockResolvedValueOnce({\n        redirectUris: ['http://localhost/callback'],\n        scopes: ['user|email_read'],\n      });\n    });\n\n    afterEach(() => {\n      done.mockReset();\n      vitest.restoreAllMocks();\n    });\n\n    it('should pass with valid scopes and redirectUri', async () => {\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://localhost/callback',\n          scope: ['user|email_read'],\n          type: 'code',\n          state: 'sample state',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n      expect(done).toHaveBeenCalledWith(\n        null,\n        {\n          clientId: 'clientId',\n          scopes: ['user|email_read'],\n          redirectUri: 'http://localhost/callback',\n        },\n        'http://localhost/callback'\n      );\n    });\n\n    it('should fail with invalid scopes', async () => {\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://localhost/callback',\n          scope: ['table|read'],\n          state: 'sample state',\n          type: 'code',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n      expect(done).toHaveBeenCalledWith(new BadRequestException('Invalid scopes: table|read'));\n    });\n\n    it('should fail if no redirectUri configured', async () => {\n      vitest.resetAllMocks();\n      vitest.spyOn(service as any, 'getOAuthApp').mockResolvedValue({\n        redirectUris: [],\n        scopes: ['user|email_read'],\n      });\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://localhost/callback',\n          scope: ['user|email_read'],\n          state: 'sample state',\n          type: 'code',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n      expect(done).toHaveBeenCalledWith(new BadRequestException('Redirect uri not configured'));\n    });\n\n    it('should fail with invalid redirectUri', async () => {\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://invalid/callback',\n          scope: ['user|email_read'],\n          state: 'sample state',\n          type: 'code',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n\n      expect(done).toHaveBeenCalledWith(new UnauthorizedException('Invalid redirectUri'));\n    });\n\n    it('should pass with default redirectUri if none is provided', async () => {\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://localhost/callback',\n          scope: ['user|email_read'],\n          state: 'sample state',\n          type: 'code',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n      expect(done).toHaveBeenCalledWith(\n        null,\n        {\n          clientId: 'clientId',\n          scopes: ['user|email_read'],\n          redirectUri: 'http://localhost/callback',\n        },\n        'http://localhost/callback'\n      );\n    });\n\n    it('should handle errors from getOAuthApp', async () => {\n      const error = new Error('Database error');\n      vitest.restoreAllMocks();\n      vitest.spyOn(service as any, 'getOAuthApp').mockRejectedValueOnce(error);\n      await service['authorizeValidate'](\n        {\n          clientID: 'clientId',\n          redirectURI: 'http://localhost/callback',\n          scope: ['read'],\n          state: 'sample state',\n          type: 'code',\n          transactionID: 'transactionID',\n        },\n        done\n      );\n      expect(done).toHaveBeenCalledWith(error);\n    });\n  });\n\n  describe('codeExchange', () => {\n    let mockDone: Mock;\n    let mockGenerateAccessToken: MockInstance;\n    let mockGetRefreshToken: MockInstance;\n    beforeEach(() => {\n      mockDone = vitest.fn();\n      mockGenerateAccessToken = vitest.spyOn(service as any, 'generateAccessToken');\n      mockGetRefreshToken = vitest.spyOn(service as any, 'getRefreshToken');\n    });\n\n    afterEach(() => {\n      mockDone.mockReset();\n      mockGetRefreshToken.mockReset();\n      mockGenerateAccessToken.mockReset();\n    });\n\n    it('should exchange code for tokens successfully', async () => {\n      const mockClient = {\n        clientId: 'clientId',\n        name: 'clientName',\n        secretId: 'secretId',\n        type: 'secret',\n        clientSecret: 'clientSecret',\n      };\n      const mockCode = 'validCode';\n      const mockRedirectUri = 'http://redirect.uri';\n      const mockCodeState = {\n        clientId: 'clientId',\n        redirectUri: 'http://redirect.uri',\n        user: { id: 'userId' },\n        scopes: ['user|email_read'],\n        type: 'secret',\n      };\n\n      cacheService.get.mockResolvedValue(mockCodeState);\n      cacheService.del.mockResolvedValue();\n      const mockAccessToken = { id: 'accessTokenId', token: 'accessToken' };\n      mockGenerateAccessToken.mockResolvedValue(mockAccessToken);\n      const mockRefreshToken = 'refreshToken';\n      mockGetRefreshToken.mockResolvedValue(mockRefreshToken);\n\n      await service['codeExchange'](mockClient, mockCode, mockRedirectUri, mockDone);\n      expect(mockDone).toHaveBeenCalledWith(null, mockAccessToken.token, mockRefreshToken, {\n        scopes: mockCodeState.scopes,\n        expires_in: expect.any(Number),\n        refresh_expires_in: expect.any(Number),\n      });\n      expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(cacheService.del).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(service['generateAccessToken']).toHaveBeenCalledWith({\n        clientId: mockClient.clientId,\n        clientName: mockClient.name,\n        userId: mockCodeState.user.id,\n        scopes: mockCodeState.scopes,\n      });\n      expect(service['getRefreshToken']).toHaveBeenCalledWith(\n        mockClient,\n        mockAccessToken.id,\n        expect.any(String)\n      );\n      expect(prismaService.txClient().oAuthAppToken.create).toHaveBeenCalledWith({\n        data: {\n          clientId: mockClient.clientId,\n          refreshTokenSign: expect.any(String),\n          appSecretId: mockClient.secretId,\n          createdBy: mockCodeState.user.id,\n          expiredTime: expect.any(String),\n        },\n      });\n    });\n\n    it('should return an UnauthorizedException if the code is invalid', async () => {\n      const mockClient = { clientId: 'clientId', name: 'clientName', secretId: 'secretId' };\n      const mockCode = 'invalidCode';\n      const mockRedirectUri = 'http://redirect.uri';\n\n      cacheService.get.mockResolvedValue(undefined);\n\n      await service['codeExchange'](mockClient, mockCode, mockRedirectUri, mockDone);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid code'));\n    });\n\n    it('should return an UnauthorizedException if the clientId is invalid', async () => {\n      const mockClient = { clientId: 'clientId', name: 'clientName', secretId: 'secretId' };\n      const mockCode = 'validCode';\n      const mockRedirectUri = 'http://redirect.uri';\n      const mockCodeState = {\n        clientId: 'invalidClientId',\n        redirectUri: 'http://redirect.uri',\n        user: { id: 'userId' },\n        scopes: ['user|email_read'],\n      };\n\n      cacheService.get.mockResolvedValue(mockCodeState);\n\n      await service['codeExchange'](mockClient, mockCode, mockRedirectUri, mockDone);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid client'));\n    });\n\n    it('should return an UnauthorizedException if the redirectUri is invalid', async () => {\n      const mockClient = { clientId: 'clientId', name: 'clientName', secretId: 'secretId' };\n      const mockCode = 'validCode';\n      const mockRedirectUri = 'http://invalid.redirect.uri';\n      const mockCodeState = {\n        clientId: 'clientId',\n        redirectUri: 'http://redirect.uri',\n        user: { id: 'userId' },\n        scopes: ['user|email_read'],\n      };\n\n      cacheService.get.mockResolvedValue(mockCodeState);\n\n      await service['codeExchange'](mockClient, mockCode, mockRedirectUri, mockDone);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid redirectUri'));\n    });\n\n    it('should catch and handle errors', async () => {\n      const mockClient = { clientId: 'clientId', name: 'clientName', secretId: 'secretId' };\n      const mockCode = 'validCode';\n      const mockRedirectUri = 'http://redirect.uri';\n\n      cacheService.get.mockRejectedValue(new Error('Some error'));\n\n      await service['codeExchange'](mockClient, mockCode, mockRedirectUri, mockDone);\n\n      expect(cacheService.get).toHaveBeenCalledWith(`oauth:code:${mockCode}`);\n      expect(mockDone).toHaveBeenCalledWith(new Error('Some error'));\n    });\n  });\n\n  describe('refreshTokenExchange', () => {\n    let mockDone: Mock;\n    let mockFindAccessToken: MockInstance;\n    let mockGenerateAccessToken: MockInstance;\n    let mockGetRefreshToken: MockInstance;\n    let mockGetRefreshTokenExpireTime: MockInstance;\n    let mockUpdateRefreshToken: MockInstance;\n    let mockFindAuthorized: MockInstance;\n\n    beforeEach(() => {\n      mockDone = vitest.fn();\n      mockFindAccessToken = prismaService.txClient().accessToken.findUnique as any;\n      mockGenerateAccessToken = vitest.spyOn(service as any, 'generateAccessToken');\n      mockGetRefreshToken = vitest.spyOn(service as any, 'getRefreshToken');\n      mockGetRefreshTokenExpireTime = vitest.spyOn(service as any, 'getRefreshTokenExpireTime');\n      mockUpdateRefreshToken = prismaService.txClient().oAuthAppToken.update as any;\n      mockFindAuthorized = prismaService.txClient().oAuthAppAuthorized.findUnique as any;\n    });\n\n    afterEach(() => {\n      mockGetRefreshTokenExpireTime?.mockReset();\n      mockFindAccessToken?.mockReset();\n      mockGetRefreshToken?.mockReset();\n      mockGenerateAccessToken?.mockReset();\n      mockUpdateRefreshToken?.mockReset();\n      mockDone.mockReset();\n    });\n\n    it('should refresh token successfully', async () => {\n      const client = {\n        type: 'secret',\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n      } as const;\n      const refreshToken = 'validRefreshToken';\n\n      const verifiedToken = {\n        clientId: 'client1',\n        secret: 'secret',\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      };\n\n      const oldAccessToken = {\n        userId: 'userId',\n        scopes: JSON.stringify(['user|email_read']),\n      };\n\n      const newAccessToken = { token: 'newAccessToken', id: 'newAccessTokenId' };\n      const newRefreshToken = 'newRefreshToken';\n      jwtService.verifyAsync.mockResolvedValue(verifiedToken);\n      mockGenerateAccessToken.mockResolvedValue(newAccessToken);\n      mockGetRefreshToken.mockResolvedValue(newRefreshToken);\n      mockFindAccessToken.mockResolvedValue(oldAccessToken);\n      mockUpdateRefreshToken.mockResolvedValue({ refreshTokenSign: 'refreshTokenSign' });\n      mockFindAuthorized.mockResolvedValueOnce({\n        clientId: client.clientId,\n        userId: 'userId',\n      });\n      await service['refreshTokenExchange'](client, refreshToken, mockDone);\n\n      expect(jwtService.verifyAsync).toHaveBeenCalledWith(refreshToken);\n      expect(prismaService.txClient().accessToken.findUnique).toHaveBeenCalledWith({\n        where: { id: verifiedToken.accessTokenId },\n      });\n      expect(service['generateAccessToken']).toHaveBeenCalledWith({\n        clientId: client.clientId,\n        clientName: client.name,\n        userId: oldAccessToken.userId,\n        scopes: ['user|email_read'],\n      });\n      expect(prismaService.txClient().oAuthAppToken.update).toHaveBeenCalledWith({\n        where: {\n          clientId: client.clientId,\n          refreshTokenSign: verifiedToken.sign,\n          appSecretId: client.secretId,\n        },\n        data: {\n          refreshTokenSign: expect.any(String),\n          expiredTime: expect.any(String),\n        },\n        select: {\n          refreshTokenSign: true,\n        },\n      });\n      expect(service['getRefreshToken']).toHaveBeenCalledWith(\n        client,\n        newAccessToken.id,\n        'refreshTokenSign'\n      );\n      expect(mockDone).toHaveBeenCalledWith(null, newAccessToken.token, newRefreshToken, {\n        scopes: ['user|email_read'],\n        expires_in: expect.any(Number),\n        refresh_expires_in: expect.any(Number),\n      });\n    });\n\n    it('should return unauthorized exception for invalid client', async () => {\n      const client = {\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n        type: 'secret',\n      } as const;\n      const refreshToken = 'validRefreshToken';\n\n      const verifiedToken = {\n        clientId: 'client2', // Invalid clientId\n        secret: 'secret',\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      };\n\n      jwtService.verifyAsync.mockResolvedValue(verifiedToken);\n\n      await service['refreshTokenExchange'](client, refreshToken, mockDone);\n\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid client'));\n    });\n\n    it('should return unauthorized exception for invalid secret', async () => {\n      const client = {\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n        type: 'secret',\n      } as const;\n      const refreshToken = 'validRefreshToken';\n\n      const verifiedToken = {\n        clientId: 'client1',\n        secret: 'invalidSecret', // Invalid secret\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      };\n\n      jwtService.verifyAsync.mockResolvedValue(verifiedToken);\n      mockFindAuthorized.mockResolvedValueOnce({\n        clientId: client.clientId,\n        userId: 'userId',\n      });\n      await service['refreshTokenExchange'](client, refreshToken, mockDone);\n\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid secret'));\n    });\n\n    it('should return unauthorized exception for invalid access token', async () => {\n      const client = {\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n        type: 'secret',\n      } as const;\n      const refreshToken = 'validRefreshToken';\n\n      const verifiedToken = {\n        clientId: 'client1',\n        secret: 'secret',\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      };\n\n      jwtService.verifyAsync.mockResolvedValue(verifiedToken);\n\n      await service['refreshTokenExchange'](client, refreshToken, mockDone);\n\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid access token'));\n    });\n\n    it('should catch and return error', async () => {\n      const client = {\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n        type: 'secret',\n      } as const;\n      const refreshToken = 'validRefreshToken';\n\n      const verifiedToken = {\n        clientId: 'client1',\n        secret: 'secret',\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      };\n      const mockAccessToken = { id: 'accessTokenId', token: 'accessToken' };\n      jwtService.verifyAsync.mockResolvedValue(verifiedToken);\n      mockFindAccessToken.mockResolvedValueOnce({\n        userId: 'userId',\n        scopes: JSON.stringify(['user|email_read']),\n      });\n      mockFindAuthorized.mockResolvedValueOnce({\n        clientId: client.clientId,\n        userId: 'userId',\n      });\n      mockGenerateAccessToken.mockResolvedValue(mockAccessToken);\n      mockUpdateRefreshToken.mockRejectedValueOnce(new Error('Database error'));\n\n      await service['refreshTokenExchange'](client, refreshToken, mockDone);\n\n      expect(mockDone).toHaveBeenCalledWith(new UnauthorizedException('Invalid refresh token'));\n    });\n  });\n\n  describe('checkTokenRateLimit', () => {\n    it('should pass when request count is within limit', async () => {\n      cacheService.incr.mockResolvedValue(15);\n      await expect(service['checkTokenRateLimit']('clientId', 'userId')).resolves.toBeUndefined();\n      expect(cacheService.incr).toHaveBeenCalledWith(\n        'oauth:token-rate:clientId:userId',\n        expect.any(Number)\n      );\n    });\n\n    it('should pass when request count equals the limit', async () => {\n      cacheService.incr.mockResolvedValue(30);\n      await expect(service['checkTokenRateLimit']('clientId', 'userId')).resolves.toBeUndefined();\n    });\n\n    it('should throw when request count exceeds the limit', async () => {\n      cacheService.incr.mockResolvedValue(31);\n      await expect(service['checkTokenRateLimit']('clientId', 'userId')).rejects.toThrow(\n        new CustomHttpException(\n          'Token request rate limit exceeded, please try again later',\n          HttpErrorCode.TOO_MANY_REQUESTS\n        )\n      );\n    });\n\n    it('should use clientId:userId as the rate limit key', async () => {\n      cacheService.incr.mockResolvedValue(1);\n      await service['checkTokenRateLimit']('app-1', 'user-abc');\n      expect(cacheService.incr).toHaveBeenCalledWith(\n        'oauth:token-rate:app-1:user-abc',\n        expect.any(Number)\n      );\n    });\n  });\n\n  describe('codeExchange rate limit', () => {\n    it('should reject code exchange when rate limited', async () => {\n      const mockDone = vitest.fn();\n      const mockCodeState = {\n        clientId: 'clientId',\n        redirectUri: 'http://redirect.uri',\n        user: { id: 'userId' },\n        scopes: ['user|email_read'],\n      };\n      cacheService.get.mockResolvedValue(mockCodeState);\n      cacheService.incr.mockResolvedValue(31);\n\n      await service['codeExchange'](\n        { clientId: 'clientId', name: 'clientName', secretId: 'secretId' },\n        'code',\n        'http://redirect.uri',\n        mockDone\n      );\n\n      expect(cacheService.incr).toHaveBeenCalledWith(\n        'oauth:token-rate:clientId:userId',\n        expect.any(Number)\n      );\n      expect(mockDone).toHaveBeenCalledWith(\n        expect.objectContaining({\n          message: 'Token request rate limit exceeded, please try again later',\n        })\n      );\n    });\n  });\n\n  describe('refreshTokenExchange rate limit', () => {\n    it('should reject refresh token exchange when rate limited', async () => {\n      const mockDone = vitest.fn();\n\n      const client = {\n        clientId: 'client1',\n        clientSecret: 'secret',\n        name: 'testApp',\n        secretId: 'secretId',\n        type: 'secret',\n      } as const;\n\n      jwtService.verifyAsync.mockResolvedValue({\n        clientId: 'client1',\n        secret: 'secret',\n        accessTokenId: 'accessTokenId',\n        sign: 'sign',\n      });\n      (prismaService.txClient().accessToken.findUnique as any).mockResolvedValue({\n        userId: 'userId',\n        scopes: JSON.stringify(['user|email_read']),\n      });\n      cacheService.incr.mockResolvedValue(31);\n\n      await service['refreshTokenExchange'](client, 'refreshToken', mockDone);\n\n      expect(cacheService.incr).toHaveBeenCalledWith(\n        'oauth:token-rate:client1:userId',\n        expect.any(Number)\n      );\n      expect(mockDone).toHaveBeenCalledWith(\n        expect.objectContaining({\n          message: 'Token request rate limit exceeded, please try again later',\n        })\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth-server.service.ts",
    "content": "import {\n  BadRequestException,\n  HttpException,\n  Injectable,\n  Logger,\n  NotFoundException,\n  UnauthorizedException,\n} from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { getRandomString, HttpErrorCode, nullsToUndefined } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { DecisionInfoGetVo } from '@teable/openapi';\nimport type { Response, Request } from 'express';\nimport { difference, pick } from 'lodash';\nimport ms from 'ms';\nimport type {\n  IssueGrantCodeFunction,\n  IssueExchangeCodeFunction,\n  ImmediateFunction,\n  ExchangeDoneFunction,\n  OAuth2,\n  ValidateFunctionArity2,\n} from 'oauth2orize';\nimport oauth2orize, { AuthorizationError } from 'oauth2orize';\nimport { CacheService } from '../../cache/cache.service';\nimport type { IOAuthCodeState } from '../../cache/types';\nimport { IOAuthConfig, OAuthConfig } from '../../configs/oauth.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { second } from '../../utils/second';\nimport { AccessTokenService } from '../access-token/access-token.service';\nimport { OAuthTxStore } from './oauth-tx-store';\nimport { PkceService } from './pkce.service';\nimport type { IAuthorizeClient, ITokenClient, IOAuth2Server, IAuthorizeRequest } from './types';\n\n@Injectable()\nexport class OAuthServerService {\n  private readonly logger = new Logger(OAuthServerService.name);\n  server: IOAuth2Server;\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cacheService: CacheService,\n    private readonly accessTokenService: AccessTokenService,\n    private readonly jwtService: JwtService,\n    private readonly oauthTxStore: OAuthTxStore,\n    private readonly pkceService: PkceService,\n    @OAuthConfig() private readonly oauth2Config: IOAuthConfig\n  ) {\n    this.server = oauth2orize.createServer({\n      store: this.oauthTxStore,\n    });\n    this.server.grant(oauth2orize.grant.code(this.codeGrant));\n    // eslint-disable-next-line @typescript-eslint/no-var-requires\n    this.server.grant(require('oauth2orize-pkce').extensions());\n    this.server.exchange(oauth2orize.exchange.code(this.codeExchange));\n    (this.server as unknown as IOAuth2Server<ITokenClient>).exchange(\n      oauth2orize.exchange.refreshToken(this.refreshTokenExchange)\n    );\n  }\n\n  private async getAuthorizedTime(userId: string, clientId: string) {\n    const authorizedTime = await this.prismaService\n      .txClient()\n      .oAuthAppAuthorized.findUnique({\n        where: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          clientId_userId: {\n            clientId,\n            userId,\n          },\n        },\n        select: {\n          authorizedTime: true,\n        },\n      })\n      .then((data) => data?.authorizedTime);\n    // validate authorized time is not expired\n    return (\n      authorizedTime &&\n      new Date(authorizedTime).getTime() + ms(this.oauth2Config.authorizedExpireIn) > Date.now()\n    );\n  }\n\n  private handleError(error: unknown | undefined) {\n    if (error instanceof AuthorizationError) {\n      return new HttpException(error.message, Number(error.status));\n    }\n    return error;\n  }\n\n  private async checkTokenRateLimit(clientId: string, userId: string) {\n    const { tokenRateLimit, tokenRateWindow } = this.oauth2Config;\n    if (tokenRateLimit <= 0) {\n      return;\n    }\n    const cacheKey = `oauth:token-rate:${clientId}:${userId}` as const;\n    const count = await this.cacheService.incr(cacheKey, second(tokenRateWindow));\n    if (count > tokenRateLimit) {\n      this.logger.warn(\n        `OAuth token rate limit exceeded for client ${clientId} user ${userId}: ${count}/${tokenRateLimit}`\n      );\n      throw new CustomHttpException(\n        `Token request rate limit exceeded, please try again later`,\n        HttpErrorCode.TOO_MANY_REQUESTS\n      );\n    }\n  }\n\n  private validateRedirectUri(\n    redirectUri: string,\n    redirectUris: string[],\n    type: 'pkce' | 'secret'\n  ) {\n    if (\n      type === 'pkce' &&\n      redirectUris.some((uri) => this.pkceService.isLoopbackMatch(uri, redirectUri))\n    ) {\n      return;\n    }\n    if (type === 'secret' && redirectUris.includes(redirectUri)) {\n      return;\n    }\n    throw new UnauthorizedException('Invalid redirectUri');\n  }\n\n  private authorizeValidate: ValidateFunctionArity2<IAuthorizeClient> = async (areq, done) => {\n    const {\n      clientID: clientId,\n      redirectURI,\n      scope: queryScopes,\n      codeChallenge,\n      codeChallengeMethod,\n    } = areq as IAuthorizeRequest;\n    try {\n      const { redirectUris, scopes } = await this.getOAuthApp(clientId);\n      // validate scopes if get scopes from user\n      const invalidScopes = difference(queryScopes, scopes);\n      if (invalidScopes.length > 0) {\n        return done(new BadRequestException('Invalid scopes: ' + invalidScopes.join(',')));\n      }\n\n      // valid redirectUri\n      if (!redirectUris.length) {\n        return done(new BadRequestException('Redirect uri not configured'));\n      }\n      const redirectUri = redirectURI || redirectUris[0];\n      const clientScopes = queryScopes ?? scopes;\n      if (codeChallenge) {\n        if (codeChallengeMethod !== 'S256') {\n          return done(new BadRequestException('Invalid code challenge method'));\n        }\n        if (!this.pkceService.isValidCodeChallenge(codeChallenge)) {\n          return done(new BadRequestException('Invalid code challenge'));\n        }\n        this.validateRedirectUri(redirectUri, redirectUris, 'pkce');\n        return done(\n          null,\n          {\n            clientId,\n            scopes: clientScopes,\n            redirectUri,\n            codeChallenge,\n            codeChallengeMethod,\n          },\n          redirectUri\n        );\n      }\n      // valid redirectUri\n      this.validateRedirectUri(redirectUri, redirectUris, 'secret');\n      done(\n        null,\n        {\n          clientId,\n          scopes: clientScopes,\n          redirectUri,\n        },\n        redirectUri\n      );\n    } catch (error) {\n      done(error as Error);\n    }\n  };\n\n  private authorizeImmediate: ImmediateFunction<IAuthorizeClient> = async (\n    client,\n    user,\n    _scope,\n    _type,\n    _areq,\n    done\n  ) => {\n    const isTrusted = await this.getAuthorizedTime(user.id, client.clientId);\n    if (isTrusted) {\n      await this.touchAuthorize(client.clientId, user.id);\n      return done(null, true, undefined, undefined);\n    }\n    return done(null, false, undefined, undefined);\n  };\n\n  async authorize(req: Request, res: Response) {\n    return new Promise<void>((resolve, reject) => {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      (this.server as any).authorization(this.authorizeValidate, this.authorizeImmediate)(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        req as any,\n        res,\n        (error: unknown) => {\n          if (error) {\n            return reject(this.handleError(error));\n          }\n          res.redirect(\n            `/oauth/decision?transaction_id=${\n              (req as Request & { oauth2: { transactionID: string } }).oauth2.transactionID\n            }`\n          );\n          resolve();\n        }\n      );\n    });\n  }\n\n  async token(req: Request, res: Response) {\n    return new Promise<void>((resolve, reject) => {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      this.server.token()(req as any, res, (error) => {\n        if (error) {\n          return reject(this.handleError(error));\n        }\n        resolve();\n      });\n    });\n  }\n\n  private decisionComplete = async (_req: unknown, oauth2: OAuth2, cb: (err?: unknown) => void) => {\n    // complete the transaction\n    await this.touchAuthorize(oauth2.req.clientID, oauth2.user.id)\n      .then(() => cb())\n      .catch(cb);\n  };\n\n  private touchAuthorize = async (clientId: string, userId: string) => {\n    await this.prismaService.oAuthAppAuthorized.upsert({\n      where: {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        clientId_userId: {\n          clientId: clientId,\n          userId: userId,\n        },\n      },\n      create: {\n        clientId: clientId,\n        userId: userId,\n        authorizedTime: new Date().toISOString(),\n      },\n      update: {\n        authorizedTime: new Date().toISOString(),\n      },\n    });\n  };\n\n  async decision(req: Request, res: Response) {\n    return new Promise<void>((resolve, reject) => {\n      // this.decision() return an array of middleware\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const fns: Array<ReturnType<IOAuth2Server['decision']>> = (this.server as any).decision(\n        undefined,\n        undefined,\n        this.decisionComplete\n      );\n      // transactionLoader loads oauth data into req.oauth2\n      const transactionLoader = fns[0];\n      const decisionFn = fns[1];\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      transactionLoader(req as any, res, (error) => {\n        if (error) {\n          return reject(this.handleError(error));\n        }\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        decisionFn(req as any, res, async (error) => {\n          if (error) {\n            return reject(this.handleError(error));\n          }\n          resolve();\n        });\n      });\n    });\n  }\n\n  private async getOAuthApp(clientId: string) {\n    const data = await this.prismaService\n      .txClient()\n      .oAuthApp.findUniqueOrThrow({\n        where: {\n          clientId,\n        },\n      })\n      .catch((error) => {\n        throw new UnauthorizedException(error.message);\n      });\n    return nullsToUndefined({\n      ...data,\n      redirectUris: data.redirectUris ? (JSON.parse(data.redirectUris) as string[]) : [],\n      scopes: data.scopes ? (JSON.parse(data.scopes) as string[]) : [],\n    });\n  }\n\n  private codeGrant: IssueGrantCodeFunction = async (client, _redirectUri, user, _ares, done) => {\n    const { clientId } = await this.getOAuthApp(client.clientId);\n    const code = getRandomString(16);\n    // save code\n    await this.cacheService.set(\n      `oauth:code:${code}`,\n      {\n        clientId,\n        redirectUri: client.redirectUri,\n        scopes: client.scopes,\n        user: pick(user, ['id', 'email', 'name']),\n        codeChallenge: client.codeChallenge,\n        codeChallengeMethod: client.codeChallengeMethod,\n      },\n      this.oauth2Config.codeExpireIn\n    );\n    done(null, code);\n  };\n\n  private generateAccessToken({\n    userId,\n    scopes,\n    clientId,\n    clientName,\n  }: {\n    userId: string;\n    scopes: string[];\n    clientId: string;\n    clientName: string;\n  }) {\n    return this.accessTokenService.createAccessToken({\n      clientId,\n      name: `oauth:${clientName}`,\n      scopes,\n      userId,\n      // 10 minutes\n      expiredTime: new Date(Date.now() + ms(this.oauth2Config.accessTokenExpireIn)).toISOString(),\n    });\n  }\n\n  private getRefreshToken(client: ITokenClient, accessTokenId: string, sign: string) {\n    const payload =\n      client.type === 'pkce'\n        ? { clientId: client.clientId, accessTokenId, sign }\n        : { clientId: client.clientId, secret: client.clientSecret, accessTokenId, sign };\n    return this.jwtService.signAsync(payload, {\n      expiresIn: this.oauth2Config.refreshTokenExpireIn,\n    });\n  }\n\n  private getRefreshTokenExpireTime() {\n    return new Date(Date.now() + ms(this.oauth2Config.refreshTokenExpireIn)).toISOString();\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private verifyExchangeClient(client: ITokenClient, state: IOAuthCodeState) {\n    // code_challenge was set during authorize — code_verifier is required\n    if (client.type === 'pkce') {\n      if (!client.codeVerifier) {\n        throw new BadRequestException('code_verifier is required');\n      }\n      if (!this.pkceService.isValidCodeVerifier(client.codeVerifier)) {\n        throw new BadRequestException('Invalid code_verifier format');\n      }\n      if (!state.codeChallenge) {\n        throw new BadRequestException('code_challenge is required');\n      }\n      if (!state.codeChallengeMethod || state.codeChallengeMethod !== 'S256') {\n        throw new BadRequestException('Invalid code_challenge method');\n      }\n      const valid = this.pkceService.validateCodeVerifier(\n        state.codeChallenge,\n        state.codeChallengeMethod,\n        client.codeVerifier\n      );\n      if (!valid) {\n        throw new UnauthorizedException('Invalid code_verifier');\n      }\n    } else if (client.type === 'secret') {\n      if (!client.clientSecret) {\n        throw new BadRequestException('client_secret is required');\n      }\n      // RFC 7636: once code_challenge is sent, code_verifier must be provided\n      if (state.codeChallenge) {\n        throw new BadRequestException('code_verifier is required for PKCE flow');\n      }\n    } else {\n      throw new BadRequestException('Invalid client type');\n    }\n  }\n\n  private codeExchange: IssueExchangeCodeFunction = async (client, code, redirectUri, done) => {\n    await this.prismaService\n      .$tx(async () => {\n        const codeState = await this.cacheService.get(`oauth:code:${code}`);\n        if (!codeState) {\n          return done(new UnauthorizedException('Invalid code'));\n        }\n        await this.cacheService.del(`oauth:code:${code}`);\n        await this.checkTokenRateLimit(client.clientId, codeState.user.id);\n\n        if (codeState.clientId !== client.clientId) {\n          return done(new UnauthorizedException('Invalid client'));\n        }\n        if (!redirectUri) {\n          return done(new UnauthorizedException('redirect_uri is required'));\n        }\n        if (redirectUri !== codeState.redirectUri) {\n          return done(new UnauthorizedException('Invalid redirectUri'));\n        }\n        const tokenClient = client as ITokenClient;\n        this.verifyExchangeClient(tokenClient, codeState);\n\n        const accessToken = await this.generateAccessToken({\n          userId: codeState.user.id,\n          scopes: codeState.scopes,\n          clientId: client.clientId,\n          clientName: tokenClient.name,\n        });\n\n        const refreshTokenSign = getRandomString(16);\n        const appSecretId = tokenClient.secretId;\n        const refreshToken = await this.getRefreshToken(\n          tokenClient,\n          accessToken.id,\n          refreshTokenSign\n        );\n        await this.prismaService.txClient().oAuthAppToken.create({\n          data: {\n            clientId: client.clientId,\n            refreshTokenSign,\n            appSecretId: appSecretId,\n            createdBy: codeState.user.id,\n            expiredTime: this.getRefreshTokenExpireTime(),\n          },\n        });\n        done(null, accessToken.token, refreshToken, {\n          scopes: codeState.scopes,\n          expires_in: second(this.oauth2Config.accessTokenExpireIn),\n          refresh_expires_in: second(this.oauth2Config.refreshTokenExpireIn),\n        });\n      })\n      .catch((error) => done(error));\n  };\n\n  private refreshTokenExchange: (\n    client: ITokenClient,\n    refreshToken: string,\n    issued: ExchangeDoneFunction\n  ) => void = (client, refreshToken: string, done) => {\n    return this.prismaService\n      .$tx(async () => {\n        const decoded = await this.jwtService.verifyAsync<{\n          clientId: string;\n          secret?: string;\n          accessTokenId: string;\n          sign: string;\n        }>(refreshToken);\n\n        if (client.clientId !== decoded.clientId) {\n          return done(new UnauthorizedException('Invalid client'));\n        }\n        if ((client as ITokenClient & { clientSecret?: string })?.clientSecret !== decoded.secret) {\n          return done(new UnauthorizedException('Invalid secret'));\n        }\n\n        const oldAccessToken = await this.prismaService.txClient().accessToken.findUnique({\n          where: { id: decoded.accessTokenId },\n        });\n        if (!oldAccessToken) {\n          return done(new UnauthorizedException('Invalid access token'));\n        }\n        await this.checkTokenRateLimit(client.clientId, oldAccessToken.userId);\n\n        const authorized = await this.prismaService.txClient().oAuthAppAuthorized.findUnique({\n          where: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            clientId_userId: {\n              clientId: decoded.clientId,\n              userId: oldAccessToken.userId,\n            },\n          },\n        });\n        if (!authorized) {\n          return done(new UnauthorizedException('Invalid authorized'));\n        }\n\n        const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : [];\n        const accessToken = await this.generateAccessToken({\n          userId: oldAccessToken.userId,\n          scopes,\n          clientId: decoded.clientId,\n          clientName: client.name,\n        });\n\n        const oauthAppToken = await this.prismaService\n          .txClient()\n          .oAuthAppToken.update({\n            where: {\n              clientId: decoded.clientId,\n              refreshTokenSign: decoded.sign,\n              appSecretId: client.secretId,\n            },\n            data: {\n              refreshTokenSign: getRandomString(16),\n              expiredTime: this.getRefreshTokenExpireTime(),\n            },\n            select: { refreshTokenSign: true },\n          })\n          .catch(() => {\n            throw new UnauthorizedException('Invalid refresh token');\n          });\n\n        const newRefreshToken = await this.getRefreshToken(\n          client,\n          accessToken.id,\n          oauthAppToken.refreshTokenSign\n        );\n        done(null, accessToken.token, newRefreshToken, {\n          scopes,\n          expires_in: second(this.oauth2Config.accessTokenExpireIn),\n          refresh_expires_in: second(this.oauth2Config.refreshTokenExpireIn),\n        });\n      })\n      .catch((error) => done(error));\n  };\n\n  async getDecisionInfo(req: Request, transactionId: string) {\n    req.body['transaction_id'] = transactionId;\n    return new Promise<DecisionInfoGetVo>((resolve, reject) => {\n      this.oauthTxStore.load(req, async (err, txn) => {\n        if (err) {\n          reject(err);\n        } else {\n          const clientId = txn!.req.clientID;\n          const oauthApp = await this.getOAuthApp(clientId);\n          if (!oauthApp) {\n            return reject(new NotFoundException('Client not found'));\n          }\n          resolve({\n            name: oauthApp.name,\n            description: oauthApp.description ?? undefined,\n            homepage: oauthApp.homepage,\n            logo: oauthApp.logo ?? undefined,\n            scopes: txn!.req.scope,\n          });\n        }\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth-tx-store.ts",
    "content": "import { BadRequestException, Injectable } from '@nestjs/common';\nimport { getRandomString } from '@teable/core';\nimport type { IUserMeVo } from '@teable/openapi';\nimport type { Request } from 'express';\nimport type { OAuth2, OAuth2Req } from 'oauth2orize';\nimport { CacheService } from '../../cache/cache.service';\nimport { IOAuthConfig, OAuthConfig } from '../../configs/oauth.config';\nimport type { IAuthorizeClient } from './types';\n\n@Injectable()\nexport class OAuthTxStore {\n  constructor(\n    private readonly cacheService: CacheService,\n    @OAuthConfig() private readonly oauth2Config: IOAuthConfig\n  ) {}\n\n  async load(req: Request, cb: (err: unknown, txn?: OAuth2<IAuthorizeClient, IUserMeVo>) => void) {\n    const transactionID = req.body?.['transaction_id'];\n    if (!transactionID) {\n      return cb(new BadRequestException('transaction_id is required'));\n    }\n\n    const txnStore = await this.cacheService.get(`oauth:txn:${transactionID}`);\n    if (!txnStore) {\n      return cb(new BadRequestException('Invalid transaction ID'));\n    }\n    const user = req.user as IUserMeVo;\n    if (txnStore.userId !== user.id) {\n      return cb(new BadRequestException('Invalid user'));\n    }\n    cb(null, {\n      transactionID,\n      redirectURI: txnStore.redirectURI,\n      client: {\n        clientId: txnStore.clientId,\n        redirectUri: txnStore.redirectURI,\n        scopes: txnStore.scopes,\n        codeChallenge: txnStore.codeChallenge,\n        codeChallengeMethod: txnStore.codeChallengeMethod as 'S256',\n      },\n      req: {\n        clientID: txnStore.clientId,\n        transactionID,\n        type: txnStore.type,\n        scope: txnStore.scopes,\n        state: txnStore.state!,\n        redirectURI: txnStore.redirectURI,\n      },\n      user,\n      info: {\n        scope: txnStore.scopes.join(' '),\n      },\n    });\n  }\n\n  async store(\n    req: Request,\n    txn: {\n      client: IAuthorizeClient;\n      redirectURI: string;\n      req: OAuth2Req;\n    },\n    cb: (err: unknown, transactionID: string) => void\n  ) {\n    const transactionID = getRandomString(16);\n    const { redirectURI, client } = txn;\n    await this.cacheService.set(\n      `oauth:txn:${transactionID}`,\n      {\n        clientId: client.clientId,\n        redirectURI,\n        type: txn.req.type,\n        scopes: client.scopes,\n        state: txn.req.state,\n        userId: (req.user as IUserMeVo).id,\n        codeChallenge: client.codeChallenge,\n        codeChallengeMethod: client.codeChallengeMethod,\n      },\n      this.oauth2Config.transactionExpireIn\n    );\n\n    cb(null, transactionID);\n  }\n\n  async remove(_req: unknown, transactionID: string) {\n    await this.cacheService.del(`oauth:txn:${transactionID}`);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { OAuthController } from './oauth.controller';\nimport { OAuthModule } from './oauth.module';\n\ndescribe('OauthController', () => {\n  let controller: OAuthController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, OAuthModule],\n      controllers: [OAuthController],\n    }).compile();\n\n    controller = module.get<OAuthController>(OAuthController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth.controller.ts",
    "content": "import {\n  BadRequestException,\n  Body,\n  Controller,\n  Delete,\n  Get,\n  HttpCode,\n  Param,\n  Post,\n  Put,\n} from '@nestjs/common';\nimport {\n  OAuthCreateRo,\n  OAuthUpdateRo,\n  oauthCreateRoSchema,\n  oauthUpdateRoSchema,\n} from '@teable/openapi';\nimport type {\n  AuthorizedVo,\n  GenerateOAuthSecretVo,\n  OAuthCreateVo,\n  OAuthGetListVo,\n  OAuthGetVo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { TokenAccess } from '../auth/decorators/token.decorator';\nimport { OAuthService } from './oauth.service';\n\n@Controller('/api/oauth/client')\nexport class OAuthController {\n  constructor(\n    private readonly oauthService: OAuthService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Get(':clientId')\n  async getOAuth(@Param('clientId') clientId: string): Promise<OAuthGetVo> {\n    return this.oauthService.getOAuth(clientId);\n  }\n\n  @Get()\n  async getOAuthList(): Promise<OAuthGetListVo> {\n    return this.oauthService.getOAuthList();\n  }\n\n  @Post()\n  async createOAuth(\n    @Body(new ZodValidationPipe(oauthCreateRoSchema)) oauthCreateRo: OAuthCreateRo\n  ): Promise<OAuthCreateVo> {\n    return this.oauthService.createOAuth(oauthCreateRo);\n  }\n\n  @Put(':clientId')\n  async updateOAuth(\n    @Param('clientId') clientId: string,\n    @Body(new ZodValidationPipe(oauthUpdateRoSchema)) oauthUpdateRo: OAuthUpdateRo\n  ): Promise<OAuthCreateVo> {\n    return this.oauthService.updateOAuth(clientId, oauthUpdateRo);\n  }\n\n  @Delete(':clientId')\n  async deleteOAuth(@Param('clientId') clientId: string): Promise<void> {\n    return this.oauthService.deleteOAuth(clientId);\n  }\n\n  @Post(':clientId/secret')\n  async generateOAuthSecret(@Param('clientId') clientId: string): Promise<GenerateOAuthSecretVo> {\n    return this.oauthService.generateSecret(clientId);\n  }\n\n  @Delete(':clientId/secret/:secretId')\n  async deleteOAuthSecret(\n    @Param('clientId') clientId: string,\n    @Param('secretId') secretId: string\n  ): Promise<void> {\n    return this.oauthService.deleteSecret(clientId, secretId);\n  }\n\n  @Post(':clientId/revoke-access')\n  @HttpCode(200)\n  async revokeAccess(@Param('clientId') clientId: string) {\n    return this.oauthService.revokeAccess(clientId);\n  }\n\n  @Post(':clientId/revoke-token')\n  @HttpCode(200)\n  async revokeToken(@Param('clientId') clientId: string) {\n    return this.oauthService.revokeToken(clientId);\n  }\n\n  @Get(':clientId/revoke-token')\n  @TokenAccess()\n  async revokeTokenGet(@Param('clientId') clientId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (!accessTokenId) {\n      throw new BadRequestException('only access token request can use this endpoint');\n    }\n    return this.oauthService.revokeToken(clientId);\n  }\n\n  @Get('authorized/list')\n  async getAuthorizedList(): Promise<AuthorizedVo[]> {\n    return this.oauthService.getAuthorizedList();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport { PassportModule } from '@nestjs/passport';\nimport { authConfig, type IAuthConfig } from '../../configs/auth.config';\nimport { AccessTokenModule } from '../access-token/access-token.module';\nimport { OAuthServerController } from './oauth-server.controller';\nimport { OAuthServerService } from './oauth-server.service';\nimport { OAuthTxStore } from './oauth-tx-store';\nimport { OAuthController } from './oauth.controller';\nimport { OAuthService } from './oauth.service';\nimport { PkceService } from './pkce.service';\nimport { OAuthClientStrategy } from './strategies/oauth2-client.strategies';\nimport { OAuthPkceClientStrategy } from './strategies/oauth2-pkce-client.strategy';\n\n@Module({\n  imports: [\n    AccessTokenModule,\n    PassportModule.register({ session: true }),\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n  ],\n  controllers: [OAuthController, OAuthServerController],\n  providers: [\n    OAuthServerService,\n    OAuthService,\n    OAuthClientStrategy,\n    OAuthPkceClientStrategy,\n    OAuthTxStore,\n    PkceService,\n  ],\n  exports: [OAuthService],\n})\nexport class OAuthModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { OAuthModule } from './oauth.module';\nimport { OAuthService } from './oauth.service';\n\ndescribe('OauthService', () => {\n  let service: OAuthService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, OAuthModule],\n      providers: [OAuthService],\n    }).compile();\n\n    service = module.get<OAuthService>(OAuthService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/oauth.service.ts",
    "content": "import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';\nimport { generateClientId, getRandomString, nullsToUndefined } from '@teable/core';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport type {\n  AuthorizedVo,\n  GenerateOAuthSecretVo,\n  OAuthCreateRo,\n  OAuthCreateVo,\n  OAuthGetListVo,\n  OAuthGetVo,\n  OAuthUpdateVo,\n} from '@teable/openapi';\nimport * as bcrypt from 'bcrypt';\nimport { pick } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\n\n@Injectable()\nexport class OAuthService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  private convertToVo<T extends { scopes?: string | null; redirectUris?: string | null }>(ro: T) {\n    return nullsToUndefined({\n      ...ro,\n      scopes: ro.scopes ? JSON.parse(ro.scopes) : undefined,\n      redirectUris: ro.redirectUris ? JSON.parse(ro.redirectUris) : undefined,\n    });\n  }\n\n  async createOAuth(ro: OAuthCreateRo): Promise<OAuthCreateVo> {\n    const userId = this.cls.get('user.id');\n    const { redirectUris, name, description, scopes, homepage, logo } = ro;\n    const res = await this.prismaService.oAuthApp.create({\n      data: {\n        name,\n        description,\n        scopes: scopes ? JSON.stringify(scopes) : null,\n        homepage,\n        logo,\n        redirectUris: redirectUris ? JSON.stringify(redirectUris) : null,\n        createdBy: userId,\n        clientId: generateClientId(),\n      },\n    });\n    return this.convertToVo(\n      pick(res, [\n        'id',\n        'name',\n        'description',\n        'scopes',\n        'homepage',\n        'logo',\n        'redirectUris',\n        'clientId',\n      ])\n    );\n  }\n\n  private getSecrets = async (clientId: string) => {\n    const secrets = await this.prismaService.oAuthAppSecret.findMany({\n      where: {\n        clientId,\n      },\n      orderBy: {\n        createdTime: 'desc',\n      },\n    });\n    if (!secrets.length) {\n      return;\n    }\n    return secrets.map((s) => ({\n      id: s.id,\n      secret: s.maskedSecret,\n      lastUsedTime: s.lastUsedTime?.toISOString(),\n    }));\n  };\n\n  async getOAuth(clientId: string): Promise<OAuthGetVo> {\n    const res = await this.prismaService.oAuthApp.findUnique({\n      where: {\n        clientId,\n      },\n    });\n    if (!res) {\n      throw new NotFoundException('OAuth client not found');\n    }\n    const secrets = await this.getSecrets(clientId);\n    return this.convertToVo(\n      pick(\n        {\n          ...res,\n          secrets,\n        },\n        [\n          'id',\n          'name',\n          'description',\n          'scopes',\n          'homepage',\n          'logo',\n          'redirectUris',\n          'clientId',\n          'secrets',\n        ]\n      )\n    );\n  }\n\n  async updateOAuth(clientId: string, ro: OAuthCreateRo): Promise<OAuthUpdateVo> {\n    const { redirectUris, name, description, scopes, homepage, logo } = ro;\n    const res = await this.prismaService.oAuthApp.update({\n      where: {\n        clientId,\n      },\n      data: {\n        name,\n        description,\n        scopes: scopes ? JSON.stringify(scopes) : null,\n        homepage,\n        logo,\n        redirectUris: redirectUris ? JSON.stringify(redirectUris) : null,\n      },\n    });\n\n    const secrets = await this.getSecrets(clientId);\n\n    return this.convertToVo(\n      pick({ ...res, secrets }, [\n        'id',\n        'name',\n        'description',\n        'scopes',\n        'homepage',\n        'logo',\n        'redirectUris',\n        'clientId',\n      ])\n    );\n  }\n\n  async deleteOAuth(clientId: string): Promise<void> {\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.oAuthApp.delete({\n        where: {\n          clientId,\n        },\n      });\n      await prisma.accessToken.deleteMany({\n        where: {\n          clientId,\n        },\n      });\n    });\n  }\n\n  async getOAuthList(): Promise<OAuthGetListVo> {\n    const userId = this.cls.get('user.id');\n    const res = await this.prismaService.oAuthApp.findMany({\n      where: {\n        createdBy: userId,\n      },\n      select: {\n        clientId: true,\n        name: true,\n        logo: true,\n        homepage: true,\n        description: true,\n      },\n    });\n    return nullsToUndefined(res);\n  }\n\n  async generateSecret(clientId: string): Promise<GenerateOAuthSecretVo> {\n    const secret = getRandomString(40).toLocaleLowerCase();\n    const hashedSecret = await bcrypt.hash(secret, 10);\n\n    const sensitivePart = secret.slice(0, secret.length - 10);\n    const maskedSecret = secret.slice(0).replace(sensitivePart, '*'.repeat(sensitivePart.length));\n\n    const res = await this.prismaService.oAuthAppSecret.create({\n      data: {\n        clientId,\n        secret: hashedSecret,\n        maskedSecret,\n        createdBy: this.cls.get('user.id'),\n      },\n    });\n\n    return {\n      secret,\n      maskedSecret,\n      id: res.id,\n      lastUsedTime: res.lastUsedTime?.toISOString(),\n    };\n  }\n\n  async deleteSecret(clientId: string, secretId: string): Promise<void> {\n    await this.prismaService.oAuthAppSecret.delete({\n      where: {\n        id: secretId,\n        clientId,\n      },\n    });\n  }\n\n  async revokeAccess(clientId: string) {\n    // validate clientId is match with current user\n    const currentUserId = this.cls.get('user.id');\n    const app = await this.prismaService.oAuthApp.findFirst({\n      where: { clientId, createdBy: currentUserId },\n    });\n    if (!app) {\n      throw new ForbiddenException('No permission to revoke access: ' + clientId);\n    }\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.oAuthAppAuthorized.deleteMany({\n        where: { clientId },\n      });\n      await prisma.oAuthAppToken.deleteMany({\n        where: {\n          clientId,\n        },\n      });\n      // delete access token\n      await prisma.accessToken.deleteMany({\n        where: { clientId },\n      });\n    });\n  }\n\n  async revokeToken(clientId: string) {\n    const userId = this.cls.get('user.id');\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.oAuthAppAuthorized.delete({\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        where: { clientId_userId: { clientId, userId } },\n      });\n\n      await prisma.oAuthAppToken.deleteMany({\n        where: {\n          createdBy: userId,\n          clientId,\n        },\n      });\n\n      await prisma.accessToken.deleteMany({\n        where: { clientId, userId },\n      });\n    });\n  }\n\n  async getAuthorizedList(): Promise<AuthorizedVo[]> {\n    const userId = this.cls.get('user.id');\n    const authorized = await this.prismaService.oAuthAppAuthorized.findMany({\n      where: {\n        userId,\n      },\n      select: {\n        clientId: true,\n      },\n    });\n    if (authorized.length === 0) {\n      return [];\n    }\n    const clientIds = authorized.map((a) => a.clientId);\n    const client = await this.prismaService.oAuthApp.findMany({\n      where: {\n        clientId: { in: clientIds },\n      },\n    });\n    if (client.length === 0) {\n      return [];\n    }\n    // user map\n    const users = await this.prismaService.user.findMany({\n      where: {\n        id: { in: client.map((c) => c.createdBy) },\n      },\n      select: {\n        id: true,\n        email: true,\n        name: true,\n      },\n    });\n    const userMap = users.reduce(\n      (acc, u) => {\n        acc[u.id] = {\n          email: u.email,\n          name: u.name,\n        };\n        return acc;\n      },\n      {} as Record<string, { email: string; name: string }>\n    );\n\n    // last used time\n    const lastUsedTime = await this.prismaService.$queryRaw<\n      {\n        clientId: string;\n        lastUsedTime: string;\n      }[]\n    >(Prisma.sql`\n      WITH ranked_clients AS (\n          SELECT\n              client_id,\n              last_used_time,\n              ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY last_used_time DESC) AS rn\n          FROM oauth_app_secret\n          WHERE client_id IN (${Prisma.join(clientIds)})\n      )\n      SELECT client_id as clientId, last_used_time as lastUsedTime\n      FROM ranked_clients\n      WHERE rn = 1;\n    `);\n\n    const lastUsedTimeMap = lastUsedTime.reduce(\n      (acc, d) => {\n        acc[d.clientId] = d;\n        return acc;\n      },\n      {} as Record<string, { clientId: string; lastUsedTime: string }>\n    );\n\n    return client.map((c) =>\n      this.convertToVo({\n        clientId: c.clientId,\n        name: c.name,\n        description: c.description,\n        logo: c.logo,\n        homepage: c.homepage,\n        scopes: c.scopes,\n        lastUsedTime: lastUsedTimeMap[c.clientId]?.lastUsedTime,\n        createdUser: userMap[c.createdBy],\n      })\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/pkce.service.ts",
    "content": "import crypto from 'crypto';\nimport { Injectable } from '@nestjs/common';\n\nconst pkceMethod = 'S256' as const;\nconst pkceChallengePattern = /^[\\w-]{43,128}$/;\nconst pkceVerifierPattern = /^[\\w.~-]{43,128}$/;\n\nexport interface IPkceAuthorizeParams {\n  codeChallenge: string;\n  codeChallengeMethod: typeof pkceMethod;\n}\n\n@Injectable()\nexport class PkceService {\n  isValidCodeChallenge(codeChallenge: string): boolean {\n    return pkceChallengePattern.test(codeChallenge);\n  }\n\n  isValidCodeVerifier(codeVerifier: string): boolean {\n    return pkceVerifierPattern.test(codeVerifier);\n  }\n\n  validateCodeVerifier(\n    codeChallenge: string,\n    codeChallengeMethod: string | undefined,\n    codeVerifier: string\n  ): boolean {\n    if (codeChallengeMethod !== pkceMethod || !this.isValidCodeVerifier(codeVerifier)) {\n      return false;\n    }\n    const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');\n    if (hash.length !== codeChallenge.length) {\n      return false;\n    }\n    return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(codeChallenge));\n  }\n\n  isLoopbackMatch(registered: string, requested: string): boolean {\n    try {\n      const reg = new URL(registered);\n      const req = new URL(requested);\n      const loopbackHosts = ['127.0.0.1', '[::1]', 'localhost'];\n      if (\n        reg.protocol === req.protocol &&\n        loopbackHosts.includes(reg.hostname) &&\n        loopbackHosts.includes(req.hostname) &&\n        reg.pathname === req.pathname\n      ) {\n        return true; // ignore port for loopback\n      }\n      return registered === requested;\n    } catch {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/strategies/oauth2-client.strategies.ts",
    "content": "import { UnauthorizedException, Injectable } from '@nestjs/common';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport * as bcrypt from 'bcrypt';\nimport { Strategy } from 'passport-oauth2-client-password';\nimport type { IExchangeClient } from '../types';\n\n@Injectable()\nexport class OAuthClientStrategy extends PassportStrategy(Strategy) {\n  constructor(private readonly prismaService: PrismaService) {\n    super();\n  }\n\n  async validate(clientId: string, clientSecret: string): Promise<IExchangeClient> {\n    const oauthApp = await this.prismaService.txClient().oAuthApp.findUnique({\n      where: {\n        clientId,\n      },\n    });\n\n    if (!oauthApp) {\n      throw new UnauthorizedException('Client not found');\n    }\n\n    const secrets = await this.prismaService.txClient().oAuthAppSecret.findMany({\n      where: {\n        clientId,\n      },\n    });\n    if (!secrets.length) {\n      throw new UnauthorizedException('No secrets found for the given clientId');\n    }\n    for (const appSecret of secrets) {\n      const isMatch = await bcrypt.compare(clientSecret, appSecret.secret);\n      if (isMatch) {\n        // update last use\n        await this.prismaService.txClient().oAuthAppSecret.update({\n          where: {\n            id: appSecret.id,\n          },\n          data: {\n            lastUsedTime: new Date().toISOString(),\n          },\n        });\n        return {\n          type: 'secret',\n          name: oauthApp.name,\n          secretId: appSecret.id,\n          clientId: appSecret.clientId,\n          clientSecret: appSecret.secret,\n        };\n      }\n    }\n\n    throw new UnauthorizedException('Client secret invalid');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/strategies/oauth2-pkce-client.strategy.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { PassportStrategy } from '@nestjs/passport';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Request } from 'express';\nimport { Strategy } from 'passport';\nimport type { IPkceExchangeClient } from '../types';\n\nclass PkceClientPasswordStrategy extends Strategy {\n  override name = 'oauth2-pkce-client';\n  private _verify: (\n    clientId: string,\n    codeVerifier: string | undefined,\n    done: (err: unknown, client?: unknown) => void\n  ) => void;\n\n  constructor(\n    verify: (\n      clientId: string,\n      codeVerifier: string | undefined,\n      done: (err: unknown, client?: unknown) => void\n    ) => void\n  ) {\n    super();\n    this._verify = verify;\n  }\n\n  override authenticate(req: Request) {\n    const clientId = req.body?.['client_id'] as string | undefined;\n    const clientSecret = req.body?.['client_secret'] as string | undefined;\n    const codeVerifier = req.body?.['code_verifier'] as string | undefined;\n    if (clientSecret || !clientId) {\n      return this.fail('Not a PKCE request', 401);\n    }\n\n    this._verify(clientId, codeVerifier, (err, client) => {\n      if (err) {\n        return this.error(err as Error);\n      }\n      if (!client) {\n        return this.fail('authentication failed', 401);\n      }\n      this.success(client);\n    });\n  }\n}\n\n@Injectable()\nexport class OAuthPkceClientStrategy extends PassportStrategy(\n  PkceClientPasswordStrategy,\n  'oauth2-pkce-client'\n) {\n  constructor(private readonly prismaService: PrismaService) {\n    super();\n  }\n\n  async validate(clientId: string, codeVerifier: string | undefined): Promise<IPkceExchangeClient> {\n    const oauthApp = await this.prismaService.txClient().oAuthApp.findUnique({\n      where: { clientId },\n    });\n\n    if (!oauthApp) {\n      throw new UnauthorizedException('Client not found');\n    }\n    return {\n      type: 'pkce',\n      clientId,\n      name: oauthApp.name,\n      codeVerifier,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/oauth/types.ts",
    "content": "import type { IUserMeVo } from '@teable/openapi';\nimport type { OAuth2Req, OAuth2Server } from 'oauth2orize';\n\nexport interface IClientBase {\n  clientId: string;\n}\n\nexport interface IAuthorizeClient extends IClientBase {\n  isTrusted?: boolean;\n  scopes: string[];\n  redirectUri: string;\n  codeChallenge?: string;\n  codeChallengeMethod?: 'S256';\n}\n\nexport interface IExchangeClient extends IClientBase {\n  type: 'secret';\n  name: string;\n  secretId: string;\n  clientSecret: string;\n}\n\nexport interface IPkceExchangeClient extends IClientBase {\n  type: 'pkce';\n  name: string;\n  secretId?: string;\n  codeVerifier?: string;\n}\n\nexport type ITokenClient = IExchangeClient | IPkceExchangeClient;\n\nexport type IOAuth2Server<Client = IClientBase, User = IUserMeVo> = OAuth2Server<Client, User>;\n\nexport interface IOAuthStoreOption {\n  transactionField?: string;\n}\n\nexport interface IClient {\n  type: string;\n  clientID: string;\n  redirectURI: string;\n  scope: string[];\n  state?: string;\n}\n\nexport interface IAuthorizeRequest extends OAuth2Req {\n  codeChallenge?: string;\n  codeChallengeMethod?: 'S256';\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/organization/organization.controller.ts",
    "content": "import { Controller, Get } from '@nestjs/common';\nimport type {\n  IGetDepartmentListVo,\n  IGetDepartmentUserVo,\n  IOrganizationMeVo,\n} from '@teable/openapi';\n\n@Controller('api/organization')\nexport class OrganizationController {\n  @Get('me')\n  async getOrganizationMe(): Promise<IOrganizationMeVo> {\n    return null;\n  }\n\n  @Get('department-user')\n  async getDepartmentUsers(): Promise<IGetDepartmentUserVo> {\n    return {\n      users: [],\n      total: 0,\n    };\n  }\n\n  @Get('department')\n  async getDepartmentList(): Promise<IGetDepartmentListVo> {\n    return [];\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/organization/organization.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { OrganizationController } from './organization.controller';\n\n@Module({\n  controllers: [OrganizationController],\n})\nexport class OrganizationModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/pin/pin.controller.ts",
    "content": "import { Body, Controller, Delete, Get, Post, Put, Query } from '@nestjs/common';\nimport type { IGetPinListVo } from '@teable/openapi';\nimport {\n  AddPinRo,\n  DeletePinRo,\n  addPinRoSchema,\n  deletePinRoSchema,\n  UpdatePinOrderRo,\n  updatePinOrderRoSchema,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { PinService } from './pin.service';\n\n@Controller('api/pin')\nexport class PinController {\n  constructor(private readonly pinService: PinService) {}\n\n  @Post()\n  async add(@Body(new ZodValidationPipe(addPinRoSchema)) query: AddPinRo) {\n    return this.pinService.addPin(query);\n  }\n\n  @Delete()\n  async delete(@Query(new ZodValidationPipe(deletePinRoSchema)) query: DeletePinRo) {\n    return this.pinService.deletePin(query);\n  }\n\n  @Get('list')\n  async getList(): Promise<IGetPinListVo> {\n    return this.pinService.getList();\n  }\n\n  @Put('order')\n  async updateOrder(@Body(new ZodValidationPipe(updatePinOrderRoSchema)) body: UpdatePinOrderRo) {\n    return this.pinService.updateOrder(body);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/pin/pin.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PinController } from './pin.controller';\nimport { PinService } from './pin.service';\n\n@Module({\n  providers: [PinService],\n  controllers: [PinController],\n})\nexport class PinModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/pin/pin.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { HttpErrorCode, nullsToUndefined, type ViewType } from '@teable/core';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport type { IGetPinListVo, AddPinRo, DeletePinRo, UpdatePinOrderRo } from '@teable/openapi';\nimport { PinType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { keyBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type {\n  AppDeleteEvent,\n  BaseDeleteEvent,\n  DashboardDeleteEvent,\n  SpaceDeleteEvent,\n  TableDeleteEvent,\n  ViewDeleteEvent,\n  WorkflowDeleteEvent,\n} from '../../event-emitter/events';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { updateOrder } from '../../utils/update-order';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\n\n@Injectable()\nexport class PinService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private async getMaxOrder(where: Prisma.PinResourceWhereInput) {\n    const aggregate = await this.prismaService.pinResource.aggregate({\n      where,\n      _max: { order: true },\n    });\n    return aggregate._max.order || 0;\n  }\n\n  async addPin(query: AddPinRo) {\n    const { type, id } = query;\n    const maxOrder = await this.getMaxOrder({\n      createdBy: this.cls.get('user.id'),\n    });\n    return this.prismaService.pinResource\n      .create({\n        data: {\n          type,\n          resourceId: id,\n          createdBy: this.cls.get('user.id'),\n          order: maxOrder + 1,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Pin already exists', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.pin.alreadyExists',\n          },\n        });\n      });\n  }\n\n  async deletePin(query: DeletePinRo) {\n    const { id, type } = query;\n    return this.prismaService.pinResource\n      .delete({\n        where: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          createdBy_resourceId: {\n            resourceId: id,\n            createdBy: this.cls.get('user.id'),\n          },\n          type,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Pin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.pin.notFound',\n          },\n        });\n      });\n  }\n\n  async getList(): Promise<IGetPinListVo> {\n    const list = await this.prismaService.pinResource.findMany({\n      where: {\n        createdBy: this.cls.get('user.id'),\n      },\n      select: {\n        resourceId: true,\n        type: true,\n        order: true,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    // Group resource IDs by type\n    const idsByType = list.reduce(\n      (acc, item) => {\n        const type = item.type as PinType;\n        if (!acc[type]) {\n          acc[type] = [];\n        }\n        acc[type].push(item.resourceId);\n        return acc;\n      },\n      {} as Record<PinType, string[]>\n    );\n\n    // Fetch all resources in parallel\n    const [baseList, spaceList, tableList, viewList, dashboardList, workflowList, appList] =\n      await Promise.all([\n        this.fetchBases(idsByType[PinType.Base]),\n        this.fetchSpaces(idsByType[PinType.Space]),\n        this.fetchTables(idsByType[PinType.Table]),\n        this.fetchViews(idsByType[PinType.View]),\n        this.fetchDashboards(idsByType[PinType.Dashboard]),\n        this.fetchWorkflows(idsByType[PinType.Workflow]),\n        this.fetchApps(idsByType[PinType.App]),\n      ]);\n\n    // Create lookup maps\n    const resourceMaps = {\n      [PinType.Base]: keyBy(baseList, 'id'),\n      [PinType.Space]: keyBy(spaceList, 'id'),\n      [PinType.Table]: keyBy(tableList, 'id'),\n      [PinType.View]: keyBy(viewList, 'id'),\n      [PinType.Dashboard]: keyBy(dashboardList, 'id'),\n      [PinType.Workflow]: keyBy(workflowList, 'id'),\n      [PinType.App]: keyBy(appList, 'id'),\n    };\n\n    return list\n      .map((item) => {\n        const { resourceId, type, order } = item;\n        const resource = this.transformResource(type as PinType, resourceId, resourceMaps);\n        if (!resource) {\n          return undefined;\n        }\n        return {\n          id: resourceId,\n          type: type as PinType,\n          order,\n          ...nullsToUndefined(resource),\n        };\n      })\n      .filter(Boolean) as IGetPinListVo;\n  }\n\n  private async fetchBases(ids?: string[]) {\n    if (!ids?.length) return [];\n    return this.prismaService.base.findMany({\n      where: { id: { in: ids }, deletedTime: null },\n      select: { id: true, name: true, icon: true },\n    });\n  }\n\n  private async fetchSpaces(ids?: string[]) {\n    if (!ids?.length) return [];\n    return this.prismaService.space.findMany({\n      where: { id: { in: ids }, deletedTime: null },\n      select: { id: true, name: true },\n    });\n  }\n\n  private async fetchTables(ids?: string[]) {\n    if (!ids?.length) return [];\n    return this.prismaService.tableMeta.findMany({\n      where: { id: { in: ids }, deletedTime: null },\n      select: { id: true, name: true, baseId: true, icon: true },\n    });\n  }\n\n  private async fetchViews(ids?: string[]) {\n    if (!ids?.length) return [];\n    return this.prismaService.$queryRaw<\n      {\n        id: string;\n        name: string;\n        baseId: string;\n        tableId: string;\n        type: ViewType;\n        options: string;\n      }[]\n    >(Prisma.sql`\n      SELECT view.id, view.name, table_meta.base_id as \"baseId\", table_meta.id as \"tableId\", view.type, view.options\n      FROM view\n      LEFT JOIN table_meta ON view.table_id = table_meta.id\n      WHERE view.id IN (${Prisma.join(ids)})\n        AND view.deleted_time IS NULL\n        AND table_meta.deleted_time IS NULL\n    `);\n  }\n\n  private async fetchDashboards(ids?: string[]) {\n    if (!ids?.length) return [];\n    return this.prismaService.dashboard.findMany({\n      where: { id: { in: ids } },\n      select: { id: true, name: true, baseId: true },\n    });\n  }\n\n  private async fetchWorkflows(ids?: string[]) {\n    if (!ids?.length) return [];\n    const sql = this.knex('workflow')\n      .select('id', 'name', this.knex.raw('base_id as \"baseId\"'))\n      .whereIn('id', ids)\n      .whereNull('deleted_time')\n      .toQuery();\n    return this.prismaService.$queryRawUnsafe<{ id: string; name: string; baseId: string }[]>(sql);\n  }\n\n  private async fetchApps(ids?: string[]) {\n    if (!ids?.length) return [];\n    const sql = this.knex('app')\n      .select('id', 'name', this.knex.raw('base_id as \"baseId\"'))\n      .whereIn('id', ids)\n      .whereNull('deleted_time')\n      .toQuery();\n    return this.prismaService.$queryRawUnsafe<{ id: string; name: string; baseId: string }[]>(sql);\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private transformResource(type: PinType, resourceId: string, resourceMaps: Record<PinType, any>) {\n    const resource = resourceMaps[type]?.[resourceId];\n    if (!resource) return undefined;\n\n    switch (type) {\n      case PinType.Base:\n        return { name: resource.name, icon: resource.icon };\n      case PinType.Space:\n      case PinType.Dashboard:\n      case PinType.Workflow:\n      case PinType.App:\n        return { name: resource.name, parentBaseId: resource.baseId };\n      case PinType.Table:\n        return { name: resource.name, parentBaseId: resource.baseId, icon: resource.icon };\n      case PinType.View: {\n        const pluginLogo = resource.options ? JSON.parse(resource.options)?.pluginLogo : undefined;\n        return {\n          name: resource.name,\n          parentBaseId: resource.baseId,\n          viewMeta: {\n            tableId: resource.tableId,\n            type: resource.type,\n            pluginLogo: pluginLogo ? getPublicFullStorageUrl(pluginLogo) : undefined,\n          },\n        };\n      }\n      default:\n        return undefined;\n    }\n  }\n\n  async updateOrder(data: UpdatePinOrderRo) {\n    const { id, type, position, anchorId, anchorType } = data;\n\n    const item = await this.prismaService.pinResource\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: {\n          resourceId: id,\n          type,\n          createdBy: this.cls.get('user.id'),\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Pin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.pin.notFound',\n          },\n        });\n      });\n\n    const anchorItem = await this.prismaService.pinResource\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: {\n          resourceId: anchorId,\n          type: anchorType,\n          createdBy: this.cls.get('user.id'),\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Pin Anchor not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.pin.anchorNotFound',\n          },\n        });\n      });\n\n    await updateOrder({\n      query: undefined,\n      position,\n      item,\n      anchorItem,\n      getNextItem: async (whereOrder, align) => {\n        return this.prismaService.pinResource.findFirst({\n          select: { order: true, id: true },\n          where: {\n            type: type,\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (_, id, data) => {\n        await this.prismaService.pinResource.update({\n          data: { order: data.newOrder },\n          where: { id },\n        });\n      },\n      shuffle: async () => {\n        const orderKey = position === 'before' ? 'lt' : 'gt';\n        const dataOrderKey = position === 'before' ? 'decrement' : 'increment';\n        await this.prismaService.pinResource.updateMany({\n          data: { order: { [dataOrderKey]: 1 } },\n          where: {\n            createdBy: this.cls.get('user.id'),\n            order: {\n              [orderKey]: anchorItem.order,\n            },\n          },\n        });\n      },\n    });\n  }\n\n  async deletePinWithoutException(query: DeletePinRo) {\n    const { id, type } = query;\n    const existingPin = await this.prismaService.pinResource.findFirst({\n      where: {\n        resourceId: id,\n        type,\n      },\n    });\n    if (!existingPin) {\n      return;\n    }\n    return this.prismaService.pinResource.deleteMany({\n      where: {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        resourceId: id,\n        type,\n      },\n    });\n  }\n\n  @OnEvent(Events.TABLE_VIEW_DELETE, { async: true })\n  @OnEvent(Events.TABLE_DELETE, { async: true })\n  @OnEvent(Events.BASE_DELETE, { async: true })\n  @OnEvent(Events.SPACE_DELETE, { async: true })\n  @OnEvent(Events.DASHBOARD_DELETE, { async: true })\n  @OnEvent(Events.WORKFLOW_DELETE, { async: true })\n  @OnEvent(Events.APP_DELETE, { async: true })\n  protected async resourceDeleteListener(\n    listenerEvent:\n      | ViewDeleteEvent\n      | TableDeleteEvent\n      | BaseDeleteEvent\n      | SpaceDeleteEvent\n      | DashboardDeleteEvent\n      | WorkflowDeleteEvent\n      | AppDeleteEvent\n  ) {\n    switch (listenerEvent.name) {\n      case Events.TABLE_VIEW_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.viewId,\n          type: PinType.View,\n        });\n        break;\n      case Events.TABLE_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.tableId,\n          type: PinType.Table,\n        });\n        break;\n      case Events.BASE_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.baseId,\n          type: PinType.Base,\n        });\n        break;\n      case Events.SPACE_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.spaceId,\n          type: PinType.Space,\n        });\n        break;\n      case Events.DASHBOARD_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.dashboardId,\n          type: PinType.Dashboard,\n        });\n        break;\n      case Events.WORKFLOW_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.workflowId,\n          type: PinType.Workflow,\n        });\n        break;\n      case Events.APP_DELETE:\n        await this.deletePinWithoutException({\n          id: listenerEvent.payload.appId,\n          type: PinType.App,\n        });\n        break;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.controller.ts",
    "content": "import { Controller, Get, Param, Query } from '@nestjs/common';\nimport {\n  getDashboardInstallPluginQueryRoSchema,\n  getPluginPanelInstallPluginQueryRoSchema,\n  IGetDashboardInstallPluginQueryRo,\n  IGetPluginPanelInstallPluginQueryRo,\n  type IBaseQueryVo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../../../zod.validation.pipe';\nimport { Permissions } from '../../../auth/decorators/permissions.decorator';\nimport { ResourceMeta } from '../../../auth/decorators/resource_meta.decorator';\nimport { PluginChartService } from './plugin-chart.service';\n\n@Controller('api/plugin/chart')\nexport class PluginChartController {\n  constructor(private readonly pluginChartService: PluginChartService) {}\n\n  @Get(':pluginInstallId/plugin-panel/:positionId/query')\n  @Permissions('table|read')\n  @ResourceMeta('tableId', 'query')\n  getPluginPanelPluginQuery(\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Param('positionId') positionId: string,\n    @Query(new ZodValidationPipe(getPluginPanelInstallPluginQueryRoSchema))\n    query: IGetPluginPanelInstallPluginQueryRo\n  ): Promise<IBaseQueryVo> {\n    const { tableId, cellFormat } = query;\n    return this.pluginChartService.getPluginPanelPluginQuery(\n      pluginInstallId,\n      positionId,\n      tableId,\n      cellFormat\n    );\n  }\n\n  @Get(':pluginInstallId/dashboard/:positionId/query')\n  @Permissions('base|read')\n  @ResourceMeta('baseId', 'query')\n  getDashboardPluginQuery(\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Param('positionId') positionId: string,\n    @Query(new ZodValidationPipe(getDashboardInstallPluginQueryRoSchema))\n    query: IGetDashboardInstallPluginQueryRo\n  ): Promise<IBaseQueryVo> {\n    const { baseId, cellFormat } = query;\n    return this.pluginChartService.getDashboardPluginQuery(\n      pluginInstallId,\n      positionId,\n      baseId,\n      cellFormat\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { BaseModule } from '../../../base/base.module';\nimport { DashboardModule } from '../../../dashboard/dashboard.module';\nimport { PluginPanelModule } from '../../../plugin-panel/plugin-panel.module';\nimport { PluginChartController } from './plugin-chart.controller';\nimport { PluginChartService } from './plugin-chart.service';\n\n@Module({\n  imports: [PluginPanelModule, DashboardModule, BaseModule],\n  providers: [PluginChartService],\n  exports: [PluginChartService],\n  controllers: [PluginChartController],\n})\nexport class PluginChartModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { CellFormat, HttpErrorCode } from '@teable/core';\nimport type { IBaseQuery } from '@teable/openapi';\nimport { CustomHttpException } from '../../../../custom.exception';\nimport { BaseQueryService } from '../../../base/base-query/base-query.service';\nimport { DashboardService } from '../../../dashboard/dashboard.service';\nimport { PluginPanelService } from '../../../plugin-panel/plugin-panel.service';\n\n@Injectable()\nexport class PluginChartService {\n  constructor(\n    private readonly baseQueryService: BaseQueryService,\n    private readonly dashboardService: DashboardService,\n    private readonly pluginPanelService: PluginPanelService\n  ) {}\n\n  async getDashboardPluginQuery(\n    pluginInstallId: string,\n    positionId: string,\n    baseId: string,\n    cellFormat: CellFormat = CellFormat.Text\n  ) {\n    const { storage } = await this.dashboardService.getPluginInstall(\n      baseId,\n      positionId,\n      pluginInstallId\n    );\n    const query = storage?.query as IBaseQuery;\n    if (!query) {\n      throw new CustomHttpException(\n        'Dashboard Plugin Storage Query not found',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.pluginChart.queryNotFound',\n          },\n        }\n      );\n    }\n    return this.baseQueryService.baseQuery(baseId, query, cellFormat);\n  }\n\n  async getPluginPanelPluginQuery(\n    pluginInstallId: string,\n    positionId: string,\n    tableId: string,\n    cellFormat: CellFormat = CellFormat.Text\n  ) {\n    const { baseId, storage } = await this.pluginPanelService.getPluginPanelPlugin(\n      tableId,\n      positionId,\n      pluginInstallId\n    );\n    const query = storage?.query as IBaseQuery;\n    if (!query) {\n      throw new CustomHttpException(\n        'Plugin Panel Plugin Storage Query not found',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.pluginChart.queryNotFound',\n          },\n        }\n      );\n    }\n    return this.baseQueryService.baseQuery(baseId, query, cellFormat);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/config/chart.ts",
    "content": "import { PluginPosition } from '@teable/openapi';\nimport type { IOfficialPluginConfig } from './types';\n\nexport const chartConfig: IOfficialPluginConfig = {\n  id: 'plgchart',\n  name: 'Chart',\n  description: 'Visualize your records on a bar, line, pie',\n  detailDesc: `\n  If you're looking for a colorful way to get a big-picture overview of a table, try a chart app.\n  \n  \n  \n  The chart app summarizes a table of records and turns it into an interactive bar, line, pie. \n  \n\n  [Learn more](https://teable.ai)\n\n  `,\n  helpUrl: 'https://help.teable.ai/en/basic/plugin/chart',\n  positions: [PluginPosition.Dashboard, PluginPosition.Panel],\n  i18n: {\n    zh: {\n      name: '图表',\n      helpUrl: 'https://help.teable.cn/zh/basic/plugin/chart',\n      description: '通过柱状图、折线图、饼图可视化您的记录',\n      detailDesc:\n        '如果您想通过色彩丰富的方式从大局上了解表格，试试图表应用。\\n\\n图表应用汇总表格记录，并将其转换为交互式的柱状图、折线图、饼图。\\n\\n[了解更多](https://teable.cn)',\n    },\n  },\n  logoPath: 'static/plugin/chart.png',\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/config/sheet-form-view.ts",
    "content": "import { PluginPosition } from '@teable/openapi';\n\nexport const sheetFormConfig = {\n  id: 'plgsheetform',\n  name: 'Sheet Form',\n  description: 'Design forms with spread sheet, then collect data into your table by sheet form',\n  detailDesc:\n    'Create powerful and flexible forms using the familiar spread sheet interface. \\n\\nWith the sheet Form Designer plugin, you can: \\n\\n- Design form templates in spread sheet. \\n\\n- Share your forms easily. \\n\\n- Collect data directly into your multi-dimensional table. \\n\\nPerfect for surveys, data collection, and customized form needs. \\n\\n[Learn more](https://help.teable.ai/en/basic/plugin/sheet-form)',\n  helpUrl: 'https://help.teable.ai/en/basic/plugin/sheet-form',\n  positions: [PluginPosition.View],\n  i18n: {\n    zh: {\n      name: 'Sheet 表单',\n      helpUrl: 'https://help.teable.cn/zh/basic/plugin/sheet-form',\n      description: '使用表格设计表单，并将数据收集到您的多维表格中',\n      detailDesc:\n        '使用熟悉的表格界面创建强大而灵活的表单。\\n\\n使用表格表单插件，您可以： \\n\\n - 在表格中设计表单模板。 \\n\\n - 轻松分享您的表格表单。 \\n\\n - 将数据直接收集到您的多维表格中。 \\n\\n非常适合问卷调查、数据收集和自定义表单需求。\\n\\n[了解更多](https://teable.cn)',\n    },\n  },\n  logoPath: 'static/plugin/sheet-form-logo.png',\n  avatarPath: 'static/plugin/sheet-form-logo.png',\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/config/types.ts",
    "content": "import type { PluginPosition } from '@teable/openapi';\n\nexport type IOfficialPluginConfig = {\n  id: string;\n  name: string;\n  description?: string;\n  detailDesc?: string;\n  helpUrl: string;\n  positions: PluginPosition[];\n  i18n?: {\n    zh: {\n      name: string;\n      helpUrl: string;\n      description: string;\n      detailDesc: string;\n    };\n  };\n  logoPath: string;\n  pluginUserId?: string;\n  avatarPath?: string;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts",
    "content": "import { join, resolve } from 'path';\nimport { Injectable, Logger, type OnModuleInit } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { getPluginEmail } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PluginStatus, UploadType } from '@teable/openapi';\nimport { createReadStream } from 'fs-extra';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport sharp from 'sharp';\nimport { BaseConfig, IBaseConfig } from '../../../configs/base.config';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { UserService } from '../../user/user.service';\nimport { generateSecret } from '../utils';\nimport { chartConfig } from './config/chart';\nimport { sheetFormConfig } from './config/sheet-form-view';\nimport type { IOfficialPluginConfig } from './config/types';\n\ninterface IUploadResult {\n  id: string;\n  path: string;\n  url: string;\n  size: number;\n  width?: number;\n  height?: number;\n  hash: string;\n  mimetype: string;\n}\n\ninterface IPreparedPlugin {\n  config: IOfficialPluginConfig & { secret: string; url: string };\n  logo: IUploadResult;\n  avatar?: IUploadResult;\n  hashedSecret: string;\n  maskedSecret: string;\n}\n\n@Injectable()\nexport class OfficialPluginInitService implements OnModuleInit {\n  private logger = new Logger(OfficialPluginInitService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly userService: UserService,\n    private readonly configService: ConfigService,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async onModuleInit() {\n    const officialPlugins = [\n      {\n        ...chartConfig,\n        secret: this.configService.get<string>('PLUGIN_CHART_SECRET') || this.baseConfig.secretKey,\n        url: `/plugin/chart`,\n      },\n      {\n        ...sheetFormConfig,\n        secret:\n          this.configService.get<string>('PLUGIN_SHEETFORMVIEW_SECRET') ||\n          this.baseConfig.secretKey,\n        url: `/plugin/sheet-form-view`,\n      },\n    ];\n\n    try {\n      // Phase 1: Upload files to storage (outside transaction)\n      const preparedPlugins: IPreparedPlugin[] = [];\n      for (const plugin of officialPlugins) {\n        this.logger.log(`Creating official plugin: ${plugin.name}`);\n        const prepared = await this.preparePlugin(plugin);\n        preparedPlugins.push(prepared);\n      }\n\n      // Phase 2: Database operations (inside transaction)\n      await this.prismaService.$tx(async () => {\n        for (const prepared of preparedPlugins) {\n          await this.savePlugin(prepared);\n        }\n      });\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      if (error.code !== 'P2002') {\n        throw error;\n      }\n    }\n    this.logger.log('Official plugins initialized');\n  }\n\n  private async uploadToStorage(\n    id: string,\n    filePath: string,\n    type: UploadType\n  ): Promise<IUploadResult> {\n    const path = join(StorageAdapter.getDir(type), id);\n\n    if (process.env.NODE_ENV === 'test') {\n      return { id, path, url: `/${path}`, size: 0, hash: '', mimetype: 'image/png' };\n    }\n\n    const fileStream = createReadStream(resolve(process.cwd(), filePath));\n    const metaReader = sharp();\n    const sharpReader = fileStream.pipe(metaReader);\n    const { width, height, format = 'png', size = 0 } = await sharpReader.metadata();\n    const bucket = StorageAdapter.getBucket(type);\n    const mimetype = `image/${format}`;\n    const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      'Content-Type': mimetype,\n    });\n\n    return { id, path, url: `/${path}`, size, width, height, hash, mimetype };\n  }\n\n  private async saveAttachment(upload: IUploadResult): Promise<void> {\n    const { id, path, size, width, height, hash, mimetype } = upload;\n    await this.prismaService.txClient().attachments.upsert({\n      create: {\n        token: id,\n        path,\n        size,\n        width,\n        height,\n        hash,\n        mimetype,\n        createdBy: 'system',\n      },\n      update: {\n        size,\n        width,\n        height,\n        hash,\n        mimetype,\n        lastModifiedBy: 'system',\n      },\n      where: {\n        token: id,\n        deletedTime: null,\n      },\n    });\n  }\n\n  private async preparePlugin(\n    pluginConfig: IOfficialPluginConfig & { secret: string; url: string }\n  ): Promise<IPreparedPlugin> {\n    const { id: pluginId, logoPath, avatarPath, pluginUserId, secret } = pluginConfig;\n\n    const logo = await this.uploadToStorage(pluginId, logoPath, UploadType.Plugin);\n    const { hashedSecret, maskedSecret } = await generateSecret(secret);\n\n    let avatar: IUploadResult | undefined;\n    if (pluginUserId && avatarPath) {\n      avatar = await this.uploadToStorage(pluginUserId, avatarPath, UploadType.Avatar);\n    }\n\n    return { config: pluginConfig, logo, avatar, hashedSecret, maskedSecret };\n  }\n\n  private async savePlugin(prepared: IPreparedPlugin): Promise<void> {\n    const { config, logo, avatar, hashedSecret, maskedSecret } = prepared;\n    const {\n      id: pluginId,\n      name,\n      description,\n      detailDesc,\n      i18n,\n      positions,\n      helpUrl,\n      url,\n      pluginUserId,\n    } = config;\n\n    // Save attachments\n    await this.saveAttachment(logo);\n    if (avatar) {\n      await this.saveAttachment(avatar);\n    }\n\n    // Create plugin user if needed\n    let userId: string | undefined;\n    if (pluginUserId) {\n      const userEmail = getPluginEmail(pluginId);\n      const user = await this.prismaService\n        .txClient()\n        .user.findFirst({ where: { id: pluginUserId, email: userEmail } });\n\n      if (!user) {\n        await this.userService.createSystemUser({\n          id: pluginUserId,\n          name,\n          avatar: avatar?.url,\n          email: userEmail,\n        });\n      }\n      userId = pluginUserId;\n    }\n\n    // Create or update plugin\n    const pluginData = {\n      name,\n      description,\n      detailDesc,\n      positions: JSON.stringify(positions),\n      helpUrl,\n      url,\n      logo: logo.url,\n      status: PluginStatus.Published,\n      i18n: JSON.stringify(i18n),\n      secret: hashedSecret,\n      maskedSecret,\n      pluginUser: userId || pluginUserId,\n      createdBy: 'system',\n    };\n\n    const exists = await this.prismaService.txClient().plugin.count({ where: { id: pluginId } });\n\n    if (exists > 0) {\n      await this.prismaService.txClient().plugin.update({\n        where: { id: pluginId },\n        data: pluginData,\n      });\n    } else {\n      await this.prismaService.txClient().plugin.create({\n        data: { id: pluginId, ...pluginData },\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { getRandomString, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  PluginStatus,\n  type IPluginGetTokenRo,\n  type IPluginGetTokenVo,\n  type IPluginRefreshTokenRo,\n  type IPluginRefreshTokenVo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../cache/cache.service';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { second } from '../../utils/second';\nimport { AccessTokenService } from '../access-token/access-token.service';\nimport { validateSecret } from './utils';\n\ninterface IRefreshPayload {\n  pluginId: string;\n  secret: string;\n  accessTokenId: string;\n}\n\n@Injectable()\nexport class PluginAuthService {\n  accessTokenExpireIn = second('10m');\n  refreshTokenExpireIn = second('30d');\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cacheService: CacheService,\n    private readonly accessTokenService: AccessTokenService,\n    private readonly jwtService: JwtService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  private generateAccessToken({\n    userId,\n    scopes,\n    clientId,\n    name,\n    baseId,\n  }: {\n    userId: string;\n    scopes: string[];\n    clientId: string;\n    name: string;\n    baseId: string;\n  }) {\n    return this.accessTokenService.createAccessToken({\n      clientId,\n      name: `plugin:${name}`,\n      scopes,\n      userId,\n      baseIds: [baseId],\n      // 10 minutes\n      expiredTime: new Date(Date.now() + this.accessTokenExpireIn * 1000).toISOString(),\n    });\n  }\n\n  private async generateRefreshToken({ pluginId, secret, accessTokenId }: IRefreshPayload) {\n    return this.jwtService.signAsync(\n      {\n        secret,\n        accessTokenId,\n        pluginId,\n      },\n      { expiresIn: this.refreshTokenExpireIn }\n    );\n  }\n\n  private async validateSecret(secret: string, pluginId: string) {\n    const plugin = await this.prismaService.plugin\n      .findFirstOrThrow({\n        where: {\n          id: pluginId,\n          OR: [\n            {\n              status: PluginStatus.Published,\n            },\n            {\n              status: { not: PluginStatus.Published },\n              createdBy: this.cls.get('user.id'),\n            },\n          ],\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        });\n      });\n    if (!plugin.pluginUser) {\n      throw new CustomHttpException('Plugin user not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.plugin.userNotFound',\n        },\n      });\n    }\n    const checkSecret = await validateSecret(secret, plugin.secret);\n    if (!checkSecret) {\n      throw new CustomHttpException('Invalid secret', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.plugin.invalidSecret',\n        },\n      });\n    }\n    return {\n      ...plugin,\n      pluginUser: plugin.pluginUser,\n    };\n  }\n\n  async token(pluginId: string, ro: IPluginGetTokenRo): Promise<IPluginGetTokenVo> {\n    const { secret, scopes, baseId } = ro;\n    const plugin = await this.validateSecret(secret, pluginId);\n\n    const accessToken = await this.generateAccessToken({\n      userId: plugin.pluginUser,\n      scopes,\n      baseId,\n      clientId: pluginId,\n      name: plugin.name,\n    });\n\n    const refreshToken = await this.generateRefreshToken({\n      pluginId,\n      secret,\n      accessTokenId: accessToken.id,\n    });\n\n    return {\n      accessToken: accessToken.token,\n      refreshToken,\n      scopes,\n      expiresIn: this.accessTokenExpireIn,\n      refreshExpiresIn: this.refreshTokenExpireIn,\n    };\n  }\n\n  async refreshToken(pluginId: string, ro: IPluginRefreshTokenRo): Promise<IPluginRefreshTokenVo> {\n    const { secret, refreshToken } = ro;\n    const plugin = await this.validateSecret(secret, pluginId);\n    const payload = await this.jwtService.verifyAsync<IRefreshPayload>(refreshToken).catch(() => {\n      throw new CustomHttpException('Invalid refresh token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.plugin.invalidRefreshToken',\n        },\n      });\n    });\n\n    if (\n      payload.pluginId !== pluginId ||\n      payload.secret !== secret ||\n      payload.accessTokenId === undefined\n    ) {\n      throw new CustomHttpException('Invalid refresh token', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.plugin.invalidRefreshToken',\n        },\n      });\n    }\n    return this.prismaService.$tx(async (prisma) => {\n      const oldAccessToken = await prisma.accessToken\n        .findFirstOrThrow({\n          where: { id: payload.accessTokenId },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Invalid refresh token', HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.plugin.invalidRefreshToken',\n            },\n          });\n        });\n\n      await prisma.accessToken.delete({\n        where: { id: payload.accessTokenId, userId: plugin.pluginUser },\n      });\n\n      const baseId = oldAccessToken.baseIds ? JSON.parse(oldAccessToken.baseIds)[0] : '';\n      const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : [];\n      if (!baseId) {\n        throw new CustomHttpException(\n          'Anomalous token with no baseId',\n          HttpErrorCode.INTERNAL_SERVER_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.plugin.anomalousToken',\n            },\n          }\n        );\n      }\n\n      const accessToken = await this.generateAccessToken({\n        userId: plugin.pluginUser,\n        scopes,\n        baseId,\n        clientId: pluginId,\n        name: plugin.name,\n      });\n\n      const refreshToken = await this.generateRefreshToken({\n        pluginId,\n        secret,\n        accessTokenId: accessToken.id,\n      });\n      return {\n        accessToken: accessToken.token,\n        refreshToken,\n        scopes,\n        expiresIn: this.accessTokenExpireIn,\n        refreshExpiresIn: this.refreshTokenExpireIn,\n      };\n    });\n  }\n\n  async authCode(pluginId: string, baseId: string) {\n    const count = await this.prismaService.pluginInstall.count({\n      where: { pluginId, baseId },\n    });\n    if (count === 0) {\n      throw new CustomHttpException('Plugin not installed', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.pluginInstall.notFound',\n        },\n      });\n    }\n    const authCode = getRandomString(16);\n    await this.cacheService.set(`plugin:auth-code:${authCode}`, { baseId, pluginId }, second('5m'));\n    return authCode;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { PluginController } from './plugin.controller';\n\ndescribe('PluginController', () => {\n  let controller: PluginController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [PluginController],\n    }).compile();\n\n    controller = module.get<PluginController>(PluginController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin.controller.ts",
    "content": "import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';\nimport type {\n  ICreatePluginVo,\n  IGetPluginCenterListVo,\n  IGetPluginsVo,\n  IGetPluginVo,\n  IPluginGetTokenVo,\n  IPluginRefreshTokenVo,\n  IPluginRegenerateSecretVo,\n  IUpdatePluginVo,\n} from '@teable/openapi';\nimport {\n  createPluginRoSchema,\n  ICreatePluginRo,\n  updatePluginRoSchema,\n  IUpdatePluginRo,\n  getPluginCenterListRoSchema,\n  IGetPluginCenterListRo,\n  pluginGetTokenRoSchema,\n  IPluginGetTokenRo,\n  pluginRefreshTokenRoSchema,\n  IPluginRefreshTokenRo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { ResourceMeta } from '../auth/decorators/resource_meta.decorator';\nimport { PluginAuthService } from './plugin-auth.service';\nimport { PluginService } from './plugin.service';\n\n@Controller('api/plugin')\nexport class PluginController {\n  constructor(\n    private readonly pluginService: PluginService,\n    private readonly pluginAuthService: PluginAuthService\n  ) {}\n\n  @Post()\n  createPlugin(\n    @Body(new ZodValidationPipe(createPluginRoSchema)) data: ICreatePluginRo\n  ): Promise<ICreatePluginVo> {\n    return this.pluginService.createPlugin(data);\n  }\n\n  @Get()\n  getPlugins(): Promise<IGetPluginsVo> {\n    return this.pluginService.getPlugins();\n  }\n\n  @Get(':pluginId')\n  getPlugin(@Param('pluginId') pluginId: string): Promise<IGetPluginVo> {\n    return this.pluginService.getPlugin(pluginId);\n  }\n\n  @Post(':pluginId/regenerate-secret')\n  regenerateSecret(@Param('pluginId') pluginId: string): Promise<IPluginRegenerateSecretVo> {\n    return this.pluginService.regenerateSecret(pluginId);\n  }\n\n  @Put(':pluginId')\n  updatePlugin(\n    @Param('pluginId') pluginId: string,\n    @Body(new ZodValidationPipe(updatePluginRoSchema)) ro: IUpdatePluginRo\n  ): Promise<IUpdatePluginVo> {\n    return this.pluginService.updatePlugin(pluginId, ro);\n  }\n\n  @Delete(':pluginId')\n  deletePlugin(@Param('pluginId') pluginId: string): Promise<void> {\n    return this.pluginService.delete(pluginId);\n  }\n\n  @Get('center/list')\n  getPluginCenterList(\n    @Query(new ZodValidationPipe(getPluginCenterListRoSchema)) ro: IGetPluginCenterListRo\n  ): Promise<IGetPluginCenterListVo> {\n    return this.pluginService.getPluginCenterList(ro.positions, ro.ids);\n  }\n\n  @Patch(':pluginId/submit')\n  submitPlugin(@Param('pluginId') pluginId: string): Promise<void> {\n    return this.pluginService.submitPlugin(pluginId);\n  }\n\n  @Patch(':pluginId/unpublish')\n  unpublishPlugin(@Param('pluginId') pluginId: string): Promise<void> {\n    return this.pluginService.unpublishPlugin(pluginId);\n  }\n\n  @Post(':pluginId/token')\n  @Public()\n  accessToken(\n    @Param('pluginId') pluginId: string,\n    @Body(new ZodValidationPipe(pluginGetTokenRoSchema)) ro: IPluginGetTokenRo\n  ): Promise<IPluginGetTokenVo> {\n    return this.pluginAuthService.token(pluginId, ro);\n  }\n\n  @Post(':pluginId/refreshToken')\n  @Public()\n  refreshToken(\n    @Param('pluginId') pluginId: string,\n    @Body(new ZodValidationPipe(pluginRefreshTokenRoSchema)) ro: IPluginRefreshTokenRo\n  ): Promise<IPluginRefreshTokenVo> {\n    return this.pluginAuthService.refreshToken(pluginId, ro);\n  }\n\n  @Post(':pluginId/authCode')\n  @Permissions('base|read')\n  @ResourceMeta('baseId', 'body')\n  authCode(@Param('pluginId') pluginId: string, @Body('baseId') baseId: string): Promise<string> {\n    return this.pluginAuthService.authCode(pluginId, baseId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport { authConfig, type IAuthConfig } from '../../configs/auth.config';\nimport { AccessTokenModule } from '../access-token/access-token.module';\nimport { StorageModule } from '../attachments/plugins/storage.module';\nimport { UserModule } from '../user/user.module';\nimport { OfficialPluginInitService } from './official/official-plugin-init.service';\nimport { PluginAuthService } from './plugin-auth.service';\nimport { PluginController } from './plugin.controller';\nimport { PluginService } from './plugin.service';\n\n@Module({\n  imports: [\n    UserModule,\n    AccessTokenModule,\n    StorageModule,\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n  ],\n  providers: [PluginService, PluginAuthService, OfficialPluginInitService],\n  controllers: [PluginController],\n})\nexport class PluginModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { PluginModule } from './plugin.module';\nimport { PluginService } from './plugin.service';\n\ndescribe('PluginService', () => {\n  let service: PluginService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, PluginModule],\n    }).compile();\n\n    service = module.get<PluginService>(PluginService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/plugin.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport {\n  generatePluginId,\n  generatePluginUserId,\n  getPluginEmail,\n  nullsToUndefined,\n  HttpErrorCode,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType, PluginStatus } from '@teable/openapi';\nimport type {\n  IGetPluginCenterListVo,\n  ICreatePluginRo,\n  ICreatePluginVo,\n  IGetPluginsVo,\n  IGetPluginVo,\n  IPluginI18n,\n  IPluginRegenerateSecretVo,\n  IUpdatePluginRo,\n  IUpdatePluginVo,\n  PluginPosition,\n  IPluginConfig,\n} from '@teable/openapi';\nimport { omit } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { UserService } from '../user/user.service';\nimport { generateSecret } from './utils';\n\n@Injectable()\nexport class PluginService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly userService: UserService\n  ) {}\n\n  private logoToVoValue(logo: string) {\n    return getPublicFullStorageUrl(logo);\n  }\n\n  private convertToVo<\n    T extends {\n      positions: string;\n      i18n?: string | null;\n      status: string;\n      config?: string | null;\n      logo: string;\n      createdTime?: Date | null;\n      lastModifiedTime?: Date | null;\n    },\n  >(ro: T) {\n    return nullsToUndefined({\n      ...ro,\n      logo: this.logoToVoValue(ro.logo),\n      status: ro.status as PluginStatus,\n      positions: JSON.parse(ro.positions) as PluginPosition[],\n      i18n: ro.i18n ? (JSON.parse(ro.i18n) as IPluginI18n) : undefined,\n      config: ro.config ? (JSON.parse(ro.config) as IPluginConfig) : undefined,\n      createdTime: ro.createdTime?.toISOString(),\n      lastModifiedTime: ro.lastModifiedTime?.toISOString(),\n    });\n  }\n\n  private async getUserMap(userIds: string[]) {\n    const users = await this.prismaService.txClient().user.findMany({\n      where: { id: { in: userIds } },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        avatar: true,\n      },\n    });\n    const systemUser = userIds.find((id) => id === 'system')\n      ? {\n          id: 'system',\n          name: 'Teable',\n          email: 'support@teable.ai',\n          avatar: undefined,\n        }\n      : undefined;\n\n    const userMap = users.reduce(\n      (acc, user) => {\n        if (user.id === 'system') {\n          acc[user.id] = {\n            id: user.id,\n            name: 'Teable',\n            email: 'support@teable.ai',\n            avatar: undefined,\n          };\n          return acc;\n        }\n        acc[user.id] = {\n          ...user,\n          avatar: user.avatar ? getPublicFullStorageUrl(user.avatar) : undefined,\n        };\n        return acc;\n      },\n      {} as Record<string, { id: string; name: string; email: string; avatar?: string }>\n    );\n\n    return systemUser\n      ? {\n          ...userMap,\n          system: systemUser,\n        }\n      : userMap;\n  }\n\n  async createPlugin(createPluginRo: ICreatePluginRo): Promise<ICreatePluginVo> {\n    const userId = this.cls.get('user.id');\n    const {\n      name,\n      description,\n      detailDesc,\n      helpUrl,\n      logo,\n      i18n,\n      positions,\n      url,\n      autoCreateMember,\n      config,\n    } = createPluginRo;\n    const { secret, hashedSecret, maskedSecret } = await generateSecret();\n    const res = await this.prismaService.$tx(async (prisma) => {\n      const pluginId = generatePluginId();\n      const user = autoCreateMember\n        ? await this.userService.createSystemUser({\n            id: generatePluginUserId(),\n            name,\n            email: getPluginEmail(pluginId),\n          })\n        : null;\n      const plugin = await prisma.plugin.create({\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          detailDesc: true,\n          positions: true,\n          helpUrl: true,\n          logo: true,\n          url: true,\n          status: true,\n          config: true,\n          i18n: true,\n          secret: true,\n          createdTime: true,\n        },\n        data: {\n          id: pluginId,\n          name,\n          description,\n          detailDesc,\n          positions: JSON.stringify(positions),\n          helpUrl,\n          url,\n          logo,\n          config: JSON.stringify(config),\n          status: PluginStatus.Developing,\n          i18n: JSON.stringify(i18n),\n          secret: hashedSecret,\n          maskedSecret,\n          pluginUser: user?.id,\n          createdBy: userId,\n        },\n      });\n      return {\n        ...plugin,\n        secret,\n        pluginUser: user\n          ? {\n              id: user.id,\n              name: user.name,\n              email: user.email,\n              avatar: user.avatar ? getPublicFullStorageUrl(user.avatar) : undefined,\n            }\n          : undefined,\n      };\n    });\n    return this.convertToVo(res);\n  }\n\n  async updatePlugin(id: string, updatePluginRo: IUpdatePluginRo): Promise<IUpdatePluginVo> {\n    const userId = this.cls.get('user.id');\n    const isAdmin = this.cls.get('user.isAdmin');\n    const { name, description, detailDesc, helpUrl, i18n, positions, url, config, logo } =\n      updatePluginRo;\n    const logoPath = logo?.startsWith('http')\n      ? `/${StorageAdapter.getDir(UploadType.Plugin)}/${logo.split('/').pop()}`\n      : logo;\n    const res = await this.prismaService.$tx(async (prisma) => {\n      const res = await prisma.plugin\n        .update({\n          select: {\n            id: true,\n            name: true,\n            description: true,\n            detailDesc: true,\n            positions: true,\n            helpUrl: true,\n            logo: true,\n            url: true,\n            config: true,\n            status: true,\n            i18n: true,\n            secret: true,\n            pluginUser: true,\n            createdTime: true,\n            lastModifiedTime: true,\n          },\n          where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId },\n          data: {\n            name,\n            description,\n            detailDesc,\n            positions: JSON.stringify(positions),\n            helpUrl,\n            url,\n            logo: logoPath,\n            config: JSON.stringify(config),\n            i18n: JSON.stringify(i18n),\n            lastModifiedBy: userId,\n          },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.plugin.notFound',\n            },\n          });\n        });\n\n      if (name && res.pluginUser) {\n        await this.userService.updateUserName(res.pluginUser, name);\n      }\n      return res;\n    });\n    const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {};\n    return this.convertToVo({\n      ...res,\n      pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined,\n    });\n  }\n\n  async getPlugin(id: string): Promise<IGetPluginVo> {\n    const userId = this.cls.get('user.id');\n    const isAdmin = this.cls.get('user.isAdmin');\n    const res = await this.prismaService.plugin\n      .findUniqueOrThrow({\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          detailDesc: true,\n          positions: true,\n          helpUrl: true,\n          logo: true,\n          url: true,\n          status: true,\n          config: true,\n          i18n: true,\n          maskedSecret: true,\n          pluginUser: true,\n          createdTime: true,\n          lastModifiedTime: true,\n        },\n        where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        });\n      });\n    const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {};\n    return this.convertToVo({\n      ...omit(res, 'maskedSecret'),\n      secret: res.maskedSecret,\n      pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined,\n    });\n  }\n\n  async getPlugins(): Promise<IGetPluginsVo> {\n    const userId = this.cls.get('user.id');\n    const isAdmin = this.cls.get('user.isAdmin');\n\n    const res = await this.prismaService.plugin.findMany({\n      where: { createdBy: isAdmin ? { in: ['system', userId] } : userId },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        detailDesc: true,\n        positions: true,\n        helpUrl: true,\n        logo: true,\n        url: true,\n        status: true,\n        i18n: true,\n        secret: true,\n        pluginUser: true,\n        createdTime: true,\n        lastModifiedTime: true,\n      },\n    });\n    const userIds = res.map((r) => r.pluginUser).filter((r) => r !== null) as string[];\n    const userMap = await this.getUserMap(userIds);\n    return res.map((r) =>\n      this.convertToVo({\n        ...r,\n        pluginUser: r.pluginUser ? userMap[r.pluginUser] : undefined,\n      })\n    );\n  }\n\n  async delete(id: string) {\n    await this.prismaService.$tx(async (prisma) => {\n      const res = await prisma.plugin.delete({ where: { id } });\n      if (res.pluginUser) {\n        await prisma.user.delete({ where: { id: res.pluginUser } });\n      }\n    });\n  }\n\n  async regenerateSecret(id: string): Promise<IPluginRegenerateSecretVo> {\n    const { secret, hashedSecret, maskedSecret } = await generateSecret();\n    await this.prismaService.plugin.update({\n      select: {\n        id: true,\n        secret: true,\n      },\n      where: { id },\n      data: {\n        secret: hashedSecret,\n        maskedSecret,\n      },\n    });\n    return { secret, id };\n  }\n\n  async getPluginCenterList(\n    positions?: PluginPosition[],\n    ids?: string[]\n  ): Promise<IGetPluginCenterListVo> {\n    const res = await this.prismaService.plugin.findMany({\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        detailDesc: true,\n        logo: true,\n        status: true,\n        url: true,\n        helpUrl: true,\n        i18n: true,\n        createdTime: true,\n        lastModifiedTime: true,\n        createdBy: true,\n      },\n      where: {\n        ...(ids?.length\n          ? {\n              id: { in: ids },\n            }\n          : {}),\n        AND: [\n          {\n            OR: [\n              {\n                status: PluginStatus.Published,\n              },\n              {\n                status: { not: PluginStatus.Published },\n                createdBy: this.cls.get('user.id'),\n              },\n            ],\n          },\n          ...(positions?.length\n            ? [\n                {\n                  OR: positions.map((position) => ({ positions: { contains: position } })),\n                },\n              ]\n            : []),\n        ],\n      },\n    });\n    const userIds = res.map((r) => r.createdBy);\n    const userMap = await this.getUserMap(userIds);\n    return res.map((r) =>\n      nullsToUndefined({\n        ...r,\n        status: r.status as PluginStatus,\n        logo: this.logoToVoValue(r.logo),\n        i18n: r.i18n ? (JSON.parse(r.i18n) as IPluginI18n) : undefined,\n        createdBy: userMap[r.createdBy],\n        createdTime: r.createdTime?.toISOString(),\n        lastModifiedTime: r.lastModifiedTime?.toISOString(),\n      })\n    );\n  }\n\n  async submitPlugin(id: string) {\n    const userId = this.cls.get('user.id');\n    await this.prismaService.plugin.update({\n      where: { id, createdBy: userId },\n      data: { status: PluginStatus.Reviewing },\n    });\n  }\n\n  async unpublishPlugin(id: string) {\n    await this.prismaService.plugin.update({\n      where: { id, status: PluginStatus.Published },\n      data: { status: PluginStatus.Developing },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin/utils.ts",
    "content": "import { getRandomString } from '@teable/core';\nimport * as bcrypt from 'bcrypt';\n\nexport const generateSecret = async (_secret?: string) => {\n  const secret = _secret ?? getRandomString(40).toLocaleLowerCase();\n  const hashedSecret = await bcrypt.hash(secret, 10);\n\n  const sensitivePart = secret.slice(0, secret.length - 10);\n  const maskedSecret = secret.slice(0).replace(sensitivePart, '*'.repeat(sensitivePart.length));\n  return { secret, hashedSecret, maskedSecret };\n};\n\nexport const validateSecret = async (secret: string, hashedSecret: string) => {\n  return bcrypt.compare(secret, hashedSecret);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-context-menu/plugin-context-menu.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';\nimport type {\n  IPluginContextMenuGetItem,\n  IPluginContextMenuGetStorageVo,\n  IPluginContextMenuGetVo,\n  IPluginContextMenuInstallVo,\n  IPluginContextMenuRenameVo,\n  IPluginContextMenuUpdateStorageVo,\n} from '@teable/openapi';\nimport {\n  IPluginContextMenuInstallRo,\n  pluginContextMenuInstallRoSchema,\n  pluginContextMenuRenameRoSchema,\n  IPluginContextMenuRenameRo,\n  pluginContextMenuUpdateStorageRoSchema,\n  pluginContextMenuMoveRoSchema,\n  IPluginContextMenuMoveRo,\n  IPluginContextMenuUpdateStorageRo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { PluginContextMenuService } from './plugin-context-menu.service';\n\n@Controller('api/table/:tableId/plugin-context-menu')\nexport class PluginContextMenuController {\n  constructor(private readonly pluginContextMenuService: PluginContextMenuService) {}\n\n  @Post('install')\n  @Permissions('table|update')\n  async installPluginContextMenu(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(pluginContextMenuInstallRoSchema))\n    body: IPluginContextMenuInstallRo\n  ): Promise<IPluginContextMenuInstallVo> {\n    return this.pluginContextMenuService.installPluginContextMenu(tableId, body);\n  }\n\n  @Get()\n  @Permissions('table|read')\n  async getPluginContextMenuList(\n    @Param('tableId') tableId: string\n  ): Promise<IPluginContextMenuGetItem[]> {\n    return this.pluginContextMenuService.getPluginContextMenuList(tableId);\n  }\n\n  @Get(':pluginInstallId')\n  @Permissions('table|read')\n  async getPluginContextMenu(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<IPluginContextMenuGetVo> {\n    return this.pluginContextMenuService.getPluginContextMenu(tableId, pluginInstallId);\n  }\n\n  @Get(':pluginInstallId/storage')\n  @Permissions('table|read')\n  async getPluginContextMenuStorage(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<IPluginContextMenuGetStorageVo> {\n    return this.pluginContextMenuService.getPluginContextMenuStorage(tableId, pluginInstallId);\n  }\n\n  @Patch(':pluginInstallId/rename')\n  @Permissions('table|update')\n  async renamePluginContextMenu(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(pluginContextMenuRenameRoSchema))\n    body: IPluginContextMenuRenameRo\n  ): Promise<IPluginContextMenuRenameVo> {\n    return this.pluginContextMenuService.renamePluginContextMenu(tableId, pluginInstallId, body);\n  }\n\n  @Put(':pluginInstallId/update-storage')\n  @Permissions('table|update')\n  async updatePluginContextMenuStorage(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(pluginContextMenuUpdateStorageRoSchema))\n    body: IPluginContextMenuUpdateStorageRo\n  ): Promise<IPluginContextMenuUpdateStorageVo> {\n    return this.pluginContextMenuService.updatePluginContextMenuStorage(\n      tableId,\n      pluginInstallId,\n      body\n    );\n  }\n\n  @Delete(':pluginInstallId')\n  @Permissions('table|update')\n  async removePluginContextMenu(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<void> {\n    return this.pluginContextMenuService.deletePluginContextMenu(tableId, pluginInstallId);\n  }\n\n  @Put(':pluginInstallId/move')\n  @Permissions('table|update')\n  async movePluginContextMenu(\n    @Param('tableId') tableId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(pluginContextMenuMoveRoSchema))\n    body: IPluginContextMenuMoveRo\n  ): Promise<void> {\n    return this.pluginContextMenuService.movePluginContextMenu(tableId, pluginInstallId, body);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-context-menu/plugin-context-menu.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { PluginContextMenuController } from './plugin-context-menu.controller';\nimport { PluginContextMenuService } from './plugin-context-menu.service';\n\n@Module({\n  imports: [CollaboratorModule],\n  controllers: [PluginContextMenuController],\n  providers: [PluginContextMenuService],\n})\nexport class PluginContextMenuModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-context-menu/plugin-context-menu.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { IBaseRole } from '@teable/core';\nimport { generatePluginInstallId, HttpErrorCode, Role } from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PluginPosition, PrincipalType } from '@teable/openapi';\nimport type {\n  IPluginContextMenuRenameRo,\n  IPluginContextMenuInstallRo,\n  IPluginContextMenuUpdateStorageRo,\n  IPluginContextMenuMoveRo,\n  IPluginContextMenuGetItem,\n  IPluginConfig,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { updateOrder } from '../../utils/update-order';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\n\n@Injectable()\nexport class PluginContextMenuService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly collaboratorService: CollaboratorService\n  ) {}\n\n  private async getMaxOrder(where: Prisma.PluginContextMenuWhereInput) {\n    const aggregate = await this.prismaService.txClient().pluginContextMenu.aggregate({\n      where,\n      _max: { order: true },\n    });\n    return aggregate._max.order || 0;\n  }\n\n  private async getBaseId(tableId: string) {\n    const base = await this.prismaService.tableMeta.findUnique({\n      where: { id: tableId },\n      select: { baseId: true },\n    });\n    if (!base) {\n      throw new CustomHttpException('Table not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.table.notFound',\n        },\n      });\n    }\n    return base.baseId;\n  }\n\n  async installPluginContextMenu(tableId: string, body: IPluginContextMenuInstallRo) {\n    const { pluginId, name } = body;\n    const plugin = await this.prismaService.plugin.findUnique({\n      where: {\n        id: pluginId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    if (!plugin) {\n      throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.plugin.notFound',\n        },\n      });\n    }\n\n    const baseId = await this.getBaseId(tableId);\n    const pluginName = name || plugin.name;\n    const userId = this.cls.get('user.id');\n    return this.prismaService.$tx(async (prisma) => {\n      const pluginInstall = await prisma.pluginInstall.create({\n        data: {\n          id: generatePluginInstallId(),\n          pluginId,\n          baseId,\n          name: pluginName,\n          positionId: tableId,\n          position: PluginPosition.ContextMenu,\n          createdBy: userId,\n        },\n        select: {\n          id: true,\n          plugin: {\n            select: {\n              pluginUser: true,\n            },\n          },\n        },\n      });\n      if (pluginInstall.plugin.pluginUser) {\n        // invite pluginUser to base\n        const exist = await this.prismaService.txClient().collaborator.count({\n          where: {\n            principalId: pluginInstall.plugin.pluginUser,\n            principalType: PrincipalType.User,\n            resourceId: baseId,\n            resourceType: CollaboratorType.Base,\n          },\n        });\n\n        if (!exist) {\n          await this.collaboratorService.createBaseCollaborator({\n            collaborators: [\n              {\n                principalId: pluginInstall.plugin.pluginUser,\n                principalType: PrincipalType.User,\n              },\n            ],\n            baseId,\n            role: Role.Owner as IBaseRole,\n          });\n        }\n      }\n      const order = await this.getMaxOrder({ tableId });\n      await prisma.pluginContextMenu.create({\n        data: {\n          pluginInstallId: pluginInstall.id,\n          order: order + 1,\n          createdBy: userId,\n          tableId,\n        },\n      });\n      return {\n        pluginInstallId: pluginInstall.id,\n        name: pluginName,\n        order: order + 1,\n      };\n    });\n  }\n\n  async getPluginContextMenuList(tableId: string) {\n    const baseId = await this.getBaseId(tableId);\n    const pluginContextMenuList = await this.prismaService.pluginContextMenu.findMany({\n      where: { tableId },\n      select: {\n        pluginInstallId: true,\n        order: true,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n    const pluginInstallList = await this.prismaService.pluginInstall.findMany({\n      where: {\n        baseId,\n        positionId: tableId,\n        position: PluginPosition.ContextMenu,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        plugin: {\n          select: {\n            logo: true,\n          },\n        },\n      },\n    });\n    return pluginContextMenuList.reduce((acc, item) => {\n      const plugin = pluginInstallList.find((plugin) => plugin.id === item.pluginInstallId);\n      if (!plugin) {\n        return acc;\n      }\n      acc.push({\n        pluginInstallId: plugin.id,\n        name: plugin.name,\n        pluginId: plugin.pluginId,\n        logo: getPublicFullStorageUrl(plugin.plugin.logo),\n        order: item.order,\n      });\n      return acc;\n    }, [] as IPluginContextMenuGetItem[]);\n  }\n\n  async getPluginContextMenuStorage(tableId: string, pluginInstallId: string) {\n    const baseId = await this.getBaseId(tableId);\n    const res = await this.prismaService.pluginInstall.findUnique({\n      where: {\n        id: pluginInstallId,\n        baseId,\n        positionId: tableId,\n        position: PluginPosition.ContextMenu,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        storage: true,\n      },\n    });\n    if (!res) {\n      throw new CustomHttpException('Plugin install not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.pluginInstall.notFound',\n        },\n      });\n    }\n    return {\n      name: res.name,\n      tableId,\n      pluginId: res.pluginId,\n      pluginInstallId: res.id,\n      storage: res.storage ? JSON.parse(res.storage) : undefined,\n    };\n  }\n\n  async getPluginContextMenu(tableId: string, pluginInstallId: string) {\n    const baseId = await this.getBaseId(tableId);\n    const res = await this.prismaService.pluginInstall.findUnique({\n      where: {\n        id: pluginInstallId,\n        baseId,\n        positionId: tableId,\n        position: PluginPosition.ContextMenu,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        positionId: true,\n        plugin: {\n          select: {\n            url: true,\n            config: true,\n          },\n        },\n      },\n    });\n    if (!res) {\n      throw new CustomHttpException('Plugin install not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.pluginInstall.notFound',\n        },\n      });\n    }\n    return {\n      tableId,\n      positionId: res.positionId,\n      pluginId: res.pluginId,\n      pluginInstallId: res.id,\n      name: res.name,\n      url: res.plugin.url || undefined,\n      config: res.plugin.config ? (JSON.parse(res.plugin.config) as IPluginConfig) : undefined,\n    };\n  }\n\n  async renamePluginContextMenu(\n    tableId: string,\n    pluginInstallId: string,\n    body: IPluginContextMenuRenameRo\n  ) {\n    const { name } = body;\n    const baseId = await this.getBaseId(tableId);\n    const res = await this.prismaService.pluginInstall.update({\n      where: {\n        id: pluginInstallId,\n        baseId,\n        positionId: tableId,\n        position: PluginPosition.ContextMenu,\n      },\n      data: {\n        name,\n      },\n    });\n    return {\n      pluginInstallId: res.id,\n      name: res.name,\n    };\n  }\n\n  async updatePluginContextMenuStorage(\n    tableId: string,\n    pluginInstallId: string,\n    body: IPluginContextMenuUpdateStorageRo\n  ) {\n    const { storage } = body;\n    const baseId = await this.getBaseId(tableId);\n    const res = await this.prismaService.pluginInstall.update({\n      where: {\n        id: pluginInstallId,\n        baseId,\n        positionId: tableId,\n        position: PluginPosition.ContextMenu,\n      },\n      data: { storage: JSON.stringify(storage) },\n    });\n    return {\n      tableId,\n      pluginInstallId: res.id,\n      storage: res.storage ? JSON.parse(res.storage) : undefined,\n    };\n  }\n\n  async deletePluginContextMenu(tableId: string, pluginInstallId: string) {\n    const baseId = await this.getBaseId(tableId);\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.pluginContextMenu.deleteMany({\n        where: { pluginInstallId, tableId },\n      });\n      await prisma.pluginInstall.delete({\n        where: {\n          id: pluginInstallId,\n          baseId,\n          positionId: tableId,\n          position: PluginPosition.ContextMenu,\n        },\n      });\n    });\n  }\n\n  async movePluginContextMenu(\n    tableId: string,\n    pluginInstallId: string,\n    body: IPluginContextMenuMoveRo\n  ) {\n    const { anchorId, position } = body;\n\n    const item = await this.prismaService.pluginContextMenu\n      .findFirstOrThrow({\n        select: { order: true, pluginInstallId: true },\n        where: {\n          pluginInstallId,\n          tableId,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          'Plugin Context Menu not found',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.pluginContextMenu.notFound',\n            },\n          }\n        );\n      })\n      .then((item) => ({\n        ...item,\n        id: item.pluginInstallId,\n      }));\n\n    const anchorItem = await this.prismaService.pluginContextMenu\n      .findFirstOrThrow({\n        select: { order: true, pluginInstallId: true },\n        where: {\n          pluginInstallId: anchorId,\n          tableId,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          'Plugin Context Menu Anchor not found',\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.pluginContextMenu.anchorNotFound',\n            },\n          }\n        );\n      })\n      .then((item) => ({\n        ...item,\n        id: item.pluginInstallId,\n      }));\n\n    await updateOrder({\n      query: tableId,\n      position,\n      item,\n      anchorItem,\n      getNextItem: async (whereOrder, align) => {\n        return this.prismaService.pluginContextMenu\n          .findFirst({\n            select: { order: true, pluginInstallId: true },\n            where: {\n              tableId,\n              order: whereOrder,\n            },\n            orderBy: { order: align },\n          })\n          .then((item) =>\n            item\n              ? {\n                  ...item,\n                  id: item.pluginInstallId,\n                }\n              : null\n          );\n      },\n      update: async (parentId, id, data) => {\n        await this.prismaService.pluginContextMenu.update({\n          data: { order: data.newOrder },\n          where: { pluginInstallId: id, tableId: parentId },\n        });\n      },\n      shuffle: async () => {\n        const orderKey = position === 'before' ? 'gte' : 'gt';\n        await this.prismaService.pluginContextMenu.updateMany({\n          data: { order: { increment: 1 } },\n          where: {\n            tableId,\n            order: {\n              [orderKey]: anchorItem.order,\n            },\n          },\n        });\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-panel/plugin-panel.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';\nimport type {\n  IPluginPanelCreateVo,\n  IPluginPanelGetVo,\n  IPluginPanelInstallVo,\n  IPluginPanelListVo,\n  IPluginPanelPluginGetVo,\n  IPluginPanelRenameVo,\n  IPluginPanelUpdateLayoutVo,\n  IPluginPanelUpdateStorageVo,\n} from '@teable/openapi';\nimport {\n  IPluginPanelCreateRo,\n  pluginPanelCreateRoSchema,\n  pluginPanelRenameRoSchema,\n  IPluginPanelRenameRo,\n  pluginPanelUpdateLayoutRoSchema,\n  IPluginPanelUpdateLayoutRo,\n  pluginPanelInstallRoSchema,\n  IPluginPanelInstallRo,\n  pluginPanelUpdateStorageRoSchema,\n  IPluginPanelUpdateStorageRo,\n  duplicatePluginPanelRoSchema,\n  IDuplicatePluginPanelRo,\n  duplicatePluginPanelInstalledPluginRoSchema,\n  IDuplicatePluginPanelInstalledPluginRo,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { PluginPanelService } from './plugin-panel.service';\n\n@Controller('api/table/:tableId/plugin-panel')\nexport class PluginPanelController {\n  constructor(private readonly pluginPanelService: PluginPanelService) {}\n\n  @Permissions('table|update')\n  @Post()\n  createPluginPanel(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(pluginPanelCreateRoSchema))\n    createPluginPanelDto: IPluginPanelCreateRo\n  ): Promise<IPluginPanelCreateVo> {\n    return this.pluginPanelService.createPluginPanel(tableId, createPluginPanelDto);\n  }\n\n  @Permissions('table|read')\n  @Get()\n  getPluginPanels(@Param('tableId') tableId: string): Promise<IPluginPanelListVo> {\n    return this.pluginPanelService.getPluginPanels(tableId);\n  }\n\n  @Permissions('table|read')\n  @Get(':pluginPanelId')\n  getPluginPanel(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string\n  ): Promise<IPluginPanelGetVo> {\n    return this.pluginPanelService.getPluginPanel(tableId, pluginPanelId);\n  }\n\n  @Permissions('table|update')\n  @Patch(':pluginPanelId/rename')\n  renamePluginPanel(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Body(new ZodValidationPipe(pluginPanelRenameRoSchema))\n    renamePluginPanelDto: IPluginPanelRenameRo\n  ): Promise<IPluginPanelRenameVo> {\n    return this.pluginPanelService.renamePluginPanel(tableId, pluginPanelId, renamePluginPanelDto);\n  }\n\n  @Permissions('table|update')\n  @Delete(':pluginPanelId')\n  async deletePluginPanel(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string\n  ): Promise<void> {\n    await this.pluginPanelService.deletePluginPanel(tableId, pluginPanelId);\n  }\n\n  @Permissions('table|update')\n  @Patch(':pluginPanelId/layout')\n  updatePluginPanelLayout(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Body(new ZodValidationPipe(pluginPanelUpdateLayoutRoSchema))\n    updatePluginPanelLayoutDto: IPluginPanelUpdateLayoutRo\n  ): Promise<IPluginPanelUpdateLayoutVo> {\n    return this.pluginPanelService.updatePluginPanelLayout(\n      tableId,\n      pluginPanelId,\n      updatePluginPanelLayoutDto\n    );\n  }\n\n  @Permissions('table|update')\n  @Post(':pluginPanelId/install')\n  installPluginPanel(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Body(new ZodValidationPipe(pluginPanelInstallRoSchema))\n    installPluginPanelDto: IPluginPanelInstallRo\n  ): Promise<IPluginPanelInstallVo> {\n    return this.pluginPanelService.installPluginPanel(\n      tableId,\n      pluginPanelId,\n      installPluginPanelDto\n    );\n  }\n\n  @Permissions('table|update')\n  @Delete(':pluginPanelId/plugin/:pluginInstallId')\n  removePluginPanelPlugin(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<void> {\n    return this.pluginPanelService.removePluginPanelPlugin(tableId, pluginPanelId, pluginInstallId);\n  }\n\n  @Permissions('table|update')\n  @Patch(':pluginPanelId/plugin/:pluginInstallId/rename')\n  renamePluginPanelPlugin(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(pluginPanelRenameRoSchema))\n    renamePluginPanelPluginDto: IPluginPanelRenameRo\n  ): Promise<IPluginPanelRenameVo> {\n    return this.pluginPanelService.renamePluginPanelPlugin(\n      tableId,\n      pluginPanelId,\n      pluginInstallId,\n      renamePluginPanelPluginDto\n    );\n  }\n\n  @Permissions('table|update')\n  @Patch(':pluginPanelId/plugin/:pluginInstallId/update-storage')\n  updatePluginPanelPluginStorage(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(pluginPanelUpdateStorageRoSchema))\n    updatePluginPanelPluginStorageDto: IPluginPanelUpdateStorageRo\n  ): Promise<IPluginPanelUpdateStorageVo> {\n    return this.pluginPanelService.updatePluginPanelPluginStorage(\n      tableId,\n      pluginPanelId,\n      pluginInstallId,\n      updatePluginPanelPluginStorageDto\n    );\n  }\n\n  @Permissions('table|read')\n  @Get(':pluginPanelId/plugin/:pluginInstallId')\n  getPluginPanelPlugin(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Param('pluginInstallId') pluginInstallId: string\n  ): Promise<IPluginPanelPluginGetVo> {\n    return this.pluginPanelService.getPluginPanelPlugin(tableId, pluginPanelId, pluginInstallId);\n  }\n\n  @Post(':pluginPanelId/duplicate')\n  @Permissions('table|update')\n  duplicatePluginPanel(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Body(new ZodValidationPipe(duplicatePluginPanelRoSchema))\n    duplicatePluginPanelDto: IDuplicatePluginPanelRo\n  ): Promise<{ id: string; name: string }> {\n    return this.pluginPanelService.duplicatePluginPanel(\n      tableId,\n      pluginPanelId,\n      duplicatePluginPanelDto\n    );\n  }\n\n  @Post(':pluginPanelId/plugin/:pluginInstallId/duplicate')\n  @Permissions('table|update')\n  duplicatePluginPanelPlugin(\n    @Param('tableId') tableId: string,\n    @Param('pluginPanelId') pluginPanelId: string,\n    @Param('pluginInstallId') pluginInstallId: string,\n    @Body(new ZodValidationPipe(duplicatePluginPanelInstalledPluginRoSchema))\n    duplicatePluginPanelInstalledPluginDto: IDuplicatePluginPanelInstalledPluginRo\n  ): Promise<{ id: string; name: string }> {\n    return this.pluginPanelService.duplicatePluginPanelPlugin(\n      tableId,\n      pluginPanelId,\n      pluginInstallId,\n      duplicatePluginPanelInstalledPluginDto\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-panel/plugin-panel.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { BaseModule } from '../base/base.module';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { PluginPanelController } from './plugin-panel.controller';\nimport { PluginPanelService } from './plugin-panel.service';\n\n@Module({\n  imports: [CollaboratorModule, BaseModule],\n  controllers: [PluginPanelController],\n  exports: [PluginPanelService],\n  providers: [PluginPanelService],\n})\nexport class PluginPanelModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/plugin-panel/plugin-panel.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type { IBaseRole } from '@teable/core';\nimport {\n  generatePluginInstallId,\n  generatePluginPanelId,\n  getUniqName,\n  HttpErrorCode,\n  nullsToUndefined,\n  Role,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PluginPosition, PrincipalType } from '@teable/openapi';\nimport type {\n  IPluginPanelRenameRo,\n  IPluginPanelUpdateLayoutRo,\n  IPluginPanelCreateRo,\n  IPluginPanelInstallRo,\n  IDashboardLayout,\n  IPluginPanelUpdateStorageRo,\n  IPluginPanelPluginItem,\n  IDuplicatePluginPanelRo,\n  IBaseJson,\n  IDuplicatePluginPanelInstalledPluginRo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { BaseImportService } from '../base/base-import.service';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\n\n@Injectable()\nexport class PluginPanelService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly baseImportService: BaseImportService\n  ) {}\n\n  createPluginPanel(tableId: string, createPluginPanelRo: IPluginPanelCreateRo) {\n    const { name } = createPluginPanelRo;\n    return this.prismaService.pluginPanel.create({\n      select: {\n        id: true,\n        name: true,\n      },\n      data: {\n        id: generatePluginPanelId(),\n        name,\n        tableId,\n        createdBy: this.cls.get('user.id'),\n      },\n    });\n  }\n\n  getPluginPanels(tableId: string) {\n    return this.prismaService.pluginPanel.findMany({\n      where: {\n        tableId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  async getPluginPanel(tableId: string, pluginPanelId: string) {\n    const panel = await this.prismaService.pluginPanel.findUnique({\n      where: {\n        id: pluginPanelId,\n        tableId,\n      },\n      select: {\n        id: true,\n        name: true,\n        layout: true,\n      },\n    });\n\n    if (!panel) {\n      throw new CustomHttpException('Plugin panel not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.pluginPanel.notFound',\n        },\n      });\n    }\n\n    const plugins = await this.prismaService.pluginInstall.findMany({\n      where: {\n        position: PluginPosition.Panel,\n        positionId: pluginPanelId,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        positionId: true,\n        plugin: {\n          select: {\n            url: true,\n          },\n        },\n      },\n    });\n\n    return {\n      ...panel,\n      layout: panel.layout ? JSON.parse(panel.layout) : undefined,\n      pluginMap: plugins.reduce(\n        (acc, plugin) => {\n          acc[plugin.id] = nullsToUndefined({\n            id: plugin.pluginId,\n            name: plugin.name,\n            positionId: plugin.positionId,\n            url: plugin.plugin.url,\n            pluginInstallId: plugin.id,\n          });\n          return acc;\n        },\n        {} as Record<string, IPluginPanelPluginItem>\n      ),\n    };\n  }\n\n  renamePluginPanel(\n    tableId: string,\n    pluginPanelId: string,\n    renamePluginPanelRo: IPluginPanelRenameRo\n  ) {\n    const { name } = renamePluginPanelRo;\n    return this.prismaService.pluginPanel.update({\n      where: { id: pluginPanelId, tableId },\n      data: { name, lastModifiedBy: this.cls.get('user.id') },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  deletePluginPanel(tableId: string, pluginPanelId: string) {\n    return this.prismaService.pluginPanel.delete({\n      where: { id: pluginPanelId, tableId },\n    });\n  }\n\n  async updatePluginPanelLayout(\n    tableId: string,\n    pluginPanelId: string,\n    updatePluginPanelLayoutRo: IPluginPanelUpdateLayoutRo\n  ) {\n    const { layout } = updatePluginPanelLayoutRo;\n    const res = await this.prismaService.pluginPanel.update({\n      where: { id: pluginPanelId, tableId },\n      data: { layout: JSON.stringify(layout), lastModifiedBy: this.cls.get('user.id') },\n      select: {\n        id: true,\n        layout: true,\n      },\n    });\n    return {\n      id: res.id,\n      layout: res.layout ? JSON.parse(res.layout) : undefined,\n    };\n  }\n\n  private async getBaseId(tableId: string) {\n    const base = await this.prismaService.tableMeta.findUnique({\n      where: {\n        id: tableId,\n      },\n      select: {\n        baseId: true,\n      },\n    });\n    if (!base) {\n      throw new CustomHttpException('Table not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.table.notFound',\n        },\n      });\n    }\n    return base.baseId;\n  }\n\n  async installPluginPanel(\n    tableId: string,\n    pluginPanelId: string,\n    installPluginPanelRo: IPluginPanelInstallRo\n  ) {\n    const { pluginId, name } = installPluginPanelRo;\n    const currentUser = this.cls.get('user.id');\n    const baseId = await this.getBaseId(tableId);\n    return this.prismaService.$tx(async (prisma) => {\n      const plugin = await prisma.plugin.findUnique({\n        where: {\n          id: pluginId,\n        },\n      });\n      if (!plugin) {\n        throw new CustomHttpException('Plugin not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        });\n      }\n      const pluginInstall = await prisma.pluginInstall.create({\n        data: {\n          id: generatePluginInstallId(),\n          pluginId,\n          baseId,\n          name: name ?? plugin.name,\n          position: PluginPosition.Panel,\n          positionId: pluginPanelId,\n          createdBy: currentUser,\n        },\n        select: {\n          id: true,\n          name: true,\n          pluginId: true,\n          plugin: {\n            select: {\n              pluginUser: true,\n            },\n          },\n        },\n      });\n      if (pluginInstall.plugin.pluginUser) {\n        // invite pluginUser to base\n        const exist = await this.prismaService.txClient().collaborator.count({\n          where: {\n            principalId: pluginInstall.plugin.pluginUser,\n            principalType: PrincipalType.User,\n            resourceId: baseId,\n            resourceType: CollaboratorType.Base,\n          },\n        });\n\n        if (!exist) {\n          await this.collaboratorService.createBaseCollaborator({\n            collaborators: [\n              {\n                principalId: pluginInstall.plugin.pluginUser,\n                principalType: PrincipalType.User,\n              },\n            ],\n            baseId,\n            role: Role.Owner as IBaseRole,\n          });\n        }\n      }\n      const pluginPanel = await prisma.pluginPanel.findUnique({\n        where: {\n          id: pluginPanelId,\n          tableId,\n        },\n        select: {\n          layout: true,\n        },\n      });\n      if (!pluginPanel) {\n        throw new CustomHttpException('Plugin panel not found', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.pluginPanel.notFound',\n          },\n        });\n      }\n      const layout = pluginPanel.layout ? (JSON.parse(pluginPanel.layout) as IDashboardLayout) : [];\n      layout.push({\n        pluginInstallId: pluginInstall.id,\n        x: 0,\n        y: Number.MAX_SAFE_INTEGER, // puts it at the bottom\n        w: 1,\n        h: 3,\n      });\n      await prisma.pluginPanel.update({\n        where: { id: pluginPanelId, tableId },\n        data: { layout: JSON.stringify(layout) },\n      });\n      return {\n        pluginId: pluginInstall.pluginId,\n        name: pluginInstall.name,\n        pluginInstallId: pluginInstall.id,\n      };\n    });\n  }\n\n  async removePluginPanelPlugin(tableId: string, pluginPanelId: string, pluginInstallId: string) {\n    const baseId = await this.getBaseId(tableId);\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.pluginInstall.delete({\n        where: { id: pluginInstallId, positionId: pluginPanelId, baseId },\n      });\n\n      const pluginPanel = await prisma.pluginPanel.findUnique({\n        where: { id: pluginPanelId, tableId },\n        select: {\n          layout: true,\n        },\n      });\n      if (!pluginPanel) {\n        throw new CustomHttpException('Plugin panel not found', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.pluginPanel.notFound',\n          },\n        });\n      }\n      const layout = pluginPanel.layout ? (JSON.parse(pluginPanel.layout) as IDashboardLayout) : [];\n      const index = layout.findIndex((item) => item.pluginInstallId === pluginInstallId);\n      if (index !== -1) {\n        layout.splice(index, 1);\n        await prisma.pluginPanel.update({\n          where: {\n            id: pluginPanelId,\n          },\n          data: {\n            layout: JSON.stringify(layout),\n          },\n        });\n      }\n    });\n  }\n\n  async renamePluginPanelPlugin(\n    tableId: string,\n    pluginPanelId: string,\n    pluginInstallId: string,\n    renamePluginPanelPluginRo: IPluginPanelRenameRo\n  ) {\n    const { name } = renamePluginPanelPluginRo;\n    const baseId = await this.getBaseId(tableId);\n    await this.prismaService.pluginInstall.update({\n      where: { id: pluginInstallId, positionId: pluginPanelId, baseId },\n      data: { name, lastModifiedBy: this.cls.get('user.id') },\n    });\n    return {\n      id: pluginInstallId,\n      name,\n    };\n  }\n\n  async updatePluginPanelPluginStorage(\n    tableId: string,\n    pluginPanelId: string,\n    pluginInstallId: string,\n    updatePluginPanelPluginStorageRo: IPluginPanelUpdateStorageRo\n  ) {\n    const { storage } = updatePluginPanelPluginStorageRo;\n    const baseId = await this.getBaseId(tableId);\n    const res = await this.prismaService.pluginInstall.update({\n      where: { id: pluginInstallId, positionId: pluginPanelId, baseId },\n      data: {\n        storage: storage ? JSON.stringify(storage) : null,\n        lastModifiedBy: this.cls.get('user.id'),\n      },\n      select: {\n        id: true,\n        storage: true,\n      },\n    });\n    return {\n      pluginInstallId: res.id,\n      tableId,\n      pluginPanelId,\n      storage: res.storage ? JSON.parse(res.storage) : undefined,\n    };\n  }\n\n  async getPluginPanelPlugin(tableId: string, pluginPanelId: string, pluginInstallId: string) {\n    const baseId = await this.getBaseId(tableId);\n    const pluginInstall = await this.prismaService.pluginInstall.findUnique({\n      where: { id: pluginInstallId, positionId: pluginPanelId, baseId },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        storage: true,\n      },\n    });\n    if (!pluginInstall) {\n      throw new CustomHttpException('Plugin install not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.pluginInstall.notFound',\n        },\n      });\n    }\n    return {\n      baseId,\n      name: pluginInstall.name,\n      tableId,\n      pluginId: pluginInstall.pluginId,\n      pluginInstallId: pluginInstall.id,\n      storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : undefined,\n    };\n  }\n\n  async duplicatePluginPanel(\n    tableId: string,\n    pluginPanelId: string,\n    duplicatePluginPanelRo: IDuplicatePluginPanelRo\n  ) {\n    const { name } = duplicatePluginPanelRo;\n    const pluginPanel = (await this.prismaService.txClient().pluginPanel.findFirstOrThrow({\n      where: {\n        tableId,\n        id: pluginPanelId,\n      },\n      select: {\n        id: true,\n        name: true,\n        layout: true,\n        tableId: true,\n      },\n    })) as IBaseJson['plugins'][PluginPosition.Panel][number];\n\n    const installedPlugins = await this.prismaService.txClient().pluginInstall.findMany({\n      where: {\n        positionId: pluginPanelId,\n        position: PluginPosition.Panel,\n      },\n      select: {\n        id: true,\n        name: true,\n        pluginId: true,\n        storage: true,\n        position: true,\n        positionId: true,\n        baseId: true,\n      },\n    });\n\n    pluginPanel.pluginInstall = installedPlugins.map((plugin) => ({\n      ...plugin,\n      position: PluginPosition.Panel,\n      storage: plugin.storage ? JSON.parse(plugin.storage) : {},\n    }));\n\n    pluginPanel.layout = pluginPanel.layout ? JSON.parse(pluginPanel.layout) : undefined;\n\n    const pluginPanelNames = await this.prismaService.txClient().pluginPanel.findMany({\n      where: {\n        tableId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    const newName = getUniqName(\n      name ?? pluginPanel.name,\n      pluginPanelNames.map((item) => item.name)\n    );\n\n    pluginPanel.name = newName;\n\n    const baseId = installedPlugins[0].baseId;\n\n    return this.prismaService.$tx(async () => {\n      const { panelMap } = await this.baseImportService.createPanel(\n        baseId,\n        [pluginPanel],\n        { [tableId]: tableId },\n        {}\n      );\n\n      const newDashboardId = panelMap[pluginPanelId];\n\n      return {\n        id: newDashboardId,\n        name: newName,\n      };\n    });\n  }\n\n  async duplicatePluginPanelPlugin(\n    tableId: string,\n    pluginPanelId: string,\n    pluginInstallId: string,\n    duplicatePluginPanelInstalledPluginRo: IDuplicatePluginPanelInstalledPluginRo\n  ) {\n    const baseId = await this.getBaseId(tableId);\n\n    return this.prismaService.$tx(async () => {\n      const { name } = duplicatePluginPanelInstalledPluginRo;\n      const installedPlugins = await this.prismaService.txClient().pluginInstall.findFirstOrThrow({\n        where: {\n          baseId,\n          id: pluginInstallId,\n          position: PluginPosition.Panel,\n        },\n      });\n      const names = await this.prismaService.txClient().pluginInstall.findMany({\n        where: {\n          baseId,\n          positionId: pluginPanelId,\n          position: PluginPosition.Panel,\n        },\n        select: {\n          name: true,\n        },\n      });\n\n      const newName = getUniqName(\n        name ?? installedPlugins.name,\n        names.map((item) => item.name)\n      );\n\n      const newPluginInstallId = generatePluginInstallId();\n\n      await this.prismaService.txClient().pluginInstall.create({\n        data: {\n          ...installedPlugins,\n          id: newPluginInstallId,\n          name: newName,\n        },\n      });\n\n      const pluginPanel = await this.prismaService.txClient().pluginPanel.findFirstOrThrow({\n        where: {\n          tableId,\n          id: pluginPanelId,\n        },\n        select: {\n          layout: true,\n        },\n      });\n\n      const layout = pluginPanel.layout ? (JSON.parse(pluginPanel.layout) as IDashboardLayout) : [];\n\n      const sourceLayout = layout.find((item) => item.pluginInstallId === pluginInstallId);\n      layout.push({\n        pluginInstallId: newPluginInstallId,\n        x: (layout.length * 2) % 12,\n        y: Number.MAX_SAFE_INTEGER, // puts it at the bottom\n        w: sourceLayout?.w || 2,\n        h: sourceLayout?.h || 2,\n      });\n\n      await this.prismaService.txClient().pluginPanel.update({\n        where: {\n          id: pluginPanelId,\n        },\n        data: {\n          layout: JSON.stringify(layout),\n        },\n      });\n\n      return {\n        id: newPluginInstallId,\n        name: newName,\n      };\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/computed.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PrismaModule } from '@teable/db-main-prisma';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { TableDomainQueryModule } from '../../table-domain/table-domain-query.module';\nimport { RecordQueryBuilderModule } from '../query-builder';\nimport { RecordModule } from '../record.module';\nimport { ComputedDependencyCollectorService } from './services/computed-dependency-collector.service';\nimport { ComputedEvaluatorService } from './services/computed-evaluator.service';\nimport { ComputedOrchestratorService } from './services/computed-orchestrator.service';\nimport { LinkCascadeResolver } from './services/link-cascade-resolver';\nimport { RecordComputedUpdateService } from './services/record-computed-update.service';\n\n@Module({\n  imports: [\n    PrismaModule,\n    RecordQueryBuilderModule,\n    RecordModule,\n    CalculationModule,\n    TableDomainQueryModule,\n  ],\n  providers: [\n    DbProvider,\n    // Core services for the computed pipeline\n    ComputedDependencyCollectorService,\n    ComputedEvaluatorService,\n    ComputedOrchestratorService,\n    RecordComputedUpdateService,\n    LinkCascadeResolver,\n  ],\n  exports: [ComputedOrchestratorService],\n})\nexport class ComputedModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type {\n  IFilter,\n  IFilterItem,\n  ILinkFieldOptions,\n  IConditionalRollupFieldOptions,\n  IConditionalLookupOptions,\n  ILookupLinkOptionsVo,\n  AutoNumberFieldCore,\n  FieldCore,\n  TableDomain,\n} from '@teable/core';\nimport { DbFieldType, DriverClient, FieldType, isFieldReferenceValue } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { InjectDbProvider } from '../../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport { Timing } from '../../../../utils/timing';\nimport type { ICellContext } from '../../../calculation/utils/changes';\nimport { TableDomainQueryService } from '../../../table-domain/table-domain-query.service';\nimport {\n  LinkCascadeResolver,\n  type IAllTableLinkSeed,\n  type IExplicitLinkSeed,\n  type ILinkEdge,\n} from './link-cascade-resolver';\n\nexport interface ICellBasicContext {\n  recordId: string;\n  fieldId: string;\n}\n\ninterface IComputedImpactGroup {\n  fieldIds: Set<string>;\n  recordIds: Set<string>;\n  preferAutoNumberPaging?: boolean;\n}\n\nexport interface IComputedImpactByTable {\n  [tableId: string]: IComputedImpactGroup;\n}\n\nexport interface IComputedCollectResult {\n  impact: IComputedImpactByTable;\n  tableDomains: Map<string, TableDomain>;\n}\n\nexport interface IFieldChangeSource {\n  tableId: string;\n  fieldIds: string[];\n}\n\ninterface IConditionalRollupAdjacencyEdge {\n  tableId: string;\n  fieldId: string;\n  foreignTableId: string;\n  filter?: IFilter | null;\n}\n\ninterface ICollectorExecutionContext {\n  getTableDomain(tableId: string): Promise<TableDomain>;\n}\n\nconst ALL_RECORDS = Symbol('ALL_RECORDS');\nconst MAX_CONDITIONAL_ROLLUP_SAMPLE = 10_000;\n\n@Injectable()\nexport class ComputedDependencyCollectorService {\n  private logger = new Logger(ComputedDependencyCollectorService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    private readonly linkCascadeResolver: LinkCascadeResolver\n  ) {}\n\n  private createExecutionContext(\n    seed?: ReadonlyMap<string, TableDomain>\n  ): ICollectorExecutionContext {\n    const cache = new Map<string, Promise<TableDomain>>();\n    if (seed) {\n      for (const [tableId, domain] of seed) {\n        cache.set(tableId, Promise.resolve(domain));\n      }\n    }\n    return {\n      getTableDomain: (tableId: string) => {\n        let promise = cache.get(tableId);\n        if (!promise) {\n          promise = this.tableDomainQueryService.getTableDomainById(tableId);\n          cache.set(tableId, promise);\n        }\n        return promise;\n      },\n    };\n  }\n\n  private async getTableDomain(\n    tableId: string,\n    ctx?: ICollectorExecutionContext\n  ): Promise<TableDomain> {\n    if (ctx) {\n      return ctx.getTableDomain(tableId);\n    }\n    return this.tableDomainQueryService.getTableDomainById(tableId);\n  }\n\n  private buildSortFieldAccessor(column: string): Knex.Raw {\n    if (this.dbProvider.driver === DriverClient.Pg) {\n      return this.knex.raw(`??::json->'sort'->>'fieldId'`, [column]);\n    }\n    return this.knex.raw(`json_extract(??, '$.sort.fieldId')`, [column]);\n  }\n\n  private buildLookupOptionsAccessor(key: keyof ILookupLinkOptionsVo): Knex.Raw {\n    if (this.dbProvider.driver === DriverClient.Pg) {\n      return this.knex.raw(`lookup_options::json->>?`, [key]);\n    }\n    return this.knex.raw(`json_extract(lookup_options, '$.\"${key}\"')`);\n  }\n\n  private applySortFieldFilter(\n    qb: Knex.QueryBuilder,\n    column: string,\n    values: readonly string[]\n  ): void {\n    if (!values.length) return;\n    const accessor = this.buildSortFieldAccessor(column);\n    const { sql, bindings } = accessor.toSQL();\n    const placeholders = values.map(() => '?').join(', ');\n    qb.whereRaw(`${sql} in (${placeholders})`, [...bindings, ...values]);\n  }\n\n  private async getDbTableName(tableId: string, ctx?: ICollectorExecutionContext): Promise<string> {\n    const tableDomain = await this.getTableDomain(tableId, ctx);\n    return tableDomain.dbTableName;\n  }\n\n  private async getAllRecordIds(\n    tableId: string,\n    ctx?: ICollectorExecutionContext\n  ): Promise<string[]> {\n    const dbTable = await this.getDbTableName(tableId, ctx);\n    const { schema, table } = this.splitDbTableName(dbTable);\n    const qb = (schema ? this.knex.withSchema(schema) : this.knex).select('__id').from(table);\n    const rows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Array<{ __id: string }>>(qb.toQuery());\n    return rows.map((r) => r.__id).filter(Boolean);\n  }\n\n  private splitDbTableName(qualified: string): { schema?: string; table: string } {\n    const parts = qualified.split('.');\n    if (parts.length === 2) return { schema: parts[0], table: parts[1] };\n    return { table: qualified };\n  }\n\n  private buildValuesTable(alias: string, columnName: string, values: readonly string[]): Knex.Raw {\n    if (!values.length) {\n      throw new Error('buildValuesTable requires at least one value');\n    }\n    const placeholders = values.map(() => '(?)').join(', ');\n    const quotedColumn = `\"${columnName.replace(/\"/g, '\"\"')}\"`;\n    return this.knex.raw(`(values ${placeholders}) as ${alias} (${quotedColumn})`, values);\n  }\n\n  // Minimal link options needed for join table lookups\n  private parseLinkOptions(\n    raw: unknown\n  ): Pick<\n    ILinkFieldOptions,\n    'foreignTableId' | 'fkHostTableName' | 'selfKeyName' | 'foreignKeyName'\n  > | null {\n    let value: unknown = raw;\n    if (typeof value === 'string') {\n      try {\n        value = JSON.parse(value);\n      } catch {\n        return null;\n      }\n    }\n    if (!value || typeof value !== 'object') return null;\n    const obj = value as Record<string, unknown>;\n    const foreignTableId = obj['foreignTableId'];\n    const fkHostTableName = obj['fkHostTableName'];\n    const selfKeyName = obj['selfKeyName'];\n    const foreignKeyName = obj['foreignKeyName'];\n    if (\n      typeof foreignTableId === 'string' &&\n      typeof fkHostTableName === 'string' &&\n      typeof selfKeyName === 'string' &&\n      typeof foreignKeyName === 'string'\n    ) {\n      return { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName };\n    }\n    return null;\n  }\n\n  private parseOptionsLoose<T = unknown>(raw: unknown): T | null {\n    if (!raw) return null;\n    if (typeof raw === 'string') {\n      try {\n        return JSON.parse(raw) as T;\n      } catch {\n        return null;\n      }\n    }\n    if (typeof raw === 'object') return raw as T;\n    return null;\n  }\n\n  private async materializeAllRecordIds(\n    tableId: string,\n    cache: Map<string, string[]>,\n    ctx?: ICollectorExecutionContext\n  ): Promise<string[]> {\n    let ids = cache.get(tableId);\n    if (ids) {\n      return ids;\n    }\n    ids = await this.getAllRecordIds(tableId, ctx);\n    cache.set(tableId, ids);\n    return ids;\n  }\n\n  @Timing()\n  private buildLinkEdgesForTables(\n    tables: Iterable<string>,\n    tableDomains: ReadonlyMap<string, TableDomain>,\n    impact?: IComputedImpactByTable\n  ): ILinkEdge[] {\n    const edges: ILinkEdge[] = [];\n    const visited = new Set<string>();\n    for (const tableId of tables) {\n      if (!tableId || visited.has(tableId)) {\n        continue;\n      }\n      visited.add(tableId);\n      const tableDomain = this.getRequiredTableDomain(tableId, tableDomains);\n      const projection = impact?.[tableId]?.fieldIds;\n      if (!projection) continue;\n      const linkFields = tableDomain.getLinkFieldsByProjection(projection);\n      for (const field of linkFields) {\n        if (field.type !== FieldType.Link || field.isLookup) continue;\n        const opts = this.parseLinkOptions(field.options);\n        if (!opts) continue;\n        edges.push({\n          foreignTableId: opts.foreignTableId,\n          hostTableId: tableId,\n          fkTableName: opts.fkHostTableName,\n          selfKeyName: opts.selfKeyName,\n          foreignKeyName: opts.foreignKeyName,\n        });\n      }\n    }\n    return edges;\n  }\n\n  private async loadTableDomains(\n    tableIds: Iterable<string>,\n    ctx: ICollectorExecutionContext\n  ): Promise<Map<string, TableDomain>> {\n    const ids = Array.from(new Set(Array.from(tableIds).filter(Boolean)));\n    if (!ids.length) return new Map();\n\n    const domains = await this.tableDomainQueryService.getTableDomainsByIds(ids);\n    if (domains.size !== ids.length) {\n      const missing = ids.filter((id) => !domains.has(id));\n      if (missing.length) {\n        throw new Error(`TableDomain not found for tableIds: ${missing.join(',')}`);\n      }\n    }\n\n    return new Map(domains);\n  }\n\n  private getRequiredTableDomain(\n    tableId: string,\n    tableDomains: ReadonlyMap<string, TableDomain>\n  ): TableDomain {\n    const domain = tableDomains.get(tableId);\n    if (!domain) {\n      throw new Error(`TableDomain not found for tableId: ${tableId}`);\n    }\n    return domain;\n  }\n\n  private addExplicitSeed(\n    seedMap: Map<string, Set<string>>,\n    tableId: string,\n    ids: Iterable<string>\n  ): boolean {\n    const normalized = Array.from(ids).filter(Boolean);\n    if (!normalized.length) {\n      return false;\n    }\n    let set = seedMap.get(tableId);\n    if (!set) {\n      set = new Set<string>();\n      seedMap.set(tableId, set);\n    }\n    let added = false;\n    for (const id of normalized) {\n      if (!set.has(id)) {\n        set.add(id);\n        added = true;\n      }\n    }\n    return added;\n  }\n\n  private markAllSeed(target: Set<string>, tableId: string): boolean {\n    if (target.has(tableId)) {\n      return false;\n    }\n    target.add(tableId);\n    return true;\n  }\n\n  private findRecordSetGrowth(\n    previous: Record<string, Set<string> | typeof ALL_RECORDS | undefined>,\n    next: Record<string, Set<string> | typeof ALL_RECORDS>\n  ): string[] {\n    const changed: string[] = [];\n    const tableIds = new Set([...Object.keys(previous), ...Object.keys(next)]);\n    for (const tableId of tableIds) {\n      const prevSet = previous[tableId];\n      const nextSet = next[tableId];\n      if (!nextSet) continue;\n      if (!prevSet) {\n        changed.push(tableId);\n        continue;\n      }\n      if (prevSet === ALL_RECORDS && nextSet === ALL_RECORDS) {\n        continue;\n      }\n      if (prevSet !== ALL_RECORDS && nextSet === ALL_RECORDS) {\n        changed.push(tableId);\n        continue;\n      }\n      if (prevSet === ALL_RECORDS && nextSet !== ALL_RECORDS) {\n        // This should not happen; treat as unchanged.\n        continue;\n      }\n      if (prevSet instanceof Set && nextSet instanceof Set) {\n        if (nextSet.size > prevSet.size) {\n          changed.push(tableId);\n          continue;\n        }\n        let hasNew = false;\n        for (const id of nextSet) {\n          if (!prevSet.has(id)) {\n            hasNew = true;\n            break;\n          }\n        }\n        if (hasNew) {\n          changed.push(tableId);\n        }\n      }\n    }\n    return changed;\n  }\n\n  @Timing()\n  private async computeLinkClosure(params: {\n    impactedTables: ReadonlySet<string>;\n    explicitSeeds: ReadonlyMap<string, Set<string>>;\n    tablesWithAllRecords: ReadonlySet<string>;\n    linkEdges: ILinkEdge[];\n    tableDomains?: ReadonlyMap<string, TableDomain>;\n    ctx?: ICollectorExecutionContext;\n  }): Promise<Record<string, Set<string> | typeof ALL_RECORDS>> {\n    const { impactedTables, explicitSeeds, tablesWithAllRecords, linkEdges, tableDomains, ctx } =\n      params;\n\n    const explicitSeedList: IExplicitLinkSeed[] = [];\n    for (const [tableId, ids] of explicitSeeds) {\n      if (!ids.size) continue;\n      explicitSeedList.push({ tableId, recordIds: Array.from(ids) });\n    }\n\n    const allSeedList: IAllTableLinkSeed[] = [];\n    for (const tableId of tablesWithAllRecords) {\n      const domain = tableDomains?.get(tableId) ?? (await this.getTableDomain(tableId, ctx));\n      if (!domain) continue;\n      allSeedList.push({ tableId, dbTableName: domain.dbTableName });\n    }\n\n    if (!explicitSeedList.length && !allSeedList.length) {\n      return {};\n    }\n\n    if (!linkEdges.length) {\n      const fallback: Record<string, Set<string> | typeof ALL_RECORDS> = {};\n      for (const [tableId, ids] of explicitSeeds) {\n        if (!ids.size || !impactedTables.has(tableId)) continue;\n        fallback[tableId] = new Set(ids);\n      }\n      for (const tableId of tablesWithAllRecords) {\n        if (!impactedTables.has(tableId)) continue;\n        fallback[tableId] = ALL_RECORDS;\n      }\n      return fallback;\n    }\n\n    const rows = await this.linkCascadeResolver.resolve({\n      explicitSeeds: explicitSeedList,\n      allTableSeeds: allSeedList,\n      edges: linkEdges,\n    });\n\n    const aggregated = new Map<string, Set<string>>();\n    for (const row of rows) {\n      if (!impactedTables.has(row.tableId)) {\n        continue;\n      }\n      let set = aggregated.get(row.tableId);\n      if (!set) {\n        set = new Set<string>();\n        aggregated.set(row.tableId, set);\n      }\n      set.add(row.recordId);\n    }\n\n    const closure: Record<string, Set<string> | typeof ALL_RECORDS> = {};\n    for (const [tableId, set] of aggregated) {\n      closure[tableId] = set;\n    }\n\n    for (const [tableId, ids] of explicitSeeds) {\n      if (!ids.size || !impactedTables.has(tableId)) continue;\n      const existing = closure[tableId];\n      if (!existing) {\n        closure[tableId] = new Set(ids);\n        continue;\n      }\n      if (existing === ALL_RECORDS) {\n        continue;\n      }\n      ids.forEach((id) => existing.add(id));\n    }\n\n    for (const tableId of tablesWithAllRecords) {\n      if (!impactedTables.has(tableId)) continue;\n      closure[tableId] = ALL_RECORDS;\n    }\n\n    return closure;\n  }\n\n  private collectFilterFieldReferences(filter?: IFilter | null): {\n    hostFieldRefs: Array<{ fieldId: string; tableId?: string }>;\n    foreignFieldIds: Set<string>;\n  } {\n    const hostFieldRefs: Array<{ fieldId: string; tableId?: string }> = [];\n    const foreignFieldIds = new Set<string>();\n    if (!filter?.filterSet?.length) {\n      return { hostFieldRefs, foreignFieldIds };\n    }\n\n    const visitValue = (value: unknown) => {\n      if (!value) return;\n      if (Array.isArray(value)) {\n        value.forEach(visitValue);\n        return;\n      }\n      if (isFieldReferenceValue(value)) {\n        hostFieldRefs.push({ fieldId: value.fieldId, tableId: value.tableId });\n      }\n    };\n\n    const traverse = (current: IFilter) => {\n      if (!current?.filterSet?.length) return;\n      for (const entry of current.filterSet as Array<IFilter | IFilterItem>) {\n        if (entry && 'fieldId' in entry) {\n          const item = entry as IFilterItem;\n          foreignFieldIds.add(item.fieldId);\n          visitValue(item.value);\n        } else if (entry && 'filterSet' in entry) {\n          traverse(entry as IFilter);\n        }\n      }\n    };\n\n    traverse(filter);\n    return { hostFieldRefs, foreignFieldIds };\n  }\n\n  private async loadFieldInstances(\n    tableId: string,\n    fieldIds: Iterable<string>,\n    ctx?: ICollectorExecutionContext\n  ): Promise<Map<string, FieldCore>> {\n    const ids = Array.from(new Set(Array.from(fieldIds).filter(Boolean)));\n    if (!ids.length) {\n      return new Map();\n    }\n\n    const tableDomain = await this.getTableDomain(tableId, ctx);\n    const map = new Map<string, FieldCore>();\n    for (const id of ids) {\n      const field = tableDomain.getField(id);\n      if (field) {\n        map.set(field.id, field);\n      }\n    }\n    return map;\n  }\n\n  private async resolveConditionalSortDependents(\n    sortFieldIds: readonly string[]\n  ): Promise<Array<{ tableId: string; fieldId: string; sortFieldId: string }>> {\n    if (!sortFieldIds.length) return [];\n\n    const prisma = this.prismaService.txClient();\n    const conditionalQuery = this.knex('field')\n      .select({\n        tableId: 'table_id',\n        fieldId: 'id',\n        sortFieldId: this.buildSortFieldAccessor('options'),\n      })\n      .whereNull('deleted_time')\n      .where('type', FieldType.ConditionalRollup)\n      .modify((qb) => this.applySortFieldFilter(qb, 'options', sortFieldIds));\n    const lookupQuery = this.knex('field')\n      .select({\n        tableId: 'table_id',\n        fieldId: 'id',\n        sortFieldId: this.buildSortFieldAccessor('lookup_options'),\n      })\n      .whereNull('deleted_time')\n      .where('is_conditional_lookup', true)\n      .modify((qb) => this.applySortFieldFilter(qb, 'lookup_options', sortFieldIds));\n\n    const [conditionalRollups, conditionalLookups] = await Promise.all([\n      prisma.$queryRawUnsafe<Array<{ tableId: string; fieldId: string; sortFieldId: string }>>(\n        conditionalQuery.toQuery()\n      ),\n      prisma.$queryRawUnsafe<Array<{ tableId: string; fieldId: string; sortFieldId: string }>>(\n        lookupQuery.toQuery()\n      ),\n    ]);\n\n    const results: Array<{ tableId: string; fieldId: string; sortFieldId: string }> = [];\n    for (const row of conditionalRollups) {\n      if (row.sortFieldId) {\n        results.push(row);\n      }\n    }\n    for (const row of conditionalLookups) {\n      if (row.sortFieldId) {\n        results.push(row);\n      }\n    }\n\n    return results;\n  }\n\n  async getConditionalSortDependents(\n    sortFieldIds: readonly string[]\n  ): Promise<Array<{ tableId: string; fieldId: string; sortFieldId: string }>> {\n    return this.resolveConditionalSortDependents(sortFieldIds);\n  }\n\n  /**\n   * Resolve link field IDs among the provided field IDs and include their symmetric counterparts.\n   */\n  @Timing()\n  private async resolveRelatedLinkFieldIds(\n    fieldIds: string[],\n    fieldToTableMap?: Map<string, string>,\n    ctx?: ICollectorExecutionContext\n  ): Promise<string[]> {\n    if (!fieldIds.length) return [];\n    const groupedByTable = new Map<string, string[]>();\n    for (const fieldId of fieldIds) {\n      const tableId = fieldToTableMap?.get(fieldId);\n      if (!tableId) continue;\n      const bucket = groupedByTable.get(tableId);\n      if (bucket) {\n        bucket.push(fieldId);\n      } else {\n        groupedByTable.set(tableId, [fieldId]);\n      }\n    }\n\n    const result = new Set<string>();\n    for (const [tableId, ids] of groupedByTable) {\n      const tableDomain = await this.getTableDomain(tableId, ctx);\n      for (const id of ids) {\n        const field = tableDomain.getField(id);\n        if (!field || field.type !== FieldType.Link || field.isLookup) continue;\n        result.add(field.id);\n        const opts = this.parseOptionsLoose<{ symmetricFieldId?: string }>(field.options);\n        if (opts?.symmetricFieldId) result.add(opts.symmetricFieldId);\n      }\n    }\n    return Array.from(result);\n  }\n\n  /**\n   * Find lookup/rollup fields whose lookupOptions.linkFieldId equals any of the provided link IDs.\n   * Returns a map: tableId -> Set<fieldId>\n   */\n  @Timing()\n  private async findLookupsByLinkIds(linkFieldIds: string[]): Promise<Record<string, Set<string>>> {\n    const acc: Record<string, Set<string>> = {};\n    const ids = Array.from(new Set(linkFieldIds.filter(Boolean)));\n    if (!ids.length) return acc;\n\n    const accessor = this.buildLookupOptionsAccessor('linkFieldId');\n    const { sql, bindings } = accessor.toSQL();\n    const placeholders = ids.map(() => '?').join(', ');\n    const query = this.knex('field')\n      .select({ tableId: 'table_id', id: 'id' })\n      .whereNull('deleted_time')\n      .whereRaw(`${sql} in (${placeholders})`, [...bindings, ...ids]);\n\n    const rows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Array<{ tableId: string; id: string }>>(query.toQuery());\n    for (const r of rows) {\n      if (!r.tableId || !r.id) continue;\n      (acc[r.tableId] ||= new Set<string>()).add(r.id);\n    }\n    return acc;\n  }\n\n  /**\n   * Same as collectDependentFieldIds but groups by table id directly in SQL.\n   * Returns a map: tableId -> Set<fieldId>\n   */\n  @Timing()\n  private async collectDependentFieldsByTable(\n    startFieldIds: string[],\n    excludeFieldIds?: string[]\n  ): Promise<Record<string, Set<string>>> {\n    if (!startFieldIds.length) return {};\n\n    const nonRecursive = this.knex\n      .select('from_field_id', 'to_field_id')\n      .from('reference')\n      .whereIn('from_field_id', startFieldIds);\n\n    const recursive = this.knex\n      .select('r.from_field_id', 'r.to_field_id')\n      .from({ r: 'reference' })\n      .join({ d: 'dep_graph' }, 'r.from_field_id', 'd.to_field_id');\n\n    const depBuilder = this.knex\n      .withRecursive('dep_graph', ['from_field_id', 'to_field_id'], nonRecursive.union(recursive))\n      .distinct({ to_field_id: 'dep_graph.to_field_id', table_id: 'f.table_id' })\n      .from('dep_graph')\n      .join({ f: 'field' }, 'f.id', 'dep_graph.to_field_id')\n      .whereNull('f.deleted_time')\n      .andWhere((qb) => {\n        qb.where('f.is_lookup', true)\n          .orWhere('f.is_computed', true)\n          .orWhere('f.type', FieldType.Link)\n          .orWhere('f.type', FieldType.Formula)\n          .orWhere('f.type', FieldType.Rollup)\n          .orWhere('f.type', FieldType.ConditionalRollup);\n      });\n    if (excludeFieldIds?.length) {\n      depBuilder.whereNotIn('dep_graph.to_field_id', excludeFieldIds);\n    }\n\n    // Also consider the changed Link fields themselves as impacted via UNION at SQL level.\n    const linkSelf = this.knex\n      .select({ to_field_id: 'f.id', table_id: 'f.table_id' })\n      .from({ f: 'field' })\n      .whereIn('f.id', startFieldIds)\n      .andWhere('f.type', FieldType.Link)\n      .whereNull('f.deleted_time');\n    // Note: we intentionally do NOT exclude starting link fields even if they\n    // are part of the changedFieldIds. We still want to include them in the\n    // impacted set so that their display columns are persisted via\n    // updateFromSelect. The computed orchestrator will independently avoid\n    // publishing ops for base-changed fields (including links).\n\n    const unionBuilder = this.knex\n      .select('*')\n      .from(depBuilder.as('dep'))\n      .union(function () {\n        this.select('*').from(linkSelf.as('link_self'));\n      });\n\n    const rows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ to_field_id: string; table_id: string }[]>(unionBuilder.toQuery());\n\n    const result: Record<string, Set<string>> = {};\n    for (const r of rows) {\n      if (!r.table_id || !r.to_field_id) continue;\n      (result[r.table_id] ||= new Set<string>()).add(r.to_field_id);\n    }\n    return result;\n  }\n\n  private async collectReferencedFieldsByTable(\n    fieldIds: string[]\n  ): Promise<Record<string, Set<string>>> {\n    const ids = Array.from(new Set(fieldIds.filter(Boolean)));\n    if (!ids.length) {\n      return {};\n    }\n\n    const refRows = await this.prismaService.txClient().reference.findMany({\n      where: { toFieldId: { in: ids } },\n      select: { fromFieldId: true },\n    });\n    const fromIds = Array.from(\n      new Set(refRows.map((row) => row.fromFieldId).filter((id): id is string => !!id))\n    );\n    if (!fromIds.length) {\n      return {};\n    }\n\n    const fields = await this.prismaService.txClient().field.findMany({\n      where: { id: { in: fromIds }, deletedTime: null },\n      select: { id: true, tableId: true },\n    });\n\n    const result: Record<string, Set<string>> = {};\n    for (const field of fields) {\n      if (!field.tableId) continue;\n      (result[field.tableId] ||= new Set<string>()).add(field.id);\n    }\n    return result;\n  }\n\n  private async getConditionalRollupImpactedRecordIds(\n    edge: IConditionalRollupAdjacencyEdge,\n    foreignRecordIds: string[],\n    changeContextMap?: Map<string, ICellContext[]>,\n    ctx?: ICollectorExecutionContext\n  ): Promise<string[] | typeof ALL_RECORDS> {\n    if (!foreignRecordIds.length) {\n      return [];\n    }\n    const uniqueForeignIds = Array.from(new Set(foreignRecordIds.filter(Boolean)));\n    if (uniqueForeignIds.length > MAX_CONDITIONAL_ROLLUP_SAMPLE) {\n      return ALL_RECORDS;\n    }\n    if (!uniqueForeignIds.length) {\n      return [];\n    }\n\n    const filter = edge.filter;\n    if (!filter) {\n      return ALL_RECORDS;\n    }\n\n    const { hostFieldRefs, foreignFieldIds } = this.collectFilterFieldReferences(filter);\n    if (!hostFieldRefs.length) {\n      return ALL_RECORDS;\n    }\n\n    if (foreignFieldIds.size === 0) {\n      return ALL_RECORDS;\n    }\n\n    if (hostFieldRefs.some((ref) => ref.tableId && ref.tableId !== edge.tableId)) {\n      return ALL_RECORDS;\n    }\n\n    const uniqueHostFieldIds = Array.from(new Set(hostFieldRefs.map((ref) => ref.fieldId)));\n    const hostFieldMap = await this.loadFieldInstances(edge.tableId, uniqueHostFieldIds, ctx);\n    if (hostFieldMap.size !== uniqueHostFieldIds.length) {\n      return ALL_RECORDS;\n    }\n\n    const foreignFieldMap = await this.loadFieldInstances(\n      edge.foreignTableId,\n      foreignFieldIds,\n      ctx\n    );\n    if (foreignFieldMap.size !== foreignFieldIds.size) {\n      return ALL_RECORDS;\n    }\n\n    // Note: when any foreign-side filter column is JSON, we bail out to ALL_RECORDS.\n    // The values-based subquery we build below uses parameter binding which serialises JSON\n    // as plain text. Postgres then attempts to cast that \"text\" into json/jsonb when evaluating\n    // operators like `@>` or `?`. Without explicit casts (e.g. `::jsonb`) the parser errors out:\n    //   ERROR: invalid input syntax for type json DETAIL: Expected \":\", but found \"}\".\n    // Rather than attempt to inline JSON literals with per-driver casting (and reimplement\n    // Prisma's quoting rules), we fall back to the conservative ALL_RECORDS path. For now this\n    // keeps correctness for complex filters (array_contains, field references, etc.) while\n    // avoiding subtle type issues. If/when we add a typed VALUES helper we can revisit this.\n    if (\n      Array.from(foreignFieldMap.values()).some((field) => field.dbFieldType === DbFieldType.Json)\n    ) {\n      return ALL_RECORDS;\n    }\n\n    if (\n      Array.from(foreignFieldMap.values()).some((field) => field.dbFieldType === DbFieldType.Json)\n    ) {\n      return ALL_RECORDS;\n    }\n\n    const hostTableName = await this.getDbTableName(edge.tableId, ctx);\n    const foreignTableName = await this.getDbTableName(edge.foreignTableId, ctx);\n\n    const hostAlias = '__host';\n    const foreignAlias = '__foreign';\n    const { schema: foreignSchema, table: foreignTable } = this.splitDbTableName(foreignTableName);\n    const foreignFrom = () =>\n      foreignSchema\n        ? this.knex.raw('??.?? as ??', [foreignSchema, foreignTable, foreignAlias])\n        : this.knex.raw('?? as ??', [foreignTable, foreignAlias]);\n\n    const quoteIdentifier = (name: string) => name.replace(/\"/g, '\"\"');\n\n    const selectionMap = new Map<string, string>();\n    const foreignFieldObj: Record<string, FieldCore> = {};\n    const foreignFieldByDbName = new Map<string, FieldCore>();\n    for (const [id, field] of foreignFieldMap) {\n      selectionMap.set(id, `\"${foreignAlias}\".\"${quoteIdentifier(field.dbFieldName)}\"`);\n      foreignFieldObj[id] = field;\n      if (field.dbFieldName) {\n        foreignFieldByDbName.set(field.dbFieldName, field);\n      }\n    }\n\n    const fieldReferenceSelectionMap = new Map<string, string>();\n    const fieldReferenceFieldMap = new Map<string, FieldCore>();\n    for (const [id, field] of hostFieldMap) {\n      fieldReferenceSelectionMap.set(id, `\"${hostAlias}\".\"${quoteIdentifier(field.dbFieldName)}\"`);\n      fieldReferenceFieldMap.set(id, field);\n    }\n\n    const existsIdAlias = '__foreign_ids';\n    const existsSubquery = this.knex\n      .select(this.knex.raw('1'))\n      .from(foreignFrom())\n      .join(\n        this.buildValuesTable(existsIdAlias, '__id', uniqueForeignIds),\n        `${foreignAlias}.__id`,\n        `${existsIdAlias}.__id`\n      );\n\n    this.dbProvider\n      .filterQuery(existsSubquery, foreignFieldObj, filter, undefined, {\n        selectionMap,\n        fieldReferenceSelectionMap,\n        fieldReferenceFieldMap,\n      })\n      .appendQueryBuilder();\n\n    const queryBuilder = this.knex\n      .select(this.knex.raw(`\"${hostAlias}\".\"__id\" as id`))\n      .from(`${hostTableName} as ${hostAlias}`)\n      .whereExists(existsSubquery);\n\n    const sql = queryBuilder.toQuery();\n    this.logger.debug(`Conditional Rollup Impacted Records SQL: ${sql}`);\n\n    const rows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id?: string; __id?: string }[]>(sql);\n\n    const ids = new Set<string>();\n    for (const row of rows) {\n      const id = row.id || row.__id;\n      if (id) {\n        ids.add(id);\n      }\n    }\n\n    if (!changeContextMap || !changeContextMap.size) {\n      return Array.from(ids);\n    }\n\n    const foreignDbFieldNamesOrdered = Array.from(\n      new Set(\n        Array.from(foreignFieldIds)\n          .map((fid) => foreignFieldMap.get(fid)?.dbFieldName)\n          .filter((name): name is string => !!name)\n      )\n    );\n\n    if (foreignDbFieldNamesOrdered.length !== foreignFieldIds.size) {\n      return ALL_RECORDS;\n    }\n\n    const selectColumns = ['__id', ...foreignDbFieldNamesOrdered];\n    const baseIdAlias = '__base_ids';\n    const baseRowsQuery = this.knex\n      .select(\n        ...selectColumns.map((column) =>\n          this.knex.raw(\n            `\"${foreignAlias}\".\"${quoteIdentifier(column)}\" as \"${quoteIdentifier(column)}\"`\n          )\n        )\n      )\n      .from(foreignFrom())\n      .join(\n        this.buildValuesTable(baseIdAlias, '__id', uniqueForeignIds),\n        `${foreignAlias}.__id`,\n        `${baseIdAlias}.__id`\n      );\n\n    const baseRows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Record<string, unknown>[]>(baseRowsQuery.toQuery());\n    const baseRowById = new Map<string, Record<string, unknown>>();\n    for (const row of baseRows) {\n      const id = row['__id'];\n      if (typeof id === 'string') {\n        baseRowById.set(id, row);\n      }\n    }\n\n    const updatedRows: Record<string, unknown>[] = [];\n    for (const recordId of uniqueForeignIds) {\n      const base: Record<string, unknown> = {\n        ...(baseRowById.get(recordId) ?? {}),\n        __id: recordId,\n      };\n      const recordContexts = changeContextMap.get(recordId) ?? [];\n      for (const ctx of recordContexts) {\n        const field = foreignFieldMap.get(ctx.fieldId);\n        if (!field) continue;\n        const converter = (\n          field as unknown as {\n            convertCellValue2DBValue?: (value: unknown) => unknown;\n          }\n        ).convertCellValue2DBValue;\n        const dbValue =\n          typeof converter === 'function' ? converter.call(field, ctx.newValue) : ctx.newValue;\n        base[field.dbFieldName] = dbValue;\n      }\n\n      let missing = false;\n      for (const fieldId of foreignFieldIds) {\n        const field = foreignFieldMap.get(fieldId);\n        if (!field) {\n          missing = true;\n          break;\n        }\n        if (!(field.dbFieldName in base)) {\n          missing = true;\n          break;\n        }\n      }\n      if (missing) {\n        return ALL_RECORDS;\n      }\n      updatedRows.push(base);\n    }\n\n    if (!updatedRows.length) {\n      return Array.from(ids);\n    }\n\n    const valueColumns = ['__id', ...foreignDbFieldNamesOrdered];\n    const valuesMatrix = updatedRows.map((row) => {\n      return valueColumns.map((column) => {\n        if (!(column in row)) return undefined;\n        return row[column];\n      });\n    });\n\n    if (valuesMatrix.some((row) => row.some((value) => typeof value === 'undefined'))) {\n      return ALL_RECORDS;\n    }\n\n    const bindings = valuesMatrix.flat();\n    const columnsSql = valueColumns.map((col) => `\"${quoteIdentifier(col)}\"`).join(', ');\n\n    const resolveColumnType = (column: string): string => {\n      if (column === '__id') {\n        return 'text';\n      }\n      const field = foreignFieldByDbName.get(column);\n      switch (field?.dbFieldType) {\n        case DbFieldType.Integer:\n          return 'integer';\n        case DbFieldType.Real:\n          return 'double precision';\n        case DbFieldType.Boolean:\n          return 'boolean';\n        case DbFieldType.DateTime:\n          return 'timestamp';\n        case DbFieldType.Blob:\n          return 'bytea';\n        case DbFieldType.Json:\n          return 'jsonb';\n        case DbFieldType.Text:\n        default:\n          return 'text';\n      }\n    };\n\n    const columnTypeSql = valueColumns.map(resolveColumnType);\n    const unionSelectSql = valuesMatrix\n      .map((row) => {\n        const columnAssignments = row\n          .map((_, columnIndex) => {\n            const typeSql = columnTypeSql[columnIndex];\n            const columnAlias = `\"${quoteIdentifier(valueColumns[columnIndex])}\"`;\n            return `CAST(? AS ${typeSql}) AS ${columnAlias}`;\n          })\n          .join(', ');\n        return `select ${columnAssignments}`;\n      })\n      .join(' union all ');\n\n    const derivedRaw = this.knex.raw(\n      `(${unionSelectSql}) as ${foreignAlias} (${columnsSql})`,\n      bindings\n    );\n    const postExistsSubquery = this.knex.select(this.knex.raw('1')).from(derivedRaw);\n\n    this.dbProvider\n      .filterQuery(postExistsSubquery, foreignFieldObj, filter, undefined, {\n        selectionMap,\n        fieldReferenceSelectionMap,\n        fieldReferenceFieldMap,\n      })\n      .appendQueryBuilder();\n\n    const postQueryBuilder = this.knex\n      .select(this.knex.raw(`\"${hostAlias}\".\"__id\" as id`))\n      .from(`${hostTableName} as ${hostAlias}`)\n      .whereExists(postExistsSubquery);\n\n    const postQuery = postQueryBuilder.toQuery();\n    this.logger.debug('postQuery %s', postQuery);\n\n    const postRows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id?: string; __id?: string }[]>(postQuery);\n\n    for (const row of postRows) {\n      const id = row.id || row.__id;\n      if (id) {\n        ids.add(id);\n      }\n    }\n\n    return Array.from(ids);\n  }\n\n  /**\n   * Build adjacency maps for link and conditional rollup relationships among the supplied tables.\n   */\n  @Timing()\n  private getAdjacencyMaps(\n    tableDomains: ReadonlyMap<string, TableDomain>,\n    projection?: IComputedImpactByTable\n  ): {\n    link: Record<string, Set<string>>;\n    conditionalRollup: Record<string, IConditionalRollupAdjacencyEdge[]>;\n  } {\n    const linkAdj: Record<string, Set<string>> = {};\n    const conditionalRollupAdj: Record<string, IConditionalRollupAdjacencyEdge[]> = {};\n\n    if (!tableDomains.size) {\n      return { link: linkAdj, conditionalRollup: conditionalRollupAdj };\n    }\n\n    for (const [tableId, tableDomain] of tableDomains) {\n      const projected = projection?.[tableId]?.fieldIds;\n      for (const field of tableDomain.fieldList) {\n        if (projected && !projected.has(field.id)) continue;\n        if (field.type === FieldType.Link && !field.isLookup) {\n          const opts = this.parseLinkOptions(field.options);\n          const from = opts?.foreignTableId;\n          if (from) {\n            (linkAdj[from] ||= new Set<string>()).add(tableId);\n          }\n          continue;\n        }\n\n        if (field.type === FieldType.ConditionalRollup) {\n          const opts = this.parseOptionsLoose<IConditionalRollupFieldOptions>(field.options);\n          const foreignTableId = opts?.foreignTableId;\n          if (!foreignTableId) continue;\n          (conditionalRollupAdj[foreignTableId] ||= []).push({\n            tableId,\n            fieldId: field.id,\n            foreignTableId,\n            filter: opts?.filter ?? undefined,\n          });\n          continue;\n        }\n\n        if (field.isConditionalLookup) {\n          const opts = this.parseOptionsLoose<IConditionalLookupOptions>(field.lookupOptions);\n          const foreignTableId = opts?.foreignTableId;\n          if (!foreignTableId) continue;\n          (conditionalRollupAdj[foreignTableId] ||= []).push({\n            tableId,\n            fieldId: field.id,\n            foreignTableId,\n            filter: opts?.filter ?? undefined,\n          });\n        }\n      }\n    }\n\n    return { link: linkAdj, conditionalRollup: conditionalRollupAdj };\n  }\n\n  /**\n   * Collect impacted fields and records by starting from changed field definitions.\n   * - Includes the starting fields themselves when they are computed/lookup/rollup/formula.\n   * - Expands to dependent computed/lookup/link/rollup fields via reference graph (SQL CTE).\n   * - Seeds recordIds with ALL records from tables owning the changed fields.\n   * - Propagates recordIds across link relationships via junction tables.\n   */\n  async collectForFieldChanges(sources: IFieldChangeSource[]): Promise<IComputedImpactByTable> {\n    const execCtx = this.createExecutionContext();\n    const startFieldIds = Array.from(new Set(sources.flatMap((s) => s.fieldIds || [])));\n    if (!startFieldIds.length) return {};\n\n    // Group starting fields by table and fetch minimal metadata\n    const fieldToTableMap = new Map<string, string>();\n    const byTable: Record<string, string[]> = {};\n    const startFields: Array<{\n      id: string;\n      tableId: string;\n      isComputed?: boolean;\n      isLookup?: boolean;\n      type: FieldType;\n    }> = [];\n\n    for (const source of sources) {\n      if (!source.fieldIds?.length) continue;\n      const tableDomain = await this.getTableDomain(source.tableId, execCtx);\n      for (const fieldId of source.fieldIds) {\n        const field = tableDomain.getField(fieldId);\n        if (!field) continue;\n        startFields.push({\n          id: field.id,\n          tableId: source.tableId,\n          isComputed: field.isComputed,\n          isLookup: field.isLookup,\n          type: field.type,\n        });\n        fieldToTableMap.set(field.id, source.tableId);\n        (byTable[source.tableId] ||= []).push(field.id);\n      }\n    }\n\n    // 1) Dependent fields grouped by table\n    const depByTable = await this.collectDependentFieldsByTable(startFieldIds);\n    const upstreamByTable = await this.collectReferencedFieldsByTable(startFieldIds);\n\n    // Initialize impact with dependent fields\n    const impact: IComputedImpactByTable = Object.entries(depByTable).reduce((acc, [tid, fset]) => {\n      acc[tid] = { fieldIds: new Set(fset), recordIds: new Set<string>() };\n      return acc;\n    }, {} as IComputedImpactByTable);\n\n    for (const [tid, fset] of Object.entries(upstreamByTable)) {\n      const group = (impact[tid] ||= {\n        fieldIds: new Set<string>(),\n        recordIds: new Set<string>(),\n      });\n      fset.forEach((fid) => group.fieldIds.add(fid));\n    }\n\n    // Ensure starting fields themselves are included so conversions can compare old/new values\n    for (const f of startFields) {\n      (impact[f.tableId] ||= {\n        fieldIds: new Set<string>(),\n        recordIds: new Set<string>(),\n      }).fieldIds.add(f.id);\n    }\n\n    // Ensure conditional rollup/lookup fields that sort by the changed fields are always impacted,\n    // even if historical references are missing.\n    const sortDependents = await this.resolveConditionalSortDependents(startFieldIds);\n    for (const { tableId, fieldId } of sortDependents) {\n      (impact[tableId] ||= {\n        fieldIds: new Set<string>(),\n        recordIds: new Set<string>(),\n      }).fieldIds.add(fieldId);\n    }\n\n    const relatedLinkIds = await this.resolveRelatedLinkFieldIds(\n      startFieldIds,\n      fieldToTableMap,\n      execCtx\n    );\n    const fallbackLookupIds = new Set<string>();\n    if (relatedLinkIds.length) {\n      const byTable = await this.findLookupsByLinkIds(relatedLinkIds);\n      for (const [tid, fset] of Object.entries(byTable)) {\n        const group = (impact[tid] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        });\n        fset.forEach((fid) => {\n          if (!group.fieldIds.has(fid)) {\n            group.fieldIds.add(fid);\n            fallbackLookupIds.add(fid);\n          }\n        });\n      }\n    }\n\n    if (fallbackLookupIds.size) {\n      // Legacy compatibility: pre-link reference rows created before lookupOptions.linkFieldId\n      // existed do not include the link→lookup edge. We need to synthesize those missing\n      // dependencies so downstream lookups/formulas still recompute.\n      const extraDeps = await this.collectDependentFieldsByTable(Array.from(fallbackLookupIds));\n      for (const [tid, fset] of Object.entries(extraDeps)) {\n        const group = (impact[tid] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        });\n        fset.forEach((fid) => group.fieldIds.add(fid));\n      }\n    }\n\n    if (!Object.keys(impact).length) return {};\n\n    const originTableIds = Object.keys(byTable);\n    const impactedTables = new Set([...Object.keys(impact), ...originTableIds]);\n    if (!impactedTables.size) {\n      return {};\n    }\n\n    for (const tid of originTableIds) {\n      const group = impact[tid];\n      if (group) group.preferAutoNumberPaging = true;\n    }\n\n    const tableDomains = await this.loadTableDomains(impactedTables, execCtx);\n    const linkEdges = this.buildLinkEdgesForTables(impactedTables, tableDomains, impact);\n    const explicitSeeds = new Map<string, Set<string>>();\n    const tablesWithAllRecords = new Set<string>(originTableIds);\n\n    const { link: linkAdj, conditionalRollup: referenceAdj } = this.getAdjacencyMaps(\n      tableDomains,\n      impact\n    );\n\n    let recordSets = await this.computeLinkClosure({\n      impactedTables,\n      explicitSeeds,\n      tablesWithAllRecords,\n      linkEdges,\n      ctx: execCtx,\n    });\n\n    const queue: string[] = [];\n    const queued = new Set<string>();\n    const enqueueConditional = (tableId: string) => {\n      if (!tableId || queued.has(tableId)) {\n        return;\n      }\n      queued.add(tableId);\n      queue.push(tableId);\n    };\n    const enqueueLinkDependents = (tableId: string) => {\n      const targets = linkAdj[tableId];\n      if (!targets) return;\n      targets.forEach((tid) => enqueueConditional(tid));\n    };\n\n    const initialGrowth = this.findRecordSetGrowth({}, recordSets);\n    initialGrowth.forEach((tid) => {\n      enqueueConditional(tid);\n      enqueueLinkDependents(tid);\n    });\n    const materializedAllRecords = new Map<string, string[]>();\n\n    while (queue.length) {\n      const src = queue.shift()!;\n      queued.delete(src);\n\n      const referenceEdges = (referenceAdj[src] || []).filter((edge) => {\n        const targetGroup = impact[edge.tableId];\n        return !!targetGroup && targetGroup.fieldIds.has(edge.fieldId);\n      });\n      if (!referenceEdges.length) {\n        continue;\n      }\n\n      const rawSet = recordSets[src];\n      if (!rawSet) {\n        continue;\n      }\n\n      let currentIds: string[] = [];\n      let shouldMaterializeAllRecords = false;\n      if (rawSet === ALL_RECORDS) {\n        const needsMaterialization = referenceEdges.some((edge) => {\n          const targetSet = recordSets[edge.tableId];\n          return targetSet !== ALL_RECORDS && edge.tableId !== src;\n        });\n        shouldMaterializeAllRecords = needsMaterialization;\n        if (shouldMaterializeAllRecords) {\n          currentIds = await this.materializeAllRecordIds(src, materializedAllRecords, execCtx);\n        }\n      } else {\n        currentIds = Array.from(rawSet);\n      }\n      if (!currentIds.length && shouldMaterializeAllRecords) {\n        continue;\n      }\n\n      const eagerReferenceMatches: Array<{\n        edge: IConditionalRollupAdjacencyEdge;\n        matched: typeof ALL_RECORDS;\n      }> = [];\n      const referencePromises: Array<\n        Promise<{ edge: IConditionalRollupAdjacencyEdge; matched: string[] | typeof ALL_RECORDS }>\n      > = [];\n      for (const edge of referenceEdges) {\n        const targetGroup = impact[edge.tableId];\n        if (!targetGroup || !targetGroup.fieldIds.has(edge.fieldId)) continue;\n        if (\n          rawSet === ALL_RECORDS &&\n          (!shouldMaterializeAllRecords ||\n            recordSets[edge.tableId] === ALL_RECORDS ||\n            edge.tableId === src)\n        ) {\n          eagerReferenceMatches.push({ edge, matched: ALL_RECORDS });\n          continue;\n        }\n        if (!currentIds.length) continue;\n        referencePromises.push(\n          this.getConditionalRollupImpactedRecordIds(edge, currentIds, undefined, execCtx).then(\n            (matched) => ({\n              edge,\n              matched,\n            })\n          )\n        );\n      }\n\n      const referenceResults = [\n        ...eagerReferenceMatches,\n        ...(await Promise.all(referencePromises)),\n      ];\n\n      let dirty = false;\n      for (const { edge, matched } of referenceResults) {\n        const targetGroup = impact[edge.tableId];\n        if (!targetGroup || !targetGroup.fieldIds.has(edge.fieldId)) continue;\n        if (matched === ALL_RECORDS) {\n          const updated = this.markAllSeed(tablesWithAllRecords, edge.tableId);\n          if (updated) {\n            targetGroup.preferAutoNumberPaging = true;\n            dirty = true;\n            enqueueConditional(edge.tableId);\n            enqueueLinkDependents(edge.tableId);\n          }\n          continue;\n        }\n        if (!matched.length) continue;\n        const updated = this.addExplicitSeed(explicitSeeds, edge.tableId, matched);\n        if (updated) {\n          dirty = true;\n          enqueueConditional(edge.tableId);\n          enqueueLinkDependents(edge.tableId);\n        }\n      }\n\n      if (dirty) {\n        const nextRecordSets = await this.computeLinkClosure({\n          impactedTables,\n          explicitSeeds,\n          tablesWithAllRecords,\n          linkEdges,\n          ctx: execCtx,\n        });\n        const growth = this.findRecordSetGrowth(recordSets, nextRecordSets);\n        growth.forEach((tid) => {\n          enqueueConditional(tid);\n          enqueueLinkDependents(tid);\n        });\n        recordSets = nextRecordSets;\n      }\n    }\n\n    for (const [tid, group] of Object.entries(impact)) {\n      const raw = recordSets[tid];\n      if (raw === ALL_RECORDS) {\n        group.preferAutoNumberPaging = true;\n        continue;\n      }\n      if (raw && raw.size) {\n        raw.forEach((id) => group.recordIds.add(id));\n      }\n    }\n\n    for (const tid of Object.keys(impact)) {\n      const g = impact[tid];\n      if (!g.fieldIds.size || (!g.recordIds.size && !g.preferAutoNumberPaging)) {\n        delete impact[tid];\n      }\n    }\n\n    return impact;\n  }\n\n  @Timing()\n  private async getFormulaFieldsWithoutDependencies(\n    tableId: string,\n    excludeFieldIds?: string[]\n  ): Promise<string[]> {\n    const query = this.knex\n      .select({ id: 'f.id' })\n      .from({ f: 'field' })\n      .leftJoin({ r: 'reference' }, 'r.to_field_id', 'f.id')\n      .where('f.table_id', tableId)\n      .whereNull('f.deleted_time')\n      .where('f.type', FieldType.Formula)\n      .andWhere((qb) => {\n        qb.whereNull('f.is_lookup').orWhere('f.is_lookup', false);\n      })\n      .andWhereRaw('COALESCE(f.has_error, false) = false')\n      .groupBy('f.id')\n      .havingRaw('COUNT(r.from_field_id) = 0');\n\n    if (excludeFieldIds?.length) {\n      query.whereNotIn('f.id', excludeFieldIds);\n    }\n\n    const sql = query.toQuery();\n    const rows = await this.prismaService.txClient().$queryRawUnsafe<{ id: string }[]>(sql);\n    return rows.map((row) => row.id).filter(Boolean);\n  }\n\n  private getAutoNumberFieldIds(table: TableDomain, excludeFieldIds?: string[]): string[] {\n    const excluded = new Set(excludeFieldIds ?? []);\n    return table.fieldList\n      .filter(\n        (field): field is AutoNumberFieldCore =>\n          field.type === FieldType.AutoNumber && !excluded.has(field.id)\n      )\n      .filter((field) => !field.getIsPersistedAsGeneratedColumn?.())\n      .map((field) => field.id);\n  }\n\n  private addContextFreeFormulasToImpact(\n    impact: IComputedImpactByTable,\n    tableId: string,\n    formulaIds: string[]\n  ): void {\n    if (!formulaIds.length) return;\n    const target = (impact[tableId] ||= {\n      fieldIds: new Set<string>(),\n      recordIds: new Set<string>(),\n    });\n    for (const id of formulaIds) {\n      target.fieldIds.add(id);\n    }\n  }\n\n  /**\n   * Collect impacted computed fields grouped by table, and the associated recordIds to re-evaluate.\n   * - Same-table computed fields: impacted recordIds are the updated records themselves.\n   * - Cross-table computed fields (via link/lookup/rollup): impacted records are those linking to\n   *   the changed records through any link field on the target table that points to the changed table.\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  @Timing()\n  async collect(\n    tableId: string,\n    ctxs: ICellContext[],\n    excludeFieldIds?: string[]\n  ): Promise<IComputedCollectResult> {\n    if (!ctxs.length) {\n      return { impact: {}, tableDomains: new Map<string, TableDomain>() };\n    }\n\n    const changedFieldIds = Array.from(new Set(ctxs.map((c) => c.fieldId)));\n    const changedRecordIds = Array.from(new Set(ctxs.map((c) => c.recordId)));\n    const fieldToTableMap = new Map<string, string>();\n    changedFieldIds.forEach((fid) => fieldToTableMap.set(fid, tableId));\n    const entryDomain = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const seedTableDomains = new Map<string, TableDomain>([[tableId, entryDomain]]);\n    const execCtx = this.createExecutionContext(seedTableDomains);\n\n    // 1) Transitive dependents grouped by table (SQL CTE + join field)\n    const contextByRecord = ctxs.reduce<Map<string, ICellContext[]>>((map, ctx) => {\n      const list = map.get(ctx.recordId);\n      if (list) {\n        list.push(ctx);\n      } else {\n        map.set(ctx.recordId, [ctx]);\n      }\n      return map;\n    }, new Map());\n\n    const relatedLinkIds = await this.resolveRelatedLinkFieldIds(\n      changedFieldIds,\n      fieldToTableMap,\n      execCtx\n    );\n    const traversalFieldIds = Array.from(new Set([...changedFieldIds, ...relatedLinkIds]));\n\n    const depByTable = await this.collectDependentFieldsByTable(traversalFieldIds, excludeFieldIds);\n    const impact: IComputedImpactByTable = Object.entries(depByTable).reduce((acc, [tid, fset]) => {\n      acc[tid] = { fieldIds: new Set(fset), recordIds: new Set<string>() };\n      return acc;\n    }, {} as IComputedImpactByTable);\n\n    // Additionally: include lookup/rollup fields that directly reference any changed link fields\n    // (or their symmetric counterparts). This ensures cross-table lookups update when links change.\n    const fallbackLookupIds = new Set<string>();\n    if (relatedLinkIds.length) {\n      const byTable = await this.findLookupsByLinkIds(relatedLinkIds);\n      for (const [tid, fset] of Object.entries(byTable)) {\n        const group = (impact[tid] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        });\n        fset.forEach((fid) => {\n          if (!group.fieldIds.has(fid)) {\n            group.fieldIds.add(fid);\n            fallbackLookupIds.add(fid);\n          }\n        });\n      }\n    }\n\n    if (fallbackLookupIds.size) {\n      // Legacy compatibility: some lookup records were created when linkFieldId was\n      // not persisted in reference graph, so we back-fill their dependents via traversal.\n      const extraDeps = await this.collectDependentFieldsByTable(\n        Array.from(fallbackLookupIds),\n        excludeFieldIds\n      );\n      for (const [tid, fset] of Object.entries(extraDeps)) {\n        const group = (impact[tid] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        });\n        fset.forEach((fid) => group.fieldIds.add(fid));\n      }\n    }\n\n    // Include symmetric link fields (if any) on the foreign table so their values\n    // are refreshed as well. The link fields themselves are already included by\n    // SQL union in collectDependentFieldsByTable.\n    const changedFieldIdSet = new Set(changedFieldIds);\n    const currentTableDomain = await this.getTableDomain(tableId, execCtx);\n    const linkFields = currentTableDomain.fieldList.filter(\n      (field) => changedFieldIdSet.has(field.id) && field.type === FieldType.Link && !field.isLookup\n    );\n\n    // Record planned foreign recordIds per foreign table based on incoming link cell new/old values\n    const plannedForeignRecordIds: Record<string, Set<string>> = {};\n\n    for (const lf of linkFields) {\n      type ILinkOptionsWithSymmetric = ILinkFieldOptions & { symmetricFieldId?: string };\n      const optsLoose = this.parseOptionsLoose<ILinkOptionsWithSymmetric>(lf.options);\n      const foreignTableId = optsLoose?.foreignTableId;\n      const symmetricFieldId = optsLoose?.symmetricFieldId;\n\n      // If symmetric, ensure foreign table symmetric field is included; recordIds\n      // for foreign table will be determined by BFS propagation below.\n      if (foreignTableId && symmetricFieldId) {\n        (impact[foreignTableId] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        }).fieldIds.add(symmetricFieldId);\n\n        // Also pre-seed foreign impacted recordIds using planned link targets\n        // Extract ids from both oldValue and newValue to cover add/remove\n        const targetIds = new Set<string>();\n        for (const ctx of ctxs) {\n          if (ctx.fieldId !== lf.id) continue;\n          const toIds = (v: unknown) => {\n            if (!v) return [] as string[];\n            const arr = Array.isArray(v) ? v : [v];\n            return arr\n              .map((x) => (x && typeof x === 'object' ? (x as { id?: string }).id : undefined))\n              .filter((id): id is string => !!id);\n          };\n          toIds(ctx.oldValue).forEach((id) => targetIds.add(id));\n          toIds(ctx.newValue).forEach((id) => targetIds.add(id));\n        }\n        if (targetIds.size) {\n          const set = (plannedForeignRecordIds[foreignTableId] ||= new Set<string>());\n          targetIds.forEach((id) => set.add(id));\n        }\n      }\n    }\n    const contextFreeFormulaIds = await this.getFormulaFieldsWithoutDependencies(\n      tableId,\n      excludeFieldIds\n    );\n    this.addContextFreeFormulasToImpact(impact, tableId, contextFreeFormulaIds);\n    const autoNumberFieldIds = this.getAutoNumberFieldIds(entryDomain, excludeFieldIds);\n    this.addContextFreeFormulasToImpact(impact, tableId, autoNumberFieldIds);\n\n    if (!Object.keys(impact).length) {\n      return { impact: {}, tableDomains: new Map(seedTableDomains) };\n    }\n\n    const impactedTables = new Set([...Object.keys(impact), tableId]);\n    for (const [tid, ids] of Object.entries(plannedForeignRecordIds)) {\n      if (!impactedTables.has(tid)) {\n        impactedTables.add(tid);\n      }\n    }\n\n    const tableDomains = await this.loadTableDomains(impactedTables, execCtx);\n    const linkEdges = this.buildLinkEdgesForTables(impactedTables, tableDomains, impact);\n    const explicitSeeds = new Map<string, Set<string>>();\n    explicitSeeds.set(tableId, new Set(changedRecordIds));\n    for (const [tid, ids] of Object.entries(plannedForeignRecordIds)) {\n      if (!ids.size) continue;\n      explicitSeeds.set(tid, new Set(ids));\n    }\n    const tablesWithAllRecords = new Set<string>();\n\n    const { link: linkAdj, conditionalRollup: referenceAdj } = this.getAdjacencyMaps(\n      tableDomains,\n      impact\n    );\n\n    let recordSets = await this.computeLinkClosure({\n      impactedTables,\n      explicitSeeds,\n      tablesWithAllRecords,\n      linkEdges,\n      tableDomains,\n      ctx: execCtx,\n    });\n\n    const queue: string[] = [];\n    const queued = new Set<string>();\n    const enqueueConditional = (tableId: string) => {\n      if (!tableId || queued.has(tableId)) {\n        return;\n      }\n      queued.add(tableId);\n      queue.push(tableId);\n    };\n    const enqueueLinkDependents = (tableId: string) => {\n      const targets = linkAdj[tableId];\n      if (!targets) return;\n      targets.forEach((tid) => enqueueConditional(tid));\n    };\n\n    const initialGrowth = this.findRecordSetGrowth({}, recordSets);\n    initialGrowth.forEach((tid) => {\n      enqueueConditional(tid);\n      enqueueLinkDependents(tid);\n    });\n    const materializedAllRecords = new Map<string, string[]>();\n\n    while (queue.length) {\n      const src = queue.shift()!;\n      queued.delete(src);\n\n      const referenceEdges = (referenceAdj[src] || []).filter((edge) => {\n        const targetGroup = impact[edge.tableId];\n        return !!targetGroup && targetGroup.fieldIds.has(edge.fieldId);\n      });\n      if (!referenceEdges.length) {\n        continue;\n      }\n\n      const rawSet = recordSets[src];\n      if (!rawSet) {\n        continue;\n      }\n\n      let currentIds: string[] = [];\n      let shouldMaterializeAllRecords = false;\n      if (rawSet === ALL_RECORDS) {\n        const needsMaterialization = referenceEdges.some((edge) => {\n          const targetSet = recordSets[edge.tableId];\n          return targetSet !== ALL_RECORDS && edge.tableId !== src;\n        });\n        shouldMaterializeAllRecords = needsMaterialization;\n        if (shouldMaterializeAllRecords) {\n          currentIds = await this.materializeAllRecordIds(src, materializedAllRecords, execCtx);\n        }\n      } else {\n        currentIds = Array.from(rawSet);\n      }\n      if (!currentIds.length && shouldMaterializeAllRecords) {\n        continue;\n      }\n\n      const eagerReferenceMatches: Array<{\n        edge: IConditionalRollupAdjacencyEdge;\n        matched: typeof ALL_RECORDS;\n      }> = [];\n      const referencePromises: Array<\n        Promise<{ edge: IConditionalRollupAdjacencyEdge; matched: string[] | typeof ALL_RECORDS }>\n      > = [];\n      for (const edge of referenceEdges) {\n        const targetGroup = impact[edge.tableId];\n        if (!targetGroup || !targetGroup.fieldIds.has(edge.fieldId)) continue;\n        if (\n          rawSet === ALL_RECORDS &&\n          (!shouldMaterializeAllRecords ||\n            recordSets[edge.tableId] === ALL_RECORDS ||\n            edge.tableId === src)\n        ) {\n          eagerReferenceMatches.push({ edge, matched: ALL_RECORDS });\n          continue;\n        }\n        if (!currentIds.length) continue;\n        const context = src === tableId ? contextByRecord : undefined;\n        referencePromises.push(\n          this.getConditionalRollupImpactedRecordIds(edge, currentIds, context, execCtx).then(\n            (matched) => ({\n              edge,\n              matched,\n            })\n          )\n        );\n      }\n\n      const referenceResults = [\n        ...eagerReferenceMatches,\n        ...(await Promise.all(referencePromises)),\n      ];\n\n      let dirty = false;\n      for (const { edge, matched } of referenceResults) {\n        const targetGroup = impact[edge.tableId];\n        if (!targetGroup || !targetGroup.fieldIds.has(edge.fieldId)) continue;\n        if (matched === ALL_RECORDS) {\n          const updated = this.markAllSeed(tablesWithAllRecords, edge.tableId);\n          if (updated) {\n            targetGroup.preferAutoNumberPaging = true;\n            dirty = true;\n            enqueueConditional(edge.tableId);\n            enqueueLinkDependents(edge.tableId);\n          }\n          continue;\n        }\n        if (!matched.length) continue;\n        const updated = this.addExplicitSeed(explicitSeeds, edge.tableId, matched);\n        if (updated) {\n          dirty = true;\n          enqueueConditional(edge.tableId);\n          enqueueLinkDependents(edge.tableId);\n        }\n      }\n\n      if (dirty) {\n        const nextRecordSets = await this.computeLinkClosure({\n          impactedTables,\n          explicitSeeds,\n          tablesWithAllRecords,\n          linkEdges,\n          tableDomains,\n          ctx: execCtx,\n        });\n        const growth = this.findRecordSetGrowth(recordSets, nextRecordSets);\n        growth.forEach((tid) => {\n          enqueueConditional(tid);\n          enqueueLinkDependents(tid);\n        });\n        recordSets = nextRecordSets;\n      }\n    }\n\n    for (const [tid, group] of Object.entries(impact)) {\n      const raw = recordSets[tid];\n      if (raw === ALL_RECORDS) {\n        group.preferAutoNumberPaging = true;\n        continue;\n      }\n      if (raw && raw.size) {\n        raw.forEach((id) => group.recordIds.add(id));\n      }\n    }\n\n    return { impact, tableDomains: new Map(tableDomains) };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/computed-evaluator.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport type { FieldCore, FormulaFieldCore, TableDomain } from '@teable/core';\nimport { FieldType, IdPrefix, RecordOpBuilder, Tables } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Knex } from 'knex';\nimport { RawOpType } from '../../../../share-db/interface';\nimport { Timing } from '../../../../utils/timing';\nimport { BatchService } from '../../../calculation/batch.service';\nimport { AUTO_NUMBER_FIELD_NAME } from '../../../field/constant';\nimport type { IFieldInstance } from '../../../field/model/factory';\nimport { InjectRecordQueryBuilder, type IRecordQueryBuilder } from '../../query-builder';\nimport { IComputedImpactByTable } from './computed-dependency-collector.service';\nimport {\n  AutoNumberCursorStrategy,\n  RecordIdBatchStrategy,\n  type IComputedRowResult,\n  type IPaginationContext,\n  type IRecordPaginationStrategy,\n} from './computed-pagination.strategy';\nimport { RecordComputedUpdateService } from './record-computed-update.service';\n\nconst recordIdBatchSize = 10_000;\nconst cursorBatchSize = 10_000;\n\n@Injectable()\nexport class ComputedEvaluatorService {\n  private readonly paginationStrategies: IRecordPaginationStrategy[] = [\n    new RecordIdBatchStrategy(),\n    new AutoNumberCursorStrategy(),\n  ];\n\n  constructor(\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder,\n    private readonly recordComputedUpdateService: RecordComputedUpdateService,\n    private readonly batchService: BatchService,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  /**\n   * For each table, query only the impacted records and dependent fields.\n   * Builds a RecordQueryBuilder with projection and converts DB values to cell values.\n   */\n  @Timing()\n  async evaluate(\n    impact: IComputedImpactByTable,\n    opts: {\n      excludeFieldIds?: Set<string>;\n      preferAutoNumberPaging?: boolean;\n      tableDomains: ReadonlyMap<string, TableDomain>;\n    }\n  ): Promise<number> {\n    const excludeFieldIds = opts.excludeFieldIds ?? new Set<string>();\n    const globalPreferAutoNumberPaging = opts.preferAutoNumberPaging === true;\n    const entries = Object.entries(impact).filter(([, group]) => group.fieldIds.size);\n    const projectionByTable = entries.reduce<Record<string, string[]>>((acc, [tableId, group]) => {\n      acc[tableId] = Array.from(group.fieldIds);\n      return acc;\n    }, {});\n\n    let totalOps = 0;\n    const tableDomainCache = opts.tableDomains;\n    if (!tableDomainCache.size) {\n      throw new Error('ComputedEvaluatorService.evaluate requires table domains');\n    }\n\n    const layers = await this.buildFieldLayers(entries);\n    if (!layers.length) {\n      return totalOps;\n    }\n\n    for (const layer of layers) {\n      for (const [tableId, layerFieldIds] of layer) {\n        const group = impact[tableId];\n        if (!group) continue;\n        const requestedFieldIds = Array.from(layerFieldIds);\n        if (!requestedFieldIds.length) continue;\n\n        const preferAutoNumberPaging =\n          globalPreferAutoNumberPaging || group.preferAutoNumberPaging === true;\n        const tableDomain = tableDomainCache.get(tableId);\n        if (!tableDomain) {\n          throw new Error(`Missing table domain for table ${tableId}`);\n        }\n\n        const fieldInstances = this.getFieldInstancesFromDomain(tableDomain, requestedFieldIds);\n        if (!fieldInstances.length) continue;\n\n        const validFieldIdSet = new Set(fieldInstances.map((f) => f.id));\n        const impactedFieldIds = new Set(\n          requestedFieldIds.filter((fid) => validFieldIdSet.has(fid))\n        );\n        if (!impactedFieldIds.size) continue;\n\n        const recordIds = Array.from(group.recordIds);\n        const dbTableName = tableDomain.dbTableName;\n        const builderRestrictRecordIds =\n          !preferAutoNumberPaging && recordIds.length > 0 && recordIds.length <= recordIdBatchSize\n            ? recordIds\n            : undefined;\n\n        const tablesOverride = this.buildTablesOverride(tableId, tableDomainCache);\n        const { qb, alias } = await this.recordQueryBuilder.createRecordQueryBuilder(dbTableName, {\n          tableId,\n          projection: Array.from(validFieldIdSet),\n          rawProjection: true,\n          preferRawFieldReferences: true,\n          projectionByTable,\n          restrictRecordIds: builderRestrictRecordIds,\n          tables: tablesOverride,\n        });\n\n        const idCol = alias ? `${alias}.__id` : '__id';\n        const orderCol = alias ? `${alias}.${AUTO_NUMBER_FIELD_NAME}` : AUTO_NUMBER_FIELD_NAME;\n        const baseQb = qb.clone();\n\n        const paginationContext = this.createPaginationContext({\n          tableId,\n          recordIds,\n          preferAutoNumberPaging,\n          baseQueryBuilder: baseQb,\n          idColumn: idCol,\n          orderColumn: orderCol,\n          fieldInstances,\n          dbTableName,\n        });\n\n        const strategy = this.selectPaginationStrategy(paginationContext);\n        await strategy.run(paginationContext, async (rows) => {\n          if (!rows.length) return;\n          const evaluatedRows = this.buildEvaluatedRows(rows, fieldInstances);\n          totalOps += this.publishBatch(\n            tableId,\n            impactedFieldIds,\n            validFieldIdSet,\n            excludeFieldIds,\n            evaluatedRows\n          );\n        });\n      }\n    }\n\n    return totalOps;\n  }\n\n  private async buildFieldLayers(\n    entries: Array<[string, IComputedImpactByTable[string]]>\n  ): Promise<Array<Map<string, Set<string>>>> {\n    const fieldIds = entries.flatMap(([, group]) => Array.from(group.fieldIds));\n    const uniqueFieldIds = Array.from(new Set(fieldIds.filter(Boolean)));\n    if (!uniqueFieldIds.length) {\n      return [];\n    }\n\n    const fieldIdToTableId = new Map<string, string>();\n    for (const [tableId, group] of entries) {\n      for (const fieldId of group.fieldIds) {\n        fieldIdToTableId.set(fieldId, tableId);\n      }\n    }\n\n    const edges = await this.loadFieldDependencyEdges(uniqueFieldIds);\n    if (!edges.length) {\n      return this.buildDefaultLayers(entries);\n    }\n\n    const levels = this.topoSortFieldLevels(uniqueFieldIds, edges);\n    if (!levels) {\n      return this.buildDefaultLayers(entries);\n    }\n\n    const layered = new Map<number, Map<string, Set<string>>>();\n    for (const fieldId of uniqueFieldIds) {\n      const level = levels.get(fieldId) ?? 0;\n      const tableId = fieldIdToTableId.get(fieldId);\n      if (!tableId) continue;\n      let tableMap = layered.get(level);\n      if (!tableMap) {\n        tableMap = new Map<string, Set<string>>();\n        layered.set(level, tableMap);\n      }\n      const fieldSet = tableMap.get(tableId) ?? new Set<string>();\n      fieldSet.add(fieldId);\n      tableMap.set(tableId, fieldSet);\n    }\n\n    const orderedLevels = Array.from(layered.keys()).sort((a, b) => a - b);\n    return orderedLevels.map((level) => layered.get(level)!);\n  }\n\n  private buildDefaultLayers(\n    entries: Array<[string, IComputedImpactByTable[string]]>\n  ): Array<Map<string, Set<string>>> {\n    const layer = new Map<string, Set<string>>();\n    for (const [tableId, group] of entries) {\n      if (!group.fieldIds.size) continue;\n      layer.set(tableId, new Set(group.fieldIds));\n    }\n    return layer.size ? [layer] : [];\n  }\n\n  private async loadFieldDependencyEdges(\n    fieldIds: string[]\n  ): Promise<Array<{ fromFieldId: string; toFieldId: string }>> {\n    const sql = Prisma.sql`\n      SELECT DISTINCT\n        r.from_field_id AS \"fromFieldId\",\n        r.to_field_id AS \"toFieldId\"\n      FROM reference r\n      WHERE r.from_field_id IN (${Prisma.join(fieldIds)})\n        AND r.to_field_id IN (${Prisma.join(fieldIds)})\n    `;\n    return this.prismaService\n      .txClient()\n      .$queryRaw<Array<{ fromFieldId: string; toFieldId: string }>>(sql);\n  }\n\n  private topoSortFieldLevels(\n    fieldIds: string[],\n    edges: Array<{ fromFieldId: string; toFieldId: string }>\n  ): Map<string, number> | null {\n    const orderIndex = new Map(fieldIds.map((fieldId, index) => [fieldId, index]));\n    const fieldSet = new Set(fieldIds);\n    const adjacency = new Map<string, Set<string>>();\n    const indegree = new Map<string, number>();\n    const levels = new Map<string, number>();\n\n    for (const fieldId of fieldIds) {\n      indegree.set(fieldId, 0);\n      levels.set(fieldId, 0);\n    }\n\n    for (const edge of edges) {\n      const { fromFieldId, toFieldId } = edge;\n      if (!fieldSet.has(fromFieldId) || !fieldSet.has(toFieldId) || fromFieldId === toFieldId) {\n        continue;\n      }\n      const targets = adjacency.get(fromFieldId) ?? new Set<string>();\n      if (!targets.has(toFieldId)) {\n        targets.add(toFieldId);\n        adjacency.set(fromFieldId, targets);\n        indegree.set(toFieldId, (indegree.get(toFieldId) ?? 0) + 1);\n      }\n    }\n\n    const queue = fieldIds\n      .filter((fieldId) => (indegree.get(fieldId) ?? 0) === 0)\n      .sort((a, b) => (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0));\n    const result: string[] = [];\n\n    while (queue.length) {\n      const current = queue.shift()!;\n      result.push(current);\n      const targets = adjacency.get(current);\n      if (!targets) continue;\n      for (const next of targets) {\n        const nextLevel = (levels.get(current) ?? 0) + 1;\n        if ((levels.get(next) ?? 0) < nextLevel) {\n          levels.set(next, nextLevel);\n        }\n        const nextDegree = (indegree.get(next) ?? 0) - 1;\n        indegree.set(next, nextDegree);\n        if (nextDegree === 0) {\n          queue.push(next);\n          queue.sort((a, b) => (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0));\n        }\n      }\n    }\n\n    return result.length === fieldIds.length ? levels : null;\n  }\n\n  private getFieldInstancesFromDomain(\n    tableDomain: TableDomain,\n    fieldIds: string[]\n  ): IFieldInstance[] {\n    if (!fieldIds.length) {\n      return [];\n    }\n    const requested = new Set(fieldIds);\n    return tableDomain.fieldList\n      .filter((field) => requested.has(field.id))\n      .map((field) => field as unknown as IFieldInstance);\n  }\n\n  private buildTablesOverride(\n    tableId: string,\n    tableDomains?: ReadonlyMap<string, TableDomain>\n  ): Tables | undefined {\n    if (!tableDomains?.size) {\n      return undefined;\n    }\n    if (!tableDomains.has(tableId)) {\n      return undefined;\n    }\n    const materialized =\n      tableDomains instanceof Map\n        ? (tableDomains as Map<string, TableDomain>)\n        : new Map(tableDomains as Iterable<[string, TableDomain]>);\n    return new Tables(tableId, materialized);\n  }\n\n  private buildEvaluatedRows(\n    rows: Array<IComputedRowResult>,\n    fieldInstances: IFieldInstance[]\n  ): Array<{\n    recordId: string;\n    version: number;\n    prevVersion?: number;\n    fields: Record<string, unknown>;\n  }> {\n    return rows.map((row) => {\n      const recordId = row.__id;\n      const version = row.__version as number;\n      const prevVersion = row.__prev_version as number | undefined;\n\n      const fieldsMap: Record<string, unknown> = {};\n      for (const field of fieldInstances) {\n        let columnName = field.dbFieldName;\n        if (field.type === FieldType.Formula) {\n          const f: FormulaFieldCore = field;\n          if (f.getIsPersistedAsGeneratedColumn()) {\n            const gen = f.getGeneratedColumnName?.();\n            if (gen) columnName = gen;\n          }\n        }\n        const raw = row[columnName as keyof typeof row] as unknown;\n        const cellValue = field.convertDBValue2CellValue(raw as never);\n        if (cellValue != null) fieldsMap[field.id] = cellValue;\n      }\n\n      return { recordId, version, prevVersion, fields: fieldsMap };\n    });\n  }\n\n  private publishBatch(\n    tableId: string,\n    impactedFieldIds: Set<string>,\n    validFieldIds: Set<string>,\n    excludeFieldIds: Set<string>,\n    evaluatedRows: Array<{\n      recordId: string;\n      version: number;\n      prevVersion?: number;\n      fields: Record<string, unknown>;\n    }>\n  ): number {\n    if (!evaluatedRows.length) return 0;\n\n    const targetFieldIds = Array.from(impactedFieldIds).filter(\n      (fid) => validFieldIds.has(fid) && !excludeFieldIds.has(fid)\n    );\n    if (!targetFieldIds.length) return 0;\n\n    const opDataList = evaluatedRows\n      .map(({ recordId, version, prevVersion, fields }) => {\n        const ops = targetFieldIds\n          .map((fid) => {\n            const hasValue = Object.prototype.hasOwnProperty.call(fields, fid);\n            const newCellValue = hasValue ? fields[fid] : null;\n            return RecordOpBuilder.editor.setRecord.build({\n              fieldId: fid,\n              newCellValue,\n              oldCellValue: null,\n            });\n          })\n          .filter(Boolean);\n\n        if (!ops.length) return null;\n\n        const opVersion = prevVersion ?? version;\n\n        return { docId: recordId, version: opVersion, data: ops, count: ops.length } as const;\n      })\n      .filter(Boolean) as { docId: string; version: number; data: unknown; count: number }[];\n\n    if (!opDataList.length) return 0;\n\n    this.batchService.saveRawOps(\n      tableId,\n      RawOpType.Edit,\n      IdPrefix.Record,\n      opDataList.map(({ docId, version, data }) => ({ docId, version, data }))\n    );\n\n    return opDataList.reduce((sum, current) => sum + current.count, 0);\n  }\n\n  private selectPaginationStrategy(context: IPaginationContext): IRecordPaginationStrategy {\n    return (\n      this.paginationStrategies.find((strategy) => strategy.canHandle(context)) ??\n      this.paginationStrategies[this.paginationStrategies.length - 1]\n    );\n  }\n\n  private createPaginationContext(params: {\n    tableId: string;\n    recordIds: string[];\n    preferAutoNumberPaging: boolean;\n    baseQueryBuilder: Knex.QueryBuilder;\n    idColumn: string;\n    orderColumn: string;\n    fieldInstances: IFieldInstance[];\n    dbTableName: string;\n  }): IPaginationContext {\n    const {\n      tableId,\n      recordIds,\n      preferAutoNumberPaging,\n      baseQueryBuilder,\n      idColumn,\n      orderColumn,\n      fieldInstances,\n      dbTableName,\n    } = params;\n\n    return {\n      tableId,\n      recordIds,\n      preferAutoNumberPaging,\n      recordIdBatchSize,\n      cursorBatchSize,\n      baseQueryBuilder,\n      idColumn,\n      orderColumn,\n      updateRecords: (qb, options) =>\n        this.recordComputedUpdateService.updateFromSelect(tableId, qb, fieldInstances, {\n          ...options,\n          dbTableName,\n        }),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable, NotFoundException } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport type { TableDomain, LastModifiedByFieldCore, LastModifiedTimeFieldCore } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport { InjectDbProvider } from '../../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../../../types/cls';\nimport { Timing } from '../../../../utils/timing';\nimport type { ICellContext } from '../../../calculation/utils/changes';\nimport { TableDomainQueryService } from '../../../table-domain/table-domain-query.service';\nimport {\n  ComputedDependencyCollectorService,\n  IComputedImpactByTable,\n} from './computed-dependency-collector.service';\nimport type { IFieldChangeSource } from './computed-dependency-collector.service';\nimport { ComputedEvaluatorService } from './computed-evaluator.service';\nimport { buildResultImpact } from './computed-utils';\n\n@Injectable()\nexport class ComputedOrchestratorService {\n  constructor(\n    private readonly collector: ComputedDependencyCollectorService,\n    private readonly evaluator: ComputedEvaluatorService,\n    private readonly prismaService: PrismaService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly cls: ClsService<IClsStore>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  /**\n   * Publish-only computed pipeline executed within the current transaction.\n   * - Collects affected computed fields across tables via dependency closure (SQL CTE).\n   * - Resolves impacted recordIds per table (same-table = changed records; cross-table = link backrefs).\n   * - Reads latest values via RecordService snapshots (projection of impacted computed fields).\n   * - Builds setRecord ops and saves them as raw ops; no DB writes, no __version bump here.\n   * - Raw ops are picked up by ShareDB publisher after the outer tx commits.\n   *\n   * Returns: { publishedOps } — total number of field set ops enqueued.\n   */\n  @Timing()\n  async computeCellChangesForRecords(\n    tableId: string,\n    cellContexts: ICellContext[],\n    update: (tableDomains?: Map<string, TableDomain>) => Promise<void>\n  ): Promise<{\n    publishedOps: number;\n    impact: Record<string, { fieldIds: string[]; recordIds: string[] }>;\n  }> {\n    // With update callback, switch to the new dual-select (old/new) mode\n    return this.computeCellChangesForRecordsMulti([{ tableId, cellContexts }], update);\n  }\n\n  /**\n   * Multi-source variant: accepts changes originating from multiple tables.\n   * Computes a unified impact once, executes the update callback, and then\n   * re-evaluates computed fields in batches while publishing ShareDB ops.\n   */\n  async computeCellChangesForRecordsMulti(\n    sources: Array<{ tableId: string; cellContexts: ICellContext[] }>,\n    update: (tableDomains?: Map<string, TableDomain>) => Promise<void>\n  ): Promise<{\n    publishedOps: number;\n    impact: Record<string, { fieldIds: string[]; recordIds: string[] }>;\n  }> {\n    const filtered = sources.filter((s) => s.cellContexts?.length);\n    if (!filtered.length) {\n      await update();\n      return { publishedOps: 0, impact: {} };\n    }\n\n    // Collect base changed field ids to avoid re-publishing base ops via computed\n    const changedFieldIds = new Set<string>();\n    const changedRecordIdsByTable = new Map<string, Set<string>>();\n    for (const s of filtered) {\n      let recordSet = changedRecordIdsByTable.get(s.tableId);\n      if (!recordSet) {\n        recordSet = new Set<string>();\n        changedRecordIdsByTable.set(s.tableId, recordSet);\n      }\n      for (const ctx of s.cellContexts) {\n        changedFieldIds.add(ctx.fieldId);\n        if (ctx.recordId) recordSet.add(ctx.recordId);\n      }\n    }\n\n    // 1) Collect impact per source and merge once\n    const exclude = Array.from(changedFieldIds);\n    const results = await Promise.all(\n      filtered.map(({ tableId, cellContexts }) =>\n        this.collector.collect(tableId, cellContexts, exclude)\n      )\n    );\n\n    const tableDomainSeeds = new Map<string, TableDomain>();\n    const impactMerged: IComputedImpactByTable = {};\n\n    for (const { impact, tableDomains } of results) {\n      for (const [tid, group] of Object.entries(impact)) {\n        const target = (impactMerged[tid] ||= {\n          fieldIds: new Set<string>(),\n          recordIds: new Set<string>(),\n        });\n        group.fieldIds.forEach((f) => target.fieldIds.add(f));\n        group.recordIds.forEach((r) => target.recordIds.add(r));\n        if (group.preferAutoNumberPaging) {\n          target.preferAutoNumberPaging = true;\n        }\n      }\n      for (const [tid, domain] of tableDomains) {\n        if (!tableDomainSeeds.has(tid)) {\n          tableDomainSeeds.set(tid, domain);\n        }\n      }\n    }\n\n    const impactedTables = Object.keys(impactMerged);\n    if (!impactedTables.length) {\n      await update();\n      return { publishedOps: 0, impact: {} };\n    }\n\n    for (const tid of impactedTables) {\n      const group = impactMerged[tid];\n      if (!group.fieldIds.size || (!group.recordIds.size && !group.preferAutoNumberPaging)) {\n        delete impactMerged[tid];\n      }\n    }\n    if (!Object.keys(impactMerged).length) {\n      await update();\n      return { publishedOps: 0, impact: {} };\n    }\n\n    const tableDomains = await this.resolveTableDomains(\n      impactMerged,\n      tableDomainSeeds,\n      filtered.map((s) => s.tableId)\n    );\n\n    await this.lockImpactedRecords(filtered, impactMerged, tableDomains);\n\n    // Track-all LastModified* fields are persisted/generated outside base ops.\n    // Ensure they are part of impacted fields and not excluded so their new values get published.\n    const excludeFieldIds = new Set(changedFieldIds);\n    for (const [tid, domain] of tableDomains) {\n      const trackAllAudit = domain\n        .getLastModifiedFields()\n        .filter((f) =>\n          f.type === FieldType.LastModifiedTime\n            ? (f as LastModifiedTimeFieldCore).isTrackAll()\n            : f.type === FieldType.LastModifiedBy && (f as LastModifiedByFieldCore).isTrackAll()\n        );\n      if (!trackAllAudit.length) continue;\n      const recordIds = changedRecordIdsByTable.get(tid);\n      if (!recordIds?.size) continue;\n      const group = (impactMerged[tid] ||= {\n        fieldIds: new Set<string>(),\n        recordIds: new Set<string>(),\n      });\n      trackAllAudit.forEach((f) => {\n        group.fieldIds.add(f.id);\n        excludeFieldIds.delete(f.id);\n      });\n      recordIds.forEach((rid) => group.recordIds.add(rid));\n    }\n\n    // 2) Perform the actual base update(s) if provided\n    await update(tableDomains);\n\n    // 3) Evaluate and publish computed values\n    const total = await this.evaluator.evaluate(impactMerged, {\n      excludeFieldIds,\n      tableDomains,\n    });\n\n    return { publishedOps: total, impact: buildResultImpact(impactMerged) };\n  }\n\n  /**\n   * Compute and publish cell changes when field definitions are UPDATED.\n   * - Collects impacted fields and records based on changed field ids (pre-update)\n   * - Executes the provided update callback within the same tx (schema/meta update)\n   * - Recomputes values via updateFromSelect, publishing ops with the latest values\n   */\n  async computeCellChangesForFields(\n    sources: IFieldChangeSource[],\n    update: () => Promise<void>\n  ): Promise<{\n    publishedOps: number;\n    impact: Record<string, { fieldIds: string[]; recordIds: string[] }>;\n  }> {\n    const impactPre = await this.collector.collectForFieldChanges(sources);\n\n    // If nothing impacted, still run update\n    if (!Object.keys(impactPre).length) {\n      await update();\n      return { publishedOps: 0, impact: {} };\n    }\n\n    await update();\n    const tableDomains = await this.resolveTableDomains(impactPre);\n    const total = await this.evaluator.evaluate(impactPre, {\n      tableDomains,\n    });\n\n    return { publishedOps: total, impact: buildResultImpact(impactPre) };\n  }\n\n  /**\n   * Compute and publish cell changes when fields are being DELETED.\n   * - Collects impacted fields and records based on the fields-to-delete (pre-delete)\n   * - Executes the provided update callback within the same tx to delete fields and dependencies\n   * - Evaluates new values and publishes ops for impacted fields EXCEPT the deleted ones\n   *   (and any fields that no longer exist after the update, e.g., symmetric link fields).\n   */\n  async computeCellChangesForFieldsBeforeDelete(\n    sources: IFieldChangeSource[],\n    update: () => Promise<void>\n  ): Promise<{\n    publishedOps: number;\n    impact: Record<string, { fieldIds: string[]; recordIds: string[] }>;\n  }> {\n    const impactPre = await this.collector.collectForFieldChanges(sources);\n\n    if (!Object.keys(impactPre).length) {\n      await update();\n      return { publishedOps: 0, impact: {} };\n    }\n\n    const startFieldIdList = Array.from(new Set(sources.flatMap((s) => s.fieldIds || [])));\n\n    await update();\n\n    // After update, some fields may be deleted; build a post-update impact that only\n    // includes fields still present to avoid selecting/updating non-existent columns.\n    const impactPost: IComputedImpactByTable = {};\n    for (const [tid, group] of Object.entries(impactPre)) {\n      const ids = Array.from(group.fieldIds);\n      if (!ids.length) continue;\n      const rows = await this.prismaService.txClient().field.findMany({\n        where: { tableId: tid, id: { in: ids }, deletedTime: null },\n        select: { id: true },\n      });\n      const existing = new Set(rows.map((r) => r.id));\n      const kept = new Set(Array.from(group.fieldIds).filter((fid) => existing.has(fid)));\n      const hasRecords = group.recordIds.size > 0;\n      const preferAuto = group.preferAutoNumberPaging === true;\n      if (kept.size && (hasRecords || preferAuto)) {\n        impactPost[tid] = {\n          fieldIds: kept,\n          recordIds: new Set(group.recordIds),\n          ...(preferAuto ? { preferAutoNumberPaging: true } : {}),\n        };\n      }\n    }\n\n    if (startFieldIdList.length) {\n      const existingStartFields = await this.prismaService.txClient().field.findMany({\n        where: { id: { in: startFieldIdList }, deletedTime: null },\n        select: { id: true },\n      });\n      const existingSet = new Set(existingStartFields.map((r) => r.id));\n      const deletedStartIds = startFieldIdList.filter((id) => !existingSet.has(id));\n\n      if (deletedStartIds.length) {\n        const dependents = await this.collector.getConditionalSortDependents(deletedStartIds);\n        if (dependents.length) {\n          for (const { tableId, fieldId } of dependents) {\n            const group = impactPost[tableId];\n            if (!group) continue;\n            group.fieldIds.delete(fieldId);\n            if (!group.fieldIds.size) {\n              delete impactPost[tableId];\n            }\n          }\n        }\n      }\n    }\n\n    if (!Object.keys(impactPost).length) {\n      return { publishedOps: 0, impact: {} };\n    }\n\n    // Also exclude the source (deleted) field ids when publishing\n    const startFieldIds = new Set<string>(startFieldIdList);\n\n    // Determine which impacted fieldIds were actually deleted (no longer exist post-update)\n    const actuallyDeleted = new Set<string>();\n    for (const [tid, group] of Object.entries(impactPre)) {\n      const ids = Array.from(group.fieldIds);\n      if (!ids.length) continue;\n      const rows = await this.prismaService.txClient().field.findMany({\n        where: { tableId: tid, id: { in: ids }, deletedTime: null },\n        select: { id: true },\n      });\n      const existing = new Set(rows.map((r) => r.id));\n      for (const fid of ids) if (!existing.has(fid)) actuallyDeleted.add(fid);\n    }\n\n    const exclude = new Set<string>([...startFieldIds, ...actuallyDeleted]);\n\n    const tableDomains = await this.resolveTableDomains(impactPost);\n    const total = await this.evaluator.evaluate(impactPost, {\n      excludeFieldIds: exclude,\n      tableDomains,\n    });\n\n    return { publishedOps: total, impact: buildResultImpact(impactPost) };\n  }\n\n  /**\n   * Compute and publish cell changes when new fields are CREATED within the same tx.\n   * - Executes the provided update callback first to persist new field definitions.\n   * - Collects impacted fields/records post-update (includes the new fields themselves).\n   * - Evaluates new values via updateFromSelect and publishes ops.\n   */\n  async computeCellChangesForFieldsAfterCreate(\n    sources: IFieldChangeSource[],\n    update: () => Promise<void>\n  ): Promise<{\n    publishedOps: number;\n    impact: Record<string, { fieldIds: string[]; recordIds: string[] }>;\n  }> {\n    await update();\n\n    if (this.cls.get('skipFieldComputation')) {\n      return { publishedOps: 0, impact: {} };\n    }\n\n    const publishTargetIds = new Set<string>();\n    for (const source of sources) {\n      if (!source.fieldIds?.length) continue;\n      for (const fid of source.fieldIds) publishTargetIds.add(fid);\n    }\n\n    const impact = await this.collector.collectForFieldChanges(sources);\n    if (!Object.keys(impact).length) return { publishedOps: 0, impact: {} };\n    const tableDomains = await this.resolveTableDomains(impact);\n\n    const exclude = new Set<string>();\n    if (publishTargetIds.size) {\n      for (const group of Object.values(impact)) {\n        for (const fid of group.fieldIds) {\n          if (!publishTargetIds.has(fid)) exclude.add(fid);\n        }\n      }\n    }\n\n    const total = await this.evaluator.evaluate(impact, {\n      preferAutoNumberPaging: true,\n      ...(exclude.size ? { excludeFieldIds: exclude } : {}),\n      tableDomains,\n    });\n\n    return { publishedOps: total, impact: buildResultImpact(impact) };\n  }\n\n  @Timing()\n  private async lockImpactedRecords(\n    sources: Array<{ tableId: string; cellContexts: ICellContext[] }>,\n    impact: IComputedImpactByTable,\n    tableDomains: Map<string, TableDomain>\n  ) {\n    if (typeof this.dbProvider.lockRecordsSql !== 'function') {\n      return;\n    }\n    const targetMap = new Map<string, Set<string>>();\n\n    for (const source of sources) {\n      if (!source.cellContexts?.length) continue;\n      let recordSet = targetMap.get(source.tableId);\n      if (!recordSet) {\n        recordSet = new Set<string>();\n        targetMap.set(source.tableId, recordSet);\n      }\n      for (const ctx of source.cellContexts) {\n        if (ctx.recordId) {\n          recordSet.add(ctx.recordId);\n        }\n      }\n    }\n\n    for (const [tableId, group] of Object.entries(impact)) {\n      if (!group.recordIds?.size) continue;\n      let recordSet = targetMap.get(tableId);\n      if (!recordSet) {\n        recordSet = new Set<string>();\n        targetMap.set(tableId, recordSet);\n      }\n      for (const id of group.recordIds) {\n        recordSet.add(id);\n      }\n    }\n\n    if (!targetMap.size) {\n      return;\n    }\n\n    const tableIds = Array.from(targetMap.keys());\n    const tableNameMap = new Map<string, string>();\n    for (const [tableId, domain] of tableDomains) {\n      if (domain?.dbTableName) {\n        tableNameMap.set(tableId, domain.dbTableName);\n      }\n    }\n\n    const missingTableIds = tableIds.filter((tableId) => !tableNameMap.has(tableId));\n    if (missingTableIds.length) {\n      const fetched = await this.tableDomainQueryService.getTableDomainsByIds(missingTableIds);\n      for (const [tableId, domain] of fetched) {\n        if (domain?.dbTableName) {\n          tableNameMap.set(tableId, domain.dbTableName);\n        }\n        if (!tableDomains.has(tableId)) {\n          tableDomains.set(tableId, domain);\n        }\n      }\n    }\n\n    const lockTargets = tableIds\n      .map((tableId) => {\n        const dbTableName = tableNameMap.get(tableId);\n        if (!dbTableName) return null;\n        const recordIds = Array.from(targetMap.get(tableId) ?? []);\n        if (!recordIds.length) return null;\n        return { tableId, dbTableName, recordIds };\n      })\n      .filter(\n        (target): target is { tableId: string; dbTableName: string; recordIds: string[] } =>\n          target !== null\n      )\n      .sort((a, b) => (a.dbTableName > b.dbTableName ? 1 : a.dbTableName < b.dbTableName ? -1 : 0));\n\n    for (const target of lockTargets) {\n      const sql = this.dbProvider.lockRecordsSql?.({\n        dbTableName: target.dbTableName,\n        idFieldName: '__id',\n        recordIds: target.recordIds,\n      });\n      if (sql) {\n        await this.prismaService.txClient().$queryRawUnsafe(sql);\n      }\n    }\n  }\n\n  private async resolveTableDomains(\n    impact: IComputedImpactByTable,\n    seed?: ReadonlyMap<string, TableDomain>,\n    extraTableIds?: Iterable<string>\n  ): Promise<Map<string, TableDomain>> {\n    const cache = new Map<string, TableDomain>();\n    if (seed?.size) {\n      for (const [tableId, domain] of seed) {\n        cache.set(tableId, domain);\n      }\n    }\n\n    const projectionByTable = new Map<string, Set<string> | undefined>();\n    for (const [tableId, group] of Object.entries(impact)) {\n      projectionByTable.set(tableId, new Set(group.fieldIds));\n    }\n    if (extraTableIds) {\n      for (const id of extraTableIds) {\n        if (!id) continue;\n        if (!projectionByTable.has(id)) {\n          projectionByTable.set(id, undefined);\n        }\n      }\n    }\n\n    const targetIds = new Set<string>(projectionByTable.keys());\n    if (!targetIds.size) {\n      return cache;\n    }\n\n    const fetchMissingDomains = async (tableIds: Iterable<string>) => {\n      const unique = Array.from(new Set(Array.from(tableIds).filter(Boolean)));\n      if (!unique.length) return;\n      const missing = unique.filter((tableId) => !cache.has(tableId));\n      if (!missing.length) return;\n      const fetched = await this.tableDomainQueryService.getTableDomainsByIds(missing);\n      for (const [tableId, domain] of fetched) {\n        cache.set(tableId, domain);\n      }\n    };\n\n    await fetchMissingDomains(targetIds);\n\n    // Only expand one hop from the impacted tables; deeper dependencies are resolved via\n    // persisted physical columns instead of recursive CTE expansion.\n    const relatedIds = new Set<string>();\n    for (const tableId of targetIds) {\n      const domain = cache.get(tableId);\n      if (!domain) {\n        continue;\n      }\n      const projection = projectionByTable.get(tableId);\n      const relatedTableIds = domain.getAllForeignTableIds(\n        projection && projection.size ? Array.from(projection) : undefined\n      );\n      for (const relatedTableId of relatedTableIds) {\n        if (!projectionByTable.has(relatedTableId)) {\n          projectionByTable.set(relatedTableId, undefined);\n        }\n        relatedIds.add(relatedTableId);\n      }\n    }\n\n    if (relatedIds.size) {\n      await fetchMissingDomains(relatedIds);\n    }\n\n    const unresolved = Array.from(projectionByTable.keys()).filter(\n      (tableId) => !cache.has(tableId)\n    );\n    if (unresolved.length) {\n      await fetchMissingDomains(unresolved);\n      const stillMissing = unresolved.filter((tableId) => !cache.has(tableId));\n      if (stillMissing.length) {\n        throw new NotFoundException(`Table(s) not found: ${stillMissing.join(', ')}`);\n      }\n    }\n\n    return cache;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/computed-pagination.strategy.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Knex } from 'knex';\nimport { AUTO_NUMBER_FIELD_NAME } from '../../../field/constant';\n\ntype Cursor = number | null;\n\nexport type IComputedRowResult = {\n  __id: string;\n  __version: number;\n  ['__prev_version']?: number;\n  ['__auto_number']?: number;\n} & Record<string, unknown>;\n\nexport type PaginationBatchHandler = (rows: IComputedRowResult[]) => Promise<void> | void;\n\nexport interface IPaginationContext {\n  tableId: string;\n  recordIds: string[];\n  preferAutoNumberPaging: boolean;\n  recordIdBatchSize: number;\n  cursorBatchSize: number;\n  baseQueryBuilder: Knex.QueryBuilder;\n  idColumn: string;\n  orderColumn: string;\n  updateRecords: (\n    qb: Knex.QueryBuilder,\n    options?: { restrictRecordIds?: string[] }\n  ) => Promise<IComputedRowResult[]>;\n}\n\nexport interface IRecordPaginationStrategy {\n  canHandle(context: IPaginationContext): boolean;\n  run(context: IPaginationContext, onBatch: PaginationBatchHandler): Promise<void>;\n}\n\nexport class RecordIdBatchStrategy implements IRecordPaginationStrategy {\n  canHandle(context: IPaginationContext): boolean {\n    return (\n      !context.preferAutoNumberPaging &&\n      context.recordIds.length > 0 &&\n      context.recordIds.length <= context.recordIdBatchSize\n    );\n  }\n\n  async run(context: IPaginationContext, onBatch: PaginationBatchHandler): Promise<void> {\n    for (const chunk of this.chunk(context.recordIds, context.recordIdBatchSize)) {\n      if (!chunk.length) continue;\n\n      const batchQb = context.baseQueryBuilder.clone().whereIn(context.idColumn, chunk);\n      const rows = await context.updateRecords(batchQb, { restrictRecordIds: chunk });\n      if (!rows.length) continue;\n\n      await onBatch(rows);\n    }\n  }\n\n  private chunk<T>(arr: T[], size: number): T[][] {\n    if (size <= 0) return [arr];\n    const result: T[][] = [];\n    for (let i = 0; i < arr.length; i += size) {\n      result.push(arr.slice(i, i + size));\n    }\n    return result;\n  }\n}\n\nexport class AutoNumberCursorStrategy implements IRecordPaginationStrategy {\n  canHandle(): boolean {\n    return true;\n  }\n\n  async run(context: IPaginationContext, onBatch: PaginationBatchHandler): Promise<void> {\n    let cursor: Cursor = null;\n\n    // eslint-disable-next-line no-constant-condition\n    while (true) {\n      const pagedQb = context.baseQueryBuilder\n        .clone()\n        .orderBy(context.orderColumn, 'asc')\n        .limit(context.cursorBatchSize);\n\n      if (cursor != null) {\n        pagedQb.where(context.orderColumn, '>', cursor);\n      }\n\n      const rows = await context.updateRecords(pagedQb);\n      if (!rows.length) break;\n\n      const sortedRows = rows.slice().sort((a, b) => {\n        const left = (a[AUTO_NUMBER_FIELD_NAME] as number) ?? 0;\n        const right = (b[AUTO_NUMBER_FIELD_NAME] as number) ?? 0;\n        if (left === right) return 0;\n        return left > right ? 1 : -1;\n      });\n\n      await onBatch(sortedRows);\n\n      const lastRow = sortedRows[sortedRows.length - 1];\n      const lastCursor = lastRow[AUTO_NUMBER_FIELD_NAME] as number | undefined;\n      if (lastCursor != null) {\n        cursor = lastCursor;\n      }\n\n      if (sortedRows.length < context.cursorBatchSize) {\n        break;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/computed-utils.ts",
    "content": "export interface IImpactGroup {\n  fieldIds: Set<string>;\n  recordIds: Set<string>;\n}\n\nexport type IImpactMap = Record<string, IImpactGroup>;\n\nexport type IResultImpact = Record<string, { fieldIds: string[]; recordIds: string[] }>;\n\nexport function buildResultImpact(impact: IImpactMap): IResultImpact {\n  return Object.entries(impact).reduce<IResultImpact>((acc, [tid, group]) => {\n    acc[tid] = {\n      fieldIds: Array.from(group.fieldIds),\n      recordIds: Array.from(group.recordIds),\n    };\n    return acc;\n  }, {});\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/link-cascade-resolver.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { chunk } from 'lodash';\nimport { Timing } from '../../../../utils/timing';\n\nexport interface ILinkEdge {\n  foreignTableId: string;\n  hostTableId: string;\n  fkTableName: string;\n  selfKeyName: string;\n  foreignKeyName: string;\n}\n\nexport interface IExplicitLinkSeed {\n  tableId: string;\n  recordIds: string[];\n}\n\nexport interface IAllTableLinkSeed {\n  tableId: string;\n  dbTableName: string;\n}\n\ninterface IResolveLinkCascadeParams {\n  explicitSeeds: IExplicitLinkSeed[];\n  allTableSeeds: IAllTableLinkSeed[];\n  edges: ILinkEdge[];\n}\n\nconst ALL_RECORDS = Symbol('ALL_RECORDS');\ntype VisitedSet = Set<string> | typeof ALL_RECORDS;\n\nconst IN_CHUNK = 500;\n\n@Injectable()\nexport class LinkCascadeResolver {\n  constructor(private readonly prismaService: PrismaService) {}\n\n  /**\n   * Iterative BFS over link edges using only frontier ids; avoids full edge table scans and keeps\n   * SQL simple. Seeds can be explicit recordIds per table or \"all records\" for tables that must be\n   * fully included.\n   */\n  @Timing()\n  async resolve(\n    params: IResolveLinkCascadeParams\n  ): Promise<Array<{ tableId: string; recordId: string }>> {\n    const { explicitSeeds, allTableSeeds, edges } = params;\n    const edgeBySrc = this.groupEdgesBySource(edges);\n    if (!edgeBySrc.size) {\n      return this.flattenSeeds(explicitSeeds, allTableSeeds);\n    }\n\n    const visited = new Map<string, VisitedSet>();\n    const queue: Array<{ tableId: string; ids?: Set<string>; all: boolean }> = [];\n\n    // seed explicit ids\n    for (const seed of explicitSeeds) {\n      if (!seed.recordIds?.length) continue;\n      const existing = visited.get(seed.tableId);\n      if (existing === ALL_RECORDS) {\n        continue;\n      }\n      const set = this.getOrInitSet(visited, seed.tableId);\n      seed.recordIds.forEach((id) => {\n        if (id) set.add(id);\n      });\n      queue.push({ tableId: seed.tableId, ids: new Set(seed.recordIds), all: false });\n    }\n\n    // seed all-table entries without materializing ids up front; use ALL sentinel and push work to DB\n    if (allTableSeeds.length) {\n      for (const seed of allTableSeeds) {\n        if (!seed.tableId) continue;\n        visited.set(seed.tableId, ALL_RECORDS);\n        queue.push({ tableId: seed.tableId, all: true });\n      }\n    }\n\n    while (queue.length) {\n      const { tableId, ids, all } = queue.shift()!;\n      const edgesFromTable = edgeBySrc.get(tableId);\n      if (!edgesFromTable?.length) continue;\n      const frontierIds = all ? [] : Array.from(ids ?? []).filter(Boolean);\n      if (!all && !frontierIds.length) continue;\n\n      const additionsByTable = new Map<string, Set<string>>();\n\n      for (const edge of edgesFromTable) {\n        const dstVisited = visited.get(edge.hostTableId);\n        if (dstVisited === ALL_RECORDS) {\n          continue; // already fully included\n        }\n\n        const rows = all\n          ? await this.fetchEdgeTargetsFromAll(edge)\n          : await this.fetchEdgeTargetsBatched(edge, frontierIds);\n\n        if (!rows.length) continue;\n\n        const dstSet = this.getOrInitSet(visited, edge.hostTableId);\n        let added = additionsByTable.get(edge.hostTableId);\n        if (!added) {\n          added = new Set<string>();\n          additionsByTable.set(edge.hostTableId, added);\n        }\n        for (const row of rows) {\n          const rid = row.record_id;\n          if (!rid || dstSet.has(rid)) continue;\n          dstSet.add(rid);\n          added.add(rid);\n        }\n      }\n\n      for (const [dstTable, newIds] of additionsByTable) {\n        if (newIds.size) {\n          queue.push({ tableId: dstTable, ids: newIds, all: false });\n        }\n      }\n    }\n\n    const result: Array<{ tableId: string; recordId: string }> = [];\n    for (const [tableId, set] of visited) {\n      if (set === ALL_RECORDS) {\n        continue;\n      }\n      for (const id of set) {\n        result.push({ tableId, recordId: id });\n      }\n    }\n    return result;\n  }\n\n  private groupEdgesBySource(edges: ILinkEdge[]): Map<string, ILinkEdge[]> {\n    const map = new Map<string, ILinkEdge[]>();\n    edges.forEach((edge) => {\n      const key = edge.foreignTableId;\n      if (!key) return;\n      let list = map.get(key);\n      if (!list) {\n        list = [];\n        map.set(key, list);\n      }\n      list.push(edge);\n    });\n    return map;\n  }\n\n  private getOrInitSet(map: Map<string, VisitedSet>, key: string): Set<string> {\n    const existing = map.get(key);\n    if (existing && existing !== ALL_RECORDS) {\n      return existing;\n    }\n    const set = new Set<string>();\n    map.set(key, set);\n    return set;\n  }\n\n  private flattenSeeds(\n    explicitSeeds: IExplicitLinkSeed[],\n    allTableSeeds: IAllTableLinkSeed[]\n  ): Array<{ tableId: string; recordId: string }> {\n    const rows: Array<{ tableId: string; recordId: string }> = [];\n    explicitSeeds.forEach((s) =>\n      s.recordIds?.forEach((id) => {\n        if (id) rows.push({ tableId: s.tableId, recordId: id });\n      })\n    );\n    // allTableSeeds skipped here; caller typically handles ALL separately if no edges\n    return rows;\n  }\n\n  private async fetchEdgeTargets(\n    edge: ILinkEdge,\n    srcIds: string[]\n  ): Promise<Array<{ record_id?: string }>> {\n    if (!srcIds.length) return [];\n    const placeholders = srcIds.map((_, i) => `$${i + 1}`).join(', ');\n    const fkTableRef = this.formatQualifiedName(edge.fkTableName);\n    const srcCol = this.quoteIdentifier(edge.foreignKeyName);\n    const dstCol = this.quoteIdentifier(edge.selfKeyName);\n    const sql = `select ${dstCol}::text as record_id\nfrom ${fkTableRef}\nwhere ${srcCol} in (${placeholders})\n  and ${srcCol} is not null\n  and ${dstCol} is not null`;\n    return await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Array<{ record_id?: string }>>(sql, ...srcIds);\n  }\n\n  private async fetchEdgeTargetsBatched(\n    edge: ILinkEdge,\n    srcIds: string[]\n  ): Promise<Array<{ record_id?: string }>> {\n    if (!srcIds.length) return [];\n    const batches = chunk(srcIds, IN_CHUNK);\n    const batchResults = await Promise.all(\n      batches.map((batch) => this.fetchEdgeTargets(edge, batch))\n    );\n    return batchResults.flat();\n  }\n\n  private async fetchEdgeTargetsFromAll(edge: ILinkEdge): Promise<Array<{ record_id?: string }>> {\n    const fkTableRef = this.formatQualifiedName(edge.fkTableName);\n    const srcCol = this.quoteIdentifier(edge.foreignKeyName);\n    const dstCol = this.quoteIdentifier(edge.selfKeyName);\n    const sql = `select distinct ${dstCol}::text as record_id\nfrom ${fkTableRef}\nwhere ${srcCol} is not null\n  and ${dstCol} is not null`;\n    return this.prismaService.txClient().$queryRawUnsafe<Array<{ record_id?: string }>>(sql);\n  }\n\n  private quoteIdentifier(identifier: string): string {\n    return `\"${identifier.replace(/\"/g, '\"\"')}\"`;\n  }\n\n  private formatQualifiedName(qualified: string): string {\n    return qualified\n      .split('.')\n      .map((part) => this.quoteIdentifier(part))\n      .join('.');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { match } from 'ts-pattern';\nimport { InjectDbProvider } from '../../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../../db-provider/db.provider.interface';\nimport { retryOnDeadlock } from '../../../../utils/retry-decorator';\nimport { Timing } from '../../../../utils/timing';\nimport { AUTO_NUMBER_FIELD_NAME } from '../../../field/constant';\nimport type { IFieldInstance } from '../../../field/model/factory';\nimport type { FormulaFieldDto } from '../../../field/model/field-dto/formula-field.dto';\n\n@Injectable()\nexport class RecordComputedUpdateService {\n  private logger = new Logger(RecordComputedUpdateService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  private async getDbTableName(tableId: string): Promise<string> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return dbTableName;\n  }\n\n  private getUpdatableColumns(fields: IFieldInstance[]): string[] {\n    const isFormulaField = (f: IFieldInstance): f is FormulaFieldDto =>\n      f.type === FieldType.Formula;\n    const isPersistedGenerated = (f: IFieldInstance) =>\n      (f as { meta?: { persistedAsGeneratedColumn?: boolean } }).meta\n        ?.persistedAsGeneratedColumn === true;\n\n    return fields\n      .filter((f) => {\n        // Skip fields currently in error state to avoid type/cast issues — except for\n        // lookup/rollup (and lookup-of-link) which we still want to persist so they\n        // get nulled out after their source is deleted. Query builder emits a typed\n        // NULL for errored lookups/rollups ensuring safe assignment.\n        const hasError = (f as unknown as { hasError?: boolean }).hasError;\n        const isLookupStyle = (f as unknown as { isLookup?: boolean }).isLookup === true;\n        const isRollup = f.type === FieldType.Rollup || f.type === FieldType.ConditionalRollup;\n        if (hasError && !isLookupStyle && !isRollup) {\n          // Only keep errored formulas in the updatable set when they are NOT persisted\n          // as generated columns (so we can null-out regular columns after dependency deletion).\n          if (f.type !== FieldType.Formula) return false;\n          if (isFormulaField(f) && f.getIsPersistedAsGeneratedColumn()) return false;\n        }\n        // Persist lookup-of-link as well (computed link columns should be stored).\n        // We rely on query builder to ensure subquery column types match target columns (e.g., jsonb).\n        // Skip formula persisted as generated columns\n        return match(f)\n          .when(isFormulaField, (f) => !f.getIsPersistedAsGeneratedColumn())\n          .with({ type: FieldType.AutoNumber }, (f) => !f.getIsPersistedAsGeneratedColumn())\n          .with({ type: FieldType.CreatedTime }, () => isLookupStyle)\n          .with({ type: FieldType.LastModifiedTime }, () => isLookupStyle)\n          .with({ type: FieldType.CreatedBy }, (f) => isLookupStyle || !isPersistedGenerated(f))\n          .with(\n            { type: FieldType.LastModifiedBy },\n            (f) => isLookupStyle || !isPersistedGenerated(f)\n          )\n          .otherwise(() => true);\n      })\n      .map((f) => f.dbFieldName);\n  }\n\n  private getReturningColumns(fields: IFieldInstance[]): string[] {\n    const isFormulaField = (f: IFieldInstance): f is FormulaFieldDto =>\n      f.type === FieldType.Formula;\n    const isPersistedGenerated = (f: IFieldInstance) =>\n      (f as { meta?: { persistedAsGeneratedColumn?: boolean } }).meta\n        ?.persistedAsGeneratedColumn === true;\n    const cols: string[] = [];\n    for (const f of fields) {\n      // Keep track-all system timestamps in the RETURNING list so computed ops\n      // can emit their values. Skip persisted generated audit users.\n      if (!f.isLookup && isPersistedGenerated(f)) {\n        if (f.type === FieldType.CreatedTime || f.type === FieldType.LastModifiedTime) {\n          cols.push(f.dbFieldName);\n          continue;\n        }\n        if (f.type === FieldType.CreatedBy || f.type === FieldType.LastModifiedBy) {\n          continue;\n        }\n      }\n      if (isFormulaField(f)) {\n        // Lookup-formula fields are persisted as regular columns on the host table\n        // and must be included in the RETURNING list by their dbFieldName.\n        if (f.isLookup) {\n          cols.push(f.dbFieldName);\n          continue;\n        }\n        // Non-lookup formulas: include generated column when persisted and not errored\n        if (f.getIsPersistedAsGeneratedColumn() && !f.hasError) {\n          cols.push(f.getGeneratedColumnName());\n          continue;\n        }\n        // Formulas persisted as regular columns still need to be returned via dbFieldName\n        cols.push(f.dbFieldName);\n        continue;\n      }\n      // Non-formula fields (including lookup/rollup) return by their physical column name\n      cols.push(f.dbFieldName);\n    }\n    // de-dup\n    return Array.from(new Set(cols));\n  }\n\n  @Timing()\n  private async lockRestrictRecords(dbTableName: string, recordIds?: string[]) {\n    if (!recordIds?.length) {\n      return;\n    }\n    if (typeof this.dbProvider.lockRecordsSql !== 'function') {\n      return;\n    }\n    const sql = this.dbProvider.lockRecordsSql({\n      dbTableName,\n      idFieldName: '__id',\n      recordIds,\n    });\n    if (!sql) {\n      return;\n    }\n    await this.prismaService.txClient().$queryRawUnsafe(sql);\n  }\n\n  @retryOnDeadlock()\n  async updateFromSelect(\n    tableId: string,\n    qb: Knex.QueryBuilder,\n    fields: IFieldInstance[],\n    opts?: { restrictRecordIds?: string[]; dbTableName?: string }\n  ): Promise<Array<{ __id: string; __version: number } & Record<string, unknown>>> {\n    const dbTableName = opts?.dbTableName ?? (await this.getDbTableName(tableId));\n\n    const columnNames = this.getUpdatableColumns(fields);\n    const returningNames = this.getReturningColumns(fields);\n\n    const returningWithAutoNumber = Array.from(\n      new Set([...returningNames, AUTO_NUMBER_FIELD_NAME])\n    );\n\n    const restrictRecordIdsRaw = opts?.restrictRecordIds?.filter(\n      (id): id is string => typeof id === 'string' && id.length > 0\n    );\n    const restrictRecordIds =\n      restrictRecordIdsRaw && restrictRecordIdsRaw.length\n        ? Array.from(new Set(restrictRecordIdsRaw))\n        : undefined;\n\n    // Acquire row-level locks in a deterministic order to avoid deadlocks when multiple\n    // computed updates touch the same set of records concurrently.\n    await this.lockRestrictRecords(dbTableName, restrictRecordIds);\n\n    const sql = this.dbProvider.updateFromSelectSql({\n      dbTableName,\n      idFieldName: '__id',\n      subQuery: qb,\n      dbFieldNames: columnNames,\n      returningDbFieldNames: returningWithAutoNumber,\n      restrictRecordIds,\n    });\n    this.logger.debug('updateFromSelect SQL:', sql);\n    try {\n      return await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<Array<{ __id: string; __version: number } & Record<string, unknown>>>(sql);\n    } catch (error) {\n      this.handleRawQueryError(error, sql, tableId, fields);\n    }\n  }\n\n  private buildFieldDebugSnapshot(fields: IFieldInstance[]): Array<Record<string, unknown>> {\n    return fields.map((field) => {\n      const f = field as unknown as Record<string, unknown>;\n      return {\n        id: f.id,\n        name: f.name,\n        type: f.type,\n        dbFieldName: f.dbFieldName,\n        dbFieldType: f.dbFieldType,\n        isLookup: f.isLookup,\n        isConditionalLookup: f.isConditionalLookup,\n        isComputed: f.isComputed,\n        hasError: f.hasError,\n        options: f.options,\n      };\n    });\n  }\n\n  private stringifyFieldDebugSnapshot(snapshot: unknown): string {\n    try {\n      return JSON.stringify(snapshot);\n    } catch (error) {\n      const reason = error instanceof Error ? error.message : String(error);\n      this.logger.warn(`Failed to stringify field debug snapshot: ${reason}`);\n      return '[field debug snapshot: <unserializable>]';\n    }\n  }\n\n  private handleRawQueryError(\n    error: unknown,\n    sql: string,\n    tableId: string,\n    fields: IFieldInstance[]\n  ): never {\n    const fieldSnapshot = this.buildFieldDebugSnapshot(fields);\n    const fieldSnapshotString = this.stringifyFieldDebugSnapshot(fieldSnapshot);\n    if (error instanceof Prisma.PrismaClientKnownRequestError) {\n      error.message = `${error.message}\\nSQL: ${sql}\\nTableId: ${tableId}\\nFields: ${fieldSnapshotString}`;\n      Object.assign(error, { sql, tableId, fields: fieldSnapshot });\n      this.logger.error(\n        `updateFromSelect known request error.\n        message: ${error.message}.\n        SQL: ${sql}.\n        Fields: ${fieldSnapshotString}`,\n        error.stack ?? undefined\n      );\n      throw error;\n    }\n    this.logger.error(\n      `updateFromSelect unexpected query error.\n      message: ${(error as Error)?.message}.\n      SQL: ${sql}.\n      tableId: ${tableId}.\n      Fields: ${fieldSnapshotString}`,\n      (error as Error)?.stack\n    );\n    if (error instanceof Error) {\n      error.message = `${error.message}\\nSQL: ${sql}\\nTableId: ${tableId}\\nFields: ${fieldSnapshotString}`;\n      Object.assign(error, { sql, tableId, fields: fieldSnapshot });\n    }\n    throw error;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/constant.ts",
    "content": "export const RECORD_DEFINE = `\nsingleLineText, type: string, example: \"bieber\"\nlongText, type: string, example: \"line1\\nline2\"\nsingleLineText, type: string, example: \"bieber\"\nattachment, type: string, example: \"bieber\"\ncheckbox, type: string, example: \"true\"\nmultipleSelect, type: string[], example: [\"red\", \"green\"]\nsingleSelect, type: string, example: \"In Progress\"\ndate, type: string, example: \"2012/12/12\"\nphoneNumber, type: string, example: \"1234567890\"\nemail, type: string, example: \"address@teable.ai\"\nurl, type: string, example: \"https://teable.ai\"\nnumber, type: number, example: 1\ncurrency, type: number, example: 1\npercent, type: number, example: 1\nduration, type: number, example: 1\nrating, type: number, example: 1\nformula,type: string, example: \"bieber\"\nrollup, type: string, example: \"bieber\"\ncount, type: number, example: 1\nmultipleRecordLinks, type: string, example: \"bieber\"\nmultipleLookupValues, type: string, example: \"bieber\"\ncreatedTime, type: string, example: \"2012/12/12 03:03\"\nlastModifiedTime, type: string, example: \"2012/12/12 03:03\"\ncreatedBy, type: string, example: \"bieber\"\nlastModifiedBy, type: string, example: \"bieber\"\nautoNumber, type: number, example: 1\nbutton, type: string, example: \"click\"\n`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/field-key.pipe.ts",
    "content": "import type { PipeTransform } from '@nestjs/common';\nimport { Inject, Injectable, Scope } from '@nestjs/common';\nimport { REQUEST } from '@nestjs/core';\nimport {\n  FieldKeyType,\n  replaceFilter,\n  replaceGroupBy,\n  replaceOrderBy,\n  replaceSearch,\n} from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { Request } from 'express';\nimport { keyBy } from 'lodash';\nimport { DataLoaderService } from '../../data-loader/data-loader.service';\n\n@Injectable({ scope: Scope.REQUEST })\nexport class FieldKeyPipe<T extends IGetRecordsRo> implements PipeTransform {\n  constructor(\n    @Inject(REQUEST) private readonly request: Request,\n    private readonly dataLoaderService: DataLoaderService\n  ) {}\n\n  async transform(value: T) {\n    const tableId = (this.request as Request).params.tableId;\n    if (!tableId) {\n      return value;\n    }\n\n    return this.transformFieldKeyTql(value, tableId);\n  }\n\n  private async transformFieldKeyTql(value: T, tableId: string): Promise<T> {\n    const fieldKeyType = value.fieldKeyType ?? FieldKeyType.Name;\n    if (fieldKeyType === FieldKeyType.Id) {\n      return value;\n    }\n\n    if (!value.filter && !value.search && !value.groupBy && !value.orderBy) {\n      return value;\n    }\n\n    const fields = await this.dataLoaderService.field.load(tableId);\n    const fieldMap = keyBy(fields, fieldKeyType);\n\n    const transformedValue = { ...value };\n\n    if (value.filter) {\n      transformedValue.filter = replaceFilter(value.filter, fieldMap, FieldKeyType.Id);\n    }\n\n    if (value.search) {\n      transformedValue.search = replaceSearch(value.search, fieldMap, FieldKeyType.Id);\n    }\n\n    if (value.groupBy) {\n      transformedValue.groupBy = replaceGroupBy(value.groupBy, fieldMap, FieldKeyType.Id);\n    }\n\n    if (value.orderBy) {\n      transformedValue.orderBy = replaceOrderBy(value.orderBy, fieldMap, FieldKeyType.Id);\n    }\n\n    return transformedValue;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts",
    "content": "import { CellValueType, FieldType, SortFunc, TimeFormatting } from '@teable/core';\nimport {\n  FieldKeyType,\n  ListTableRecordsQuery,\n  ListTableRecordsResult,\n  v2CoreTokens,\n} from '@teable/v2-core';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { RecordOpenApiV2Service } from './record-open-api-v2.service';\n\ndescribe('RecordOpenApiV2Service', () => {\n  const createdTimeIso = '2026-03-19T01:02:03.000Z';\n  const getDocIdsByQuery = vi.fn();\n  const getSnapshotBulkWithPermission = vi.fn();\n  const createContext = vi.fn();\n  const getReadQuerySource = vi.fn();\n  const getFieldsByQuery = vi.fn();\n  const execute = vi.fn();\n  const resolve = vi.fn();\n  const getContainer = vi.fn();\n\n  let service: RecordOpenApiV2Service;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    resolve.mockImplementation((token) => {\n      if (token === v2CoreTokens.queryBus) {\n        return { execute };\n      }\n      return undefined;\n    });\n    getContainer.mockResolvedValue({ resolve });\n    createContext.mockResolvedValue({});\n    getReadQuerySource.mockResolvedValue(undefined);\n    getFieldsByQuery.mockResolvedValue([]);\n    execute.mockResolvedValue({\n      isErr: () => false,\n      value: ListTableRecordsResult.create(\n        [\n          { id: 'rec1111111111111111', fields: {}, version: 1 },\n          { id: 'rec2222222222222222', fields: {}, version: 1 },\n        ],\n        2,\n        0,\n        2\n      ),\n    });\n    getSnapshotBulkWithPermission.mockResolvedValue([\n      { data: { id: 'rec1111111111111111', fields: {} } },\n      { data: { id: 'rec2222222222222222', fields: {} } },\n    ]);\n\n    service = new RecordOpenApiV2Service(\n      { getContainer } as never,\n      { createContext } as never,\n      { getDocIdsByQuery, getSnapshotBulkWithPermission } as never,\n      {} as never,\n      {} as never,\n      { get: vi.fn() } as never,\n      { getFieldsByQuery } as never,\n      { getReadQuerySource } as never,\n      {} as never,\n      {} as never\n    );\n  });\n\n  it('should ignore unreadable fields in orderBy and groupBy', () => {\n    const query = {\n      orderBy: [\n        { fieldId: 'fldReadable', order: SortFunc.Asc },\n        { fieldId: 'fldHidden', order: SortFunc.Desc },\n      ],\n      groupBy: [\n        { fieldId: 'fldHidden', order: SortFunc.Asc },\n        { fieldId: 'fldReadable', order: SortFunc.Desc },\n      ],\n    };\n\n    expect(\n      (\n        service as unknown as {\n          sanitizeReadableSortAndGroup: (\n            input: typeof query,\n            enabledFieldIds?: string[]\n          ) => typeof query;\n        }\n      ).sanitizeReadableSortAndGroup(query, ['fldReadable'])\n    ).toEqual({\n      orderBy: [{ fieldId: 'fldReadable', order: SortFunc.Asc }],\n      groupBy: [{ fieldId: 'fldReadable', order: SortFunc.Desc }],\n    });\n  });\n\n  it('should keep orderBy and groupBy unchanged when all fields are readable', () => {\n    const query = {\n      orderBy: [{ fieldId: 'fldReadable', order: SortFunc.Asc }],\n      groupBy: [{ fieldId: 'fldReadable', order: SortFunc.Desc }],\n    };\n\n    expect(\n      (\n        service as unknown as {\n          sanitizeReadableSortAndGroup: (\n            input: typeof query,\n            enabledFieldIds?: string[]\n          ) => typeof query;\n        }\n      ).sanitizeReadableSortAndGroup(query, ['fldReadable'])\n    ).toEqual(query);\n  });\n\n  it('forwards advanced link filters into the v2 query handler instead of using docIds fallback', async () => {\n    const filterLinkCellCandidate: [string, string] = [\n      `fld${'d'.repeat(16)}`,\n      `rec${'e'.repeat(16)}`,\n    ];\n    const selectedRecordIds = [`rec${'f'.repeat(16)}`];\n    const viewId = `viw${'g'.repeat(16)}`;\n\n    const result = await service.getRecords(`tbl${'c'.repeat(16)}`, {\n      fieldKeyType: FieldKeyType.Id,\n      filterLinkCellCandidate,\n      selectedRecordIds,\n      skip: 0,\n      take: 2,\n      viewId,\n      ignoreViewQuery: true,\n    });\n\n    expect(getDocIdsByQuery).not.toHaveBeenCalled();\n    expect(execute).toHaveBeenCalledTimes(1);\n\n    const query = execute.mock.calls[0]?.[1];\n    expect(query).toBeInstanceOf(ListTableRecordsQuery);\n    expect((query as ListTableRecordsQuery).filterLinkCellCandidate).toEqual(\n      filterLinkCellCandidate\n    );\n    expect((query as ListTableRecordsQuery).selectedRecordIds).toEqual(selectedRecordIds);\n    expect((query as ListTableRecordsQuery).viewId).toBe(viewId);\n    expect((query as ListTableRecordsQuery).ignoreViewQuery).toBe(true);\n    expect(getReadQuerySource).toHaveBeenCalledWith(`tbl${'c'.repeat(16)}`, {\n      viewId,\n      keepPrimaryKey: false,\n    });\n\n    expect(result.records).toEqual([\n      { id: 'rec1111111111111111', fields: {} },\n      { id: 'rec2222222222222222', fields: {} },\n    ]);\n  });\n\n  it('formats sorted top-level system datetime fields in the final OpenAPI response', async () => {\n    execute.mockResolvedValue({\n      isErr: () => false,\n      value: ListTableRecordsResult.create(\n        [{ id: 'rec1111111111111111', fields: {}, version: 1 }],\n        1,\n        0,\n        1\n      ),\n    });\n    getSnapshotBulkWithPermission.mockResolvedValue([\n      {\n        data: {\n          id: 'rec1111111111111111',\n          createdTime: createdTimeIso,\n          fields: {\n            createdTime: createdTimeIso,\n          },\n        },\n      },\n    ]);\n    getFieldsByQuery.mockResolvedValue([\n      {\n        id: 'fldCreatedTime0001',\n        name: 'createdTime',\n        type: FieldType.CreatedTime,\n        cellValueType: CellValueType.DateTime,\n        isMultipleCellValue: false,\n        dbFieldType: 'timestamp',\n        options: {\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: 'None',\n            timeZone: 'UTC',\n          },\n        },\n      },\n    ]);\n\n    const result = await service.getRecords(`tbl${'c'.repeat(16)}`, {\n      fieldKeyType: FieldKeyType.Name,\n      skip: 0,\n      take: 1,\n      orderBy: [{ fieldId: 'fldCreatedTime0001', order: SortFunc.Asc }],\n    });\n\n    expect(result.records).toEqual([\n      {\n        id: 'rec1111111111111111',\n        createdTime: '2026-03-19',\n        fields: {\n          createdTime: '2026-03-19T01:02:03.000Z',\n        },\n      },\n    ]);\n  });\n\n  it('does not normalize system datetime fields when they are not part of the active sort', async () => {\n    execute.mockResolvedValue({\n      isErr: () => false,\n      value: ListTableRecordsResult.create(\n        [{ id: 'rec1111111111111111', fields: {}, version: 1 }],\n        1,\n        0,\n        1\n      ),\n    });\n    getSnapshotBulkWithPermission.mockResolvedValue([\n      {\n        data: {\n          id: 'rec1111111111111111',\n          createdTime: createdTimeIso,\n          fields: {\n            createdTime: createdTimeIso,\n          },\n        },\n      },\n    ]);\n    getFieldsByQuery.mockResolvedValue([\n      {\n        id: 'fldCreatedTime0001',\n        name: 'createdTime',\n        type: FieldType.CreatedTime,\n        cellValueType: CellValueType.DateTime,\n        isMultipleCellValue: false,\n        dbFieldType: 'timestamp',\n        options: {\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: TimeFormatting.None,\n            timeZone: 'UTC',\n          },\n        },\n      },\n    ]);\n\n    const result = await service.getRecords(`tbl${'c'.repeat(16)}`, {\n      fieldKeyType: FieldKeyType.Name,\n      skip: 0,\n      take: 1,\n    });\n\n    expect(result.records).toEqual([\n      {\n        id: 'rec1111111111111111',\n        createdTime: createdTimeIso,\n        fields: {\n          createdTime: createdTimeIso,\n        },\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable, HttpException, HttpStatus, Inject, forwardRef } from '@nestjs/common';\nimport { trace } from '@opentelemetry/api';\nimport {\n  CellFormat,\n  CellValueType,\n  FieldKeyType,\n  FieldType,\n  TimeFormatting,\n  formatDateToString,\n  isMeTag,\n  parseClipboardText,\n  type IDatetimeFormatting,\n  type IFilter,\n  type IFilterSet,\n} from '@teable/core';\nimport type {\n  IUpdateRecordRo,\n  IFormSubmitRo,\n  IRecord,\n  ICreateRecordsRo,\n  ICreateRecordsVo,\n  IGetRecordsRo,\n  IPasteRo,\n  IPasteVo,\n  IRangesRo,\n  IRecordsVo,\n  IRecordInsertOrderRo,\n  IUpdateRecordsRo,\n} from '@teable/openapi';\nimport { RangeType } from '@teable/openapi';\nimport {\n  executeCreateRecordsEndpoint,\n  executeSubmitRecordEndpoint,\n  executeDeleteRecordsEndpoint,\n  executeDeleteByRangeEndpoint,\n  executePasteEndpoint,\n  executeClearEndpoint,\n  executeUpdateRecordEndpoint,\n  executeDuplicateRecordEndpoint,\n  executeReorderRecordsEndpoint,\n  executeListTableRecordsEndpoint,\n} from '@teable/v2-contract-http-implementation/handlers';\nimport { v2CoreTokens } from '@teable/v2-core';\nimport type {\n  ICommandBus,\n  IExecutionContext,\n  IListTableRecordsQueryInput,\n  IQueryBus,\n  RecordFilter,\n  RecordFilterDateValue,\n  RecordFilterGroup,\n  RecordFilterNode,\n  RecordFilterOperator,\n  RecordFilterValue,\n} from '@teable/v2-core';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { AggregationService } from '../../aggregation/aggregation.service';\nimport { FieldService } from '../../field/field.service';\nimport { SelectionService } from '../../selection/selection.service';\nimport { TableService } from '../../table/table.service';\nimport { V2_RECORD_PASTE_AUDIT_CONTEXT_KEY } from '../../v2/v2-audit-log.constants';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\nimport { RecordPermissionService } from '../record-permission.service';\nimport { RecordService } from '../record.service';\nimport { RecordOpenApiService } from './record-open-api.service';\n\nconst internalServerError = 'Internal server error';\nconst invalidFilterCode = 'validation.invalid_filter';\nconst v1SymbolOperatorMap: Record<string, string> = {\n  '=': 'is',\n  '!=': 'isNot',\n  '>': 'isGreater',\n  '>=': 'isGreaterEqual',\n  '<': 'isLess',\n  '<=': 'isLessEqual',\n  LIKE: 'contains',\n  'NOT LIKE': 'doesNotContain',\n  IN: 'isAnyOf',\n  'NOT IN': 'isNoneOf',\n  HAS: 'hasAllOf',\n  'IS NULL': 'isEmpty',\n  'IS NOT NULL': 'isNotEmpty',\n  'IS WITH IN': 'isWithIn',\n};\n\n@Injectable()\nexport class RecordOpenApiV2Service {\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory,\n    private readonly recordService: RecordService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly tableService: TableService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly fieldService: FieldService,\n    private readonly recordPermissionService: RecordPermissionService,\n    private readonly aggregationService: AggregationService,\n    @Inject(forwardRef(() => SelectionService))\n    private readonly selectionService: SelectionService\n  ) {}\n\n  private throwV2Error(\n    error: {\n      code: string;\n      message: string;\n      tags?: ReadonlyArray<string>;\n      details?: Readonly<Record<string, unknown>>;\n    },\n    status: number\n  ): never {\n    throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {\n      domainCode: error.code,\n      domainTags: error.tags,\n      details: error.details,\n    });\n  }\n\n  async getRecords(tableId: string, query: IGetRecordsRo): Promise<IRecordsVo> {\n    if (query.filterLinkCellSelected && query.filterLinkCellCandidate) {\n      this.throwV2Error(\n        {\n          code: invalidFilterCode,\n          message:\n            'filterLinkCellSelected and filterLinkCellCandidate can not be set at the same time',\n          tags: ['validation'],\n        },\n        HttpStatus.BAD_REQUEST\n      );\n    }\n\n    const context = await this.createV2ReadContext(tableId, query);\n    const enabledFieldIds = (\n      context as IExecutionContext & {\n        recordReadQuerySource?: { enabledFieldIds?: string[] };\n      }\n    ).recordReadQuerySource?.enabledFieldIds;\n    const effectiveQuery = {\n      ...query,\n      ...this.sanitizeReadableSortAndGroup(query, enabledFieldIds),\n    } satisfies IGetRecordsRo;\n\n    const requestedFieldKeyType = query.fieldKeyType ?? FieldKeyType.Name;\n    const snapshotProjection = await this.resolveSnapshotProjection(\n      tableId,\n      query,\n      requestedFieldKeyType\n    );\n    const normalizedFilter = await this.normalizeFilterForV2(tableId, query.filter);\n    const sortWithGroupFallback = this.mergeGroupByIntoSort(\n      effectiveQuery.groupBy,\n      effectiveQuery.orderBy\n    );\n    const normalizedSort = sortWithGroupFallback?.map((item) => ({\n      fieldId: item.fieldId,\n      order: item.order,\n    }));\n    const normalizedGroupBy = effectiveQuery.groupBy?.map((item) => item.fieldId);\n    const queryExtra = this.shouldLoadQueryExtra(effectiveQuery)\n      ? await this.getQueryExtra(tableId, effectiveQuery)\n      : undefined;\n\n    const container = await this.v2ContainerService.getContainer();\n    const queryBus = container.resolve<IQueryBus>(v2CoreTokens.queryBus);\n    const pageResult = await this.executeListRecordsEndpoint(\n      {\n        tableId,\n        // FieldKeyPipe has normalized request field keys to ids.\n        fieldKeyType: FieldKeyType.Id,\n        limit: query.take,\n        offset: query.skip,\n        ...(normalizedFilter ? { filter: normalizedFilter } : {}),\n        ...(normalizedSort?.length ? { sort: normalizedSort } : {}),\n        ...(normalizedGroupBy?.length ? { groupBy: normalizedGroupBy } : {}),\n        ...(effectiveQuery.search ? { search: effectiveQuery.search } : {}),\n        ...(effectiveQuery.filterLinkCellSelected\n          ? { filterLinkCellSelected: effectiveQuery.filterLinkCellSelected }\n          : {}),\n        ...(effectiveQuery.filterLinkCellCandidate\n          ? { filterLinkCellCandidate: effectiveQuery.filterLinkCellCandidate }\n          : {}),\n        ...(effectiveQuery.selectedRecordIds?.length\n          ? { selectedRecordIds: effectiveQuery.selectedRecordIds }\n          : {}),\n        ...(effectiveQuery.viewId ? { viewId: effectiveQuery.viewId } : {}),\n        ...(effectiveQuery.ignoreViewQuery !== undefined\n          ? { ignoreViewQuery: effectiveQuery.ignoreViewQuery }\n          : {}),\n      },\n      context,\n      queryBus\n    );\n    const orderedRecords = pageResult.records;\n\n    if (orderedRecords.length === 0) {\n      return queryExtra ? { records: [], extra: queryExtra } : { records: [] };\n    }\n\n    const recordIds = orderedRecords.map((record) => record.id);\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      recordIds,\n      snapshotProjection,\n      requestedFieldKeyType,\n      query.cellFormat,\n      true\n    );\n\n    if (snapshots.length !== recordIds.length) {\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    const snapshotMap = new Map(\n      snapshots.map((snapshot) => [snapshot.data.id, snapshot.data as IRecord])\n    );\n    const records = recordIds\n      .map((recordId) => snapshotMap.get(recordId))\n      .filter((record): record is IRecord => Boolean(record));\n\n    if (records.length !== recordIds.length) {\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    const normalizedRecords = await this.formatSystemDatetimeFields(\n      tableId,\n      records,\n      query.cellFormat,\n      sortWithGroupFallback?.map((item) => item.fieldId)\n    );\n\n    return queryExtra\n      ? { records: normalizedRecords, extra: queryExtra }\n      : { records: normalizedRecords };\n  }\n\n  private async formatSystemDatetimeFields(\n    tableId: string,\n    records: IRecord[],\n    cellFormat?: CellFormat,\n    sortedFieldIds?: ReadonlyArray<string>\n  ): Promise<IRecord[]> {\n    if (!records.length || cellFormat === CellFormat.Text || !sortedFieldIds?.length) {\n      return records;\n    }\n\n    const sortedFieldIdSet = new Set(sortedFieldIds);\n    const fields = await this.fieldService.getFieldsByQuery(tableId);\n    const formatters = fields.flatMap((field) => {\n      if (!sortedFieldIdSet.has(field.id)) {\n        return [];\n      }\n      if (field.type !== FieldType.CreatedTime && field.type !== FieldType.LastModifiedTime) {\n        return [];\n      }\n\n      const formatting = this.extractDatetimeFormatting(field.options);\n      if (!formatting || formatting.time !== TimeFormatting.None) {\n        return [];\n      }\n\n      return [\n        {\n          topLevelKey:\n            field.type === FieldType.CreatedTime\n              ? ('createdTime' as const)\n              : ('lastModifiedTime' as const),\n          formatting,\n        },\n      ];\n    });\n\n    if (!formatters.length) {\n      return records;\n    }\n\n    return records.map((record) => {\n      let nextRecord: IRecord | undefined;\n\n      for (const formatter of formatters) {\n        const topLevelValue = record[formatter.topLevelKey];\n        if (typeof topLevelValue === 'string') {\n          const formattedTopLevel = formatDateToString(topLevelValue, formatter.formatting);\n          if (formattedTopLevel !== topLevelValue) {\n            nextRecord ??= { ...record };\n            nextRecord[formatter.topLevelKey] = formattedTopLevel;\n          }\n        }\n      }\n\n      return nextRecord ?? record;\n    });\n  }\n\n  private extractDatetimeFormatting(options: unknown): IDatetimeFormatting | undefined {\n    if (!options || typeof options !== 'object' || !('formatting' in options)) {\n      return undefined;\n    }\n\n    const formatting = options.formatting;\n    if (!formatting || typeof formatting !== 'object') {\n      return undefined;\n    }\n\n    return formatting as IDatetimeFormatting;\n  }\n\n  private toProjectionMap(fieldKeys?: string | string[]): Record<string, boolean> | undefined {\n    if (!fieldKeys) {\n      return undefined;\n    }\n    const keys = (Array.isArray(fieldKeys) ? fieldKeys : [fieldKeys]).filter(\n      (key): key is string => typeof key === 'string' && key.length > 0\n    );\n    if (!keys.length) {\n      return undefined;\n    }\n    return keys.reduce<Record<string, boolean>>((acc, key) => {\n      acc[key] = true;\n      return acc;\n    }, {});\n  }\n\n  private async resolveSnapshotProjection(\n    tableId: string,\n    query: IGetRecordsRo,\n    fieldKeyType: FieldKeyType\n  ): Promise<Record<string, boolean> | undefined> {\n    const explicitProjection = this.toProjectionMap(\n      query.projection as unknown as string | string[]\n    );\n    if (explicitProjection) {\n      return explicitProjection;\n    }\n\n    if (query.ignoreViewQuery || !query.viewId) {\n      return undefined;\n    }\n\n    const visibleFields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: query.viewId,\n      filterHidden: true,\n    });\n\n    const projectionKeys = visibleFields\n      .map((field) => {\n        if (fieldKeyType === FieldKeyType.Id) {\n          return field.id;\n        }\n        if (fieldKeyType === FieldKeyType.Name) {\n          return field.name;\n        }\n        return field.dbFieldName || field.name;\n      })\n      .filter((key): key is string => Boolean(key));\n\n    return this.toProjectionMap(projectionKeys);\n  }\n\n  private async executeListRecordsEndpoint(\n    input: IListTableRecordsQueryInput,\n    context: IExecutionContext,\n    queryBus: IQueryBus\n  ): Promise<{\n    records: Array<{ id: string; fields: Record<string, unknown> }>;\n    pagination: { hasMore: boolean };\n  }> {\n    const result = await executeListTableRecordsEndpoint(context, input, queryBus);\n    if (result.status === 200 && result.body.ok) {\n      return {\n        records: result.body.data.records as Array<{ id: string; fields: Record<string, unknown> }>,\n        pagination: {\n          hasMore: result.body.data.pagination.hasMore,\n        },\n      };\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  private async createV2ReadContext(\n    tableId: string,\n    query: Pick<IGetRecordsRo, 'viewId' | 'ignoreViewQuery' | 'filterLinkCellSelected'>\n  ): Promise<IExecutionContext> {\n    const context = await this.v2ContextFactory.createContext();\n    const readSource = await this.recordPermissionService.getReadQuerySource(tableId, {\n      viewId: query.viewId,\n      keepPrimaryKey: Boolean(query.filterLinkCellSelected),\n    });\n    if (!readSource) {\n      return context;\n    }\n    return {\n      ...context,\n      recordReadQuerySource: {\n        tableName: readSource.tableName,\n        cteName: readSource.cteName,\n        cteSql: readSource.cteSql,\n        enabledFieldIds: readSource.enabledFieldIds,\n      },\n    } as IExecutionContext;\n  }\n\n  private sanitizeReadableSortAndGroup(\n    query: Pick<IGetRecordsRo, 'orderBy' | 'groupBy'>,\n    enabledFieldIds?: string[]\n  ): Pick<IGetRecordsRo, 'orderBy' | 'groupBy'> {\n    if (!enabledFieldIds?.length) {\n      return {\n        orderBy: query.orderBy,\n        groupBy: query.groupBy,\n      };\n    }\n\n    const enabledFieldIdSet = new Set(enabledFieldIds);\n    const orderBy = query.orderBy?.filter((item) => enabledFieldIdSet.has(item.fieldId));\n    const groupBy = query.groupBy?.filter((item) => enabledFieldIdSet.has(item.fieldId));\n\n    return {\n      orderBy: orderBy?.length ? orderBy : undefined,\n      groupBy: groupBy?.length ? groupBy : undefined,\n    };\n  }\n\n  private shouldLoadQueryExtra(query: IGetRecordsRo): boolean {\n    return Boolean(query.search || query.groupBy?.length || query.collapsedGroupIds?.length);\n  }\n\n  private async getQueryExtra(\n    tableId: string,\n    query: IGetRecordsRo\n  ): Promise<IRecordsVo['extra'] | undefined> {\n    const result = await this.recordService.getDocIdsByQuery(\n      tableId,\n      {\n        fieldKeyType: FieldKeyType.Id,\n        ignoreViewQuery: query.ignoreViewQuery ?? false,\n        viewId: query.viewId,\n        filter: query.filter,\n        orderBy: query.orderBy,\n        search: query.search,\n        groupBy: query.groupBy,\n        collapsedGroupIds: query.collapsedGroupIds,\n        projection: query.projection,\n        skip: query.skip,\n        take: query.take,\n      },\n      true\n    );\n    return result.extra;\n  }\n\n  async updateRecord(\n    tableId: string,\n    recordId: string,\n    updateRecordRo: IUpdateRecordRo,\n    windowId?: string,\n    isAiInternal?: string\n  ): Promise<IRecord> {\n    const order = updateRecordRo.order;\n    const hasOrder = Boolean(order);\n    const fields = updateRecordRo.record.fields ?? {};\n    const hasFields = Object.keys(fields).length > 0;\n\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    if (hasFields) {\n      // Convert v1 input format to v2 format\n      // v1: { record: { fields: { fieldKey: value } } }\n      // v2: { tableId, recordId, fields: { fieldId: value } }\n      // v1 stores select field values by name, v2 stores by id\n      // Preserve v1's default typecast behavior (false) to ensure proper validation\n      const v2Input = {\n        tableId,\n        recordId,\n        fields,\n        typecast: updateRecordRo.typecast ?? false,\n        fieldKeyType: updateRecordRo.fieldKeyType,\n        ...(order\n          ? {\n              order: {\n                viewId: order.viewId,\n                anchorId: order.anchorId,\n                position: order.position,\n              },\n            }\n          : {}),\n      };\n\n      const result = await executeUpdateRecordEndpoint(context, v2Input, commandBus);\n      if (!(result.status === 200 && result.body.ok)) {\n        if (!result.body.ok) {\n          this.throwV2Error(result.body.error, result.status);\n        }\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n    }\n\n    if (!hasFields && hasOrder && order) {\n      const reorderResult = await executeReorderRecordsEndpoint(\n        context,\n        {\n          tableId,\n          recordIds: [recordId],\n          order: {\n            viewId: order.viewId,\n            anchorId: order.anchorId,\n            position: order.position,\n          },\n        },\n        commandBus\n      );\n      if (!(reorderResult.status === 200 && reorderResult.body.ok)) {\n        if (!reorderResult.body.ok) {\n          this.throwV2Error(reorderResult.body.error, reorderResult.status);\n        }\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n    }\n\n    if (hasFields || hasOrder) {\n      const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n        tableId,\n        [recordId],\n        undefined,\n        updateRecordRo.fieldKeyType || FieldKeyType.Name,\n        undefined,\n        true\n      );\n\n      if (snapshots.length === 1) {\n        return snapshots[0].data as IRecord;\n      }\n\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async updateRecords(\n    tableId: string,\n    updateRecordsRo: IUpdateRecordsRo,\n    windowId?: string,\n    isAiInternal?: string\n  ): Promise<IRecord[]> {\n    const order = updateRecordsRo.order;\n    const records = updateRecordsRo.records ?? [];\n    const recordIds = records.map((record) => record.id);\n    const hasOrder = Boolean(order);\n    const hasFields = records.some(\n      (record) => record.fields && Object.keys(record.fields).length > 0\n    );\n\n    if (!hasOrder || hasFields) {\n      return (\n        await this.recordOpenApiService.updateRecords(\n          tableId,\n          updateRecordsRo,\n          windowId,\n          isAiInternal\n        )\n      ).records;\n    }\n\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    if (hasOrder && order) {\n      const reorderResult = await executeReorderRecordsEndpoint(\n        context,\n        {\n          tableId,\n          recordIds,\n          order: {\n            viewId: order.viewId,\n            anchorId: order.anchorId,\n            position: order.position,\n          },\n        },\n        commandBus\n      );\n      if (!(reorderResult.status === 200 && reorderResult.body.ok)) {\n        if (!reorderResult.body.ok) {\n          this.throwV2Error(reorderResult.body.error, reorderResult.status);\n        }\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n    }\n\n    if (recordIds.length === 0) {\n      return [];\n    }\n\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      recordIds,\n      undefined,\n      updateRecordsRo.fieldKeyType || FieldKeyType.Name,\n      undefined,\n      true\n    );\n\n    if (snapshots.length !== recordIds.length) {\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data]));\n    const resultRecords = recordIds\n      .map((recordId) => snapshotMap.get(recordId))\n      .filter((record): record is IRecord => Boolean(record));\n\n    if (resultRecords.length !== recordIds.length) {\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    return resultRecords;\n  }\n\n  async createRecords(\n    tableId: string,\n    createRecordsRo: ICreateRecordsRo,\n    isAiInternal?: string\n  ): Promise<ICreateRecordsVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    // Preserve v1's default typecast behavior (false) to ensure proper validation\n    const records = createRecordsRo.records;\n\n    const result = await executeCreateRecordsEndpoint(\n      context,\n      {\n        tableId,\n        records,\n        typecast: createRecordsRo.typecast ?? false,\n        fieldKeyType: createRecordsRo.fieldKeyType,\n        order: createRecordsRo.order,\n      },\n      commandBus\n    );\n\n    if (result.status === 201 && result.body.ok) {\n      const recordIds = result.body.data.records.map((record) => record.id);\n      if (recordIds.length === 0) {\n        return { records: [] };\n      }\n\n      const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n        tableId,\n        recordIds,\n        undefined,\n        createRecordsRo.fieldKeyType || FieldKeyType.Name,\n        undefined,\n        true\n      );\n\n      if (snapshots.length !== recordIds.length) {\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n\n      const snapshotMap = new Map(snapshots.map((snapshot) => [snapshot.data.id, snapshot.data]));\n      const resultRecords = recordIds\n        .map((recordId) => snapshotMap.get(recordId))\n        .filter((record): record is IRecord => Boolean(record));\n\n      if (resultRecords.length !== recordIds.length) {\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n\n      return { records: resultRecords };\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async formSubmit(tableId: string, formSubmitRo: IFormSubmitRo): Promise<IRecord> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const result = await executeSubmitRecordEndpoint(\n      context,\n      {\n        tableId,\n        formId: formSubmitRo.viewId,\n        fields: formSubmitRo.fields,\n        typecast: formSubmitRo.typecast ?? false,\n      },\n      commandBus\n    );\n\n    if (result.status === 201 && result.body.ok) {\n      const recordId = result.body.data.record.id;\n      const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n        tableId,\n        [recordId],\n        undefined,\n        FieldKeyType.Id,\n        undefined,\n        true\n      );\n\n      if (snapshots.length !== 1) {\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n\n      return snapshots[0].data as IRecord;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async paste(\n    tableId: string,\n    pasteRo: IPasteRo,\n    options?: { updateFilter?: IFilterSet | null; windowId?: string }\n  ): Promise<IPasteVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n    (\n      context as IExecutionContext & {\n        [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean;\n      }\n    )[V2_RECORD_PASTE_AUDIT_CONTEXT_KEY] = true;\n    const windowId = options?.windowId;\n    const tracer = trace.getTracer('default');\n\n    // Convert v1 input format to v2 format\n    // v1 ranges format depends on type:\n    // - default (cell range): [[startCol, startRow], [endCol, endRow]]\n    // - columns: [[startCol, endCol]] - single element array\n    // - rows: [[startRow, endRow]] - single element array\n    // v2 now supports type parameter directly and handles the conversion internally\n    const {\n      ranges,\n      content,\n      viewId,\n      header,\n      type,\n      projection,\n      filter,\n      orderBy,\n      groupBy,\n      collapsedGroupIds,\n      search,\n      ignoreViewQuery,\n    } = pasteRo;\n\n    let fallbackRanges: IPasteVo['ranges'] | null = null;\n    let v2Input: unknown;\n    let finalContent: unknown[][] = [];\n    let startCol = 0;\n    let startRow = 0;\n    let truncatedRows = 0;\n\n    await tracer.startActiveSpan('teable.paste.v2.prepare', async (span) => {\n      try {\n        // Parse content if it's a string (tab-separated values)\n        let parsedContent: unknown[][] =\n          typeof content === 'string' ? this.parseCopyContent(content) : content;\n\n        // Get permissions to check for field|create and record|create\n        const permissions = this.cls.get('permissions') ?? [];\n        const hasFieldCreatePermission = permissions.includes('field|create');\n        const hasRecordCreatePermission = permissions.includes('record|create');\n\n        // Get table size to calculate expansion needs\n        const rangeQuery = await this.normalizeRangeQuery(tableId, {\n          viewId,\n          filter,\n          search,\n          groupBy,\n          orderBy,\n          collapsedGroupIds,\n          ignoreViewQuery,\n        });\n        const queryRo = {\n          viewId: rangeQuery.viewId,\n          ignoreViewQuery: rangeQuery.ignoreViewQuery,\n          filter: rangeQuery.filter,\n          projection,\n          orderBy: rangeQuery.orderBy,\n          groupBy: rangeQuery.groupBy,\n          collapsedGroupIds,\n          search,\n        };\n\n        const fields = await this.fieldService.getFieldInstances(tableId, {\n          viewId: rangeQuery.viewId,\n          filterHidden: true,\n          projection,\n        });\n        const { rowCount: rowCountInView } = await this.aggregationService.performRowCount(\n          tableId,\n          queryRo\n        );\n\n        const tableSize: [number, number] = [fields.length, rowCountInView];\n\n        // Calculate start cell based on range type\n        if (type === 'columns') {\n          startCol = ranges[0]![0];\n          startRow = 0;\n        } else if (type === 'rows') {\n          startCol = 0;\n          startRow = ranges[0]![0];\n        } else {\n          startCol = ranges[0]![0];\n          startRow = ranges[0]![1];\n        }\n\n        // Expand paste content to fill selection (matches V1 behavior)\n        parsedContent = this.expandPasteContent(\n          parsedContent,\n          type,\n          ranges,\n          tableSize[0],\n          tableSize[1],\n          startCol,\n          startRow\n        );\n\n        const contentCols = parsedContent[0]?.length ?? 0;\n        const contentRows = parsedContent.length;\n\n        // Calculate expansion needs\n        const numColsToExpand = Math.max(0, startCol + contentCols - tableSize[0]);\n        const numRowsToExpand = Math.max(0, startRow + contentRows - tableSize[1]);\n\n        // Apply permission-based limits (like V1's calculateExpansion)\n        const effectiveColsToExpand = hasFieldCreatePermission ? numColsToExpand : 0;\n        const effectiveRowsToExpand = hasRecordCreatePermission ? numRowsToExpand : 0;\n\n        // When paste needs to create new fields, fall back to V1's paste implementation.\n        // V2's paste doesn't support field creation, and mixing V2 record operations with\n        // V1 field operations causes database lock conflicts during undo.\n        if (effectiveColsToExpand > 0) {\n          fallbackRanges = await this.selectionService.paste(tableId, pasteRo, {\n            windowId,\n          });\n          return;\n        }\n\n        // Truncate content if expansion is not allowed\n        finalContent = parsedContent;\n        const maxCols = tableSize[0] - startCol + effectiveColsToExpand;\n        const maxRows = tableSize[1] - startRow + effectiveRowsToExpand;\n\n        // Track if we need to adjust ranges due to truncation\n        let truncatedCols = contentCols;\n        truncatedRows = contentRows;\n\n        if (contentCols > maxCols || contentRows > maxRows) {\n          truncatedRows = Math.min(contentRows, maxRows);\n          truncatedCols = Math.min(contentCols, maxCols);\n          finalContent = parsedContent\n            .slice(0, truncatedRows)\n            .map((row) => row.slice(0, truncatedCols));\n        }\n\n        // Adjust ranges to match truncated content (prevents V2 core from re-expanding)\n        let adjustedRanges = ranges;\n        if (type === undefined && finalContent.length > 0 && finalContent[0]?.length > 0) {\n          // For cell type, adjust end position to match truncated content\n          const adjustedEndCol = startCol + truncatedCols - 1;\n          const adjustedEndRow = startRow + truncatedRows - 1;\n          adjustedRanges = [\n            [startCol, startRow],\n            [adjustedEndCol, adjustedEndRow],\n          ];\n        }\n\n        // Convert header to sourceFields format if provided\n        const sourceFields = header?.map((field) => ({\n          name: field.name,\n          type: field.type,\n          cellValueType: field.cellValueType,\n          isComputed: field.isComputed,\n          isLookup: field.isLookup,\n          isMultipleCellValue: field.isMultipleCellValue,\n          options: field.options,\n        }));\n\n        const normalizedFilter = await this.normalizeFilterForV2(tableId, queryRo.filter);\n        const normalizedUpdateFilter = options?.updateFilter\n          ? await this.normalizeFilterForV2(tableId, options.updateFilter)\n          : undefined;\n        const sortWithGroupFallback = this.mergeGroupByIntoSort(\n          rangeQuery.groupBy,\n          rangeQuery.orderBy\n        );\n        v2Input = {\n          tableId,\n          viewId: rangeQuery.viewId,\n          ranges: adjustedRanges,\n          content: finalContent,\n          typecast: true,\n          sourceFields,\n          type, // Pass type to v2 for internal handling\n          projection,\n          // Let v2 core interpret the legacy search tuple via RecordSearch so\n          // search-aware row mapping and field/operator compatibility stay aligned.\n          filter: normalizedFilter,\n          search: rangeQuery.search,\n          updateFilter: normalizedUpdateFilter,\n          sort: sortWithGroupFallback,\n          groupBy: rangeQuery.groupBy?.map((item) => ({\n            fieldId: item.fieldId,\n            order: item.order,\n          })),\n          ignoreViewQuery: rangeQuery.ignoreViewQuery,\n        };\n      } finally {\n        span.end();\n      }\n    });\n\n    if (fallbackRanges) {\n      return { ranges: fallbackRanges };\n    }\n\n    if (!v2Input) {\n      throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    const result = await executePasteEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      // V2 returns { updatedCount, createdCount, createdRecordIds }\n      // V1 expects { ranges: [[startCol, startRow], [endCol, endRow]] }\n      // Use truncatedRows (content size) for range calculation, not operation count,\n      // because some rows may be skipped due to permission filters\n      const finalCols = finalContent[0]?.length ?? 1;\n\n      // Note: Record creation undo/redo is handled by V2's RecordsBatchCreated projection handler\n      // Field creation case is handled by V1 fallback above\n\n      // Best-effort: normalize v1 range formats (cell/rows/columns) into a cell range.\n      // v1 \"ranges\" uses `cellSchema` for all modes:\n      // - default: [col, row]\n      // - columns: [startCol, endCol]\n      // - rows: [startRow, endRow]\n      if (type === 'columns') {\n        const endCol = startCol + finalCols - 1;\n        return {\n          ranges: [\n            [startCol, 0],\n            [endCol, Math.max(truncatedRows - 1, 0)],\n          ],\n        };\n      }\n\n      if (type === 'rows') {\n        const endRow = ranges[0]![1];\n        return {\n          ranges: [\n            [0, startRow],\n            [Math.max(finalCols - 1, 0), endRow],\n          ],\n        };\n      }\n\n      const endRow = startRow + Math.max(truncatedRows - 1, 0);\n      const endCol = startCol + finalCols - 1;\n      return {\n        ranges: [\n          [startCol, startRow],\n          [endCol, Math.max(endRow, startRow)],\n        ],\n      };\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  /**\n   * Expand paste content to fill target selection (matches V1 behavior).\n   * If the selection is a multiple of the content size, the content is tiled.\n   */\n  private expandPasteContent(\n    content: unknown[][],\n    type: 'columns' | 'rows' | undefined,\n    ranges: [number, number][],\n    totalCols: number,\n    totalRows: number,\n    startCol: number,\n    startRow: number\n  ): unknown[][] {\n    if (content.length === 0 || content[0]?.length === 0) {\n      return content;\n    }\n\n    const contentRows = content.length;\n    const contentCols = content[0]!.length;\n\n    // Calculate target range size\n    let targetRows: number;\n    let targetCols: number;\n\n    if (type === 'columns') {\n      const endCol = ranges[0]![1];\n      targetCols = endCol - startCol + 1;\n      targetRows = totalRows;\n    } else if (type === 'rows') {\n      const endRow = ranges[0]![1];\n      targetRows = endRow - startRow + 1;\n      targetCols = totalCols;\n    } else {\n      // Cell range: [[startCol, startRow], [endCol, endRow]]\n      const endCol = ranges[1]?.[0] ?? startCol;\n      const endRow = ranges[1]?.[1] ?? startRow;\n      targetCols = endCol - startCol + 1;\n      targetRows = endRow - startRow + 1;\n    }\n\n    // If target equals content size, no expansion needed\n    if (targetRows === contentRows && targetCols === contentCols) {\n      return content;\n    }\n\n    // Only expand if target is an exact multiple of content dimensions\n    if (targetRows % contentRows !== 0 || targetCols % contentCols !== 0) {\n      return content;\n    }\n\n    // Tile content to fill the target range\n    return Array.from({ length: targetRows }, (_, rowIdx) =>\n      Array.from(\n        { length: targetCols },\n        (_, colIdx) => content[rowIdx % contentRows]![colIdx % contentCols]\n      )\n    );\n  }\n\n  async clear(tableId: string, rangesRo: IRangesRo): Promise<null> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo);\n    const normalizedFilter = await this.normalizeFilterForV2(tableId, rangeQuery.filter);\n    const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy);\n    const v2Input = {\n      tableId,\n      viewId: rangeQuery.viewId,\n      ranges: rangesRo.ranges,\n      type: rangesRo.type,\n      projection: rangesRo.projection,\n      filter: normalizedFilter,\n      search: rangeQuery.search,\n      sort: sortWithGroupFallback,\n      groupBy: rangeQuery.groupBy?.map((item) => ({\n        fieldId: item.fieldId,\n        order: item.order,\n      })),\n      ignoreViewQuery: rangeQuery.ignoreViewQuery,\n    };\n\n    const result = await executeClearEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      // V1 clear returns null\n      return null;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  /**\n   * Get record IDs from ranges for undo/redo support and permission checks.\n   * This method queries the record IDs that will be affected by a range-based operation.\n   */\n  async getRecordIdsFromRanges(tableId: string, rangesRo: IRangesRo): Promise<string[]> {\n    const {\n      ranges,\n      type,\n      viewId,\n      filter,\n      orderBy,\n      search,\n      groupBy,\n      collapsedGroupIds,\n      ignoreViewQuery,\n    } = rangesRo;\n\n    const baseQuery = {\n      viewId,\n      ignoreViewQuery,\n      filter,\n      orderBy,\n      search,\n      groupBy,\n      collapsedGroupIds,\n      fieldKeyType: FieldKeyType.Id,\n    };\n    const maxBatchSize = 1000;\n\n    const fetchRecordIdsByRange = async (start: number, end: number): Promise<string[]> => {\n      const total = end - start + 1;\n      if (total <= 0) {\n        return [];\n      }\n\n      let recordIds: string[] = [];\n      for (let offset = 0; offset < total; offset += maxBatchSize) {\n        const take = Math.min(maxBatchSize, total - offset);\n        const result = await this.recordService.getDocIdsByQuery(\n          tableId,\n          {\n            ...baseQuery,\n            skip: start + offset,\n            take,\n          },\n          true\n        );\n        recordIds = recordIds.concat(result.ids);\n        if (result.ids.length < take) {\n          break;\n        }\n      }\n      return recordIds;\n    };\n\n    if (type === RangeType.Columns) {\n      // For columns selection, get all record IDs\n      const result = await this.recordService.getDocIdsByQuery(\n        tableId,\n        { ...baseQuery, skip: 0, take: -1 },\n        true\n      );\n      return result.ids;\n    }\n\n    if (type === RangeType.Rows) {\n      // For rows selection, iterate through each range [start, end]\n      let recordIds: string[] = [];\n      for (const [start, end] of ranges) {\n        recordIds = recordIds.concat(await fetchRecordIdsByRange(start, end));\n      }\n      return recordIds;\n    }\n\n    // Default: cell range - ranges is [[startCol, startRow], [endCol, endRow]]\n    const [start, end] = ranges;\n    return fetchRecordIdsByRange(start[1], end[1]);\n  }\n\n  async deleteByRange(\n    tableId: string,\n    rangesRo: IRangesRo,\n    _windowId?: string\n  ): Promise<{ ids: string[] }> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo);\n    const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy);\n\n    // Build v2 deleteByRange input\n    const v2Input = {\n      tableId,\n      viewId: rangeQuery.viewId,\n      ranges: rangesRo.ranges,\n      type: rangesRo.type,\n      filter: await this.normalizeFilterForV2(tableId, rangeQuery.filter),\n      sort: sortWithGroupFallback?.map((item) => ({\n        fieldId: item.fieldId,\n        order: item.order,\n      })),\n      search: rangeQuery.search,\n      groupBy: rangeQuery.groupBy?.map((item) => ({\n        fieldId: item.fieldId,\n        order: item.order,\n      })),\n      ignoreViewQuery: rangeQuery.ignoreViewQuery,\n    };\n\n    const result = await executeDeleteByRangeEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      // V2's DeleteByRangeHandler captures snapshots and emits RecordsDeleted event.\n      // Undo/redo is handled directly by v2 command replay.\n      return { ids: [...result.body.data.deletedRecordIds] };\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async deleteRecords(\n    tableId: string,\n    recordIds: string[],\n    _windowId?: string\n  ): Promise<IRecordsVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    // Query records before deletion to return them in V1 format\n    const recordSnapshots = await this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      recordIds,\n      undefined,\n      FieldKeyType.Id,\n      undefined,\n      true\n    );\n\n    const v2Input = {\n      tableId,\n      recordIds,\n    };\n\n    const result = await executeDeleteRecordsEndpoint(context, v2Input, commandBus);\n\n    if (result.status === 200 && result.body.ok) {\n      // Return records that were deleted (V1 format)\n      return {\n        records: recordSnapshots.map((snapshot) => snapshot.data as IRecord),\n      };\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  /**\n   * Parse tab-separated content string into 2D array\n   */\n  private parseCopyContent(content: string): unknown[][] {\n    return parseClipboardText(content);\n  }\n\n  private async resolveViewId(tableId: string, viewId?: string | null): Promise<string> {\n    if (viewId) {\n      return viewId;\n    }\n    const defaultView = await this.tableService.getDefaultViewId(tableId);\n    return defaultView.id;\n  }\n\n  private async normalizeRangeQuery(\n    tableId: string,\n    query: Pick<\n      IRangesRo,\n      | 'viewId'\n      | 'filter'\n      | 'search'\n      | 'groupBy'\n      | 'orderBy'\n      | 'collapsedGroupIds'\n      | 'ignoreViewQuery'\n    >\n  ): Promise<{\n    viewId: string;\n    filter: IFilter | null | undefined;\n    search: IRangesRo['search'];\n    orderBy: IRangesRo['orderBy'];\n    groupBy: IRangesRo['groupBy'];\n    ignoreViewQuery: boolean;\n  }> {\n    const resolvedViewId = await this.resolveViewId(tableId, query.viewId);\n    const filterWithCollapsed = await this.buildRangeFilter(tableId, {\n      viewId: resolvedViewId,\n      filter: query.filter,\n      search: query.search,\n      groupBy: query.groupBy,\n      collapsedGroupIds: query.collapsedGroupIds,\n      ignoreViewQuery: query.ignoreViewQuery,\n    });\n\n    return {\n      viewId: resolvedViewId,\n      filter: filterWithCollapsed,\n      search: query.search,\n      orderBy: query.orderBy,\n      groupBy: query.groupBy,\n      ignoreViewQuery: query.ignoreViewQuery ?? false,\n    };\n  }\n\n  /**\n   * V1 selection APIs derive row offsets from `groupBy + orderBy`.\n   * Keep the same effective sort in v2 input so row targeting remains stable\n   * even when intermediate adapters fail to carry `groupBy`.\n   */\n  private mergeGroupByIntoSort(\n    groupBy?: IRangesRo['groupBy'],\n    orderBy?: IRangesRo['orderBy']\n  ): IRangesRo['orderBy'] {\n    const merged = [...(groupBy ?? []), ...(orderBy ?? [])];\n    if (!merged.length) {\n      return undefined;\n    }\n\n    const deduplicated = merged.filter(\n      (item, index, list) =>\n        list.findIndex((candidate) => candidate.fieldId === item.fieldId) === index\n    );\n\n    return deduplicated.length ? deduplicated : undefined;\n  }\n\n  private async buildRangeFilter(\n    tableId: string,\n    query: {\n      viewId: string;\n      filter?: IFilter | null;\n      search?: IRangesRo['search'];\n      groupBy?: IRangesRo['groupBy'];\n      collapsedGroupIds?: string[];\n      ignoreViewQuery?: boolean;\n    }\n  ): Promise<IFilter | null | undefined> {\n    const normalizedGroupBy = query.groupBy ?? undefined;\n    if (!normalizedGroupBy?.length || !query.collapsedGroupIds?.length) {\n      return query.filter;\n    }\n    const normalizedSearch = this.normalizeGroupRelatedSearch(query.search);\n    const normalizedFilter = query.filter ?? undefined;\n\n    const { filter } = await this.recordService.getGroupRelatedData(tableId, {\n      viewId: query.viewId,\n      ignoreViewQuery: query.ignoreViewQuery ?? false,\n      filter: normalizedFilter,\n      search: normalizedSearch,\n      groupBy: normalizedGroupBy,\n      collapsedGroupIds: query.collapsedGroupIds,\n    });\n\n    return filter;\n  }\n\n  private normalizeGroupRelatedSearch(search?: IRangesRo['search']): IGetRecordsRo['search'] {\n    if (!search) {\n      return undefined;\n    }\n\n    const [searchValue, fieldId, hideNotMatch] = search;\n    if (fieldId == null) {\n      return [searchValue];\n    }\n    if (hideNotMatch == null) {\n      return [searchValue, fieldId];\n    }\n    return [searchValue, fieldId, hideNotMatch];\n  }\n\n  private async normalizeFilterForV2(\n    tableId: string,\n    filter: unknown\n  ): Promise<RecordFilter | undefined | null> {\n    const mapped = this.mapV1FilterToV2(filter);\n    if (!mapped) {\n      return mapped;\n    }\n\n    const fields = await this.fieldService.getFieldInstances(tableId, { filterHidden: true });\n    const fieldMetaMap = new Map(\n      fields.map((field) => [\n        field.id,\n        {\n          type: field.type,\n          cellValueType: field.cellValueType,\n        },\n      ])\n    );\n    const currentUserId = this.cls.get('user.id');\n\n    const normalizeNode = (node: RecordFilterNode): RecordFilterNode | null => {\n      if ('not' in node) {\n        const next = normalizeNode(node.not);\n        if (!next) return null;\n        return { not: next };\n      }\n\n      if ('items' in node) {\n        const items = node.items\n          .map((item) => normalizeNode(item))\n          .filter((item): item is RecordFilterNode => Boolean(item));\n        if (!items.length) return null;\n        return { conjunction: node.conjunction, items };\n      }\n\n      const operator = node.operator as RecordFilterOperator;\n      const operatorsExpectingNull: ReadonlySet<RecordFilterOperator> = new Set([\n        'isEmpty',\n        'isNotEmpty',\n      ]);\n      const operatorsExpectingArray: ReadonlySet<RecordFilterOperator> = new Set([\n        'isAnyOf',\n        'isNoneOf',\n        'hasAnyOf',\n        'hasAllOf',\n        'isNotExactly',\n        'hasNoneOf',\n        'isExactly',\n      ]);\n      const fieldMeta = fieldMetaMap.get(node.fieldId);\n      let value = node.value as RecordFilterValue;\n\n      if (operatorsExpectingNull.has(operator)) {\n        if (value !== null) return null;\n        return { ...node, value: null };\n      }\n\n      if (value == null) {\n        const isCheckboxField =\n          fieldMeta?.type === FieldType.Checkbox ||\n          fieldMeta?.cellValueType === CellValueType.Boolean;\n        if (operator === 'is' && isCheckboxField) {\n          value = false;\n        } else {\n          return null;\n        }\n      }\n\n      if (\n        currentUserId &&\n        fieldMeta &&\n        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(\n          fieldMeta.type as FieldType\n        )\n      ) {\n        if (Array.isArray(value)) {\n          value = value.map((entry) =>\n            typeof entry === 'string' && isMeTag(entry) ? currentUserId : entry\n          ) as RecordFilterValue;\n        } else if (typeof value === 'string' && isMeTag(value)) {\n          value = currentUserId as RecordFilterValue;\n        }\n      }\n\n      if (operatorsExpectingArray.has(operator)) {\n        if (!Array.isArray(value) && !this.isRecordFilterFieldReferenceValue(value)) {\n          value = [value] as RecordFilterValue;\n        }\n        if (Array.isArray(value) && value.length === 0) return null;\n      }\n\n      return {\n        ...node,\n        value,\n      };\n    };\n\n    const normalized = normalizeNode(mapped);\n    return normalized ?? undefined;\n  }\n\n  private mapV1FilterToV2(filter: unknown): RecordFilter | undefined | null {\n    if (filter === undefined) return undefined;\n    if (filter === null) return null;\n    if (this.isV2FilterNode(filter)) return this.normalizeV2FilterNode(filter);\n    if (this.isV1FilterGroup(filter)) return this.mapV1FilterGroup(filter);\n    if (this.isV1FilterItem(filter)) return this.mapV1FilterItem(filter);\n    return undefined;\n  }\n\n  private isV2FilterNode(value: unknown): value is RecordFilterNode {\n    if (!value || typeof value !== 'object') return false;\n    const record = value as Record<string, unknown>;\n    if (Array.isArray(record.items)) return true;\n    if (record.not && typeof record.not === 'object') return true;\n    if (typeof record.fieldId === 'string' && typeof record.operator === 'string') return true;\n    return false;\n  }\n\n  private isV1FilterGroup(\n    value: unknown\n  ): value is { conjunction: 'and' | 'or'; filterSet: unknown[] } {\n    if (!value || typeof value !== 'object') return false;\n    const record = value as Record<string, unknown>;\n    return Array.isArray(record.filterSet);\n  }\n\n  private isV1FilterItem(\n    value: unknown\n  ): value is { fieldId: string; operator: string; value?: unknown; isSymbol?: boolean } {\n    if (!value || typeof value !== 'object') return false;\n    const record = value as Record<string, unknown>;\n    return typeof record.fieldId === 'string' && typeof record.operator === 'string';\n  }\n\n  private mapV1FilterGroup(filter: {\n    conjunction: 'and' | 'or';\n    filterSet: unknown[];\n  }): RecordFilterGroup | null {\n    const items = filter.filterSet\n      .map((entry) => this.mapV1FilterEntry(entry))\n      .filter((entry): entry is RecordFilterNode => Boolean(entry));\n    if (items.length === 0) return null;\n    return {\n      conjunction: filter.conjunction === 'or' ? 'or' : 'and',\n      items,\n    };\n  }\n\n  private mapV1FilterEntry(entry: unknown): RecordFilterNode | null {\n    if (entry === null || entry === undefined) return null;\n    if (this.isV1FilterGroup(entry)) return this.mapV1FilterGroup(entry);\n    if (this.isV1FilterItem(entry)) return this.mapV1FilterItem(entry);\n    if (this.isV2FilterNode(entry)) return this.normalizeV2FilterNode(entry);\n    return null;\n  }\n\n  private mapV1FilterItem(filter: {\n    fieldId: string;\n    operator: string;\n    value?: unknown;\n    isSymbol?: boolean;\n  }): RecordFilterNode | null {\n    const operator = this.normalizeV1Operator(\n      filter.operator,\n      filter.isSymbol\n    ) as RecordFilterOperator;\n    const rawValue = 'value' in filter ? filter.value : null;\n    const legacyDateRangeCondition = this.mapLegacyDateRangeCondition(\n      filter.fieldId,\n      operator,\n      rawValue\n    );\n    if (legacyDateRangeCondition) return legacyDateRangeCondition;\n\n    const operatorsExpectingNull: ReadonlySet<RecordFilterOperator> = new Set([\n      'isEmpty',\n      'isNotEmpty',\n    ]);\n    const operatorsExpectingArray: ReadonlySet<RecordFilterOperator> = new Set([\n      'isAnyOf',\n      'isNoneOf',\n      'hasAnyOf',\n      'hasAllOf',\n      'isNotExactly',\n      'hasNoneOf',\n      'isExactly',\n    ]);\n\n    if (operatorsExpectingNull.has(operator)) {\n      return {\n        fieldId: filter.fieldId,\n        operator,\n        value: null,\n      };\n    }\n\n    if (operatorsExpectingArray.has(operator)) {\n      let value = rawValue;\n      if (value == null) return null;\n      if (!Array.isArray(value) && !this.isRecordFilterFieldReferenceValue(value)) {\n        value = [value];\n      }\n      if (Array.isArray(value) && value.length === 0) return null;\n      return {\n        fieldId: filter.fieldId,\n        operator,\n        value: value as RecordFilterValue,\n      };\n    }\n\n    if (rawValue == null) {\n      if (operator === 'is') {\n        return {\n          fieldId: filter.fieldId,\n          operator,\n          value: null,\n        };\n      }\n      return null;\n    }\n\n    return {\n      fieldId: filter.fieldId,\n      operator,\n      value: rawValue as RecordFilterValue,\n    };\n  }\n\n  private normalizeV1Operator(operator: string, isSymbol?: boolean): string {\n    const mapped = v1SymbolOperatorMap[operator];\n    if (mapped) return mapped;\n    if (isSymbol) return operator;\n    return operator;\n  }\n\n  private mapLegacyDateRangeCondition(\n    fieldId: string,\n    operator: RecordFilterOperator,\n    value: unknown\n  ): RecordFilterNode | null {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return null;\n\n    const record = value as Record<string, unknown>;\n    if (record.mode !== 'dateRange') return null;\n\n    if (operator !== 'is' && operator !== 'isWithIn') {\n      this.throwV2Error(\n        {\n          code: invalidFilterCode,\n          message: 'dateRange mode only supports is/isWithIn operators',\n          tags: ['validation'],\n        },\n        HttpStatus.BAD_REQUEST\n      );\n    }\n\n    const exactDate = record.exactDate;\n    const exactDateEnd = record.exactDateEnd;\n    const timeZone = record.timeZone;\n    if (\n      typeof exactDate !== 'string' ||\n      typeof exactDateEnd !== 'string' ||\n      typeof timeZone !== 'string'\n    ) {\n      return null;\n    }\n\n    const startTimestamp = Date.parse(exactDate);\n    const endTimestamp = Date.parse(exactDateEnd);\n    if (!Number.isFinite(startTimestamp) || !Number.isFinite(endTimestamp)) {\n      return null;\n    }\n    if (startTimestamp > endTimestamp) {\n      this.throwV2Error(\n        {\n          code: invalidFilterCode,\n          message: 'dateRange exactDate must be less than or equal to exactDateEnd',\n          tags: ['validation'],\n          details: { fieldId, exactDate, exactDateEnd },\n        },\n        HttpStatus.BAD_REQUEST\n      );\n    }\n\n    return {\n      conjunction: 'and',\n      items: [\n        {\n          fieldId,\n          operator: 'isOnOrAfter',\n          value: {\n            mode: 'exactDate',\n            exactDate,\n            timeZone,\n          } as RecordFilterDateValue,\n        },\n        {\n          fieldId,\n          operator: 'isOnOrBefore',\n          value: {\n            mode: 'exactDate',\n            exactDate: exactDateEnd,\n            timeZone,\n          } as RecordFilterDateValue,\n        },\n      ],\n    };\n  }\n\n  private normalizeV2FilterNode(filter: RecordFilterNode): RecordFilterNode | null {\n    if ('not' in filter) {\n      const next = this.normalizeV2FilterNode(filter.not);\n      if (!next) return null;\n      return { not: next };\n    }\n\n    if ('items' in filter) {\n      const items = filter.items\n        .map((item) => this.normalizeV2FilterNode(item))\n        .filter((item): item is RecordFilterNode => Boolean(item));\n      if (!items.length) return null;\n      return { conjunction: filter.conjunction, items };\n    }\n\n    const operator = filter.operator as RecordFilterOperator;\n    const value = filter.value as RecordFilterValue;\n    const legacyDateRangeCondition = this.mapLegacyDateRangeCondition(\n      filter.fieldId,\n      operator,\n      value\n    );\n    if (legacyDateRangeCondition) return legacyDateRangeCondition;\n\n    const operatorsExpectingNull: ReadonlySet<RecordFilterOperator> = new Set([\n      'isEmpty',\n      'isNotEmpty',\n    ]);\n    const operatorsExpectingArray: ReadonlySet<RecordFilterOperator> = new Set([\n      'isAnyOf',\n      'isNoneOf',\n      'hasAnyOf',\n      'hasAllOf',\n      'isNotExactly',\n      'hasNoneOf',\n      'isExactly',\n    ]);\n\n    if (operatorsExpectingNull.has(operator)) {\n      if (value !== null) return null;\n      return filter;\n    }\n\n    if (operatorsExpectingArray.has(operator)) {\n      if (value == null) return null;\n      if (Array.isArray(value) && value.length === 0) return null;\n      return filter;\n    }\n\n    if (value == null) {\n      if (operator === 'is') return filter;\n      return null;\n    }\n    return filter;\n  }\n\n  private isRecordFilterFieldReferenceValue(value: unknown): value is {\n    fieldId: string;\n    type: 'field';\n  } {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n    const record = value as Record<string, unknown>;\n    return record.type === 'field' && typeof record.fieldId === 'string';\n  }\n\n  async duplicateRecord(\n    tableId: string,\n    recordId: string,\n    order?: IRecordInsertOrderRo\n  ): Promise<IRecord> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const result = await executeDuplicateRecordEndpoint(\n      context,\n      {\n        tableId,\n        recordId,\n        order,\n      },\n      commandBus\n    );\n\n    if (result.status === 201 && result.body.ok) {\n      const duplicatedRecordId = result.body.data.record.id;\n\n      // Use V1 to get the full record with proper field key mapping\n      const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n        tableId,\n        [duplicatedRecordId],\n        undefined,\n        FieldKeyType.Name,\n        undefined,\n        true\n      );\n\n      if (snapshots.length !== 1 || !snapshots[0]) {\n        throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n      }\n\n      return snapshots[0].data as IRecord;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Headers,\n  Param,\n  Patch,\n  Post,\n  Query,\n  Req,\n  UploadedFile,\n  UseGuards,\n  UseInterceptors,\n} from '@nestjs/common';\nimport { FileInterceptor } from '@nestjs/platform-express';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  createRecordsRoSchema,\n  getRecordQuerySchema,\n  getRecordsRoSchema,\n  updateRecordRoSchema,\n  deleteRecordsQuerySchema,\n  getRecordHistoryQuerySchema,\n  updateRecordsRoSchema,\n  recordInsertOrderRoSchema,\n  recordGetCollaboratorsRoSchema,\n  formSubmitRoSchema,\n  optionalRecordOrderSchema,\n  insertAttachmentRoSchema,\n} from '@teable/openapi';\nimport type {\n  IAutoFillCellVo,\n  IButtonClickVo,\n  ICreateRecordsVo,\n  IRecord,\n  IRecordGetCollaboratorsVo,\n  IRecordStatusVo,\n  IRecordsVo,\n  ICreateRecordsRo,\n  IDeleteRecordsQuery,\n  IGetRecordQuery,\n  IGetRecordHistoryQuery,\n  IGetRecordsRo,\n  IRecordGetCollaboratorsRo,\n  IRecordInsertOrderRo,\n  IUpdateRecordRo,\n  IUpdateRecordsRo,\n  IFormSubmitRo,\n  IInsertAttachmentRo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../../event-emitter/events';\nimport { PerformanceCacheService } from '../../../performance-cache';\nimport { generateRecordCacheKey } from '../../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../../types/cls';\nimport { filterHasMe } from '../../../utils/filter-has-me';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';\nimport { RecordService } from '../record.service';\nimport { FieldKeyPipe } from './field-key.pipe';\nimport { RecordOpenApiV2Service } from './record-open-api-v2.service';\nimport { RecordOpenApiService } from './record-open-api.service';\nimport { TqlPipe } from './tql.pipe';\n\n@UseGuards(V2FeatureGuard)\n@UseInterceptors(V2IndicatorInterceptor)\n@Controller('api/table/:tableId/record')\n@AllowAnonymous()\nexport class RecordOpenApiController {\n  constructor(\n    private readonly recordService: RecordService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly performanceCacheService: PerformanceCacheService,\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly recordOpenApiV2Service: RecordOpenApiV2Service\n  ) {}\n\n  @Permissions('record|update')\n  @Get(':recordId/history')\n  async getRecordHistory(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Query(new ZodValidationPipe(getRecordHistoryQuerySchema)) query: IGetRecordHistoryQuery\n  ) {\n    return this.recordOpenApiService.getRecordHistory(tableId, recordId, query);\n  }\n\n  @Permissions('table_record_history|read')\n  @Get('/history')\n  async getRecordListHistory(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(getRecordHistoryQuerySchema)) query: IGetRecordHistoryQuery\n  ) {\n    return this.recordOpenApiService.getRecordHistory(tableId, undefined, query);\n  }\n\n  @Permissions('record|read')\n  @Get('collaborators')\n  async getCollaborators(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(recordGetCollaboratorsRoSchema)) query: IRecordGetCollaboratorsRo\n  ): Promise<IRecordGetCollaboratorsVo> {\n    return this.recordService.getRecordsCollaborators(tableId, query);\n  }\n\n  @UseV2Feature('getRecords')\n  @Permissions('record|read')\n  @Get()\n  async getRecords(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe, FieldKeyPipe) query: IGetRecordsRo\n  ): Promise<IRecordsVo> {\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.getRecords(tableId, query);\n    }\n\n    return await this.recordService.getRecords(tableId, query, true);\n  }\n\n  @Permissions('record|read')\n  @Get(':recordId')\n  async getRecord(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Query(new ZodValidationPipe(getRecordQuerySchema)) query: IGetRecordQuery\n  ): Promise<IRecord> {\n    return await this.recordService.getRecord(tableId, recordId, query, true, true);\n  }\n\n  @UseV2Feature('updateRecord')\n  @Permissions('record|update')\n  @Patch(':recordId')\n  async updateRecord(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Body(new ZodValidationPipe(updateRecordRoSchema)) updateRecordRo: IUpdateRecordRo,\n    @Headers('x-window-id') windowId?: string,\n    @Headers('x-ai-internal') isAiInternal?: string\n  ): Promise<IRecord> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.updateRecord(\n        tableId,\n        recordId,\n        updateRecordRo,\n        windowId,\n        isAiInternal\n      );\n    }\n\n    return await this.recordOpenApiService.updateRecord(\n      tableId,\n      recordId,\n      updateRecordRo,\n      windowId,\n      isAiInternal\n    );\n  }\n\n  @Permissions('record|update')\n  @Post(':recordId/:fieldId/uploadAttachment')\n  @UseInterceptors(FileInterceptor('file'))\n  async uploadAttachment(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('fieldId') fieldId: string,\n    @UploadedFile() file?: Express.Multer.File,\n    @Body('fileUrl') fileUrl?: string\n  ): Promise<IRecord> {\n    return await this.recordOpenApiService.uploadAttachment(\n      tableId,\n      recordId,\n      fieldId,\n      file,\n      fileUrl\n    );\n  }\n\n  @Permissions('record|update')\n  @Post(':recordId/:fieldId/insertAttachment')\n  async insertAttachment(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('fieldId') fieldId: string,\n    @Body(new ZodValidationPipe(insertAttachmentRoSchema)) body: IInsertAttachmentRo\n  ): Promise<IRecord> {\n    return await this.recordOpenApiService.insertAttachment(\n      tableId,\n      recordId,\n      fieldId,\n      body.attachments,\n      body.anchorId\n    );\n  }\n\n  @Permissions('record|update')\n  @UseV2Feature('updateRecords')\n  @Patch()\n  async updateRecords(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(updateRecordsRoSchema)) updateRecordsRo: IUpdateRecordsRo,\n    @Headers('x-window-id') windowId?: string,\n    @Headers('x-ai-internal') isAiInternal?: string\n  ): Promise<IRecord[]> {\n    if (this.cls.get('useV2')) {\n      return await this.recordOpenApiV2Service.updateRecords(\n        tableId,\n        updateRecordsRo,\n        windowId,\n        isAiInternal\n      );\n    }\n\n    return (\n      await this.recordOpenApiService.updateRecords(\n        tableId,\n        updateRecordsRo,\n        windowId,\n        isAiInternal\n      )\n    ).records;\n  }\n\n  @UseV2Feature('createRecord')\n  @Permissions('record|create')\n  @Post()\n  @EmitControllerEvent(Events.OPERATION_RECORDS_CREATE)\n  async createRecords(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(createRecordsRoSchema)) createRecordsRo: ICreateRecordsRo,\n    @Headers('x-ai-internal') isAiInternal?: string\n  ): Promise<ICreateRecordsVo> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return await this.recordOpenApiV2Service.createRecords(\n        tableId,\n        createRecordsRo,\n        isAiInternal\n      );\n    }\n\n    return await this.recordOpenApiService.multipleCreateRecords(\n      tableId,\n      createRecordsRo,\n      undefined,\n      isAiInternal\n    );\n  }\n\n  @UseV2Feature('formSubmit')\n  @Permissions('record|create')\n  @Post('form-submit')\n  async formSubmit(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(formSubmitRoSchema)) formSubmitRo: IFormSubmitRo\n  ): Promise<IRecord> {\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.formSubmit(tableId, formSubmitRo);\n    }\n\n    return await this.recordOpenApiService.formSubmit(tableId, formSubmitRo);\n  }\n\n  @UseV2Feature('duplicateRecord')\n  @Permissions('record|create', 'record|read')\n  @Post(':recordId/duplicate')\n  @EmitControllerEvent(Events.OPERATION_RECORDS_CREATE)\n  async duplicateRecord(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Body(new ZodValidationPipe(optionalRecordOrderSchema)) order?: IRecordInsertOrderRo\n  ) {\n    if (this.cls.get('useV2')) {\n      return await this.recordOpenApiV2Service.duplicateRecord(tableId, recordId, order);\n    }\n    return await this.recordOpenApiService.duplicateRecord(tableId, recordId, order);\n  }\n\n  @UseV2Feature('deleteRecord')\n  @Permissions('record|delete')\n  @Delete(':recordId')\n  async deleteRecord(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<IRecord> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      const result = await this.recordOpenApiV2Service.deleteRecords(tableId, [recordId], windowId);\n      return result.records[0];\n    }\n\n    return await this.recordOpenApiService.deleteRecord(tableId, recordId, windowId);\n  }\n\n  @UseV2Feature('deleteRecord')\n  @Permissions('record|delete')\n  @Delete()\n  async deleteRecords(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(deleteRecordsQuerySchema)) query: IDeleteRecordsQuery,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<IRecordsVo> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.deleteRecords(tableId, query.recordIds, windowId);\n    }\n\n    return await this.recordOpenApiService.deleteRecords(tableId, query.recordIds, windowId);\n  }\n\n  @Permissions('record|read')\n  @Get('/socket/snapshot-bulk')\n  async getSnapshotBulk(\n    @Param('tableId') tableId: string,\n    @Query('ids') ids: string[],\n    @Query('projection') projection?: { [fieldNameOrId: string]: boolean }\n  ) {\n    return this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      ids,\n      projection,\n      undefined,\n      undefined,\n      true\n    );\n  }\n\n  @Permissions('record|read')\n  @Post('/socket/doc-ids')\n  async getDocIds(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo\n  ) {\n    return this.getDocIdsWithCache(tableId, query);\n  }\n\n  private async getDocIdsWithCache(tableId: string, query: IGetRecordsRo) {\n    const table = await this.prismaService.tableMeta.findUniqueOrThrow({\n      where: {\n        id: tableId,\n      },\n      select: {\n        lastModifiedTime: true,\n      },\n    });\n    const viewId = query.viewId;\n    let viewFilter: string | null = null;\n    if (viewId) {\n      const view = await this.prismaService.view.findUniqueOrThrow({\n        where: {\n          id: viewId,\n        },\n        select: {\n          filter: true,\n        },\n      });\n      viewFilter = view.filter;\n    }\n    const cacheQuery =\n      filterHasMe(query.filter) || filterHasMe(viewFilter)\n        ? { ...query, currentUserId: this.cls.get('user.id') }\n        : query;\n\n    const cacheKey = generateRecordCacheKey(\n      'doc_ids',\n      tableId,\n      table.lastModifiedTime?.getTime().toString() ?? '0',\n      cacheQuery\n    );\n    return this.performanceCacheService.wrap(\n      cacheKey,\n      () => {\n        return this.recordService.getDocIdsByQuery(tableId, cacheQuery, true);\n      },\n      {\n        ttl: 60 * 60, // 1 hour\n      }\n    );\n  }\n\n  @Permissions('table|read')\n  @Get(':recordId/status')\n  async getRecordStatus(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo\n  ): Promise<IRecordStatusVo> {\n    return await this.recordService.getRecordStatus(tableId, recordId, query);\n  }\n\n  @Permissions('record|update')\n  @Post(':recordId/:fieldId/auto-fill')\n  async autoFillCell(\n    @Param('tableId') _tableId: string,\n    @Param('recordId') _recordId: string,\n    @Param('fieldId') _fieldId: string\n  ): Promise<IAutoFillCellVo> {\n    return { taskId: '' };\n  }\n\n  @Permissions('record|read')\n  @Post(':recordId/:fieldId/button-click')\n  async buttonClick(\n    @Req() req: Express.Request,\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IButtonClickVo> {\n    const result = await this.recordOpenApiService.buttonClick(tableId, recordId, fieldId);\n    return { ...result, runId: '' };\n  }\n\n  @Permissions('record|update')\n  @Post(':recordId/:fieldId/button-reset')\n  async buttonReset(\n    @Param('tableId') tableId: string,\n    @Param('recordId') recordId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IRecord> {\n    return await this.recordOpenApiService.resetButton(tableId, recordId, fieldId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api.module.ts",
    "content": "import { Module, forwardRef } from '@nestjs/common';\nimport { AggregationModule } from '../../aggregation/aggregation.module';\nimport { AttachmentsStorageModule } from '../../attachments/attachments-storage.module';\nimport { AttachmentsModule } from '../../attachments/attachments.module';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { CanaryModule } from '../../canary/canary.module';\nimport { CollaboratorModule } from '../../collaborator/collaborator.module';\nimport { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';\nimport { FieldModule } from '../../field/field.module';\nimport { SelectionModule } from '../../selection/selection.module';\nimport { TableModule } from '../../table/table.module';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { V2Module } from '../../v2/v2.module';\nimport { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';\nimport { ViewModule } from '../../view/view.module';\nimport { RecordModifyModule } from '../record-modify/record-modify.module';\nimport { RecordModule } from '../record.module';\nimport { RecordOpenApiV2Service } from './record-open-api-v2.service';\nimport { RecordOpenApiController } from './record-open-api.controller';\nimport { RecordOpenApiService } from './record-open-api.service';\n\n@Module({\n  imports: [\n    RecordModule,\n    RecordModifyModule,\n    FieldCalculateModule,\n    FieldModule,\n    CalculationModule,\n    AggregationModule,\n    AttachmentsStorageModule,\n    AttachmentsModule,\n    CollaboratorModule,\n    ViewModule,\n    ViewOpenApiModule,\n    TableModule,\n    TableDomainQueryModule,\n    V2Module,\n    CanaryModule,\n    forwardRef(() => SelectionModule),\n  ],\n  controllers: [RecordOpenApiController],\n  providers: [RecordOpenApiService, RecordOpenApiV2Service],\n  exports: [RecordOpenApiService, RecordOpenApiV2Service],\n})\nexport class RecordOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { RecordOpenApiModule } from './record-open-api.module';\nimport { RecordOpenApiService } from './record-open-api.service';\n\ndescribe('RecordOpenApiService', () => {\n  let service: RecordOpenApiService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, RecordOpenApiModule],\n    }).compile();\n\n    service = module.get<RecordOpenApiService>(RecordOpenApiService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport { Injectable } from '@nestjs/common';\nimport type {\n  IAttachmentCellValue,\n  IAttachmentItem,\n  IButtonFieldCellValue,\n  IButtonFieldOptions,\n  IMakeOptional,\n} from '@teable/core';\nimport { FieldKeyType, FieldType, HttpErrorCode, ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  CreateRecordAction,\n  ICreateRecordsRo,\n  IUpdateRecordsRo,\n  UpdateRecordAction,\n} from '@teable/openapi';\nimport type {\n  IRecordHistoryItemVo,\n  ICreateRecordsVo,\n  IFormSubmitRo,\n  IGetRecordHistoryQuery,\n  IRecord,\n  IRecordHistoryVo,\n  IRecordInsertOrderRo,\n  IUpdateRecordRo,\n} from '@teable/openapi';\nimport { isEmpty, keyBy, pick } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { retryOnDeadlock } from '../../../utils/retry-decorator';\nimport { AttachmentsService } from '../../attachments/attachments.service';\nimport { getPublicFullStorageUrl } from '../../attachments/plugins/utils';\nimport { FieldService } from '../../field/field.service';\nimport { createFieldInstanceByRaw } from '../../field/model/factory';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { RecordModifyService } from '../record-modify/record-modify.service';\nimport { RecordModifySharedService } from '../record-modify/record-modify.shared.service';\nimport type { IRecordInnerRo } from '../record.service';\nimport { RecordService } from '../record.service';\nimport type { IUpdateRecordsInternalRo } from '../type';\n\n@Injectable()\nexport class RecordOpenApiService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly attachmentsService: AttachmentsService,\n    private readonly recordModifyService: RecordModifyService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly recordModifySharedService: RecordModifySharedService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly fieldService: FieldService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  @retryOnDeadlock()\n  async multipleCreateRecords(\n    tableId: string,\n    createRecordsRo: ICreateRecordsRo,\n    ignoreMissingFields: boolean = false,\n    isAiInternal?: string\n  ): Promise<ICreateRecordsVo> {\n    const res = await this.prismaService.$tx(\n      async () =>\n        this.recordModifyService.multipleCreateRecords(\n          tableId,\n          createRecordsRo,\n          ignoreMissingFields\n        ),\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    const appId = this.cls.get('appId');\n    if (appId) {\n      this.cls.set('skipRecordAuditLog', true);\n      await this.recordService.emitRecordAuditLogEvent(\n        CreateRecordAction.AppRecordCreate,\n        tableId,\n        createRecordsRo.records?.length ?? 0,\n        appId\n      );\n    } else if (isAiInternal) {\n      this.cls.set('skipRecordAuditLog', true);\n      this.cls.set('user.id', 'aiRobot');\n      await this.recordService.emitRecordAuditLogEvent(\n        CreateRecordAction.AiRecordCreate,\n        tableId,\n        createRecordsRo.records?.length ?? 0\n      );\n    }\n\n    return res;\n  }\n\n  /**\n   * create records without any ops, only typecast and sql\n   * @param tableId\n   * @param createRecordsRo\n   */\n  async createRecordsOnlySql(tableId: string, createRecordsRo: ICreateRecordsRo): Promise<void> {\n    await this.prismaService.$tx(async () => {\n      return await this.recordModifyService.createRecordsOnlySql(tableId, createRecordsRo);\n    });\n  }\n\n  async createRecords(\n    tableId: string,\n    createRecordsRo: ICreateRecordsRo & { records: IMakeOptional<IRecordInnerRo, 'id'>[] },\n    ignoreMissingFields: boolean = false\n  ): Promise<ICreateRecordsVo> {\n    return await this.prismaService.$tx(\n      async () =>\n        this.recordModifyService.multipleCreateRecords(\n          tableId,\n          createRecordsRo,\n          ignoreMissingFields\n        ),\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n  }\n\n  @retryOnDeadlock()\n  async updateRecords(\n    tableId: string,\n    updateRecordsRo: IUpdateRecordsRo,\n    windowId?: string,\n    isAiInternal?: string\n  ) {\n    const res = await this.recordModifyService.updateRecords(\n      tableId,\n      updateRecordsRo as IUpdateRecordsInternalRo,\n      windowId\n    );\n\n    const appId = this.cls.get('appId');\n    if (appId) {\n      this.cls.set('skipRecordAuditLog', true);\n      await this.recordService.emitRecordAuditLogEvent(\n        UpdateRecordAction.AppRecordUpdate,\n        tableId,\n        updateRecordsRo.records?.length ?? 0,\n        appId\n      );\n    } else if (isAiInternal) {\n      this.cls.set('skipRecordAuditLog', true);\n      this.cls.set('user.id', 'aiRobot');\n      await this.recordService.emitRecordAuditLogEvent(\n        UpdateRecordAction.AiRecordUpdate,\n        tableId,\n        updateRecordsRo.records?.length ?? 0\n      );\n    }\n\n    return res;\n  }\n\n  async simpleUpdateRecords(tableId: string, updateRecordsRo: IUpdateRecordsRo) {\n    return await this.recordModifyService.simpleUpdateRecords(\n      tableId,\n      updateRecordsRo as IUpdateRecordsInternalRo\n    );\n  }\n\n  async updateRecord(\n    tableId: string,\n    recordId: string,\n    updateRecordRo: IUpdateRecordRo,\n    windowId?: string,\n    isAiInternal?: string\n  ): Promise<IRecord> {\n    await this.updateRecords(\n      tableId,\n      {\n        ...updateRecordRo,\n        records: [{ id: recordId, fields: updateRecordRo.record.fields }],\n      },\n      windowId,\n      isAiInternal\n    );\n\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      [recordId],\n      undefined,\n      updateRecordRo.fieldKeyType || FieldKeyType.Name,\n      undefined,\n      true\n    );\n\n    if (snapshots.length !== 1) {\n      throw new CustomHttpException('update record failed', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.record.updateFailed',\n        },\n      });\n    }\n\n    return snapshots[0].data;\n  }\n\n  async deleteRecord(tableId: string, recordId: string, windowId?: string) {\n    return this.recordModifyService.deleteRecord(tableId, recordId, windowId);\n  }\n\n  async deleteRecords(tableId: string, recordIds: string[], windowId?: string) {\n    return this.recordModifyService.deleteRecords(tableId, recordIds, windowId);\n  }\n\n  async getRecordHistory(\n    tableId: string,\n    recordId: string | undefined,\n    query: IGetRecordHistoryQuery,\n    projectionIds?: string[]\n  ): Promise<IRecordHistoryVo> {\n    const { cursor, startDate, endDate } = query;\n    const limit = 20;\n\n    const dateFilter: { [key: string]: Date } = {};\n    if (startDate) {\n      dateFilter['gte'] = new Date(startDate);\n    }\n    if (endDate) {\n      dateFilter['lte'] = new Date(endDate);\n    }\n\n    const list = await this.prismaService.recordHistory.findMany({\n      where: {\n        tableId,\n        ...(recordId ? { recordId } : {}),\n        ...(Object.keys(dateFilter).length > 0 ? { createdTime: dateFilter } : {}),\n        ...(projectionIds?.length ? { fieldId: { in: projectionIds } } : {}),\n      },\n      select: {\n        id: true,\n        recordId: true,\n        fieldId: true,\n        before: true,\n        after: true,\n        createdTime: true,\n        createdBy: true,\n      },\n      take: limit + 1,\n      cursor: cursor ? { id: cursor } : undefined,\n      orderBy: {\n        createdTime: 'desc',\n      },\n    });\n\n    let nextCursor: typeof cursor | undefined = undefined;\n\n    if (list.length > limit) {\n      const nextItem = list.pop();\n      nextCursor = nextItem?.id;\n    }\n\n    const createdBySet: Set<string> = new Set();\n    const historyList: IRecordHistoryItemVo[] = [];\n\n    for (const item of list) {\n      const { id, recordId, fieldId, before, after, createdTime, createdBy } = item;\n\n      createdBySet.add(createdBy);\n      const beforeObj = JSON.parse(before as string);\n      const afterObj = JSON.parse(after as string);\n      const { meta: beforeMeta, data: beforeData } = beforeObj as IRecordHistoryItemVo['before'];\n      const { meta: afterMeta, data: afterData } = afterObj as IRecordHistoryItemVo['after'];\n      const { type: beforeType } = beforeMeta;\n      const { type: afterType } = afterMeta;\n\n      if (beforeType === FieldType.Attachment) {\n        beforeObj.data = await this.recordService.getAttachmentPresignedCellValue(\n          beforeData as IAttachmentCellValue\n        );\n      }\n\n      if (afterType === FieldType.Attachment) {\n        afterObj.data = await this.recordService.getAttachmentPresignedCellValue(\n          afterData as IAttachmentCellValue\n        );\n      }\n\n      historyList.push({\n        id,\n        tableId,\n        recordId,\n        fieldId,\n        before: beforeObj,\n        after: afterObj,\n        createdTime: createdTime.toISOString(),\n        createdBy,\n      });\n    }\n\n    const userList = await this.prismaService.user.findMany({\n      where: {\n        id: {\n          in: Array.from(createdBySet),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        avatar: true,\n      },\n    });\n\n    const handledUserList = userList.map((user) => {\n      const { avatar } = user;\n      return {\n        ...user,\n        avatar: avatar && getPublicFullStorageUrl(avatar),\n      };\n    });\n\n    return {\n      historyList,\n      userMap: keyBy(handledUserList, 'id'),\n      nextCursor,\n    };\n  }\n\n  private async getValidateAttachmentRecord(tableId: string, recordId: string, fieldId: string) {\n    const field = await this.prismaService\n      .txClient()\n      .field.findFirstOrThrow({\n        where: {\n          id: fieldId,\n          deletedTime: null,\n        },\n        select: {\n          id: true,\n          type: true,\n          isComputed: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      });\n\n    if (field.type !== FieldType.Attachment) {\n      throw new CustomHttpException('Field is not an attachment', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.field.notAttachment',\n        },\n      });\n    }\n\n    if (field.isComputed) {\n      throw new CustomHttpException('Field is computed', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.field.isComputed',\n        },\n      });\n    }\n\n    const recordData = await this.recordService.getRecordsById(tableId, [recordId]);\n    const record = recordData.records[0];\n    if (!record) {\n      throw new CustomHttpException(`Record ${recordId} not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.record.notFound',\n        },\n      });\n    }\n    return record;\n  }\n\n  async uploadAttachment(\n    tableId: string,\n    recordId: string,\n    fieldId: string,\n    file?: Express.Multer.File,\n    fileUrl?: string\n  ) {\n    if (!file && !fileUrl) {\n      throw new CustomHttpException('No file or URL provided', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.record.noFileOrUrlProvided',\n        },\n      });\n    }\n\n    const record = await this.getValidateAttachmentRecord(tableId, recordId, fieldId);\n\n    const attachmentItem = file\n      ? await this.attachmentsService.uploadFile(file)\n      : await this.attachmentsService.uploadFromUrl(fileUrl as string);\n\n    // Update the cell value\n    const updateRecordRo: IUpdateRecordRo = {\n      fieldKeyType: FieldKeyType.Id,\n      record: {\n        fields: {\n          [fieldId]: ((record.fields[fieldId] || []) as IAttachmentItem[]).concat(attachmentItem),\n        },\n      },\n    };\n\n    return await this.updateRecord(tableId, recordId, updateRecordRo);\n  }\n\n  async insertAttachment(\n    tableId: string,\n    recordId: string,\n    fieldId: string,\n    attachments: IAttachmentItem[],\n    anchorId?: string\n  ) {\n    if (!attachments.length) {\n      throw new CustomHttpException('No attachments provided', HttpErrorCode.VALIDATION_ERROR);\n    }\n\n    const record = await this.getValidateAttachmentRecord(tableId, recordId, fieldId);\n\n    // Fetch full attachment data for each attachment item from database\n\n    const current = (record.fields[fieldId] || []) as IAttachmentItem[];\n    const anchorIndex = anchorId ? current.findIndex((item) => item.id === anchorId) : -1;\n    const next =\n      anchorIndex >= 0\n        ? [...current.slice(0, anchorIndex + 1), ...attachments, ...current.slice(anchorIndex + 1)]\n        : current.concat(attachments);\n\n    const updateRecordRo: IUpdateRecordRo = {\n      fieldKeyType: FieldKeyType.Id,\n      record: {\n        fields: {\n          [fieldId]: next,\n        },\n      },\n    };\n\n    return await this.updateRecord(tableId, recordId, updateRecordRo);\n  }\n\n  async duplicateRecord(\n    tableId: string,\n    recordId: string,\n    order?: IRecordInsertOrderRo,\n    projection?: string[]\n  ) {\n    const query = { fieldKeyType: FieldKeyType.Id, projection };\n    const result = await this.recordService.getRecord(tableId, recordId, query);\n    const records = { fields: result.fields };\n    const createRecordsRo = {\n      fieldKeyType: FieldKeyType.Id,\n      order,\n      records: [records],\n    };\n    return await this.prismaService\n      .$tx(async () => this.createRecords(tableId, createRecordsRo))\n      .then((res) => {\n        return res.records[0];\n      });\n  }\n\n  async buttonClick(tableId: string, recordId: string, fieldId: string) {\n    const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: {\n        id: fieldId,\n        type: FieldType.Button,\n        deletedTime: null,\n      },\n    });\n\n    const fieldInstance = createFieldInstanceByRaw(fieldRaw);\n    const options = fieldInstance.options as IButtonFieldOptions;\n    const isActive = options.workflow && options.workflow.id && options.workflow.isActive;\n    if (!isActive) {\n      throw new CustomHttpException(\n        `Button field's workflow ${options.workflow?.id} is not active`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.workflow.notActive',\n          },\n        }\n      );\n    }\n\n    const maxCount = options.maxCount || 0;\n    const record = await this.recordService.getRecord(tableId, recordId, {\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const fieldValue = record.fields[fieldId] as IButtonFieldCellValue;\n    const count = fieldValue?.count || 0;\n    if (maxCount > 0 && count >= maxCount) {\n      throw new CustomHttpException(\n        `Button click count ${count} reached max count ${maxCount}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.button.clickCountReachedMaxCount',\n          },\n        }\n      );\n    }\n    const updatedRecord: IRecord = await this.updateRecord(tableId, recordId, {\n      record: {\n        fields: { [fieldId]: { count: count + 1 } },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n    updatedRecord.fields = pick(updatedRecord.fields, [fieldId]);\n\n    return {\n      tableId,\n      fieldId,\n      record: updatedRecord,\n    };\n  }\n\n  async resetButton(tableId: string, recordId: string, fieldId: string) {\n    const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({\n      where: {\n        id: fieldId,\n        type: FieldType.Button,\n        deletedTime: null,\n      },\n    });\n\n    const fieldInstance = createFieldInstanceByRaw(fieldRaw);\n    const fieldOptions = fieldInstance.options as IButtonFieldOptions;\n    if (!fieldOptions.resetCount) {\n      throw new CustomHttpException(\n        'Button field does not support reset',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.button.notSupportReset',\n          },\n        }\n      );\n    }\n\n    return await this.updateRecord(tableId, recordId, {\n      fieldKeyType: FieldKeyType.Id,\n      record: {\n        fields: {\n          [fieldId]: null,\n        },\n      },\n    });\n  }\n\n  public async validateFieldsAndTypecast<\n    T extends {\n      fields: Record<string, unknown>;\n    },\n  >(\n    tableId: string,\n    records: T[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Name,\n    typecast: boolean = false,\n    ignoreMissingFields: boolean = false\n  ) {\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    return this.recordModifySharedService.validateFieldsAndTypecast(\n      table,\n      records,\n      fieldKeyType,\n      typecast,\n      ignoreMissingFields\n    );\n  }\n\n  async formSubmit(\n    tableId: string,\n    formSubmitRo: IFormSubmitRo,\n    options?: { includeHiddenField?: boolean }\n  ): Promise<IRecord> {\n    const { viewId, fields, typecast } = formSubmitRo;\n    const { includeHiddenField = false } = options ?? {};\n\n    // 1. Validate view exists and is Form type\n    await this.prismaService.view\n      .findFirstOrThrow({\n        where: { id: viewId, tableId, deletedTime: null, type: ViewType.Form },\n      })\n      .catch(() => {\n        throw new CustomHttpException('View is not a form', HttpErrorCode.RESTRICTED_RESOURCE, {\n          localization: {\n            i18nKey: 'httpErrors.share.viewTypeNotAllowed',\n          },\n        });\n      });\n\n    // 2. Check field visibility - only allow submission of visible fields\n    const visibleFields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId,\n      filterHidden: !includeHiddenField,\n    });\n    const visibleFieldIdSet = new Set(visibleFields.map(({ id }) => id));\n\n    if (\n      (!visibleFields.length && !isEmpty(fields)) ||\n      Object.keys(fields).some((fieldId) => !visibleFieldIdSet.has(fieldId))\n    ) {\n      throw new CustomHttpException(\n        'The form contains hidden fields, submission not allowed.',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.hiddenFieldsSubmissionNotAllowed',\n          },\n        }\n      );\n    }\n\n    // 3. Create record with form entry context\n    const { records } = await this.prismaService.$tx(async () => {\n      this.cls.set('entry', { type: 'form', id: viewId });\n      this.cls.set('skipRecordAuditLog', true);\n      return this.createRecords(tableId, {\n        records: [{ fields }],\n        fieldKeyType: FieldKeyType.Id,\n        typecast,\n      });\n    });\n\n    // 4. Emit form audit log\n    await this.emitFormAuditLog(tableId, records.length);\n\n    // 5. Validate record creation\n    if (records.length === 0) {\n      throw new CustomHttpException(\n        'The number of successful submit records is 0',\n        HttpErrorCode.INTERNAL_SERVER_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.submitRecordsError',\n          },\n        }\n      );\n    }\n\n    return records[0];\n  }\n\n  private async emitFormAuditLog(tableId: string, length: number) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n\n    await this.cls.run(async () => {\n      this.cls.set('user.id', userId);\n      this.cls.set('origin', origin!);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.FormSubmit,\n        resourceId: tableId,\n        recordCount: length,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/record-undo-redo-service.ts",
    "content": ""
  },
  {
    "path": "apps/nestjs-backend/src/features/record/open-api/tql.pipe.ts",
    "content": "import type { ArgumentMetadata, PipeTransform } from '@nestjs/common';\nimport { BadRequestException, Injectable } from '@nestjs/common';\nimport type { IFilter } from '@teable/core';\nimport { parseTQL } from '@teable/core';\n\n@Injectable()\nexport class TqlPipe<T extends { filterByTql?: string; filter?: IFilter }>\n  implements PipeTransform\n{\n  transform(value: T, _metadata: ArgumentMetadata) {\n    this.transformFilterTql(value);\n    return value;\n  }\n\n  private transformFilterTql(value: T): void {\n    if (value.filterByTql) {\n      try {\n        value.filter = parseTQL(value.filterByTql);\n      } catch (e) {\n        throw new BadRequestException(`TQL parse error, ${(e as Error).message}`);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/field-cte-visitor.ts",
    "content": "/* eslint-disable sonarjs/no-collapsible-if */\n/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable sonarjs/no-duplicated-branches */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-empty-function */\nimport { Logger } from '@nestjs/common';\nimport {\n  DriverClient,\n  FieldType,\n  Relationship,\n  type IFilter,\n  type IFilterItem,\n  type IFieldVisitor,\n  type AttachmentFieldCore,\n  type AutoNumberFieldCore,\n  type CheckboxFieldCore,\n  type CreatedByFieldCore,\n  type CreatedTimeFieldCore,\n  type DateFieldCore,\n  type FormulaFieldCore,\n  type LastModifiedByFieldCore,\n  type LastModifiedTimeFieldCore,\n  type LinkFieldCore,\n  type LongTextFieldCore,\n  type MultipleSelectFieldCore,\n  type NumberFieldCore,\n  type RatingFieldCore,\n  type RollupFieldCore,\n  type ConditionalRollupFieldCore,\n  type IConditionalLookupOptions,\n  type SingleLineTextFieldCore,\n  type SingleSelectFieldCore,\n  type UserFieldCore,\n  type ButtonFieldCore,\n  type Tables,\n  type TableDomain,\n  type ILinkFieldOptions,\n  type FieldCore,\n  type IRollupFieldOptions,\n  DbFieldType,\n  CellValueType,\n  extractFieldIdsFromFilter,\n  SortFunc,\n  isFieldReferenceValue,\n  isLinkLookupOptions,\n  normalizeConditionalLimit,\n  contains as FilterOperatorContains,\n  doesNotContain as FilterOperatorDoesNotContain,\n  hasAllOf as FilterOperatorHasAllOf,\n  hasAnyOf as FilterOperatorHasAnyOf,\n  hasNoneOf as FilterOperatorHasNoneOf,\n  is as FilterOperatorIs,\n  isAfter as FilterOperatorIsAfter,\n  isAnyOf as FilterOperatorIsAnyOf,\n  isBefore as FilterOperatorIsBefore,\n  isExactly as FilterOperatorIsExactly,\n  isGreater as FilterOperatorIsGreater,\n  isGreaterEqual as FilterOperatorIsGreaterEqual,\n  isLess as FilterOperatorIsLess,\n  isLessEqual as FilterOperatorIsLessEqual,\n  isNoneOf as FilterOperatorIsNoneOf,\n  isNotEmpty as FilterOperatorIsNotEmpty,\n  isNotExactly as FilterOperatorIsNotExactly,\n  isEmpty as FilterOperatorIsEmpty,\n  isOnOrAfter as FilterOperatorIsOnOrAfter,\n  isOnOrBefore as FilterOperatorIsOnOrBefore,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport { match } from 'ts-pattern';\nimport type { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { ID_FIELD_NAME } from '../../field/constant';\nimport { FieldFormattingVisitor } from './field-formatting-visitor';\nimport { FieldSelectVisitor } from './field-select-visitor';\nimport type { IFieldSelectName } from './field-select.type';\nimport type {\n  IMutableQueryBuilderState,\n  IReadonlyQueryBuilderState,\n} from './record-query-builder.interface';\nimport { RecordQueryBuilderManager, ScopedSelectionState } from './record-query-builder.manager';\nimport {\n  getLinkUsesJunctionTable,\n  getTableAliasFromTable,\n  getOrderedFieldsByProjection,\n  isDateLikeField,\n} from './record-query-builder.util';\nimport type { IRecordQueryDialectProvider } from './record-query-dialect.interface';\n\ntype ICteResult = void;\n\nconst JUNCTION_ALIAS = 'j';\n\nconst SUPPORTED_EQUALITY_RESIDUAL_OPERATORS = new Set<string>([\n  FilterOperatorIs.value,\n  FilterOperatorContains.value,\n  FilterOperatorDoesNotContain.value,\n  FilterOperatorIsGreater.value,\n  FilterOperatorIsGreaterEqual.value,\n  FilterOperatorIsLess.value,\n  FilterOperatorIsLessEqual.value,\n  FilterOperatorIsEmpty.value,\n  FilterOperatorIsNotEmpty.value,\n  FilterOperatorIsAnyOf.value,\n  FilterOperatorIsNoneOf.value,\n  FilterOperatorHasAnyOf.value,\n  FilterOperatorHasAllOf.value,\n  FilterOperatorHasNoneOf.value,\n  FilterOperatorIsExactly.value,\n  FilterOperatorIsNotExactly.value,\n  FilterOperatorIsBefore.value,\n  FilterOperatorIsAfter.value,\n  FilterOperatorIsOnOrBefore.value,\n  FilterOperatorIsOnOrAfter.value,\n]);\n\nconst JSON_AGG_FUNCTIONS = new Set(['array_compact', 'array_unique']);\n\nfunction parseRollupFunctionName(expression: string): string {\n  const match = expression.match(/^(\\w+)\\(\\{values\\}\\)$/);\n  if (!match) {\n    throw new Error(`Invalid rollup expression: ${expression}`);\n  }\n  return match[1].toLowerCase();\n}\n\nfunction unwrapJsonAggregateForScalar(\n  driver: DriverClient,\n  expression: string,\n  field: FieldCore,\n  isJsonAggregate: boolean\n): string {\n  if (\n    !isJsonAggregate ||\n    field.isMultipleCellValue ||\n    field.dbFieldType === DbFieldType.Json ||\n    driver !== DriverClient.Pg\n  ) {\n    return expression;\n  }\n  return `(${expression}) ->> 0`;\n}\n\nclass FieldCteSelectionVisitor implements IFieldVisitor<IFieldSelectName> {\n  constructor(\n    private readonly qb: Knex.QueryBuilder,\n    private readonly dbProvider: IDbProvider,\n    private readonly dialect: IRecordQueryDialectProvider,\n    private readonly table: TableDomain,\n    private readonly foreignTable: TableDomain,\n    private readonly state: IReadonlyQueryBuilderState,\n    private readonly joinedCtes?: Set<string>, // Track which CTEs are already JOINed in current scope\n    private readonly isSingleValueRelationshipContext: boolean = false, // In ManyOne/OneOne CTEs, avoid aggregates\n    private readonly foreignAliasOverride?: string,\n    private readonly currentLinkFieldId?: string,\n    private readonly blockedLinkFieldIds?: ReadonlySet<string>,\n    private readonly readyLinkFieldIds?: ReadonlySet<string>\n  ) {}\n  private get fieldCteMap() {\n    return this.state.getFieldCteMap();\n  }\n  private canReuseNestedCte(fieldId?: string): fieldId is string {\n    return (\n      !!fieldId &&\n      this.fieldCteMap.has(fieldId) &&\n      fieldId !== this.currentLinkFieldId &&\n      !this.blockedLinkFieldIds?.has(fieldId) &&\n      (!!this.readyLinkFieldIds?.has(fieldId) || this.readyLinkFieldIds === undefined)\n    );\n  }\n\n  private mergeBlockedLinkIds(\n    extras?: Iterable<string | undefined>\n  ): ReadonlySet<string> | undefined {\n    if (!extras) {\n      return this.blockedLinkFieldIds;\n    }\n    let result: Set<string> | undefined;\n    const base = this.blockedLinkFieldIds;\n    for (const id of extras) {\n      if (!id) continue;\n      if (base?.has(id)) continue;\n      if (!result) {\n        result = new Set(base ?? []);\n      }\n      result.add(id);\n    }\n    return result ?? base;\n  }\n\n  private getReadyLinkFieldIdsSnapshot(): ReadonlySet<string> | undefined {\n    return this.readyLinkFieldIds ? new Set(this.readyLinkFieldIds) : undefined;\n  }\n\n  private createFieldSelectVisitor(\n    table: TableDomain,\n    alias?: string,\n    rawProjection = true,\n    preferRawFieldReferences = true,\n    extraBlockedLinkIds?: Iterable<string | undefined>\n  ): FieldSelectVisitor {\n    // Only allow link CTE references that are actually joined in this scope; otherwise\n    // the selector may emit a CTE reference that isn't present in FROM/JOIN, leading\n    // to \"missing FROM-clause\" errors in nested rollup/lookups during computed updates.\n    const scopedReadyLinkFieldIds = this.joinedCtes\n      ? new Set(this.joinedCtes)\n      : this.readyLinkFieldIds;\n    return new FieldSelectVisitor(\n      this.qb.client.queryBuilder(),\n      this.dbProvider,\n      table,\n      new ScopedSelectionState(this.state),\n      this.dialect,\n      alias,\n      rawProjection,\n      preferRawFieldReferences,\n      this.mergeBlockedLinkIds(extraBlockedLinkIds),\n      scopedReadyLinkFieldIds,\n      this.currentLinkFieldId\n    );\n  }\n  private getForeignAlias(): string {\n    return this.foreignAliasOverride || getTableAliasFromTable(this.foreignTable);\n  }\n  private getJsonAggregationFunction(fieldReference: string): string {\n    return this.dialect.jsonAggregateNonNull(fieldReference);\n  }\n\n  private normalizeJsonAggregateExpression(expression: string): string {\n    const trimmed = expression.trim();\n    if (!trimmed) {\n      return expression;\n    }\n    const upper = trimmed.toUpperCase();\n    if (upper === 'NULL') {\n      return 'NULL::jsonb';\n    }\n    if (upper === 'NULL::JSONB') {\n      return trimmed;\n    }\n    if (upper.startsWith('NULL::')) {\n      return `(${expression})::jsonb`;\n    }\n    return expression;\n  }\n  private buildPhysicalFieldExpression(field: FieldCore, alias: string): string {\n    if (field.hasError) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n    return `\"${alias}\".\"${field.dbFieldName}\"`;\n  }\n\n  /**\n   * Build a subquery (SELECT 1 WHERE ...) for foreign table filter using provider's filterQuery.\n   * The subquery references the current foreign alias in-scope and carries proper bindings.\n   */\n  private buildForeignFilterSubquery(filter: IFilter): string {\n    const foreignAlias = this.getForeignAlias();\n    // Build selectionMap mapping foreign field ids to alias-qualified columns\n    const selectionMap = new Map<string, string>();\n    for (const f of this.foreignTable.fields.ordered) {\n      selectionMap.set(f.id, `\"${foreignAlias}\".\"${f.dbFieldName}\"`);\n    }\n    // Build field map for filter compiler\n    const fieldMap = this.foreignTable.fieldList.reduce(\n      (map, f) => {\n        map[f.id] = f as FieldCore;\n        return map;\n      },\n      {} as Record<string, FieldCore>\n    );\n    // Build subquery with WHERE conditions\n    const sub = this.qb.client.queryBuilder().select(this.qb.client.raw('1'));\n    this.dbProvider\n      .filterQuery(sub, fieldMap, filter, undefined, { selectionMap } as unknown as {\n        selectionMap: Map<string, string>;\n      })\n      .appendQueryBuilder();\n    return `(${sub.toQuery()})`;\n  }\n\n  private unwrapSelectName(selection: IFieldSelectName | string): string {\n    return typeof selection === 'string' ? selection : selection.toQuery();\n  }\n  /**\n   * Generate rollup aggregation expression based on rollup function\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private generateRollupAggregation(\n    expression: string,\n    fieldExpression: string,\n    targetField: FieldCore,\n    orderByField?: string,\n    rowPresenceExpr?: string\n  ): string {\n    const functionName = parseRollupFunctionName(expression);\n    return this.dialect.rollupAggregate(functionName, fieldExpression, {\n      targetField,\n      orderByField,\n      rowPresenceExpr,\n    });\n  }\n\n  /**\n   * Generate rollup expression for single-value relationships (ManyOne/OneOne)\n   * Avoids using aggregate functions so GROUP BY is not required.\n   */\n  private generateSingleValueRollupAggregation(\n    rollupField: FieldCore,\n    targetField: FieldCore,\n    expression: string,\n    fieldExpression: string\n  ): string {\n    const functionName = parseRollupFunctionName(expression);\n    return this.dialect.singleValueRollupAggregate(functionName, fieldExpression, {\n      rollupField,\n      targetField,\n    });\n  }\n  private buildSingleValueRollup(\n    field: FieldCore,\n    targetField: FieldCore,\n    expression: string\n  ): string {\n    const rollupOptions = field.options as IRollupFieldOptions;\n    const rollupFilter = (field as FieldCore).getFilter?.();\n    if (rollupFilter) {\n      const sub = this.buildForeignFilterSubquery(rollupFilter);\n      const filteredExpr =\n        this.dbProvider.driver === DriverClient.Pg\n          ? `CASE WHEN EXISTS ${sub} THEN ${expression} ELSE NULL END`\n          : expression;\n      return this.generateSingleValueRollupAggregation(\n        field,\n        targetField,\n        rollupOptions.expression,\n        filteredExpr\n      );\n    }\n    return this.generateSingleValueRollupAggregation(\n      field,\n      targetField,\n      rollupOptions.expression,\n      expression\n    );\n  }\n  private buildAggregateRollup(\n    rollupField: FieldCore,\n    targetField: FieldCore,\n    expression: string\n  ): string {\n    const linkField = rollupField.getLinkField(this.table);\n    const options = linkField?.options as ILinkFieldOptions | undefined;\n    const rollupOptions = rollupField.options as IRollupFieldOptions;\n\n    let orderByField: string | undefined;\n    if (this.dbProvider.driver === DriverClient.Pg && linkField && options) {\n      const usesJunctionTable = getLinkUsesJunctionTable(linkField);\n      const hasOrderColumn = linkField.getHasOrderColumn();\n      if (usesJunctionTable) {\n        orderByField = hasOrderColumn\n          ? `${JUNCTION_ALIAS}.\"${linkField.getOrderColumnName()}\" IS NULL DESC, ${JUNCTION_ALIAS}.\"${linkField.getOrderColumnName()}\" ASC, ${JUNCTION_ALIAS}.\"__id\" ASC`\n          : `${JUNCTION_ALIAS}.\"__id\" ASC`;\n      } else if (options.relationship === Relationship.OneMany) {\n        const foreignAlias = this.getForeignAlias();\n        orderByField = hasOrderColumn\n          ? `\"${foreignAlias}\".\"${linkField.getOrderColumnName()}\" IS NULL DESC, \"${foreignAlias}\".\"${linkField.getOrderColumnName()}\" ASC, \"${foreignAlias}\".\"__id\" ASC`\n          : `\"${foreignAlias}\".\"__id\" ASC`;\n      }\n    }\n\n    const rowPresenceField = `\"${this.getForeignAlias()}\".\"__id\"`;\n\n    const rollupFunctionName = parseRollupFunctionName(rollupOptions.expression);\n    const aggregatesToJson = JSON_AGG_FUNCTIONS.has(rollupFunctionName);\n    const formattingVisitor = new FieldFormattingVisitor(expression, this.dialect);\n    const formattedExpression = targetField.accept(formattingVisitor);\n    const useFormattedForArrayFunctions =\n      (targetField.type === FieldType.Link ||\n        targetField.type === FieldType.Formula ||\n        targetField.type === FieldType.ConditionalRollup) &&\n      (rollupFunctionName === 'array_join' ||\n        rollupFunctionName === 'concatenate' ||\n        rollupFunctionName === 'array_unique' ||\n        rollupFunctionName === 'array_compact');\n    const aggregationInputExpression = useFormattedForArrayFunctions\n      ? formattedExpression\n      : expression;\n    const buildAggregate = (expr: string) => {\n      const aggregate = this.generateRollupAggregation(\n        rollupOptions.expression,\n        expr,\n        targetField,\n        orderByField,\n        rowPresenceField\n      );\n      return unwrapJsonAggregateForScalar(\n        this.dbProvider.driver,\n        aggregate,\n        rollupField,\n        aggregatesToJson\n      );\n    };\n\n    const rollupFilter = (rollupField as FieldCore).getFilter?.();\n    if (rollupFilter && this.dbProvider.driver === DriverClient.Pg) {\n      const sub = this.buildForeignFilterSubquery(rollupFilter);\n      const filteredExpr = `CASE WHEN EXISTS ${sub} THEN ${aggregationInputExpression} ELSE NULL END`;\n      return buildAggregate(filteredExpr);\n    }\n\n    return buildAggregate(aggregationInputExpression);\n  }\n  private visitLookupField(field: FieldCore): IFieldSelectName {\n    if (!field.isLookup) {\n      throw new Error('Not a lookup field');\n    }\n\n    // If this lookup field is marked as error, don't attempt to resolve.\n    // Emit a typed NULL so the expression matches the physical column.\n    if (field.hasError) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n\n    if (field.isConditionalLookup) {\n      const cteName = this.fieldCteMap.get(field.id);\n      if (!cteName) {\n        // Log warning when conditional lookup CTE is missing\n        const fieldCteMapKeys = Array.from(this.fieldCteMap.keys());\n        console.warn(\n          `[ConditionalLookup] CTE not found for field ${field.id} (${field.name}). ` +\n            `Available CTEs: [${fieldCteMapKeys.join(', ')}]. ` +\n            `Returning NULL::${field.dbFieldType}`\n        );\n        return this.dialect.typedNullFor(field.dbFieldType);\n      }\n      return `\"${cteName}\".\"conditional_lookup_${field.id}\"`;\n    }\n\n    const foreignAlias = this.getForeignAlias();\n    const targetLookupField = field.getForeignLookupField(this.foreignTable);\n\n    if (!targetLookupField) {\n      // Try to fetch via the CTE of the foreign link if present\n      const nestedLinkFieldId = getLinkFieldId(field.lookupOptions);\n      const fieldCteMap = this.state.getFieldCteMap();\n      // Guard against self-referencing the CTE being defined (would require WITH RECURSIVE)\n      if (this.canReuseNestedCte(nestedLinkFieldId) && this.joinedCtes?.has(nestedLinkFieldId)) {\n        const nestedCteName = fieldCteMap.get(nestedLinkFieldId)!;\n        // Check if this CTE is JOINed in current scope\n        const linkExpr = `\"${nestedCteName}\".\"link_value\"`;\n        return this.isSingleValueRelationshipContext\n          ? linkExpr\n          : field.isMultipleCellValue\n            ? this.getJsonAggregationFunction(linkExpr)\n            : linkExpr;\n      }\n      // If still not found or field has error, return NULL instead of throwing\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n\n    // Prefer physical column values to avoid recursive formula/lookup expansion.\n    let expression = this.buildPhysicalFieldExpression(targetLookupField, foreignAlias);\n\n    // For Postgres multi-value lookups targeting datetime-like fields, normalize the\n    // element expression to an ISO8601 UTC string so downstream JSON comparisons using\n    // lexicographical ranges (jsonpath @ >= \"...\" && @ <= \"...\") behave correctly.\n    // Do NOT alter single-value lookups to preserve native type comparisons in filters.\n    if (\n      this.dbProvider.driver === DriverClient.Pg &&\n      field.isMultipleCellValue &&\n      isDateLikeField(targetLookupField) &&\n      targetLookupField.dbFieldType === DbFieldType.DateTime\n    ) {\n      // Format: 2020-01-10T16:00:00.000Z, wrap as jsonb so downstream aggregation remains valid JSON.\n      const isoUtcExpr = `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')`;\n      expression = `to_jsonb(${isoUtcExpr})`;\n    }\n    // Build deterministic order-by for multi-value lookups using the link field configuration\n    const linkForOrderingId = getLinkFieldId(field.lookupOptions);\n    let orderByClause: string | undefined;\n    if (linkForOrderingId) {\n      try {\n        const linkForOrdering = this.table.getField(linkForOrderingId) as LinkFieldCore;\n        const usesJunctionTable = getLinkUsesJunctionTable(linkForOrdering);\n        const hasOrderColumn = linkForOrdering.getHasOrderColumn();\n        if (this.dbProvider.driver === DriverClient.Pg) {\n          if (usesJunctionTable) {\n            orderByClause = hasOrderColumn\n              ? `${JUNCTION_ALIAS}.\"${linkForOrdering.getOrderColumnName()}\" IS NULL DESC, ${JUNCTION_ALIAS}.\"${linkForOrdering.getOrderColumnName()}\" ASC, ${JUNCTION_ALIAS}.\"__id\" ASC`\n              : `${JUNCTION_ALIAS}.\"__id\" ASC`;\n          } else {\n            orderByClause = hasOrderColumn\n              ? `\"${foreignAlias}\".\"${linkForOrdering.getOrderColumnName()}\" IS NULL DESC, \"${foreignAlias}\".\"${linkForOrdering.getOrderColumnName()}\" ASC, \"${foreignAlias}\".\"__id\" ASC`\n              : `\"${foreignAlias}\".\"__id\" ASC`;\n          }\n        }\n      } catch (_) {\n        // ignore ordering if link field not found in current table context\n      }\n    }\n\n    // Field-specific filter applied here\n    const filter = field.getFilter?.();\n    if (!filter) {\n      if (!field.isMultipleCellValue || this.isSingleValueRelationshipContext) {\n        return expression;\n      }\n      if (this.dbProvider.driver === DriverClient.Pg && orderByClause) {\n        const sanitizedExpression = this.normalizeJsonAggregateExpression(expression);\n        return `json_agg(${sanitizedExpression} ORDER BY ${orderByClause}) FILTER (WHERE ${sanitizedExpression} IS NOT NULL)`;\n      }\n      // For SQLite, ensure deterministic ordering by aggregating from an ordered correlated subquery\n      if (this.dbProvider.driver === DriverClient.Sqlite) {\n        try {\n          const linkForOrderingId = getLinkFieldId(field.lookupOptions);\n          const fieldCteMap = this.state.getFieldCteMap();\n          const mainAlias = getTableAliasFromTable(this.table);\n          const foreignDb = this.foreignTable.dbTableName;\n          // Prefer order from link CTE's JSON array (preserves insertion order)\n          if (\n            linkForOrderingId &&\n            fieldCteMap.has(linkForOrderingId) &&\n            this.joinedCtes?.has(linkForOrderingId) &&\n            linkForOrderingId !== this.currentLinkFieldId\n          ) {\n            const cteName = fieldCteMap.get(linkForOrderingId)!;\n            const exprForInner = expression.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n            return `(\n              SELECT CASE WHEN COUNT(*) > 0\n                THEN json_group_array(CASE WHEN ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n                ELSE NULL END\n              FROM json_each(\n                CASE\n                  WHEN json_valid((SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\"))\n                   AND json_type((SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\")) = 'array'\n                  THEN (SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\")\n                  ELSE json('[]')\n                END\n              ) AS je\n              JOIN \"${foreignDb}\" AS f ON f.\"__id\" = json_extract(je.value, '$.id')\n              ORDER BY je.key ASC\n            )`;\n          }\n          // Fallback to FK/junction ordering using the current link field\n          const baseLink = field as LinkFieldCore;\n          const opts = baseLink.options as ILinkFieldOptions;\n          const usesJunctionTable = getLinkUsesJunctionTable(baseLink);\n          const hasOrderColumn = baseLink.getHasOrderColumn();\n          const fkHost = opts.fkHostTableName!;\n          const selfKey = opts.selfKeyName;\n          const foreignKey = opts.foreignKeyName;\n          const exprForInner = expression.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n          if (usesJunctionTable) {\n            const ordCol = hasOrderColumn ? `j.\"${baseLink.getOrderColumnName()}\"` : undefined;\n            const order = ordCol\n              ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, j.\"__id\" ASC`\n              : `j.\"__id\" ASC`;\n            return `(\n              SELECT CASE WHEN COUNT(*) > 0\n                THEN json_group_array(CASE WHEN ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n                ELSE NULL END\n              FROM \"${fkHost}\" AS j\n              JOIN \"${foreignDb}\" AS f ON j.\"${foreignKey}\" = f.\"__id\"\n              WHERE j.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n              ORDER BY ${order}\n            )`;\n          }\n          const ordCol = hasOrderColumn ? `f.\"${opts.selfKeyName}_order\"` : undefined;\n          const order = ordCol\n            ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, f.\"__id\" ASC`\n            : `f.\"__id\" ASC`;\n          return `(\n            SELECT CASE WHEN COUNT(*) > 0\n              THEN json_group_array(CASE WHEN ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n              ELSE NULL END\n            FROM \"${foreignDb}\" AS f\n            WHERE f.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n            ORDER BY ${order}\n          )`;\n        } catch (_) {\n          // fallback to non-deterministic aggregation\n        }\n      }\n      return this.getJsonAggregationFunction(expression);\n    }\n    const sub = this.buildForeignFilterSubquery(filter);\n\n    if (!field.isMultipleCellValue || this.isSingleValueRelationshipContext) {\n      // Single value: conditionally null out for both PG and SQLite\n      if (this.dbProvider.driver === DriverClient.Pg) {\n        return `CASE WHEN EXISTS ${sub} THEN ${expression} ELSE NULL END`;\n      }\n      return `CASE WHEN EXISTS ${sub} THEN ${expression} ELSE NULL END`;\n    }\n\n    if (this.dbProvider.driver === DriverClient.Pg) {\n      const sanitizedExpression = this.normalizeJsonAggregateExpression(expression);\n      if (orderByClause) {\n        return `json_agg(${sanitizedExpression} ORDER BY ${orderByClause}) FILTER (WHERE (EXISTS ${sub}) AND ${sanitizedExpression} IS NOT NULL)`;\n      }\n      return `json_agg(${sanitizedExpression}) FILTER (WHERE (EXISTS ${sub}) AND ${sanitizedExpression} IS NOT NULL)`;\n    }\n\n    // SQLite: use a correlated, ordered subquery to produce deterministic ordering\n    try {\n      const linkForOrderingId = getLinkFieldId(field.lookupOptions);\n      const fieldCteMap = this.state.getFieldCteMap();\n      const mainAlias = getTableAliasFromTable(this.table);\n      const foreignDb = this.foreignTable.dbTableName;\n      // Prefer order from link CTE JSON array\n      if (\n        linkForOrderingId &&\n        fieldCteMap.has(linkForOrderingId) &&\n        this.joinedCtes?.has(linkForOrderingId) &&\n        linkForOrderingId !== this.currentLinkFieldId\n      ) {\n        const cteName = fieldCteMap.get(linkForOrderingId)!;\n        const exprForInner = expression.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n        const subForInner = sub.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n        return `(\n          SELECT CASE WHEN SUM(CASE WHEN (EXISTS ${subForInner}) THEN 1 ELSE 0 END) > 0\n            THEN json_group_array(CASE WHEN (EXISTS ${subForInner}) AND ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n            ELSE NULL END\n          FROM json_each(\n            CASE\n              WHEN json_valid((SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\"))\n               AND json_type((SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\")) = 'array'\n              THEN (SELECT \"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${mainAlias}\".\"__id\")\n              ELSE json('[]')\n            END\n          ) AS je\n          JOIN \"${foreignDb}\" AS f ON f.\"__id\" = json_extract(je.value, '$.id')\n          ORDER BY je.key ASC\n        )`;\n      }\n      if (linkForOrderingId) {\n        const linkForOrdering = this.table.getField(linkForOrderingId) as LinkFieldCore;\n        const opts = linkForOrdering.options as ILinkFieldOptions;\n        const usesJunctionTable = getLinkUsesJunctionTable(linkForOrdering);\n        const hasOrderColumn = linkForOrdering.getHasOrderColumn();\n        const fkHost = opts.fkHostTableName!;\n        const selfKey = opts.selfKeyName;\n        const foreignKey = opts.foreignKeyName;\n        // Adapt expression and filter subquery to inner alias \"f\"\n        const exprForInner = expression.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n        const subForInner = sub.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n        if (usesJunctionTable) {\n          const ordCol = hasOrderColumn ? `j.\"${linkForOrdering.getOrderColumnName()}\"` : undefined;\n          const order = ordCol\n            ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, j.\"__id\" ASC`\n            : `j.\"__id\" ASC`;\n          return `(\n            SELECT CASE WHEN SUM(CASE WHEN (EXISTS ${subForInner}) THEN 1 ELSE 0 END) > 0\n              THEN json_group_array(CASE WHEN (EXISTS ${subForInner}) AND ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n              ELSE NULL END\n            FROM \"${fkHost}\" AS j\n            JOIN \"${foreignDb}\" AS f ON j.\"${foreignKey}\" = f.\"__id\"\n            WHERE j.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n            ORDER BY ${order}\n          )`;\n        } else {\n          const ordCol = hasOrderColumn ? `f.\"${selfKey}_order\"` : undefined;\n          const order = ordCol\n            ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, f.\"__id\" ASC`\n            : `f.\"__id\" ASC`;\n          return `(\n            SELECT CASE WHEN SUM(CASE WHEN (EXISTS ${subForInner}) THEN 1 ELSE 0 END) > 0\n              THEN json_group_array(CASE WHEN (EXISTS ${subForInner}) AND ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n              ELSE NULL END\n            FROM \"${foreignDb}\" AS f\n            WHERE f.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n            ORDER BY ${order}\n          )`;\n        }\n      }\n      // Default ordering using the current link field\n      const baseLink = field as LinkFieldCore;\n      const opts = baseLink.options as ILinkFieldOptions;\n      const usesJunctionTable = getLinkUsesJunctionTable(baseLink);\n      const hasOrderColumn = baseLink.getHasOrderColumn();\n      const fkHost = opts.fkHostTableName!;\n      const selfKey = opts.selfKeyName;\n      const foreignKey = opts.foreignKeyName;\n      const exprForInner = expression.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n      const subForInner = sub.replaceAll(`\"${this.getForeignAlias()}\"`, '\"f\"');\n      if (usesJunctionTable) {\n        const ordCol = hasOrderColumn ? `j.\"${baseLink.getOrderColumnName()}\"` : undefined;\n        const order = ordCol\n          ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, j.\"__id\" ASC`\n          : `j.\"__id\" ASC`;\n        return `(\n          SELECT CASE WHEN SUM(CASE WHEN (EXISTS ${subForInner}) THEN 1 ELSE 0 END) > 0\n            THEN json_group_array(CASE WHEN (EXISTS ${subForInner}) AND ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n            ELSE NULL END\n          FROM \"${fkHost}\" AS j\n          JOIN \"${foreignDb}\" AS f ON j.\"${foreignKey}\" = f.\"__id\"\n          WHERE j.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n          ORDER BY ${order}\n        )`;\n      }\n      {\n        const ordCol = hasOrderColumn ? `f.\"${selfKey}_order\"` : undefined;\n        const order = ordCol\n          ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, f.\"__id\" ASC`\n          : `f.\"__id\" ASC`;\n        return `(\n          SELECT CASE WHEN SUM(CASE WHEN (EXISTS ${subForInner}) THEN 1 ELSE 0 END) > 0\n            THEN json_group_array(CASE WHEN (EXISTS ${subForInner}) AND ${exprForInner} IS NOT NULL THEN ${exprForInner} END)\n            ELSE NULL END\n          FROM \"${foreignDb}\" AS f\n          WHERE f.\"${selfKey}\" = \"${mainAlias}\".\"__id\"\n          ORDER BY ${order}\n        )`;\n      }\n    } catch (_) {\n      // fall back\n    }\n    // Fallback: emulate FILTER and null removal using CASE inside the aggregate\n    return `json_group_array(CASE WHEN (EXISTS ${sub}) AND ${expression} IS NOT NULL THEN ${expression} END)`;\n  }\n  visitNumberField(field: NumberFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitSingleLineTextField(field: SingleLineTextFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitLongTextField(field: LongTextFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitAttachmentField(field: AttachmentFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitCheckboxField(field: CheckboxFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitDateField(field: DateFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitRatingField(field: RatingFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitAutoNumberField(field: AutoNumberFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitLinkField(field: LinkFieldCore): IFieldSelectName {\n    // If this Link field is itself a lookup (lookup-of-link), treat it as a generic lookup\n    // so we resolve via nested CTEs instead of using physical link options.\n    if (field.isLookup) {\n      return this.visitLookupField(field);\n    }\n    const foreignTable = this.foreignTable;\n    const driver = this.dbProvider.driver;\n    const junctionAlias = JUNCTION_ALIAS;\n\n    const targetLookupField = foreignTable.mustGetField(field.options.lookupFieldId);\n    const usesJunctionTable = getLinkUsesJunctionTable(field);\n    const foreignTableAlias = this.getForeignAlias();\n    const isMultiValue = field.getIsMultiValue();\n    const hasOrderColumn = field.getHasOrderColumn();\n\n    // Use table alias for cleaner SQL\n    const recordIdRef = `\"${foreignTableAlias}\".\"${ID_FIELD_NAME}\"`;\n\n    // Prefer physical column values to avoid recursive formula/lookup expansion.\n    let rawSelectionExpression = this.buildPhysicalFieldExpression(\n      targetLookupField,\n      foreignTableAlias\n    );\n\n    // Apply field formatting to build the display expression\n    const formattingVisitor = new FieldFormattingVisitor(rawSelectionExpression, this.dialect);\n    let formattedSelectionExpression = targetLookupField.accept(formattingVisitor);\n    // Self-join: ensure expressions use the foreign alias override\n    const defaultForeignAlias = getTableAliasFromTable(foreignTable);\n    if (defaultForeignAlias !== foreignTableAlias) {\n      formattedSelectionExpression = formattedSelectionExpression.replaceAll(\n        `\"${defaultForeignAlias}\"`,\n        `\"${foreignTableAlias}\"`\n      );\n      rawSelectionExpression = rawSelectionExpression.replaceAll(\n        `\"${defaultForeignAlias}\"`,\n        `\"${foreignTableAlias}\"`\n      );\n    }\n\n    // Determine if this relationship should return multiple values (array) or single value (object)\n    // Apply field-level filter for Link (only affects this column)\n    const linkFieldFilter = (field as FieldCore).getFilter?.();\n    const linkFilterSub = linkFieldFilter\n      ? this.buildForeignFilterSubquery(linkFieldFilter)\n      : undefined;\n    return match(driver)\n      .with(DriverClient.Pg, () => {\n        // Build JSON object with id and title, then strip null values to remove title key when null\n        const conditionalJsonObject = this.dialect.buildLinkJsonObject(\n          recordIdRef,\n          formattedSelectionExpression,\n          rawSelectionExpression\n        );\n\n        if (isMultiValue) {\n          // Filter out null records and return empty array if no valid records exist\n          // Build an ORDER BY clause with NULLS FIRST semantics and stable tie-breaks using __id\n\n          const orderByClause = match({ usesJunctionTable, hasOrderColumn })\n            .with({ usesJunctionTable: true, hasOrderColumn: true }, () => {\n              // ManyMany with order column: NULLS FIRST, then order column ASC, then junction __id ASC\n              const linkField = field as LinkFieldCore;\n              const ord = `${junctionAlias}.\"${linkField.getOrderColumnName()}\"`;\n              return `${ord} IS NULL DESC, ${ord} ASC, ${junctionAlias}.\"__id\" ASC`;\n            })\n            .with({ usesJunctionTable: true, hasOrderColumn: false }, () => {\n              // ManyMany without order column: order by junction __id\n              return `${junctionAlias}.\"__id\" ASC`;\n            })\n            .with({ usesJunctionTable: false, hasOrderColumn: true }, () => {\n              // OneMany/ManyOne/OneOne with order column: NULLS FIRST, then order ASC, then foreign __id ASC\n              const linkField = field as LinkFieldCore;\n              const ord = `\"${foreignTableAlias}\".\"${linkField.getOrderColumnName()}\"`;\n              return `${ord} IS NULL DESC, ${ord} ASC, \"${foreignTableAlias}\".\"__id\" ASC`;\n            })\n            .with({ usesJunctionTable: false, hasOrderColumn: false }, () => `${recordIdRef} ASC`) // Fallback to record ID if no order column is available\n            .exhaustive();\n\n          const baseFilter = `${recordIdRef} IS NOT NULL`;\n          const appliedFilter = linkFilterSub\n            ? `(EXISTS ${linkFilterSub}) AND ${baseFilter}`\n            : baseFilter;\n          const sanitizedExpression = this.normalizeJsonAggregateExpression(conditionalJsonObject);\n          return `json_agg(${sanitizedExpression} ORDER BY ${orderByClause}) FILTER (WHERE ${appliedFilter})`;\n        } else {\n          // For single value relationships (ManyOne, OneOne) always return a single object or null\n          const cond = linkFilterSub\n            ? `${recordIdRef} IS NOT NULL AND EXISTS ${linkFilterSub}`\n            : `${recordIdRef} IS NOT NULL`;\n          return `CASE WHEN ${cond} THEN ${conditionalJsonObject} ELSE NULL END`;\n        }\n      })\n      .with(DriverClient.Sqlite, () => {\n        // Create conditional JSON object that only includes title if it's not null\n        const conditionalJsonObject = this.dialect.buildLinkJsonObject(\n          recordIdRef,\n          formattedSelectionExpression,\n          rawSelectionExpression\n        );\n\n        if (isMultiValue) {\n          // For SQLite, build a correlated, ordered subquery to ensure deterministic ordering\n          const mainAlias = getTableAliasFromTable(this.table);\n          const foreignDb = this.foreignTable.dbTableName;\n          const usesJunctionTable = getLinkUsesJunctionTable(field);\n          const hasOrderColumn = field.getHasOrderColumn();\n\n          const innerIdRef = `\"f\".\"${ID_FIELD_NAME}\"`;\n          const innerTitleExpr = formattedSelectionExpression.replaceAll(\n            `\"${foreignTableAlias}\"`,\n            '\"f\"'\n          );\n          const innerRawExpr = rawSelectionExpression.replaceAll(`\"${foreignTableAlias}\"`, '\"f\"');\n          const innerJson = `CASE WHEN ${innerRawExpr} IS NOT NULL THEN json_object('id', ${innerIdRef}, 'title', ${innerTitleExpr}) ELSE json_object('id', ${innerIdRef}) END`;\n          const innerFilter = linkFilterSub\n            ? `(EXISTS ${linkFilterSub.replaceAll(`\"${foreignTableAlias}\"`, '\"f\"')})`\n            : '1=1';\n\n          const opts = field.options as ILinkFieldOptions;\n          return (\n            this.dialect.buildDeterministicLookupAggregate({\n              tableDbName: this.table.dbTableName,\n              mainAlias: getTableAliasFromTable(this.table),\n              foreignDbName: this.foreignTable.dbTableName,\n              foreignAlias: foreignTableAlias,\n              linkFieldOrderColumn: hasOrderColumn\n                ? `${JUNCTION_ALIAS}.\"${field.getOrderColumnName()}\"`\n                : undefined,\n              linkFieldHasOrderColumn: hasOrderColumn,\n              usesJunctionTable,\n              selfKeyName: opts.selfKeyName,\n              foreignKeyName: opts.foreignKeyName,\n              recordIdRef,\n              formattedSelectionExpression,\n              rawSelectionExpression,\n              linkFilterSubquerySql: linkFilterSub,\n              // Pass the actual junction table name here; the dialect will alias it as \"j\".\n              junctionAlias: opts.fkHostTableName!,\n            }) || this.getJsonAggregationFunction(conditionalJsonObject)\n          );\n        } else {\n          const cond = linkFilterSub\n            ? `${recordIdRef} IS NOT NULL AND EXISTS ${linkFilterSub}`\n            : `${recordIdRef} IS NOT NULL`;\n          return `CASE WHEN ${cond} THEN ${conditionalJsonObject} ELSE NULL END`;\n        }\n      })\n      .otherwise(() => {\n        throw new Error(`Unsupported database driver: ${driver}`);\n      });\n  }\n  visitRollupField(field: RollupFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.visitLookupField(field);\n    }\n\n    // If rollup field is marked as error, don't attempt to resolve; just return NULL\n    if (field.hasError) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n\n    const foreignAlias = this.getForeignAlias();\n    const targetLookupField = field.getForeignLookupField(this.foreignTable);\n    if (!targetLookupField) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n    // Prefer physical column values to avoid recursive formula/lookup expansion.\n    const expression = this.buildPhysicalFieldExpression(targetLookupField, foreignAlias);\n    const linkField = field.getLinkField(this.table);\n    const options = linkField?.options as ILinkFieldOptions;\n    const isSingleValueRelationship =\n      options.relationship === Relationship.ManyOne || options.relationship === Relationship.OneOne;\n\n    if (isSingleValueRelationship) {\n      return this.buildSingleValueRollup(field, targetLookupField, expression);\n    }\n    return this.buildAggregateRollup(field, targetLookupField, expression);\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.visitLookupField(field);\n    }\n    const cteName = this.fieldCteMap.get(field.id);\n    if (!cteName) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n\n    return `\"${cteName}\".\"conditional_rollup_${field.id}\"`;\n  }\n  visitSingleSelectField(field: SingleSelectFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitMultipleSelectField(field: MultipleSelectFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitFormulaField(field: FormulaFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitCreatedTimeField(field: CreatedTimeFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitUserField(field: UserFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitCreatedByField(field: CreatedByFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitLastModifiedByField(field: LastModifiedByFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n  visitButtonField(field: ButtonFieldCore): IFieldSelectName {\n    return this.visitLookupField(field);\n  }\n}\n\nexport class FieldCteVisitor implements IFieldVisitor<ICteResult> {\n  private logger = new Logger(FieldCteVisitor.name);\n\n  static generateCTENameForField(table: TableDomain, field: LinkFieldCore) {\n    return `CTE_${getTableAliasFromTable(table)}_${field.id}`;\n  }\n\n  private readonly _table: TableDomain;\n  private readonly state: IMutableQueryBuilderState;\n  private readonly conditionalRollupGenerationStack = new Set<string>();\n  private readonly conditionalLookupGenerationStack = new Set<string>();\n  private readonly linkCteGenerationStack = new Set<string>();\n  private readonly emittedLinkCteIds = new Set<string>();\n  private readonly pendingLinkCteNames = new Map<string, string>();\n  private filteredIdSet?: Set<string>;\n  private readonly projection?: string[];\n  private readonly expandFormulaReferences: boolean;\n\n  constructor(\n    public readonly qb: Knex.QueryBuilder,\n    private readonly dbProvider: IDbProvider,\n    private readonly tables: Tables,\n    state: IMutableQueryBuilderState | undefined,\n    private readonly dialect: IRecordQueryDialectProvider,\n    projection?: string[],\n    expandFormulaReferences: boolean = true\n  ) {\n    this.state = state ?? new RecordQueryBuilderManager('table');\n    this._table = tables.mustGetEntryTable();\n    this.projection = projection;\n    this.expandFormulaReferences = expandFormulaReferences;\n  }\n\n  get table() {\n    return this._table;\n  }\n\n  get fieldCteMap(): ReadonlyMap<string, string> {\n    return this.state.getFieldCteMap();\n  }\n\n  private unwrapSelectName(selection: IFieldSelectName | string): string {\n    return typeof selection === 'string' ? selection : selection.toQuery();\n  }\n\n  private getReadyLinkFieldIdsSnapshotForVisitor(): ReadonlySet<string> | undefined {\n    return new Set(this.emittedLinkCteIds);\n  }\n\n  private createFieldSelectVisitor(\n    table: TableDomain,\n    alias?: string,\n    rawProjection = true,\n    preferRawFieldReferences = true,\n    blockedLinkFieldIds?: Iterable<string | undefined>\n  ): FieldSelectVisitor {\n    let blocked: Set<string> | undefined;\n    if (this.linkCteGenerationStack.size) {\n      blocked = new Set(this.linkCteGenerationStack);\n    }\n    if (blockedLinkFieldIds) {\n      for (const id of blockedLinkFieldIds) {\n        if (!id) continue;\n        if (!blocked) {\n          blocked = new Set();\n        }\n        blocked.add(id);\n      }\n    }\n\n    let currentLinkFieldId: string | undefined;\n    for (const id of this.linkCteGenerationStack) {\n      currentLinkFieldId = id;\n    }\n    return new FieldSelectVisitor(\n      this.qb.client.queryBuilder(),\n      this.dbProvider,\n      table,\n      new ScopedSelectionState(this.state),\n      this.dialect,\n      alias,\n      rawProjection,\n      preferRawFieldReferences,\n      blocked,\n      new Set(this.emittedLinkCteIds),\n      currentLinkFieldId\n    );\n  }\n\n  private getCteNameForField(fieldId: string): string | undefined {\n    return this.state.getCteName(fieldId) ?? this.pendingLinkCteNames.get(fieldId);\n  }\n\n  private buildFieldReferenceContext(\n    table: TableDomain,\n    foreignTable: TableDomain,\n    mainAlias: string,\n    foreignAlias: string\n  ): {\n    fieldReferenceSelectionMap: Map<string, string>;\n    fieldReferenceFieldMap: Map<string, FieldCore>;\n  } {\n    const fieldReferenceSelectionMap = new Map<string, string>();\n    const fieldReferenceFieldMap = new Map<string, FieldCore>();\n\n    if (table.id === foreignTable.id) {\n      for (const field of table.fields.ordered) {\n        fieldReferenceSelectionMap.set(field.id, `\"${foreignAlias}\".\"${field.dbFieldName}\"`);\n        fieldReferenceFieldMap.set(field.id, field as FieldCore);\n      }\n      return { fieldReferenceSelectionMap, fieldReferenceFieldMap };\n    }\n\n    for (const field of table.fields.ordered) {\n      fieldReferenceSelectionMap.set(field.id, `\"${mainAlias}\".\"${field.dbFieldName}\"`);\n      fieldReferenceFieldMap.set(field.id, field as FieldCore);\n    }\n\n    for (const field of foreignTable.fields.ordered) {\n      if (fieldReferenceSelectionMap.has(field.id)) continue;\n      fieldReferenceSelectionMap.set(field.id, `\"${foreignAlias}\".\"${field.dbFieldName}\"`);\n      fieldReferenceFieldMap.set(field.id, field as FieldCore);\n    }\n\n    return { fieldReferenceSelectionMap, fieldReferenceFieldMap };\n  }\n\n  private buildPhysicalFieldExpression(field: FieldCore, alias: string): string {\n    if (field.hasError) {\n      return this.dialect.typedNullFor(field.dbFieldType);\n    }\n    return `\"${alias}\".\"${field.dbFieldName}\"`;\n  }\n\n  private buildConditionalFilterSelectionMap(\n    foreignTable: TableDomain,\n    foreignAlias: string,\n    filter: IFilter | null | undefined,\n    selectVisitor: FieldSelectVisitor\n  ): Map<string, string> {\n    const selectionMap = new Map<string, string>();\n    if (!filter) return selectionMap;\n\n    const filterFieldIds = extractFieldIdsFromFilter(filter);\n    for (const fieldId of filterFieldIds) {\n      const field = foreignTable.getField(fieldId);\n      if (!field) continue;\n      let selection = this.buildPhysicalFieldExpression(field, foreignAlias);\n      if (\n        this.expandFormulaReferences &&\n        (field.type === FieldType.ConditionalRollup || field.isConditionalLookup)\n      ) {\n        selection = this.resolveConditionalComputedTargetExpression(\n          field,\n          foreignTable,\n          foreignAlias,\n          selectVisitor\n        );\n      }\n      selectionMap.set(field.id, selection);\n    }\n\n    return selectionMap;\n  }\n\n  private getBaseIdSubquery(): Knex.QueryBuilder | undefined {\n    const baseCteName = this.state.getBaseCteName();\n    if (!baseCteName) {\n      return undefined;\n    }\n    return this.qb.client.queryBuilder().select(ID_FIELD_NAME).from(baseCteName);\n  }\n\n  private applyMainTableRestriction(builder: Knex.QueryBuilder, alias: string): void {\n    const subquery = this.getBaseIdSubquery();\n    if (!subquery) {\n      return;\n    }\n    builder.whereIn(`${alias}.${ID_FIELD_NAME}`, subquery);\n  }\n\n  private withCte(\n    name: string,\n    builder: (qb: Knex.QueryBuilder) => void,\n    opts?: { materialized?: boolean }\n  ): void {\n    const qbWithMaterialized = this.qb as Knex.QueryBuilder & {\n      withMaterialized?: (\n        alias: string,\n        expression: Knex.QueryBuilder | ((qb: Knex.QueryBuilder) => void)\n      ) => Knex.QueryBuilder;\n    };\n    if (opts?.materialized && typeof qbWithMaterialized.withMaterialized === 'function') {\n      qbWithMaterialized.withMaterialized(name, builder);\n      return;\n    }\n    this.qb.with(name, builder);\n  }\n\n  private fromTableWithRestriction(\n    builder: Knex.QueryBuilder,\n    table: TableDomain,\n    alias: string\n  ): void {\n    const source =\n      table.id === this.table.id\n        ? this.state.getOriginalMainTableSource() ?? table.dbTableName\n        : table.dbTableName;\n    builder.from(`${source} as ${alias}`);\n    if (table.id === this.table.id) {\n      this.applyMainTableRestriction(builder, alias);\n    }\n  }\n\n  private ensureLinkDependencyForScope(\n    candidate: LinkFieldCore | null | undefined,\n    foreignTable: TableDomain,\n    currentLinkFieldId: string,\n    nestedJoins: Set<string>\n  ): void {\n    if (!candidate?.id || candidate.id === currentLinkFieldId) {\n      return;\n    }\n    // When the candidate link field is currently being generated higher up the stack,\n    // avoid joining to its CTE (it does not exist yet and would create a cyclic dependency).\n    if (this.linkCteGenerationStack.has(candidate.id)) {\n      return;\n    }\n    if (!this.fieldCteMap.has(candidate.id)) {\n      this.generateLinkFieldCteForTable(foreignTable, candidate);\n    }\n    // Only join nested CTEs that have already been materialized earlier in the WITH clause.\n    if (this.fieldCteMap.has(candidate.id) && this.emittedLinkCteIds.has(candidate.id)) {\n      nestedJoins.add(candidate.id);\n    }\n  }\n\n  private getBlockedLinkFieldIds(currentLinkFieldId: string): ReadonlySet<string> | undefined {\n    if (!this.linkCteGenerationStack.size) {\n      return undefined;\n    }\n    const blocked = new Set(this.linkCteGenerationStack);\n    return blocked.size ? blocked : undefined;\n  }\n\n  /**\n   * Apply an explicit cast to align the SQL expression type with the target field's DB column type.\n   * This prevents Postgres from rejecting UPDATE ... FROM assignments due to type mismatches\n   * (e.g., assigning a text expression to a double precision column).\n   */\n  private castExpressionForDbType(expression: string, field: FieldCore): string {\n    if (this.dbProvider.driver !== DriverClient.Pg) return expression;\n    const castSuffix = (() => {\n      switch (field.dbFieldType) {\n        case DbFieldType.Json:\n          return '::jsonb';\n        case DbFieldType.Integer:\n          return '::integer';\n        case DbFieldType.Real:\n          return '::double precision';\n        case DbFieldType.DateTime:\n          return '::timestamptz';\n        case DbFieldType.Boolean:\n          return '::boolean';\n        case DbFieldType.Blob:\n          return '::bytea';\n        case DbFieldType.Text:\n        default:\n          return '::text';\n      }\n    })();\n    return `(${expression})${castSuffix}`;\n  }\n\n  private rollupFunctionSupportsOrdering(expression: string): boolean {\n    const fn = parseRollupFunctionName(expression);\n    switch (fn) {\n      case 'array_join':\n      case 'array_compact':\n      case 'concatenate':\n        return true;\n      default:\n        return false;\n    }\n  }\n\n  private buildConditionalRollupAggregation(\n    rollupExpression: string,\n    fieldExpression: string,\n    targetField: FieldCore,\n    foreignAlias: string,\n    orderByClause?: string\n  ): string {\n    const fn = parseRollupFunctionName(rollupExpression);\n    const shouldFlattenNestedArray =\n      fn === 'array_compact' &&\n      ((targetField?.isMultipleCellValue ?? false) || (targetField?.isConditionalLookup ?? false));\n    return this.dialect.rollupAggregate(fn, fieldExpression, {\n      targetField,\n      rowPresenceExpr: `\"${foreignAlias}\".\"${ID_FIELD_NAME}\"`,\n      orderByField: orderByClause,\n      flattenNestedArray: shouldFlattenNestedArray,\n    });\n  }\n\n  private extractConditionalEqualityJoinPlan(\n    filter: IFilter | null | undefined,\n    table: TableDomain,\n    foreignTable: TableDomain,\n    mainAlias: string,\n    foreignAlias: string\n  ): {\n    joinKeys: Array<{ alias: string; hostExpr: string; foreignExpr: string }>;\n    residualFilter: IFilter | null;\n  } | null {\n    if (!filter?.filterSet?.length) return null;\n\n    const joinKeys: Array<{ alias: string; hostExpr: string; foreignExpr: string }> = [];\n\n    type FilterNode = Exclude<IFilter, null>;\n\n    const buildResidual = (\n      current: IFilter | null | undefined\n    ): { ok: boolean; residual: IFilter } => {\n      if (!current?.filterSet?.length) return { ok: false, residual: null };\n      const conjunction = current.conjunction ?? 'and';\n      if (conjunction !== 'and') return { ok: false, residual: null };\n\n      const residualEntries: Array<FilterNode | IFilterItem> = [];\n\n      for (const entry of current.filterSet ?? []) {\n        if (!entry) continue;\n        if ('fieldId' in entry) {\n          const item = entry as IFilterItem;\n\n          if (item.operator === FilterOperatorIs.value && isFieldReferenceValue(item.value)) {\n            const hostRef = item.value;\n            if (hostRef.tableId && hostRef.tableId !== table.id) {\n              return { ok: false, residual: null };\n            }\n            const foreignField = foreignTable.getField(item.fieldId);\n            const hostField = table.getField(hostRef.fieldId);\n            if (!foreignField || !hostField) {\n              return { ok: false, residual: null };\n            }\n            if (isDateLikeField(foreignField) || isDateLikeField(hostField)) {\n              return { ok: false, residual: null };\n            }\n            // When the foreign scope is the same table, compare the host record's fieldId\n            // against the foreign row's referenced field so \"Field A is {Field B}\" reads as\n            // host.FieldA = foreign.FieldB instead of the reverse.\n            const hostJoinField = foreignTable.id === table.id ? foreignField : hostField;\n            const foreignJoinField = foreignTable.id === table.id ? hostField : foreignField;\n            const joinKey = this.buildConditionalEqualityJoinKey(\n              hostJoinField,\n              foreignJoinField,\n              mainAlias,\n              foreignAlias\n            );\n            if (!joinKey) {\n              return { ok: false, residual: null };\n            }\n            const alias = `__cr_key_${joinKeys.length}`;\n            joinKeys.push({ alias, ...joinKey });\n            continue;\n          }\n\n          if (isFieldReferenceValue(item.value)) {\n            return { ok: false, residual: null };\n          }\n\n          if (!SUPPORTED_EQUALITY_RESIDUAL_OPERATORS.has(item.operator)) {\n            return { ok: false, residual: null };\n          }\n\n          residualEntries.push(entry);\n          continue;\n        }\n\n        if ('filterSet' in entry) {\n          const nested = buildResidual(entry as IFilter);\n          if (!nested.ok) {\n            return { ok: false, residual: null };\n          }\n          const nestedResidual = nested.residual;\n          if (nestedResidual && 'filterSet' in nestedResidual && nestedResidual.filterSet?.length) {\n            residualEntries.push(nestedResidual as FilterNode);\n          }\n          continue;\n        }\n\n        return { ok: false, residual: null };\n      }\n\n      if (!residualEntries.length) {\n        return { ok: true, residual: null };\n      }\n\n      return {\n        ok: true,\n        residual: {\n          conjunction,\n          filterSet: residualEntries,\n        } as FilterNode,\n      };\n    };\n\n    const { ok, residual } = buildResidual(filter);\n    if (!ok || !joinKeys.length) return null;\n    return { joinKeys, residualFilter: residual };\n  }\n\n  private getConditionalEqualityFallback(aggregationFn: string, field: FieldCore): string | null {\n    switch (aggregationFn) {\n      case 'countall':\n      case 'count':\n      case 'counta':\n      case 'sum':\n      case 'average':\n        return '0::double precision';\n      case 'max':\n      case 'min': {\n        const dbType = field.dbFieldType ?? DbFieldType.Text;\n        return this.dialect.typedNullFor(dbType);\n      }\n      default:\n        return null;\n    }\n  }\n\n  private buildConditionalEqualityJoinKey(\n    hostField: FieldCore,\n    foreignField: FieldCore,\n    mainAlias: string,\n    foreignAlias: string\n  ): { hostExpr: string; foreignExpr: string } | null {\n    const hostDbType = hostField.dbFieldType;\n    const foreignDbType = foreignField.dbFieldType;\n    const hostRef = `\"${mainAlias}\".\"${hostField.dbFieldName}\"`;\n    const foreignRef = `\"${foreignAlias}\".\"${foreignField.dbFieldName}\"`;\n\n    const isTextHost = hostDbType === DbFieldType.Text;\n    const isTextForeign = foreignDbType === DbFieldType.Text;\n    const isJsonHost = hostDbType === DbFieldType.Json;\n    const isJsonForeign = foreignDbType === DbFieldType.Json;\n\n    const isUserOrLinkField = (field: FieldCore) =>\n      [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(\n        field.type\n      );\n\n    if (\n      isJsonHost &&\n      isJsonForeign &&\n      isUserOrLinkField(hostField) &&\n      isUserOrLinkField(foreignField)\n    ) {\n      if (hostField.isMultipleCellValue || foreignField.isMultipleCellValue) {\n        return null;\n      }\n      if (this.dbProvider.driver === DriverClient.Pg) {\n        return {\n          hostExpr: `jsonb_extract_path_text(${hostRef}::jsonb, 'id')`,\n          foreignExpr: `jsonb_extract_path_text(${foreignRef}::jsonb, 'id')`,\n        };\n      }\n      if (this.dbProvider.driver === DriverClient.Sqlite) {\n        return {\n          hostExpr: `json_extract(${hostRef}, '$.id')`,\n          foreignExpr: `json_extract(${foreignRef}, '$.id')`,\n        };\n      }\n    }\n\n    // Exact type match (e.g., text-text, integer-integer)\n    if (hostDbType === foreignDbType) {\n      if (isTextHost && isTextForeign) {\n        return { hostExpr: `LOWER(${hostRef})`, foreignExpr: `LOWER(${foreignRef})` };\n      }\n      return { hostExpr: hostRef, foreignExpr: foreignRef };\n    }\n\n    // Link-title equality against text fields (Postgres only).\n    // When comparing a link field to a text field with \"is\" in conditional rollups,\n    // match on linked record titles instead of the raw JSON payload. For multi-link\n    // foreign fields, jsonb_path_query expands each title, so any matching title\n    // satisfies the equality join.\n    if (this.dbProvider.driver === DriverClient.Pg) {\n      if (isTextHost && isJsonForeign && foreignField.type === FieldType.Link) {\n        const path = foreignField.isMultipleCellValue ? '$[*].title' : '$.title';\n        const hostExpr = `LOWER(${hostRef})`;\n        const foreignExpr = `LOWER(jsonb_path_query(${foreignRef}::jsonb, '${path}') #>> '{}')`;\n        return { hostExpr, foreignExpr };\n      }\n\n      if (isJsonHost && isTextForeign && hostField.type === FieldType.Link) {\n        if (!hostField.isMultipleCellValue) {\n          const path = '$.title';\n          const hostExpr = `LOWER(jsonb_path_query(${hostRef}::jsonb, '${path}') #>> '{}')`;\n          const foreignExpr = `LOWER(${foreignRef})`;\n          return { hostExpr, foreignExpr };\n        }\n        // Multi-link on the host side can't be expanded without duplicating host rows.\n        // Fall through to the generic text/json coercion.\n      }\n    }\n\n    // Text/JSON combos: coerce both sides to text to avoid operator errors (text = jsonb)\n    if ((isTextHost && isJsonForeign) || (isJsonHost && isTextForeign)) {\n      const hostExpr = `LOWER((${hostRef})::text)`;\n      const foreignExpr = `LOWER((${foreignRef})::text)`;\n      return { hostExpr, foreignExpr };\n    }\n\n    return null;\n  }\n\n  private resolveConditionalComputedTargetExpression(\n    targetField: FieldCore,\n    foreignTable: TableDomain,\n    foreignAlias: string,\n    selectVisitor: FieldSelectVisitor\n  ): string {\n    if (\n      !this.expandFormulaReferences &&\n      (targetField.isLookup ||\n        targetField.type === FieldType.Rollup ||\n        targetField.type === FieldType.ConditionalRollup ||\n        targetField.type === FieldType.Link)\n    ) {\n      return this.buildPhysicalFieldExpression(targetField, foreignAlias);\n    }\n\n    if (targetField.type === FieldType.ConditionalRollup) {\n      const conditionalTarget = targetField as ConditionalRollupFieldCore;\n      this.generateConditionalRollupFieldCteForScope(foreignTable, conditionalTarget);\n      const nestedCteName = this.getCteNameForField(conditionalTarget.id);\n      if (nestedCteName) {\n        return `((SELECT \"conditional_rollup_${conditionalTarget.id}\" FROM \"${nestedCteName}\" WHERE \"${nestedCteName}\".\"main_record_id\" = \"${foreignAlias}\".\"${ID_FIELD_NAME}\"))`;\n      }\n      const fallback = conditionalTarget.accept(selectVisitor);\n      return this.unwrapSelectName(fallback);\n    }\n\n    if (targetField.isConditionalLookup) {\n      const options = targetField.getConditionalLookupOptions?.();\n      if (options) {\n        this.generateConditionalLookupFieldCteForScope(foreignTable, targetField, options);\n      }\n      const nestedCteName = this.getCteNameForField(targetField.id);\n      if (nestedCteName) {\n        const column = `conditional_lookup_${targetField.id}`;\n        return `((SELECT \"${column}\" FROM \"${nestedCteName}\" WHERE \"${nestedCteName}\".\"main_record_id\" = \"${foreignAlias}\".\"${ID_FIELD_NAME}\"))`;\n      }\n    }\n\n    const targetSelect = targetField.accept(selectVisitor);\n    return this.unwrapSelectName(targetSelect);\n  }\n\n  private coerceConditionalLookupTargetExpression(\n    expression: string,\n    targetField: FieldCore\n  ): string {\n    if (targetField.isConditionalLookup || targetField.isMultipleCellValue) {\n      return expression;\n    }\n    if (targetField.cellValueType === CellValueType.Number) {\n      if (this.dbProvider.driver === DriverClient.Pg) {\n        return `(${expression})::double precision`;\n      }\n      if (this.dbProvider.driver === DriverClient.Sqlite) {\n        return `CAST(${expression} AS NUMERIC)`;\n      }\n    }\n    if (targetField.cellValueType === CellValueType.Boolean) {\n      if (this.dbProvider.driver === DriverClient.Pg) {\n        return `(${expression})::boolean`;\n      }\n      if (this.dbProvider.driver === DriverClient.Sqlite) {\n        return `CAST(${expression} AS NUMERIC)`;\n      }\n    }\n    return expression;\n  }\n\n  private generateConditionalRollupFieldCte(field: ConditionalRollupFieldCore): void {\n    this.generateConditionalRollupFieldCteForScope(this.table, field);\n  }\n\n  private generateConditionalRollupFieldCteForScope(\n    table: TableDomain,\n    field: ConditionalRollupFieldCore\n  ): void {\n    if (field.hasError) return;\n    if (this.state.getFieldCteMap().has(field.id)) return;\n    if (this.conditionalRollupGenerationStack.has(field.id)) return;\n\n    this.conditionalRollupGenerationStack.add(field.id);\n    try {\n      const {\n        foreignTableId,\n        lookupFieldId,\n        expression = 'countall({values})',\n        filter,\n        sort,\n        limit,\n      } = field.options;\n      if (!foreignTableId || !lookupFieldId) {\n        return;\n      }\n\n      const foreignTable = this.tables.getTable(foreignTableId);\n      if (!foreignTable) {\n        return;\n      }\n\n      const targetField = foreignTable.getField(lookupFieldId);\n      if (!targetField) {\n        return;\n      }\n\n      const joinToMain = table === this.table;\n\n      const cteName = `CTE_REF_${field.id}`;\n      const mainAlias = getTableAliasFromTable(table);\n      const foreignAlias = getTableAliasFromTable(foreignTable);\n      const foreignAliasUsed = foreignAlias === mainAlias ? `${foreignAlias}_ref` : foreignAlias;\n\n      const selectVisitor = this.createFieldSelectVisitor(\n        foreignTable,\n        foreignAliasUsed,\n        true,\n        !this.expandFormulaReferences\n      );\n\n      const rawExpression = this.resolveConditionalComputedTargetExpression(\n        targetField,\n        foreignTable,\n        foreignAliasUsed,\n        selectVisitor\n      );\n      const normalizedExpression = this.coerceConditionalLookupTargetExpression(\n        rawExpression,\n        targetField\n      );\n      const formattingVisitor = new FieldFormattingVisitor(rawExpression, this.dialect);\n      const formattedExpression = targetField.accept(formattingVisitor);\n\n      const aggregationFn = parseRollupFunctionName(expression);\n      const useFormattedForArrayFunctions =\n        (targetField.type === FieldType.Link ||\n          targetField.type === FieldType.Formula ||\n          targetField.type === FieldType.ConditionalRollup) &&\n        (aggregationFn === 'array_join' ||\n          aggregationFn === 'concatenate' ||\n          aggregationFn === 'array_unique' ||\n          aggregationFn === 'array_compact');\n      const aggregationInputExpression = useFormattedForArrayFunctions\n        ? formattedExpression\n        : rawExpression;\n\n      const supportsOrdering = this.rollupFunctionSupportsOrdering(expression);\n\n      let orderByClause: string | undefined;\n      if (supportsOrdering && sort?.fieldId) {\n        const sortField = foreignTable.getField(sort.fieldId);\n        if (sortField) {\n          let sortExpression = this.resolveConditionalComputedTargetExpression(\n            sortField,\n            foreignTable,\n            foreignAliasUsed,\n            selectVisitor\n          );\n\n          const defaultForeignAlias = getTableAliasFromTable(foreignTable);\n          if (defaultForeignAlias !== foreignAliasUsed) {\n            sortExpression = sortExpression.replaceAll(\n              `\"${defaultForeignAlias}\"`,\n              `\"${foreignAliasUsed}\"`\n            );\n          }\n\n          const direction = sort.order === SortFunc.Desc ? 'DESC' : 'ASC';\n          orderByClause = `${sortExpression} ${direction}`;\n        }\n      }\n\n      const aggregateExpression = this.buildConditionalRollupAggregation(\n        expression,\n        aggregationInputExpression,\n        targetField,\n        foreignAliasUsed,\n        supportsOrdering ? orderByClause : undefined\n      );\n      const aggregatesToJson = JSON_AGG_FUNCTIONS.has(aggregationFn);\n      const normalizedAggregateExpression = unwrapJsonAggregateForScalar(\n        this.dbProvider.driver,\n        aggregateExpression,\n        field,\n        aggregatesToJson\n      );\n      const castedAggregateExpression = this.castExpressionForDbType(\n        normalizedAggregateExpression,\n        field\n      );\n\n      const equalityEnabledFns = new Set([\n        'countall',\n        'count',\n        'counta',\n        'sum',\n        'average',\n        'max',\n        'min',\n        'and',\n        'or',\n        'xor',\n        'array_unique',\n      ]);\n      const canUseEqualityPlan =\n        equalityEnabledFns.has(aggregationFn) &&\n        !supportsOrdering &&\n        !orderByClause &&\n        !sort?.fieldId;\n      const equalityPlan = canUseEqualityPlan\n        ? this.extractConditionalEqualityJoinPlan(\n            filter,\n            table,\n            foreignTable,\n            mainAlias,\n            foreignAliasUsed\n          )\n        : null;\n      const preferMaterializedCte = this.dbProvider.driver === DriverClient.Pg;\n\n      if (equalityPlan?.joinKeys.length) {\n        const countsAlias = `__cr_counts_${field.id}`;\n        const countsQuery = this.qb.client\n          .queryBuilder()\n          .from(`${foreignTable.dbTableName} as ${foreignAliasUsed}`);\n        for (const cond of equalityPlan.joinKeys) {\n          countsQuery.select(this.qb.client.raw(`${cond.foreignExpr} as \"${cond.alias}\"`));\n          countsQuery.groupByRaw(cond.foreignExpr);\n        }\n        countsQuery.select(this.qb.client.raw(`${castedAggregateExpression} as \"reference_value\"`));\n\n        if (equalityPlan.residualFilter) {\n          const fieldMap = foreignTable.fieldList.reduce(\n            (map, f) => {\n              map[f.id] = f as FieldCore;\n              return map;\n            },\n            {} as Record<string, FieldCore>\n          );\n\n          const selectionMap = new Map<string, IFieldSelectName>();\n          for (const f of foreignTable.fields.ordered) {\n            selectionMap.set(f.id, `\"${foreignAliasUsed}\".\"${f.dbFieldName}\"`);\n          }\n\n          const { fieldReferenceSelectionMap, fieldReferenceFieldMap } =\n            this.buildFieldReferenceContext(table, foreignTable, mainAlias, foreignAliasUsed);\n\n          this.dbProvider\n            .filterQuery(countsQuery, fieldMap, equalityPlan.residualFilter, undefined, {\n              selectionMap,\n              fieldReferenceSelectionMap,\n              fieldReferenceFieldMap,\n            })\n            .appendQueryBuilder();\n        }\n\n        const equalityFallback = this.getConditionalEqualityFallback(aggregationFn, field);\n        // Materialize to stop Postgres from re-running the aggregate for every outer row\n        // when the host table is re-joined during UPDATE ... LIMIT pagination.\n        this.withCte(\n          cteName,\n          (cqb) => {\n            cqb.select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`);\n            const refValueSql =\n              equalityFallback != null\n                ? `COALESCE(${countsAlias}.\"reference_value\", ${equalityFallback})`\n                : `${countsAlias}.\"reference_value\"`;\n            cqb.select(cqb.client.raw(`${refValueSql} as \"conditional_rollup_${field.id}\"`));\n            this.fromTableWithRestriction(cqb, table, mainAlias);\n            const countsSql = countsQuery.toQuery();\n            cqb.leftJoin(this.qb.client.raw(`(${countsSql}) as ${countsAlias}`), (join) => {\n              for (const cond of equalityPlan.joinKeys) {\n                join.on(\n                  this.qb.client.raw(cond.hostExpr),\n                  '=',\n                  this.qb.client.raw(`${countsAlias}.\"${cond.alias}\"`)\n                );\n              }\n            });\n          },\n          { materialized: preferMaterializedCte }\n        );\n\n        if (joinToMain && !this.state.isCteJoined(cteName)) {\n          this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n          this.state.markCteJoined(cteName);\n        }\n\n        this.state.setFieldCte(field.id, cteName);\n        return;\n      }\n\n      const aggregateSourceQuery = this.qb.client\n        .queryBuilder()\n        .select('*')\n        .from(`${foreignTable.dbTableName} as ${foreignAliasUsed}`);\n\n      if (filter) {\n        const fieldMap = foreignTable.fieldList.reduce(\n          (map, f) => {\n            map[f.id] = f as FieldCore;\n            return map;\n          },\n          {} as Record<string, FieldCore>\n        );\n\n        const selectionMap = this.buildConditionalFilterSelectionMap(\n          foreignTable,\n          foreignAliasUsed,\n          filter,\n          selectVisitor\n        );\n\n        const { fieldReferenceSelectionMap, fieldReferenceFieldMap } =\n          this.buildFieldReferenceContext(table, foreignTable, mainAlias, foreignAliasUsed);\n\n        this.dbProvider\n          .filterQuery(aggregateSourceQuery, fieldMap, filter, undefined, {\n            selectionMap,\n            fieldReferenceSelectionMap,\n            fieldReferenceFieldMap,\n          })\n          .appendQueryBuilder();\n      }\n\n      if (supportsOrdering && orderByClause) {\n        aggregateSourceQuery.orderByRaw(orderByClause);\n      }\n\n      if (supportsOrdering) {\n        const resolvedLimit = normalizeConditionalLimit(limit);\n        aggregateSourceQuery.limit(resolvedLimit);\n      }\n\n      const aggregateQuery = this.qb.client\n        .queryBuilder()\n        .from(aggregateSourceQuery.as(foreignAliasUsed));\n\n      aggregateQuery.select(this.qb.client.raw(`${castedAggregateExpression} as reference_value`));\n      const aggregateSql = aggregateQuery.toQuery();\n\n      this.withCte(\n        cteName,\n        (cqb) => {\n          cqb\n            .select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`)\n            .select(cqb.client.raw(`(${aggregateSql}) as \"conditional_rollup_${field.id}\"`))\n            .modify((builder) => this.fromTableWithRestriction(builder, table, mainAlias));\n        },\n        { materialized: preferMaterializedCte }\n      );\n\n      if (joinToMain && !this.state.isCteJoined(cteName)) {\n        this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n        this.state.markCteJoined(cteName);\n      }\n\n      this.state.setFieldCte(field.id, cteName);\n    } finally {\n      this.conditionalRollupGenerationStack.delete(field.id);\n    }\n  }\n\n  private generateConditionalLookupFieldCte(field: FieldCore, options: IConditionalLookupOptions) {\n    this.generateConditionalLookupFieldCteForScope(this.table, field, options);\n  }\n\n  private generateConditionalLookupFieldCteForScope(\n    table: TableDomain,\n    field: FieldCore,\n    options: IConditionalLookupOptions\n  ): void {\n    if (field.hasError) {\n      this.logger.warn(\n        `[ConditionalLookup] Skipping CTE generation for field ${field.id} (${field.name}): field.hasError=true`\n      );\n      return;\n    }\n    if (this.state.getFieldCteMap().has(field.id)) return;\n    if (this.conditionalLookupGenerationStack.has(field.id)) return;\n\n    this.conditionalLookupGenerationStack.add(field.id);\n    try {\n      const { foreignTableId, lookupFieldId, filter, sort, limit } = options;\n      if (!foreignTableId || !lookupFieldId) {\n        this.logger.warn(\n          `[ConditionalLookup] Skipping CTE generation for field ${field.id} (${field.name}): ` +\n            `foreignTableId=${foreignTableId}, lookupFieldId=${lookupFieldId}`\n        );\n        return;\n      }\n\n      const foreignTable = this.tables.getTable(foreignTableId);\n      if (!foreignTable) {\n        this.logger.warn(\n          `[ConditionalLookup] Skipping CTE generation for field ${field.id} (${field.name}): ` +\n            `foreignTable not found for foreignTableId=${foreignTableId}`\n        );\n        return;\n      }\n\n      const targetField = foreignTable.getField(lookupFieldId);\n      if (!targetField) {\n        this.logger.warn(\n          `[ConditionalLookup] Skipping CTE generation for field ${field.id} (${field.name}): ` +\n            `targetField not found for lookupFieldId=${lookupFieldId} in foreignTable=${foreignTableId}`\n        );\n        return;\n      }\n\n      const requiredLinkFields = new Map<string, LinkFieldCore>();\n\n      const ensureLinkDependencies = (candidate?: FieldCore) => {\n        if (!candidate) return;\n        if (candidate.type === FieldType.Link) {\n          const linkField = candidate as LinkFieldCore;\n          requiredLinkFields.set(linkField.id, linkField);\n          if (!this.state.getFieldCteMap().has(linkField.id)) {\n            this.generateLinkFieldCteForTable(foreignTable, linkField);\n          }\n        }\n        for (const linkField of candidate.getLinkFields(foreignTable)) {\n          if (!linkField) continue;\n          requiredLinkFields.set(linkField.id, linkField as LinkFieldCore);\n          if (this.state.getFieldCteMap().has(linkField.id)) continue;\n          this.generateLinkFieldCteForTable(foreignTable, linkField as LinkFieldCore);\n        }\n      };\n      ensureLinkDependencies(targetField);\n      const preferMaterializedCte = this.dbProvider.driver === DriverClient.Pg;\n\n      const joinToMain = table === this.table;\n\n      const cteName = `CTE_CONDITIONAL_LOOKUP_${field.id}`;\n      const mainAlias = getTableAliasFromTable(table);\n      const foreignAlias = getTableAliasFromTable(foreignTable);\n      const foreignAliasUsed = foreignAlias === mainAlias ? `${foreignAlias}_ref` : foreignAlias;\n\n      const selectVisitor = this.createFieldSelectVisitor(\n        foreignTable,\n        foreignAliasUsed,\n        true,\n        !this.expandFormulaReferences\n      );\n\n      const rawExpression = this.resolveConditionalComputedTargetExpression(\n        targetField,\n        foreignTable,\n        foreignAliasUsed,\n        selectVisitor\n      );\n\n      const joinLinkDependencies = (qb: Knex.QueryBuilder) => {\n        for (const linkField of requiredLinkFields.values()) {\n          const cteName = this.getCteNameForField(linkField.id);\n          if (!cteName) continue;\n          qb.leftJoin(cteName, `${foreignAliasUsed}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n        }\n      };\n\n      const aggregateBase = this.qb.client\n        .queryBuilder()\n        .select('*')\n        .from(`${foreignTable.dbTableName} as ${foreignAliasUsed}`);\n\n      joinLinkDependencies(aggregateBase);\n\n      const normalizedExpression = this.coerceConditionalLookupTargetExpression(\n        rawExpression,\n        targetField\n      );\n      const targetValueAlias = `__cl_target_${field.id}`;\n      aggregateBase.select(this.qb.client.raw(`${normalizedExpression} as \"${targetValueAlias}\"`));\n      const projectedTargetExpr = `\"${foreignAliasUsed}\".\"${targetValueAlias}\"`;\n\n      let orderByClause: string | undefined;\n      if (sort?.fieldId) {\n        const sortField = foreignTable.getField(sort.fieldId);\n        if (sortField) {\n          ensureLinkDependencies(sortField);\n\n          let sortExpression = this.resolveConditionalComputedTargetExpression(\n            sortField,\n            foreignTable,\n            foreignAliasUsed,\n            selectVisitor\n          );\n\n          const defaultForeignAlias = getTableAliasFromTable(foreignTable);\n          if (defaultForeignAlias !== foreignAliasUsed) {\n            sortExpression = sortExpression.replaceAll(\n              `\"${defaultForeignAlias}\"`,\n              `\"${foreignAliasUsed}\"`\n            );\n          }\n\n          const direction = sort.order === SortFunc.Desc ? 'DESC' : 'ASC';\n          const sortAlias = `__cl_sort_${sort.fieldId}_${field.id}`;\n          aggregateBase.select(this.qb.client.raw(`${sortExpression} as \"${sortAlias}\"`));\n          orderByClause = `\"${sortAlias}\" ${direction}`;\n        }\n      }\n\n      const aggregateExpressionInfo =\n        field.type === FieldType.ConditionalRollup\n          ? {\n              expression: this.dialect.jsonAggregateNonNull(projectedTargetExpr, orderByClause),\n              isJsonAggregate: true,\n            }\n          : (() => {\n              const expression = this.buildConditionalRollupAggregation(\n                'array_compact({values})',\n                projectedTargetExpr,\n                targetField,\n                foreignAliasUsed,\n                orderByClause\n              );\n              return {\n                expression,\n                isJsonAggregate: JSON_AGG_FUNCTIONS.has('array_compact'),\n              };\n            })();\n      const normalizedAggregateExpression = unwrapJsonAggregateForScalar(\n        this.dbProvider.driver,\n        aggregateExpressionInfo.expression,\n        field,\n        aggregateExpressionInfo.isJsonAggregate\n      );\n      const castedAggregateExpression = this.castExpressionForDbType(\n        normalizedAggregateExpression,\n        field\n      );\n\n      const resolvedLimit = normalizeConditionalLimit(limit);\n      const equalityPlan = this.extractConditionalEqualityJoinPlan(\n        filter,\n        table,\n        foreignTable,\n        mainAlias,\n        foreignAliasUsed\n      );\n      const lookupAlias = `conditional_lookup_${field.id}`;\n      const rollupAlias = `conditional_rollup_${field.id}`;\n\n      const applyConditionalFilter = (\n        targetQb: Knex.QueryBuilder,\n        targetFilter: IFilter | null | undefined = filter\n      ) => {\n        if (!targetFilter) return;\n\n        const fieldMap = foreignTable.fieldList.reduce(\n          (map, f) => {\n            map[f.id] = f as FieldCore;\n            return map;\n          },\n          {} as Record<string, FieldCore>\n        );\n\n        const selectionMap = this.buildConditionalFilterSelectionMap(\n          foreignTable,\n          foreignAliasUsed,\n          targetFilter,\n          selectVisitor\n        );\n\n        const { fieldReferenceSelectionMap, fieldReferenceFieldMap } =\n          this.buildFieldReferenceContext(table, foreignTable, mainAlias, foreignAliasUsed);\n\n        this.dbProvider\n          .filterQuery(targetQb, fieldMap, targetFilter, undefined, {\n            selectionMap,\n            fieldReferenceSelectionMap,\n            fieldReferenceFieldMap,\n          })\n          .appendQueryBuilder();\n      };\n\n      if (equalityPlan?.joinKeys.length) {\n        const partitionClause = equalityPlan.joinKeys.map((cond) => cond.foreignExpr).join(', ');\n        const windowOrder = orderByClause ? ` ORDER BY ${orderByClause}` : '';\n        const windowClause = partitionClause\n          ? `PARTITION BY ${partitionClause}${windowOrder}`\n          : windowOrder.trim();\n        const rowNumberExpr = windowClause\n          ? `ROW_NUMBER() OVER (${windowClause})`\n          : 'ROW_NUMBER() OVER ()';\n\n        const rankedSourceQuery = aggregateBase.clone();\n        applyConditionalFilter(rankedSourceQuery, equalityPlan.residualFilter);\n\n        const rankedWithWindow = this.qb.client\n          .queryBuilder()\n          .from(rankedSourceQuery.as(foreignAliasUsed))\n          .select(`${foreignAliasUsed}.*`)\n          .select(this.qb.client.raw(`${rowNumberExpr} as \"__cl_rank\"`));\n\n        const limitedSourceQuery = this.qb.client\n          .queryBuilder()\n          .from(rankedWithWindow.as(foreignAliasUsed))\n          .select('*')\n          .whereRaw('\"__cl_rank\" <= ?', [resolvedLimit]);\n\n        const aggregateQuery = this.qb.client\n          .queryBuilder()\n          .from(limitedSourceQuery.as(foreignAliasUsed));\n\n        for (const cond of equalityPlan.joinKeys) {\n          aggregateQuery\n            .select(this.qb.client.raw(`${cond.foreignExpr} as \"${cond.alias}\"`))\n            .groupByRaw(cond.foreignExpr);\n        }\n\n        aggregateQuery.select(\n          this.qb.client.raw(`${castedAggregateExpression} as reference_value`)\n        );\n        const aggregateSql = aggregateQuery.toQuery();\n        const joinAlias = `__cl_${field.id}`;\n\n        this.withCte(\n          cteName,\n          (cqb) => {\n            cqb.select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`);\n            cqb.select(cqb.client.raw(`${joinAlias}.\"reference_value\" as \"${lookupAlias}\"`));\n            if (field.type === FieldType.ConditionalRollup) {\n              cqb.select(cqb.client.raw(`${joinAlias}.\"reference_value\" as \"${rollupAlias}\"`));\n            }\n            this.fromTableWithRestriction(cqb, table, mainAlias);\n            cqb.leftJoin(this.qb.client.raw(`(${aggregateSql}) as ${joinAlias}`), (join) => {\n              for (const cond of equalityPlan.joinKeys) {\n                join.on(\n                  this.qb.client.raw(cond.hostExpr),\n                  '=',\n                  this.qb.client.raw(`${joinAlias}.\"${cond.alias}\"`)\n                );\n              }\n            });\n          },\n          { materialized: preferMaterializedCte }\n        );\n\n        if (joinToMain && !this.state.isCteJoined(cteName)) {\n          this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n          this.state.markCteJoined(cteName);\n        }\n\n        this.state.setFieldCte(field.id, cteName);\n        return;\n      }\n\n      const aggregateSourceQuery = aggregateBase.clone();\n      applyConditionalFilter(aggregateSourceQuery);\n\n      if (orderByClause) {\n        aggregateSourceQuery.orderByRaw(orderByClause);\n      }\n\n      aggregateSourceQuery.limit(resolvedLimit);\n\n      const aggregateQuery = this.qb.client\n        .queryBuilder()\n        .from(aggregateSourceQuery.as(foreignAliasUsed));\n\n      aggregateQuery.select(this.qb.client.raw(`${castedAggregateExpression} as reference_value`));\n\n      const aggregateSql = aggregateQuery.toQuery();\n\n      this.withCte(\n        cteName,\n        (cqb) => {\n          cqb.select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`);\n          const makeAggregateSelect = (alias: string) =>\n            cqb.client.raw(`(${aggregateSql}) as \"${alias}\"`);\n          cqb.select(makeAggregateSelect(lookupAlias));\n          if (field.type === FieldType.ConditionalRollup) {\n            cqb.select(makeAggregateSelect(rollupAlias));\n          }\n          this.fromTableWithRestriction(cqb, table, mainAlias);\n        },\n        { materialized: preferMaterializedCte }\n      );\n\n      if (joinToMain && !this.state.isCteJoined(cteName)) {\n        this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n        this.state.markCteJoined(cteName);\n      }\n\n      this.state.setFieldCte(field.id, cteName);\n    } finally {\n      this.conditionalLookupGenerationStack.delete(field.id);\n    }\n  }\n\n  public build() {\n    const list = getOrderedFieldsByProjection(\n      this.table,\n      this.projection,\n      this.expandFormulaReferences\n    ) as FieldCore[];\n    this.filteredIdSet = new Set(list.map((f) => f.id));\n\n    // Ensure CTEs for any link fields that are dependencies of the projected fields.\n    // This allows selecting lookup/rollup values even when the link fields themselves\n    // are not part of the projection.\n    for (const field of list) {\n      const linkFields =\n        !this.expandFormulaReferences && field.type === FieldType.Formula\n          ? []\n          : field.getLinkFields(this.table);\n      for (const lf of linkFields) {\n        if (!lf) continue;\n        if (!this.state.getFieldCteMap().has(lf.id)) {\n          this.generateLinkFieldCte(lf);\n        }\n      }\n\n      if (field.isConditionalLookup) {\n        const options = field.getConditionalLookupOptions?.();\n        if (options) {\n          this.generateConditionalLookupFieldCte(field, options);\n        } else {\n          this.logger.warn(\n            `[ConditionalLookup] getConditionalLookupOptions returned undefined for field ${field.id} (${field.name}). ` +\n              `isConditionalLookup=${field.isConditionalLookup}, lookupOptions=${JSON.stringify(field.lookupOptions)}`\n          );\n        }\n      }\n    }\n\n    for (const field of list) {\n      field.accept(this);\n    }\n  }\n\n  private generateLinkFieldCte(linkField: LinkFieldCore): void {\n    // Avoid defining the same CTE multiple times in a single WITH clause\n    if (this.state.getFieldCteMap().has(linkField.id)) {\n      return;\n    }\n    if (this.linkCteGenerationStack.has(linkField.id)) {\n      return;\n    }\n    const foreignTable = this.tables.getLinkForeignTable(linkField);\n    // Skip CTE generation if foreign table is missing (e.g., deleted)\n    if (!foreignTable) {\n      return;\n    }\n    const cteName = FieldCteVisitor.generateCTENameForField(this.table, linkField);\n    const usesJunctionTable = getLinkUsesJunctionTable(linkField);\n    const options = linkField.options as ILinkFieldOptions;\n    const mainAlias = getTableAliasFromTable(this.table);\n    const foreignAlias = getTableAliasFromTable(foreignTable);\n    const foreignAliasUsed = foreignAlias === mainAlias ? `${foreignAlias}_f` : foreignAlias;\n    const { fkHostTableName, selfKeyName, foreignKeyName, relationship } = options;\n\n    this.linkCteGenerationStack.add(linkField.id);\n    this.pendingLinkCteNames.set(linkField.id, cteName);\n\n    try {\n      const buildLinkCte = () => {\n        // Determine which lookup/rollup fields depend on this link. Even if a field isn't part of\n        // the current projection we still need to expose its computed column, otherwise nested CTEs\n        // that reuse this link cannot reference the precomputed values mid-query.\n        const lookupFields = linkField.getLookupFields(this.table);\n        const rollupFields = linkField.getRollupFields(this.table);\n\n        // Pre-generate nested CTEs limited to selected lookup/rollup dependencies\n        this.generateNestedForeignCtesIfNeeded(\n          this.table,\n          foreignTable,\n          linkField,\n          new Set(lookupFields.map((f) => f.id)),\n          new Set(rollupFields.map((f) => f.id))\n        );\n\n        // Hard guarantee: if any main-table lookup targets a foreign-table lookup, ensure the\n        // foreign link CTE used by that target lookup is generated before referencing it.\n        for (const lk of lookupFields) {\n          const target = lk.getForeignLookupField(foreignTable);\n          const nestedLinkId = target ? getLinkFieldId(target.lookupOptions) : undefined;\n          if (nestedLinkId) {\n            const nestedLink = foreignTable.getField(nestedLinkId) as LinkFieldCore | undefined;\n            if (nestedLink && !this.state.getFieldCteMap().has(nestedLink.id)) {\n              this.generateLinkFieldCteForTable(foreignTable, nestedLink);\n            }\n          }\n        }\n\n        // Collect all nested link dependencies that need to be JOINed\n        const nestedJoins = new Set<string>();\n\n        const ensureConditionalComputedCteForField = (targetField?: FieldCore) => {\n          if (!targetField) {\n            return;\n          }\n          if (targetField.type === FieldType.ConditionalRollup && !targetField.isLookup) {\n            this.generateConditionalRollupFieldCteForScope(\n              foreignTable,\n              targetField as ConditionalRollupFieldCore\n            );\n          }\n          if (targetField.isConditionalLookup) {\n            const options = targetField.getConditionalLookupOptions?.();\n            if (options) {\n              this.generateConditionalLookupFieldCteForScope(foreignTable, targetField, options);\n            }\n          }\n        };\n\n        const ensureLinkDependency = (linkFieldCore?: LinkFieldCore | null) =>\n          this.ensureLinkDependencyForScope(linkFieldCore, foreignTable, linkField.id, nestedJoins);\n\n        const collectLinkDependencies = (\n          field: FieldCore | undefined,\n          visited: Set<string> = new Set()\n        ) => {\n          if (!field || visited.has(field.id)) {\n            return;\n          }\n          visited.add(field.id);\n\n          ensureConditionalComputedCteForField(field);\n\n          if (field.type === FieldType.Link) {\n            ensureLinkDependency(field as LinkFieldCore);\n          }\n\n          const viaLookupId = getLinkFieldId(field.lookupOptions);\n          if (viaLookupId) {\n            const nestedLinkField = foreignTable.getField(viaLookupId) as LinkFieldCore | undefined;\n            ensureLinkDependency(nestedLinkField);\n          }\n\n          const directLinks = field.getLinkFields(foreignTable);\n          for (const lf of directLinks) {\n            ensureLinkDependency(lf);\n          }\n\n          const maybeGetReferenceFields = (\n            field as unknown as {\n              getReferenceFields?: (table: TableDomain) => FieldCore[];\n            }\n          ).getReferenceFields;\n          if (typeof maybeGetReferenceFields === 'function') {\n            if (this.expandFormulaReferences) {\n              const referencedFields = maybeGetReferenceFields.call(field, foreignTable) ?? [];\n              for (const refField of referencedFields) {\n                collectLinkDependencies(refField, visited);\n              }\n            }\n          }\n        };\n\n        // Helper: add dependent link fields from a target field\n        const addDepLinksFromTarget = (field: FieldCore) => {\n          const targetField = field.getForeignLookupField(foreignTable);\n          if (!targetField) return;\n          collectLinkDependencies(targetField);\n        };\n\n        // Ensure lookup-of-link targets bring along their nested link CTEs and are JOINed\n        for (const lookupField of lookupFields) {\n          const nestedLinkId = getLinkFieldId(lookupField.lookupOptions);\n          if (!nestedLinkId) continue;\n          const nestedLinkField = foreignTable.getField(nestedLinkId) as LinkFieldCore | undefined;\n          ensureLinkDependency(nestedLinkField);\n        }\n\n        const ensureDisplayFieldDependencies = () => {\n          const displayFieldIds = new Set<string>();\n          const lookupFieldId = (linkField.options as ILinkFieldOptions).lookupFieldId;\n          if (lookupFieldId) {\n            displayFieldIds.add(lookupFieldId);\n          }\n          const primaryField = foreignTable.getPrimaryField();\n          if (primaryField?.id) {\n            displayFieldIds.add(primaryField.id);\n          }\n\n          for (const displayFieldId of displayFieldIds) {\n            const displayField = foreignTable.getField(displayFieldId) as FieldCore | undefined;\n            if (displayField) {\n              collectLinkDependencies(displayField);\n            }\n          }\n        };\n\n        ensureDisplayFieldDependencies();\n\n        // Explicitly join nested link CTEs referenced by lookup-of-link targets so lookup values\n        // remain available when the target field itself is a lookup.\n        for (const lookupField of lookupFields) {\n          const nestedLinkId = getLinkFieldId(lookupField.lookupOptions);\n          if (!nestedLinkId) continue;\n          const nestedLinkField = foreignTable.getField(nestedLinkId) as LinkFieldCore | undefined;\n          ensureLinkDependency(nestedLinkField);\n        }\n\n        if (process.env.DEBUG_NESTED_CTE === '1' && nestedJoins.size) {\n          // eslint-disable-next-line no-console\n          console.log('[FieldCteVisitor] nested CTE dependencies', {\n            linkFieldId: linkField.id,\n            linkFieldName: linkField.name,\n            relationship,\n            usesJunctionTable,\n            nested: Array.from(nestedJoins),\n          });\n        }\n\n        // Check lookup fields: collect all dependent link fields\n        for (const lookupField of lookupFields) {\n          addDepLinksFromTarget(lookupField);\n        }\n\n        // Check rollup fields: collect all dependent link fields\n        for (const rollupField of rollupFields) {\n          addDepLinksFromTarget(rollupField);\n        }\n\n        addDepLinksFromTarget(linkField);\n\n        this.qb\n          // eslint-disable-next-line sonarjs/cognitive-complexity\n          .with(cteName, (cqb) => {\n            // Create set of JOINed CTEs for this scope\n            const joinedCtesInScope = new Set(nestedJoins);\n            const blockedLinkFieldIds = this.getBlockedLinkFieldIds(linkField.id);\n            const readyLinkFieldIds = this.getReadyLinkFieldIdsSnapshotForVisitor();\n\n            const visitor = new FieldCteSelectionVisitor(\n              cqb,\n              this.dbProvider,\n              this.dialect,\n              this.table,\n              foreignTable,\n              this.state,\n              joinedCtesInScope,\n              usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n              foreignAliasUsed,\n              linkField.id,\n              blockedLinkFieldIds,\n              readyLinkFieldIds\n            );\n            const linkValue = linkField.accept(visitor);\n\n            cqb.select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`);\n            // Ensure jsonb type on Postgres to avoid type mismatch (e.g., NULL defaults)\n            const linkValueExpr =\n              this.dbProvider.driver === DriverClient.Pg ? `${linkValue}::jsonb` : `${linkValue}`;\n            cqb.select(cqb.client.raw(`${linkValueExpr} as link_value`));\n\n            for (const lookupField of lookupFields) {\n              const visitor = new FieldCteSelectionVisitor(\n                cqb,\n                this.dbProvider,\n                this.dialect,\n                this.table,\n                foreignTable,\n                this.state,\n                joinedCtesInScope,\n                usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n                foreignAliasUsed,\n                linkField.id,\n                blockedLinkFieldIds,\n                readyLinkFieldIds\n              );\n              const lookupValue = lookupField.accept(visitor);\n              cqb.select(cqb.client.raw(`${lookupValue} as \"lookup_${lookupField.id}\"`));\n            }\n\n            for (const rollupField of rollupFields) {\n              const visitor = new FieldCteSelectionVisitor(\n                cqb,\n                this.dbProvider,\n                this.dialect,\n                this.table,\n                foreignTable,\n                this.state,\n                joinedCtesInScope,\n                usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n                foreignAliasUsed,\n                linkField.id,\n                blockedLinkFieldIds,\n                readyLinkFieldIds\n              );\n              const rollupValue = rollupField.accept(visitor);\n              cqb.select(cqb.client.raw(`${rollupValue} as \"rollup_${rollupField.id}\"`));\n            }\n\n            if (usesJunctionTable) {\n              if (process.env.DEBUG_NESTED_CTE === '1') {\n                // eslint-disable-next-line no-console\n                console.log('[FieldCteVisitor] join scope (junction)', {\n                  linkFieldId: linkField.id,\n                  relationship,\n                  nestedCount: nestedJoins.size,\n                });\n              }\n              this.fromTableWithRestriction(cqb, this.table, mainAlias);\n              cqb\n                .leftJoin(\n                  `${fkHostTableName} as ${JUNCTION_ALIAS}`,\n                  `${mainAlias}.__id`,\n                  `${JUNCTION_ALIAS}.${selfKeyName}`\n                )\n                .leftJoin(\n                  `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                  `${JUNCTION_ALIAS}.${foreignKeyName}`,\n                  `${foreignAliasUsed}.__id`\n                );\n\n              // Add LEFT JOINs to nested CTEs\n              for (const nestedLinkFieldId of nestedJoins) {\n                const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n                if (!nestedCteName) {\n                  continue;\n                }\n                cqb.leftJoin(\n                  nestedCteName,\n                  `${nestedCteName}.main_record_id`,\n                  `${foreignAliasUsed}.__id`\n                );\n              }\n\n              // Removed global application of all lookup/rollup filters: we now apply per-field filters only at selection time\n\n              cqb.groupBy(`${mainAlias}.__id`);\n\n              // For SQLite, add ORDER BY at query level since json_group_array doesn't support internal ordering\n              if (this.dbProvider.driver === DriverClient.Sqlite) {\n                cqb.orderBy(`${JUNCTION_ALIAS}.__id`);\n              }\n            } else if (relationship === Relationship.OneMany) {\n              if (process.env.DEBUG_NESTED_CTE === '1') {\n                // eslint-disable-next-line no-console\n                console.log('[FieldCteVisitor] join scope (one-many)', {\n                  linkFieldId: linkField.id,\n                  relationship,\n                  nestedCount: nestedJoins.size,\n                });\n              }\n              // For non-one-way OneMany relationships, foreign key is stored in the foreign table\n              // No junction table needed\n\n              this.fromTableWithRestriction(cqb, this.table, mainAlias);\n              cqb.leftJoin(\n                `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                `${mainAlias}.__id`,\n                `${foreignAliasUsed}.${selfKeyName}`\n              );\n\n              // Add LEFT JOINs to nested CTEs\n              for (const nestedLinkFieldId of nestedJoins) {\n                const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n                if (!nestedCteName) {\n                  continue;\n                }\n                cqb.leftJoin(\n                  nestedCteName,\n                  `${nestedCteName}.main_record_id`,\n                  `${foreignAliasUsed}.__id`\n                );\n              }\n\n              // Removed global application of all lookup/rollup filters\n\n              cqb.groupBy(`${mainAlias}.__id`);\n\n              // For SQLite, add ORDER BY at query level (NULLS FIRST + stable tie-breaker)\n              if (this.dbProvider.driver === DriverClient.Sqlite) {\n                if (linkField.getHasOrderColumn()) {\n                  cqb.orderByRaw(\n                    `(CASE WHEN ${foreignAliasUsed}.${selfKeyName}_order IS NULL THEN 0 ELSE 1 END) ASC`\n                  );\n                  cqb.orderBy(`${foreignAliasUsed}.${selfKeyName}_order`, 'asc');\n                }\n                // Always tie-break by record id for deterministic order\n                cqb.orderBy(`${foreignAliasUsed}.__id`, 'asc');\n              }\n            } else if (\n              relationship === Relationship.ManyOne ||\n              relationship === Relationship.OneOne\n            ) {\n              // Direct join for many-to-one and one-to-one relationships\n              // No GROUP BY needed for single-value relationships\n\n              // For OneOne and ManyOne relationships, the foreign key is always stored in fkHostTableName\n              // But we need to determine the correct join condition based on which table we're querying from\n              const isForeignKeyInMainTable = fkHostTableName === this.table.dbTableName;\n\n              this.fromTableWithRestriction(cqb, this.table, mainAlias);\n\n              if (isForeignKeyInMainTable) {\n                // Foreign key is stored in the main table (original field case)\n                // Join: main_table.foreign_key_column = foreign_table.__id\n                cqb.leftJoin(\n                  `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                  `${mainAlias}.${foreignKeyName}`,\n                  `${foreignAliasUsed}.__id`\n                );\n              } else {\n                // Foreign key is stored in the foreign table (symmetric field case)\n                // Join: foreign_table.foreign_key_column = main_table.__id\n                // Note: for symmetric fields, selfKeyName and foreignKeyName are swapped\n                cqb.leftJoin(\n                  `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                  `${foreignAliasUsed}.${selfKeyName}`,\n                  `${mainAlias}.__id`\n                );\n              }\n\n              // Removed global application of all lookup/rollup filters\n\n              // Add LEFT JOINs to nested CTEs for single-value relationships\n              for (const nestedLinkFieldId of nestedJoins) {\n                const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n                if (!nestedCteName) {\n                  continue;\n                }\n                cqb.leftJoin(\n                  nestedCteName,\n                  `${nestedCteName}.main_record_id`,\n                  `${foreignAliasUsed}.__id`\n                );\n              }\n            }\n          });\n\n        if (!this.state.isCteJoined(cteName)) {\n          this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n          this.state.markCteJoined(cteName);\n        }\n      };\n\n      buildLinkCte();\n      this.state.setFieldCte(linkField.id, cteName);\n      this.emittedLinkCteIds.add(linkField.id);\n    } finally {\n      this.linkCteGenerationStack.delete(linkField.id);\n      this.pendingLinkCteNames.delete(linkField.id);\n    }\n  }\n\n  /**\n   * Generate CTEs for foreign table's dependent link fields if any of the lookup/rollup targets\n   * on the current link field point to lookup fields in the foreign table.\n   * This ensures multi-layer lookup/rollup can reference precomputed values via nested CTEs.\n   */\n  private generateNestedForeignCtesIfNeeded(\n    mainTable: TableDomain,\n    foreignTable: TableDomain,\n    mainToForeignLinkField: LinkFieldCore,\n    limitLookupIds?: Set<string>,\n    limitRollupIds?: Set<string>\n  ): void {\n    const nestedLinkFields = new Map<string, LinkFieldCore>();\n    const ensureConditionalComputedCte = (table: TableDomain, targetField?: FieldCore) => {\n      if (!targetField) return;\n      if (targetField.type === FieldType.ConditionalRollup && !targetField.isLookup) {\n        this.generateConditionalRollupFieldCteForScope(\n          table,\n          targetField as ConditionalRollupFieldCore\n        );\n      }\n      if (targetField.isConditionalLookup) {\n        const options = targetField.getConditionalLookupOptions?.();\n        if (options) {\n          this.generateConditionalLookupFieldCteForScope(table, targetField, options);\n        }\n      }\n    };\n\n    // Collect lookup fields on main table that depend on this link\n    let lookupFields = mainToForeignLinkField.getLookupFields(mainTable);\n    if (limitLookupIds) {\n      lookupFields = lookupFields.filter((f) => limitLookupIds.has(f.id));\n    }\n    for (const lookupField of lookupFields) {\n      const target = lookupField.getForeignLookupField(foreignTable);\n      if (target) {\n        ensureConditionalComputedCte(foreignTable, target);\n        if (target.type === FieldType.Link) {\n          const lf = target as LinkFieldCore;\n          if (!nestedLinkFields.has(lf.id)) nestedLinkFields.set(lf.id, lf);\n        }\n        for (const lf of target.getLinkFields(foreignTable)) {\n          if (!nestedLinkFields.has(lf.id)) nestedLinkFields.set(lf.id, lf);\n        }\n      } else {\n        const nestedId = lookupField.lookupOptions?.lookupFieldId;\n        const nestedField = nestedId ? foreignTable.getField(nestedId) : undefined;\n        if (\n          nestedField &&\n          nestedField.type === FieldType.Link &&\n          !nestedLinkFields.has(nestedField.id)\n        ) {\n          nestedLinkFields.set(nestedField.id, nestedField as LinkFieldCore);\n        }\n        ensureConditionalComputedCte(foreignTable, nestedField);\n      }\n    }\n\n    // Collect rollup fields on main table that depend on this link\n    let rollupFields = mainToForeignLinkField.getRollupFields(mainTable);\n    if (limitRollupIds) {\n      rollupFields = rollupFields.filter((f) => limitRollupIds.has(f.id));\n    }\n    for (const rollupField of rollupFields) {\n      const target = rollupField.getForeignLookupField(foreignTable);\n      if (target) {\n        ensureConditionalComputedCte(foreignTable, target);\n        if (target.type === FieldType.Link) {\n          const lf = target as LinkFieldCore;\n          if (!nestedLinkFields.has(lf.id)) nestedLinkFields.set(lf.id, lf);\n        }\n        for (const lf of target.getLinkFields(foreignTable)) {\n          if (!nestedLinkFields.has(lf.id)) nestedLinkFields.set(lf.id, lf);\n        }\n      } else {\n        const nestedId = rollupField.lookupOptions?.lookupFieldId;\n        const nestedField = nestedId ? foreignTable.getField(nestedId) : undefined;\n        if (\n          nestedField &&\n          nestedField.type === FieldType.Link &&\n          !nestedLinkFields.has(nestedField.id)\n        ) {\n          nestedLinkFields.set(nestedField.id, nestedField as LinkFieldCore);\n        }\n        ensureConditionalComputedCte(foreignTable, nestedField);\n      }\n    }\n\n    // Generate CTEs for each nested link field on the foreign table if not already generated\n    for (const [nestedLinkFieldId, nestedLinkFieldCore] of nestedLinkFields) {\n      if (this.state.getFieldCteMap().has(nestedLinkFieldId)) continue;\n      this.generateLinkFieldCteForTable(foreignTable, nestedLinkFieldCore);\n    }\n  }\n\n  /**\n   * Generate CTE for a link field using the provided table as the \"main\" table context.\n   * This is used to build nested CTEs for foreign tables.\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private generateLinkFieldCteForTable(table: TableDomain, linkField: LinkFieldCore): void {\n    if (this.fieldCteMap.has(linkField.id)) {\n      return;\n    }\n    if (this.linkCteGenerationStack.has(linkField.id)) {\n      return;\n    }\n    const foreignTable = this.tables.getLinkForeignTable(linkField);\n    if (!foreignTable) {\n      return;\n    }\n    const cteName = FieldCteVisitor.generateCTENameForField(table, linkField);\n    const usesJunctionTable = getLinkUsesJunctionTable(linkField);\n    const options = linkField.options as ILinkFieldOptions;\n    const mainAlias = getTableAliasFromTable(table);\n    const foreignAlias = getTableAliasFromTable(foreignTable);\n    const foreignAliasUsed = foreignAlias === mainAlias ? `${foreignAlias}_f` : foreignAlias;\n    const { fkHostTableName, selfKeyName, foreignKeyName, relationship } = options;\n\n    this.linkCteGenerationStack.add(linkField.id);\n    this.pendingLinkCteNames.set(linkField.id, cteName);\n\n    try {\n      const buildForeignLinkCte = () => {\n        // Ensure deeper nested dependencies for this nested link are also generated\n        this.generateNestedForeignCtesIfNeeded(table, foreignTable, linkField);\n\n        const ensureConditionalComputedCteForField = (targetField?: FieldCore) => {\n          if (!targetField) {\n            return;\n          }\n          if (targetField.type === FieldType.ConditionalRollup && !targetField.isLookup) {\n            this.generateConditionalRollupFieldCteForScope(\n              foreignTable,\n              targetField as ConditionalRollupFieldCore\n            );\n          }\n          if (targetField.isConditionalLookup) {\n            const options = targetField.getConditionalLookupOptions?.();\n            if (options) {\n              this.generateConditionalLookupFieldCteForScope(foreignTable, targetField, options);\n            }\n          }\n        };\n\n        const ensureLinkDependency = (linkFieldCore?: LinkFieldCore | null) =>\n          this.ensureLinkDependencyForScope(linkFieldCore, foreignTable, linkField.id, nestedJoins);\n\n        // Collect all nested link dependencies that need to be JOINed\n        const nestedJoins = new Set<string>();\n        const lookupFields = linkField.getLookupFields(table);\n        const rollupFields = linkField.getRollupFields(table);\n        if (this.filteredIdSet) {\n          // filteredIdSet belongs to the main table. For nested tables, we cannot filter\n          // by main-table projection IDs; keep all nested lookup/rollup columns to ensure correctness.\n        }\n\n        const collectLinkDependencies = (\n          field: FieldCore | undefined,\n          visited: Set<string> = new Set()\n        ) => {\n          if (!field || visited.has(field.id)) {\n            return;\n          }\n          visited.add(field.id);\n\n          ensureConditionalComputedCteForField(field);\n\n          if (field.type === FieldType.Link) {\n            ensureLinkDependency(field as LinkFieldCore);\n          }\n\n          const viaLookupId = getLinkFieldId(field.lookupOptions);\n          if (viaLookupId) {\n            const nestedLinkField = foreignTable.getField(viaLookupId) as LinkFieldCore | undefined;\n            ensureLinkDependency(nestedLinkField);\n          }\n\n          const directLinks = field.getLinkFields(foreignTable);\n          for (const lf of directLinks) {\n            ensureLinkDependency(lf);\n          }\n\n          const maybeGetReferenceFields = (\n            field as unknown as {\n              getReferenceFields?: (table: TableDomain) => FieldCore[];\n            }\n          ).getReferenceFields;\n          if (typeof maybeGetReferenceFields === 'function') {\n            const referencedFields = maybeGetReferenceFields.call(field, foreignTable) ?? [];\n            for (const refField of referencedFields) {\n              collectLinkDependencies(refField, visited);\n            }\n          }\n        };\n\n        // Check if any lookup/rollup fields depend on nested CTEs\n        for (const lookupField of lookupFields) {\n          const target = lookupField.getForeignLookupField(foreignTable);\n          if (target) {\n            collectLinkDependencies(target);\n          }\n        }\n\n        for (const rollupField of rollupFields) {\n          const target = rollupField.getForeignLookupField(foreignTable);\n          if (target) {\n            collectLinkDependencies(target);\n          }\n        }\n\n        collectLinkDependencies(linkField.getForeignLookupField(foreignTable));\n\n        this.qb.with(cteName, (cqb) => {\n          // Create set of JOINed CTEs for this scope\n          const joinedCtesInScope = new Set(nestedJoins);\n          const blockedLinkFieldIds = this.getBlockedLinkFieldIds(linkField.id);\n          const readyLinkFieldIds = this.getReadyLinkFieldIdsSnapshotForVisitor();\n\n          const visitor = new FieldCteSelectionVisitor(\n            cqb,\n            this.dbProvider,\n            this.dialect,\n            table,\n            foreignTable,\n            this.state,\n            joinedCtesInScope,\n            usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n            foreignAliasUsed,\n            linkField.id,\n            blockedLinkFieldIds,\n            readyLinkFieldIds\n          );\n          const linkValue = linkField.accept(visitor);\n\n          cqb.select(`${mainAlias}.${ID_FIELD_NAME} as main_record_id`);\n          // Ensure jsonb type on Postgres to avoid type mismatch (e.g., NULL defaults)\n          const linkValueExpr =\n            this.dbProvider.driver === DriverClient.Pg ? `${linkValue}::jsonb` : `${linkValue}`;\n          cqb.select(cqb.client.raw(`${linkValueExpr} as link_value`));\n\n          for (const lookupField of lookupFields) {\n            const visitor = new FieldCteSelectionVisitor(\n              cqb,\n              this.dbProvider,\n              this.dialect,\n              table,\n              foreignTable,\n              this.state,\n              joinedCtesInScope,\n              usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n              foreignAliasUsed,\n              linkField.id,\n              blockedLinkFieldIds,\n              readyLinkFieldIds\n            );\n            const lookupValue = lookupField.accept(visitor);\n            cqb.select(cqb.client.raw(`${lookupValue} as \"lookup_${lookupField.id}\"`));\n          }\n\n          for (const rollupField of rollupFields) {\n            const visitor = new FieldCteSelectionVisitor(\n              cqb,\n              this.dbProvider,\n              this.dialect,\n              table,\n              foreignTable,\n              this.state,\n              joinedCtesInScope,\n              usesJunctionTable || relationship === Relationship.OneMany ? false : true,\n              foreignAliasUsed,\n              linkField.id,\n              blockedLinkFieldIds,\n              readyLinkFieldIds\n            );\n            const rollupValue = rollupField.accept(visitor);\n            // Ensure the rollup CTE column has a type that matches the physical column\n            // to avoid Postgres UPDATE ... FROM assignment type mismatches (e.g., text vs numeric).\n            const value = typeof rollupValue === 'string' ? rollupValue : rollupValue.toQuery();\n            const castedRollupValue = this.castExpressionForDbType(value, rollupField);\n            cqb.select(cqb.client.raw(`${castedRollupValue} as \"rollup_${rollupField.id}\"`));\n          }\n\n          if (usesJunctionTable) {\n            this.fromTableWithRestriction(cqb, table, mainAlias);\n            cqb\n              .leftJoin(\n                `${fkHostTableName} as ${JUNCTION_ALIAS}`,\n                `${mainAlias}.__id`,\n                `${JUNCTION_ALIAS}.${selfKeyName}`\n              )\n              .leftJoin(\n                `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                `${JUNCTION_ALIAS}.${foreignKeyName}`,\n                `${foreignAliasUsed}.__id`\n              );\n\n            // Add LEFT JOINs to nested CTEs\n            for (const nestedLinkFieldId of nestedJoins) {\n              const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n              if (!nestedCteName) {\n                if (process.env.DEBUG_NESTED_CTE === '1') {\n                  // eslint-disable-next-line no-console\n                  console.log('[FieldCteVisitor] missing nested CTE mapping', {\n                    linkFieldId: linkField.id,\n                    nestedLinkFieldId,\n                    relationship,\n                  });\n                }\n                continue;\n              }\n              if (process.env.DEBUG_NESTED_CTE === '1') {\n                // eslint-disable-next-line no-console\n                console.log('[FieldCteVisitor] joining nested CTE', {\n                  linkFieldId: linkField.id,\n                  nestedLinkFieldId,\n                  nestedCteName,\n                  relationship,\n                });\n              }\n              cqb.leftJoin(\n                nestedCteName,\n                `${nestedCteName}.main_record_id`,\n                `${foreignAliasUsed}.__id`\n              );\n            }\n\n            cqb.groupBy(`${mainAlias}.__id`);\n\n            if (this.dbProvider.driver === DriverClient.Sqlite) {\n              if (linkField.getHasOrderColumn()) {\n                const ordCol = `${JUNCTION_ALIAS}.${linkField.getOrderColumnName()}`;\n                cqb.orderByRaw(`(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC`);\n                cqb.orderBy(ordCol, 'asc');\n              }\n              cqb.orderBy(`${JUNCTION_ALIAS}.__id`, 'asc');\n            }\n          } else if (relationship === Relationship.OneMany) {\n            this.fromTableWithRestriction(cqb, table, mainAlias);\n            cqb.leftJoin(\n              `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n              `${mainAlias}.__id`,\n              `${foreignAliasUsed}.${selfKeyName}`\n            );\n\n            // Add LEFT JOINs to nested CTEs\n            for (const nestedLinkFieldId of nestedJoins) {\n              const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n              if (!nestedCteName) {\n                continue;\n              }\n              if (process.env.DEBUG_NESTED_CTE === '1') {\n                // eslint-disable-next-line no-console\n                console.log('[FieldCteVisitor] joining nested CTE', {\n                  linkFieldId: linkField.id,\n                  nestedLinkFieldId,\n                  nestedCteName,\n                  relationship,\n                });\n              }\n              cqb.leftJoin(\n                nestedCteName,\n                `${nestedCteName}.main_record_id`,\n                `${foreignAliasUsed}.__id`\n              );\n            }\n\n            cqb.groupBy(`${mainAlias}.__id`);\n\n            if (this.dbProvider.driver === DriverClient.Sqlite) {\n              if (linkField.getHasOrderColumn()) {\n                cqb.orderByRaw(\n                  `(CASE WHEN ${foreignAliasUsed}.${selfKeyName}_order IS NULL THEN 0 ELSE 1 END) ASC`\n                );\n                cqb.orderBy(`${foreignAliasUsed}.${selfKeyName}_order`, 'asc');\n              }\n              cqb.orderBy(`${foreignAliasUsed}.__id`, 'asc');\n            }\n          } else if (\n            relationship === Relationship.ManyOne ||\n            relationship === Relationship.OneOne\n          ) {\n            const isForeignKeyInMainTable = fkHostTableName === table.dbTableName;\n            this.fromTableWithRestriction(cqb, table, mainAlias);\n\n            if (isForeignKeyInMainTable) {\n              cqb.leftJoin(\n                `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                `${mainAlias}.${foreignKeyName}`,\n                `${foreignAliasUsed}.__id`\n              );\n            } else {\n              cqb.leftJoin(\n                `${foreignTable.dbTableName} as ${foreignAliasUsed}`,\n                `${foreignAliasUsed}.${selfKeyName}`,\n                `${mainAlias}.__id`\n              );\n            }\n\n            // Add LEFT JOINs to nested CTEs for single-value relationships\n            for (const nestedLinkFieldId of nestedJoins) {\n              const nestedCteName = this.getCteNameForField(nestedLinkFieldId);\n              if (!nestedCteName) {\n                if (process.env.DEBUG_NESTED_CTE === '1') {\n                  // eslint-disable-next-line no-console\n                  console.log('[FieldCteVisitor] missing nested CTE mapping', {\n                    linkFieldId: linkField.id,\n                    nestedLinkFieldId,\n                    relationship,\n                  });\n                }\n                continue;\n              }\n              if (process.env.DEBUG_NESTED_CTE === '1') {\n                // eslint-disable-next-line no-console\n                console.log('[FieldCteVisitor] joining nested CTE', {\n                  linkFieldId: linkField.id,\n                  nestedLinkFieldId,\n                  nestedCteName,\n                  relationship,\n                });\n              }\n              cqb.leftJoin(\n                nestedCteName,\n                `${nestedCteName}.main_record_id`,\n                `${foreignAliasUsed}.__id`\n              );\n            }\n          }\n        });\n      };\n\n      buildForeignLinkCte();\n      this.state.setFieldCte(linkField.id, cteName);\n      this.emittedLinkCteIds.add(linkField.id);\n    } finally {\n      this.linkCteGenerationStack.delete(linkField.id);\n      this.pendingLinkCteNames.delete(linkField.id);\n    }\n  }\n\n  visitNumberField(_field: NumberFieldCore): void {}\n  visitSingleLineTextField(_field: SingleLineTextFieldCore): void {}\n  visitLongTextField(_field: LongTextFieldCore): void {}\n  visitAttachmentField(_field: AttachmentFieldCore): void {}\n  visitCheckboxField(_field: CheckboxFieldCore): void {}\n  visitDateField(_field: DateFieldCore): void {}\n  visitRatingField(_field: RatingFieldCore): void {}\n  visitAutoNumberField(_field: AutoNumberFieldCore): void {}\n  visitLinkField(field: LinkFieldCore): void {\n    if (field.hasError) return;\n    const existingCteName = this.state.getCteName(field.id);\n    if (existingCteName) {\n      this.ensureLinkCteJoined(existingCteName);\n      return;\n    }\n    this.generateLinkFieldCte(field);\n  }\n  visitRollupField(_field: RollupFieldCore): void {}\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): void {\n    if (field.isLookup) {\n      return;\n    }\n    this.generateConditionalRollupFieldCte(field);\n  }\n  visitSingleSelectField(_field: SingleSelectFieldCore): void {}\n  visitMultipleSelectField(_field: MultipleSelectFieldCore): void {}\n  visitFormulaField(_field: FormulaFieldCore): void {}\n  visitCreatedTimeField(_field: CreatedTimeFieldCore): void {}\n  visitLastModifiedTimeField(_field: LastModifiedTimeFieldCore): void {}\n  visitUserField(_field: UserFieldCore): void {}\n  visitCreatedByField(_field: CreatedByFieldCore): void {}\n  visitLastModifiedByField(_field: LastModifiedByFieldCore): void {}\n  visitButtonField(_field: ButtonFieldCore): void {}\n\n  private ensureLinkCteJoined(cteName: string): void {\n    if (this.state.isCteJoined(cteName)) {\n      return;\n    }\n    const mainAlias = getTableAliasFromTable(this.table);\n    this.qb.leftJoin(cteName, `${mainAlias}.${ID_FIELD_NAME}`, `${cteName}.main_record_id`);\n    this.state.markCteJoined(cteName);\n  }\n}\nconst getLinkFieldId = (options: FieldCore['lookupOptions']): string | undefined => {\n  return options && isLinkLookupOptions(options) ? options.linkFieldId : undefined;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts",
    "content": "import {\n  type IFieldVisitor,\n  type SingleLineTextFieldCore,\n  type LongTextFieldCore,\n  type NumberFieldCore,\n  type CheckboxFieldCore,\n  type DateFieldCore,\n  type RatingFieldCore,\n  type AutoNumberFieldCore,\n  type SingleSelectFieldCore,\n  type MultipleSelectFieldCore,\n  type AttachmentFieldCore,\n  type LinkFieldCore,\n  type RollupFieldCore,\n  type ConditionalRollupFieldCore,\n  type FormulaFieldCore,\n  CellValueType,\n  type CreatedTimeFieldCore,\n  type LastModifiedTimeFieldCore,\n  type UserFieldCore,\n  type CreatedByFieldCore,\n  type LastModifiedByFieldCore,\n  type ButtonFieldCore,\n  type INumberFormatting,\n  type IDatetimeFormatting,\n} from '@teable/core';\nimport { match, P } from 'ts-pattern';\nimport type { IRecordQueryDialectProvider } from './record-query-dialect.interface';\n\n/**\n * Field formatting visitor that converts field cellValue2String logic to SQL expressions\n */\nexport class FieldFormattingVisitor implements IFieldVisitor<string> {\n  constructor(\n    private readonly fieldExpression: string,\n    private readonly dialect: IRecordQueryDialectProvider\n  ) {}\n\n  /**\n   * Convert field expression to text/string format for database-specific SQL\n   */\n  private convertToText(): string {\n    return this.dialect.toText(this.fieldExpression);\n  }\n\n  /**\n   * Apply number formatting to field expression\n   */\n  private applyNumberFormatting(formatting: INumberFormatting): string {\n    return this.dialect.formatNumber(this.fieldExpression, formatting);\n  }\n\n  /**\n   * Apply number formatting to a custom numeric expression\n   * Useful for formatting per-element inside JSON array iteration\n   */\n  private applyNumberFormattingTo(expression: string, formatting: INumberFormatting): string {\n    return this.dialect.formatNumber(expression, formatting);\n  }\n\n  /**\n   * Format multiple numeric values contained in a JSON array to a comma-separated string\n   */\n  private formatMultipleNumberValues(formatting: INumberFormatting): string {\n    return this.dialect.formatNumberArray(this.fieldExpression, formatting);\n  }\n\n  /**\n   * Apply date/time formatting to field expression\n   */\n  private applyDateFormatting(formatting: IDatetimeFormatting): string {\n    return this.dialect.formatDate(this.fieldExpression, formatting);\n  }\n\n  /**\n   * Format multiple datetime values contained in a JSON array\n   */\n  private formatMultipleDateValues(formatting: IDatetimeFormatting): string {\n    return this.dialect.formatDateArray(this.fieldExpression, formatting);\n  }\n\n  /**\n   * Format multiple string values (like multiple select) to comma-separated string\n   * Also handles link field arrays with objects containing id and title\n   */\n  private formatMultipleStringValues(\n    field?:\n      | SingleSelectFieldCore\n      | MultipleSelectFieldCore\n      | UserFieldCore\n      | CreatedByFieldCore\n      | LastModifiedByFieldCore\n      | FormulaFieldCore\n  ): string {\n    const fieldInfo = field ? { fieldInfo: field } : undefined;\n    return this.dialect.formatStringArray(this.fieldExpression, fieldInfo);\n  }\n\n  visitSingleLineTextField(_field: SingleLineTextFieldCore): string {\n    // Text fields don't need special formatting, return as-is\n    return this.fieldExpression;\n  }\n\n  visitLongTextField(_field: LongTextFieldCore): string {\n    // Text fields don't need special formatting, return as-is\n    return this.fieldExpression;\n  }\n\n  visitNumberField(field: NumberFieldCore): string {\n    const formatting = field.options.formatting;\n    if (field.isMultipleCellValue) {\n      return this.formatMultipleNumberValues(formatting);\n    }\n    return this.applyNumberFormatting(formatting);\n  }\n\n  visitCheckboxField(_field: CheckboxFieldCore): string {\n    // Checkbox fields are stored as boolean, convert to string\n    return this.convertToText();\n  }\n\n  visitDateField(_field: DateFieldCore): string {\n    if (_field.options?.formatting) {\n      if (_field.isMultipleCellValue) {\n        return this.formatMultipleDateValues(_field.options.formatting);\n      }\n      return this.applyDateFormatting(_field.options.formatting);\n    }\n    return this.fieldExpression;\n  }\n\n  visitRatingField(_field: RatingFieldCore): string {\n    // Rating fields should display without trailing .0\n    // If value is an integer, render as integer text; otherwise, fall back to generic number->text\n    return this.dialect.formatRating(this.fieldExpression);\n  }\n\n  visitAutoNumberField(_field: AutoNumberFieldCore): string {\n    // Auto number fields are numbers, convert to string\n    return this.convertToText();\n  }\n\n  visitSingleSelectField(_field: SingleSelectFieldCore): string {\n    // Select fields are stored as strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitMultipleSelectField(_field: MultipleSelectFieldCore): string {\n    // Multiple select fields are stored as strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitAttachmentField(_field: AttachmentFieldCore): string {\n    // Attachment fields are complex, for now return as-is\n    return this.fieldExpression;\n  }\n\n  visitLinkField(_field: LinkFieldCore): string {\n    if (_field.isMultipleCellValue) {\n      // Extract titles from link arrays in a deterministic order\n      return this.dialect.formatStringArray(this.fieldExpression, { fieldInfo: _field });\n    }\n    // Single link: read the embedded title from the JSON object\n    return this.dialect.jsonTitleFromExpr(this.fieldExpression);\n  }\n\n  visitRollupField(_field: RollupFieldCore): string {\n    // Rollup fields depend on their result type, for now return as-is\n    return this.fieldExpression;\n  }\n\n  visitConditionalRollupField(_field: ConditionalRollupFieldCore): string {\n    return this.fieldExpression;\n  }\n\n  visitFormulaField(field: FormulaFieldCore): string {\n    // Formula fields need formatting based on their result type and formatting options\n    const { cellValueType, options, isMultipleCellValue } = field;\n    const formatting = options.formatting;\n\n    // Apply formatting based on the formula's result type using match pattern\n    return match({ cellValueType, formatting, isMultipleCellValue })\n      .with(\n        {\n          cellValueType: CellValueType.Number,\n          formatting: P.not(P.nullish),\n          isMultipleCellValue: true,\n        },\n        ({ formatting }) => this.formatMultipleNumberValues(formatting as INumberFormatting)\n      )\n      .with(\n        { cellValueType: CellValueType.Number, formatting: P.not(P.nullish) },\n        ({ formatting }) => this.applyNumberFormatting(formatting as INumberFormatting)\n      )\n      .with(\n        { cellValueType: CellValueType.DateTime, formatting: P.not(P.nullish) },\n        ({ formatting, isMultipleCellValue }) => {\n          const datetimeFormatting = formatting as IDatetimeFormatting;\n          if (isMultipleCellValue) {\n            return this.formatMultipleDateValues(datetimeFormatting);\n          }\n          return this.applyDateFormatting(datetimeFormatting);\n        }\n      )\n      .with({ cellValueType: CellValueType.String, isMultipleCellValue: true }, () => {\n        // For multiple-value string fields (like multiple select), convert array to comma-separated string\n        return this.formatMultipleStringValues(field);\n      })\n      .otherwise(() => {\n        // For other cell value types (single String, Boolean), return as-is\n        return this.fieldExpression;\n      });\n  }\n\n  visitCreatedTimeField(_field: CreatedTimeFieldCore): string {\n    // Created time fields are stored as ISO strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitLastModifiedTimeField(_field: LastModifiedTimeFieldCore): string {\n    // Last modified time fields are stored as ISO strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitUserField(_field: UserFieldCore): string {\n    if (_field.isMultipleCellValue) {\n      return this.formatMultipleStringValues(_field);\n    }\n    return this.dialect.jsonTitleFromExpr(this.fieldExpression);\n  }\n\n  visitCreatedByField(_field: CreatedByFieldCore): string {\n    // Created by fields are stored as strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitLastModifiedByField(_field: LastModifiedByFieldCore): string {\n    // Last modified by fields are stored as strings, return as-is\n    return this.fieldExpression;\n  }\n\n  visitButtonField(_field: ButtonFieldCore): string {\n    // Button fields don't have values, return as-is\n    return this.fieldExpression;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport type {\n  FieldCore,\n  AttachmentFieldCore,\n  AutoNumberFieldCore,\n  CheckboxFieldCore,\n  CreatedByFieldCore,\n  CreatedTimeFieldCore,\n  DateFieldCore,\n  FormulaFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  LinkFieldCore,\n  LongTextFieldCore,\n  MultipleSelectFieldCore,\n  NumberFieldCore,\n  RatingFieldCore,\n  RollupFieldCore,\n  ConditionalRollupFieldCore,\n  SingleLineTextFieldCore,\n  SingleSelectFieldCore,\n  UserFieldCore,\n  IFieldVisitor,\n  ButtonFieldCore,\n  TableDomain,\n} from '@teable/core';\nimport { DbFieldType, FieldType, isLinkLookupOptions, DriverClient } from '@teable/core';\n// no driver-specific logic here; use dialect for differences\nimport type { Knex } from 'knex';\nimport type { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { AUTO_NUMBER_FIELD_NAME } from '../../field/constant';\nimport { isSystemUserField } from '../../field/fields-utils';\nimport type { IFieldSelectName } from './field-select.type';\nimport type {\n  IRecordSelectionMap,\n  IMutableQueryBuilderState,\n} from './record-query-builder.interface';\nimport { getTableAliasFromTable } from './record-query-builder.util';\nimport type { IRecordQueryDialectProvider } from './record-query-dialect.interface';\n\n/**\n * Field visitor that returns appropriate database column selectors for knex.select()\n *\n * For regular fields: returns the dbFieldName as string\n *\n * The returned value can be used directly with knex.select() or knex.raw()\n *\n * Also maintains a selectionMap that tracks field ID to selector name mappings,\n * which can be accessed via getSelectionMap() method.\n */\nexport class FieldSelectVisitor implements IFieldVisitor<IFieldSelectName> {\n  constructor(\n    private readonly qb: Knex.QueryBuilder,\n    private readonly dbProvider: IDbProvider,\n    private readonly table: TableDomain,\n    private readonly state: IMutableQueryBuilderState,\n    private readonly dialect: IRecordQueryDialectProvider,\n    private readonly aliasOverride?: string,\n    /**\n     * When true, select raw scalar values for lookup/rollup CTEs instead of formatted display values.\n     * This avoids type mismatches when propagating values back into physical columns (e.g. timestamptz).\n     */\n    private readonly rawProjection: boolean = false,\n    private readonly preferRawFieldReferences: boolean = false,\n    private readonly blockedLinkFieldIds?: ReadonlySet<string>,\n    private readonly readyLinkFieldIds?: ReadonlySet<string>,\n    private readonly currentLinkFieldId?: string\n  ) {}\n\n  private get tableAlias() {\n    return this.aliasOverride || getTableAliasFromTable(this.table);\n  }\n\n  private isLinkFieldBlocked(fieldId?: string | null): boolean {\n    return !!fieldId && !!this.blockedLinkFieldIds?.has(fieldId);\n  }\n\n  private isLinkFieldReady(fieldId?: string | null): boolean {\n    if (!fieldId) return false;\n    if (!this.readyLinkFieldIds) return true;\n    return this.readyLinkFieldIds.has(fieldId);\n  }\n\n  private isViewContext(): boolean {\n    return this.state.getContext() === 'view';\n  }\n\n  private isTableCacheContext(): boolean {\n    return this.state.getContext() === 'tableCache';\n  }\n\n  /**\n   * Whether we should select from the materialized view or table directly\n   */\n  private shouldSelectRaw() {\n    return this.isViewContext() || this.isTableCacheContext();\n  }\n\n  private castExpressionForDbType(expression: string, field: FieldCore): string {\n    if (this.dbProvider.driver !== DriverClient.Pg) {\n      return expression;\n    }\n\n    const suffix = this.getCastSuffixForDbType(field.dbFieldType);\n    if (!suffix) {\n      return expression;\n    }\n\n    return `(${expression})${suffix}`;\n  }\n\n  private getCastSuffixForDbType(dbFieldType?: DbFieldType): string | null {\n    switch (dbFieldType) {\n      case DbFieldType.Json:\n        return '::jsonb';\n      case DbFieldType.Integer:\n        return '::integer';\n      case DbFieldType.Real:\n        return '::double precision';\n      case DbFieldType.DateTime:\n        return '::timestamptz';\n      case DbFieldType.Boolean:\n        return '::boolean';\n      case DbFieldType.Blob:\n        return '::bytea';\n      case DbFieldType.Text:\n      default:\n        return null;\n    }\n  }\n\n  private buildTypedNull(field: FieldCore): string {\n    return this.dialect.typedNullFor(field.dbFieldType);\n  }\n\n  /**\n   * Returns the selection map containing field ID to selector name mappings\n   * @returns Map where key is field ID and value is the selector name/expression\n   */\n  public getSelectionMap(): IRecordSelectionMap {\n    return new Map(this.state.getSelectionMap());\n  }\n\n  /**\n   * Generate column select with\n   *\n   * @example\n   *   generateColumnSelectWithAlias('name') // returns 'name'\n   *\n   * @param name  column name\n   * @returns String column name with table alias or Raw expression\n   */\n  private generateColumnSelect(name: string): IFieldSelectName {\n    const alias = this.tableAlias;\n    if (!alias) {\n      return name;\n    }\n    return `\"${alias}\".\"${name}\"`;\n  }\n\n  /**\n   * Returns the appropriate column selector for a field\n   * @param field The field to get the selector for\n   * @returns String column name with table alias or Raw expression\n   */\n  private getColumnSelector(field: FieldCore): IFieldSelectName {\n    return this.generateColumnSelect(field.dbFieldName);\n  }\n\n  private selectSystemColumn(field: FieldCore, columnName: string): IFieldSelectName {\n    const alias = this.tableAlias;\n    const selector = alias ? `\"${alias}\".\"${columnName}\"` : columnName;\n    this.state.setSelection(field.id, selector);\n    return selector;\n  }\n\n  // Typed NULL generation is delegated to the dialect implementation\n\n  /**\n   * Check if field is a Lookup field and return appropriate selector\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private checkAndSelectLookupField(field: FieldCore): IFieldSelectName {\n    // Check if this is a Lookup field\n    if (field.isLookup) {\n      const fieldCteMap = this.state.getFieldCteMap();\n      // Lookup has no standard column in base table.\n      // When building from a materialized view, fallback to the view's column.\n      if (this.shouldSelectRaw()) {\n        if (isSystemUserField(field) && !field.isLookup) {\n          const columnSelector = this.getColumnSelector(field) as string;\n          const expr = this.dialect.buildUserJsonObjectById(columnSelector);\n          this.state.setSelection(field.id, expr);\n          return this.qb.client.raw(expr);\n        }\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      // Check if the field has error (e.g., target field deleted)\n      if (field.hasError || !field.lookupOptions) {\n        // Base-table context: return typed NULL to match the physical column type\n        const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n        const raw = this.qb.client.raw(nullExpr);\n        this.state.setSelection(field.id, nullExpr);\n        return raw;\n      }\n\n      // Conditional lookup CTEs are stored against the field itself.\n      if (field.isConditionalLookup) {\n        if (!fieldCteMap.has(field.id)) {\n          console.warn(\n            `[ConditionalLookup] CTE not in fieldCteMap for field ${field.id} (${(field as unknown as { name?: string }).name}). ` +\n              `Available CTE keys: [${Array.from(fieldCteMap.keys()).join(', ')}]`\n          );\n        } else {\n          const conditionalCteName = fieldCteMap.get(field.id)!;\n          if (!this.state.isCteJoined(conditionalCteName)) {\n            // If the CTE isn't joined in this scope, fall back to raw column access.\n            console.warn(\n              `[ConditionalLookup] CTE ${conditionalCteName} for field ${field.id} (${(field as unknown as { name?: string }).name}) is not joined in current scope`\n            );\n          } else {\n            const column =\n              field.type === FieldType.ConditionalRollup\n                ? `conditional_rollup_${field.id}`\n                : `conditional_lookup_${field.id}`;\n            const rawExpression = this.qb.client.raw(`??.\"${column}\"`, [conditionalCteName]);\n            this.state.setSelection(field.id, `\"${conditionalCteName}\".\"${column}\"`);\n            return rawExpression;\n          }\n        }\n      }\n\n      // For regular lookup fields, use the corresponding link field CTE\n      if (field.lookupOptions && isLinkLookupOptions(field.lookupOptions)) {\n        const { linkFieldId } = field.lookupOptions;\n        if (\n          linkFieldId &&\n          fieldCteMap.has(linkFieldId) &&\n          !this.isLinkFieldBlocked(linkFieldId) &&\n          this.isLinkFieldReady(linkFieldId)\n        ) {\n          const cteName = fieldCteMap.get(linkFieldId)!;\n          const flattenedExpr = this.dialect.flattenLookupCteValue(\n            cteName,\n            field.id,\n            !!field.isMultipleCellValue,\n            field.dbFieldType\n          );\n          if (flattenedExpr) {\n            this.state.setSelection(field.id, flattenedExpr);\n            return this.qb.client.raw(flattenedExpr);\n          }\n          // Default: return CTE column directly\n          const rawExpression = this.qb.client.raw(`??.\"lookup_${field.id}\"`, [cteName]);\n          this.state.setSelection(field.id, `\"${cteName}\".\"lookup_${field.id}\"`);\n          return rawExpression;\n        }\n      }\n\n      if (this.rawProjection) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const raw = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return raw;\n    } else {\n      const columnSelector = this.getColumnSelector(field);\n      this.state.setSelection(field.id, columnSelector);\n      return columnSelector;\n    }\n  }\n\n  /**\n   * Returns the generated column selector for formula fields\n   * @param field The formula field\n   */\n  private getFormulaColumnSelector(field: FormulaFieldCore): IFieldSelectName {\n    if (!field.isLookup) {\n      if (this.shouldSelectRaw()) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      // If any referenced field (recursively) is unresolved, fall back to NULL\n      if (field.hasUnresolvedReferences(this.table)) {\n        const nullExpr = this.buildTypedNull(field);\n        this.state.setSelection(field.id, nullExpr);\n        return this.qb.client.raw(nullExpr);\n      }\n\n      const expression = field.getExpression();\n      const timezone = field.options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n      // In raw/propagation context (used by UPDATE ... FROM SELECT), avoid referencing\n      // the physical generated column directly, since it may have been dropped by\n      // cascading schema changes (e.g., deleting a referenced base column). Instead,\n      // always emit the computed expression which degrades to NULL when references\n      // are unresolved.\n      if (this.rawProjection) {\n        const formulaSql = this.dbProvider.convertFormulaToSelectQuery(expression, {\n          table: this.table,\n          tableAlias: this.tableAlias,\n          selectionMap: this.getSelectionMap(),\n          fieldCteMap: this.state.getFieldCteMap(),\n          readyLinkFieldIds: this.readyLinkFieldIds,\n          currentLinkFieldId: this.currentLinkFieldId,\n          timeZone: timezone,\n          preferRawFieldReferences: this.preferRawFieldReferences,\n          targetDbFieldType: field.dbFieldType,\n        });\n        const normalized =\n          field.dbFieldType === DbFieldType.Json ? `to_jsonb(${formulaSql})` : formulaSql;\n        const casted = this.castExpressionForDbType(normalized as string, field);\n        this.state.setSelection(field.id, casted);\n        return casted;\n      }\n\n      if (!field.getIsPersistedAsGeneratedColumn()) {\n        const formulaSql = this.dbProvider.convertFormulaToSelectQuery(expression, {\n          table: this.table,\n          tableAlias: this.tableAlias,\n          selectionMap: this.getSelectionMap(),\n          fieldCteMap: this.state.getFieldCteMap(),\n          readyLinkFieldIds: this.readyLinkFieldIds,\n          currentLinkFieldId: this.currentLinkFieldId,\n          timeZone: timezone,\n          preferRawFieldReferences: this.preferRawFieldReferences,\n          targetDbFieldType: field.dbFieldType,\n        });\n        const normalized =\n          field.dbFieldType === DbFieldType.Json ? `to_jsonb(${formulaSql})` : formulaSql;\n        const casted = this.castExpressionForDbType(normalized as string, field);\n        this.state.setSelection(field.id, casted);\n        return casted;\n      }\n\n      // For non-raw contexts where the generated column exists, select it directly\n      const columnName = field.getGeneratedColumnName();\n      const columnSelector = this.generateColumnSelect(columnName);\n      this.state.setSelection(field.id, columnSelector);\n      return columnSelector;\n    }\n    // For lookup formula fields, use table alias if provided\n    if (field.hasError) {\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const rawNull = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return rawNull;\n    }\n    const lookupSelector = this.generateColumnSelect(field.dbFieldName);\n    this.state.setSelection(field.id, lookupSelector);\n    return lookupSelector;\n  }\n\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitSingleLineTextField(field: SingleLineTextFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitLongTextField(field: LongTextFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitAttachmentField(field: AttachmentFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitCheckboxField(field: CheckboxFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitDateField(field: DateFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n    const name = this.getColumnSelector(field);\n\n    // In lookup/rollup CTE context, return the raw column (timestamptz) to preserve type\n    // so UPDATE ... FROM (SELECT ...) can assign into timestamp columns without casting issues.\n    if (this.rawProjection) {\n      this.state.setSelection(field.id, name);\n      return name;\n    }\n\n    this.state.setSelection(field.id, name);\n    return name;\n  }\n\n  visitRatingField(field: RatingFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitAutoNumberField(field: AutoNumberFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n    if (this.rawProjection) {\n      const selector = this.generateColumnSelect(AUTO_NUMBER_FIELD_NAME);\n      this.state.setSelection(field.id, selector);\n      return selector;\n    }\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitLinkField(field: LinkFieldCore): IFieldSelectName {\n    // Check if this is a Lookup field first\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n\n    const fieldCteMap = this.state.getFieldCteMap();\n    const cteName = fieldCteMap?.get(field.id);\n    const canUseCte =\n      !!cteName && !this.isLinkFieldBlocked(field.id) && this.isLinkFieldReady(field.id);\n    const isSelfReference = this.currentLinkFieldId === field.id;\n\n    if (!canUseCte || isSelfReference) {\n      // If we are selecting from a materialized view, the view already exposes\n      // the projected column for this field, so select the physical column.\n      if (this.shouldSelectRaw()) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      if (this.rawProjection) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      if (!field.hasError) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      // When building directly from base table and no CTE is available\n      // (e.g., foreign table deleted or errored), return a dialect-typed NULL\n      // to avoid type mismatch when assigning into persisted columns.\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const raw = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return raw;\n    }\n\n    const resolvedCteName = cteName!;\n    // Return Raw expression for selecting from CTE\n    const rawExpression = this.qb.client.raw(`??.\"link_value\"`, [resolvedCteName]);\n    // For WHERE clauses, store the CTE column reference\n    this.state.setSelection(field.id, `\"${resolvedCteName}\".\"link_value\"`);\n    return rawExpression;\n  }\n\n  visitRollupField(field: RollupFieldCore): IFieldSelectName {\n    if (this.shouldSelectRaw()) {\n      // In view context, select the view column directly\n      const columnSelector = this.getColumnSelector(field);\n      this.state.setSelection(field.id, columnSelector);\n      return columnSelector;\n    }\n\n    const fieldCteMap = this.state.getFieldCteMap();\n    if (!isLinkLookupOptions(field.lookupOptions)) {\n      if (this.rawProjection) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const raw = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return raw;\n    }\n\n    const linkLookupOptions = field.lookupOptions;\n\n    const linkFieldId = linkLookupOptions.linkFieldId;\n    if (\n      !linkFieldId ||\n      !fieldCteMap?.has(linkFieldId) ||\n      this.isLinkFieldBlocked(linkFieldId) ||\n      !this.isLinkFieldReady(linkFieldId)\n    ) {\n      if (this.rawProjection) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      // From base table context, without CTE, return dialect-typed NULL to match column type\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const raw = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return raw;\n    }\n\n    // Rollup fields use the link field's CTE with pre-computed rollup values\n    // Check if the field has error (e.g., target field deleted)\n    if (field.hasError) {\n      // Field has error, return dialect-typed NULL to indicate this field should be null\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const rawExpression = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return rawExpression;\n    }\n\n    const linkField = field.getLinkField(this.table);\n    if (!linkField) {\n      if (this.rawProjection) {\n        const columnSelector = this.getColumnSelector(field);\n        this.state.setSelection(field.id, columnSelector);\n        return columnSelector;\n      }\n      const nullExpr = this.buildTypedNull(field);\n      this.state.setSelection(field.id, nullExpr);\n      return this.qb.client.raw(nullExpr);\n    }\n    const cteName = fieldCteMap.get(linkFieldId)!;\n\n    // Return Raw expression for selecting pre-computed rollup value from link CTE\n    const rawExpression = this.qb.client.raw(`??.\"rollup_${field.id}\"`, [cteName]);\n    // For WHERE clauses, store the CTE column reference\n    this.state.setSelection(field.id, `\"${cteName}\".\"rollup_${field.id}\"`);\n    return rawExpression;\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n\n    const fieldCteMap = this.state.getFieldCteMap();\n\n    if (this.rawProjection && (!fieldCteMap.has(field.id) || !this.isLinkFieldReady(field.id))) {\n      const columnSelector = this.getColumnSelector(field);\n      this.state.setSelection(field.id, columnSelector);\n      return columnSelector;\n    }\n\n    if (this.shouldSelectRaw()) {\n      const columnSelector = this.getColumnSelector(field);\n      this.state.setSelection(field.id, columnSelector);\n      return columnSelector;\n    }\n\n    const cteName = fieldCteMap.get(field.id);\n    if (!cteName) {\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const raw = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return raw;\n    }\n\n    const columnName = `conditional_rollup_${field.id}`;\n    const selectionExpr = `\"${cteName}\".\"${columnName}\"`;\n    this.state.setSelection(field.id, selectionExpr);\n    return this.qb.client.raw('??.??', [cteName, columnName]);\n  }\n\n  // Select field types\n  visitSingleSelectField(field: SingleSelectFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitButtonField(field: ButtonFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  // Formula field types - these may use generated columns\n  visitFormulaField(field: FormulaFieldCore): IFieldSelectName {\n    // If the formula field has an error (e.g., referenced field deleted), return NULL\n    if (field.hasError) {\n      const nullExpr = this.dialect.typedNullFor(field.dbFieldType);\n      const rawExpression = this.qb.client.raw(nullExpr);\n      this.state.setSelection(field.id, nullExpr);\n      return rawExpression;\n    }\n\n    // For Formula fields, check Lookup first, then use formula logic\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n    return this.getFormulaColumnSelector(field);\n  }\n\n  // User field types\n  visitUserField(field: UserFieldCore): IFieldSelectName {\n    return this.checkAndSelectLookupField(field);\n  }\n\n  visitCreatedTimeField(field: CreatedTimeFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n\n    return this.selectSystemColumn(field, '__created_time');\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n\n    const trackAll = field.isTrackAll();\n\n    // For track-all (generated column) fields, selecting the system column yields the same value\n    if (trackAll) {\n      return this.selectSystemColumn(field, '__last_modified_time');\n    }\n\n    const selector = this.getColumnSelector(field);\n    if (typeof selector === 'string') {\n      this.state.setSelection(field.id, selector);\n    }\n    return selector;\n  }\n\n  visitCreatedByField(field: CreatedByFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n    // Build JSON with user info from system column __created_by\n    const alias = this.tableAlias;\n    const idRef = alias ? `\"${alias}\".\"__created_by\"` : `\"__created_by\"`;\n    const expr = this.dialect.buildUserJsonObjectById(idRef);\n    this.state.setSelection(field.id, expr);\n    return this.qb.client.raw(expr);\n  }\n\n  visitLastModifiedByField(field: LastModifiedByFieldCore): IFieldSelectName {\n    if (field.isLookup) {\n      return this.checkAndSelectLookupField(field);\n    }\n\n    const trackAll = field.isTrackAll();\n    if (trackAll) {\n      // Build JSON with user info from system column __last_modified_by\n      const alias = this.tableAlias;\n      const idRef = alias ? `\"${alias}\".\"__last_modified_by\"` : `\"__last_modified_by\"`;\n      const expr = this.dialect.buildUserJsonObjectById(idRef);\n      this.state.setSelection(field.id, expr);\n      return this.qb.client.raw(expr);\n    }\n\n    return this.checkAndSelectLookupField(field);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/field-select.type.ts",
    "content": "import type { Knex } from 'knex';\n\nexport type IFieldSelectName = string | Knex.Raw;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/formula-support-generated-column-validator.spec.ts",
    "content": "import { CellValueType, DbFieldType, FieldType } from '@teable/core';\nimport type { FieldCore, TableDomain } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\nimport { GeneratedColumnQuerySupportValidatorPostgres } from '../../../db-provider/generated-column-query/postgres/generated-column-query-support-validator.postgres';\nimport { validateFormulaSupport } from './formula-validation';\n\nconst makeMockTable = (fields: Record<string, Partial<FieldCore>>): TableDomain =>\n  ({\n    getField: (id: string) => fields[id] as FieldCore | undefined,\n  }) as unknown as TableDomain;\n\ndescribe('FormulaSupportGeneratedColumnValidator', () => {\n  it('rejects numeric formulas when args are definitely non-numeric', () => {\n    const table = makeMockTable({\n      fldDate: {\n        id: 'fldDate',\n        name: 'Date',\n        dbFieldName: 'Field_45',\n        type: FieldType.Date,\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        isLookup: false,\n        isMultipleCellValue: false,\n      },\n      fldText: {\n        id: 'fldText',\n        name: 'Text',\n        dbFieldName: 'Field_1',\n        type: FieldType.SingleLineText,\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        isLookup: false,\n        isMultipleCellValue: false,\n      },\n    });\n\n    const validator = new GeneratedColumnQuerySupportValidatorPostgres();\n    expect(validateFormulaSupport(validator, 'SUM({fldDate},{fldText})', table)).toBe(false);\n  });\n\n  it('allows numeric formulas when args are numeric', () => {\n    const table = makeMockTable({\n      fldNum1: {\n        id: 'fldNum1',\n        name: 'Num1',\n        dbFieldName: 'num1',\n        type: FieldType.Number,\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        isLookup: false,\n        isMultipleCellValue: false,\n      },\n      fldNum2: {\n        id: 'fldNum2',\n        name: 'Num2',\n        dbFieldName: 'num2',\n        type: FieldType.Number,\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        isLookup: false,\n        isMultipleCellValue: false,\n      },\n    });\n\n    const validator = new GeneratedColumnQuerySupportValidatorPostgres();\n    expect(validateFormulaSupport(validator, 'SUM({fldNum1},{fldNum2})', table)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/formula-support-generated-column-validator.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport type {\n  TableDomain,\n  IFunctionCallInfo,\n  ExprContext,\n  FormulaFieldCore,\n  UnaryOpContext,\n  RuleNode,\n} from '@teable/core';\nimport {\n  parseFormula,\n  FunctionCallCollectorVisitor,\n  FieldReferenceVisitor,\n  FieldType,\n  AbstractParseTreeVisitor,\n  CellValueType,\n  FunctionName,\n  LeftWhitespaceOrCommentsContext,\n  normalizeFunctionNameAlias,\n  RightWhitespaceOrCommentsContext,\n  StringLiteralContext,\n  IntegerLiteralContext,\n  DecimalLiteralContext,\n  BooleanLiteralContext,\n  FunctionCallContext,\n  FieldReferenceCurlyContext,\n  BracketsContext,\n  BinaryOpContext,\n  DbFieldType,\n  extractFieldReferenceId,\n  getFieldReferenceTokenText,\n} from '@teable/core';\nimport { match } from 'ts-pattern';\nimport type { IGeneratedColumnQuerySupportValidator } from './sql-conversion.visitor';\n\n/**\n * Validates whether a formula expression is supported for generated column creation\n * by checking if all functions used in the formula are supported by the database provider.\n */\nexport class FormulaSupportGeneratedColumnValidator {\n  constructor(\n    private readonly supportValidator: IGeneratedColumnQuerySupportValidator,\n    private readonly tableDomain: TableDomain\n  ) {}\n\n  /**\n   * Validates whether a formula expression can be used to create a generated column\n   * @param expression The formula expression to validate\n   * @returns true if all functions in the formula are supported, false otherwise\n   */\n  validateFormula(expression: string): boolean {\n    try {\n      // Parse the formula expression into an AST\n      const tree = parseFormula(expression);\n\n      // First check if any referenced fields are link, lookup, or rollup fields\n      if (!this.validateFieldReferences(tree)) {\n        return false;\n      }\n\n      if (this.hasDatetimeStringConcatenation(tree)) {\n        return false;\n      }\n\n      if (this.hasDatetimeTextSlicing(tree)) {\n        return false;\n      }\n\n      if (this.hasLogicalNonBooleanArgs(tree)) {\n        return false;\n      }\n\n      if (this.hasNumericFunctionWithNonNumericArgs(tree)) {\n        return false;\n      }\n\n      if (this.containsLogicalFunctions(tree)) {\n        return false;\n      }\n\n      // Extract all function calls from the AST\n      const collector = new FunctionCallCollectorVisitor();\n      const functionCalls = collector.visit(tree);\n\n      // Check if all functions are supported\n      return (\n        functionCalls.every((funcCall: IFunctionCallInfo) => {\n          return this.isFunctionSupported(funcCall.name, funcCall.paramCount);\n        }) && this.validateTypeSafety(tree)\n      );\n    } catch (error) {\n      // If parsing fails, the formula is not valid for generated columns\n      console.warn(`Failed to parse formula expression: ${expression}`, error);\n      return false;\n    }\n  }\n\n  /**\n   * Validates that all field references in the formula are supported for generated columns\n   * @param tree The parsed formula AST\n   * @param visitedFields Set of field IDs already visited to prevent circular references\n   * @returns true if all field references are supported, false otherwise\n   */\n  private validateFieldReferences(\n    tree: ExprContext,\n    visitedFields: Set<string> = new Set()\n  ): boolean {\n    // Extract field references from the formula\n    const fieldReferenceVisitor = new FieldReferenceVisitor();\n    const fieldIds = fieldReferenceVisitor.visit(tree);\n\n    // Check each referenced field\n    for (const fieldId of fieldIds) {\n      if (!this.validateSingleFieldReference(fieldId, visitedFields)) {\n        return false;\n      }\n    }\n\n    return true;\n  }\n\n  /**\n   * Validates a single field reference, including recursive validation for formula fields\n   * @param fieldId The field ID to validate\n   * @param visitedFields Set of field IDs already visited to prevent circular references\n   * @returns true if the field reference is supported, false otherwise\n   */\n  private validateSingleFieldReference(fieldId: string, visitedFields: Set<string>): boolean {\n    // Prevent circular references\n    if (visitedFields.has(fieldId)) {\n      return true; // Skip already visited fields to avoid infinite recursion\n    }\n\n    const field = this.tableDomain.getField(fieldId);\n    if (!field) {\n      // If field is not found, it's invalid for generated columns\n      return false;\n    }\n\n    // Disallow referencing non-immutable or generated-backed fields\n    // 1) Link / Lookup / Rollup (require joins/CTEs)\n    // 2) System generated fields and user-by fields\n    if (\n      field.type === FieldType.Link ||\n      field.type === FieldType.Rollup ||\n      field.type === FieldType.ConditionalRollup ||\n      field.isLookup === true ||\n      field.type === FieldType.CreatedTime ||\n      field.type === FieldType.LastModifiedTime ||\n      field.type === FieldType.AutoNumber ||\n      field.type === FieldType.CreatedBy ||\n      field.type === FieldType.LastModifiedBy\n    ) {\n      return false;\n    }\n\n    // If it's a formula field, recursively check its dependencies\n    if (field.type === FieldType.Formula) {\n      const formulaField = field as FormulaFieldCore;\n\n      if (!formulaField.getIsPersistedAsGeneratedColumn()) {\n        return false;\n      }\n\n      visitedFields.add(fieldId);\n\n      try {\n        const expression = formulaField.getExpression();\n        if (expression) {\n          const tree = parseFormula(expression);\n          return this.validateFieldReferences(tree, visitedFields);\n        }\n      } catch (error) {\n        // If parsing the nested formula fails, consider it unsupported\n        console.warn(`Failed to parse nested formula expression for field ${fieldId}:`, error);\n        return false;\n      } finally {\n        visitedFields.delete(fieldId);\n      }\n    }\n\n    return true;\n  }\n\n  /**\n   * Checks if a specific function is supported for generated columns\n   * @param functionName The function name (case-insensitive)\n   * @param paramCount The number of parameters for the function\n   * @returns true if the function is supported, false otherwise\n   */\n  private isFunctionSupported(funcName: string, paramCount: number): boolean {\n    if (!funcName) {\n      return false;\n    }\n\n    try {\n      return (\n        this.checkNumericFunctions(funcName, paramCount) ||\n        this.checkTextFunctions(funcName, paramCount) ||\n        this.checkDateTimeFunctions(funcName, paramCount) ||\n        this.checkLogicalFunctions(funcName, paramCount) ||\n        this.checkArrayFunctions(funcName, paramCount) ||\n        this.checkSystemFunctions(funcName)\n      );\n    } catch (error) {\n      console.warn(`Error checking support for function ${funcName}:`, error);\n      return false;\n    }\n  }\n\n  private checkNumericFunctions(funcName: string, paramCount: number): boolean {\n    const dummyParam = 'dummy';\n    const dummyParams = Array(paramCount).fill(dummyParam);\n\n    return match(funcName)\n      .with('SUM', () => this.supportValidator.sum(dummyParams))\n      .with('AVERAGE', () => this.supportValidator.average(dummyParams))\n      .with('MAX', () => this.supportValidator.max(dummyParams))\n      .with('MIN', () => this.supportValidator.min(dummyParams))\n      .with('ROUND', () =>\n        this.supportValidator.round(dummyParam, paramCount > 1 ? dummyParam : undefined)\n      )\n      .with('ROUNDUP', () =>\n        this.supportValidator.roundUp(dummyParam, paramCount > 1 ? dummyParam : undefined)\n      )\n      .with('ROUNDDOWN', () =>\n        this.supportValidator.roundDown(dummyParam, paramCount > 1 ? dummyParam : undefined)\n      )\n      .with('CEILING', () => this.supportValidator.ceiling(dummyParam))\n      .with('FLOOR', () => this.supportValidator.floor(dummyParam))\n      .with('EVEN', () => this.supportValidator.even(dummyParam))\n      .with('ODD', () => this.supportValidator.odd(dummyParam))\n      .with('INT', () => this.supportValidator.int(dummyParam))\n      .with('ABS', () => this.supportValidator.abs(dummyParam))\n      .with('SQRT', () => this.supportValidator.sqrt(dummyParam))\n      .with('POWER', () => this.supportValidator.power(dummyParam, dummyParam))\n      .with('EXP', () => this.supportValidator.exp(dummyParam))\n      .with('LOG', () =>\n        this.supportValidator.log(dummyParam, paramCount > 1 ? dummyParam : undefined)\n      )\n      .with('MOD', () => this.supportValidator.mod(dummyParam, dummyParam))\n      .with('VALUE', () => this.supportValidator.value(dummyParam))\n      .otherwise(() => false);\n  }\n\n  private checkTextFunctions(funcName: string, paramCount: number): boolean {\n    const dummyParam = 'dummy';\n    const dummyParams = Array(paramCount).fill(dummyParam);\n\n    return match(funcName)\n      .with('CONCATENATE', () => this.supportValidator.concatenate(dummyParams))\n      .with('FIND', () =>\n        this.supportValidator.find(dummyParam, dummyParam, paramCount > 2 ? dummyParam : undefined)\n      )\n      .with('SEARCH', () =>\n        this.supportValidator.search(\n          dummyParam,\n          dummyParam,\n          paramCount > 2 ? dummyParam : undefined\n        )\n      )\n      .with('MID', () => this.supportValidator.mid(dummyParam, dummyParam, dummyParam))\n      .with('LEFT', () => this.supportValidator.left(dummyParam, dummyParam))\n      .with('RIGHT', () => this.supportValidator.right(dummyParam, dummyParam))\n      .with('REPLACE', () =>\n        this.supportValidator.replace(dummyParam, dummyParam, dummyParam, dummyParam)\n      )\n      .with('REGEX_REPLACE', () =>\n        this.supportValidator.regexpReplace(dummyParam, dummyParam, dummyParam)\n      )\n      .with('SUBSTITUTE', () =>\n        this.supportValidator.substitute(\n          dummyParam,\n          dummyParam,\n          dummyParam,\n          paramCount > 3 ? dummyParam : undefined\n        )\n      )\n      .with('LOWER', () => this.supportValidator.lower(dummyParam))\n      .with('UPPER', () => this.supportValidator.upper(dummyParam))\n      .with('REPT', () => this.supportValidator.rept(dummyParam, dummyParam))\n      .with('TRIM', () => this.supportValidator.trim(dummyParam))\n      .with('LEN', () => this.supportValidator.len(dummyParam))\n      .with('T', () => this.supportValidator.t(dummyParam))\n      .with('ENCODE_URL_COMPONENT', () => this.supportValidator.encodeUrlComponent(dummyParam))\n      .otherwise(() => false);\n  }\n\n  private checkDateTimeFunctions(funcName: string, paramCount: number): boolean {\n    const dummyParam = 'dummy';\n\n    return match(funcName)\n      .with('NOW', () => this.supportValidator.now())\n      .with('TODAY', () => this.supportValidator.today())\n      .with('DATE_ADD', () => this.supportValidator.dateAdd(dummyParam, dummyParam, dummyParam))\n      .with('DATESTR', () => this.supportValidator.datestr(dummyParam))\n      .with('DATETIME_DIFF', () =>\n        this.supportValidator.datetimeDiff(dummyParam, dummyParam, dummyParam)\n      )\n      .with('DATETIME_FORMAT', () => this.supportValidator.datetimeFormat(dummyParam, dummyParam))\n      .with('DATETIME_PARSE', () => this.supportValidator.datetimeParse(dummyParam, dummyParam))\n      .with('DAY', () => this.supportValidator.day(dummyParam))\n      .with('FROMNOW', () => this.supportValidator.fromNow(dummyParam))\n      .with('HOUR', () => this.supportValidator.hour(dummyParam))\n      .with('IS_AFTER', () => this.supportValidator.isAfter(dummyParam, dummyParam))\n      .with('IS_BEFORE', () => this.supportValidator.isBefore(dummyParam, dummyParam))\n      .with('IS_SAME', () =>\n        this.supportValidator.isSame(\n          dummyParam,\n          dummyParam,\n          paramCount > 2 ? dummyParam : undefined\n        )\n      )\n      .with('LAST_MODIFIED_TIME', () => this.supportValidator.lastModifiedTime())\n      .with('MINUTE', () => this.supportValidator.minute(dummyParam))\n      .with('MONTH', () => this.supportValidator.month(dummyParam))\n      .with('SECOND', () => this.supportValidator.second(dummyParam))\n      .with('TIMESTR', () => this.supportValidator.timestr(dummyParam))\n      .with('TONOW', () => this.supportValidator.toNow(dummyParam))\n      .with('WEEKNUM', () => this.supportValidator.weekNum(dummyParam))\n      .with('WEEKDAY', () => this.supportValidator.weekday(dummyParam))\n      .with('WORKDAY', () => this.supportValidator.workday(dummyParam, dummyParam))\n      .with('WORKDAY_DIFF', () => this.supportValidator.workdayDiff(dummyParam, dummyParam))\n      .with('YEAR', () => this.supportValidator.year(dummyParam))\n      .with('CREATED_TIME', () => this.supportValidator.createdTime())\n      .otherwise(() => false);\n  }\n\n  private checkLogicalFunctions(funcName: string, paramCount: number): boolean {\n    const dummyParam = 'dummy';\n    const dummyParams = Array(paramCount).fill(dummyParam);\n\n    return match(funcName)\n      .with('IF', () => this.supportValidator.if(dummyParam, dummyParam, dummyParam))\n      .with('AND', () => this.supportValidator.and(dummyParams))\n      .with('OR', () => this.supportValidator.or(dummyParams))\n      .with('NOT', () => this.supportValidator.not(dummyParam))\n      .with('XOR', () => this.supportValidator.xor(dummyParams))\n      .with('BLANK', () => this.supportValidator.blank())\n      .with('ERROR', () => this.supportValidator.error(dummyParam))\n      .with('ISERROR', () => this.supportValidator.isError(dummyParam))\n      .with('SWITCH', () => this.supportValidator.switch(dummyParam, [], dummyParam))\n      .otherwise(() => false);\n  }\n\n  private checkArrayFunctions(funcName: string, paramCount: number): boolean {\n    const dummyParam = 'dummy';\n    const dummyParams = Array(paramCount).fill(dummyParam);\n\n    return match(funcName)\n      .with('COUNT', () => this.supportValidator.count(dummyParams))\n      .with('COUNTA', () => this.supportValidator.countA(dummyParams))\n      .with('COUNTALL', () => this.supportValidator.countAll(dummyParam))\n      .with('ARRAY_JOIN', () =>\n        this.supportValidator.arrayJoin(dummyParam, paramCount > 1 ? dummyParam : undefined)\n      )\n      .with('ARRAY_UNIQUE', () => this.supportValidator.arrayUnique(dummyParams))\n      .with('ARRAY_FLATTEN', () => this.supportValidator.arrayFlatten(dummyParams))\n      .with('ARRAY_COMPACT', () => this.supportValidator.arrayCompact(dummyParams))\n      .otherwise(() => false);\n  }\n\n  private checkSystemFunctions(funcName: string): boolean {\n    const dummyParam = 'dummy';\n\n    return match(funcName)\n      .with('RECORD_ID', () => this.supportValidator.recordId())\n      .with('AUTO_NUMBER', () => this.supportValidator.autoNumber())\n      .with('TEXT_ALL', () => this.supportValidator.textAll(dummyParam))\n      .otherwise(() => false);\n  }\n\n  /**\n   * Perform a conservative type-safety validation over binary/unary operations.\n   * Only blocks clearly invalid expressions (e.g., arithmetic with definite string literals\n   * or text fields). If types are uncertain, it allows it to avoid false negatives.\n   */\n  private validateTypeSafety(tree: ExprContext): boolean {\n    try {\n      class TypeInferVisitor extends AbstractParseTreeVisitor<\n        'string' | 'number' | 'boolean' | 'datetime' | 'unknown'\n      > {\n        constructor(private readonly tableDomain: TableDomain) {\n          super();\n        }\n\n        protected defaultResult(): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return 'unknown';\n        }\n\n        visitStringLiteral(\n          _ctx: StringLiteralContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return 'string';\n        }\n\n        visitIntegerLiteral(\n          _ctx: IntegerLiteralContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return 'number';\n        }\n\n        visitDecimalLiteral(\n          _ctx: DecimalLiteralContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return 'number';\n        }\n\n        visitBooleanLiteral(\n          _ctx: BooleanLiteralContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return 'boolean';\n        }\n\n        visitBrackets(\n          ctx: BracketsContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          return ctx.expr().accept(this);\n        }\n\n        visitUnaryOp(\n          ctx: UnaryOpContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          const operandType = ctx.expr().accept(this);\n          // Unary minus is numeric-only; if we can prove it's string, mark as unknown (invalid later)\n          return operandType === 'string' ? 'unknown' : 'number';\n        }\n\n        visitFieldReferenceCurly(\n          ctx: FieldReferenceCurlyContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          const normalizedFieldId = extractFieldReferenceId(ctx);\n          const rawToken = getFieldReferenceTokenText(ctx);\n          const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';\n          const field = this.tableDomain.getField(fieldId);\n          if (!field) return 'unknown';\n          switch (field.cellValueType) {\n            case CellValueType.String:\n              return 'string';\n            case CellValueType.Number:\n              return 'number';\n            case CellValueType.Boolean:\n              return 'boolean';\n            case CellValueType.DateTime:\n              return 'datetime';\n            case 'dateTime':\n              return 'datetime';\n            default:\n              if (\n                field.type === FieldType.Date ||\n                field.type === FieldType.CreatedTime ||\n                field.type === FieldType.LastModifiedTime\n              ) {\n                return 'datetime';\n              }\n              if (field.cellValueType === 'datetime') {\n                return 'datetime';\n              }\n              if (field.dbFieldType === 'DATETIME') {\n                return 'datetime';\n              }\n              return 'unknown';\n          }\n        }\n\n        visitFunctionCall(\n          _ctx: FunctionCallContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          // We don't derive precise return types here; keep as unknown to avoid false negatives\n          return 'unknown';\n        }\n\n        // eslint-disable-next-line sonarjs/cognitive-complexity\n        visitBinaryOp(\n          ctx: BinaryOpContext\n        ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n          const operator = ctx._op?.text ?? '';\n          const leftType = ctx.expr(0).accept(this);\n          const rightType = ctx.expr(1).accept(this);\n\n          const arithmetic = ['-', '*', '/', '%'];\n          const comparison = ['>', '<', '>=', '<=', '=', '!=', '<>'];\n          const stringConcat = ['&'];\n\n          if (operator === '+') {\n            // Ambiguous in our grammar; be conservative: if either side is string, treat as string\n            if (leftType === 'string' || rightType === 'string') return 'string';\n            if (leftType === 'datetime' || rightType === 'datetime') return 'string';\n            if (leftType === 'number' && rightType === 'number') return 'number';\n            return 'unknown';\n          }\n\n          if (arithmetic.includes(operator)) {\n            // Arithmetic requires numeric operands. If any side is definitively string -> invalid\n            if (leftType === 'string' || rightType === 'string') return 'unknown';\n            if (leftType === 'datetime' || rightType === 'datetime') return 'datetime';\n            return 'number';\n          }\n\n          if (comparison.includes(operator)) {\n            return 'boolean';\n          }\n\n          if (stringConcat.includes(operator)) {\n            return 'string';\n          }\n\n          return 'unknown';\n        }\n      }\n\n      class InvalidArithmeticDetector extends AbstractParseTreeVisitor<boolean> {\n        constructor(private readonly infer: TypeInferVisitor) {\n          super();\n        }\n\n        protected defaultResult(): boolean {\n          return false;\n        }\n\n        visitChildren(node: RuleNode): boolean {\n          const n = node.childCount;\n          for (let i = 0; i < n; i++) {\n            const child = node.getChild(i);\n            if (child && child.accept(this)) {\n              return true;\n            }\n          }\n          return false;\n        }\n\n        visitBinaryOp(ctx: BinaryOpContext): boolean {\n          const operator = ctx._op?.text ?? '';\n          const arithmetic = ['-', '*', '/', '%'];\n          const stringConcat = ['&'];\n          const plusOperator = operator === '+';\n          if (plusOperator || stringConcat.includes(operator)) {\n            const leftType = ctx.expr(0).accept(this.infer);\n            const rightType = ctx.expr(1).accept(this.infer);\n            const behavesAsString =\n              stringConcat.includes(operator) ||\n              (plusOperator &&\n                (leftType === 'string' ||\n                  rightType === 'string' ||\n                  leftType === 'datetime' ||\n                  rightType === 'datetime'));\n            if (behavesAsString && (leftType === 'datetime' || rightType === 'datetime')) {\n              return true;\n            }\n          }\n          if (arithmetic.includes(operator)) {\n            const leftType = ctx.expr(0).accept(this.infer);\n            const rightType = ctx.expr(1).accept(this.infer);\n            // If we can prove any operand is a string or datetime, this arithmetic is unsafe\n            if (\n              leftType === 'string' ||\n              rightType === 'string' ||\n              leftType === 'datetime' ||\n              rightType === 'datetime'\n            ) {\n              return true;\n            }\n          }\n          // Continue walking\n          return this.visitChildren(ctx);\n        }\n      }\n\n      const infer = new TypeInferVisitor(this.tableDomain);\n      const detector = new InvalidArithmeticDetector(infer);\n      // If detector finds invalid arithmetic, validation fails\n      return !tree.accept(detector);\n    } catch (e) {\n      console.warn('Type-safety validation failed with error:', e);\n      // On validator failure, be conservative and disable generated column support\n      return false;\n    }\n  }\n\n  private hasDatetimeStringConcatenation(tree: ExprContext): boolean {\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    const self = this;\n    class DatetimeConcatDetector extends AbstractParseTreeVisitor<boolean> {\n      protected defaultResult(): boolean {\n        return false;\n      }\n\n      // eslint-disable-next-line sonarjs/no-identical-functions\n      visitChildren(node: RuleNode): boolean {\n        let index = 0;\n        while (index < node.childCount) {\n          const child = node.getChild(index);\n          if (child && child.accept(this)) {\n            return true;\n          }\n          index++;\n        }\n        return false;\n      }\n\n      visitBinaryOp(ctx: BinaryOpContext): boolean {\n        const operator = ctx._op?.text ?? '';\n        if (operator === '+' || operator === '&') {\n          const leftType = self.inferBasicType(ctx.expr(0));\n          const rightType = self.inferBasicType(ctx.expr(1));\n          const behavesAsString =\n            operator === '&' || leftType === 'string' || rightType === 'string';\n          if ((leftType === 'datetime' || rightType === 'datetime') && behavesAsString) {\n            return true;\n          }\n        }\n        return this.visitChildren(ctx);\n      }\n\n      visitFunctionCall(ctx: FunctionCallContext): boolean {\n        const rawName = ctx.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        if (fnName === FunctionName.Concatenate) {\n          const hasDatetimeArg = ctx.expr().some((exprCtx) => {\n            return self.inferBasicType(exprCtx) === 'datetime';\n          });\n          if (hasDatetimeArg) {\n            return true;\n          }\n        }\n\n        return this.visitChildren(ctx);\n      }\n    }\n\n    return tree.accept(new DatetimeConcatDetector()) ?? false;\n  }\n\n  private hasDatetimeTextSlicing(tree: ExprContext): boolean {\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    const self = this;\n    class DatetimeTextSliceDetector extends AbstractParseTreeVisitor<boolean> {\n      protected defaultResult(): boolean {\n        return false;\n      }\n\n      visitChildren(node: RuleNode): boolean {\n        const n = node.childCount;\n        for (let i = 0; i < n; i++) {\n          const child = node.getChild(i);\n          if (child && child.accept(this)) {\n            return true;\n          }\n        }\n        return false;\n      }\n\n      visitFunctionCall(ctx: FunctionCallContext): boolean {\n        const rawName = ctx.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        const exprs = ctx.expr();\n        const hasDatetimeArg = exprs.some((exprCtx) => self.inferBasicType(exprCtx) === 'datetime');\n\n        if (hasDatetimeArg) {\n          switch (fnName) {\n            case FunctionName.Left:\n            case FunctionName.Right:\n            case FunctionName.Mid:\n            case FunctionName.Replace:\n              return true;\n            default:\n              break;\n          }\n        }\n\n        return this.visitChildren(ctx);\n      }\n    }\n\n    return tree.accept(new DatetimeTextSliceDetector()) ?? false;\n  }\n\n  private hasLogicalNonBooleanArgs(tree: ExprContext): boolean {\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    const self = this;\n    class LogicalArgumentDetector extends AbstractParseTreeVisitor<boolean> {\n      protected defaultResult(): boolean {\n        return false;\n      }\n\n      visitChildren(node: RuleNode): boolean {\n        const n = node.childCount;\n        for (let i = 0; i < n; i++) {\n          const child = node.getChild(i);\n          if (child && child.accept(this)) {\n            return true;\n          }\n        }\n        return false;\n      }\n\n      visitFunctionCall(ctx: FunctionCallContext): boolean {\n        const rawName = ctx.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        const isLogical =\n          fnName === FunctionName.And ||\n          fnName === FunctionName.Or ||\n          fnName === FunctionName.Not ||\n          fnName === FunctionName.Xor;\n\n        if (isLogical) {\n          const exprs = ctx.expr();\n          for (const exprCtx of exprs) {\n            const argType = self.inferBasicType(exprCtx);\n            if (argType === 'string' || argType === 'number' || argType === 'datetime') {\n              return true;\n            }\n          }\n        }\n\n        return this.visitChildren(ctx);\n      }\n    }\n\n    return tree.accept(new LogicalArgumentDetector()) ?? false;\n  }\n\n  private hasNumericFunctionWithNonNumericArgs(tree: ExprContext): boolean {\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    const self = this;\n    const numericFunctions = new Set<FunctionName>([\n      FunctionName.Sum,\n      FunctionName.Average,\n      FunctionName.Round,\n      FunctionName.RoundUp,\n      FunctionName.RoundDown,\n      FunctionName.Ceiling,\n      FunctionName.Floor,\n      FunctionName.Even,\n      FunctionName.Odd,\n      FunctionName.Int,\n      FunctionName.Abs,\n      FunctionName.Sqrt,\n      FunctionName.Power,\n      FunctionName.Exp,\n      FunctionName.Log,\n      FunctionName.Mod,\n      FunctionName.Value,\n    ]);\n\n    class NumericFunctionArgDetector extends AbstractParseTreeVisitor<boolean> {\n      protected defaultResult(): boolean {\n        return false;\n      }\n\n      visitChildren(node: RuleNode): boolean {\n        const n = node.childCount;\n        for (let i = 0; i < n; i++) {\n          const child = node.getChild(i);\n          if (child && child.accept(this)) {\n            return true;\n          }\n        }\n        return false;\n      }\n\n      visitFunctionCall(ctx: FunctionCallContext): boolean {\n        const rawName = ctx.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        if (numericFunctions.has(fnName)) {\n          const exprs = ctx.expr();\n          for (const exprCtx of exprs) {\n            const argType = self.inferBasicType(exprCtx);\n            if (argType === 'string' || argType === 'datetime') {\n              return true;\n            }\n          }\n        }\n\n        return this.visitChildren(ctx);\n      }\n    }\n\n    return tree.accept(new NumericFunctionArgDetector()) ?? false;\n  }\n\n  private containsLogicalFunctions(tree: ExprContext): boolean {\n    class LogicalFunctionDetector extends AbstractParseTreeVisitor<boolean> {\n      protected defaultResult(): boolean {\n        return false;\n      }\n\n      visitChildren(node: RuleNode): boolean {\n        let index = 0;\n        while (index < node.childCount) {\n          const child = node.getChild(index);\n          if (child && child.accept(this)) {\n            return true;\n          }\n          index++;\n        }\n        return false;\n      }\n\n      visitFunctionCall(ctx: FunctionCallContext): boolean {\n        const rawName = ctx.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        const isLogical =\n          fnName === FunctionName.And ||\n          fnName === FunctionName.Or ||\n          fnName === FunctionName.Not ||\n          fnName === FunctionName.Xor;\n\n        if (isLogical) {\n          return true;\n        }\n\n        return this.visitChildren(ctx);\n      }\n    }\n\n    return tree.accept(new LogicalFunctionDetector()) ?? false;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private inferBasicType(\n    ctx: ExprContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    if (ctx instanceof StringLiteralContext) {\n      return 'string';\n    }\n    if (ctx instanceof IntegerLiteralContext || ctx instanceof DecimalLiteralContext) {\n      return 'number';\n    }\n    if (ctx instanceof BooleanLiteralContext) {\n      return 'boolean';\n    }\n    if (ctx instanceof FieldReferenceCurlyContext) {\n      const normalizedFieldId = extractFieldReferenceId(ctx);\n      const rawToken = getFieldReferenceTokenText(ctx);\n      const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';\n      const field = this.tableDomain.getField(fieldId);\n      if (!field) {\n        return 'unknown';\n      }\n      switch (field.cellValueType) {\n        case CellValueType.String:\n          return 'string';\n        case CellValueType.Number:\n          return 'number';\n        case CellValueType.Boolean:\n          return 'boolean';\n        case CellValueType.DateTime:\n          return 'datetime';\n        default:\n          if (\n            field.type === FieldType.Date ||\n            field.type === FieldType.CreatedTime ||\n            field.type === FieldType.LastModifiedTime\n          ) {\n            return 'datetime';\n          }\n          if (field?.dbFieldType === DbFieldType.DateTime) {\n            return 'datetime';\n          }\n          return 'unknown';\n      }\n    }\n    if (ctx instanceof FunctionCallContext) {\n      const rawName = ctx.func_name().text.toUpperCase();\n      const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n      if (\n        [\n          FunctionName.Today,\n          FunctionName.Now,\n          FunctionName.DateAdd,\n          FunctionName.CreatedTime,\n          FunctionName.LastModifiedTime,\n          FunctionName.DatetimeParse,\n        ].includes(fnName)\n      ) {\n        return 'datetime';\n      }\n      if (fnName === FunctionName.Concatenate) {\n        return 'string';\n      }\n      return 'unknown';\n    }\n    if (ctx instanceof BinaryOpContext) {\n      const operator = ctx._op?.text ?? '';\n      const leftType = this.inferBasicType(ctx.expr(0));\n      const rightType = this.inferBasicType(ctx.expr(1));\n      if (operator === '+' || operator === '&') {\n        if (leftType === 'string' || rightType === 'string') {\n          return 'string';\n        }\n        if (leftType === 'datetime' || rightType === 'datetime') {\n          return 'string';\n        }\n        if (leftType === 'number' && rightType === 'number') {\n          return 'number';\n        }\n        return 'unknown';\n      }\n      if (['-', '*', '/', '%'].includes(operator)) {\n        return 'number';\n      }\n      if (['>', '<', '>=', '<=', '=', '!=', '<>', '&&', '||'].includes(operator)) {\n        return 'boolean';\n      }\n      if (operator === '&') {\n        return 'string';\n      }\n      return 'unknown';\n    }\n    if (ctx instanceof BracketsContext) {\n      return this.inferBasicType(ctx.expr());\n    }\n    if (\n      ctx instanceof LeftWhitespaceOrCommentsContext ||\n      ctx instanceof RightWhitespaceOrCommentsContext\n    ) {\n      return this.inferBasicType(ctx.expr());\n    }\n    return 'unknown';\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/formula-validation.ts",
    "content": "import type { TableDomain } from '@teable/core';\nimport { FormulaSupportGeneratedColumnValidator } from './formula-support-generated-column-validator';\nimport type { IGeneratedColumnQuerySupportValidator } from './sql-conversion.visitor';\n\n/**\n * Pure function to validate if a formula expression is supported for generated columns\n * @param supportValidator The database-specific support validator\n * @param expression The formula expression to validate\n * @param fieldMap Optional field map to check field references\n * @returns true if the formula is supported, false otherwise\n */\nexport function validateFormulaSupport(\n  supportValidator: IGeneratedColumnQuerySupportValidator,\n  expression: string,\n  tableDomain: TableDomain\n): boolean {\n  supportValidator.setContext({ table: tableDomain });\n  const validator = new FormulaSupportGeneratedColumnValidator(supportValidator, tableDomain);\n  return validator.validateFormula(expression);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/index.ts",
    "content": "export type {\n  IRecordQueryBuilder,\n  ICreateRecordQueryBuilderOptions,\n  ICreateRecordAggregateBuilderOptions,\n  IReadonlyQueryBuilderState,\n  IMutableQueryBuilderState,\n} from './record-query-builder.interface';\nexport { RecordQueryBuilderService } from './record-query-builder.service';\nexport { RecordQueryBuilderModule } from './record-query-builder.module';\nexport { RECORD_QUERY_BUILDER_SYMBOL } from './record-query-builder.symbol';\nexport { InjectRecordQueryBuilder } from './record-query-builder.provider';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.spec.ts",
    "content": "import { DbFieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { describe, expect, it } from 'vitest';\nimport { PgRecordQueryDialect } from './pg-record-query-dialect';\n\ndescribe('PgRecordQueryDialect#flattenLookupCteValue', () => {\n  const dialect = new PgRecordQueryDialect({} as unknown as Knex);\n\n  it('returns null for single-value lookups', () => {\n    const result = dialect.flattenLookupCteValue(\n      'cte_lookup',\n      'fld_single',\n      false,\n      DbFieldType.Text\n    );\n    expect(result).toBeNull();\n  });\n\n  it('keeps jsonb payloads when field is stored as json', () => {\n    const sql = dialect.flattenLookupCteValue('cte_lookup', 'fld_json', true, DbFieldType.Json);\n    expect(sql).toContain('\"cte_lookup\".\"lookup_fld_json\"::jsonb');\n    expect(sql).not.toContain('to_jsonb(\"cte_lookup\".\"lookup_fld_json\")');\n  });\n\n  it('wraps scalar payloads with to_jsonb for non-json fields', () => {\n    const sql = dialect.flattenLookupCteValue('cte_lookup', 'fld_scalar', true, DbFieldType.Text);\n    expect(sql).toContain('to_jsonb(\"cte_lookup\".\"lookup_fld_scalar\")');\n  });\n});\n\ndescribe('PgRecordQueryDialect#linkExtractTitles', () => {\n  const dialect = new PgRecordQueryDialect({} as unknown as Knex);\n\n  it('extracts single-value link titles via metadata without pg_typeof guards', () => {\n    const sql = dialect.linkExtractTitles('\"main\".\"LinkField\"', false);\n    expect(sql).toBe(\n      `(CASE WHEN \"main\".\"LinkField\" IS NULL THEN NULL ELSE (\"main\".\"LinkField\"::jsonb)->>'title' END)`\n    );\n    expect(sql).not.toContain('pg_typeof');\n  });\n\n  it('extracts multi-value link titles using jsonb_array_elements without pg_typeof', () => {\n    const sql = dialect.linkExtractTitles('\"cte\".\"link_value\"', true);\n    expect(sql).toContain('jsonb_array_elements(\"cte\".\"link_value\"::jsonb)');\n    expect(sql).not.toContain('pg_typeof');\n  });\n});\n\ndescribe('PgRecordQueryDialect#coerceToNumericForCompare', () => {\n  const dialect = new PgRecordQueryDialect({} as unknown as Knex);\n\n  it('keeps trusted numeric literals as direct numeric casts', () => {\n    const sql = dialect.coerceToNumericForCompare('39.93');\n    expect(sql).toBe('(39.93)::numeric');\n  });\n\n  it('guards malformed sanitized text before numeric cast', () => {\n    const sql = dialect.coerceToNumericForCompare('\"t\".\"DisplayPrice\"');\n    expect(sql).toContain(\"REGEXP_REPLACE(((\\\"t\\\".\\\"DisplayPrice\\\")::text), '[^0-9.+-]', '', 'g')\");\n    expect(sql).toContain(\"~ '^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'\");\n    expect(sql).toContain('THEN NULLIF(');\n    expect(sql).toContain('::numeric');\n    expect(sql).toContain('ELSE NULL');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts",
    "content": "import type {\n  INumberFormatting,\n  ICurrencyFormatting,\n  FieldCore,\n  IDatetimeFormatting,\n  Relationship,\n} from '@teable/core';\nimport {\n  DriverClient,\n  FieldType,\n  CellValueType,\n  DbFieldType,\n  DateFormattingPreset,\n  TimeFormatting,\n} from '@teable/core';\nimport type { Knex } from 'knex';\nimport { FieldFormattingVisitor } from '../field-formatting-visitor';\nimport type { IRecordQueryDialectProvider } from '../record-query-dialect.interface';\n\nexport class PgRecordQueryDialect implements IRecordQueryDialectProvider {\n  readonly driver = DriverClient.Pg as const;\n\n  constructor(private readonly knex: Knex) {}\n\n  toText(expr: string): string {\n    return `(${expr})::TEXT`;\n  }\n\n  formatNumber(expr: string, formatting: INumberFormatting): string {\n    const { type, precision } = formatting;\n    switch (type) {\n      case 'decimal':\n        return `ROUND(CAST(${expr} AS NUMERIC), ${precision ?? 0})::TEXT`;\n      case 'percent':\n        return `ROUND(CAST(${expr} * 100 AS NUMERIC), ${precision ?? 0})::TEXT || '%'`;\n      case 'currency': {\n        const symbol = (formatting as ICurrencyFormatting).symbol || '$';\n        if (typeof precision === 'number') {\n          return `'${symbol}' || ROUND(CAST(${expr} AS NUMERIC), ${precision})::TEXT`;\n        }\n        return `'${symbol}' || (${expr})::TEXT`;\n      }\n      default:\n        return `(${expr})::TEXT`;\n    }\n  }\n\n  formatNumberArray(expr: string, formatting: INumberFormatting): string {\n    const elem = `(elem #>> '{}')::numeric`;\n    const formatted = this.formatNumber(elem, formatting).replace(\n      /\\(elem #>> '\\{\\}'\\)::numeric/,\n      elem\n    );\n    return `(\n        SELECT string_agg(${formatted}, ', ' ORDER BY ord)\n        FROM jsonb_array_elements(COALESCE((${expr})::jsonb, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)\n      )`;\n  }\n\n  formatStringArray(expr: string, opts?: { fieldInfo?: FieldCore }): string {\n    const trimmedRaw = expr.trim();\n    const upperExpr = trimmedRaw.toUpperCase();\n    if (upperExpr === 'NULL' || upperExpr === 'NULL::JSONB' || upperExpr === 'NULL::JSON') {\n      return 'NULL::text';\n    }\n    if (upperExpr.startsWith('NULL::') && !upperExpr.startsWith('NULL::TEXT')) {\n      return `${trimmedRaw}::text`;\n    }\n    if (upperExpr === 'NULL::TEXT') {\n      return trimmedRaw;\n    }\n    const safeArrayExpr =\n      this.buildArrayNormalizerFromField(expr, opts?.fieldInfo) ??\n      this.buildGenericArrayNormalizer(expr);\n    const elementText = `CASE\n      WHEN jsonb_typeof(elem) = 'object' THEN COALESCE(elem->>'title', elem->>'name', elem #>> '{}')\n      ELSE elem #>> '{}'\n    END`;\n    return `(\n        SELECT string_agg(\n          ${elementText},\n          ', '\n          ORDER BY ord\n        )\n        FROM jsonb_array_elements(${safeArrayExpr}) WITH ORDINALITY AS t(elem, ord)\n      )`;\n  }\n\n  private buildArrayNormalizerFromField(expr: string, fieldInfo?: FieldCore): string | null {\n    if (!fieldInfo) {\n      return null;\n    }\n\n    const baseExpr = `(${expr})`;\n    const isLikelyJson =\n      (fieldInfo as unknown as { isMultipleCellValue?: boolean }).isMultipleCellValue === true ||\n      fieldInfo.dbFieldType === DbFieldType.Json ||\n      fieldInfo.type === FieldType.Link ||\n      fieldInfo.type === FieldType.Attachment ||\n      fieldInfo.type === FieldType.MultipleSelect ||\n      fieldInfo.type === FieldType.User ||\n      fieldInfo.type === FieldType.CreatedBy ||\n      fieldInfo.type === FieldType.LastModifiedBy;\n\n    if (!isLikelyJson) {\n      return null;\n    }\n\n    const jsonExpr = `to_jsonb(${baseExpr})`;\n\n    return `(CASE\n      WHEN ${baseExpr} IS NULL THEN '[]'::jsonb\n      WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN COALESCE(${jsonExpr}, '[]'::jsonb)\n      ELSE jsonb_build_array(${jsonExpr})\n    END)`;\n  }\n\n  private buildGenericArrayNormalizer(expr: string): string {\n    const jsonExpr = `to_jsonb(${expr})`;\n    const textExpr = `((${expr})::text)`;\n    const trimmedExpr = `BTRIM(${textExpr})`;\n    const parsedTextArray = `CASE\n          WHEN ${trimmedExpr} = '' THEN '[]'::jsonb\n          WHEN LEFT(${trimmedExpr}, 1) = '[' THEN COALESCE((${expr})::jsonb, '[]'::jsonb)\n          ELSE jsonb_build_array(${jsonExpr})\n        END`;\n\n    return `(CASE\n        WHEN ${expr} IS NULL THEN '[]'::jsonb\n        WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN COALESCE(${jsonExpr}, '[]'::jsonb)\n        WHEN jsonb_typeof(${jsonExpr}) = 'object' THEN jsonb_build_array(${jsonExpr})\n        ELSE ${parsedTextArray}\n      END)`;\n  }\n\n  formatRating(expr: string): string {\n    return `CASE WHEN (${expr} = ROUND(${expr})) THEN ROUND(${expr})::TEXT ELSE (${expr})::TEXT END`;\n  }\n\n  private escapeLiteral(value: string): string {\n    return value.replace(/'/g, \"''\");\n  }\n\n  private getDatePattern(date: string): string {\n    switch (date as DateFormattingPreset) {\n      case DateFormattingPreset.US:\n        return 'FMMM/FMDD/YYYY';\n      case DateFormattingPreset.European:\n        return 'FMDD/FMMM/YYYY';\n      case DateFormattingPreset.Asian:\n        return 'YYYY/MM/DD';\n      case DateFormattingPreset.ISO:\n        return 'YYYY-MM-DD';\n      case DateFormattingPreset.YM:\n        return 'YYYY-MM';\n      case DateFormattingPreset.MD:\n        return 'MM-DD';\n      case DateFormattingPreset.Y:\n        return 'YYYY';\n      case DateFormattingPreset.M:\n        return 'MM';\n      case DateFormattingPreset.D:\n        return 'DD';\n      default:\n        return 'YYYY-MM-DD';\n    }\n  }\n\n  private getTimePattern(time: TimeFormatting | undefined): string | null {\n    switch (time) {\n      case TimeFormatting.Hour24:\n        return 'HH24:MI';\n      case TimeFormatting.Hour12:\n        return 'HH12:MI AM';\n      default:\n        return null;\n    }\n  }\n\n  private buildDateFormattingExpression(\n    valueExpression: string,\n    formatting: IDatetimeFormatting\n  ): string {\n    const { date, time, timeZone } = formatting;\n    const timePattern = this.getTimePattern(time ?? TimeFormatting.None);\n    const datePattern = this.getDatePattern(date);\n    const pattern = timePattern ? `${datePattern} ${timePattern}` : datePattern;\n    const tz = this.escapeLiteral(timeZone ?? 'UTC');\n    const patternLiteral = this.escapeLiteral(pattern);\n    return `TO_CHAR(TIMEZONE('${tz}', (${valueExpression})::timestamptz), '${patternLiteral}')`;\n  }\n\n  formatDate(expr: string, formatting: IDatetimeFormatting): string {\n    return this.buildDateFormattingExpression(expr, formatting);\n  }\n\n  formatDateArray(expr: string, formatting: IDatetimeFormatting): string {\n    const elementExpr = this.buildDateFormattingExpression(\"(elem #>> '{}')\", formatting);\n    return `(\n        SELECT string_agg(\n          CASE\n            WHEN (elem #>> '{}') IS NULL THEN NULL\n            ELSE ${elementExpr}\n          END,\n          ', '\n          ORDER BY ord\n        )\n        FROM jsonb_array_elements(COALESCE((${expr})::jsonb, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)\n      )`;\n  }\n\n  private hasWrappingParentheses(expr: string): boolean {\n    if (!expr.startsWith('(') || !expr.endsWith(')')) {\n      return false;\n    }\n    let depth = 0;\n    for (let i = 0; i < expr.length; i++) {\n      const ch = expr[i];\n      if (ch === '(') {\n        depth++;\n      } else if (ch === ')') {\n        depth--;\n        if (depth === 0 && i < expr.length - 1) {\n          return false;\n        }\n        if (depth < 0) {\n          return false;\n        }\n      }\n    }\n    return depth === 0;\n  }\n\n  private isNumericLiteral(expr: string): boolean {\n    let trimmed = expr.trim();\n    while (trimmed.length > 0 && this.hasWrappingParentheses(trimmed)) {\n      trimmed = trimmed.slice(1, -1).trim();\n    }\n    // eslint-disable-next-line regexp/no-unused-capturing-group\n    return /^[-+]?\\d+(\\.\\d+)?$/.test(trimmed);\n  }\n\n  coerceToNumericForCompare(expr: string): string {\n    // Same safe numeric coercion used for arithmetic\n    if (this.isNumericLiteral(expr)) {\n      return `(${expr})::numeric`;\n    }\n    return this.buildSafeNumericExpression(expr, 'numeric');\n  }\n\n  linkHasAny(selectionSql: string): string {\n    return `(${selectionSql} IS NOT NULL AND ${selectionSql}::text != 'null' AND ${selectionSql}::text != '[]')`;\n  }\n\n  linkExtractTitles(selectionSql: string, isMultiple: boolean): string {\n    const normalized = `${selectionSql}::jsonb`;\n\n    if (isMultiple) {\n      return `(SELECT json_agg(value->>'title') FROM jsonb_array_elements(${normalized}) AS value)::jsonb`;\n    }\n\n    return `(CASE WHEN ${selectionSql} IS NULL THEN NULL ELSE (${normalized})->>'title' END)`;\n  }\n\n  jsonTitleFromExpr(selectionSql: string): string {\n    return `(${selectionSql}->>'title')`;\n  }\n\n  selectUserNameById(idRef: string): string {\n    return `(SELECT u.name FROM users u WHERE u.id = ${idRef})`;\n  }\n\n  buildUserJsonObjectById(idRef: string): string {\n    return `(\n        SELECT jsonb_build_object('id', u.id, 'title', u.name, 'email', u.email)\n        FROM users u\n        WHERE u.id = ${idRef}\n      )`;\n  }\n\n  flattenLookupCteValue(\n    cteName: string,\n    fieldId: string,\n    isMultiple: boolean,\n    dbFieldType: DbFieldType\n  ): string | null {\n    if (!isMultiple) return null;\n    const columnRef = `\"${cteName}\".\"lookup_${fieldId}\"`;\n    const normalized =\n      dbFieldType === DbFieldType.Json ? `${columnRef}::jsonb` : `to_jsonb(${columnRef})`;\n    return `(\n            WITH RECURSIVE f(e) AS (\n              SELECT ${normalized}\n              UNION ALL\n              SELECT jsonb_array_elements(f.e)\n              FROM f\n              WHERE jsonb_typeof(f.e) = 'array'\n            )\n            SELECT jsonb_agg(e) FILTER (WHERE jsonb_typeof(e) <> 'array') FROM f\n          )`;\n  }\n\n  jsonAggregateNonNull(expression: string, orderByClause?: string): string {\n    const order = orderByClause ? ` ORDER BY ${orderByClause}` : '';\n    const normalizedExpr = this.normalizeJsonbAggregateInput(expression);\n    // Use jsonb_agg so downstream consumers (persisted link/lookup columns) expecting jsonb\n    // do not hit implicit cast issues during UPDATE ... FROM assignments.\n    return `jsonb_agg(${normalizedExpr}${order}) FILTER (WHERE ${normalizedExpr} IS NOT NULL)`;\n  }\n\n  private normalizeJsonbAggregateInput(expression: string): string {\n    const trimmed = expression.trim();\n    if (!trimmed) {\n      return expression;\n    }\n    const upper = trimmed.toUpperCase();\n    if (upper === 'NULL') {\n      return 'NULL::jsonb';\n    }\n    if (upper === 'NULL::JSONB') {\n      return trimmed;\n    }\n    if (upper.startsWith('NULL::')) {\n      return `(${expression})::jsonb`;\n    }\n    return expression;\n  }\n\n  stringAggregate(expression: string, delimiter: string, orderByClause?: string): string {\n    const order = orderByClause ? ` ORDER BY ${orderByClause}` : '';\n    return `STRING_AGG(${expression}::text, ${this.knex.raw('?', [delimiter]).toQuery()}${order})`;\n  }\n\n  jsonArrayLength(expr: string): string {\n    return `jsonb_array_length(${expr}::jsonb)`;\n  }\n\n  nullJson(): string {\n    return 'NULL::json';\n  }\n\n  typedNullFor(dbFieldType: DbFieldType): string {\n    switch (dbFieldType) {\n      case DbFieldType.Json:\n        return 'NULL::jsonb';\n      case DbFieldType.Integer:\n        return 'NULL::integer';\n      case DbFieldType.Real:\n        return 'NULL::double precision';\n      case DbFieldType.DateTime:\n        return 'NULL::timestamptz';\n      case DbFieldType.Boolean:\n        return 'NULL::boolean';\n      case DbFieldType.Blob:\n        return 'NULL::bytea';\n      case DbFieldType.Text:\n      default:\n        return 'NULL::text';\n    }\n  }\n\n  private buildSafeNumericExpression(\n    expression: string,\n    castType: 'numeric' | 'double precision'\n  ): string {\n    const cleaned = this.buildSanitizedNumericText(expression);\n    const numericPattern = `'^[+-]{0,1}(\\\\d+(\\\\.\\\\d+){0,1}|\\\\.\\\\d+)$'`;\n    return `(CASE\n      WHEN ${cleaned} IS NULL THEN NULL\n      WHEN ${cleaned} ~ ${numericPattern} THEN ${cleaned}::${castType}\n      ELSE NULL\n    END)`;\n  }\n\n  private buildSanitizedNumericText(expression: string): string {\n    const textExpr = `((${expression})::text)`;\n    const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;\n    return `NULLIF(${sanitized}, '')`;\n  }\n\n  private sanitizeNumericTextExpression(expression: string): string {\n    return this.buildSafeNumericExpression(expression, 'double precision');\n  }\n\n  private buildJsonNumericSumExpression(fieldExpression: string): string {\n    const expr = `(${fieldExpression})`;\n    const scalarValue = this.sanitizeNumericTextExpression(expr);\n    const arraySum = `(SELECT SUM(${this.sanitizeNumericTextExpression('elem.value')})\n        FROM jsonb_array_elements_text(${expr}::jsonb) AS elem(value))`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN 0\n      WHEN jsonb_typeof(${expr}::jsonb) = 'array' THEN COALESCE(${arraySum}, 0)\n      ELSE COALESCE(${scalarValue}, 0)\n    END)`;\n  }\n\n  private buildJsonNumericCountExpression(fieldExpression: string): string {\n    const expr = `(${fieldExpression})`;\n    const scalarValue = this.sanitizeNumericTextExpression(expr);\n    const scalarCount = `(CASE WHEN ${scalarValue} IS NULL THEN 0 ELSE 1 END)`;\n    const elementCount = `(SELECT SUM(CASE WHEN ${this.sanitizeNumericTextExpression('elem.value')} IS NULL THEN 0 ELSE 1 END)\n        FROM jsonb_array_elements_text(${expr}::jsonb) AS elem(value))`;\n    return `(CASE\n      WHEN ${expr} IS NULL THEN 0\n      WHEN jsonb_typeof(${expr}::jsonb) = 'array' THEN COALESCE(${elementCount}, 0)\n      ELSE ${scalarCount}\n    END)`;\n  }\n\n  private castAgg(sql: string): string {\n    // normalize to double precision for numeric rollups\n    return `CAST(${sql} AS DOUBLE PRECISION)`;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  rollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    opts: {\n      targetField?: FieldCore;\n      orderByField?: string;\n      rowPresenceExpr?: string;\n      flattenNestedArray?: boolean;\n    }\n  ): string {\n    const { targetField, orderByField, rowPresenceExpr, flattenNestedArray } = opts;\n    const isNumericTarget =\n      targetField?.type === FieldType.Number ||\n      (targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===\n        CellValueType.Number;\n\n    switch (fn) {\n      case 'sum':\n        // Prefer numeric targets: number field or formula resolving to number\n        if (isNumericTarget) {\n          if (targetField?.isMultipleCellValue) {\n            const numericExpr = this.buildJsonNumericSumExpression(fieldExpression);\n            return this.castAgg(`COALESCE(SUM(${numericExpr}), 0)`);\n          }\n          return this.castAgg(`COALESCE(SUM(${fieldExpression}), 0)`);\n        }\n        // Non-numeric target: avoid SUM() casting errors\n        return this.castAgg('SUM(0)');\n      case 'average':\n        if (isNumericTarget) {\n          if (targetField?.isMultipleCellValue) {\n            const sumExpr = this.buildJsonNumericSumExpression(fieldExpression);\n            const countExpr = this.buildJsonNumericCountExpression(fieldExpression);\n            const sumAgg = `COALESCE(SUM(${sumExpr}), 0)`;\n            const countAgg = `COALESCE(SUM(${countExpr}), 0)`;\n            return this.castAgg(\n              `CASE WHEN ${countAgg} = 0 THEN 0 ELSE ${sumAgg} / ${countAgg} END`\n            );\n          }\n          return this.castAgg(`COALESCE(AVG(${fieldExpression}), 0)`);\n        }\n        return this.castAgg('AVG(0)');\n      case 'count':\n        return this.castAgg(`COALESCE(COUNT(${fieldExpression}), 0)`);\n      case 'countall': {\n        if (targetField?.type === FieldType.MultipleSelect) {\n          return this.castAgg(\n            `COALESCE(SUM(CASE WHEN ${fieldExpression} IS NOT NULL THEN jsonb_array_length(${fieldExpression}::jsonb) ELSE 0 END), 0)`\n          );\n        }\n        const base = rowPresenceExpr ?? fieldExpression;\n        return this.castAgg(`COALESCE(COUNT(${base}), 0)`);\n      }\n      case 'counta':\n        return this.castAgg(`COALESCE(COUNT(${fieldExpression}), 0)`);\n      case 'max': {\n        const isDateFieldType =\n          targetField?.type === FieldType.Date ||\n          targetField?.type === FieldType.CreatedTime ||\n          targetField?.type === FieldType.LastModifiedTime;\n        const isDateTimeTarget =\n          isDateFieldType ||\n          targetField?.cellValueType === CellValueType.DateTime ||\n          targetField?.dbFieldType === DbFieldType.DateTime;\n        const aggregate = `MAX(${fieldExpression})`;\n        return isDateTimeTarget ? aggregate : this.castAgg(aggregate);\n      }\n      case 'min': {\n        const isDateFieldType =\n          targetField?.type === FieldType.Date ||\n          targetField?.type === FieldType.CreatedTime ||\n          targetField?.type === FieldType.LastModifiedTime;\n        const isDateTimeTarget =\n          isDateFieldType ||\n          targetField?.cellValueType === CellValueType.DateTime ||\n          targetField?.dbFieldType === DbFieldType.DateTime;\n        const aggregate = `MIN(${fieldExpression})`;\n        return isDateTimeTarget ? aggregate : this.castAgg(aggregate);\n      }\n      case 'and':\n        return `BOOL_AND(${fieldExpression}::boolean)`;\n      case 'or':\n        return `BOOL_OR(${fieldExpression}::boolean)`;\n      case 'xor':\n        return `(COUNT(CASE WHEN ${fieldExpression}::boolean THEN 1 END) % 2 = 1)`;\n      case 'array_join':\n      case 'concatenate':\n        return orderByField\n          ? `STRING_AGG(${fieldExpression}::text, ', ' ORDER BY ${orderByField})`\n          : `STRING_AGG(${fieldExpression}::text, ', ')`;\n      case 'array_unique':\n        return `json_agg(DISTINCT ${fieldExpression})`;\n      case 'array_compact': {\n        const buildAggregate = (expr: string) =>\n          orderByField\n            ? `jsonb_agg(${expr} ORDER BY ${orderByField}) FILTER (WHERE (${expr}) IS NOT NULL AND (${expr})::text <> '')`\n            : `jsonb_agg(${expr}) FILTER (WHERE (${expr}) IS NOT NULL AND (${expr})::text <> '')`;\n        const baseAggregate = buildAggregate(fieldExpression);\n        if (flattenNestedArray) {\n          return `(WITH RECURSIVE flattened(val) AS (\n              SELECT COALESCE(${baseAggregate}, '[]'::jsonb)\n              UNION ALL\n              SELECT elem\n              FROM flattened\n              CROSS JOIN LATERAL jsonb_array_elements(flattened.val) AS elem\n              WHERE jsonb_typeof(flattened.val) = 'array'\n            )\n            SELECT jsonb_agg(val) FILTER (\n              WHERE jsonb_typeof(val) <> 'array'\n                AND jsonb_typeof(val) <> 'null'\n                AND val <> '\"\"'::jsonb\n            ) FROM flattened)`;\n        }\n        return baseAggregate;\n      }\n      default:\n        throw new Error(`Unsupported rollup function: ${fn}`);\n    }\n  }\n\n  singleValueRollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    options: { rollupField: FieldCore; targetField: FieldCore }\n  ): string {\n    const requiresJsonArray = options.rollupField.dbFieldType === DbFieldType.Json;\n    const needsFormatted =\n      (options.targetField.type === FieldType.Link ||\n        options.targetField.type === FieldType.Formula ||\n        options.targetField.type === FieldType.ConditionalRollup) &&\n      (fn === 'array_join' ||\n        fn === 'concatenate' ||\n        fn === 'array_unique' ||\n        fn === 'array_compact');\n    const formattedExpr = needsFormatted\n      ? options.targetField.accept(new FieldFormattingVisitor(fieldExpression, this))\n      : fieldExpression;\n    const exprForAggregation = needsFormatted ? formattedExpr : fieldExpression;\n    switch (fn) {\n      case 'sum':\n      case 'average':\n        // For single-value relationships, SUM reduces to the value itself.\n        // Coalesce to 0 and cast to double precision for numeric stability.\n        // If the expression is non-numeric, upstream rollup setup should avoid SUM on such targets.\n        return `COALESCE(CAST(${fieldExpression} AS DOUBLE PRECISION), 0)`;\n      case 'max':\n      case 'min':\n      case 'array_join':\n      case 'concatenate':\n        return `${exprForAggregation}`;\n      case 'count':\n      case 'countall':\n      case 'counta':\n        return `CASE WHEN ${fieldExpression} IS NULL THEN 0 ELSE 1 END`;\n      case 'and':\n      case 'or':\n      case 'xor':\n        return `(COALESCE((${fieldExpression})::boolean, false))`;\n      case 'array_unique':\n      case 'array_compact':\n        if (!requiresJsonArray) {\n          return `${fieldExpression}`;\n        }\n        return `(CASE WHEN ${fieldExpression} IS NULL THEN '[]'::jsonb ELSE jsonb_build_array(${fieldExpression}) END)`;\n      default:\n        return `${fieldExpression}`;\n    }\n  }\n\n  buildLinkJsonObject(\n    recordIdRef: string,\n    formattedSelectionExpression: string,\n    _rawSelectionExpression: string\n  ): string {\n    return `jsonb_strip_nulls(jsonb_build_object('id', ${recordIdRef}, 'title', ${formattedSelectionExpression}))::jsonb`;\n  }\n\n  applyLinkCteOrdering(\n    _qb: Knex.QueryBuilder,\n    _opts: {\n      relationship: Relationship;\n      usesJunctionTable: boolean;\n      hasOrderColumn: boolean;\n      junctionAlias: string;\n      foreignAlias: string;\n      selfKeyName: string;\n    }\n  ): void {\n    // Postgres needs no extra ordering hacks at CTE level for json_agg\n  }\n\n  buildDeterministicLookupAggregate(): string | null {\n    // PG returns null to signal not needed; caller should use json_agg with ORDER BY\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/providers/sqlite-record-query-dialect.ts",
    "content": "import type {\n  INumberFormatting,\n  ICurrencyFormatting,\n  FieldCore,\n  IDatetimeFormatting,\n} from '@teable/core';\nimport { DriverClient, FieldType, Relationship, DbFieldType } from '@teable/core';\nimport type { Knex } from 'knex';\nimport type { IRecordQueryDialectProvider } from '../record-query-dialect.interface';\n\nexport class SqliteRecordQueryDialect implements IRecordQueryDialectProvider {\n  readonly driver = DriverClient.Sqlite as const;\n\n  constructor(private readonly knex: Knex) {}\n\n  toText(expr: string): string {\n    return `CAST(${expr} AS TEXT)`;\n  }\n\n  formatNumber(expr: string, formatting: INumberFormatting): string {\n    const { type, precision } = formatting;\n    switch (type) {\n      case 'decimal':\n        return `PRINTF('%.${precision ?? 0}f', ${expr})`;\n      case 'percent':\n        return `PRINTF('%.${precision ?? 0}f', ${expr} * 100) || '%'`;\n      case 'currency': {\n        const symbol = (formatting as ICurrencyFormatting).symbol || '$';\n        if (typeof precision === 'number') {\n          return `'${symbol}' || PRINTF('%.${precision}f', ${expr})`;\n        }\n        return `'${symbol}' || CAST(${expr} AS TEXT)`;\n      }\n      default:\n        return `CAST(${expr} AS TEXT)`;\n    }\n  }\n\n  formatNumberArray(expr: string, formatting: INumberFormatting): string {\n    const elemNumExpr = `CAST(json_extract(value, '$') AS NUMERIC)`;\n    const formatted = this.formatNumber(elemNumExpr, formatting).replace(\n      /CAST\\(json_extract\\(value, '\\$'\\) AS NUMERIC\\)/g,\n      elemNumExpr\n    );\n    const safeArrayExpr = `CASE WHEN json_valid(${expr}) THEN ${expr} ELSE json('[]') END`;\n    return `(\n        SELECT GROUP_CONCAT(${formatted}, ', ')\n        FROM json_each(${safeArrayExpr})\n        ORDER BY key\n      )`;\n  }\n\n  formatStringArray(expr: string, _opts?: { fieldInfo?: FieldCore }): string {\n    const safeArrayExpr = `CASE WHEN json_valid(${expr}) THEN ${expr} ELSE json('[]') END`;\n    return `(\n        SELECT GROUP_CONCAT(\n          CASE\n            WHEN json_type(value) = 'text' THEN json_extract(value, '$')\n            WHEN json_type(value) = 'object' THEN json_extract(value, '$.title')\n            ELSE value\n          END,\n          ', '\n        )\n        FROM json_each(${safeArrayExpr})\n        ORDER BY key\n      )`;\n  }\n\n  formatRating(expr: string): string {\n    return `CASE WHEN (${expr} = CAST(${expr} AS INTEGER)) THEN CAST(CAST(${expr} AS INTEGER) AS TEXT) ELSE CAST(${expr} AS TEXT) END`;\n  }\n\n  formatDate(expr: string, _formatting: IDatetimeFormatting): string {\n    return `CAST(${expr} AS TEXT)`;\n  }\n\n  formatDateArray(expr: string, _formatting: IDatetimeFormatting): string {\n    return this.formatStringArray(expr);\n  }\n\n  coerceToNumericForCompare(expr: string): string {\n    return `CAST(${expr} AS NUMERIC)`;\n  }\n\n  linkHasAny(selectionSql: string): string {\n    return `(${selectionSql} IS NOT NULL AND ${selectionSql} != 'null' AND ${selectionSql} != '[]')`;\n  }\n\n  linkExtractTitles(selectionSql: string, isMultiple: boolean): string {\n    if (isMultiple) {\n      return `(\n        SELECT json_group_array(json_extract(value, '$.title'))\n        FROM json_each(CASE WHEN json_valid(${selectionSql}) AND json_type(${selectionSql}) = 'array' THEN ${selectionSql} ELSE json('[]') END) AS value\n        ORDER BY key\n      )`;\n    }\n    return `json_extract(${selectionSql}, '$.title')`;\n  }\n\n  jsonTitleFromExpr(selectionSql: string): string {\n    return `json_extract(${selectionSql}, '$.title')`;\n  }\n\n  selectUserNameById(idRef: string): string {\n    return `(SELECT name FROM users WHERE id = ${idRef})`;\n  }\n\n  buildUserJsonObjectById(idRef: string): string {\n    return `json_object(\n        'id', ${idRef},\n        'title', (SELECT name FROM users WHERE id = ${idRef}),\n        'email', (SELECT email FROM users WHERE id = ${idRef})\n      )`;\n  }\n\n  flattenLookupCteValue(\n    _cteName: string,\n    _fieldId: string,\n    _isMultiple: boolean,\n    _dbFieldType: DbFieldType\n  ): string | null {\n    return null;\n  }\n\n  jsonAggregateNonNull(expression: string): string {\n    return `json_group_array(CASE WHEN ${expression} IS NOT NULL THEN ${expression} END)`;\n  }\n\n  stringAggregate(expression: string, delimiter: string): string {\n    return `GROUP_CONCAT(${expression}, ${this.knex.raw('?', [delimiter]).toQuery()})`;\n  }\n\n  jsonArrayLength(expr: string): string {\n    return `json_array_length(${expr})`;\n  }\n\n  nullJson(): string {\n    return 'NULL';\n  }\n\n  typedNullFor(_dbFieldType: DbFieldType): string {\n    // SQLite does not require type-specific NULL casts\n    return 'NULL';\n  }\n\n  rollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    opts: {\n      targetField?: FieldCore;\n      orderByField?: string;\n      rowPresenceExpr?: string;\n      flattenNestedArray?: boolean;\n    }\n  ): string {\n    const { targetField } = opts;\n    switch (fn) {\n      case 'sum':\n        return `COALESCE(SUM(${fieldExpression}), 0)`;\n      case 'average':\n        return `COALESCE(AVG(${fieldExpression}), 0)`;\n      case 'count':\n        return `COALESCE(COUNT(${fieldExpression}), 0)`;\n      case 'countall': {\n        if (targetField?.type === FieldType.MultipleSelect) {\n          return `COALESCE(SUM(CASE WHEN ${fieldExpression} IS NOT NULL THEN json_array_length(${fieldExpression}) ELSE 0 END), 0)`;\n        }\n        return `COALESCE(COUNT(${opts.rowPresenceExpr ?? fieldExpression}), 0)`;\n      }\n      case 'counta':\n        return `COALESCE(COUNT(${fieldExpression}), 0)`;\n      case 'max':\n        return `MAX(${fieldExpression})`;\n      case 'min':\n        return `MIN(${fieldExpression})`;\n      case 'and':\n        return `MIN(${fieldExpression})`;\n      case 'or':\n        return `MAX(${fieldExpression})`;\n      case 'xor':\n        return `(COUNT(CASE WHEN ${fieldExpression} THEN 1 END) % 2 = 1)`;\n      case 'array_join':\n      case 'concatenate':\n        return `GROUP_CONCAT(${fieldExpression}, ', ')`;\n      case 'array_unique':\n        return `json_group_array(DISTINCT ${fieldExpression})`;\n      case 'array_compact':\n        return `json_group_array(CASE WHEN ${fieldExpression} IS NOT NULL AND CAST(${fieldExpression} AS TEXT) <> '' THEN ${fieldExpression} END)`;\n      default:\n        throw new Error(`Unsupported rollup function: ${fn}`);\n    }\n  }\n\n  singleValueRollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    options: { rollupField: FieldCore; targetField: FieldCore }\n  ): string {\n    const requiresJsonArray = options.rollupField.dbFieldType === DbFieldType.Json;\n    switch (fn) {\n      case 'sum':\n      case 'average':\n        return `COALESCE(${fieldExpression}, 0)`;\n      case 'max':\n      case 'min':\n      case 'array_join':\n      case 'concatenate':\n        return `${fieldExpression}`;\n      case 'count':\n      case 'countall':\n      case 'counta':\n        return `CASE WHEN ${fieldExpression} IS NULL THEN 0 ELSE 1 END`;\n      case 'and':\n      case 'or':\n      case 'xor':\n        return `(CASE WHEN ${fieldExpression} THEN 1 ELSE 0 END)`;\n      case 'array_unique':\n      case 'array_compact':\n        if (!requiresJsonArray) {\n          return `${fieldExpression}`;\n        }\n        return `(CASE WHEN ${fieldExpression} IS NULL THEN json('[]') ELSE json_array(${fieldExpression}) END)`;\n      default:\n        return `${fieldExpression}`;\n    }\n  }\n\n  buildLinkJsonObject(\n    recordIdRef: string,\n    formattedSelectionExpression: string,\n    rawSelectionExpression: string\n  ): string {\n    return `CASE\n          WHEN ${rawSelectionExpression} IS NOT NULL THEN json_object('id', ${recordIdRef}, 'title', ${formattedSelectionExpression})\n          ELSE json_object('id', ${recordIdRef})\n        END`;\n  }\n\n  applyLinkCteOrdering(\n    qb: Knex.QueryBuilder,\n    opts: {\n      relationship: Relationship;\n      usesJunctionTable: boolean;\n      hasOrderColumn: boolean;\n      junctionAlias: string;\n      foreignAlias: string;\n      selfKeyName: string;\n    }\n  ): void {\n    // Apply deterministic ordering for SQLite when aggregating arrays\n    const {\n      relationship,\n      usesJunctionTable,\n      hasOrderColumn,\n      junctionAlias,\n      foreignAlias,\n      selfKeyName,\n    } = opts;\n    if (usesJunctionTable) {\n      if (hasOrderColumn) {\n        qb.orderByRaw(`(CASE WHEN ${junctionAlias}.\"order\" IS NULL THEN 0 ELSE 1 END) ASC`);\n        qb.orderBy(`${junctionAlias}.\"order\"`, 'asc');\n      }\n      qb.orderBy(`${junctionAlias}.__id`, 'asc');\n    } else if (relationship === Relationship.OneMany) {\n      if (hasOrderColumn) {\n        qb.orderByRaw(\n          `(CASE WHEN ${foreignAlias}.${selfKeyName}_order IS NULL THEN 0 ELSE 1 END) ASC`\n        );\n        qb.orderBy(`${foreignAlias}.${selfKeyName}_order`, 'asc');\n      }\n      qb.orderBy(`${foreignAlias}.__id`, 'asc');\n    }\n  }\n\n  buildDeterministicLookupAggregate({\n    tableDbName,\n    mainAlias,\n    foreignDbName,\n    foreignAlias,\n    linkFieldOrderColumn,\n    linkFieldHasOrderColumn,\n    usesJunctionTable,\n    selfKeyName,\n    foreignKeyName,\n    recordIdRef,\n    formattedSelectionExpression,\n    rawSelectionExpression,\n    linkFilterSubquerySql,\n    junctionAlias,\n  }: {\n    tableDbName: string;\n    mainAlias: string;\n    foreignDbName: string;\n    foreignAlias: string;\n    linkFieldOrderColumn?: string;\n    linkFieldHasOrderColumn: boolean;\n    usesJunctionTable: boolean;\n    selfKeyName: string;\n    foreignKeyName: string;\n    recordIdRef: string;\n    formattedSelectionExpression: string;\n    rawSelectionExpression: string;\n    linkFilterSubquerySql?: string;\n    junctionAlias: string;\n  }): string | null {\n    // Build correlated, ordered subquery aggregation for SQLite multi-value lookup\n    const innerIdRef = `\"f\".\"__id\"`;\n    const innerTitleExpr = formattedSelectionExpression.replaceAll(`\"${foreignAlias}\"`, '\"f\"');\n    const innerRawExpr = rawSelectionExpression.replaceAll(`\"${foreignAlias}\"`, '\"f\"');\n    const innerJson = `CASE WHEN ${innerRawExpr} IS NOT NULL THEN json_object('id', ${innerIdRef}, 'title', ${innerTitleExpr}) ELSE json_object('id', ${innerIdRef}) END`;\n    const innerFilter = linkFilterSubquerySql\n      ? `(EXISTS ${linkFilterSubquerySql.replaceAll(`\"${foreignAlias}\"`, '\"f\"')})`\n      : '1=1';\n\n    if (usesJunctionTable) {\n      // Prefer preserved insertion order via junction __id; add stable tie-breaker on foreign id\n      const order =\n        linkFieldHasOrderColumn && linkFieldOrderColumn\n          ? `(CASE WHEN ${linkFieldOrderColumn} IS NULL THEN 0 ELSE 1 END) ASC, ${linkFieldOrderColumn} ASC, ${junctionAlias}.\"__id\" ASC, f.\"__id\" ASC`\n          : `${junctionAlias}.\"__id\" ASC, f.\"__id\" ASC`;\n      return `(\n              SELECT CASE WHEN SUM(CASE WHEN ${innerFilter} THEN 1 ELSE 0 END) > 0\n                THEN (\n                  SELECT json_group_array(json(item)) FROM (\n                    SELECT ${innerJson} AS item\n                    FROM \"${tableDbName}\" AS m\n                    JOIN \"${junctionAlias}\" AS j ON m.\"__id\" = j.\"${selfKeyName}\"\n                    JOIN \"${foreignDbName}\" AS f ON j.\"${foreignKeyName}\" = f.\"__id\"\n                    WHERE m.\"__id\" = \"${mainAlias}\".\"__id\" AND (${innerFilter})\n                    ORDER BY ${order}\n                  )\n                )\n                ELSE NULL END\n              FROM \"${junctionAlias}\" AS j\n              JOIN \"${foreignDbName}\" AS f ON j.\"${foreignKeyName}\" = f.\"__id\"\n              WHERE j.\"${selfKeyName}\" = \"${mainAlias}\".\"__id\"\n            )`;\n    }\n\n    const ordCol = linkFieldHasOrderColumn ? `f.\"${selfKeyName}_order\"` : undefined;\n    const order = ordCol\n      ? `(CASE WHEN ${ordCol} IS NULL THEN 0 ELSE 1 END) ASC, ${ordCol} ASC, f.\"__id\" ASC`\n      : `f.\"__id\" ASC`;\n    return `(\n            SELECT CASE WHEN SUM(CASE WHEN ${innerFilter} THEN 1 ELSE 0 END) > 0\n              THEN (\n                SELECT json_group_array(json(item)) FROM (\n                  SELECT ${innerJson} AS item\n                  FROM \"${foreignDbName}\" AS f\n                  WHERE f.\"${selfKeyName}\" = \"${mainAlias}\".\"__id\" AND (${innerFilter})\n                  ORDER BY ${order}\n                )\n              )\n              ELSE NULL END\n            FROM \"${foreignDbName}\" AS f\n            WHERE f.\"${selfKeyName}\" = \"${mainAlias}\".\"__id\"\n          )`;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.interface.ts",
    "content": "import type { FieldCore, IFilter, IGroup, ISortItem, TableDomain, Tables } from '@teable/core';\nimport type { IAggregationField } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { IFieldSelectName } from './field-select.type';\n\nexport interface IPrepareViewParams {\n  tableIdOrDbTableName: string;\n}\n\n/**\n * Options for creating record query builder\n */\nexport interface ICreateRecordQueryBuilderOptions {\n  /** The table ID or database table name */\n  tableId: string;\n  /** Optional preconfigured query builder (e.g., with permission CTEs attached) */\n  builder?: Knex.QueryBuilder;\n  /** Optional view ID for filtering */\n  viewId?: string;\n  /** Optional filter */\n  filter?: IFilter;\n  /** Optional sort */\n  sort?: ISortItem[];\n  /** Optional current user ID */\n  currentUserId?: string;\n  useQueryModel?: boolean;\n  /** Limit SELECT to these field IDs (plus system columns) */\n  projection?: string[];\n  /**\n   * Optional mapping of tableId -> fieldIds to further limit link/lookup CTE generation\n   * on related tables. If omitted, all dependent lookups on foreign tables are considered.\n   */\n  projectionByTable?: Record<string, string[]>;\n  /** Optional pagination limit (take) */\n  limit?: number;\n  /** Optional pagination offset (skip) */\n  offset?: number;\n  /** When true, hide-not-match search filtering is applied */\n  hasSearch?: boolean;\n  /** Optional fallback field used for default ordering */\n  defaultOrderField?: string;\n  /**\n   * When true, select raw DB values for fields instead of formatted display values.\n   * Useful for UPDATE ... FROM (SELECT ...) operations to avoid type mismatches (e.g., timestamptz vs text).\n   */\n  rawProjection?: boolean;\n  /**\n   * When true, prefer raw field references when converting formulas to SQL (skip formatting).\n   * Typically used alongside rawProjection when the consumer needs source values (e.g., jsonb) rather than formatted text.\n   */\n  preferRawFieldReferences?: boolean;\n  /**\n   * Optional list of record IDs to restrict the query to before generating CTEs.\n   * Useful when the caller intends to apply a final WHERE IN \"__id\" (...) filter anyway.\n   */\n  restrictRecordIds?: string[];\n  /**\n   * Optional table domain graph to reuse when building the query.\n   */\n  tables?: Tables;\n}\n\n/**\n * Options for creating record aggregate query builder\n */\nexport interface ICreateRecordAggregateBuilderOptions {\n  /** The table ID or database table name */\n  tableId: string;\n  /** Optional preconfigured query builder (e.g., with permission CTEs attached) */\n  builder?: Knex.QueryBuilder;\n  /** Optional view ID for filtering */\n  viewId?: string;\n  /** Optional filter */\n  filter?: IFilter;\n  /** Aggregation fields to compute */\n  aggregationFields: IAggregationField[];\n  /** Optional group by */\n  groupBy?: IGroup;\n  /** Optional current user ID */\n  currentUserId?: string;\n  /** Optional projection to minimize CTE/select */\n  projection?: string[];\n  useQueryModel?: boolean;\n  /**\n   * Optional list of record IDs to restrict the query to before generating CTEs.\n   */\n  restrictRecordIds?: string[];\n}\n\n/**\n * Interface for record query builder service\n * This interface defines the public API for building table record queries\n */\nexport interface IRecordQueryBuilder {\n  prepareView(\n    from: string,\n    params: IPrepareViewParams\n  ): Promise<{ qb: Knex.QueryBuilder; table: TableDomain }>;\n  /**\n   * Create a record query builder with select fields for the given table\n   * @param queryBuilder - existing query builder to use\n   * @param options - options for creating the query builder\n   * @returns Promise<{ qb: Knex.QueryBuilder }> - The configured query builder\n   */\n  createRecordQueryBuilder(\n    from: string,\n    options: ICreateRecordQueryBuilderOptions\n  ): Promise<{ qb: Knex.QueryBuilder; alias: string; selectionMap: IReadonlyRecordSelectionMap }>;\n\n  /**\n   * Create a record aggregate query builder for aggregation operations\n   * @param queryBuilder - existing query builder to use\n   * @param options - options for creating the aggregate query builder\n   * @returns Promise<{ qb: Knex.QueryBuilder }> - The configured query builder with aggregation\n   */\n  createRecordAggregateBuilder(\n    from: string,\n    options: ICreateRecordAggregateBuilderOptions\n  ): Promise<{ qb: Knex.QueryBuilder; alias: string; selectionMap: IReadonlyRecordSelectionMap }>;\n}\n\n/**\n * IRecordQueryFieldCteMap\n */\nexport type IRecordQueryFieldCteMap = Map<string, string>;\n\nexport type IRecordSelectionMap = Map<string, IFieldSelectName>;\nexport type IReadonlyRecordSelectionMap = ReadonlyMap<string, IFieldSelectName>;\n\n// Query context: whether we build directly from base table or from materialized view\nexport type IRecordQueryContext = 'table' | 'tableCache' | 'view';\n\nexport interface IRecordQueryFilterContext {\n  selectionMap: IReadonlyRecordSelectionMap;\n  fieldReferenceSelectionMap?: Map<string, string>;\n  fieldReferenceFieldMap?: Map<string, FieldCore>;\n}\n\nexport interface IRecordQuerySortContext {\n  selectionMap: IReadonlyRecordSelectionMap;\n}\n\nexport interface IRecordQueryGroupContext {\n  selectionMap: IReadonlyRecordSelectionMap;\n}\n\nexport interface IRecordQueryAggregateContext {\n  selectionMap: IReadonlyRecordSelectionMap;\n  tableDbName: string;\n  tableAlias: string;\n}\n\n/**\n * Readonly state interface for query-builder shared state\n * Provides read access to CTE map and selection map.\n */\nexport interface IReadonlyQueryBuilderState {\n  /** Get immutable view of fieldId -> CTE name */\n  getFieldCteMap(): ReadonlyMap<string, string>;\n  /** Get immutable view of fieldId -> selection (column/expression) */\n  getSelectionMap(): ReadonlyMap<string, IFieldSelectName>;\n  /** Get current query context (table or view) */\n  getContext(): IRecordQueryContext;\n  /** Get main table alias used in the top-level FROM */\n  getMainTableAlias(): string | undefined;\n  /** Get the current source relation used for the main table (table/view/base CTE) */\n  getMainTableSource(): string | undefined;\n  /** Get the original physical source relation for the main table */\n  getOriginalMainTableSource(): string | undefined;\n  /** Get the optional pagination base CTE name */\n  getBaseCteName(): string | undefined;\n  /** Convenience helpers */\n  hasFieldCte(fieldId: string): boolean;\n  getCteName(fieldId: string): string | undefined;\n  /** Check if a CTE has already been joined to the main query */\n  isCteJoined(cteName: string): boolean;\n}\n\n/**\n * Mutable state interface for query-builder shared state\n * Extends readonly with mutation capabilities. Only mutating visitors/services should hold this.\n */\nexport interface IMutableQueryBuilderState extends IReadonlyQueryBuilderState {\n  /** Set fieldId -> CTE name mapping */\n  setFieldCte(fieldId: string, cteName: string): void;\n  /** Clear all CTE mappings (rarely needed) */\n  clearFieldCtes(): void;\n\n  /** Record field selection for top-level select */\n  setSelection(fieldId: string, selection: IFieldSelectName): void;\n  /** Remove a selection entry */\n  deleteSelection(fieldId: string): void;\n  /** Clear selections */\n  clearSelections(): void;\n  /** Set main table alias */\n  setMainTableAlias(alias: string): void;\n  /** Set main table source relation (table/view/cte) */\n  setMainTableSource(source: string): void;\n  /** Set pagination base CTE name */\n  setBaseCteName(cteName: string | undefined): void;\n  /** Mark that a CTE has been joined to the main query */\n  markCteJoined(cteName: string): void;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.manager.ts",
    "content": "import type { IFieldSelectName } from './field-select.type';\nimport type {\n  IReadonlyQueryBuilderState,\n  IMutableQueryBuilderState,\n  IRecordQueryContext,\n} from './record-query-builder.interface';\n\n/**\n * Central manager for query-builder shared state.\n * Implements both readonly and mutable interfaces; pass as readonly where mutation is not allowed.\n */\nexport class RecordQueryBuilderManager implements IMutableQueryBuilderState {\n  constructor(public readonly context: IRecordQueryContext) {}\n  private readonly fieldIdToCteName: Map<string, string> = new Map();\n  private readonly fieldIdToSelection: Map<string, IFieldSelectName> = new Map();\n  private readonly joinedCtes: Set<string> = new Set();\n  private mainAlias?: string;\n  private mainSource?: string;\n  private originalMainSource?: string;\n  private baseCteName?: string;\n\n  // Readonly API\n  getFieldCteMap(): ReadonlyMap<string, string> {\n    return this.fieldIdToCteName;\n  }\n\n  getSelectionMap(): ReadonlyMap<string, IFieldSelectName> {\n    return this.fieldIdToSelection;\n  }\n\n  getContext(): IRecordQueryContext {\n    return this.context;\n  }\n\n  getMainTableAlias(): string | undefined {\n    return this.mainAlias;\n  }\n\n  getMainTableSource(): string | undefined {\n    return this.mainSource;\n  }\n\n  getOriginalMainTableSource(): string | undefined {\n    return this.originalMainSource ?? this.mainSource;\n  }\n\n  getBaseCteName(): string | undefined {\n    return this.baseCteName;\n  }\n\n  hasFieldCte(fieldId: string): boolean {\n    return this.fieldIdToCteName.has(fieldId);\n  }\n\n  getCteName(fieldId: string): string | undefined {\n    return this.fieldIdToCteName.get(fieldId);\n  }\n\n  isCteJoined(cteName: string): boolean {\n    return this.joinedCtes.has(cteName);\n  }\n\n  // Mutable API\n  setFieldCte(fieldId: string, cteName: string): void {\n    this.fieldIdToCteName.set(fieldId, cteName);\n  }\n\n  clearFieldCtes(): void {\n    this.fieldIdToCteName.clear();\n    this.joinedCtes.clear();\n  }\n\n  setSelection(fieldId: string, selection: IFieldSelectName): void {\n    this.fieldIdToSelection.set(fieldId, selection);\n  }\n\n  deleteSelection(fieldId: string): void {\n    this.fieldIdToSelection.delete(fieldId);\n  }\n\n  clearSelections(): void {\n    this.fieldIdToSelection.clear();\n  }\n\n  setMainTableAlias(alias: string): void {\n    this.mainAlias = alias;\n  }\n\n  setMainTableSource(source: string): void {\n    this.mainSource = source;\n    if (!this.originalMainSource) {\n      this.originalMainSource = source;\n    }\n  }\n\n  setBaseCteName(cteName: string | undefined): void {\n    this.baseCteName = cteName;\n  }\n\n  markCteJoined(cteName: string): void {\n    this.joinedCtes.add(cteName);\n  }\n}\n\n// A helper to expose a readonly view from a mutable manager when needed\nexport function asReadonlyState(state: IMutableQueryBuilderState): IReadonlyQueryBuilderState {\n  return state as unknown as IReadonlyQueryBuilderState;\n}\n\n/**\n * Scoped state that shares the CTE map from a base state but maintains\n * an isolated selection map for temporary/select-scope computations.\n */\nexport class ScopedSelectionState implements IMutableQueryBuilderState {\n  private readonly base: IReadonlyQueryBuilderState;\n  private readonly localSelection: Map<string, IFieldSelectName> = new Map();\n\n  constructor(base: IReadonlyQueryBuilderState) {\n    this.base = base;\n  }\n\n  // Readonly over CTE map\n  getFieldCteMap(): ReadonlyMap<string, string> {\n    return this.base.getFieldCteMap();\n  }\n\n  getSelectionMap(): ReadonlyMap<string, IFieldSelectName> {\n    return this.localSelection;\n  }\n\n  getContext(): IRecordQueryContext {\n    return this.base.getContext();\n  }\n\n  hasFieldCte(fieldId: string): boolean {\n    return this.base.hasFieldCte(fieldId);\n  }\n\n  getCteName(fieldId: string): string | undefined {\n    return this.base.getCteName(fieldId);\n  }\n\n  isCteJoined(cteName: string): boolean {\n    return this.base.isCteJoined(cteName);\n  }\n\n  getMainTableAlias(): string | undefined {\n    return this.base.getMainTableAlias();\n  }\n\n  getMainTableSource(): string | undefined {\n    return this.base.getMainTableSource();\n  }\n\n  getOriginalMainTableSource(): string | undefined {\n    return this.base.getOriginalMainTableSource();\n  }\n\n  getBaseCteName(): string | undefined {\n    return this.base.getBaseCteName();\n  }\n\n  // Mutations: selection only\n  setSelection(fieldId: string, selection: IFieldSelectName): void {\n    this.localSelection.set(fieldId, selection);\n  }\n\n  deleteSelection(fieldId: string): void {\n    this.localSelection.delete(fieldId);\n  }\n\n  clearSelections(): void {\n    this.localSelection.clear();\n  }\n\n  // CTE mutations are unsupported in scoped selection state\n  setFieldCte(_fieldId: string, _cteName: string): void {\n    // intentionally no-op; CTE writes must happen on the manager\n    throw new Error('setFieldCte is not supported on ScopedSelectionState');\n  }\n\n  clearFieldCtes(): void {\n    throw new Error('clearFieldCtes is not supported on ScopedSelectionState');\n  }\n\n  setMainTableAlias(_alias: string): void {\n    throw new Error('setMainTableAlias is not supported on ScopedSelectionState');\n  }\n\n  setMainTableSource(_source: string): void {\n    throw new Error('setMainTableSource is not supported on ScopedSelectionState');\n  }\n\n  setBaseCteName(_cteName: string | undefined): void {\n    throw new Error('setBaseCteName is not supported on ScopedSelectionState');\n  }\n\n  markCteJoined(_cteName: string): void {\n    throw new Error('markCteJoined is not supported on ScopedSelectionState');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PrismaModule } from '@teable/db-main-prisma';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { TableDomainQueryModule } from '../../table-domain/table-domain-query.module';\nimport { RecordQueryDialectProvider } from './record-query-builder.provider';\nimport { RecordQueryBuilderService } from './record-query-builder.service';\nimport { RECORD_QUERY_BUILDER_SYMBOL } from './record-query-builder.symbol';\n\n/**\n * Module for record query builder functionality\n * This module provides services for building table record queries\n */\n@Module({\n  imports: [PrismaModule, TableDomainQueryModule],\n  providers: [\n    DbProvider,\n    RecordQueryDialectProvider,\n    {\n      provide: RECORD_QUERY_BUILDER_SYMBOL,\n      useClass: RecordQueryBuilderService,\n    },\n  ],\n  exports: [RECORD_QUERY_BUILDER_SYMBOL],\n})\nexport class RecordQueryBuilderModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.provider.ts",
    "content": "import type { Provider } from '@nestjs/common';\nimport { Inject } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { getDriverName } from '../../../utils/db-helpers';\nimport { PgRecordQueryDialect } from './providers/pg-record-query-dialect';\nimport { SqliteRecordQueryDialect } from './providers/sqlite-record-query-dialect';\nimport { RECORD_QUERY_BUILDER_SYMBOL } from './record-query-builder.symbol';\nimport {\n  RECORD_QUERY_DIALECT_SYMBOL,\n  type IRecordQueryDialectProvider,\n} from './record-query-dialect.interface';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const InjectRecordQueryBuilder = () => Inject(RECORD_QUERY_BUILDER_SYMBOL);\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const InjectRecordQueryDialect = () => Inject(RECORD_QUERY_DIALECT_SYMBOL);\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const RecordQueryDialectProvider: Provider = {\n  provide: RECORD_QUERY_DIALECT_SYMBOL,\n  useFactory: (knex: Knex): IRecordQueryDialectProvider => {\n    const driverClient = getDriverName(knex);\n    switch (driverClient) {\n      case DriverClient.Sqlite:\n        return new SqliteRecordQueryDialect(knex);\n      case DriverClient.Pg:\n        return new PgRecordQueryDialect(knex);\n      default:\n        return new PgRecordQueryDialect(knex);\n    }\n  },\n  inject: ['CUSTOM_KNEX'],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts",
    "content": "import { Inject, Injectable, Logger } from '@nestjs/common';\nimport { DbFieldType, extractFieldIdsFromFilter, FieldType, SortFunc, Tables } from '@teable/core';\nimport type { FieldCore, IFilter, ISortItem, TableDomain } from '@teable/core';\nimport { Knex } from 'knex';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { isUserOrLink } from '../../../utils/is-user-or-link';\nimport { ID_FIELD_NAME, preservedDbFieldNames } from '../../field/constant';\nimport { TableDomainQueryService } from '../../table-domain/table-domain-query.service';\nimport { FieldCteVisitor } from './field-cte-visitor';\nimport { FieldSelectVisitor } from './field-select-visitor';\nimport type {\n  ICreateRecordAggregateBuilderOptions,\n  ICreateRecordQueryBuilderOptions,\n  IPrepareViewParams,\n  IRecordQueryBuilder,\n  IMutableQueryBuilderState,\n  IReadonlyRecordSelectionMap,\n} from './record-query-builder.interface';\nimport { RecordQueryBuilderManager } from './record-query-builder.manager';\nimport { InjectRecordQueryDialect } from './record-query-builder.provider';\nimport { getOrderedFieldsByProjection, getTableAliasFromTable } from './record-query-builder.util';\nimport { IRecordQueryDialectProvider } from './record-query-dialect.interface';\n\n@Injectable()\nexport class RecordQueryBuilderService implements IRecordQueryBuilder {\n  private readonly logger = new Logger(RecordQueryBuilderService.name);\n  constructor(\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    @InjectDbProvider()\n    private readonly dbProvider: IDbProvider,\n    @Inject('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectRecordQueryDialect()\n    private readonly dialect: IRecordQueryDialectProvider\n  ) {}\n\n  private async createQueryBuilderFromTable(\n    from: string,\n    tableId: string,\n    projection?: string[],\n    baseBuilder?: Knex.QueryBuilder,\n    providedTables?: Tables\n  ): Promise<{\n    qb: Knex.QueryBuilder;\n    alias: string;\n    tables: Tables;\n    table: TableDomain;\n    state: IMutableQueryBuilderState;\n  }> {\n    let tables = providedTables;\n    if (!tables || !tables.hasTable(tableId)) {\n      tables = await this.tableDomainQueryService.getAllRelatedTableDomains(tableId, projection);\n    } else if (tables.entryTableId !== tableId) {\n      tables = new Tables(tableId, new Map(tables.tableDomains), new Set(tables.visited));\n    }\n    const table = tables.mustGetEntryTable();\n    const mainTableAlias = getTableAliasFromTable(table);\n    const qbSource = baseBuilder ?? this.knex.queryBuilder();\n    const qb = qbSource.from({ [mainTableAlias]: from });\n\n    const state: IMutableQueryBuilderState = new RecordQueryBuilderManager('table');\n    state.setMainTableAlias(mainTableAlias);\n    state.setMainTableSource(table.dbTableName);\n    if (from !== table.dbTableName) {\n      state.setMainTableSource(from);\n    }\n\n    return { qb, alias: mainTableAlias, tables, table, state };\n  }\n\n  private async createQueryBuilderFromTableCache(\n    tableId: string,\n    from: string,\n    baseBuilder?: Knex.QueryBuilder\n  ): Promise<{\n    qb: Knex.QueryBuilder;\n    alias: string;\n    table: TableDomain;\n    state: IMutableQueryBuilderState;\n  }> {\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const mainTableAlias = getTableAliasFromTable(table);\n    const qbSource = baseBuilder ?? this.knex.queryBuilder();\n    const qb = qbSource.from({ [mainTableAlias]: from });\n\n    const state = new RecordQueryBuilderManager('tableCache');\n    state.setMainTableAlias(mainTableAlias);\n    state.setMainTableSource(table.dbTableName);\n\n    return { qb, table, state, alias: mainTableAlias };\n  }\n\n  private async createQueryBuilder(\n    from: string,\n    tableId: string,\n    options: Partial<ICreateRecordQueryBuilderOptions> = {}\n  ): Promise<{\n    qb: Knex.QueryBuilder;\n    alias: string;\n    table: TableDomain;\n    state: IMutableQueryBuilderState;\n  }> {\n    const useQueryModel = options.useQueryModel ?? false;\n    const baseBuilder = options.builder;\n\n    let builder:\n      | {\n          qb: Knex.QueryBuilder;\n          alias: string;\n          table: TableDomain;\n          state: IMutableQueryBuilderState;\n          tables?: Tables;\n        }\n      | undefined;\n\n    if (useQueryModel) {\n      try {\n        builder = await this.createQueryBuilderFromTableCache(tableId, from, baseBuilder);\n      } catch (error) {\n        this.logger.error(`Failed to create query builder from view: ${error}, use table instead`);\n        builder = await this.createQueryBuilderFromTable(\n          from,\n          tableId,\n          options.projection,\n          baseBuilder,\n          options.tables\n        );\n      }\n    } else {\n      builder = await this.createQueryBuilderFromTable(\n        from,\n        tableId,\n        options.projection,\n        baseBuilder,\n        options.tables\n      );\n    }\n\n    const { qb, alias, table, state } = builder;\n\n    if (state.getContext() === 'table') {\n      const tables = (builder as unknown as { tables: Tables }).tables;\n      this.applyBasePaginationIfNeeded(qb, table, state, alias, {\n        limit: options.limit,\n        offset: options.offset,\n        filter: options.filter,\n        sort: options.sort,\n        currentUserId: options.currentUserId,\n        defaultOrderField: options.defaultOrderField,\n        hasSearch: options.hasSearch,\n        restrictRecordIds: options.restrictRecordIds,\n      });\n      this.buildFieldCtes(\n        qb,\n        tables,\n        state,\n        options.projection,\n        options.preferRawFieldReferences ?? false\n      );\n    }\n\n    return { qb, alias, table, state };\n  }\n\n  async prepareView(\n    from: string,\n    params: IPrepareViewParams\n  ): Promise<{ qb: Knex.QueryBuilder; table: TableDomain }> {\n    const { tableIdOrDbTableName } = params;\n    const { qb, table, state } = await this.createQueryBuilder(from, tableIdOrDbTableName);\n\n    this.buildSelect(qb, table, state);\n\n    return { qb, table };\n  }\n\n  async createRecordQueryBuilder(\n    from: string,\n    options: ICreateRecordQueryBuilderOptions\n  ): Promise<{ qb: Knex.QueryBuilder; alias: string; selectionMap: IReadonlyRecordSelectionMap }> {\n    const { tableId, filter, sort, currentUserId, restrictRecordIds } = options;\n    const { qb, alias, table, state } = await this.createQueryBuilder(from, tableId, {\n      builder: options.builder,\n      useQueryModel: options.useQueryModel,\n      projection: options.projection,\n      projectionByTable: options.projectionByTable,\n      tables: options.tables,\n      limit: options.limit,\n      offset: options.offset,\n      filter,\n      sort,\n      currentUserId,\n      defaultOrderField: options.defaultOrderField,\n      hasSearch: options.hasSearch,\n      restrictRecordIds,\n      preferRawFieldReferences: options.preferRawFieldReferences,\n    });\n\n    this.buildSelect(\n      qb,\n      table,\n      state,\n      options.projection,\n      options.rawProjection,\n      options.preferRawFieldReferences ?? false\n    );\n\n    // Selection map collected as fields are visited.\n\n    const selectionMap = state.getSelectionMap();\n    if (filter) {\n      this.buildFilter(qb, table, filter, selectionMap, currentUserId, alias);\n    }\n\n    if (sort) {\n      this.buildSort(qb, table, sort, selectionMap);\n    }\n\n    return { qb, alias, selectionMap };\n  }\n\n  async createRecordAggregateBuilder(\n    from: string,\n    options: ICreateRecordAggregateBuilderOptions\n  ): Promise<{ qb: Knex.QueryBuilder; alias: string; selectionMap: IReadonlyRecordSelectionMap }> {\n    const {\n      tableId,\n      filter,\n      aggregationFields,\n      groupBy,\n      currentUserId,\n      useQueryModel,\n      restrictRecordIds,\n    } = options;\n    const { qb, table, alias, state } = await this.createQueryBuilder(from, tableId, {\n      builder: options.builder,\n      useQueryModel,\n      projection: options.projection,\n      filter,\n      currentUserId,\n      restrictRecordIds,\n    });\n\n    this.buildAggregateSelect(qb, table, state);\n    const selectionMap = state.getSelectionMap();\n\n    if (filter) {\n      this.buildFilter(qb, table, filter, selectionMap, currentUserId, alias);\n    }\n\n    const fieldMap = table.fieldList.reduce(\n      (map, field) => {\n        map[field.id] = field;\n        return map;\n      },\n      {} as Record<string, FieldCore>\n    );\n\n    const groupByFieldIds = groupBy?.map((item) => item.fieldId);\n    // Apply aggregation (do NOT pass groupBy here; grouping is handled by GroupQuery below)\n    this.dbProvider\n      .aggregationQuery(qb, fieldMap, aggregationFields, undefined, {\n        selectionMap,\n        tableDbName: table.dbTableName,\n        tableAlias: alias,\n      })\n      .appendBuilder();\n\n    // Apply grouping if specified\n    if (groupBy && groupBy.length > 0) {\n      this.dbProvider\n        .groupQuery(qb, fieldMap, groupByFieldIds, undefined, { selectionMap })\n        .appendGroupBuilder();\n\n      for (const groupItem of groupBy) {\n        const groupedField = fieldMap[groupItem.fieldId];\n        if (!groupedField) continue;\n        const direction: 'ASC' | 'DESC' = groupItem.order === SortFunc.Desc ? 'DESC' : 'ASC';\n\n        this.orderAggregateByGroup(qb, groupedField, direction, selectionMap);\n      }\n    }\n\n    return { qb, alias, selectionMap };\n  }\n\n  private buildFieldCtes(\n    qb: Knex.QueryBuilder,\n    tables: Tables | undefined,\n    state: IMutableQueryBuilderState,\n    projection?: string[],\n    preferRawFieldReferences: boolean = false\n  ): void {\n    if (!tables) {\n      return;\n    }\n    const visitor = new FieldCteVisitor(\n      qb,\n      this.dbProvider,\n      tables,\n      state,\n      this.dialect,\n      projection,\n      !preferRawFieldReferences\n    );\n    visitor.build();\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private orderAggregateByGroup(\n    qb: Knex.QueryBuilder,\n    field: FieldCore,\n    direction: 'ASC' | 'DESC',\n    selectionMap: IReadonlyRecordSelectionMap\n  ) {\n    const nullOrdering = direction === 'DESC' ? 'NULLS LAST' : 'NULLS FIRST';\n    const quotedAlias = `\"${field.dbFieldName.replace(/\"/g, '\"\"')}\"`;\n    const selection = selectionMap.get(field.id);\n    const selectionExpression =\n      typeof selection === 'string' ? selection : selection ? selection.toQuery() : undefined;\n\n    const orderableSelection = selectionExpression ?? quotedAlias;\n    // Respect choice order for select fields (single & multiple)\n    if (field.type === FieldType.SingleSelect || field.type === FieldType.MultipleSelect) {\n      const rawChoices = (field.options as { choices?: { name: string }[] } | undefined)?.choices;\n      const choices = Array.isArray(rawChoices) ? rawChoices : [];\n      if (choices.length) {\n        const arrayLiteral = `ARRAY[${choices\n          .map(({ name }) => this.knex.raw('?', [name]).toQuery())\n          .join(', ')}]`;\n\n        if (field.type === FieldType.MultipleSelect) {\n          const firstIndexExpr = `CASE\n            WHEN ${orderableSelection} IS NULL THEN NULL\n            WHEN jsonb_typeof(${orderableSelection}::jsonb) = 'array'\n              THEN ARRAY_POSITION(${arrayLiteral}, jsonb_path_query_first(${orderableSelection}::jsonb, '$[0]') #>> '{}')\n            ELSE ARRAY_POSITION(${arrayLiteral}, ${orderableSelection}::text)\n          END`;\n          qb.orderByRaw(`${firstIndexExpr} ${direction} ${nullOrdering}`);\n          qb.orderByRaw(`${orderableSelection}::jsonb::text ${direction} ${nullOrdering}`);\n          return;\n        } else {\n          const normalizedExpr = this.normalizeOrderableTextExpression(\n            orderableSelection,\n            field.dbFieldType\n          );\n          const arrayPositionExpr = `ARRAY_POSITION(${arrayLiteral}, ${normalizedExpr})`;\n          qb.orderByRaw(`${arrayPositionExpr} ${direction} ${nullOrdering}`);\n          return;\n        }\n      }\n    }\n\n    if (isUserOrLink(field.type)) {\n      if (field.isMultipleCellValue) {\n        if (selectionExpression) {\n          qb.orderByRaw(\n            `jsonb_path_query_array((${selectionExpression})::jsonb, '$[*].title')::text ${direction} ${nullOrdering}`\n          );\n        } else {\n          qb.orderByRaw(`${quotedAlias} ${direction} ${nullOrdering}`);\n        }\n      } else {\n        qb.orderByRaw(\n          `(${selectionExpression ?? quotedAlias})::jsonb ->> 'title' ${direction} ${nullOrdering}`\n        );\n      }\n      return;\n    }\n\n    qb.orderByRaw(`${quotedAlias} ${direction} ${nullOrdering}`);\n  }\n\n  private normalizeOrderableTextExpression(expr: string, dbFieldType: DbFieldType): string {\n    if (!expr || dbFieldType !== DbFieldType.Json) {\n      return expr;\n    }\n    const wrappedExpr = `(${expr})`;\n    const jsonbValue = `to_jsonb${wrappedExpr}`;\n    const firstArrayElement = `jsonb_path_query_first(${jsonbValue}, '$[0]')`;\n    return `(CASE\n      WHEN ${wrappedExpr} IS NULL THEN NULL\n      ELSE\n        CASE jsonb_typeof(${jsonbValue})\n          WHEN 'string' THEN ${jsonbValue} #>> '{}'\n          WHEN 'number' THEN ${jsonbValue} #>> '{}'\n          WHEN 'boolean' THEN ${jsonbValue} #>> '{}'\n          WHEN 'null' THEN NULL\n          WHEN 'array' THEN ${firstArrayElement} #>> '{}'\n          ELSE ${jsonbValue}::text\n        END\n    END)`;\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private applyBasePaginationIfNeeded(\n    qb: Knex.QueryBuilder,\n    table: TableDomain,\n    state: IMutableQueryBuilderState,\n    alias: string,\n    params: {\n      limit?: number;\n      offset?: number;\n      filter?: IFilter;\n      sort?: ISortItem[];\n      currentUserId?: string;\n      defaultOrderField?: string;\n      hasSearch?: boolean;\n      restrictRecordIds?: string[];\n    }\n  ): void {\n    const {\n      limit,\n      offset,\n      filter,\n      sort,\n      currentUserId,\n      defaultOrderField,\n      hasSearch,\n      restrictRecordIds,\n    } = params;\n    state.setBaseCteName(undefined);\n\n    if (state.getContext() !== 'table') {\n      return;\n    }\n\n    const originalSource = state.getOriginalMainTableSource();\n    if (!originalSource) {\n      return;\n    }\n\n    const baseLimit = this.resolveBaseLimit(limit, offset);\n    let applyPagination = Boolean(baseLimit) && !hasSearch;\n    const normalizedRecordIds = Array.from(\n      new Set(\n        (restrictRecordIds ?? []).filter(\n          (id): id is string => typeof id === 'string' && id.length > 0\n        )\n      )\n    );\n    const applyRecordRestriction = normalizedRecordIds.length > 0;\n\n    if (!applyPagination && !applyRecordRestriction) {\n      return;\n    }\n\n    let baseSelectionMap: Map<string, string> | undefined;\n\n    if (applyPagination) {\n      const requiredFieldIds = this.collectRequiredFieldIds(filter, sort, defaultOrderField);\n      const fieldLookup = this.buildFieldLookup(table);\n\n      if (this.referencesComputedField(requiredFieldIds, fieldLookup)) {\n        // Fall back to full table scan when pagination conflicts with computed fields,\n        // but still allow record-level restriction to run.\n        applyPagination = false;\n        if (!applyRecordRestriction) {\n          return;\n        }\n      } else {\n        baseSelectionMap = this.createBaseSelectionMap(requiredFieldIds, fieldLookup, alias);\n      }\n    }\n\n    const baseBuilder = this.knex\n      .queryBuilder()\n      .select(this.knex.raw('??.*', [alias]))\n      .from({ [alias]: originalSource });\n\n    if (applyPagination && filter) {\n      this.buildFilter(baseBuilder, table, filter, baseSelectionMap!, currentUserId, alias);\n    }\n\n    if (applyPagination && sort && sort.length) {\n      this.buildSort(baseBuilder, table, sort, baseSelectionMap!);\n    }\n\n    if (applyPagination && defaultOrderField) {\n      baseBuilder.orderBy(`${alias}.${defaultOrderField}`, 'asc');\n    }\n\n    if (applyPagination && baseLimit) {\n      baseBuilder.limit(baseLimit);\n    }\n\n    if (applyRecordRestriction) {\n      baseBuilder.whereIn(`${alias}.${ID_FIELD_NAME}`, normalizedRecordIds);\n    }\n\n    const baseCteName = `BASE_${alias}`;\n    qb.with(baseCteName, baseBuilder);\n    qb.from({ [alias]: baseCteName });\n    state.setBaseCteName(baseCteName);\n    state.setMainTableSource(baseCteName);\n  }\n\n  private isComputedField(field: FieldCore): boolean {\n    if (field.isLookup) {\n      return true;\n    }\n    switch (field.type) {\n      case FieldType.Rollup:\n      case FieldType.ConditionalRollup:\n      case FieldType.Formula:\n        return true;\n      default:\n        return false;\n    }\n  }\n\n  private resolveBaseLimit(limit?: number, offset?: number): number | undefined {\n    if (limit === undefined || limit === null) {\n      return undefined;\n    }\n    if (limit < 0 || limit === -1) {\n      return undefined;\n    }\n    const safeOffset = offset && offset > 0 ? offset : 0;\n    const baseLimit = safeOffset + limit;\n    if (!Number.isFinite(baseLimit) || baseLimit <= 0) {\n      return undefined;\n    }\n    return baseLimit;\n  }\n\n  private collectRequiredFieldIds(\n    filter: IFilter | undefined,\n    sort: ISortItem[] | undefined,\n    defaultOrderField?: string\n  ): Set<string> {\n    const ids = new Set<string>();\n    for (const fieldId of extractFieldIdsFromFilter(filter)) {\n      ids.add(fieldId);\n    }\n    sort?.forEach((item) => {\n      if (item.fieldId) {\n        ids.add(item.fieldId);\n      }\n    });\n    if (defaultOrderField) {\n      ids.add(defaultOrderField);\n    }\n    return ids;\n  }\n\n  private buildFieldLookup(table: TableDomain): Map<string, FieldCore> {\n    const lookup = new Map<string, FieldCore>();\n    for (const field of table.fieldList) {\n      lookup.set(field.id, field);\n    }\n    return lookup;\n  }\n\n  private referencesComputedField(\n    fieldIds: Set<string>,\n    fieldLookup: Map<string, FieldCore>\n  ): boolean {\n    for (const fieldId of fieldIds) {\n      const field = fieldLookup.get(fieldId);\n      if (!field) {\n        continue;\n      }\n      if (this.isComputedField(field)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  private createBaseSelectionMap(\n    fieldIds: Set<string>,\n    fieldLookup: Map<string, FieldCore>,\n    alias: string\n  ): Map<string, string> {\n    const selectionMap = new Map<string, string>();\n    for (const fieldId of fieldIds) {\n      const field = fieldLookup.get(fieldId);\n      if (!field) continue;\n      selectionMap.set(field.id, `\"${alias}\".\"${field.dbFieldName}\"`);\n    }\n    return selectionMap;\n  }\n\n  private getReadyLinkFieldIds(state: IMutableQueryBuilderState): ReadonlySet<string> | undefined {\n    const fieldCtes = state.getFieldCteMap();\n    if (!fieldCtes.size) {\n      return undefined;\n    }\n    const ready = new Set<string>();\n    for (const [fieldId, cteName] of fieldCtes) {\n      if (state.isCteJoined(cteName)) {\n        ready.add(fieldId);\n      }\n    }\n    return ready;\n  }\n\n  private buildSelect(\n    qb: Knex.QueryBuilder,\n    table: TableDomain,\n    state: IMutableQueryBuilderState,\n    projection?: string[],\n    rawProjection: boolean = false,\n    preferRawFieldReferences: boolean = false\n  ): this {\n    const readyLinkFieldIds = this.getReadyLinkFieldIds(state);\n    const visitor = new FieldSelectVisitor(\n      qb,\n      this.dbProvider,\n      table,\n      state,\n      this.dialect,\n      undefined,\n      rawProjection,\n      preferRawFieldReferences,\n      undefined,\n      readyLinkFieldIds\n    );\n    const alias = getTableAliasFromTable(table);\n\n    for (const field of preservedDbFieldNames) {\n      qb.select(`${alias}.${field}`);\n    }\n\n    const orderedFields = getOrderedFieldsByProjection(\n      table,\n      projection,\n      !preferRawFieldReferences\n    ) as FieldCore[];\n    for (const field of orderedFields) {\n      const result = field.accept(visitor);\n      if (!result) continue;\n      if (typeof result === 'string') {\n        // Always alias via raw to avoid Knex placeholder detection on expressions (e.g., regex with '?')\n        const aliasBinding = field.dbFieldName;\n        qb.select({ [aliasBinding]: this.knex.raw(result) });\n      } else {\n        qb.select({ [field.dbFieldName]: result });\n      }\n    }\n\n    return this;\n  }\n\n  private buildAggregateSelect(\n    qb: Knex.QueryBuilder,\n    table: TableDomain,\n    state: IMutableQueryBuilderState\n  ): this {\n    const readyLinkFieldIds = this.getReadyLinkFieldIds(state);\n    const visitor = new FieldSelectVisitor(\n      qb,\n      this.dbProvider,\n      table,\n      state,\n      this.dialect,\n      undefined,\n      false,\n      false,\n      undefined,\n      readyLinkFieldIds\n    );\n\n    // Add field-specific selections using visitor pattern\n    for (const field of table.fields.ordered) {\n      field.accept(visitor);\n    }\n\n    return this;\n  }\n\n  private buildFilter(\n    qb: Knex.QueryBuilder,\n    table: TableDomain,\n    filter: IFilter,\n    selectionMap: IReadonlyRecordSelectionMap,\n    currentUserId: string | undefined,\n    mainAlias?: string\n  ): this {\n    // Allow filters to reference fields even if they are not part of the final projection\n    // so that permission-hidden fields can still participate in WHERE clauses.\n    const map = table.fieldList.reduce(\n      (acc, field) => {\n        acc[field.id] = field;\n        acc[field.name] = field;\n        return acc;\n      },\n      {} as Record<string, FieldCore>\n    );\n    const augmentedSelection = new Map(selectionMap);\n    if (mainAlias) {\n      table.fieldList.forEach((field) => {\n        const qualified = this.knex.ref(`${mainAlias}.${field.dbFieldName}`).toQuery();\n        augmentedSelection.set(field.id, qualified);\n      });\n    }\n    this.dbProvider\n      .filterQuery(\n        qb,\n        map,\n        filter,\n        { withUserId: currentUserId },\n        { selectionMap: augmentedSelection }\n      )\n      .appendQueryBuilder();\n    return this;\n  }\n\n  private buildSort(\n    qb: Knex.QueryBuilder,\n    table: TableDomain,\n    sort: ISortItem[],\n    selectionMap: IReadonlyRecordSelectionMap\n  ): this {\n    // Restrict sortable fields to those present in the current selection (permission-respected)\n    const allowedIds = new Set<string>(Array.from(selectionMap.keys()));\n    const map = table.fieldList.reduce(\n      (acc, field) => {\n        if (!allowedIds.has(field.id)) return acc;\n        acc[field.id] = field;\n        acc[field.name] = field;\n        return acc;\n      },\n      {} as Record<string, FieldCore>\n    );\n    this.dbProvider.sortQuery(qb, map, sort, undefined, { selectionMap }).appendSortBuilder();\n    return this;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.symbol.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/**\n * Injection token for the record query builder service\n * This symbol is used for dependency injection to avoid direct class references\n */\nexport const RECORD_QUERY_BUILDER_SYMBOL = Symbol('RECORD_QUERY_BUILDER');\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-builder.util.ts",
    "content": "/* eslint-disable sonarjs/no-collapsible-if */\nimport { CellValueType, FieldType, Relationship } from '@teable/core';\nimport type {\n  FieldCore,\n  ILinkFieldOptions,\n  LinkFieldCore,\n  TableDomain,\n  FormulaFieldCore,\n} from '@teable/core';\n\nexport function getTableAliasFromTable(table: TableDomain): string {\n  // Use a short, deterministic alias derived from table id to avoid\n  // collisions with the physical table name (especially when names are\n  // truncated to 63 chars by Postgres). This guarantees the alias never\n  // equals the underlying relation name and stays well within length limits.\n  const safeId = table.id.replace(/\\W/g, '_');\n  return `t_${safeId}`;\n}\n\nexport function getLinkUsesJunctionTable(field: LinkFieldCore): boolean {\n  const options = field.options as ILinkFieldOptions;\n  return (\n    options.relationship === Relationship.ManyMany ||\n    (options.relationship === Relationship.OneMany && !!options.isOneWay)\n  );\n}\n\n/**\n * Compute a minimal, ordered field list based on a projection of field IDs.\n * - Always respects `table.fields.ordered` ordering.\n * - When projection is empty/undefined, returns all fields.\n * - Ensures dependencies are included:\n *   - Lookup → include its link field\n *   - Rollup → include its link field\n *   - Formula → recursively include referenced fields (and therefore their link deps)\n */\n// eslint-disable-next-line sonarjs/cognitive-complexity\nexport function getOrderedFieldsByProjection(\n  table: TableDomain,\n  projection?: string[],\n  expandFormulaReferences: boolean = true\n): FieldCore[] {\n  const ordered = table.fields.ordered as FieldCore[];\n  if (!projection || projection.length === 0) return ordered;\n\n  const byId: Record<string, FieldCore | undefined> = Object.fromEntries(\n    ordered.map((f) => [f.id, f])\n  );\n\n  const wanted = new Set<string>(projection);\n  const queue: string[] = [...wanted];\n  const visitedFormula = new Set<string>();\n\n  while (queue.length) {\n    const id = queue.pop()!;\n    const field = byId[id];\n    if (!field) continue;\n\n    // Link: nothing else to add\n    if (field.type === FieldType.Link) {\n      wanted.add(field.id);\n      continue;\n    }\n\n    // Lookup / Rollup: include its link field via model method\n    if (\n      field.isLookup ||\n      field.type === FieldType.Rollup ||\n      field.type === FieldType.ConditionalRollup\n    ) {\n      const link = field.getLinkField(table);\n      if (link && !wanted.has(link.id)) {\n        wanted.add(link.id);\n        queue.push(link.id);\n      }\n      continue;\n    }\n\n    // Formula: recursively include references\n    if (field.type === FieldType.Formula) {\n      if (!expandFormulaReferences) continue;\n      if (visitedFormula.has(field.id)) continue;\n      visitedFormula.add(field.id);\n      const refs = (field as FormulaFieldCore).getReferenceFields(table);\n      for (const rf of refs) {\n        if (!rf) continue;\n        if (!wanted.has(rf.id)) {\n          wanted.add(rf.id);\n          queue.push(rf.id);\n        }\n      }\n    }\n  }\n\n  // Return in ordered order\n  return ordered.filter((f) => wanted.has(f.id));\n}\n\n/**\n * Determine whether a field is date-like (i.e., represents a datetime value).\n * - True for Date, CreatedTime, LastModifiedTime\n * - True for Formula fields whose result cellValueType is DateTime\n */\nexport function isDateLikeField(field: FieldCore): boolean {\n  if (\n    field.type === FieldType.Date ||\n    field.type === FieldType.CreatedTime ||\n    field.type === FieldType.LastModifiedTime\n  ) {\n    return true;\n  }\n  if (field.type === FieldType.Formula) {\n    const f = field as FormulaFieldCore;\n    return f.cellValueType === CellValueType.DateTime;\n  }\n  return false;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts",
    "content": "import type {\n  DriverClient,\n  FieldCore,\n  INumberFormatting,\n  Relationship,\n  DbFieldType,\n  IDatetimeFormatting,\n} from '@teable/core';\nimport type { Knex } from 'knex';\n\n/**\n * Database-dialect provider for Record Query Builder.\n * Centralizes all SQL fragment differences between PostgreSQL and SQLite so callers\n * can build queries without sprinkling driver-specific if/else throughout the codebase.\n *\n * All methods return SQL snippets as strings that can be embedded in knex.raw or string\n * templating. Implementations MUST ensure generated SQL is valid for their driver.\n */\nexport interface IRecordQueryDialectProvider {\n  /**\n   * Current driver this provider targets.\n   * - PG example: DriverClient.Pg\n   * - SQLite example: DriverClient.Sqlite\n   */\n  readonly driver: DriverClient;\n\n  // Generic casts/formatting\n\n  /**\n   * Cast any SQL expression to text string.\n   * - PG: returns `(expr)::TEXT`\n   * - SQLite: returns `CAST(expr AS TEXT)`\n   * @example\n   * ```ts\n   * dialect.toText('t.amount')\n   * // PG:     (t.amount)::TEXT\n   * // SQLite: CAST(t.amount AS TEXT)\n   * ```\n   */\n  toText(expr: string): string;\n\n  /**\n   * Format a numeric SQL expression according to app number formatting rules.\n   * Supports decimal, percent, currency (symbol + precision), etc.\n   * @example\n   * ```ts\n   * dialect.formatNumber('t.price', { type: 'decimal', precision: 2 })\n   * // PG:     ROUND(CAST(t.price AS NUMERIC), 2)::TEXT\n   * // SQLite: PRINTF('%.2f', t.price)\n   * ```\n   */\n  formatNumber(expr: string, formatting: INumberFormatting): string;\n\n  /**\n   * Format elements of a JSON array of numbers into a single comma-separated string\n   * while preserving original array order.\n   * @example\n   * ```ts\n   * dialect.formatNumberArray('t.values', { type: 'percent', precision: 1 })\n   * // PG:     SELECT string_agg(ROUND(...), ', ')\n   * //          FROM jsonb_array_elements((t.values)::jsonb) WITH ORDINALITY\n   * // SQLite: SELECT GROUP_CONCAT(PRINTF(...), ', ')\n   * //          FROM json_each(CASE WHEN json_valid(t.values) THEN t.values ELSE json('[]') END)\n   * ```\n   */\n  formatNumberArray(expr: string, formatting: INumberFormatting): string;\n\n  /**\n   * Join elements of a JSON array (text/object) into a comma-separated string.\n   * For objects with title, extracts the title.\n   * @example\n   * ```ts\n   * dialect.formatStringArray('t.tags')\n   * // PG:     SELECT string_agg(CASE ... END, ', ')\n   * //          FROM jsonb_array_elements((t.tags)::jsonb) WITH ORDINALITY\n   * // SQLite: SELECT GROUP_CONCAT(CASE ... END, ', ')\n   * //          FROM json_each(CASE WHEN json_valid(t.tags) THEN t.tags ELSE json('[]') END)\n   * ```\n   */\n  formatStringArray(expr: string, opts?: { fieldInfo?: FieldCore }): string;\n\n  /**\n   * Format rating values: emit integer text if it is an integer; otherwise real as text.\n   * @example\n   * ```ts\n   * dialect.formatRating('t.rating')\n   * // PG:     CASE WHEN (t.rating = ROUND(t.rating))\n   * //            THEN ROUND(t.rating)::TEXT ELSE (t.rating)::TEXT END\n   * // SQLite: CASE WHEN (t.rating = CAST(t.rating AS INTEGER))\n   * //            THEN CAST(CAST(t.rating AS INTEGER) AS TEXT) ELSE CAST(t.rating AS TEXT) END\n   * ```\n   */\n  formatRating(expr: string): string;\n\n  /**\n   * Format a datetime SQL expression according to field formatting (date preset, time preset, timezone).\n   * Implementations should mirror {@link formatDateToString} semantics.\n   */\n  formatDate(expr: string, formatting: IDatetimeFormatting): string;\n\n  /**\n   * Format each element of a JSON array of datetimes according to field formatting and join with comma + space.\n   */\n  formatDateArray(expr: string, formatting: IDatetimeFormatting): string;\n\n  // Safe coercions used in comparisons\n\n  /**\n   * Safely coerce a string-like SQL expression to numeric for comparisons without runtime errors.\n   * @example\n   * ```sql\n   * -- Use in comparisons\n   * <coerceToNumericForCompare('t.left')> > <coerceToNumericForCompare('t.right')>\n   * ```\n   */\n  coerceToNumericForCompare(expr: string): string;\n\n  // Link/user helpers in SELECT context\n\n  /**\n   * Check whether a link JSON value is present and non-empty.\n   * @example\n   * ```ts\n   * dialect.linkHasAny('\"cte\".\"link_value\"')\n   * // PG:     (cte.link_value IS NOT NULL AND (cte.link_value)::text != 'null' AND (cte.link_value)::text != '[]')\n   * // SQLite: (cte.link_value IS NOT NULL AND cte.link_value != 'null' AND cte.link_value != '[]')\n   * ```\n   */\n  linkHasAny(selectionSql: string): string;\n\n  /**\n   * Extract link title(s) from a link JSON value.\n   * - When isMultiple = true: return a JSON array of titles.\n   * - When isMultiple = false: return a single title string.\n   * @example PostgreSQL\n   * ```sql\n   * (SELECT json_agg(value->>'title')\n   *  FROM jsonb_array_elements(cte.link_value::jsonb) AS value)::jsonb\n   * ```\n   * @example SQLite\n   * ```sql\n   * (SELECT json_group_array(json_extract(value, '$.title'))\n   *  FROM json_each(CASE WHEN json_valid(cte.link_value) AND json_type(cte.link_value)='array'\n   *                      THEN cte.link_value ELSE json('[]') END)\n   *  ORDER BY key)\n   * ```\n   */\n  linkExtractTitles(selectionSql: string, isMultiple: boolean): string;\n\n  /**\n   * Extract the 'title' property from a JSON object expression.\n   * @example\n   * ```ts\n   * dialect.jsonTitleFromExpr('t.user_json')\n   * // PG:     (t.user_json->>'title')\n   * // SQLite: json_extract(t.user_json, '$.title')\n   * ```\n   */\n  jsonTitleFromExpr(selectionSql: string): string;\n\n  /**\n   * Subquery snippet to select user name by id.\n   * @example\n   * ```ts\n   * dialect.selectUserNameById('\"t\".\"__created_by\"')\n   * // PG:     (SELECT u.name FROM users u WHERE u.id = \"t\".\"__created_by\")\n   * // SQLite: (SELECT name FROM users WHERE id = \"t\".\"__created_by\")\n   * ```\n   */\n  selectUserNameById(idRef: string): string;\n\n  /**\n   * Build a JSON object for system user fields: { id, title, email }.\n   * @example\n   * ```ts\n   * dialect.buildUserJsonObjectById('\"t\".\"__created_by\"')\n   * // PG:     (SELECT jsonb_build_object('id', u.id, 'title', u.name, 'email', u.email) FROM users u WHERE u.id = \"t\".\"__created_by\")\n   * // SQLite: json_object('id', \"t\".\"__created_by\", 'title', (SELECT name FROM users WHERE id = \"t\".\"__created_by\"), 'email', (SELECT email FROM users WHERE id = \"t\".\"__created_by\"))\n   * ```\n   */\n  buildUserJsonObjectById(idRef: string): string;\n\n  // Lookup CTE helpers\n\n  /**\n   * Flatten a lookup CTE column if necessary (e.g., PG nested arrays) and return a SQL expression.\n   * Return null when no special handling is required.\n   * @example\n   * ```ts\n   * dialect.flattenLookupCteValue('CTE_main_link', 'fld_123', true, DbFieldType.Json) // => WITH RECURSIVE ... jsonb_array_elements ...\n   * ```\n   */\n  flattenLookupCteValue(\n    cteName: string,\n    fieldId: string,\n    isMultiple: boolean,\n    dbFieldType: DbFieldType\n  ): string | null;\n\n  // JSON aggregation helpers\n\n  /**\n   * Aggregate non-null values into a JSON array; optionally with ORDER BY.\n   * @example\n   * ```ts\n   * dialect.jsonAggregateNonNull('f.title', 'f.__id ASC')\n   * // PG:     json_agg(f.title ORDER BY f.__id ASC) FILTER (WHERE f.title IS NOT NULL)\n   * // SQLite: json_group_array(CASE WHEN f.title IS NOT NULL THEN f.title END)\n   * ```\n   */\n  jsonAggregateNonNull(expression: string, orderByClause?: string): string;\n\n  /**\n   * Aggregate values into a string with delimiter; optionally with ORDER BY.\n   * @example\n   * ```ts\n   * dialect.stringAggregate('t.name', ', ', 't.__id')\n   * // PG:     STRING_AGG(t.name::text, ', ' ORDER BY t.__id)\n   * // SQLite: GROUP_CONCAT(t.name, ', ')\n   * ```\n   */\n  stringAggregate(expression: string, delimiter: string, orderByClause?: string): string;\n\n  /**\n   * Return the length of a JSON array expression.\n   * @example\n   * ```ts\n   * dialect.jsonArrayLength('t.tags')\n   * // PG:     jsonb_array_length(t.tags::jsonb)\n   * // SQLite: json_array_length(t.tags)\n   * ```\n   */\n  jsonArrayLength(expr: string): string;\n\n  /**\n   * Dialect-specific typed NULL for JSON contexts\n   * - PG: NULL::json\n   * - SQLite: NULL\n   */\n  nullJson(): string;\n\n  /**\n   * Produce a typed NULL literal appropriate for the provided database field type.\n   * - PG: returns casts like NULL::jsonb, NULL::timestamptz, etc.\n   * - SQLite: plain NULL (no strong typing).\n   */\n  typedNullFor(dbFieldType: DbFieldType): string;\n\n  // Rollup helpers\n\n  /**\n   * Build an aggregate expression for rollup in multi-value relationships.\n   * Supported functions: sum, average, count, countall, counta, max, min, and, or, xor,\n   * array_join/concatenate, array_unique, array_compact.\n   * @example\n   * ```ts\n   * dialect.rollupAggregate('sum', 'f.amount', { orderByField: 'j.__id' })\n   * // PG:     CAST(COALESCE(SUM(f.amount), 0) AS DOUBLE PRECISION)\n   * // SQLite: COALESCE(SUM(f.amount), 0)\n   * ```\n   */\n  rollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    opts: {\n      targetField?: FieldCore;\n      orderByField?: string;\n      rowPresenceExpr?: string;\n      flattenNestedArray?: boolean;\n    }\n  ): string;\n\n  /**\n   * Build rollup-like expression for single-value relationships without GROUP BY.\n   * @example\n   * ```ts\n   * dialect.singleValueRollupAggregate('count', 'f.amount', { rollupField, targetField })\n   * // PG:     CASE WHEN f.amount IS NULL THEN 0 ELSE 1 END\n   * ```\n   */\n  singleValueRollupAggregate(\n    fn: string,\n    fieldExpression: string,\n    options: { rollupField: FieldCore; targetField: FieldCore }\n  ): string;\n\n  /**\n   * Build conditional JSON for link cell: { id, title? }.\n   * If the title expression is NULL, omit title in PG (strip nulls) or omit the key in SQLite.\n   * @example\n   * ```ts\n   * dialect.buildLinkJsonObject('f.\"__id\"', 'formattedTitleExpr', 'rawTitleExpr')\n   * // PG:     jsonb_strip_nulls(jsonb_build_object('id', f.\"__id\", 'title', formattedTitleExpr))::jsonb\n   * // SQLite: CASE WHEN rawTitleExpr IS NOT NULL THEN json_object('id', f.\"__id\", 'title', formattedTitleExpr) ELSE json_object('id', f.\"__id\") END\n   * ```\n   */\n  buildLinkJsonObject(\n    recordIdRef: string,\n    formattedSelectionExpression: string,\n    rawSelectionExpression: string\n  ): string;\n\n  /**\n   * Apply deterministic ordering workarounds for JSON aggregations in CTEs.\n   * Only SQLite typically modifies the builder (e.g., ORDER BY junction.__id); PG is a no-op.\n   * @example\n   * ```ts\n   * dialect.applyLinkCteOrdering(qb, { relationship: Relationship.OneMany, usesJunctionTable: false, hasOrderColumn: true, junctionAlias: 'j', foreignAlias: 'f', selfKeyName: 'main_id' })\n   * ```\n   */\n  applyLinkCteOrdering(\n    qb: Knex.QueryBuilder,\n    opts: {\n      relationship: Relationship;\n      usesJunctionTable: boolean;\n      hasOrderColumn: boolean;\n      junctionAlias: string;\n      foreignAlias: string;\n      selfKeyName: string;\n    }\n  ): void;\n\n  /**\n   * Build deterministic ordered aggregate for multi-value LOOKUP (SQLite path).\n   * - PG: return null and let caller use json_agg ORDER BY directly.\n   * - SQLite: return a correlated subquery using json_group_array with ORDER BY to preserve order.\n   * @example\n   * ```ts\n   * dialect.buildDeterministicLookupAggregate({\n   *   tableDbName: 'main', mainAlias: 'm', foreignDbName: 'foreign', foreignAlias: 'f',\n   *   usesJunctionTable: true, linkFieldOrderColumn: 'j.\"order\"', junctionAlias: 'j',\n   *   linkFieldHasOrderColumn: true, selfKeyName: 'main_id', foreignKeyName: 'foreign_id',\n   *   recordIdRef: 'f.\"__id\"', formattedSelectionExpression: '...titleExpr...', rawSelectionExpression: '...rawExpr...'\n   * })\n   * ```\n   */\n  buildDeterministicLookupAggregate(params: {\n    tableDbName: string;\n    mainAlias: string;\n    foreignDbName: string;\n    foreignAlias: string;\n    linkFieldOrderColumn?: string; // e.g., j.\"order\" or f.\"self_order\"\n    linkFieldHasOrderColumn: boolean;\n    usesJunctionTable: boolean;\n    selfKeyName: string;\n    foreignKeyName: string;\n    recordIdRef: string; // f.\"__id\"\n    formattedSelectionExpression: string; // using foreign alias\n    rawSelectionExpression: string; // using foreign alias\n    linkFilterSubquerySql?: string; // EXISTS (subquery) condition\n    junctionAlias: string; // typically 'j'\n  }): string | null;\n}\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const RECORD_QUERY_DIALECT_SYMBOL = Symbol('RECORD_QUERY_DIALECT');\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts",
    "content": "/* eslint-disable regexp/no-unused-capturing-group */\n/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable regexp/no-dupe-characters-character-class */\n/* eslint-disable sonarjs/no-duplicated-branches */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-collapsible-if */\n/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport {\n  StringLiteralContext,\n  IntegerLiteralContext,\n  LeftWhitespaceOrCommentsContext,\n  RightWhitespaceOrCommentsContext,\n  CircularReferenceError,\n  FunctionCallContext,\n  FunctionName,\n  FieldType,\n  CellValueType,\n  DriverClient,\n  AbstractParseTreeVisitor,\n  BinaryOpContext,\n  BooleanLiteralContext,\n  BracketsContext,\n  DecimalLiteralContext,\n  FieldReferenceCurlyContext,\n  isLinkField,\n  parseFormula,\n  isFieldHasExpression,\n  isFormulaField,\n  isLinkLookupOptions,\n  normalizeFunctionNameAlias,\n  DbFieldType,\n  DateFormattingPreset,\n  extractFieldReferenceId,\n  getFieldReferenceTokenText,\n  FUNCTIONS,\n  Relationship,\n  TimeFormatting,\n} from '@teable/core';\nimport type {\n  FormulaVisitor,\n  ExprContext,\n  TableDomain,\n  FieldCore,\n  AutoNumberFieldCore,\n  CreatedTimeFieldCore,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n  FormulaFieldCore,\n  IFieldWithExpression,\n  IFormulaParamMetadata,\n  IFormulaParamFieldMetadata,\n  FormulaParamType,\n  IDatetimeFormatting,\n  ITeableToDbFunctionConverter,\n} from '@teable/core';\nimport type { RootContext, UnaryOpContext } from '@teable/formula';\nimport type { Knex } from 'knex';\nimport { match } from 'ts-pattern';\nimport type { IFieldSelectName } from './field-select.type';\nimport { PgRecordQueryDialect } from './providers/pg-record-query-dialect';\nimport { SqliteRecordQueryDialect } from './providers/sqlite-record-query-dialect';\nimport type { IRecordSelectionMap } from './record-query-builder.interface';\nimport type { IRecordQueryDialectProvider } from './record-query-dialect.interface';\n\nfunction unescapeString(str: string): string {\n  return str.replace(/\\\\(.)/g, (_, char) => {\n    return match(char)\n      .with('n', () => '\\n')\n      .with('t', () => '\\t')\n      .with('r', () => '\\r')\n      .with('\\\\', () => '\\\\')\n      .with(\"'\", () => \"'\")\n      .with('\"', () => '\"')\n      .otherwise((c) => c);\n  });\n}\n\nconst STRING_FUNCTIONS = new Set<FunctionName>([\n  FunctionName.Concatenate,\n  FunctionName.Left,\n  FunctionName.Right,\n  FunctionName.Mid,\n  FunctionName.Upper,\n  FunctionName.Lower,\n  FunctionName.Trim,\n  FunctionName.Substitute,\n  FunctionName.Replace,\n  FunctionName.T,\n  FunctionName.Blank,\n  FunctionName.Datestr,\n  FunctionName.Timestr,\n  FunctionName.ArrayJoin,\n]);\n\nconst NUMBER_FUNCTIONS = new Set<FunctionName>([\n  FunctionName.Sum,\n  FunctionName.Average,\n  FunctionName.Max,\n  FunctionName.Min,\n  FunctionName.Round,\n  FunctionName.RoundUp,\n  FunctionName.RoundDown,\n  FunctionName.Ceiling,\n  FunctionName.Floor,\n  FunctionName.Abs,\n  FunctionName.Sqrt,\n  FunctionName.Power,\n  FunctionName.Exp,\n  FunctionName.Log,\n  FunctionName.Mod,\n  FunctionName.Value,\n  FunctionName.Find,\n  FunctionName.Search,\n  FunctionName.Len,\n  FunctionName.Count,\n  FunctionName.CountA,\n  FunctionName.CountAll,\n]);\n\nconst BOOLEAN_FUNCTIONS = new Set<FunctionName>([\n  FunctionName.And,\n  FunctionName.Or,\n  FunctionName.Not,\n  FunctionName.Xor,\n]);\n\nconst MULTI_VALUE_AGGREGATED_FUNCTIONS = new Set<FunctionName>([\n  FunctionName.DatetimeFormat,\n  FunctionName.Value,\n  FunctionName.Abs,\n  FunctionName.Datestr,\n  FunctionName.Timestr,\n  FunctionName.Day,\n  FunctionName.Month,\n  FunctionName.Year,\n  FunctionName.Weekday,\n  FunctionName.WeekNum,\n  FunctionName.Hour,\n  FunctionName.Minute,\n  FunctionName.Second,\n  FunctionName.FromNow,\n  FunctionName.ToNow,\n  FunctionName.Round,\n  FunctionName.RoundUp,\n  FunctionName.RoundDown,\n  FunctionName.Floor,\n  FunctionName.Ceiling,\n  FunctionName.Int,\n]);\n\nconst MULTI_VALUE_FIELD_TYPES = new Set<FieldType>([\n  FieldType.Link,\n  FieldType.Attachment,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n]);\n\nconst STRING_FIELD_TYPES = new Set<FieldType>([\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n  FieldType.Attachment,\n  FieldType.Link,\n  FieldType.Button,\n]);\n\nconst DATETIME_FIELD_TYPES = new Set<FieldType>([\n  FieldType.Date,\n  FieldType.CreatedTime,\n  FieldType.LastModifiedTime,\n]);\n\nconst NUMBER_FIELD_TYPES = new Set<FieldType>([\n  FieldType.Number,\n  FieldType.Rating,\n  FieldType.AutoNumber,\n  FieldType.Rollup,\n]);\n\n/**\n * Context information for formula conversion\n */\nexport interface IFormulaConversionContext {\n  table: TableDomain;\n  /** Whether this conversion is for a generated column (affects immutable function handling) */\n  isGeneratedColumn?: boolean;\n  driverClient?: DriverClient;\n  expansionCache?: Map<string, string>;\n  /** Optional timezone to interpret date/time literals and fields in SELECT context */\n  timeZone?: string;\n}\n\n/**\n * Extended context for select query formula conversion with CTE support\n */\nexport interface ISelectFormulaConversionContext extends IFormulaConversionContext {\n  selectionMap: IRecordSelectionMap;\n  /** Table alias to use for field references */\n  tableAlias?: string;\n  /** CTE map: linkFieldId -> cteName */\n  fieldCteMap?: ReadonlyMap<string, string>;\n  /** Link field IDs whose CTEs have already been emitted (safe for reference) */\n  readyLinkFieldIds?: ReadonlySet<string>;\n  /** Current link field id whose CTE is being generated (used to avoid self references) */\n  currentLinkFieldId?: string;\n  /** When true, prefer raw field references (no title formatting) to preserve native types */\n  preferRawFieldReferences?: boolean;\n  /** Target DB field type for the enclosing formula selection (used for type-sensitive raw projection) */\n  targetDbFieldType?: DbFieldType;\n}\n\n/**\n * Result of formula conversion\n */\nexport interface IFormulaConversionResult {\n  sql: string;\n  dependencies: string[]; // field IDs that this formula depends on\n}\n\n/**\n * Interface for database-specific generated column query implementations\n * Each database provider (PostgreSQL, SQLite) should implement this interface\n * to provide SQL translations for Teable formula functions that will be used\n * in database generated columns. This interface ensures formula expressions\n * are converted to immutable SQL expressions suitable for generated columns.\n */\nexport interface IGeneratedColumnQueryInterface\n  extends ITeableToDbFunctionConverter<string, IFormulaConversionContext> {}\n\n/**\n * Interface for database-specific SELECT query implementations\n * Each database provider (PostgreSQL, SQLite) should implement this interface\n * to provide SQL translations for Teable formula functions that will be used\n * in SELECT statements as computed columns. Unlike generated columns, these\n * expressions can use mutable functions and have different optimization strategies.\n */\nexport interface ISelectQueryInterface\n  extends ITeableToDbFunctionConverter<string, IFormulaConversionContext> {}\n\n/**\n * Interface for validating whether Teable formula functions convert to generated column are supported\n * by a specific database provider. Each method returns a boolean indicating\n * whether the corresponding function can be converted to a valid database expression.\n */\nexport interface IGeneratedColumnQuerySupportValidator\n  extends ITeableToDbFunctionConverter<boolean, IFormulaConversionContext> {}\n\n/**\n * Get should expand field reference\n *\n * @param field\n * @returns boolean\n */\nfunction shouldExpandFieldReference(\n  field: FieldCore\n): field is\n  | FormulaFieldCore\n  | AutoNumberFieldCore\n  | CreatedTimeFieldCore\n  | LastModifiedTimeFieldCore {\n  if (isFormulaField(field) && field.isLookup) {\n    return false;\n  }\n  return isFieldHasExpression(field);\n}\n\n/**\n * Abstract base visitor that contains common functionality for SQL conversion\n */\nabstract class BaseSqlConversionVisitor<\n    TFormulaQuery extends ITeableToDbFunctionConverter<string, IFormulaConversionContext>,\n  >\n  extends AbstractParseTreeVisitor<string>\n  implements FormulaVisitor<IFieldSelectName>\n{\n  protected expansionStack: Set<string> = new Set();\n\n  protected defaultResult(): string {\n    throw new Error('Method not implemented.');\n  }\n\n  protected getQuestionMarkExpression(): string {\n    if (this.context.driverClient === DriverClient.Sqlite) {\n      return 'CHAR(63)';\n    }\n    return 'CHR(63)';\n  }\n\n  constructor(\n    protected readonly knex: Knex,\n    protected formulaQuery: TFormulaQuery,\n    protected context: IFormulaConversionContext,\n    protected dialect?: IRecordQueryDialectProvider\n  ) {\n    super();\n    // Initialize a dialect provider for use in driver-specific pieces when callers don't inject one\n    if (!this.dialect) {\n      const d = this.context.driverClient;\n      if (d === DriverClient.Pg) this.dialect = new PgRecordQueryDialect(this.knex);\n      else this.dialect = new SqliteRecordQueryDialect(this.knex);\n    }\n  }\n\n  visitRoot(ctx: RootContext): string {\n    return ctx.expr().accept(this);\n  }\n\n  visitStringLiteral(ctx: StringLiteralContext): string {\n    const quotedString = ctx.text;\n    const rawString = quotedString.slice(1, -1);\n    const unescapedString = unescapeString(rawString);\n\n    if (!unescapedString.includes('?')) {\n      return this.formulaQuery.stringLiteral(unescapedString);\n    }\n\n    const charExpr = this.getQuestionMarkExpression();\n    const parts = unescapedString.split('?');\n    const segments: string[] = [];\n\n    parts.forEach((part, index) => {\n      if (part.length) {\n        segments.push(this.formulaQuery.stringLiteral(part));\n      }\n      if (index < parts.length - 1) {\n        segments.push(charExpr);\n      }\n    });\n\n    if (segments.length === 0) {\n      return charExpr;\n    }\n\n    if (segments.length === 1) {\n      return segments[0];\n    }\n\n    return this.formulaQuery.concatenate(segments);\n  }\n\n  visitIntegerLiteral(ctx: IntegerLiteralContext): string {\n    const value = parseInt(ctx.text, 10);\n    return this.formulaQuery.numberLiteral(value);\n  }\n\n  visitDecimalLiteral(ctx: DecimalLiteralContext): string {\n    const value = parseFloat(ctx.text);\n    return this.formulaQuery.numberLiteral(value);\n  }\n\n  visitBooleanLiteral(ctx: BooleanLiteralContext): string {\n    const value = ctx.text.toUpperCase() === 'TRUE';\n    return this.formulaQuery.booleanLiteral(value);\n  }\n\n  visitLeftWhitespaceOrComments(ctx: LeftWhitespaceOrCommentsContext): string {\n    return ctx.expr().accept(this);\n  }\n\n  visitRightWhitespaceOrComments(ctx: RightWhitespaceOrCommentsContext): string {\n    return ctx.expr().accept(this);\n  }\n\n  visitBrackets(ctx: BracketsContext): string {\n    const innerExpression = ctx.expr().accept(this);\n    return this.formulaQuery.parentheses(innerExpression);\n  }\n\n  visitUnaryOp(ctx: UnaryOpContext): string {\n    const operandCtx = ctx.expr();\n    const operand = operandCtx.accept(this);\n    const operator = ctx.MINUS();\n    const metadata = [this.buildParamMetadata(operandCtx)];\n    this.formulaQuery.setCallMetadata(metadata);\n\n    try {\n      if (operator) {\n        return this.formulaQuery.unaryMinus(operand);\n      }\n      return operand;\n    } finally {\n      this.formulaQuery.setCallMetadata(undefined);\n    }\n  }\n\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {\n    const normalizedFieldId = extractFieldReferenceId(ctx);\n    const rawToken = getFieldReferenceTokenText(ctx);\n    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n\n    const fieldInfo = this.context.table.getField(fieldId);\n    if (!fieldInfo) {\n      throw new Error(`Field not found: ${fieldId}`);\n    }\n\n    // Check if this is a formula field that needs recursive expansion\n    if (shouldExpandFieldReference(fieldInfo)) {\n      return this.expandFormulaField(fieldId, fieldInfo);\n    }\n\n    // Note: user-related field handling for select queries is implemented\n    // in SelectColumnSqlConversionVisitor where selection context exists.\n\n    return this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);\n  }\n\n  /**\n   * Recursively expand a formula field reference\n   * @param fieldId The field ID to expand\n   * @param fieldInfo The field information\n   * @returns The expanded SQL expression\n   */\n  protected expandFormulaField(fieldId: string, fieldInfo: IFieldWithExpression): string {\n    // Initialize expansion cache if not present\n    if (!this.context.expansionCache) {\n      this.context.expansionCache = new Map();\n    }\n\n    // Check cache first\n    if (this.context.expansionCache.has(fieldId)) {\n      return this.context.expansionCache.get(fieldId)!;\n    }\n\n    // Check for circular references\n    if (this.expansionStack.has(fieldId)) {\n      throw new CircularReferenceError(fieldId, Array.from(this.expansionStack));\n    }\n\n    const expression = fieldInfo.getExpression();\n\n    // If no expression is found, fall back to normal field reference\n    if (!expression) {\n      return this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);\n    }\n\n    // Add to expansion stack to detect circular references\n    this.expansionStack.add(fieldId);\n\n    const selectContext = this.context as ISelectFormulaConversionContext | undefined;\n    const prevTargetDbFieldType = selectContext?.targetDbFieldType;\n    const prevTimeZone = selectContext?.timeZone;\n    const nextTargetDbFieldType = (fieldInfo as unknown as { dbFieldType?: DbFieldType })\n      ?.dbFieldType;\n    const rawOptions = (fieldInfo as unknown as { options?: unknown })?.options;\n    let nextTimeZone: string | undefined;\n    if (rawOptions && typeof rawOptions === 'object') {\n      nextTimeZone = (rawOptions as { timeZone?: string }).timeZone;\n    } else if (typeof rawOptions === 'string') {\n      try {\n        nextTimeZone = (JSON.parse(rawOptions) as { timeZone?: string } | undefined)?.timeZone;\n      } catch {\n        nextTimeZone = undefined;\n      }\n    }\n\n    if (selectContext) {\n      if (nextTargetDbFieldType != null) {\n        selectContext.targetDbFieldType = nextTargetDbFieldType;\n      }\n      if (nextTimeZone != null) {\n        selectContext.timeZone = nextTimeZone;\n      }\n    }\n\n    try {\n      // Recursively expand the expression by parsing and visiting it\n      const tree = parseFormula(expression);\n      const expandedSql = tree.accept(this);\n\n      // Cache the result\n      this.context.expansionCache.set(fieldId, expandedSql);\n\n      return expandedSql;\n    } finally {\n      if (selectContext) {\n        selectContext.targetDbFieldType = prevTargetDbFieldType;\n        selectContext.timeZone = prevTimeZone;\n      }\n      // Remove from expansion stack\n      this.expansionStack.delete(fieldId);\n    }\n  }\n\n  visitFunctionCall(ctx: FunctionCallContext): string {\n    const rawName = ctx.func_name().text.toUpperCase();\n    const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n    const exprContexts = ctx.expr();\n    let params = exprContexts.map((exprCtx) => exprCtx.accept(this));\n    params = this.normalizeFunctionParamsForMultiplicity(fnName, params, exprContexts);\n    const paramMetadata = exprContexts.map((exprCtx) => this.buildParamMetadata(exprCtx));\n    this.formulaQuery.setCallMetadata(paramMetadata);\n\n    const execute = () => {\n      const multiValueFormat = this.tryBuildMultiValueAggregator(fnName, params, exprContexts);\n      if (multiValueFormat) {\n        return multiValueFormat;\n      }\n\n      return (\n        match(fnName)\n          // Numeric Functions\n          .with(FunctionName.Sum, () => this.formulaQuery.sum(params))\n          .with(FunctionName.Average, () => this.formulaQuery.average(params))\n          .with(FunctionName.Max, () => this.formulaQuery.max(params))\n          .with(FunctionName.Min, () => this.formulaQuery.min(params))\n          .with(FunctionName.Round, () => this.formulaQuery.round(params[0], params[1]))\n          .with(FunctionName.RoundUp, () => this.formulaQuery.roundUp(params[0], params[1]))\n          .with(FunctionName.RoundDown, () => this.formulaQuery.roundDown(params[0], params[1]))\n          .with(FunctionName.Ceiling, () => this.formulaQuery.ceiling(params[0]))\n          .with(FunctionName.Floor, () => this.formulaQuery.floor(params[0]))\n          .with(FunctionName.Even, () => this.formulaQuery.even(params[0]))\n          .with(FunctionName.Odd, () => this.formulaQuery.odd(params[0]))\n          .with(FunctionName.Int, () => this.formulaQuery.int(params[0]))\n          .with(FunctionName.Abs, () => this.formulaQuery.abs(params[0]))\n          .with(FunctionName.Sqrt, () => this.formulaQuery.sqrt(params[0]))\n          .with(FunctionName.Power, () => this.formulaQuery.power(params[0], params[1]))\n          .with(FunctionName.Exp, () => this.formulaQuery.exp(params[0]))\n          .with(FunctionName.Log, () => this.formulaQuery.log(params[0], params[1]))\n          .with(FunctionName.Mod, () => this.formulaQuery.mod(params[0], params[1]))\n          .with(FunctionName.Value, () => this.formulaQuery.value(params[0]))\n\n          // Text Functions\n          .with(FunctionName.Concatenate, () => {\n            const coerced = params.map((param, index) =>\n              this.coerceToStringForConcatenation(param, exprContexts[index])\n            );\n            return this.formulaQuery.concatenate(coerced);\n          })\n          .with(FunctionName.Find, () => this.formulaQuery.find(params[0], params[1], params[2]))\n          .with(FunctionName.Search, () =>\n            this.formulaQuery.search(params[0], params[1], params[2])\n          )\n          .with(FunctionName.Mid, () => this.formulaQuery.mid(params[0], params[1], params[2]))\n          .with(FunctionName.Left, () => {\n            const textOperand = this.coerceToStringForConcatenation(params[0], exprContexts[0]);\n            const sliceLength = this.normalizeTextSliceCount(params[1], exprContexts[1]);\n            return this.formulaQuery.left(textOperand, sliceLength);\n          })\n          .with(FunctionName.Right, () => {\n            const textOperand = this.coerceToStringForConcatenation(params[0], exprContexts[0]);\n            const sliceLength = this.normalizeTextSliceCount(params[1], exprContexts[1]);\n            return this.formulaQuery.right(textOperand, sliceLength);\n          })\n          .with(FunctionName.Replace, () =>\n            this.formulaQuery.replace(params[0], params[1], params[2], params[3])\n          )\n          .with(FunctionName.RegExpReplace, () =>\n            this.formulaQuery.regexpReplace(params[0], params[1], params[2])\n          )\n          .with(FunctionName.Substitute, () =>\n            this.formulaQuery.substitute(params[0], params[1], params[2], params[3])\n          )\n          .with(FunctionName.Lower, () => this.formulaQuery.lower(params[0]))\n          .with(FunctionName.Upper, () => this.formulaQuery.upper(params[0]))\n          .with(FunctionName.Rept, () => this.formulaQuery.rept(params[0], params[1]))\n          .with(FunctionName.Trim, () => this.formulaQuery.trim(params[0]))\n          .with(FunctionName.Len, () => this.formulaQuery.len(params[0]))\n          .with(FunctionName.T, () => this.formulaQuery.t(params[0]))\n          .with(FunctionName.EncodeUrlComponent, () =>\n            this.formulaQuery.encodeUrlComponent(params[0])\n          )\n\n          // DateTime Functions\n          .with(FunctionName.Now, () => this.formulaQuery.now())\n          .with(FunctionName.Today, () => this.formulaQuery.today())\n          .with(FunctionName.DateAdd, () =>\n            this.formulaQuery.dateAdd(params[0], params[1], params[2])\n          )\n          .with(FunctionName.Datestr, () => this.formulaQuery.datestr(params[0]))\n          .with(FunctionName.DatetimeDiff, () => {\n            const unitExpr = params[2] ?? `'day'`;\n            return this.formulaQuery.datetimeDiff(params[0], params[1], unitExpr);\n          })\n          .with(FunctionName.DatetimeFormat, () =>\n            this.formulaQuery.datetimeFormat(params[0], params[1])\n          )\n          .with(FunctionName.DatetimeParse, () =>\n            this.formulaQuery.datetimeParse(params[0], params[1])\n          )\n          .with(FunctionName.Day, () => this.formulaQuery.day(params[0]))\n          .with(FunctionName.FromNow, () => this.formulaQuery.fromNow(params[0], params[1]))\n          .with(FunctionName.Hour, () => this.formulaQuery.hour(params[0]))\n          .with(FunctionName.IsAfter, () => this.formulaQuery.isAfter(params[0], params[1]))\n          .with(FunctionName.IsBefore, () => this.formulaQuery.isBefore(params[0], params[1]))\n          .with(FunctionName.IsSame, () =>\n            this.formulaQuery.isSame(params[0], params[1], params[2])\n          )\n          .with(FunctionName.LastModifiedTime, () => this.formulaQuery.lastModifiedTime())\n          .with(FunctionName.Minute, () => this.formulaQuery.minute(params[0]))\n          .with(FunctionName.Month, () => this.formulaQuery.month(params[0]))\n          .with(FunctionName.Second, () => this.formulaQuery.second(params[0]))\n          .with(FunctionName.Timestr, () => this.formulaQuery.timestr(params[0]))\n          .with(FunctionName.ToNow, () => this.formulaQuery.toNow(params[0], params[1]))\n          .with(FunctionName.WeekNum, () => this.formulaQuery.weekNum(params[0]))\n          .with(FunctionName.Weekday, () => this.formulaQuery.weekday(params[0], params[1]))\n          .with(FunctionName.Workday, () =>\n            this.formulaQuery.workday(params[0], params[1], params[2])\n          )\n          .with(FunctionName.WorkdayDiff, () => this.formulaQuery.workdayDiff(params[0], params[1]))\n          .with(FunctionName.Year, () => this.formulaQuery.year(params[0]))\n          .with(FunctionName.CreatedTime, () => this.formulaQuery.createdTime())\n\n          // Logical Functions\n          .with(FunctionName.If, () => {\n            const [rawConditionSql, rawTrueSql, rawFalseSql] = params;\n            const conditionSql = rawConditionSql ?? 'NULL';\n            const trueSql = rawTrueSql ?? 'NULL';\n            const falseSql = rawFalseSql ?? 'NULL';\n\n            let coercedTrue = trueSql;\n            let coercedFalse = falseSql;\n\n            const trueExprCtx = exprContexts[1];\n            const falseExprCtx = exprContexts[2];\n            const trueType = this.inferExpressionType(trueExprCtx);\n            const falseType = this.inferExpressionType(falseExprCtx);\n            const trueSqlTrimmed = (rawTrueSql ?? '').trim();\n            const falseSqlTrimmed = (rawFalseSql ?? '').trim();\n            const trueIsBlank =\n              rawTrueSql == null ||\n              this.isBlankLikeExpression(trueExprCtx) ||\n              trueSqlTrimmed === \"''\";\n            const falseIsBlank =\n              rawFalseSql == null ||\n              this.isBlankLikeExpression(falseExprCtx) ||\n              falseSqlTrimmed === \"''\";\n\n            const shouldNullOutTrueBranch = trueIsBlank && falseType !== 'string';\n            const shouldNullOutFalseBranch = falseIsBlank && trueType !== 'string';\n\n            if (shouldNullOutTrueBranch) {\n              coercedTrue = 'NULL';\n            }\n\n            if (shouldNullOutFalseBranch) {\n              coercedFalse = 'NULL';\n            }\n\n            if (this.inferExpressionType(ctx) === 'string') {\n              coercedTrue = this.coerceCaseBranchToText(coercedTrue);\n              coercedFalse = this.coerceCaseBranchToText(coercedFalse);\n            }\n\n            return this.formulaQuery.if(conditionSql, coercedTrue, coercedFalse);\n          })\n          .with(FunctionName.And, () => {\n            const booleanParams = params.map((param, index) =>\n              this.normalizeBooleanExpression(param, exprContexts[index])\n            );\n            return this.formulaQuery.and(booleanParams);\n          })\n          .with(FunctionName.Or, () => {\n            const booleanParams = params.map((param, index) =>\n              this.normalizeBooleanExpression(param, exprContexts[index])\n            );\n            return this.formulaQuery.or(booleanParams);\n          })\n          .with(FunctionName.Not, () => {\n            const booleanParam = this.normalizeBooleanExpression(params[0], exprContexts[0]);\n            return this.formulaQuery.not(booleanParam);\n          })\n          .with(FunctionName.Xor, () => {\n            const booleanParams = params.map((param, index) =>\n              this.normalizeBooleanExpression(param, exprContexts[index])\n            );\n            return this.formulaQuery.xor(booleanParams);\n          })\n          .with(FunctionName.Blank, () => this.formulaQuery.blank())\n          .with(FunctionName.IsError, () => this.formulaQuery.isError(params[0]))\n          .with(FunctionName.Switch, () => {\n            // Handle switch function with variable number of case-result pairs\n            const expression = params[0];\n            const cases: Array<{ case: string; result: string }> = [];\n            let defaultResult: string | undefined;\n\n            type SwitchResultEntry = {\n              sql: string;\n              ctx: ExprContext;\n              type: 'string' | 'number' | 'boolean' | 'datetime' | 'unknown';\n            };\n\n            const resultEntries: SwitchResultEntry[] = [];\n\n            // Helper to normalize blank-like results when other branches require stricter typing\n            const normalizeBlankResults = () => {\n              const hasNumber = resultEntries.some((entry) => entry.type === 'number');\n              const hasBoolean = resultEntries.some((entry) => entry.type === 'boolean');\n              const hasDatetime = resultEntries.some((entry) => entry.type === 'datetime');\n\n              const requiresNumeric = hasNumber;\n              const requiresBoolean = hasBoolean;\n              const requiresDatetime = hasDatetime;\n\n              const shouldNullifyEntry = (entry: SwitchResultEntry): boolean => {\n                const isBlank =\n                  this.isBlankLikeExpression(entry.ctx) || (entry.sql ?? '').trim() === \"''\";\n\n                if (!isBlank) {\n                  return false;\n                }\n\n                if (requiresNumeric && entry.type !== 'number') {\n                  return true;\n                }\n\n                if (requiresBoolean && entry.type !== 'boolean') {\n                  return true;\n                }\n\n                if (requiresDatetime && entry.type !== 'datetime') {\n                  return true;\n                }\n\n                return false;\n              };\n\n              for (const entry of resultEntries) {\n                if (shouldNullifyEntry(entry)) {\n                  entry.sql = 'NULL';\n                }\n              }\n            };\n\n            // Collect case/result pairs and default (if any)\n            for (let i = 1; i < params.length; i += 2) {\n              if (i + 1 < params.length) {\n                const resultCtx = exprContexts[i + 1];\n                resultEntries.push({\n                  sql: params[i + 1],\n                  ctx: resultCtx,\n                  type: this.inferExpressionType(resultCtx),\n                });\n\n                cases.push({\n                  case: params[i],\n                  result: params[i + 1],\n                });\n              } else {\n                const resultCtx = exprContexts[i];\n                resultEntries.push({\n                  sql: params[i],\n                  ctx: resultCtx,\n                  type: this.inferExpressionType(resultCtx),\n                });\n                defaultResult = params[i];\n              }\n            }\n\n            // Normalize blank results only after we have collected all branch types\n            normalizeBlankResults();\n\n            if (this.inferExpressionType(ctx) === 'string') {\n              for (const entry of resultEntries) {\n                entry.sql = this.coerceCaseBranchToText(entry.sql);\n              }\n            }\n\n            // Apply normalized SQL back to cases/default\n            let resultIndex = 0;\n            for (let i = 0; i < cases.length; i++) {\n              cases[i] = {\n                case: cases[i].case,\n                result: resultEntries[resultIndex++].sql,\n              };\n            }\n\n            if (defaultResult !== undefined) {\n              defaultResult = resultEntries[resultIndex]?.sql;\n            }\n\n            return this.formulaQuery.switch(expression, cases, defaultResult);\n          })\n\n          // Array Functions\n          .with(FunctionName.Count, () => this.formulaQuery.count(params))\n          .with(FunctionName.CountA, () => this.formulaQuery.countA(params))\n          .with(FunctionName.CountAll, () => this.formulaQuery.countAll(params[0]))\n          .with(FunctionName.ArrayJoin, () => this.formulaQuery.arrayJoin(params[0], params[1]))\n          .with(FunctionName.ArrayUnique, () => this.formulaQuery.arrayUnique(params))\n          .with(FunctionName.ArrayFlatten, () => this.formulaQuery.arrayFlatten(params))\n          .with(FunctionName.ArrayCompact, () => this.formulaQuery.arrayCompact(params))\n\n          // System Functions\n          .with(FunctionName.RecordId, () => this.formulaQuery.recordId())\n          .with(FunctionName.AutoNumber, () => this.formulaQuery.autoNumber())\n          .with(FunctionName.TextAll, () => this.formulaQuery.textAll(params[0]))\n\n          .otherwise((fn) => {\n            throw new Error(`Unsupported function: ${fn}`);\n          })\n      );\n    };\n\n    try {\n      return execute();\n    } finally {\n      this.formulaQuery.setCallMetadata(undefined);\n    }\n  }\n\n  visitBinaryOp(ctx: BinaryOpContext): string {\n    const exprContexts = [ctx.expr(0), ctx.expr(1)];\n    const paramMetadata = exprContexts.map((exprCtx) => this.buildParamMetadata(exprCtx));\n    this.formulaQuery.setCallMetadata(paramMetadata);\n\n    try {\n      let left = exprContexts[0].accept(this);\n      let right = exprContexts[1].accept(this);\n      const operator = ctx._op;\n\n      // For comparison operators, ensure operands are comparable to avoid\n      // Postgres errors like \"operator does not exist: text > integer\".\n      // If one side is number and the other is string, safely cast the string\n      // side to numeric (driver-aware) before building the comparison.\n      const leftType = this.inferExpressionType(exprContexts[0]);\n      const rightType = this.inferExpressionType(exprContexts[1]);\n      const needsNumericCoercion = (op: string) =>\n        ['>', '<', '>=', '<=', '=', '!=', '<>'].includes(op);\n      if (operator.text && needsNumericCoercion(operator.text)) {\n        const isBooleanNumericCompare =\n          (leftType === 'boolean' && rightType === 'number') ||\n          (leftType === 'number' && rightType === 'boolean');\n        if (isBooleanNumericCompare) {\n          if (leftType === 'boolean') {\n            left = this.coerceBooleanToNumeric(left, exprContexts[0]);\n            right = this.safeCastToNumeric(right);\n          } else {\n            left = this.safeCastToNumeric(left);\n            right = this.coerceBooleanToNumeric(right, exprContexts[1]);\n          }\n        } else if (leftType === 'number' && rightType === 'string') {\n          right = this.safeCastToNumeric(right);\n        } else if (leftType === 'string' && rightType === 'number') {\n          left = this.safeCastToNumeric(left);\n        }\n      }\n\n      // For arithmetic operators (except '+'), coerce string operands to numeric\n      // so expressions like \"text * 3\" or \"'10' / '2'\" work without errors in generated columns.\n      const needsArithmeticNumericCoercion = (op: string) => ['*', '/', '-', '%'].includes(op);\n      if (operator.text && needsArithmeticNumericCoercion(operator.text)) {\n        if (leftType === 'string') {\n          left = this.safeCastToNumeric(left);\n        }\n        if (rightType === 'string') {\n          right = this.safeCastToNumeric(right);\n        }\n      }\n\n      return match(operator.text)\n        .with('+', () => {\n          // Check if either operand is a string type for concatenation\n          const _leftType = this.inferExpressionType(exprContexts[0]);\n          const _rightType = this.inferExpressionType(exprContexts[1]);\n          const paramMetadata = [\n            this.buildParamMetadata(exprContexts[0]),\n            this.buildParamMetadata(exprContexts[1]),\n          ];\n          this.formulaQuery.setCallMetadata(paramMetadata);\n\n          const forceNumericAddition = this.shouldForceNumericAddition();\n\n          if (\n            !forceNumericAddition &&\n            (_leftType === 'string' ||\n              _rightType === 'string' ||\n              _leftType === 'datetime' ||\n              _rightType === 'datetime')\n          ) {\n            const coercedLeft = this.coerceToStringForConcatenation(left, ctx.expr(0), _leftType);\n            const coercedRight = this.coerceToStringForConcatenation(\n              right,\n              ctx.expr(1),\n              _rightType\n            );\n            return this.formulaQuery.stringConcat(coercedLeft, coercedRight);\n          }\n\n          return this.formulaQuery.add(left, right);\n        })\n        .with('-', () => this.formulaQuery.subtract(left, right))\n        .with('*', () => this.formulaQuery.multiply(left, right))\n        .with('/', () => this.formulaQuery.divide(left, right))\n        .with('%', () => this.formulaQuery.modulo(left, right))\n        .with('>', () => this.formulaQuery.greaterThan(left, right))\n        .with('<', () => this.formulaQuery.lessThan(left, right))\n        .with('>=', () => this.formulaQuery.greaterThanOrEqual(left, right))\n        .with('<=', () => this.formulaQuery.lessThanOrEqual(left, right))\n        .with('=', () => this.formulaQuery.equal(left, right))\n        .with('!=', '<>', () => this.formulaQuery.notEqual(left, right))\n        .with('&&', () => {\n          const normalizedLeft = this.normalizeBooleanExpression(left, ctx.expr(0));\n          const normalizedRight = this.normalizeBooleanExpression(right, ctx.expr(1));\n          return this.formulaQuery.logicalAnd(normalizedLeft, normalizedRight);\n        })\n        .with('||', () => {\n          const normalizedLeft = this.normalizeBooleanExpression(left, ctx.expr(0));\n          const normalizedRight = this.normalizeBooleanExpression(right, ctx.expr(1));\n          return this.formulaQuery.logicalOr(normalizedLeft, normalizedRight);\n        })\n        .with('&', () => {\n          // Always treat & as string concatenation to avoid type issues\n          const leftType = this.inferExpressionType(ctx.expr(0));\n          const rightType = this.inferExpressionType(ctx.expr(1));\n          const paramMetadata = [\n            this.buildParamMetadata(ctx.expr(0)),\n            this.buildParamMetadata(ctx.expr(1)),\n          ];\n          this.formulaQuery.setCallMetadata(paramMetadata);\n          const coercedLeft = this.coerceToStringForConcatenation(left, ctx.expr(0), leftType);\n          const coercedRight = this.coerceToStringForConcatenation(right, ctx.expr(1), rightType);\n          return this.formulaQuery.stringConcat(coercedLeft, coercedRight);\n        })\n        .otherwise((op) => {\n          throw new Error(`Unsupported binary operator: ${op}`);\n        });\n    } finally {\n      this.formulaQuery.setCallMetadata(undefined);\n    }\n  }\n\n  private normalizeFunctionParamsForMultiplicity(\n    fnName: FunctionName,\n    params: string[],\n    exprContexts: ExprContext[]\n  ): string[] {\n    const funcMeta = FUNCTIONS[fnName];\n    if (!funcMeta) {\n      return params;\n    }\n\n    return params.map((paramSql, index) => {\n      if (funcMeta.acceptMultipleValue) {\n        return paramSql;\n      }\n\n      if (this.shouldPreserveMultiValueParam(fnName, exprContexts[index], index, paramSql)) {\n        return paramSql;\n      }\n\n      return this.reduceMultiFieldReferenceParam(exprContexts[index], paramSql);\n    });\n  }\n\n  private tryBuildMultiValueAggregator(\n    fnName: FunctionName,\n    params: string[],\n    exprContexts: ExprContext[]\n  ): string | null {\n    if (!exprContexts[0] || this.dialect?.driver !== DriverClient.Pg) {\n      return null;\n    }\n\n    const isMulti = this.isMultiValueExpr(exprContexts[0], params[0]);\n    if (!isMulti) {\n      return null;\n    }\n\n    switch (fnName) {\n      case FunctionName.DatetimeFormat: {\n        const formatExpr = params[1] ?? `'YYYY-MM-DD HH:mm'`;\n        return this.buildPgDatetimeFormatAggregator(params[0], formatExpr);\n      }\n      case FunctionName.Value:\n        return this.buildPgNumericAggregator(params[0], (scalarText) =>\n          this.formulaQuery.value(scalarText)\n        );\n      case FunctionName.Abs:\n        return this.buildPgNumericAggregator(params[0], (scalarText) =>\n          this.formulaQuery.abs(this.formulaQuery.value(scalarText))\n        );\n      case FunctionName.Datestr:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.datestr(scalar)\n        );\n      case FunctionName.Timestr:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.timestr(scalar)\n        );\n      case FunctionName.Day:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.day(scalar)\n        );\n      case FunctionName.Month:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.month(scalar)\n        );\n      case FunctionName.Year:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.year(scalar)\n        );\n      case FunctionName.Weekday:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.weekday(scalar, params[1])\n        );\n      case FunctionName.WeekNum:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.weekNum(scalar)\n        );\n      case FunctionName.Hour:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.hour(scalar)\n        );\n      case FunctionName.Minute:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.minute(scalar)\n        );\n      case FunctionName.Second:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.second(scalar)\n        );\n      case FunctionName.FromNow:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.fromNow(scalar, params[1])\n        );\n      case FunctionName.ToNow:\n        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.toNow(scalar, params[1])\n        );\n      case FunctionName.Round:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.round(scalar, params[1] ?? '0')\n        );\n      case FunctionName.RoundUp:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.roundUp(scalar, params[1] ?? '0')\n        );\n      case FunctionName.RoundDown:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.roundDown(scalar, params[1] ?? '0')\n        );\n      case FunctionName.Floor:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.floor(scalar)\n        );\n      case FunctionName.Ceiling:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.ceiling(scalar)\n        );\n      case FunctionName.Int:\n        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>\n          this.formulaQuery.int(scalar)\n        );\n      default:\n        return null;\n    }\n  }\n\n  private shouldPreserveMultiValueParam(\n    fnName: FunctionName,\n    exprCtx: ExprContext,\n    index: number,\n    paramSql: string\n  ): boolean {\n    if (MULTI_VALUE_AGGREGATED_FUNCTIONS.has(fnName) && index === 0) {\n      return true;\n    }\n\n    return this.isMultiValueExpr(exprCtx, paramSql);\n  }\n\n  private reduceMultiFieldReferenceParam(exprCtx: ExprContext, paramSql: string): string {\n    if (!this.isMultiValueExpr(exprCtx, paramSql)) {\n      return paramSql;\n    }\n\n    const fieldInfo = this.getFieldInfoFromExpr(exprCtx);\n    if (fieldInfo) {\n      return this.extractSingleValueFromMultiReference(paramSql, fieldInfo);\n    }\n    return paramSql;\n  }\n\n  private getFieldInfoFromExpr(exprCtx: ExprContext): FieldCore | undefined {\n    if (!exprCtx) {\n      return undefined;\n    }\n\n    if (exprCtx instanceof BracketsContext) {\n      return this.getFieldInfoFromExpr(exprCtx.expr());\n    }\n\n    if (\n      exprCtx instanceof LeftWhitespaceOrCommentsContext ||\n      exprCtx instanceof RightWhitespaceOrCommentsContext\n    ) {\n      return this.getFieldInfoFromExpr(exprCtx.expr());\n    }\n\n    if (exprCtx instanceof FieldReferenceCurlyContext) {\n      const normalizedFieldId = extractFieldReferenceId(exprCtx);\n      const rawToken = getFieldReferenceTokenText(exprCtx);\n      const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n      if (!fieldId) {\n        return undefined;\n      }\n      return this.context.table.getField(fieldId);\n    }\n\n    return undefined;\n  }\n\n  private isMultiValueField(fieldInfo?: FieldCore): boolean {\n    if (!fieldInfo) {\n      return false;\n    }\n\n    const fieldType = fieldInfo.type as FieldType;\n    const lookupHolder = fieldInfo as unknown as {\n      isLookup?: boolean;\n      dbFieldName?: string;\n      lookupOptions?: { linkFieldId?: string };\n      isMultipleCellValue?: boolean;\n    };\n\n    // Link fields: only treat as multi-value when the relationship is multi or explicitly flagged.\n    if (fieldType === FieldType.Link) {\n      return this.isLinkFieldMulti(fieldInfo);\n    }\n\n    const isLookupField =\n      lookupHolder.isLookup === true ||\n      lookupHolder.dbFieldName?.startsWith('lookup_') ||\n      lookupHolder.dbFieldName?.startsWith('conditional_lookup_');\n\n    // Lookup of link: mirror the link field multiplicity instead of assuming array values.\n    if (isLookupField && lookupHolder.lookupOptions?.linkFieldId) {\n      const linkField = this.context.table.getField(lookupHolder.lookupOptions.linkFieldId);\n      if (this.isLinkFieldMulti(linkField as FieldCore | undefined)) {\n        return true;\n      }\n    }\n\n    if (lookupHolder.isMultipleCellValue) {\n      return true;\n    }\n\n    // For lookup fields that are not multi-value (e.g., many-one link lookup), stop here to avoid\n    // treating scalar JSON objects as arrays.\n    if (isLookupField) {\n      return false;\n    }\n\n    if (MULTI_VALUE_FIELD_TYPES.has(fieldType)) {\n      return true;\n    }\n\n    return false;\n  }\n\n  private isLinkFieldMulti(linkField?: FieldCore): boolean {\n    if (!linkField) {\n      return false;\n    }\n    if ((linkField as unknown as { isMultipleCellValue?: boolean })?.isMultipleCellValue) {\n      return true;\n    }\n    const relationship = (\n      linkField as unknown as {\n        options?: { relationship?: Relationship };\n      }\n    ).options?.relationship;\n    if (!relationship) {\n      return false;\n    }\n    return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;\n  }\n\n  private isMultiValueExpr(exprCtx: ExprContext, paramSql?: string): boolean {\n    if (exprCtx instanceof BracketsContext) {\n      return this.isMultiValueExpr(exprCtx.expr(), paramSql);\n    }\n\n    if (\n      exprCtx instanceof LeftWhitespaceOrCommentsContext ||\n      exprCtx instanceof RightWhitespaceOrCommentsContext\n    ) {\n      return this.isMultiValueExpr(exprCtx.expr(), paramSql);\n    }\n\n    const fieldInfo = this.getFieldInfoFromExpr(exprCtx);\n    if (fieldInfo) {\n      // When we have metadata for the referenced field, trust it instead of falling back to\n      // string-based heuristics (which misclassify scalar lookups/rollups whose dbFieldName\n      // happens to contain \"lookup_\").\n      return this.isMultiValueField(fieldInfo);\n    }\n\n    if (exprCtx instanceof FunctionCallContext) {\n      const rawName = exprCtx.func_name().text.toUpperCase();\n      const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n      if (\n        fnName === FunctionName.ArrayUnique ||\n        fnName === FunctionName.ArrayFlatten ||\n        fnName === FunctionName.ArrayCompact\n      ) {\n        return true;\n      }\n    }\n\n    // Only attempt SQL-based heuristics for unresolved direct field references.\n    // For composite expressions (binary ops, comparisons, nested functions), the presence of\n    // \"link_value\"/\"lookup_\" fragments does not imply the *result* is multi-value.\n    if (exprCtx instanceof FieldReferenceCurlyContext && paramSql) {\n      const lookupMatch = paramSql.match(/lookup_(fld[A-Za-z0-9]+)/);\n      if (lookupMatch && this.context?.table) {\n        const referencedField = this.context.table.getField(lookupMatch[1]);\n        if (referencedField) {\n          return this.isMultiValueField(referencedField as FieldCore);\n        }\n      }\n    }\n\n    return false;\n  }\n\n  private extractSingleValueFromMultiReference(expr: string, fieldInfo: FieldCore): string {\n    if (!this.dialect) {\n      return expr;\n    }\n\n    switch (this.dialect.driver) {\n      case DriverClient.Pg:\n        return this.buildPgSingleValueExtractor(expr, fieldInfo);\n      case DriverClient.Sqlite:\n        return this.buildSqliteSingleValueExtractor(expr);\n      default:\n        return expr;\n    }\n  }\n\n  private buildSqliteSingleValueExtractor(expr: string): string {\n    // SQLite formulas already treat multi-value columns as JSON text during coercion.\n    // Returning the original expression keeps existing behaviour consistent.\n    return expr;\n  }\n\n  private buildPgSingleValueExtractor(expr: string, _fieldInfo: FieldCore): string {\n    const fieldInfo = _fieldInfo;\n    const normalizedJson = this.normalizeMultiValueExprToJson(expr);\n\n    const firstElement = `(SELECT elem\n      FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)\n      WHERE jsonb_typeof(elem) <> 'null'\n      ORDER BY ord\n      LIMIT 1\n    )`;\n\n    const scalarJson = `(CASE\n      WHEN ${normalizedJson} IS NULL THEN NULL::jsonb\n      WHEN jsonb_typeof(${normalizedJson}) = 'array' THEN ${firstElement}\n      ELSE ${normalizedJson}\n    END)`;\n\n    return `(CASE\n      WHEN ${scalarJson} IS NULL THEN NULL\n      WHEN jsonb_typeof(${scalarJson}) = 'object' THEN COALESCE(\n        ${scalarJson}->>'title',\n        ${scalarJson}->>'name',\n        (${scalarJson})::text\n      )\n      WHEN jsonb_typeof(${scalarJson}) = 'array' THEN NULL\n      ELSE ${this.formatScalarDatetimeIfNeeded(`${scalarJson} #>> '{}'`, fieldInfo)}\n    END)`;\n  }\n\n  private formatScalarDatetimeIfNeeded(scalar: string, fieldInfo: FieldCore): string {\n    if (this.context?.isGeneratedColumn) {\n      return scalar;\n    }\n    const isDatetimeCell =\n      (fieldInfo as unknown as { cellValueType?: CellValueType })?.cellValueType ===\n        CellValueType.DateTime || fieldInfo.dbFieldType === DbFieldType.DateTime;\n\n    if (!isDatetimeCell || !this.dialect || typeof this.dialect.formatDate !== 'function') {\n      return scalar;\n    }\n\n    const formatting = this.getFieldDatetimeFormatting(fieldInfo);\n    const fallBackFormatting: IDatetimeFormatting = {\n      date: DateFormattingPreset.ISO,\n      time: TimeFormatting.Hour24,\n      timeZone: this.context?.timeZone ?? 'UTC',\n    };\n\n    return this.dialect.formatDate(scalar, formatting ?? fallBackFormatting);\n  }\n\n  private normalizeMultiValueExprToJson(expr: string): string {\n    const baseExpr = `(${expr})`;\n    const coercedJson = `(CASE\n      WHEN ${baseExpr} IS NULL THEN NULL::jsonb\n      WHEN pg_typeof(${baseExpr}) = 'jsonb'::regtype THEN (${baseExpr})::text::jsonb\n      WHEN pg_typeof(${baseExpr}) = 'json'::regtype THEN (${baseExpr})::text::jsonb\n      WHEN pg_typeof(${baseExpr}) IN ('text', 'varchar', 'bpchar', 'character varying', 'unknown') THEN\n        CASE\n          WHEN NULLIF(BTRIM((${baseExpr})::text), '') IS NULL THEN NULL::jsonb\n          WHEN LEFT(BTRIM((${baseExpr})::text), 1) = '[' THEN (${baseExpr})::text::jsonb\n          ELSE jsonb_build_array(to_jsonb(${baseExpr}))\n        END\n      ELSE to_jsonb(${baseExpr})\n    END)`;\n    return `(CASE\n      WHEN ${coercedJson} IS NULL THEN NULL::jsonb\n      WHEN jsonb_typeof(${coercedJson}) = 'array' THEN ${coercedJson}\n      ELSE jsonb_build_array(${coercedJson})\n    END)`;\n  }\n\n  private extractJsonScalarText(elemRef: string): string {\n    return `(CASE\n      WHEN jsonb_typeof(${elemRef}) = 'object' THEN COALESCE(${elemRef}->>'title', ${elemRef}->>'name', ${elemRef} #>> '{}')\n      WHEN jsonb_typeof(${elemRef}) = 'array' THEN NULL\n      ELSE ${elemRef} #>> '{}'\n    END)`;\n  }\n\n  private buildPgNumericAggregator(\n    valueExpr: string,\n    buildNumericExpr: (scalarTextExpr: string) => string\n  ): string {\n    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);\n    const scalarText = this.extractJsonScalarText('elem');\n    const numericExpr = buildNumericExpr(scalarText);\n    const formattedExpr = `(CASE WHEN ${numericExpr} IS NULL THEN NULL ELSE ${numericExpr} END)`;\n    const aggregated = this.dialect!.stringAggregate(formattedExpr, ', ', 'ord');\n    return `(CASE\n      WHEN ${normalizedJson} IS NULL THEN NULL\n      ELSE (\n        SELECT ${aggregated}\n        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)\n      )\n    END)`;\n  }\n\n  private buildPgDatetimeFormatAggregator(valueExpr: string, formatExpr: string): string {\n    return this.buildPgDatetimeScalarAggregator(valueExpr, (scalar) =>\n      this.formulaQuery.datetimeFormat(scalar, formatExpr)\n    );\n  }\n\n  private buildPgNumericScalarAggregator(\n    valueExpr: string,\n    buildScalarExpr: (numericScalar: string) => string\n  ): string {\n    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);\n    const elementScalar = this.extractJsonScalarText('elem');\n    const sanitizedScalar = `NULLIF(${elementScalar}, '')`;\n    const numericScalar = this.formulaQuery.value(sanitizedScalar);\n    const computedExpr = buildScalarExpr(numericScalar);\n    const safeExpr = `(CASE WHEN ${numericScalar} IS NULL THEN NULL ELSE (${computedExpr})::text END)`;\n    const aggregated = this.dialect!.stringAggregate(safeExpr, ', ', 'ord');\n    return `(CASE\n      WHEN ${normalizedJson} IS NULL THEN NULL\n      ELSE (\n        SELECT ${aggregated}\n        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)\n      )\n    END)`;\n  }\n\n  private buildPgDatetimeScalarAggregator(\n    valueExpr: string,\n    buildScalarExpr: (sanitizedScalar: string) => string\n  ): string {\n    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);\n    const elementScalar = this.extractJsonScalarText('elem');\n    const sanitizedScalar = `NULLIF(${elementScalar}, '')`;\n    const computedExpr = buildScalarExpr(sanitizedScalar);\n    const safeExpr = `(CASE WHEN ${sanitizedScalar} IS NULL THEN NULL ELSE (${computedExpr})::text END)`;\n    const aggregated = this.dialect!.stringAggregate(safeExpr, ', ', 'ord');\n    return `(CASE\n      WHEN ${normalizedJson} IS NULL THEN NULL\n      ELSE (\n        SELECT ${aggregated}\n        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)\n      )\n    END)`;\n  }\n\n  /**\n   * Safely cast an expression to numeric for comparisons.\n   * For PostgreSQL, avoid runtime errors by returning NULL for non-numeric text.\n   * For other drivers, fall back to a direct numeric cast.\n   */\n  private safeCastToNumeric(value: string): string {\n    return this.dialect!.coerceToNumericForCompare(value);\n  }\n\n  /**\n   * Normalize a boolean expression into a numeric scalar (1/0) for cross-type comparisons.\n   * Preserves NULL so equality checks against NULL behave as expected.\n   */\n  private coerceBooleanToNumeric(value: string, exprCtx?: ExprContext): string {\n    const normalized =\n      exprCtx && exprCtx instanceof FieldReferenceCurlyContext\n        ? this.normalizeBooleanFieldReference(value, exprCtx) ?? value\n        : value;\n    const boolExpr = `(${normalized})`;\n    return `(CASE WHEN ${boolExpr} IS NULL THEN NULL WHEN ${boolExpr} THEN 1 ELSE 0 END)::numeric`;\n  }\n\n  /**\n   * Coerce values participating in string concatenation to textual representation when needed.\n   * Datetime operands are cast to string to mirror client-side behaviour and to avoid relying\n   * on database-specific implicit casts that may be non-immutable for generated columns.\n   */\n  private coerceToStringForConcatenation(\n    value: string,\n    exprCtx: ExprContext,\n    inferredType?: 'string' | 'number' | 'boolean' | 'datetime' | 'unknown'\n  ): string {\n    let fieldInfo: FieldCore | undefined;\n    let normalizedValue = value;\n    let coercedMultiToString = false;\n    if (exprCtx instanceof FieldReferenceCurlyContext) {\n      const normalizedFieldId = extractFieldReferenceId(exprCtx);\n      const rawToken = getFieldReferenceTokenText(exprCtx);\n      const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n      fieldInfo = this.context.table.getField(fieldId);\n      const isMultiField = this.isMultiValueField(fieldInfo as FieldCore);\n      const cellValueType = (fieldInfo as unknown as { cellValueType?: CellValueType })\n        ?.cellValueType;\n      const hasDatetimeSemantics =\n        (fieldInfo && DATETIME_FIELD_TYPES.has(fieldInfo.type as FieldType)) ||\n        cellValueType === CellValueType.DateTime ||\n        fieldInfo?.dbFieldType === DbFieldType.DateTime;\n      if (\n        fieldInfo &&\n        (fieldInfo as unknown as { cellValueType?: CellValueType })?.cellValueType ===\n          CellValueType.DateTime\n      ) {\n        // Keep a note that this value carries datetime semantics even when inferred as string\n        inferredType = inferredType === undefined ? 'datetime' : inferredType;\n      }\n      if (isMultiField && this.dialect) {\n        // Normalize multi-value references (lookup, link, multi-select, etc.) into a deterministic\n        // comma-separated string so downstream text operations behave as expected.\n        if (\n          fieldInfo &&\n          hasDatetimeSemantics &&\n          typeof this.dialect.formatDateArray === 'function'\n        ) {\n          const formatting =\n            this.getFieldDatetimeFormatting(fieldInfo) ??\n            ({\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.Hour24,\n              timeZone: this.context?.timeZone ?? 'UTC',\n            } as IDatetimeFormatting);\n          normalizedValue = this.dialect.formatDateArray(value, formatting);\n        } else {\n          normalizedValue = this.dialect.formatStringArray(value, { fieldInfo });\n        }\n        coercedMultiToString = true;\n      }\n    }\n    const type = coercedMultiToString\n      ? 'string'\n      : inferredType ?? this.inferExpressionType(exprCtx);\n    if (type === 'datetime') {\n      const fallBackFormatting: IDatetimeFormatting = {\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.Hour24,\n        timeZone: this.context?.timeZone ?? 'UTC',\n      };\n      const formatting = fieldInfo ? this.getFieldDatetimeFormatting(fieldInfo) : undefined;\n      if (this.dialect?.formatDate) {\n        return this.dialect.formatDate(normalizedValue, formatting ?? fallBackFormatting);\n      }\n      return this.formulaQuery.datetimeFormat(normalizedValue, \"'YYYY-MM-DD HH24:MI'\");\n    }\n    return normalizedValue;\n  }\n\n  private getFieldDatetimeFormatting(fieldInfo: FieldCore): IDatetimeFormatting | undefined {\n    const rawOptions = (fieldInfo as unknown as { options?: unknown })?.options;\n    const formatting =\n      rawOptions && typeof rawOptions === 'object'\n        ? (rawOptions as { formatting?: IDatetimeFormatting }).formatting\n        : typeof rawOptions === 'string'\n          ? (() => {\n              try {\n                return (JSON.parse(rawOptions) as { formatting?: IDatetimeFormatting } | undefined)\n                  ?.formatting;\n              } catch {\n                return undefined;\n              }\n            })()\n          : undefined;\n    if (formatting) return formatting;\n\n    const getter = (\n      fieldInfo as unknown as {\n        getDatetimeFormatting?: () => IDatetimeFormatting | undefined;\n      }\n    )?.getDatetimeFormatting;\n    if (typeof getter === 'function') {\n      return getter.call(fieldInfo);\n    }\n\n    return undefined;\n  }\n\n  private shouldForceNumericAddition(): boolean {\n    const selectContext = this.context as ISelectFormulaConversionContext | undefined;\n    const targetType = selectContext?.targetDbFieldType;\n    return targetType === DbFieldType.Integer || targetType === DbFieldType.Real;\n  }\n\n  private coerceCaseBranchToText(expr: string): string {\n    const trimmed = expr.trim();\n    const driver = this.context.driverClient ?? DriverClient.Pg;\n\n    // eslint-disable-next-line regexp/prefer-w\n    const nullPattern = /^NULL(?:::[a-zA-Z_][a-zA-Z0-9_\\s]*)?$/i;\n    if (!trimmed || nullPattern.test(trimmed)) {\n      return driver === DriverClient.Sqlite ? 'CAST(NULL AS TEXT)' : 'NULL::text';\n    }\n\n    const isStringLiteral = trimmed.length >= 2 && trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\");\n    if (isStringLiteral) {\n      return expr;\n    }\n\n    if (driver === DriverClient.Sqlite) {\n      const upper = trimmed.toUpperCase();\n      if (upper.startsWith('CAST(') && upper.endsWith('AS TEXT)')) {\n        return expr;\n      }\n      return `CAST(${expr} AS TEXT)`;\n    }\n\n    if (/::\\s*text\\b/i.test(trimmed) || /\\)::\\s*text\\b/i.test(trimmed)) {\n      return expr;\n    }\n\n    return `(${expr})::text`;\n  }\n\n  private normalizeTextSliceCount(valueSql?: string, exprCtx?: ExprContext): string {\n    if (!valueSql || !exprCtx) {\n      return '1';\n    }\n\n    const trimmedLiteral = valueSql.trim();\n    if (/^[-+]?\\d+(\\.\\d+)?$/.test(trimmedLiteral)) {\n      const literalNumber = Math.floor(Number(trimmedLiteral));\n      const clamped = Number.isFinite(literalNumber) ? Math.max(literalNumber, 0) : 0;\n      return clamped.toString();\n    }\n\n    const type = this.inferExpressionType(exprCtx);\n    const driver = this.context.driverClient ?? DriverClient.Pg;\n\n    if (type === 'boolean') {\n      if (driver === DriverClient.Sqlite) {\n        return `(CASE WHEN ${valueSql} IS NULL THEN 0 WHEN ${valueSql} <> 0 THEN 1 ELSE 0 END)`;\n      }\n      return `(CASE WHEN ${valueSql} IS NULL THEN 0 WHEN ${valueSql} THEN 1 ELSE 0 END)`;\n    }\n\n    const numericExpr = this.safeCastToNumeric(valueSql);\n    if (driver === DriverClient.Sqlite) {\n      const flooredExpr = `CAST(${numericExpr} AS INTEGER)`;\n      return `COALESCE(CASE WHEN ${flooredExpr} < 0 THEN 0 ELSE ${flooredExpr} END, 0)`;\n    }\n    const flooredExpr = `FLOOR(${numericExpr})`;\n    return `COALESCE(GREATEST(${flooredExpr}, 0), 0)`;\n  }\n  private normalizeBooleanExpression(valueSql: string, exprCtx: ExprContext): string {\n    const type = this.inferExpressionType(exprCtx);\n    const driver = this.context.driverClient ?? DriverClient.Pg;\n\n    switch (type) {\n      case 'boolean':\n        if (driver === DriverClient.Sqlite) {\n          return `(COALESCE((${valueSql}), 0) != 0)`;\n        }\n        return `(COALESCE((${this.normalizeBooleanFieldReference(valueSql, exprCtx) ?? valueSql})::boolean, FALSE))`;\n      case 'number': {\n        if (driver === DriverClient.Sqlite) {\n          const numericExpr = this.safeCastToNumeric(valueSql);\n          return `(COALESCE(${numericExpr}, 0) <> 0)`;\n        }\n        const sanitized = `REGEXP_REPLACE(((${valueSql})::text), '[^0-9.+-]', '', 'g')`;\n        const numericCandidate = `(CASE\n          WHEN ${sanitized} ~ '^[-+]{0,1}(\\\\d+\\\\.\\\\d+|\\\\d+|\\\\.\\\\d+)$' THEN ${sanitized}::double precision\n          ELSE NULL\n        END)`;\n        return `(COALESCE(${numericCandidate}, 0) <> 0)`;\n      }\n      case 'string': {\n        if (driver === DriverClient.Sqlite) {\n          const textExpr = `CAST(${valueSql} AS TEXT)`;\n          const trimmedExpr = `TRIM(${textExpr})`;\n          return `((${valueSql}) IS NOT NULL AND ${trimmedExpr} <> '' AND LOWER(${trimmedExpr}) <> 'null')`;\n        }\n        const textExpr = `(${valueSql})::text`;\n        const trimmedExpr = `TRIM(${textExpr})`;\n        return `((${valueSql}) IS NOT NULL AND ${trimmedExpr} <> '' AND LOWER(${trimmedExpr}) <> 'null')`;\n      }\n      case 'datetime':\n        return `((${valueSql}) IS NOT NULL)`;\n      default:\n        return `((${valueSql}) IS NOT NULL)`;\n    }\n  }\n\n  /**\n   * Coerce direct field references carrying boolean semantics into a proper boolean scalar.\n   * This keeps the SQL maintainable by leveraging schema metadata rather than runtime pg_typeof checks.\n   */\n  private normalizeBooleanFieldReference(valueSql: string, exprCtx: ExprContext): string | null {\n    if (!(exprCtx instanceof FieldReferenceCurlyContext)) {\n      return null;\n    }\n\n    const normalizedFieldId = extractFieldReferenceId(exprCtx);\n    const rawToken = getFieldReferenceTokenText(exprCtx);\n    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n    const fieldInfo = this.context.table?.getField(fieldId);\n    if (!fieldInfo) {\n      return null;\n    }\n\n    const isBooleanField =\n      fieldInfo.dbFieldType === DbFieldType.Boolean || fieldInfo.cellValueType === 'boolean';\n    if (!isBooleanField) {\n      return null;\n    }\n\n    return `((${valueSql}))::boolean`;\n  }\n\n  private isBlankLikeExpression(ctx: ExprContext): boolean {\n    if (ctx instanceof StringLiteralContext) {\n      const raw = ctx.text;\n      if (raw.startsWith(\"'\") && raw.endsWith(\"'\")) {\n        const unescaped = unescapeString(raw.slice(1, -1));\n        return unescaped === '';\n      }\n      return false;\n    }\n\n    if (ctx instanceof FunctionCallContext) {\n      const rawName = ctx.func_name().text.toUpperCase();\n      const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n      return fnName === FunctionName.Blank;\n    }\n\n    return false;\n  }\n  /**\n   * Infer the type of an expression for type-aware operations\n   */\n  private inferExpressionType(\n    ctx: ExprContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    // Handle literals\n    const literalType = this.inferLiteralType(ctx);\n    if (literalType !== 'unknown') {\n      return literalType;\n    }\n\n    // Handle field references\n    if (ctx instanceof FieldReferenceCurlyContext) {\n      return this.inferFieldReferenceType(ctx);\n    }\n\n    // Handle function calls\n    if (ctx instanceof FunctionCallContext) {\n      return this.inferFunctionReturnType(ctx);\n    }\n\n    // Handle binary operations\n    if (ctx instanceof BinaryOpContext) {\n      return this.inferBinaryOperationType(ctx);\n    }\n\n    // Handle parentheses - infer from inner expression\n    if (ctx instanceof BracketsContext) {\n      return this.inferExpressionType(ctx.expr());\n    }\n\n    // Handle whitespace/comments - infer from inner expression\n    if (\n      ctx instanceof LeftWhitespaceOrCommentsContext ||\n      ctx instanceof RightWhitespaceOrCommentsContext\n    ) {\n      return this.inferExpressionType(ctx.expr());\n    }\n\n    // Default to unknown for unhandled cases\n    return 'unknown';\n  }\n\n  /**\n   * Infer type from literal contexts\n   */\n  private inferLiteralType(\n    ctx: ExprContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    if (ctx instanceof StringLiteralContext) {\n      return 'string';\n    }\n\n    if (ctx instanceof IntegerLiteralContext || ctx instanceof DecimalLiteralContext) {\n      return 'number';\n    }\n\n    if (ctx instanceof BooleanLiteralContext) {\n      return 'boolean';\n    }\n\n    return 'unknown';\n  }\n\n  /**\n   * Infer type from field reference\n   */\n  private inferFieldReferenceType(\n    ctx: FieldReferenceCurlyContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    const { fieldInfo } = this.resolveFieldReference(ctx);\n\n    if (!fieldInfo) {\n      return 'unknown';\n    }\n\n    if (\n      fieldInfo.isMultipleCellValue ||\n      (fieldInfo.isLookup && fieldInfo.dbFieldType === DbFieldType.Json)\n    ) {\n      // Multi-value fields (e.g. lookups) are materialized as JSON arrays even when the\n      // referenced cellValueType is datetime. Treat them as strings to avoid pushing JSON\n      // expressions through datetime-specific casts like ::timestamptz, which PostgreSQL\n      // rejects at runtime.\n      return 'string';\n    }\n\n    if (!fieldInfo.type) {\n      return 'unknown';\n    }\n\n    return this.mapFieldTypeToBasicType(fieldInfo);\n  }\n\n  private resolveFieldReference(ctx: FieldReferenceCurlyContext): {\n    fieldId: string;\n    fieldInfo?: FieldCore;\n  } {\n    const normalizedFieldId = extractFieldReferenceId(ctx);\n    const rawToken = getFieldReferenceTokenText(ctx);\n    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n    const fieldInfo = this.context.table.getField(fieldId);\n    return { fieldId, fieldInfo };\n  }\n\n  private buildParamMetadata(exprCtx: ExprContext): IFormulaParamMetadata {\n    const type = this.inferExpressionType(exprCtx) as FormulaParamType;\n    const fieldRef = this.extractFieldReferenceMetadata(exprCtx);\n    if (fieldRef) {\n      const { fieldId, fieldInfo } = fieldRef;\n      const fieldMetadata: IFormulaParamFieldMetadata = {\n        id: fieldId,\n        type: fieldInfo?.type as FieldType | undefined,\n        cellValueType: fieldInfo?.cellValueType,\n        isMultiple: Boolean(fieldInfo?.isMultipleCellValue),\n        isLookup: Boolean(fieldInfo?.isLookup),\n        dbFieldName: fieldInfo?.dbFieldName,\n        dbFieldType: fieldInfo?.dbFieldType,\n      };\n      return {\n        type,\n        isFieldReference: true,\n        field: fieldMetadata,\n      };\n    }\n    return {\n      type,\n      isFieldReference: false,\n    };\n  }\n\n  private extractFieldReferenceMetadata(\n    exprCtx: ExprContext\n  ): { fieldId: string; fieldInfo?: FieldCore } | undefined {\n    if (exprCtx instanceof FieldReferenceCurlyContext) {\n      return this.resolveFieldReference(exprCtx);\n    }\n    if (exprCtx instanceof BracketsContext) {\n      return this.extractFieldReferenceMetadata(exprCtx.expr());\n    }\n    if (exprCtx instanceof LeftWhitespaceOrCommentsContext) {\n      return this.extractFieldReferenceMetadata(exprCtx.expr());\n    }\n    if (exprCtx instanceof RightWhitespaceOrCommentsContext) {\n      return this.extractFieldReferenceMetadata(exprCtx.expr());\n    }\n    return undefined;\n  }\n\n  /**\n   * Map field types to basic types\n   */\n  private mapFieldTypeToBasicType(\n    fieldInfo: FieldCore\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    const { type, cellValueType } = fieldInfo;\n    const typeEnum = type as FieldType;\n\n    if (STRING_FIELD_TYPES.has(typeEnum)) {\n      return 'string';\n    }\n\n    if (DATETIME_FIELD_TYPES.has(typeEnum)) {\n      return 'datetime';\n    }\n\n    if (NUMBER_FIELD_TYPES.has(typeEnum)) {\n      return 'number';\n    }\n\n    if (typeEnum === FieldType.Checkbox) {\n      return 'boolean';\n    }\n\n    if (\n      typeEnum === FieldType.Formula ||\n      typeEnum === FieldType.Rollup ||\n      typeEnum === FieldType.ConditionalRollup\n    ) {\n      if (cellValueType) {\n        return this.mapCellValueTypeToBasicType(cellValueType);\n      }\n      return 'unknown';\n    }\n\n    if (cellValueType) {\n      return this.mapCellValueTypeToBasicType(cellValueType);\n    }\n\n    return 'unknown';\n  }\n\n  /**\n   * Map cell value types to basic types\n   */\n  private mapCellValueTypeToBasicType(\n    cellValueType: string\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    switch (cellValueType) {\n      case 'string':\n        return 'string';\n      case 'number':\n        return 'number';\n      case 'boolean':\n        return 'boolean';\n      case 'datetime':\n      case 'dateTime':\n        return 'datetime';\n      default:\n        return 'unknown';\n    }\n  }\n\n  /**\n   * Infer return type from function calls\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private inferFunctionReturnType(\n    ctx: FunctionCallContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    const rawName = ctx.func_name().text.toUpperCase();\n    const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n\n    if (STRING_FUNCTIONS.has(fnName)) {\n      return 'string';\n    }\n\n    if (NUMBER_FUNCTIONS.has(fnName)) {\n      return 'number';\n    }\n\n    if (BOOLEAN_FUNCTIONS.has(fnName)) {\n      return 'boolean';\n    }\n\n    if (fnName === FunctionName.If) {\n      const [, trueExpr, falseExpr] = ctx.expr();\n      const trueType = trueExpr ? this.inferExpressionType(trueExpr) : 'unknown';\n      const falseType = falseExpr ? this.inferExpressionType(falseExpr) : 'unknown';\n\n      if (!falseExpr) {\n        return trueType;\n      }\n\n      if (!trueExpr) {\n        return falseType;\n      }\n\n      if (trueType === falseType) {\n        return trueType;\n      }\n\n      if (trueType === 'number' || falseType === 'number') {\n        const trueIsBlank = this.isBlankLikeExpression(trueExpr);\n        const falseIsBlank = this.isBlankLikeExpression(falseExpr);\n        if (trueType === 'number' && (falseIsBlank || falseType === 'number')) {\n          return 'number';\n        }\n        if (falseType === 'number' && (trueIsBlank || trueType === 'number')) {\n          return 'number';\n        }\n      }\n\n      if (trueType === 'datetime' && falseType === 'datetime') {\n        return 'datetime';\n      }\n\n      return 'unknown';\n    }\n\n    if (fnName === FunctionName.Switch) {\n      const exprContexts = ctx.expr();\n      const resultExprs: ExprContext[] = [];\n\n      for (let i = 2; i < exprContexts.length; i += 2) {\n        resultExprs.push(exprContexts[i]);\n      }\n\n      if (exprContexts.length % 2 === 0 && exprContexts.length > 1) {\n        resultExprs.push(exprContexts[exprContexts.length - 1]);\n      }\n\n      if (resultExprs.length === 0) {\n        return 'unknown';\n      }\n\n      const resultTypes = resultExprs.map((expr) => this.inferExpressionType(expr));\n      const nonUnknownTypes = resultTypes.filter((type) => type !== 'unknown');\n\n      if (nonUnknownTypes.length === 0) {\n        return 'unknown';\n      }\n\n      const firstType = nonUnknownTypes[0];\n      if (nonUnknownTypes.every((type) => type === firstType)) {\n        return firstType;\n      }\n\n      const hasNumber = nonUnknownTypes.includes('number');\n      const hasDatetime = nonUnknownTypes.includes('datetime');\n      const hasBoolean = nonUnknownTypes.includes('boolean');\n\n      if (hasNumber) {\n        const convertibleToNumber = resultExprs.every((expr, index) => {\n          const type = resultTypes[index];\n          return type === 'number' || this.isBlankLikeExpression(expr);\n        });\n        if (convertibleToNumber) {\n          return 'number';\n        }\n      }\n\n      if (hasDatetime) {\n        const convertibleToDatetime = resultExprs.every((expr, index) => {\n          const type = resultTypes[index];\n          return type === 'datetime' || this.isBlankLikeExpression(expr);\n        });\n        if (convertibleToDatetime) {\n          return 'datetime';\n        }\n      }\n\n      if (hasBoolean) {\n        const convertibleToBoolean = resultExprs.every((expr, index) => {\n          const type = resultTypes[index];\n          return type === 'boolean' || this.isBlankLikeExpression(expr);\n        });\n        if (convertibleToBoolean) {\n          return 'boolean';\n        }\n      }\n\n      return 'unknown';\n    }\n\n    // Basic detection for functions that yield datetime\n    if (\n      [\n        FunctionName.CreatedTime,\n        FunctionName.LastModifiedTime,\n        FunctionName.Today,\n        FunctionName.Now,\n        FunctionName.DateAdd,\n        FunctionName.DatetimeParse,\n      ].includes(fnName)\n    ) {\n      return 'datetime';\n    }\n\n    return 'unknown';\n  }\n\n  /**\n   * Infer type from binary operations\n   */\n  private inferBinaryOperationType(\n    ctx: BinaryOpContext\n  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {\n    const operator = ctx._op?.text;\n\n    if (!operator) {\n      return 'unknown';\n    }\n\n    const arithmeticOperators = ['-', '*', '/', '%'];\n    const comparisonOperators = ['>', '<', '>=', '<=', '=', '!=', '<>', '&&', '||'];\n    const stringOperators = ['&']; // Bitwise AND is treated as string concatenation\n\n    // Special handling for + operator - it can be either arithmetic or string concatenation\n    if (operator === '+') {\n      const leftType = this.inferExpressionType(ctx.expr(0));\n      const rightType = this.inferExpressionType(ctx.expr(1));\n\n      if (leftType === 'string' || rightType === 'string') {\n        return 'string';\n      }\n\n      if (leftType === 'datetime' || rightType === 'datetime') {\n        return 'string';\n      }\n\n      return 'number';\n    }\n\n    if (arithmeticOperators.includes(operator)) {\n      return 'number';\n    }\n\n    if (comparisonOperators.includes(operator)) {\n      return 'boolean';\n    }\n\n    if (stringOperators.includes(operator)) {\n      return 'string';\n    }\n\n    return 'unknown';\n  }\n}\n\n/**\n * Visitor that converts Teable formula AST to SQL expressions for generated columns\n * Uses dependency injection to get database-specific SQL implementations\n * Tracks field dependencies for generated column updates\n */\nexport class GeneratedColumnSqlConversionVisitor extends BaseSqlConversionVisitor<IGeneratedColumnQueryInterface> {\n  private dependencies: string[] = [];\n\n  /**\n   * Get the conversion result with SQL and dependencies\n   */\n  getResult(sql: string): IFormulaConversionResult {\n    return {\n      sql,\n      dependencies: Array.from(new Set(this.dependencies)),\n    };\n  }\n\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {\n    const normalizedFieldId = extractFieldReferenceId(ctx);\n    const rawToken = getFieldReferenceTokenText(ctx);\n    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1)?.trim() ?? '';\n    this.dependencies.push(fieldId);\n    return super.visitFieldReferenceCurly(ctx);\n  }\n}\n\n/**\n * Visitor that converts Teable formula AST to SQL expressions for select queries\n * Uses dependency injection to get database-specific SQL implementations\n * Does not track dependencies as it's used for runtime queries\n */\nexport class SelectColumnSqlConversionVisitor extends BaseSqlConversionVisitor<ISelectQueryInterface> {\n  /**\n   * Override field reference handling to support CTE-based field references\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {\n    const normalizedFieldId = extractFieldReferenceId(ctx);\n    const rawToken = getFieldReferenceTokenText(ctx);\n    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';\n\n    const fieldInfo = this.context.table.getField(fieldId);\n    if (!fieldInfo) {\n      // Fallback: referenced field not found in current table domain.\n      // Return NULL and emit a warning for visibility without breaking the query.\n      try {\n        const t = this.context.table;\n        // eslint-disable-next-line no-console\n        console.warn(\n          `Select formula fallback: missing field {${fieldId}} in table ${t?.name || ''}(${t?.id || ''}); selecting NULL`\n        );\n      } catch {\n        // ignore logging failures\n      }\n      return 'NULL';\n    }\n\n    // Check if this field has a CTE mapping (for link, lookup, rollup fields)\n    const selectContext = this.context as ISelectFormulaConversionContext;\n    const preferRaw = !!selectContext.preferRawFieldReferences;\n    const selectionMap = selectContext.selectionMap;\n    const selection = selectionMap?.get(fieldId);\n    let selectionSql = typeof selection === 'string' ? selection : selection?.toSQL().sql;\n    const cteMap = selectContext.fieldCteMap;\n    const readyLinkFieldIds =\n      selectContext.readyLinkFieldIds &&\n      typeof (selectContext.readyLinkFieldIds as { has?: unknown }).has === 'function'\n        ? (selectContext.readyLinkFieldIds as ReadonlySet<string>)\n        : undefined;\n    const isSelfReference = selectContext.currentLinkFieldId === fieldId;\n    // For link fields with CTE mapping, use the CTE directly\n    // No need for complex cross-CTE reference handling in most cases\n\n    // Handle different field types that use CTEs\n    if (isLinkField(fieldInfo)) {\n      // Prefer direct column when raw references are requested; otherwise fallback to CTE mapping.\n      // However, when the field is not already part of the current selection (common when resolving\n      // display fields for nested link CTEs), we still need to reference the CTE to access the link\n      // value even in raw contexts; otherwise formulas that reference link fields end up reading\n      // NULL placeholders instead of the computed JSON payload.\n      const cteName = cteMap?.get(fieldId);\n      const isReady = !readyLinkFieldIds || readyLinkFieldIds.has(fieldId);\n      const canReferenceCte = !preferRaw && !isSelfReference && !!cteName && isReady;\n      if (canReferenceCte) {\n        selectionSql = `\"${cteName}\".\"link_value\"`;\n      } else if (!preferRaw && !isSelfReference && cteName && selectContext.tableAlias && isReady) {\n        const tableAlias = selectContext.tableAlias;\n        // Use a scalar subquery when the CTE isn't joined in scope but is available in WITH.\n        selectionSql = `(SELECT \"${cteName}\".\"link_value\" FROM \"${cteName}\" WHERE \"${cteName}\".\"main_record_id\" = \"${tableAlias}\".\"__id\")`;\n      }\n      // Provide a safe fallback if selection map has no entry\n      if (!selectionSql) {\n        if (selectContext.tableAlias) {\n          selectionSql = `\"${selectContext.tableAlias}\".\"${fieldInfo.dbFieldName}\"`;\n        } else {\n          selectionSql = `\"${fieldInfo.dbFieldName}\"`;\n        }\n      }\n      // Check if this link field is being used in a boolean context\n      const isBooleanContext = this.isInBooleanContext(ctx);\n\n      // Use database driver from context\n      if (isBooleanContext) {\n        return this.dialect!.linkHasAny(selectionSql);\n      }\n      // For non-boolean context, extract title values as JSON array or single title\n      return this.dialect!.linkExtractTitles(selectionSql, !!fieldInfo.isMultipleCellValue);\n    }\n\n    if (\n      preferRaw &&\n      (fieldInfo.isLookup ||\n        fieldInfo.type === FieldType.Rollup ||\n        fieldInfo.type === FieldType.ConditionalRollup)\n    ) {\n      const tableAlias = selectContext.tableAlias;\n      const directRef = tableAlias\n        ? `\"${tableAlias}\".\"${fieldInfo.dbFieldName}\"`\n        : `\"${fieldInfo.dbFieldName}\"`;\n      if (fieldInfo.isLookup) {\n        const normalized = this.normalizeLookupSelection(directRef, fieldInfo, selectContext);\n        if (normalized !== directRef) {\n          return normalized;\n        }\n      }\n      return this.coerceRawMultiValueReference(directRef, fieldInfo, selectContext);\n    }\n\n    if (preferRaw && shouldExpandFieldReference(fieldInfo)) {\n      const tableAlias = selectContext.tableAlias;\n      const directRef = tableAlias\n        ? `\"${tableAlias}\".\"${fieldInfo.dbFieldName}\"`\n        : `\"${fieldInfo.dbFieldName}\"`;\n      return this.coerceRawMultiValueReference(directRef, fieldInfo, selectContext);\n    }\n\n    // Check if this is a formula field that needs recursive expansion\n    if (shouldExpandFieldReference(fieldInfo)) {\n      return this.expandFormulaField(fieldId, fieldInfo);\n    }\n\n    // If this is a lookup or rollup and CTE map is available, use it\n    const linkLookupOptions =\n      fieldInfo.lookupOptions && isLinkLookupOptions(fieldInfo.lookupOptions)\n        ? fieldInfo.lookupOptions\n        : undefined;\n    const linkLookupLinkId = linkLookupOptions?.linkFieldId;\n    const canReferenceLookupCte =\n      !preferRaw &&\n      !!cteMap &&\n      !!linkLookupLinkId &&\n      cteMap.has(linkLookupLinkId) &&\n      (!readyLinkFieldIds || readyLinkFieldIds.has(linkLookupLinkId)) &&\n      selectContext.currentLinkFieldId !== linkLookupLinkId;\n    if (canReferenceLookupCte) {\n      const cteName = cteMap!.get(linkLookupLinkId!)!;\n      const columnName = fieldInfo.isLookup\n        ? `lookup_${fieldInfo.id}`\n        : (fieldInfo as unknown as { type?: string }).type === 'rollup'\n          ? `rollup_${fieldInfo.id}`\n          : undefined;\n      if (columnName) {\n        let columnRef = `\"${cteName}\".\"${columnName}\"`;\n        if (preferRaw && fieldInfo.type !== FieldType.Link) {\n          const adjusted = this.coerceRawMultiValueReference(columnRef, fieldInfo, selectContext);\n          if (selectContext.targetDbFieldType === DbFieldType.Json) {\n            return adjusted;\n          }\n          columnRef = adjusted;\n        }\n        if (\n          fieldInfo.type === FieldType.Link &&\n          fieldInfo.isLookup &&\n          isLinkLookupOptions(fieldInfo.lookupOptions)\n        ) {\n          if (preferRaw && selectContext.targetDbFieldType === DbFieldType.Json) {\n            return columnRef;\n          }\n          if (fieldInfo.dbFieldType !== DbFieldType.Json) {\n            return columnRef;\n          }\n          const titlesExpr = this.dialect!.linkExtractTitles(\n            columnRef,\n            !!fieldInfo.isMultipleCellValue\n          );\n          if (fieldInfo.isMultipleCellValue) {\n            return this.dialect!.formatStringArray(titlesExpr, { fieldInfo });\n          }\n          return titlesExpr;\n        }\n        return columnRef;\n      }\n    }\n\n    // Handle user-related fields\n    if (fieldInfo.type === FieldType.CreatedBy) {\n      // For system user fields, derive directly from system columns to avoid JSON dependency\n      const alias = selectContext.tableAlias;\n      const idRef = alias ? `\"${alias}\".\"__created_by\"` : `\"__created_by\"`;\n      return this.dialect!.selectUserNameById(idRef);\n    }\n    if (fieldInfo.type === FieldType.LastModifiedBy) {\n      const trackAll = (fieldInfo as LastModifiedByFieldCore).isTrackAll();\n      if (trackAll) {\n        const alias = selectContext.tableAlias;\n        const idRef = alias ? `\"${alias}\".\"__last_modified_by\"` : `\"__last_modified_by\"`;\n        return this.dialect!.selectUserNameById(idRef);\n      }\n      if (!selectionSql) {\n        if (selectContext.tableAlias) {\n          selectionSql = `\"${selectContext.tableAlias}\".\"${fieldInfo.dbFieldName}\"`;\n        } else {\n          selectionSql = `\"${fieldInfo.dbFieldName}\"`;\n        }\n      }\n      if (preferRaw && selectContext.targetDbFieldType === DbFieldType.Json) {\n        if (fieldInfo.isMultipleCellValue) {\n          return this.dialect!.linkExtractTitles(selectionSql, true);\n        }\n        const titleExpr = this.dialect!.jsonTitleFromExpr(selectionSql);\n        if (this.dialect!.driver === DriverClient.Pg) {\n          return `to_jsonb(${titleExpr})`;\n        }\n        if (this.dialect!.driver === DriverClient.Sqlite) {\n          return `json(${titleExpr})`;\n        }\n        return titleExpr;\n      }\n      if (fieldInfo.isMultipleCellValue) {\n        return this.dialect!.linkExtractTitles(selectionSql, true);\n      }\n      return this.dialect!.jsonTitleFromExpr(selectionSql);\n    }\n    if (fieldInfo.type === FieldType.User) {\n      // For normal User fields, extract title from the JSON selection when available\n      if (!selectionSql) {\n        if (selectContext.tableAlias) {\n          selectionSql = `\"${selectContext.tableAlias}\".\"${fieldInfo.dbFieldName}\"`;\n        } else {\n          selectionSql = `\"${fieldInfo.dbFieldName}\"`;\n        }\n      }\n\n      if (preferRaw && selectContext.targetDbFieldType === DbFieldType.Json) {\n        if (fieldInfo.isMultipleCellValue) {\n          return this.dialect!.linkExtractTitles(selectionSql, true);\n        }\n        // For single-value formulas targeting json columns, wrap scalar title as json\n        const titleExpr = this.dialect!.jsonTitleFromExpr(selectionSql);\n        if (this.dialect!.driver === DriverClient.Pg) {\n          return `to_jsonb(${titleExpr})`;\n        }\n        if (this.dialect!.driver === DriverClient.Sqlite) {\n          return `json(${titleExpr})`;\n        }\n        return titleExpr;\n      }\n\n      return this.dialect!.jsonTitleFromExpr(selectionSql);\n    }\n\n    if (selectionSql) {\n      const normalizedSelection = this.normalizeLookupSelection(\n        selectionSql,\n        fieldInfo,\n        selectContext\n      );\n\n      if (normalizedSelection !== selectionSql) {\n        return normalizedSelection;\n      }\n\n      if (preferRaw) {\n        return this.coerceRawMultiValueReference(selectionSql, fieldInfo, selectContext);\n      }\n\n      return selectionSql;\n    }\n    // Use table alias if provided in context\n    if (selectContext.tableAlias) {\n      const aliasExpr = `\"${selectContext.tableAlias}\".\"${fieldInfo.dbFieldName}\"`;\n      return preferRaw\n        ? this.coerceRawMultiValueReference(aliasExpr, fieldInfo, selectContext)\n        : aliasExpr;\n    }\n\n    const fallbackExpr = this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);\n    return preferRaw\n      ? this.coerceRawMultiValueReference(fallbackExpr, fieldInfo, selectContext)\n      : fallbackExpr;\n  }\n\n  private normalizeLookupSelection(\n    expr: string,\n    fieldInfo: FieldCore,\n    selectContext: ISelectFormulaConversionContext\n  ): string {\n    if (!expr) {\n      return expr;\n    }\n\n    const dialect = this.dialect;\n    if (!dialect) {\n      return expr;\n    }\n\n    if (\n      fieldInfo.type !== FieldType.Link ||\n      !fieldInfo.isLookup ||\n      !fieldInfo.lookupOptions ||\n      !isLinkLookupOptions(fieldInfo.lookupOptions)\n    ) {\n      return expr;\n    }\n\n    const preferRaw = !!selectContext.preferRawFieldReferences;\n    const targetDbType = selectContext.targetDbFieldType;\n    const trimmed = expr.trim();\n    if (!trimmed || trimmed.toUpperCase() === 'NULL') {\n      return expr;\n    }\n\n    const titlesExpr = dialect.linkExtractTitles(expr, !!fieldInfo.isMultipleCellValue);\n    if (preferRaw && targetDbType === DbFieldType.Json) {\n      return fieldInfo.isMultipleCellValue ? titlesExpr : expr;\n    }\n    if (fieldInfo.isMultipleCellValue) {\n      return dialect.formatStringArray(titlesExpr, { fieldInfo });\n    }\n    return titlesExpr;\n  }\n\n  private coerceRawMultiValueReference(\n    expr: string,\n    fieldInfo: FieldCore,\n    selectContext: ISelectFormulaConversionContext\n  ): string {\n    if (!expr) return expr;\n    const trimmed = expr.trim().toUpperCase();\n    if (trimmed === 'NULL') {\n      return expr;\n    }\n    if (!fieldInfo.isMultipleCellValue) {\n      return expr;\n    }\n\n    const targetType = selectContext.targetDbFieldType;\n    if (!targetType || targetType === DbFieldType.Json) {\n      return expr;\n    }\n\n    if (!this.dialect) {\n      return expr;\n    }\n\n    // eslint-disable-next-line sonarjs/no-small-switch\n    switch (this.dialect.driver) {\n      case DriverClient.Pg: {\n        if (targetType !== DbFieldType.DateTime) {\n          return expr;\n        }\n        const safeJsonExpr = `(CASE\n          WHEN pg_typeof(${expr}) = 'jsonb'::regtype THEN (${expr})::text::jsonb\n          WHEN pg_typeof(${expr}) = 'json'::regtype THEN (${expr})::text::jsonb\n          ELSE NULL::jsonb\n        END)`;\n        return `(SELECT elem #>> '{}'\n          FROM jsonb_array_elements(COALESCE(${safeJsonExpr}, '[]'::jsonb)) AS elem\n          WHERE jsonb_typeof(elem) NOT IN ('array','object')\n          LIMIT 1\n        )`;\n      }\n      default:\n        return expr;\n    }\n  }\n\n  /**\n   * Check if a field reference is being used in a boolean context\n   * (i.e., as a parameter to logical functions like AND, OR, NOT, etc.)\n   */\n  private isInBooleanContext(ctx: FieldReferenceCurlyContext): boolean {\n    let parent = ctx.parent;\n\n    // Walk up the parse tree to find if we're inside a logical function\n    while (parent) {\n      if (parent instanceof FunctionCallContext) {\n        const rawName = parent.func_name().text.toUpperCase();\n        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;\n        if (BOOLEAN_FUNCTIONS.has(fnName)) {\n          return true;\n        }\n\n        if (fnName === FunctionName.If) {\n          const conditionExpr = parent.expr(0);\n          return conditionExpr ? this.isAncestorNode(conditionExpr, ctx) : false;\n        }\n\n        return false;\n      }\n\n      // Also check for binary logical operators\n      if (parent instanceof BinaryOpContext) {\n        const operator = parent._op?.text;\n        if (!operator) return false;\n        // Only treat actual logical operators as boolean context; comparison operators\n        // should preserve the original field value for proper type-aware comparisons.\n        const logicalOperators = ['&&', '||'];\n        return logicalOperators.includes(operator);\n      }\n\n      parent = parent.parent;\n    }\n\n    return false;\n  }\n\n  private isAncestorNode(ancestor: any, node: any): boolean {\n    let current = node;\n    while (current) {\n      if (current === ancestor) {\n        return true;\n      }\n      current = current.parent;\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { IMakeOptional, TableDomain } from '@teable/core';\nimport { CellFormat, FieldKeyType, FieldType, HttpErrorCode, generateRecordId } from '@teable/core';\nimport type { ICreateRecordsRo, ICreateRecordsVo } from '@teable/openapi';\nimport { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { BatchService } from '../../calculation/batch.service';\nimport { LinkService } from '../../calculation/link.service';\nimport type { ICellContext } from '../../calculation/utils/changes';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { ComputedOrchestratorService } from '../computed/services/computed-orchestrator.service';\nimport type { IRecordInnerRo } from '../record.service';\nimport { RecordService } from '../record.service';\nimport { RecordModifySharedService } from './record-modify.shared.service';\n\n@Injectable()\nexport class RecordCreateService {\n  constructor(\n    private readonly recordService: RecordService,\n    private readonly shared: RecordModifySharedService,\n    private readonly batchService: BatchService,\n    private readonly linkService: LinkService,\n    private readonly computedOrchestrator: ComputedOrchestratorService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  async multipleCreateRecords(\n    tableId: string,\n    createRecordsRo: ICreateRecordsRo,\n    ignoreMissingFields: boolean = false\n  ): Promise<ICreateRecordsVo> {\n    const { fieldKeyType = FieldKeyType.Name, records, typecast, order } = createRecordsRo;\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const typecastRecords = await this.shared.validateFieldsAndTypecast<\n      IMakeOptional<IRecordInnerRo, 'id'>\n    >(table, records, fieldKeyType, typecast, ignoreMissingFields);\n    const preparedRecords = await this.shared.appendRecordOrderIndexes(\n      table,\n      typecastRecords,\n      order\n    );\n    const chunkSize = this.thresholdConfig.calcChunkSize;\n    const chunks: IMakeOptional<IRecordInnerRo, 'id'>[][] = [];\n    for (let i = 0; i < preparedRecords.length; i += chunkSize) {\n      chunks.push(preparedRecords.slice(i, i + chunkSize));\n    }\n    const acc: ICreateRecordsVo = { records: [] };\n    for (const chunk of chunks) {\n      const res = await this.createRecords(table, chunk, fieldKeyType);\n      acc.records.push(...res.records);\n    }\n    return acc;\n  }\n\n  async createRecords(\n    table: TableDomain,\n    recordsRo: IMakeOptional<IRecordInnerRo, 'id'>[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Name,\n    projection?: string[]\n  ): Promise<ICreateRecordsVo> {\n    if (recordsRo.length === 0) {\n      throw new CustomHttpException('Create records is empty', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.record.createRecordsEmpty',\n        },\n      });\n    }\n    const records = recordsRo.map((r) => ({ ...r, id: r.id || generateRecordId() }));\n    const fields = table.fieldList;\n    await this.recordService.batchCreateRecords(table, records, fieldKeyType, fields);\n    const recordsWithDefaults = await this.shared.appendDefaultValue(records, fieldKeyType, fields);\n    const contextReadyRecords = await this.shared.ensureReferencedBaseFieldsForNewRecords(\n      recordsWithDefaults,\n      fieldKeyType,\n      fields\n    );\n    const recordIds = contextReadyRecords.map((r) => r.id);\n    const projectionByTable = this.buildProjectionByTable(table, fieldKeyType, contextReadyRecords);\n    const createCtxs = await this.shared.generateCellContexts(\n      table,\n      fieldKeyType,\n      contextReadyRecords,\n      true\n    );\n    await this.linkService.getDerivateByLink(table.id, createCtxs, undefined, projectionByTable);\n    const changes = this.shared.compressAndFilterChanges(table, createCtxs);\n    const opsMap = this.shared.formatChangesToOps(changes);\n    const computedCtxs = this.appendSystemFieldContextsForCreate(table, recordIds, createCtxs);\n    // Publish computed values (with old/new) around base updates\n    await this.computedOrchestrator.computeCellChangesForRecords(\n      table.id,\n      computedCtxs,\n      async (tables) => {\n        await this.batchService.updateRecords(opsMap, undefined, undefined, tables);\n      }\n    );\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      table.id,\n      recordIds,\n      this.recordService.convertProjection(projection),\n      fieldKeyType,\n      CellFormat.Json,\n      true\n    );\n    return { records: snapshots.map((s) => s.data) };\n  }\n\n  async createRecordsOnlySql(tableId: string, createRecordsRo: ICreateRecordsRo): Promise<void> {\n    const { fieldKeyType = FieldKeyType.Name, records, typecast } = createRecordsRo;\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const typecastRecords = await this.shared.validateFieldsAndTypecast<\n      IMakeOptional<IRecordInnerRo, 'id'>\n    >(table, records, fieldKeyType, typecast);\n    await this.recordService.createRecordsOnlySql(table, typecastRecords);\n  }\n\n  private buildProjectionByTable(\n    table: TableDomain,\n    fieldKeyType: FieldKeyType,\n    records: { fields: Record<string, unknown> }[]\n  ): Record<string, string[]> | undefined {\n    const fieldsMap = table.getFieldsMap(fieldKeyType);\n    const projectionIds = records.reduce<Set<string>>((acc, record) => {\n      Object.keys(record.fields).forEach((key) => {\n        const field = fieldsMap.get(key);\n        if (field) {\n          acc.add(field.id);\n        }\n      });\n      return acc;\n    }, new Set<string>());\n\n    return projectionIds.size ? { [table.id]: Array.from(projectionIds) } : undefined;\n  }\n\n  private appendSystemFieldContextsForCreate(\n    table: TableDomain,\n    recordIds: string[],\n    cellContexts: ICellContext[]\n  ): ICellContext[] {\n    if (!recordIds.length) return cellContexts;\n\n    const systemFieldIds = table.fieldList\n      .filter(\n        (field) =>\n          field.type === FieldType.CreatedTime ||\n          field.type === FieldType.CreatedBy ||\n          field.type === FieldType.LastModifiedTime ||\n          field.type === FieldType.LastModifiedBy ||\n          field.type === FieldType.AutoNumber\n      )\n      .map((field) => field.id);\n\n    if (!systemFieldIds.length) return cellContexts;\n\n    const existing = new Set(cellContexts.map((ctx) => `${ctx.recordId}:${ctx.fieldId}`));\n    const extraContexts: ICellContext[] = [];\n\n    for (const recordId of recordIds) {\n      for (const fieldId of systemFieldIds) {\n        const key = `${recordId}:${fieldId}`;\n        if (existing.has(key)) continue;\n        existing.add(key);\n        extraContexts.push({ recordId, fieldId });\n      }\n    }\n\n    return extraContexts.length ? cellContexts.concat(extraContexts) : cellContexts;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-delete.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { generateOperationId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { LinkService } from '../../calculation/link.service';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { ComputedOrchestratorService } from '../computed/services/computed-orchestrator.service';\nimport { RecordService } from '../record.service';\n\n@Injectable()\nexport class RecordDeleteService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly linkService: LinkService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly computedOrchestrator: ComputedOrchestratorService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  async deleteRecord(tableId: string, recordId: string, windowId?: string) {\n    const result = await this.deleteRecords(tableId, [recordId], windowId);\n    return result.records[0];\n  }\n\n  async deleteRecords(tableId: string, recordIds: string[], windowId?: string) {\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const { records: recordsForEvent, orders } = await this.prismaService.$tx(async () => {\n      // Use a base-table query to ensure link values are derived from junction tables.\n      const recordsForEvent = await this.recordService.getRecordsById(\n        tableId,\n        recordIds,\n        false,\n        false\n      );\n      const cellContextsByTableId = await this.linkService.getDeleteRecordUpdateContext(\n        tableId,\n        recordsForEvent.records\n      );\n\n      // Prepare sources for multi-orchestrator run\n      const sources: {\n        tableId: string;\n        cellContexts: {\n          recordId: string;\n          fieldId: string;\n          newValue?: unknown;\n          oldValue?: unknown;\n        }[];\n      }[] = [];\n      for (const effectedTableId in cellContextsByTableId) {\n        const cellContexts = cellContextsByTableId[effectedTableId];\n        await this.linkService.getDerivateByLink(effectedTableId, cellContexts);\n        // Exclude the table being deleted from (we only publish to related tables)\n        if (effectedTableId !== tableId) {\n          sources.push({ tableId: effectedTableId, cellContexts });\n        }\n      }\n\n      const orders = windowId\n        ? await this.recordService.getRecordIndexes(table, recordIds)\n        : undefined;\n\n      // Publish computed/link changes with old/new around the actual delete\n      await this.computedOrchestrator.computeCellChangesForRecordsMulti(sources, async () => {\n        await this.recordService.batchDeleteRecords(tableId, recordIds);\n      });\n\n      return { records: recordsForEvent, orders };\n    });\n\n    this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_DELETE, {\n      operationId: generateOperationId(),\n      windowId,\n      tableId,\n      userId: this.cls.get('user.id'),\n      records: recordsForEvent.records.map((record, index) => ({\n        ...record,\n        order: orders?.[index],\n      })),\n    });\n\n    return recordsForEvent;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-duplicate.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldKeyType, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IRecordInsertOrderRo, IRecord } from '@teable/openapi';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { RecordService } from '../record.service';\nimport { RecordCreateService } from './record-create.service';\n\n@Injectable()\nexport class RecordDuplicateService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly recordCreateService: RecordCreateService,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  async duplicateRecord(\n    tableId: string,\n    recordId: string,\n    order: IRecordInsertOrderRo,\n    projection?: string[]\n  ): Promise<IRecord> {\n    const query = { fieldKeyType: FieldKeyType.Id, projection };\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const result = await this.recordService.getRecord(tableId, recordId, query).catch(() => null);\n    if (!result) {\n      throw new CustomHttpException(`Record ${recordId} not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.record.notFound',\n        },\n      });\n    }\n    const records = { fields: result.fields };\n    const createRecordsRo = {\n      fieldKeyType: FieldKeyType.Id,\n      order,\n      records: [records],\n    };\n    return await this.prismaService\n      .$tx(async () =>\n        this.recordCreateService.createRecords(\n          table,\n          createRecordsRo.records,\n          FieldKeyType.Id,\n          projection\n        )\n      )\n      .then((res) => {\n        if (!res.records[0]) {\n          throw new CustomHttpException('Duplicate record failed', HttpErrorCode.VALIDATION_ERROR, {\n            localization: {\n              i18nKey: 'httpErrors.record.duplicateFailed',\n            },\n          });\n        }\n        return res.records[0];\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-modify.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsStorageModule } from '../../attachments/attachments-storage.module';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { CollaboratorModule } from '../../collaborator/collaborator.module';\nimport { DataLoaderModule } from '../../data-loader/data-loader.module';\nimport { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';\nimport { ViewModule } from '../../view/view.module';\nimport { ComputedModule } from '../computed/computed.module';\nimport { RecordModule } from '../record.module';\nimport { RecordCreateService } from './record-create.service';\nimport { RecordDeleteService } from './record-delete.service';\nimport { RecordDuplicateService } from './record-duplicate.service';\nimport { RecordModifyService } from './record-modify.service';\nimport { RecordModifySharedService } from './record-modify.shared.service';\nimport { RecordUpdateService } from './record-update.service';\n\n@Module({\n  imports: [\n    RecordModule,\n    CalculationModule,\n    FieldCalculateModule,\n    ViewOpenApiModule,\n    ViewModule,\n    AttachmentsStorageModule,\n    CollaboratorModule,\n    DataLoaderModule,\n    ComputedModule,\n    TableDomainQueryModule,\n  ],\n  providers: [\n    RecordModifyService,\n    RecordModifySharedService,\n    RecordCreateService,\n    RecordUpdateService,\n    RecordDeleteService,\n    RecordDuplicateService,\n  ],\n  exports: [RecordModifyService, RecordModifySharedService],\n})\nexport class RecordModifyModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-modify.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { FieldKeyType } from '@teable/core';\nimport type { IMakeOptional } from '@teable/core';\nimport type {\n  IRecord,\n  ICreateRecordsRo,\n  ICreateRecordsVo,\n  IRecordInsertOrderRo,\n} from '@teable/openapi';\nimport { TableDomainQueryService } from '../../table-domain';\nimport type { IRecordInnerRo } from '../record.service';\nimport type { IUpdateRecordsInternalRo } from '../type';\nimport { RecordCreateService } from './record-create.service';\nimport { RecordDeleteService } from './record-delete.service';\nimport { RecordDuplicateService } from './record-duplicate.service';\nimport { RecordUpdateService } from './record-update.service';\n\n@Injectable()\nexport class RecordModifyService {\n  constructor(\n    private readonly createService: RecordCreateService,\n    private readonly updateService: RecordUpdateService,\n    private readonly deleteService: RecordDeleteService,\n    private readonly duplicateService: RecordDuplicateService,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  async updateRecords(\n    tableId: string,\n    updateRecordsRo: IUpdateRecordsInternalRo,\n    windowId?: string\n  ) {\n    return this.updateService.updateRecords(tableId, updateRecordsRo, windowId);\n  }\n\n  async simpleUpdateRecords(tableId: string, updateRecordsRo: IUpdateRecordsInternalRo) {\n    return this.updateService.simpleUpdateRecords(tableId, updateRecordsRo);\n  }\n\n  async multipleCreateRecords(\n    tableId: string,\n    createRecordsRo: ICreateRecordsRo,\n    ignoreMissingFields: boolean = false\n  ): Promise<ICreateRecordsVo> {\n    return this.createService.multipleCreateRecords(tableId, createRecordsRo, ignoreMissingFields);\n  }\n\n  async createRecords(\n    tableId: string,\n    recordsRo: IMakeOptional<IRecordInnerRo, 'id'>[],\n    fieldKeyType?: FieldKeyType,\n    projection?: string[]\n  ): Promise<ICreateRecordsVo> {\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    return this.createService.createRecords(\n      table,\n      recordsRo,\n      fieldKeyType ?? FieldKeyType.Name,\n      projection\n    );\n  }\n\n  async createRecordsOnlySql(tableId: string, createRecordsRo: ICreateRecordsRo): Promise<void> {\n    return this.createService.createRecordsOnlySql(tableId, createRecordsRo);\n  }\n\n  async deleteRecord(tableId: string, recordId: string, windowId?: string) {\n    return this.deleteService.deleteRecord(tableId, recordId, windowId);\n  }\n\n  async deleteRecords(tableId: string, recordIds: string[], windowId?: string) {\n    return this.deleteService.deleteRecords(tableId, recordIds, windowId);\n  }\n\n  async duplicateRecord(\n    tableId: string,\n    recordId: string,\n    order: IRecordInsertOrderRo,\n    projection?: string[]\n  ): Promise<IRecord> {\n    return this.duplicateService.duplicateRecord(tableId, recordId, order, projection);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-modify.shared.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport {\n  FieldKeyType,\n  FieldType,\n  FormulaFieldCore,\n  TableDomain,\n  HttpErrorCode,\n} from '@teable/core';\nimport type {\n  FieldCore,\n  IMakeOptional,\n  IUserFieldOptions,\n  LastModifiedByFieldCore,\n  LastModifiedTimeFieldCore,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IRecord, IRecordInsertOrderRo } from '@teable/openapi';\nimport { isEqual, forEach, keyBy, map } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { Timing } from '../../../utils/timing';\nimport { AttachmentsStorageService } from '../../attachments/attachments-storage.service';\nimport type { ICellContext, ICellChange } from '../../calculation/utils/changes';\nimport { formatChangesToOps, mergeDuplicateChange } from '../../calculation/utils/changes';\nimport { CollaboratorService } from '../../collaborator/collaborator.service';\nimport { DataLoaderService } from '../../data-loader/data-loader.service';\nimport { FieldConvertingService } from '../../field/field-calculate/field-converting.service';\nimport { createFieldInstanceByRaw } from '../../field/model/factory';\nimport { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport { ViewService } from '../../view/view.service';\nimport type { IRecordInnerRo } from '../record.service';\nimport { RecordService } from '../record.service';\nimport type { IFieldRaws } from '../type';\nimport { TypeCastAndValidate } from '../typecast.validate';\n\n@Injectable()\nexport class RecordModifySharedService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly fieldConvertingService: FieldConvertingService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly viewService: ViewService,\n    private readonly attachmentsStorageService: AttachmentsStorageService,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly dataLoaderService: DataLoaderService\n  ) {}\n\n  // Shared change compression and filtering utilities\n  compressAndFilterChanges(table: TableDomain, cellContexts: ICellContext[]): ICellChange[] {\n    if (!cellContexts.length) return [];\n\n    const rawChanges: ICellChange[] = cellContexts.map((ctx) => ({\n      tableId: table.id,\n      recordId: ctx.recordId,\n      fieldId: ctx.fieldId,\n      newValue: ctx.newValue,\n      oldValue: ctx.oldValue,\n    }));\n\n    const merged = mergeDuplicateChange(rawChanges);\n    const nonNoop = merged.filter((c) => !isEqual(c.newValue, c.oldValue));\n    if (!nonNoop.length) return [];\n\n    const fieldIds = Array.from(new Set(nonNoop.map((c) => c.fieldId)));\n    const sysFields = table.getLastModifiedFields().filter((f) => {\n      if (!fieldIds.includes(f.id)) return false;\n      if (f.type === FieldType.LastModifiedTime) {\n        const lmt = f as LastModifiedTimeFieldCore;\n        // Only treat as a system field when it tracks all fields (generated column)\n        return lmt.isTrackAll();\n      }\n      if (f.type === FieldType.LastModifiedBy) {\n        return (f as LastModifiedByFieldCore).isTrackAll();\n      }\n      return true;\n    });\n    const sysSet = new Set(sysFields.map((f) => f.id));\n    return nonNoop.filter((c) => !sysSet.has(c.fieldId));\n  }\n\n  private getEffectFieldInstances(\n    table: TableDomain,\n    recordsFields: Record<string, unknown>[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Name,\n    ignoreMissingFields: boolean = false\n  ) {\n    const fieldIdsOrNamesSet = recordsFields.reduce<Set<string>>((acc, recordFields) => {\n      const fieldIds = Object.keys(recordFields);\n      forEach(fieldIds, (fieldId) => acc.add(fieldId));\n      return acc;\n    }, new Set());\n\n    const usedFieldIdsOrNames = Array.from(fieldIdsOrNamesSet);\n    const fieldsMap = table.getFieldsMap(fieldKeyType);\n\n    const usedFields = usedFieldIdsOrNames\n      .map((fieldIdOrName) => fieldsMap.get(fieldIdOrName))\n      .filter((f): f is FieldCore => !!f);\n\n    if (!ignoreMissingFields && usedFields.length !== usedFieldIdsOrNames.length) {\n      const usedSet = new Set(map(usedFields, fieldKeyType));\n      const missedFields = usedFieldIdsOrNames.filter(\n        (fieldIdOrName) => !usedSet.has(fieldIdOrName)\n      );\n      throw new CustomHttpException(\n        `Field ${fieldKeyType}: ${missedFields.join(', ')} not found`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.fieldKeyTypeNotFound',\n            context: {\n              fieldKeyType,\n              missedFields: missedFields.join(', '),\n            },\n          },\n        }\n      );\n    }\n    return usedFields;\n  }\n\n  @Timing()\n  async validateFieldsAndTypecast<\n    T extends {\n      fields: Record<string, unknown>;\n    },\n  >(\n    table: TableDomain,\n    records: T[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Name,\n    typecast: boolean = false,\n    ignoreMissingFields: boolean = false\n  ): Promise<T[]> {\n    const recordsFields = map(records, 'fields');\n    const effectFieldInstance = this.getEffectFieldInstances(\n      table,\n      recordsFields,\n      fieldKeyType,\n      ignoreMissingFields\n    );\n\n    const newRecordsFields: Record<string, unknown>[] = recordsFields.map(() => ({}));\n    for (const field of effectFieldInstance) {\n      // skip computed field\n      if (field.isComputed) {\n        continue;\n      }\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services: {\n          prismaService: this.prismaService,\n          fieldConvertingService: this.fieldConvertingService,\n          recordService: this.recordService,\n          attachmentsStorageService: this.attachmentsStorageService,\n          collaboratorService: this.collaboratorService,\n          dataLoaderService: this.dataLoaderService,\n        },\n        field,\n        tableId: table.id,\n        typecast,\n      });\n      const fieldIdOrName = field[fieldKeyType];\n\n      const cellValues = recordsFields.map((recordFields) => recordFields[fieldIdOrName]);\n\n      const newCellValues = await typeCastAndValidate.typecastCellValuesWithField(cellValues);\n      newRecordsFields.forEach((recordField, i) => {\n        // do not generate undefined field key\n        if (newCellValues[i] !== undefined) {\n          recordField[fieldIdOrName] = newCellValues[i];\n        }\n      });\n    }\n    return records.map((record, i) => ({\n      ...record,\n      fields: newRecordsFields[i],\n    }));\n  }\n\n  @Timing()\n  async generateCellContexts(\n    table: TableDomain,\n    fieldKeyType: FieldKeyType,\n    records: { id: string; fields: { [fieldNameOrId: string]: unknown } }[],\n    isNewRecord?: boolean,\n    projectionFields?: string[]\n  ) {\n    const fieldsMap = table.getFieldsMap(fieldKeyType);\n    const projectionByFieldId =\n      projectionFields && projectionFields.length > 0\n        ? projectionFields.reduce<Record<string, boolean>>((acc, key) => {\n            const field = fieldsMap.get(key);\n            if (field) {\n              acc[field.id] = true;\n            }\n            return acc;\n          }, {})\n        : records.reduce<Record<string, boolean>>((acc, record) => {\n            Object.keys(record.fields).forEach((key) => {\n              const field = fieldsMap.get(key);\n              if (field) {\n                acc[field.id] = true;\n              }\n            });\n            return acc;\n          }, {});\n\n    const cellContexts: ICellContext[] = [];\n\n    let oldRecordsMap: Record<string, IRecord> = {} as Record<string, IRecord>;\n    if (!isNewRecord) {\n      const oldRecords = (\n        await this.recordService.getSnapshotBulk(\n          table.id,\n          records.map((r) => r.id),\n          Object.keys(projectionByFieldId).length ? projectionByFieldId : undefined,\n          FieldKeyType.Id,\n          undefined,\n          true\n        )\n      ).map((s) => s.data);\n      oldRecordsMap = keyBy(oldRecords, 'id');\n    }\n\n    for (const record of records) {\n      Object.entries(record.fields).forEach(([fieldNameOrId, value]) => {\n        if (!fieldsMap.has(fieldNameOrId)) {\n          throw new CustomHttpException(\n            `Field ${fieldNameOrId} not found`,\n            HttpErrorCode.NOT_FOUND,\n            {\n              localization: {\n                i18nKey: 'httpErrors.field.notFound',\n              },\n            }\n          );\n        }\n        const fieldId = fieldsMap.get(fieldNameOrId)!.id;\n        const oldCellValue = isNewRecord ? null : oldRecordsMap[record.id]?.fields[fieldId] ?? null;\n        cellContexts.push({\n          recordId: record.id,\n          fieldId,\n          newValue: value,\n          oldValue: oldCellValue,\n        });\n      });\n    }\n    return cellContexts;\n  }\n\n  async getRecordOrderIndexes(\n    table: TableDomain,\n    orderRo: IRecordInsertOrderRo,\n    recordCount: number\n  ) {\n    const dbTableName = table.dbTableName;\n    let indexes: number[] = [];\n    await this.viewOpenApiService.updateRecordOrdersInner({\n      tableId: table.id,\n      dbTableName,\n      itemLength: recordCount,\n      indexField: await this.viewService.getOrCreateViewIndexField(dbTableName, orderRo.viewId),\n      orderRo,\n      update: async (result) => {\n        indexes = result;\n      },\n    });\n    return indexes;\n  }\n\n  async appendRecordOrderIndexes(\n    table: TableDomain,\n    records: IMakeOptional<IRecordInnerRo, 'id'>[],\n    order: IRecordInsertOrderRo | undefined\n  ) {\n    if (!order) return records;\n    const indexes = await this.getRecordOrderIndexes(table, order, records.length);\n    return records.map((record, i) => ({\n      ...record,\n      order: indexes ? { [order.viewId]: indexes[i] } : undefined,\n    }));\n  }\n\n  private transformUserDefaultValue(\n    options: IUserFieldOptions,\n    defaultValue: string | string[]\n  ): unknown {\n    const currentUserId = this.cls.get('user.id');\n    const ids = Array.from(\n      new Set([defaultValue].flat().map((id) => (id === 'me' ? currentUserId : id)))\n    );\n    return options.isMultiple ? ids.map((id) => ({ id })) : ids[0] ? { id: ids[0] } : undefined;\n  }\n\n  getDefaultValue(type: FieldType, options: unknown, defaultValue: unknown) {\n    switch (type) {\n      case FieldType.Date:\n        return defaultValue === 'now' ? new Date().toISOString() : defaultValue;\n      case FieldType.SingleSelect:\n        return Array.isArray(defaultValue) ? defaultValue[0] : defaultValue;\n      case FieldType.MultipleSelect:\n        return Array.isArray(defaultValue) ? defaultValue : [defaultValue];\n      case FieldType.User:\n        return this.transformUserDefaultValue(\n          options as IUserFieldOptions,\n          defaultValue as string | string[]\n        );\n      case FieldType.Checkbox:\n        return defaultValue ? true : null;\n      default:\n        return defaultValue;\n    }\n  }\n\n  async getUserInfoFromDatabase(userIds: string[]) {\n    const usersRaw = await this.prismaService.txClient().user.findMany({\n      where: { id: { in: userIds }, deletedTime: null },\n      select: { id: true, name: true, email: true },\n    });\n    return keyBy(\n      usersRaw.map((u) => ({ id: u.id, title: u.name, email: u.email })),\n      'id'\n    );\n  }\n\n  async fillUserInfo(\n    records: { id: string; fields: { [fieldNameOrId: string]: unknown } }[],\n    userFields: readonly FieldCore[],\n    fieldKeyType: FieldKeyType\n  ) {\n    const userIds = new Set<string>();\n    records.forEach((record) => {\n      userFields.forEach((field) => {\n        const key = field[fieldKeyType];\n        const v = record.fields[key] as unknown;\n        if (v) {\n          if (Array.isArray(v)) (v as { id: string }[]).forEach((i) => userIds.add(i.id));\n          else userIds.add((v as { id: string }).id);\n        }\n      });\n    });\n    const info = await this.getUserInfoFromDatabase(Array.from(userIds));\n    return records.map((record) => {\n      const fields: Record<string, unknown> = { ...record.fields };\n      userFields.forEach((field) => {\n        const key = field[fieldKeyType];\n        const v = fields[key] as unknown;\n        if (v) {\n          fields[key] = Array.isArray(v)\n            ? (v as { id: string }[]).map((i) => ({ ...i, ...info[i.id] }))\n            : { ...(v as { id: string }), ...info[(v as { id: string }).id] };\n        }\n      });\n      return { ...record, fields };\n    });\n  }\n\n  @Timing()\n  async ensureReferencedBaseFieldsForNewRecords(\n    records: { id: string; fields: { [fieldNameOrId: string]: unknown } }[],\n    fieldKeyType: FieldKeyType,\n    fields: readonly FieldCore[]\n  ) {\n    if (!records.length) return records;\n\n    const baseFieldKeyById = fields.reduce<Map<string, string | undefined>>((acc, field) => {\n      if (this.isDerivedField(field)) {\n        return acc;\n      }\n      const key = field[fieldKeyType] as string | undefined;\n      acc.set(field.id, key);\n      return acc;\n    }, new Map());\n    if (!baseFieldKeyById.size) {\n      return records;\n    }\n\n    const baseFieldIds = Array.from(baseFieldKeyById.keys());\n    if (!baseFieldIds.length) return records;\n\n    const referencedRows = await this.prismaService.txClient().reference.findMany({\n      where: {\n        fromFieldId: { in: baseFieldIds },\n      },\n      select: { fromFieldId: true },\n    });\n\n    const referencedFieldIds = referencedRows.reduce<Set<string>>((acc, row) => {\n      if (baseFieldKeyById.has(row.fromFieldId)) {\n        acc.add(row.fromFieldId);\n      }\n      return acc;\n    }, new Set<string>());\n\n    if (referencedFieldIds.size < baseFieldIds.length) {\n      const fallbackReferenced = this.collectReferencedBaseFieldIdsFromFieldRaws(\n        fields,\n        baseFieldKeyById\n      );\n      fallbackReferenced.forEach((id) => referencedFieldIds.add(id));\n    }\n\n    const referencedFieldKeys = Array.from(referencedFieldIds).reduce<Set<string>>((acc, id) => {\n      const key = baseFieldKeyById.get(id);\n      if (key) {\n        acc.add(key);\n      }\n      return acc;\n    }, new Set());\n\n    if (!referencedFieldKeys.size) return records;\n\n    const hasOwn = Object.prototype.hasOwnProperty;\n\n    return records.map((record) => {\n      let fields = record.fields;\n      let mutated = false;\n      referencedFieldKeys.forEach((key) => {\n        if (!hasOwn.call(fields, key)) {\n          if (!mutated) {\n            fields = { ...fields };\n            mutated = true;\n          }\n          fields[key] = null;\n        }\n      });\n      return mutated ? { ...record, fields } : record;\n    });\n  }\n\n  @Timing()\n  async appendDefaultValue(\n    records: { id: string; fields: { [fieldNameOrId: string]: unknown } }[],\n    fieldKeyType: FieldKeyType,\n    fieldList: readonly FieldCore[]\n  ) {\n    const processed = records.map((record) => {\n      const fields: Record<string, unknown> = { ...record.fields };\n      for (const f of fieldList) {\n        const { type, options, isComputed } = f;\n        if (options == null || isComputed) continue;\n        if (!('defaultValue' in options)) continue;\n        const dv = options.defaultValue;\n        if (dv == null) continue;\n        const key = f[fieldKeyType];\n        if (fields[key] != null) continue;\n        fields[key] = this.getDefaultValue(type as FieldType, options, dv);\n      }\n      return { ...record, fields };\n    });\n    const userFields = fieldList.filter((f) => f.type === FieldType.User);\n    if (userFields.length) return this.fillUserInfo(processed, userFields, fieldKeyType);\n    return processed;\n  }\n\n  private collectReferencedBaseFieldIdsFromFieldRaws(\n    fields: readonly FieldCore[],\n    baseFieldKeyById: Map<string, string | undefined>\n  ): Set<string> {\n    const referenced = new Set<string>();\n    const fieldById = new Map(fields.map((field) => [field.id, field]));\n    const fieldByName = new Map(fields.map((field) => [field.name, field]));\n    const memo = new Map<string, Set<string>>();\n    const visiting = new Set<string>();\n\n    const resolveField = (identifier: string): FieldCore | undefined => {\n      if (!identifier) return undefined;\n      return fieldById.get(identifier) ?? fieldByName.get(identifier);\n    };\n\n    const collectBaseDeps = (field: FieldCore | undefined): Set<string> => {\n      if (!field) return new Set();\n      if (!this.isDerivedField(field)) {\n        return baseFieldKeyById.has(field.id) ? new Set([field.id]) : new Set();\n      }\n      const cached = memo.get(field.id);\n      if (cached) return cached;\n      if (visiting.has(field.id)) return new Set();\n      visiting.add(field.id);\n\n      const result = new Set<string>();\n      memo.set(field.id, result);\n\n      const appendBase = (identifier: string | undefined) => {\n        if (!identifier) return;\n        if (baseFieldKeyById.has(identifier)) {\n          result.add(identifier);\n          return;\n        }\n        const target = resolveField(identifier);\n        if (target) {\n          const nested = collectBaseDeps(target);\n          nested.forEach((id) => result.add(id));\n        }\n      };\n\n      if (field.type === FieldType.Formula) {\n        const options = this.parseJsonValue<{ expression?: string }>(field.options);\n        const expression = options?.expression;\n        if (expression) {\n          const deps = FormulaFieldCore.getReferenceFieldIds(expression);\n          deps.forEach((dep) => appendBase(dep));\n        }\n      }\n\n      if (field.isLookup || field.isConditionalLookup || this.isLookupLikeRollup(field)) {\n        appendBase(this.extractLookupLinkFieldId(field));\n      }\n\n      visiting.delete(field.id);\n      return result;\n    };\n\n    for (const field of fields) {\n      if (!this.isDerivedField(field)) continue;\n      const deps = collectBaseDeps(field);\n      deps.forEach((id) => referenced.add(id));\n    }\n    return referenced;\n  }\n\n  private extractLookupLinkFieldId(field: FieldCore): string | undefined {\n    const options = this.parseJsonValue<{ linkFieldId?: string }>(field.lookupOptions);\n    return options?.linkFieldId;\n  }\n\n  private isDerivedField(field: FieldCore): boolean {\n    if (field.isLookup || field.isConditionalLookup) {\n      return true;\n    }\n    if (this.isLookupLikeRollup(field)) {\n      return true;\n    }\n    if (field.type === FieldType.Formula) {\n      return true;\n    }\n    return !!field.isComputed;\n  }\n\n  private isLookupLikeRollup(field: FieldCore): boolean {\n    return field.type === FieldType.Rollup || field.type === FieldType.ConditionalRollup;\n  }\n\n  private parseJsonValue<T>(value: unknown): T | undefined {\n    if (value == null) return undefined;\n    if (typeof value === 'string') {\n      try {\n        return JSON.parse(value) as T;\n      } catch {\n        return undefined;\n      }\n    }\n    return value as T;\n  }\n\n  // Convenience re-export so callers don't need to import from utils\n  formatChangesToOps = formatChangesToOps;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-modify/record-update.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { TableDomain } from '@teable/core';\nimport { FieldKeyType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IRecordInsertOrderRo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { retryOnDeadlock } from '../../../utils/retry-decorator';\nimport { Timing } from '../../../utils/timing';\nimport { BatchService } from '../../calculation/batch.service';\nimport { LinkService } from '../../calculation/link.service';\nimport { SystemFieldService } from '../../calculation/system-field.service';\nimport { composeOpMaps, type IOpsMap } from '../../calculation/utils/compose-maps';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport { ComputedOrchestratorService } from '../computed/services/computed-orchestrator.service';\nimport { RecordService } from '../record.service';\nimport { IUpdateRecordsInternalRo } from '../type';\nimport { RecordModifySharedService } from './record-modify.shared.service';\n\n@Injectable()\nexport class RecordUpdateService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly systemFieldService: SystemFieldService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly batchService: BatchService,\n    private readonly linkService: LinkService,\n    private readonly computedOrchestrator: ComputedOrchestratorService,\n    private readonly shared: RecordModifySharedService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Timing({\n    key: 'updateRecords',\n    thresholdMs: 2000,\n    reportToSentry: true,\n    sentryTag: 'record-update',\n    sentryContext: (args) => {\n      const [tableId, updateRecordsRo, windowId] = args as [\n        string,\n        Partial<IUpdateRecordsInternalRo>,\n        string | undefined,\n      ];\n      return {\n        tableId,\n        windowId,\n        recordCount: updateRecordsRo?.records?.length,\n        fieldIds: updateRecordsRo?.fieldIds,\n        typecast: updateRecordsRo?.typecast,\n      };\n    },\n  })\n  @retryOnDeadlock()\n  async updateRecords(\n    tableId: string,\n    updateRecordsRo: IUpdateRecordsInternalRo,\n    windowId?: string\n  ) {\n    const effectiveWindowId = windowId ?? this.cls.get('windowId');\n    const {\n      records,\n      order,\n      fieldKeyType = FieldKeyType.Name,\n      typecast,\n      fieldIds,\n    } = updateRecordsRo;\n\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const scopedRecords = this.filterRecordsByFieldKeys(records, fieldIds);\n    const orderIndexesBefore =\n      order != null && effectiveWindowId\n        ? await this.recordService.getRecordIndexes(\n            table,\n            records.map((r) => r.id),\n            (order as IRecordInsertOrderRo).viewId\n          )\n        : undefined;\n\n    const cellContexts = await this.prismaService.$tx(async () => {\n      if (order != null) {\n        const { viewId, anchorId, position } = order as IRecordInsertOrderRo;\n        await this.viewOpenApiService.updateRecordOrders(table, viewId, {\n          anchorId,\n          position,\n          recordIds: records.map((r) => r.id),\n        });\n      }\n\n      const typecastRecords = await this.shared.validateFieldsAndTypecast(\n        table,\n        scopedRecords,\n        fieldKeyType,\n        typecast\n      );\n\n      const preparedRecords = await this.systemFieldService.getModifiedSystemOpsMap(\n        table,\n        fieldKeyType,\n        typecastRecords\n      );\n\n      const projectionFields = this.collectProjectionFields(preparedRecords);\n      const projectionByTable = this.toProjectionByTable(table, fieldKeyType, projectionFields);\n      const ctxs = await this.shared.generateCellContexts(\n        table,\n        fieldKeyType,\n        preparedRecords,\n        false,\n        projectionFields\n      );\n      // Publish computed/link/lookup changes with old/new by wrapping the base update\n      await this.computedOrchestrator.computeCellChangesForRecords(\n        tableId,\n        ctxs,\n        async (tables) => {\n          const linkDerivate = await this.linkService.planDerivateByLink(\n            tableId,\n            ctxs,\n            undefined,\n            tables,\n            projectionByTable\n          );\n          const changes = this.shared.compressAndFilterChanges(table, ctxs);\n          const opsMap: IOpsMap = this.shared.formatChangesToOps(changes);\n          const linkOpsMap: IOpsMap | undefined = linkDerivate?.cellChanges?.length\n            ? this.shared.formatChangesToOps(linkDerivate.cellChanges)\n            : undefined;\n          // Compose base ops with link-derived ops so symmetric link updates are also published\n          const composedOpsMap: IOpsMap = composeOpMaps([opsMap, linkOpsMap]);\n\n          await this.linkService.commitForeignKeyChanges(\n            tableId,\n            linkDerivate?.fkRecordMap,\n            tables\n          );\n          await this.batchService.updateRecords(composedOpsMap, undefined, undefined, tables);\n        }\n      );\n      return ctxs;\n    });\n\n    const recordIds = records.map((r) => r.id);\n    if (effectiveWindowId) {\n      const orderIndexesAfter =\n        order && (await this.recordService.getRecordIndexes(table, recordIds, order.viewId));\n\n      this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_UPDATE, {\n        tableId,\n        windowId: effectiveWindowId,\n        userId: this.cls.get('user.id'),\n        recordIds,\n        fieldIds: fieldIds?.length ? fieldIds : Object.keys(scopedRecords[0]?.fields || {}),\n        cellContexts,\n        orderIndexesBefore,\n        orderIndexesAfter,\n      });\n    }\n\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      tableId,\n      recordIds,\n      undefined,\n      fieldKeyType,\n      undefined,\n      true\n    );\n    return {\n      records: snapshots.map((snapshot) => snapshot.data),\n      cellContexts,\n    };\n  }\n\n  async simpleUpdateRecords(tableId: string, updateRecordsRo: IUpdateRecordsInternalRo) {\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n\n    const { fieldKeyType = FieldKeyType.Name, records, fieldIds } = updateRecordsRo;\n    const scopedRecords = this.filterRecordsByFieldKeys(records, fieldIds);\n    const preparedRecords = await this.systemFieldService.getModifiedSystemOpsMap(\n      table,\n      fieldKeyType,\n      scopedRecords\n    );\n\n    const projectionFields = this.collectProjectionFields(preparedRecords);\n    const projectionByTable = this.toProjectionByTable(table, fieldKeyType, projectionFields);\n    const cellContexts = await this.shared.generateCellContexts(\n      table,\n      fieldKeyType,\n      preparedRecords,\n      false,\n      projectionFields\n    );\n    await this.computedOrchestrator.computeCellChangesForRecords(\n      tableId,\n      cellContexts,\n      async (tables) => {\n        const linkDerivate = await this.linkService.planDerivateByLink(\n          tableId,\n          cellContexts,\n          undefined,\n          tables,\n          projectionByTable\n        );\n        const changes = this.shared.compressAndFilterChanges(table, cellContexts);\n        const opsMap: IOpsMap = this.shared.formatChangesToOps(changes);\n        const linkOpsMap: IOpsMap | undefined = linkDerivate?.cellChanges?.length\n          ? this.shared.formatChangesToOps(linkDerivate.cellChanges)\n          : undefined;\n        const composedOpsMap: IOpsMap = composeOpMaps([opsMap, linkOpsMap]);\n\n        await this.linkService.commitForeignKeyChanges(tableId, linkDerivate?.fkRecordMap, tables);\n        await this.batchService.updateRecords(composedOpsMap, undefined, undefined, tables);\n      }\n    );\n    return cellContexts;\n  }\n\n  private filterRecordsByFieldKeys<\n    T extends { fields: Record<string, unknown> } & Record<string, unknown>,\n  >(records: T[], fieldKeys?: string[]): T[] {\n    if (!fieldKeys?.length) {\n      return records;\n    }\n    const keySet = new Set(fieldKeys);\n    return records.map((record) => {\n      const filteredFields: Record<string, unknown> = {};\n      let same = true;\n      for (const [key, value] of Object.entries(record.fields)) {\n        if (keySet.has(key)) {\n          filteredFields[key] = value;\n        } else {\n          same = false;\n        }\n      }\n      if (same) {\n        return record;\n      }\n      return {\n        ...record,\n        fields: filteredFields,\n      } as T;\n    });\n  }\n\n  private collectProjectionFields(records: { fields: Record<string, unknown> }[]): string[] {\n    const projection = new Set<string>();\n    records.forEach((record) => {\n      Object.keys(record.fields).forEach((fieldKey) => projection.add(fieldKey));\n    });\n    return Array.from(projection);\n  }\n\n  private toProjectionByTable(\n    table: TableDomain,\n    fieldKeyType: FieldKeyType,\n    projectionFields: string[]\n  ): Record<string, string[]> | undefined {\n    if (!projectionFields.length) {\n      return undefined;\n    }\n    const fieldsMap = table.getFieldsMap(fieldKeyType);\n    const ids = projectionFields.reduce<Set<string>>((acc, key) => {\n      const field = fieldsMap.get(key);\n      if (field) {\n        acc.add(field.id);\n      }\n      return acc;\n    }, new Set<string>());\n    return ids.size ? { [table.id]: Array.from(ids) } : undefined;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-permission.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { Knex } from 'knex';\n\nexport type IWrapViewQuery = {\n  keepPrimaryKey?: boolean;\n  viewId?: string;\n};\n\nexport type IRecordReadQuerySource = {\n  tableName: string;\n  cteName: string;\n  cteSql: string;\n  enabledFieldIds?: string[];\n};\n\n@Injectable()\nexport class RecordPermissionService {\n  async getReadQuerySource(\n    _tableId: string,\n    _query?: IWrapViewQuery\n  ): Promise<IRecordReadQuerySource | undefined> {\n    return undefined;\n  }\n\n  async wrapView(\n    _tableId: string,\n    builder: Knex.QueryBuilder,\n    _query?: IWrapViewQuery\n  ): Promise<{ viewCte?: string; builder: Knex.QueryBuilder; enabledFieldIds?: string[] }> {\n    return {\n      viewCte: undefined,\n      builder,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record-query.service.ts",
    "content": "// TODO: move record service read related to record-query.service.ts\n\nimport { Injectable, Logger } from '@nestjs/common';\nimport { TableDomain, type IRecord } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { Timing } from '../../utils/timing';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByRaw, fieldCore2FieldInstance } from '../field/model/factory';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from './query-builder';\n\n/**\n * Service for querying record data\n * This service is separated from RecordService to avoid circular dependencies\n */\n@Injectable()\nexport class RecordQueryService {\n  private readonly logger = new Logger(RecordQueryService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder\n  ) {}\n\n  /**\n   * Get the database column name to query for a field\n   * For lookup formula fields, use the standard field name\n   */\n  private getQueryColumnName(field: IFieldInstance): string {\n    return field.dbFieldName;\n  }\n  /**\n   * Get record snapshots in bulk by record IDs\n   * This is a simplified version of RecordService.getSnapshotBulk for internal use\n   */\n  @Timing()\n  async getSnapshotBulk(\n    table: TableDomain,\n    recordIds: string[]\n  ): Promise<{ id: string; data: IRecord }[]> {\n    if (recordIds.length === 0) {\n      return [];\n    }\n\n    try {\n      // Get table info\n\n      const { qb: queryBuilder } = await this.recordQueryBuilder.createRecordQueryBuilder(\n        table.dbTableName,\n        {\n          tableId: table.id,\n          viewId: undefined,\n          useQueryModel: true,\n          restrictRecordIds: recordIds,\n        }\n      );\n      const sql = queryBuilder.whereIn('__id', recordIds).toQuery();\n\n      // Query records from database\n\n      this.logger.debug(`Querying records: ${sql}`);\n\n      const rawRecords = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ [key: string]: unknown }[]>(sql);\n\n      const fields = table.fieldList.map((f) => fieldCore2FieldInstance(f));\n\n      // Convert raw records to IRecord format\n      const snapshots: { id: string; data: IRecord }[] = [];\n\n      for (const rawRecord of rawRecords) {\n        const recordId = rawRecord.__id as string;\n        const createdTime = rawRecord.__created_time as string;\n        const lastModifiedTime = rawRecord.__last_modified_time as string;\n\n        const recordFields: { [fieldId: string]: unknown } = {};\n\n        // Convert database values to cell values\n        for (const field of fields) {\n          const dbValue = rawRecord[this.getQueryColumnName(field)];\n          const cellValue = field.convertDBValue2CellValue(dbValue);\n          recordFields[field.id] = cellValue;\n        }\n\n        const record: IRecord = {\n          id: recordId,\n          fields: recordFields,\n          createdTime,\n          lastModifiedTime,\n          createdBy: 'system', // Simplified for internal use\n          lastModifiedBy: 'system', // Simplified for internal use\n        };\n\n        snapshots.push({\n          id: recordId,\n          data: record,\n        });\n      }\n\n      return snapshots;\n    } catch (error) {\n      this.logger.error(`Failed to get snapshots for table ${table.id}: ${error}`);\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { AttachmentsStorageModule } from '../attachments/attachments-storage.module';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { TableIndexService } from '../table/table-index.service';\nimport { RecordQueryBuilderModule } from './query-builder';\nimport { RecordPermissionService } from './record-permission.service';\nimport { RecordQueryService } from './record-query.service';\nimport { RecordService } from './record.service';\nimport { UserNameListener } from './user-name.listener.service';\n\n@Module({\n  imports: [CalculationModule, AttachmentsStorageModule, RecordQueryBuilderModule],\n  providers: [\n    UserNameListener,\n    RecordService,\n    RecordQueryService,\n    DbProvider,\n    TableIndexService,\n    RecordPermissionService,\n  ],\n  exports: [RecordService, RecordQueryService, RecordPermissionService],\n})\nexport class RecordModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { RecordModule } from './record.module';\nimport { RecordService } from './record.service';\n\ndescribe('RecordService', () => {\n  let service: RecordService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, RecordModule],\n    }).compile();\n\n    service = module.get<RecordService>(RecordService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/record.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Injectable, Logger } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport { Prisma } from '@prisma/client';\nimport type {\n  CreatedByFieldCore,\n  FieldCore,\n  IAttachmentCellValue,\n  IColumnMeta,\n  IExtraResult,\n  IFilter,\n  IFilterItem,\n  IFilterSet,\n  IGridColumnMeta,\n  IGroup,\n  ILinkFieldOptions,\n  ILinkCellValue,\n  IRecord,\n  ISnapshotBase,\n  ISortItem,\n} from '@teable/core';\nimport {\n  and,\n  CellFormat,\n  CellValueType,\n  DbFieldType,\n  DriverClient,\n  FieldKeyType,\n  FieldType,\n  generateRecordId,\n  HttpErrorCode,\n  identify,\n  IdPrefix,\n  mergeFilter,\n  mergeWithDefaultFilter,\n  mergeWithDefaultSort,\n  or,\n  parseGroup,\n  Relationship,\n  StatisticsFunc,\n  TableDomain,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  CreateRecordAction,\n  ICreateRecordsRo,\n  IGetRecordQuery,\n  IGetRecordsRo,\n  IGroupHeaderPoint,\n  IGroupHeaderRef,\n  IGroupPoint,\n  IGroupPointsVo,\n  IRecordGetCollaboratorsRo,\n  IRecordStatusVo,\n  IRecordsVo,\n  UpdateRecordAction,\n} from '@teable/openapi';\nimport { DEFAULT_MAX_SEARCH_FIELD_COUNT, GroupPointType, UploadType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { get, difference, keyBy, orderBy, uniqBy, toNumber } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../cache/cache.service';\nimport { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { Events } from '../../event-emitter/events';\nimport { RawOpType } from '../../share-db/interface';\nimport type { IClsStore } from '../../types/cls';\nimport { convertValueToStringify, string2Hash } from '../../utils';\nimport { handleDBValidationErrors } from '../../utils/db-validation-error';\nimport { generateFilterItem } from '../../utils/filter';\nimport {\n  generateTableThumbnailPath,\n  getTableThumbnailToken,\n} from '../../utils/generate-thumbnail-path';\nimport { Timing } from '../../utils/timing';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { BatchService } from '../calculation/batch.service';\nimport { DataLoaderService } from '../data-loader/data-loader.service';\nimport type { IVisualTableDefaultField } from '../field/constant';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport { UserFieldDto } from '../field/model/field-dto/user-field.dto';\nimport { TableIndexService } from '../table/table-index.service';\nimport { ROW_ORDER_FIELD_PREFIX } from '../view/constant';\nimport { InjectRecordQueryBuilder, IRecordQueryBuilder } from './query-builder';\nimport { RecordPermissionService } from './record-permission.service';\n\ntype IUserFields = { id: string; dbFieldName: string }[];\ntype IGeneratedColumnMeta = { meta?: { persistedAsGeneratedColumn?: boolean } };\ntype IGeneratedColumnStateRow = {\n  column_name: string;\n  is_generated: string | null;\n};\n\nfunction removeUndefined<T extends Record<string, unknown>>(obj: T) {\n  return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;\n}\n\nexport interface IRecordInnerRo {\n  id: string;\n  fields: Record<string, unknown>;\n  createdBy?: string;\n  lastModifiedBy?: string;\n  createdTime?: string;\n  lastModifiedTime?: string;\n  autoNumber?: number;\n  order?: Record<string, number>; // viewId: index\n}\n\n@Injectable()\nexport class RecordService {\n  private logger = new Logger(RecordService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly batchService: BatchService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly cacheService: CacheService,\n    private readonly attachmentStorageService: AttachmentsStorageService,\n    private readonly recordPermissionService: RecordPermissionService,\n    private readonly tableIndexService: TableIndexService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly dataLoaderService: DataLoaderService,\n    @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder,\n    private readonly eventEmitter: EventEmitter2\n  ) {}\n\n  /**\n   * Get the database column name to query for a field\n   * For lookup formula fields, use the standard field name\n   */\n  private getQueryColumnName(field: IFieldInstance): string {\n    return field.dbFieldName;\n  }\n\n  private async getWritableCreatedTimeFieldNames(\n    dbTableName: string,\n    fields: readonly FieldCore[]\n  ): Promise<Set<string>> {\n    const createdTimeFields = fields.filter(\n      (field) => field.type === FieldType.CreatedTime && !field.isLookup\n    );\n    if (!createdTimeFields.length) {\n      return new Set<string>();\n    }\n\n    const fallbackWritableFieldNames = new Set(\n      createdTimeFields\n        .filter(\n          (field) => (field as IGeneratedColumnMeta).meta?.persistedAsGeneratedColumn !== true\n        )\n        .map((field) => field.dbFieldName)\n    );\n\n    if (this.dbProvider.driver !== DriverClient.Pg) {\n      return fallbackWritableFieldNames;\n    }\n\n    const [schemaName, tableName] = this.dbProvider.splitTableName(dbTableName);\n    const sqlNative = this.knex('information_schema.columns')\n      .select<IGeneratedColumnStateRow[]>('column_name', 'is_generated')\n      .where({\n        table_schema: schemaName,\n        table_name: tableName,\n      })\n      .whereIn(\n        'column_name',\n        createdTimeFields.map((field) => field.dbFieldName)\n      )\n      .toSQL()\n      .toNative();\n\n    const rows = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<IGeneratedColumnStateRow[]>(sqlNative.sql, ...sqlNative.bindings);\n    const columnStateMap = new Map(rows.map((row) => [row.column_name, row.is_generated]));\n\n    return new Set(\n      createdTimeFields\n        .filter((field) => {\n          const isGenerated = columnStateMap.get(field.dbFieldName);\n          if (isGenerated == null) {\n            return fallbackWritableFieldNames.has(field.dbFieldName);\n          }\n          return isGenerated === 'NEVER';\n        })\n        .map((field) => field.dbFieldName)\n    );\n  }\n\n  private dbRecord2RecordFields(\n    record: IRecord['fields'],\n    fields: IFieldInstance[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Id,\n    cellFormat: CellFormat = CellFormat.Json\n  ) {\n    return fields.reduce<IRecord['fields']>((acc, field) => {\n      const fieldNameOrId = field[fieldKeyType];\n      const queryColumnName = this.getQueryColumnName(field);\n      const dbCellValue = record[queryColumnName];\n      const cellValue = field.convertDBValue2CellValue(dbCellValue);\n      if (cellValue != null) {\n        acc[fieldNameOrId] =\n          cellFormat === CellFormat.Text ? field.cellValue2String(cellValue) : cellValue;\n      }\n      return acc;\n    }, {});\n  }\n\n  async getAllRecordCount(dbTableName: string) {\n    const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative();\n\n    const queryResult = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings);\n    return Number(queryResult[0]?.count ?? 0);\n  }\n\n  async getDbValueMatrix(\n    dbTableName: string,\n    userFields: IUserFields,\n    rowIndexFieldNames: string[],\n    createRecordsRo: ICreateRecordsRo\n  ) {\n    const rowCount = await this.getAllRecordCount(dbTableName);\n    const dbValueMatrix: unknown[][] = [];\n    for (let i = 0; i < createRecordsRo.records.length; i++) {\n      const recordData = createRecordsRo.records[i].fields;\n      // 1. collect cellValues\n      const recordValues = userFields.map<unknown>((field) => {\n        const cellValue = recordData[field.id];\n        if (cellValue == null) {\n          return null;\n        }\n        return cellValue;\n      });\n\n      // 2. generate rowIndexValues\n      const rowIndexValues = rowIndexFieldNames.map(() => rowCount + i);\n\n      // 3. generate id, __created_time, __created_by, __version\n      const systemValues = [generateRecordId(), new Date().toISOString(), 'admin', 1];\n\n      dbValueMatrix.push([...recordValues, ...rowIndexValues, ...systemValues]);\n    }\n    return dbValueMatrix;\n  }\n\n  async getDbTableName(tableId: string) {\n    const tableMeta = await this.prismaService\n      .txClient()\n      .tableMeta.findUniqueOrThrow({\n        where: { id: tableId },\n        select: { dbTableName: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Table not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n    return tableMeta.dbTableName;\n  }\n\n  private async getLinkCellIds(tableId: string, field: IFieldInstance, recordId: string) {\n    const prisma = this.prismaService.txClient();\n    const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n\n    const { qb: queryBuilder } = await this.recordQueryBuilder.createRecordQueryBuilder(\n      dbTableName,\n      {\n        tableId,\n        viewId: undefined,\n        restrictRecordIds: [recordId],\n        useQueryModel: true,\n      }\n    );\n    const sql = queryBuilder.where('__id', recordId).toQuery();\n\n    const result = await prisma.$queryRawUnsafe<{ id: string; [key: string]: unknown }[]>(sql);\n    return result\n      .map((item) => {\n        return field.convertDBValue2CellValue(item[field.dbFieldName]) as\n          | ILinkCellValue\n          | ILinkCellValue[];\n      })\n      .filter(Boolean)\n      .flat()\n      .map((item) => item.id);\n  }\n\n  private async buildLinkSelectedSort(\n    queryBuilder: Knex.QueryBuilder,\n    dbTableName: string,\n    filterLinkCellSelected: [string, string]\n  ) {\n    const prisma = this.prismaService.txClient();\n    const [fieldId, recordId] = filterLinkCellSelected;\n    const fieldRaw = await prisma.field\n      .findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      });\n    const field = createFieldInstanceByRaw(fieldRaw);\n    if (!field.isMultipleCellValue) {\n      return;\n    }\n\n    const ids = await this.getLinkCellIds(fieldRaw.tableId, field, recordId);\n    if (!ids.length) {\n      return;\n    }\n\n    // sql capable for sqlite\n    const valuesQuery = ids\n      .map((id, index) => `SELECT ${index + 1} AS sort_order, '${id}' AS id`)\n      .join(' UNION ALL ');\n\n    queryBuilder\n      .with('ordered_ids', this.knex.raw(`${valuesQuery}`))\n      .leftJoin('ordered_ids', function () {\n        this.on(`${dbTableName}.__id`, '=', 'ordered_ids.id');\n      })\n      .orderBy('ordered_ids.sort_order');\n  }\n\n  private isJunctionTable(dbTableName: string) {\n    if (dbTableName.includes('.')) {\n      return dbTableName.split('.')[1].startsWith('junction');\n    }\n    return dbTableName.split('_')[1].startsWith('junction');\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async buildLinkSelectedQuery(\n    queryBuilder: Knex.QueryBuilder,\n    tableId: string,\n    dbTableName: string,\n    alias: string,\n    filterLinkCellSelected: [string, string] | string\n  ) {\n    const prisma = this.prismaService.txClient();\n    const fieldId = Array.isArray(filterLinkCellSelected)\n      ? filterLinkCellSelected[0]\n      : filterLinkCellSelected;\n    const recordId = Array.isArray(filterLinkCellSelected) ? filterLinkCellSelected[1] : undefined;\n\n    const fieldRaw = await prisma.field\n      .findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      });\n\n    const field = createFieldInstanceByRaw(fieldRaw);\n\n    if (field.type !== FieldType.Link) {\n      throw new CustomHttpException(\n        'You can only filter by link field',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.onlyLinkFieldCanBeFiltered',\n          },\n        }\n      );\n    }\n    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName } = field.options;\n    if (foreignTableId !== tableId) {\n      throw new CustomHttpException(\n        'Field is not linked to current table',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.notLinkedToCurrentTable',\n          },\n        }\n      );\n    }\n\n    if (fkHostTableName !== dbTableName) {\n      queryBuilder.leftJoin(\n        `${fkHostTableName}`,\n        `${alias}.__id`,\n        '=',\n        `${fkHostTableName}.${foreignKeyName}`\n      );\n      if (recordId) {\n        queryBuilder.where(`${fkHostTableName}.${selfKeyName}`, recordId);\n        return;\n      }\n      queryBuilder.whereNotNull(`${fkHostTableName}.${foreignKeyName}`);\n      return;\n    }\n\n    if (recordId) {\n      queryBuilder.where(`${alias}.${selfKeyName}`, recordId);\n      return;\n    }\n    queryBuilder.whereNotNull(`${alias}.${selfKeyName}`);\n  }\n\n  async buildLinkCandidateQuery(\n    queryBuilder: Knex.QueryBuilder,\n    tableId: string,\n    filterLinkCellCandidate: [string, string] | string\n  ) {\n    const prisma = this.prismaService.txClient();\n    const fieldId = Array.isArray(filterLinkCellCandidate)\n      ? filterLinkCellCandidate[0]\n      : filterLinkCellCandidate;\n    const recordId = Array.isArray(filterLinkCellCandidate)\n      ? filterLinkCellCandidate[1]\n      : undefined;\n\n    const fieldRaw = await prisma.field\n      .findFirstOrThrow({\n        where: { id: fieldId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      });\n\n    const field = createFieldInstanceByRaw(fieldRaw);\n\n    if (field.type !== FieldType.Link) {\n      throw new CustomHttpException(\n        'You can only filter by link field',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.onlyLinkFieldCanBeFiltered',\n          },\n        }\n      );\n    }\n    const { foreignTableId, fkHostTableName, selfKeyName, foreignKeyName, relationship } =\n      field.options;\n    if (foreignTableId !== tableId) {\n      throw new CustomHttpException(\n        'Field is not linked to current table',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.notLinkedToCurrentTable',\n          },\n        }\n      );\n    }\n    if (relationship === Relationship.OneMany) {\n      if (this.isJunctionTable(fkHostTableName)) {\n        queryBuilder.whereNotIn('__id', function () {\n          this.select(foreignKeyName).from(fkHostTableName);\n          if (recordId) {\n            this.whereNot(selfKeyName, recordId);\n          }\n        });\n      } else {\n        queryBuilder.where(function () {\n          this.whereNull(selfKeyName);\n          if (recordId) {\n            this.orWhere(selfKeyName, recordId);\n          }\n        });\n      }\n    }\n    if (relationship === Relationship.OneOne) {\n      if (selfKeyName === '__id') {\n        queryBuilder.whereNotIn('__id', function () {\n          this.select(foreignKeyName).from(fkHostTableName).whereNotNull(foreignKeyName);\n          if (recordId) {\n            this.whereNot(selfKeyName, recordId);\n          }\n        });\n      } else {\n        queryBuilder.where(function () {\n          this.whereNull(selfKeyName);\n          if (recordId) {\n            this.orWhere(selfKeyName, recordId);\n          }\n        });\n      }\n    }\n  }\n\n  private async getNecessaryFieldMap(\n    tableId: string,\n    filter?: IFilter,\n    orderBy?: ISortItem[],\n    groupBy?: IGroup,\n    search?: [string, string?, boolean?],\n    projection?: string[]\n  ) {\n    if (filter || orderBy?.length || groupBy?.length || search) {\n      // Always load full field metadata so filters can reference denied fields for read,\n      // while projection limits applied later keep them hidden from results.\n      const fields = await this.getFieldsByProjection(tableId, undefined);\n      const allowedSet = projection?.length ? new Set(projection) : undefined;\n      return fields.reduce(\n        (map, field) => {\n          if (allowedSet && !allowedSet.has(field.id)) {\n            return map;\n          }\n          map[field.id] = field;\n          map[field.name] = field;\n          return map;\n        },\n        {} as Record<string, IFieldInstance>\n      );\n    }\n  }\n\n  private async sanitizeFilterByEnabledFields(\n    tableId: string,\n    filter: IFilter | undefined,\n    enabledFieldIds?: string[]\n  ): Promise<IFilter | undefined> {\n    if (!filter || !enabledFieldIds?.length) {\n      return filter;\n    }\n    const fields = await this.dataLoaderService.field.load(tableId);\n    const keyToId = new Map<string, string>();\n    for (const field of fields) {\n      keyToId.set(field.id, field.id);\n      keyToId.set(field.name, field.id);\n      keyToId.set(field.dbFieldName, field.id);\n    }\n    const allowed = new Set(enabledFieldIds);\n\n    const sanitize = (target: IFilter): IFilter | null => {\n      if (!target) {\n        return null;\n      }\n\n      const isFilterGroup = (value: unknown): value is IFilter =>\n        !!value && typeof value === 'object' && 'filterSet' in value;\n\n      const isFilterLeaf = (value: unknown): value is IFilterItem =>\n        !!value && typeof value === 'object' && 'fieldId' in value;\n\n      const sanitizedSet: NonNullable<IFilter>['filterSet'] = [];\n      for (const item of target.filterSet) {\n        if (isFilterGroup(item)) {\n          const nested = sanitize(item);\n          if (nested) {\n            sanitizedSet.push(nested);\n          }\n          continue;\n        }\n\n        if (!isFilterLeaf(item)) {\n          continue;\n        }\n\n        const candidateId = keyToId.get(item.fieldId) ?? item.fieldId;\n        if (!allowed.has(candidateId)) {\n          continue;\n        }\n        sanitizedSet.push({\n          ...item,\n          fieldId: candidateId,\n        });\n      }\n\n      if (sanitizedSet.length === 0) {\n        return null;\n      }\n      return {\n        ...target,\n        filterSet: sanitizedSet,\n      };\n    };\n\n    const sanitized = sanitize(filter);\n    return sanitized ?? undefined;\n  }\n\n  private async getTinyView(tableId: string, viewId?: string) {\n    if (!viewId) {\n      return;\n    }\n\n    return this.prismaService\n      .txClient()\n      .view.findFirstOrThrow({\n        select: { id: true, type: true, filter: true, sort: true, group: true, columnMeta: true },\n        where: { tableId, id: viewId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`View ${viewId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        });\n      });\n  }\n\n  public parseSearch(\n    search: [string, string?, boolean?],\n    fieldMap?: Record<string, IFieldInstance>\n  ): [string, string?, boolean?] {\n    const [searchValue, fieldId, hideNotMatchRow] = search;\n\n    if (!fieldMap) {\n      throw new CustomHttpException(\n        'fieldMap is required when search is set',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.fieldMapRequired',\n          },\n        }\n      );\n    }\n\n    if (!fieldId) {\n      return [searchValue, fieldId, hideNotMatchRow];\n    }\n\n    const fieldIds = fieldId?.split(',');\n\n    fieldIds.forEach((id) => {\n      const field = fieldMap[id];\n      if (!field) {\n        throw new CustomHttpException(`Field ${fieldId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.field.notFound',\n          },\n        });\n      }\n    });\n\n    return [searchValue, fieldId, hideNotMatchRow];\n  }\n\n  private stringifyRawQueryDebugPayload(payload: unknown): string {\n    try {\n      return JSON.stringify(payload, (_, value) =>\n        typeof value === 'bigint' ? value.toString() : value\n      );\n    } catch (error) {\n      const reason = error instanceof Error ? error.message : String(error);\n      this.logger.warn(`Failed to stringify raw query debug payload: ${reason}`);\n      return '[raw query debug payload: <unserializable>]';\n    }\n  }\n\n  private handleRawQueryError(\n    error: unknown,\n    sql: string,\n    debugContext: Record<string, unknown>\n  ): never {\n    const context = { sql, ...debugContext };\n    const contextString = this.stringifyRawQueryDebugPayload(context);\n    if (error instanceof Prisma.PrismaClientKnownRequestError) {\n      error.message = `${error.message}\\nContext: ${contextString}`;\n      Object.assign(error, context);\n      this.logger.error(\n        `Raw query known request error. Context: ${contextString}`,\n        error.stack ?? undefined\n      );\n      throw error;\n    }\n    this.logger.error(\n      `Raw query unexpected error. message: ${(error as Error)?.message}. Context: ${contextString}`,\n      (error as Error)?.stack\n    );\n    if (error instanceof Error) {\n      error.message = `${error.message}\\nContext: ${contextString}`;\n      Object.assign(error, context);\n    }\n    throw error;\n  }\n\n  async prepareQuery(\n    tableId: string,\n    query: Pick<\n      IGetRecordsRo,\n      | 'viewId'\n      | 'orderBy'\n      | 'groupBy'\n      | 'filter'\n      | 'search'\n      | 'filterLinkCellSelected'\n      | 'ignoreViewQuery'\n    >\n  ) {\n    const viewId = query.ignoreViewQuery ? undefined : query.viewId;\n    const {\n      orderBy: extraOrderBy,\n      groupBy: extraGroupBy,\n      filter: extraFilter,\n      search: originSearch,\n    } = query;\n    const dbTableName = await this.getDbTableName(tableId);\n    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      {\n        viewId: query.viewId,\n        keepPrimaryKey: Boolean(query.filterLinkCellSelected),\n      }\n    );\n\n    const view = await this.getTinyView(tableId, viewId);\n\n    const mergedFilter = mergeWithDefaultFilter(view?.filter, extraFilter);\n    const filter = await this.sanitizeFilterByEnabledFields(tableId, mergedFilter, enabledFieldIds);\n    const orderBy = mergeWithDefaultSort(view?.sort, extraOrderBy);\n    const groupBy = parseGroup(extraGroupBy);\n    const fieldMap = await this.getNecessaryFieldMap(\n      tableId,\n      filter,\n      orderBy,\n      groupBy,\n      originSearch,\n      enabledFieldIds\n    );\n\n    const search = originSearch ? this.parseSearch(originSearch, fieldMap) : undefined;\n\n    return {\n      permissionBuilder: builder,\n      dbTableName,\n      viewCte,\n      filter,\n      search,\n      orderBy,\n      groupBy,\n      fieldMap,\n      enabledFieldIds,\n    };\n  }\n\n  async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) {\n    if (!viewId) {\n      return '__auto_number';\n    }\n    const columnName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;\n    const exists = await this.dbProvider.checkColumnExist(\n      dbTableName,\n      columnName,\n      this.prismaService.txClient()\n    );\n\n    if (exists) {\n      return columnName;\n    }\n    return '__auto_number';\n  }\n\n  /**\n   * Builds a query based on filtering and sorting criteria.\n   *\n   * This method creates a `Knex` query builder that constructs SQL queries based on the provided\n   * filtering and sorting parameters. It also takes into account the context of the current user,\n   * which is crucial for ensuring the security and relevance of data access.\n   *\n   * @param {string} tableId - The unique identifier of the table to determine the target of the query.\n   * @param {Pick<IGetRecordsRo, 'viewId' | 'orderBy' | 'filter' | 'filterLinkCellCandidate'>} query - An object of query parameters, including view ID, sorting rules, filtering conditions, etc.\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async buildFilterSortQuery(\n    tableId: string,\n    query: Pick<\n      IGetRecordsRo,\n      | 'viewId'\n      | 'ignoreViewQuery'\n      | 'orderBy'\n      | 'groupBy'\n      | 'filter'\n      | 'search'\n      | 'filterLinkCellCandidate'\n      | 'filterLinkCellSelected'\n      | 'collapsedGroupIds'\n      | 'selectedRecordIds'\n      | 'skip'\n      | 'take'\n    >,\n    useQueryModel = false\n  ) {\n    // Prepare the base query builder, filtering conditions, sorting rules, grouping rules and field mapping\n    const {\n      permissionBuilder,\n      dbTableName,\n      viewCte,\n      filter,\n      search,\n      orderBy,\n      groupBy,\n      fieldMap,\n      enabledFieldIds,\n    } = await this.prepareQuery(tableId, query);\n\n    const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId);\n\n    const restrictRecordIds =\n      query.selectedRecordIds && !query.filterLinkCellCandidate\n        ? query.selectedRecordIds\n        : undefined;\n\n    // Retrieve the current user's ID to build user-related query conditions\n    const currentUserId = this.cls.get('user.id');\n    const projectionIds = fieldMap\n      ? Array.from(new Set(Object.values(fieldMap).map((f) => f.id))).filter(\n          (id) => !enabledFieldIds || enabledFieldIds.includes(id)\n        )\n      : [];\n\n    const { qb, alias, selectionMap } = await this.recordQueryBuilder.createRecordQueryBuilder(\n      viewCte ?? dbTableName,\n      {\n        tableId,\n        viewId: query.viewId,\n        filter,\n        currentUserId,\n        sort: [...(groupBy ?? []), ...(orderBy ?? [])],\n        // Only select fields required by filter/order/search to avoid touching unrelated columns\n        projection: projectionIds,\n        useQueryModel,\n        limit: query.take,\n        offset: query.skip,\n        hasSearch: Boolean(search?.[2]),\n        defaultOrderField: basicSortIndex,\n        restrictRecordIds,\n        builder: permissionBuilder,\n      }\n    );\n\n    if (query.filterLinkCellSelected && query.filterLinkCellCandidate) {\n      throw new CustomHttpException(\n        'filterLinkCellSelected and filterLinkCellCandidate can not be set at the same time',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.filterLinkCellQueryConflict',\n          },\n        }\n      );\n    }\n\n    if (query.selectedRecordIds) {\n      query.filterLinkCellCandidate\n        ? qb.whereNotIn(`${alias}.__id`, query.selectedRecordIds)\n        : qb.whereIn(`${alias}.__id`, query.selectedRecordIds);\n    }\n\n    if (query.filterLinkCellCandidate) {\n      await this.buildLinkCandidateQuery(qb, tableId, query.filterLinkCellCandidate);\n    }\n\n    if (query.filterLinkCellSelected) {\n      await this.buildLinkSelectedQuery(\n        qb,\n        tableId,\n        dbTableName,\n        alias,\n        query.filterLinkCellSelected\n      );\n    }\n\n    if (search && search[2] && fieldMap) {\n      const searchFields = await this.getSearchFields(\n        fieldMap,\n        search,\n        query?.viewId,\n        enabledFieldIds\n      );\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      qb.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });\n      });\n    }\n\n    // ignore sorting when filterLinkCellSelected is set\n    if (query.filterLinkCellSelected && Array.isArray(query.filterLinkCellSelected)) {\n      await this.buildLinkSelectedSort(qb, alias, query.filterLinkCellSelected);\n    } else {\n      // view sorting added by default\n      qb.orderBy(`${alias}.${basicSortIndex}`, 'asc');\n    }\n\n    // If you return `queryBuilder` directly and use `await` to receive it,\n    // it will perform a query DB operation, which we obviously don't want to see here\n    return { queryBuilder: qb, dbTableName, viewCte, alias };\n  }\n\n  convertProjection(fieldKeys?: string[]) {\n    return fieldKeys?.reduce<Record<string, boolean>>((acc, cur) => {\n      acc[cur] = true;\n      return acc;\n    }, {});\n  }\n\n  private async convertEnabledFieldIdsToProjection(\n    tableId: string,\n    enabledFieldIds?: string[],\n    fieldKeyType: FieldKeyType = FieldKeyType.Id\n  ) {\n    if (!enabledFieldIds?.length) {\n      return undefined;\n    }\n\n    if (fieldKeyType === FieldKeyType.Id) {\n      return this.convertProjection(enabledFieldIds);\n    }\n\n    const fields = await this.dataLoaderService.field.load(tableId, {\n      id: enabledFieldIds,\n    });\n    if (!fields.length) {\n      return undefined;\n    }\n\n    const fieldKeys = fields\n      .map((field) => field[fieldKeyType] as string | undefined)\n      .filter((key): key is string => Boolean(key));\n\n    return fieldKeys.length ? this.convertProjection(fieldKeys) : undefined;\n  }\n\n  async getRecordsById(\n    tableId: string,\n    recordIds: string[],\n    withPermission = true,\n    useQueryModel = true\n  ): Promise<IRecordsVo> {\n    const recordSnapshot = await this[\n      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'\n    ](tableId, recordIds, undefined, FieldKeyType.Id, undefined, useQueryModel);\n\n    if (!recordSnapshot.length) {\n      throw new CustomHttpException('Can not get record', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.record.notFound',\n        },\n      });\n    }\n\n    return {\n      records: recordSnapshot.map((r) => r.data),\n    };\n  }\n\n  private async getViewProjection(\n    tableId: string,\n    query: IGetRecordsRo\n  ): Promise<Record<string, boolean> | undefined> {\n    const viewId = query.viewId;\n    if (!viewId) {\n      return;\n    }\n\n    const fieldKeyType = query.fieldKeyType || FieldKeyType.Name;\n    const view = await this.prismaService.txClient().view.findFirstOrThrow({\n      where: { id: viewId, deletedTime: null },\n      select: { id: true, columnMeta: true },\n    });\n\n    const columnMeta = JSON.parse(view.columnMeta) as IColumnMeta;\n\n    const useVisible = Object.values(columnMeta).some((column) => 'visible' in column);\n    const useHidden = Object.values(columnMeta).some((column) => 'hidden' in column);\n\n    if (!useVisible && !useHidden) {\n      return;\n    }\n\n    const fieldRaws = await this.dataLoaderService.field.load(tableId);\n\n    const fieldMap = keyBy(fieldRaws, 'id');\n\n    const projection = Object.entries(columnMeta).reduce<Record<string, boolean>>(\n      (acc, [fieldId, column]) => {\n        const field = fieldMap[fieldId];\n        if (!field) return acc;\n\n        const fieldKey = field[fieldKeyType];\n\n        if (useVisible) {\n          if ('visible' in column && column.visible) {\n            acc[fieldKey] = true;\n          }\n        } else if (useHidden) {\n          if (!('hidden' in column) || !column.hidden) {\n            acc[fieldKey] = true;\n          }\n        } else {\n          acc[fieldKey] = true;\n        }\n\n        return acc;\n      },\n      {}\n    );\n\n    return Object.keys(projection).length > 0 ? projection : undefined;\n  }\n\n  async getRecords(\n    tableId: string,\n    query: IGetRecordsRo,\n    useQueryModel = false\n  ): Promise<IRecordsVo> {\n    const queryResult = await this.getDocIdsByQuery(\n      tableId,\n      {\n        ignoreViewQuery: query.ignoreViewQuery ?? false,\n        viewId: query.viewId,\n        skip: query.skip,\n        take: query.take,\n        filter: query.filter,\n        orderBy: query.orderBy,\n        search: query.search,\n        groupBy: query.groupBy,\n        filterLinkCellCandidate: query.filterLinkCellCandidate,\n        filterLinkCellSelected: query.filterLinkCellSelected,\n        selectedRecordIds: query.selectedRecordIds,\n      },\n      useQueryModel\n    );\n\n    const projection = query.projection\n      ? this.convertProjection(query.projection)\n      : await this.getViewProjection(tableId, query);\n\n    const recordSnapshot = await this.getSnapshotBulkWithPermission(\n      tableId,\n      queryResult.ids,\n      projection,\n      query.fieldKeyType || FieldKeyType.Name,\n      query.cellFormat,\n      useQueryModel\n    );\n\n    return {\n      records: recordSnapshot.map((r) => r.data),\n      extra: queryResult.extra,\n    };\n  }\n\n  async getRecord(\n    tableId: string,\n    recordId: string,\n    query: IGetRecordQuery,\n    withPermission = true,\n    useQueryModel = false\n  ): Promise<IRecord> {\n    const { projection, fieldKeyType = FieldKeyType.Name, cellFormat } = query;\n    const recordSnapshot = await this[\n      withPermission ? 'getSnapshotBulkWithPermission' : 'getSnapshotBulk'\n    ](\n      tableId,\n      [recordId],\n      this.convertProjection(projection),\n      fieldKeyType,\n      cellFormat,\n      useQueryModel\n    );\n\n    if (!recordSnapshot.length) {\n      throw new CustomHttpException('Can not get record', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.record.notFound',\n        },\n      });\n    }\n\n    return recordSnapshot[0].data;\n  }\n\n  async getCellValue(tableId: string, recordId: string, fieldId: string) {\n    const record = await this.getRecord(tableId, recordId, {\n      projection: [fieldId],\n      fieldKeyType: FieldKeyType.Id,\n    });\n    return record.fields[fieldId];\n  }\n\n  async getMaxRecordOrder(dbTableName: string) {\n    const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative();\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings);\n\n    return Number(result[0]?.max ?? 0) + 1;\n  }\n\n  async batchDeleteRecords(tableId: string, recordIds: string[]) {\n    const dbTableName = await this.getDbTableName(tableId);\n    // get version by recordIds, __id as id, __version as version\n    const nativeQuery = this.knex(dbTableName)\n      .select('__id as id', '__version as version')\n      .whereIn('__id', recordIds)\n      .toQuery();\n    const recordRaw = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery);\n\n    if (recordIds.length !== recordRaw.length) {\n      throw new CustomHttpException(\n        `Some records to be deleted cannot be found, ids: ${difference(\n          recordIds,\n          recordRaw.map((r) => r.id)\n        ).join(',')}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.record.deletedIdsNotFound',\n          },\n        }\n      );\n    }\n\n    const recordRawMap = keyBy(recordRaw, 'id');\n\n    const dataList = recordIds.map((recordId) => ({\n      docId: recordId,\n      version: recordRawMap[recordId].version,\n    }));\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.Record, dataList);\n\n    await this.batchDel(tableId, recordIds);\n  }\n\n  private async getViewIndexColumns(dbTableName: string) {\n    const columnInfoQuery = this.dbProvider.columnInfo(dbTableName);\n    const columns = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery);\n    return columns\n      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))\n      .map((column) => column.name);\n  }\n\n  @Timing()\n  async getRecordIndexes(\n    table: TableDomain,\n    recordIds: string[],\n    viewId?: string\n  ): Promise<Record<string, number>[] | undefined> {\n    const dbTableName = table.dbTableName;\n    const allViewIndexColumns = await this.getViewIndexColumns(dbTableName);\n    const viewIndexColumns = viewId\n      ? (() => {\n          const viewIndexColumns = allViewIndexColumns.filter((column) => column.endsWith(viewId));\n          return viewIndexColumns.length === 0 ? ['__auto_number'] : viewIndexColumns;\n        })()\n      : allViewIndexColumns;\n\n    if (!viewIndexColumns.length) {\n      return;\n    }\n\n    // get all viewIndexColumns value for __id in recordIds\n    const indexQuery = this.knex(dbTableName)\n      .select(\n        viewIndexColumns.reduce<Record<string, string>>((acc, columnName) => {\n          if (columnName === '__auto_number') {\n            acc[viewId as string] = '__auto_number';\n            return acc;\n          }\n          const theViewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);\n          acc[theViewId] = columnName;\n          return acc;\n        }, {})\n      )\n      .select('__id')\n      .whereIn('__id', recordIds)\n      .toQuery();\n    const indexValues = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<Record<string, number>[]>(indexQuery);\n\n    const indexMap = indexValues.reduce<Record<string, Record<string, number>>>((map, cur) => {\n      const id = cur.__id;\n      delete cur.__id;\n      map[id] = cur;\n      return map;\n    }, {});\n\n    return recordIds.map((recordId) => indexMap[recordId]);\n  }\n\n  async updateRecordIndexes(\n    tableId: string,\n    recordsWithOrder: {\n      id: string;\n      order?: Record<string, number>;\n    }[]\n  ) {\n    const dbTableName = await this.getDbTableName(tableId);\n    const viewIndexColumns = await this.getViewIndexColumns(dbTableName);\n    if (!viewIndexColumns.length) {\n      return;\n    }\n\n    const updateRecordSqls = recordsWithOrder\n      .map((record) => {\n        const order = record.order;\n        const orderFields = viewIndexColumns.reduce<Record<string, number>>((acc, columnName) => {\n          const viewId = columnName.substring(ROW_ORDER_FIELD_PREFIX.length + 1);\n          const index = order?.[viewId];\n          if (index != null) {\n            acc[columnName] = index;\n          }\n          return acc;\n        }, {});\n\n        if (!order || Object.keys(orderFields).length === 0) {\n          return;\n        }\n\n        return this.knex(dbTableName).update(orderFields).where('__id', record.id).toQuery();\n      })\n      .filter(Boolean) as string[];\n\n    for (const sql of updateRecordSqls) {\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n  }\n\n  @Timing()\n  async batchCreateRecords(\n    table: TableDomain,\n    records: IRecordInnerRo[],\n    fieldKeyType: FieldKeyType,\n    fields: readonly FieldCore[]\n  ) {\n    const snapshots = await this.createBatch(table, records, fieldKeyType, fields);\n\n    const dataList = snapshots.map((snapshot) => ({\n      docId: snapshot.__id,\n      version: snapshot.__version == null ? 0 : snapshot.__version - 1,\n    }));\n\n    this.batchService.saveRawOps(table.id, RawOpType.Create, IdPrefix.Record, dataList);\n  }\n\n  @Timing()\n  async createRecordsOnlySql(\n    table: TableDomain,\n    records: {\n      fields: Record<string, unknown>;\n    }[]\n  ) {\n    const user = this.cls.get('user');\n    const userId = user.id;\n    await this.creditCheck(table.id);\n    const dbTableName = table.dbTableName;\n    const fields = await this.getFieldsByProjection(table.id);\n    const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames(\n      dbTableName,\n      fields\n    );\n    const auditUserValue =\n      user &&\n      UserFieldDto.fullAvatarUrl({\n        id: user.id,\n        title: user.name,\n        email: user.email,\n      });\n    const createdByFields = fields.filter(\n      (f) => f.type === FieldType.CreatedBy && f.shouldPersistAuditValue?.()\n    ) as IFieldInstance[];\n    const fieldInstanceMap = fields.reduce(\n      (map, curField) => {\n        map[curField.id] = curField;\n        return map;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n\n    const newRecords = records.map((record) => {\n      const createdTime =\n        writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined;\n      const fieldsValues: Record<string, unknown> = {};\n      Object.entries(record.fields).forEach(([fieldId, value]) => {\n        const fieldInstance = fieldInstanceMap[fieldId];\n        fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value);\n      });\n      if (auditUserValue && createdByFields.length) {\n        createdByFields.forEach((field) => {\n          fieldsValues[field.dbFieldName] = field.convertCellValue2DBValue({\n            ...auditUserValue,\n          });\n        });\n      }\n      writableCreatedTimeFieldNames.forEach((dbFieldName) => {\n        if (createdTime != null) {\n          fieldsValues[dbFieldName] = createdTime;\n        }\n      });\n      return removeUndefined({\n        __id: generateRecordId(),\n        __created_by: userId,\n        __created_time: createdTime,\n        __version: 1,\n        ...fieldsValues,\n      });\n    });\n    const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords);\n    await this.prismaService.txClient().$executeRawUnsafe(sql);\n  }\n\n  async creditCheck(tableId: string) {\n    if (!this.thresholdConfig.maxFreeRowLimit) {\n      return;\n    }\n\n    const table = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } },\n    });\n\n    const rowCount = await this.getAllRecordCount(table.dbTableName);\n\n    const maxRowCount =\n      table.base.space.credit == null\n        ? this.thresholdConfig.maxFreeRowLimit\n        : table.base.space.credit;\n\n    if (rowCount >= maxRowCount) {\n      this.logger.log(`Exceed row count: ${maxRowCount}`, 'creditCheck');\n      throw new CustomHttpException(\n        `Exceed max row limit: ${maxRowCount}, please contact us to increase the limit`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.billing.exceedMaxRowLimit',\n            context: {\n              maxRowCount,\n            },\n          },\n        }\n      );\n    }\n  }\n\n  private async getAllViewIndexesField(dbTableName: string) {\n    const query = this.dbProvider.columnInfo(dbTableName);\n    const columns = await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(query);\n    return columns\n      .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX))\n      .map((column) => column.name)\n      .reduce<{ [viewId: string]: string }>((acc, cur) => {\n        const viewId = cur.substring(ROW_ORDER_FIELD_PREFIX.length + 1);\n        acc[viewId] = cur;\n        return acc;\n      }, {});\n  }\n\n  private hasPersistedLinkColumn(field: FieldCore) {\n    if (field.type !== FieldType.Link) {\n      return true;\n    }\n\n    const options = field.options as ILinkFieldOptions | undefined;\n    if (!options) {\n      return true;\n    }\n\n    const inferredForeignKeyName =\n      options.foreignKeyName ??\n      (options.relationship === Relationship.ManyOne || options.relationship === Relationship.OneOne\n        ? field.dbFieldName\n        : undefined);\n    const inferredSelfKeyName =\n      options.selfKeyName ??\n      (options.relationship === Relationship.OneMany && options.isOneWay === false\n        ? field.dbFieldName\n        : undefined);\n\n    return (\n      field.dbFieldName !== inferredForeignKeyName && field.dbFieldName !== inferredSelfKeyName\n    );\n  }\n\n  private async createBatch(\n    table: TableDomain,\n    records: IRecordInnerRo[],\n    fieldKeyType: FieldKeyType,\n    fields: readonly FieldCore[]\n  ) {\n    const userId = this.cls.get('user.id');\n    await this.creditCheck(table.id);\n\n    const { dbTableName, name: tableName } = table;\n    const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);\n    const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames(\n      dbTableName,\n      fields\n    );\n\n    const views = await this.prismaService.txClient().view.findMany({\n      where: { tableId: table.id, deletedTime: null },\n      select: { id: true },\n    });\n\n    const allViewIndexes = await this.getAllViewIndexesField(dbTableName);\n\n    const validationFields = fields\n      .filter((f) => !f.isComputed)\n      .filter((field) => field.notNull || field.unique)\n      .filter((field) => this.hasPersistedLinkColumn(field));\n\n    const user = this.cls.get('user');\n    const auditUserValue =\n      user &&\n      UserFieldDto.fullAvatarUrl({\n        id: user.id,\n        title: user.name,\n        email: user.email,\n      });\n    const createdByFields = fields.filter(\n      (f) => f.type === FieldType.CreatedBy && (f as CreatedByFieldCore).shouldPersistAuditValue?.()\n    );\n    const cloneAuditUserValue = () => (auditUserValue ? { ...auditUserValue } : null);\n    const sanitizeAuditUserValue = () => {\n      const cloned = cloneAuditUserValue();\n      if (cloned && typeof cloned === 'object' && 'avatarUrl' in cloned) {\n        // Avatar URLs are derived; strip before persistence to keep storage lean\n        delete (cloned as { avatarUrl?: string }).avatarUrl;\n      }\n      return cloned;\n    };\n\n    const snapshots = records\n      .map((record, i) =>\n        views.reduce<{ [viewIndexFieldName: string]: number }>((pre, cur) => {\n          const viewIndexFieldName = allViewIndexes[cur.id];\n          const recordViewIndex = record.order?.[cur.id];\n          if (!viewIndexFieldName) {\n            return pre;\n          }\n          if (recordViewIndex) {\n            pre[viewIndexFieldName] = recordViewIndex;\n          } else {\n            pre[viewIndexFieldName] = maxRecordOrder + i;\n          }\n          return pre;\n        }, {})\n      )\n      .map((order, i) => {\n        const snapshot = records[i];\n        const fields = snapshot.fields;\n        const createdTime =\n          snapshot.createdTime ??\n          (writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined);\n\n        const dbFieldValueMap = validationFields.reduce(\n          (map, field) => {\n            const dbFieldName = field.dbFieldName;\n            const fieldKey = field[fieldKeyType];\n            const cellValue = fields[fieldKey];\n\n            map[dbFieldName] = cellValue;\n            return map;\n          },\n          {} as Record<string, unknown>\n        );\n        const auditFieldValues: Record<string, unknown> = {};\n\n        if (auditUserValue && createdByFields.length) {\n          createdByFields.forEach((field) => {\n            auditFieldValues[field.dbFieldName] = sanitizeAuditUserValue();\n          });\n        }\n\n        const createdTimeFieldValues = Array.from(writableCreatedTimeFieldNames).reduce(\n          (map, dbFieldName) => {\n            if (createdTime != null) {\n              map[dbFieldName] = createdTime;\n            }\n            return map;\n          },\n          {} as Record<string, unknown>\n        );\n\n        return removeUndefined({\n          __id: snapshot.id,\n          __created_by: snapshot.createdBy || userId,\n          __last_modified_by: snapshot.lastModifiedBy || undefined,\n          __created_time: createdTime,\n          __last_modified_time: snapshot.lastModifiedTime || undefined,\n          __auto_number: snapshot.autoNumber == null ? undefined : snapshot.autoNumber,\n          __version: 1,\n          ...order,\n          ...dbFieldValueMap,\n          ...auditFieldValues,\n          ...createdTimeFieldValues,\n        });\n      });\n\n    const sql = this.dbProvider.batchInsertSql(\n      dbTableName,\n      snapshots.map((s) => {\n        return Object.entries(s).reduce(\n          (acc, [key, value]) => {\n            if (Array.isArray(value)) {\n              acc[key] = JSON.stringify(value);\n              return acc;\n            }\n            if (value && typeof value === 'object') {\n              const isDate = (value as Date) instanceof Date;\n              if (!isDate) {\n                acc[key] = JSON.stringify(value);\n                return acc;\n              }\n            }\n            acc[key] = value;\n            return acc;\n          },\n          {} as Record<string, unknown>\n        );\n      })\n    );\n\n    await handleDBValidationErrors({\n      fn: () => this.prismaService.txClient().$executeRawUnsafe(sql),\n      handleUniqueError: () => {\n        throw new CustomHttpException(\n          `Fields ${validationFields.map((f) => f.id).join(', ')} unique validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueDuplicate',\n              context: {\n                tableName,\n                fieldName: validationFields.map((f) => f.name).join(', '),\n              },\n            },\n          }\n        );\n      },\n      handleNotNullError: () => {\n        throw new CustomHttpException(\n          `Fields ${validationFields.map((f) => f.id).join(', ')} not null validation failed`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.custom.fieldValueNotNull',\n              context: {\n                tableName,\n                fieldName: validationFields.map((f) => f.name).join(', '),\n              },\n            },\n          }\n        );\n      },\n    });\n\n    return snapshots;\n  }\n\n  private async batchDel(tableId: string, recordIds: string[]) {\n    const dbTableName = await this.getDbTableName(tableId);\n\n    const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery();\n    await this.prismaService.txClient().$executeRawUnsafe(nativeQuery);\n  }\n\n  public async getFieldsByProjection(\n    tableId: string,\n    projection?: { [fieldNameOrId: string]: boolean },\n    fieldKeyType: FieldKeyType = FieldKeyType.Id\n  ) {\n    let fields = await this.dataLoaderService.field.load(tableId);\n    if (projection) {\n      const projectionFieldKeys = Object.entries(projection)\n        .filter(([, v]) => v)\n        .map(([k]) => k);\n      if (projectionFieldKeys.length) {\n        fields = fields.filter((field) => projectionFieldKeys.includes(field[fieldKeyType]));\n      }\n    }\n\n    return fields.map((field) => createFieldInstanceByRaw(field));\n  }\n\n  private async getCachePreviewUrlTokenMap(\n    records: ISnapshotBase<IRecord>[],\n    fields: IFieldInstance[],\n    fieldKeyType: FieldKeyType\n  ) {\n    const previewToken: string[] = [];\n    for (const field of fields) {\n      if (field.type === FieldType.Attachment) {\n        const fieldKey = field[fieldKeyType];\n        for (const record of records) {\n          const cellValue = record.data.fields[fieldKey];\n          if (cellValue == null) continue;\n          (cellValue as IAttachmentCellValue).forEach((item) => {\n            if (item.mimetype.startsWith('image/') && item.width && item.height) {\n              const { smThumbnailPath, lgThumbnailPath } = generateTableThumbnailPath(item.path);\n              previewToken.push(getTableThumbnailToken(smThumbnailPath));\n              previewToken.push(getTableThumbnailToken(lgThumbnailPath));\n            }\n            previewToken.push(item.token);\n          });\n        }\n      }\n    }\n    // limit 1000 one handle\n    const tokenMap: Record<string, string> = {};\n    for (let i = 0; i < previewToken.length; i += 1000) {\n      const tokenBatch = previewToken.slice(i, i + 1000);\n      const previewUrls = await this.cacheService.getMany(\n        tokenBatch.map((token) => `attachment:preview:${token}` as const)\n      );\n      previewUrls.forEach((url, index) => {\n        if (url) {\n          tokenMap[previewToken[i + index]] = url.url;\n        }\n      });\n    }\n    return tokenMap;\n  }\n\n  private async getThumbnailPathTokenMap(\n    records: ISnapshotBase<IRecord>[],\n    fields: IFieldInstance[],\n    fieldKeyType: FieldKeyType\n  ) {\n    const thumbnailTokens: string[] = [];\n    for (const field of fields) {\n      if (field.type === FieldType.Attachment) {\n        const fieldKey = field[fieldKeyType];\n        for (const record of records) {\n          const cellValue = record.data.fields[fieldKey];\n          if (cellValue == null) continue;\n          (cellValue as IAttachmentCellValue).forEach((item) => {\n            if (item.mimetype.startsWith('image/') && item.width && item.height) {\n              thumbnailTokens.push(getTableThumbnailToken(item.token));\n            }\n          });\n        }\n      }\n    }\n    if (thumbnailTokens.length === 0) {\n      return {};\n    }\n    const attachments = await this.prismaService.txClient().attachments.findMany({\n      where: { token: { in: thumbnailTokens } },\n      select: { token: true, thumbnailPath: true },\n    });\n    return attachments.reduce<\n      Record<\n        string,\n        | {\n            sm?: string;\n            lg?: string;\n          }\n        | undefined\n      >\n    >((acc, cur) => {\n      acc[cur.token] = cur.thumbnailPath ? JSON.parse(cur.thumbnailPath) : undefined;\n      return acc;\n    }, {});\n  }\n\n  @Timing()\n  private async recordsPresignedUrl(\n    records: ISnapshotBase<IRecord>[],\n    fields: IFieldInstance[],\n    fieldKeyType: FieldKeyType\n  ) {\n    if (records.length === 0 || fields.findIndex((f) => f.type === FieldType.Attachment) === -1) {\n      return records;\n    }\n    const cacheTokenUrlMap = await this.getCachePreviewUrlTokenMap(records, fields, fieldKeyType);\n    const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap(\n      records,\n      fields,\n      fieldKeyType\n    );\n    for (const field of fields) {\n      if (field.type === FieldType.Attachment) {\n        const fieldKey = field[fieldKeyType];\n        for (const record of records) {\n          const cellValue = record.data.fields[fieldKey];\n          const presignedCellValue = await this.getAttachmentPresignedCellValue(\n            cellValue as IAttachmentCellValue,\n            cacheTokenUrlMap,\n            thumbnailPathTokenMap\n          );\n          if (presignedCellValue == null) continue;\n\n          record.data.fields[fieldKey] = presignedCellValue;\n        }\n      }\n    }\n    return records;\n  }\n\n  async getAttachmentPresignedCellValue(\n    cellValue: IAttachmentCellValue | null,\n    cacheTokenUrlMap?: Record<string, string>,\n    thumbnailPathTokenMap?: Record<string, { sm?: string; lg?: string } | undefined>\n  ) {\n    if (cellValue == null) {\n      return null;\n    }\n\n    return await Promise.all(\n      cellValue.map(async (item) => {\n        const { path, mimetype, token } = item;\n        const presignedUrl =\n          cacheTokenUrlMap?.[token] ??\n          (await this.attachmentStorageService.getPreviewUrlByPath(\n            StorageAdapter.getBucket(UploadType.Table),\n            path,\n            token,\n            undefined,\n            {\n              'Content-Type': mimetype,\n              'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(item.name)}`,\n            }\n          ));\n        let smThumbnailUrl: string | undefined;\n        let lgThumbnailUrl: string | undefined;\n        if (thumbnailPathTokenMap && thumbnailPathTokenMap[token]) {\n          const { sm: smThumbnailPath, lg: lgThumbnailPath } = thumbnailPathTokenMap[token]!;\n          if (smThumbnailPath) {\n            smThumbnailUrl =\n              cacheTokenUrlMap?.[getTableThumbnailToken(smThumbnailPath)] ??\n              (await this.attachmentStorageService.getTableThumbnailUrl(smThumbnailPath, mimetype));\n          }\n          if (lgThumbnailPath) {\n            lgThumbnailUrl =\n              cacheTokenUrlMap?.[getTableThumbnailToken(lgThumbnailPath)] ??\n              (await this.attachmentStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));\n          }\n        }\n        const isImage = mimetype.startsWith('image/');\n        return {\n          ...item,\n          presignedUrl,\n          smThumbnailUrl: isImage ? smThumbnailUrl || presignedUrl : undefined,\n          lgThumbnailUrl: isImage ? lgThumbnailUrl || presignedUrl : undefined,\n        };\n      })\n    );\n  }\n\n  private async getSnapshotBulkInner(\n    builder: Knex.QueryBuilder,\n    viewQueryDbTableName: string,\n    query: {\n      tableId: string;\n      recordIds: string[];\n      projection?: { [fieldNameOrId: string]: boolean };\n      fieldKeyType: FieldKeyType;\n      cellFormat: CellFormat;\n      useQueryModel: boolean;\n    }\n  ): Promise<ISnapshotBase<IRecord>[]> {\n    const { tableId, recordIds, projection, fieldKeyType, cellFormat } = query;\n    const fields = await this.getFieldsByProjection(tableId, projection, fieldKeyType);\n    const fieldIds = fields.map((f) => f.id);\n\n    const { qb: queryBuilder } = await this.recordQueryBuilder.createRecordQueryBuilder(\n      viewQueryDbTableName,\n      {\n        tableId,\n        viewId: undefined,\n        useQueryModel: query.useQueryModel,\n        projection: fieldIds,\n        restrictRecordIds: recordIds,\n        builder,\n      }\n    );\n\n    const nativeQuery = queryBuilder.whereIn('__id', recordIds).toQuery();\n\n    this.logger.debug('getSnapshotBulkInner query %s', nativeQuery);\n\n    let result: ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[];\n    try {\n      result = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<\n          ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]\n        >(nativeQuery);\n    } catch (error) {\n      this.handleRawQueryError(error, nativeQuery, {\n        tableId,\n        viewQueryDbTableName,\n        recordIdsCount: recordIds.length,\n        recordIds: recordIds.slice(0, 20),\n        projectionFieldIds: fieldIds,\n        fieldKeyType,\n        cellFormat,\n        useQueryModel: query.useQueryModel,\n      });\n    }\n\n    const recordIdsMap = recordIds.reduce(\n      (acc, recordId, currentIndex) => {\n        acc[recordId] = currentIndex;\n        return acc;\n      },\n      {} as { [recordId: string]: number }\n    );\n\n    recordIds.forEach((recordId) => {\n      if (!(recordId in recordIdsMap)) {\n        throw new CustomHttpException(`Record ${recordId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.record.notFound',\n          },\n        });\n      }\n    });\n\n    const primaryField = await this.getPrimaryField(tableId);\n\n    const snapshots = result\n      .sort((a, b) => {\n        return recordIdsMap[a.__id] - recordIdsMap[b.__id];\n      })\n      .map((record) => {\n        const recordFields = this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat);\n        const name = recordFields[primaryField[fieldKeyType]];\n        return {\n          id: record.__id,\n          v: record.__version,\n          type: 'json0',\n          data: {\n            fields: recordFields,\n            name:\n              cellFormat === CellFormat.Text\n                ? (name as string)\n                : primaryField.cellValue2String(name),\n            id: record.__id,\n            autoNumber: record.__auto_number,\n            createdTime: record.__created_time?.toISOString(),\n            lastModifiedTime: record.__last_modified_time?.toISOString(),\n            createdBy: record.__created_by,\n            lastModifiedBy: record.__last_modified_by || undefined,\n          },\n        };\n      });\n    if (cellFormat === CellFormat.Json) {\n      return await this.recordsPresignedUrl(snapshots, fields, fieldKeyType);\n    }\n    return snapshots;\n  }\n\n  async getSnapshotBulkWithPermission(\n    tableId: string,\n    recordIds: string[],\n    projection?: { [fieldNameOrId: string]: boolean },\n    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.\n    cellFormat = CellFormat.Json,\n    useQueryModel = false\n  ) {\n    const dbTableName = await this.getDbTableName(tableId);\n    const { viewCte, builder, enabledFieldIds } = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      {\n        keepPrimaryKey: true,\n      }\n    );\n    const viewQueryDbTableName = viewCte ?? dbTableName;\n    const finalProjection =\n      projection ??\n      (await this.convertEnabledFieldIdsToProjection(tableId, enabledFieldIds, fieldKeyType));\n    return this.getSnapshotBulkInner(builder, viewQueryDbTableName, {\n      tableId,\n      recordIds,\n      projection: finalProjection,\n      fieldKeyType,\n      cellFormat,\n      useQueryModel,\n    });\n  }\n\n  async getSnapshotBulk(\n    tableId: string,\n    recordIds: string[],\n    projection?: { [fieldNameOrId: string]: boolean },\n    fieldKeyType: FieldKeyType = FieldKeyType.Id, // for convince of collaboration, getSnapshotBulk use id as field key by default.\n    cellFormat = CellFormat.Json,\n    useQueryModel = false\n  ): Promise<ISnapshotBase<IRecord>[]> {\n    const dbTableName = await this.getDbTableName(tableId);\n    return this.getSnapshotBulkInner(this.knex.queryBuilder(), dbTableName, {\n      tableId,\n      recordIds,\n      projection,\n      fieldKeyType,\n      cellFormat,\n      useQueryModel,\n    });\n  }\n\n  async getDocIdsByQuery(\n    tableId: string,\n    query: IGetRecordsRo,\n    useQueryModel = false\n  ): Promise<{ ids: string[]; extra?: IExtraResult }> {\n    const { skip, take = 100, ignoreViewQuery } = query;\n\n    if (identify(tableId) !== IdPrefix.Table) {\n      throw new CustomHttpException(\n        'Query collection must be table ID',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.queryCollectionMustBeTableId',\n          },\n        }\n      );\n    }\n\n    if (take > 1000) {\n      throw new CustomHttpException(\n        `The maximum search index result is 1000`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.maxSearchIndexResult',\n          },\n        }\n      );\n    }\n\n    const viewId = ignoreViewQuery ? undefined : query.viewId;\n    const {\n      groupPoints,\n      allGroupHeaderRefs,\n      filter: filterWithGroup,\n    } = await this.getGroupRelatedData(\n      tableId,\n      {\n        ...query,\n        viewId,\n      },\n      useQueryModel\n    );\n    const { queryBuilder, dbTableName } = await this.buildFilterSortQuery(\n      tableId,\n      {\n        ...query,\n        filter: filterWithGroup,\n      },\n      useQueryModel\n    );\n    // queryBuilder.select(this.knex.ref(`${selectDbTableName}.__id`));\n\n    skip && queryBuilder.offset(skip);\n    if (take !== -1) {\n      queryBuilder.limit(take);\n    }\n\n    const sqlNative = queryBuilder.toSQL().toNative();\n    const sqlDebug = queryBuilder.toQuery();\n    this.logger.debug('getRecordsQuery: %s', sqlDebug);\n    let result: { __id: string }[];\n    try {\n      result = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ __id: string }[]>(sqlNative.sql, ...sqlNative.bindings);\n    } catch (error) {\n      this.handleRawQueryError(error, sqlNative.sql, {\n        tableId,\n        dbTableName,\n        viewId,\n        ignoreViewQuery,\n        useQueryModel,\n        take,\n        skip,\n        orderBy: query.orderBy,\n        groupBy: query.groupBy,\n        filter: filterWithGroup,\n        search: query.search,\n        filterLinkCellCandidate: query.filterLinkCellCandidate,\n        filterLinkCellSelected: query.filterLinkCellSelected,\n        selectedRecordIds: query.selectedRecordIds,\n        bindings: sqlNative.bindings,\n        sqlDebug,\n      });\n    }\n    const ids = result.map((r) => r.__id);\n\n    const {\n      builder: searchWrapBuilder,\n      viewCte: searchViewCte,\n      enabledFieldIds,\n    } = await this.recordPermissionService.wrapView(tableId, this.knex.queryBuilder(), {\n      keepPrimaryKey: Boolean(query.filterLinkCellSelected),\n      viewId,\n    });\n    // this search step should not abort the query\n    const searchBuilder = searchViewCte\n      ? searchWrapBuilder.from(searchViewCte)\n      : this.knex(dbTableName);\n    try {\n      const searchHitIndex = await this.getSearchHitIndex(\n        tableId,\n        {\n          ...query,\n          projection: query.projection\n            ? enabledFieldIds\n              ? query.projection.filter((id) => enabledFieldIds.includes(id))\n              : query.projection\n            : enabledFieldIds,\n          viewId,\n        },\n        searchBuilder.whereIn('__id', ids),\n        enabledFieldIds\n      );\n      return { ids, extra: { groupPoints, searchHitIndex, allGroupHeaderRefs } };\n    } catch (e) {\n      this.logger.error(`Get search index error: ${(e as Error).message}`, (e as Error)?.stack);\n    }\n\n    return { ids, extra: { groupPoints, allGroupHeaderRefs } };\n  }\n\n  async getSearchFields(\n    originFieldInstanceMap: Record<string, IFieldInstance>,\n    search?: [string, string?, boolean?],\n    viewId?: string,\n    projection?: string[]\n  ) {\n    const maxSearchFieldCount = process.env.MAX_SEARCH_FIELD_COUNT\n      ? toNumber(process.env.MAX_SEARCH_FIELD_COUNT)\n      : DEFAULT_MAX_SEARCH_FIELD_COUNT;\n    let viewColumnMeta: IGridColumnMeta | null = null;\n    const fieldInstanceMap = projection?.length === 0 ? {} : { ...originFieldInstanceMap };\n    if (!search) {\n      return [] as IFieldInstance[];\n    }\n\n    const isSearchAllFields = !search?.[1];\n\n    if (viewId) {\n      const { columnMeta: viewColumnRawMeta } =\n        (await this.prismaService.view.findUnique({\n          where: { id: viewId, deletedTime: null },\n          select: { columnMeta: true },\n        })) || {};\n\n      viewColumnMeta = viewColumnRawMeta ? JSON.parse(viewColumnRawMeta) : null;\n\n      if (viewColumnMeta) {\n        Object.entries(viewColumnMeta).forEach(([key, value]) => {\n          if (get(value, ['hidden'])) {\n            delete fieldInstanceMap[key];\n          }\n        });\n      }\n    }\n\n    if (projection?.length) {\n      Object.keys(fieldInstanceMap).forEach((fieldId) => {\n        if (!projection.includes(fieldId)) {\n          delete fieldInstanceMap[fieldId];\n        }\n      });\n    }\n\n    return uniqBy(\n      orderBy(\n        Object.values(fieldInstanceMap)\n          .map((field) => ({\n            ...field,\n            isStructuredCellValue: field.isStructuredCellValue,\n          }))\n          .filter((field) => {\n            if (!viewColumnMeta) {\n              return true;\n            }\n            return !viewColumnMeta?.[field.id]?.hidden;\n          })\n          .filter((field) => {\n            if (!projection) {\n              return true;\n            }\n            return projection.includes(field.id);\n          })\n          .filter((field) => {\n            if (isSearchAllFields) {\n              return true;\n            }\n\n            const searchArr = search?.[1]?.split(',') || [];\n            return searchArr.includes(field.id);\n          })\n          .filter((field) => {\n            if (\n              [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) &&\n              isSearchAllFields\n            ) {\n              return false;\n            }\n            if (field.cellValueType === CellValueType.Boolean) {\n              return false;\n            }\n            return true;\n          })\n          .filter((field) => {\n            if (field.type === FieldType.Button) {\n              return false;\n            }\n            return true;\n          })\n          .map((field) => {\n            return {\n              ...field,\n              order: viewColumnMeta?.[field.id]?.order ?? Number.MIN_SAFE_INTEGER,\n            };\n          }),\n        ['order', 'createTime']\n      ),\n      'id'\n    ).slice(0, maxSearchFieldCount) as unknown as IFieldInstance[];\n  }\n\n  private async getSearchHitIndex(\n    tableId: string,\n    query: IGetRecordsRo,\n    builder: Knex.QueryBuilder,\n    enabledFieldIds?: string[]\n  ) {\n    const { search, viewId, projection, ignoreViewQuery } = query;\n\n    if (!search) {\n      return null;\n    }\n\n    const fieldsRaw = await this.dataLoaderService.field.load(tableId, {\n      id: enabledFieldIds,\n    });\n\n    const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));\n    const fieldInstanceMap = fieldInstances.reduce(\n      (map, field) => {\n        map[field.id] = field;\n        return map;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n    const searchFields = await this.getSearchFields(\n      fieldInstanceMap,\n      search,\n      ignoreViewQuery ? undefined : viewId,\n      projection\n    );\n\n    const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n\n    if (searchFields.length === 0) {\n      return null;\n    }\n\n    const newQuery = this.knex\n      .with('current_page_records', builder)\n      .with('search_index', (qb) => {\n        this.dbProvider.searchIndexQuery(\n          qb,\n          'current_page_records',\n          searchFields,\n          {\n            search,\n          },\n          tableIndex,\n          undefined,\n          undefined,\n          undefined\n        );\n      })\n      .from('search_index');\n\n    const searchQuery = newQuery.toQuery();\n\n    this.logger.debug('getSearchHitIndex query: %s', searchQuery);\n\n    const result =\n      await this.prismaService.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(searchQuery);\n\n    if (!result.length) {\n      return null;\n    }\n\n    return result.map((res) => ({\n      fieldId: res.fieldId,\n      recordId: res.__id,\n    }));\n  }\n\n  async getRecordsFields(\n    tableId: string,\n    query: IGetRecordsRo,\n    useQueryModel = true\n  ): Promise<Pick<IRecord, 'id' | 'fields'>[]> {\n    if (identify(tableId) !== IdPrefix.Table) {\n      throw new CustomHttpException(\n        'Query collection must be table ID',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.aggregation.queryCollectionMustBeTableId',\n          },\n        }\n      );\n    }\n\n    const {\n      skip,\n      take,\n      orderBy,\n      search,\n      groupBy,\n      collapsedGroupIds,\n      fieldKeyType,\n      cellFormat,\n      projection,\n      viewId,\n      ignoreViewQuery,\n      filterLinkCellCandidate,\n      filterLinkCellSelected,\n    } = query;\n\n    const fields = await this.getFieldsByProjection(\n      tableId,\n      this.convertProjection(projection),\n      fieldKeyType\n    );\n\n    const { filter: filterWithGroup } = await this.getGroupRelatedData(tableId, query);\n\n    const { queryBuilder } = await this.buildFilterSortQuery(\n      tableId,\n      {\n        viewId,\n        ignoreViewQuery,\n        filter: filterWithGroup,\n        orderBy,\n        search,\n        groupBy,\n        collapsedGroupIds,\n        filterLinkCellCandidate,\n        filterLinkCellSelected,\n        skip,\n        take,\n      },\n      useQueryModel\n    );\n    skip && queryBuilder.offset(skip);\n    take !== -1 && take && queryBuilder.limit(take);\n    const sql = queryBuilder.toQuery();\n\n    this.logger.debug('getRecordsFields query: %s', sql);\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<(Pick<IRecord, 'fields'> & Pick<IVisualTableDefaultField, '__id'>)[]>(sql);\n\n    return result.map((record) => {\n      return {\n        id: record.__id,\n        fields: this.dbRecord2RecordFields(record, fields, fieldKeyType, cellFormat),\n      };\n    });\n  }\n\n  private async getPrimaryField(tableId: string) {\n    const field = await this.dataLoaderService.field.load(tableId, {\n      isPrimary: [true],\n    });\n    if (!field.length) {\n      throw new CustomHttpException(\n        `Could not find primary field in table ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notFoundPrimaryField',\n          },\n        }\n      );\n    }\n    return createFieldInstanceByRaw(field[0]);\n  }\n\n  async getRecordsHeadWithTitles(tableId: string, titles: string[]) {\n    const dbTableName = await this.getDbTableName(tableId);\n    const field = await this.getPrimaryField(tableId);\n\n    // only text field support type cast to title\n    if (field.dbFieldType !== DbFieldType.Text) {\n      return [];\n    }\n\n    const queryBuilder = this.knex(dbTableName)\n      .select({ title: field.dbFieldName, id: '__id' })\n      .whereIn(field.dbFieldName, titles);\n\n    const querySql = queryBuilder.toQuery();\n\n    return this.prismaService.txClient().$queryRawUnsafe<{ id: string; title: string }[]>(querySql);\n  }\n\n  async getRecordsHeadWithIds(tableId: string, recordIds: string[]) {\n    const dbTableName = await this.getDbTableName(tableId);\n    const field = await this.getPrimaryField(tableId);\n\n    const queryBuilder = this.knex(dbTableName)\n      .select({ title: field.dbFieldName, id: '__id' })\n      .whereIn('__id', recordIds);\n\n    const querySql = queryBuilder.toQuery();\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; title: unknown }[]>(querySql);\n\n    return result.map((r) => ({\n      id: r.id,\n      title: field.cellValue2String(r.title),\n    }));\n  }\n\n  async filterRecordIdsByFilter(\n    tableId: string,\n    recordIds: string[],\n    filter?: IFilter | null\n  ): Promise<string[]> {\n    const { queryBuilder, alias } = await this.buildFilterSortQuery(\n      tableId,\n      {\n        filter,\n      },\n      true\n    );\n    queryBuilder.whereIn(`${alias}.__id`, recordIds);\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());\n    return result.map((r) => r.__id);\n  }\n\n  async getDiffIdsByIdAndFilter(tableId: string, recordIds: string[], filter?: IFilter | null) {\n    const ids = await this.filterRecordIdsByFilter(tableId, recordIds, filter);\n    return difference(recordIds, ids);\n  }\n\n  @Timing()\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async groupDbCollection2GroupPoints(\n    groupResult: { [key: string]: unknown; __c: number }[],\n    groupFields: IFieldInstance[],\n    groupBy: IGroup | undefined,\n    collapsedGroupIds: string[] | undefined,\n    rowCount: number\n  ) {\n    const groupPoints: IGroupPoint[] = [];\n    const allGroupHeaderRefs: IGroupHeaderRef[] = [];\n    const collapsedGroupIdsSet = new Set(collapsedGroupIds);\n    let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];\n    let curRowCount = 0;\n    let collapsedDepth = Number.MAX_SAFE_INTEGER;\n\n    for (let i = 0; i < groupResult.length; i++) {\n      const item = groupResult[i];\n      const { __c: count } = item;\n\n      for (let index = 0; index < groupFields.length; index++) {\n        const field = groupFields[index];\n        const { id, dbFieldName } = field;\n        const fieldValue = convertValueToStringify(item[dbFieldName]);\n\n        if (fieldValues[index] === fieldValue) continue;\n\n        const flagString = `${id}_${[...fieldValues.slice(0, index), fieldValue].join('_')}`;\n        const groupId = String(string2Hash(flagString));\n\n        allGroupHeaderRefs.push({ id: groupId, depth: index });\n\n        if (index > collapsedDepth) break;\n\n        // Reset the collapsedDepth when encountering the next peer grouping\n        collapsedDepth = Number.MAX_SAFE_INTEGER;\n\n        fieldValues[index] = fieldValue;\n        fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));\n\n        const isCollapsedInner = collapsedGroupIdsSet.has(groupId) ?? false;\n        let value = field.convertDBValue2CellValue(fieldValue);\n\n        if (field.type === FieldType.Attachment) {\n          value = await this.getAttachmentPresignedCellValue(value as IAttachmentCellValue);\n        }\n\n        groupPoints.push({\n          id: groupId,\n          type: GroupPointType.Header,\n          depth: index,\n          value,\n          isCollapsed: isCollapsedInner,\n        });\n\n        if (isCollapsedInner) {\n          collapsedDepth = index;\n        }\n      }\n\n      curRowCount += Number(count);\n      if (collapsedDepth !== Number.MAX_SAFE_INTEGER) continue;\n      groupPoints.push({ type: GroupPointType.Row, count: Number(count) });\n    }\n\n    if (curRowCount < rowCount) {\n      groupPoints.push(\n        {\n          id: 'unknown',\n          type: GroupPointType.Header,\n          depth: 0,\n          value: 'Unknown',\n          isCollapsed: false,\n        },\n        { type: GroupPointType.Row, count: rowCount - curRowCount }\n      );\n    }\n\n    return {\n      groupPoints,\n      allGroupHeaderRefs,\n    };\n  }\n\n  private getFilterByCollapsedGroup({\n    groupBy,\n    groupPoints,\n    fieldInstanceMap,\n    collapsedGroupIds,\n  }: {\n    groupBy: IGroup;\n    groupPoints: IGroupPointsVo;\n    fieldInstanceMap: Record<string, IFieldInstance>;\n    collapsedGroupIds?: string[];\n  }) {\n    if (!groupBy?.length || groupPoints == null || collapsedGroupIds == null) return null;\n    const groupIds: string[] = [];\n    const groupId2DataMap = groupPoints.reduce(\n      (prev, cur) => {\n        if (cur.type !== GroupPointType.Header) {\n          return prev;\n        }\n        const { id, depth } = cur;\n\n        groupIds[depth] = id;\n        prev[id] = { ...cur, path: groupIds.slice(0, depth + 1) };\n        return prev;\n      },\n      {} as Record<string, IGroupHeaderPoint & { path: string[] }>\n    );\n\n    const filterQuery: IFilter = {\n      conjunction: and.value,\n      filterSet: [],\n    };\n\n    for (const groupId of collapsedGroupIds) {\n      const groupData = groupId2DataMap[groupId];\n\n      if (groupData == null) continue;\n\n      const { path } = groupData;\n      const innerFilterSet: IFilterSet = {\n        conjunction: or.value,\n        filterSet: [],\n      };\n\n      path.forEach((pathGroupId) => {\n        const pathGroupData = groupId2DataMap[pathGroupId];\n\n        if (pathGroupData == null) return;\n\n        const { depth } = pathGroupData;\n        const curGroup = groupBy[depth];\n\n        if (curGroup == null) return;\n\n        const { fieldId } = curGroup;\n        const field = fieldInstanceMap[fieldId];\n\n        if (field == null) return;\n\n        const filterItem = generateFilterItem(field, pathGroupData.value);\n        innerFilterSet.filterSet.push(filterItem);\n      });\n\n      filterQuery.filterSet.push(innerFilterSet);\n    }\n\n    return filterQuery;\n  }\n\n  async getRowCountByFilter(\n    dbTableName: string,\n    fieldInstanceMap: Record<string, IFieldInstance>,\n    tableId: string,\n    filter?: IFilter,\n    search?: [string, string?, boolean?],\n    viewId?: string,\n    useQueryModel = false\n  ) {\n    const withUserId = this.cls.get('user.id');\n    const wrap = await this.recordPermissionService.wrapView(\n      tableId,\n      this.knex.queryBuilder(),\n      viewId\n        ? {\n            viewId,\n          }\n        : undefined\n    );\n\n    const { qb, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(\n      wrap.viewCte ?? dbTableName,\n      {\n        tableId,\n        aggregationFields: [],\n        viewId,\n        filter,\n        currentUserId: withUserId,\n        useQueryModel,\n        builder: wrap.builder,\n      }\n    );\n\n    if (search && search[2]) {\n      const searchFields = await this.getSearchFields(\n        fieldInstanceMap,\n        search,\n        viewId,\n        wrap.enabledFieldIds\n      );\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      qb.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });\n      });\n    }\n\n    const rowCountSql = qb.count({ count: '*' });\n    const sql = rowCountSql.toQuery();\n    this.logger.debug('getRowCountSql: %s', sql);\n    const result = await this.prismaService.$queryRawUnsafe<{ count?: number }[]>(sql);\n    return Number(result[0].count);\n  }\n\n  public async getGroupRelatedData(tableId: string, query?: IGetRecordsRo, useQueryModel = false) {\n    const { groupBy: extraGroupBy, filter, search, ignoreViewQuery, queryId } = query || {};\n    let groupPoints: IGroupPoint[] = [];\n    let allGroupHeaderRefs: IGroupHeaderRef[] = [];\n    let collapsedGroupIds = query?.collapsedGroupIds;\n\n    if (queryId) {\n      const cacheKey = `query-params:${queryId}` as const;\n      const cache = await this.cacheService.get(cacheKey);\n      if (cache) {\n        collapsedGroupIds = (cache.queryParams as IGetRecordsRo)?.collapsedGroupIds;\n      }\n    }\n\n    const fullGroupBy = parseGroup(extraGroupBy);\n\n    if (!fullGroupBy?.length) {\n      return {\n        groupPoints,\n        filter,\n      };\n    }\n\n    const viewId = ignoreViewQuery ? undefined : query?.viewId;\n    const viewRaw = await this.getTinyView(tableId, viewId);\n    const {\n      viewCte,\n      builder: permissionBuilder,\n      enabledFieldIds,\n    } = await this.recordPermissionService.wrapView(tableId, this.knex.queryBuilder(), {\n      keepPrimaryKey: Boolean(query?.filterLinkCellSelected),\n      viewId,\n    });\n    const fieldInstanceMap = (await this.getNecessaryFieldMap(\n      tableId,\n      filter,\n      undefined,\n      fullGroupBy,\n      search,\n      enabledFieldIds\n    ))!;\n    const enabledFieldIdSet = enabledFieldIds ? new Set(enabledFieldIds) : undefined;\n    const groupBy = fullGroupBy.filter(\n      (item) =>\n        fieldInstanceMap[item.fieldId] &&\n        (!enabledFieldIdSet || enabledFieldIdSet.has(item.fieldId))\n    );\n\n    if (!groupBy?.length) {\n      return {\n        groupPoints,\n        filter,\n        builder: permissionBuilder,\n      };\n    }\n\n    const dbTableName = await this.getDbTableName(tableId);\n\n    const filterStr = viewRaw?.filter;\n    const mergedFilter = mergeWithDefaultFilter(filterStr, filter);\n    const groupFieldIds = groupBy.map((item) => item.fieldId);\n\n    const withUserId = this.cls.get('user.id');\n    const shouldUseQueryModel = useQueryModel && !viewCte;\n    const { qb: queryBuilder, selectionMap } =\n      await this.recordQueryBuilder.createRecordAggregateBuilder(viewCte ?? dbTableName, {\n        tableId,\n        viewId,\n        filter: mergedFilter,\n        aggregationFields: [\n          {\n            fieldId: '*',\n            statisticFunc: StatisticsFunc.Count,\n            alias: '__c',\n          },\n        ],\n        groupBy,\n        currentUserId: withUserId,\n        useQueryModel: shouldUseQueryModel,\n        builder: permissionBuilder,\n      });\n\n    if (search && search[2]) {\n      const searchFields = await this.getSearchFields(fieldInstanceMap, search, viewId);\n      const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);\n      queryBuilder.where((builder) => {\n        this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });\n      });\n    }\n\n    queryBuilder.limit(this.thresholdConfig.maxGroupPoints);\n\n    const groupSql = queryBuilder.toQuery();\n    this.logger.debug('groupSql: %s', groupSql);\n    const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]).filter(Boolean);\n    const rowCount = await this.getRowCountByFilter(\n      dbTableName,\n      fieldInstanceMap,\n      tableId,\n      mergedFilter,\n      search,\n      viewId,\n      useQueryModel\n    );\n\n    try {\n      const result =\n        await this.prismaService.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(\n          groupSql\n        );\n      const pointsResult = await this.groupDbCollection2GroupPoints(\n        result,\n        groupFields,\n        groupBy,\n        collapsedGroupIds,\n        rowCount\n      );\n      groupPoints = pointsResult.groupPoints;\n      allGroupHeaderRefs = pointsResult.allGroupHeaderRefs;\n    } catch (error) {\n      this.logger.error(`Get group points error in table ${tableId}: `, error);\n    }\n\n    const filterWithCollapsed = this.getFilterByCollapsedGroup({\n      groupBy,\n      groupPoints,\n      fieldInstanceMap,\n      collapsedGroupIds,\n    });\n\n    return {\n      groupPoints,\n      allGroupHeaderRefs,\n      filter: mergeFilter(filter, filterWithCollapsed),\n      builder: permissionBuilder,\n    };\n  }\n\n  async getRecordStatus(\n    tableId: string,\n    recordId: string,\n    query: IGetRecordsRo\n  ): Promise<IRecordStatusVo> {\n    const dbTableName = await this.getDbTableName(tableId);\n    const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1);\n\n    const result = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery());\n\n    const isDeleted = result.length === 0;\n\n    if (isDeleted) {\n      return { isDeleted, isVisible: false };\n    }\n\n    const queryResult = await this.getDocIdsByQuery(\n      tableId,\n      {\n        ignoreViewQuery: query.ignoreViewQuery ?? false,\n        viewId: query.viewId,\n        skip: query.skip,\n        take: query.take,\n        filter: query.filter,\n        orderBy: query.orderBy,\n        search: query.search,\n        groupBy: query.groupBy,\n        filterLinkCellCandidate: query.filterLinkCellCandidate,\n        filterLinkCellSelected: query.filterLinkCellSelected,\n        selectedRecordIds: query.selectedRecordIds,\n      },\n      true\n    );\n    const isVisible = queryResult.ids.includes(recordId);\n    return { isDeleted, isVisible };\n  }\n\n  async emitRecordAuditLogEvent(\n    action: UpdateRecordAction | CreateRecordAction,\n    tableId: string,\n    recordCount: number,\n    appId?: string\n  ) {\n    this.eventEmitter.emit(Events.TABLE_RECORD_CREATE_RELATIVE, {\n      action,\n      resourceId: tableId,\n      recordCount,\n      params: {\n        appId,\n      },\n    });\n  }\n\n  async getRecordsCollaborators(\n    tableId: string,\n    query: IRecordGetCollaboratorsRo & { filter?: IFilter | null }\n  ) {\n    const { fieldId, skip, take, search, filter } = query;\n    const [fieldRaw] = await this.dataLoaderService.field.load(tableId, {\n      id: [fieldId],\n    });\n    if (\n      !fieldRaw ||\n      ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(\n        fieldRaw.type as FieldType\n      )\n    ) {\n      throw new CustomHttpException(\n        'field type is not user-related field',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.fieldNotUserRelatedField',\n          },\n        }\n      );\n    }\n    const { queryBuilder } = await this.buildFilterSortQuery(\n      tableId,\n      {\n        filter,\n      },\n      true\n    );\n    const collaboratorsQueryBuilder = this.knex.queryBuilder().with('table_records', queryBuilder);\n\n    const { dbFieldName, isMultipleCellValue } = fieldRaw;\n    collaboratorsQueryBuilder.whereNotNull(dbFieldName);\n    collaboratorsQueryBuilder.from('table_records');\n    this.dbProvider.shareFilterCollaboratorsQuery(\n      collaboratorsQueryBuilder,\n      dbFieldName,\n      isMultipleCellValue\n    );\n\n    const resQuery = this.knex('users')\n      .with('coll', collaboratorsQueryBuilder)\n      .select('id', 'email', 'name', 'avatar')\n      .from('coll')\n      .leftJoin('users', 'users.id', '=', 'coll.user_id')\n      .limit(take ?? 50)\n      .offset(skip ?? 0);\n    if (search) {\n      this.dbProvider.searchBuilder(resQuery, [\n        ['users.name', search],\n        ['users.email', search],\n      ]);\n    }\n    const users = await this.prismaService\n      .txClient()\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      .$queryRawUnsafe<{ id: string; email: string; name: string; avatar: string | null }[]>(\n        resQuery.toQuery()\n      );\n\n    return users.map(({ id, email, name, avatar }) => ({\n      userId: id,\n      email,\n      userName: name,\n      avatar: avatar && getPublicFullStorageUrl(avatar),\n    }));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/type.ts",
    "content": "import type { Field } from '@prisma/client';\nimport type { IUpdateRecordsRo } from '@teable/openapi';\n\nexport type IFieldRaws = Pick<\n  Field,\n  | 'id'\n  | 'name'\n  | 'type'\n  | 'options'\n  | 'unique'\n  | 'notNull'\n  | 'isComputed'\n  | 'isLookup'\n  | 'isConditionalLookup'\n  | 'lookupOptions'\n  | 'lookupLinkedFieldId'\n  | 'dbFieldName'\n>[];\n\nexport type IUpdateRecordsInternalRo = Omit<IUpdateRecordsRo, 'records'> & {\n  fieldIds?: string[];\n  records: {\n    id: string;\n    fields: Record<string, unknown>;\n    order?: Record<string, number>;\n  }[];\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/typecast.validate.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { IUserCellValue } from '@teable/core';\nimport { Colors, FieldType, UserFieldCore } from '@teable/core';\nimport type { PrismaService } from '@teable/db-main-prisma';\nimport { plainToInstance } from 'class-transformer';\nimport { vi } from 'vitest';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { getError } from '../../../test/utils/get-error';\nimport type { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport type { CollaboratorService } from '../collaborator/collaborator.service';\nimport type { DataLoaderService } from '../data-loader/data-loader.service';\nimport type { FieldConvertingService } from '../field/field-calculate/field-converting.service';\nimport type { IFieldInstance } from '../field/model/factory';\nimport type { SingleSelectFieldDto } from '../field/model/field-dto/single-select-field.dto';\nimport type { UserFieldDto } from '../field/model/field-dto/user-field.dto';\nimport type { RecordService } from './record.service';\nimport { TypeCastAndValidate } from './typecast.validate';\n\nvi.mock('zod-validation-error', () => {\n  return {\n    __esModule: true,\n    fromZodError: (message: any) => message,\n  };\n});\n\ndescribe('TypeCastAndValidate', () => {\n  const prismaService = mockDeep<PrismaService>();\n  const fieldConvertingService = mockDeep<FieldConvertingService>();\n  const recordService = mockDeep<RecordService>();\n  const attachmentsStorageService = mockDeep<AttachmentsStorageService>();\n  const collaboratorService = mockDeep<CollaboratorService>();\n  const dataLoaderService = mockDeep<DataLoaderService>();\n\n  const services = {\n    prismaService,\n    fieldConvertingService,\n    recordService,\n    attachmentsStorageService,\n    collaboratorService,\n    dataLoaderService,\n  };\n  const tableId = 'tableId';\n\n  afterEach(() => {\n    mockReset(fieldConvertingService);\n    mockReset(prismaService);\n    mockReset(recordService);\n    mockReset(collaboratorService);\n    mockReset(dataLoaderService);\n  });\n\n  describe('typecastCellValuesWithField', () => {\n    it('should call castToSingleSelect for single select field', async () => {\n      const field = mockDeep<IFieldInstance>({ type: FieldType.SingleSelect, isComputed: false });\n      const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n      const cellValues: unknown[] = [];\n\n      vi.spyOn(typeCastAndValidate as any, 'castToSingleSelect').mockResolvedValue(cellValues);\n\n      const result = await typeCastAndValidate.typecastCellValuesWithField(cellValues);\n\n      expect(result).toEqual(cellValues);\n      expect(typeCastAndValidate['castToSingleSelect']).toBeCalledWith(cellValues);\n    });\n\n    it('should call castToMultipleSelect for multiple select field', async () => {\n      const field = mockDeep<IFieldInstance>({ type: FieldType.MultipleSelect, isComputed: false });\n      const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n      const cellValues: unknown[] = [];\n\n      vi.spyOn(typeCastAndValidate as any, 'castToMultipleSelect').mockResolvedValue(cellValues);\n\n      const result = await typeCastAndValidate.typecastCellValuesWithField(cellValues);\n\n      expect(result).toEqual(cellValues);\n      expect(typeCastAndValidate['castToMultipleSelect']).toBeCalledWith(cellValues);\n    });\n\n    it('should call castToLink for link field', async () => {\n      const field = mockDeep<IFieldInstance>({ type: FieldType.Link, isComputed: false });\n      const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n      const cellValues: Record<string, unknown>[] = [];\n\n      vi.spyOn(typeCastAndValidate as any, 'castToLink').mockResolvedValue(cellValues);\n\n      const result = await typeCastAndValidate.typecastCellValuesWithField(cellValues);\n\n      expect(result).toEqual(cellValues);\n      expect(typeCastAndValidate['castToLink']).toBeCalledWith(cellValues);\n    });\n\n    it('should call defaultCastTo for other field', async () => {\n      const field = mockDeep<IFieldInstance>({ type: FieldType.SingleLineText, isComputed: false });\n      const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n      const cellValues: unknown[] = [];\n\n      vi.spyOn(typeCastAndValidate as any, 'defaultCastTo').mockResolvedValue(cellValues);\n\n      const result = await typeCastAndValidate.typecastCellValuesWithField(cellValues);\n\n      expect(result).toEqual(cellValues);\n      expect(typeCastAndValidate['defaultCastTo']).toBeCalledWith(cellValues);\n    });\n\n    it('should reject if sub method throws error', async () => {\n      const field = mockDeep<IFieldInstance>({ type: FieldType.SingleSelect, isComputed: false });\n      const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n\n      vi.spyOn(typeCastAndValidate as any, 'castToSingleSelect').mockImplementation(() => {\n        throw new Error('xxxxx');\n      });\n\n      await expect(typeCastAndValidate.typecastCellValuesWithField([])).rejects.toThrow();\n    });\n  });\n\n  describe('valueToStringArray', () => {\n    const typeCastAndValidate = new TypeCastAndValidate({\n      services,\n      field: mockDeep<IFieldInstance>(),\n      tableId,\n    });\n    it('should return null for null value', () => {\n      const result = typeCastAndValidate['valueToStringArray'](null);\n      expect(result).toBeNull();\n    });\n\n    it('should convert array to string array', () => {\n      const value = [1, '2', null, undefined];\n      const result = typeCastAndValidate['valueToStringArray'](value);\n      expect(result).toEqual(['1', '2']);\n    });\n\n    it('should return single element array for string', () => {\n      const value = 'str';\n      const result = typeCastAndValidate['valueToStringArray'](value);\n      expect(result).toEqual(['str']);\n    });\n\n    it('should convert object to string', () => {\n      const value = { toString: () => 'obj' };\n      const result = typeCastAndValidate['valueToStringArray'](value);\n      expect(result).toEqual(['obj']);\n    });\n\n    it('should filter out null values in array', () => {\n      const value = [1, null, 2];\n      const result = typeCastAndValidate['valueToStringArray'](value);\n      expect(result).toEqual(['1', '2']);\n    });\n\n    it('should filter out empty string values', () => {\n      const value = ['1', '', '2'];\n      const result = typeCastAndValidate['valueToStringArray'](value);\n      expect(result).toEqual(['1', '2']);\n    });\n\n    it('should handle error when toString throws', () => {\n      const value = {\n        toString: () => {\n          throw new Error();\n        },\n      };\n      expect(() => typeCastAndValidate['valueToStringArray'](value)).toThrow();\n    });\n  });\n\n  it('should bypass notNull for computed fields', async () => {\n    const field = mockDeep<IFieldInstance>({\n      type: FieldType.Formula,\n      isComputed: true,\n      notNull: true,\n      validateCellValue: vi.fn().mockReturnValue({ success: true, data: null }),\n      validateCellValueWithNotNull: vi.fn().mockReturnValue({ success: true, data: null }),\n    });\n    const typeCastAndValidate = new TypeCastAndValidate({ services, field, tableId });\n    const result = (typeCastAndValidate as any).mapFieldsCellValuesWithValidate(\n      [null],\n      (v: any) => v\n    );\n    expect(result[0]).toBeNull();\n    expect(field.validateCellValueWithNotNull).toHaveBeenCalled();\n  });\n\n  describe('mapFieldsCellValuesWithValidate', () => {\n    const field = mockDeep<IFieldInstance>({ id: 'fldxxxx' });\n    const typeCastAndValidate = new TypeCastAndValidate({\n      services,\n      field,\n      tableId,\n      typecast: true,\n    });\n    it('should map record and apply callback', () => {\n      const cellValues = [1];\n      const callback = vi.fn(() => 'value');\n\n      field.validateCellValueWithNotNull = vi.fn().mockReturnValue({\n        success: false,\n        error: 'error',\n      }) as any;\n\n      const result = typeCastAndValidate['mapFieldsCellValuesWithValidate'](cellValues, callback);\n\n      expect(result).toEqual(['value']);\n      expect(callback).toBeCalledWith(1);\n    });\n\n    it('should throw error when validate fails', async () => {\n      const cellValues = [1];\n\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n      });\n\n      field.validateCellValueWithNotNull = vi.fn().mockReturnValue({\n        success: false,\n        error: 'error',\n      }) as any;\n\n      const error = await getError(async () =>\n        typeCastAndValidate['mapFieldsCellValuesWithValidate'](cellValues, vi.fn())\n      );\n      expect(error).toBeDefined();\n      expect(error?.status).toBe(400);\n    });\n\n    it('should return null if typecast is false', () => {\n      const field = mockDeep<IFieldInstance>({\n        validateCellValueWithNotNull: vi.fn().mockReturnValue({ success: true, data: null }),\n      }) as any;\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n      });\n\n      field.validateCellValue = vi.fn().mockReturnValue({\n        success: true,\n      }) as any;\n\n      const cellValues = [1];\n\n      const result = typeCastAndValidate['mapFieldsCellValuesWithValidate'](\n        cellValues,\n        () => 'value'\n      );\n\n      expect(result).toEqual([null]);\n    });\n\n    it('should not throw error if no field value', () => {\n      const cellValues = [undefined];\n\n      const result = typeCastAndValidate['mapFieldsCellValuesWithValidate'](cellValues, vi.fn());\n\n      expect(result).toEqual([undefined]);\n    });\n  });\n\n  describe('createOptionsIfNotExists', () => {\n    const field = {\n      id: 'fldxxxx',\n      type: FieldType.SingleSelect,\n      options: { choices: [{ id: 'xxx', name: '1', color: Colors.Blue }] },\n    } as any;\n    const typeCastAndValidate = new TypeCastAndValidate({\n      services,\n      field,\n      tableId,\n      typecast: true,\n    });\n\n    it('should create new options and update field', async () => {\n      fieldConvertingService.stageAnalysis.mockImplementation(() => Promise.resolve({}) as any);\n\n      await typeCastAndValidate['createOptionsIfNotExists'](['1', '2']);\n\n      expect(fieldConvertingService.stageAnalysis).toBeCalledWith(\n        tableId,\n        field.id,\n        expect.objectContaining({\n          type: FieldType.SingleSelect,\n          options: expect.objectContaining({\n            choices: expect.arrayContaining([\n              expect.objectContaining({ name: '1' }),\n              expect.objectContaining({ name: '2' }),\n            ]),\n          }),\n        })\n      );\n    });\n\n    it('should return if no options', async () => {\n      fieldConvertingService.stageAnalysis.mockImplementation(() => Promise.resolve() as any);\n      await typeCastAndValidate['createOptionsIfNotExists']([]);\n\n      expect(fieldConvertingService.stageAnalysis).not.toBeCalled();\n    });\n  });\n\n  describe('defaultCastTo', () => {\n    it('should call mapFieldsCellValuesWithValidate with repair callback', () => {\n      const field = mockDeep<IFieldInstance>({ id: 'fldxxxx', repair: () => 'repair' });\n      const cellValues = ['value'];\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n        typecast: true,\n      });\n\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => (args[1] as any)()\n      );\n\n      const result = typeCastAndValidate['defaultCastTo'](cellValues);\n\n      expect(result).toEqual('repair');\n    });\n  });\n\n  describe('castToSingleSelect', () => {\n    const field = mockDeep<SingleSelectFieldDto>({\n      id: 'fldxxxx',\n      type: FieldType.SingleSelect,\n      options: {\n        choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],\n        preventAutoNewOptions: false,\n      },\n    });\n    const cellValues = ['value'];\n    const typeCastAndValidate = new TypeCastAndValidate({\n      services,\n      field,\n      tableId,\n      typecast: true,\n    });\n    it('should call dependencies correctly and return', async () => {\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => (args[1] as any)('value')\n      );\n\n      vi.spyOn(typeCastAndValidate as any, 'createOptionsIfNotExists').mockImplementation(\n        () => ({})\n      );\n\n      const result = await typeCastAndValidate['castToSingleSelect'](cellValues);\n\n      expect(typeCastAndValidate['mapFieldsCellValuesWithValidate']).toBeCalled();\n      expect(typeCastAndValidate['createOptionsIfNotExists']).toBeCalledWith(['value']);\n      expect(result).toEqual('value');\n    });\n  });\n\n  describe('castToMultipleSelect', () => {\n    const field = mockDeep<SingleSelectFieldDto>({\n      id: 'fldxxxx',\n      type: FieldType.SingleSelect,\n      options: {\n        choices: [{ id: '1', name: 'option 1', color: Colors.Blue }],\n        preventAutoNewOptions: false,\n      },\n    });\n    const cellValues = ['value'];\n    const typeCastAndValidate = new TypeCastAndValidate({\n      services,\n      field,\n      tableId,\n      typecast: true,\n    });\n    it('should call dependencies correctly and return', async () => {\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => (args[1] as any)('value')\n      );\n\n      vi.spyOn(typeCastAndValidate as any, 'createOptionsIfNotExists').mockImplementation(\n        () => ({})\n      );\n\n      const result = await typeCastAndValidate['castToMultipleSelect'](cellValues);\n\n      expect(typeCastAndValidate['mapFieldsCellValuesWithValidate']).toBeCalled();\n      expect(typeCastAndValidate['createOptionsIfNotExists']).toBeCalledWith(['value']);\n      expect(result).toEqual(['value']);\n    });\n  });\n\n  describe('castToUser', () => {\n    const bobCv: IUserCellValue = {\n      id: '1',\n      title: 'bob',\n      email: 'bob@example.com',\n      avatarUrl: expect.stringContaining('api/attachments/read/public/avatar/1'),\n    };\n    const tomCv: IUserCellValue = {\n      id: '2',\n      title: 'tom',\n      email: 'tom@example.com',\n      avatarUrl: expect.stringContaining('api/attachments/read/public/avatar/2'),\n    };\n    beforeEach(() => {\n      collaboratorService.getUserCollaboratorsByTableId.mockResolvedValue([\n        { id: '1', name: 'bob', email: 'bob@example.com', avatar: null, isSystem: false },\n        { id: '2', name: 'tom', email: 'tom@example.com', avatar: null, isSystem: false },\n      ]);\n    });\n\n    it('string cell value', async () => {\n      const field = mockDeep<UserFieldDto>({\n        id: 'fldxxxx',\n        type: FieldType.User,\n      });\n      field.convertStringToCellValue.mockImplementation((value: string, ctx: any) => {\n        return new UserFieldCore().convertStringToCellValue(value, ctx);\n      });\n      const cellValues = ['bob', '1', 'bob@example.com', 'xxxx', 'bob,tom'];\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n        typecast: true,\n      });\n\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => args[0].map((v: any) => (args[1] as any)(v))\n      );\n\n      const expectedCv: (IUserCellValue | null)[] = [bobCv, bobCv, bobCv, null, bobCv];\n\n      const result = await typeCastAndValidate['castToUser'](cellValues);\n      expect(result).toEqual(expectedCv);\n    });\n\n    it('multiple cell value', async () => {\n      const field = mockDeep<UserFieldDto>({\n        id: 'fldxxxx',\n        type: FieldType.User,\n        isMultipleCellValue: true,\n      });\n      field.convertStringToCellValue.mockImplementation((value: string, ctx: any) => {\n        return plainToInstance(UserFieldCore, {\n          isMultipleCellValue: true,\n        }).convertStringToCellValue(value, ctx);\n      });\n      const cellValues = ['bob', '1', 'bob@example.com', 'xxxx', 'bob,tom'];\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n        typecast: true,\n      });\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => args[0].map((v: any) => (args[1] as any)(v))\n      );\n      const result = await typeCastAndValidate['castToUser'](cellValues);\n      const expectedCv: (IUserCellValue | IUserCellValue[] | null)[] = [\n        [bobCv],\n        [bobCv],\n        [bobCv],\n        null,\n        [bobCv, tomCv],\n      ];\n      expect(result).toEqual(expectedCv);\n    });\n\n    it('object cell value', async () => {\n      const field = mockDeep<UserFieldDto>({\n        id: 'fldxxxx',\n        type: FieldType.User,\n      });\n\n      const cellValues = [\n        { id: '1' },\n        { name: 'bob' },\n        { email: 'bob@example.com' },\n        null,\n        { title: 'bob' },\n      ];\n\n      field.convertStringToCellValue.mockImplementation((value: string, ctx: any) => {\n        return new UserFieldCore().convertStringToCellValue(value, ctx);\n      });\n\n      const typeCastAndValidate = new TypeCastAndValidate({\n        services,\n        field,\n        tableId,\n        typecast: true,\n      });\n      vi.spyOn(typeCastAndValidate as any, 'mapFieldsCellValuesWithValidate').mockImplementation(\n        (...args: any[]) => args[0].map((v: any) => (args[1] as any)(v))\n      );\n      const result = await typeCastAndValidate['castToUser'](cellValues);\n\n      expect(result).toEqual([bobCv, bobCv, bobCv, null, bobCv]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/typecast.validate.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport type {\n  FieldCore,\n  IAttachmentCellValueRo,\n  IAttachmentItem,\n  IAttachmentItemRo,\n  ILinkCellValue,\n  ISelectFieldChoice,\n  ISelectFieldOptions,\n  IUserCellValue,\n  UserFieldCore,\n} from '@teable/core';\nimport {\n  ColorUtils,\n  FieldType,\n  generateAttachmentId,\n  generateChoiceId,\n  HttpErrorCode,\n  IdPrefix,\n  nullsToUndefined,\n} from '@teable/core';\nimport type { PrismaService } from '@teable/db-main-prisma';\nimport { isObject, keyBy, map } from 'lodash';\nimport { fromZodError } from 'zod-validation-error';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport type { CollaboratorService } from '../collaborator/collaborator.service';\nimport type { DataLoaderService } from '../data-loader/data-loader.service';\nimport type { FieldConvertingService } from '../field/field-calculate/field-converting.service';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\nimport type { MultipleSelectFieldDto } from '../field/model/field-dto/multiple-select-field.dto';\nimport type { SingleSelectFieldDto } from '../field/model/field-dto/single-select-field.dto';\nimport { UserFieldDto } from '../field/model/field-dto/user-field.dto';\nimport type { RecordService } from './record.service';\n\ninterface IServices {\n  prismaService: PrismaService;\n  fieldConvertingService: FieldConvertingService;\n  recordService: RecordService;\n  attachmentsStorageService: AttachmentsStorageService;\n  collaboratorService: CollaboratorService;\n  dataLoaderService: DataLoaderService;\n}\n\ninterface IObjectType {\n  id?: string;\n  title?: string;\n  name?: string;\n  email?: string;\n}\n\nconst convertUser = (input: unknown): string | undefined => {\n  if (typeof input === 'string') return input;\n\n  if (Array.isArray(input)) {\n    if (input.every((item) => typeof item === 'string')) {\n      return input.join();\n    }\n    if (input.every((item) => typeof item === 'object' && item !== null)) {\n      return (\n        input\n          .map((item) => convertUser(item as IObjectType))\n          .filter(Boolean)\n          .join() || undefined\n      );\n    }\n    return undefined;\n  }\n\n  if (typeof input === 'object' && input !== null) {\n    const obj = input as IObjectType;\n    return obj.id ?? obj.email ?? obj.title ?? obj.name ?? undefined;\n  }\n\n  return undefined;\n};\n\n/**\n * Cell type conversion:\n * Because there are some merge operations, we choose column-by-column conversion here.\n */\nexport class TypeCastAndValidate {\n  private readonly services: IServices;\n  private readonly field: FieldCore;\n  private readonly tableId: string;\n  private readonly typecast?: boolean;\n  private cache: Record<string, unknown> = {};\n\n  constructor({\n    services,\n    field,\n    typecast,\n    tableId,\n  }: {\n    services: IServices;\n    field: FieldCore;\n    typecast?: boolean;\n    tableId: string;\n  }) {\n    this.services = services;\n    this.field = field;\n    this.typecast = typecast;\n    this.tableId = tableId;\n    if (\n      !this.field.isComputed &&\n      (this.field.type === FieldType.SingleSelect || this.field.type === FieldType.MultipleSelect)\n    ) {\n      this.cache.choicesMap = keyBy((this.field.options as ISelectFieldOptions).choices, 'name');\n    }\n  }\n\n  /**\n   * Attempts to cast a cell value to the appropriate type based on the field configuration.\n   * Calls the appropriate typecasting method depending on the field type.\n   */\n  async typecastCellValuesWithField(cellValues: unknown[]) {\n    const { type, isComputed } = this.field;\n    if (isComputed) {\n      return cellValues;\n    }\n    switch (type) {\n      case FieldType.SingleSelect:\n        return await this.castToSingleSelect(cellValues);\n      case FieldType.MultipleSelect:\n        return await this.castToMultipleSelect(cellValues);\n      case FieldType.Link: {\n        return await this.castToLink(cellValues);\n      }\n      case FieldType.User:\n        return await this.castToUser(cellValues);\n      case FieldType.Attachment:\n        return await this.castToAttachment(cellValues);\n      case FieldType.Date:\n        return this.castToDate(cellValues);\n      default:\n        return this.defaultCastTo(cellValues);\n    }\n  }\n\n  private defaultCastTo(cellValues: unknown[]) {\n    return this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {\n      return this.field.repair(cellValue);\n    });\n  }\n\n  /**\n   * Traverse fieldRecords, and do validation here.\n   */\n  private mapFieldsCellValuesWithValidate(\n    cellValues: unknown[],\n    callBack: (cellValue: unknown) => unknown,\n    validateBusinessRules?: (cellValue: unknown) => unknown\n  ) {\n    return cellValues.map((cellValue) => {\n      if (cellValue === undefined) {\n        return;\n      }\n      const validate = this.field.validateCellValueWithNotNull(cellValue);\n      if (!validate) return;\n      if (!validate.success) {\n        if (this.typecast) {\n          return callBack(cellValue);\n        } else if (validate?.error) {\n          throw new CustomHttpException(\n            `Cell value ${cellValue} typecast field ${this.field.name}[${this.field.id}] validation failed: ${fromZodError(validate.error).message}`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.typecast.cellValueValidationFailed',\n              },\n            }\n          );\n        }\n      }\n      if (this.field.type === FieldType.SingleLineText || this.field.type === FieldType.LongText) {\n        return this.field.convertStringToCellValue(validate.data as string);\n      }\n      return validate.data == null ? null : validateBusinessRules?.(validate.data) ?? validate.data;\n    });\n  }\n\n  /**\n   * Converts the provided value to a string array.\n   * Handles multiple types of input such as arrays, strings, and other types.\n   */\n  private valueToStringArray(value: unknown): string[] | null {\n    if (value == null) {\n      return null;\n    }\n    if (Array.isArray(value)) {\n      return value.filter((v) => v != null && v !== '').map((v) => String(v).trim());\n    }\n    if (typeof value === 'string') {\n      const trimValue = value.trim();\n      return trimValue ? [trimValue] : null;\n    }\n    const strValue = String(value);\n    if (strValue != null) {\n      const trimValue = strValue.trim();\n      return trimValue ? [trimValue] : null;\n    }\n\n    return null;\n  }\n\n  /**\n   * Creates select options if they do not already exist in the field.\n   * Also updates the field with the newly created options.\n   */\n  private async createOptionsIfNotExists(choicesNames: string[]) {\n    if (!choicesNames.length) {\n      return;\n    }\n    const { id, type, options, aiConfig } = this.field as\n      | SingleSelectFieldDto\n      | MultipleSelectFieldDto;\n    const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;\n    const notExists = choicesNames.filter((name) => !existsChoicesNameMap[name]);\n    const colors = ColorUtils.randomColor(map(options.choices, 'color'), notExists.length);\n    const newChoices = notExists.map((name, index) => ({\n      id: generateChoiceId(),\n      name,\n      color: colors[index],\n    }));\n\n    // TODO: seems not necessary\n    const { newField } = await this.services.fieldConvertingService.stageAnalysis(\n      this.tableId,\n      id,\n      {\n        type,\n        aiConfig,\n        options: {\n          ...options,\n          choices: options.choices.concat(newChoices),\n        },\n      }\n    );\n\n    await this.services.fieldConvertingService.stageAlter(this.tableId, newField, this.field);\n    await this.services.dataLoaderService.field.clear();\n  }\n\n  /**\n   * Casts the value to a single select option.\n   * Creates the option if it does not already exist.\n   */\n  private async castToSingleSelect(cellValues: unknown[]): Promise<unknown[]> {\n    const allValuesSet = new Set<string>();\n    const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;\n    const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;\n    const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {\n      const valueArr = this.valueToStringArray(cellValue);\n      const newCellValue: string | null = valueArr?.length ? valueArr[0] : null;\n      newCellValue && allValuesSet.add(newCellValue);\n      return newCellValue;\n    }) as string[];\n\n    if (preventAutoNewOptions) {\n      return newCellValues\n        ? newCellValues.map((v) => (existsChoicesNameMap[v] ? v : null))\n        : newCellValues;\n    }\n\n    await this.createOptionsIfNotExists([...allValuesSet]);\n    return newCellValues;\n  }\n\n  private castToDate(cellValues: unknown[]): unknown[] {\n    return cellValues.map((cellValue) => {\n      if (cellValue === undefined) {\n        return;\n      }\n      const validate = this.field.validateCellValue(cellValue);\n      if (!validate) return;\n      if (!validate.success) {\n        return this.field.repair(cellValue);\n      }\n      return validate.data == null ? null : validate.data;\n    });\n  }\n\n  /**\n   * Casts the value to multiple select options.\n   * Creates the option if it does not already exist.\n   */\n  private async castToMultipleSelect(cellValues: unknown[]): Promise<unknown[]> {\n    const allValuesSet = new Set<string>();\n    const { preventAutoNewOptions } = this.field.options as ISelectFieldOptions;\n    const newCellValues = this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {\n      const valueArr =\n        typeof cellValue === 'string'\n          ? cellValue.split(',').map((s) => s.trim())\n          : Array.isArray(cellValue)\n            ? cellValue.filter((v) => typeof v === 'string').map((v) => v.trim())\n            : null;\n      const newCellValue: string[] | null = valueArr?.length ? valueArr : null;\n      // collect all options\n      newCellValue?.forEach((v) => v && allValuesSet.add(v));\n      return newCellValue;\n    });\n\n    if (preventAutoNewOptions) {\n      const existsChoicesNameMap = this.cache.choicesMap as Record<string, ISelectFieldChoice>;\n      return newCellValues\n        ? newCellValues.map((v) => {\n            if (v && Array.isArray(v)) {\n              return (v as string[]).filter((v) => existsChoicesNameMap[v]);\n            }\n            return v;\n          })\n        : newCellValues;\n    }\n\n    await this.createOptionsIfNotExists([...allValuesSet]);\n    return newCellValues;\n  }\n\n  /**\n   * Casts the value to a link type, link it with another table.\n   * Try to find the rows with matching titles from the link table and write them to the cell.\n   */\n  private async castToLink(cellValues: unknown[]): Promise<unknown[]> {\n    const linkRecordMap = this.typecast ? await this.getLinkTableRecordMap(cellValues) : {};\n    return this.mapFieldsCellValuesWithValidate(cellValues, (cellValue: unknown) => {\n      return this.castToLinkOne(cellValue, linkRecordMap);\n    });\n  }\n\n  private async castToUser(cellValues: unknown[]): Promise<unknown[]> {\n    const userStrArray = cellValues.map((v) => {\n      const stringCv = convertUser(v);\n      if (!stringCv) {\n        return [];\n      }\n      const stringCvArr = stringCv.split(',').map((s) => s.trim());\n      if (this.field.isMultipleCellValue) {\n        return stringCvArr;\n      }\n      return stringCvArr[0];\n    });\n    const ctx = await this.services.collaboratorService.getUserCollaboratorsByTableId(\n      this.tableId,\n      {\n        containsIn: {\n          keys: ['id', 'name', 'email', 'phone'],\n          values: userStrArray.flat(),\n        },\n      }\n    );\n\n    const userMap = keyBy(ctx, 'id');\n\n    return this.mapFieldsCellValuesWithValidate(\n      cellValues,\n      (cellValue: unknown) => {\n        const strValue = convertUser(cellValue);\n        if (strValue) {\n          const cv = (this.field as UserFieldCore).convertStringToCellValue(strValue, {\n            userSets: ctx,\n          });\n          if (Array.isArray(cv)) {\n            return cv.map(UserFieldDto.fullAvatarUrl);\n          }\n          return cv ? UserFieldDto.fullAvatarUrl(cv) : cv;\n        }\n        return null;\n      },\n      (validatedCellValue: unknown) => {\n        if (this.field.isMultipleCellValue) {\n          const notInUserMap = (validatedCellValue as IUserCellValue[]).find((v) => !userMap[v.id]);\n          if (notInUserMap) {\n            throw new CustomHttpException(\n              `User(${notInUserMap.id}) not found in table(${this.tableId})`,\n              HttpErrorCode.VALIDATION_ERROR,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.user.notFound',\n                },\n              }\n            );\n          }\n          return (validatedCellValue as IUserCellValue[]).map((v) => {\n            const user = userMap[v.id];\n            return UserFieldDto.fullAvatarUrl({\n              id: user.id,\n              title: user.name,\n              email: user.email,\n            });\n          });\n        }\n        const user = userMap[(validatedCellValue as IUserCellValue).id];\n        if (!user) {\n          throw new CustomHttpException(\n            `User(${(validatedCellValue as IUserCellValue).id}) not found in table(${this.tableId})`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.user.notFound',\n              },\n            }\n          );\n        }\n        return UserFieldDto.fullAvatarUrl({\n          id: user.id,\n          title: user.name,\n          email: user.email,\n        });\n      }\n    );\n  }\n\n  private async getAttachmentCvMapByCv(cellValues: unknown[]): Promise<\n    Record<\n      string,\n      {\n        token: string;\n        size: number;\n        mimetype: string;\n        width: number | null;\n        height: number | null;\n        path: string;\n      }\n    >\n  > {\n    const tokens = cellValues\n      .flat()\n      .flatMap((v) => {\n        if (isObject(v) && 'token' in v && typeof v.token === 'string') {\n          return [v.token];\n        }\n      })\n      .filter(Boolean) as string[];\n    if (tokens.length === 0) {\n      return {};\n    }\n    const attachmentMetadata = await this.services.prismaService.attachments.findMany({\n      where: { token: { in: tokens } },\n      select: {\n        token: true,\n        size: true,\n        mimetype: true,\n        width: true,\n        height: true,\n        path: true,\n      },\n    });\n    return keyBy(\n      attachmentMetadata.map((a) => ({ ...a, size: Number(a.size) })),\n      'token'\n    );\n  }\n\n  private async castToAttachment(cellValues: unknown[]): Promise<unknown[]> {\n    const attachmentItemsMap = this.typecast ? await this.getAttachmentItemMap(cellValues) : {};\n    const attachmentCvMap = await this.getAttachmentCvMapByCv(cellValues);\n    const unsignedValues = this.mapFieldsCellValuesWithValidate(\n      cellValues,\n      (cellValue: unknown) => {\n        const splitValues = typeof cellValue === 'string' ? cellValue.split(',') : cellValue;\n        if (Array.isArray(splitValues)) {\n          const result = splitValues.map((v) => attachmentItemsMap[v]).filter(Boolean);\n          if (result.length) {\n            return result;\n          }\n        }\n      },\n      (validatedCellValue: unknown) => {\n        const attachmentCellValue = validatedCellValue as IAttachmentCellValueRo;\n        const notInAttachmentMap = attachmentCellValue.find((v) => !attachmentCvMap[v.token]);\n        if (notInAttachmentMap) {\n          throw new CustomHttpException(\n            `Attachment(${notInAttachmentMap.token}) not found`,\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.attachment.notFound',\n              },\n            }\n          );\n        }\n        const idsSet = new Set<string>();\n        return attachmentCellValue.map((v: IAttachmentItemRo) => {\n          let id = v.id ?? generateAttachmentId();\n          if (idsSet.has(id)) {\n            id = generateAttachmentId(); // duplicate id, generate new one\n          }\n          idsSet.add(id);\n          return {\n            ...nullsToUndefined(attachmentCvMap[v.token]),\n            name: v.name,\n            id,\n          };\n        });\n      }\n    );\n\n    return unsignedValues.map((cellValues) => {\n      const attachmentCellValue = cellValues as (IAttachmentItem & {\n        thumbnailPath?: { sm?: string; lg?: string };\n      })[];\n      if (!attachmentCellValue) {\n        return attachmentCellValue;\n      }\n\n      return attachmentCellValue;\n    });\n  }\n\n  /**\n   * Get the recordMap of the link table, the format is: {[title]: [id]}.\n   * compatible with title, title[], id, id[]\n   */\n  private async getLinkTableRecordMap(cellValues: unknown[]) {\n    const titles = cellValues\n      .flat()\n      .filter((v) => v != null && typeof v !== 'object')\n      .map((v) =>\n        typeof v === 'string' && this.field.isMultipleCellValue\n          ? v.split(',').map((t) => t.trim())\n          : (v as string)\n      )\n      .flat();\n\n    if (titles.length === 0) {\n      return {};\n    }\n\n    // id[]\n    if (typeof titles[0] === 'string' && titles[0].startsWith('rec')) {\n      const linkRecords = await this.services.recordService.getRecordsHeadWithIds(\n        (this.field as LinkFieldDto).options.foreignTableId,\n        titles\n      );\n      return keyBy(linkRecords, 'id');\n    }\n\n    // title[]\n    const linkRecords = await this.services.recordService.getRecordsHeadWithTitles(\n      (this.field as LinkFieldDto).options.foreignTableId,\n      titles\n    );\n\n    return keyBy(linkRecords, 'title');\n  }\n\n  private async getAttachmentItemMap(\n    cellValues: unknown[]\n  ): Promise<Record<string, IAttachmentItem>> {\n    // Extract and flatten attachment IDs from cell values\n    const attachmentIds = cellValues\n      .flat()\n      .flatMap((v) => {\n        if (typeof v === 'string') {\n          return v.split(',').map((s) => s.trim());\n        }\n        if (Array.isArray(v)) {\n          return v\n            .map((v) => {\n              if (typeof v === 'string') {\n                return v;\n              }\n              if (isObject(v) && 'id' in v && typeof v.id === 'string') {\n                return v.id;\n              }\n              return undefined;\n            })\n            .filter(Boolean) as string[];\n        }\n        return [];\n      })\n      .filter((v) => v?.startsWith(IdPrefix.Attachment));\n\n    // Fetch attachment metadata from attachmentsTable\n    const attachmentMetadata = await this.services.prismaService.attachmentsTable.findMany({\n      where: { attachmentId: { in: attachmentIds } },\n      select: { attachmentId: true, token: true, name: true },\n    });\n\n    const tokens = attachmentMetadata.map((item) => item.token);\n    const metadataMap = keyBy(attachmentMetadata, 'token');\n\n    // Fetch attachment details from attachments table\n    const attachmentDetails = await this.services.prismaService.attachments.findMany({\n      where: { token: { in: tokens } },\n      select: {\n        token: true,\n        size: true,\n        mimetype: true,\n        path: true,\n        width: true,\n        height: true,\n      },\n    });\n\n    // Combine metadata and details into a single map\n    return attachmentDetails.reduce<\n      Record<string, IAttachmentItem & { thumbnailPath?: { sm?: string; lg?: string } }>\n    >((acc, detail) => {\n      const metadata = metadataMap[detail.token];\n      acc[metadata.attachmentId] = {\n        ...nullsToUndefined(detail),\n        size: Number(detail.size),\n        name: metadata.name,\n        id: generateAttachmentId(),\n      };\n      return acc;\n    }, {});\n  }\n\n  /**\n   * The conversion of cellValue here is mainly about the difference between filtering null values,\n   * returning data based on isMultipleCellValue.\n   */\n  private castToLinkOne(\n    cellValue: unknown,\n    linkTableRecordMap: Record<string, { id: string; title?: string }>\n  ): ILinkCellValue[] | ILinkCellValue | null {\n    const { isMultipleCellValue } = this.field;\n    if (isMultipleCellValue) {\n      if (typeof cellValue === 'string') {\n        return cellValue\n          .split(',')\n          .map((v) => v.trim())\n          .map((v) => linkTableRecordMap[v])\n          .filter(Boolean);\n      }\n      if (Array.isArray(cellValue)) {\n        return cellValue\n          .map((v) => {\n            if (typeof v === 'string') {\n              return linkTableRecordMap[v];\n            }\n            if (isObject(v) && 'id' in v && typeof v.id === 'string') {\n              return linkTableRecordMap[v.id];\n            }\n            return null;\n          })\n          .filter(Boolean) as ILinkCellValue[];\n      }\n    }\n    return linkTableRecordMap[cellValue as string] || null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/record/user-name.listener.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { ModuleRef } from '@nestjs/core';\nimport { IUserInfoVo } from '@teable/openapi';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport { V2UserRenamePropagationService } from '../v2/v2-user-rename-propagation.service';\n\n@Injectable()\nexport class UserNameListener {\n  private readonly logger = new Logger(UserNameListener.name);\n\n  constructor(\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly moduleRef: ModuleRef\n  ) {}\n\n  private async propagateRename(user: IUserInfoVo) {\n    // Resolve lazily to avoid wiring RecordModule back to V2Module. V2Module already depends on\n    // ShareDb/Table modules, which pull RecordModule in transitively.\n    const propagationService = this.moduleRef.get(V2UserRenamePropagationService, {\n      strict: false,\n    });\n    if (!propagationService) {\n      this.logger.warn(\n        'V2UserRenamePropagationService is unavailable, skipping user rename propagation'\n      );\n      return;\n    }\n\n    await propagationService.propagateUserRename({\n      actorId: user.id,\n      userId: user.id,\n      requestId: `user-rename:${user.id}:${Date.now()}`,\n      name: user.name,\n    });\n  }\n\n  @OnEvent(Events.USER_RENAME, { async: true })\n  async updateUserName(user: IUserInfoVo) {\n    try {\n      await this.propagateRename(user);\n    } catch (e: unknown) {\n      const error = e as Error;\n      this.logger.error(error.message, error.stack);\n    }\n\n    this.eventEmitterService.emit(Events.TABLE_USER_RENAME_COMPLETE, user);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/selection/selection.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { SelectionController } from './selection.controller';\n\ndescribe('SelectionController', () => {\n  let controller: SelectionController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [SelectionController],\n    }).compile();\n\n    controller = module.get<SelectionController>(SelectionController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/selection/selection.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Query,\n  Headers,\n  UseGuards,\n  UseInterceptors,\n} from '@nestjs/common';\nimport type {\n  ICopyVo,\n  IRangesToIdVo,\n  IPasteVo,\n  IDeleteVo,\n  ITemporaryPasteVo,\n} from '@teable/openapi';\nimport {\n  IRangesToIdQuery,\n  rangesToIdQuerySchema,\n  rangesQuerySchema,\n  IPasteRo,\n  pasteRoSchema,\n  rangesRoSchema,\n  IRangesRo,\n  temporaryPasteRoSchema,\n  ITemporaryPasteRo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { UseV2Feature } from '../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../canary/interceptors/v2-indicator.interceptor';\nimport { RecordOpenApiV2Service } from '../record/open-api/record-open-api-v2.service';\nimport { TqlPipe } from '../record/open-api/tql.pipe';\nimport { SelectionService } from './selection.service';\n\n@UseGuards(V2FeatureGuard)\n@UseInterceptors(V2IndicatorInterceptor)\n@Controller('api/table/:tableId/selection')\nexport class SelectionController {\n  constructor(\n    private selectionService: SelectionService,\n    private readonly recordOpenApiV2Service: RecordOpenApiV2Service,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Permissions('record|read')\n  @Get('/range-to-id')\n  async getIdsFromRanges(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(rangesToIdQuerySchema), TqlPipe) query: IRangesToIdQuery\n  ): Promise<IRangesToIdVo> {\n    return this.selectionService.getIdsFromRanges(tableId, query);\n  }\n\n  @Permissions('record|read', 'record|copy')\n  @Get('/copy')\n  async copy(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) query: IRangesRo\n  ): Promise<ICopyVo> {\n    return this.selectionService.copy(tableId, query);\n  }\n\n  @UseV2Feature('paste')\n  @Permissions('record|update')\n  @Patch('/paste')\n  async paste(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(pasteRoSchema), TqlPipe) pasteRo: IPasteRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<IPasteVo> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.paste(tableId, pasteRo, { windowId });\n    }\n\n    const ranges = await this.selectionService.paste(tableId, pasteRo, {\n      windowId,\n    });\n    return { ranges };\n  }\n\n  @Permissions('record|read')\n  @Patch('/temporaryPaste')\n  async temporaryPaste(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(temporaryPasteRoSchema), TqlPipe)\n    temporaryPasteRo: ITemporaryPasteRo\n  ): Promise<ITemporaryPasteVo> {\n    return await this.selectionService.temporaryPaste(tableId, temporaryPasteRo);\n  }\n\n  @UseV2Feature('clear')\n  @Permissions('record|update')\n  @Patch('/clear')\n  async clear(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(rangesRoSchema), TqlPipe) rangesRo: IRangesRo,\n    @Headers('x-window-id') windowId?: string\n  ) {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.clear(tableId, rangesRo);\n    }\n\n    await this.selectionService.clear(tableId, rangesRo, {\n      windowId,\n    });\n    return null;\n  }\n\n  @UseV2Feature('deleteRecord')\n  @Permissions('record|delete')\n  @Delete('/delete')\n  async delete(\n    @Param('tableId') tableId: string,\n    @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) rangesRo: IRangesRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<IDeleteVo> {\n    // Use V2 logic when canary config enables it for this space + feature\n    if (this.cls.get('useV2')) {\n      return this.recordOpenApiV2Service.deleteByRange(tableId, rangesRo);\n    }\n\n    return this.selectionService.delete(tableId, rangesRo, {\n      windowId,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/selection/selection.module.ts",
    "content": "import { Module, forwardRef } from '@nestjs/common';\nimport { AggregationModule } from '../aggregation/aggregation.module';\nimport { CanaryModule } from '../canary/canary.module';\nimport { FieldCalculateModule } from '../field/field-calculate/field-calculate.module';\nimport { FieldModule } from '../field/field.module';\nimport { RecordOpenApiModule } from '../record/open-api/record-open-api.module';\nimport { RecordModule } from '../record/record.module';\nimport { SelectionController } from './selection.controller';\nimport { SelectionService } from './selection.service';\n\n@Module({\n  imports: [\n    RecordModule,\n    FieldModule,\n    AggregationModule,\n    forwardRef(() => RecordOpenApiModule),\n    FieldCalculateModule,\n    CanaryModule,\n  ],\n  controllers: [SelectionController],\n  providers: [SelectionService],\n  exports: [SelectionService],\n})\nexport class SelectionModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/selection/selection.service.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { faker } from '@faker-js/faker';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type {\n  IDatetimeFormatting,\n  IFieldOptionsVo,\n  IFieldVo,\n  IMultiNumberShowAs,\n  ISingleLineTextFieldOptions,\n} from '@teable/core';\nimport {\n  CellValueType,\n  Colors,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  MultiNumberDisplayType,\n  NumberFormattingType,\n  SingleLineTextDisplayType,\n  SingleNumberDisplayType,\n  TIME_ZONE_LIST,\n  defaultUserFieldOptions,\n  getPermissions,\n  Role,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { RangeType } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { vi } from 'vitest';\nimport type { DeepMockProxy } from 'vitest-mock-extended';\nimport { mockDeep, mockReset } from 'vitest-mock-extended';\nimport { GlobalModule } from '../../global/global.module';\nimport type { IClsStore } from '../../types/cls';\nimport type { IAggregationService } from '../aggregation/aggregation.service.interface';\nimport { AGGREGATION_SERVICE_SYMBOL } from '../aggregation/aggregation.service.symbol';\nimport { FieldCreatingService } from '../field/field-calculate/field-creating.service';\nimport { FieldSupplementService } from '../field/field-calculate/field-supplement.service';\nimport { FieldService } from '../field/field.service';\nimport { createFieldInstanceByVo } from '../field/model/factory';\nimport { RecordOpenApiService } from '../record/open-api/record-open-api.service';\nimport { RecordService } from '../record/record.service';\nimport { SelectionModule } from './selection.module';\nimport { SelectionService } from './selection.service';\n\ndescribe('selectionService', () => {\n  let selectionService: SelectionService;\n  let recordService: RecordService;\n  let fieldService: FieldService;\n  let prismaService: DeepMockProxy<PrismaService>;\n  let recordOpenApiService: RecordOpenApiService;\n  let fieldCreatingService: FieldCreatingService;\n  let fieldSupplementService: FieldSupplementService;\n  let clsService: ClsService<IClsStore>;\n  let aggregationService: IAggregationService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, SelectionModule],\n    })\n      .overrideProvider(PrismaService)\n      .useValue(mockDeep<PrismaService>())\n      .compile();\n\n    selectionService = module.get<SelectionService>(SelectionService);\n    fieldService = module.get<FieldService>(FieldService);\n    recordService = module.get<RecordService>(RecordService);\n    recordOpenApiService = module.get<RecordOpenApiService>(RecordOpenApiService);\n    fieldCreatingService = module.get<FieldCreatingService>(FieldCreatingService);\n    fieldSupplementService = module.get<FieldSupplementService>(FieldSupplementService);\n    clsService = module.get<ClsService<IClsStore>>(ClsService);\n    aggregationService = module.get<IAggregationService>(AGGREGATION_SERVICE_SYMBOL);\n\n    prismaService = module.get<PrismaService>(\n      PrismaService\n    ) as unknown as DeepMockProxy<PrismaService>;\n    mockReset(prismaService);\n  });\n\n  const tableId = 'table1';\n  const viewId = 'view1';\n\n  describe('copy', () => {\n    it('should return merged ranges data', async () => {\n      const mockSelectionCtxRecords = [\n        {\n          id: 'record1',\n          fields: {\n            field1: '1',\n            field2: '2',\n            field3: '3',\n          },\n        },\n        {\n          id: 'record2',\n          fields: {\n            field1: '1',\n            field2: '2',\n          },\n        },\n      ];\n      const mockSelectionCtxFields = [\n        { id: 'field1', name: 'Field 1', type: FieldType.SingleLineText },\n        { id: 'field2', name: 'Field 2', type: FieldType.SingleLineText },\n      ];\n      vi.spyOn(selectionService as any, 'getSelectionCtxByRange').mockReturnValue({\n        records: mockSelectionCtxRecords,\n        fields: mockSelectionCtxFields,\n      });\n\n      const result = await selectionService.copy(tableId, {\n        viewId,\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n      });\n\n      expect(result?.content).toEqual('1\\t2\\n1\\t2');\n    });\n  });\n\n  describe('parseCopyContent', () => {\n    it('should parse the copy content into a 2D array', () => {\n      // Input\n      const content = 'John\\tDoe\\tjohn.doe@example.com\\nJane\\tSmith\\tjane.smith@example.com';\n      const expectedParsedContent = [\n        ['John', 'Doe', 'john.doe@example.com'],\n        ['Jane', 'Smith', 'jane.smith@example.com'],\n      ];\n\n      // Perform the parsing\n      const result = selectionService['parseCopyContent'](content);\n\n      // Verify the result\n      expect(result).toEqual(expectedParsedContent);\n    });\n  });\n\n  describe('calculateExpansion', () => {\n    it('should calculate the number of rows and columns to expand', async () => {\n      // Input\n      const tableSize: [number, number] = [5, 4];\n      const cell: [number, number] = [2, 3];\n      const tableDataSize: [number, number] = [2, 2];\n      const expectedExpansion = [0, 1];\n\n      // Perform the calculation\n      const result = await clsService.runWith(\n        {\n          user: {} as any,\n          tx: {},\n          origin: {\n            ip: '127.0.0.1',\n            byApi: false,\n            userAgent: 'test',\n            referer: 'test',\n          },\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => selectionService['calculateExpansion'](tableSize, cell, tableDataSize)\n      );\n\n      // no permission to expand column\n      // Perform the calculation\n      const resultNoPermission = await clsService.runWith(\n        {\n          user: {} as any,\n          tx: {},\n          origin: {\n            ip: '127.0.0.1',\n            byApi: false,\n            userAgent: 'test',\n            referer: 'test',\n          },\n          permissions: getPermissions(Role.Editor),\n        },\n        async () => selectionService['calculateExpansion'](tableSize, cell, tableDataSize)\n      );\n\n      // Verify the result\n      expect(result).toEqual(expectedExpansion);\n      expect(resultNoPermission).toEqual([0, expectedExpansion[1]]);\n    });\n  });\n\n  describe('expandColumns', () => {\n    it('should expand the columns and create new fields', async () => {\n      vi.spyOn(fieldService as any, 'generateDbFieldName').mockReturnValue('fieldName');\n      // Mock dependencies\n      const tableId = 'table1';\n      // const viewId = 'view1';\n      const header = [\n        { id: '3', name: 'Email', type: FieldType.SingleLineText },\n        { id: '4', name: 'Phone', type: FieldType.SingleLineText },\n      ] as IFieldVo[];\n      const numColsToExpand = 2;\n      vi.spyOn(fieldSupplementService, 'prepareCreateField').mockResolvedValueOnce(header[0]);\n      vi.spyOn(fieldSupplementService, 'prepareCreateField').mockResolvedValueOnce(header[1]);\n      vi.spyOn(fieldCreatingService, 'alterCreateField').mockImplementation(\n        (() => undefined) as any\n      );\n\n      // Perform expanding columns\n      const result = await selectionService['expandColumns']({\n        tableId,\n        header,\n        numColsToExpand,\n      });\n\n      // Verify the createField calls\n      expect(fieldCreatingService.alterCreateField).toHaveBeenCalledTimes(2);\n\n      // Verify the result\n      expect(result.length).toEqual(2);\n    });\n  });\n\n  describe('fillCells', () => {\n    it('should return updated records with new fields merged when newRecords is provided', () => {\n      const oldRecords = [\n        { id: '1', fields: { a: 1, b: 2 } },\n        { id: '2', fields: { c: 3, d: 4 } },\n      ];\n      const newRecords = [{ fields: { b: 20 } }, { fields: { d: 40, e: 5 } }];\n\n      const result = selectionService['fillCells'](oldRecords, newRecords);\n\n      expect(result).toEqual({\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          { id: '1', fields: { b: 20 } },\n          { id: '2', fields: { d: 40, e: 5 } },\n        ],\n      });\n    });\n\n    it('should return records with empty fields when newRecords is undefined', () => {\n      const oldRecords = [\n        { id: '1', fields: { a: 1, b: 2 } },\n        { id: '2', fields: { c: 3, d: 4 } },\n      ];\n\n      const result = selectionService['fillCells'](oldRecords);\n\n      expect(result).toEqual({\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          { id: '1', fields: {} },\n          { id: '2', fields: {} },\n        ],\n      });\n    });\n\n    it('should return records with empty fields when newRecords is an empty array', () => {\n      const oldRecords = [\n        { id: '1', fields: { a: 1, b: 2 } },\n        { id: '2', fields: { c: 3, d: 4 } },\n      ];\n\n      const result = selectionService['fillCells'](oldRecords, []);\n\n      expect(result).toEqual({\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          { id: '1', fields: {} },\n          { id: '2', fields: {} },\n        ],\n      });\n    });\n\n    it('should merge fields correctly when newRecords has fewer elements', () => {\n      const oldRecords = [\n        { id: '1', fields: { a: 1, b: 2 } },\n        { id: '2', fields: { c: 3, d: 4 } },\n      ];\n      const newRecords = [{ fields: { b: 20 } }];\n\n      const result = selectionService['fillCells'](oldRecords, newRecords);\n\n      expect(result).toEqual({\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          { id: '1', fields: { b: 20 } },\n          { id: '2', fields: {} },\n        ],\n      });\n    });\n  });\n\n  describe('expandPasteContent', () => {\n    it('should expand data when range is multiple of paste data size', () => {\n      const pasteData = [\n        ['1', '2'],\n        ['3', '4'],\n      ];\n      const range = [\n        [0, 0],\n        [3, 3],\n      ] as [[number, number], [number, number]];\n      const expected = [\n        ['1', '2', '1', '2'],\n        ['3', '4', '3', '4'],\n        ['1', '2', '1', '2'],\n        ['3', '4', '3', '4'],\n      ];\n\n      expect(selectionService['expandPasteContent'](pasteData, range)).toEqual(expected);\n    });\n\n    it('should not expand data when range is not multiple of paste data size', () => {\n      const pasteData = [\n        ['1', '2'],\n        ['3', '4'],\n      ];\n      const range = [\n        [0, 0],\n        [2, 2],\n      ] as [[number, number], [number, number]];\n\n      expect(selectionService['expandPasteContent'](pasteData, range)).toEqual(pasteData);\n    });\n  });\n\n  describe('getRangeCell', () => {\n    const maxRange = [\n      [0, 0],\n      [5, 5],\n    ] as [number, number][];\n\n    it('should return correct range for column type', () => {\n      const range = [[1, 2]] as [number, number][];\n      const type = RangeType.Columns;\n      const expected = [\n        [1, 0],\n        [2, 5],\n      ];\n\n      expect(selectionService['getRangeCell'](maxRange, range, type)).toEqual(expected);\n    });\n\n    it('should return correct range for row type', () => {\n      const range = [[1, 2]] as [number, number][];\n      const type = RangeType.Rows;\n      const expected = [\n        [0, 1],\n        [5, 2],\n      ];\n\n      expect(selectionService['getRangeCell'](maxRange, range, type)).toEqual(expected);\n    });\n\n    it('should return input range for default type', () => {\n      const range = [\n        [1, 2],\n        [3, 4],\n      ] as [number, number][];\n      const type = undefined;\n\n      expect(selectionService['getRangeCell'](maxRange, range, type)).toEqual(range);\n    });\n  });\n\n  describe('paste', () => {\n    const content = 'A1\\tB1\\tC1\\nA2\\tB2\\tC2\\nA3\\tB3\\tC3';\n    const tableData = [\n      ['A1', 'B1', 'C1'],\n      ['A2', 'B2', 'C2'],\n      ['A3', 'B3', 'C3'],\n    ];\n\n    it('should paste table data and update records', async () => {\n      // Mock input parameters\n      const tableId = 'testTableId';\n      const viewId = 'testViewId';\n\n      // Mock dependencies\n      const mockFields = [\n        {\n          id: 'fieldId1',\n          name: 'Field 1',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 1',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n        {\n          id: 'fieldId2',\n          name: 'Field 2',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 2',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n        {\n          id: 'fieldId3',\n          name: 'Field 3',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 3',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n      ].map(createFieldInstanceByVo);\n\n      const pasteRo = {\n        ranges: [\n          [2, 1],\n          [2, 1],\n        ] as [number, number][],\n        content,\n        header: mockFields,\n      };\n\n      const mockRecords = [\n        { id: 'recordId1', fields: {} },\n        { id: 'recordId2', fields: {} },\n      ];\n\n      const mockNewFields = [\n        {\n          id: 'newFieldId1',\n          name: 'Field 1',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 1',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n        {\n          id: 'newFieldId2',\n          name: 'Field 2',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 2',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n      ].map(createFieldInstanceByVo);\n\n      vi.spyOn(selectionService as any, 'parseCopyContent').mockReturnValue(tableData);\n\n      vi.spyOn(aggregationService, 'performRowCount').mockResolvedValue({\n        rowCount: mockRecords.length,\n      });\n      vi.spyOn(recordService, 'getRecordsFields').mockResolvedValue(\n        mockRecords.slice(pasteRo.ranges[0][1])\n      );\n\n      vi.spyOn(fieldService, 'getFieldInstances').mockResolvedValue(mockFields);\n\n      vi.spyOn(selectionService as any, 'expandColumns').mockResolvedValue(mockNewFields);\n\n      vi.spyOn(recordOpenApiService, 'updateRecords').mockResolvedValue({} as any);\n\n      vi.spyOn(recordOpenApiService, 'createRecords').mockResolvedValue({ records: [] } as any);\n\n      prismaService.$tx.mockImplementation(async (fn, _options) => {\n        return await fn(prismaService);\n      });\n\n      // Call the method\n      const result = await clsService.runWith(\n        {\n          user: {} as any,\n          tx: {},\n          origin: {\n            ip: '127.0.0.1',\n            byApi: false,\n            userAgent: 'test',\n            referer: 'test',\n          },\n          permissions: getPermissions(Role.Owner),\n        },\n        async () => await selectionService.paste(tableId, { viewId, ...pasteRo })\n      );\n\n      // Assertions\n      expect(selectionService['parseCopyContent']).toHaveBeenCalledWith(content);\n      expect(aggregationService.performRowCount).toHaveBeenCalledWith(tableId, { viewId });\n      expect(recordService.getRecordsFields).toHaveBeenCalledWith(\n        tableId,\n        {\n          viewId,\n          skip: 1,\n          projection: ['fieldId3'],\n          take: tableData.length,\n          fieldKeyType: 'id',\n        },\n        true\n      );\n\n      expect(fieldService.getFieldInstances).toHaveBeenCalledWith(tableId, {\n        viewId,\n        filterHidden: true,\n      });\n\n      expect(selectionService['expandColumns']).toHaveBeenCalledWith({\n        tableId,\n        header: mockFields,\n        numColsToExpand: 2,\n      });\n\n      expect(result).toEqual([\n        [2, 1],\n        [4, 3],\n      ]);\n    });\n  });\n\n  describe('clear', () => {\n    const tableId = 'testTableId';\n    const viewId = 'testViewId';\n    const records = [\n      {\n        id: 'record1',\n        fields: {\n          field1: '1',\n          field2: '2',\n        },\n      },\n    ];\n    const fields = [\n      { id: 'field1', name: 'Field 1', type: FieldType.SingleLineText },\n      { id: 'field2', name: 'Field 2', type: FieldType.SingleLineText },\n    ];\n\n    it('should clear both fields and records when type is undefined', async () => {\n      // Mock the required dependencies and their methods\n      const clearRo = {\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ] as [number, number][],\n      };\n      // Mock the updateRecordsRo object\n      const updateRecordsRo = {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ id: 'record1', fields: { field1: null } }],\n      };\n      const expectedFieldIds = fields.map((field) => field.id);\n\n      // Mock the required methods from the service\n      selectionService['getSelectionCtxByRange'] = vi.fn().mockResolvedValue({ fields, records });\n      selectionService['tableDataToRecords'] = vi.fn().mockReturnValue([{ fields: {} }]);\n      selectionService['fillCells'] = vi.fn().mockReturnValue(updateRecordsRo);\n      recordOpenApiService.updateRecords = vi.fn().mockResolvedValue(null);\n\n      // Call the clear method\n      await selectionService.clear(tableId, { viewId, ...clearRo });\n\n      // Expect the methods to have been called with the correct parameters\n      expect(selectionService['getSelectionCtxByRange']).toHaveBeenCalledWith(tableId, {\n        viewId,\n        ranges: clearRo.ranges,\n      });\n      expect(selectionService['fillCells']).toHaveBeenCalledWith(records, [{ fields: {} }]);\n      expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith(\n        tableId,\n        { ...updateRecordsRo, fieldIds: expectedFieldIds },\n        undefined\n      );\n    });\n  });\n\n  describe('optionsRoToVoByCvType', () => {\n    it('should return correct options for Number type', () => {\n      const cellValueType = CellValueType.Number;\n      const options: IFieldOptionsVo = {\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 3,\n        },\n        showAs: {\n          type: faker.helpers.arrayElement(Object.values(SingleNumberDisplayType)),\n          color: faker.helpers.arrayElement(Object.values(Colors)),\n          showValue: faker.datatype.boolean(),\n          maxValue: faker.number.int(),\n        },\n      };\n\n      const result = selectionService['optionsRoToVoByCvType'](cellValueType, options);\n\n      expect(result).toEqual({\n        type: FieldType.Number,\n        options,\n      });\n    });\n\n    it('should return correct options for DateTime type', () => {\n      const cellValueType = CellValueType.DateTime;\n      const options: IFieldOptionsVo = {\n        formatting: {\n          date: 'MM/DD/YYYY',\n          time: 'HH:mm',\n          timeZone: TIME_ZONE_LIST[0],\n        } as IDatetimeFormatting,\n      };\n\n      const result = selectionService['optionsRoToVoByCvType'](cellValueType, options);\n\n      expect(result).toEqual({\n        type: FieldType.Date,\n        options,\n      });\n    });\n\n    it('should return correct options for String type', () => {\n      const cellValueType = CellValueType.String;\n      const options: IFieldOptionsVo = {\n        showAs: {\n          type: faker.helpers.arrayElement(Object.values(SingleLineTextDisplayType)),\n        },\n      } as ISingleLineTextFieldOptions;\n\n      const result = selectionService['optionsRoToVoByCvType'](cellValueType, options);\n\n      expect(result).toEqual({\n        type: FieldType.SingleLineText,\n        options,\n      });\n    });\n\n    it('should return correct options for Boolean type', () => {\n      const cellValueType = CellValueType.Boolean;\n      const options: IFieldOptionsVo = {};\n\n      const result = selectionService['optionsRoToVoByCvType'](cellValueType, options);\n\n      expect(result).toEqual({\n        type: FieldType.Checkbox,\n        options: {},\n      });\n    });\n\n    it('should throw BadRequestException for invalid cellValueType', () => {\n      const cellValueType = 'InvalidType' as any;\n      const options: IFieldOptionsVo = {};\n\n      expect(() => selectionService['optionsRoToVoByCvType'](cellValueType, options)).toThrowError(\n        'Invalid cellValueType'\n      );\n    });\n  });\n\n  describe('fieldVoToRo', () => {\n    it('should return default SingleLineText field if no field is provided', () => {\n      const result = selectionService['fieldVoToRo'](undefined);\n\n      expect(result).toEqual({\n        type: FieldType.SingleLineText,\n      });\n    });\n\n    it('should return correct User field for CreatedBy and LastModifiedBy types', () => {\n      const createdByField: IFieldVo = {\n        type: FieldType.CreatedBy,\n        id: '',\n        name: '',\n        description: '',\n        isComputed: true,\n        options: undefined as any,\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n\n      const lastModifiedByField: IFieldVo = {\n        type: FieldType.LastModifiedBy,\n        id: '',\n        options: undefined as any,\n        name: '',\n        isComputed: true,\n        description: '',\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n\n      const createdByResult = selectionService['fieldVoToRo'](createdByField);\n      const lastModifiedByResult = selectionService['fieldVoToRo'](lastModifiedByField);\n\n      expect(createdByResult).toEqual({\n        type: FieldType.User,\n        options: defaultUserFieldOptions,\n        name: '',\n        description: '',\n      });\n\n      expect(lastModifiedByResult).toEqual({\n        type: FieldType.User,\n        options: defaultUserFieldOptions,\n        name: '',\n        description: '',\n      });\n    });\n\n    it('should handle computed fields with valid cellValueType', () => {\n      const computedField: IFieldVo = {\n        id: '',\n        name: '',\n        description: '',\n        type: FieldType.Formula,\n        isComputed: true,\n        cellValueType: CellValueType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n          showAs: {\n            type: MultiNumberDisplayType.Bar,\n            color: Colors.Blue,\n            showValue: true,\n            maxValue: 100,\n          } as IMultiNumberShowAs,\n        },\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n\n      const optionsRoToVoByCvTypeMock = vitest.spyOn(\n        selectionService as any,\n        'optionsRoToVoByCvType'\n      );\n\n      const result = selectionService['fieldVoToRo'](computedField);\n\n      expect(result).toEqual({\n        name: '',\n        description: '',\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n          showAs: {\n            type: MultiNumberDisplayType.Bar,\n            color: Colors.Blue,\n            showValue: true,\n            maxValue: 100,\n          },\n        },\n      });\n\n      expect(optionsRoToVoByCvTypeMock).toHaveBeenCalledWith(\n        computedField.cellValueType,\n        computedField.options\n      );\n\n      optionsRoToVoByCvTypeMock.mockRestore();\n    });\n\n    it('should handle computed fields with invalid cellValueType', () => {\n      const computedField: IFieldVo = {\n        id: '',\n        name: '',\n        description: '',\n        type: FieldType.Number,\n        isComputed: false,\n        cellValueType: CellValueType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n          showAs: {\n            type: MultiNumberDisplayType.Bar,\n            color: Colors.Blue,\n            showValue: true,\n            maxValue: 100,\n          } as IMultiNumberShowAs,\n        },\n        dbFieldType: DbFieldType.Integer,\n        dbFieldName: '',\n      };\n\n      const optionsRoToVoByCvTypeMock = vitest.spyOn(\n        selectionService as any,\n        'optionsRoToVoByCvType'\n      );\n\n      const result = selectionService['fieldVoToRo'](computedField);\n\n      expect(result).toEqual({\n        name: '',\n        description: '',\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n          showAs: {\n            type: MultiNumberDisplayType.Bar,\n            color: Colors.Blue,\n            showValue: true,\n            maxValue: 100,\n          },\n        },\n      });\n\n      expect(optionsRoToVoByCvTypeMock).not.toHaveBeenCalled();\n\n      optionsRoToVoByCvTypeMock.mockRestore();\n    });\n  });\n\n  describe('lookupOptionsRoToVo', () => {\n    it('should return MultipleSelect options for SingleSelect with isMultipleCellValue', () => {\n      const field: IFieldVo = {\n        type: FieldType.SingleSelect,\n        isMultipleCellValue: true,\n        options: {\n          choices: [],\n        },\n        id: '',\n        name: '',\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n\n      const result = selectionService['lookupOptionsRoToVo'](field);\n\n      expect(result).toEqual({\n        type: FieldType.MultipleSelect,\n        options: field.options,\n      });\n    });\n\n    it('should return User options with isMultiple true for FieldType User with isMultipleCellValue', () => {\n      const field: IFieldVo = {\n        type: FieldType.User,\n        isMultipleCellValue: true,\n        options: {\n          isMultiple: false,\n          shouldNotify: false,\n        },\n        id: '',\n        name: '',\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n      const result = selectionService['lookupOptionsRoToVo'](field);\n\n      expect(result).toEqual({\n        type: FieldType.User,\n        options: {\n          ...field.options,\n          isMultiple: true,\n        },\n      });\n    });\n\n    it('should return the same type and options for other cases', () => {\n      const field: IFieldVo = {\n        type: FieldType.SingleLineText,\n        isMultipleCellValue: false,\n        options: {},\n        id: '',\n        name: '',\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: '',\n      };\n\n      const result = selectionService['lookupOptionsRoToVo'](field);\n\n      expect(result).toEqual({\n        type: field.type,\n        options: field.options,\n      });\n    });\n  });\n\n  describe('tableDataToRecords', () => {\n    it('should return the cells with provided table data', async () => {\n      // Mock data\n      const tableData = [\n        ['A1', 'B1', 'C1'],\n        ['A2', 'B2', 'C2'],\n        ['A3', 'B3', 'C3'],\n      ];\n\n      const fields = [\n        {\n          id: 'field1',\n          name: 'Field 1',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 1',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n        {\n          id: 'field2',\n          name: 'Field 2',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 2',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n        {\n          id: 'field3',\n          name: 'Field 3',\n          type: FieldType.SingleLineText,\n          options: {},\n          dbFieldName: 'Field 3',\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n          columnMeta: {},\n        },\n      ].map(createFieldInstanceByVo);\n\n      // Execute the method\n      const updateRecordsRo = selectionService['tableDataToRecords']({\n        tableData,\n        fields,\n      });\n\n      expect(updateRecordsRo).toEqual([\n        {\n          fields: { field1: 'A1', field2: 'B1', field3: 'C1' },\n        },\n        {\n          fields: { field1: 'A2', field2: 'B2', field3: 'C2' },\n        },\n        {\n          fields: { field1: 'A3', field2: 'B3', field3: 'C3' },\n        },\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/selection/selection.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type {\n  IButtonFieldOptions,\n  IDateFieldOptions,\n  IFieldOptionsRo,\n  IFieldOptionsVo,\n  IFieldRo,\n  IFieldVo,\n  INumberFieldOptionsRo,\n  IRecord,\n  ISingleLineTextFieldOptions,\n  IUserFieldOptions,\n} from '@teable/core';\nimport {\n  CellValueType,\n  FieldKeyType,\n  FieldType,\n  HttpErrorCode,\n  datetimeFormattingSchema,\n  defaultDatetimeFormatting,\n  defaultNumberFormatting,\n  defaultUserFieldOptions,\n  numberFormattingSchema,\n  parseClipboardText,\n  singleLineTextShowAsSchema,\n  singleNumberShowAsSchema,\n  stringifyClipboardText,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IUpdateRecordsRo,\n  IRangesToIdQuery,\n  IRangesToIdVo,\n  IPasteRo,\n  IPasteVo,\n  IRangesRo,\n  IDeleteVo,\n  ITemporaryPasteVo,\n  ICreateRecordsRo,\n} from '@teable/openapi';\nimport { IdReturnType, RangeType, UpdateRecordAction, CreateRecordAction } from '@teable/openapi';\nimport { difference, pick } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { IAggregationService } from '../aggregation/aggregation.service.interface';\nimport { InjectAggregationService } from '../aggregation/aggregation.service.provider';\nimport { FieldCreatingService } from '../field/field-calculate/field-creating.service';\nimport { FieldSupplementService } from '../field/field-calculate/field-supplement.service';\nimport { FieldService } from '../field/field.service';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByVo } from '../field/model/factory';\nimport { RecordOpenApiService } from '../record/open-api/record-open-api.service';\nimport { RecordService } from '../record/record.service';\nimport type { IUpdateRecordsInternalRo } from '../record/type';\n\n@Injectable()\nexport class SelectionService {\n  constructor(\n    private readonly recordService: RecordService,\n    private readonly fieldService: FieldService,\n    private readonly prismaService: PrismaService,\n    @InjectAggregationService() private readonly aggregationService: IAggregationService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly fieldCreatingService: FieldCreatingService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cls: ClsService<IClsStore>,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  async getIdsFromRanges(tableId: string, query: IRangesToIdQuery): Promise<IRangesToIdVo> {\n    const { returnType } = query;\n    if (returnType === IdReturnType.RecordId) {\n      return {\n        recordIds: await this.rowSelectionToIds(tableId, query),\n      };\n    }\n\n    if (returnType === IdReturnType.FieldId) {\n      return {\n        fieldIds: await this.columnSelectionToIds(tableId, query),\n      };\n    }\n\n    if (returnType === IdReturnType.All) {\n      return {\n        fieldIds: await this.columnSelectionToIds(tableId, query),\n        recordIds: await this.rowSelectionToIds(tableId, query),\n      };\n    }\n\n    throw new CustomHttpException('Invalid return type', HttpErrorCode.VALIDATION_ERROR, {\n      localization: {\n        i18nKey: 'httpErrors.selection.invalidReturnType',\n      },\n    });\n  }\n\n  private async columnSelectionToIds(tableId: string, query: IRangesToIdQuery): Promise<string[]> {\n    const { type, viewId, ranges, projection } = query;\n    const result = await this.fieldService.getDocIdsByQuery(tableId, {\n      viewId,\n      filterHidden: true,\n      projection,\n    });\n\n    if (type === RangeType.Rows) {\n      return result.ids;\n    }\n\n    if (type === RangeType.Columns) {\n      return ranges.reduce<string[]>((acc, range) => {\n        return acc.concat(result.ids.slice(range[0], range[1] + 1));\n      }, []);\n    }\n\n    const [start, end] = ranges;\n    return result.ids.slice(start[0], end[0] + 1);\n  }\n\n  private async rowSelectionToIds(tableId: string, query: IRangesToIdQuery): Promise<string[]> {\n    const { type, ranges } = query;\n    if (type === RangeType.Columns) {\n      const result = await this.recordService.getDocIdsByQuery(\n        tableId,\n        {\n          ...query,\n          skip: 0,\n          take: -1,\n        },\n        true\n      );\n      return result.ids;\n    }\n\n    if (type === RangeType.Rows) {\n      let recordIds: string[] = [];\n      const total = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);\n      if (total > this.thresholdConfig.maxReadRows) {\n        throw new CustomHttpException(\n          `Exceed max read rows ${this.thresholdConfig.maxReadRows}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.selection.exceedMaxReadRows',\n            },\n          }\n        );\n      }\n      for (const [start, end] of ranges) {\n        const result = await this.recordService.getDocIdsByQuery(\n          tableId,\n          {\n            ...query,\n            skip: start,\n            take: end + 1 - start,\n          },\n          true\n        );\n        recordIds = recordIds.concat(result.ids);\n      }\n\n      return recordIds;\n    }\n\n    const [start, end] = ranges;\n    const total = end[1] - start[1] + 1;\n    if (total > this.thresholdConfig.maxReadRows) {\n      throw new CustomHttpException(\n        `Exceed max read rows ${this.thresholdConfig.maxReadRows}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.selection.exceedMaxReadRows',\n          },\n        }\n      );\n    }\n    const result = await this.recordService.getDocIdsByQuery(\n      tableId,\n      {\n        ...query,\n        skip: start[1],\n        take: end[1] + 1 - start[1],\n      },\n      true\n    );\n\n    return result.ids;\n  }\n\n  private fieldsToProjection(fields: IFieldVo[], fieldKeyType: FieldKeyType) {\n    return fields.map((f) => f[fieldKeyType]);\n  }\n\n  private async columnsSelectionCtx(tableId: string, rangesRo: IRangesRo) {\n    const { ranges, type, projection, ...queryRo } = rangesRo;\n\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: queryRo.viewId,\n      filterHidden: true,\n      projection,\n    });\n    const filteredFields = ranges.reduce((acc, range) => {\n      return acc.concat(fields.slice(range[0], range[1] + 1));\n    }, [] as IFieldVo[]);\n\n    const records = await this.recordService.getRecordsFields(\n      tableId,\n      {\n        ...queryRo,\n        skip: 0,\n        take: -1,\n        fieldKeyType: FieldKeyType.Id,\n        projection: this.fieldsToProjection(filteredFields, FieldKeyType.Id),\n      },\n      true\n    );\n\n    return {\n      records,\n      fields: filteredFields,\n    };\n  }\n\n  private async rowsSelectionCtx(tableId: string, rangesRo: IRangesRo) {\n    const { ranges, type, projection, ...queryRo } = rangesRo;\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: queryRo.viewId,\n      filterHidden: true,\n      projection,\n    });\n    let records: Pick<IRecord, 'id' | 'fields'>[] = [];\n    for (const [start, end] of ranges) {\n      const recordsFields = await this.recordService.getRecordsFields(\n        tableId,\n        {\n          ...queryRo,\n          skip: start,\n          take: end + 1 - start,\n          fieldKeyType: FieldKeyType.Id,\n          projection: this.fieldsToProjection(fields, FieldKeyType.Id),\n        },\n        true\n      );\n      records = records.concat(recordsFields);\n    }\n\n    return {\n      records,\n      fields,\n    };\n  }\n\n  private async defaultSelectionCtx(tableId: string, rangesRo: IRangesRo) {\n    const { ranges, type, projection, ...queryRo } = rangesRo;\n    const [start, end] = ranges;\n    const fields = await this.fieldService.getFieldInstances(tableId, {\n      viewId: queryRo.viewId,\n      filterHidden: true,\n      projection,\n    });\n    const selectedFields = fields.slice(start[0], end[0] + 1);\n    const records = await this.recordService.getRecordsFields(\n      tableId,\n      {\n        ...queryRo,\n        skip: start[1],\n        take: end[1] + 1 - start[1],\n        fieldKeyType: FieldKeyType.Id,\n        projection: this.fieldsToProjection(selectedFields, FieldKeyType.Id),\n      },\n      true\n    );\n    return { records, fields: selectedFields };\n  }\n\n  private async parseRange(\n    tableId: string,\n    rangesRo: IRangesRo\n  ): Promise<{ cellCount: number; columnCount: number; rowCount: number }> {\n    const { ranges, type, projection, ...queryRo } = rangesRo;\n    switch (type) {\n      case RangeType.Columns: {\n        const { rowCount } = await this.aggregationService.performRowCount(tableId, queryRo);\n        const columnCount = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);\n        const cellCount = rowCount * columnCount;\n\n        return { cellCount, columnCount, rowCount };\n      }\n      case RangeType.Rows: {\n        const fields = await this.fieldService.getFieldsByQuery(tableId, {\n          viewId: queryRo.viewId,\n          filterHidden: true,\n          projection,\n        });\n        const columnCount = fields.length;\n        const rowCount = ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);\n        const cellCount = rowCount * columnCount;\n\n        return { cellCount, columnCount, rowCount };\n      }\n      default: {\n        const [start, end] = ranges;\n        const columnCount = end[0] - start[0] + 1;\n        const rowCount = end[1] - start[1] + 1;\n        const cellCount = rowCount * columnCount;\n\n        return { cellCount, columnCount, rowCount };\n      }\n    }\n  }\n\n  private async getSelectionCtxByRange(tableId: string, rangesRo: IRangesRo) {\n    const { type } = rangesRo;\n    switch (type) {\n      case RangeType.Columns: {\n        return await this.columnsSelectionCtx(tableId, rangesRo);\n      }\n      case RangeType.Rows: {\n        return await this.rowsSelectionCtx(tableId, rangesRo);\n      }\n      default:\n        return await this.defaultSelectionCtx(tableId, rangesRo);\n    }\n  }\n\n  private optionsRoToVoByCvType(\n    cellValueType: CellValueType,\n    options: IFieldOptionsVo = {}\n  ): { type: FieldType; options: IFieldOptionsRo } {\n    switch (cellValueType) {\n      case CellValueType.Number: {\n        const numberOptions = options as INumberFieldOptionsRo;\n        const formattingRes = numberFormattingSchema.safeParse(numberOptions?.formatting);\n        const showAsRes = singleNumberShowAsSchema.safeParse(numberOptions?.showAs);\n        return {\n          type: FieldType.Number,\n          options: {\n            formatting: formattingRes.success ? formattingRes?.data : defaultNumberFormatting,\n            showAs: showAsRes.success ? showAsRes?.data : undefined,\n          },\n        };\n      }\n      case CellValueType.DateTime: {\n        const dateOptions = options as IDateFieldOptions;\n        const formattingRes = datetimeFormattingSchema.safeParse(dateOptions?.formatting);\n        return {\n          type: FieldType.Date,\n          options: {\n            formatting: formattingRes.success ? formattingRes?.data : defaultDatetimeFormatting,\n          },\n        };\n      }\n      case CellValueType.String: {\n        const singleLineTextOptions = options as ISingleLineTextFieldOptions;\n        const showAsRes = singleLineTextShowAsSchema.safeParse(singleLineTextOptions.showAs);\n        return {\n          type: FieldType.SingleLineText,\n          options: {\n            showAs: showAsRes.success ? showAsRes?.data : undefined,\n          },\n        };\n      }\n      case CellValueType.Boolean: {\n        return {\n          type: FieldType.Checkbox,\n          options: {},\n        };\n      }\n      default:\n        throw new CustomHttpException('Invalid cellValueType', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.selection.invalidCellValueType',\n          },\n        });\n    }\n  }\n\n  private lookupOptionsRoToVo(field: IFieldVo): { type: FieldType; options: IFieldOptionsRo } {\n    const { type, isMultipleCellValue, options } = field;\n    if (type === FieldType.SingleSelect && isMultipleCellValue) {\n      return {\n        type: FieldType.MultipleSelect,\n        options,\n      };\n    }\n    if (type === FieldType.User && isMultipleCellValue) {\n      const userOptions = options as IUserFieldOptions;\n      return {\n        type,\n        options: {\n          ...userOptions,\n          isMultiple: true,\n        },\n      };\n    }\n    return { type, options };\n  }\n\n  private fieldVoToRo(field?: IFieldVo): IFieldRo {\n    if (!field) {\n      return {\n        type: FieldType.SingleLineText,\n      };\n    }\n    const { isComputed, isLookup } = field;\n    const baseField = pick(field, 'name', 'type', 'options', 'description');\n\n    if (isComputed && !isLookup) {\n      if ([FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)) {\n        return {\n          ...baseField,\n          type: FieldType.User,\n          options: defaultUserFieldOptions,\n        };\n      }\n      return {\n        ...baseField,\n        ...this.optionsRoToVoByCvType(field.cellValueType, field.options),\n      };\n    }\n\n    if (isLookup) {\n      return {\n        ...baseField,\n        ...this.lookupOptionsRoToVo(field),\n      };\n    }\n\n    return baseField;\n  }\n\n  private async expandColumns({\n    tableId,\n    header = [],\n    numColsToExpand,\n  }: {\n    tableId: string;\n    header?: IFieldVo[];\n    numColsToExpand: number;\n  }) {\n    const colLen = header.length;\n    const res: IFieldVo[] = [];\n    for (let i = colLen - numColsToExpand; i < colLen; i++) {\n      const field = this.fieldVoToRo(header[i]);\n      const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, field);\n      if (fieldVo.type === FieldType.Button) {\n        delete (fieldVo.options as IButtonFieldOptions).workflow;\n      }\n      const fieldInstance = createFieldInstanceByVo(fieldVo);\n      // expend columns do not need to calculate\n      await this.fieldCreatingService.alterCreateField(tableId, fieldInstance);\n      res.push(fieldVo);\n    }\n    return res;\n  }\n\n  private parseCopyContent(content: string): string[][] {\n    return parseClipboardText(content);\n  }\n\n  private stringifyCopyContent(content: string[][]): string {\n    return stringifyClipboardText(content);\n  }\n\n  private calculateExpansion(\n    tableSize: [number, number],\n    cell: [number, number],\n    tableDataSize: [number, number]\n  ): [number, number] {\n    const permissions = this.cls.get('permissions');\n    const [numCols, numRows] = tableSize;\n    const [dataNumCols, dataNumRows] = tableDataSize;\n\n    const endCol = cell[0] + dataNumCols;\n    const endRow = cell[1] + dataNumRows;\n\n    const numRowsToExpand = Math.max(0, endRow - numRows);\n    const numColsToExpand = Math.max(0, endCol - numCols);\n    const hasFieldCreatePermission = permissions.includes('field|create');\n    const hasRecordCreatePermission = permissions.includes('record|create');\n    return [\n      hasFieldCreatePermission ? numColsToExpand : 0,\n      hasRecordCreatePermission ? numRowsToExpand : 0,\n    ];\n  }\n\n  private tableDataToRecords({\n    tableData,\n    fields,\n  }: {\n    tableData: string[][];\n    fields: IFieldInstance[];\n  }) {\n    const records: { fields: IRecord['fields'] }[] = tableData.map(() => ({ fields: {} }));\n    fields.forEach((field, col) => {\n      if (field.isComputed) {\n        return;\n      }\n      tableData.forEach((cellCols, row) => {\n        records[row].fields[field.id] = cellCols?.[col] ?? null;\n      });\n    });\n    return records;\n  }\n\n  private cellValueToRecords({\n    tableData,\n    fields,\n    sourceFields,\n  }: {\n    tableData: unknown[][];\n    fields: IFieldInstance[];\n    sourceFields: IFieldInstance[];\n  }) {\n    const records: { fields: IRecord['fields'] }[] = tableData.map(() => ({ fields: {} }));\n    fields.forEach((field, col) => {\n      const sourceField = sourceFields[col];\n      if (field.isComputed) {\n        return;\n      }\n      // eslint-disable-next-line sonarjs/cognitive-complexity\n      tableData.forEach((cellCols, row) => {\n        const cellValue = cellCols?.[col] ?? null;\n        const recordField = records[row].fields;\n\n        if (cellValue == null) {\n          recordField[field.id] = null;\n          return;\n        }\n\n        switch (field.type) {\n          case FieldType.User:\n          case FieldType.Attachment:\n            {\n              const cvs = [cellValue].flat();\n              recordField[field.id] =\n                sourceField.type === field.type\n                  ? field.isMultipleCellValue\n                    ? cvs\n                    : cvs?.[0]\n                  : sourceField.cellValue2String(cellValue);\n            }\n            break;\n          case FieldType.Date:\n            recordField[field.id] =\n              sourceField.type === FieldType.Date\n                ? Array.isArray(cellValue)\n                  ? cellValue[0]\n                  : cellValue\n                : sourceField.cellValue2String(cellValue);\n            break;\n          case FieldType.Link: {\n            recordField[field.id] = cellValue\n              ? sourceField.type === FieldType.Link\n                ? [cellValue as { id: string }]\n                    .flat()\n                    .map((v) => (typeof v === 'string' ? v : v.id))\n                    .join(',')\n                : sourceField.cellValue2String(cellValue)\n              : null;\n            break;\n          }\n          default:\n            recordField[field.id] = sourceField.cellValue2String(cellValue) ?? null;\n        }\n      });\n    });\n    return records;\n  }\n\n  private fillCells(\n    oldRecords: {\n      id: string;\n      fields: IRecord['fields'];\n    }[],\n    newRecords?: { fields: IRecord['fields'] }[]\n  ): IUpdateRecordsRo {\n    return {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: true,\n      records: oldRecords.map(({ id }, index) => {\n        const newFields = newRecords?.[index]?.fields;\n        const updateFields = newFields ?? {};\n        return {\n          id,\n          fields: updateFields,\n        };\n      }),\n    };\n  }\n\n  async copy(tableId: string, rangesRo: IRangesRo) {\n    const { cellCount } = await this.parseRange(tableId, rangesRo);\n\n    if (cellCount > this.thresholdConfig.maxCopyCells) {\n      throw new CustomHttpException(\n        `Exceed max copy cells ${this.thresholdConfig.maxCopyCells}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.selection.exceedMaxCopyCells',\n          },\n        }\n      );\n    }\n\n    const { fields, records } = await this.getSelectionCtxByRange(tableId, rangesRo);\n    const fieldInstances = fields.map(createFieldInstanceByVo);\n    const rectangleData = records.map((record) =>\n      fieldInstances.map((fieldInstance) =>\n        fieldInstance.cellValue2String(record.fields[fieldInstance.id] as never)\n      )\n    );\n    return {\n      content: this.stringifyCopyContent(rectangleData),\n      header: fields,\n    };\n  }\n\n  // If the pasted selection is twice the size of the content,\n  // the content is automatically expanded to the selection size\n  private expandPasteContent(pasteData: unknown[][], range: [[number, number], [number, number]]) {\n    const [start, end] = range;\n    const [startCol, startRow] = start;\n    const [endCol, endRow] = end;\n\n    const rangeRows = endRow - startRow + 1;\n    const rangeCols = endCol - startCol + 1;\n\n    const pasteRows = pasteData.length;\n    const pasteCols = pasteData[0].length;\n\n    if (rangeRows % pasteRows !== 0 || rangeCols % pasteCols !== 0) {\n      return pasteData;\n    }\n\n    return Array.from({ length: rangeRows }, (_, i) =>\n      Array.from({ length: rangeCols }, (_, j) => pasteData[i % pasteRows][j % pasteCols])\n    );\n  }\n\n  // Paste does not support non-contiguous selections,\n  // the first selection is taken by default.\n  private getRangeCell(\n    maxRange: [number, number][],\n    range: [number, number][],\n    type?: RangeType\n  ): [[number, number], [number, number]] {\n    const [maxStart, maxEnd] = maxRange;\n    const [maxStartCol, maxStartRow] = maxStart;\n    const [maxEndCol, maxEndRow] = maxEnd;\n\n    if (type === RangeType.Columns) {\n      return [\n        [range[0][0], maxStartRow],\n        [range[0][1], maxEndRow],\n      ];\n    }\n\n    if (type === RangeType.Rows) {\n      return [\n        [maxStartCol, range[0][0]],\n        [maxEndCol, range[0][1]],\n      ];\n    }\n    return [range[0], range[1]];\n  }\n\n  // For pasting to add new lines\n  async temporaryPaste(\n    tableId: string,\n    pasteRo: IPasteRo,\n    {\n      permissionFilter,\n    }: {\n      permissionFilter?: (data: { fields: IRecord['fields'] }[]) => Promise<\n        {\n          fields: IRecord['fields'];\n        }[]\n      >;\n    } = {}\n  ) {\n    const { content, header, viewId, ranges, projection } = pasteRo;\n    const pasteContent = typeof content === 'string' ? this.parseCopyContent(content) : content;\n    const pasteContentSize = pasteContent.length * pasteContent[0].length;\n    if (pasteContentSize > this.thresholdConfig.maxPasteCells) {\n      throw new CustomHttpException(\n        `Exceed max paste cells ${this.thresholdConfig.maxPasteCells}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.selection.exceedMaxPasteCells',\n          },\n        }\n      );\n    }\n\n    const fields = await this.fieldService.getFieldInstances(tableId, {\n      viewId,\n      filterHidden: true,\n      projection,\n    });\n\n    const rangeCell = ranges as [[number, number], [number, number]];\n    const startColumnIndex = rangeCell[0][0];\n\n    const tableData = this.expandPasteContent(pasteContent, rangeCell);\n    const tableColCount = tableData[0].length;\n    const effectFields = fields.slice(startColumnIndex, startColumnIndex + tableColCount);\n    const sourceFields = header && header.map((f) => createFieldInstanceByVo(f));\n    let result: ITemporaryPasteVo = [];\n\n    await this.prismaService.$tx(async () => {\n      const newRecords = sourceFields\n        ? this.cellValueToRecords({\n            tableData,\n            fields: effectFields,\n            sourceFields,\n          })\n        : this.tableDataToRecords({\n            tableData: tableData as string[][],\n            fields: effectFields,\n          });\n      const filteredNewRecords = permissionFilter ? await permissionFilter(newRecords) : newRecords;\n\n      result = await this.recordOpenApiService.validateFieldsAndTypecast(\n        tableId,\n        filteredNewRecords,\n        FieldKeyType.Id,\n        true\n      );\n    });\n\n    return result;\n  }\n\n  async paste(\n    tableId: string,\n    pasteRo: IPasteRo,\n    {\n      expansionChecker,\n      permissionFilter,\n      windowId,\n    }: {\n      expansionChecker?: (col: number, row: number) => Promise<void>;\n      permissionFilter?: (\n        type: 'create' | 'update',\n        data: ICreateRecordsRo | IUpdateRecordsRo,\n        newFields?: { id: string; name: string; dbFieldName: string }[]\n      ) => Promise<ICreateRecordsRo | IUpdateRecordsRo>;\n      windowId?: string;\n    } = {}\n  ) {\n    const effectiveWindowId = windowId ?? this.cls.get('windowId');\n    const { content, header, ...rangesRo } = pasteRo;\n    const { ranges, type, ...queryRo } = rangesRo;\n    const { viewId } = queryRo;\n    const { cellCount } = await this.parseRange(tableId, rangesRo);\n    const pasteContent = typeof content === 'string' ? this.parseCopyContent(content) : content;\n    const pasteContentSize = pasteContent.length * pasteContent[0].length;\n    if (\n      cellCount > this.thresholdConfig.maxPasteCells ||\n      pasteContentSize > this.thresholdConfig.maxPasteCells\n    ) {\n      throw new CustomHttpException(\n        `Exceed max paste cells ${this.thresholdConfig.maxPasteCells}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.selection.exceedMaxPasteCells',\n          },\n        }\n      );\n    }\n\n    const { rowCount: rowCountInView } = await this.aggregationService.performRowCount(\n      tableId,\n      queryRo\n    );\n    const sourceFields = header && header.map((f) => createFieldInstanceByVo(f));\n    const fields = await this.fieldService.getFieldInstances(tableId, {\n      viewId,\n      filterHidden: true,\n      projection: rangesRo.projection,\n    });\n\n    const tableSize: [number, number] = [fields.length, rowCountInView];\n    const rangeCell = this.getRangeCell(\n      [\n        [0, 0],\n        [tableSize[0] - 1, tableSize[1] - 1],\n      ],\n      ranges,\n      type\n    );\n\n    const tableData = this.expandPasteContent(pasteContent, rangeCell);\n    const tableColCount = tableData[0].length;\n    const tableRowCount = tableData.length;\n\n    const cell = rangeCell[0];\n    const [col, row] = cell;\n\n    const effectFields = fields.slice(col, col + tableColCount);\n\n    const projection = effectFields.map((f) => f.id);\n\n    const existingRecords = await this.recordService.getRecordsFields(\n      tableId,\n      {\n        ...queryRo,\n        projection,\n        skip: row,\n        take: tableData.length,\n        fieldKeyType: FieldKeyType.Id,\n      },\n      true\n    );\n    const [numColsToExpand, numRowsToExpand] = this.calculateExpansion(tableSize, cell, [\n      tableColCount,\n      tableRowCount,\n    ]);\n    await expansionChecker?.(numColsToExpand, numRowsToExpand);\n\n    const updateRange: IPasteVo['ranges'] = [cell, cell];\n\n    const newFields = await this.prismaService.$tx(async () => {\n      // Expansion col\n      return await this.expandColumns({\n        tableId,\n        header,\n        numColsToExpand,\n      });\n    });\n\n    const { updateRecords, newRecords } = await this.prismaService.$tx(async () => {\n      const updateFields = effectFields.concat(newFields.map(createFieldInstanceByVo));\n\n      // get all effect records, contains update and need create record\n      const recordsFromClipboard = sourceFields\n        ? this.cellValueToRecords({\n            tableData,\n            fields: updateFields,\n            sourceFields,\n          })\n        : this.tableDataToRecords({\n            tableData: tableData as string[][],\n            fields: updateFields,\n          });\n\n      // Warning: Update before creating\n      // Fill cells\n      const toUpdateRecords = recordsFromClipboard.slice(0, existingRecords.length);\n      const updateRecordsRo = this.fillCells(existingRecords, toUpdateRecords);\n      const filteredUpdateRecordsRo = permissionFilter\n        ? await permissionFilter('update', updateRecordsRo, newFields)\n        : updateRecordsRo;\n      const updateFieldIds = updateFields.map((field) => field.id);\n      const maybeInternal = filteredUpdateRecordsRo as IUpdateRecordsInternalRo;\n      const updateRecordsPayload: IUpdateRecordsInternalRo =\n        maybeInternal.fieldIds !== undefined\n          ? maybeInternal\n          : {\n              ...maybeInternal,\n              fieldIds: updateFieldIds,\n            };\n      const { cellContexts } = await this.recordOpenApiService.updateRecords(\n        tableId,\n        updateRecordsPayload\n      );\n\n      if (updateRecordsPayload?.records?.length) {\n        await this.emitPasteSelectionAuditLog(\n          UpdateRecordAction.PasteRecord,\n          tableId,\n          updateRecordsPayload?.records?.length\n        );\n      }\n\n      let newRecords: IRecord[] | undefined;\n      // create record\n      if (numRowsToExpand) {\n        const createNewRecords = recordsFromClipboard.slice(existingRecords.length);\n        const createRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n          records: createNewRecords,\n        };\n        const filteredCreateRecordsRo = permissionFilter\n          ? await permissionFilter('create', createRecordsRo, newFields)\n          : createRecordsRo;\n        this.cls.set('skipRecordAuditLog', true);\n        newRecords = (\n          await this.recordOpenApiService.createRecords(tableId, filteredCreateRecordsRo, undefined)\n        ).records;\n      }\n\n      updateRange[1] = [col + updateFields.length - 1, row + tableRowCount - 1];\n      return {\n        updateRecords: {\n          cellContexts,\n          recordIds: existingRecords.map(({ id }) => id),\n          fieldIds: updateFields.map(({ id }) => id),\n        },\n        newRecords,\n      };\n    });\n\n    if (effectiveWindowId) {\n      this.eventEmitterService.emitAsync(Events.OPERATION_PASTE_SELECTION, {\n        windowId: effectiveWindowId,\n        userId: this.cls.get('user.id'),\n        tableId,\n        updateRecords,\n        newFields,\n        newRecords,\n      });\n    }\n\n    if (newRecords?.length) {\n      // Emit audit log for paste operation\n      await this.emitPasteSelectionAuditLog(\n        CreateRecordAction.RecordPaste,\n        tableId,\n        newRecords?.length\n      );\n    }\n\n    return updateRange;\n  }\n\n  async clear(\n    tableId: string,\n    rangesRo: IRangesRo,\n    {\n      windowId,\n      permissionFilter,\n    }: {\n      windowId?: string;\n      permissionFilter?: (data: IUpdateRecordsRo) => Promise<IUpdateRecordsRo>;\n    } = {}\n  ) {\n    const { fields, records } = await this.getSelectionCtxByRange(tableId, rangesRo);\n    const fieldInstances = fields.map(createFieldInstanceByVo);\n    const fieldIds = fields.map((field) => field.id);\n    const updateRecords = this.tableDataToRecords({\n      tableData: Array.from({ length: records.length }, () => []),\n      fields: fieldInstances,\n    });\n    const updateRecordsRo = this.fillCells(records, updateRecords);\n    const filteredUpdateRecordsRo: IUpdateRecordsRo = permissionFilter\n      ? await permissionFilter(updateRecordsRo)\n      : updateRecordsRo;\n    const maybeInternal = filteredUpdateRecordsRo as IUpdateRecordsInternalRo;\n    const payload: IUpdateRecordsInternalRo =\n      maybeInternal.fieldIds !== undefined ? maybeInternal : { ...maybeInternal, fieldIds };\n    await this.recordOpenApiService.updateRecords(tableId, payload, windowId);\n  }\n\n  async delete(\n    tableId: string,\n    rangesRo: IRangesRo,\n    {\n      windowId,\n      permissionFilter,\n    }: {\n      windowId?: string;\n      permissionFilter?: (recordIds: string[]) => Promise<string[]>;\n    }\n  ): Promise<IDeleteVo> {\n    const { records } = await this.getSelectionCtxByRange(tableId, rangesRo);\n    const recordIds = records.map(({ id }) => id);\n    const filteredRecordIds = permissionFilter ? await permissionFilter(recordIds) : recordIds;\n    const diffRecordIds = difference(recordIds, filteredRecordIds);\n    if (diffRecordIds.length) {\n      throw new CustomHttpException(\n        `You don't have permission to delete records: ${diffRecordIds}`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.permission.deleteRecords',\n            context: { recordIds: diffRecordIds.join(',') },\n          },\n        }\n      );\n    }\n    await this.recordOpenApiService.deleteRecords(tableId, filteredRecordIds, windowId);\n    return { ids: filteredRecordIds };\n  }\n\n  private async emitPasteSelectionAuditLog(\n    action: UpdateRecordAction | CreateRecordAction,\n    tableId: string,\n    newRecordLength?: number\n  ) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n    this.cls.set('skipRecordAuditLog', true);\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action,\n        resourceId: tableId,\n        recordCount: newRecordLength ?? 0,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts",
    "content": "import { Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { AdminOpenApiService } from './admin-open-api.service';\n\n@Controller('api/admin')\n@Permissions('instance|update')\nexport class AdminOpenApiController {\n  constructor(private readonly adminService: AdminOpenApiService) {}\n\n  @Patch('/plugin/:pluginId/publish')\n  async publishPlugin(@Param('pluginId') pluginId: string): Promise<void> {\n    await this.adminService.publishPlugin(pluginId);\n  }\n\n  @Patch('/plugin/:pluginId/unpublish')\n  async unpublishPlugin(@Param('pluginId') pluginId: string): Promise<void> {\n    await this.adminService.unpublishPlugin(pluginId);\n  }\n\n  @Post('/attachment/repair-table-thumbnail')\n  async repairTableAttachmentThumbnail(): Promise<void> {\n    await this.adminService.repairTableAttachmentThumbnail();\n  }\n\n  @Get('/debug/heap-snapshot')\n  async getHeapSnapshot(@Res() res: Response): Promise<void> {\n    await this.adminService.getHeapSnapshot(res);\n  }\n\n  @Get('performance-cache-stats')\n  async getPerformanceCache() {\n    return await this.adminService.getPerformanceCache();\n  }\n\n  @Delete('performance-cache')\n  async deletePerformanceCache(@Query('key') key?: string) {\n    return await this.adminService.deletePerformanceCache(key);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { MulterModule } from '@nestjs/platform-express';\nimport multer from 'multer';\nimport { AttachmentsCropModule } from '../../attachments/attachments-crop.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { AdminOpenApiController } from './admin-open-api.controller';\nimport { AdminOpenApiService } from './admin-open-api.service';\n\n@Module({\n  imports: [\n    AttachmentsCropModule,\n    MulterModule.register({\n      storage: multer.diskStorage({}),\n    }),\n    StorageModule,\n  ],\n  controllers: [AdminOpenApiController],\n  exports: [AdminOpenApiService],\n  providers: [AdminOpenApiService],\n})\nexport class AdminOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts",
    "content": "import { Session } from 'node:inspector';\nimport { Readable } from 'node:stream';\nimport {\n  BadRequestException,\n  Injectable,\n  InternalServerErrorException,\n  Logger,\n} from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PluginStatus, UploadType } from '@teable/openapi';\nimport { Response } from 'express';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { PerformanceCacheService } from '../../../performance-cache';\nimport { Timing } from '../../../utils/timing';\nimport { AttachmentsCropQueueProcessor } from '../../attachments/attachments-crop.processor';\nimport StorageAdapter from '../../attachments/plugins/adapter';\n\n@Injectable()\nexport class AdminOpenApiService {\n  private readonly logger = new Logger(AdminOpenApiService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly attachmentsCropQueueProcessor: AttachmentsCropQueueProcessor,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  async publishPlugin(pluginId: string) {\n    return this.prismaService.plugin.update({\n      where: { id: pluginId, status: PluginStatus.Reviewing },\n      data: { status: PluginStatus.Published },\n    });\n  }\n\n  async unpublishPlugin(pluginId: string) {\n    return this.prismaService.plugin.update({\n      where: { id: pluginId, status: PluginStatus.Published },\n      data: { status: PluginStatus.Developing },\n    });\n  }\n\n  async repairTableAttachmentThumbnail() {\n    // once handle 1000 attachments\n    const take = 1000;\n    let total = 0;\n    for (let skip = 0; ; skip += take) {\n      const sqlNative = this.knex('attachments_table')\n        .select(\n          'attachments.token',\n          'attachments.height',\n          'attachments.mimetype',\n          'attachments.path'\n        )\n        .leftJoin('attachments', 'attachments_table.token', 'attachments.token')\n        .whereNotNull('attachments.height')\n        .whereNull('attachments.deleted_time')\n        .whereNull('attachments.thumbnail_path')\n        .limit(take)\n        .offset(skip)\n        .toSQL()\n        .toNative();\n      const attachments = await this.prismaService.$queryRawUnsafe<\n        { token: string; height?: number; mimetype: string; path: string }[]\n      >(sqlNative.sql, ...sqlNative.bindings);\n      this.logger.log('attachments', attachments, sqlNative.sql);\n      if (attachments.length === 0) {\n        break;\n      }\n      total += attachments.length;\n      await this.attachmentsCropQueueProcessor.queue.addBulk(\n        attachments.map((attachment) => ({\n          name: 'admin_attachment_crop_image',\n          data: {\n            ...attachment,\n            bucket: StorageAdapter.getBucket(UploadType.Table),\n          },\n        }))\n      );\n      this.logger.log(`Processed ${attachments.length} attachments`);\n    }\n    this.logger.log(`Total processed ${total} attachments`);\n  }\n\n  @Timing()\n  async getHeapSnapshot(res: Response) {\n    const podName = process.env.HOSTNAME || 'unknown';\n    const session = new Session();\n    const timestamp = new Date().toISOString();\n    const filename = `heap-${podName}-${timestamp}.heapsnapshot`;\n    try {\n      const snapshotStream = new Readable({\n        // eslint-disable-next-line @typescript-eslint/no-empty-function\n        read() {},\n      });\n\n      res.setHeader('Content-Type', 'application/octet-stream');\n      res.setHeader('Content-Disposition', `attachment; filename=\"${filename}\"`);\n\n      session.connect();\n      session.on('HeapProfiler.addHeapSnapshotChunk', (m) => {\n        snapshotStream.push(m.params.chunk);\n      });\n\n      const snapshotPromise = new Promise<void>((resolve, reject) => {\n        session.post('HeapProfiler.takeHeapSnapshot', undefined, (err) => {\n          if (err) {\n            reject(err);\n          } else {\n            snapshotStream.push(null);\n            resolve();\n          }\n        });\n      });\n\n      snapshotStream.on('error', (error) => {\n        this.logger.error(`Stream error for pod ${podName}:`, error);\n        throw new InternalServerErrorException(`Stream error: ${error.message}`);\n      });\n\n      snapshotStream.pipe(res);\n\n      await new Promise<void>((resolve, reject) => {\n        res.on('finish', () => {\n          this.logger.log(`Heap snapshot streaming completed for pod ${podName}`);\n          resolve();\n        });\n\n        res.on('error', (error) => {\n          this.logger.error(`Response error for pod ${podName}:`, error);\n          reject(error);\n        });\n\n        snapshotStream.on('error', reject);\n      });\n\n      await snapshotPromise;\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (error: any) {\n      throw new InternalServerErrorException(\n        `Failed to get heap snapshot: ${error.message}, podName: ${podName}, timestamp: ${timestamp}`\n      );\n    } finally {\n      session.disconnect();\n      this.logger.log(`Session disconnected for pod ${podName}`);\n    }\n  }\n\n  async getPerformanceCache() {\n    return {\n      stats: this.performanceCacheService.getStats(),\n      typeStats: this.performanceCacheService.getTypeStats(),\n    };\n  }\n\n  async deletePerformanceCache(key?: string) {\n    if (!key) {\n      throw new BadRequestException('key is required');\n    }\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    await this.performanceCacheService.del(key as any);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  BadRequestException,\n  Body,\n  Controller,\n  Get,\n  Patch,\n  Post,\n  Put,\n  UploadedFile,\n  UseInterceptors,\n} from '@nestjs/common';\nimport { FileInterceptor } from '@nestjs/platform-express';\nimport type {\n  IPublicSettingVo,\n  ISetSettingMailTransportConfigVo,\n  ISettingVo,\n  ITestLLMVo,\n  IUploadLogoVo,\n  IBatchTestLLMVo,\n  ITestApiKeyVo,\n  ITestPublicAccessVo,\n} from '@teable/openapi';\nimport {\n  IUpdateSettingRo,\n  testLLMRoSchema,\n  updateSettingRoSchema,\n  ITestLLMRo,\n  setSettingMailTransportConfigRoSchema,\n  ISetSettingMailTransportConfigRo,\n  SettingKey,\n  batchTestLLMRoSchema,\n  IBatchTestLLMRo,\n  testApiKeyRoSchema,\n  ITestApiKeyRo,\n} from '@teable/openapi';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { Public } from '../../auth/decorators/public.decorator';\nimport { TurnstileService } from '../../auth/turnstile/turnstile.service';\nimport { SettingOpenApiService } from './setting-open-api.service';\n\n@Controller('api/admin/setting')\nexport class SettingOpenApiController {\n  constructor(\n    private readonly settingOpenApiService: SettingOpenApiService,\n    private readonly turnstileService: TurnstileService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  /**\n   * Get the instance settings, now we have config for AI, there are some sensitive fields, we need check the permission before return.\n   */\n  @Permissions('instance|read')\n  @Get()\n  async getSetting(): Promise<ISettingVo> {\n    return await this.settingOpenApiService.getSetting();\n  }\n\n  /**\n   * Public endpoint for getting public settings without authentication\n   */\n  @Public()\n  @Get('public')\n  async getPublicSetting(): Promise<IPublicSettingVo> {\n    const setting = await this.settingOpenApiService.getSetting([\n      SettingKey.INSTANCE_ID,\n      SettingKey.BRAND_NAME,\n      SettingKey.BRAND_LOGO,\n      SettingKey.DISALLOW_SIGN_UP,\n      SettingKey.DISALLOW_SPACE_CREATION,\n      SettingKey.DISALLOW_SPACE_INVITATION,\n      SettingKey.DISALLOW_DASHBOARD,\n      SettingKey.ENABLE_EMAIL_VERIFICATION,\n      SettingKey.ENABLE_WAITLIST,\n      SettingKey.ENABLE_CREDIT_REWARD,\n      SettingKey.AI_CONFIG,\n      SettingKey.APP_CONFIG,\n    ]);\n    const { aiConfig, appConfig, enableCreditReward, ...rest } = setting;\n    return {\n      ...rest,\n      enableCreditReward: enableCreditReward ?? undefined,\n      aiConfig: {\n        enable: Boolean(aiConfig?.chatModel?.lg),\n        llmProviders:\n          aiConfig?.llmProviders?.map((provider) => ({\n            type: provider.type,\n            name: provider.name,\n            models: provider.models,\n            isInstance: true,\n            modelConfigs: provider.modelConfigs,\n          })) ?? [],\n        chatModel: aiConfig?.chatModel ?? undefined,\n        capabilities: aiConfig?.capabilities,\n        // Include gateway models for space-level AI config\n        gatewayModels: aiConfig?.gatewayModels,\n      },\n      appGenerationEnabled: Boolean(appConfig?.apiKey),\n      turnstileSiteKey: this.turnstileService.getTurnstileSiteKey(),\n      changeEmailSendCodeMailRate: this.thresholdConfig.changeEmailSendCodeMailRate,\n      resetPasswordSendMailRate: this.thresholdConfig.resetPasswordSendMailRate,\n      signupVerificationSendCodeMailRate: this.thresholdConfig.signupVerificationSendCodeMailRate,\n    };\n  }\n\n  @Patch()\n  @Permissions('instance|update')\n  async updateSetting(\n    @Body(new ZodValidationPipe(updateSettingRoSchema))\n    updateSettingRo: IUpdateSettingRo\n  ): Promise<ISettingVo> {\n    return await this.settingOpenApiService.updateSetting(updateSettingRo);\n  }\n\n  @UseInterceptors(\n    FileInterceptor('file', {\n      fileFilter: (_req, file, callback) => {\n        if (file.mimetype.startsWith('image/')) {\n          callback(null, true);\n        } else {\n          callback(new BadRequestException('Invalid file type'), false);\n        }\n      },\n      limits: {\n        fileSize: 500 * 1024, // limit file size is 500KB\n      },\n    })\n  )\n  @Patch('logo')\n  @Permissions('instance|update')\n  async uploadLogo(@UploadedFile() file: Express.Multer.File): Promise<IUploadLogoVo> {\n    return this.settingOpenApiService.uploadLogo(file);\n  }\n\n  @Permissions('instance|update')\n  @Post('test-llm')\n  async testLLM(\n    @Body(new ZodValidationPipe(testLLMRoSchema)) testLLMRo: ITestLLMRo\n  ): Promise<ITestLLMVo> {\n    return await this.settingOpenApiService.testLLM(testLLMRo);\n  }\n\n  @Permissions('instance|update')\n  @Post('batch-test-llm')\n  async batchTestLLM(\n    @Body(new ZodValidationPipe(batchTestLLMRoSchema.optional())) batchTestLLMRo?: IBatchTestLLMRo\n  ): Promise<IBatchTestLLMVo> {\n    return await this.settingOpenApiService.batchTestLLM(batchTestLLMRo);\n  }\n\n  @Permissions('instance|update')\n  @Post('test-api-key')\n  async testApiKey(\n    @Body(new ZodValidationPipe(testApiKeyRoSchema)) testApiKeyRo: ITestApiKeyRo\n  ): Promise<ITestApiKeyVo> {\n    return await this.settingOpenApiService.testApiKey(testApiKeyRo);\n  }\n\n  @Permissions('instance|update')\n  @Get('test-public-access')\n  async testPublicAccess(): Promise<ITestPublicAccessVo> {\n    return await this.settingOpenApiService.testPublicAccess();\n  }\n\n  @Permissions('instance|update')\n  @Put('set-mail-transport-config')\n  async setMailTransportConfig(\n    @Body(new ZodValidationPipe(setSettingMailTransportConfigRoSchema))\n    setMailTransportConfigRo: ISetSettingMailTransportConfigRo\n  ): Promise<ISetSettingMailTransportConfigVo> {\n    await this.settingOpenApiService.setMailTransportConfig(setMailTransportConfigRo);\n\n    return {\n      ...setMailTransportConfigRo,\n      transportConfig: {\n        ...setMailTransportConfigRo.transportConfig,\n        auth: {\n          user: setMailTransportConfigRo.transportConfig.auth.user,\n          pass: '',\n        },\n      },\n    };\n  }\n\n  /**\n   * Get available models from AI Gateway\n   * Returns configured=false if gateway is not set up\n   */\n  @Public()\n  @Get('gateway-models')\n  async getGatewayModels() {\n    return await this.settingOpenApiService.getGatewayModels();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/setting-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { MulterModule } from '@nestjs/platform-express';\nimport multer from 'multer';\nimport { AttachmentsStorageModule } from '../../attachments/attachments-storage.module';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { TurnstileModule } from '../../auth/turnstile/turnstile.module';\nimport { SettingModule } from '../setting.module';\nimport { SettingOpenApiController } from './setting-open-api.controller';\nimport { SettingOpenApiService } from './setting-open-api.service';\n\n@Module({\n  imports: [\n    MulterModule.register({\n      storage: multer.diskStorage({}),\n    }),\n    StorageModule,\n    AttachmentsStorageModule,\n    SettingModule,\n    TurnstileModule,\n  ],\n  controllers: [SettingOpenApiController],\n  exports: [SettingOpenApiService],\n  providers: [SettingOpenApiService],\n})\nexport class SettingOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/open-api/setting-open-api.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { readFile } from 'fs/promises';\nimport { join, resolve } from 'path';\nimport type { OpenAIProvider } from '@ai-sdk/openai';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  ISetSettingMailTransportConfigRo,\n  IChatModelAbility,\n  IAbilityDetail,\n  ISettingVo,\n  ITestLLMRo,\n  ITestLLMVo,\n  IBatchTestLLMRo,\n  IBatchTestLLMVo,\n  IModelTestResult,\n  LLMProvider,\n  ITestApiKeyRo,\n  ITestApiKeyVo,\n  ITestPublicAccessVo,\n  GatewayModelType,\n  GatewayModelTag,\n  GatewayModelProvider,\n} from '@teable/openapi';\nimport { chatModelAbilityType, UploadType, LLMProviderType } from '@teable/openapi';\nimport { createGateway, generateText, tool, experimental_generateImage } from 'ai';\nimport type { LanguageModel, TextPart, FilePart } from 'ai';\nimport axios from 'axios';\nimport { uniq } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { z } from 'zod';\nimport { BaseConfig, IBaseConfig } from '../../../configs/base.config';\nimport { type IStorageConfig, StorageConfig } from '../../../configs/storage';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { getAdaptedProviderOptions, modelProviders } from '../../ai/util';\nimport { AttachmentsStorageService } from '../../attachments/attachments-storage.service';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\nimport { getPublicFullStorageUrl } from '../../attachments/plugins/utils';\nimport { EMAIL_LOGO_TOKEN } from '../../builtin-assets-init/builtin-assets-init.service';\nimport { verifyTransport } from '../../mail-sender/mail-helpers';\nimport { SettingService } from '../setting.service';\n\nconst unknownErrorMsg = 'unknown error';\n\n// Test file tokens from builtin-assets-init\nconst actTestImageToken = 'actTestImage';\nconst actTestPdfToken = 'actTestPDF';\n// Test file paths\nconst testImagePath = 'static/test/test-image.png';\nconst testPdfPath = 'static/test/test-pdf.pdf';\n// Expected letter in test files - use uppercase K for stricter matching\nconst expectedLetter = 'k';\n\n@Injectable()\nexport class SettingOpenApiService {\n  private readonly logger = new Logger(SettingOpenApiService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @StorageConfig() private readonly storageConfig: IStorageConfig,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly settingService: SettingService,\n    protected readonly attachmentsStorageService: AttachmentsStorageService\n  ) {}\n\n  async getSetting(names?: string[]): Promise<ISettingVo> {\n    return this.settingService.getSetting(names);\n  }\n\n  async updateSetting(updateSettingRo: Partial<ISettingVo>): Promise<ISettingVo> {\n    return this.settingService.updateSetting(updateSettingRo);\n  }\n\n  async getServerBrand(): Promise<{ brandName: string; brandLogo: string }> {\n    const logoPath = join(StorageAdapter.getDir(UploadType.Logo), EMAIL_LOGO_TOKEN);\n    return {\n      brandName: 'Teable',\n      brandLogo: getPublicFullStorageUrl(logoPath),\n    };\n  }\n\n  async uploadLogo(file: Express.Multer.File) {\n    const token = 'brand';\n    const path = join(StorageAdapter.getDir(UploadType.Logo), 'brand');\n    const bucket = StorageAdapter.getBucket(UploadType.Logo);\n\n    const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, file.path, {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      'Content-Type': file.mimetype,\n    });\n\n    const { size, mimetype } = file;\n    const userId = this.cls.get('user.id');\n\n    await this.prismaService.txClient().attachments.upsert({\n      create: {\n        hash,\n        size,\n        mimetype,\n        token,\n        path,\n        createdBy: userId,\n      },\n      update: {\n        hash,\n        size,\n        mimetype,\n        path,\n      },\n      where: {\n        token,\n        deletedTime: null,\n      },\n    });\n\n    await this.updateSetting({ brandLogo: path });\n\n    return {\n      url: getPublicFullStorageUrl(path),\n    };\n  }\n\n  /**\n   * Test attachment support with a specific data source (URL or base64)\n   */\n  private async testAttachmentWithData(\n    modelInstance: LanguageModel,\n    data: string,\n    contentType: string\n  ): Promise<boolean> {\n    // Request AI to put the letter in quotes for strict validation\n    const testPrompt =\n      'What letter or character do you see in this image/file? ' +\n      'Please respond with ONLY the letter wrapped in double quotes, like \"X\". ' +\n      'Do not add any other text.';\n\n    try {\n      const textPart: TextPart = {\n        type: 'text',\n        text: testPrompt,\n      };\n\n      const filePart: FilePart = {\n        type: 'file' as const,\n        data,\n        mediaType: contentType,\n      };\n\n      const res = await generateText({\n        model: modelInstance,\n        messages: [\n          {\n            role: 'user',\n            content: [textPart, filePart],\n          },\n        ],\n        temperature: 0,\n      });\n\n      const responseText = res.text.trim();\n\n      // Log the full response for debugging\n      this.logger.log(\n        `[testAttachment] Full AI response: \"${responseText}\", data preview: \"${data.substring(0, 100)}...\"`\n      );\n\n      // Strict validation: expect exactly \"K\" or \"k\" in quotes\n      const quotedLetterMatch = responseText.match(/\"([^\"]+)\"/);\n      const letterInQuotes = quotedLetterMatch ? quotedLetterMatch[1].toLowerCase() : null;\n      const containsExpectedInQuotes = letterInQuotes === expectedLetter;\n\n      // Fallback: also check if response is just the letter (some models might not follow format)\n      const isJustTheLetter =\n        responseText.toLowerCase() === expectedLetter ||\n        responseText.toLowerCase() === expectedLetter.toUpperCase();\n\n      // Anti-hallucination checks:\n      // 1. Response should be short (< 30 chars) - a direct answer\n      const isShortResponse = responseText.length < 30;\n\n      // 2. Response should not indicate inability to see the file\n      const cannotSeeIndicators = [\n        'cannot see',\n        \"can't see\",\n        'unable to',\n        'no image',\n        'no file',\n        \"don't see\",\n        'not visible',\n        'not able to',\n        'sorry',\n        'error',\n      ];\n      const indicatesCannotSee = cannotSeeIndicators.some((indicator) =>\n        responseText.toLowerCase().includes(indicator)\n      );\n\n      const isValid =\n        (containsExpectedInQuotes || isJustTheLetter) && isShortResponse && !indicatesCannotSee;\n\n      this.logger.log(\n        `[testAttachment] Validation: letterInQuotes=\"${letterInQuotes}\", ` +\n          `containsExpectedInQuotes=${containsExpectedInQuotes}, isJustTheLetter=${isJustTheLetter}, ` +\n          `isShortResponse=${isShortResponse}, indicatesCannotSee=${indicatesCannotSee}, ` +\n          `isValid=${isValid}`\n      );\n\n      return isValid;\n    } catch (error) {\n      this.logger.error(\n        `[testAttachment] Error: ${error instanceof Error ? error.message : unknownErrorMsg}`\n      );\n      return false;\n    }\n  }\n\n  /**\n   * Get signed URL for a test file\n   */\n  private async getTestFileSignedUrl(token: string): Promise<string | null> {\n    try {\n      const bucket = StorageAdapter.getBucket(UploadType.ChatFile);\n      const url = await this.attachmentsStorageService.getPreviewUrl(bucket, token);\n      return url || null;\n    } catch (error) {\n      this.logger.error(`Failed to get signed URL for ${token}: ${error}`);\n      return null;\n    }\n  }\n\n  /**\n   * Get base64 data URL for a test file\n   */\n  private async getTestFileBase64(filePath: string, contentType: string): Promise<string | null> {\n    try {\n      const fullPath = resolve(process.cwd(), filePath);\n      const fileBuffer = await readFile(fullPath);\n      const base64 = fileBuffer.toString('base64');\n      return `data:${contentType};base64,${base64}`;\n    } catch (error) {\n      this.logger.error(`Failed to read file for base64 ${filePath}: ${error}`);\n      return null;\n    }\n  }\n\n  /**\n   * Test image or PDF support with both URL and base64 forms in parallel\n   * Returns detailed support info: { url: boolean, base64: boolean }\n   */\n  private async testAttachmentAbility(\n    modelInstance: LanguageModel,\n    token: string,\n    filePath: string,\n    contentType: string\n  ): Promise<IAbilityDetail> {\n    // Get both data sources in parallel\n    const [signedUrl, base64Data] = await Promise.all([\n      this.getTestFileSignedUrl(token),\n      this.getTestFileBase64(filePath, contentType),\n    ]);\n\n    // Run both tests in parallel\n    const [urlResult, base64Result] = await Promise.all([\n      signedUrl\n        ? this.testAttachmentWithData(modelInstance, signedUrl, contentType).then((r) => {\n            this.logger.log(`testAttachmentAbility URL test for ${token}: ${r}`);\n            return r;\n          })\n        : Promise.resolve(false),\n      base64Data\n        ? this.testAttachmentWithData(modelInstance, base64Data, contentType).then((r) => {\n            this.logger.log(`testAttachmentAbility base64 test for ${token}: ${r}`);\n            return r;\n          })\n        : Promise.resolve(false),\n    ]);\n\n    return { url: urlResult, base64: base64Result };\n  }\n\n  private async testToolCall(modelInstance: LanguageModel): Promise<boolean> {\n    try {\n      // Define tools inline with generateText for proper type inference\n      const result = await generateText({\n        model: modelInstance,\n        prompt: 'What is the weather in Tokyo? Please use the available tool.',\n        tools: {\n          get_weather: tool({\n            description: 'Get the current weather for a location',\n            inputSchema: z.object({\n              location: z.string().describe('The city name'),\n            }),\n            execute: async ({ location }) => `Weather in ${location}: Sunny, 25°C`,\n          }),\n        },\n      });\n\n      // Check multiple ways to detect tool calls\n      // 1. Check toolCalls directly on result\n      const hasDirectToolCall = result.toolCalls && result.toolCalls.length > 0;\n      // 2. Check steps for tool calls\n      const hasStepToolCall = result.steps?.some(\n        (step) => step.toolCalls && step.toolCalls.length > 0\n      );\n      // 3. Check toolResults\n      const hasToolResults = result.toolResults && result.toolResults.length > 0;\n\n      const hasToolCall = hasDirectToolCall || hasStepToolCall || hasToolResults;\n\n      this.logger.log(\n        `testToolCall result: hasDirectToolCall=${hasDirectToolCall}, hasStepToolCall=${hasStepToolCall}, hasToolResults=${hasToolResults}`\n      );\n      return hasToolCall;\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : unknownErrorMsg;\n      this.logger.error(`testToolCall error: ${errorMessage}`);\n\n      // Any error during tool call test means the model cannot properly use tools\n      // Even schema errors indicate the model/provider combination is not usable for tool calling\n      this.logger.log('testToolCall: Error during test, marking as unsupported');\n      return false;\n    }\n  }\n\n  private async testChatModelAbility(\n    modelInstance: LanguageModel,\n    ability: ITestLLMRo['ability']\n  ): Promise<IChatModelAbility> {\n    if (!ability?.length) {\n      return {};\n    }\n\n    const testAbilities = uniq(ability);\n    const result: IChatModelAbility = {};\n\n    // Run all tests in parallel for better performance\n    const testPromises: Promise<void>[] = [];\n\n    if (testAbilities.includes(chatModelAbilityType.enum.image)) {\n      testPromises.push(\n        this.testAttachmentAbility(\n          modelInstance,\n          actTestImageToken,\n          testImagePath,\n          'image/png'\n        ).then((detail) => {\n          // Store detailed result - at least one form should work\n          result.image = detail;\n        })\n      );\n    }\n\n    if (testAbilities.includes(chatModelAbilityType.enum.pdf)) {\n      testPromises.push(\n        this.testAttachmentAbility(\n          modelInstance,\n          actTestPdfToken,\n          testPdfPath,\n          'application/pdf'\n        ).then((detail) => {\n          // Store detailed result - at least one form should work\n          result.pdf = detail;\n        })\n      );\n    }\n\n    if (testAbilities.includes(chatModelAbilityType.enum.toolCall)) {\n      testPromises.push(\n        this.testToolCall(modelInstance).then((supported) => {\n          result.toolCall = supported;\n        })\n      );\n    }\n\n    // Wait for all tests to complete\n    await Promise.all(testPromises);\n\n    return result;\n  }\n\n  private parseModelKey(modelKey: string) {\n    const [type, model, name] = modelKey.split('@');\n    return { type, model, name };\n  }\n\n  async testLLM(testLLMRo: ITestLLMRo): Promise<ITestLLMVo> {\n    const {\n      type,\n      baseUrl,\n      apiKey,\n      models,\n      ability,\n      modelKey,\n      testImageGeneration,\n      testImageToImage,\n    } = testLLMRo;\n\n    try {\n      const modelArray = models.split(',');\n      const model = modelKey ? this.parseModelKey(modelKey).model : modelArray[0];\n\n      // Handle AI Gateway separately using createGateway from AI SDK\n      // See: https://ai-sdk.dev/providers/ai-sdk-providers/ai-gateway\n      if (type === LLMProviderType.AI_GATEWAY) {\n        const gatewayProvider = createGateway({\n          apiKey,\n          baseURL: baseUrl || undefined,\n        });\n\n        // Handle image generation model testing\n        if (testImageGeneration) {\n          // Gemini image models via Gateway use generateText, not experimental_generateImage\n          throw new CustomHttpException(\n            'Image generation testing not supported for AI Gateway models yet',\n            HttpErrorCode.VALIDATION_ERROR\n          );\n        }\n\n        // Standard text model testing\n        const testPrompt = 'Hello, please respond with \"Connection successful!\"';\n        const modelInstance = gatewayProvider(model) as unknown as LanguageModel;\n        const { text } = await generateText({\n          model: modelInstance,\n          prompt: testPrompt,\n          temperature: 1,\n        });\n        const supportAbilities = await this.testChatModelAbility(modelInstance, ability);\n        return {\n          success: true,\n          response: text,\n          ability: supportAbilities,\n        };\n      }\n\n      const provider = modelProviders[type as keyof typeof modelProviders];\n      const providerOptions = getAdaptedProviderOptions(type, {\n        name: model,\n        baseURL: baseUrl,\n        apiKey,\n      });\n      const modelProvider = provider({\n        ...providerOptions,\n      } as never) as OpenAIProvider;\n\n      // Handle image generation model testing\n      if (testImageGeneration) {\n        return await this.testImageGenerationModel(modelProvider, model, type, testImageToImage);\n      }\n\n      // Standard text model testing\n      const testPrompt = 'Hello, please respond with \"Connection successful!\"';\n      const modelInstance = modelProvider(model) as unknown as LanguageModel;\n      const { text } = await generateText({\n        model: modelInstance,\n        prompt: testPrompt,\n        temperature: 1,\n      });\n      const supportAbilities = await this.testChatModelAbility(modelInstance, ability);\n      return {\n        success: true,\n        response: text,\n        ability: supportAbilities,\n      };\n    } catch (error) {\n      const message = error instanceof Error ? error.message : unknownErrorMsg;\n      throw new CustomHttpException(\n        'LLM test failed with error: ' + message,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.ai.testLLMFailed',\n          },\n        }\n      );\n    }\n  }\n\n  private async testImageGenerationModel(\n    modelProvider: OpenAIProvider,\n    model: string,\n    providerType: LLMProviderType,\n    testImageToImage?: boolean\n  ): Promise<ITestLLMVo> {\n    try {\n      // Google Gemini native image generation models use generateText with responseModalities\n      if (providerType === LLMProviderType.GOOGLE) {\n        return await this.testGoogleImageGeneration(modelProvider, model, testImageToImage);\n      }\n\n      // OpenAI-style image generation (DALL-E, etc.)\n\n      const imageModel = modelProvider.image(model);\n\n      if (testImageToImage) {\n        // Test image-to-image: provide an image as input\n        // Note: Not all image models support this, so we catch errors gracefully\n        const testImageUrl =\n          'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';\n        await experimental_generateImage({\n          model: imageModel,\n          prompt: 'A simple test image',\n          n: 1,\n          size: '256x256',\n          providerOptions: {\n            openai: {\n              image: testImageUrl,\n            },\n          },\n        });\n      } else {\n        // Test basic text-to-image generation\n        await experimental_generateImage({\n          model: imageModel,\n          prompt: 'A simple test: draw a small red circle',\n          n: 1,\n          size: '256x256',\n        });\n      }\n\n      return {\n        success: true,\n        response: testImageToImage\n          ? 'Image-to-image generation successful'\n          : 'Image generation successful',\n      };\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Image generation failed';\n      return {\n        success: false,\n        response: message,\n      };\n    }\n  }\n\n  /**\n   * Test Google Gemini native image generation models\n   * These models use generateText with responseModalities: ['TEXT', 'IMAGE']\n   */\n  private async testGoogleImageGeneration(\n    modelProvider: OpenAIProvider,\n    model: string,\n    testImageToImage?: boolean\n  ): Promise<ITestLLMVo> {\n    try {\n      const modelInstance = modelProvider(model) as unknown as LanguageModel;\n\n      if (testImageToImage) {\n        // Test image-to-image with a simple 1x1 pixel image\n        const testImageBase64 =\n          'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';\n\n        const result = await generateText({\n          model: modelInstance,\n          messages: [\n            {\n              role: 'user',\n              content: [\n                {\n                  type: 'image',\n                  image: `data:image/png;base64,${testImageBase64}`,\n                },\n                {\n                  type: 'text',\n                  text: 'Generate a variation of this image with a red circle',\n                },\n              ],\n            },\n          ],\n          providerOptions: {\n            google: {\n              responseModalities: ['TEXT', 'IMAGE'],\n            },\n          },\n        });\n\n        // Check if we got any response (text or image parts)\n        if (result.text || result.response) {\n          return {\n            success: true,\n            response: 'Image-to-image generation successful',\n          };\n        }\n      } else {\n        // Test text-to-image generation\n        const result = await generateText({\n          model: modelInstance,\n          prompt: 'Generate an image of a simple red circle on white background',\n          providerOptions: {\n            google: {\n              responseModalities: ['TEXT', 'IMAGE'],\n            },\n          },\n        });\n\n        // Check if we got any response\n        if (result.text || result.response) {\n          return {\n            success: true,\n            response: 'Image generation successful',\n          };\n        }\n      }\n\n      return {\n        success: false,\n        response: 'No image generated',\n      };\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Image generation failed';\n      return {\n        success: false,\n        response: message,\n      };\n    }\n  }\n\n  async setMailTransportConfig(setMailTransportConfigRo: ISetSettingMailTransportConfigRo) {\n    const { name, transportConfig } = setMailTransportConfigRo;\n    await verifyTransport(transportConfig);\n    await this.settingService.updateSetting({\n      [name]: transportConfig,\n    });\n  }\n\n  /**\n   * Test a single model and return the result\n   * This is a non-throwing version for batch testing\n   */\n  private async testSingleModel(\n    provider: Required<LLMProvider>,\n    model: string\n  ): Promise<IModelTestResult> {\n    const { type, name: providerName, baseUrl, apiKey } = provider;\n    const modelKey = `${type}@${model}@${providerName}`;\n    const testPrompt = 'Hello, please respond with \"Connection successful!\"';\n\n    try {\n      let modelInstance: LanguageModel;\n\n      // Handle AI Gateway separately\n      if (type === LLMProviderType.AI_GATEWAY) {\n        const gatewayProvider = createGateway({\n          apiKey,\n          baseURL: baseUrl || undefined,\n        });\n        modelInstance = gatewayProvider(model) as unknown as LanguageModel;\n      } else {\n        const providerFactory = modelProviders[type as keyof typeof modelProviders];\n\n        if (!providerFactory) {\n          return {\n            modelKey,\n            providerName,\n            providerType: type,\n            model,\n            success: false,\n            error: `Unsupported provider type: ${type}`,\n          };\n        }\n\n        const providerOptions = getAdaptedProviderOptions(type, {\n          name: model,\n          baseURL: baseUrl,\n          apiKey,\n        });\n        const modelProvider = providerFactory({\n          ...providerOptions,\n        } as never) as OpenAIProvider;\n        modelInstance = modelProvider(model) as unknown as LanguageModel;\n      }\n\n      // Test basic generation\n      await generateText({\n        model: modelInstance,\n        prompt: testPrompt,\n        temperature: 1,\n      });\n\n      // Test image support (vision capability)\n      const ability = await this.testChatModelAbility(modelInstance, [\n        chatModelAbilityType.enum.image,\n      ]);\n\n      return {\n        modelKey,\n        providerName,\n        providerType: type,\n        model,\n        success: true,\n        ability,\n      };\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : unknownErrorMsg;\n      this.logger.error(`Batch test failed for model ${modelKey}: ${errorMessage}`);\n\n      return {\n        modelKey,\n        providerName,\n        providerType: type,\n        model,\n        success: false,\n        error: errorMessage,\n      };\n    }\n  }\n\n  /**\n   * Batch test all configured LLM models\n   * Tests basic generation and image (attachment) support for each model\n   */\n  async batchTestLLM(batchTestLLMRo?: IBatchTestLLMRo): Promise<IBatchTestLLMVo> {\n    // Get providers from request or from settings\n    let providers: LLMProvider[];\n\n    if (batchTestLLMRo?.providers && batchTestLLMRo.providers.length > 0) {\n      providers = batchTestLLMRo.providers;\n    } else {\n      const setting = await this.getSetting();\n      providers = setting.aiConfig?.llmProviders ?? [];\n    }\n\n    if (providers.length === 0) {\n      return {\n        totalModels: 0,\n        testedModels: 0,\n        successCount: 0,\n        failedCount: 0,\n        results: [],\n      };\n    }\n\n    // Expand all models from all providers\n    const modelTests: { provider: Required<LLMProvider>; model: string }[] = [];\n\n    for (const provider of providers) {\n      if (!provider.apiKey || !provider.baseUrl || !provider.models) {\n        continue;\n      }\n\n      const models = provider.models\n        .split(',')\n        .map((m) => m.trim())\n        .filter(Boolean);\n      for (const model of models) {\n        modelTests.push({\n          provider: provider as Required<LLMProvider>,\n          model,\n        });\n      }\n    }\n\n    const totalModels = modelTests.length;\n\n    if (totalModels === 0) {\n      return {\n        totalModels: 0,\n        testedModels: 0,\n        successCount: 0,\n        failedCount: 0,\n        results: [],\n      };\n    }\n\n    // Run all tests in parallel with concurrency limit\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    const CONCURRENCY_LIMIT = 5;\n    const results: IModelTestResult[] = [];\n\n    for (let i = 0; i < modelTests.length; i += CONCURRENCY_LIMIT) {\n      const batch = modelTests.slice(i, i + CONCURRENCY_LIMIT);\n      const batchResults = await Promise.all(\n        batch.map(({ provider, model }) => this.testSingleModel(provider, model))\n      );\n      results.push(...batchResults);\n    }\n\n    const successCount = results.filter((r) => r.success).length;\n    const failedCount = results.filter((r) => !r.success).length;\n\n    return {\n      totalModels,\n      testedModels: results.length,\n      successCount,\n      failedCount,\n      results,\n    };\n  }\n\n  /**\n   * Test API key validity for AI Gateway or v0\n   * Optionally also tests attachment transfer modes (URL and Base64)\n   * When testAttachment is true, results are automatically saved to appConfig\n   */\n  async testApiKey(testApiKeyRo: ITestApiKeyRo): Promise<ITestApiKeyVo> {\n    const { type, apiKey, baseUrl, testAttachment } = testApiKeyRo;\n\n    if (type === 'aiGateway') {\n      const keyResult = await this.testAiGatewayKey(apiKey, baseUrl);\n\n      // If key test failed or attachment test not requested, return early\n      if (!keyResult.success || !testAttachment) {\n        return keyResult;\n      }\n\n      // Key is valid, now test attachment transfer modes\n      const attachmentResult = await this.testAttachmentTransferModes(apiKey, baseUrl);\n\n      // Auto-save results and switch mode if needed\n      if (attachmentResult) {\n        await this.saveAttachmentTestResults(attachmentResult);\n      }\n\n      return {\n        ...keyResult,\n        attachmentTest: attachmentResult,\n      };\n    } else if (type === 'v0') {\n      return this.testV0Key(apiKey, baseUrl);\n    } else if (type === 'vercel') {\n      return this.testVercelToken(apiKey, baseUrl);\n    }\n\n    return { success: false, error: { code: 'unknown', message: 'Unknown API type' } };\n  }\n\n  private static readonly URL_CHECKER_ENDPOINT = 'https://access-checker.teable.ai/check';\n  private static readonly URL_CHECKER_KEY = 'teable-checker-sk-2026xYz9Kw3mN7pQ';\n\n  private getStorageTestFileUrl(): string | undefined {\n    const { provider } = this.storageConfig;\n    if (provider === 'local') {\n      return undefined;\n    }\n    const logoPath = join(StorageAdapter.getDir(UploadType.Logo), EMAIL_LOGO_TOKEN);\n    return getPublicFullStorageUrl(logoPath);\n  }\n\n  private async checkUrlAccessible(\n    url: string,\n    setting: { instanceId?: string; createdTime?: string | number | Date }\n  ): Promise<{\n    success: boolean;\n    statusCode?: number;\n    error?: string;\n    checkedFrom?: string;\n  }> {\n    const deployedAt = String(setting.createdTime || '');\n    const resp = await axios.get<{\n      success: boolean;\n      statusCode?: number;\n      latencyMs: number;\n      error?: string;\n      checkedFrom: string;\n    }>(SettingOpenApiService.URL_CHECKER_ENDPOINT, {\n      timeout: 20000,\n      params: {\n        url,\n        instanceId: setting.instanceId || '',\n        version: process.env.NEXT_PUBLIC_BUILD_VERSION || '',\n        deployedAt,\n      },\n      headers: {\n        Authorization: `Bearer ${SettingOpenApiService.URL_CHECKER_KEY}`,\n      },\n    });\n    return resp.data;\n  }\n\n  private async checkStorageAccess(setting: {\n    instanceId?: string;\n    createdTime?: string | number | Date;\n  }): Promise<ITestPublicAccessVo['storageCheck']> {\n    const storageUrl = this.getStorageTestFileUrl();\n    if (!storageUrl) {\n      return undefined;\n    }\n\n    try {\n      const data = await this.checkUrlAccessible(storageUrl, setting);\n      if (data.success) {\n        return { success: true, storageUrl };\n      }\n      return {\n        success: false,\n        storageUrl,\n        error: data.error || `Not reachable (HTTP ${data.statusCode}) from ${data.checkedFrom}`,\n      };\n    } catch (error) {\n      const msg = error instanceof Error ? error.message : 'Storage check failed';\n      this.logger.warn(`Storage access check failed: ${msg}`);\n      return { success: false, storageUrl, error: msg };\n    }\n  }\n\n  async testPublicAccess(): Promise<ITestPublicAccessVo> {\n    const publicOrigin = this.baseConfig.publicOrigin;\n\n    if (!publicOrigin) {\n      return { success: false, error: 'PUBLIC_ORIGIN not set' };\n    }\n\n    try {\n      const setting = await this.settingService.getSetting();\n\n      const originData = await this.checkUrlAccessible(`${publicOrigin}/health`, setting);\n      const originOk = originData.success;\n      const originError = originOk\n        ? undefined\n        : originData.error ||\n          `Not reachable (HTTP ${originData.statusCode}) from ${originData.checkedFrom}`;\n\n      const storageCheck = await this.checkStorageAccess(setting);\n      const allOk = originOk && (storageCheck?.success ?? true);\n\n      return { success: allOk, publicOrigin, error: originError, storageCheck };\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Check failed';\n      this.logger.warn(`Public access check failed: ${message}`);\n      return { success: false, publicOrigin, error: message };\n    }\n  }\n\n  /**\n   * Save attachment test results to aiConfig and auto-switch mode if needed\n   */\n  private async saveAttachmentTestResults(\n    attachmentResult: NonNullable<ITestApiKeyVo['attachmentTest']>\n  ): Promise<void> {\n    try {\n      const { aiConfig } = await this.settingService.getSetting();\n      const currentMode = aiConfig?.attachmentTransferMode || 'url';\n\n      // Prepare the update\n      const update: {\n        attachmentTest: NonNullable<ITestApiKeyVo['attachmentTest']> & { testedAt: string };\n        attachmentTransferMode?: 'url' | 'base64';\n      } = {\n        attachmentTest: {\n          ...attachmentResult,\n          testedAt: new Date().toISOString(),\n        },\n      };\n\n      // Auto-switch mode if:\n      // 1. URL mode failed but Base64 succeeded -> switch to base64\n      // 2. Current mode is base64 but now URL works -> switch to url (optional, keep user choice)\n      const urlWorks = attachmentResult.urlMode?.success ?? false;\n      const base64Works = attachmentResult.base64Mode?.success ?? false;\n\n      if (!urlWorks && base64Works && currentMode === 'url') {\n        // URL doesn't work, switch to base64\n        update.attachmentTransferMode = 'base64';\n        this.logger.log('Auto-switching attachment transfer mode to base64 (URL mode failed)');\n      }\n      // Note: We don't auto-switch back to URL even if it now works,\n      // because the user might have intentionally chosen base64\n\n      await this.settingService.updateSetting({\n        aiConfig: {\n          ...aiConfig,\n          llmProviders: aiConfig?.llmProviders ?? [],\n          ...update,\n        },\n      });\n      this.logger.log('Saved attachment test results to aiConfig');\n    } catch (error) {\n      this.logger.error(`Failed to save attachment test results: ${error}`);\n      // Don't throw - this is a non-critical operation\n    }\n  }\n\n  /**\n   * Test attachment transfer modes (URL and Base64) in parallel\n   * Uses vision model to verify if AI can access attachments via each mode\n   */\n  private async testAttachmentTransferModes(\n    apiKey: string,\n    baseUrl?: string\n  ): Promise<ITestApiKeyVo['attachmentTest']> {\n    const testModel = 'openai/gpt-4o-mini';\n\n    try {\n      // Create gateway instance\n      const gatewayOptions: { apiKey: string; baseURL?: string } = { apiKey };\n      if (baseUrl) {\n        gatewayOptions.baseURL = baseUrl;\n      }\n      const gateway = createGateway(gatewayOptions);\n      const modelInstance = gateway(testModel);\n\n      // Test image with both URL and Base64 modes in parallel\n      const imageResult = await this.testAttachmentAbility(\n        modelInstance,\n        actTestImageToken,\n        testImagePath,\n        'image/png'\n      );\n\n      // Determine recommended mode based on test results\n      let recommendedMode: 'url' | 'base64' | undefined;\n      if (imageResult.url && imageResult.base64) {\n        recommendedMode = 'url'; // Both work, prefer URL for performance\n      } else if (!imageResult.url && imageResult.base64) {\n        recommendedMode = 'base64'; // Only Base64 works\n      } else if (imageResult.url && !imageResult.base64) {\n        recommendedMode = 'url'; // Only URL works (rare case)\n      }\n      // If both fail, recommendedMode remains undefined\n\n      return {\n        urlMode: {\n          success: imageResult.url ?? false,\n          errorMessage: imageResult.url ? undefined : 'AI service cannot access attachment URL',\n        },\n        base64Mode: {\n          success: imageResult.base64 ?? false,\n          errorMessage: imageResult.base64\n            ? undefined\n            : 'AI service cannot process base64 attachment',\n        },\n        recommendedMode,\n        testedOrigin: this.baseConfig.publicOrigin,\n      };\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : String(error);\n      this.logger.error(`testAttachmentTransferModes error: ${errorMessage}`);\n\n      return {\n        urlMode: { success: false, errorMessage },\n        base64Mode: { success: false, errorMessage },\n        testedOrigin: this.baseConfig.publicOrigin,\n      };\n    }\n  }\n\n  private async testAiGatewayKey(apiKey: string, baseUrl?: string): Promise<ITestApiKeyVo> {\n    try {\n      // Only set baseURL if user provided a custom one, otherwise use SDK default\n      // SDK default: https://ai-gateway.vercel.sh/v1/ai\n      const gatewayOptions: { apiKey: string; baseURL?: string } = { apiKey };\n      if (baseUrl) {\n        gatewayOptions.baseURL = baseUrl;\n      }\n      const gateway = createGateway(gatewayOptions);\n\n      // Use a minimal generateText call to verify the key\n      await generateText({\n        model: gateway('openai/gpt-4o-mini'),\n        prompt: 'hi',\n      });\n\n      return { success: true };\n    } catch (error) {\n      return this.parseApiKeyError(error, 'AI Gateway');\n    }\n  }\n\n  private parseApiKeyError(error: unknown, service: string): ITestApiKeyVo {\n    const errorMessage = String(error).toLowerCase();\n    const rawMessage = String(error);\n    const errorObj = error as {\n      status?: number;\n      statusCode?: number;\n      message?: string;\n      cause?: { status?: number; message?: string };\n      data?: { error?: { type?: string; code?: string; message?: string } };\n    };\n\n    const status = errorObj.status || errorObj.statusCode || errorObj.cause?.status;\n    const detailedMessage = errorObj.data?.error?.message || errorObj.message || rawMessage;\n\n    this.logger.error(\n      '%s key test failed: status=%s, message=%s, raw=%s',\n      service,\n      status,\n      detailedMessage,\n      rawMessage\n    );\n\n    // Determine error code based on status and message\n    const code = this.getApiKeyErrorCode(status, errorMessage);\n    return { success: false, error: { code, message: detailedMessage } };\n  }\n\n  private getApiKeyErrorCode(\n    status: number | undefined,\n    errorMessage: string\n  ):\n    | 'unauthorized'\n    | 'forbidden'\n    | 'need_credit_card'\n    | 'insufficient_quota'\n    | 'network_error'\n    | 'unknown' {\n    // 401 unauthorized\n    if (\n      status === 401 ||\n      errorMessage.includes('401') ||\n      errorMessage.includes('unauthorized') ||\n      errorMessage.includes('invalid api key') ||\n      errorMessage.includes('invalid_api_key')\n    ) {\n      return 'unauthorized';\n    }\n\n    // 403 forbidden / credit card required\n    if (status === 403 || errorMessage.includes('403')) {\n      if (\n        errorMessage.includes('customer_verification_required') ||\n        errorMessage.includes('credit card')\n      ) {\n        return 'need_credit_card';\n      }\n      return 'forbidden';\n    }\n\n    // Insufficient quota\n    if (\n      errorMessage.includes('insufficient') ||\n      errorMessage.includes('quota') ||\n      errorMessage.includes('balance')\n    ) {\n      return 'insufficient_quota';\n    }\n\n    // Network errors\n    if (\n      errorMessage.includes('econnrefused') ||\n      errorMessage.includes('enotfound') ||\n      errorMessage.includes('timeout') ||\n      errorMessage.includes('fetch failed')\n    ) {\n      return 'network_error';\n    }\n\n    return 'unknown';\n  }\n\n  private async testV0Key(apiKey: string, baseUrl?: string): Promise<ITestApiKeyVo> {\n    const url = `${baseUrl || 'https://api.v0.dev/v1'}/projects`;\n\n    try {\n      await axios.get(url, {\n        headers: { Authorization: `Bearer ${apiKey}` },\n      });\n      return { success: true };\n    } catch (error) {\n      if (!axios.isAxiosError(error)) {\n        return this.parseApiKeyError(error, 'v0');\n      }\n\n      const status = error.response?.status;\n      const data = error.response?.data as {\n        error?: { type?: string; code?: string; message?: string };\n      };\n      const detailedMessage = data?.error?.message || error.message;\n\n      this.logger.error('v0 key test failed: status=%s, message=%s', status, detailedMessage);\n\n      // No response = network error\n      if (!error.response) {\n        return { success: false, error: { code: 'network_error', message: detailedMessage } };\n      }\n\n      const code = this.getV0ErrorCode(status, data, detailedMessage);\n      return { success: false, error: { code, message: detailedMessage } };\n    }\n  }\n\n  private async testVercelToken(token: string, baseUrl?: string): Promise<ITestApiKeyVo> {\n    const apiBase = baseUrl || 'https://api.vercel.com';\n\n    const url = `${apiBase}/v2/user`;\n    try {\n      await axios.get(url, { headers: { Authorization: `Bearer ${token}` } });\n      return { success: true };\n    } catch (error) {\n      if (!axios.isAxiosError(error)) {\n        return this.parseApiKeyError(error, 'vercel');\n      }\n\n      const status = error.response?.status;\n      const detailedMessage =\n        (error.response?.data as { error?: { message?: string } })?.error?.message || error.message;\n\n      this.logger.error('Vercel token test failed: status=%s, message=%s', status, detailedMessage);\n\n      if (!error.response) {\n        return { success: false, error: { code: 'network_error', message: detailedMessage } };\n      }\n\n      if (status === 401 || status === 403) {\n        return { success: false, error: { code: 'unauthorized', message: detailedMessage } };\n      }\n\n      return { success: false, error: { code: 'unknown', message: detailedMessage } };\n    }\n  }\n\n  private getV0ErrorCode(\n    status: number | undefined,\n    data: { error?: { type?: string; code?: string; message?: string } } | undefined,\n    message: string\n  ): 'unauthorized' | 'forbidden' | 'insufficient_quota' | 'unknown' {\n    if (status === 401) return 'unauthorized';\n    if (status === 403) return 'forbidden';\n\n    const errorType = data?.error?.type?.toLowerCase() || '';\n    const errorCode = data?.error?.code?.toLowerCase() || '';\n    const errorMsg = message.toLowerCase();\n\n    if (\n      errorType.includes('insufficient') ||\n      errorCode.includes('insufficient') ||\n      errorMsg.includes('insufficient') ||\n      errorMsg.includes('quota')\n    ) {\n      return 'insufficient_quota';\n    }\n\n    return 'unknown';\n  }\n\n  /**\n   * Get available models from AI Gateway\n   * Returns empty array if gateway is not configured\n   * Uses Redis cache with 1 hour TTL from SettingService\n   */\n  async getGatewayModels(): Promise<{\n    configured: boolean;\n    models: Array<{\n      id: string;\n      name?: string;\n      description?: string;\n      type?: GatewayModelType;\n      tags?: GatewayModelTag[];\n      contextWindow?: number;\n      maxTokens?: number;\n      created?: number;\n      ownedBy?: GatewayModelProvider;\n      pricing?: Record<string, unknown>;\n    }>;\n  }> {\n    // Check if gateway is configured\n    const { aiConfig } = await this.settingService.getSetting();\n    if (!aiConfig?.aiGatewayApiKey) {\n      return { configured: false, models: [] };\n    }\n\n    try {\n      const models = await this.settingService.getGatewayModels();\n      this.logger.log(`Fetched ${models.length} gateway models`);\n      return { configured: true, models };\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : String(error);\n      const errorStack = error instanceof Error ? error.stack : '';\n      this.logger.error(`Failed to fetch gateway models: ${errorMessage}`, errorStack);\n      // Return configured=true but empty models on error\n      // so frontend knows gateway is configured but had a fetch error\n      return { configured: true, models: [] };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/setting.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SettingService } from './setting.service';\n\n@Module({\n  imports: [],\n  exports: [SettingService],\n  providers: [SettingService],\n})\nexport class SettingModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/setting/setting.service.ts",
    "content": "/**\n * IMPORTANT LEGAL NOTICE:\n *\n * This file is part of Teable, licensed under the GNU Affero General Public License (AGPL).\n *\n * While Teable is open source software, the brand assets (including but not limited to\n * the Teable name, logo, and brand identity) are protected intellectual property.\n * Modification, replacement, or removal of these brand assets is strictly prohibited\n * and constitutes a violation of our trademark rights and the terms of the AGPL license.\n *\n * Under Section 7(e) of AGPLv3, we explicitly reserve all rights to the\n * Teable brand assets. Any unauthorized modification, redistribution, or use\n * of these assets, including creating derivative works that remove or replace\n * the brand assets, may result in legal action.\n */\n\nimport { BadRequestException, Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { SettingKey, convertGatewayApiModel } from '@teable/openapi';\nimport type { IGatewayApiModel, IGatewayApiModelRaw, ISettingVo } from '@teable/openapi';\nimport axios from 'axios';\nimport { isArray } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { PerformanceCacheService } from '../../performance-cache';\nimport type { IClsStore } from '../../types/cls';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { SettingModel } from '../model/setting';\n\n// In-memory cache for Gateway models (TTL: 1 hour)\nconst gatewayModelsCacheTtl = 60 * 60 * 1000;\n\ninterface IGatewayModelsCache {\n  data: IGatewayApiModel[];\n  expiresAt: number;\n}\n\n@Injectable()\nexport class SettingService {\n  private readonly logger = new Logger(SettingService.name);\n\n  // In-memory cache for Gateway models - faster than Redis for static data\n  private gatewayModelsCache: IGatewayModelsCache | null = null;\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly settingModel: SettingModel,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  async getSetting(names?: string[]): Promise<ISettingVo> {\n    const settings = await this.settingModel.getSetting();\n    const res: Record<string, unknown> = {\n      instanceId: '',\n    };\n    if (!isArray(settings)) {\n      return res as ISettingVo;\n    }\n\n    const nameSet = names ? new Set(names) : new Set(settings.map((setting) => setting.name));\n    for (const setting of settings) {\n      if (!nameSet.has(setting.name)) {\n        continue;\n      }\n      const value = this.parseSettingContent(setting.content);\n      if (setting.name === SettingKey.BRAND_LOGO) {\n        res[setting.name] = value ? getPublicFullStorageUrl(value as string) : value;\n      } else {\n        res[setting.name] = value;\n      }\n\n      if (setting.name === SettingKey.INSTANCE_ID) {\n        res.createdTime =\n          setting.createdTime instanceof Date\n            ? setting.createdTime.toISOString()\n            : setting.createdTime;\n      }\n    }\n\n    // Apply environment variable overrides\n    this.applyEnvOverrides(res);\n\n    return res as ISettingVo;\n  }\n\n  /**\n   * Apply environment variable overrides for settings\n   * - TEST_AI_CONFIG: Completely overrides aiConfig (for testing)\n   * - AI_GATEWAY_API_KEY: Fallback for aiConfig.aiGatewayApiKey if not set\n   */\n  private applyEnvOverrides(res: Record<string, unknown>): void {\n    // TEST_AI_CONFIG completely overrides aiConfig (for testing)\n    const testAiConfig = process.env.TEST_AI_CONFIG;\n    if (testAiConfig) {\n      try {\n        res[SettingKey.AI_CONFIG] = JSON.parse(testAiConfig);\n      } catch {\n        this.logger.warn('Failed to parse TEST_AI_CONFIG environment variable');\n      }\n    }\n\n    // AI_GATEWAY_API_KEY fallback for aiConfig.aiGatewayApiKey\n    const envAiGatewayApiKey = process.env.AI_GATEWAY_API_KEY;\n    if (envAiGatewayApiKey) {\n      const aiConfig = res[SettingKey.AI_CONFIG] as Record<string, unknown> | undefined;\n      if (!aiConfig?.aiGatewayApiKey) {\n        res[SettingKey.AI_CONFIG] = {\n          ...aiConfig,\n          aiGatewayApiKey: envAiGatewayApiKey,\n        };\n      }\n    }\n  }\n\n  async updateSetting(updateSettingRo: Partial<ISettingVo>): Promise<ISettingVo> {\n    const userId = this.cls.get('user.id');\n    const updates = Object.entries(updateSettingRo).map(([name, value]) => ({\n      where: { name },\n      update: { content: JSON.stringify(value ?? null), lastModifiedBy: userId },\n      create: {\n        name,\n        content: JSON.stringify(value ?? null),\n        createdBy: userId,\n      },\n    }));\n\n    const results = await Promise.all(\n      updates.map((update) => this.prismaService.txClient().setting.upsert(update))\n    );\n\n    const res: Record<string, unknown> = {};\n    for (const setting of results) {\n      const value = this.parseSettingContent(setting.content);\n      res[setting.name] = value;\n    }\n\n    return res as ISettingVo;\n  }\n\n  private parseSettingContent(content: string | null): unknown {\n    if (!content) return null;\n\n    try {\n      return JSON.parse(content);\n    } catch (error) {\n      // If parsing fails, return the original content\n      return content;\n    }\n  }\n\n  /**\n   * Fetch AI Gateway models with in-memory cache (1 hour TTL)\n   * In-memory is faster than Redis for this static data\n   */\n  async getGatewayModels(): Promise<IGatewayApiModel[]> {\n    // Check in-memory cache first\n    if (this.gatewayModelsCache && Date.now() < this.gatewayModelsCache.expiresAt) {\n      return this.gatewayModelsCache.data;\n    }\n\n    try {\n      const response = await axios.get<{ data: IGatewayApiModelRaw[] }>(\n        'https://ai-gateway.vercel.sh/v1/models',\n        { timeout: 10000 }\n      );\n\n      // Convert snake_case API response to camelCase\n      const models = (response.data?.data || []).map(convertGatewayApiModel);\n\n      // Update in-memory cache\n      this.gatewayModelsCache = {\n        data: models,\n        expiresAt: Date.now() + gatewayModelsCacheTtl,\n      };\n\n      return models;\n    } catch (error) {\n      // If fetch fails but we have stale cache, return it\n      if (this.gatewayModelsCache) {\n        this.logger.warn(`[getGatewayModels] Failed to refresh, using stale cache: ${error}`);\n        return this.gatewayModelsCache.data;\n      }\n\n      this.logger.error(\n        `Failed to fetch AI Gateway models ${error instanceof Error ? error.message : String(error)}`\n      );\n      throw new BadRequestException('Failed to fetch AI Gateway models');\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/guard/auth.guard.ts",
    "content": "import type { ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { AuthGuard as PassportAuthGuard } from '@nestjs/passport';\nimport { ANONYMOUS_USER_ID, HttpErrorCode, IdPrefix } from '@teable/core';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport { AuthGuard } from '../../auth/guard/auth.guard';\nimport { getTemplateHeader } from '../../auth/utils';\nimport { ShareAuthService } from '../share-auth.service';\nimport { SHARE_JWT_STRATEGY } from './constant';\nimport { IS_SHARE_LINK_VIEW } from './link-view.decorator';\nimport { IS_SHARE_SUBMIT_KEY } from './submit.decorator';\n\n@Injectable()\nexport class ShareAuthGuard extends PassportAuthGuard([SHARE_JWT_STRATEGY]) {\n  constructor(\n    private readonly shareAuthService: ShareAuthService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly authGuard: AuthGuard,\n    private readonly reflector: Reflector\n  ) {\n    super();\n  }\n\n  async validate(context: ExecutionContext, shareId: string) {\n    const req = context.switchToHttp().getRequest();\n    // share link view route\n    const isShareLinkView = this.reflector.getAllAndOverride<boolean>(IS_SHARE_LINK_VIEW, [\n      context.getHandler(),\n      context.getClass(),\n    ]);\n\n    if (isShareLinkView && shareId.startsWith(IdPrefix.Field)) {\n      const activate = (await this.authGuard.validate(context)) as boolean;\n      const templateHeader = getTemplateHeader(req);\n      const shareInfo = await this.shareAuthService.getLinkViewInfo(shareId, templateHeader);\n      req.shareInfo = shareInfo;\n      return activate;\n    }\n\n    const shareInfo = await this.shareAuthService.getShareViewInfo(shareId);\n\n    try {\n      req.shareInfo = shareInfo;\n      // submit route\n      const isShareSubmit = this.reflector.getAllAndOverride<boolean>(IS_SHARE_SUBMIT_KEY, [\n        context.getHandler(),\n        context.getClass(),\n      ]);\n      const submit = shareInfo.shareMeta?.submit;\n      if (isShareSubmit && submit?.allow && submit?.requireLogin) {\n        return this.authGuard.validate(context);\n      }\n\n      this.cls.set('user', {\n        id: ANONYMOUS_USER_ID,\n        name: ANONYMOUS_USER_ID,\n        email: '',\n      });\n\n      if (shareInfo.view?.shareMeta?.password) {\n        return (await super.canActivate(context)) as boolean;\n      }\n      return true;\n    } catch (err) {\n      throw new CustomHttpException('Unauthorized', HttpErrorCode.UNAUTHORIZED_SHARE);\n    }\n  }\n\n  async canActivate(context: ExecutionContext) {\n    const req = context.switchToHttp().getRequest();\n    const shareId = req.params.shareId;\n    return this.validate(context, shareId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/guard/constant.ts",
    "content": "export const SHARE_JWT_STRATEGY = 'share-jwt-strategy';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/guard/link-view.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const IS_SHARE_LINK_VIEW = 'isShareLinkView';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ShareLinkView = () => SetMetadata(IS_SHARE_LINK_VIEW, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/guard/share-auth-local.guard.ts",
    "content": "import type { CanActivate, ExecutionContext } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { ShareAuthService } from '../share-auth.service';\n\n@Injectable()\nexport class ShareAuthLocalGuard implements CanActivate {\n  constructor(private readonly shareAuthService: ShareAuthService) {}\n\n  async canActivate(context: ExecutionContext) {\n    const req = context.switchToHttp().getRequest();\n    const shareId = req.params.shareId;\n    const password = req.body.password;\n    const authShareId = await this.shareAuthService.authShareView(shareId, password);\n    req.shareId = authShareId;\n    req.password = password;\n    if (!authShareId) {\n      throw new CustomHttpException('Incorrect password.', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.share.incorrectPassword',\n        },\n      });\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/guard/submit.decorator.ts",
    "content": "import { SetMetadata } from '@nestjs/common';\n\nexport const IS_SHARE_SUBMIT_KEY = 'isShareSubmit';\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ShareSubmit = () => SetMetadata(IS_SHARE_SUBMIT_KEY, true);\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share-auth.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { JwtModule } from '@nestjs/jwt';\nimport { PassportModule } from '@nestjs/passport';\nimport { authConfig, type IAuthConfig } from '../../configs/auth.config';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { AuthModule } from '../auth/auth.module';\nimport { ShareAuthGuard } from './guard/auth.guard';\nimport { ShareAuthService } from './share-auth.service';\nimport { JwtStrategy } from './strategies/jwt.strategy';\n\n@Module({\n  imports: [\n    AuthModule,\n    PassportModule,\n    JwtModule.registerAsync({\n      useFactory: (config: IAuthConfig) => ({\n        secret: config.jwt.secret,\n        signOptions: {\n          expiresIn: config.jwt.expiresIn,\n        },\n      }),\n      inject: [authConfig.KEY],\n    }),\n  ],\n  providers: [JwtStrategy, ShareAuthService, DbProvider, ShareAuthGuard],\n  exports: [ShareAuthService, ShareAuthGuard],\n})\nexport class ShareAuthModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share-auth.service.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { FieldType, HttpErrorCode, isAnonymous } from '@teable/core';\nimport type { IViewVo, IShareViewMeta, ILinkFieldOptions } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { PermissionService } from '../auth/permission.service';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\nimport { createViewVoByRaw } from '../view/model/factory';\n\nexport interface IShareViewInfo {\n  shareId: string;\n  tableId: string;\n  view?: IViewVo;\n  linkOptions?: Pick<ILinkFieldOptions, 'filterByViewId' | 'visibleFieldIds' | 'filter'>;\n  shareMeta?: IShareViewMeta;\n}\n\nexport interface IJwtShareInfo {\n  shareId: string;\n  password: string;\n}\n\n@Injectable()\nexport class ShareAuthService {\n  constructor(\n    private readonly permissionService: PermissionService,\n    private readonly prismaService: PrismaService,\n    private readonly jwtService: JwtService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  async validateJwtToken(token: string) {\n    try {\n      return await this.jwtService.verifyAsync<IJwtShareInfo>(token);\n    } catch {\n      throw new UnauthorizedException();\n    }\n  }\n\n  async authShareView(shareId: string, pass: string): Promise<string | null> {\n    const view = await this.prismaService.view.findFirst({\n      where: { shareId, enableShare: true, deletedTime: null },\n      select: { shareId: true, shareMeta: true },\n    });\n    if (!view) {\n      return null;\n    }\n    const shareMeta = view.shareMeta ? (JSON.parse(view.shareMeta) as IShareViewMeta) : undefined;\n    const password = shareMeta?.password;\n    if (!password) {\n      throw new CustomHttpException(\n        'Password restriction is not enabled',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareAuth.passwordRestrictionNotEnabled',\n          },\n        }\n      );\n    }\n    return pass === password ? shareId : null;\n  }\n\n  async authToken(jwtShareInfo: IJwtShareInfo) {\n    return await this.jwtService.signAsync(jwtShareInfo);\n  }\n\n  async getShareViewInfo(shareId: string): Promise<IShareViewInfo> {\n    const view = await this.prismaService.view.findFirst({\n      where: { shareId, enableShare: true, deletedTime: null },\n    });\n    if (!view) {\n      throw new CustomHttpException('Share view not found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.shareAuth.shareViewNotFound',\n        },\n      });\n    }\n    const viewVo = createViewVoByRaw(view);\n    return {\n      shareId,\n      tableId: view.tableId,\n      view: createViewVoByRaw(view),\n      shareMeta: viewVo.shareMeta,\n    };\n  }\n\n  async getLinkViewInfo(linkFieldId: string, templateHeader?: string): Promise<IShareViewInfo> {\n    const fieldRaw = await this.prismaService.field\n      .findFirstOrThrow({\n        where: {\n          id: linkFieldId,\n          deletedTime: null,\n        },\n      })\n      .catch((_err) => {\n        throw new CustomHttpException(\n          `Link field ${linkFieldId} not exist`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.shareAuth.linkFieldNotFound',\n            },\n          }\n        );\n      });\n\n    const field = createFieldInstanceByRaw(fieldRaw);\n    if (field.type !== FieldType.Link) {\n      throw new CustomHttpException(\n        'Field is not a link field',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.fieldTypeNotLinkField',\n          },\n        }\n      );\n    }\n\n    if (templateHeader) {\n      const templateId = this.permissionService.getTemplateIdByHeader(templateHeader);\n      if (!templateId) {\n        throw new CustomHttpException(\n          `Template header is invalid`,\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.permission.templateHeaderInvalid',\n            },\n          }\n        );\n      }\n    }\n    if (templateHeader || isAnonymous(this.cls.get('user.id'))) {\n      await this.permissionService.validTemplatePermissions(fieldRaw.tableId, [\n        'table|read',\n        'record|read',\n        'field|read',\n      ]);\n    } else {\n      // make sure user has permission to access the table where the link field from\n      await this.permissionService.validPermissions(fieldRaw.tableId, [\n        'table|read',\n        'record|read',\n        'field|read',\n      ]);\n    }\n\n    const { filterByViewId, visibleFieldIds, filter } = field.options;\n\n    return {\n      shareId: linkFieldId,\n      tableId: field.options.foreignTableId,\n      linkOptions: { filterByViewId, visibleFieldIds, filter },\n      shareMeta: {\n        allowCopy: true,\n        includeRecords: true,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share-socket.service.ts",
    "content": "import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';\nimport { HttpErrorCode, type IGetFieldsQuery } from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { difference } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { CustomHttpException } from '../../custom.exception';\nimport { FieldService } from '../field/field.service';\nimport { RecordService } from '../record/record.service';\nimport { ViewService } from '../view/view.service';\nimport type { IShareViewInfo } from './share-auth.service';\n\n@Injectable()\nexport class ShareSocketService {\n  constructor(\n    private readonly viewService: ViewService,\n    private readonly fieldService: FieldService,\n    private readonly recordService: RecordService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  getViewDocIdsByQuery(shareInfo: IShareViewInfo) {\n    const { tableId, view } = shareInfo;\n    if (!view) {\n      throw new CustomHttpException('View not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.view.notFound',\n        },\n      });\n    }\n    return this.viewService.getDocIdsByQuery(tableId, {\n      includeIds: [view.id],\n    });\n  }\n\n  getViewSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) {\n    const { tableId, view } = shareInfo;\n    if (!view) {\n      throw new CustomHttpException('View not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.view.notFound',\n        },\n      });\n    }\n\n    if (ids.length > 1 || ids[0] !== view.id) {\n      throw new CustomHttpException(\n        'View permission not allowed: read',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareSocket.viewPermissionNotAllowed',\n          },\n        }\n      );\n    }\n    return this.viewService.getSnapshotBulk(tableId, [view.id]);\n  }\n\n  async getFieldDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetFieldsQuery = {}) {\n    const { tableId, view, linkOptions } = shareInfo;\n    const { filterByViewId, visibleFieldIds } = linkOptions ?? {};\n    const viewId = filterByViewId ?? view?.id;\n    const filterHidden = !view?.shareMeta?.includeHiddenField;\n\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      ...query,\n      viewId,\n      filterHidden: Boolean(filterByViewId) || filterHidden,\n    });\n    const fieldIds = fields.map((field) => field.id);\n\n    if (visibleFieldIds?.length) {\n      return {\n        ids: fields\n          .filter((f) => visibleFieldIds?.includes(f.id) || f.isPrimary)\n          .map((field) => field.id),\n      };\n    }\n    return { ids: fieldIds };\n  }\n\n  async getFieldSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) {\n    const { tableId } = shareInfo;\n    await this.validFieldSnapshotPermission(shareInfo, ids);\n    const { ids: fieldIds } = await this.getFieldDocIdsByQuery(shareInfo);\n    return this.fieldService.getSnapshotBulk(tableId, fieldIds);\n  }\n\n  async validFieldSnapshotPermission(shareInfo: IShareViewInfo, ids: string[]) {\n    const { ids: fieldIds } = await this.getFieldDocIdsByQuery(shareInfo);\n    const unPermissionIds = difference(ids, fieldIds);\n    if (unPermissionIds.length) {\n      throw new CustomHttpException(\n        `Field(${unPermissionIds.join(',')}) permission not allowed: read`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareSocket.fieldPermissionNotAllowed',\n          },\n        }\n      );\n    }\n  }\n\n  async getRecordDocIdsByQuery(\n    shareInfo: IShareViewInfo,\n    query: IGetRecordsRo,\n    useQueryModel = true\n  ) {\n    const { tableId, view, linkOptions, shareMeta } = shareInfo;\n\n    if (!shareMeta?.includeRecords) {\n      return { ids: [] };\n    }\n\n    const { id } = view ?? {};\n    const { filterByViewId } = linkOptions ?? {};\n    const viewId = filterByViewId ?? id;\n    // if filterLinkCellSelected is not empty, use it as filter\n    const defaultFilter = linkOptions?.filter ?? query.filter;\n    const filter = !query.filterLinkCellSelected ? defaultFilter : undefined;\n    let projection = query.projection;\n\n    if (linkOptions) {\n      projection = (await this.getFieldDocIdsByQuery(shareInfo, query)).ids;\n    }\n\n    return this.recordService.getDocIdsByQuery(\n      tableId,\n      { ...query, viewId, filter, projection },\n      useQueryModel\n    );\n  }\n\n  async getRecordSnapshotBulk(shareInfo: IShareViewInfo, ids: string[], useQueryModel: boolean) {\n    const { tableId } = shareInfo;\n    await this.validRecordSnapshotPermission(shareInfo, ids);\n    return this.recordService.getSnapshotBulk(\n      tableId,\n      ids,\n      undefined,\n      undefined,\n      undefined,\n      useQueryModel\n    );\n  }\n\n  async validRecordSnapshotPermission(shareInfo: IShareViewInfo, ids: string[]) {\n    const { tableId, shareMeta, view } = shareInfo;\n    if (!shareMeta?.includeRecords) {\n      throw new CustomHttpException(\n        `Record(${ids.join(',')}) permission not allowed: read`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareSocket.recordPermissionNotAllowed',\n          },\n        }\n      );\n    }\n    const diff = await this.recordService.getDiffIdsByIdAndFilter(tableId, ids, view?.filter);\n    if (diff.length) {\n      throw new CustomHttpException(\n        `Record(${diff.join(',')}) permission not allowed: read`,\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.shareSocket.recordPermissionNotAllowed',\n          },\n        }\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { ShareController } from './share.controller';\n\ndescribe('ShareController', () => {\n  let controller: ShareController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [ShareController],\n    }).compile();\n\n    controller = module.get<ShareController>(ShareController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share.controller.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n  Controller,\n  HttpCode,\n  Post,\n  Res,\n  UseGuards,\n  Request,\n  Get,\n  Body,\n  Query,\n  Param,\n} from '@nestjs/common';\nimport { IGetFieldsQuery, getFieldsQuerySchema } from '@teable/core';\nimport {\n  ShareViewFormSubmitRo,\n  shareViewFormSubmitRoSchema,\n  shareViewRowCountRoSchema,\n  shareViewAggregationsRoSchema,\n  shareViewGroupPointsRoSchema,\n  shareViewRecordsRoSchema,\n  IShareViewRowCountRo,\n  IShareViewGroupPointsRo,\n  IShareViewAggregationsRo,\n  IShareViewRecordsRo,\n  rangesQuerySchema,\n  IRangesRo,\n  shareViewLinkRecordsRoSchema,\n  IShareViewLinkRecordsRo,\n  shareViewCollaboratorsRoSchema,\n  IShareViewCollaboratorsRo,\n  getRecordsRoSchema,\n  IGetRecordsRo,\n  shareViewCalendarDailyCollectionRoSchema,\n  IShareViewCalendarDailyCollectionRo,\n  searchCountRoSchema,\n  ISearchCountRo,\n  ISearchIndexByQueryRo,\n  searchIndexByQueryRoSchema,\n} from '@teable/openapi';\nimport type {\n  IRecord,\n  IAggregationVo,\n  IRowCountVo,\n  IGroupPointsVo,\n  ICopyVo,\n  ShareViewGetVo,\n  IShareViewLinkRecordsVo,\n  IShareViewCollaboratorsVo,\n  ICalendarDailyCollectionVo,\n  ISearchCountVo,\n  ISearchIndexVo,\n  IButtonClickVo,\n  IRecordsVo,\n} from '@teable/openapi';\nimport { Response } from 'express';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { AllowAnonymous } from '../auth/decorators/allow-anonymous.decorator';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { TqlPipe } from '../record/open-api/tql.pipe';\nimport { ShareAuthGuard } from './guard/auth.guard';\nimport { ShareLinkView } from './guard/link-view.decorator';\nimport { ShareAuthLocalGuard } from './guard/share-auth-local.guard';\nimport { ShareSubmit } from './guard/submit.decorator';\nimport type { IShareViewInfo } from './share-auth.service';\nimport { ShareAuthService } from './share-auth.service';\nimport { ShareSocketService } from './share-socket.service';\nimport { ShareService } from './share.service';\n\n@Controller('api/share')\n@Public()\nexport class ShareController {\n  constructor(\n    private readonly shareService: ShareService,\n    private readonly shareAuthService: ShareAuthService,\n    private readonly shareSocketService: ShareSocketService\n  ) {}\n\n  @HttpCode(200)\n  @UseGuards(ShareAuthLocalGuard)\n  @Post('/:shareId/view/auth')\n  async auth(@Request() req: any, @Res({ passthrough: true }) res: Response) {\n    const shareId = req.shareId;\n    const password = req.password;\n    const token = await this.shareAuthService.authToken({ shareId, password });\n    res.cookie(shareId, token, {\n      httpOnly: true,\n      maxAge: 1000 * 60 * 60 * 24 * 7,\n    });\n    return { token };\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/view')\n  async getShareView(@Request() req?: any): Promise<ShareViewGetVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getShareView(shareInfo);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/aggregations')\n  async getViewAggregations(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewAggregationsRoSchema), TqlPipe)\n    query?: IShareViewAggregationsRo\n  ): Promise<IAggregationVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewAggregations(shareInfo, query);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/view/row-count')\n  async getViewRowCount(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewRowCountRoSchema), TqlPipe)\n    query?: IShareViewRowCountRo\n  ): Promise<IRowCountVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewRowCount(shareInfo, query);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/view/records')\n  async getViewRecords(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewRecordsRoSchema), TqlPipe)\n    query?: IShareViewRecordsRo\n  ): Promise<IRecordsVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewRecords(shareInfo, query);\n  }\n\n  @ShareSubmit()\n  @UseGuards(ShareAuthGuard)\n  @Post('/:shareId/view/form-submit')\n  async submitRecord(\n    @Request() req: any,\n    @Body(new ZodValidationPipe(shareViewFormSubmitRoSchema))\n    shareViewFormSubmitRo: ShareViewFormSubmitRo\n  ): Promise<IRecord> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.formSubmit(shareInfo, shareViewFormSubmitRo);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/copy')\n  async copy(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) shareViewCopyRo: IRangesRo\n  ): Promise<ICopyVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.copy(shareInfo, shareViewCopyRo);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/group-points')\n  async getViewGroupPoints(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewGroupPointsRoSchema))\n    query?: IShareViewGroupPointsRo\n  ): Promise<IGroupPointsVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewGroupPoints(shareInfo, query);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/calendar-daily-collection')\n  async getViewCalendarDailyCollection(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewCalendarDailyCollectionRoSchema))\n    query: IShareViewCalendarDailyCollectionRo\n  ): Promise<ICalendarDailyCollectionVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewCalendarDailyCollection(shareInfo, query);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/link-records')\n  async viewLinkRecords(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewLinkRecordsRoSchema))\n    shareViewLinkRecordsRo: IShareViewLinkRecordsRo\n  ): Promise<IShareViewLinkRecordsVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewLinkRecords(shareInfo, shareViewLinkRecordsRo);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/collaborators')\n  async getViewCollaborators(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(shareViewCollaboratorsRoSchema)) query: IShareViewCollaboratorsRo\n  ): Promise<IShareViewCollaboratorsVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareService.getViewCollaborators(shareInfo, query);\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/search-count')\n  async getSearchCount(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(searchCountRoSchema))\n    queryRo: ISearchCountRo\n  ): Promise<ISearchCountVo> {\n    const { tableId, view } = req.shareInfo as IShareViewInfo;\n    return this.shareService.getShareSearchCount(tableId, { ...queryRo, viewId: view?.id });\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Get('/:shareId/view/search-index')\n  async getSearchIndex(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(searchIndexByQueryRoSchema))\n    queryRo: ISearchIndexByQueryRo\n  ): Promise<ISearchIndexVo> {\n    const { tableId, view } = req.shareInfo as IShareViewInfo;\n    return this.shareService.getShareSearchIndex(tableId, { ...queryRo, viewId: view?.id });\n  }\n\n  @UseGuards(ShareAuthGuard)\n  @Post('/:shareId/view/record/:recordId/:fieldId/button-click')\n  async buttonClick(\n    @Request() req: any,\n    @Param('recordId') recordId: string,\n    @Param('fieldId') fieldId: string\n  ): Promise<IButtonClickVo> {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    const result = await this.shareService.buttonClick(shareInfo, recordId, fieldId);\n    return { ...result, runId: '' };\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/socket/view/snapshot-bulk')\n  async getViewSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareSocketService.getViewSnapshotBulk(shareInfo, ids);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/socket/view/doc-ids')\n  async getViewDocIds(@Request() req: any) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareSocketService.getViewDocIdsByQuery(shareInfo);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/socket/field/snapshot-bulk')\n  async getFieldSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareSocketService.getFieldSnapshotBulk(shareInfo, ids);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/socket/field/doc-ids')\n  async getFieldDocIds(\n    @Request() req: any,\n    @Query(new ZodValidationPipe(getFieldsQuerySchema)) query: IGetFieldsQuery\n  ) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n\n    return this.shareSocketService.getFieldDocIdsByQuery(shareInfo, query);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Get('/:shareId/socket/record/snapshot-bulk')\n  async getRecordSnapshotBulk(@Request() req: any, @Query('ids') ids: string[]) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareSocketService.getRecordSnapshotBulk(shareInfo, ids, true);\n  }\n\n  @ShareLinkView()\n  @UseGuards(ShareAuthGuard)\n  @AllowAnonymous()\n  @Post('/:shareId/socket/record/doc-ids')\n  async getRecordDocIds(\n    @Request() req: any,\n    @Body(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo\n  ) {\n    const shareInfo = req.shareInfo as IShareViewInfo;\n    return this.shareSocketService.getRecordDocIdsByQuery(shareInfo, query, true);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { AggregationModule } from '../aggregation/aggregation.module';\nimport { AuthModule } from '../auth/auth.module';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { FieldModule } from '../field/field.module';\nimport { RecordOpenApiModule } from '../record/open-api/record-open-api.module';\nimport { RecordModule } from '../record/record.module';\nimport { SelectionModule } from '../selection/selection.module';\nimport { ViewModule } from '../view/view.module';\nimport { ShareAuthModule } from './share-auth.module';\nimport { ShareSocketService } from './share-socket.service';\nimport { ShareController } from './share.controller';\nimport { ShareService } from './share.service';\n\n@Module({\n  imports: [\n    AuthModule,\n    FieldModule,\n    RecordModule,\n    RecordOpenApiModule,\n    SelectionModule,\n    AggregationModule,\n    ShareAuthModule,\n    CollaboratorModule,\n    ViewModule,\n  ],\n  providers: [ShareService, DbProvider, ShareSocketService],\n  controllers: [ShareController],\n  exports: [ShareService, ShareSocketService],\n})\nexport class ShareModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { ShareModule } from './share.module';\nimport { ShareService } from './share.service';\n\ndescribe('ShareService', () => {\n  let service: ShareService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, ShareModule],\n    }).compile();\n\n    service = module.get<ShareService>(ShareService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/share.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type { IFilter, IFieldVo, IViewVo, ILinkFieldOptions, StatisticsFunc } from '@teable/core';\nimport { CellFormat, FieldKeyType, FieldType, HttpErrorCode, ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ShareViewLinkRecordsType, PluginPosition } from '@teable/openapi';\nimport type {\n  IShareViewCalendarDailyCollectionRo,\n  ShareViewFormSubmitRo,\n  ShareViewGetVo,\n  IShareViewRowCountRo,\n  IShareViewAggregationsRo,\n  IShareViewRecordsRo,\n  IRangesRo,\n  IShareViewGroupPointsRo,\n  IAggregationVo,\n  IGroupPointsVo,\n  IRowCountVo,\n  IShareViewLinkRecordsRo,\n  IRecordsVo,\n  IShareViewCollaboratorsRo,\n  ISearchCountRo,\n  ISearchIndexByQueryRo,\n} from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../types/cls';\nimport { convertViewVoAttachmentUrl } from '../../utils/convert-view-vo-attachment-url';\nimport { isNotHiddenField } from '../../utils/is-not-hidden-field';\nimport { IAggregationService } from '../aggregation/aggregation.service.interface';\nimport { InjectAggregationService } from '../aggregation/aggregation.service.provider';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { FieldService } from '../field/field.service';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByVo } from '../field/model/factory';\nimport { RecordOpenApiService } from '../record/open-api/record-open-api.service';\nimport { RecordService } from '../record/record.service';\nimport { SelectionService } from '../selection/selection.service';\nimport type { IShareViewInfo } from './share-auth.service';\nimport { ShareSocketService } from './share-socket.service';\n\nexport interface IJwtShareInfo {\n  shareId: string;\n  password: string;\n}\n\n@Injectable()\nexport class ShareService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly fieldService: FieldService,\n    private readonly recordService: RecordService,\n    @InjectAggregationService() private readonly aggregationService: IAggregationService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly selectionService: SelectionService,\n    private readonly collaboratorService: CollaboratorService,\n    private readonly shareSocketService: ShareSocketService,\n    private readonly cls: ClsService<IClsStore>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async getShareView(shareInfo: IShareViewInfo): Promise<ShareViewGetVo> {\n    const { shareId, tableId, view, linkOptions, shareMeta } = shareInfo;\n    const { id, group } = view ?? {};\n    const { filterByViewId, filter, visibleFieldIds } = linkOptions ?? {};\n    const viewId = filterByViewId ?? id;\n\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId,\n      filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField,\n    });\n    const filteredFields = visibleFieldIds?.length\n      ? fields.filter((f) => visibleFieldIds?.includes(f.id) || f.isPrimary)\n      : fields;\n\n    let records: IRecordsVo['records'] = [];\n    let extra: ShareViewGetVo['extra'];\n    if (shareMeta?.includeRecords) {\n      const recordsData = await this.recordService.getRecords(\n        tableId,\n        {\n          viewId,\n          skip: 0,\n          take: 50,\n          filter,\n          groupBy: group,\n          fieldKeyType: FieldKeyType.Id,\n          projection: filteredFields.map((f) => f.id),\n        },\n        true\n      );\n      records = recordsData.records;\n      extra = recordsData.extra;\n    }\n\n    if (view?.type === ViewType.Plugin) {\n      const pluginInstall = await this.prismaService.pluginInstall.findFirst({\n        where: { positionId: viewId, position: PluginPosition.View },\n        select: {\n          id: true,\n          pluginId: true,\n          name: true,\n          storage: true,\n          plugin: {\n            select: {\n              url: true,\n            },\n          },\n        },\n      });\n      if (!pluginInstall) {\n        throw new CustomHttpException('Plugin install not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.pluginInstall.notFound',\n          },\n        });\n      }\n      const plugin = {\n        pluginId: pluginInstall.pluginId,\n        pluginInstallId: pluginInstall.id,\n        name: pluginInstall.name,\n        storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : undefined,\n        url: pluginInstall.plugin.url || undefined,\n      };\n      if (extra) {\n        extra.plugin = plugin;\n      } else {\n        extra = { plugin: plugin };\n      }\n    }\n\n    return {\n      shareMeta,\n      shareId,\n      tableId,\n      viewId,\n      view: view ? convertViewVoAttachmentUrl(view) : undefined,\n      fields: filteredFields,\n      records,\n      extra,\n    };\n  }\n\n  async getViewAggregations(\n    shareInfo: IShareViewInfo,\n    query: IShareViewAggregationsRo = {}\n  ): Promise<IAggregationVo> {\n    const { tableId, shareMeta } = shareInfo;\n    if (!shareMeta?.includeRecords) {\n      return { aggregations: [] };\n    }\n    const viewId = shareInfo.view?.id;\n    const filter = query?.filter ?? null;\n    const groupBy = query?.groupBy ?? null;\n    const fieldStats: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];\n    if (query?.field) {\n      Object.entries(query.field).forEach(([key, value]) => {\n        const stats = value.map((fieldId) => {\n          // check field hidden\n          if (shareInfo.view) {\n            this.preCheckFieldHidden(shareInfo.view as IViewVo, key);\n          }\n          return {\n            fieldId,\n            statisticFunc: key as StatisticsFunc,\n          };\n        });\n        fieldStats.push(...stats);\n      });\n    }\n    const result = await this.aggregationService.performAggregation({\n      tableId,\n      withView: { viewId, customFilter: filter, customFieldStats: fieldStats, groupBy },\n      useQueryModel: true,\n    });\n\n    return { aggregations: result?.aggregations };\n  }\n\n  async getViewRowCount(\n    shareInfo: IShareViewInfo,\n    query?: IShareViewRowCountRo\n  ): Promise<IRowCountVo> {\n    const { view, linkOptions, shareMeta } = shareInfo;\n\n    if (!shareMeta?.includeRecords) {\n      return { rowCount: 0 };\n    }\n\n    const { id } = view ?? {};\n    const { filterByViewId } = linkOptions ?? {};\n    const viewId = filterByViewId ?? id;\n    const tableId = shareInfo.tableId;\n    // if filterLinkCellSelected is not empty, use it as filter\n    const defaultFilter = linkOptions?.filter ?? query?.filter;\n    const filter = query?.filterLinkCellSelected ? undefined : defaultFilter;\n    const result = await this.aggregationService.performRowCount(tableId, {\n      viewId,\n      filter,\n      ...query,\n    });\n\n    return {\n      rowCount: result.rowCount,\n    };\n  }\n\n  async getViewRecords(\n    shareInfo: IShareViewInfo,\n    query?: IShareViewRecordsRo\n  ): Promise<IRecordsVo> {\n    const { tableId, view, linkOptions, shareMeta } = shareInfo;\n\n    if (!shareMeta?.includeRecords) {\n      return { records: [] };\n    }\n\n    const { id, group } = view ?? {};\n    const { filterByViewId, filter: linkFilter, visibleFieldIds } = linkOptions ?? {};\n    const viewId = filterByViewId ?? id;\n\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId,\n      filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField,\n    });\n    const filteredFields = visibleFieldIds?.length\n      ? fields.filter((f) => visibleFieldIds?.includes(f.id) || f.isPrimary)\n      : fields;\n\n    return await this.recordService.getRecords(\n      tableId,\n      {\n        viewId,\n        skip: query?.skip ?? 0,\n        take: query?.take ?? 100,\n        filter: query?.filter ?? linkFilter,\n        orderBy: query?.orderBy,\n        groupBy: query?.groupBy ?? group,\n        fieldKeyType: FieldKeyType.Id,\n        projection: query?.projection ?? filteredFields.map((f) => f.id),\n      },\n      true\n    );\n  }\n\n  async formSubmit(shareInfo: IShareViewInfo, shareViewFormSubmitRo: ShareViewFormSubmitRo) {\n    const { tableId, view, shareMeta } = shareInfo;\n    const { fields, typecast } = shareViewFormSubmitRo;\n    if (!shareMeta?.submit?.allow) {\n      throw new CustomHttpException('not allowed to submit', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.share.notAllowedToSubmit',\n        },\n      });\n    }\n    if (!view) {\n      throw new CustomHttpException('view is required', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.share.viewRequired',\n        },\n      });\n    }\n\n    return this.recordOpenApiService.formSubmit(\n      tableId,\n      { viewId: view.id, fields, typecast },\n      { includeHiddenField: view.shareMeta?.includeHiddenField }\n    );\n  }\n\n  async copy(shareInfo: IShareViewInfo, shareViewCopyRo: IRangesRo) {\n    if (!shareInfo.shareMeta?.allowCopy) {\n      throw new CustomHttpException('not allowed to copy', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.share.notAllowedToCopy',\n        },\n      });\n    }\n\n    return this.selectionService.copy(shareInfo.tableId, {\n      viewId: shareInfo.view?.id,\n      ...shareViewCopyRo,\n    });\n  }\n\n  private preCheckFieldHidden(view: IViewVo, fieldId: string) {\n    // hidden check\n    if (!view.shareMeta?.includeHiddenField && !isNotHiddenField(fieldId, view)) {\n      throw new CustomHttpException(\n        'field is hidden, not allowed',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.fieldHiddenNotAllowed',\n          },\n        }\n      );\n    }\n  }\n\n  async getViewLinkRecords(shareInfo: IShareViewInfo, query: IShareViewLinkRecordsRo) {\n    const { tableId, view } = shareInfo;\n    const { fieldId } = query;\n    if (!view) {\n      throw new CustomHttpException('view is required', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.share.viewRequired',\n        },\n      });\n    }\n\n    this.preCheckFieldHidden(view as IViewVo, fieldId);\n\n    // link field check\n    const field = await this.fieldService.getField(tableId, fieldId);\n    if (field.type !== FieldType.Link) {\n      throw new CustomHttpException(\n        'Field type is not link field',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.fieldTypeNotLinkField',\n          },\n        }\n      );\n    }\n\n    let recordsVo: IRecordsVo;\n    if (view.type === ViewType.Form) {\n      recordsVo = await this.getFormLinkRecords(field, query);\n    } else if (view.type === ViewType.Plugin) {\n      recordsVo =\n        query.type === ShareViewLinkRecordsType.Candidate\n          ? await this.getFormLinkRecords(field, query)\n          : await this.getViewFilterLinkRecords(field, query);\n    } else {\n      recordsVo = await this.getViewFilterLinkRecords(field, query);\n    }\n    return recordsVo.records.map(({ id, name, fields }) => {\n      const lookupFieldId = (field.options as ILinkFieldOptions).lookupFieldId;\n      const title = lookupFieldId ? (fields[lookupFieldId] as string) : name;\n      return { id, title };\n    });\n  }\n\n  async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {\n    const { lookupFieldId, foreignTableId, filter, filterByViewId } =\n      field.options as ILinkFieldOptions;\n    const { take, skip, search } = query;\n\n    return this.recordService.getRecords(\n      foreignTableId,\n      {\n        viewId: filterByViewId ?? undefined,\n        filter,\n        take,\n        skip,\n        search: search ? [search, lookupFieldId, true] : undefined,\n        projection: [lookupFieldId],\n        fieldKeyType: FieldKeyType.Id,\n        filterLinkCellCandidate: field.id,\n        cellFormat: CellFormat.Text,\n      },\n      true\n    );\n  }\n\n  async getViewFilterLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {\n    const { fieldId, skip, take, search } = query;\n\n    const { foreignTableId, lookupFieldId } = field.options as ILinkFieldOptions;\n\n    return this.recordService.getRecords(\n      foreignTableId,\n      {\n        skip,\n        take,\n        search: search ? [search, lookupFieldId, true] : undefined,\n        fieldKeyType: FieldKeyType.Id,\n        projection: [lookupFieldId],\n        filterLinkCellSelected: fieldId,\n        cellFormat: CellFormat.Text,\n      },\n      true\n    );\n  }\n\n  async getViewGroupPoints(\n    shareInfo: IShareViewInfo,\n    query?: IShareViewGroupPointsRo\n  ): Promise<IGroupPointsVo> {\n    if (!shareInfo.shareMeta?.includeRecords) {\n      return [];\n    }\n    const viewId = shareInfo.view?.id;\n    const tableId = shareInfo.tableId;\n    const view = shareInfo.view;\n    if (viewId == null) return null;\n\n    if (view) {\n      query?.groupBy?.forEach(({ fieldId }) => {\n        this.preCheckFieldHidden(view, fieldId);\n      });\n    }\n\n    return this.aggregationService.getGroupPoints(tableId, { ...query, viewId });\n  }\n\n  async getViewCollaborators(shareInfo: IShareViewInfo, query: IShareViewCollaboratorsRo) {\n    const { view, tableId } = shareInfo;\n    const { fieldId } = query;\n\n    if (!view) {\n      return this.getViewAllCollaborators(shareInfo, query);\n    }\n\n    // only form, kanban and plugin view can get all collaborators\n    if ([ViewType.Form, ViewType.Kanban, ViewType.Plugin].includes(view.type)) {\n      return this.getViewAllCollaborators(shareInfo, query);\n    }\n\n    if (!fieldId) {\n      throw new CustomHttpException('fieldId is required', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.share.fieldIdRequired',\n        },\n      });\n    }\n\n    await this.preCheckFieldHidden(view as IViewVo, fieldId);\n\n    // user field check\n    const field = await this.fieldService.getField(tableId, fieldId);\n    // All user field, contains lastModifiedBy, createdBy\n    if (![FieldType.User, FieldType.LastModifiedBy, FieldType.CreatedBy].includes(field.type)) {\n      throw new CustomHttpException(\n        'field type is not user-related field',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.share.fieldNotUserRelatedField',\n          },\n        }\n      );\n    }\n\n    return this.getViewFilterCollaborators(shareInfo, field, query);\n  }\n\n  private async getViewFilterUserQuery(\n    tableId: string,\n    filter: IFilter | undefined,\n    userField: IFieldVo,\n    fieldMap: Record<string, IFieldInstance>,\n    query?: { skip?: number; take?: number; search?: string }\n  ) {\n    const { skip = 0, take = 50, search } = query ?? {};\n    const dbTableName = await this.recordService.getDbTableName(tableId);\n    const queryBuilder = this.knex(dbTableName);\n    const { isMultipleCellValue, dbFieldName } = userField;\n\n    this.dbProvider.shareFilterCollaboratorsQuery(queryBuilder, dbFieldName, isMultipleCellValue);\n    queryBuilder.whereNotNull(dbFieldName);\n    this.dbProvider.filterQuery(queryBuilder, fieldMap, filter).appendQueryBuilder();\n\n    const resQuery = this.knex('users')\n      .select('id', 'email', 'name', 'avatar')\n      .from(this.knex.raw(`(${queryBuilder.toQuery()}) AS coll`))\n      .leftJoin('users', 'users.id', '=', 'coll.user_id');\n    if (search) {\n      this.dbProvider.searchBuilder(resQuery, [\n        ['users.name', search],\n        ['users.email', search],\n      ]);\n    }\n    if (skip) {\n      resQuery.offset(skip);\n    }\n    if (take) {\n      resQuery.limit(take);\n    }\n    return resQuery.toQuery();\n  }\n\n  async getViewFilterCollaborators(\n    shareInfo: IShareViewInfo,\n    field: IFieldVo,\n    query?: { skip?: number; take?: number; search?: string }\n  ) {\n    const { tableId, view } = shareInfo;\n    if (!view) {\n      throw new CustomHttpException('view is required', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.share.viewRequired',\n        },\n      });\n    }\n\n    const fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: view.id,\n    });\n\n    const nativeQuery = await this.getViewFilterUserQuery(\n      tableId,\n      view.filter,\n      field,\n      fields.reduce(\n        (acc, field) => {\n          acc[field.id] = createFieldInstanceByVo(field);\n          return acc;\n        },\n        {} as Record<string, IFieldInstance>\n      ),\n      query\n    );\n\n    const users = await this.prismaService\n      .txClient()\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      .$queryRawUnsafe<{ id: string; email: string; name: string; avatar: string | null }[]>(\n        nativeQuery\n      );\n\n    return users.map(({ id, email, name, avatar }) => ({\n      userId: id,\n      email,\n      userName: name,\n      avatar: avatar && getPublicFullStorageUrl(avatar),\n    }));\n  }\n\n  async getViewAllCollaborators(\n    shareInfo: IShareViewInfo,\n    query?: { skip?: number; take?: number; search?: string; fieldId?: string }\n  ) {\n    const { skip = 0, take = 50, search } = query ?? {};\n    const { tableId, view } = shareInfo;\n\n    if (view && ![ViewType.Form, ViewType.Kanban, ViewType.Plugin].includes(view.type)) {\n      throw new CustomHttpException('view type is not allowed', HttpErrorCode.RESTRICTED_RESOURCE, {\n        localization: {\n          i18nKey: 'httpErrors.share.viewTypeNotAllowed',\n        },\n      });\n    }\n\n    let fields = await this.fieldService.getFieldsByQuery(tableId, {\n      viewId: view?.id,\n      filterHidden: !view?.shareMeta?.includeHiddenField,\n    });\n    if (query?.fieldId) {\n      fields = fields.filter((field) => field.id === query.fieldId);\n    }\n    // If there is no user field, return an empty array\n    if (\n      !fields.some((field) =>\n        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)\n      )\n    ) {\n      return [];\n    }\n    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      select: { baseId: true },\n      where: { id: tableId },\n    });\n    const list = await this.collaboratorService.getUserCollaborators(baseId, {\n      skip,\n      take,\n      search,\n    });\n    return list.map((item) => ({\n      userId: item.id,\n      email: item.email,\n      userName: item.name,\n      avatar: item.avatar,\n    }));\n  }\n\n  async getShareSearchCount(tableId: string, query: ISearchCountRo) {\n    return this.aggregationService.getSearchCount(tableId, query);\n  }\n\n  async getShareSearchIndex(tableId: string, query: ISearchIndexByQueryRo) {\n    return this.aggregationService.getRecordIndexBySearchOrder(tableId, query);\n  }\n\n  async getViewCalendarDailyCollection(\n    shareInfo: IShareViewInfo,\n    query: IShareViewCalendarDailyCollectionRo\n  ) {\n    return this.aggregationService.getCalendarDailyCollection(shareInfo.tableId, {\n      ...query,\n      viewId: shareInfo.view?.id,\n    });\n  }\n\n  async buttonClick(shareInfo: IShareViewInfo, recordId: string, fieldId: string) {\n    await this.shareSocketService.validFieldSnapshotPermission(shareInfo, [fieldId]);\n    await this.shareSocketService.validRecordSnapshotPermission(shareInfo, [recordId]);\n    return this.recordOpenApiService.buttonClick(shareInfo.tableId, recordId, fieldId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/share/strategies/jwt.strategy.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { ConfigType } from '@nestjs/config';\nimport { PassportStrategy } from '@nestjs/passport';\nimport cookie from 'cookie';\nimport type { Request } from 'express';\nimport { ExtractJwt, Strategy } from 'passport-jwt';\nimport type { authConfig } from '../../../configs/auth.config';\nimport { AuthConfig } from '../../../configs/auth.config';\nimport { SHARE_JWT_STRATEGY } from '../guard/constant';\nimport { ShareAuthService } from '../share-auth.service';\nimport type { IJwtShareInfo } from '../share.service';\n\n@Injectable()\nexport class JwtStrategy extends PassportStrategy(Strategy, SHARE_JWT_STRATEGY) {\n  constructor(\n    @AuthConfig() readonly config: ConfigType<typeof authConfig>,\n    private readonly shareAuthService: ShareAuthService\n  ) {\n    super({\n      jwtFromRequest: ExtractJwt.fromExtractors([JwtStrategy.fromAuthCookieAsToken]),\n      ignoreExpiration: false,\n      secretOrKey: config.jwt.secret,\n    });\n  }\n\n  public static fromAuthCookieAsToken(req: Request): string | null {\n    const shareId = req.params.shareId || (req.headers['tea-share-id'] as string);\n    const cookieObj = cookie.parse(req.headers.cookie ?? '');\n    return cookieObj?.[shareId] ?? null;\n  }\n\n  async validate(payload: IJwtShareInfo) {\n    const { shareId, password } = payload;\n    const authShareId = await this.shareAuthService.authShareView(shareId, password);\n    if (!authShareId) {\n      throw new UnauthorizedException();\n    }\n    return authShareId;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/space.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { SpaceController } from './space.controller';\n\ndescribe('SpaceController', () => {\n  let controller: SpaceController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [SpaceController],\n    }).compile();\n\n    controller = module.get<SpaceController>(SpaceController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/space.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Body, Controller, Param, Patch, Post, Get, Delete, Query } from '@nestjs/common';\nimport { HttpErrorCode, Role } from '@teable/core';\nimport type {\n  ICreateSpaceVo,\n  IUpdateSpaceVo,\n  IGetSpaceVo,\n  EmailInvitationVo,\n  ListSpaceInvitationLinkVo,\n  CreateSpaceInvitationLinkVo,\n  UpdateSpaceInvitationLinkVo,\n  ListSpaceCollaboratorVo,\n  IGetBaseAllVo,\n  ITestLLMVo,\n  ISpaceSearchVo,\n} from '@teable/openapi';\nimport {\n  createSpaceRoSchema,\n  ICreateSpaceRo,\n  updateSpaceRoSchema,\n  IUpdateSpaceRo,\n  emailSpaceInvitationRoSchema,\n  updateSpaceInvitationLinkRoSchema,\n  CreateSpaceInvitationLinkRo,\n  EmailSpaceInvitationRo,\n  UpdateSpaceInvitationLinkRo,\n  createSpaceInvitationLinkRoSchema,\n  updateSpaceCollaborateRoSchema,\n  UpdateSpaceCollaborateRo,\n  CollaboratorType,\n  deleteSpaceCollaboratorRoSchema,\n  DeleteSpaceCollaboratorRo,\n  listSpaceCollaboratorRoSchema,\n  ListSpaceCollaboratorRo,\n  addSpaceCollaboratorRoSchema,\n  AddSpaceCollaboratorRo,\n  createIntegrationRoSchema,\n  ICreateIntegrationRo,\n  updateIntegrationRoSchema,\n  IUpdateIntegrationRo,\n  testLLMRoSchema,\n  ITestLLMRo,\n  spaceSearchRoSchema,\n  ISpaceSearchRo,\n} from '@teable/openapi';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../event-emitter/events';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { InvitationService } from '../invitation/invitation.service';\nimport { SpaceService } from './space.service';\n@Controller('api/space/')\nexport class SpaceController {\n  constructor(\n    private readonly spaceService: SpaceService,\n    private readonly invitationService: InvitationService,\n    private readonly collaboratorService: CollaboratorService\n  ) {}\n\n  @Post()\n  @Permissions('space|create')\n  @EmitControllerEvent(Events.SPACE_CREATE)\n  async createSpace(\n    @Body(new ZodValidationPipe(createSpaceRoSchema))\n    createSpaceRo: ICreateSpaceRo\n  ): Promise<ICreateSpaceVo> {\n    return await this.spaceService.createSpace(createSpaceRo);\n  }\n\n  @Permissions('space|update')\n  @Patch(':spaceId')\n  @EmitControllerEvent(Events.SPACE_UPDATE)\n  async updateSpace(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(updateSpaceRoSchema))\n    updateSpaceRo: IUpdateSpaceRo\n  ): Promise<IUpdateSpaceVo> {\n    return await this.spaceService.updateSpace(spaceId, updateSpaceRo);\n  }\n\n  @Permissions('space|read')\n  @Get(':spaceId')\n  async getSpaceById(@Param('spaceId') spaceId: string): Promise<IGetSpaceVo> {\n    return await this.spaceService.getSpaceById(spaceId);\n  }\n\n  @Permissions('space|read')\n  @Get()\n  async getSpaceList(): Promise<IGetSpaceVo[]> {\n    return await this.spaceService.getSpaceList();\n  }\n\n  @Permissions('space|delete')\n  @Delete(':spaceId')\n  @EmitControllerEvent(Events.SPACE_DELETE)\n  async deleteSpace(@Param('spaceId') spaceId: string) {\n    await this.spaceService.deleteSpace(spaceId);\n    return null;\n  }\n\n  @Permissions('space|invite_link')\n  @Post(':spaceId/invitation/link')\n  async createInvitationLink(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(createSpaceInvitationLinkRoSchema))\n    spaceInvitationLinkRo: CreateSpaceInvitationLinkRo\n  ): Promise<CreateSpaceInvitationLinkVo> {\n    return this.invitationService.generateInvitationLink({\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n      role: spaceInvitationLinkRo.role,\n    });\n  }\n\n  @Permissions('space|invite_link')\n  @Delete(':spaceId/invitation/link/:invitationId')\n  async deleteInvitationLink(\n    @Param('spaceId') spaceId: string,\n    @Param('invitationId') invitationId: string\n  ): Promise<void> {\n    return this.invitationService.deleteInvitationLink({\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n      invitationId,\n    });\n  }\n\n  @Permissions('base|read')\n  @Get(':spaceId/base')\n  async getBaseList(@Param('spaceId') spaceId: string): Promise<IGetBaseAllVo> {\n    return await this.spaceService.getBaseListBySpaceId(spaceId);\n  }\n\n  @Permissions('space|read')\n  @Get(':spaceId/search')\n  async search(\n    @Param('spaceId') spaceId: string,\n    @Query(new ZodValidationPipe(spaceSearchRoSchema)) query: ISpaceSearchRo\n  ): Promise<ISpaceSearchVo> {\n    return await this.spaceService.search(spaceId, query);\n  }\n\n  @Permissions('space|invite_link')\n  @Patch(':spaceId/invitation/link/:invitationId')\n  async updateInvitationLink(\n    @Param('spaceId') spaceId: string,\n    @Param('invitationId') invitationId: string,\n    @Body(new ZodValidationPipe(updateSpaceInvitationLinkRoSchema))\n    updateSpaceInvitationLinkRo: UpdateSpaceInvitationLinkRo\n  ): Promise<UpdateSpaceInvitationLinkVo> {\n    return this.invitationService.updateInvitationLink({\n      invitationId,\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n      role: updateSpaceInvitationLinkRo.role,\n    });\n  }\n\n  @Permissions('space|invite_link')\n  @Get(':spaceId/invitation/link')\n  async listInvitationLinkBySpace(\n    @Param('spaceId') spaceId: string\n  ): Promise<ListSpaceInvitationLinkVo> {\n    return this.invitationService.getInvitationLink(spaceId, CollaboratorType.Space);\n  }\n\n  @Permissions('space|invite_email')\n  @Post(':spaceId/invitation/email')\n  async emailInvitation(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(emailSpaceInvitationRoSchema))\n    emailSpaceInvitationRo: EmailSpaceInvitationRo\n  ): Promise<EmailInvitationVo> {\n    return this.invitationService.emailInvitationBySpace(spaceId, emailSpaceInvitationRo);\n  }\n\n  @Permissions('space|read')\n  @Get(':spaceId/collaborators')\n  async listCollaborator(\n    @Param('spaceId') spaceId: string,\n    @Query(new ZodValidationPipe(listSpaceCollaboratorRoSchema))\n    options: ListSpaceCollaboratorRo\n  ): Promise<ListSpaceCollaboratorVo> {\n    const stats = await this.collaboratorService.getSpaceCollaboratorStats(spaceId, options);\n    return {\n      collaborators: await this.collaboratorService.getListBySpace(spaceId, options),\n      total: stats.total,\n      uniqTotal: stats.uniqTotal,\n    };\n  }\n\n  @Patch(':spaceId/collaborators')\n  @Permissions('space|read')\n  async updateCollaborator(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(updateSpaceCollaborateRoSchema))\n    updateSpaceCollaborateRo: UpdateSpaceCollaborateRo\n  ): Promise<void> {\n    if (\n      updateSpaceCollaborateRo.role !== Role.Owner &&\n      (await this.collaboratorService.isUniqueOwnerUser(\n        spaceId,\n        updateSpaceCollaborateRo.principalId\n      ))\n    ) {\n      throw new CustomHttpException(\n        'Cannot change the role of the only owner of the space',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.cannotChangeOnlyOwnerRole',\n          },\n        }\n      );\n    }\n    await this.collaboratorService.updateCollaborator({\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n      ...updateSpaceCollaborateRo,\n    });\n  }\n\n  @Delete(':spaceId/collaborators')\n  @Permissions('space|read')\n  async deleteCollaborator(\n    @Param('spaceId') spaceId: string,\n    @Query(new ZodValidationPipe(deleteSpaceCollaboratorRoSchema))\n    deleteSpaceCollaboratorRo: DeleteSpaceCollaboratorRo\n  ): Promise<void> {\n    if (\n      await this.collaboratorService.isUniqueOwnerUser(\n        spaceId,\n        deleteSpaceCollaboratorRo.principalId\n      )\n    ) {\n      throw new CustomHttpException(\n        'Cannot delete the only owner of the space',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.cannotDeleteOnlyOwner',\n          },\n        }\n      );\n    }\n    await this.collaboratorService.deleteCollaborator({\n      resourceId: spaceId,\n      resourceType: CollaboratorType.Space,\n      ...deleteSpaceCollaboratorRo,\n    });\n  }\n\n  @Delete(':spaceId/permanent')\n  @EmitControllerEvent(Events.SPACE_DELETE)\n  async permanentDeleteSpace(@Param('spaceId') spaceId: string) {\n    await this.spaceService.permanentDeleteSpace(spaceId);\n    return { spaceId, permanent: true };\n  }\n\n  @Permissions('space|read')\n  @Post(':spaceId/collaborator')\n  async addCollaborators(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(addSpaceCollaboratorRoSchema))\n    addSpaceCollaboratorRo: AddSpaceCollaboratorRo\n  ) {\n    return this.collaboratorService.addSpaceCollaborators(spaceId, addSpaceCollaboratorRo);\n  }\n\n  @Permissions('space|update')\n  @Get(':spaceId/integration')\n  async getIntegrationList(@Param('spaceId') spaceId: string) {\n    return this.spaceService.getIntegrationList(spaceId);\n  }\n\n  @Permissions('space|update')\n  @Post(':spaceId/integration')\n  async createIntegration(\n    @Param('spaceId') spaceId: string,\n    @Body(new ZodValidationPipe(createIntegrationRoSchema))\n    addIntegrationRo: ICreateIntegrationRo\n  ) {\n    return this.spaceService.createIntegration(spaceId, addIntegrationRo);\n  }\n\n  @Permissions('space|update')\n  @Patch(':spaceId/integration/:integrationId')\n  async updateIntegration(\n    @Param('spaceId') spaceId: string,\n    @Param('integrationId') integrationId: string,\n    @Body(new ZodValidationPipe(updateIntegrationRoSchema))\n    updateIntegrationRo: IUpdateIntegrationRo\n  ) {\n    return this.spaceService.updateIntegration(integrationId, updateIntegrationRo, spaceId);\n  }\n\n  @Permissions('space|update')\n  @Delete(':spaceId/integration/:integrationId')\n  async deleteIntegration(\n    @Param('spaceId') spaceId: string,\n    @Param('integrationId') integrationId: string\n  ) {\n    return this.spaceService.deleteIntegration(integrationId, spaceId);\n  }\n\n  @Permissions('space|update')\n  @Post(':spaceId/test-llm')\n  async testIntegrationLLM(\n    @Param('spaceId') _spaceId: string,\n    @Body(new ZodValidationPipe(testLLMRoSchema)) testLLMRo: ITestLLMRo\n  ): Promise<ITestLLMVo> {\n    return await this.spaceService.testIntegrationLLM(testLLMRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/space.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PermissionModule } from '../auth/permission.module';\nimport { BaseModule } from '../base/base.module';\nimport { CollaboratorModule } from '../collaborator/collaborator.module';\nimport { InvitationModule } from '../invitation/invitation.module';\nimport { SettingOpenApiModule } from '../setting/open-api/setting-open-api.module';\nimport { SettingModule } from '../setting/setting.module';\nimport { SpaceController } from './space.controller';\nimport { SpaceService } from './space.service';\nimport { TemplateSpaceInitService } from './template-space-init/template-space.init.service';\n\n@Module({\n  controllers: [SpaceController],\n  providers: [SpaceService, TemplateSpaceInitService],\n  exports: [SpaceService, TemplateSpaceInitService],\n  imports: [\n    SettingModule,\n    SettingOpenApiModule,\n    CollaboratorModule,\n    InvitationModule,\n    BaseModule,\n    PermissionModule,\n  ],\n})\nexport class SpaceModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/space.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { SpaceModule } from './space.module';\nimport { SpaceService } from './space.service';\n\ndescribe('SpaceService', () => {\n  let service: SpaceService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, SpaceModule],\n    }).compile();\n\n    service = module.get<SpaceService>(SpaceService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/space.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type { IRole } from '@teable/core';\nimport {\n  HttpErrorCode,\n  Role,\n  canManageRole,\n  generateIntegrationId,\n  generateSpaceId,\n  getUniqName,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  ICreateIntegrationRo,\n  ICreateSpaceRo,\n  IIntegrationItemVo,\n  ISpaceSearchRo,\n  ISpaceSearchVo,\n  ITestLLMRo,\n  IUpdateIntegrationRo,\n  IUpdateSpaceRo,\n} from '@teable/openapi';\nimport { ResourceType, CollaboratorType, PrincipalType, IntegrationType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { keyBy, map, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateIntegrationCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { PermissionService } from '../auth/permission.service';\nimport { BaseService } from '../base/base.service';\nimport { CollaboratorService } from '../collaborator/collaborator.service';\nimport { SettingOpenApiService } from '../setting/open-api/setting-open-api.service';\nimport { SettingService } from '../setting/setting.service';\n@Injectable()\nexport class SpaceService {\n  constructor(\n    protected readonly prismaService: PrismaService,\n    protected readonly cls: ClsService<IClsStore>,\n    protected readonly baseService: BaseService,\n    protected readonly collaboratorService: CollaboratorService,\n    protected readonly permissionService: PermissionService,\n    protected readonly settingService: SettingService,\n    protected readonly settingOpenApiService: SettingOpenApiService,\n    protected readonly performanceCacheService: PerformanceCacheService,\n    @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig,\n    @InjectModel('CUSTOM_KNEX') protected readonly knex: Knex,\n    @InjectDbProvider() protected readonly dbProvider: IDbProvider\n  ) {}\n\n  async createSpaceByParams(spaceCreateInput: Prisma.SpaceCreateInput) {\n    return await this.prismaService.$tx(async () => {\n      const result = await this.prismaService.txClient().space.create({\n        select: {\n          id: true,\n          name: true,\n        },\n        data: spaceCreateInput,\n      });\n      await this.collaboratorService.createSpaceCollaborator({\n        collaborators: [\n          {\n            principalId: spaceCreateInput.createdBy,\n            principalType: PrincipalType.User,\n          },\n        ],\n        role: Role.Owner,\n        spaceId: result.id,\n      });\n      return result;\n    });\n  }\n\n  async getSpaceById(spaceId: string) {\n    const space = await this.prismaService.space.findFirst({\n      select: {\n        id: true,\n        name: true,\n      },\n      where: {\n        id: spaceId,\n        deletedTime: null,\n      },\n    });\n    if (!space) {\n      throw new CustomHttpException('Space not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.space.notFound',\n        },\n      });\n    }\n    const role = await this.permissionService.getRoleBySpaceId(spaceId);\n    if (!role) {\n      throw new CustomHttpException(\n        'You have no permission to access this space',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.noPermission',\n          },\n        }\n      );\n    }\n    return {\n      ...space,\n      role,\n    };\n  }\n\n  async filterSpaceListWithAccessToken(spaceList: { id: string; name: string }[]) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    if (!accessTokenId) {\n      return spaceList;\n    }\n    const accessToken = await this.permissionService.getAccessToken(accessTokenId);\n    if (accessToken.hasFullAccess) {\n      return spaceList;\n    }\n    if (!accessToken.spaceIds?.length) {\n      return [];\n    }\n    return spaceList.filter((space) => accessToken.spaceIds.includes(space.id));\n  }\n\n  async getSpaceList() {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    const collaboratorSpaceList = await this.prismaService.collaborator.findMany({\n      select: {\n        resourceId: true,\n        roleName: true,\n      },\n      where: {\n        principalId: { in: [userId, ...(departmentIds || [])] },\n        resourceType: CollaboratorType.Space,\n      },\n    });\n    const spaceIds = map(collaboratorSpaceList, 'resourceId') as string[];\n    const spaceList = await this.prismaService.space.findMany({\n      where: {\n        id: { in: spaceIds },\n        deletedTime: null,\n        isTemplate: null,\n      },\n      select: { id: true, name: true },\n      orderBy: { createdTime: 'asc' },\n    });\n    const roleMap = collaboratorSpaceList.reduce(\n      (acc, curr) => {\n        if (\n          !acc[curr.resourceId] ||\n          canManageRole(curr.roleName as IRole, acc[curr.resourceId].roleName as IRole)\n        ) {\n          acc[curr.resourceId] = curr;\n        }\n        return acc;\n      },\n      {} as Record<string, { roleName: string; resourceId: string }>\n    );\n    const filteredSpaceList = await this.filterSpaceListWithAccessToken(spaceList);\n    return filteredSpaceList.map((space) => ({\n      ...space,\n      role: roleMap[space.id].roleName as IRole,\n    }));\n  }\n\n  async createSpace(createSpaceRo: ICreateSpaceRo) {\n    const userId = this.cls.get('user.id');\n    const isAdmin = this.cls.get('user.isAdmin');\n\n    if (!isAdmin) {\n      const setting = await this.settingService.getSetting();\n      if (setting?.disallowSpaceCreation) {\n        throw new CustomHttpException(\n          'The current instance disallow space creation by the administrator',\n          HttpErrorCode.RESTRICTED_RESOURCE,\n          {\n            localization: {\n              i18nKey: 'httpErrors.space.disallowSpaceCreation',\n            },\n          }\n        );\n      }\n    }\n\n    const spaceList = await this.prismaService.space.findMany({\n      where: { deletedTime: null, createdBy: userId },\n      select: { name: true },\n    });\n\n    const names = spaceList.map((space) => space.name);\n    const uniqName = getUniqName(createSpaceRo.name ?? 'Space', names);\n\n    const spaceId = generateSpaceId();\n\n    // create default ai integration\n    await this.createDefaultAIIntegration(spaceId);\n\n    return await this.createSpaceByParams({\n      id: spaceId,\n      name: uniqName,\n      createdBy: userId,\n    });\n  }\n\n  async updateSpace(spaceId: string, updateSpaceRo: IUpdateSpaceRo) {\n    const userId = this.cls.get('user.id');\n\n    return await this.prismaService.space.update({\n      select: {\n        id: true,\n        name: true,\n      },\n      data: {\n        ...updateSpaceRo,\n        lastModifiedBy: userId,\n      },\n      where: {\n        id: spaceId,\n        deletedTime: null,\n      },\n    });\n  }\n\n  async deleteSpace(spaceId: string) {\n    const userId = this.cls.get('user.id');\n\n    await this.prismaService.$tx(async () => {\n      await this.prismaService\n        .txClient()\n        .space.update({\n          data: {\n            deletedTime: new Date(),\n            lastModifiedBy: userId,\n          },\n          where: {\n            id: spaceId,\n            deletedTime: null,\n          },\n        })\n        .catch(() => {\n          throw new CustomHttpException('Space not found', HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.space.notFound',\n            },\n          });\n        });\n    });\n  }\n\n  async getBaseListBySpaceId(spaceId: string) {\n    const { spaceIds, roleMap } =\n      await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();\n    if (!spaceIds.includes(spaceId)) {\n      throw new CustomHttpException(\n        'You have no permission to access this space',\n        HttpErrorCode.RESTRICTED_RESOURCE,\n        {\n          localization: {\n            i18nKey: 'httpErrors.space.noPermission',\n          },\n        }\n      );\n    }\n    const baseList = await this.prismaService.base.findMany({\n      select: {\n        id: true,\n        name: true,\n        order: true,\n        spaceId: true,\n        icon: true,\n        createdBy: true,\n        lastModifiedTime: true,\n        createdTime: true,\n      },\n      where: {\n        spaceId,\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    const userList = await this.prismaService.user.findMany({\n      where: { id: { in: baseList.map((base) => base.createdBy) } },\n      select: { id: true, name: true, avatar: true },\n    });\n    const userMap = keyBy(userList, 'id');\n\n    return baseList.map((base) => {\n      const role = roleMap[base.id] || roleMap[base.spaceId];\n      const createdUser = userMap[base.createdBy];\n      return {\n        ...base,\n        role,\n        lastModifiedTime: base.lastModifiedTime?.toISOString(),\n        createdTime: base.createdTime?.toISOString(),\n        createdUser: createdUser\n          ? {\n              ...createdUser,\n              avatar: createdUser.avatar ? getPublicFullStorageUrl(createdUser.avatar) : null,\n            }\n          : undefined,\n      };\n    });\n  }\n\n  protected getTableMapping(): Record<\n    string,\n    { table: string; hasDeletedTime: boolean; hasIcon?: boolean }\n  > {\n    return {\n      [ResourceType.Base]: { table: 'base', hasDeletedTime: true, hasIcon: true },\n      [ResourceType.Table]: { table: 'table_meta', hasDeletedTime: true, hasIcon: true },\n      [ResourceType.Dashboard]: { table: 'dashboard', hasDeletedTime: false, hasIcon: false },\n    };\n  }\n\n  /**\n   * Parse cursor in format: {iso_timestamp}_{id}\n   */\n  private parseCursor(cursor?: string): { timeStr: string; id: string } | null {\n    if (!cursor) return null;\n    // Find the last underscore to handle IDs that might contain underscores\n    const lastUnderscoreIndex = cursor.lastIndexOf('_');\n    if (lastUnderscoreIndex === -1) return null;\n    const timeStr = cursor.substring(0, lastUnderscoreIndex);\n    const id = cursor.substring(lastUnderscoreIndex + 1);\n    return { timeStr, id };\n  }\n\n  /**\n   * Generate cursor from createdTime ISO string and id\n   */\n  private generateCursor(createdTimeStr: string, id: string): string {\n    return `${createdTimeStr}_${id}`;\n  }\n\n  async search(spaceId: string, query: ISpaceSearchRo): Promise<ISpaceSearchVo> {\n    const { search, pageSize = 10, cursor, type: filterType } = query;\n\n    const bases = await this.prismaService.base.findMany({\n      where: { spaceId, deletedTime: null },\n      select: { id: true, name: true, createdBy: true, spaceId: true },\n    });\n    const baseMap = keyBy(bases, 'id');\n    const baseIds = bases.map((base) => base.id);\n    if (baseIds.length === 0) {\n      return { list: [], total: 0, nextCursor: null };\n    }\n\n    const tableMapping = this.getTableMapping();\n    const searchableTypes = Object.keys(tableMapping).map((key) => key as ResourceType);\n    const typesToSearch = filterType ? [filterType] : searchableTypes;\n\n    const cursorData = this.parseCursor(cursor);\n\n    const buildSubQuery = (resourceType: ResourceType) => {\n      const mapping = tableMapping[resourceType];\n      if (!mapping) return null;\n\n      const { table, hasDeletedTime, hasIcon } = mapping;\n      const isBase = resourceType === ResourceType.Base;\n\n      let subQuery = this.knex(table).select(\n        'id',\n        'name',\n        this.knex.raw('? as type', [resourceType]),\n        hasIcon ? this.knex.raw('COALESCE(icon, NULL) as icon') : this.knex.raw('NULL as icon'),\n        isBase ? this.knex.raw('id as base_id') : 'base_id',\n        'created_by',\n        'created_time'\n      );\n\n      subQuery = this.dbProvider.searchBuilder(subQuery, [['name', search]]);\n\n      if (isBase) {\n        subQuery = subQuery.whereIn('id', baseIds);\n      } else {\n        subQuery = subQuery.whereIn('base_id', baseIds);\n      }\n\n      if (hasDeletedTime) {\n        subQuery = subQuery.whereNull('deleted_time');\n      }\n\n      return subQuery;\n    };\n\n    const validQueries = typesToSearch\n      .map((t) => buildSubQuery(t))\n      .filter((q): q is Knex.QueryBuilder => q !== null);\n\n    if (validQueries.length === 0) {\n      return { list: [], total: 0, nextCursor: null };\n    }\n\n    let unionQuery = validQueries[0];\n    for (let i = 1; i < validQueries.length; i++) {\n      unionQuery = unionQuery.unionAll(validQueries[i]);\n    }\n\n    const isFirstPage = !cursorData;\n\n    const totalCountExpr = isFirstPage\n      ? this.knex.raw('COUNT(*) OVER() as total_count')\n      : this.knex.raw('0 as total_count');\n\n    let dataQuery = this.knex\n      .from(unionQuery.as('combined'))\n      .select('*', totalCountExpr)\n      .orderBy('created_time', 'desc')\n      .orderBy('id', 'desc')\n      .limit(pageSize + 1);\n\n    if (cursorData) {\n      dataQuery = dataQuery.whereRaw('(created_time, id) < (?, ?)', [\n        cursorData.timeStr,\n        cursorData.id,\n      ]);\n    }\n\n    interface ISearchResultRow {\n      id: string;\n      name: string;\n      type: ResourceType;\n      icon: string | null;\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      base_id: string;\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      created_by: string;\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      created_time: Date;\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      total_count: bigint | number;\n    }\n\n    const rows = await this.prismaService.$queryRawUnsafe<ISearchResultRow[]>(dataQuery.toQuery());\n\n    const total = isFirstPage && rows.length > 0 ? Number(rows[0].total_count) : 0;\n    const hasMore = rows.length > pageSize;\n    const resultsToReturn = hasMore ? rows.slice(0, pageSize) : rows;\n\n    const userIds = resultsToReturn\n      .map((row) => row.created_by)\n      .filter((id): id is string => id !== null);\n\n    const spaceIdsForBases = uniq(\n      resultsToReturn\n        .filter((row) => row.type === ResourceType.Base)\n        .map((row) => baseMap[row.base_id].spaceId)\n    );\n    const { validCreatorSet, spaceOwnerMap } =\n      await this.collaboratorService.buildSpaceOwnerContext(spaceIdsForBases);\n\n    const allUserIds = uniq([...userIds, ...spaceOwnerMap.values()]);\n    const userList = await this.prismaService.user.findMany({\n      where: { id: { in: allUserIds } },\n      select: { id: true, name: true, avatar: true },\n    });\n    const userMap = keyBy(userList, 'id');\n\n    const list = resultsToReturn.map((row) => {\n      const base = baseMap[row.base_id];\n      const isCreatorInSpace = validCreatorSet.has(`${base?.spaceId}:${row.created_by}`);\n      const displayUserId =\n        row.type === ResourceType.Base\n          ? isCreatorInSpace\n            ? row.created_by\n            : spaceOwnerMap.get(base.spaceId)\n          : row.created_by;\n      const displayUser = displayUserId ? userMap[displayUserId] : undefined;\n      return {\n        id: row.id,\n        name: row.name,\n        type: row.type,\n        icon: row.icon,\n        baseId: row.base_id,\n        baseName: base?.name ?? '',\n        createdTime: row.created_time.toISOString(),\n        createdUser: displayUser\n          ? {\n              ...displayUser,\n              avatar: displayUser.avatar && getPublicFullStorageUrl(displayUser.avatar),\n            }\n          : undefined,\n      };\n    });\n\n    const nextCursor =\n      hasMore && resultsToReturn.length > 0\n        ? this.generateCursor(\n            resultsToReturn[resultsToReturn.length - 1].created_time.toISOString(),\n            resultsToReturn[resultsToReturn.length - 1].id\n          )\n        : null;\n\n    return { list, total, nextCursor };\n  }\n\n  async permanentDeleteSpace(spaceId: string, ignorePermissionCheck: boolean = false) {\n    if (!ignorePermissionCheck) {\n      const accessTokenId = this.cls.get('accessTokenId');\n      await this.permissionService.validPermissions(spaceId, ['space|delete'], accessTokenId, true);\n    }\n\n    await this.prismaService.space\n      .findUniqueOrThrow({\n        where: { id: spaceId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Space not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.space.notFound',\n          },\n        });\n      });\n\n    await this.prismaService.$tx(\n      async (prisma) => {\n        const bases = await prisma.base.findMany({\n          where: { spaceId },\n          select: { id: true },\n        });\n\n        for (const { id } of bases) {\n          await this.baseService.permanentDeleteBase(id, ignorePermissionCheck);\n        }\n\n        await this.cleanSpaceRelatedData(spaceId);\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async cleanSpaceRelatedData(spaceId: string) {\n    // delete collaborators for space\n    await this.prismaService.txClient().collaborator.deleteMany({\n      where: { resourceId: spaceId, resourceType: CollaboratorType.Space },\n    });\n\n    // delete invitation for space\n    await this.prismaService.txClient().invitation.deleteMany({\n      where: { spaceId },\n    });\n\n    // delete invitation record for space\n    await this.prismaService.txClient().invitationRecord.deleteMany({\n      where: { spaceId },\n    });\n\n    // delete integrations for space\n    await this.prismaService.txClient().integration.deleteMany({\n      where: { resourceId: spaceId },\n    });\n\n    // delete space\n    await this.prismaService.txClient().space.delete({\n      where: { id: spaceId },\n    });\n\n    // delete trash for space\n    await this.prismaService.txClient().trash.deleteMany({\n      where: {\n        resourceId: spaceId,\n        resourceType: ResourceType.Space,\n      },\n    });\n  }\n\n  @PerformanceCache({\n    ttl: 600, // 10 minutes\n    keyGenerator: generateIntegrationCacheKey,\n    statsType: 'integration',\n  })\n  async getIntegrationList(spaceId: string): Promise<IIntegrationItemVo[]> {\n    const integrationList = await this.prismaService.integration.findMany({\n      where: { resourceId: spaceId },\n    });\n    return integrationList.map(({ id, config, type, enable, createdTime, lastModifiedTime }) => {\n      return {\n        id,\n        spaceId,\n        type: type as IntegrationType,\n        enable: enable ?? false,\n        config: JSON.parse(config),\n        createdTime: createdTime.toISOString(),\n        lastModifiedTime: lastModifiedTime?.toISOString(),\n      };\n    });\n  }\n\n  async createIntegration(spaceId: string, addIntegrationRo: ICreateIntegrationRo) {\n    const { type, enable, config } = addIntegrationRo;\n\n    await this.performanceCacheService.del(generateIntegrationCacheKey(spaceId));\n    if (type === IntegrationType.AI) {\n      const aiIntegration = await this.prismaService.integration.findFirst({\n        where: {\n          resourceId: spaceId,\n          type: IntegrationType.AI,\n        },\n      });\n\n      if (!aiIntegration) {\n        return await this.prismaService.integration.create({\n          data: {\n            id: generateIntegrationId(),\n            resourceId: spaceId,\n            type,\n            enable,\n            config: JSON.stringify(config),\n          },\n        });\n      }\n\n      const { id, enable: originalEnable } = aiIntegration;\n      const originalConfig = JSON.parse(aiIntegration.config);\n\n      return await this.prismaService.integration.update({\n        where: { id },\n        data: {\n          config: JSON.stringify({\n            ...originalConfig,\n            ...config,\n            llmProviders: [...originalConfig.llmProviders, ...config.llmProviders],\n          }),\n          enable: enable ?? originalEnable,\n        },\n      });\n    }\n\n    const res = await this.prismaService.integration.create({\n      data: {\n        id: generateIntegrationId(),\n        resourceId: spaceId,\n        type,\n        enable,\n        config: JSON.stringify(config),\n      },\n    });\n    await this.performanceCacheService.del(generateIntegrationCacheKey(spaceId));\n    return res;\n  }\n\n  async createDefaultAIIntegration(spaceId: string) {\n    const res = await this.prismaService.integration.create({\n      data: {\n        id: generateIntegrationId(),\n        resourceId: spaceId,\n        type: IntegrationType.AI,\n        enable: false,\n        config: JSON.stringify({\n          llmProviders: [],\n        }),\n      },\n    });\n    await this.performanceCacheService.del(generateIntegrationCacheKey(spaceId));\n    return res;\n  }\n\n  async updateIntegration(\n    integrationId: string,\n    updateIntegrationRo: IUpdateIntegrationRo,\n    spaceId: string\n  ) {\n    const { enable, config } = updateIntegrationRo;\n    const updateData: Record<string, unknown> = {};\n    if (enable != null) {\n      updateData.enable = enable;\n    }\n    if (config) {\n      updateData.config = JSON.stringify(config);\n    }\n    const res = await this.prismaService.integration.update({\n      where: { id: integrationId },\n      data: updateData,\n    });\n    await this.performanceCacheService.del(generateIntegrationCacheKey(spaceId));\n    return res;\n  }\n\n  async deleteIntegration(integrationId: string, spaceId: string) {\n    await this.prismaService.integration.delete({\n      where: { id: integrationId },\n    });\n    await this.performanceCacheService.del(generateIntegrationCacheKey(spaceId));\n  }\n\n  async testIntegrationLLM(testLLMRo: ITestLLMRo) {\n    return await this.settingOpenApiService.testLLM(testLLMRo);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/space/template-space-init/template-space.init.service.ts",
    "content": "import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';\nimport { IdPrefix } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\n\nexport const TEMPLATE_SPACE_ID = `${IdPrefix.Space}DefaultTempSpcId`;\n\n@Injectable()\nexport class TemplateSpaceInitService implements OnModuleInit {\n  private logger = new Logger(TemplateSpaceInitService.name);\n\n  constructor(private readonly prismaService: PrismaService) {}\n\n  async onModuleInit() {\n    const prisma = this.prismaService.txClient();\n\n    const initTemplateSpaceId = TEMPLATE_SPACE_ID;\n\n    await prisma.space.upsert({\n      where: {\n        id: initTemplateSpaceId,\n      },\n      update: {\n        isTemplate: true,\n      },\n      create: {\n        id: initTemplateSpaceId,\n        name: 'Template Space',\n        isTemplate: true,\n        createdBy: 'system',\n      },\n    });\n\n    this.logger.log('Template space ensured');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/constant.ts",
    "content": "import type { IFieldRo, IViewRo } from '@teable/core';\nimport { Colors, FieldType, ViewType } from '@teable/core';\nimport type { ICreateRecordsRo } from '@teable/openapi';\n\nexport const DEFAULT_FIELDS: IFieldRo[] = [\n  { name: 'Name', type: FieldType.SingleLineText },\n  {\n    name: 'Count',\n    type: FieldType.Number,\n  },\n  {\n    name: 'Status',\n    type: FieldType.SingleSelect,\n    options: {\n      choices: [\n        {\n          name: 'light',\n          color: Colors.GrayBright,\n        },\n        {\n          name: 'medium',\n          color: Colors.YellowBright,\n        },\n        {\n          name: 'heavy',\n          color: Colors.TealBright,\n        },\n      ],\n    },\n  },\n];\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const DEFAULT_VIEWS: IViewRo[] = [\n  {\n    name: 'Grid view',\n    type: ViewType.Grid,\n    columnMeta: {},\n  },\n];\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const DEFAULT_RECORD_DATA: ICreateRecordsRo['records'] = [\n  { fields: {} },\n  { fields: {} },\n  { fields: {} },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.mapper.spec.ts",
    "content": "import { FieldType } from '@teable/core';\nimport { describe, expect, it } from 'vitest';\n\nimport { mapLegacyCreateTableToV2Input } from './table-open-api-v2.mapper';\n\ndescribe('mapLegacyCreateTableToV2Input', () => {\n  const foreignTableId = 'tblForeign';\n  const revenueFieldId = 'fldRevenue';\n  const sumValuesExpression = 'sum({values})';\n\n  it('maps legacy rollup fields into v2 create-table config', () => {\n    const input = mapLegacyCreateTableToV2Input('bseTest', {\n      name: 'Rollup Table',\n      fields: [\n        {\n          id: 'fldRollup',\n          name: 'Revenue Total',\n          type: FieldType.Rollup,\n          cellValueType: 'number',\n          isMultipleCellValue: false,\n          options: {\n            expression: sumValuesExpression,\n            timeZone: 'UTC',\n          },\n          lookupOptions: {\n            linkFieldId: 'fldLink',\n            foreignTableId,\n            lookupFieldId: revenueFieldId,\n          },\n        },\n      ],\n      views: [{ type: 'grid', name: 'Grid' }],\n      records: [],\n    });\n\n    expect(input.fields).toEqual([\n      {\n        id: 'fldRollup',\n        name: 'Revenue Total',\n        type: 'rollup',\n        cellValueType: 'number',\n        options: {\n          expression: sumValuesExpression,\n          timeZone: 'utc',\n        },\n        config: {\n          linkFieldId: 'fldLink',\n          foreignTableId,\n          lookupFieldId: revenueFieldId,\n        },\n      },\n    ]);\n  });\n\n  it('maps legacy conditional rollup and conditional lookup fields into v2 create-table inputs', () => {\n    const input = mapLegacyCreateTableToV2Input('bseTest', {\n      name: 'Conditional Table',\n      fields: [\n        {\n          id: 'fldConditionalRollup',\n          name: 'High Revenue Total',\n          type: FieldType.ConditionalRollup,\n          cellValueType: 'number',\n          isMultipleCellValue: false,\n          options: {\n            foreignTableId,\n            lookupFieldId: revenueFieldId,\n            expression: sumValuesExpression,\n            timeZone: 'UTC',\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: revenueFieldId, operator: 'isGreater', value: 100 }],\n            },\n          },\n        },\n        {\n          id: 'fldConditionalLookup',\n          name: 'High Revenue Company',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          isMultipleCellValue: true,\n          options: {\n            formatting: { type: 'singleLineText' },\n          },\n          lookupOptions: {\n            foreignTableId,\n            lookupFieldId: 'fldName',\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: revenueFieldId, operator: 'isGreater', value: 100 }],\n            },\n          },\n        },\n      ],\n      views: [{ type: 'grid', name: 'Grid' }],\n      records: [],\n    });\n\n    expect(input.fields).toEqual([\n      {\n        id: 'fldConditionalRollup',\n        name: 'High Revenue Total',\n        type: 'conditionalRollup',\n        cellValueType: 'number',\n        options: {\n          expression: sumValuesExpression,\n          timeZone: 'utc',\n        },\n        config: {\n          foreignTableId,\n          lookupFieldId: revenueFieldId,\n          condition: {\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: revenueFieldId, operator: 'isGreater', value: 100 }],\n            },\n          },\n        },\n      },\n      {\n        id: 'fldConditionalLookup',\n        name: 'High Revenue Company',\n        type: 'conditionalLookup',\n        isMultipleCellValue: true,\n        options: {\n          foreignTableId,\n          lookupFieldId: 'fldName',\n          condition: {\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: revenueFieldId, operator: 'isGreater', value: 100 }],\n            },\n          },\n        },\n        innerOptions: {\n          formatting: { type: 'singleLineText' },\n        },\n      },\n    ]);\n  });\n\n  it('preserves db table and field names in v2 create-table inputs', () => {\n    const input = mapLegacyCreateTableToV2Input('bseTest', {\n      name: 'Custom Names',\n      dbTableName: 'bseTest.custom_table',\n      fields: [\n        {\n          id: 'fldName',\n          name: 'Name',\n          dbFieldName: 'db_field_name',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      views: [{ type: 'grid', name: 'Grid' }],\n      records: [],\n    });\n\n    expect(input.dbTableName).toBe('bseTest.custom_table');\n    expect(input.fields).toEqual([\n      {\n        id: 'fldName',\n        name: 'Name',\n        dbFieldName: 'db_field_name',\n        type: 'singleLineText',\n      },\n    ]);\n  });\n\n  it('normalizes legacy UTC values for generic field options', () => {\n    const input = mapLegacyCreateTableToV2Input('bseTest', {\n      name: 'Date Table',\n      fields: [\n        {\n          id: 'fldDate',\n          name: 'Due Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: 'YYYY-MM-DD',\n              time: 'HH:mm',\n              timeZone: 'UTC',\n            },\n          },\n        },\n      ],\n      views: [{ type: 'grid', name: 'Grid' }],\n      records: [],\n    });\n\n    expect(input.fields).toEqual([\n      {\n        id: 'fldDate',\n        name: 'Due Date',\n        type: 'date',\n        options: {\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: 'HH:mm',\n            timeZone: 'utc',\n          },\n        },\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.mapper.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { IFieldRo } from '@teable/core';\nimport type { ICreateTableWithDefault } from '@teable/openapi';\nimport type { ICreateTableCommandInput, ITableFieldInput } from '@teable/v2-core';\n\nconst asRecord = (value: unknown): Record<string, unknown> | undefined =>\n  value && typeof value === 'object' && !Array.isArray(value)\n    ? (value as Record<string, unknown>)\n    : undefined;\n\nconst withDefined = <T extends Record<string, unknown>>(value: T): T => {\n  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;\n};\n\nconst normalizeLegacyTimeZone = (value: unknown): unknown => {\n  if (Array.isArray(value)) {\n    return value.map((item) => normalizeLegacyTimeZone(item));\n  }\n\n  if (!value || typeof value !== 'object') {\n    return value;\n  }\n\n  const normalized: Record<string, unknown> = {};\n  for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n    if (key === 'timeZone' && raw === 'UTC') {\n      normalized[key] = 'utc';\n      continue;\n    }\n    normalized[key] = normalizeLegacyTimeZone(raw);\n  }\n\n  return normalized;\n};\n\nconst getResultTypePair = (field: Record<string, unknown>): Record<string, unknown> => {\n  const cellValueType = field.cellValueType;\n  const isMultipleCellValue = field.isMultipleCellValue;\n\n  if (typeof cellValueType === 'string' && typeof isMultipleCellValue === 'boolean') {\n    return isMultipleCellValue ? { cellValueType, isMultipleCellValue } : { cellValueType };\n  }\n\n  return {};\n};\n\nconst pickLookupOptions = (lookupOptions: Record<string, unknown> | undefined) =>\n  withDefined({\n    linkFieldId: lookupOptions?.linkFieldId as string | undefined,\n    foreignTableId: lookupOptions?.foreignTableId as string | undefined,\n    lookupFieldId: lookupOptions?.lookupFieldId as string | undefined,\n    filter: lookupOptions?.filter,\n    sort: lookupOptions?.sort,\n    limit: lookupOptions?.limit,\n  });\n\nconst pickCondition = (lookupOptions: Record<string, unknown> | undefined) =>\n  withDefined({\n    filter: lookupOptions?.filter,\n    sort: lookupOptions?.sort,\n    limit: lookupOptions?.limit,\n  });\n\nconst pickFormulaOptions = (options: Record<string, unknown> | undefined) =>\n  withDefined({\n    expression: options?.expression as string | undefined,\n    timeZone: options?.timeZone as string | undefined,\n    formatting: options?.formatting,\n    showAs: options?.showAs,\n  });\n\nconst pickRollupConfig = (\n  options: Record<string, unknown> | undefined,\n  lookupOptions: Record<string, unknown> | undefined\n) =>\n  withDefined({\n    linkFieldId: (options?.linkFieldId ?? lookupOptions?.linkFieldId) as string | undefined,\n    foreignTableId: (options?.foreignTableId ?? lookupOptions?.foreignTableId) as\n      | string\n      | undefined,\n    lookupFieldId: (options?.lookupFieldId ?? lookupOptions?.lookupFieldId) as string | undefined,\n  });\n\nconst pickLinkOptions = (options: Record<string, unknown> | undefined) =>\n  withDefined({\n    baseId: options?.baseId as string | undefined,\n    relationship: options?.relationship,\n    foreignTableId: options?.foreignTableId as string | undefined,\n    lookupFieldId: options?.lookupFieldId as string | undefined,\n    isOneWay: options?.isOneWay as boolean | undefined,\n    fkHostTableName: options?.fkHostTableName as string | undefined,\n    selfKeyName: options?.selfKeyName as string | undefined,\n    foreignKeyName: options?.foreignKeyName as string | undefined,\n    symmetricFieldId: options?.symmetricFieldId as string | undefined,\n    filterByViewId: (options?.filterByViewId ?? undefined) as string | null | undefined,\n    visibleFieldIds: (options?.visibleFieldIds ?? undefined) as string[] | null | undefined,\n    filter: options?.filter,\n  });\n\nconst mapBaseField = (field: IFieldRo) =>\n  withDefined({\n    id: field.id,\n    name: field.name,\n    dbFieldName: field.dbFieldName,\n    description: field.description ?? undefined,\n    aiConfig: field.aiConfig ?? undefined,\n    isPrimary: (field as Record<string, unknown>).isPrimary === true ? true : undefined,\n    notNull: field.notNull,\n    unique: field.unique,\n  });\n\nconst mapLegacyFieldToV2Field = (field: IFieldRo): ITableFieldInput => {\n  const baseField = mapBaseField(field);\n  const rawField = field as Record<string, unknown>;\n  const options = asRecord(field.options);\n  const lookupOptions = asRecord(field.lookupOptions);\n\n  if (field.isLookup) {\n    if (field.isConditionalLookup) {\n      return mapLegacyConditionalLookupField(\n        baseField,\n        rawField,\n        field.type,\n        options,\n        lookupOptions\n      );\n    }\n\n    return mapLegacyLookupField(baseField, rawField, lookupOptions, options);\n  }\n\n  if (field.type === FieldType.Rollup) {\n    return mapLegacyRollupField(baseField, rawField, options, lookupOptions);\n  }\n\n  if (field.type === FieldType.Link) {\n    return normalizeLegacyTimeZone({\n      ...baseField,\n      type: 'link',\n      options: pickLinkOptions(options),\n    }) as ITableFieldInput;\n  }\n\n  if (field.type === FieldType.ConditionalRollup || rawField.type === 'conditionalRollup') {\n    return mapLegacyConditionalRollupField(baseField, rawField, options);\n  }\n\n  return normalizeLegacyTimeZone(\n    withDefined({\n      ...baseField,\n      type: field.type as ITableFieldInput['type'],\n      ...(options ? { options } : {}),\n    })\n  ) as ITableFieldInput;\n};\n\nconst mapLegacyConditionalLookupField = (\n  baseField: ReturnType<typeof mapBaseField>,\n  rawField: Record<string, unknown>,\n  fieldType: IFieldRo['type'],\n  options: Record<string, unknown> | undefined,\n  lookupOptions: Record<string, unknown> | undefined\n): ITableFieldInput => {\n  const foreignTableId = lookupOptions?.foreignTableId as string | undefined;\n  const lookupFieldId = lookupOptions?.lookupFieldId as string | undefined;\n  const condition = pickCondition(lookupOptions);\n\n  if (fieldType === FieldType.Rollup) {\n    return normalizeLegacyTimeZone({\n      ...baseField,\n      type: 'conditionalRollup',\n      ...getResultTypePair(rawField),\n      options: pickFormulaOptions(options),\n      config: {\n        foreignTableId: foreignTableId ?? '',\n        lookupFieldId: lookupFieldId ?? '',\n        condition,\n      },\n    }) as ITableFieldInput;\n  }\n\n  return normalizeLegacyTimeZone({\n    ...baseField,\n    type: 'conditionalLookup',\n    options: {\n      foreignTableId: foreignTableId ?? '',\n      lookupFieldId: lookupFieldId ?? '',\n      condition,\n    },\n    ...(typeof rawField.isMultipleCellValue === 'boolean'\n      ? { isMultipleCellValue: rawField.isMultipleCellValue }\n      : {}),\n    innerOptions: options,\n  }) as ITableFieldInput;\n};\n\nconst mapLegacyLookupField = (\n  baseField: ReturnType<typeof mapBaseField>,\n  rawField: Record<string, unknown>,\n  lookupOptions: Record<string, unknown> | undefined,\n  options: Record<string, unknown> | undefined\n): ITableFieldInput =>\n  normalizeLegacyTimeZone({\n    ...baseField,\n    type: 'lookup',\n    legacyMultiplicityDerivation: true,\n    ...(rawField.isMultipleCellValue === true ? { isMultipleCellValue: true } : {}),\n    options: pickLookupOptions(lookupOptions),\n    innerOptions: options,\n  }) as ITableFieldInput;\n\nconst mapLegacyRollupField = (\n  baseField: ReturnType<typeof mapBaseField>,\n  rawField: Record<string, unknown>,\n  options: Record<string, unknown> | undefined,\n  lookupOptions: Record<string, unknown> | undefined\n): ITableFieldInput =>\n  normalizeLegacyTimeZone({\n    ...baseField,\n    type: 'rollup',\n    ...getResultTypePair(rawField),\n    options: pickFormulaOptions(options),\n    config: pickRollupConfig(options, lookupOptions),\n  }) as ITableFieldInput;\n\nconst mapLegacyConditionalRollupField = (\n  baseField: ReturnType<typeof mapBaseField>,\n  rawField: Record<string, unknown>,\n  options: Record<string, unknown> | undefined\n): ITableFieldInput =>\n  normalizeLegacyTimeZone({\n    ...baseField,\n    type: 'conditionalRollup',\n    ...getResultTypePair(rawField),\n    options: pickFormulaOptions(options),\n    config: {\n      foreignTableId: options?.foreignTableId as string,\n      lookupFieldId: options?.lookupFieldId as string,\n      condition: pickCondition(options),\n    },\n  }) as ITableFieldInput;\n\nexport const mapLegacyCreateTableToV2Input = (\n  baseId: string,\n  table: ICreateTableWithDefault\n): ICreateTableCommandInput => {\n  return {\n    baseId,\n    name: table.name ?? 'New table',\n    ...(table.dbTableName ? { dbTableName: table.dbTableName } : {}),\n    fields: table.fields.map(mapLegacyFieldToV2Field),\n    views: table.views.map((view) =>\n      withDefined({\n        type: view.type,\n        name: view.name,\n      })\n    ),\n    records: table.records?.map((record) =>\n      withDefined({\n        id: 'id' in record && typeof record.id === 'string' ? record.id : undefined,\n        fields: record.fields,\n      })\n    ),\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.spec.ts",
    "content": "import { FieldType } from '@teable/core';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst { executeCreateTableEndpoint, executeDeleteTableEndpoint, executeRestoreTableEndpoint } =\n  vi.hoisted(() => ({\n    executeCreateTableEndpoint: vi.fn(),\n    executeDeleteTableEndpoint: vi.fn(),\n    executeRestoreTableEndpoint: vi.fn(),\n  }));\n\nvi.mock('@teable/v2-contract-http-implementation/handlers', () => ({\n  executeCreateTableEndpoint,\n  executeDeleteTableEndpoint,\n  executeRestoreTableEndpoint,\n}));\n\nvi.mock('../table.service', () => ({\n  TableService: class TableService {},\n}));\n\nvi.mock('../../field/open-api/field-open-api.service', () => ({\n  FieldOpenApiService: class FieldOpenApiService {},\n}));\n\nvi.mock('../../record/record.service', () => ({\n  RecordService: class RecordService {},\n}));\n\nvi.mock('../../v2/v2-container.service', () => ({\n  V2ContainerService: class V2ContainerService {},\n}));\n\nvi.mock('../../v2/v2-execution-context.factory', () => ({\n  V2ExecutionContextFactory: class V2ExecutionContextFactory {},\n}));\n\nvi.mock('../../view/view.service', () => ({\n  ViewService: class ViewService {},\n}));\n\nimport { TableOpenApiV2Service } from './table-open-api-v2.service';\n\ndescribe('TableOpenApiV2Service.createTable', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  const createService = (overrides?: {\n    tableService?: Record<string, unknown>;\n    fieldOpenApiService?: Record<string, unknown>;\n    viewService?: Record<string, unknown>;\n    recordService?: Record<string, unknown>;\n    dbProvider?: Record<string, unknown>;\n  }) =>\n    new TableOpenApiV2Service(\n      {\n        getContainer: vi.fn().mockResolvedValue({\n          resolve: vi.fn().mockReturnValue({}),\n        }),\n      } as never,\n      {\n        createContext: vi.fn().mockResolvedValue({}),\n      } as never,\n      (overrides?.tableService ?? {}) as never,\n      (overrides?.fieldOpenApiService ?? {}) as never,\n      (overrides?.viewService ?? {}) as never,\n      (overrides?.recordService ?? {}) as never,\n      {\n        generateDbTableName: vi\n          .fn()\n          .mockImplementation((baseId: string, name: string) => `${baseId}.${name}`),\n        ...overrides?.dbProvider,\n      } as never\n    );\n\n  it('fills missing legacy link lookupFieldId and prefixes legacy dbTableName before calling v2', async () => {\n    executeCreateTableEndpoint.mockResolvedValue({\n      status: 400,\n      body: {\n        ok: false,\n        error: {\n          code: 'validation.invalid',\n          message: 'Invalid create table',\n          tags: ['validation'],\n        },\n      },\n    });\n\n    const fieldOpenApiService = {\n      getFields: vi.fn().mockResolvedValue([\n        {\n          id: 'fldPrimary',\n          name: 'Name',\n          type: FieldType.SingleLineText,\n          isPrimary: true,\n        },\n      ]),\n    };\n\n    const service = createService({\n      fieldOpenApiService,\n    });\n\n    await expect(\n      service.createTable('bseTest', {\n        name: 'Links',\n        dbTableName: 'legacy_table',\n        fields: [\n          {\n            name: 'Related',\n            type: FieldType.Link,\n            options: {\n              relationship: 'manyMany',\n              foreignTableId: 'tblForeign',\n            },\n          },\n        ],\n        views: [],\n        records: [],\n      })\n    ).rejects.toBeTruthy();\n\n    expect(fieldOpenApiService.getFields).toHaveBeenCalledWith('tblForeign', {\n      filterHidden: false,\n    });\n    expect(executeCreateTableEndpoint).toHaveBeenCalledTimes(1);\n    expect(executeCreateTableEndpoint.mock.calls[0]?.[1]).toMatchObject({\n      baseId: 'bseTest',\n      name: 'Links',\n      dbTableName: 'bseTest.legacy_table',\n      fields: [\n        {\n          name: 'Related',\n          type: 'link',\n          options: {\n            relationship: 'manyMany',\n            foreignTableId: 'tblForeign',\n            lookupFieldId: 'fldPrimary',\n          },\n        },\n      ],\n    });\n  });\n\n  it('rebuilds legacy create-table response in chunks', async () => {\n    executeCreateTableEndpoint.mockResolvedValue({\n      status: 201,\n      body: {\n        ok: true,\n        data: {\n          table: {\n            id: 'tblTest',\n          },\n        },\n      },\n    });\n\n    const recordIds = Array.from({ length: 1001 }, (_, index) => `rec${index + 1}`);\n    const tableService = {\n      getTableMeta: vi.fn().mockResolvedValue({\n        id: 'tblTest',\n        name: 'Orders',\n        dbTableName: 'bseTest.orders',\n        defaultViewId: 'viwDefault',\n      }),\n    };\n    const fieldOpenApiService = {\n      getFields: vi.fn().mockResolvedValue([\n        {\n          id: 'fldName',\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        },\n      ]),\n    };\n    const viewService = {\n      getViews: vi.fn().mockResolvedValue([\n        {\n          id: 'viwDefault',\n          name: 'Grid',\n          type: 'grid',\n        },\n      ]),\n    };\n    const recordService = {\n      getDocIdsByQuery: vi\n        .fn()\n        .mockResolvedValueOnce({ ids: recordIds.slice(0, 1000) })\n        .mockResolvedValueOnce({ ids: recordIds.slice(1000) }),\n      getSnapshotBulkWithPermission: vi.fn().mockResolvedValue(\n        [...recordIds].reverse().map((recordId) => ({\n          data: {\n            id: recordId,\n            name: recordId,\n            fields: {},\n          },\n        }))\n      ),\n    };\n\n    const service = createService({\n      tableService,\n      fieldOpenApiService,\n      viewService,\n      recordService,\n    });\n\n    const result = await service.createTable('bseTest', {\n      name: 'Orders',\n      fields: [],\n      views: [],\n      records: Array.from({ length: 1001 }, () => ({\n        fields: {},\n      })),\n    });\n\n    expect(recordService.getDocIdsByQuery).toHaveBeenNthCalledWith(1, 'tblTest', {\n      viewId: 'viwDefault',\n      skip: 0,\n      take: 1000,\n    });\n    expect(recordService.getDocIdsByQuery).toHaveBeenNthCalledWith(2, 'tblTest', {\n      viewId: 'viwDefault',\n      skip: 1000,\n      take: 1,\n    });\n    expect(result.records).toHaveLength(1001);\n    expect(result.records[0]?.id).toBe('rec1');\n    expect(result.records[1000]?.id).toBe('rec1001');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts",
    "content": "import { HttpException, HttpStatus, Injectable } from '@nestjs/common';\nimport { CellFormat, FieldKeyType, FieldType } from '@teable/core';\nimport type { IFieldRo, ILinkFieldOptionsRo, IRecord } from '@teable/core';\nimport type { ICreateTableWithDefault, ITableFullVo, ITableVo } from '@teable/openapi';\nimport {\n  executeCreateTableEndpoint,\n  executeDeleteTableEndpoint,\n  executeRestoreTableEndpoint,\n} from '@teable/v2-contract-http-implementation/handlers';\nimport { v2CoreTokens } from '@teable/v2-core';\nimport type { ICommandBus } from '@teable/v2-core';\nimport { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport { RecordService } from '../../record/record.service';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\nimport { ViewService } from '../../view/view.service';\nimport { TableService } from '../table.service';\nimport { mapLegacyCreateTableToV2Input } from './table-open-api-v2.mapper';\n\nconst internalServerError = 'Internal server error';\n\n@Injectable()\nexport class TableOpenApiV2Service {\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory,\n    private readonly tableService: TableService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly viewService: ViewService,\n    private readonly recordService: RecordService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  private throwV2Error(\n    error: {\n      code: string;\n      message: string;\n      tags?: ReadonlyArray<string>;\n      details?: Readonly<Record<string, unknown>>;\n    },\n    status: number\n  ): never {\n    throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {\n      domainCode: error.code,\n      domainTags: error.tags,\n      details: error.details,\n    });\n  }\n\n  async createTable(baseId: string, createTableRo: ICreateTableWithDefault): Promise<ITableFullVo> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n    const normalizedCreateTableRo = await this.normalizeLegacyCreateTableRo(baseId, createTableRo);\n    const result = await executeCreateTableEndpoint(\n      context,\n      mapLegacyCreateTableToV2Input(baseId, normalizedCreateTableRo),\n      commandBus\n    );\n\n    if (result.status === 201 && result.body.ok) {\n      return await this.buildLegacyCreateTableResponse(\n        baseId,\n        normalizedCreateTableRo,\n        result.body.data.table.id\n      );\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async deleteTable(\n    baseId: string,\n    tableId: string,\n    mode: 'soft' | 'permanent' = 'soft'\n  ): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const result = await executeDeleteTableEndpoint(\n      context,\n      {\n        baseId,\n        tableId,\n        mode,\n      },\n      commandBus\n    );\n\n    if (result.status === 200 && result.body.ok) {\n      return;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  async restoreTable(baseId: string, tableId: string): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const result = await executeRestoreTableEndpoint(\n      context,\n      {\n        baseId,\n        tableId,\n      },\n      commandBus\n    );\n\n    if (result.status === 200 && result.body.ok) {\n      return;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n\n  private async buildLegacyCreateTableResponse(\n    baseId: string,\n    createTableRo: ICreateTableWithDefault,\n    tableId: string\n  ): Promise<ITableFullVo> {\n    const table = await this.tableService.getTableMeta(baseId, tableId);\n    const fields = await this.fieldOpenApiService.getFields(tableId, {\n      filterHidden: false,\n    });\n    const views = await this.viewService.getViews(tableId);\n    const records = await this.getCreatedRecords(table, createTableRo);\n\n    return {\n      ...table,\n      fields,\n      views,\n      records,\n    };\n  }\n\n  private async getCreatedRecords(\n    table: ITableVo,\n    createTableRo: ICreateTableWithDefault\n  ): Promise<IRecord[]> {\n    const total = createTableRo.records?.length ?? 0;\n    if (total === 0) {\n      return [];\n    }\n\n    const recordIds: string[] = [];\n    for (let skip = 0; skip < total; skip += 1000) {\n      const take = Math.min(1000, total - skip);\n      const { ids } = await this.recordService.getDocIdsByQuery(table.id, {\n        viewId: table.defaultViewId,\n        skip,\n        take,\n      });\n      recordIds.push(...ids);\n    }\n\n    if (recordIds.length === 0) {\n      return [];\n    }\n\n    const snapshots = await this.recordService.getSnapshotBulkWithPermission(\n      table.id,\n      recordIds,\n      undefined,\n      createTableRo.fieldKeyType ?? FieldKeyType.Name,\n      CellFormat.Json\n    );\n    const recordById = new Map(\n      snapshots.map((snapshot) => [snapshot.data.id, snapshot.data] as const)\n    );\n\n    return recordIds\n      .map((recordId) => recordById.get(recordId))\n      .filter((record): record is IRecord => record != null);\n  }\n\n  private async normalizeLegacyCreateTableRo(\n    baseId: string,\n    createTableRo: ICreateTableWithDefault\n  ): Promise<ICreateTableWithDefault> {\n    const withLookupFieldIds = await this.populateLegacyLinkLookupFieldIds(createTableRo);\n    const normalizedDbTableName = this.normalizeLegacyDbTableName(\n      baseId,\n      withLookupFieldIds.dbTableName\n    );\n\n    if (normalizedDbTableName === withLookupFieldIds.dbTableName) {\n      return withLookupFieldIds;\n    }\n\n    return {\n      ...withLookupFieldIds,\n      dbTableName: normalizedDbTableName,\n    };\n  }\n\n  private normalizeLegacyDbTableName(baseId: string, dbTableName?: string): string | undefined {\n    if (!dbTableName) {\n      return dbTableName;\n    }\n\n    const legacyPrefix = this.dbProvider.generateDbTableName(baseId, '');\n    if (dbTableName.startsWith(legacyPrefix)) {\n      return dbTableName;\n    }\n\n    return this.dbProvider.generateDbTableName(baseId, dbTableName);\n  }\n\n  private async populateLegacyLinkLookupFieldIds(\n    createTableRo: ICreateTableWithDefault\n  ): Promise<ICreateTableWithDefault> {\n    const fields = createTableRo.fields ?? [];\n    const foreignTableIds = [\n      ...new Set(\n        fields.flatMap((field) => {\n          if (field.type !== FieldType.Link || field.isLookup) {\n            return [];\n          }\n\n          const options =\n            field.options && typeof field.options === 'object' && !Array.isArray(field.options)\n              ? (field.options as Record<string, unknown>)\n              : undefined;\n          if (typeof options?.lookupFieldId === 'string') {\n            return [];\n          }\n\n          const foreignTableId = options?.foreignTableId;\n          return typeof foreignTableId === 'string' ? [foreignTableId] : [];\n        })\n      ),\n    ];\n\n    if (foreignTableIds.length === 0) {\n      return createTableRo;\n    }\n\n    const primaryFieldIdByTableId = new Map<string, string>();\n    await Promise.all(\n      foreignTableIds.map(async (foreignTableId) => {\n        const foreignFields = await this.fieldOpenApiService.getFields(foreignTableId, {\n          filterHidden: false,\n        });\n        const primaryField = foreignFields.find(\n          (field) => (field as Record<string, unknown>).isPrimary === true\n        );\n        if (primaryField?.id) {\n          primaryFieldIdByTableId.set(foreignTableId, primaryField.id);\n        }\n      })\n    );\n\n    let changed = false;\n    const nextFields = fields.map<IFieldRo>((field) => {\n      if (field.type !== FieldType.Link || field.isLookup) {\n        return field;\n      }\n\n      const options =\n        field.options && typeof field.options === 'object' && !Array.isArray(field.options)\n          ? (field.options as Record<string, unknown>)\n          : undefined;\n      if (typeof options?.lookupFieldId === 'string') {\n        return field;\n      }\n\n      if (typeof options?.relationship !== 'string') {\n        return field;\n      }\n\n      const foreignTableId =\n        typeof options?.foreignTableId === 'string' ? options.foreignTableId : null;\n      if (!foreignTableId) {\n        return field;\n      }\n\n      const lookupFieldId = primaryFieldIdByTableId.get(foreignTableId);\n      if (!lookupFieldId) {\n        return field;\n      }\n\n      changed = true;\n      const nextOptions: ILinkFieldOptionsRo = {\n        ...(field.options as ILinkFieldOptionsRo),\n        lookupFieldId,\n      };\n      return {\n        ...field,\n        options: nextOptions,\n      };\n    });\n\n    if (!changed) {\n      return createTableRo;\n    }\n\n    return {\n      ...createTableRo,\n      fields: nextFields,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Post,\n  Put,\n  Query,\n  UseGuards,\n  UseInterceptors,\n} from '@nestjs/common';\nimport type {\n  IDuplicateTableVo,\n  IGetAbnormalVo,\n  ITableFullVo,\n  ITableListVo,\n  ITableVo,\n} from '@teable/openapi';\nimport {\n  tableRoSchema,\n  ICreateTableWithDefault,\n  dbTableNameRoSchema,\n  IDbTableNameRo,\n  ITableDescriptionRo,\n  ITableIconRo,\n  ITableNameRo,\n  IUpdateOrderRo,\n  tableDescriptionRoSchema,\n  tableIconRoSchema,\n  tableNameRoSchema,\n  updateOrderRoSchema,\n  IToggleIndexRo,\n  toggleIndexRoSchema,\n  TableIndex,\n  duplicateTableRoSchema,\n  IDuplicateTableRo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';\nimport { TableIndexService } from '../table-index.service';\nimport { TablePermissionService } from '../table-permission.service';\nimport { TableService } from '../table.service';\nimport { TableOpenApiV2Service } from './table-open-api-v2.service';\nimport { TableOpenApiService } from './table-open-api.service';\nimport { TablePipe } from './table.pipe';\n\n@UseGuards(V2FeatureGuard)\n@UseInterceptors(V2IndicatorInterceptor)\n@Controller('api/base/:baseId/table')\n@AllowAnonymous()\nexport class TableController {\n  constructor(\n    private readonly tableService: TableService,\n    private readonly tableOpenApiService: TableOpenApiService,\n    private readonly tableIndexService: TableIndexService,\n    private readonly tablePermissionService: TablePermissionService,\n    private readonly tableOpenApiV2Service: TableOpenApiV2Service,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Permissions('table|read')\n  @Get(':tableId/default-view-id')\n  async getDefaultViewId(@Param('tableId') tableId: string): Promise<{ id: string }> {\n    return await this.tableService.getDefaultViewId(tableId);\n  }\n\n  @Permissions('table|read')\n  @Get(':tableId')\n  async getTable(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string\n  ): Promise<ITableVo> {\n    return await this.tableOpenApiService.getTable(baseId, tableId);\n  }\n\n  @Permissions('table|read')\n  @Get()\n  async getTables(@Param('baseId') baseId: string): Promise<ITableListVo> {\n    return await this.tableOpenApiService.getTables(baseId);\n  }\n\n  @Permissions('table|update')\n  @Put(':tableId/name')\n  async updateName(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(tableNameRoSchema)) tableNameRo: ITableNameRo\n  ) {\n    return await this.tableOpenApiService.updateName(baseId, tableId, tableNameRo.name);\n  }\n\n  @Permissions('table|update')\n  @Put(':tableId/icon')\n  async updateIcon(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(tableIconRoSchema)) tableIconRo: ITableIconRo\n  ) {\n    return await this.tableOpenApiService.updateIcon(baseId, tableId, tableIconRo.icon);\n  }\n\n  @Permissions('table|update')\n  @Put(':tableId/description')\n  async updateDescription(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(tableDescriptionRoSchema)) tableDescriptionRo: ITableDescriptionRo\n  ) {\n    return await this.tableOpenApiService.updateDescription(\n      baseId,\n      tableId,\n      tableDescriptionRo.description\n    );\n  }\n\n  @Permissions('table|update')\n  @Put(':tableId/db-table-name')\n  async updateDbTableName(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(dbTableNameRoSchema)) dbTableNameRo: IDbTableNameRo\n  ) {\n    return await this.tableOpenApiService.updateDbTableName(\n      baseId,\n      tableId,\n      dbTableNameRo.dbTableName\n    );\n  }\n\n  @Permissions('table|update')\n  @Put(':tableId/order')\n  async updateOrder(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo\n  ) {\n    return await this.tableOpenApiService.updateOrder(baseId, tableId, updateOrderRo);\n  }\n\n  @Post()\n  @UseV2Feature('createTable')\n  @Permissions('table|create')\n  async createTable(\n    @Param('baseId') baseId: string,\n    @Body(new ZodValidationPipe(tableRoSchema), TablePipe) createTableRo: ICreateTableWithDefault\n  ): Promise<ITableFullVo> {\n    if (this.cls.get('useV2')) {\n      return await this.tableOpenApiV2Service.createTable(baseId, createTableRo);\n    }\n    return await this.tableOpenApiService.createTable(baseId, createTableRo);\n  }\n\n  @Permissions('table|create')\n  @Permissions('table|read')\n  @Post(':tableId/duplicate')\n  async duplicateTable(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(duplicateTableRoSchema), TablePipe)\n    duplicateTableRo: IDuplicateTableRo\n  ): Promise<IDuplicateTableVo> {\n    return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo);\n  }\n\n  @UseV2Feature('deleteTable')\n  @Delete(':tableId')\n  @Permissions('table|delete')\n  async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {\n    if (this.cls.get('useV2')) {\n      await this.tableOpenApiV2Service.deleteTable(baseId, tableId);\n      return;\n    }\n    return await this.tableOpenApiService.deleteTable(baseId, tableId);\n  }\n\n  @UseV2Feature('deleteTable')\n  @Delete(':tableId/permanent')\n  @Permissions('table|delete')\n  async permanentDeleteTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {\n    if (this.cls.get('useV2')) {\n      await this.tableOpenApiV2Service.deleteTable(baseId, tableId, 'permanent');\n      return;\n    }\n    return this.tableOpenApiService.permanentDeleteTables(baseId, [tableId]);\n  }\n\n  @Permissions('table|read')\n  @Get(':tableId/permission')\n  async getPermission(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {\n    return await this.tableOpenApiService.getPermission(baseId, tableId);\n  }\n\n  @Permissions('table|read')\n  @Get('/socket/snapshot-bulk')\n  async getSnapshotBulk(@Param('baseId') baseId: string, @Query('ids') ids: string[]) {\n    const permissionMap = await this.tablePermissionService.getTablePermissionMapByBaseId(\n      baseId,\n      ids\n    );\n    const snapshotBulk = await this.tableService.getSnapshotBulk(baseId, ids);\n    return snapshotBulk.map((snapshot) => {\n      return {\n        ...snapshot,\n        data: {\n          ...snapshot.data,\n          permission: permissionMap[snapshot.id],\n        },\n      };\n    });\n  }\n\n  @Permissions('table|read')\n  @Get('/socket/doc-ids')\n  async getDocIds(@Param('baseId') baseId: string) {\n    return this.tableService.getDocIdsByQuery(baseId, undefined);\n  }\n\n  @Post(':tableId/index')\n  @Permissions('table|update')\n  async toggleIndex(\n    @Param('baseId') baseId: string,\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(toggleIndexRoSchema)) searchIndexRo: IToggleIndexRo\n  ) {\n    return this.tableIndexService.toggleIndex(tableId, searchIndexRo);\n  }\n\n  @Get(':tableId/activated-index')\n  @Permissions('table|read')\n  async getTableIndex(@Param('tableId') tableId: string): Promise<string[]> {\n    return this.tableIndexService.getActivatedTableIndexes(tableId);\n  }\n\n  @Get(':tableId/abnormal-index')\n  @Permissions('table|read')\n  async getAbnormalTableIndex(\n    @Param('tableId') tableId: string,\n    @Query('type') tableIndexType: TableIndex\n  ): Promise<IGetAbnormalVo> {\n    return this.tableIndexService.getAbnormalTableIndex(tableId, tableIndexType);\n  }\n\n  @Patch(':tableId/index/repair')\n  @Permissions('table|update')\n  async repairIndex(\n    @Param('tableId') tableId: string,\n    @Query('type') tableIndexType: TableIndex\n  ): Promise<void> {\n    return this.tableIndexService.repairIndex(tableId, tableIndexType);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../../db-provider/db.provider';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { CalculationModule } from '../../calculation/calculation.module';\nimport { CanaryModule } from '../../canary/canary.module';\nimport { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';\nimport { FieldDuplicateModule } from '../../field/field-duplicate/field-duplicate.module';\nimport { FieldOpenApiModule } from '../../field/open-api/field-open-api.module';\nimport { GraphModule } from '../../graph/graph.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { RecordModule } from '../../record/record.module';\nimport { V2Module } from '../../v2/v2.module';\nimport { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';\nimport { ViewModule } from '../../view/view.module';\nimport { TableDuplicateService } from '../table-duplicate.service';\nimport { TableIndexService } from '../table-index.service';\nimport { TableModule } from '../table.module';\nimport { TableOpenApiV2Service } from './table-open-api-v2.service';\nimport { TableController } from './table-open-api.controller';\nimport { TableOpenApiService } from './table-open-api.service';\n\n@Module({\n  imports: [\n    FieldCalculateModule,\n    RecordModule,\n    RecordOpenApiModule,\n    ViewOpenApiModule,\n    FieldOpenApiModule,\n    FieldDuplicateModule,\n    TableModule,\n    ShareDbModule,\n    CalculationModule,\n    GraphModule,\n    V2Module,\n    CanaryModule,\n    ViewModule,\n  ],\n  controllers: [TableController],\n  providers: [\n    DbProvider,\n    TableOpenApiService,\n    TableOpenApiV2Service,\n    TableIndexService,\n    TableDuplicateService,\n  ],\n  exports: [TableOpenApiService, TableOpenApiV2Service, TableDuplicateService],\n})\nexport class TableOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api.server.spec.ts",
    "content": "import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst useV2Feature = () => () => undefined;\n\nvi.mock('../table.service', () => ({\n  TableService: class TableService {},\n}));\n\nvi.mock('./table-open-api.service', () => ({\n  TableOpenApiService: class TableOpenApiService {},\n}));\n\nvi.mock('../table-index.service', () => ({\n  TableIndexService: class TableIndexService {},\n}));\n\nvi.mock('../table-permission.service', () => ({\n  TablePermissionService: class TablePermissionService {},\n}));\n\nvi.mock('./table-open-api-v2.service', () => ({\n  TableOpenApiV2Service: class TableOpenApiV2Service {},\n}));\n\nvi.mock('../../canary/decorators/use-v2-feature.decorator', () => ({\n  UseV2Feature: useV2Feature,\n}));\n\nvi.mock('../../canary/guards/v2-feature.guard', () => ({\n  V2FeatureGuard: class V2FeatureGuard {},\n}));\n\nvi.mock('../../canary/interceptors/v2-indicator.interceptor', () => ({\n  V2IndicatorInterceptor: class V2IndicatorInterceptor {},\n}));\n\nvi.mock('@teable/db-main-prisma', () => ({\n  PrismaService: class PrismaService {},\n}));\n\nlet tableControllerClass: new (...args: unknown[]) => {\n  createTable: (baseId: string, createTableRo: unknown) => Promise<unknown>;\n  archiveTable: (baseId: string, tableId: string) => Promise<unknown>;\n  permanentDeleteTable: (baseId: string, tableId: string) => Promise<unknown>;\n};\n\ndescribe('TableController.archiveTable', () => {\n  beforeAll(async () => {\n    const module = await import('./table-open-api.controller');\n    tableControllerClass = module.TableController as typeof tableControllerClass;\n  });\n\n  const createController = (useV2: boolean) => {\n    const tableOpenApiService = {\n      createTable: vi.fn().mockResolvedValue({ id: 'tbl-legacy' }),\n      deleteTable: vi.fn(),\n      permanentDeleteTables: vi.fn(),\n    };\n    const tableOpenApiV2Service = {\n      createTable: vi.fn().mockResolvedValue({ id: 'tbl-v2' }),\n      deleteTable: vi.fn(),\n    };\n    const cls = {\n      get: vi.fn((key: string) => (key === 'useV2' ? useV2 : undefined)),\n    };\n\n    const controller = new tableControllerClass(\n      {} as never,\n      tableOpenApiService as never,\n      {} as never,\n      {} as never,\n      tableOpenApiV2Service as never,\n      cls as never\n    );\n\n    return {\n      controller,\n      tableOpenApiService,\n      tableOpenApiV2Service,\n    };\n  };\n\n  beforeEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('routes delete-table through v2 when useV2 is enabled', async () => {\n    const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);\n\n    await controller.archiveTable('bse1', 'tbl1');\n\n    expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');\n    expect(tableOpenApiService.deleteTable).not.toHaveBeenCalled();\n  });\n\n  it('routes create-table through v2 when useV2 is enabled', async () => {\n    const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);\n    const createTableRo = { name: 'Projects', fields: [] };\n\n    const result = await controller.createTable('bse1', createTableRo);\n\n    expect(tableOpenApiV2Service.createTable).toHaveBeenCalledWith('bse1', createTableRo);\n    expect(tableOpenApiService.createTable).not.toHaveBeenCalled();\n    expect(result).toEqual({ id: 'tbl-v2' });\n  });\n\n  it('keeps the legacy create-table path when useV2 is disabled', async () => {\n    const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(false);\n    const createTableRo = { name: 'Projects', fields: [] };\n\n    const result = await controller.createTable('bse1', createTableRo);\n\n    expect(tableOpenApiService.createTable).toHaveBeenCalledWith('bse1', createTableRo);\n    expect(tableOpenApiV2Service.createTable).not.toHaveBeenCalled();\n    expect(result).toEqual({ id: 'tbl-legacy' });\n  });\n\n  it('keeps the legacy delete-table path when useV2 is disabled', async () => {\n    const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(false);\n\n    await controller.archiveTable('bse1', 'tbl1');\n\n    expect(tableOpenApiService.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1');\n    expect(tableOpenApiV2Service.deleteTable).not.toHaveBeenCalled();\n  });\n\n  it('routes permanent delete through v2 when useV2 is enabled', async () => {\n    const { controller, tableOpenApiService, tableOpenApiV2Service } = createController(true);\n\n    await controller.permanentDeleteTable('bse1', 'tbl1');\n\n    expect(tableOpenApiV2Service.deleteTable).toHaveBeenCalledWith('bse1', 'tbl1', 'permanent');\n    expect(tableOpenApiService.permanentDeleteTables).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api.service.spec.ts",
    "content": "import { CellValueType, DbFieldType, FieldType, Relationship } from '@teable/core';\nimport { describe, expect, it, vi } from 'vitest';\nimport { TableOpenApiService } from './table-open-api.service';\n\ndescribe('TableOpenApiService.prepareFields', () => {\n  it('prepares same-batch link fields before dependent lookup and rollup fields', async () => {\n    const nameFieldRo = {\n      id: 'fldName',\n      name: 'Name',\n      type: FieldType.SingleLineText,\n    };\n    const linkFieldRo = {\n      id: 'fldLink',\n      name: 'Company',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeignName',\n      },\n    };\n    const lookupFieldRo = {\n      id: 'fldLookup',\n      name: 'Company Name',\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        linkFieldId: 'fldLink',\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeignName',\n      },\n    };\n    const rollupFieldRo = {\n      id: 'fldRollup',\n      name: 'Company Revenue',\n      type: FieldType.Rollup,\n      options: {\n        expression: 'sum({values})',\n      },\n      lookupOptions: {\n        linkFieldId: 'fldLink',\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeignRevenue',\n      },\n    };\n\n    const preparedNameField = {\n      id: 'fldName',\n      name: 'Name',\n      dbFieldName: 'name',\n      type: FieldType.SingleLineText,\n      options: {},\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Text,\n    };\n    const preparedLinkField = {\n      id: 'fldLink',\n      name: 'Company',\n      dbFieldName: 'company',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeignName',\n        fkHostTableName: '__link_host',\n        selfKeyName: '__fk_self',\n        foreignKeyName: '__fk_foreign',\n      },\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n      isMultipleCellValue: undefined,\n    };\n\n    const fieldSupplementService = {\n      prepareCreateFields: vi.fn().mockResolvedValue([preparedNameField, preparedLinkField]),\n      prepareCreateField: vi.fn().mockImplementation(async (_tableId, fieldRo, batchFieldVos) => {\n        expect(batchFieldVos).toEqual(\n          expect.arrayContaining([\n            expect.objectContaining({\n              id: 'fldLink',\n              type: FieldType.Link,\n              options: expect.objectContaining({\n                foreignTableId: 'tblForeign',\n                fkHostTableName: '__link_host',\n              }),\n            }),\n          ])\n        );\n\n        return {\n          id: fieldRo.id,\n          name: fieldRo.name,\n          dbFieldName: fieldRo.id === 'fldLookup' ? 'company_name' : 'company_revenue',\n          type: fieldRo.type,\n          isLookup: fieldRo.isLookup,\n          options: fieldRo.options ?? {},\n          lookupOptions: fieldRo.lookupOptions,\n          cellValueType: CellValueType.String,\n          dbFieldType: DbFieldType.Text,\n        };\n      }),\n    };\n\n    const service = new TableOpenApiService(\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      fieldSupplementService as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never,\n      {} as never\n    );\n\n    const fields = await (\n      service as unknown as {\n        prepareFields: (tableId: string, fieldRos: Array<typeof nameFieldRo>) => Promise<unknown[]>;\n      }\n    ).prepareFields('tblTest', [nameFieldRo, linkFieldRo, lookupFieldRo, rollupFieldRo]);\n\n    expect(fieldSupplementService.prepareCreateFields).toHaveBeenCalledWith('tblTest', [\n      nameFieldRo,\n      linkFieldRo,\n    ]);\n    expect(fieldSupplementService.prepareCreateField).toHaveBeenCalledTimes(2);\n    expect(fields).toHaveLength(4);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts",
    "content": "import { NotFoundException, Injectable, Logger } from '@nestjs/common';\nimport type {\n  FieldAction,\n  IFieldRo,\n  IFieldVo,\n  ILinkFieldOptions,\n  ILookupOptionsVo,\n  IViewRo,\n  RecordAction,\n  IRole,\n  TableAction,\n  ViewAction,\n  BasePermission,\n} from '@teable/core';\nimport {\n  ActionPrefix,\n  FieldKeyType,\n  FieldType,\n  HttpErrorCode,\n  IdPrefix,\n  TemplateRolePermission,\n  actionPrefixMap,\n  getBasePermission,\n  isLinkLookupOptions,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CreateRecordAction, ResourceType } from '@teable/openapi';\nimport type {\n  ICreateRecordsRo,\n  ICreateTableRo,\n  ICreateTableWithDefault,\n  IDuplicateTableRo,\n  ITableFullVo,\n  ITablePermissionVo,\n  ITableVo,\n  IUpdateOrderRo,\n} from '@teable/openapi';\nimport { nanoid } from 'nanoid';\nimport { ClsService } from 'nestjs-cls';\nimport { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport { RawOpType } from '../../../share-db/interface';\nimport type { IClsStore } from '../../../types/cls';\nimport { updateOrder } from '../../../utils/update-order';\nimport { PermissionService } from '../../auth/permission.service';\nimport { BatchService } from '../../calculation/batch.service';\nimport { LinkService } from '../../calculation/link.service';\nimport { FieldCreatingService } from '../../field/field-calculate/field-creating.service';\nimport { FieldSupplementService } from '../../field/field-calculate/field-supplement.service';\nimport { createFieldInstanceByVo } from '../../field/model/factory';\nimport { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport { RecordService } from '../../record/record.service';\nimport { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport { TableDuplicateService } from '../table-duplicate.service';\nimport { TableService } from '../table.service';\n\n@Injectable()\nexport class TableOpenApiService {\n  private logger = new Logger(TableOpenApiService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly recordService: RecordService,\n    private readonly tableService: TableService,\n    private readonly linkService: LinkService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly fieldCreatingService: FieldCreatingService,\n    private readonly fieldSupplementService: FieldSupplementService,\n    private readonly permissionService: PermissionService,\n    private readonly tableDuplicateService: TableDuplicateService,\n    private readonly batchService: BatchService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  private async createView(tableId: string, viewRos: IViewRo[]) {\n    const viewCreationPromises = viewRos.map(async (viewRo) => {\n      return this.viewOpenApiService.createView(tableId, viewRo);\n    });\n    return await Promise.all(viewCreationPromises);\n  }\n\n  private async createField(tableId: string, fieldVos: IFieldVo[]) {\n    const fieldSnapshots: IFieldVo[] = [];\n    const fieldNameSet = new Set<string>();\n    for (const fieldVo of fieldVos) {\n      if (fieldNameSet.has(fieldVo.name)) {\n        throw new CustomHttpException(\n          `Field name ${fieldVo.name} already exists`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.fieldNameAlreadyExists',\n            },\n          }\n        );\n      }\n      fieldNameSet.add(fieldVo.name);\n      const fieldInstance = createFieldInstanceByVo(fieldVo);\n      await this.fieldCreatingService.alterCreateField(tableId, fieldInstance);\n      fieldSnapshots.push(fieldVo);\n    }\n    return fieldSnapshots;\n  }\n\n  private async createFields(tableId: string, fieldVos: IFieldVo[]) {\n    const fieldNameSet = new Set<string>();\n\n    for (const fieldVo of fieldVos) {\n      if (fieldNameSet.has(fieldVo.name)) {\n        throw new CustomHttpException(\n          `Field name ${fieldVo.name} already exists`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.field.fieldNameAlreadyExists',\n            },\n          }\n        );\n      }\n      fieldNameSet.add(fieldVo.name);\n    }\n\n    const fieldInstances = fieldVos.map((fieldVo) => createFieldInstanceByVo(fieldVo));\n\n    await this.fieldCreatingService.alterCreateFields(tableId, fieldInstances);\n\n    return fieldVos;\n  }\n\n  private async createRecords(tableId: string, data: ICreateRecordsRo) {\n    return this.recordOpenApiService.createRecords(tableId, data);\n  }\n\n  private async prepareFields(tableId: string, fieldRos: IFieldRo[]) {\n    const independentFields: IFieldRo[] = [];\n    const dependentFields: IFieldRo[] = [];\n    fieldRos.forEach((field) => {\n      if (field.type === FieldType.Formula || field.type === FieldType.Rollup || field.isLookup) {\n        dependentFields.push(field);\n      } else {\n        independentFields.push(field);\n      }\n    });\n\n    const fields: IFieldVo[] = await this.fieldSupplementService.prepareCreateFields(\n      tableId,\n      independentFields\n    );\n\n    const allFieldRos = independentFields.concat(dependentFields);\n\n    const fieldVoMap = new Map<IFieldRo, IFieldVo>();\n    independentFields.forEach((f, i) => fieldVoMap.set(f, fields[i]));\n\n    for (const fieldRo of dependentFields) {\n      const batchFieldVos = allFieldRos\n        .filter((ro) => ro !== fieldRo)\n        .map((ro) => fieldVoMap.get(ro) ?? (ro as unknown as IFieldVo));\n      const computedFieldVo = await this.fieldSupplementService.prepareCreateField(\n        tableId,\n        fieldRo,\n        batchFieldVos\n      );\n      fieldVoMap.set(fieldRo, computedFieldVo);\n    }\n\n    const orderedFields = fieldRos.map((ro) => fieldVoMap.get(ro)).filter(Boolean) as IFieldVo[];\n\n    const repeatedDbFieldNames = orderedFields\n      .map((f) => f.dbFieldName)\n      .filter((value, index, self) => self.indexOf(value) !== index);\n\n    // generator dbFieldName may repeat, this is fix it.\n    return orderedFields.map((f) => {\n      const newField = { ...f };\n      const { dbFieldName } = newField;\n\n      if (repeatedDbFieldNames.includes(dbFieldName)) {\n        newField.dbFieldName = `${dbFieldName}_${nanoid(3)}`;\n      }\n\n      return newField;\n    });\n  }\n\n  async createTable(baseId: string, tableRo: ICreateTableWithDefault): Promise<ITableFullVo> {\n    const schema = await this.prismaService.$tx(async () => {\n      const tableVo = await this.createTableMeta(baseId, tableRo);\n      const tableId = tableVo.id;\n\n      const preparedFields = await this.prepareFields(tableId, tableRo.fields);\n\n      // set the first field to be the primary field if not set\n      if (!preparedFields.find((field) => field.isPrimary)) {\n        preparedFields[0].isPrimary = true;\n      }\n\n      // create teable should not set computed field isPending, because noting need to calculate when create\n      preparedFields.forEach((field) => delete field.isPending);\n      await this.createFields(tableId, preparedFields);\n\n      const viewVos = await this.createView(tableId, tableRo.views);\n      const allFieldVos = await this.fieldOpenApiService.getFields(tableId, {\n        filterHidden: false,\n      });\n\n      // Maintain original field order from input to ensure consistent API response\n      const fieldIdOrder = new Map(preparedFields.map((f, i) => [f.id, i]));\n      const fieldVos = allFieldVos.sort((a, b) => {\n        const orderA = fieldIdOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;\n        const orderB = fieldIdOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;\n        return orderA - orderB;\n      });\n\n      return {\n        ...tableVo,\n        total: tableRo.records?.length || 0,\n        fields: fieldVos,\n        views: viewVos,\n        defaultViewId: viewVos[0].id,\n      };\n    });\n\n    const isDefaultRecords =\n      tableRo.records?.length === 3 &&\n      tableRo?.records?.every(({ fields }) => Object.keys(fields).length === 0);\n\n    // default records\n    if (isDefaultRecords) {\n      this.cls.set('skipRecordAuditLog', true);\n    }\n\n    const records = await this.prismaService.$tx(async () => {\n      const recordsVo =\n        tableRo.records?.length &&\n        (await this.createRecords(schema.id, {\n          records: tableRo.records,\n          fieldKeyType: tableRo.fieldKeyType ?? FieldKeyType.Name,\n        }));\n\n      return recordsVo ? recordsVo.records : [];\n    });\n\n    if (isDefaultRecords) {\n      await this.emitDefaultRecordsAuditLog(schema.id, tableRo);\n    }\n\n    return {\n      ...schema,\n      records,\n    };\n  }\n\n  async duplicateTable(baseId: string, tableId: string, tableRo: IDuplicateTableRo) {\n    return await this.tableDuplicateService.duplicateTable(baseId, tableId, tableRo);\n  }\n\n  async createTableMeta(baseId: string, tableRo: ICreateTableRo) {\n    return await this.tableService.createTable(baseId, tableRo);\n  }\n\n  async getTable(baseId: string, tableId: string): Promise<ITableVo> {\n    return await this.tableService.getTableMeta(baseId, tableId);\n  }\n\n  async getTables(baseId: string, includeTableIds?: string[]): Promise<ITableVo[]> {\n    const tablesMeta = await this.prismaService.txClient().tableMeta.findMany({\n      orderBy: { order: 'asc' },\n      where: {\n        baseId,\n        deletedTime: null,\n        id: includeTableIds ? { in: includeTableIds } : undefined,\n      },\n    });\n    const tableIds = tablesMeta.map((tableMeta) => tableMeta.id);\n    const tableDefaultViewIds = await this.tableService.getTableDefaultViewId(tableIds);\n    return tablesMeta.map((tableMeta, i) => {\n      const defaultViewId = tableDefaultViewIds[i];\n      if (!defaultViewId) {\n        throw new CustomHttpException(\n          `defaultViewId is not found in table ${tableMeta.id}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.defaultViewNotFound',\n            },\n          }\n        );\n      }\n      return {\n        ...tableMeta,\n        description: tableMeta.description ?? undefined,\n        icon: tableMeta.icon ?? undefined,\n        lastModifiedTime:\n          tableMeta.lastModifiedTime?.toISOString() || tableMeta.createdTime.toISOString(),\n        defaultViewId,\n      };\n    });\n  }\n\n  async detachLink(tableId: string) {\n    // handle the link field in this table\n    const linkFields = await this.prismaService.txClient().field.findMany({\n      where: { tableId, type: FieldType.Link, isLookup: null, deletedTime: null },\n      select: { id: true, options: true },\n    });\n\n    for (const field of linkFields) {\n      if (field.options) {\n        const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n        // if the link field is a self-link field, skip it\n        if (options.foreignTableId === tableId) {\n          continue;\n        }\n      }\n      await this.fieldOpenApiService.convertField(tableId, field.id, {\n        type: FieldType.SingleLineText,\n      });\n    }\n\n    // handle the link field in related tables\n    const relatedLinkFieldRaws = await this.linkService.getRelatedLinkFieldRaws(tableId);\n\n    for (const field of relatedLinkFieldRaws) {\n      if (field.tableId === tableId) {\n        continue;\n      }\n      await this.fieldOpenApiService.convertField(field.tableId, field.id, {\n        type: FieldType.SingleLineText,\n      });\n    }\n  }\n\n  async permanentDeleteTables(baseId: string, tableIds: string[]) {\n    // If the table has already been deleted, exceptions may occur\n    // If the table hasn't been deleted and permanent deletion is executed directly,\n    // we need to handle the deletion of associated data\n    try {\n      for (const tableId of tableIds) {\n        await this.detachLink(tableId);\n      }\n    } catch (e) {\n      console.log('Permanent delete tables error:', e);\n    }\n\n    return await this.prismaService.$tx(\n      async () => {\n        await this.dropTables(tableIds);\n        await this.cleanTaskRelatedData(tableIds);\n        await this.cleanTablesRelatedData(baseId, tableIds);\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async dropTables(tableIds: string[]) {\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: tableIds } },\n      select: { dbTableName: true, version: true, id: true, baseId: true, deletedTime: true },\n    });\n\n    for (const table of tables) {\n      if (!table.deletedTime) {\n        await this.batchService.saveRawOps(table.baseId, RawOpType.Del, IdPrefix.Table, [\n          { docId: table.id, version: table.version },\n        ]);\n      }\n      await this.prismaService\n        .txClient()\n        .$executeRawUnsafe(this.dbProvider.dropTable(table.dbTableName));\n    }\n  }\n\n  async cleanTaskRelatedData(tableIds: string[]) {\n    const alternativeFields = await this.prismaService.txClient().field.findMany({\n      where: { tableId: { in: tableIds } },\n      select: { id: true },\n    });\n    const alternativeFieldIds = alternativeFields.map((field) => field.id);\n\n    // clean task reference for fields\n    await this.prismaService.txClient().taskReference.deleteMany({\n      where: {\n        OR: [\n          { fromFieldId: { in: alternativeFieldIds } },\n          { toFieldId: { in: alternativeFieldIds } },\n        ],\n      },\n    });\n\n    // clean task for table\n    await this.prismaService.txClient().task.deleteMany({\n      where: {\n        OR: tableIds.map((tableId) => ({\n          snapshot: {\n            contains: `\"tableId\":\"${tableId}\"`,\n          },\n        })),\n      },\n    });\n  }\n\n  async cleanReferenceFieldIds(tableIds: string[]) {\n    const fields = await this.prismaService.txClient().field.findMany({\n      where: { tableId: { in: tableIds }, type: { in: [FieldType.Link, FieldType.Formula] } },\n      select: { id: true },\n    });\n    const fieldIds = fields.map((field) => field.id);\n    await this.prismaService.txClient().reference.deleteMany({\n      where: { OR: [{ fromFieldId: { in: fieldIds } }, { toFieldId: { in: fieldIds } }] },\n    });\n  }\n\n  async cleanTablesRelatedData(baseId: string, tableIds: string[]) {\n    // delete field for table\n    await this.prismaService.txClient().field.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n\n    // delete view for table\n    await this.prismaService.txClient().view.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n\n    // clean attachment for table\n    await this.prismaService.txClient().attachmentsTable.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n\n    // clear ops for view/field/record\n    await this.prismaService.txClient().ops.deleteMany({\n      where: { collection: { in: tableIds } },\n    });\n\n    // clean ops for table\n    await this.prismaService.txClient().ops.deleteMany({\n      where: { collection: baseId, docId: { in: tableIds } },\n    });\n\n    await this.prismaService.txClient().tableMeta.deleteMany({\n      where: { id: { in: tableIds } },\n    });\n\n    // clean record history for table\n    await this.prismaService.txClient().recordHistory.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n\n    // clean trash for table\n    await this.prismaService.txClient().trash.deleteMany({\n      where: { resourceId: { in: tableIds }, resourceType: ResourceType.Table },\n    });\n\n    // clean table trash\n    await this.prismaService.txClient().tableTrash.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n\n    // clean record trash\n    await this.prismaService.txClient().recordTrash.deleteMany({\n      where: { tableId: { in: tableIds } },\n    });\n  }\n\n  async deleteTable(baseId: string, tableId: string) {\n    try {\n      await this.detachLink(tableId);\n    } catch (e) {\n      console.log(`Detach link error in table ${tableId}:`, e);\n    }\n\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        const deletedTime = new Date();\n\n        await this.tableService.deleteTable(baseId, tableId, deletedTime);\n\n        await prisma.field.updateMany({\n          where: { tableId, deletedTime: null },\n          data: { deletedTime },\n        });\n\n        await prisma.view.updateMany({\n          where: { tableId, deletedTime: null },\n          data: { deletedTime },\n        });\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async restoreTable(baseId: string, tableId: string) {\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        const { deletedTime } = await prisma.trash.findFirstOrThrow({\n          where: { resourceId: tableId, resourceType: ResourceType.Table },\n        });\n\n        if (!deletedTime) {\n          throw new CustomHttpException(\n            'Unable to restore this table because it is not in the trash',\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.table.notInTrash',\n              },\n            }\n          );\n        }\n\n        await this.tableService.restoreTable(baseId, tableId);\n\n        await prisma.field.updateMany({\n          where: { tableId, deletedTime },\n          data: { deletedTime: null },\n        });\n\n        await prisma.view.updateMany({\n          where: { tableId, deletedTime },\n          data: { deletedTime: null },\n        });\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async sqlQuery(tableId: string, viewId: string, sql: string) {\n    this.logger.log('sqlQuery:sql: ' + sql);\n    const { queryBuilder } = await this.recordService.buildFilterSortQuery(\n      tableId,\n      {\n        viewId,\n      },\n      true\n    );\n\n    const baseQuery = queryBuilder.toString();\n    const { dbTableName } = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n\n    const combinedQuery = `\n      WITH base AS (${baseQuery})\n      ${sql.replace(dbTableName, 'base')};\n    `;\n    this.logger.log('sqlQuery:sql:combine: ' + combinedQuery);\n\n    return this.prismaService.$queryRawUnsafe(combinedQuery);\n  }\n\n  async updateName(baseId: string, tableId: string, name: string) {\n    await this.prismaService.$tx(async () => {\n      await this.tableService.updateTable(baseId, tableId, { name });\n    });\n  }\n\n  async updateIcon(baseId: string, tableId: string, icon: string) {\n    await this.prismaService.$tx(async () => {\n      await this.tableService.updateTable(baseId, tableId, { icon });\n    });\n  }\n\n  async updateDescription(baseId: string, tableId: string, description: string | null) {\n    await this.prismaService.$tx(async () => {\n      await this.tableService.updateTable(baseId, tableId, { description });\n    });\n  }\n\n  async updateDbTableName(baseId: string, tableId: string, dbTableNameRo: string) {\n    const dbTableName = this.dbProvider.joinDbTableName(baseId, dbTableNameRo);\n    const existDbTableName = await this.prismaService.tableMeta\n      .findFirst({\n        where: { baseId, dbTableName, deletedTime: null },\n        select: { id: true },\n      })\n      .catch(() => {\n        throw new NotFoundException(`table ${tableId} not found`);\n      });\n\n    if (existDbTableName) {\n      throw new CustomHttpException(\n        `dbTableName ${dbTableNameRo} already exists`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.dbTableNameAlreadyExists',\n          },\n        }\n      );\n    }\n\n    const { dbTableName: oldDbTableName } = await this.prismaService.tableMeta\n      .findFirstOrThrow({\n        where: { id: tableId, baseId, deletedTime: null },\n        select: { dbTableName: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`table ${tableId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n\n    const linkFieldsQuery = this.dbProvider.optionsQuery(\n      FieldType.Link,\n      'fkHostTableName',\n      oldDbTableName\n    );\n    const lookupFieldsQuery = this.dbProvider.lookupOptionsQuery('fkHostTableName', oldDbTableName);\n\n    await this.prismaService.$tx(async (prisma) => {\n      const linkFieldsRaw =\n        await this.prismaService.$queryRawUnsafe<{ id: string; options: string }[]>(\n          linkFieldsQuery\n        );\n      const lookupFieldsRaw =\n        await this.prismaService.$queryRawUnsafe<{ id: string; lookupOptions: string }[]>(\n          lookupFieldsQuery\n        );\n\n      for (const field of linkFieldsRaw) {\n        const options = JSON.parse(field.options as string) as ILinkFieldOptions;\n        await prisma.field.update({\n          where: { id: field.id },\n          data: { options: JSON.stringify({ ...options, fkHostTableName: dbTableName }) },\n        });\n      }\n\n      for (const field of lookupFieldsRaw) {\n        const lookupOptions = JSON.parse(field.lookupOptions as string) as ILookupOptionsVo;\n        if (!isLinkLookupOptions(lookupOptions)) {\n          continue;\n        }\n        await prisma.field.update({\n          where: { id: field.id },\n          data: {\n            lookupOptions: JSON.stringify({\n              ...lookupOptions,\n              fkHostTableName: dbTableName,\n            }),\n          },\n        });\n      }\n\n      await this.tableService.updateTable(baseId, tableId, { dbTableName });\n      const renameSql = this.dbProvider.renameTableName(oldDbTableName, dbTableName);\n      for (const sql of renameSql) {\n        await prisma.$executeRawUnsafe(sql);\n      }\n    });\n  }\n\n  async shuffle(baseId: string) {\n    const tables = await this.prismaService.tableMeta.findMany({\n      where: { baseId, deletedTime: null },\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n\n    this.logger.log(`lucky table shuffle! ${baseId}`, 'shuffle');\n\n    await this.prismaService.$tx(async () => {\n      for (let i = 0; i < tables.length; i++) {\n        const table = tables[i];\n        await this.tableService.updateTable(baseId, table.id, { order: i });\n      }\n    });\n  }\n\n  async updateOrder(baseId: string, tableId: string, orderRo: IUpdateOrderRo) {\n    const { anchorId, position } = orderRo;\n\n    const tablesOrder = await this.prismaService.txClient().tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n      select: {\n        order: true,\n      },\n    });\n\n    const uniqOrder = [...new Set(tablesOrder.map((t) => t.order))];\n\n    // if the table order has the same order, should shuffle\n    const shouldShuffle = uniqOrder.length !== tablesOrder.length;\n\n    if (shouldShuffle) {\n      await this.shuffle(baseId);\n    }\n\n    const table = await this.prismaService.tableMeta\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { baseId, id: tableId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Table ${tableId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n\n    const anchorTable = await this.prismaService.tableMeta\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { baseId, id: anchorId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.anchorNotFound',\n          },\n        });\n      });\n\n    await updateOrder({\n      query: baseId,\n      position,\n      item: table,\n      anchorItem: anchorTable,\n      getNextItem: async (whereOrder, align) => {\n        return this.prismaService.tableMeta.findFirst({\n          select: { order: true, id: true },\n          where: {\n            baseId,\n            deletedTime: null,\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (\n        parentId: string,\n        id: string,\n        data: { newOrder: number; oldOrder: number }\n      ) => {\n        await this.prismaService.$tx(async () => {\n          await this.tableService.updateTable(parentId, id, { order: data.newOrder });\n        });\n      },\n      shuffle: this.shuffle.bind(this),\n    });\n  }\n\n  async getPermission(baseId: string, tableId: string): Promise<ITablePermissionVo> {\n    const baseShare = this.cls.get('baseShare');\n    if (\n      this.cls.get('template') ||\n      this.cls.get('template.baseId') === baseId ||\n      baseShare?.baseId === baseId\n    ) {\n      return this.getPermissionByPermissionMap(\n        TemplateRolePermission as Record<BasePermission, boolean>\n      );\n    }\n    let role: IRole | null = await this.permissionService.getRoleByBaseId(baseId);\n    if (!role) {\n      const { spaceId } = await this.permissionService.getUpperIdByBaseId(baseId);\n      role = await this.permissionService.getRoleBySpaceId(spaceId);\n    }\n    if (!role) {\n      throw new CustomHttpException(`Role not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.role.notFound',\n        },\n      });\n    }\n    return this.getPermissionByRole(tableId, role);\n  }\n\n  private async getPermissionByPermissionMap(permissionMap: Record<BasePermission, boolean>) {\n    const tablePermission = actionPrefixMap[ActionPrefix.Table].reduce(\n      (acc, action) => {\n        acc[action] = permissionMap[action];\n        return acc;\n      },\n      {} as Record<TableAction, boolean>\n    );\n    const viewPermission = actionPrefixMap[ActionPrefix.View].reduce(\n      (acc, action) => {\n        acc[action] = permissionMap[action];\n        return acc;\n      },\n      {} as Record<ViewAction, boolean>\n    );\n\n    const recordPermission = actionPrefixMap[ActionPrefix.Record].reduce(\n      (acc, action) => {\n        acc[action] = permissionMap[action];\n        return acc;\n      },\n      {} as Record<RecordAction, boolean>\n    );\n\n    const fieldPermission = actionPrefixMap[ActionPrefix.Field].reduce(\n      (acc, action) => {\n        acc[action] = permissionMap[action];\n        return acc;\n      },\n      {} as Record<FieldAction, boolean>\n    );\n\n    return {\n      table: tablePermission,\n      field: fieldPermission,\n      record: recordPermission,\n      view: viewPermission,\n    };\n  }\n\n  async getPermissionByRole(tableId: string, role: IRole) {\n    const permissionMap = getBasePermission(role);\n    return this.getPermissionByPermissionMap(permissionMap);\n  }\n\n  private async emitDefaultRecordsAuditLog(tableId: string, ro: ICreateTableWithDefault) {\n    this.eventEmitterService.emit(Events.TABLE_RECORD_CREATE_RELATIVE, {\n      resourceId: tableId,\n      action: CreateRecordAction.CreateDefaultRecords,\n      recordCount: 3,\n      params: ro,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table.pipe.helper.ts",
    "content": "import type { IFieldVo } from '@teable/core';\nimport { HttpErrorCode, PRIMARY_SUPPORTED_TYPES } from '@teable/core';\nimport type { ICreateTableRo, ICreateTableWithDefault } from '@teable/openapi';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { DEFAULT_FIELDS, DEFAULT_VIEWS, DEFAULT_RECORD_DATA } from '../constant';\n\nexport const prepareCreateTableRo = (tableRo: ICreateTableRo): ICreateTableWithDefault => {\n  const fieldRos = tableRo.fields && tableRo.fields.length ? tableRo.fields : DEFAULT_FIELDS;\n  // make sure first field to be the primary field;\n  (fieldRos[0] as IFieldVo).isPrimary = true;\n  if (!PRIMARY_SUPPORTED_TYPES.has(fieldRos[0].type)) {\n    throw new CustomHttpException(\n      `Field type ${fieldRos[0].type} is not supported as primary field`,\n      HttpErrorCode.VALIDATION_ERROR,\n      {\n        localization: {\n          i18nKey: 'httpErrors.field.primaryFieldNotSupported',\n        },\n      }\n    );\n  }\n\n  return {\n    ...tableRo,\n    fields: fieldRos,\n    views: tableRo.views && tableRo.views.length ? tableRo.views : DEFAULT_VIEWS,\n    records: tableRo.records ? tableRo.records : DEFAULT_RECORD_DATA,\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/open-api/table.pipe.ts",
    "content": "import type { ArgumentMetadata, PipeTransform } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport type { ICreateTableRo } from '@teable/openapi';\nimport { prepareCreateTableRo } from './table.pipe.helper';\n\n@Injectable()\nexport class TablePipe implements PipeTransform {\n  async transform(value: ICreateTableRo, _metadata: ArgumentMetadata) {\n    return prepareCreateTableRo(value);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table-duplicate.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport {\n  generateViewId,\n  generateShareId,\n  FieldType,\n  ViewType,\n  generatePluginInstallId,\n  HttpErrorCode,\n} from '@teable/core';\nimport type { View } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  CreateRecordAction,\n  type IDuplicateTableRo,\n  type IDuplicateTableVo,\n  type IFieldWithTableIdJson,\n} from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { get, pick, omit } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { DataLoaderService } from '../data-loader/data-loader.service';\nimport { FieldDuplicateService } from '../field/field-duplicate/field-duplicate.service';\nimport { createFieldInstanceByRaw, rawField2FieldObj } from '../field/model/factory';\nimport type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';\nimport { FieldOpenApiService } from '../field/open-api/field-open-api.service';\nimport { ROW_ORDER_FIELD_PREFIX } from '../view/constant';\nimport { createViewVoByRaw } from '../view/model/factory';\nimport { TableService } from './table.service';\n\n@Injectable()\nexport class TableDuplicateService {\n  private logger = new Logger(TableDuplicateService.name);\n\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    private readonly tableService: TableService,\n    private readonly fieldOpenService: FieldOpenApiService,\n    private readonly fieldDuplicateService: FieldDuplicateService,\n    private readonly dataLoaderService: DataLoaderService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  private disableTableDomainDataLoader() {\n    if (!this.cls.isActive()) {\n      return;\n    }\n    this.cls.set('dataLoaderCache.disabled', true);\n    this.cls.set('dataLoaderCache.cacheKeys', []);\n    this.dataLoaderService.field.clear();\n    this.dataLoaderService.table.clear();\n  }\n\n  async duplicateTable(baseId: string, tableId: string, duplicateRo: IDuplicateTableRo) {\n    const { includeRecords, name } = duplicateRo;\n    this.disableTableDomainDataLoader();\n    const {\n      id: sourceTableId,\n      icon,\n      description,\n      dbTableName,\n    } = await this.prismaService.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n    });\n    return await this.prismaService.$tx(\n      async () => {\n        const newTableVo = await this.tableService.createTable(baseId, {\n          name,\n          icon,\n          description,\n        });\n        const sourceToTargetFieldMap = await this.duplicateFields(sourceTableId, newTableVo.id);\n        const sourceToTargetViewMap = await this.duplicateViews(\n          sourceTableId,\n          newTableVo.id,\n          sourceToTargetFieldMap\n        );\n        await this.repairDuplicateOmit(\n          sourceToTargetFieldMap,\n          sourceToTargetViewMap,\n          newTableVo.id\n        );\n\n        if (includeRecords) {\n          const count = await this.duplicateTableData(\n            dbTableName,\n            newTableVo.dbTableName,\n            sourceToTargetViewMap,\n            sourceToTargetFieldMap,\n            []\n          );\n\n          await this.emitTableDuplicateAuditLog(newTableVo.id, count, duplicateRo);\n\n          await this.duplicateAttachments(sourceTableId, newTableVo.id, sourceToTargetFieldMap);\n          await this.duplicateLinkJunction(\n            { [sourceTableId]: newTableVo.id },\n            sourceToTargetFieldMap\n          );\n        }\n\n        const viewPlain = await this.prismaService.txClient().view.findMany({\n          where: {\n            tableId: newTableVo.id,\n            deletedTime: null,\n          },\n          orderBy: {\n            order: 'asc',\n          },\n        });\n\n        const fieldPlain = await this.prismaService.txClient().field.findMany({\n          where: {\n            tableId: newTableVo.id,\n            deletedTime: null,\n          },\n          orderBy: {\n            createdTime: 'asc',\n          },\n        });\n\n        return {\n          ...newTableVo,\n          views: viewPlain.map((v) => createViewVoByRaw(v)),\n          fields: fieldPlain.map((f) => omit(rawField2FieldObj(f), ['meta'])),\n          viewMap: sourceToTargetViewMap,\n          fieldMap: sourceToTargetFieldMap,\n          defaultViewId: viewPlain[0]?.id,\n        } as IDuplicateTableVo;\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async duplicateTableData(\n    sourceDbTableName: string,\n    targetDbTableName: string,\n    sourceToTargetViewMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>,\n    crossBaseLinkInfo: { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[]\n  ) {\n    const prisma = this.prismaService.txClient();\n    const qb = this.knex.queryBuilder();\n\n    const columnInfoQuery = this.dbProvider.columnInfo(sourceDbTableName);\n\n    const newColumnsInfoQuery = this.dbProvider.columnInfo(targetDbTableName);\n\n    const allSourceColumns = (\n      await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery)\n    ).map(({ name }) => name);\n\n    // Only filter by crossBaseLinkInfo if it's not empty\n    // When crossBaseLinkInfo is empty (normal table duplication), include all columns\n    const oldOriginColumns =\n      crossBaseLinkInfo.length === 0\n        ? allSourceColumns\n        : allSourceColumns.filter((name) =>\n            crossBaseLinkInfo\n              .map(({ selfKeyName }) => selfKeyName)\n              .filter((selfKeyName) => selfKeyName !== '__id' && selfKeyName)\n              .includes(name)\n          );\n\n    const crossBaseLinkDbFieldNames = crossBaseLinkInfo.map(\n      ({ dbFieldName, isMultipleCellValue }) => ({\n        dbFieldName,\n        isMultipleCellValue,\n      })\n    );\n\n    const newOriginColumns = (\n      await prisma.$queryRawUnsafe<{ name: string }[]>(newColumnsInfoQuery)\n    ).map(({ name }) => name);\n\n    const oldRowColumns = oldOriginColumns.filter((name) =>\n      name.startsWith(ROW_ORDER_FIELD_PREFIX)\n    );\n\n    // Exclude computed field columns (formula/lookup/rollup/created time/etc.) from data insertion\n    // because generated columns cannot be directly inserted into\n    let computedDbFieldNames: string[] = [];\n    try {\n      const targetTable = await prisma.tableMeta.findFirst({\n        where: { dbTableName: targetDbTableName, deletedTime: null },\n        select: { id: true },\n      });\n      if (targetTable?.id) {\n        const computedFields = await prisma.field.findMany({\n          where: { tableId: targetTable.id, deletedTime: null, isComputed: true },\n          select: { dbFieldName: true },\n        });\n        computedDbFieldNames = computedFields.map((f) => f.dbFieldName);\n      }\n    } catch (_e) {\n      // Best effort; if query fails, fallback to existing filters\n      computedDbFieldNames = [];\n    }\n\n    const computedSet = new Set(computedDbFieldNames);\n\n    const newFieldColumns = newOriginColumns.filter(\n      (name) =>\n        !name.startsWith(ROW_ORDER_FIELD_PREFIX) &&\n        !name.startsWith('__fk_fld') &&\n        !computedSet.has(name)\n    );\n\n    const oldFkColumns = oldOriginColumns.filter((name) => name.startsWith('__fk_fld'));\n\n    const newRowColumns = oldRowColumns.map((name) =>\n      sourceToTargetViewMap[name.slice(6)] ? `__row_${sourceToTargetViewMap[name.slice(6)]}` : name\n    );\n\n    const newFkColumns = oldFkColumns.map((name) =>\n      sourceToTargetFieldMap[name.slice(5)] ? `__fk_${sourceToTargetFieldMap[name.slice(5)]}` : name\n    );\n\n    for (const name of newRowColumns) {\n      await this.createRowOrderField(targetDbTableName, name.slice(6));\n    }\n\n    for (const name of newFkColumns) {\n      await this.createFkField(targetDbTableName, name.slice(5));\n    }\n\n    // following field should not be duplicated\n    const systemColumns = [\n      '__auto_number',\n      '__created_time',\n      '__last_modified_time',\n      '__last_modified_by',\n    ];\n\n    const excludeFields = await prisma.field.findMany({\n      where: {\n        id: {\n          in: Object.keys(sourceToTargetFieldMap),\n        },\n        type: FieldType.Button,\n      },\n      select: {\n        dbFieldName: true,\n      },\n    });\n    const excludeDbFieldNames = excludeFields.map(({ dbFieldName }) => dbFieldName);\n    const excludeColumnsSet = new Set([\n      ...systemColumns,\n      ...excludeDbFieldNames,\n      ...computedDbFieldNames,\n    ]);\n\n    // use new table field columns info\n    // old table contains ghost columns or customer columns\n    const oldColumns = newFieldColumns\n      .concat(oldRowColumns)\n      .concat(oldFkColumns)\n      .filter((dbFieldName) => !excludeColumnsSet.has(dbFieldName));\n\n    const newColumns = newFieldColumns\n      .concat(newRowColumns)\n      .concat(newFkColumns)\n      .filter((dbFieldName) => !excludeColumnsSet.has(dbFieldName));\n\n    const sql = this.dbProvider\n      .duplicateTableQuery(qb)\n      .duplicateTableData(\n        sourceDbTableName,\n        targetDbTableName,\n        newColumns,\n        oldColumns,\n        crossBaseLinkDbFieldNames\n      )\n      .toQuery();\n\n    const sourceTableCountSql = await this.knex(sourceDbTableName)\n      .count('*', { as: 'count' })\n      .toQuery();\n\n    const sourceTableCountResult =\n      await prisma.$queryRawUnsafe<[{ count: bigint | number }]>(sourceTableCountSql);\n\n    await prisma.$executeRawUnsafe(sql);\n\n    return Number(sourceTableCountResult[0]?.count || 0);\n  }\n\n  private async createRowOrderField(dbTableName: string, viewId: string) {\n    const prisma = this.prismaService.txClient();\n\n    const rowIndexFieldName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;\n\n    const columnExists = await this.dbProvider.checkColumnExist(\n      dbTableName,\n      rowIndexFieldName,\n      prisma\n    );\n\n    if (!columnExists) {\n      // add a field for maintain row order number\n      const addRowIndexColumnSql = this.knex.schema\n        .alterTable(dbTableName, (table) => {\n          table.double(rowIndexFieldName);\n        })\n        .toQuery();\n      await prisma.$executeRawUnsafe(addRowIndexColumnSql);\n    }\n\n    // create index\n    const indexName = `idx_${ROW_ORDER_FIELD_PREFIX}_${viewId}`;\n    const createRowIndexSQL = this.knex\n      .raw(\n        `\n  CREATE INDEX IF NOT EXISTS ?? ON ?? (??)\n`,\n        [indexName, dbTableName, rowIndexFieldName]\n      )\n      .toQuery();\n\n    await prisma.$executeRawUnsafe(createRowIndexSQL);\n  }\n\n  private async createFkField(dbTableName: string, fieldId: string) {\n    const prisma = this.prismaService.txClient();\n\n    const fkFieldName = `__fk_${fieldId}`;\n\n    const columnExists = await this.dbProvider.checkColumnExist(dbTableName, fkFieldName, prisma);\n\n    if (!columnExists) {\n      const addFkColumnSql = this.knex.schema\n        .alterTable(dbTableName, (table) => {\n          table.string(fkFieldName);\n        })\n        .toQuery();\n      await prisma.$executeRawUnsafe(addFkColumnSql);\n    }\n  }\n\n  private async duplicateFields(sourceTableId: string, targetTableId: string) {\n    const fieldsRaw = await this.prismaService.txClient().field.findMany({\n      where: { tableId: sourceTableId, deletedTime: null },\n      // for promise the link group create order\n      orderBy: {\n        createdTime: 'asc',\n      },\n    });\n    const fieldsInstances = fieldsRaw\n      .map((f) => ({\n        ...createFieldInstanceByRaw(f),\n        order: f.order,\n        createdTime: f.createdTime.toISOString(),\n      }))\n      .map((f) => {\n        return {\n          ...f,\n          sourceTableId,\n          targetTableId,\n        } as IFieldWithTableIdJson;\n      });\n    const sourceToTargetFieldMap: Record<string, string> = {};\n    const tableIdMap: Record<string, string> = {\n      [sourceTableId]: targetTableId,\n    };\n\n    const nonCommonFieldTypes = [\n      FieldType.Link,\n      FieldType.Rollup,\n      FieldType.ConditionalRollup,\n      FieldType.Formula,\n      FieldType.Button,\n    ];\n\n    const commonFields = fieldsInstances.filter(\n      ({ type, isLookup, aiConfig }) =>\n        !nonCommonFieldTypes.includes(type) && !isLookup && !aiConfig\n    );\n\n    // the primary formula which rely on other fields\n    const primaryFormulaFields = fieldsInstances.filter(\n      ({ type, isLookup }) => type === FieldType.Formula && !isLookup\n    );\n\n    // these field require other field, we need to merge them and ensure a specific order\n    const linkFields = fieldsInstances.filter(\n      ({ type, isLookup }) => type === FieldType.Link && !isLookup\n    );\n\n    const buttonFields = fieldsInstances.filter(\n      ({ type, isLookup }) => type === FieldType.Button && !isLookup\n    );\n\n    // rest fields, like formula, rollup, lookup fields\n    const dependencyFields = fieldsInstances.filter(\n      ({ id }) =>\n        ![...primaryFormulaFields, ...linkFields, ...buttonFields, ...commonFields]\n          .map(({ id }) => id)\n          .includes(id)\n    );\n\n    await this.fieldDuplicateService.createCommonFields(commonFields, sourceToTargetFieldMap);\n\n    await this.fieldDuplicateService.createButtonFields(buttonFields, sourceToTargetFieldMap);\n\n    await this.fieldDuplicateService.createTmpPrimaryFormulaFields(\n      primaryFormulaFields,\n      sourceToTargetFieldMap\n    );\n\n    // main fix formula dbField type\n    await this.fieldDuplicateService.repairPrimaryFormulaFields(\n      primaryFormulaFields,\n      sourceToTargetFieldMap\n    );\n\n    // duplicate link fields different from duplicate base link field\n    await this.duplicateLinkFields(\n      sourceTableId,\n      targetTableId,\n      linkFields,\n      sourceToTargetFieldMap\n    );\n\n    await this.fieldDuplicateService.createDependencyFields(\n      dependencyFields,\n      tableIdMap,\n      sourceToTargetFieldMap,\n      'table'\n    );\n\n    // fix formula expression' field map\n    await this.fieldDuplicateService.repairPrimaryFormulaFields(\n      primaryFormulaFields,\n      sourceToTargetFieldMap\n    );\n\n    const formulaFields = fieldsInstances.filter(\n      ({ type, isLookup }) => type === FieldType.Formula && !isLookup\n    );\n\n    // fix formula reference\n    await this.fieldDuplicateService.repairFormulaReference(formulaFields, sourceToTargetFieldMap);\n\n    return sourceToTargetFieldMap;\n  }\n\n  private async duplicateLinkFields(\n    sourceTableId: string,\n    targetTableId: string,\n    linkFields: IFieldWithTableIdJson[],\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const twoWaySelfLinkFields = linkFields.filter((f) => {\n      const options = f.options as ILinkFieldOptions;\n      return options.foreignTableId === sourceTableId;\n    });\n\n    const mergedTwoWaySelfLinkFields = [] as [IFieldWithTableIdJson, IFieldWithTableIdJson][];\n\n    twoWaySelfLinkFields.forEach((f) => {\n      // two-way self link field should only create one of it\n      if (!mergedTwoWaySelfLinkFields.some((group) => group.some(({ id: fId }) => fId === f.id))) {\n        const groupField = twoWaySelfLinkFields.find(\n          ({ options }) => get(options, 'symmetricFieldId') === f.id\n        );\n        groupField && mergedTwoWaySelfLinkFields.push([f, groupField]);\n      }\n    });\n\n    const otherLinkFields = linkFields.filter(\n      (f) => !twoWaySelfLinkFields.map((f) => f.id).includes(f.id)\n    );\n\n    // self link field\n    for (let i = 0; i < mergedTwoWaySelfLinkFields.length; i++) {\n      const f = mergedTwoWaySelfLinkFields[i][0];\n      const { notNull, unique, description } = f;\n      const groupField = mergedTwoWaySelfLinkFields[i][1] as unknown as LinkFieldDto;\n      const { name, type, dbFieldName, id, order } = f;\n      const options = f.options as ILinkFieldOptions;\n      const newField = await this.fieldOpenService.createField(targetTableId, {\n        type: type as FieldType,\n        dbFieldName,\n        name,\n        description,\n        options: {\n          ...pick(options, [\n            'relationship',\n            'isOneWay',\n            'filterByViewId',\n            'filter',\n            'visibleFieldIds',\n          ]),\n          foreignTableId: targetTableId,\n        },\n      });\n      await this.fieldDuplicateService.replenishmentConstraint(newField.id, targetTableId, order, {\n        notNull,\n        unique,\n        dbFieldName,\n      });\n      sourceToTargetFieldMap[id] = newField.id;\n      sourceToTargetFieldMap[options.symmetricFieldId!] = (\n        newField.options as ILinkFieldOptions\n      ).symmetricFieldId!;\n\n      // self link should updated the opposite field dbFieldName and name\n      const { dbTableName: targetDbTableName } = await this.prismaService\n        .txClient()\n        .tableMeta.findUniqueOrThrow({\n          where: {\n            id: targetTableId,\n          },\n          select: {\n            dbTableName: true,\n          },\n        });\n\n      const { dbFieldName: genDbFieldName } = await this.prismaService\n        .txClient()\n        .field.findUniqueOrThrow({\n          where: {\n            id: sourceToTargetFieldMap[groupField.id],\n          },\n          select: {\n            dbFieldName: true,\n          },\n        });\n\n      await this.prismaService.txClient().field.update({\n        where: {\n          id: sourceToTargetFieldMap[groupField.id],\n        },\n        data: {\n          dbFieldName: groupField.dbFieldName,\n          name: groupField.name,\n          options: JSON.stringify({ ...groupField.options, foreignTableId: targetTableId }),\n        },\n      });\n\n      // Only attempt to rename if a physical column exists.\n      // Link fields do not create standard columns; self-link symmetric side definitely doesn't.\n      const prisma = this.prismaService.txClient();\n      const exists = await this.dbProvider.checkColumnExist(\n        targetDbTableName,\n        genDbFieldName,\n        prisma\n      );\n      if (exists) {\n        const alterTableSql = this.dbProvider.renameColumn(\n          targetDbTableName,\n          genDbFieldName,\n          groupField.dbFieldName\n        );\n        for (const sql of alterTableSql) {\n          await prisma.$executeRawUnsafe(sql);\n        }\n      }\n    }\n\n    // other common link field\n    for (let i = 0; i < otherLinkFields.length; i++) {\n      const f = otherLinkFields[i];\n      const { type, description, name, notNull, unique, options, dbFieldName, order } = f;\n      const newField = await this.fieldOpenService.createField(targetTableId, {\n        type: type as FieldType,\n        description,\n        dbFieldName,\n        name,\n        options: {\n          ...pick(options, [\n            'baseId',\n            'relationship',\n            'foreignTableId',\n            'isOneWay',\n            'filterByViewId',\n            'filter',\n            'visibleFieldIds',\n          ]),\n          // duplicate link field always be one-way, consider that advanced auth control etc.\n          isOneWay: true,\n        } as ILinkFieldOptions,\n      });\n      await this.fieldDuplicateService.replenishmentConstraint(newField.id, targetTableId, order, {\n        notNull,\n        unique,\n        dbFieldName,\n      });\n      sourceToTargetFieldMap[f.id] = newField.id;\n    }\n  }\n\n  private async duplicateViews(\n    sourceTableId: string,\n    targetTableId: string,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const views = await this.prismaService.view.findMany({\n      where: { tableId: sourceTableId, deletedTime: null },\n    });\n    const viewsWithoutPlugin = views.filter((v) => v.type !== ViewType.Plugin);\n    const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);\n    const sourceToTargetViewMap = {} as Record<string, string>;\n    const userId = this.cls.get('user.id');\n    const prisma = this.prismaService.txClient();\n    await prisma.view.createMany({\n      data: viewsWithoutPlugin.map((view) => {\n        const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;\n\n        const updatedFields = fieldsToReplace.reduce(\n          (acc, field) => {\n            if (view[field]) {\n              acc[field] = Object.entries(sourceToTargetFieldMap).reduce(\n                (result, [key, value]) => result.replaceAll(key, value),\n                view[field]!\n              );\n            }\n            return acc;\n          },\n          {} as Partial<typeof view>\n        );\n\n        const newViewId = generateViewId();\n\n        sourceToTargetViewMap[view.id] = newViewId;\n\n        return {\n          ...view,\n          createdTime: new Date().toISOString(),\n          createdBy: userId,\n          version: 1,\n          tableId: targetTableId,\n          id: newViewId,\n          shareId: generateShareId(),\n          ...updatedFields,\n        };\n      }),\n    });\n\n    // duplicate plugin view\n    await this.duplicatePluginViews(\n      targetTableId,\n      pluginViews,\n      sourceToTargetViewMap,\n      sourceToTargetFieldMap\n    );\n\n    return sourceToTargetViewMap;\n  }\n\n  private async duplicatePluginViews(\n    targetTableId: string,\n    pluginViews: View[],\n    sourceToTargetViewMap: Record<string, string>,\n    sourceToTargetFieldMap: Record<string, string>\n  ) {\n    const prisma = this.prismaService.txClient();\n\n    if (!pluginViews.length) return;\n\n    const pluginData = await prisma.pluginInstall.findMany({\n      where: {\n        id: {\n          in: pluginViews.map((v) => (v.options ? JSON.parse(v.options).pluginInstallId : null)),\n        },\n      },\n    });\n\n    for (const view of pluginViews) {\n      const plugin = view.options ? JSON.parse(view.options) : null;\n      if (!plugin) {\n        throw new CustomHttpException(\n          `Duplicate plugin view error: plugin not found`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.plugin.notFound',\n            },\n          }\n        );\n      }\n      const { pluginInstallId, pluginId } = plugin;\n\n      const newPluginInsId = generatePluginInstallId();\n      const newViewId = generateViewId();\n\n      sourceToTargetViewMap[view.id] = newViewId;\n\n      const pluginInfo = pluginData.find((p) => p.id === pluginInstallId);\n\n      if (!pluginInfo) continue;\n\n      let curPluginStorage = pluginInfo?.storage;\n      let pluginOptions = plugin.options;\n\n      if (curPluginStorage) {\n        Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {\n          curPluginStorage = curPluginStorage?.replaceAll(key, value) || null;\n        });\n      }\n\n      if (pluginOptions) {\n        Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {\n          pluginOptions = pluginOptions.replaceAll(key, value);\n        });\n        pluginOptions = pluginOptions.replaceAll(pluginId, newPluginInsId);\n      }\n\n      const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;\n\n      const updatedFields = fieldsToReplace.reduce(\n        (acc, field) => {\n          if (view[field]) {\n            acc[field] = Object.entries(sourceToTargetFieldMap).reduce(\n              (result, [key, value]) => result.replaceAll(key, value),\n              view[field]!\n            );\n          }\n          return acc;\n        },\n        {} as Partial<typeof view>\n      );\n\n      await prisma.pluginInstall.create({\n        data: {\n          ...pluginInfo,\n          createdBy: this.cls.get('user.id'),\n          id: newPluginInsId,\n          createdTime: new Date().toISOString(),\n          lastModifiedBy: null,\n          lastModifiedTime: null,\n          storage: curPluginStorage,\n          positionId: newViewId,\n        },\n      });\n\n      await prisma.view.create({\n        data: {\n          ...view,\n          createdTime: new Date().toISOString(),\n          createdBy: this.cls.get('user.id'),\n          version: 1,\n          tableId: targetTableId,\n          id: newViewId,\n          shareId: generateShareId(),\n          options: pluginOptions,\n          ...updatedFields,\n        },\n      });\n    }\n\n    return sourceToTargetViewMap;\n  }\n\n  private async repairDuplicateOmit(\n    sourceToTargetFieldMap: Record<string, string>,\n    sourceToTargetViewMap: Record<string, string>,\n    targetTableId: string\n  ) {\n    const fieldRaw = await this.prismaService.txClient().field.findMany({\n      where: {\n        tableId: targetTableId,\n        deletedTime: null,\n      },\n      orderBy: {\n        createdTime: 'asc',\n      },\n    });\n\n    const selfLinkFields = fieldRaw.filter(\n      ({ type, options }) =>\n        type === FieldType.Link &&\n        options &&\n        (JSON.parse(options) as ILinkFieldOptions)?.foreignTableId === targetTableId\n    );\n\n    for (const field of selfLinkFields) {\n      const { id: fieldId, options } = field;\n      if (!options) continue;\n\n      let newOptions = options;\n\n      Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {\n        newOptions = newOptions.replaceAll(key, value);\n      });\n\n      Object.entries(sourceToTargetViewMap).forEach(([key, value]) => {\n        newOptions = newOptions.replaceAll(key, value);\n      });\n\n      await this.prismaService.txClient().field.update({\n        where: {\n          id: fieldId,\n        },\n        data: {\n          options: newOptions,\n        },\n      });\n    }\n  }\n\n  private extractFieldIds(expression: string): string[] {\n    const matches = expression.match(/\\{fld[a-zA-Z0-9]+\\}/g);\n\n    if (!matches) {\n      return [];\n    }\n    return matches.map((match) => match.slice(1, -1));\n  }\n\n  async duplicateAttachments(\n    sourceTableId: string,\n    targetTableId: string,\n    fieldIdMap: Record<string, string>\n  ) {\n    const prisma = this.prismaService.txClient();\n    const attachmentFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: sourceTableId,\n        type: FieldType.Attachment,\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n      },\n    });\n    const qb = this.knex.queryBuilder();\n\n    const attachmentFieldIds = attachmentFieldRaws.map(({ id }) => id);\n\n    const userId = this.cls.get('user.id');\n\n    for (const attachmentFieldId of attachmentFieldIds) {\n      const sql = this.dbProvider\n        .duplicateAttachmentTableQuery(qb)\n        .duplicateAttachmentTable(\n          sourceTableId,\n          targetTableId,\n          attachmentFieldId,\n          fieldIdMap[attachmentFieldId],\n          userId\n        )\n        .toQuery();\n\n      await prisma.$executeRawUnsafe(sql);\n    }\n  }\n\n  // duplicate link junction table\n  async duplicateLinkJunction(\n    tableIdMap: Record<string, string>,\n    fieldIdMap: Record<string, string>,\n    allowCrossBase: boolean = true,\n    disconnectedLinkFieldIds?: string[]\n  ) {\n    const prisma = this.prismaService.txClient();\n    const sourceLinkFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: { in: Object.keys(tableIdMap) },\n        type: FieldType.Link,\n        deletedTime: null,\n      },\n    });\n\n    const targetLinkFieldRaws = await prisma.field.findMany({\n      where: {\n        tableId: { in: Object.values(tableIdMap) },\n        type: FieldType.Link,\n        deletedTime: null,\n      },\n    });\n\n    const sourceFields = sourceLinkFieldRaws\n      .filter(({ isLookup }) => !isLookup)\n      .map((f) => createFieldInstanceByRaw(f))\n      .filter((field) => {\n        if (allowCrossBase) {\n          return true;\n        }\n        // if not allow cross base, filter out it.\n        return !(field.options as ILinkFieldOptions).baseId;\n      })\n      .filter((field) => {\n        if (!disconnectedLinkFieldIds?.length) {\n          return true;\n        }\n        return !disconnectedLinkFieldIds.includes(field.id);\n      });\n    const targetFields = targetLinkFieldRaws.map((f) => createFieldInstanceByRaw(f));\n\n    const junctionDbTableNameMap = {} as Record<\n      string,\n      {\n        sourceSelfKeyName: string;\n        sourceForeignKeyName: string;\n        targetSelfKeyName: string;\n        targetForeignKeyName: string;\n        targetFkHostTableName: string;\n      }\n    >;\n\n    for (const sourceField of sourceFields) {\n      const { options: sourceOptions } = sourceField;\n      const {\n        fkHostTableName: sourceFkHostTableName,\n        selfKeyName: sourceSelfKeyName,\n        foreignKeyName: sourceForeignKeyName,\n      } = sourceOptions as ILinkFieldOptions;\n      const targetField = targetFields.find((f) => f.id === fieldIdMap[sourceField.id])!;\n      const { options: targetOptions } = targetField;\n      const {\n        fkHostTableName: targetFkHostTableName,\n        selfKeyName: targetSelfKeyName,\n        foreignKeyName: targetForeignKeyName,\n      } = targetOptions as ILinkFieldOptions;\n      if (sourceFkHostTableName.includes('junction_')) {\n        junctionDbTableNameMap[sourceFkHostTableName] = {\n          sourceSelfKeyName,\n          sourceForeignKeyName,\n          targetSelfKeyName,\n          targetForeignKeyName,\n          targetFkHostTableName,\n        };\n      }\n    }\n    for (const [sourceJunctionDbTableName, targetJunctionInfo] of Object.entries(\n      junctionDbTableNameMap\n    )) {\n      const {\n        sourceSelfKeyName,\n        sourceForeignKeyName,\n        targetSelfKeyName,\n        targetForeignKeyName,\n        targetFkHostTableName,\n      } = targetJunctionInfo;\n      const sql = this.knex\n        .raw(\n          `INSERT INTO ?? (\"${targetSelfKeyName}\",\"${targetForeignKeyName}\") SELECT \"${sourceSelfKeyName}\", \"${sourceForeignKeyName}\" FROM ??`,\n          [targetFkHostTableName, sourceJunctionDbTableName]\n        )\n        .toQuery();\n\n      await prisma.$executeRawUnsafe(sql);\n    }\n  }\n\n  private async emitTableDuplicateAuditLog(\n    targetTableId: string,\n    recordCount: number,\n    ro: IDuplicateTableRo\n  ) {\n    const userId = this.cls.get('user.id');\n    const origin = this.cls.get('origin');\n\n    await this.cls.run(async () => {\n      this.cls.set('origin', origin!);\n      this.cls.set('user.id', userId);\n      await this.eventEmitterService.emitAsync(Events.TABLE_RECORD_CREATE_RELATIVE, {\n        action: CreateRecordAction.TableDuplicate,\n        resourceId: targetTableId,\n        recordCount,\n        params: ro,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table-index.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { BadRequestException, Injectable, Logger } from '@nestjs/common';\nimport { CellValueType, FieldType, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { TableIndex } from '@teable/openapi';\nimport type { IGetAbnormalVo, ITableIndexType, IToggleIndexRo } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IClsStore } from '../../types/cls';\nimport type { IFieldInstance } from '../field/model/factory';\nimport { createFieldInstanceByRaw } from '../field/model/factory';\n\nconst unSupportTableIndex = 'Unsupport table index type';\n\n@Injectable()\nexport class TableIndexService {\n  private logger = new Logger(TableIndexService.name);\n\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async getSearchIndexFields(tableId: string): Promise<IFieldInstance[]> {\n    const fieldsRaw = await this.prismaService.field.findMany({\n      where: {\n        tableId,\n        deletedTime: null,\n      },\n    });\n    return fieldsRaw\n      .filter(\n        ({ cellValueType, type }) =>\n          cellValueType !== CellValueType.DateTime && type !== FieldType.Button\n      )\n      .map((field) => createFieldInstanceByRaw(field))\n      .map((field) => ({\n        ...field,\n        isStructuredCellValue: field.isStructuredCellValue,\n      })) as IFieldInstance[];\n  }\n\n  async getActivatedTableIndexes(\n    tableId: string,\n    type: TableIndex = TableIndex.search\n  ): Promise<TableIndex[]> {\n    const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({\n      where: {\n        id: tableId,\n      },\n      select: {\n        dbTableName: true,\n      },\n    });\n\n    if (type === TableIndex.search) {\n      const searchIndexSql = this.dbProvider.searchIndex().getExistTableIndexSql(dbTableName);\n      const [{ exists: searchIndexExist }] = await this.prismaService.$queryRawUnsafe<\n        {\n          exists: boolean;\n        }[]\n      >(searchIndexSql);\n\n      const result: ITableIndexType[] = [];\n\n      if (searchIndexExist) {\n        result.push(TableIndex.search);\n      }\n\n      return result;\n    } else {\n      throw new CustomHttpException(\n        'Table index type not supported',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notSupportTableIndex',\n          },\n        }\n      );\n    }\n  }\n\n  async toggleIndex(tableId: string, enableRo: IToggleIndexRo) {\n    const { type } = enableRo;\n    if (type !== TableIndex.search) {\n      throw new CustomHttpException(\n        'Table index type not supported',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notSupportTableIndex',\n          },\n        }\n      );\n    }\n\n    const index = await this.getActivatedTableIndexes(tableId);\n\n    const fields = await this.getSearchIndexFields(tableId);\n\n    const { dbTableName } = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: {\n        id: tableId,\n      },\n      select: {\n        dbTableName: true,\n      },\n    });\n\n    await this.toggleSearchIndex(dbTableName, fields, !index.includes(type));\n  }\n\n  async toggleSearchIndex(dbTableName: string, fields: IFieldInstance[], toEnable: boolean) {\n    if (toEnable) {\n      const sqls = this.dbProvider.searchIndex().getCreateIndexSql(dbTableName, fields);\n      return await this.prismaService.$tx(\n        async (prisma) => {\n          for (let i = 0; i < sqls.length; i++) {\n            const sql = sqls[i];\n            try {\n              await prisma.$executeRawUnsafe(sql);\n            } catch (error) {\n              console.error('toggleSearchIndex:create:error', sql);\n              throw new CustomHttpException(\n                `Create table index error: ${error instanceof Error ? error.message : 'Unknown error'}`,\n                HttpErrorCode.VALIDATION_ERROR,\n                {\n                  localization: {\n                    i18nKey: 'httpErrors.table.createTableIndexError',\n                  },\n                }\n              );\n            }\n          }\n        },\n        { timeout: this.thresholdConfig.bigTransactionTimeout }\n      );\n    }\n\n    const sql = this.dbProvider.searchIndex().getDropIndexSql(dbTableName);\n    try {\n      return await this.prismaService.$executeRawUnsafe(sql);\n    } catch (error) {\n      console.error('toggleSearchIndex:drop:error', sql);\n      throw new CustomHttpException(\n        `Drop table index error: ${error instanceof Error ? error.message : 'Unknown error'}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.dropTableIndexError',\n          },\n        }\n      );\n    }\n  }\n\n  async deleteSearchFieldIndex(tableId: string, field: IFieldInstance) {\n    const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n    const { dbTableName } = tableRaw;\n    const index = await this.getActivatedTableIndexes(tableId);\n    if (index.includes(TableIndex.search)) {\n      const sql = this.dbProvider.searchIndex().getDeleteSingleIndexSql(dbTableName, field);\n      // Execute within current transaction if present to keep boundaries consistent\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n  }\n\n  async createSearchFieldSingleIndex(tableId: string, fieldInstance: IFieldInstance) {\n    if (\n      fieldInstance.cellValueType === CellValueType.DateTime ||\n      fieldInstance.type === FieldType.Button\n    ) {\n      return;\n    }\n    const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n    const { dbTableName } = tableRaw;\n    const index = await this.getActivatedTableIndexes(tableId);\n    const sql = this.dbProvider.searchIndex().createSingleIndexSql(dbTableName, fieldInstance);\n    if (index.includes(TableIndex.search) && sql) {\n      await this.prismaService.txClient().$executeRawUnsafe(sql);\n    }\n  }\n\n  async updateSearchFieldIndexName(\n    tableId: string,\n    oldField: Pick<IFieldInstance, 'id' | 'dbFieldName'>,\n    newField: Pick<IFieldInstance, 'id' | 'dbFieldName'>\n  ) {\n    const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n    const { dbTableName } = tableRaw;\n    const index = await this.getActivatedTableIndexes(tableId);\n    if (index.includes(TableIndex.search)) {\n      const sql = this.dbProvider\n        .searchIndex()\n        .getUpdateSingleIndexNameSql(dbTableName, oldField, newField);\n      await this.prismaService.$executeRawUnsafe(sql);\n    }\n  }\n\n  async getIndexInfo(tableId: string) {\n    const tableRaw = await this.prismaService.txClient().tableMeta.findFirstOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { dbTableName: true },\n    });\n    const { dbTableName } = tableRaw;\n\n    const sql = this.dbProvider.searchIndex().getIndexInfoSql(dbTableName);\n    return this.prismaService.$queryRawUnsafe<unknown[]>(sql);\n  }\n\n  async getAbnormalTableIndex(tableId: string, type: TableIndex) {\n    const index = await this.getActivatedTableIndexes(tableId);\n    if (!index.includes(type)) {\n      return [] as IGetAbnormalVo;\n    }\n\n    const tableRaw = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: {\n        id: tableId,\n      },\n    });\n\n    const { dbTableName } = tableRaw;\n\n    const fieldInstances = await this.getSearchIndexFields(tableId);\n\n    const indexInfo = await this.getIndexInfo(tableId);\n\n    return await this.dbProvider\n      .searchIndex()\n      .getAbnormalIndex(dbTableName, fieldInstances, indexInfo);\n  }\n\n  async repairIndex(tableId: string, type: TableIndex) {\n    if (type !== TableIndex.search) {\n      throw new CustomHttpException(\n        'Table index type not supported',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notSupportTableIndex',\n          },\n        }\n      );\n    }\n\n    const tableRaw = await this.prismaService.tableMeta.findFirstOrThrow({\n      where: {\n        id: tableId,\n        deletedTime: null,\n      },\n      select: {\n        dbTableName: true,\n      },\n    });\n\n    const { dbTableName } = tableRaw;\n    const dropSql = this.dbProvider.searchIndex().getDropIndexSql(dbTableName);\n    const fieldInstances = await this.getSearchIndexFields(tableId);\n    const createSqls = this.dbProvider.searchIndex().getCreateIndexSql(dbTableName, fieldInstances);\n    await this.prismaService.$tx(\n      async (prisma) => {\n        await prisma.$executeRawUnsafe(dropSql);\n        for (let i = 0; i < createSqls.length; i++) {\n          await prisma.$executeRawUnsafe(createSqls[i]);\n        }\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table-permission.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { Action, ExcludeAction, TableAction } from '@teable/core';\nimport {\n  ActionPrefix,\n  actionPrefixMap,\n  getPermissionMap,\n  HttpErrorCode,\n  TemplateRolePermission,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { pick } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { getMaxLevelRole } from '../../utils/get-max-level-role';\n\n@Injectable()\nexport class TablePermissionService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  async getProjectionTableIds(_baseId: string): Promise<string[] | undefined> {\n    const shareViewId = this.cls.get('shareViewId');\n    if (shareViewId) {\n      return this.getViewQueryWithSharePermission();\n    }\n  }\n\n  protected async getViewQueryWithSharePermission() {\n    return [];\n  }\n\n  async getTablePermissionMapByBaseId(\n    baseId: string,\n    tableIds?: string[]\n  ): Promise<Record<string, Record<ExcludeAction<TableAction, 'table|create'>, boolean>>> {\n    if (this.cls.get('template')) {\n      return this.getTablePermissionMapByPermissions(baseId, TemplateRolePermission, tableIds);\n    }\n    // Handle base share access - use same read-only permissions as template\n    if (this.cls.get('baseShare')) {\n      return this.getTablePermissionMapByPermissions(baseId, TemplateRolePermission, tableIds);\n    }\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    const base = await this.prismaService\n      .txClient()\n      .base.findUniqueOrThrow({\n        where: { id: baseId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Base not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.base.notFound',\n          },\n        });\n      });\n    const collaborators = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: { in: [userId, ...(departmentIds || [])] },\n        resourceId: { in: [baseId, base.spaceId] },\n      },\n    });\n    if (collaborators.length === 0) {\n      throw new CustomHttpException('Collaborator not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.collaborator.notFound',\n        },\n      });\n    }\n    const roleName = getMaxLevelRole(collaborators);\n    return this.getTablePermissionMapByPermissions(baseId, getPermissionMap(roleName), tableIds);\n  }\n\n  private async getTablePermissionMapByPermissions(\n    baseId: string,\n    permissions: Record<Action, boolean>,\n    tableIds?: string[]\n  ) {\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { baseId, deletedTime: null, id: { in: tableIds } },\n    });\n    return tables.reduce(\n      (acc, table) => {\n        acc[table.id] = pick(\n          permissions,\n          actionPrefixMap[ActionPrefix.Table].filter(\n            (action) => action !== 'table|create'\n          ) as ExcludeAction<TableAction, 'table|create'>[]\n        );\n        return acc;\n      },\n      {} as Record<string, Record<ExcludeAction<TableAction, 'table|create'>, boolean>>\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { FieldModule } from '../field/field.module';\nimport { RecordModule } from '../record/record.module';\nimport { ViewModule } from '../view/view.module';\nimport { TablePermissionService } from './table-permission.service';\nimport { TableService } from './table.service';\n@Module({\n  imports: [CalculationModule, FieldModule, RecordModule, ViewModule],\n  providers: [TableService, DbProvider, TablePermissionService],\n  exports: [FieldModule, RecordModule, ViewModule, TableService, TablePermissionService],\n})\nexport class TableModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { TableModule } from './table.module';\nimport { TableService } from './table.service';\n\ndescribe('TableService', () => {\n  let service: TableService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, TableModule],\n    }).compile();\n\n    service = module.get<TableService>(TableService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should convert table name to valid db table name', () => {\n    const dbTableName = service.generateValidName('!@#$1_a ha3ha 中文');\n    expect(dbTableName).toBe('t1_a_ha3ha_Zhong_Wen');\n  });\n\n  it('should limit table name to 40', () => {\n    const dbTableName = service.generateValidName('t'.repeat(50));\n    expect(dbTableName).toBe('t'.repeat(40));\n  });\n\n  it('should convert chinese to pin yin', () => {\n    const dbTableName = service.generateValidName('中文');\n    expect(dbTableName).toBe('Zhong_Wen');\n  });\n\n  it('should convert empty table name unnamed', () => {\n    const dbTableName = service.generateValidName('');\n    expect(dbTableName).toBe('unnamed');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table/table.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { IOtOperation, ISnapshotBase } from '@teable/core';\nimport {\n  DriverClient,\n  generateTableId,\n  getRandomString,\n  getUniqName,\n  HttpErrorCode,\n  IdPrefix,\n  nullsToUndefined,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ICreateTableRo, ITableVo } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IReadonlyAdapterService } from '../../share-db/interface';\nimport { RawOpType } from '../../share-db/interface';\nimport type { IClsStore } from '../../types/cls';\nimport { convertNameToValidCharacter } from '../../utils/name-conversion';\nimport { BatchService } from '../calculation/batch.service';\n\n@Injectable()\nexport class TableService implements IReadonlyAdapterService {\n  private logger = new Logger(TableService.name);\n\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    private readonly batchService: BatchService,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  generateValidName(name: string) {\n    return convertNameToValidCharacter(name, 40);\n  }\n\n  private async lockBaseRow(baseId: string) {\n    if (this.dbProvider.driver !== DriverClient.Pg) return;\n\n    await this.prismaService.txClient()\n      .$executeRaw`select id from base where id = ${baseId} for update`;\n  }\n\n  private async createDBTable(baseId: string, tableRo: ICreateTableRo, createTable = true) {\n    const userId = this.cls.get('user.id');\n    await this.lockBaseRow(baseId);\n\n    const tableRaws = await this.prismaService.txClient().tableMeta.findMany({\n      where: { baseId, deletedTime: null },\n      select: { name: true, order: true },\n    });\n    const tableId = generateTableId();\n    const names = tableRaws.map((table) => table.name);\n    const uniqName = getUniqName(tableRo.name ?? 'New table', names);\n    const order =\n      tableRaws.reduce((acc, cur) => {\n        return acc > cur.order ? acc : cur.order;\n      }, 0) + 1;\n\n    const validTableName = this.generateValidName(uniqName);\n    let dbTableName = this.dbProvider.generateDbTableName(\n      baseId,\n      tableRo.dbTableName || validTableName\n    );\n\n    if (tableRo.dbTableName) {\n      const existTable = await this.prismaService.txClient().tableMeta.findFirst({\n        where: { dbTableName, baseId },\n        select: { id: true },\n      });\n\n      if (existTable) {\n        throw new CustomHttpException(\n          `dbTableName ${tableRo.dbTableName} already exists`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.table.dbTableNameAlreadyExists',\n            },\n          }\n        );\n      }\n    } else {\n      const existTable = await this.prismaService.txClient().tableMeta.findFirst({\n        where: { dbTableName },\n        select: { id: true },\n      });\n\n      if (existTable) {\n        // add uniqId ensure no conflict\n        dbTableName += getRandomString(10);\n      }\n    }\n\n    const data: Prisma.TableMetaCreateInput = {\n      id: tableId,\n      base: {\n        connect: {\n          id: baseId,\n        },\n      },\n      name: uniqName,\n      description: tableRo.description,\n      icon: tableRo.icon,\n      dbTableName,\n      order,\n      createdBy: userId,\n      version: 1,\n    };\n\n    const tableMeta = await this.prismaService.txClient().tableMeta.create({\n      data,\n    });\n\n    if (!createTable) {\n      return tableMeta;\n    }\n\n    const createTableSchema = this.knex.schema.createTable(dbTableName, (table) => {\n      table.string('__id').unique(`${baseId}_${tableMeta.id}__id_unique`).notNullable();\n      table.increments('__auto_number').primary();\n      table.dateTime('__created_time').defaultTo(this.knex.fn.now()).notNullable();\n      table.dateTime('__last_modified_time');\n      table.string('__created_by').notNullable();\n      table.string('__last_modified_by');\n      table.integer('__version').notNullable();\n    });\n\n    for (const sql of createTableSchema.toSQL()) {\n      await this.prismaService.txClient().$executeRawUnsafe(sql.sql);\n    }\n    return tableMeta;\n  }\n\n  async getTableDefaultViewId(tableIds: string[]) {\n    if (!tableIds.length) return [];\n\n    const nativeSql = this.knex\n      .select({\n        tableId: 'id',\n        viewId: this.knex\n          .select('id')\n          .from('view')\n          .whereRaw('view.table_id = table_meta.id')\n          .whereRaw('view.deleted_time is null')\n          .orderBy('order')\n          .limit(1),\n      })\n      .from('table_meta')\n      .whereIn('id', tableIds)\n      .toSQL()\n      .toNative();\n\n    const results = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ tableId: string; viewId: string }[]>(nativeSql.sql, ...nativeSql.bindings);\n\n    return tableIds.map((tableId) => {\n      const item = results.find((result) => result.tableId === tableId);\n      return item?.viewId;\n    });\n  }\n\n  async getTableMeta(baseId: string, tableId: string): Promise<ITableVo> {\n    const tableMeta = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { id: tableId, baseId, deletedTime: null },\n    });\n\n    if (!tableMeta) {\n      throw new CustomHttpException(\n        `Table not found with id: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        }\n      );\n    }\n\n    const tableDefaultViewIds = await this.getTableDefaultViewId([tableId]);\n    if (!tableDefaultViewIds[0]) {\n      throw new CustomHttpException('defaultViewId not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.view.defaultViewNotFound',\n        },\n      });\n    }\n\n    return {\n      ...tableMeta,\n      description: tableMeta.description ?? undefined,\n      icon: tableMeta.icon ?? undefined,\n      lastModifiedTime:\n        tableMeta.lastModifiedTime?.toISOString() || tableMeta.createdTime.toISOString(),\n      defaultViewId: tableDefaultViewIds[0],\n    };\n  }\n\n  async getDefaultViewId(tableId: string) {\n    const viewRaw = await this.prismaService.view.findFirst({\n      where: { tableId, deletedTime: null },\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n    if (!viewRaw) {\n      throw new CustomHttpException(\n        `View not found with tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n    return viewRaw;\n  }\n\n  async createTable(\n    baseId: string,\n    snapshot: ICreateTableRo,\n    createTable: boolean = true\n  ): Promise<ITableVo> {\n    const tableVo = await this.createDBTable(baseId, snapshot, createTable);\n    await this.batchService.saveRawOps(baseId, RawOpType.Create, IdPrefix.Table, [\n      {\n        docId: tableVo.id,\n        version: 0,\n        data: tableVo,\n      },\n    ]);\n    return nullsToUndefined({\n      ...tableVo,\n      lastModifiedTime: tableVo.lastModifiedTime?.toISOString(),\n    });\n  }\n\n  async deleteTable(baseId: string, tableId: string, deletedTime: Date) {\n    const result = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { id: tableId, baseId, deletedTime: null },\n    });\n\n    if (!result) {\n      throw new CustomHttpException(\n        `Table not found with id: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        }\n      );\n    }\n\n    const { version } = result;\n    const userId = this.cls.get('user.id');\n\n    await this.prismaService.txClient().tableMeta.update({\n      where: { id: tableId, baseId },\n      data: { version: version + 1, deletedTime, lastModifiedBy: userId },\n    });\n\n    await this.batchService.saveRawOps(baseId, RawOpType.Del, IdPrefix.Table, [\n      { docId: tableId, version },\n    ]);\n  }\n\n  async restoreTable(baseId: string, tableId: string) {\n    const result = await this.prismaService.txClient().tableMeta.findFirst({\n      where: { id: tableId, baseId, deletedTime: { not: null } },\n    });\n\n    if (!result) {\n      throw new CustomHttpException(`Table ${tableId} not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.table.notFound',\n        },\n      });\n    }\n\n    const { version } = result;\n    const userId = this.cls.get('user.id');\n\n    await this.prismaService.txClient().tableMeta.update({\n      where: { id: tableId, baseId },\n      data: { version: version + 1, deletedTime: null, lastModifiedBy: userId },\n    });\n\n    await this.batchService.saveRawOps(baseId, RawOpType.Create, IdPrefix.Table, [\n      { docId: tableId, version },\n    ]);\n  }\n\n  async updateTable(\n    baseId: string,\n    tableId: string,\n    input: Omit<\n      Prisma.TableMetaUpdateInput,\n      | 'id'\n      | 'createdBy'\n      | 'lastModifiedBy'\n      | 'createdTime'\n      | 'lastModifiedTime'\n      | 'version'\n      | 'base'\n      | 'fields'\n      | 'views'\n    >\n  ) {\n    const select = Object.keys(input).reduce<{ [key: string]: boolean }>((acc, key) => {\n      acc[key] = true;\n      return acc;\n    }, {});\n\n    const tableRaw = await this.prismaService\n      .txClient()\n      .tableMeta.findFirstOrThrow({\n        where: { id: tableId, baseId, deletedTime: null },\n        select: {\n          ...select,\n          version: true,\n          lastModifiedBy: true,\n          lastModifiedTime: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `Table not found with id: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.table.notFound',\n            },\n          }\n        );\n      });\n\n    const updateInput: Prisma.TableMetaUpdateInput = {\n      ...input,\n      version: tableRaw.version + 1,\n      lastModifiedBy: this.cls.get('user.id'),\n      lastModifiedTime: new Date().toISOString(),\n    };\n\n    const ops = Object.entries(updateInput)\n      .filter(([key, value]) => Boolean(value !== (tableRaw as Record<string, unknown>)[key]))\n      .map<IOtOperation>(([key, value]) => {\n        return {\n          p: [key],\n          oi: value,\n          od: (tableRaw as Record<string, unknown>)[key],\n        };\n      });\n\n    const tableRawAfter = await this.prismaService.txClient().tableMeta.update({\n      where: { id: tableId },\n      data: updateInput,\n    });\n\n    await this.batchService.saveRawOps(baseId, RawOpType.Edit, IdPrefix.Table, [\n      {\n        docId: tableId,\n        version: tableRaw.version,\n        data: ops,\n      },\n    ]);\n\n    return tableRawAfter;\n  }\n\n  async create(baseId: string, snapshot: ITableVo) {\n    await this.createDBTable(baseId, snapshot);\n  }\n\n  async getSnapshotBulk(\n    baseId: string,\n    ids: string[],\n    ops: {\n      ignoreDefaultViewId?: boolean;\n    } = {}\n  ): Promise<ISnapshotBase<ITableVo>[]> {\n    const { ignoreDefaultViewId } = ops;\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: { baseId, id: { in: ids }, deletedTime: null },\n      orderBy: { order: 'asc' },\n    });\n\n    const tableDefaultViewIds = ignoreDefaultViewId ? [] : await this.getTableDefaultViewId(ids);\n    return tables\n      .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))\n      .map((table, i) => {\n        const res = {\n          id: table.id,\n          v: table.version,\n          type: 'json0',\n          data: {\n            ...table,\n            description: table.description ?? undefined,\n            icon: table.icon ?? undefined,\n            lastModifiedTime:\n              table.lastModifiedTime?.toISOString() || table.createdTime.toISOString(),\n          } as ITableVo,\n        };\n        if (!ignoreDefaultViewId) {\n          res.data.defaultViewId = tableDefaultViewIds[i];\n        }\n        return res;\n      });\n  }\n\n  async getDocIdsByQuery(baseId: string, query: { projectionTableIds?: string[] } = {}) {\n    const { projectionTableIds } = query;\n    const tables = await this.prismaService.txClient().tableMeta.findMany({\n      where: {\n        deletedTime: null,\n        baseId,\n        ...(projectionTableIds\n          ? {\n              id: { in: projectionTableIds },\n            }\n          : {}),\n      },\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n    return { ids: tables.map((table) => table.id) };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table-domain/index.ts",
    "content": "export * from './table-domain-query.service';\nexport * from './table-domain-query.module';\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table-domain/table-domain-query.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { PrismaModule } from '@teable/db-main-prisma';\nimport { TableDomainQueryService } from './table-domain-query.service';\n\n/**\n * Module for table domain query functionality\n * This module provides services for fetching and constructing table domain objects\n * specifically for record query operations\n */\n@Module({\n  imports: [PrismaModule],\n  providers: [TableDomainQueryService],\n  exports: [TableDomainQueryService],\n})\nexport class TableDomainQueryModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/table-domain/table-domain-query.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { Injectable } from '@nestjs/common';\nimport { HttpErrorCode, TableDomain, Tables } from '@teable/core';\nimport type { FieldCore } from '@teable/core';\nimport type { Field, TableMeta } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IClsStore } from '../../types/cls';\nimport { Timing } from '../../utils/timing';\nimport { DataLoaderService } from '../data-loader/data-loader.service';\nimport { rawField2FieldObj, createFieldInstanceByVo } from '../field/model/factory';\n\n/**\n * Service for querying and constructing table domain objects\n * This service is responsible for fetching table metadata and fields,\n * then constructing complete TableDomain objects for record queries\n */\n@Injectable()\nexport class TableDomainQueryService {\n  constructor(\n    private readonly dataLoaderService: DataLoaderService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  /**\n   * Get a complete table domain object by table ID\n   * This method fetches both table metadata and all associated fields,\n   * then constructs a TableDomain object with a Fields collection\n   *\n   * @param tableId - The ID of the table to fetch\n   * @returns Promise<TableDomain> - Complete table domain object with fields\n   * @throws NotFoundException - If table is not found or has been deleted\n   */\n  async getTableDomainById(tableId: string): Promise<TableDomain> {\n    this.enableTableDomainDataLoader();\n    const tableMeta = await this.getTableMetaById(tableId);\n    const fieldRaws = await this.getTableFields(tableMeta.id);\n    return this.buildTableDomain(tableMeta, fieldRaws);\n  }\n\n  async getTableDomainsByIds(tableIds: string[]): Promise<Map<string, TableDomain>> {\n    const uniqueIds = Array.from(new Set(tableIds.filter(Boolean)));\n    if (!uniqueIds.length) {\n      return new Map();\n    }\n\n    const tableMetas = await this.prismaService.txClient().tableMeta.findMany({\n      where: { id: { in: uniqueIds }, deletedTime: null },\n      include: {\n        fields: {\n          where: { deletedTime: null },\n        },\n      },\n    });\n\n    const domainMap = new Map<string, TableDomain>();\n    for (const tableMeta of tableMetas) {\n      const sortedFields = this.sortFieldRaws(tableMeta.fields as Field[]);\n      const domain = this.buildTableDomain(tableMeta, sortedFields);\n      domainMap.set(tableMeta.id, domain);\n    }\n\n    return domainMap;\n  }\n\n  /**\n   * Get table metadata by ID\n   * @private\n   */\n  private async getTableMetaById(tableId: string) {\n    const [tableMeta] = (await this.dataLoaderService.table.loadByIds([tableId])) as TableMeta[];\n\n    if (!tableMeta) {\n      throw new CustomHttpException(\n        `Table not found with id: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        }\n      );\n    }\n\n    return tableMeta;\n  }\n\n  private async getTableFields(tableId: string) {\n    const fields = await this.dataLoaderService.field.load(tableId);\n    return this.sortFieldRaws(fields as Field[]);\n  }\n\n  private sortFieldRaws(fieldRaws: Field[]): Field[] {\n    return [...fieldRaws].sort((a, b) => {\n      const primaryDiff = this.comparePrimaryRank(a.isPrimary, b.isPrimary);\n      if (primaryDiff !== 0) {\n        return primaryDiff;\n      }\n\n      const orderDiff = (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER);\n      if (orderDiff !== 0) {\n        return orderDiff;\n      }\n\n      return a.createdTime.getTime() - b.createdTime.getTime();\n    });\n  }\n\n  private comparePrimaryRank(valueA?: boolean | null, valueB?: boolean | null) {\n    const rank = (value?: boolean | null) => {\n      if (value === true) {\n        return 0;\n      }\n      if (value === false) {\n        return 1;\n      }\n      return 2;\n    };\n\n    return rank(valueA) - rank(valueB);\n  }\n\n  private buildTableDomain(tableMeta: TableMeta, fieldRaws: Field[]): TableDomain {\n    const fieldInstances = fieldRaws.map((fieldRaw) => {\n      const fieldVo = rawField2FieldObj(fieldRaw);\n      return createFieldInstanceByVo(fieldVo) as FieldCore;\n    });\n\n    return new TableDomain({\n      id: tableMeta.id,\n      name: tableMeta.name,\n      dbTableName: tableMeta.dbTableName,\n      dbViewName: tableMeta.dbViewName ?? undefined,\n      icon: tableMeta.icon || undefined,\n      description: tableMeta.description || undefined,\n      lastModifiedTime:\n        tableMeta.lastModifiedTime?.toISOString() || tableMeta.createdTime.toISOString(),\n      baseId: tableMeta.baseId,\n      fields: fieldInstances,\n    });\n  }\n\n  /**\n   * Get all related table domains recursively\n   * This method will fetch the current table domain and all tables it references\n   * through link fields and formula fields that reference link fields\n   *\n   * @param tableId - The root table ID to start from\n   * @param fieldIds - Optional projection of field IDs to limit foreign table traversal on the entry table\n   * @returns Promise<Tables> - Tables domain object containing all related table domains\n   */\n  @Timing()\n  async getAllRelatedTableDomains(tableId: string, fieldIds?: string[]) {\n    this.enableTableDomainDataLoader();\n    return this.#getAllRelatedTableDomains(tableId, fieldIds);\n  }\n\n  async #getAllRelatedTableDomains(\n    tableId: string,\n    projectionFieldIds?: string[]\n  ): Promise<Tables> {\n    const tables = new Tables(tableId);\n    const queue: Array<{ tableId: string; projection?: string[] }> = [\n      { tableId, projection: projectionFieldIds },\n    ];\n\n    while (queue.length) {\n      const batch = queue.splice(0);\n      const idsToFetch = Array.from(\n        new Set(batch.map((item) => item.tableId).filter((id) => !tables.isVisited(id)))\n      );\n\n      if (idsToFetch.length) {\n        const domainMap = await this.getTableDomainsByIds(idsToFetch);\n\n        if (!tables.hasTable(tableId) && !domainMap.has(tableId)) {\n          throw new CustomHttpException(\n            `Table not found with id: ${tableId}`,\n            HttpErrorCode.NOT_FOUND,\n            {\n              localization: {\n                i18nKey: 'httpErrors.table.notFound',\n              },\n            }\n          );\n        }\n\n        for (const id of idsToFetch) {\n          const domain = domainMap.get(id);\n          if (!domain) {\n            // Related table was deleted or not found; skip gracefully\n            continue;\n          }\n\n          tables.addTable(id, domain);\n          tables.markVisited(id);\n        }\n      }\n\n      for (const { tableId: currentId, projection } of batch) {\n        const domain = tables.getTable(currentId);\n        if (!domain) {\n          continue;\n        }\n\n        const fieldProjection =\n          currentId === tableId && projection && projection.length ? projection : undefined;\n\n        const foreignTableIds = domain.getAllForeignTableIds(fieldProjection);\n        for (const foreignTableId of foreignTableIds) {\n          if (!tables.isVisited(foreignTableId)) {\n            queue.push({ tableId: foreignTableId });\n          }\n        }\n      }\n    }\n\n    return tables;\n  }\n\n  private enableTableDomainDataLoader() {\n    if (!this.cls.isActive()) {\n      return;\n    }\n    if (this.cls.get('dataLoaderCache.disabled')) {\n      return;\n    }\n    const cacheKeys = this.cls.get('dataLoaderCache.cacheKeys') ?? [];\n    const requiredKeys: ('table' | 'field')[] = ['table', 'field'];\n    const missingKeys = requiredKeys.filter((key) => !cacheKeys.includes(key));\n    if (missingKeys.length) {\n      this.cls.set('dataLoaderCache.cacheKeys', [...cacheKeys, ...missingKeys]);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/template/template-open-api.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { TemplateOpenApiController } from './template-open-api.controller';\n\ndescribe('CommentOpenApiController', () => {\n  let controller: TemplateOpenApiController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [TemplateOpenApiController],\n    }).compile();\n\n    controller = module.get<TemplateOpenApiController>(TemplateOpenApiController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/template/template-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Controller, Get, Post, Body, Param, Patch, Delete, Query, Put } from '@nestjs/common';\nimport {\n  createTemplateRoSchema,\n  ICreateTemplateCategoryRo,\n  ICreateTemplateRo,\n  ITemplateListQueryRo,\n  ITemplateQueryRoSchema,\n  IUpdateTemplateCategoryRo,\n  IUpdateTemplateRo,\n  IUpdateOrderRo,\n  templateListQueryRoSchema,\n  templateQueryRoSchema,\n  updateTemplateCategoryRoSchema,\n  updateTemplateRoSchema,\n  updateOrderRoSchema,\n} from '@teable/openapi';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { Permissions } from '../auth/decorators/permissions.decorator';\nimport { Public } from '../auth/decorators/public.decorator';\nimport { TemplateOpenApiService } from './template-open-api.service';\nimport { TemplatePermalinkService } from './template-permalink.service';\n\n@Controller('api/template')\nexport class TemplateOpenApiController {\n  constructor(\n    private readonly templateOpenApiService: TemplateOpenApiService,\n    private readonly templatePermalinkService: TemplatePermalinkService\n  ) {}\n\n  @Get()\n  @Permissions('instance|update')\n  async getTemplateList(\n    @Query(new ZodValidationPipe(templateListQueryRoSchema)) query?: ITemplateListQueryRo\n  ) {\n    return this.templateOpenApiService.getAllTemplateList(query);\n  }\n\n  @Public()\n  @Get('/published')\n  async getPublishedTemplateList(\n    @Query(new ZodValidationPipe(templateQueryRoSchema)) templateQuery: ITemplateQueryRoSchema\n  ) {\n    return this.templateOpenApiService.getPublishedTemplateList(templateQuery);\n  }\n\n  @Post('/create')\n  @Permissions('instance|update')\n  async createTemplate(\n    @Body(new ZodValidationPipe(createTemplateRoSchema)) createTemplateRo: ICreateTemplateRo\n  ) {\n    return this.templateOpenApiService.createTemplate(createTemplateRo);\n  }\n\n  @Delete('/:templateId')\n  @Permissions('instance|update')\n  async deleteTemplate(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.deleteTemplate(templateId);\n  }\n\n  @Patch('/:templateId')\n  @Permissions('instance|update')\n  async updateTemplate(\n    @Param('templateId') templateId: string,\n    @Body(new ZodValidationPipe(updateTemplateRoSchema)) updateTemplateRo: IUpdateTemplateRo\n  ) {\n    return this.templateOpenApiService.updateTemplate(templateId, updateTemplateRo);\n  }\n\n  @Patch('/:templateId/pin-top')\n  @Permissions('instance|update')\n  async updateTemplateOrder(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.pinTopTemplate(templateId);\n  }\n\n  @Put('/:templateId/order')\n  @Permissions('instance|update')\n  async updateOrder(\n    @Param('templateId') templateId: string,\n    @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo\n  ) {\n    return await this.templateOpenApiService.updateOrder(templateId, updateOrderRo);\n  }\n\n  @Post('/:templateId/snapshot')\n  @Permissions('instance|update')\n  async createTemplateSnapshot(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.createTemplateSnapshot(templateId);\n  }\n\n  @Post('/category/create')\n  @Permissions('instance|update')\n  async createTemplateCategory(@Body() createTemplateCategoryRo: ICreateTemplateCategoryRo) {\n    return this.templateOpenApiService.createTemplateCategory(createTemplateCategoryRo);\n  }\n\n  @Get('/category/list')\n  async getTemplateCategoryList() {\n    return this.templateOpenApiService.getTemplateCategoryList();\n  }\n\n  @Delete('/category/:templateCategoryId')\n  @Permissions('instance|update')\n  async deleteTemplateCategory(@Param('templateCategoryId') templateCategoryId: string) {\n    return this.templateOpenApiService.deleteTemplateCategory(templateCategoryId);\n  }\n\n  @Patch('/category/:templateCategoryId')\n  @Permissions('instance|update')\n  async updateTemplateCategory(\n    @Param('templateCategoryId') templateCategoryId: string,\n    @Body(new ZodValidationPipe(updateTemplateCategoryRoSchema))\n    updateTemplateCategoryRo: IUpdateTemplateCategoryRo\n  ) {\n    return this.templateOpenApiService.updateTemplateCategory(\n      templateCategoryId,\n      updateTemplateCategoryRo\n    );\n  }\n\n  @Put('/category/:templateCategoryId/order')\n  @Permissions('instance|update')\n  async updateTemplateCategoryOrder(\n    @Param('templateCategoryId') templateCategoryId: string,\n    @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo\n  ) {\n    return await this.templateOpenApiService.updateTemplateCategoryOrder(\n      templateCategoryId,\n      updateOrderRo\n    );\n  }\n\n  @Get('/by-base/:baseId')\n  async getTemplateByBaseId(@Param('baseId') baseId: string) {\n    return this.templateOpenApiService.getTemplateByBaseId(baseId);\n  }\n\n  @Delete('/unpublish/:templateId')\n  async unpublishTemplate(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.deleteTemplate(templateId);\n  }\n\n  @Public()\n  @Get('/:templateId')\n  async getTemplateById(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.getTemplateDetailById(templateId);\n  }\n\n  @Public()\n  @Patch('/:templateId/visit')\n  async incrementTemplateVisitCount(@Param('templateId') templateId: string) {\n    return this.templateOpenApiService.incrementTemplateVisitCount(templateId);\n  }\n\n  @Public()\n  @Get('/permalink/:identifier')\n  async getTemplatePermalink(@Param('identifier') identifier: string) {\n    return await this.templatePermalinkService.resolvePermalink(identifier);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/template/template-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsStorageModule } from '../attachments/attachments-storage.module';\nimport { BaseModule } from '../base/base.module';\nimport { TemplateOpenApiController } from './template-open-api.controller';\nimport { TemplateOpenApiService } from './template-open-api.service';\nimport { TemplatePermalinkService } from './template-permalink.service';\n\n@Module({\n  imports: [BaseModule, AttachmentsStorageModule],\n  controllers: [TemplateOpenApiController],\n  providers: [TemplateOpenApiService, TemplatePermalinkService],\n  exports: [TemplateOpenApiService, TemplatePermalinkService],\n})\nexport class TemplateOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/template/template-open-api.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { generateTemplateCategoryId, generateTemplateId, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\n\nimport {\n  type ICreateTemplateCategoryRo,\n  type ICreateTemplateRo,\n  type ITemplateListQueryRo,\n  type IUpdateTemplateCategoryRo,\n  type IUpdateTemplateRo,\n  type ITemplateQueryRoSchema,\n  type IUpdateOrderRo,\n  BaseDuplicateMode,\n  MAX_TEMPLATE_CATEGORY_COUNT,\n} from '@teable/openapi';\nimport { isNumber } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { PerformanceCacheService, PerformanceCache } from '../../performance-cache';\nimport {\n  generateTemplateCacheKeyByBaseId,\n  generateTemplateCategoryCacheKey,\n  generateTemplatePermalinkCacheKey,\n} from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { updateOrder } from '../../utils/update-order';\nimport { AttachmentsStorageService } from '../attachments/attachments-storage.service';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { BaseDuplicateService } from '../base/base-duplicate.service';\n\n@Injectable()\nexport class TemplateOpenApiService {\n  private logger = new Logger(TemplateOpenApiService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly baseDuplicateService: BaseDuplicateService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly attachmentsStorageService: AttachmentsStorageService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  async createTemplate(createTemplateRo: ICreateTemplateRo) {\n    const userId = this.cls.get('user.id');\n    const templateId = generateTemplateId();\n    const prisma = this.prismaService.txClient();\n    const order = await prisma.template.aggregate({\n      _max: {\n        order: true,\n      },\n    });\n    const finalOrder = isNumber(order._max.order) ? order._max.order + 1 : 1;\n\n    return await prisma.template.create({\n      data: {\n        id: templateId,\n        ...createTemplateRo,\n        createdBy: userId,\n        order: finalOrder,\n      },\n    });\n  }\n\n  async getAllTemplateList(query?: ITemplateListQueryRo) {\n    const { skip = 0, take = 300 } = query ?? {};\n    const prisma = this.prismaService.txClient();\n\n    this.validateTakeCount(take);\n\n    const res = await prisma.template.findMany({\n      orderBy: {\n        order: 'asc',\n      },\n      skip,\n      take,\n      select: {\n        id: true,\n        name: true,\n        cover: true,\n        snapshot: true,\n        createdBy: true,\n        categoryId: true,\n        isSystem: true,\n        featured: true,\n        isPublished: true,\n        description: true,\n        baseId: true,\n        usageCount: true,\n        markdownDescription: true,\n        publishInfo: true,\n        visitCount: true,\n      },\n    });\n\n    return this.transformTemplateListResult(res);\n  }\n\n  async getPublishedTemplateList(templateQuery?: ITemplateQueryRoSchema) {\n    const { skip = 0, take = 100 } = templateQuery ?? {};\n    const prisma = this.prismaService.txClient();\n    const featured = templateQuery?.featured;\n    const categoryId = templateQuery?.categoryId;\n    const search = templateQuery?.search;\n\n    this.validateTakeCount(take);\n\n    const res = await prisma.template.findMany({\n      where: {\n        isPublished: true,\n        ...(featured === true\n          ? { featured: true }\n          : featured === false\n            ? { OR: [{ featured: false }, { featured: null }] }\n            : {}),\n        categoryId: categoryId ? { has: categoryId } : undefined,\n        name: search ? { contains: search, mode: 'insensitive' } : undefined,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n      skip,\n      take,\n    });\n\n    return this.transformTemplateListResult(res);\n  }\n\n  private validateTakeCount(take: number) {\n    if (take && take > 1000) {\n      throw new CustomHttpException('Take count is too large', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.template.takeCountTooLarge',\n        },\n      });\n    }\n  }\n\n  private async transformTemplateListResult<\n    T extends { id: string; cover: string | null; snapshot: string | null; createdBy: string },\n  >(templates: T[]) {\n    const previewUrlMap: Record<string, string> = {};\n    const userIds = templates.map((item) => item.createdBy).filter((id) => !!id);\n    const userMap = await this.getSpecifiedUserInfoByUserId(userIds);\n\n    for (const item of templates) {\n      const cover = item.cover ? JSON.parse(item.cover) : undefined;\n      if (!cover) {\n        continue;\n      }\n\n      const { path, thumbnailPath } = cover;\n      // Use thumbnail path if the image is larger than thumbnail size\n      const finalThumbnailPath = thumbnailPath?.lg ?? path;\n      // Template cover is stored in publicBucket, no need for signed URL\n      previewUrlMap[item.id] = getPublicFullStorageUrl(finalThumbnailPath);\n    }\n\n    return templates.map((item) => {\n      const creator = userMap?.[item.createdBy];\n      return {\n        ...item,\n        cover: item.cover\n          ? {\n              ...JSON.parse(item.cover),\n              presignedUrl: previewUrlMap[item.id],\n            }\n          : undefined,\n        snapshot: item.snapshot ? JSON.parse(item.snapshot) : undefined,\n        createdBy: creator ?? null,\n      };\n    });\n  }\n\n  async deleteTemplate(templateId: string) {\n    return await this.prismaService\n      .txClient()\n      .template.delete({\n        where: {\n          id: templateId,\n        },\n      })\n      .then(async (res) => {\n        if (res.baseId) {\n          await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));\n        }\n        // Clear permalink cache\n        await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));\n        return res;\n      });\n  }\n\n  async updateTemplate(templateId: string, updateTemplateRo: IUpdateTemplateRo) {\n    const prisma = this.prismaService.txClient();\n    const newCover = updateTemplateRo?.cover\n      ? JSON.stringify(updateTemplateRo.cover)\n      : updateTemplateRo?.cover;\n\n    const originalTemplate = await prisma.template.findUniqueOrThrow({\n      where: { id: templateId },\n    });\n\n    if (updateTemplateRo.isPublished && !originalTemplate.snapshot) {\n      throw new CustomHttpException(\n        'This template could not be published, causing the lacking of snapshot',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.template.snapshotRequired',\n          },\n        }\n      );\n    }\n\n    await prisma.template\n      .update({\n        where: { id: templateId },\n        data: {\n          ...updateTemplateRo,\n          categoryId: updateTemplateRo.categoryId,\n          cover: newCover as string | null | undefined,\n        },\n      })\n      .then(async (res) => {\n        if (res.baseId) {\n          await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));\n        }\n        // Clear permalink cache when template is updated (especially when publish status changes)\n        await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));\n        return res;\n      });\n  }\n\n  async createTemplateSnapshot(templateId: string) {\n    const prisma = this.prismaService.txClient();\n    const templateRaw = await prisma.template.findUniqueOrThrow({\n      where: { id: templateId },\n      select: {\n        baseId: true,\n        name: true,\n        snapshot: true,\n      },\n    });\n\n    if (!templateRaw.baseId) {\n      throw new CustomHttpException('Source template not found', HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.template.sourceTemplateNotFound',\n        },\n      });\n    }\n\n    const templateSpaceId = await prisma.space.findFirstOrThrow({\n      where: {\n        isTemplate: true,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        // duplicate a base for template snapshot, not allow cross base field relative, all cross base link field will be duplicated as single text fields\n        const {\n          base: { id, spaceId, name },\n        } = await this.baseDuplicateService.duplicateBase(\n          {\n            fromBaseId: templateRaw.baseId!,\n            spaceId: templateSpaceId.id,\n            withRecords: true,\n            name: templateRaw?.name || 'template snapshot',\n          },\n          false,\n          BaseDuplicateMode.CreateTemplate\n        );\n\n        if (templateRaw.snapshot) {\n          // delete previous base\n          const snapshot = JSON.parse(templateRaw.snapshot);\n          await prisma.base.update({\n            where: { id: snapshot.baseId },\n            data: {\n              deletedTime: new Date().toISOString(),\n            },\n          });\n        }\n\n        return await prisma.template\n          .update({\n            where: { id: templateId },\n            data: {\n              snapshot: JSON.stringify({\n                baseId: id,\n                snapshotTime: new Date().toISOString(),\n                spaceId,\n                name,\n              }),\n              lastModifiedBy: this.cls.get('user.id'),\n            },\n          })\n          .then(async (res) => {\n            if (res.baseId) {\n              await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));\n            }\n            // Clear permalink cache when snapshot is updated\n            await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));\n            return res;\n          });\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async createTemplateCategory(createTemplateCategoryRo: ICreateTemplateCategoryRo) {\n    const prisma = this.prismaService.txClient();\n    const userId = this.cls.get('user.id');\n\n    // Check if category limit reached (max 50)\n    const categoryCount = await prisma.templateCategory.count();\n    if (categoryCount >= MAX_TEMPLATE_CATEGORY_COUNT) {\n      throw new CustomHttpException(\n        `Template category limit reached (max ${MAX_TEMPLATE_CATEGORY_COUNT})`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.template.categoryLimitReached',\n            context: {\n              maxCount: MAX_TEMPLATE_CATEGORY_COUNT,\n            },\n          },\n        }\n      );\n    }\n\n    const categoryId = generateTemplateCategoryId();\n    const maxOrder = await prisma.templateCategory.aggregate({\n      _max: {\n        order: true,\n      },\n    });\n\n    const finalOrder = isNumber(maxOrder._max.order) ? maxOrder._max.order + 1 : 1;\n\n    await this.performanceCacheService.del(generateTemplateCategoryCacheKey());\n\n    return await prisma.templateCategory.create({\n      data: {\n        id: categoryId,\n        ...createTemplateCategoryRo,\n        createdBy: userId,\n        order: finalOrder,\n      },\n    });\n  }\n\n  @PerformanceCache({\n    ttl: 60 * 60 * 24,\n    keyGenerator: generateTemplateCategoryCacheKey,\n    statsType: 'template',\n  })\n  async getTemplateCategoryList() {\n    return await this.prismaService.txClient().templateCategory.findMany({\n      orderBy: {\n        order: 'asc',\n      },\n      // limit 50\n      take: MAX_TEMPLATE_CATEGORY_COUNT,\n    });\n  }\n\n  async pinTopTemplate(templateId: string) {\n    const prisma = this.prismaService.txClient();\n    const result = await prisma.template.aggregate({\n      _min: {\n        order: true,\n      },\n    });\n\n    if (!isNumber(result._min.order)) {\n      throw new CustomHttpException('No min order found', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.template.noMinOrderFound',\n        },\n      });\n    }\n\n    await prisma.template\n      .update({\n        where: { id: templateId },\n        data: { order: result._min.order - 1 },\n      })\n      .then(async (res) => {\n        if (res.baseId) {\n          await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));\n        }\n        return res;\n      });\n  }\n\n  async deleteTemplateCategory(categoryId: string) {\n    await this.performanceCacheService.del(generateTemplateCategoryCacheKey());\n    await this.prismaService.txClient().templateCategory.delete({\n      where: { id: categoryId },\n    });\n  }\n\n  async updateTemplateCategory(\n    categoryId: string,\n    updateTemplateCategoryRo: IUpdateTemplateCategoryRo\n  ) {\n    await this.performanceCacheService.del(generateTemplateCategoryCacheKey());\n    await this.prismaService.txClient().templateCategory.update({\n      where: { id: categoryId },\n      data: { ...updateTemplateCategoryRo },\n    });\n  }\n\n  async shuffleCategories() {\n    const categories = await this.prismaService.txClient().templateCategory.findMany({\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n\n    this.logger.log(`category shuffle!`, 'shuffleCategories');\n\n    await this.prismaService.$tx(async (prisma) => {\n      for (let i = 0; i < categories.length; i++) {\n        const category = categories[i];\n        await prisma.templateCategory.update({\n          where: { id: category.id },\n          data: { order: i + 1 },\n        });\n      }\n    });\n  }\n\n  async updateTemplateCategoryOrder(categoryId: string, orderRo: IUpdateOrderRo) {\n    const { anchorId, position } = orderRo;\n    const prisma = this.prismaService.txClient();\n\n    // Check if there are duplicate orders, if so, shuffle first\n    const categoriesOrder = await prisma.templateCategory.findMany({\n      select: {\n        order: true,\n      },\n    });\n\n    const uniqOrder = [...new Set(categoriesOrder.map((c) => c.order))];\n\n    // if the category order has the same order, should shuffle\n    const shouldShuffle = uniqOrder.length !== categoriesOrder.length;\n\n    if (shouldShuffle) {\n      await this.shuffleCategories();\n    }\n\n    const category = await prisma.templateCategory\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { id: categoryId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Template category not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.template.categoryNotFound',\n          },\n        });\n      });\n\n    const anchorCategory = await prisma.templateCategory\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { id: anchorId },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          'Anchor template category not found',\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.table.anchorNotFound',\n              context: {\n                anchorId,\n              },\n            },\n          }\n        );\n      });\n\n    await this.performanceCacheService.del(generateTemplateCategoryCacheKey());\n\n    await updateOrder({\n      query: null,\n      position,\n      item: category,\n      anchorItem: anchorCategory,\n      getNextItem: async (whereOrder, align) => {\n        return prisma.templateCategory.findFirst({\n          select: { order: true, id: true },\n          where: {\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (_, id, data) => {\n        await prisma.templateCategory.update({\n          data: { order: data.newOrder },\n          where: { id },\n        });\n      },\n      shuffle: this.shuffleCategories.bind(this),\n    });\n  }\n\n  async getTemplateDetailById(templateId: string) {\n    const prisma = this.prismaService.txClient();\n    const template = await prisma.template.findUniqueOrThrow({\n      where: { id: templateId },\n    });\n\n    const cover = template.cover ? JSON.parse(template.cover) : undefined;\n\n    const newCover = {\n      ...cover,\n      presignedUrl: undefined,\n    };\n\n    if (cover) {\n      const { path } = cover;\n      // Template cover is stored in publicBucket, no need for signed URL\n      newCover.presignedUrl = getPublicFullStorageUrl(path);\n    }\n\n    const userMap = await this.getSpecifiedUserInfoByUserId([template.createdBy]);\n    const creator = userMap?.[template.createdBy];\n\n    return {\n      ...template,\n      cover: {\n        ...newCover,\n      },\n      snapshot: template.snapshot ? JSON.parse(template.snapshot) : undefined,\n      createdBy: creator,\n    };\n  }\n\n  async getTemplateByBaseId(baseId: string) {\n    const prisma = this.prismaService.txClient();\n    const template = await prisma.template.findUnique({\n      where: { baseId },\n      select: {\n        id: true,\n        name: true,\n        categoryId: true,\n        isSystem: true,\n        featured: true,\n        isPublished: true,\n        description: true,\n        baseId: true,\n        cover: true,\n        usageCount: true,\n        markdownDescription: true,\n        publishInfo: true,\n        visitCount: true,\n        createdBy: true,\n        snapshot: true,\n      },\n    });\n\n    if (!template) {\n      return null;\n    }\n\n    const cover = template.cover ? JSON.parse(template.cover) : undefined;\n\n    const newCover = {\n      ...cover,\n      presignedUrl: undefined,\n    };\n\n    if (cover) {\n      const { path } = cover;\n      // Template cover is stored in publicBucket, no need for signed URL\n      newCover.presignedUrl = getPublicFullStorageUrl(path);\n    }\n\n    const userMap = await this.getSpecifiedUserInfoByUserId([template.createdBy]);\n\n    const creator = userMap?.[template.createdBy];\n\n    return {\n      ...template,\n      cover: cover ? { ...newCover } : null,\n      snapshot: template.snapshot ? JSON.parse(template.snapshot) : null,\n      createdBy: creator ?? null,\n    };\n  }\n\n  async incrementTemplateVisitCount(templateId: string) {\n    await this.prismaService.txClient().template.update({\n      where: { id: templateId },\n      data: { visitCount: { increment: 1 } },\n    });\n  }\n\n  private async getSpecifiedUserInfoByUserId(userIds: string[]) {\n    const prisma = this.prismaService.txClient();\n    const users = await prisma.user.findMany({\n      where: {\n        id: { in: userIds },\n        deletedTime: null,\n      },\n      select: {\n        id: true,\n        name: true,\n        avatar: true,\n        email: true,\n      },\n    });\n\n    return users.reduce(\n      (acc, user) => {\n        acc[user.id] = {\n          id: user.id,\n          name: user.name,\n          avatar: user.avatar ? getPublicFullStorageUrl(user.avatar) : undefined,\n          email: user.email,\n        };\n        return acc;\n      },\n      {} as Record<string, { id: string; name: string; avatar: string | undefined; email: string }>\n    );\n  }\n\n  async shuffle(_query: unknown) {\n    const templates = await this.prismaService.txClient().template.findMany({\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n\n    this.logger.log(`lucky template shuffle!`, 'shuffle');\n\n    await this.prismaService.$tx(async (prisma) => {\n      for (let i = 0; i < templates.length; i++) {\n        const template = templates[i];\n        await prisma.template.update({\n          where: { id: template.id },\n          data: { order: i + 1 },\n        });\n      }\n    });\n  }\n\n  async updateOrder(templateId: string, orderRo: IUpdateOrderRo) {\n    const { anchorId, position } = orderRo;\n    const prisma = this.prismaService.txClient();\n\n    // Check if there are duplicate orders, if so, shuffle first\n    const templatesOrder = await prisma.template.findMany({\n      select: {\n        order: true,\n      },\n    });\n\n    const uniqOrder = [...new Set(templatesOrder.map((t) => t.order))];\n\n    // if the template order has the same order, should shuffle\n    const shouldShuffle = uniqOrder.length !== templatesOrder.length;\n\n    if (shouldShuffle) {\n      await this.shuffle(null);\n    }\n\n    const template = await prisma.template\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { id: templateId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Template not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.base.templateNotFound',\n          },\n        });\n      });\n\n    const anchorTemplate = await prisma.template\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { id: anchorId },\n      })\n      .catch(() => {\n        throw new CustomHttpException('Anchor template not found', HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.anchorNotFound',\n            context: {\n              anchorId,\n            },\n          },\n        });\n      });\n\n    await updateOrder({\n      query: null,\n      position,\n      item: template,\n      anchorItem: anchorTemplate,\n      getNextItem: async (whereOrder, align) => {\n        return prisma.template.findFirst({\n          select: { order: true, id: true },\n          where: {\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (_, id, data) => {\n        await prisma.template.update({\n          data: { order: data.newOrder },\n          where: { id },\n        });\n      },\n      shuffle: this.shuffle.bind(this),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/template/template-permalink.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { IdPrefix, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITemplatePermalinkVo } from '@teable/openapi';\nimport { CustomHttpException } from '../../custom.exception';\nimport { PerformanceCache, PerformanceCacheService } from '../../performance-cache';\nimport { generateTemplatePermalinkCacheKey } from '../../performance-cache/generate-keys';\n\n@Injectable()\nexport class TemplatePermalinkService {\n  private logger = new Logger(TemplatePermalinkService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly performanceCacheService: PerformanceCacheService\n  ) {}\n\n  @PerformanceCache({\n    ttl: 86400, // 1 day (24 hours)\n    keyGenerator: (identifier: string) => generateTemplatePermalinkCacheKey(identifier),\n  })\n  async resolvePermalink(identifier: string): Promise<ITemplatePermalinkVo> {\n    const prisma = this.prismaService.txClient();\n\n    if (!identifier.startsWith(IdPrefix.Template)) {\n      throw new CustomHttpException('Invalid identifier', HttpErrorCode.NOT_FOUND);\n    }\n\n    // 1. Find template by ID\n    const template = await prisma.template.findUnique({\n      where: { id: identifier },\n      select: {\n        publishInfo: true,\n        snapshot: true,\n        isPublished: true,\n        id: true,\n      },\n    });\n\n    // 2. Validate template exists\n    if (!template) {\n      throw new CustomHttpException('Template not found', HttpErrorCode.NOT_FOUND);\n    }\n\n    // 3. Check if template is published\n    if (!template.isPublished) {\n      throw new CustomHttpException('Template is not published', HttpErrorCode.RESTRICTED_RESOURCE);\n    }\n\n    // 4. Parse snapshot and publishInfo\n    const snapshot = template.snapshot ? JSON.parse(template.snapshot) : {};\n    const publishInfo = template.publishInfo as { defaultUrl?: string } | null;\n    const snapshotBaseId = snapshot.baseId;\n\n    if (!snapshotBaseId) {\n      throw new CustomHttpException(\n        'Template snapshot is invalid',\n        HttpErrorCode.UNPROCESSABLE_ENTITY\n      );\n    }\n\n    // 5. Get redirect URL from publishInfo, fallback to base homepage\n    const defaultUrl = publishInfo?.defaultUrl;\n    const redirectUrl = defaultUrl || `/base/${snapshotBaseId}`;\n\n    return {\n      redirectUrl,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { generateRecordTrashId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ResourceType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { Events } from '../../../event-emitter/events';\nimport { IDeleteFieldsPayload } from '../../undo-redo/operations/delete-fields.operation';\nimport { IDeleteRecordsPayload } from '../../undo-redo/operations/delete-records.operation';\nimport { IDeleteViewPayload } from '../../undo-redo/operations/delete-view.operation';\n\n@Injectable()\nexport class TableTrashListener {\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  @OnEvent(Events.OPERATION_RECORDS_DELETE)\n  async recordDeleteListener(payload: IDeleteRecordsPayload) {\n    const { operationId, userId, tableId, records } = payload;\n\n    if (!operationId) return;\n\n    const recordIds = records.map((record) => record.id);\n\n    await this.prismaService.$tx(\n      async (prisma) => {\n        await prisma.tableTrash.create({\n          data: {\n            id: operationId,\n            tableId,\n            createdBy: userId,\n            resourceType: ResourceType.Record,\n            snapshot: JSON.stringify(recordIds),\n          },\n        });\n\n        const batchSize = 5000;\n        for (let i = 0; i < records.length; i += batchSize) {\n          const batch = records.slice(i, i + batchSize);\n          const recordTrashData = batch.map((record) => ({\n            id: generateRecordTrashId(),\n            table_id: tableId,\n            record_id: record.id,\n            snapshot: JSON.stringify(record),\n            created_by: userId,\n          }));\n\n          const query = this.knex.insert(recordTrashData).into('record_trash').toQuery();\n          await prisma.$executeRawUnsafe(query);\n        }\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  @OnEvent(Events.OPERATION_FIELDS_DELETE, { async: true })\n  async fieldDeleteListener(payload: IDeleteFieldsPayload) {\n    const { userId, tableId, fields, records, operationId } = payload;\n\n    if (!operationId) return;\n\n    await this.prismaService.tableTrash.create({\n      data: {\n        id: operationId,\n        tableId,\n        createdBy: userId,\n        resourceType: ResourceType.Field,\n        snapshot: JSON.stringify({ fields, records }),\n      },\n    });\n  }\n\n  @OnEvent(Events.OPERATION_VIEW_DELETE, { async: true })\n  async viewDeleteListener(payload: IDeleteViewPayload) {\n    const { operationId, tableId, viewId, userId } = payload;\n\n    if (!operationId) return;\n\n    await this.prismaService.tableTrash.create({\n      data: {\n        id: operationId,\n        tableId,\n        createdBy: userId,\n        resourceType: ResourceType.View,\n        snapshot: JSON.stringify([viewId]),\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/trash.controller.ts",
    "content": "import { Controller, Delete, Get, Param, Post, Query, Res } from '@nestjs/common';\nimport type { ITrashVo } from '@teable/openapi';\nimport {\n  ITrashRo,\n  trashItemsRoSchema,\n  trashRoSchema,\n  ITrashItemsRo,\n  resetTrashItemsRoSchema,\n  IResetTrashItemsRo,\n} from '@teable/openapi';\nimport type { Response } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { TokenAccess } from '../auth/decorators/token.decorator';\nimport {\n  X_TEABLE_V2_FEATURE_HEADER,\n  X_TEABLE_V2_HEADER,\n  X_TEABLE_V2_REASON_HEADER,\n} from '../canary/interceptors/v2-indicator.interceptor';\nimport { TrashService } from './trash.service';\n\n@Controller('api/trash/')\nexport class TrashController {\n  protected static readonly restoreTableV2Feature = 'restoreTable';\n\n  constructor(\n    private readonly trashService: TrashService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Get()\n  async getTrash(@Query(new ZodValidationPipe(trashRoSchema)) query: ITrashRo): Promise<ITrashVo> {\n    return await this.trashService.getTrash(query);\n  }\n\n  @Get('items')\n  @TokenAccess()\n  async getTrashItems(\n    @Query(new ZodValidationPipe(trashItemsRoSchema)) query: ITrashItemsRo\n  ): Promise<ITrashVo> {\n    return await this.trashService.getTrashItems(query);\n  }\n\n  @Post('restore/:trashId')\n  @TokenAccess()\n  async restoreTrash(\n    @Param('trashId') trashId: string,\n    @Res({ passthrough: true }) response: Response\n  ): Promise<void> {\n    await this.prepareRestoreTableCanary(trashId, response);\n    if (this.cls.get('useV2')) {\n      return await this.trashService.restoreTrashV2(trashId);\n    }\n    return await this.trashService.restoreTrash(trashId);\n  }\n\n  @Delete('reset-items')\n  @TokenAccess()\n  async resetTrashItems(\n    @Query(new ZodValidationPipe(resetTrashItemsRoSchema)) query: IResetTrashItemsRo\n  ): Promise<void> {\n    return await this.trashService.resetTrashItems(query);\n  }\n\n  @Delete(':trashId')\n  @TokenAccess()\n  async delete(@Param('trashId') trashId: string): Promise<void> {\n    return await this.trashService.delete(trashId);\n  }\n\n  protected async prepareRestoreTableCanary(trashId: string, response: Response): Promise<void> {\n    const decision = await this.trashService.getRestoreTableV2Decision(trashId);\n    if (!decision) {\n      return;\n    }\n\n    this.cls.set('useV2', decision.useV2);\n    this.cls.set('v2Feature', TrashController.restoreTableV2Feature);\n    this.cls.set('v2Reason', decision.reason);\n\n    response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');\n    response.setHeader(X_TEABLE_V2_FEATURE_HEADER, TrashController.restoreTableV2Feature);\n    response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/trash.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsTableModule } from '../attachments/attachments-table.module';\nimport { BaseModule } from '../base/base.module';\nimport { CanaryModule } from '../canary/canary.module';\nimport { FieldOpenApiModule } from '../field/open-api/field-open-api.module';\nimport { RecordOpenApiModule } from '../record/open-api/record-open-api.module';\nimport { RecordModule } from '../record/record.module';\nimport { SpaceModule } from '../space/space.module';\nimport { TableOpenApiModule } from '../table/open-api/table-open-api.module';\nimport { UserModule } from '../user/user.module';\nimport { V2Module } from '../v2/v2.module';\nimport { ViewModule } from '../view/view.module';\nimport { TableTrashListener } from './listener/table-trash.listener';\nimport { TrashController } from './trash.controller';\nimport { TrashService } from './trash.service';\nimport { V2TableTrashService } from './v2-table-trash.service';\n\n@Module({\n  imports: [\n    AttachmentsTableModule,\n    UserModule,\n    SpaceModule,\n    BaseModule,\n    CanaryModule,\n    TableOpenApiModule,\n    FieldOpenApiModule,\n    RecordOpenApiModule,\n    RecordModule,\n    V2Module,\n    ViewModule,\n  ],\n  controllers: [TrashController],\n  providers: [TrashService, TableTrashListener, V2TableTrashService],\n  exports: [TrashService],\n})\nexport class TrashModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/trash.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type { FieldType, IFieldVo } from '@teable/core';\nimport { FieldKeyType, HttpErrorCode, IdPrefix, Role } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IResetTrashItemsRo,\n  IResourceMapVo,\n  ITrashItemsRo,\n  ITrashItemVo,\n  ITrashRo,\n  ITrashVo,\n} from '@teable/openapi';\nimport { CollaboratorType, TableTrashType, TrashType } from '@teable/openapi';\nimport { TableId, v2CoreTokens } from '@teable/v2-core';\nimport type { Table, TableQueryService } from '@teable/v2-core';\nimport { Knex } from 'knex';\nimport { keyBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport type { ICreateFieldsOperation } from '../../cache/types';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport type { IPerformanceCacheStore } from '../../performance-cache';\nimport { PerformanceCacheService } from '../../performance-cache';\nimport { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';\nimport type { IClsStore } from '../../types/cls';\nimport { PermissionService } from '../auth/permission.service';\nimport { BaseService } from '../base/base.service';\nimport { CanaryService, type IV2Decision } from '../canary/canary.service';\nimport { FieldOpenApiService } from '../field/open-api/field-open-api.service';\nimport { RecordOpenApiService } from '../record/open-api/record-open-api.service';\nimport { RecordService } from '../record/record.service';\nimport { SpaceService } from '../space/space.service';\nimport { TableOpenApiV2Service } from '../table/open-api/table-open-api-v2.service';\nimport { TableOpenApiService } from '../table/open-api/table-open-api.service';\nimport { UserService } from '../user/user.service';\nimport { V2ContainerService } from '../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../v2/v2-execution-context.factory';\nimport { ViewService } from '../view/view.service';\nimport { resolveV2TrashRecordDisplayName } from './v2-trash-record-name';\n\n@Injectable()\nexport class TrashService {\n  constructor(\n    protected readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,\n    protected readonly prismaService: PrismaService,\n    protected readonly cls: ClsService<IClsStore>,\n    protected readonly userService: UserService,\n    protected readonly permissionService: PermissionService,\n    protected readonly spaceService: SpaceService,\n    protected readonly baseService: BaseService,\n    protected readonly tableOpenApiService: TableOpenApiService,\n    protected readonly tableOpenApiV2Service: TableOpenApiV2Service,\n    protected readonly fieldOpenApiService: FieldOpenApiService,\n    protected readonly recordOpenApiService: RecordOpenApiService,\n    protected readonly recordService: RecordService,\n    protected readonly viewService: ViewService,\n    protected readonly v2ContainerService: V2ContainerService,\n    protected readonly v2ExecutionContextFactory: V2ExecutionContextFactory,\n    protected readonly canaryService: CanaryService,\n    @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig,\n    @InjectModel('CUSTOM_KNEX') protected readonly knex: Knex\n  ) {}\n\n  async getAuthorizedSpacesAndBases() {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n\n    const collaborators = await this.prismaService.txClient().collaborator.findMany({\n      where: {\n        principalId: { in: [userId, ...(departmentIds || [])] },\n        roleName: { in: [Role.Owner, Role.Creator] },\n      },\n      select: {\n        resourceId: true,\n        resourceType: true,\n      },\n    });\n\n    const baseIds = new Set<string>();\n    const spaceIds = new Set<string>();\n\n    collaborators.forEach(({ resourceId, resourceType }) => {\n      if (resourceType === CollaboratorType.Base) baseIds.add(resourceId);\n      if (resourceType === CollaboratorType.Space) spaceIds.add(resourceId);\n    });\n    const bases = await this.prismaService.base.findMany({\n      where: {\n        OR: [{ spaceId: { in: Array.from(spaceIds) } }, { id: { in: Array.from(baseIds) } }],\n      },\n      select: {\n        id: true,\n        name: true,\n        spaceId: true,\n        space: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n    const spaces = await this.prismaService.space.findMany({\n      where: { id: { in: Array.from(spaceIds) } },\n      select: { id: true, name: true },\n    });\n\n    return {\n      spaces,\n      bases,\n    };\n  }\n\n  async getTrash(trashRo: ITrashRo) {\n    const { resourceType, spaceId } = trashRo;\n\n    switch (resourceType) {\n      case TrashType.Space:\n        return await this.getSpaceTrash();\n      case TrashType.Base:\n        return await this.getBaseTrash(spaceId);\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  private async getSpaceTrash() {\n    const { spaces } = await this.getAuthorizedSpacesAndBases();\n    const spaceIds = spaces.map((space) => space.id);\n    const spaceIdMap = keyBy(spaces, 'id');\n    const list = await this.prismaService.trash.findMany({\n      where: { resourceId: { in: spaceIds } },\n      orderBy: { deletedTime: 'desc' },\n    });\n\n    const trashItems: ITrashItemVo[] = [];\n    const deletedBySet: Set<string> = new Set();\n    const resourceMap: IResourceMapVo = {};\n\n    list.forEach((item) => {\n      const { id, resourceId, resourceType, deletedTime, deletedBy } = item;\n\n      trashItems.push({\n        id,\n        resourceId,\n        resourceType: resourceType as TrashType,\n        deletedTime: deletedTime.toISOString(),\n        deletedBy,\n      });\n      resourceMap[resourceId] = {\n        id: resourceId,\n        name: spaceIdMap[resourceId].name,\n      };\n      deletedBySet.add(deletedBy);\n    });\n\n    const userList = await this.userService.getUserInfoList(Array.from(deletedBySet));\n\n    return {\n      trashItems,\n      resourceMap,\n      userMap: keyBy(userList, 'id'),\n      nextCursor: null,\n    };\n  }\n\n  private async getBaseTrash(spaceId?: string) {\n    const { bases } = await this.getAuthorizedSpacesAndBases();\n    const authorizedBaseIds = bases.map((base) => base.id);\n    const authorizedBaseSpaceIds = bases.map((base) => base.spaceId);\n    const baseIdMap = keyBy(bases, 'id');\n\n    const trashedSpaces = await this.prismaService.trash.findMany({\n      where: {\n        resourceType: TrashType.Space,\n        resourceId: { in: authorizedBaseSpaceIds },\n      },\n      select: { resourceId: true },\n    });\n    const list = await this.prismaService.trash.findMany({\n      where: {\n        parentId: {\n          notIn: trashedSpaces.map((space) => space.resourceId),\n          in: spaceId ? [spaceId] : undefined,\n        },\n        resourceId: { in: authorizedBaseIds },\n        resourceType: TrashType.Base,\n      },\n    });\n\n    const trashItems: ITrashItemVo[] = [];\n    const deletedBySet: Set<string> = new Set();\n    const resourceMap: IResourceMapVo = {};\n\n    list.forEach((item) => {\n      const { id, resourceId, resourceType, deletedTime, deletedBy } = item;\n\n      trashItems.push({\n        id,\n        resourceId,\n        resourceType: resourceType as TrashType,\n        deletedTime: deletedTime.toISOString(),\n        deletedBy,\n      });\n      deletedBySet.add(deletedBy);\n\n      const baseInfo = baseIdMap[resourceId];\n      resourceMap[resourceId] = {\n        id: resourceId,\n        spaceId: baseInfo.spaceId,\n        name: baseInfo.name,\n      };\n      resourceMap[baseInfo.spaceId] = {\n        id: baseInfo.spaceId,\n        name: baseInfo.space.name,\n      };\n    });\n    const userList = await this.userService.getUserInfoList(Array.from(deletedBySet));\n\n    return {\n      trashItems,\n      resourceMap,\n      userMap: keyBy(userList, 'id'),\n      nextCursor: null,\n    };\n  }\n\n  async getTrashItems(trashItemsRo: ITrashItemsRo): Promise<ITrashVo> {\n    const { resourceType } = trashItemsRo;\n\n    switch (resourceType) {\n      case TrashType.Base:\n        return await this.getBaseTrashItems(trashItemsRo);\n      case TrashType.Table:\n        return await this.getTableTrashItems(trashItemsRo);\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  private async getV2TableDomain(tableId: string): Promise<Table | null> {\n    const tableIdResult = TableId.create(tableId);\n    if (tableIdResult.isErr()) {\n      return null;\n    }\n\n    try {\n      const container = await this.v2ContainerService.getContainer();\n      const tableQueryService = container.resolve<TableQueryService>(\n        v2CoreTokens.tableQueryService\n      );\n      const queryContext = await this.v2ExecutionContextFactory.createContext();\n      const tableResult = await tableQueryService.getById(queryContext, tableIdResult.value);\n\n      return tableResult.isOk() ? tableResult.value : null;\n    } catch {\n      return null;\n    }\n  }\n\n  private async getRecordTrashResourceMap(\n    tableId: string,\n    recordList: Array<{ recordId: string; snapshot: string }>\n  ): Promise<IResourceMapVo> {\n    const cache = { loaded: false, table: null as Table | null };\n    const resourceMap: IResourceMapVo = {};\n\n    for (const { recordId, snapshot } of recordList) {\n      const parsedSnapshot = JSON.parse(snapshot) as {\n        id?: string;\n        name?: string;\n        fields?: Record<string, unknown>;\n      };\n\n      const name = await this.resolveRecordTrashName(tableId, recordId, parsedSnapshot, cache);\n      resourceMap[recordId] = { id: recordId, name };\n    }\n\n    return resourceMap;\n  }\n\n  private async getCachedV2Table(\n    tableId: string,\n    cache: { loaded: boolean; table: Table | null }\n  ): Promise<Table | null> {\n    if (!cache.loaded) {\n      cache.table = await this.getV2TableDomain(tableId);\n      cache.loaded = true;\n    }\n\n    return cache.table;\n  }\n\n  private async resolveRecordTrashName(\n    tableId: string,\n    recordId: string,\n    parsedSnapshot: { id?: string; name?: string; fields?: Record<string, unknown> },\n    cache: { loaded: boolean; table: Table | null }\n  ): Promise<string> {\n    const snapshotName = typeof parsedSnapshot.name === 'string' ? parsedSnapshot.name.trim() : '';\n    if (snapshotName) {\n      return snapshotName;\n    }\n\n    if (\n      parsedSnapshot.fields == null ||\n      typeof parsedSnapshot.fields !== 'object' ||\n      Array.isArray(parsedSnapshot.fields)\n    ) {\n      return '';\n    }\n\n    const table = await this.getCachedV2Table(tableId, cache);\n    if (!table) {\n      return '';\n    }\n\n    const nameResult = resolveV2TrashRecordDisplayName(table, {\n      id: parsedSnapshot.id ?? recordId,\n      fields: parsedSnapshot.fields,\n    });\n\n    return nameResult.isOk() ? nameResult.value ?? '' : '';\n  }\n\n  async getResourceMapByIds(\n    resourceType: TableTrashType,\n    resourceIds: string[],\n    tableId: string\n  ): Promise<IResourceMapVo> {\n    switch (resourceType) {\n      case TableTrashType.View: {\n        const views = await this.prismaService.view.findMany({\n          where: { id: { in: resourceIds }, deletedTime: { not: null } },\n          select: {\n            id: true,\n            name: true,\n            type: true,\n          },\n        });\n        return keyBy(views, 'id');\n      }\n      case TableTrashType.Field: {\n        const fields = await this.prismaService.field.findMany({\n          where: { id: { in: resourceIds }, deletedTime: { not: null } },\n          select: {\n            id: true,\n            name: true,\n            type: true,\n            options: true,\n            isLookup: true,\n            isConditionalLookup: true,\n          },\n        });\n        return fields.reduce((acc, { id, name, type, options, isLookup, isConditionalLookup }) => {\n          acc[id] = {\n            id,\n            name,\n            type: type as FieldType,\n            options: options ? JSON.parse(options) : undefined,\n            isLookup,\n            isConditionalLookup,\n          };\n          return acc;\n        }, {} as IResourceMapVo);\n      }\n      case TableTrashType.Record: {\n        const recordList = await this.prismaService.recordTrash.findMany({\n          where: { tableId, recordId: { in: resourceIds } },\n          select: {\n            recordId: true,\n            snapshot: true,\n          },\n        });\n\n        return await this.getRecordTrashResourceMap(tableId, recordList);\n      }\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async getTableTrashItems(trashItemsRo: ITrashItemsRo): Promise<ITrashVo> {\n    const { resourceId: tableId, cursor, pageSize = 20 } = trashItemsRo;\n    const accessTokenId = this.cls.get('accessTokenId');\n    let nextCursor: typeof cursor | undefined = undefined;\n\n    await this.permissionService.validPermissions(\n      tableId,\n      ['table|trash_read'],\n      accessTokenId,\n      true\n    );\n\n    const list = await this.prismaService.tableTrash.findMany({\n      where: {\n        tableId,\n      },\n      select: {\n        id: true,\n        snapshot: true,\n        resourceType: true,\n        createdBy: true,\n        createdTime: true,\n      },\n      take: pageSize + 1,\n      cursor: cursor ? { id: cursor } : undefined,\n      orderBy: {\n        createdTime: 'desc',\n      },\n    });\n\n    if (list.length > pageSize) {\n      const nextItem = list.pop();\n      nextCursor = nextItem?.id;\n    }\n\n    const deletedResourceMap: Record<\n      TableTrashType.View | TableTrashType.Field | TableTrashType.Record,\n      string[]\n    > = {\n      [TableTrashType.View]: [],\n      [TableTrashType.Field]: [],\n      [TableTrashType.Record]: [],\n    };\n    const deletedBySet: Set<string> = new Set();\n    const trashItems = list.map((item) => {\n      const { id, snapshot, createdBy, createdTime } = item;\n      const parsedSnapshot = JSON.parse(snapshot);\n      const resourceType = item.resourceType as TableTrashType;\n\n      const resourceIds =\n        resourceType === TableTrashType.Field\n          ? (parsedSnapshot.fields as IFieldVo[]).map(({ id }) => id)\n          : parsedSnapshot;\n      deletedResourceMap[resourceType].push(...resourceIds);\n      deletedBySet.add(createdBy);\n\n      return {\n        id,\n        resourceType: resourceType,\n        deletedTime: createdTime.toISOString(),\n        deletedBy: createdBy,\n        resourceIds,\n      };\n    });\n\n    const resourceMap: IResourceMapVo = {};\n\n    for (const [type, ids] of Object.entries(deletedResourceMap)) {\n      if (ids.length > 0) {\n        const resources = await this.getResourceMapByIds(type as TableTrashType, ids, tableId);\n        Object.assign(resourceMap, resources);\n      }\n    }\n\n    const userList = await this.userService.getUserInfoList(Array.from(deletedBySet));\n\n    return {\n      trashItems,\n      resourceMap,\n      userMap: keyBy(userList, 'id'),\n      nextCursor,\n    };\n  }\n\n  protected async getBaseTrashResourceList(baseId: string) {\n    return await this.prismaService.tableMeta.findMany({\n      where: {\n        baseId,\n        deletedTime: { not: null },\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  async getBaseTrashItems(trashItemsRo: ITrashItemsRo): Promise<ITrashVo> {\n    const { resourceId: baseId, cursor, pageSize = 20 } = trashItemsRo;\n    let nextCursor: string | null | undefined = undefined;\n\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(\n      baseId,\n      ['table|delete', 'app|delete', 'automation|delete'],\n      accessTokenId,\n      true\n    );\n\n    const trashItems: ITrashItemVo[] = [];\n    const deletedBySet: Set<string> = new Set();\n    const resourceList = await this.getBaseTrashResourceList(baseId);\n    const resourceMap: IResourceMapVo = keyBy(resourceList, 'id');\n\n    const list = await this.prismaService.trash.findMany({\n      where: {\n        parentId: baseId,\n      },\n      take: pageSize + 1,\n      cursor: cursor ? { id: cursor } : undefined,\n      orderBy: { deletedTime: 'desc' },\n    });\n\n    if (list.length > pageSize) {\n      const nextItem = list.pop();\n      nextCursor = nextItem?.id;\n    }\n\n    list.forEach((item) => {\n      const { id, resourceId, resourceType, deletedTime, deletedBy } = item;\n\n      trashItems.push({\n        id,\n        resourceId,\n        resourceType: resourceType as TrashType,\n        deletedTime: deletedTime.toISOString(),\n        deletedBy,\n      });\n      deletedBySet.add(deletedBy);\n    });\n    const userList = await this.userService.getUserInfoList(Array.from(deletedBySet));\n\n    return {\n      trashItems,\n      resourceMap,\n      userMap: keyBy(userList, 'id'),\n      nextCursor: nextCursor ?? null,\n    };\n  }\n\n  private async restoreSpace(spaceId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(spaceId, ['space|create'], accessTokenId, true);\n\n    await this.prismaService.txClient().space.update({\n      where: { id: spaceId },\n      data: { deletedTime: null },\n    });\n  }\n\n  private async restoreBase(baseId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(baseId, ['base|create'], accessTokenId, true);\n\n    const prisma = this.prismaService.txClient();\n    const base = await prisma.base.findUniqueOrThrow({\n      where: { id: baseId },\n      select: { id: true, spaceId: true },\n    });\n    const trashedSpace = await prisma.trash.findFirst({\n      where: { resourceId: base.spaceId, resourceType: TrashType.Space },\n    });\n\n    if (trashedSpace != null) {\n      throw new CustomHttpException(\n        'Unable to restore this base because its parent space is also trashed',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.trash.parentSpaceTrashed',\n          },\n        }\n      );\n    }\n\n    await this.permissionService.validPermissions(baseId, ['base|create'], accessTokenId, true);\n\n    await prisma.base.update({\n      where: { id: baseId },\n      data: { deletedTime: null },\n    });\n\n    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));\n  }\n\n  private async assertParentNotTrashed(parentId: string | null) {\n    if (!parentId) {\n      return;\n    }\n\n    // Use recursive CTE to check if any parent in the hierarchy is trashed\n    const query = this.knex\n      .withRecursive('parent_chain', (qb) => {\n        // Base case: check if the immediate parent is in trash\n        qb.select('resource_id', 'parent_id')\n          .from('trash')\n          .where('resource_id', parentId)\n          .unionAll((qb) => {\n            // Recursive case: traverse up the parent hierarchy\n            qb.select('t.resource_id', 't.parent_id')\n              .from('trash as t')\n              .join('parent_chain as pc', 't.resource_id', 'pc.parent_id')\n              .whereNotNull('pc.parent_id');\n          });\n      })\n      .select('resource_id')\n      .from('parent_chain')\n      .limit(1)\n      .toQuery();\n\n    const result = await this.prismaService.$queryRawUnsafe<{ resourceId: string }[]>(query);\n    if (result.length > 0) {\n      throw new CustomHttpException(\n        'Unable to restore this resource because its parent is also in trash',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.trash.parentBaseTrashed',\n          },\n        }\n      );\n    }\n  }\n\n  private async restoreTable(tableId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(tableId, ['table|create'], accessTokenId, true);\n\n    const prisma = this.prismaService.txClient();\n    const { baseId } = await prisma.tableMeta\n      .findUniqueOrThrow({\n        where: { id: tableId },\n        select: { baseId: true },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`The table ${tableId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.table.notFound',\n          },\n        });\n      });\n    await this.tableOpenApiService.restoreTable(baseId, tableId);\n    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));\n  }\n\n  async getRestoreTableV2Decision(\n    trashId: string\n  ): Promise<(IV2Decision & { baseId: string; tableId: string }) | undefined> {\n    if (trashId.startsWith(IdPrefix.Operation)) {\n      return undefined;\n    }\n\n    const trash = await this.prismaService.txClient().trash.findUnique({\n      where: { id: trashId },\n      select: {\n        resourceId: true,\n        resourceType: true,\n        parentId: true,\n      },\n    });\n\n    if (!trash || trash.resourceType !== TrashType.Table) {\n      return undefined;\n    }\n\n    const baseId = trash.parentId;\n    if (!baseId) {\n      return { useV2: false, reason: 'disabled', baseId: '', tableId: trash.resourceId };\n    }\n\n    const base = await this.prismaService.txClient().base.findUnique({\n      where: { id: baseId, deletedTime: null },\n      select: { spaceId: true },\n    });\n\n    if (!base?.spaceId) {\n      return { useV2: false, reason: 'disabled', baseId, tableId: trash.resourceId };\n    }\n\n    const decision = await this.canaryService.shouldUseV2WithReason(base.spaceId, 'restoreTable');\n    return {\n      ...decision,\n      baseId,\n      tableId: trash.resourceId,\n    };\n  }\n\n  async restoreTrashV2(trashId: string) {\n    const decision = await this.getRestoreTableV2Decision(trashId);\n    if (!decision) {\n      throw new CustomHttpException(`The trash ${trashId} not found`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.trash.notFound',\n        },\n      });\n    }\n\n    await this.assertParentNotTrashed(decision.baseId);\n    await this.restoreTableV2(decision.baseId, decision.tableId);\n  }\n\n  private async restoreTableV2(baseId: string, tableId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(tableId, ['table|create'], accessTokenId, true);\n    await this.tableOpenApiV2Service.restoreTable(baseId, tableId);\n    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));\n  }\n\n  async restoreResource(trash: { resourceType: TrashType; resourceId: string }) {\n    const { resourceType, resourceId } = trash;\n    switch (resourceType) {\n      case TrashType.Space:\n        return this.restoreSpace(resourceId);\n      case TrashType.Base:\n        return this.restoreBase(resourceId);\n      case TrashType.Table:\n        return this.restoreTable(resourceId);\n      default:\n        throw new CustomHttpException(\n          `Invalid resource type ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n\n  async restoreTableResource(trashId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n\n    const {\n      tableId,\n      resourceType,\n      snapshot: originSnapshot,\n    } = await this.prismaService.tableTrash\n      .findUniqueOrThrow({\n        where: { id: trashId },\n        select: {\n          tableId: true,\n          resourceType: true,\n          snapshot: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `The table trash ${trashId} not found`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.tableNotFound',\n            },\n          }\n        );\n      });\n\n    await this.permissionService.validPermissions(\n      tableId,\n      ['table|trash_update'],\n      accessTokenId,\n      true\n    );\n\n    const snapshot = JSON.parse(originSnapshot);\n\n    return await this.prismaService.$tx(\n      async (prisma) => {\n        switch (resourceType) {\n          case TableTrashType.View: {\n            await this.viewService.restoreView(tableId, snapshot[0]);\n            break;\n          }\n          case TableTrashType.Field: {\n            const { fields, records } = snapshot as ICreateFieldsOperation['result'];\n            await this.fieldOpenApiService.createFields(tableId, fields);\n            if (records) {\n              const existingSnapshots = await this.recordService.getSnapshotBulk(\n                tableId,\n                records.map((r) => r.id)\n              );\n              const existingIdSet = new Set(existingSnapshots.map((s) => s.data.id));\n              const filteredRecords = records.filter((r) => existingIdSet.has(r.id));\n              if (filteredRecords.length) {\n                await this.recordOpenApiService.updateRecords(tableId, {\n                  fieldKeyType: FieldKeyType.Id,\n                  records: filteredRecords,\n                });\n              }\n            }\n            break;\n          }\n          case TableTrashType.Record: {\n            const originRecords = await prisma.recordTrash.findMany({\n              where: { tableId, recordId: { in: snapshot } },\n              select: { snapshot: true },\n            });\n            const records = originRecords.map(({ snapshot }) => JSON.parse(snapshot));\n            await this.recordOpenApiService.multipleCreateRecords(\n              tableId,\n              {\n                fieldKeyType: FieldKeyType.Id,\n                records,\n                typecast: true,\n              },\n              true\n            );\n            await prisma.recordTrash.deleteMany({\n              where: { tableId, recordId: { in: snapshot } },\n            });\n            break;\n          }\n          default:\n            throw new CustomHttpException(\n              `Invalid resource type ${resourceType}`,\n              HttpErrorCode.VALIDATION_ERROR,\n              {\n                localization: {\n                  i18nKey: 'httpErrors.trash.invalidResourceType',\n                },\n              }\n            );\n        }\n\n        await prisma.tableTrash.delete({\n          where: { id: trashId },\n        });\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async restoreTrash(trashId: string) {\n    if (trashId.startsWith(IdPrefix.Operation)) {\n      return await this.restoreTableResource(trashId);\n    }\n\n    await this.prismaService.$tx(async (prisma) => {\n      const trash = await prisma.trash\n        .findUniqueOrThrow({\n          where: { id: trashId },\n          select: {\n            id: true,\n            resourceId: true,\n            resourceType: true,\n            parentId: true,\n          },\n        })\n        .catch(() => {\n          throw new CustomHttpException(`The trash ${trashId} not found`, HttpErrorCode.NOT_FOUND, {\n            localization: {\n              i18nKey: 'httpErrors.trash.notFound',\n            },\n          });\n        });\n\n      await this.assertParentNotTrashed(trash.parentId);\n\n      await this.restoreResource({\n        resourceType: trash.resourceType as TrashType,\n        resourceId: trash.resourceId,\n      });\n\n      await prisma.trash.deleteMany({\n        where: { id: trashId },\n      });\n    });\n  }\n\n  /**\n   * Reset base trash resource (tables, Apps, Workflows)\n   */\n  protected async resetBaseTrashResource(resetTrashItemsRo: IResetTrashItemsRo) {\n    const { resourceId } = resetTrashItemsRo;\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(\n      resourceId,\n      ['table|delete', 'app|delete', 'automation|delete'],\n      accessTokenId,\n      true\n    );\n\n    const tables = await this.prismaService.tableMeta.findMany({\n      where: {\n        baseId: resourceId,\n        deletedTime: { not: null },\n      },\n      select: { id: true },\n    });\n\n    if (!tables.length) return;\n\n    const tableIds = tables.map(({ id }) => id);\n    await this.tableOpenApiService.permanentDeleteTables(resourceId, tableIds);\n  }\n\n  async resetTrashItems(resetTrashItemsRo: IResetTrashItemsRo) {\n    const { resourceId, resourceType } = resetTrashItemsRo;\n\n    if (![TrashType.Base, TrashType.Table].includes(resourceType)) {\n      throw new CustomHttpException(\n        `Invalid resource type ${resourceType}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.trash.invalidResourceType',\n          },\n        }\n      );\n    }\n\n    if (resourceType === TrashType.Base) {\n      await this.resetBaseTrashResource(resetTrashItemsRo);\n    }\n\n    if (resourceType === TrashType.Table) {\n      await this.resetTableTrashItems(resourceId);\n    }\n  }\n\n  private async resetTableTrashItems(tableId: string) {\n    const accessTokenId = this.cls.get('accessTokenId');\n    await this.permissionService.validPermissions(\n      tableId,\n      ['table|trash_reset'],\n      accessTokenId,\n      true\n    );\n\n    const deletedList = await this.prismaService.tableTrash.findMany({\n      where: { tableId },\n      select: { resourceType: true, snapshot: true },\n    });\n    let deletedViewIds: string[] = [];\n    let deletedFieldIds: string[] = [];\n    let deletedRecordIds: string[] = [];\n\n    deletedList.forEach(({ resourceType, snapshot }) => {\n      const parsedSnapshot = JSON.parse(snapshot);\n\n      if (resourceType === TableTrashType.View) {\n        deletedViewIds.push(...parsedSnapshot);\n      }\n\n      if (resourceType === TableTrashType.Field) {\n        deletedFieldIds.push(...(parsedSnapshot.fields as IFieldVo[]).map(({ id }) => id));\n      }\n\n      if (resourceType === TableTrashType.Record) {\n        deletedRecordIds.push(...parsedSnapshot);\n      }\n    });\n\n    deletedViewIds = [...new Set(deletedViewIds)];\n    deletedFieldIds = [...new Set(deletedFieldIds)];\n    deletedRecordIds = [...new Set(deletedRecordIds)];\n\n    await this.prismaService.$tx(async (prisma) => {\n      await prisma.view.deleteMany({\n        where: { id: { in: deletedViewIds } },\n      });\n\n      await prisma.field.deleteMany({\n        where: { id: { in: deletedFieldIds } },\n      });\n\n      await prisma.taskReference.deleteMany({\n        where: {\n          OR: [{ fromFieldId: { in: deletedFieldIds } }, { toFieldId: { in: deletedFieldIds } }],\n        },\n      });\n\n      await prisma.ops.deleteMany({\n        where: {\n          collection: tableId,\n          docId: { in: [...deletedViewIds, ...deletedFieldIds, ...deletedRecordIds] },\n        },\n      });\n\n      await prisma.recordTrash.deleteMany({\n        where: { tableId },\n      });\n\n      await prisma.tableTrash.deleteMany({\n        where: { tableId },\n      });\n    });\n  }\n\n  async delete(trashId: string, ignorePermissionCheck = false): Promise<void> {\n    const trash = await this.prismaService.trash\n      .findUniqueOrThrow({\n        where: { id: trashId },\n      })\n      .catch(() => {\n        throw new CustomHttpException(`The trash ${trashId} not found`, HttpErrorCode.NOT_FOUND, {\n          localization: {\n            i18nKey: 'httpErrors.trash.notFound',\n          },\n        });\n      });\n\n    await this.deleteResource(\n      {\n        ...trash,\n        resourceType: trash.resourceType as TrashType,\n      },\n      ignorePermissionCheck\n    );\n  }\n\n  async deleteResource(\n    trash: {\n      resourceType: TrashType;\n      resourceId: string;\n      parentId?: string | null;\n    },\n    ignorePermissionCheck = false\n  ): Promise<void> {\n    const { resourceType, resourceId, parentId } = trash;\n\n    switch (resourceType) {\n      case TrashType.Space:\n        return this.spaceService.permanentDeleteSpace(resourceId, ignorePermissionCheck);\n      case TrashType.Base:\n        return this.baseService.permanentDeleteBase(resourceId, ignorePermissionCheck);\n      case TrashType.Table: {\n        const baseId = parentId ?? '';\n        if (!baseId) {\n          throw new CustomHttpException(\n            'Base ID is required for deleting table resources',\n            HttpErrorCode.VALIDATION_ERROR,\n            {\n              localization: {\n                i18nKey: 'httpErrors.trash.parentNotFound',\n              },\n            }\n          );\n        }\n        if (!ignorePermissionCheck) {\n          const accessTokenId = this.cls.get('accessTokenId');\n          await this.permissionService.validPermissions(\n            baseId,\n            ['table|delete'],\n            accessTokenId,\n            true\n          );\n        }\n        return this.tableOpenApiService.permanentDeleteTables(baseId, [resourceId]);\n      }\n      default:\n        throw new CustomHttpException(\n          `Unsupported resource type: ${resourceType}`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.trash.invalidResourceType',\n            },\n          }\n        );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts",
    "content": "import { ResourceType } from '@teable/openapi';\nimport { ActorId, BaseId, TableId, TableName, TableRestored, TableTrashed } from '@teable/v2-core';\nimport { describe, expect, it, vi } from 'vitest';\n\nvi.mock('@teable/db-main-prisma', () => ({\n  PrismaModule: class PrismaModule {},\n  PrismaService: class PrismaService {},\n}));\n\nimport { V2TableRestoredProjection, V2TableTrashedProjection } from './v2-table-trash.service';\n\ndescribe('V2TableTrashedProjection', () => {\n  it('writes a table trash entry for soft-deleted tables', async () => {\n    const deletedTime = new Date('2026-03-12T00:00:00.000Z');\n    const prisma = {\n      tableMeta: {\n        findUnique: vi.fn().mockResolvedValue({\n          baseId: 'bseaaaaaaaaaaaaaaaa',\n          deletedTime,\n        }),\n      },\n      trash: {\n        deleteMany: vi.fn().mockResolvedValue({ count: 0 }),\n        create: vi.fn().mockResolvedValue({}),\n      },\n    };\n\n    const projection = new V2TableTrashedProjection(prisma as never);\n    const context = {\n      actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(),\n    };\n    const event = TableTrashed.create({\n      tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(),\n      baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(),\n      tableName: TableName.create('Trash Me')._unsafeUnwrap(),\n      fieldIds: [],\n      viewIds: [],\n    });\n\n    const result = await projection.handle(context, event);\n\n    expect(result._unsafeUnwrap()).toBeUndefined();\n    expect(prisma.tableMeta.findUnique).toHaveBeenCalledWith({\n      where: { id: 'tblaaaaaaaaaaaaaaaa' },\n      select: { baseId: true, deletedTime: true },\n    });\n    expect(prisma.trash.deleteMany).toHaveBeenCalledWith({\n      where: {\n        resourceId: 'tblaaaaaaaaaaaaaaaa',\n        resourceType: ResourceType.Table,\n      },\n    });\n    expect(prisma.trash.create).toHaveBeenCalledWith({\n      data: {\n        resourceId: 'tblaaaaaaaaaaaaaaaa',\n        resourceType: ResourceType.Table,\n        parentId: 'bseaaaaaaaaaaaaaaaa',\n        deletedTime,\n        deletedBy: 'usrTestUserId',\n      },\n    });\n  });\n});\n\ndescribe('V2TableRestoredProjection', () => {\n  it('removes a table trash entry after restore', async () => {\n    const prisma = {\n      trash: {\n        deleteMany: vi.fn().mockResolvedValue({ count: 1 }),\n      },\n    };\n\n    const projection = new V2TableRestoredProjection(prisma as never);\n    const context = {\n      actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(),\n    };\n    const event = TableRestored.create({\n      tableId: TableId.create('tblaaaaaaaaaaaaaaaa')._unsafeUnwrap(),\n      baseId: BaseId.create('bseaaaaaaaaaaaaaaaa')._unsafeUnwrap(),\n      tableName: TableName.create('Restore Me')._unsafeUnwrap(),\n      fieldIds: [],\n      viewIds: [],\n    });\n\n    const result = await projection.handle(context, event);\n\n    expect(result._unsafeUnwrap()).toBeUndefined();\n    expect(prisma.trash.deleteMany).toHaveBeenCalledWith({\n      where: {\n        resourceId: 'tblaaaaaaaaaaaaaaaa',\n        resourceType: ResourceType.Table,\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts",
    "content": "import type { OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { IRecord } from '@teable/core';\nimport { generateOperationId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ResourceType } from '@teable/openapi';\nimport {\n  ProjectionHandler,\n  RecordsDeleted,\n  TableRestored,\n  TableTrashed,\n  TableQueryService,\n  ok,\n  v2CoreTokens,\n  type DomainError,\n  type IEventHandler,\n  type IExecutionContext,\n  type Result,\n} from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { AttachmentsTableService } from '../attachments/attachments-table.service';\nimport type { IDeleteRecordsPayload } from '../undo-redo/operations/delete-records.operation';\nimport { V2ContainerService } from '../v2/v2-container.service';\nimport type { IV2ProjectionRegistrar } from '../v2/v2-projection-registrar';\nimport { TableTrashListener } from './listener/table-trash.listener';\nimport { resolveV2TrashRecordDisplayName } from './v2-trash-record-name';\n\n@ProjectionHandler(RecordsDeleted)\nexport class V2RecordsDeletedTableTrashProjection implements IEventHandler<RecordsDeleted> {\n  constructor(\n    private readonly tableTrashListener: TableTrashListener,\n    private readonly tableQueryService: TableQueryService\n  ) {}\n\n  async handle(\n    context: IExecutionContext,\n    event: RecordsDeleted\n  ): Promise<Result<void, DomainError>> {\n    if (event.recordSnapshots.length === 0) {\n      return ok(undefined);\n    }\n\n    const tableResult = await this.tableQueryService.getById(context, event.tableId);\n    const table = tableResult.isOk() ? tableResult.value : null;\n\n    const records: IDeleteRecordsPayload['records'] = event.recordSnapshots.map((snapshot) => {\n      const record: IDeleteRecordsPayload['records'][number] = {\n        id: snapshot.id,\n        fields: snapshot.fields as IRecord['fields'],\n        autoNumber: snapshot.autoNumber,\n        createdTime: snapshot.createdTime,\n        createdBy: snapshot.createdBy,\n        lastModifiedTime: snapshot.lastModifiedTime,\n        lastModifiedBy: snapshot.lastModifiedBy,\n        order: snapshot.orders,\n      };\n\n      if (table) {\n        const nameResult = resolveV2TrashRecordDisplayName(table, {\n          id: snapshot.id,\n          fields: snapshot.fields,\n        });\n        if (nameResult.isOk() && nameResult.value) {\n          record.name = nameResult.value;\n        }\n      }\n\n      return record;\n    });\n\n    await this.tableTrashListener.recordDeleteListener({\n      operationId: generateOperationId(),\n      windowId: context.windowId,\n      tableId: event.tableId.toString(),\n      userId: context.actorId.toString(),\n      records,\n    });\n\n    return ok(undefined);\n  }\n}\n\n@ProjectionHandler(RecordsDeleted)\nexport class V2RecordsDeletedAttachmentProjection implements IEventHandler<RecordsDeleted> {\n  constructor(private readonly attachmentsTableService: AttachmentsTableService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordsDeleted\n  ): Promise<Result<void, DomainError>> {\n    if (event.recordIds.length === 0) {\n      return ok(undefined);\n    }\n\n    await this.attachmentsTableService.deleteRecords(\n      event.tableId.toString(),\n      event.recordIds.map((id) => id.toString())\n    );\n\n    return ok(undefined);\n  }\n}\n\n@ProjectionHandler(TableTrashed)\nexport class V2TableTrashedProjection implements IEventHandler<TableTrashed> {\n  constructor(private readonly prisma: PrismaService) {}\n\n  async handle(\n    context: IExecutionContext,\n    event: TableTrashed\n  ): Promise<Result<void, DomainError>> {\n    const table = await this.prisma.tableMeta.findUnique({\n      where: { id: event.tableId.toString() },\n      select: { baseId: true, deletedTime: true },\n    });\n\n    if (!table?.deletedTime) {\n      return ok(undefined);\n    }\n\n    await this.prisma.trash.deleteMany({\n      where: {\n        resourceId: event.tableId.toString(),\n        resourceType: ResourceType.Table,\n      },\n    });\n\n    await this.prisma.trash.create({\n      data: {\n        resourceId: event.tableId.toString(),\n        resourceType: ResourceType.Table,\n        parentId: table.baseId,\n        deletedTime: table.deletedTime,\n        deletedBy: context.actorId.toString(),\n      },\n    });\n\n    return ok(undefined);\n  }\n}\n\n@ProjectionHandler(TableRestored)\nexport class V2TableRestoredProjection implements IEventHandler<TableRestored> {\n  constructor(private readonly prisma: PrismaService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: TableRestored\n  ): Promise<Result<void, DomainError>> {\n    await this.prisma.trash.deleteMany({\n      where: {\n        resourceId: event.tableId.toString(),\n        resourceType: ResourceType.Table,\n      },\n    });\n\n    return ok(undefined);\n  }\n}\n\n@Injectable()\nexport class V2TableTrashService implements IV2ProjectionRegistrar, OnModuleInit {\n  private readonly logger = new Logger(V2TableTrashService.name);\n\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly tableTrashListener: TableTrashListener,\n    private readonly attachmentsTableService: AttachmentsTableService,\n    private readonly prisma: PrismaService\n  ) {}\n\n  onModuleInit(): void {\n    this.v2ContainerService.addProjectionRegistrar(this);\n  }\n\n  registerProjections(container: DependencyContainer): void {\n    this.logger.log('Registering V2 trash projections');\n\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n\n    container.registerInstance(\n      V2RecordsDeletedTableTrashProjection,\n      new V2RecordsDeletedTableTrashProjection(this.tableTrashListener, tableQueryService)\n    );\n\n    container.registerInstance(\n      V2RecordsDeletedAttachmentProjection,\n      new V2RecordsDeletedAttachmentProjection(this.attachmentsTableService)\n    );\n    container.registerInstance(V2TableTrashedProjection, new V2TableTrashedProjection(this.prisma));\n    container.registerInstance(\n      V2TableRestoredProjection,\n      new V2TableRestoredProjection(this.prisma)\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/trash/v2-trash-record-name.ts",
    "content": "import {\n  FieldId,\n  RecordId,\n  TableRecord,\n  TableRecordCellValue,\n  err,\n  type DomainError,\n  type Result,\n  type Table,\n} from '@teable/v2-core';\n\nexport interface IV2TrashRecordSnapshotLike {\n  id: string;\n  fields: Record<string, unknown>;\n}\n\nconst buildTableRecordFromSnapshot = (\n  table: Table,\n  snapshot: IV2TrashRecordSnapshotLike\n): Result<TableRecord, DomainError> => {\n  const recordIdResult = RecordId.create(snapshot.id);\n  if (recordIdResult.isErr()) {\n    return err(recordIdResult.error);\n  }\n\n  const fieldValues: Array<{ fieldId: FieldId; value: TableRecordCellValue }> = [];\n  for (const [fieldIdRaw, rawValue] of Object.entries(snapshot.fields)) {\n    const fieldIdResult = FieldId.create(fieldIdRaw);\n    if (fieldIdResult.isErr()) {\n      return err(fieldIdResult.error);\n    }\n\n    const cellValueResult = TableRecordCellValue.create(rawValue);\n    if (cellValueResult.isErr()) {\n      return err(cellValueResult.error);\n    }\n\n    fieldValues.push({\n      fieldId: fieldIdResult.value,\n      value: cellValueResult.value,\n    });\n  }\n\n  return TableRecord.create({\n    id: recordIdResult.value,\n    tableId: table.id(),\n    fieldValues,\n  });\n};\n\nexport const resolveV2TrashRecordDisplayName = (\n  table: Table,\n  snapshot: IV2TrashRecordSnapshotLike\n): Result<string | null, DomainError> => {\n  return buildTableRecordFromSnapshot(table, snapshot).andThen((record) =>\n    record.displayName(table)\n  );\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.controller.ts",
    "content": "import { Controller, Headers, Param, Post, Res } from '@nestjs/common';\nimport type { IRedoVo, IUndoVo } from '@teable/openapi';\nimport type { Response } from 'express';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { UndoRedoService, X_TEABLE_UNDO_REDO_ENGINE_HEADER } from './undo-redo.service';\n\n@Controller('api/table/:tableId/undo-redo')\nexport class UndoRedoController {\n  constructor(private readonly undoRedoService: UndoRedoService) {}\n\n  @Permissions('table|read')\n  @Post('undo')\n  async undo(\n    @Headers('x-window-id') windowId: string,\n    @Param('tableId') tableId: string,\n    @Res({ passthrough: true }) res: Response\n  ): Promise<IUndoVo> {\n    const result = await this.undoRedoService.undo(tableId, windowId);\n    res.setHeader(X_TEABLE_UNDO_REDO_ENGINE_HEADER, result.engine);\n    return result.body;\n  }\n\n  @Permissions('table|read')\n  @Post('redo')\n  async redo(\n    @Headers('x-window-id') windowId: string,\n    @Param('tableId') tableId: string,\n    @Res({ passthrough: true }) res: Response\n  ): Promise<IRedoVo> {\n    const result = await this.undoRedoService.redo(tableId, windowId);\n    res.setHeader(X_TEABLE_UNDO_REDO_ENGINE_HEADER, result.engine);\n    return result.body;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { V2Module } from '../../v2/v2.module';\nimport { UndoRedoStackModule } from '../stack/undo-redo-stack.module';\nimport { UndoRedoController } from './undo-redo.controller';\nimport { UndoRedoService } from './undo-redo.service';\n\n@Module({\n  imports: [UndoRedoStackModule, V2Module],\n  controllers: [UndoRedoController],\n  providers: [UndoRedoService],\n})\nexport class UndoRedoModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { IRedoVo, IUndoVo } from '@teable/openapi';\nimport { RedoCommand, RedoResult, UndoCommand, UndoResult, v2CoreTokens } from '@teable/v2-core';\nimport type { ICommandBus } from '@teable/v2-core';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\nimport { UndoRedoOperationService } from '../stack/undo-redo-operation.service';\nimport { UndoRedoStackService } from '../stack/undo-redo-stack.service';\n\nexport const X_TEABLE_UNDO_REDO_ENGINE_HEADER = 'x-teable-undo-redo-engine';\n\nexport type UndoRedoEngine = 'v1' | 'v2';\n\ntype UndoRedoResponse<T extends IUndoVo | IRedoVo> = {\n  body: T;\n  engine: UndoRedoEngine;\n};\n\n@Injectable()\nexport class UndoRedoService {\n  logger = new Logger(UndoRedoService.name);\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory,\n    private readonly undoRedoStackService: UndoRedoStackService,\n    private readonly undoRedoOperationService: UndoRedoOperationService\n  ) {}\n\n  async undo(tableId: string, windowId: string): Promise<UndoRedoResponse<IUndoVo>> {\n    const v2Result = await this.executeV2UndoRedo(tableId, windowId, 'undo');\n    if (v2Result) {\n      return v2Result;\n    }\n\n    const { operation, push } = await this.undoRedoStackService.popUndo(tableId, windowId);\n\n    if (!operation) {\n      return {\n        body: {\n          status: 'empty',\n        },\n        engine: 'v1',\n      };\n    }\n\n    try {\n      const newOperation = await this.undoRedoOperationService.undo(operation);\n      await push(newOperation);\n    } catch (error: unknown) {\n      if (error instanceof Error) {\n        this.logger.error(error.message, error.stack);\n        return {\n          body: {\n            status: 'failed',\n            errorMessage: error.message,\n          },\n          engine: 'v1',\n        };\n      }\n      this.logger.error('An unknown error occurred');\n      return {\n        body: {\n          status: 'failed',\n          errorMessage: 'An unknown error occurred',\n        },\n        engine: 'v1',\n      };\n    }\n\n    return {\n      body: {\n        status: 'fulfilled',\n      },\n      engine: 'v1',\n    };\n  }\n\n  async redo(tableId: string, windowId: string): Promise<UndoRedoResponse<IRedoVo>> {\n    const v2Result = await this.executeV2UndoRedo(tableId, windowId, 'redo');\n    if (v2Result) {\n      return v2Result;\n    }\n\n    const { operation, push } = await this.undoRedoStackService.popRedo(tableId, windowId);\n    if (!operation) {\n      return {\n        body: {\n          status: 'empty',\n        },\n        engine: 'v1',\n      };\n    }\n\n    try {\n      const newOperation = await this.undoRedoOperationService.redo(operation);\n      await push(newOperation);\n    } catch (error: unknown) {\n      if (error instanceof Error) {\n        this.logger.error(error.message, error.stack);\n        return {\n          body: {\n            status: 'failed',\n            errorMessage: error.message,\n          },\n          engine: 'v1',\n        };\n      }\n      this.logger.error('An unknown error occurred');\n      return {\n        body: {\n          status: 'failed',\n          errorMessage: 'An unknown error occurred',\n        },\n        engine: 'v1',\n      };\n    }\n\n    return {\n      body: {\n        status: 'fulfilled',\n      },\n      engine: 'v1',\n    };\n  }\n\n  private async executeV2UndoRedo(\n    tableId: string,\n    windowId: string,\n    mode: 'undo' | 'redo'\n  ): Promise<UndoRedoResponse<IUndoVo | IRedoVo> | undefined> {\n    try {\n      const container = await this.v2ContainerService.getContainer();\n      const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n      const context = await this.v2ContextFactory.createContext();\n      context.windowId = windowId;\n\n      const commandResult =\n        mode === 'undo'\n          ? UndoCommand.create({ tableId, windowId })\n          : RedoCommand.create({ tableId, windowId });\n\n      if (commandResult.isErr()) {\n        return {\n          body: {\n            status: 'failed',\n            errorMessage: commandResult.error.message,\n          },\n          engine: 'v2',\n        };\n      }\n\n      const executeResult = await commandBus.execute<\n        UndoCommand | RedoCommand,\n        UndoResult | RedoResult\n      >(context, commandResult.value);\n      if (executeResult.isErr()) {\n        return {\n          body: {\n            status: 'failed',\n            errorMessage: executeResult.error.message,\n          },\n          engine: 'v2',\n        };\n      }\n\n      if (!executeResult.value.entry) {\n        return undefined;\n      }\n\n      return {\n        body: {\n          status: 'fulfilled',\n        },\n        engine: 'v2',\n      };\n    } catch (error: unknown) {\n      if (error instanceof Error) {\n        this.logger.error(error.message, error.stack);\n        return {\n          body: {\n            status: 'failed',\n            errorMessage: error.message,\n          },\n          engine: 'v2',\n        };\n      }\n\n      this.logger.error('An unknown error occurred');\n      return {\n        body: {\n          status: 'failed',\n          errorMessage: 'An unknown error occurred',\n        },\n        engine: 'v2',\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/convert-field-v2.operation.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { IConvertFieldRo, IFieldVo, IOtOperation } from '@teable/core';\nimport type { IConvertFieldV2Operation } from '../../../cache/types';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport type { FieldOpenApiV2Service } from '../../field/open-api/field-open-api-v2.service';\n\nexport class ConvertFieldV2Operation {\n  constructor(private readonly fieldOpenApiV2Service: FieldOpenApiV2Service) {}\n\n  private isComputedField(field: IFieldVo) {\n    return (\n      field.type === FieldType.Formula ||\n      Boolean(field.isLookup) ||\n      Boolean(field.isConditionalLookup) ||\n      field.type === FieldType.Rollup ||\n      field.type === FieldType.ConditionalRollup\n    );\n  }\n\n  private shouldReplayUndo(oldField: IFieldVo) {\n    return !this.isComputedField(oldField);\n  }\n\n  private shouldReplayRedo(newField: IFieldVo) {\n    return !this.isComputedField(newField);\n  }\n\n  private extractLinkDisplayValue(value: unknown): unknown {\n    if (value == null) {\n      return null;\n    }\n    if (Array.isArray(value)) {\n      const titles = value\n        .map((item) =>\n          item &&\n          typeof item === 'object' &&\n          typeof (item as Record<string, unknown>).title === 'string'\n            ? (item as Record<string, unknown>).title\n            : undefined\n        )\n        .filter((item): item is string => item != null);\n      if (!titles.length) {\n        return null;\n      }\n      return titles.join(', ');\n    }\n    if (value && typeof value === 'object') {\n      const title = (value as Record<string, unknown>).title;\n      if (typeof title === 'string') {\n        return title;\n      }\n    }\n    return null;\n  }\n\n  private applyLinkToTextReplayFallback(modifiedOps: IOpsMap): IOpsMap {\n    const next: IOpsMap = {};\n    for (const [tableId, recordMap] of Object.entries(modifiedOps)) {\n      const nextRecordMap: IOpsMap[string] = {};\n      for (const [recordId, ops] of Object.entries(recordMap)) {\n        nextRecordMap[recordId] = ops.map((op) => {\n          if (op.oi != null) {\n            return op;\n          }\n          const fallback = this.extractLinkDisplayValue(op.od);\n          if (fallback == null) {\n            return op;\n          }\n          return {\n            ...(op as IOtOperation),\n            oi: fallback,\n          };\n        });\n      }\n      next[tableId] = nextRecordMap;\n    }\n    return next;\n  }\n\n  private toConvertFieldRo(field: IFieldVo): IConvertFieldRo {\n    const ro: IConvertFieldRo = {\n      type: field.type,\n      name: field.name,\n      description: field.description ?? null,\n      notNull: Boolean(field.notNull),\n      unique: Boolean(field.unique),\n      isLookup: Boolean(field.isLookup),\n      isConditionalLookup: Boolean(field.isConditionalLookup),\n      options: field.options,\n      lookupOptions: field.lookupOptions,\n      aiConfig: field.aiConfig ?? null,\n      ...(field.dbFieldName ? { dbFieldName: field.dbFieldName } : {}),\n    };\n\n    if (field.type === FieldType.Link && ro.options && typeof ro.options === 'object') {\n      const linkOptions = { ...(ro.options as Record<string, unknown>) };\n      if (!Object.prototype.hasOwnProperty.call(linkOptions, 'isOneWay')) {\n        linkOptions.isOneWay = false;\n      }\n      ro.options = linkOptions;\n    }\n\n    return ro;\n  }\n\n  private async convertWithV2(\n    tableId: string,\n    fieldId: string,\n    field: IFieldVo,\n    mode: 'undo' | 'redo'\n  ) {\n    await this.fieldOpenApiV2Service.convertField(tableId, fieldId, this.toConvertFieldRo(field), {\n      emitOperation: false,\n      suppressWindowId: true,\n      undoRedoMode: mode,\n    });\n  }\n\n  async undo(operation: IConvertFieldV2Operation) {\n    const { tableId } = operation.params;\n    const { oldField, modifiedOps } = operation.result;\n    await this.convertWithV2(tableId, oldField.id, oldField, 'undo');\n    if (modifiedOps && this.shouldReplayUndo(oldField)) {\n      await this.fieldOpenApiV2Service.replayModifiedOps(modifiedOps as IOpsMap, 'old', 'undo');\n    }\n    return operation;\n  }\n\n  async redo(operation: IConvertFieldV2Operation) {\n    const { tableId } = operation.params;\n    const { oldField, newField, modifiedOps } = operation.result;\n    await this.convertWithV2(tableId, newField.id, newField, 'redo');\n    if (modifiedOps && this.shouldReplayRedo(newField)) {\n      const replayOps =\n        oldField.type === FieldType.Link &&\n        (newField.type === FieldType.SingleLineText || newField.type === FieldType.LongText)\n          ? this.applyLinkToTextReplayFallback(modifiedOps as IOpsMap)\n          : (modifiedOps as IOpsMap);\n      await this.fieldOpenApiV2Service.replayModifiedOps(replayOps, 'new', 'redo');\n    }\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/convert-field.operation.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { IFieldVo, IOtOperation } from '@teable/core';\nimport type { PrismaService } from '@teable/db-main-prisma';\nimport type { IConvertFieldOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { IThresholdConfig } from '../../../configs/threshold.config';\nimport type { IOpsMap } from '../../calculation/utils/compose-maps';\nimport { createFieldInstanceByVo } from '../../field/model/factory';\nimport type { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\n\nexport interface IConvertFieldPayload {\n  windowId: string;\n  tableId: string;\n  userId: string;\n  oldField: IFieldVo;\n  newField: IFieldVo;\n  modifiedOps?: IOpsMap;\n  references?: string[];\n  supplementChange?: {\n    tableId: string;\n    newField: IFieldVo;\n    oldField: IFieldVo;\n  };\n}\n\nexport class ConvertFieldOperation {\n  constructor(\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly prismaService: PrismaService,\n    private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  async event2Operation(payload: IConvertFieldPayload): Promise<IConvertFieldOperation> {\n    return {\n      name: OperationName.ConvertField,\n      params: {\n        tableId: payload.tableId,\n      },\n      result: {\n        oldField: payload.oldField,\n        newField: payload.newField,\n        modifiedOps: payload.modifiedOps,\n        references: payload.references,\n        supplementChange: payload.supplementChange,\n      },\n    };\n  }\n\n  // convert oi to od, od to oi in IOtOperation\n  private revertOpsMap(opsMap: IOpsMap) {\n    return Object.entries(opsMap).reduce<IOpsMap>((acc, [key, opsKeyMap]) => {\n      acc[key] = Object.entries(opsKeyMap).reduce<Record<string, IOtOperation[]>>(\n        (opAcc, [opsKey, op]) => {\n          opAcc[opsKey] = op.map(\n            (singleOp) =>\n              ({\n                ...singleOp,\n                oi: singleOp.od,\n                od: singleOp.oi,\n              }) as IOtOperation\n          );\n          return opAcc;\n        },\n        {}\n      );\n      return acc;\n    }, {});\n  }\n\n  private isLinkForeignTableChanged(oldField: IFieldVo, newField: IFieldVo) {\n    if (oldField.type !== FieldType.Link || newField.type !== FieldType.Link) {\n      return false;\n    }\n    if (oldField.isLookup || newField.isLookup) {\n      return false;\n    }\n    const oldOptions =\n      oldField.options && typeof oldField.options === 'object'\n        ? (oldField.options as Record<string, unknown>)\n        : undefined;\n    const newOptions =\n      newField.options && typeof newField.options === 'object'\n        ? (newField.options as Record<string, unknown>)\n        : undefined;\n    const oldForeignTableId =\n      oldOptions && typeof oldOptions.foreignTableId === 'string'\n        ? oldOptions.foreignTableId\n        : undefined;\n    const newForeignTableId =\n      newOptions && typeof newOptions.foreignTableId === 'string'\n        ? newOptions.foreignTableId\n        : undefined;\n    return Boolean(\n      oldForeignTableId && newForeignTableId && oldForeignTableId !== newForeignTableId\n    );\n  }\n\n  private async forceLookupRelatedError(linkFieldId: string) {\n    const dependentLookupFields = await this.prismaService.txClient().field.findMany({\n      where: {\n        lookupLinkedFieldId: linkFieldId,\n        deletedTime: null,\n        OR: [{ isLookup: true }, { type: FieldType.Rollup }, { type: FieldType.ConditionalRollup }],\n      },\n      select: { id: true },\n    });\n\n    if (!dependentLookupFields.length) {\n      return;\n    }\n\n    await this.prismaService.txClient().field.updateMany({\n      where: {\n        id: { in: dependentLookupFields.map((item) => item.id) },\n      },\n      data: {\n        hasError: true,\n      },\n    });\n  }\n\n  async undo(operation: IConvertFieldOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { oldField, newField, modifiedOps, references, supplementChange } = result;\n    await this.prismaService.$tx(\n      async () => {\n        await this.fieldOpenApiService.performConvertField({\n          tableId,\n          oldField: createFieldInstanceByVo(newField),\n          newField: createFieldInstanceByVo(oldField),\n          modifiedOps: modifiedOps && this.revertOpsMap(modifiedOps),\n          supplementChange: supplementChange && {\n            tableId: supplementChange.tableId,\n            oldField: createFieldInstanceByVo(supplementChange.newField),\n            newField: createFieldInstanceByVo(supplementChange.oldField),\n          },\n        });\n\n        if (references) {\n          await this.fieldOpenApiService.restoreReference(references);\n        }\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    return operation;\n  }\n\n  async redo(operation: IConvertFieldOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { oldField, newField, modifiedOps, references, supplementChange } = result;\n    await this.prismaService.$tx(\n      async () => {\n        await this.fieldOpenApiService.performConvertField({\n          tableId,\n          oldField: createFieldInstanceByVo(oldField),\n          newField: createFieldInstanceByVo(newField),\n          modifiedOps,\n          supplementChange: supplementChange && {\n            tableId: supplementChange.tableId,\n            oldField: createFieldInstanceByVo(supplementChange.oldField),\n            newField: createFieldInstanceByVo(supplementChange.newField),\n          },\n        });\n\n        if (references) {\n          await this.fieldOpenApiService.restoreReference(references);\n        }\n\n        if (this.isLinkForeignTableChanged(oldField, newField)) {\n          await this.forceLookupRelatedError(newField.id);\n        }\n      },\n      { timeout: this.thresholdConfig.bigTransactionTimeout }\n    );\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/create-fields.operation.ts",
    "content": "import { FieldKeyType } from '@teable/core';\nimport type { IColumnMeta, IFieldVo } from '@teable/core';\nimport type { ICreateFieldsOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\n\nexport interface ICreateFieldsPayload {\n  windowId: string;\n  tableId: string;\n  userId: string;\n  fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];\n  records?: {\n    id: string;\n    fields: Record<string, unknown>;\n  }[];\n}\n\nexport class CreateFieldsOperation {\n  constructor(\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly recordOpenApiService: RecordOpenApiService\n  ) {}\n\n  async event2Operation(payload: ICreateFieldsPayload): Promise<ICreateFieldsOperation> {\n    return {\n      name: OperationName.CreateFields,\n      params: {\n        tableId: payload.tableId,\n      },\n      result: {\n        fields: payload.fields,\n        records: payload.records,\n      },\n    };\n  }\n\n  async undo(operation: ICreateFieldsOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { fields } = result;\n\n    await this.fieldOpenApiService.deleteFields(\n      tableId,\n      fields.map((field) => field.id)\n    );\n\n    return operation;\n  }\n\n  async redo(operation: ICreateFieldsOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { fields, records } = result;\n\n    await this.fieldOpenApiService.createFields(tableId, fields);\n\n    if (records) {\n      await this.recordOpenApiService.updateRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records: records,\n      });\n    }\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/create-records.operation.ts",
    "content": "import { FieldKeyType } from '@teable/core';\nimport type { ICreateRecordsRo, IRecordsVo } from '@teable/openapi';\nimport { OperationName, type ICreateRecordsOperation } from '../../../cache/types';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport type { RecordService } from '../../record/record.service';\nimport type { TableDomainQueryService } from '../../table-domain';\n\nexport interface ICreateRecordsPayload {\n  reqParams: { tableId: string };\n  reqBody: ICreateRecordsRo;\n  resolveData: IRecordsVo;\n}\n\nexport class CreateRecordsOperation {\n  constructor(\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly recordService: RecordService,\n    private readonly tableDomainQueryService: TableDomainQueryService\n  ) {}\n\n  async event2Operation(payload: ICreateRecordsPayload): Promise<ICreateRecordsOperation> {\n    const { reqParams, resolveData } = payload;\n    const { tableId } = reqParams;\n    const { records = [] } = resolveData;\n\n    const recordIds = records.map((record) => record.id);\n\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    const indexes = await this.recordService.getRecordIndexes(table, recordIds);\n    return {\n      name: OperationName.CreateRecords,\n      params: {\n        tableId: tableId,\n      },\n      result: {\n        records: records.map((r, i) => ({ ...r, order: indexes?.[i] })),\n      },\n    };\n  }\n\n  async undo(operation: ICreateRecordsOperation) {\n    const { params, result } = operation;\n\n    const recordIds = result.records.map((record) => record.id);\n\n    await this.recordOpenApiService.deleteRecords(params.tableId, recordIds);\n    return operation;\n  }\n\n  async redo(operation: ICreateRecordsOperation) {\n    const { params, result } = operation;\n\n    await this.recordOpenApiService.multipleCreateRecords(params.tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records: result.records,\n    });\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/create-view.operation.ts",
    "content": "import type { IViewRo, IViewVo } from '@teable/core';\nimport type { ICreateViewOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport type { ViewService } from '../../view/view.service';\n\nexport interface ICreateViewPayload {\n  reqParams: { tableId: string };\n  reqBody: IViewRo;\n  resolveData: IViewVo;\n}\n\nexport class CreateViewOperation {\n  constructor(\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly viewService: ViewService\n  ) {}\n\n  async event2Operation(payload: ICreateViewPayload): Promise<ICreateViewOperation> {\n    return {\n      name: OperationName.CreateView,\n      params: {\n        tableId: payload.reqParams.tableId,\n      },\n      result: {\n        view: payload.resolveData,\n      },\n    };\n  }\n\n  async undo(operation: ICreateViewOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { view } = result;\n\n    await this.viewOpenApiService.deleteView(tableId, view.id);\n    return operation;\n  }\n\n  async redo(operation: ICreateViewOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { view } = result;\n\n    await this.viewService.restoreView(tableId, view.id);\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts",
    "content": "import { FieldKeyType } from '@teable/core';\nimport type { PrismaService } from '@teable/db-main-prisma';\nimport type { IDeleteFieldsOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport type { ICreateFieldsPayload } from './create-fields.operation';\n\nexport type IDeleteFieldsPayload = ICreateFieldsPayload & { operationId: string };\nexport class DeleteFieldsOperation {\n  constructor(\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  async event2Operation(payload: IDeleteFieldsPayload): Promise<IDeleteFieldsOperation> {\n    return {\n      name: OperationName.DeleteFields,\n      params: {\n        tableId: payload.tableId,\n      },\n      result: {\n        fields: payload.fields,\n        records: payload.records,\n      },\n      operationId: payload.operationId,\n    };\n  }\n\n  async undo(operation: IDeleteFieldsOperation) {\n    const { params, result, operationId = '' } = operation;\n    const { tableId } = params;\n    const { fields, records } = result;\n\n    const count = await this.prismaService.tableTrash.count({\n      where: { id: operationId },\n    });\n\n    if (operationId && Number(count) === 0) return operation;\n\n    await this.fieldOpenApiService.createFields(tableId, fields);\n\n    if (records) {\n      await this.recordOpenApiService.updateRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records,\n      });\n    }\n\n    if (operationId) {\n      await this.prismaService.tableTrash.delete({\n        where: { id: operationId },\n      });\n    }\n    return operation;\n  }\n\n  async redo(operation: IDeleteFieldsOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { fields } = result;\n\n    await this.fieldOpenApiService.deleteFields(\n      tableId,\n      fields.map((field) => field.id)\n    );\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts",
    "content": "import type { IRecord } from '@teable/core';\nimport { FieldKeyType } from '@teable/core';\nimport type { PrismaService } from '@teable/db-main-prisma';\nimport type { IDeleteRecordsOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { IThresholdConfig } from '../../../configs/threshold.config';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\n\nexport interface IDeleteRecordsPayload {\n  operationId: string;\n  windowId?: string;\n  tableId: string;\n  userId: string;\n  records: (IRecord & { order?: Record<string, number> })[];\n}\n\nexport class DeleteRecordsOperation {\n  constructor(\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly prismaService: PrismaService,\n    private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  async event2Operation(payload: IDeleteRecordsPayload): Promise<IDeleteRecordsOperation> {\n    return {\n      name: OperationName.DeleteRecords,\n      params: {\n        tableId: payload.tableId,\n      },\n      result: {\n        records: payload.records,\n      },\n      operationId: payload.operationId,\n    };\n  }\n\n  async undo(operation: IDeleteRecordsOperation) {\n    const { params, result, operationId = '' } = operation;\n\n    const count = await this.prismaService.tableTrash.count({\n      where: { id: operationId },\n    });\n\n    if (operationId && Number(count) === 0) return operation;\n\n    await this.prismaService.$tx(\n      async (prisma) => {\n        await this.recordOpenApiService.multipleCreateRecords(params.tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: result.records,\n        });\n\n        if (operationId) {\n          const recordIds = result.records.map((record) => record.id);\n\n          await prisma.tableTrash.delete({\n            where: { id: operationId },\n          });\n          await prisma.recordTrash.deleteMany({\n            where: {\n              tableId: params.tableId,\n              recordId: { in: recordIds },\n            },\n          });\n        }\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n\n    return operation;\n  }\n\n  async redo(operation: IDeleteRecordsOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n\n    await this.recordOpenApiService.deleteRecords(\n      tableId,\n      result.records.map((record) => record.id)\n    );\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/delete-view.operation.ts",
    "content": "import type { PrismaService } from '@teable/db-main-prisma';\nimport type { IDeleteViewOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport type { ViewService } from '../../view/view.service';\n\nexport interface IDeleteViewPayload {\n  operationId: string;\n  windowId: string;\n  tableId: string;\n  viewId: string;\n  userId: string;\n}\n\nexport class DeleteViewOperation {\n  constructor(\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly viewService: ViewService,\n    private readonly prismaService: PrismaService\n  ) {}\n\n  async event2Operation(payload: IDeleteViewPayload): Promise<IDeleteViewOperation> {\n    return {\n      name: OperationName.DeleteView,\n      params: {\n        tableId: payload.tableId,\n        viewId: payload.viewId,\n      },\n      operationId: payload.operationId,\n    };\n  }\n\n  async undo(operation: IDeleteViewOperation) {\n    const { params, operationId = '' } = operation;\n    const { tableId, viewId } = params;\n\n    const count = await this.prismaService.tableTrash.count({\n      where: { id: operationId },\n    });\n\n    if (operationId && Number(count) === 0) return operation;\n\n    await this.prismaService.$tx(async (prisma) => {\n      await this.viewService.restoreView(tableId, viewId);\n      await prisma.tableTrash.delete({\n        where: { id: operationId },\n      });\n    });\n    return operation;\n  }\n\n  async redo(operation: IDeleteViewOperation) {\n    const { params } = operation;\n    const { tableId, viewId } = params;\n\n    await this.viewOpenApiService.deleteView(tableId, viewId);\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/paste-selection.operation.ts",
    "content": "import type { IColumnMeta, IFieldVo, IRecord } from '@teable/core';\nimport { FieldKeyType } from '@teable/core';\nimport { keyBy } from 'lodash';\nimport { OperationName } from '../../../cache/types';\nimport type { IPasteSelectionOperation } from '../../../cache/types';\nimport type { ICellContext } from '../../calculation/utils/changes';\nimport type { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\n\nexport interface IPasteSelectionPayload {\n  windowId: string;\n  userId: string;\n  tableId: string;\n  updateRecords?: {\n    recordIds: string[];\n    fieldIds: string[];\n    cellContexts: ICellContext[];\n  };\n  newFields?: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];\n  newRecords?: (IRecord & { order?: Record<string, number> })[];\n}\n\nexport class PasteSelectionOperation {\n  constructor(\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly fieldOpenApiService: FieldOpenApiService\n  ) {}\n\n  async event2Operation(payload: IPasteSelectionPayload): Promise<IPasteSelectionOperation> {\n    return {\n      name: OperationName.PasteSelection,\n      params: {\n        tableId: payload.tableId,\n      },\n      result: {\n        updateRecords: payload.updateRecords,\n        newFields: payload.newFields,\n        newRecords: payload.newRecords,\n      },\n    };\n  }\n\n  async undo(operation: IPasteSelectionOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { updateRecords, newRecords, newFields } = result;\n\n    if (updateRecords) {\n      const { cellContexts, recordIds, fieldIds } = updateRecords;\n\n      const cellContextMap = keyBy(\n        cellContexts,\n        (cellContext) => `${cellContext.recordId}-${cellContext.fieldId}`\n      );\n\n      const records = recordIds.map((recordId) => ({\n        id: recordId,\n        fields: fieldIds.reduce<Record<string, unknown>>((acc, fieldId) => {\n          const key = `${recordId}-${fieldId}`;\n          const cellContext = cellContextMap[key];\n          if (cellContext) {\n            acc[fieldId] = cellContext.oldValue == null ? null : cellContext.oldValue;\n          }\n          return acc;\n        }, {}),\n      }));\n\n      await this.recordOpenApiService.updateRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records,\n      });\n    }\n\n    if (newFields && newFields.length > 0) {\n      await this.fieldOpenApiService.deleteFields(\n        tableId,\n        newFields.map((field) => field.id)\n      );\n    }\n\n    if (newRecords && newRecords.length > 0) {\n      await this.recordOpenApiService.deleteRecords(\n        tableId,\n        newRecords.map((r) => r.id)\n      );\n    }\n\n    return operation;\n  }\n\n  async redo(operation: IPasteSelectionOperation) {\n    const { params, result } = operation;\n    const { tableId } = params;\n    const { updateRecords, newRecords, newFields } = result;\n\n    if (newFields && newFields.length > 0) {\n      await this.fieldOpenApiService.createFields(tableId, newFields);\n    }\n\n    if (newRecords && newRecords.length > 0) {\n      await this.recordOpenApiService.multipleCreateRecords(params.tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records: newRecords,\n      });\n    }\n\n    if (updateRecords) {\n      const { cellContexts, recordIds, fieldIds } = updateRecords;\n\n      const cellContextMap = keyBy(\n        cellContexts,\n        (cellContext) => `${cellContext.recordId}-${cellContext.fieldId}`\n      );\n\n      const records = recordIds.map((recordId) => ({\n        id: recordId,\n        fields: fieldIds.reduce<Record<string, unknown>>((acc, fieldId) => {\n          const key = `${recordId}-${fieldId}`;\n          const cellContext = cellContextMap[key];\n          if (cellContext) {\n            acc[fieldId] = cellContext.newValue == null ? null : cellContext.newValue;\n          }\n          return acc;\n        }, {}),\n      }));\n\n      await this.recordOpenApiService.updateRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records,\n      });\n    }\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/update-records-order.operation.ts",
    "content": "import type { IUpdateRecordsOrderOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\n\nexport interface IUpdateRecordsOrderPayload {\n  windowId: string;\n  tableId: string;\n  viewId: string;\n  userId: string;\n  recordIds: string[];\n  orderIndexesBefore?: Record<string, number>[];\n  orderIndexesAfter?: Record<string, number>[];\n}\n\nexport class UpdateRecordsOrderOperation {\n  constructor(private readonly viewOpenApiService: ViewOpenApiService) {}\n\n  async event2Operation(\n    payload: IUpdateRecordsOrderPayload\n  ): Promise<IUpdateRecordsOrderOperation> {\n    const { tableId, viewId, recordIds, orderIndexesAfter, orderIndexesBefore } = payload;\n\n    const ordersMap = recordIds.reduce<{\n      [recordId: string]: {\n        newOrder?: Record<string, number>;\n        oldOrder?: Record<string, number>;\n      };\n    }>((acc, recordId, index) => {\n      if (orderIndexesAfter?.[index] == orderIndexesBefore?.[index]) {\n        return acc;\n      }\n\n      acc[recordId] = {\n        newOrder: orderIndexesAfter?.[index],\n        oldOrder: orderIndexesBefore?.[index],\n      };\n      return acc;\n    }, {});\n\n    return {\n      name: OperationName.UpdateRecordsOrder,\n      params: {\n        tableId,\n        viewId,\n        recordIds,\n      },\n      result: {\n        ordersMap,\n      },\n    };\n  }\n\n  // TODO: filter out fields that are not in the record, filter out computed fields\n  async undo(operation: IUpdateRecordsOrderOperation) {\n    const { params, result } = operation;\n    const { tableId, viewId, recordIds } = params;\n    const { ordersMap } = result;\n\n    const records = recordIds.map((recordId) => ({\n      id: recordId,\n      order: ordersMap?.[recordId]?.oldOrder,\n    }));\n    await this.viewOpenApiService.updateRecordIndexes(tableId, viewId, records);\n    return operation;\n  }\n\n  async redo(operation: IUpdateRecordsOrderOperation) {\n    const { params, result } = operation;\n    const { tableId, viewId, recordIds } = params;\n    const { ordersMap } = result;\n\n    const records = recordIds.map((recordId) => ({\n      id: recordId,\n      order: ordersMap?.[recordId]?.newOrder,\n    }));\n    await this.viewOpenApiService.updateRecordIndexes(tableId, viewId, records);\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/update-records.operation.ts",
    "content": "import { FieldKeyType } from '@teable/core';\nimport { keyBy } from 'lodash';\nimport type { IUpdateRecordsOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { ICellContext } from '../../calculation/utils/changes';\nimport type { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport type { RecordService } from '../../record/record.service';\n\nexport interface IUpdateRecordsPayload {\n  windowId: string;\n  tableId: string;\n  userId: string;\n  recordIds: string[];\n  fieldIds: string[];\n  cellContexts: ICellContext[];\n  orderIndexesBefore?: Record<string, number>[];\n  orderIndexesAfter?: Record<string, number>[];\n}\n\nexport class UpdateRecordsOperation {\n  constructor(\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly recordService: RecordService\n  ) {}\n\n  async event2Operation(payload: IUpdateRecordsPayload): Promise<IUpdateRecordsOperation> {\n    const { tableId, recordIds, fieldIds, cellContexts, orderIndexesAfter, orderIndexesBefore } =\n      payload;\n\n    const ordersMap = recordIds.reduce<{\n      [recordId: string]: {\n        newOrder?: Record<string, number>;\n        oldOrder?: Record<string, number>;\n      };\n    }>((acc, recordId, index) => {\n      if (orderIndexesAfter?.[index] == orderIndexesBefore?.[index]) {\n        return acc;\n      }\n\n      acc[recordId] = {\n        newOrder: orderIndexesAfter?.[index],\n        oldOrder: orderIndexesBefore?.[index],\n      };\n      return acc;\n    }, {});\n\n    return {\n      name: OperationName.UpdateRecords,\n      params: {\n        tableId,\n        recordIds,\n        fieldIds,\n      },\n      result: {\n        cellContexts,\n        ordersMap,\n      },\n    };\n  }\n\n  // TODO: filter out fields that are not in the record, filter out computed fields\n  async undo(operation: IUpdateRecordsOperation) {\n    const { params, result } = operation;\n    const { tableId, recordIds, fieldIds } = params;\n    const { cellContexts, ordersMap } = result;\n\n    const cellContextMap = keyBy(\n      cellContexts,\n      (cellContext) => `${cellContext.recordId}-${cellContext.fieldId}`\n    );\n\n    const records = recordIds.map((recordId) => ({\n      id: recordId,\n      fields: fieldIds.reduce<Record<string, unknown>>((acc, fieldId) => {\n        const key = `${recordId}-${fieldId}`;\n        const cellContext = cellContextMap[key];\n        if (cellContext) {\n          acc[fieldId] = cellContext.oldValue == null ? null : cellContext.oldValue;\n        }\n        return acc;\n      }, {}),\n      order: ordersMap?.[recordId]?.oldOrder,\n    }));\n\n    await this.recordService.updateRecordIndexes(tableId, records);\n\n    await this.recordOpenApiService.updateRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records,\n    });\n\n    return operation;\n  }\n\n  async redo(operation: IUpdateRecordsOperation) {\n    const { params, result } = operation;\n    const { tableId, recordIds, fieldIds } = params;\n    const { cellContexts, ordersMap } = result;\n\n    const cellContextMap = keyBy(\n      cellContexts,\n      (cellContext) => `${cellContext.recordId}-${cellContext.fieldId}`\n    );\n\n    const records = recordIds.map((recordId) => ({\n      id: recordId,\n      fields: fieldIds.reduce<Record<string, unknown>>((acc, fieldId) => {\n        const key = `${recordId}-${fieldId}`;\n        const cellContext = cellContextMap[key];\n        if (cellContext) {\n          acc[fieldId] = cellContext.newValue == null ? null : cellContext.newValue;\n        }\n        return acc;\n      }, {}),\n      order: ordersMap?.[recordId]?.newOrder,\n    }));\n\n    await this.recordService.updateRecordIndexes(tableId, records);\n\n    await this.recordOpenApiService.updateRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records,\n    });\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/operations/update-view.operation.ts",
    "content": "import type { IOtOperation, IViewPropertyKeys } from '@teable/core';\nimport Sharedb from 'sharedb';\nimport type { IUpdateViewOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport type { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\n\nexport interface IUpdateViewPayload {\n  tableId: string;\n  windowId: string;\n  viewId: string;\n  userId: string;\n  byKey?: {\n    key: IViewPropertyKeys;\n    newValue: unknown;\n    oldValue: unknown;\n  };\n  byOps?: IOtOperation[];\n}\n\nexport class UpdateViewOperation {\n  constructor(private readonly viewOpenApiService: ViewOpenApiService) {}\n\n  async event2Operation(payload: IUpdateViewPayload): Promise<IUpdateViewOperation> {\n    const { byKey, byOps } = payload;\n    return {\n      name: OperationName.UpdateView,\n      params: {\n        tableId: payload.tableId,\n        viewId: payload.viewId,\n      },\n      result: {\n        byKey,\n        byOps,\n      },\n    };\n  }\n\n  async undo(operation: IUpdateViewOperation) {\n    const { params, result } = operation;\n    const { tableId, viewId } = params;\n    const { byKey, byOps } = result;\n\n    if (byKey) {\n      const { key, oldValue } = byKey;\n      await this.viewOpenApiService.setViewProperty(tableId, viewId, key, oldValue);\n    }\n\n    if (byOps) {\n      await this.viewOpenApiService.updateViewByOps(\n        tableId,\n        viewId,\n        Sharedb.types.map['json0'].invert?.(byOps)\n      );\n    }\n\n    return operation;\n  }\n\n  async redo(operation: IUpdateViewOperation) {\n    const { params, result } = operation;\n    const { tableId, viewId } = params;\n    const { byKey, byOps } = result;\n\n    if (byKey) {\n      const { key, newValue } = byKey;\n      await this.viewOpenApiService.setViewProperty(tableId, viewId, key, newValue);\n    }\n\n    if (byOps) {\n      await this.viewOpenApiService.updateViewByOps(tableId, viewId, byOps);\n    }\n\n    return operation;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { assertNever } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IUndoRedoOperation } from '../../../cache/types';\nimport { OperationName } from '../../../cache/types';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { Events, IEventRawContext } from '../../../event-emitter/events';\nimport { FieldOpenApiV2Service } from '../../field/open-api/field-open-api-v2.service';\nimport { FieldOpenApiService } from '../../field/open-api/field-open-api.service';\nimport { RecordOpenApiService } from '../../record/open-api/record-open-api.service';\nimport { RecordService } from '../../record/record.service';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { ViewOpenApiService } from '../../view/open-api/view-open-api.service';\nimport { ViewService } from '../../view/view.service';\nimport { ConvertFieldV2Operation } from '../operations/convert-field-v2.operation';\nimport { ConvertFieldOperation, IConvertFieldPayload } from '../operations/convert-field.operation';\nimport { CreateFieldsOperation, ICreateFieldsPayload } from '../operations/create-fields.operation';\nimport type { ICreateRecordsPayload } from '../operations/create-records.operation';\nimport { CreateRecordsOperation } from '../operations/create-records.operation';\nimport type { ICreateViewPayload } from '../operations/create-view.operation';\nimport { CreateViewOperation } from '../operations/create-view.operation';\nimport { DeleteFieldsOperation, IDeleteFieldsPayload } from '../operations/delete-fields.operation';\nimport {\n  DeleteRecordsOperation,\n  IDeleteRecordsPayload,\n} from '../operations/delete-records.operation';\nimport { IDeleteViewPayload, DeleteViewOperation } from '../operations/delete-view.operation';\nimport {\n  IPasteSelectionPayload,\n  PasteSelectionOperation,\n} from '../operations/paste-selection.operation';\nimport {\n  IUpdateRecordsOrderPayload,\n  UpdateRecordsOrderOperation,\n} from '../operations/update-records-order.operation';\nimport {\n  UpdateRecordsOperation,\n  IUpdateRecordsPayload,\n} from '../operations/update-records.operation';\nimport { IUpdateViewPayload, UpdateViewOperation } from '../operations/update-view.operation';\nimport { UndoRedoStackService } from './undo-redo-stack.service';\n\n@Injectable()\nexport class UndoRedoOperationService {\n  createRecords: CreateRecordsOperation;\n  deleteRecords: DeleteRecordsOperation;\n  updateRecords: UpdateRecordsOperation;\n  updateRecordsOrder: UpdateRecordsOrderOperation;\n  createFields: CreateFieldsOperation;\n  deleteFields: DeleteFieldsOperation;\n  convertField: ConvertFieldOperation;\n  convertFieldV2: ConvertFieldV2Operation;\n  pasteSelection: PasteSelectionOperation;\n  deleteView: DeleteViewOperation;\n  createView: CreateViewOperation;\n  updateView: UpdateViewOperation;\n\n  constructor(\n    private readonly undoRedoStackService: UndoRedoStackService,\n    private readonly recordOpenApiService: RecordOpenApiService,\n    private readonly fieldOpenApiService: FieldOpenApiService,\n    private readonly fieldOpenApiV2Service: FieldOpenApiV2Service,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly recordService: RecordService,\n    private readonly viewService: ViewService,\n    private readonly prismaService: PrismaService,\n    private readonly tableDomainQueryService: TableDomainQueryService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {\n    this.createRecords = new CreateRecordsOperation(\n      this.recordOpenApiService,\n      this.recordService,\n      this.tableDomainQueryService\n    );\n    this.deleteRecords = new DeleteRecordsOperation(\n      this.recordOpenApiService,\n      this.prismaService,\n      this.thresholdConfig\n    );\n    this.updateRecords = new UpdateRecordsOperation(this.recordOpenApiService, this.recordService);\n    this.updateRecordsOrder = new UpdateRecordsOrderOperation(this.viewOpenApiService);\n    this.createFields = new CreateFieldsOperation(\n      this.fieldOpenApiService,\n      this.recordOpenApiService\n    );\n    this.deleteFields = new DeleteFieldsOperation(\n      this.fieldOpenApiService,\n      this.recordOpenApiService,\n      this.prismaService\n    );\n    this.convertField = new ConvertFieldOperation(\n      this.fieldOpenApiService,\n      this.prismaService,\n      this.thresholdConfig\n    );\n    this.convertFieldV2 = new ConvertFieldV2Operation(this.fieldOpenApiV2Service);\n    this.pasteSelection = new PasteSelectionOperation(\n      this.recordOpenApiService,\n      this.fieldOpenApiService\n    );\n    this.deleteView = new DeleteViewOperation(\n      this.viewOpenApiService,\n      this.viewService,\n      this.prismaService\n    );\n    this.createView = new CreateViewOperation(this.viewOpenApiService, this.viewService);\n    this.updateView = new UpdateViewOperation(this.viewOpenApiService);\n  }\n\n  async undo(operation: IUndoRedoOperation): Promise<IUndoRedoOperation> {\n    switch (operation.name) {\n      case OperationName.CreateRecords:\n        return this.createRecords.undo(operation);\n      case OperationName.DeleteRecords:\n        return this.deleteRecords.undo(operation);\n      case OperationName.UpdateRecords:\n        return this.updateRecords.undo(operation);\n      case OperationName.UpdateRecordsOrder:\n        return this.updateRecordsOrder.undo(operation);\n      case OperationName.CreateFields:\n        return this.createFields.undo(operation);\n      case OperationName.DeleteFields:\n        return this.deleteFields.undo(operation);\n      case OperationName.PasteSelection:\n        return this.pasteSelection.undo(operation);\n      case OperationName.ConvertField:\n        return this.convertField.undo(operation);\n      case OperationName.ConvertFieldV2:\n        return this.convertFieldV2.undo(operation);\n      case OperationName.DeleteView:\n        return this.deleteView.undo(operation);\n      case OperationName.CreateView:\n        return this.createView.undo(operation);\n      case OperationName.UpdateView:\n        return this.updateView.undo(operation);\n      default:\n        assertNever(operation);\n    }\n  }\n\n  async redo(operation: IUndoRedoOperation): Promise<IUndoRedoOperation> {\n    switch (operation.name) {\n      case OperationName.CreateRecords:\n        return this.createRecords.redo(operation);\n      case OperationName.DeleteRecords:\n        return this.deleteRecords.redo(operation);\n      case OperationName.UpdateRecords:\n        return this.updateRecords.redo(operation);\n      case OperationName.UpdateRecordsOrder:\n        return this.updateRecordsOrder.redo(operation);\n      case OperationName.CreateFields:\n        return this.createFields.redo(operation);\n      case OperationName.DeleteFields:\n        return this.deleteFields.redo(operation);\n      case OperationName.PasteSelection:\n        return this.pasteSelection.redo(operation);\n      case OperationName.ConvertField:\n        return this.convertField.redo(operation);\n      case OperationName.ConvertFieldV2:\n        return this.convertFieldV2.redo(operation);\n      case OperationName.DeleteView:\n        return this.deleteView.redo(operation);\n      case OperationName.CreateView:\n        return this.createView.redo(operation);\n      case OperationName.UpdateView:\n        return this.updateView.redo(operation);\n      default:\n        assertNever(operation);\n    }\n  }\n\n  @OnEvent(Events.OPERATION_RECORDS_CREATE)\n  private async onCreateRecords(payload: IEventRawContext) {\n    const windowId = payload.reqHeaders['x-window-id'] as string;\n    const userId = payload.reqUser?.id;\n    if (!windowId || !userId) {\n      return;\n    }\n    const operation = await this.createRecords.event2Operation(payload as ICreateRecordsPayload);\n    await this.undoRedoStackService.push(userId, operation.params.tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_RECORDS_DELETE)\n  private async onDeleteRecords(payload: IDeleteRecordsPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.deleteRecords.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_RECORDS_UPDATE)\n  private async onUpdateRecords(payload: IUpdateRecordsPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.updateRecords.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_RECORDS_ORDER_UPDATE)\n  private async onUpdateRecordsOrder(payload: IUpdateRecordsOrderPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.updateRecordsOrder.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_FIELDS_CREATE)\n  private async onCreateFields(payload: ICreateFieldsPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.createFields.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_FIELDS_DELETE)\n  private async onDeleteFields(payload: IDeleteFieldsPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.deleteFields.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_PASTE_SELECTION)\n  private async onPasteSelection(payload: IPasteSelectionPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.pasteSelection.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_FIELD_CONVERT)\n  private async onConvertField(payload: IConvertFieldPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n\n    const operation = await this.convertField.event2Operation(payload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_VIEW_DELETE)\n  private async onDeleteView(payload: IDeleteViewPayload) {\n    const { windowId, userId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n    const operation = await this.deleteView.event2Operation(payload as IDeleteViewPayload);\n    await this.undoRedoStackService.push(userId, operation.params.tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_VIEW_CREATE)\n  private async onCreateView(payload: IEventRawContext) {\n    const windowId = payload.reqHeaders['x-window-id'] as string;\n    const userId = payload.reqUser?.id;\n    if (!windowId || !userId) {\n      return;\n    }\n    const operation = await this.createView.event2Operation(payload as ICreateViewPayload);\n    await this.undoRedoStackService.push(userId, operation.params.tableId, windowId, operation);\n  }\n\n  @OnEvent(Events.OPERATION_VIEW_UPDATE)\n  private async onUpdateView(payload: IUpdateViewPayload) {\n    const { windowId, userId, tableId } = payload;\n    if (!windowId || !userId) {\n      return;\n    }\n    const operation = await this.updateView.event2Operation(payload as IUpdateViewPayload);\n    await this.undoRedoStackService.push(userId, tableId, windowId, operation);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.module.ts",
    "content": "import { Module, forwardRef } from '@nestjs/common';\nimport { FieldOpenApiModule } from '../../field/open-api/field-open-api.module';\nimport { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';\nimport { RecordModule } from '../../record/record.module';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';\nimport { ViewModule } from '../../view/view.module';\nimport { UndoRedoOperationService } from './undo-redo-operation.service';\nimport { UndoRedoStackService } from './undo-redo-stack.service';\n\n@Module({\n  imports: [\n    RecordModule,\n    forwardRef(() => RecordOpenApiModule),\n    ViewModule,\n    ViewOpenApiModule,\n    forwardRef(() => FieldOpenApiModule),\n    TableDomainQueryModule,\n  ],\n  providers: [UndoRedoStackService, UndoRedoOperationService],\n  exports: [UndoRedoStackService, UndoRedoOperationService],\n})\nexport class UndoRedoStackModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-stack.service.ts",
    "content": "import { Injectable, InternalServerErrorException } from '@nestjs/common';\nimport { ClsService } from 'nestjs-cls';\nimport { CacheService } from '../../../cache/cache.service';\nimport type { IUndoRedoOperation } from '../../../cache/types';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\n\n@Injectable()\nexport class UndoRedoStackService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cacheService: CacheService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  private async getUndoStack(userId: string, tableId: string, windowId: string) {\n    return (await this.cacheService.get(`operations:undo:${userId}:${tableId}:${windowId}`)) || [];\n  }\n\n  private async getRedoStack(userId: string, tableId: string, windowId: string) {\n    return (await this.cacheService.get(`operations:redo:${userId}:${tableId}:${windowId}`)) || [];\n  }\n\n  private async setUndoStack(\n    userId: string,\n    tableId: string,\n    windowId: string,\n    undoStack: IUndoRedoOperation[]\n  ) {\n    await this.cacheService.set(\n      `operations:undo:${userId}:${tableId}:${windowId}`,\n      undoStack,\n      this.thresholdConfig.undoExpirationTime\n    );\n  }\n\n  private async setRedoStack(\n    userId: string,\n    tableId: string,\n    windowId: string,\n    redoStack: IUndoRedoOperation[]\n  ) {\n    await this.cacheService.set(\n      `operations:redo:${userId}:${tableId}:${windowId}`,\n      redoStack,\n      this.thresholdConfig.undoExpirationTime\n    );\n  }\n\n  async push(\n    userId: string,\n    tableId: string,\n    windowId: string,\n    operation: IUndoRedoOperation\n  ): Promise<void> {\n    const maxUndoStackSize = this.thresholdConfig.maxUndoStackSize;\n    let undoStack = await this.getUndoStack(userId, tableId, windowId);\n\n    undoStack.push(operation);\n    if (undoStack.length > this.thresholdConfig.maxUndoStackSize) {\n      undoStack = undoStack.slice(-maxUndoStackSize);\n    }\n\n    await this.setUndoStack(userId, tableId, windowId, undoStack);\n\n    // Clear redo stack when a new operation is pushed\n    await this.cacheService.del(`operations:redo:${userId}:${tableId}:${windowId}`);\n\n    this.eventEmitterService.emit(Events.OPERATION_PUSH, operation);\n  }\n\n  async mergeLastOperation(\n    userId: string,\n    tableId: string,\n    windowId: string,\n    merge: (operation: IUndoRedoOperation) => IUndoRedoOperation | null\n  ): Promise<boolean> {\n    const undoStack = await this.getUndoStack(userId, tableId, windowId);\n    if (!undoStack.length) {\n      return false;\n    }\n\n    const lastIndex = undoStack.length - 1;\n    const merged = merge(undoStack[lastIndex]);\n    if (!merged) {\n      return false;\n    }\n\n    undoStack[lastIndex] = merged;\n    await this.setUndoStack(userId, tableId, windowId, undoStack);\n    return true;\n  }\n\n  async popUndo(tableId: string, windowId: string) {\n    const userId = this.cls.get('user.id');\n    const undoStack = await this.getUndoStack(userId, tableId, windowId);\n    const redoStack = await this.getRedoStack(userId, tableId, windowId);\n\n    const operation = undoStack.pop();\n\n    return {\n      operation,\n      push: async (newOperation: IUndoRedoOperation) => {\n        if (!newOperation) {\n          throw new InternalServerErrorException('No operation to undo');\n        }\n        redoStack.push(newOperation);\n        await this.setUndoStack(userId, tableId, windowId, undoStack);\n        await this.setRedoStack(userId, tableId, windowId, redoStack);\n      },\n    };\n  }\n\n  async popRedo(tableId: string, windowId: string) {\n    const userId = this.cls.get('user.id');\n    const undoStack = await this.getUndoStack(userId, tableId, windowId);\n    const redoStack = await this.getRedoStack(userId, tableId, windowId);\n\n    const operation = redoStack.pop();\n\n    return {\n      operation,\n      push: async (newOperation: IUndoRedoOperation) => {\n        if (!newOperation) {\n          throw new InternalServerErrorException('No operation to redo');\n        }\n        undoStack.push(newOperation);\n        await this.setUndoStack(userId, tableId, windowId, undoStack);\n        await this.setRedoStack(userId, tableId, windowId, redoStack);\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/delete-user/delete-user.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { StorageModule } from '../../attachments/plugins/storage.module';\nimport { SessionStoreService } from '../../auth/session/session-store.service';\nimport { DeleteUserService } from './delete-user.service';\n\n@Module({\n  imports: [StorageModule],\n  providers: [DeleteUserService, SessionStoreService],\n  exports: [DeleteUserService],\n})\nexport class DeleteUserModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/delete-user/delete-user.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { join } from 'path';\nimport { Injectable } from '@nestjs/common';\nimport { getRandomString, HttpErrorCode, Role } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PluginStatus, PrincipalType, UploadType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport type { IClsStore } from '../../../types/cls';\nimport StorageAdapter from '../../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../attachments/plugins/storage';\n\n@Injectable()\nexport class DeleteUserService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly prismaService: PrismaService,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  private async updateUserAvatarToDeleted(userId: string) {\n    const path = join(StorageAdapter.getDir(UploadType.Avatar), userId);\n    const bucket = StorageAdapter.getBucket(UploadType.Avatar);\n    const mimetype = `image/png`;\n    const { hash } = await this.storageAdapter.uploadFileWidthPath(\n      bucket,\n      path,\n      'static/system/deleted-user-avatar.png',\n      {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': mimetype,\n      }\n    );\n    await this.prismaService.txClient().attachments.update({\n      data: {\n        hash,\n      },\n      where: {\n        token: userId,\n        deletedTime: null,\n      },\n    });\n  }\n\n  private async permanentlyDeleteUser(userId: string) {\n    await this.prismaService.txClient().user.update({\n      where: { id: userId, permanentDeletedTime: null },\n      data: {\n        email: `deleted-${getRandomString(10)}@teable.ai`,\n        name: 'Deleted User',\n        permanentDeletedTime: new Date().toISOString(),\n        deletedTime: new Date().toISOString(),\n      },\n    });\n    // update user avatar to default avatar\n    await this.updateUserAvatarToDeleted(userId);\n  }\n\n  private async clearUserData(userId: string) {\n    // clear user data\n    // clear token\n    await this.prismaService.txClient().accessToken.deleteMany({\n      where: {\n        userId,\n      },\n    });\n    // clear account\n    await this.prismaService.txClient().account.deleteMany({\n      where: {\n        userId,\n      },\n    });\n    // clear comment subscription\n    await this.prismaService.txClient().commentSubscription.deleteMany({\n      where: {\n        createdBy: userId,\n      },\n    });\n    // clear invitation\n    await this.prismaService.txClient().invitation.deleteMany({\n      where: {\n        createdBy: userId,\n      },\n    });\n    // clear notification\n    await this.prismaService.txClient().notification.deleteMany({\n      where: {\n        toUserId: userId,\n      },\n    });\n    // clear Oauth app\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex('oauth_app_token as t')\n          .join('oauth_app_secret as s', 't.app_secret_id', 's.id')\n          .join('oauth_app as a', 's.client_id', 'a.client_id')\n          .where('a.created_by', userId)\n          .del()\n          .toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex('oauth_app_secret as s')\n          .join('oauth_app as a', 's.client_id', 'a.client_id')\n          .where('a.created_by', userId)\n          .del()\n          .toQuery()\n      );\n    await this.prismaService\n      .txClient()\n      .$executeRawUnsafe(\n        this.knex('oauth_app_authorized as auth')\n          .join('oauth_app as a', 'auth.client_id', 'a.client_id')\n          .where('a.created_by', userId)\n          .del()\n          .toQuery()\n      );\n    await this.prismaService.txClient().oAuthApp.deleteMany({\n      where: {\n        createdBy: userId,\n      },\n    });\n    // clear Pin\n    await this.prismaService.txClient().pinResource.deleteMany({\n      where: {\n        createdBy: userId,\n      },\n    });\n    // clear Plugin develop\n    await this.prismaService.txClient().plugin.deleteMany({\n      where: {\n        createdBy: userId,\n        status: {\n          not: PluginStatus.Published,\n        },\n      },\n    });\n    // clear user last visit\n    await this.prismaService.txClient().userLastVisit.deleteMany({\n      where: {\n        userId,\n      },\n    });\n\n    // clear collaborator\n    await this.prismaService.txClient().collaborator.deleteMany({\n      where: {\n        principalId: userId,\n      },\n    });\n  }\n\n  private async validateDeleteUser(userId: string) {\n    const collaboratorSpaces = await this.prismaService.txClient().$queryRawUnsafe<\n      {\n        id: string;\n        name: string;\n        deletedTime: string | null;\n      }[]\n    >(\n      this.knex\n        .queryBuilder()\n        .select({\n          id: 'space.id',\n          name: 'space.name',\n          deletedTime: 'space.deleted_time',\n        })\n        .from('collaborator')\n        .innerJoin('space', 'collaborator.resource_id', 'space.id')\n        .where('principal_id', userId)\n        .where('principal_type', PrincipalType.User)\n        .where((d1) =>\n          d1\n            .where((d2) =>\n              d2\n                .whereIn('collaborator.role_name', [Role.Owner, Role.Creator])\n                .whereNotNull('space.deleted_time')\n            )\n            .orWhereNull('space.deleted_time')\n        )\n        .toQuery()\n    );\n    if (collaboratorSpaces.length > 0) {\n      throw new CustomHttpException(\n        'User has collaborators in spaces (or deleted spaces in trash): ' +\n          collaboratorSpaces.map((space) => space.name).join(', '),\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          spaces: collaboratorSpaces.map((space) => ({\n            id: space.id,\n            name: space.name,\n            deletedTime: space.deletedTime ? new Date(space.deletedTime).toISOString() : null,\n          })),\n          localization: {\n            i18nKey: 'httpErrors.user.collaboratorsInSpaces',\n          },\n        }\n      );\n    }\n  }\n\n  async deleteUserById(userId: string) {\n    await this.prismaService.$tx(async () => {\n      await this.validateDeleteUser(userId);\n      await this.clearUserData(userId);\n      await this.permanentlyDeleteUser(userId);\n    });\n  }\n\n  async deleteUser() {\n    const userId = this.cls.get('user.id');\n    await this.deleteUserById(userId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/last-visit/last-visit.controller.ts",
    "content": "import { Body, Controller, Get, Post, Query } from '@nestjs/common';\nimport type {\n  IUserLastVisitBaseNodeVo,\n  IUserLastVisitListBaseVo,\n  IUserLastVisitMapVo,\n  IUserLastVisitVo,\n} from '@teable/openapi';\nimport {\n  IGetUserLastVisitRo,\n  IGetUserLastVisitBaseNodeRo,\n  IUpdateUserLastVisitRo,\n  getUserLastVisitBaseNodeRoSchema,\n  getUserLastVisitRoSchema,\n  updateUserLastVisitRoSchema,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../../types/cls';\nimport { ZodValidationPipe } from '../../../zod.validation.pipe';\nimport { LastVisitService } from './last-visit.service';\n\n@Controller('api/user/last-visit')\nexport class LastVisitController {\n  constructor(\n    private readonly lastVisitService: LastVisitService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Get()\n  async getUserLastVisit(\n    @Query(new ZodValidationPipe(getUserLastVisitRoSchema)) params: IGetUserLastVisitRo\n  ): Promise<IUserLastVisitVo | undefined> {\n    const userId = this.cls.get('user.id');\n    return this.lastVisitService.getUserLastVisit(userId, params);\n  }\n\n  @Post()\n  async updateUserLastVisit(\n    @Body(new ZodValidationPipe(updateUserLastVisitRoSchema))\n    updateUserLastVisitRo: IUpdateUserLastVisitRo\n  ) {\n    const userId = this.cls.get('user.id');\n    return this.lastVisitService.updateUserLastVisit(userId, updateUserLastVisitRo);\n  }\n\n  @Get('/map')\n  async getUserLastVisitMap(\n    @Query(new ZodValidationPipe(getUserLastVisitRoSchema)) params: IGetUserLastVisitRo\n  ): Promise<IUserLastVisitMapVo> {\n    const userId = this.cls.get('user.id');\n    return this.lastVisitService.getUserLastVisitMap(userId, params);\n  }\n\n  @Get('/list-base')\n  async getUserLastVisitListBase(): Promise<IUserLastVisitListBaseVo> {\n    return this.lastVisitService.baseVisit();\n  }\n\n  @Get('/base-node')\n  async getUserLastVisitBaseNode(\n    @Query(new ZodValidationPipe(getUserLastVisitBaseNodeRoSchema))\n    params: IGetUserLastVisitBaseNodeRo\n  ): Promise<IUserLastVisitBaseNodeVo> {\n    const userId = this.cls.get('user.id');\n    return this.lastVisitService.getUserLastVisitBaseNode(userId, params);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/last-visit/last-visit.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { LastVisitController } from './last-visit.controller';\nimport { LastVisitService } from './last-visit.service';\n\n@Module({\n  controllers: [LastVisitController],\n  providers: [LastVisitService],\n  exports: [LastVisitService],\n})\nexport class LastVisitModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { OnEvent } from '@nestjs/event-emitter';\nimport { HttpErrorCode, type IRole } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  IGetUserLastVisitRo,\n  IGetUserLastVisitBaseNodeRo,\n  IUpdateUserLastVisitRo,\n  IUserLastVisitListBaseVo,\n  IUserLastVisitMapVo,\n  IUserLastVisitVo,\n  IUserLastVisitBaseNodeVo,\n} from '@teable/openapi';\nimport { LastVisitResourceType } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { keyBy } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport type {\n  BaseDeleteEvent,\n  SpaceDeleteEvent,\n  DashboardDeleteEvent,\n  WorkflowDeleteEvent,\n  AppDeleteEvent,\n  TableDeleteEvent,\n  ViewDeleteEvent,\n} from '../../../event-emitter/events';\nimport { Events } from '../../../event-emitter/events';\nimport { LastVisitUpdateEvent } from '../../../event-emitter/events/last-visit/last-visit.event';\nimport type { IClsStore } from '../../../types/cls';\n\n@Injectable()\nexport class LastVisitService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  async getUserLastVisitBaseNode(\n    userId: string,\n    params: IGetUserLastVisitBaseNodeRo\n  ): Promise<IUserLastVisitBaseNodeVo> {\n    const lastVisit = await this.prismaService.userLastVisit.findFirst({\n      where: {\n        userId,\n        parentResourceId: params.parentResourceId,\n        resourceType: {\n          in: [\n            LastVisitResourceType.Table,\n            LastVisitResourceType.Dashboard,\n            LastVisitResourceType.Workflow,\n            LastVisitResourceType.App,\n          ],\n        },\n      },\n      orderBy: {\n        lastVisitTime: 'desc',\n      },\n      take: 1,\n      select: {\n        resourceId: true,\n        resourceType: true,\n      },\n    });\n\n    if (!lastVisit) {\n      return;\n    }\n\n    return {\n      resourceId: lastVisit.resourceId,\n      resourceType: lastVisit.resourceType as LastVisitResourceType,\n    };\n  }\n\n  async spaceVisit(userId: string, parentResourceId: string) {\n    const lastVisit = await this.prismaService.userLastVisit.findFirst({\n      where: {\n        userId,\n        parentResourceId,\n        resourceType: LastVisitResourceType.Space,\n      },\n      orderBy: {\n        lastVisitTime: 'desc',\n      },\n      take: 1,\n      select: {\n        resourceId: true,\n        resourceType: true,\n      },\n    });\n\n    if (lastVisit) {\n      return {\n        resourceId: lastVisit.resourceId,\n        resourceType: lastVisit.resourceType as LastVisitResourceType,\n      };\n    }\n\n    return undefined;\n  }\n\n  async tableVisit(userId: string, baseId: string): Promise<IUserLastVisitVo | undefined> {\n    const knex = this.knex;\n\n    const query = this.knex\n      .with('table_visit', (qb) => {\n        qb.select({\n          resourceId: 'ulv.resource_id',\n        })\n          .from('user_last_visit as ulv')\n          .leftJoin('table_meta as t', function () {\n            this.on('t.id', '=', 'ulv.resource_id').andOnNull('t.deleted_time');\n          })\n          .where('ulv.user_id', userId)\n          .where('ulv.resource_type', LastVisitResourceType.Table)\n          .where('ulv.parent_resource_id', baseId)\n          .limit(1);\n      })\n      .select({\n        tableId: 'table_visit.resourceId',\n        viewId: 'ulv.resource_id',\n      })\n      .from('table_visit')\n      .leftJoin('user_last_visit as ulv', function () {\n        this.on('ulv.parent_resource_id', '=', 'table_visit.resourceId')\n          .andOn('ulv.resource_type', knex.raw('?', LastVisitResourceType.View))\n          .andOn('ulv.user_id', knex.raw('?', userId));\n      })\n      .leftJoin('view as v', function () {\n        this.on('v.id', '=', 'ulv.resource_id').andOnNull('v.deleted_time');\n      })\n      .whereRaw('(ulv.resource_id IS NULL OR v.id IS NOT NULL)')\n      .limit(1)\n      .toQuery();\n\n    const results = await this.prismaService.$queryRawUnsafe<\n      {\n        tableId: string;\n        tableLastVisitTime: Date;\n        viewId: string;\n        viewLastVisitTime: Date;\n      }[]\n    >(query);\n\n    const result = results[0];\n\n    if (result && result.tableId && result.viewId) {\n      return {\n        resourceId: result.tableId,\n        childResourceId: result.viewId,\n        resourceType: LastVisitResourceType.Table,\n      };\n    }\n\n    if (result && result.tableId) {\n      const table = await this.prismaService.tableMeta.findFirst({\n        select: {\n          id: true,\n          views: {\n            select: {\n              id: true,\n            },\n            take: 1,\n            orderBy: {\n              order: 'asc',\n            },\n            where: {\n              deletedTime: null,\n            },\n          },\n        },\n        where: {\n          id: result.tableId,\n          deletedTime: null,\n        },\n      });\n\n      if (!table) {\n        return;\n      }\n\n      return {\n        resourceId: table.id,\n        childResourceId: table.views[0].id,\n        resourceType: LastVisitResourceType.Table,\n      };\n    }\n\n    const table = await this.prismaService.tableMeta.findFirst({\n      select: {\n        id: true,\n        views: {\n          select: {\n            id: true,\n          },\n          take: 1,\n          orderBy: {\n            order: 'asc',\n          },\n          where: {\n            deletedTime: null,\n          },\n        },\n      },\n      where: {\n        baseId,\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    if (!table) {\n      return;\n    }\n\n    return {\n      resourceId: table.id,\n      childResourceId: table.views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    };\n  }\n\n  async viewVisit(userId: string, parentResourceId: string) {\n    const query = this.knex\n      .select({\n        resourceId: 'ulv.resource_id',\n      })\n      .from('user_last_visit as ulv')\n      .leftJoin('view as v', function () {\n        this.on('v.id', '=', 'ulv.resource_id').andOnNull('v.deleted_time');\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.View)\n      .where('ulv.parent_resource_id', parentResourceId)\n      .whereNotNull('v.id')\n      .limit(1);\n\n    const sql = query.toQuery();\n\n    const results = await this.prismaService.$queryRawUnsafe<IUserLastVisitVo[]>(sql);\n    const lastVisit = results[0];\n\n    if (lastVisit) {\n      return {\n        resourceId: lastVisit.resourceId,\n        resourceType: LastVisitResourceType.View,\n      };\n    }\n\n    const view = await this.prismaService.view.findFirst({\n      select: {\n        id: true,\n      },\n      where: {\n        tableId: parentResourceId,\n        deletedTime: null,\n      },\n      orderBy: {\n        order: 'asc',\n      },\n    });\n\n    if (view) {\n      return {\n        resourceId: view.id,\n        resourceType: LastVisitResourceType.View,\n      };\n    }\n  }\n\n  async dashboardVisit(userId: string, parentResourceId: string) {\n    const query = this.knex\n      .select({\n        resourceId: 'ulv.resource_id',\n      })\n      .from('user_last_visit as ulv')\n      .leftJoin('dashboard as v', function () {\n        this.on('v.id', '=', 'ulv.resource_id');\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.Dashboard)\n      .where('ulv.parent_resource_id', parentResourceId)\n      .whereNotNull('v.id')\n      .limit(1);\n\n    const sql = query.toQuery();\n\n    const results = await this.prismaService.$queryRawUnsafe<IUserLastVisitVo[]>(sql);\n    const lastVisit = results[0];\n\n    if (lastVisit) {\n      return {\n        resourceId: lastVisit.resourceId,\n        resourceType: LastVisitResourceType.Dashboard,\n      };\n    }\n\n    const dashboard = await this.prismaService.dashboard.findFirst({\n      select: {\n        id: true,\n      },\n      where: {\n        baseId: parentResourceId,\n      },\n    });\n\n    if (dashboard) {\n      return {\n        resourceId: dashboard.id,\n        resourceType: LastVisitResourceType.Dashboard,\n      };\n    }\n  }\n\n  async workflowVisit(userId: string, parentResourceId: string) {\n    const query = this.knex\n      .select({\n        resourceId: 'ulv.resource_id',\n      })\n      .from('user_last_visit as ulv')\n      .leftJoin('workflow as v', function () {\n        this.on('v.id', '=', 'ulv.resource_id').andOnNull('v.deleted_time');\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.Workflow)\n      .where('ulv.parent_resource_id', parentResourceId)\n      .whereNotNull('v.id')\n      .limit(1)\n      .toQuery();\n\n    const results = await this.prismaService.$queryRawUnsafe<IUserLastVisitVo[]>(query);\n    const lastVisit = results[0];\n\n    if (lastVisit) {\n      return {\n        resourceId: lastVisit.resourceId,\n        resourceType: LastVisitResourceType.Workflow,\n      };\n    }\n\n    const workflowQuery = this.knex('workflow')\n      .select({\n        id: 'id',\n      })\n      .where('base_id', parentResourceId)\n      .whereNull('deleted_time')\n      .orderBy('order', 'asc')\n      .limit(1)\n      .toQuery();\n\n    const workflowResults =\n      await this.prismaService.$queryRawUnsafe<{ id: string }[]>(workflowQuery);\n    const workflow = workflowResults[0];\n\n    if (workflow) {\n      return {\n        resourceId: workflow.id,\n        resourceType: LastVisitResourceType.Workflow,\n      };\n    }\n  }\n\n  async appVisit(userId: string, parentResourceId: string) {\n    const query = this.knex\n      .select({\n        resourceId: 'ulv.resource_id',\n      })\n      .from('user_last_visit as ulv')\n      .leftJoin('app as a', function () {\n        this.on('a.id', '=', 'ulv.resource_id').andOnNull('a.deleted_time');\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.App)\n      .where('ulv.parent_resource_id', parentResourceId)\n      .whereNotNull('a.id')\n      .limit(1)\n      .toQuery();\n\n    const results = await this.prismaService.$queryRawUnsafe<IUserLastVisitVo[]>(query);\n    const lastVisit = results[0];\n\n    if (lastVisit) {\n      return {\n        resourceId: lastVisit.resourceId,\n        resourceType: LastVisitResourceType.App,\n      };\n    }\n\n    const appQuery = this.knex('app')\n      .select({\n        id: 'id',\n      })\n      .where('base_id', parentResourceId)\n      .whereNull('deleted_time')\n      .orderBy('last_modified_time', 'desc')\n      .limit(1)\n      .toQuery();\n\n    const appResults = await this.prismaService.$queryRawUnsafe<{ id: string }[]>(appQuery);\n    const app = appResults[0];\n\n    if (app) {\n      return {\n        resourceId: app.id,\n        resourceType: LastVisitResourceType.App,\n      };\n    }\n\n    return undefined;\n  }\n\n  async baseVisit(): Promise<IUserLastVisitListBaseVo> {\n    const userId = this.cls.get('user.id');\n    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);\n    const query = this.knex\n      .distinct(['ulv.resource_id'])\n      .select({\n        resourceId: 'ulv.resource_id',\n        resourceType: 'ulv.resource_type',\n        lastVisitTime: 'ulv.last_visit_time',\n        resourceName: 'b.name',\n        resourceIcon: 'b.icon',\n        resourceRole: 'c.role_name',\n        spaceId: 's.id',\n        createBy: 'b.created_by',\n      })\n      .from('user_last_visit as ulv')\n      .join('base as b', function () {\n        this.on('b.id', '=', 'ulv.resource_id').andOnNull('b.deleted_time');\n      })\n      .join('space as s', function () {\n        this.on('s.id', '=', 'ulv.parent_resource_id').andOnNull('s.deleted_time');\n      })\n      .join('collaborator as c', function () {\n        this.onIn('c.principal_id', [...(departmentIds ?? []), userId]).andOn(function () {\n          this.on('c.resource_id', '=', 'ulv.parent_resource_id').orOn(\n            'c.resource_id',\n            '=',\n            'ulv.resource_id'\n          );\n        });\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.Base)\n      .whereNotNull('b.id')\n      .whereNotNull('c.id')\n      .orderBy('ulv.last_visit_time', 'desc');\n\n    const results = await this.prismaService.$queryRawUnsafe<\n      {\n        resourceId: string;\n        resourceType: LastVisitResourceType;\n        lastVisitTime: Date;\n        resourceName: string;\n        resourceIcon: string;\n        resourceRole: IRole;\n        spaceId: string;\n        createBy: string;\n      }[]\n    >(query.toQuery());\n\n    const list = results.map((result) => ({\n      resourceId: result.resourceId,\n      resourceType: result.resourceType,\n      lastVisitTime: result.lastVisitTime.toISOString(),\n      resource: {\n        id: result.resourceId,\n        name: result.resourceName,\n        icon: result.resourceIcon,\n        role: result.resourceRole,\n        spaceId: result.spaceId,\n        createdBy: result.createBy,\n      },\n    }));\n\n    return {\n      total: results.length,\n      list,\n    };\n  }\n\n  async getUserLastVisit(\n    userId: string,\n    params: IGetUserLastVisitRo\n  ): Promise<IUserLastVisitVo | undefined> {\n    switch (params.resourceType) {\n      case LastVisitResourceType.Space:\n        return this.spaceVisit(userId, params.parentResourceId);\n      case LastVisitResourceType.Table:\n        return this.tableVisit(userId, params.parentResourceId);\n      case LastVisitResourceType.View:\n        return this.viewVisit(userId, params.parentResourceId);\n      case LastVisitResourceType.Dashboard:\n        return this.dashboardVisit(userId, params.parentResourceId);\n      case LastVisitResourceType.Workflow:\n        return this.workflowVisit(userId, params.parentResourceId);\n      case LastVisitResourceType.App:\n        return this.appVisit(userId, params.parentResourceId);\n      default:\n        throw new CustomHttpException('Invalid resource type', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.lastVisit.invalidResourceType',\n          },\n        });\n    }\n  }\n\n  async updateUserLastVisit(userId: string, updateData: IUpdateUserLastVisitRo) {\n    this.eventEmitterService.emitAsync(\n      Events.LAST_VISIT_UPDATE,\n      new LastVisitUpdateEvent(updateData)\n    );\n    const { resourceType, resourceId, parentResourceId, childResourceId } = updateData;\n\n    if (resourceType === LastVisitResourceType.Base) {\n      await this.updateUserLastVisitRecord({\n        userId,\n        resourceType: LastVisitResourceType.Base,\n        resourceId,\n        parentResourceId,\n      });\n      return;\n    }\n\n    await this.updateUserLastVisitRecord({\n      userId,\n      resourceType,\n      resourceId,\n      parentResourceId,\n      maxRecords: 1,\n      maxKeys: ['parentResourceId'],\n    });\n\n    if (childResourceId) {\n      await this.updateUserLastVisitRecord({\n        userId,\n        resourceType: LastVisitResourceType.View,\n        resourceId: childResourceId,\n        parentResourceId: resourceId,\n        maxRecords: 1,\n        maxKeys: ['parentResourceId'],\n      });\n    }\n  }\n\n  async updateUserLastVisitRecord({\n    userId,\n    resourceType,\n    resourceId,\n    maxRecords = 0,\n    parentResourceId,\n    maxKeys,\n  }: {\n    userId: string;\n    resourceType: string;\n    resourceId: string;\n    parentResourceId: string;\n    maxRecords?: number;\n    maxKeys?: 'parentResourceId'[];\n  }) {\n    await this.prismaService.$transaction(async (prisma) => {\n      await prisma.userLastVisit.upsert({\n        where: {\n          userId_resourceType_resourceId: {\n            userId,\n            resourceType,\n            resourceId,\n          },\n        },\n        update: {\n          lastVisitTime: new Date().toISOString(),\n        },\n        create: {\n          userId,\n          resourceType,\n          resourceId,\n          parentResourceId,\n        },\n      });\n\n      if (maxRecords > 0) {\n        const oldRecords = await prisma.userLastVisit.findMany({\n          where: {\n            userId,\n            resourceType,\n            ...(maxKeys?.includes('parentResourceId') ? { parentResourceId } : {}),\n          },\n          orderBy: {\n            lastVisitTime: 'desc',\n          },\n          skip: maxRecords,\n          select: {\n            id: true,\n          },\n        });\n\n        if (oldRecords.length > 0) {\n          await prisma.userLastVisit.deleteMany({\n            where: {\n              id: {\n                in: oldRecords.map((record) => record.id),\n              },\n            },\n          });\n        }\n      }\n    });\n  }\n\n  async getUserLastVisitMap(\n    userId: string,\n    params: IGetUserLastVisitRo\n  ): Promise<IUserLastVisitMapVo> {\n    const tables = await this.prismaService.tableMeta.findMany({\n      select: {\n        id: true,\n      },\n      where: {\n        baseId: params.parentResourceId,\n        deletedTime: null,\n      },\n    });\n\n    const query = this.knex\n      .select({\n        resourceId: 'ulv.resource_id',\n        parentResourceId: 'ulv.parent_resource_id',\n      })\n      .from('user_last_visit as ulv')\n      .leftJoin('view as v', function () {\n        this.on('v.id', '=', 'ulv.resource_id').andOnNull('v.deleted_time');\n      })\n      .where('ulv.user_id', userId)\n      .where('ulv.resource_type', LastVisitResourceType.View)\n      .whereIn(\n        'ulv.parent_resource_id',\n        tables.map((table) => table.id)\n      )\n      .whereNotNull('v.id');\n\n    const sql = query.toQuery();\n    const results =\n      await this.prismaService.$queryRawUnsafe<(IUserLastVisitVo & { parentResourceId: string })[]>(\n        sql\n      );\n\n    // If some tables don't have a last visited view, find their first view\n    const tablesWithVisit = new Set(results.map((result) => result.parentResourceId));\n    const tablesWithoutVisit = tables.filter((table) => !tablesWithVisit.has(table.id));\n\n    if (tablesWithoutVisit.length > 0) {\n      const defaultViews = await this.prismaService.view.findMany({\n        select: {\n          id: true,\n          tableId: true,\n        },\n        where: {\n          tableId: {\n            in: tablesWithoutVisit.map((t) => t.id),\n          },\n          deletedTime: null,\n        },\n        orderBy: {\n          order: 'asc',\n        },\n        distinct: ['tableId'],\n      });\n\n      // Add default views to results\n      for (const view of defaultViews) {\n        results.push({\n          resourceId: view.id,\n          parentResourceId: view.tableId,\n          resourceType: LastVisitResourceType.View,\n        });\n      }\n    }\n\n    return keyBy(results, 'parentResourceId');\n  }\n\n  @OnEvent(Events.BASE_DELETE, { async: true })\n  @OnEvent(Events.SPACE_DELETE, { async: true })\n  @OnEvent(Events.TABLE_DELETE, { async: true })\n  @OnEvent(Events.TABLE_VIEW_DELETE, { async: true })\n  @OnEvent(Events.DASHBOARD_DELETE, { async: true })\n  @OnEvent(Events.WORKFLOW_DELETE, { async: true })\n  @OnEvent(Events.APP_DELETE, { async: true })\n  protected async resourceDeleteListener(\n    listenerEvent:\n      | BaseDeleteEvent\n      | SpaceDeleteEvent\n      | TableDeleteEvent\n      | ViewDeleteEvent\n      | DashboardDeleteEvent\n      | WorkflowDeleteEvent\n      | AppDeleteEvent\n  ) {\n    switch (listenerEvent.name) {\n      case Events.BASE_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            OR: [\n              {\n                resourceId: listenerEvent.payload.baseId,\n                resourceType: LastVisitResourceType.Base,\n              },\n              {\n                parentResourceId: listenerEvent.payload.baseId,\n                resourceType: LastVisitResourceType.Table,\n              },\n            ],\n          },\n        });\n        break;\n      case Events.SPACE_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            parentResourceId: listenerEvent.payload.spaceId,\n            resourceType: LastVisitResourceType.Base,\n          },\n        });\n        break;\n      case Events.TABLE_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            OR: [\n              {\n                resourceId: listenerEvent.payload.tableId,\n                resourceType: LastVisitResourceType.Table,\n              },\n              {\n                parentResourceId: listenerEvent.payload.tableId,\n                resourceType: LastVisitResourceType.View,\n              },\n            ],\n          },\n        });\n        break;\n      case Events.TABLE_VIEW_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            resourceId: listenerEvent.payload.viewId,\n            resourceType: LastVisitResourceType.View,\n          },\n        });\n        break;\n      case Events.DASHBOARD_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            resourceId: listenerEvent.payload.dashboardId,\n            resourceType: LastVisitResourceType.Dashboard,\n          },\n        });\n        break;\n      case Events.WORKFLOW_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            resourceId: listenerEvent.payload.workflowId,\n            resourceType: LastVisitResourceType.Workflow,\n          },\n        });\n        break;\n      case Events.APP_DELETE:\n        await this.prismaService.userLastVisit.deleteMany({\n          where: {\n            resourceId: listenerEvent.payload.appId,\n            resourceType: LastVisitResourceType.App,\n          },\n        });\n        break;\n    }\n\n    this.eventEmitterService.emitAsync(Events.LAST_VISIT_CLEAR, {});\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/user.controller.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { UserController } from './user.controller';\n\ndescribe('UserController', () => {\n  let controller: UserController;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      controllers: [UserController],\n    }).compile();\n\n    controller = module.get<UserController>(UserController);\n  });\n\n  it('should be defined', () => {\n    expect(controller).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/user.controller.ts",
    "content": "import {\n  BadRequestException,\n  Body,\n  Controller,\n  Patch,\n  UploadedFile,\n  UseInterceptors,\n} from '@nestjs/common';\nimport { FileInterceptor } from '@nestjs/platform-express';\nimport {\n  IUpdateUserLangRo,\n  IUpdateUserNameRo,\n  IUserNotifyMeta,\n  updateUserLangRoSchema,\n  updateUserNameRoSchema,\n  userNotifyMetaSchema,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { ZodValidationPipe } from '../../zod.validation.pipe';\nimport { UserService } from './user.service';\n\n@Controller('api/user')\nexport class UserController {\n  constructor(\n    private readonly userService: UserService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Patch('name')\n  async updateName(\n    @Body(new ZodValidationPipe(updateUserNameRoSchema)) updateUserNameRo: IUpdateUserNameRo\n  ): Promise<void> {\n    const userId = this.cls.get('user.id');\n    return this.userService.updateUserName(userId, updateUserNameRo.name);\n  }\n\n  // Supported avatar image types (gif not supported for cropping)\n  private static readonly avatarAllowedMimetypes = [\n    'image/jpeg',\n    'image/png',\n    'image/webp',\n    'image/jpg',\n  ];\n\n  @UseInterceptors(\n    FileInterceptor('file', {\n      fileFilter: (_req, file, callback) => {\n        const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];\n        if (allowedTypes.includes(file.mimetype)) {\n          callback(null, true);\n        } else {\n          callback(\n            new BadRequestException('Unsupported file type. Only JPEG, PNG, and WebP are allowed.'),\n            false\n          );\n        }\n      },\n      limits: {\n        fileSize: 3 * 1024 * 1024, // limit file size is 3MB\n      },\n    })\n  )\n  @Patch('avatar')\n  async updateAvatar(@UploadedFile() file: Express.Multer.File): Promise<void> {\n    const userId = this.cls.get('user.id');\n    return this.userService.updateAvatar(userId, file);\n  }\n\n  @Patch('notify-meta')\n  async updateNotifyMeta(\n    @Body(new ZodValidationPipe(userNotifyMetaSchema))\n    updateUserNotifyMetaRo: IUserNotifyMeta\n  ): Promise<void> {\n    const userId = this.cls.get('user.id');\n    return this.userService.updateNotifyMeta(userId, updateUserNotifyMetaRo);\n  }\n\n  @Patch('lang')\n  async updateLang(\n    @Body(new ZodValidationPipe(updateUserLangRoSchema)) updateUserLangRo: IUpdateUserLangRo\n  ): Promise<void> {\n    const userId = this.cls.get('user.id');\n    return this.userService.updateLang(userId, updateUserLangRo.lang);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/user.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { MulterModule } from '@nestjs/platform-express';\nimport multer from 'multer';\nimport { StorageModule } from '../attachments/plugins/storage.module';\nimport { SettingModule } from '../setting/setting.module';\nimport { LastVisitModule } from './last-visit/last-visit.module';\nimport { UserController } from './user.controller';\nimport { UserService } from './user.service';\n\n@Module({\n  controllers: [UserController],\n  imports: [\n    MulterModule.register({\n      storage: multer.diskStorage({}),\n    }),\n    StorageModule,\n    SettingModule,\n    LastVisitModule,\n  ],\n  providers: [UserService],\n  exports: [UserService],\n})\nexport class UserModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/user.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { UserModule } from './user.module';\nimport { UserService } from './user.service';\n\ndescribe('UserService', () => {\n  let service: UserService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, UserModule],\n    }).compile();\n\n    service = module.get<UserService>(UserService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/user/user.service.ts",
    "content": "import https from 'https';\nimport { join } from 'path';\nimport { Injectable } from '@nestjs/common';\nimport {\n  generateAccountId,\n  generateSpaceId,\n  generateUserId,\n  HttpErrorCode,\n  minidenticon,\n  Role,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { CollaboratorType, PrincipalType, UploadType } from '@teable/openapi';\nimport type { IUserInfoVo, ICreateSpaceRo, IUserNotifyMeta } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { I18nContext } from 'nestjs-i18n';\nimport sharp from 'sharp';\nimport { CacheService } from '../../cache/cache.service';\nimport { BaseConfig, IBaseConfig } from '../../configs/base.config';\nimport { CustomHttpException } from '../../custom.exception';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport { UserSignUpEvent } from '../../event-emitter/events/user/user.event';\nimport type { IClsStore } from '../../types/cls';\nimport StorageAdapter from '../attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../attachments/plugins/storage';\nimport { getPublicFullStorageUrl } from '../attachments/plugins/utils';\nimport { UserModel } from '../model/user';\nimport { SettingService } from '../setting/setting.service';\n\n@Injectable()\nexport class UserService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly settingService: SettingService,\n    private readonly cacheService: CacheService,\n    private readonly userModel: UserModel,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter\n  ) {}\n\n  async getUserById(id: string) {\n    const userRaw = await this.userModel.getUserRawById(id);\n\n    return (\n      userRaw && {\n        ...userRaw,\n        avatar: userRaw.avatar && getPublicFullStorageUrl(userRaw.avatar),\n        notifyMeta: userRaw.notifyMeta && JSON.parse(userRaw.notifyMeta),\n      }\n    );\n  }\n\n  async getUserByEmail(email: string) {\n    return await this.prismaService.txClient().user.findUnique({\n      where: { email: email.toLowerCase(), deletedTime: null },\n      include: { accounts: true },\n    });\n  }\n\n  async createSpaceBySignup(createSpaceRo: ICreateSpaceRo) {\n    const userId = this.cls.get('user.id');\n    const uniqName = createSpaceRo.name ?? 'Space';\n\n    const space = await this.prismaService.txClient().space.create({\n      select: {\n        id: true,\n        name: true,\n      },\n      data: {\n        id: generateSpaceId(),\n        name: uniqName,\n        createdBy: userId,\n      },\n    });\n    await this.prismaService.txClient().collaborator.create({\n      data: {\n        resourceId: space.id,\n        resourceType: CollaboratorType.Space,\n        roleName: Role.Owner,\n        principalType: PrincipalType.User,\n        principalId: userId,\n        createdBy: userId,\n      },\n    });\n    return space;\n  }\n\n  async createUserWithSettingCheck(\n    user: Omit<Prisma.UserCreateInput, 'name'> & { name?: string },\n    account?: Omit<Prisma.AccountUncheckedCreateInput, 'userId'>,\n    defaultSpaceName?: string,\n    inviteCode?: string,\n    autoSpaceCreation: boolean = true\n  ) {\n    const setting = await this.settingService.getSetting();\n    if (setting?.disallowSignUp) {\n      throw new CustomHttpException(\n        'The current instance disallow sign up by the administrator',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.user.disallowSignUp',\n          },\n        }\n      );\n    }\n    if (setting.enableWaitlist) {\n      await this.checkWaitlistInviteCode(inviteCode);\n    }\n\n    return await this.createUser(user, account, defaultSpaceName, autoSpaceCreation);\n  }\n\n  async checkWaitlistInviteCode(inviteCode?: string) {\n    if (!inviteCode) {\n      throw new CustomHttpException(\n        'Waitlist is enabled, invite code is required',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.user.waitlistInviteCodeRequired',\n          },\n        }\n      );\n    }\n\n    const times = await this.cacheService.get(`waitlist:invite-code:${inviteCode}`);\n    if (!times || times <= 0) {\n      throw new CustomHttpException(\n        'Waitlist is enabled, invite code is invalid',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.user.waitlistInviteCodeInvalid',\n          },\n        }\n      );\n    }\n\n    await this.cacheService.set(`waitlist:invite-code:${inviteCode}`, times - 1, '30d');\n\n    return true;\n  }\n\n  async createUser(\n    user: Omit<Prisma.UserCreateInput, 'name'> & { name?: string },\n    account?: Omit<Prisma.AccountUncheckedCreateInput, 'userId'>,\n    defaultSpaceName?: string,\n    autoSpaceCreation: boolean = true\n  ) {\n    // defaults\n    const defaultNotifyMeta: IUserNotifyMeta = {\n      email: true,\n    };\n\n    user = {\n      ...user,\n      id: user.id ?? generateUserId(),\n      email: user.email.toLowerCase(),\n      notifyMeta: JSON.stringify(defaultNotifyMeta),\n    };\n\n    const userTotalCount = await this.prismaService.txClient().user.count({\n      where: { isSystem: null },\n    });\n\n    const isAdmin = userTotalCount === 0;\n\n    if (!user?.avatar) {\n      const avatar = await this.generateDefaultAvatar(user.id!);\n      user = {\n        ...user,\n        avatar,\n      };\n    }\n    // default space created\n    const newUser = await this.prismaService.txClient().user.create({\n      data: {\n        ...user,\n        name: user.name ?? user.email.split('@')[0],\n        isAdmin: isAdmin ? true : null,\n        lang: I18nContext.current()?.lang,\n      },\n    });\n    const { id, name } = newUser;\n    if (account) {\n      await this.prismaService.txClient().account.create({\n        data: { id: generateAccountId(), ...account, userId: id },\n      });\n    }\n    if (this.baseConfig.isCloud && autoSpaceCreation) {\n      await this.cls.runWith(this.cls.get(), async () => {\n        this.cls.set('user.id', id);\n        await this.createSpaceBySignup({ name: defaultSpaceName || `${name}'s space` });\n      });\n    }\n    return newUser;\n  }\n\n  async updateUserName(id: string, name: string) {\n    const user: IUserInfoVo = await this.prismaService.txClient().user.update({\n      data: {\n        name,\n      },\n      where: { id, deletedTime: null },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        avatar: true,\n      },\n    });\n    this.eventEmitterService.emitAsync(Events.USER_RENAME, user);\n  }\n\n  // Avatar size for cropping (square)\n  private static readonly avatarSize = 128;\n  private static readonly avatarMimetype = 'image/webp';\n\n  async updateAvatar(id: string, avatarFile: { path: string; mimetype: string; size: number }) {\n    const storagePath = join(StorageAdapter.getDir(UploadType.Avatar), id);\n    const bucket = StorageAdapter.getBucket(UploadType.Avatar);\n\n    // Crop the image to a square before uploading\n    const croppedImageBuffer = await this.cropAvatarImage(avatarFile.path);\n\n    // Upload the cropped image buffer directly\n    const { hash } = await this.storageAdapter.uploadFile(bucket, storagePath, croppedImageBuffer, {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      'Content-Type': UserService.avatarMimetype,\n    });\n\n    await this.mountAttachment(id, {\n      hash,\n      size: croppedImageBuffer.length,\n      mimetype: UserService.avatarMimetype,\n      token: id,\n      path: storagePath,\n    });\n\n    await this.prismaService.txClient().user.update({\n      data: {\n        avatar: storagePath,\n      },\n      where: { id, deletedTime: null },\n    });\n  }\n\n  /**\n   * Crop avatar image to a square (center crop) and resize to avatarSize\n   * Output format is WebP for better compression\n   */\n  private async cropAvatarImage(filePath: string): Promise<Buffer> {\n    try {\n      const image = sharp(filePath, { failOn: 'none' });\n      const metadata = await image.metadata();\n\n      if (!metadata.width || !metadata.height) {\n        throw new CustomHttpException('Unsupported file type', HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.attachment.invalidImage',\n          },\n        });\n      }\n\n      // Center crop to square\n      const size = Math.min(metadata.width, metadata.height);\n      const left = Math.floor((metadata.width - size) / 2);\n      const top = Math.floor((metadata.height - size) / 2);\n\n      return await image\n        .extract({ left, top, width: size, height: size })\n        .resize(UserService.avatarSize, UserService.avatarSize)\n        .webp({ quality: 85 })\n        .toBuffer();\n    } catch (error) {\n      // If it's already a CustomHttpException, rethrow it\n      if (error instanceof CustomHttpException) {\n        throw error;\n      }\n      // For any other errors (e.g., unsupported format, corrupted file), throw 400\n      throw new CustomHttpException('Unsupported file type', HttpErrorCode.VALIDATION_ERROR, {\n        localization: {\n          i18nKey: 'httpErrors.attachment.invalidImage',\n        },\n      });\n    }\n  }\n\n  private async mountAttachment(\n    userId: string,\n    input: Prisma.AttachmentsCreateInput | Prisma.AttachmentsUpdateInput\n  ) {\n    await this.prismaService.txClient().attachments.upsert({\n      create: {\n        ...input,\n        createdBy: userId,\n      } as Prisma.AttachmentsCreateInput,\n      update: input as Prisma.AttachmentsUpdateInput,\n      where: {\n        token: userId,\n        deletedTime: null,\n      },\n    });\n  }\n\n  async updateNotifyMeta(id: string, notifyMetaRo: IUserNotifyMeta) {\n    await this.prismaService.txClient().user.update({\n      data: {\n        notifyMeta: JSON.stringify(notifyMetaRo),\n      },\n      where: { id, deletedTime: null },\n    });\n  }\n\n  async updateLang(id: string, lang: string) {\n    await this.prismaService.txClient().user.update({\n      data: {\n        lang,\n      },\n      where: { id, deletedTime: null },\n    });\n  }\n\n  private async generateDefaultAvatar(id: string) {\n    const path = join(StorageAdapter.getDir(UploadType.Avatar), id);\n    const bucket = StorageAdapter.getBucket(UploadType.Avatar);\n\n    const svgSize = [410, 410];\n    const svgString = minidenticon(id);\n    const svgObject = sharp(Buffer.from(svgString))\n      .resize(svgSize[0], svgSize[1])\n      .flatten({ background: '#f0f0f0' })\n      .png({ quality: 90 });\n    const mimetype = 'image/png';\n    const { size } = await svgObject.metadata();\n    const svgBuffer = await svgObject.toBuffer();\n\n    const { hash } = await this.storageAdapter.uploadFile(bucket, path, svgBuffer, {\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n      'Content-Type': mimetype,\n    });\n\n    await this.mountAttachment(id, {\n      hash: hash,\n      size: size,\n      mimetype: mimetype,\n      token: id,\n      path: path,\n      width: svgSize[0],\n      height: svgSize[1],\n    });\n\n    return path;\n  }\n\n  private async uploadAvatarByUrl(userId: string, url: string) {\n    return new Promise<string>((resolve, reject) => {\n      https\n        .get(url, async (response) => {\n          try {\n            // Collect the image data into a buffer\n            const chunks: Buffer[] = [];\n            for await (const chunk of response) {\n              chunks.push(chunk);\n            }\n            const imageBuffer = Buffer.concat(chunks);\n\n            // Crop the image to square and resize\n            const croppedBuffer = await this.cropAvatarBuffer(imageBuffer);\n\n            const storagePath = join(StorageAdapter.getDir(UploadType.Avatar), userId);\n            const bucket = StorageAdapter.getBucket(UploadType.Avatar);\n            const { hash } = await this.storageAdapter.uploadFile(\n              bucket,\n              storagePath,\n              croppedBuffer,\n              {\n                // eslint-disable-next-line @typescript-eslint/naming-convention\n                'Content-Type': UserService.avatarMimetype,\n              }\n            );\n\n            await this.mountAttachment(userId, {\n              hash: hash,\n              size: croppedBuffer.length,\n              mimetype: UserService.avatarMimetype,\n              token: userId,\n              path: storagePath,\n            });\n            resolve(storagePath);\n          } catch (error) {\n            reject(error);\n          }\n        })\n        .on('error', (error) => {\n          reject(error);\n        });\n    });\n  }\n\n  /**\n   * Crop avatar image buffer to a square (center crop) and resize to avatarSize\n   * Output format is WebP for better compression\n   */\n  private async cropAvatarBuffer(imageBuffer: Buffer): Promise<Buffer> {\n    const image = sharp(imageBuffer, { failOn: 'none' });\n    const metadata = await image.metadata();\n\n    if (!metadata.width || !metadata.height) {\n      // If we can't get metadata, just resize without center crop\n      return image\n        .resize(UserService.avatarSize, UserService.avatarSize)\n        .webp({ quality: 85 })\n        .toBuffer();\n    }\n\n    // Center crop to square\n    const size = Math.min(metadata.width, metadata.height);\n    const left = Math.floor((metadata.width - size) / 2);\n    const top = Math.floor((metadata.height - size) / 2);\n\n    return image\n      .extract({ left, top, width: size, height: size })\n      .resize(UserService.avatarSize, UserService.avatarSize)\n      .webp({ quality: 85 })\n      .toBuffer();\n  }\n\n  async findOrCreateUser(\n    user: {\n      name: string;\n      email: string;\n      provider: string;\n      providerId: string;\n      type: string;\n      avatarUrl?: string;\n    },\n    autoSpaceCreation: boolean = true,\n    onCreateNewUser?: () => void\n  ) {\n    let isNewUser = false;\n    const res = await this.prismaService.$tx(async () => {\n      const { email, name, provider, providerId, type, avatarUrl } = user;\n      // account exist check\n      const existAccount = await this.prismaService.txClient().account.findFirst({\n        where: { provider, providerId },\n      });\n      if (existAccount) {\n        return await this.getUserById(existAccount.userId);\n      }\n\n      // user exist check\n      const existUser = await this.getUserByEmail(email);\n      if (existUser && existUser.isSystem) {\n        throw new CustomHttpException('User is system user', HttpErrorCode.UNAUTHORIZED, {\n          localization: {\n            i18nKey: 'httpErrors.user.systemUser',\n          },\n        });\n      }\n      if (!existUser) {\n        const userId = generateUserId();\n        let avatar: string | undefined = undefined;\n        if (avatarUrl) {\n          try {\n            avatar = await this.uploadAvatarByUrl(userId, avatarUrl);\n          } catch {\n            // Ignore avatar upload errors, don't block user login\n          }\n        }\n        isNewUser = true;\n        onCreateNewUser?.();\n        return await this.createUserWithSettingCheck(\n          { id: userId, email, name, avatar },\n          { provider, providerId, type },\n          undefined,\n          undefined,\n          autoSpaceCreation\n        );\n      }\n\n      await this.prismaService.txClient().account.create({\n        data: { id: generateAccountId(), provider, providerId, type, userId: existUser.id },\n      });\n      return existUser;\n    });\n    if (res && isNewUser) {\n      this.eventEmitterService.emitAsync(Events.USER_SIGNUP, new UserSignUpEvent(res.id));\n    }\n    return res;\n  }\n\n  async refreshLastSignTime(userId: string) {\n    await this.prismaService.txClient().user.update({\n      where: { id: userId, deletedTime: null },\n      data: { lastSignTime: new Date().toISOString() },\n    });\n    this.eventEmitterService.emitAsync(Events.USER_SIGNIN, { userId });\n  }\n\n  async getUserInfoList(userIds: string[]) {\n    const userList = await this.prismaService.user.findMany({\n      where: {\n        id: { in: userIds },\n      },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        avatar: true,\n      },\n    });\n    return userList.map((user) => {\n      const { avatar } = user;\n      return {\n        ...user,\n        avatar: avatar && getPublicFullStorageUrl(avatar),\n      };\n    });\n  }\n\n  async createSystemUser({\n    id = generateUserId(),\n    email,\n    name,\n    avatar,\n  }: {\n    id?: string;\n    email: string;\n    name: string;\n    avatar?: string;\n  }) {\n    return this.prismaService.$tx(async () => {\n      if (!avatar) {\n        avatar = await this.generateDefaultAvatar(id);\n      }\n      return this.prismaService.txClient().user.create({\n        data: {\n          id,\n          email,\n          name,\n          avatar,\n          isSystem: true,\n        },\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts",
    "content": "import { getActionTriggerChannel } from '@teable/core';\nimport {\n  BaseId,\n  FieldCreated,\n  FieldId,\n  FieldUpdated,\n  RecordsBatchUpdated,\n  TableActionTriggerRequested,\n  TableId,\n  type IExecutionContext,\n  type IEventHandler,\n} from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { describe, expect, it } from 'vitest';\nimport type { ShareDbService } from '../../share-db/share-db.service';\nimport { V2ActionTriggerService } from './v2-action-trigger.service';\n\ntype IPresencePayload = Array<{ actionKey: string; payload?: Record<string, unknown> }>;\n\nconst defaultTimeZone = 'UTC';\nconst defaultDateFormat = 'YYYY-MM-DD';\nconst sourceFieldId = 'fldSource0000000001';\n\nconst waitForPresenceFlush = async () => {\n  await new Promise<void>((resolve) => {\n    if (typeof setImmediate === 'function') {\n      setImmediate(() => resolve());\n      return;\n    }\n    setTimeout(() => resolve(), 0);\n  });\n};\n\nconst fieldUpdateSemantics = {\n  type: {\n    realtimePath: ['type'],\n    presencePath: ['type'],\n    mayRequirePresence: true,\n  },\n  options: {\n    realtimePath: ['options'],\n    presencePath: ['options'],\n    mayRequirePresence: true,\n  },\n  formatting: {\n    realtimePath: ['options'],\n    presencePath: ['options', 'formatting'],\n    mayRequirePresence: true,\n  },\n} as const;\n\nconst createIds = () => {\n  return {\n    baseId: BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(),\n    tableId: TableId.create(`tbl${'b'.repeat(16)}`)._unsafeUnwrap(),\n    fieldId: FieldId.create(`fld${'c'.repeat(16)}`)._unsafeUnwrap(),\n  };\n};\n\ndescribe('V2ActionTriggerService', () => {\n  it('emits setField presence payload with changed new values', async () => {\n    let channelSubmitted: string | undefined;\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: (channel: string) => {\n          channelSubmitted = channel;\n          return {\n            create: () => ({\n              submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n                submitted = data;\n                cb?.();\n              },\n            }),\n          };\n        },\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2FieldUpdatedActionTriggerProjection'\n    )?.instance as IEventHandler<FieldUpdated> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const event = FieldUpdated.create({\n      baseId,\n      tableId,\n      fieldId,\n      updatedProperties: ['type', 'options'],\n      changes: {\n        type: { oldValue: 'singleLineText', newValue: 'singleSelect' },\n        options: {\n          oldValue: { showAs: { type: 'url' } },\n          newValue: { choices: [{ id: 'opt1', name: 'Open' }] },\n        },\n      },\n      propertySemantics: {\n        type: fieldUpdateSemantics.type,\n        options: fieldUpdateSemantics.options,\n      },\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n\n    expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));\n    expect(submitted).toEqual([\n      {\n        actionKey: 'setField',\n        payload: {\n          tableId: tableId.toString(),\n          field: {\n            id: fieldId.toString(),\n            updatedProperties: ['type', 'options'],\n            type: 'singleSelect',\n            options: {\n              choices: [{ id: 'opt1', name: 'Open' }],\n            },\n          },\n        },\n      },\n    ]);\n  });\n\n  it('emits addField and setRecord presence payloads for field created', async () => {\n    let channelSubmitted: string | undefined;\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: (channel: string) => {\n          channelSubmitted = channel;\n          return {\n            create: () => ({\n              submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n                submitted = data;\n                cb?.();\n              },\n            }),\n          };\n        },\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2FieldCreatedActionTriggerProjection'\n    )?.instance as IEventHandler<FieldCreated> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const event = FieldCreated.create({\n      baseId,\n      tableId,\n      fieldId,\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n\n    expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));\n    expect(submitted).toEqual([\n      {\n        actionKey: 'addField',\n        payload: {\n          tableId: tableId.toString(),\n          field: {\n            id: fieldId.toString(),\n          },\n        },\n      },\n      {\n        actionKey: 'setRecord',\n        payload: {\n          tableId: tableId.toString(),\n          fieldIds: [fieldId.toString()],\n        },\n      },\n    ]);\n  });\n\n  it('emits setField presence payload for formatting-only field updates', async () => {\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: () => ({\n          create: () => ({\n            submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n              submitted = data;\n              cb?.();\n            },\n          }),\n        }),\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2FieldUpdatedActionTriggerProjection'\n    )?.instance as IEventHandler<FieldUpdated> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const event = FieldUpdated.create({\n      baseId,\n      tableId,\n      fieldId,\n      updatedProperties: ['formatting'],\n      changes: {\n        formatting: {\n          oldValue: {\n            date: defaultDateFormat,\n            time: 'None',\n            timeZone: defaultTimeZone,\n          },\n          newValue: {\n            date: defaultDateFormat,\n            time: 'hh:mm A',\n            timeZone: defaultTimeZone,\n          },\n        },\n      },\n      propertySemantics: {\n        formatting: fieldUpdateSemantics.formatting,\n      },\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n    expect(submitted).toEqual([\n      {\n        actionKey: 'setField',\n        payload: {\n          tableId: tableId.toString(),\n          field: {\n            id: fieldId.toString(),\n            updatedProperties: ['formatting'],\n            options: {\n              formatting: {\n                date: defaultDateFormat,\n                time: 'hh:mm A',\n                timeZone: defaultTimeZone,\n              },\n            },\n          },\n        },\n      },\n    ]);\n  });\n\n  it('does not emit setField action for unrelated field property updates', async () => {\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: () => ({\n          create: () => ({\n            submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n              submitted = data;\n              cb?.();\n            },\n          }),\n        }),\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2FieldUpdatedActionTriggerProjection'\n    )?.instance as IEventHandler<FieldUpdated> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const event = FieldUpdated.create({\n      baseId,\n      tableId,\n      fieldId,\n      updatedProperties: ['description'],\n      changes: {\n        description: { oldValue: 'old', newValue: 'new' },\n      },\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n    expect(submitted).toBeUndefined();\n  });\n\n  it('emits requested action trigger payload for schema-driven presence events', async () => {\n    let channelSubmitted: string | undefined;\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: (channel: string) => {\n          channelSubmitted = channel;\n          return {\n            create: () => ({\n              submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n                submitted = data;\n                cb?.();\n              },\n            }),\n          };\n        },\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2TableActionTriggerRequestedProjection'\n    )?.instance as IEventHandler<TableActionTriggerRequested> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId } = createIds();\n    const event = TableActionTriggerRequested.create({\n      baseId,\n      tableId,\n      actionKey: 'setField',\n      payload: {\n        tableId: tableId.toString(),\n        field: {\n          id: sourceFieldId,\n        },\n        fieldIds: [sourceFieldId, 'fldComputed00000002'],\n      },\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n\n    expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));\n    expect(submitted).toEqual([\n      {\n        actionKey: 'setField',\n        payload: {\n          tableId: tableId.toString(),\n          field: {\n            id: sourceFieldId,\n          },\n          fieldIds: [sourceFieldId, 'fldComputed00000002'],\n        },\n      },\n    ]);\n  });\n\n  it('emits setRecord presence payload with fieldIds for large batch updates', async () => {\n    let channelSubmitted: string | undefined;\n    let submitted: IPresencePayload | undefined;\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: (channel: string) => {\n          channelSubmitted = channel;\n          return {\n            create: () => ({\n              submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n                submitted = data;\n                cb?.();\n              },\n            }),\n          };\n        },\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const projection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2RecordsBatchUpdatedActionTriggerProjection'\n    )?.instance as IEventHandler<RecordsBatchUpdated> | undefined;\n\n    expect(projection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const event = RecordsBatchUpdated.create({\n      baseId,\n      tableId,\n      source: 'user',\n      updates: Array.from({ length: 1001 }, (_, index) => ({\n        recordId: `rec${index.toString().padStart(16, '0')}`,\n        oldVersion: 1,\n        newVersion: 2,\n        changes: [\n          {\n            fieldId: fieldId.toString(),\n            oldValue: `old-${index}`,\n            newValue: `new-${index}`,\n          },\n        ],\n      })),\n    });\n\n    const result = await projection?.handle({} as IExecutionContext, event);\n    expect(result?.isOk()).toBe(true);\n    await waitForPresenceFlush();\n\n    expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));\n    expect(submitted).toEqual([\n      {\n        actionKey: 'setRecord',\n        payload: {\n          tableId: tableId.toString(),\n          fieldIds: [fieldId.toString()],\n        },\n      },\n    ]);\n  });\n\n  it('batches field patch and schema-refresh setField actions into one presence submit for schema-driven updates', async () => {\n    const submissions: IPresencePayload[] = [];\n\n    const shareDbService = {\n      connect: () => ({\n        getPresence: () => ({\n          create: () => ({\n            submit: (data: IPresencePayload, cb?: (error?: unknown) => void) => {\n              submissions.push(data);\n              cb?.();\n            },\n          }),\n        }),\n      }),\n    } as unknown as ShareDbService;\n\n    const registered: Array<{ instance: unknown }> = [];\n    const container = {\n      registerInstance: (_token: unknown, instance: unknown) => {\n        registered.push({ instance });\n        return container;\n      },\n    } as unknown as DependencyContainer;\n\n    const service = new V2ActionTriggerService(shareDbService);\n    service.registerProjections(container);\n\n    const fieldUpdatedProjection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2FieldUpdatedActionTriggerProjection'\n    )?.instance as IEventHandler<FieldUpdated> | undefined;\n    const actionTriggerProjection = registered.find(\n      (item) =>\n        (item.instance as { constructor?: { name?: string } }).constructor?.name ===\n        'V2TableActionTriggerRequestedProjection'\n    )?.instance as IEventHandler<TableActionTriggerRequested> | undefined;\n\n    expect(fieldUpdatedProjection).toBeDefined();\n    expect(actionTriggerProjection).toBeDefined();\n\n    const { baseId, tableId, fieldId } = createIds();\n    const fieldUpdatedEvent = FieldUpdated.create({\n      baseId,\n      tableId,\n      fieldId,\n      updatedProperties: ['type', 'options'],\n      changes: {\n        type: { oldValue: 'singleLineText', newValue: 'number' },\n        options: {\n          oldValue: { showAs: { type: 'number' } },\n          newValue: { formatting: { decimal: 0 } },\n        },\n      },\n      propertySemantics: {\n        type: fieldUpdateSemantics.type,\n        options: fieldUpdateSemantics.options,\n      },\n    });\n    const schemaRefreshEvent = TableActionTriggerRequested.create({\n      baseId,\n      tableId,\n      actionKey: 'setField',\n      payload: {\n        tableId: tableId.toString(),\n        field: {\n          id: fieldId.toString(),\n        },\n        fieldIds: [fieldId.toString()],\n      },\n    });\n\n    const fieldResult = await fieldUpdatedProjection?.handle(\n      {} as IExecutionContext,\n      fieldUpdatedEvent\n    );\n    const actionResult = await actionTriggerProjection?.handle(\n      {} as IExecutionContext,\n      schemaRefreshEvent\n    );\n    expect(fieldResult?.isOk()).toBe(true);\n    expect(actionResult?.isOk()).toBe(true);\n\n    await waitForPresenceFlush();\n\n    expect(submissions).toEqual([\n      [\n        {\n          actionKey: 'setField',\n          payload: {\n            tableId: tableId.toString(),\n            field: {\n              id: fieldId.toString(),\n              updatedProperties: ['type', 'options'],\n              type: 'number',\n              options: {\n                formatting: { decimal: 0 },\n              },\n            },\n          },\n        },\n        {\n          actionKey: 'setField',\n          payload: {\n            tableId: tableId.toString(),\n            field: {\n              id: fieldId.toString(),\n            },\n            fieldIds: [fieldId.toString()],\n          },\n        },\n      ],\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { getActionTriggerChannel } from '@teable/core';\nimport type { ITableActionKey } from '@teable/core';\nimport {\n  FieldCreated,\n  FieldDeleted,\n  FieldUpdated,\n  RecordCreated,\n  RecordUpdated,\n  RecordReordered,\n  RecordsBatchCreated,\n  RecordsBatchUpdated,\n  RecordsDeleted,\n  TableActionTriggerRequested,\n  ProjectionHandler,\n  ok,\n  serializeFieldUpdatedValue,\n  isLargeRecordBatchMutation,\n} from '@teable/v2-core';\nimport type { IExecutionContext, IEventHandler, DomainError, Result } from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { ShareDbService } from '../../share-db/share-db.service';\n\nexport interface IActionTriggerData {\n  actionKey: ITableActionKey;\n  payload?: Record<string, unknown>;\n}\n\ntype IPendingActionTriggerBatch = {\n  shareDbService: ShareDbService;\n  tableId: string;\n  data: IActionTriggerData[];\n};\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  value instanceof Object && !Array.isArray(value);\n\nconst setValueAtPath = (\n  target: Record<string, unknown>,\n  path: ReadonlyArray<string>,\n  value: unknown\n) => {\n  if (path.length === 0) {\n    return;\n  }\n\n  let current = target;\n  for (const segment of path.slice(0, -1)) {\n    const nested = current[segment];\n    if (!isRecord(nested)) {\n      current[segment] = {};\n    }\n    current = current[segment] as Record<string, unknown>;\n  }\n\n  current[path[path.length - 1] as string] = value;\n};\n\nconst buildUpdatedFieldPatch = (event: FieldUpdated): Record<string, unknown> => {\n  const patch: Record<string, unknown> = {\n    id: event.fieldId.toString(),\n    updatedProperties: [...event.updatedProperties],\n  };\n\n  for (const property of event.updatedProperties) {\n    const change = event.changes[property];\n    if (!change) {\n      continue;\n    }\n\n    setValueAtPath(\n      patch,\n      event.presencePathFor(property),\n      serializeFieldUpdatedValue(change.newValue)\n    );\n  }\n\n  return patch;\n};\n\nconst collectChangedFieldIds = (updates: RecordsBatchUpdated['updates']): string[] => {\n  const fieldIds = new Set<string>();\n\n  for (const update of updates) {\n    for (const change of update.changes) {\n      fieldIds.add(change.fieldId);\n    }\n  }\n\n  return [...fieldIds];\n};\n\n/**\n * Helper to emit action triggers via ShareDB presence.\n * Batches actions per table to avoid later submits overwriting earlier ones\n * within the same schema update turn.\n */\nconst pendingActionTriggerBatches = new Map<string, IPendingActionTriggerBatch>();\nlet flushScheduled = false;\n\nconst deferFlush = (flush: () => void) => {\n  if (typeof setImmediate === 'function') {\n    setImmediate(flush);\n    return;\n  }\n  setTimeout(flush, 0);\n};\n\nconst flushPendingActionTriggers = () => {\n  flushScheduled = false;\n  const batches = [...pendingActionTriggerBatches.values()];\n  pendingActionTriggerBatches.clear();\n\n  for (const batch of batches) {\n    const channel = getActionTriggerChannel(batch.tableId);\n    const presence = batch.shareDbService.connect().getPresence(channel);\n    const localPresence = presence.create(batch.tableId);\n    localPresence.submit(batch.data, (error) => {\n      if (error) console.error('Action trigger error:', error);\n    });\n  }\n};\n\nconst emitActionTrigger = (\n  shareDbService: ShareDbService,\n  tableId: string,\n  data: IActionTriggerData[]\n) => {\n  const pending = pendingActionTriggerBatches.get(tableId) ?? {\n    shareDbService,\n    tableId,\n    data: [],\n  };\n  pending.data.push(...data);\n  pendingActionTriggerBatches.set(tableId, pending);\n\n  if (!flushScheduled) {\n    flushScheduled = true;\n    deferFlush(flushPendingActionTriggers);\n  }\n};\n\n/**\n * V2 projection handler that emits action triggers for record create events.\n * This enables V1 frontend features like row count refresh.\n */\n@ProjectionHandler(RecordCreated)\nclass V2RecordCreatedActionTriggerProjection implements IEventHandler<RecordCreated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordCreated\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for batch record create events.\n */\n@ProjectionHandler(RecordsBatchCreated)\nclass V2RecordsBatchCreatedActionTriggerProjection implements IEventHandler<RecordsBatchCreated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordsBatchCreated\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'addRecord' }]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for record update events.\n */\n@ProjectionHandler(RecordUpdated)\nclass V2RecordUpdatedActionTriggerProjection implements IEventHandler<RecordUpdated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordUpdated\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for batch record update events.\n */\n@ProjectionHandler(RecordsBatchUpdated)\nclass V2RecordsBatchUpdatedActionTriggerProjection implements IEventHandler<RecordsBatchUpdated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordsBatchUpdated\n  ): Promise<Result<void, DomainError>> {\n    if (isLargeRecordBatchMutation(event.updates.length)) {\n      const fieldIds = collectChangedFieldIds(event.updates);\n      emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n        {\n          actionKey: 'setRecord',\n          payload: {\n            tableId: event.tableId.toString(),\n            fieldIds,\n          },\n        },\n      ]);\n      return ok(undefined);\n    }\n\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for record reorder events.\n */\n@ProjectionHandler(RecordReordered)\nclass V2RecordReorderedActionTriggerProjection implements IEventHandler<RecordReordered> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordReordered\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [{ actionKey: 'setRecord' }]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for record delete events.\n */\n@ProjectionHandler(RecordsDeleted)\nclass V2RecordsDeletedActionTriggerProjection implements IEventHandler<RecordsDeleted> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: RecordsDeleted\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n      { actionKey: 'deleteRecord' },\n    ]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for field create events.\n */\n@ProjectionHandler(FieldCreated)\nclass V2FieldCreatedActionTriggerProjection implements IEventHandler<FieldCreated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: FieldCreated\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n      {\n        actionKey: 'addField',\n        payload: {\n          tableId: event.tableId.toString(),\n          field: {\n            id: event.fieldId.toString(),\n          },\n        },\n      },\n      // Trigger schema-driven record query refresh for the newly added field.\n      {\n        actionKey: 'setRecord',\n        payload: {\n          tableId: event.tableId.toString(),\n          fieldIds: [event.fieldId.toString()],\n        },\n      },\n    ]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for field delete events.\n */\n@ProjectionHandler(FieldDeleted)\nclass V2FieldDeletedActionTriggerProjection implements IEventHandler<FieldDeleted> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: FieldDeleted\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n      {\n        actionKey: 'deleteField',\n        payload: {\n          tableId: event.tableId.toString(),\n          fieldId: event.fieldId.toString(),\n        },\n      },\n    ]);\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that emits action triggers for field update events.\n */\n@ProjectionHandler(FieldUpdated)\nclass V2FieldUpdatedActionTriggerProjection implements IEventHandler<FieldUpdated> {\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: FieldUpdated\n  ): Promise<Result<void, DomainError>> {\n    if (!event.mayRequirePresence()) {\n      return ok(undefined);\n    }\n\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n      {\n        actionKey: 'setField',\n        payload: {\n          tableId: event.tableId.toString(),\n          field: buildUpdatedFieldPatch(event),\n        },\n      },\n    ]);\n    return ok(undefined);\n  }\n}\n\n@ProjectionHandler(TableActionTriggerRequested)\nclass V2TableActionTriggerRequestedProjection\n  implements IEventHandler<TableActionTriggerRequested>\n{\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  async handle(\n    _context: IExecutionContext,\n    event: TableActionTriggerRequested\n  ): Promise<Result<void, DomainError>> {\n    emitActionTrigger(this.shareDbService, event.tableId.toString(), [\n      {\n        actionKey: event.actionKey,\n        ...(event.payload ? { payload: event.payload } : {}),\n      },\n    ]);\n    return ok(undefined);\n  }\n}\n\n/**\n * Service that registers V2 action trigger projections with the V2 container.\n * These projections emit ShareDB presence events for V1 frontend compatibility.\n */\n@Injectable()\nexport class V2ActionTriggerService {\n  private readonly logger = new Logger(V2ActionTriggerService.name);\n\n  constructor(private readonly shareDbService: ShareDbService) {}\n\n  /**\n   * Register action trigger projections with the V2 container.\n   * Call this after the V2 container is created.\n   */\n  registerProjections(container: DependencyContainer): void {\n    this.logger.log('Registering V2 action trigger projections');\n\n    const shareDbService = this.shareDbService;\n\n    // Register projection instances directly since they depend on NestJS ShareDbService\n    container.registerInstance(\n      V2RecordCreatedActionTriggerProjection,\n      new V2RecordCreatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2RecordsBatchCreatedActionTriggerProjection,\n      new V2RecordsBatchCreatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2RecordUpdatedActionTriggerProjection,\n      new V2RecordUpdatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2RecordsBatchUpdatedActionTriggerProjection,\n      new V2RecordsBatchUpdatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2RecordReorderedActionTriggerProjection,\n      new V2RecordReorderedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2RecordsDeletedActionTriggerProjection,\n      new V2RecordsDeletedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2FieldCreatedActionTriggerProjection,\n      new V2FieldCreatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2FieldDeletedActionTriggerProjection,\n      new V2FieldDeletedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2FieldUpdatedActionTriggerProjection,\n      new V2FieldUpdatedActionTriggerProjection(shareDbService)\n    );\n\n    container.registerInstance(\n      V2TableActionTriggerRequestedProjection,\n      new V2TableActionTriggerRequestedProjection(shareDbService)\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-audit-log.constants.ts",
    "content": "import type { IFieldVo } from '@teable/core';\n\nexport const V2_FIELD_UPDATE_AUDIT_CONTEXT_KEY = '__teable_v2_field_update_audit_context';\nexport const V2_RECORD_PASTE_AUDIT_CONTEXT_KEY = '__teable_v2_record_paste_audit_context';\n\nexport interface IV2FieldUpdateAuditContext {\n  tableId: string;\n  fieldId: string;\n  oldField: IFieldVo;\n  inputField: Record<string, unknown>;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-command-bus-tracing.middleware.ts",
    "content": "import { TeableSpanAttributes } from '@teable/v2-core';\nimport type {\n  CommandBusNext,\n  ICommandBusMiddleware,\n  IExecutionContext,\n} from '@teable/v2-core' with { 'resolution-mode': 'import' };\n\nconst describeError = (error: unknown): string => {\n  if (error instanceof Error) return error.message || error.name;\n  if (typeof error === 'string') return error;\n  try {\n    return JSON.stringify(error) ?? String(error);\n  } catch {\n    return String(error);\n  }\n};\n\n/**\n * Extract relevant IDs from command for tracing.\n * Safely extracts tableId, recordId, fieldId if present.\n */\nconst extractCommandIds = (\n  command: unknown\n): { tableId?: string; recordId?: string; fieldId?: string } => {\n  if (!command || typeof command !== 'object') return {};\n\n  const cmd = command as Record<string, unknown>;\n  return {\n    tableId: typeof cmd.tableId === 'string' ? cmd.tableId : undefined,\n    recordId: typeof cmd.recordId === 'string' ? cmd.recordId : undefined,\n    fieldId: typeof cmd.fieldId === 'string' ? cmd.fieldId : undefined,\n  };\n};\n\nexport class CommandBusTracingMiddleware implements ICommandBusMiddleware {\n  async handle<TCommand, TResult>(\n    context: IExecutionContext,\n    command: TCommand,\n    next: CommandBusNext<TCommand, TResult>\n  ) {\n    const tracer = context.tracer;\n    if (!tracer) {\n      return next(context, command);\n    }\n\n    const commandName =\n      (command as { constructor?: { name?: string } }).constructor?.name ?? 'UnknownCommand';\n    const ids = extractCommandIds(command);\n\n    // Build span attributes with teable prefix\n    const attributes: Record<string, string> = {\n      [TeableSpanAttributes.VERSION]: 'v2',\n      [TeableSpanAttributes.COMPONENT]: 'command',\n      [TeableSpanAttributes.COMMAND]: commandName,\n      [TeableSpanAttributes.OPERATION]: `command.${commandName}`,\n    };\n\n    // Add entity IDs if present\n    if (ids.tableId) {\n      attributes[TeableSpanAttributes.TABLE_ID] = ids.tableId;\n    }\n    if (ids.recordId) {\n      attributes[TeableSpanAttributes.RECORD_ID] = ids.recordId;\n    }\n    if (ids.fieldId) {\n      attributes[TeableSpanAttributes.FIELD_ID] = ids.fieldId;\n    }\n\n    const span = tracer.startSpan(`teable.command.${commandName}`, attributes);\n\n    try {\n      const result = await next(context, command);\n      if (result.isErr()) {\n        span.recordError(result.error.message ?? 'Unknown error');\n      }\n      return result;\n    } catch (error) {\n      span.recordError(describeError(error));\n      throw error;\n    } finally {\n      span.end();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-container.service.ts",
    "content": "import type { OnModuleDestroy } from '@nestjs/common';\nimport { Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { KeyvUndoRedoStore } from '@teable/v2-adapter-undo-redo-keyv';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport {\n  ShareDbPubSubPublisher,\n  registerV2ShareDbRealtime,\n} from '@teable/v2-adapter-realtime-sharedb';\nimport { v2CoreTokens } from '@teable/v2-core';\nimport { createV2NodePgContainer } from '@teable/v2-container-node';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { registerV2ImportServices } from '@teable/v2-import';\nimport { PinoLogger } from 'nestjs-pino';\nimport { ShareDbService } from '../../share-db/share-db.service';\nimport { CacheService } from '../../cache/cache.service';\nimport { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';\nimport { V2ActionTriggerService } from './v2-action-trigger.service';\nimport { CommandBusTracingMiddleware } from './v2-command-bus-tracing.middleware';\nimport { PinoLoggerAdapter } from './v2-logger.adapter';\nimport type { IV2ProjectionRegistrar } from './v2-projection-registrar';\nimport { QueryBusTracingMiddleware } from './v2-query-bus-tracing.middleware';\nimport { OpenTelemetryTracer } from './v2-tracer.adapter';\n\n@Injectable()\nexport class V2ContainerService implements OnModuleDestroy {\n  private containerPromise?: Promise<DependencyContainer>;\n  private readonly dynamicRegistrars: IV2ProjectionRegistrar[] = [];\n\n  constructor(\n    private readonly configService: ConfigService,\n    private readonly pinoLogger: PinoLogger,\n    private readonly shareDbService: ShareDbService,\n    private readonly cacheService: CacheService,\n    private readonly actionTriggerService: V2ActionTriggerService,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  /**\n   * Add a projection registrar dynamically.\n   * Must be called during module initialization (onModuleInit), before getContainer() is called.\n   */\n  addProjectionRegistrar(registrar: IV2ProjectionRegistrar): void {\n    this.dynamicRegistrars.push(registrar);\n  }\n\n  async getContainer(): Promise<DependencyContainer> {\n    if (!this.containerPromise) {\n      const connectionString = this.configService.getOrThrow<string>('PRISMA_DATABASE_URL');\n      const logger = new PinoLoggerAdapter(this.pinoLogger);\n      const tracer = new OpenTelemetryTracer();\n      const commandBusMiddlewares = [new CommandBusTracingMiddleware()];\n      const queryBusMiddlewares = [new QueryBusTracingMiddleware()];\n      const computedUpdateMode = process.env.V2_COMPUTED_UPDATE_MODE;\n      this.containerPromise = createV2NodePgContainer({\n        connectionString,\n        logger,\n        tracer,\n        commandBusMiddlewares,\n        queryBusMiddlewares,\n        computedUpdate: computedUpdateMode === 'sync' ? { mode: 'sync' } : undefined,\n        maxFreeRowLimit: this.configService.get<number>('MAX_FREE_ROW_LIMIT'),\n      }).then((container) => {\n        registerV2ShareDbRealtime(container, {\n          publisher: new ShareDbPubSubPublisher(this.shareDbService.pubsub),\n        });\n        container.registerInstance(\n          v2CoreTokens.undoRedoStore,\n          new KeyvUndoRedoStore(this.cacheService.getKeyv(), {\n            keyPrefix: 'v2:undo-redo',\n            ttlMs: this.thresholdConfig.undoExpirationTime * 1000,\n            maxEntries: this.thresholdConfig.maxUndoStackSize,\n          })\n        );\n        // Register V2 import services (csv, excel adapters)\n        registerV2ImportServices(container);\n        // Register V2 action trigger projections for V1 frontend compatibility\n        this.actionTriggerService.registerProjections(container);\n        // Register dynamically added projections (audit-log, automation, task, etc.)\n        for (const registrar of this.dynamicRegistrars) {\n          registrar.registerProjections(container);\n        }\n        return container;\n      });\n    }\n\n    return this.containerPromise;\n  }\n\n  async onModuleDestroy(): Promise<void> {\n    if (!this.containerPromise) return;\n\n    const container = await this.containerPromise;\n    const db = container.resolve<{ destroy(): Promise<void> }>(v2PostgresDbTokens.db);\n    await db.destroy();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-create-table-compat.constants.ts",
    "content": "import type { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\n\nexport const V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY =\n  '__teable_v2_create_table_legacy_events_context';\n\ntype IV2CreateTableLegacyEventsClsStore = IClsStore & {\n  [V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY]?: boolean;\n};\n\nexport const getV2CreateTableLegacyEventsFlag = (cls: ClsService<IClsStore>): boolean => {\n  return (\n    (cls as ClsService<IV2CreateTableLegacyEventsClsStore>).get(\n      V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY\n    ) === true\n  );\n};\n\nexport const setV2CreateTableLegacyEventsFlag = (\n  cls: ClsService<IClsStore>,\n  value: boolean\n): void => {\n  (cls as ClsService<IV2CreateTableLegacyEventsClsStore>).set(\n    V2_CREATE_TABLE_LEGACY_EVENTS_CONTEXT_KEY,\n    value\n  );\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts",
    "content": "import { Injectable, HttpException, HttpStatus } from '@nestjs/common';\nimport type { IExecutionContext, ITracer } from '@teable/v2-core';\nimport { ActorId, DEFAULT_MAX_TABLE_FIELD_COUNT, v2CoreTokens } from '@teable/v2-core';\nimport { ClsService } from 'nestjs-cls';\nimport { I18nContext, I18nService } from 'nestjs-i18n';\n\nimport type { IClsStore } from '../../types/cls';\nimport type { I18nTranslations } from '../../types/i18n.generated';\nimport { V2ContainerService } from './v2-container.service';\n\nconst defaultMaxSelectFieldOptionsPerField = 5000;\nconst maxSelectFieldOptionsPerFieldEnv = 'MAX_SELECT_FIELD_OPTIONS_PER_FIELD';\nconst maxTableFieldsPerTableEnv = 'MAX_TABLE_FIELDS_PER_TABLE';\n\nconst resolveNonNegativeInteger = (raw: string | undefined, fallback: number): number => {\n  if (raw == null) {\n    return fallback;\n  }\n\n  const parsed = Number(raw);\n  if (!Number.isInteger(parsed) || parsed < 0) {\n    return fallback;\n  }\n\n  return parsed;\n};\n\nconst resolveMaxSelectFieldOptionsPerField = (): number =>\n  resolveNonNegativeInteger(\n    process.env[maxSelectFieldOptionsPerFieldEnv],\n    defaultMaxSelectFieldOptionsPerField\n  );\n\nconst resolveMaxTableFieldsPerTable = (): number =>\n  resolveNonNegativeInteger(process.env[maxTableFieldsPerTableEnv], DEFAULT_MAX_TABLE_FIELD_COUNT);\n\n/**\n * Factory for creating V2 execution contexts with proper tracer and requestId injection.\n * Centralizes the context creation logic to ensure consistent tracing across all V2 operations.\n */\n@Injectable()\nexport class V2ExecutionContextFactory {\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly i18n: I18nService<I18nTranslations>\n  ) {}\n\n  /**\n   * Creates a complete execution context with actorId, tracer, and requestId.\n   * @throws HttpException if user.id is not available or ActorId creation fails\n   */\n  async createContext(): Promise<IExecutionContext> {\n    const container = await this.v2ContainerService.getContainer();\n    const tracer = container.resolve<ITracer>(v2CoreTokens.tracer);\n\n    const userId = this.cls.get('user.id');\n    if (!userId) {\n      throw new HttpException('User not authenticated', HttpStatus.UNAUTHORIZED);\n    }\n\n    const userName = this.cls.get('user.name');\n    const userEmail = this.cls.get('user.email');\n\n    const actorIdResult = ActorId.create(userId);\n    if (actorIdResult.isErr()) {\n      throw new HttpException(actorIdResult.error.message, HttpStatus.INTERNAL_SERVER_ERROR);\n    }\n\n    // Use CLS ID as requestId for ShareDB src matching (consistent with V1 batch.service)\n    // This ensures the client that initiated the request can identify its own ops\n    const requestId = this.cls.getId();\n\n    // Get windowId from CLS for undo/redo tracking\n    const windowId = this.cls.get('windowId');\n    const t: NonNullable<IExecutionContext['$t']> = (key, options) =>\n      this.i18n.t(`table.${key}` as never, {\n        args: options,\n        lang: I18nContext.current()?.lang ?? 'en',\n      }) as string;\n\n    const context: IExecutionContext = {\n      actorId: actorIdResult.value,\n      tracer,\n      requestId,\n      windowId,\n      config: {\n        selectFieldOptions: {\n          maxChoicesPerField: resolveMaxSelectFieldOptionsPerField(),\n        },\n        tableFields: {\n          maxFieldsPerTable: resolveMaxTableFieldsPerTable(),\n        },\n      },\n      $t: t,\n    };\n\n    return {\n      ...context,\n      actorName: userName,\n      actorEmail: userEmail,\n    } as IExecutionContext;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-field-delete-compat.constants.ts",
    "content": "import type { IOtOperation } from '@teable/core';\nimport type { ILegacyDeleteFieldsPayloadSnapshot } from '../field/open-api/field-open-api.service';\n\nexport const V2_FIELD_DELETE_COMPAT_CONTEXT_KEY = '__teable_v2_field_delete_compat_context';\n\nexport interface IV2FieldDeleteCompatContext {\n  tableId: string;\n  userId: string;\n  operationId: string;\n  remainingFieldIds: Set<string>;\n  frozenFieldOps: Record<string, IOtOperation[]>;\n  legacyDeletePayload: ILegacyDeleteFieldsPayloadSnapshot;\n  completed?: boolean;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts",
    "content": "import type { OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ResourceType } from '@teable/openapi';\nimport { FieldDeleted, ProjectionHandler, ok } from '@teable/v2-core';\nimport type { DomainError, IEventHandler, IExecutionContext, Result } from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { ViewService } from '../view/view.service';\nimport { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants';\nimport type { IV2FieldDeleteCompatContext } from './v2-field-delete-compat.constants';\nimport { V2ContainerService } from './v2-container.service';\nimport type { IV2ProjectionRegistrar } from './v2-projection-registrar';\n\nconst getFieldDeleteCompatContext = (\n  context: IExecutionContext,\n  event: FieldDeleted\n): IV2FieldDeleteCompatContext | undefined => {\n  const compatContext = (\n    context as IExecutionContext & {\n      [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]?: IV2FieldDeleteCompatContext;\n    }\n  )[V2_FIELD_DELETE_COMPAT_CONTEXT_KEY];\n\n  if (!compatContext || compatContext.completed) {\n    return undefined;\n  }\n\n  if (compatContext.tableId !== event.tableId.toString()) {\n    return undefined;\n  }\n\n  return compatContext;\n};\n\n@ProjectionHandler(FieldDeleted)\nclass V2FieldDeletedCompatProjection implements IEventHandler<FieldDeleted> {\n  constructor(\n    private readonly prisma: PrismaService,\n    private readonly viewService: ViewService\n  ) {}\n\n  async handle(\n    context: IExecutionContext,\n    event: FieldDeleted\n  ): Promise<Result<void, DomainError>> {\n    const compatContext = getFieldDeleteCompatContext(context, event);\n    if (!compatContext) {\n      return ok(undefined);\n    }\n\n    const fieldId = event.fieldId.toString();\n    if (!compatContext.remainingFieldIds.has(fieldId)) {\n      return ok(undefined);\n    }\n\n    compatContext.remainingFieldIds.delete(fieldId);\n    if (compatContext.remainingFieldIds.size > 0) {\n      return ok(undefined);\n    }\n\n    compatContext.completed = true;\n\n    if (Object.keys(compatContext.frozenFieldOps).length > 0) {\n      await this.viewService.batchUpdateViewByOps(\n        compatContext.tableId,\n        compatContext.frozenFieldOps\n      );\n    }\n\n    await this.prisma.tableTrash.create({\n      data: {\n        id: compatContext.operationId,\n        tableId: compatContext.tableId,\n        createdBy: compatContext.userId,\n        resourceType: ResourceType.Field,\n        snapshot: JSON.stringify({\n          fields: compatContext.legacyDeletePayload.fields,\n          records: compatContext.legacyDeletePayload.records,\n        }),\n      },\n    });\n\n    return ok(undefined);\n  }\n}\n\n@Injectable()\nexport class V2FieldDeleteCompatService implements IV2ProjectionRegistrar, OnModuleInit {\n  private readonly logger = new Logger(V2FieldDeleteCompatService.name);\n\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly prisma: PrismaService,\n    private readonly viewService: ViewService\n  ) {}\n\n  onModuleInit(): void {\n    this.v2ContainerService.addProjectionRegistrar(this);\n  }\n\n  registerProjections(container: DependencyContainer): void {\n    this.logger.debug('Registering V2 field delete compatibility projections');\n    container.registerInstance(\n      V2FieldDeletedCompatProjection,\n      new V2FieldDeletedCompatProjection(this.prisma, this.viewService)\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-logger.adapter.ts",
    "content": "import { createLogScopeContext, type ILogger, type LogContext } from '@teable/v2-core';\nimport type { PinoLogger } from 'nestjs-pino';\n\nexport class PinoLoggerAdapter implements ILogger {\n  constructor(private readonly logger: PinoLogger) {}\n\n  debug(message: string, context?: LogContext): void {\n    if (context) {\n      this.logger.debug(context, message);\n      return;\n    }\n    this.logger.debug(message);\n  }\n\n  info(message: string, context?: LogContext): void {\n    if (context) {\n      this.logger.info(context, message);\n      return;\n    }\n    this.logger.info(message);\n  }\n\n  warn(message: string, context?: LogContext): void {\n    if (context) {\n      this.logger.warn(context, message);\n      return;\n    }\n    this.logger.warn(message);\n  }\n\n  error(message: string, context?: LogContext): void {\n    if (context) {\n      this.logger.error(context, message);\n      return;\n    }\n    this.logger.error(message);\n  }\n\n  child(context: LogContext): ILogger {\n    this.logger.logger.child(context);\n    return this;\n  }\n\n  scope(scope: string, context?: LogContext): ILogger {\n    return this.child(createLogScopeContext(scope, context ?? {}));\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-openapi.controller.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { randomBytes } from 'crypto';\nimport { Controller, Get, Header, Req, Res } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { generateV2OpenApiDocument } from '@teable/v2-contract-http-openapi';\nimport { Request, Response } from 'express';\nimport type { IBaseConfig } from '../../configs/base.config';\nimport { Public } from '../auth/decorators/public.decorator';\n\nconst V2_BASE_PATH = 'api/v2';\nconst OPENAPI_SPEC_PATH = `/${V2_BASE_PATH}/openapi.json`;\nconst SCALAR_CDN_ORIGIN = 'https://cdn.jsdelivr.net';\n\nconst buildServerUrl = (baseConfig: IBaseConfig | undefined, req: Request): string | undefined => {\n  const publicOrigin = baseConfig?.publicOrigin;\n  if (publicOrigin) return publicOrigin;\n\n  const host = req.get('host');\n  if (!host) return undefined;\n\n  return `${req.protocol}://${host}`;\n};\n\nconst buildDocsCsp = (nonce: string): string =>\n  [\n    \"default-src 'self'\",\n    \"base-uri 'self'\",\n    \"frame-ancestors 'self'\",\n    \"object-src 'none'\",\n    \"img-src 'self' data: https:\",\n    \"font-src 'self' data: https:\",\n    \"style-src 'self' https: 'unsafe-inline'\",\n    \"connect-src 'self'\",\n    `script-src 'self' ${SCALAR_CDN_ORIGIN} 'nonce-${nonce}'`,\n    `script-src-elem 'self' ${SCALAR_CDN_ORIGIN} 'nonce-${nonce}'`,\n    \"script-src-attr 'none'\",\n  ].join('; ');\n\nconst buildScalarHtml = (specUrl: string, nonce: string): string => `<!doctype html>\n<html>\n  <head>\n    <title>Teable v2 API</title>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n\n    <script nonce=\"${nonce}\" src=\"https://cdn.jsdelivr.net/npm/@scalar/api-reference\"></script>\n    <script nonce=\"${nonce}\">\n      Scalar.createApiReference('#app', {\n        url: '${specUrl}',\n      });\n    </script>\n  </body>\n</html>\n`;\n\n@Public()\n@Controller(V2_BASE_PATH)\nexport class V2OpenApiController {\n  constructor(private readonly configService: ConfigService) {}\n\n  @Get('openapi.json')\n  @Header('Content-Type', 'application/json')\n  async openapi(@Req() req: Request) {\n    const baseConfig = this.configService.get<IBaseConfig>('base');\n    const serverUrl = buildServerUrl(baseConfig, req);\n\n    const serverBaseUrl = serverUrl ? `${serverUrl.replace(/\\/$/, '')}/${V2_BASE_PATH}` : undefined;\n\n    return generateV2OpenApiDocument({\n      servers: serverBaseUrl ? [{ url: serverBaseUrl }] : undefined,\n    });\n  }\n\n  @Get('docs')\n  @Header('Content-Type', 'text/html; charset=utf-8')\n  docs(@Res({ passthrough: true }) res: Response) {\n    const nonce = randomBytes(16).toString('base64');\n    res.setHeader('Content-Security-Policy', buildDocsCsp(nonce));\n    return buildScalarHtml(OPENAPI_SPEC_PATH, nonce);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-projection-registrar.ts",
    "content": "import type { DependencyContainer } from '@teable/v2-di';\n\n/**\n * Interface for services that register projections with the V2 container.\n * Enterprise modules can implement this interface and call\n * `V2ContainerService.addProjectionRegistrar(this)` in their `onModuleInit` hook.\n */\nexport interface IV2ProjectionRegistrar {\n  registerProjections(container: DependencyContainer): void;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-query-bus-tracing.middleware.ts",
    "content": "import type { QueryBusNext, IQueryBusMiddleware, IExecutionContext } from '@teable/v2-core';\n\nconst describeError = (error: unknown): string => {\n  if (error instanceof Error) return error.message || error.name;\n  if (typeof error === 'string') return error;\n  try {\n    return JSON.stringify(error) ?? String(error);\n  } catch {\n    return String(error);\n  }\n};\n\nexport class QueryBusTracingMiddleware implements IQueryBusMiddleware {\n  async handle<TQuery, TResult>(\n    context: IExecutionContext,\n    query: TQuery,\n    next: QueryBusNext<TQuery, TResult>\n  ) {\n    const tracer = context.tracer;\n    if (!tracer) {\n      return next(context, query);\n    }\n\n    const queryName =\n      (query as { constructor?: { name?: string } }).constructor?.name ?? 'UnknownQuery';\n    const span = tracer.startSpan(`teable.query.${queryName}`, {\n      query: queryName,\n    });\n\n    try {\n      const result = await next(context, query);\n      if (result.isErr()) {\n        span.recordError(result.error.message ?? 'Unknown error');\n      }\n      return result;\n    } catch (error) {\n      span.recordError(describeError(error));\n      throw error;\n    } finally {\n      span.end();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-record-history.service.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { ISelectFieldOptions } from '@teable/core';\nimport { FieldType as CoreFieldType, generateRecordHistoryId } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  FieldId,\n  FieldValueTypeVisitor,\n  ProjectionHandler,\n  RecordUpdated,\n  RecordsBatchUpdated,\n  TableQueryService,\n  ok,\n  v2CoreTokens,\n} from '@teable/v2-core';\nimport type {\n  DomainError,\n  Field,\n  IEventHandler,\n  IExecutionContext,\n  IFieldVisitor,\n  MultipleSelectField,\n  Result,\n  SingleSelectField,\n} from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport { Knex } from 'knex';\nimport { isEqual, isString } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { BaseConfig, IBaseConfig } from '../../configs/base.config';\nimport { EventEmitterService } from '../../event-emitter/event-emitter.service';\nimport { Events } from '../../event-emitter/events';\nimport type { IClsStore } from '../../types/cls';\nimport { V2ContainerService } from './v2-container.service';\nimport type { IV2ProjectionRegistrar } from './v2-projection-registrar';\n\nconst SELECT_FIELD_TYPE_SET = new Set([CoreFieldType.SingleSelect, CoreFieldType.MultipleSelect]);\n\ninterface IRecordHistoryEntry {\n  id: string;\n  table_id: string;\n  record_id: string;\n  field_id: string;\n  before: string;\n  after: string;\n  created_by: string;\n}\n\ninterface IFieldHistoryMeta {\n  type: string;\n  name: string;\n  options: Record<string, unknown> | null | undefined;\n  cellValueType: string;\n  isComputed: boolean;\n}\n\n/**\n * Visitor to extract field options for record history.\n * Returns options in a format compatible with V1 record history.\n */\nclass FieldOptionsVisitor implements IFieldVisitor<Record<string, unknown> | null> {\n  visitSingleLineTextField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitLongTextField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitNumberField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitRatingField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitFormulaField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitRollupField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitSingleSelectField(\n    field: SingleSelectField\n  ): Result<Record<string, unknown> | null, DomainError> {\n    const choices = field.selectOptions().map((opt) => ({\n      id: opt.id().toString(),\n      name: opt.name().toString(),\n      color: opt.color().toString(),\n    }));\n    return ok({ choices });\n  }\n  visitMultipleSelectField(\n    field: MultipleSelectField\n  ): Result<Record<string, unknown> | null, DomainError> {\n    const choices = field.selectOptions().map((opt) => ({\n      id: opt.id().toString(),\n      name: opt.name().toString(),\n      color: opt.color().toString(),\n    }));\n    return ok({ choices });\n  }\n  visitCheckboxField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitAttachmentField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitDateField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitCreatedTimeField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitLastModifiedTimeField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitUserField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitCreatedByField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitLastModifiedByField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitAutoNumberField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitButtonField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitLinkField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitLookupField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitConditionalRollupField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n  visitConditionalLookupField(): Result<Record<string, unknown> | null, DomainError> {\n    return ok(null);\n  }\n}\n\n/**\n * Extracts field metadata from V2 Field domain object.\n */\nconst extractFieldMeta = (field: Field): IFieldHistoryMeta => {\n  const type = field.type().toString();\n  const name = field.name().toString();\n  const isComputed = field.computed().toBoolean();\n\n  // Get cellValueType via visitor\n  const valueTypeResult = field.accept(new FieldValueTypeVisitor());\n  const cellValueType = valueTypeResult.isOk()\n    ? valueTypeResult.value.cellValueType.toString()\n    : 'string';\n\n  // Get options via visitor\n  const optionsResult = field.accept(new FieldOptionsVisitor());\n  const options = optionsResult.isOk() ? optionsResult.value : null;\n\n  return { type, name, options, cellValueType, isComputed };\n};\n\n/**\n * Minimizes field options for select fields to only include choices that match the value.\n */\nconst minimizeFieldOptions = (\n  value: unknown,\n  meta: IFieldHistoryMeta\n): Record<string, unknown> | null | undefined => {\n  const { type, options: _options } = meta;\n\n  if (SELECT_FIELD_TYPE_SET.has(type as CoreFieldType) && _options) {\n    const options = _options as ISelectFieldOptions;\n    const { choices } = options;\n\n    if (value == null) {\n      return { ...options, choices: [] };\n    }\n\n    if (isString(value)) {\n      return { ...options, choices: choices.filter(({ name }) => name === value) };\n    }\n\n    if (Array.isArray(value)) {\n      const valueSet = new Set(value);\n      return { ...options, choices: choices.filter(({ name }) => valueSet.has(name)) };\n    }\n  }\n\n  return _options;\n};\n\n/**\n * Builds the history entry JSON structure for before/after values.\n */\nconst buildHistoryValue = (\n  value: unknown,\n  meta: IFieldHistoryMeta\n): { meta: object; data: unknown } => ({\n  meta: {\n    type: meta.type,\n    name: meta.name,\n    options: minimizeFieldOptions(value, meta),\n    cellValueType: meta.cellValueType,\n  },\n  data: value,\n});\n\n/**\n * V2 projection handler that writes record history for individual record update events.\n */\n@ProjectionHandler(RecordUpdated)\nclass V2RecordUpdatedHistoryProjection implements IEventHandler<RecordUpdated> {\n  constructor(\n    private readonly prisma: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly baseConfig: IBaseConfig,\n    private readonly knex: Knex,\n    private readonly tableQueryService: TableQueryService,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  async handle(\n    context: IExecutionContext,\n    event: RecordUpdated\n  ): Promise<Result<void, DomainError>> {\n    // Check if record history is disabled\n    if (this.baseConfig.recordHistoryDisabled) {\n      return ok(undefined);\n    }\n\n    // Skip computed updates - we only track user-initiated changes\n    if (event.source === 'computed') {\n      return ok(undefined);\n    }\n\n    const tableIdStr = event.tableId.toString();\n    const recordId = event.recordId.toString();\n    const userId = this.cls.get('user.id');\n\n    // Get field IDs from changes\n    if (event.changes.length === 0) {\n      return ok(undefined);\n    }\n\n    // Load table from V2 domain\n    const tableResult = await this.tableQueryService.getById(context, event.tableId);\n    if (tableResult.isErr()) {\n      return ok(undefined); // Silently skip if table not found\n    }\n    const table = tableResult.value;\n\n    // Build field metadata map\n    const fieldMetaMap = new Map<string, IFieldHistoryMeta>();\n    for (const change of event.changes) {\n      const fieldIdResult = FieldId.create(change.fieldId);\n      if (fieldIdResult.isErr()) continue;\n\n      const fieldResult = table.getField((f) => f.id().equals(fieldIdResult.value));\n      if (fieldResult.isOk()) {\n        fieldMetaMap.set(change.fieldId, extractFieldMeta(fieldResult.value));\n      }\n    }\n\n    // Build history entries\n    const recordHistoryList: IRecordHistoryEntry[] = [];\n\n    for (const change of event.changes) {\n      const meta = fieldMetaMap.get(change.fieldId);\n      if (!meta) continue;\n\n      // Skip no-op changes\n      if (isEqual(change.oldValue, change.newValue)) continue;\n\n      // Skip computed fields\n      if (meta.isComputed) continue;\n\n      recordHistoryList.push({\n        id: generateRecordHistoryId(),\n        table_id: tableIdStr,\n        record_id: recordId,\n        field_id: change.fieldId,\n        before: JSON.stringify(buildHistoryValue(change.oldValue, meta)),\n        after: JSON.stringify(buildHistoryValue(change.newValue, meta)),\n        created_by: userId as string,\n      });\n    }\n\n    // Insert history records\n    if (recordHistoryList.length > 0) {\n      const query = this.knex.insert(recordHistoryList).into('record_history').toQuery();\n      await this.prisma.$executeRawUnsafe(query);\n    }\n\n    // Emit RECORD_HISTORY_CREATE event for compatibility\n    this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, {\n      recordIds: [recordId],\n    });\n\n    return ok(undefined);\n  }\n}\n\n/**\n * V2 projection handler that writes record history for batch record update events.\n * RecordsBatchUpdated is used by paste operations.\n */\n@ProjectionHandler(RecordsBatchUpdated)\nclass V2RecordsBatchUpdatedHistoryProjection implements IEventHandler<RecordsBatchUpdated> {\n  constructor(\n    private readonly prisma: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly baseConfig: IBaseConfig,\n    private readonly knex: Knex,\n    private readonly tableQueryService: TableQueryService,\n    private readonly eventEmitterService: EventEmitterService\n  ) {}\n\n  async handle(\n    context: IExecutionContext,\n    event: RecordsBatchUpdated\n  ): Promise<Result<void, DomainError>> {\n    // Check if record history is disabled\n    if (this.baseConfig.recordHistoryDisabled) {\n      return ok(undefined);\n    }\n\n    // Skip computed updates\n    if (event.source === 'computed') {\n      return ok(undefined);\n    }\n\n    const tableIdStr = event.tableId.toString();\n    const userId = this.cls.get('user.id');\n\n    // Collect all field IDs from all updates\n    const fieldIdSet = new Set<string>();\n    for (const update of event.updates) {\n      for (const change of update.changes) {\n        fieldIdSet.add(change.fieldId);\n      }\n    }\n\n    if (fieldIdSet.size === 0) {\n      return ok(undefined);\n    }\n\n    // Load table from V2 domain\n    const tableResult = await this.tableQueryService.getById(context, event.tableId);\n    if (tableResult.isErr()) {\n      return ok(undefined); // Silently skip if table not found\n    }\n    const table = tableResult.value;\n\n    // Build field metadata map\n    const fieldMetaMap = new Map<string, IFieldHistoryMeta>();\n    for (const fieldIdStr of fieldIdSet) {\n      const fieldIdResult = FieldId.create(fieldIdStr);\n      if (fieldIdResult.isErr()) continue;\n\n      const fieldResult = table.getField((f) => f.id().equals(fieldIdResult.value));\n      if (fieldResult.isOk()) {\n        fieldMetaMap.set(fieldIdStr, extractFieldMeta(fieldResult.value));\n      }\n    }\n\n    // Build history entries for all updates\n    const recordHistoryList: IRecordHistoryEntry[] = [];\n    const recordIds: string[] = [];\n\n    const batchSize = 5000;\n\n    for (const update of event.updates) {\n      const recordId = update.recordId;\n      recordIds.push(recordId);\n\n      for (const change of update.changes) {\n        const meta = fieldMetaMap.get(change.fieldId);\n        if (!meta) continue;\n\n        // Skip no-op changes\n        if (isEqual(change.oldValue, change.newValue)) continue;\n\n        // Skip computed fields\n        if (meta.isComputed) continue;\n\n        recordHistoryList.push({\n          id: generateRecordHistoryId(),\n          table_id: tableIdStr,\n          record_id: recordId,\n          field_id: change.fieldId,\n          before: JSON.stringify(buildHistoryValue(change.oldValue, meta)),\n          after: JSON.stringify(buildHistoryValue(change.newValue, meta)),\n          created_by: userId as string,\n        });\n      }\n    }\n\n    // Insert history records in batches\n    for (let i = 0; i < recordHistoryList.length; i += batchSize) {\n      const batch = recordHistoryList.slice(i, i + batchSize);\n      if (batch.length > 0) {\n        const query = this.knex.insert(batch).into('record_history').toQuery();\n        await this.prisma.$executeRawUnsafe(query);\n      }\n    }\n\n    // Emit RECORD_HISTORY_CREATE event for compatibility\n    if (recordIds.length > 0) {\n      this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, {\n        recordIds,\n      });\n    }\n\n    return ok(undefined);\n  }\n}\n\n/**\n * Service that registers V2 record history projections with the V2 container.\n * These projections write record history to the database when records are updated.\n */\n@Injectable()\nexport class V2RecordHistoryService implements IV2ProjectionRegistrar, OnModuleInit {\n  private readonly logger = new Logger(V2RecordHistoryService.name);\n\n  constructor(\n    private readonly prisma: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    @BaseConfig() private readonly baseConfig: IBaseConfig,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly v2ContainerService: V2ContainerService\n  ) {}\n\n  /**\n   * Register this service with V2ContainerService on module initialization.\n   */\n  onModuleInit(): void {\n    this.v2ContainerService.addProjectionRegistrar(this);\n  }\n\n  /**\n   * Register record history projections with the V2 container.\n   */\n  registerProjections(container: DependencyContainer): void {\n    this.logger.log('Registering V2 record history projections');\n\n    // Resolve TableQueryService from V2 container\n    const tableQueryService = container.resolve<TableQueryService>(v2CoreTokens.tableQueryService);\n\n    // Register projection instances with services\n    container.registerInstance(\n      V2RecordUpdatedHistoryProjection,\n      new V2RecordUpdatedHistoryProjection(\n        this.prisma,\n        this.cls,\n        this.baseConfig,\n        this.knex,\n        tableQueryService,\n        this.eventEmitterService\n      )\n    );\n\n    container.registerInstance(\n      V2RecordsBatchUpdatedHistoryProjection,\n      new V2RecordsBatchUpdatedHistoryProjection(\n        this.prisma,\n        this.cls,\n        this.baseConfig,\n        this.knex,\n        tableQueryService,\n        this.eventEmitterService\n      )\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-tracer.adapter.ts",
    "content": "import type { Span as ApiSpan } from '@opentelemetry/api';\nimport { SpanStatusCode, context as otelContext, trace } from '@opentelemetry/api';\nimport type { ISpan, ITracer, SpanAttributeValue, SpanAttributes } from '@teable/v2-core';\n\nclass OpenTelemetrySpan implements ISpan {\n  constructor(public readonly span: ApiSpan) {}\n\n  setAttribute(key: string, value: SpanAttributeValue): void {\n    this.span.setAttribute(key, value);\n  }\n\n  setAttributes(attributes: SpanAttributes): void {\n    this.span.setAttributes(attributes);\n  }\n\n  recordError(message: string): void {\n    this.span.recordException(message);\n    this.span.setStatus({ code: SpanStatusCode.ERROR, message });\n  }\n\n  end(): void {\n    this.span.end();\n  }\n}\n\nexport class OpenTelemetryTracer implements ITracer {\n  constructor(private readonly name = 'v2-core') {}\n\n  startSpan(name: string, attributes?: SpanAttributes): ISpan {\n    const tracer = trace.getTracer(this.name);\n    const span = tracer.startSpan(name, { attributes }, otelContext.active());\n    return new OpenTelemetrySpan(span);\n  }\n\n  async withSpan<T>(span: ISpan, callback: () => Promise<T>): Promise<T> {\n    if (span instanceof OpenTelemetrySpan) {\n      return otelContext.with(trace.setSpan(otelContext.active(), span.span), callback);\n    }\n    return callback();\n  }\n\n  getActiveSpan(): ISpan | undefined {\n    const span = trace.getActiveSpan();\n    if (!span) return undefined;\n    return new OpenTelemetrySpan(span);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-undo-redo.constants.ts",
    "content": "import type { IFieldVo } from '@teable/core';\n\nexport const V2_FIELD_CONVERT_UNDO_CONTEXT_KEY = '__teable_v2_field_convert_undo_context';\n\nexport interface IV2FieldConvertUndoContext {\n  tableId: string;\n  fieldId: string;\n  oldField: IFieldVo;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-user-rename-propagation.service.spec.ts",
    "content": "import { PropagateUserRenameCommand, v2CoreTokens } from '@teable/v2-core';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport type { V2ContainerService } from './v2-container.service';\nimport { V2UserRenamePropagationService } from './v2-user-rename-propagation.service';\n\nconst okResult = <T>(value: T) => ({\n  isErr: () => false,\n  isOk: () => true,\n  value,\n});\n\ndescribe('V2UserRenamePropagationService', () => {\n  it('dispatches the internal user-rename command through the internal v2 command bus', async () => {\n    const commandBus = {\n      execute: vi.fn().mockResolvedValue(okResult(undefined)),\n    };\n    const container = {\n      resolve: (token: symbol) => {\n        if (token === v2CoreTokens.internalCommandBus) return commandBus;\n        if (token === v2CoreTokens.tracer) return {};\n        throw new Error(`Unexpected token: ${String(token)}`);\n      },\n    };\n    const service = new V2UserRenamePropagationService({\n      getContainer: vi.fn().mockResolvedValue(container),\n    } as unknown as V2ContainerService);\n\n    await service.propagateUserRename({\n      actorId: `usr${'a'.repeat(17)}`,\n      userId: `usr${'b'.repeat(17)}`,\n      name: 'Renamed User',\n      requestId: 'test-request-id',\n    });\n\n    expect(commandBus.execute).toHaveBeenCalledTimes(1);\n    const [context, command] = commandBus.execute.mock.calls[0] as [\n      { actorId: { toString: () => string }; requestId: string },\n      PropagateUserRenameCommand,\n    ];\n    expect(context.actorId.toString()).toBe(`usr${'a'.repeat(17)}`);\n    expect(context.requestId).toBe('test-request-id');\n    expect(command).toBeInstanceOf(PropagateUserRenameCommand);\n    expect(command.userId.toString()).toBe(`usr${'b'.repeat(17)}`);\n    expect(command.name).toBe('Renamed User');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2-user-rename-propagation.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport type { IExecutionContext, IInternalCommandBus, ITracer } from '@teable/v2-core';\nimport { ActorId, PropagateUserRenameCommand, v2CoreTokens } from '@teable/v2-core';\n\nimport { V2ContainerService } from './v2-container.service';\n\nexport type IUserRenamePropagationRequest = {\n  actorId: string;\n  userId: string;\n  name: string;\n  requestId?: string;\n};\n\n/**\n * Backend bridge for dispatching the v2 internal user-rename command. The command owns both the\n * physical user-snapshot patch and downstream computed refresh, so the Nest listener does not\n * mutate record tables directly anymore.\n */\n@Injectable()\nexport class V2UserRenamePropagationService {\n  private readonly logger = new Logger(V2UserRenamePropagationService.name);\n\n  constructor(private readonly v2ContainerService: V2ContainerService) {}\n\n  async propagateUserRename(input: IUserRenamePropagationRequest): Promise<void> {\n    const actorIdResult = ActorId.create(input.actorId);\n    if (actorIdResult.isErr()) {\n      this.logger.error(actorIdResult.error.message);\n      return;\n    }\n\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<IInternalCommandBus>(v2CoreTokens.internalCommandBus);\n    const tracer = container.resolve<ITracer>(v2CoreTokens.tracer);\n    const context: IExecutionContext = {\n      actorId: actorIdResult.value,\n      tracer,\n      requestId: input.requestId ?? `user-rename:${input.userId}:${Date.now()}`,\n    };\n    const commandResult = PropagateUserRenameCommand.create({\n      userId: input.userId,\n      name: input.name,\n    });\n    if (commandResult.isErr()) {\n      this.logger.error(commandResult.error.message);\n      return;\n    }\n\n    const executeResult = await commandBus.execute(context, commandResult.value);\n    if (executeResult.isErr()) {\n      this.logger.error(executeResult.error.message);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2.controller.ts",
    "content": "/* eslint-disable @typescript-eslint/ban-ts-comment */\n// @ts-nocheck\nimport { Controller } from '@nestjs/common';\nimport { Implement, implement, ORPCError } from '@orpc/nest';\nimport { v2Contract } from '@teable/v2-contract-http';\nimport {\n  executeCreateTableEndpoint,\n  executeDeleteRecordsEndpoint,\n  executeGetTableByIdEndpoint,\n  executeUpdateRecordsEndpoint,\n} from '@teable/v2-contract-http-implementation/handlers';\nimport { v2CoreTokens } from '@teable/v2-core';\nimport type { IQueryBus, ICommandBus } from '@teable/v2-core' with { 'resolution-mode': 'import' };\nimport { V2ContainerService } from './v2-container.service';\nimport { V2ExecutionContextFactory } from './v2-execution-context.factory';\n\n@Controller('api/v2')\nexport class V2Controller {\n  constructor(\n    private readonly v2Container: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory\n  ) {}\n\n  @Implement(v2Contract.tables)\n  tables() {\n    return {\n      create: implement(v2Contract.tables.create).handler(async ({ input }) => {\n        const container = await this.v2Container.getContainer();\n        const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n        const context = await this.v2ContextFactory.createContext();\n\n        const result = await executeCreateTableEndpoint(context, input, commandBus);\n\n        if (result.status === 201) return result.body;\n\n        if (result.status === 400) {\n          throw new ORPCError('BAD_REQUEST', { message: result.body.error });\n        }\n\n        throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.body.error });\n      }),\n      getById: implement(v2Contract.tables.getById).handler(async ({ input }) => {\n        const container = await this.v2Container.getContainer();\n        const queryBus = container.resolve<IQueryBus>(v2CoreTokens.queryBus);\n        const context = await this.v2ContextFactory.createContext();\n\n        const result = await executeGetTableByIdEndpoint(context, input, queryBus);\n        if (result.status === 200) return result.body;\n\n        if (result.status === 400) {\n          throw new ORPCError('BAD_REQUEST', { message: result.body.error });\n        }\n\n        if (result.status === 404) {\n          throw new ORPCError('NOT_FOUND', { message: result.body.error });\n        }\n\n        // Placeholder for actual implementation\n        throw new ORPCError('NOT_IMPLEMENTED', { message: 'Not implemented yet' });\n      }),\n      deleteRecords: implement(v2Contract.tables.deleteRecords).handler(async ({ input }) => {\n        const container = await this.v2Container.getContainer();\n        const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n        const context = await this.v2ContextFactory.createContext();\n\n        const result = await executeDeleteRecordsEndpoint(context, input, commandBus);\n\n        if (result.status === 200) return result.body;\n\n        if (result.status === 400) {\n          throw new ORPCError('BAD_REQUEST', { message: result.body.error });\n        }\n\n        if (result.status === 404) {\n          throw new ORPCError('NOT_FOUND', { message: result.body.error });\n        }\n\n        throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.body.error });\n      }),\n      updateRecords: implement(v2Contract.tables.updateRecords).handler(async ({ input }) => {\n        const container = await this.v2Container.getContainer();\n        const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n        const context = await this.v2ContextFactory.createContext();\n\n        const result = await executeUpdateRecordsEndpoint(context, input, commandBus);\n\n        if (result.status === 200) return result.body;\n\n        if (result.status === 400) {\n          throw new ORPCError('BAD_REQUEST', { message: result.body.error });\n        }\n\n        if (result.status === 404) {\n          throw new ORPCError('NOT_FOUND', { message: result.body.error });\n        }\n\n        throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.body.error });\n      }),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/v2/v2.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ORPCModule } from '@orpc/nest';\nimport type { Response } from 'express';\nimport { LoggerModule } from '../../logger/logger.module';\nimport { ShareDbModule } from '../../share-db/share-db.module';\nimport { UndoRedoStackService } from '../undo-redo/stack/undo-redo-stack.service';\nimport { ViewModule } from '../view/view.module';\nimport { V2ActionTriggerService } from './v2-action-trigger.service';\nimport { V2ContainerService } from './v2-container.service';\nimport { V2Controller } from './v2.controller';\nimport { V2ExecutionContextFactory } from './v2-execution-context.factory';\nimport { V2FieldDeleteCompatService } from './v2-field-delete-compat.service';\nimport { V2OpenApiController } from './v2-openapi.controller';\nimport { V2RecordHistoryService } from './v2-record-history.service';\nimport { V2UserRenamePropagationService } from './v2-user-rename-propagation.service';\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null;\n\nconst formatIssuePath = (path: unknown): string => {\n  if (typeof path === 'string') return path;\n  if (!Array.isArray(path) || path.length === 0) return '';\n\n  let formatted = '';\n  for (const segment of path) {\n    if (typeof segment === 'number') {\n      formatted += `[${segment}]`;\n      continue;\n    }\n    const text = String(segment);\n    formatted = formatted ? `${formatted}.${text}` : text;\n  }\n\n  return formatted;\n};\n\nconst formatIssue = (issue: unknown): string | null => {\n  if (!isRecord(issue)) return null;\n\n  const message = typeof issue.message === 'string' ? issue.message : '';\n  const path = formatIssuePath(issue.path);\n\n  if (message && path) return `${path}: ${message}`;\n  if (message) return message;\n  if (path) return path;\n  return null;\n};\n\nconst formatIssues = (data: unknown): string[] => {\n  if (!isRecord(data)) return [];\n  const issues = data.issues;\n  if (!Array.isArray(issues)) return [];\n\n  return issues.map(formatIssue).filter((issue): issue is string => Boolean(issue));\n};\n\nconst toErrorMessage = (body: unknown): string => {\n  if (typeof body === 'string') return body;\n  if (!isRecord(body)) return 'Unexpected error';\n\n  const message = typeof body.message === 'string' ? body.message : 'Unexpected error';\n  const issues = formatIssues(body.data);\n  if (issues.length > 0) return `${message}: ${issues.join('; ')}`;\n\n  return message;\n};\n\n@Module({\n  imports: [\n    ORPCModule.forRoot({\n      sendResponseInterceptors: [\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        async (options: any) => {\n          const { response, standardResponse, next } = options;\n          if (standardResponse.status < 400) return next();\n\n          const expressResponse = response as Response;\n          expressResponse.status(standardResponse.status);\n          for (const [key, value] of Object.entries(standardResponse.headers)) {\n            if (value != null) {\n              expressResponse.setHeader(\n                key,\n                value as unknown as string | number | readonly string[]\n              );\n            }\n          }\n\n          return { ok: false as const, error: toErrorMessage(standardResponse.body) };\n        },\n      ],\n    }),\n    LoggerModule.register(),\n    ShareDbModule,\n    ViewModule,\n  ],\n  controllers: [V2Controller, V2OpenApiController],\n  providers: [\n    V2ContainerService,\n    V2ExecutionContextFactory,\n    V2ActionTriggerService,\n    V2UserRenamePropagationService,\n    V2FieldDeleteCompatService,\n    V2RecordHistoryService,\n    UndoRedoStackService,\n  ],\n  exports: [V2ContainerService, V2ExecutionContextFactory, V2UserRenamePropagationService],\n})\nexport class V2Module {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/constant.ts",
    "content": "import { ViewType } from '@teable/core';\nimport type { IShareViewMeta } from '@teable/core';\n\nexport const ROW_ORDER_FIELD_PREFIX = '__row';\n\nexport const defaultShareMetaMap: Record<ViewType, IShareViewMeta | undefined> = {\n  [ViewType.Form]: {\n    submit: {\n      allow: true,\n    },\n  },\n  [ViewType.Kanban]: {\n    includeRecords: true,\n  },\n  [ViewType.Grid]: {\n    includeRecords: true,\n  },\n  [ViewType.Calendar]: {\n    includeRecords: true,\n  },\n  [ViewType.Gallery]: {\n    includeRecords: true,\n  },\n  [ViewType.Plugin]: undefined,\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/calendar-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { CalendarViewCore } from '@teable/core';\n\nexport class CalendarViewDto extends CalendarViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    includeRecords: true,\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/factory.ts",
    "content": "import type { IViewVo } from '@teable/core';\nimport { assertNever, ViewType } from '@teable/core';\nimport type { View } from '@teable/db-main-prisma';\nimport { plainToInstance } from 'class-transformer';\nimport { CalendarViewDto } from './calendar-view.dto';\nimport { FormViewDto } from './form-view.dto';\nimport { GalleryViewDto } from './gallery-view.dto';\nimport { GridViewDto } from './grid-view.dto';\nimport { KanbanViewDto } from './kanban-view.dto';\nimport { PluginViewDto } from './plugin-view.dto';\n\nexport function createViewInstanceByRaw(viewRaw: View) {\n  const viewVo = createViewVoByRaw(viewRaw);\n\n  switch (viewVo.type) {\n    case ViewType.Grid:\n      return plainToInstance(GridViewDto, viewVo);\n    case ViewType.Kanban:\n      return plainToInstance(KanbanViewDto, viewVo);\n    case ViewType.Gallery:\n      return plainToInstance(GalleryViewDto, viewVo);\n    case ViewType.Calendar:\n      return plainToInstance(CalendarViewDto, viewVo);\n    case ViewType.Form:\n      return plainToInstance(FormViewDto, viewVo);\n    case ViewType.Plugin:\n      return plainToInstance(PluginViewDto, viewVo);\n    default:\n      assertNever(viewVo.type);\n  }\n}\n\nexport function createViewVoByRaw(viewRaw: View): IViewVo {\n  return {\n    id: viewRaw.id,\n    name: viewRaw.name,\n    type: viewRaw.type as ViewType,\n    description: viewRaw.description || undefined,\n    options: JSON.parse(viewRaw.options as string) || undefined,\n    filter: JSON.parse(viewRaw.filter as string) || undefined,\n    sort: JSON.parse(viewRaw.sort as string) || undefined,\n    group: JSON.parse(viewRaw.group as string) || undefined,\n    shareId: viewRaw.shareId || undefined,\n    shareMeta: JSON.parse(viewRaw.shareMeta as string) || undefined,\n    enableShare: viewRaw.enableShare || undefined,\n    createdBy: viewRaw.createdBy,\n    lastModifiedBy: viewRaw.lastModifiedBy || undefined,\n    createdTime: viewRaw.createdTime.toISOString(),\n    lastModifiedTime: viewRaw.lastModifiedTime ? viewRaw.lastModifiedTime.toISOString() : undefined,\n    columnMeta: JSON.parse(viewRaw.columnMeta as string) || undefined,\n    isLocked: viewRaw.isLocked || undefined,\n  };\n}\n\nexport type IViewInstance = ReturnType<typeof createViewInstanceByRaw>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/form-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { FormViewCore } from '@teable/core';\n\nexport class FormViewDto extends FormViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    submit: {\n      allow: true,\n    },\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/gallery-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { GalleryViewCore } from '@teable/core';\n\nexport class GalleryViewDto extends GalleryViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    includeRecords: true,\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/grid-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { GridViewCore } from '@teable/core';\n\nexport class GridViewDto extends GridViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    includeRecords: true,\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/kanban-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { KanbanViewCore } from '@teable/core';\n\nexport class KanbanViewDto extends KanbanViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    includeRecords: true,\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/model/plugin-view.dto.ts",
    "content": "import type { IShareViewMeta } from '@teable/core';\nimport { PluginViewCore } from '@teable/core';\n\nexport class PluginViewDto extends PluginViewCore {\n  defaultShareMeta: IShareViewMeta = {\n    includeRecords: true,\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts",
    "content": "import { HttpException, HttpStatus, Injectable } from '@nestjs/common';\nimport type { IUpdateRecordOrdersRo } from '@teable/openapi';\nimport { executeReorderRecordsEndpoint } from '@teable/v2-contract-http-implementation/handlers';\nimport type { ICommandBus } from '@teable/v2-core';\nimport { v2CoreTokens } from '@teable/v2-core';\n\nimport { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception';\nimport { V2ContainerService } from '../../v2/v2-container.service';\nimport { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory';\n\nconst internalServerError = 'Internal server error';\n\n@Injectable()\nexport class ViewOpenApiV2Service {\n  constructor(\n    private readonly v2ContainerService: V2ContainerService,\n    private readonly v2ContextFactory: V2ExecutionContextFactory\n  ) {}\n\n  private throwV2Error(\n    error: {\n      code: string;\n      message: string;\n      tags?: ReadonlyArray<string>;\n      details?: Readonly<Record<string, unknown>>;\n    },\n    status: number\n  ): never {\n    throw new CustomHttpException(error.message, getDefaultCodeByStatus(status), {\n      domainCode: error.code,\n      domainTags: error.tags,\n      details: error.details,\n    });\n  }\n\n  async updateRecordOrders(\n    tableId: string,\n    viewId: string,\n    updateRecordOrdersRo: IUpdateRecordOrdersRo\n  ): Promise<void> {\n    const container = await this.v2ContainerService.getContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const context = await this.v2ContextFactory.createContext();\n\n    const v2Input = {\n      tableId,\n      recordIds: updateRecordOrdersRo.recordIds,\n      order: {\n        viewId,\n        anchorId: updateRecordOrdersRo.anchorId,\n        position: updateRecordOrdersRo.position,\n      },\n    };\n\n    const result = await executeReorderRecordsEndpoint(context, v2Input, commandBus);\n    if (result.status === 200 && result.body.ok) {\n      return;\n    }\n\n    if (!result.body.ok) {\n      this.throwV2Error(result.body.error, result.status);\n    }\n\n    throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/open-api/view-open-api.controller.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Post,\n  Put,\n  Query,\n  Headers,\n  UseGuards,\n  UseInterceptors,\n} from '@nestjs/common';\nimport type { IViewVo } from '@teable/core';\nimport {\n  viewRoSchema,\n  manualSortRoSchema,\n  IManualSortRo,\n  IViewRo,\n  IColumnMetaRo,\n  columnMetaRoSchema,\n  IFilterRo,\n  IViewGroupRo,\n  filterRoSchema,\n  viewGroupRoSchema,\n} from '@teable/core';\nimport {\n  viewNameRoSchema,\n  IViewNameRo,\n  viewDescriptionRoSchema,\n  IViewDescriptionRo,\n  viewShareMetaRoSchema,\n  IViewShareMetaRo,\n  viewSortRoSchema,\n  IViewSortRo,\n  viewOptionsRoSchema,\n  IViewOptionsRo,\n  updateOrderRoSchema,\n  IUpdateOrderRo,\n  updateRecordOrdersRoSchema,\n  IUpdateRecordOrdersRo,\n  viewInstallPluginRoSchema,\n  IViewInstallPluginRo,\n  viewPluginUpdateStorageRoSchema,\n  IViewPluginUpdateStorageRo,\n  viewLockedRoSchema,\n  IViewLockedRo,\n} from '@teable/openapi';\nimport type {\n  IEnableShareViewVo,\n  IGetViewFilterLinkRecordsVo,\n  IGetViewInstallPluginVo,\n  IViewInstallPluginVo,\n} from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { ZodValidationPipe } from '../../..//zod.validation.pipe';\nimport { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';\nimport { Permissions } from '../../auth/decorators/permissions.decorator';\nimport { UseV2Feature } from '../../canary/decorators/use-v2-feature.decorator';\nimport { V2FeatureGuard } from '../../canary/guards/v2-feature.guard';\nimport { V2IndicatorInterceptor } from '../../canary/interceptors/v2-indicator.interceptor';\nimport { TableDomainQueryService } from '../../table-domain';\nimport { ViewService } from '../view.service';\nimport { ViewOpenApiV2Service } from './view-open-api-v2.service';\nimport { ViewOpenApiService } from './view-open-api.service';\n\n@Controller('api/table/:tableId/view')\n@AllowAnonymous()\nexport class ViewOpenApiController {\n  constructor(\n    private readonly viewService: ViewService,\n    private readonly viewOpenApiService: ViewOpenApiService,\n    private readonly viewOpenApiV2Service: ViewOpenApiV2Service,\n    protected readonly tableDomainQueryService: TableDomainQueryService,\n    private readonly cls: ClsService<IClsStore>\n  ) {}\n\n  @Permissions('view|read')\n  @Get(':viewId')\n  async getView(\n    @Param('tableId') _tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<IViewVo> {\n    return await this.viewService.getViewById(viewId);\n  }\n\n  @Permissions('view|read')\n  @Get()\n  async getViews(@Param('tableId') tableId: string): Promise<IViewVo[]> {\n    return await this.viewService.getViews(tableId);\n  }\n\n  @Permissions('view|create')\n  @Post()\n  @EmitControllerEvent(Events.OPERATION_VIEW_CREATE)\n  async createView(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(viewRoSchema)) viewRo: IViewRo\n  ): Promise<IViewVo> {\n    return await this.viewOpenApiService.createView(tableId, viewRo);\n  }\n\n  @Permissions('view|delete')\n  @Delete('/:viewId')\n  async deleteView(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Headers('x-window-id') windowId?: string\n  ) {\n    return await this.viewOpenApiService.deleteView(tableId, viewId, windowId);\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/name')\n  async updateName(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewNameRoSchema)) viewNameRo: IViewNameRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'name',\n      viewNameRo.name,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/description')\n  async updateDescription(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewDescriptionRoSchema)) viewDescriptionRo: IViewDescriptionRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'description',\n      viewDescriptionRo.description,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/locked')\n  async updateLocked(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewLockedRoSchema)) viewLockedRo: IViewLockedRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'isLocked',\n      viewLockedRo.isLocked,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/share-meta')\n  async updateShareMeta(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewShareMetaRoSchema)) viewShareMetaRo: IViewShareMetaRo\n  ): Promise<void> {\n    return await this.viewOpenApiService.updateShareMeta(tableId, viewId, viewShareMetaRo);\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/manual-sort')\n  async manualSort(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(manualSortRoSchema)) updateViewOrderRo: IManualSortRo\n  ): Promise<void> {\n    return await this.viewOpenApiService.manualSort(tableId, viewId, updateViewOrderRo);\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/column-meta')\n  async updateColumnMeta(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(columnMetaRoSchema)) updateViewColumnMetaRo: IColumnMetaRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.updateViewColumnMeta(\n      tableId,\n      viewId,\n      updateViewColumnMetaRo,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/filter')\n  async updateViewFilter(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(filterRoSchema)) updateViewFilterRo: IFilterRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'filter',\n      updateViewFilterRo.filter,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/sort')\n  async updateViewSort(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewSortRoSchema)) updateViewSortRo: IViewSortRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'sort',\n      updateViewSortRo.sort,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/group')\n  async updateViewGroup(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewGroupRoSchema)) updateViewGroupRo: IViewGroupRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.setViewProperty(\n      tableId,\n      viewId,\n      'group',\n      updateViewGroupRo.group,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Patch('/:viewId/options')\n  async updateViewOptions(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewOptionsRoSchema)) updateViewOptionRo: IViewOptionsRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.patchViewOptions(\n      tableId,\n      viewId,\n      updateViewOptionRo.options,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/order')\n  async updateViewOrder(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(updateOrderRoSchema)) updateOrderRo: IUpdateOrderRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.updateViewOrder(tableId, viewId, updateOrderRo, windowId);\n  }\n\n  @Permissions('view|update')\n  @Put('/:viewId/record-order')\n  @UseV2Feature('reorderRecords')\n  @UseGuards(V2FeatureGuard)\n  @UseInterceptors(V2IndicatorInterceptor)\n  async updateRecordOrders(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(updateRecordOrdersRoSchema))\n    updateRecordOrdersRo: IUpdateRecordOrdersRo,\n    @Headers('x-window-id') windowId?: string\n  ): Promise<void> {\n    if (this.cls.get('useV2')) {\n      await this.viewOpenApiV2Service.updateRecordOrders(tableId, viewId, updateRecordOrdersRo);\n      return;\n    }\n\n    const table = await this.tableDomainQueryService.getTableDomainById(tableId);\n    return await this.viewOpenApiService.updateRecordOrders(\n      table,\n      viewId,\n      updateRecordOrdersRo,\n      windowId\n    );\n  }\n\n  @Permissions('view|update')\n  @Post('/:viewId/refresh-share-id')\n  async refreshShareId(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<IEnableShareViewVo> {\n    return await this.viewOpenApiService.refreshShareId(tableId, viewId);\n  }\n\n  @Permissions('view|share')\n  @Post('/:viewId/enable-share')\n  async enableShare(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<IEnableShareViewVo> {\n    return await this.viewOpenApiService.enableShare(tableId, viewId);\n  }\n\n  @Permissions('view|update')\n  @Post('/:viewId/disable-share')\n  async disableShare(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<void> {\n    return await this.viewOpenApiService.disableShare(tableId, viewId);\n  }\n\n  @Permissions('view|read')\n  @Get('/:viewId/filter-link-records')\n  async getFilterLinkRecords(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<IGetViewFilterLinkRecordsVo> {\n    return this.viewOpenApiService.getFilterLinkRecords(tableId, viewId);\n  }\n\n  @Permissions('view|read')\n  @Get('/socket/snapshot-bulk')\n  async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) {\n    return this.viewService.getSnapshotBulk(tableId, ids);\n  }\n\n  @Permissions('view|read')\n  @Get('/socket/doc-ids')\n  async getDocIds(@Param('tableId') tableId: string) {\n    return this.viewService.getDocIdsByQuery(tableId, undefined);\n  }\n\n  @Permissions('view|create')\n  @Post('/plugin')\n  async pluginInstall(\n    @Param('tableId') tableId: string,\n    @Body(new ZodValidationPipe(viewInstallPluginRoSchema)) ro: IViewInstallPluginRo\n  ): Promise<IViewInstallPluginVo> {\n    return this.viewOpenApiService.pluginInstall(tableId, ro);\n  }\n\n  @Get(':viewId/plugin')\n  @Permissions('view|read')\n  getPluginInstall(\n    @Param('tableId') tableId: string,\n    @Param('viewId') viewId: string\n  ): Promise<IGetViewInstallPluginVo> {\n    return this.viewOpenApiService.getPluginInstall(tableId, viewId);\n  }\n\n  @Permissions('view|update')\n  @Patch(':viewId/plugin/:pluginInstallId')\n  async pluginUpdateStorage(\n    @Param('viewId') viewId: string,\n    @Body(new ZodValidationPipe(viewPluginUpdateStorageRoSchema))\n    ro: IViewPluginUpdateStorageRo\n  ) {\n    return this.viewOpenApiService.updatePluginStorage(viewId, ro.storage);\n  }\n\n  @Permissions('view|create')\n  @Post('/:viewId/duplicate')\n  async duplicateView(@Param('tableId') tableId: string, @Param('viewId') viewId: string) {\n    return this.viewOpenApiService.duplicateView(tableId, viewId);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../../../share-db/share-db.module';\nimport { CanaryModule } from '../../canary/canary.module';\nimport { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module';\nimport { FieldModule } from '../../field/field.module';\nimport { RecordModule } from '../../record/record.module';\nimport { TableDomainQueryModule } from '../../table-domain';\nimport { V2Module } from '../../v2/v2.module';\nimport { ViewModule } from '../view.module';\nimport { ViewOpenApiV2Service } from './view-open-api-v2.service';\nimport { ViewOpenApiController } from './view-open-api.controller';\nimport { ViewOpenApiService } from './view-open-api.service';\n\n@Module({\n  imports: [\n    ViewModule,\n    ShareDbModule,\n    RecordModule,\n    FieldModule,\n    FieldCalculateModule,\n    TableDomainQueryModule,\n    V2Module,\n    CanaryModule,\n  ],\n  controllers: [ViewOpenApiController],\n  providers: [ViewOpenApiService, ViewOpenApiV2Service],\n  exports: [ViewOpenApiService, ViewOpenApiV2Service],\n})\nexport class ViewOpenApiModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/open-api/view-open-api.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../../global/global.module';\nimport { ViewOpenApiModule } from './view-open-api.module';\nimport { ViewOpenApiService } from './view-open-api.service';\n\ndescribe('ViewOpenApiService', () => {\n  let service: ViewOpenApiService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, ViewOpenApiModule],\n    }).compile();\n\n    service = module.get<ViewOpenApiService>(ViewOpenApiService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable, Logger } from '@nestjs/common';\nimport type {\n  IOtOperation,\n  IViewRo,\n  IViewVo,\n  IColumnMetaRo,\n  IViewOptions,\n  IGridColumnMeta,\n  IFilter,\n  IFilterItem,\n  ILinkFieldOptions,\n  IPluginViewOptions,\n  IViewPropertyKeys,\n  ISort,\n  IGroup,\n  TableDomain,\n} from '@teable/core';\nimport {\n  ViewType,\n  IManualSortRo,\n  ViewOpBuilder,\n  generateShareId,\n  VIEW_JSON_KEYS,\n  validateOptionsType,\n  FieldType,\n  IdPrefix,\n  generatePluginInstallId,\n  generateOperationId,\n  extractFieldIdsFromFilter,\n  validateFilterOperatorModeCompatibility,\n  HttpErrorCode,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { PluginPosition, PluginStatus } from '@teable/openapi';\nimport type {\n  IViewPluginUpdateStorageRo,\n  IGetViewFilterLinkRecordsVo,\n  IUpdateOrderRo,\n  IUpdateRecordOrdersRo,\n  IViewInstallPluginRo,\n  IViewShareMetaRo,\n} from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { keyBy, pick } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';\nimport { CustomHttpException } from '../../../custom.exception';\nimport { InjectDbProvider } from '../../../db-provider/db.provider';\nimport { IDbProvider } from '../../../db-provider/db.provider.interface';\nimport { EventEmitterService } from '../../../event-emitter/event-emitter.service';\nimport { Events } from '../../../event-emitter/events';\nimport type { IClsStore } from '../../../types/cls';\nimport { Timing } from '../../../utils/timing';\nimport { updateMultipleOrders, updateOrder } from '../../../utils/update-order';\nimport { FieldViewSyncService } from '../../field/field-calculate/field-view-sync.service';\nimport { FieldService } from '../../field/field.service';\nimport type { IFieldInstance } from '../../field/model/factory';\nimport { createFieldInstanceByRaw, createFieldInstanceByVo } from '../../field/model/factory';\nimport { RecordService } from '../../record/record.service';\nimport { createViewInstanceByRaw } from '../model/factory';\nimport { ViewService } from '../view.service';\n\n@Injectable()\nexport class ViewOpenApiService {\n  private logger = new Logger(ViewOpenApiService.name);\n\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly recordService: RecordService,\n    private readonly viewService: ViewService,\n    private readonly fieldService: FieldService,\n    private readonly fieldViewSyncService: FieldViewSyncService,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly cls: ClsService<IClsStore>,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig\n  ) {}\n\n  async createView(tableId: string, viewRo: IViewRo) {\n    if (viewRo.type === ViewType.Plugin) {\n      const res = await this.pluginInstall(tableId, {\n        name: viewRo.name,\n        pluginId: (viewRo.options as IPluginViewOptions).pluginId,\n        shareId: viewRo.shareId,\n        shareMeta: viewRo.shareMeta,\n        enableShare: viewRo.enableShare,\n      });\n      return this.viewService.getViewById(res.viewId);\n    }\n    return await this.prismaService.$tx(async () => {\n      return this.createViewInner(tableId, viewRo);\n    });\n  }\n\n  async deleteView(tableId: string, viewId: string, windowId?: string) {\n    const result = await this.prismaService.$tx(async () => {\n      await this.fieldViewSyncService.deleteLinkOptionsDependenciesByViewId(tableId, viewId);\n      return await this.deleteViewInner(tableId, viewId);\n    });\n\n    this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_DELETE, {\n      operationId: generateOperationId(),\n      windowId,\n      tableId,\n      viewId,\n      userId: this.cls.get('user.id'),\n    });\n\n    return result;\n  }\n\n  private async createViewInner(tableId: string, viewRo: IViewRo): Promise<IViewVo> {\n    return await this.viewService.createView(tableId, viewRo);\n  }\n\n  private async deleteViewInner(tableId: string, viewId: string) {\n    return await this.viewService.deleteView(tableId, viewId);\n  }\n\n  private updateRecordOrderSql(orderRawSql: string, dbTableName: string, indexField: string) {\n    return this.knex\n      .raw(\n        `\n        UPDATE :dbTableName:\n        SET :indexField: = temp_order.new_order\n        FROM (\n          SELECT __id, ROW_NUMBER() OVER (ORDER BY ${orderRawSql}) AS new_order FROM :dbTableName:\n        ) AS temp_order\n        WHERE :dbTableName:.__id = temp_order.__id AND :dbTableName:.:indexField: != temp_order.new_order;\n      `,\n        {\n          dbTableName,\n          indexField,\n        }\n      )\n      .toQuery();\n  }\n\n  @Timing()\n  async manualSort(tableId: string, viewId: string, viewOrderRo: IManualSortRo) {\n    const { sortObjs } = viewOrderRo;\n    const dbTableName = await this.recordService.getDbTableName(tableId);\n    const fields = await this.fieldService.getFieldsByQuery(tableId, { viewId });\n    const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId);\n\n    const queryBuilder = this.knex(dbTableName);\n\n    const fieldInsMap = fields.reduce(\n      (map, field) => {\n        map[field.id] = createFieldInstanceByVo(field);\n        return map;\n      },\n      {} as Record<string, IFieldInstance>\n    );\n\n    const orderRawSql = this.dbProvider\n      .sortQuery(queryBuilder, fieldInsMap, sortObjs, undefined, undefined)\n      .getRawSortSQLText();\n\n    // build ops\n    const newSort = {\n      sortObjs: sortObjs,\n      manualSort: true,\n    };\n\n    await this.prismaService.$tx(\n      async (prisma) => {\n        await prisma.$executeRawUnsafe(\n          this.updateRecordOrderSql(orderRawSql, dbTableName, indexField)\n        );\n        await this.viewService.updateViewSort(tableId, viewId, newSort);\n      },\n      {\n        timeout: this.thresholdConfig.bigTransactionTimeout,\n      }\n    );\n  }\n\n  async updateViewColumnMeta(\n    tableId: string,\n    viewId: string,\n    columnMetaRo: IColumnMetaRo,\n    windowId?: string\n  ) {\n    const view = await this.prismaService.view\n      .findFirstOrThrow({\n        where: { tableId, id: viewId },\n        select: {\n          columnMeta: true,\n          version: true,\n          id: true,\n          type: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `View not found with id: ${viewId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.notFound',\n            },\n          }\n        );\n      });\n\n    // validate field legal\n    const fields = await this.prismaService.field.findMany({\n      where: { tableId, deletedTime: null },\n      select: {\n        id: true,\n        isPrimary: true,\n      },\n    });\n    const primaryFields = fields.filter((field) => field.isPrimary).map((field) => field.id);\n\n    const isHiddenPrimaryField = columnMetaRo.some(\n      (f) => primaryFields.includes(f.fieldId) && (f.columnMeta as IGridColumnMeta).hidden\n    );\n    const fieldIds = columnMetaRo.map(({ fieldId }) => fieldId);\n\n    if (!fieldIds.every((id) => fields.map(({ id }) => id).includes(id))) {\n      throw new CustomHttpException(\n        `Fields ${fieldIds.join(', ')} not found in table ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.field.notFoundInTable',\n            context: {\n              fieldIds: fieldIds.join(', '),\n              tableId,\n            },\n          },\n        }\n      );\n    }\n\n    const allowHiddenPrimaryType = [ViewType.Calendar, ViewType.Form];\n    /**\n     * validate whether hidden primary field\n     * only form view or list view(todo) can hidden primary field\n     */\n    if (isHiddenPrimaryField && !allowHiddenPrimaryType.includes(view.type as ViewType)) {\n      throw new CustomHttpException(\n        `Primary field can not be hidden for view type ${view.type}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.primaryFieldCannotBeHidden',\n          },\n        }\n      );\n    }\n\n    const curColumnMeta = JSON.parse(view.columnMeta);\n    const ops: IOtOperation[] = [];\n\n    columnMetaRo.forEach(({ fieldId, columnMeta }) => {\n      const obj = {\n        fieldId,\n        newColumnMeta: { ...curColumnMeta[fieldId], ...columnMeta },\n        oldColumnMeta: curColumnMeta[fieldId] ? curColumnMeta[fieldId] : undefined,\n      };\n      ops.push(ViewOpBuilder.editor.updateViewColumnMeta.build(obj));\n    });\n\n    await this.updateViewByOps(tableId, viewId, ops);\n\n    if (windowId) {\n      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {\n        tableId,\n        windowId,\n        viewId,\n        userId: this.cls.get('user.id'),\n        byOps: ops,\n      });\n    }\n  }\n\n  async updateShareMeta(tableId: string, viewId: string, viewShareMetaRo: IViewShareMetaRo) {\n    return this.setViewProperty(tableId, viewId, 'shareMeta', viewShareMetaRo);\n  }\n\n  async validateFilter(tableId: string, filter: IFilter) {\n    const fieldIds = extractFieldIdsFromFilter(filter);\n    if (fieldIds.length > 0) {\n      const fields = await this.prismaService.field.findMany({\n        where: { tableId, id: { in: fieldIds } },\n        select: { id: true, type: true },\n      });\n\n      // Check for unsupported Button type fields\n      const unsupportedFields = fields.filter((f) => f.type === FieldType.Button);\n      if (unsupportedFields.length > 0) {\n        throw new CustomHttpException(\n          `Filter fields ${unsupportedFields.map((f) => f.id).join(', ')} are unsupported ${FieldType.Button} type fields`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.filterUnsupportedFieldType',\n            },\n          }\n        );\n      }\n\n      // Validate operator + mode compatibility for date fields\n      const fieldTypeMap = fields.reduce(\n        (acc, f) => {\n          acc[f.id] = f.type as FieldType;\n          return acc;\n        },\n        {} as Record<string, FieldType>\n      );\n      const validationErrors = validateFilterOperatorModeCompatibility(filter, fieldTypeMap);\n      if (validationErrors.length > 0) {\n        throw new CustomHttpException(validationErrors[0].message, HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.view.filterInvalidOperatorMode',\n          },\n        });\n      }\n    }\n  }\n\n  async validateSort(tableId: string, sort: ISort) {\n    const fieldIds = sort?.sortObjs?.map(({ fieldId }) => fieldId) || [];\n    if (fieldIds.length > 0) {\n      const unsupportedFields = await this.prismaService.field.findMany({\n        where: { tableId, id: { in: fieldIds }, type: FieldType.Button },\n        select: { id: true },\n      });\n      if (unsupportedFields.length > 0) {\n        throw new CustomHttpException(\n          `Sort fields ${unsupportedFields.map((f) => f.id).join(', ')} are unsupported ${FieldType.Button} type fields`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.sortUnsupportedFieldType',\n            },\n          }\n        );\n      }\n    }\n  }\n\n  async validateGroup(tableId: string, group: IGroup) {\n    const fieldIds = group?.map(({ fieldId }) => fieldId) || [];\n    if (fieldIds.length > 0) {\n      const unsupportedFields = await this.prismaService.field.findMany({\n        where: { tableId, id: { in: fieldIds }, type: FieldType.Button },\n        select: { id: true },\n      });\n      if (unsupportedFields.length > 0) {\n        throw new CustomHttpException(\n          `Group fields ${unsupportedFields.map((f) => f.id).join(', ')} are unsupported ${FieldType.Button} type fields`,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.groupUnsupportedFieldType',\n            },\n          }\n        );\n      }\n    }\n  }\n\n  async setViewProperty(\n    tableId: string,\n    viewId: string,\n    key: IViewPropertyKeys,\n    newValue: unknown,\n    windowId?: string\n  ) {\n    const curView = await this.prismaService.view\n      .findFirstOrThrow({\n        select: { [key]: true },\n        where: { tableId, id: viewId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `View not found with id: ${viewId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.notFound',\n            },\n          }\n        );\n      });\n\n    if (key === 'filter') {\n      await this.validateFilter(tableId, newValue as IFilter);\n    }\n\n    if (key === 'sort') {\n      await this.validateSort(tableId, newValue as ISort);\n    }\n\n    if (key === 'group') {\n      await this.validateGroup(tableId, newValue as IGroup);\n    }\n\n    const oldValue =\n      curView[key] != null && VIEW_JSON_KEYS.includes(key)\n        ? JSON.parse(curView[key])\n        : curView[key];\n    const ops = ViewOpBuilder.editor.setViewProperty.build({\n      key,\n      newValue,\n      oldValue,\n    });\n\n    await this.updateViewByOps(tableId, viewId, [ops]);\n\n    if (windowId) {\n      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {\n        tableId,\n        windowId,\n        viewId,\n        userId: this.cls.get('user.id'),\n        byKey: {\n          key,\n          newValue,\n          oldValue,\n        },\n      });\n    }\n  }\n\n  async updateViewByOps(tableId: string, viewId: string, ops: IOtOperation[]) {\n    return await this.prismaService.$tx(async () => {\n      return await this.viewService.updateViewByOps(tableId, viewId, ops);\n    });\n  }\n\n  async patchViewOptions(\n    tableId: string,\n    viewId: string,\n    viewOptions: IViewOptions,\n    windowId?: string\n  ) {\n    const curView = await this.prismaService.view\n      .findFirstOrThrow({\n        select: { options: true, type: true },\n        where: { tableId, id: viewId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `View not found with id: ${viewId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.notFound',\n            },\n          }\n        );\n      });\n    const { options, type: viewType } = curView;\n\n    // validate option type\n    try {\n      validateOptionsType(viewType as ViewType, viewOptions);\n    } catch (err) {\n      throw new CustomHttpException(\n        `View option parse error: ${err instanceof Error ? err.message : 'Unknown error'}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.propertyParseError',\n          },\n        }\n      );\n    }\n\n    const oldOptions = options ? JSON.parse(options) : options;\n    const op = ViewOpBuilder.editor.setViewProperty.build({\n      key: 'options',\n      newValue: {\n        ...oldOptions,\n        ...viewOptions,\n      },\n      oldValue: oldOptions,\n    });\n    await this.updateViewByOps(tableId, viewId, [op]);\n\n    if (windowId) {\n      this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {\n        tableId,\n        windowId,\n        viewId,\n        userId: this.cls.get('user.id'),\n        byOps: [op],\n      });\n    }\n  }\n\n  /**\n   * shuffle view order\n   */\n  async shuffle(tableId: string) {\n    const views = await this.prismaService.view.findMany({\n      where: { tableId, deletedTime: null },\n      select: { id: true, order: true },\n      orderBy: { order: 'asc' },\n    });\n\n    this.logger.log(`lucky view shuffle! ${tableId}`, 'shuffle');\n\n    await this.prismaService.$tx(async () => {\n      const opsMap: { [viewId: string]: IOtOperation[] } = {};\n      for (let i = 0; i < views.length; i++) {\n        const view = views[i];\n        opsMap[view.id] = [\n          ViewOpBuilder.editor.setViewProperty.build({\n            key: 'order',\n            newValue: i,\n            oldValue: view.order,\n          }),\n        ];\n      }\n      await this.viewService.batchUpdateViewByOps(tableId, opsMap);\n    });\n  }\n\n  async updateViewOrder(\n    tableId: string,\n    viewId: string,\n    orderRo: IUpdateOrderRo,\n    windowId?: string\n  ) {\n    const { anchorId, position } = orderRo;\n\n    const view = await this.prismaService.view\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { tableId, id: viewId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `View not found with id: ${viewId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.notFound',\n            },\n          }\n        );\n      });\n\n    const anchorView = await this.prismaService.view\n      .findFirstOrThrow({\n        select: { order: true, id: true },\n        where: { tableId, id: anchorId, deletedTime: null },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `Anchor not found with id: ${anchorId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.anchorNotFound',\n            },\n          }\n        );\n      });\n\n    await updateOrder({\n      query: tableId,\n      position,\n      item: view,\n      anchorItem: anchorView,\n      getNextItem: async (whereOrder, align) => {\n        return this.prismaService.view.findFirst({\n          select: { order: true, id: true },\n          where: {\n            tableId,\n            deletedTime: null,\n            order: whereOrder,\n          },\n          orderBy: { order: align },\n        });\n      },\n      update: async (\n        parentId: string,\n        id: string,\n        data: { newOrder: number; oldOrder: number }\n      ) => {\n        const op = ViewOpBuilder.editor.setViewProperty.build({\n          key: 'order',\n          newValue: data.newOrder,\n          oldValue: data.oldOrder,\n        });\n        await this.updateViewByOps(parentId, id, [op]);\n\n        if (windowId) {\n          this.eventEmitterService.emitAsync(Events.OPERATION_VIEW_UPDATE, {\n            tableId,\n            windowId,\n            viewId,\n            userId: this.cls.get('user.id'),\n            byOps: [op],\n          });\n        }\n      },\n      shuffle: this.shuffle.bind(this),\n    });\n  }\n\n  /**\n   * shuffle record order\n   */\n  async shuffleRecords(dbTableName: string, indexField: string) {\n    const recordCount = await this.recordService.getAllRecordCount(dbTableName);\n    if (recordCount > 100_000) {\n      throw new CustomHttpException(\n        `Not enough gap to shuffle the row here, record count: ${recordCount}`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notEnoughGapToShuffleRow',\n          },\n        }\n      );\n    }\n\n    const sql = this.updateRecordOrderSql(\n      this.knex.raw(`?? ASC`, [indexField]).toQuery(),\n      dbTableName,\n      indexField\n    );\n\n    await this.prismaService.$executeRawUnsafe(sql);\n  }\n\n  @Timing()\n  async updateRecordOrdersInner(props: {\n    tableId: string;\n    dbTableName: string;\n    itemLength: number;\n    indexField: string;\n    orderRo: {\n      anchorId: string;\n      position: 'before' | 'after';\n    };\n    update: (indexes: number[]) => Promise<void>;\n  }) {\n    const { tableId, itemLength, dbTableName, indexField, orderRo, update } = props;\n    const { anchorId, position } = orderRo;\n\n    const anchorRecordSql = this.knex(dbTableName)\n      .select({\n        id: '__id',\n        order: indexField,\n      })\n      .where('__id', anchorId)\n      .toQuery();\n\n    const anchorRecord = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ id: string; order: number }[]>(anchorRecordSql)\n      .then((res) => {\n        return res[0];\n      });\n\n    if (!anchorRecord) {\n      throw new CustomHttpException(\n        `Anchor not found with id: ${anchorId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.anchorNotFound',\n          },\n        }\n      );\n    }\n\n    await updateMultipleOrders({\n      parentId: tableId,\n      position,\n      itemLength,\n      anchorItem: anchorRecord,\n      getNextItem: async (whereOrder, align) => {\n        const nextRecordSql = this.knex(dbTableName)\n          .select({\n            id: '__id',\n            order: indexField,\n          })\n          .where(\n            indexField,\n            whereOrder.lt != null ? '<' : '>',\n            (whereOrder.lt != null ? whereOrder.lt : whereOrder.gt) as number\n          )\n          .orderBy(indexField, align)\n          .limit(1)\n          .toQuery();\n        return this.prismaService\n          .txClient()\n          .$queryRawUnsafe<{ id: string; order: number }[]>(nextRecordSql)\n          .then((res) => {\n            return res[0];\n          });\n      },\n      update,\n      shuffle: async () => {\n        await this.shuffleRecords(dbTableName, indexField);\n      },\n    });\n  }\n\n  async updateRecordIndexes(\n    tableId: string,\n    viewId: string,\n    recordsWithOrder: {\n      id: string;\n      order?: Record<string, number>;\n    }[]\n  ) {\n    // for notify view update only\n    await this.prismaService.$tx(async () => {\n      const ops = ViewOpBuilder.editor.setViewProperty.build({\n        key: 'lastModifiedTime',\n        newValue: new Date().toISOString(),\n      });\n      await this.viewService.updateViewByOps(tableId, viewId, [ops]);\n      await this.recordService.updateRecordIndexes(tableId, recordsWithOrder);\n    });\n  }\n\n  async updateRecordOrders(\n    table: TableDomain,\n    viewId: string,\n    orderRo: IUpdateRecordOrdersRo,\n    windowId?: string\n  ) {\n    const recordIds = orderRo.recordIds;\n    const dbTableName = table.dbTableName;\n    const orderIndexesBefore = windowId\n      ? await this.recordService.getRecordIndexes(table, recordIds, viewId)\n      : undefined;\n\n    const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId);\n\n    await this.updateRecordOrdersInner({\n      tableId: table.id,\n      dbTableName,\n      itemLength: recordIds.length,\n      indexField,\n      orderRo,\n      update: async (indexes) => {\n        // for notify view update only\n        const ops = ViewOpBuilder.editor.setViewProperty.build({\n          key: 'lastModifiedTime',\n          newValue: new Date().toISOString(),\n        });\n\n        await this.prismaService.$tx(async (prisma) => {\n          await this.viewService.updateViewByOps(table.id, viewId, [ops]);\n          for (let i = 0; i < recordIds.length; i++) {\n            const recordId = recordIds[i];\n            const updateRecordSql = this.knex(dbTableName)\n              .update({\n                [indexField]: indexes[i],\n              })\n              .where('__id', recordId)\n              .toQuery();\n            await prisma.$executeRawUnsafe(updateRecordSql);\n          }\n        });\n      },\n    });\n\n    if (windowId) {\n      const orderIndexesAfter = await this.recordService.getRecordIndexes(table, recordIds, viewId);\n      this.eventEmitterService.emitAsync(Events.OPERATION_RECORDS_ORDER_UPDATE, {\n        tableId: table.id,\n        windowId,\n        recordIds,\n        viewId,\n        userId: this.cls.get('user.id'),\n        orderIndexesBefore,\n        orderIndexesAfter,\n      });\n    }\n  }\n\n  async refreshShareId(tableId: string, viewId: string) {\n    const view = await this.prismaService.view.findUnique({\n      where: { id: viewId, tableId, deletedTime: null },\n      select: { shareId: true, enableShare: true },\n    });\n    if (!view) {\n      throw new CustomHttpException(\n        `View not found with id: ${viewId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n    const { enableShare } = view;\n    if (!enableShare) {\n      throw new CustomHttpException(\n        `View ${viewId} has not been enabled share`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.shareNotEnabled',\n          },\n        }\n      );\n    }\n    const newShareId = generateShareId();\n    const setShareIdOp = ViewOpBuilder.editor.setViewProperty.build({\n      key: 'shareId',\n      newValue: newShareId,\n      oldValue: view.shareId || undefined,\n    });\n    await this.updateViewByOps(tableId, viewId, [setShareIdOp]);\n    return { shareId: newShareId };\n  }\n\n  async enableShare(tableId: string, viewId: string) {\n    const view = await this.prismaService.view.findUnique({\n      where: { id: viewId, tableId, deletedTime: null },\n    });\n    if (!view) {\n      throw new CustomHttpException(\n        `View not found with id: ${viewId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n    const { enableShare, shareId } = view;\n    if (enableShare) {\n      throw new CustomHttpException(\n        `View ${viewId} has already been enabled share`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.shareAlreadyEnabled',\n          },\n        }\n      );\n    }\n    const newShareId = generateShareId();\n    const enableShareOp = ViewOpBuilder.editor.setViewProperty.build({\n      key: 'enableShare',\n      newValue: true,\n      oldValue: enableShare || undefined,\n    });\n    const setShareIdOp = ViewOpBuilder.editor.setViewProperty.build({\n      key: 'shareId',\n      newValue: newShareId,\n      oldValue: shareId || undefined,\n    });\n\n    const ops = [enableShareOp, setShareIdOp];\n\n    const viewInstance = createViewInstanceByRaw(view);\n    if (!view.shareMeta && viewInstance.defaultShareMeta) {\n      const initShareMetaOp = ViewOpBuilder.editor.setViewProperty.build({\n        key: 'shareMeta',\n        newValue: viewInstance.defaultShareMeta,\n      });\n      ops.push(initShareMetaOp);\n    }\n    await this.updateViewByOps(tableId, viewId, ops);\n    return { shareId: newShareId };\n  }\n\n  async disableShare(tableId: string, viewId: string) {\n    const view = await this.prismaService.view.findUnique({\n      where: { id: viewId, tableId, deletedTime: null },\n      select: { shareId: true, enableShare: true, shareMeta: true },\n    });\n    if (!view) {\n      throw new CustomHttpException(\n        `View not found with id: ${viewId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n    const { enableShare } = view;\n    if (!enableShare) {\n      throw new CustomHttpException(\n        `View ${viewId} has already been disable share`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.shareAlreadyDisabled',\n          },\n        }\n      );\n    }\n    const enableShareOp = ViewOpBuilder.editor.setViewProperty.build({\n      key: 'enableShare',\n      newValue: false,\n      oldValue: enableShare || undefined,\n    });\n\n    await this.updateViewByOps(tableId, viewId, [enableShareOp]);\n  }\n\n  /**\n   * @param linkFields {fieldId: foreignTableId}\n   * @returns {foreignTableId: Set<recordId>}\n   */\n  private collectFilterLinkFieldRecords(linkFields: Record<string, string>, filter?: IFilter) {\n    if (!filter || !filter.filterSet) {\n      return undefined;\n    }\n\n    const tableRecordMap: Record<string, Set<string>> = {};\n\n    const mergeRecordMap = (source: Record<string, Set<string>> = {}) => {\n      for (const [fieldId, recordSet] of Object.entries(source)) {\n        tableRecordMap[fieldId] = tableRecordMap[fieldId] || new Set();\n        recordSet.forEach((item) => tableRecordMap[fieldId].add(item));\n      }\n    };\n\n    for (const filterItem of filter.filterSet) {\n      if ('filterSet' in filterItem) {\n        const groupTableRecordMap = this.collectFilterLinkFieldRecords(\n          linkFields,\n          filterItem as IFilter\n        );\n        if (groupTableRecordMap) {\n          mergeRecordMap(groupTableRecordMap);\n        }\n        continue;\n      }\n\n      const { value, fieldId } = filterItem as IFilterItem;\n\n      const foreignTableId = linkFields[fieldId];\n      if (!foreignTableId) {\n        continue;\n      }\n\n      if (Array.isArray(value)) {\n        mergeRecordMap({ [foreignTableId]: new Set(value as string[]) });\n      } else if (typeof value === 'string' && value.startsWith(IdPrefix.Record)) {\n        mergeRecordMap({ [foreignTableId]: new Set([value]) });\n      }\n    }\n\n    return tableRecordMap;\n  }\n\n  async getFilterLinkRecords(tableId: string, viewId: string) {\n    const view = await this.viewService.getViewById(viewId);\n    return this.getFilterLinkRecordsByTable(tableId, view.filter);\n  }\n\n  async getFilterLinkRecordsByTable(tableId: string, filter?: IFilter) {\n    if (!filter) {\n      return [];\n    }\n    const linkFields = await this.prismaService.field.findMany({\n      where: { tableId, deletedTime: null, type: FieldType.Link },\n    });\n\n    const linkFieldInstances = linkFields.map((field) => createFieldInstanceByRaw(field));\n\n    const lookupFieldIds = linkFieldInstances.reduce((arr, field) => {\n      const { lookupFieldId } = field.options as ILinkFieldOptions;\n      if (lookupFieldId) {\n        arr.push(lookupFieldId);\n      }\n      return arr;\n    }, [] as string[]);\n\n    const linkFieldTableMap = linkFields.reduce(\n      (map, field) => {\n        const { foreignTableId } = JSON.parse(field.options as string) as ILinkFieldOptions;\n        if (foreignTableId) {\n          map[field.id] = foreignTableId;\n        }\n        return map;\n      },\n      {} as Record<string, string>\n    );\n\n    const tableRecordMap = this.collectFilterLinkFieldRecords(linkFieldTableMap, filter);\n\n    if (!tableRecordMap) {\n      return [];\n    }\n\n    const lookupFieldRaws = await this.prismaService.field.findMany({\n      where: { id: { in: lookupFieldIds }, deletedTime: null },\n    });\n    const lookupFieldRawsMap = keyBy(lookupFieldRaws, 'tableId');\n\n    const res: IGetViewFilterLinkRecordsVo = [];\n    for (const [foreignTableId, recordSet] of Object.entries(tableRecordMap)) {\n      const dbTableName = await this.recordService.getDbTableName(foreignTableId);\n\n      const lookupedFieldRaw = lookupFieldRawsMap[foreignTableId];\n      if (!lookupedFieldRaw) {\n        continue;\n      }\n      const dbFieldName = lookupedFieldRaw.dbFieldName;\n\n      const nativeQuery = this.knex(dbTableName)\n        .select('__id as id', `${dbFieldName} as title`)\n        .orderBy('__auto_number')\n        .whereIn('__id', Array.from(recordSet))\n        .toQuery();\n\n      const list = await this.prismaService\n        .txClient()\n        .$queryRawUnsafe<{ id: string; title: string | null }[]>(nativeQuery);\n      const fieldInstances = createFieldInstanceByRaw(lookupedFieldRaw);\n      res.push({\n        tableId: foreignTableId,\n        records: list.map(({ id, title }) => ({\n          id,\n          title:\n            fieldInstances.cellValue2String(fieldInstances.convertDBValue2CellValue(title)) ||\n            undefined,\n        })),\n      });\n    }\n    return res;\n  }\n\n  async pluginInstall(\n    tableId: string,\n    ro: IViewInstallPluginRo & {\n      shareId?: string;\n      shareMeta?: IViewShareMetaRo;\n      enableShare?: boolean;\n    }\n  ) {\n    const userId = this.cls.get('user.id');\n    const { name, pluginId, shareId, shareMeta, enableShare } = ro;\n    const plugin = await this.prismaService.txClient().plugin.findUnique({\n      where: {\n        id: pluginId,\n        OR: [\n          {\n            status: PluginStatus.Published,\n          },\n          {\n            status: { not: PluginStatus.Published },\n            createdBy: this.cls.get('user.id'),\n          },\n        ],\n      },\n      select: { id: true, name: true, logo: true, positions: true },\n    });\n    if (!plugin) {\n      throw new CustomHttpException(\n        `Plugin not found with id: ${pluginId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        }\n      );\n    }\n    if (!plugin.positions.includes(PluginPosition.View)) {\n      throw new CustomHttpException(\n        `Plugin ${pluginId} does not support install in view`,\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notSupportInstallInView',\n          },\n        }\n      );\n    }\n    const viewName = name || plugin.name;\n    return this.prismaService.$tx(async (prisma) => {\n      const pluginInstallId = generatePluginInstallId();\n      const view = await this.createViewInner(tableId, {\n        name: viewName,\n        type: ViewType.Plugin,\n        enableShare,\n        shareMeta,\n        shareId,\n        options: {\n          pluginInstallId,\n          pluginId,\n          pluginLogo: plugin.logo,\n        } as IPluginViewOptions,\n      });\n      const table = await prisma.tableMeta.findUniqueOrThrow({\n        where: { id: tableId, deletedTime: null },\n        select: { baseId: true },\n      });\n      const newPlugin = await prisma.pluginInstall.create({\n        data: {\n          id: pluginInstallId,\n          baseId: table?.baseId,\n          positionId: view.id,\n          position: PluginPosition.View,\n          name: viewName,\n          pluginId: ro.pluginId,\n          createdBy: userId,\n        },\n      });\n      return {\n        pluginId: newPlugin.pluginId,\n        pluginInstallId: newPlugin.id,\n        name: newPlugin.name,\n        viewId: view.id,\n      };\n    });\n  }\n\n  async updatePluginStorage(viewId: string, storage: IViewPluginUpdateStorageRo['storage']) {\n    const pluginInstall = await this.prismaService.pluginInstall.findFirst({\n      where: { positionId: viewId, position: PluginPosition.View },\n      select: { id: true },\n    });\n    if (!pluginInstall) {\n      throw new CustomHttpException(\n        `Plugin install not found with viewId: ${viewId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        }\n      );\n    }\n    return this.prismaService.pluginInstall.update({\n      where: { id: pluginInstall.id },\n      data: { storage: JSON.stringify(storage) },\n    });\n  }\n\n  async getPluginInstall(tableId: string, viewId: string) {\n    const table = await this.prismaService.tableMeta.findUniqueOrThrow({\n      where: { id: tableId, deletedTime: null },\n      select: { baseId: true },\n    });\n    const pluginInstall = await this.prismaService.pluginInstall.findFirst({\n      where: { positionId: viewId, position: PluginPosition.View },\n      select: {\n        id: true,\n        pluginId: true,\n        name: true,\n        storage: true,\n        plugin: {\n          select: { url: true },\n        },\n      },\n    });\n    if (!pluginInstall) {\n      throw new CustomHttpException(\n        `Plugin install not found with viewId: ${viewId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.plugin.notFound',\n          },\n        }\n      );\n    }\n    return {\n      name: pluginInstall.name,\n      pluginId: pluginInstall.pluginId,\n      pluginInstallId: pluginInstall.id,\n      storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : undefined,\n      baseId: table.baseId,\n      url: pluginInstall.plugin.url || undefined,\n    };\n  }\n\n  async duplicateView(tableId: string, viewId: string) {\n    const view = await this.viewService.getViewById(viewId);\n    const { options: optionsRaw } = await this.prismaService.txClient().view.findUniqueOrThrow({\n      where: { id: viewId, deletedTime: null },\n      select: { options: true },\n    });\n    const options = optionsRaw ? JSON.parse(optionsRaw) : undefined;\n    return this.prismaService.$tx(async (prisma) => {\n      const viewVo = await this.createView(tableId, {\n        ...pick(view, [\n          'name',\n          'type',\n          'description',\n          'filter',\n          'group',\n          'columnMeta',\n          'sort',\n          'enableShare',\n          'shareMeta',\n          'shareId',\n          'isLocked',\n        ]),\n        options,\n        shareId: view.shareId ? generateShareId() : undefined,\n      });\n\n      if (view.type === ViewType.Plugin) {\n        const originPluginInstallId = (view.options as IPluginViewOptions)?.pluginInstallId;\n        const newPluginInstallId = (viewVo.options as IPluginViewOptions)?.pluginInstallId;\n        const { storage: pluginStorage } = await prisma.pluginInstall.findUniqueOrThrow({\n          where: { id: originPluginInstallId },\n          select: { storage: true },\n        });\n\n        await prisma.pluginInstall.update({\n          where: { id: newPluginInstallId },\n          data: { storage: pluginStorage },\n        });\n      }\n\n      return viewVo;\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/utils/derive-frozen-fields.ts",
    "content": "import type { IGridViewOptions, IGridColumnMeta, IGridColumn } from '@teable/core';\n\nexport function adjustFrozenField(\n  originOptions: IGridViewOptions,\n  originColumnMeta: IGridColumnMeta,\n  columnMetaUpdate: IGridColumnMeta\n): IGridViewOptions | null {\n  const frozenFieldId = originOptions?.frozenFieldId;\n\n  if (!frozenFieldId) return null;\n  if (!Object.prototype.hasOwnProperty.call(columnMetaUpdate, frozenFieldId)) return null;\n\n  const frozenColumnUpdate: IGridColumn | undefined = frozenFieldId\n    ? columnMetaUpdate[frozenFieldId]\n    : undefined;\n  const originOrders = Object.keys(originColumnMeta).sort(\n    (a, b) => originColumnMeta[a].order - originColumnMeta[b].order\n  );\n\n  // frozen field has been deleted\n  if (frozenColumnUpdate == null) {\n    const index = originOrders.indexOf(frozenFieldId);\n    const newFrozenId = index > 0 ? originOrders[index - 1] : undefined;\n    return {\n      ...originOptions,\n      frozenFieldId: newFrozenId,\n    };\n  }\n\n  const oldOrder = originColumnMeta[frozenFieldId]?.order;\n  const newOrder = frozenColumnUpdate.order;\n\n  if (oldOrder == null || newOrder == null || newOrder === oldOrder) return null;\n\n  const oldIndex = originOrders.indexOf(frozenFieldId);\n  const prevNeighborId = oldIndex > 0 ? originOrders[oldIndex - 1] : undefined;\n\n  const nextOptions: IGridViewOptions = { ...(originOptions as IGridViewOptions) };\n  if (prevNeighborId) {\n    nextOptions.frozenFieldId = prevNeighborId;\n  } else {\n    delete (nextOptions as Record<string, unknown>).frozenFieldId;\n  }\n  return nextOptions;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/view.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { DbProvider } from '../../db-provider/db.provider';\nimport { CalculationModule } from '../calculation/calculation.module';\nimport { ViewService } from './view.service';\n\n@Module({\n  imports: [CalculationModule],\n  providers: [ViewService, DbProvider],\n  exports: [ViewService],\n})\nexport class ViewModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/view.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../../global/global.module';\nimport { ViewModule } from './view.module';\nimport { ViewService } from './view.service';\n\ndescribe('ViewService', () => {\n  let service: ViewService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, ViewModule],\n    }).compile();\n\n    service = module.get<ViewService>(ViewService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/features/view/view.service.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport type {\n  ISnapshotBase,\n  IViewRo,\n  IViewVo,\n  ISort,\n  IOtOperation,\n  IUpdateViewColumnMetaOpContext,\n  ISetViewPropertyOpContext,\n  IColumnMeta,\n  IViewPropertyKeys,\n  IGroup,\n  IViewOptions,\n  IFilter,\n  IKanbanViewOptions,\n  IFilterSet,\n  IGalleryViewOptions,\n  ICalendarViewOptions,\n  IColumn,\n  IGridColumnMeta,\n} from '@teable/core';\nimport {\n  getUniqName,\n  IdPrefix,\n  generateViewId,\n  OpName,\n  ViewOpBuilder,\n  viewVoSchema,\n  ViewType,\n  FieldType,\n  CellValueType,\n  HttpErrorCode,\n} from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { isEmpty, isNull, isString, merge, snakeCase, uniq } from 'lodash';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport { fromZodError } from 'zod-validation-error';\nimport { CustomHttpException } from '../../custom.exception';\nimport { InjectDbProvider } from '../../db-provider/db.provider';\nimport { IDbProvider } from '../../db-provider/db.provider.interface';\nimport type { IReadonlyAdapterService } from '../../share-db/interface';\nimport { RawOpType } from '../../share-db/interface';\nimport type { IClsStore } from '../../types/cls';\nimport { convertViewVoAttachmentUrl } from '../../utils/convert-view-vo-attachment-url';\nimport { BatchService } from '../calculation/batch.service';\nimport { ROW_ORDER_FIELD_PREFIX } from './constant';\nimport { createViewInstanceByRaw, createViewVoByRaw } from './model/factory';\nimport { adjustFrozenField } from './utils/derive-frozen-fields';\n\ntype IViewOpContext = IUpdateViewColumnMetaOpContext | ISetViewPropertyOpContext;\n\n@Injectable()\nexport class ViewService implements IReadonlyAdapterService {\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly batchService: BatchService,\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,\n    @InjectDbProvider() private readonly dbProvider: IDbProvider\n  ) {}\n\n  getRowIndexFieldName(viewId: string) {\n    return `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;\n  }\n\n  getRowIndexFieldIndexName(viewId: string) {\n    return `idx_${ROW_ORDER_FIELD_PREFIX}_${viewId}`;\n  }\n\n  private async polishOrderAndName(tableId: string, viewRo: IViewRo) {\n    const viewRaws = await this.prismaService.txClient().view.findMany({\n      where: { tableId, deletedTime: null },\n      select: { name: true, order: true },\n      orderBy: { order: 'asc' },\n    });\n\n    let { name } = viewRo;\n\n    const names = viewRaws.map((view) => view.name);\n    name = getUniqName(name ?? 'New view', names);\n\n    const maxOrder = viewRaws[viewRaws.length - 1]?.order;\n    const order = maxOrder == null ? 0 : maxOrder + 1;\n\n    return { name, order };\n  }\n\n  async existIndex(dbTableName: string, viewId: string) {\n    const columnName = this.getRowIndexFieldName(viewId);\n    const exists = await this.dbProvider.checkColumnExist(\n      dbTableName,\n      columnName,\n      this.prismaService.txClient()\n    );\n\n    if (exists) {\n      return columnName;\n    }\n  }\n\n  async createViewIndexField(dbTableName: string, viewId: string) {\n    const prisma = this.prismaService.txClient();\n\n    const rowIndexFieldName = this.getRowIndexFieldName(viewId);\n\n    // add a field for maintain row order number\n    const addRowIndexColumnSql = this.knex.schema\n      .alterTable(dbTableName, (table) => {\n        table.double(rowIndexFieldName);\n      })\n      .toQuery();\n    await prisma.$executeRawUnsafe(addRowIndexColumnSql);\n\n    // fill initial order for every record, with auto increment integer\n    const updateRowIndexSql = this.knex(dbTableName)\n      .update({\n        [rowIndexFieldName]: this.knex.ref('__auto_number'),\n      })\n      .toQuery();\n    await prisma.$executeRawUnsafe(updateRowIndexSql);\n\n    // create index\n    const createRowIndexSQL = this.knex.schema\n      .alterTable(dbTableName, (table) => {\n        table.index(rowIndexFieldName, this.getRowIndexFieldIndexName(viewId));\n      })\n      .toQuery();\n    await prisma.$executeRawUnsafe(createRowIndexSQL);\n    return rowIndexFieldName;\n  }\n\n  async getOrCreateViewIndexField(dbTableName: string, viewId: string) {\n    const indexFieldName = await this.existIndex(dbTableName, viewId);\n    if (indexFieldName) {\n      return indexFieldName;\n    }\n    return this.createViewIndexField(dbTableName, viewId);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private async viewDataCompensation(tableId: string, viewRo: IViewRo) {\n    // create view compensation data\n    const innerViewRo = { ...viewRo };\n\n    // primary field set visible default\n    if ([ViewType.Kanban, ViewType.Gallery, ViewType.Calendar].includes(viewRo.type)) {\n      const primaryField = await this.prismaService.txClient().field.findFirstOrThrow({\n        where: { tableId, isPrimary: true, deletedTime: null },\n        select: { id: true },\n      });\n      const columnMeta = innerViewRo.columnMeta ?? {};\n      const primaryFieldColumnMeta = columnMeta[primaryField.id] ?? {};\n      innerViewRo.columnMeta = {\n        ...columnMeta,\n        [primaryField.id]: { ...primaryFieldColumnMeta, visible: true },\n      };\n\n      // set default cover field id for gallery view\n      if (innerViewRo.type === ViewType.Gallery) {\n        const fields = await this.prismaService.txClient().field.findMany({\n          where: { tableId, deletedTime: null },\n          select: { id: true, type: true },\n        });\n        const galleryOptions = (innerViewRo.options ?? {}) as IGalleryViewOptions;\n        const coverFieldId =\n          galleryOptions.coverFieldId ??\n          fields.find((field) => field.type === FieldType.Attachment)?.id;\n        innerViewRo.options = {\n          ...galleryOptions,\n          coverFieldId,\n        };\n      }\n\n      // set default start date and end date field ids for calendar view\n      if (innerViewRo.type === ViewType.Calendar) {\n        const fields = await this.prismaService.txClient().field.findMany({\n          where: { tableId, deletedTime: null },\n          select: { id: true, cellValueType: true, isMultipleCellValue: true },\n        });\n        const calendarOptions = (innerViewRo.options ?? {}) as ICalendarViewOptions;\n\n        const dateFieldIds = fields\n          .filter(\n            ({ cellValueType, isMultipleCellValue }) =>\n              cellValueType === CellValueType.DateTime && !isMultipleCellValue\n          )\n          .map(({ id }) => id);\n\n        if (!dateFieldIds.length) return innerViewRo;\n\n        const startDateFieldId = calendarOptions.startDateFieldId ?? dateFieldIds[0];\n        const endDateFieldId = calendarOptions.endDateFieldId ?? dateFieldIds[1] ?? dateFieldIds[0];\n\n        innerViewRo.options = {\n          ...calendarOptions,\n          startDateFieldId,\n          endDateFieldId,\n        };\n      }\n    }\n\n    if (viewRo.type === ViewType.Form) {\n      const fields = await this.prismaService.txClient().field.findMany({\n        where: { tableId, deletedTime: null },\n        select: {\n          id: true,\n          type: true,\n          isComputed: true,\n        },\n        orderBy: [{ order: 'asc' }, { createdTime: 'asc' }],\n      });\n\n      if (!fields?.length) return innerViewRo;\n\n      const columnMeta = innerViewRo.columnMeta ?? {};\n      for (const f of fields) {\n        const { id, type, isComputed } = f;\n\n        if (isComputed || type === FieldType.Button) continue;\n\n        const prev = columnMeta[id] ?? {};\n        columnMeta[id] = { ...prev, visible: true } as IColumn;\n      }\n      innerViewRo.columnMeta = columnMeta;\n    }\n    return innerViewRo;\n  }\n\n  async restoreView(tableId: string, viewId: string) {\n    await this.prismaService.$tx(async () => {\n      await this.prismaService.txClient().view.update({\n        where: { id: viewId },\n        data: {\n          deletedTime: null,\n        },\n      });\n      const ops = ViewOpBuilder.editor.setViewProperty.build({\n        key: 'lastModifiedTime',\n        newValue: new Date().toISOString(),\n      });\n      await this.updateViewByOps(tableId, viewId, [ops]);\n    });\n  }\n\n  async createDbView(tableId: string, viewRo: IViewRo) {\n    const userId = this.cls.get('user.id');\n    const createViewRo = await this.viewDataCompensation(tableId, viewRo);\n\n    const {\n      description,\n      type,\n      options,\n      sort,\n      filter,\n      group,\n      columnMeta,\n      shareId,\n      shareMeta,\n      enableShare,\n      isLocked,\n    } = createViewRo;\n\n    const { name, order } = await this.polishOrderAndName(tableId, createViewRo);\n\n    const viewId = generateViewId();\n    const prisma = this.prismaService.txClient();\n\n    const orderColumnMeta = await this.generateViewOrderColumnMeta(tableId);\n\n    const mergedColumnMeta = merge(orderColumnMeta, columnMeta);\n\n    const data: Prisma.ViewCreateInput = {\n      id: viewId,\n      table: {\n        connect: {\n          id: tableId,\n        },\n      },\n      name,\n      description,\n      type,\n      options: options ? JSON.stringify(options) : undefined,\n      sort: sort ? JSON.stringify(sort) : undefined,\n      filter: filter ? JSON.stringify(filter) : undefined,\n      group: group ? JSON.stringify(group) : undefined,\n      version: 1,\n      order,\n      createdBy: userId,\n      columnMeta: mergedColumnMeta ? JSON.stringify(mergedColumnMeta) : JSON.stringify({}),\n      shareId,\n      shareMeta: shareMeta ? JSON.stringify(shareMeta) : undefined,\n      enableShare,\n      isLocked,\n    };\n\n    return await prisma.view.create({ data });\n  }\n\n  async getViewById(viewId: string): Promise<IViewVo> {\n    const viewRaw = await this.prismaService.txClient().view.findUniqueOrThrow({\n      where: { id: viewId, deletedTime: null },\n    });\n\n    return convertViewVoAttachmentUrl(createViewInstanceByRaw(viewRaw) as IViewVo);\n  }\n\n  async getViews(tableId: string, ids?: string[]): Promise<IViewVo[]> {\n    const viewRaws = await this.prismaService.txClient().view.findMany({\n      where: {\n        tableId,\n        deletedTime: null,\n        id: { in: ids },\n      },\n      orderBy: { order: 'asc' },\n    });\n\n    return viewRaws.map((viewRaw) => convertViewVoAttachmentUrl(createViewVoByRaw(viewRaw)));\n  }\n\n  async createView(tableId: string, viewRo: IViewRo): Promise<IViewVo> {\n    const viewRaw = await this.createDbView(tableId, viewRo);\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Create, IdPrefix.View, [\n      { docId: viewRaw.id, version: 0, data: viewRaw },\n    ]);\n\n    return convertViewVoAttachmentUrl(createViewVoByRaw(viewRaw));\n  }\n\n  async deleteView(tableId: string, viewId: string) {\n    // Use SELECT FOR UPDATE to lock all views in the table to prevent concurrent deletion\n    // This ensures that when checking if this is the last view, no other transaction\n    // can delete views simultaneously\n    const views = await this.prismaService.txClient().$queryRaw<\n      Array<{ id: string; version: number }>\n    >`\n      SELECT id, version FROM \"view\" \n      WHERE \"table_id\" = ${tableId} \n        AND \"deleted_time\" IS NULL \n      FOR UPDATE\n    `;\n\n    if (views.length <= 1) {\n      throw new CustomHttpException(\n        'Cannot delete the last view in a table. A table must have at least one view.',\n        HttpErrorCode.VALIDATION_ERROR,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.cannotDeleteLastView',\n          },\n        }\n      );\n    }\n\n    const viewToDelete = views.find((v) => v.id === viewId);\n    if (!viewToDelete) {\n      throw new CustomHttpException(\n        `View not found with id: ${viewId} and tableId: ${tableId}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n\n    await this.del(viewToDelete.version + 1, tableId, viewId);\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Del, IdPrefix.View, [\n      { docId: viewId, version: viewToDelete.version },\n    ]);\n  }\n\n  async updateViewSort(tableId: string, viewId: string, sort: ISort) {\n    const viewRaw = await this.prismaService\n      .txClient()\n      .view.findFirstOrThrow({\n        where: { id: viewId, tableId, deletedTime: null },\n        select: {\n          sort: true,\n          version: true,\n        },\n      })\n      .catch(() => {\n        throw new CustomHttpException(\n          `View not found with id: ${viewId} and tableId: ${tableId}`,\n          HttpErrorCode.NOT_FOUND,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.notFound',\n            },\n          }\n        );\n      });\n\n    const updateInput: Prisma.ViewUpdateInput = {\n      sort: JSON.stringify(sort),\n      lastModifiedBy: this.cls.get('user.id'),\n      lastModifiedTime: new Date(),\n    };\n\n    const ops = [\n      ViewOpBuilder.editor.setViewProperty.build({\n        key: 'sort',\n        newValue: sort,\n        oldValue: viewRaw?.sort ? JSON.parse(viewRaw.sort) : null,\n      }),\n    ];\n\n    const viewRawAfter = await this.prismaService.txClient().view.update({\n      where: { id: viewId },\n      data: { version: viewRaw.version + 1, ...updateInput },\n    });\n\n    await this.batchService.saveRawOps(tableId, RawOpType.Edit, IdPrefix.View, [\n      {\n        docId: viewId,\n        version: viewRaw.version,\n        data: ops,\n      },\n    ]);\n\n    return viewRawAfter;\n  }\n\n  async updateViewByOps(tableId: string, viewId: string, ops: IOtOperation[]) {\n    await this.batchUpdateViewByOps(tableId, { [viewId]: ops });\n  }\n\n  async batchUpdateViewByOps(tableId: string, opsMap: { [viewId: string]: IOtOperation[] }) {\n    const { updateViewMap, updateViewKeySet } = this.getBatchUpdateViewContext(opsMap);\n    if (updateViewKeySet.size === 0) {\n      return;\n    }\n    const updatedViewIds = Object.keys(updateViewMap).filter((viewId) => {\n      const viewData = updateViewMap[viewId];\n      const { property = {}, columnMeta = {} } = viewData ?? {};\n      return Object.keys(property).length > 0 || Object.keys(columnMeta).length > 0;\n    });\n\n    const isColumnMetaUpdated = updateViewKeySet.has('columnMeta');\n    const viewRaws = await this.prismaService.txClient().view.findMany({\n      where: { id: { in: updatedViewIds }, tableId, deletedTime: null },\n      select: {\n        columnMeta: isColumnMetaUpdated,\n        options: isColumnMetaUpdated,\n        type: isColumnMetaUpdated,\n        id: true,\n        version: true,\n      },\n    });\n\n    const userId = this.cls.get('user.id');\n    const data: {\n      id: string;\n      values: { [key: string]: unknown };\n    }[] = viewRaws.map((view) => {\n      const { id: viewId, version, columnMeta, options, type } = view;\n      const updateView = updateViewMap[viewId];\n\n      const values: Record<string, unknown> = {\n        ...updateView.property,\n        version: version + 1,\n        lastModifiedBy: userId,\n      };\n\n      if (updateView.columnMeta) {\n        const originColumnMeta = isString(columnMeta) ? JSON.parse(columnMeta) : {};\n        const newColumnMeta = this.mergeUpdatedViewColumnMeta(\n          originColumnMeta,\n          updateView.columnMeta\n        );\n        values.columnMeta = JSON.stringify(newColumnMeta);\n\n        if (type === ViewType.Grid) {\n          const originOptions = options ? JSON.parse(options) : {};\n          const newOptions = adjustFrozenField(\n            originOptions,\n            originColumnMeta,\n            updateView.columnMeta as IGridColumnMeta\n          );\n\n          if (newOptions) {\n            values.options = JSON.stringify(newOptions);\n            const newOptionsOp = ViewOpBuilder.editor.setViewProperty.build({\n              key: 'options',\n              oldValue: originOptions,\n              newValue: newOptions,\n            });\n            opsMap[viewId] = [...(opsMap[viewId] ?? []), newOptionsOp];\n          }\n        }\n      }\n\n      return {\n        id: viewId,\n        values,\n      };\n    });\n\n    if (data.length === 1) {\n      const { id, values } = data[0];\n      await this.prismaService.txClient().view.update({\n        where: { id },\n        data: values,\n      });\n    } else if (data.length > 1) {\n      await this.batchUpdateDB(data);\n    }\n\n    const opDataList: {\n      docId: string;\n      version: number;\n      data?: unknown;\n    }[] = viewRaws.map((view) => {\n      return {\n        docId: view.id,\n        version: view.version,\n        data: opsMap[view.id],\n      };\n    });\n\n    this.batchService.saveRawOps(tableId, RawOpType.Edit, IdPrefix.View, opDataList);\n  }\n\n  async create(tableId: string, view: IViewVo) {\n    await this.createDbView(tableId, view);\n  }\n\n  async del(_version: number, _tableId: string, viewId: string) {\n    await this.prismaService.txClient().view.update({\n      where: { id: viewId },\n      data: {\n        deletedTime: new Date(),\n      },\n    });\n  }\n\n  // get column order map for all views, order by fieldIds, key by viewId\n  async getColumnsMetaMap(tableId: string, fieldIds: string[]): Promise<IColumnMeta[]> {\n    const viewRaws = await this.prismaService.txClient().view.findMany({\n      select: { id: true, columnMeta: true },\n      where: { tableId, deletedTime: null },\n    });\n\n    const viewRawMap = viewRaws.reduce<{ [viewId: string]: IColumnMeta }>((pre, cur) => {\n      pre[cur.id] = JSON.parse(cur.columnMeta);\n      return pre;\n    }, {});\n\n    return fieldIds.map((fieldId) => {\n      return viewRaws.reduce<IColumnMeta>((pre, view) => {\n        pre[view.id] = viewRawMap[view.id][fieldId];\n        return pre;\n      }, {});\n    });\n  }\n\n  getUpdateViewContext(ops: IOtOperation[]) {\n    const opContexts = ops.map((op) => {\n      const ctx = ViewOpBuilder.detect(op);\n      if (!ctx) {\n        throw new CustomHttpException(`unknown view editing op`, HttpErrorCode.VALIDATION_ERROR, {\n          localization: {\n            i18nKey: 'httpErrors.custom.invalidOperation',\n          },\n        });\n      }\n      return ctx as IViewOpContext;\n    });\n\n    const setPropertyOpContexts: ISetViewPropertyOpContext[] = [];\n    const updateColumnMetaOpContexts: IUpdateViewColumnMetaOpContext[] = [];\n    for (const opContext of opContexts) {\n      if (opContext.name === OpName.SetViewProperty) {\n        setPropertyOpContexts.push(opContext);\n      } else if (opContext.name === OpName.UpdateViewColumnMeta) {\n        updateColumnMetaOpContexts.push(opContext);\n      }\n    }\n\n    const res: {\n      property?: Record<string, string | null>;\n      columnMeta?: Record<string, IColumn | null>;\n    } = {};\n    if (setPropertyOpContexts.length > 0) {\n      res.property = this.mergeSetViewPropertyByOpContexts(setPropertyOpContexts);\n    }\n    if (updateColumnMetaOpContexts.length > 0) {\n      res.columnMeta = this.mergeUpdatedViewColumnMetaByOpContexts(updateColumnMetaOpContexts);\n    }\n\n    return res;\n  }\n\n  getBatchUpdateViewContext(opsMap: { [viewId: string]: IOtOperation[] }) {\n    const updateViewMap: {\n      [viewId: string]: {\n        property?: Record<string, string | null>;\n        columnMeta?: Record<string, IColumn | null>;\n      };\n    } = {};\n    const updateViewKeySet = new Set<string>();\n    for (const [viewId, ops] of Object.entries(opsMap)) {\n      const { property, columnMeta } = this.getUpdateViewContext(ops);\n\n      Object.keys(property ?? {}).forEach((key) => {\n        updateViewKeySet.add(key);\n      });\n      if (Object.keys(columnMeta ?? {}).length > 0) {\n        updateViewKeySet.add('columnMeta');\n      }\n\n      updateViewMap[viewId] = {\n        property,\n        columnMeta,\n      };\n    }\n\n    return {\n      updateViewMap,\n      updateViewKeySet,\n    };\n  }\n\n  mergeUpdatedViewColumnMeta(\n    originColumnMeta: IColumnMeta,\n    newColumnMeta: Record<string, IColumn | null>\n  ) {\n    const newColumnMetaKeys = uniq([\n      ...Object.keys(originColumnMeta),\n      ...Object.keys(newColumnMeta),\n    ]);\n\n    return newColumnMetaKeys.reduce(\n      (acc: IColumnMeta, key) => {\n        if (isNull(newColumnMeta[key])) {\n          delete acc[key];\n        } else if (newColumnMeta[key]) {\n          acc[key] = newColumnMeta[key] as IColumn;\n        }\n        return acc;\n      },\n      { ...originColumnMeta }\n    );\n  }\n\n  mergeUpdatedViewColumnMetaByOpContexts(opContexts: IUpdateViewColumnMetaOpContext[]) {\n    const result: Record<string, IColumn | null> = {};\n    for (const opContext of opContexts) {\n      const { fieldId, newColumnMeta } = opContext;\n\n      if (!newColumnMeta) {\n        result[fieldId] = null;\n      } else {\n        const old = result[fieldId] ?? {};\n        result[fieldId] = {\n          ...old,\n          ...newColumnMeta,\n        };\n      }\n    }\n\n    return result;\n  }\n\n  mergeSetViewPropertyByOpContexts(opContexts: ISetViewPropertyOpContext[]) {\n    const result: Record<string, string | null> = {};\n    for (const opContext of opContexts) {\n      const { key, newValue } = opContext;\n      const parseResult = viewVoSchema.partial().safeParse({ [key]: newValue });\n      if (!parseResult.success) {\n        throw new CustomHttpException(\n          fromZodError(parseResult.error).message,\n          HttpErrorCode.VALIDATION_ERROR,\n          {\n            localization: {\n              i18nKey: 'httpErrors.view.propertyParseError',\n            },\n          }\n        );\n      }\n      const parsedValue = parseResult.data[key] as IViewPropertyKeys;\n      result[key] =\n        parsedValue == null\n          ? null\n          : typeof parsedValue === 'object'\n            ? JSON.stringify(parsedValue)\n            : parsedValue;\n    }\n    return result;\n  }\n\n  async batchUpdateDB(\n    data: {\n      id: string;\n      values: { [key: string]: unknown };\n    }[]\n  ) {\n    if (data.length === 0) {\n      return;\n    }\n\n    const caseStatements: Record<string, { when: string; then: unknown }[]> = {};\n    for (const { id, values } of data) {\n      for (const [key, value] of Object.entries(values)) {\n        if (!caseStatements[key]) {\n          caseStatements[key] = [];\n        }\n        caseStatements[key].push({ when: id, then: value });\n      }\n    }\n\n    const updatePayload: Record<string, Knex.Raw> = {};\n    for (const [key, statements] of Object.entries(caseStatements)) {\n      if (statements.length === 0) {\n        continue;\n      }\n      const column = snakeCase(key);\n      const whenClauses: string[] = [];\n      const caseBindings: unknown[] = [];\n      for (const { when, then } of statements) {\n        whenClauses.push('WHEN ?? = ? THEN ?');\n        caseBindings.push('id', when, then);\n      }\n      const caseExpression = `CASE ${whenClauses.join(' ')} ELSE ?? END`;\n      const rawExpression = this.knex.raw(caseExpression, [...caseBindings, column]);\n      updatePayload[column] = rawExpression;\n    }\n\n    const idsToUpdate = data.map((item) => item.id);\n    const finalSql = this.knex('view').update(updatePayload).whereIn('id', idsToUpdate).toString();\n    // fs.writeFileSync('batch-update-view-sql.sql', finalSql);\n    await this.prismaService.txClient().$executeRawUnsafe(finalSql);\n  }\n\n  async getSnapshotBulk(tableId: string, ids: string[]): Promise<ISnapshotBase<IViewVo>[]> {\n    const views = await this.prismaService.txClient().view.findMany({\n      where: { tableId, id: { in: ids }, deletedTime: null },\n    });\n\n    if (views.length !== ids.length) {\n      const notFoundIds = ids.filter((id) => !views.some((view) => view.id === id));\n      throw new CustomHttpException(\n        `View not found: ${notFoundIds.join(', ')}`,\n        HttpErrorCode.NOT_FOUND,\n        {\n          localization: {\n            i18nKey: 'httpErrors.view.notFound',\n          },\n        }\n      );\n    }\n\n    return views\n      .map((view) => {\n        return {\n          id: view.id,\n          v: view.version,\n          type: 'json0',\n          data: convertViewVoAttachmentUrl(createViewVoByRaw(view)),\n        };\n      })\n      .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));\n  }\n\n  async getDocIdsByQuery(tableId: string, query?: { includeIds: string[] }) {\n    const views = await this.prismaService.txClient().view.findMany({\n      where: { tableId, deletedTime: null, id: { in: query?.includeIds } },\n      select: { id: true },\n      orderBy: { order: 'asc' },\n    });\n\n    return { ids: views.map((v) => v.id) };\n  }\n\n  async generateViewOrderColumnMeta(tableId: string) {\n    const fields = await this.prismaService.txClient().field.findMany({\n      select: { id: true },\n      where: { tableId, deletedTime: null },\n      orderBy: [\n        { isPrimary: { sort: 'asc', nulls: 'last' } },\n        { order: 'asc' },\n        { createdTime: 'asc' },\n      ],\n    });\n\n    if (isEmpty(fields)) {\n      return;\n    }\n\n    return fields.reduce<IColumnMeta>((pre, cur, index) => {\n      pre[cur.id] = { order: index };\n      return pre;\n    }, {});\n  }\n\n  async initViewColumnMeta(\n    tableId: string,\n    fieldIds: string[],\n    initViewColumnMapList?: Record<string, IColumn>[]\n  ) {\n    // 1. get all views id and column meta by tableId\n    const view = await this.prismaService.txClient().view.findMany({\n      where: { tableId, deletedTime: null },\n      select: { columnMeta: true, id: true },\n    });\n\n    if (isEmpty(view)) {\n      return;\n    }\n\n    const opsMap: { [viewId: string]: IOtOperation[] } = {};\n    for (let i = 0; i < view.length; i++) {\n      const ops: IOtOperation[] = [];\n      const viewId = view[i].id;\n      const curColumnMeta: IColumnMeta = JSON.parse(view[i].columnMeta);\n      const maxOrder = isEmpty(curColumnMeta)\n        ? -1\n        : Math.max(...Object.values(curColumnMeta).map((meta) => meta.order));\n      fieldIds.forEach((fieldId, i) => {\n        const initColumn = initViewColumnMapList?.[i]?.[viewId];\n        const op = ViewOpBuilder.editor.updateViewColumnMeta.build({\n          fieldId: fieldId,\n          newColumnMeta: initColumn\n            ? { ...initColumn, order: initColumn.order ?? maxOrder + 1 }\n            : { order: maxOrder + 1 },\n          oldColumnMeta: undefined,\n        });\n        ops.push(op);\n      });\n\n      // 2. build update ops and emit\n      opsMap[viewId] = ops;\n    }\n\n    await this.batchUpdateViewByOps(tableId, opsMap);\n  }\n\n  async deleteViewRelativeByFields(tableId: string, fieldIds: string[]) {\n    // 1. get all views id and column meta by tableId\n    const view = await this.prismaService.txClient().view.findMany({\n      select: {\n        columnMeta: true,\n        group: true,\n        options: true,\n        sort: true,\n        filter: true,\n        id: true,\n        type: true,\n      },\n      where: { tableId, deletedTime: null },\n    });\n\n    if (!view) {\n      throw new CustomHttpException(`no view in this table`, HttpErrorCode.NOT_FOUND, {\n        localization: {\n          i18nKey: 'httpErrors.view.notFound',\n        },\n      });\n    }\n\n    const opsMap: { [viewId: string]: IOtOperation[] } = {};\n    for (let i = 0; i < view.length; i++) {\n      const ops: IOtOperation[] = [];\n      const viewId = view[i].id;\n      const viewType = view[i].type;\n\n      const curColumnMeta: IColumnMeta = JSON.parse(view[i].columnMeta);\n      const curSort: ISort = view[i].sort ? JSON.parse(view[i].sort!) : null;\n      const curGroup: IGroup = view[i].group ? JSON.parse(view[i].group!) : null;\n      const curOptions: IViewOptions = view[i].options ? JSON.parse(view[i].options!) : null;\n      const curFilter: IFilter = view[i].filter ? JSON.parse(view[i].filter!) : null;\n\n      fieldIds.forEach((fieldId) => {\n        const columnOps = this.getDeleteColumnMetaByFieldIdOps(curColumnMeta, fieldId);\n        ops.push(columnOps);\n\n        // filter\n        if (view[i].filter && view[i].filter?.includes(fieldId) && curFilter) {\n          const filterOps = this.getDeleteFilterByFieldIdOps(curFilter, fieldId);\n          ops.push(filterOps);\n        }\n\n        // sort\n        if (curSort && Array.isArray(curSort.sortObjs)) {\n          const sortOps = this.getDeleteSortByFieldIdOps(curSort, fieldId);\n          ops.push(sortOps);\n        }\n\n        // group\n        if (curGroup && Array.isArray(curGroup)) {\n          const groupOps = this.getDeleteGroupByFieldIdOps(curGroup, fieldId);\n          ops.push(groupOps);\n        }\n\n        // options for kanban view stackFieldId\n        if (viewType === ViewType.Kanban && curOptions) {\n          const optionsOps = this.getDeleteOptionByFieldIdOps(curOptions, fieldId);\n          ops.push(optionsOps);\n        }\n      });\n\n      // 2. build update ops and emit\n      opsMap[viewId] = ops;\n    }\n    await this.batchUpdateViewByOps(tableId, opsMap);\n  }\n\n  getDeleteFilterByFieldIdOps(filter: IFilterSet, fieldId: string) {\n    const newFilter = this.getDeletedFilterByFieldId(filter, fieldId);\n    return ViewOpBuilder.editor.setViewProperty.build({\n      key: 'filter',\n      newValue: newFilter,\n      oldValue: filter,\n    });\n  }\n  getDeletedFilterByFieldId(filter: IFilterSet, fieldId: string) {\n    const removeItemsByFieldId = (filter: IFilterSet, fieldId: string) => {\n      if (Array.isArray(filter.filterSet)) {\n        filter.filterSet = filter.filterSet.filter((item) => {\n          if ('fieldId' in item && item.fieldId === fieldId) {\n            return false;\n          }\n          if ('filterSet' in item && item.filterSet) {\n            removeItemsByFieldId(item, fieldId);\n            return item.filterSet.length > 0;\n          }\n          return true;\n        });\n      }\n      return filter;\n    };\n    const newFilter = removeItemsByFieldId({ ...filter }, fieldId) as IFilter;\n    return newFilter?.filterSet?.length ? newFilter : null;\n  }\n  private getDeleteSortByFieldIdOps(sort: NonNullable<ISort>, fieldId: string) {\n    const newSort: ISort = {\n      sortObjs: sort.sortObjs.filter((sortItem) => sortItem.fieldId !== fieldId),\n      manualSort: !!sort.manualSort,\n    };\n    return ViewOpBuilder.editor.setViewProperty.build({\n      key: 'sort',\n      newValue: newSort?.sortObjs.length ? newSort : null,\n      oldValue: sort,\n    });\n  }\n  private getDeleteGroupByFieldIdOps(group: NonNullable<IGroup>, fieldId: string) {\n    const newGroup: IGroup = group.filter((groupItem) => groupItem.fieldId !== fieldId);\n    return ViewOpBuilder.editor.setViewProperty.build({\n      key: 'group',\n      newValue: newGroup?.length ? newGroup : null,\n      oldValue: group,\n    });\n  }\n  private getDeleteColumnMetaByFieldIdOps(columnMeta: NonNullable<IColumnMeta>, fieldId: string) {\n    return ViewOpBuilder.editor.updateViewColumnMeta.build({\n      fieldId: fieldId,\n      newColumnMeta: null,\n      oldColumnMeta: { ...columnMeta[fieldId] },\n    });\n  }\n  private getDeleteOptionByFieldIdOps(options: IViewOptions, fieldId: string) {\n    const newOptions = { ...options } as IKanbanViewOptions;\n    if (newOptions.stackFieldId === fieldId) {\n      delete newOptions.stackFieldId;\n    }\n    if (newOptions.coverFieldId === fieldId) {\n      delete newOptions.coverFieldId;\n    }\n    return ViewOpBuilder.editor.setViewProperty.build({\n      key: 'options',\n      newValue: newOptions,\n      oldValue: options,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/filter/global-exception.filter.ts",
    "content": "import type { ExceptionFilter, HttpException } from '@nestjs/common';\nimport {\n  BadRequestException,\n  Catch,\n  ForbiddenException,\n  Logger,\n  NotFoundException,\n  NotImplementedException,\n  UnauthorizedException,\n  ArgumentsHost,\n} from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { SentryExceptionCaptured } from '@sentry/nestjs';\nimport type { Request, Response } from 'express';\nimport type { ILoggerConfig } from '../configs/logger.config';\nimport { TemplateAppTokenNotAllowedException } from '../custom.exception';\nimport { exceptionParse } from '../utils/exception-parse';\n\n@Catch()\nexport class GlobalExceptionFilter implements ExceptionFilter {\n  private logger = new Logger(GlobalExceptionFilter.name);\n\n  constructor(private readonly configService: ConfigService) {}\n\n  @SentryExceptionCaptured()\n  catch(exception: Error | HttpException, host: ArgumentsHost) {\n    const { enableGlobalErrorLogging } = this.configService.getOrThrow<ILoggerConfig>('logger');\n\n    const ctx = host.switchToHttp();\n    const response = ctx.getResponse<Response>();\n    const request = ctx.getRequest<Request>();\n\n    if (\n      enableGlobalErrorLogging ||\n      !(\n        exception instanceof BadRequestException ||\n        exception instanceof UnauthorizedException ||\n        exception instanceof ForbiddenException ||\n        exception instanceof NotFoundException ||\n        exception instanceof NotImplementedException\n      )\n    ) {\n      this.logError(exception, request);\n    }\n    if (exception instanceof TemplateAppTokenNotAllowedException) {\n      return response.status(exception.getStatus()).json({\n        message: exception.message,\n      });\n    }\n    const customHttpException = exceptionParse(exception);\n    const status = customHttpException.getStatus();\n    return response.status(status).json({\n      message: customHttpException.message,\n      status: status,\n      code: customHttpException.code,\n      data: customHttpException.data,\n    });\n  }\n\n  protected logError(exception: Error, request: Request) {\n    this.logger.error(\n      {\n        url: request?.url,\n        message: exception.message,\n      },\n      exception.stack\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/global.module.ts",
    "content": "import type { DynamicModule, MiddlewareConsumer, ModuleMetadata, NestModule } from '@nestjs/common';\nimport { Global, Module } from '@nestjs/common';\nimport { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';\nimport { context, trace } from '@opentelemetry/api';\nimport { PrismaModule } from '@teable/db-main-prisma';\nimport type { Request } from 'express';\nimport { nanoid } from 'nanoid';\nimport { ClsMiddleware, ClsModule } from 'nestjs-cls';\nimport {\n  I18nModule,\n  QueryResolver,\n  AcceptLanguageResolver,\n  HeaderResolver,\n  CookieResolver,\n} from 'nestjs-i18n';\nimport { CacheModule } from '../cache/cache.module';\nimport { ConfigModule } from '../configs/config.module';\nimport { X_REQUEST_ID } from '../const';\nimport { DbProvider } from '../db-provider/db.provider';\nimport { EventEmitterModule } from '../event-emitter/event-emitter.module';\nimport { AuthGuard } from '../features/auth/guard/auth.guard';\nimport { PermissionGuard } from '../features/auth/guard/permission.guard';\nimport { PermissionModule } from '../features/auth/permission.module';\nimport { DataLoaderModule } from '../features/data-loader/data-loader.module';\nimport { ModelModule } from '../features/model/model.module';\nimport { RequestInfoMiddleware } from '../middleware/request-info.middleware';\nimport { PerformanceCacheModule } from '../performance-cache';\nimport { RouteTracingInterceptor } from '../tracing/route-tracing.interceptor';\nimport { getI18nPath, getI18nTypesOutputPath } from '../utils/i18n';\nimport { KnexModule } from './knex';\n\nconst globalModules = {\n  imports: [\n    ConfigModule.register(),\n    ClsModule.forRoot({\n      global: true,\n      middleware: {\n        mount: false,\n        generateId: true,\n        idGenerator: (req: Request) => {\n          const existingID = req.headers[X_REQUEST_ID] as string;\n          if (existingID) return existingID;\n\n          const span = trace.getSpan(context.active());\n          if (!span) return nanoid();\n\n          const { traceId } = span.spanContext();\n          return traceId;\n        },\n      },\n    }),\n    CacheModule.register({ global: true }),\n    EventEmitterModule.register({ global: true }),\n    KnexModule.register(),\n    ModelModule,\n    PrismaModule,\n    PermissionModule,\n    DataLoaderModule,\n    PerformanceCacheModule,\n    I18nModule.forRootAsync({\n      useFactory: () => {\n        const i18nPath = getI18nPath();\n        const typesOutputPath = getI18nTypesOutputPath();\n        return {\n          fallbackLanguage: 'en',\n          loaderOptions: {\n            path: i18nPath,\n            watch: process.env.NODE_ENV !== 'production',\n          },\n          typesOutputPath,\n          formatter: (template: string, ...args: Array<string | Record<string, string>>) => {\n            // replace {{field}} to {$field}\n            const normalized = template.replace(/\\{\\{\\s*(\\w+)\\s*\\}\\}/g, '{$1}');\n            const options = I18nModule['sanitizeI18nOptions']();\n            return options.formatter(normalized, ...args);\n          },\n        };\n      },\n      resolvers: [\n        { use: QueryResolver, options: ['lang'] },\n        { use: CookieResolver, options: ['NEXT_LOCALE'] },\n        AcceptLanguageResolver,\n        new HeaderResolver(['x-lang']),\n      ],\n    }),\n  ],\n\n  // for overriding the default TablePermissionService, FieldPermissionService, RecordPermissionService, and ViewPermissionService\n  providers: [\n    DbProvider,\n    RequestInfoMiddleware,\n    {\n      provide: APP_GUARD,\n      useClass: AuthGuard,\n    },\n    {\n      provide: APP_GUARD,\n      useClass: PermissionGuard,\n    },\n    {\n      provide: APP_INTERCEPTOR,\n      useClass: RouteTracingInterceptor,\n    },\n  ],\n  exports: [DbProvider],\n};\n\n@Global()\n@Module(globalModules)\nexport class GlobalModule implements NestModule {\n  configure(consumer: MiddlewareConsumer) {\n    consumer.apply(ClsMiddleware).forRoutes('*').apply(RequestInfoMiddleware).forRoutes('*');\n  }\n\n  static register(moduleMetadata: ModuleMetadata): DynamicModule {\n    return {\n      module: GlobalModule,\n      global: true,\n      imports: [...globalModules.imports, ...(moduleMetadata.imports || [])],\n      providers: [...globalModules.providers, ...(moduleMetadata.providers || [])],\n      exports: [...globalModules.exports, ...(moduleMetadata.exports || [])],\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/init-bootstrap.provider.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Provider } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Knex } from 'knex';\nimport { InitBootstrapService } from './init-bootstrap.service';\n\nexport const InitBootstrapProvider: Provider = {\n  provide: InitBootstrapService,\n  useFactory: async (prismaService: PrismaService, knex: Knex) => {\n    const initBootstrapService = new InitBootstrapService(prismaService, knex);\n\n    await initBootstrapService.init();\n\n    return initBootstrapService;\n  },\n  inject: [PrismaService, 'CUSTOM_KNEX'],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/init-bootstrap.service.ts",
    "content": "import { Injectable, Logger } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { getDriverName } from '../utils/db-helpers';\n\n@Injectable()\nexport class InitBootstrapService {\n  private readonly logger = new Logger(InitBootstrapService.name);\n  constructor(\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {}\n\n  async init() {\n    const driverName = getDriverName(this.knex);\n\n    if (driverName === DriverClient.Sqlite) {\n      await this.prismaService\n        .$queryRaw`PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;`.catch((error) => {\n        this.logger.error('Prisma Set `PRAGMA` Failed due to:', error.stack);\n        process.exit(1);\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/knex/index.ts",
    "content": "export * from './knex.extend';\nexport * from './knex.module';\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/knex/knex.extend.ts",
    "content": "/* eslint-disable @typescript-eslint/no-namespace */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { DriverClient } from '@teable/core';\nimport knex from 'knex';\nimport { getDriverName } from '../../utils/db-helpers';\n\ntry {\n  knex.QueryBuilder.extend('columnList', function (tableName: string) {\n    const driverClient = getDriverName(this);\n\n    switch (driverClient) {\n      case DriverClient.Sqlite:\n        return knex(this.client.config).raw(`PRAGMA table_info(??)`, tableName);\n      case DriverClient.Pg: {\n        const [schema, name] = tableName.split('.');\n        this.select({\n          name: 'column_name',\n          type: 'data_type',\n          dflt_value: 'column_default',\n          notnull: 'is_nullable',\n        })\n          .from('information_schema.columns')\n          .where('table_name', name)\n          .where('table_schema', schema);\n        break;\n      }\n    }\n    return this;\n  });\n} catch (e) {\n  console.error(e);\n}\n\ndeclare module 'knex' {\n  namespace Knex {\n    interface QueryBuilder {\n      columnList(tableName: string): Knex.QueryBuilder;\n    }\n  }\n}\n\nexport { knex };\n"
  },
  {
    "path": "apps/nestjs-backend/src/global/knex/knex.module.ts",
    "content": "import type { DynamicModule } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { parseDsn } from '@teable/core';\nimport { KnexModule as BaseKnexModule } from 'nest-knexjs';\n\n@Module({})\nexport class KnexModule {\n  static register(): DynamicModule {\n    return BaseKnexModule.forRootAsync(\n      {\n        inject: [ConfigService],\n        useFactory: (config: ConfigService) => {\n          const databaseUrl = config.getOrThrow<string>('PRISMA_DATABASE_URL');\n          const { driver } = parseDsn(databaseUrl);\n          return {\n            config: {\n              client: driver,\n              useNullAsDefault: true,\n            },\n            name: 'CUSTOM_KNEX',\n          };\n        },\n      },\n      'CUSTOM_KNEX'\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/index.ts",
    "content": "import './instrument';\nimport './tracing';\nimport type { INestApplication } from '@nestjs/common';\nimport { bootstrap } from './bootstrap';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ndeclare const module: any;\n\nlet app: INestApplication | undefined;\n\nasync function main() {\n  app = await bootstrap();\n}\n\nmain();\n\n// Force exit after timeout if app.close() hangs during development\n// enableShutdownHooks() in bootstrap.ts handles graceful shutdown,\n// but some modules may not release resources properly\nif (module.hot) {\n  const forceExitTimeout = 5000; // 5 seconds\n\n  const forceExit = (signal: string) => {\n    console.log(`Received ${signal}, forcing exit in ${forceExitTimeout}ms if not closed...`);\n    setTimeout(() => {\n      console.log('Force exiting due to timeout...');\n      process.exit(0);\n    }, forceExitTimeout).unref();\n  };\n\n  process.on('SIGINT', () => forceExit('SIGINT'));\n  process.on('SIGTERM', () => forceExit('SIGTERM'));\n\n  module.hot.accept((err: Error) => {\n    if (err) {\n      console.error('[HMR] Update failed, restarting...', err);\n      // If HMR fails, restart the app\n      main();\n    }\n  });\n  module.hot.dispose(() => {\n    app?.close();\n  });\n}\n\nexport { app };\n"
  },
  {
    "path": "apps/nestjs-backend/src/instrument.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport * as Sentry from '@sentry/nestjs';\n\nif (process.env.BACKEND_SENTRY_DSN) {\n  const traceRate = Number(process.env.BACKEND_SENTRY_TRACE_SAMPLING_RATE ?? 0.1);\n  Sentry.init({\n    dsn: process.env.BACKEND_SENTRY_DSN,\n    tracesSampleRate: traceRate,\n    skipOpenTelemetrySetup: true,\n    enableLogs: true,\n    _experiments: {\n      enableMetrics: true,\n    },\n    release: process.env.NEXT_PUBLIC_BUILD_VERSION || 'development',\n    environment: process.env.NODE_ENV || 'development',\n    defaultIntegrations: false,\n    // Only keep error-related integrations, tracing is handled by OTEL\n    integrations: [\n      Sentry.consoleLoggingIntegration({ levels: ['warn', 'error'] }),\n      Sentry.pinoIntegration(),\n      Sentry.childProcessIntegration(),\n      Sentry.onUnhandledRejectionIntegration(),\n      Sentry.onUncaughtExceptionIntegration(),\n      // base\n      Sentry.dedupeIntegration(),\n      Sentry.functionToStringIntegration(),\n      Sentry.linkedErrorsIntegration(),\n      Sentry.dataloaderIntegration(),\n    ],\n  });\n  Logger.log(`Sentry initialized, tracesSampleRate: ${traceRate}`);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/logger/logger.module.ts",
    "content": "import type { DynamicModule } from '@nestjs/common';\nimport { Module } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { context, trace } from '@opentelemetry/api';\nimport { ClsService } from 'nestjs-cls';\nimport { LoggerModule as BaseLoggerModule } from 'nestjs-pino';\nimport type { ILoggerConfig } from '../configs/logger.config';\nimport { X_REQUEST_ID } from '../const';\nimport type { IClsStore } from '../types/cls';\n\n@Module({})\nexport class LoggerModule {\n  static register(): DynamicModule {\n    return BaseLoggerModule.forRootAsync({\n      inject: [ClsService, ConfigService],\n      useFactory: (cls: ClsService<IClsStore>, config: ConfigService) => {\n        const { level } = config.getOrThrow<ILoggerConfig>('logger');\n        const env = process.env.NODE_ENV;\n        const isCi = ['true', '1'].includes(process.env?.CI ?? '');\n\n        const disableAutoLogging = isCi || env === 'test';\n        const shouldAutoLog = !disableAutoLogging && (env === 'production' || level === 'debug');\n\n        return {\n          pinoHttp: {\n            serializers: {\n              req(req) {\n                delete req.headers;\n                return req;\n              },\n              res(res) {\n                delete res.headers;\n                return res;\n              },\n            },\n            name: 'teable',\n            level: level,\n            // Disable automatic HTTP request logging in CI and tests\n            autoLogging: shouldAutoLog\n              ? {\n                  ignore: (req) => {\n                    const url = req.url;\n                    if (!url) return false;\n\n                    if (url.startsWith('/_next')) return true;\n                    if (url.startsWith('/__next')) return true;\n                    if (url === '/favicon.ico') return true;\n                    if (url.startsWith('/.well-known/')) return true;\n                    if (url === '/health' || url === '/ping') return true;\n                    if (req.headers.upgrade === 'websocket') return true;\n                    return false;\n                  },\n                }\n              : false,\n            genReqId: (req, res) => {\n              const existingID = req.id ?? req.headers[X_REQUEST_ID];\n              if (existingID) return existingID;\n              const id = cls.getId();\n              res.setHeader(X_REQUEST_ID, id);\n              return id;\n            },\n            transport:\n              process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,\n            formatters: {\n              log(object) {\n                const span = trace.getSpan(context.active());\n                if (!span) return { ...object };\n                const { traceId, spanId } = span.spanContext();\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                const sessionId = (object as any)?.res?.req?.sessionID;\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                const reqPath = (object as any)?.res?.req?.route?.path;\n                return {\n                  ...object,\n                  route: reqPath,\n                  is_access_token: Boolean(cls.get('accessTokenId')),\n                  user_id: cls.get('user.id'),\n                  session_id: sessionId,\n                  spanId,\n                  traceId,\n                };\n              },\n            },\n          },\n        };\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/middleware/request-info.middleware.ts",
    "content": "import type { NestMiddleware } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { X_CANARY_HEADER } from '@teable/openapi';\nimport type { Request, Response, NextFunction } from 'express';\nimport { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../types/cls';\n\n@Injectable()\nexport class RequestInfoMiddleware implements NestMiddleware {\n  private readonly logger = new Logger(RequestInfoMiddleware.name);\n\n  constructor(private readonly cls: ClsService<IClsStore>) {}\n\n  use(req: Request, res: Response, next: NextFunction) {\n    const userAgent = req.headers['user-agent'] || '';\n    const referer = req.headers.referer || '';\n    const authHeader = req.headers.authorization || '';\n    const byApi = authHeader.toLowerCase().startsWith('bearer ');\n    const origin: IClsStore['origin'] = {\n      ip: req.ip || req.socket.remoteAddress || '',\n      byApi,\n      userAgent,\n      referer,\n    };\n\n    this.cls.set('origin', origin);\n\n    // Check if this is an internal automation call\n    // Store in CLS to pass through to batch service\n    const isAutomationInternal = req.headers['x-automation-internal'] === 'true';\n    const isAiInternal = req.headers['x-ai-internal'] === 'true';\n\n    // for inner axios call, skip record audit log\n    if (isAutomationInternal || isAiInternal) {\n      this.cls.set('skipRecordAuditLog', true);\n    }\n\n    if (isAiInternal) {\n      this.cls.set('user.id', 'aiRobot');\n    }\n\n    // Canary header for canary release override\n    const canaryHeader = req.headers[X_CANARY_HEADER];\n    if (typeof canaryHeader === 'string') {\n      this.cls.set('canaryHeader', canaryHeader);\n    }\n\n    next();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/observability/observability.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ProfilerModule } from './profiling/profiler.module';\n@Module({\n  imports: [ProfilerModule],\n})\nexport class ObservabilityModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/observability/profiling/profiler.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { StorageModule } from '../../features/attachments/plugins/storage.module';\nimport { ProfilerService } from './profiler.service';\n@Module({\n  imports: [StorageModule],\n  providers: [ProfilerService],\n  exports: [ProfilerService],\n})\nexport class ProfilerModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/observability/profiling/profiler.service.ts",
    "content": "import * as inspector from 'inspector';\nimport * as os from 'os';\nimport path from 'path';\nimport { Injectable, Logger } from '@nestjs/common';\nimport type { OnModuleInit, OnModuleDestroy } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport dayjs from 'dayjs';\nimport { IStorageConfig, StorageConfig } from '../../configs/storage';\nimport StorageAdapter from '../../features/attachments/plugins/adapter';\nimport { InjectStorageAdapter } from '../../features/attachments/plugins/storage';\n\n/**\n * ProfilerService is used to profile the CPU usage of the application.\n * ENV:\n * // enable profiling, default false\n * - ENABLE_PROFILING=true\n * // save interval in milliseconds, default 1 hour (60 * 60 * 1000)\n * - PROFILE_SAVE_INTERVAL=60_000\n * // profile directory, default profiles\n * - PROFILE_DIRECTORY=profiles\n */\n\n@Injectable()\nexport class ProfilerService implements OnModuleInit, OnModuleDestroy {\n  private readonly logger = new Logger(ProfilerService.name);\n  private session: inspector.Session | null = null;\n  private intervalTimer: NodeJS.Timeout | null = null;\n  private saveInterval: number;\n  private profileCounter = 0;\n  private enabled = false;\n  private profileDirectory: string;\n  private isSaving = false;\n  private isShuttingDown = false;\n  private readonly hostname = os.hostname();\n\n  // Safety limits\n  private readonly maxProfileSizeMB = 500; // Max 500MB per profile\n  private readonly uploadTimeoutMs = 30000; // 30 seconds upload timeout\n  private readonly maxUploadRetries = 3;\n\n  constructor(\n    private readonly configService: ConfigService,\n    @StorageConfig() readonly storageConfig: IStorageConfig,\n    @InjectStorageAdapter() readonly storageAdapter: StorageAdapter\n  ) {\n    this.enabled = this.configService.get('ENABLE_PROFILING') === 'true';\n\n    // default 1 hour\n    this.saveInterval = parseInt(\n      this.configService.get('PROFILE_SAVE_INTERVAL') || `${60 * 60 * 1000}`\n    );\n\n    this.profileDirectory = this.configService.get('PROFILE_DIRECTORY') || 'profiles';\n  }\n\n  async onModuleInit() {\n    if (!this.enabled) {\n      this.logger.log('💤 Profiling disabled (set ENABLE_PROFILING=true to enable)');\n      return;\n    }\n\n    const started = this.startSession();\n    if (!started) {\n      this.logger.error('Failed to initialize profiler');\n      return;\n    }\n\n    this.startPeriodicSave();\n\n    const intervalMinutes = Math.floor(this.saveInterval / 60000);\n    this.logger.log(`📊 Profiler initialized - saving every ${intervalMinutes} minutes`);\n  }\n\n  async onModuleDestroy() {\n    if (!this.enabled) {\n      return;\n    }\n\n    this.logger.log('🛑 Shutting down profiler...');\n    await this.cleanup();\n  }\n\n  /**\n   * Start a new profiling session\n   */\n  private startSession(): boolean {\n    try {\n      if (this.session) {\n        this.session.disconnect();\n      }\n\n      this.session = new inspector.Session();\n      this.session.connect();\n      this.session.post('Profiler.enable');\n      this.session.post('Profiler.start');\n      this.logger.log(`🔥 CPU Profiling started (Hostname: ${this.hostname})`);\n      return true;\n    } catch (error) {\n      this.logger.error('Failed to start profiler', error);\n      this.session = null;\n      return false;\n    }\n  }\n\n  /**\n   * Stop the current profiling session and get profile data\n   */\n  private async stopSession(): Promise<inspector.Profiler.Profile | null> {\n    if (!this.session) {\n      return null;\n    }\n\n    return new Promise((resolve) => {\n      this.session!.post('Profiler.stop', (err, { profile }) => {\n        this.session?.disconnect();\n        this.session = null;\n\n        if (err) {\n          this.logger.error('Failed to stop profiler', err);\n          resolve(null);\n        } else {\n          resolve(profile);\n        }\n      });\n    });\n  }\n\n  private generateProfileFilename() {\n    this.profileCounter++;\n    const timestamp = new Date().getTime();\n    return `cpu-${this.profileCounter}-${this.hostname}-${timestamp}.cpuprofile`;\n  }\n\n  /**\n   * Save profile data to storage\n   */\n  private async saveProfile(profile: inspector.Profiler.Profile): Promise<boolean> {\n    try {\n      const filename = this.generateProfileFilename();\n      const buffer = Buffer.from(JSON.stringify(profile));\n      const sizeInMB = (buffer.length / 1024 / 1024).toFixed(2);\n\n      // Safety check: validate profile size\n      const sizeMBNum = parseFloat(sizeInMB);\n      if (sizeMBNum > this.maxProfileSizeMB) {\n        this.logger.warn(\n          `Profile size ${sizeInMB}MB exceeds maximum ${this.maxProfileSizeMB}MB, skipping upload`\n        );\n        return false;\n      }\n\n      await this.uploadToStorage(filename, buffer);\n      this.logger.log(`✅ Profile uploaded: ${filename} (${sizeInMB} MB)`);\n      return true;\n    } catch (error) {\n      this.logger.error('Failed to save profile', error);\n      return false;\n    }\n  }\n\n  private startPeriodicSave() {\n    this.intervalTimer = setInterval(async () => {\n      // Skip if already saving or shutting down\n      if (this.isSaving || this.isShuttingDown) {\n        this.logger.debug('Skipping periodic save (already in progress or shutting down)');\n        return;\n      }\n\n      this.logger.log('⏰ Periodic save triggered');\n      try {\n        await this.saveAndRestart();\n      } catch (error) {\n        this.logger.error('Failed to save profile', error);\n      }\n    }, this.saveInterval);\n\n    // Prevent timer from keeping process alive\n    this.intervalTimer.unref();\n  }\n\n  /**\n   * Save current profile and restart profiling session\n   */\n  private async saveAndRestart(): Promise<void> {\n    if (!this.session) {\n      this.logger.warn('No active profiling session');\n      return;\n    }\n\n    if (this.isSaving) {\n      this.logger.warn('Save already in progress, skipping');\n      return;\n    }\n\n    this.isSaving = true;\n\n    try {\n      // Stop current session and get profile data with timeout\n      const profile = await Promise.race([\n        this.stopSession(),\n        new Promise<null>((_, reject) =>\n          setTimeout(() => reject(new Error('Stop session timeout after 60s')), 60000)\n        ),\n      ]);\n\n      if (!profile) {\n        throw new Error('Failed to get profile data');\n      }\n\n      // Save profile to storage\n      await this.saveProfile(profile);\n\n      // Restart profiling session if not shutting down\n      if (!this.isShuttingDown) {\n        const restarted = this.startSession();\n        if (restarted) {\n          this.logger.log('🔄 Profiling restarted');\n        }\n      }\n    } catch (error) {\n      this.logger.error('Failed to save/restart profile', error);\n\n      // Try to restart profiler even if save failed\n      if (!this.isShuttingDown && !this.session) {\n        const restarted = this.startSession();\n        if (restarted) {\n          this.logger.log('🔄 Profiling restarted after error');\n        }\n      }\n\n      throw error;\n    } finally {\n      this.isSaving = false;\n    }\n  }\n\n  private async uploadToStorage(filename: string, buffer: Buffer): Promise<void> {\n    const fullPath = path.join(this.profileDirectory, dayjs().format('YYYY-MM-DD'), filename);\n\n    // Retry logic with exponential backoff\n    let lastError: Error | null = null;\n    for (let attempt = 1; attempt <= this.maxUploadRetries; attempt++) {\n      try {\n        const uploadPromise = this.storageAdapter.uploadFile(\n          this.storageConfig.privateBucket,\n          fullPath,\n          buffer,\n          {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/json',\n          }\n        );\n\n        // Add timeout wrapper\n        const timeoutPromise = new Promise<never>((_, reject) =>\n          setTimeout(\n            () => reject(new Error(`Upload timeout after ${this.uploadTimeoutMs}ms`)),\n            this.uploadTimeoutMs\n          )\n        );\n\n        await Promise.race([uploadPromise, timeoutPromise]);\n\n        // Success!\n        if (attempt > 1) {\n          this.logger.log(`Upload succeeded on attempt ${attempt}/${this.maxUploadRetries}`);\n        }\n        return;\n      } catch (error) {\n        lastError = error as Error;\n        this.logger.warn(\n          `Upload attempt ${attempt}/${this.maxUploadRetries} failed: ${lastError.message}`\n        );\n\n        if (attempt < this.maxUploadRetries) {\n          // Exponential backoff: 1s, 2s, 4s, ...\n          const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 10000);\n          this.logger.debug(`Retrying upload in ${delayMs}ms...`);\n          await new Promise((resolve) => setTimeout(resolve, delayMs));\n        }\n      }\n    }\n\n    // All retries failed\n    throw new Error(\n      `Failed to upload profile after ${this.maxUploadRetries} attempts: ${lastError?.message}`\n    );\n  }\n\n  /**\n   * Wait for ongoing save operation to complete\n   */\n  private async waitForSaveCompletion(maxWaitMs = 5000): Promise<void> {\n    if (!this.isSaving) {\n      return;\n    }\n\n    this.logger.log('Waiting for ongoing save to complete...');\n    const startTime = Date.now();\n\n    while (this.isSaving && Date.now() - startTime < maxWaitMs) {\n      await new Promise((resolve) => setTimeout(resolve, 100));\n    }\n\n    if (this.isSaving) {\n      this.logger.warn(`Ongoing save did not complete within ${maxWaitMs}ms`);\n    }\n  }\n\n  /**\n   * Cleanup on shutdown: save final profile and release resources\n   */\n  private async cleanup(): Promise<void> {\n    if (this.isShuttingDown) {\n      this.logger.warn('Cleanup already in progress');\n      return;\n    }\n\n    this.isShuttingDown = true;\n\n    // Clear periodic save timer\n    if (this.intervalTimer) {\n      clearInterval(this.intervalTimer);\n      this.intervalTimer = null;\n    }\n\n    // Wait for any ongoing save to complete\n    await this.waitForSaveCompletion(5000);\n\n    // Save final profile if session is active\n    if (!this.session) {\n      return;\n    }\n\n    try {\n      // Stop session and get final profile with timeout\n      const profile = await Promise.race([\n        this.stopSession(),\n        new Promise<null>((resolve) => {\n          setTimeout(() => {\n            this.logger.warn('⚠️ Final profile stop timeout (10s), forcing shutdown');\n            this.session?.disconnect();\n            this.session = null;\n            resolve(null);\n          }, 10000);\n        }),\n      ]);\n\n      if (profile) {\n        await this.saveProfile(profile);\n        this.logger.log(`📊 Total profiles saved: ${this.profileCounter}`);\n      }\n    } catch (error) {\n      this.logger.error('Failed to save final profile', error);\n    }\n  }\n\n  /**\n   * Manually trigger a profile save and restart\n   * Note: This should be protected by authentication in production\n   */\n  async manualSave() {\n    if (!this.enabled) {\n      throw new Error('Profiling is not enabled');\n    }\n\n    if (this.isShuttingDown) {\n      throw new Error('Service is shutting down');\n    }\n\n    this.logger.log('📸 Manual save triggered');\n    await this.saveAndRestart();\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/cache-metrics/metrics.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CacheMetricsService } from './metrics.service';\n\n@Module({\n  providers: [CacheMetricsService],\n  exports: [CacheMetricsService],\n})\nexport class CacheMetricsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/cache-metrics/metrics.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { metrics } from '@opentelemetry/api';\n\n@Injectable()\nexport class CacheMetricsService {\n  private readonly meter = metrics.getMeter('teable-observability');\n\n  private readonly cacheHits = this.meter.createCounter('performance.cache.hit', {\n    description: 'Performance cache hit count',\n  });\n  private readonly cacheMisses = this.meter.createCounter('performance.cache.miss', {\n    description: 'Performance cache miss count',\n  });\n  private readonly cacheGetTime = this.meter.createHistogram('performance.cache.get.time', {\n    description: 'Performance cache get time in milliseconds',\n    unit: 'ms',\n    advice: {\n      explicitBucketBoundaries: [1, 2, 5, 10, 25, 50, 75, 100],\n    },\n  });\n  private readonly cacheHitRate = this.meter.createGauge('performance.cache.hit.rate', {\n    description: 'Performance cache hit rate percentage',\n    unit: '%',\n  });\n\n  recordHit(cacheType: string, attributes?: Record<string, string>): void {\n    this.cacheHits.add(1, {\n      cache_type: cacheType,\n      ...attributes,\n    });\n  }\n\n  recordMiss(cacheType: string, attributes?: Record<string, string>): void {\n    this.cacheMisses.add(1, {\n      cache_type: cacheType,\n      ...attributes,\n    });\n  }\n\n  recordGetTime(cacheType: string, durationMs: number, attributes?: Record<string, string>): void {\n    this.cacheGetTime.record(durationMs, {\n      cache_type: cacheType,\n      ...attributes,\n    });\n  }\n\n  recordHitRate(cacheType: string, hitRate: number, attributes?: Record<string, string>): void {\n    this.cacheHitRate.record(hitRate, {\n      cache_type: cacheType,\n      ...attributes,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/decorator.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { generateServiceCacheKey } from './generate-keys';\nimport { PerformanceCacheService } from './service';\nimport type { ICacheDecoratorOptions } from './types';\n\n/**\n * Default values for performance cache decorator options\n */\nconst DEFAULT_OPTIONS: Partial<ICacheDecoratorOptions> = {\n  ttl: 300, // 5 minutes\n  skipGet: false,\n  skipSet: false,\n  preventConcurrent: false, // disable concurrent prevention by default\n};\n\n/**\n * Performance cache decorator\n * Automatically adds caching functionality to methods\n *\n * @param options Cache options\n * @returns Decorator function\n *\n * @example\n * ```typescript\n * // Basic usage\n * class UserService {\n *   @PerformanceCache({ ttl: 600 })\n *   async getUserById(userId: string) {\n *     return this.userRepository.findById(userId);\n *   }\n *\n *   // Custom key generator\n *   @PerformanceCache({\n *     keyGenerator: (tableId, filters) => `table:${tableId}:${JSON.stringify(filters)}`\n *   })\n *   async getTableData(tableId: string, filters: any) {\n *     return this.queryTableData(tableId, filters);\n *   }\n *\n *   // Conditional cache\n *   @PerformanceCache({\n *     condition: (useCache) => useCache === true,\n *     ttl: 600\n *   })\n *   async getExpensiveData(data: any, useCache = false) {\n *     return this.calculateExpensiveData(data);\n *   }\n * }\n * ```\n */\nexport function PerformanceCache(options: ICacheDecoratorOptions = {}) {\n  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n    const originalMethod = descriptor.value;\n    const finalOptions = { ...DEFAULT_OPTIONS, ...options };\n\n    descriptor.value = async function (...args: any[]) {\n      // Get dependency injected service\n      const cacheService = getInjectedService(\n        this,\n        PerformanceCacheService,\n        finalOptions.cacheServiceName\n      );\n\n      if (!cacheService) {\n        throw new Error(\n          `PerformanceCacheService is not available in ${target.constructor.name}.${propertyKey}`\n        );\n      }\n\n      // Check condition function\n      if (finalOptions.condition && !finalOptions.condition(...args)) {\n        return originalMethod.apply(this, args);\n      }\n\n      // Generate cache key\n      const cacheKey = generateCacheKey(target.constructor.name, propertyKey, args, finalOptions);\n\n      // Wrap original method execution\n      return cacheService.wrap(cacheKey as any, () => originalMethod.apply(this, args), {\n        ttl: finalOptions.ttl,\n        skipGet: finalOptions.skipGet,\n        skipSet: finalOptions.skipSet,\n        preventConcurrent: finalOptions.preventConcurrent,\n        statsType: finalOptions.statsType,\n      });\n    };\n\n    return descriptor;\n  };\n}\n\n/**\n * Generate cache key\n */\nfunction generateCacheKey(\n  className: string,\n  methodName: string,\n  args: any[],\n  options: ICacheDecoratorOptions\n): string {\n  // If custom key generator is provided\n  if (options.keyGenerator) {\n    return options.keyGenerator(...args);\n  }\n\n  // Default key generation logic\n  return generateServiceCacheKey(className, methodName, args);\n}\n\n/**\n * Get dependency injected service instance\n */\nfunction getInjectedService<T>(\n  instance: any,\n  serviceClass: new (...args: any[]) => T,\n  cacheServiceName?: string\n): T | null {\n  try {\n    // Try to get service from instance\n    const serviceName = serviceClass.name;\n    const defaultName = cacheServiceName\n      ? cacheServiceName\n      : serviceName.charAt(0).toLowerCase() + serviceName.slice(1);\n    if (instance[defaultName] instanceof serviceClass) {\n      return instance[defaultName];\n    }\n\n    return null;\n  } catch (error) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/generate-keys.ts",
    "content": "import { generateHash } from './utils';\n\nexport function generateRecordCacheKey(\n  path: string,\n  tableId: string,\n  version: string,\n  query: unknown\n) {\n  return `record:${path}:${tableId}:${version}:${generateHash(query)}` as const;\n}\n\nexport function generateAggCacheKey(\n  path: string,\n  tableId: string,\n  version: string,\n  query: unknown\n) {\n  return `agg:${path}:${tableId}:${version}:${generateHash(query)}` as const;\n}\n\nexport function generateServiceCacheKey(className: string, methodName: string, args: unknown) {\n  return `service:${className}:${methodName}:${generateHash(args)}` as const;\n}\n\nexport function generateUserCacheKey(userId: string) {\n  return `user:${userId}` as const;\n}\n\nexport function generateCollaboratorCacheKey(resourceId: string) {\n  return `collaborator:${resourceId}` as const;\n}\n\nexport function generateAccessTokenCacheKey(id: string) {\n  return `access-token:${id}` as const;\n}\n\nexport function generateSettingCacheKey() {\n  return `instance:setting` as const;\n}\n\nexport function generateIntegrationCacheKey(spaceId: string) {\n  return `integration:${spaceId}` as const;\n}\n\nexport function generateBaseNodeListCacheKey(baseId: string) {\n  return `base-node-list:${baseId}` as const;\n}\n\nexport function generateTemplateCacheKeyByBaseId(baseId: string) {\n  return `template:base:${baseId}` as const;\n}\n\nexport function generateTemplateCategoryCacheKey() {\n  return `template:category-list` as const;\n}\n\nexport function generateTemplatePermalinkCacheKey(identifier: string) {\n  return `template:permalink:${identifier}` as const;\n}\n\nexport function generateInstanceBillableUserCountCacheKey() {\n  return 'instance-billable-count' as const;\n}\n\nexport function generateBaseShareListCacheKey(baseId: string) {\n  return `base-share-list:${baseId}` as const;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/index.ts",
    "content": "// Core services and modules\nexport { PerformanceCacheService } from './service';\nexport { PerformanceCacheModule } from './module';\n\n// Decorators\nexport { PerformanceCache } from './decorator';\n\n// Type definitions\nexport type {\n  IPerformanceCacheStore,\n  ICacheOptions,\n  ICacheDecoratorOptions,\n  ICacheStats,\n} from './types';\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { CacheMetricsModule } from './cache-metrics/metrics.module';\nimport { PerformanceCacheService } from './service';\n\n@Global()\n@Module({\n  imports: [CacheMetricsModule],\n  providers: [PerformanceCacheService],\n  exports: [PerformanceCacheService],\n})\nexport class PerformanceCacheModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/performance-cache.decorator.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { Injectable } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../global/global.module';\nimport { PerformanceCache } from './decorator';\nimport { PerformanceCacheService } from './service';\n\n// Test service with decorated methods\n@Injectable()\nclass TestService {\n  public callCount = 0; // Track method calls manually\n\n  constructor(private readonly performanceCacheService: PerformanceCacheService) {}\n\n  // Basic caching\n  @PerformanceCache({ ttl: 300 })\n  async basicMethod(value: string): Promise<string> {\n    this.callCount++; // Increment call count\n    return `processed-${value}`;\n  }\n\n  // With custom key generator\n  @PerformanceCache({\n    ttl: 300,\n    keyGenerator: (userId: number, type: string) =>\n      `service:TestService:customKeyMethod:${userId}:${type}`,\n  })\n  async customKeyMethod(userId: number, type: string): Promise<string> {\n    this.callCount++;\n    return `user-${userId}-data-${type}`;\n  }\n\n  // Conditional caching\n  @PerformanceCache({\n    ttl: 300,\n    condition: (value: string, enableCache: boolean) => enableCache,\n  })\n  async conditionalMethod(value: string, _enableCache: boolean): Promise<string> {\n    this.callCount++;\n    return `conditional-${value}`;\n  }\n\n  // Method with cache key parameter\n  async methodWithCacheKey(data: string): Promise<string> {\n    this.callCount++;\n    return `keyed-${data}`;\n  }\n\n  // Disable concurrent prevention\n  @PerformanceCache({\n    ttl: 300,\n    preventConcurrent: false,\n  })\n  async noConcurrentPrevention(value: string): Promise<string> {\n    this.callCount++;\n    await new Promise((resolve) => setTimeout(resolve, 100));\n    return `no-concurrent-${value}`;\n  }\n\n  // Long operation with concurrent prevention\n  @PerformanceCache({\n    ttl: 600,\n    preventConcurrent: true,\n  })\n  async longOperation(id: string): Promise<string> {\n    this.callCount++;\n    await new Promise((resolve) => setTimeout(resolve, 500));\n    return `long-result-${id}`;\n  }\n\n  // Skip options\n  @PerformanceCache({\n    ttl: 300,\n    skipGet: false,\n    skipSet: false,\n  })\n  async normalOperation(value: string): Promise<string> {\n    this.callCount++;\n    return `normal-${value}`;\n  }\n\n  // Method that throws error\n  @PerformanceCache({ ttl: 300 })\n  async errorMethod(): Promise<string> {\n    this.callCount++;\n    throw new Error('Test error');\n  }\n\n  // Helper method to get cache stats\n  getCacheStats() {\n    return this.performanceCacheService.getStats();\n  }\n\n  // Helper method to reset cache stats\n  resetCacheStats() {\n    this.performanceCacheService.resetStats();\n  }\n\n  // Helper method to clear cache (for testing)\n  async clearCache() {\n    // Clear all test cache patterns\n    await this.performanceCacheService._clear();\n    this.callCount = 0;\n  }\n}\n\ndescribe.runIf(process.env.BACKEND_PERFORMANCE_CACHE)('Performance Cache Decorators', () => {\n  let module: TestingModule;\n  let testService: TestService;\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      imports: [GlobalModule],\n      providers: [\n        TestService,\n        {\n          provide: ConfigService,\n          useValue: {\n            get: vi.fn((key: string) => {\n              if (key === 'BACKEND_PERFORMANCE_CACHE') {\n                return process.env.BACKEND_PERFORMANCE_CACHE || 'redis://localhost:6379';\n              }\n              return undefined;\n            }),\n          },\n        },\n      ],\n    }).compile();\n\n    testService = module.get<TestService>(TestService);\n  });\n\n  afterEach(async () => {\n    // Clean up\n    testService.resetCacheStats();\n    testService.clearCache();\n    await module.close();\n  });\n\n  describe('@PerformanceCache Decorator', () => {\n    it('should cache method results', async () => {\n      vi.spyOn(testService, 'basicMethod');\n      // First call\n      const result1 = await testService.basicMethod('test');\n\n      // Second call (should be cached)\n      const result2 = await testService.basicMethod('test');\n\n      expect(result1).toBe('processed-test');\n      expect(result2).toBe('processed-test');\n      expect(testService.callCount).toBe(1); // Only called once due to caching\n    });\n\n    it('should cache different arguments separately', async () => {\n      vi.spyOn(testService, 'basicMethod');\n\n      const result1 = await testService.basicMethod('test1');\n      const result2 = await testService.basicMethod('test2');\n      const result3 = await testService.basicMethod('test1'); // Should be cached\n\n      expect(result1).toBe('processed-test1');\n      expect(result2).toBe('processed-test2');\n      expect(result3).toBe('processed-test1');\n      expect(testService.callCount).toBe(2); // Called twice for different args\n    });\n\n    it('should use custom key generator', async () => {\n      vi.spyOn(testService, 'customKeyMethod');\n\n      const result1 = await testService.customKeyMethod(123, 'profile');\n      const result2 = await testService.customKeyMethod(123, 'profile'); // Same key\n      const result3 = await testService.customKeyMethod(124, 'profile'); // Different key\n\n      expect(result1).toBe('user-123-data-profile');\n      expect(result2).toBe('user-123-data-profile');\n      expect(result3).toBe('user-124-data-profile');\n      expect(testService.callCount).toBe(2); // Two different keys, so called twice\n    });\n\n    it('should handle conditional caching', async () => {\n      vi.spyOn(testService, 'conditionalMethod');\n\n      // With caching enabled\n      const result1 = await testService.conditionalMethod('test', true);\n      const result2 = await testService.conditionalMethod('test', true);\n\n      // With caching disabled\n      const result3 = await testService.conditionalMethod('test', false);\n      const result4 = await testService.conditionalMethod('test', false);\n\n      expect(result1).toBe('conditional-test');\n      expect(result2).toBe('conditional-test');\n      expect(result3).toBe('conditional-test');\n      expect(result4).toBe('conditional-test');\n\n      // Should be called 3 times: 1 for cached, 2 for non-cached\n      expect(testService.callCount).toBe(3);\n    });\n\n    it('should not cache errors', async () => {\n      vi.spyOn(testService, 'errorMethod');\n\n      // First call should throw\n      await expect(testService.errorMethod()).rejects.toThrow('Test error');\n\n      // Second call should also throw (not cached)\n      await expect(testService.errorMethod()).rejects.toThrow('Test error');\n\n      expect(testService.callCount).toBe(2);\n    });\n\n    it('should handle concurrent requests', async () => {\n      vi.spyOn(testService, 'longOperation');\n\n      // Multiple concurrent calls\n      const promises = Array.from({ length: 5 }, () =>\n        testService.longOperation('concurrent-test')\n      );\n\n      const results = await Promise.all(promises);\n\n      // All results should be the same\n      expect(results.every((r) => r === results[0])).toBe(true);\n      expect(results[0]).toBe('long-result-concurrent-test');\n\n      // Should only be called once due to concurrent prevention\n      expect(testService.callCount).toBe(1);\n\n      // Check concurrent waits in stats\n      const stats = testService.getCacheStats();\n      expect(stats.hits).toBe(4);\n    }, 10000);\n\n    it('should allow concurrent execution when disabled', async () => {\n      vi.spyOn(testService, 'noConcurrentPrevention');\n\n      // Multiple concurrent calls with different values\n      const promises = Array.from({ length: 3 }, (_, i) =>\n        testService.noConcurrentPrevention(`test-${i}`)\n      );\n\n      const results = await Promise.all(promises);\n\n      // All should execute\n      expect(testService.callCount).toBe(3);\n      expect(results).toEqual([\n        'no-concurrent-test-0',\n        'no-concurrent-test-1',\n        'no-concurrent-test-2',\n      ]);\n    }, 10000);\n  });\n\n  describe('Performance and Statistics', () => {\n    it('should update cache statistics', async () => {\n      testService.resetCacheStats();\n\n      // Generate some cache activity\n      await testService.basicMethod('stats-test'); // Miss + Set\n      await testService.basicMethod('stats-test'); // Hit\n\n      const stats = testService.getCacheStats();\n\n      expect(stats.hits).toBeGreaterThan(0);\n      expect(stats.sets).toBeGreaterThan(0);\n    });\n\n    it('should handle high concurrency correctly', async () => {\n      const concurrentRequests = 10;\n      const testValue = 'concurrency-test';\n\n      const promises = Array.from({ length: concurrentRequests }, () =>\n        testService.longOperation(testValue)\n      );\n\n      const startTime = Date.now();\n      const results = await Promise.all(promises);\n      const endTime = Date.now();\n\n      // All results should be identical\n      expect(results.every((r) => r === results[0])).toBe(true);\n\n      // Should complete in roughly the time of one operation\n      // (allowing for some overhead)\n      expect(endTime - startTime).toBeLessThan(1000);\n\n      const stats = testService.getCacheStats();\n      expect(stats.hits).toBe(9);\n    }, 15000);\n  });\n\n  describe('Error Handling and Edge Cases', () => {\n    it('should handle cache service unavailable', async () => {\n      // This test would require mocking the cache service to be unavailable\n      // For now, we'll test that methods still work even if caching fails\n\n      const result = await testService.basicMethod('fallback-test');\n      expect(result).toBe('processed-fallback-test');\n    });\n\n    it('should handle invalid cache keys gracefully', async () => {\n      // Test with various edge case inputs\n      const testCases = ['', ' ', '\\n', '\\t', 'special characters', '🚀emoji'];\n\n      for (const testCase of testCases) {\n        const result = await testService.basicMethod(testCase);\n        expect(result).toBe(`processed-${testCase}`);\n      }\n    });\n  });\n\n  describe('Configuration Options', () => {\n    it('should respect TTL settings', async () => {\n      // This is harder to test without waiting for expiration\n      // But we can verify the method executes correctly\n      const result = await testService.normalOperation('ttl-test');\n      expect(result).toBe('normal-ttl-test');\n    });\n\n    it('should work with different key prefixes', async () => {\n      // Methods with different configurations should work independently\n      const result1 = await testService.basicMethod('prefix-test');\n      const result2 = await testService.customKeyMethod(456, 'settings');\n\n      expect(result1).toBe('processed-prefix-test');\n      expect(result2).toBe('user-456-data-settings');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/service.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport KeyvRedis from '@keyv/redis';\nimport { Injectable, Logger, Optional } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport Keyv from 'keyv';\nimport { floor } from 'lodash';\nimport type { RedlockAbortSignal } from 'redlock';\nimport Redlock, { ExecutionError, ResourceLockedError } from 'redlock';\nimport { CacheMetricsService } from './cache-metrics/metrics.service';\nimport type { ICacheOptions, ICacheStats, IPerformanceCacheStore } from './types';\n\n@Injectable()\nexport class PerformanceCacheService<T extends IPerformanceCacheStore = IPerformanceCacheStore> {\n  private readonly logger = new Logger(PerformanceCacheService.name);\n  private keyv!: Keyv;\n  private redlock?: Redlock;\n  private enabled = false;\n  private typeStats: Partial<Record<string, { hits: number; misses: number }>> = {};\n\n  private stats: ICacheStats = {\n    hits: 0,\n    misses: 0,\n    sets: 0,\n    deletes: 0,\n    errors: 0,\n  };\n\n  private readonly lockPrefix = 'perf:lock';\n\n  constructor(\n    private readonly configService: ConfigService,\n    @Optional() private readonly cacheMetricsService?: CacheMetricsService\n  ) {\n    try {\n      const redisUri = this.configService.get<string>('BACKEND_PERFORMANCE_CACHE');\n\n      if (!redisUri) {\n        this.logger.warn(\n          'Performance cache is disabled - BACKEND_PERFORMANCE_CACHE not configured'\n        );\n        return;\n      }\n\n      this.enabled = true;\n\n      // Initialize Keyv for caching\n      const store = new KeyvRedis(redisUri, { useRedisSets: false });\n      this.keyv = new Keyv({ namespace: 'teable_perf', store });\n\n      this.keyv.on('error', (error) => {\n        this.logger.error(\n          `Performance cache connection error: ${error instanceof Error ? error.message : String(error)}`\n        );\n        this.stats.errors++;\n      });\n\n      // Initialize Redlock for distributed locking\n      this.redlock = new Redlock([store.redis], {\n        driftFactor: 0.01, // 1% drift tolerance\n        retryCount: 10, // Retry 10 times before giving up\n        retryDelay: 300, // 300ms base delay between retries\n        retryJitter: 100, // Add up to 100ms random jitter\n        automaticExtensionThreshold: 500, // Auto-extend if <500ms remaining\n      });\n\n      this.redlock.on('error', (error: Error) => {\n        // Check if it's a ResourceLockedError (normal during contention)\n        if (error.name === 'ResourceLockedError') {\n          this.logger.debug(`Resource locked (normal contention): ${error.message}`);\n        } else {\n          this.logger.error(\n            `Redlock error: ${error instanceof Error ? error.message : String(error)}`\n          );\n          this.stats.errors++;\n        }\n      });\n\n      this.logger.log('Performance cache initialized with Redis and Redlock');\n    } catch (error) {\n      this.logger.error(\n        `Failed to initialize performance cache: ${error instanceof Error ? error.message : String(error)}`\n      );\n      this.stats.errors++;\n    }\n  }\n\n  private recordTypeStats(type: 'hits' | 'misses', cacheType?: string) {\n    if (!cacheType) {\n      return;\n    }\n    const stats = this.typeStats[cacheType] || { hits: 0, misses: 0 };\n    if (type === 'hits') stats.hits++;\n    else stats.misses++;\n    this.typeStats[cacheType] = stats;\n    type === 'hits'\n      ? this.cacheMetricsService?.recordHit(cacheType)\n      : this.cacheMetricsService?.recordMiss(cacheType);\n    this.cacheMetricsService?.recordHitRate(\n      cacheType,\n      floor(stats.hits / Math.max(stats.hits + stats.misses, 1), 4) * 100\n    );\n  }\n\n  /**\n   * Check if cache is available\n   */\n  private isAvailable(): boolean {\n    return this.enabled && this.keyv != null;\n  }\n\n  /**\n   * Check if redlock is available\n   */\n  private isRedlockAvailable(): boolean {\n    return this.enabled && this.redlock != null;\n  }\n\n  private setValueToKeyv(key: string, value: T[keyof T], ttlMs: number | undefined) {\n    return this.keyv.set(key as string, { data: value }, ttlMs);\n  }\n\n  /**\n   * Get cache value\n   */\n  async get<TKey extends keyof T>(key: TKey, options: ICacheOptions = {}) {\n    if (!this.isAvailable() || options.skipGet) {\n      return null;\n    }\n    try {\n      const startTime = Date.now();\n      const value = await this.keyv.get(key as string);\n      const endTime = Date.now();\n      const durationMs = endTime - startTime;\n      options.statsType && this.cacheMetricsService?.recordGetTime(options.statsType, durationMs);\n      if (value == undefined) {\n        this.stats.misses++;\n        this.recordTypeStats('misses', options.statsType);\n        return null;\n      }\n\n      this.stats.hits++;\n      this.recordTypeStats('hits', options.statsType);\n      return value as { data: T[TKey] };\n    } catch (error) {\n      this.logger.error('Error getting cache value:', error);\n      this.stats.errors++;\n      return null;\n    }\n  }\n\n  /**\n   * Set cache value\n   */\n  async set<TKey extends keyof T>(\n    key: TKey,\n    value: T[TKey],\n    options: ICacheOptions = {}\n  ): Promise<void> {\n    if (!this.isAvailable() || options.skipSet) {\n      return;\n    }\n\n    if (options.ttl == undefined) {\n      throw new Error('ttl is required');\n    }\n\n    try {\n      const ttlMs = options.ttl ? options.ttl * 1000 : undefined;\n\n      await this.setValueToKeyv(key as string, value, ttlMs);\n      this.stats.sets++;\n    } catch (error) {\n      this.logger.error(\n        `Error setting cache value: ${error instanceof Error ? error.message : String(error)}`\n      );\n      this.stats.errors++;\n      console.error(error);\n    }\n  }\n\n  /**\n   * Delete cache value\n   */\n  async del<TKey extends keyof T>(key: TKey): Promise<void> {\n    if (!this.isAvailable()) {\n      return;\n    }\n\n    try {\n      await this.keyv.delete(key as string);\n      this.stats.deletes++;\n    } catch (error) {\n      this.logger.error('Error deleting cache value:', error);\n      this.stats.errors++;\n    }\n  }\n\n  /**\n   * Batch get cache values\n   */\n  async mget<TKey extends keyof T>(\n    keys: TKey[],\n    options: ICacheOptions = {}\n  ): Promise<Array<T[TKey] | null>> {\n    if (!this.isAvailable() || options.skipGet) {\n      return keys.map(() => null);\n    }\n\n    try {\n      const values = await this.keyv.get(keys as string[]);\n      return values.map((value) => {\n        if (value == undefined) {\n          this.stats.misses++;\n          this.recordTypeStats('misses', options.statsType);\n          return null;\n        }\n        this.stats.hits++;\n        this.recordTypeStats('hits', options.statsType);\n        return value as T[TKey];\n      });\n    } catch (error) {\n      this.logger.error(\n        `Error getting multiple cache values: ${error instanceof Error ? error.message : String(error)}`\n      );\n      this.stats.errors++;\n      return keys.map(() => null);\n    }\n  }\n\n  /**\n   * Batch set cache values\n   */\n  async mset(\n    keyValuePairs: Array<{ key: keyof T; value: T[keyof T] }>,\n    options: ICacheOptions = {}\n  ): Promise<void> {\n    if (!this.isAvailable() || options.skipSet) {\n      return;\n    }\n\n    try {\n      const ttlMs = options.ttl ? options.ttl * 1000 : undefined;\n\n      for (const { key, value } of keyValuePairs) {\n        await this.setValueToKeyv(key as string, value, ttlMs);\n      }\n\n      this.stats.sets += keyValuePairs.length;\n    } catch (error) {\n      this.logger.error(\n        `Error setting multiple cache values: ${error instanceof Error ? error.message : String(error)}`\n      );\n      this.stats.errors++;\n    }\n  }\n\n  /**\n   * Clear cache keys matching pattern\n   * @internal only for testing\n   */\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  async _clear() {\n    if (!this.isAvailable()) {\n      return 0;\n    }\n\n    try {\n      await this.keyv.clear();\n    } catch (error) {\n      this.logger.error(\n        `Error deleting cache pattern: ${error instanceof Error ? error.message : String(error)}`\n      );\n      this.stats.errors++;\n    }\n  }\n\n  /**\n   * Get cache statistics\n   */\n  getStats(): ICacheStats {\n    return { ...this.stats };\n  }\n\n  /**\n   * Reset cache statistics\n   */\n  resetStats(): void {\n    this.stats = {\n      hits: 0,\n      misses: 0,\n      sets: 0,\n      deletes: 0,\n      errors: 0,\n    };\n  }\n\n  getTypeStats() {\n    return this.typeStats;\n  }\n\n  resetTypeStats(): void {\n    this.typeStats = {};\n  }\n  /**\n   * Generic cache wrapper method\n   * Returns cached value if exists, otherwise executes function and caches result\n   * Prevents concurrent execution for the same cache key using Redlock\n   */\n  async wrap<TResult>(\n    key: keyof T,\n    fn: () => Promise<TResult>,\n    options: ICacheOptions = {}\n  ): Promise<TResult> {\n    const finalOptions = { preventConcurrent: true, ...options };\n\n    if (!this.isAvailable()) {\n      return fn();\n    }\n\n    // Try to get from cache first\n    const cached = await this.get(key, options);\n    if (cached !== null) {\n      return cached?.data as TResult;\n    }\n\n    // If concurrent prevention is disabled or redlock unavailable, execute directly\n    if (!finalOptions.preventConcurrent || !this.isRedlockAvailable()) {\n      return this.executeAndCache(key, fn, options);\n    }\n\n    // Use redlock for distributed locking\n    const cacheKeyStr = key as string;\n    const lockResource = `${this.lockPrefix}:${cacheKeyStr}`;\n    try {\n      // Use redlock.using for automatic lock management\n      return await this.redlock!.using(\n        [lockResource],\n        10000,\n        async (signal: RedlockAbortSignal) => {\n          // Check if lock extension failed\n          if (signal.aborted) {\n            throw signal.error;\n          }\n\n          // Check cache again in case another instance already populated it\n          const cachedAfterLock = await this.get(key, options);\n          if (cachedAfterLock !== null) {\n            this.logger.debug(`Cache populated by another instance: ${cacheKeyStr}`);\n            return cachedAfterLock?.data as TResult;\n          }\n\n          // Check again before executing (in case of long operations)\n          if (signal.aborted) {\n            throw signal.error;\n          }\n          // Execute and cache the result\n          this.logger.debug(`Executing with distributed lock: ${cacheKeyStr}`);\n          return await this.executeAndCache(key, fn, options);\n        }\n      );\n    } catch (error: unknown) {\n      if (error instanceof ResourceLockedError || error instanceof ExecutionError) {\n        this.logger.error(`Redlock error for ${cacheKeyStr}: ${error}`);\n        await new Promise((resolve) => setTimeout(resolve, 50));\n        const cachedAfterLock = await this.get(key, options);\n        if (cachedAfterLock !== null) {\n          return cachedAfterLock?.data as TResult;\n        }\n        return this.executeAndCache(key, fn, options);\n      }\n      this.stats.errors++;\n      // Fallback to direct execution\n      throw error;\n    }\n  }\n\n  /**\n   * Execute function and cache the result\n   */\n  private async executeAndCache<TResult>(\n    key: keyof T,\n    fn: () => Promise<TResult>,\n    options: ICacheOptions = {}\n  ): Promise<TResult> {\n    // Execute the function\n    const result = await fn();\n    this.logger.log(`Generated cache key: ${key as string}`);\n    // Store to cache\n    await this.set(key, result as T[keyof T], options);\n\n    return result;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/types.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { IPickUserMe } from '../features/auth/utils';\n\n/**\n * Performance cache key-value store interface\n * Used to define data types that can be stored in performance cache\n */\nexport interface IPerformanceCacheStore {\n  // record cache, format: record:path:table_id:version:query_hash\n  [key: `record:${string}:${string}:${string}:${string}`]: unknown;\n\n  // Aggregation result cache, format: agg:path:table_id:version:query_hash\n  [key: `agg:${string}:${string}:${string}:${string}`]: unknown;\n\n  // Service method cache, format: service:class_name:method:params_hash\n  [key: `service:${string}:${string}:${string}`]: unknown;\n\n  // user cache, format: user:user_id\n  [key: `user:${string}`]: IPickUserMe & { deactivatedTime: string | null };\n\n  // collaborator cache, format: collaborator:resource_id\n  [key: `collaborator:${string}`]: unknown;\n\n  // access token cache, format: access-token:id\n  [key: `access-token:${string}`]: unknown;\n\n  // integration cache, format: integration:space_id\n  [key: `integration:${string}`]: unknown;\n\n  // template cache\n  [key: `template:${string}`]: unknown;\n\n  // instance setting cache, format: instance:setting\n  'instance:setting': unknown;\n\n  // base node list cache, format: base-node-list:base_id\n  [key: `base-node-list:${string}`]: unknown;\n\n  // template cache, format: template:base:base_id\n  [key: `template:base:${string}`]: unknown;\n\n  // billable user count cache, format: instance-billable-count\n  'instance-billable-count': number;\n\n  // AI Gateway models cache, format: ai-gateway:models\n  'ai-gateway:models': unknown;\n\n  // Base share list cache, format: base-share-list:base_id\n  [key: `base-share-list:${string}`]: { nodeId: string }[];\n}\n\n/**\n * Cache options interface\n */\nexport interface ICacheOptions {\n  /** Cache expiration time (seconds) */\n  ttl?: number;\n  /** Whether to skip cache reading (write only) */\n  skipGet?: boolean;\n  /** Whether to skip cache writing (read only) */\n  skipSet?: boolean;\n  /** Whether to prevent concurrent cache generation for same key (default: true) */\n  preventConcurrent?: boolean;\n  /** Performance prefix */\n  statsType?: string;\n}\n\n/**\n * Cache decorator options\n */\nexport interface ICacheDecoratorOptions extends ICacheOptions {\n  /** Cache key generation function, uses default parameter hash if not provided */\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  keyGenerator?: (...args: any[]) => string;\n  /** Condition function, skip cache when returns false */\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  condition?: (...args: any[]) => boolean;\n  /** Cache service name, if not provided, use the default name: performanceCacheService */\n  cacheServiceName?: string;\n}\n\n/**\n * Cache statistics\n */\nexport interface ICacheStats {\n  /** Hit count */\n  hits: number;\n  /** Miss count */\n  misses: number;\n  /** Set count */\n  sets: number;\n  /** Delete count */\n  deletes: number;\n  /** Error count */\n  errors: number;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/performance-cache/utils.ts",
    "content": "export const generateHash = (data: unknown): string => {\n  const str = typeof data === 'string' ? data : JSON.stringify(data);\n  let hash = 0;\n  for (let i = 0; i < str.length; i++) {\n    hash = ((hash << 5) - hash + str.charCodeAt(i)) & 0xffffffff;\n  }\n  return Math.abs(hash).toString(36);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/auth.middleware.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport url from 'url';\nimport type ShareDBClass from 'sharedb';\n\nexport const authMiddleware = (shareDB: ShareDBClass) => {\n  const runWithCls = async (context: ShareDBClass.middleware.QueryContext, callback: any) => {\n    const cookie = context.agent.custom.cookie;\n    const shareId = context.agent.custom.shareId;\n    const baseShareId = context.agent.custom.baseShareId;\n    const templateHeader = context.agent.custom.templateHeader;\n    if (context.options) {\n      context.options = { ...context.options, cookie, shareId, baseShareId, templateHeader };\n    } else {\n      context.options = { cookie, shareId, baseShareId, templateHeader };\n    }\n    callback();\n  };\n\n  shareDB.use('connect', async (context, callback) => {\n    if (!context.req) {\n      callback();\n      return;\n    }\n    const cookie = context.req.headers.cookie;\n    context.agent.custom.cookie = cookie;\n\n    const newUrl = new url.URL(context.req.url, 'https://example.com');\n    const shareId = newUrl.searchParams.get('shareId');\n    const baseShareIdParam = newUrl.searchParams.get('baseShareId');\n    // Only set baseShareId if explicitly provided, don't fallback to shareId\n    // This allows view share (shareId only) and base share (baseShareId) to work independently\n    const baseShareId = baseShareIdParam || null;\n    const templateHeader = newUrl.searchParams.get('templateHeader');\n    context.agent.custom.templateHeader = templateHeader;\n    context.agent.custom.shareId = shareId;\n    context.agent.custom.baseShareId = baseShareId;\n    callback();\n  });\n\n  shareDB.use('query', (context, callback) => runWithCls(context, callback));\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/interface.ts",
    "content": "import type { ISnapshotBase } from '@teable/core';\nimport type { CreateOp, DB, DeleteOp, EditOp } from 'sharedb';\n\nexport interface IReadonlyAdapterService {\n  getSnapshotBulk(\n    collectionId: string,\n    ids: string[],\n    projection?: { [fieldNameOrId: string]: boolean },\n    extra?: unknown\n  ): Promise<ISnapshotBase<unknown>[]>;\n\n  getDocIdsByQuery(\n    collectionId: string,\n    query: unknown\n  ): Promise<{ ids: string[]; extra?: unknown }>;\n}\n\nexport interface IShareDbReadonlyAdapterService extends IReadonlyAdapterService {\n  // get current version and type of the document\n  getVersionAndType(\n    collectionId: string,\n    docId: string\n  ): Promise<{ version: number; type: RawOpType }>;\n\n  getVersionAndTypeMap(\n    collectionId: string,\n    docIds: string[]\n  ): Promise<Record<string, { version: number; type: RawOpType }>>;\n}\n\nexport interface IAdapterService {\n  create(collectionId: string, snapshot: unknown): Promise<void>;\n\n  del(version: number, collectionId: string, docId: string): Promise<void>;\n\n  update(\n    version: number,\n    collectionId: string,\n    docId: string,\n    opContexts: unknown[]\n  ): Promise<void>;\n}\n\nexport interface IShareDbConfig {\n  db: DB;\n}\n\nexport enum RawOpType {\n  Create = 'create',\n  Del = 'del',\n  Edit = 'edit',\n}\n\nexport type IEditOp = Omit<EditOp, 'c' | 'd' | 'create' | 'del'>;\nexport type IDeleteOp = Omit<DeleteOp, 'c' | 'd' | 'create' | 'op'>;\nexport type ICreateOp = Omit<CreateOp, 'c' | 'd' | 'op' | 'del'>;\n\nexport type IRawOp = ICreateOp | IDeleteOp | IEditOp;\n\nexport interface IRawOpMap {\n  [collection: string]: {\n    [docId: string]: IRawOp;\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/metrics/realtime-metrics.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { RealtimeMetricsService } from './realtime-metrics.service';\n\n@Module({\n  providers: [RealtimeMetricsService],\n  exports: [RealtimeMetricsService],\n})\nexport class RealtimeMetricsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/metrics/realtime-metrics.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { metrics } from '@opentelemetry/api';\n\n@Injectable()\nexport class RealtimeMetricsService {\n  private readonly meter = metrics.getMeter('teable-observability');\n\n  private readonly connectionsActive = this.meter.createUpDownCounter(\n    'realtime.connections.active',\n    { description: 'Number of currently active WebSocket connections' }\n  );\n  private readonly connectionsTotal = this.meter.createCounter('realtime.connections.total', {\n    description: 'Total number of WebSocket connections established',\n  });\n  private readonly disconnectsTotal = this.meter.createCounter('realtime.disconnects.total', {\n    description: 'Total number of WebSocket disconnections',\n  });\n  private readonly operationsTotal = this.meter.createCounter('realtime.operations.total', {\n    description: 'Total number of ShareDB operations submitted',\n  });\n  private readonly operationDuration = this.meter.createHistogram('realtime.operations.duration', {\n    description: 'ShareDB operation duration in milliseconds',\n    unit: 'ms',\n    advice: {\n      explicitBucketBoundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000],\n    },\n  });\n  private readonly operationsErrors = this.meter.createCounter('realtime.operations.errors', {\n    description: 'Total number of ShareDB operation errors',\n  });\n  private readonly publishTotal = this.meter.createCounter('realtime.publish.total', {\n    description: 'Total number of operations published via PubSub',\n  });\n  private readonly connectionErrors = this.meter.createCounter('realtime.connections.errors', {\n    description: 'Total number of WebSocket connection errors',\n  });\n\n  recordConnectionOpen(): void {\n    this.connectionsActive.add(1);\n    this.connectionsTotal.add(1);\n  }\n\n  recordConnectionClose(): void {\n    this.connectionsActive.add(-1);\n    this.disconnectsTotal.add(1);\n  }\n\n  recordConnectionError(): void {\n    this.connectionErrors.add(1);\n  }\n\n  recordOperationSubmit(durationMs?: number): void {\n    this.operationsTotal.add(1);\n    if (durationMs != null) {\n      this.operationDuration.record(durationMs);\n    }\n  }\n\n  recordOperationError(errorType: string): void {\n    this.operationsErrors.add(1, { error_type: errorType });\n  }\n\n  recordOpsPublished(count: number): void {\n    this.publishTotal.add(count);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/field-readonly.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { IGetFieldsQuery } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { RawOpType, IShareDbReadonlyAdapterService } from '../interface';\nimport { ReadonlyService } from './readonly.service';\nimport type { IReadonlyServiceContext } from './types';\n\n@Injectable()\nexport class FieldReadonlyServiceAdapter\n  extends ReadonlyService\n  implements IShareDbReadonlyAdapterService\n{\n  constructor(\n    private readonly cls: ClsService<IReadonlyServiceContext>,\n    private readonly prismaService: PrismaService\n  ) {\n    super(cls);\n  }\n\n  getDocIdsByQuery(tableId: string, query: IGetFieldsQuery = {}) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/field/doc-ids`\n      : `/table/${tableId}/field/socket/doc-ids`;\n    return this.axios\n      .get(url, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n        params: query,\n      })\n      .then((res) => res.data);\n  }\n  getSnapshotBulk(tableId: string, ids: string[]) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/field/snapshot-bulk`\n      : `/table/${tableId}/field/socket/snapshot-bulk`;\n    return this.axios\n      .get(url, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n        params: {\n          ids,\n        },\n      })\n      .then((res) => res.data);\n  }\n\n  getVersionAndType(tableId: string, fieldId: string) {\n    return this.prismaService.field\n      .findUnique({\n        where: {\n          id: fieldId,\n          tableId,\n        },\n        select: {\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((res) => {\n        return this.formatVersionAndType(res);\n      });\n  }\n\n  getVersionAndTypeMap(tableId: string, fieldIds: string[]) {\n    return this.prismaService.field\n      .findMany({\n        where: {\n          id: { in: fieldIds },\n          tableId,\n        },\n        select: {\n          id: true,\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((fields) => {\n        return fields.reduce(\n          (acc, field) => {\n            acc[field.id] = this.formatVersionAndType(field);\n            return acc;\n          },\n          {} as Record<string, { version: number; type: RawOpType }>\n        );\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/readonly.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FieldReadonlyServiceAdapter } from './field-readonly.service';\nimport { RecordReadonlyServiceAdapter } from './record-readonly.service';\nimport { TableReadonlyServiceAdapter } from './table-readonly.service';\nimport { ViewReadonlyServiceAdapter } from './view-readonly.service';\n\n@Module({\n  imports: [],\n  providers: [\n    RecordReadonlyServiceAdapter,\n    FieldReadonlyServiceAdapter,\n    ViewReadonlyServiceAdapter,\n    TableReadonlyServiceAdapter,\n  ],\n  exports: [\n    RecordReadonlyServiceAdapter,\n    FieldReadonlyServiceAdapter,\n    ViewReadonlyServiceAdapter,\n    TableReadonlyServiceAdapter,\n  ],\n})\nexport class ReadonlyModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/readonly.service.ts",
    "content": "import { BadRequestException, Logger } from '@nestjs/common';\nimport { createAxios } from '@teable/openapi';\nimport type { ClsService } from 'nestjs-cls';\nimport type { IClsStore } from '../../types/cls';\nimport { RawOpType } from '../interface';\n\nexport class ReadonlyService {\n  private readonly logger = new Logger(ReadonlyService.name);\n\n  protected axios;\n  constructor(clsService: ClsService<IClsStore>) {\n    this.axios = createAxios();\n    this.axios.interceptors.request.use((config) => {\n      const cookie = clsService.get('cookie');\n      config.headers.cookie = cookie;\n      config.baseURL = `http://localhost:${process.env.PORT}/api`;\n      return config;\n    });\n  }\n\n  formatVersionAndType(record?: { version: number; deletedTime: Date | null } | null): {\n    version: number;\n    type: RawOpType;\n  } {\n    if (!record) {\n      return { version: -1, type: RawOpType.Del };\n    }\n    const { version, deletedTime } = record;\n    if (version < 1) {\n      throw new BadRequestException('Version is less than 1');\n    }\n\n    if (deletedTime) {\n      return { version: version - 1, type: RawOpType.Del };\n    }\n\n    if (version === 1) {\n      return { version: 0, type: RawOpType.Create };\n    }\n    return { version: version - 1, type: RawOpType.Edit };\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts",
    "content": "import { Injectable, NotFoundException } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi';\nimport { Knex } from 'knex';\nimport { InjectModel } from 'nest-knexjs';\nimport { ClsService } from 'nestjs-cls';\nimport type { IShareDbReadonlyAdapterService, RawOpType } from '../interface';\nimport { ReadonlyService } from './readonly.service';\nimport type { IReadonlyServiceContext } from './types';\n\n@Injectable()\nexport class RecordReadonlyServiceAdapter\n  extends ReadonlyService\n  implements IShareDbReadonlyAdapterService\n{\n  constructor(\n    private readonly cls: ClsService<IReadonlyServiceContext>,\n    private readonly prismaService: PrismaService,\n    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex\n  ) {\n    super(cls);\n  }\n\n  getDocIdsByQuery(tableId: string, query: IGetRecordsRo = {}) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/record/doc-ids`\n      : `/table/${tableId}/record/socket/doc-ids`;\n    return this.axios\n      .post(\n        url,\n        {\n          ...query,\n          filter: JSON.stringify(query?.filter),\n          orderBy: JSON.stringify(query?.orderBy),\n          groupBy: JSON.stringify(query?.groupBy),\n          collapsedGroupIds: JSON.stringify(query?.collapsedGroupIds),\n        },\n        {\n          headers: {\n            cookie: this.cls.get('cookie'),\n            [IS_TEMPLATE_HEADER]: templateHeader,\n            [BASE_SHARE_ID_HEADER]: baseShareId,\n          },\n        }\n      )\n      .then((res) => res.data);\n  }\n  getSnapshotBulk(\n    tableId: string,\n    recordIds: string[],\n    projection?: { [fieldNameOrId: string]: boolean }\n  ) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/record/snapshot-bulk`\n      : `/table/${tableId}/record/socket/snapshot-bulk`;\n    return this.axios\n      .get(url, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n        params: {\n          ids: recordIds,\n          projection,\n        },\n      })\n      .then((res) => res.data);\n  }\n\n  private async validateTable(tableId: string) {\n    const table = await this.prismaService.tableMeta.findUnique({\n      where: {\n        id: tableId,\n      },\n      select: {\n        version: true,\n        deletedTime: true,\n        dbTableName: true,\n      },\n    });\n    if (!table) {\n      throw new NotFoundException('Table not found');\n    }\n    return table;\n  }\n\n  async getVersionAndType(tableId: string, recordId: string) {\n    const table = await this.validateTable(tableId);\n    return this.prismaService\n      .$queryRawUnsafe<\n        { version: number; deletedTime: Date | null }[]\n      >(this.knex(table.dbTableName).select('__version as version').where('__id', recordId).toQuery())\n      .then((res) => {\n        return this.formatVersionAndType(res[0]);\n      });\n  }\n\n  async getVersionAndTypeMap(tableId: string, recordIds: string[]) {\n    const table = await this.validateTable(tableId);\n    const nativeQuery = this.knex(table.dbTableName)\n      .select('__version as version', '__id')\n      .whereIn('__id', recordIds)\n      .toQuery();\n    const recordRaw = await this.prismaService\n      .txClient()\n      .$queryRawUnsafe<{ version: number; deletedTime: Date | null; __id: string }[]>(nativeQuery);\n    return recordRaw.reduce(\n      (acc, record) => {\n        acc[record.__id] = this.formatVersionAndType(record);\n        return acc;\n      },\n      {} as Record<string, { version: number; type: RawOpType }>\n    );\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/table-readonly.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IShareDbReadonlyAdapterService, RawOpType } from '../interface';\nimport { ReadonlyService } from './readonly.service';\nimport type { IReadonlyServiceContext } from './types';\n\n@Injectable()\nexport class TableReadonlyServiceAdapter\n  extends ReadonlyService\n  implements IShareDbReadonlyAdapterService\n{\n  constructor(\n    private readonly cls: ClsService<IReadonlyServiceContext>,\n    private readonly prismaService: PrismaService\n  ) {\n    super(cls);\n  }\n\n  getDocIdsByQuery(baseId: string) {\n    const templateHeader = this.cls.get('templateHeader');\n    const baseShareId = this.cls.get('baseShareId');\n    return this.axios\n      .get(`/base/${baseId}/table/socket/doc-ids`, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n      })\n      .then((res) => res.data);\n  }\n  getSnapshotBulk(baseId: string, ids: string[]) {\n    const templateHeader = this.cls.get('templateHeader');\n    const baseShareId = this.cls.get('baseShareId');\n    return this.axios\n      .get(`/base/${baseId}/table/socket/snapshot-bulk`, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n        params: {\n          ids,\n        },\n      })\n      .then((res) => res.data);\n  }\n\n  getVersionAndType(baseId: string, tableId: string) {\n    return this.prismaService.tableMeta\n      .findUnique({\n        where: {\n          id: tableId,\n          baseId,\n        },\n        select: {\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((res) => {\n        return this.formatVersionAndType(res);\n      });\n  }\n\n  getVersionAndTypeMap(baseId: string, tableIds: string[]) {\n    return this.prismaService.tableMeta\n      .findMany({\n        where: {\n          id: { in: tableIds },\n          baseId,\n        },\n        select: {\n          id: true,\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((tables) => {\n        return tables.reduce(\n          (acc, table) => {\n            acc[table.id] = this.formatVersionAndType(table);\n            return acc;\n          },\n          {} as Record<string, { version: number; type: RawOpType }>\n        );\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/types.ts",
    "content": "import type { IClsStore } from '../../types/cls';\n\nexport interface IReadonlyServiceContext extends IClsStore {\n  templateHeader?: string;\n  baseShareId?: string;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/readonly/view-readonly.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport type { IShareDbReadonlyAdapterService, RawOpType } from '../interface';\nimport { ReadonlyService } from './readonly.service';\nimport type { IReadonlyServiceContext } from './types';\n\n@Injectable()\nexport class ViewReadonlyServiceAdapter\n  extends ReadonlyService\n  implements IShareDbReadonlyAdapterService\n{\n  constructor(\n    private readonly cls: ClsService<IReadonlyServiceContext>,\n    private readonly prismaService: PrismaService\n  ) {\n    super(cls);\n  }\n\n  getDocIdsByQuery(tableId: string) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/view/doc-ids`\n      : `/table/${tableId}/view/socket/doc-ids`;\n    return this.axios\n      .get(url, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n      })\n      .then((res) => res.data);\n  }\n  getSnapshotBulk(tableId: string, ids: string[]) {\n    const shareId = this.cls.get('shareViewId');\n    const baseShareId = this.cls.get('baseShareId');\n    const useShareViewEndpoint = shareId && !baseShareId;\n    const templateHeader = this.cls.get('templateHeader');\n    const url = useShareViewEndpoint\n      ? `/share/${shareId}/socket/view/snapshot-bulk`\n      : `/table/${tableId}/view/socket/snapshot-bulk`;\n    return this.axios\n      .get(url, {\n        headers: {\n          cookie: this.cls.get('cookie'),\n          [IS_TEMPLATE_HEADER]: templateHeader,\n          [BASE_SHARE_ID_HEADER]: baseShareId,\n        },\n        params: {\n          ids,\n        },\n      })\n      .then((res) => res.data);\n  }\n\n  getVersionAndType(tableId: string, viewId: string) {\n    return this.prismaService.view\n      .findUnique({\n        where: {\n          id: viewId,\n          tableId,\n        },\n        select: {\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((res) => {\n        return this.formatVersionAndType(res);\n      });\n  }\n\n  getVersionAndTypeMap(tableId: string, viewIds: string[]) {\n    return this.prismaService.view\n      .findMany({\n        where: {\n          id: { in: viewIds },\n          tableId,\n        },\n        select: {\n          id: true,\n          version: true,\n          deletedTime: true,\n        },\n      })\n      .then((views) => {\n        return views.reduce(\n          (acc, view) => {\n            acc[view.id] = this.formatVersionAndType(view);\n            return acc;\n          },\n          {} as Record<string, { version: number; type: RawOpType }>\n        );\n      });\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/repair-attachment-op/repair-attachment-op.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AttachmentsStorageModule } from '../../features/attachments/attachments-storage.module';\nimport { RepairAttachmentOpService } from './repair-attachment-op.service';\n\n@Module({\n  imports: [AttachmentsStorageModule],\n  providers: [RepairAttachmentOpService],\n  exports: [RepairAttachmentOpService],\n})\nexport class RepairAttachmentOpModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/repair-attachment-op/repair-attachment-op.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport type { IAttachmentCellValue, IOtOperation } from '@teable/core';\nimport { RecordOpBuilder } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { UploadType } from '@teable/openapi';\nimport type { EditOp, CreateOp, DeleteOp } from 'sharedb';\nimport { CacheService } from '../../cache/cache.service';\nimport { AttachmentsStorageService } from '../../features/attachments/attachments-storage.service';\nimport StorageAdapter from '../../features/attachments/plugins/adapter';\nimport { getTableThumbnailToken } from '../../utils/generate-thumbnail-path';\nimport { Timing } from '../../utils/timing';\nimport type { IRawOpMap } from '../interface';\n\n@Injectable()\nexport class RepairAttachmentOpService {\n  constructor(\n    private readonly prismaService: PrismaService,\n    private readonly cacheService: CacheService,\n    private readonly attachmentsStorageService: AttachmentsStorageService\n  ) {}\n\n  private isEditOp(rawOp: EditOp | CreateOp | DeleteOp): rawOp is EditOp {\n    return Boolean(!rawOp.del && !rawOp.create && rawOp.op);\n  }\n\n  private getAttachmentCell(op: IOtOperation) {\n    const setRecordOp = RecordOpBuilder.editor.setRecord.detect(op);\n    if (!setRecordOp) {\n      return;\n    }\n    const newCellValue = setRecordOp.newCellValue;\n    if (newCellValue && Array.isArray(newCellValue) && newCellValue?.[0]?.mimetype) {\n      return newCellValue as IAttachmentCellValue;\n    }\n  }\n\n  private getCollectionsAttachmentToken(rawOp: EditOp | CreateOp | DeleteOp): string[] | undefined {\n    if (!this.isEditOp(rawOp)) {\n      return;\n    }\n    return rawOp.op.reduce((acc, op) => {\n      const attachmentCell = this.getAttachmentCell(op);\n      if (!attachmentCell) {\n        return acc;\n      }\n      attachmentCell.forEach((cell) => {\n        if (!cell.presignedUrl) {\n          acc.push(cell.token);\n        }\n      });\n      return acc;\n    }, []);\n  }\n\n  private async getThumbnailPathTokenMap(tokens: string[]) {\n    const thumbnailPathTokenMap: Record<\n      string,\n      {\n        sm?: string;\n        lg?: string;\n      }\n    > = {};\n    // once handle 1000 tokens\n    const batchSize = 1000;\n    for (let i = 0; i < tokens.length; i += batchSize) {\n      const batch = tokens.slice(i, i + batchSize);\n      const attachments = await this.prismaService.txClient().attachments.findMany({\n        where: { token: { in: batch } },\n        select: { token: true, thumbnailPath: true },\n      });\n      attachments.forEach((attachment) => {\n        if (attachment.thumbnailPath) {\n          thumbnailPathTokenMap[attachment.token] = JSON.parse(attachment.thumbnailPath);\n        }\n      });\n    }\n    return thumbnailPathTokenMap;\n  }\n\n  private async getCachePreviewUrlTokenMap(tokens: string[]) {\n    const previewUrlTokenMap: Record<string, string> = {};\n    // once handle 1000 tokens\n    const batchSize = 1000;\n    for (let i = 0; i < tokens.length; i += batchSize) {\n      const batch = tokens.slice(i, i + batchSize);\n      const previewUrls = await this.cacheService.getMany(\n        batch.map((token) => `attachment:preview:${token}` as const)\n      );\n      previewUrls.forEach((urlCache, index) => {\n        if (urlCache) {\n          previewUrlTokenMap[batch[i + index]] = urlCache.url;\n        }\n      });\n    }\n    return previewUrlTokenMap;\n  }\n\n  @Timing()\n  async getCollectionsAttachmentsContext(rawOpMaps: IRawOpMap[]) {\n    const collectionsAttachmentTokens: Record<string, string[]> = {};\n    for (const rawOpMap of rawOpMaps) {\n      for (const collection in rawOpMap) {\n        const data = rawOpMap[collection];\n        for (const docId in data) {\n          const rawOp = data[docId] as EditOp | CreateOp | DeleteOp;\n          const attachmentCells = this.getCollectionsAttachmentToken(rawOp);\n          const tableId = collection.split('_')[1];\n          if (attachmentCells?.length) {\n            collectionsAttachmentTokens[`${tableId}-${docId}`] = attachmentCells;\n          }\n        }\n      }\n    }\n    const tokens = Object.values(collectionsAttachmentTokens).flat();\n    const uniqueTokens = [...new Set(tokens)];\n    const thumbnailPathTokenMap = await this.getThumbnailPathTokenMap(uniqueTokens);\n    const cachePreviewUrlTokenMap = await this.getCachePreviewUrlTokenMap(uniqueTokens);\n    return {\n      thumbnailPathTokenMap,\n      cachePreviewUrlTokenMap,\n    };\n  }\n\n  private async presignedAttachmentUrl(\n    item: { name: string; path: string; token: string; mimetype: string },\n    context: {\n      thumbnailPathTokenMap: Record<string, { sm?: string; lg?: string }>;\n      cachePreviewUrlTokenMap: Record<string, string>;\n    }\n  ) {\n    const { thumbnailPathTokenMap, cachePreviewUrlTokenMap } = context;\n    const { path, token, mimetype, name } = item;\n\n    const presignedUrl =\n      cachePreviewUrlTokenMap[token] ??\n      (await this.attachmentsStorageService.getPreviewUrlByPath(\n        StorageAdapter.getBucket(UploadType.Table),\n        path,\n        token,\n        undefined,\n        {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': mimetype,\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Disposition': `attachment; filename=\"${name}\"`,\n        }\n      ));\n    let smThumbnailUrl: string | undefined;\n    let lgThumbnailUrl: string | undefined;\n    if (mimetype.startsWith('image/') && thumbnailPathTokenMap && thumbnailPathTokenMap[token]) {\n      const { sm: smThumbnailPath, lg: lgThumbnailPath } = thumbnailPathTokenMap[token]!;\n      if (smThumbnailPath) {\n        smThumbnailUrl =\n          cachePreviewUrlTokenMap?.[getTableThumbnailToken(smThumbnailPath)] ??\n          (await this.attachmentsStorageService.getTableThumbnailUrl(smThumbnailPath, mimetype));\n      }\n      if (lgThumbnailPath) {\n        lgThumbnailUrl =\n          cachePreviewUrlTokenMap?.[getTableThumbnailToken(lgThumbnailPath)] ??\n          (await this.attachmentsStorageService.getTableThumbnailUrl(lgThumbnailPath, mimetype));\n      }\n      smThumbnailUrl = smThumbnailUrl || presignedUrl;\n      lgThumbnailUrl = lgThumbnailUrl || presignedUrl;\n    }\n    return {\n      presignedUrl,\n      smThumbnailUrl,\n      lgThumbnailUrl,\n    };\n  }\n\n  async repairAttachmentOp(\n    rawOp: EditOp | CreateOp | DeleteOp,\n    context: {\n      thumbnailPathTokenMap: Record<string, { sm?: string; lg?: string }>;\n      cachePreviewUrlTokenMap: Record<string, string>;\n    }\n  ) {\n    if (!this.isEditOp(rawOp)) {\n      return rawOp;\n    }\n    for (const op of rawOp.op) {\n      const newAttachmentCell = this.getAttachmentCell(op);\n      if (!newAttachmentCell) {\n        continue;\n      }\n      for (const item of newAttachmentCell) {\n        if (!item.presignedUrl) {\n          const { presignedUrl, smThumbnailUrl, lgThumbnailUrl } =\n            await this.presignedAttachmentUrl(item, context);\n          item.presignedUrl = presignedUrl;\n          item.smThumbnailUrl = smThumbnailUrl;\n          item.lgThumbnailUrl = lgThumbnailUrl;\n        }\n      }\n      op.oi = newAttachmentCell;\n    }\n    return rawOp;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/share-db.adapter.ts",
    "content": "import {\n  Injectable,\n  Logger,\n  NotFoundException,\n  Optional,\n  UnauthorizedException,\n} from '@nestjs/common';\nimport type {\n  IFieldPropertyKey,\n  IFieldVo,\n  IOtOperation,\n  IRecord,\n  ISnapshotBase,\n  ITablePropertyKey,\n} from '@teable/core';\nimport {\n  FieldOpBuilder,\n  getRandomString,\n  IdPrefix,\n  RecordOpBuilder,\n  TableOpBuilder,\n} from '@teable/core';\nimport type { ITableVo } from '@teable/openapi';\nimport { omit } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { CreateOp, DeleteOp, EditOp } from 'sharedb';\nimport ShareDb from 'sharedb';\nimport type { SnapshotMeta } from 'sharedb/lib/sharedb';\nimport { FieldService } from '../features/field/field.service';\nimport { TableService } from '../features/table/table.service';\nimport type { IClsStore } from '../types/cls';\nimport { exceptionParse } from '../utils/exception-parse';\nimport {\n  RawOpType,\n  type ICreateOp,\n  type IEditOp,\n  type IShareDbReadonlyAdapterService,\n} from './interface';\nimport { FieldReadonlyServiceAdapter } from './readonly/field-readonly.service';\nimport { RecordReadonlyServiceAdapter } from './readonly/record-readonly.service';\nimport { TableReadonlyServiceAdapter } from './readonly/table-readonly.service';\nimport { ViewReadonlyServiceAdapter } from './readonly/view-readonly.service';\n\nexport interface ICollectionSnapshot {\n  type: string;\n  v: number;\n  data: IRecord;\n}\n\ntype IProjection = { [fieldNameOrId: string]: boolean };\n\n@Injectable()\nexport class ShareDbAdapter extends ShareDb.DB {\n  private logger = new Logger(ShareDbAdapter.name);\n\n  closed: boolean;\n\n  constructor(\n    private readonly cls: ClsService<IClsStore>,\n    private readonly tableService: TableReadonlyServiceAdapter,\n    private readonly recordService: RecordReadonlyServiceAdapter,\n    private readonly fieldService: FieldReadonlyServiceAdapter,\n    private readonly viewService: ViewReadonlyServiceAdapter,\n    private readonly tableServiceInner: TableService,\n    @Optional() private readonly fieldServiceInner?: FieldService\n  ) {\n    super();\n    this.closed = false;\n  }\n\n  getReadonlyService(type: IdPrefix): IShareDbReadonlyAdapterService {\n    switch (type) {\n      case IdPrefix.View:\n        return this.viewService;\n      case IdPrefix.Field:\n        return this.fieldService;\n      case IdPrefix.Record:\n        return this.recordService;\n      case IdPrefix.Table:\n        return this.tableService;\n    }\n    throw new Error(`QueryType: ${type} has no readonly adapter service implementation`);\n  }\n\n  query = async (\n    collection: string,\n    query: unknown,\n    projection: IProjection,\n    options: unknown,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    callback: (err: any, snapshots: Snapshot[], extra?: any) => void\n  ) => {\n    this.queryPoll(collection, query, options, (error, results, extra) => {\n      if (error) {\n        return callback(error, []);\n      }\n      if (!results.length) {\n        return callback(undefined, [], extra);\n      }\n\n      this.getSnapshotBulk(\n        collection,\n        results as string[],\n        projection,\n        options,\n        (error, snapshots) => {\n          if (error) {\n            return callback(error, []);\n          }\n          callback(\n            error,\n            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n            results.map((id) => snapshots![id]),\n            extra\n          );\n        }\n      );\n    });\n  };\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private getAuthHeaders(options: any) {\n    const cookie = options?.cookie || options?.agentCustom?.cookie;\n    const shareId = options?.shareId || options?.agentCustom?.shareId;\n    const baseShareId = options?.baseShareId || options?.agentCustom?.baseShareId;\n    const templateHeader = options?.templateHeader || options?.agentCustom?.templateHeader;\n    if (!cookie && !shareId && !baseShareId && !templateHeader) {\n      this.logger.error(`No cookie found in options agentCustom: ${JSON.stringify(options)}`);\n      throw new UnauthorizedException('Unauthorized request not authorized');\n    }\n    return { cookie, shareViewId: shareId, baseShareId, templateHeader };\n  }\n\n  async queryPoll(\n    collection: string,\n    query: unknown,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    callback: (error: any | null, ids: string[], extra?: any) => void\n  ) {\n    try {\n      const authHeaders = this.getAuthHeaders(options);\n      await this.cls.runWith(\n        {\n          ...this.cls.get(),\n          ...authHeaders,\n        },\n        async () => {\n          const [docType, collectionId] = collection.split('_');\n          const queryResult = await this.getReadonlyService(docType as IdPrefix).getDocIdsByQuery(\n            collectionId,\n            query\n          );\n          callback(null, queryResult.ids, queryResult.extra);\n        }\n      );\n    } catch (e) {\n      this.logger.error(e);\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      callback(exceptionParse(e as Error), []);\n    }\n  }\n\n  // Return true to avoid polling if there is no possibility that an op could\n  // affect a query's results\n  // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n  // @ts-ignore\n  skipPoll(\n    _collection: string,\n    _id: string,\n    op: CreateOp | DeleteOp | EditOp,\n    _query: unknown\n  ): boolean {\n    // ShareDB is in charge of doing the validation of ops, so at this point we\n    // should be able to assume that the op is structured validly\n    if (op.create || op.del) return false;\n    return !op.op;\n  }\n\n  close(callback: () => void) {\n    this.closed = true;\n\n    if (callback) callback();\n  }\n\n  async commit() {\n    throw new Error('Method not implemented.');\n  }\n\n  private snapshots2Map<T>(snapshots: ({ id: string } & T)[]): Record<string, T> {\n    return snapshots.reduce<Record<string, T>>((pre, cur) => {\n      pre[cur.id] = cur;\n      return pre;\n    }, {});\n  }\n\n  // Get the named document from the database. The callback is called with (err,\n  // snapshot). A snapshot with a version of zero is returned if the document\n  // has never been created in the database.\n  async getSnapshotBulk(\n    collection: string,\n    ids: string[],\n    projection: IProjection | undefined,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    callback: (err: unknown | null, data?: Record<string, Snapshot>) => void\n  ) {\n    try {\n      const [docType, collectionId] = collection.split('_');\n\n      let authHeaders;\n      try {\n        authHeaders = this.getAuthHeaders(options);\n      } catch {\n        // For internal (server-side) connections without auth, resolve field docs directly\n        if (docType === IdPrefix.Field && this.fieldServiceInner) {\n          const snapshotData = await this.fieldServiceInner.getSnapshotBulk(collectionId, ids);\n          if (snapshotData.length) {\n            const snapshots = snapshotData.map(\n              (snapshot) =>\n                new Snapshot(snapshot.id, snapshot.v, snapshot.type, snapshot.data, null)\n            );\n            callback(null, this.snapshots2Map(snapshots));\n          } else {\n            const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null));\n            callback(null, this.snapshots2Map(snapshots));\n          }\n          return;\n        }\n        throw new UnauthorizedException('Unauthorized request not authorized');\n      }\n      const snapshotData = await this.cls.runWith(\n        {\n          ...this.cls.get(),\n          ...authHeaders,\n        },\n        async () => {\n          return this.getReadonlyService(docType as IdPrefix).getSnapshotBulk(\n            collectionId,\n            ids,\n            projection && projection['$submit'] ? undefined : projection\n          );\n        }\n      );\n      if (snapshotData.length) {\n        const snapshots = snapshotData.map(\n          (snapshot) =>\n            new Snapshot(\n              snapshot.id,\n              snapshot.v,\n              snapshot.type,\n              snapshot.data,\n              null // TODO: metadata\n            )\n        );\n        callback(null, this.snapshots2Map(snapshots));\n      } else {\n        const snapshots = ids.map((id) => new Snapshot(id, 0, null, undefined, null));\n        callback(null, this.snapshots2Map(snapshots));\n      }\n    } catch (err) {\n      this.logger.error(err);\n      callback(exceptionParse(err as Error));\n    }\n  }\n\n  async getSnapshot(\n    collection: string,\n    id: string,\n    projection: IProjection | undefined,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    callback: (err: unknown, data?: Snapshot) => void\n  ) {\n    await this.getSnapshotBulk(collection, [id], projection, options, (err, data) => {\n      if (err) {\n        callback(err);\n      } else {\n        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n        callback(null, data![id]);\n      }\n    });\n  }\n\n  private async getSnapshotData(\n    docType: IdPrefix,\n    collectionId: string,\n    ids: string[],\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any\n  ) {\n    if (ids.length === 0) {\n      return [];\n    }\n    if (docType === IdPrefix.Table) {\n      return await this.tableServiceInner.getSnapshotBulk(collectionId, ids, {\n        ignoreDefaultViewId: true,\n      });\n    }\n    const authHeaders = this.getAuthHeaders(options);\n    const snapshots = await this.cls.runWith(\n      {\n        ...this.cls.get(),\n        ...authHeaders,\n      },\n      async () => {\n        return await this.getReadonlyService(docType as IdPrefix).getSnapshotBulk(\n          collectionId,\n          ids\n        );\n      }\n    );\n\n    // Filter out meta field for Field type to prevent it from being sent to frontend\n    if (docType === IdPrefix.Field) {\n      return snapshots.map((snapshot) => ({\n        ...snapshot,\n        data: omit(snapshot.data as object, ['meta']),\n      }));\n    }\n\n    return snapshots;\n  }\n\n  private hasGapVersion({\n    opType,\n    currentVersion,\n    fromVersion,\n  }: {\n    opType: RawOpType;\n    currentVersion: number;\n    fromVersion: number;\n  }) {\n    if (opType === RawOpType.Del) {\n      return false;\n    }\n\n    if (fromVersion > currentVersion) {\n      return false;\n    }\n    return true;\n  }\n\n  async internalGetOps(\n    collection: string,\n    id: string,\n    from: number,\n    to: number | null,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    callback: (error: unknown, data?: unknown) => void,\n    dataFunctions: {\n      getVersionAndType: (\n        collectionId: string,\n        id: string\n      ) => Promise<{ version: number; type: RawOpType }>;\n      getSnapshotData: (\n        docType: IdPrefix,\n        collectionId: string,\n        ids: string[],\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        options: any\n      ) => Promise<ISnapshotBase<unknown>[]>;\n    }\n  ) {\n    const { getVersionAndType, getSnapshotData } = dataFunctions;\n    try {\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      const [docType, collectionId] = collection.split('_');\n\n      const { version, type } = await getVersionAndType(collectionId, id);\n\n      if (!this.hasGapVersion({ opType: type, currentVersion: version, fromVersion: from })) {\n        callback(null, []);\n        return;\n      }\n\n      const snapshotData = await getSnapshotData(docType as IdPrefix, collectionId, [id], options);\n\n      if (!snapshotData.length) {\n        throw new NotFoundException(`docType: ${docType}, id: ${id} not found`);\n      }\n\n      const { data } = snapshotData[0];\n      const baseRaw = {\n        src: getRandomString(21),\n        seq: 1,\n        v: version,\n      };\n      if (type === RawOpType.Create) {\n        callback(null, [\n          {\n            ...baseRaw,\n            create: {\n              type: 'json0',\n              data,\n            },\n          } as ICreateOp,\n        ]);\n        return;\n      }\n\n      const editOp = this.getOpsFromSnapshot(docType as IdPrefix, data);\n      const gapVersion = Math.max((to || baseRaw.v + 1) - from, 0);\n      const editOps = new Array(gapVersion).fill(0).map((_, i) => {\n        return {\n          ...baseRaw,\n          src: getRandomString(21),\n          v: from + i,\n        } as IEditOp;\n      });\n      if (gapVersion > 0) {\n        editOps[gapVersion - 1].op = editOp;\n      }\n      callback(null, editOps);\n    } catch (err) {\n      this.logger.error(err);\n      callback(exceptionParse(err as Error));\n    }\n  }\n\n  // Get operations between [from, to) non-inclusively. (Ie, the range should\n  // contain start but not end).\n  //\n  // If end is null, this function should return all operations from start onwards.\n  //\n  // The operations that getOps returns don't need to have a version: field.\n  // The version will be inferred from the parameters if it is missing.\n  //\n  // Callback should be called as callback(error, [list of ops]);\n  async getOps(\n    collection: string,\n    id: string,\n    from: number,\n    to: number | null,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    callback: (error: unknown, data?: unknown) => void\n  ) {\n    const [docType] = collection.split('_');\n    const readonlyService = this.getReadonlyService(docType as IdPrefix);\n    await this.internalGetOps(collection, id, from, to, options, callback, {\n      getVersionAndType: async (...args) => await readonlyService.getVersionAndType(...args),\n      getSnapshotData: async (...args) => await this.getSnapshotData(...args),\n    });\n  }\n\n  async getOpsBulk(\n    collection: string,\n    fromMap: Record<string, number>,\n    toMap: Record<string, number | null> | undefined,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    options: any,\n    callback: (error: unknown, data?: unknown) => void\n  ) {\n    const [docType, collectionId] = collection.split('_');\n    const readonlyService = this.getReadonlyService(docType as IdPrefix);\n    const versionAndTypeMap = await readonlyService.getVersionAndTypeMap(\n      collectionId,\n      Object.keys(fromMap)\n    );\n    const needGetSnapshotDataIds: string[] = [];\n    for (const [id, from] of Object.entries(fromMap)) {\n      const versionAndType = versionAndTypeMap[id];\n      if (!versionAndType) {\n        continue;\n      }\n      if (\n        this.hasGapVersion({\n          opType: versionAndType.type,\n          currentVersion: versionAndType.version,\n          fromVersion: from,\n        })\n      ) {\n        needGetSnapshotDataIds.push(id);\n      }\n    }\n\n    const snapshotDataMap = await this.getSnapshotData(\n      docType as IdPrefix,\n      collectionId,\n      needGetSnapshotDataIds,\n      options\n    ).then((snapshots) => {\n      return snapshots.reduce(\n        (acc, snapshot) => {\n          acc[snapshot.id] = snapshot;\n          return acc;\n        },\n        {} as Record<string, ISnapshotBase<unknown>>\n      );\n    });\n    const result: Record<string, unknown> = {};\n    for (const [id, from] of Object.entries(fromMap)) {\n      let resultError: unknown = null;\n      await this.internalGetOps(\n        collection,\n        id,\n        from,\n        toMap?.[id] ?? null,\n        options,\n        (err, data) => {\n          if (err) {\n            resultError = err;\n          }\n          result[id] = data;\n        },\n        {\n          getVersionAndType: async (_collectionId, id) =>\n            versionAndTypeMap[id] ?? { version: 0, type: RawOpType.Del },\n          getSnapshotData: async (...args) => {\n            const ids = args[2];\n            return ids.map((id) => snapshotDataMap[id]).filter(Boolean);\n          },\n        }\n      );\n      if (resultError) {\n        callback(resultError);\n        return;\n      }\n    }\n    callback(null, result);\n  }\n\n  private getOpsFromSnapshot(docType: IdPrefix, snapshot: unknown): IOtOperation[] {\n    switch (docType) {\n      case IdPrefix.Record:\n        return Object.entries((snapshot as IRecord).fields).map(([fieldId, fieldValue]) => {\n          return RecordOpBuilder.editor.setRecord.build({\n            fieldId,\n            newCellValue: fieldValue,\n            oldCellValue: undefined,\n          });\n        });\n      case IdPrefix.Field:\n        return Object.entries(snapshot as IFieldVo)\n          .filter(([key]) => key !== 'id')\n          .map(([key, value]) => {\n            return FieldOpBuilder.editor.setFieldProperty.build({\n              key: key as IFieldPropertyKey,\n              newValue: value,\n              oldValue: undefined,\n            });\n          });\n      case IdPrefix.Table:\n        return Object.entries(snapshot as ITableVo)\n          .filter(([key]) => key !== 'id')\n          .map(([key, value]) => {\n            return TableOpBuilder.editor.setTableProperty.build({\n              key: key as ITablePropertyKey,\n              newValue: value,\n              oldValue: undefined,\n            });\n          });\n      default:\n        return [];\n    }\n  }\n}\n\nclass Snapshot implements ShareDb.Snapshot {\n  constructor(\n    public id: string,\n    public v: number,\n    public type: string | null,\n    public data: unknown,\n    public m: SnapshotMeta | null\n  ) {}\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/share-db.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FieldModule } from '../features/field/field.module';\nimport { TableModule } from '../features/table/table.module';\nimport { RealtimeMetricsModule } from './metrics/realtime-metrics.module';\nimport { ReadonlyModule } from './readonly/readonly.module';\nimport { RepairAttachmentOpModule } from './repair-attachment-op/repair-attachment-op.module';\nimport { ShareDbAdapter } from './share-db.adapter';\nimport { ShareDbService } from './share-db.service';\n\n@Module({\n  imports: [\n    TableModule,\n    FieldModule,\n    ReadonlyModule,\n    RepairAttachmentOpModule,\n    RealtimeMetricsModule,\n  ],\n  providers: [ShareDbService, ShareDbAdapter],\n  exports: [ShareDbService, RealtimeMetricsModule],\n})\nexport class ShareDbModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/share-db.service.ts",
    "content": "import { Injectable, Logger, Optional } from '@nestjs/common';\nimport { context as otelContext, trace as otelTrace } from '@opentelemetry/api';\nimport { FieldOpBuilder, IdPrefix, ViewOpBuilder } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { noop } from 'lodash';\nimport { ClsService } from 'nestjs-cls';\nimport type { CreateOp, DeleteOp, EditOp } from 'sharedb';\nimport ShareDBClass from 'sharedb';\nimport { CacheConfig, ICacheConfig } from '../configs/cache.config';\nimport { EventEmitterService } from '../event-emitter/event-emitter.service';\nimport { PerformanceCacheService } from '../performance-cache';\nimport type { IClsStore } from '../types/cls';\nimport { Timing } from '../utils/timing';\nimport { authMiddleware } from './auth.middleware';\nimport type { IRawOpMap } from './interface';\nimport { RealtimeMetricsService } from './metrics/realtime-metrics.service';\nimport { RepairAttachmentOpService } from './repair-attachment-op/repair-attachment-op.service';\nimport { ShareDbAdapter } from './share-db.adapter';\nimport { RedisPubSub } from './sharedb-redis.pubsub';\n\nconst v2ProjectionOpSourcePrefix = '@@v2-projection:';\nconst v2ProjectionSubmitSource = '@@v2-projection';\n\nconst hasClientStream = (\n  agent: unknown\n): agent is { stream: { write?: unknown; send?: unknown } } => {\n  if (!agent || typeof agent !== 'object') {\n    return false;\n  }\n  if (!('stream' in agent)) {\n    return false;\n  }\n\n  const stream = (agent as { stream?: unknown }).stream;\n  if (!stream || typeof stream !== 'object') {\n    return false;\n  }\n\n  return 'write' in stream || 'send' in stream;\n};\n\n@Injectable()\nexport class ShareDbService extends ShareDBClass {\n  private logger = new Logger(ShareDbService.name);\n\n  constructor(\n    readonly shareDbAdapter: ShareDbAdapter,\n    private readonly eventEmitterService: EventEmitterService,\n    private readonly prismaService: PrismaService,\n    private readonly cls: ClsService<IClsStore>,\n    private readonly repairAttachmentOpService: RepairAttachmentOpService,\n    @CacheConfig() private readonly cacheConfig: ICacheConfig,\n    private readonly performanceCacheService: PerformanceCacheService,\n    @Optional() private readonly realtimeMetrics?: RealtimeMetricsService\n  ) {\n    super({\n      presence: true,\n      doNotForwardSendPresenceErrorsToClient: true,\n      db: shareDbAdapter,\n      maxSubmitRetries: 3,\n    });\n\n    const { provider, redis } = this.cacheConfig;\n    if (provider === 'redis') {\n      if (!redis.uri) {\n        throw new Error('Redis URI is required for Redis cache provider.');\n      }\n      const redisPubsub = new RedisPubSub({ redisURI: redis.uri });\n\n      this.logger.log(`> Detected Redis cache; enabled the Redis pub/sub adapter for ShareDB.`);\n      this.pubsub = redisPubsub;\n    }\n\n    authMiddleware(this);\n    this.use('submit', this.onSubmit);\n\n    // broadcast raw op events to client\n    this.prismaService.bindAfterTransaction(async () => {\n      const rawOpMaps = this.cls.get('tx.rawOpMaps');\n      this.cls.set('tx.rawOpMaps', undefined);\n\n      const ops: IRawOpMap[] = [];\n      if (rawOpMaps?.length) {\n        ops.push(...rawOpMaps);\n      }\n\n      if (ops.length) {\n        await this.updateTableMetaByRawOpMap(rawOpMaps);\n        await this.publishOpsMap(rawOpMaps);\n        this.eventEmitterService.ops2Event(ops);\n      }\n\n      // clear cache keys\n      const clearCacheKeys = this.cls.get('clearCacheKeys');\n      if (clearCacheKeys?.length) {\n        await Promise.all(clearCacheKeys.map((key) => this.performanceCacheService.del(key)));\n        this.cls.set('clearCacheKeys', undefined);\n      }\n    });\n  }\n\n  getConnection() {\n    return this.connect();\n  }\n\n  @Timing()\n  private async updateTableMetaByRawOpMap(rawOpMap?: IRawOpMap[]) {\n    if (!rawOpMap?.length) {\n      return;\n    }\n    const collection = rawOpMap.flatMap((map) => Object.keys(map));\n    const tableIds = collection\n      .filter(\n        (c) =>\n          c.startsWith(IdPrefix.Record) ||\n          c.startsWith(IdPrefix.View) ||\n          c.startsWith(IdPrefix.Field)\n      )\n      .map((c) => c.split('_')[1]);\n\n    if (!tableIds.length) {\n      return;\n    }\n    await this.prismaService.txClient().tableMeta.updateMany({\n      where: { id: { in: tableIds } },\n      data: { lastModifiedTime: new Date().toISOString() },\n    });\n  }\n\n  @Timing()\n  async publishOpsMap(rawOpMaps: IRawOpMap[] | undefined) {\n    if (!rawOpMaps?.length) {\n      return;\n    }\n    let publishCount = 0;\n    const repairAttachmentContext =\n      await this.repairAttachmentOpService.getCollectionsAttachmentsContext(rawOpMaps);\n    for (const rawOpMap of rawOpMaps) {\n      for (const collection in rawOpMap) {\n        const data = rawOpMap[collection];\n        for (const docId in data) {\n          const rawOp = data[docId] as EditOp | CreateOp | DeleteOp;\n          const channels = [collection, `${collection}.${docId}`];\n          rawOp.c = collection;\n          rawOp.d = docId;\n          const repairedOp = await this.repairAttachmentOpService.repairAttachmentOp(\n            rawOp,\n            repairAttachmentContext\n          );\n          this.pubsub.publish(channels, repairedOp, noop);\n          publishCount++;\n\n          if (this.shouldPublishAction(repairedOp)) {\n            const tableId = collection.split('_')[1];\n            this.publishRelatedChannels(tableId, repairedOp);\n          }\n        }\n      }\n    }\n    if (publishCount > 0) {\n      this.realtimeMetrics?.recordOpsPublished(publishCount);\n    }\n  }\n\n  // for update record when import\n  publishRecordChannel(tableId: string, rawOp: EditOp | CreateOp | DeleteOp) {\n    this.pubsub.publish([`${IdPrefix.Record}_${tableId}`], rawOp, noop);\n  }\n\n  private shouldPublishAction(rawOp: EditOp | CreateOp | DeleteOp) {\n    const viewKeys = ['filter', 'sort', 'group', 'lastModifiedTime'];\n    const fieldKeys = ['options'];\n    return rawOp.op?.some(\n      (op) =>\n        viewKeys.includes(ViewOpBuilder.editor.setViewProperty.detect(op)?.key as string) ||\n        fieldKeys.includes(FieldOpBuilder.editor.setFieldProperty.detect(op)?.key as string)\n    );\n  }\n\n  /**\n   * this is for some special scenarios like manual sort\n   * which only send view ops but update record too\n   */\n  private publishRelatedChannels(tableId: string, rawOp: EditOp | CreateOp | DeleteOp) {\n    this.pubsub.publish([`${IdPrefix.Record}_${tableId}`], rawOp, noop);\n    this.pubsub.publish([`${IdPrefix.Field}_${tableId}`], rawOp, noop);\n  }\n\n  private onSubmit = (\n    context: ShareDBClass.middleware.SubmitContext,\n    next: (err?: unknown) => void\n  ) => {\n    const tracer = otelTrace.getTracer('default');\n    const currentSpan = tracer.startSpan('submitOp');\n\n    otelContext.with(otelTrace.setSpan(otelContext.active(), currentSpan), () => {\n      const submitSource =\n        ((context as ShareDBClass.middleware.SubmitContext & { options?: { source?: unknown } })\n          .options?.source as unknown) ??\n        ((context as ShareDBClass.middleware.SubmitContext & { extra?: { source?: unknown } }).extra\n          ?.source as unknown);\n      if (submitSource === v2ProjectionSubmitSource) {\n        return next();\n      }\n\n      const opSource = typeof context.op.src === 'string' ? context.op.src : '';\n      if (opSource.startsWith(v2ProjectionOpSourcePrefix)) {\n        return next();\n      }\n\n      if (!hasClientStream(context.agent)) {\n        return next();\n      }\n\n      const [docType] = context.collection.split('_');\n\n      if (docType !== IdPrefix.Record || !context.op.op) {\n        this.realtimeMetrics?.recordOperationError('invalid_doc_type');\n        return next(new Error('only record op can be committed'));\n      }\n      this.realtimeMetrics?.recordOperationSubmit();\n      next();\n    });\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/share-db.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { GlobalModule } from '../global/global.module';\nimport { ShareDbModule } from './share-db.module';\nimport { ShareDbService } from './share-db.service';\n\ndescribe('ShareDb', () => {\n  let provider: ShareDbService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [GlobalModule, ShareDbModule],\n    }).compile();\n\n    provider = module.get<ShareDbService>(ShareDbService);\n  });\n\n  it('should be defined', () => {\n    expect(provider).toBeDefined();\n  });\n\n  // it('create simple document', (done) => {\n  //   const randomTitle = `B:${Math.floor(Math.random() * 1000)}`;\n  //   const doc = provider.connect().get('books', randomTitle);\n  //   doc.create({ title: randomTitle }, function (error) {\n  //     if (error) throw error;\n  //     doc.submitOp({ p: ['author'], oi: 'George Orwell' }, undefined, (error: unknown) => {\n  //       if (error) throw error;\n  //       console.log('submit succeed!');\n  //       done();\n  //     });\n  //   });\n  // }, 1000);\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/sharedb-redis.pubsub.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport Redis from 'ioredis';\nimport type { Error as ShareDBError } from 'sharedb';\nimport { PubSub } from 'sharedb';\n\nconst PUBLISH_SCRIPT = 'for i = 2, #ARGV do ' + 'redis.call(\"publish\", ARGV[i], ARGV[1]) ' + 'end';\n\n// Redis pubsub driver for ShareDB.\n//\n// The redis driver requires two redis clients (a single redis client can't do\n// both pubsub and normal messaging). These clients will be created\n// automatically if you don't provide them.\nexport class RedisPubSub extends PubSub {\n  client: Redis;\n  observer: Redis;\n  _closing?: boolean;\n\n  constructor(options: { redisURI: string; prefix?: string }) {\n    super(options);\n\n    const isDev = process.env.NODE_ENV === 'development';\n\n    const devRedisOptions = {\n      retryStrategy(times: number) {\n        if (times > 20) {\n          console.error('Redis connection retry limit exceeded');\n          return null;\n        }\n        return Math.min(times * 100, 3000);\n      },\n      maxRetriesPerRequest: null,\n      reconnectOnError(err: unknown) {\n        const message = err instanceof Error ? err.message : String(err);\n        return (\n          message.includes('Connection is closed') ||\n          message.includes('READONLY') ||\n          message.includes('ECONNRESET') ||\n          message.includes('ETIMEDOUT') ||\n          message.includes('ENOTFOUND')\n        );\n      },\n      autoResendUnfulfilledCommands: true,\n      autoResubscribe: true,\n      connectTimeout: 10000,\n      commandTimeout: 5000,\n      enableReadyCheck: true,\n      enableOfflineQueue: true,\n      lazyConnect: false,\n    };\n\n    this.client = isDev\n      ? new Redis(options.redisURI, devRedisOptions)\n      : new Redis(options.redisURI);\n\n    // Redis doesn't allow the same connection to both listen to channels and do\n    // operations. Make an extra redis connection for subscribing with the same\n    // options if not provided\n    this.observer = isDev\n      ? new Redis(options.redisURI, devRedisOptions)\n      : new Redis(options.redisURI);\n\n    if (isDev) {\n      this.setupConnectionListeners(this.client, 'client');\n      this.setupConnectionListeners(this.observer, 'observer');\n    }\n\n    this.observer.on('message', this.handleMessage.bind(this));\n  }\n\n  private setupConnectionListeners(redis: Redis, name: string): void {\n    redis.on('connect', () => {\n      console.log(`[ShareDB Redis ${name}] Connecting...`);\n    });\n\n    redis.on('ready', () => {\n      console.log(`[ShareDB Redis ${name}] Ready`);\n    });\n\n    redis.on('error', (err) => {\n      console.error(`[ShareDB Redis ${name}] Error:`, err.message);\n    });\n\n    redis.on('close', () => {\n      console.warn(`[ShareDB Redis ${name}] Connection closed`);\n    });\n\n    redis.on('reconnecting', (delay: number) => {\n      console.log(`[ShareDB Redis ${name}] Reconnecting in ${delay}ms...`);\n    });\n\n    redis.on('end', () => {\n      console.warn(`[ShareDB Redis ${name}] Connection ended`);\n    });\n  }\n\n  close(\n    callback = function (err: ShareDBError | null) {\n      if (err) throw err;\n    }\n  ): void {\n    PubSub.prototype.close.call(this, (err) => {\n      if (err) return callback(err);\n      this._close().then(function () {\n        callback(null);\n      }, callback);\n    });\n  }\n\n  async _close() {\n    if (this._closing) {\n      return;\n    }\n    this._closing = true;\n    this.observer.removeAllListeners();\n    await Promise.all([this.client.quit(), this.observer.quit()]);\n  }\n\n  _subscribe(channel: string, callback: (err: ShareDBError | null) => void): void {\n    this.observer.subscribe(channel).then(function () {\n      callback(null);\n    }, callback);\n  }\n\n  handleMessage(channel: string, message: string) {\n    this._emit(channel, JSON.parse(message));\n  }\n\n  _unsubscribe(channel: string, callback: (err: ShareDBError | null) => void): void {\n    this.observer.unsubscribe(channel).then(function () {\n      callback(null);\n    }, callback);\n  }\n\n  async _publish(channels: string[], data: unknown, callback: (err: ShareDBError | null) => void) {\n    const message = JSON.stringify(data);\n    const args = [message].concat(channels);\n    this.client.eval(PUBLISH_SCRIPT, 0, ...args).then(function () {\n      callback(null);\n    }, callback);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/share-db/utils.ts",
    "content": "import { ActionPrefix, IdPrefix } from '@teable/core';\nimport type { CreateOp, DeleteOp, EditOp } from 'sharedb';\n\nexport const getPrefixAction = (docType: IdPrefix) => {\n  switch (docType) {\n    case IdPrefix.View:\n      return ActionPrefix.View;\n    case IdPrefix.Table:\n      return ActionPrefix.Table;\n    case IdPrefix.Record:\n      return ActionPrefix.Record;\n    case IdPrefix.Field:\n      return ActionPrefix.Field;\n    default:\n      return null;\n  }\n};\n\nexport const getAction = (op: CreateOp | DeleteOp | EditOp) => {\n  if (op.create) {\n    return 'create';\n  }\n  if (op.del) {\n    return 'delete';\n  }\n  if (op.op) {\n    return 'update';\n  }\n  return null;\n};\n\nexport const getAxiosBaseUrl = () => `http://localhost:${process.env.PORT}/api`;\n"
  },
  {
    "path": "apps/nestjs-backend/src/swagger.ts",
    "content": "import 'dayjs/plugin/timezone';\nimport 'dayjs/plugin/utc';\nimport fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type { OpenAPIObject } from '@nestjs/swagger';\nimport { SwaggerModule } from '@nestjs/swagger';\nimport { getOpenApiDocumentation } from '@teable/openapi';\nimport type { RedocOptions } from 'nestjs-redoc';\nimport { RedocModule } from 'nestjs-redoc';\n\nexport async function setupSwagger(\n  app: INestApplication,\n  publicOrigin: string,\n  enabledSnippet: boolean\n) {\n  const openApiDocumentation = await getOpenApiDocumentation({\n    origin: publicOrigin,\n    snippet: enabledSnippet,\n  });\n\n  const jsonString = JSON.stringify(openApiDocumentation);\n  fs.writeFileSync(path.join(__dirname, '/openapi.json'), jsonString);\n  SwaggerModule.setup('/docs', app, openApiDocumentation as OpenAPIObject);\n\n  // Instead of using SwaggerModule.setup() you call this module\n  const redocOptions: RedocOptions = {\n    logo: {\n      backgroundColor: '#F0F0F0',\n      altText: 'Teable logo',\n    },\n  };\n  await RedocModule.setup('/redocs', app, openApiDocumentation as OpenAPIObject, redocOptions);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/tracing/base-tracing.service.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport type { Span } from '@opentelemetry/api';\nimport { trace } from '@opentelemetry/api';\n\nexport abstract class BaseTracingService {\n  protected readonly logger = new Logger(this.constructor.name);\n\n  protected withActiveSpan(fn: (span: Span) => void): void {\n    try {\n      const span = trace.getActiveSpan();\n      if (!span) return;\n      fn(span);\n    } catch (e) {\n      this.logger.warn(`Tracing failed: ${e}`);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/tracing/decorators/span.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Span as ApiSpan, SpanOptions } from '@opentelemetry/api';\nimport { SpanStatusCode, trace } from '@opentelemetry/api';\nimport { copyDecoratorMetadata } from '../../utils/metadata';\n\nconst recordException = (span: ApiSpan, error: any) => {\n  span.recordException(error);\n  span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });\n};\n\nexport function Span(name?: string, options: SpanOptions = {}): MethodDecorator {\n  return (target: any, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>) => {\n    const originalFunction = descriptor.value;\n    const wrappedFunction = function (this: any, ...args: any[]) {\n      const spanName = name || `${target.constructor.name}.${String(propertyKey)}`;\n      const tracer = trace.getTracer('default');\n\n      return tracer.startActiveSpan(spanName, options, (span) => {\n        if (originalFunction.constructor.name === 'AsyncFunction') {\n          return originalFunction\n            .apply(this, args)\n            .catch((error: any) => {\n              recordException(span, error);\n              // Throw error to propagate it further\n              throw error;\n            })\n            .finally(() => {\n              span.end();\n            });\n        }\n\n        try {\n          return originalFunction.apply(this, args);\n        } catch (error) {\n          recordException(span, error);\n          // Throw error to propagate it further\n          throw error;\n        } finally {\n          span.end();\n        }\n      });\n    };\n\n    descriptor.value = wrappedFunction;\n\n    copyDecoratorMetadata(originalFunction, wrappedFunction);\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/tracing/route-tracing.interceptor.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';\nimport { Inject, Injectable, Optional } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { trace, TraceFlags } from '@opentelemetry/api';\nimport type { Observable } from 'rxjs';\nimport { tap } from 'rxjs/operators';\n\nconst buildTraceLink = (traceId: string, baseUrl?: string) => {\n  const normalizedBaseUrl = baseUrl?.replace(/\\/+$/, '');\n  if (!normalizedBaseUrl) return null;\n  return `${normalizedBaseUrl}/trace/${traceId}?uiEmbed=v0`;\n};\n\nconst buildTraceparent = (traceId: string, spanId: string, traceFlags: TraceFlags) => {\n  const sampled = (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED;\n  return `00-${traceId}-${spanId}-${sampled ? '01' : '00'}`;\n};\n\n@Injectable()\nexport class RouteTracingInterceptor implements NestInterceptor {\n  private readonly traceLinkBaseUrl?: string;\n\n  constructor(@Optional() @Inject(ConfigService) configService?: ConfigService) {\n    this.traceLinkBaseUrl =\n      configService?.get<string>('TRACE_LINK_BASE_URL') ?? process.env.TRACE_LINK_BASE_URL;\n  }\n\n  intercept(context: ExecutionContext, next: CallHandler): Observable<void> {\n    const request = context.switchToHttp().getRequest();\n    const response = context.switchToHttp().getResponse();\n\n    const span = trace.getActiveSpan();\n\n    if (span) {\n      const controllerClass = context.getClass();\n      const handlerName = context.getHandler();\n      const httpMethod = request.method;\n      const url = request.url;\n      const route = request.route?.path || this.extractRouteFromUrl(url);\n\n      span.setAttributes({\n        'http.method': httpMethod,\n        'http.route': route,\n        'http.target': url,\n        'http.url': `${request.protocol}://${request.get('host')}${url}`,\n        'nest.controller': controllerClass.name,\n        'nest.handler': handlerName.name,\n        'teable.route.full': `${httpMethod} ${route}`,\n        'teable.route.controller': controllerClass.name,\n        'teable.route.handler': handlerName.name,\n      });\n\n      const spanName = `${httpMethod} ${route}`;\n      span.updateName(spanName);\n\n      // Set trace response headers\n      const spanContext = span.spanContext();\n      response.setHeader(\n        'traceparent',\n        buildTraceparent(spanContext.traceId, spanContext.spanId, spanContext.traceFlags)\n      );\n      const traceLink = buildTraceLink(spanContext.traceId, this.traceLinkBaseUrl);\n      if (traceLink) {\n        response.setHeader('Link', `<${traceLink}>; rel=\"trace\"`);\n      }\n    }\n\n    return next.handle().pipe(\n      tap(() => {\n        if (span) {\n          span.setAttributes({\n            'http.status_code': response.statusCode,\n            responseStatusCode: response.statusCode.toString(),\n          });\n        }\n      })\n    );\n  }\n\n  private extractRouteFromUrl(url: string): string {\n    return url\n      .split('?')[0]\n      .replace(/\\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g, '/:id')\n      .replace(/\\/[a-z0-9]{20,}/gi, '/:id')\n      .replace(/\\/\\d+/g, '/:id')\n      .replace(/\\/rec[a-zA-Z0-9]+/g, '/:recordId')\n      .replace(/\\/tbl[a-zA-Z0-9]+/g, '/:tableId')\n      .replace(/\\/fld[a-zA-Z0-9]+/g, '/:fieldId')\n      .replace(/\\/vw[a-zA-Z0-9]+/g, '/:viewId')\n      .replace(/\\/bs[a-zA-Z0-9]+/g, '/:baseId')\n      .replace(/\\/spc[a-zA-Z0-9]+/g, '/:spaceId');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/tracing.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/**\n * OpenTelemetry Tracing Configuration\n *\n * This module initializes OpenTelemetry SDK for distributed tracing, logging, and metrics.\n *\n * Environment Variables:\n * ─────────────────────────────────────────────────────────────────────────────────────────────────────────\n * | Variable                           | Description                    | Dev Default      | Prod Default |\n * |------------------------------------|--------------------------------|------------------|--------------|\n * | OTEL_EXPORTER_OTLP_ENDPOINT        | Trace exporter endpoint        | localhost:4318   | (disabled)   |\n * | OTEL_EXPORTER_OTLP_LOGS_ENDPOINT   | Log exporter endpoint          | localhost:4318   | (disabled)   |\n * | OTEL_EXPORTER_OTLP_METRICS_ENDPOINT| Metrics exporter endpoint      | (disabled)       | (disabled)   |\n * | OTEL_EXPORTER_OTLP_HEADERS         | Custom headers (key=val,...)   | (none)           | (none)       |\n * | OTEL_SERVICE_NAME                  | Service name for tracing       | teable           | teable       |\n * | OTEL_EXPORT_RATIO                  | Export ratio (0.0-1.0)         | 1.0 (100%)       | 0.1 (10%)    |\n * | OTEL_EXPORT_LATENCY_THRESHOLD_MS   | Slow request threshold (ms)    | 1500             | 1500         |\n * | OTEL_METRIC_EXPORT_INTERVAL_MS     | Metrics export interval (ms)   | 10000            | 60000        |\n * | BACKEND_SENTRY_DSN                 | Sentry DSN for error tracking  | (disabled)       | (disabled)   |\n * | BUILD_VERSION                      | Build version for resource     | (none)           | (none)       |\n * ─────────────────────────────────────────────────────────────────────────────────────────────────────────\n *\n * Notes:\n * - In development, traces and logs are enabled by default (localhost endpoint)\n * - In production, you must explicitly set OTEL_EXPORTER_OTLP_ENDPOINT to enable tracing\n * - Sampling rate is always 100%; OTEL_EXPORT_RATIO controls how many spans are sent to backend\n * - Smart export always sends: errors, HTTP 5xx responses, and slow requests (regardless of ratio)\n */\nimport { Logger } from '@nestjs/common';\nimport { metrics, SpanKind, SpanStatusCode } from '@opentelemetry/api';\nimport { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';\nimport { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport { ExpressInstrumentation, ExpressLayerType } from '@opentelemetry/instrumentation-express';\nimport { HttpInstrumentation } from '@opentelemetry/instrumentation-http';\nimport { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';\nimport { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';\nimport { PgInstrumentation } from '@opentelemetry/instrumentation-pg';\nimport { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';\nimport { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node';\nimport { resourceFromAttributes } from '@opentelemetry/resources';\nimport * as opentelemetry from '@opentelemetry/sdk-node';\nimport { BatchSpanProcessor, NoopSpanProcessor } from '@opentelemetry/sdk-trace-base';\nimport type { SpanProcessor } from '@opentelemetry/sdk-trace-base';\nimport {\n  ATTR_HTTP_RESPONSE_STATUS_CODE,\n  ATTR_SERVICE_NAME,\n  ATTR_SERVICE_VERSION,\n} from '@opentelemetry/semantic-conventions';\nimport { PrismaInstrumentation } from '@prisma/instrumentation';\nimport {\n  SentryPropagator,\n  SentrySpanProcessor,\n  wrapContextManagerClass,\n} from '@sentry/opentelemetry';\n\n// Use webpack's special require that bypasses bundling, falling back to standard require\n// This is needed because webpack transforms import.meta.url and createRequire in ways\n// that can break module resolution for native Node.js modules like pg.\ndeclare const __non_webpack_require__: NodeRequire | undefined;\nconst nativeRequire: NodeRequire =\n  typeof __non_webpack_require__ !== 'undefined' ? __non_webpack_require__ : require;\n\nconst { BatchLogRecordProcessor } = opentelemetry.logs;\nconst { PeriodicExportingMetricReader, AggregationType } = opentelemetry.metrics;\nconst { AlwaysOnSampler } = opentelemetry.node;\n\nconst otelLogger = new Logger('OpenTelemetry');\nconst isDevelopment = process.env.NODE_ENV !== 'production';\n\n/**\n * Environment-specific default values\n * - undefined means the feature is disabled unless explicitly configured\n */\nconst ENV_DEFAULTS = {\n  development: {\n    OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318/v1/traces',\n    OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: 'http://localhost:4318/v1/logs',\n    OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: undefined,\n    OTEL_SERVICE_NAME: 'teable',\n    OTEL_EXPORT_RATIO: '1.0',\n    OTEL_EXPORT_LATENCY_THRESHOLD_MS: '1500',\n    OTEL_METRIC_EXPORT_INTERVAL_MS: '10000',\n  },\n  production: {\n    OTEL_EXPORTER_OTLP_ENDPOINT: undefined,\n    OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: undefined,\n    OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: undefined,\n    OTEL_SERVICE_NAME: 'teable',\n    OTEL_EXPORT_RATIO: '0.1',\n    OTEL_EXPORT_LATENCY_THRESHOLD_MS: '1500',\n    OTEL_METRIC_EXPORT_INTERVAL_MS: '60000',\n  },\n} as const;\n\nconst hasSentry = !!process.env.BACKEND_SENTRY_DSN;\n\ntype EnvConfigKey = keyof typeof ENV_DEFAULTS.development;\n\n/**\n * Get configuration value\n * Priority: environment variable > current environment default\n */\nconst getConfig = (key: EnvConfigKey): string | undefined => {\n  const envValue = process.env[key];\n  if (envValue !== undefined) return envValue;\n\n  const defaults = isDevelopment ? ENV_DEFAULTS.development : ENV_DEFAULTS.production;\n  return defaults[key];\n};\n\nconst parseHeaders = (headerStr?: string): Record<string, string> => {\n  if (!headerStr) return {};\n  return headerStr.split(',').reduce(\n    (acc, curr) => {\n      const [key, ...valueParts] = curr.split('=');\n      const value = valueParts.join('=');\n      if (key && value) acc[key.trim()] = value.trim();\n      return acc;\n    },\n    {} as Record<string, string>\n  );\n};\n\nconst parseNumber = (value: string | undefined, defaultValue: number): number => {\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : defaultValue;\n};\n\n// Configuration\nconst headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS);\nconst traceEndpoint = getConfig('OTEL_EXPORTER_OTLP_ENDPOINT');\nconst logEndpoint = getConfig('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT');\nconst metricsEndpoint = getConfig('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT');\nconst serviceName = getConfig('OTEL_SERVICE_NAME') || 'teable';\nconst exportRatio = Math.max(0, Math.min(1, parseNumber(getConfig('OTEL_EXPORT_RATIO'), 0.1)));\nconst latencyThresholdMs = Math.max(\n  0,\n  parseNumber(getConfig('OTEL_EXPORT_LATENCY_THRESHOLD_MS'), 1500)\n);\nconst metricExportIntervalMs = Math.max(\n  1000,\n  parseNumber(getConfig('OTEL_METRIC_EXPORT_INTERVAL_MS'), 60000)\n);\n\n// Exporters\nconst createExporterOptions = (url?: string) => ({\n  url,\n  headers: { 'Content-Type': 'application/x-protobuf', ...headers },\n});\n\nconst traceExporter = traceEndpoint\n  ? new OTLPTraceExporter(createExporterOptions(traceEndpoint))\n  : undefined;\nconst logExporter = logEndpoint\n  ? new OTLPLogExporter(createExporterOptions(logEndpoint))\n  : undefined;\nconst metricsExporter = metricsEndpoint\n  ? new OTLPMetricExporter(createExporterOptions(metricsEndpoint))\n  : undefined;\n\n// Strip high-cardinality resource attributes from metrics only.\n// Traces and logs keep these for debugging; metrics drop them to prevent\n// cardinality explosion in ephemeral containers (each restart = new host.name + pid).\nif (metricsExporter) {\n  const dropFromMetricResource = new Set([\n    'host.name',\n    'host.arch',\n    'os.type',\n    'os.description',\n    'process.pid',\n    'process.command',\n    'process.command_args',\n    'process.command_line',\n    'process.executable.path',\n    'process.owner',\n    'service.instance.id',\n  ]);\n  const origExport = metricsExporter.export.bind(metricsExporter);\n  metricsExporter.export = (metrics, cb) => {\n    const attrs = Object.fromEntries(\n      Object.entries(metrics.resource.attributes).filter(([k]) => !dropFromMetricResource.has(k))\n    );\n    origExport({ ...metrics, resource: resourceFromAttributes(attrs) }, cb);\n  };\n}\n\n// Smart export: deterministic decision based on traceId hash\n// No cache needed - hash function is pure and fast\nconst getTraceDecision = (traceId: string): boolean => {\n  // FNV-1a hash for better distribution\n  let hash = 2166136261;\n  for (let i = 0; i < traceId.length; i++) {\n    hash ^= traceId.charCodeAt(i);\n    hash = (hash * 16777619) >>> 0;\n  }\n  return hash % 10000 < exportRatio * 10000;\n};\n\nconst shouldExportSpan = (span: opentelemetry.tracing.ReadableSpan): boolean => {\n  if (exportRatio >= 1.0) return true;\n\n  // Always export errors\n  if (span.status.code === SpanStatusCode.ERROR) return true;\n\n  // Always export HTTP errors (5xx)\n  const httpStatusCode = span.attributes[ATTR_HTTP_RESPONSE_STATUS_CODE];\n  if (typeof httpStatusCode === 'number' && httpStatusCode >= 500) return true;\n\n  // Always export slow requests\n  const durationMs = span.duration[0] * 1000 + span.duration[1] / 1_000_000;\n  if (durationMs > latencyThresholdMs) return true;\n\n  // Consistent export decision based on traceId - all spans in same trace have same fate\n  return getTraceDecision(span.spanContext().traceId);\n};\n\nconst createSmartBatchProcessor = (exporter: OTLPTraceExporter): SpanProcessor => {\n  const batchProcessor = new BatchSpanProcessor(exporter, {\n    maxQueueSize: 2048,\n    maxExportBatchSize: 512,\n    scheduledDelayMillis: 5000,\n    exportTimeoutMillis: 30000,\n  });\n  if (exportRatio >= 1.0) return batchProcessor;\n\n  return {\n    onStart: batchProcessor.onStart.bind(batchProcessor),\n    onEnd: (span: opentelemetry.tracing.ReadableSpan) => {\n      if (shouldExportSpan(span)) batchProcessor.onEnd(span);\n    },\n    shutdown: batchProcessor.shutdown.bind(batchProcessor),\n    forceFlush: batchProcessor.forceFlush.bind(batchProcessor),\n  };\n};\n\n// Track in-flight outbound HTTP requests by target host via SpanProcessor,\n// since instrumentation-http only records duration after completion.\nconst httpClientActiveRequests = metrics\n  .getMeter('teable-observability')\n  .createUpDownCounter('http.client.active_requests', {\n    description: 'Number of currently in-flight outbound HTTP requests',\n  });\n\nconst httpClientActiveRequestsProcessor: SpanProcessor = {\n  onStart(span): void {\n    if (span.kind !== SpanKind.CLIENT) return;\n    const host = String(\n      span.attributes['server.address'] || span.attributes['net.peer.name'] || ''\n    );\n    if (host) {\n      httpClientActiveRequests.add(1, { 'server.address': host });\n    }\n  },\n  onEnd(span): void {\n    if (span.kind !== SpanKind.CLIENT) return;\n    const host = String(\n      span.attributes['server.address'] || span.attributes['net.peer.name'] || ''\n    );\n    if (host) {\n      httpClientActiveRequests.add(-1, { 'server.address': host });\n    }\n  },\n  shutdown: () => Promise.resolve(),\n  forceFlush: () => Promise.resolve(),\n};\n\n// Span processors - NoopSpanProcessor ensures trace context is always generated\n// even when no exporter is configured (needed for trace ID in logs)\nconst spanProcessors = [\n  ...(hasSentry ? [new SentrySpanProcessor()] : []),\n  ...(traceExporter ? [createSmartBatchProcessor(traceExporter)] : [new NoopSpanProcessor()]),\n  httpClientActiveRequestsProcessor,\n];\n\n// When Sentry is enabled, use SentryPropagator and SentryContextManager to ensure\n// Sentry spans are properly correlated with OTEL traces and async context is preserved.\nconst SentryContextManager = hasSentry\n  ? wrapContextManagerClass(AsyncLocalStorageContextManager)\n  : undefined;\n\nconst ignorePaths = [\n  '/favicon.ico',\n  '/_next/',\n  '/__nextjs',\n  '/images/',\n  '/.well-known/',\n  '/health',\n];\n\n// Drop old semconv HTTP metrics — new semconv (http.*.request.duration) is used in all dashboards;\n// the old names (http.server.duration, http.client.duration) are pure duplicates with high cardinality.\nconst dropAggregation = { type: AggregationType.DROP } as const;\nconst metricViews = [\n  { instrumentName: 'http.server.duration', aggregation: dropAggregation },\n  { instrumentName: 'http.client.duration', aggregation: dropAggregation },\n];\n\nconst otelSDK = new opentelemetry.NodeSDK({\n  spanProcessors,\n  logRecordProcessors: logExporter ? [new BatchLogRecordProcessor(logExporter)] : [],\n  sampler: new AlwaysOnSampler(),\n  contextManager: SentryContextManager ? new SentryContextManager() : undefined,\n  textMapPropagator: hasSentry ? new SentryPropagator() : undefined,\n  views: metricViews,\n  metricReader: metricsExporter\n    ? new PeriodicExportingMetricReader({\n        exporter: metricsExporter,\n        exportIntervalMillis: metricExportIntervalMs,\n      })\n    : undefined,\n  instrumentations: [\n    new HttpInstrumentation({\n      ignoreIncomingRequestHook: (req) => ignorePaths.some((path) => req.url?.startsWith(path)),\n    }),\n    new ExpressInstrumentation({\n      ignoreLayersType: [ExpressLayerType.MIDDLEWARE, ExpressLayerType.REQUEST_HANDLER],\n    }),\n    new NestInstrumentation(),\n    new PrismaInstrumentation(),\n    new PgInstrumentation({\n      enhancedDatabaseReporting: true, // Records SQL; ensure sensitive data is scrubbed.\n      requireParentSpan: false, // Create spans even without parent, ensures v2 Kysely queries are traced\n    }),\n    new PinoInstrumentation(),\n    new RuntimeNodeInstrumentation(),\n    new IORedisInstrumentation({\n      requireParentSpan: true,\n    }),\n  ],\n  resource: resourceFromAttributes({\n    [ATTR_SERVICE_NAME]: serviceName,\n    [ATTR_SERVICE_VERSION]: process.env.BUILD_VERSION,\n  }),\n});\n\n// Log configuration on startup\notelLogger.log(\n  `Initialized: service=${serviceName}, env=${isDevelopment ? 'dev' : 'prod'}, ` +\n    `exportRatio=${exportRatio * 100}%, latencyThreshold=${latencyThresholdMs}ms, ` +\n    `exporters=[traces:${!!traceEndpoint}, logs:${!!logEndpoint}, metrics:${!!metricsEndpoint}], ` +\n    `metricsInterval=${metricExportIntervalMs}ms, ` +\n    `sentry=${hasSentry}`\n);\n\nexport default otelSDK;\n\n// This ensures instrumentation is applied BEFORE any instrumented modules (like pg) are loaded.\ntry {\n  otelSDK.start();\n  // Force load pg after SDK start to ensure it is instrumented.\n  // OpenTelemetry instruments modules by patching their exports when they're first required.\n  // If pg is loaded before SDK.start(), the instrumentation won't work.\n  //\n  // Use nativeRequire to bypass webpack bundling and ensure we're loading\n  // the actual pg module from node_modules, not a bundled version.\n  try {\n    nativeRequire('pg');\n  } catch {\n    // pg might not be available, that's ok\n  }\n\n  // Also force load via ESM import to ensure ESM module cache is populated\n  // This is important because v2 adapter uses `await import('pg')`\n  void import('pg').catch(() => {\n    // pg might not be available via ESM, that's ok\n  });\n} catch (err) {\n  console.error('OTEL SDK start error:', err);\n}\n\nlet isShuttingDown = false;\nconst shutdownHandler = () => {\n  if (isShuttingDown) return Promise.resolve();\n  isShuttingDown = true;\n  return otelSDK.shutdown().then(\n    () => otelLogger.log('Shutdown successfully'),\n    (err) => otelLogger.error('Shutdown error', err)\n  );\n};\n\nprocess.on('SIGTERM', shutdownHandler);\nprocess.on('SIGINT', shutdownHandler);\n"
  },
  {
    "path": "apps/nestjs-backend/src/types/cls.ts",
    "content": "import type { Action, IFieldVo } from '@teable/core';\nimport type { Prisma } from '@teable/db-main-prisma';\nimport type { V2Feature } from '@teable/openapi';\nimport type { ClsStore } from 'nestjs-cls';\nimport type { IWorkflowContext } from '../features/auth/strategies/types';\nimport type { IPerformanceCacheStore } from '../performance-cache';\nimport type { IRawOpMap } from '../share-db/interface';\nimport type { IDataLoaderCache } from './data-loader';\n\nexport type V2Reason =\n  | 'env_force_v2_all'\n  | 'config_force_v2_all'\n  | 'header_override'\n  | 'space_feature'\n  | 'disabled'\n  | 'feature_not_enabled'\n  | 'no_feature';\n\nexport interface IClsStore extends ClsStore {\n  user: {\n    id: string;\n    name: string;\n    email: string;\n    isAdmin?: boolean | null;\n  };\n  accessTokenId?: string;\n  // for template authentication\n  template?: {\n    id: string;\n    baseId: string;\n  };\n  // for base share context (truthy = share mode, baseId for permission check, nodeId for node filtering)\n  baseShare?: {\n    baseId: string;\n    nodeId: string;\n  };\n  entry?: {\n    type: string;\n    id: string;\n  };\n  origin: {\n    ip: string;\n    byApi: boolean;\n    userAgent: string;\n    referer: string;\n  };\n  tx: {\n    client?: Prisma.TransactionClient;\n    timeStr?: string;\n    id?: string;\n    rawOpMaps?: IRawOpMap[];\n  };\n  shareViewId?: string;\n  permissions: Action[];\n  // this is used to check if the user is in the space when the user operate in a space\n  spaceId?: string;\n  // for share db adapter\n  cookie?: string;\n  oldField?: IFieldVo;\n  organization?: {\n    id: string;\n    name: string;\n    isAdmin: boolean;\n    departments?: {\n      id: string;\n      name: string;\n    }[];\n  };\n  tempAuthBaseId?: string; // for automation robot\n  skipRecordAuditLog?: boolean; // skip individual record audit logs for automation\n  appId?: string; // for app internal call\n  workflowContext?: IWorkflowContext;\n  dataLoaderCache?: IDataLoaderCache;\n  clearCacheKeys?: (keyof IPerformanceCacheStore)[];\n  canaryHeader?: string; // x-canary header value for canary release override\n  useV2?: boolean; // Flag to indicate if V2 implementation should be used (set by V2FeatureGuard)\n  v2Reason?: V2Reason; // Reason why V2 was enabled or disabled\n  v2Feature?: V2Feature; // The feature name that triggered V2 check\n  windowId?: string; // Window ID from x-window-id header for undo/redo tracking\n  skipFieldComputation?: boolean; // Skip computed field evaluation during bulk structure creation (import/duplicate)\n  // cache for base share node tree (to avoid repeated queries within same request)\n  baseShareNodeCache?: Map<\n    string,\n    { id: string; parentId: string | null; resourceType: string; resourceId: string | null }[]\n  >;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/types/data-loader.ts",
    "content": "import type { Prisma } from '@prisma/client';\n\nexport type IFieldLoaderItem = Prisma.$FieldPayload['scalars'];\n\nexport interface IFieldLoaderData {\n  dataMap?: Map<string, IFieldLoaderItem>;\n  fullParentIds?: string[];\n}\n\nexport type ITableLoaderItem = Prisma.$TableMetaPayload['scalars'];\n\nexport interface ITableLoaderData {\n  dataMap?: Map<string, ITableLoaderItem>;\n  fullParentIds?: string[];\n}\n\nexport type IViewLoaderItem = Prisma.$ViewPayload['scalars'];\n\nexport interface IViewLoaderData {\n  dataMap?: Map<string, IViewLoaderItem>;\n  fullParentIds?: string[];\n}\n\nexport interface IDataLoaderCache {\n  tableData?: ITableLoaderData;\n  fieldData?: IFieldLoaderData;\n  viewData?: IViewLoaderData;\n  cacheKeys?: ('table' | 'field' | 'view')[];\n  disabled?: boolean;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/types/i18n.generated.ts",
    "content": "/* DO NOT EDIT, file generated by nestjs-i18n */\n\n/* eslint-disable */\n/* prettier-ignore */\nimport { Path } from \"nestjs-i18n\";\n/* prettier-ignore */\nexport type I18nTranslations = {\n    \"auth\": {\n        \"page\": {\n            \"signin\": string;\n            \"signup\": string;\n            \"title\": string;\n        };\n        \"title\": {\n            \"signin\": string;\n            \"signup\": string;\n        };\n        \"content\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"button\": {\n            \"signin\": string;\n            \"signup\": string;\n            \"resend\": string;\n        };\n        \"label\": {\n            \"email\": string;\n            \"password\": string;\n            \"verificationCode\": string;\n        };\n        \"placeholder\": {\n            \"password\": string;\n            \"email\": string;\n            \"verificationCode\": string;\n        };\n        \"signError\": {\n            \"exist\": string;\n            \"incorrect\": string;\n            \"tooManyRequests\": string;\n            \"turnstileRequired\": string;\n            \"turnstileError\": string;\n            \"turnstileExpired\": string;\n            \"turnstileTimeout\": string;\n        };\n        \"signupError\": {\n            \"verificationCodeRequired\": string;\n            \"verificationCodeInvalid\": string;\n            \"passwordLength\": string;\n            \"passwordInvalid\": string;\n            \"sendMailRateLimit\": string;\n        };\n        \"socialAuth\": {\n            \"title\": string;\n            \"sso\": {\n                \"title\": string;\n                \"description\": string;\n                \"error\": string;\n            };\n        };\n        \"resetPassword\": {\n            \"header\": string;\n            \"description\": string;\n            \"label\": string;\n            \"error\": {\n                \"requiredPassword\": string;\n                \"invalidLink\": string;\n            };\n            \"success\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"buttonText\": string;\n        };\n        \"forgetPassword\": {\n            \"trigger\": string;\n            \"header\": string;\n            \"description\": string;\n            \"errorRequiredEmail\": string;\n            \"errorInvalidEmail\": string;\n            \"buttonText\": string;\n            \"success\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"sendMailRateLimit\": string;\n        };\n        \"legal\": {\n            \"tip\": string;\n            \"termsUrl\": string;\n            \"privacyUrl\": string;\n        };\n    };\n    \"chart\": {\n        \"notBaseId\": string;\n        \"notPositionId\": string;\n        \"notPluginInstallId\": string;\n        \"initBridge\": string;\n        \"actions\": {\n            \"cancel\": string;\n            \"save\": string;\n        };\n        \"queryTitle\": string;\n        \"notSupport\": string;\n        \"chart\": {\n            \"bar\": string;\n            \"line\": string;\n            \"pie\": string;\n            \"area\": string;\n            \"table\": string;\n        };\n        \"form\": {\n            \"chartType\": {\n                \"placeholder\": string;\n                \"label\": string;\n            };\n            \"pie\": {\n                \"dimension\": string;\n                \"measure\": string;\n                \"showTotal\": string;\n            };\n            \"combo\": {\n                \"xAxis\": {\n                    \"label\": string;\n                    \"placeholder\": string;\n                };\n                \"yAxis\": {\n                    \"label\": string;\n                    \"placeholder\": string;\n                    \"position\": string;\n                };\n                \"xDisplay\": {\n                    \"label\": string;\n                };\n                \"yDisplay\": {\n                    \"label\": string;\n                };\n                \"addXAxis\": string;\n                \"addYAxis\": string;\n                \"stack\": string;\n                \"position\": {\n                    \"label\": string;\n                    \"auto\": string;\n                    \"left\": string;\n                    \"right\": string;\n                };\n                \"goalLine\": {\n                    \"label\": string;\n                };\n                \"range\": {\n                    \"label\": string;\n                    \"min\": string;\n                    \"max\": string;\n                };\n                \"lineStyle\": {\n                    \"label\": string;\n                    \"normal\": string;\n                    \"linear\": string;\n                    \"step\": string;\n                };\n                \"displayType\": string;\n            };\n            \"typeError\": string;\n            \"updateQuery\": string;\n            \"queryError\": string;\n            \"querySuccess\": string;\n            \"decimal\": string;\n            \"prefix\": string;\n            \"suffix\": string;\n            \"showLabel\": string;\n            \"showLegend\": string;\n            \"value\": string;\n            \"label\": string;\n            \"padding\": {\n                \"label\": string;\n                \"top\": string;\n                \"right\": string;\n                \"bottom\": string;\n                \"left\": string;\n            };\n            \"tableConfig\": string;\n            \"width\": string;\n        };\n        \"reloadQuery\": string;\n        \"noStorage\": string;\n        \"noPermission\": string;\n        \"goConfig\": string;\n    };\n    \"common\": {\n        \"actions\": {\n            \"title\": string;\n            \"add\": string;\n            \"save\": string;\n            \"doNotSave\": string;\n            \"submit\": string;\n            \"confirm\": string;\n            \"continue\": string;\n            \"close\": string;\n            \"edit\": string;\n            \"fill\": string;\n            \"update\": string;\n            \"create\": string;\n            \"delete\": string;\n            \"cancel\": string;\n            \"zoomIn\": string;\n            \"zoomOut\": string;\n            \"back\": string;\n            \"remove\": string;\n            \"removeConfig\": string;\n            \"saveSucceed\": string;\n            \"submitSucceed\": string;\n            \"editSucceed\": string;\n            \"updateSucceed\": string;\n            \"deleteSucceed\": string;\n            \"resetSucceed\": string;\n            \"restoreSucceed\": string;\n            \"loading\": string;\n            \"refreshPage\": string;\n            \"yesDelete\": string;\n            \"rename\": string;\n            \"duplicate\": string;\n            \"export\": string;\n            \"import\": string;\n            \"change\": string;\n            \"upgrade\": string;\n            \"upgradeToLevel\": string;\n            \"search\": string;\n            \"loadMore\": string;\n            \"collapseSidebar\": string;\n            \"restore\": string;\n            \"permanentDelete\": string;\n            \"globalSearch\": string;\n            \"fieldSearch\": string;\n            \"tableIndex\": string;\n            \"showAllRow\": string;\n            \"hideNotMatchRow\": string;\n            \"more\": string;\n            \"expand\": string;\n            \"view\": string;\n            \"preview\": string;\n            \"viewAndEdit\": string;\n            \"deleteTip\": string;\n            \"move\": string;\n            \"turnOn\": string;\n            \"exit\": string;\n            \"next\": string;\n            \"previous\": string;\n            \"select\": string;\n            \"refresh\": string;\n            \"login\": string;\n            \"useTemplate\": string;\n            \"copyToMySpace\": string;\n            \"saveToMySpace\": string;\n            \"supportSaveCopy\": string;\n            \"backToSpace\": string;\n            \"switchBase\": string;\n            \"getMore\": string;\n            \"copySuccess\": string;\n            \"download\": string;\n            \"retry\": string;\n            \"copyLink\": string;\n            \"collapse\": string;\n            \"viewDetails\": string;\n        };\n        \"quickAction\": {\n            \"title\": string;\n            \"placeHolder\": string;\n        };\n        \"password\": {\n            \"setInvalid\": string;\n        };\n        \"template\": {\n            \"non\": {\n                \"share\": string;\n                \"copy\": string;\n            };\n            \"aiTitle\": string;\n            \"aiGreeting\": string;\n            \"aiSubTitle\": string;\n            \"guideTitle\": string;\n            \"watchVideo\": string;\n            \"title\": string;\n            \"description\": string;\n            \"browseAll\": string;\n            \"templateTitle\": string;\n            \"loadMore\": string;\n            \"allTemplatesLoaded\": string;\n            \"createTemplate\": string;\n            \"promptBox\": {\n                \"placeholder\": string;\n                \"start\": string;\n                \"carouselGuides\": {\n                    \"guide1\": string;\n                    \"guide2\": string;\n                    \"guide3\": string;\n                    \"guide4\": string;\n                    \"guide5\": string;\n                    \"guide6\": string;\n                    \"guide7\": string;\n                };\n            };\n            \"useTemplateDialog\": {\n                \"title\": string;\n                \"description\": string;\n                \"noSpaceDescription\": string;\n                \"newSpacePlaceholder\": string;\n                \"createSpace\": string;\n            };\n        };\n        \"share\": {\n            \"copyToSpaceDialog\": {\n                \"title\": string;\n                \"description\": string;\n                \"baseName\": string;\n                \"baseNamePlaceholder\": string;\n                \"selectSpace\": string;\n                \"noSpaceDescription\": string;\n                \"newSpacePlaceholder\": string;\n                \"createSpace\": string;\n                \"copyTarget\": string;\n                \"createNewBase\": string;\n                \"copyToExistingBase\": string;\n                \"selectBase\": string;\n                \"selectBasePlaceholder\": string;\n                \"noBaseInSpace\": string;\n            };\n        };\n        \"settings\": {\n            \"title\": string;\n            \"personal\": {\n                \"title\": string;\n            };\n            \"back\": string;\n            \"account\": {\n                \"title\": string;\n                \"tab\": string;\n                \"updatePhoto\": string;\n                \"updateNameDesc\": string;\n                \"securityTitle\": string;\n                \"email\": string;\n                \"password\": string;\n                \"passwordDesc\": string;\n                \"changePassword\": {\n                    \"title\": string;\n                    \"desc\": string;\n                    \"current\": string;\n                    \"new\": string;\n                    \"confirm\": string;\n                };\n                \"changePasswordError\": {\n                    \"disMatch\": string;\n                    \"equal\": string;\n                    \"invalid\": string;\n                    \"invalidNew\": string;\n                };\n                \"changePasswordSuccess\": {\n                    \"title\": string;\n                    \"desc\": string;\n                };\n                \"manageToken\": string;\n                \"addPassword\": {\n                    \"title\": string;\n                    \"desc\": string;\n                    \"password\": string;\n                    \"confirm\": string;\n                };\n                \"addPasswordError\": {\n                    \"disMatch\": string;\n                    \"invalid\": string;\n                };\n                \"addPasswordSuccess\": {\n                    \"title\": string;\n                };\n                \"deleteAccount\": {\n                    \"title\": string;\n                    \"desc\": string;\n                    \"error\": {\n                        \"title\": string;\n                        \"desc\": string;\n                        \"spacesError\": string;\n                    };\n                    \"confirm\": {\n                        \"title\": string;\n                        \"placeholder\": string;\n                    };\n                    \"loading\": string;\n                };\n                \"changeEmail\": {\n                    \"title\": string;\n                    \"desc\": string;\n                    \"current\": string;\n                    \"new\": string;\n                    \"code\": string;\n                    \"getCode\": string;\n                    \"error\": {\n                        \"invalidCode\": string;\n                        \"invalidPassword\": string;\n                        \"invalidConflict\": string;\n                        \"invalidSameEmail\": string;\n                        \"sendMailRateLimit\": string;\n                    };\n                    \"success\": {\n                        \"title\": string;\n                        \"desc\": string;\n                        \"sendSuccess\": string;\n                    };\n                };\n            };\n            \"notify\": {\n                \"title\": string;\n                \"label\": string;\n                \"desc\": string;\n            };\n            \"setting\": {\n                \"title\": string;\n                \"theme\": string;\n                \"themeDesc\": string;\n                \"dark\": string;\n                \"light\": string;\n                \"system\": string;\n                \"version\": string;\n                \"language\": string;\n                \"interactionMode\": string;\n                \"mouseMode\": string;\n                \"touchMode\": string;\n                \"systemMode\": string;\n                \"buySelfHostedLicense\": string;\n            };\n            \"nav\": {\n                \"settings\": string;\n                \"logout\": string;\n                \"contactSupport\": string;\n            };\n            \"integration\": {\n                \"title\": string;\n                \"thirdPartyIntegrations\": {\n                    \"title\": string;\n                    \"description\": string;\n                    \"lastUsed\": string;\n                    \"revoke\": string;\n                    \"owner\": string;\n                    \"revokeTitle\": string;\n                    \"revokeDesc\": string;\n                    \"scopeTitle\": string;\n                    \"scopeDesc\": string;\n                };\n                \"userIntegration\": {\n                    \"title\": string;\n                    \"description\": string;\n                    \"emptyDescription\": string;\n                    \"actions\": {\n                        \"reconnect\": string;\n                    };\n                    \"slack\": {\n                        \"user\": string;\n                        \"workspace\": string;\n                    };\n                    \"email\": {\n                        \"user\": string;\n                        \"email\": string;\n                    };\n                    \"deleteTitle\": string;\n                    \"deleteDesc\": string;\n                    \"create\": string;\n                    \"manage\": string;\n                    \"searchPlaceholder\": string;\n                    \"defaultName\": string;\n                    \"callback\": {\n                        \"error\": string;\n                        \"title\": string;\n                        \"desc\": string;\n                    };\n                };\n                \"description\": string;\n                \"lastUsed\": string;\n                \"revoke\": string;\n                \"owner\": string;\n                \"revokeTitle\": string;\n                \"revokeDesc\": string;\n                \"scopeTitle\": string;\n                \"scopeDesc\": string;\n            };\n            \"templateAdmin\": {\n                \"title\": string;\n                \"noData\": string;\n                \"importing\": string;\n                \"usageCount\": string;\n                \"useTemplate\": string;\n                \"createdBy\": string;\n                \"backToTemplateList\": string;\n                \"tips\": {\n                    \"errorCategoryName\": string;\n                    \"needSnapshot\": string;\n                    \"needPublish\": string;\n                    \"needBaseSource\": string;\n                    \"forbiddenUpdateSystemTemplate\": string;\n                    \"addCategoryTips\": string;\n                    \"categoryNamePlaceholder\": string;\n                    \"duplicateCategoryName\": string;\n                };\n                \"category\": {\n                    \"menu\": {\n                        \"getStarted\": string;\n                        \"recommended\": string;\n                        \"all\": string;\n                        \"browseByCategory\": string;\n                    };\n                };\n                \"header\": {\n                    \"cover\": string;\n                    \"name\": string;\n                    \"description\": string;\n                    \"markdownDescription\": string;\n                    \"category\": string;\n                    \"isSystem\": string;\n                    \"source\": string;\n                    \"status\": string;\n                    \"publishSnapshot\": string;\n                    \"snapshotTime\": string;\n                    \"actions\": string;\n                    \"featured\": string;\n                    \"createdBy\": string;\n                    \"userNonExistent\": string;\n                    \"preview\": string;\n                    \"usage\": string;\n                    \"visit\": string;\n                };\n                \"actions\": {\n                    \"title\": string;\n                    \"publish\": string;\n                    \"delete\": string;\n                    \"duplicate\": string;\n                    \"preview\": string;\n                    \"use\": string;\n                    \"pinTop\": string;\n                    \"addCategory\": string;\n                    \"selectCategory\": string;\n                    \"viewTemplate\": string;\n                    \"manageCategory\": string;\n                };\n                \"relatedTemplates\": string;\n                \"noImage\": string;\n                \"baseSelectPanel\": {\n                    \"title\": string;\n                    \"description\": string;\n                    \"confirm\": string;\n                    \"search\": string;\n                    \"cancel\": string;\n                    \"selectBase\": string;\n                    \"createTemplate\": string;\n                    \"abnormalBase\": string;\n                };\n            };\n        };\n        \"noun\": {\n            \"table\": string;\n            \"view\": string;\n            \"space\": string;\n            \"base\": string;\n            \"field\": string;\n            \"record\": string;\n            \"dashboard\": string;\n            \"automation\": string;\n            \"authorityMatrix\": string;\n            \"design\": string;\n            \"adminPanel\": string;\n            \"license\": string;\n            \"instanceId\": string;\n            \"beta\": string;\n            \"trash\": string;\n            \"global\": string;\n            \"organizationPanel\": string;\n            \"unknownError\": string;\n            \"pluginPanel\": string;\n            \"pluginContextMenu\": string;\n            \"plugin\": string;\n            \"copy\": string;\n            \"credits\": string;\n            \"aiChat\": string;\n            \"app\": string;\n            \"webSearch\": string;\n            \"folder\": string;\n            \"newAutomation\": string;\n            \"newApp\": string;\n            \"newFolder\": string;\n            \"template\": string;\n        };\n        \"level\": {\n            \"free\": string;\n            \"plus\": string;\n            \"pro\": string;\n            \"business\": string;\n            \"enterprise\": string;\n        };\n        \"noResult\": string;\n        \"allNodes\": string;\n        \"noDescription\": string;\n        \"untitled\": string;\n        \"name\": string;\n        \"description\": string;\n        \"required\": string;\n        \"characters\": string;\n        \"atLeastOne\": string;\n        \"guide\": {\n            \"prev\": string;\n            \"next\": string;\n            \"done\": string;\n            \"skip\": string;\n            \"createSpaceTooltipTitle\": string;\n            \"createSpaceTooltipContent\": string;\n            \"createBaseTooltipTitle\": string;\n            \"createBaseTooltipContent\": string;\n            \"createTableTooltipTitle\": string;\n            \"createTableTooltipContent\": string;\n            \"createViewTooltipTitle\": string;\n            \"createViewTooltipContent\": string;\n            \"viewFilteringTooltipTitle\": string;\n            \"viewFilteringTooltipContent\": string;\n            \"viewSortingTooltipTitle\": string;\n            \"viewSortingTooltipContent\": string;\n            \"viewGroupingTooltipTitle\": string;\n            \"viewGroupingTooltipContent\": string;\n            \"apiButtonTooltipTitle\": string;\n            \"apiButtonTooltipContent\": string;\n        };\n        \"token\": string;\n        \"poweredBy\": string;\n        \"invite\": {\n            \"dialog\": {\n                \"title\": string;\n                \"desc_one\": string;\n                \"desc_other\": string;\n                \"tabEmail\": string;\n                \"emailPlaceholder\": string;\n                \"tabLink\": string;\n                \"linkPlaceholder\": string;\n                \"emailSend\": string;\n                \"linkSend\": string;\n                \"spaceTitle\": string;\n                \"collaboratorSearchPlaceholder\": string;\n                \"collaboratorJoin\": string;\n                \"collaboratorRemove\": string;\n                \"linkTitle\": string;\n                \"linkCreatedTime\": string;\n                \"linkCopySuccess\": string;\n                \"linkRemove\": string;\n                \"desc_billable_one\": string;\n                \"desc_billable_other\": string;\n                \"spaceTitleWithCount\": string;\n                \"baseTitle\": string;\n                \"allCollaboratorsTitle\": string;\n                \"baseOnly\": string;\n                \"noInviteLinks\": string;\n                \"linkDescription\": string;\n                \"haveAccess\": string;\n                \"desc\": string;\n            };\n            \"base\": {\n                \"title\": string;\n                \"desc_one\": string;\n                \"desc_other\": string;\n                \"baseTitle\": string;\n                \"collaboratorSearchPlaceholder\": string;\n                \"baseTitleWithCount\": string;\n            };\n            \"addOrgCollaborator\": {\n                \"title\": string;\n                \"placeholder\": string;\n            };\n            \"sendInvitationSuccess\": string;\n            \"table\": {\n                \"collaborator\": string;\n                \"accessPermission\": string;\n                \"joinAt\": string;\n            };\n            \"authority\": {\n                \"title\": string;\n                \"description\": string;\n                \"viewDetail\": string;\n            };\n        };\n        \"help\": {\n            \"title\": string;\n            \"appLink\": string;\n            \"mainLink\": string;\n            \"apiLink\": string;\n        };\n        \"pagePermissionChangeTip\": string;\n        \"listEmptyTips\": string;\n        \"billing\": {\n            \"overLimits\": string;\n            \"overLimitsDescription\": string;\n            \"userLimitExceededDescription\": string;\n            \"unavailableInPlanTips\": string;\n            \"unavailableConnectionTips\": string;\n            \"levelTips\": string;\n            \"enterpriseFeature\": string;\n            \"automationRequiresUpgrade\": string;\n            \"authorityMatrixRequiresUpgrade\": string;\n            \"viewPricing\": string;\n            \"billable\": string;\n            \"billableByAuthorityMatrix\": string;\n            \"licenseExpiredGracePeriod\": string;\n            \"spaceSubscriptionModal\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"status\": {\n                \"active\": string;\n                \"canceled\": string;\n                \"incomplete\": string;\n                \"incompleteExpired\": string;\n                \"trialing\": string;\n                \"pastDue\": string;\n                \"unpaid\": string;\n                \"paused\": string;\n                \"seatLimitExceeded\": string;\n            };\n            \"contactAdminToUpgrade\": string;\n        };\n        \"admin\": {\n            \"setting\": {\n                \"instanceTitle\": string;\n                \"description\": string;\n                \"allowSignUp\": string;\n                \"allowSignUpDescription\": string;\n                \"allowSpaceInvitation\": string;\n                \"allowSpaceInvitationDescription\": string;\n                \"allowSpaceCreation\": string;\n                \"allowSpaceCreationDescription\": string;\n                \"enableEmailVerification\": string;\n                \"enableEmailVerificationDescription\": string;\n                \"enableWaitlist\": string;\n                \"enableWaitlistDescription\": string;\n                \"generalSettings\": string;\n                \"aiSettings\": string;\n                \"brandingSettings\": {\n                    \"title\": string;\n                    \"description\": string;\n                    \"brandName\": string;\n                    \"logo\": string;\n                    \"logoDescription\": string;\n                    \"logoUpload\": string;\n                    \"logoUploadDescription\": string;\n                };\n                \"ai\": {\n                    \"name\": string;\n                    \"nameDescription\": string;\n                    \"enable\": string;\n                    \"enableDescription\": string;\n                    \"updateLLMProvider\": string;\n                    \"addProvider\": string;\n                    \"addProviderDescription\": string;\n                    \"providerType\": string;\n                    \"baseUrl\": string;\n                    \"apiKey\": string;\n                    \"baseUrlDescription\": string;\n                    \"apiKeyDescription\": string;\n                    \"models\": string;\n                    \"modelsDescription\": string;\n                    \"baseUrlRequired\": string;\n                    \"fetchModelListError\": string;\n                    \"provider\": string;\n                    \"providerDescription\": string;\n                    \"modelPreferences\": string;\n                    \"modelPreferencesDescription\": string;\n                    \"embeddingModel\": string;\n                    \"embeddingModelDescription\": string;\n                    \"translationModel\": string;\n                    \"translationModelDescription\": string;\n                    \"chatModel\": string;\n                    \"chatModelDescription\": string;\n                    \"chatModels\": {\n                        \"lg\": string;\n                        \"lgDescription\": string;\n                    };\n                    \"actions\": {\n                        \"title\": string;\n                        \"aiField\": {\n                            \"title\": string;\n                            \"description\": string;\n                        };\n                        \"aiChat\": {\n                            \"title\": string;\n                            \"description\": string;\n                        };\n                    };\n                    \"chatModelTest\": {\n                        \"text\": string;\n                        \"description\": string;\n                        \"notConfigLgModel\": string;\n                        \"confirmTitle\": string;\n                        \"confirmDescription\": string;\n                        \"confirm\": string;\n                        \"cancel\": string;\n                        \"missingCapabilitiesWarning\": string;\n                        \"enableAITitle\": string;\n                        \"enableAIDescription\": string;\n                        \"enableAI\": string;\n                        \"skipTest\": string;\n                        \"modelNotSuitable\": string;\n                    };\n                    \"chatModelAbility\": {\n                        \"image\": string;\n                        \"pdf\": string;\n                        \"webSearch\": string;\n                        \"disabledWebSearch\": string;\n                        \"lgModelAbility\": string;\n                        \"toolCall\": string;\n                        \"reasoning\": string;\n                        \"imageGeneration\": string;\n                        \"missingVision\": string;\n                        \"missingToolCall\": string;\n                        \"notTested\": string;\n                        \"supportedFormats\": string;\n                    };\n                    \"configUpdated\": string;\n                    \"noModelFound\": string;\n                    \"searchModel\": string;\n                    \"selectModel\": string;\n                    \"input\": string;\n                    \"output\": string;\n                    \"inputOrOutputTip\": string;\n                    \"imageOutput\": string;\n                    \"imageOutputTip\": string;\n                    \"supportImageOutputTip\": string;\n                    \"supportVisionTip\": string;\n                    \"supportAudioTip\": string;\n                    \"supportVideoTip\": string;\n                    \"supportDeepThinkTip\": string;\n                    \"testConnection\": string;\n                    \"testing\": string;\n                    \"testSuccess\": string;\n                    \"testFailed\": string;\n                    \"fillRequiredFields\": string;\n                    \"modelsRequired\": string;\n                    \"noValidModel\": string;\n                    \"addCustomModel\": string;\n                    \"isOpenRouter\": string;\n                    \"customModel\": string;\n                    \"customModelDescription\": string;\n                    \"aiAbilitySettings\": string;\n                    \"aiAbilitySettingsDescription\": string;\n                    \"imageModelAbility\": {\n                        \"generation\": string;\n                        \"imageToImage\": string;\n                    };\n                    \"moreModels\": string;\n                    \"noModelsAvailable\": string;\n                    \"testCompleteWithCount\": string;\n                    \"allTestsFailed\": string;\n                    \"batchTest\": string;\n                    \"test\": string;\n                    \"testProvider\": string;\n                    \"testProviderTooltip\": string;\n                    \"batchTesting\": string;\n                    \"batchTestComplete\": string;\n                    \"batchTestResults\": string;\n                    \"batchTestResultsSummary\": string;\n                    \"batchTestNoModels\": string;\n                    \"modelStatus\": string;\n                    \"imageSupport\": string;\n                    \"basicGeneration\": string;\n                    \"supported\": string;\n                    \"notSupported\": string;\n                    \"partialSupport\": string;\n                    \"urlSupport\": string;\n                    \"base64Support\": string;\n                    \"closeResults\": string;\n                    \"retryFailed\": string;\n                    \"stopTest\": string;\n                    \"pending\": string;\n                    \"configuredModels\": string;\n                    \"modelRates\": string;\n                    \"model\": string;\n                    \"inputRate\": string;\n                    \"outputRate\": string;\n                    \"inputRateTip\": string;\n                    \"outputRateTip\": string;\n                    \"rateExplanationTitle\": string;\n                    \"rateExplanationFormula\": string;\n                    \"rateExplanationExample\": string;\n                    \"ratesDescription\": string;\n                    \"advancedRates\": string;\n                    \"advancedRatesDescription\": string;\n                    \"cacheRead\": string;\n                    \"cacheWrite\": string;\n                    \"reasoning\": string;\n                    \"perImage\": string;\n                    \"cacheReadRateTip\": string;\n                    \"cacheWriteRateTip\": string;\n                    \"reasoningRateTip\": string;\n                    \"imageRateTip\": string;\n                    \"imageModel\": string;\n                    \"imageGeneration\": string;\n                    \"imageToImage\": string;\n                    \"clickToToggleImageModel\": string;\n                    \"markAsImageModel\": string;\n                    \"imageGenerationModel\": string;\n                    \"markedAsImageModel\": string;\n                    \"markedAsTextModel\": string;\n                    \"fetchPricing\": string;\n                    \"fetchPricingTip\": string;\n                    \"fetchPricingError\": string;\n                    \"pricingPreview\": string;\n                    \"pricingPreviewDesc\": string;\n                    \"openRouterId\": string;\n                    \"notFound\": string;\n                    \"applyPricing\": string;\n                    \"pricingApplied\": string;\n                    \"pricingAppliedCount\": string;\n                    \"hint\": {\n                        \"title\": string;\n                        \"missingV1Suffix\": string;\n                        \"removeTrailingSlash\": string;\n                        \"checkApiKey\": string;\n                        \"azureDeployment\": string;\n                        \"checkQuotaOrPermission\": string;\n                        \"checkModelName\": string;\n                        \"checkConnection\": string;\n                        \"ollamaRunning\": string;\n                        \"sslCertificate\": string;\n                        \"checkConfiguration\": string;\n                    };\n                    \"recommended\": string;\n                    \"gatewayModels\": string;\n                    \"gatewayModelsDescription\": string;\n                    \"gatewayDescription\": string;\n                    \"noGatewayModels\": string;\n                    \"addModel\": string;\n                    \"addGatewayModel\": string;\n                    \"popularModels\": string;\n                    \"modelId\": string;\n                    \"modelIdHint\": string;\n                    \"searchModelPlaceholder\": string;\n                    \"noMatchingModels\": string;\n                    \"useCustomId\": string;\n                    \"typeToSearch\": string;\n                    \"modelNotFound\": string;\n                    \"testModel\": string;\n                    \"testModelSuccess\": string;\n                    \"testModelImageSuccess\": string;\n                    \"testModelNotFound\": string;\n                    \"displayLabel\": string;\n                    \"isImageModel\": string;\n                    \"capabilities\": string;\n                    \"setAsDefault\": string;\n                    \"quickAdd\": string;\n                    \"guide\": {\n                        \"configStatus\": string;\n                        \"ready\": string;\n                        \"needsAttention\": string;\n                        \"incomplete\": string;\n                        \"aiEnabled\": string;\n                        \"aiEnabledDesc\": string;\n                        \"aiDisabledDesc\": string;\n                        \"gatewayKey\": string;\n                        \"gatewayKeyConfigured\": string;\n                        \"gatewayKeyMissing\": string;\n                        \"gatewayKeyRequired\": string;\n                        \"gatewayModels\": string;\n                        \"gatewayModelsConfigured\": string;\n                        \"gatewayModelsEmpty\": string;\n                        \"providers\": string;\n                        \"providersConfigured\": string;\n                        \"providersEmpty\": string;\n                        \"chatModel\": string;\n                        \"chatModelGateway\": string;\n                        \"chatModelProvider\": string;\n                        \"chatModelMissing\": string;\n                    };\n                    \"enableCard\": {\n                        \"title\": string;\n                        \"ready\": string;\n                        \"needsConfig\": string;\n                        \"disabled\": string;\n                        \"missingConfig\": string;\n                        \"allConfigured\": string;\n                    };\n                    \"wizard\": {\n                        \"setupProgress\": string;\n                        \"checklist\": string;\n                        \"allComplete\": string;\n                        \"nextStep\": string;\n                        \"configureAI\": string;\n                        \"optional\": string;\n                        \"gatewayHelp\": string;\n                        \"gatewayByok\": string;\n                        \"getApiKey\": string;\n                        \"keyInvalid\": string;\n                        \"gatewayErrorUnauthorized\": string;\n                        \"gatewayErrorNeedCreditCard\": string;\n                        \"gatewayErrorInsufficientQuota\": string;\n                        \"gatewayErrorForbidden\": string;\n                        \"gatewayErrorNetwork\": string;\n                        \"pleaseTest\": string;\n                        \"test\": string;\n                        \"testing\": string;\n                        \"attachmentTest\": {\n                            \"title\": string;\n                            \"urlMode\": string;\n                            \"base64Mode\": string;\n                            \"accessible\": string;\n                            \"inaccessible\": string;\n                            \"urlNotAccessibleWarning\": string;\n                            \"useBase64Mode\": string;\n                            \"base64ModeDescription\": string;\n                            \"originChanged\": string;\n                            \"originChangedDesc\": string;\n                        };\n                        \"saveAndContinue\": string;\n                        \"completeStep1First\": string;\n                        \"completeStep2First\": string;\n                        \"addCustom\": string;\n                        \"enabledModels\": string;\n                        \"chatDefault\": string;\n                        \"noModelsAvailable\": string;\n                        \"quickSetup\": string;\n                        \"useRecommended\": string;\n                        \"useRecommendedDesc\": string;\n                        \"chatModels\": string;\n                        \"chatModelTip\": string;\n                        \"selectChatModel\": string;\n                        \"lgDesc\": string;\n                        \"mdDesc\": string;\n                        \"smDesc\": string;\n                        \"readyToUse\": string;\n                        \"customProviderHelp\": string;\n                        \"testModelCapabilities\": string;\n                        \"customModelsAutoImported\": string;\n                        \"modelsCount\": string;\n                        \"customModelsHint\": string;\n                        \"gatewayOption\": {\n                            \"title\": string;\n                            \"desc\": string;\n                        };\n                        \"customOption\": {\n                            \"title\": string;\n                            \"desc\": string;\n                        };\n                        \"step\": {\n                            \"llmApi\": string;\n                            \"llmApiDesc\": string;\n                            \"modelPool\": string;\n                            \"modelPoolDesc\": string;\n                            \"chatModel\": string;\n                            \"chatModelDesc\": string;\n                            \"providers\": string;\n                            \"providersDesc\": string;\n                        };\n                    };\n                };\n                \"webSearch\": {\n                    \"description\": string;\n                };\n                \"app\": {\n                    \"domain\": string;\n                    \"v0ApiKey\": string;\n                    \"customDomain\": string;\n                    \"customDomainDescription\": string;\n                    \"vercelToken\": string;\n                    \"vercelTokenDescription\": string;\n                    \"apiProxy\": string;\n                    \"apiProxyDescription\": string;\n                    \"v0BaseUrl\": string;\n                    \"vercelBaseUrl\": string;\n                    \"aiGateway\": string;\n                    \"aiGatewayDescription\": string;\n                    \"aiGatewayApiKey\": string;\n                    \"aiGatewayKeyConfigured\": string;\n                    \"aiGatewayBaseUrl\": string;\n                };\n            };\n            \"action\": {\n                \"enterApiKey\": string;\n                \"goToConfiguration\": string;\n            };\n            \"tips\": {\n                \"thankYouForUsingTeable\": string;\n                \"pleaseGoToConfiguration\": string;\n                \"pleaseContactAdmin\": string;\n            };\n            \"configuration\": {\n                \"title\": string;\n                \"description\": string;\n                \"copyInstance\": string;\n                \"list\": {\n                    \"publicOrigin\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"https\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"databaseProxy\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"href\": string;\n                    };\n                    \"llmApi\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"errorTips\": string;\n                    };\n                    \"app\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"errorTips\": string;\n                    };\n                    \"webSearch\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"errorTips\": string;\n                    };\n                    \"email\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"errorTips\": string;\n                    };\n                    \"aiEnable\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"aiLlmApi\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"aiModelPool\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"aiChatModel\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"appBuilderV0\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"appBuilderDomain\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                    \"appBuilderApiProxy\": {\n                        \"title\": string;\n                        \"description\": string;\n                    };\n                };\n                \"progressTitle\": string;\n                \"allComplete\": string;\n                \"incomplete\": string;\n                \"optional\": string;\n                \"completed\": string;\n                \"group\": {\n                    \"system\": string;\n                    \"ai\": string;\n                    \"appBuilder\": string;\n                };\n            };\n            \"canary\": {\n                \"title\": string;\n                \"enable\": string;\n                \"enableDescription\": string;\n                \"spaces\": string;\n                \"spacesDescription\": string;\n                \"configure\": string;\n                \"spaceIds\": string;\n                \"spaceIdsDescription\": string;\n                \"spaceIdsPlaceholder\": string;\n                \"preview\": string;\n                \"noSpaceIds\": string;\n            };\n        };\n        \"notification\": {\n            \"title\": string;\n            \"unread\": string;\n            \"read\": string;\n            \"markAs\": string;\n            \"markAllAsRead\": string;\n            \"noUnread\": string;\n            \"changeSetting\": string;\n            \"new\": string;\n            \"showMore\": string;\n            \"exportBase\": {\n                \"successText\": string;\n                \"failedText\": string;\n            };\n        };\n        \"role\": {\n            \"title\": {\n                \"owner\": string;\n                \"creator\": string;\n                \"editor\": string;\n                \"commenter\": string;\n                \"viewer\": string;\n            };\n            \"description\": {\n                \"owner\": string;\n                \"creator\": string;\n                \"editor\": string;\n                \"commenter\": string;\n                \"viewer\": string;\n            };\n        };\n        \"trash\": {\n            \"spaceTrash\": string;\n            \"type\": string;\n            \"resetTrash\": string;\n            \"deletedBy\": string;\n            \"deletedTime\": string;\n            \"fromSpace\": string;\n            \"permanentDeleteTips\": string;\n            \"resetTrashConfirm\": string;\n            \"addToTrash\": string;\n            \"description\": string;\n            \"spaceDescription\": string;\n            \"spaceInnerDescription\": string;\n            \"baseDescription\": string;\n        };\n        \"pluginCenter\": {\n            \"pluginUrlEmpty\": string;\n            \"install\": string;\n            \"publisher\": string;\n            \"lastUpdated\": string;\n            \"pluginNotFound\": string;\n            \"pluginEmpty\": {\n                \"title\": string;\n            };\n        };\n        \"automation\": {\n            \"turnOnTip\": string;\n        };\n        \"email\": {\n            \"send\": string;\n            \"config\": string;\n            \"customConfig\": string;\n            \"notify\": string;\n            \"automation\": string;\n            \"customNotifyConfig\": string;\n            \"customAutomationConfig\": string;\n            \"addConfig\": string;\n            \"editConfig\": string;\n            \"resetConfig\": string;\n            \"testEmail\": string;\n            \"testEmailPlaceholder\": string;\n            \"testEmailError\": string;\n            \"testEmailSend\": string;\n            \"configError\": string;\n            \"host\": string;\n            \"hostDescription\": string;\n            \"port\": string;\n            \"secure\": string;\n            \"auth\": string;\n            \"username\": string;\n            \"password\": string;\n            \"sender\": string;\n            \"senderName\": string;\n            \"subscribe\": string;\n            \"unsubscribe\": string;\n            \"unsubscribeList\": string;\n            \"unsubscribeTime\": string;\n            \"source\": string;\n            \"sourceAutomationDeleted\": string;\n            \"processing\": string;\n            \"unsubscribeH1\": string;\n            \"unsubscribeH2\": string;\n            \"subscribeH1\": string;\n            \"subscribeH2\": string;\n            \"unsubscribeListTip\": string;\n            \"templates\": {\n                \"resetPassword\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"message\": string;\n                    \"buttonText\": string;\n                };\n                \"emailVerifyCode\": {\n                    \"signupVerification\": {\n                        \"subject\": string;\n                        \"title\": string;\n                        \"message\": string;\n                    };\n                    \"domainVerification\": {\n                        \"subject\": string;\n                        \"title\": string;\n                        \"message\": string;\n                    };\n                    \"changeEmailVerification\": {\n                        \"subject\": string;\n                        \"title\": string;\n                        \"message\": string;\n                    };\n                };\n                \"collaboratorCellTag\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"buttonText\": string;\n                };\n                \"collaboratorMultiRowTag\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"buttonText\": string;\n                };\n                \"invite\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"message\": string;\n                    \"buttonText\": string;\n                };\n                \"waitlistInvite\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"message\": string;\n                    \"buttonText\": string;\n                };\n                \"test\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"message\": string;\n                };\n                \"notify\": {\n                    \"subject\": string;\n                    \"title\": string;\n                    \"buttonText\": string;\n                    \"import\": {\n                        \"title\": string;\n                        \"table\": {\n                            \"aborted\": {\n                                \"message\": string;\n                            };\n                            \"failed\": {\n                                \"message\": string;\n                            };\n                            \"planLimitExceeded\": {\n                                \"message\": string;\n                            };\n                            \"noRecordsProcessed\": {\n                                \"message\": string;\n                            };\n                            \"success\": {\n                                \"message\": string;\n                                \"inplace\": string;\n                            };\n                            \"partialSuccess\": {\n                                \"message\": string;\n                                \"messageNoReport\": string;\n                            };\n                            \"allFailed\": {\n                                \"message\": string;\n                                \"messageNoReport\": string;\n                            };\n                        };\n                    };\n                    \"recordComment\": {\n                        \"title\": string;\n                        \"message\": string;\n                    };\n                    \"automation\": {\n                        \"title\": string;\n                        \"failed\": {\n                            \"title\": string;\n                            \"message\": string;\n                        };\n                        \"insufficientCredit\": {\n                            \"title\": string;\n                            \"message\": string;\n                        };\n                        \"runQuotaExceeded\": {\n                            \"title\": string;\n                            \"message\": string;\n                        };\n                    };\n                    \"billing\": {\n                        \"title\": string;\n                        \"credit\": {\n                            \"warning80\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                            \"warning90\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                        };\n                        \"automationRun\": {\n                            \"warning80\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                            \"warning90\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                            \"gracePeriod\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                        };\n                    };\n                    \"exportBase\": {\n                        \"title\": string;\n                        \"success\": {\n                            \"message\": string;\n                        };\n                        \"failed\": {\n                            \"message\": string;\n                        };\n                    };\n                    \"task\": {\n                        \"ai\": {\n                            \"failed\": {\n                                \"title\": string;\n                                \"message\": string;\n                            };\n                            \"cancelled\": {\n                                \"title\": string;\n                                \"rateLimit\": string;\n                                \"creditExhausted\": string;\n                                \"authFailed\": string;\n                                \"serviceUnavailable\": string;\n                                \"unknown\": string;\n                            };\n                        };\n                    };\n                    \"rewardRejected\": {\n                        \"title\": string;\n                        \"message\": string;\n                        \"buttonText\": string;\n                    };\n                    \"rewardApproved\": {\n                        \"title\": string;\n                        \"message\": string;\n                        \"buttonText\": string;\n                    };\n                };\n            };\n            \"title\": string;\n        };\n        \"waitlist\": {\n            \"title\": string;\n            \"email\": string;\n            \"joinTitle\": string;\n            \"joinDesc\": string;\n            \"emailPlaceholder\": string;\n            \"youAreOnTheList\": string;\n            \"thanksForJoining\": string;\n            \"back\": string;\n            \"inviteCodePlaceholder\": string;\n            \"join\": string;\n            \"joining\": string;\n            \"invite\": string;\n            \"inviteTime\": string;\n            \"createdTime\": string;\n            \"yes\": string;\n            \"no\": string;\n            \"generateCode\": string;\n            \"count\": string;\n            \"times\": string;\n            \"generate\": string;\n            \"code\": string;\n            \"inviteSuccess\": string;\n            \"app\": {\n                \"previewAppError\": string;\n                \"sendErrorToAI\": string;\n            };\n        };\n        \"base\": {\n            \"deleteTip\": string;\n            \"createResource\": string;\n            \"noPermissionToCreateResource\": string;\n        };\n        \"credit\": {\n            \"title\": string;\n            \"leftAmount\": string;\n            \"winFreeCredits\": string;\n            \"getCredits\": string;\n            \"winCredit\": {\n                \"title\": string;\n                \"freeCredits\": string;\n                \"guidelinesTitle\": string;\n                \"tagTeableio\": string;\n                \"minCharacters\": string;\n                \"minFollowers\": string;\n                \"limitPerWeek\": string;\n                \"postOnX\": string;\n                \"postOnLinkedIn\": string;\n                \"preFilledDraft\": string;\n                \"claimTitle\": string;\n                \"userEmail\": string;\n                \"postUrlLabel\": string;\n                \"postUrlPlaceholder\": string;\n                \"invalidUrl\": string;\n                \"claiming\": string;\n                \"claimCredits\": string;\n                \"congratulations\": string;\n                \"claimSuccess\": string;\n                \"verifying\": string;\n                \"verifyingDescription\": string;\n                \"verifyFailed\": string;\n                \"tryAgain\": string;\n            };\n            \"error\": {\n                \"verificationFailed\": string;\n            };\n        };\n        \"reward\": {\n            \"title\": string;\n            \"rewardCredits\": string;\n            \"minCharCount\": string;\n            \"minFollowerCount\": string;\n            \"mustMention\": string;\n            \"fetchSnapshotFailed\": string;\n            \"alreadyClaimedThisWeek\": string;\n            \"manage\": {\n                \"title\": string;\n                \"description\": string;\n                \"overview\": string;\n                \"records\": string;\n                \"searchSpace\": string;\n                \"searchRecords\": string;\n                \"dateRange\": string;\n                \"from\": string;\n                \"to\": string;\n                \"totalSpaces\": string;\n                \"totalRecords\": string;\n                \"space\": string;\n                \"allSpaces\": string;\n                \"user\": string;\n                \"creator\": string;\n                \"sourceType\": string;\n                \"platform\": string;\n                \"allStatuses\": string;\n                \"allSourceTypes\": string;\n                \"allPlatforms\": string;\n                \"pendingCount\": string;\n                \"approvedCount\": string;\n                \"rejectedCount\": string;\n                \"approvedAmount\": string;\n                \"consumedAmount\": string;\n                \"availableAmount\": string;\n                \"expiringSoonAmount\": string;\n                \"amount\": string;\n                \"remainingAmount\": string;\n                \"createdTime\": string;\n                \"rewardTime\": string;\n                \"expiredTime\": string;\n                \"lastModified\": string;\n                \"viewDetails\": string;\n                \"details\": string;\n                \"basicInfo\": string;\n                \"amountInfo\": string;\n                \"timeInfo\": string;\n                \"socialInfo\": string;\n                \"verifyResult\": string;\n                \"uniqueKey\": string;\n                \"verify\": string;\n                \"valid\": string;\n                \"invalid\": string;\n                \"errors\": string;\n                \"copied\": string;\n                \"openPost\": string;\n                \"noData\": string;\n                \"page\": string;\n                \"status\": {\n                    \"label\": string;\n                    \"pending\": string;\n                    \"approved\": string;\n                    \"rejected\": string;\n                };\n            };\n        };\n        \"system\": {\n            \"notFound\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"links\": {\n                \"backToHome\": string;\n            };\n            \"forbidden\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"paymentRequired\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"error\": {\n                \"title\": string;\n                \"description\": string;\n            };\n        };\n        \"import\": {\n            \"error\": {\n                \"dateOutOfRange\": string;\n                \"planRowLimit\": string;\n                \"notNullValidation\": string;\n                \"uniqueValidation\": string;\n                \"requestTimeout\": string;\n                \"chunkProcessingFailed\": string;\n                \"unknown\": string;\n            };\n        };\n        \"changelog\": {\n            \"newUpdate\": string;\n            \"title\": string;\n            \"url\": string;\n            \"id\": string;\n        };\n        \"noPermissionToCreateBase\": string;\n        \"app\": {\n            \"title\": string;\n            \"description\": string;\n            \"previewAppError\": string;\n            \"sendErrorToAI\": string;\n        };\n        \"chat\": {\n            \"serverError\": string;\n            \"serverErrorHint\": string;\n        };\n        \"clickToCopyTooltip\": string;\n        \"copiedTooltip\": string;\n        \"hiddenFieldCount_one\": string;\n        \"hiddenFieldCount_other\": string;\n        \"invalidFieldMapping\": string;\n        \"sourceFieldNotFoundMapping\": string;\n        \"targetFieldNotFoundMapping\": string;\n        \"fieldTypeNotSupportedMapping\": string;\n        \"fieldSettingsNotMatchMapping\": string;\n        \"fieldSettingsLookupNotMatch\": string;\n        \"fieldSettingsLinkTableNotMatch\": string;\n        \"fieldSettingsLinkViewNotMatch\": string;\n        \"fieldTypeDifferentMapping\": string;\n        \"fieldMappingSourceTip\": string;\n        \"fieldMappingTargetTip\": string;\n        \"reset\": string;\n        \"checkAll\": string;\n        \"uncheckAll\": string;\n        \"duplicateOptionsMapping\": string;\n        \"lookupFieldInvalidMapping\": string;\n        \"noMatchedOptions\": string;\n        \"needManualSelectionMapping\": string;\n        \"targetFieldIsComputed\": string;\n        \"targetFieldIsComputedTips\": string;\n        \"emptyOption\": string;\n        \"showEmptyTip\": string;\n        \"hideEmptyTip\": string;\n        \"hideText\": string;\n        \"showText\": string;\n        \"sourceTable\": string;\n        \"sourceView\": string;\n        \"non\": {\n            \"share\": string;\n            \"copy\": string;\n        };\n    };\n    \"dashboard\": {\n        \"empty\": {\n            \"title\": string;\n            \"description\": string;\n            \"create\": string;\n        };\n        \"addPlugin\": string;\n        \"createDashboard\": {\n            \"button\": string;\n            \"title\": string;\n            \"placeholder\": string;\n        };\n        \"findDashboard\": string;\n        \"expand\": string;\n        \"deprecation\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"pluginUrlEmpty\": string;\n        \"install\": string;\n        \"publisher\": string;\n        \"lastUpdated\": string;\n        \"pluginNotFound\": string;\n        \"pluginEmpty\": {\n            \"title\": string;\n        };\n    };\n    \"developer\": {\n        \"apiQueryBuilder\": string;\n        \"subTitle\": string;\n        \"apiList\": string;\n        \"cellFormat\": string;\n        \"fieldKeyType\": string;\n        \"fieldKeyTypeDesc\": string;\n        \"chooseSource\": string;\n        \"action\": {\n            \"selectBase\": string;\n            \"selectTable\": string;\n        };\n        \"pickParams\": string;\n        \"buildResult\": string;\n        \"buildResultEmpty\": string;\n        \"previewReturnValue\": string;\n        \"replaceToken\": string;\n        \"createNewToken\": string;\n        \"showPagination\": string;\n        \"addSort\": string;\n        \"tabs\": {\n            \"apiBuilder\": string;\n            \"aiContext\": string;\n        };\n        \"aiContext\": {\n            \"title\": string;\n            \"description\": string;\n            \"selectTableFirst\": string;\n            \"fullContext\": string;\n            \"compactContext\": string;\n            \"copyToClipboard\": string;\n            \"copied\": string;\n            \"compactDescription\": string;\n        };\n        \"only10Records\": string;\n    };\n    \"oauth\": {\n        \"add\": string;\n        \"title\": {\n            \"add\": string;\n            \"edit\": string;\n            \"description\": string;\n        };\n        \"form\": {\n            \"name\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"description\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"homePageUrl\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"logo\": {\n                \"label\": string;\n                \"description\": string;\n                \"placeholder\": string;\n                \"button\": string;\n                \"clear\": string;\n                \"lengthError\": string;\n                \"typeError\": string;\n                \"Label\": string;\n            };\n            \"callbackUrl\": {\n                \"label\": string;\n                \"description\": string;\n                \"add\": string;\n            };\n            \"scopes\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"secret\": {\n                \"label\": string;\n                \"add\": string;\n                \"newDescription\": string;\n                \"empty\": string;\n                \"lastUsed\": string;\n                \"tag\": string;\n                \"neverUsed\": string;\n            };\n            \"clientId\": {\n                \"label\": string;\n            };\n        };\n        \"formType\": {\n            \"basic\": string;\n            \"scopes\": string;\n            \"identify\": string;\n            \"clientInfo\": string;\n        };\n        \"decision\": {\n            \"title\": string;\n            \"scopes\": string;\n            \"redirectDescription\": string;\n            \"authorize\": string;\n        };\n        \"help\": {\n            \"link\": string;\n            \"title\": string;\n        };\n        \"deleteConfirm\": {\n            \"title\": string;\n            \"description\": string;\n        };\n    };\n    \"plugin\": {\n        \"add\": string;\n        \"title\": {\n            \"add\": string;\n            \"edit\": string;\n        };\n        \"pluginUser\": {\n            \"name\": string;\n            \"description\": string;\n        };\n        \"secret\": string;\n        \"regenerateSecret\": string;\n        \"form\": {\n            \"name\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"description\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"detailDesc\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"logo\": {\n                \"label\": string;\n                \"description\": string;\n                \"upload\": string;\n                \"clear\": string;\n                \"placeholder\": string;\n                \"lengthError\": string;\n                \"typeError\": string;\n                \"Label\": string;\n            };\n            \"helpUrl\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"positions\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"i18n\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"url\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"autoCreateMember\": {\n                \"label\": string;\n                \"description\": string;\n            };\n            \"config\": {\n                \"label\": string;\n                \"description\": string;\n            };\n        };\n        \"markdown\": {\n            \"write\": string;\n            \"preview\": string;\n        };\n        \"status\": {\n            \"reviewing\": string;\n            \"published\": string;\n            \"developing\": string;\n        };\n        \"button\": {\n            \"submitApproved\": string;\n        };\n    };\n    \"sdk\": {\n        \"common\": {\n            \"comingSoon\": string;\n            \"empty\": string;\n            \"noRecords\": string;\n            \"unnamedRecord\": string;\n            \"untitled\": string;\n            \"cancel\": string;\n            \"confirm\": string;\n            \"back\": string;\n            \"done\": string;\n            \"create\": string;\n            \"search\": {\n                \"placeholder\": string;\n                \"empty\": string;\n            };\n            \"readOnlyTip\": string;\n            \"selectPlaceHolder\": string;\n            \"loading\": string;\n            \"loadMore\": string;\n            \"uploadFailed\": string;\n            \"rowCount\": string;\n            \"summary\": string;\n            \"summaryTip\": string;\n            \"actions\": string;\n            \"remove\": string;\n            \"runStatus\": {\n                \"success\": string;\n                \"failed\": string;\n                \"running\": string;\n            };\n            \"resetSuccess\": string;\n            \"click\": string;\n            \"clickedCount\": string;\n            \"atLeastOne\": string;\n        };\n        \"notification\": {\n            \"title\": string;\n        };\n        \"preview\": {\n            \"previewFileLimit\": string;\n            \"loadFileError\": string;\n        };\n        \"undoRedo\": {\n            \"undo\": string;\n            \"redo\": string;\n            \"undoFailed\": string;\n            \"redoFailed\": string;\n            \"nothingToUndo\": string;\n            \"nothingToRedo\": string;\n            \"undoSucceed\": string;\n            \"redoSucceed\": string;\n            \"undoing\": string;\n            \"redoing\": string;\n        };\n        \"editor\": {\n            \"attachment\": {\n                \"uploadDragOver\": string;\n                \"uploadBaseTextPrefix\": string;\n                \"uploadBaseText\": string;\n                \"uploadDragDefault\": string;\n                \"upload\": string;\n                \"downloadAll\": string;\n                \"downloading\": string;\n                \"downloadSuccess\": string;\n                \"downloadFailed\": string;\n                \"downloadCancelled\": string;\n                \"requireHttps\": string;\n            };\n            \"date\": {\n                \"placeholder\": string;\n                \"today\": string;\n                \"rangePlaceholder\": string;\n                \"rangeSelected\": string;\n                \"invalidTimeRange\": string;\n                \"from\": string;\n                \"to\": string;\n            };\n            \"formula\": {\n                \"title\": string;\n                \"guideSyntax\": string;\n                \"guideExample\": string;\n                \"helperExample\": string;\n                \"fieldValue\": string;\n                \"placeholder\": string;\n                \"placeholderForAIPrompt\": string;\n                \"editExpression\": string;\n                \"generateExpressionByAI\": string;\n                \"inputPrompt\": string;\n                \"generateExpression\": string;\n                \"generatingByAI\": string;\n                \"generatedExpressionTips\": string;\n                \"action\": {\n                    \"generating\": string;\n                    \"generate\": string;\n                    \"apply\": string;\n                };\n                \"expressionRequired\": string;\n            };\n            \"link\": {\n                \"placeholder\": string;\n                \"searchPlaceholder\": string;\n                \"allFields\": string;\n                \"globalSearch\": string;\n                \"fieldSearch\": string;\n                \"maxFieldTips\": string;\n                \"create\": string;\n                \"selectRecord\": string;\n                \"all\": string;\n                \"selected\": string;\n                \"expandRecordError\": string;\n                \"alreadyOpen\": string;\n                \"linkedTo\": string;\n                \"goToForeignTable\": string;\n                \"foreignTableIdRequired\": string;\n                \"linkFieldIdRequired\": string;\n                \"selectTooManyRecords\": string;\n                \"relationshipRequired\": string;\n                \"rangeSelectFailed\": string;\n            };\n            \"user\": {\n                \"searchPlaceholder\": string;\n                \"notify\": string;\n            };\n            \"select\": {\n                \"addOption\": string;\n                \"choicesNameRequired\": string;\n            };\n            \"lookup\": {\n                \"lookupFieldIdRequired\": string;\n                \"lookupOptionsNotAllowed\": string;\n                \"lookupOptionsRequired\": string;\n                \"refineOptionsError\": string;\n            };\n            \"rollup\": {\n                \"expressionRequired\": string;\n                \"unsupportedTip\": string;\n            };\n            \"conditionalRollup\": {\n                \"filterRequired\": string;\n            };\n            \"conditionalLookup\": {\n                \"filterRequired\": string;\n            };\n            \"aiConfig\": {\n                \"modelKeyRequired\": string;\n                \"typeNotSupported\": string;\n                \"sourceFieldIdRequired\": string;\n                \"targetLanguageRequired\": string;\n                \"promptRequired\": string;\n            };\n            \"error\": {\n                \"refineOptionsError\": string;\n                \"optionsRequired\": string;\n            };\n        };\n        \"filter\": {\n            \"label\": string;\n            \"displayLabel\": string;\n            \"displayLabel_other\": string;\n            \"addCondition\": string;\n            \"addConditionGroup\": string;\n            \"nestedLimitTip\": string;\n            \"linkInputPlaceholder\": string;\n            \"groupDescription\": string;\n            \"currentUser\": string;\n            \"tips\": {\n                \"scope\": string;\n            };\n            \"invalidateSelected\": string;\n            \"invalidateSelectedTips\": string;\n            \"default\": {\n                \"empty\": string;\n                \"placeholder\": string;\n            };\n            \"conjunction\": {\n                \"and\": string;\n                \"or\": string;\n                \"where\": string;\n                \"meetingAll\": string;\n                \"meetingAny\": string;\n            };\n            \"operator\": {\n                \"is\": string;\n                \"isNot\": string;\n                \"contains\": string;\n                \"doesNotContain\": string;\n                \"isEmpty\": string;\n                \"isNotEmpty\": string;\n                \"isGreater\": string;\n                \"isGreaterEqual\": string;\n                \"isLess\": string;\n                \"isLessEqual\": string;\n                \"isAnyOf\": string;\n                \"isNoneOf\": string;\n                \"hasAnyOf\": string;\n                \"hasAllOf\": string;\n                \"hasNoneOf\": string;\n                \"isExactly\": string;\n                \"isWithIn\": string;\n                \"isBefore\": string;\n                \"isAfter\": string;\n                \"isOnOrBefore\": string;\n                \"isOnOrAfter\": string;\n                \"number\": {\n                    \"is\": string;\n                    \"isNot\": string;\n                    \"isGreater\": string;\n                    \"isGreaterEqual\": string;\n                    \"isLess\": string;\n                    \"isLessEqual\": string;\n                };\n            };\n            \"conditionalRollup\": {\n                \"switchToField\": string;\n                \"switchToValue\": string;\n            };\n            \"component\": {\n                \"date\": {\n                    \"today\": string;\n                    \"tomorrow\": string;\n                    \"yesterday\": string;\n                    \"oneWeekAgo\": string;\n                    \"oneWeekFromNow\": string;\n                    \"oneMonthAgo\": string;\n                    \"oneMonthFromNow\": string;\n                    \"daysAgo\": string;\n                    \"daysFromNow\": string;\n                    \"exactDate\": string;\n                    \"exactFormatDate\": string;\n                    \"currentWeek\": string;\n                    \"currentMonth\": string;\n                    \"currentYear\": string;\n                    \"lastWeek\": string;\n                    \"lastMonth\": string;\n                    \"lastYear\": string;\n                    \"nextWeekPeriod\": string;\n                    \"nextMonthPeriod\": string;\n                    \"nextYearPeriod\": string;\n                    \"pastWeek\": string;\n                    \"pastMonth\": string;\n                    \"pastYear\": string;\n                    \"nextWeek\": string;\n                    \"nextMonth\": string;\n                    \"nextYear\": string;\n                    \"pastNumberOfDays\": string;\n                    \"nextNumberOfDays\": string;\n                    \"dateRange\": string;\n                };\n            };\n        };\n        \"color\": {\n            \"label\": string;\n        };\n        \"rowHeight\": {\n            \"short\": string;\n            \"medium\": string;\n            \"tall\": string;\n            \"extraTall\": string;\n            \"title\": string;\n        };\n        \"fieldNameConfig\": {\n            \"title\": string;\n            \"displayLines\": string;\n        };\n        \"share\": {\n            \"title\": string;\n        };\n        \"extensions\": {\n            \"title\": string;\n        };\n        \"hidden\": {\n            \"label\": string;\n            \"configLabel_one\": string;\n            \"configLabel_other\": string;\n            \"configLabel_other_visible\": string;\n            \"showAll\": string;\n            \"hideAll\": string;\n            \"primaryKey\": string;\n        };\n        \"expandRecord\": {\n            \"copy\": string;\n            \"duplicateRecord\": string;\n            \"copyRecordUrl\": string;\n            \"deleteRecord\": string;\n            \"addRecordComment\": string;\n            \"viewRecordHistory\": string;\n            \"recordHistory\": {\n                \"hiddenRecordHistory\": string;\n                \"showRecordHistory\": string;\n                \"createdTime\": string;\n                \"createdBy\": string;\n                \"before\": string;\n                \"after\": string;\n                \"viewRecord\": string;\n            };\n            \"showHiddenFields\": string;\n            \"hideHiddenFields\": string;\n            \"showMore\": string;\n            \"showLess\": string;\n        };\n        \"sort\": {\n            \"label\": string;\n            \"displayLabel_one\": string;\n            \"displayLabel_other\": string;\n            \"setTips\": string;\n            \"addButton\": string;\n            \"autoSort\": string;\n            \"selectASCLabel\": string;\n            \"selectDESCLabel\": string;\n        };\n        \"group\": {\n            \"label\": string;\n            \"displayLabel_one\": string;\n            \"displayLabel_other\": string;\n            \"setTips\": string;\n            \"addButton\": string;\n        };\n        \"field\": {\n            \"title\": {\n                \"singleLineText\": string;\n                \"longText\": string;\n                \"singleSelect\": string;\n                \"number\": string;\n                \"multipleSelect\": string;\n                \"link\": string;\n                \"formula\": string;\n                \"date\": string;\n                \"createdTime\": string;\n                \"lastModifiedTime\": string;\n                \"attachment\": string;\n                \"checkbox\": string;\n                \"rollup\": string;\n                \"conditionalRollup\": string;\n                \"user\": string;\n                \"rating\": string;\n                \"autoNumber\": string;\n                \"lookup\": string;\n                \"conditionalLookup\": string;\n                \"button\": string;\n                \"createdBy\": string;\n                \"lastModifiedBy\": string;\n            };\n            \"description\": {\n                \"singleLineText\": string;\n                \"longText\": string;\n                \"singleSelect\": string;\n                \"number\": string;\n                \"multipleSelect\": string;\n                \"link\": string;\n                \"formula\": string;\n                \"date\": string;\n                \"createdTime\": string;\n                \"lastModifiedTime\": string;\n                \"attachment\": string;\n                \"checkbox\": string;\n                \"rollup\": string;\n                \"conditionalRollup\": string;\n                \"user\": string;\n                \"rating\": string;\n                \"autoNumber\": string;\n                \"lookup\": string;\n                \"conditionalLookup\": string;\n                \"button\": string;\n                \"createdBy\": string;\n                \"lastModifiedBy\": string;\n            };\n            \"link\": {\n                \"oneWay\": string;\n                \"twoWay\": string;\n            };\n            \"button\": {\n                \"confirm\": {\n                    \"title\": string;\n                    \"description\": string;\n                };\n            };\n        };\n        \"permission\": {\n            \"actionDescription\": {\n                \"spaceCreate\": string;\n                \"spaceDelete\": string;\n                \"spaceRead\": string;\n                \"spaceUpdate\": string;\n                \"spaceInviteEmail\": string;\n                \"spaceInviteLink\": string;\n                \"spaceGrantRole\": string;\n                \"baseCreate\": string;\n                \"baseDelete\": string;\n                \"baseRead\": string;\n                \"baseReadAll\": string;\n                \"baseUpdate\": string;\n                \"baseInviteEmail\": string;\n                \"baseInviteLink\": string;\n                \"baseTableImport\": string;\n                \"baseAuthorityMatrixConfig\": string;\n                \"baseDbConnect\": string;\n                \"tableCreate\": string;\n                \"tableRead\": string;\n                \"tableDelete\": string;\n                \"tableUpdate\": string;\n                \"tableImport\": string;\n                \"tableExport\": string;\n                \"tableTrashRead\": string;\n                \"tableTrashUpdate\": string;\n                \"tableTrashReset\": string;\n                \"viewCreate\": string;\n                \"viewDelete\": string;\n                \"viewRead\": string;\n                \"viewUpdate\": string;\n                \"viewShare\": string;\n                \"fieldCreate\": string;\n                \"fieldDelete\": string;\n                \"fieldRead\": string;\n                \"fieldUpdate\": string;\n                \"recordCreate\": string;\n                \"recordComment\": string;\n                \"recordDelete\": string;\n                \"recordRead\": string;\n                \"recordUpdate\": string;\n                \"recordCopy\": string;\n                \"automationCreate\": string;\n                \"automationDelete\": string;\n                \"automationRead\": string;\n                \"automationUpdate\": string;\n                \"appCreate\": string;\n                \"appDelete\": string;\n                \"appRead\": string;\n                \"appUpdate\": string;\n                \"userProfileRead\": string;\n                \"userEmailRead\": string;\n                \"userIntegrations\": string;\n                \"recordHistoryRead\": string;\n                \"baseQuery\": string;\n                \"instanceRead\": string;\n                \"instanceUpdate\": string;\n                \"enterpriseRead\": string;\n                \"enterpriseUpdate\": string;\n            };\n        };\n        \"noun\": {\n            \"table\": string;\n            \"view\": string;\n            \"space\": string;\n            \"base\": string;\n            \"field\": string;\n            \"record\": string;\n            \"automation\": string;\n            \"app\": string;\n            \"user\": string;\n            \"recordHistory\": string;\n            \"you\": string;\n            \"instance\": string;\n            \"enterprise\": string;\n            \"history\": string;\n            \"global\": string;\n        };\n        \"formula\": {\n            \"SUM\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"AVERAGE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MAX\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MIN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ROUND\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ROUNDUP\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ROUNDDOWN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"CEILING\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"FLOOR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"EVEN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ODD\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"INT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ABS\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"SQRT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"POWER\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"EXP\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"LOG\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MOD\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"VALUE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"CONCATENATE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"FIND\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"SEARCH\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MID\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"LEFT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"RIGHT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"REPLACE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"REGEXP_REPLACE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"SUBSTITUTE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"LOWER\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"UPPER\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"REPT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"TRIM\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"LEN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"T\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ENCODE_URL_COMPONENT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"IF\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"SWITCH\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"AND\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"OR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"XOR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"NOT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"BLANK\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ERROR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"IS_ERROR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"TODAY\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"NOW\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"YEAR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MONTH\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"WEEKNUM\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"WEEKDAY\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DAY\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"HOUR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"MINUTE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"SECOND\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"FROMNOW\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"TONOW\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DATETIME_DIFF\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"WORKDAY\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"WORKDAY_DIFF\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"IS_SAME\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"IS_AFTER\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"IS_BEFORE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DATE_ADD\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DATESTR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"TIMESTR\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DATETIME_FORMAT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"DATETIME_PARSE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"CREATED_TIME\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"LAST_MODIFIED_TIME\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"COUNTALL\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"COUNTA\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"COUNT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ARRAY_JOIN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ARRAY_UNIQUE\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ARRAY_FLATTEN\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"ARRAY_COMPACT\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"TEXT_ALL\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"RECORD_ID\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"AUTO_NUMBER\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n            \"FORMULA\": {\n                \"summary\": string;\n                \"example\": string;\n            };\n        };\n        \"functionType\": {\n            \"fields\": string;\n            \"numeric\": string;\n            \"text\": string;\n            \"logical\": string;\n            \"date\": string;\n            \"array\": string;\n            \"system\": string;\n        };\n        \"statisticFunc\": {\n            \"none\": string;\n            \"count\": string;\n            \"empty\": string;\n            \"filled\": string;\n            \"unique\": string;\n            \"max\": string;\n            \"min\": string;\n            \"sum\": string;\n            \"average\": string;\n            \"checked\": string;\n            \"unChecked\": string;\n            \"percentEmpty\": string;\n            \"percentFilled\": string;\n            \"percentUnique\": string;\n            \"percentChecked\": string;\n            \"percentUnChecked\": string;\n            \"earliestDate\": string;\n            \"latestDate\": string;\n            \"dateRangeOfDays\": string;\n            \"dateRangeOfMonths\": string;\n            \"totalAttachmentSize\": string;\n        };\n        \"baseQuery\": {\n            \"add\": string;\n            \"error\": {\n                \"invalidCol\": string;\n                \"invalidCols\": string;\n                \"invalidTable\": string;\n                \"requiredSelect\": string;\n            };\n            \"from\": {\n                \"title\": string;\n                \"fromTable\": string;\n                \"fromQuery\": string;\n            };\n            \"select\": {\n                \"title\": string;\n            };\n            \"where\": {\n                \"title\": string;\n            };\n            \"groupBy\": {\n                \"title\": string;\n            };\n            \"orderBy\": {\n                \"title\": string;\n                \"asc\": string;\n                \"desc\": string;\n            };\n            \"limit\": {\n                \"title\": string;\n            };\n            \"offset\": {\n                \"title\": string;\n            };\n            \"join\": {\n                \"title\": string;\n                \"joinType\": string;\n                \"leftJoin\": string;\n                \"rightJoin\": string;\n                \"innerJoin\": string;\n                \"fullJoin\": string;\n                \"data\": string;\n            };\n            \"aggregation\": {\n                \"title\": string;\n            };\n        };\n        \"comment\": {\n            \"title\": string;\n            \"placeholder\": string;\n            \"emptyComment\": string;\n            \"deletedComment\": string;\n            \"imageSizeLimit\": string;\n            \"tip\": {\n                \"editing\": string;\n                \"edited\": string;\n                \"notifyAll\": string;\n                \"notifyRelatedToMe\": string;\n                \"all\": string;\n                \"relatedToMe\": string;\n                \"reactionUserSuffix\": string;\n                \"me\": string;\n                \"connection\": string;\n            };\n            \"toolbar\": {\n                \"link\": string;\n                \"image\": string;\n                \"mention\": string;\n            };\n            \"floatToolbar\": {\n                \"editLink\": string;\n                \"caption\": string;\n                \"delete\": string;\n                \"linkText\": string;\n                \"enterUrl\": string;\n            };\n        };\n        \"memberSelector\": {\n            \"title\": string;\n            \"memberSelectorSearchPlaceholder\": string;\n            \"departmentSelectorSearchPlaceholder\": string;\n            \"selected\": string;\n            \"noSelected\": string;\n            \"empty\": string;\n            \"emptyDepartment\": string;\n        };\n        \"httpErrors\": {\n            \"validationError\": string;\n            \"invalidCaptcha\": string;\n            \"invalidCredentials\": string;\n            \"unauthorized\": string;\n            \"unauthorizedShare\": string;\n            \"paymentRequired\": string;\n            \"creditLimitExceeded\": string;\n            \"restrictedResource\": string;\n            \"notFound\": string;\n            \"conflict\": string;\n            \"unprocessableEntity\": string;\n            \"userLimitExceeded\": string;\n            \"tooManyRequests\": string;\n            \"internalServerError\": string;\n            \"databaseConnectionUnavailable\": string;\n            \"gatewayTimeout\": string;\n            \"unknownErrorCode\": string;\n            \"networkError\": string;\n            \"requestTimeout\": string;\n            \"failedDependency\": string;\n            \"automationNodeParseError\": string;\n            \"automationNodeNeedTest\": string;\n            \"automationNodeTestOutdated\": string;\n            \"invalidToken\": string;\n            \"custom\": {\n                \"fieldValueNotNull\": string;\n                \"fieldValueDuplicate\": string;\n                \"linkFieldValueDuplicate\": string;\n                \"requestTimeout\": string;\n                \"searchTimeOut\": string;\n                \"dependencyNodeRequire\": string;\n                \"invalidOperation\": string;\n            };\n            \"comment\": {\n                \"listCountExceeded\": string;\n                \"invalidContentType\": string;\n            };\n            \"attachment\": {\n                \"tokenExpireInTooLong\": string;\n                \"s3RegionRequired\": string;\n                \"s3EndpointRequired\": string;\n                \"s3AccessKeyRequired\": string;\n                \"s3SecretKeyRequired\": string;\n                \"s3UploadMethodMustBePut\": string;\n                \"presignedError\": string;\n                \"invalidObjectMeta\": string;\n                \"invalidImageStream\": string;\n                \"calculateImageSizeFailed\": string;\n                \"uploadFailed\": string;\n                \"invalidImage\": string;\n                \"cantGetImageStream\": string;\n                \"invalidProvider\": string;\n                \"failedToDeleteDirectory\": string;\n                \"invalidToken\": string;\n                \"tokenExpired\": string;\n                \"sizeMismatch\": string;\n                \"notAllowUploadFileType\": string;\n                \"notFound\": string;\n                \"invalidPath\": string;\n                \"fileSizeExceedsMaximumLimit\": string;\n                \"invalidUploadType\": string;\n                \"urlReject\": string;\n            };\n            \"email\": {\n                \"testEmailError\": string;\n            };\n            \"auth\": {\n                \"invalidConfirm\": string;\n                \"emailNotRegistered\": string;\n                \"passwordNotSet\": string;\n                \"systemUser\": string;\n                \"alreadyRegistered\": string;\n                \"passwordIncorrect\": string;\n                \"tokenInvalid\": string;\n                \"passwordAlreadyExists\": string;\n                \"verificationCodeInvalid\": string;\n                \"newEmailSameAsCurrentEmail\": string;\n                \"emailAlreadyRegistered\": string;\n                \"waitlistNotEnabled\": string;\n                \"emailOrPasswordIncorrect\": string;\n                \"accountDeactivated\": string;\n                \"accountLockedOut\": string;\n            };\n            \"automation\": {\n                \"buttonClickTriggerDuplicated\": string;\n                \"triggerNotFound\": string;\n                \"nodeNotFound\": string;\n                \"triggerTestFailed\": string;\n                \"testFailed\": string;\n                \"runFailed\": string;\n                \"nodeParseError\": string;\n                \"nodeNeedTest\": string;\n                \"nodeTestOutdated\": string;\n                \"notFound\": string;\n                \"currentSnapshotEmpty\": string;\n                \"runNotFound\": string;\n                \"anchorNotFound\": string;\n                \"validationError\": string;\n                \"tableNotInBase\": string;\n                \"alreadyActiveAndNotDraft\": string;\n                \"noActiveSnapshot\": string;\n                \"triggerNodeAlreadyExists\": string;\n                \"generateLogicError\": string;\n                \"logicNotFound\": string;\n                \"actionNotFound\": string;\n                \"unSupportDuplicateWorkflowNodeType\": string;\n                \"unSupportLogicType\": string;\n                \"groupEndNotFound\": string;\n                \"insertNodeError\": string;\n                \"controlNodeNotBeTested\": string;\n                \"invalidNodeType\": string;\n                \"unsupportedCategory\": string;\n                \"unknownConnectionType\": string;\n                \"imapPasswordNotConfigured\": string;\n                \"integrationNotFound\": string;\n                \"webhookTriggerNotFound\": string;\n                \"emailReceivedTriggerNotFound\": string;\n                \"emailConnectorNotAvailable\": string;\n                \"listMailboxesFailed\": string;\n            };\n            \"scrape\": {\n                \"unknownDataset\": string;\n                \"apiKeyNotConfigured\": string;\n                \"triggerFailed\": string;\n                \"snapshotError\": string;\n                \"timeout\": string;\n            };\n            \"integration\": {\n                \"oauthCodeExchangeFailed\": string;\n                \"oauthTokenRefreshFailed\": string;\n                \"userInfoFetchFailed\": string;\n            };\n            \"space\": {\n                \"notFound\": string;\n                \"noPermission\": string;\n                \"disallowSpaceCreation\": string;\n                \"cannotChangeOnlyOwnerRole\": string;\n                \"cannotDeleteOnlyOwner\": string;\n                \"deleted\": string;\n                \"cannotOperate\": string;\n                \"notBelongToOrg\": string;\n                \"invalidSpaceIds\": string;\n            };\n            \"base\": {\n                \"notFound\": string;\n                \"cannotAccess\": string;\n                \"anchorNotFound\": string;\n                \"baseAndSpaceMismatch\": string;\n                \"templateNotFound\": string;\n            };\n            \"baseNode\": {\n                \"baseIdIsRequired\": string;\n                \"nodeIdIsRequired\": string;\n                \"invalidResourceType\": string;\n                \"notFound\": string;\n                \"parentMustBeFolder\": string;\n                \"cannotDuplicateFolder\": string;\n                \"cannotDeleteEmptyFolder\": string;\n                \"onlyOneOfParentIdOrAnchorIdRequired\": string;\n                \"cannotMoveToItself\": string;\n                \"cannotMoveToCircularReference\": string;\n                \"anchorIdOrParentIdRequired\": string;\n                \"parentNotFound\": string;\n                \"parentIsNotFolder\": string;\n                \"circularReference\": string;\n                \"folderDepthLimitExceeded\": string;\n                \"folderNotFound\": string;\n                \"anchorNotFound\": string;\n                \"nameAlreadyExists\": string;\n            };\n            \"dashboard\": {\n                \"notFound\": string;\n            };\n            \"plugin\": {\n                \"notFound\": string;\n                \"notSupportInstallInView\": string;\n                \"userNotFound\": string;\n                \"invalidSecret\": string;\n                \"invalidRefreshToken\": string;\n                \"anomalousToken\": string;\n            };\n            \"pluginPanel\": {\n                \"notFound\": string;\n            };\n            \"pluginInstall\": {\n                \"notFound\": string;\n            };\n            \"share\": {\n                \"incorrectPassword\": string;\n                \"notAllowedToSubmit\": string;\n                \"viewRequired\": string;\n                \"hiddenFieldsSubmissionNotAllowed\": string;\n                \"submitRecordsError\": string;\n                \"notAllowedToCopy\": string;\n                \"fieldHiddenNotAllowed\": string;\n                \"fieldTypeNotLinkField\": string;\n                \"fieldIdRequired\": string;\n                \"fieldNotUserRelatedField\": string;\n                \"viewTypeNotAllowed\": string;\n            };\n            \"shareAuth\": {\n                \"passwordRestrictionNotEnabled\": string;\n                \"shareViewNotFound\": string;\n                \"linkFieldNotFound\": string;\n            };\n            \"baseShare\": {\n                \"notFound\": string;\n                \"alreadyExists\": string;\n                \"copyNotAllowed\": string;\n            };\n            \"shareSocket\": {\n                \"viewPermissionNotAllowed\": string;\n                \"fieldPermissionNotAllowed\": string;\n                \"recordPermissionNotAllowed\": string;\n            };\n            \"pluginContextMenu\": {\n                \"notFound\": string;\n                \"anchorNotFound\": string;\n            };\n            \"pluginChart\": {\n                \"queryNotFound\": string;\n            };\n            \"dbConnection\": {\n                \"unsupportedDriver\": string;\n                \"onlyOwnerCanRemove\": string;\n                \"onlyOwnerCanCreate\": string;\n                \"roleNotExist\": string;\n            };\n            \"baseQuery\": {\n                \"queryFailed\": string;\n                \"invalidJoinType\": string;\n                \"tableNotFound\": string;\n            };\n            \"baseSqlExecutor\": {\n                \"notAllowedToExecuteSqlWithKeyword\": string;\n                \"whiteListCheckError\": string;\n                \"databaseConnectionFailed\": string;\n                \"executeQuerySqlFailed\": string;\n                \"readOnlyCheckFailed\": string;\n            };\n            \"permission\": {\n                \"createRecordWithDeniedFields\": string;\n                \"deleteRecords\": string;\n                \"readRecordWithDeniedFields\": string;\n                \"updateRecordWithDeniedFields\": string;\n                \"checkIdNotExist\": string;\n                \"userNotAdmin\": string;\n                \"accessTokenNoPermission\": string;\n                \"invalidResource\": string;\n                \"notAllowedSpace\": string;\n                \"notAllowedBase\": string;\n                \"notAllowedTables\": string;\n                \"notAllowedOperationTable\": string;\n                \"notAllowedOperationRecord\": string;\n                \"notAllowedRecordUpdate\": string;\n                \"notAllowedOperationView\": string;\n                \"deniedByEnabledAuthorityMatrix\": string;\n                \"invalidRequestPath\": string;\n                \"notAllowedOperation\": string;\n                \"notAllowedDepartment\": string;\n                \"templateHeaderInvalid\": string;\n            };\n            \"authorityMatrix\": {\n                \"defaultRoleNotFound\": string;\n                \"alreadyDisabled\": string;\n                \"alreadyEnabled\": string;\n                \"notFound\": string;\n                \"primaryFieldCannotBeDisabledForRead\": string;\n                \"fieldDuplicated\": string;\n                \"cannotSetRecordPermissionGroup\": string;\n                \"notFoundBaseAndTable\": string;\n                \"roleTablesShouldNotBeEmpty\": string;\n            };\n            \"selection\": {\n                \"invalidReturnType\": string;\n                \"exceedMaxReadRows\": string;\n                \"invalidCellValueType\": string;\n                \"exceedMaxCopyCells\": string;\n                \"exceedMaxPasteCells\": string;\n            };\n            \"field\": {\n                \"unsupportedFieldType\": string;\n                \"unsupportedPrimaryFieldType\": string;\n                \"primaryFieldNotSupported\": string;\n                \"calculateRecordNotFound\": string;\n                \"toRecordIdsOrFromRecordIdsRequired\": string;\n                \"recordFieldsRequired\": string;\n                \"uniqueUnsupportedType\": string;\n                \"notNullValidationWhenCreateField\": string;\n                \"dbFieldNameAlreadyExists\": string;\n                \"fieldValidationError\": string;\n                \"fieldNameAlreadyExists\": string;\n                \"notFound\": string;\n                \"fieldKeyTypeNotFound\": string;\n                \"notFoundInTable\": string;\n                \"deleteFieldsNotFound\": string;\n                \"lookupValuesShouldBeArray\": string;\n                \"linkCellValuesShouldBeArray\": string;\n                \"lookupAndLinkLengthMatch\": string;\n                \"cycleDetected\": string;\n                \"cycleDetectedCreateField\": string;\n                \"recordMapNotFound\": string;\n                \"forbidDeletePrimaryField\": string;\n                \"foreignTableIdInvalid\": string;\n                \"relationshipInvalid\": string;\n                \"linkFieldIdInvalid\": string;\n                \"lookupFieldIdInvalid\": string;\n                \"formulaExpressionParseError\": string;\n                \"formulaReferenceNotFound\": string;\n                \"formulaReferenceNotFieldId\": string;\n                \"rollupExpressionParseError\": string;\n                \"choiceNameAlreadyExists\": string;\n                \"symmetricFieldIdRequired\": string;\n                \"foreignKeyNameCannotUseId\": string;\n                \"createForeignKeyError\": string;\n                \"lookupFieldTypeNotEqual\": string;\n                \"recordNotFound\": string;\n                \"linkCellRecordIdAlreadyExists\": string;\n                \"oneOneLinkCellValueCannotBeArray\": string;\n                \"manyOneLinkCellValueCannotBeArray\": string;\n                \"foreignKeyDuplicate\": string;\n                \"linkConsistencyError\": string;\n                \"oneManyLinkCellValueShouldBeArray\": string;\n                \"manyManyLinkCellValueShouldBeArray\": string;\n                \"onlyLinkFieldCanBeFiltered\": string;\n                \"notLinkedToCurrentTable\": string;\n                \"notAttachment\": string;\n                \"isComputed\": string;\n                \"notFoundAICofig\": string;\n                \"foreignTableIdRequired\": string;\n                \"lookupFieldIdRequired\": string;\n                \"lookupFieldNotExist\": string;\n                \"lookupFieldNotBelongToTable\": string;\n                \"lookupFieldTypeNotMatch\": string;\n                \"conditionalRollupOptionsRequired\": string;\n                \"conditionalRollupParseError\": string;\n                \"conditionalLookupOptionsRequired\": string;\n                \"button\": {\n                    \"clickCountReachedMaxCount\": string;\n                    \"notSupportReset\": string;\n                };\n            };\n            \"view\": {\n                \"notFound\": string;\n                \"cannotDeleteLastView\": string;\n                \"defaultViewNotFound\": string;\n                \"propertyParseError\": string;\n                \"primaryFieldCannotBeHidden\": string;\n                \"filterUnsupportedFieldType\": string;\n                \"filterInvalidOperator\": string;\n                \"filterInvalidOperatorMode\": string;\n                \"sortUnsupportedFieldType\": string;\n                \"groupUnsupportedFieldType\": string;\n                \"anchorNotFound\": string;\n                \"notEnoughGapToShuffleRow\": string;\n                \"shareNotEnabled\": string;\n                \"shareAlreadyEnabled\": string;\n                \"shareAlreadyDisabled\": string;\n            };\n            \"billing\": {\n                \"insufficientCredit\": string;\n                \"exceedMaxRowLimit\": string;\n                \"exceedMaxAutomationRunLimit\": string;\n            };\n            \"aggregation\": {\n                \"searchQueryRequired\": string;\n                \"maxSearchIndexResult\": string;\n                \"queryCollectionMustBeTableId\": string;\n                \"searchTimeOut\": string;\n                \"indexNotFound\": string;\n                \"invalidStartDateFieldId\": string;\n                \"invalidEndDateFieldId\": string;\n                \"fieldMapRequired\": string;\n                \"filterLinkCellQueryConflict\": string;\n            };\n            \"ai\": {\n                \"chatModelLgNotSet\": string;\n                \"chatModelLgProviderNotSet\": string;\n                \"chatModelSmNotSet\": string;\n                \"chatModelMdNotSet\": string;\n                \"configurationNotSet\": string;\n                \"unsupportedProvider\": string;\n                \"providerConfigurationNotSet\": string;\n                \"testLLMFailed\": string;\n                \"audioNotSupported\": string;\n                \"imageNotSupported\": string;\n                \"modelNotSet\": string;\n                \"unsupportedFileType\": string;\n                \"unsupportedModelType\": string;\n                \"embeddingModelNotSet\": string;\n                \"validateActionFailed\": string;\n                \"generateFailed\": string;\n                \"unsupportedActionType\": string;\n                \"gatewayApiKeyNotSet\": string;\n                \"geminiImageNotSupportedViaGateway\": string;\n            };\n            \"role\": {\n                \"notFound\": string;\n            };\n            \"collaborator\": {\n                \"alreadyExisted\": string;\n                \"notFound\": string;\n                \"userNotFoundInCollaborator\": string;\n                \"noPermissionToDelete\": string;\n                \"noPermissionToUpdate\": string;\n                \"noPermissionToOperateRole\": string;\n                \"alreadyExistedInBase\": string;\n                \"userNotFound\": string;\n                \"baseNotFound\": string;\n                \"noPermissionToAddRole\": string;\n                \"departmentNotFound\": string;\n            };\n            \"table\": {\n                \"notFound\": string;\n                \"dbTableNameAlreadyExists\": string;\n                \"anchorNotFound\": string;\n                \"notInTrash\": string;\n                \"notSupportTableIndex\": string;\n                \"createTableIndexError\": string;\n                \"dropTableIndexError\": string;\n                \"notFoundPrimaryField\": string;\n            };\n            \"export\": {\n                \"notSupportViewType\": string;\n            };\n            \"import\": {\n                \"notSupportedFileFormat\": string;\n                \"notSupportedFileType\": string;\n                \"exceedMaxFieldsLength\": string;\n                \"tooManyConcurrentImports\": string;\n            };\n            \"invitation\": {\n                \"disallowSpaceInvitation\": string;\n                \"invalidCode\": string;\n                \"linkNotFound\": string;\n                \"linkExpired\": string;\n                \"limitExceeded\": string;\n            };\n            \"pin\": {\n                \"alreadyExists\": string;\n                \"notFound\": string;\n                \"anchorNotFound\": string;\n            };\n            \"trash\": {\n                \"invalidResourceType\": string;\n                \"notFound\": string;\n                \"parentSpaceTrashed\": string;\n                \"parentBaseOrSpaceTrashed\": string;\n                \"parentBaseTrashed\": string;\n                \"parentNotFound\": string;\n                \"tableNotFound\": string;\n            };\n            \"license\": {\n                \"invalid\": string;\n                \"instanceIdMismatch\": string;\n                \"expired\": string;\n                \"userLimitExceeded\": string;\n            };\n            \"domainVerification\": {\n                \"notFound\": string;\n                \"invalidCode\": string;\n                \"resendCooldown\": string;\n                \"alreadyVerified\": string;\n            };\n            \"organization\": {\n                \"notFound\": string;\n                \"authenticationNotFound\": string;\n                \"spaceShouldExist\": string;\n                \"emailsNotInOrgDomain\": string;\n                \"emailNotSpaceUser\": string;\n            };\n            \"mail\": {\n                \"failedToSendEmail\": string;\n            };\n            \"user\": {\n                \"disallowSignUp\": string;\n                \"waitlistInviteCodeRequired\": string;\n                \"waitlistInviteCodeInvalid\": string;\n                \"systemUser\": string;\n                \"collaboratorsInSpaces\": string;\n                \"notFound\": string;\n                \"cannotDeleteAdmin\": string;\n                \"cannotDeactivateAdmin\": string;\n                \"cannotRemoveLastAdmin\": string;\n                \"permanentDeleted\": string;\n                \"cannotDeleteSelf\": string;\n                \"alreadyInDepartment\": string;\n                \"emailsNotFound\": string;\n                \"deleted\": string;\n                \"alreadyInOrg\": string;\n                \"notInOrg\": string;\n            };\n            \"record\": {\n                \"notFound\": string;\n                \"deletedIdsNotFound\": string;\n                \"updateFailed\": string;\n                \"noFileOrUrlProvided\": string;\n                \"createRecordsEmpty\": string;\n                \"duplicateFailed\": string;\n            };\n            \"typecast\": {\n                \"cellValueValidationFailed\": string;\n            };\n            \"workflow\": {\n                \"notActive\": string;\n            };\n            \"lastVisit\": {\n                \"invalidResourceType\": string;\n            };\n            \"template\": {\n                \"categoryNotFound\": string;\n                \"snapshotRequired\": string;\n                \"sourceTemplateNotFound\": string;\n                \"noMinOrderFound\": string;\n                \"takeCountTooLarge\": string;\n                \"categoryLimitReached\": string;\n            };\n            \"department\": {\n                \"parentNotFound\": string;\n                \"notFound\": string;\n                \"cannotMoveToItself\": string;\n                \"cannotMoveToSub\": string;\n            };\n            \"app\": {\n                \"notFound\": string;\n                \"noFilesToUpdate\": string;\n                \"noChatIdFound\": string;\n                \"noChatFound\": string;\n                \"versionNotFound\": string;\n                \"cannotRollbackToLatestVersion\": string;\n                \"noChatOrProjectTokenFound\": string;\n                \"apiKeyNotSet\": string;\n                \"cannotDeployAppBeforeInitialization\": string;\n                \"noProjectOrVersionFound\": string;\n                \"noDeploymentUrlAvailable\": string;\n                \"noFilesInZip\": string;\n                \"zipFileTooLarge\": string;\n                \"invalidZip\": string;\n            };\n            \"reward\": {\n                \"notFound\": string;\n                \"unsupportedSourceType\": string;\n                \"maxClaimsReached\": string;\n                \"verificationFailed\": string;\n                \"alreadyClaimedThisWeek\": string;\n                \"invalidPostUrl\": string;\n                \"postAlreadyUsed\": string;\n                \"unsupportedPlatformUrl\": string;\n                \"unsupportedPlatform\": string;\n                \"minCharCount\": string;\n                \"minFollowerCount\": string;\n                \"mustMention\": string;\n                \"fetchTweetFailed\": string;\n                \"tweetNotFound\": string;\n                \"fetchUserFailed\": string;\n                \"xUserNotFound\": string;\n                \"fetchLinkedInPostFailed\": string;\n                \"linkedInPostNotFound\": string;\n                \"linkedInAuthorNotFound\": string;\n                \"fetchLinkedInUserFailed\": string;\n            };\n        };\n        \"aiError\": {\n            \"title\": string;\n            \"retry\": string;\n            \"dismiss\": string;\n        };\n    };\n    \"setting\": {\n        \"personalAccessToken\": string;\n        \"oauthApps\": string;\n        \"plugins\": string;\n    };\n    \"share\": {\n        \"auth\": {\n            \"title\": string;\n            \"submit\": string;\n            \"password\": string;\n            \"passwordTooShort\": string;\n        };\n        \"toolbar\": {\n            \"filterLinkSelectPlaceholder\": string;\n        };\n        \"openOnNewPage\": string;\n        \"errorTips\": string;\n        \"form\": {\n            \"requireLoginTip\": string;\n            \"login\": string;\n        };\n    };\n    \"space\": {\n        \"initialSpaceName\": string;\n        \"action\": {\n            \"createBase\": string;\n            \"createSpace\": string;\n            \"invite\": string;\n        };\n        \"allSpaces\": string;\n        \"emptySpaceTitle\": string;\n        \"spaceIsEmpty\": string;\n        \"baseModal\": {\n            \"copy\": string;\n            \"duplicate\": string;\n            \"createBaseFromTemplate\": string;\n            \"duplicateRecords\": string;\n            \"duplicateRecordsTip\": string;\n            \"toSpace\": string;\n            \"copyToSpace\": string;\n            \"duplicateBase\": string;\n            \"missTargetTip\": string;\n            \"copying\": string;\n            \"copyingTemplate\": string;\n            \"howToCreate\": string;\n            \"fromScratch\": string;\n            \"fromTemplate\": string;\n            \"moveBaseToAnotherSpace\": string;\n            \"chooseSpace\": string;\n            \"duplicateBaseSucceedAndJump\": string;\n        };\n        \"spaceSetting\": {\n            \"title\": string;\n            \"general\": string;\n            \"collaborators\": string;\n            \"generalDescription\": string;\n            \"collaboratorDescription\": string;\n            \"spaceName\": string;\n            \"spaceId\": string;\n            \"importBase\": string;\n        };\n        \"pin\": {\n            \"add\": string;\n            \"remove\": string;\n            \"pin\": string;\n            \"empty\": string;\n        };\n        \"tooltip\": {\n            \"noPermissionToCreateBase\": string;\n        };\n        \"tip\": {\n            \"delete\": string;\n            \"title\": string;\n            \"exportTips1\": string;\n            \"exportTips2\": string;\n            \"exportTips3\": string;\n            \"exportIncludeDataLabel\": string;\n            \"exportIncludeDataDescription\": string;\n            \"moveBaseSuccessTitle\": string;\n            \"moveBaseSuccessDescription\": string;\n        };\n        \"deleteSpaceModal\": {\n            \"title\": string;\n            \"blockedTitle\": string;\n            \"blockedDesc\": string;\n            \"permanentDeleteWarning\": string;\n            \"confirmInputLabel\": string;\n        };\n        \"sharedBase\": {\n            \"title\": string;\n            \"description\": string;\n            \"empty\": string;\n        };\n        \"integration\": {\n            \"title\": string;\n            \"description\": string;\n            \"addIntegration\": string;\n            \"ai\": string;\n        };\n        \"aiSetting\": {\n            \"title\": string;\n            \"description\": string;\n            \"enableTips\": string;\n            \"enable\": string;\n            \"enableSwitchTips\": string;\n        };\n        \"import\": {\n            \"importing\": string;\n            \"importWayTip\": string;\n            \"baseImportTips\": string;\n            \"confirm\": string;\n            \"phase\": {\n                \"parsingStructure\": string;\n                \"creatingBase\": string;\n                \"creatingTable\": string;\n                \"creatingCommonFields\": string;\n                \"creatingButtonFields\": string;\n                \"creatingFormulaFields\": string;\n                \"creatingLinkFields\": string;\n                \"creatingLookupFields\": string;\n                \"creatingTableViews\": string;\n                \"creatingPlugins\": string;\n                \"creatingFolders\": string;\n                \"creatingWorkflows\": string;\n                \"creatingApps\": string;\n                \"creatingAuthorityMatrix\": string;\n                \"queuingAttachments\": string;\n                \"uploadingAppFiles\": string;\n                \"queuingDataImport\": string;\n                \"done\": string;\n                \"clickToView\": string;\n            };\n        };\n        \"template\": {\n            \"title\": string;\n            \"description\": string;\n            \"noTemplatesAvailable\": string;\n            \"noTemplatesDescription\": string;\n        };\n        \"recentlyBase\": {\n            \"title\": string;\n        };\n        \"noBases\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"noSpaces\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"baseList\": {\n            \"allBases\": string;\n            \"owner\": string;\n            \"createdTime\": string;\n            \"lastOpened\": string;\n            \"enter\": string;\n            \"noTables\": string;\n            \"empty\": string;\n            \"recent\": string;\n            \"manual\": string;\n            \"noBasesFound\": string;\n        };\n        \"publishBase\": {\n            \"title\": string;\n            \"description\": string;\n            \"infoTitle\": string;\n            \"form\": {\n                \"title\": string;\n                \"description\": string;\n                \"security\": string;\n                \"includeNodes\": string;\n                \"advanced\": string;\n                \"publishNode\": string;\n                \"includeData\": string;\n                \"defaultActiveNode\": string;\n                \"select\": string;\n                \"descriptionPlaceholder\": string;\n                \"titlePlaceholder\": string;\n                \"toBeFilledTitle\": string;\n                \"toBeFilledDescription\": string;\n            };\n            \"publishToCommunity\": string;\n            \"publish\": string;\n            \"publishSuccess\": string;\n            \"previewTips\": string;\n            \"update\": string;\n            \"unPublish\": string;\n            \"unPublishSuccess\": string;\n            \"unPublishConfirmTitle\": string;\n            \"unPublishConfirmDescription\": string;\n            \"usageCount\": string;\n            \"uploadCover\": string;\n            \"changeCover\": string;\n            \"uploading\": string;\n            \"uploadSuccess\": string;\n            \"uploadFailed\": string;\n            \"invalidImageType\": string;\n            \"tips\": {\n                \"publishValidation\": string;\n                \"atLeastOneNode\": string;\n            };\n            \"urlCopied\": string;\n            \"urlCopiedForDiscord\": string;\n            \"featuredLabel\": string;\n            \"unfeaturedLabel\": string;\n            \"featuredTip\": string;\n            \"unfeaturedTip\": string;\n            \"publishSuccessDescription\": string;\n            \"shareWith\": string;\n            \"unpublishedApps\": {\n                \"title\": string;\n                \"description\": string;\n                \"publishAll\": string;\n                \"publish\": string;\n                \"published\": string;\n                \"publishing\": string;\n                \"publishFailed\": string;\n                \"publishFailedTip1\": string;\n                \"publishFailedTip2\": string;\n                \"notPublished\": string;\n                \"ignoreAndContinue\": string;\n                \"goToFix\": string;\n                \"redeploy\": string;\n                \"unnamedApp\": string;\n            };\n        };\n        \"collaborators\": string;\n        \"more\": string;\n    };\n    \"table\": {\n        \"toolbar\": {\n            \"comingSoon\": string;\n            \"viewFilterInShare\": string;\n            \"createFieldButtonText\": string;\n            \"others\": {\n                \"share\": {\n                    \"label\": string;\n                    \"statusLabel\": string;\n                    \"noPermission\": string;\n                    \"shareLink\": string;\n                    \"copied\": string;\n                    \"genLink\": string;\n                    \"allowCopy\": string;\n                    \"showAllFields\": string;\n                    \"restrict\": string;\n                    \"tips\": string;\n                    \"passwordTitle\": string;\n                    \"passwordTips\": string;\n                    \"embed\": string;\n                    \"embedPreview\": string;\n                    \"hideToolbar\": string;\n                    \"URLSetting\": string;\n                    \"URLSettingDescription\": string;\n                    \"cancel\": string;\n                    \"save\": string;\n                    \"requireLogin\": string;\n                    \"copyCode\": string;\n                    \"theme\": string;\n                    \"themeSystem\": string;\n                    \"themeLight\": string;\n                    \"themeDark\": string;\n                };\n                \"extensions\": {\n                    \"label\": string;\n                    \"graph\": string;\n                };\n                \"api\": {\n                    \"label\": string;\n                    \"restfulApi\": string;\n                    \"databaseConnection\": string;\n                    \"title\": string;\n                    \"aiContext\": string;\n                    \"advanced\": string;\n                    \"generatingToken\": string;\n                    \"aiContextTitle\": string;\n                    \"aiContextDescriptionNoToken\": string;\n                    \"aiContextDescriptionWithToken\": string;\n                    \"generateToken\": string;\n                    \"confirmTitle\": string;\n                    \"confirmDescription\": string;\n                    \"scopeTableRead\": string;\n                    \"scopeFieldRead\": string;\n                    \"scopeRead\": string;\n                    \"scopeCreate\": string;\n                    \"scopeUpdate\": string;\n                    \"scopeDelete\": string;\n                    \"confirmExpiry\": string;\n                    \"confirmButton\": string;\n                    \"tokenInfo\": string;\n                    \"tokenCreatedSuccess\": string;\n                    \"copied\": string;\n                    \"copy\": string;\n                    \"copyAIDoc\": string;\n                    \"aiDocPreview\": string;\n                    \"manageToken\": string;\n                    \"openInNewTab\": string;\n                    \"advancedDesc\": string;\n                    \"openAdvanced\": string;\n                    \"queryBuilderTitle\": string;\n                    \"queryBuilderDesc\": string;\n                    \"viewApiDocs\": string;\n                };\n                \"personalView\": {\n                    \"personal\": string;\n                    \"tip\": string;\n                    \"collaborative\": string;\n                    \"dialog\": {\n                        \"title\": string;\n                        \"description\": string;\n                        \"cancelText\": string;\n                        \"confirmText\": string;\n                    };\n                };\n            };\n        };\n        \"welcome\": {\n            \"title\": string;\n            \"emptyTitle\": string;\n            \"description\": string;\n            \"help\": string;\n            \"helpCenter\": string;\n        };\n        \"validation\": {\n            \"link\": {\n                \"batch_duplicate\": string;\n                \"one_many_duplicate\": string;\n                \"one_one_duplicate\": string;\n            };\n            \"field\": {\n                \"maxColumnLimit\": string;\n            };\n        };\n        \"field\": {\n            \"fieldManagement\": string;\n            \"fieldManagementDesc\": string;\n            \"advancedProps\": string;\n            \"hide\": string;\n            \"default\": {\n                \"singleLineText\": {\n                    \"title\": string;\n                };\n                \"longText\": {\n                    \"title\": string;\n                };\n                \"number\": {\n                    \"title\": string;\n                    \"formatType\": string;\n                    \"currencySymbol\": string;\n                    \"defaultSymbol\": string;\n                    \"precision\": string;\n                    \"decimalExample\": string;\n                    \"currencyExample\": string;\n                    \"percentExample\": string;\n                    \"CurrencySymbol\": string;\n                    \"%Example\": string;\n                };\n                \"singleSelect\": {\n                    \"title\": string;\n                    \"options\": {\n                        \"todo\": string;\n                        \"inProgress\": string;\n                        \"done\": string;\n                    };\n                };\n                \"multipleSelect\": {\n                    \"title\": string;\n                };\n                \"attachment\": {\n                    \"title\": string;\n                };\n                \"user\": {\n                    \"title\": string;\n                };\n                \"date\": {\n                    \"title\": string;\n                    \"dateFormatting\": string;\n                    \"timeFormatting\": string;\n                    \"timeZone\": string;\n                    \"yearMonth\": string;\n                    \"monthDay\": string;\n                    \"year\": string;\n                    \"month\": string;\n                    \"day\": string;\n                    \"local\": string;\n                    \"friendly\": string;\n                    \"us\": string;\n                    \"european\": string;\n                    \"asia\": string;\n                    \"custom\": string;\n                    \"12Hour\": string;\n                    \"24Hour\": string;\n                    \"noDisplay\": string;\n                };\n                \"autoNumber\": {\n                    \"title\": string;\n                };\n                \"createdTime\": {\n                    \"title\": string;\n                };\n                \"lastModifiedTime\": {\n                    \"title\": string;\n                };\n                \"createdBy\": {\n                    \"title\": string;\n                };\n                \"lastModifiedBy\": {\n                    \"title\": string;\n                };\n                \"rating\": {\n                    \"title\": string;\n                };\n                \"checkbox\": {\n                    \"title\": string;\n                };\n                \"button\": {\n                    \"title\": string;\n                    \"label\": string;\n                    \"color\": string;\n                    \"limitCount\": string;\n                    \"resetCount\": string;\n                    \"maxCount\": string;\n                    \"automation\": string;\n                    \"customAutomation\": string;\n                    \"clickConfirm\": string;\n                    \"confirmTitle\": string;\n                    \"confirmDescription\": string;\n                    \"confirmButtonText\": string;\n                };\n                \"formula\": {\n                    \"title\": string;\n                    \"formula\": string;\n                };\n                \"lookup\": {\n                    \"title\": string;\n                };\n                \"conditionalLookup\": {\n                    \"title\": string;\n                };\n                \"rollup\": {\n                    \"title\": string;\n                    \"rollup\": string;\n                    \"selectAnRollupFunction\": string;\n                    \"func\": {\n                        \"and\": string;\n                        \"arrayCompact\": string;\n                        \"arrayJoin\": string;\n                        \"arrayUnique\": string;\n                        \"average\": string;\n                        \"concatenate\": string;\n                        \"count\": string;\n                        \"countA\": string;\n                        \"countAll\": string;\n                        \"max\": string;\n                        \"min\": string;\n                        \"or\": string;\n                        \"sum\": string;\n                        \"xor\": string;\n                    };\n                    \"funcDesc\": {\n                        \"and\": string;\n                        \"arrayCompact\": string;\n                        \"arrayJoin\": string;\n                        \"arrayUnique\": string;\n                        \"average\": string;\n                        \"concatenate\": string;\n                        \"count\": string;\n                        \"countA\": string;\n                        \"countAll\": string;\n                        \"max\": string;\n                        \"min\": string;\n                        \"or\": string;\n                        \"sum\": string;\n                        \"xor\": string;\n                    };\n                };\n                \"conditionalRollup\": {\n                    \"title\": string;\n                    \"description\": string;\n                };\n            };\n            \"editor\": {\n                \"addField\": string;\n                \"editField\": string;\n                \"insertField\": string;\n                \"graph\": string;\n                \"defaultValue\": string;\n                \"reset\": string;\n                \"fieldUpdated\": string;\n                \"fieldCreated\": string;\n                \"confirmFieldChange\": string;\n                \"areYouSurePerformIt\": string;\n                \"addDescription\": string;\n                \"dbFieldName\": string;\n                \"description\": string;\n                \"descriptionPlaceholder\": string;\n                \"type\": string;\n                \"showAs\": string;\n                \"color\": string;\n                \"number\": string;\n                \"chartBar\": string;\n                \"chartLine\": string;\n                \"ring\": string;\n                \"bar\": string;\n                \"text\": string;\n                \"markdown\": string;\n                \"url\": string;\n                \"email\": string;\n                \"phone\": string;\n                \"maxNumber\": string;\n                \"showNumber\": string;\n                \"autoFillDate\": string;\n                \"createSymmetricLink\": string;\n                \"allowLinkMultipleRecords\": string;\n                \"allowLinkToDuplicateRecords\": string;\n                \"allowSymmetricFieldLinkMultipleRecords\": string;\n                \"oneToOne\": string;\n                \"oneToMany\": string;\n                \"manyToOne\": string;\n                \"manyToMany\": string;\n                \"self\": string;\n                \"selectTable\": string;\n                \"selectBase\": string;\n                \"linkFromAnotherBase\": string;\n                \"inSelfLink\": string;\n                \"betweenTwoTables\": string;\n                \"tips\": string;\n                \"linkTipMessage\": string;\n                \"style\": string;\n                \"maximum\": string;\n                \"addOption\": string;\n                \"allowMultiUsers\": string;\n                \"notifyUsers\": string;\n                \"searchTable\": string;\n                \"calculating\": string;\n                \"doSaveChanges\": string;\n                \"linkFieldToLookup\": string;\n                \"lookupToTable\": string;\n                \"rollupToTable\": string;\n                \"selectField\": string;\n                \"linkTable\": string;\n                \"linkBase\": string;\n                \"tableNoPermission\": string;\n                \"baseNoPermission\": string;\n                \"noLinkTip\": string;\n                \"fieldValidationRules\": string;\n                \"enableValidateFieldUnique\": string;\n                \"enableValidateFieldNotNull\": string;\n                \"knowMore\": string;\n                \"linkFieldKnowMoreLink\": string;\n                \"showByField\": string;\n                \"filterByView\": string;\n                \"filter\": string;\n                \"hideFields\": string;\n                \"moreOptions\": string;\n                \"allowNewOptionsWhenEditing\": string;\n                \"deleteField\": {\n                    \"title\": string;\n                    \"simpleConfirm\": string;\n                    \"withDependencies\": string;\n                    \"affectedFields\": string;\n                    \"fieldsToDelete\": string;\n                    \"unviewedHint\": string;\n                    \"deleteCount\": string;\n                    \"noAffectedFields\": string;\n                    \"riskIdentified\": string;\n                    \"noDependencies\": string;\n                    \"safeToDelete\": string;\n                    \"safeToDeleteDesc\": string;\n                    \"affectedItems\": string;\n                    \"type\": string;\n                    \"source\": string;\n                    \"sourceTable\": string;\n                    \"typeField\": string;\n                };\n                \"conditionalLookup\": {\n                    \"sortLimitToggleLabel\": string;\n                    \"sortLabel\": string;\n                    \"orderPlaceholder\": string;\n                    \"clearSort\": string;\n                    \"limitLabel\": string;\n                    \"limitPlaceholder\": string;\n                    \"limitHint\": string;\n                    \"sortMissingWarningTitle\": string;\n                    \"sortMissingWarningDescription\": string;\n                };\n                \"lastModifiedScope\": string;\n                \"lastModifiedAll\": string;\n                \"lastModifiedSpecific\": string;\n                \"lastModifiedSelect\": string;\n                \"lastModifiedSelectAll\": string;\n                \"noEditableFields\": string;\n                \"conditionalRollup\": {\n                    \"fieldMapping\": string;\n                    \"selectBaseField\": string;\n                    \"noMappings\": string;\n                };\n            };\n            \"subTitle\": {\n                \"link\": string;\n                \"singleLineText\": string;\n                \"longText\": string;\n                \"attachment\": string;\n                \"checkbox\": string;\n                \"multipleSelect\": string;\n                \"singleSelect\": string;\n                \"user\": string;\n                \"date\": string;\n                \"number\": string;\n                \"duration\": string;\n                \"rating\": string;\n                \"formula\": string;\n                \"rollup\": string;\n                \"conditionalLookup\": string;\n                \"count\": string;\n                \"createdTime\": string;\n                \"lastModifiedTime\": string;\n                \"createdBy\": string;\n                \"lastModifiedBy\": string;\n                \"autoNumber\": string;\n                \"button\": string;\n                \"lookup\": string;\n                \"conditionalRollup\": string;\n            };\n            \"fieldName\": string;\n            \"fieldNameOptional\": string;\n            \"fieldType\": string;\n            \"aiConfig\": {\n                \"title\": string;\n                \"type\": {\n                    \"summary\": string;\n                    \"translation\": string;\n                    \"extraction\": string;\n                    \"improvement\": string;\n                    \"tag\": string;\n                    \"classification\": string;\n                    \"customization\": string;\n                    \"imageGeneration\": string;\n                    \"rating\": string;\n                };\n                \"label\": {\n                    \"type\": string;\n                    \"model\": string;\n                    \"targetLanguage\": string;\n                    \"sourceField\": string;\n                    \"sourceFieldForTag\": string;\n                    \"sourceFieldForClassify\": string;\n                    \"attachPrompt\": string;\n                    \"prompt\": string;\n                    \"sourceFieldForAttachment\": string;\n                    \"imageSize\": string;\n                    \"imageQuality\": string;\n                    \"imageCount\": string;\n                    \"aspectRatio\": string;\n                    \"resolution\": string;\n                    \"advancedSettings\": string;\n                };\n                \"placeholder\": {\n                    \"summarize\": string;\n                    \"translate\": string;\n                    \"extractInfo\": string;\n                    \"extractDate\": string;\n                    \"improveText\": string;\n                    \"attachPromptForTag\": string;\n                    \"attachPromptForClassify\": string;\n                    \"attachPrompt\": string;\n                    \"prompt\": string;\n                    \"type\": string;\n                    \"targetLanguage\": string;\n                    \"imageSize\": string;\n                    \"imageQuality\": string;\n                    \"attachPromptForImageGeneration\": string;\n                    \"attachPromptForRating\": string;\n                    \"aspectRatio\": string;\n                    \"resolution\": string;\n                };\n                \"imageQuality\": {\n                    \"low\": string;\n                    \"medium\": string;\n                    \"high\": string;\n                };\n                \"autoFill\": {\n                    \"title\": string;\n                    \"tip\": string;\n                };\n                \"autoFillFieldDialog\": {\n                    \"title\": string;\n                    \"description\": string;\n                };\n                \"autoFillConfirm\": {\n                    \"title\": string;\n                    \"description\": string;\n                    \"saveConfigOnly\": string;\n                    \"generate\": string;\n                    \"generateFailed\": string;\n                    \"generateMode\": string;\n                    \"emptyOnlyMode\": string;\n                    \"emptyOnlyModeDesc\": string;\n                    \"allMode\": string;\n                    \"allModeDesc\": string;\n                    \"saveOnlyMode\": string;\n                    \"saveOnlyModeDesc\": string;\n                    \"fillEmptyCells\": string;\n                    \"generateAll\": string;\n                    \"recommended\": string;\n                    \"taskLimited\": string;\n                    \"limitWarning\": string;\n                };\n                \"action\": {\n                    \"addAttachment\": string;\n                };\n                \"hint\": {\n                    \"imageInputSupported\": string;\n                    \"attachmentNotSupported\": string;\n                    \"singleImageOnly\": string;\n                };\n                \"auto\": string;\n                \"resolution\": {\n                    \"1K\": string;\n                    \"2K\": string;\n                    \"4K\": string;\n                };\n            };\n        };\n        \"table\": {\n            \"newTableLabel\": string;\n            \"rename\": string;\n            \"design\": string;\n            \"tableRecordHistory\": string;\n            \"deleteConfirm\": string;\n            \"dbTableName\": string;\n            \"schemaName\": string;\n            \"baseInfo\": string;\n            \"typeOfDatabase\": string;\n            \"descriptionForTable\": string;\n            \"nameForTable\": string;\n            \"deleteTip1\": string;\n            \"deleteTip2\": string;\n            \"operator\": {\n                \"createBlank\": string;\n            };\n            \"actionTips\": {\n                \"copyAndPasteEnvironment\": string;\n                \"copyAndPasteBrowser\": string;\n                \"copying\": string;\n                \"copySuccessful\": string;\n                \"copyFailed\": string;\n                \"pasting\": string;\n                \"pasteSuccessful\": string;\n                \"pasteFailed\": string;\n                \"filling\": string;\n                \"fillSuccessful\": string;\n                \"fillFailed\": string;\n                \"clearing\": string;\n                \"clearSuccessful\": string;\n                \"deleteFieldConfirmTitle\": string;\n                \"deleting\": string;\n                \"deleteSuccessful\": string;\n                \"pasteFileFailed\": string;\n                \"copyError\": {\n                    \"noFocus\": string;\n                    \"noPermission\": string;\n                };\n                \"clearConfirmTitle\": string;\n                \"clearConfirmDescription\": string;\n                \"deleteRecordConfirmTitle\": string;\n                \"deleteRecordConfirmDescription\": string;\n                \"pasteConfirmTitle\": string;\n                \"pasteConfirmDescription\": string;\n                \"expandCommonDescription\": string;\n                \"expandColDescription\": string;\n                \"expandRowDescription\": string;\n                \"paste\": string;\n                \"deleteRecord\": string;\n                \"clear\": string;\n                \"conjunction\": string;\n                \"pasing\": string;\n            };\n            \"graph\": {\n                \"tableLabel\": string;\n                \"effectCells\": string;\n                \"estimatedTime\": string;\n                \"linkFieldCount\": string;\n            };\n            \"integrity\": {\n                \"check\": string;\n                \"title\": string;\n                \"loading\": string;\n                \"allGood\": string;\n                \"fixIssues\": string;\n                \"type\": string;\n                \"message\": string;\n                \"errorType\": {\n                    \"ForeignTableNotFound\": string;\n                    \"ForeignKeyNotFound\": string;\n                    \"SelfKeyNotFound\": string;\n                    \"SymmetricFieldNotFound\": string;\n                    \"MissingRecordReference\": string;\n                    \"InvalidLinkReference\": string;\n                    \"ForeignKeyHostTableNotFound\": string;\n                    \"ReferenceFieldNotFound\": string;\n                    \"UniqueIndexNotFound\": string;\n                    \"EmptyString\": string;\n                };\n            };\n            \"index\": {\n                \"description\": string;\n                \"repair\": string;\n                \"repairTip\": string;\n                \"enableIndexTip\": string;\n                \"globalSearchTip_limited\": string;\n                \"globalSearchTip_infinity\": string;\n                \"autoIndexTip\": string;\n                \"enableIndex\": string;\n                \"keepAsIs\": string;\n                \"ignoreIndexError\": string;\n            };\n            \"searchTips\": {\n                \"maxFieldTips_limited\": string;\n            };\n            \"tableInfo\": string;\n            \"tableInfoDetail\": string;\n        };\n        \"import\": {\n            \"title\": {\n                \"upload\": string;\n                \"import\": string;\n                \"localFile\": string;\n                \"linkUrl\": string;\n                \"linkUrlInputTitle\": string;\n                \"importTitle\": string;\n                \"incrementImportTitle\": string;\n                \"optionsTitle\": string;\n                \"primitiveFields\": string;\n                \"importFields\": string;\n                \"primaryField\": string;\n                \"tipsTitle\": string;\n                \"confirm\": string;\n            };\n            \"menu\": {\n                \"addFromOtherSource\": string;\n                \"excelFile\": string;\n                \"csvFile\": string;\n                \"importCsvData\": string;\n                \"importExcelData\": string;\n                \"cancel\": string;\n                \"leave\": string;\n                \"downAsCsv\": string;\n                \"importData\": string;\n                \"duplicate\": string;\n                \"duplicating\": string;\n                \"duplicateSuccess\": string;\n                \"duplicateFailed\": string;\n                \"importing\": string;\n                \"includeRecords\": string;\n                \"autoFill\": string;\n            };\n            \"tips\": {\n                \"importWayTip\": string;\n                \"leaveTip\": string;\n                \"fileExceedSizeTip\": string;\n                \"analyzing\": string;\n                \"importing\": string;\n                \"notSupportFieldType\": string;\n                \"resultEmpty\": string;\n                \"searchPlaceholder\": string;\n                \"importAlert\": string;\n                \"noTips\": string;\n            };\n            \"options\": {\n                \"autoSelectFieldOptionName\": string;\n                \"useFirstRowAsHeaderOptionName\": string;\n                \"importDataOptionName\": string;\n                \"sheetKey\": string;\n                \"excludeFirstRow\": string;\n            };\n            \"form\": {\n                \"defaultFieldName\": string;\n                \"error\": {\n                    \"urlEmptyTip\": string;\n                    \"errorFileFormat\": string;\n                    \"uniqueFieldName\": string;\n                    \"fieldNameEmpty\": string;\n                    \"atLeastAImportField\": string;\n                    \"urlValidateTip\": string;\n                };\n                \"option\": {\n                    \"doNotImport\": string;\n                };\n            };\n        };\n        \"export\": {\n            \"menu\": {\n                \"exportCsv\": string;\n            };\n        };\n        \"grid\": {\n            \"prefillingRowTitle\": string;\n            \"prefillingRowTooltip\": string;\n            \"presortRowTitle\": string;\n        };\n        \"form\": {\n            \"fieldsManagement\": string;\n            \"addAll\": string;\n            \"removeAll\": string;\n            \"hideFieldTip\": string;\n            \"unableAddFieldTip\": string;\n            \"removeFromFormTip\": string;\n            \"descriptionPlaceholder\": string;\n            \"dragToFormTip\": string;\n            \"protectedFieldTip\": string;\n        };\n        \"kanban\": {\n            \"toolbar\": {\n                \"hideFieldName\": string;\n                \"customizeCards\": string;\n                \"stackedBy\": string;\n                \"chooseStackingField\": string;\n                \"chooseStackingFieldDescription\": string;\n                \"hideEmptyStack\": string;\n                \"imageSetting\": string;\n                \"fit\": string;\n                \"noImage\": string;\n                \"chooseAttachmentField\": string;\n            };\n            \"stack\": {\n                \"addStack\": string;\n                \"noCards\": string;\n                \"uncategorized\": string;\n            };\n            \"stackMenu\": {\n                \"collapseStack\": string;\n                \"renameStack\": string;\n                \"deleteStack\": string;\n            };\n            \"cardMenu\": {\n                \"insertCardAbove\": string;\n                \"insertCardBelow\": string;\n                \"expandCard\": string;\n                \"deleteCard\": string;\n                \"duplicateCard\": string;\n            };\n            \"\\u043F\\u0430\\u043D\\u0435\\u043B\\u044C \\u0456\\u043D\\u0441\\u0442\\u0440\\u0443\\u043C\\u0435\\u043D\\u0442\\u0456\\u0432\": {\n                \"hideFieldName\": string;\n                \"customizeCards\": string;\n                \"stackedBy\": string;\n                \"chooseStackingField\": string;\n                \"chooseStackingFieldDescription\": string;\n                \"hideEmptyStack\": string;\n                \"imageSetting\": string;\n                \"fit\": string;\n                \"noImage\": string;\n                \"chooseAttachmentField\": string;\n            };\n            \"\\u0441\\u0442\\u0435\\u043A\": {\n                \"addStack\": string;\n                \"noCards\": string;\n                \"uncategorized\": string;\n            };\n        };\n        \"calendar\": {\n            \"toolbar\": {\n                \"config\": string;\n                \"startDateField\": string;\n                \"endDateField\": string;\n                \"titleField\": string;\n                \"colorField\": string;\n                \"colorType\": string;\n                \"customColor\": string;\n                \"alignWithRecords\": string;\n                \"ColorField\": string;\n            };\n            \"placeholder\": {\n                \"selectColorField\": string;\n            };\n            \"dialog\": {\n                \"startDate\": string;\n                \"endDate\": string;\n                \"notAdd\": string;\n                \"addDateField\": string;\n                \"content\": string;\n            };\n            \"moreLinkText\": string;\n        };\n        \"menu\": {\n            \"insertRecordAbove\": string;\n            \"insertRecordBelow\": string;\n            \"copyCells\": string;\n            \"deleteRecord\": string;\n            \"deleteAllSelectedRecords\": string;\n            \"editField\": string;\n            \"insertFieldLeft\": string;\n            \"insertFieldRight\": string;\n            \"freezeUpField\": string;\n            \"hideField\": string;\n            \"deleteField\": string;\n            \"deleteAllSelectedFields\": string;\n            \"filterField\": string;\n            \"sortField\": string;\n            \"groupField\": string;\n            \"autoFill\": string;\n            \"groupMenuTitle\": string;\n            \"expandGroup\": string;\n            \"collapseGroup\": string;\n            \"expandAllGroups\": string;\n            \"collapseAllGroups\": string;\n            \"addToChat\": string;\n            \"duplicateField\": string;\n            \"downloadAllAttachments\": string;\n        };\n        \"connection\": {\n            \"title\": string;\n            \"description\": string;\n            \"noPermission\": string;\n            \"connectionCountTip\": string;\n            \"createFailed\": string;\n            \"helpLink\": string;\n        };\n        \"view\": {\n            \"addRecord\": string;\n            \"searchView\": string;\n            \"dragToolTip\": string;\n            \"insertToolTip\": string;\n            \"action\": {\n                \"rename\": string;\n                \"duplicate\": string;\n                \"delete\": string;\n                \"lock\": string;\n                \"unlock\": string;\n                \"enable\": string;\n            };\n            \"category\": {\n                \"table\": string;\n                \"form\": string;\n                \"kanban\": string;\n                \"gallery\": string;\n                \"calendar\": string;\n            };\n            \"crash\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"addPluginView\": string;\n            \"search\": {\n                \"field_one\": string;\n                \"field_other\": string;\n            };\n            \"locked\": {\n                \"tip\": string;\n            };\n            \"noView\": string;\n        };\n        \"lastModifiedTime\": string;\n        \"lastModify\": string;\n        \"pasteNewRecords\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"tableTrash\": {\n            \"title\": string;\n            \"resourceType\": string;\n            \"deletedResource\": string;\n        };\n        \"baseShare\": {\n            \"title\": string;\n            \"shareTitle\": string;\n            \"shareToWeb\": string;\n            \"description\": string;\n            \"nodeShareDescription\": string;\n            \"shareLinks\": string;\n            \"newLink\": string;\n            \"noShareLinks\": string;\n            \"createFirstLink\": string;\n            \"editSettings\": string;\n            \"refreshLink\": string;\n            \"deleteLink\": string;\n            \"deleteConfirmTitle\": string;\n            \"deleteConfirmDescription\": string;\n            \"createSuccess\": string;\n            \"createFailed\": string;\n            \"updateSuccess\": string;\n            \"updateFailed\": string;\n            \"deleteSuccess\": string;\n            \"deleteFailed\": string;\n            \"refreshSuccess\": string;\n            \"refreshFailed\": string;\n            \"copied\": string;\n            \"shareLink\": string;\n            \"linkHolderLabel\": string;\n            \"linkHolderCanView\": string;\n            \"linkHolderCanEdit\": string;\n            \"linkHolderCanCopyAndSave\": string;\n            \"passwordProtection\": string;\n            \"enterPassword\": string;\n            \"selectNodes\": string;\n            \"shareEntireBase\": string;\n            \"shareSelectedNodes\": string;\n            \"shareEntireBaseDescription\": string;\n            \"noNodesSelectedWarning\": string;\n            \"allowSave\": string;\n            \"allowSaveDescription\": string;\n            \"allowCopy\": string;\n            \"allowCopyData\": string;\n            \"allowDuplicate\": string;\n            \"allowCopyDescription\": string;\n            \"selectedNodes\": string;\n            \"allNodes\": string;\n            \"sharedNode\": string;\n            \"sharedNodeDescription\": string;\n            \"publicShareTitle\": string;\n            \"publicShareCount\": string;\n            \"noPublicShare\": string;\n            \"security\": string;\n            \"restrictByPassword\": string;\n            \"advanced\": string;\n            \"embedConfig\": string;\n            \"appPublicLink\": string;\n            \"appNotPublished\": string;\n            \"goToPublish\": string;\n            \"publishSuccess\": string;\n            \"publishFailed\": string;\n            \"openLink\": string;\n            \"appPublished\": string;\n            \"shareTableTab\": string;\n            \"shareViewTab\": string;\n            \"shareNodeTab\": string;\n        };\n        \"aiChat\": {\n            \"tool\": {\n                \"getTableFields\": string;\n                \"getTablesMeta\": string;\n                \"sqlQuery\": string;\n                \"generateScriptAction\": string;\n                \"getScriptInput\": string;\n                \"getTeableApi\": string;\n                \"dataVisualization\": string;\n                \"updateBase\": string;\n                \"args\": string;\n                \"result\": string;\n                \"thinking\": string;\n                \"toBeConfirmed\": string;\n                \"errorMessage\": string;\n                \"confirm\": string;\n                \"createRecordsSuccess\": string;\n                \"createRecordsFailed\": string;\n                \"updateRecordsSuccess\": string;\n                \"updateRecordsFailed\": string;\n                \"generatingRecords\": string;\n                \"creatingRecords\": string;\n                \"updatingRecords\": string;\n                \"recordsPreview\": string;\n                \"andMoreRecords\": string;\n                \"unknownError\": string;\n                \"recordIds\": string;\n                \"records\": string;\n                \"viewAll\": string;\n                \"showLess\": string;\n                \"generatingData\": string;\n                \"generatingUpdates\": string;\n                \"recordsGenerated\": string;\n                \"recordsCount\": string;\n                \"fieldsCount\": string;\n                \"fieldsGenerated\": string;\n                \"updatedProperties\": string;\n                \"configured\": string;\n                \"recordsToUpdate\": string;\n                \"showingLast\": string;\n                \"recordLabel\": string;\n                \"statusGenerating\": string;\n                \"statusCreating\": string;\n                \"statusUpdating\": string;\n                \"statusCreated\": string;\n                \"statusUpdated\": string;\n                \"getApps\": {\n                    \"title\": string;\n                    \"loading\": string;\n                    \"foundApps\": string;\n                    \"noApps\": string;\n                    \"openApp\": string;\n                };\n                \"generateApp\": {\n                    \"title\": string;\n                    \"creatingApp\": string;\n                    \"updatingApp\": string;\n                    \"generatingApp\": string;\n                    \"generating\": string;\n                    \"openApp\": string;\n                    \"viewProgress\": string;\n                    \"newApp\": string;\n                    \"building\": string;\n                };\n                \"generateAutomation\": {\n                    \"title\": string;\n                    \"creatingAutomation\": string;\n                    \"updatingAutomation\": string;\n                    \"generatingAutomation\": string;\n                    \"building\": string;\n                    \"openAutomation\": string;\n                    \"viewProgress\": string;\n                    \"testResults\": string;\n                    \"triggerTest\": string;\n                    \"actionTest\": string;\n                };\n                \"htmlPreview\": {\n                    \"preview\": string;\n                    \"code\": string;\n                    \"download\": string;\n                    \"downloadHtml\": string;\n                    \"downloadImage\": string;\n                    \"copy\": string;\n                    \"copied\": string;\n                    \"fullscreen\": string;\n                    \"exitFullscreen\": string;\n                    \"downloadSuccess\": string;\n                    \"downloadFailed\": string;\n                    \"iframeFailed\": string;\n                };\n                \"loadAttachment\": {\n                    \"title\": string;\n                    \"loading\": string;\n                    \"failed\": string;\n                    \"empty\": string;\n                    \"modeNative\": string;\n                    \"modeNativeDesc\": string;\n                    \"modeExtracted\": string;\n                    \"modeExtractedDesc\": string;\n                    \"visionLoaded\": string;\n                    \"pdfLoaded\": string;\n                    \"textExtracted\": string;\n                    \"contextLoaded\": string;\n                    \"truncated\": string;\n                    \"preview\": string;\n                };\n                \"textExtract\": {\n                    \"title\": string;\n                    \"loading\": string;\n                    \"failed\": string;\n                    \"empty\": string;\n                    \"preview\": string;\n                    \"truncated\": string;\n                    \"previews\": string;\n                    \"chars\": string;\n                    \"totalCharacters\": string;\n                    \"filesTruncated\": string;\n                };\n                \"importExcel\": {\n                    \"title\": string;\n                    \"loading\": string;\n                    \"failed\": string;\n                    \"suggestions\": string;\n                    \"analyzeComplete\": string;\n                    \"worksheets\": string;\n                    \"columns\": string;\n                    \"importComplete\": string;\n                    \"stageAnalyze\": string;\n                    \"stageImport\": string;\n                };\n            };\n            \"tools\": {\n                \"getTeableApi\": string;\n                \"readFiles\": string;\n                \"writeFile\": string;\n                \"deleteFiles\": string;\n                \"listFiles\": string;\n                \"addDependencies\": string;\n                \"checkBuildErrors\": string;\n                \"lint\": string;\n            };\n            \"fallback\": {\n                \"previewLoadFailed\": string;\n                \"retry\": string;\n                \"chatAborted\": string;\n            };\n            \"preview\": {\n                \"deletedTable\": string;\n                \"deletedView\": string;\n                \"deletedField\": string;\n                \"deletedRecords\": string;\n            };\n            \"agentName\": {\n                \"tableOperatorAgent\": string;\n                \"viewOperatorAgent\": string;\n                \"fieldOperatorAgent\": string;\n                \"recordOperatorAgent\": string;\n                \"buildBaseAgent\": string;\n                \"buildAutomationAgent\": string;\n            };\n            \"confirm\": {\n                \"toBeConfirmed\": string;\n                \"deleteWarning\": string;\n            };\n            \"action\": {\n                \"createTable\": string;\n                \"updateTable\": string;\n                \"updateTableName\": string;\n                \"deleteTable\": string;\n                \"createView\": string;\n                \"updateView\": string;\n                \"updateViewName\": string;\n                \"deleteView\": string;\n                \"createField\": string;\n                \"createAiField\": string;\n                \"createLinkField\": string;\n                \"createLookupField\": string;\n                \"createRollupField\": string;\n                \"createFormulaField\": string;\n                \"deleteField\": string;\n                \"updateField\": string;\n                \"createRecord\": string;\n                \"createRecords\": string;\n                \"deleteRecord\": string;\n                \"updateRecord\": string;\n                \"updateRecords\": string;\n                \"updateBase\": string;\n                \"planTask\": string;\n                \"generateTables\": string;\n                \"generatePrimaryFields\": string;\n                \"generateFields\": string;\n                \"generateViews\": string;\n                \"generateRecords\": string;\n                \"generateAIFields\": string;\n                \"generateLinkFields\": string;\n                \"generateLookupFields\": string;\n                \"generateRollupFields\": string;\n                \"generateFormulaFields\": string;\n                \"generateWorkflow\": string;\n                \"generateTrigger\": string;\n                \"generateScriptAction\": string;\n                \"generateSendMailAction\": string;\n                \"generateAction\": string;\n                \"setupAutomationTrigger\": string;\n                \"testAutomationNode\": string;\n                \"activateAutomation\": string;\n                \"executeScript\": string;\n                \"wait\": string;\n                \"generateScriptFlowChart\": string;\n                \"triggerAiFill\": string;\n                \"initialize\": string;\n                \"rename\": string;\n                \"buildTest\": string;\n                \"developTask\": string;\n                \"generateSummary\": string;\n                \"previewEnvironment\": string;\n                \"getRelativeData\": string;\n                \"getPreviousNodeOutputVariables\": string;\n                \"getApiJson\": string;\n                \"generateScriptAndDependencies\": string;\n                \"analyzingAttachment\": string;\n                \"locateResource\": string;\n                \"goTo\": string;\n                \"operationSuccess\": string;\n                \"operationFailed\": string;\n                \"deleteAutomationNode\": string;\n            };\n            \"aiFill\": {\n                \"processedRecords\": string;\n            };\n            \"queryTool\": {\n                \"getRecords\": string;\n                \"getRecordsWithTable\": string;\n                \"getGridRows\": string;\n                \"getGridRowsWithTable\": string;\n                \"getFields\": string;\n                \"getFieldsWithTable\": string;\n                \"getTables\": string;\n                \"getViews\": string;\n                \"getViewsWithTable\": string;\n                \"sqlQuery\": string;\n                \"querying\": string;\n                \"queryFailed\": string;\n                \"aborted\": string;\n                \"noData\": string;\n                \"dataFormatError\": string;\n                \"unsupportedQueryType\": string;\n                \"returnedRecords\": string;\n                \"record\": string;\n                \"moreRecords\": string;\n                \"foundFields\": string;\n                \"moreFields\": string;\n                \"foundTables\": string;\n                \"moreTables\": string;\n                \"foundViews\": string;\n                \"moreViews\": string;\n                \"queryReturned\": string;\n                \"row\": string;\n                \"moreRows\": string;\n                \"getDoc\": string;\n                \"getDocWithTopic\": string;\n                \"getAutomations\": string;\n                \"getAutomation\": string;\n                \"getAutomationRuns\": string;\n                \"foundAutomations\": string;\n                \"moreAutomations\": string;\n                \"foundRuns\": string;\n                \"moreRuns\": string;\n                \"active\": string;\n                \"trigger\": string;\n                \"actions\": string;\n                \"moreActions\": string;\n                \"getUserIntegrations\": string;\n                \"connectedIntegrations\": string;\n                \"availableToConnect\": string;\n                \"connect\": string;\n                \"noIntegrationsAvailable\": string;\n                \"activateTool\": string;\n                \"webSearch\": string;\n                \"webSearchResults\": string;\n                \"webSearchCompleted\": string;\n                \"searchApi\": string;\n                \"searchApiWithQuery\": string;\n                \"noApiFound\": string;\n                \"foundApis\": string;\n                \"totalApis\": string;\n                \"callApi\": string;\n                \"callApiWithMethod\": string;\n                \"response\": string;\n                \"success\": string;\n                \"failed\": string;\n                \"inputData\": string;\n                \"availableNodes\": string;\n                \"hasPreviousCode\": string;\n                \"noInputData\": string;\n            };\n            \"showUI\": {\n                \"connect\": string;\n                \"connecting\": string;\n                \"connected\": string;\n                \"connectToUse\": string;\n                \"checkingConnection\": string;\n                \"confirm\": string;\n                \"confirmed\": string;\n                \"cancel\": string;\n                \"cancelled\": string;\n                \"connectionCancelled\": string;\n            };\n            \"codeBlock\": {\n                \"hiddenLines\": string;\n                \"collapseCode\": string;\n                \"code\": string;\n                \"preview\": string;\n            };\n            \"buildFlow\": {\n                \"progress\": string;\n                \"completed\": string;\n                \"completedDesc\": string;\n                \"stepStatus\": {\n                    \"initializing\": string;\n                    \"naming\": string;\n                    \"planning\": string;\n                    \"developing\": string;\n                    \"summarizing\": string;\n                    \"deploying\": string;\n                    \"testing\": string;\n                };\n                \"moduleStatus\": {\n                    \"running\": string;\n                    \"completed\": string;\n                    \"error\": string;\n                    \"pending\": string;\n                };\n                \"toolStatus\": {\n                    \"running\": string;\n                    \"completed\": string;\n                    \"error\": string;\n                };\n            };\n            \"generateScript\": {\n                \"generateSuccess\": string;\n            };\n            \"buildBase\": {\n                \"title\": string;\n                \"generateSuccess\": string;\n                \"generateError\": string;\n            };\n            \"buildAutomation\": {\n                \"title\": string;\n                \"generateSuccess\": string;\n            };\n            \"automation\": {\n                \"created\": string;\n                \"updated\": string;\n                \"workflow\": string;\n                \"trigger\": string;\n                \"scriptAction\": string;\n                \"workflowLabel\": string;\n                \"triggerLabel\": string;\n                \"scriptActionLabel\": string;\n                \"workflowId\": string;\n                \"triggerId\": string;\n                \"scriptActionId\": string;\n                \"viewAutomation\": string;\n                \"navigateToAutomation\": string;\n                \"triggerType\": {\n                    \"recordCreated\": string;\n                    \"recordUpdated\": string;\n                    \"recordCreatedOrUpdated\": string;\n                    \"formSubmitted\": string;\n                    \"scheduledTime\": string;\n                    \"buttonClick\": string;\n                };\n                \"activated\": string;\n                \"deactivated\": string;\n                \"discarded\": string;\n                \"activateFailed\": string;\n                \"deactivateFailed\": string;\n                \"discardFailed\": string;\n                \"scriptUpdated\": string;\n                \"scriptUpdateFailed\": string;\n                \"scriptExecuted\": string;\n                \"scriptExecutionFailed\": string;\n                \"scriptReady\": string;\n                \"executingScript\": string;\n                \"waitedSeconds\": string;\n                \"waitFailed\": string;\n                \"flowchartGenerated\": string;\n                \"flowchartGenerationFailed\": string;\n            };\n            \"newChat\": string;\n            \"clearChat\": string;\n            \"expand\": string;\n            \"history\": string;\n            \"close\": string;\n            \"clearChatConfirmTitle\": string;\n            \"clearChatConfirmDesc\": string;\n            \"dontShowAgain\": string;\n            \"noModel\": string;\n            \"addAttachment\": string;\n            \"noHistory\": string;\n            \"noFoundHistory\": string;\n            \"timeGroup\": {\n                \"today\": string;\n                \"oneWeek\": string;\n                \"twoWeek\": string;\n                \"oneMonth\": string;\n                \"other\": string;\n            };\n            \"context\": {\n                \"button\": string;\n                \"search\": string;\n                \"searchEmpty\": string;\n                \"emptyContext\": string;\n                \"selectionRows\": string;\n            };\n            \"inputPlaceholder\": string;\n            \"thought\": string;\n            \"meta\": {\n                \"timeCostUnit\": string;\n                \"timeCostDescription\": string;\n                \"creditDescription\": string;\n                \"tokenDescription\": string;\n                \"input\": string;\n                \"output\": string;\n                \"tokens\": string;\n                \"totalTimeCost\": string;\n                \"totalCreditCost\": string;\n                \"customModel\": string;\n                \"tokenDetails\": string;\n                \"cachedInput\": string;\n                \"cacheWrite\": string;\n                \"reasoning\": string;\n                \"taskCompleted\": string;\n            };\n            \"dataVisualization\": {\n                \"error\": string;\n            };\n            \"tips\": {\n                \"modelTips\": string;\n            };\n            \"attachment\": {\n                \"imageNotSupported\": string;\n                \"attachmentSizeExceeded\": string;\n            };\n            \"suggestions\": {\n                \"recommend\": string;\n                \"ask\": string;\n                \"analyze\": string;\n                \"build\": string;\n                \"title\": string;\n                \"whatCanIDo\": string;\n                \"createOrModifyDatabase\": string;\n                \"buildAutomations\": string;\n                \"buildApps\": string;\n                \"buildMeCRM\": string;\n                \"addAIField\": string;\n                \"createDataAnalysis\": string;\n                \"emailWhenRecordCreated\": string;\n                \"syncStatusToSlack\": string;\n                \"buildDashboard\": string;\n                \"buildLeadCapture\": string;\n            };\n            \"buildApp\": {\n                \"thinking\": {\n                    \"duration\": string;\n                };\n                \"task\": {\n                    \"searching\": string;\n                    \"readingFiles\": string;\n                    \"foundResults\": string;\n                    \"noIssuesFound\": string;\n                    \"defaultTitle\": string;\n                };\n                \"codeProject\": {\n                    \"defaultTitle\": string;\n                };\n            };\n            \"scriptPreview\": {\n                \"aiModelRequired\": string;\n                \"writeCodeHint\": string;\n                \"noPreview\": string;\n                \"generatePreview\": string;\n                \"analyzing\": string;\n                \"codeChanged\": string;\n                \"regenerate\": string;\n                \"refresh\": string;\n                \"regenerating\": string;\n            };\n        };\n        \"download\": {\n            \"allAttachments\": {\n                \"title\": string;\n                \"loading\": string;\n                \"rowsWithAttachments\": string;\n                \"totalAttachments\": string;\n                \"totalSize\": string;\n                \"startDownload\": string;\n                \"confirmTitle\": string;\n                \"confirmDescription\": string;\n                \"confirm\": string;\n                \"cancel\": string;\n                \"downloading\": string;\n                \"downloadingFile\": string;\n                \"progress\": string;\n                \"completed\": string;\n                \"cancelled\": string;\n                \"noAttachments\": string;\n                \"error\": string;\n                \"errorPartial\": string;\n                \"requireHttps\": string;\n                \"advancedOptions\": string;\n                \"namingFieldLabel\": string;\n                \"selectField\": string;\n                \"groupByRow\": string;\n                \"groupByRowTip\": string;\n            };\n        };\n        \"plugin\": {\n            \"recent\": string;\n            \"more\": string;\n        };\n        \"pluginPanel\": {\n            \"empty\": {\n                \"description\": string;\n            };\n            \"createPluginPanel\": {\n                \"button\": string;\n                \"title\": string;\n            };\n            \"namePlaceholder\": string;\n        };\n        \"addPlugin\": string;\n        \"pluginContextMenu\": {\n            \"mangeButton\": string;\n            \"manage\": string;\n            \"noPlugin\": string;\n            \"delete\": string;\n            \"deleteDescription\": string;\n        };\n        \"permission\": {\n            \"cell\": {\n                \"deniedRead\": string;\n                \"deniedUpdate\": string;\n            };\n        };\n        \"upload\": {\n            \"panelUploading\": string;\n            \"panelFailed\": string;\n            \"panelCompleted\": string;\n            \"statusFailed\": string;\n            \"statusCompleted\": string;\n            \"statusCancel\": string;\n            \"statusRetry\": string;\n        };\n    };\n    \"token\": {\n        \"access\": string;\n        \"name\": string;\n        \"description\": string;\n        \"scopes\": string;\n        \"expiration\": string;\n        \"createdTime\": string;\n        \"lastUse\": string;\n        \"allSpace\": string;\n        \"formLabelTips\": {\n            \"name\": string;\n            \"description\": string;\n            \"scopes\": string;\n            \"access\": string;\n        };\n        \"new\": {\n            \"headerTitle\": string;\n            \"title\": string;\n            \"description\": string;\n            \"button\": string;\n            \"success\": {\n                \"title\": string;\n                \"description\": string;\n            };\n            \"expirationList\": {\n                \"days\": string;\n                \"permanent\": string;\n                \"custom\": string;\n                \"pick\": string;\n            };\n        };\n        \"edit\": {\n            \"title\": string;\n            \"name\": string;\n            \"scopes\": string;\n            \"selectAll\": string;\n            \"cancelSelectAll\": string;\n        };\n        \"refresh\": {\n            \"title\": string;\n            \"description\": string;\n            \"button\": string;\n        };\n        \"accessSelect\": {\n            \"button\": string;\n            \"empty\": string;\n            \"spaceSelectItem\": string;\n            \"inputPlaceholder\": string;\n            \"fullAccess\": {\n                \"button\": string;\n                \"description\": string;\n                \"title\": string;\n            };\n            \"sharedBase\": string;\n        };\n        \"moreScopes\": string;\n        \"list\": {\n            \"description\": string;\n        };\n        \"empty\": {\n            \"list\": string;\n            \"access\": string;\n        };\n        \"deleteConfirm\": {\n            \"title\": string;\n            \"description\": string;\n        };\n        \"help\": {\n            \"link\": string;\n        };\n        \"noAccessConfirm\": {\n            \"title\": string;\n            \"description\": string;\n        };\n    };\n    \"zod\": {\n        \"errors\": {\n            \"invalid_type\": string;\n            \"invalid_type_received_undefined\": string;\n            \"invalid_type_received_null\": string;\n            \"invalid_literal\": string;\n            \"unrecognized_keys\": string;\n            \"invalid_union\": string;\n            \"invalid_union_discriminator\": string;\n            \"invalid_enum_value\": string;\n            \"invalid_arguments\": string;\n            \"invalid_return_type\": string;\n            \"invalid_date\": string;\n            \"custom\": string;\n            \"invalid_intersection_types\": string;\n            \"not_multiple_of\": string;\n            \"not_finite\": string;\n            \"invalid_string\": {\n                \"email\": string;\n                \"url\": string;\n                \"uuid\": string;\n                \"cuid\": string;\n                \"regex\": string;\n                \"datetime\": string;\n                \"startsWith\": string;\n                \"endsWith\": string;\n            };\n            \"too_small\": {\n                \"array\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"string\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"number\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"set\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"date\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n            };\n            \"too_big\": {\n                \"array\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"string\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"number\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"set\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n                \"date\": {\n                    \"exact\": string;\n                    \"inclusive\": string;\n                    \"not_inclusive\": string;\n                };\n            };\n        };\n        \"validations\": {\n            \"email\": string;\n            \"url\": string;\n            \"uuid\": string;\n            \"cuid\": string;\n            \"regex\": string;\n            \"datetime\": string;\n        };\n        \"types\": {\n            \"function\": string;\n            \"number\": string;\n            \"string\": string;\n            \"nan\": string;\n            \"integer\": string;\n            \"float\": string;\n            \"boolean\": string;\n            \"date\": string;\n            \"bigint\": string;\n            \"undefined\": string;\n            \"symbol\": string;\n            \"null\": string;\n            \"array\": string;\n            \"object\": string;\n            \"unknown\": string;\n            \"promise\": string;\n            \"void\": string;\n            \"never\": string;\n            \"map\": string;\n            \"set\": string;\n        };\n    };\n};\n/* prettier-ignore */\nexport type I18nPath = Path<I18nTranslations>;\n"
  },
  {
    "path": "apps/nestjs-backend/src/types/redlock.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module 'redlock' {\n  export class ExecutionError extends Error {\n    attempts: any[];\n  }\n\n  export class ResourceLockedError extends ExecutionError {}\n\n  export interface RedlockAbortSignal {\n    readonly aborted: boolean;\n    readonly error?: Error;\n  }\n\n  export interface Settings {\n    driftFactor?: number;\n    retryCount?: number;\n    retryDelay?: number;\n    retryJitter?: number;\n    automaticExtensionThreshold?: number;\n  }\n\n  export interface ExecutionResult<T> {\n    attempts: any[];\n    value?: T;\n  }\n\n  export class Lock {\n    readonly resources: string[];\n    readonly expiration: number;\n  }\n\n  export default class Redlock {\n    constructor(clients: any[], settings?: Settings);\n\n    acquire(resources: string[], duration: number, settings?: Partial<Settings>): Promise<Lock>;\n\n    release(lock: Lock, settings?: Partial<Settings>): Promise<ExecutionResult<void>>;\n\n    extend(lock: Lock, duration: number, settings?: Partial<Settings>): Promise<Lock>;\n\n    using<T>(\n      resources: string[],\n      duration: number,\n      routine: (signal: RedlockAbortSignal) => Promise<T>,\n      settings?: Partial<Settings>\n    ): Promise<T>;\n\n    on(event: string, listener: (...args: any[]) => void): this;\n\n    quit(): Promise<void>;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/types/session.ts",
    "content": "import type { SessionData } from 'express-session';\n\nexport interface ISessionData extends SessionData {\n  passport: {\n    user: {\n      id: string;\n    };\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/code-generate.ts",
    "content": "import { createHmac } from 'crypto';\nimport { baseConfig } from '../configs/base.config';\n\nexport const generateInvitationCode = (invitationId: string) => {\n  const hmac = createHmac('sha256', baseConfig().secretKey);\n  return hmac.update(invitationId).digest('hex');\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/convert-view-vo-attachment-url.ts",
    "content": "import type { IFormViewOptions, IPluginViewOptions, IViewVo } from '@teable/core';\nimport { ViewType } from '@teable/core';\nimport { getPublicFullStorageUrl } from '../features/attachments/plugins/utils';\n\nexport const convertViewVoAttachmentUrl = (viewVo: IViewVo) => {\n  if (viewVo.type === ViewType.Form) {\n    const formOptions = viewVo.options as IFormViewOptions;\n    formOptions?.coverUrl &&\n      (formOptions.coverUrl = formOptions.coverUrl\n        ? getPublicFullStorageUrl(formOptions.coverUrl)\n        : undefined);\n    formOptions?.logoUrl &&\n      (formOptions.logoUrl = formOptions.logoUrl\n        ? getPublicFullStorageUrl(formOptions.logoUrl)\n        : undefined);\n  }\n  if (viewVo.type === ViewType.Plugin) {\n    const pluginOptions = viewVo.options as IPluginViewOptions;\n    pluginOptions.pluginLogo = getPublicFullStorageUrl(pluginOptions.pluginLogo);\n  }\n  return viewVo;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/date-to-iso.ts",
    "content": "export const dateToIso = <T extends object>(obj: T) => {\n  return Object.fromEntries(\n    Object.entries(obj).map(([key, value]) => [\n      key,\n      value instanceof Date ? value.toISOString() : value,\n    ])\n  ) as {\n    [K in keyof T]: T[K] extends Date\n      ? string\n      : T[K] extends Date | null\n        ? string | null\n        : T[K] extends Date | undefined\n          ? string | undefined\n          : T[K];\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/db-helpers.ts",
    "content": "import { DriverClient } from '@teable/core';\nimport type { Knex } from 'knex';\nimport { get } from 'lodash';\n\nexport function getDriverName(knex: Knex | Knex.QueryBuilder) {\n  return get(knex, 'client.config.client', '') as DriverClient;\n}\n\nexport function isPostgreSQL(knex: Knex) {\n  return getDriverName(knex) === DriverClient.Pg;\n}\n\nexport function isSQLite(knex: Knex) {\n  return getDriverName(knex) === DriverClient.Sqlite;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/db-validation-error.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nexport enum PostgresErrorCode {\n  NOT_NULL_VIOLATION = '23502',\n  UNIQUE_VIOLATION = '23505',\n}\n\nexport enum SqliteErrorCode {\n  NOT_NULL_VIOLATION = '1299',\n  UNIQUE_VIOLATION = '2067',\n}\n\nexport const handleDBValidationErrors = async ({\n  fn,\n  handleUniqueError,\n  handleNotNullError,\n}: {\n  fn: () => Promise<unknown>;\n  handleUniqueError: () => Promise<void>;\n  handleNotNullError: () => Promise<void>;\n}) => {\n  try {\n    await fn();\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  } catch (e: any) {\n    const code = e.meta?.code ?? e.code;\n    if (code === PostgresErrorCode.UNIQUE_VIOLATION || code === SqliteErrorCode.UNIQUE_VIOLATION) {\n      return handleUniqueError();\n    }\n    if (\n      code === PostgresErrorCode.NOT_NULL_VIOLATION ||\n      code === SqliteErrorCode.NOT_NULL_VIOLATION\n    ) {\n      return handleNotNullError();\n    }\n    throw e;\n  }\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/encryptor.ts",
    "content": "import * as crypto from 'crypto';\n\ninterface IEncryptionOptions {\n  algorithm: string;\n  key: string | Buffer;\n  iv: string | Buffer;\n  encoding?: BufferEncoding;\n}\n\nexport class Encryptor<T> {\n  private readonly options: Required<IEncryptionOptions>;\n\n  constructor(options: IEncryptionOptions) {\n    this.options = {\n      ...options,\n      encoding: options.encoding ?? 'hex',\n    };\n  }\n\n  encrypt(data: T): string {\n    try {\n      const { algorithm, key, iv, encoding } = this.options;\n      const cipher = crypto.createCipheriv(algorithm, key, iv);\n      const encrypted = cipher.update(JSON.stringify(data), 'utf-8', encoding);\n      return encrypted + cipher.final(encoding);\n    } catch (error) {\n      throw new Error('Encryption failed');\n    }\n  }\n\n  decrypt(encryptedData: string): T {\n    try {\n      const { algorithm, key, iv, encoding } = this.options;\n      const decipher = crypto.createDecipheriv(algorithm, key, iv);\n      const decrypted = decipher.update(encryptedData, encoding, 'utf-8');\n      return JSON.parse(decrypted + decipher.final('utf-8')) as T;\n    } catch (error) {\n      throw new Error('Decryption failed');\n    }\n  }\n}\n\nexport const getEncryptor = <T>(options: IEncryptionOptions) => new Encryptor<T>(options);\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/exception-parse.ts",
    "content": "import { HttpException } from '@nestjs/common';\nimport { HttpErrorCode, HttpError } from '@teable/core';\nimport { CustomHttpException, getDefaultCodeByStatus } from '../custom.exception';\n\nexport const exceptionParse = (\n  exception: Error | HttpException | CustomHttpException | HttpError\n): CustomHttpException => {\n  if (exception instanceof HttpError) {\n    return new CustomHttpException(exception.message, exception.code);\n  }\n\n  if (\n    exception &&\n    typeof exception === 'object' &&\n    'code' in exception &&\n    'getStatus' in exception\n  ) {\n    return exception;\n  }\n\n  if (exception instanceof HttpException) {\n    const status = exception.getStatus();\n    return new CustomHttpException(exception.message, getDefaultCodeByStatus(status));\n  }\n\n  return new CustomHttpException(\n    process.env.NODE_ENV === 'test'\n      ? `Internal Server Error: ${exception.message}, ${exception.stack}`\n      : 'Internal Server Error',\n    HttpErrorCode.INTERNAL_SERVER_ERROR\n  );\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/extract-field-reference.ts",
    "content": "export const extractFieldReferences = (prompt: string): string[] => {\n  const fieldRefRegex = /\\{(fld[a-zA-Z0-9]+)\\}/g;\n  const fieldIds: string[] = [];\n  let match;\n  while ((match = fieldRefRegex.exec(prompt)) !== null) {\n    fieldIds.push(match[1]);\n  }\n  return [...new Set(fieldIds)];\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/file-utils.spec.ts",
    "content": "import crypto from 'crypto';\nimport * as fs from 'fs';\nimport { Readable as ReadableStream } from 'node:stream';\nimport { FileUtils } from './file-utils';\n\nvi.mock('fs');\n\ndescribe('FileUtils', () => {\n  it('should generate hash from file path', async () => {\n    vi.spyOn(fs, 'createReadStream').mockReturnValueOnce(\n      new ReadableStream({\n        read() {\n          this.push('file content');\n          this.push(null);\n        },\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      }) as any\n    );\n\n    const hash = await FileUtils.getHash('test/to/file.txt');\n    const expectedHash = crypto.createHash('sha256').update('file content').digest('hex');\n    expect(hash).toBe(expectedHash);\n  });\n\n  it('should generate hash from ReadableStream', async () => {\n    const stream = new ReadableStream({\n      read() {\n        this.push('stream content');\n        this.push(null);\n      },\n    });\n    const hash = await FileUtils.getHash(stream);\n    const expectedHash = crypto.createHash('sha256').update('stream content').digest('hex');\n    expect(hash).toBe(expectedHash);\n  });\n\n  it('should generate hash from Buffer', async () => {\n    const buffer = Buffer.from('buffer content');\n    const hash = await FileUtils.getHash(buffer);\n    const expectedHash = crypto.createHash('sha256').update('buffer content').digest('hex');\n    expect(hash).toBe(expectedHash);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/file-utils.ts",
    "content": "import crypto from 'crypto';\nimport { createReadStream } from 'node:fs';\nimport { pipeline, Readable as ReadableStream } from 'node:stream';\nimport { promisify } from 'node:util';\n\nconst pipelineAsync = promisify(pipeline);\n\nexport class FileUtils {\n  static async getHash(path: string): Promise<string>;\n  static async getHash(stream: ReadableStream): Promise<string>;\n  static async getHash(buffer: Buffer): Promise<string>;\n  /**\n   * Implements the overloaded method. Uses argument type checking to determine the logic to execute.\n   * @param input A file path, ReadStream, or Buffer.\n   * @returns A promise that resolves with the hex-encoded hash.\n   */\n  static async getHash(input: string | ReadableStream | Buffer): Promise<string> {\n    let stream: ReadableStream;\n\n    if (typeof input === 'string') {\n      // If input is a file path, create a read stream.\n      stream = createReadStream(input);\n    } else if (Buffer.isBuffer(input)) {\n      // If input is a Buffer, convert it to a stream.\n      stream = ReadableStream.from(input);\n    } else {\n      // If input is already a stream, use it as is.\n      stream = input;\n    }\n\n    const hash = crypto.createHash('sha256');\n\n    await pipelineAsync(stream, hash);\n\n    return hash.digest('hex');\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/filter-has-me.ts",
    "content": "import { Me, type IFilter } from '@teable/core';\n\nexport function filterHasMe(filter: IFilter | string | undefined | null) {\n  if (!filter) {\n    return false;\n  }\n  if (typeof filter === 'string') {\n    return filter.includes(Me);\n  }\n  return JSON.stringify(filter).includes(Me);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/filter.spec.ts",
    "content": "import { CellValueType, FieldType, isNot, isNotExactly } from '@teable/core';\nimport type { IFieldInstance } from '../features/field/model/factory';\nimport { generateFilterItem } from './filter';\n\nconst createField = (partial: Partial<IFieldInstance>): IFieldInstance =>\n  ({\n    id: 'fld_test',\n    type: FieldType.SingleSelect,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    ...partial,\n  }) as IFieldInstance;\n\ndescribe('generateFilterItem', () => {\n  it('uses isNotExactly for multi-value singleSelect fields', () => {\n    const field = createField({\n      type: FieldType.SingleSelect,\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: true,\n    });\n\n    const result = generateFilterItem(field, ['Supplier A']);\n\n    expect(result.operator).toBe(isNotExactly.value);\n    expect(result.value).toEqual(['Supplier A']);\n  });\n\n  it('keeps isNot for single-value singleSelect fields', () => {\n    const field = createField({\n      type: FieldType.SingleSelect,\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: false,\n    });\n\n    const result = generateFilterItem(field, 'Supplier A');\n\n    expect(result.operator).toBe(isNot.value);\n    expect(result.value).toBe('Supplier A');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/filter.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { IUserCellValue, ILinkCellValue, IOperator, IDatetimeFormatting } from '@teable/core';\nimport {\n  FieldType,\n  isNot,\n  is,\n  isNotEmpty,\n  isNotExactly,\n  CellValueType,\n  exactFormatDate,\n} from '@teable/core';\nimport { fromZonedTime } from 'date-fns-tz';\nimport type { IFieldInstance } from '../features/field/model/factory';\n\nconst SPECIAL_OPERATOR_FIELD_TYPE_SET = new Set([\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n  FieldType.Link,\n]);\n\nexport const shouldFilterByDefaultValue = (\n  field: { type: FieldType; cellValueType: CellValueType } | undefined\n) => {\n  if (!field) return false;\n\n  const { type, cellValueType } = field;\n  return (\n    type === FieldType.Checkbox ||\n    (type === FieldType.Formula && cellValueType === CellValueType.Boolean)\n  );\n};\n\nexport const cellValue2FilterValue = (cellValue: unknown, field: IFieldInstance) => {\n  const { type, isMultipleCellValue } = field;\n\n  if (\n    cellValue == null ||\n    ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(type)\n  )\n    return cellValue;\n\n  if (isMultipleCellValue) {\n    return (cellValue as (IUserCellValue | ILinkCellValue)[])?.map((v) => v.id);\n  }\n  return (cellValue as IUserCellValue | ILinkCellValue).id;\n};\n\nexport const generateFilterItem = (field: IFieldInstance, value: unknown) => {\n  let operator: IOperator = isNot.value;\n  const { id: fieldId, type, isMultipleCellValue, options, cellValueType } = field;\n\n  if (shouldFilterByDefaultValue(field)) {\n    operator = is.value;\n    value = !value || null;\n  } else if (value == null) {\n    operator = isNotEmpty.value;\n  } else if (\n    type === FieldType.Date ||\n    (type === FieldType.Formula && cellValueType === CellValueType.DateTime)\n  ) {\n    const timeZone =\n      (options?.formatting as IDatetimeFormatting)?.timeZone ??\n      Intl.DateTimeFormat().resolvedOptions().timeZone;\n    const dateStr = fromZonedTime(value as string, timeZone).toISOString();\n    value = {\n      exactDate: dateStr,\n      mode: exactFormatDate.value,\n      timeZone,\n    };\n  } else if (SPECIAL_OPERATOR_FIELD_TYPE_SET.has(type) && isMultipleCellValue) {\n    operator = isNotExactly.value;\n  }\n\n  return {\n    fieldId,\n    value: cellValue2FilterValue(value, field) as never,\n    operator,\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/generate-thumbnail-path.ts",
    "content": "import {\n  ATTACHMENT_LG_THUMBNAIL_HEIGHT,\n  ATTACHMENT_SM_THUMBNAIL_HEIGHT,\n} from '../features/attachments/constant';\nimport { ThumbnailSize } from '../features/attachments/plugins/types';\nimport { generateCropImagePath } from '../features/attachments/plugins/utils';\n\nexport const generateTableThumbnailPath = (path: string) => {\n  return {\n    smThumbnailPath: generateCropImagePath(path, ThumbnailSize.SM),\n    lgThumbnailPath: generateCropImagePath(path, ThumbnailSize.LG),\n  };\n};\n\nexport const getTableThumbnailToken = (path: string) => {\n  const token = path.split('/').pop();\n  if (!token) {\n    throw new Error('Invalid path');\n  }\n  return token;\n};\n\nexport const getTableThumbnailSize = (width: number, height: number) => {\n  const aspectRatio = width / height;\n  const smWidth = aspectRatio * ATTACHMENT_SM_THUMBNAIL_HEIGHT;\n  const lgWidth = aspectRatio * ATTACHMENT_LG_THUMBNAIL_HEIGHT;\n  return {\n    smThumbnail: {\n      width: Math.round(smWidth),\n      height: Math.round(ATTACHMENT_SM_THUMBNAIL_HEIGHT),\n    },\n    lgThumbnail: {\n      width: Math.round(lgWidth),\n      height: Math.round(ATTACHMENT_LG_THUMBNAIL_HEIGHT),\n    },\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/get-max-level-role.ts",
    "content": "import { canManageRole, type IRole } from '@teable/core';\n\nexport const getMaxLevelRole = (collaborators: { roleName: string | IRole }[]): IRole => {\n  return collaborators.sort((a, b) => {\n    return canManageRole(a.roleName as IRole, b.roleName as IRole) ? -1 : 1;\n  })[0].roleName as IRole;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/i18n.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\n\nconst localPaths = [\n  process.env.I18N_LOCALES_PATH || '',\n  path.join(__dirname, '../../../community/packages/common-i18n/src/locales'),\n  path.join(__dirname, '../../../packages/common-i18n/src/locales'),\n  path.join(__dirname, '../../node_modules/@teable/common-i18n/src/locales'),\n];\n\nexport const getI18nPath = () => {\n  console.debug('backend I18n path checking', __dirname, 'localPaths', localPaths);\n  return localPaths.filter(Boolean).find((str) => {\n    const exists = fs.existsSync(str);\n    console.debug(`backend I18n path checking exists ${exists} ${str} `);\n    if (exists) {\n      console.debug('backend I18n path found', str);\n    }\n    return exists;\n  });\n};\n\nexport const getI18nTypesOutputPath = () => {\n  const path = process.env.I18N_TYPES_OUTPUT_PATH;\n  console.debug('backend I18n types output path:', path);\n  if (!path) {\n    return undefined;\n  }\n  return path;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/index.ts",
    "content": "export * from './name-conversion';\nexport * from './string-hash';\nexport * from './file-utils';\nexport * from './value-convert';\nexport * from './extract-field-reference';\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/is-not-hidden-field.ts",
    "content": "import type {\n  IViewVo,\n  IKanbanViewOptions,\n  IGalleryViewOptions,\n  ICalendarViewOptions,\n} from '@teable/core';\nimport { ColorConfigType, ViewType } from '@teable/core';\n\nexport const isNotHiddenField = (\n  fieldId: string,\n  view: Pick<IViewVo, 'type' | 'options' | 'columnMeta'>\n) => {\n  const { type: viewType, columnMeta, options } = view;\n\n  // check if field is hidden by visible or hidden\n  if (viewType === ViewType.Kanban) {\n    const { stackFieldId, coverFieldId } = (options ?? {}) as IKanbanViewOptions;\n    return (\n      [stackFieldId, coverFieldId].includes(fieldId) ||\n      (columnMeta[fieldId] as { visible?: boolean })?.visible !== false\n    );\n  }\n\n  if (viewType === ViewType.Gallery) {\n    const { coverFieldId } = (options ?? {}) as IGalleryViewOptions;\n    return (\n      fieldId === coverFieldId || (columnMeta[fieldId] as { visible?: boolean })?.visible !== false\n    );\n  }\n\n  if (viewType === ViewType.Calendar) {\n    const { startDateFieldId, endDateFieldId, titleFieldId, colorConfig } = (options ??\n      {}) as ICalendarViewOptions;\n    return (\n      (colorConfig?.type === ColorConfigType.Field && colorConfig.fieldId === fieldId) ||\n      [startDateFieldId, endDateFieldId, titleFieldId].includes(fieldId) ||\n      (columnMeta[fieldId] as { visible?: boolean })?.visible !== false\n    );\n  }\n\n  if ([ViewType.Form].includes(viewType)) {\n    return Boolean((columnMeta[fieldId] as { visible?: boolean })?.visible);\n  }\n  return !(columnMeta[fieldId] as { hidden?: boolean })?.hidden;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/is-user-or-link.ts",
    "content": "import { FieldType } from '@teable/core';\n\nexport const isUserOrLink = (type: FieldType) => {\n  return [FieldType.Link, FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(\n    type\n  );\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/major-field-keys-changed.spec.ts",
    "content": "import { FieldType, Relationship, NumberFormattingType } from '@teable/core';\nimport type {\n  ILinkFieldOptions,\n  INumberFormatting,\n  IFieldVo,\n  IConvertFieldRo,\n  IFormulaFieldOptions,\n} from '@teable/core';\nimport { majorFieldKeysChanged } from './major-field-keys-changed';\n\n// Mock data setup\nconst linkField = {\n  type: FieldType.Link,\n  name: 'link',\n  dbFieldName: 'link_field',\n  options: {\n    relationship: Relationship.ManyOne,\n    foreignTableId: 'foreignTable',\n    lookupFieldId: 'lookupField',\n    isOneWay: true,\n    fkHostTableName: 'hostTable',\n    selfKeyName: 'selfKey',\n    foreignKeyName: 'foreignKey',\n    symmetricFieldId: 'symmetricField',\n  } as ILinkFieldOptions,\n} as IFieldVo;\n\nconst formulaField = {\n  type: FieldType.Formula,\n  name: 'name',\n  dbFieldName: 'dbFieldName',\n  options: {\n    expression: '1 + 1',\n    formatting: {\n      precision: 1,\n      type: NumberFormattingType.Decimal,\n    } as INumberFormatting,\n  },\n} as IFieldVo;\n\nconst newFieldSame: IConvertFieldRo = {\n  type: FieldType.Link,\n  name: 'link',\n  dbFieldName: 'link_field',\n  options: {\n    relationship: Relationship.ManyOne,\n    foreignTableId: 'foreignTable',\n  },\n};\n\n// Test cases\ndescribe('majorFieldKeysChanged', () => {\n  it('should return false if the field has not changed', () => {\n    expect(majorFieldKeysChanged(linkField, newFieldSame)).toBe(false);\n  });\n\n  it('should return true if a major field property like type has changed', () => {\n    expect(majorFieldKeysChanged(linkField, formulaField)).toBe(true);\n  });\n\n  it('should return false if non-major options like formatting have changed', () => {\n    expect(\n      majorFieldKeysChanged(formulaField, {\n        ...formulaField,\n        options: {\n          ...formulaField.options,\n          formatting: {\n            ...(formulaField.options as IFormulaFieldOptions).formatting,\n            precision: 2,\n          } as INumberFormatting,\n        } as IFormulaFieldOptions,\n      })\n    ).toBe(false);\n  });\n\n  it('should return true if major options like expression have changed', () => {\n    expect(\n      majorFieldKeysChanged(formulaField, {\n        ...formulaField,\n        options: { ...formulaField.options, expression: '2+2' },\n      })\n    ).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/major-field-keys-changed.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { IFieldVo, IConvertFieldRo } from '@teable/core';\nimport { FIELD_RO_PROPERTIES } from '@teable/core';\nimport { isEqual, difference } from 'lodash';\n\nexport const NON_INFECT_OPTION_KEYS = new Set([\n  'formatting',\n  'showAs',\n  'visibleFieldIds',\n  'filterByViewId',\n  'filter',\n  'sort',\n  'limit',\n]);\n\nexport const majorOptionsKeyChanged = (\n  oldOptions: Record<string, unknown>,\n  newOptions: Record<string, unknown>\n) => {\n  const keys = Object.keys(newOptions).filter((key) => !isEqual(oldOptions[key], newOptions[key]));\n\n  return keys.some((key) => !NON_INFECT_OPTION_KEYS.has(key));\n};\n\nexport function majorFieldKeysChanged(oldField: IFieldVo, fieldRo: IConvertFieldRo) {\n  const keys = FIELD_RO_PROPERTIES.filter((key) => !isEqual(fieldRo[key], oldField[key]));\n  // filter property\n  const majorKeys = difference(keys, ['name', 'description', 'dbFieldName']);\n\n  if (!majorKeys.length) {\n    return false;\n  }\n\n  // only non infect options changed\n  if (majorKeys.length === 1 && majorKeys[0] === 'options') {\n    const oldOptions = (oldField.options as Record<string, unknown>) || {};\n    const newOptions = (fieldRo.options as Record<string, unknown>) || {};\n\n    return majorOptionsKeyChanged(oldOptions, newOptions);\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/metadata.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nexport const copyDecoratorMetadata = (originalFunction: any, newFunction: any): void => {\n  // Get the current metadata and set onto the wrapper\n  // to ensure other decorators ( ie: NestJS EventPattern / RolesGuard )\n  // won't be affected by the use of this instrumentation\n  Reflect.getMetadataKeys(originalFunction).forEach((metadataKey) => {\n    Reflect.defineMetadata(\n      metadataKey,\n      Reflect.getMetadata(metadataKey, originalFunction),\n      newFunction\n    );\n  });\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/name-conversion.ts",
    "content": "import { slugify } from 'transliteration';\n\nexport function convertNameToValidCharacter(name: string, maxLength = 10): string {\n  let cleanedName = slugify(name, { allowedChars: 'a-zA-Z0-9_', separator: '_', lowercase: false });\n\n  if (cleanedName === '' || /^_+$/.test(cleanedName)) {\n    return 'unnamed';\n  }\n\n  if (!/^[a-z]/i.test(cleanedName)) {\n    cleanedName = 't' + cleanedName;\n  }\n\n  cleanedName = cleanedName.substring(0, maxLength);\n\n  return cleanedName;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/postgres-regex-escape.ts",
    "content": "/**\n * PostgreSQL regex escape utility\n *\n * PostgreSQL uses POSIX regular expressions, special characters that need to be escaped include:\n * . ^ $ * + ? { } [ ] \\ | ( )\n */\n\n/**\n * Escape special characters in PostgreSQL regular expressions\n * @param input String to be escaped\n * @returns Escaped string\n */\nexport function escapePostgresRegex(input: string): string {\n  if (typeof input !== 'string') {\n    return String(input);\n  }\n\n  // Special characters that need to be escaped in PostgreSQL POSIX regular expressions\n  // Reference: https://www.postgresql.org/docs/current/functions-matching.html\n  return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * Escape regular expressions in PostgreSQL JSONB path expressions\n * Used for like_regex operator\n * @param input String to be escaped\n * @returns Escaped string\n */\nexport function escapeJsonbRegex(input: string): string {\n  if (typeof input !== 'string') {\n    return String(input);\n  }\n\n  // For like_regex in JSONB path expressions, escape regex special characters\n  // Avoid double-escaping by handling all characters in one pass\n  return input.replace(/[.*+?^${}()|[\\]\\\\\"]/g, (match) => {\n    if (match === '\\\\') {\n      // Backslashes need to be double-escaped for JSONB path expressions\n      return '\\\\\\\\\\\\\\\\';\n    }\n    if (match === '\"') {\n      // Double quotes must be escaped to stay within jsonpath string literals\n      return '\\\\\"';\n    }\n    // Other regex special characters need to be escaped with double backslashes\n    return '\\\\\\\\' + match;\n  });\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/retry-decorator.spec.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport { Prisma } from '@prisma/client';\nimport { retryOnDeadlock } from './retry-decorator';\n\nclass TestService {\n  @retryOnDeadlock()\n  async testMethod() {\n    throw new Prisma.PrismaClientKnownRequestError('Simulated deadlock', {\n      code: '40P01',\n      clientVersion: '1.0.0',\n    });\n  }\n\n  // 300ms backoff time is determined through testing, 3 retries take approximately 4s in total\n  @retryOnDeadlock({\n    maxRetries: 3,\n    initialBackoff: 300,\n    jitter: 1.0,\n  })\n  async testMethod2() {\n    throw new Prisma.PrismaClientKnownRequestError('Simulated deadlock', {\n      code: '40P01',\n      clientVersion: '1.0.0',\n    });\n  }\n}\n\ndescribe('RetryOnDeadlock Decorator', () => {\n  let service: TestService;\n\n  beforeEach(() => {\n    service = new TestService();\n  });\n\n  beforeAll(() => {\n    vitest.mock('./threshold.config', () => ({\n      thresholdConfig: () => ({\n        dbDeadlock: {\n          maxRetries: 3,\n          initialBackoff: 200,\n          jitter: 1,\n        },\n      }),\n    }));\n  });\n\n  it('should retry on deadlock error', async () => {\n    await expect(service.testMethod()).rejects.toThrow('Database deadlock detected');\n  });\n\n  it('should retry on deadlock error with custom backoff', async () => {\n    await expect(service.testMethod2()).rejects.toThrow('Database deadlock detected');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/retry-decorator.ts",
    "content": "import { Logger } from '@nestjs/common';\nimport { HttpErrorCode } from '@teable/core';\nimport { thresholdConfig } from '../configs/threshold.config';\nimport { CustomHttpException } from '../custom.exception';\n\ninterface IRetryOptions {\n  maxRetries?: number;\n  initialBackoff?: number;\n  jitter?: number;\n}\n\ninterface IRetryConfig {\n  errorCodes: string[];\n  errorMessage: string;\n  errorCode: HttpErrorCode;\n  loggerName: string;\n}\n\nfunction createRetryDecorator(config: IRetryConfig) {\n  const logger = new Logger(config.loggerName);\n\n  return function (opt?: IRetryOptions) {\n    const { dbDeadlock } = thresholdConfig();\n    const {\n      maxRetries = dbDeadlock.maxRetries,\n      initialBackoff = dbDeadlock.initialBackoff,\n      jitter = dbDeadlock.jitter,\n    } = opt ?? {};\n\n    return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) {\n      const originalMethod = descriptor.value;\n\n      descriptor.value = async function (...args: unknown[]) {\n        let retries = 0;\n        let backoff = initialBackoff + Math.random() * jitter;\n\n        while (retries <= maxRetries) {\n          try {\n            return await originalMethod.apply(this, args);\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          } catch (error: any) {\n            const { errorCodes, errorMessage, errorCode } = config;\n            if (\n              errorCodes.includes(error.code) ||\n              (error.meta?.code && errorCodes.includes(error.meta.code as string))\n            ) {\n              if (retries === maxRetries) {\n                logger.error(`${errorMessage} after ${retries} retries`, error.stack);\n                throw new CustomHttpException(errorMessage, errorCode);\n              }\n              await new Promise((resolve) => setTimeout(resolve, backoff));\n              backoff *= 1.5 + Math.random() * jitter;\n            } else {\n              throw error;\n            }\n          }\n          retries++;\n        }\n      };\n\n      return descriptor;\n    };\n  };\n}\n\nexport const retryOnDeadlock = createRetryDecorator({\n  errorCodes: ['40P01', 'P2034'],\n  errorMessage: 'Database deadlock detected',\n  errorCode: HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE,\n  loggerName: 'DeadlockRetryDecorator',\n});\n\nexport const retryOnUniqueViolation = createRetryDecorator({\n  errorCodes: ['23505'],\n  errorMessage: 'Database unique violation detected',\n  errorCode: HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE,\n  loggerName: 'UniqueViolationRetryDecorator',\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/second.ts",
    "content": "import ms from 'ms';\n\nexport const second = (value: string) => {\n  return Math.floor(ms(value) / 1000);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/sql-like-escape.ts",
    "content": "/**\n * Escape SQL LIKE wildcards (%, _, \\) for use with ESCAPE '\\' clause\n */\nexport function escapeLikeWildcards(value: unknown): string {\n  const str = typeof value === 'string' ? value : String(value);\n  return str.replace(/[\\\\%_]/g, '\\\\$&');\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/string-hash.ts",
    "content": "export const string2Hash = (str: string) => {\n  let hash = 5381;\n  let i = str.length;\n\n  while (i) {\n    hash = (hash * 33) ^ str.charCodeAt(--i);\n  }\n\n  return hash >>> 0;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/timing.ts",
    "content": "/* eslint-disable @typescript-eslint/ban-types */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { Logger } from '@nestjs/common';\nimport { trace } from '@opentelemetry/api';\nimport * as Sentry from '@sentry/nestjs';\nimport { Span } from '../tracing/decorators/span';\n\ntype SentrySeverity = Extract<Parameters<typeof Sentry.captureMessage>[1], string>;\n\ntype TimingOptions = {\n  key?: string;\n  thresholdMs?: number;\n  reportToSentry?: boolean;\n  sentryLevel?: SentrySeverity;\n  sentryTag?: string;\n  // Attach OTEL trace ids to Sentry context for correlation\n  attachActiveSpan?: boolean;\n  // Extra context for sentry; can be static or derived from method args/this\n  sentryContext?:\n    | Record<string, unknown>\n    | ((args: any[], instance: unknown) => Record<string, unknown> | undefined);\n};\n\nexport function Timing(customLoggerKeyOrOptions?: string | TimingOptions): MethodDecorator {\n  const logger = new Logger('Timing');\n  const options: TimingOptions =\n    typeof customLoggerKeyOrOptions === 'string'\n      ? { key: customLoggerKeyOrOptions }\n      : customLoggerKeyOrOptions || {};\n  const {\n    key,\n    thresholdMs = 100,\n    reportToSentry = false,\n    sentryLevel = 'warning',\n    sentryTag,\n    attachActiveSpan = true,\n    sentryContext,\n  } = options;\n\n  return (\n    target: Object,\n    propertyKey: string | symbol,\n    descriptor: TypedPropertyDescriptor<any>\n  ) => {\n    // Enhancements to the current decorator can be reported to the link tracking system\n    Span()(target, propertyKey, descriptor);\n\n    const originalMethod = descriptor.value;\n    descriptor.value = function (...args: any[]) {\n      const start = process.hrtime.bigint();\n      const result = originalMethod.apply(this, args);\n      const className = target.constructor.name;\n      const methodName = String(propertyKey);\n\n      const report = () => {\n        const end = process.hrtime.bigint();\n        const durationMs = Number((end - start) / BigInt(1000000));\n        if (durationMs > thresholdMs) {\n          const heapUsedMb = Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100;\n          const activeSpan = attachActiveSpan ? trace.getActiveSpan() : undefined;\n          const spanContext = activeSpan?.spanContext();\n          logger.log(\n            `${className} - ${String(key || propertyKey)} Execution Time: ${durationMs} ms; Heap Usage: ${heapUsedMb} MB`\n          );\n          if (reportToSentry) {\n            Sentry.withScope((scope) => {\n              scope.setLevel?.(sentryLevel);\n              scope.setTag('feature', 'timing');\n              scope.setTag('timing.class', className);\n              scope.setTag('timing.method', methodName);\n              if (sentryTag) {\n                scope.setTag('timing.tag', sentryTag);\n              }\n              const extraContext =\n                typeof sentryContext === 'function' ? sentryContext(args, this) : sentryContext;\n              if (extraContext) {\n                scope.setContext('timing.extra', extraContext);\n              }\n              if (spanContext) {\n                scope.setContext('trace', {\n                  trace_id: spanContext.traceId,\n                  span_id: spanContext.spanId,\n                  op: 'timing',\n                  status: 'ok',\n                });\n              }\n              scope.setContext('timing', {\n                durationMs,\n                thresholdMs,\n                heapUsedMb,\n                argsLength: args?.length ?? 0,\n                traceId: spanContext?.traceId,\n                spanId: spanContext?.spanId,\n              });\n              Sentry.captureMessage(\n                `${className}.${methodName} exceeded timing threshold (${durationMs}ms > ${thresholdMs}ms)`,\n                sentryLevel\n              );\n            });\n          }\n        }\n      };\n\n      if (result instanceof Promise) {\n        return result\n          .then((data) => {\n            report();\n            return data;\n          })\n          .catch((error) => {\n            report();\n            throw error;\n          });\n      }\n      report();\n      return result;\n    };\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/update-order.spec.ts",
    "content": "import { updateOrder, updateMultipleOrders } from './update-order'; // Adjust the import path as necessary\n\ndescribe('updateOrder', () => {\n  // Mock dependencies\n  const getNextItemMock = vi.fn();\n  const updateMock = vi.fn();\n  const shuffleMock = vi.fn();\n\n  afterEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('correctly handles reordering before the anchor item', async () => {\n    // Setup for case 1\n    getNextItemMock.mockResolvedValueOnce({ id: '2', order: 2 });\n    const params = {\n      query: 'parent1',\n      position: 'before' as const,\n      item: { id: 'item1', order: 4 },\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateOrder(params);\n\n    // Verify getNextItem was called correctly\n    expect(getNextItemMock).toHaveBeenCalledWith({ lt: 3 }, 'desc');\n    // Verify update was called with expected arguments\n    expect(updateMock).toHaveBeenCalledWith('parent1', 'item1', {\n      newOrder: 2.5,\n      oldOrder: 4,\n    });\n    // Verify shuffle was not called\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('correctly handles reordering after the anchor item', async () => {\n    // Setup for case 2\n    getNextItemMock.mockResolvedValueOnce({ id: '4', order: 4 });\n    const params = {\n      query: 'parent1',\n      position: 'after' as const,\n      item: { id: 'item1', order: 2 },\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateOrder(params);\n\n    // Verify getNextItem was called correctly\n    expect(getNextItemMock).toHaveBeenCalledWith({ gt: 3 }, 'asc');\n    // Verify update was called with expected arguments\n    expect(updateMock).toHaveBeenCalledWith('parent1', 'item1', {\n      newOrder: 3.5,\n      oldOrder: 2,\n    });\n    // Verify shuffle was not called\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('handles null from getNextItem correctly, indicating no next item', async () => {\n    // Setup: getNextItem returns null\n    getNextItemMock.mockResolvedValueOnce(null);\n    const params = {\n      query: 'parent1',\n      position: 'after' as const, // Can test 'before' in a similar manner with adjusted logic\n      item: { id: 'item1', order: 4 },\n      anchorItem: { id: 'anchor', order: 5 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateOrder(params);\n\n    // When there's no item after the anchor, we expect the item to move just after the anchor\n    expect(updateMock).toHaveBeenCalledWith('parent1', 'item1', { newOrder: 6, oldOrder: 4 });\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('calls shuffle when the new order is too close to the anchor order', async () => {\n    // Setup: getNextItem returns a value that would cause a shuffle due to close orders\n    getNextItemMock.mockResolvedValueOnce({ id: 'anchor', order: 3 - Number.EPSILON });\n    const params = {\n      query: 'parent1',\n      position: 'before' as const,\n      item: { id: 'item1', order: 4 },\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    // it will not be endless loop, because getNextItemMock will return null in the next call\n    await updateOrder(params);\n\n    // Verify shuffle is called due to the order being too close\n    expect(shuffleMock).toHaveBeenCalledOnce();\n    expect(updateMock).toHaveBeenCalledOnce(); // Ensure update is called after shuffle\n  });\n});\n\ndescribe('update multiple order', () => {\n  // Mock dependencies\n  const getNextItemMock = vi.fn();\n  const updateMock = vi.fn();\n  const shuffleMock = vi.fn();\n\n  afterEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('correctly handles reordering before the anchor item', async () => {\n    // Setup for case 1\n    getNextItemMock.mockResolvedValueOnce({ id: '2', order: 2 });\n    const params = {\n      parentId: 'parent1',\n      position: 'before' as const,\n      itemLength: 3,\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateMultipleOrders(params);\n\n    // Verify getNextItem was called correctly\n    expect(getNextItemMock).toHaveBeenCalledWith({ lt: 3 }, 'desc');\n    // Verify update was called with expected arguments\n    expect(updateMock).toHaveBeenCalledWith([2.25, 2.5, 2.75]);\n    // Verify shuffle was not called\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('correctly handles reordering after the anchor item', async () => {\n    // Setup for case 2\n    getNextItemMock.mockResolvedValueOnce({ id: '4', order: 4 });\n    const params = {\n      parentId: 'parent1',\n      position: 'after' as const,\n      itemLength: 3,\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateMultipleOrders(params);\n\n    // Verify getNextItem was called correctly\n    expect(getNextItemMock).toHaveBeenCalledWith({ gt: 3 }, 'asc');\n    // Verify update was called with expected arguments\n    expect(updateMock).toHaveBeenCalledWith([3.25, 3.5, 3.75]);\n    // Verify shuffle was not called\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('handles null from getNextItem correctly, indicating no next item', async () => {\n    // Setup: getNextItem returns null\n    getNextItemMock.mockResolvedValueOnce(null);\n    const params = {\n      parentId: 'parent1',\n      position: 'after' as const,\n      itemLength: 3,\n      anchorItem: { id: 'anchor', order: 7 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    await updateMultipleOrders(params);\n\n    // When there's no item after the anchor, we expect the item to move just after the anchor\n    expect(updateMock).toHaveBeenCalledWith([7.25, 7.5, 7.75]);\n    expect(shuffleMock).not.toHaveBeenCalled();\n  });\n\n  it('calls shuffle when the new order is too close to the anchor order', async () => {\n    // Setup: getNextItem returns a value that would cause a shuffle due to close orders\n    getNextItemMock.mockResolvedValueOnce({ id: 'anchor', order: 3 - Number.EPSILON });\n    const params = {\n      parentId: 'parent1',\n      position: 'before' as const,\n      itemLength: 1,\n      anchorItem: { id: 'anchor', order: 3 },\n      getNextItem: getNextItemMock,\n      update: updateMock,\n      shuffle: shuffleMock,\n    };\n\n    // it will not be endless loop, because getNextItemMock will return null in the next call\n    await updateMultipleOrders(params);\n\n    // Verify shuffle is called due to the order being too close\n    expect(shuffleMock).toHaveBeenCalledOnce();\n    expect(updateMock).toHaveBeenCalledOnce(); // Ensure update is called after shuffle\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/update-order.ts",
    "content": "/**\n * if we have [1,2,3,4,5]\n * --------------------------------\n * case 1:\n * anchorId = 3, position = 'before', order = 2\n * pick the order < 3, we have [1, 2]\n * orderBy desc, we have [2, 1]\n * pick the first one, we have 2\n * --------------------------------\n * case 2:\n * anchorId = 3, position = 'after', order = 2\n * pick the order > 3, we have [4, 5]\n * orderBy asc, we have [4, 5]\n * pick the first one, we have 4\n */\nexport async function updateOrder<T>(params: {\n  query: T;\n  position: 'before' | 'after';\n  item: { id: string; order: number };\n  anchorItem: { id: string; order: number };\n  getNextItem: (\n    whereOrder: { lt?: number; gt?: number },\n    align: 'desc' | 'asc'\n  ) => Promise<{ id: string; order: number } | null>;\n  update: (query: T, id: string, data: { newOrder: number; oldOrder: number }) => Promise<void>;\n  shuffle: (query: T) => Promise<void>;\n}) {\n  const { query, position, item, anchorItem, getNextItem, update, shuffle } = params;\n  const nextView = await getNextItem(\n    { [position === 'before' ? 'lt' : 'gt']: anchorItem.order },\n    position === 'before' ? 'desc' : 'asc'\n  );\n\n  const order = nextView\n    ? (nextView.order + anchorItem.order) / 2\n    : anchorItem.order + (position === 'before' ? -1 : 1);\n\n  const { id, order: oldOrder } = item;\n\n  if (Math.abs(order - anchorItem.order) < Number.EPSILON * 2) {\n    await shuffle(query);\n    // recursive call\n    await updateOrder(params);\n    return;\n  }\n  await update(query, id, { newOrder: order, oldOrder });\n}\n\n/**\n * if we have [1,2,3,4,5]\n * --------------------------------\n * case 1:\n * anchor = 3, position = 'before', item.length = 2\n * pick the order < 3, we have [1, 2]\n * orderBy desc, we have [2, 1]\n * pick the first one, we have 2 for the next order\n * gap = ABS((anchor - next) / (item.length + 1)) = (3 - 2) / (2 + 1) = 0.333\n * new item orders = next + gap * item.index = [2.333, 2.667]\n * --------------------------------\n * case 2:\n * anchor = 3, position = 'after', item.length = 2\n * pick the order > 3, we have [4, 5]\n * orderBy asc, we have [4, 5]\n * pick the first one, we have 4 for the next order\n * gap = ABS((anchor - next) / (item.length + 1)) = ABS((3 - 4) / (2 + 1)) = 0.333\n * new item orders = anchor + gap * item.index = [3.333, 3.667]\n */\nexport async function updateMultipleOrders(params: {\n  parentId: string;\n  position: 'before' | 'after';\n  itemLength: number;\n  anchorItem: { id: string; order: number };\n  getNextItem: (\n    whereOrder: { lt?: number; gt?: number },\n    align: 'desc' | 'asc'\n  ) => Promise<{ id: string; order: number } | null>;\n  update: (indexes: number[]) => Promise<void>;\n  shuffle: (parentId: string) => Promise<void>;\n}) {\n  const { parentId, position, itemLength, anchorItem, getNextItem, update, shuffle } = params;\n  const nextView = await getNextItem(\n    { [position === 'before' ? 'lt' : 'gt']: anchorItem.order },\n    position === 'before' ? 'desc' : 'asc'\n  );\n\n  const nextOrder = nextView ? nextView.order : anchorItem.order + (position === 'before' ? -1 : 1);\n  const gap = Math.abs((anchorItem.order - nextOrder) / (itemLength + 1));\n\n  if (gap < Number.EPSILON * 2) {\n    await shuffle(parentId);\n    // recursive call\n    await updateMultipleOrders(params);\n    return;\n  }\n\n  const orderBase = position === 'before' ? nextOrder : anchorItem.order;\n  const newItems = Array.from({ length: itemLength }).map(\n    (_, index) => orderBase + gap * (index + 1)\n  );\n\n  await update(newItems);\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/utils/value-convert.ts",
    "content": "import { isDate } from 'lodash';\n\nexport const convertValueToStringify = (value: unknown): number | string | null => {\n  if (typeof value === 'bigint' || typeof value === 'number') {\n    return Number(value);\n  }\n  if (isDate(value)) {\n    return value.toISOString();\n  }\n  if (typeof value === 'string') {\n    return value;\n  }\n  if (value == null) return null;\n  return JSON.stringify(value);\n};\n"
  },
  {
    "path": "apps/nestjs-backend/src/worker/parse.ts",
    "content": "import { parentPort, workerData } from 'worker_threads';\nimport { getRandomString } from '@teable/core';\nimport type { IImportConstructorParams } from '../features/import/open-api/import.class';\nimport { importerFactory } from '../features/import/open-api/import.class';\n\nconst parse = () => {\n  const { config, options, id } = { ...workerData } as {\n    config: IImportConstructorParams;\n    options: {\n      skipFirstNLines: number;\n      key: string;\n    };\n    id: string;\n  };\n  const importer = importerFactory(config.type, config);\n  importer.parse(\n    { ...options },\n    async (chunk, lastChunk) => {\n      return await new Promise((resolve) => {\n        const chunkId = `chunk_${getRandomString(8)}`;\n        parentPort?.postMessage({ type: 'chunk', data: chunk, chunkId, id, lastChunk });\n        parentPort?.on('message', (result) => {\n          const { type, chunkId: tunnelChunkId } = result;\n          if (type === 'done' && tunnelChunkId === chunkId) {\n            resolve();\n          }\n        });\n      });\n    },\n    () => {\n      parentPort?.postMessage({ type: 'finished', id });\n      parentPort?.close();\n    },\n    (error) => {\n      parentPort?.postMessage({ type: 'error', data: error, id });\n      parentPort?.close();\n    }\n  );\n};\n\nparse();\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.gateway.dev.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ConfigService } from '@nestjs/config';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { Mock } from 'vitest';\nimport { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { ShareDbService } from '../share-db/share-db.service';\nimport { DevWsGateway } from './ws.gateway.dev';\n\n// Mock http module\nvi.mock('http', () => {\n  const mockServer = {\n    on: vi.fn(),\n    close: vi.fn((callback) => callback()),\n    listen: vi.fn((port, callback) => callback()),\n  };\n  return {\n    default: {\n      createServer: vi.fn(() => mockServer),\n    },\n    createServer: vi.fn(() => mockServer),\n  };\n});\n\n// Mock sockjs\nvi.mock('sockjs', () => {\n  return {\n    default: {\n      createServer: vi.fn(() => ({\n        on: vi.fn(),\n        installHandlers: vi.fn(),\n      })),\n    },\n  };\n});\n\n// Mock @an-epiphany/websocket-json-stream\nvi.mock('@an-epiphany/websocket-json-stream', () => {\n  return {\n    WebSocketJSONStream: vi.fn(function (this: any) {\n      this.on = vi.fn();\n      this.pipe = vi.fn();\n      return this;\n    }),\n  };\n});\n\ndescribe('DevWsGateway', () => {\n  let gateway: DevWsGateway;\n  let shareDbService: { listen: Mock; close: Mock };\n  let configService: { get: Mock };\n  let mockSockjsServer: { on: Mock; installHandlers: Mock };\n  let mockHttpServer: { on: Mock; close: Mock; listen: Mock };\n\n  const testPort = 3001;\n\n  beforeEach(async () => {\n    // Reset all mocks\n    vi.clearAllMocks();\n\n    // Create mock sockjs server\n    mockSockjsServer = {\n      on: vi.fn(),\n      installHandlers: vi.fn(),\n    };\n\n    // Create mock HTTP server\n    mockHttpServer = {\n      on: vi.fn(),\n      close: vi.fn((callback) => callback()),\n      listen: vi.fn((port, callback) => callback()),\n    };\n\n    // Update mocks\n    const sockjs = await import('sockjs');\n    (sockjs.default.createServer as Mock).mockReturnValue(mockSockjsServer);\n\n    const http = await import('http');\n    (http.default.createServer as Mock).mockReturnValue(mockHttpServer);\n\n    // Create mock ConfigService\n    configService = {\n      get: vi.fn().mockReturnValue(testPort),\n    };\n\n    // Create mock ShareDbService\n    shareDbService = {\n      listen: vi.fn(),\n      close: vi.fn((callback) => callback()),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DevWsGateway,\n        {\n          provide: ShareDbService,\n          useValue: shareDbService,\n        },\n        {\n          provide: ConfigService,\n          useValue: configService,\n        },\n      ],\n    }).compile();\n\n    gateway = module.get<DevWsGateway>(DevWsGateway);\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('should be defined', () => {\n    expect(gateway).toBeDefined();\n  });\n\n  describe('onModuleInit', () => {\n    it('should get port from config service', async () => {\n      gateway.onModuleInit();\n\n      expect(configService.get).toHaveBeenCalledWith('SOCKET_PORT');\n    });\n\n    it('should create standalone HTTP server', async () => {\n      const http = await import('http');\n\n      gateway.onModuleInit();\n\n      expect(http.default.createServer).toHaveBeenCalled();\n    });\n\n    it('should create sockjs server and install handlers', async () => {\n      const sockjs = await import('sockjs');\n\n      gateway.onModuleInit();\n\n      expect(sockjs.default.createServer).toHaveBeenCalledWith({\n        prefix: '/socket',\n        transports: ['websocket', 'xhr-streaming'],\n        response_limit: 2 * 1024 * 1024,\n        log: expect.any(Function),\n      });\n      expect(mockSockjsServer.on).toHaveBeenCalledWith('connection', expect.any(Function));\n      expect(mockSockjsServer.installHandlers).toHaveBeenCalledWith(mockHttpServer);\n    });\n\n    it('should set up error handler for HTTP server', async () => {\n      gateway.onModuleInit();\n\n      expect(mockHttpServer.on).toHaveBeenCalledWith('error', expect.any(Function));\n    });\n\n    it('should listen on configured port', async () => {\n      gateway.onModuleInit();\n\n      expect(mockHttpServer.listen).toHaveBeenCalledWith(testPort, expect.any(Function));\n    });\n  });\n\n  describe('handleConnection', () => {\n    it('should handle null connection gracefully', () => {\n      gateway.onModuleInit();\n\n      const connectionHandler = mockSockjsServer.on.mock.calls.find(\n        (call) => call[0] === 'connection'\n      )?.[1];\n\n      expect(connectionHandler).toBeDefined();\n      expect(() => connectionHandler(null)).not.toThrow();\n    });\n\n    it('should call shareDb.listen with stream and request', () => {\n      gateway.onModuleInit();\n\n      const mockRequest = { headers: { cookie: 'test' } };\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: mockRequest } },\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      expect(shareDbService.listen).toHaveBeenCalledWith(expect.any(Object), mockRequest);\n    });\n\n    it('should use empty request if session.recv.request is undefined', () => {\n      gateway.onModuleInit();\n\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: undefined,\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      expect(shareDbService.listen).toHaveBeenCalledWith(expect.any(Object), expect.any(Object));\n    });\n  });\n\n  describe('handleServerError', () => {\n    it('should log HTTP server errors', () => {\n      gateway.onModuleInit();\n\n      const errorHandler = mockHttpServer.on.mock.calls.find((call) => call[0] === 'error')?.[1];\n\n      expect(errorHandler).toBeDefined();\n\n      const loggerSpy = vi.spyOn((gateway as any).logger, 'error');\n      const testError = new Error('Test HTTP error');\n      testError.stack = 'Test stack trace';\n\n      errorHandler(testError);\n\n      expect(loggerSpy).toHaveBeenCalledWith('HTTP server error', 'Test stack trace');\n    });\n  });\n\n  describe('onModuleDestroy', () => {\n    it('should close all active connections', async () => {\n      gateway.onModuleInit();\n\n      const mockConn1 = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n      const mockConn2 = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n\n      const connectionHandler = mockSockjsServer.on.mock.calls.find(\n        (call) => call[0] === 'connection'\n      )?.[1];\n\n      connectionHandler(mockConn1);\n      connectionHandler(mockConn2);\n\n      await gateway.onModuleDestroy();\n\n      expect(mockConn1.close).toHaveBeenCalled();\n      expect(mockConn2.close).toHaveBeenCalled();\n      expect((gateway as any).activeConnections.size).toBe(0);\n    });\n\n    it('should close shareDb and HTTP server in parallel', async () => {\n      gateway.onModuleInit();\n\n      await gateway.onModuleDestroy();\n\n      expect(shareDbService.close).toHaveBeenCalled();\n      expect(mockHttpServer.close).toHaveBeenCalled();\n    });\n\n    it('should clear sockjsServer and httpServer references', async () => {\n      gateway.onModuleInit();\n\n      expect((gateway as any).sockjsServer).not.toBeNull();\n      expect((gateway as any).httpServer).not.toBeNull();\n\n      await gateway.onModuleDestroy();\n\n      expect((gateway as any).sockjsServer).toBeNull();\n      expect((gateway as any).httpServer).toBeNull();\n    });\n\n    it('should handle shareDb close error gracefully', async () => {\n      const closeError = new Error('ShareDb close error');\n      closeError.stack = 'ShareDb stack trace';\n      shareDbService.close.mockImplementation((callback) => callback(closeError));\n\n      gateway.onModuleInit();\n\n      const loggerSpy = vi.spyOn((gateway as any).logger, 'error');\n\n      await gateway.onModuleDestroy();\n\n      expect(loggerSpy).toHaveBeenCalledWith('ShareDb close error', 'ShareDb stack trace');\n    });\n\n    it('should handle HTTP server close error gracefully', async () => {\n      const closeError = new Error('HTTP close error');\n      closeError.stack = 'HTTP stack trace';\n      mockHttpServer.close.mockImplementation((callback) => callback(closeError));\n\n      gateway.onModuleInit();\n\n      const loggerSpy = vi.spyOn((gateway as any).logger, 'error');\n\n      await gateway.onModuleDestroy();\n\n      expect(loggerSpy).toHaveBeenCalledWith('DevWsGateway close error', 'HTTP stack trace');\n    });\n\n    it('should handle missing httpServer gracefully', async () => {\n      gateway.onModuleInit();\n      (gateway as any).httpServer = null;\n\n      // Should not throw\n      await expect(gateway.onModuleDestroy()).resolves.not.toThrow();\n    });\n\n    it('should handle connection close error gracefully', async () => {\n      gateway.onModuleInit();\n\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn().mockImplementation(() => {\n          throw new Error('Close error');\n        }),\n        _session: { recv: { request: {} } },\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      // Should not throw\n      await expect(gateway.onModuleDestroy()).resolves.not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.gateway.dev.ts",
    "content": "import http from 'http';\nimport type { AdaptableWebSocket } from '@an-epiphany/websocket-json-stream';\nimport { WebSocketJSONStream } from '@an-epiphany/websocket-json-stream';\nimport type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger, Optional } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { Request } from 'express';\nimport sockjs from 'sockjs';\nimport { RealtimeMetricsService } from '../share-db/metrics/realtime-metrics.service';\nimport { ShareDbService } from '../share-db/share-db.service';\n\n@Injectable()\nexport class DevWsGateway implements OnModuleInit, OnModuleDestroy {\n  private logger = new Logger(DevWsGateway.name);\n  private sockjsServer: sockjs.Server | null = null;\n  private httpServer: http.Server | null = null;\n  private readonly activeConnections = new Set<sockjs.Connection>();\n\n  constructor(\n    private readonly shareDb: ShareDbService,\n    private readonly configService: ConfigService,\n    @Optional() private readonly realtimeMetrics?: RealtimeMetricsService\n  ) {}\n\n  onModuleInit() {\n    const port = this.configService.get<number>('SOCKET_PORT');\n\n    // SockJS server configuration for collaborative data sync (similar to Airtable)\n    // - transports: Only websocket and xhr-streaming (xhr-polling excluded for performance)\n    // - response_limit: 1MB to handle large batch operations (table sync, bulk row updates)\n    this.sockjsServer = sockjs.createServer({\n      prefix: '/socket',\n      transports: ['websocket', 'xhr-streaming'],\n      response_limit: 2 * 1024 * 1024, // 2MB for large collaborative payloads\n      log: (severity: string, message: string) => {\n        if (severity === 'error') {\n          this.logger.error(message);\n        } else if (severity === 'info') {\n          this.logger.log(message);\n        } else {\n          this.logger.debug(message);\n        }\n      },\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n    } as sockjs.ServerOptions & { transports: string[]; response_limit: number });\n\n    this.sockjsServer.on('connection', this.handleConnection);\n\n    // Create a standalone HTTP server for development\n    this.httpServer = http.createServer();\n\n    // Handle HTTP server errors\n    this.httpServer.on('error', this.handleServerError);\n\n    this.sockjsServer.installHandlers(this.httpServer);\n\n    this.httpServer.listen(port, () => {\n      this.logger.log(`DevWsGateway (SockJS) initialized, Port: ${port}`);\n    });\n  }\n\n  private handleConnection = (conn: sockjs.Connection) => {\n    if (!conn) return;\n\n    this.activeConnections.add(conn);\n    this.realtimeMetrics?.recordConnectionOpen();\n    this.logger.log(`sockjs:on:connection (active: ${this.activeConnections.size})`);\n\n    // Handle connection close to clean up tracking\n    conn.on('close', () => {\n      this.activeConnections.delete(conn);\n      this.realtimeMetrics?.recordConnectionClose();\n      this.logger.log(`sockjs:on:close (active: ${this.activeConnections.size})`);\n    });\n\n    try {\n      const stream = new WebSocketJSONStream(conn as unknown as AdaptableWebSocket, {\n        adapterType: 'sockjs-node',\n      });\n\n      // Get the request with full headers (including cookies)\n      const request = this.getRequestFromConnection(conn);\n\n      this.shareDb.listen(stream, request);\n    } catch (error) {\n      this.logger.error('Connection error', error);\n      this.realtimeMetrics?.recordConnectionError();\n      conn.write(JSON.stringify({ error }));\n      conn.close();\n      this.activeConnections.delete(conn);\n    }\n  };\n\n  /**\n   * Extract HTTP request from SockJS connection.\n   *\n   * SockJS transports provide request access differently:\n   * - XHR (xhr-polling, xhr-streaming): Full request at _session.recv.request\n   * - WebSocket: Request stored in faye-websocket driver at _session.recv.ws._driver._request\n   *\n   * @see https://github.com/sockjs/sockjs-node/blob/main/lib/transport/response-receiver.js\n   * @see https://github.com/sockjs/sockjs-node/blob/main/lib/transport/websocket.js\n   * @see https://github.com/faye/faye-websocket-node (uses websocket-driver internally)\n   */\n  private getRequestFromConnection(conn: sockjs.Connection): Request {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const recv = (conn as any)?._session?.recv;\n\n    // XHR transports: ResponseReceiver stores full request with cookies\n    if (recv?.request) {\n      return recv.request as Request;\n    }\n\n    // WebSocket transport: FayeWebsocket stores request in driver._request\n    // Path: recv.ws (FayeWebsocket) -> _driver (Hybi/Base) -> _request (IncomingMessage)\n    const wsRequest = recv?.ws?._driver?._request;\n    if (wsRequest) {\n      return wsRequest as Request;\n    }\n\n    // Fallback: use connection's url and headers (no cookies)\n    this.logger.warn(\n      `Could not find original request for connection (protocol: ${conn.protocol}), falling back to filtered headers`\n    );\n    return {\n      url: conn.url || '/socket',\n      headers: conn.headers || {},\n    } as unknown as Request;\n  }\n\n  private handleServerError = (error: Error) => {\n    this.logger.error('HTTP server error', error?.stack);\n  };\n\n  async onModuleDestroy() {\n    try {\n      this.logger.log('Starting graceful shutdown...');\n\n      // Terminate all active connections first\n      for (const conn of this.activeConnections) {\n        try {\n          conn.close();\n        } catch {\n          // Ignore errors during connection close\n        }\n      }\n      this.activeConnections.clear();\n\n      await Promise.all([\n        new Promise<void>((resolve) => {\n          this.shareDb.close((err) => {\n            if (err) {\n              this.logger.error('ShareDb close error', err?.stack);\n            } else {\n              this.logger.log('ShareDb closed successfully');\n            }\n            resolve();\n          });\n        }),\n\n        new Promise<void>((resolve) => {\n          if (!this.httpServer) {\n            resolve();\n            return;\n          }\n          this.httpServer.close((err) => {\n            if (err) {\n              this.logger.error('DevWsGateway close error', err?.stack);\n            } else {\n              this.logger.log('SockJS server closed successfully');\n            }\n            resolve();\n          });\n        }),\n      ]);\n\n      // Clean up references\n      this.sockjsServer = null;\n      this.httpServer = null;\n\n      this.logger.log('Graceful shutdown completed');\n    } catch (err) {\n      this.logger.error('Dev module close error: ' + (err as Error).message, (err as Error)?.stack);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.gateway.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { HttpServer } from '@nestjs/common';\nimport { HttpAdapterHost } from '@nestjs/core';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type { Mock } from 'vitest';\nimport { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { ShareDbService } from '../share-db/share-db.service';\nimport { WsGateway } from './ws.gateway';\n\n// Mock sockjs\nvi.mock('sockjs', () => {\n  return {\n    default: {\n      createServer: vi.fn(() => ({\n        on: vi.fn(),\n        installHandlers: vi.fn(),\n      })),\n    },\n  };\n});\n\n// Mock @an-epiphany/websocket-json-stream\nvi.mock('@an-epiphany/websocket-json-stream', () => {\n  return {\n    WebSocketJSONStream: vi.fn(function (this: any) {\n      this.on = vi.fn();\n      this.pipe = vi.fn();\n      return this;\n    }),\n  };\n});\n\ndescribe('WsGateway', () => {\n  let gateway: WsGateway;\n  let shareDbService: { listen: Mock; close: Mock };\n  let mockHttpAdapterHost: { httpAdapter: { getHttpServer: Mock } };\n  let mockHttpServer: HttpServer;\n  let mockSockjsServer: { on: Mock; installHandlers: Mock };\n\n  beforeEach(async () => {\n    // Reset all mocks\n    vi.clearAllMocks();\n\n    // Create mock sockjs server\n    mockSockjsServer = {\n      on: vi.fn(),\n      installHandlers: vi.fn(),\n    };\n\n    // Update the sockjs mock to return our mock server\n    const sockjs = await import('sockjs');\n    (sockjs.default.createServer as Mock).mockReturnValue(mockSockjsServer);\n\n    // Create mock HTTP server with event emitter capabilities\n    mockHttpServer = {\n      on: vi.fn(),\n    } as unknown as HttpServer;\n\n    // Create mock HttpAdapterHost\n    mockHttpAdapterHost = {\n      httpAdapter: {\n        getHttpServer: vi.fn().mockReturnValue(mockHttpServer),\n      },\n    };\n\n    // Create mock ShareDbService\n    shareDbService = {\n      listen: vi.fn(),\n      close: vi.fn((callback) => callback()),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WsGateway,\n        {\n          provide: ShareDbService,\n          useValue: shareDbService,\n        },\n        {\n          provide: HttpAdapterHost,\n          useValue: mockHttpAdapterHost,\n        },\n      ],\n    }).compile();\n\n    gateway = module.get<WsGateway>(WsGateway);\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('should be defined', () => {\n    expect(gateway).toBeDefined();\n  });\n\n  describe('onModuleInit', () => {\n    it('should create sockjs server and install handlers', async () => {\n      const sockjs = await import('sockjs');\n\n      gateway.onModuleInit();\n\n      expect(sockjs.default.createServer).toHaveBeenCalledWith({\n        prefix: '/socket',\n        transports: ['websocket', 'xhr-streaming'],\n        response_limit: 2 * 1024 * 1024,\n        log: expect.any(Function),\n      });\n      expect(mockSockjsServer.on).toHaveBeenCalledWith('connection', expect.any(Function));\n      expect(mockSockjsServer.installHandlers).toHaveBeenCalledWith(mockHttpServer);\n    });\n\n    it('should log messages based on severity', async () => {\n      const sockjs = await import('sockjs');\n      let logFn: (severity: string, message: string) => void;\n\n      (sockjs.default.createServer as Mock).mockImplementation((options) => {\n        logFn = options.log;\n        return mockSockjsServer;\n      });\n\n      gateway.onModuleInit();\n\n      // Test log function with different severities\n      const loggerSpy = vi.spyOn((gateway as any).logger, 'error');\n      const logSpy = vi.spyOn((gateway as any).logger, 'log');\n      const debugSpy = vi.spyOn((gateway as any).logger, 'debug');\n\n      logFn!('error', 'error message');\n      expect(loggerSpy).toHaveBeenCalledWith('error message');\n\n      logFn!('info', 'info message');\n      expect(logSpy).toHaveBeenCalledWith('info message');\n\n      logFn!('debug', 'debug message');\n      expect(debugSpy).toHaveBeenCalledWith('debug message');\n    });\n  });\n\n  describe('handleConnection', () => {\n    it('should handle null connection gracefully', () => {\n      gateway.onModuleInit();\n\n      // Get the connection handler\n      const connectionHandler = mockSockjsServer.on.mock.calls.find(\n        (call) => call[0] === 'connection'\n      )?.[1];\n\n      expect(connectionHandler).toBeDefined();\n\n      // Should not throw when connection is null\n      expect(() => connectionHandler(null)).not.toThrow();\n    });\n\n    it('should set up close handler for connection', () => {\n      gateway.onModuleInit();\n\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      // Verify close handler was set up\n      expect(mockConn.on).toHaveBeenCalledWith('close', expect.any(Function));\n\n      // Get close handler and call it\n      const closeHandler = mockConn.on.mock.calls.find((call) => call[0] === 'close')?.[1];\n      closeHandler();\n\n      // Verify connection was removed from active connections\n      expect((gateway as any).activeConnections.has(mockConn)).toBe(false);\n    });\n\n    it('should call shareDb.listen with stream and request', () => {\n      gateway.onModuleInit();\n\n      const mockRequest = { headers: { cookie: 'test' } };\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: mockRequest } },\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      expect(shareDbService.listen).toHaveBeenCalledWith(expect.any(Object), mockRequest);\n    });\n\n    it('should handle connection error and close connection', async () => {\n      gateway.onModuleInit();\n\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n\n      // Make WebSocketJSONStream throw an error\n      const wsJsonStreamModule = await import('@an-epiphany/websocket-json-stream');\n      (wsJsonStreamModule.WebSocketJSONStream as unknown as Mock).mockImplementationOnce(() => {\n        throw new Error('Stream error');\n      });\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      expect(mockConn.write).toHaveBeenCalledWith(expect.stringContaining('error'));\n      expect(mockConn.close).toHaveBeenCalled();\n      expect((gateway as any).activeConnections.has(mockConn)).toBe(false);\n    });\n  });\n\n  describe('onModuleDestroy', () => {\n    it('should close all active connections', async () => {\n      gateway.onModuleInit();\n\n      const mockConn1 = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n      const mockConn2 = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn(),\n        _session: { recv: { request: {} } },\n      };\n\n      const connectionHandler = mockSockjsServer.on.mock.calls.find(\n        (call) => call[0] === 'connection'\n      )?.[1];\n\n      connectionHandler(mockConn1);\n      connectionHandler(mockConn2);\n\n      await gateway.onModuleDestroy();\n\n      expect(mockConn1.close).toHaveBeenCalled();\n      expect(mockConn2.close).toHaveBeenCalled();\n      expect((gateway as any).activeConnections.size).toBe(0);\n    });\n\n    it('should close shareDb', async () => {\n      gateway.onModuleInit();\n\n      await gateway.onModuleDestroy();\n\n      expect(shareDbService.close).toHaveBeenCalled();\n    });\n\n    it('should clear sockjsServer reference', async () => {\n      gateway.onModuleInit();\n\n      expect((gateway as any).sockjsServer).not.toBeNull();\n\n      await gateway.onModuleDestroy();\n\n      expect((gateway as any).sockjsServer).toBeNull();\n    });\n\n    it('should handle shareDb close error gracefully', async () => {\n      const closeError = new Error('Close error');\n      shareDbService.close.mockImplementation((callback) => callback(closeError));\n\n      gateway.onModuleInit();\n\n      // Should not throw\n      await expect(gateway.onModuleDestroy()).resolves.not.toThrow();\n    });\n\n    it('should handle connection close error gracefully', async () => {\n      gateway.onModuleInit();\n\n      const mockConn = {\n        on: vi.fn(),\n        write: vi.fn(),\n        close: vi.fn().mockImplementation(() => {\n          throw new Error('Close error');\n        }),\n        _session: { recv: { request: {} } },\n      };\n\n      // Call handleConnection directly to avoid mock timing issues\n      (gateway as any).handleConnection(mockConn);\n\n      // Should not throw\n      await expect(gateway.onModuleDestroy()).resolves.not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.gateway.ts",
    "content": "import type http from 'http';\nimport type { AdaptableWebSocket } from '@an-epiphany/websocket-json-stream';\nimport { WebSocketJSONStream } from '@an-epiphany/websocket-json-stream';\nimport type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger, Optional } from '@nestjs/common';\nimport { HttpAdapterHost } from '@nestjs/core';\nimport type { Request } from 'express';\nimport sockjs from 'sockjs';\nimport { RealtimeMetricsService } from '../share-db/metrics/realtime-metrics.service';\nimport { ShareDbService } from '../share-db/share-db.service';\n\n@Injectable()\nexport class WsGateway implements OnModuleInit, OnModuleDestroy {\n  private logger = new Logger(WsGateway.name);\n  private sockjsServer: sockjs.Server | null = null;\n  private readonly activeConnections = new Set<sockjs.Connection>();\n\n  constructor(\n    private readonly shareDb: ShareDbService,\n    private readonly httpAdapterHost: HttpAdapterHost,\n    @Optional() private readonly realtimeMetrics?: RealtimeMetricsService\n  ) {}\n\n  onModuleInit() {\n    const httpServer = this.httpAdapterHost.httpAdapter.getHttpServer() as http.Server;\n\n    // SockJS server configuration for collaborative data sync (similar to Airtable)\n    // - transports: Only websocket and xhr-streaming (xhr-polling excluded for performance)\n    // - response_limit: 1MB to handle large batch operations (table sync, bulk row updates)\n    this.sockjsServer = sockjs.createServer({\n      prefix: '/socket',\n      transports: ['websocket', 'xhr-streaming'],\n      response_limit: 2 * 1024 * 1024, // 2MB for large collaborative payloads\n      log: (severity: string, message: string) => {\n        if (severity === 'error') {\n          this.logger.error(message);\n        } else if (severity === 'info') {\n          this.logger.log(message);\n        } else {\n          this.logger.debug(message);\n        }\n      },\n      // eslint-disable-next-line @typescript-eslint/naming-convention\n    } as sockjs.ServerOptions & { transports: string[]; response_limit: number });\n\n    this.sockjsServer.on('connection', this.handleConnection);\n    this.sockjsServer.installHandlers(httpServer);\n    this.logger.log('WsGateway (SockJS) initialized');\n  }\n\n  private handleConnection = (conn: sockjs.Connection) => {\n    if (!conn) return;\n\n    this.activeConnections.add(conn);\n    this.realtimeMetrics?.recordConnectionOpen();\n    this.logger.log(`sockjs:on:connection (active: ${this.activeConnections.size})`);\n\n    // Handle connection close to clean up tracking\n    conn.on('close', () => {\n      this.activeConnections.delete(conn);\n      this.realtimeMetrics?.recordConnectionClose();\n      this.logger.log(`sockjs:on:close (active: ${this.activeConnections.size})`);\n    });\n\n    try {\n      const stream = new WebSocketJSONStream(conn as unknown as AdaptableWebSocket, {\n        adapterType: 'sockjs-node',\n      });\n\n      // Extract request with headers (including cookies for auth)\n      const request = this.getRequestFromConnection(conn);\n\n      this.shareDb.listen(stream, request);\n    } catch (error) {\n      this.logger.error('Connection error', error);\n      this.realtimeMetrics?.recordConnectionError();\n      conn.write(JSON.stringify({ error }));\n      conn.close();\n      this.activeConnections.delete(conn);\n    }\n  };\n\n  /**\n   * Extract HTTP request from SockJS connection.\n   *\n   * SockJS transports provide request access differently:\n   * - XHR (xhr-polling, xhr-streaming): Full request at _session.recv.request\n   * - WebSocket: Request stored in faye-websocket driver at _session.recv.ws._driver._request\n   *\n   * @see https://github.com/sockjs/sockjs-node/blob/main/lib/transport/response-receiver.js\n   * @see https://github.com/sockjs/sockjs-node/blob/main/lib/transport/websocket.js\n   * @see https://github.com/faye/faye-websocket-node (uses websocket-driver internally)\n   */\n  private getRequestFromConnection(conn: sockjs.Connection): Request {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const recv = (conn as any)?._session?.recv;\n\n    // XHR transports: ResponseReceiver stores full request with cookies\n    if (recv?.request) {\n      return recv.request as Request;\n    }\n\n    // WebSocket transport: FayeWebsocket stores request in driver._request\n    // Path: recv.ws (FayeWebsocket) -> _driver (Hybi/Base) -> _request (IncomingMessage)\n    const wsRequest = recv?.ws?._driver?._request;\n    if (wsRequest) {\n      return wsRequest as Request;\n    }\n\n    // Fallback: use connection's url and headers (no cookies)\n    this.logger.warn(\n      `Could not find original request for connection (protocol: ${conn.protocol}), falling back to filtered headers`\n    );\n    return {\n      url: conn.url || '/socket',\n      headers: conn.headers || {},\n    } as unknown as Request;\n  }\n\n  async onModuleDestroy() {\n    try {\n      this.logger.log('Starting graceful shutdown...');\n\n      // Terminate all active connections\n      for (const conn of this.activeConnections) {\n        try {\n          conn.close();\n        } catch {\n          // Ignore errors during connection close\n        }\n      }\n      this.activeConnections.clear();\n\n      // Close ShareDb\n      await new Promise<void>((resolve, reject) => {\n        this.shareDb.close((err) => {\n          if (err) {\n            reject(err);\n          } else {\n            resolve();\n          }\n        });\n      });\n\n      // Clean up sockjs server reference\n      this.sockjsServer = null;\n\n      this.logger.log('Graceful shutdown completed');\n    } catch (err) {\n      this.logger.error('Module close error: ' + (err as Error).message, (err as Error)?.stack);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ShareDbModule } from '../share-db/share-db.module';\nimport { WsGateway } from './ws.gateway';\nimport { DevWsGateway } from './ws.gateway.dev';\nimport { WsService } from './ws.service';\n\n@Module({\n  imports: [ShareDbModule],\n  providers: [\n    WsService,\n    process.env.NODE_ENV === 'production' || process.env.SERVER_PORT === process.env.SOCKET_PORT\n      ? WsGateway\n      : DevWsGateway,\n  ],\n})\nexport class WsModule {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.service.spec.ts",
    "content": "import type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport { WsService } from './ws.service';\n\ndescribe('WsService', () => {\n  let service: WsService;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [WsService],\n    }).compile();\n\n    service = module.get<WsService>(WsService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/ws/ws.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class WsService {}\n"
  },
  {
    "path": "apps/nestjs-backend/src/zod.validation.pipe.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { fail } from 'assert';\nimport { BadRequestException } from '@nestjs/common';\nimport { z } from 'zod';\nimport { ZodValidationPipe } from './zod.validation.pipe';\n\ndescribe('ZodValidationPipe', () => {\n  describe('Basic validation', () => {\n    const simpleSchema = z.object({\n      name: z.string(),\n      age: z.number(),\n    });\n\n    let pipe: ZodValidationPipe;\n\n    beforeEach(() => {\n      pipe = new ZodValidationPipe(simpleSchema);\n    });\n\n    it('should pass through valid data unchanged', () => {\n      const validData = {\n        name: 'John',\n        age: 30,\n      };\n\n      const result = pipe.transform(validData, {} as any);\n      expect(result).toEqual(validData);\n    });\n\n    it('should throw BadRequestException for invalid data', () => {\n      const invalidData = {\n        name: 'John',\n        age: 'thirty', // Wrong type\n      };\n\n      expect(() => pipe.transform(invalidData, {} as any)).toThrow(BadRequestException);\n    });\n\n    it('should format error messages', () => {\n      const invalidData = {\n        name: 123, // Wrong type\n      };\n\n      try {\n        pipe.transform(invalidData, {} as any);\n        fail('Should have thrown');\n      } catch (error) {\n        const message = (error as BadRequestException).message;\n        expect(message).toContain('Validation error');\n      }\n    });\n  });\n\n  describe('Custom error messages from schema', () => {\n    it('should prioritize custom error messages over generic ones', () => {\n      const schemaWithCustomError = z\n        .string()\n        .refine((val) => val.length > 5, 'Custom error: String must be longer than 5 characters');\n\n      const pipe = new ZodValidationPipe(schemaWithCustomError);\n\n      try {\n        pipe.transform('abc', {} as any);\n        fail('Should have thrown');\n      } catch (error) {\n        const message = (error as BadRequestException).message;\n        // Custom error should be used\n        expect(message).toContain('Custom error');\n      }\n    });\n  });\n\n  describe('Long error message truncation', () => {\n    it('should truncate very long error messages', () => {\n      const complexSchema = z.object({\n        field1: z.string(),\n        field2: z.string(),\n        field3: z.string(),\n        field4: z.string(),\n        field5: z.string(),\n        field6: z.string(),\n        field7: z.string(),\n        field8: z.string(),\n        field9: z.string(),\n        field10: z.string(),\n        field11: z.string(),\n        field12: z.string(),\n        field13: z.string(),\n        field14: z.string(),\n        field15: z.string(),\n        field16: z.string(),\n        field17: z.string(),\n        field18: z.string(),\n        field19: z.string(),\n        field20: z.string(),\n        field21: z.string(),\n        field22: z.string(),\n        field23: z.string(),\n        field24: z.string(),\n        field25: z.string(),\n        field26: z.string(),\n        field27: z.string(),\n        field28: z.string(),\n        field29: z.string(),\n        field30: z.string(),\n      });\n\n      const pipe = new ZodValidationPipe(complexSchema);\n\n      try {\n        pipe.transform({}, {} as any);\n        fail('Should have thrown');\n      } catch (error) {\n        const message = (error as BadRequestException).message;\n        // If message is very long, should be truncated\n        if (message.length > 1000) {\n          expect(message).toContain('truncated');\n        }\n      }\n    });\n  });\n\n  describe('Custom union error message', () => {\n    it('should use custom message for invalid_union instead of detailed errors', () => {\n      // Create a union with custom error message\n      const schema1 = z.object({ type: z.literal('A'), value: z.string() });\n      const schema2 = z.object({ type: z.literal('B'), value: z.number() });\n\n      const unionSchema = z.union([schema1, schema2], {\n        error: () => {\n          return 'Custom helpful message: Please use type \"A\" with string value or type \"B\" with number value';\n        },\n      });\n\n      const pipe = new ZodValidationPipe(unionSchema);\n\n      try {\n        pipe.transform({ type: 'C', value: 'test' }, {} as any);\n        fail('Should have thrown');\n      } catch (error) {\n        const message = (error as BadRequestException).message;\n        // Should use our custom message, not the detailed union errors\n        expect(message).toContain('Custom helpful message');\n        expect(message).toContain('type \"A\"');\n        expect(message).toContain('type \"B\"');\n        // Should NOT contain the default Zod error format\n        expect(message).not.toContain('Invalid input at');\n      }\n    });\n\n    it('should use fromZodError for invalid_union with default message', () => {\n      const schema1 = z.object({ type: z.literal('A'), value: z.string() });\n      const schema2 = z.object({ type: z.literal('B'), value: z.number() });\n\n      const unionSchema = z.union([schema1, schema2]); // No custom error\n\n      const pipe = new ZodValidationPipe(unionSchema);\n\n      try {\n        pipe.transform({ type: 'C', value: 'test' }, {} as any);\n        fail('Should have thrown');\n      } catch (error) {\n        const message = (error as BadRequestException).message;\n        // Should use fromZodError formatting\n        expect(message).toContain('Validation error');\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/src/zod.validation.pipe.ts",
    "content": "import type { PipeTransform, ArgumentMetadata } from '@nestjs/common';\nimport { BadRequestException, Injectable } from '@nestjs/common';\nimport type { z } from 'zod';\nimport { fromZodError } from 'zod-validation-error';\n\nconst maxErrorLength = 1000;\n\n@Injectable()\nexport class ZodValidationPipe implements PipeTransform {\n  constructor(private readonly schema: unknown) {}\n\n  public transform(value: unknown, _metadata: ArgumentMetadata): unknown {\n    const result = (this.schema as z.Schema).safeParse(value);\n\n    if (!result.success) {\n      let message: string;\n\n      // For invalid_union with custom message, use that instead of detailed errors\n      if (\n        result.error.issues.length === 1 &&\n        result.error.issues[0].code === 'invalid_union' &&\n        result.error.issues[0].message &&\n        !result.error.issues[0].message.startsWith('Invalid')\n      ) {\n        message = result.error.issues[0].message;\n      } else {\n        message = fromZodError(result.error).message;\n      }\n\n      // Truncate very long error messages\n      if (message.length > maxErrorLength) {\n        message = message.substring(0, maxErrorLength) + '... (truncated)';\n      }\n\n      throw new BadRequestException(message);\n    }\n\n    return result.data;\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/access-token.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { Role } from '@teable/core';\nimport type {\n  CreateAccessTokenRo,\n  CreateAccessTokenVo,\n  ICreateSpaceVo,\n  IGetSpaceVo,\n  ITableFullVo,\n  UpdateAccessTokenRo,\n} from '@teable/openapi';\nimport {\n  createAccessToken,\n  deleteAccessToken,\n  listAccessToken,\n  listAccessTokenVoSchema,\n  refreshAccessToken,\n  refreshAccessTokenVoSchema,\n  updateAccessToken,\n  GET_TABLE_LIST,\n  urlBuilder,\n  GET_RECORDS_URL,\n  EMAIL_SPACE_INVITATION,\n  CREATE_SPACE,\n  CREATE_BASE,\n  DELETE_SPACE,\n  createAxios,\n  axios as defaultAxios,\n  createSpace,\n  createBase,\n  deleteSpace,\n  deleteBase,\n  getAccessToken,\n  GET_BASE_ALL,\n  GET_SPACE_LIST,\n  UPDATE_SPACE_COLLABORATE,\n  DELETE_SPACE_COLLABORATOR,\n  CREATE_ACCESS_TOKEN,\n  USER_ME,\n  PrincipalType,\n} from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport { splitAccessToken } from '../src/features/access-token/access-token.encryptor';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { createTable, initApp, permanentDeleteSpace } from './utils/init-app';\n\ndescribe('OpenAPI AccessTokenController (e2e)', () => {\n  let app: INestApplication;\n  let baseId: string;\n  let spaceId: string;\n  const email = globalThis.testConfig.email;\n  const email2 = 'accesstoken@example.com';\n  let table: ITableFullVo;\n  let token: CreateAccessTokenVo;\n\n  let defaultCreateRo: CreateAccessTokenRo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const space = await createSpace({ name: 'access token space' }).then((res) => res.data);\n    const base = await createBase({ spaceId: space.id, name: 'access token base' }).then(\n      (res) => res.data\n    );\n    baseId = base.id;\n    spaceId = space.id;\n    defaultCreateRo = {\n      name: 'token1',\n      description: 'token1',\n      scopes: ['table|read', 'record|read'],\n      baseIds: [baseId],\n      spaceIds: [spaceId],\n      expiredTime: dayjs(Date.now() + 1000 * 60 * 60 * 24).format('YYYY-MM-DD'),\n    };\n    table = await createTable(baseId, { name: 'table1' });\n    token = (await createAccessToken(defaultCreateRo)).data;\n    expect(token).toHaveProperty('id');\n  });\n\n  afterAll(async () => {\n    await permanentDeleteSpace(spaceId);\n    const { data } = await listAccessToken();\n    for (const { id } of data) {\n      await deleteAccessToken(id);\n    }\n    await app.close();\n  });\n\n  it('create access token invalid expiredTime', async () => {\n    const ro = {\n      ...defaultCreateRo,\n      expiredTime: '25/02/2023',\n    };\n    const error = await getError(() => createAccessToken(ro));\n    expect(error?.status).toEqual(400);\n    expect(error?.message).contain('expiredTime');\n  });\n\n  it('check access token', async () => {\n    const accessToken = '1234567890';\n    const res = splitAccessToken(accessToken);\n    expect(res).toEqual(null);\n  });\n\n  it('/api/access-token (GET)', async () => {\n    const { data } = await listAccessToken();\n    expect(listAccessTokenVoSchema.safeParse(data).success).toEqual(true);\n\n    expect(data.some(({ id }) => id === token.id)).toEqual(true);\n  });\n\n  it('/api/access-token/:accessTokenId (PUT)', async () => {\n    const { data: newAccessToken } = await createAccessToken(defaultCreateRo);\n    const updateRo: UpdateAccessTokenRo = {\n      name: 'new token',\n      description: 'new desc',\n      scopes: ['table|read', 'record|read', 'record|create'],\n      baseIds: null,\n      spaceIds: null,\n    };\n    const { data } = await updateAccessToken(newAccessToken.id, updateRo);\n    expect(data).toEqual({\n      ...updateRo,\n      id: newAccessToken.id,\n      baseIds: undefined,\n      spaceIds: undefined,\n    });\n  });\n\n  it('/api/access-token/:accessTokenId (DELETE)', async () => {\n    const { data: newAccessToken } = await createAccessToken(defaultCreateRo);\n    const res = await deleteAccessToken(newAccessToken.id);\n    expect(res.status).toEqual(200);\n  });\n\n  it('/api/access-token/:accessTokenId/refresh (POST) 200', async () => {\n    const { data: newAccessToken } = await createAccessToken(defaultCreateRo);\n    const res = await refreshAccessToken(newAccessToken.id, {\n      expiredTime: dayjs(Date.now() + 1000 * 60 * 60 * 24).format('YYYY-MM-DD'),\n    });\n    expect(res.status).toEqual(200);\n    expect(refreshAccessTokenVoSchema.safeParse(res.data).success).toEqual(true);\n  });\n\n  it('/api/access-token/:accessTokenId (GET) include deleted spaceIds and baseIds', async () => {\n    const space = await createSpace({ name: 'deleted space' }).then((res) => res.data);\n    const base = await createBase({ spaceId: space.id, name: 'deleted base' }).then(\n      (res) => res.data\n    );\n    const ro = {\n      ...defaultCreateRo,\n      spaceIds: [space.id],\n      baseIds: [base.id],\n    };\n    const { data: newAccessToken } = await createAccessToken(ro);\n    await deleteBase(base.id);\n    await deleteSpace(space.id);\n    const { data } = await getAccessToken(newAccessToken.id);\n    await permanentDeleteSpace(space.id);\n    expect(data.spaceIds).toEqual([]);\n    expect(data.baseIds).toEqual([]);\n  });\n\n  describe('validate accessToken permission', () => {\n    let tableReadToken: string;\n    let recordReadToken: string;\n    let baseReadAllToken: string;\n    let spaceReadToken: string;\n    const axios = createAxios();\n\n    beforeAll(async () => {\n      const { data: tableReadTokenData } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'table read token',\n        scopes: ['table|read'],\n      });\n      tableReadToken = tableReadTokenData.token;\n      const { data: recordReadTokenData } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'record read token',\n        scopes: ['record|read'],\n      });\n      recordReadToken = recordReadTokenData.token;\n      const { data: baseReadAllTokenData } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'base read all token',\n        scopes: ['base|read_all'],\n      });\n      baseReadAllToken = baseReadAllTokenData.token;\n      axios.defaults.baseURL = defaultAxios.defaults.baseURL;\n\n      const { data: spaceReadTokenData } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'space read token',\n        scopes: ['space|read'],\n      });\n      spaceReadToken = spaceReadTokenData.token;\n    });\n\n    it('get table list has table|read permission', async () => {\n      const res = await axios.get(urlBuilder(GET_TABLE_LIST, { baseId }), {\n        headers: {\n          Authorization: `Bearer ${tableReadToken}`,\n        },\n      });\n      expect(res.status).toEqual(200);\n    });\n\n    it('get table list has not table|read permission', async () => {\n      const error = await getError(() =>\n        axios.get(urlBuilder(GET_TABLE_LIST, { baseId }), {\n          headers: {\n            Authorization: `Bearer ${recordReadToken}`,\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('get base list has not base|read_all permission', async () => {\n      const error = await getError(() =>\n        axios.get(urlBuilder(GET_BASE_ALL), {\n          headers: {\n            Authorization: `Bearer ${tableReadToken}`,\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('get base list has base|read_all permission', async () => {\n      const res = await axios.get(urlBuilder(GET_BASE_ALL), {\n        headers: {\n          Authorization: `Bearer ${baseReadAllToken}`,\n        },\n      });\n      expect(res.status).toEqual(200);\n    });\n\n    it('get record list has record|read permission', async () => {\n      const res = await axios.get(urlBuilder(GET_RECORDS_URL, { tableId: table.id }), {\n        headers: {\n          Authorization: `Bearer ${recordReadToken}`,\n        },\n      });\n      expect(res.status).toEqual(200);\n    });\n\n    it('get record list has not record|read permission', async () => {\n      const error = await getError(() =>\n        axios.get(urlBuilder(GET_RECORDS_URL, { tableId: table.id }), {\n          headers: {\n            Authorization: `Bearer ${tableReadToken}`,\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('access token permission < user permission', async () => {\n      const newUserAxios = await createNewUserAxios({\n        email: email2,\n        password: '12345678',\n      });\n\n      const { data: newUserSpace } = await newUserAxios.post<ICreateSpaceVo>(CREATE_SPACE, {\n        name: 'permission test space',\n      });\n\n      const spaceId = newUserSpace.id;\n      await newUserAxios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), {\n        role: Role.Viewer,\n        emails: [email],\n      });\n\n      const { data: createBaseAccessTokenData } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'base access token',\n        scopes: ['base|read'],\n        spaceIds: [spaceId],\n      });\n\n      const error = await getError(() =>\n        axios.post(\n          CREATE_BASE,\n          { spaceId },\n          {\n            headers: {\n              Authorization: `Bearer ${createBaseAccessTokenData.token}`,\n            },\n          }\n        )\n      );\n      expect(error?.status).toEqual(403);\n      await newUserAxios.delete(urlBuilder(DELETE_SPACE, { spaceId }));\n    });\n\n    it('get space list has space|read permission', async () => {\n      const res = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {\n        headers: {\n          Authorization: `Bearer ${spaceReadToken}`,\n        },\n      });\n      expect(res.status).toEqual(200);\n      expect(res.data.map(({ id }) => id)).toEqual([spaceId]);\n    });\n\n    it('get space list has not space|read permission', async () => {\n      const error = await getError(() =>\n        axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {\n          headers: {\n            Authorization: `Bearer ${tableReadToken}`,\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('hasFullAccess', async () => {\n      const space = await createSpace({ name: 'has full access space' }).then((res) => res.data);\n      const { data: newAccessToken } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'has full access token',\n        scopes: ['space|read'],\n      });\n      const { data: fullAccessToken } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'has full access token',\n        scopes: ['space|read'],\n        hasFullAccess: true,\n      });\n      const newAccessTokenRes = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {\n        headers: {\n          Authorization: `Bearer ${newAccessToken.token}`,\n        },\n      });\n      const fullAccessTokenRes = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {\n        headers: {\n          Authorization: `Bearer ${fullAccessToken.token}`,\n        },\n      });\n      await permanentDeleteSpace(space.id);\n      expect(newAccessTokenRes.status).toEqual(200);\n      expect(newAccessTokenRes.data.map(({ id }) => id)).toEqual([spaceId]);\n      expect(fullAccessTokenRes.status).toEqual(200);\n      expect(fullAccessTokenRes.data.map(({ id }) => id)).toEqual(\n        expect.arrayContaining([spaceId, space.id])\n      );\n    });\n\n    it('access token with expiredTime in expired', async () => {\n      const expiredTime = dayjs(Date.now() - 10000).format('YYYY-MM-DD');\n      const { data } = await createAccessToken({\n        ...defaultCreateRo,\n        name: 'expired access token',\n        scopes: ['space|read'],\n        expiredTime,\n      });\n\n      const error = await getError(() =>\n        axios.get(urlBuilder(GET_SPACE_LIST), {\n          headers: {\n            Authorization: `Bearer ${data.token}`,\n          },\n        })\n      );\n      expect(error?.status).toEqual(401);\n    });\n\n    it('space collaborator operations with space|read token should still enforce role hierarchy', async () => {\n      const creatorEmail = `creator-token-${Date.now()}@example.com`;\n      const viewerEmail = `viewer-token-${Date.now()}@example.com`;\n      const creatorAxios = await createNewUserAxios({\n        email: creatorEmail,\n        password: '12345678',\n      });\n      const viewerAxios = await createNewUserAxios({\n        email: viewerEmail,\n        password: '12345678',\n      });\n\n      const { data: testSpace } = await createSpace({\n        name: 'space token collaborator permission',\n      });\n      const testSpaceId = testSpace.id;\n\n      try {\n        await defaultAxios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId: testSpaceId }), {\n          role: Role.Creator,\n          emails: [creatorEmail],\n        });\n        await defaultAxios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId: testSpaceId }), {\n          role: Role.Viewer,\n          emails: [viewerEmail],\n        });\n\n        const viewerUserId = (await viewerAxios.get<{ id: string }>(USER_ME)).data.id;\n        const ownerUserId = globalThis.testConfig.userId;\n\n        const { data: creatorBase } = await creatorAxios.post<{ id: string }>(CREATE_BASE, {\n          spaceId: testSpaceId,\n          name: 'creator token base',\n        });\n\n        const creatorTokenRes = await creatorAxios.post<CreateAccessTokenVo>(CREATE_ACCESS_TOKEN, {\n          name: 'creator space read token',\n          description: 'creator space read token',\n          scopes: ['space|read'],\n          baseIds: [creatorBase.id],\n          spaceIds: [testSpaceId],\n          expiredTime: dayjs(Date.now() + 1000 * 60 * 60 * 24).format('YYYY-MM-DD'),\n        });\n\n        const creatorTokenAxios = createAxios();\n        creatorTokenAxios.defaults.baseURL = defaultAxios.defaults.baseURL;\n        creatorTokenAxios.defaults.headers.common.Authorization = `Bearer ${creatorTokenRes.data.token}`;\n\n        const updateViewerRes = await creatorTokenAxios.patch(\n          urlBuilder(UPDATE_SPACE_COLLABORATE, { spaceId: testSpaceId }),\n          {\n            role: Role.Commenter,\n            principalId: viewerUserId,\n            principalType: PrincipalType.User,\n          }\n        );\n        expect(updateViewerRes.status).toBe(200);\n\n        const updateOwnerError = await getError(() =>\n          creatorTokenAxios.patch(urlBuilder(UPDATE_SPACE_COLLABORATE, { spaceId: testSpaceId }), {\n            role: Role.Viewer,\n            principalId: ownerUserId,\n            principalType: PrincipalType.User,\n          })\n        );\n        expect(updateOwnerError?.status).toBe(400);\n        expect(updateOwnerError?.message).toBe(\n          'Cannot change the role of the only owner of the space'\n        );\n\n        const deleteOwnerError = await getError(() =>\n          creatorTokenAxios.delete(\n            urlBuilder(DELETE_SPACE_COLLABORATOR, { spaceId: testSpaceId }),\n            {\n              params: {\n                principalId: ownerUserId,\n                principalType: PrincipalType.User,\n              },\n            }\n          )\n        );\n        expect(deleteOwnerError?.status).toBe(400);\n        expect(deleteOwnerError?.message).toBe('Cannot delete the only owner of the space');\n\n        const deleteViewerRes = await creatorTokenAxios.delete(\n          urlBuilder(DELETE_SPACE_COLLABORATOR, { spaceId: testSpaceId }),\n          {\n            params: {\n              principalId: viewerUserId,\n              principalType: PrincipalType.User,\n            },\n          }\n        );\n        expect(deleteViewerRes.status).toBe(200);\n      } finally {\n        await permanentDeleteSpace(testSpaceId);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/aggregation-search-count-question-mark.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType, NumberFormattingType } from '@teable/core';\nimport { getSearchCount as apiGetSearchCount } from '@teable/openapi';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Aggregation search count with question mark (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let tableId: string | undefined;\n  let numberFieldId: string | undefined;\n\n  const urlField1 = { name: 'url1', type: FieldType.SingleLineText };\n  const urlField2 = { name: 'url2', type: FieldType.SingleLineText };\n  const numberField = {\n    name: 'num',\n    type: FieldType.Number,\n    options: {\n      formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n    },\n  };\n\n  const urlWithQuestionMark = 'https://example.com/path?param=value';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const table = await createTable(baseId, {\n      name: `search_count_question_mark_${Date.now()}`,\n      fields: [urlField1, urlField2, numberField],\n      records: [\n        { fields: { [urlField1.name]: urlWithQuestionMark, [urlField2.name]: 'no', num: 10.1 } },\n        { fields: { [urlField1.name]: 'no', [urlField2.name]: urlWithQuestionMark, num: 20.2 } },\n        { fields: { [urlField1.name]: 'no', [urlField2.name]: 'no', num: 30.3 } },\n      ],\n    });\n\n    tableId = table.id;\n    numberFieldId = table.fields?.find((f) => f.name === numberField.name)?.id;\n  });\n\n  afterAll(async () => {\n    if (tableId) {\n      await permanentDeleteTable(baseId, tableId);\n    }\n    await app.close();\n  });\n\n  it('should return count without failing when search contains \"?\"', async () => {\n    const res = await apiGetSearchCount(tableId!, {\n      search: [urlWithQuestionMark, '', true],\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.count).toBe(2);\n  });\n\n  it('should support number precision bindings', async () => {\n    const res = await apiGetSearchCount(tableId!, {\n      search: ['10', numberFieldId!, true],\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.count).toBe(1);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/aggregation-search.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, SortFunc, StatisticsFunc, ViewType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  getAggregation,\n  getSearchCount,\n  getSearchIndex,\n  createField,\n  updateViewColumnMeta,\n  getRecordIndex,\n  updateViewSort,\n} from '@teable/openapi';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\nimport { getError } from './utils/get-error';\n\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  createRecords,\n  getRecords,\n  createView,\n} from './utils/init-app';\n\ndescribe('OpenAPI AggregationController (e2e)', () => {\n  let app: INestApplication;\n  let table: ITableFullVo;\n  let subTable: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    table = await createTable(baseId, {\n      name: 'record_query_x_20',\n      fields: x_20.fields,\n      records: x_20.records,\n    });\n\n    const x20Link = x_20_link(table);\n    subTable = await createTable(baseId, {\n      name: 'sort_x_20',\n      fields: x20Link.fields,\n      records: x20Link.records,\n    });\n\n    const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n    for (const field of x20LinkFromLookups.fields) {\n      await createField(subTable.id, field);\n    }\n\n    await createField(table.id, {\n      name: 'Formula_Boolean',\n      options: {\n        expression: `{${table.fields[0].id}} > 1`,\n      },\n      type: FieldType.Formula,\n    });\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n    await permanentDeleteTable(baseId, subTable.id);\n  });\n\n  describe.skip('OpenAPI AggregationController (e2e) get count with search query', () => {\n    it('should get searchCount', async () => {\n      const result = await getSearchCount(table.id, {\n        // eslint-disable-next-line sonarjs/no-duplicate-string\n        search: ['Text Field', '', false],\n      });\n      expect(result?.data?.count).toBe(22);\n    });\n\n    it('should filter the hidden filed', async () => {\n      const result = await getSearchCount(table.id, {\n        search: ['1', '', false],\n      });\n      await updateViewColumnMeta(table.id, table.views[0].id, [\n        {\n          fieldId: table.fields[1].id,\n          columnMeta: { hidden: true },\n        },\n      ]);\n      const result2 = await getSearchCount(table.id, {\n        search: ['1', '', false],\n        viewId: table.views[0].id,\n      });\n      expect(result?.data?.count).toBe(86);\n      expect(result2?.data?.count).toBe(74);\n    });\n\n    it('should return 0 when there is no result', async () => {\n      const result = await getSearchCount(table.id, {\n        search: ['Go to Gentle night', '', false],\n      });\n      expect(result?.data?.count).toBe(0);\n    });\n  });\n\n  describe('OpenAPI AggregationController (e2e) get record index with query', () => {\n    it('should get search index', async () => {\n      const result = await getSearchIndex(table.id, {\n        take: 10,\n        search: ['Text Field', '', false],\n      });\n      const targetFieldId = table.fields?.[0]?.id;\n      expect(result?.data?.length).toBe(10);\n      expect(result?.data?.map(({ index, fieldId }) => ({ index, fieldId }))).toEqual([\n        { index: 2, fieldId: targetFieldId },\n        { index: 3, fieldId: targetFieldId },\n        { index: 4, fieldId: targetFieldId },\n        { index: 5, fieldId: targetFieldId },\n        { index: 6, fieldId: targetFieldId },\n        { index: 7, fieldId: targetFieldId },\n        { index: 8, fieldId: targetFieldId },\n        { index: 9, fieldId: targetFieldId },\n        { index: 10, fieldId: targetFieldId },\n        { index: 11, fieldId: targetFieldId },\n      ]);\n    });\n\n    it('should get search index with offset', async () => {\n      const result = await getSearchIndex(table.id, {\n        take: 10,\n        skip: 1,\n        search: ['Text Field', '', false],\n      });\n      const targetFieldId = table.fields?.[0]?.id;\n      expect(result?.data?.length).toBe(10);\n      expect(result?.data?.map(({ index, fieldId }) => ({ index, fieldId }))).toEqual([\n        { index: 3, fieldId: targetFieldId },\n        { index: 4, fieldId: targetFieldId },\n        { index: 5, fieldId: targetFieldId },\n        { index: 6, fieldId: targetFieldId },\n        { index: 7, fieldId: targetFieldId },\n        { index: 8, fieldId: targetFieldId },\n        { index: 9, fieldId: targetFieldId },\n        { index: 10, fieldId: targetFieldId },\n        { index: 11, fieldId: targetFieldId },\n        { index: 12, fieldId: targetFieldId },\n      ]);\n    });\n\n    it('should throw a error when take over 1000', async () => {\n      const error = await getError(() =>\n        getSearchIndex(table.id, {\n          take: 1001,\n          search: ['Text Field', '', false],\n        })\n      );\n      expect(error?.status).toBe(400);\n      expect(error?.message).toBe('The maximum search index result is 1000');\n    });\n\n    it('should return null when there is no found', async () => {\n      const result2 = await getSearchIndex(table.id, {\n        take: 1,\n        search: ['Go to Gentle night', '', false],\n      });\n      expect(result2?.data).toBe('');\n    });\n  });\n\n  describe('aggregation statistics with search filtering', () => {\n    let statTable: ITableFullVo;\n    let nameFieldId: string;\n    let quantityFieldId: string;\n\n    beforeAll(async () => {\n      statTable = await createTable(baseId, {\n        name: 'agg_search_filter',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Quantity', type: FieldType.Number },\n        ],\n        records: [\n          { fields: { Name: 'apple phone', Quantity: 180 } },\n          { fields: { Name: 'battery', Quantity: 60 } },\n          { fields: { Name: 'apple cable', Quantity: 120 } },\n        ],\n      });\n\n      nameFieldId = statTable.fields.find((field) => field.name === 'Name')!.id;\n      quantityFieldId = statTable.fields.find((field) => field.name === 'Quantity')!.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, statTable.id);\n    });\n\n    const getAggValue = async (\n      statisticFunc: StatisticsFunc,\n      search?: [string, string, boolean]\n    ) => {\n      const result = (\n        await getAggregation(statTable.id, {\n          field: { [statisticFunc]: [quantityFieldId] },\n          ...(search ? { search } : {}),\n        })\n      ).data;\n\n      return result.aggregations?.find((agg) => agg.fieldId === quantityFieldId)?.total?.value;\n    };\n\n    it.each<[StatisticsFunc, number, number]>([\n      [StatisticsFunc.Sum, 360, 300],\n      [StatisticsFunc.Average, 120, 150],\n      [StatisticsFunc.Min, 60, 120],\n      [StatisticsFunc.Max, 180, 180],\n      [StatisticsFunc.Count, 3, 2],\n    ])('%s respects hide-not-matching search', async (statisticFunc, totalAll, totalFiltered) => {\n      const initialValue = await getAggValue(statisticFunc);\n      expect(initialValue).toBe(totalAll);\n\n      const filteredValue = await getAggValue(statisticFunc, ['apple', nameFieldId, true]);\n      expect(filteredValue).toBe(totalFiltered);\n    });\n  });\n\n  describe('get record index', () => {\n    let indexTable: ITableFullVo;\n    let viewId: string;\n    let numberFieldId: string;\n\n    beforeAll(async () => {\n      indexTable = await createTable(baseId, {\n        name: 'agg_record_index',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Number', type: FieldType.Number },\n        ],\n        records: [\n          { fields: { Name: 'Alice', Number: 30 } },\n          { fields: { Name: 'Bob', Number: 10 } },\n          { fields: { Name: 'Charlie', Number: 20 } },\n        ],\n      });\n\n      numberFieldId = indexTable.fields.find((f) => f.name === 'Number')!.id;\n\n      const view = await createView(indexTable.id, {\n        name: 'Sorted by Number',\n        type: ViewType.Grid,\n      });\n      viewId = view.id;\n\n      await updateViewSort(indexTable.id, viewId, {\n        sort: { sortObjs: [{ fieldId: numberFieldId, order: SortFunc.Asc }] },\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, indexTable.id);\n    });\n\n    it('should return correct index with view sort', async () => {\n      const { records } = await getRecords(indexTable.id, { fieldKeyType: FieldKeyType.Id });\n      const nameFieldId = indexTable.fields.find((f) => f.name === 'Name')!.id;\n\n      const alice = records.find((r) => r.fields[nameFieldId] === 'Alice')!;\n      const bob = records.find((r) => r.fields[nameFieldId] === 'Bob')!;\n\n      // Sorted by Number ASC: Bob(10)=0, Charlie(20)=1, Alice(30)=2\n      const bobResult = await getRecordIndex(indexTable.id, { recordId: bob.id, viewId });\n      const aliceResult = await getRecordIndex(indexTable.id, { recordId: alice.id, viewId });\n      expect(bobResult.data).toEqual({ index: 0 });\n      expect(aliceResult.data).toEqual({ index: 2 });\n    });\n\n    it('should return correct index for newly created record in sorted view', async () => {\n      // Number=15 should land between Bob(10) and Charlie(20)\n      const { records: newRecords } = await createRecords(indexTable.id, {\n        records: [{ fields: { [numberFieldId]: 15 } }],\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const result = await getRecordIndex(indexTable.id, {\n        recordId: newRecords[0].id,\n        viewId,\n      });\n      // Bob(10)=0, NewRec(15)=1, Charlie(20)=2, Alice(30)=3\n      expect(result.data).toEqual({ index: 1 });\n    });\n\n    it('should return falsy for non-existent record', async () => {\n      const result = await getRecordIndex(indexTable.id, { recordId: 'recNonExistent' });\n      expect(result.data).toBeFalsy();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/aggregation.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, IFilter, IGroup, ILinkFieldOptions } from '@teable/core';\nimport {\n  Colors,\n  FieldKeyType,\n  FieldType,\n  Relationship,\n  contains,\n  is,\n  isNot,\n  isGreaterEqual,\n  SortFunc,\n  StatisticsFunc,\n  ViewType,\n  NumberFormattingType,\n} from '@teable/core';\nimport type { IGroupHeaderPoint, ITableFullVo } from '@teable/openapi';\nimport {\n  getAggregation,\n  getCalendarDailyCollection,\n  getGroupPoints,\n  getRowCount,\n  getSearchIndex,\n  GroupPointType,\n  uploadAttachment,\n} from '@teable/openapi';\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { x_20 } from './data-helpers/20x';\nimport {\n  CHECKBOX_FIELD_CASES,\n  DATE_FIELD_CASES,\n  MULTIPLE_SELECT_FIELD_CASES,\n  NUMBER_FIELD_CASES,\n  SINGLE_SELECT_FIELD_CASES,\n  TEXT_FIELD_CASES,\n  USER_FIELD_CASES,\n} from './data-helpers/caces/aggregation-query';\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  createRecords,\n  createView,\n  createField,\n  updateRecordByApi,\n  getRecords,\n  getRecord,\n} from './utils/init-app';\n\ndescribe('OpenAPI AggregationController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n  const textFieldCases = isForceV2\n    ? TEXT_FIELD_CASES.map((testCase) => {\n        switch (testCase.aggFunc) {\n          case StatisticsFunc.Empty:\n            return { ...testCase, expectValue: 0 };\n          case StatisticsFunc.Filled:\n            return { ...testCase, expectValue: 23 };\n          case StatisticsFunc.Unique:\n            return { ...testCase, expectValue: 22 };\n          case StatisticsFunc.PercentEmpty:\n            return { ...testCase, expectValue: 0 };\n          case StatisticsFunc.PercentFilled:\n            return { ...testCase, expectValue: 100 };\n          case StatisticsFunc.PercentUnique:\n            return { ...testCase, expectValue: 95.65217391304348 };\n          default:\n            return testCase;\n        }\n      })\n    : TEXT_FIELD_CASES;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  describe('link updates when primary field is user', () => {\n    let sourceTable: ITableFullVo;\n    let targetTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let symmetricFieldId: string;\n    let sourceRecordId: string;\n    let targetRecordId: string;\n\n    beforeAll(async () => {\n      const assigneeField: IFieldRo = { name: 'Assignee', type: FieldType.User };\n      sourceTable = await createTable(baseId, {\n        name: 'agg_user_primary_source',\n        fields: [assigneeField],\n        records: [\n          {\n            fields: {\n              [assigneeField.name!]: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n\n      targetTable = await createTable(baseId, {\n        name: 'agg_user_primary_target',\n        fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Project: 'Project Alpha' } },\n          { fields: { Project: 'Project Beta' } },\n        ],\n      });\n\n      linkField = (await createField(sourceTable.id, {\n        name: 'Related Project',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: targetTable.id,\n        },\n      })) as IFieldVo;\n\n      symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n      expect(symmetricFieldId).toBeDefined();\n\n      sourceRecordId = sourceTable.records[0].id;\n      targetRecordId = targetTable.records[0].id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, sourceTable.id);\n      await permanentDeleteTable(baseId, targetTable.id);\n    });\n\n    it('propagates symmetric link titles from user primary field', async () => {\n      await updateRecordByApi(sourceTable.id, sourceRecordId, linkField.id, [\n        { id: targetRecordId },\n      ]);\n\n      const symmetricRecord = await getRecord(targetTable.id, targetRecordId);\n      const symmetricValue = symmetricRecord.fields[symmetricFieldId];\n      expect(symmetricValue).toBeDefined();\n      const normalizedValue = Array.isArray(symmetricValue) ? symmetricValue : [symmetricValue];\n      expect(normalizedValue).toHaveLength(1);\n      expect(normalizedValue[0]).toMatchObject({\n        id: sourceRecordId,\n        title: globalThis.testConfig.userName,\n      });\n    });\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  async function getViewAggregations(\n    tableId: string,\n    viewId: string,\n    funcs: StatisticsFunc,\n    fieldId: string[],\n    groupBy?: IGroup\n  ) {\n    return (\n      await getAggregation(tableId, {\n        viewId: viewId,\n        field: { [funcs]: fieldId },\n        groupBy,\n      })\n    ).data;\n  }\n\n  async function getViewRowCount(tableId: string, viewId: string) {\n    return (await getRowCount(tableId, { viewId })).data;\n  }\n\n  describe('basis field aggregation record', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'agg_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get rowCount', async () => {\n      const { rowCount } = await getViewRowCount(table.id, table.views[0].id);\n      expect(rowCount).toEqual(23);\n    });\n\n    it('should limit rowCount to selectedRecordIds', async () => {\n      const selectedIds = table.records.slice(0, 2).map((record) => record.id);\n      const response = await getRowCount(table.id, {\n        viewId: table.views[0].id,\n        selectedRecordIds: selectedIds,\n        ignoreViewQuery: true,\n      });\n\n      expect(response.data.rowCount).toEqual(selectedIds.length);\n    });\n\n    describe('row count contains filter with jsonpath literals', () => {\n      const specialName = 'Person \"Quote\" \\\\ Slash';\n      let tasksTable: ITableFullVo;\n      let peopleTable: ITableFullVo;\n      let linkFieldId: string;\n\n      beforeAll(async () => {\n        peopleTable = await createTable(baseId, {\n          name: 'agg_row_count_people',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n          records: [{ fields: { Name: specialName } }, { fields: { Name: 'Plain Person' } }],\n        });\n\n        tasksTable = await createTable(baseId, {\n          name: 'agg_row_count_tasks',\n          fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n          records: [{ fields: { Title: 'Escaped Match' } }, { fields: { Title: 'Other Task' } }],\n        });\n\n        const linkField = (await createField(tasksTable.id, {\n          name: 'Assignee',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: peopleTable.id,\n          },\n        })) as IFieldVo;\n        linkFieldId = linkField.id;\n\n        await updateRecordByApi(tasksTable.id, tasksTable.records[0].id, linkFieldId, {\n          id: peopleTable.records[0].id,\n        });\n        await updateRecordByApi(tasksTable.id, tasksTable.records[1].id, linkFieldId, {\n          id: peopleTable.records[1].id,\n        });\n      });\n\n      afterAll(async () => {\n        await permanentDeleteTable(baseId, tasksTable.id);\n        await permanentDeleteTable(baseId, peopleTable.id);\n      });\n\n      it('should honor contains filter with escaped value', async () => {\n        const filter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: linkFieldId,\n              operator: contains.value,\n              value: specialName,\n            },\n          ],\n        };\n\n        const { rowCount } = (await getRowCount(tasksTable.id, { filter })).data;\n        expect(rowCount).toEqual(1);\n      });\n    });\n\n    describe('simple aggregation text field record', () => {\n      test.each(textFieldCases)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(textFieldCases)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toBe(expectGroupedCount);\n        }\n      );\n\n      function resolveTextFieldGroupingExpectations(): {\n        textField: IFieldVo;\n        expectedValues: (string | null)[];\n        expectedDescendingValues: (string | null)[];\n      } {\n        const textFieldIndex = TEXT_FIELD_CASES[0].fieldIndex;\n        const textField = table.fields[textFieldIndex];\n        const collator = new Intl.Collator();\n        const rawValues: (string | null)[] = table.records.map((record) => {\n          const value = record.fields[textField.name];\n          if (value == null) {\n            return null;\n          }\n          return typeof value === 'string' ? value : String(value);\n        });\n\n        const uniqueValues = Array.from(new Set<string | null>(rawValues));\n        const expectedValues = [...uniqueValues].sort((left, right) => {\n          if (left === right) return 0;\n          if (left == null) return -1;\n          if (right == null) return 1;\n          return collator.compare(left, right);\n        });\n\n        const expectedDescendingValues = [...expectedValues].reverse();\n\n        return { textField, expectedValues, expectedDescendingValues };\n      }\n\n      it('should return group points for text field in ascending order', async () => {\n        const { textField, expectedValues } = resolveTextFieldGroupingExpectations();\n        const groupPoints = (\n          await getGroupPoints(table.id, {\n            groupBy: [{ fieldId: textField.id, order: SortFunc.Asc }],\n          })\n        ).data;\n\n        expect(groupPoints).toBeDefined();\n\n        const headerValues = groupPoints!\n          .filter(\n            (point): point is IGroupHeaderPoint =>\n              point.type === GroupPointType.Header && point.depth === 0\n          )\n          .map((point) => (point.value ?? null) as string | null);\n\n        expect(headerValues).toEqual(expectedValues);\n      });\n\n      it('should return group points for text field in descending order', async () => {\n        const { textField, expectedDescendingValues } = resolveTextFieldGroupingExpectations();\n        const groupPoints = (\n          await getGroupPoints(table.id, {\n            groupBy: [{ fieldId: textField.id, order: SortFunc.Desc }],\n          })\n        ).data;\n\n        expect(groupPoints).toBeDefined();\n\n        const headerValues = groupPoints!\n          .filter(\n            (point): point is IGroupHeaderPoint =>\n              point.type === GroupPointType.Header && point.depth === 0\n          )\n          .map((point) => (point.value ?? null) as string | null);\n\n        expect(headerValues).toEqual(expectedDescendingValues);\n      });\n    });\n\n    describe('simple aggregation number field record', () => {\n      test.each(NUMBER_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(NUMBER_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toBe(expectGroupedCount);\n        }\n      );\n    });\n\n    describe('simple aggregation single select field record', () => {\n      test.each(SINGLE_SELECT_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(SINGLE_SELECT_FIELD_CASES)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toEqual(expectGroupedCount);\n        }\n      );\n    });\n\n    describe('simple aggregation multiple select field record', () => {\n      test.each(MULTIPLE_SELECT_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(MULTIPLE_SELECT_FIELD_CASES)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toEqual(expectGroupedCount);\n        }\n      );\n    });\n\n    describe('simple aggregation date field record', () => {\n      test.each(DATE_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          if (typeof expectValue === 'number') {\n            expect(total?.value).toBeCloseTo(expectValue, 4);\n          } else {\n            expect(total?.value).toBe(expectValue);\n          }\n        }\n      );\n\n      test.each(DATE_FIELD_CASES)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toEqual(expectGroupedCount);\n        }\n      );\n    });\n\n    describe('simple aggregation checkbox field record', () => {\n      test.each(CHECKBOX_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(CHECKBOX_FIELD_CASES)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toEqual(expectGroupedCount);\n        }\n      );\n    });\n\n    describe('simple aggregation user field record', () => {\n      test.each(USER_FIELD_CASES)(\n        `should agg func [$aggFunc] value: $expectValue`,\n        async ({ fieldIndex, aggFunc, expectValue }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(tableId, viewId, aggFunc, [fieldId]);\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ total }] = result.aggregations!;\n          expect(total?.aggFunc).toBe(aggFunc);\n          expect(total?.value).toBeCloseTo(expectValue, 4);\n        }\n      );\n\n      test.each(USER_FIELD_CASES)(\n        `should agg func [$aggFunc] value with groupBy: $expectGroupedCount`,\n        async ({ fieldIndex, aggFunc, expectGroupedCount }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const result = await getViewAggregations(\n            tableId,\n            viewId,\n            aggFunc,\n            [fieldId],\n            [\n              {\n                fieldId,\n                order: SortFunc.Asc,\n              },\n            ]\n          );\n          expect(result).toBeDefined();\n          expect(result.aggregations?.length).toBeGreaterThan(0);\n\n          const [{ group }] = result.aggregations!;\n          expect(group).toBeDefined();\n          expect(Object.keys(group ?? []).length).toEqual(expectGroupedCount);\n        }\n      );\n    });\n\n    it('percent aggregation zero', async () => {\n      const tableId = table.id;\n      const viewId = table.views[0].id;\n      const fieldId = table.fields[0].id;\n      const checkboxFieldId = table.fields[4].id;\n      const result = await getAggregation(tableId, {\n        viewId: viewId,\n        field: {\n          [StatisticsFunc.PercentFilled]: [fieldId],\n          [StatisticsFunc.PercentUnique]: [fieldId],\n          [StatisticsFunc.PercentChecked]: [checkboxFieldId],\n          [StatisticsFunc.PercentUnChecked]: [checkboxFieldId],\n          [StatisticsFunc.PercentEmpty]: [fieldId],\n        },\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId,\n              operator: is.value,\n              value: 'xxxxxxxxxx',\n            },\n          ],\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        } as any,\n      }).then((res) => res.data);\n      expect(result).toBeDefined();\n      expect(result.aggregations).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({\n            fieldId,\n            total: expect.objectContaining({\n              aggFunc: StatisticsFunc.PercentUnique,\n            }),\n          }),\n          expect.objectContaining({\n            fieldId,\n            total: expect.objectContaining({\n              aggFunc: StatisticsFunc.PercentEmpty,\n            }),\n          }),\n          expect.objectContaining({\n            fieldId,\n            total: expect.objectContaining({\n              aggFunc: StatisticsFunc.PercentFilled,\n            }),\n          }),\n          expect.objectContaining({\n            fieldId: checkboxFieldId,\n            total: expect.objectContaining({\n              aggFunc: StatisticsFunc.PercentChecked,\n            }),\n          }),\n          expect.objectContaining({\n            fieldId: checkboxFieldId,\n            total: expect.objectContaining({\n              aggFunc: StatisticsFunc.PercentUnChecked,\n            }),\n          }),\n        ])\n      );\n\n      result.aggregations?.forEach((agg) => {\n        expect(agg.total?.value).toBeCloseTo(0, 4);\n      });\n    });\n  });\n\n  describe('aggregation projection respects field selection', () => {\n    let projectionTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n    let amountField: IFieldVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let viewId: string;\n\n    const sumFieldDef = { name: 'Amount', type: FieldType.Number };\n    const labelFieldDef = { name: 'Label', type: FieldType.SingleLineText };\n    const foreignNameFieldDef = { name: 'Order Name', type: FieldType.SingleLineText };\n    const foreignTagFieldDef = { name: 'Order Tag', type: FieldType.SingleLineText };\n\n    beforeAll(async () => {\n      projectionTable = await createTable(baseId, {\n        name: 'agg_projection_main',\n        fields: [labelFieldDef, sumFieldDef],\n        records: [\n          { fields: { [labelFieldDef.name]: 'Row 1', [sumFieldDef.name]: 10 } },\n          { fields: { [labelFieldDef.name]: 'Row 2', [sumFieldDef.name]: 30 } },\n        ],\n      });\n\n      amountField = projectionTable.fields.find((field) => field.name === sumFieldDef.name)!;\n      viewId = projectionTable.views[0].id;\n\n      foreignTable = await createTable(baseId, {\n        name: 'agg_projection_foreign',\n        fields: [foreignNameFieldDef, foreignTagFieldDef],\n        records: [\n          {\n            fields: {\n              [foreignNameFieldDef.name]: 'Order A',\n              [foreignTagFieldDef.name]: 'include',\n            },\n          },\n          {\n            fields: {\n              [foreignNameFieldDef.name]: 'Order B',\n              [foreignTagFieldDef.name]: 'exclude',\n            },\n          },\n        ],\n      });\n\n      const foreignTagField = foreignTable.fields.find(\n        (field) => field.name === foreignTagFieldDef.name\n      )!;\n\n      linkField = (await createField(projectionTable.id, {\n        name: 'Orders',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      })) as IFieldVo;\n\n      lookupField = (await createField(projectionTable.id, {\n        name: 'Order Tag Lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: foreignTagField.id,\n        },\n      })) as IFieldVo;\n\n      const [firstRecord, secondRecord] = projectionTable.records;\n      await updateRecordByApi(projectionTable.id, firstRecord.id, linkField.id, [\n        { id: foreignTable.records[0].id },\n      ]);\n      await updateRecordByApi(projectionTable.id, secondRecord.id, linkField.id, [\n        { id: foreignTable.records[1].id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, projectionTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('should aggregate a number field with projection applied', async () => {\n      const response = await getAggregation(projectionTable.id, {\n        viewId,\n        field: {\n          [StatisticsFunc.Sum]: [amountField.id],\n        },\n      });\n      const aggregation = response.data.aggregations?.find(\n        (item) => item.fieldId === amountField.id\n      );\n      expect(aggregation?.total?.value).toBe(40);\n    });\n\n    it('should aggregate correctly when lookup fields are present', async () => {\n      const response = await getAggregation(projectionTable.id, {\n        viewId,\n        field: {\n          [StatisticsFunc.Sum]: [amountField.id],\n        },\n      });\n      const aggregation = response.data.aggregations?.find(\n        (item) => item.fieldId === amountField.id\n      );\n      expect(aggregation?.total?.value).toBe(40);\n    });\n\n    it('should sum correctly when filtering by lookup values', async () => {\n      const response = await getAggregation(projectionTable.id, {\n        viewId,\n        field: {\n          [StatisticsFunc.Sum]: [amountField.id],\n        },\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: lookupField.id,\n              operator: is.value,\n              value: 'include',\n            },\n          ],\n        } as IFilter,\n      });\n      const aggregation = response.data.aggregations?.find(\n        (item) => item.fieldId === amountField.id\n      );\n      expect(aggregation?.total?.value).toBe(10);\n    });\n  });\n\n  describe('single select lookup grouping order', () => {\n    let campusTable: ITableFullVo;\n    let assignmentTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let categoryFieldId: string;\n\n    const categoryFieldDef = {\n      name: 'Category',\n      type: FieldType.SingleSelect,\n      options: {\n        choices: [\n          { id: 'beta', name: 'Beta', color: Colors.BlueBright },\n          { id: 'alpha', name: 'Alpha', color: Colors.CyanBright },\n        ],\n      },\n    } as IFieldRo;\n\n    beforeAll(async () => {\n      campusTable = await createTable(baseId, {\n        name: 'agg_lookup_single_select_source',\n        fields: [{ name: 'Campus', type: FieldType.SingleLineText } as IFieldRo, categoryFieldDef],\n        records: [\n          { fields: { Campus: 'North Campus', [categoryFieldDef.name!]: 'Alpha' } },\n          { fields: { Campus: 'South Campus', [categoryFieldDef.name!]: 'Beta' } },\n        ],\n      });\n      categoryFieldId = campusTable.fields.find(\n        (field) => field.name === categoryFieldDef.name\n      )!.id;\n\n      assignmentTable = await createTable(baseId, {\n        name: 'agg_lookup_single_select_target',\n        fields: [{ name: 'Task', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Task: 'Onboard' } }, { fields: { Task: 'Closeout' } }],\n      });\n\n      linkField = (await createField(assignmentTable.id, {\n        name: 'Campus Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: campusTable.id,\n        },\n      })) as IFieldVo;\n\n      lookupField = (await createField(assignmentTable.id, {\n        name: 'Campus Category',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: campusTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: categoryFieldId,\n        },\n      })) as IFieldVo;\n\n      const [northCampus, southCampus] = campusTable.records;\n      const [firstAssignment, secondAssignment] = assignmentTable.records;\n\n      await updateRecordByApi(assignmentTable.id, firstAssignment.id, linkField.id, [\n        { id: northCampus.id },\n      ]);\n      await updateRecordByApi(assignmentTable.id, secondAssignment.id, linkField.id, [\n        { id: southCampus.id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, assignmentTable.id);\n      await permanentDeleteTable(baseId, campusTable.id);\n    });\n\n    it('orders lookup group headers according to single select choice order', async () => {\n      const groupPoints = (\n        await getGroupPoints(assignmentTable.id, {\n          groupBy: [{ fieldId: lookupField.id, order: SortFunc.Asc }],\n        })\n      ).data!;\n\n      const headerValues = groupPoints\n        .filter(\n          (point): point is IGroupHeaderPoint =>\n            point.type === GroupPointType.Header && point.depth === 0\n        )\n        .map((point) => {\n          const { value } = point;\n          if (Array.isArray(value)) {\n            return (value[0] ?? null) as string | null;\n          }\n          return (value ?? null) as string | null;\n        });\n\n      expect(headerValues).toEqual(['Beta', 'Alpha']);\n    });\n  });\n\n  describe('multi-value numeric lookup aggregation', () => {\n    let ordersTable: ITableFullVo;\n    let summaryTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    const orderAmounts = [299.88, 42.12, 10.5];\n\n    beforeAll(async () => {\n      ordersTable = await createTable(baseId, {\n        name: 'agg_order_source',\n        fields: [\n          { name: 'Order Name', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Amount',\n            type: FieldType.Number,\n            options: {\n              formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n            },\n          } as IFieldRo,\n        ],\n        records: orderAmounts.map((amount, index) => ({\n          fields: { 'Order Name': `Order ${index + 1}`, Amount: amount },\n        })),\n      });\n\n      summaryTable = await createTable(baseId, {\n        name: 'agg_order_summary',\n        fields: [{ name: 'Summary', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Summary: 'All Orders' } }],\n      });\n\n      const summaryRecordId = summaryTable.records[0].id;\n      linkField = (await createField(summaryTable.id, {\n        name: 'Orders',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: ordersTable.id,\n        },\n      } as IFieldRo)) as IFieldVo;\n\n      await updateRecordByApi(\n        summaryTable.id,\n        summaryRecordId,\n        linkField.id,\n        ordersTable.records.map((record) => ({ id: record.id }))\n      );\n\n      const amountFieldId = ordersTable.fields.find((field) => field.name === 'Amount')!.id;\n      lookupField = (await createField(summaryTable.id, {\n        name: 'Order Amount Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: ordersTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: amountFieldId,\n        },\n      } as IFieldRo)) as IFieldVo;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, summaryTable.id);\n      await permanentDeleteTable(baseId, ordersTable.id);\n    });\n\n    it('sums decimal lookup values without truncation', async () => {\n      const response = await getAggregation(summaryTable.id, {\n        viewId: summaryTable.views[0].id,\n        field: {\n          [StatisticsFunc.Sum]: [lookupField.id],\n        },\n      });\n\n      const aggregation = response.data.aggregations?.find(\n        (item) => item.fieldId === lookupField.id\n      );\n      const expectedSum = orderAmounts.reduce((acc, value) => acc + value, 0);\n      expect(aggregation?.total?.value).toBeCloseTo(expectedSum, 4);\n    });\n  });\n\n  describe('get group point by group', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'agg_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get group points with collapsed group IDs', async () => {\n      const singleSelectField = table.fields[2];\n      const groupBy = [\n        {\n          fieldId: singleSelectField.id,\n          order: SortFunc.Asc,\n        },\n      ];\n      const groupPoints = (await getGroupPoints(table.id, { groupBy })).data!;\n      expect(groupPoints.length).toEqual(8);\n\n      const firstGroupHeader = groupPoints.find(\n        ({ type }) => type === GroupPointType.Header\n      ) as IGroupHeaderPoint;\n\n      const collapsedGroupPoints = (\n        await getGroupPoints(table.id, { groupBy, collapsedGroupIds: [firstGroupHeader.id] })\n      ).data!;\n\n      expect(collapsedGroupPoints.length).toEqual(7);\n    });\n\n    it('should get group header refs with collapsed group IDs', async () => {\n      const singleSelectField = table.fields[2];\n      const groupBy = [\n        {\n          fieldId: singleSelectField.id,\n          order: SortFunc.Asc,\n        },\n      ];\n      const originalResult = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        groupBy,\n      });\n      expect(originalResult.extra?.allGroupHeaderRefs?.length).toEqual(4);\n\n      const firstGroupHeaderId = originalResult.extra!.allGroupHeaderRefs![0].id;\n\n      const result = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        groupBy,\n        collapsedGroupIds: [firstGroupHeaderId],\n      });\n\n      expect(result.extra?.allGroupHeaderRefs?.length).toEqual(4);\n    });\n\n    it('should keep single select group order', async () => {\n      const singleSelectField = table.fields[2];\n      const groupBy = [\n        {\n          fieldId: singleSelectField.id,\n          order: SortFunc.Asc,\n        },\n      ];\n\n      const groupPoints = (await getGroupPoints(table.id, { groupBy })).data!;\n      const headerValues = groupPoints\n        .filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header)\n        .filter(({ depth }) => depth === 0)\n        .map(({ value }) => value);\n\n      const expectedOptions = ['x', 'y', 'z'];\n      const startIndex = headerValues[0] == null ? 1 : 0;\n      expect(headerValues.slice(startIndex, startIndex + expectedOptions.length)).toEqual(\n        expectedOptions\n      );\n\n      const tailValues = headerValues.slice(startIndex + expectedOptions.length);\n      expect(tailValues.length <= 1).toBe(true);\n      if (tailValues.length === 1) {\n        expect(tailValues[0]).toBe('Unknown');\n      }\n    });\n\n    it('should get group points by user field', async () => {\n      const userField = table.fields[5];\n      const multipleUserField = table.fields[7];\n\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [userField.id]: {\n                id: 'usrTestUserId',\n                title: 'test',\n                avatarUrl: 'https://test.com',\n              },\n              [multipleUserField.id]: [\n                { id: 'usrTestUserId_1', title: 'test', email: 'test@test1.com' },\n              ],\n            },\n          },\n          {\n            fields: {\n              [userField.id]: {\n                id: 'usrTestUserId',\n                title: 'test',\n                email: 'test@test.com',\n                avatarUrl: 'https://test.com',\n              },\n              [multipleUserField.id]: [\n                {\n                  id: 'usrTestUserId_1',\n                  title: 'test',\n                  email: 'test@test.com',\n                  avatarUrl: 'https://test1.com',\n                },\n              ],\n            },\n          },\n        ],\n      });\n\n      const groupByUserField = [\n        {\n          fieldId: userField.id,\n          order: SortFunc.Asc,\n        },\n      ];\n\n      const groupByMultipleUserField = [\n        {\n          fieldId: multipleUserField.id,\n          order: SortFunc.Asc,\n        },\n      ];\n      const groupPoints = (await getGroupPoints(table.id, { groupBy: groupByUserField })).data!;\n      expect(groupPoints.length).toEqual(4);\n\n      const groupPointsForMultiple = (\n        await getGroupPoints(table.id, { groupBy: groupByMultipleUserField })\n      ).data!;\n      expect(groupPointsForMultiple.length).toEqual(6);\n    });\n\n    it('should order user group headers by display title', async () => {\n      const groupedTable = await createTable(baseId, {\n        fields: [\n          {\n            name: 'Assignee',\n            type: FieldType.User,\n          },\n        ],\n      });\n\n      const userField = groupedTable.fields.find((field) => field.name === 'Assignee')!;\n\n      await createRecords(groupedTable.id, {\n        records: [\n          {\n            fields: {\n              [userField.id]: {\n                id: 'usrTestUserId',\n                title: 'Alpha',\n              },\n            },\n          },\n          {\n            fields: {\n              [userField.id]: {\n                id: 'usrTestUserId_1',\n                title: 'Beta',\n              },\n            },\n          },\n        ],\n      });\n\n      try {\n        const groupBy = [\n          {\n            fieldId: userField.id,\n            order: SortFunc.Asc,\n          },\n        ];\n\n        const groupPoints = (await getGroupPoints(groupedTable.id, { groupBy })).data!;\n\n        const headerTitles = groupPoints\n          .filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header)\n          .filter(({ depth, value }) => depth === 0 && value != null)\n          .map(({ value }) => {\n            if (typeof value === 'object' && value !== null && 'title' in value) {\n              return (value as { title?: string }).title ?? null;\n            }\n            return typeof value === 'string' ? value : null;\n          })\n          .filter((title): title is string => Boolean(title));\n\n        const sortedTitles = [...headerTitles].sort((a, b) => a.localeCompare(b, 'en'));\n\n        expect(headerTitles).toEqual(sortedTitles);\n      } finally {\n        await permanentDeleteTable(baseId, groupedTable.id);\n      }\n    });\n\n    it('should filter single select values case-sensitively (TM3D vs TM3d)', async () => {\n      const categoryFieldDef = {\n        name: 'Category',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choTM3D', name: 'TM3D', color: Colors.CyanBright },\n            { id: 'choTM3d', name: 'TM3d', color: Colors.BlueBright },\n          ],\n        },\n      } as IFieldRo;\n\n      const groupedTable = await createTable(baseId, {\n        name: 'agg_group_collapse_case_sensitive',\n        fields: [categoryFieldDef],\n        records: [\n          { fields: { [categoryFieldDef.name!]: 'TM3D' } },\n          { fields: { [categoryFieldDef.name!]: 'TM3D' } },\n          { fields: { [categoryFieldDef.name!]: 'TM3d' } },\n        ],\n      });\n\n      try {\n        const categoryFieldId = groupedTable.fields.find(\n          (field) => field.name === categoryFieldDef.name\n        )!.id;\n\n        const rowCountIs = (\n          await getRowCount(groupedTable.id, {\n            viewId: groupedTable.views[0].id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: categoryFieldId,\n                  operator: is.value,\n                  value: 'TM3D',\n                },\n              ],\n            } as IFilter,\n          })\n        ).data.rowCount;\n\n        expect(rowCountIs).toBe(2);\n\n        const rowCountIsNot = (\n          await getRowCount(groupedTable.id, {\n            viewId: groupedTable.views[0].id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: categoryFieldId,\n                  operator: isNot.value,\n                  value: 'TM3D',\n                },\n              ],\n            } as IFilter,\n          })\n        ).data.rowCount;\n\n        // Only TM3d should remain.\n        expect(rowCountIsNot).toBe(1);\n      } finally {\n        await permanentDeleteTable(baseId, groupedTable.id);\n      }\n    });\n  });\n\n  describe('should get calendar daily collection', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'agg_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get calendar daily collection', async () => {\n      const result = await getCalendarDailyCollection(table.id, {\n        startDateFieldId: table.fields[3].id,\n        endDateFieldId: table.fields[3].id,\n        startDate: '2022-01-27T16:00:00.000Z',\n        endDate: '2022-03-12T16:00:00.000Z',\n      });\n\n      expect(result).toBeDefined();\n      expect(result.data.countMap).toEqual({\n        '2022-01-28': 1,\n        '2022-03-01': 1,\n        '2022-03-02': 1,\n        '2022-03-12': 1,\n      });\n      expect(result.data.records.length).toEqual(4);\n    });\n  });\n\n  describe('aggregation with ignoreViewQuery', () => {\n    let table: ITableFullVo;\n    let viewId: string;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'agg_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const numberFieldId = table.fields[1].id;\n      const view = await createView(table.id, {\n        type: ViewType.Grid,\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: numberFieldId, operator: isGreaterEqual.value, value: 16 }],\n        },\n        sort: {\n          sortObjs: [{ fieldId: numberFieldId, order: SortFunc.Asc }],\n        },\n        group: [{ fieldId: numberFieldId, order: SortFunc.Asc }],\n      });\n      viewId = view.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get row count with ignoreViewQuery', async () => {\n      const { rowCount } = (await getRowCount(table.id, { viewId, ignoreViewQuery: true })).data;\n      expect(rowCount).toEqual(23);\n    });\n\n    it('should get aggregation with ignoreViewQuery', async () => {\n      const result = (\n        await getAggregation(table.id, {\n          viewId,\n          field: { [StatisticsFunc.Count]: [table.fields[0].id] },\n          ignoreViewQuery: true,\n        })\n      ).data;\n      expect(result.aggregations?.length).toEqual(1);\n      expect(result.aggregations?.[0].total?.value).toEqual(23);\n    });\n\n    it('should get group points with ignoreViewQuery', async () => {\n      const result = (\n        await getGroupPoints(table.id, {\n          viewId,\n          groupBy: [{ fieldId: table.fields[0].id, order: SortFunc.Asc }],\n          ignoreViewQuery: true,\n        })\n      ).data;\n      const groupCount = result?.filter(({ type }) => type === GroupPointType.Header).length;\n      expect(groupCount).toEqual(22);\n    });\n\n    // it.only('should get search count with ignoreViewQuery', async () => {\n    //   const result = (\n    //     await getSearchCount(table.id, {\n    //       viewId,\n    //       search: ['Text Field 10', '', false],\n    //       ignoreViewQuery: true,\n    //     })\n    //   ).data;\n    //   expect(result.count).toEqual(2);\n    // });\n\n    it('should get search index with ignoreViewQuery', async () => {\n      const result = (\n        await getSearchIndex(table.id, {\n          viewId,\n          take: 50,\n          search: ['Text Field 10', '', false],\n          ignoreViewQuery: true,\n        })\n      ).data;\n      expect(result?.length).toEqual(2);\n    });\n\n    it('should get calendar daily collection with ignoreViewQuery', async () => {\n      const result = await getCalendarDailyCollection(table.id, {\n        viewId,\n        startDateFieldId: table.fields[3].id,\n        endDateFieldId: table.fields[3].id,\n        startDate: '2022-01-27T16:00:00.000Z',\n        endDate: '2022-03-12T16:00:00.000Z',\n        ignoreViewQuery: true,\n      });\n\n      expect(result).toBeDefined();\n      expect(result.data.countMap).toEqual({\n        '2022-01-28': 1,\n        '2022-03-01': 1,\n        '2022-03-02': 1,\n        '2022-03-12': 1,\n      });\n      expect(result.data.records.length).toEqual(4);\n    });\n  });\n\n  describe('attachment total size aggregation with groupBy', () => {\n    let tableId: string;\n    let groupFieldId: string;\n    let attachmentFieldId: string;\n    let recordA1Id: string;\n    let recordA2Id: string;\n    let recordB1Id: string;\n    let file10Path: string;\n    let file20Path: string;\n\n    beforeAll(async () => {\n      file10Path = path.join(StorageAdapter.TEMPORARY_DIR, 'agg-10b.bin');\n      file20Path = path.join(StorageAdapter.TEMPORARY_DIR, 'agg-20b.bin');\n      fs.writeFileSync(file10Path, 'a'.repeat(10));\n      fs.writeFileSync(file20Path, 'b'.repeat(20));\n\n      const table = await createTable(baseId, {\n        name: 'agg_attachment_group',\n        fields: [\n          {\n            name: 'group',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { id: 'A', name: 'A', color: Colors.BlueBright },\n                { id: 'B', name: 'B', color: Colors.CyanBright },\n              ],\n            },\n          },\n          {\n            name: 'att',\n            type: FieldType.Attachment,\n          },\n        ],\n      });\n      tableId = table.id;\n      groupFieldId = table.fields[0].id;\n      attachmentFieldId = table.fields[1].id;\n\n      const created = await createRecords(tableId, {\n        records: [\n          { fields: { [groupFieldId]: 'A' } },\n          { fields: { [groupFieldId]: 'A' } },\n          { fields: { [groupFieldId]: 'B' } },\n        ],\n      });\n\n      recordA1Id = created.records[0].id;\n      recordA2Id = created.records[1].id;\n      recordB1Id = created.records[2].id;\n\n      await uploadAttachment(\n        tableId,\n        recordA1Id,\n        attachmentFieldId,\n        fs.createReadStream(file10Path)\n      );\n      await uploadAttachment(\n        tableId,\n        recordA2Id,\n        attachmentFieldId,\n        fs.createReadStream(file20Path)\n      );\n      await uploadAttachment(\n        tableId,\n        recordB1Id,\n        attachmentFieldId,\n        fs.createReadStream(file20Path)\n      );\n    });\n\n    afterAll(async () => {\n      try {\n        await permanentDeleteTable(baseId, tableId);\n      } finally {\n        if (fs.existsSync(file10Path)) fs.unlinkSync(file10Path);\n        if (fs.existsSync(file20Path)) fs.unlinkSync(file20Path);\n      }\n    });\n\n    it('should compute per-group total attachment size correctly', async () => {\n      const result = await getAggregation(tableId, {\n        field: { [StatisticsFunc.TotalAttachmentSize]: [attachmentFieldId] },\n        groupBy: [{ fieldId: groupFieldId, order: SortFunc.Asc }],\n      }).then((res) => res.data);\n\n      expect(result.aggregations?.length).toBe(1);\n      const [{ total, group }] = result.aggregations!;\n      expect(total?.aggFunc).toBe(StatisticsFunc.TotalAttachmentSize);\n      expect(Number(total?.value)).toBe(50);\n      expect(group).toBeDefined();\n      const values = Object.values(group ?? {})\n        .map((g) => g.value as number)\n        .sort((a, b) => a - b);\n      expect(values).toEqual(['0', '20', '30']);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/attachment.e2e-spec.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type { IAttachmentCellValue, IAttachmentItem } from '@teable/core';\nimport { CellFormat, FieldKeyType, FieldType, getRandomString } from '@teable/core';\nimport type { CreateAccessTokenRo, ITableFullVo } from '@teable/openapi';\nimport {\n  createAccessToken,\n  createAxios,\n  createBase,\n  createSpace,\n  getRecord,\n  updateRecord,\n  uploadAttachment,\n  urlBuilder,\n  axios as defaultAxios,\n  GET_RECORD_URL,\n  permanentDeleteSpace,\n  listAccessToken,\n  deleteAccessToken,\n} from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport { CacheService } from '../src/cache/cache.service';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport { permanentDeleteTable, createField, createTable, initApp } from './utils/init-app';\n\ndescribe('OpenAPI AttachmentController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let table: ITableFullVo;\n  let filePath: string;\n  let appUrl: string;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    appUrl = appCtx.appUrl;\n    filePath = path.join(StorageAdapter.TEMPORARY_DIR, 'test-file.txt');\n    fs.writeFileSync(filePath, 'This is a test file for attachment upload.');\n  });\n\n  afterAll(async () => {\n    if (fs.existsSync(filePath)) {\n      fs.unlinkSync(filePath);\n    }\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, { name: 'table1' });\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  it('should upload and typecast attachment', async () => {\n    const field = await createField(table.id, { type: FieldType.Attachment });\n\n    expect(fs.existsSync(filePath)).toBe(true);\n\n    const fileContent = fs.createReadStream(filePath);\n\n    const record1 = await uploadAttachment(table.id, table.records[0].id, field.id, fileContent, {\n      filename: '😀1 2.txt',\n    });\n\n    expect(record1.status).toBe(201);\n    expect((record1.data.fields[field.id] as Array<object>).length).toEqual(1);\n    console.log('record1.data.fields[field.id]', record1.data.fields[field.id]);\n    expect((record1.data.fields[field.id] as Array<IAttachmentItem>)[0]!.name).toEqual('😀1 2.txt');\n\n    const existingAttachment = (record1.data.fields[field.id] as IAttachmentCellValue)[0]!;\n    const presignedUrl = existingAttachment.presignedUrl || '';\n    const localAttachmentUrl = presignedUrl.startsWith('http')\n      ? presignedUrl\n      : `${appUrl}${presignedUrl}`;\n    const record2 = await uploadAttachment(\n      table.id,\n      table.records[0].id,\n      field.id,\n      localAttachmentUrl\n    );\n    expect(record2.status).toBe(201);\n    expect((record2.data.fields[field.id] as Array<object>).length).toEqual(2);\n\n    const field2 = await createField(table.id, { type: FieldType.Attachment });\n    const record3 = await updateRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: true,\n      record: {\n        fields: {\n          [field2.id]: (record2.data.fields[field.id] as Array<{ id: string }>)\n            .map((item) => item.id)\n            .join(','),\n        },\n      },\n    });\n    expect((record3.data.fields[field2.id] as Array<object>).length).toEqual(2);\n\n    const field3 = await createField(table.id, { type: FieldType.Attachment });\n    const record4 = await updateRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: true,\n      record: {\n        fields: {\n          [field3.id]: (record2.data.fields[field.id] as Array<{ id: string }>).map(\n            (item) => item.id\n          ),\n        },\n      },\n    });\n    expect((record4.data.fields[field3.id] as Array<object>).length).toEqual(2);\n  });\n\n  it('should get thumbnail url', async () => {\n    const eventEmitterService = app.get(EventEmitterService);\n    const awaitWithEvent = createAwaitWithEvent(eventEmitterService, Events.CROP_IMAGE_COMPLETE);\n    const imagePath = path.join(StorageAdapter.TEMPORARY_DIR, `./${getRandomString(12)}.svg`);\n    fs.writeFileSync(\n      imagePath,\n      `<svg width=\"200\" height=\"200\" xmlns=\"http://www.w3.org/2000/svg\">\n  <circle cx=\"100\" cy=\"100\" r=\"80\" fill=\"blue\" />\n  <rect x=\"60\" y=\"60\" width=\"80\" height=\"80\" fill=\"yellow\" />\n</svg>`\n    );\n    const imageStream = fs.createReadStream(imagePath);\n    const field = await createField(table.id, { type: FieldType.Attachment });\n\n    await awaitWithEvent(async () => {\n      await uploadAttachment(table.id, table.records[0].id, field.id, imageStream);\n      fs.unlinkSync(imagePath);\n    });\n    eventEmitterService.eventEmitter.removeAllListeners(Events.CROP_IMAGE_COMPLETE);\n    const record = await getRecord(table.id, table.records[0].id);\n    const attachment = (record.data.fields[field.name] as IAttachmentCellValue)[0];\n    expect(attachment?.lgThumbnailUrl).toBe(attachment.presignedUrl);\n    expect(attachment?.smThumbnailUrl).toBeDefined();\n    expect(attachment.smThumbnailUrl).not.toBe(attachment.presignedUrl);\n  });\n\n  it('should write attachment with simplified ro format without typecast', async () => {\n    // Step 1: Upload attachment to get token\n    const field = await createField(table.id, { type: FieldType.Attachment });\n\n    expect(fs.existsSync(filePath)).toBe(true);\n\n    const fileContent = fs.createReadStream(filePath);\n    const uploadResult = await uploadAttachment(\n      table.id,\n      table.records[0].id,\n      field.id,\n      fileContent,\n      {\n        filename: 'test-upload.txt',\n      }\n    );\n\n    expect(uploadResult.status).toBe(201);\n    const uploadedAttachment = (uploadResult.data.fields[field.id] as IAttachmentCellValue)[0]!;\n    expect(uploadedAttachment).toBeDefined();\n    expect(uploadedAttachment.token).toBeDefined();\n    expect(uploadedAttachment.size).toBeDefined();\n    expect(uploadedAttachment.mimetype).toBeDefined();\n\n    // Step 2: Create another field to test writing with simplified format\n    const field2 = await createField(table.id, { type: FieldType.Attachment });\n\n    // Step 3: Write attachment using simplified format WITHOUT typecast\n    const simplifiedAttachmentRo = [\n      {\n        name: 'renamed-file.txt', // User can rename\n        token: uploadedAttachment.token,\n      },\n    ];\n\n    const updateResult = await updateRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: false, // ❗ Key point: without typecast\n      record: {\n        fields: {\n          [field2.id]: simplifiedAttachmentRo,\n        },\n      },\n    });\n\n    expect(updateResult.status).toBe(200);\n\n    // Step 4: Re-fetch record to verify data is actually stored in DB\n    const storedRecord = await getRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n    });\n    const resultAttachments = storedRecord.data.fields[field2.id] as IAttachmentCellValue;\n    expect(resultAttachments).toBeDefined();\n    expect(resultAttachments.length).toBe(1);\n\n    // Step 5: Verify all metadata is present from stored data\n    const resultAttachment = resultAttachments[0]!;\n    console.log('resultAttachment from DB:', resultAttachment);\n    expect(resultAttachment.id).toBeDefined();\n    expect(resultAttachment.id).toMatch(/^act/); // Should have attachment ID prefix\n    expect(resultAttachment.name).toBe('renamed-file.txt'); // Should use the name from ro\n    expect(resultAttachment.token).toBe(uploadedAttachment.token); // Same token\n    expect(resultAttachment.size).toBe(uploadedAttachment.size); // Metadata from DB\n    expect(resultAttachment.mimetype).toBe(uploadedAttachment.mimetype); // Metadata from DB\n    expect(resultAttachment.path).toBeDefined(); // Metadata from DB\n    expect(resultAttachment.presignedUrl).toBeDefined();\n\n    // Step 6: Test with optional id (reuse existing attachment id)\n    const field3 = await createField(table.id, { type: FieldType.Attachment });\n    const simplifiedAttachmentRoWithId = [\n      {\n        id: resultAttachment.id, // Reuse the id\n        name: 'renamed-again.txt',\n        token: uploadedAttachment.token,\n      },\n    ];\n\n    const updateResult2 = await updateRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: false, // Still without typecast\n      record: {\n        fields: {\n          [field3.id]: simplifiedAttachmentRoWithId,\n        },\n      },\n    });\n\n    expect(updateResult2.status).toBe(200);\n\n    // Step 7: Re-fetch record again to verify id reuse is stored correctly\n    const storedRecord2 = await getRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n    });\n    const resultAttachments2 = storedRecord2.data.fields[field3.id] as IAttachmentCellValue;\n    expect(resultAttachments2.length).toBe(1);\n\n    const resultAttachment2 = resultAttachments2[0]!;\n    console.log('resultAttachment2 from DB:', resultAttachment2);\n    expect(resultAttachment2.id).toBe(resultAttachment.id); // Should reuse the same id\n    expect(resultAttachment2.name).toBe('renamed-again.txt');\n    expect(resultAttachment2.token).toBe(uploadedAttachment.token);\n    expect(resultAttachment2.size).toBeDefined();\n    expect(resultAttachment2.mimetype).toBeDefined();\n    expect(resultAttachment2.path).toBeDefined();\n  });\n\n  it('should get attachment absolute url by token', async () => {\n    const space = await createSpace({ name: 'access token space' }).then((res) => res.data);\n    const base = await createBase({ spaceId: space.id, name: 'access token base' }).then(\n      (res) => res.data\n    );\n    const table = await createTable(base.id, { name: 'table1' });\n    const field = await createField(table.id, {\n      name: 'attachment123',\n      type: FieldType.Attachment,\n    });\n\n    expect(fs.existsSync(filePath)).toBe(true);\n\n    const fileContent = fs.createReadStream(filePath);\n    const recordId = table.records[0].id;\n    const record = await uploadAttachment(table.id, recordId, field.id, fileContent);\n\n    expect(record.status).toBe(201);\n    expect((record.data.fields[field.id] as Array<object>).length).toEqual(1);\n    const attachment = (record.data.fields[field.id] as IAttachmentCellValue)[0]!;\n    expect(attachment.presignedUrl?.startsWith(appUrl)).toBe(false);\n\n    const defaultCreateRo: CreateAccessTokenRo = {\n      name: 'token1',\n      description: 'token1',\n      scopes: ['table|read', 'record|read'],\n      baseIds: [base.id],\n      spaceIds: [space.id],\n      expiredTime: dayjs(Date.now() + 1000 * 60 * 60 * 24).format('YYYY-MM-DD'),\n    };\n    const { data: recordReadTokenData } = await createAccessToken({\n      ...defaultCreateRo,\n      name: 'record read token',\n      scopes: ['record|read'],\n    });\n\n    const cacheService = app.get(CacheService);\n    await cacheService.del(`attachment:preview:${attachment.token}`);\n\n    const axios = createAxios();\n    axios.defaults.baseURL = defaultAxios.defaults.baseURL;\n    const res = await axios.get(urlBuilder(GET_RECORD_URL, { tableId: table.id, recordId }), {\n      params: {\n        fieldKeyType: FieldKeyType.Id,\n        cellFormat: CellFormat.Json,\n      },\n      headers: {\n        Authorization: `Bearer ${recordReadTokenData.token}`,\n      },\n    });\n\n    expect(res.status).toEqual(200);\n    expect((res.data.fields[field.id] as Array<object>).length).toEqual(1);\n    const attachmentByToken = (res.data.fields[field.id] as IAttachmentCellValue)[0]!;\n    expect(attachmentByToken.presignedUrl?.startsWith(appUrl)).toBe(true);\n\n    await permanentDeleteSpace(space.id);\n    const { data } = await listAccessToken();\n    for (const { id } of data) {\n      await deleteAccessToken(id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/audit-user-fields.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type { IRecordsVo } from '@teable/openapi';\nimport {\n  createBase,\n  createField,\n  createRecords,\n  createTable,\n  deleteBase,\n  getRecord,\n  getRecords,\n  initApp,\n  updateRecord,\n} from './utils/init-app';\n\ndescribe('Audit user fields (API only)', () => {\n  let app: INestApplication;\n  const spaceId = globalThis.testConfig.spaceId;\n  const userName = globalThis.testConfig.userName;\n  const userEmail = globalThis.testConfig.email;\n  let baseId: string;\n\n  const basicFields: IFieldRo[] = [\n    {\n      name: 'Title',\n      type: FieldType.SingleLineText,\n    },\n  ];\n\n  const getRecordById = (records: IRecordsVo['records'], id: string) =>\n    records.find((r) => r.id === id);\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const base = await createBase({ name: 'audit-user', spaceId });\n    baseId = base.id;\n  });\n\n  afterAll(async () => {\n    await deleteBase(baseId);\n    await app.close();\n  });\n\n  it('populates CreatedBy on new records', async () => {\n    const table = await createTable(baseId, { name: 'audit-created', fields: basicFields });\n    const titleFieldId = table.fields?.find((f) => f.name === 'Title')?.id as string;\n    const createdByField = await createField(table.id, { type: FieldType.CreatedBy });\n\n    const { records: createdRecords } = await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [titleFieldId]: 'alpha',\n          },\n        },\n      ],\n    });\n\n    const createdId = createdRecords[0].id;\n    const list = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n    const target = getRecordById(list.records, createdId);\n\n    expect(target?.fields[createdByField.id]).toMatchObject({\n      id: globalThis.testConfig.userId,\n      title: userName,\n      email: userEmail,\n    });\n  });\n\n  it('updates LastModifiedBy and formulas referencing CreatedBy return the user name', async () => {\n    const table = await createTable(baseId, { name: 'audit-last-mod', fields: basicFields });\n    const titleFieldId = table.fields?.find((f) => f.name === 'Title')?.id as string;\n    const createdByField = await createField(table.id, { type: FieldType.CreatedBy });\n    const lastModifiedByField = await createField(table.id, { type: FieldType.LastModifiedBy });\n\n    const { records: createdRecords } = await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [titleFieldId]: 'first',\n          },\n        },\n      ],\n    });\n    const recordId = createdRecords[0].id;\n\n    await updateRecord(table.id, recordId, {\n      record: {\n        fields: {\n          [titleFieldId]: 'updated',\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const updatedJson = await getRecord(table.id, recordId);\n\n    expect(updatedJson.fields[createdByField.id]).toMatchObject({\n      title: userName,\n      email: userEmail,\n    });\n    expect(updatedJson.fields[lastModifiedByField.id]).toMatchObject({\n      title: userName,\n      email: userEmail,\n    });\n  });\n\n  it('supports searching on user audit fields', async () => {\n    const table = await createTable(baseId, { name: 'audit-search', fields: basicFields });\n    const titleFieldId = table.fields?.find((f) => f.name === 'Title')?.id as string;\n    const createdByField = await createField(table.id, { type: FieldType.CreatedBy });\n\n    const { records: createdRecords } = await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [titleFieldId]: 'search-me',\n          },\n        },\n      ],\n    });\n    const recordId = createdRecords[0].id;\n\n    const searchRes = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      search: [userName, createdByField.id],\n    });\n\n    expect(searchRes.records.map((r) => r.id)).toContain(recordId);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/auth.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { JwtService } from '@nestjs/jwt';\nimport { DriverClient, generateAccountId, HttpErrorCode } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type {\n  CreateAccessTokenVo,\n  CreateSpaceInvitationLinkVo,\n  ICommentVo,\n  ICreateCommentRo,\n  ICreatePluginVo,\n  IDeleteUserErrorData,\n  IGetTempTokenVo,\n  ITableFullVo,\n  IUserMeVo,\n  ISettingVo,\n} from '@teable/openapi';\nimport {\n  ADD_PIN,\n  CHANGE_EMAIL,\n  CommentNodeType,\n  CREATE_ACCESS_TOKEN,\n  CREATE_BASE,\n  CREATE_COMMENT,\n  CREATE_COMMENT_SUBSCRIBE,\n  CREATE_PLUGIN,\n  CREATE_SPACE,\n  CREATE_SPACE_INVITATION_LINK,\n  CREATE_TABLE,\n  createAxios,\n  DELETE_BASE,\n  DELETE_SPACE,\n  DELETE_USER,\n  GET_TEMP_TOKEN,\n  PERMANENT_DELETE_SPACE,\n  PinType,\n  PluginPosition,\n  PluginStatus,\n  SEND_CHANGE_EMAIL_CODE,\n  sendSignupVerificationCode,\n  SIGN_IN,\n  signup,\n  urlBuilder,\n  USER_ME,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport axios from 'axios';\nimport { vi } from 'vitest';\nimport { AUTH_SESSION_COOKIE_NAME } from '../src/const';\nimport { SettingService } from '../src/features/setting/setting.service';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\ndescribe('Auth Controller (e2e)', () => {\n  let app: INestApplication;\n  let prismaService: PrismaService;\n  let settingService: SettingService;\n  let originalGetSetting: ISettingVo;\n\n  const authTestEmail = 'auth@test-auth.com';\n\n  beforeAll(async () => {\n    process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE = '0';\n    process.env.BACKEND_SIGNUP_VERIFICATION_SEND_CODE_MAIL_RATE = '0';\n    process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE = '0';\n\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prismaService = app.get(PrismaService);\n    settingService = app.get(SettingService);\n    originalGetSetting = await settingService.getSetting();\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  afterEach(async () => {\n    await prismaService.user.deleteMany({ where: { email: authTestEmail } });\n  });\n\n  it('api/auth/signup - password min length', async () => {\n    const error = await getError(() =>\n      signup({\n        email: authTestEmail,\n        password: '123456',\n      })\n    );\n    expect(error?.status).toBe(400);\n  });\n\n  it('api/auth/signup - password include letter and number', async () => {\n    const error = await getError(() =>\n      signup({\n        email: authTestEmail,\n        password: '12345678',\n      })\n    );\n    expect(error?.status).toBe(400);\n  });\n\n  it('api/auth/signup - email is already registered', async () => {\n    const error = await getError(() =>\n      signup({\n        email: globalThis.testConfig.email,\n        password: '12345678a',\n      })\n    );\n    expect(error?.status).toBe(409);\n  });\n\n  it('api/auth/signup - system email', async () => {\n    const error = await getError(() =>\n      signup({\n        email: 'anonymous@system.teable.ai',\n        password: '12345678a',\n      })\n    );\n    expect(error?.status).toBe(400);\n  });\n\n  it('api/auth/signup - invite email', async () => {\n    await prismaService.user.create({\n      data: {\n        email: 'invite@test-invite-signup.com',\n        name: 'Invite',\n      },\n    });\n    const res = await signup({\n      email: 'invite@test-invite-signup.com',\n      password: '12345678a',\n    });\n    expect(res.status).toBe(201);\n    await prismaService.user.delete({\n      where: { email: 'invite@test-invite-signup.com' },\n    });\n  });\n\n  describe('sign up with email verification', () => {\n    beforeEach(async () => {\n      vi.spyOn(settingService, 'getSetting').mockImplementation(async () => {\n        return {\n          ...originalGetSetting,\n          enableEmailVerification: true,\n        };\n      });\n    });\n\n    afterEach(() => {\n      vi.restoreAllMocks();\n    });\n\n    it('api/auth/signup - email verification is required', async () => {\n      const error = await getError(() =>\n        signup({\n          email: authTestEmail,\n          password: '12345678a',\n        })\n      );\n      expect(error?.status).toBe(422);\n    });\n\n    it('api/auth/signup - email verification is invalid', async () => {\n      const error = await getError(() =>\n        signup({\n          email: authTestEmail,\n          password: '12345678a',\n          verification: {\n            token: 'invalid',\n            code: 'invalid',\n          },\n        })\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('api/auth/signup - email verification success', async () => {\n      const error = await getError(() =>\n        signup({\n          email: authTestEmail,\n          password: '12345678a',\n        })\n      );\n      expect(error?.data).not.toBeUndefined();\n      const data = error?.data as { token: string; expiresTime: number };\n      expect(data.token).not.toBeUndefined();\n      expect(data.expiresTime).not.toBeUndefined();\n      const jwtService = app.get(JwtService);\n      const decoded = await jwtService.verifyAsync<{ email: string; code: string }>(data.token);\n      const res = await signup({\n        email: authTestEmail,\n        password: '12345678a',\n        verification: {\n          token: data.token,\n          code: decoded.code,\n        },\n      });\n      expect(res.data.email).toBe(authTestEmail);\n    });\n  });\n\n  it('api/auth/send-signup-verification-code', async () => {\n    const res = await sendSignupVerificationCode(authTestEmail);\n    expect(res.data.token).not.toBeUndefined();\n    expect(res.data.expiresTime).not.toBeUndefined();\n  });\n\n  it('api/auth/send-signup-verification-code - registered email', async () => {\n    const error = await getError(() => sendSignupVerificationCode(globalThis.testConfig.email));\n    expect(error?.status).toBe(409);\n  });\n\n  it('api/auth/send-signup-verification-code - system email', async () => {\n    const error = await getError(() => sendSignupVerificationCode('anonymous@system.teable.ai'));\n    expect(error?.status).toBe(400);\n  });\n\n  it('api/auth/send-signup-verification-code - invite email', async () => {\n    const inviteEmail = 'invite@test-invite-signup-verification-code.com';\n    await prismaService.user.create({\n      data: {\n        email: inviteEmail,\n        name: 'Invite',\n      },\n    });\n    const res = await sendSignupVerificationCode(inviteEmail);\n    expect(res.status).toBe(200);\n    await prismaService.user.delete({\n      where: { email: inviteEmail },\n    });\n  });\n\n  describe('change email', () => {\n    const changeEmail = 'change-email@test-change-email.com';\n    const changedEmail = 'changed-email@test-changed-email.com';\n    let changeEmailAxios: AxiosInstance;\n\n    beforeEach(async () => {\n      changeEmailAxios = await createNewUserAxios({\n        email: changeEmail,\n        password: '12345678a',\n      });\n    });\n\n    afterEach(async () => {\n      await prismaService.user.deleteMany({ where: { email: changeEmail } });\n      await prismaService.user.deleteMany({ where: { email: changedEmail } });\n    });\n\n    it('api/auth/send-change-email-code - new email is already registered', async () => {\n      const error = await getError(() =>\n        changeEmailAxios.post(SEND_CHANGE_EMAIL_CODE, {\n          email: globalThis.testConfig.email,\n          password: '12345678a',\n        })\n      );\n      expect(error?.status).toBe(409);\n    });\n\n    it('api/auth/send-change-email-code - password is incorrect', async () => {\n      const error = await getError(() =>\n        changeEmailAxios.post(SEND_CHANGE_EMAIL_CODE, {\n          email: changedEmail,\n          password: '12345678',\n        })\n      );\n      expect(error?.code).toBe(HttpErrorCode.INVALID_CREDENTIALS);\n    });\n\n    it('api/auth/send-change-email-code - same email', async () => {\n      const error = await getError(() =>\n        changeEmailAxios.post(SEND_CHANGE_EMAIL_CODE, {\n          email: changeEmail,\n          password: '12345678a',\n        })\n      );\n      expect(error?.code).toBe(HttpErrorCode.CONFLICT);\n    });\n\n    it('api/auth/change-email', async () => {\n      const codeRes = await changeEmailAxios.post(SEND_CHANGE_EMAIL_CODE, {\n        email: changedEmail,\n        password: '12345678a',\n      });\n      expect(codeRes.data.token).not.toBeUndefined();\n      const jwtService = app.get(JwtService);\n      const decoded = await jwtService.verifyAsync<{ email: string; code: string }>(\n        codeRes.data.token\n      );\n      const newChangeEmailAxios = await createNewUserAxios({\n        email: changeEmail,\n        password: '12345678a',\n      });\n      const changeRes = await newChangeEmailAxios.patch(CHANGE_EMAIL, {\n        email: changedEmail,\n        token: codeRes.data.token,\n        code: decoded.code,\n      });\n      expect(JSON.stringify(changeRes.headers['set-cookie'])).toContain(\n        `\"${AUTH_SESSION_COOKIE_NAME}=;`\n      );\n      const newAxios = axios.create({\n        baseURL: codeRes.config.baseURL,\n      });\n      const res = await newAxios.post(SIGN_IN, {\n        email: changedEmail,\n        password: '12345678a',\n      });\n      expect(res.data.email).toBe(changedEmail);\n    });\n\n    it('api/auth/change-email - token is invalid', async () => {\n      const error = await getError(() =>\n        changeEmailAxios.patch(CHANGE_EMAIL, {\n          email: changedEmail,\n          token: 'invalid',\n          code: 'invalid',\n        })\n      );\n      expect(error?.code).toBe(HttpErrorCode.INVALID_CAPTCHA);\n    });\n\n    it('api/auth/change-email - code is invalid', async () => {\n      const codeRes = await changeEmailAxios.post(SEND_CHANGE_EMAIL_CODE, {\n        email: changedEmail,\n        password: '12345678a',\n      });\n      const error = await getError(() =>\n        changeEmailAxios.patch(CHANGE_EMAIL, {\n          email: changedEmail,\n          token: codeRes.data.token,\n          code: 'invalid',\n        })\n      );\n      expect(error?.code).toBe(HttpErrorCode.INVALID_CAPTCHA);\n    });\n  });\n\n  it('api/auth/temp-token', async () => {\n    const userAxios = await createNewUserAxios({\n      email: 'temp-token@test-temp-token.com',\n      password: '12345678',\n    });\n    const res = await userAxios.get<IGetTempTokenVo>(GET_TEMP_TOKEN);\n    expect(res.data.accessToken).not.toBeUndefined();\n    expect(res.data.expiresTime).not.toBeUndefined();\n    const newAxios = createAxios();\n    newAxios.interceptors.request.use((config) => {\n      config.headers.Authorization = `Bearer ${res.data.accessToken}`;\n      config.baseURL = res.config.baseURL;\n      return config;\n    });\n    const userRes = await newAxios.get<IUserMeVo>(USER_ME);\n    expect(userRes.data.email).toBe('temp-token@test-temp-token.com');\n  });\n\n  const createTestDataForDeleteUser = async (\n    userAxios: AxiosInstance,\n    prismaService: PrismaService\n  ) => {\n    const user = await userAxios.get<IUserMeVo>(USER_ME);\n    const userId = user.data.id;\n    // create space\n    const spaceRes = await userAxios.post(CREATE_SPACE, {\n      name: 'test-delete-user-space',\n    });\n    const spaceId = spaceRes.data.id;\n    const space2 = await userAxios.post(CREATE_SPACE, {\n      name: 'test-delete-user-space-2',\n    });\n    const deleteSpaceId = space2.data.id;\n    await userAxios.delete(\n      urlBuilder(DELETE_SPACE, {\n        spaceId: space2.data.id,\n      })\n    );\n    // create base\n    const baseRes = await userAxios.post(CREATE_BASE, {\n      name: 'test-delete-user-base',\n      spaceId,\n    });\n    const baseId = baseRes.data.id;\n    const createBase2 = await userAxios.post(CREATE_BASE, {\n      name: 'test-delete-user-base-2',\n      spaceId,\n    });\n    await userAxios.delete(\n      urlBuilder(DELETE_BASE, {\n        baseId: createBase2.data.id,\n      })\n    );\n    const deleteBaseId = createBase2.data.id;\n\n    const table = await userAxios.post<ITableFullVo>(\n      urlBuilder(CREATE_TABLE, {\n        baseId,\n      }),\n      {\n        name: 'test-delete-user-table',\n      }\n    );\n    const tableId = table.data.id;\n    const recordId = table.data.records[0].id;\n    const comment = await userAxios.post<ICommentVo>(\n      urlBuilder(CREATE_COMMENT, {\n        tableId,\n        recordId,\n      }),\n      {\n        content: [\n          {\n            type: CommentNodeType.Paragraph,\n            children: [\n              {\n                type: CommentNodeType.Text,\n                value: 'test-delete-user-comment',\n              },\n            ],\n          },\n        ],\n      } as ICreateCommentRo\n    );\n    const commentId = comment.data.id;\n\n    // token\n    const tokenRes = await userAxios.post<CreateAccessTokenVo>(CREATE_ACCESS_TOKEN, {\n      name: 'test-delete-user-token',\n      scopes: ['record:read'],\n      expiredTime: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),\n    });\n    const accessTokenId = tokenRes.data.id;\n    // create account\n    await prismaService.account.create({\n      data: {\n        id: generateAccountId(),\n        userId,\n        type: 'access_token',\n        provider: 'teable',\n        providerId: 'test-delete-user-token-' + new Date().getTime(),\n      },\n    });\n\n    // create comment subscribe\n    await userAxios.post(urlBuilder(CREATE_COMMENT_SUBSCRIBE, { tableId, recordId }));\n    // create invitation\n    const invitation = await userAxios.post<CreateSpaceInvitationLinkVo>(\n      urlBuilder(CREATE_SPACE_INVITATION_LINK, { spaceId }),\n      {\n        role: 'owner',\n      }\n    );\n    const invitationId = invitation.data.invitationId;\n    // create invitation record\n    const invitationRecord = await prismaService.invitationRecord.create({\n      data: {\n        invitationId,\n        spaceId,\n        type: 'link',\n        inviter: userId,\n        accepter: 'xxxxxx',\n      },\n      select: {\n        id: true,\n      },\n    });\n    const invitationRecordId = invitationRecord.id;\n\n    // OAuthApp\n    const oauthAppClientId = 'test-delete-user-oauth-app-' + new Date().getTime();\n    await prismaService.oAuthApp.create({\n      data: {\n        name: 'delete-user-oauth-app',\n        clientId: oauthAppClientId,\n        createdBy: userId,\n        homepage: 'https://test-delete-user-oauth-app.com',\n      },\n    });\n    await prismaService.oAuthAppAuthorized.create({\n      data: {\n        clientId: oauthAppClientId,\n        userId,\n        authorizedTime: new Date().toISOString(),\n      },\n    });\n    const oauthAppSecret = await prismaService.oAuthAppSecret.create({\n      data: {\n        clientId: oauthAppClientId,\n        secret: 'delete-user-oauth-app-secret-' + new Date().getTime(),\n        maskedSecret: 'delete-user-oauth-app-secret-' + new Date().getTime(),\n        createdBy: userId,\n      },\n    });\n    const oauthAppSecretId = oauthAppSecret.id;\n    await prismaService.oAuthAppToken.create({\n      data: {\n        appSecretId: oauthAppSecretId,\n        refreshTokenSign: 'delete-user-oauth-app-refresh-token-sign-' + new Date().getTime(),\n        expiredTime: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),\n        createdBy: userId,\n        clientId: oauthAppClientId,\n      },\n    });\n\n    // pin space\n    await userAxios.post(ADD_PIN, {\n      id: spaceId,\n      type: PinType.Space,\n    });\n    const pinSpaceId = spaceId;\n\n    // plugin\n    const plugin = await userAxios.post<ICreatePluginVo>(CREATE_PLUGIN, {\n      name: 'delete-user-plugin',\n      logo: 'https://test-delete-user-plugin.com/logo.png',\n      positions: [PluginPosition.Dashboard],\n    });\n    const developingPluginId = plugin.data.id;\n    const publishedPlugin = await userAxios.post<ICreatePluginVo>(CREATE_PLUGIN, {\n      name: 'pub-user-plugin',\n      logo: 'https://test-delete-user-plugin.com/logo.png',\n      positions: [PluginPosition.Dashboard],\n    });\n    const publishedPluginId = publishedPlugin.data.id;\n    await prismaService.plugin.update({\n      where: { id: publishedPluginId },\n      data: {\n        status: PluginStatus.Published,\n      },\n    });\n\n    return {\n      spaceId,\n      baseId,\n      tableId,\n      recordId,\n      commentId,\n      deleteBaseId,\n      deleteSpaceId,\n      accessTokenId,\n      invitationId,\n      invitationRecordId,\n      oauthAppClientId,\n      oauthAppSecretId,\n      developingPluginId,\n      publishedPluginId,\n      pinSpaceId,\n      userId,\n    };\n  };\n\n  it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n    'api/auth/delete-user - need confirm',\n    async () => {\n      const userAxios = await createNewUserAxios({\n        email: 'delete-user@test-delete-user.com',\n        password: '12345678',\n      });\n      const error = await getError(() => userAxios.delete(DELETE_USER));\n      expect(error?.status).toBe(400);\n      expect(error?.message).toContain('confirm');\n      const error2 = await getError(() =>\n        userAxios.delete(DELETE_USER, { params: { confirm: 'DELETE1' } })\n      );\n      expect(error2?.status).toBe(400);\n      expect(error2?.message).toContain('Please enter DELETE to confirm');\n    }\n  );\n\n  it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n    'api/auth/delete-user',\n    async () => {\n      await prismaService.user.deleteMany({\n        where: {\n          email: 'delete-user@test-delete-user.com',\n        },\n      });\n      const userAxios = await createNewUserAxios({\n        email: 'delete-user@test-delete-user.com',\n        password: '12345678',\n      });\n      const testData = await createTestDataForDeleteUser(userAxios, prismaService);\n      const error = await getError(() =>\n        userAxios.delete(DELETE_USER, { params: { confirm: 'DELETE' } })\n      );\n      expect(error?.status).toBe(400);\n      const errorData = error?.data as IDeleteUserErrorData;\n      expect(errorData.spaces.length).toBe(2);\n      expect(errorData.spaces).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({\n            id: testData.deleteSpaceId,\n            deletedTime: expect.any(String),\n          }),\n          expect.objectContaining({\n            id: testData.spaceId,\n            deletedTime: null,\n          }),\n        ])\n      );\n      for (const space of errorData.spaces) {\n        const spaceRes = await userAxios.delete(\n          urlBuilder(PERMANENT_DELETE_SPACE, { spaceId: space.id })\n        );\n        expect(spaceRes.status).toBe(200);\n      }\n      const res = await userAxios.delete(DELETE_USER, { params: { confirm: 'DELETE' } });\n      expect(res.status).toBe(200);\n      // validate data\n      // token\n      const tokenRes = await prismaService.accessToken.findFirst({\n        where: {\n          id: testData.accessTokenId,\n        },\n      });\n      expect(tokenRes).toBeNull();\n      // account\n      const accountRes = await prismaService.account.findFirst({\n        where: {\n          id: testData.accessTokenId,\n        },\n      });\n      expect(accountRes).toBeNull();\n      // comment subscribe\n      const commentSubscribeRes = await prismaService.commentSubscription.findFirst({\n        where: {\n          createdBy: testData.userId,\n        },\n      });\n      expect(commentSubscribeRes).toBeNull();\n      // invitation\n      const invitationRes = await prismaService.invitation.findFirst({\n        where: {\n          id: testData.invitationId,\n        },\n      });\n      expect(invitationRes).toBeNull();\n      // invitation record\n      const invitationRecordRes = await prismaService.invitationRecord.findFirst({\n        where: {\n          id: testData.invitationRecordId,\n        },\n      });\n      expect(invitationRecordRes).toBeNull();\n      // OAuthApp\n      const oauthAppRes = await prismaService.oAuthApp.findFirst({\n        where: {\n          clientId: testData.oauthAppClientId,\n        },\n      });\n      expect(oauthAppRes).toBeNull();\n      // OAuthAppSecret\n      const oauthAppSecretRes = await prismaService.oAuthAppSecret.findFirst({\n        where: {\n          id: testData.oauthAppSecretId,\n        },\n      });\n      expect(oauthAppSecretRes).toBeNull();\n      // OAuthAppToken\n      const oauthAppTokenRes = await prismaService.oAuthAppToken.findFirst({\n        where: {\n          appSecretId: testData.oauthAppSecretId,\n        },\n      });\n      expect(oauthAppTokenRes).toBeNull();\n      // pin space\n      const pinSpaceRes = await prismaService.pinResource.findFirst({\n        where: {\n          resourceId: testData.pinSpaceId,\n        },\n      });\n      expect(pinSpaceRes).toBeNull();\n      // plugin\n      const developingPluginRes = await prismaService.plugin.findFirst({\n        where: {\n          id: testData.developingPluginId,\n        },\n      });\n      expect(developingPluginRes).toBeNull();\n      const publishedPluginRes = await prismaService.plugin.findFirst({\n        where: {\n          id: testData.publishedPluginId,\n        },\n      });\n      expect(publishedPluginRes).toBeDefined();\n      await prismaService.plugin.delete({\n        where: {\n          id: testData.publishedPluginId,\n        },\n      });\n      // user\n      const userRes = await prismaService.user.findFirst({\n        where: {\n          id: testData.userId,\n          name: 'Deleted User',\n          permanentDeletedTime: {\n            not: null,\n          },\n          deletedTime: {\n            not: null,\n          },\n        },\n      });\n      expect(userRes).toBeDefined();\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/auto-number.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { domainError, err, v2CoreTokens } from '@teable/v2-core';\nimport type { ITableRecordRepository } from '@teable/v2-core';\nimport { vi } from 'vitest';\nimport { RecordService } from '../src/features/record/record.service';\nimport { V2ContainerService } from '../src/features/v2/v2-container.service';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  convertField,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  deleteRecords,\n} from './utils/init-app';\n\ndescribe('Auto number continuity (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('when record creation fails', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: `auto-number-${Date.now()}` });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should not advance autoNumber if the request fails before hitting the database', async () => {\n      const initial = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const initialCount = initial.records.length;\n      const maxAutoNumber =\n        initial.records.reduce((max, r) => Math.max(max, r.autoNumber ?? 0), 0) || 0;\n\n      const spy = isForceV2\n        ? vi\n            .spyOn(\n              (await app.get(V2ContainerService).getContainer()).resolve<ITableRecordRepository>(\n                v2CoreTokens.tableRecordRepository\n              ),\n              'insertMany'\n            )\n            .mockResolvedValueOnce(\n              err(domainError.unexpected({ message: 'mocked-create-failure' }))\n            )\n        : vi\n            .spyOn(app.get(RecordService), 'batchCreateRecords')\n            .mockImplementationOnce(async () => {\n              throw new Error('mocked-create-failure');\n            });\n\n      await createRecords(\n        table.id,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [table.fields[0].id]: 'should-fail' } }],\n        },\n        500\n      );\n      spy.mockRestore();\n\n      const { records: created } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table.fields[0].id]: 'ok' } }],\n      });\n\n      const after = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const finalMax = after.records.reduce((max, r) => Math.max(max, r.autoNumber ?? 0), 0) || 0;\n\n      expect(after.records.length).toBe(initialCount + 1);\n      expect(finalMax).toBe(maxAutoNumber + 1);\n      expect(created[0].autoNumber).toBe(finalMax);\n    });\n\n    it('should keep autoNumber when missing required field then retry with value', async () => {\n      let initial = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const maxAutoNumber =\n        initial.records.reduce((max, r) => Math.max(max, r.autoNumber ?? 0), 0) || 0;\n      if (initial.records.length) {\n        await deleteRecords(\n          table.id,\n          initial.records.map((r) => r.id)\n        );\n        initial = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      }\n\n      const initialCount = initial.records.length;\n\n      let requiredField = await createField(table.id, {\n        name: 'Required',\n        type: FieldType.SingleLineText,\n      });\n\n      requiredField = await convertField(table.id, requiredField.id, {\n        ...requiredField,\n        notNull: true,\n      });\n\n      await createRecords(\n        table.id,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [requiredField.id]: null } }],\n        },\n        400\n      );\n\n      const { records: created } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [requiredField.id]: 'ok' } }],\n      });\n\n      const after = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const finalMax = after.records.reduce((max, r) => Math.max(max, r.autoNumber ?? 0), 0) || 0;\n\n      expect(after.records.length).toBe(initialCount + 1);\n      expect(finalMax).toBe(maxAutoNumber + 1);\n      expect(created[0].autoNumber).toBe(finalMax);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-duplicate.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions, ILookupOptionsRo } from '@teable/core';\nimport {\n  DriverClient,\n  FieldAIActionType,\n  FieldKeyType,\n  FieldType,\n  Relationship,\n  Role,\n  ViewType,\n} from '@teable/core';\nimport type { ICreateBaseVo, ICreateSpaceVo } from '@teable/openapi';\nimport {\n  BaseNodeResourceType,\n  CREATE_SPACE,\n  createBase,\n  createBaseNode,\n  createDashboard,\n  createField,\n  createPluginPanel,\n  createSpace,\n  deleteBase,\n  deleteRecords,\n  deleteSpace,\n  duplicateBase,\n  EMAIL_SPACE_INVITATION,\n  getBaseList,\n  getBaseNodeTree,\n  getDashboard,\n  getDashboardInstallPlugin,\n  getDashboardList,\n  getField,\n  getFields,\n  getPluginPanel,\n  getPluginPanelPlugin,\n  getTableList,\n  getViewList,\n  installPlugin,\n  installPluginPanel,\n  installViewPlugin,\n  listPluginPanels,\n  LLMProviderType,\n  moveBaseNode,\n  updateSetting,\n  urlBuilder,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport {\n  convertField,\n  createRecords,\n  createTable,\n  getRecords,\n  initApp,\n  updateRecord,\n  permanentDeleteBase,\n} from './utils/init-app';\n\ndescribe('OpenAPI Base Duplicate (e2e)', () => {\n  let app: INestApplication;\n  let base: ICreateBaseVo;\n  let spaceId: string;\n  let newUserAxios: AxiosInstance;\n  let duplicateBaseId: string | undefined;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    newUserAxios = await createNewUserAxios({\n      email: 'test@gmail.com',\n      password: '12345678',\n    });\n\n    const space = await newUserAxios.post<ICreateSpaceVo>(CREATE_SPACE, {\n      name: 'test space',\n    });\n    spaceId = space.data.id;\n    await newUserAxios.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), {\n      role: Role.Owner,\n      emails: [globalThis.testConfig.email],\n    });\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    base = (await createBase({ spaceId, name: 'test base' })).data;\n  });\n\n  afterEach(async () => {\n    await permanentDeleteBase(base.id);\n    if (duplicateBaseId) {\n      await permanentDeleteBase(duplicateBaseId);\n      duplicateBaseId = undefined;\n    }\n  });\n\n  if (globalThis.testConfig.driver !== DriverClient.Pg) {\n    expect(true).toBeTruthy();\n    return;\n  }\n\n  it('duplicate base with cross base link and lookup field', async () => {\n    const base2 = (await createBase({ spaceId, name: 'test base 2' })).data;\n    const base2Table = await createTable(base2.id, { name: 'table1' });\n\n    const table1 = await createTable(base.id, { name: 'table1' });\n\n    const crossBaseLinkField = (\n      await createField(table1.id, {\n        name: 'cross base link field',\n        type: FieldType.Link,\n        options: {\n          baseId: base2.id,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2Table.id,\n        },\n      })\n    ).data;\n\n    await createField(table1.id, {\n      name: 'cross base lookup field',\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: base2Table.id,\n        linkFieldId: crossBaseLinkField.id,\n        lookupFieldId: base2Table.fields[0].id,\n      },\n    });\n\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n    });\n\n    expect(dupResult.status).toBe(201);\n  });\n\n  it('duplicate within current space', async () => {\n    const table1 = await createTable(base.id, { name: 'table1' });\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n    });\n\n    const getResult = await getTableList(dupResult.data.id);\n    const records = await getRecords(getResult.data[0].id);\n    expect(records.records.length).toBe(0);\n\n    expect(getResult.data.length).toBe(1);\n    expect(getResult.data[0].name).toBe(table1.name);\n    expect(getResult.data[0].id).not.toBe(table1.id);\n    await deleteBase(dupResult.data.id);\n  });\n\n  it('duplicate with records', async () => {\n    const table1 = await createTable(base.id, { name: 'table1' });\n    const preRecords = await getRecords(table1.id);\n    await updateRecord(table1.id, preRecords.records[0].id, {\n      record: { fields: { [table1.fields[0].name]: 'new value' } },\n    });\n\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n      withRecords: true,\n    });\n\n    const getResult = await getTableList(dupResult.data.id);\n\n    const records = await getRecords(getResult.data[0].id);\n    expect(records.records[0].lastModifiedBy).toBeFalsy();\n    expect(records.records[0].createdTime).toBeTruthy();\n    expect(records.records[0].fields[table1.fields[0].name]).toEqual('new value');\n    expect(records.records.length).toBe(3);\n\n    await deleteBase(dupResult.data.id);\n  });\n\n  it('duplicate base with tables which have primary formula field, expression with link field', async () => {\n    const table1 = await createTable(base.id, {\n      name: 'table1',\n    });\n    const table2 = await createTable(base.id, { name: 'table2' });\n\n    const fields = (await getFields(table1.id)).data;\n\n    const primaryField = fields.find(({ isPrimary }) => isPrimary)!;\n    // const numberField = fields.find(({ type }) => type === FieldType.Number)!;\n\n    const formulaRelyLinkField = (\n      await createField(table1.id, {\n        name: 'link field1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: table2.id },\n      })\n    ).data;\n\n    const formulaPrimaryField = await convertField(table1.id, primaryField.id, {\n      name: 'formula field',\n      type: FieldType.Formula,\n      options: { expression: `{${formulaRelyLinkField.id}}`, timeZone: 'Asia/Shanghai' },\n    });\n\n    await createField(table2.id, {\n      name: 'link field',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyMany, foreignTableId: table1.id },\n    });\n\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n      withRecords: true,\n    });\n\n    const { id: baseId } = dupResult.data;\n    const tables = await getTableList(baseId);\n\n    const duplicateTable1 = tables.data.find(({ name }) => name === table1.name);\n    const duplicateTable1Fields = (await getFields(duplicateTable1!.id)).data;\n    const duplicateTable1FormulaField = duplicateTable1Fields.find(\n      ({ type }) => type === FieldType.Formula\n    );\n    expect(duplicateTable1FormulaField?.cellValueType).toBe(formulaPrimaryField.cellValueType);\n    expect(duplicateTable1FormulaField?.dbFieldType).toBe(formulaPrimaryField.dbFieldType);\n\n    expect(dupResult.status).toBe(201);\n  });\n\n  it('duplicate base with link field', async () => {\n    const table1 = await createTable(base.id, { name: 'table1' });\n    const table2 = await createTable(base.id, { name: 'table2' });\n\n    // create link field\n    const table2LinkFieldRo: IFieldRo = {\n      name: 'link field',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table1.id,\n      },\n    };\n\n    const table2LinkField = (await createField(table2.id, table2LinkFieldRo)).data;\n\n    const symmetricField = (\n      await getField(\n        table1.id,\n        (table2LinkField.options as ILinkFieldOptions).symmetricFieldId as string\n      )\n    )?.data;\n\n    // update recording link field to one way\n    await convertField(table1.id, symmetricField?.id as string, {\n      type: FieldType.Link,\n      name: symmetricField.name,\n      dbFieldName: symmetricField.dbFieldName,\n      options: {\n        ...symmetricField?.options,\n        relationship: Relationship.OneMany,\n      } as ILinkFieldOptions,\n    });\n\n    await convertField(table1.id, symmetricField?.id as string, {\n      type: FieldType.Link,\n      name: symmetricField.name,\n      dbFieldName: symmetricField.dbFieldName,\n      options: {\n        ...symmetricField?.options,\n        relationship: Relationship.ManyMany,\n      } as ILinkFieldOptions,\n    });\n\n    // create lookup field\n    const table2LookupFieldRo: IFieldRo = {\n      name: 'lookup field',\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: table1.id,\n        linkFieldId: table2LinkField.id,\n        lookupFieldId: table1.fields[0].id,\n      } as ILookupOptionsRo,\n    };\n\n    const table2LookupField = (await createField(table2.id, table2LookupFieldRo)).data;\n\n    const table1LinkField = (\n      await getField(\n        table1.id,\n        (table2LinkField.options as ILinkFieldOptions).symmetricFieldId as string\n      )\n    ).data;\n\n    const table1Records = await getRecords(table1.id);\n    const table2Records = await getRecords(table2.id);\n    // update record before copy\n    await updateRecord(table2.id, table2Records.records[0].id, {\n      record: { fields: { [table2LinkField.name]: [{ id: table1Records.records[0].id }] } },\n    });\n    await updateRecord(table1.id, table1Records.records[0].id, {\n      record: { fields: { [table1.fields[0].name]: 'text 1' } },\n    });\n\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n      withRecords: true,\n    });\n    const newBaseId = dupResult.data.id;\n\n    const getResult = await getTableList(newBaseId);\n    const newTable1 = getResult.data[0];\n    const newTable2 = getResult.data[1];\n\n    const newTable1Records = await getRecords(newTable1.id);\n    const newTable2Records = await getRecords(newTable2.id);\n    expect(newTable1Records.records[0].lastModifiedBy).toBeFalsy();\n    expect(newTable1Records.records[0].createdTime).toBeTruthy();\n    expect(newTable1Records.records[0].fields[table1LinkField.name]).toMatchObject([\n      {\n        id: newTable2Records.records[0].id,\n      },\n    ]);\n    expect(newTable2Records.records[0].fields[table2LookupField.name]).toEqual(['text 1']);\n    expect(newTable1Records.records.length).toBe(3);\n\n    // update record in duplicated table\n    await updateRecord(newTable2.id, table2Records.records[0].id, {\n      record: { fields: { [table2LinkField.name]: [{ id: table1Records.records[1].id }] } },\n    });\n    await updateRecord(newTable1.id, table1Records.records[2].id, {\n      record: { fields: { [table1LinkField.name]: [{ id: table2Records.records[2].id }] } },\n    });\n    await updateRecord(newTable1.id, table1Records.records[1].id, {\n      record: { fields: { [table1.fields[0].name]: 'text 2' } },\n    });\n\n    const newTable1RecordsAfter = await getRecords(newTable1.id);\n    const newTable2RecordsAfter = await getRecords(newTable2.id);\n    expect(newTable1RecordsAfter.records[0].fields[table1LinkField.name]).toBeUndefined();\n    expect(newTable1RecordsAfter.records[1].fields[table1LinkField.name]).toMatchObject([\n      {\n        id: newTable2Records.records[0].id,\n      },\n    ]);\n    expect(newTable2RecordsAfter.records[2].fields[table2LinkField.name]).toMatchObject([\n      {\n        id: newTable1Records.records[2].id,\n      },\n    ]);\n    expect(newTable2RecordsAfter.records[0].fields[table2LookupField.name]).toEqual(['text 2']);\n\n    await deleteBase(dupResult.data.id);\n  });\n\n  it('should autoNumber work in a duplicated table', async () => {\n    await createTable(base.id, { name: 'table1' });\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n      withRecords: true,\n    });\n\n    const getResult = await getTableList(dupResult.data.id);\n    const newTable = getResult.data[0];\n\n    await createRecords(newTable.id, { records: [{ fields: {} }] });\n\n    const records = await getRecords(newTable.id);\n    expect(records.records[records.records.length - 1].autoNumber).toEqual(records.records.length);\n    expect(records.records.length).toBe(4);\n    await deleteBase(dupResult.data.id);\n  });\n\n  it('should duplicate ai field relative config', async () => {\n    const tableWithAiField = await createTable(base.id, { name: 'table-ai-field' });\n\n    const aiSetting = (\n      await updateSetting({\n        aiConfig: {\n          enable: true,\n          llmProviders: [\n            {\n              apiKey: 'test-ai-config',\n              baseUrl: 'localhost:3000/api/test',\n              models: 'test-e2e',\n              name: 'test',\n              type: LLMProviderType.ANTHROPIC,\n            },\n          ],\n        },\n      })\n    ).data;\n\n    const codingModel = aiSetting.aiConfig?.llmProviders[0].models;\n\n    const aiField = (\n      await createField(tableWithAiField.id, {\n        name: 'ai field',\n        type: FieldType.SingleLineText,\n        aiConfig: {\n          attachPrompt: 'test-attach-prompt',\n          modelKey: codingModel,\n          sourceFieldId: tableWithAiField.fields[0].id,\n          type: FieldAIActionType.Summary,\n        },\n      })\n    ).data;\n\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n      withRecords: true,\n    });\n\n    const tableList = await getTableList(dupResult.data.id);\n    const duplicatedTableWithAiField = tableList.data.find(\n      ({ name }) => name === tableWithAiField.name\n    );\n    const duplicatedFields = (await getFields(duplicatedTableWithAiField!.id)).data;\n    const duplicatedAiField = duplicatedFields.find((f) => f.aiConfig);\n    expect(duplicatedAiField?.aiConfig).toEqual({\n      ...aiField.aiConfig,\n      sourceFieldId: duplicatedFields[0].id,\n    });\n\n    await deleteBase(dupResult.data.id);\n  });\n\n  it('should duplicate the base with node [Folder, Table, Dashboard]', async () => {\n    const nodeBaseId = base.id;\n\n    // Create folders using createBaseNode\n    const folder1Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Folder,\n      name: 'Folder 1',\n    }).then((res) => res.data);\n    const folder2Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Folder,\n      name: 'Folder 2',\n    }).then((res) => res.data);\n\n    // Create tables using createBaseNode\n    const table1Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Table,\n      name: 'Table 1',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      views: [{ name: 'Grid view', type: ViewType.Grid }],\n    }).then((res) => res.data);\n    const table2Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Table,\n      name: 'Table 2',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      views: [{ name: 'Grid view', type: ViewType.Grid }],\n    }).then((res) => res.data);\n\n    // Create dashboards using createBaseNode\n    const dashboard1Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Dashboard,\n      name: 'Dashboard 1',\n    }).then((res) => res.data);\n    const dashboard2Node = await createBaseNode(nodeBaseId, {\n      resourceType: BaseNodeResourceType.Dashboard,\n      name: 'Dashboard 2',\n    }).then((res) => res.data);\n\n    // Move table1 into folder1 and dashboard1 into folder2\n    await moveBaseNode(nodeBaseId, table1Node.id, { parentId: folder1Node.id });\n    await moveBaseNode(nodeBaseId, dashboard1Node.id, { parentId: folder2Node.id });\n\n    // Get updated node tree\n    const updatedSourceNodeTree = await getBaseNodeTree(nodeBaseId).then((res) => res.data);\n    const updatedSourceNodes = updatedSourceNodeTree.nodes;\n\n    // Duplicate the base\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy',\n    }).then((res) => res.data);\n\n    duplicateBaseId = dupResult.id;\n\n    // Verify duplicated node tree\n    const duplicatedNodeTree = await getBaseNodeTree(duplicateBaseId).then((res) => res.data);\n    const duplicatedNodes = duplicatedNodeTree.nodes;\n\n    // Verify same number of nodes\n    expect(duplicatedNodes.length).toBe(updatedSourceNodes.length);\n\n    // Verify resource types distribution\n    const sourceResourceTypes = updatedSourceNodes\n      .map((n) => n.resourceType)\n      .sort()\n      .join(',');\n    const duplicatedResourceTypes = duplicatedNodes\n      .map((n) => n.resourceType)\n      .sort()\n      .join(',');\n    expect(duplicatedResourceTypes).toBe(sourceResourceTypes);\n\n    // Verify folder count\n    const sourceFolders = updatedSourceNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Folder\n    );\n    const duplicatedFolders = duplicatedNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Folder\n    );\n    expect(duplicatedFolders.length).toBe(sourceFolders.length);\n\n    // Verify table count\n    const sourceTables = updatedSourceNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Table\n    );\n    const duplicatedTables = duplicatedNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Table\n    );\n    expect(duplicatedTables.length).toBe(sourceTables.length);\n\n    // Verify dashboard count\n    const sourceDashboards = updatedSourceNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Dashboard\n    );\n    const duplicatedDashboards = duplicatedNodes.filter(\n      (n) => n.resourceType === BaseNodeResourceType.Dashboard\n    );\n    expect(duplicatedDashboards.length).toBe(sourceDashboards.length);\n\n    // Verify hierarchy: nodes with parents should still have parents\n    const sourceNodesWithParent = updatedSourceNodes.filter((n) => n.parentId !== null);\n    const duplicatedNodesWithParent = duplicatedNodes.filter((n) => n.parentId !== null);\n    expect(duplicatedNodesWithParent.length).toBe(sourceNodesWithParent.length);\n\n    // Verify folder names are preserved\n    const sourceFolderNames = sourceFolders.map((f) => f.resourceMeta?.name).sort();\n    const duplicatedFolderNames = duplicatedFolders.map((f) => f.resourceMeta?.name).sort();\n    expect(duplicatedFolderNames).toEqual(sourceFolderNames);\n\n    // Verify that table inside folder1 exists in imported base\n    const duplicatedFolder1 = duplicatedFolders.find(\n      (f) => f.resourceMeta?.name === folder1Node.resourceMeta?.name\n    );\n    expect(duplicatedFolder1).toBeDefined();\n    const tableInsideFolder = duplicatedNodes.find((n) => {\n      return n.resourceType === BaseNodeResourceType.Table && n.parentId === duplicatedFolder1!.id;\n    });\n    expect(tableInsideFolder).toBeDefined();\n\n    // Verify that dashboard inside folder2 exists in imported base\n    const duplicatedFolder2 = duplicatedFolders.find(\n      (f) => f.resourceMeta?.name === folder2Node.resourceMeta?.name\n    );\n    expect(duplicatedFolder2).toBeDefined();\n    const dashboardInsideFolder = duplicatedNodes.find((n) => {\n      return (\n        n.resourceType === BaseNodeResourceType.Dashboard && n.parentId === duplicatedFolder2!.id\n      );\n    });\n    expect(dashboardInsideFolder).toBeDefined();\n\n    // Verify tables are accessible\n    const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n    expect(duplicatedTableList.length).toBe(2);\n    expect(duplicatedTableList.map((t) => t.name).sort()).toEqual(\n      [table1Node.resourceMeta?.name, table2Node.resourceMeta?.name].sort()\n    );\n\n    // Verify dashboards are accessible\n    const duplicatedDashboardList = await getDashboardList(duplicateBaseId).then((res) => res.data);\n    expect(duplicatedDashboardList.length).toBe(2);\n    expect(duplicatedDashboardList.map((d) => d.name).sort()).toEqual(\n      [dashboard1Node.resourceMeta?.name, dashboard2Node.resourceMeta?.name].sort()\n    );\n  });\n\n  describe('Duplicate cross space', () => {\n    let newSpace: ICreateSpaceVo;\n    beforeEach(async () => {\n      newSpace = (await createSpace({ name: 'new space' })).data;\n    });\n\n    afterEach(async () => {\n      await deleteSpace(newSpace.id);\n    });\n\n    it('duplicate base to another space', async () => {\n      await createTable(base.id, { name: 'table1' });\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: newSpace.id,\n        name: 'test base copy',\n      });\n\n      const baseResult = await getBaseList({ spaceId: newSpace.id });\n      const tableResult = await getTableList(dupResult.data.id);\n      const records = await getRecords(tableResult.data[0].id);\n      expect(records.records.length).toBe(0);\n      expect(baseResult.data.length).toBe(1);\n\n      expect(tableResult.data.length).toBe(1);\n    });\n  });\n\n  describe('should duplicate all plugins', () => {\n    it('should duplicate all dashboard plugins', async () => {\n      const dashboard = (await createDashboard(base.id, { name: 'dashboard' })).data;\n      const dashboard2 = (await createDashboard(base.id, { name: 'dashboard2' })).data;\n\n      await installPlugin(base.id, dashboard.id, {\n        name: 'plugin1',\n        pluginId: 'plgchart',\n      });\n\n      await installPlugin(base.id, dashboard.id, {\n        name: 'plugin2',\n        pluginId: 'plgchart',\n      });\n\n      await installPlugin(base.id, dashboard2.id, {\n        name: 'plugin2_1',\n        pluginId: 'plgchart',\n      });\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy',\n      });\n      duplicateBaseId = dupResult.data.id;\n      const newBaseId = dupResult.data.id;\n\n      const dashboardList = (await getDashboardList(newBaseId)).data;\n\n      const dashboard1Info = (await getDashboard(newBaseId, dashboardList[0].id)).data;\n\n      expect(dashboard1Info.layout?.length).toBe(2);\n      const installedPlugins = (\n        await getDashboardInstallPlugin(\n          newBaseId,\n          dashboardList[0].id,\n          dashboard1Info.layout![0].pluginInstallId\n        )\n      ).data;\n\n      expect(dashboardList.length).toBe(2);\n      expect(installedPlugins.name).toBe('plugin1');\n    });\n\n    it('should duplicate all panel plugins', async () => {\n      const pluginTable = await createTable(base.id, { name: 'table1PanelPlugin' });\n\n      const panel = (await createPluginPanel(pluginTable.id, { name: 'panel1' })).data;\n      const panel2 = (await createPluginPanel(pluginTable.id, { name: 'panel2' })).data;\n\n      await installPluginPanel(pluginTable.id, panel.id, {\n        name: 'plugin1',\n        pluginId: 'plgchart',\n      });\n\n      await installPluginPanel(pluginTable.id, panel.id, {\n        name: 'plugin2',\n        pluginId: 'plgchart',\n      });\n\n      await installPluginPanel(pluginTable.id, panel2.id, {\n        name: 'plugin2_1',\n        pluginId: 'plgchart',\n      });\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy',\n      });\n      duplicateBaseId = dupResult.data.id;\n      const panelList = (await listPluginPanels(pluginTable.id)).data;\n\n      const panel1Info = (\n        await getPluginPanel(pluginTable.id, panelList.find(({ name }) => name === 'panel1')!.id)\n      ).data;\n\n      const installedPlugins = (\n        await getPluginPanelPlugin(\n          pluginTable.id,\n          panelList.find(({ name }) => name === 'panel1')!.id,\n          panel1Info.layout![0].pluginInstallId\n        )\n      ).data;\n\n      expect(panel1Info.layout?.length).toBe(2);\n      expect(panelList.length).toBe(2);\n      expect(installedPlugins.name).toBe('plugin1');\n    });\n\n    it('should duplicate all view plugins', async () => {\n      const pluginTable = await createTable(base.id, { name: 'table1ViewPlugin' });\n      const tableId = pluginTable.id;\n\n      const sheetView1 = (\n        await installViewPlugin(tableId, { name: 'sheetView1', pluginId: 'plgsheetform' })\n      ).data;\n      const sheetView2 = (\n        await installViewPlugin(tableId, { name: 'sheetView2', pluginId: 'plgsheetform' })\n      ).data;\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy',\n      });\n      duplicateBaseId = dupResult.data.id;\n      const views = (await getViewList(tableId)).data;\n\n      const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);\n\n      expect(pluginViews.length).toBe(2);\n\n      expect(pluginViews.find(({ name }) => name === sheetView1.name)).toBeDefined();\n      expect(pluginViews.find(({ name }) => name === sheetView2.name)).toBeDefined();\n    });\n  });\n\n  // with ai\n  it('should duplicate base with bidirectional link field', async () => {\n    const table1 = await createTable(base.id, { name: 'table1' });\n    const table2 = await createTable(base.id, { name: 'table2' });\n    await deleteRecords(\n      table1.id,\n      table1.records.map((r) => r.id)\n    );\n    await deleteRecords(\n      table2.id,\n      table2.records.map((r) => r.id)\n    );\n    // Create bidirectional link field with dbFieldName 'link'\n    const linkFieldRo: IFieldRo = {\n      name: 'link field',\n      type: FieldType.Link,\n      dbFieldName: 'link',\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n      },\n    };\n\n    const linkField = (await createField(table1.id, linkFieldRo)).data;\n\n    // Get the symmetric field\n    const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n    const symmetricField = (await getField(table2.id, symmetricFieldId)).data;\n\n    // Convert link field to required (notNull: true)\n    await convertField(table1.id, linkField.id, {\n      ...linkFieldRo,\n      notNull: true,\n    });\n    await createRecords(table2.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: {} }, { fields: {} }, { fields: {} }],\n    });\n    // Get records\n    const table2Records = await getRecords(table2.id);\n    await createRecords(table1.id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {\n            [linkField.name]: [{ id: table2Records.records[0].id }],\n          },\n        },\n        {\n          fields: {\n            [linkField.name]: [{ id: table2Records.records[1].id }],\n          },\n        },\n        {\n          fields: {\n            [linkField.name]: [{ id: table2Records.records[2].id }],\n          },\n        },\n      ],\n    });\n\n    // Duplicate base with records\n    const dupResult = await duplicateBase({\n      fromBaseId: base.id,\n      spaceId: spaceId,\n      name: 'test base copy - required link',\n      withRecords: true,\n    });\n\n    duplicateBaseId = dupResult.data.id;\n\n    // Verify duplicated base\n    const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n    expect(duplicatedTableList.length).toBe(2);\n\n    const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'table1')!;\n    const duplicatedTable2 = duplicatedTableList.find((t) => t.name === 'table2')!;\n\n    // Verify link field properties\n    const duplicatedTable1Fields = (await getFields(duplicatedTable1.id)).data;\n    const duplicatedLinkField = duplicatedTable1Fields.find((f) => f.dbFieldName === 'link');\n\n    expect(duplicatedLinkField).toBeDefined();\n    expect(duplicatedLinkField?.type).toBe(FieldType.Link);\n    expect(duplicatedLinkField?.dbFieldName).toBe('link');\n    expect(duplicatedLinkField?.notNull).toBe(true);\n    expect((duplicatedLinkField?.options as ILinkFieldOptions).relationship).toBe(\n      Relationship.ManyMany\n    );\n    expect((duplicatedLinkField?.options as ILinkFieldOptions).foreignTableId).toBe(\n      duplicatedTable2.id\n    );\n\n    // Verify symmetric field\n    const duplicatedTable2Fields = (await getFields(duplicatedTable2.id)).data;\n    const duplicatedSymmetricField = duplicatedTable2Fields.find(\n      (f) => f.id === (duplicatedLinkField?.options as ILinkFieldOptions).symmetricFieldId\n    );\n    expect(duplicatedSymmetricField).toBeDefined();\n\n    // Verify link data is preserved\n    const duplicatedTable1Records = await getRecords(duplicatedTable1.id);\n    const duplicatedTable2Records = await getRecords(duplicatedTable2.id);\n\n    expect(duplicatedTable1Records.records[0].fields[linkField.name]).toMatchObject([\n      { id: duplicatedTable2Records.records[0].id },\n    ]);\n    expect(duplicatedTable1Records.records[1].fields[linkField.name]).toMatchObject([\n      { id: duplicatedTable2Records.records[1].id },\n    ]);\n    expect(duplicatedTable1Records.records[2].fields[linkField.name]).toMatchObject([\n      { id: duplicatedTable2Records.records[2].id },\n    ]);\n\n    // Verify symmetric link data\n    expect(duplicatedTable2Records.records[0].fields[symmetricField.name]).toMatchObject([\n      { id: duplicatedTable1Records.records[0].id },\n    ]);\n    expect(duplicatedTable2Records.records[1].fields[symmetricField.name]).toMatchObject([\n      { id: duplicatedTable1Records.records[1].id },\n    ]);\n    expect(duplicatedTable2Records.records[2].fields[symmetricField.name]).toMatchObject([\n      { id: duplicatedTable1Records.records[2].id },\n    ]);\n  });\n\n  describe('Partial base duplication with nodes parameter', () => {\n    it('should duplicate only selected tables using nodes parameter', async () => {\n      const table1 = await createTable(base.id, { name: 'table1' });\n      const table2 = await createTable(base.id, { name: 'table2' });\n      await createTable(base.id, { name: 'table3' });\n\n      // Create link between table1 and table2\n      const linkField12 = (\n        await createField(table1.id, {\n          name: 'link to table2',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table2.id,\n          },\n        })\n      ).data;\n\n      // Create records and link data\n      const table1Records = await getRecords(table1.id);\n      const table2Records = await getRecords(table2.id);\n\n      await updateRecord(table1.id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField12.name]: [{ id: table2Records.records[0].id }],\n          },\n        },\n      });\n\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table1'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table2'\n      );\n\n      expect(table1Node).toBeDefined();\n      expect(table2Node).toBeDefined();\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - partial',\n        withRecords: true,\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      expect(duplicatedTableList.length).toBe(2);\n      expect(duplicatedTableList.map((t) => t.name).sort()).toEqual(['table1', 'table2'].sort());\n\n      // Verify link field data is copied\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'table1')!;\n      const duplicatedTable2 = duplicatedTableList.find((t) => t.name === 'table2')!;\n      const duplicatedTable1Records = await getRecords(duplicatedTable1.id);\n      const duplicatedTable2Records = await getRecords(duplicatedTable2.id);\n\n      // Link data should be preserved\n      expect(duplicatedTable1Records.records[0].fields[linkField12.name]).toBeDefined();\n      expect(duplicatedTable1Records.records[0].fields[linkField12.name]).toMatchObject([\n        { id: duplicatedTable2Records.records[0].id },\n      ]);\n    });\n\n    it('should handle disconnected link fields when duplicating partial tables', async () => {\n      const table1 = await createTable(base.id, { name: 'table1' });\n      const table2 = await createTable(base.id, { name: 'table2' });\n      const table3 = await createTable(base.id, { name: 'table3' });\n\n      // Create link from table1 to table2\n      const linkField12 = (\n        await createField(table1.id, {\n          name: 'link to table2',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table2.id,\n          },\n        })\n      ).data;\n\n      // Create link from table1 to table3\n      const linkField13 = (\n        await createField(table1.id, {\n          name: 'link to table3',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table3.id,\n          },\n        })\n      ).data;\n\n      // Create records with link data\n      const table1Records = await getRecords(table1.id);\n      const table2Records = await getRecords(table2.id);\n      const table3Records = await getRecords(table3.id);\n\n      await updateRecord(table1.id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField12.name]: [{ id: table2Records.records[0].id }],\n            [linkField13.name]: [{ id: table3Records.records[0].id }],\n          },\n        },\n      });\n\n      // Only duplicate table1 and table2, excluding table3\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table1'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table2'\n      );\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - disconnected links',\n        withRecords: true,\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'table1')!;\n      const duplicatedTable2 = duplicatedTableList.find((t) => t.name === 'table2')!;\n\n      // Get fields of duplicated table1\n      const duplicatedTable1Fields = (await getFields(duplicatedTable1.id)).data;\n      const duplicatedLinkField12 = duplicatedTable1Fields.find((f) => f.name === 'link to table2');\n      const duplicatedLinkField13 = duplicatedTable1Fields.find((f) => f.name === 'link to table3');\n\n      // Link to table2 should exist and remain as Link type\n      expect(duplicatedLinkField12).toBeDefined();\n      expect(duplicatedLinkField12?.type).toBe(FieldType.Link);\n\n      // Link to table3 should be converted to SingleLineText (disconnected - table3 was not included)\n      expect(duplicatedLinkField13).toBeDefined();\n      expect(duplicatedLinkField13?.type).toBe(FieldType.SingleLineText);\n\n      // Get records and verify link field values\n      const duplicatedTable1Records = await getRecords(duplicatedTable1.id);\n      const duplicatedTable2Records = await getRecords(duplicatedTable2.id);\n\n      // Link to table2 should have data and point to the duplicated table2 record\n      expect(duplicatedTable1Records.records[0].fields[linkField12.name]).toBeDefined();\n      expect(duplicatedTable1Records.records[0].fields[linkField12.name]).toMatchObject([\n        { id: duplicatedTable2Records.records[0].id },\n      ]);\n\n      // Link to table3 should be empty or null (disconnected - table3 was not included)\n      const linkToTable3Value = duplicatedTable1Records.records[0].fields[linkField13.name];\n      expect(\n        linkToTable3Value === null ||\n          linkToTable3Value === undefined ||\n          (Array.isArray(linkToTable3Value) && linkToTable3Value.length === 0)\n      ).toBe(true);\n    });\n\n    it('should duplicate link field data correctly with multiple records', async () => {\n      const table1 = await createTable(base.id, { name: 'Products' });\n      const table2 = await createTable(base.id, { name: 'Categories' });\n\n      // Create link field from Products to Categories\n      const linkField = (\n        await createField(table1.id, {\n          name: 'categories',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table2.id,\n          },\n        })\n      ).data;\n\n      // Get records\n      const table1Records = await getRecords(table1.id);\n      const table2Records = await getRecords(table2.id);\n\n      // Create multiple link relationships\n      await updateRecord(table1.id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [\n              { id: table2Records.records[0].id },\n              { id: table2Records.records[1].id },\n            ],\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1Records.records[1].id, {\n        record: {\n          fields: {\n            [linkField.name]: [{ id: table2Records.records[1].id }],\n          },\n        },\n      });\n\n      // Duplicate with records\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Products'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) =>\n          n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Categories'\n      );\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - link data',\n        withRecords: true,\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      // Verify duplicated data\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'Products')!;\n      const duplicatedTable2 = duplicatedTableList.find((t) => t.name === 'Categories')!;\n\n      const duplicatedTable1Records = await getRecords(duplicatedTable1.id);\n      const duplicatedTable2Records = await getRecords(duplicatedTable2.id);\n\n      // First record should have 2 links\n      const firstRecordLinks = duplicatedTable1Records.records[0].fields[linkField.name];\n      expect(firstRecordLinks).toBeDefined();\n      expect(Array.isArray(firstRecordLinks)).toBe(true);\n      expect((firstRecordLinks as unknown[]).length).toBe(2);\n      expect(firstRecordLinks).toMatchObject([\n        { id: duplicatedTable2Records.records[0].id },\n        { id: duplicatedTable2Records.records[1].id },\n      ]);\n\n      // Second record should have 1 link\n      const secondRecordLinks = duplicatedTable1Records.records[1].fields[linkField.name];\n      expect(secondRecordLinks).toBeDefined();\n      expect(Array.isArray(secondRecordLinks)).toBe(true);\n      expect((secondRecordLinks as unknown[]).length).toBe(1);\n      expect(secondRecordLinks).toMatchObject([{ id: duplicatedTable2Records.records[1].id }]);\n\n      // Third record should have no links\n      const thirdRecordLinkValue = duplicatedTable1Records.records[2].fields[linkField.name];\n      expect(\n        thirdRecordLinkValue === null ||\n          thirdRecordLinkValue === undefined ||\n          (Array.isArray(thirdRecordLinkValue) && thirdRecordLinkValue.length === 0)\n      ).toBe(true);\n    });\n\n    it('should duplicate bidirectional link field data correctly', async () => {\n      const table1 = await createTable(base.id, { name: 'Tasks' });\n      const table2 = await createTable(base.id, { name: 'Users' });\n\n      // Create bidirectional link field\n      const linkField = (\n        await createField(table1.id, {\n          name: 'assigned to',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table2.id,\n          },\n        })\n      ).data;\n\n      // Get the symmetric field\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n      const symmetricField = (await getField(table2.id, symmetricFieldId)).data;\n\n      // Get records\n      const table1Records = await getRecords(table1.id);\n      const table2Records = await getRecords(table2.id);\n\n      // Create link from table1 side\n      await updateRecord(table1.id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [{ id: table2Records.records[0].id }],\n          },\n        },\n      });\n\n      // Create link from table2 side\n      await updateRecord(table2.id, table2Records.records[1].id, {\n        record: {\n          fields: {\n            [symmetricField.name]: [{ id: table1Records.records[1].id }],\n          },\n        },\n      });\n\n      // Duplicate with records\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Tasks'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Users'\n      );\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - bidirectional link',\n        withRecords: true,\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      // Verify duplicated data\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'Tasks')!;\n      const duplicatedTable2 = duplicatedTableList.find((t) => t.name === 'Users')!;\n\n      const duplicatedTable1Records = await getRecords(duplicatedTable1.id);\n      const duplicatedTable2Records = await getRecords(duplicatedTable2.id);\n\n      // Verify link from table1 side\n      expect(duplicatedTable1Records.records[0].fields[linkField.name]).toMatchObject([\n        { id: duplicatedTable2Records.records[0].id },\n      ]);\n\n      // Verify link from table2 side (symmetric field)\n      expect(duplicatedTable2Records.records[1].fields[symmetricField.name]).toMatchObject([\n        { id: duplicatedTable1Records.records[1].id },\n      ]);\n\n      // Verify bidirectional relationship\n      expect(duplicatedTable1Records.records[1].fields[linkField.name]).toMatchObject([\n        { id: duplicatedTable2Records.records[1].id },\n      ]);\n    });\n\n    it('should preserve folder hierarchy when duplicating with nodes parameter', async () => {\n      const folder1Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 1',\n      }).then((res) => res.data);\n\n      await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 2',\n      });\n\n      const table1Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table in Folder',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n\n      await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table outside',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n\n      // Move table1 into folder1\n      await moveBaseNode(base.id, table1Node.id, { parentId: folder1Node.id });\n\n      // Only duplicate the table inside folder (should include parent folder)\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - with parent folder',\n        nodes: [table1Node.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedNodeTree = await getBaseNodeTree(duplicateBaseId).then((res) => res.data);\n      const duplicatedNodes = duplicatedNodeTree.nodes;\n\n      // Should include the folder (parent) and the table\n      const duplicatedFolders = duplicatedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Folder\n      );\n      const duplicatedTables = duplicatedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Table\n      );\n\n      expect(duplicatedFolders.length).toBe(1);\n      expect(duplicatedFolders[0].resourceMeta?.name).toBe('Folder 1');\n\n      expect(duplicatedTables.length).toBe(1);\n      expect(duplicatedTables[0].resourceMeta?.name).toBe('Table in Folder');\n\n      // Verify table is still inside the folder\n      expect(duplicatedTables[0].parentId).toBe(duplicatedFolders[0].id);\n\n      // Verify table2 is not included\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      expect(duplicatedTableList.length).toBe(1);\n      expect(duplicatedTableList[0].name).toBe('Table in Folder');\n    });\n\n    it('should convert disconnected link fields to SingleLineText and clear data', async () => {\n      const table1 = await createTable(base.id, { name: 'Orders' });\n      const table2 = await createTable(base.id, { name: 'Customers' });\n      const table3 = await createTable(base.id, { name: 'Products' });\n\n      // Create link from Orders to Customers (will be included)\n      const linkField12 = (\n        await createField(table1.id, {\n          name: 'customer',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table2.id,\n          },\n        })\n      ).data;\n\n      // Create link from Orders to Products (will be excluded)\n      const linkField13 = (\n        await createField(table1.id, {\n          name: 'product',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table3.id,\n          },\n        })\n      ).data;\n\n      // Add some link data\n      const table1Records = await getRecords(table1.id);\n      const table2Records = await getRecords(table2.id);\n      const table3Records = await getRecords(table3.id);\n\n      await updateRecord(table1.id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField12.name]: [{ id: table2Records.records[0].id }],\n            [linkField13.name]: [{ id: table3Records.records[0].id }],\n          },\n        },\n      });\n\n      // Only duplicate table1 and table2, excluding table3\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Orders'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'Customers'\n      );\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - field type conversion',\n        withRecords: true,\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'Orders')!;\n\n      // Verify field types\n      const duplicatedFields = (await getFields(duplicatedTable1.id)).data;\n      const customerField = duplicatedFields.find((f) => f.name === 'customer');\n      const productField = duplicatedFields.find((f) => f.name === 'product');\n\n      // Customer field should remain as Link\n      expect(customerField).toBeDefined();\n      expect(customerField?.type).toBe(FieldType.Link);\n      expect((customerField?.options as ILinkFieldOptions)?.foreignTableId).toBeDefined();\n\n      // Product field should be converted to SingleLineText\n      expect(productField).toBeDefined();\n      expect(productField?.type).toBe(FieldType.SingleLineText);\n      // Options should be empty object or not have link-specific properties\n      expect(productField?.options).toBeDefined();\n      expect((productField?.options as ILinkFieldOptions)?.foreignTableId).toBeUndefined();\n\n      // Verify data: customer link should have data, product field should be empty\n      const duplicatedRecords = await getRecords(duplicatedTable1.id);\n      expect(duplicatedRecords.records[0].fields[linkField12.name]).toBeDefined();\n\n      const productFieldValue = duplicatedRecords.records[0].fields[linkField13.name];\n      expect(\n        productFieldValue === null || productFieldValue === undefined || productFieldValue === ''\n      ).toBe(true);\n    });\n\n    it('should handle lookup fields when link field is disconnected', async () => {\n      const table1 = await createTable(base.id, { name: 'table1' });\n      await createTable(base.id, { name: 'table2' });\n      const table3 = await createTable(base.id, { name: 'table3' });\n\n      // Create link from table1 to table3\n      const linkField13 = (\n        await createField(table1.id, {\n          name: 'link to table3',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table3.id,\n          },\n        })\n      ).data;\n\n      // Create lookup field based on the link to table3\n      await createField(table1.id, {\n        name: 'lookup from table3',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table3.id,\n          linkFieldId: linkField13.id,\n          lookupFieldId: table3.fields[0].id,\n        },\n      });\n\n      // Only duplicate table1 and table2, excluding table3\n      const nodeTree = await getBaseNodeTree(base.id).then((res) => res.data);\n      const table1Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table1'\n      );\n      const table2Node = nodeTree.nodes.find(\n        (n) => n.resourceType === BaseNodeResourceType.Table && n.resourceMeta?.name === 'table2'\n      );\n\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - disconnected lookup',\n        nodes: [table1Node!.id, table2Node!.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      const duplicatedTable1 = duplicatedTableList.find((t) => t.name === 'table1')!;\n\n      // Get fields and verify lookup field exists\n      const duplicatedTable1Fields = (await getFields(duplicatedTable1.id)).data;\n      const lookupField = duplicatedTable1Fields.find((f) => f.name === 'lookup from table3');\n\n      // Lookup field should be converted to SingleLineText (disconnected - based on link to table3)\n      expect(lookupField).toBeDefined();\n      expect(lookupField?.type).toBe(FieldType.SingleLineText);\n      expect(lookupField?.isLookup).toBeFalsy();\n    });\n\n    it('should duplicate multiple folders and their contents with nodes parameter', async () => {\n      const folder1Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder A',\n      }).then((res) => res.data);\n\n      const folder2Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder B',\n      }).then((res) => res.data);\n\n      const table1Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table A1',\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n\n      const table2Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table B1',\n        fields: [{ name: 'Field2', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n\n      const table3Node = await createBaseNode(base.id, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table B2',\n        fields: [{ name: 'Field3', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n\n      // Move tables into folders\n      await moveBaseNode(base.id, table1Node.id, { parentId: folder1Node.id });\n      await moveBaseNode(base.id, table2Node.id, { parentId: folder2Node.id });\n      await moveBaseNode(base.id, table3Node.id, { parentId: folder2Node.id });\n\n      // Duplicate only Folder A's table and one table from Folder B\n      const dupResult = await duplicateBase({\n        fromBaseId: base.id,\n        spaceId: spaceId,\n        name: 'test base copy - multiple folders',\n        nodes: [table1Node.id, table2Node.id],\n      });\n\n      duplicateBaseId = dupResult.data.id;\n\n      const duplicatedNodeTree = await getBaseNodeTree(duplicateBaseId).then((res) => res.data);\n      const duplicatedNodes = duplicatedNodeTree.nodes;\n\n      const duplicatedFolders = duplicatedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Folder\n      );\n      const duplicatedTables = duplicatedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Table\n      );\n\n      // Should have both folders\n      expect(duplicatedFolders.length).toBe(2);\n      expect(duplicatedFolders.map((f) => f.resourceMeta?.name).sort()).toEqual(\n        ['Folder A', 'Folder B'].sort()\n      );\n\n      // Should have only 2 tables\n      expect(duplicatedTables.length).toBe(2);\n      expect(duplicatedTables.map((t) => t.resourceMeta?.name).sort()).toEqual(\n        ['Table A1', 'Table B1'].sort()\n      );\n\n      // Table B2 should not be included\n      const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);\n      expect(duplicatedTableList.find((t) => t.name === 'Table B2')).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-export-sentry.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { ClsService } from 'nestjs-cls';\nimport { vi } from 'vitest';\nimport { BaseExportService } from '../src/features/base/base-export.service';\nimport type { IClsStore } from '../src/types/cls';\nimport { createBase, initApp, permanentDeleteBase, runWithTestUser } from './utils/init-app';\n\nconst waitFor = async (condition: () => boolean, timeout = 1000, interval = 25) => {\n  const start = Date.now();\n  while (Date.now() - start < timeout) {\n    if (condition()) {\n      return;\n    }\n    await new Promise((resolve) => setTimeout(resolve, interval));\n  }\n  throw new Error('Condition not met within timeout');\n};\n\ndescribe('Base export sentry reporting (e2e)', () => {\n  let app: INestApplication;\n  let baseExportService: BaseExportService;\n  let clsService: ClsService<IClsStore>;\n  let baseId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    clsService = app.get(ClsService);\n    baseExportService = app.get(BaseExportService);\n    const base = await createBase({\n      name: `sentry-export-${Date.now()}`,\n      spaceId: globalThis.testConfig.spaceId,\n    });\n    baseId = base.id;\n  });\n\n  afterAll(async () => {\n    if (baseId) {\n      await permanentDeleteBase(baseId);\n    }\n    await app.close();\n  });\n\n  it('captures export failures in sentry even when running asynchronously', async () => {\n    const exportError = new Error('mock export failure');\n    // Cast to `any` to access private methods for testing purposes\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const exportService = baseExportService as any;\n\n    const captureErrorSpy = vi.spyOn(exportService, 'captureExportError');\n    const processSpy = vi\n      .spyOn(exportService, 'processExportBaseZip')\n      .mockRejectedValue(exportError);\n    const notifySpy = vi.spyOn(exportService, 'notifyExportResult').mockResolvedValue(undefined);\n\n    await runWithTestUser(clsService, async () => {\n      await baseExportService.exportBaseZip(baseId, false);\n    });\n\n    await waitFor(() => notifySpy.mock.calls.length > 0);\n\n    expect(captureErrorSpy).toHaveBeenCalledWith(\n      exportError,\n      expect.objectContaining({\n        baseId,\n        baseName: expect.any(String),\n        includeData: false,\n        stage: 'processExport',\n      })\n    );\n    expect(notifySpy).toHaveBeenCalled();\n\n    processSpy.mockRestore();\n    notifySpy.mockRestore();\n    captureErrorSpy.mockRestore();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-node-folder.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { getRandomString } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  createBaseNodeFolder,\n  updateBaseNodeFolder,\n  deleteBaseNodeFolder,\n  createBaseNode,\n  BaseNodeResourceType,\n  deleteBaseNode,\n} from '@teable/openapi';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\ndescribe('BaseNodeFolderController (e2e) /api/base/:baseId/node/folder', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const folderNameToDelete = 'Folder To Delete';\n  const whitespaceOnlyName = '   ';\n  const originalFolderName = 'Original Folder';\n  let prisma: PrismaService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('POST /api/base/:baseId/node/folder - Create folder', () => {\n    it('should create a folder successfully', async () => {\n      const ro = { name: 'Test Folder' };\n      const response = await createBaseNodeFolder(baseId, ro);\n\n      expect(response.data).toBeDefined();\n      expect(response.data.name).toContain('Test Folder');\n      expect(response.data.id).toBeDefined();\n\n      // Cleanup\n      await deleteBaseNodeFolder(baseId, response.data.id);\n    });\n\n    it('should create multiple folders with same name (auto unique)', async () => {\n      const ro = { name: 'Duplicate Folder' };\n      const response1 = await createBaseNodeFolder(baseId, ro);\n      const response2 = await createBaseNodeFolder(baseId, ro);\n\n      expect(response1.data.name).toContain('Duplicate Folder');\n      expect(response2.data.name).toContain('Duplicate Folder');\n      expect(response1.data.name).not.toBe(response2.data.name);\n      expect(response1.data.id).not.toBe(response2.data.id);\n\n      // Cleanup\n      await deleteBaseNodeFolder(baseId, response1.data.id);\n      await deleteBaseNodeFolder(baseId, response2.data.id);\n    });\n\n    it('should trim folder name', async () => {\n      const ro = { name: '  Trimmed Folder  ' };\n      const response = await createBaseNodeFolder(baseId, ro);\n\n      expect(response.data.name).toContain('Trimmed Folder');\n\n      // Cleanup\n      await deleteBaseNodeFolder(baseId, response.data.id);\n    });\n\n    it('should fail with empty name', async () => {\n      const ro = { name: '' };\n      const error = await getError(() => createBaseNodeFolder(baseId, ro));\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail with whitespace only name', async () => {\n      const ro = { name: whitespaceOnlyName };\n      const error = await getError(() => createBaseNodeFolder(baseId, ro));\n\n      expect(error?.status).toBe(400);\n    });\n  });\n\n  describe('PATCH /api/base/:baseId/node/folder/:folderId - Update folder', () => {\n    let folderId: string;\n\n    beforeEach(async () => {\n      const response = await createBaseNodeFolder(baseId, { name: originalFolderName });\n      folderId = response.data.id;\n    });\n\n    afterEach(async () => {\n      try {\n        await deleteBaseNodeFolder(baseId, folderId);\n      } catch (e) {\n        // Folder might already be deleted in some tests\n      }\n    });\n\n    it('should rename folder successfully', async () => {\n      const updateRo = { name: 'Renamed Folder' };\n      const response = await updateBaseNodeFolder(baseId, folderId, updateRo);\n\n      expect(response.data).toBeDefined();\n      expect(response.data.name).toBe('Renamed Folder');\n      expect(response.data.id).toBe(folderId);\n    });\n\n    it('should trim folder name when renaming', async () => {\n      const updateRo = { name: '  Trimmed Renamed  ' };\n      const response = await updateBaseNodeFolder(baseId, folderId, updateRo);\n\n      expect(response.data.name).toBe('Trimmed Renamed');\n    });\n\n    it('should fail when renaming to existing folder name', async () => {\n      // Create another folder\n      const anotherFolder = await createBaseNodeFolder(baseId, { name: 'Existing Folder' });\n\n      // Try to rename original folder to existing name\n      const updateRo = { name: 'Existing Folder' };\n      const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));\n\n      expect(error?.status).toBe(400);\n      expect(error?.message).toContain('Folder name already exists');\n\n      // Cleanup\n      await deleteBaseNodeFolder(baseId, anotherFolder.data.id);\n    });\n\n    it('should allow renaming folder to same name', async () => {\n      const updateRo = { name: originalFolderName };\n      const response = await updateBaseNodeFolder(baseId, folderId, updateRo);\n\n      expect(response.data.name).toBe(originalFolderName);\n    });\n\n    it('should fail with empty name', async () => {\n      const updateRo = { name: '' };\n      const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail with whitespace only name', async () => {\n      const updateRo = { name: whitespaceOnlyName };\n      const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail when updating non-existent folder', async () => {\n      const nonExistentId = 'non-existent-folder-id';\n      const updateRo = { name: 'New Name' };\n      const error = await getError(() => updateBaseNodeFolder(baseId, nonExistentId, updateRo));\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('DELETE /api/base/:baseId/node/folder/:folderId - Delete folder', () => {\n    it('should delete empty folder successfully', async () => {\n      // Create a folder\n      const folder = await createBaseNodeFolder(baseId, { name: folderNameToDelete });\n      const folderId = folder.data.id;\n\n      const findFolder = await prisma.baseNodeFolder.findFirst({\n        where: { id: folderId },\n      });\n      expect(findFolder).toBeDefined();\n\n      // Delete the folder\n      await deleteBaseNodeFolder(baseId, folderId);\n\n      const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({\n        where: { id: folderId },\n      });\n      expect(findFolderAfterDelete).toBeNull();\n\n      // Verify folder is deleted\n      const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when deleting folder with children', async () => {\n      // Create a parent folder\n      const parentFolder = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Folder',\n      }).then((res) => res.data);\n\n      // Create a child folder inside the parent folder using createBaseNode\n      const childFolder = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        parentId: parentFolder.id,\n        name: 'Child Folder',\n      }).then((res) => res.data);\n\n      // Try to delete the parent folder\n      const error = await getError(() => deleteBaseNode(baseId, parentFolder.id));\n\n      expect(error?.status).toBe(400);\n      expect(error?.message).toContain('Cannot delete folder because it is not empty');\n\n      // Cleanup - need to delete the folder manually after removing children\n      await deleteBaseNode(baseId, childFolder.id);\n      await deleteBaseNode(baseId, parentFolder.id);\n\n      const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({\n        where: { id: { in: [parentFolder.id, childFolder.id] } },\n      });\n      expect(findFolderAfterDelete).toBeNull();\n    });\n\n    it('should fail when deleting non-existent folder', async () => {\n      const nonExistentId = 'non-existent-folder-id';\n      const error = await getError(() => deleteBaseNodeFolder(baseId, nonExistentId));\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should handle deletion of already deleted folder', async () => {\n      // Create and delete a folder\n      const folder = await createBaseNodeFolder(baseId, { name: 'Temp Folder' });\n      const folderId = folder.data.id;\n      await deleteBaseNodeFolder(baseId, folderId);\n\n      // Try to delete again\n      const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('Integration tests', () => {\n    it('should create, update and delete folder in sequence', async () => {\n      // Create\n      const createResponse = await createBaseNodeFolder(baseId, { name: 'Integration Folder' });\n      expect(createResponse.data.name).toContain('Integration Folder');\n      const folderId = createResponse.data.id;\n\n      // Update\n      const newName = getRandomString(10);\n      const updateResponse = await updateBaseNodeFolder(baseId, folderId, {\n        name: newName,\n      });\n      expect(updateResponse.data.name).toContain(newName);\n\n      // Delete\n      await deleteBaseNodeFolder(baseId, folderId);\n\n      const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({\n        where: { id: folderId },\n      });\n      expect(findFolderAfterDelete).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-node.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship, Role, ViewType } from '@teable/core';\nimport type { IBaseNodeTableResourceMeta, IBaseNodeVo } from '@teable/openapi';\nimport {\n  axios,\n  createBaseNode,\n  getBaseNodeTree,\n  getBaseNode,\n  updateBaseNode,\n  deleteBaseNode,\n  moveBaseNode,\n  duplicateBaseNode,\n  BaseNodeResourceType,\n  createBase,\n  emailBaseInvitation,\n  createSpace as apiCreateSpace,\n  permanentDeleteSpace as apiPermanentDeleteSpace,\n  urlBuilder,\n  GET_BASE_NODE_LIST,\n  GET_BASE_NODE_TREE,\n  GET_BASE_NODE,\n  CREATE_BASE_NODE,\n  UPDATE_BASE_NODE,\n  DELETE_BASE_NODE,\n  MOVE_BASE_NODE,\n  DUPLICATE_BASE_NODE,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { getFields, initApp, permanentDeleteBase } from './utils/init-app';\n\n// Constants for reused strings\nconst nonExistentId = 'non-existent-node-id';\nconst getTestFolder = 'Get Test Folder';\nconst originalName = 'Original Name';\nconst testFolder = 'Test Folder';\nconst updatedName = 'Updated Name';\nconst testTableName = 'Test Table';\nconst windowIdHeader = 'x-window-id';\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('BaseNodeController (e2e) /api/base/:baseId/node', () => {\n  let app: INestApplication;\n  let baseId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const base = await createBase({\n      name: 'test base node',\n      spaceId: globalThis.testConfig.spaceId,\n    }).then((res) => res.data);\n    baseId = base.id;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteBase(baseId);\n    await app.close();\n  });\n\n  describe('GET /api/base/:baseId/node/tree - Get tree structure', () => {\n    it('should get base node tree successfully', async () => {\n      const response = await getBaseNodeTree(baseId);\n\n      expect(response.data).toBeDefined();\n      expect(response.data).toHaveProperty('nodes');\n      expect(Array.isArray(response.data.nodes)).toBe(true);\n    });\n\n    it('should return tree with correct structure', async () => {\n      // Create a test node\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Tree Test Folder',\n      });\n\n      const response = await getBaseNodeTree(baseId);\n      const createdNode = response.data.nodes.find((n: IBaseNodeVo) => n.id === node.data.id);\n\n      expect(createdNode).toBeDefined();\n      expect(createdNode?.resourceMeta?.name).toBe('Tree Test Folder');\n      expect(createdNode?.resourceType).toBe(BaseNodeResourceType.Folder);\n\n      // Cleanup\n      await deleteBaseNode(baseId, node.data.id);\n    });\n  });\n\n  describe('GET /api/base/:baseId/node/:nodeId - Get single node', () => {\n    let testNodeId: string;\n\n    beforeEach(async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: getTestFolder,\n      });\n      testNodeId = node.data.id;\n    });\n\n    afterEach(async () => {\n      await deleteBaseNode(baseId, testNodeId);\n    });\n\n    it('should get single node successfully', async () => {\n      const response = await getBaseNode(baseId, testNodeId);\n\n      expect(response.data).toBeDefined();\n      expect(response.data.id).toBe(testNodeId);\n      expect(response.data.resourceMeta?.name).toBe(getTestFolder);\n      expect(response.data.resourceType).toBe(BaseNodeResourceType.Folder);\n    });\n\n    it('should fail when node does not exist', async () => {\n      const error = await getError(() => getBaseNode(baseId, nonExistentId));\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when baseId and nodeId do not match', async () => {\n      const wrongBaseId = 'wrong-base-id';\n      const error = await getError(() => getBaseNode(wrongBaseId, testNodeId));\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('POST /api/base/:baseId/node - Create node', () => {\n    const nodesToCleanup: string[] = [];\n\n    afterEach(async () => {\n      // Cleanup created nodes\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(baseId, nodeId);\n      }\n      nodesToCleanup.length = 0;\n    });\n\n    it('should create a folder node successfully', async () => {\n      const response = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: testFolder,\n      });\n\n      expect(response.data).toBeDefined();\n      expect(response.data.resourceMeta?.name).toBe(testFolder);\n      expect(response.data.resourceType).toBe(BaseNodeResourceType.Folder);\n      expect(response.data.id).toBeDefined();\n\n      nodesToCleanup.push(response.data.id);\n    });\n\n    it('should create a table node successfully', async () => {\n      const response = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: testTableName,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      const resourceMeta = response.data.resourceMeta as IBaseNodeTableResourceMeta;\n      expect(response.data).toBeDefined();\n      expect(resourceMeta.name).toBe(testTableName);\n      expect(resourceMeta.defaultViewId).toBeDefined();\n      expect(response.data.resourceType).toBe(BaseNodeResourceType.Table);\n      expect(response.data.resourceId).toBeDefined();\n\n      nodesToCleanup.push(response.data.id);\n    });\n\n    it('should expose create-table canary headers when creating a table node', async () => {\n      const response = await axios.post(\n        urlBuilder(CREATE_BASE_NODE, { baseId }),\n        {\n          resourceType: BaseNodeResourceType.Table,\n          name: 'Create Via Node Route',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n          views: [{ name: 'Grid view', type: ViewType.Grid }],\n        },\n        {\n          headers: {\n            [windowIdHeader]: 'win-base-node-create-table',\n          },\n        }\n      );\n\n      expect(response.status).toBe(201);\n      expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');\n      expect(response.headers['x-teable-v2-feature']).toBe('createTable');\n      expect(response.headers['x-teable-v2-reason']).toBeTruthy();\n\n      nodesToCleanup.push(response.data.id);\n    });\n\n    it('should create all supported table field types through the node canary route', async () => {\n      const foreignNode = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'All Types Foreign',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Revenue', type: FieldType.Number },\n        ],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(foreignNode.data.id);\n\n      const foreignFields = await getFields(foreignNode.data.resourceId);\n      const foreignNameFieldId = foreignFields.find((field) => field.name === 'Name')?.id;\n      const foreignRevenueFieldId = foreignFields.find((field) => field.name === 'Revenue')?.id;\n\n      expect(foreignNameFieldId).toBeTruthy();\n      expect(foreignRevenueFieldId).toBeTruthy();\n      if (!foreignNameFieldId || !foreignRevenueFieldId) return;\n\n      const amountFieldId = 'fldalltypesamount01';\n      const companyLinkFieldId = 'fldalltypeslink0001';\n      const companyLookupFieldId = 'fldalltypeslook0001';\n      const companyRollupFieldId = 'fldalltypesroll0001';\n      const conditionalLookupFieldId = 'fldalltypescdl00001';\n      const conditionalRollupFieldId = 'fldalltypescdr00001';\n\n      const response = await axios.post(\n        urlBuilder(CREATE_BASE_NODE, { baseId }),\n        {\n          resourceType: BaseNodeResourceType.Table,\n          name: 'All Types Via Node Route',\n          fields: [\n            { name: 'Name', type: FieldType.SingleLineText },\n            { name: 'Description', type: FieldType.LongText, options: { defaultValue: 'Details' } },\n            {\n              id: amountFieldId,\n              name: 'Amount',\n              type: FieldType.Number,\n              options: {\n                formatting: { type: 'currency', precision: 2, symbol: '$' },\n                showAs: { type: 'bar', color: 'teal', showValue: true, maxValue: 100 },\n                defaultValue: 10,\n              },\n            },\n            {\n              name: 'Score',\n              type: FieldType.Formula,\n              options: { expression: `{${amountFieldId}} * 2` },\n            },\n            {\n              name: 'Priority',\n              type: FieldType.Rating,\n              options: { max: 5, icon: 'star', color: 'yellowBright' },\n            },\n            {\n              name: 'Status',\n              type: FieldType.SingleSelect,\n              options: {\n                choices: [\n                  { name: 'Todo', color: 'blue' },\n                  { name: 'Doing', color: 'yellow' },\n                  { name: 'Done', color: 'green' },\n                ],\n              },\n            },\n            {\n              name: 'Tags',\n              type: FieldType.MultipleSelect,\n              options: {\n                choices: [\n                  { name: 'Frontend', color: 'purple' },\n                  { name: 'Backend', color: 'orange' },\n                ],\n              },\n            },\n            { name: 'Done', type: FieldType.Checkbox, options: { defaultValue: true } },\n            { name: 'Files', type: FieldType.Attachment },\n            {\n              name: 'Due Date',\n              type: FieldType.Date,\n              options: {\n                formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'UTC' },\n                defaultValue: 'now',\n              },\n            },\n            { name: 'Auto Number', type: FieldType.AutoNumber },\n            { name: 'Created Time', type: FieldType.CreatedTime },\n            { name: 'Last Modified Time', type: FieldType.LastModifiedTime },\n            { name: 'Created By', type: FieldType.CreatedBy },\n            { name: 'Last Modified By', type: FieldType.LastModifiedBy },\n            {\n              name: 'Owner',\n              type: FieldType.User,\n              options: { isMultiple: true, shouldNotify: false, defaultValue: ['me'] },\n            },\n            {\n              name: 'Action',\n              type: FieldType.Button,\n              options: {\n                label: 'Run',\n                color: 'teal',\n                maxCount: 3,\n                resetCount: true,\n                workflow: { id: 'wflaaaaaaaaaaaaaaaa', name: 'Deploy', isActive: true },\n              },\n            },\n            {\n              id: companyLinkFieldId,\n              name: 'Company',\n              type: FieldType.Link,\n              options: {\n                relationship: Relationship.ManyOne,\n                foreignTableId: foreignNode.data.resourceId,\n                lookupFieldId: foreignNameFieldId,\n              },\n            },\n            {\n              id: companyLookupFieldId,\n              name: 'Company Name',\n              type: FieldType.SingleLineText,\n              isLookup: true,\n              lookupOptions: {\n                linkFieldId: companyLinkFieldId,\n                foreignTableId: foreignNode.data.resourceId,\n                lookupFieldId: foreignNameFieldId,\n              },\n            },\n            {\n              id: companyRollupFieldId,\n              name: 'Company Revenue Total',\n              type: FieldType.Rollup,\n              options: { expression: 'sum({values})', timeZone: 'UTC' },\n              lookupOptions: {\n                linkFieldId: companyLinkFieldId,\n                foreignTableId: foreignNode.data.resourceId,\n                lookupFieldId: foreignRevenueFieldId,\n              },\n            },\n            {\n              id: conditionalLookupFieldId,\n              name: 'High Revenue Companies',\n              type: FieldType.SingleLineText,\n              isLookup: true,\n              isConditionalLookup: true,\n              lookupOptions: {\n                foreignTableId: foreignNode.data.resourceId,\n                lookupFieldId: foreignNameFieldId,\n                filter: {\n                  conjunction: 'and',\n                  filterSet: [\n                    {\n                      fieldId: foreignRevenueFieldId,\n                      operator: 'isGreater',\n                      value: 100,\n                    },\n                  ],\n                },\n              },\n            },\n            {\n              id: conditionalRollupFieldId,\n              name: 'High Revenue Total',\n              type: FieldType.ConditionalRollup,\n              options: {\n                foreignTableId: foreignNode.data.resourceId,\n                lookupFieldId: foreignRevenueFieldId,\n                expression: 'sum({values})',\n                timeZone: 'UTC',\n                filter: {\n                  conjunction: 'and',\n                  filterSet: [\n                    {\n                      fieldId: foreignRevenueFieldId,\n                      operator: 'isGreater',\n                      value: 100,\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n          views: [{ name: 'Grid view', type: ViewType.Grid }],\n        },\n        {\n          headers: {\n            [windowIdHeader]: 'win-base-node-all-types',\n          },\n        }\n      );\n\n      expect(response.status).toBe(201);\n      expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');\n      expect(response.headers['x-teable-v2-feature']).toBe('createTable');\n      expect(response.headers['x-teable-v2-reason']).toBeTruthy();\n\n      nodesToCleanup.push(response.data.id);\n\n      const fields = await getFields(response.data.resourceId);\n      const fieldByName = new Map(fields.map((field) => [field.name, field]));\n\n      expect(fieldByName.get('Name')?.type).toBe(FieldType.SingleLineText);\n      expect(fieldByName.get('Description')?.type).toBe(FieldType.LongText);\n      expect(fieldByName.get('Amount')?.type).toBe(FieldType.Number);\n      expect(fieldByName.get('Score')?.type).toBe(FieldType.Formula);\n      expect(fieldByName.get('Priority')?.type).toBe(FieldType.Rating);\n      expect(fieldByName.get('Status')?.type).toBe(FieldType.SingleSelect);\n      expect(fieldByName.get('Tags')?.type).toBe(FieldType.MultipleSelect);\n      expect(fieldByName.get('Done')?.type).toBe(FieldType.Checkbox);\n      expect(fieldByName.get('Files')?.type).toBe(FieldType.Attachment);\n      expect(fieldByName.get('Due Date')?.type).toBe(FieldType.Date);\n      expect(fieldByName.get('Auto Number')?.type).toBe(FieldType.AutoNumber);\n      expect(fieldByName.get('Created Time')?.type).toBe(FieldType.CreatedTime);\n      expect(fieldByName.get('Last Modified Time')?.type).toBe(FieldType.LastModifiedTime);\n      expect(fieldByName.get('Created By')?.type).toBe(FieldType.CreatedBy);\n      expect(fieldByName.get('Last Modified By')?.type).toBe(FieldType.LastModifiedBy);\n      expect(fieldByName.get('Owner')?.type).toBe(FieldType.User);\n      expect(fieldByName.get('Action')?.type).toBe(FieldType.Button);\n      expect(fieldByName.get('Company')?.type).toBe(FieldType.Link);\n      expect(fieldByName.get('Company Name')?.type).toBe(FieldType.SingleLineText);\n      expect(fieldByName.get('Company Name')?.isLookup).toBe(true);\n      expect(fieldByName.get('Company Revenue Total')?.type).toBe(FieldType.Rollup);\n      expect(fieldByName.get('High Revenue Companies')?.type).toBe(FieldType.SingleLineText);\n      expect(fieldByName.get('High Revenue Companies')?.isLookup).toBe(true);\n      expect(fieldByName.get('High Revenue Companies')?.isConditionalLookup).toBe(true);\n      expect(fieldByName.get('High Revenue Total')?.type).toBe(FieldType.ConditionalRollup);\n    });\n\n    it('should create a dashboard node successfully', async () => {\n      const response = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Dashboard,\n        name: 'Test Dashboard',\n      });\n\n      expect(response.data).toBeDefined();\n      expect(response.data.resourceMeta?.name).toBe('Test Dashboard');\n      expect(response.data.resourceType).toBe(BaseNodeResourceType.Dashboard);\n      expect(response.data.resourceId).toBeDefined();\n\n      nodesToCleanup.push(response.data.id);\n    });\n\n    it('should create nested node with parentId', async () => {\n      // Create parent folder\n      const parent = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Folder',\n      });\n      nodesToCleanup.push(parent.data.id);\n\n      // Create child node\n      const child = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child Folder',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child.data.id);\n\n      expect(child.data.parentId).toBe(parent.data.id);\n\n      // Verify in tree\n      const tree = await getBaseNodeTree(baseId);\n      const parentNode = tree.data.nodes.find((n: IBaseNodeVo) => n.id === parent.data.id);\n      expect(parentNode?.children).toBeDefined();\n      expect(parentNode?.children?.some((c) => c.id === child.data.id)).toBe(true);\n    });\n\n    it('should trim node name', async () => {\n      const response = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: '  Trimmed Name  ',\n      });\n\n      expect(response.data.resourceMeta?.name).toBe('Trimmed Name');\n      nodesToCleanup.push(response.data.id);\n    });\n\n    it('should fail with empty name', async () => {\n      const error = await getError(() =>\n        createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: '',\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail with whitespace only name', async () => {\n      const error = await getError(() =>\n        createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: '   ',\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail when parent node does not exist', async () => {\n      const error = await getError(() =>\n        createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: 'Test Folder',\n          parentId: 'non-existent-parent-id',\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when parent node is not folder type', async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: testTableName,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n\n      nodesToCleanup.push(node.data.id);\n\n      const error = await getError(() =>\n        createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Table,\n          name: testTableName,\n          fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n          views: [{ name: 'Grid view', type: ViewType.Grid }],\n          parentId: node.data.id,\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('PUT /api/base/:baseId/node/:nodeId - Update node', () => {\n    let testNodeId: string;\n\n    beforeEach(async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: originalName,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      testNodeId = node.data.id;\n    });\n\n    afterEach(async () => {\n      await deleteBaseNode(baseId, testNodeId);\n    });\n\n    it('should update node name successfully', async () => {\n      const response = await updateBaseNode(baseId, testNodeId, {\n        name: updatedName,\n      });\n\n      expect(response.data.resourceMeta?.name).toBe(updatedName);\n      expect(response.data.id).toBe(testNodeId);\n    });\n\n    it('should update node icon successfully', async () => {\n      const response = await updateBaseNode(baseId, testNodeId, {\n        icon: '📁',\n      });\n\n      expect(response.data.resourceMeta?.icon).toBe('📁');\n      expect(response.data.id).toBe(testNodeId);\n    });\n\n    it('should update both name and icon', async () => {\n      const response = await updateBaseNode(baseId, testNodeId, {\n        name: updatedName,\n        icon: '🎯',\n      });\n\n      expect(response.data.resourceMeta?.name).toBe(updatedName);\n      expect(response.data.resourceMeta?.icon).toBe('🎯');\n    });\n\n    it('should trim name when updating', async () => {\n      const response = await updateBaseNode(baseId, testNodeId, {\n        name: '  Trimmed Updated  ',\n      });\n\n      expect(response.data.resourceMeta?.name).toBe('Trimmed Updated');\n    });\n\n    it('should handle empty update object', async () => {\n      const response = await updateBaseNode(baseId, testNodeId, {});\n\n      expect(response.data.id).toBe(testNodeId);\n      expect(response.data.resourceMeta?.name).toBe(originalName);\n    });\n\n    it('should fail when updating non-existent node', async () => {\n      const error = await getError(() =>\n        updateBaseNode(baseId, nonExistentId, { name: 'New Name' })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail with empty name', async () => {\n      const error = await getError(() => updateBaseNode(baseId, testNodeId, { name: '' }));\n\n      expect(error?.status).toBe(400);\n    });\n  });\n\n  describe('DELETE /api/base/:baseId/node/:nodeId - Delete node', () => {\n    it('should delete leaf node successfully', async () => {\n      // Create a node\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'To Delete',\n      });\n\n      // Delete it\n      await deleteBaseNode(baseId, node.data.id);\n\n      // Verify it's deleted\n      const error = await getError(() => getBaseNode(baseId, node.data.id));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when deleting non-existent node', async () => {\n      const error = await getError(() => deleteBaseNode(baseId, nonExistentId));\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should handle deletion of already deleted node', async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Temp Node',\n      });\n\n      // Delete once\n      await deleteBaseNode(baseId, node.data.id);\n\n      // Try to delete again\n      const error = await getError(() => deleteBaseNode(baseId, node.data.id));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when delete folder node with children', async () => {\n      const folder = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder',\n      }).then((res) => res.data);\n\n      await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child',\n        parentId: folder.id,\n      }).then((res) => res.data.id);\n\n      // Verify it's deleted\n      const error = await getError(() => deleteBaseNode(baseId, folder.id));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should expose delete-table canary headers when deleting a table node', async () => {\n      const table = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Delete Via Node Route',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n\n      const response = await axios.delete(\n        urlBuilder(DELETE_BASE_NODE, { baseId, nodeId: table.data.id }),\n        {\n          headers: {\n            [windowIdHeader]: 'win-base-node-delete-table',\n          },\n        }\n      );\n\n      expect(response.status).toBe(200);\n      expect(response.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');\n      expect(response.headers['x-teable-v2-feature']).toBe('deleteTable');\n      expect(response.headers['x-teable-v2-reason']).toBeTruthy();\n\n      const error = await getError(() => getBaseNode(baseId, table.data.id));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('PUT /api/base/:baseId/node/:nodeId/move - Move node', () => {\n    const nodesToCleanup: string[] = [];\n\n    afterEach(async () => {\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(baseId, nodeId);\n      }\n      nodesToCleanup.length = 0;\n    });\n\n    it('should move node to another folder', async () => {\n      // Create nodes\n      const folder1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 1',\n      });\n      nodesToCleanup.push(folder1.data.id);\n\n      const folder2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 2',\n      });\n      nodesToCleanup.push(folder2.data.id);\n\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node to Move',\n        parentId: folder1.data.id,\n      });\n      nodesToCleanup.push(node.data.id);\n\n      // Move node to folder2\n      const response = await moveBaseNode(baseId, node.data.id, {\n        parentId: folder2.data.id,\n      });\n\n      expect(response.data.parentId).toBe(folder2.data.id);\n    });\n\n    it('should move node to root level', async () => {\n      // Create parent folder and child\n      const parent = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent',\n      });\n      nodesToCleanup.push(parent.data.id);\n\n      const child = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child.data.id);\n\n      // Move to root\n      const response = await moveBaseNode(baseId, child.data.id, {\n        parentId: null,\n      });\n\n      expect(response.data.parentId).toBeNull();\n    });\n\n    it('should reorder nodes using anchorId and position', async () => {\n      // Create multiple nodes at root level\n      const node1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node 1',\n      });\n      nodesToCleanup.push(node1.data.id);\n\n      const node2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node 2',\n      });\n      nodesToCleanup.push(node2.data.id);\n\n      const node3 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node 3',\n      });\n      nodesToCleanup.push(node3.data.id);\n\n      // Move node3 before node1\n      const response = await moveBaseNode(baseId, node3.data.id, {\n        anchorId: node1.data.id,\n        position: 'before',\n      });\n\n      expect(response.data).toBeDefined();\n      expect(response.data.id).toBe(node3.data.id);\n    });\n\n    it('should reorder nodes using position before and anchorId same parent', async () => {\n      // Create a parent folder\n      const parent = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Folder',\n      });\n      nodesToCleanup.push(parent.data.id);\n\n      // Create multiple child nodes under same parent\n      const child1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child 1',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child1.data.id);\n\n      const child2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child 2',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child2.data.id);\n\n      const child3 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child 3',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child3.data.id);\n\n      // Move child3 before child1 (both have same parent)\n      const response = await moveBaseNode(baseId, child3.data.id, {\n        anchorId: child1.data.id,\n        position: 'before',\n      });\n\n      expect(response.data).toBeDefined();\n      expect(response.data.id).toBe(child3.data.id);\n      expect(response.data.parentId).toBe(parent.data.id);\n    });\n\n    it('should reorder nodes using position after', async () => {\n      const node1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node A',\n      });\n      nodesToCleanup.push(node1.data.id);\n\n      const node2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Node B',\n      });\n      nodesToCleanup.push(node2.data.id);\n\n      // Move node1 after node2\n      const response = await moveBaseNode(baseId, node1.data.id, {\n        anchorId: node2.data.id,\n        position: 'after',\n      });\n\n      expect(response.data.id).toBe(node1.data.id);\n    });\n\n    it('should reorder nodes using position after and anchorId same parent', async () => {\n      // Create a parent folder\n      const parent = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Container',\n      });\n      nodesToCleanup.push(parent.data.id);\n\n      // Create multiple child nodes under same parent\n      const childA = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child A',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(childA.data.id);\n\n      const childB = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child B',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(childB.data.id);\n\n      const childC = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child C',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(childC.data.id);\n\n      // Move childA after childC (both have same parent)\n      const response = await moveBaseNode(baseId, childA.data.id, {\n        anchorId: childC.data.id,\n        position: 'after',\n      });\n\n      expect(response.data).toBeDefined();\n      expect(response.data.id).toBe(childA.data.id);\n      expect(response.data.parentId).toBe(parent.data.id);\n    });\n\n    it('should fail when moving node to itself', async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Self Reference Node',\n      });\n      nodesToCleanup.push(node.data.id);\n\n      const error = await getError(() =>\n        moveBaseNode(baseId, node.data.id, {\n          parentId: node.data.id,\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail when moving node to its own child (circular reference)', async () => {\n      // Create parent and child\n      const parent = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent',\n      });\n      nodesToCleanup.push(parent.data.id);\n\n      const child = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child',\n        parentId: parent.data.id,\n      });\n      nodesToCleanup.push(child.data.id);\n\n      // Try to move parent into child (circular reference)\n      const error = await getError(() =>\n        moveBaseNode(baseId, parent.data.id, {\n          parentId: child.data.id,\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail when anchor node does not exist', async () => {\n      const node = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Test Node',\n      });\n      nodesToCleanup.push(node.data.id);\n\n      const error = await getError(() =>\n        moveBaseNode(baseId, node.data.id, {\n          anchorId: 'non-existent-anchor',\n          position: 'before',\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should fail when parent node does not folder type', async () => {\n      // Create a table node (non-folder type)\n      const table = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Non-Folder Parent',\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(table.data.id);\n\n      // Create a folder node\n      const folder = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder Node',\n      });\n      nodesToCleanup.push(folder.data.id);\n\n      // Try to move folder under table (should fail because table is not a folder)\n      const error = await getError(() =>\n        moveBaseNode(baseId, folder.data.id, {\n          parentId: table.data.id,\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n  });\n\n  describe('POST /api/base/:baseId/node/:nodeId/duplicate - Duplicate node', () => {\n    const nodesToCleanup: string[] = [];\n\n    afterEach(async () => {\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(baseId, nodeId);\n      }\n      nodesToCleanup.length = 0;\n    });\n\n    it('should duplicate folder fail', async () => {\n      const original = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Original Folder',\n      });\n      nodesToCleanup.push(original.data.id);\n\n      const error = await getError(() =>\n        duplicateBaseNode(baseId, original.data.id, {\n          name: 'Duplicated Folder',\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should duplicate table successfully', async () => {\n      const original = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Original Table',\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(original.data.id);\n\n      const duplicate = await duplicateBaseNode(baseId, original.data.id, {\n        name: 'Duplicated Table',\n      });\n      nodesToCleanup.push(duplicate.data.id);\n\n      expect(duplicate.data.id).not.toBe(original.data.id);\n      expect(duplicate.data.resourceId).not.toBe(original.data.resourceId);\n      expect(duplicate.data.resourceMeta?.name).toBe('Duplicated Table');\n    });\n\n    it('should duplicate dashboard successfully', async () => {\n      const original = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Dashboard,\n        name: 'Original Dashboard',\n      });\n      nodesToCleanup.push(original.data.id);\n\n      const duplicate = await duplicateBaseNode(baseId, original.data.id, {\n        name: 'Duplicated Dashboard',\n      });\n      nodesToCleanup.push(duplicate.data.id);\n\n      expect(duplicate.data.id).not.toBe(original.data.id);\n      expect(duplicate.data.resourceMeta?.name).toBe('Duplicated Dashboard');\n    });\n\n    it('should fail when duplicating non-existent node', async () => {\n      const error = await getError(() =>\n        duplicateBaseNode(baseId, nonExistentId, { name: 'Duplicate' })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n  });\n\n  describe('Integration scenarios', () => {\n    const nodesToCleanup: string[] = [];\n\n    afterEach(async () => {\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(baseId, nodeId);\n      }\n      nodesToCleanup.length = 0;\n    });\n\n    it('should handle complete CRUD lifecycle', async () => {\n      // Create\n      const created = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Lifecycle Test',\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      expect(created.data.resourceMeta?.name).toBe('Lifecycle Test');\n      nodesToCleanup.push(created.data.id);\n\n      // Read\n      const read = await getBaseNode(baseId, created.data.id);\n      expect(read.data.id).toBe(created.data.id);\n\n      // Update\n      const updated = await updateBaseNode(baseId, created.data.id, {\n        name: 'Updated Lifecycle Test',\n        icon: '🔄',\n      });\n      expect(updated.data.resourceMeta?.name).toBe('Updated Lifecycle Test');\n      expect(updated.data.resourceMeta?.icon).toBe('🔄');\n\n      // Delete\n      await deleteBaseNode(baseId, created.data.id);\n      const error = await getError(() => getBaseNode(baseId, created.data.id));\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n\n      // Remove from cleanup since already deleted\n      nodesToCleanup.pop();\n    });\n\n    it('should handle complex folder hierarchy', async () => {\n      const root = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Root',\n      });\n      nodesToCleanup.push(root.data.id);\n\n      const child1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child 1',\n        parentId: root.data.id,\n      });\n      nodesToCleanup.push(child1.data.id);\n\n      const child2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Child 2',\n        parentId: root.data.id,\n      });\n      nodesToCleanup.push(child2.data.id);\n\n      const child1Table = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Child 1 Table',\n        parentId: child1.data.id,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(child1Table.data.id);\n\n      // Verify structure\n      const tree = await getBaseNodeTree(baseId);\n      const rootNode = tree.data.nodes.find((n: IBaseNodeVo) => n.id === root.data.id);\n\n      expect(rootNode?.children).toHaveLength(2);\n      const child1Node = tree.data.nodes.find((n: IBaseNodeVo) => n.id === child1.data.id);\n      expect(child1Node?.children).toHaveLength(1);\n    });\n\n    it('should handle moving nodes between folders', async () => {\n      // Create structure: Folder A with Child, Folder B empty\n      const folderA = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder A',\n      });\n      nodesToCleanup.push(folderA.data.id);\n\n      const folderB = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder B',\n      });\n      nodesToCleanup.push(folderB.data.id);\n\n      const child = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Movable Table',\n        parentId: folderA.data.id,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(child.data.id);\n\n      // Verify initial state\n      let node = await getBaseNode(baseId, child.data.id);\n      expect(node.data.parentId).toBe(folderA.data.id);\n\n      // Move to Folder B\n      await moveBaseNode(baseId, child.data.id, {\n        parentId: folderB.data.id,\n      });\n\n      // Verify moved\n      node = await getBaseNode(baseId, child.data.id);\n      expect(node.data.parentId).toBe(folderB.data.id);\n\n      // Move to root\n      await moveBaseNode(baseId, child.data.id, {\n        parentId: null,\n      });\n\n      // Verify at root\n      node = await getBaseNode(baseId, child.data.id);\n      expect(node.data.parentId).toBeNull();\n    });\n\n    it('should maintain order when creating and moving nodes', async () => {\n      // Create multiple nodes\n      const nodes = [];\n      for (let i = 1; i <= 3; i++) {\n        const node = await createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: `Order Test ${i}`,\n        });\n        nodes.push(node.data);\n        nodesToCleanup.push(node.data.id);\n      }\n\n      // Get tree and verify all nodes exist\n      const tree = await getBaseNodeTree(baseId);\n      for (const node of nodes) {\n        const found = tree.data.nodes.find((n: IBaseNodeVo) => n.id === node.id);\n        expect(found).toBeDefined();\n      }\n    });\n  });\n\n  describe('Folder depth limitation', () => {\n    const nodesToCleanup: string[] = [];\n\n    afterEach(async () => {\n      // Cleanup nodes in reverse order to handle hierarchy\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(baseId, nodeId);\n      }\n      nodesToCleanup.length = 0;\n    });\n\n    it('should allow creating folders up to max depth (3 levels)', async () => {\n      // Create level 1 folder\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Level 1 Folder',\n      });\n      nodesToCleanup.push(level1.data.id);\n      expect(level1.data.parentId).toBeNull();\n\n      // Create level 2 folder\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Level 2 Folder',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n      expect(level2.data.parentId).toBe(level1.data.id);\n    });\n\n    it('should fail when creating folder exceeding max depth (4th level)', async () => {\n      // Create 3 levels of folders\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Depth Limit Level 1',\n      });\n      nodesToCleanup.push(level1.data.id);\n\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Depth Limit Level 2',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n\n      // Try to create level 4 folder (should fail)\n      const error = await getError(() =>\n        createBaseNode(baseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: 'Depth Limit Level 3',\n          parentId: level2.data.id,\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should allow creating table in folder at max depth', async () => {\n      // Create 2 levels of folders\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Table Depth Level 1',\n      });\n      nodesToCleanup.push(level1.data.id);\n\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Table Depth Level 2',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n\n      // Create table in level 2 folder (should succeed - tables don't count as depth)\n      const table = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table in Max Depth',\n        parentId: level2.data.id,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(table.data.id);\n      expect(table.data.parentId).toBe(level2.data.id);\n    });\n\n    it('should fail when moving folder to exceed max depth using anchorId', async () => {\n      // Create 3 levels of folders\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Move Depth Level 1',\n      });\n      nodesToCleanup.push(level1.data.id);\n\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Move Depth Level 2',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n\n      const level3 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table in Move Depth Level 3',\n        parentId: level2.data.id,\n        fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      });\n      nodesToCleanup.push(level3.data.id);\n\n      // Create a folder at root level to move\n      const folderToMove = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder to Move',\n      });\n      nodesToCleanup.push(folderToMove.data.id);\n\n      // Try to move folder next to level2 (which would make it level 3 if it had the same parent)\n      // Using anchorId with position should check depth\n      const error = await getError(() =>\n        moveBaseNode(baseId, folderToMove.data.id, {\n          anchorId: level3.data.id,\n          position: 'after',\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail when moving folder to another folder exceeds max depth using parentId', async () => {\n      // Create 2 levels of folders (max depth)\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Move Depth Level 1',\n      });\n      nodesToCleanup.push(level1.data.id);\n\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Parent Move Depth Level 2',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n\n      // Create a folder at root level to move\n      const folderToMove = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder to Move Into Depth',\n      });\n      nodesToCleanup.push(folderToMove.data.id);\n\n      // Try to move folder into level2 using parentId (would exceed max depth)\n      const error = await getError(() =>\n        moveBaseNode(baseId, folderToMove.data.id, {\n          parentId: level2.data.id,\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should allow moving folder within valid depth using anchorId', async () => {\n      // Create 2 levels of folders\n      const level1 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Valid Move Level 1',\n      });\n      nodesToCleanup.push(level1.data.id);\n\n      const level2 = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Valid Move Level 2',\n        parentId: level1.data.id,\n      });\n      nodesToCleanup.push(level2.data.id);\n\n      // Create a folder at root level\n      const folderToMove = await createBaseNode(baseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder to Move Valid',\n      });\n      nodesToCleanup.push(folderToMove.data.id);\n\n      // Move folder next to level2 (which makes it level 3 - still valid)\n      const response = await moveBaseNode(baseId, folderToMove.data.id, {\n        anchorId: level2.data.id,\n        position: 'after',\n      });\n\n      expect(response.data.id).toBe(folderToMove.data.id);\n      expect(response.data.parentId).toBe(level1.data.id);\n    });\n\n    it('should return maxFolderDepth in tree response', async () => {\n      const response = await getBaseNodeTree(baseId);\n\n      expect(response.data).toHaveProperty('maxFolderDepth');\n      expect(response.data.maxFolderDepth).toBe(2);\n    });\n  });\n\n  describe('Permission tests', () => {\n    let permissionSpaceId: string;\n    let permissionBaseId: string;\n    let viewerAxios: AxiosInstance;\n    let creatorAxios: AxiosInstance;\n    let nonCollaboratorAxios: AxiosInstance;\n    const nodesToCleanup: string[] = [];\n\n    const viewerEmail = 'base-node-viewer@test.com';\n    const creatorEmail = 'base-node-creator@test.com';\n    const nonCollaboratorEmail = 'base-node-non-collaborator@test.com';\n\n    beforeAll(async () => {\n      // Create a new space and base for permission tests\n      const space = await apiCreateSpace({ name: 'Permission Test Space' }).then((res) => res.data);\n      permissionSpaceId = space.id;\n\n      const base = await createBase({\n        name: 'Permission Test Base',\n        spaceId: permissionSpaceId,\n      }).then((res) => res.data);\n      permissionBaseId = base.id;\n\n      // Create test users\n      viewerAxios = await createNewUserAxios({\n        email: viewerEmail,\n        password: '12345678',\n      });\n\n      creatorAxios = await createNewUserAxios({\n        email: creatorEmail,\n        password: '12345678',\n      });\n\n      nonCollaboratorAxios = await createNewUserAxios({\n        email: nonCollaboratorEmail,\n        password: '12345678',\n      });\n\n      // Invite viewer with Viewer role (read-only)\n      await emailBaseInvitation({\n        baseId: permissionBaseId,\n        emailBaseInvitationRo: {\n          emails: [viewerEmail],\n          role: Role.Viewer,\n        },\n      });\n\n      // Invite creator with Creator role (full access)\n      await emailBaseInvitation({\n        baseId: permissionBaseId,\n        emailBaseInvitationRo: {\n          emails: [creatorEmail],\n          role: Role.Creator,\n        },\n      });\n    });\n\n    afterAll(async () => {\n      // Cleanup nodes first\n      for (const nodeId of [...nodesToCleanup].reverse()) {\n        await deleteBaseNode(permissionBaseId, nodeId);\n      }\n      // Then delete the space (which will delete the base)\n      await apiPermanentDeleteSpace(permissionSpaceId);\n    });\n\n    describe('Non-collaborator access', () => {\n      it('should fail to get node list when user is not a collaborator', async () => {\n        const error = await getError(() =>\n          nonCollaboratorAxios.get(urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId }))\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should fail to get node tree when user is not a collaborator', async () => {\n        const error = await getError(() =>\n          nonCollaboratorAxios.get(urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId }))\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should fail to create node when user is not a collaborator', async () => {\n        const error = await getError(() =>\n          nonCollaboratorAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {\n            resourceType: BaseNodeResourceType.Folder,\n            name: 'Unauthorized Folder',\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n    });\n\n    describe('Viewer role permissions', () => {\n      let testFolderId: string;\n      let testTableId: string;\n      let testDashboardId: string;\n\n      beforeAll(async () => {\n        // Create test nodes as owner for viewer to test against\n        const folder = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: 'Viewer Test Folder',\n        });\n        testFolderId = folder.data.id;\n        nodesToCleanup.push(testFolderId);\n\n        const table = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Table,\n          name: 'Viewer Test Table',\n          fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n          views: [{ name: 'Grid view', type: ViewType.Grid }],\n        });\n        testTableId = table.data.id;\n        nodesToCleanup.push(testTableId);\n\n        const dashboard = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Dashboard,\n          name: 'Viewer Test Dashboard',\n        });\n        testDashboardId = dashboard.data.id;\n        nodesToCleanup.push(testDashboardId);\n      });\n\n      it('should allow viewer to get node list', async () => {\n        const response = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })\n        );\n        expect(response.status).toBe(200);\n        expect(Array.isArray(response.data)).toBe(true);\n      });\n\n      it('should allow viewer to get node tree', async () => {\n        const response = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })\n        );\n        expect(response.status).toBe(200);\n        expect(response.data).toHaveProperty('nodes');\n      });\n\n      it('should allow viewer to get single folder node', async () => {\n        const response = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testFolderId })\n        );\n        expect(response.status).toBe(200);\n        expect(response.data.id).toBe(testFolderId);\n      });\n\n      it('should allow viewer to get single table node', async () => {\n        const response = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId })\n        );\n        expect(response.status).toBe(200);\n        expect(response.data.id).toBe(testTableId);\n      });\n\n      it('should allow viewer to get single dashboard node', async () => {\n        const response = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId })\n        );\n        expect(response.status).toBe(200);\n        expect(response.data.id).toBe(testDashboardId);\n      });\n\n      it('should deny viewer from creating folder node', async () => {\n        const error = await getError(() =>\n          viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {\n            resourceType: BaseNodeResourceType.Folder,\n            name: 'Viewer Created Folder',\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from creating table node', async () => {\n        // Viewer doesn't have table|create permission\n        const error = await getError(() =>\n          viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Viewer Table',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from creating dashboard node', async () => {\n        // Viewer doesn't have base|update permission required for Dashboard creation\n        const error = await getError(() =>\n          viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {\n            resourceType: BaseNodeResourceType.Dashboard,\n            name: 'Viewer Dashboard',\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from updating table node', async () => {\n        // Viewer doesn't have table|update permission\n        const error = await getError(() =>\n          viewerAxios.put(\n            urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),\n            { name: 'Viewer Updated Table' }\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from updating dashboard node', async () => {\n        // Viewer doesn't have base|update permission\n        const error = await getError(() =>\n          viewerAxios.put(\n            urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId }),\n            { name: 'Viewer Updated Dashboard' }\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from deleting table node', async () => {\n        // Viewer doesn't have table|delete permission\n        const error = await getError(() =>\n          viewerAxios.delete(\n            urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId })\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from deleting dashboard node', async () => {\n        // Viewer doesn't have base|update permission\n        const error = await getError(() =>\n          viewerAxios.delete(\n            urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId })\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from moving node (requires base|update)', async () => {\n        // Move operation requires base|update permission\n        const error = await getError(() =>\n          viewerAxios.put(\n            urlBuilder(MOVE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),\n            { parentId: testFolderId }\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('should deny viewer from duplicating table node', async () => {\n        // Duplicate requires BaseNodeAction.Read and BaseNodeAction.Create\n        // For table, create requires table|create which viewer doesn't have\n        const error = await getError(() =>\n          viewerAxios.post(\n            urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),\n            { name: 'Duplicated Table' }\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n    });\n\n    describe('Creator role permissions', () => {\n      const creatorNodesToCleanup: string[] = [];\n\n      afterEach(async () => {\n        for (const nodeId of [...creatorNodesToCleanup].reverse()) {\n          await deleteBaseNode(permissionBaseId, nodeId);\n        }\n        creatorNodesToCleanup.length = 0;\n      });\n\n      it('should allow creator to get node list', async () => {\n        const response = await creatorAxios.get(\n          urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })\n        );\n        expect(response.status).toBe(200);\n      });\n\n      it('should allow creator to get node tree', async () => {\n        const response = await creatorAxios.get(\n          urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })\n        );\n        expect(response.status).toBe(200);\n      });\n\n      it('should allow creator to create folder node', async () => {\n        const response = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Folder,\n            name: 'Creator Folder',\n          }\n        );\n        expect(response.status).toBe(201);\n        expect(response.data.resourceMeta?.name).toBe('Creator Folder');\n        creatorNodesToCleanup.push(response.data.id);\n      });\n\n      it('should allow creator to create table node', async () => {\n        const response = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Creator Table',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          }\n        );\n        expect(response.status).toBe(201);\n        expect(response.data.resourceMeta?.name).toBe('Creator Table');\n        creatorNodesToCleanup.push(response.data.id);\n      });\n\n      it('should allow creator to create dashboard node', async () => {\n        const response = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Dashboard,\n            name: 'Creator Dashboard',\n          }\n        );\n        expect(response.status).toBe(201);\n        expect(response.data.resourceMeta?.name).toBe('Creator Dashboard');\n        creatorNodesToCleanup.push(response.data.id);\n      });\n\n      it('should allow creator to update table node', async () => {\n        const table = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Table to Update',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          }\n        );\n        creatorNodesToCleanup.push(table.data.id);\n\n        const response = await creatorAxios.put(\n          urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),\n          { name: 'Updated Table Name' }\n        );\n        expect(response.status).toBe(200);\n        expect(response.data.resourceMeta?.name).toBe('Updated Table Name');\n      });\n\n      it('should allow creator to delete table node', async () => {\n        const table = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Table to Delete',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          }\n        );\n\n        const response = await creatorAxios.delete(\n          urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id })\n        );\n        expect(response.status).toBe(200);\n      });\n\n      it('should allow creator to move node', async () => {\n        const folder = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Folder,\n            name: 'Move Target Folder',\n          }\n        );\n        creatorNodesToCleanup.push(folder.data.id);\n\n        const table = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Table to Move',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          }\n        );\n        creatorNodesToCleanup.push(table.data.id);\n\n        const response = await creatorAxios.put(\n          urlBuilder(MOVE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),\n          { parentId: folder.data.id }\n        );\n        expect(response.status).toBe(200);\n        expect(response.data.parentId).toBe(folder.data.id);\n      });\n\n      it('should allow creator to duplicate table node', async () => {\n        const table = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Table,\n            name: 'Table to Duplicate',\n            fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n            views: [{ name: 'Grid view', type: ViewType.Grid }],\n          }\n        );\n        creatorNodesToCleanup.push(table.data.id);\n\n        const response = await creatorAxios.post(\n          urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),\n          { name: 'Duplicated Table' }\n        );\n        expect(response.status).toBe(201);\n        expect(response.data.resourceMeta?.name).toBe('Duplicated Table');\n        creatorNodesToCleanup.push(response.data.id);\n      });\n\n      it('should allow creator to duplicate dashboard node', async () => {\n        const dashboard = await creatorAxios.post(\n          urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),\n          {\n            resourceType: BaseNodeResourceType.Dashboard,\n            name: 'Dashboard to Duplicate',\n          }\n        );\n        creatorNodesToCleanup.push(dashboard.data.id);\n\n        const response = await creatorAxios.post(\n          urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: dashboard.data.id }),\n          { name: 'Duplicated Dashboard' }\n        );\n        expect(response.status).toBe(201);\n        expect(response.data.resourceMeta?.name).toBe('Duplicated Dashboard');\n        creatorNodesToCleanup.push(response.data.id);\n      });\n    });\n\n    describe('Permission filtering on list/tree endpoints', () => {\n      it('should filter nodes based on user permissions in list', async () => {\n        // Create nodes as owner\n        const folder = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: 'Shared Folder',\n        });\n        nodesToCleanup.push(folder.data.id);\n\n        const table = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Table,\n          name: 'Shared Table',\n          fields: [{ name: 'Field1', type: FieldType.SingleLineText }],\n          views: [{ name: 'Grid view', type: ViewType.Grid }],\n        });\n        nodesToCleanup.push(table.data.id);\n\n        // Viewer should see nodes they have permission to read\n        const viewerList = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })\n        );\n        expect(viewerList.status).toBe(200);\n\n        // Viewer has table|read so they should see the table\n        const viewerTableNode = viewerList.data.find((n: IBaseNodeVo) => n.id === table.data.id);\n        expect(viewerTableNode).toBeDefined();\n\n        // Viewer has base|read so they should see the folder (folder has no special permission)\n        const viewerFolderNode = viewerList.data.find((n: IBaseNodeVo) => n.id === folder.data.id);\n        expect(viewerFolderNode).toBeDefined();\n      });\n\n      it('should filter nodes based on user permissions in tree', async () => {\n        const folder = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Folder,\n          name: 'Tree Test Folder',\n        });\n        nodesToCleanup.push(folder.data.id);\n\n        const dashboard = await createBaseNode(permissionBaseId, {\n          resourceType: BaseNodeResourceType.Dashboard,\n          name: 'Tree Test Dashboard',\n        });\n        nodesToCleanup.push(dashboard.data.id);\n\n        // Viewer should see nodes in tree\n        const viewerTree = await viewerAxios.get(\n          urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })\n        );\n        expect(viewerTree.status).toBe(200);\n\n        // Viewer has base|read so they should see dashboard (dashboard read requires base|read)\n        const viewerDashboardNode = viewerTree.data.nodes.find(\n          (n: IBaseNodeVo) => n.id === dashboard.data.id\n        );\n        expect(viewerDashboardNode).toBeDefined();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-query.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport {\n  CellFormat,\n  Colors,\n  FieldType,\n  contains,\n  hasAnyOf,\n  isAnyOf,\n  isGreater,\n  SortFunc,\n  StatisticsFunc,\n  TimeFormatting,\n} from '@teable/core';\nimport type { IBaseQuery, ITableFullVo } from '@teable/openapi';\nimport { createTable, BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { BaseQueryService } from '../src/features/base/base-query/base-query.service';\nimport { initApp } from './utils/init-app';\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\ntype AggregationCase = {\n  name: string;\n  buildQuery: () => IBaseQuery;\n  resultKey: () => string;\n  expected: unknown | ((value: unknown) => void);\n  before?: () => Promise<(() => void) | void> | (() => void);\n};\n\ndescribe('BaseSqlQuery e2e', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let baseQueryService: BaseQueryService;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    baseQueryService = app.get(BaseQueryService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const baseQuery = async (\n    baseId: string,\n    baseQuery: IBaseQuery,\n    cellFormat: CellFormat = CellFormat.Text\n  ) => {\n    return await baseQueryService.baseQuery(baseId, baseQuery, cellFormat);\n  };\n\n  describe('Iterate through each query capability', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'age?',\n            type: FieldType.Number,\n          },\n          {\n            name: 'position',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                {\n                  name: 'Frontend Developer',\n                  color: Colors.Red,\n                },\n                {\n                  name: 'Backend Developer',\n                  color: Colors.Blue,\n                },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              name: 'Alice',\n              'age?': 20,\n              position: 'Frontend Developer',\n            },\n          },\n          {\n            fields: {\n              name: 'Bob',\n              'age?': 30,\n              position: 'Backend Developer',\n            },\n          },\n          {\n            fields: {\n              name: 'Charlie',\n              'age?': 40,\n              position: 'Frontend Developer',\n            },\n          },\n        ],\n      }).then((res) => res.data);\n    });\n\n    it('aggregation', async () => {\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        aggregation: [\n          {\n            column: table.fields[1].id,\n            type: BaseQueryColumnType.Field,\n            statisticFunc: StatisticsFunc.Average,\n          },\n        ],\n      });\n\n      expect(res.rows).toEqual([\n        expect.objectContaining({ [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 30 }),\n      ]);\n    });\n\n    it('filter', async () => {\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        where: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              column: table.fields[1].id,\n              type: BaseQueryColumnType.Field,\n              operator: isGreater.value,\n              value: 35,\n            },\n          ],\n        },\n      });\n      expect(res.columns).toHaveLength(3);\n      expect(res.rows).toEqual([\n        {\n          [`${table.fields[0].id}`]: 'Charlie',\n          [`${table.fields[1].id}`]: 40,\n          [`${table.fields[2].id}`]: 'Frontend Developer',\n        },\n      ]);\n    });\n\n    it('orderBy', async () => {\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        orderBy: [\n          {\n            column: table.fields[1].id,\n            type: BaseQueryColumnType.Field,\n            order: SortFunc.Desc,\n          },\n        ],\n      });\n      expect(res.columns).toHaveLength(3);\n      expect(res.rows).toEqual([\n        {\n          [`${table.fields[0].id}`]: 'Charlie',\n          [`${table.fields[1].id}`]: 40,\n          [`${table.fields[2].id}`]: 'Frontend Developer',\n        },\n        {\n          [`${table.fields[0].id}`]: 'Bob',\n          [`${table.fields[1].id}`]: 30,\n          [`${table.fields[2].id}`]: 'Backend Developer',\n        },\n        {\n          [`${table.fields[0].id}`]: 'Alice',\n          [`${table.fields[1].id}`]: 20,\n          [`${table.fields[2].id}`]: 'Frontend Developer',\n        },\n      ]);\n    });\n\n    it('groupBy', async () => {\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        select: [\n          {\n            column: table.fields[2].id,\n            type: BaseQueryColumnType.Field,\n          },\n          {\n            column: `${table.fields[1].id}_${StatisticsFunc.Average}`,\n            type: BaseQueryColumnType.Aggregation,\n          },\n        ],\n        groupBy: [\n          {\n            column: table.fields[2].id,\n            type: BaseQueryColumnType.Field,\n          },\n        ],\n        aggregation: [\n          {\n            column: table.fields[1].id,\n            type: BaseQueryColumnType.Field,\n            statisticFunc: StatisticsFunc.Average,\n          },\n        ],\n      });\n      expect(res.columns).toHaveLength(2);\n      const sortByRole = (a: Record<string, unknown>, b: Record<string, unknown>) =>\n        String(a[table.fields[2].id]).localeCompare(String(b[table.fields[2].id]));\n      expect([...res.rows].sort(sortByRole)).toEqual(\n        [\n          {\n            [`${table.fields[2].id}`]: 'Backend Developer',\n            [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 30,\n          },\n          {\n            [`${table.fields[2].id}`]: 'Frontend Developer',\n            [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 30,\n          },\n        ].sort(sortByRole)\n      );\n    });\n\n    it('groupBy with date', async () => {\n      const table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'id',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'date',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: 'YYYY-MM-DD',\n                time: TimeFormatting.None,\n                timeZone: 'Asia/Shanghai',\n              },\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              id: '1',\n              date: '2024-01-01',\n            },\n          },\n          {\n            fields: {\n              id: '2',\n              date: '2024-01-02',\n            },\n          },\n          {\n            fields: {\n              id: '3',\n              date: '2024-01-01',\n            },\n          },\n        ],\n      }).then((res) => res.data);\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        groupBy: [{ column: table.fields[1].id, type: BaseQueryColumnType.Field }],\n      });\n      expect(res.columns).toHaveLength(1);\n      expect(res.rows).toEqual(\n        expect.arrayContaining([\n          { [`${table.fields[1].id}`]: '2024-01-01' },\n          { [`${table.fields[1].id}`]: '2024-01-02' },\n        ])\n      );\n    });\n\n    it('groupBy with single user field', async () => {\n      const table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'user',\n            type: FieldType.User,\n          },\n        ],\n        records: [\n          {\n            fields: {},\n          },\n          {\n            fields: {\n              user: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      }).then((res) => res.data);\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        groupBy: [{ column: table.fields[0].id, type: BaseQueryColumnType.Field }],\n      });\n      expect(res.columns).toHaveLength(1);\n      const sortByUser = (a: Record<string, unknown>, b: Record<string, unknown>) =>\n        String(a[table.fields[0].id] ?? '').localeCompare(String(b[table.fields[0].id] ?? ''));\n      expect([...res.rows].sort(sortByUser)).toEqual(\n        [{}, { [`${table.fields[0].id}`]: globalThis.testConfig.userName }].sort(sortByUser)\n      );\n    });\n\n    it('filters multi-user field with pre-qualified column names', async () => {\n      const table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'members',\n            type: FieldType.User,\n            options: {\n              isMultiple: true,\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              members: [\n                {\n                  id: globalThis.testConfig.userId,\n                  title: globalThis.testConfig.userName,\n                  email: globalThis.testConfig.email,\n                },\n              ],\n            },\n          },\n          {\n            fields: {\n              members: [],\n            },\n          },\n        ],\n      }).then((res) => res.data);\n      const membersField = table.fields.find((field) => field.name === 'members');\n      expect(membersField).toBeDefined();\n      try {\n        const res = await baseQuery(\n          baseId,\n          {\n            from: table.id,\n            select: [\n              {\n                column: membersField!.id,\n                type: BaseQueryColumnType.Field,\n              },\n            ],\n            where: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  column: membersField!.id,\n                  type: BaseQueryColumnType.Field,\n                  operator: hasAnyOf.value,\n                  value: [globalThis.testConfig.userId],\n                },\n              ],\n            },\n          },\n          CellFormat.Json\n        );\n\n        expect(res.rows).toHaveLength(1);\n        expect(res.rows[0][membersField!.id]).toEqual([\n          expect.objectContaining({ id: globalThis.testConfig.userId }),\n        ]);\n      } finally {\n        // no additional cleanup required\n      }\n    });\n\n    it('limit and offset', async () => {\n      const res = await baseQuery(baseId, {\n        from: table.id,\n        limit: 1,\n        offset: 1,\n      });\n      expect(res.columns).toHaveLength(3);\n      expect(res.rows).toHaveLength(1);\n    });\n\n    describe('from', () => {\n      it('from query', async () => {\n        const res = await baseQuery(baseId, {\n          from: {\n            from: table.id,\n            where: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  column: table.fields[1].id,\n                  type: BaseQueryColumnType.Field,\n                  operator: isGreater.value,\n                  value: 35,\n                },\n              ],\n            },\n          },\n        });\n        expect(res.columns).toHaveLength(3);\n        expect(res.rows).toEqual([\n          {\n            [`${table.fields[0].id}`]: 'Charlie',\n            [`${table.fields[1].id}`]: 40,\n            [`${table.fields[2].id}`]: 'Frontend Developer',\n          },\n        ]);\n      });\n\n      it('from query with aggregation', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Average}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n          ],\n          from: {\n            from: table.id,\n            where: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  column: table.fields[1].id,\n                  type: BaseQueryColumnType.Field,\n                  operator: isGreater.value,\n                  value: 35,\n                },\n              ],\n            },\n          },\n          aggregation: [\n            {\n              column: table.fields[1].id,\n              type: BaseQueryColumnType.Field,\n              statisticFunc: StatisticsFunc.Average,\n            },\n          ],\n        });\n        expect(res.columns).toHaveLength(1);\n        expect(res.rows).toEqual([{ [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 40 }]);\n      });\n\n      it('from query include aggregation', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Average}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n          ],\n          from: {\n            from: table.id,\n            aggregation: [\n              {\n                column: table.fields[1].id,\n                type: BaseQueryColumnType.Field,\n                statisticFunc: StatisticsFunc.Average,\n              },\n            ],\n          },\n        });\n        expect(res.columns).toHaveLength(1);\n        expect(res.rows).toEqual([{ [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 30 }]);\n      });\n\n      it('from query include aggregation and filter', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Average}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n          ],\n          from: {\n            from: table.id,\n            aggregation: [\n              {\n                column: table.fields[1].id,\n                type: BaseQueryColumnType.Field,\n                statisticFunc: StatisticsFunc.Average,\n              },\n            ],\n            where: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  column: table.fields[1].id,\n                  type: BaseQueryColumnType.Field,\n                  operator: isGreater.value,\n                  value: 35,\n                },\n              ],\n            },\n          },\n        });\n        expect(res.columns).toHaveLength(1);\n        expect(res.rows).toEqual([{ [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 40 }]);\n      });\n\n      it('from query include aggregation and filter and orderBy and groupBy', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Average}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n          ],\n          from: {\n            from: table.id,\n            aggregation: [\n              {\n                column: table.fields[1].id,\n                type: BaseQueryColumnType.Field,\n                statisticFunc: StatisticsFunc.Average,\n              },\n            ],\n            where: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  column: table.fields[1].id,\n                  type: BaseQueryColumnType.Field,\n                  operator: isGreater.value,\n                  value: 35,\n                },\n              ],\n            },\n            orderBy: [\n              {\n                column: table.fields[0].id,\n                type: BaseQueryColumnType.Field,\n                order: SortFunc.Desc,\n              },\n            ],\n            groupBy: [\n              {\n                column: table.fields[0].id,\n                type: BaseQueryColumnType.Field,\n              },\n            ],\n          },\n        });\n        expect(res.columns).toHaveLength(1);\n        expect(res.rows).toEqual([{ [`${table.fields[1].id}_${StatisticsFunc.Average}`]: 40 }]);\n      });\n\n      it('from query include aggregation, filter query aggregation field', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n            {\n              column: table.fields[2].id,\n              type: BaseQueryColumnType.Field,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n                type: BaseQueryColumnType.Aggregation,\n                operator: isGreater.value,\n                value: 25,\n              },\n            ],\n          },\n          orderBy: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n              type: BaseQueryColumnType.Aggregation,\n              order: SortFunc.Desc,\n            },\n          ],\n          from: {\n            from: table.id,\n            aggregation: [\n              {\n                column: table.fields[1].id,\n                type: BaseQueryColumnType.Field,\n                statisticFunc: StatisticsFunc.Sum,\n              },\n            ],\n            groupBy: [\n              {\n                column: table.fields[2].id,\n                type: BaseQueryColumnType.Field,\n              },\n            ],\n          },\n        });\n        expect(res.columns).toHaveLength(2);\n        expect(res.rows).toEqual([\n          {\n            [`${table.fields[1].id}_${StatisticsFunc.Sum}`]: 60,\n            [`${table.fields[2].id}`]: 'Frontend Developer',\n          },\n          {\n            [`${table.fields[1].id}_${StatisticsFunc.Sum}`]: 30,\n            [`${table.fields[2].id}`]: 'Backend Developer',\n          },\n        ]);\n      });\n\n      it('from query include aggregation, filter and group query aggregation field - query include select', async () => {\n        const res = await baseQuery(baseId, {\n          select: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n            {\n              column: table.fields[2].id,\n              type: BaseQueryColumnType.Field,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n                type: BaseQueryColumnType.Aggregation,\n                operator: isGreater.value,\n                value: 25,\n              },\n            ],\n          },\n          groupBy: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n              type: BaseQueryColumnType.Aggregation,\n            },\n            {\n              column: table.fields[2].id,\n              type: BaseQueryColumnType.Field,\n            },\n          ],\n          orderBy: [\n            {\n              column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n              type: BaseQueryColumnType.Aggregation,\n              order: SortFunc.Desc,\n            },\n          ],\n          from: {\n            select: [\n              {\n                column: `${table.fields[1].id}_${StatisticsFunc.Sum}`,\n                type: BaseQueryColumnType.Aggregation,\n              },\n              {\n                column: table.fields[2].id,\n                type: BaseQueryColumnType.Field,\n              },\n            ],\n            from: table.id,\n            aggregation: [\n              {\n                column: table.fields[1].id,\n                type: BaseQueryColumnType.Field,\n                statisticFunc: StatisticsFunc.Sum,\n              },\n            ],\n            groupBy: [\n              {\n                column: table.fields[2].id,\n                type: BaseQueryColumnType.Field,\n              },\n            ],\n          },\n        });\n        expect(res.columns).toHaveLength(2);\n        expect(res.rows).toEqual([\n          {\n            [`${table.fields[1].id}_${StatisticsFunc.Sum}`]: 60,\n            [`${table.fields[2].id}`]: 'Frontend Developer',\n          },\n          {\n            [`${table.fields[1].id}_${StatisticsFunc.Sum}`]: 30,\n            [`${table.fields[2].id}`]: 'Backend Developer',\n          },\n        ]);\n      });\n    });\n  });\n\n  describe('Dashboard statistics combinations', () => {\n    let statsTable: ITableFullVo;\n    let statsRecordField: ITableFullVo['fields'][number];\n    let statsScoreField: ITableFullVo['fields'][number];\n    let statsStatusField: ITableFullVo['fields'][number];\n    let statsDueField: ITableFullVo['fields'][number];\n    let statsAssigneesField: ITableFullVo['fields'][number];\n\n    const statsAggregationCases: AggregationCase[] = [\n      {\n        name: 'sums score values greater than 25',\n        buildQuery: () => ({\n          from: statsTable.id,\n          aggregation: [\n            {\n              column: statsScoreField.id,\n              type: BaseQueryColumnType.Field,\n              statisticFunc: StatisticsFunc.Sum,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: statsScoreField.id,\n                type: BaseQueryColumnType.Field,\n                operator: isGreater.value,\n                value: 25,\n              },\n            ],\n          },\n        }),\n        resultKey: () => `${statsScoreField.id}_${StatisticsFunc.Sum}`,\n        expected: 70,\n      },\n      {\n        name: 'averages score for Todo records',\n        buildQuery: () => ({\n          from: statsTable.id,\n          aggregation: [\n            {\n              column: statsScoreField.id,\n              type: BaseQueryColumnType.Field,\n              statisticFunc: StatisticsFunc.Average,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: statsStatusField.id,\n                type: BaseQueryColumnType.Field,\n                operator: isAnyOf.value,\n                value: ['Todo'],\n              },\n            ],\n          },\n        }),\n        resultKey: () => `${statsScoreField.id}_${StatisticsFunc.Average}`,\n        expected: 30,\n      },\n      {\n        name: 'selects latest due date for assigned user',\n        buildQuery: () => ({\n          from: statsTable.id,\n          aggregation: [\n            {\n              column: statsDueField.id,\n              type: BaseQueryColumnType.Field,\n              statisticFunc: StatisticsFunc.LatestDate,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: statsAssigneesField.id,\n                type: BaseQueryColumnType.Field,\n                operator: hasAnyOf.value,\n                value: [globalThis.testConfig.userId],\n              },\n            ],\n          },\n        }),\n        resultKey: () => `${statsDueField.id}_${StatisticsFunc.LatestDate}`,\n        expected: (value: unknown) => {\n          expect(typeof value === 'string' || value instanceof Date).toBe(true);\n          const zoned = dayjs(value as string).tz('Asia/Shanghai');\n          expect(zoned.isValid()).toBe(true);\n          expect(zoned.year()).toBe(2024);\n          expect(zoned.month()).toBe(0);\n          expect(zoned.date()).toBe(10);\n        },\n      },\n      {\n        name: 'counts status entries when record contains Beta',\n        buildQuery: () => ({\n          from: statsTable.id,\n          aggregation: [\n            {\n              column: statsStatusField.id,\n              type: BaseQueryColumnType.Field,\n              statisticFunc: StatisticsFunc.Count,\n            },\n          ],\n          where: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                column: statsRecordField.id,\n                type: BaseQueryColumnType.Field,\n                operator: contains.value,\n                value: 'Beta',\n              },\n            ],\n          },\n        }),\n        resultKey: () => `${statsStatusField.id}_${StatisticsFunc.Count}`,\n        expected: 1,\n      },\n    ];\n\n    beforeAll(async () => {\n      statsTable = await createTable(baseId, {\n        fields: [\n          {\n            name: 'record',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'score',\n            type: FieldType.Number,\n          },\n          {\n            name: 'status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'Todo', color: Colors.Red },\n                { name: 'In Progress', color: Colors.Blue },\n              ],\n            },\n          },\n          {\n            name: 'due',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: 'YYYY-MM-DD',\n                time: TimeFormatting.None,\n                timeZone: 'Asia/Shanghai',\n              },\n            },\n          },\n          {\n            name: 'assignees',\n            type: FieldType.User,\n            options: {\n              isMultiple: true,\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              record: 'Alpha',\n              score: 20,\n              status: 'Todo',\n              due: '2024-01-02',\n              assignees: [\n                {\n                  id: globalThis.testConfig.userId,\n                  title: globalThis.testConfig.userName,\n                  email: globalThis.testConfig.email,\n                },\n              ],\n            },\n          },\n          {\n            fields: {\n              record: 'Beta',\n              score: 30,\n              status: 'In Progress',\n              due: '2024-01-05',\n              assignees: [],\n            },\n          },\n          {\n            fields: {\n              record: 'Gamma',\n              score: 40,\n              status: 'Todo',\n              due: '2024-01-10',\n              assignees: [\n                {\n                  id: globalThis.testConfig.userId,\n                  title: globalThis.testConfig.userName,\n                  email: globalThis.testConfig.email,\n                },\n              ],\n            },\n          },\n        ],\n      }).then((res) => res.data);\n\n      const fieldByName = (fieldName: string) => {\n        const field = statsTable.fields.find((cur) => cur.name === fieldName);\n        if (!field) {\n          throw new Error(`Field ${fieldName} not found in stats table`);\n        }\n        return field;\n      };\n\n      statsRecordField = fieldByName('record');\n      statsScoreField = fieldByName('score');\n      statsStatusField = fieldByName('status');\n      statsDueField = fieldByName('due');\n      statsAssigneesField = fieldByName('assignees');\n    });\n\n    it.each(statsAggregationCases)('%s', async (testCase) => {\n      const cleanupCandidate = testCase.before ? await testCase.before() : undefined;\n      const cleanup = typeof cleanupCandidate === 'function' ? cleanupCandidate : undefined;\n\n      try {\n        const result = await baseQuery(baseId, testCase.buildQuery(), CellFormat.Json);\n        expect(result.rows).toHaveLength(1);\n        const key = testCase.resultKey();\n        expect(result.columns.some((column) => column.column === key)).toBe(true);\n        const value = result.rows[0][key];\n        if (typeof testCase.expected === 'function') {\n          (testCase.expected as (val: unknown) => void)(value);\n        } else {\n          expect(value).toEqual(testCase.expected);\n        }\n      } finally {\n        cleanup?.();\n      }\n    });\n  });\n\n  describe('Iterate through each query capability with join', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeAll(async () => {\n      table1 = await createTable(baseId, {\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'age',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              name: 'Alice',\n              age: 20,\n            },\n          },\n          {\n            fields: {\n              name: 'Bob',\n              age: 30,\n            },\n          },\n          {\n            fields: {\n              name: 'Charlie',\n              age: 40,\n            },\n          },\n        ],\n      }).then((res) => res.data);\n\n      table2 = await createTable(baseId, {\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'age',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              name: 'David',\n              age: 20,\n            },\n          },\n          {\n            fields: {\n              name: 'Eve',\n              age: 30,\n            },\n          },\n          {\n            fields: {\n              name: 'Frank',\n              age: 50,\n            },\n          },\n        ],\n      }).then((res) => res.data);\n    });\n\n    it('join', async () => {\n      const res = await baseQuery(baseId, {\n        from: table1.id,\n        join: [\n          {\n            type: BaseQueryJoinType.Left,\n            table: table2.id,\n            on: [`${table1.fields[1].id}`, `${table2.fields[1].id}`],\n          },\n        ],\n      });\n      expect(res.columns).toHaveLength(4);\n      expect(res.rows).toEqual([\n        {\n          [`${table1.fields[0].id}`]: 'Alice',\n          [`${table1.fields[1].id}`]: 20,\n          [`${table2.fields[0].id}`]: 'David',\n          [`${table2.fields[1].id}`]: 20,\n        },\n        {\n          [`${table1.fields[0].id}`]: 'Bob',\n          [`${table1.fields[1].id}`]: 30,\n          [`${table2.fields[0].id}`]: 'Eve',\n          [`${table2.fields[1].id}`]: 30,\n        },\n        {\n          [`${table1.fields[0].id}`]: 'Charlie',\n          [`${table1.fields[1].id}`]: 40,\n        },\n      ]);\n    });\n\n    it('join inner', async () => {\n      const res = await baseQuery(baseId, {\n        from: table1.id,\n        join: [\n          {\n            type: BaseQueryJoinType.Inner,\n            table: table2.id,\n            on: [`${table1.fields[1].id}`, `${table2.fields[1].id}`],\n          },\n        ],\n      });\n      expect(res.columns).toHaveLength(4);\n      expect(res.rows).toEqual([\n        {\n          [`${table1.fields[0].id}`]: 'Alice',\n          [`${table1.fields[1].id}`]: 20,\n          [`${table2.fields[0].id}`]: 'David',\n          [`${table2.fields[1].id}`]: 20,\n        },\n        {\n          [`${table1.fields[0].id}`]: 'Bob',\n          [`${table1.fields[1].id}`]: 30,\n          [`${table2.fields[0].id}`]: 'Eve',\n          [`${table2.fields[1].id}`]: 30,\n        },\n      ]);\n    });\n\n    it('join filter and select', async () => {\n      const res = await baseQuery(baseId, {\n        from: table1.id,\n        join: [\n          {\n            type: BaseQueryJoinType.Left,\n            table: table2.id,\n            on: [`${table1.fields[1].id}`, `${table2.fields[1].id}`],\n          },\n        ],\n        where: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              column: `${table2.fields[1].id}`,\n              type: BaseQueryColumnType.Field,\n              operator: isGreater.value,\n              value: 25,\n            },\n          ],\n        },\n        select: [\n          {\n            column: `${table1.fields[0].id}`,\n            type: BaseQueryColumnType.Field,\n          },\n          {\n            column: `${table2.fields[0].id}`,\n            type: BaseQueryColumnType.Field,\n          },\n        ],\n      });\n      expect(res.columns).toHaveLength(2);\n      expect(res.rows).toEqual([\n        {\n          [`${table1.fields[0].id}`]: 'Bob',\n          [`${table2.fields[0].id}`]: 'Eve',\n        },\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-share.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILookupOptionsRo } from '@teable/core';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { IBaseNodeVo, IGetBaseShareVo } from '@teable/openapi';\nimport {\n  BASE_SHARE_AUTH,\n  BASE_SHARE_ID_HEADER,\n  BaseNodeResourceType,\n  copyBaseShare,\n  createBase,\n  createBaseNode,\n  createBaseShare,\n  createField,\n  createSpace,\n  deleteBaseShare,\n  deleteSpace,\n  GET_BASE_NODE_LIST,\n  GET_BASE_NODE_TREE,\n  GET_BASE_SHARE,\n  getBaseNodeList,\n  getBaseShareByNodeId,\n  getFields,\n  getTableList,\n  listBaseShare,\n  moveBaseNode,\n  refreshBaseShare,\n  updateBaseShare,\n  urlBuilder,\n} from '@teable/openapi';\nimport { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user';\nimport { getError } from './utils/get-error';\nimport {\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteBase,\n  updateRecord,\n} from './utils/init-app';\n\ndescribe('BaseShareController (e2e)', () => {\n  let app: INestApplication;\n  let baseId: string;\n  let folderNodeId: string;\n  let rootTableId: string;\n  let childTableId: string;\n  let rootTableNodeId: string;\n  let childTableNodeId: string;\n  let anonymousUser: ReturnType<typeof createAnonymousUserAxios>;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    anonymousUser = createAnonymousUserAxios(appCtx.appUrl);\n\n    const base = await createBase({\n      name: 'base-share-e2e',\n      spaceId: globalThis.testConfig.spaceId,\n    }).then((res) => res.data);\n    baseId = base.id;\n\n    const rootTable = await createTable(baseId, { name: 'root-table' });\n    const childTable = await createTable(baseId, { name: 'child-table' });\n    rootTableId = rootTable.id;\n    childTableId = childTable.id;\n\n    const folder = await createBaseNode(baseId, {\n      resourceType: BaseNodeResourceType.Folder,\n      name: 'share-folder',\n    });\n    folderNodeId = folder.data.id;\n\n    const nodeList = await getBaseNodeList(baseId);\n    const rootTableNode = nodeList.data.find((node) => node.resourceId === rootTableId);\n    const childTableNode = nodeList.data.find((node) => node.resourceId === childTableId);\n    if (!rootTableNode || !childTableNode) {\n      throw new Error('Table nodes not found in base node list');\n    }\n    rootTableNodeId = rootTableNode.id;\n    childTableNodeId = childTableNode.id;\n\n    await moveBaseNode(baseId, childTableNodeId, { parentId: folderNodeId });\n  });\n\n  afterAll(async () => {\n    await permanentDeleteBase(baseId);\n    await app.close();\n  });\n\n  describe('BaseShareController - Admin API /api/base/:baseId/share', () => {\n    const createdShareIds: string[] = [];\n\n    afterEach(async () => {\n      // Clean up all shares created during the test\n      for (const shareId of createdShareIds) {\n        await deleteBaseShare(baseId, shareId).catch(() => undefined);\n      }\n      createdShareIds.length = 0;\n    });\n\n    it('should create base share with nodeId', async () => {\n      const res = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(res.data.shareId);\n      expect(res.status).toEqual(201);\n      expect(res.data.baseId).toEqual(baseId);\n      expect(res.data.shareId).toBeDefined();\n      expect(res.data.nodeId).toEqual(rootTableNodeId);\n      expect(res.data.enabled).toBe(true);\n      expect(res.data.password).toBe(false);\n      expect(res.data.allowSave).toBeNull();\n      expect(res.data.allowCopy).toBeNull();\n    });\n\n    it('should create base share with password', async () => {\n      const res = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        password: 'test123456',\n      });\n      createdShareIds.push(res.data.shareId);\n      expect(res.status).toEqual(201);\n      expect(res.data.password).toBe(true);\n    });\n\n    it('should create base share with folder nodeId', async () => {\n      const res = await createBaseShare(baseId, { nodeId: folderNodeId });\n      createdShareIds.push(res.data.shareId);\n      expect(res.status).toEqual(201);\n      expect(res.data.nodeId).toEqual(folderNodeId);\n    });\n\n    it('should create base share with allowSave and allowCopy', async () => {\n      const res = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        allowSave: true,\n        allowCopy: true,\n      });\n      createdShareIds.push(res.data.shareId);\n      expect(res.status).toEqual(201);\n      expect(res.data.allowSave).toBe(true);\n      expect(res.data.allowCopy).toBe(true);\n    });\n\n    it('should list all shared node IDs', async () => {\n      // Create shares with different nodeIds\n      const share1 = await createBaseShare(baseId, { nodeId: folderNodeId });\n      createdShareIds.push(share1.data.shareId);\n      const share2 = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share2.data.shareId);\n\n      const res = await listBaseShare(baseId);\n      expect(res.status).toEqual(200);\n      expect(Array.isArray(res.data)).toBe(true);\n      expect(res.data.length).toBeGreaterThanOrEqual(2);\n\n      // List only returns nodeId\n      const nodeIds = res.data.map((s) => s.nodeId);\n      expect(nodeIds).toContain(folderNodeId);\n      expect(nodeIds).toContain(rootTableNodeId);\n    });\n\n    it('should get base share by nodeId', async () => {\n      // Use childTableNodeId to avoid conflicts with Public API tests using folderNodeId\n      const share = await createBaseShare(baseId, {\n        nodeId: childTableNodeId,\n        password: 'secret123',\n      });\n      createdShareIds.push(share.data.shareId);\n\n      const res = await getBaseShareByNodeId(baseId, childTableNodeId);\n      expect(res.status).toEqual(200);\n      expect(res.data.shareId).toEqual(share.data.shareId);\n      expect(res.data.baseId).toEqual(baseId);\n      expect(res.data.nodeId).toEqual(childTableNodeId);\n      // password is returned as boolean\n      expect(res.data.password).toBe(true);\n    });\n\n    it('should update base share settings', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      // Update allowSave and allowCopy\n      const updateRes = await updateBaseShare(baseId, shareId, {\n        allowSave: true,\n        allowCopy: true,\n      });\n      expect(updateRes.status).toEqual(200);\n      expect(updateRes.data.allowSave).toBe(true);\n      expect(updateRes.data.allowCopy).toBe(true);\n\n      // Add password\n      const passwordRes = await updateBaseShare(baseId, shareId, { password: 'newpass123' });\n      expect(passwordRes.status).toEqual(200);\n      expect(passwordRes.data.password).toBe(true);\n\n      // Remove password by setting null\n      const removePassRes = await updateBaseShare(baseId, shareId, { password: null });\n      expect(removePassRes.status).toEqual(200);\n      expect(removePassRes.data.password).toBe(false);\n\n      // Update enabled status (do this last as disabled share may not be updatable)\n      const disableRes = await updateBaseShare(baseId, shareId, { enabled: false });\n      expect(disableRes.status).toEqual(200);\n      expect(disableRes.data.enabled).toBe(false);\n    });\n\n    it('should delete base share', async () => {\n      // Use childTableNodeId to avoid conflicts with other tests using folderNodeId\n      const share = await createBaseShare(baseId, { nodeId: childTableNodeId });\n      const shareId = share.data.shareId;\n\n      const deleteRes = await deleteBaseShare(baseId, shareId);\n      expect(deleteRes.status).toEqual(200);\n\n      // Verify share is deleted (getByNodeId should return null or empty)\n      const res = await getBaseShareByNodeId(baseId, childTableNodeId);\n      expect(res.status).toEqual(200);\n      expect(res.data).toBeFalsy();\n    });\n\n    it('should refresh base share id', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      const originalShareId = share.data.shareId;\n\n      const refreshRes = await refreshBaseShare(baseId, originalShareId);\n      createdShareIds.push(refreshRes.data.shareId);\n      expect(refreshRes.status).toEqual(201);\n      expect(refreshRes.data.shareId).not.toEqual(originalShareId);\n      expect(refreshRes.data.baseId).toEqual(baseId);\n\n      // Verify the share still exists with new shareId via nodeId lookup\n      const newShareRes = await getBaseShareByNodeId(baseId, rootTableNodeId);\n      expect(newShareRes.status).toEqual(200);\n      expect(newShareRes.data.shareId).toEqual(refreshRes.data.shareId);\n    });\n  });\n\n  describe('BaseShareOpenController - Public API /api/share/:shareId/base', () => {\n    const createdShareIds: string[] = [];\n\n    afterEach(async () => {\n      // Clean up all shares created during the test\n      for (const shareId of createdShareIds) {\n        await deleteBaseShare(baseId, shareId).catch(() => undefined);\n      }\n      createdShareIds.length = 0;\n    });\n\n    it('should get base share info without password', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const res = await anonymousUser.get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }));\n      expect(res.status).toEqual(200);\n      expect(res.data.baseId).toEqual(baseId);\n      expect(res.data.shareMeta).toBeDefined();\n      expect(res.data.shareMeta.password).toBe(false);\n      expect(res.data.shareMeta.nodeId).toEqual(rootTableNodeId);\n    });\n\n    it('should return defaultUrl for redirect', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const res = await anonymousUser.get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }));\n      expect(res.status).toEqual(200);\n\n      // Should have defaultUrl for redirect\n      expect(res.data.defaultUrl).toBeDefined();\n      expect(res.data.defaultUrl).toContain(`/base/${baseId}/table/${rootTableId}`);\n    });\n\n    it('should return nodeId in shareMeta when sharing a folder', async () => {\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const res = await anonymousUser.get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }));\n      expect(res.status).toEqual(200);\n      expect(res.data.shareMeta.nodeId).toEqual(folderNodeId);\n\n      // defaultUrl should point to the first table within the shared folder\n      expect(res.data.defaultUrl).toBeDefined();\n      expect(res.data.defaultUrl).toContain(`/base/${baseId}/table/${childTableId}`);\n    });\n\n    it('should return defaultUrl for shared table node', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const res = await anonymousUser.get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }));\n      expect(res.status).toEqual(200);\n\n      // defaultUrl should point to the shared table\n      expect(res.data.defaultUrl).toBeDefined();\n      expect(res.data.defaultUrl).toContain(`/base/${baseId}/table/${rootTableId}`);\n    });\n\n    it('should include allowSave and allowCopy in shareMeta', async () => {\n      const share = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        allowSave: true,\n        allowCopy: false,\n      });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const res = await anonymousUser.get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }));\n      expect(res.status).toEqual(200);\n      expect(res.data.shareMeta.allowSave).toBe(true);\n      expect(res.data.shareMeta.allowCopy).toBe(false);\n    });\n\n    it('should require authentication for password-protected share', async () => {\n      const share = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        password: 'testpwd123',\n      });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      // Direct access without auth should return 401 for password-protected shares\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId }))\n      );\n      expect(error?.status).toEqual(401);\n    });\n\n    it('should authenticate with correct password', async () => {\n      const password = 'correctpass123';\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId, password });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const authRes = await anonymousUser.post(urlBuilder(BASE_SHARE_AUTH, { shareId }), {\n        password,\n      });\n      expect(authRes.status).toEqual(200);\n      expect(authRes.data.token).toBeDefined();\n      expect(authRes.headers['set-cookie']).toBeDefined();\n    });\n\n    it('should reject authentication with wrong password', async () => {\n      const share = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        password: 'correctpass',\n      });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const error = await getError(() =>\n        anonymousUser.post(urlBuilder(BASE_SHARE_AUTH, { shareId }), {\n          password: 'wrongpassword',\n        })\n      );\n      expect(error?.status).toEqual(400);\n    });\n\n    it('requires password for base share protected endpoints', async () => {\n      const share = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        password: '123123123',\n      });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_NODE_LIST, { baseId }), {\n          headers: {\n            [BASE_SHARE_ID_HEADER]: shareId,\n          },\n        })\n      );\n      expect(error?.status).toEqual(401);\n\n      const authRes = await anonymousUser.post(urlBuilder(BASE_SHARE_AUTH, { shareId }), {\n        password: '123123123',\n      });\n      const listRes = await anonymousUser.get(urlBuilder(GET_BASE_NODE_LIST, { baseId }), {\n        headers: {\n          [BASE_SHARE_ID_HEADER]: shareId,\n          cookie: authRes.headers['set-cookie'],\n        },\n      });\n      expect(listRes.status).toEqual(200);\n    });\n\n    it('rejects disabled base share access', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      await updateBaseShare(baseId, shareId, { enabled: false });\n\n      const getShareError = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId }))\n      );\n      expect(getShareError?.status).toEqual(404);\n\n      const listError = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_NODE_LIST, { baseId }), {\n          headers: {\n            [BASE_SHARE_ID_HEADER]: shareId,\n          },\n        })\n      );\n      expect(listError?.status).toEqual(403);\n    });\n\n    it('filters base node list/tree by shared node', async () => {\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      const listRes = await anonymousUser.get<IBaseNodeVo[]>(\n        urlBuilder(GET_BASE_NODE_LIST, { baseId }),\n        {\n          headers: {\n            [BASE_SHARE_ID_HEADER]: shareId,\n          },\n        }\n      );\n      const listNodeIds = new Set(listRes.data.map((node) => node.id));\n      // Verify folder and child table are included\n      expect(listNodeIds.has(folderNodeId)).toBe(true);\n      expect(listNodeIds.has(childTableNodeId)).toBe(true);\n\n      const treeRes = await anonymousUser.get<{ nodes: IBaseNodeVo[] }>(\n        urlBuilder(GET_BASE_NODE_TREE, { baseId }),\n        {\n          headers: {\n            [BASE_SHARE_ID_HEADER]: shareId,\n          },\n        }\n      );\n      const treeNodeIds = new Set(treeRes.data.nodes.map((node) => node.id));\n      // Verify folder and child table are included in tree\n      expect(treeNodeIds.has(folderNodeId)).toBe(true);\n      expect(treeNodeIds.has(childTableNodeId)).toBe(true);\n    });\n\n    it('should return 404 for non-existent share', async () => {\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId: 'non-existent-share-id' }))\n      );\n      expect(error?.status).toEqual(404);\n    });\n  });\n\n  describe('BaseShareOpenController - Copy Base Share /api/share/:shareId/base/copy', () => {\n    let targetSpaceId: string;\n    let copiedBaseId: string | undefined;\n    let testShareId: string | undefined;\n    const rejectedCopyName = 'should-not-copy';\n\n    beforeAll(async () => {\n      const space = await createSpace({ name: 'copy-target-space' });\n      targetSpaceId = space.data.id;\n    });\n\n    afterAll(async () => {\n      await deleteSpace(targetSpaceId);\n    });\n\n    afterEach(async () => {\n      if (copiedBaseId) {\n        await permanentDeleteBase(copiedBaseId);\n        copiedBaseId = undefined;\n      }\n      if (testShareId) {\n        await deleteBaseShare(baseId, testShareId).catch(() => undefined);\n        testShareId = undefined;\n      }\n    });\n\n    it('should copy base share to my space', async () => {\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId, allowSave: true });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        name: 'copied-base',\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      expect(copyRes.data.id).toBeDefined();\n      expect(copyRes.data.name).toEqual('copied-base');\n\n      copiedBaseId = copyRes.data.id;\n\n      // Verify tables are copied\n      const tableList = await getTableList(copiedBaseId);\n      expect(tableList.data.length).toBeGreaterThan(0);\n    });\n\n    it('should copy base share with records', async () => {\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId, allowSave: true });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        name: 'copied-base-with-records',\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n\n      // Verify records are copied\n      const tableList = await getTableList(copiedBaseId);\n      const records = await getRecords(tableList.data[0].id);\n      expect(records.records.length).toBeGreaterThan(0);\n    });\n\n    it('should copy base share without records', async () => {\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId, allowSave: true });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        name: 'copied-base-without-records',\n        withRecords: false,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n\n      // Verify no records are copied\n      const tableList = await getTableList(copiedBaseId);\n      const records = await getRecords(tableList.data[0].id);\n      expect(records.records.length).toEqual(0);\n    });\n\n    it('should reject copy when allowSave is false', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId, allowSave: false });\n      testShareId = share.data.shareId;\n      // Clear any inherited password from previously soft-deleted share for this nodeId\n      await updateBaseShare(baseId, testShareId, { password: null });\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          name: rejectedCopyName,\n          withRecords: true,\n        })\n      );\n\n      expect(error?.status).toEqual(403);\n    });\n\n    it('should reject copy when allowSave is not set (null)', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      testShareId = share.data.shareId;\n      // Clear any inherited password from previously soft-deleted share for this nodeId\n      await updateBaseShare(baseId, testShareId, { password: null });\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          name: rejectedCopyName,\n          withRecords: true,\n        })\n      );\n\n      expect(error?.status).toEqual(403);\n    });\n\n    it('should reject copy of password-protected base share without password', async () => {\n      // Password-protected shares require authentication even for logged-in users\n      const share = await createBaseShare(baseId, {\n        nodeId: rootTableNodeId,\n        password: 'testpassword123',\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          name: rejectedCopyName,\n          withRecords: true,\n        })\n      );\n\n      expect(error?.status).toEqual(401);\n    });\n\n    it('should reject copy to non-existent space', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId, allowSave: true });\n      testShareId = share.data.shareId;\n      // Clear any inherited password from previously soft-deleted share for this nodeId\n      await updateBaseShare(baseId, testShareId, { password: null });\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: 'non-existent-space-id',\n          name: rejectedCopyName,\n          withRecords: true,\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should generate default name when name is not provided', async () => {\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId, allowSave: true });\n      testShareId = share.data.shareId;\n      // Clear any inherited password from previously soft-deleted share for this nodeId\n      await updateBaseShare(baseId, testShareId, { password: null });\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n      expect(copyRes.data.name).toBeDefined();\n      expect(copyRes.data.name.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('BaseShareOpenController - Copy Base Share with Link Fields', () => {\n    let linkBaseId: string;\n    let linkTargetSpaceId: string;\n    let copiedBaseId: string | undefined;\n    let testShareId: string | undefined;\n    let table1Id: string;\n    let table2Id: string;\n    let table3Id: string;\n    let table1NodeId: string;\n    let linkField12: { id: string; name: string };\n    let linkField13: { id: string; name: string };\n\n    beforeAll(async () => {\n      // Create target space\n      const space = await createSpace({ name: 'link-copy-target-space' });\n      linkTargetSpaceId = space.data.id;\n\n      // Create a separate base for link field tests\n      const base = await createBase({\n        name: 'base-share-link-e2e',\n        spaceId: globalThis.testConfig.spaceId,\n      });\n      linkBaseId = base.data.id;\n\n      // Create tables\n      const table1 = await createTable(linkBaseId, { name: 'Orders' });\n      const table2 = await createTable(linkBaseId, { name: 'Customers' });\n      const table3 = await createTable(linkBaseId, { name: 'Products' });\n      table1Id = table1.id;\n      table2Id = table2.id;\n      table3Id = table3.id;\n\n      // Get node ID for table1 (Orders)\n      const linkNodeList = await getBaseNodeList(linkBaseId);\n      const table1Node = linkNodeList.data.find((n) => n.resourceId === table1Id);\n      if (!table1Node) {\n        throw new Error('Table1 node not found in link base node list');\n      }\n      table1NodeId = table1Node.id;\n\n      // Create link from Orders to Customers\n      const linkFieldRo12: IFieldRo = {\n        name: 'customer',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2Id,\n        },\n      };\n      const field12 = await createField(table1Id, linkFieldRo12);\n      linkField12 = { id: field12.data.id, name: field12.data.name };\n\n      // Create link from Orders to Products\n      const linkFieldRo13: IFieldRo = {\n        name: 'products',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table3Id,\n        },\n      };\n      const field13 = await createField(table1Id, linkFieldRo13);\n      linkField13 = { id: field13.data.id, name: field13.data.name };\n\n      // Create some link data\n      const table1Records = await getRecords(table1Id);\n      const table2Records = await getRecords(table2Id);\n      const table3Records = await getRecords(table3Id);\n\n      await updateRecord(table1Id, table1Records.records[0].id, {\n        record: {\n          fields: {\n            [linkField12.name]: [{ id: table2Records.records[0].id }],\n            [linkField13.name]: [{ id: table3Records.records[0].id }],\n          },\n        },\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteBase(linkBaseId);\n      await deleteSpace(linkTargetSpaceId);\n    });\n\n    afterEach(async () => {\n      if (copiedBaseId) {\n        await permanentDeleteBase(copiedBaseId);\n        copiedBaseId = undefined;\n      }\n      if (testShareId) {\n        await deleteBaseShare(linkBaseId, testShareId).catch(() => undefined);\n        testShareId = undefined;\n      }\n    });\n\n    it('should copy base share with single table and disconnect link fields', async () => {\n      const share = await createBaseShare(linkBaseId, { nodeId: table1NodeId, allowSave: true });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: linkTargetSpaceId,\n        name: 'copied-link-base',\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n\n      // Only the shared table (Orders) should be copied;\n      // linked tables (Customers, Products) are outside the shared node\n      const tableList = await getTableList(copiedBaseId);\n      expect(tableList.data.length).toBe(1);\n      expect(tableList.data[0].name).toBe('Orders');\n\n      // Link fields to tables outside the shared node should be disconnected (converted to text)\n      const ordersFields = await getFields(tableList.data[0].id);\n      const customerField = ordersFields.data.find((f) => f.name === linkField12.name);\n      const productsField = ordersFields.data.find((f) => f.name === linkField13.name);\n      expect(customerField?.type).toBe(FieldType.SingleLineText);\n      expect(productsField?.type).toBe(FieldType.SingleLineText);\n    });\n\n    it('should convert disconnected link fields when copying partial base', async () => {\n      // Create a separate base for this test to avoid state pollution\n      const testBase = await createBase({\n        name: 'partial-copy-test-base',\n        spaceId: globalThis.testConfig.spaceId,\n      });\n      const testBaseId = testBase.data.id;\n\n      // Create tables\n      const ordersTable = await createTable(testBaseId, { name: 'Orders' });\n      const customersTable = await createTable(testBaseId, { name: 'Customers' });\n      const productsTable = await createTable(testBaseId, { name: 'Products' });\n\n      // Create link from Orders to Customers\n      await createField(ordersTable.id, {\n        name: 'customer',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: customersTable.id,\n        },\n      });\n\n      // Create link from Orders to Products (will be disconnected)\n      await createField(ordersTable.id, {\n        name: 'products',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: productsTable.id,\n        },\n      });\n\n      // Get node IDs\n      const nodeList = await getBaseNodeList(testBaseId);\n      const ordersNode = nodeList.data.find((n) => n.resourceId === ordersTable.id);\n      const customersNode = nodeList.data.find((n) => n.resourceId === customersTable.id);\n\n      // Create a folder containing only Orders and Customers\n      const folder = await createBaseNode(testBaseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'partial-folder',\n      });\n\n      await moveBaseNode(testBaseId, ordersNode!.id, { parentId: folder.data.id });\n      await moveBaseNode(testBaseId, customersNode!.id, { parentId: folder.data.id });\n\n      // Share only the folder\n      const share = await createBaseShare(testBaseId, {\n        nodeId: folder.data.id,\n        allowSave: true,\n      });\n\n      const copyRes = await copyBaseShare(share.data.shareId, {\n        spaceId: linkTargetSpaceId,\n        name: 'copied-partial-link-base',\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n\n      // Verify only 2 tables are copied\n      const tableList = await getTableList(copiedBaseId);\n      expect(tableList.data.length).toBe(2);\n      expect(tableList.data.map((t) => t.name).sort()).toEqual(['Customers', 'Orders'].sort());\n\n      // Verify link to Customers remains as Link type\n      const copiedOrdersTable = tableList.data.find((t) => t.name === 'Orders')!;\n      const ordersFields = await getFields(copiedOrdersTable.id);\n      const customerField = ordersFields.data.find((f) => f.name === 'customer');\n      expect(customerField?.type).toBe(FieldType.Link);\n\n      // Verify link to Products is converted to SingleLineText (disconnected)\n      const productsField = ordersFields.data.find((f) => f.name === 'products');\n      expect(productsField?.type).toBe(FieldType.SingleLineText);\n\n      // Cleanup\n      await permanentDeleteBase(testBaseId);\n    });\n\n    it('should handle lookup fields based on disconnected links', async () => {\n      // Create a separate base for this test\n      const testBase = await createBase({\n        name: 'lookup-copy-test-base',\n        spaceId: globalThis.testConfig.spaceId,\n      });\n      const testBaseId = testBase.data.id;\n\n      // Create tables\n      const ordersTable = await createTable(testBaseId, { name: 'Orders' });\n      const customersTable = await createTable(testBaseId, { name: 'Customers' });\n      const productsTable = await createTable(testBaseId, { name: 'Products' });\n\n      // Create link from Orders to Products\n      const linkToProducts = await createField(ordersTable.id, {\n        name: 'products',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: productsTable.id,\n        },\n      });\n\n      // Create a lookup field based on link to Products\n      const productsFields = await getFields(productsTable.id);\n      await createField(ordersTable.id, {\n        name: 'product lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: productsTable.id,\n          linkFieldId: linkToProducts.data.id,\n          lookupFieldId: productsFields.data[0].id,\n        } as ILookupOptionsRo,\n      });\n\n      // Get node IDs for Orders and Customers tables only (exclude Products)\n      const nodeList = await getBaseNodeList(testBaseId);\n      const ordersNode = nodeList.data.find((n) => n.resourceId === ordersTable.id);\n      const customersNode = nodeList.data.find((n) => n.resourceId === customersTable.id);\n\n      // Create a folder containing only Orders and Customers\n      const folder = await createBaseNode(testBaseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'lookup-test-folder',\n      });\n\n      await moveBaseNode(testBaseId, ordersNode!.id, { parentId: folder.data.id });\n      await moveBaseNode(testBaseId, customersNode!.id, { parentId: folder.data.id });\n\n      // Share only the folder\n      const share = await createBaseShare(testBaseId, {\n        nodeId: folder.data.id,\n        allowSave: true,\n      });\n\n      const copyRes = await copyBaseShare(share.data.shareId, {\n        spaceId: linkTargetSpaceId,\n        name: 'copied-lookup-test-base',\n        withRecords: true,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      copiedBaseId = copyRes.data.id;\n\n      // Verify lookup field is converted to SingleLineText (disconnected)\n      const tableList = await getTableList(copiedBaseId);\n      const copiedOrdersTable = tableList.data.find((t) => t.name === 'Orders')!;\n      const ordersFields = await getFields(copiedOrdersTable.id);\n      const lookupField = ordersFields.data.find((f) => f.name === 'product lookup');\n\n      expect(lookupField?.type).toBe(FieldType.SingleLineText);\n      expect(lookupField?.isLookup).toBeFalsy();\n\n      // Cleanup\n      await permanentDeleteBase(testBaseId);\n    });\n  });\n\n  describe('BaseShareOpenController - Copy Share to Existing Base', () => {\n    let sourceBaseId: string;\n    let targetSpaceId: string;\n    let targetBaseId: string;\n    let copiedBaseId: string | undefined;\n    let testShareId: string | undefined;\n\n    beforeAll(async () => {\n      const space = await createSpace({ name: 'copy-to-existing-base-space' });\n      targetSpaceId = space.data.id;\n\n      const srcBase = await createBase({\n        name: 'share-copy-source',\n        spaceId: globalThis.testConfig.spaceId,\n      });\n      sourceBaseId = srcBase.data.id;\n\n      await createTable(sourceBaseId, { name: 'SourceTable1' });\n      await createTable(sourceBaseId, { name: 'SourceTable2' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteBase(sourceBaseId);\n      await deleteSpace(targetSpaceId);\n    });\n\n    afterEach(async () => {\n      if (copiedBaseId) {\n        await permanentDeleteBase(copiedBaseId);\n        copiedBaseId = undefined;\n      }\n      if (targetBaseId) {\n        await permanentDeleteBase(targetBaseId).catch(() => undefined);\n      }\n      if (testShareId) {\n        await deleteBaseShare(sourceBaseId, testShareId).catch(() => undefined);\n        testShareId = undefined;\n      }\n    });\n\n    it('should copy share tables into an existing base', async () => {\n      const existingBase = await createBase({\n        name: 'existing-target-base',\n        spaceId: targetSpaceId,\n      });\n      targetBaseId = existingBase.data.id;\n\n      await createTable(targetBaseId, { name: 'ExistingTable' });\n\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const firstNode = nodeList.data[0];\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: firstNode.id,\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        withRecords: true,\n        baseId: targetBaseId,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      expect(copyRes.data.id).toEqual(targetBaseId);\n\n      const tableList = await getTableList(targetBaseId);\n      const tableNames = tableList.data.map((t) => t.name);\n      expect(tableNames).toContain('ExistingTable');\n      expect(tableList.data.length).toBeGreaterThan(1);\n    });\n\n    it('should preserve existing base name and icon when copying into it', async () => {\n      const existingBase = await createBase({\n        name: 'my-precious-base',\n        spaceId: targetSpaceId,\n      });\n      targetBaseId = existingBase.data.id;\n\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const firstNode = nodeList.data[0];\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: firstNode.id,\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        withRecords: false,\n        baseId: targetBaseId,\n      });\n\n      expect(copyRes.status).toEqual(200);\n      expect(copyRes.data.name).toEqual('my-precious-base');\n    });\n\n    it('should reject copy to non-existent base', async () => {\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const firstNode = nodeList.data[0];\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: firstNode.id,\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n      targetBaseId = '';\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          withRecords: false,\n          baseId: 'non-existent-base-id',\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should reject copy to base in different space', async () => {\n      const otherSpace = await createSpace({ name: 'other-space-for-mismatch' });\n      const existingBase = await createBase({\n        name: 'base-in-other-space',\n        spaceId: otherSpace.data.id,\n      });\n      targetBaseId = existingBase.data.id;\n\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const firstNode = nodeList.data[0];\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: firstNode.id,\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          withRecords: false,\n          baseId: targetBaseId,\n        })\n      );\n\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n\n      await permanentDeleteBase(targetBaseId);\n      targetBaseId = '';\n      await deleteSpace(otherSpace.data.id);\n    });\n\n    it('should reject copy when allowSave is false even with valid targetBaseId', async () => {\n      const existingBase = await createBase({\n        name: 'target-no-save',\n        spaceId: targetSpaceId,\n      });\n      targetBaseId = existingBase.data.id;\n\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const firstNode = nodeList.data[0];\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: firstNode.id,\n        allowSave: false,\n      });\n      testShareId = share.data.shareId;\n      await updateBaseShare(sourceBaseId, testShareId, { password: null });\n\n      const error = await getError(() =>\n        copyBaseShare(testShareId!, {\n          spaceId: targetSpaceId,\n          withRecords: false,\n          baseId: targetBaseId,\n        })\n      );\n\n      expect(error?.status).toEqual(403);\n    });\n\n    it('should handle copying tables with same name into existing base', async () => {\n      const existingBase = await createBase({\n        name: 'base-with-same-table-name',\n        spaceId: targetSpaceId,\n      });\n      targetBaseId = existingBase.data.id;\n\n      await createTable(targetBaseId, { name: 'SourceTable1' });\n\n      const nodeList = await getBaseNodeList(sourceBaseId);\n      const sourceTableNode = nodeList.data.find(\n        (node) =>\n          node.resourceType === BaseNodeResourceType.Table &&\n          node.resourceMeta?.name === 'SourceTable1'\n      );\n\n      if (!sourceTableNode) {\n        throw new Error('SourceTable1 node not found in base node list');\n      }\n\n      const share = await createBaseShare(sourceBaseId, {\n        nodeId: sourceTableNode.id,\n        allowSave: true,\n      });\n      testShareId = share.data.shareId;\n\n      const copyRes = await copyBaseShare(testShareId, {\n        spaceId: targetSpaceId,\n        withRecords: true,\n        baseId: targetBaseId,\n      });\n\n      expect(copyRes.status).toEqual(200);\n\n      const tableList = await getTableList(targetBaseId);\n      const tableNames = tableList.data.map((t) => t.name);\n      expect(tableNames).toContain('SourceTable1');\n      const renamedTable = tableNames.find(\n        (n) => n.startsWith('SourceTable1') && n !== 'SourceTable1'\n      );\n      expect(renamedTable).toBeDefined();\n    });\n  });\n\n  describe('BaseShareOpenController - Edge Cases', () => {\n    const createdShareIds: string[] = [];\n\n    afterEach(async () => {\n      for (const shareId of createdShareIds) {\n        await deleteBaseShare(baseId, shareId).catch(() => undefined);\n      }\n      createdShareIds.length = 0;\n    });\n\n    it('should reject copy after share is disabled', async () => {\n      // Create a share with allowSave enabled, then disable it, then try to copy\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId, allowSave: true });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      // Disable the share\n      await updateBaseShare(baseId, shareId, { enabled: false });\n\n      // Attempt to copy — should fail because the share is disabled\n      const error = await getError(() =>\n        copyBaseShare(shareId, {\n          spaceId: globalThis.testConfig.spaceId,\n          name: 'should-not-exist',\n          withRecords: false,\n        })\n      );\n      // Disabled share should not be found (404) or be forbidden (403)\n      expect(error?.status).toBeGreaterThanOrEqual(400);\n    });\n\n    it('should invalidate old shareId after refresh', async () => {\n      // Create share, refresh to get new shareId, then access with old shareId\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId });\n      const oldShareId = share.data.shareId;\n      createdShareIds.push(oldShareId);\n      // Clear any inherited password from previously soft-deleted share for this nodeId\n      await updateBaseShare(baseId, oldShareId, { password: null });\n\n      // Refresh to get a new shareId\n      const refreshed = await refreshBaseShare(baseId, oldShareId);\n      const newShareId = refreshed.data.shareId;\n      createdShareIds.push(newShareId);\n      expect(newShareId).not.toEqual(oldShareId);\n\n      // Old shareId should no longer work\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId: oldShareId }))\n      );\n      expect(error?.status).toEqual(404);\n\n      // New shareId should work\n      const res = await anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId: newShareId }));\n      expect(res.status).toEqual(200);\n    });\n\n    it('should invalidate old JWT cookie after shareId refresh', async () => {\n      const password = 'refreshtest123';\n      const share = await createBaseShare(baseId, { nodeId: rootTableNodeId, password });\n      const oldShareId = share.data.shareId;\n      createdShareIds.push(oldShareId);\n\n      // Authenticate with old shareId to get JWT cookie\n      const authRes = await anonymousUser.post(\n        urlBuilder(BASE_SHARE_AUTH, { shareId: oldShareId }),\n        {\n          password,\n        }\n      );\n      expect(authRes.status).toEqual(200);\n      const oldCookie = authRes.headers['set-cookie'];\n\n      // Refresh the shareId\n      const refreshed = await refreshBaseShare(baseId, oldShareId);\n      const newShareId = refreshed.data.shareId;\n      createdShareIds.push(newShareId);\n\n      // Old cookie + old shareId should fail (share not found)\n      const oldError = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId: oldShareId }), {\n          headers: { cookie: oldCookie },\n        })\n      );\n      expect(oldError?.status).toEqual(404);\n\n      // Old cookie + new shareId should fail (cookie is keyed by old shareId, JWT contains old shareId)\n      const mismatchError = await getError(() =>\n        anonymousUser.get(urlBuilder(GET_BASE_SHARE, { shareId: newShareId }), {\n          headers: { cookie: oldCookie },\n        })\n      );\n      // Should require re-authentication (401) since the new share still has password\n      expect(mismatchError?.status).toEqual(401);\n    });\n\n    it('should handle concurrent creation of share for same nodeId', async () => {\n      // Two concurrent requests to create a share for the same nodeId\n      // Due to unique constraint on nodeId, at most one should succeed via create;\n      // the other should either get a conflict error or be handled gracefully\n      const results = await Promise.allSettled([\n        createBaseShare(baseId, { nodeId: rootTableNodeId }),\n        createBaseShare(baseId, { nodeId: rootTableNodeId }),\n      ]);\n\n      const successes = results.filter((r) => r.status === 'fulfilled');\n      const failures = results.filter((r) => r.status === 'rejected');\n\n      // At least one should succeed\n      expect(successes.length).toBeGreaterThanOrEqual(1);\n      // If both \"succeed\" (second sees existing → conflict before DB), that's fine too\n      // The key invariant: only one share should exist for this nodeId\n      expect(successes.length + failures.length).toBe(2);\n\n      // Clean up all successfully created shares\n      for (const result of successes) {\n        const r = result as PromiseFulfilledResult<Awaited<ReturnType<typeof createBaseShare>>>;\n        createdShareIds.push(r.value.data.shareId);\n      }\n\n      // Verify only one share exists for this nodeId\n      const shareList = await listBaseShare(baseId);\n      const sharesForNode = shareList.data.filter((s) => s.nodeId === rootTableNodeId);\n      expect(sharesForNode.length).toBe(1);\n    });\n\n    it('should allow authenticated user to access share via share header', async () => {\n      // Logged-in user (not anonymous) accesses share endpoints via X-Tea-Base-Share header\n      const share = await createBaseShare(baseId, { nodeId: folderNodeId });\n      createdShareIds.push(share.data.shareId);\n      const shareId = share.data.shareId;\n\n      // Authenticated user should be able to get base node list via share header\n      const listRes = await anonymousUser.get(urlBuilder(GET_BASE_NODE_LIST, { baseId }), {\n        headers: {\n          [BASE_SHARE_ID_HEADER]: shareId,\n        },\n      });\n      expect(listRes.status).toEqual(200);\n      expect(Array.isArray(listRes.data)).toBe(true);\n\n      // Should only see nodes under the shared folder\n      const nodeIds = new Set(listRes.data.map((n: IBaseNodeVo) => n.id));\n      expect(nodeIds.has(folderNodeId)).toBe(true);\n      expect(nodeIds.has(childTableNodeId)).toBe(true);\n      // Root table is outside the shared folder, should not be visible\n      expect(nodeIds.has(rootTableNodeId)).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/base-sql-executor.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { BaseSqlExecutorService } from '../src/features/base-sql-executor/base-sql-executor.service';\nimport {\n  createBase,\n  createSpace,\n  createTable,\n  initApp,\n  permanentDeleteSpace,\n} from './utils/init-app';\n\ndescribe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n  'BaseSqlExecutorService',\n  () => {\n    let app: INestApplication;\n    let baseSqlExecutorService: BaseSqlExecutorService;\n    let prismaService: PrismaService;\n    let baseId: string;\n    let spaceId: string;\n    let tableDbName: string;\n    let baseId2: string;\n\n    beforeAll(async () => {\n      const appCtx = await initApp();\n      app = appCtx.app;\n      baseSqlExecutorService = app.get(BaseSqlExecutorService);\n      prismaService = app.get(PrismaService);\n      spaceId = await createSpace({\n        name: 'BaseSqlExecutorService test space',\n      }).then((space) => space.id);\n\n      baseId = await createBase({\n        name: 'BaseSqlExecutorService test base',\n        spaceId,\n      }).then((base) => base.id);\n      baseId2 = await createBase({\n        name: 'BaseSqlExecutorService test base2',\n        spaceId,\n      }).then((base) => base.id);\n\n      const table = await createTable(baseId, {\n        name: 'BaseSqlExecutorService test table',\n      });\n      tableDbName = `\"${table.dbTableName.split('.')[0]}\".\"${table.dbTableName.split('.')[1]}\"`;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteSpace(spaceId);\n      await app.close();\n    });\n\n    it('only read only role can execute sql', async () => {\n      const result = await baseSqlExecutorService.executeQuerySql(\n        baseId,\n        `select * from ${tableDbName}`\n      );\n      expect(result).toBeDefined();\n    });\n\n    it('read only role can not execute sql to throw error', async () => {\n      await expect(\n        baseSqlExecutorService['db']?.$queryRawUnsafe(`create table ${tableDbName} (id int)`)\n      ).rejects.toThrow('ERROR: permission denied for schema');\n    });\n\n    it('read only role can read base', async () => {\n      await expect(\n        baseSqlExecutorService.executeQuerySql(baseId2, `select * from ${tableDbName}`, {\n          projectionTableDbNames: [tableDbName.replaceAll('\"', '')],\n        })\n      ).rejects.toThrow('ERROR: permission denied for schema');\n    });\n\n    it('prisma service can execute sql', async () => {\n      await prismaService.$queryRawUnsafe(`create table test (id int)`);\n      await prismaService.$queryRawUnsafe(`drop table test`);\n    });\n  }\n);\n"
  },
  {
    "path": "apps/nestjs-backend/test/base.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ILinkFieldOptions } from '@teable/core';\nimport { FieldType, Relationship, Role } from '@teable/core';\nimport type {\n  ICreateBaseVo,\n  ICreateSpaceVo,\n  IUserMeVo,\n  ListBaseInvitationLinkVo,\n  UserCollaboratorItem,\n  IBaseErdEdge,\n} from '@teable/openapi';\nimport {\n  baseErdVoSchema,\n  CREATE_BASE,\n  CREATE_BASE_INVITATION_LINK,\n  CREATE_SPACE,\n  createBaseInvitationLink,\n  createBaseInvitationLinkVoSchema,\n  createTable,\n  DELETE_BASE,\n  DELETE_BASE_COLLABORATOR,\n  DELETE_SPACE,\n  DELETE_SPACE_COLLABORATOR,\n  deleteBaseCollaborator,\n  deleteBaseInvitationLink,\n  EMAIL_BASE_INVITATION,\n  EMAIL_SPACE_INVITATION,\n  emailBaseInvitation,\n  GET_BASE_ALL,\n  GET_BASE_LIST,\n  getBaseAll,\n  getBaseCollaboratorList,\n  getBaseErd,\n  getUserCollaborators,\n  listBaseCollaboratorUserVoSchema,\n  listBaseInvitationLink,\n  MOVE_BASE,\n  PrincipalType,\n  UPDATE_BASE_COLLABORATE,\n  UPDATE_BASE_INVITATION_LINK,\n  updateBaseCollaborator,\n  updateBaseInvitationLink,\n  urlBuilder,\n  USER_ME,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport {\n  createBase,\n  createField,\n  createSpace,\n  deleteSpace,\n  initApp,\n  permanentDeleteSpace,\n} from './utils/init-app';\n\ndescribe('OpenAPI BaseController (e2e)', () => {\n  let app: INestApplication;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Base Invitation and operator collaborators', () => {\n    const newUserEmail = 'newuser@example.com';\n    const newUser3Email = 'newuser2@example.com';\n\n    let userRequest: AxiosInstance;\n    let user3Request: AxiosInstance;\n    let spaceId: string;\n    let baseId: string;\n    beforeAll(async () => {\n      user3Request = await createNewUserAxios({\n        email: newUser3Email,\n        password: '12345678',\n      });\n      userRequest = await createNewUserAxios({\n        email: newUserEmail,\n        password: '12345678',\n      });\n      spaceId = (await userRequest.post<ICreateSpaceVo>(CREATE_SPACE, { name: 'new base' })).data\n        .id;\n    });\n    beforeEach(async () => {\n      const res = await userRequest.post<ICreateBaseVo>(CREATE_BASE, {\n        name: 'new base',\n        spaceId,\n      });\n      baseId = res.data.id;\n      await userRequest.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n        emails: [globalThis.testConfig.email],\n        role: Role.Creator,\n      });\n    });\n\n    afterEach(async () => {\n      await userRequest.delete<null>(\n        urlBuilder(DELETE_BASE, {\n          baseId,\n        })\n      );\n    });\n    afterAll(async () => {\n      await userRequest.delete<null>(\n        urlBuilder(DELETE_SPACE, {\n          spaceId,\n        })\n      );\n    });\n\n    it('/api/base/:baseId/invitation/link (POST)', async () => {\n      const res = await createBaseInvitationLink({\n        baseId,\n        createBaseInvitationLinkRo: { role: Role.Creator },\n      });\n      expect(createBaseInvitationLinkVoSchema.safeParse(res.data).success).toEqual(true);\n\n      const linkList = await listBaseInvitationLink(baseId);\n      expect(linkList.data).toHaveLength(1);\n    });\n\n    it('/api/base/{baseId}/invitation/link (POST) - Forbidden', async () => {\n      await userRequest.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n        emails: [newUser3Email],\n        role: Role.Editor,\n      });\n      const error = await getError(() =>\n        user3Request.post(urlBuilder(CREATE_BASE_INVITATION_LINK, { baseId }), {\n          role: Role.Creator,\n        })\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/base/:baseId/invitation/link/:invitationId (PATCH)', async () => {\n      const res = await createBaseInvitationLink({\n        baseId,\n        createBaseInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      const newBaseUpdate = await updateBaseInvitationLink({\n        baseId,\n        invitationId: newInvitationId,\n        updateBaseInvitationLinkRo: { role: Role.Editor },\n      });\n      expect(newBaseUpdate.data.role).toEqual(Role.Editor);\n    });\n\n    it('/api/base/:baseId/invitation/link/:invitationId (PATCH) - exceeds limit role', async () => {\n      const res = await createBaseInvitationLink({\n        baseId,\n        createBaseInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      await userRequest.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n        emails: [newUser3Email],\n        role: Role.Editor,\n      });\n      const error = await getError(() =>\n        user3Request.patch(\n          urlBuilder(UPDATE_BASE_INVITATION_LINK, { baseId, invitationId: newInvitationId }),\n          { role: Role.Creator }\n        )\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/base/:baseId/invitation/link (GET)', async () => {\n      const res = await getBaseCollaboratorList(baseId);\n      expect(res.data.collaborators).toHaveLength(2);\n    });\n\n    it('/api/base/:baseId/invitation/link (GET) - pagination', async () => {\n      const res = await getBaseCollaboratorList(baseId, { skip: 1, take: 1 });\n      expect(res.data.collaborators).toHaveLength(1);\n      expect(res.data.total).toBe(2);\n    });\n\n    it('/api/base/:baseId/invitation/link (GET) - search', async () => {\n      const res = await getBaseCollaboratorList(baseId, { search: 'newuser' });\n      expect(res.data.collaborators).toHaveLength(1);\n      expect((res.data.collaborators[0] as UserCollaboratorItem).email).toBe(newUserEmail);\n      expect(res.data.total).toBe(1);\n    });\n\n    it('/api/base/:baseId/invitation/link/:invitationId (DELETE)', async () => {\n      const res = await createBaseInvitationLink({\n        baseId,\n        createBaseInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      await deleteBaseInvitationLink({ baseId, invitationId: newInvitationId });\n\n      const list: ListBaseInvitationLinkVo = (await listBaseInvitationLink(baseId)).data;\n      expect(list.find((v) => v.invitationId === newInvitationId)).toBeUndefined();\n    });\n\n    it('/api/base/:baseId/invitation/email (POST)', async () => {\n      await emailBaseInvitation({\n        baseId,\n        emailBaseInvitationRo: { role: Role.Creator, emails: [newUser3Email] },\n      });\n\n      const { collaborators } = (await getBaseCollaboratorList(baseId)).data;\n\n      const newCollaboratorInfo = (collaborators as UserCollaboratorItem[]).find(\n        ({ email }) => email === newUser3Email\n      );\n\n      expect(newCollaboratorInfo).not.toBeUndefined();\n      expect(newCollaboratorInfo?.role).toEqual(Role.Creator);\n    });\n\n    it('/api/base/:baseId/invitation/email (POST) - exceeds limit role', async () => {\n      await userRequest.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n        emails: [newUser3Email],\n        role: Role.Editor,\n      });\n      const error = await getError(() =>\n        user3Request.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n          emails: [newUser3Email],\n          role: Role.Creator,\n        })\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/base/:baseId/invitation/email (POST) - not exist email', async () => {\n      await emailBaseInvitation({\n        baseId,\n        emailBaseInvitationRo: { emails: ['not.exist@email.com'], role: Role.Creator },\n      });\n      const { collaborators } = (await getBaseCollaboratorList(baseId)).data;\n      expect(collaborators).toHaveLength(3);\n    });\n\n    it('/api/base/:baseId/invitation/email (POST) - user in space', async () => {\n      const error = await getError(() =>\n        emailBaseInvitation({\n          baseId,\n          emailBaseInvitationRo: { emails: [globalThis.testConfig.email], role: Role.Creator },\n        })\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    describe('operator collaborators', () => {\n      let newUser3Id: string;\n      beforeEach(async () => {\n        await userRequest.post(urlBuilder(EMAIL_BASE_INVITATION, { baseId }), {\n          emails: [newUser3Email],\n          role: Role.Editor,\n        });\n        const res = await user3Request.get<IUserMeVo>(USER_ME);\n        newUser3Id = res.data.id;\n      });\n\n      it('/api/base/:baseId/collaborator/users (GET)', async () => {\n        const res = await getUserCollaborators(baseId);\n        expect(res.data.users).toHaveLength(3);\n        expect(res.data.total).toBe(3);\n        expect(listBaseCollaboratorUserVoSchema.strict().safeParse(res.data).success).toEqual(true);\n      });\n\n      it('/api/base/:baseId/collaborator/users (GET) - pagination', async () => {\n        const res = await getUserCollaborators(baseId, { skip: 1, take: 1 });\n        expect(res.data.users).toHaveLength(1);\n        expect(res.data.total).toBe(3);\n      });\n\n      it('/api/base/:baseId/collaborator/users (GET) - search', async () => {\n        const res = await getUserCollaborators(baseId, { search: 'newuser' });\n        expect(res.data.users).toHaveLength(2);\n        expect(res.data.total).toBe(2);\n      });\n\n      it('/api/base/:baseId/collaborators (PATCH)', async () => {\n        const res = await updateBaseCollaborator({\n          baseId,\n          updateBaseCollaborateRo: {\n            role: Role.Creator,\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        expect(res.status).toBe(200);\n      });\n\n      it('/api/base/:baseId/collaborators (PATCH) - exceeds limit role', async () => {\n        const error = await getError(() =>\n          user3Request.patch<void>(\n            urlBuilder(UPDATE_BASE_COLLABORATE, {\n              baseId,\n            }),\n            {\n              role: Role.Viewer,\n              principalId: globalThis.testConfig.userId,\n              principalType: PrincipalType.User,\n            }\n          )\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/base/:baseId/collaborators (PATCH) - exceeds limit role - system user', async () => {\n        await updateBaseCollaborator({\n          baseId: baseId,\n          updateBaseCollaborateRo: {\n            role: Role.Editor,\n            principalId: globalThis.testConfig.userId,\n            principalType: PrincipalType.User,\n          },\n        });\n        const error = await getError(() =>\n          updateBaseCollaborator({\n            baseId: baseId,\n            updateBaseCollaborateRo: {\n              role: Role.Creator,\n              principalId: globalThis.testConfig.userId,\n              principalType: PrincipalType.User,\n            },\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/base/:baseId/collaborators (PATCH) - self ', async () => {\n        const res = await updateBaseCollaborator({\n          baseId: baseId,\n          updateBaseCollaborateRo: {\n            role: Role.Editor,\n            principalId: globalThis.testConfig.userId,\n            principalType: PrincipalType.User,\n          },\n        });\n        expect(res?.status).toBe(200);\n      });\n\n      it('/api/base/:baseId/collaborators (PATCH) - allow update role equal to self', async () => {\n        await updateBaseCollaborator({\n          baseId: baseId,\n          updateBaseCollaborateRo: {\n            role: Role.Editor,\n            principalId: globalThis.testConfig.userId,\n            principalType: PrincipalType.User,\n          },\n        });\n        const res = await user3Request.patch<void>(\n          urlBuilder(UPDATE_BASE_COLLABORATE, {\n            baseId,\n          }),\n          {\n            role: Role.Viewer,\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          }\n        );\n        expect(res?.status).toBe(200);\n      });\n\n      it('/api/base/:baseId/collaborators (DELETE)', async () => {\n        const res = await deleteBaseCollaborator({\n          baseId,\n          deleteBaseCollaboratorRo: {\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        expect(res.status).toBe(200);\n        const collList = await getBaseCollaboratorList(baseId);\n        expect(collList.data.collaborators).toHaveLength(2);\n      });\n\n      it('/api/base/:baseId/collaborators (DELETE) - exceeds limit role', async () => {\n        await updateBaseCollaborator({\n          baseId,\n          updateBaseCollaborateRo: {\n            role: Role.Creator,\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        const error = await getError(() =>\n          deleteBaseCollaborator({\n            baseId,\n            deleteBaseCollaboratorRo: {\n              principalId: newUser3Id,\n              principalType: PrincipalType.User,\n            },\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/base/:baseId/collaborators (DELETE) - self', async () => {\n        await deleteBaseCollaborator({\n          baseId: baseId,\n          deleteBaseCollaboratorRo: {\n            principalId: globalThis.testConfig.userId,\n            principalType: PrincipalType.User,\n          },\n        });\n        const error = await getError(() => getBaseCollaboratorList(baseId));\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/base/:baseId/collaborators (DELETE) - space user delete base user', async () => {\n        const res = await userRequest.delete(urlBuilder(DELETE_BASE_COLLABORATOR, { baseId }), {\n          params: { principalId: newUser3Id, principalType: PrincipalType.User },\n        });\n        expect(res.status).toBe(200);\n      });\n\n      it('/api/space/:spaceId/collaborators (DELETE) - space user delete base user', async () => {\n        const res = await userRequest.delete(urlBuilder(DELETE_BASE_COLLABORATOR, { baseId }), {\n          params: { principalId: newUser3Id, principalType: PrincipalType.User },\n        });\n        expect(res.status).toBe(200);\n      });\n\n      it('/api/base/:baseId/move (PUT)', async () => {\n        const user1SpaceId = (\n          await userRequest.post<ICreateSpaceVo>(CREATE_SPACE, { name: 'new base' })\n        ).data.id;\n\n        const user1SpaceId2 = (\n          await userRequest.post<ICreateSpaceVo>(CREATE_SPACE, { name: 'new base2' })\n        ).data.id;\n\n        const spaceBaseList1 = (\n          await userRequest.get(urlBuilder(GET_BASE_LIST, { spaceId: user1SpaceId }))\n        ).data;\n\n        const spaceBaseList2 = (\n          await userRequest.get(urlBuilder(GET_BASE_LIST, { spaceId: user1SpaceId2 }))\n        ).data;\n\n        expect(spaceBaseList1.length).toBe(0);\n        expect(spaceBaseList2.length).toBe(0);\n\n        const newBase1 = (\n          await userRequest.post(urlBuilder(CREATE_BASE), {\n            name: 'base1',\n            spaceId: user1SpaceId,\n          })\n        ).data;\n\n        // move base\n        await userRequest.put(\n          urlBuilder(MOVE_BASE, {\n            baseId: newBase1.id,\n          }),\n          {\n            spaceId: user1SpaceId2,\n          }\n        );\n\n        const spaceBaseList1AfterMove = (\n          await userRequest.get(urlBuilder(GET_BASE_LIST, { spaceId: user1SpaceId2 }))\n        ).data;\n\n        expect(spaceBaseList1AfterMove.length).toBe(1);\n        expect(spaceBaseList1AfterMove[0].id).toBe(newBase1.id);\n      });\n    });\n  });\n\n  it('/api/base/access/all (GET)', async () => {\n    const spaceId1 = await createSpace({\n      name: 'new space test base access all',\n    }).then((res) => res.id);\n    const baseId1 = await createBase({\n      name: 'new base test base access all',\n      spaceId: spaceId1,\n    }).then((res) => res.id);\n    const spaceId2 = await createSpace({\n      name: 'new space test base access all',\n    }).then((res) => res.id);\n    const baseId2 = await createBase({\n      name: 'new base test base access all',\n      spaceId: spaceId2,\n    }).then((res) => res.id);\n\n    await deleteSpace(spaceId1);\n\n    const res = await getBaseAll();\n\n    await permanentDeleteSpace(spaceId1);\n    await permanentDeleteSpace(spaceId2);\n\n    expect(res.data.find((v) => v.id === baseId1)).toBeUndefined();\n    expect(res.data.find((v) => v.id === baseId2)).toBeDefined();\n  });\n\n  describe('Base owner display after member removal', () => {\n    const userAEmail = 'userA-t1606@example.com';\n    const userBEmail = 'userB-t1606@example.com';\n    let userARequest: AxiosInstance;\n    let userBRequest: AxiosInstance;\n    let userAId: string;\n    let userBId: string;\n    let spaceId: string;\n    let baseId: string;\n\n    beforeAll(async () => {\n      // Create user A (space owner) and user B\n      userARequest = await createNewUserAxios({\n        email: userAEmail,\n        password: '12345678',\n      });\n      userBRequest = await createNewUserAxios({\n        email: userBEmail,\n        password: '12345678',\n      });\n\n      // Get user A's ID (space owner)\n      const userAInfo = await userARequest.get<IUserMeVo>(USER_ME);\n      userAId = userAInfo.data.id;\n\n      // Get user B's ID\n      const userBInfo = await userBRequest.get<IUserMeVo>(USER_ME);\n      userBId = userBInfo.data.id;\n\n      // User A creates a space\n      spaceId = (\n        await userARequest.post<ICreateSpaceVo>(CREATE_SPACE, { name: 'T1606 test space' })\n      ).data.id;\n\n      // User A invites user B to the space\n      await userARequest.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId }), {\n        emails: [userBEmail],\n        role: Role.Creator,\n      });\n\n      // User B creates a base in the space\n      baseId = (\n        await userBRequest.post<ICreateBaseVo>(CREATE_BASE, {\n          name: 'T1606 test base',\n          spaceId,\n        })\n      ).data.id;\n    });\n\n    afterAll(async () => {\n      // Clean up\n      await userARequest.delete(urlBuilder(DELETE_BASE, { baseId }));\n      await userARequest.delete(urlBuilder(DELETE_SPACE, { spaceId }));\n    });\n\n    it('should fallback to space owner when creator is removed from space', async () => {\n      // Verify user B is the creator before removal (via getBaseAll)\n      const beforeRemoval = await userARequest.get(GET_BASE_ALL);\n      const baseBefore = beforeRemoval.data.find((b: { id: string }) => b.id === baseId);\n      expect(baseBefore).toBeDefined();\n      expect(baseBefore.createdUser).toBeDefined();\n      expect(baseBefore.createdUser.id).toBe(userBId);\n\n      // User A removes user B from the space\n      await userARequest.delete(urlBuilder(DELETE_SPACE_COLLABORATOR, { spaceId }), {\n        params: { principalId: userBId, principalType: PrincipalType.User },\n      });\n\n      // Verify createdUser is now the space owner (user A) after removal\n      const afterRemoval = await userARequest.get(GET_BASE_ALL);\n      const baseAfter = afterRemoval.data.find((b: { id: string }) => b.id === baseId);\n      expect(baseAfter).toBeDefined();\n      // The createdUser should fallback to space owner (user A) since user B is no longer in the space\n      expect(baseAfter.createdUser).toBeDefined();\n      expect(baseAfter.createdUser.id).toBe(userAId);\n    });\n  });\n\n  describe('Base ERD', () => {\n    let spaceId1: string;\n\n    beforeEach(async () => {\n      spaceId1 = await createSpace({\n        name: 'new space test base erd',\n      }).then((res) => res.id);\n    });\n    afterEach(async () => {\n      await permanentDeleteSpace(spaceId1);\n    });\n\n    const getRelationReference = (edges: IBaseErdEdge[]) => {\n      return edges\n        .filter((edge) => Boolean(edge.relationship))\n        .map((edge) => {\n          const { source, target } = edge;\n          return `${source.tableId}.${source.fieldId}-${target.tableId}.${target.fieldId}`;\n        })\n        .sort();\n    };\n\n    const getTypeMap = (edges: IBaseErdEdge[]) => {\n      return edges\n        .filter((edge) => !edge.relationship)\n        .reduce(\n          (acc, edge) => {\n            acc[edge.type] = (acc[edge.type] || 0) + 1;\n            return acc;\n          },\n          {} as Record<FieldType | 'lookup', number>\n        );\n    };\n\n    it('/api/base/:baseId/erd (GET) - relationship', async () => {\n      const baseId = await createBase({\n        spaceId: spaceId1,\n      }).then((res) => res.id);\n      const table1 = await createTable(baseId).then((res) => res.data);\n      const table2 = await createTable(baseId).then((res) => res.data);\n\n      await createField(table1.id, {\n        name: 'new link field1',\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          foreignTableId: table2.id,\n          relationship: Relationship.OneOne,\n        },\n      });\n\n      await createField(table1.id, {\n        name: 'new link field2',\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      await createField(table1.id, {\n        name: 'new link field3',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyOne,\n        },\n      });\n\n      await createField(table1.id, {\n        name: 'new link field4',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyMany,\n        },\n      });\n\n      const data = await getBaseErd(baseId).then((res) => res.data);\n      expect(baseErdVoSchema.safeParse(data).success).toEqual(true);\n      expect(data.baseId).toEqual(baseId);\n      expect(getRelationReference(data.edges).length).toEqual(4);\n    });\n\n    it('/api/base/:baseId/erd (GET) - reference(formula, lookup, rollup, link)', async () => {\n      const baseId = await createBase({\n        spaceId: spaceId1,\n      }).then((res) => res.id);\n      const table1 = await createTable(baseId).then((res) => res.data);\n      const table2 = await createTable(baseId).then((res) => res.data);\n\n      const textField = table1.fields[0];\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.OneOne,\n        },\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textField.id}}`,\n        },\n      });\n\n      await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupField.id}}`,\n        },\n      });\n\n      const data = await getBaseErd(baseId).then((res) => res.data);\n      expect(baseErdVoSchema.safeParse(data).success).toEqual(true);\n      expect(data.baseId).toEqual(baseId);\n      expect(getRelationReference(data.edges).length).toEqual(1);\n      const typeMap = getTypeMap(data.edges);\n      expect(typeMap).toEqual({\n        formula: 2,\n        link: (linkField.options as ILinkFieldOptions).isOneWay ? 1 : 2,\n        lookup: 1,\n        rollup: 1,\n      });\n    });\n\n    it('/api/base/:baseId/erd (GET) - cross base', async () => {\n      const baseId1 = await createBase({\n        spaceId: spaceId1,\n      }).then((res) => res.id);\n      const base1Table1 = await createTable(baseId1).then((res) => res.data);\n\n      const baseId2 = await createBase({\n        spaceId: spaceId1,\n      }).then((res) => res.id);\n      const base2Table1 = await createTable(baseId2).then((res) => res.data);\n\n      await createField(base1Table1.id, {\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          foreignTableId: base2Table1.id,\n          relationship: Relationship.OneOne,\n        },\n      });\n\n      const baseId3 = await createBase({\n        spaceId: spaceId1,\n      }).then((res) => res.id);\n      const base3Table1 = await createTable(baseId3).then((res) => res.data);\n\n      await createField(base2Table1.id, {\n        type: FieldType.Link,\n        options: {\n          baseId: baseId3,\n          foreignTableId: base3Table1.id,\n          relationship: Relationship.OneOne,\n        },\n      });\n\n      const base1Erd = await getBaseErd(baseId1).then((res) => res.data);\n      expect(baseErdVoSchema.safeParse(base1Erd).success).toEqual(true);\n      expect(base1Erd.baseId).toEqual(baseId1);\n      expect(getRelationReference(base1Erd.edges).length).toEqual(1);\n\n      const base2Erd = await getBaseErd(baseId2).then((res) => res.data);\n      expect(baseErdVoSchema.safeParse(base2Erd).success).toEqual(true);\n      expect(base2Erd.baseId).toEqual(baseId2);\n      expect(getRelationReference(base2Erd.edges).length).toEqual(2);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/basic-link.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILinkFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getRecords,\n  getRecord,\n  initApp,\n  updateRecordByApi,\n  getField,\n  convertField,\n} from './utils/init-app';\n\ndescribe('Basic Link Field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const expectHasOrderColumn = async (fieldId: string, expected: boolean) => {\n    const prisma = app.get(PrismaService);\n    const fieldRaw = await prisma.field.findUniqueOrThrow({\n      where: { id: fieldId },\n      select: { meta: true },\n    });\n    const meta = fieldRaw.meta ? (JSON.parse(fieldRaw.meta) as { hasOrderColumn?: boolean }) : null;\n    expect(meta?.hasOrderColumn ?? false).toBe(expected);\n  };\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('OneMany relationship with lookup and rollup', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let rollupField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (parent table)\n      const textFieldRo: IFieldRo = {\n        name: 'Title',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Score',\n        type: FieldType.Number,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Title: 'Project A', Score: 100 } },\n          { fields: { Title: 'Project B', Score: 200 } },\n        ],\n      });\n\n      // Create table2 (child table)\n      table2 = await createTable(baseId, {\n        name: 'Tasks',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Title: 'Task 1', Score: 10 } },\n          { fields: { Title: 'Task 2', Score: 20 } },\n          { fields: { Title: 'Task 3', Score: 30 } },\n        ],\n      });\n\n      // Create OneMany link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField = await createField(table1.id, linkFieldRo);\n\n      // Create lookup field to get task titles\n      const lookupFieldRo: IFieldRo = {\n        name: 'Task Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id, // Title field\n          linkFieldId: linkField.id,\n        },\n      };\n\n      lookupField = await createField(table1.id, lookupFieldRo);\n\n      // Create rollup field to sum task scores\n      const rollupFieldRo: IFieldRo = {\n        name: 'Total Task Score',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id, // Score field\n          linkFieldId: linkField.id,\n        },\n      };\n\n      rollupField = await createField(table1.id, rollupFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create OneMany relationship and verify lookup/rollup values', async () => {\n      // Link tasks to projects\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      // Get records and verify link, lookup, and rollup values\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(2);\n\n      // Project A should have 2 linked tasks\n      const projectA = records.records.find((r) => r.name === 'Project A');\n      expect(projectA?.fields[linkField.id]).toHaveLength(2);\n      expect(projectA?.fields[linkField.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ title: 'Task 1' }),\n          expect.objectContaining({ title: 'Task 2' }),\n        ])\n      );\n\n      // Lookup should return task titles\n      expect(projectA?.fields[lookupField.id]).toEqual(['Task 1', 'Task 2']);\n\n      // Rollup should sum task scores (10 + 20 = 30)\n      expect(projectA?.fields[rollupField.id]).toBe(30);\n\n      // Project B should have 1 linked task\n      const projectB = records.records.find((r) => r.name === 'Project B');\n      expect(projectB?.fields[linkField.id]).toHaveLength(1);\n      expect(projectB?.fields[linkField.id]).toEqual([\n        expect.objectContaining({ title: 'Task 3' }),\n      ]);\n\n      // Lookup should return task title\n      expect(projectB?.fields[lookupField.id]).toEqual(['Task 3']);\n\n      // Rollup should return task score (30)\n      expect(projectB?.fields[rollupField.id]).toBe(30);\n    });\n\n    it('should handle empty links for OneMany (no linked tasks)', async () => {\n      // 初始状态未建立任何链接\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const projectA = records.records.find((r) => r.name === 'Project A');\n      const projectB = records.records.find((r) => r.name === 'Project B');\n\n      expect(projectA?.fields[linkField.id]).toBeUndefined();\n      expect(projectA?.fields[lookupField.id]).toBeUndefined();\n      expect(projectA?.fields[rollupField.id]).toBe(0);\n\n      expect(projectB?.fields[linkField.id]).toBeUndefined();\n      expect(projectB?.fields[lookupField.id]).toBeUndefined();\n      expect(projectB?.fields[rollupField.id]).toBe(0);\n    });\n  });\n\n  describe('ManyOne relationship with lookup and rollup', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let rollupField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (child table)\n      const textFieldRo: IFieldRo = {\n        name: 'Title',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Hours',\n        type: FieldType.Number,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Tasks',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Title: 'Task 1', Hours: 5 } },\n          { fields: { Title: 'Task 2', Hours: 8 } },\n          { fields: { Title: 'Task 3', Hours: 3 } },\n        ],\n      });\n\n      // Create table2 (parent table)\n      table2 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Title: 'Project A', Hours: 100 } },\n          { fields: { Title: 'Project B', Hours: 200 } },\n        ],\n      });\n\n      // Create ManyOne link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Project',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField = await createField(table1.id, linkFieldRo);\n\n      // Create lookup field to get project title\n      const lookupFieldRo: IFieldRo = {\n        name: 'Project Title',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id, // Title field\n          linkFieldId: linkField.id,\n        },\n      };\n\n      lookupField = await createField(table1.id, lookupFieldRo);\n\n      // Create rollup field to get project hours\n      const rollupFieldRo: IFieldRo = {\n        name: 'Project Hours',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id, // Hours field\n          linkFieldId: linkField.id,\n        },\n      };\n\n      rollupField = await createField(table1.id, rollupFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create ManyOne relationship and verify lookup/rollup values', async () => {\n      // Link tasks to projects\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      await updateRecordByApi(table1.id, table1.records[2].id, linkField.id, {\n        id: table2.records[1].id,\n      });\n\n      // Get records and verify link, lookup, and rollup values\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(3);\n\n      // Task 1 should link to Project A\n      const task1 = records.records.find((r) => r.name === 'Task 1');\n      expect(task1?.fields[linkField.id]).toEqual(expect.objectContaining({ title: 'Project A' }));\n      expect(task1?.fields[lookupField.id]).toBe('Project A');\n\n      expect(task1?.fields[rollupField.id]).toBe(100);\n\n      // Task 2 should link to Project A\n      const task2 = records.records.find((r) => r.name === 'Task 2');\n      expect(task2?.fields[linkField.id]).toEqual(expect.objectContaining({ title: 'Project A' }));\n      expect(task2?.fields[lookupField.id]).toBe('Project A');\n      expect(task2?.fields[rollupField.id]).toBe(100);\n\n      // Task 3 should link to Project B\n      const task3 = records.records.find((r) => r.name === 'Task 3');\n      expect(task3?.fields[linkField.id]).toEqual(expect.objectContaining({ title: 'Project B' }));\n      expect(task3?.fields[lookupField.id]).toBe('Project B');\n      expect(task3?.fields[rollupField.id]).toBe(200);\n    });\n\n    it('should handle null link for ManyOne (no parent)', async () => {\n      // 不建立链接，直接读取（使用 beforeEach 初始数据）\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const task1 = records.records.find((r) => r.name === 'Task 1');\n      expect(task1?.fields[linkField.id]).toBeUndefined();\n      expect(task1?.fields[lookupField.id]).toBeUndefined();\n      expect(task1?.fields[rollupField.id]).toBe(0);\n    });\n  });\n\n  describe('Link formulas comparing text to lookup values', () => {\n    let orderTable: ITableFullVo | undefined;\n    let detailTable: ITableFullVo | undefined;\n\n    afterEach(async () => {\n      if (orderTable) {\n        await permanentDeleteTable(baseId, orderTable.id);\n        orderTable = undefined;\n      }\n      if (detailTable) {\n        await permanentDeleteTable(baseId, detailTable.id);\n        detailTable = undefined;\n      }\n    });\n\n    it('should update records without errors when formula compares text field to lookup result', async () => {\n      orderTable = await createTable(baseId, {\n        name: 'orders',\n        fields: [\n          {\n            name: 'Order Number',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { 'Order Number': 'ORD-001' } },\n          { fields: { 'Order Number': 'ORD-002' } },\n        ],\n      });\n\n      detailTable = await createTable(baseId, {\n        name: 'order details',\n        fields: [\n          {\n            name: 'External Number',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { 'External Number': 'ORD-001' } },\n          { fields: { 'External Number': 'ORD-002' } },\n        ],\n      });\n\n      const orderNumberField = orderTable.fields.find((f) => f.name === 'Order Number')!;\n      const externalNumberField = detailTable.fields.find((f) => f.name === 'External Number')!;\n\n      const linkField = await createField(orderTable.id, {\n        name: 'Detail Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: detailTable.id,\n        },\n      });\n\n      const lookupField = await createField(orderTable.id, {\n        name: 'External Number Lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: detailTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: externalNumberField.id,\n        },\n      });\n\n      const formulaField = await createField(orderTable.id, {\n        name: 'Match Flag',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${orderNumberField.id}} = {${lookupField.id}}, \"match\", \"not-match\")`,\n        },\n      });\n\n      await updateRecordByApi(orderTable.id, orderTable.records[0].id, linkField.id, {\n        id: detailTable.records[0].id,\n      });\n\n      const linkedRecord = await getRecord(orderTable.id, orderTable.records[0].id);\n      expect(linkedRecord.fields[formulaField.id]).toBe('match');\n\n      await updateRecordByApi(\n        orderTable.id,\n        orderTable.records[0].id,\n        orderNumberField.id,\n        'ORD-001-UPDATED'\n      );\n\n      const updatedRecord = await getRecord(orderTable.id, orderTable.records[0].id);\n      expect(updatedRecord.fields[formulaField.id]).toBe('not-match');\n    });\n  });\n\n  describe('Lookup formula text functions', () => {\n    let projectTable: ITableFullVo;\n    let taskTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let formulaField: IFieldVo;\n\n    beforeEach(async () => {\n      const taskNameField: IFieldRo = {\n        name: 'Task',\n        type: FieldType.SingleLineText,\n      };\n      const taskDateField: IFieldRo = {\n        name: 'Due Date',\n        type: FieldType.Date,\n      };\n\n      taskTable = await createTable(baseId, {\n        name: 'Formula Tasks',\n        fields: [taskNameField, taskDateField],\n        records: [\n          {\n            fields: {\n              Task: 'Task Alpha',\n              'Due Date': '2024-10-31',\n            },\n          },\n        ],\n      });\n\n      const projectNameField: IFieldRo = {\n        name: 'Project',\n        type: FieldType.SingleLineText,\n      };\n\n      projectTable = await createTable(baseId, {\n        name: 'Formula Projects',\n        fields: [projectNameField],\n        records: [\n          {\n            fields: {\n              Project: 'Project One',\n            },\n          },\n        ],\n      });\n\n      linkField = await createField(projectTable.id, {\n        name: 'Linked Tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: taskTable.id,\n        },\n      });\n\n      const dueDateFieldId = taskTable.fields.find((f) => f.name === 'Due Date')!.id;\n\n      lookupField = await createField(projectTable.id, {\n        name: 'Task Due Dates',\n        type: FieldType.Date,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: taskTable.id,\n          lookupFieldId: dueDateFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      formulaField = await createField(projectTable.id, {\n        name: 'Due Year',\n        type: FieldType.Formula,\n        options: {\n          expression: `LEFT({${lookupField.id}}, 4)`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, projectTable.id);\n      await permanentDeleteTable(baseId, taskTable.id);\n    });\n\n    it('should treat lookup arrays as comma-separated strings for text formulas', async () => {\n      await updateRecordByApi(projectTable.id, projectTable.records[0].id, linkField.id, [\n        { id: taskTable.records[0].id },\n      ]);\n\n      const record = await getRecord(projectTable.id, projectTable.records[0].id);\n      const lookupValue = record.fields[lookupField.id] as string[] | undefined;\n\n      expect(Array.isArray(lookupValue)).toBe(true);\n      expect(lookupValue).toHaveLength(1);\n      expect(lookupValue?.[0]).toMatch(/^2024-10-/);\n      expect(record.fields[formulaField.id]).toBe('2024');\n    });\n  });\n\n  describe('ManyMany relationship with lookup and rollup', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n    let linkField2: IFieldVo;\n    let lookupField1: IFieldVo;\n    let rollupField1: IFieldVo;\n    let lookupField2: IFieldVo;\n    let rollupField2: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Students)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Grade',\n        type: FieldType.Number,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Students',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Name: 'Alice', Grade: 95 } },\n\n          { fields: { Name: 'Bob', Grade: 87 } },\n          { fields: { Name: 'Charlie', Grade: 92 } },\n        ],\n      });\n\n      // Create table2 (Courses)\n      table2 = await createTable(baseId, {\n        name: 'Courses',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Name: 'Math', Grade: 4 } },\n          { fields: { Name: 'Science', Grade: 3 } },\n          { fields: { Name: 'History', Grade: 2 } },\n        ],\n      });\n\n      // Create ManyMany link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Courses',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Get the symmetric field in table2\n      const linkOptions = linkField1.options as any;\n      linkField2 = await getField(table2.id, linkOptions.symmetricFieldId);\n\n      // Create lookup field in table1 to get course names\n      const lookupFieldRo1: IFieldRo = {\n        name: 'Course Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id, // Name field\n          linkFieldId: linkField1.id,\n        },\n      };\n\n      lookupField1 = await createField(table1.id, lookupFieldRo1);\n\n      // Create rollup field in table1 to sum course credits\n      const rollupFieldRo1: IFieldRo = {\n        name: 'Total Credits',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id, // Grade field (used as credits)\n          linkFieldId: linkField1.id,\n        },\n      };\n\n      rollupField1 = await createField(table1.id, rollupFieldRo1);\n\n      // Create lookup field in table2 to get student names\n      const lookupFieldRo2: IFieldRo = {\n        name: 'Student Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id, // Name field\n          linkFieldId: linkField2.id,\n        },\n      };\n\n      lookupField2 = await createField(table2.id, lookupFieldRo2);\n\n      // Create rollup field in table2 to count student grades\n      const rollupFieldRo2: IFieldRo = {\n        name: 'Student Count',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'count({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[1].id, // Grade field\n          linkFieldId: linkField2.id,\n        },\n      };\n\n      rollupField2 = await createField(table2.id, rollupFieldRo2);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create ManyMany relationship and verify lookup/rollup values', async () => {\n      // Link students to courses\n      // Alice takes Math and Science\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Bob takes Math and History\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[2].id },\n      ]);\n\n      // Charlie takes Science\n      await updateRecordByApi(table1.id, table1.records[2].id, linkField1.id, [\n        { id: table2.records[1].id },\n      ]);\n\n      // Get student records and verify\n      const studentRecords = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(studentRecords.records).toHaveLength(3);\n\n      // Alice should have Math and Science\n      const alice = studentRecords.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toHaveLength(2);\n      expect(alice?.fields[lookupField1.id]).toEqual(expect.arrayContaining(['Math', 'Science']));\n      expect(alice?.fields[rollupField1.id]).toBe(7); // 4 + 3 credits\n\n      // Bob should have Math and History\n      const bob = studentRecords.records.find((r) => r.name === 'Bob');\n      expect(bob?.fields[linkField1.id]).toHaveLength(2);\n      expect(bob?.fields[lookupField1.id]).toEqual(expect.arrayContaining(['Math', 'History']));\n      expect(bob?.fields[rollupField1.id]).toBe(6); // 4 + 2 credits\n\n      // Charlie should have Science\n      const charlie = studentRecords.records.find((r) => r.name === 'Charlie');\n      expect(charlie?.fields[linkField1.id]).toHaveLength(1);\n      expect(charlie?.fields[lookupField1.id]).toEqual(['Science']);\n\n      expect(charlie?.fields[rollupField1.id]).toBe(3); // 3 credits\n\n      // Get course records and verify reverse relationships\n      const courseRecords = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(courseRecords.records).toHaveLength(3);\n\n      // Math should have Alice and Bob\n      const math = courseRecords.records.find((r) => r.name === 'Math');\n      expect(math?.fields[linkField2.id]).toHaveLength(2);\n      expect(math?.fields[lookupField2.id]).toEqual(expect.arrayContaining(['Alice', 'Bob']));\n      expect(math?.fields[rollupField2.id]).toBe(2); // Count of students\n\n      // Science should have Alice and Charlie\n      const science = courseRecords.records.find((r) => r.name === 'Science');\n      expect(science?.fields[linkField2.id]).toHaveLength(2);\n      expect(science?.fields[lookupField2.id]).toEqual(\n        expect.arrayContaining(['Alice', 'Charlie'])\n      );\n      expect(science?.fields[rollupField2.id]).toBe(2); // Count of students\n\n      // History should have Bob\n      const history = courseRecords.records.find((r) => r.name === 'History');\n      expect(history?.fields[linkField2.id]).toHaveLength(1);\n      expect(history?.fields[lookupField2.id]).toEqual(['Bob']);\n      expect(history?.fields[rollupField2.id]).toBe(1); // Count of students\n    });\n  });\n\n  describe('OneOne TwoWay relationship - MAIN TEST CASE', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n    let linkField2: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Users)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Users',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Alice' } }, { fields: { Name: 'Bob' } }],\n      });\n\n      // Create table2 (Profiles)\n      table2 = await createTable(baseId, {\n        name: 'Profiles',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Profile A' } }, { fields: { Name: 'Profile B' } }],\n      });\n\n      // Create OneOne TwoWay link field from table1 to table2\n      // NOTE: Not setting isOneWay: true, so this creates a bidirectional relationship\n      const linkFieldRo: IFieldRo = {\n        name: 'Profile',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          // isOneWay: false (default) - creates symmetric field\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Get the symmetric field in table2\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeDefined();\n      linkField2 = await getField(table2.id, linkOptions.symmetricFieldId);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create OneOne TwoWay relationship and verify bidirectional linking', async () => {\n      // Link Alice to Profile A\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, {\n        id: table2.records[0].id,\n      });\n\n      // Link Bob to Profile B\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, {\n        id: table2.records[1].id,\n      });\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(table1Records.records).toHaveLength(2);\n\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toEqual(expect.objectContaining({ title: 'Profile A' }));\n\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      expect(bob?.fields[linkField1.id]).toEqual(expect.objectContaining({ title: 'Profile B' }));\n\n      // CRITICAL TEST: Verify table2 records show correct symmetric links\n      // This is where the bug should manifest - table2 symmetric field data should be empty\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(table2Records.records).toHaveLength(2);\n\n      // Profile A should link back to Alice\n      const profileA = table2Records.records.find((r) => r.id === table2.records[0].id);\n      console.log('Profile A symmetric field data:', profileA?.fields[linkField2.id]);\n      expect(profileA?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ id: table1.records[0].id })\n      );\n\n      // Profile B should link back to Bob\n      const profileB = table2Records.records.find((r) => r.id === table2.records[1].id);\n      console.log('Profile B symmetric field data:', profileB?.fields[linkField2.id]);\n      expect(profileB?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ id: table1.records[1].id })\n      );\n    });\n\n    it('should handle empty OneOne TwoWay relationship', async () => {\n      // No links established, verify both sides are empty\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toBeUndefined();\n\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const profileA = table2Records.records.find((r) => r.id === table2.records[0].id);\n      expect(profileA?.fields[linkField2.id]).toBeUndefined();\n    });\n  });\n\n  describe('OneOne OneWay relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Users)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Users',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Alice' } }, { fields: { Name: 'Bob' } }],\n      });\n\n      // Create table2 (Profiles)\n      table2 = await createTable(baseId, {\n        name: 'Profiles',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Profile A' } }, { fields: { Name: 'Profile B' } }],\n      });\n\n      // Create OneOne OneWay link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Profile',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true, // No symmetric field created\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Verify no symmetric field was created\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeUndefined();\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create OneOne OneWay relationship and verify unidirectional linking', async () => {\n      // Link Alice to Profile A\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, {\n        id: table2.records[0].id,\n      });\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toEqual(expect.objectContaining({ title: 'Profile A' }));\n\n      // Verify table2 has no link fields (one-way relationship)\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const profileA = table2Records.records.find((r) => r.name === 'Profile A');\n      // Should not have any link field since it's one-way\n      // When using fieldKeyType: Id, we need to filter by field ID, not field name\n      const nameFieldId = table2.fields.find((f) => f.name === 'Name')?.id;\n      const linkFieldNames = Object.keys(profileA?.fields || {}).filter(\n        (key) => key !== nameFieldId\n      );\n      expect(linkFieldNames).toHaveLength(0);\n    });\n  });\n\n  describe('OneMany OneWay relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Projects)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Project A' } }, { fields: { Name: 'Project B' } }],\n      });\n\n      // Create table2 (Tasks)\n      table2 = await createTable(baseId, {\n        name: 'Tasks',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Task 1' } },\n          { fields: { Name: 'Task 2' } },\n          { fields: { Name: 'Task 3' } },\n        ],\n      });\n\n      // Create OneMany OneWay link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // No symmetric field created\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Verify no symmetric field was created\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeUndefined();\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create OneMany OneWay relationship and verify unidirectional linking', async () => {\n      // Link Project A to multiple tasks\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Link Project B to one task\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const projectA = table1Records.records.find((r) => r.name === 'Project A');\n      expect(projectA?.fields[linkField1.id]).toHaveLength(2);\n      expect(projectA?.fields[linkField1.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ title: 'Task 1' }),\n          expect.objectContaining({ title: 'Task 2' }),\n        ])\n      );\n\n      const projectB = table1Records.records.find((r) => r.name === 'Project B');\n      expect(projectB?.fields[linkField1.id]).toHaveLength(1);\n      expect(projectB?.fields[linkField1.id]).toEqual([\n        expect.objectContaining({ title: 'Task 3' }),\n      ]);\n\n      // Verify table2 has no link fields (one-way relationship)\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const task1 = table2Records.records.find((r) => r.name === 'Task 1');\n      // When using fieldKeyType: Id, we need to filter by field ID, not field name\n      const nameFieldId = table2.fields.find((f) => f.name === 'Name')?.id;\n      const linkFieldNames = Object.keys(task1?.fields || {}).filter((key) => key !== nameFieldId);\n      expect(linkFieldNames).toHaveLength(0);\n    });\n  });\n\n  describe('OneMany TwoWay relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n    let linkField2: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Projects)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Project A' } }, { fields: { Name: 'Project B' } }],\n      });\n\n      // Create table2 (Tasks)\n      table2 = await createTable(baseId, {\n        name: 'Tasks',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Task 1' } },\n          { fields: { Name: 'Task 2' } },\n          { fields: { Name: 'Task 3' } },\n        ],\n      });\n\n      // Create OneMany TwoWay link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          // isOneWay: false (default) - creates symmetric field\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Get the symmetric field in table2 (should be ManyOne)\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeDefined();\n      linkField2 = await getField(table2.id, linkOptions.symmetricFieldId);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create OneMany TwoWay relationship and verify bidirectional linking', async () => {\n      // Link Project A to multiple tasks\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Link Project B to one task\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const projectA = table1Records.records.find((r) => r.name === 'Project A');\n      expect(projectA?.fields[linkField1.id]).toHaveLength(2);\n\n      const projectB = table1Records.records.find((r) => r.name === 'Project B');\n      expect(projectB?.fields[linkField1.id]).toHaveLength(1);\n\n      // Verify table2 records show correct symmetric links (ManyOne relationship)\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Task 1 should link back to Project A\n      const task1 = table2Records.records.find((r) => r.id === table2.records[0].id);\n      expect(task1?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ id: table1.records[0].id })\n      );\n\n      // Task 2 should link back to Project A\n      const task2 = table2Records.records.find((r) => r.id === table2.records[1].id);\n      expect(task2?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ id: table1.records[0].id })\n      );\n\n      // Task 3 should link back to Project B\n      const task3 = table2Records.records.find((r) => r.id === table2.records[2].id);\n      expect(task3?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ id: table1.records[1].id })\n      );\n    });\n  });\n\n  describe('ManyMany OneWay relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Students)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Students',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Alice' } }, { fields: { Name: 'Bob' } }],\n      });\n\n      // Create table2 (Courses)\n      table2 = await createTable(baseId, {\n        name: 'Courses',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Math' } }, { fields: { Name: 'Science' } }],\n      });\n\n      // Create ManyMany OneWay link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Courses',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // No symmetric field created\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Verify no symmetric field was created\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeUndefined();\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create ManyMany OneWay relationship and verify unidirectional linking', async () => {\n      // Link students to courses\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toHaveLength(2);\n      expect(alice?.fields[linkField1.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ title: 'Math' }),\n          expect.objectContaining({ title: 'Science' }),\n        ])\n      );\n\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      expect(bob?.fields[linkField1.id]).toHaveLength(1);\n      expect(bob?.fields[linkField1.id]).toEqual([expect.objectContaining({ title: 'Math' })]);\n\n      // Verify table2 has no link fields (one-way relationship)\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const math = table2Records.records.find((r) => r.name === 'Math');\n      // When using fieldKeyType: Id, we need to filter by field ID, not field name\n      const nameFieldId = table2.fields.find((f) => f.name === 'Name')?.id;\n      const linkFieldNames = Object.keys(math?.fields || {}).filter((key) => key !== nameFieldId);\n      expect(linkFieldNames).toHaveLength(0);\n    });\n  });\n\n  describe('ManyMany TwoWay relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n    let linkField2: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 (Students)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Students',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Alice' } }, { fields: { Name: 'Bob' } }],\n      });\n\n      // Create table2 (Courses)\n      table2 = await createTable(baseId, {\n        name: 'Courses',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Math' } }, { fields: { Name: 'Science' } }],\n      });\n\n      // Create ManyMany TwoWay link field from table1 to table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Courses',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          // isOneWay: false (default) - creates symmetric field\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo);\n\n      // Get the symmetric field in table2 (should also be ManyMany)\n      const linkOptions = linkField1.options as any;\n      expect(linkOptions.symmetricFieldId).toBeDefined();\n      linkField2 = await getField(table2.id, linkOptions.symmetricFieldId);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create ManyMany TwoWay relationship and verify bidirectional linking', async () => {\n      // Link students to courses\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // Verify table1 records show correct links\n      const table1Records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField1.id]).toHaveLength(2);\n\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      expect(bob?.fields[linkField1.id]).toHaveLength(1);\n\n      // Verify table2 records show correct symmetric links (ManyMany relationship)\n      const table2Records = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Math should link back to both Alice and Bob\n      const math = table2Records.records.find((r) => r.id === table2.records[0].id);\n      expect(math?.fields[linkField2.id]).toHaveLength(2);\n      expect(math?.fields[linkField2.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ id: table1.records[0].id }),\n          expect.objectContaining({ id: table1.records[1].id }),\n        ])\n      );\n\n      // Science should link back to Alice only\n      const science = table2Records.records.find((r) => r.id === table2.records[1].id);\n      expect(science?.fields[linkField2.id]).toHaveLength(1);\n      expect(science?.fields[linkField2.id]).toEqual([\n        expect.objectContaining({ id: table1.records[0].id }),\n      ]);\n    });\n  });\n\n  describe('Convert ManyMany TwoWay to OneWay', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField1: IFieldVo;\n    let linkField2: IFieldVo;\n\n    beforeEach(async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Users',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Alice' } },\n          { fields: { Name: 'Bob' } },\n          { fields: { Name: 'Charlie' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Project A' } },\n          { fields: { Name: 'Project B' } },\n          { fields: { Name: 'Project C' } },\n        ],\n      });\n\n      const linkFieldRo1: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false, // 双向关联\n        },\n      };\n\n      linkField1 = await createField(table1.id, linkFieldRo1);\n\n      const symmetricFieldId = (linkField1.options as ILinkFieldOptions).symmetricFieldId;\n      if (symmetricFieldId) {\n        linkField2 = await getField(table2.id, symmetricFieldId);\n      }\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should convert bidirectional to unidirectional link without errors and maintain correct data', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField1.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordsBefore = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const table2RecordsBefore = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const aliceBefore = table1RecordsBefore.records.find((r) => r.name === 'Alice');\n      expect(aliceBefore?.fields[linkField1.id]).toHaveLength(2);\n      expect(aliceBefore?.fields[linkField1.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ title: 'Project A' }),\n          expect.objectContaining({ title: 'Project B' }),\n        ])\n      );\n\n      const bobBefore = table1RecordsBefore.records.find((r) => r.name === 'Bob');\n      expect(bobBefore?.fields[linkField1.id]).toHaveLength(1);\n      expect(bobBefore?.fields[linkField1.id]).toEqual([\n        expect.objectContaining({ title: 'Project C' }),\n      ]);\n\n      const projectABefore = table2RecordsBefore.records.find((r) => r.name === 'Project A');\n      const projectBBefore = table2RecordsBefore.records.find((r) => r.name === 'Project B');\n      const projectCBefore = table2RecordsBefore.records.find((r) => r.name === 'Project C');\n\n      expect(projectABefore?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ title: 'Alice' })\n      );\n      expect(projectBBefore?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ title: 'Alice' })\n      );\n      expect(projectCBefore?.fields[linkField2.id]).toEqual(\n        expect.objectContaining({ title: 'Bob' })\n      );\n\n      const convertFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField1.id, convertFieldRo);\n\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      // 验证转换后 table1 的数据仍然正确\n      const table1RecordsAfter = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const aliceAfter = table1RecordsAfter.records.find((r) => r.name === 'Alice');\n      expect(aliceAfter?.fields[linkField1.id]).toHaveLength(2);\n      expect(aliceAfter?.fields[linkField1.id]).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ title: 'Project A' }),\n          expect.objectContaining({ title: 'Project B' }),\n        ])\n      );\n\n      const bobAfter = table1RecordsAfter.records.find((r) => r.name === 'Bob');\n      expect(bobAfter?.fields[linkField1.id]).toHaveLength(1);\n      expect(bobAfter?.fields[linkField1.id]).toEqual([\n        expect.objectContaining({ title: 'Project C' }),\n      ]);\n\n      const table2RecordsAfter = await getRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      table2RecordsAfter.records.forEach((record) => {\n        const fieldKeys = Object.keys(record.fields);\n        expect(fieldKeys).toHaveLength(1); // 只有 Name 字段\n        // When using fieldKeyType: Id, the key should be the field ID, not the field name\n        const nameFieldId = table2.fields.find((f) => f.name === 'Name')?.id;\n        expect(fieldKeys[0]).toBe(nameFieldId);\n      });\n    });\n  });\n\n  describe('Advanced Link Field Conversion Tests', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      // Create first table (Users table)\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Users',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Alice' } },\n          { fields: { Name: 'Bob' } },\n          { fields: { Name: 'Charlie' } },\n        ],\n      });\n\n      // Create second table (Projects table)\n      table2 = await createTable(baseId, {\n        name: 'Projects',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Name: 'Project A' } },\n          { fields: { Name: 'Project B' } },\n          { fields: { Name: 'Project C' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should convert OneMany TwoWay to OneWay without errors', async () => {\n      // Create bidirectional OneMany link field\n      const linkFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // Establish link relationships\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Convert to unidirectional link\n      const convertFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // Convert to unidirectional\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      await expectHasOrderColumn(linkField.id, false);\n\n      // Verify data integrity\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField.id]).toHaveLength(2);\n    });\n\n    it('should convert OneOne TwoWay to OneWay without errors', async () => {\n      // Create bidirectional OneOne link field\n      const linkFieldRo: IFieldRo = {\n        name: 'Project',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // Establish link relationship\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // Convert to unidirectional link\n      const convertFieldRo: IFieldRo = {\n        name: 'Project',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true, // Convert to unidirectional\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneOne,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      await expectHasOrderColumn(linkField.id, true);\n\n      // Verify data integrity\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField.id]).toEqual(expect.objectContaining({ title: 'Project A' }));\n    });\n\n    it('should convert OneWay to TwoWay without errors', async () => {\n      // 创建单向 OneMany 关联字段\n      const linkFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // 单向关联\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // 建立关联关系\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // 转换为双向关联\n      const convertFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false, // 转为双向关联\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // 验证转换成功\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n        isOneWay: false,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n      await expectHasOrderColumn(linkField.id, true);\n\n      // 验证数据完整性\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField.id]).toHaveLength(1);\n\n      // 验证对称字段存在\n      const symmetricFieldId = (convertedField.options as ILinkFieldOptions).symmetricFieldId;\n      const symmetricField = await getField(table2.id, symmetricFieldId!);\n      expect(symmetricField).toBeDefined();\n      await expectHasOrderColumn(symmetricFieldId!, true);\n    });\n\n    it('should convert OneMany to ManyMany without errors', async () => {\n      // 创建 OneMany 关联字段\n      const linkFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // 建立关联关系\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // 转换为 ManyMany 关联\n      const convertFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // 验证转换成功\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n        isOneWay: false,\n      });\n\n      // 验证数据完整性\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField.id]).toHaveLength(1);\n    });\n\n    it('should convert ManyMany to OneMany without errors', async () => {\n      // Create ManyMany link field\n      const linkFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // Establish link relationship\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // Convert to OneMany relationship\n      const convertFieldRo: IFieldRo = {\n        name: 'Projects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n        isOneWay: false,\n      });\n\n      // Verify data integrity\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[linkField.id]).toHaveLength(1);\n    });\n\n    it('should convert bidirectional link created in table2 to unidirectional in table1', async () => {\n      // Create bidirectional ManyOne link field in table2 (Projects -> Users)\n      const linkFieldRo: IFieldRo = {\n        name: 'Assignees',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n      // Establish link relationships\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField.id, {\n        id: table1.records[1].id,\n      });\n\n      // Verify symmetric field exists in table1\n      expect(symmetricFieldId).toBeDefined();\n      const symmetricField = await getField(table1.id, symmetricFieldId!);\n      expect(symmetricField).toBeDefined();\n\n      // Convert the symmetric field in table1 to unidirectional\n      const convertFieldRo: IFieldRo = {\n        name: symmetricField.name,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // Convert to unidirectional\n        },\n      };\n\n      const convertedField = await convertField(table1.id, symmetricFieldId!, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      // Verify data integrity in table1\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      expect(alice?.fields[convertedField.id]).toHaveLength(1);\n      expect(bob?.fields[convertedField.id]).toHaveLength(1);\n\n      // Note: When converting bidirectional to unidirectional, the symmetric field is deleted\n      // This is the correct behavior - the original field in table2 may also be affected\n      // The conversion successfully completed as evidenced by the 200 status code\n\n      // Verify the symmetric field was properly deleted (this is expected behavior)\n      // When converting bidirectional to unidirectional, the symmetric field should be removed\n    });\n\n    // Comprehensive Link Field Conversion Test Matrix\n    // Testing all combinations of: Direction (OneWay/TwoWay) × Relationship (OneMany/ManyOne/ManyMany) × Table (Source/Target)\n    describe('Comprehensive Link Field Conversion Matrix', () => {\n      let sourceTable: ITableFullVo;\n      let targetTable: ITableFullVo;\n\n      beforeEach(async () => {\n        // Create two tables for comprehensive testing\n        const sourceTableRo = {\n          name: 'SourceTable',\n          fields: [\n            {\n              name: 'Name',\n              type: FieldType.SingleLineText,\n            },\n          ],\n          records: [\n            { fields: { Name: 'Source1' } },\n            { fields: { Name: 'Source2' } },\n            { fields: { Name: 'Source3' } },\n          ],\n        };\n\n        const targetTableRo = {\n          name: 'TargetTable',\n          fields: [\n            {\n              name: 'Name',\n              type: FieldType.SingleLineText,\n            },\n          ],\n          records: [\n            { fields: { Name: 'Target1' } },\n            { fields: { Name: 'Target2' } },\n            { fields: { Name: 'Target3' } },\n          ],\n        };\n\n        sourceTable = await createTable(baseId, sourceTableRo);\n        targetTable = await createTable(baseId, targetTableRo);\n      });\n\n      afterEach(async () => {\n        await permanentDeleteTable(baseId, sourceTable.id);\n        await permanentDeleteTable(baseId, targetTable.id);\n      });\n\n      // Test Matrix: OneWay → TwoWay conversions\n      describe('OneWay to TwoWay Conversions', () => {\n        it('should convert OneMany OneWay (source) to OneMany TwoWay', async () => {\n          // Create OneMany OneWay field in source table\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          expect((linkField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          // Create some link data before conversion\n          const sourceRecords = await getRecords(sourceTable.id);\n          const targetRecords = await getRecords(targetTable.id);\n\n          // Link first source record to first two target records\n          await updateRecordByApi(sourceTable.id, sourceRecords.records[0].id, linkField.id, [\n            { id: targetRecords.records[0].id },\n            { id: targetRecords.records[1].id },\n          ]);\n\n          // Convert to TwoWay\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          await expectHasOrderColumn(linkField.id, true);\n\n          // Verify symmetric field was created in target table\n          const symmetricFieldId = (convertedField.options as ILinkFieldOptions).symmetricFieldId;\n          const symmetricField = await getField(targetTable.id, symmetricFieldId!);\n          expect((symmetricField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyOne\n          );\n          await expectHasOrderColumn(symmetricFieldId!, true);\n\n          // Verify record data integrity after conversion\n          const updatedSourceRecords = await getRecords(sourceTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const updatedTargetRecords = await getRecords(targetTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          // Check that the original link data is preserved\n          const sourceRecord = updatedSourceRecords.records.find(\n            (r) => r.id === sourceRecords.records[0].id\n          );\n          const linkValue = sourceRecord?.fields[convertedField.id] as any[];\n          expect(linkValue).toHaveLength(2);\n          expect(linkValue.map((l) => l.id)).toContain(targetRecords.records[0].id);\n          expect(linkValue.map((l) => l.id)).toContain(targetRecords.records[1].id);\n\n          // Check that symmetric links were created\n          const targetRecord1 = updatedTargetRecords.records.find(\n            (r) => r.id === targetRecords.records[0].id\n          );\n          const targetRecord2 = updatedTargetRecords.records.find(\n            (r) => r.id === targetRecords.records[1].id\n          );\n          const targetRecord3 = updatedTargetRecords.records.find(\n            (r) => r.id === targetRecords.records[2].id\n          );\n\n          expect(targetRecord1?.fields[symmetricField.id]).toEqual({\n            id: sourceRecords.records[0].id,\n            title: 'Source1',\n          });\n          expect(targetRecord2?.fields[symmetricField.id]).toEqual({\n            id: sourceRecords.records[0].id,\n            title: 'Source1',\n          });\n          expect(targetRecord3?.fields[symmetricField.id]).toBeUndefined();\n        });\n\n        it('should convert ManyOne OneWay (source) to ManyOne TwoWay', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyOne_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          const symmetricFieldId = (convertedField.options as ILinkFieldOptions).symmetricFieldId;\n          const symmetricField = await getField(targetTable.id, symmetricFieldId!);\n          expect((symmetricField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.OneMany\n          );\n        });\n\n        it('should convert ManyMany OneWay (source) to ManyMany TwoWay', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          const symmetricFieldId = (convertedField.options as ILinkFieldOptions).symmetricFieldId;\n          const symmetricField = await getField(targetTable.id, symmetricFieldId!);\n          expect((symmetricField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n        });\n      });\n\n      // Test Matrix: TwoWay → OneWay conversions\n      describe('TwoWay to OneWay Conversions', () => {\n        it('should convert OneMany TwoWay to OneWay (convert from source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n          // Create some link data before conversion\n          const initialSourceRecords = await getRecords(sourceTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const initialTargetRecords = await getRecords(targetTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          // Link first source record to first two target records\n          await updateRecordByApi(\n            sourceTable.id,\n            initialSourceRecords.records[0].id,\n            linkField.id,\n            [{ id: initialTargetRecords.records[0].id }, { id: initialTargetRecords.records[1].id }]\n          );\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, false);\n\n          // Verify record data integrity after conversion\n          const finalSourceRecords = await getRecords(sourceTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const finalTargetRecords = await getRecords(targetTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          expect(finalSourceRecords.records).toHaveLength(3);\n          expect(finalTargetRecords.records).toHaveLength(3);\n\n          // Verify that the original link data is preserved in the source table\n          const sourceRecord = finalSourceRecords.records.find(\n            (r) => r.id === initialSourceRecords.records[0].id\n          );\n          const linkValue = sourceRecord?.fields[convertedField.id] as any[];\n          expect(linkValue).toHaveLength(2);\n          expect(linkValue.map((l) => l.id)).toContain(initialTargetRecords.records[0].id);\n          expect(linkValue.map((l) => l.id)).toContain(initialTargetRecords.records[1].id);\n\n          // Verify that target records no longer have symmetric field data (since it was deleted)\n          finalTargetRecords.records.forEach((record) => {\n            // The symmetric field should not exist anymore\n            expect(record.fields).not.toHaveProperty(symmetricFieldId!);\n          });\n\n          // Verify symmetric field was deleted\n          try {\n            await getField(targetTable.id, symmetricFieldId!);\n            expect(true).toBe(false); // Should not reach here\n          } catch (error) {\n            expect(error).toBeDefined(); // Expected - field should be deleted\n          }\n        });\n\n        it('should convert OneMany TwoWay to OneWay (convert from target table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n          const symmetricField = await getField(targetTable.id, symmetricFieldId!);\n\n          // Convert the symmetric field (ManyOne) to OneWay\n          const convertFieldRo: IFieldRo = {\n            name: symmetricField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: sourceTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(\n            targetTable.id,\n            symmetricFieldId!,\n            convertFieldRo\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(symmetricFieldId!, true);\n        });\n\n        it('should convert ManyMany TwoWay to OneWay (convert from source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n        });\n\n        it('should convert ManyMany TwoWay to OneWay (convert from target table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n          const convertFieldRo: IFieldRo = {\n            name: 'Converted_ManyMany_OneWay',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: sourceTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(\n            targetTable.id,\n            symmetricFieldId!,\n            convertFieldRo\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n        });\n      });\n\n      // Test Matrix: Relationship Type Conversions (while maintaining direction)\n      describe('Relationship Type Conversions', () => {\n        it('should convert OneMany OneWay to ManyOne OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          // Create some link data before conversion (OneMany allows multiple targets)\n          const beforeSourceRecords = await getRecords(sourceTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const beforeTargetRecords = await getRecords(targetTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          await updateRecordByApi(sourceTable.id, beforeSourceRecords.records[0].id, linkField.id, [\n            { id: beforeTargetRecords.records[0].id },\n            { id: beforeTargetRecords.records[1].id },\n          ]);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyOne\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, true);\n\n          // Verify record data after conversion (ManyOne should keep only one link)\n          const afterSourceRecords = await getRecords(sourceTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const sourceRecord = afterSourceRecords.records.find(\n            (r) => r.id === beforeSourceRecords.records[0].id\n          );\n          const linkValue = sourceRecord?.fields[convertedField.id];\n\n          // ManyOne relationship should have only one linked record (the first one is typically kept)\n          expect(linkValue).toBeDefined();\n          if (Array.isArray(linkValue)) {\n            expect(linkValue).toHaveLength(1);\n          } else {\n            expect(linkValue).toHaveProperty('id');\n          }\n        });\n\n        it('should convert OneMany OneWay to ManyMany OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, true);\n        });\n\n        it('should convert ManyOne OneWay to OneMany OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyOne_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.OneMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, false);\n        });\n\n        it('should convert ManyOne OneWay to ManyMany OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyOne_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, true);\n        });\n\n        it('should convert ManyMany OneWay to OneMany OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.OneMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, false);\n        });\n\n        it('should convert ManyMany OneWay to ManyOne OneWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_OneWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: targetTable.id,\n              isOneWay: true,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyOne\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(true);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n          await expectHasOrderColumn(linkField.id, true);\n        });\n      });\n\n      // Test Matrix: Bidirectional Relationship Type Conversions\n      describe('Bidirectional Relationship Type Conversions', () => {\n        it('should convert OneMany TwoWay to ManyMany TwoWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          // Verify symmetric field was updated to ManyMany\n          const updatedSymmetricField = await getField(targetTable.id, symmetricFieldId!);\n          expect((updatedSymmetricField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n        });\n\n        it('should convert ManyMany TwoWay to OneMany TwoWay (source table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'ManyMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n          const convertFieldRo: IFieldRo = {\n            name: linkField.name,\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(sourceTable.id, linkField.id, convertFieldRo);\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.OneMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          // Verify symmetric field was updated to ManyOne\n          const updatedSymmetricField = await getField(targetTable.id, symmetricFieldId!);\n          expect((updatedSymmetricField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyOne\n          );\n        });\n\n        it('should convert OneMany TwoWay to ManyMany TwoWay (target table)', async () => {\n          const linkFieldRo: IFieldRo = {\n            name: 'OneMany_TwoWay_Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: targetTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const linkField = await createField(sourceTable.id, linkFieldRo);\n          const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n          // Convert from target table (ManyOne to ManyMany)\n          const convertFieldRo: IFieldRo = {\n            name: 'Converted_ManyMany_TwoWay',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: sourceTable.id,\n              isOneWay: false,\n            },\n          };\n\n          const convertedField = await convertField(\n            targetTable.id,\n            symmetricFieldId!,\n            convertFieldRo\n          );\n          expect((convertedField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n          expect((convertedField.options as ILinkFieldOptions).isOneWay).toBe(false);\n          expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n          // Verify original field was updated to ManyMany\n          const updatedOriginalField = await getField(sourceTable.id, linkField.id);\n          expect((updatedOriginalField.options as ILinkFieldOptions).relationship).toBe(\n            Relationship.ManyMany\n          );\n        });\n      });\n    });\n\n    it('should convert ManyMany TwoWay created in table2 to OneWay in table1', async () => {\n      // Create bidirectional ManyMany link field in table2\n      const linkFieldRo: IFieldRo = {\n        name: 'Contributors',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n      // Establish complex link relationships\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, [\n        { id: table1.records[0].id },\n        { id: table1.records[1].id },\n      ]);\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField.id, [\n        { id: table1.records[1].id },\n        { id: table1.records[2].id },\n      ]);\n\n      // Verify symmetric field exists in table1\n      expect(symmetricFieldId).toBeDefined();\n      const symmetricField = await getField(table1.id, symmetricFieldId!);\n      expect(symmetricField).toBeDefined();\n\n      // Convert the symmetric field in table1 to unidirectional\n      const convertFieldRo: IFieldRo = {\n        name: symmetricField.name,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true, // Convert to unidirectional\n        },\n      };\n\n      const convertedField = await convertField(table1.id, symmetricFieldId!, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      // Verify data integrity - complex many-to-many relationships preserved\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      const charlie = table1Records.records.find((r) => r.name === 'Charlie');\n\n      expect(alice?.fields[convertedField.id]).toHaveLength(1); // Project A\n      expect(bob?.fields[convertedField.id]).toHaveLength(2); // Project A, Project B\n      expect(charlie?.fields[convertedField.id]).toHaveLength(1); // Project B\n    });\n\n    it('should handle OneOne bidirectional conversion with existing data', async () => {\n      // Create bidirectional OneOne link field in table2\n      const linkFieldRo: IFieldRo = {\n        name: 'MainUser',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table1.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n      // Establish OneOne relationships\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField.id, {\n        id: table1.records[1].id,\n      });\n\n      // Convert the symmetric field in table1 to unidirectional\n      const convertFieldRo: IFieldRo = {\n        name: 'MainProject',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true, // Convert to unidirectional\n        },\n      };\n\n      const convertedField = await convertField(table1.id, symmetricFieldId!, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.OneOne,\n        foreignTableId: table2.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      // Verify data integrity - OneOne relationships preserved\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      const bob = table1Records.records.find((r) => r.name === 'Bob');\n      const charlie = table1Records.records.find((r) => r.name === 'Charlie');\n\n      expect(alice?.fields[convertedField.id]).toEqual(\n        expect.objectContaining({ title: 'Project A' })\n      );\n      expect(bob?.fields[convertedField.id]).toEqual(\n        expect.objectContaining({ title: 'Project B' })\n      );\n      expect(charlie?.fields[convertedField.id]).toBeUndefined();\n    });\n\n    it('should convert relationship type while maintaining bidirectional nature', async () => {\n      // Create bidirectional OneMany link field\n      const linkFieldRo: IFieldRo = {\n        name: 'TeamProjects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false, // Bidirectional link\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      // Establish relationships\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Convert relationship type from OneMany to ManyMany while keeping bidirectional\n      const convertFieldRo: IFieldRo = {\n        name: 'TeamProjects',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const convertedField = await convertField(table1.id, linkField.id, convertFieldRo);\n\n      // Verify conversion success\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeDefined();\n\n      // Verify data integrity\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const alice = table1Records.records.find((r) => r.name === 'Alice');\n      expect(alice?.fields[convertedField.id]).toHaveLength(2);\n\n      // Verify symmetric field still exists and works\n      const newSymmetricFieldId = (convertedField.options as ILinkFieldOptions).symmetricFieldId;\n      const newSymmetricField = await getField(table2.id, newSymmetricFieldId!);\n      expect(newSymmetricField).toBeDefined();\n      expect(newSymmetricField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n      });\n    });\n  });\n\n  describe('User primary field link relationships', () => {\n    const OWNER_FIELD_NAME = 'Owner';\n    const LABEL_FIELD_NAME = 'Label';\n    const defaultUserTitle = globalThis.testConfig.userName || 'Test User';\n    const secondaryUserTitle = 'test';\n\n    const defaultUserFactory = () => ({\n      id: globalThis.testConfig.userId,\n      title: defaultUserTitle,\n      email: globalThis.testConfig.email,\n    });\n\n    const secondaryUserFactory = () => ({\n      id: 'usrTestUserId',\n      title: secondaryUserTitle,\n    });\n\n    const buildUserPrimaryTable = async (\n      name: string,\n      firstUserFactory: () => Record<string, unknown>,\n      secondUserFactory: () => Record<string, unknown>\n    ) => {\n      return createTable(baseId, {\n        name,\n        fields: [\n          { name: OWNER_FIELD_NAME, type: FieldType.User } as IFieldRo,\n          { name: LABEL_FIELD_NAME, type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              [OWNER_FIELD_NAME]: firstUserFactory(),\n              [LABEL_FIELD_NAME]: `${name}-1`,\n            },\n          },\n          {\n            fields: {\n              [OWNER_FIELD_NAME]: secondUserFactory(),\n              [LABEL_FIELD_NAME]: `${name}-2`,\n            },\n          },\n        ],\n      });\n    };\n\n    const expectLinkValueHasTitle = (value: unknown, _expectedTitle: string) => {\n      const extractTitle = (input: unknown): string | undefined => {\n        if (input == null) return undefined;\n        if (typeof input === 'string') return input;\n        if (Array.isArray(input)) {\n          for (const item of input) {\n            const title = extractTitle(item);\n            if (title) return title;\n          }\n          return undefined;\n        }\n        if (typeof input === 'object') {\n          const record = input as Record<string, unknown>;\n          const title = extractTitle(record.title);\n          if (title) return title;\n          const name = extractTitle(record.name);\n          if (name) return name;\n        }\n        return undefined;\n      };\n\n      const title = extractTitle(value);\n      expect(typeof title).toBe('string');\n      expect(title?.length).toBeGreaterThan(0);\n    };\n\n    it('supports ManyMany linking when both tables use user primary fields', async () => {\n      const sourceTable = await buildUserPrimaryTable(\n        'user-mm-src',\n        defaultUserFactory,\n        secondaryUserFactory\n      );\n      const targetTable = await buildUserPrimaryTable(\n        'user-mm-target',\n        secondaryUserFactory,\n        defaultUserFactory\n      );\n\n      try {\n        const linkField = (await createField(sourceTable.id, {\n          name: 'Partners',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: targetTable.id,\n          },\n        })) as IFieldVo;\n\n        const symmetricFieldId = (linkField.options as ILinkFieldOptions)\n          .symmetricFieldId as string;\n        expect(symmetricFieldId).toBeDefined();\n\n        await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, [\n          { id: targetTable.records[0].id },\n        ]);\n\n        const sourceRecord = await getRecord(sourceTable.id, sourceTable.records[0].id);\n        expectLinkValueHasTitle(sourceRecord.fields[linkField.id], secondaryUserTitle);\n\n        const targetRecord = await getRecord(targetTable.id, targetTable.records[0].id);\n        expectLinkValueHasTitle(targetRecord.fields[symmetricFieldId], defaultUserTitle);\n      } finally {\n        await permanentDeleteTable(baseId, sourceTable.id);\n        await permanentDeleteTable(baseId, targetTable.id);\n      }\n    });\n\n    it('supports ManyOne linking when both tables use user primary fields', async () => {\n      const sourceTable = await buildUserPrimaryTable(\n        'user-mn-src',\n        defaultUserFactory,\n        secondaryUserFactory\n      );\n      const targetTable = await buildUserPrimaryTable(\n        'user-mn-target',\n        secondaryUserFactory,\n        defaultUserFactory\n      );\n\n      try {\n        const linkField = (await createField(sourceTable.id, {\n          name: 'OwnerProject',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: targetTable.id,\n          },\n        })) as IFieldVo;\n\n        const symmetricFieldId = (linkField.options as ILinkFieldOptions)\n          .symmetricFieldId as string;\n        expect(symmetricFieldId).toBeDefined();\n\n        await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, {\n          id: targetTable.records[0].id,\n        });\n\n        const sourceRecord = await getRecord(sourceTable.id, sourceTable.records[0].id);\n        expectLinkValueHasTitle(sourceRecord.fields[linkField.id], secondaryUserTitle);\n\n        const targetRecord = await getRecord(targetTable.id, targetTable.records[0].id);\n        expectLinkValueHasTitle(targetRecord.fields[symmetricFieldId], defaultUserTitle);\n      } finally {\n        await permanentDeleteTable(baseId, sourceTable.id);\n        await permanentDeleteTable(baseId, targetTable.id);\n      }\n    });\n\n    it('supports OneMany linking when both tables use user primary fields', async () => {\n      const sourceTable = await buildUserPrimaryTable(\n        'user-om-src',\n        defaultUserFactory,\n        secondaryUserFactory\n      );\n      const targetTable = await buildUserPrimaryTable(\n        'user-om-target',\n        secondaryUserFactory,\n        defaultUserFactory\n      );\n\n      try {\n        const linkField = (await createField(sourceTable.id, {\n          name: 'TeamMembers',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: targetTable.id,\n          },\n        })) as IFieldVo;\n\n        const symmetricFieldId = (linkField.options as ILinkFieldOptions)\n          .symmetricFieldId as string;\n        expect(symmetricFieldId).toBeDefined();\n\n        await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, [\n          { id: targetTable.records[0].id },\n        ]);\n\n        const sourceRecord = await getRecord(sourceTable.id, sourceTable.records[0].id);\n        expectLinkValueHasTitle(sourceRecord.fields[linkField.id], secondaryUserTitle);\n\n        const targetRecord = await getRecord(targetTable.id, targetTable.records[0].id);\n        expectLinkValueHasTitle(targetRecord.fields[symmetricFieldId], defaultUserTitle);\n      } finally {\n        await permanentDeleteTable(baseId, sourceTable.id);\n        await permanentDeleteTable(baseId, targetTable.id);\n      }\n    });\n\n    it('supports OneOne linking when both tables use user primary fields', async () => {\n      const sourceTable = await buildUserPrimaryTable(\n        'user-oo-src',\n        defaultUserFactory,\n        secondaryUserFactory\n      );\n      const targetTable = await buildUserPrimaryTable(\n        'user-oo-target',\n        secondaryUserFactory,\n        defaultUserFactory\n      );\n\n      try {\n        const linkField = (await createField(sourceTable.id, {\n          name: 'ProfileOwner',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneOne,\n            foreignTableId: targetTable.id,\n          },\n        })) as IFieldVo;\n\n        const symmetricFieldId = (linkField.options as ILinkFieldOptions)\n          .symmetricFieldId as string;\n        expect(symmetricFieldId).toBeDefined();\n\n        await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, {\n          id: targetTable.records[0].id,\n        });\n\n        const sourceRecord = await getRecord(sourceTable.id, sourceTable.records[0].id);\n        expectLinkValueHasTitle(sourceRecord.fields[linkField.id], secondaryUserTitle);\n\n        const targetRecord = await getRecord(targetTable.id, targetTable.records[0].id);\n        expectLinkValueHasTitle(targetRecord.fields[symmetricFieldId], defaultUserTitle);\n      } finally {\n        await permanentDeleteTable(baseId, sourceTable.id);\n        await permanentDeleteTable(baseId, targetTable.id);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/bidirectional-formula-link.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  getRecords as apiGetRecords,\n  createField,\n  updateRecord,\n  convertField,\n  getFields,\n} from '@teable/openapi';\nimport { createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\ndescribe('Bidirectional Formula Link Fields (e2e)', () => {\n  let app: INestApplication;\n  let baseId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    baseId = globalThis.testConfig.baseId;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('many-to-many bidirectional link with formula field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeAll(async () => {\n      // Create Table1 with primary text field that will be converted to formula\n      table1 = await createTable(baseId, {\n        name: 'Table1_FormulaTest',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { Title: 'Item1' } },\n          { fields: { Title: 'Item2' } },\n          { fields: { Title: 'Item3' } },\n        ],\n      });\n\n      // Create Table2\n      table2 = await createTable(baseId, {\n        name: 'Table2_FormulaTest',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { Title: 'Group1' } }, { fields: { Title: 'Group2' } }],\n      });\n\n      // Create many-to-many link field from Table1 to Table2\n      const linkFieldResponse = await createField(table1.id, {\n        name: 'LinkedGroups',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const linkField = linkFieldResponse.data;\n\n      // Convert Table1's primary field (Title) to a formula field that references the link field\n      const primaryField = table1.fields[0]; // This is the \"Title\" field\n      await convertField(table1.id, primaryField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${linkField.id}}`, // Reference the link field\n        },\n      });\n\n      // Get fresh table data to get the created fields\n      const table1Records = await apiGetRecords(table1.id, { viewId: table1.views[0].id });\n      const table2Records = await apiGetRecords(table2.id, { viewId: table2.views[0].id });\n\n      // Link Item1 to Group1\n      await updateRecord(table1.id, table1Records.data.records[0].id, {\n        record: {\n          fields: {\n            LinkedGroups: [{ id: table2Records.data.records[0].id }],\n          },\n        },\n      });\n\n      // Link Item2 to both Group1 and Group2\n      await updateRecord(table1.id, table1Records.data.records[1].id, {\n        record: {\n          fields: {\n            LinkedGroups: [\n              { id: table2Records.data.records[0].id },\n              { id: table2Records.data.records[1].id },\n            ],\n          },\n        },\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should correctly display formula values in bidirectional link titles', async () => {\n      // Get Table2 records to check the bidirectional link\n      const table2Records = await apiGetRecords(table2.id, { viewId: table2.views[0].id });\n      expect(table2Records.data.records).toHaveLength(2);\n\n      // Get updated Table2 fields to find the symmetric link field (created automatically)\n      const table2Fields = await getFields(table2.id, {});\n      const linkField = table2Fields.data.find((f) => f.type === FieldType.Link);\n      expect(linkField).toBeDefined();\n      expect(linkField!.name).toContain('Table1_FormulaTest');\n\n      // Check Group1 record - should be linked to Item1 and Item2\n      const group1Record = table2Records.data.records.find((r) => r.fields.Title === 'Group1');\n      expect(group1Record).toBeDefined();\n\n      const group1Links = group1Record!.fields[linkField!.name!] as any[];\n      expect(Array.isArray(group1Links)).toBe(true);\n      expect(group1Links).toHaveLength(2); // Linked to Item1 and Item2\n\n      // Verify that each linked record has correct title (should show formula result)\n      // The formula field references the link field, so it should show the linked groups\n      const titles = group1Links.map((link) => link.title).sort();\n      expect(titles).toEqual(['Group1', 'Group1, Group2']); // Item1 links to Group1, Item2 links to Group1,Group2\n\n      // Check Group2 record - should be linked to Item2 only\n      const group2Record = table2Records.data.records.find((r) => r.fields.Title === 'Group2');\n      expect(group2Record).toBeDefined();\n\n      const group2Links = group2Record!.fields[linkField!.name!] as any[];\n      expect(Array.isArray(group2Links)).toBe(true);\n      expect(group2Links).toHaveLength(1); // Linked to Item2 only\n\n      // Verify the linked record has correct title\n      expect(group2Links[0].title).toBe('Group1, Group2'); // Item2 links to both groups\n\n      // Verify all linked records have both id and title\n      [...group1Links, ...group2Links].forEach((link) => {\n        expect(link).toHaveProperty('id');\n        expect(link).toHaveProperty('title');\n        expect(typeof link.id).toBe('string');\n        expect(typeof link.title).toBe('string');\n        expect(link.title).not.toBe(''); // Title should not be empty\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/canary.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport {\n  getSetting,\n  updateSetting,\n  SettingKey,\n  getBaseById,\n  axios,\n  urlBuilder,\n  GET_BASE,\n  X_CANARY_HEADER,\n} from '@teable/openapi';\nimport { CanaryService } from '../src/features/canary';\nimport {\n  createSpace,\n  permanentDeleteSpace,\n  permanentDeleteBase,\n  createBase,\n  initApp,\n} from './utils/init-app';\n\ndescribe('Canary Release (e2e)', () => {\n  let app: INestApplication;\n  let canaryService: CanaryService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    canaryService = app.get(CanaryService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  afterEach(async () => {\n    // Reset canary config after each test\n    await updateSetting({\n      [SettingKey.CANARY_CONFIG]: {\n        enabled: false,\n        spaceIds: [],\n      },\n    });\n  });\n\n  describe('Canary Config CRUD via API', () => {\n    it('should save and retrieve canary config', async () => {\n      const testSpaceIds = ['spc123', 'spc456'];\n\n      // Update canary config\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: testSpaceIds,\n        },\n      });\n\n      // Retrieve and verify\n      const res = await getSetting();\n      expect(res.data.canaryConfig).toEqual({\n        enabled: true,\n        spaceIds: testSpaceIds,\n      });\n    });\n\n    it('should update canary config enabled state', async () => {\n      // First enable\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: ['spc123'],\n        },\n      });\n\n      let res = await getSetting();\n      expect(res.data.canaryConfig?.enabled).toBe(true);\n\n      // Then disable\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: false,\n          spaceIds: ['spc123'],\n        },\n      });\n\n      res = await getSetting();\n      expect(res.data.canaryConfig?.enabled).toBe(false);\n    });\n  });\n\n  describe('Space Canary Status Check', () => {\n    const testSpaceId = 'spcCanaryTest123';\n\n    it('should return false when canary config is disabled', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: false,\n          spaceIds: [testSpaceId],\n        },\n      });\n\n      const result = await canaryService.isSpaceInCanary(testSpaceId);\n      expect(result).toBe(false);\n    });\n\n    it('should return false when space is not in canary list', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: ['spcOther'],\n        },\n      });\n\n      const result = await canaryService.isSpaceInCanary(testSpaceId);\n      expect(result).toBe(false);\n    });\n\n    it('should return true when space is in canary list and config is enabled', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: [testSpaceId, 'spcOther'],\n        },\n      });\n\n      const result = await canaryService.isSpaceInCanary(testSpaceId);\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('Base API isCanary Field', () => {\n    let spaceId: string;\n    let baseId: string;\n\n    beforeAll(async () => {\n      // Create a real space and base\n      const space = await createSpace({ name: 'Canary Base API Test' });\n      spaceId = space.id;\n\n      const base = await createBase({ spaceId, name: 'Test Base' });\n      baseId = base.id;\n    });\n\n    afterAll(async () => {\n      if (baseId) {\n        await permanentDeleteBase(baseId);\n      }\n      if (spaceId) {\n        await permanentDeleteSpace(spaceId);\n      }\n    });\n\n    it('should return isCanary: true when space is in canary', async () => {\n      // Configure canary with the space\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: [spaceId],\n        },\n      });\n\n      const res = await getBaseById(baseId);\n      expect(res.data.isCanary).toBe(true);\n    });\n\n    it('should not include isCanary when space is not in canary', async () => {\n      // Configure canary without the space\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: ['spcOther'],\n        },\n      });\n\n      const res = await getBaseById(baseId);\n      expect(res.data.isCanary).toBeUndefined();\n    });\n\n    it('should not include isCanary when canary is disabled', async () => {\n      // Disable canary\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: false,\n          spaceIds: [spaceId],\n        },\n      });\n\n      const res = await getBaseById(baseId);\n      expect(res.data.isCanary).toBeUndefined();\n    });\n\n    it('should return isCanary: true when header is set to true', async () => {\n      const res = await axios.get<IGetBaseVo>(\n        urlBuilder(GET_BASE, {\n          baseId,\n        }),\n        {\n          headers: {\n            [X_CANARY_HEADER]: 'true',\n          },\n        }\n      );\n      expect(res.data.isCanary).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/collaboration.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, IdPrefix, ViewType } from '@teable/core';\nimport type { IFieldVo, IRecord } from '@teable/core';\nimport {\n  createRecords as apiCreateRecords,\n  updateRecord as apiUpdateRecord,\n  deleteRecord as apiDeleteRecord,\n  createField as apiCreateField,\n  deleteField as apiDeleteField,\n  enableShareView as apiEnableShareView,\n} from '@teable/openapi';\nimport type { Query, Doc, Connection } from 'sharedb/lib/client';\nimport ShareDBClient from 'sharedb/lib/client';\nimport { ShareDbService } from '../src/share-db/share-db.service';\nimport { initApp, createTable, permanentDeleteTable } from './utils/init-app';\n\n/**\n * Check if sockjs-client is available for transport fallback tests\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention\nlet SockJS: any;\nlet isSockJSAvailable = false;\ntry {\n  // eslint-disable-next-line @typescript-eslint/no-require-imports\n  SockJS = require('sockjs-client');\n  isSockJSAvailable = true;\n} catch {\n  // sockjs-client not installed, skip transport fallback tests\n}\n\n/**\n * SockJS transport types for testing\n * Note: xhr-polling is excluded as it's no longer supported\n */\ntype ISockJSTransport = 'websocket' | 'xhr-streaming';\n\n/** Transport constants */\nconst transportWebsocket: ISockJSTransport = 'websocket';\nconst transportXhrStreaming: ISockJSTransport = 'xhr-streaming';\n\n/** Default transport chain for fallback tests */\nconst defaultTransportChain: ISockJSTransport[] = [transportWebsocket, transportXhrStreaming];\n\nconst defaultTimeout = 5000;\nconst eventTimeout = 3000;\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\nconst describeWhenV1 = isForceV2 ? describe.skip : describe;\nconst describeSockJS = isSockJSAvailable ? describeWhenV1 : describe.skip;\n\n/**\n * Helper: Wait for ShareDB query to be ready\n */\nconst waitForQueryReady = <T>(query: Query<T>, timeout = defaultTimeout): Promise<void> => {\n  return new Promise((resolve, reject) => {\n    if (query.ready) {\n      resolve();\n      return;\n    }\n\n    const timer = setTimeout(() => {\n      reject(new Error('Query ready timeout'));\n    }, timeout);\n\n    query.once('ready', () => {\n      clearTimeout(timer);\n      resolve();\n    });\n\n    query.once('error', (err: any) => {\n      clearTimeout(timer);\n      reject(err);\n    });\n  });\n};\n\n/**\n * Helper: Wait for query event with timeout\n */\nconst waitForQueryEvent = <T>(\n  query: Query<any>,\n  eventName: 'insert' | 'remove' | 'move' | 'changed',\n  timeout = eventTimeout\n): Promise<T> => {\n  return new Promise((resolve, reject) => {\n    const timer = setTimeout(() => {\n      reject(new Error(`Event \"${eventName}\" timeout`));\n    }, timeout);\n\n    const handler = (...args: any[]) => {\n      clearTimeout(timer);\n      resolve(args as T);\n    };\n\n    query.once(eventName, handler as any);\n  });\n};\n\n/**\n * Helper: Wait for doc op event with timeout\n */\nconst waitForDocOp = (doc: Doc<any>, timeout = eventTimeout): Promise<any[]> => {\n  return new Promise((resolve, reject) => {\n    const timer = setTimeout(() => {\n      reject(new Error('Doc op event timeout'));\n    }, timeout);\n\n    const handler = (ops: any[]) => {\n      clearTimeout(timer);\n      resolve(ops);\n    };\n\n    doc.once('op', handler);\n  });\n};\n\n/**\n * Helper: Create ShareDB connection via internal service\n */\nconst createConnection = (\n  shareDbService: ShareDbService,\n  cookie: string,\n  port: string\n): Connection => {\n  return shareDbService.connect(undefined, {\n    url: `ws://localhost:${port}/socket`,\n    headers: { cookie },\n  });\n};\n\ndescribe('Collaboration (e2e)', () => {\n  let app: INestApplication;\n  let tableId: string;\n  let viewId: string;\n  let shareId: string;\n  let cookie: string;\n  let port: string;\n  const baseId = globalThis.testConfig.baseId;\n  let shareDbService!: ShareDbService;\n  let defaultFieldId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    cookie = appCtx.cookie;\n    port = process.env.PORT!;\n    shareDbService = app.get(ShareDbService);\n\n    // Create test table\n    const table = await createTable(baseId, {\n      name: 'collaboration-test-table',\n      views: [{ type: ViewType.Grid, name: 'default-view' }],\n    });\n    tableId = table.id;\n    viewId = table.defaultViewId!;\n    defaultFieldId = table.fields[0].id;\n\n    // Enable share view for testing SockJS WebSocket with shareId\n    const shareResult = await apiEnableShareView({ tableId, viewId });\n    shareId = shareResult.data.shareId;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n    await app.close();\n  });\n\n  describeWhenV1('Real-time subscription', () => {\n    let connection: Connection;\n\n    beforeEach(() => {\n      connection = createConnection(shareDbService, cookie, port);\n    });\n\n    afterEach(() => {\n      connection?.close();\n    });\n\n    describe('Record operations', () => {\n      it('should receive insert event when creating records via API', async () => {\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n        const initialCount = query.results.length;\n\n        // Set up event listener before API call\n        const insertPromise = waitForQueryEvent<[Doc<IRecord>[], number]>(query, 'insert');\n\n        // Create record via API\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'test-value' } }],\n        });\n        expect(createResult.status).toBe(201);\n\n        // Wait for insert event\n        const [insertedDocs] = await insertPromise;\n\n        expect(insertedDocs.length).toBeGreaterThan(0);\n        expect(query.results.length).toBe(initialCount + 1);\n\n        // Cleanup\n        await apiDeleteRecord(tableId, createResult.data.records[0].id);\n      });\n\n      it('should receive op event when updating record via API', async () => {\n        // First create a record\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'initial-value' } }],\n        });\n        const recordId = createResult.data.records[0].id;\n\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const doc = connection.get(collection, recordId);\n\n        await new Promise<void>((resolve, reject) => {\n          doc.subscribe((err) => {\n            if (err) reject(err);\n            else resolve();\n          });\n        });\n\n        // Set up op listener\n        const opPromise = waitForDocOp(doc);\n\n        // Update record via API\n        await apiUpdateRecord(tableId, recordId, {\n          fieldKeyType: FieldKeyType.Id,\n          record: { fields: { [defaultFieldId]: 'updated-value' } },\n        });\n\n        // Wait for op event\n        const ops = await opPromise;\n        expect(ops).toBeDefined();\n        expect(ops.length).toBeGreaterThan(0);\n\n        // Cleanup\n        doc.destroy();\n        await apiDeleteRecord(tableId, recordId);\n      });\n\n      it('should receive remove event when deleting record via API', async () => {\n        // First create a record\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'to-delete' } }],\n        });\n        const recordId = createResult.data.records[0].id;\n\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n\n        // Verify record exists in query results\n        const initialDoc = query.results.find((doc) => doc.id === recordId);\n        expect(initialDoc).toBeDefined();\n\n        // Set up remove listener\n        const removePromise = waitForQueryEvent<[Doc<IRecord>[], number]>(query, 'remove');\n\n        // Delete record via API\n        await apiDeleteRecord(tableId, recordId);\n\n        // Wait for remove event\n        const [removedDocs] = await removePromise;\n        expect(removedDocs.some((doc) => doc.id === recordId)).toBe(true);\n      });\n    });\n\n    describe('Field operations', () => {\n      it('should receive insert event when creating field via API', async () => {\n        const collection = `${IdPrefix.Field}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n        const initialCount = query.results.length;\n\n        // Set up event listener\n        const insertPromise = waitForQueryEvent<[Doc<IFieldVo>[], number]>(query, 'insert');\n\n        // Create field via API\n        const fieldResult = await apiCreateField(tableId, {\n          name: 'test-field',\n          type: 'singleLineText' as any,\n        });\n        expect(fieldResult.status).toBe(201);\n\n        // Wait for insert event\n        const [insertedDocs] = await insertPromise;\n        expect(insertedDocs.length).toBeGreaterThan(0);\n        expect(query.results.length).toBe(initialCount + 1);\n\n        // Cleanup\n        await apiDeleteField(tableId, fieldResult.data.id);\n      });\n\n      it('should receive remove event when deleting field via API', async () => {\n        // First create a field\n        const fieldResult = await apiCreateField(tableId, {\n          name: 'field-to-delete',\n          type: 'singleLineText' as any,\n        });\n        const fieldId = fieldResult.data.id;\n\n        const collection = `${IdPrefix.Field}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n\n        // Set up remove listener\n        const removePromise = waitForQueryEvent<[Doc<IFieldVo>[], number]>(query, 'remove');\n\n        // Delete field via API\n        await apiDeleteField(tableId, fieldId);\n\n        // Wait for remove event\n        const [removedDocs] = await removePromise;\n        expect(removedDocs.some((doc) => doc.id === fieldId)).toBe(true);\n      });\n    });\n\n    describe('View operations', () => {\n      it('should be able to subscribe to view collection', async () => {\n        const collection = `${IdPrefix.View}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n\n        // Should have at least the default view\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n        expect(query.results[0].data).toBeDefined();\n      });\n    });\n\n    describe('Multiple subscribers', () => {\n      it('should broadcast changes to all subscribers', async () => {\n        const collection = `${IdPrefix.Record}_${tableId}`;\n\n        // Create two connections\n        const connection1 = createConnection(shareDbService, cookie, port);\n        const connection2 = createConnection(shareDbService, cookie, port);\n\n        const query1 = connection1.createSubscribeQuery(collection, {});\n        const query2 = connection2.createSubscribeQuery(collection, {});\n\n        await Promise.all([waitForQueryReady(query1), waitForQueryReady(query2)]);\n\n        // Set up listeners for both\n        const insert1Promise = waitForQueryEvent<[Doc<IRecord>[], number]>(query1, 'insert');\n        const insert2Promise = waitForQueryEvent<[Doc<IRecord>[], number]>(query2, 'insert');\n\n        // Create record\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'broadcast-test' } }],\n        });\n\n        // Both should receive the event\n        const [[docs1], [docs2]] = await Promise.all([insert1Promise, insert2Promise]);\n\n        expect(docs1.length).toBeGreaterThan(0);\n        expect(docs2.length).toBeGreaterThan(0);\n        expect(docs1[0].id).toBe(docs2[0].id);\n\n        // Cleanup\n        connection1.close();\n        connection2.close();\n        await apiDeleteRecord(tableId, createResult.data.records[0].id);\n      });\n    });\n  });\n\n  describe('Connection resilience', () => {\n    it('should handle rapid subscribe/unsubscribe cycles', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n\n      for (let i = 0; i < 5; i++) {\n        const conn = createConnection(shareDbService, cookie, port);\n        const query = conn.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n\n        conn.close();\n      }\n    });\n\n    it('should handle multiple concurrent subscriptions on same connection', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n\n      const recordCollection = `${IdPrefix.Record}_${tableId}`;\n      const fieldCollection = `${IdPrefix.Field}_${tableId}`;\n      const viewCollection = `${IdPrefix.View}_${tableId}`;\n\n      const recordQuery = conn.createSubscribeQuery(recordCollection, {});\n      const fieldQuery = conn.createSubscribeQuery(fieldCollection, {});\n      const viewQuery = conn.createSubscribeQuery(viewCollection, {});\n\n      await Promise.all([\n        waitForQueryReady(recordQuery),\n        waitForQueryReady(fieldQuery),\n        waitForQueryReady(viewQuery),\n      ]);\n\n      expect(recordQuery.ready).toBe(true);\n      expect(fieldQuery.ready).toBe(true);\n      expect(viewQuery.ready).toBe(true);\n\n      conn.close();\n    });\n  });\n\n  describeWhenV1('SockJS transport compatibility', () => {\n    it('should successfully establish connection via SockJS endpoint', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n\n      // Connection should be established\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const query = conn.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query);\n      expect(query.results.length).toBeGreaterThanOrEqual(1);\n\n      conn.close();\n    });\n\n    it('should handle connection with query parameters', async () => {\n      // Test connection with shareId parameter (used in share view)\n      const conn = shareDbService.connect(undefined, {\n        url: `ws://localhost:${port}/socket?test=param`,\n        headers: { cookie },\n      });\n\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const query = conn.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query);\n      expect(query.ready).toBe(true);\n\n      conn.close();\n    });\n\n    it('should maintain stable connection for extended operations', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n      const collection = `${IdPrefix.Record}_${tableId}`;\n      const query = conn.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query);\n\n      // Perform multiple operations\n      const createdIds: string[] = [];\n      for (let i = 0; i < 3; i++) {\n        const insertPromise = waitForQueryEvent<[Doc<IRecord>[], number]>(query, 'insert');\n\n        const result = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: `stability-test-${i}` } }],\n        });\n        createdIds.push(result.data.records[0].id);\n\n        const [insertedDocs] = await insertPromise;\n        expect(insertedDocs.length).toBeGreaterThan(0);\n      }\n\n      // Cleanup\n      for (const id of createdIds) {\n        await apiDeleteRecord(tableId, id);\n      }\n\n      conn.close();\n    });\n  });\n\n  describe('Error handling and security', () => {\n    describe('Authentication behavior', () => {\n      /**\n       * Note: ShareDB connection establishment doesn't validate auth immediately.\n       * Auth validation happens at query/operation time through middleware.\n       * These tests verify the current behavior.\n       */\n      it('should establish connection without cookie (auth checked at query time)', async () => {\n        // Connect without cookie - connection succeeds, auth checked during query\n        const conn = shareDbService.connect(undefined, {\n          url: `ws://localhost:${port}/socket`,\n          headers: {}, // No cookie\n        });\n\n        // Connection should be established (auth is lazy)\n        await new Promise<void>((resolve) => {\n          if (conn.state === 'connected') {\n            resolve();\n          } else {\n            conn.on('connected', () => resolve());\n          }\n        });\n\n        expect(conn.state).toBe('connected');\n        conn.close();\n      });\n\n      it('should establish connection with invalid cookie (auth checked at query time)', async () => {\n        // Connect with invalid cookie - connection succeeds, auth checked during query\n        const conn = shareDbService.connect(undefined, {\n          url: `ws://localhost:${port}/socket`,\n          headers: { cookie: 'invalid_session=fake_token_12345' },\n        });\n\n        await new Promise<void>((resolve) => {\n          if (conn.state === 'connected') {\n            resolve();\n          } else {\n            conn.on('connected', () => resolve());\n          }\n        });\n\n        expect(conn.state).toBe('connected');\n        conn.close();\n      });\n\n      it('should establish connection with invalid shareId (validated at query time)', async () => {\n        // ShareId validation happens during query execution, not at connection time\n        const conn = shareDbService.connect(undefined, {\n          url: `ws://localhost:${port}/socket?shareId=invalid_share_id_12345`,\n          headers: {},\n        });\n\n        await new Promise<void>((resolve) => {\n          if (conn.state === 'connected') {\n            resolve();\n          } else {\n            conn.on('connected', () => resolve());\n          }\n        });\n\n        expect(conn.state).toBe('connected');\n        conn.close();\n      });\n    });\n\n    describe('Query behavior with different auth states', () => {\n      it('should handle query subscription with valid auth', async () => {\n        const conn = createConnection(shareDbService, cookie, port);\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const query = conn.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n        expect(query.ready).toBe(true);\n\n        conn.close();\n      });\n\n      it('should handle query to non-existent table (returns empty results)', async () => {\n        const conn = createConnection(shareDbService, cookie, port);\n        const fakeTableId = 'tbl_nonexistent_12345';\n        const collection = `${IdPrefix.Record}_${fakeTableId}`;\n        const query = conn.createSubscribeQuery(collection, {});\n\n        // Query may succeed with empty results or error - verify it handles gracefully\n        const result = await new Promise<{ ready: boolean; error?: any }>((resolve) => {\n          const timeout = setTimeout(() => resolve({ ready: false, error: 'Timeout' }), 5000);\n\n          query.once('ready', () => {\n            clearTimeout(timeout);\n            resolve({ ready: true });\n          });\n\n          query.once('error', (err: any) => {\n            clearTimeout(timeout);\n            resolve({ ready: false, error: err });\n          });\n        });\n\n        // Either succeeds with empty or fails - both are valid behaviors\n        expect(result.ready || result.error).toBeTruthy();\n\n        conn.close();\n      });\n\n      it('should handle doc subscription for non-existent record', async () => {\n        const conn = createConnection(shareDbService, cookie, port);\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const fakeRecordId = 'rec_nonexistent_12345';\n        const doc = conn.get(collection, fakeRecordId);\n\n        // Subscribe to non-existent doc - may succeed with null data or error\n        const result = await new Promise<{ subscribed: boolean; error?: any }>((resolve) => {\n          const timeout = setTimeout(() => resolve({ subscribed: false, error: 'Timeout' }), 3000);\n\n          doc.subscribe((err) => {\n            clearTimeout(timeout);\n            if (err) {\n              resolve({ subscribed: false, error: err });\n            } else {\n              resolve({ subscribed: true });\n            }\n          });\n        });\n\n        // Doc subscription behavior varies - verify it handles gracefully\n        expect(result.subscribed || result.error).toBeTruthy();\n\n        doc.destroy();\n        conn.close();\n      });\n    });\n\n    describe('Connection error handling', () => {\n      it('should handle query error event gracefully', async () => {\n        const conn = createConnection(shareDbService, cookie, port);\n        const invalidCollection = 'invalid_collection_format';\n        const query = conn.createSubscribeQuery(invalidCollection, {});\n\n        const errorPromise = new Promise<any>((resolve) => {\n          query.once('error', (err: any) => {\n            resolve(err);\n          });\n        });\n\n        const error = await errorPromise;\n        expect(error).toBeDefined();\n\n        conn.close();\n      });\n\n      it('should emit error for malformed doc subscription', async () => {\n        const conn = createConnection(shareDbService, cookie, port);\n\n        // Try to subscribe to a doc with invalid collection format\n        const doc = conn.get('malformed', 'test');\n\n        await expect(\n          new Promise<void>((resolve, reject) => {\n            const timeout = setTimeout(() => reject(new Error('Timeout')), 3000);\n\n            doc.subscribe((err) => {\n              clearTimeout(timeout);\n              if (err) reject(err);\n              else resolve();\n            });\n          })\n        ).rejects.toThrow();\n\n        doc.destroy();\n        conn.close();\n      });\n    });\n  });\n\n  describe('Disconnection and reconnection', () => {\n    it('should detect connection close and clean up resources', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const query = conn.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query);\n      expect(query.ready).toBe(true);\n\n      // Close connection\n      conn.close();\n\n      // Query should no longer be active after connection close\n      // Note: ShareDB may not immediately update query state\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      // Connection should be closed\n      expect(conn.state).toBe('closed');\n    });\n\n    it('should handle server-initiated disconnect gracefully', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const query = conn.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query);\n\n      // Set up disconnect listener\n      const disconnectPromise = new Promise<void>((resolve) => {\n        conn.on('state', (newState: string) => {\n          if (newState === 'disconnected' || newState === 'closed') {\n            resolve();\n          }\n        });\n      });\n\n      // Force close\n      conn.close();\n\n      await disconnectPromise;\n      expect(['disconnected', 'closed']).toContain(conn.state);\n    });\n\n    it('should allow creating new connection after previous one closed', async () => {\n      // First connection\n      const conn1 = createConnection(shareDbService, cookie, port);\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const query1 = conn1.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query1);\n      expect(query1.results.length).toBeGreaterThanOrEqual(1);\n\n      // Close first connection\n      conn1.close();\n\n      // Wait for cleanup\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      // Create new connection - should work\n      const conn2 = createConnection(shareDbService, cookie, port);\n      const query2 = conn2.createSubscribeQuery(collection, {});\n\n      await waitForQueryReady(query2);\n      expect(query2.results.length).toBeGreaterThanOrEqual(1);\n\n      conn2.close();\n    });\n\n    // V2 uses caching for ShareDB queries, so fresh connections may not immediately see\n    // records created via API until the cache is invalidated\n    it.skipIf(isForceV2)('should maintain data consistency after reconnection', async () => {\n      const collection = `${IdPrefix.Record}_${tableId}`;\n\n      // First connection - get initial state\n      const conn1 = createConnection(shareDbService, cookie, port);\n      const query1 = conn1.createSubscribeQuery(collection, {});\n      await waitForQueryReady(query1);\n      const initialCount = query1.results.length;\n      conn1.close();\n\n      // Create a record while disconnected\n      const createResult = await apiCreateRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [defaultFieldId]: 'reconnect-test' } }],\n      });\n\n      // Reconnect and verify new record is visible\n      const conn2 = createConnection(shareDbService, cookie, port);\n      const query2 = conn2.createSubscribeQuery(collection, {});\n      await waitForQueryReady(query2);\n\n      expect(query2.results.length).toBe(initialCount + 1);\n\n      // Cleanup\n      await apiDeleteRecord(tableId, createResult.data.records[0].id);\n      conn2.close();\n    });\n\n    it('should handle multiple rapid reconnections', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n\n      for (let i = 0; i < 5; i++) {\n        const conn = createConnection(shareDbService, cookie, port);\n        const query = conn.createSubscribeQuery(collection, {});\n\n        await waitForQueryReady(query);\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n\n        conn.close();\n\n        // Minimal delay between reconnections\n        await new Promise((resolve) => setTimeout(resolve, 20));\n      }\n    });\n\n    it('should clean up subscriptions on connection close', async () => {\n      const conn = createConnection(shareDbService, cookie, port);\n\n      // Create multiple subscriptions\n      const recordQuery = conn.createSubscribeQuery(`${IdPrefix.Record}_${tableId}`, {});\n      const fieldQuery = conn.createSubscribeQuery(`${IdPrefix.Field}_${tableId}`, {});\n      const viewQuery = conn.createSubscribeQuery(`${IdPrefix.View}_${tableId}`, {});\n\n      await Promise.all([\n        waitForQueryReady(recordQuery),\n        waitForQueryReady(fieldQuery),\n        waitForQueryReady(viewQuery),\n      ]);\n\n      // All queries should be ready\n      expect(recordQuery.ready).toBe(true);\n      expect(fieldQuery.ready).toBe(true);\n      expect(viewQuery.ready).toBe(true);\n\n      // Close connection - all subscriptions should be cleaned up\n      conn.close();\n\n      // Connection should be closed\n      expect(conn.state).toBe('closed');\n    });\n  });\n\n  /**\n   * SockJS transport fallback tests\n   * These tests verify that all SockJS transports work correctly.\n   * Skipped if sockjs-client package is not available.\n   */\n  describeSockJS('SockJS transport fallback (real client)', () => {\n    /**\n     * Helper: Create SockJS socket connection with specific transports\n     * Note: This tests the transport layer only, not ShareDB operations\n     * (WebSocket transport doesn't support cookies/headers for auth)\n     */\n    const createSockJSSocket = (\n      transports: ISockJSTransport[],\n      connectionTimeout = 10000\n    ): Promise<{ socket: any; transport: string }> => {\n      return new Promise((resolve, reject) => {\n        const url = `http://127.0.0.1:${port}/socket`;\n        const socket = new SockJS(url, undefined, {\n          transports,\n          timeout: 5000,\n        });\n\n        let actualTransport = 'unknown';\n\n        const timeoutId = setTimeout(() => {\n          cleanup();\n          reject(new Error(`SockJS connection timeout (transports: ${transports.join(', ')})`));\n        }, connectionTimeout);\n\n        const cleanup = () => {\n          clearTimeout(timeoutId);\n          socket.onopen = null;\n          socket.onclose = null;\n          socket.onerror = null;\n        };\n\n        socket.onopen = () => {\n          cleanup();\n          // Get the actual transport used\n          actualTransport = (socket as any).transport || 'unknown';\n          resolve({ socket, transport: actualTransport });\n        };\n\n        socket.onerror = (err: any) => {\n          cleanup();\n          reject(new Error(`SockJS error: ${err?.message || 'unknown error'}`));\n        };\n\n        socket.onclose = (event: any) => {\n          cleanup();\n          if (event?.code !== 1000) {\n            reject(\n              new Error(`SockJS closed unexpectedly: code=${event?.code}, reason=${event?.reason}`)\n            );\n          }\n        };\n      });\n    };\n\n    it('should establish connection using WebSocket transport', async () => {\n      // Test that SockJS can establish a WebSocket connection to the server\n      const { socket, transport } = await createSockJSSocket([transportWebsocket]);\n      console.log(`Connected using transport: ${transport}`);\n\n      expect(socket.readyState).toBe(SockJS.OPEN);\n      expect(transport).toBeDefined();\n\n      socket.close();\n    });\n\n    it('should establish connection using XHR streaming transport (fallback)', async () => {\n      const { socket, transport } = await createSockJSSocket([transportXhrStreaming]);\n      console.log(`Connected using transport: ${transport}`);\n\n      expect(socket.readyState).toBe(SockJS.OPEN);\n      expect(transport).toBeDefined();\n\n      socket.close();\n    });\n\n    it('should automatically select best available transport', async () => {\n      // Test with full transport chain - SockJS will try each in order\n      const { socket, transport } = await createSockJSSocket(defaultTransportChain);\n      console.log(`Connected using transport: ${transport}`);\n\n      expect(socket.readyState).toBe(SockJS.OPEN);\n      // Should pick websocket as the best available\n      expect(transport).toBeDefined();\n\n      socket.close();\n    });\n\n    it('should handle graceful close and reconnection', async () => {\n      // First connection\n      const { socket: socket1, transport: transport1 } =\n        await createSockJSSocket(defaultTransportChain);\n      console.log(`First connection using transport: ${transport1}`);\n      expect(socket1.readyState).toBe(SockJS.OPEN);\n\n      // Close first connection\n      socket1.close();\n\n      // Wait for close to complete\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      // Create new connection (simulating reconnect)\n      const { socket: socket2, transport: transport2 } =\n        await createSockJSSocket(defaultTransportChain);\n      console.log(`Second connection using transport: ${transport2}`);\n      expect(socket2.readyState).toBe(SockJS.OPEN);\n\n      socket2.close();\n    });\n\n    it('should send and receive messages via SockJS', async () => {\n      const { socket } = await createSockJSSocket(defaultTransportChain);\n\n      // Create ShareDB connection\n      const connection = new ShareDBClient.Connection(socket as any);\n\n      // Send a message (even without auth, the message should be transmitted)\n      // We just verify the transport layer works, not the auth\n      expect(connection.state).toBe('connecting');\n\n      // Wait for ShareDB to connect\n      await new Promise<void>((resolve) => {\n        if (connection.state === 'connected') {\n          resolve();\n        } else {\n          connection.on('connected', () => resolve());\n        }\n      });\n\n      expect(connection.state).toBe('connected');\n\n      connection.close();\n      socket.close();\n    });\n\n    /**\n     * Helper: Create SockJS socket with shareId for authenticated operations\n     */\n    const createSockJSSocketWithShareId = (\n      shareIdParam: string,\n      transports: ISockJSTransport[] = defaultTransportChain,\n      connectionTimeout = 10000\n    ): Promise<{ socket: any; connection: ShareDBClient.Connection; transport: string }> => {\n      return new Promise((resolve, reject) => {\n        // Use shareId in URL for authentication (instead of cookie)\n        const url = `http://127.0.0.1:${port}/socket?shareId=${shareIdParam}`;\n        const socket = new SockJS(url, undefined, {\n          transports,\n          timeout: 5000,\n        });\n\n        const connection = new ShareDBClient.Connection(socket as any);\n        let actualTransport = 'unknown';\n\n        const timeoutId = setTimeout(() => {\n          cleanup();\n          reject(new Error(`SockJS connection timeout (transports: ${transports.join(', ')})`));\n        }, connectionTimeout);\n\n        const cleanup = () => {\n          clearTimeout(timeoutId);\n        };\n\n        connection.on('connected', () => {\n          cleanup();\n          actualTransport = (socket as any).transport || 'unknown';\n          resolve({ socket, connection, transport: actualTransport });\n        });\n\n        connection.on('error', (err) => {\n          cleanup();\n          const errMsg = (err as unknown as Error)?.message || 'unknown error';\n          reject(new Error(`ShareDB connection error: ${errMsg}`));\n        });\n      });\n    };\n\n    it('should collaborate via WebSocket transport with shareId auth', async () => {\n      // Test WebSocket transport with shareId authentication\n      const { socket, connection, transport } = await createSockJSSocketWithShareId(shareId, [\n        transportWebsocket,\n      ]);\n      console.log(`Collaboration test using transport: ${transport}`);\n\n      try {\n        // Subscribe to view collection (share view allows read access)\n        const viewCollection = `${IdPrefix.View}_${tableId}`;\n        const query = connection.createSubscribeQuery(viewCollection, {});\n\n        await waitForQueryReady(query);\n\n        expect(query.results).not.toBeNull();\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n      } finally {\n        connection.close();\n        socket.close();\n      }\n    });\n\n    it('should collaborate via XHR-streaming transport with shareId auth', async () => {\n      // Test XHR-streaming transport with shareId authentication\n      const { socket, connection, transport } = await createSockJSSocketWithShareId(shareId, [\n        transportXhrStreaming,\n      ]);\n      console.log(`Collaboration test using transport: ${transport}`);\n\n      try {\n        const viewCollection = `${IdPrefix.View}_${tableId}`;\n        const query = connection.createSubscribeQuery(viewCollection, {});\n\n        await waitForQueryReady(query);\n\n        expect(query.results).not.toBeNull();\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n      } finally {\n        connection.close();\n        socket.close();\n      }\n    });\n\n    it('should receive real-time updates via WebSocket with shareId auth', async () => {\n      // Test real-time updates via WebSocket transport\n      const { socket, connection, transport } = await createSockJSSocketWithShareId(shareId, [\n        transportWebsocket,\n      ]);\n      console.log(`Real-time update test using transport: ${transport}`);\n\n      try {\n        const recordCollection = `${IdPrefix.Record}_${tableId}`;\n        const query = connection.createSubscribeQuery(recordCollection, {});\n\n        await waitForQueryReady(query);\n\n        // Set up insert listener\n        const insertPromise = waitForQueryEvent<[Doc<IRecord>[], number]>(query, 'insert');\n\n        // Create record via API (still needs cookie auth for write operations)\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'websocket-realtime-test' } }],\n        });\n\n        // Verify we receive the insert event via WebSocket\n        const [insertedDocs] = await insertPromise;\n        expect(insertedDocs.length).toBeGreaterThan(0);\n        expect(insertedDocs[0].id).toBe(createResult.data.records[0].id);\n\n        // Cleanup\n        await apiDeleteRecord(tableId, createResult.data.records[0].id);\n      } finally {\n        connection.close();\n        socket.close();\n      }\n    });\n\n    it('should broadcast to multiple clients using different transports', async () => {\n      // Test that updates are broadcast to clients using different transports\n      const { socket: wsSocket, connection: wsConn } = await createSockJSSocketWithShareId(\n        shareId,\n        [transportWebsocket]\n      );\n      const { socket: xhrSocket, connection: xhrConn } = await createSockJSSocketWithShareId(\n        shareId,\n        [transportXhrStreaming]\n      );\n\n      try {\n        const recordCollection = `${IdPrefix.Record}_${tableId}`;\n\n        const wsQuery = wsConn.createSubscribeQuery(recordCollection, {});\n        const xhrQuery = xhrConn.createSubscribeQuery(recordCollection, {});\n\n        await Promise.all([waitForQueryReady(wsQuery), waitForQueryReady(xhrQuery)]);\n\n        // Set up insert listeners for both\n        const wsInsertPromise = waitForQueryEvent<[Doc<IRecord>[], number]>(wsQuery, 'insert');\n        const xhrInsertPromise = waitForQueryEvent<[Doc<IRecord>[], number]>(\n          xhrQuery,\n          'insert',\n          10000\n        );\n\n        // Create record\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'multi-transport-test' } }],\n        });\n\n        // Both should receive the event\n        const [[wsDocs], [xhrDocs]] = await Promise.all([wsInsertPromise, xhrInsertPromise]);\n\n        expect(wsDocs[0].id).toBe(createResult.data.records[0].id);\n        expect(xhrDocs[0].id).toBe(createResult.data.records[0].id);\n\n        // Cleanup\n        await apiDeleteRecord(tableId, createResult.data.records[0].id);\n      } finally {\n        wsConn.close();\n        xhrConn.close();\n        wsSocket.close();\n        xhrSocket.close();\n      }\n    });\n\n    it('should handle rapid transport switching (close and reconnect with different transport)', async () => {\n      const transportsToTest: ISockJSTransport[][] = [\n        [transportWebsocket],\n        [transportXhrStreaming],\n        [transportWebsocket],\n        [transportXhrStreaming],\n      ];\n\n      for (const transports of transportsToTest) {\n        const { socket, connection, transport } = await createSockJSSocketWithShareId(\n          shareId,\n          transports\n        );\n        console.log(`Rapid switch test - connected with: ${transport}`);\n\n        const viewCollection = `${IdPrefix.View}_${tableId}`;\n        const query = connection.createSubscribeQuery(viewCollection, {});\n\n        await waitForQueryReady(query);\n        expect(query.results.length).toBeGreaterThanOrEqual(1);\n\n        connection.close();\n        socket.close();\n\n        // Small delay between switches\n        await new Promise((resolve) => setTimeout(resolve, 50));\n      }\n    });\n\n    describe('SockJS error handling', () => {\n      it('should handle invalid URL gracefully', async () => {\n        await expect(\n          new Promise((resolve, reject) => {\n            const url = `http://127.0.0.1:${port}/invalid-endpoint`;\n            const socket = new SockJS(url, undefined, {\n              transports: defaultTransportChain,\n              timeout: 3000,\n            });\n\n            const timeoutId = setTimeout(() => {\n              socket.close();\n              reject(new Error('Connection timeout'));\n            }, 5000);\n\n            socket.onopen = () => {\n              clearTimeout(timeoutId);\n              socket.close();\n              resolve('connected');\n            };\n\n            socket.onclose = (event: any) => {\n              clearTimeout(timeoutId);\n              if (event?.code !== 1000) {\n                reject(new Error(`Connection failed: ${event?.code}`));\n              }\n            };\n          })\n        ).rejects.toThrow();\n      });\n\n      it('should establish SockJS connection with invalid shareId (validated at query time)', async () => {\n        // ShareId validation happens during query, not at connection time\n        const url = `http://127.0.0.1:${port}/socket?shareId=invalid_share_id`;\n        const socket = new SockJS(url, undefined, {\n          transports: defaultTransportChain,\n          timeout: 5000,\n        });\n\n        const connection = new ShareDBClient.Connection(socket as any);\n\n        // Wait for connection - should succeed (auth is lazy)\n        await new Promise<void>((resolve) => {\n          if (connection.state === 'connected') {\n            resolve();\n          } else {\n            connection.on('connected', () => resolve());\n          }\n        });\n\n        expect(connection.state).toBe('connected');\n\n        // Query behavior depends on auth middleware implementation\n        const collection = `${IdPrefix.Record}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n\n        const result = await new Promise<{ ready: boolean; error?: any }>((resolve) => {\n          const timeout = setTimeout(() => resolve({ ready: false, error: 'Timeout' }), 5000);\n\n          query.once('ready', () => {\n            clearTimeout(timeout);\n            resolve({ ready: true });\n          });\n\n          query.once('error', (err: any) => {\n            clearTimeout(timeout);\n            resolve({ ready: false, error: err });\n          });\n        });\n\n        // Verify query handled gracefully (either succeeds or fails with error)\n        expect(result.ready || result.error).toBeTruthy();\n\n        connection.close();\n        socket.close();\n      });\n    });\n\n    describe('SockJS reconnection', () => {\n      it('should successfully reconnect after socket close', async () => {\n        // First connection\n        const { socket: socket1, connection: conn1 } = await createSockJSSocketWithShareId(\n          shareId,\n          defaultTransportChain\n        );\n\n        const collection = `${IdPrefix.View}_${tableId}`;\n        const query1 = conn1.createSubscribeQuery(collection, {});\n        await waitForQueryReady(query1);\n        expect(query1.results.length).toBeGreaterThanOrEqual(1);\n\n        // Close first connection\n        conn1.close();\n        socket1.close();\n\n        // Wait for close to complete\n        await new Promise((resolve) => setTimeout(resolve, 200));\n\n        // Reconnect\n        const { socket: socket2, connection: conn2 } = await createSockJSSocketWithShareId(\n          shareId,\n          defaultTransportChain\n        );\n\n        const query2 = conn2.createSubscribeQuery(collection, {});\n        await waitForQueryReady(query2);\n        expect(query2.results.length).toBeGreaterThanOrEqual(1);\n\n        conn2.close();\n        socket2.close();\n      });\n\n      it('should maintain data consistency after SockJS reconnection', async () => {\n        const recordCollection = `${IdPrefix.Record}_${tableId}`;\n\n        // First connection - get initial count\n        const { socket: socket1, connection: conn1 } = await createSockJSSocketWithShareId(\n          shareId,\n          [transportWebsocket]\n        );\n        const query1 = conn1.createSubscribeQuery(recordCollection, {});\n        await waitForQueryReady(query1);\n        const initialCount = query1.results.length;\n\n        conn1.close();\n        socket1.close();\n\n        // Create record while disconnected (using API with cookie auth)\n        const createResult = await apiCreateRecords(tableId, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: { [defaultFieldId]: 'sockjs-reconnect-test' } }],\n        });\n\n        // Reconnect and verify\n        const { socket: socket2, connection: conn2 } = await createSockJSSocketWithShareId(\n          shareId,\n          [transportWebsocket]\n        );\n        const query2 = conn2.createSubscribeQuery(recordCollection, {});\n        await waitForQueryReady(query2);\n\n        expect(query2.results.length).toBe(initialCount + 1);\n\n        // Cleanup\n        await apiDeleteRecord(tableId, createResult.data.records[0].id);\n        conn2.close();\n        socket2.close();\n      });\n\n      it('should handle socket close event properly', async () => {\n        const { socket, connection } = await createSockJSSocketWithShareId(\n          shareId,\n          defaultTransportChain\n        );\n\n        const collection = `${IdPrefix.View}_${tableId}`;\n        const query = connection.createSubscribeQuery(collection, {});\n        await waitForQueryReady(query);\n\n        // Set up close listener\n        const closePromise = new Promise<void>((resolve) => {\n          socket.onclose = () => resolve();\n        });\n\n        // Close socket\n        socket.close();\n\n        await closePromise;\n        expect(socket.readyState).toBe(SockJS.CLOSED);\n      });\n\n      it('should handle connection state transitions', async () => {\n        const { socket, connection } = await createSockJSSocketWithShareId(\n          shareId,\n          defaultTransportChain\n        );\n\n        expect(connection.state).toBe('connected');\n\n        const stateChanges: string[] = [];\n        connection.on('state', (newState: string) => {\n          stateChanges.push(newState);\n        });\n\n        connection.close();\n        socket.close();\n\n        // Wait for state transitions\n        await new Promise((resolve) => setTimeout(resolve, 100));\n\n        // Should have transitioned to closed/disconnected\n        expect(stateChanges.length).toBeGreaterThan(0);\n        expect(['closed', 'disconnected']).toContain(connection.state);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/comment-count-collapsed-group.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo, IFilter, IGroup } from '@teable/core';\nimport { Colors, FieldKeyType, FieldType, SortFunc } from '@teable/core';\nimport {\n  CommentNodeType,\n  GroupPointType,\n  createComment,\n  getCommentCount,\n} from '@teable/openapi';\nimport type { IGroupHeaderPoint, ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getField,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('OpenAPI Comment count with collapsed groups (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  let sourceTable: ITableFullVo;\n  let hostTable: ITableFullVo;\n  let groupedLookupFieldId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    sourceTable = await createTable(baseId, {\n      name: 'comment_count_group_source',\n      fields: [\n        { name: 'LookupKey', type: FieldType.SingleLineText },\n        {\n          name: 'Category',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'choice-1', name: 'Alpha', color: Colors.Blue },\n              { id: 'choice-2', name: 'Beta', color: Colors.Green },\n              { id: 'choice-3', name: 'Gamma', color: Colors.Orange },\n            ],\n          },\n        },\n      ],\n      records: [\n        { fields: { LookupKey: 'K-1', Category: 'Alpha' } },\n        { fields: { LookupKey: 'K-1', Category: 'Beta' } },\n        { fields: { LookupKey: 'K-2', Category: 'Gamma' } },\n      ],\n    });\n\n    hostTable = await createTable(baseId, {\n      name: 'comment_count_group_host',\n      fields: [{ name: 'LookupKey', type: FieldType.SingleLineText }],\n      records: [{ fields: { LookupKey: 'K-1' } }, { fields: { LookupKey: 'K-2' } }],\n    });\n\n    const sourceKeyField = sourceTable.fields.find(\n      ({ name }) => name === 'LookupKey'\n    ) as IFieldVo;\n    const sourceCategoryField = sourceTable.fields.find(\n      ({ name }) => name === 'Category'\n    ) as IFieldVo;\n    const hostKeyField = hostTable.fields.find(\n      ({ name }) => name === 'LookupKey'\n    ) as IFieldVo;\n\n    const matchByKeyFilter: IFilter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: sourceKeyField.id,\n          operator: 'is',\n          value: { type: 'field', fieldId: hostKeyField.id },\n        },\n      ],\n    };\n\n    const groupedLookupField = await createField(hostTable.id, {\n      name: 'GroupedCategory',\n      type: FieldType.SingleSelect,\n      isLookup: true,\n      isConditionalLookup: true,\n      lookupOptions: {\n        foreignTableId: sourceTable.id,\n        lookupFieldId: sourceCategoryField.id,\n        filter: matchByKeyFilter,\n      },\n    });\n\n    groupedLookupFieldId = groupedLookupField.id;\n    const refreshedLookupField = await getField(hostTable.id, groupedLookupFieldId);\n    expect(refreshedLookupField.isMultipleCellValue).toBe(true);\n\n    await createComment(hostTable.id, hostTable.records[0].id, {\n      content: [\n        {\n          type: CommentNodeType.Paragraph,\n          children: [{ type: CommentNodeType.Text, value: 'host-1' }],\n        },\n      ],\n      quoteId: null,\n    });\n  });\n\n  afterAll(async () => {\n    if (hostTable?.id) {\n      await permanentDeleteTable(baseId, hostTable.id);\n    }\n    if (sourceTable?.id) {\n      await permanentDeleteTable(baseId, sourceTable.id);\n    }\n    await app.close();\n  });\n\n  it('should not throw filterInvalidOperator when collapsed groups are provided', async () => {\n    const groupBy: IGroup = [{ fieldId: groupedLookupFieldId, order: SortFunc.Asc }];\n\n    const groupedRecords = await getRecords(hostTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      groupBy,\n    });\n\n    const firstGroupHeader = groupedRecords.extra?.groupPoints?.find(\n      (point): point is IGroupHeaderPoint =>\n        point.type === GroupPointType.Header && point.depth === 0\n    );\n    expect(firstGroupHeader).toBeDefined();\n\n    const response = await getCommentCount(hostTable.id, {\n      viewId: hostTable.views[0].id,\n      type: 'rec',\n      take: 300,\n      skip: 0,\n      groupBy,\n      collapsedGroupIds: [firstGroupHeader!.id],\n    });\n\n    expect(response.status).toBe(200);\n    expect(Array.isArray(response.data)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/comment.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ICommentVo } from '@teable/openapi';\nimport {\n  createComment,\n  CommentNodeType,\n  getCommentList,\n  updateComment,\n  getCommentDetail,\n  createCommentReaction,\n  deleteCommentReaction,\n  createCommentSubscribe,\n  EmojiSymbol,\n  getCommentSubscribe,\n  deleteCommentSubscribe,\n} from '@teable/openapi';\nimport { createTable, deleteTable, initApp } from './utils/init-app';\n\ndescribe('OpenAPI CommentController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const userId = globalThis.testConfig.userId;\n  let tableId: string;\n  let recordId: string;\n  let comments: ICommentVo[] = [];\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    const { id, records } = await createTable(baseId, { name: 'table' });\n    tableId = id;\n    recordId = records[0].id;\n\n    const commentList = [];\n    for (let i = 0; i < 20; i++) {\n      const result = await createComment(tableId, recordId, {\n        content: [\n          {\n            type: CommentNodeType.Paragraph,\n            children: [{ type: CommentNodeType.Text, value: `${i}` }],\n          },\n        ],\n        quoteId: null,\n      });\n      commentList.push(result.data);\n    }\n    comments = commentList;\n  });\n  afterEach(async () => {\n    await deleteTable(baseId, tableId);\n  });\n\n  it('should achieve the whole comment crud flow', async () => {\n    // create comment\n    const createRes = await createComment(tableId, recordId, {\n      content: [\n        {\n          type: CommentNodeType.Paragraph,\n          children: [{ type: CommentNodeType.Text, value: 'hello world' }],\n        },\n      ],\n      quoteId: null,\n    });\n\n    const result = await getCommentDetail(tableId, recordId, createRes.data.id);\n    const { content, id: commentId } = result?.data as ICommentVo;\n    expect(content).toEqual([\n      {\n        type: CommentNodeType.Paragraph,\n        children: [{ type: CommentNodeType.Text, value: 'hello world' }],\n      },\n    ]);\n\n    // update comment\n    await updateComment(tableId, recordId, commentId, {\n      content: [\n        {\n          type: CommentNodeType.Paragraph,\n          children: [{ type: CommentNodeType.Text, value: 'Good night, Paris.' }],\n        },\n      ],\n    });\n\n    const updatedResult = await getCommentDetail(tableId, recordId, createRes.data.id);\n\n    expect(updatedResult?.data?.content).toEqual([\n      {\n        type: CommentNodeType.Paragraph,\n        children: [{ type: CommentNodeType.Text, value: 'Good night, Paris.' }],\n      },\n    ]);\n\n    // create reaction\n    await createCommentReaction(tableId, recordId, createRes.data.id, {\n      reaction: EmojiSymbol.eyes,\n    });\n\n    const createdReactionResult = await getCommentDetail(tableId, recordId, createRes.data.id);\n    expect(createdReactionResult?.data?.reaction?.[0]?.reaction).toEqual(EmojiSymbol.eyes);\n    expect(createdReactionResult?.data?.reaction?.[0]?.user?.[0]?.id).toEqual(userId);\n\n    // delete reaction\n    await deleteCommentReaction(tableId, recordId, createRes.data.id, {\n      reaction: EmojiSymbol.eyes,\n    });\n\n    const deletedReactionResult = await getCommentDetail(tableId, recordId, createRes.data.id);\n    expect(deletedReactionResult?.data?.reaction).toBeNull();\n  });\n\n  describe('get comment list with cursor', async () => {\n    it('should get latest comments when cursor is null', async () => {\n      const latestRes = await getCommentList(tableId, recordId, {\n        cursor: null,\n        take: 5,\n      });\n\n      expect(latestRes.data.comments.length).toBe(5);\n      expect(latestRes.data.comments.map((com) => com.id)).toEqual(\n        comments.slice(-5).map((com) => com.id)\n      );\n      expect(latestRes.data.nextCursor).toBe(comments.slice(-6).shift()?.id);\n    });\n\n    it('should return next 20 comments', async () => {\n      const nextCursorCommentRes = await getCommentList(tableId, recordId, {\n        cursor: comments[14].id,\n        take: 20,\n      });\n\n      expect(nextCursorCommentRes.data.comments.length).toBe(15);\n      expect(nextCursorCommentRes.data.comments.map((com) => com.id)).toEqual(\n        comments.slice(0, 15).map((com) => com.id)\n      );\n      expect(nextCursorCommentRes.data.nextCursor).toBeNull();\n    });\n    it('should get comment by cursor with backward direction', async () => {\n      const backwardRes = await getCommentList(tableId, recordId, {\n        cursor: comments[0].id,\n        take: 10,\n        direction: 'backward',\n      });\n      expect(backwardRes.data.comments.length).toBe(10);\n      expect(backwardRes.data.comments.map((com) => com.id)).toEqual(\n        comments.slice(0, 10).map((com) => com.id)\n      );\n      expect(backwardRes.data.nextCursor).toBe(comments[10].id);\n    });\n\n    it('should return the comment by cursor exclude cursor', async () => {\n      const result = await getCommentList(tableId, recordId, {\n        cursor: comments[0].id,\n        take: 10,\n        direction: 'backward',\n        includeCursor: false,\n      });\n\n      expect(result.data.comments.length).toBe(10);\n      expect(result.data.comments.map((com) => com.id)).toEqual(\n        comments.slice(1, 11).map((com) => com.id)\n      );\n      expect(result.data.nextCursor).toBe(comments[11].id);\n    });\n\n    it('should get comment list with mention user and image', async () => {\n      await createComment(tableId, recordId, {\n        content: [\n          {\n            type: CommentNodeType.Paragraph,\n            children: [\n              { type: CommentNodeType.Text, value: 'hello' },\n              {\n                type: CommentNodeType.Mention,\n                value: userId,\n                name: 'a',\n                avatar: 'b',\n              },\n            ],\n          },\n          {\n            type: CommentNodeType.Img,\n            path: 'comment/xxxxxx',\n            url: 'c',\n          },\n        ],\n        quoteId: null,\n      });\n\n      const result = await getCommentList(tableId, recordId, {\n        cursor: null,\n        take: 1,\n        direction: 'forward',\n      });\n      expect(result.data.comments[0].content).toEqual([\n        {\n          type: CommentNodeType.Paragraph,\n          children: [\n            { type: CommentNodeType.Text, value: 'hello' },\n            {\n              type: CommentNodeType.Mention,\n              value: userId,\n              name: globalThis.testConfig.userName,\n              avatar: expect.any(String),\n            },\n          ],\n        },\n        {\n          type: CommentNodeType.Img,\n          path: 'comment/xxxxxx',\n          url: expect.any(String),\n        },\n      ]);\n      expect(result.data.comments[0].createdBy).toEqual({\n        id: userId,\n        name: globalThis.testConfig.userName,\n        avatar: expect.any(String),\n      });\n    });\n  });\n\n  describe('comment subscribe relative', () => {\n    it('should subscribe the record comment', async () => {\n      await createCommentSubscribe(tableId, recordId);\n      const result = await getCommentSubscribe(tableId, recordId);\n      expect(result?.data?.createdBy).toBe(userId);\n    });\n\n    it('should return null when can not found the subscribe info', async () => {\n      await createCommentSubscribe(tableId, recordId);\n      const result = await getCommentSubscribe(tableId, recordId);\n      expect(result?.data?.createdBy).toBe(userId);\n\n      await deleteCommentSubscribe(tableId, recordId);\n      const subscribeInfo = await getCommentSubscribe(tableId, recordId);\n      // actually the subscribe info is null but, there is no idea to return ''.\n      expect(subscribeInfo.data).toEqual('');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/comprehensive-aggregation.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IRatingFieldOptions, IViewVo } from '@teable/core';\nimport {\n  Colors,\n  DateFormattingPreset,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  StatisticsFunc,\n  TimeFormatting,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getAggregation, createField, createRecords, getView } from '@teable/openapi';\nimport { createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\ndescribe('Comprehensive Aggregation Tests (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let mainTable: ITableFullVo;\n  let relatedTable: ITableFullVo;\n  let linkField: any;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    // Create related table first\n    relatedTable = await createTable(baseId, {\n      name: 'Related Table',\n      fields: [\n        {\n          name: 'Related Text',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Related Number',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n      ],\n      records: [\n        { fields: { 'Related Text': 'Related Item 1', 'Related Number': 100 } },\n        { fields: { 'Related Text': 'Related Item 2', 'Related Number': 200 } },\n        { fields: { 'Related Text': 'Related Item 3', 'Related Number': 300 } },\n      ],\n    });\n\n    // Create main table with comprehensive field types\n    mainTable = await createTable(baseId, {\n      name: 'Comprehensive Aggregation Test Table',\n      records: [], // 不创建默认记录，我们会手动创建\n      fields: [\n        {\n          name: 'Text Field',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Long Text Field',\n          type: FieldType.LongText,\n        },\n        {\n          name: 'Number Field',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n        {\n          name: 'Date Field',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'Asia/Singapore',\n            },\n          },\n        },\n        {\n          name: 'Checkbox Field',\n          type: FieldType.Checkbox,\n        },\n        {\n          name: 'Single Select Field',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'opt1', name: 'Option 1', color: Colors.Blue },\n              { id: 'opt2', name: 'Option 2', color: Colors.Green },\n              { id: 'opt3', name: 'Option 3', color: Colors.Red },\n            ],\n          },\n        },\n        {\n          name: 'Multiple Select Field',\n          type: FieldType.MultipleSelect,\n          options: {\n            choices: [\n              { id: 'tag1', name: 'Tag 1', color: Colors.Cyan },\n              { id: 'tag2', name: 'Tag 2', color: Colors.Yellow },\n              { id: 'tag3', name: 'Tag 3', color: Colors.Purple },\n            ],\n          },\n        },\n        {\n          name: 'Rating Field',\n          type: FieldType.Rating,\n          options: {\n            icon: 'star',\n            color: 'yellowBright',\n            max: 5,\n          } as IRatingFieldOptions,\n        },\n        {\n          name: 'User Field',\n          type: FieldType.User,\n        },\n        {\n          name: 'Multiple User Field',\n          type: FieldType.User,\n          options: {\n            isMultiple: true,\n            shouldNotify: false,\n          },\n        },\n      ],\n    });\n\n    // Create link field\n    linkField = await createField(mainTable.id, {\n      name: 'Link Field',\n      type: FieldType.Link,\n      options: {\n        foreignTableId: relatedTable.id,\n        relationship: Relationship.ManyOne,\n      },\n    });\n\n    // Add comprehensive test records to main table\n    const testRecords = [\n      // Record 1: Complete data\n      {\n        fields: {\n          'Text Field': 'Sample Text A',\n          'Long Text Field': 'This is a long text content for comprehensive testing',\n          'Number Field': 100.5,\n          'Date Field': '2024-01-15',\n          'Checkbox Field': true,\n          'Single Select Field': 'Option 1',\n          'Multiple Select Field': ['Tag 1', 'Tag 2'],\n          'Rating Field': 5,\n          'User Field': { id: globalThis.testConfig.userId, title: 'Test User' },\n          'Multiple User Field': [{ id: globalThis.testConfig.userId, title: 'Test User' }],\n          'Link Field': { id: relatedTable.records[0].id },\n        },\n      },\n      // Record 2: Partial data\n      {\n        fields: {\n          'Text Field': 'Sample Text B',\n          'Number Field': 250.75,\n          'Date Field': '2024-02-20',\n          'Checkbox Field': false,\n          'Single Select Field': 'Option 2',\n          'Multiple Select Field': ['Tag 2', 'Tag 3'],\n          'Rating Field': 3,\n          'Link Field': { id: relatedTable.records[1].id },\n        },\n      },\n      // Record 3: Different values\n      {\n        fields: {\n          'Text Field': 'Sample Text C',\n          'Long Text Field': 'Another long text for testing purposes',\n          'Number Field': 75.25,\n          'Date Field': '2024-03-10',\n          'Checkbox Field': true,\n          'Single Select Field': 'Option 1',\n          'Rating Field': 4,\n          'User Field': { id: globalThis.testConfig.userId, title: 'Test User' },\n          'Link Field': { id: relatedTable.records[2].id },\n        },\n      },\n      // Record 4: Minimal data\n      {\n        fields: {\n          'Text Field': 'Sample Text D',\n          'Number Field': 0,\n          'Checkbox Field': false,\n          'Rating Field': 1,\n        },\n      },\n      // Record 5: Empty/null values\n      {\n        fields: {\n          'Number Field': 500,\n          'Date Field': '2024-04-05',\n          'Checkbox Field': true,\n          'Rating Field': 2,\n        },\n      },\n      // Record 6: Duplicate text for unique testing\n      {\n        fields: {\n          'Text Field': 'Sample Text A', // Duplicate\n          'Number Field': 150,\n          'Single Select Field': 'Option 3',\n          'Rating Field': 5,\n        },\n      },\n    ];\n\n    await createRecords(mainTable.id, { records: testRecords });\n\n    // Refresh table data to get updated records\n    const updatedTable = await createTable(baseId, { name: 'temp' });\n    await permanentDeleteTable(baseId, updatedTable.id);\n  });\n\n  afterEach(async () => {\n    if (mainTable?.id) {\n      await permanentDeleteTable(baseId, mainTable.id);\n    }\n    if (relatedTable?.id) {\n      await permanentDeleteTable(baseId, relatedTable.id);\n    }\n  });\n\n  // Helper function to get aggregation results\n  async function getAggregationResult(\n    tableId: string,\n    viewId: string,\n    fieldId: string,\n    statisticFunc: StatisticsFunc\n  ) {\n    const result = await getAggregation(tableId, {\n      viewId,\n      field: { [statisticFunc]: [fieldId] },\n    });\n    return result.data;\n  }\n\n  // Helper function to verify column meta\n  async function verifyColumnMeta(tableId: string, viewId: string) {\n    const view: IViewVo = (await getView(tableId, viewId)).data;\n    expect(view.columnMeta).toBeDefined();\n    return view;\n  }\n\n  describe('Column Meta Verification', () => {\n    test('should have correct column metadata structure', async () => {\n      const view = await verifyColumnMeta(mainTable.id, mainTable.views[0].id);\n\n      // Verify that all fields have column metadata\n      const fieldIds = mainTable.fields.map((f) => f.id);\n      fieldIds.forEach((fieldId) => {\n        expect(view.columnMeta[fieldId]).toBeDefined();\n        expect(view.columnMeta[fieldId].order).toBeDefined();\n      });\n    });\n  });\n\n  describe('Text Field Aggregation', () => {\n    let textFieldId: string;\n\n    beforeEach(() => {\n      textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.Count\n      );\n\n      expect(result.aggregations).toBeDefined();\n      expect(result.aggregations!.length).toBe(1);\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.fieldId).toBe(textFieldId);\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6); // Total records\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(1); // One record with empty text field\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(5); // Five records with text field filled\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(4); // Four unique text values (one duplicate)\n    });\n\n    test('should calculate percentEmpty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.PercentEmpty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentEmpty);\n      expect(aggregation.total?.value).toBeCloseTo(16.67, 1); // 1/6 * 100\n    });\n\n    test('should calculate percentFilled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.PercentFilled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentFilled);\n      expect(aggregation.total?.value).toBeCloseTo(83.33, 1); // 5/6 * 100\n    });\n\n    test('should calculate percentUnique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.PercentUnique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentUnique);\n      expect(aggregation.total?.value).toBeCloseTo(66.67, 1); // 4/6 * 100\n    });\n  });\n\n  describe('Number Field Aggregation', () => {\n    let numberFieldId: string;\n\n    beforeEach(() => {\n      numberFieldId = mainTable.fields.find((f) => f.name === 'Number Field')!.id;\n    });\n\n    test('should calculate sum correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Sum\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Sum);\n      // Sum: 100.50 + 250.75 + 75.25 + 0 + 500 + 150 = 1076.50\n      expect(aggregation.total?.value).toBeCloseTo(1076.5, 2);\n    });\n\n    test('should calculate average correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Average\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Average);\n      // Average: 1076.50 / 6 = 179.42\n      expect(aggregation.total?.value).toBeCloseTo(179.42, 2);\n    });\n\n    test('should calculate min correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Min\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Min);\n      expect(aggregation.total?.value).toBe(0);\n    });\n\n    test('should calculate max correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Max\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Max);\n      expect(aggregation.total?.value).toBe(500);\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(0); // All records have number values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        numberFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(6); // All number values are unique\n    });\n  });\n\n  describe('Date Field Aggregation', () => {\n    let dateFieldId: string;\n\n    beforeEach(() => {\n      dateFieldId = mainTable.fields.find((f) => f.name === 'Date Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(2); // Two records without dates\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(4); // Four records with dates\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(4); // All date values are unique\n    });\n\n    test('should calculate earliestDate correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.EarliestDate\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.EarliestDate);\n      expect(aggregation.total?.value).toBe('2024-01-14T16:00:00.000Z'); // Adjusted for timezone\n    });\n\n    test('should calculate latestDate correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.LatestDate\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.LatestDate);\n      expect(aggregation.total?.value).toBe('2024-04-04T16:00:00.000Z'); // Adjusted for timezone\n    });\n\n    test('should calculate dateRangeOfDays correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.DateRangeOfDays\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.DateRangeOfDays);\n      // From 2024-01-15 to 2024-04-05 = 81 days\n      expect(aggregation.total?.value).toBe(81);\n    });\n\n    test('should calculate dateRangeOfMonths correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        dateFieldId,\n        StatisticsFunc.DateRangeOfMonths\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.DateRangeOfMonths);\n      // From 2024-01-14 to 2024-04-04 = approximately 2 months (adjusted for timezone)\n      expect(aggregation.total?.value).toBe(2);\n    });\n  });\n\n  describe('Checkbox Field Aggregation', () => {\n    let checkboxFieldId: string;\n\n    beforeEach(() => {\n      checkboxFieldId = mainTable.fields.find((f) => f.name === 'Checkbox Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        checkboxFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate checked correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        checkboxFieldId,\n        StatisticsFunc.Checked\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Checked);\n      expect(aggregation.total?.value).toBe(3); // Three records with checkbox checked\n    });\n\n    test('should calculate unChecked correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        checkboxFieldId,\n        StatisticsFunc.UnChecked\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.UnChecked);\n      expect(aggregation.total?.value).toBe(3); // Three records with checkbox unchecked\n    });\n\n    test('should calculate percentChecked correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        checkboxFieldId,\n        StatisticsFunc.PercentChecked\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentChecked);\n      expect(aggregation.total?.value).toBeCloseTo(50, 1); // 3/6 * 100\n    });\n\n    test('should calculate percentUnChecked correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        checkboxFieldId,\n        StatisticsFunc.PercentUnChecked\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentUnChecked);\n      expect(aggregation.total?.value).toBeCloseTo(50, 1); // 3/6 * 100\n    });\n  });\n\n  describe('Single Select Field Aggregation', () => {\n    let singleSelectFieldId: string;\n\n    beforeEach(() => {\n      singleSelectFieldId = mainTable.fields.find((f) => f.name === 'Single Select Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(2); // Two records without single select values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(4); // Four records with single select values\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(3); // Three unique select options\n    });\n\n    test('should calculate percentEmpty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.PercentEmpty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentEmpty);\n      expect(aggregation.total?.value).toBeCloseTo(33.33, 1); // 2/6 * 100\n    });\n\n    test('should calculate percentFilled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.PercentFilled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentFilled);\n      expect(aggregation.total?.value).toBeCloseTo(66.67, 1); // 4/6 * 100\n    });\n\n    test('should calculate percentUnique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        singleSelectFieldId,\n        StatisticsFunc.PercentUnique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentUnique);\n      expect(aggregation.total?.value).toBeCloseTo(50, 1); // 3/6 * 100\n    });\n  });\n\n  describe('Multiple Select Field Aggregation', () => {\n    let multipleSelectFieldId: string;\n\n    beforeEach(() => {\n      multipleSelectFieldId = mainTable.fields.find((f) => f.name === 'Multiple Select Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleSelectFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleSelectFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(4); // Four records without multiple select values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleSelectFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(2); // Two records with multiple select values\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleSelectFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(3); // Three unique tags: Tag 1, Tag 2, Tag 3\n    });\n  });\n\n  describe('Rating Field Aggregation', () => {\n    let ratingFieldId: string;\n\n    beforeEach(() => {\n      ratingFieldId = mainTable.fields.find((f) => f.name === 'Rating Field')!.id;\n    });\n\n    test('should calculate sum correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        ratingFieldId,\n        StatisticsFunc.Sum\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Sum);\n      // Sum: 5 + 3 + 4 + 1 + 2 + 5 = 20\n      expect(aggregation.total?.value).toBe(20);\n    });\n\n    test('should calculate average correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        ratingFieldId,\n        StatisticsFunc.Average\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Average);\n      // Average: 20 / 6 = 3.33\n      expect(aggregation.total?.value).toBeCloseTo(3.33, 2);\n    });\n\n    test('should calculate min correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        ratingFieldId,\n        StatisticsFunc.Min\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Min);\n      expect(aggregation.total?.value).toBe(1);\n    });\n\n    test('should calculate max correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        ratingFieldId,\n        StatisticsFunc.Max\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Max);\n      expect(aggregation.total?.value).toBe(5);\n    });\n  });\n\n  describe('User Field Aggregation', () => {\n    let userFieldId: string;\n\n    beforeEach(() => {\n      userFieldId = mainTable.fields.find((f) => f.name === 'User Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        userFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        userFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(4); // Four records without user values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        userFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(2); // Two records with user values\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        userFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(1); // One unique user (we only use globalThis.testConfig.userId)\n    });\n  });\n\n  describe('Multiple User Field Aggregation', () => {\n    let multipleUserFieldId: string;\n\n    beforeEach(() => {\n      multipleUserFieldId = mainTable.fields.find((f) => f.name === 'Multiple User Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleUserFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleUserFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(5); // Five records without multiple user values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        multipleUserFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(1); // One record with multiple user values\n    });\n  });\n\n  describe('Link Field Aggregation', () => {\n    let linkFieldId: string;\n\n    beforeEach(() => {\n      linkFieldId = linkField.data.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        linkFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        linkFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(3); // Three records without link values\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        linkFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(3); // Three records with link values\n    });\n\n    test('should calculate percentEmpty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        linkFieldId,\n        StatisticsFunc.PercentEmpty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentEmpty);\n      expect(aggregation.total?.value).toBeCloseTo(50, 1); // 3/6 * 100\n    });\n\n    test('should calculate percentFilled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        linkFieldId,\n        StatisticsFunc.PercentFilled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.PercentFilled);\n      expect(aggregation.total?.value).toBeCloseTo(50, 1); // 3/6 * 100\n    });\n  });\n\n  describe('Error Handling', () => {\n    test('should handle invalid field ID', async () => {\n      await expect(\n        getAggregationResult(\n          mainTable.id,\n          mainTable.views[0].id,\n          'invalid-field-id',\n          StatisticsFunc.Count\n        )\n      ).rejects.toThrow();\n    });\n\n    test('should handle unsupported aggregation function for field type', async () => {\n      const textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n\n      // Text fields don't support Sum aggregation\n      await expect(\n        getAggregationResult(mainTable.id, mainTable.views[0].id, textFieldId, StatisticsFunc.Sum)\n      ).rejects.toThrow();\n    });\n\n    test('should handle invalid table ID', async () => {\n      const textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n\n      await expect(\n        getAggregationResult(\n          'invalid-table-id',\n          mainTable.views[0].id,\n          textFieldId,\n          StatisticsFunc.Count\n        )\n      ).rejects.toThrow();\n    });\n\n    test('should handle invalid view ID', async () => {\n      const textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n\n      await expect(\n        getAggregationResult(mainTable.id, 'invalid-view-id', textFieldId, StatisticsFunc.Count)\n      ).rejects.toThrow();\n    });\n  });\n\n  describe('Complex Aggregation Scenarios', () => {\n    test('should handle multiple field aggregations in single request', async () => {\n      const textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n      const numberFieldId = mainTable.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const result = await getAggregation(mainTable.id, {\n        viewId: mainTable.views[0].id,\n        field: {\n          [StatisticsFunc.Count]: [textFieldId], // Text field uses count\n          [StatisticsFunc.Sum]: [numberFieldId], // Number field uses sum\n        },\n      });\n\n      expect(result.data.aggregations).toBeDefined();\n      expect(result.data.aggregations!.length).toBe(2);\n\n      // Find text field aggregation\n      const textAggregation = result.data.aggregations!.find((a) => a.fieldId === textFieldId);\n      expect(textAggregation?.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(textAggregation?.total?.value).toBe(6);\n\n      // Find number field aggregation\n      const numberAggregation = result.data.aggregations!.find((a) => a.fieldId === numberFieldId);\n      expect(numberAggregation?.total?.aggFunc).toBe(StatisticsFunc.Sum);\n      expect(numberAggregation?.total?.value).toBeCloseTo(1076.5, 2);\n    });\n\n    test('should verify API response format consistency', async () => {\n      const textFieldId = mainTable.fields.find((f) => f.name === 'Text Field')!.id;\n\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        textFieldId,\n        StatisticsFunc.Count\n      );\n\n      // Verify response structure\n      expect(result).toHaveProperty('aggregations');\n      expect(Array.isArray(result.aggregations)).toBe(true);\n      expect(result.aggregations!.length).toBeGreaterThan(0);\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation).toHaveProperty('fieldId');\n      expect(aggregation).toHaveProperty('total');\n      expect(aggregation.total).toHaveProperty('aggFunc');\n      expect(aggregation.total).toHaveProperty('value');\n\n      // Verify field ID format\n      expect(aggregation.fieldId).toMatch(/^fld/);\n      expect(typeof aggregation.total?.value).toBe('number');\n    });\n\n    test('should handle empty table aggregations', async () => {\n      // Create a new empty table for this test\n      const emptyTable = await createTable(baseId, {\n        name: 'Empty Table',\n        fields: [\n          {\n            name: 'Empty Text Field',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [], // Explicitly specify empty records array\n      });\n\n      try {\n        const textFieldId = emptyTable.fields.find((f) => f.name === 'Empty Text Field')!.id;\n\n        const result = await getAggregationResult(\n          emptyTable.id,\n          emptyTable.views[0].id,\n          textFieldId,\n          StatisticsFunc.Count\n        );\n\n        const aggregation = result.aggregations![0];\n        expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n        expect(aggregation.total?.value).toBe(0);\n      } finally {\n        await permanentDeleteTable(baseId, emptyTable.id);\n      }\n    });\n  });\n\n  describe('Long Text Field Aggregation', () => {\n    let longTextFieldId: string;\n\n    beforeEach(() => {\n      longTextFieldId = mainTable.fields.find((f) => f.name === 'Long Text Field')!.id;\n    });\n\n    test('should calculate count correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        longTextFieldId,\n        StatisticsFunc.Count\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Count);\n      expect(aggregation.total?.value).toBe(6);\n    });\n\n    test('should calculate empty correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        longTextFieldId,\n        StatisticsFunc.Empty\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Empty);\n      expect(aggregation.total?.value).toBe(4); // Four records without long text\n    });\n\n    test('should calculate filled correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        longTextFieldId,\n        StatisticsFunc.Filled\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Filled);\n      expect(aggregation.total?.value).toBe(2); // Two records with long text\n    });\n\n    test('should calculate unique correctly', async () => {\n      const result = await getAggregationResult(\n        mainTable.id,\n        mainTable.views[0].id,\n        longTextFieldId,\n        StatisticsFunc.Unique\n      );\n\n      const aggregation = result.aggregations![0];\n      expect(aggregation.total?.aggFunc).toBe(StatisticsFunc.Unique);\n      expect(aggregation.total?.value).toBe(2); // Two unique long text values\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/comprehensive-field-filter.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFilter, IOperator, IRatingFieldOptions } from '@teable/core';\nimport {\n  and,\n  FieldKeyType,\n  FieldType,\n  Colors,\n  DateFormattingPreset,\n  TimeFormatting,\n  NumberFormattingType,\n  Relationship,\n  // Filter operators\n  is,\n  isNot,\n  contains,\n  doesNotContain,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isEmpty,\n  isNotEmpty,\n  isAnyOf,\n  isNoneOf,\n  hasAnyOf,\n  hasAllOf,\n  hasNoneOf,\n  isExactly,\n  isNotExactly,\n  isAfter,\n  isBefore,\n  isOnOrAfter,\n  isOnOrBefore,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getRecords as apiGetRecords, createField, createRecords } from '@teable/openapi';\nimport { createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\ndescribe('Comprehensive Field Filter Tests (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let mainTable: ITableFullVo;\n  let relatedTable: ITableFullVo;\n  let linkField: any;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  beforeEach(async () => {\n    // Create fresh tables and data for each test to ensure isolation\n\n    // Create related table first\n    relatedTable = await createTable(baseId, {\n      name: 'Related Table',\n      fields: [\n        {\n          name: 'Related Text',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Related Number',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n        {\n          name: 'Related Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        },\n        {\n          name: 'Related Checkbox',\n          type: FieldType.Checkbox,\n        },\n      ],\n      records: [\n        {\n          fields: {\n            'Related Text': 'Related Item 1',\n            'Related Number': 100,\n            'Related Date': '2024-01-01',\n            'Related Checkbox': true,\n          },\n        },\n        {\n          fields: {\n            'Related Text': 'Related Item 2',\n            'Related Number': 200,\n            'Related Date': '2024-02-01',\n            'Related Checkbox': false,\n          },\n        },\n        {\n          fields: {\n            'Related Text': 'Related Item 3',\n            'Related Number': 300,\n            'Related Date': '2024-03-01',\n            'Related Checkbox': null,\n          },\n        },\n      ],\n    });\n\n    // Create main table with all field types\n    mainTable = await createTable(baseId, {\n      name: 'Main Table',\n      records: [], // Prevent default records from being created\n      fields: [\n        {\n          name: 'Text Field',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Long Text Field',\n          type: FieldType.LongText,\n        },\n        {\n          name: 'Number Field',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n        {\n          name: 'Date Field',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        },\n        {\n          name: 'Checkbox Field',\n          type: FieldType.Checkbox,\n        },\n        {\n          name: 'Single Select Field',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'opt1', name: 'Option 1', color: Colors.Red },\n              { id: 'opt2', name: 'Option 2', color: Colors.Blue },\n              { id: 'opt3', name: 'Option 3', color: Colors.Green },\n            ],\n          },\n        },\n        {\n          name: 'Multiple Select Field',\n          type: FieldType.MultipleSelect,\n          options: {\n            choices: [\n              { id: 'tag1', name: 'Tag 1', color: Colors.Red },\n              { id: 'tag2', name: 'Tag 2', color: Colors.Blue },\n              { id: 'tag3', name: 'Tag 3', color: Colors.Green },\n            ],\n          },\n        },\n        {\n          name: 'Rating Field',\n          type: FieldType.Rating,\n          options: {\n            icon: 'star',\n            color: 'yellowBright',\n            max: 5,\n          } as IRatingFieldOptions,\n        },\n      ],\n    });\n\n    // Create link field\n    linkField = await createField(mainTable.id, {\n      name: 'Link Field',\n      type: FieldType.Link,\n      options: {\n        foreignTableId: relatedTable.id,\n        relationship: Relationship.ManyOne,\n      },\n    });\n\n    // Get field IDs for formula references\n    const numberFieldId = mainTable.fields.find((f) => f.name === 'Number Field')!.id;\n\n    // Create formula fields\n    const generatedFormulaField = await createField(mainTable.id, {\n      name: 'Generated Formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberFieldId}} * 2`,\n      },\n    });\n\n    const selectFormulaField = await createField(mainTable.id, {\n      name: 'Select Formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `IF({${numberFieldId}} > 20, \"High\", \"Low\")`,\n      },\n    });\n\n    // Update mainTable.fields to include the new fields\n    mainTable.fields.push(linkField.data);\n    mainTable.fields.push(generatedFormulaField.data);\n    mainTable.fields.push(selectFormulaField.data);\n\n    // Add test records to main table\n    const records = [\n      {\n        fields: {\n          'Text Field': 'Test Text 1',\n          'Long Text Field': 'This is a long text content for testing',\n          'Number Field': 10.5,\n          'Date Field': '2024-01-15',\n          'Checkbox Field': true,\n          'Single Select Field': 'Option 1',\n          'Multiple Select Field': ['Tag 1', 'Tag 2'],\n          'Rating Field': 4,\n          'Link Field': { id: relatedTable.records[0].id },\n        },\n      },\n      {\n        fields: {\n          'Text Field': 'Test Text 2',\n          'Long Text Field': 'Another long text for testing purposes',\n          'Number Field': 25.75,\n          'Date Field': '2024-02-20',\n          'Checkbox Field': false,\n          'Single Select Field': 'Option 2',\n          'Multiple Select Field': ['Tag 2', 'Tag 3'],\n          'Rating Field': 3,\n          'Link Field': { id: relatedTable.records[1].id },\n        },\n      },\n      {\n        fields: {\n          'Text Field': null,\n          'Long Text Field': null,\n          'Number Field': null,\n          'Date Field': null,\n          'Checkbox Field': null,\n          'Single Select Field': null,\n          'Multiple Select Field': null,\n          'Rating Field': null,\n          'Link Field': null,\n        },\n      },\n    ];\n\n    for (const record of records) {\n      await createRecords(mainTable.id, { fieldKeyType: FieldKeyType.Name, records: [record] });\n    }\n\n    // No need to refresh table data, fields are already available\n  });\n\n  afterEach(async () => {\n    // Clean up tables after each test\n    if (mainTable?.id) {\n      await permanentDeleteTable(baseId, mainTable.id);\n    }\n    if (relatedTable?.id) {\n      await permanentDeleteTable(baseId, relatedTable.id);\n    }\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  async function getFilterRecord(tableId: string, filter: IFilter) {\n    return (\n      await apiGetRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        filter: filter,\n      })\n    ).data;\n  }\n\n  const doTest = async (\n    fieldName: string,\n    operator: IOperator,\n    queryValue: any,\n    expectedLength: number,\n    expectedRecordMatchers?: Array<Record<string, any>>\n  ) => {\n    const field = mainTable.fields.find((f) => f.name === fieldName);\n    if (!field) {\n      throw new Error(`Field ${fieldName} not found`);\n    }\n\n    const filter: IFilter = {\n      filterSet: [\n        {\n          fieldId: field.id,\n          value: queryValue,\n          operator,\n        },\n      ],\n      conjunction: and.value,\n    };\n\n    const { records } = await getFilterRecord(mainTable.id, filter);\n    expect(records.length).toBe(expectedLength);\n\n    // If expectedRecordMatchers provided, verify the content of returned records\n    if (expectedRecordMatchers && expectedRecordMatchers.length > 0) {\n      expectedRecordMatchers.forEach((matcher, index) => {\n        expect(records[index]).toMatchObject(matcher);\n      });\n    }\n  };\n\n  // Verify mainTable has exactly 3 records\n  test('should have exactly 3 records in mainTable', async () => {\n    const { records } = await getFilterRecord(mainTable.id, { filterSet: [], conjunction: 'and' });\n    expect(records.length).toBe(3);\n  });\n\n  describe('Text Field Filters', () => {\n    const fieldName = 'Text Field';\n\n    test('should filter with is operator', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, is.value, 'Test Text 1', 1, [\n        { fields: expect.objectContaining({ [field!.id]: 'Test Text 1' }) },\n      ]);\n    });\n\n    test('should filter with isNot operator', async () => {\n      await doTest(fieldName, isNot.value, 'Test Text 1', 2);\n    });\n\n    test('should filter with contains operator', async () => {\n      await doTest(fieldName, contains.value, 'Test', 2);\n    });\n\n    test('should filter with doesNotContain operator', async () => {\n      await doTest(fieldName, doesNotContain.value, 'Test', 1);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, isEmpty.value, null, 1, [\n        { fields: expect.not.objectContaining({ [field!.id]: expect.anything() }) },\n      ]);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n\n    // Text field doesn't support isAnyOf and isNoneOf operators\n    // Removed unsupported operators: isAnyOf, isNoneOf\n  });\n\n  describe('Long Text Field Filters', () => {\n    const fieldName = 'Long Text Field';\n\n    test('should filter with contains operator', async () => {\n      await doTest(fieldName, contains.value, 'long text', 2);\n    });\n\n    test('should filter with doesNotContain operator', async () => {\n      await doTest(fieldName, doesNotContain.value, 'testing', 1);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n  });\n\n  describe('Number Field Filters', () => {\n    const fieldName = 'Number Field';\n\n    test('should filter with is operator', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, is.value, 10.5, 1, [\n        { fields: expect.objectContaining({ [field!.id]: 10.5 }) },\n      ]);\n    });\n\n    test('should filter with isNot operator', async () => {\n      await doTest(fieldName, isNot.value, 10.5, 2);\n    });\n\n    test('should filter with isGreater operator', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, isGreater.value, 20, 1, [\n        { fields: expect.objectContaining({ [field!.id]: expect.any(Number) }) },\n      ]);\n    });\n\n    test('should filter with isGreaterEqual operator', async () => {\n      await doTest(fieldName, isGreaterEqual.value, 10.5, 2);\n    });\n\n    test('should filter with isLess operator', async () => {\n      await doTest(fieldName, isLess.value, 20, 1);\n    });\n\n    test('should filter with isLessEqual operator', async () => {\n      await doTest(fieldName, isLessEqual.value, 25.75, 2);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n\n    // Number field doesn't support isAnyOf and isNoneOf operators\n    // Removed unsupported operators: isAnyOf, isNoneOf\n  });\n\n  describe('Date Field Filters', () => {\n    const fieldName = 'Date Field';\n\n    test('should filter with is operator', async () => {\n      await doTest(\n        fieldName,\n        is.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-01-15T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        1\n      );\n    });\n\n    test('should filter with isNot operator', async () => {\n      await doTest(\n        fieldName,\n        isNot.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-01-15T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        2\n      );\n    });\n\n    test('should filter with isAfter operator', async () => {\n      await doTest(\n        fieldName,\n        isAfter.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-01-31T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        1\n      );\n    });\n\n    test('should filter with isBefore operator', async () => {\n      await doTest(\n        fieldName,\n        isBefore.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-02-01T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        1\n      );\n    });\n\n    test('should filter with isOnOrAfter operator', async () => {\n      await doTest(\n        fieldName,\n        isOnOrAfter.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-01-15T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        2\n      );\n    });\n\n    test('should filter with isOnOrBefore operator', async () => {\n      await doTest(\n        fieldName,\n        isOnOrBefore.value,\n        {\n          mode: 'exactDate',\n          exactDate: '2024-02-20T00:00:00.000Z',\n          timeZone: 'UTC',\n        },\n        2\n      );\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n  });\n\n  describe('Checkbox Field Filters', () => {\n    const fieldName = 'Checkbox Field';\n\n    test('should filter with is operator for true', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, is.value, true, 1, [\n        { fields: expect.objectContaining({ [field!.id]: true }) },\n      ]);\n    });\n\n    test('should filter with is operator for false', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, is.value, false, 2, [\n        // Record with false value (may not be present in fields object)\n        { fields: expect.not.objectContaining({ [field!.id]: true }) },\n        // Record with null value (definitely not present in fields object)\n        { fields: expect.not.objectContaining({ [field!.id]: expect.anything() }) },\n      ]);\n    });\n\n    test('should filter with is operator for null', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      await doTest(fieldName, is.value, null, 2, [\n        // Record with false value (may not be present in fields object)\n        { fields: expect.not.objectContaining({ [field!.id]: true }) },\n        // Record with null value (definitely not present in fields object)\n        { fields: expect.not.objectContaining({ [field!.id]: expect.anything() }) },\n      ]);\n    });\n\n    // Checkbox field only supports 'is' operator\n    // Removed unsupported operators: isNot, isEmpty, isNotEmpty\n  });\n\n  describe('Single Select Field Filters', () => {\n    const fieldName = 'Single Select Field';\n\n    test('should filter with is operator', async () => {\n      await doTest(fieldName, is.value, 'Option 1', 1);\n    });\n\n    test('should filter with isNot operator', async () => {\n      await doTest(fieldName, isNot.value, 'Option 1', 2);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n\n    test('should filter with isAnyOf operator', async () => {\n      await doTest(fieldName, isAnyOf.value, ['Option 1', 'Option 2'], 2);\n    });\n\n    test('should filter with isNoneOf operator', async () => {\n      await doTest(fieldName, isNoneOf.value, ['Option 1'], 2);\n    });\n  });\n\n  describe('Multiple Select Field Filters', () => {\n    const fieldName = 'Multiple Select Field';\n\n    test('should filter with hasAnyOf operator', async () => {\n      await doTest(fieldName, hasAnyOf.value, ['Tag 1'], 1);\n    });\n\n    test('should filter with hasAllOf operator', async () => {\n      await doTest(fieldName, hasAllOf.value, ['Tag 1', 'Tag 2'], 1);\n    });\n\n    test('should filter with hasNoneOf operator', async () => {\n      await doTest(fieldName, hasNoneOf.value, ['Tag 1'], 2);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n\n    test('should filter with isExactly operator', async () => {\n      await doTest(fieldName, isExactly.value, ['Tag 1', 'Tag 2'], 1);\n    });\n\n    test('should filter with isNotExactly operator', async () => {\n      await doTest(fieldName, isNotExactly.value, ['Tag 1', 'Tag 2'], 2);\n    });\n  });\n\n  describe('Rating Field Filters', () => {\n    const fieldName = 'Rating Field';\n\n    test('should filter with is operator', async () => {\n      await doTest(fieldName, is.value, 4, 1);\n    });\n\n    test('should filter with isNot operator', async () => {\n      await doTest(fieldName, isNot.value, 4, 2);\n    });\n\n    test('should filter with isGreater operator', async () => {\n      await doTest(fieldName, isGreater.value, 3, 1);\n    });\n\n    test('should filter with isGreaterEqual operator', async () => {\n      await doTest(fieldName, isGreaterEqual.value, 3, 2);\n    });\n\n    test('should filter with isLess operator', async () => {\n      await doTest(fieldName, isLess.value, 4, 1);\n    });\n\n    test('should filter with isLessEqual operator', async () => {\n      await doTest(fieldName, isLessEqual.value, 4, 2);\n    });\n\n    test('should filter with isEmpty operator', async () => {\n      await doTest(fieldName, isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest(fieldName, isNotEmpty.value, null, 2);\n    });\n  });\n\n  describe('Formula Field Filters', () => {\n    let generatedFormulaField: any;\n    let selectFormulaField: any;\n\n    beforeEach(async () => {\n      // Create a generated column formula (simple expression)\n      generatedFormulaField = await createField(mainTable.id, {\n        name: 'Generated Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${mainTable.fields.find((f) => f.name === 'Number Field')!.id}} * 2`,\n        },\n      });\n\n      // Create a select query formula (complex expression with functions)\n      selectFormulaField = await createField(mainTable.id, {\n        name: 'Select Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `YEAR({${mainTable.fields.find((f) => f.name === 'Date Field')!.id}})`,\n        },\n      });\n\n      // Add the new fields to mainTable\n      mainTable.fields.push(generatedFormulaField.data, selectFormulaField.data);\n    });\n\n    describe('Generated Column Formula', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Generated Formula', is.value, 21, 1); // 10.5 * 2 = 21\n      });\n\n      test('should filter with isGreater operator', async () => {\n        await doTest('Generated Formula', isGreater.value, 30, 1); // 25.75 * 2 = 51.5\n      });\n\n      test('should filter with isLess operator', async () => {\n        await doTest('Generated Formula', isLess.value, 30, 2); // 10.5 * 2 = 21, blank -> 0\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Generated Formula', isEmpty.value, null, 0);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Generated Formula', isNotEmpty.value, null, 3);\n      });\n    });\n\n    describe('Select Query Formula', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Select Formula', is.value, '2024', 0);\n      });\n\n      test('should filter with isNot operator', async () => {\n        await doTest('Select Formula', isNot.value, '2024', 3);\n      });\n\n      test('should filter with contains operator', async () => {\n        await doTest('Select Formula', contains.value, '202', 0);\n      });\n\n      test('should filter with doesNotContain operator', async () => {\n        await doTest('Select Formula', doesNotContain.value, '2024', 3);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Select Formula', isEmpty.value, null, 0);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Select Formula', isNotEmpty.value, null, 3);\n      });\n    });\n  });\n\n  describe('Link Field Filters', () => {\n    test('should filter with isEmpty operator', async () => {\n      await doTest('Link Field', isEmpty.value, null, 1);\n    });\n\n    test('should filter with isNotEmpty operator', async () => {\n      await doTest('Link Field', isNotEmpty.value, null, 2);\n    });\n  });\n\n  describe('Lookup Field Filters', () => {\n    let lookupTextField: any;\n    let lookupNumberField: any;\n    let lookupDateField: any;\n    let lookupCheckboxField: any;\n\n    beforeEach(async () => {\n      // Create lookup fields for different types\n      lookupTextField = await createField(mainTable.id, {\n        name: 'Lookup Text',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Text')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      lookupNumberField = await createField(mainTable.id, {\n        name: 'Lookup Number',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      lookupDateField = await createField(mainTable.id, {\n        name: 'Lookup Date',\n        type: FieldType.Date,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Date')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      lookupCheckboxField = await createField(mainTable.id, {\n        name: 'Lookup Checkbox',\n        type: FieldType.Checkbox,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Checkbox')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      // Add Lookup fields to mainTable.fields for testing\n      mainTable.fields.push(lookupTextField.data);\n      mainTable.fields.push(lookupNumberField.data);\n      mainTable.fields.push(lookupDateField.data);\n      mainTable.fields.push(lookupCheckboxField.data);\n    });\n\n    describe('Lookup Text Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Lookup Text', is.value, 'Related Item 1', 1);\n      });\n\n      test('should filter with contains operator', async () => {\n        await doTest('Lookup Text', contains.value, 'Related', 2);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Lookup Text', isEmpty.value, null, 1);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Lookup Text', isNotEmpty.value, null, 2);\n      });\n    });\n\n    describe('Lookup Number Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Lookup Number', is.value, 100, 1);\n      });\n\n      test('should filter with isGreater operator', async () => {\n        await doTest('Lookup Number', isGreater.value, 150, 1);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Lookup Number', isEmpty.value, null, 1);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Lookup Number', isNotEmpty.value, null, 2);\n      });\n    });\n\n    describe('Lookup Date Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest(\n          'Lookup Date',\n          is.value,\n          {\n            mode: 'exactDate',\n            exactDate: '2024-01-01T00:00:00.000Z',\n            timeZone: 'UTC',\n          },\n          1\n        );\n      });\n\n      test('should filter with isAfter operator', async () => {\n        await doTest(\n          'Lookup Date',\n          isAfter.value,\n          {\n            mode: 'exactDate',\n            exactDate: '2024-01-15T00:00:00.000Z',\n            timeZone: 'UTC',\n          },\n          1\n        );\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Lookup Date', isEmpty.value, null, 1);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Lookup Date', isNotEmpty.value, null, 2);\n      });\n    });\n\n    describe('Lookup Checkbox Field', () => {\n      test('should filter with is operator for true', async () => {\n        await doTest('Lookup Checkbox', is.value, true, 1);\n      });\n\n      test('should filter with is operator for false', async () => {\n        await doTest('Lookup Checkbox', is.value, false, 2);\n      });\n\n      test('should filter with is operator for null', async () => {\n        await doTest('Lookup Checkbox', is.value, null, 2);\n      });\n\n      // Lookup Checkbox field only supports 'is' operator\n      // Removed unsupported operators: isEmpty, isNotEmpty\n    });\n  });\n\n  describe('Rollup Field Filters', () => {\n    let rollupSumField: any;\n    let rollupCountField: any;\n    let rollupMaxField: any;\n\n    beforeEach(async () => {\n      // Create rollup fields for different aggregation functions\n      rollupSumField = await createField(mainTable.id, {\n        name: 'Rollup Sum',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          linkFieldId: linkField.data.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n        },\n      });\n\n      rollupCountField = await createField(mainTable.id, {\n        name: 'Rollup Count',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'count({values})',\n        },\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          linkFieldId: linkField.data.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n        },\n      });\n\n      rollupMaxField = await createField(mainTable.id, {\n        name: 'Rollup Max',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'max({values})',\n        },\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          linkFieldId: linkField.data.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n        },\n      });\n\n      // Add Rollup fields to mainTable.fields for testing\n      mainTable.fields.push(rollupSumField.data);\n      mainTable.fields.push(rollupCountField.data);\n      mainTable.fields.push(rollupMaxField.data);\n    });\n\n    describe('Rollup Sum Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Rollup Sum', is.value, 100, 1); // Single related record\n      });\n\n      test('should filter with isGreater operator', async () => {\n        await doTest('Rollup Sum', isGreater.value, 150, 1);\n      });\n\n      test('should filter with isLess operator', async () => {\n        await doTest('Rollup Sum', isLess.value, 150, 2);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Rollup Sum', isEmpty.value, null, 0);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Rollup Sum', isNotEmpty.value, null, 3);\n      });\n    });\n\n    describe('Rollup Count Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Rollup Count', is.value, 1, 2); // Each linked record has 1 related record\n      });\n\n      test('should filter with isGreater operator', async () => {\n        await doTest('Rollup Count', isGreater.value, 0, 2);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Rollup Count', isEmpty.value, null, 0);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Rollup Count', isNotEmpty.value, null, 3);\n      });\n    });\n\n    describe('Rollup Max Field', () => {\n      test('should filter with is operator', async () => {\n        await doTest('Rollup Max', is.value, 100, 1);\n      });\n\n      test('should filter with isGreater operator', async () => {\n        await doTest('Rollup Max', isGreater.value, 150, 1);\n      });\n\n      test('should filter with isLess operator', async () => {\n        await doTest('Rollup Max', isLess.value, 150, 1);\n      });\n\n      test('should filter with isEmpty operator', async () => {\n        await doTest('Rollup Max', isEmpty.value, null, 1);\n      });\n\n      test('should filter with isNotEmpty operator', async () => {\n        await doTest('Rollup Max', isNotEmpty.value, null, 2);\n      });\n    });\n  });\n\n  describe('Complex Filter Scenarios', () => {\n    test('should handle multiple filters with AND conjunction', async () => {\n      const textField = mainTable.fields.find((f) => f.name === 'Text Field');\n      const numberField = mainTable.fields.find((f) => f.name === 'Number Field');\n\n      const filter: IFilter = {\n        filterSet: [\n          {\n            fieldId: textField!.id,\n            value: 'Test Text 1',\n            operator: is.value,\n          },\n          {\n            fieldId: numberField!.id,\n            value: 10.5,\n            operator: is.value,\n          },\n        ],\n        conjunction: and.value,\n      };\n\n      const { records } = await getFilterRecord(mainTable.id, filter);\n      expect(records.length).toBe(1);\n    });\n\n    test('should handle nested filter groups', async () => {\n      const textField = mainTable.fields.find((f) => f.name === 'Text Field');\n      const numberField = mainTable.fields.find((f) => f.name === 'Number Field');\n\n      const filter: IFilter = {\n        filterSet: [\n          {\n            fieldId: textField!.id,\n            value: null,\n            operator: isEmpty.value,\n          },\n          {\n            conjunction: and.value,\n            filterSet: [\n              {\n                fieldId: numberField!.id,\n                value: 20,\n                operator: isGreater.value,\n              },\n            ],\n          },\n        ],\n        conjunction: 'or' as any,\n      };\n\n      const { records } = await getFilterRecord(mainTable.id, filter);\n      expect(records.length).toBe(2); // Empty text OR number > 20\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/comprehensive-field-sort.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicated-branches */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IRatingFieldOptions, ISortItem } from '@teable/core';\nimport {\n  FieldKeyType,\n  FieldType,\n  Colors,\n  DateFormattingPreset,\n  TimeFormatting,\n  NumberFormattingType,\n  Relationship,\n  SortFunc,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getRecords as apiGetRecords, createField, createRecords } from '@teable/openapi';\nimport { createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\ndescribe('Comprehensive Field Sort Tests (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let mainTable: ITableFullVo;\n  let relatedTable: ITableFullVo;\n  let linkField: any;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  beforeEach(async () => {\n    // Create fresh tables and data for each test to ensure isolation\n\n    // Create related table first\n    relatedTable = await createTable(baseId, {\n      name: 'Related Table',\n      fields: [\n        {\n          name: 'Related Text',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Related Number',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n        {\n          name: 'Related Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        },\n      ],\n      records: [\n        {\n          fields: {\n            'Related Text': 'Alpha',\n            'Related Number': 100,\n            'Related Date': '2024-01-01',\n          },\n        },\n        {\n          fields: {\n            'Related Text': 'Beta',\n            'Related Number': 200,\n            'Related Date': '2024-02-01',\n          },\n        },\n        {\n          fields: {\n            'Related Text': 'Gamma',\n            'Related Number': 300,\n            'Related Date': '2024-03-01',\n          },\n        },\n      ],\n    });\n\n    // Create main table with all field types\n    mainTable = await createTable(baseId, {\n      name: 'Main Table',\n      records: [], // Prevent default records from being created\n      fields: [\n        {\n          name: 'Text Field',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Number Field',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n          },\n        },\n        {\n          name: 'Date Field',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        },\n        {\n          name: 'Checkbox Field',\n          type: FieldType.Checkbox,\n        },\n        {\n          name: 'Single Select Field',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'opt1', name: 'High', color: Colors.Red },\n              { id: 'opt2', name: 'Medium', color: Colors.Blue },\n              { id: 'opt3', name: 'Low', color: Colors.Green },\n            ],\n          },\n        },\n        {\n          name: 'Multiple Select Field',\n          type: FieldType.MultipleSelect,\n          options: {\n            choices: [\n              { id: 'tag1', name: 'Urgent', color: Colors.Red },\n              { id: 'tag2', name: 'Important', color: Colors.Blue },\n              { id: 'tag3', name: 'Normal', color: Colors.Green },\n            ],\n          },\n        },\n        {\n          name: 'Rating Field',\n          type: FieldType.Rating,\n          options: {\n            icon: 'star',\n            color: 'yellowBright',\n            max: 5,\n          } as IRatingFieldOptions,\n        },\n      ],\n    });\n\n    // Create link field\n    linkField = await createField(mainTable.id, {\n      name: 'Link Field',\n      type: FieldType.Link,\n      options: {\n        foreignTableId: relatedTable.id,\n        relationship: Relationship.ManyOne,\n      },\n    });\n\n    // Get field IDs for formula references\n    const numberFieldId = mainTable.fields.find((f) => f.name === 'Number Field')!.id;\n\n    // Create formula fields\n    const generatedFormulaField = await createField(mainTable.id, {\n      name: 'Generated Formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberFieldId}} * 2`,\n      },\n    });\n\n    // Create rollup field\n    const rollupField = await createField(mainTable.id, {\n      name: 'Rollup Field',\n      type: FieldType.Rollup,\n      options: {\n        expression: 'sum({values})',\n      },\n      lookupOptions: {\n        foreignTableId: relatedTable.id,\n        lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n        linkFieldId: linkField.data.id,\n      },\n    });\n\n    // Update mainTable.fields to include the new fields\n    mainTable.fields.push(linkField.data);\n    mainTable.fields.push(generatedFormulaField.data);\n    mainTable.fields.push(rollupField.data);\n\n    // Add test records to main table with specific values for sorting\n    const records = [\n      {\n        fields: {\n          'Text Field': 'Charlie',\n          'Number Field': 30.5,\n          'Date Field': '2024-03-15',\n          'Checkbox Field': true,\n          'Single Select Field': 'High',\n          'Multiple Select Field': ['Urgent', 'Important'],\n          'Rating Field': 5,\n          'Link Field': { id: relatedTable.records[2].id }, // Gamma\n        },\n      },\n      {\n        fields: {\n          'Text Field': 'Alpha',\n          'Number Field': 10.25,\n          'Date Field': '2024-01-10',\n          'Checkbox Field': false,\n          'Single Select Field': 'Low',\n          'Multiple Select Field': ['Normal'],\n          'Rating Field': 2,\n          'Link Field': { id: relatedTable.records[0].id }, // Alpha\n        },\n      },\n      {\n        fields: {\n          'Text Field': 'Beta',\n          'Number Field': 20.75,\n          'Date Field': '2024-02-20',\n          'Checkbox Field': null,\n          'Single Select Field': 'Medium',\n          'Multiple Select Field': ['Important', 'Normal'],\n          'Rating Field': 4,\n          'Link Field': { id: relatedTable.records[1].id }, // Beta\n        },\n      },\n      {\n        fields: {\n          'Text Field': null,\n          'Number Field': null,\n          'Date Field': null,\n          'Checkbox Field': null,\n          'Single Select Field': null,\n          'Multiple Select Field': null,\n          'Rating Field': null,\n          'Link Field': null,\n        },\n      },\n    ];\n\n    for (const record of records) {\n      await createRecords(mainTable.id, { fieldKeyType: FieldKeyType.Name, records: [record] });\n    }\n  });\n\n  afterEach(async () => {\n    // Clean up tables after each test\n    if (mainTable?.id) {\n      await permanentDeleteTable(baseId, mainTable.id);\n    }\n    if (relatedTable?.id) {\n      await permanentDeleteTable(baseId, relatedTable.id);\n    }\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  async function getSortedRecords(tableId: string, sort: ISortItem[]) {\n    return (\n      await apiGetRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        orderBy: sort,\n      })\n    ).data;\n  }\n\n  const doSortTest = async (fieldName: string, order: SortFunc) => {\n    const field = mainTable.fields.find((f) => f.name === fieldName);\n    if (!field) {\n      throw new Error(`Field ${fieldName} not found`);\n    }\n\n    const sort: ISortItem[] = [\n      {\n        fieldId: field.id,\n        order,\n      },\n    ];\n\n    const { records } = await getSortedRecords(mainTable.id, sort);\n\n    // Verify that sorting works and returns the expected number of records\n    expect(records.length).toBe(4);\n    expect(records).toBeDefined();\n\n    // Verify actual sorting order based on field type\n    const fieldValues = records.map((r) => r.fields[field.id]);\n    const nonNullValues = fieldValues.filter((v) => v !== null && v !== undefined);\n\n    if (nonNullValues.length > 1) {\n      // Check sorting order based on field type\n      if (field.type === FieldType.Number) {\n        // Number field sorting\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = Number(nonNullValues[i]);\n          const next = Number(nonNullValues[i + 1]);\n          if (order === SortFunc.Asc) {\n            expect(current).toBeLessThanOrEqual(next);\n          } else {\n            expect(current).toBeGreaterThanOrEqual(next);\n          }\n        }\n      } else if (field.type === FieldType.SingleLineText) {\n        // Text field sorting\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = String(nonNullValues[i]);\n          const next = String(nonNullValues[i + 1]);\n          if (order === SortFunc.Asc) {\n            expect(current.localeCompare(next)).toBeLessThanOrEqual(0);\n          } else {\n            expect(current.localeCompare(next)).toBeGreaterThanOrEqual(0);\n          }\n        }\n      } else if (field.type === FieldType.Date) {\n        // Date field sorting\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = new Date(nonNullValues[i] as string);\n          const next = new Date(nonNullValues[i + 1] as string);\n          if (order === SortFunc.Asc) {\n            expect(current.getTime()).toBeLessThanOrEqual(next.getTime());\n          } else {\n            expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime());\n          }\n        }\n      } else if (field.type === FieldType.Rollup) {\n        // Rollup field sorting (typically numeric)\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = Number(nonNullValues[i]);\n          const next = Number(nonNullValues[i + 1]);\n          if (order === SortFunc.Asc) {\n            expect(current).toBeLessThanOrEqual(next);\n          } else {\n            expect(current).toBeGreaterThanOrEqual(next);\n          }\n        }\n      }\n    }\n  };\n\n  // Verify mainTable has exactly 4 records\n  test('should have exactly 4 records in mainTable', async () => {\n    const { records } = await getSortedRecords(mainTable.id, []);\n    expect(records.length).toBe(4);\n  });\n\n  describe('Text Field Sorting', () => {\n    const fieldName = 'Text Field';\n\n    test('should sort ascending (A-Z)', async () => {\n      await doSortTest(fieldName, SortFunc.Asc);\n    });\n\n    test('should sort descending (Z-A)', async () => {\n      await doSortTest(fieldName, SortFunc.Desc);\n    });\n  });\n\n  describe('Number Field Sorting', () => {\n    const fieldName = 'Number Field';\n\n    test('should sort ascending (low to high)', async () => {\n      await doSortTest(fieldName, SortFunc.Asc);\n    });\n\n    test('should sort descending (high to low)', async () => {\n      await doSortTest(fieldName, SortFunc.Desc);\n    });\n  });\n\n  describe('Date Field Sorting', () => {\n    const fieldName = 'Date Field';\n\n    test('should sort ascending (earliest to latest)', async () => {\n      await doSortTest(fieldName, SortFunc.Asc);\n    });\n\n    test('should sort descending (latest to earliest)', async () => {\n      await doSortTest(fieldName, SortFunc.Desc);\n    });\n  });\n\n  describe('Rollup Field Sorting (via doSortTest)', () => {\n    const fieldName = 'Rollup Field';\n\n    test('should sort ascending', async () => {\n      await doSortTest(fieldName, SortFunc.Asc);\n    });\n\n    test('should sort descending', async () => {\n      await doSortTest(fieldName, SortFunc.Desc);\n    });\n  });\n\n  describe('Checkbox Field Sorting', () => {\n    const fieldName = 'Checkbox Field';\n\n    test('should sort ascending (false/null first, true last)', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order\n      const checkboxValues = records.map((r) => r.fields[field!.id]);\n\n      // Find indices of different values\n      let falseNullCount = 0;\n      let trueCount = 0;\n      let lastTrueIndex = -1;\n\n      checkboxValues.forEach((value, index) => {\n        if (value === true) {\n          trueCount++;\n          lastTrueIndex = index;\n        } else {\n          falseNullCount++;\n        }\n      });\n\n      // In ascending order, true values should come after false/null values\n      if (trueCount > 0 && falseNullCount > 0) {\n        expect(lastTrueIndex).toBeGreaterThanOrEqual(falseNullCount - 1);\n      }\n    });\n\n    test('should sort descending (true first, false/null last)', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order\n      const checkboxValues = records.map((r) => r.fields[field!.id]);\n\n      // Find first false/null index\n      let firstFalseNullIndex = -1;\n      let trueCount = 0;\n\n      checkboxValues.forEach((value, index) => {\n        if (value === true) {\n          trueCount++;\n        } else if (firstFalseNullIndex === -1) {\n          firstFalseNullIndex = index;\n        }\n      });\n\n      // In descending order, true values should come before false/null values\n      if (trueCount > 0 && firstFalseNullIndex !== -1) {\n        expect(firstFalseNullIndex).toBeGreaterThanOrEqual(trueCount);\n      }\n    });\n  });\n\n  describe('Single Select Field Sorting', () => {\n    const fieldName = 'Single Select Field';\n\n    test('should sort ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order - choices are: High, Medium, Low\n      const selectValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = selectValues.filter((v) => v !== null && v !== undefined);\n\n      // Check that non-null values are in correct order\n      if (nonNullValues.length > 1) {\n        const choiceOrder = ['High', 'Medium', 'Low'];\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const currentIndex = choiceOrder.indexOf(nonNullValues[i] as string);\n          const nextIndex = choiceOrder.indexOf(nonNullValues[i + 1] as string);\n          if (currentIndex !== -1 && nextIndex !== -1) {\n            expect(currentIndex).toBeLessThanOrEqual(nextIndex);\n          }\n        }\n      }\n    });\n\n    test('should sort descending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order - choices are: High, Medium, Low (reversed for desc)\n      const selectValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = selectValues.filter((v) => v !== null && v !== undefined);\n\n      // Check that non-null values are in correct descending order\n      if (nonNullValues.length > 1) {\n        const choiceOrder = ['Low', 'Medium', 'High']; // Reversed for descending\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const currentIndex = choiceOrder.indexOf(nonNullValues[i] as string);\n          const nextIndex = choiceOrder.indexOf(nonNullValues[i + 1] as string);\n          if (currentIndex !== -1 && nextIndex !== -1) {\n            expect(currentIndex).toBeLessThanOrEqual(nextIndex);\n          }\n        }\n      }\n    });\n  });\n\n  describe('Rating Field Sorting', () => {\n    const fieldName = 'Rating Field';\n\n    test('should sort ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order - ratings should be in ascending order\n      const ratingValues = records.map((r) => r.fields[field!.id]);\n      const nonNullRatings = ratingValues.filter((v) => v !== null && v !== undefined) as number[];\n\n      // Check that non-null ratings are in ascending order\n      for (let i = 0; i < nonNullRatings.length - 1; i++) {\n        expect(nonNullRatings[i]).toBeLessThanOrEqual(nonNullRatings[i + 1]);\n      }\n\n      // Null values should come first or last consistently\n      const firstNonNullIndex = ratingValues.findIndex((v) => v !== null && v !== undefined);\n      if (firstNonNullIndex > 0) {\n        // If there are nulls before non-nulls, all nulls should be at the beginning\n        for (let i = 0; i < firstNonNullIndex; i++) {\n          expect(ratingValues[i] ?? undefined).toBeUndefined();\n        }\n      }\n    });\n\n    test('should sort descending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order - ratings should be in descending order\n      const ratingValues = records.map((r) => r.fields[field!.id]);\n      const nonNullRatings = ratingValues.filter((v) => v !== null && v !== undefined) as number[];\n\n      // Check that non-null ratings are in descending order\n      for (let i = 0; i < nonNullRatings.length - 1; i++) {\n        expect(nonNullRatings[i]).toBeGreaterThanOrEqual(nonNullRatings[i + 1]);\n      }\n    });\n  });\n\n  describe('Formula Field Sorting', () => {\n    const fieldName = 'Generated Formula';\n\n    test('should sort generated formula ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify that formula values are present and sorted\n      const formulaValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = formulaValues.filter((v) => v !== null && v !== undefined);\n      expect(nonNullValues.length).toBeGreaterThan(0);\n\n      // Check ascending order for numeric formula values\n      if (nonNullValues.length > 1 && typeof nonNullValues[0] === 'number') {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          expect(Number(nonNullValues[i])).toBeLessThanOrEqual(Number(nonNullValues[i + 1]));\n        }\n      }\n    });\n\n    test('should sort generated formula descending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify that formula values are present and sorted\n      const formulaValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = formulaValues.filter((v) => v !== null && v !== undefined);\n      expect(nonNullValues.length).toBeGreaterThan(0);\n\n      // Check descending order for numeric formula values\n      if (nonNullValues.length > 1 && typeof nonNullValues[0] === 'number') {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          expect(Number(nonNullValues[i])).toBeGreaterThanOrEqual(Number(nonNullValues[i + 1]));\n        }\n      }\n    });\n  });\n\n  describe('Link Field Sorting', () => {\n    const fieldName = 'Link Field';\n\n    test('should sort link field ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [\n        {\n          fieldId: field!.id,\n          order: SortFunc.Asc,\n        },\n      ];\n\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order for link field\n      const linkValues = records.map((r) => r.fields[field!.id]);\n\n      // Count non-null and null values\n      const nonNullCount = linkValues.filter((v) => v !== null && v !== undefined).length;\n      const nullCount = linkValues.filter((v) => v === null || v === undefined).length;\n\n      expect(nonNullCount).toBeGreaterThan(0);\n      expect(nullCount).toBeGreaterThan(0);\n      expect(nonNullCount + nullCount).toBe(4);\n\n      // Verify that null values are consistently positioned (either all at start or all at end)\n      const firstNullIndex = linkValues.findIndex((v) => v === null || v === undefined);\n      const lastNonNullIndex =\n        linkValues\n          .map((v, i) => (v !== null && v !== undefined ? i : -1))\n          .filter((i) => i !== -1)\n          .pop() || -1;\n\n      if (firstNullIndex !== -1 && lastNonNullIndex !== -1) {\n        // Either nulls come first or nulls come last, but not mixed\n        expect(firstNullIndex === 0 || lastNonNullIndex < firstNullIndex).toBe(true);\n      }\n    });\n  });\n\n  describe('Rollup Field Sorting', () => {\n    const fieldName = 'Rollup Field';\n\n    test('should sort rollup field ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order for rollup field\n      const rollupValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = rollupValues.filter((v) => v !== null && v !== undefined);\n\n      // Check ascending order for rollup values (typically numeric)\n      if (nonNullValues.length > 1) {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = Number(nonNullValues[i]);\n          const next = Number(nonNullValues[i + 1]);\n          expect(current).toBeLessThanOrEqual(next);\n        }\n      }\n    });\n\n    test('should sort rollup field descending', async () => {\n      const field = mainTable.fields.find((f) => f.name === fieldName);\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order for rollup field\n      const rollupValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = rollupValues.filter((v) => v !== null && v !== undefined);\n\n      // Check descending order for rollup values (typically numeric)\n      if (nonNullValues.length > 1) {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = Number(nonNullValues[i]);\n          const next = Number(nonNullValues[i + 1]);\n          expect(current).toBeGreaterThanOrEqual(next);\n        }\n      }\n    });\n  });\n\n  describe('Lookup Field Sorting', () => {\n    let lookupTextField: any;\n    let lookupNumberField: any;\n\n    beforeEach(async () => {\n      // Create lookup fields\n      lookupTextField = await createField(mainTable.id, {\n        name: 'Lookup Text',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Text')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      lookupNumberField = await createField(mainTable.id, {\n        name: 'Lookup Number',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: relatedTable.id,\n          lookupFieldId: relatedTable.fields.find((f) => f.name === 'Related Number')!.id,\n          linkFieldId: linkField.data.id,\n        },\n      });\n\n      mainTable.fields.push(lookupTextField.data);\n      mainTable.fields.push(lookupNumberField.data);\n    });\n\n    test('should sort lookup text field ascending', async () => {\n      const field = mainTable.fields.find((f) => f.name === 'Lookup Text');\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Asc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order for lookup text field\n      const lookupValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = lookupValues.filter((v) => v !== null && v !== undefined);\n\n      // Check ascending order for text values\n      if (nonNullValues.length > 1) {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = String(nonNullValues[i]);\n          const next = String(nonNullValues[i + 1]);\n          expect(current.localeCompare(next)).toBeLessThanOrEqual(0);\n        }\n      }\n    });\n\n    test('should sort lookup number field descending', async () => {\n      const field = mainTable.fields.find((f) => f.name === 'Lookup Number');\n      const sort: ISortItem[] = [{ fieldId: field!.id, order: SortFunc.Desc }];\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify actual sorting order for lookup number field\n      const lookupValues = records.map((r) => r.fields[field!.id]);\n      const nonNullValues = lookupValues.filter((v) => v !== null && v !== undefined);\n\n      // Check descending order for number values\n      if (nonNullValues.length > 1) {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          const current = Number(nonNullValues[i]);\n          const next = Number(nonNullValues[i + 1]);\n          expect(current).toBeGreaterThanOrEqual(next);\n        }\n      }\n    });\n  });\n\n  describe('Multiple Field Sorting', () => {\n    test('should sort by multiple fields', async () => {\n      const textField = mainTable.fields.find((f) => f.name === 'Text Field');\n      const numberField = mainTable.fields.find((f) => f.name === 'Number Field');\n\n      const sort: ISortItem[] = [\n        {\n          fieldId: textField!.id,\n          order: SortFunc.Asc,\n        },\n        {\n          fieldId: numberField!.id,\n          order: SortFunc.Desc,\n        },\n      ];\n\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify multiple field sorting order\n      const textValues = records.map((r) => r.fields[textField!.id]);\n      const numberValues = records.map((r) => r.fields[numberField!.id]);\n\n      // Check primary sort (text field ascending)\n      const nonNullTextIndices: number[] = [];\n      textValues.forEach((value, index) => {\n        if (value !== null && value !== undefined) {\n          nonNullTextIndices.push(index);\n        }\n      });\n\n      // For records with same text values, check secondary sort (number field descending)\n      for (let i = 0; i < nonNullTextIndices.length - 1; i++) {\n        const currentIndex = nonNullTextIndices[i];\n        const nextIndex = nonNullTextIndices[i + 1];\n        const currentText = textValues[currentIndex];\n        const nextText = textValues[nextIndex];\n\n        if (currentText === nextText) {\n          // Same text value, check number sorting (descending)\n          const currentNumber = numberValues[currentIndex];\n          const nextNumber = numberValues[nextIndex];\n          if (currentNumber !== null && nextNumber !== null) {\n            expect(Number(currentNumber)).toBeGreaterThanOrEqual(Number(nextNumber));\n          }\n        } else if (typeof currentText === 'string' && typeof nextText === 'string') {\n          // Different text values, should be in ascending order\n          expect(currentText.localeCompare(nextText)).toBeLessThanOrEqual(0);\n        }\n      }\n    });\n  });\n\n  describe('Sort with Selection Context', () => {\n    test('should handle formula field sorting with selection context', async () => {\n      const formulaField = mainTable.fields.find((f) => f.name === 'Generated Formula');\n\n      const sort: ISortItem[] = [\n        {\n          fieldId: formulaField!.id,\n          order: SortFunc.Asc,\n        },\n      ];\n\n      // Test that the sort works correctly with the new context parameter\n      const { records } = await getSortedRecords(mainTable.id, sort);\n      expect(records.length).toBe(4);\n\n      // Verify that formula values are present and properly sorted\n      const formulaValues = records.map((r) => r.fields[formulaField!.id]);\n      const nonNullValues = formulaValues.filter((v) => v !== null && v !== undefined);\n\n      expect(nonNullValues.length).toBeGreaterThan(0);\n\n      // Verify ascending order for formula values\n      if (nonNullValues.length > 1 && typeof nonNullValues[0] === 'number') {\n        for (let i = 0; i < nonNullValues.length - 1; i++) {\n          expect(Number(nonNullValues[i])).toBeLessThanOrEqual(Number(nonNullValues[i + 1]));\n        }\n      }\n\n      // The important thing is that sorting works with the new context parameter\n    });\n  });\n\n  describe('Multiple Select Sorting with Question Mark Choices', () => {\n    let specialTable: ITableFullVo;\n    let specialFieldId: string;\n\n    beforeEach(async () => {\n      specialTable = await createTable(baseId, {\n        name: 'Multi Select Question Mark Table',\n        fields: [\n          {\n            name: 'Special Multi Select',\n            type: FieldType.MultipleSelect,\n            options: {\n              choices: [\n                { id: 'opt-a', name: 'Alpha?' },\n                { id: 'opt-b', name: 'Beta' },\n                { id: 'opt-c', name: 'Gamma' },\n              ],\n            },\n          },\n        ],\n        records: [\n          { fields: { 'Special Multi Select': ['Beta'] } },\n          { fields: { 'Special Multi Select': ['Alpha?'] } },\n          { fields: { 'Special Multi Select': ['Gamma'] } },\n          { fields: { 'Special Multi Select': null } },\n        ],\n      });\n      specialFieldId =\n        specialTable.fields.find((f) => f.name === 'Special Multi Select')?.id ??\n        (() => {\n          throw new Error('Special Multi Select field not found');\n        })();\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, specialTable.id);\n    });\n\n    test('should sort ascending even when choices contain \"?\"', async () => {\n      const { data } = await apiGetRecords(specialTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        orderBy: [{ fieldId: specialFieldId, order: SortFunc.Asc }],\n      });\n      const { records } = data;\n\n      expect(records.length).toBe(4);\n      const firstChoice = records.map((r) => {\n        const value = r.fields[specialFieldId] as string[] | null | undefined;\n        return value?.[0] ?? null;\n      });\n\n      // Null should come first (NULLS FIRST), followed by ordered choices\n      expect(firstChoice).toEqual([null, 'Alpha?', 'Beta', 'Gamma']);\n    });\n\n    test('should sort descending even when choices contain \"?\"', async () => {\n      const { data } = await apiGetRecords(specialTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        orderBy: [{ fieldId: specialFieldId, order: SortFunc.Desc }],\n      });\n      const { records } = data;\n\n      expect(records.length).toBe(4);\n      const firstChoice = records.map((r) => {\n        const value = r.fields[specialFieldId] as string[] | null | undefined;\n        return value?.[0] ?? null;\n      });\n\n      // For DESC, choices should be reversed and NULLS LAST\n      expect(firstChoice).toEqual(['Gamma', 'Beta', 'Alpha?', null]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/computed-orchestrator.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFilter,\n  IFilterItem,\n  ILinkFieldOptions,\n  ILookupOptionsRo,\n} from '@teable/core';\nimport {\n  FieldType,\n  Relationship,\n  FieldKeyType,\n  is as FilterOperatorIs,\n  isGreater as FilterOperatorIsGreater,\n  isNotEmpty as FilterOperatorIsNotEmpty,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { duplicateField, convertField } from '@teable/openapi';\nimport { ActorId, type IComputedUpdateDrainService, v2CoreTokens } from '@teable/v2-core';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport type { Knex } from 'knex';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { FieldSelectVisitor } from '../src/features/record/query-builder/field-select-visitor';\nimport { RecordQueryBuilderManager } from '../src/features/record/query-builder/record-query-builder.manager';\nimport {\n  type IRecordQueryDialectProvider,\n  RECORD_QUERY_DIALECT_SYMBOL,\n} from '../src/features/record/query-builder/record-query-dialect.interface';\nimport { TableDomainQueryService } from '../src/features/table-domain/table-domain-query.service';\nimport { V2ContainerService } from '../src/features/v2/v2-container.service';\nimport { createAwaitWithEventWithResultWithCount } from './utils/event-promise';\nimport {\n  deleteField,\n  createField,\n  createTable,\n  createRecords,\n  getFields,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n  updateRecord,\n  getRecord,\n} from './utils/init-app';\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('Computed Orchestrator (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n  let prisma: PrismaService;\n  let knex: Knex;\n  let db: IDbProvider;\n  let tableDomainQueryService: TableDomainQueryService;\n  let recordDialect: IRecordQueryDialectProvider;\n  let v2ContainerService: V2ContainerService;\n  const baseId = (globalThis as any).testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n    prisma = app.get(PrismaService);\n    knex = app.get('CUSTOM_KNEX' as any);\n    db = app.get<IDbProvider>(DB_PROVIDER_SYMBOL as any);\n    tableDomainQueryService = app.get(TableDomainQueryService);\n    recordDialect = app.get<IRecordQueryDialectProvider>(RECORD_QUERY_DIALECT_SYMBOL as any);\n    v2ContainerService = app.get(V2ContainerService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  /**\n   * Process v2 computed update outbox tasks.\n   * This ensures all async computed updates are completed before assertions.\n   */\n  async function processV2Outbox(times = 1): Promise<void> {\n    if (!isForceV2) return;\n\n    const container = await v2ContainerService.getContainer();\n    const drainService = container.resolve<IComputedUpdateDrainService>(\n      v2CoreTokens.computedUpdateDrainService\n    );\n    const context = { actorId: ActorId.create('system')._unsafeUnwrap() };\n\n    for (let i = 0; i < times; i++) {\n      const maxIterations = 100;\n      let iterations = 0;\n\n      while (iterations < maxIterations) {\n        const result = await drainService.drainOnce(context, {\n          workerId: 'test-worker',\n          limit: 100,\n        });\n\n        if (result.isErr()) {\n          throw new Error(`Outbox processing failed: ${result.error.message}`);\n        }\n\n        // result.value is the number of processed tasks\n        if (result.value === 0) {\n          break;\n        }\n        iterations++;\n      }\n    }\n  }\n\n  /**\n   * V2-compatible wrapper for createAwaitWithEventWithResultWithCount.\n   * In v2 mode, events are handled differently, so we execute the function\n   * and process the outbox to ensure async updates complete, returning empty payloads.\n   * Tests that need to verify event payloads should be skipped in v2 mode.\n   */\n  function createAwaitWithEventV2Compatible(\n    _eventEmitterService: EventEmitterService,\n    _event: Events,\n    _count: number = 1\n  ) {\n    return async function fn<T>(fn: () => Promise<T>) {\n      if (isForceV2) {\n        // In v2 mode, execute and process outbox to ensure async updates complete\n        const result = await fn();\n        await processV2Outbox();\n        return { result, payloads: [] };\n      }\n      // In v1 mode, use the original event-based waiting\n      return createAwaitWithEventWithResultWithCount(_eventEmitterService, _event, _count)(fn);\n    };\n  }\n\n  async function runAndCaptureRecordUpdates<T>(fn: () => Promise<T>): Promise<{\n    result: T;\n    events: any[];\n  }> {\n    if (isForceV2) {\n      // In v2 mode, execute and process outbox to ensure async updates complete\n      // Events are not emitted in V2 mode, so we return an empty array\n      const result = await fn();\n      await processV2Outbox();\n      return { result, events: [] };\n    }\n\n    const events: any[] = [];\n    const handler = (payload: any) => events.push(payload);\n    eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler);\n    try {\n      const result = await fn();\n      // allow async emission to flush\n      await new Promise((r) => setTimeout(r, 50));\n      return { result, events };\n    } finally {\n      eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler);\n    }\n  }\n\n  // ---- DB helpers for asserting physical columns ----\n  const getDbTableName = async (tableId: string) => {\n    const { dbTableName } = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return dbTableName as string;\n  };\n\n  const getRow = async (dbTableName: string, id: string) => {\n    return (\n      await prisma.$queryRawUnsafe<any[]>(knex(dbTableName).select('*').where('__id', id).toQuery())\n    )[0];\n  };\n\n  const parseMaybe = (v: unknown) => {\n    if (typeof v === 'string') {\n      try {\n        return JSON.parse(v);\n      } catch {\n        return v;\n      }\n    }\n    return v;\n  };\n\n  type FieldChangePayload = { oldValue: any; newValue: any };\n  type FieldChangeMap = Record<string, FieldChangePayload>;\n\n  const assertChange = (change: FieldChangePayload | undefined): FieldChangePayload => {\n    expect(change).toBeDefined();\n    return change!;\n  };\n\n  const expectNoOldValue = (change: FieldChangePayload) => {\n    expect(change.oldValue === null || change.oldValue === undefined).toBe(true);\n  };\n\n  const toChangeMap = (event: any): FieldChangeMap => {\n    const recordPayload = Array.isArray(event.payload.record)\n      ? event.payload.record[0]\n      : event.payload.record;\n    return (recordPayload?.fields ?? {}) as FieldChangeMap;\n  };\n\n  const findRecordChangeMap = (\n    events: any[],\n    tableId: string,\n    recordId: string\n  ): FieldChangeMap | undefined => {\n    for (const event of events) {\n      if (!event?.payload || event.payload.tableId !== tableId) continue;\n      const recordPayloads = Array.isArray(event.payload.record)\n        ? event.payload.record\n        : [event.payload.record];\n      for (const rec of recordPayloads) {\n        if (rec?.id === recordId) {\n          return (rec.fields ?? {}) as FieldChangeMap;\n        }\n      }\n    }\n    return undefined;\n  };\n\n  // ===== Formula related =====\n  describe('Formula', () => {\n    it('emits old/new values for formula on same table when base field changes', async () => {\n      const table = await createTable(baseId, {\n        name: 'OldNew_Formula',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 1 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n      const f1 = await createField(table.id, {\n        name: 'F1',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}}` },\n      } as IFieldRo);\n\n      await updateRecordByApi(table.id, table.records[0].id, aId, 1);\n\n      // Expect a single record.update event; assert old/new for formula field\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await updateRecordByApi(table.id, table.records[0].id, aId, 2);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const event = payloads[0] as any; // RecordUpdateEvent\n        expect(event.payload.tableId).toBe(table.id);\n        const changes = event.payload.record.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        // Formula F1 should move from 1 -> 2\n        const f1Change = assertChange(changes[f1.id]);\n        expectNoOldValue(f1Change);\n        expect(f1Change.newValue).toEqual(2);\n      }\n\n      // Assert physical column for formula (non-generated) reflects new value\n      const tblName = await getDbTableName(table.id);\n      const row = await getRow(tblName, table.records[0].id);\n      const f1Full = (await getFields(table.id)).find((f) => f.id === (f1 as any).id)! as any;\n      expect(parseMaybe((row as any)[f1Full.dbFieldName])).toEqual(2);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('creates and updates numeric formula via API with computed results', async () => {\n      const table = await createTable(baseId, {\n        name: 'Formula_Api_RoundTrip',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n      });\n\n      const aField = table.fields.find((f) => f.name === 'A')!;\n      const formulaField = (await createField(table.id, {\n        name: 'F_via_api',\n        type: FieldType.Formula,\n        options: { expression: `{${aField.id}} * 2` },\n      } as IFieldRo)) as any;\n\n      const created = await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [aField.id]: 10,\n            },\n          },\n        ],\n      });\n\n      const recordId = created.records[0].id;\n      const createdRecord = await getRecord(table.id, recordId);\n      expect(createdRecord.fields[formulaField.id]).toEqual(20);\n\n      await updateRecordByApi(table.id, recordId, aField.id, null);\n      const updatedRecord = await getRecord(table.id, recordId);\n      expect(updatedRecord.fields[formulaField.id]).toBe(0);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('recomputes layered formulas after a formula definition change', async () => {\n      const table = await createTable(baseId, {\n        name: 'Formula_Layer_Recompute',\n        fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { Amount: 5 } }],\n      });\n      const amountId = table.fields.find((f) => f.name === 'Amount')!.id;\n\n      const plusOne = await createField(table.id, {\n        name: 'PlusOne',\n        type: FieldType.Formula,\n        options: { expression: `{${amountId}} + 1` },\n      } as IFieldRo);\n\n      const plusTwo = await createField(table.id, {\n        name: 'PlusTwo',\n        type: FieldType.Formula,\n        options: { expression: `{${plusOne.id}} + 1` },\n      } as IFieldRo);\n\n      const recordId = table.records[0].id;\n      const initial = await getRecord(table.id, recordId);\n      expect(initial.fields[plusOne.id]).toEqual(6);\n      expect(initial.fields[plusTwo.id]).toEqual(7);\n\n      await convertField(table.id, plusOne.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${amountId}} + 2` },\n      });\n\n      const updated = await getRecord(table.id, recordId);\n      expect(updated.fields[plusOne.id]).toEqual(7);\n      expect(updated.fields[plusTwo.id]).toEqual(8);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('computes string formula referencing multi-value field without CASE type mismatch', async () => {\n      const table = await createTable(baseId, {\n        name: 'Formula_String_MultiValue',\n        fields: [\n          {\n            name: 'Brand List',\n            type: FieldType.MultipleSelect,\n            options: {\n              choices: [\n                { id: 'brand-alpha', name: 'Alpha' },\n                { id: 'brand-beta', name: 'Beta' },\n              ],\n            },\n          } as IFieldRo,\n          { name: 'Code', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Display Name', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n      });\n\n      const brandField = table.fields.find((f) => f.name === 'Brand List')!;\n      const codeField = table.fields.find((f) => f.name === 'Code')!;\n      const nameField = table.fields.find((f) => f.name === 'Display Name')!;\n\n      const codeValue = 'BP-001';\n      const nameValue = 'Sample Product';\n\n      const { records } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              'Brand List': ['Alpha', 'Beta'],\n              Code: codeValue,\n              'Display Name': nameValue,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const expression = `\nIF(\n  OR(\n    LEN({${brandField.id}} & \"\") = 0,\n    LEN({${codeField.id}} & \"\") = 0,\n    LEN({${nameField.id}} & \"\") = 0\n  ),\n  \"\",\n  \"B:/版权品/\" &\n  IF(\n    FIND(\",\", {${brandField.id}} & \"\") > 0,\n    LEFT({${brandField.id}} & \"\", FIND(\",\", {${brandField.id}} & \"\") - 1),\n    {${brandField.id}}\n  ) &\n  \"/\" & {${codeField.id}} & \" \" & {${nameField.id}}\n)`.trim();\n\n      const formulaField = await createField(table.id, {\n        name: 'Computed Path',\n        type: FieldType.Formula,\n        options: { expression },\n      } as IFieldRo);\n\n      // Allow computed orchestrator to backfill existing rows\n      await new Promise((resolve) => setTimeout(resolve, 50));\n\n      const extractFields = (record: any) => record.fields ?? record.data?.fields ?? {};\n\n      const initialRecord = await getRecord(table.id, recordId);\n      const firstValue = extractFields(initialRecord)[formulaField.id];\n      expect(typeof firstValue).toBe('string');\n      expect((firstValue as string).startsWith('B:/版权品/')).toBe(true);\n      expect(firstValue).toContain('Alpha');\n      expect(firstValue).toContain(`${codeValue} ${nameValue}`);\n\n      await updateRecord(table.id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            'Brand List': ['Beta'],\n          },\n        },\n      });\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      const secondValue = extractFields(updatedRecord)[formulaField.id];\n      expect(typeof secondValue).toBe('string');\n      expect((secondValue as string).startsWith('B:/版权品/')).toBe(true);\n      expect(secondValue).toContain('Beta');\n      expect(secondValue).toContain(`${codeValue} ${nameValue}`);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('Formula unchanged publishes computed value with empty oldValue', async () => {\n      // T with A and F = {A}*{A}; change A: 1 -> -1, F stays 1\n      const table = await createTable(baseId, {\n        name: 'NoEvent_Formula_NoChange',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 1 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n      const f = await createField(table.id, {\n        name: 'F',\n        type: FieldType.Formula,\n        // F = A*A, so 1 -> -1 leaves F = 1 unchanged\n        options: { expression: `{${aId}} * {${aId}}` },\n      } as IFieldRo);\n\n      // Prime value\n      await updateRecordByApi(table.id, table.records[0].id, aId, 1);\n\n      // Expect a single update event, and it should NOT include a change entry for F\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await updateRecordByApi(table.id, table.records[0].id, aId, -1);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const event = payloads[0] as any;\n        const recs = Array.isArray(event.payload.record)\n          ? event.payload.record\n          : [event.payload.record];\n        const change = recs[0]?.fields?.[f.id] as FieldChangePayload | undefined;\n        const formulaChange = assertChange(change);\n        expectNoOldValue(formulaChange);\n        expect(formulaChange.newValue).toEqual(1);\n      }\n\n      // DB: F should remain 1\n      const tblName = await getDbTableName(table.id);\n      const row = await getRow(tblName, table.records[0].id);\n      const fFull = (await getFields(table.id)).find((x) => x.id === (f as any).id)! as any;\n      expect(parseMaybe((row as any)[fFull.dbFieldName])).toEqual(1);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('Formula referencing formula: base change cascades old/new for all computed', async () => {\n      // T with base A and chained formulas: B={A}+1, C={B}*2, D={C}-{A}\n      const table = await createTable(baseId, {\n        name: 'Formula_Chain',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 2 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n\n      const b = await createField(table.id, {\n        name: 'B',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}} + 1` },\n      } as IFieldRo);\n      const c = await createField(table.id, {\n        name: 'C',\n        type: FieldType.Formula,\n        options: { expression: `{${b.id}} * 2` },\n      } as IFieldRo);\n      const d = await createField(table.id, {\n        name: 'D',\n        type: FieldType.Formula,\n        options: { expression: `{${c.id}} - {${aId}}` },\n      } as IFieldRo);\n\n      // Prime value to 2\n      await updateRecordByApi(table.id, table.records[0].id, aId, 2);\n\n      // Expect a single update event on this table; verify B,C,D old/new\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await updateRecordByApi(table.id, table.records[0].id, aId, 3);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const event = payloads[0] as any;\n        expect(event.payload.tableId).toBe(table.id);\n        const rec = Array.isArray(event.payload.record)\n          ? event.payload.record[0]\n          : event.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n\n        // A: 2 -> 3, so B: 3 -> 4, C: 6 -> 8, D: 4 -> 5\n        const bChange = assertChange(changes[b.id]);\n        expectNoOldValue(bChange);\n        expect(bChange.newValue).toEqual(4);\n\n        const cChange = assertChange(changes[c.id]);\n        expectNoOldValue(cChange);\n        expect(cChange.newValue).toEqual(8);\n\n        const dChange = assertChange(changes[d.id]);\n        expectNoOldValue(dChange);\n        expect(dChange.newValue).toEqual(5);\n      }\n\n      // DB: B=4, C=8, D=5\n      const dbName = await getDbTableName(table.id);\n      const row = await getRow(dbName, table.records[0].id);\n      const fields = await getFields(table.id);\n      const bFull = fields.find((x) => x.id === (b as any).id)! as any;\n      const cFull = fields.find((x) => x.id === (c as any).id)! as any;\n      const dFull = fields.find((x) => x.id === (d as any).id)! as any;\n      expect(parseMaybe((row as any)[bFull.dbFieldName])).toEqual(4);\n      expect(parseMaybe((row as any)[cFull.dbFieldName])).toEqual(8);\n      expect(parseMaybe((row as any)[dFull.dbFieldName])).toEqual(5);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('skips joining missing nested link CTEs when a foreign table is deleted', async () => {\n      const clients = await createTable(baseId, {\n        name: 'co-nested-link-clients',\n        fields: [{ name: 'Client Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { 'Client Name': 'ACME Corp' } }],\n      });\n      const projects = await createTable(baseId, {\n        name: 'co-nested-link-projects',\n        fields: [{ name: 'Project Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { 'Project Name': 'Apollo' } }],\n      });\n      const tasks = await createTable(baseId, {\n        name: 'co-nested-link-tasks',\n        fields: [{ name: 'Task Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { 'Task Name': 'Kickoff' } }],\n      });\n\n      try {\n        const clientNameFieldId = clients.fields.find((field) => field.name === 'Client Name')!.id;\n\n        const projectClientLink = await createField(projects.id, {\n          name: 'Client',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: clients.id,\n          } as ILinkFieldOptions,\n        } as IFieldRo);\n\n        const projectClientLookup = await createField(projects.id, {\n          name: 'Client Name Lookup',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: clients.id,\n            linkFieldId: projectClientLink.id,\n            lookupFieldId: clientNameFieldId,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const taskProjectLink = await createField(tasks.id, {\n          name: 'Project',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: projects.id,\n          } as ILinkFieldOptions,\n        } as IFieldRo);\n\n        const taskClientLookup = await createField(tasks.id, {\n          name: 'Task Client',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: projects.id,\n            linkFieldId: taskProjectLink.id,\n            lookupFieldId: projectClientLookup.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const clientRecordId = clients.records[0].id;\n        const projectRecordId = projects.records[0].id;\n        const taskRecordId = tasks.records[0].id;\n\n        await updateRecordByApi(projects.id, projectRecordId, projectClientLink.id, {\n          id: clientRecordId,\n        });\n        await updateRecordByApi(tasks.id, taskRecordId, taskProjectLink.id, {\n          id: projectRecordId,\n        });\n\n        const beforeDelete = await getRecord(tasks.id, taskRecordId);\n        expect(beforeDelete.fields?.[taskClientLookup.id]).toBe('ACME Corp');\n\n        await permanentDeleteTable(baseId, clients.id);\n\n        await expect(\n          updateRecordByApi(tasks.id, taskRecordId, taskProjectLink.id, null)\n        ).resolves.toBeDefined();\n\n        const afterUpdate = await getRecord(tasks.id, taskRecordId);\n        expect(afterUpdate.fields?.[taskClientLookup.id]).toBeUndefined();\n      } finally {\n        await permanentDeleteTable(baseId, tasks.id).catch(() => undefined);\n        await permanentDeleteTable(baseId, projects.id).catch(() => undefined);\n        await permanentDeleteTable(baseId, clients.id).catch(() => undefined);\n      }\n    });\n\n    it('persists multi-value date lookup formulas without timezone cast regressions', async () => {\n      const parent = await createTable(baseId, { name: 'Formula_Lookup_Parent', fields: [] });\n      const child = await createTable(baseId, { name: 'Formula_Lookup_Child', fields: [] });\n\n      try {\n        const childDateField = await createField(child.id, {\n          name: 'Session Time',\n          type: FieldType.Date,\n        } as IFieldRo);\n\n        const linkField = await createField(parent.id, {\n          name: 'Sessions',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: child.id,\n          } as ILinkFieldOptions,\n        } as IFieldRo);\n\n        const symmetricFieldId = (linkField.options as ILinkFieldOptions)\n          .symmetricFieldId as string;\n\n        const lookupField = await createField(parent.id, {\n          name: 'All Session Times',\n          type: FieldType.Date,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: child.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: childDateField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const formulaField = await createField(parent.id, {\n          name: 'Follow Up Session',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATE_ADD({${lookupField.id}}, 14, 'day')`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const parentRecord = await createRecords(parent.id, { records: [{ fields: {} }] });\n        const parentRecordId = parentRecord.records[0].id;\n\n        const childRecord = await createRecords(child.id, {\n          typecast: true,\n          records: [\n            {\n              fields: {\n                [childDateField.id]: '2024-01-01T00:00:00.000Z',\n                [symmetricFieldId]: { id: parentRecordId },\n              },\n            },\n          ],\n        });\n        const childRecordId = childRecord.records[0].id;\n\n        // Ensure parent link field references the child so lookup returns multi-value array\n        await updateRecordByApi(parent.id, parentRecordId, linkField.id, [{ id: childRecordId }]);\n\n        const persistedParent = await getRecord(parent.id, parentRecordId);\n        const followUpValue = persistedParent.fields?.[formulaField.id];\n        expect(followUpValue).toBeTruthy();\n        const followUpTz = dayjs(followUpValue as string).tz('Asia/Shanghai');\n\n        const baseLookupRaw = persistedParent.fields?.[lookupField.id];\n        const baseIso =\n          typeof baseLookupRaw === 'string'\n            ? baseLookupRaw\n            : Array.isArray(baseLookupRaw)\n              ? (baseLookupRaw[0] as string | undefined)\n              : undefined;\n        expect(baseIso).toBeTruthy();\n        const baseTz = dayjs(baseIso as string).tz('Asia/Shanghai');\n\n        expect(followUpTz.diff(baseTz, 'day')).toBe(14);\n      } finally {\n        await permanentDeleteTable(baseId, child.id);\n        await permanentDeleteTable(baseId, parent.id);\n      }\n    });\n\n    it('persists datetime + blank guard formulas without timestamptz jsonb casts', async () => {\n      const table = await createTable(baseId, {\n        name: 'Formula_Datetime_Blank',\n        fields: [\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Due Date', type: FieldType.Date } as IFieldRo,\n        ],\n        records: [{ fields: {} }],\n      });\n\n      try {\n        const statusField = table.fields.find((f) => f.name === 'Status')!;\n        const dueField = table.fields.find((f) => f.name === 'Due Date')!;\n\n        const expression = `IF({${statusField.id}}=BLANK(),\"未分配\",IF(AND({${statusField.id}}=\"进行中\",DATETIME_DIFF(TODAY(),{${dueField.id}},\"day\")>=1),\"🔴超时\",\"🔵正常\"))`;\n        const formulaField = await createField(table.id, {\n          name: 'Status Summary',\n          type: FieldType.Formula,\n          options: {\n            expression,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const recordId = table.records[0].id;\n        const overdueDate = dayjs().tz('Asia/Shanghai').subtract(2, 'day').format('YYYY-MM-DD');\n\n        // Allow async computed persistence to populate the initial formula value\n        await new Promise((resolve) => setTimeout(resolve, 50));\n\n        const initial = await getRecord(table.id, recordId);\n        expect(initial.fields?.[formulaField.id]).toEqual('未分配');\n\n        await updateRecord(table.id, recordId, {\n          record: {\n            fields: {\n              [statusField.id]: '进行中',\n              [dueField.id]: overdueDate,\n            },\n          },\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n        });\n\n        const overdueRecord = await getRecord(table.id, recordId);\n        expect(overdueRecord.fields?.[formulaField.id]).toEqual('🔴超时');\n\n        await updateRecord(table.id, recordId, {\n          record: {\n            fields: {\n              [statusField.id]: null,\n            },\n          },\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n        });\n\n        const resetRecord = await getRecord(table.id, recordId);\n        expect(resetRecord.fields?.[formulaField.id]).toEqual('未分配');\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('handles divide and modulo by zero during computed persistence', async () => {\n      const table = await createTable(baseId, { name: 'Formula_Divide_Zero', fields: [] });\n\n      try {\n        const numeratorField = await createField(table.id, {\n          name: 'Numerator',\n          type: FieldType.Number,\n        } as IFieldRo);\n        const denominatorField = await createField(table.id, {\n          name: 'Denominator',\n          type: FieldType.Number,\n        } as IFieldRo);\n\n        const ratioField = await createField(table.id, {\n          name: 'Ratio',\n          type: FieldType.Formula,\n          options: { expression: `{${numeratorField.id}} / {${denominatorField.id}}` },\n        } as IFieldRo);\n\n        const remainderField = await createField(table.id, {\n          name: 'Remainder',\n          type: FieldType.Formula,\n          options: { expression: `{${numeratorField.id}} % {${denominatorField.id}}` },\n        } as IFieldRo);\n\n        const created = await createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [numeratorField.id]: 10,\n                [denominatorField.id]: 0,\n              },\n            },\n          ],\n        });\n        const recordId = created.records[0].id;\n\n        const record = await getRecord(table.id, recordId);\n        expect(record.fields?.[ratioField.id] ?? null).toBeNull();\n        expect(record.fields?.[remainderField.id] ?? null).toBeNull();\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n  });\n\n  describe('Query Builder Selection', () => {\n    it('falls back to raw column selection when conditional lookup CTE is not joined', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Selection_Foreign',\n        fields: [{ name: 'Value', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { Value: 10 } }],\n      });\n      const foreignValueId = foreign.fields.find((f) => f.name === 'Value')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalLookup_Selection_Host',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Row' } }],\n      });\n\n      const conditionalLookup = await createField(host.id, {\n        name: 'Filtered Value',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignValueId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: foreignValueId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const hostDomain = await tableDomainQueryService.getTableDomainById(host.id);\n      const lookupField = hostDomain.getField(conditionalLookup.id);\n      expect(lookupField?.isConditionalLookup).toBe(true);\n\n      const state = new RecordQueryBuilderManager('table');\n      const cteName = `CTE_CONDITIONAL_LOOKUP_${conditionalLookup.id}`;\n      state.setFieldCte(conditionalLookup.id, cteName);\n\n      const visitor = new FieldSelectVisitor(\n        knex.queryBuilder(),\n        db,\n        hostDomain,\n        state,\n        recordDialect,\n        't',\n        true,\n        true\n      );\n\n      const selection = lookupField!.accept(visitor);\n      const selectionSql = typeof selection === 'string' ? selection : selection.toQuery();\n      expect(selectionSql).toBe(`\"t\".\"${lookupField!.dbFieldName}\"`);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n  });\n\n  // ===== Lookup & Rollup related =====\n  describe('Lookup & Rollup', () => {\n    it('updates lookup when link changes (ManyOne, single value)', async () => {\n      // T1 with numeric source\n      const t1 = await createTable(baseId, {\n        name: 'LinkChange_M1_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 123 } }, { fields: { A: 456 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n\n      // T2 with ManyOne link -> T1 and a lookup of A\n      const t2 = await createTable(baseId, {\n        name: 'LinkChange_M1_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t2.id, {\n        name: 'L_T1_M1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkp = await createField(t2.id, {\n        name: 'LKP_A',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link.id, lookupFieldId: aId } as any,\n      } as any);\n\n      // Set link to first record (A=123)\n      await updateRecordByApi(t2.id, t2.records[0].id, link.id, { id: t1.records[0].id });\n\n      // Switch link to second record (A=456). Capture updates; assert T2 lookup old/new and DB persisted\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(t2.id, t2.records[0].id, link.id, { id: t1.records[1].id });\n      });\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evt = events.find((e) => e.payload.tableId === t2.id)!;\n        const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n        const lkpChange = assertChange(changes[lkp.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toEqual(456);\n      }\n\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const lkpFull = (await getFields(t2.id)).find((f) => f.id === (lkp as any).id)! as any;\n      expect(parseMaybe((t2Row as any)[lkpFull.dbFieldName])).toEqual(456);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('post-convert (one-way -> two-way) persists symmetric link values on foreign table', async () => {\n      // T1 with title and one record\n      const t1 = await createTable(baseId, {\n        name: 'Conv_OW_TO_TW_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'A1' } }],\n      });\n\n      // T2 with title and one record\n      const t2 = await createTable(baseId, {\n        name: 'Conv_OW_TO_TW_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'B1' } }],\n      });\n\n      // Create a one-way OneMany link on T1 -> T2\n      const linkOnT1 = await createField(t1.id, {\n        name: 'L_T2_OM_OW',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: t2.id, isOneWay: true },\n      } as IFieldRo);\n\n      // Set T1[A1].L_T2_OM_OW = [T2[B1]]\n      await updateRecordByApi(t1.id, t1.records[0].id, linkOnT1.id, [{ id: t2.records[0].id }]);\n\n      // Convert link to two-way (still OneMany) and capture record.update events\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        return await convertField(t1.id, linkOnT1.id, {\n          id: linkOnT1.id,\n          type: FieldType.Link,\n          name: 'L_T2_OM_TW',\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: t2.id,\n            isOneWay: false,\n          },\n        } as any);\n      });\n\n      // Should have created a symmetric field on T2; resolve it by discovery\n      const t2FieldsAfter = await getFields(t2.id);\n      const symmetric = t2FieldsAfter.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n      const symmetricFieldId = symmetric.id;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evtOnT2 = events.find((e) => e.payload?.tableId === t2.id);\n        expect(evtOnT2).toBeDefined();\n        const recT2 = Array.isArray(evtOnT2!.payload.record)\n          ? evtOnT2!.payload.record.find((r: any) => r.id === t2.records[0].id)\n          : evtOnT2!.payload.record;\n        const changeOnT2 = recT2.fields?.[symmetricFieldId!];\n        expect(changeOnT2).toBeDefined();\n        expect(\n          changeOnT2.newValue?.id ||\n            (Array.isArray(changeOnT2.newValue) ? changeOnT2.newValue[0]?.id : undefined)\n        ).toBe(t1.records[0].id);\n      }\n\n      // DB: the symmetric physical column on T2[B1] should be populated with {id: A1}\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const symField = (await getFields(t2.id)).find((f) => f.id === symmetricFieldId)! as any;\n      const rawVal = (t2Row as any)[symField.dbFieldName];\n      const parsed = parseMaybe(rawVal);\n      const asObj = Array.isArray(parsed) ? parsed[0] : parsed;\n      expect(asObj?.id).toBe(t1.records[0].id);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('updates lookup when link array shrinks (OneMany, multi value)', async () => {\n      // T2 with numeric values\n      const t2 = await createTable(baseId, {\n        name: 'LinkChange_OM_T2',\n        fields: [{ name: 'V', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { V: 123 } }, { fields: { V: 456 } }],\n      });\n      const vId = t2.fields.find((f) => f.name === 'V')!.id;\n\n      // T1 with OneMany link -> T2 and lookup of V\n      const t1 = await createTable(baseId, {\n        name: 'LinkChange_OM_T1',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t1.id, {\n        name: 'L_T2_OM',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const lkp = await createField(t1.id, {\n        name: 'LKP_V',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t2.id, linkFieldId: link.id, lookupFieldId: vId } as any,\n      } as any);\n\n      // Set link to two records [123, 456]\n      await updateRecordByApi(t1.id, t1.records[0].id, link.id, [\n        { id: t2.records[0].id },\n        { id: t2.records[1].id },\n      ]);\n\n      // Shrink to single record [123]; assert T1 lookup old/new and DB persisted\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, link.id, [{ id: t2.records[0].id }]);\n      });\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evt = events.find((e) => e.payload.tableId === t1.id)!;\n        const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n        const lkpChange = assertChange(changes[lkp.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toEqual([123]);\n      }\n\n      const t1Db = await getDbTableName(t1.id);\n      const t1Row = await getRow(t1Db, t1.records[0].id);\n      const lkpFull = (await getFields(t1.id)).find((f) => f.id === (lkp as any).id)! as any;\n      expect(parseMaybe((t1Row as any)[lkpFull.dbFieldName])).toEqual([123]);\n\n      await permanentDeleteTable(baseId, t1.id);\n      await permanentDeleteTable(baseId, t2.id);\n    });\n\n    it('updates lookup to null when link cleared (OneMany, multi value)', async () => {\n      // T2 with numeric values\n      const t2 = await createTable(baseId, {\n        name: 'LinkClear_OM_T2',\n        fields: [{ name: 'V', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { V: 11 } }, { fields: { V: 22 } }],\n      });\n      const vId = t2.fields.find((f) => f.name === 'V')!.id;\n\n      // T1 with OneMany link -> T2 and lookup of V\n      const t1 = await createTable(baseId, {\n        name: 'LinkClear_OM_T1',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t1.id, {\n        name: 'L_T2_OM_Clear',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const lkp = await createField(t1.id, {\n        name: 'LKP_V_Clear',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t2.id, linkFieldId: link.id, lookupFieldId: vId } as any,\n      } as any);\n\n      // Set link to two records [11, 22]\n      await updateRecordByApi(t1.id, t1.records[0].id, link.id, [\n        { id: t2.records[0].id },\n        { id: t2.records[1].id },\n      ]);\n\n      // Clear link to null; assert old/new and DB persisted NULL\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, link.id, null);\n      });\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evt = events.find((e) => e.payload.tableId === t1.id)!;\n        const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n        const lkpChange = assertChange(changes[lkp.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toBeNull();\n      }\n\n      const t1Db = await getDbTableName(t1.id);\n      const t1Row = await getRow(t1Db, t1.records[0].id);\n      const lkpFull = (await getFields(t1.id)).find((f) => f.id === (lkp as any).id)! as any;\n      expect((t1Row as any)[lkpFull.dbFieldName]).toBeNull();\n\n      await permanentDeleteTable(baseId, t1.id);\n      await permanentDeleteTable(baseId, t2.id);\n    });\n\n    it('updates lookup when link is replaced (ManyMany, multi value -> multi value)', async () => {\n      // T1 with numeric values\n      const t1 = await createTable(baseId, {\n        name: 'LinkReplace_MM_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 5 } }, { fields: { A: 7 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n\n      // T2 with ManyMany link -> T1 and lookup of A\n      const t2 = await createTable(baseId, {\n        name: 'LinkReplace_MM_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t2.id, {\n        name: 'L_T1_MM',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkp = await createField(t2.id, {\n        name: 'LKP_A_MM',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link.id, lookupFieldId: aId } as any,\n      } as any);\n\n      // Set link to [r1] -> lookup [5]\n      await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[0].id }]);\n\n      // Replace with [r2] -> lookup [7]\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[1].id }]);\n      });\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evt = events.find((e) => e.payload.tableId === t2.id)!;\n        const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n        const lkpChange = assertChange(changes[lkp.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toEqual([7]);\n      }\n\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const lkpFull = (await getFields(t2.id)).find((f) => f.id === (lkp as any).id)! as any;\n      expect(parseMaybe((t2Row as any)[lkpFull.dbFieldName])).toEqual([7]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('emits old/new values for lookup across tables when source changes', async () => {\n      // T1 with number\n      const t1 = await createTable(baseId, {\n        name: 'OldNew_Lookup_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 10 } }],\n      });\n      const t1A = t1.fields.find((f) => f.name === 'A')!.id;\n\n      await updateRecordByApi(t1.id, t1.records[0].id, t1A, 10);\n\n      // T2 link -> T1 and lookup A\n      const t2 = await createTable(baseId, {\n        name: 'OldNew_Lookup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link2 = await createField(t2.id, {\n        name: 'L2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkp2 = await createField(t2.id, {\n        name: 'LK1',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link2.id, lookupFieldId: t1A } as any,\n      } as any);\n\n      // Establish link values\n      await updateRecordByApi(t2.id, t2.records[0].id, link2.id, [{ id: t1.records[0].id }]);\n\n      // Expect two record.update events (T1 base, T2 lookup). Assert T2 lookup old/new\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, t1A, 20);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        // Find T2 event\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const changes = t2Event.payload.record.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        const lkpChange = assertChange(changes[lkp2.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toEqual([20]);\n      }\n\n      // DB: lookup column should be [20]\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const lkp2Full = (await getFields(t2.id)).find((f) => f.id === (lkp2 as any).id)! as any;\n      expect(parseMaybe((t2Row as any)[lkp2Full.dbFieldName])).toEqual([20]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('emits old/new values for rollup across tables when source changes', async () => {\n      // T1 with numbers\n      const t1 = await createTable(baseId, {\n        name: 'OldNew_Rollup_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 3 } }, { fields: { A: 7 } }],\n      });\n      const t1A = t1.fields.find((f) => f.name === 'A')!.id;\n\n      await updateRecordByApi(t1.id, t1.records[0].id, t1A, 3);\n      await updateRecordByApi(t1.id, t1.records[1].id, t1A, 7);\n\n      // T2 link -> T1 with rollup sum(A)\n      const t2 = await createTable(baseId, {\n        name: 'OldNew_Rollup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link2 = await createField(t2.id, {\n        name: 'L2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const roll2 = await createField(t2.id, {\n        name: 'R2',\n        type: FieldType.Rollup,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link2.id, lookupFieldId: t1A } as any,\n        options: { expression: 'sum({values})' } as any,\n      } as any);\n\n      // Establish links: T2 -> both rows in T1\n      await updateRecordByApi(t2.id, t2.records[0].id, link2.id, [\n        { id: t1.records[0].id },\n        { id: t1.records[1].id },\n      ]);\n\n      // Change one A: 3 -> 4; rollup 10 -> 11\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, t1A, 4);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        // Find T2 event\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const changes = t2Event.payload.record.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        const rollChange = assertChange(changes[roll2.id]);\n        expectNoOldValue(rollChange);\n        expect(rollChange.newValue).toEqual(11);\n      }\n\n      // DB: rollup column should be 11\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const roll2Full = (await getFields(t2.id)).find((f) => f.id === (roll2 as any).id)! as any;\n      expect(parseMaybe((t2Row as any)[roll2Full.dbFieldName])).toEqual(11);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('Cross-table chain: T3.lookup(T2.lookup(T1.formula(A))) updates when A changes', async () => {\n      // T1: A (number), F = A*3\n      const t1 = await createTable(baseId, {\n        name: 'Chain3_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 4 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      const f1 = await createField(t1.id, {\n        name: 'F',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}} * 3` },\n      } as IFieldRo);\n      // Prime A\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 4);\n\n      // T2: link -> T1, LKP2 = lookup(F)\n      const t2 = await createTable(baseId, {\n        name: 'Chain3_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l12 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkp2 = await createField(t2.id, {\n        name: 'LKP2',\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: l12.id, lookupFieldId: f1.id } as any,\n      } as any);\n      await updateRecordByApi(t2.id, t2.records[0].id, l12.id, [{ id: t1.records[0].id }]);\n\n      // T3: link -> T2, LKP3 = lookup(LKP2)\n      const t3 = await createTable(baseId, {\n        name: 'Chain3_T3',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l23 = await createField(t3.id, {\n        name: 'L_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const lkp3 = await createField(t3.id, {\n        name: 'LKP3',\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: t2.id,\n          linkFieldId: l23.id,\n          lookupFieldId: lkp2.id,\n        } as any,\n      } as any);\n      await updateRecordByApi(t3.id, t3.records[0].id, l23.id, [{ id: t2.records[0].id }]);\n\n      // Change A: 4 -> 5; then F: 12 -> 15; LKP2: [12] -> [15]; LKP3: [12] -> [15]\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        3\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, aId, 5);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        // T1\n        const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!;\n        const t1Changes = (\n          Array.isArray(t1Event.payload.record) ? t1Event.payload.record[0] : t1Event.payload.record\n        ).fields as FieldChangeMap;\n        const t1Change = assertChange(t1Changes[f1.id]);\n        expectNoOldValue(t1Change);\n        expect(t1Change.newValue).toEqual(15);\n\n        // T2\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const t2Changes = (\n          Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n        ).fields as FieldChangeMap;\n        const t2Change = assertChange(t2Changes[lkp2.id]);\n        expectNoOldValue(t2Change);\n        expect(t2Change.newValue).toEqual([15]);\n\n        // T3\n        const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!;\n        const t3Changes = (\n          Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record\n        ).fields as FieldChangeMap;\n        const t3Change = assertChange(t3Changes[lkp3.id]);\n        expectNoOldValue(t3Change);\n        expect(t3Change.newValue).toEqual([15]);\n      }\n\n      // DB: T1.F=15, T2.LKP2=[15], T3.LKP3=[15]\n      const t1Db = await getDbTableName(t1.id);\n      const t2Db = await getDbTableName(t2.id);\n      const t3Db = await getDbTableName(t3.id);\n      const t1Row = await getRow(t1Db, t1.records[0].id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const t3Row = await getRow(t3Db, t3.records[0].id);\n      const [f1Full] = (await getFields(t1.id)).filter((x) => x.id === (f1 as any).id) as any[];\n      const [lkp2Full] = (await getFields(t2.id)).filter((x) => x.id === (lkp2 as any).id) as any[];\n      const [lkp3Full] = (await getFields(t3.id)).filter((x) => x.id === (lkp3 as any).id) as any[];\n      expect(parseMaybe((t1Row as any)[f1Full.dbFieldName])).toEqual(15);\n      expect(parseMaybe((t2Row as any)[lkp2Full.dbFieldName])).toEqual([15]);\n      expect(parseMaybe((t3Row as any)[lkp3Full.dbFieldName])).toEqual([15]);\n\n      await permanentDeleteTable(baseId, t3.id);\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('handles interleaved lookup dependencies across tables', async () => {\n      // T1: base number\n      const t1 = await createTable(baseId, {\n        name: 'Interleave_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 1 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n\n      // T3: base number used by T2 lookup (creates table-level cycle)\n      const t3 = await createTable(baseId, {\n        name: 'Interleave_T3',\n        fields: [{ name: 'CBase', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { CBase: 5 } }],\n      });\n      const cBaseId = t3.fields.find((f) => f.name === 'CBase')!.id;\n\n      // T2: lookup A via link to T1; also lookup CBase via link to T3\n      const t2 = await createTable(baseId, {\n        name: 'Interleave_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const linkT1 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkpA = await createField(t2.id, {\n        name: 'LKP_A',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: linkT1.id, lookupFieldId: aId } as any,\n      } as any);\n      const linkT3 = await createField(t2.id, {\n        name: 'L_T3',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t3.id },\n      } as IFieldRo);\n      const lkpC = await createField(t2.id, {\n        name: 'LKP_C',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: t3.id,\n          linkFieldId: linkT3.id,\n          lookupFieldId: cBaseId,\n        } as any,\n      } as any);\n\n      // T3: lookup LKP_A from T2 (depends on T2)\n      const linkT2 = await createField(t3.id, {\n        name: 'L_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const lkpFromT2 = await createField(t3.id, {\n        name: 'LKP_T2_A',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: t2.id,\n          linkFieldId: linkT2.id,\n          lookupFieldId: lkpA.id,\n        } as any,\n      } as any);\n\n      // Establish links to create interleaved dependencies\n      await updateRecordByApi(t2.id, t2.records[0].id, linkT1.id, [{ id: t1.records[0].id }]);\n      await updateRecordByApi(t2.id, t2.records[0].id, linkT3.id, [{ id: t3.records[0].id }]);\n      await updateRecordByApi(t3.id, t3.records[0].id, linkT2.id, [{ id: t2.records[0].id }]);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        3\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, aId, 7);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const t2Changes = (\n          Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n        ).fields as FieldChangeMap;\n        const t2Change = assertChange(t2Changes[lkpA.id]);\n        expectNoOldValue(t2Change);\n        expect(t2Change.newValue).toEqual([7]);\n\n        const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!;\n        const t3Changes = (\n          Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record\n        ).fields as FieldChangeMap;\n        const t3Change = assertChange(t3Changes[lkpFromT2.id]);\n        expectNoOldValue(t3Change);\n        expect(t3Change.newValue).toEqual([7]);\n      }\n\n      const t2Db = await getDbTableName(t2.id);\n      const t3Db = await getDbTableName(t3.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const t3Row = await getRow(t3Db, t3.records[0].id);\n      const t2Fields = await getFields(t2.id);\n      const [lkpAFull] = t2Fields.filter((x) => x.id === (lkpA as any).id) as any[];\n      const [lkpCFull] = t2Fields.filter((x) => x.id === (lkpC as any).id) as any[];\n      const [lkpFromT2Full] = (await getFields(t3.id)).filter(\n        (x) => x.id === (lkpFromT2 as any).id\n      ) as any[];\n      expect(parseMaybe((t2Row as any)[lkpAFull.dbFieldName])).toEqual([7]);\n      expect(parseMaybe((t2Row as any)[lkpCFull.dbFieldName])).toEqual([5]);\n      expect(parseMaybe((t3Row as any)[lkpFromT2Full.dbFieldName])).toEqual([7]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t3.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('propagates multi-level lookup chain across four tables', async () => {\n      // T1: A (number)\n      const t1 = await createTable(baseId, {\n        name: 'Chain4_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 2 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 2);\n\n      // T2: link -> T1, L2 = lookup(A)\n      const t2 = await createTable(baseId, {\n        name: 'Chain4_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l12 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const l2 = await createField(t2.id, {\n        name: 'L2',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: l12.id, lookupFieldId: aId } as any,\n      } as any);\n      await updateRecordByApi(t2.id, t2.records[0].id, l12.id, [{ id: t1.records[0].id }]);\n\n      // T3: link -> T2, L3 = lookup(L2)\n      const t3 = await createTable(baseId, {\n        name: 'Chain4_T3',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l23 = await createField(t3.id, {\n        name: 'L_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const l3 = await createField(t3.id, {\n        name: 'L3',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t2.id, linkFieldId: l23.id, lookupFieldId: l2.id } as any,\n      } as any);\n      await updateRecordByApi(t3.id, t3.records[0].id, l23.id, [{ id: t2.records[0].id }]);\n\n      // T4: link -> T3, L4 = lookup(L3)\n      const t4 = await createTable(baseId, {\n        name: 'Chain4_T4',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l34 = await createField(t4.id, {\n        name: 'L_T3',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t3.id },\n      } as IFieldRo);\n      const l4 = await createField(t4.id, {\n        name: 'L4',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t3.id, linkFieldId: l34.id, lookupFieldId: l3.id } as any,\n      } as any);\n      await updateRecordByApi(t4.id, t4.records[0].id, l34.id, [{ id: t3.records[0].id }]);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        4\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, aId, 9);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const t2Changes = (\n          Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n        ).fields as FieldChangeMap;\n        const t2Change = assertChange(t2Changes[l2.id]);\n        expectNoOldValue(t2Change);\n        expect(t2Change.newValue).toEqual([9]);\n\n        const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!;\n        const t3Changes = (\n          Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record\n        ).fields as FieldChangeMap;\n        const t3Change = assertChange(t3Changes[l3.id]);\n        expectNoOldValue(t3Change);\n        expect(t3Change.newValue).toEqual([9]);\n\n        const t4Event = (payloads as any[]).find((e) => e.payload.tableId === t4.id)!;\n        const t4Changes = (\n          Array.isArray(t4Event.payload.record) ? t4Event.payload.record[0] : t4Event.payload.record\n        ).fields as FieldChangeMap;\n        const t4Change = assertChange(t4Changes[l4.id]);\n        expectNoOldValue(t4Change);\n        expect(t4Change.newValue).toEqual([9]);\n      }\n\n      const t2Db = await getDbTableName(t2.id);\n      const t3Db = await getDbTableName(t3.id);\n      const t4Db = await getDbTableName(t4.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const t3Row = await getRow(t3Db, t3.records[0].id);\n      const t4Row = await getRow(t4Db, t4.records[0].id);\n      const [l2Full] = (await getFields(t2.id)).filter((x) => x.id === (l2 as any).id) as any[];\n      const [l3Full] = (await getFields(t3.id)).filter((x) => x.id === (l3 as any).id) as any[];\n      const [l4Full] = (await getFields(t4.id)).filter((x) => x.id === (l4 as any).id) as any[];\n      expect(parseMaybe((t2Row as any)[l2Full.dbFieldName])).toEqual([9]);\n      expect(parseMaybe((t3Row as any)[l3Full.dbFieldName])).toEqual([9]);\n      expect(parseMaybe((t4Row as any)[l4Full.dbFieldName])).toEqual([9]);\n\n      await permanentDeleteTable(baseId, t4.id);\n      await permanentDeleteTable(baseId, t3.id);\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n  });\n\n  // ===== Conditional Rollup =====\n  describe('Conditional Rollup', () => {\n    it('reacts to foreign filter and lookup column changes', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Note', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'r1', Status: 'include', Note: 'alpha' } },\n          { fields: { Title: 'r2', Status: 'exclude', Note: 'beta' } },\n        ],\n      });\n      const titleId = foreign.fields.find((f) => f.name === 'Title')!.id;\n      const statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_Host',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      const filter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'include',\n          },\n        ],\n      } as any;\n\n      const { result: conditionalRollupField, events: creationEvents } =\n        await runAndCaptureRecordUpdates(async () => {\n          return await createField(host.id, {\n            name: 'Ref Count',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: titleId,\n              expression: 'count({values})',\n              filter,\n            },\n          } as IFieldRo);\n        });\n\n      if (!isForceV2) {\n        const hostCreateEvent = creationEvents.find((e) => e.payload.tableId === host.id);\n        expect(hostCreateEvent).toBeDefined();\n        const createRecordPayload = Array.isArray(hostCreateEvent!.payload.record)\n          ? hostCreateEvent!.payload.record[0]\n          : hostCreateEvent!.payload.record;\n        const createChanges = createRecordPayload.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        expect(createChanges[conditionalRollupField.id]).toBeDefined();\n        expect(createChanges[conditionalRollupField.id].newValue).toEqual(1);\n      }\n\n      const referenceEdges = await prisma.reference.findMany({\n        where: { toFieldId: conditionalRollupField.id },\n        select: { fromFieldId: true },\n      });\n      expect(referenceEdges.map((edge) => edge.fromFieldId)).toEqual(\n        expect.arrayContaining([titleId, statusId])\n      );\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find(\n        (f) => f.id === conditionalRollupField.id\n      )! as any;\n      expect(\n        parseMaybe((await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName])\n      ).toEqual(1);\n\n      const valueBeforeStatus = parseMaybe(\n        (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName]\n      );\n      expect(valueBeforeStatus).toEqual(1);\n\n      const { events: filterEvents } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(foreign.id, foreign.records[1].id, statusId, 'include');\n      });\n      const valueAfterStatus = parseMaybe(\n        (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName]\n      );\n      expect(valueAfterStatus).toEqual(2);\n      if (!isForceV2) {\n        const hostFilterEvent = filterEvents.find((e) => e.payload.tableId === host.id);\n        expect(hostFilterEvent).toBeDefined();\n        const filterRecordPayload = Array.isArray(hostFilterEvent!.payload.record)\n          ? hostFilterEvent!.payload.record[0]\n          : hostFilterEvent!.payload.record;\n        const filterChanges = filterRecordPayload.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        expect(filterChanges[conditionalRollupField.id]).toBeDefined();\n        expect(filterChanges[conditionalRollupField.id].newValue).toEqual(2);\n      }\n\n      const { events: lookupColumnEvents } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(foreign.id, foreign.records[0].id, titleId, null);\n      });\n      const valueAfterLookupColumnChange = parseMaybe(\n        (await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName]\n      );\n      expect(valueAfterLookupColumnChange).toEqual(1);\n      if (!isForceV2) {\n        const hostLookupEvent = lookupColumnEvents.find((e) => e.payload.tableId === host.id);\n        expect(hostLookupEvent).toBeDefined();\n        const lookupRecordPayload = Array.isArray(hostLookupEvent!.payload.record)\n          ? hostLookupEvent!.payload.record[0]\n          : hostLookupEvent!.payload.record;\n        const lookupChanges = lookupRecordPayload.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n        expect(lookupChanges[conditionalRollupField.id]).toBeDefined();\n        expect(lookupChanges[conditionalRollupField.id].newValue).toEqual(1);\n      }\n\n      expect(\n        parseMaybe((await getRow(hostDbTable, host.records[0].id))[hostFieldVo.dbFieldName])\n      ).toEqual(1);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    const setupEqualityConditionalRollup = async (\n      expression: string,\n      options?: {\n        extraFilterItems?: (ids: {\n          foreignEmailId: string;\n          foreignAmountId: string;\n          hostEmailId: string;\n        }) => IFilterItem[];\n      }\n    ) => {\n      const foreign = await createTable(baseId, {\n        name: `RefLookup_Equality_Foreign_${expression}`,\n        fields: [\n          { name: 'Email', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Email: 'alice@example.com', Amount: 10 } },\n          { fields: { Email: 'alice@example.com', Amount: 20 } },\n          { fields: { Email: 'bob@example.com', Amount: 5 } },\n        ],\n      });\n      const foreignEmailId = foreign.fields.find((f) => f.name === 'Email')!.id;\n      const foreignAmountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      const host = await createTable(baseId, {\n        name: `RefLookup_Equality_Host_${expression}`,\n        fields: [{ name: 'Email', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Email: 'alice@example.com' } },\n          { fields: { Email: 'nobody@example.com' } },\n        ],\n      });\n      const hostEmailId = host.fields.find((f) => f.name === 'Email')!.id;\n      const aliceRecordId = host.records[0].id;\n      const nobodyRecordId = host.records[1].id;\n\n      const filterSet: Array<IFilter | IFilterItem> = [\n        {\n          fieldId: foreignEmailId,\n          operator: FilterOperatorIs.value,\n          value: { type: 'field', fieldId: hostEmailId },\n        },\n      ];\n\n      const additionalFilterItems = options?.extraFilterItems?.({\n        foreignEmailId,\n        foreignAmountId,\n        hostEmailId,\n      });\n      if (additionalFilterItems?.length) {\n        filterSet.push(...additionalFilterItems);\n      }\n\n      const { result: rollupField, events } = await runAndCaptureRecordUpdates(async () => {\n        return await createField(host.id, {\n          name: `Equality ${expression}`,\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: foreignAmountId,\n            expression,\n            filter: {\n              conjunction: 'and',\n              filterSet,\n            },\n          },\n        } as IFieldRo);\n      });\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any;\n\n      return {\n        foreign,\n        host,\n        rollupField,\n        creationEvents: events,\n        foreignEmailId,\n        foreignAmountId,\n        hostEmailId,\n        hostDbTable,\n        hostFieldVo,\n        aliceRecordId,\n        nobodyRecordId,\n        cleanup: async () => {\n          await permanentDeleteTable(baseId, host.id);\n          await permanentDeleteTable(baseId, foreign.id);\n        },\n      };\n    };\n\n    const normalizeAggregateValue = (value: unknown): number | null | undefined => {\n      if (value === null || value === undefined) return value as null | undefined;\n      if (typeof value === 'number') return value;\n      if (typeof value === 'string' && value.trim().length) {\n        const parsed = Number(value);\n        if (!Number.isNaN(parsed)) return parsed;\n      }\n      return value as number | null | undefined;\n    };\n\n    const expectAggregateValue = (\n      value: unknown,\n      expected: number | null,\n      mode: 'equal' | 'closeTo' = 'equal'\n    ) => {\n      if (expected === null) {\n        expect(value === null || value === undefined).toBe(true);\n        return;\n      }\n      const normalized = normalizeAggregateValue(value);\n      expect(typeof normalized === 'number' && !Number.isNaN(normalized)).toBe(true);\n      if (mode === 'closeTo') {\n        expect(normalized as number).toBeCloseTo(expected, 6);\n      } else {\n        expect(normalized).toEqual(expected);\n      }\n    };\n\n    type EqualityAggregateContext = Awaited<ReturnType<typeof setupEqualityConditionalRollup>>;\n\n    const equalityAggregateCases: Array<{\n      expression: string;\n      initialAlice: number | null;\n      initialNobody: number | null;\n      updatedAlice: number | null;\n      updatedNobody?: number | null;\n      update: (ctx: EqualityAggregateContext) => Promise<void>;\n      compareMode?: 'equal' | 'closeTo';\n    }> = [\n      {\n        expression: 'count({values})',\n        initialAlice: 2,\n        initialNobody: 0,\n        updatedAlice: 3,\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 12,\n                },\n              },\n            ],\n          });\n        },\n      },\n      {\n        expression: 'countall({values})',\n        initialAlice: 2,\n        initialNobody: 0,\n        updatedAlice: 3,\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 9,\n                },\n              },\n            ],\n          });\n        },\n      },\n      {\n        expression: 'sum({values})',\n        initialAlice: 30,\n        initialNobody: 0,\n        updatedAlice: 45,\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 15,\n                },\n              },\n            ],\n          });\n        },\n      },\n      {\n        expression: 'average({values})',\n        initialAlice: 15,\n        initialNobody: 0,\n        updatedAlice: 20,\n        compareMode: 'closeTo',\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 30,\n                },\n              },\n            ],\n          });\n        },\n      },\n      {\n        expression: 'max({values})',\n        initialAlice: 20,\n        initialNobody: null,\n        updatedAlice: 25,\n        updatedNobody: null,\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 25,\n                },\n              },\n            ],\n          });\n        },\n      },\n      {\n        expression: 'min({values})',\n        initialAlice: 10,\n        initialNobody: null,\n        updatedAlice: 4,\n        updatedNobody: null,\n        update: async (ctx) => {\n          await createRecords(ctx.foreign.id, {\n            records: [\n              {\n                fields: {\n                  [ctx.foreignEmailId]: 'alice@example.com',\n                  [ctx.foreignAmountId]: 4,\n                },\n              },\n            ],\n          });\n        },\n      },\n    ];\n\n    describe('conditional rollup equality aggregates', () => {\n      it.each(equalityAggregateCases)(\n        'evaluates $expression with equality filter',\n        async ({\n          expression,\n          compareMode = 'equal',\n          initialAlice,\n          initialNobody,\n          updatedAlice,\n          updatedNobody,\n          update,\n        }) => {\n          const ctx = await setupEqualityConditionalRollup(expression);\n          const { cleanup } = ctx;\n          try {\n            if (!isForceV2) {\n              const createAliceChange = findRecordChangeMap(\n                ctx.creationEvents,\n                ctx.host.id,\n                ctx.aliceRecordId\n              );\n              expect(createAliceChange).toBeDefined();\n              expectAggregateValue(\n                createAliceChange?.[ctx.rollupField.id]?.newValue,\n                initialAlice,\n                compareMode\n              );\n\n              const createNobodyChange = findRecordChangeMap(\n                ctx.creationEvents,\n                ctx.host.id,\n                ctx.nobodyRecordId\n              );\n              expect(createNobodyChange).toBeDefined();\n              expectAggregateValue(\n                createNobodyChange?.[ctx.rollupField.id]?.newValue,\n                initialNobody,\n                compareMode\n              );\n            }\n\n            const initialAliceValue = parseMaybe(\n              (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName]\n            );\n            expectAggregateValue(initialAliceValue, initialAlice, compareMode);\n\n            const initialNobodyValue = parseMaybe(\n              (await getRow(ctx.hostDbTable, ctx.nobodyRecordId))[ctx.hostFieldVo.dbFieldName]\n            );\n            expectAggregateValue(initialNobodyValue, initialNobody, compareMode);\n\n            const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => {\n              await update(ctx);\n            });\n\n            if (!isForceV2) {\n              const updateAliceChange = findRecordChangeMap(\n                updateEvents,\n                ctx.host.id,\n                ctx.aliceRecordId\n              );\n              expect(updateAliceChange).toBeDefined();\n              expectAggregateValue(\n                updateAliceChange?.[ctx.rollupField.id]?.newValue,\n                updatedAlice,\n                compareMode\n              );\n            }\n\n            const updatedAliceValue = parseMaybe(\n              (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName]\n            );\n            expectAggregateValue(updatedAliceValue, updatedAlice, compareMode);\n\n            const updatedNobodyValue = parseMaybe(\n              (await getRow(ctx.hostDbTable, ctx.nobodyRecordId))[ctx.hostFieldVo.dbFieldName]\n            );\n            expectAggregateValue(updatedNobodyValue, updatedNobody ?? initialNobody, compareMode);\n          } finally {\n            await cleanup();\n          }\n        }\n      );\n\n      it('evaluates sum({values}) with equality and additional predicates', async () => {\n        const ctx = await setupEqualityConditionalRollup('sum({values})', {\n          extraFilterItems: ({ foreignAmountId }) => [\n            {\n              fieldId: foreignAmountId,\n              operator: FilterOperatorIsGreater.value,\n              value: 10,\n            },\n            {\n              fieldId: foreignAmountId,\n              operator: FilterOperatorIsNotEmpty.value,\n              value: null,\n            },\n          ],\n        });\n        const { cleanup } = ctx;\n        try {\n          if (!isForceV2) {\n            const createAliceChange = findRecordChangeMap(\n              ctx.creationEvents,\n              ctx.host.id,\n              ctx.aliceRecordId\n            );\n            expect(createAliceChange).toBeDefined();\n            expectAggregateValue(createAliceChange?.[ctx.rollupField.id]?.newValue, 20, 'equal');\n\n            const createNobodyChange = findRecordChangeMap(\n              ctx.creationEvents,\n              ctx.host.id,\n              ctx.nobodyRecordId\n            );\n            expect(createNobodyChange).toBeDefined();\n            expectAggregateValue(createNobodyChange?.[ctx.rollupField.id]?.newValue, 0, 'equal');\n          }\n\n          const initialAliceValue = parseMaybe(\n            (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName]\n          );\n          expectAggregateValue(initialAliceValue, 20, 'equal');\n\n          const initialNobodyValue = parseMaybe(\n            (await getRow(ctx.hostDbTable, ctx.nobodyRecordId))[ctx.hostFieldVo.dbFieldName]\n          );\n          expectAggregateValue(initialNobodyValue, 0, 'equal');\n\n          const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => {\n            await createRecords(ctx.foreign.id, {\n              records: [\n                {\n                  fields: {\n                    [ctx.foreignEmailId]: 'alice@example.com',\n                    [ctx.foreignAmountId]: 15,\n                  },\n                },\n              ],\n            });\n          });\n\n          if (!isForceV2) {\n            const updateAliceChange = findRecordChangeMap(\n              updateEvents,\n              ctx.host.id,\n              ctx.aliceRecordId\n            );\n            expect(updateAliceChange).toBeDefined();\n            expectAggregateValue(updateAliceChange?.[ctx.rollupField.id]?.newValue, 35, 'equal');\n          }\n\n          const updatedAliceValue = parseMaybe(\n            (await getRow(ctx.hostDbTable, ctx.aliceRecordId))[ctx.hostFieldVo.dbFieldName]\n          );\n          expectAggregateValue(updatedAliceValue, 35, 'equal');\n\n          const updatedNobodyValue = parseMaybe(\n            (await getRow(ctx.hostDbTable, ctx.nobodyRecordId))[ctx.hostFieldVo.dbFieldName]\n          );\n          expectAggregateValue(updatedNobodyValue, 0, 'equal');\n        } finally {\n          await cleanup();\n        }\n      });\n    });\n\n    it('aggregates with equality-filtered sum referencing host fields', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_Sum_Equality_Foreign',\n        fields: [\n          { name: 'Email', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Email: 'alice@example.com', Amount: 10 } },\n          { fields: { Email: 'alice@example.com', Amount: 20 } },\n          { fields: { Email: 'bob@example.com', Amount: 5 } },\n        ],\n      });\n      const foreignEmailId = foreign.fields.find((f) => f.name === 'Email')!.id;\n      const foreignAmountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_Sum_Equality_Host',\n        fields: [{ name: 'Email', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Email: 'alice@example.com' } },\n          { fields: { Email: 'nobody@example.com' } },\n        ],\n      });\n      const hostEmailId = host.fields.find((f) => f.name === 'Email')!.id;\n      const aliceId = host.records[0].id;\n      const nobodyId = host.records[1].id;\n\n      const { result: rollupField, events: creationEvents } = await runAndCaptureRecordUpdates(\n        async () => {\n          return await createField(host.id, {\n            name: 'Sum By Email',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: foreignAmountId,\n              expression: 'sum({values})',\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: foreignEmailId,\n                    operator: 'is',\n                    value: { type: 'field', fieldId: hostEmailId },\n                  },\n                ],\n              },\n            },\n          } as IFieldRo);\n        }\n      );\n\n      if (!isForceV2) {\n        const createAliceChange = findRecordChangeMap(creationEvents, host.id, aliceId);\n        expect(createAliceChange).toBeDefined();\n        expect(createAliceChange?.[rollupField.id]?.newValue).toEqual(30);\n        const createNobodyChange = findRecordChangeMap(creationEvents, host.id, nobodyId);\n        expect(createNobodyChange).toBeDefined();\n        expect(createNobodyChange?.[rollupField.id]?.newValue).toEqual(0);\n      }\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any;\n      expect(parseMaybe((await getRow(hostDbTable, aliceId))[hostFieldVo.dbFieldName])).toEqual(30);\n      expect(parseMaybe((await getRow(hostDbTable, nobodyId))[hostFieldVo.dbFieldName])).toEqual(0);\n\n      const { events: updateEvents } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(foreign.id, foreign.records[0].id, foreignAmountId, 15);\n      });\n      if (!isForceV2) {\n        const updateAliceChange = findRecordChangeMap(updateEvents, host.id, aliceId);\n        expect(updateAliceChange).toBeDefined();\n        expect(updateAliceChange?.[rollupField.id]?.newValue).toEqual(35);\n        const updateNobodyChange = findRecordChangeMap(updateEvents, host.id, nobodyId);\n        expect(updateNobodyChange?.[rollupField.id]).toBeUndefined();\n      }\n      expect(parseMaybe((await getRow(hostDbTable, aliceId))[hostFieldVo.dbFieldName])).toEqual(35);\n      expect(parseMaybe((await getRow(hostDbTable, nobodyId))[hostFieldVo.dbFieldName])).toEqual(0);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('evaluates equality filter comparing link titles to host text', async () => {\n      const tags = await createTable(baseId, {\n        name: 'RefLookup_LinkTitle_Tags',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Name: 'TagA' } }, { fields: { Name: 'TagB' } }],\n      });\n      const tagARecordId = tags.records.find((r) => r.fields.Name === 'TagA')!.id;\n      const tagBRecordId = tags.records.find((r) => r.fields.Name === 'TagB')!.id;\n\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_LinkTitle_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Tags',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: tags.id,\n            } as ILinkFieldOptions,\n          } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'r1', Amount: 10 } },\n          { fields: { Title: 'r2', Amount: 20 } },\n          { fields: { Title: 'r3', Amount: 5 } },\n        ],\n      });\n      const foreignTagsId = foreign.fields.find((f) => f.name === 'Tags')!.id;\n      const foreignAmountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      await updateRecordByApi(foreign.id, foreign.records[0].id, foreignTagsId, [\n        { id: tagARecordId },\n      ]);\n      await updateRecordByApi(foreign.id, foreign.records[1].id, foreignTagsId, [\n        { id: tagBRecordId },\n      ]);\n      await updateRecordByApi(foreign.id, foreign.records[2].id, foreignTagsId, [\n        { id: tagARecordId },\n        { id: tagBRecordId },\n      ]);\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_LinkTitle_Host',\n        fields: [{ name: 'TagName', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { TagName: 'TagA' } },\n          { fields: { TagName: 'TagB' } },\n          { fields: { TagName: 'TagC' } },\n        ],\n      });\n      const hostTagNameId = host.fields.find((f) => f.name === 'TagName')!.id;\n      const hostAId = host.records[0].id;\n      const hostBId = host.records[1].id;\n      const hostCId = host.records[2].id;\n\n      const { result: rollupField, events: creationEvents } = await runAndCaptureRecordUpdates(\n        async () => {\n          return await createField(host.id, {\n            name: 'Sum By Tag Title',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: foreignAmountId,\n              expression: 'sum({values})',\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: foreignTagsId,\n                    operator: FilterOperatorIs.value,\n                    value: { type: 'field', fieldId: hostTagNameId },\n                  },\n                ],\n              },\n            },\n          } as IFieldRo);\n        }\n      );\n\n      if (!isForceV2) {\n        const createAChange = findRecordChangeMap(creationEvents, host.id, hostAId);\n        expect(createAChange).toBeDefined();\n        expect(createAChange?.[rollupField.id]?.newValue).toEqual(15);\n\n        const createBChange = findRecordChangeMap(creationEvents, host.id, hostBId);\n        expect(createBChange).toBeDefined();\n        expect(createBChange?.[rollupField.id]?.newValue).toEqual(25);\n\n        const createCChange = findRecordChangeMap(creationEvents, host.id, hostCId);\n        expect(createCChange).toBeDefined();\n        expect(createCChange?.[rollupField.id]?.newValue).toEqual(0);\n      }\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find((f) => f.id === rollupField.id)! as any;\n      expect(parseMaybe((await getRow(hostDbTable, hostAId))[hostFieldVo.dbFieldName])).toEqual(15);\n      expect(parseMaybe((await getRow(hostDbTable, hostBId))[hostFieldVo.dbFieldName])).toEqual(25);\n      expect(parseMaybe((await getRow(hostDbTable, hostCId))[hostFieldVo.dbFieldName])).toEqual(0);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n      await permanentDeleteTable(baseId, tags.id);\n    });\n\n    it('marks hasError when referenced lookup or filter fields are removed', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_Dependency_Foreign',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Name: 'rowA', Amount: 2, Status: 'active' } },\n          { fields: { Name: 'rowB', Amount: 5, Status: 'inactive' } },\n        ],\n      });\n      const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      const statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_Dependency_Host',\n        fields: [\n          { name: 'Primary', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'FilterValue', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [{ fields: { Primary: 'row1', FilterValue: 'active' } }],\n      });\n      const filterFieldId = host.fields.find((f) => f.name === 'FilterValue')!.id;\n\n      const amountLookup = await createField(host.id, {\n        name: 'Total Amount',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      const filter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: filterFieldId },\n          },\n        ],\n      } as any;\n\n      const statusLookup = await createField(host.id, {\n        name: 'Active Status Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: statusId,\n          expression: 'count({values})',\n          filter,\n        },\n      } as IFieldRo);\n\n      await deleteField(foreign.id, amountId);\n      const hostFieldsAfterLookupDelete = await getFields(host.id);\n      const amountLookupVo = hostFieldsAfterLookupDelete.find(\n        (f) => f.id === amountLookup.id\n      ) as any;\n      expect(amountLookupVo?.hasError).toBe(true);\n\n      await deleteField(foreign.id, statusId);\n      const hostFieldsAfterFilterDelete = await getFields(host.id);\n      const statusLookupVo = hostFieldsAfterFilterDelete.find(\n        (f) => f.id === statusLookup.id\n      ) as any;\n      expect(statusLookupVo?.hasError).toBe(true);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('recomputes when filter compares foreign field to host field and either side changes', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_FieldRef_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'r1', Status: 'A' } },\n          { fields: { Title: 'r2', Status: 'C' } },\n        ],\n      });\n      const titleId = foreign.fields.find((f) => f.name === 'Title')!.id;\n      const statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_FieldRef_Host',\n        fields: [{ name: 'Target', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Target: 'A' } }],\n      });\n      const targetFieldId = host.fields.find((f) => f.name === 'Target')!.id;\n      const hostRecordId = host.records[0].id;\n\n      const filter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetFieldId },\n          },\n        ],\n      } as any;\n\n      const { result: conditionalRollupField, events: creationEvents } =\n        await runAndCaptureRecordUpdates(async () => {\n          return await createField(host.id, {\n            name: 'Status Matches',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: titleId,\n              expression: 'count({values})',\n              filter,\n            },\n          } as IFieldRo);\n        });\n\n      if (!isForceV2) {\n        const createChange = findRecordChangeMap(creationEvents, host.id, hostRecordId);\n        expect(createChange).toBeDefined();\n        expect(createChange?.[conditionalRollupField.id]?.newValue).toEqual(1);\n      }\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find(\n        (f) => f.id === conditionalRollupField.id\n      )! as any;\n      expect(\n        parseMaybe((await getRow(hostDbTable, hostRecordId))[hostFieldVo.dbFieldName])\n      ).toEqual(1);\n\n      const { events: hostFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(host.id, hostRecordId, targetFieldId, 'B');\n      });\n      if (!isForceV2) {\n        const hostFieldChange = findRecordChangeMap(hostFieldChangeEvents, host.id, hostRecordId);\n        expect(hostFieldChange).toBeDefined();\n        const hostFieldLookupChange = assertChange(hostFieldChange?.[conditionalRollupField.id]);\n        expectNoOldValue(hostFieldLookupChange);\n        expect(hostFieldLookupChange.newValue).toEqual(0);\n      }\n\n      expect(\n        parseMaybe((await getRow(hostDbTable, hostRecordId))[hostFieldVo.dbFieldName])\n      ).toEqual(0);\n\n      const { events: foreignFieldChangeEvents } = await runAndCaptureRecordUpdates(async () => {\n        await updateRecordByApi(foreign.id, foreign.records[1].id, statusId, 'B');\n      });\n      if (!isForceV2) {\n        const foreignDrivenChange = findRecordChangeMap(\n          foreignFieldChangeEvents,\n          host.id,\n          hostRecordId\n        );\n        expect(foreignDrivenChange).toBeDefined();\n        const foreignLookupChange = assertChange(foreignDrivenChange?.[conditionalRollupField.id]);\n        expectNoOldValue(foreignLookupChange);\n        expect(foreignLookupChange.newValue).toEqual(1);\n      }\n\n      expect(\n        parseMaybe((await getRow(hostDbTable, hostRecordId))[hostFieldVo.dbFieldName])\n      ).toEqual(1);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('recomputes existing records when conditional rollup filter expands its matches', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RefLookup_FilterExpansion_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Note', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'r1', Status: 'include', Note: 'alpha' } },\n          { fields: { Title: 'r2', Status: 'exclude', Note: 'beta' } },\n        ],\n      });\n      const titleId = foreign.fields.find((f) => f.name === 'Title')!.id;\n      const statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n      const noteId = foreign.fields.find((f) => f.name === 'Note')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'RefLookup_FilterExpansion_Host',\n        fields: [{ name: 'DesiredStatus', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { DesiredStatus: 'include' } },\n          { fields: { DesiredStatus: 'exclude' } },\n        ],\n      });\n      const desiredStatusId = host.fields.find((f) => f.name === 'DesiredStatus')!.id;\n      const hostRecordAId = host.records[0].id;\n      const hostRecordBId = host.records[1].id;\n\n      const narrowFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: desiredStatusId },\n          },\n          {\n            fieldId: noteId,\n            operator: 'is',\n            value: 'alpha',\n          },\n        ],\n      } as any;\n\n      const { result: conditionalRollupField, events: createEvents } =\n        await runAndCaptureRecordUpdates(async () => {\n          return await createField(host.id, {\n            name: 'Matching Rows',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: titleId,\n              expression: 'count({values})',\n              filter: narrowFilter,\n            },\n          } as IFieldRo);\n        });\n\n      const hostDbTable = await getDbTableName(host.id);\n      const hostFieldVo = (await getFields(host.id)).find(\n        (f) => f.id === conditionalRollupField.id\n      )! as any;\n\n      if (!isForceV2) {\n        const createChangeA = findRecordChangeMap(createEvents, host.id, hostRecordAId);\n        expect(createChangeA).toBeDefined();\n        expect(createChangeA?.[conditionalRollupField.id]?.newValue).toEqual(1);\n\n        const createChangeB = findRecordChangeMap(createEvents, host.id, hostRecordBId);\n        expect(createChangeB).toBeDefined();\n        expect(createChangeB?.[conditionalRollupField.id]?.newValue).toEqual(0);\n      }\n\n      expect(\n        parseMaybe((await getRow(hostDbTable, hostRecordAId))[hostFieldVo.dbFieldName])\n      ).toEqual(1);\n      expect(\n        parseMaybe((await getRow(hostDbTable, hostRecordBId))[hostFieldVo.dbFieldName])\n      ).toEqual(0);\n\n      const wideFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: desiredStatusId },\n          },\n        ],\n      } as any;\n\n      const { events: filterChangeEvents } = await runAndCaptureRecordUpdates(async () => {\n        await convertField(host.id, conditionalRollupField.id, {\n          id: conditionalRollupField.id,\n          name: conditionalRollupField.name,\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            expression: 'count({values})',\n            filter: wideFilter,\n          },\n        } as IFieldRo);\n      });\n\n      if (!isForceV2) {\n        const updatedChangeA = findRecordChangeMap(filterChangeEvents, host.id, hostRecordAId);\n        if (updatedChangeA?.[conditionalRollupField.id]) {\n          const change = assertChange(updatedChangeA[conditionalRollupField.id]);\n          expectNoOldValue(change);\n          expect(change.newValue).toEqual(1);\n        }\n\n        const updatedChangeB = findRecordChangeMap(filterChangeEvents, host.id, hostRecordBId);\n        expect(updatedChangeB).toBeDefined();\n        const updatedLookupChangeB = assertChange(updatedChangeB?.[conditionalRollupField.id]);\n        expectNoOldValue(updatedLookupChangeB);\n        expect(updatedLookupChangeB.newValue).toEqual(1);\n      }\n\n      const valueAfterFilterChangeA = parseMaybe(\n        (await getRow(hostDbTable, hostRecordAId))[hostFieldVo.dbFieldName]\n      );\n      expect(valueAfterFilterChangeA).toEqual(1);\n\n      const valueAfterFilterChangeB = parseMaybe(\n        (await getRow(hostDbTable, hostRecordBId))[hostFieldVo.dbFieldName]\n      );\n      expect(valueAfterFilterChangeB).toEqual(1);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('handles self-table filters comparing multiple host fields without overflowing the stack', async () => {\n      const table = await createTable(baseId, {\n        name: 'RefLookup_Self_FieldRefs',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Category: 'A' } },\n          { fields: { Title: 'Alpha', Category: 'A' } },\n          { fields: { Title: 'Alpha', Category: 'B' } },\n          { fields: { Title: 'Beta', Category: 'A' } },\n        ],\n      });\n      const titleId = table.fields.find((f) => f.name === 'Title')!.id;\n      const categoryId = table.fields.find((f) => f.name === 'Category')!.id;\n      const firstAlphaId = table.records[0].id;\n      const secondAlphaId = table.records[1].id;\n      const alphaBId = table.records[2].id;\n      const betaId = table.records[3].id;\n\n      const duplicateFieldFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: titleId,\n            operator: 'is',\n            value: { type: 'field', fieldId: titleId, tableId: table.id },\n          },\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryId, tableId: table.id },\n          },\n        ],\n      } as any;\n\n      const { result: rollupField } = await runAndCaptureRecordUpdates(async () => {\n        return await createField(table.id, {\n          name: 'Self Scoped Count',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: table.id,\n            lookupFieldId: titleId,\n            expression: 'countall({values})',\n            filter: duplicateFieldFilter,\n          },\n        } as IFieldRo);\n      });\n\n      const references = await prisma.reference.findMany({\n        where: { toFieldId: rollupField.id },\n        select: { fromFieldId: true },\n      });\n      expect(references.map((ref) => ref.fromFieldId)).toEqual(\n        expect.arrayContaining([titleId, categoryId])\n      );\n\n      const tableRecords = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const countsById = new Map(\n        tableRecords.records.map((record) => [record.id, record.fields?.[rollupField.id]])\n      );\n      expect(countsById.get(firstAlphaId)).toEqual(2);\n      expect(countsById.get(secondAlphaId)).toEqual(2);\n      expect(countsById.get(alphaBId)).toEqual(1);\n      expect(countsById.get(betaId)).toEqual(1);\n\n      await updateRecordByApi(table.id, firstAlphaId, categoryId, 'B');\n\n      const updated = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const updatedCounts = new Map(\n        updated.records.map((record) => [record.id, record.fields?.[rollupField.id]])\n      );\n      expect(updatedCounts.get(firstAlphaId)).toEqual(2);\n      expect(updatedCounts.get(secondAlphaId)).toEqual(1);\n      expect(updatedCounts.get(alphaBId)).toEqual(2);\n      expect(updatedCounts.get(betaId)).toEqual(1);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n  });\n\n  // ===== Delete Field Computed Ops =====\n  describe('Delete Field', () => {\n    it('emits old->null for same-table formula when referenced field is deleted', async () => {\n      const table = await createTable(baseId, {\n        name: 'Del_Formula_SameTable',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 'r1', A: 5 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n      const f = await createField(table.id, {\n        name: 'F',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}} + 1` },\n      } as IFieldRo);\n\n      // Prime record value\n      await updateRecordByApi(table.id, table.records[0].id, aId, 5);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await deleteField(table.id, aId);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const event = payloads[0] as any;\n        expect(event.payload.tableId).toBe(table.id);\n        const rec = Array.isArray(event.payload.record)\n          ? event.payload.record[0]\n          : event.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n        const formulaChange = assertChange(changes[f.id]);\n        expectNoOldValue(formulaChange);\n        expect(formulaChange.newValue).toBeNull();\n      }\n\n      // DB: F should be null after delete of dependency\n      const dbName = await getDbTableName(table.id);\n      const row = await getRow(dbName, table.records[0].id);\n      const fFull = (await getFields(table.id)).find((x) => x.id === (f as any).id)! as any;\n      expect((row as any)[fFull.dbFieldName]).toBeNull();\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('emits old->null for multi-level formulas when base field is deleted', async () => {\n      const table = await createTable(baseId, {\n        name: 'Del_Multi_Formula',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 'r1', A: 2 } }],\n      });\n\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n      const b = await createField(table.id, {\n        name: 'B',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}} + 1` },\n      } as IFieldRo);\n      const c = await createField(table.id, {\n        name: 'C',\n        type: FieldType.Formula,\n        options: { expression: `{${b.id}} * 2` },\n      } as IFieldRo);\n\n      // Prime values\n      await updateRecordByApi(table.id, table.records[0].id, aId, 2);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await deleteField(table.id, aId);\n      })) as any;\n\n      // Event payload verification only in v1 mode\n      if (!isForceV2) {\n        const evt = payloads[0];\n        const rec = Array.isArray(evt.payload.record) ? evt.payload.record[0] : evt.payload.record;\n        const changes = rec.fields as FieldChangeMap;\n\n        // A: 2; B: 3; C: 6 -> null after delete\n        const bChange = assertChange(changes[b.id]);\n        expectNoOldValue(bChange);\n        expect(bChange.newValue).toBeNull();\n        const cChange = assertChange(changes[c.id]);\n        expectNoOldValue(cChange);\n        expect(cChange.newValue).toBeNull();\n      }\n\n      // DB: B and C should be null\n      const dbName = await getDbTableName(table.id);\n      const row = await getRow(dbName, table.records[0].id);\n      const fields = await getFields(table.id);\n      const bFull = fields.find((x) => x.id === (b as any).id)! as any;\n      const cFull = fields.find((x) => x.id === (c as any).id)! as any;\n      expect((row as any)[bFull.dbFieldName]).toBeNull();\n      expect((row as any)[cFull.dbFieldName]).toBeNull();\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('emits old->null for multi-level lookup when source field is deleted', async () => {\n      // T1: A (number)\n      const t1 = await createTable(baseId, {\n        name: 'Del_Multi_Lookup_T1',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 't1r1', A: 10 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 10);\n\n      // T2: link -> T1, L2 = lookup(A)\n      const t2 = await createTable(baseId, {\n        name: 'Del_Multi_Lookup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l12 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const l2 = await createField(t2.id, {\n        name: 'L2',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: l12.id, lookupFieldId: aId } as any,\n      } as any);\n      await updateRecordByApi(t2.id, t2.records[0].id, l12.id, [{ id: t1.records[0].id }]);\n\n      // T3: link -> T2, L3 = lookup(L2)\n      const t3 = await createTable(baseId, {\n        name: 'Del_Multi_Lookup_T3',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const l23 = await createField(t3.id, {\n        name: 'L_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const l3 = await createField(t3.id, {\n        name: 'L3',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t2.id, linkFieldId: l23.id, lookupFieldId: l2.id } as any,\n      } as any);\n      await updateRecordByApi(t3.id, t3.records[0].id, l23.id, [{ id: t2.records[0].id }]);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await deleteField(t1.id, aId);\n      })) as any;\n\n      if (!isForceV2) {\n        // T2\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const t2Changes = (\n          Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n        ).fields as FieldChangeMap;\n        const t2Change = assertChange(t2Changes[l2.id]);\n        expectNoOldValue(t2Change);\n        expect(t2Change.newValue).toBeNull();\n\n        // T3\n        const t3Event = (payloads as any[]).find((e) => e.payload.tableId === t3.id)!;\n        const t3Changes = (\n          Array.isArray(t3Event.payload.record) ? t3Event.payload.record[0] : t3Event.payload.record\n        ).fields as FieldChangeMap;\n        const t3Change = assertChange(t3Changes[l3.id]);\n        expectNoOldValue(t3Change);\n        expect(t3Change.newValue).toBeNull();\n      }\n\n      // DB: L2 and L3 should be null\n      const t2Db = await getDbTableName(t2.id);\n      const t3Db = await getDbTableName(t3.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const t3Row = await getRow(t3Db, t3.records[0].id);\n      const l2Full = (await getFields(t2.id)).find((x) => x.id === (l2 as any).id)! as any;\n      const l3Full = (await getFields(t3.id)).find((x) => x.id === (l3 as any).id)! as any;\n      expect((t2Row as any)[l2Full.dbFieldName]).toBeNull();\n      expect((t3Row as any)[l3Full.dbFieldName]).toBeNull();\n\n      await permanentDeleteTable(baseId, t3.id);\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('emits old->null for lookup when source field is deleted', async () => {\n      // T1 with A\n      const t1 = await createTable(baseId, {\n        name: 'Del_Lookup_T1',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 'r1', A: 10 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 10);\n\n      // T2 link -> T1 and lookup A\n      const t2 = await createTable(baseId, {\n        name: 'Del_Lookup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t2.id, {\n        name: 'L',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const lkp = await createField(t2.id, {\n        name: 'LKP',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link.id, lookupFieldId: aId } as any,\n      } as any);\n\n      await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[0].id }]);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await deleteField(t1.id, aId);\n      })) as any;\n\n      if (!isForceV2) {\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const changes = (\n          Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n        ).fields as FieldChangeMap;\n        const lkpChange = assertChange(changes[lkp.id]);\n        expectNoOldValue(lkpChange);\n        expect(lkpChange.newValue).toBeNull();\n      }\n\n      // DB: LKP should be null\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const lkpFull = (await getFields(t2.id)).find((x) => x.id === (lkp as any).id)! as any;\n      expect((t2Row as any)[lkpFull.dbFieldName]).toBeNull();\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it.skip('emits old->null for rollup when source field is deleted', async () => {\n      const t1 = await createTable(baseId, {\n        name: 'Del_Rollup_T1',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'A', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 'r1', A: 3 } }, { fields: { Title: 'r2', A: 7 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 3);\n      await updateRecordByApi(t1.id, t1.records[1].id, aId, 7);\n\n      const t2 = await createTable(baseId, {\n        name: 'Del_Rollup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      const roll = await createField(t2.id, {\n        name: 'R',\n        type: FieldType.Rollup,\n        lookupOptions: { foreignTableId: t1.id, linkFieldId: link.id, lookupFieldId: aId } as any,\n        options: { expression: 'sum({values})' } as any,\n      } as any);\n\n      await updateRecordByApi(t2.id, t2.records[0].id, link.id, [\n        { id: t1.records[0].id },\n        { id: t1.records[1].id },\n      ]);\n\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        1\n      )(async () => {\n        await deleteField(t1.id, aId);\n      })) as any;\n\n      const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n      const changes = (\n        Array.isArray(t2Event.payload.record) ? t2Event.payload.record[0] : t2Event.payload.record\n      ).fields as FieldChangeMap;\n      const rollChange = assertChange(changes[roll.id]);\n      expectNoOldValue(rollChange);\n      expect(rollChange.newValue).toBeNull();\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n  });\n\n  describe('Field Create/Update/Duplicate events', () => {\n    it('create: basic field does not trigger record.update; computed fields do when refs have values', async () => {\n      const table = await createTable(baseId, {\n        name: 'Create_Field_Event',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 1 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n\n      // Prime A\n      await updateRecordByApi(table.id, table.records[0].id, aId, 1);\n\n      // 1) basic field\n      {\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await createField(table.id, { name: 'B', type: FieldType.SingleLineText } as IFieldRo);\n        });\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const baseField = (await getFields(table.id)).find((f) => f.name === 'B')!;\n          const changeMap = toChangeMap(events[0]);\n          const bChange = assertChange(changeMap[baseField.id]);\n          expectNoOldValue(bChange);\n          expect(bChange.newValue).toBeNull();\n        }\n      }\n\n      // 2) formula referencing A -> expect 1 update with newValue\n      {\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await createField(table.id, {\n            name: 'F',\n            type: FieldType.Formula,\n            options: { expression: `{${aId}} + 1` },\n          } as IFieldRo);\n        });\n        const fId = (await getFields(table.id)).find((f) => f.name === 'F')!.id;\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const changeMap = toChangeMap(events[0]);\n          const fChange = assertChange(changeMap[fId]);\n          expectNoOldValue(fChange);\n          expect(fChange.newValue).toEqual(2);\n        }\n\n        // DB: F should equal 2\n        const tbl = await getDbTableName(table.id);\n        const row = await getRow(tbl, table.records[0].id);\n        const fFull = (await getFields(table.id)).find((x) => x.id === fId)! as any;\n        expect(parseMaybe((row as any)[fFull.dbFieldName])).toEqual(2);\n      }\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('create: lookup/rollup only trigger record.update when link + source values exist', async () => {\n      // T1 with A=10\n      const t1 = await createTable(baseId, {\n        name: 'Create_LookupRollup_T1',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 10 } }],\n      });\n      const aId = t1.fields.find((f) => f.name === 'A')!.id;\n      await updateRecordByApi(t1.id, t1.records[0].id, aId, 10);\n\n      // T2 single record without link\n      const t2 = await createTable(baseId, {\n        name: 'Create_LookupRollup_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      // 1) create lookup without link -> expect 0 updates\n      const link = await createField(t2.id, {\n        name: 'L',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n      {\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await createField(t2.id, {\n            name: 'LK',\n            type: FieldType.Number,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: t1.id,\n              linkFieldId: link.id,\n              lookupFieldId: aId,\n            } as any,\n          } as any);\n        });\n        const lkpField = (await getFields(t2.id)).find((f) => f.name === 'LK')!;\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const changeMap = toChangeMap(events[0]);\n          const lkpChange = assertChange(changeMap[lkpField.id]);\n          expectNoOldValue(lkpChange);\n          expect(lkpChange.newValue).toBeNull();\n        }\n\n        // DB: LK should be null when there is no link\n        const t2Db = await getDbTableName(t2.id);\n        const t2Row = await getRow(t2Db, t2.records[0].id);\n        const lkpFull = lkpField as any;\n        expect((t2Row as any)[lkpFull.dbFieldName]).toBeNull();\n      }\n\n      // Establish link and then create rollup -> expect 1 update\n      await updateRecordByApi(t2.id, t2.records[0].id, link.id, [{ id: t1.records[0].id }]);\n      {\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await createField(t2.id, {\n            name: 'R',\n            type: FieldType.Rollup,\n            lookupOptions: {\n              foreignTableId: t1.id,\n              linkFieldId: link.id,\n              lookupFieldId: aId,\n            } as any,\n            options: { expression: 'sum({values})' } as any,\n          } as any);\n        });\n        const rId = (await getFields(t2.id)).find((f) => f.name === 'R')!.id;\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const changeMap = toChangeMap(events[0]);\n          const rChange = assertChange(changeMap[rId]);\n          expectNoOldValue(rChange);\n          expect(rChange.newValue).toEqual(10);\n        }\n\n        // DB: R should equal 10\n        const t2Db = await getDbTableName(t2.id);\n        const t2Row = await getRow(t2Db, t2.records[0].id);\n        const rFull = (await getFields(t2.id)).find((f) => f.id === rId)! as any;\n        expect(parseMaybe((t2Row as any)[rFull.dbFieldName])).toEqual(10);\n      }\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('update(convert): changing a formula expression publishes record.update when values change', async () => {\n      const table = await createTable(baseId, {\n        name: 'Update_Field_Event',\n        fields: [{ name: 'A', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { A: 2 } }],\n      });\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n      const f = await createField(table.id, {\n        name: 'F',\n        type: FieldType.Formula,\n        options: { expression: `{${aId}}` },\n      } as IFieldRo);\n      await updateRecordByApi(table.id, table.records[0].id, aId, 2);\n\n      // convert F: {A} -> {A} + 5\n      const { events } = await runAndCaptureRecordUpdates(async () => {\n        await convertField(table.id, f.id, {\n          id: f.id,\n          type: FieldType.Formula,\n          name: f.name,\n          options: { expression: `{${aId}} + 5` },\n        } as any);\n      });\n      if (!isForceV2) {\n        expect(events.length).toBe(1);\n        const changeMap = toChangeMap(events[0]);\n        const fChange = assertChange(changeMap[f.id]);\n        expectNoOldValue(fChange);\n        expect(fChange.newValue).toEqual(7);\n      }\n\n      // DB: F should be 7 after convert\n      const tbl = await getDbTableName(table.id);\n      const row = await getRow(tbl, table.records[0].id);\n      const fFull = (await getFields(table.id)).find((x) => x.id === (f as any).id)! as any;\n      expect(parseMaybe((row as any)[fFull.dbFieldName])).toEqual(7);\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('duplicate: basic field with empty values does not trigger record.update; computed duplicate does', async () => {\n      const table = await createTable(baseId, {\n        name: 'Duplicate_Field_Event',\n        fields: [\n          { name: 'Text', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Num', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [{ fields: { Num: 3 } }],\n      });\n      const numId = table.fields.find((f) => f.name === 'Num')!.id;\n      await updateRecordByApi(table.id, table.records[0].id, numId, 3);\n\n      // Duplicate Text (empty values) -> expect 0 updates\n      {\n        const textField = (await getFields(table.id)).find((f) => f.name === 'Text')!;\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await duplicateField(table.id, textField.id, { name: 'Text_copy' });\n        });\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const textCopyField = (await getFields(table.id)).find((f) => f.name === 'Text_copy')!;\n          const changeMap = toChangeMap(events[0]);\n          const textCopyChange = assertChange(changeMap[textCopyField.id]);\n          expectNoOldValue(textCopyChange);\n          expect(textCopyChange.newValue).toBeNull();\n        }\n      }\n\n      // Add formula F = Num + 1; duplicate it -> expect updates for computed values\n      const f = await createField(table.id, {\n        name: 'F',\n        type: FieldType.Formula,\n        options: { expression: `{${numId}} + 1` },\n      } as IFieldRo);\n      {\n        const { events } = await runAndCaptureRecordUpdates(async () => {\n          await duplicateField(table.id, f.id, { name: 'F_copy' });\n        });\n        const fCopyId = (await getFields(table.id)).find((x) => x.name === 'F_copy')!.id;\n        if (!isForceV2) {\n          expect(events.length).toBe(1);\n          const changeMap = toChangeMap(events[0]);\n          const fCopyChange = assertChange(changeMap[fCopyId]);\n          expectNoOldValue(fCopyChange);\n          expect(fCopyChange.newValue).toEqual(4);\n        }\n\n        // DB: F_copy should equal 4\n        const tbl = await getDbTableName(table.id);\n        const row = await getRow(tbl, table.records[0].id);\n        const fCopyFull = (await getFields(table.id)).find((x) => x.id === fCopyId)! as any;\n        expect(parseMaybe((row as any)[fCopyFull.dbFieldName])).toEqual(4);\n      }\n\n      await permanentDeleteTable(baseId, table.id);\n    });\n  });\n\n  // ===== Link related =====\n  describe('Link', () => {\n    it('updates link titles when source record title changes (ManyMany)', async () => {\n      // T1 with title\n      const t1 = await createTable(baseId, {\n        name: 'LinkTitle_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Foo' } }],\n      });\n      const titleId = t1.fields.find((f) => f.name === 'Title')!.id;\n\n      // T2 link -> T1\n      const t2 = await createTable(baseId, {\n        name: 'LinkTitle_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link2 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n\n      // Establish link value\n      await updateRecordByApi(t2.id, t2.records[0].id, link2.id, [{ id: t1.records[0].id }]);\n\n      // Change title in T1, expect T2 link cell title updated in event\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t1.id, t1.records[0].id, titleId, 'Bar');\n      })) as any;\n\n      if (!isForceV2) {\n        // Find T2 event\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n        const changes = t2Event.payload.record.fields as FieldChangeMap;\n        const linkChange = assertChange(changes[link2.id]);\n        expectNoOldValue(linkChange);\n        expect([linkChange.newValue]?.flat()?.[0]?.title).toEqual('Bar');\n      }\n\n      // DB: link cell title should be updated to 'Bar'\n      const t2Db = await getDbTableName(t2.id);\n      const t2Row = await getRow(t2Db, t2.records[0].id);\n      const link2Full = (await getFields(t2.id)).find((f) => f.id === (link2 as any).id)! as any;\n      const linkCell = parseMaybe((t2Row as any)[link2Full.dbFieldName]) as any[] | undefined;\n      expect([linkCell]?.flat()?.[0]?.title).toEqual('Bar');\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('bidirectional link add/remove reflects on counterpart (multi-select)', async () => {\n      // T1 with title, two records\n      const t1 = await createTable(baseId, {\n        name: 'BiLink_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'A' } }, { fields: { Title: 'B' } }],\n      });\n\n      // T2 link -> T1\n      const t2 = await createTable(baseId, {\n        name: 'BiLink_T2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n      const link2 = await createField(t2.id, {\n        name: 'L_T1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      } as IFieldRo);\n\n      const r1 = t1.records[0].id;\n      const r2 = t1.records[1].id;\n      const t2r = t2.records[0].id;\n\n      // Initially set link to [r1]\n      await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r1 }]);\n      await processV2Outbox();\n\n      // Add r2: updates T2 link and T1[r2] symmetric\n      await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r1 }, { id: r2 }]);\n      await processV2Outbox();\n\n      // Remove r1: updates T2 link and T1[r1] symmetric\n      await updateRecordByApi(t2.id, t2r, link2.id, [{ id: r2 }]);\n      await processV2Outbox();\n\n      // Verify symmetric link fields on T1 via field discovery\n      const t1Fields = await getFields(t1.id);\n      const symOnT1 = t1Fields.find(\n        (f) => f.type === FieldType.Link && (f as any).options?.foreignTableId === t2.id\n      )!;\n      expect(symOnT1).toBeDefined();\n\n      // After removal, r1 should not link back; r2 should link back to T2r\n\n      // DB: verify physical link columns\n      const t2Db = await getDbTableName(t2.id);\n      const t1Db = await getDbTableName(t1.id);\n      const t2Row = await getRow(t2Db, t2r);\n      const link2Full = (await getFields(t2.id)).find((f) => f.id === (link2 as any).id)! as any;\n      const t2LinkIds = ((parseMaybe((t2Row as any)[link2Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      expect(t2LinkIds).toEqual([r2]);\n\n      const r1Row = await getRow(t1Db, r1);\n      const r2Row = await getRow(t1Db, r2);\n      const symFull = symOnT1 as any;\n      const r1Sym = (parseMaybe((r1Row as any)[symFull.dbFieldName]) as any[]) || [];\n      const r2SymIds = ((parseMaybe((r2Row as any)[symFull.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      expect(r1Sym.length).toBe(0);\n      expect(r2SymIds).toEqual([t2r]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('ManyMany bidirectional link: set 1-1 -> 2-1 publishes newValue on both sides', async () => {\n      // T1 with title and 3 records: 1-1, 1-2, 1-3\n      const t1 = await createTable(baseId, {\n        name: 'MM_Bidir_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Title: '1-1' } },\n          { fields: { Title: '1-2' } },\n          { fields: { Title: '1-3' } },\n        ],\n      });\n\n      // T2 with title and 3 records: 2-1, 2-2, 2-3\n      const t2 = await createTable(baseId, {\n        name: 'MM_Bidir_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Title: '2-1' } },\n          { fields: { Title: '2-2' } },\n          { fields: { Title: '2-3' } },\n        ],\n      });\n\n      // Create link on T1 -> T2 (ManyMany). This also creates symmetric link on T2 -> T1\n      const linkOnT1 = await createField(t1.id, {\n        name: 'Link_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n\n      // Find symmetric link field id on T2 -> T1\n      const t2Fields = await getFields(t2.id);\n      const linkOnT2 = t2Fields.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n\n      const r1_1 = t1.records[0].id; // 1-1\n      const r2_1 = t2.records[0].id; // 2-1\n\n      // Perform: set T1[1-1].Link_T2 = [2-1]\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t1.id, r1_1, linkOnT1.id, [{ id: r2_1 }]);\n      })) as any;\n\n      // Helper to normalize array-ish values\n      const norm = (v: any) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n      const idsOf = (v: any) =>\n        norm(v)\n          .map((x: any) => x?.id)\n          .filter(Boolean);\n\n      if (!isForceV2) {\n        // Expect: one event on T1[1-1] and one symmetric event on T2[2-1]\n        const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!;\n        const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n\n        // Assert T1 event: linkOnT1 newValue [2-1]\n        const t1Changes = t1Event.payload.record.fields as FieldChangeMap;\n        const t1Change = assertChange(t1Changes[linkOnT1.id]);\n        expectNoOldValue(t1Change);\n        expect(new Set(idsOf(t1Change.newValue))).toEqual(new Set([r2_1]));\n\n        // Assert T2 event: symmetric link newValue [1-1]\n        const t2Changes = t2Event.payload.record.fields as FieldChangeMap;\n        const t2Change = assertChange(t2Changes[linkOnT2.id]);\n        expectNoOldValue(t2Change);\n        expect(new Set(idsOf(t2Change.newValue))).toEqual(new Set([r1_1]));\n      }\n\n      // DB: verify both sides persisted\n      const t1Db = await getDbTableName(t1.id);\n      const t2Db = await getDbTableName(t2.id);\n      const t1Row = await getRow(t1Db, r1_1);\n      const t2Row = await getRow(t2Db, r2_1);\n      const linkOnT1Full = (await getFields(t1.id)).find(\n        (f) => f.id === (linkOnT1 as any).id\n      )! as any;\n      const linkOnT2Full = (await getFields(t2.id)).find(\n        (f) => f.id === (linkOnT2 as any).id\n      )! as any;\n      const t1Ids = ((parseMaybe((t1Row as any)[linkOnT1Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      const t2Ids = ((parseMaybe((t2Row as any)[linkOnT2Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      expect(t1Ids).toEqual([r2_1]);\n      expect(t2Ids).toEqual([r1_1]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('ManyMany multi-select: add and remove items trigger symmetric old/new on target rows', async () => {\n      // T1 with title and 1 record: A1\n      const t1 = await createTable(baseId, {\n        name: 'MM_AddRemove_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'A1' } }],\n      });\n\n      // T2 with title and 2 records: B1, B2\n      const t2 = await createTable(baseId, {\n        name: 'MM_AddRemove_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'B1' } }, { fields: { Title: 'B2' } }],\n      });\n\n      const linkOnT1 = await createField(t1.id, {\n        name: 'L_T2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n\n      const t2Fields = await getFields(t2.id);\n      const linkOnT2 = t2Fields.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n\n      const norm = (v: any) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n      const idsOf = (v: any) =>\n        norm(v)\n          .map((x: any) => x?.id)\n          .filter(Boolean);\n\n      const rA1 = t1.records[0].id;\n      const rB1 = t2.records[0].id;\n      const rB2 = t2.records[1].id;\n\n      const getChangeFromEvent = (\n        evt: any,\n        linkFieldId: string,\n        recordId?: string\n      ): FieldChangePayload | undefined => {\n        const recs = Array.isArray(evt.payload.record) ? evt.payload.record : [evt.payload.record];\n        const target = recordId ? recs.find((r: any) => r.id === recordId) : recs[0];\n        return target?.fields?.[linkFieldId];\n      };\n\n      // Step 1: set T1[A1] = [B1]; expect symmetric event on T2[B1]\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]);\n        })) as any;\n\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB1));\n          expectNoOldValue(change);\n          expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1]));\n        }\n      }\n\n      // Step 2: add B2 -> [B1, B2]; expect symmetric event for T2[B2]\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]);\n        })) as any;\n\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const change = assertChange(getChangeFromEvent(t2Event, linkOnT2.id, rB2));\n          expectNoOldValue(change);\n          expect(new Set(idsOf(change.newValue))).toEqual(new Set([rA1]));\n        }\n      }\n\n      // Step 3: remove B1 -> [B2]; expect symmetric removal event on T2[B1]\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]);\n        })) as any;\n\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const change = assertChange(\n            getChangeFromEvent(t2Event, linkOnT2.id, rB1) ||\n              getChangeFromEvent(t2Event, linkOnT2.id)\n          );\n          expectNoOldValue(change);\n          expect(norm(change.newValue).length).toBe(0);\n        }\n      }\n\n      // DB: final state T1[A1] -> [B2] and symmetric T2[B2] -> [A1]\n      const t1Db = await getDbTableName(t1.id);\n      const t2Db = await getDbTableName(t2.id);\n      const t1Row = await getRow(t1Db, rA1);\n      const t2RowB2 = await getRow(t2Db, rB2);\n      const linkOnT1Full = (await getFields(t1.id)).find(\n        (f) => f.id === (linkOnT1 as any).id\n      )! as any;\n      const linkOnT2Full = (await getFields(t2.id)).find(\n        (f) => f.id === (linkOnT2 as any).id\n      )! as any;\n      const t1Ids = ((parseMaybe((t1Row as any)[linkOnT1Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      const t2Ids = ((parseMaybe((t2RowB2 as any)[linkOnT2Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      expect(t1Ids).toEqual([rB2]);\n      expect(t2Ids).toEqual([rA1]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('ManyOne single-select: add and switch target emit symmetric add/remove with correct old/new', async () => {\n      // T1: many→one (single link)\n      const t1 = await createTable(baseId, {\n        name: 'M1_S_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'A1' } }],\n      });\n      const t2 = await createTable(baseId, {\n        name: 'M1_S_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'B1' } }, { fields: { Title: 'B2' } }],\n      });\n      const linkOnT1 = await createField(t1.id, {\n        name: 'L_T2_M1',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: t2.id },\n      } as IFieldRo);\n      const t2Fields = await getFields(t2.id);\n      const linkOnT2 = t2Fields.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n\n      const norm = (v: any) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n      const idsOf = (v: any) =>\n        norm(v)\n          .map((x: any) => x?.id)\n          .filter(Boolean);\n\n      const rA1 = t1.records[0].id;\n      const rB1 = t2.records[0].id;\n      const rB2 = t2.records[1].id;\n\n      // Set A1 -> B1\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB1 });\n        })) as any;\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const recs = Array.isArray(t2Event.payload.record)\n            ? t2Event.payload.record\n            : [t2Event.payload.record];\n          const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as\n            | FieldChangePayload\n            | undefined;\n          const linkChange = assertChange(change);\n          expectNoOldValue(linkChange);\n          expect(new Set(idsOf(linkChange.newValue))).toEqual(new Set([rA1]));\n        }\n      }\n\n      // Switch A1 -> B2 (removes from B1, adds to B2)\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, { id: rB2 });\n        })) as any;\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const recs = Array.isArray(t2Event.payload.record)\n            ? t2Event.payload.record\n            : [t2Event.payload.record];\n          const changeFor = (recordId: string) =>\n            recs.find((r: any) => r.id === recordId)?.fields?.[linkOnT2.id] as\n              | FieldChangePayload\n              | undefined;\n          const removal = assertChange(changeFor(rB1));\n          expectNoOldValue(removal);\n          expect(norm(removal.newValue).length).toBe(0);\n\n          const addition = assertChange(changeFor(rB2));\n          expectNoOldValue(addition);\n          expect(new Set(idsOf(addition.newValue))).toEqual(new Set([rA1]));\n        }\n      }\n\n      // DB: final state T1[A1] -> {id: B2} and symmetric on T2\n      const t1Db = await getDbTableName(t1.id);\n      const t2Db = await getDbTableName(t2.id);\n      const t1Row = await getRow(t1Db, rA1);\n      const t2RowB1 = await getRow(t2Db, rB1);\n      const t2RowB2 = await getRow(t2Db, rB2);\n      const linkOnT1Full = (await getFields(t1.id)).find(\n        (f) => f.id === (linkOnT1 as any).id\n      )! as any;\n      const linkOnT2Full = (await getFields(t2.id)).find(\n        (f) => f.id === (linkOnT2 as any).id\n      )! as any;\n      const t1Val = parseMaybe((t1Row as any)[linkOnT1Full.dbFieldName]) as any[] | any | null;\n      const b1Val = parseMaybe((t2RowB1 as any)[linkOnT2Full.dbFieldName]) as any[] | any | null;\n      const b2Val = parseMaybe((t2RowB2 as any)[linkOnT2Full.dbFieldName]) as any[] | any | null;\n      const asArr = (v: any) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n      expect(asArr(t1Val).map((x) => x?.id)).toEqual([rB2]);\n      expect(asArr(b1Val).length).toBe(0);\n      expect(asArr(b2Val).map((x) => x?.id)).toEqual([rA1]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('OneMany multi-select: add/remove items emit symmetric single-link old/new on foreign rows', async () => {\n      // T1: one→many (multi link on source)\n      const t1 = await createTable(baseId, {\n        name: '1M_M_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'A1' } }],\n      });\n      const t2 = await createTable(baseId, {\n        name: '1M_M_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'B1' } }, { fields: { Title: 'B2' } }],\n      });\n      const linkOnT1 = await createField(t1.id, {\n        name: 'L_T2_1M',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const t2Fields = await getFields(t2.id);\n      const linkOnT2 = t2Fields.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n\n      const rA1 = t1.records[0].id;\n      const rB1 = t2.records[0].id;\n      const rB2 = t2.records[1].id;\n\n      // Set [B1]\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }]);\n        })) as any;\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const recs = Array.isArray(t2Event.payload.record)\n            ? t2Event.payload.record\n            : [t2Event.payload.record];\n          const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as\n            | FieldChangePayload\n            | undefined;\n          const addChange = assertChange(change);\n          expectNoOldValue(addChange);\n          expect(addChange.newValue?.id).toBe(rA1);\n        }\n      }\n\n      // Add B2 -> [B1, B2]; expect symmetric add on B2\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB1 }, { id: rB2 }]);\n        })) as any;\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const recs = Array.isArray(t2Event.payload.record)\n            ? t2Event.payload.record\n            : [t2Event.payload.record];\n          const change = recs.find((r: any) => r.id === rB2)?.fields?.[linkOnT2.id] as\n            | FieldChangePayload\n            | undefined;\n          const addChange = assertChange(change);\n          expectNoOldValue(addChange);\n          expect(addChange.newValue?.id).toBe(rA1);\n        }\n      }\n\n      // Remove B1 -> [B2]; expect symmetric removal on B1\n      {\n        const { payloads } = (await createAwaitWithEventV2Compatible(\n          eventEmitterService,\n          Events.TABLE_RECORD_UPDATE,\n          2\n        )(async () => {\n          await updateRecordByApi(t1.id, rA1, linkOnT1.id, [{ id: rB2 }]);\n        })) as any;\n        if (!isForceV2) {\n          const t2Event = (payloads as any[]).find((e) => e.payload.tableId === t2.id)!;\n          const recs = Array.isArray(t2Event.payload.record)\n            ? t2Event.payload.record\n            : [t2Event.payload.record];\n          const change = recs.find((r: any) => r.id === rB1)?.fields?.[linkOnT2.id] as\n            | FieldChangePayload\n            | undefined;\n          const removalChange = assertChange(change);\n          expectNoOldValue(removalChange);\n          expect(removalChange.newValue).toBeNull();\n        }\n      }\n\n      // DB: final state T1[A1] -> [B2] and symmetric T2[B2] -> {id: A1}\n      const t1Db = await getDbTableName(t1.id);\n      const t2Db = await getDbTableName(t2.id);\n      const t1Row = await getRow(t1Db, rA1);\n      const t2RowB1 = await getRow(t2Db, rB1);\n      const t2RowB2 = await getRow(t2Db, rB2);\n      const linkOnT1Full = (await getFields(t1.id)).find(\n        (f) => f.id === (linkOnT1 as any).id\n      )! as any;\n      const linkOnT2Full = (await getFields(t2.id)).find(\n        (f) => f.id === (linkOnT2 as any).id\n      )! as any;\n      const t1Ids = ((parseMaybe((t1Row as any)[linkOnT1Full.dbFieldName]) as any[]) || []).map(\n        (x: any) => x?.id\n      );\n      const b1Val = parseMaybe((t2RowB1 as any)[linkOnT2Full.dbFieldName]) as any[] | any | null;\n      const b2Val = parseMaybe((t2RowB2 as any)[linkOnT2Full.dbFieldName]) as any[] | any | null;\n      const asArr = (v: any) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n      expect(t1Ids).toEqual([rB2]);\n      expect(asArr(b1Val).length).toBe(0);\n      expect(asArr(b2Val).map((x) => x?.id)).toEqual([rA1]);\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n\n    it('ManyMany: removing unrelated item still republishes unchanged counterpart with newValue only', async () => {\n      // T1 with two records: 1-1, 1-2\n      const t1 = await createTable(baseId, {\n        name: 'MM_NoChange_T1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: '1-1' } }, { fields: { Title: '1-2' } }],\n      });\n      // T2 with one record: 2-1\n      const t2 = await createTable(baseId, {\n        name: 'MM_NoChange_T2',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: '2-1' } }],\n      });\n\n      // Create ManyMany link on T1 -> T2; symmetric generated on T2\n      const linkOnT1 = await createField(t1.id, {\n        name: 'L_T2_MM',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t2.id },\n      } as IFieldRo);\n      const t2Fields = await getFields(t2.id);\n      const linkOnT2 = t2Fields.find(\n        (ff) => ff.type === FieldType.Link && (ff as any).options?.foreignTableId === t1.id\n      )!;\n\n      const r1_1 = t1.records[0].id;\n      const r1_2 = t1.records[1].id;\n      const r2_1 = t2.records[0].id;\n\n      // 1) Establish mutual link 1-1 <-> 2-1\n      await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t1.id, r1_1, linkOnT1.id, [{ id: r2_1 }]);\n      });\n\n      // 2) Add 1-2 to 2-1, now 2-1 links [1-1, 1-2]\n      await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t2.id, r2_1, linkOnT2.id, [{ id: r1_1 }, { id: r1_2 }]);\n      });\n\n      // 3) Remove 1-2, keep only 1-1; expect:\n      //    - T2[2-1] changed\n      //    - T1[1-2] changed (removed)\n      //    - T1[1-1] re-published with same newValue (oldValue missing)\n      const { payloads } = (await createAwaitWithEventV2Compatible(\n        eventEmitterService,\n        Events.TABLE_RECORD_UPDATE,\n        2\n      )(async () => {\n        await updateRecordByApi(t2.id, r2_1, linkOnT2.id, [{ id: r1_1 }]);\n      })) as any;\n\n      if (!isForceV2) {\n        const t1Event = (payloads as any[]).find((e) => e.payload.tableId === t1.id)!;\n        const recs = Array.isArray(t1Event.payload.record)\n          ? t1Event.payload.record\n          : [t1Event.payload.record];\n\n        const changeOn11 = recs.find((r: any) => r.id === r1_1)?.fields?.[linkOnT1.id] as\n          | FieldChangePayload\n          | undefined;\n        const changeOn12 = recs.find((r: any) => r.id === r1_2)?.fields?.[linkOnT1.id] as\n          | FieldChangePayload\n          | undefined;\n\n        const removalChange = assertChange(changeOn12); // 1-2 removed 2-1\n        expectNoOldValue(removalChange);\n        expect(removalChange.newValue).toBeNull();\n\n        const unchangedRepublish = assertChange(changeOn11);\n        expectNoOldValue(unchangedRepublish);\n        const idsOf = (v: any) =>\n          (Array.isArray(v) ? v : v ? [v] : []).map((item: any) => item?.id).filter(Boolean);\n        expect(new Set(idsOf(unchangedRepublish.newValue))).toEqual(new Set([r2_1]));\n      }\n\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t1.id);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/computed-user-field.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship, Role } from '@teable/core';\nimport {\n  deleteSpaceCollaborator,\n  emailSpaceInvitation,\n  getRecord,\n  getRecords,\n  updateRecord,\n  USER_ME,\n  deleteTable,\n  UPDATE_USER_NAME,\n  urlBuilder,\n  CREATE_FIELD,\n  CREATE_TABLE,\n  emailBaseInvitation,\n  PrincipalType,\n} from '@teable/openapi';\nimport type { IUserMeVo, ITableFullVo } from '@teable/openapi';\nimport { ActorId, type IComputedUpdateDrainService, v2CoreTokens } from '@teable/v2-core';\nimport type { AxiosInstance } from 'axios';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { V2ContainerService } from '../src/features/v2/v2-container.service';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport {\n  createBase,\n  createField,\n  createRecords,\n  createTable,\n  deleteBase,\n  deleteField,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Computed user field (e2e)', () => {\n  let app: INestApplication;\n  let v2ContainerService: V2ContainerService;\n  const spaceId = globalThis.testConfig.spaceId;\n  const userName = globalThis.testConfig.userName;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n  let baseId: string;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    v2ContainerService = app.get(V2ContainerService);\n    const base = await createBase({ name: 'base1', spaceId });\n    baseId = base.id;\n  });\n\n  afterAll(async () => {\n    await deleteBase(baseId);\n    await app.close();\n  });\n\n  async function processV2Outbox(): Promise<void> {\n    if (!isForceV2) return;\n\n    const container = await v2ContainerService.getContainer();\n    const drainService = container.resolve<IComputedUpdateDrainService>(\n      v2CoreTokens.computedUpdateDrainService\n    );\n    const context = { actorId: ActorId.create('system')._unsafeUnwrap() };\n    let iterations = 0;\n\n    while (iterations < 100) {\n      const result = await drainService.drainOnce(context, {\n        workerId: 'computed-user-field-test',\n        limit: 100,\n      });\n\n      if (result.isErr()) {\n        throw new Error(`Outbox processing failed: ${result.error.message}`);\n      }\n\n      if (result.value === 0) {\n        return;\n      }\n\n      iterations++;\n    }\n\n    throw new Error('Timed out draining computed update outbox');\n  }\n\n  describe('CRUD', () => {\n    let table1: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n    });\n\n    afterEach(async () => {\n      await deleteTable(baseId, table1.id);\n    });\n\n    it('should create a created by field', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.CreatedBy,\n      };\n\n      const createdByField = await createField(table1.id, fieldRo);\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      records.data.records.forEach((record) => {\n        expect(record.fields[createdByField.id]).toMatchObject({\n          title: userName,\n        });\n      });\n    });\n\n    it('should create a last modified by field', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.LastModifiedBy,\n      };\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const lastModifiedByField = await createField(table1.id, fieldRo);\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(records.data.records[0].fields[lastModifiedByField.id]).toMatchObject({\n        title: userName,\n      });\n\n      if (isForceV2) {\n        expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({\n          title: userName,\n        });\n      } else {\n        expect(records.data.records[1].fields[lastModifiedByField.id]).toBeUndefined();\n      }\n\n      await updateRecord(table1.id, table1.records[1].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test2',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const updatedRecord = await getRecord(table1.id, records.data.records[1].id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(updatedRecord.data.fields[lastModifiedByField.id]).toMatchObject({\n        title: userName,\n      });\n    });\n\n    it('should update formula result depends on a last modified by field', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.LastModifiedBy,\n      };\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const lastModifiedByField = await createField(table1.id, fieldRo);\n\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lastModifiedByField.id}}`,\n        },\n      };\n\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(records.data.records[0].fields[lastModifiedByField.id]).toMatchObject({\n        title: userName,\n      });\n\n      expect(records.data.records[0].fields[formulaField.id]).toEqual(userName);\n\n      if (isForceV2) {\n        expect(records.data.records[1].fields[lastModifiedByField.id]).toMatchObject({\n          title: userName,\n        });\n        expect(records.data.records[1].fields[formulaField.id]).toEqual(userName);\n      } else {\n        expect(records.data.records[1].fields[lastModifiedByField.id]).toBeUndefined();\n      }\n\n      await updateRecord(table1.id, table1.records[1].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test2',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const updatedRecord = await getRecord(table1.id, table1.records[1].id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(updatedRecord.data.fields[lastModifiedByField.id]).toMatchObject({\n        title: userName,\n      });\n\n      expect(updatedRecord.data.fields[formulaField.id]).toEqual(userName);\n    });\n\n    it('should update formula result depends on a last modified time field', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.LastModifiedTime,\n      };\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const lastModifiedTimeField = await createField(table1.id, fieldRo);\n\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lastModifiedTimeField.id}}`,\n        },\n      };\n\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(records.data.records[0].fields[lastModifiedTimeField.id]).toEqual(\n        records.data.records[0].lastModifiedTime\n      );\n\n      expect(records.data.records[0].fields[formulaField.id]).toEqual(\n        records.data.records[0].lastModifiedTime\n      );\n\n      if (isForceV2) {\n        expect(records.data.records[1].fields[lastModifiedTimeField.id]).toEqual(\n          records.data.records[1].lastModifiedTime\n        );\n        expect(records.data.records[1].fields[formulaField.id]).toEqual(\n          records.data.records[1].lastModifiedTime\n        );\n      } else {\n        expect(records.data.records[1].fields[lastModifiedTimeField.id]).toBeUndefined();\n      }\n\n      await updateRecord(table1.id, table1.records[1].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'test2',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const updatedRecord = await getRecord(table1.id, table1.records[1].id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(updatedRecord.data.fields[lastModifiedTimeField.id]).toEqual(\n        updatedRecord.data.lastModifiedTime\n      );\n\n      expect(updatedRecord.data.fields[formulaField.id]).toEqual(\n        updatedRecord.data.lastModifiedTime\n      );\n    });\n\n    it('should allow configuring Last Modified By field to track specific fields only', async () => {\n      const textField = await createField(table1.id, {\n        name: 'text-field',\n        type: FieldType.SingleLineText,\n      });\n      const numberField = await createField(table1.id, {\n        name: 'number-field',\n        type: FieldType.Number,\n      });\n\n      const lastModifiedByField = await createField(table1.id, {\n        type: FieldType.LastModifiedBy,\n        options: {\n          trackedFieldIds: [textField.id],\n        },\n      });\n\n      const recordId = table1.records[0].id;\n\n      await updateRecord(table1.id, recordId, {\n        record: {\n          fields: {\n            [numberField.id]: 1,\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id });\n      if (isForceV2) {\n        expect(record.data.fields[lastModifiedByField.id]).toMatchObject({\n          id: globalThis.testConfig.userId,\n          title: globalThis.testConfig.userName,\n        });\n      } else {\n        expect(record.data.fields[lastModifiedByField.id]).toBeUndefined();\n      }\n\n      await updateRecord(table1.id, recordId, {\n        record: {\n          fields: {\n            [textField.id]: 'tracked change',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id });\n      expect(record.data.fields[lastModifiedByField.id]).toMatchObject({\n        id: globalThis.testConfig.userId,\n        title: globalThis.testConfig.userName,\n      });\n    });\n\n    it('should fall back to track all when tracked fields are removed', async () => {\n      const textField = await createField(table1.id, {\n        name: 'text-field',\n        type: FieldType.SingleLineText,\n      });\n      const numberField = await createField(table1.id, {\n        name: 'number-field',\n        type: FieldType.Number,\n      });\n\n      const lastModifiedByField = await createField(table1.id, {\n        type: FieldType.LastModifiedBy,\n        options: {\n          trackedFieldIds: [textField.id],\n        },\n      });\n\n      const recordId = table1.records[0].id;\n\n      await updateRecord(table1.id, recordId, {\n        record: {\n          fields: {\n            [numberField.id]: 1,\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      let record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id });\n      if (isForceV2) {\n        expect(record.data.fields[lastModifiedByField.id]).toMatchObject({\n          id: globalThis.testConfig.userId,\n          title: globalThis.testConfig.userName,\n        });\n      } else {\n        expect(record.data.fields[lastModifiedByField.id]).toBeUndefined();\n      }\n\n      await deleteField(table1.id, textField.id);\n\n      await updateRecord(table1.id, recordId, {\n        record: {\n          fields: {\n            [numberField.id]: 2,\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      record = await getRecord(table1.id, recordId, { fieldKeyType: FieldKeyType.Id });\n      expect(record.data.fields[lastModifiedByField.id]).toMatchObject({\n        id: globalThis.testConfig.userId,\n        title: globalThis.testConfig.userName,\n      });\n    });\n\n    it('should persist multi-user formula values via computed updates', async () => {\n      const userField = await createField(table1.id, {\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          shouldNotify: false,\n        },\n      });\n\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${userField.id}}`,\n        },\n      });\n\n      expect(formulaField.isMultipleCellValue).toBe(true);\n\n      const recordId = table1.records[0].id;\n\n      await updateRecord(table1.id, recordId, {\n        record: {\n          fields: {\n            [userField.id]: [globalThis.testConfig.userId],\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      });\n\n      const updatedRecord = await getRecord(table1.id, recordId, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(updatedRecord.data.fields[userField.id]).toEqual([\n        expect.objectContaining({ title: globalThis.testConfig.userName }),\n      ]);\n      expect(updatedRecord.data.fields[formulaField.id]).toContain(globalThis.testConfig.userName);\n    });\n  });\n\n  describe('rename', () => {\n    const renameUserEmail = `rename-user-${Date.now()}@example.com`;\n    let user2Request: AxiosInstance;\n    let user2: IUserMeVo;\n    let table1: ITableFullVo;\n    let eventEmitterService: EventEmitterService;\n    let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n\n    beforeAll(async () => {\n      user2Request = await createNewUserAxios({\n        email: renameUserEmail,\n        password: '12345678',\n      });\n      eventEmitterService = app.get(EventEmitterService);\n      awaitWithEvent = createAwaitWithEvent(eventEmitterService, Events.TABLE_USER_RENAME_COMPLETE);\n\n      await awaitWithEvent(() =>\n        user2Request.patch<void>(urlBuilder(UPDATE_USER_NAME), { name: 'default' })\n      );\n      user2 = (await user2Request.get<IUserMeVo>(USER_ME)).data;\n\n      await emailSpaceInvitation({\n        spaceId: globalThis.testConfig.spaceId,\n        emailSpaceInvitationRo: { role: Role.Creator, emails: [renameUserEmail] },\n      });\n      table1 = (\n        await user2Request.post<ITableFullVo>(urlBuilder(CREATE_TABLE, { baseId }), {\n          name: 'table1',\n        })\n      ).data;\n    });\n\n    afterAll(async () => {\n      await deleteSpaceCollaborator({\n        spaceId: globalThis.testConfig.spaceId,\n        deleteSpaceCollaboratorRo: {\n          principalId: user2.id,\n          principalType: PrincipalType.User,\n        },\n      });\n      await deleteTable(baseId, table1.id);\n    });\n\n    it('should update createdBy fields when user rename', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.CreatedBy,\n      };\n\n      const field = await user2Request\n        .post<IFieldVo>(urlBuilder(CREATE_FIELD, { tableId: table1.id }), fieldRo)\n        .then((res) => res.data);\n\n      console.log('user2user2', user2);\n      await awaitWithEvent(() => user2Request.patch<void>(UPDATE_USER_NAME, { name: 'new name' }));\n\n      console.log('user2user2 res', (await user2Request.get<IUserMeVo>(USER_ME)).data);\n      const getRecordsResponse = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      getRecordsResponse.data.records.forEach((record) => {\n        expect(record.fields[field.id]).toMatchObject({\n          title: 'new name',\n        });\n      });\n    });\n\n    it('should update createBy fields when user rename - base collaborator', async () => {\n      const user3Email = `rename-user3-${Date.now()}@example.com`;\n      const user3Request = await createNewUserAxios({\n        email: user3Email,\n        password: '12345678',\n      });\n      await emailBaseInvitation({\n        baseId,\n        emailBaseInvitationRo: { role: Role.Creator, emails: [user3Email] },\n      });\n      const table = (\n        await user3Request.post<ITableFullVo>(urlBuilder(CREATE_TABLE, { baseId }), {\n          name: 'table2',\n        })\n      ).data;\n      const field = await user3Request\n        .post<IFieldVo>(urlBuilder(CREATE_FIELD, { tableId: table.id }), {\n          type: FieldType.CreatedBy,\n        })\n        .then((res) => res.data);\n      await awaitWithEvent(() => user3Request.patch<void>(UPDATE_USER_NAME, { name: 'new name' }));\n\n      const getRecordsResponse = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      getRecordsResponse.data.records.forEach((record) => {\n        expect(record.fields[field.id]).toMatchObject({\n          title: 'new name',\n        });\n      });\n    });\n\n    it('should update user fields when user rename', async () => {\n      const fieldRo: IFieldRo = {\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          shouldNotify: false,\n        },\n      };\n\n      const field = (\n        await user2Request.post<IFieldVo>(urlBuilder(CREATE_FIELD, { tableId: table1.id }), fieldRo)\n      ).data;\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [field.id]: [globalThis.testConfig.userId, user2.id],\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      });\n\n      await awaitWithEvent(() =>\n        user2Request.patch<void>(UPDATE_USER_NAME, { name: 'new name 2' })\n      );\n\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records.data.records[0].fields[field.id]).toMatchObject([\n        {\n          title: 'test',\n        },\n        {\n          title: 'new name 2',\n        },\n      ]);\n    });\n\n    it('should cascade user rename through lookup and downstream computed fields', async () => {\n      const initialName = 'rename-chain-initial';\n      const nextName = 'rename-chain-next';\n      let sourceTableId: string | undefined;\n      let hostTableId: string | undefined;\n      let summaryTableId: string | undefined;\n\n      try {\n        await awaitWithEvent(() =>\n          user2Request.patch<void>(UPDATE_USER_NAME, { name: initialName })\n        );\n\n        const sourceTable = await createTable(baseId, {\n          name: 'rename-user-source',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        });\n        sourceTableId = sourceTable.id;\n\n        const sourcePrimaryFieldId = sourceTable.fields.find((field) => field.isPrimary)?.id;\n        if (!sourcePrimaryFieldId) {\n          throw new Error('Missing source primary field');\n        }\n\n        const ownerField = await createField(sourceTable.id, {\n          name: 'Owner',\n          type: FieldType.User,\n          options: {\n            isMultiple: false,\n            shouldNotify: false,\n          },\n        });\n\n        const ownerFormulaField = await createField(sourceTable.id, {\n          name: 'Owner Formula',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${ownerField.id}}`,\n          },\n        });\n\n        const sourceRecords = await createRecords(sourceTable.id, {\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n          records: [\n            {\n              fields: {\n                [sourcePrimaryFieldId]: 'source-1',\n                [ownerField.id]: {\n                  id: user2.id,\n                  title: initialName,\n                },\n              },\n            },\n          ],\n        });\n        const sourceRecordId = sourceRecords.records[0].id;\n\n        const hostTable = await createTable(baseId, {\n          name: 'rename-user-host',\n          fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n        });\n        hostTableId = hostTable.id;\n\n        const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id;\n        if (!hostPrimaryFieldId) {\n          throw new Error('Missing host primary field');\n        }\n\n        const sourceLinkField = await createField(hostTable.id, {\n          name: 'Source',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: sourceTable.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const lookupOwnerField = await createField(hostTable.id, {\n          name: 'Lookup Owner',\n          type: FieldType.User,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: sourceTable.id,\n            linkFieldId: sourceLinkField.id,\n            lookupFieldId: ownerField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const lookupOwnerFormulaField = await createField(hostTable.id, {\n          name: 'Lookup Owner Formula',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${lookupOwnerField.id}}`,\n          },\n        });\n\n        const hostRecords = await createRecords(hostTable.id, {\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n          records: [\n            {\n              fields: {\n                [hostPrimaryFieldId]: 'host-1',\n                [sourceLinkField.id]: { id: sourceRecordId },\n              },\n            },\n          ],\n        });\n        const hostRecordId = hostRecords.records[0].id;\n\n        const summaryTable = await createTable(baseId, {\n          name: 'rename-user-summary',\n          fields: [{ name: 'Summary', type: FieldType.SingleLineText }],\n        });\n        summaryTableId = summaryTable.id;\n\n        const summaryPrimaryFieldId = summaryTable.fields.find((field) => field.isPrimary)?.id;\n        if (!summaryPrimaryFieldId) {\n          throw new Error('Missing summary primary field');\n        }\n\n        const hostLinkField = await createField(summaryTable.id, {\n          name: 'Hosts',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: hostTable.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const hostOwnerRollupField = await createField(summaryTable.id, {\n          name: 'Host Owner Names',\n          type: FieldType.Rollup,\n          options: {\n            expression: 'array_join({values})',\n          },\n          lookupOptions: {\n            foreignTableId: hostTable.id,\n            linkFieldId: hostLinkField.id,\n            lookupFieldId: lookupOwnerFormulaField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const summaryRecords = await createRecords(summaryTable.id, {\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n          records: [\n            {\n              fields: {\n                [summaryPrimaryFieldId]: 'summary-1',\n                [hostLinkField.id]: [{ id: hostRecordId }],\n              },\n            },\n          ],\n        });\n        const summaryRecordId = summaryRecords.records[0].id;\n\n        const waitForSourceOwnerSnapshot = async (expectedName: string) => {\n          const timeoutMs = process.env.CI ? 15000 : 5000;\n          const startedAt = Date.now();\n          let latestSourceRecord: Awaited<ReturnType<typeof getRecord>>['data'] | undefined;\n\n          while (Date.now() - startedAt < timeoutMs) {\n            await processV2Outbox();\n            latestSourceRecord = (\n              await getRecord(sourceTable.id, sourceRecordId, { fieldKeyType: FieldKeyType.Id })\n            ).data;\n\n            if (latestSourceRecord.fields[ownerField.id]?.title === expectedName) {\n              return latestSourceRecord;\n            }\n\n            await new Promise((resolve) => setTimeout(resolve, 100));\n          }\n\n          latestSourceRecord =\n            latestSourceRecord ??\n            (await getRecord(sourceTable.id, sourceRecordId, { fieldKeyType: FieldKeyType.Id }))\n              .data;\n\n          expect(latestSourceRecord.fields[ownerField.id]).toMatchObject({ title: expectedName });\n          return latestSourceRecord;\n        };\n\n        const waitForRenameChain = async (expectedName: string) => {\n          const timeoutMs = process.env.CI ? 15000 : 5000;\n          const startedAt = Date.now();\n          let latestSourceRecord: Awaited<ReturnType<typeof getRecord>>['data'] | undefined;\n          let latestHostRecord: Awaited<ReturnType<typeof getRecord>>['data'] | undefined;\n          let latestSummaryRecord: Awaited<ReturnType<typeof getRecord>>['data'] | undefined;\n\n          // Lookup -> formula -> rollup propagation can still be settling when the\n          // record read happens immediately after setup or rename in CI shards.\n          // When FORCE_V2_ALL is enabled, drain the computed outbox explicitly so the\n          // test waits on real propagation work instead of only wall-clock time.\n          while (Date.now() - startedAt < timeoutMs) {\n            await processV2Outbox();\n            latestSourceRecord = (\n              await getRecord(sourceTable.id, sourceRecordId, { fieldKeyType: FieldKeyType.Id })\n            ).data;\n            latestHostRecord = (\n              await getRecord(hostTable.id, hostRecordId, { fieldKeyType: FieldKeyType.Id })\n            ).data;\n            latestSummaryRecord = (\n              await getRecord(summaryTable.id, summaryRecordId, { fieldKeyType: FieldKeyType.Id })\n            ).data;\n\n            if (\n              latestSourceRecord.fields[ownerField.id]?.title === expectedName &&\n              latestSourceRecord.fields[ownerFormulaField.id] === expectedName &&\n              latestHostRecord.fields[lookupOwnerField.id]?.title === expectedName &&\n              String(latestHostRecord.fields[lookupOwnerFormulaField.id] ?? '').includes(\n                expectedName\n              ) &&\n              String(latestSummaryRecord.fields[hostOwnerRollupField.id] ?? '').includes(\n                expectedName\n              )\n            ) {\n              return {\n                sourceRecord: latestSourceRecord,\n                hostRecord: latestHostRecord,\n                summaryRecord: latestSummaryRecord,\n              };\n            }\n\n            await new Promise((resolve) => setTimeout(resolve, 100));\n          }\n\n          latestSourceRecord =\n            latestSourceRecord ??\n            (await getRecord(sourceTable.id, sourceRecordId, { fieldKeyType: FieldKeyType.Id }))\n              .data;\n          latestHostRecord =\n            latestHostRecord ??\n            (await getRecord(hostTable.id, hostRecordId, { fieldKeyType: FieldKeyType.Id })).data;\n          latestSummaryRecord =\n            latestSummaryRecord ??\n            (await getRecord(summaryTable.id, summaryRecordId, { fieldKeyType: FieldKeyType.Id }))\n              .data;\n\n          expect(latestSourceRecord.fields[ownerField.id]).toMatchObject({ title: expectedName });\n          expect(latestSourceRecord.fields[ownerFormulaField.id]).toEqual(expectedName);\n          expect(latestHostRecord.fields[lookupOwnerField.id]).toMatchObject({\n            title: expectedName,\n          });\n          expect(String(latestHostRecord.fields[lookupOwnerFormulaField.id] ?? '')).toContain(\n            expectedName\n          );\n          expect(String(latestSummaryRecord.fields[hostOwnerRollupField.id] ?? '')).toContain(\n            expectedName\n          );\n\n          return {\n            sourceRecord: latestSourceRecord,\n            hostRecord: latestHostRecord,\n            summaryRecord: latestSummaryRecord,\n          };\n        };\n\n        // The behavior under test is the rename cascade. Initial create-time formula/rollup\n        // backfill is covered elsewhere and can settle later than the raw user snapshot in CI.\n        const sourceBeforeRename = await waitForSourceOwnerSnapshot(initialName);\n        expect(sourceBeforeRename.fields[ownerField.id]).toMatchObject({ title: initialName });\n\n        await awaitWithEvent(() => user2Request.patch<void>(UPDATE_USER_NAME, { name: nextName }));\n        await processV2Outbox();\n\n        const {\n          sourceRecord: sourceAfterRename,\n          hostRecord: hostAfterRename,\n          summaryRecord: summaryAfterRename,\n        } = await waitForRenameChain(nextName);\n\n        expect(sourceAfterRename.fields[ownerField.id]).toMatchObject({ title: nextName });\n        expect(sourceAfterRename.fields[ownerFormulaField.id]).toEqual(nextName);\n        expect(hostAfterRename.fields[lookupOwnerField.id]).toMatchObject({ title: nextName });\n        expect(String(hostAfterRename.fields[lookupOwnerFormulaField.id])).toContain(nextName);\n        expect(String(summaryAfterRename.fields[hostOwnerRollupField.id])).toContain(nextName);\n      } finally {\n        if (summaryTableId) {\n          await permanentDeleteTable(baseId, summaryTableId);\n        }\n        if (hostTableId) {\n          await permanentDeleteTable(baseId, hostTableId);\n        }\n        if (sourceTableId) {\n          await permanentDeleteTable(baseId, sourceTableId);\n        }\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/computed-version-regression.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events, type RecordUpdateEvent } from '../src/event-emitter/events';\nimport {\n  createField,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('Computed ops version alignment (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n  const baseId = globalThis.testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const waitForRecordUpdateOnTable = (tableId: string) =>\n    new Promise<RecordUpdateEvent>((resolve) => {\n      const handler = (event: RecordUpdateEvent) => {\n        if (event.payload.tableId !== tableId) return;\n        eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler);\n        resolve(event);\n      };\n      eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler);\n    });\n\n  // Skip in v2 mode - this test verifies v1 event payload format\n  // v2 uses different event system (RecordUpdated/RecordsBatchUpdated)\n  const itWhenV1 = isForceV2 ? it.skip : it;\n\n  itWhenV1(\n    'emits non-null new values for track-all last modified fields and formulas',\n    async () => {\n      let table: Awaited<ReturnType<typeof createTable>> | undefined;\n      try {\n        table = await createTable(baseId, {\n          name: 'computed_version_alignment',\n          fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n          records: [{ fields: { Title: 'before' } }],\n        });\n\n        const titleId = table.fields.find((f) => f.name === 'Title')!.id;\n        const lmtField = await createField(table.id, {\n          name: 'LMT',\n          type: FieldType.LastModifiedTime,\n        });\n        const lmbField = await createField(table.id, {\n          name: 'LMB',\n          type: FieldType.LastModifiedBy,\n        });\n        const formulaField = await createField(table.id, {\n          name: 'UpperTitle',\n          type: FieldType.Formula,\n          options: { expression: `UPPER({${titleId}})` },\n        });\n\n        const waitForUpdate = waitForRecordUpdateOnTable(table.id);\n        await updateRecordByApi(table.id, table.records[0].id, titleId, 'after');\n        const event = await waitForUpdate;\n\n        const recordPayload = Array.isArray(event.payload.record)\n          ? event.payload.record[0]\n          : event.payload.record;\n        const changes = recordPayload.fields as Record<\n          string,\n          { oldValue: unknown; newValue: unknown }\n        >;\n\n        expect(changes[lmtField.id]).toBeDefined();\n        expect(typeof changes[lmtField.id].newValue).toBe('string');\n\n        expect(changes[lmbField.id]).toBeDefined();\n        expect(changes[lmbField.id].newValue).toMatchObject({\n          id: globalThis.testConfig.userId,\n        });\n\n        expect(changes[formulaField.id]).toBeDefined();\n        expect(changes[formulaField.id].newValue).toBe('AFTER');\n      } finally {\n        if (table) {\n          await permanentDeleteTable(baseId, table.id);\n        }\n      }\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/conditional-lookup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IAttachmentCellValue,\n  IConditionalRollupFieldOptions,\n  IFieldRo,\n  IFieldVo,\n  IFilter,\n  ILookupOptionsRo,\n  IConditionalLookupOptions,\n} from '@teable/core';\nimport {\n  isConditionalLookupOptions,\n  Colors,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  SortFunc,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { uploadAttachment } from '@teable/openapi';\nimport {\n  createField,\n  convertField,\n  createTable,\n  deleteField,\n  getRecord,\n  getField,\n  getFields,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n  permanentDeleteTable,\n  createBase,\n  deleteBase,\n} from './utils/init-app';\n\ndescribe('OpenAPI Conditional Lookup field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('basic text filter lookup', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let titleId: string;\n    let statusId: string;\n    let statusFilterId: string;\n    let activeHostRecordId: string;\n    let gammaRecordId: string;\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active' } },\n          { fields: { Title: 'Beta', Status: 'Active' } },\n          { fields: { Title: 'Gamma', Status: 'Closed' } },\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n      gammaRecordId = foreign.records.find((record) => record.fields.Title === 'Gamma')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText, options: {} } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n      });\n      statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n      activeHostRecordId = host.records.find(\n        (record) => record.fields.StatusFilter === 'Active'\n      )!.id;\n\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Matching Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: statusMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should expose conditional lookup metadata', async () => {\n      const fields = await getFields(host.id);\n      const retrieved = fields.find((field) => field.id === lookupField.id)!;\n      expect(retrieved.isLookup).toBe(true);\n      expect(retrieved.isConditionalLookup).toBe(true);\n      expect(retrieved.lookupOptions).toMatchObject({\n        foreignTableId: foreign.id,\n        lookupFieldId: titleId,\n      });\n\n      const fieldDetail = await getField(host.id, lookupField.id);\n      expect(fieldDetail.id).toBe(lookupField.id);\n      expect(fieldDetail.lookupOptions).toMatchObject({\n        foreignTableId: foreign.id,\n        lookupFieldId: titleId,\n        filter: expect.objectContaining({ conjunction: 'and' }),\n      });\n    });\n\n    it('should resolve filtered lookup values for host records', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const activeRecord = records.records.find((record) => record.id === host.records[0].id)!;\n      const closedRecord = records.records.find((record) => record.id === host.records[1].id)!;\n\n      expect(activeRecord.fields[lookupField.id]).toEqual(['Alpha', 'Beta']);\n      expect(closedRecord.fields[lookupField.id]).toEqual(['Gamma']);\n    });\n\n    it('should refresh conditional lookup when foreign records enter the filter', async () => {\n      const baseline = await getRecord(host.id, activeHostRecordId);\n      expect(baseline.fields[lookupField.id]).toEqual(['Alpha', 'Beta']);\n\n      await updateRecordByApi(foreign.id, gammaRecordId, statusId, 'Active');\n      const afterStatus = await getRecord(host.id, activeHostRecordId);\n      expect(afterStatus.fields[lookupField.id]).toEqual(['Alpha', 'Beta', 'Gamma']);\n\n      await updateRecordByApi(foreign.id, gammaRecordId, titleId, 'Gamma Updated');\n      const afterTitle = await getRecord(host.id, activeHostRecordId);\n      expect(afterTitle.fields[lookupField.id]).toEqual(['Alpha', 'Beta', 'Gamma Updated']);\n\n      await updateRecordByApi(foreign.id, gammaRecordId, titleId, 'Gamma');\n      await updateRecordByApi(foreign.id, gammaRecordId, statusId, 'Closed');\n      const restored = await getRecord(host.id, activeHostRecordId);\n      expect(restored.fields[lookupField.id]).toEqual(['Alpha', 'Beta']);\n    });\n  });\n\n  describe('filter option synchronization', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let titleId: string;\n    let statusId: string;\n    const statusChoices = [\n      { id: 'status-active', name: 'Active', color: Colors.Green },\n      { id: 'status-closed', name: 'Closed', color: Colors.Gray },\n    ];\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Filter_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: { choices: statusChoices },\n          } as IFieldRo,\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Filter_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n      });\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Active Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should update conditional lookup filters when select option names change', async () => {\n      await convertField(foreign.id, statusId, {\n        name: 'Status',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ ...statusChoices[0], name: 'Active Plus' }, statusChoices[1]],\n        },\n      } as IFieldRo);\n\n      const refreshed = await getField(host.id, lookupField.id);\n      const updatedLookup = refreshed.lookupOptions as IConditionalLookupOptions | undefined;\n      const filterItem = updatedLookup?.filter?.filterSet?.[0];\n      // @ts-expect-error handle value\n      expect(filterItem?.value).toBe('Active Plus');\n    });\n  });\n\n  describe('sort and limit options', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let titleId: string;\n    let statusId: string;\n    let scoreId: string;\n    let statusFilterId: string;\n    let activeRecordId: string;\n    let closedRecordId: string;\n    let gammaRecordId: string;\n    let statusMatchFilter: IFilter;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Sort_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Score', type: FieldType.Number, options: {} } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active', Score: 70 } },\n          { fields: { Title: 'Beta', Status: 'Active', Score: 90 } },\n          { fields: { Title: 'Gamma', Status: 'Active', Score: 40 } },\n          { fields: { Title: 'Delta', Status: 'Closed', Score: 100 } },\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n      scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n      gammaRecordId = foreign.records.find((record) => record.fields.Title === 'Gamma')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Sort_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText, options: {} } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n      });\n      statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n      activeRecordId = host.records[0].id;\n      closedRecordId = host.records[1].id;\n\n      statusMatchFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Top Scores',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: statusMatchFilter,\n          sort: {\n            fieldId: scoreId,\n            order: SortFunc.Desc,\n          },\n          limit: 2,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should apply sort and limit to conditional lookup results', async () => {\n      const originalField = await getField(host.id, lookupField.id);\n      const originalLookupOptions = originalField.lookupOptions as ILookupOptionsRo;\n      const originalOptions = originalField.options;\n      const originalName = originalField.name;\n\n      try {\n        expect(originalLookupOptions).toMatchObject({\n          sort: { fieldId: scoreId, order: SortFunc.Desc },\n          limit: 2,\n        });\n\n        const initialRecords = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n        const initialActive = initialRecords.records.find(\n          (record) => record.id === activeRecordId\n        )!;\n        const initialClosed = initialRecords.records.find(\n          (record) => record.id === closedRecordId\n        )!;\n        expect(initialActive.fields[lookupField.id]).toEqual(['Beta', 'Alpha']);\n        expect(initialClosed.fields[lookupField.id]).toEqual(['Delta']);\n\n        lookupField = await convertField(host.id, lookupField.id, {\n          name: lookupField.name,\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: lookupField.options,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            filter: statusMatchFilter,\n            sort: {\n              fieldId: scoreId,\n              order: SortFunc.Asc,\n            },\n            limit: 1,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const ascField = await getField(host.id, lookupField.id);\n        expect(ascField.lookupOptions).toMatchObject({\n          sort: { fieldId: scoreId, order: SortFunc.Asc },\n          limit: 1,\n        });\n\n        let activeRecord = await getRecord(host.id, activeRecordId);\n        const closedRecord = await getRecord(host.id, closedRecordId);\n        expect(activeRecord.fields[lookupField.id]).toEqual(['Gamma']);\n        expect(closedRecord.fields[lookupField.id]).toEqual(['Delta']);\n\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 75);\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[lookupField.id]).toEqual(['Alpha']);\n\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 40);\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[lookupField.id]).toEqual(['Gamma']);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Closed');\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[lookupField.id]).toEqual(['Delta']);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[lookupField.id]).toEqual(['Gamma']);\n\n        lookupField = await convertField(host.id, lookupField.id, {\n          name: lookupField.name,\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: lookupField.options,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            filter: statusMatchFilter,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const disabledField = await getField(host.id, lookupField.id);\n        const disabledOptions = disabledField.lookupOptions;\n        if (!isConditionalLookupOptions(disabledOptions)) {\n          throw new Error('expected conditional lookup options');\n        }\n        expect(disabledOptions.sort).toBeUndefined();\n        expect(disabledOptions.limit).toBeUndefined();\n\n        const unsortedRecords = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n        const unsortedActive = unsortedRecords.records.find(\n          (record) => record.id === activeRecordId\n        )!;\n        const unsortedClosed = unsortedRecords.records.find(\n          (record) => record.id === closedRecordId\n        )!;\n        const activeTitles = [...(unsortedActive.fields[lookupField.id] as string[])].sort();\n        expect(activeTitles).toEqual(['Alpha', 'Beta', 'Gamma']);\n        expect(unsortedClosed.fields[lookupField.id]).toEqual(['Delta']);\n      } finally {\n        lookupField = await convertField(host.id, lookupField.id, {\n          name: originalName,\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: originalOptions,\n          lookupOptions: originalLookupOptions,\n        } as IFieldRo);\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 40);\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n      }\n    });\n\n    it('sorts referenced lookup fields with limits applied', async () => {\n      const colors = await createTable(baseId, {\n        name: 'ConditionalLookup_Sort_Colors',\n        fields: [{ name: 'Color', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Color: 'Amber' } }, { fields: { Color: 'Teal' } }],\n      });\n      const colorId = colors.fields.find((f) => f.name === 'Color')!.id;\n\n      const items = await createTable(baseId, {\n        name: 'ConditionalLookup_Sort_Items',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Color',\n            type: FieldType.Link,\n            options: { relationship: Relationship.ManyOne, foreignTableId: colors.id },\n          } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active', Color: { id: colors.records[1].id } } },\n          { fields: { Title: 'Beta', Status: 'Active', Color: { id: colors.records[0].id } } },\n          { fields: { Title: 'Gamma', Status: 'Closed', Color: { id: colors.records[1].id } } },\n        ],\n      });\n      const titleId = items.fields.find((f) => f.name === 'Title')!.id;\n      const statusId = items.fields.find((f) => f.name === 'Status')!.id;\n      const colorLinkId = items.fields.find((f) => f.name === 'Color')!.id;\n\n      const colorLookup = await createField(items.id, {\n        name: 'Color Name',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: colors.id,\n          linkFieldId: colorLinkId,\n          lookupFieldId: colorId,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalLookup_Sort_Lookup_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n      });\n      const statusFilterId = host.fields.find((f) => f.name === 'StatusFilter')!.id;\n      const activeId = host.records[0].id;\n      const closedId = host.records[1].id;\n\n      const lookupField = await createField(host.id, {\n        name: 'Top By Color',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: items.id,\n          lookupFieldId: titleId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: statusId,\n                operator: 'is',\n                value: { type: 'field', fieldId: statusFilterId },\n              },\n            ],\n          },\n          sort: { fieldId: colorLookup.id, order: SortFunc.Asc },\n          limit: 1,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const activeRecord = await getRecord(host.id, activeId);\n      const closedRecord = await getRecord(host.id, closedId);\n      expect(activeRecord.fields[lookupField.id]).toEqual(['Beta']);\n      expect(closedRecord.fields[lookupField.id]).toEqual(['Gamma']);\n\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, items.id);\n      await permanentDeleteTable(baseId, colors.id);\n    });\n  });\n\n  describe('filter scenarios', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let categoryTitlesField: IFieldVo;\n    let dynamicActiveAmountField: IFieldVo;\n    let highValueAmountField: IFieldVo;\n    let categoryFieldId: string;\n    let minimumAmountFieldId: string;\n    let categoryId: string;\n    let amountId: string;\n    let statusId: string;\n    let hardwareRecordId: string;\n    let softwareRecordId: string;\n    let servicesRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Filter_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Laptop', Category: 'Hardware', Amount: 70, Status: 'Active' } },\n          { fields: { Title: 'Mouse', Category: 'Hardware', Amount: 20, Status: 'Active' } },\n          { fields: { Title: 'Subscription', Category: 'Software', Amount: 40, Status: 'Trial' } },\n          { fields: { Title: 'Upgrade', Category: 'Software', Amount: 80, Status: 'Active' } },\n          { fields: { Title: 'Support', Category: 'Services', Amount: 15, Status: 'Active' } },\n        ],\n      });\n      categoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Filter_Host',\n        fields: [\n          { name: 'CategoryFilter', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'MinimumAmount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { CategoryFilter: 'Hardware', MinimumAmount: 50 } },\n          { fields: { CategoryFilter: 'Software', MinimumAmount: 30 } },\n          { fields: { CategoryFilter: 'Services', MinimumAmount: 10 } },\n        ],\n      });\n\n      categoryFieldId = host.fields.find((f) => f.name === 'CategoryFilter')!.id;\n      minimumAmountFieldId = host.fields.find((f) => f.name === 'MinimumAmount')!.id;\n      hardwareRecordId = host.records[0].id;\n      softwareRecordId = host.records[1].id;\n      servicesRecordId = host.records[2].id;\n\n      const categoryFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n        ],\n      };\n\n      categoryTitlesField = await createField(host.id, {\n        name: 'Category Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: foreign.fields.find((f) => f.name === 'Title')!.id,\n          filter: categoryFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const dynamicActiveFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: { type: 'field', fieldId: minimumAmountFieldId },\n          },\n        ],\n      };\n\n      dynamicActiveAmountField = await createField(host.id, {\n        name: 'Dynamic Active Amounts',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          filter: dynamicActiveFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const highValueActiveFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: 50,\n          },\n        ],\n      };\n\n      highValueAmountField = await createField(host.id, {\n        name: 'High Value Active Amounts',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          filter: highValueActiveFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should recalc lookup values when host filter field changes', async () => {\n      const baseline = await getRecord(host.id, hardwareRecordId);\n      expect(baseline.fields[categoryTitlesField.id]).toEqual(['Laptop', 'Mouse']);\n\n      await updateRecordByApi(host.id, hardwareRecordId, categoryFieldId, 'Software');\n      const updated = await getRecord(host.id, hardwareRecordId);\n      expect(updated.fields[categoryTitlesField.id]).toEqual(['Subscription', 'Upgrade']);\n\n      await updateRecordByApi(host.id, hardwareRecordId, categoryFieldId, 'Hardware');\n      const restored = await getRecord(host.id, hardwareRecordId);\n      expect(restored.fields[categoryTitlesField.id]).toEqual(['Laptop', 'Mouse']);\n    });\n\n    it('should apply field-referenced numeric filters', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n      const servicesRecord = records.records.find((record) => record.id === servicesRecordId)!;\n\n      expect(hardwareRecord.fields[dynamicActiveAmountField.id]).toEqual([70]);\n      expect(softwareRecord.fields[dynamicActiveAmountField.id]).toEqual([80]);\n      expect(servicesRecord.fields[dynamicActiveAmountField.id]).toEqual([15]);\n    });\n\n    it('should support multi-condition filters with static thresholds', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n      const servicesRecord = records.records.find((record) => record.id === servicesRecordId)!;\n\n      expect(hardwareRecord.fields[highValueAmountField.id]).toEqual([70]);\n      expect(softwareRecord.fields[highValueAmountField.id]).toEqual([80]);\n      expect(servicesRecord.fields[highValueAmountField.id] ?? []).toEqual([]);\n    });\n\n    it('should recompute when host numeric thresholds change', async () => {\n      const original = await getRecord(host.id, servicesRecordId);\n      expect(original.fields[dynamicActiveAmountField.id]).toEqual([15]);\n\n      await updateRecordByApi(host.id, servicesRecordId, minimumAmountFieldId, 50);\n      const raisedThreshold = await getRecord(host.id, servicesRecordId);\n      expect(raisedThreshold.fields[dynamicActiveAmountField.id] ?? []).toEqual([]);\n\n      await updateRecordByApi(host.id, servicesRecordId, minimumAmountFieldId, 10);\n      const reset = await getRecord(host.id, servicesRecordId);\n      expect(reset.fields[dynamicActiveAmountField.id]).toEqual([15]);\n    });\n  });\n\n  describe('text filter edge cases', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let emptyLabelScoresField: IFieldVo;\n    let nonEmptyLabelsField: IFieldVo;\n    let alphaNotesField: IFieldVo;\n    let labelId: string;\n    let notesId: string;\n    let scoreId: string;\n    let hostRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Text_Foreign',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Notes', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Label: 'Alpha', Notes: 'Alpha plan', Score: 10 } },\n          { fields: { Label: '', Notes: 'Empty label entry', Score: 5 } },\n          { fields: { Notes: 'Missing label Alpha entry', Score: 7 } },\n          { fields: { Label: 'Beta', Notes: 'Beta details', Score: 12 } },\n          { fields: { Label: 'Gamma', Notes: 'General info', Score: 8 } },\n        ],\n      });\n\n      labelId = foreign.fields.find((field) => field.name === 'Label')!.id;\n      notesId = foreign.fields.find((field) => field.name === 'Notes')!.id;\n      scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Text_Host',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Name: 'Row 1' } }],\n      });\n      hostRecordId = host.records[0].id;\n\n      emptyLabelScoresField = await createField(host.id, {\n        name: 'Empty Label Scores',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: labelId,\n                operator: 'isEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      nonEmptyLabelsField = await createField(host.id, {\n        name: 'Non Empty Labels',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: labelId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: labelId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      alphaNotesField = await createField(host.id, {\n        name: 'Alpha Notes',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: notesId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: notesId,\n                operator: 'contains',\n                value: 'Alpha',\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should include values when filtering for empty text', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[emptyLabelScoresField.id]).toEqual([5, 7]);\n    });\n\n    it('should exclude blanks when using isNotEmpty filters', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[nonEmptyLabelsField.id]).toEqual(['Alpha', 'Beta', 'Gamma']);\n    });\n\n    it('should support contains filters against text fields', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[alphaNotesField.id]).toEqual([\n        'Alpha plan',\n        'Missing label Alpha entry',\n      ]);\n    });\n  });\n\n  describe('date field reference filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let taskId: string;\n    let dueDateId: string;\n    let hoursId: string;\n    let targetDateId: string;\n    let onTargetTasksField: IFieldVo;\n    let afterTargetHoursField: IFieldVo;\n    let beforeTargetHoursField: IFieldVo;\n    let onOrBeforeTasksField: IFieldVo;\n    let onOrAfterTasksField: IFieldVo;\n    let onOrAfterDueDateField: IFieldVo;\n    let targetTenRecordId: string;\n    let targetElevenRecordId: string;\n    let targetThirteenRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Date_Foreign',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Due Date', type: FieldType.Date } as IFieldRo,\n          { name: 'Hours', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Spec Draft', 'Due Date': '2024-09-10', Hours: 5 } },\n          { fields: { Task: 'Review', 'Due Date': '2024-09-11', Hours: 3 } },\n          { fields: { Task: 'Finalize', 'Due Date': '2024-09-12', Hours: 7 } },\n        ],\n      });\n\n      taskId = foreign.fields.find((f) => f.name === 'Task')!.id;\n      dueDateId = foreign.fields.find((f) => f.name === 'Due Date')!.id;\n      hoursId = foreign.fields.find((f) => f.name === 'Hours')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Date_Host',\n        fields: [{ name: 'Target Date', type: FieldType.Date } as IFieldRo],\n        records: [\n          { fields: { 'Target Date': '2024-09-10' } },\n          { fields: { 'Target Date': '2024-09-11' } },\n          { fields: { 'Target Date': '2024-09-13' } },\n        ],\n      });\n\n      targetDateId = host.fields.find((f) => f.name === 'Target Date')!.id;\n      targetTenRecordId = host.records[0].id;\n      targetElevenRecordId = host.records[1].id;\n      targetThirteenRecordId = host.records[2].id;\n\n      await updateRecordByApi(host.id, targetTenRecordId, targetDateId, '2024-09-10T08:00:00.000Z');\n      await updateRecordByApi(\n        host.id,\n        targetElevenRecordId,\n        targetDateId,\n        '2024-09-11T12:30:00.000Z'\n      );\n      await updateRecordByApi(\n        host.id,\n        targetThirteenRecordId,\n        targetDateId,\n        '2024-09-13T16:45:00.000Z'\n      );\n\n      const onTargetFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      };\n\n      onTargetTasksField = await createField(host.id, {\n        name: 'On Target Tasks',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: taskId,\n          filter: onTargetFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const afterTargetFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isAfter',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      };\n\n      afterTargetHoursField = await createField(host.id, {\n        name: 'After Target Hours',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: hoursId,\n          filter: afterTargetFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const beforeTargetFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isBefore',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      };\n\n      beforeTargetHoursField = await createField(host.id, {\n        name: 'Before Target Hours',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: hoursId,\n          filter: beforeTargetFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const onOrBeforeFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isOnOrBefore',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      };\n\n      onOrBeforeTasksField = await createField(host.id, {\n        name: 'On Or Before Tasks',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: taskId,\n          filter: onOrBeforeFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const onOrAfterFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isOnOrAfter',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      };\n\n      onOrAfterTasksField = await createField(host.id, {\n        name: 'On Or After Tasks',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: taskId,\n          filter: onOrAfterFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      onOrAfterDueDateField = await createField(host.id, {\n        name: 'On Or After Due Dates',\n        type: FieldType.Date,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: dueDateId,\n          filter: onOrAfterFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should evaluate date comparisons referencing host fields', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const targetTen = records.records.find((record) => record.id === targetTenRecordId)!;\n      const targetEleven = records.records.find((record) => record.id === targetElevenRecordId)!;\n      const targetThirteen = records.records.find(\n        (record) => record.id === targetThirteenRecordId\n      )!;\n\n      expect(targetTen.fields[onTargetTasksField.id]).toEqual(['Spec Draft']);\n      expect(targetTen.fields[afterTargetHoursField.id]).toEqual([3, 7]);\n      expect(targetTen.fields[beforeTargetHoursField.id] ?? []).toEqual([]);\n      expect(targetTen.fields[onOrBeforeTasksField.id]).toEqual(['Spec Draft']);\n      expect(targetTen.fields[onOrAfterTasksField.id]).toEqual([\n        'Spec Draft',\n        'Review',\n        'Finalize',\n      ]);\n\n      expect(targetEleven.fields[onTargetTasksField.id]).toEqual(['Review']);\n      expect(targetEleven.fields[afterTargetHoursField.id]).toEqual([7]);\n      expect(targetEleven.fields[beforeTargetHoursField.id]).toEqual([5]);\n      expect(targetEleven.fields[onOrBeforeTasksField.id]).toEqual(['Spec Draft', 'Review']);\n      expect(targetEleven.fields[onOrAfterTasksField.id]).toEqual(['Review', 'Finalize']);\n\n      expect(targetThirteen.fields[onTargetTasksField.id] ?? []).toEqual([]);\n      expect(targetThirteen.fields[afterTargetHoursField.id] ?? []).toEqual([]);\n      expect(targetThirteen.fields[beforeTargetHoursField.id]).toEqual([5, 3, 7]);\n      expect(targetThirteen.fields[onOrBeforeTasksField.id]).toEqual([\n        'Spec Draft',\n        'Review',\n        'Finalize',\n      ]);\n      expect(targetThirteen.fields[onOrAfterTasksField.id] ?? []).toEqual([]);\n    });\n\n    it('should reuse source field formatting for date lookups', async () => {\n      const hostFieldDetail = await getField(host.id, onOrAfterDueDateField.id);\n      const foreignFieldDetail = await getField(foreign.id, dueDateId);\n      expect(hostFieldDetail.options).toEqual(foreignFieldDetail.options);\n    });\n  });\n\n  describe('date sort with isBefore filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let foreignThicknessId: string;\n    let foreignWidthId: string;\n    let foreignLengthId: string;\n    let foreignDateId: string;\n    let foreignPriceId: string;\n    let hostThicknessId: string;\n    let hostWidthId: string;\n    let hostLengthId: string;\n    let hostDateId: string;\n    let hostRecordEarlyId: string;\n    let hostRecordMidId: string;\n    let hostRecordAltLengthId: string;\n\n    beforeAll(async () => {\n      const numberOptions = {\n        formatting: { precision: 2, type: NumberFormattingType.Decimal },\n      };\n      const dateOptions = {\n        formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'Asia/Shanghai' },\n      };\n\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_DateSort_Foreign',\n        fields: [\n          { name: 'Thickness', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Width', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Length', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Date', type: FieldType.Date, options: dateOptions } as IFieldRo,\n          { name: 'Price', type: FieldType.Number, options: numberOptions } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              Length: 3,\n              Date: '2024-01-05T12:00:00.000Z',\n              Price: 110,\n            },\n          },\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              Length: 3,\n              Date: '2024-01-01T12:00:00.000Z',\n              Price: 100,\n            },\n          },\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              Length: 3,\n              Date: '2024-01-10T12:00:00.000Z',\n              Price: 120,\n            },\n          },\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              Length: 4,\n              Date: '2024-01-03T12:00:00.000Z',\n              Price: 130,\n            },\n          },\n        ],\n      });\n\n      foreignThicknessId = foreign.fields.find((f) => f.name === 'Thickness')!.id;\n      foreignWidthId = foreign.fields.find((f) => f.name === 'Width')!.id;\n      foreignLengthId = foreign.fields.find((f) => f.name === 'Length')!.id;\n      foreignDateId = foreign.fields.find((f) => f.name === 'Date')!.id;\n      foreignPriceId = foreign.fields.find((f) => f.name === 'Price')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_DateSort_Host',\n        fields: [\n          { name: 'Thickness', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Width', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Std Length', type: FieldType.Number, options: numberOptions } as IFieldRo,\n          { name: 'Date', type: FieldType.Date, options: dateOptions } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              'Std Length': 3,\n              Date: '2024-01-02T12:00:00.000Z',\n            },\n          },\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              'Std Length': 3,\n              Date: '2024-01-08T12:00:00.000Z',\n            },\n          },\n          {\n            fields: {\n              Thickness: 1.2,\n              Width: 2.5,\n              'Std Length': 4,\n              Date: '2024-01-04T12:00:00.000Z',\n            },\n          },\n        ],\n      });\n\n      hostThicknessId = host.fields.find((f) => f.name === 'Thickness')!.id;\n      hostWidthId = host.fields.find((f) => f.name === 'Width')!.id;\n      hostLengthId = host.fields.find((f) => f.name === 'Std Length')!.id;\n      hostDateId = host.fields.find((f) => f.name === 'Date')!.id;\n\n      hostRecordEarlyId = host.records[0].id;\n      hostRecordMidId = host.records[1].id;\n      hostRecordAltLengthId = host.records[2].id;\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignThicknessId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostThicknessId },\n          },\n          {\n            fieldId: foreignWidthId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostWidthId },\n          },\n          {\n            fieldId: foreignLengthId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostLengthId },\n          },\n          {\n            fieldId: foreignDateId,\n            operator: 'isBefore',\n            value: { type: 'field', fieldId: hostDateId },\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Lookup Price',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: numberOptions,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignPriceId,\n          filter,\n          sort: { fieldId: foreignDateId, order: SortFunc.Asc },\n          limit: 1,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should sort and limit conditional lookup results by date', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const earlyRecord = records.records.find((record) => record.id === hostRecordEarlyId)!;\n      const midRecord = records.records.find((record) => record.id === hostRecordMidId)!;\n      const altLengthRecord = records.records.find(\n        (record) => record.id === hostRecordAltLengthId\n      )!;\n\n      expect(earlyRecord.fields[lookupField.id]).toEqual([100]);\n      expect(midRecord.fields[lookupField.id]).toEqual([100]);\n      expect(altLengthRecord.fields[lookupField.id]).toEqual([130]);\n    });\n  });\n\n  describe('self-table field-reference lookups projecting alternate fields', () => {\n    let table: ITableFullVo;\n    let nameId: string;\n    let nameMirrorId: string;\n    let title2Id: string;\n    let matchingLookupField: IFieldVo;\n    let rowAliceId: string;\n    let rowBobId: string;\n    let rowCharlieId: string;\n    let rowDaveId: string;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'ConditionalLookup_Self_AltProjection',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'NameMirror', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Title2', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'T1', Name: 'Alice', NameMirror: 'Alice', Title2: 'T1-alt' } },\n          { fields: { Title: 'T2', Name: 'Bob', NameMirror: 'Alice', Title2: 'T2-alt' } },\n          { fields: { Title: 'T3', Name: 'Charlie', NameMirror: 'Charlie', Title2: 'T3-alt' } },\n          { fields: { Title: 'T4', Name: 'Dave', Title2: 'T4-alt' } },\n        ],\n      });\n\n      nameId = table.fields.find((f) => f.name === 'Name')!.id;\n      nameMirrorId = table.fields.find((f) => f.name === 'NameMirror')!.id;\n      title2Id = table.fields.find((f) => f.name === 'Title2')!.id;\n\n      rowAliceId = table.records[0].id;\n      rowBobId = table.records[1].id;\n      rowCharlieId = table.records[2].id;\n      rowDaveId = table.records[3].id;\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: nameMirrorId,\n            operator: 'is',\n            value: { type: 'field', fieldId: nameId },\n          },\n        ],\n      };\n\n      matchingLookupField = await createField(table.id, {\n        name: 'Matching Title2 Values',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: table.id,\n          lookupFieldId: title2Id,\n          filter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should project the requested field from matching self-table rows', async () => {\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const rowAlice = records.records.find((r) => r.id === rowAliceId)!;\n      const rowBob = records.records.find((r) => r.id === rowBobId)!;\n      const rowCharlie = records.records.find((r) => r.id === rowCharlieId)!;\n      const rowDave = records.records.find((r) => r.id === rowDaveId)!;\n\n      expect(rowAlice.fields[matchingLookupField.id]).toEqual(['T1-alt']);\n      expect(rowBob.fields[matchingLookupField.id]).toEqual(['T1-alt']);\n      expect(rowCharlie.fields[matchingLookupField.id]).toEqual(['T3-alt']);\n      expect(rowDave.fields[matchingLookupField.id] ?? []).toEqual([]);\n    });\n  });\n\n  describe('self-table field-reference lookups selecting alternate titles', () => {\n    let table: ITableFullVo;\n    let nameId: string;\n    let name2Id: string;\n    let title2Id: string;\n    let lookupAltTitleField: IFieldVo;\n    let row1Id: string;\n    let row2Id: string;\n    let row3Id: string;\n    let row4Id: string;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'ConditionalLookup_Self_Title2',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Name2', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Title2', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: '00001', Name: '张三', Name2: '张三', Title2: '00001' } },\n          { fields: { Title: '00002', Name: '李四', Name2: null, Title2: null } },\n          { fields: { Title: '00003', Name: '王五', Name2: '李四', Title2: '00002' } },\n          { fields: { Title: '00004', Name: '赵六', Name2: '你好', Title2: null } },\n        ],\n      });\n\n      nameId = table.fields.find((f) => f.name === 'Name')!.id;\n      name2Id = table.fields.find((f) => f.name === 'Name2')!.id;\n      title2Id = table.fields.find((f) => f.name === 'Title2')!.id;\n\n      row1Id = table.records[0].id;\n      row2Id = table.records[1].id;\n      row3Id = table.records[2].id;\n      row4Id = table.records[3].id;\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: name2Id,\n            operator: 'is',\n            value: { type: 'field', fieldId: nameId },\n          },\n        ],\n      };\n\n      lookupAltTitleField = await createField(table.id, {\n        name: 'Title2 via matching Name2',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: table.id,\n          lookupFieldId: title2Id,\n          filter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should return Title2 from foreign rows where host Name2 matches foreign Name', async () => {\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((r) => r.id === row1Id)!;\n      const row2 = records.records.find((r) => r.id === row2Id)!;\n      const row3 = records.records.find((r) => r.id === row3Id)!;\n      const row4 = records.records.find((r) => r.id === row4Id)!;\n\n      expect(row1.fields[lookupAltTitleField.id]).toEqual(['00001']);\n      expect(row2.fields[lookupAltTitleField.id] ?? []).toEqual([]);\n      expect(row3.fields[lookupAltTitleField.id] ?? []).toEqual([]);\n      expect(row4.fields[lookupAltTitleField.id] ?? []).toEqual([]);\n    });\n  });\n\n  describe('boolean field reference filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let booleanLookupField: IFieldVo;\n    let titleFieldId: string;\n    let statusFieldId: string;\n    let hostFlagFieldId: string;\n    let hostTrueRecordId: string;\n    let hostUnsetRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Bool_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'IsActive', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', IsActive: true } },\n          { fields: { Title: 'Beta', IsActive: false } },\n          { fields: { Title: 'Gamma', IsActive: true } },\n        ],\n      });\n      titleFieldId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusFieldId = foreign.fields.find((field) => field.name === 'IsActive')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Bool_Host',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'TargetActive', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Name: 'Should Match True', TargetActive: true } },\n          { fields: { Name: 'Should Match Unset' } },\n        ],\n      });\n      hostFlagFieldId = host.fields.find((field) => field.name === 'TargetActive')!.id;\n      hostTrueRecordId = host.records[0].id;\n      hostUnsetRecordId = host.records[1].id;\n\n      const booleanFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostFlagFieldId },\n          },\n        ],\n      };\n\n      booleanLookupField = await createField(host.id, {\n        name: 'Matching Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleFieldId,\n          filter: booleanFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should filter boolean-referenced lookups', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hostTrueRecord = records.records.find((record) => record.id === hostTrueRecordId)!;\n      const hostUnsetRecord = records.records.find((record) => record.id === hostUnsetRecordId)!;\n\n      expect(hostTrueRecord.fields[booleanLookupField.id]).toEqual(['Alpha', 'Gamma']);\n      expect(hostUnsetRecord.fields[booleanLookupField.id] ?? []).toEqual([]);\n    });\n\n    it('should react when host boolean criteria change', async () => {\n      await updateRecordByApi(host.id, hostTrueRecordId, hostFlagFieldId, null);\n      await updateRecordByApi(host.id, hostUnsetRecordId, hostFlagFieldId, true);\n\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hostTrueRecord = records.records.find((record) => record.id === hostTrueRecordId)!;\n      const hostUnsetRecord = records.records.find((record) => record.id === hostUnsetRecordId)!;\n\n      expect(hostTrueRecord.fields[booleanLookupField.id] ?? []).toEqual([]);\n      expect(hostUnsetRecord.fields[booleanLookupField.id]).toEqual(['Alpha', 'Gamma']);\n    });\n  });\n\n  describe('field and literal comparison matrix', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let fieldDrivenTitlesField: IFieldVo;\n    let literalMixTitlesField: IFieldVo;\n    let quantityWindowLookupField: IFieldVo;\n    let titleId: string;\n    let categoryId: string;\n    let amountId: string;\n    let quantityId: string;\n    let statusId: string;\n    let categoryPickId: string;\n    let amountFloorId: string;\n    let quantityMaxId: string;\n    let statusTargetId: string;\n    let hostHardwareActiveId: string;\n    let hostOfficeActiveId: string;\n    let hostHardwareInactiveId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_FieldMatrix_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Quantity', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Laptop',\n              Category: 'Hardware',\n              Amount: 80,\n              Quantity: 5,\n              Status: 'Active',\n            },\n          },\n          {\n            fields: {\n              Title: 'Monitor',\n              Category: 'Hardware',\n              Amount: 20,\n              Quantity: 2,\n              Status: 'Inactive',\n            },\n          },\n          {\n            fields: {\n              Title: 'Subscription',\n              Category: 'Office',\n              Amount: 60,\n              Quantity: 10,\n              Status: 'Active',\n            },\n          },\n          {\n            fields: {\n              Title: 'Upgrade',\n              Category: 'Office',\n              Amount: 35,\n              Quantity: 3,\n              Status: 'Active',\n            },\n          },\n        ],\n      });\n      titleId = foreign.fields.find((f) => f.name === 'Title')!.id;\n      categoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      quantityId = foreign.fields.find((f) => f.name === 'Quantity')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_FieldMatrix_Host',\n        fields: [\n          { name: 'CategoryPick', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'AmountFloor', type: FieldType.Number } as IFieldRo,\n          { name: 'QuantityMax', type: FieldType.Number } as IFieldRo,\n          { name: 'StatusTarget', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              CategoryPick: 'Hardware',\n              AmountFloor: 60,\n              QuantityMax: 10,\n              StatusTarget: 'Active',\n            },\n          },\n          {\n            fields: {\n              CategoryPick: 'Office',\n              AmountFloor: 30,\n              QuantityMax: 12,\n              StatusTarget: 'Active',\n            },\n          },\n          {\n            fields: {\n              CategoryPick: 'Hardware',\n              AmountFloor: 10,\n              QuantityMax: 4,\n              StatusTarget: 'Inactive',\n            },\n          },\n        ],\n      });\n\n      categoryPickId = host.fields.find((f) => f.name === 'CategoryPick')!.id;\n      amountFloorId = host.fields.find((f) => f.name === 'AmountFloor')!.id;\n      quantityMaxId = host.fields.find((f) => f.name === 'QuantityMax')!.id;\n      statusTargetId = host.fields.find((f) => f.name === 'StatusTarget')!.id;\n      hostHardwareActiveId = host.records[0].id;\n      hostOfficeActiveId = host.records[1].id;\n      hostHardwareInactiveId = host.records[2].id;\n\n      const fieldDrivenFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryPickId },\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreaterEqual',\n            value: { type: 'field', fieldId: amountFloorId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusTargetId },\n          },\n        ],\n      };\n\n      fieldDrivenTitlesField = await createField(host.id, {\n        name: 'Field Driven Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: fieldDrivenFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const literalMixFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: 'Hardware',\n          },\n          {\n            fieldId: statusId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: statusTargetId },\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: 15,\n          },\n        ],\n      };\n\n      literalMixTitlesField = await createField(host.id, {\n        name: 'Literal Mix Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: literalMixFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const quantityWindowFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryPickId },\n          },\n          {\n            fieldId: quantityId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: quantityMaxId },\n          },\n        ],\n      };\n\n      quantityWindowLookupField = await createField(host.id, {\n        name: 'Quantity Window Values',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: quantityId,\n          filter: quantityWindowFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should evaluate field-to-field comparisons across operators', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[fieldDrivenTitlesField.id]).toEqual(['Laptop']);\n      expect(officeActive.fields[fieldDrivenTitlesField.id]).toEqual(['Subscription', 'Upgrade']);\n      expect(hardwareInactive.fields[fieldDrivenTitlesField.id]).toEqual(['Monitor']);\n    });\n\n    it('should mix literal and field referenced criteria', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[literalMixTitlesField.id]).toEqual(['Monitor']);\n      expect(officeActive.fields[literalMixTitlesField.id]).toEqual(['Monitor']);\n      expect(hardwareInactive.fields[literalMixTitlesField.id]).toEqual(['Laptop']);\n    });\n\n    it('should support field referenced numeric windows with lookups', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[quantityWindowLookupField.id]).toEqual([5, 2]);\n      expect(officeActive.fields[quantityWindowLookupField.id]).toEqual([10, 3]);\n      expect(hardwareInactive.fields[quantityWindowLookupField.id]).toEqual([2]);\n    });\n\n    it('should recompute when host thresholds change', async () => {\n      await updateRecordByApi(host.id, hostHardwareActiveId, amountFloorId, 90);\n      const tightened = await getRecord(host.id, hostHardwareActiveId);\n      expect(tightened.fields[fieldDrivenTitlesField.id] ?? []).toEqual([]);\n\n      await updateRecordByApi(host.id, hostHardwareActiveId, amountFloorId, 60);\n      const restored = await getRecord(host.id, hostHardwareActiveId);\n      expect(restored.fields[fieldDrivenTitlesField.id]).toEqual(['Laptop']);\n    });\n  });\n\n  describe('advanced operator coverage', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let tierWindowNamesField: IFieldVo;\n    let tagAllLookupField: IFieldVo;\n    let tagNoneLookupField: IFieldVo;\n    let ratingValuesLookupField: IFieldVo;\n    let currencyScoreLookupField: IFieldVo;\n    let percentScoreLookupField: IFieldVo;\n    let tierSelectLookupField: IFieldVo;\n    let nameId: string;\n    let tierId: string;\n    let tagsId: string;\n    let ratingId: string;\n    let scoreId: string;\n    let targetTierId: string;\n    let minRatingId: string;\n    let maxScoreId: string;\n    let hostRow1Id: string;\n    let hostRow2Id: string;\n    let hostRow3Id: string;\n\n    beforeAll(async () => {\n      const tierChoices = [\n        { id: 'tier-basic', name: 'Basic', color: Colors.Blue },\n        { id: 'tier-pro', name: 'Pro', color: Colors.Green },\n        { id: 'tier-enterprise', name: 'Enterprise', color: Colors.Orange },\n      ];\n      const tagChoices = [\n        { id: 'tag-urgent', name: 'Urgent', color: Colors.Red },\n        { id: 'tag-review', name: 'Review', color: Colors.Blue },\n        { id: 'tag-backlog', name: 'Backlog', color: Colors.Purple },\n      ];\n\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_AdvancedOps_Foreign',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Tier',\n            type: FieldType.SingleSelect,\n            options: { choices: tierChoices },\n          } as IFieldRo,\n          {\n            name: 'Tags',\n            type: FieldType.MultipleSelect,\n            options: { choices: tagChoices },\n          } as IFieldRo,\n          { name: 'IsActive', type: FieldType.Checkbox } as IFieldRo,\n          {\n            name: 'Rating',\n            type: FieldType.Rating,\n            options: { icon: 'star', color: 'yellowBright', max: 5 },\n          } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Alpha',\n              Tier: 'Basic',\n              Tags: ['Urgent', 'Review'],\n              IsActive: true,\n              Rating: 4,\n              Score: 45,\n            },\n          },\n          {\n            fields: {\n              Name: 'Beta',\n              Tier: 'Pro',\n              Tags: ['Review'],\n              IsActive: false,\n              Rating: 5,\n              Score: 80,\n            },\n          },\n          {\n            fields: {\n              Name: 'Gamma',\n              Tier: 'Pro',\n              Tags: ['Urgent'],\n              IsActive: true,\n              Rating: 2,\n              Score: 30,\n            },\n          },\n          {\n            fields: {\n              Name: 'Delta',\n              Tier: 'Enterprise',\n              Tags: ['Review', 'Backlog'],\n              IsActive: true,\n              Rating: 4,\n              Score: 55,\n            },\n          },\n          {\n            fields: {\n              Name: 'Epsilon',\n              Tier: 'Pro',\n              Tags: ['Review'],\n              IsActive: true,\n              Rating: null,\n              Score: 25,\n            },\n          },\n        ],\n      });\n\n      nameId = foreign.fields.find((f) => f.name === 'Name')!.id;\n      tierId = foreign.fields.find((f) => f.name === 'Tier')!.id;\n      tagsId = foreign.fields.find((f) => f.name === 'Tags')!.id;\n      ratingId = foreign.fields.find((f) => f.name === 'Rating')!.id;\n      scoreId = foreign.fields.find((f) => f.name === 'Score')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_AdvancedOps_Host',\n        fields: [\n          {\n            name: 'TargetTier',\n            type: FieldType.SingleSelect,\n            options: { choices: tierChoices },\n          } as IFieldRo,\n          { name: 'MinRating', type: FieldType.Number } as IFieldRo,\n          { name: 'MaxScore', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              TargetTier: 'Basic',\n              MinRating: 3,\n              MaxScore: 60,\n            },\n          },\n          {\n            fields: {\n              TargetTier: 'Pro',\n              MinRating: 4,\n              MaxScore: 90,\n            },\n          },\n          {\n            fields: {\n              TargetTier: 'Enterprise',\n              MinRating: 4,\n              MaxScore: 70,\n            },\n          },\n        ],\n      });\n\n      targetTierId = host.fields.find((f) => f.name === 'TargetTier')!.id;\n      minRatingId = host.fields.find((f) => f.name === 'MinRating')!.id;\n      maxScoreId = host.fields.find((f) => f.name === 'MaxScore')!.id;\n      hostRow1Id = host.records[0].id;\n      hostRow2Id = host.records[1].id;\n      hostRow3Id = host.records[2].id;\n\n      const tierWindowFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: tierId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetTierId },\n          },\n          {\n            fieldId: tagsId,\n            operator: 'hasAllOf',\n            value: ['Review'],\n          },\n          {\n            fieldId: tagsId,\n            operator: 'hasNoneOf',\n            value: ['Backlog'],\n          },\n          {\n            fieldId: ratingId,\n            operator: 'isGreaterEqual',\n            value: { type: 'field', fieldId: minRatingId },\n          },\n          {\n            fieldId: scoreId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: maxScoreId },\n          },\n        ],\n      };\n\n      tierWindowNamesField = await createField(host.id, {\n        name: 'Tier Window Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: nameId,\n          filter: tierWindowFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      tagAllLookupField = await createField(host.id, {\n        name: 'Tag All Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: nameId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: tagsId,\n                operator: 'hasAllOf',\n                value: ['Review'],\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      tagNoneLookupField = await createField(host.id, {\n        name: 'Tag None Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: nameId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: tagsId,\n                operator: 'hasNoneOf',\n                value: ['Backlog'],\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      ratingValuesLookupField = await createField(host.id, {\n        name: 'Rating Values',\n        type: FieldType.Rating,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: ratingId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: ratingId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      currencyScoreLookupField = await createField(host.id, {\n        name: 'Score Currency Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Currency,\n            symbol: '¥',\n            precision: 1,\n          },\n        },\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: scoreId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      percentScoreLookupField = await createField(host.id, {\n        name: 'Score Percent Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Percent,\n            precision: 2,\n          },\n        },\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: scoreId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      tierSelectLookupField = await createField(host.id, {\n        name: 'Tier Select Lookup',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: {\n          choices: tierChoices,\n        },\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: tierId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: tagsId,\n                operator: 'hasAllOf',\n                value: ['Review'],\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should evaluate combined field-referenced conditions across heterogeneous types', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n      const row2 = records.records.find((record) => record.id === hostRow2Id)!;\n      const row3 = records.records.find((record) => record.id === hostRow3Id)!;\n\n      expect(row1.fields[tierWindowNamesField.id]).toEqual(['Alpha']);\n      expect(row2.fields[tierWindowNamesField.id]).toEqual(['Beta']);\n      expect(row3.fields[tierWindowNamesField.id] ?? []).toEqual([]);\n    });\n\n    it('should evaluate multi-select operators within lookups', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n      const row2 = records.records.find((record) => record.id === hostRow2Id)!;\n      const row3 = records.records.find((record) => record.id === hostRow3Id)!;\n\n      const expectedTagAll = ['Alpha', 'Beta', 'Delta', 'Epsilon'].sort();\n      const expectedTagNone = ['Alpha', 'Beta', 'Gamma', 'Epsilon'].sort();\n\n      const row1TagAll = [...(row1.fields[tagAllLookupField.id] as string[])].sort();\n      const row2TagAll = [...(row2.fields[tagAllLookupField.id] as string[])].sort();\n      const row3TagAll = [...(row3.fields[tagAllLookupField.id] as string[])].sort();\n      expect(row1TagAll).toEqual(expectedTagAll);\n      expect(row2TagAll).toEqual(expectedTagAll);\n      expect(row3TagAll).toEqual(expectedTagAll);\n\n      const row1TagNone = [...(row1.fields[tagNoneLookupField.id] as string[])].sort();\n      const row2TagNone = [...(row2.fields[tagNoneLookupField.id] as string[])].sort();\n      const row3TagNone = [...(row3.fields[tagNoneLookupField.id] as string[])].sort();\n      expect(row1TagNone).toEqual(expectedTagNone);\n      expect(row2TagNone).toEqual(expectedTagNone);\n      expect(row3TagNone).toEqual(expectedTagNone);\n    });\n\n    it('should filter rating values while excluding empty entries', async () => {\n      const record = await getRecord(host.id, hostRow1Id);\n      const ratings = [...(record.fields[ratingValuesLookupField.id] as number[])].sort();\n      expect(ratings).toEqual([2, 4, 4, 5]);\n    });\n\n    it('should persist numeric formatting options on lookup fields', async () => {\n      const currencyFieldMeta = await getField(host.id, currencyScoreLookupField.id);\n      const currencyFormatting = currencyFieldMeta.options as {\n        formatting?: { type: NumberFormattingType; precision?: number; symbol?: string };\n      };\n      expect(currencyFormatting.formatting).toEqual({\n        type: NumberFormattingType.Currency,\n        symbol: '¥',\n        precision: 1,\n      });\n\n      const percentFieldMeta = await getField(host.id, percentScoreLookupField.id);\n      const percentFormatting = percentFieldMeta.options as {\n        formatting?: { type: NumberFormattingType; precision?: number };\n      };\n      expect(percentFormatting.formatting).toEqual({\n        type: NumberFormattingType.Percent,\n        precision: 2,\n      });\n\n      const record = await getRecord(host.id, hostRow1Id);\n      const expectedTotals = [25, 30, 45, 55, 80];\n      const currencyValues = [...(record.fields[currencyScoreLookupField.id] as number[])].sort(\n        (a, b) => a - b\n      );\n      const percentValues = [...(record.fields[percentScoreLookupField.id] as number[])].sort(\n        (a, b) => a - b\n      );\n      expect(currencyValues).toEqual(expectedTotals);\n      expect(percentValues).toEqual(expectedTotals);\n    });\n\n    it('should include select metadata within lookup results', async () => {\n      const record = await getRecord(host.id, hostRow1Id);\n      const tiers = record.fields[tierSelectLookupField.id] as Array<\n        string | { id: string; name: string; color: string }\n      >;\n      expect(Array.isArray(tiers)).toBe(true);\n      const tierNames = tiers\n        .map((tier) => (typeof tier === 'string' ? tier : tier.name))\n        .filter((name): name is string => Boolean(name))\n        .sort();\n      expect(tierNames).toEqual(['Basic', 'Enterprise', 'Pro', 'Pro'].sort());\n      tiers.forEach((tier) => {\n        if (typeof tier === 'string') {\n          expect(typeof tier).toBe('string');\n          return;\n        }\n        expect(typeof tier.id).toBe('string');\n        expect(typeof tier.color).toBe('string');\n      });\n    });\n\n    it('should preserve computed metadata when renaming select lookups via convertField', async () => {\n      const beforeRename = await getField(host.id, tierSelectLookupField.id);\n      expect(beforeRename.dbFieldType).toBe(DbFieldType.Json);\n      expect(beforeRename.isMultipleCellValue).toBe(true);\n      expect(beforeRename.isComputed).toBe(true);\n      expect(beforeRename.lookupOptions).toBeDefined();\n\n      const originalName = beforeRename.name;\n      const fieldId = tierSelectLookupField.id;\n\n      try {\n        tierSelectLookupField = await convertField(host.id, fieldId, {\n          name: 'Tier Select Lookup Renamed',\n          type: FieldType.SingleSelect,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: beforeRename.options,\n          lookupOptions: beforeRename.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n\n        expect(tierSelectLookupField.name).toBe('Tier Select Lookup Renamed');\n        expect(tierSelectLookupField.dbFieldType).toBe(DbFieldType.Json);\n        expect(tierSelectLookupField.isLookup).toBe(true);\n        expect(tierSelectLookupField.isConditionalLookup).toBe(true);\n        expect(tierSelectLookupField.isComputed).toBe(true);\n        expect(tierSelectLookupField.isMultipleCellValue).toBe(true);\n        expect(tierSelectLookupField.options).toEqual(beforeRename.options);\n        expect(tierSelectLookupField.lookupOptions).toMatchObject(\n          beforeRename.lookupOptions as Record<string, unknown>\n        );\n\n        const record = await getRecord(host.id, hostRow1Id);\n        const tiers = record.fields[tierSelectLookupField.id] as Array<string | { name?: string }>;\n        expect(Array.isArray(tiers)).toBe(true);\n        const tierNames = tiers\n          .map((tier) => (typeof tier === 'string' ? tier : tier.name))\n          .filter((name): name is string => Boolean(name))\n          .sort();\n        expect(tierNames).toEqual(['Basic', 'Enterprise', 'Pro', 'Pro'].sort());\n      } finally {\n        tierSelectLookupField = await convertField(host.id, fieldId, {\n          name: originalName,\n          type: FieldType.SingleSelect,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: beforeRename.options,\n          lookupOptions: beforeRename.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n      }\n    });\n\n    it('should preserve computed metadata when renaming text conditional lookups via convertField', async () => {\n      const beforeRename = await getField(host.id, tagAllLookupField.id);\n      expect(beforeRename.dbFieldType).toBe(DbFieldType.Json);\n      expect(beforeRename.isMultipleCellValue).toBe(true);\n      expect(beforeRename.isComputed).toBe(true);\n      expect(beforeRename.lookupOptions).toBeDefined();\n\n      const originalName = beforeRename.name;\n      const fieldId = tagAllLookupField.id;\n      const recordBefore = await getRecord(host.id, hostRow1Id);\n      const baseline = recordBefore.fields[fieldId];\n\n      try {\n        tagAllLookupField = await convertField(host.id, fieldId, {\n          name: 'Tag All Names Renamed',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: beforeRename.options,\n          lookupOptions: beforeRename.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n\n        expect(tagAllLookupField.name).toBe('Tag All Names Renamed');\n        expect(tagAllLookupField.dbFieldType).toBe(DbFieldType.Json);\n        expect(tagAllLookupField.isLookup).toBe(true);\n        expect(tagAllLookupField.isConditionalLookup).toBe(true);\n        expect(tagAllLookupField.isComputed).toBe(true);\n        expect(tagAllLookupField.isMultipleCellValue).toBe(true);\n        expect(tagAllLookupField.options).toEqual(beforeRename.options);\n        expect(tagAllLookupField.lookupOptions).toMatchObject(\n          beforeRename.lookupOptions as Record<string, unknown>\n        );\n\n        const recordAfter = await getRecord(host.id, hostRow1Id);\n        expect(recordAfter.fields[fieldId]).toEqual(baseline);\n      } finally {\n        tagAllLookupField = await convertField(host.id, fieldId, {\n          name: originalName,\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: beforeRename.options,\n          lookupOptions: beforeRename.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n      }\n    });\n\n    it('should retain computed metadata when renaming and updating lookup formatting via convertField', async () => {\n      const beforeUpdate = await getField(host.id, currencyScoreLookupField.id);\n      expect(beforeUpdate.dbFieldType).toBe(DbFieldType.Json);\n      const fieldId = currencyScoreLookupField.id;\n      const originalName = beforeUpdate.name;\n      const recordBefore = await getRecord(host.id, hostRow1Id);\n      const baseline = recordBefore.fields[fieldId];\n      const originalOptions = beforeUpdate.options as {\n        formatting?: { type: NumberFormattingType; symbol?: string; precision?: number };\n      };\n      const updatedOptions = {\n        ...originalOptions,\n        formatting: {\n          type: NumberFormattingType.Currency,\n          symbol: '$',\n          precision: 0,\n        },\n      };\n\n      try {\n        currencyScoreLookupField = await convertField(host.id, fieldId, {\n          name: `${originalName} Renamed`,\n          type: FieldType.Number,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: updatedOptions,\n          lookupOptions: beforeUpdate.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n\n        expect(currencyScoreLookupField.name).toBe(`${originalName} Renamed`);\n        expect(currencyScoreLookupField.dbFieldType).toBe(beforeUpdate.dbFieldType);\n        expect(currencyScoreLookupField.isComputed).toBe(true);\n        expect(currencyScoreLookupField.isMultipleCellValue).toBe(true);\n        expect((currencyScoreLookupField.options as typeof updatedOptions).formatting).toEqual(\n          updatedOptions.formatting\n        );\n\n        const recordAfter = await getRecord(host.id, hostRow1Id);\n        expect(recordAfter.fields[fieldId]).toEqual(baseline);\n      } finally {\n        currencyScoreLookupField = await convertField(host.id, fieldId, {\n          name: originalName,\n          type: FieldType.Number,\n          isLookup: true,\n          isConditionalLookup: true,\n          options: originalOptions,\n          lookupOptions: beforeUpdate.lookupOptions as ILookupOptionsRo,\n        } as IFieldRo);\n      }\n    });\n\n    it('should recompute when host filters change', async () => {\n      await updateRecordByApi(host.id, hostRow1Id, maxScoreId, 40);\n      const tightened = await getRecord(host.id, hostRow1Id);\n      expect(tightened.fields[tierWindowNamesField.id] ?? []).toEqual([]);\n\n      await updateRecordByApi(host.id, hostRow1Id, maxScoreId, 60);\n      const restored = await getRecord(host.id, hostRow1Id);\n      expect(restored.fields[tierWindowNamesField.id]).toEqual(['Alpha']);\n\n      await updateRecordByApi(host.id, hostRow2Id, minRatingId, 6);\n      const stricter = await getRecord(host.id, hostRow2Id);\n      expect(stricter.fields[tierWindowNamesField.id] ?? []).toEqual([]);\n\n      await updateRecordByApi(host.id, hostRow2Id, minRatingId, 4);\n      const ratingRestored = await getRecord(host.id, hostRow2Id);\n      expect(ratingRestored.fields[tierWindowNamesField.id]).toEqual(['Beta']);\n    });\n  });\n\n  describe('conditional lookup referencing derived field types', () => {\n    let derivedBaseId: string;\n    let suppliers: ITableFullVo;\n    let products: ITableFullVo;\n    let host: ITableFullVo;\n    let supplierRatingId: string;\n    let linkToSupplierField: IFieldVo;\n    let supplierRatingLookup: IFieldVo;\n    let supplierRatingConditionalLookup: IFieldVo;\n    let supplierRatingConditionalRollup: IFieldVo;\n    let supplierRatingDoubleFormula: IFieldVo;\n    let ratingValuesLookupField: IFieldVo;\n    let ratingFormulaLookupField: IFieldVo;\n    let supplierLinkLookupField: IFieldVo;\n    let conditionalLookupMirrorField: IFieldVo;\n    let conditionalRollupMirrorField: IFieldVo;\n    let hostProductsLinkField: IFieldVo;\n    let minSupplierRatingFieldId: string;\n    let supplierNameFieldId: string;\n    let productSupplierNameFieldId: string;\n    let supplierBRecordId: string;\n    let subscriptionProductId: string;\n\n    beforeAll(async () => {\n      const createdBase = await createBase({\n        spaceId: globalThis.testConfig.spaceId,\n        name: 'Conditional Lookup Derived Types',\n      });\n      derivedBaseId = createdBase.id;\n\n      suppliers = await createTable(derivedBaseId, {\n        name: 'ConditionalLookup_Supplier',\n        fields: [\n          { name: 'SupplierName', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Rating', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { SupplierName: 'Supplier A', Rating: 5 } },\n          { fields: { SupplierName: 'Supplier B', Rating: 4 } },\n        ],\n      });\n      supplierRatingId = suppliers.fields.find((f) => f.name === 'Rating')!.id;\n      supplierNameFieldId = suppliers.fields.find((f) => f.name === 'SupplierName')!.id;\n      supplierBRecordId = suppliers.records.find(\n        (record) => record.fields.SupplierName === 'Supplier B'\n      )!.id;\n\n      products = await createTable(derivedBaseId, {\n        name: 'ConditionalLookup_Product',\n        fields: [\n          { name: 'ProductName', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Supplier Name', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { ProductName: 'Laptop', 'Supplier Name': 'Supplier A' } },\n          { fields: { ProductName: 'Mouse', 'Supplier Name': 'Supplier B' } },\n          { fields: { ProductName: 'Subscription', 'Supplier Name': 'Supplier B' } },\n        ],\n      });\n      productSupplierNameFieldId = products.fields.find((f) => f.name === 'Supplier Name')!.id;\n      subscriptionProductId = products.records.find(\n        (record) => record.fields.ProductName === 'Subscription'\n      )!.id;\n\n      linkToSupplierField = await createField(products.id, {\n        name: 'Supplier Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: suppliers.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(products.id, products.records[0].id, linkToSupplierField.id, {\n        id: suppliers.records[0].id,\n      });\n      await updateRecordByApi(products.id, products.records[1].id, linkToSupplierField.id, {\n        id: suppliers.records[1].id,\n      });\n      await updateRecordByApi(products.id, products.records[2].id, linkToSupplierField.id, {\n        id: suppliers.records[1].id,\n      });\n\n      supplierRatingLookup = await createField(products.id, {\n        name: 'Supplier Rating Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: suppliers.id,\n          linkFieldId: linkToSupplierField.id,\n          lookupFieldId: supplierRatingId,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await createField(products.id, {\n        name: 'Supplier Rating Sum',\n        type: FieldType.Rollup,\n        lookupOptions: {\n          foreignTableId: suppliers.id,\n          linkFieldId: linkToSupplierField.id,\n          lookupFieldId: supplierRatingId,\n        } as ILookupOptionsRo,\n        options: {\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      const minSupplierRatingField = await createField(products.id, {\n        name: 'Minimum Supplier Rating',\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 1,\n          },\n        },\n      } as IFieldRo);\n      minSupplierRatingFieldId = minSupplierRatingField.id;\n\n      await updateRecordByApi(products.id, products.records[0].id, minSupplierRatingFieldId, 4.5);\n      await updateRecordByApi(products.id, products.records[1].id, minSupplierRatingFieldId, 3.5);\n      await updateRecordByApi(products.id, products.records[2].id, minSupplierRatingFieldId, 4.5);\n\n      supplierRatingConditionalLookup = await createField(products.id, {\n        name: 'Supplier Rating Conditional Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 1,\n          },\n        },\n        lookupOptions: {\n          foreignTableId: suppliers.id,\n          lookupFieldId: supplierRatingId,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: supplierNameFieldId,\n                operator: 'is',\n                value: { type: 'field', fieldId: productSupplierNameFieldId },\n              },\n              {\n                fieldId: supplierRatingId,\n                operator: 'isGreaterEqual',\n                value: { type: 'field', fieldId: minSupplierRatingFieldId },\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      supplierRatingDoubleFormula = await createField(products.id, {\n        name: 'Supplier Rating Double',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${supplierRatingLookup.id}} * 2`,\n        },\n      } as IFieldRo);\n\n      const supplierRatingConditionalRollupOptions: IConditionalRollupFieldOptions = {\n        foreignTableId: suppliers.id,\n        lookupFieldId: supplierRatingId,\n        expression: 'sum({values})',\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: supplierNameFieldId,\n              operator: 'is',\n              value: { type: 'field', fieldId: productSupplierNameFieldId },\n            },\n            {\n              fieldId: supplierRatingId,\n              operator: 'isGreaterEqual',\n              value: { type: 'field', fieldId: minSupplierRatingFieldId },\n            },\n          ],\n        },\n      };\n\n      supplierRatingConditionalRollup = await createField(products.id, {\n        name: 'Supplier Rating Conditional Sum',\n        type: FieldType.ConditionalRollup,\n        options: supplierRatingConditionalRollupOptions,\n      } as IFieldRo);\n\n      host = await createTable(derivedBaseId, {\n        name: 'ConditionalLookup_Derived_Host',\n        fields: [{ name: 'Summary', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Summary: 'Global' } }],\n      });\n\n      hostProductsLinkField = await createField(host.id, {\n        name: 'Products Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: products.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        host.id,\n        host.records[0].id,\n        hostProductsLinkField.id,\n        products.records.map((record) => ({ id: record.id }))\n      );\n\n      const ratingPresentFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: supplierRatingLookup.id,\n            operator: 'isNotEmpty',\n            value: null,\n          },\n        ],\n      };\n\n      ratingValuesLookupField = await createField(host.id, {\n        name: 'Supplier Ratings (Lookup)',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingLookup.id,\n          filter: ratingPresentFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      ratingFormulaLookupField = await createField(host.id, {\n        name: 'Supplier Ratings Doubled (Lookup)',\n        type: FieldType.Formula,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingDoubleFormula.id,\n          filter: ratingPresentFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      supplierLinkLookupField = await createField(host.id, {\n        name: 'Supplier Links (Lookup)',\n        type: FieldType.Link,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: products.id,\n          lookupFieldId: linkToSupplierField.id,\n          filter: ratingPresentFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const conditionalLookupHasValueFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: supplierRatingConditionalLookup.id,\n            operator: 'isNotEmpty',\n            value: null,\n          },\n        ],\n      };\n\n      conditionalLookupMirrorField = await createField(host.id, {\n        name: 'Supplier Ratings (Conditional Lookup Source)',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingConditionalLookup.id,\n          filter: conditionalLookupHasValueFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const positiveConditionalRollupFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: supplierRatingConditionalRollup.id,\n            operator: 'isGreater',\n            value: 0,\n          },\n        ],\n      };\n\n      conditionalRollupMirrorField = await createField(host.id, {\n        name: 'Supplier Rating Conditional Sums (Lookup)',\n        type: FieldType.ConditionalRollup,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingConditionalRollup.id,\n          filter: positiveConditionalRollupFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(derivedBaseId, host.id);\n      await permanentDeleteTable(derivedBaseId, products.id);\n      await permanentDeleteTable(derivedBaseId, suppliers.id);\n      await deleteBase(derivedBaseId);\n    });\n\n    describe('standard lookup source', () => {\n      it('returns lookup values from lookup fields', async () => {\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        expect(hostRecord.fields[ratingValuesLookupField.id]).toEqual([5, 4, 4]);\n      });\n    });\n\n    describe('formula source', () => {\n      it('projects formula results from foreign fields', async () => {\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        expect(hostRecord.fields[ratingFormulaLookupField.id]).toEqual([10, 8, 8]);\n      });\n    });\n\n    describe('link source', () => {\n      it('includes link metadata for targeted link fields', async () => {\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        const linkValues = hostRecord.fields[supplierLinkLookupField.id] as Array<{\n          id: string;\n          title: string;\n        }>;\n        expect(Array.isArray(linkValues)).toBe(true);\n        expect(linkValues).toHaveLength(3);\n        const supplierIds = linkValues.map((link) => link.id).sort();\n        expect(supplierIds).toEqual(\n          [suppliers.records[0].id, suppliers.records[1].id, suppliers.records[1].id].sort()\n        );\n        linkValues.forEach((link) => {\n          expect(typeof link.title).toBe('string');\n          expect(link.title.length).toBeGreaterThan(0);\n        });\n      });\n    });\n\n    describe('conditional lookup source', () => {\n      it('retrieves filtered values and mirrors formatting', async () => {\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        expect(hostRecord.fields[conditionalLookupMirrorField.id]).toEqual([5, 4]);\n        const lookupValues = hostRecord.fields[conditionalLookupMirrorField.id] as unknown[];\n        expect(lookupValues.every((value) => typeof value === 'number')).toBe(true);\n\n        const hostFieldDetail = await getField(host.id, conditionalLookupMirrorField.id);\n        const foreignFieldDetail = await getField(products.id, supplierRatingConditionalLookup.id);\n        expect(hostFieldDetail.options).toEqual(foreignFieldDetail.options);\n      });\n    });\n\n    describe('conditional rollup source', () => {\n      it('collects aggregates from conditional rollup fields', async () => {\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        expect(hostRecord.fields[conditionalRollupMirrorField.id]).toEqual([5, 4]);\n      });\n    });\n\n    it('should refresh conditional rollup mirrors when source aggregates gain new matches', async () => {\n      const baselineHost = await getRecord(host.id, host.records[0].id);\n      const baselineRollupValues = [\n        ...((baselineHost.fields[conditionalRollupMirrorField.id] as number[]) || []),\n      ];\n      const baselineLookupValues = [\n        ...((baselineHost.fields[conditionalLookupMirrorField.id] as number[]) || []),\n      ];\n      expect(baselineRollupValues).toEqual([5, 4]);\n      expect(baselineLookupValues).toEqual([5, 4]);\n\n      const baselineProduct = await getRecord(products.id, subscriptionProductId);\n      const baselineRollup = baselineProduct.fields[supplierRatingConditionalRollup.id] as\n        | number\n        | null\n        | undefined;\n      expect(baselineRollup ?? 0).toBe(0);\n\n      await updateRecordByApi(suppliers.id, supplierBRecordId, supplierRatingId, 5);\n\n      const afterBoostHost = await getRecord(host.id, host.records[0].id);\n      const rollupValues =\n        (afterBoostHost.fields[conditionalRollupMirrorField.id] as number[]) || [];\n      const lookupValues =\n        (afterBoostHost.fields[conditionalLookupMirrorField.id] as number[]) || [];\n      const baselineFiveRollupCount = baselineRollupValues.filter((value) => value === 5).length;\n      const baselineFiveLookupCount = baselineLookupValues.filter((value) => value === 5).length;\n      expect(rollupValues.filter((value) => value === 5).length).toBeGreaterThan(\n        baselineFiveRollupCount\n      );\n      expect(lookupValues.filter((value) => value === 5).length).toBeGreaterThan(\n        baselineFiveLookupCount\n      );\n\n      const subscriptionAfterBoost = await getRecord(products.id, subscriptionProductId);\n      expect(subscriptionAfterBoost.fields[supplierRatingConditionalRollup.id]).toEqual(5);\n\n      await updateRecordByApi(suppliers.id, supplierBRecordId, supplierRatingId, 4);\n\n      const restoredHost = await getRecord(host.id, host.records[0].id);\n      const restoredRollupValues =\n        (restoredHost.fields[conditionalRollupMirrorField.id] as number[]) || [];\n      const restoredLookupValues =\n        (restoredHost.fields[conditionalLookupMirrorField.id] as number[]) || [];\n      expect(restoredRollupValues.filter((value) => value > 0)).toEqual(\n        baselineRollupValues.filter((value) => value > 0)\n      );\n      expect(restoredLookupValues.filter((value) => value > 0)).toEqual(\n        baselineLookupValues.filter((value) => value > 0)\n      );\n\n      const subscriptionRestored = await getRecord(products.id, subscriptionProductId);\n      const restoredRollup = subscriptionRestored.fields[supplierRatingConditionalRollup.id] as\n        | number\n        | null\n        | undefined;\n      expect(restoredRollup ?? 0).toBe(baselineRollup ?? 0);\n    });\n\n    it('marks lookup dependencies as errored when source fields are removed', async () => {\n      await deleteField(products.id, supplierRatingLookup.id);\n      const afterLookupDelete = await getFields(host.id);\n      expect(afterLookupDelete.find((f) => f.id === ratingValuesLookupField.id)?.hasError).toBe(\n        true\n      );\n    });\n  });\n\n  describe('conditional lookup across bases', () => {\n    let foreignBaseId: string;\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let crossBaseLookupField: IFieldVo;\n    let foreignCategoryId: string;\n    let foreignAmountId: string;\n    let hostCategoryId: string;\n    let hardwareRecordId: string;\n    let softwareRecordId: string;\n\n    beforeAll(async () => {\n      const spaceId = globalThis.testConfig.spaceId;\n      const createdBase = await createBase({ spaceId, name: 'Conditional Lookup Cross Base' });\n      foreignBaseId = createdBase.id;\n\n      foreign = await createTable(foreignBaseId, {\n        name: 'ConditionalLookup_CrossBase_Foreign',\n        fields: [\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Category: 'Hardware', Amount: 100 } },\n          { fields: { Category: 'Hardware', Amount: 50 } },\n          { fields: { Category: 'Software', Amount: 70 } },\n        ],\n      });\n      foreignCategoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      foreignAmountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_CrossBase_Host',\n        fields: [{ name: 'CategoryMatch', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { CategoryMatch: 'Hardware' } },\n          { fields: { CategoryMatch: 'Software' } },\n        ],\n      });\n      hostCategoryId = host.fields.find((f) => f.name === 'CategoryMatch')!.id;\n      hardwareRecordId = host.records[0].id;\n      softwareRecordId = host.records[1].id;\n\n      const categoryFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignCategoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostCategoryId },\n          },\n        ],\n      };\n\n      crossBaseLookupField = await createField(host.id, {\n        name: 'Cross Base Amounts',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          baseId: foreignBaseId,\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignAmountId,\n          filter: categoryFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(foreignBaseId, foreign.id);\n      await deleteBase(foreignBaseId);\n    });\n\n    it('aggregates values when referencing a foreign base', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n\n      expect(hardwareRecord.fields[crossBaseLookupField.id]).toEqual([100, 50]);\n      expect(softwareRecord.fields[crossBaseLookupField.id]).toEqual([70]);\n    });\n  });\n\n  describe('sort dependency edge cases', () => {\n    it('updates results when the sort field is converted through the API', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalLookup_SortConvert_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'RawScore', type: FieldType.Number } as IFieldRo,\n            { name: 'Bonus', type: FieldType.Number } as IFieldRo,\n            { name: 'EffectiveScore', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            {\n              fields: {\n                Title: 'Alpha',\n                Status: 'Active',\n                RawScore: 70,\n                Bonus: 0,\n                EffectiveScore: 70,\n              },\n            },\n            {\n              fields: {\n                Title: 'Beta',\n                Status: 'Active',\n                RawScore: 90,\n                Bonus: -60,\n                EffectiveScore: 90,\n              },\n            },\n            {\n              fields: {\n                Title: 'Gamma',\n                Status: 'Active',\n                RawScore: 40,\n                Bonus: 0,\n                EffectiveScore: 40,\n              },\n            },\n          ],\n        });\n\n        const titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const rawScoreId = foreign.fields.find((field) => field.name === 'RawScore')!.id;\n        const bonusId = foreign.fields.find((field) => field.name === 'Bonus')!.id;\n        const effectiveScoreId = foreign.fields.find(\n          (field) => field.name === 'EffectiveScore'\n        )!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalLookup_SortConvert_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n        const activeRecordId = host.records[0].id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Converted Sort Lookup',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            filter: statusMatchFilter,\n            sort: { fieldId: effectiveScoreId, order: SortFunc.Desc },\n            limit: 2,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const baseline = await getRecord(host.id, activeRecordId);\n        expect(baseline.fields[lookupField.id]).toEqual(['Beta', 'Alpha']);\n\n        await convertField(foreign.id, effectiveScoreId, {\n          name: 'EffectiveScore',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${rawScoreId}} + {${bonusId}}`,\n          },\n        } as IFieldRo);\n\n        const refreshed = await getRecord(host.id, activeRecordId);\n        expect(refreshed.fields[lookupField.id]).toEqual(['Alpha', 'Gamma']);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n\n    it('keeps only the limit after the sort field is deleted', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalLookup_DeleteSort_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'EffectiveScore', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Alpha', Status: 'Active', EffectiveScore: 70 } },\n            { fields: { Title: 'Beta', Status: 'Active', EffectiveScore: 90 } },\n            { fields: { Title: 'Gamma', Status: 'Active', EffectiveScore: 40 } },\n            { fields: { Title: 'Delta', Status: 'Closed', EffectiveScore: 100 } },\n          ],\n        });\n\n        const titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const effectiveScoreId = foreign.fields.find(\n          (field) => field.name === 'EffectiveScore'\n        )!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalLookup_DeleteSort_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n        const activeRecordId = host.records[0].id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Limit Without Sort Lookup',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            filter: statusMatchFilter,\n            sort: { fieldId: effectiveScoreId, order: SortFunc.Desc },\n            limit: 2,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const baseline = await getRecord(host.id, activeRecordId);\n        expect(baseline.fields[lookupField.id]).toEqual(['Beta', 'Alpha']);\n\n        await deleteField(foreign.id, effectiveScoreId);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Closed');\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n\n        const refreshedRecord = await getRecord(host.id, activeRecordId);\n        const refreshedValue = refreshedRecord.fields[lookupField.id] as\n          | string[]\n          | null\n          | undefined;\n        if (Array.isArray(refreshedValue)) {\n          expect(refreshedValue.length).toBeLessThanOrEqual(2);\n          expect(refreshedValue).not.toContain('Delta');\n        } else {\n          expect(refreshedValue == null).toBe(true);\n        }\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n  });\n\n  describe('conditional rollup filters referencing host titles', () => {\n    let tableA: ITableFullVo;\n    let tableB: ITableFullVo;\n    let tableATitleFieldId: string;\n    let tableBTitleFieldId: string;\n    let tableAFirstAlphaRecordId: string;\n    let tableABetaRecordId: string;\n    let tableASecondAlphaRecordId: string;\n    let tableBAlphaRecordId: string;\n    let tableBGammaRecordId: string;\n    let tableBConditionalRollupField: IFieldVo;\n    let tableASelfConditionalRollupField: IFieldVo;\n\n    beforeAll(async () => {\n      tableA = await createTable(baseId, {\n        name: 'ConditionalLookup_TitleMatch_Primary',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Title: 'Alpha' } },\n          { fields: { Title: 'Beta' } },\n          { fields: { Title: 'Alpha' } },\n        ],\n      });\n      tableATitleFieldId = tableA.fields.find((field) => field.name === 'Title')!.id;\n      tableAFirstAlphaRecordId = tableA.records[0].id;\n      tableABetaRecordId = tableA.records[1].id;\n      tableASecondAlphaRecordId = tableA.records[2].id;\n\n      tableB = await createTable(baseId, {\n        name: 'ConditionalLookup_TitleMatch_Secondary',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Alpha' } }, { fields: { Title: 'Gamma' } }],\n      });\n      tableBTitleFieldId = tableB.fields.find((field) => field.name === 'Title')!.id;\n      tableBAlphaRecordId = tableB.records[0].id;\n      tableBGammaRecordId = tableB.records[1].id;\n\n      const matchPrimaryTitleFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: tableATitleFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: tableBTitleFieldId },\n          },\n        ],\n      };\n\n      tableBConditionalRollupField = await createField(tableB.id, {\n        name: 'Matching Primary Titles',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: tableA.id,\n          lookupFieldId: tableATitleFieldId,\n          expression: 'count({values})',\n          filter: matchPrimaryTitleFilter,\n        },\n      } as IFieldRo);\n\n      const selfTitleFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: tableATitleFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: tableATitleFieldId },\n          },\n        ],\n      };\n\n      tableASelfConditionalRollupField = await createField(tableA.id, {\n        name: 'Self Title Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: tableA.id,\n          lookupFieldId: tableATitleFieldId,\n          expression: 'count({values})',\n          filter: selfTitleFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, tableB.id);\n      await permanentDeleteTable(baseId, tableA.id);\n    });\n\n    it('aggregates foreign matches when filter ties titles to host fields', async () => {\n      const tableBRecords = await getRecords(tableB.id, { fieldKeyType: FieldKeyType.Id });\n      const alphaRecord = tableBRecords.records.find(\n        (record) => record.id === tableBAlphaRecordId\n      )!;\n      const gammaRecord = tableBRecords.records.find(\n        (record) => record.id === tableBGammaRecordId\n      )!;\n\n      expect(alphaRecord.fields[tableBConditionalRollupField.id]).toEqual(2);\n      expect(gammaRecord.fields[tableBConditionalRollupField.id]).toEqual(0);\n    });\n\n    it('aggregates self-table matches when foreign scope equals host table', async () => {\n      const tableARecords = await getRecords(tableA.id, { fieldKeyType: FieldKeyType.Id });\n      const firstAlpha = tableARecords.records.find(\n        (record) => record.id === tableAFirstAlphaRecordId\n      )!;\n      const betaRecord = tableARecords.records.find((record) => record.id === tableABetaRecordId)!;\n      const secondAlpha = tableARecords.records.find(\n        (record) => record.id === tableASecondAlphaRecordId\n      )!;\n\n      expect(firstAlpha.fields[tableASelfConditionalRollupField.id]).toEqual(2);\n      expect(secondAlpha.fields[tableASelfConditionalRollupField.id]).toEqual(2);\n      expect(betaRecord.fields[tableASelfConditionalRollupField.id]).toEqual(1);\n    });\n  });\n\n  describe('circular dependency detection', () => {\n    it('rejects converting a conditional lookup that would introduce a cycle', async () => {\n      let alpha: ITableFullVo | undefined;\n      let beta: ITableFullVo | undefined;\n      let betaLookup: IFieldVo | undefined;\n      let alphaRollup: IFieldVo | undefined;\n\n      try {\n        alpha = await createTable(baseId, {\n          name: 'ConditionalLookup_Cycle_Alpha',\n          fields: [\n            { name: 'Alpha Key', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Alpha Value', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { 'Alpha Key': 'A', 'Alpha Value': 10 } },\n            { fields: { 'Alpha Key': 'B', 'Alpha Value': 20 } },\n          ],\n        });\n        const alphaKeyId = alpha.fields.find((field) => field.name === 'Alpha Key')!.id;\n        const alphaValueId = alpha.fields.find((field) => field.name === 'Alpha Value')!.id;\n\n        beta = await createTable(baseId, {\n          name: 'ConditionalLookup_Cycle_Beta',\n          fields: [\n            { name: 'Beta Key', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Beta Quantity', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { 'Beta Key': 'A', 'Beta Quantity': 1 } },\n            { fields: { 'Beta Key': 'B', 'Beta Quantity': 2 } },\n          ],\n        });\n        const betaKeyId = beta.fields.find((field) => field.name === 'Beta Key')!.id;\n\n        const matchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: alphaKeyId,\n              operator: 'is',\n              value: { type: 'field', fieldId: betaKeyId },\n            },\n          ],\n        };\n\n        betaLookup = await createField(beta.id, {\n          name: 'Alpha Values Lookup',\n          type: FieldType.Number,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: alpha.id,\n            lookupFieldId: alphaValueId,\n            filter: matchFilter,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const rollupFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: betaKeyId,\n              operator: 'is',\n              value: { type: 'field', fieldId: alphaKeyId },\n            },\n          ],\n        };\n\n        alphaRollup = await createField(alpha.id, {\n          name: 'Beta Lookup Count',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: beta.id,\n            lookupFieldId: betaLookup.id,\n            expression: 'count({values})',\n            filter: rollupFilter,\n          },\n        } as IFieldRo);\n\n        await convertField(\n          beta.id,\n          betaLookup.id,\n          {\n            name: 'Alpha Values Lookup',\n            type: FieldType.ConditionalRollup,\n            isLookup: true,\n            isConditionalLookup: true,\n            lookupOptions: {\n              foreignTableId: alpha.id,\n              lookupFieldId: alphaRollup.id,\n              filter: matchFilter,\n            } as ILookupOptionsRo,\n          } as IFieldRo,\n          400\n        );\n\n        const lookupAfterFailure = await getField(beta.id, betaLookup.id);\n        expect((lookupAfterFailure.lookupOptions as ILookupOptionsRo).lookupFieldId).toBe(\n          alphaValueId\n        );\n      } finally {\n        if (beta) {\n          await permanentDeleteTable(baseId, beta.id);\n        }\n        if (alpha) {\n          await permanentDeleteTable(baseId, alpha.id);\n        }\n      }\n    });\n  });\n\n  describe('user field filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let titleId: string;\n    let foreignOwnerId: string;\n    let hostOwnerId: string;\n    let assignedRecordId: string;\n    let emptyRecordId: string;\n\n    beforeAll(async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_User_Foreign',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Owner', type: FieldType.User } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Task Alpha', Owner: userCell } },\n          { fields: { Task: 'Task Beta' } },\n          { fields: { Task: 'Task Gamma', Owner: userCell } },\n        ],\n      });\n\n      titleId = foreign.fields.find((field) => field.name === 'Task')!.id;\n      foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_User_Host',\n        fields: [{ name: 'Assigned', type: FieldType.User } as IFieldRo],\n        records: [{ fields: { Assigned: userCell } }, { fields: {} }],\n      });\n\n      hostOwnerId = host.fields.find((field) => field.name === 'Assigned')!.id;\n      assignedRecordId = host.records[0].id;\n      emptyRecordId = host.records[1].id;\n\n      const ownerMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignOwnerId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostOwnerId },\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Owned Tasks',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: ownerMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should create conditional lookup filtered by matching users', async () => {\n      expect(lookupField.id).toBeDefined();\n\n      const assignedRecord = await getRecord(host.id, assignedRecordId);\n      const ownedTasks = [...((assignedRecord.fields[lookupField.id] as string[]) ?? [])].sort();\n      expect(ownedTasks).toEqual(['Task Alpha', 'Task Gamma']);\n\n      const emptyRecord = await getRecord(host.id, emptyRecordId);\n      expect((emptyRecord.fields[lookupField.id] as string[] | undefined) ?? []).toEqual([]);\n    });\n  });\n\n  describe('user field filters with multi host field', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let titleId: string;\n    let foreignOwnerId: string;\n    let hostAssigneesId: string;\n    let assignedRecordId: string;\n    let emptyRecordId: string;\n\n    beforeAll(async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_User_Foreign_MultiHost',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Owner', type: FieldType.User } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Task Alpha', Owner: userCell } },\n          { fields: { Task: 'Task Beta', Owner: userCell } },\n          { fields: { Task: 'Task Gamma' } },\n        ],\n      });\n\n      titleId = foreign.fields.find((field) => field.name === 'Task')!.id;\n      foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_User_Host_Multi',\n        fields: [\n          {\n            name: 'Assignees',\n            type: FieldType.User,\n            options: { isMultiple: true },\n          } as IFieldRo,\n        ],\n        records: [{ fields: { Assignees: [userCell] } }, { fields: { Assignees: null } }],\n      });\n\n      hostAssigneesId = host.fields.find((field) => field.name === 'Assignees')!.id;\n      assignedRecordId = host.records[0].id;\n      emptyRecordId = host.records[1].id;\n\n      const ownerMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignOwnerId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostAssigneesId },\n          },\n        ],\n      };\n\n      lookupField = await createField(host.id, {\n        name: 'Owned Tasks',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: ownerMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should match single user against multi user reference', async () => {\n      expect(lookupField.id).toBeDefined();\n\n      const assignedRecord = await getRecord(host.id, assignedRecordId);\n      const ownedTasks = [...((assignedRecord.fields[lookupField.id] as string[]) ?? [])].sort();\n      expect(ownedTasks).toEqual(['Task Alpha', 'Task Beta']);\n\n      const emptyRecord = await getRecord(host.id, emptyRecordId);\n      expect((emptyRecord.fields[lookupField.id] as string[] | undefined) ?? []).toEqual([]);\n    });\n  });\n\n  describe('field reference compatibility validation', () => {\n    it('marks lookup field as errored when reference field type changes', async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Compatibility_Foreign',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Owner', type: FieldType.User } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Task Alpha', Owner: userCell } },\n          { fields: { Task: 'Task Beta' } },\n        ],\n      });\n      const foreignTaskId = foreign.fields.find((field) => field.name === 'Task')!.id;\n      const foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalLookup_Compatibility_Host',\n        fields: [{ name: 'Assigned', type: FieldType.User } as IFieldRo],\n        records: [{ fields: { Assigned: userCell } }],\n      });\n      const hostOwnerId = host.fields.find((field) => field.name === 'Assigned')!.id;\n\n      try {\n        const ownerMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: foreignOwnerId,\n              operator: 'is',\n              value: { type: 'field', fieldId: hostOwnerId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Owned Tasks',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: foreignTaskId,\n            filter: ownerMatchFilter,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const initialLookup = await getField(host.id, lookupField.id);\n        expect(initialLookup.hasError).toBeFalsy();\n\n        await convertField(host.id, hostOwnerId, {\n          name: 'Assigned',\n          type: FieldType.SingleLineText,\n          options: {},\n        } as IFieldRo);\n\n        const erroredLookup = await getField(host.id, lookupField.id);\n        expect(erroredLookup.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('marks lookup field as errored when foreign field type changes', async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Compatibility_ForeignKey',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Owner', type: FieldType.User } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Task Alpha', Owner: userCell } },\n          { fields: { Task: 'Task Beta', Owner: userCell } },\n        ],\n      });\n      const foreignTaskId = foreign.fields.find((field) => field.name === 'Task')!.id;\n      const foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalLookup_Compatibility_HostKey',\n        fields: [{ name: 'Assigned', type: FieldType.User } as IFieldRo],\n        records: [{ fields: { Assigned: userCell } }],\n      });\n      const hostOwnerId = host.fields.find((field) => field.name === 'Assigned')!.id;\n\n      try {\n        const ownerMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: foreignOwnerId,\n              operator: 'is',\n              value: { type: 'field', fieldId: hostOwnerId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Owned Tasks',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: foreignTaskId,\n            filter: ownerMatchFilter,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const initialLookup = await getField(host.id, lookupField.id);\n        expect(initialLookup.hasError).toBeFalsy();\n\n        await convertField(foreign.id, foreignOwnerId, {\n          name: 'Owner',\n          type: FieldType.SingleLineText,\n          options: {},\n        } as IFieldRo);\n\n        const erroredLookup = await getField(host.id, lookupField.id);\n        expect(erroredLookup.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n  });\n\n  describe('numeric array field reference filters', () => {\n    let games: ITableFullVo;\n    let summary: ITableFullVo;\n    let gamesLinkFieldId: string;\n    let thresholdFieldId: string;\n    let ceilingFieldId: string;\n    let targetFieldId: string;\n    let exactFieldId: string;\n    let excludeFieldId: string;\n    let aliceSummaryId: string;\n    let bobSummaryId: string;\n    let scoresAboveThresholdField: IFieldVo;\n    let scoresWithinCeilingField: IFieldVo;\n    let scoresEqualTargetField: IFieldVo;\n    let scoresNotExactField: IFieldVo;\n    let scoresWithoutExcludedField: IFieldVo;\n\n    beforeAll(async () => {\n      games = await createTable(baseId, {\n        name: 'ConditionalLookup_NumberArray_Games',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Player: 'Alice', Score: 10 } },\n          { fields: { Player: 'Alice', Score: 12 } },\n          { fields: { Player: 'Bob', Score: 7 } },\n        ],\n      });\n      const scoreFieldId = games.fields.find((f) => f.name === 'Score')!.id;\n\n      const gamePlayerFieldId = games.fields.find((f) => f.name === 'Player')!.id;\n\n      summary = await createTable(baseId, {\n        name: 'ConditionalLookup_NumberArray_Summary',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Games',\n            type: FieldType.Link,\n            options: {\n              foreignTableId: games.id,\n              relationship: Relationship.ManyMany,\n            },\n          } as IFieldRo,\n          { name: 'Threshold', type: FieldType.Number } as IFieldRo,\n          { name: 'Ceiling', type: FieldType.Number } as IFieldRo,\n          { name: 'Target', type: FieldType.Number } as IFieldRo,\n          { name: 'Exact', type: FieldType.Number } as IFieldRo,\n          { name: 'Exclude', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Player: 'Alice',\n              Games: [{ id: games.records[0].id }, { id: games.records[1].id }],\n              Threshold: 11,\n              Ceiling: 12,\n              Target: 12,\n              Exact: 12,\n              Exclude: 10,\n            },\n          },\n          {\n            fields: {\n              Player: 'Bob',\n              Games: [{ id: games.records[2].id }],\n              Threshold: 8,\n              Ceiling: 8,\n              Target: 9,\n              Exact: 7,\n              Exclude: 5,\n            },\n          },\n        ],\n      });\n\n      gamesLinkFieldId = summary.fields.find((f) => f.name === 'Games')!.id;\n      const summaryPlayerFieldId = summary.fields.find((f) => f.name === 'Player')!.id;\n      await createField(summary.id, {\n        name: 'Round Scores',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          linkFieldId: gamesLinkFieldId,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n      thresholdFieldId = summary.fields.find((f) => f.name === 'Threshold')!.id;\n      ceilingFieldId = summary.fields.find((f) => f.name === 'Ceiling')!.id;\n      targetFieldId = summary.fields.find((f) => f.name === 'Target')!.id;\n      exactFieldId = summary.fields.find((f) => f.name === 'Exact')!.id;\n      excludeFieldId = summary.fields.find((f) => f.name === 'Exclude')!.id;\n      aliceSummaryId = summary.records[0].id;\n      bobSummaryId = summary.records[1].id;\n\n      const scoresAboveThresholdFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isGreater',\n            value: { type: 'field', fieldId: thresholdFieldId },\n          },\n        ],\n      };\n      scoresAboveThresholdField = await createField(summary.id, {\n        name: 'Scores Above Threshold',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          filter: scoresAboveThresholdFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const scoresWithinCeilingFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: ceilingFieldId },\n          },\n        ],\n      };\n      scoresWithinCeilingField = await createField(summary.id, {\n        name: 'Scores Within Ceiling',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          filter: scoresWithinCeilingFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const equalTargetFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetFieldId },\n          },\n        ],\n      };\n      scoresEqualTargetField = await createField(summary.id, {\n        name: 'Scores Equal Target',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          filter: equalTargetFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const notExactFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: exactFieldId },\n          },\n        ],\n      };\n      scoresNotExactField = await createField(summary.id, {\n        name: 'Scores Not Exact',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          filter: notExactFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const withoutExcludedFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: excludeFieldId },\n          },\n        ],\n      };\n      scoresWithoutExcludedField = await createField(summary.id, {\n        name: 'Scores Without Excluded',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          filter: withoutExcludedFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, summary.id);\n      await permanentDeleteTable(baseId, games.id);\n    });\n\n    it('filters numeric lookup arrays using field references', async () => {\n      const records = await getRecords(summary.id, { fieldKeyType: FieldKeyType.Id });\n      const aliceSummary = records.records.find((record) => record.id === aliceSummaryId)!;\n      const bobSummary = records.records.find((record) => record.id === bobSummaryId)!;\n\n      expect(aliceSummary.fields[scoresAboveThresholdField.id]).toEqual([12]);\n      expect(\n        (bobSummary.fields[scoresAboveThresholdField.id] as number[] | undefined) ?? []\n      ).toEqual([]);\n\n      expect(aliceSummary.fields[scoresWithinCeilingField.id]).toEqual([10, 12]);\n      expect(bobSummary.fields[scoresWithinCeilingField.id]).toEqual([7]);\n\n      expect(aliceSummary.fields[scoresEqualTargetField.id]).toEqual([12]);\n      expect((bobSummary.fields[scoresEqualTargetField.id] as number[] | undefined) ?? []).toEqual(\n        []\n      );\n\n      expect((aliceSummary.fields[scoresNotExactField.id] as number[] | undefined) ?? []).toEqual([\n        10,\n      ]);\n      expect((bobSummary.fields[scoresNotExactField.id] as number[] | undefined) ?? []).toEqual([]);\n\n      expect(aliceSummary.fields[scoresWithoutExcludedField.id]).toEqual([12]);\n      expect(bobSummary.fields[scoresWithoutExcludedField.id]).toEqual([7]);\n    });\n  });\n\n  describe('multi-value flattening', () => {\n    it('flattens attachment conditional lookup values before persisting', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n      const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cl-attach-'));\n      const filePath = path.join(tempDir, 'conditional-lookup-attachment.txt');\n      fs.writeFileSync(filePath, 'conditional lookup attachment payload');\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalLookup_Attachment_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Files', type: FieldType.Attachment } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Alpha', Status: 'Keep' } },\n            { fields: { Title: 'Beta', Status: 'Keep' } },\n            { fields: { Title: 'Gamma', Status: 'Skip' } },\n          ],\n        });\n        const titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const filesFieldId = foreign.fields.find((field) => field.name === 'Files')!.id;\n\n        const uploadFile = async (recordId: string, filename: string) => {\n          const res = await uploadAttachment(\n            foreign!.id,\n            recordId,\n            filesFieldId,\n            fs.createReadStream(filePath),\n            { filename }\n          );\n          expect(res.status).toBe(201);\n        };\n        await uploadFile(foreign.records[0].id, 'alpha.txt');\n        await uploadFile(foreign.records[1].id, 'beta.txt');\n\n        host = await createTable(baseId, {\n          name: 'ConditionalLookup_Attachment_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Keep' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Matched Files',\n          type: FieldType.Attachment,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: filesFieldId,\n            filter: statusMatchFilter,\n            sort: { fieldId: titleId, order: SortFunc.Asc },\n          } as ILookupOptionsRo,\n          options: {},\n        } as IFieldRo);\n\n        const record = await getRecord(host.id, host.records[0].id);\n        const attachments = record.fields[lookupField.id] as IAttachmentCellValue;\n        expect(Array.isArray(attachments)).toBe(true);\n        expect(attachments).toHaveLength(2);\n        expect(attachments.some((item) => Array.isArray(item))).toBe(false);\n        expect(attachments.map((item) => item.name)).toEqual(['alpha.txt', 'beta.txt']);\n      } finally {\n        fs.rmSync(tempDir, { recursive: true, force: true });\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n\n    it('flattens multi-select conditional lookup values before persisting', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n      const tagChoices = [\n        { id: 'tag-red', name: 'Red', color: Colors.Red },\n        { id: 'tag-blue', name: 'Blue', color: Colors.Blue },\n        { id: 'tag-green', name: 'Green', color: Colors.Green },\n      ];\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalLookup_MultiSelect_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Bucket', type: FieldType.SingleLineText } as IFieldRo,\n            {\n              name: 'Tags',\n              type: FieldType.MultipleSelect,\n              options: { choices: tagChoices },\n            } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Red Row', Bucket: 'A', Tags: ['Red'] } },\n            { fields: { Title: 'Blue Row', Bucket: 'A', Tags: ['Blue'] } },\n            { fields: { Title: 'Green Row', Bucket: 'B', Tags: ['Green'] } },\n          ],\n        });\n\n        const titleFieldId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const bucketFieldId = foreign.fields.find((field) => field.name === 'Bucket')!.id;\n        const tagsFieldId = foreign.fields.find((field) => field.name === 'Tags')!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalLookup_MultiSelect_Host',\n          fields: [{ name: 'BucketFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { BucketFilter: 'A' } }],\n        });\n        const bucketFilterId = host.fields.find((field) => field.name === 'BucketFilter')!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Filtered Tags',\n          type: FieldType.MultipleSelect,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: tagsFieldId,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: bucketFieldId,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: bucketFilterId },\n                },\n              ],\n            },\n            sort: { fieldId: titleFieldId, order: SortFunc.Asc },\n          } as ILookupOptionsRo,\n          options: { choices: tagChoices },\n        } as IFieldRo);\n\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        const tags = hostRecord.fields[lookupField.id] as string[];\n        expect(Array.isArray(tags)).toBe(true);\n        expect(tags.every((tag) => typeof tag === 'string')).toBe(true);\n        expect(tags).toEqual(['Blue', 'Red']);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n  });\n\n  describe('limit enforcement', () => {\n    const limitCap = Number(process.env.CONDITIONAL_QUERY_MAX_LIMIT ?? '5000');\n    const totalActive = limitCap + 2;\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let titleId: string;\n    let statusId: string;\n    let statusFilterId: string;\n    let lookupFieldId: string;\n    const activeTitles = Array.from({ length: totalActive }, (_, idx) => `Active ${idx + 1}`);\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalLookup_Limit_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n        ],\n        records: [\n          ...activeTitles.map((title) => ({\n            fields: { Title: title, Status: 'Active' },\n          })),\n          { fields: { Title: 'Closed Item', Status: 'Closed' } },\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalLookup_Limit_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText, options: {} } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }],\n      });\n      statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('rejects creating a conditional lookup with limit beyond configured maximum', async () => {\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      await createField(\n        host.id,\n        {\n          name: 'TooManyRecords',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            filter: statusMatchFilter,\n            limit: limitCap + 1,\n          } as ILookupOptionsRo,\n        } as IFieldRo,\n        400\n      );\n    });\n\n    it('caps resolved lookup results to the maximum limit when limit is omitted', async () => {\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      const lookupField = await createField(host.id, {\n        name: 'Limited Titles',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          filter: statusMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n      lookupFieldId = lookupField.id;\n\n      const record = await getRecord(host.id, host.records[0].id);\n      const values = record.fields[lookupFieldId] as string[];\n      expect(Array.isArray(values)).toBe(true);\n      expect(values.length).toBe(limitCap);\n      expect(values).toEqual(activeTitles.slice(0, limitCap));\n      expect(values).not.toContain(activeTitles[limitCap]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFieldVo,\n  ILookupOptionsRo,\n  IConditionalRollupFieldOptions,\n  IFilter,\n  IFilterItem,\n  IUserFieldOptions,\n} from '@teable/core';\nimport {\n  CellValueType,\n  Colors,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  generateFieldId,\n  isGreater,\n  SortFunc,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { createAwaitWithEventWithResult } from './utils/event-promise';\nimport {\n  createBase,\n  createField,\n  convertField,\n  createRecords,\n  createTable,\n  deleteBase,\n  deleteField,\n  getField,\n  getFields,\n  getRecord,\n  getRecords,\n  getTable,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI Conditional Rollup field (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('expression coverage', () => {\n    const setupConditionalRollupFixtures = async () => {\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalRollupExpr_Foreign',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Flag', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Label: 'Alpha', Amount: 10, Flag: true } },\n          { fields: { Label: 'Alpha', Amount: null, Flag: true } },\n          { fields: { Label: 'Beta', Amount: 20, Flag: true } },\n        ],\n      });\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalRollupExpr_Host',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Name: 'Host Row' } }],\n      });\n\n      const linkField = await createField(host.id, {\n        name: 'Links',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: foreign.id,\n        },\n      } as IFieldRo);\n\n      const hostRecordId = host.records[0].id;\n      await updateRecordByApi(host.id, hostRecordId, linkField.id, [\n        { id: foreign.records[0].id },\n        { id: foreign.records[1].id },\n        { id: foreign.records[2].id },\n      ]);\n\n      const labelId = foreign.fields.find((field) => field.name === 'Label')!.id;\n      const amountId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n      const flagId = foreign.fields.find((field) => field.name === 'Flag')!.id;\n      return { foreign, host, linkField, hostRecordId, labelId, amountId, flagId };\n    };\n\n    const conditionalRollupCases: Array<{\n      expression: string;\n      lookupFieldKey: 'labelId' | 'amountId' | 'flagId';\n      expected: unknown;\n    }> = [\n      { expression: 'countall({values})', lookupFieldKey: 'amountId', expected: 3 },\n      { expression: 'counta({values})', lookupFieldKey: 'labelId', expected: 3 },\n      { expression: 'count({values})', lookupFieldKey: 'amountId', expected: 2 },\n      { expression: 'sum({values})', lookupFieldKey: 'amountId', expected: 30 },\n      { expression: 'average({values})', lookupFieldKey: 'amountId', expected: 15 },\n      { expression: 'max({values})', lookupFieldKey: 'amountId', expected: 20 },\n      { expression: 'min({values})', lookupFieldKey: 'amountId', expected: 10 },\n      { expression: 'and({values})', lookupFieldKey: 'flagId', expected: true },\n      { expression: 'or({values})', lookupFieldKey: 'flagId', expected: true },\n      { expression: 'xor({values})', lookupFieldKey: 'flagId', expected: true },\n      {\n        expression: 'array_join({values})',\n        lookupFieldKey: 'labelId',\n        expected: 'Alpha, Alpha, Beta',\n      },\n      {\n        expression: 'array_unique({values})',\n        lookupFieldKey: 'labelId',\n        expected: ['Alpha', 'Beta'],\n      },\n      {\n        expression: 'array_compact({values})',\n        lookupFieldKey: 'labelId',\n        expected: ['Alpha', 'Alpha', 'Beta'],\n      },\n      {\n        expression: 'concatenate({values})',\n        lookupFieldKey: 'labelId',\n        expected: 'Alpha, Alpha, Beta',\n      },\n    ];\n\n    it.each(conditionalRollupCases)(\n      'should support conditional rollup expression %s without filters',\n      async ({ expression, lookupFieldKey, expected }) => {\n        let fixtures: Awaited<ReturnType<typeof setupConditionalRollupFixtures>> | undefined;\n        try {\n          fixtures = await setupConditionalRollupFixtures();\n          const { foreign, host, hostRecordId } = fixtures;\n          const lookupFieldId = fixtures[lookupFieldKey];\n\n          const field = await createField(host.id, {\n            name: `conditional rollup ${expression}`,\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId,\n              expression,\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: fixtures.labelId,\n                    operator: 'isNotEmpty',\n                    value: null,\n                  },\n                ],\n              },\n            },\n          } as IFieldRo);\n\n          const record = await getRecord(host.id, hostRecordId);\n          const value = record.fields[field.id];\n\n          if (Array.isArray(expected)) {\n            expect(Array.isArray(value)).toBe(true);\n            const sortedExpected = [...expected].sort();\n            const sortedValue = [...(value as unknown[])].sort();\n            expect(sortedValue).toEqual(sortedExpected);\n          } else if (typeof expected === 'string') {\n            if (expected.includes(', ')) {\n              expect((value as string).split(', ').sort()).toEqual(expected.split(', ').sort());\n            } else {\n              expect(value).toEqual(expected);\n            }\n          } else {\n            expect(value).toEqual(expected);\n          }\n        } finally {\n          if (fixtures?.host) {\n            await permanentDeleteTable(baseId, fixtures.host.id);\n          }\n          if (fixtures?.foreign) {\n            await permanentDeleteTable(baseId, fixtures.foreign.id);\n          }\n        }\n      }\n    );\n  });\n\n  describe('table and field retrieval', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let orderId: string;\n    let statusId: string;\n    let statusFilterId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_View_Foreign',\n        fields: [\n          { name: 'Order', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Order: 'A-001', Status: 'Active', Amount: 10 } },\n          { fields: { Order: 'A-002', Status: 'Active', Amount: 5 } },\n          { fields: { Order: 'C-001', Status: 'Closed', Amount: 2 } },\n        ],\n      });\n      orderId = foreign.fields.find((f) => f.name === 'Order')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_View_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n      });\n      statusFilterId = host.fields.find((f) => f.name === 'StatusFilter')!.id;\n\n      const filter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      } as any;\n\n      lookupField = await createField(host.id, {\n        name: 'Matching Orders',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: orderId,\n          expression: 'count({values})',\n          filter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should expose conditional rollup via table and field endpoints', async () => {\n      const tableInfo = await getTable(baseId, host.id);\n      expect(tableInfo.id).toBe(host.id);\n\n      const fields = await getFields(host.id);\n      const retrieved = fields.find((field) => field.id === lookupField.id)!;\n      expect(retrieved.type).toBe(FieldType.ConditionalRollup);\n      expect((retrieved.options as any).lookupFieldId).toBe(orderId);\n      expect((retrieved.options as any).foreignTableId).toBe(foreign.id);\n\n      const fieldDetail = await getField(host.id, lookupField.id);\n      expect(fieldDetail.id).toBe(lookupField.id);\n      expect((fieldDetail.options as any).expression).toBe('count({values})');\n      expect(fieldDetail.isComputed).toBe(true);\n    });\n\n    it('should compute lookup values for each host record', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n\n      const first = records.records.find((record) => record.id === host.records[0].id)!;\n      const second = records.records.find((record) => record.id === host.records[1].id)!;\n\n      expect(first.fields[lookupField.id]).toEqual(2);\n      expect(second.fields[lookupField.id]).toEqual(1);\n    });\n  });\n\n  describe('limit enforcement', () => {\n    const limitCap = Number(process.env.CONDITIONAL_QUERY_MAX_LIMIT ?? '5000');\n    const totalActive = limitCap + 3;\n    const activeTitles = Array.from({ length: totalActive }, (_, idx) => `Score ${idx + 1}`);\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let titleId: string;\n    let statusId: string;\n    let scoreId: string;\n    let statusFilterId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Limit_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          ...activeTitles.map((title, idx) => ({\n            fields: { Title: title, Status: 'Active', Score: idx + 1 },\n          })),\n          { fields: { Title: 'Closed Item', Status: 'Closed', Score: 999 } },\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n      scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Limit_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }],\n      });\n      statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('rejects creating conditional rollups with limit above the configured cap', async () => {\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      await createField(\n        host.id,\n        {\n          name: 'TooManyRollupValues',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            expression: 'array_compact({values})',\n            filter: statusMatchFilter,\n            sort: { fieldId: scoreId, order: SortFunc.Asc },\n            limit: limitCap + 1,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo,\n        400\n      );\n    });\n\n    it('caps array aggregation results to the configured maximum when limit is omitted', async () => {\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      const rollupField = await createField(host.id, {\n        name: 'Limited Titles Rollup',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          expression: 'array_compact({values})',\n          filter: statusMatchFilter,\n          sort: { fieldId: scoreId, order: SortFunc.Asc },\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      const record = await getRecord(host.id, host.records[0].id);\n      const values = record.fields[rollupField.id] as string[];\n      expect(Array.isArray(values)).toBe(true);\n      expect(values.length).toBe(limitCap);\n      expect(values).toEqual(activeTitles.slice(0, limitCap));\n      expect(values).not.toContain(activeTitles[limitCap]);\n    });\n  });\n\n  describe('self equality filters', () => {\n    it('supports creating records when rollup filters compare against same-table fields', async () => {\n      let table: ITableFullVo | undefined;\n      const categoryChoices = [\n        { id: 'cat-a', name: 'Category A', color: Colors.Blue },\n        { id: 'cat-b', name: 'Category B', color: Colors.Green },\n      ];\n\n      try {\n        table = await createTable(baseId, {\n          name: 'ConditionalRollup_Self_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Count', type: FieldType.Number } as IFieldRo,\n            {\n              name: 'Category',\n              type: FieldType.SingleSelect,\n              options: { choices: categoryChoices },\n            } as IFieldRo,\n          ],\n          records: [\n            {\n              fields: {\n                Title: 'Alpha',\n                Count: 1,\n                Category: categoryChoices[0].name,\n              },\n            },\n            {\n              fields: {\n                Title: 'Beta',\n                Count: 2,\n                Category: categoryChoices[1].name,\n              },\n            },\n            {\n              fields: {\n                Title: 'Gamma',\n                Count: 3,\n                Category: categoryChoices[0].name,\n              },\n            },\n          ],\n        });\n\n        const titleFieldId = table.fields.find((field) => field.name === 'Title')!.id;\n        const countFieldId = table.fields.find((field) => field.name === 'Count')!.id;\n        const categoryFieldId = table.fields.find((field) => field.name === 'Category')!.id;\n\n        const linkField = await createField(table.id, {\n          name: 'Self Links',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: table.id,\n          },\n        } as IFieldRo);\n\n        const currentRecordIds = table.records.map((record) => record.id);\n        let currentLinkTargets = currentRecordIds.map((id) => ({ id }));\n\n        const syncAllLinks = async () => {\n          for (const recordId of currentRecordIds) {\n            await updateRecordByApi(table!.id, recordId, linkField.id, currentLinkTargets);\n          }\n        };\n\n        await syncAllLinks();\n\n        const rollupField = await createField(table.id, {\n          name: 'Self Category Count',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: table.id,\n            lookupFieldId: categoryFieldId,\n            expression: 'count({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: titleFieldId,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: titleFieldId, tableId: table.id },\n                },\n                {\n                  fieldId: countFieldId,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: countFieldId, tableId: table.id },\n                },\n              ],\n            },\n          },\n        } as IFieldRo);\n\n        const expectRollupValue = async (recordId: string, expected: number) => {\n          const record = await getRecord(table!.id, recordId);\n          expect(record.fields[rollupField.id]).toEqual(expected);\n        };\n\n        for (const recordId of currentRecordIds) {\n          await expectRollupValue(recordId, 1);\n        }\n\n        const created = await createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [titleFieldId]: 'Delta',\n                [countFieldId]: null,\n                [categoryFieldId]: categoryChoices[1].name,\n              },\n            },\n          ],\n        });\n        const newRecordId = created.records[0].id;\n        currentRecordIds.push(newRecordId);\n        currentLinkTargets = currentRecordIds.map((id) => ({ id }));\n        await syncAllLinks();\n\n        await expectRollupValue(newRecordId, 0);\n\n        await updateRecordByApi(table.id, newRecordId, countFieldId, 4);\n\n        await expectRollupValue(newRecordId, 1);\n\n        await updateRecordByApi(table.id, newRecordId, titleFieldId, 'Delta Updated');\n\n        await expectRollupValue(newRecordId, 1);\n      } finally {\n        if (table) {\n          await permanentDeleteTable(baseId, table.id);\n        }\n      }\n    });\n  });\n\n  describe('filter option synchronization', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let rollupField: IFieldVo;\n    let statusId: string;\n    let amountId: string;\n    const statusChoices = [\n      { id: 'status-active', name: 'Active', color: Colors.Green },\n      { id: 'status-closed', name: 'Closed', color: Colors.Gray },\n    ];\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Filter_Foreign',\n        fields: [\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: { choices: statusChoices },\n          } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n      });\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n      amountId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Filter_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n      });\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n        ],\n      };\n\n      rollupField = await createField(host.id, {\n        name: 'Active Amount Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n          filter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should update conditional rollup filters when select option names change', async () => {\n      await convertField(foreign.id, statusId, {\n        name: 'Status',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ ...statusChoices[0], name: 'Active Plus' }, statusChoices[1]],\n        },\n      } as IFieldRo);\n\n      const refreshed = await getField(host.id, rollupField.id);\n      const options = refreshed.options as IConditionalRollupFieldOptions;\n      const filterItem = options.filter?.filterSet?.[0] as IFilterItem | undefined;\n      expect(filterItem?.value).toBe('Active Plus');\n    });\n  });\n\n  describe('sort and limit options', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let rollupField: IFieldVo;\n    let titleId: string;\n    let statusId: string;\n    let scoreId: string;\n    let statusFilterId: string;\n    let activeRecordId: string;\n    let closedRecordId: string;\n    let gammaRecordId: string;\n    let statusMatchFilter: IFilter;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Sort_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active', Score: 70 } },\n          { fields: { Title: 'Beta', Status: 'Active', Score: 90 } },\n          { fields: { Title: 'Gamma', Status: 'Active', Score: 40 } },\n          { fields: { Title: 'Delta', Status: 'Closed', Score: 100 } },\n        ],\n      });\n      titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n      statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n      scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n      gammaRecordId = foreign.records.find((record) => record.fields.Title === 'Gamma')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Sort_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n      });\n      statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n      activeRecordId = host.records[0].id;\n      closedRecordId = host.records[1].id;\n\n      statusMatchFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterId },\n          },\n        ],\n      };\n\n      rollupField = await createField(host.id, {\n        name: 'Top Titles Rollup',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: titleId,\n          expression: 'array_compact({values})',\n          filter: statusMatchFilter,\n          sort: { fieldId: scoreId, order: SortFunc.Desc },\n          limit: 2,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should honor sort and limit for array rollups and react to updates', async () => {\n      const originalField = await getField(host.id, rollupField.id);\n      const originalOptions = {\n        ...(originalField.options as IConditionalRollupFieldOptions),\n      };\n      const originalName = originalField.name;\n\n      try {\n        expect(originalOptions.sort).toEqual({ fieldId: scoreId, order: SortFunc.Desc });\n        expect(originalOptions.limit).toBe(2);\n\n        const baselineRecords = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n        const baselineActive = baselineRecords.records.find(\n          (record) => record.id === activeRecordId\n        )!;\n        const baselineClosed = baselineRecords.records.find(\n          (record) => record.id === closedRecordId\n        )!;\n        expect(baselineActive.fields[rollupField.id]).toEqual(['Beta', 'Alpha']);\n        expect(baselineClosed.fields[rollupField.id]).toEqual(['Delta']);\n\n        const ascOptions: IConditionalRollupFieldOptions = {\n          ...originalOptions,\n          sort: { fieldId: scoreId, order: SortFunc.Asc },\n          limit: 1,\n        };\n\n        rollupField = await convertField(host.id, rollupField.id, {\n          name: rollupField.name,\n          type: FieldType.ConditionalRollup,\n          options: ascOptions,\n        } as IFieldRo);\n\n        let activeRecord = await getRecord(host.id, activeRecordId);\n        let closedRecord = await getRecord(host.id, closedRecordId);\n        expect(activeRecord.fields[rollupField.id]).toEqual(['Gamma']);\n        expect(closedRecord.fields[rollupField.id]).toEqual(['Delta']);\n\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 75);\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[rollupField.id]).toEqual(['Alpha']);\n\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 40);\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[rollupField.id]).toEqual(['Gamma']);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Closed');\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[rollupField.id]).toEqual(['Delta']);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n        activeRecord = await getRecord(host.id, activeRecordId);\n        expect(activeRecord.fields[rollupField.id]).toEqual(['Gamma']);\n\n        rollupField = await convertField(host.id, rollupField.id, {\n          name: rollupField.name,\n          type: FieldType.ConditionalRollup,\n          options: {\n            ...(rollupField.options as IConditionalRollupFieldOptions),\n            sort: undefined,\n            limit: undefined,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const fieldAfterDisable = await getField(host.id, rollupField.id);\n        // eslint-disable-next-line no-console\n        console.log('[test] field after disable', fieldAfterDisable.options);\n\n        const unsortedField = await getField(host.id, rollupField.id);\n        const unsortedOptions = unsortedField.options as IConditionalRollupFieldOptions;\n        expect(unsortedOptions.sort).toBeUndefined();\n        expect(unsortedOptions.limit).toBeUndefined();\n\n        const unsortedRecords = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n        const unsortedActive = unsortedRecords.records.find(\n          (record) => record.id === activeRecordId\n        )!;\n        const unsortedTitles = [...(unsortedActive.fields[rollupField.id] as string[])].sort();\n        expect(unsortedTitles).toEqual(['Alpha', 'Beta', 'Gamma']);\n\n        closedRecord = unsortedRecords.records.find((record) => record.id === closedRecordId)!;\n        expect(closedRecord.fields[rollupField.id]).toEqual(['Delta']);\n      } finally {\n        rollupField = await convertField(host.id, rollupField.id, {\n          name: originalName,\n          type: FieldType.ConditionalRollup,\n          options: originalOptions,\n        } as IFieldRo);\n        await updateRecordByApi(foreign.id, gammaRecordId, scoreId, 40);\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n      }\n    });\n  });\n\n  describe('filter scenarios', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let categorySumField: IFieldVo;\n    let categoryAverageField: IFieldVo;\n    let dynamicActiveCountField: IFieldVo;\n    let highValueActiveCountField: IFieldVo;\n    let categoryFieldId: string;\n    let minimumAmountFieldId: string;\n    let categoryId: string;\n    let amountId: string;\n    let statusId: string;\n    let hardwareRecordId: string;\n    let softwareRecordId: string;\n    let servicesRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_Filter_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Laptop', Category: 'Hardware', Amount: 70, Status: 'Active' } },\n          { fields: { Title: 'Mouse', Category: 'Hardware', Amount: 20, Status: 'Active' } },\n          { fields: { Title: 'Subscription', Category: 'Software', Amount: 40, Status: 'Trial' } },\n          { fields: { Title: 'Upgrade', Category: 'Software', Amount: 80, Status: 'Active' } },\n          { fields: { Title: 'Support', Category: 'Services', Amount: 15, Status: 'Active' } },\n        ],\n      });\n      categoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Filter_Host',\n        fields: [\n          { name: 'CategoryFilter', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'MinimumAmount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { CategoryFilter: 'Hardware', MinimumAmount: 50 } },\n          { fields: { CategoryFilter: 'Software', MinimumAmount: 30 } },\n          { fields: { CategoryFilter: 'Services', MinimumAmount: 10 } },\n        ],\n      });\n\n      categoryFieldId = host.fields.find((f) => f.name === 'CategoryFilter')!.id;\n      minimumAmountFieldId = host.fields.find((f) => f.name === 'MinimumAmount')!.id;\n      hardwareRecordId = host.records[0].id;\n      softwareRecordId = host.records[1].id;\n      servicesRecordId = host.records[2].id;\n\n      const categoryFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n        ],\n      } as any;\n\n      categorySumField = await createField(host.id, {\n        name: 'Category Total',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n          filter: categoryFilter,\n        },\n      } as IFieldRo);\n\n      categoryAverageField = await createField(host.id, {\n        name: 'Category Average',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'average({values})',\n          filter: categoryFilter,\n        },\n      } as IFieldRo);\n\n      const dynamicActiveFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: { type: 'field', fieldId: minimumAmountFieldId },\n          },\n        ],\n      } as any;\n\n      dynamicActiveCountField = await createField(host.id, {\n        name: 'Dynamic Active Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: dynamicActiveFilter,\n        },\n      } as IFieldRo);\n\n      const highValueActiveFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryFieldId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: 50,\n          },\n        ],\n      } as any;\n\n      highValueActiveCountField = await createField(host.id, {\n        name: 'High Value Active Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: highValueActiveFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should recalc lookup values when host filter field changes', async () => {\n      const baseline = await getRecord(host.id, hardwareRecordId);\n      expect(baseline.fields[categorySumField.id]).toEqual(90);\n      expect(baseline.fields[categoryAverageField.id]).toEqual(45);\n\n      await updateRecordByApi(host.id, hardwareRecordId, categoryFieldId, 'Software');\n      const updated = await getRecord(host.id, hardwareRecordId);\n      expect(updated.fields[categorySumField.id]).toEqual(120);\n      expect(updated.fields[categoryAverageField.id]).toEqual(60);\n\n      await updateRecordByApi(host.id, hardwareRecordId, categoryFieldId, 'Hardware');\n      const restored = await getRecord(host.id, hardwareRecordId);\n      expect(restored.fields[categorySumField.id]).toEqual(90);\n      expect(restored.fields[categoryAverageField.id]).toEqual(45);\n    });\n\n    it('should apply field-referenced numeric filters', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n      const servicesRecord = records.records.find((record) => record.id === servicesRecordId)!;\n\n      expect(hardwareRecord.fields[dynamicActiveCountField.id]).toEqual(1);\n      expect(softwareRecord.fields[dynamicActiveCountField.id]).toEqual(1);\n      expect(servicesRecord.fields[dynamicActiveCountField.id]).toEqual(1);\n    });\n\n    it('should support multi-condition filters with static thresholds', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n      const servicesRecord = records.records.find((record) => record.id === servicesRecordId)!;\n\n      expect(hardwareRecord.fields[highValueActiveCountField.id]).toEqual(1);\n      expect(softwareRecord.fields[highValueActiveCountField.id]).toEqual(1);\n      expect(servicesRecord.fields[highValueActiveCountField.id]).toEqual(0);\n    });\n\n    it('should filter host records by conditional rollup values', async () => {\n      const filtered = await getRecords(host.id, {\n        fieldKeyType: FieldKeyType.Id,\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: categorySumField.id,\n              operator: isGreater.value,\n              value: 100,\n            },\n          ],\n        },\n      });\n\n      expect(filtered.records.map((record) => record.id)).toEqual([softwareRecordId]);\n    });\n\n    it('should recompute when host numeric thresholds change', async () => {\n      const original = await getRecord(host.id, servicesRecordId);\n      expect(original.fields[dynamicActiveCountField.id]).toEqual(1);\n\n      await updateRecordByApi(host.id, servicesRecordId, minimumAmountFieldId, 50);\n      const raisedThreshold = await getRecord(host.id, servicesRecordId);\n      expect(raisedThreshold.fields[dynamicActiveCountField.id]).toEqual(0);\n\n      await updateRecordByApi(host.id, servicesRecordId, minimumAmountFieldId, 10);\n      const reset = await getRecord(host.id, servicesRecordId);\n      expect(reset.fields[dynamicActiveCountField.id]).toEqual(1);\n    });\n  });\n\n  describe('text filter edge cases', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let emptyLabelCountField: IFieldVo;\n    let nonEmptyLabelCountField: IFieldVo;\n    let labelCountAField: IFieldVo;\n    let alphaScoreSumField: IFieldVo;\n    let labelId: string;\n    let notesId: string;\n    let scoreId: string;\n    let hostRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Text_Foreign',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Notes', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Label: 'Alpha', Notes: 'Alpha plan', Score: 10 } },\n          { fields: { Label: '', Notes: 'Empty label entry', Score: 5 } },\n          { fields: { Notes: 'Missing label Alpha entry', Score: 7 } },\n          { fields: { Label: 'Beta', Notes: 'Beta details', Score: 12 } },\n          { fields: { Label: 'Gamma', Notes: 'General info', Score: 8 } },\n        ],\n      });\n\n      labelId = foreign.fields.find((field) => field.name === 'Label')!.id;\n      notesId = foreign.fields.find((field) => field.name === 'Notes')!.id;\n      scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Text_Host',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Name: 'Row 1' } }],\n      });\n      hostRecordId = host.records[0].id;\n\n      emptyLabelCountField = await createField(host.id, {\n        name: 'Empty Label Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'count({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: labelId,\n                operator: 'isEmpty',\n                value: null,\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n\n      nonEmptyLabelCountField = await createField(host.id, {\n        name: 'Non Empty Label Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'count({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: labelId,\n                operator: 'isNotEmpty',\n                value: null,\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n\n      labelCountAField = await createField(host.id, {\n        name: 'Label CountA',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: labelId,\n          expression: 'counta({values})',\n        },\n      } as IFieldRo);\n\n      alphaScoreSumField = await createField(host.id, {\n        name: 'Alpha Score Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'sum({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: notesId,\n                operator: 'contains',\n                value: 'Alpha',\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should treat blank strings as empty when filtering text fields', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[emptyLabelCountField.id]).toEqual(2);\n      expect(record.fields[nonEmptyLabelCountField.id]).toEqual(3);\n    });\n\n    it('should skip blank values in counta aggregations', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[labelCountAField.id]).toEqual(3);\n    });\n\n    it('should honor contains filters for text rollups', async () => {\n      const record = await getRecord(host.id, hostRecordId);\n\n      expect(record.fields[alphaScoreSumField.id]).toEqual(17);\n    });\n  });\n\n  describe('date field reference filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let dueDateId: string;\n    let amountId: string;\n    let targetDateId: string;\n    let onTargetCountField: IFieldVo;\n    let afterTargetSumField: IFieldVo;\n    let beforeTargetSumField: IFieldVo;\n    let onOrBeforeTargetCountField: IFieldVo;\n    let onOrAfterTargetCountField: IFieldVo;\n    let targetTenRecordId: string;\n    let targetElevenRecordId: string;\n    let targetThirteenRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Date_Foreign',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Due Date', type: FieldType.Date } as IFieldRo,\n          { name: 'Hours', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Spec Draft', 'Due Date': '2024-09-10', Hours: 5 } },\n          { fields: { Task: 'Review', 'Due Date': '2024-09-11', Hours: 3 } },\n          { fields: { Task: 'Finalize', 'Due Date': '2024-09-12', Hours: 7 } },\n        ],\n      });\n\n      dueDateId = foreign.fields.find((field) => field.name === 'Due Date')!.id;\n      amountId = foreign.fields.find((field) => field.name === 'Hours')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Date_Host',\n        fields: [{ name: 'Target Date', type: FieldType.Date } as IFieldRo],\n        records: [\n          { fields: { 'Target Date': '2024-09-10' } },\n          { fields: { 'Target Date': '2024-09-11' } },\n          { fields: { 'Target Date': '2024-09-13' } },\n        ],\n      });\n\n      targetDateId = host.fields.find((field) => field.name === 'Target Date')!.id;\n      targetTenRecordId = host.records[0].id;\n      targetElevenRecordId = host.records[1].id;\n      targetThirteenRecordId = host.records[2].id;\n\n      await updateRecordByApi(host.id, targetTenRecordId, targetDateId, '2024-09-10T12:34:56.000Z');\n      await updateRecordByApi(\n        host.id,\n        targetElevenRecordId,\n        targetDateId,\n        '2024-09-11T12:50:00.000Z'\n      );\n      await updateRecordByApi(\n        host.id,\n        targetThirteenRecordId,\n        targetDateId,\n        '2024-09-13T12:15:00.000Z'\n      );\n\n      const onTargetFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      } as any;\n\n      onTargetCountField = await createField(host.id, {\n        name: 'On Target Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: onTargetFilter,\n        },\n      } as IFieldRo);\n\n      const afterTargetFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isAfter',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      } as any;\n\n      afterTargetSumField = await createField(host.id, {\n        name: 'After Target Hours',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n          filter: afterTargetFilter,\n        },\n      } as IFieldRo);\n\n      const beforeTargetFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isBefore',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      } as any;\n\n      beforeTargetSumField = await createField(host.id, {\n        name: 'Before Target Hours',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n          filter: beforeTargetFilter,\n        },\n      } as IFieldRo);\n\n      const onOrBeforeFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isOnOrBefore',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      } as any;\n\n      onOrBeforeTargetCountField = await createField(host.id, {\n        name: 'On Or Before Target Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: onOrBeforeFilter,\n        },\n      } as IFieldRo);\n\n      const onOrAfterFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: dueDateId,\n            operator: 'isOnOrAfter',\n            value: { type: 'field', fieldId: targetDateId },\n          },\n        ],\n      } as any;\n\n      onOrAfterTargetCountField = await createField(host.id, {\n        name: 'On Or After Target Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: onOrAfterFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    const dateReferenceScenarios = [\n      {\n        name: 'aggregates matches when due date equals host target date',\n        field: () => onTargetCountField,\n        expected: [1, 1, 0],\n      },\n      {\n        name: 'sums hours occurring after the host target date',\n        field: () => afterTargetSumField,\n        expected: [10, 7, 0],\n      },\n      {\n        name: 'sums hours occurring before the host target date',\n        field: () => beforeTargetSumField,\n        expected: [0, 5, 15],\n      },\n      {\n        name: 'counts records on or after the host target date',\n        field: () => onOrAfterTargetCountField,\n        expected: [3, 2, 0],\n      },\n      {\n        name: 'counts records on or before the host target date',\n        field: () => onOrBeforeTargetCountField,\n        expected: [1, 2, 3],\n      },\n    ] as const;\n\n    it.each(dateReferenceScenarios)('$name', async ({ field, expected }) => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const targetTen = records.records.find((record) => record.id === targetTenRecordId)!;\n      const targetEleven = records.records.find((record) => record.id === targetElevenRecordId)!;\n      const targetThirteen = records.records.find(\n        (record) => record.id === targetThirteenRecordId\n      )!;\n\n      const aggregateField = field();\n      expect([\n        targetTen.fields[aggregateField.id],\n        targetEleven.fields[aggregateField.id],\n        targetThirteen.fields[aggregateField.id],\n      ]).toEqual(expected);\n    });\n  });\n\n  describe('boolean field reference filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let statusFieldId: string;\n    let hostFlagFieldId: string;\n    let matchCountField: IFieldVo;\n    let hostTrueRecordId: string;\n    let hostFalseRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Bool_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'IsActive', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', IsActive: true } },\n          { fields: { Title: 'Beta', IsActive: false } },\n          { fields: { Title: 'Gamma', IsActive: true } },\n        ],\n      });\n\n      statusFieldId = foreign.fields.find((field) => field.name === 'IsActive')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_Bool_Host',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'TargetActive', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Name: 'Should Match True', TargetActive: true } },\n          { fields: { Name: 'Should Match False' } },\n        ],\n      });\n\n      hostFlagFieldId = host.fields.find((field) => field.name === 'TargetActive')!.id;\n      hostTrueRecordId = host.records[0].id;\n      hostFalseRecordId = host.records[1].id;\n\n      const matchFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostFlagFieldId },\n          },\n        ],\n      } as any;\n\n      matchCountField = await createField(host.id, {\n        name: 'Matching Actives',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: statusFieldId,\n          expression: 'count({values})',\n          filter: matchFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should aggregate based on host boolean field references', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hostTrueRecord = records.records.find((record) => record.id === hostTrueRecordId)!;\n      const hostFalseRecord = records.records.find((record) => record.id === hostFalseRecordId)!;\n\n      expect(hostTrueRecord.fields[matchCountField.id]).toEqual(2);\n      expect(hostFalseRecord.fields[matchCountField.id]).toEqual(0);\n    });\n\n    it('should react to host boolean changes', async () => {\n      await updateRecordByApi(host.id, hostTrueRecordId, hostFlagFieldId, null);\n      await updateRecordByApi(host.id, hostFalseRecordId, hostFlagFieldId, true);\n\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hostTrueRecord = records.records.find((record) => record.id === hostTrueRecordId)!;\n      const hostFalseRecord = records.records.find((record) => record.id === hostFalseRecordId)!;\n\n      expect(hostTrueRecord.fields[matchCountField.id]).toEqual(0);\n      expect(hostFalseRecord.fields[matchCountField.id]).toEqual(2);\n    });\n  });\n\n  describe('field and literal comparison matrix', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let fieldDrivenCountField: IFieldVo;\n    let literalMixCountField: IFieldVo;\n    let quantityWindowSumField: IFieldVo;\n    let categoryId: string;\n    let amountId: string;\n    let quantityId: string;\n    let statusId: string;\n    let categoryPickId: string;\n    let amountFloorId: string;\n    let quantityMaxId: string;\n    let statusTargetId: string;\n    let hostHardwareActiveId: string;\n    let hostOfficeActiveId: string;\n    let hostHardwareInactiveId: string;\n    let foreignLaptopId: string;\n    let foreignMonitorId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_FieldMatrix_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Quantity', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Laptop',\n              Category: 'Hardware',\n              Amount: 80,\n              Quantity: 5,\n              Status: 'Active',\n            },\n          },\n          {\n            fields: {\n              Title: 'Monitor',\n              Category: 'Hardware',\n              Amount: 20,\n              Quantity: 2,\n              Status: 'Inactive',\n            },\n          },\n          {\n            fields: {\n              Title: 'Subscription',\n              Category: 'Office',\n              Amount: 60,\n              Quantity: 10,\n              Status: 'Active',\n            },\n          },\n          {\n            fields: {\n              Title: 'Upgrade',\n              Category: 'Office',\n              Amount: 35,\n              Quantity: 3,\n              Status: 'Active',\n            },\n          },\n        ],\n      });\n\n      categoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      quantityId = foreign.fields.find((f) => f.name === 'Quantity')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n      foreignLaptopId = foreign.records.find((record) => record.fields.Title === 'Laptop')!.id;\n      foreignMonitorId = foreign.records.find((record) => record.fields.Title === 'Monitor')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_FieldMatrix_Host',\n        fields: [\n          { name: 'CategoryPick', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'AmountFloor', type: FieldType.Number } as IFieldRo,\n          { name: 'QuantityMax', type: FieldType.Number } as IFieldRo,\n          { name: 'StatusTarget', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              CategoryPick: 'Hardware',\n              AmountFloor: 60,\n              QuantityMax: 10,\n              StatusTarget: 'Active',\n            },\n          },\n          {\n            fields: {\n              CategoryPick: 'Office',\n              AmountFloor: 30,\n              QuantityMax: 12,\n              StatusTarget: 'Active',\n            },\n          },\n          {\n            fields: {\n              CategoryPick: 'Hardware',\n              AmountFloor: 10,\n              QuantityMax: 4,\n              StatusTarget: 'Inactive',\n            },\n          },\n        ],\n      });\n\n      categoryPickId = host.fields.find((f) => f.name === 'CategoryPick')!.id;\n      amountFloorId = host.fields.find((f) => f.name === 'AmountFloor')!.id;\n      quantityMaxId = host.fields.find((f) => f.name === 'QuantityMax')!.id;\n      statusTargetId = host.fields.find((f) => f.name === 'StatusTarget')!.id;\n      hostHardwareActiveId = host.records[0].id;\n      hostOfficeActiveId = host.records[1].id;\n      hostHardwareInactiveId = host.records[2].id;\n\n      const fieldDrivenFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryPickId },\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreaterEqual',\n            value: { type: 'field', fieldId: amountFloorId },\n          },\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusTargetId },\n          },\n        ],\n      } as any;\n\n      fieldDrivenCountField = await createField(host.id, {\n        name: 'Field Driven Matches',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: fieldDrivenFilter,\n        },\n      } as IFieldRo);\n\n      const literalMixFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: 'Hardware',\n          },\n          {\n            fieldId: statusId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: statusTargetId },\n          },\n          {\n            fieldId: amountId,\n            operator: 'isGreater',\n            value: 15,\n          },\n        ],\n      } as any;\n\n      literalMixCountField = await createField(host.id, {\n        name: 'Literal Mix Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'count({values})',\n          filter: literalMixFilter,\n        },\n      } as IFieldRo);\n\n      const quantityWindowFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: categoryPickId },\n          },\n          {\n            fieldId: quantityId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: quantityMaxId },\n          },\n        ],\n      } as any;\n\n      quantityWindowSumField = await createField(host.id, {\n        name: 'Quantity Window Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: quantityId,\n          expression: 'sum({values})',\n          filter: quantityWindowFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should evaluate field-to-field comparisons across operators', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[fieldDrivenCountField.id]).toEqual(1);\n      expect(officeActive.fields[fieldDrivenCountField.id]).toEqual(2);\n      expect(hardwareInactive.fields[fieldDrivenCountField.id]).toEqual(1);\n    });\n\n    it('should mix literal and field referenced criteria', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[literalMixCountField.id]).toEqual(1);\n      expect(officeActive.fields[literalMixCountField.id]).toEqual(1);\n      expect(hardwareInactive.fields[literalMixCountField.id]).toEqual(1);\n    });\n\n    it('should support field referenced numeric windows with aggregations', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareActive = records.records.find((record) => record.id === hostHardwareActiveId)!;\n      const officeActive = records.records.find((record) => record.id === hostOfficeActiveId)!;\n      const hardwareInactive = records.records.find(\n        (record) => record.id === hostHardwareInactiveId\n      )!;\n\n      expect(hardwareActive.fields[quantityWindowSumField.id]).toEqual(7);\n      expect(officeActive.fields[quantityWindowSumField.id]).toEqual(13);\n      expect(hardwareInactive.fields[quantityWindowSumField.id]).toEqual(2);\n    });\n\n    it('should recompute when host thresholds change', async () => {\n      await updateRecordByApi(host.id, hostHardwareActiveId, amountFloorId, 90);\n      const tightened = await getRecord(host.id, hostHardwareActiveId);\n      expect(tightened.fields[fieldDrivenCountField.id]).toEqual(0);\n\n      await updateRecordByApi(host.id, hostHardwareActiveId, amountFloorId, 60);\n      const restored = await getRecord(host.id, hostHardwareActiveId);\n      expect(restored.fields[fieldDrivenCountField.id]).toEqual(1);\n    });\n\n    it('should react to foreign table updates referenced by filters', async () => {\n      await updateRecordByApi(foreign.id, foreignLaptopId, statusId, 'Inactive');\n      const afterStatusChange = await getRecord(host.id, hostHardwareActiveId);\n      expect(afterStatusChange.fields[fieldDrivenCountField.id]).toEqual(0);\n      expect(afterStatusChange.fields[literalMixCountField.id]).toEqual(2);\n\n      await updateRecordByApi(foreign.id, foreignLaptopId, statusId, 'Active');\n      const restored = await getRecord(host.id, hostHardwareActiveId);\n      expect(restored.fields[fieldDrivenCountField.id]).toEqual(1);\n      expect(restored.fields[literalMixCountField.id]).toEqual(1);\n\n      await updateRecordByApi(foreign.id, foreignMonitorId, quantityId, 4);\n      const quantityAdjusted = await getRecord(host.id, hostHardwareInactiveId);\n      expect(quantityAdjusted.fields[quantityWindowSumField.id]).toEqual(4);\n\n      await updateRecordByApi(foreign.id, foreignMonitorId, quantityId, 2);\n      const quantityRestored = await getRecord(host.id, hostHardwareInactiveId);\n      expect(quantityRestored.fields[quantityWindowSumField.id]).toEqual(2);\n    });\n  });\n\n  describe('advanced operator coverage', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let tierWindowField: IFieldVo;\n    let tagAllCountField: IFieldVo;\n    let tagNoneCountField: IFieldVo;\n    let concatNameField: IFieldVo;\n    let uniqueTierField: IFieldVo;\n    let compactRatingField: IFieldVo;\n    let currencyScoreField: IFieldVo;\n    let percentScoreField: IFieldVo;\n    let tierId: string;\n    let nameId: string;\n    let tagsId: string;\n    let ratingId: string;\n    let scoreId: string;\n    let targetTierId: string;\n    let minRatingId: string;\n    let maxScoreId: string;\n    let hostRow1Id: string;\n    let hostRow2Id: string;\n    let hostRow3Id: string;\n\n    beforeAll(async () => {\n      const tierChoices = [\n        { id: 'tier-basic', name: 'Basic', color: Colors.Blue },\n        { id: 'tier-pro', name: 'Pro', color: Colors.Green },\n        { id: 'tier-enterprise', name: 'Enterprise', color: Colors.Orange },\n      ];\n      const tagChoices = [\n        { id: 'tag-urgent', name: 'Urgent', color: Colors.Red },\n        { id: 'tag-review', name: 'Review', color: Colors.Blue },\n        { id: 'tag-backlog', name: 'Backlog', color: Colors.Purple },\n      ];\n\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_AdvancedOps_Foreign',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Tier',\n            type: FieldType.SingleSelect,\n            options: { choices: tierChoices },\n          } as IFieldRo,\n          {\n            name: 'Tags',\n            type: FieldType.MultipleSelect,\n            options: { choices: tagChoices },\n          } as IFieldRo,\n          { name: 'IsActive', type: FieldType.Checkbox } as IFieldRo,\n          {\n            name: 'Rating',\n            type: FieldType.Rating,\n            options: { icon: 'star', color: 'yellowBright', max: 5 },\n          } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Alpha',\n              Tier: 'Basic',\n              Tags: ['Urgent', 'Review'],\n              IsActive: true,\n              Rating: 4,\n              Score: 45,\n            },\n          },\n          {\n            fields: {\n              Name: 'Beta',\n              Tier: 'Pro',\n              Tags: ['Review'],\n              IsActive: false,\n              Rating: 5,\n              Score: 80,\n            },\n          },\n          {\n            fields: {\n              Name: 'Gamma',\n              Tier: 'Pro',\n              Tags: ['Urgent'],\n              IsActive: true,\n              Rating: 2,\n              Score: 30,\n            },\n          },\n          {\n            fields: {\n              Name: 'Delta',\n              Tier: 'Enterprise',\n              Tags: ['Review', 'Backlog'],\n              IsActive: true,\n              Rating: 4,\n              Score: 55,\n            },\n          },\n          {\n            fields: {\n              Name: 'Epsilon',\n              Tier: 'Pro',\n              Tags: ['Review'],\n              IsActive: true,\n              Rating: null,\n              Score: 25,\n            },\n          },\n        ],\n      });\n\n      nameId = foreign.fields.find((f) => f.name === 'Name')!.id;\n      tierId = foreign.fields.find((f) => f.name === 'Tier')!.id;\n      tagsId = foreign.fields.find((f) => f.name === 'Tags')!.id;\n      ratingId = foreign.fields.find((f) => f.name === 'Rating')!.id;\n      scoreId = foreign.fields.find((f) => f.name === 'Score')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_AdvancedOps_Host',\n        fields: [\n          {\n            name: 'TargetTier',\n            type: FieldType.SingleSelect,\n            options: { choices: tierChoices },\n          } as IFieldRo,\n          { name: 'MinRating', type: FieldType.Number } as IFieldRo,\n          { name: 'MaxScore', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              TargetTier: 'Basic',\n              MinRating: 3,\n              MaxScore: 60,\n            },\n          },\n          {\n            fields: {\n              TargetTier: 'Pro',\n              MinRating: 4,\n              MaxScore: 90,\n            },\n          },\n          {\n            fields: {\n              TargetTier: 'Enterprise',\n              MinRating: 4,\n              MaxScore: 70,\n            },\n          },\n        ],\n      });\n\n      targetTierId = host.fields.find((f) => f.name === 'TargetTier')!.id;\n      minRatingId = host.fields.find((f) => f.name === 'MinRating')!.id;\n      maxScoreId = host.fields.find((f) => f.name === 'MaxScore')!.id;\n      hostRow1Id = host.records[0].id;\n      hostRow2Id = host.records[1].id;\n      hostRow3Id = host.records[2].id;\n\n      const tierWindowFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: tierId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetTierId },\n          },\n          {\n            fieldId: tagsId,\n            operator: 'hasAllOf',\n            value: ['Review'],\n          },\n          {\n            fieldId: tagsId,\n            operator: 'hasNoneOf',\n            value: ['Backlog'],\n          },\n          {\n            fieldId: ratingId,\n            operator: 'isGreaterEqual',\n            value: { type: 'field', fieldId: minRatingId },\n          },\n          {\n            fieldId: scoreId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: maxScoreId },\n          },\n        ],\n      } as any;\n\n      tierWindowField = await createField(host.id, {\n        name: 'Tier Window Matches',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'count({values})',\n          filter: tierWindowFilter,\n        },\n      } as IFieldRo);\n\n      tagAllCountField = await createField(host.id, {\n        name: 'Tag All Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'count({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: tagsId,\n                operator: 'hasAllOf',\n                value: ['Review'],\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n\n      tagNoneCountField = await createField(host.id, {\n        name: 'Tag None Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'count({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: tagsId,\n                operator: 'hasNoneOf',\n                value: ['Backlog'],\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n\n      concatNameField = await createField(host.id, {\n        name: 'Concatenated Names',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: nameId,\n          expression: 'concatenate({values})',\n        },\n      } as IFieldRo);\n\n      uniqueTierField = await createField(host.id, {\n        name: 'Unique Tier List',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: tierId,\n          expression: 'array_unique({values})',\n        },\n      } as IFieldRo);\n\n      compactRatingField = await createField(host.id, {\n        name: 'Compact Rating Values',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: ratingId,\n          expression: 'array_compact({values})',\n        },\n      } as IFieldRo);\n\n      currencyScoreField = await createField(host.id, {\n        name: 'Currency Score Total',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'sum({values})',\n          formatting: {\n            type: NumberFormattingType.Currency,\n            precision: 1,\n            symbol: '¥',\n          },\n        },\n      } as IFieldRo);\n\n      percentScoreField = await createField(host.id, {\n        name: 'Percent Score Total',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: scoreId,\n          expression: 'sum({values})',\n          formatting: {\n            type: NumberFormattingType.Percent,\n            precision: 2,\n          },\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should evaluate combined field-referenced conditions across types', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n      const row2 = records.records.find((record) => record.id === hostRow2Id)!;\n      const row3 = records.records.find((record) => record.id === hostRow3Id)!;\n\n      expect(row1.fields[tierWindowField.id]).toEqual(1);\n      expect(row2.fields[tierWindowField.id]).toEqual(1);\n      expect(row3.fields[tierWindowField.id]).toEqual(0);\n    });\n\n    it('should support concatenate and unique aggregations', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n      const row2 = records.records.find((record) => record.id === hostRow2Id)!;\n\n      const namesRow1 = (row1.fields[concatNameField.id] as string).split(', ').sort();\n      const namesRow2 = (row2.fields[concatNameField.id] as string).split(', ').sort();\n      const expectedNames = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'].sort();\n      expect(namesRow1).toEqual(expectedNames);\n      expect(namesRow2).toEqual(expectedNames);\n\n      const uniqueTierList = [...(row1.fields[uniqueTierField.id] as string[])].sort();\n      expect(uniqueTierList).toEqual(['Basic', 'Enterprise', 'Pro']);\n      expect((row2.fields[uniqueTierField.id] as string[]).sort()).toEqual(uniqueTierList);\n    });\n\n    it('should remove null values when compacting arrays', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n\n      const compactRatings = row1.fields[compactRatingField.id] as unknown[];\n      expect(Array.isArray(compactRatings)).toBe(true);\n      expect(compactRatings).toEqual(expect.arrayContaining([4, 5, 2, 4]));\n      expect(compactRatings).toHaveLength(4);\n      expect(compactRatings).not.toContain(null);\n    });\n\n    it('should evaluate multi-select operators with field references', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const row1 = records.records.find((record) => record.id === hostRow1Id)!;\n      const row2 = records.records.find((record) => record.id === hostRow2Id)!;\n      const row3 = records.records.find((record) => record.id === hostRow3Id)!;\n\n      expect(row1.fields[tagAllCountField.id]).toEqual(4);\n      expect(row2.fields[tagAllCountField.id]).toEqual(4);\n      expect(row3.fields[tagAllCountField.id]).toEqual(4);\n\n      expect(row1.fields[tagNoneCountField.id]).toEqual(4);\n      expect(row2.fields[tagNoneCountField.id]).toEqual(4);\n      expect(row3.fields[tagNoneCountField.id]).toEqual(4);\n    });\n\n    it('should recompute results when host filters change', async () => {\n      await updateRecordByApi(host.id, hostRow1Id, maxScoreId, 40);\n      const tightened = await getRecord(host.id, hostRow1Id);\n      expect(tightened.fields[tierWindowField.id]).toEqual(0);\n\n      await updateRecordByApi(host.id, hostRow1Id, maxScoreId, 60);\n      const restored = await getRecord(host.id, hostRow1Id);\n      expect(restored.fields[tierWindowField.id]).toEqual(1);\n\n      await updateRecordByApi(host.id, hostRow2Id, minRatingId, 6);\n      const stricter = await getRecord(host.id, hostRow2Id);\n      expect(stricter.fields[tierWindowField.id]).toEqual(0);\n\n      await updateRecordByApi(host.id, hostRow2Id, minRatingId, 4);\n      const ratingRestored = await getRecord(host.id, hostRow2Id);\n      expect(ratingRestored.fields[tierWindowField.id]).toEqual(1);\n    });\n\n    it('should respond to foreign changes impacting multi-type comparisons', async () => {\n      const baseline = await getRecord(host.id, hostRow2Id);\n      expect(baseline.fields[tierWindowField.id]).toEqual(1);\n\n      await updateRecordByApi(foreign.id, foreign.records[1].id, ratingId, 3);\n      const lowered = await getRecord(host.id, hostRow2Id);\n      expect(lowered.fields[tierWindowField.id]).toEqual(0);\n\n      await updateRecordByApi(foreign.id, foreign.records[1].id, ratingId, 5);\n      const reset = await getRecord(host.id, hostRow2Id);\n      expect(reset.fields[tierWindowField.id]).toEqual(1);\n    });\n\n    it('should persist numeric formatting options', async () => {\n      const currencyFieldMeta = await getField(host.id, currencyScoreField.id);\n      expect((currencyFieldMeta.options as IConditionalRollupFieldOptions)?.formatting).toEqual({\n        type: NumberFormattingType.Currency,\n        precision: 1,\n        symbol: '¥',\n      });\n\n      const percentFieldMeta = await getField(host.id, percentScoreField.id);\n      expect((percentFieldMeta.options as IConditionalRollupFieldOptions)?.formatting).toEqual({\n        type: NumberFormattingType.Percent,\n        precision: 2,\n      });\n\n      const record = await getRecord(host.id, hostRow1Id);\n      expect(record.fields[currencyScoreField.id]).toEqual(45 + 80 + 30 + 55 + 25);\n      expect(record.fields[percentScoreField.id]).toEqual(45 + 80 + 30 + 55 + 25);\n    });\n  });\n\n  describe('conversion and dependency behaviour', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let amountId: string;\n    let statusId: string;\n    let hostRecordId: string;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_Conversion_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Amount: 2, Status: 'Active' } },\n          { fields: { Title: 'Beta', Amount: 4, Status: 'Active' } },\n          { fields: { Title: 'Gamma', Amount: 6, Status: 'Inactive' } },\n        ],\n      });\n      amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Conversion_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Label: 'Row 1' } }],\n      });\n      hostRecordId = host.records[0].id;\n\n      lookupField = await createField(host.id, {\n        name: 'Total Amount',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should recalc when expression updates via convertField', async () => {\n      const initial = await getRecord(host.id, hostRecordId);\n      expect(initial.fields[lookupField.id]).toEqual(12);\n\n      lookupField = await convertField(host.id, lookupField.id, {\n        name: lookupField.name,\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'max({values})',\n        },\n      } as IFieldRo);\n\n      const afterExpressionChange = await getRecord(host.id, hostRecordId);\n      expect(afterExpressionChange.fields[lookupField.id]).toEqual(6);\n    });\n\n    it('should preserve computed metadata when renaming conditional rollups via convertField', async () => {\n      const beforeRename = await getField(host.id, lookupField.id);\n      const originalName = beforeRename.name;\n      const fieldId = lookupField.id;\n      const baseline = (await getRecord(host.id, hostRecordId)).fields[fieldId];\n\n      try {\n        lookupField = await convertField(host.id, fieldId, {\n          name: `${originalName} Renamed`,\n          type: FieldType.ConditionalRollup,\n          options: beforeRename.options as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        expect(lookupField.name).toBe(`${originalName} Renamed`);\n        expect(lookupField.dbFieldType).toBe(beforeRename.dbFieldType);\n        expect(lookupField.isComputed).toBe(true);\n        expect(lookupField.isMultipleCellValue).toBe(beforeRename.isMultipleCellValue);\n        expect(lookupField.options).toEqual(beforeRename.options);\n\n        const recordAfter = await getRecord(host.id, hostRecordId);\n        expect(recordAfter.fields[fieldId]).toEqual(baseline);\n      } finally {\n        lookupField = await convertField(host.id, fieldId, {\n          name: originalName,\n          type: FieldType.ConditionalRollup,\n          options: beforeRename.options as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n      }\n    });\n\n    it('should retain computed metadata when renaming and updating conditional rollup formatting', async () => {\n      const beforeUpdate = await getField(host.id, lookupField.id);\n      const fieldId = lookupField.id;\n      const originalName = beforeUpdate.name;\n      const baseline = (await getRecord(host.id, hostRecordId)).fields[fieldId];\n      const originalOptions = beforeUpdate.options as IConditionalRollupFieldOptions;\n      const updatedOptions: IConditionalRollupFieldOptions = {\n        ...originalOptions,\n        formatting: {\n          type: NumberFormattingType.Currency,\n          symbol: '$',\n          precision: 0,\n        },\n      };\n\n      try {\n        lookupField = await convertField(host.id, fieldId, {\n          name: `${originalName} Renamed`,\n          type: FieldType.ConditionalRollup,\n          options: updatedOptions,\n        } as IFieldRo);\n\n        expect(lookupField.name).toBe(`${originalName} Renamed`);\n        expect(lookupField.dbFieldType).toBe(beforeUpdate.dbFieldType);\n        expect(lookupField.isComputed).toBe(true);\n        expect(lookupField.isMultipleCellValue).toBe(beforeUpdate.isMultipleCellValue);\n        expect((lookupField.options as IConditionalRollupFieldOptions)?.formatting).toEqual(\n          updatedOptions.formatting\n        );\n\n        const recordAfter = await getRecord(host.id, hostRecordId);\n        expect(recordAfter.fields[fieldId]).toEqual(baseline);\n      } finally {\n        lookupField = await convertField(host.id, fieldId, {\n          name: originalName,\n          type: FieldType.ConditionalRollup,\n          options: originalOptions,\n        } as IFieldRo);\n      }\n    });\n\n    it('should respect updated filters and foreign mutations', async () => {\n      const statusFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n        ],\n      } as any;\n\n      lookupField = await convertField(host.id, lookupField.id, {\n        name: 'Active Total Amount',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n          filter: statusFilter,\n        },\n      } as IFieldRo);\n\n      const afterFilter = await getRecord(host.id, hostRecordId);\n      expect(afterFilter.fields[lookupField.id]).toEqual(6);\n\n      await updateRecordByApi(foreign.id, foreign.records[2].id, statusId, 'Active');\n      const afterStatusChange = await getRecord(host.id, hostRecordId);\n      expect(afterStatusChange.fields[lookupField.id]).toEqual(12);\n\n      await updateRecordByApi(foreign.id, foreign.records[0].id, amountId, 7);\n      const afterAmountChange = await getRecord(host.id, hostRecordId);\n      expect(afterAmountChange.fields[lookupField.id]).toEqual(17);\n\n      await deleteField(foreign.id, statusId);\n      const hostFields = await getFields(host.id);\n      const erroredField = hostFields.find((field) => field.id === lookupField.id)!;\n      expect(erroredField.hasError).toBe(true);\n    });\n\n    it('marks conditional rollup error when aggregation becomes incompatible after foreign conversion', async () => {\n      const standaloneLookupField = await createField(host.id, {\n        name: 'Standalone Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: amountId,\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      const baseline = await getRecord(host.id, hostRecordId);\n      expect(baseline.fields[standaloneLookupField.id]).toEqual(17);\n\n      await convertField(foreign.id, amountId, {\n        name: 'Amount (Single Select)',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { name: '2', color: Colors.Blue },\n            { name: '4', color: Colors.Green },\n            { name: '6', color: Colors.Orange },\n          ],\n        },\n      } as IFieldRo);\n      let erroredField: IFieldVo | undefined;\n      for (let attempt = 0; attempt < 10; attempt++) {\n        const fieldsAfterConversion = await getFields(host.id);\n        erroredField = fieldsAfterConversion.find((field) => field.id === standaloneLookupField.id);\n        if (erroredField?.hasError) break;\n        await new Promise((resolve) => setTimeout(resolve, 200));\n      }\n      expect(erroredField?.hasError).toBe(true);\n    });\n  });\n\n  describe('datetime aggregation conversions', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let lookupField: IFieldVo;\n    let occurredOnId: string;\n    let statusId: string;\n    let hostRecordId: string;\n    let activeFilter: any;\n\n    const ACTIVE_LATEST_DATE = '2024-01-15T08:00:00.000Z';\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_Date_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'OccurredOn', type: FieldType.Date } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Alpha',\n              Status: 'Active',\n              OccurredOn: '2024-01-10T08:00:00.000Z',\n            },\n          },\n          {\n            fields: {\n              Title: 'Beta',\n              Status: 'Active',\n              OccurredOn: ACTIVE_LATEST_DATE,\n            },\n          },\n          {\n            fields: {\n              Title: 'Gamma',\n              Status: 'Closed',\n              OccurredOn: '2024-01-01T08:00:00.000Z',\n            },\n          },\n        ],\n      });\n      occurredOnId = foreign.fields.find((f) => f.name === 'OccurredOn')!.id;\n      statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Date_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Label: 'Row 1' } }],\n      });\n      hostRecordId = host.records[0].id;\n\n      activeFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusId,\n            operator: 'is',\n            value: 'Active',\n          },\n        ],\n      } as any;\n\n      lookupField = await createField(host.id, {\n        name: 'Active Event Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: occurredOnId,\n          expression: 'count({values})',\n          filter: activeFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('converts to datetime aggregation without casting errors', async () => {\n      const baseline = await getRecord(host.id, hostRecordId);\n      expect(baseline.fields[lookupField.id]).toEqual(2);\n\n      lookupField = await convertField(host.id, lookupField.id, {\n        name: 'Latest Active Event',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: occurredOnId,\n          expression: 'max({values})',\n          filter: activeFilter,\n        },\n      } as IFieldRo);\n\n      expect(lookupField.cellValueType).toBe(CellValueType.DateTime);\n      expect(lookupField.dbFieldType).toBe(DbFieldType.DateTime);\n\n      const afterConversion = await getRecord(host.id, hostRecordId);\n      expect(afterConversion.fields[lookupField.id]).toEqual(ACTIVE_LATEST_DATE);\n    });\n  });\n\n  describe('interoperability with standard lookup fields', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let consumer: ITableFullVo;\n    let foreignAmountFieldId: string;\n    let conditionalRollupField: IFieldVo;\n    let consumerLinkField: IFieldVo;\n\n    beforeAll(async () => {\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_Nested_Foreign',\n        fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo],\n        records: [\n          { fields: { Amount: 70 } },\n          { fields: { Amount: 20 } },\n          { fields: { Amount: 40 } },\n        ],\n      });\n      foreignAmountFieldId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Nested_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Label: 'Totals' } }],\n      });\n\n      conditionalRollupField = await createField(host.id, {\n        name: 'Category Amount Total',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignAmountFieldId,\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      consumer = await createTable(baseId, {\n        name: 'RefLookup_Nested_Consumer',\n        fields: [{ name: 'Owner', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Owner: 'Team A' } }],\n      });\n\n      consumerLinkField = await createField(consumer.id, {\n        name: 'LinkHost',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: host.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(consumer.id, consumer.records[0].id, consumerLinkField.id, {\n        id: host.records[0].id,\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, consumer.id);\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('allows creating a standard lookup targeting a conditional rollup field', async () => {\n      const hostRecord = await getRecord(host.id, host.records[0].id);\n      expect(hostRecord.fields[conditionalRollupField.id]).toEqual(130);\n\n      const lookupField = await createField(consumer.id, {\n        name: 'Lookup Category Total',\n        type: FieldType.ConditionalRollup,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: host.id,\n          linkFieldId: consumerLinkField.id,\n          lookupFieldId: conditionalRollupField.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const consumerRecord = await getRecord(consumer.id, consumer.records[0].id);\n      expect(consumerRecord.fields[lookupField.id]).toEqual(130);\n    });\n  });\n\n  describe('conditional rollup targeting derived fields', () => {\n    let suppliers: ITableFullVo;\n    let products: ITableFullVo;\n    let host: ITableFullVo;\n    let supplierRatingId: string;\n    let linkToSupplierField: IFieldVo;\n    let supplierRatingLookup: IFieldVo;\n    let supplierRatingRollup: IFieldVo;\n    let conditionalRollupMax: IFieldVo;\n    let referenceRollupSum: IFieldVo;\n    let referenceLinkCount: IFieldVo;\n\n    beforeAll(async () => {\n      suppliers = await createTable(baseId, {\n        name: 'RefLookup_Supplier',\n        fields: [\n          { name: 'SupplierName', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          {\n            name: 'Rating',\n            type: FieldType.Number,\n            options: {\n              formatting: {\n                type: NumberFormattingType.Decimal,\n                precision: 2,\n              },\n            },\n          } as IFieldRo,\n        ],\n        records: [\n          { fields: { SupplierName: 'Supplier A', Rating: 5 } },\n          { fields: { SupplierName: 'Supplier B', Rating: 4 } },\n        ],\n      });\n      supplierRatingId = suppliers.fields.find((f) => f.name === 'Rating')!.id;\n\n      products = await createTable(baseId, {\n        name: 'RefLookup_Product',\n        fields: [\n          { name: 'ProductName', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n        ],\n        records: [\n          { fields: { ProductName: 'Laptop', Category: 'Hardware' } },\n          { fields: { ProductName: 'Mouse', Category: 'Hardware' } },\n          { fields: { ProductName: 'Subscription', Category: 'Software' } },\n        ],\n      });\n\n      linkToSupplierField = await createField(products.id, {\n        name: 'Supplier Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: suppliers.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(products.id, products.records[0].id, linkToSupplierField.id, {\n        id: suppliers.records[0].id,\n      });\n      await updateRecordByApi(products.id, products.records[1].id, linkToSupplierField.id, {\n        id: suppliers.records[1].id,\n      });\n      await updateRecordByApi(products.id, products.records[2].id, linkToSupplierField.id, {\n        id: suppliers.records[1].id,\n      });\n\n      supplierRatingLookup = await createField(products.id, {\n        name: 'Supplier Rating Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: suppliers.id,\n          linkFieldId: linkToSupplierField.id,\n          lookupFieldId: supplierRatingId,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      supplierRatingRollup = await createField(products.id, {\n        name: 'Supplier Rating Sum',\n        type: FieldType.Rollup,\n        lookupOptions: {\n          foreignTableId: suppliers.id,\n          linkFieldId: linkToSupplierField.id,\n          lookupFieldId: supplierRatingId,\n        } as ILookupOptionsRo,\n        options: {\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Derived_Host',\n        fields: [{ name: 'Summary', type: FieldType.SingleLineText, options: {} } as IFieldRo],\n        records: [{ fields: { Summary: 'Global' } }],\n      });\n\n      conditionalRollupMax = await createField(host.id, {\n        name: 'Supplier Rating Max (Lookup)',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingLookup.id,\n          expression: 'max({values})',\n        },\n      } as IFieldRo);\n\n      referenceRollupSum = await createField(host.id, {\n        name: 'Supplier Rating Total (Rollup)',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: products.id,\n          lookupFieldId: supplierRatingRollup.id,\n          expression: 'sum({values})',\n        },\n      } as IFieldRo);\n\n      referenceLinkCount = await createField(host.id, {\n        name: 'Linked Supplier Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: products.id,\n          lookupFieldId: linkToSupplierField.id,\n          expression: 'count({values})',\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, products.id);\n      await permanentDeleteTable(baseId, suppliers.id);\n    });\n\n    it('aggregates lookup-derived conditional rollup values', async () => {\n      const hostRecord = await getRecord(host.id, host.records[0].id);\n      expect(hostRecord.fields[conditionalRollupMax.id]).toEqual(5);\n      expect(hostRecord.fields[referenceRollupSum.id]).toEqual(13);\n      expect(hostRecord.fields[referenceLinkCount.id]).toEqual(3);\n    });\n\n    it('tracks dependencies when conditional rollup targets derived fields', async () => {\n      const initialHostFields = await getFields(host.id);\n      const initialLookupMax = initialHostFields.find(\n        (f) => f.id === conditionalRollupMax.id\n      )! as IFieldVo;\n      const initialRollupSum = initialHostFields.find(\n        (f) => f.id === referenceRollupSum.id\n      )! as IFieldVo;\n      const initialLinkCount = initialHostFields.find(\n        (f) => f.id === referenceLinkCount.id\n      )! as IFieldVo;\n\n      expect(initialLookupMax.hasError).toBeFalsy();\n      expect(initialRollupSum.hasError).toBeFalsy();\n      expect(initialLinkCount.hasError).toBeFalsy();\n\n      await deleteField(products.id, supplierRatingLookup.id);\n      const afterLookupDelete = await getFields(host.id);\n      expect(afterLookupDelete.find((f) => f.id === conditionalRollupMax.id)?.hasError).toBe(true);\n\n      await deleteField(products.id, supplierRatingRollup.id);\n      const afterRollupDelete = await getFields(host.id);\n      expect(afterRollupDelete.find((f) => f.id === referenceRollupSum.id)?.hasError).toBe(true);\n\n      await deleteField(products.id, linkToSupplierField.id);\n      const afterLinkDelete = await getFields(host.id);\n      expect(afterLinkDelete.find((f) => f.id === referenceLinkCount.id)?.hasError).toBe(true);\n    });\n  });\n\n  describe('self-referencing conditional rollup propagation', () => {\n    let table: ITableFullVo;\n    let amountFieldId: string;\n    let rollupField: IFieldVo;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'ConditionalRollup_Self_Propagation',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Label: 'Alpha', Amount: 5 } },\n          { fields: { Label: 'Beta', Amount: 3 } },\n        ],\n      });\n      amountFieldId = table.fields.find((field) => field.name === 'Amount')!.id;\n\n      rollupField = await createField(table.id, {\n        name: 'Global Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: table.id,\n          lookupFieldId: amountFieldId,\n          expression: 'sum({values})',\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('converts without repeating ALL_RECORDS expansion', async () => {\n      const updated = await convertField(table.id, rollupField.id, {\n        name: rollupField.name,\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: table.id,\n          lookupFieldId: amountFieldId,\n          expression: 'max({values})',\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      expect((updated.options as IConditionalRollupFieldOptions).expression).toBe('max({values})');\n\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const values = records.records.map((record) => record.fields[rollupField.id]);\n      expect(values).toEqual([5, 5]);\n    });\n  });\n\n  describe('conditional rollup across bases', () => {\n    let foreignBaseId: string;\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let crossBaseRollup: IFieldVo;\n    let foreignCategoryId: string;\n    let foreignAmountId: string;\n    let hostCategoryId: string;\n    let hardwareRecordId: string;\n    let softwareRecordId: string;\n\n    beforeAll(async () => {\n      const spaceId = globalThis.testConfig.spaceId;\n      const createdBase = await createBase({ spaceId, name: 'Conditional Rollup Cross Base' });\n      foreignBaseId = createdBase.id;\n\n      foreign = await createTable(foreignBaseId, {\n        name: 'CrossBase_Foreign',\n        fields: [\n          { name: 'Category', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          {\n            name: 'Amount',\n            type: FieldType.Number,\n            options: {\n              formatting: {\n                type: NumberFormattingType.Decimal,\n                precision: 2,\n              },\n            },\n          } as IFieldRo,\n        ],\n        records: [\n          { fields: { Category: 'Hardware', Amount: 100 } },\n          { fields: { Category: 'Hardware', Amount: 50 } },\n          { fields: { Category: 'Software', Amount: 70 } },\n        ],\n      });\n      foreignCategoryId = foreign.fields.find((f) => f.name === 'Category')!.id;\n      foreignAmountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n\n      host = await createTable(baseId, {\n        name: 'CrossBase_Host',\n        fields: [\n          { name: 'CategoryMatch', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n        ],\n        records: [\n          { fields: { CategoryMatch: 'Hardware' } },\n          { fields: { CategoryMatch: 'Software' } },\n        ],\n      });\n      hostCategoryId = host.fields.find((f) => f.name === 'CategoryMatch')!.id;\n      hardwareRecordId = host.records[0].id;\n      softwareRecordId = host.records[1].id;\n\n      const categoryFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignCategoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostCategoryId },\n          },\n        ],\n      } as any;\n\n      crossBaseRollup = await createField(host.id, {\n        name: 'Cross Base Amount Total',\n        type: FieldType.ConditionalRollup,\n        options: {\n          baseId: foreignBaseId,\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignAmountId,\n          expression: 'sum({values})',\n          filter: categoryFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(foreignBaseId, foreign.id);\n      await deleteBase(foreignBaseId);\n    });\n\n    it('aggregates values when referencing a foreign base', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareRecordId)!;\n\n      expect(hardwareRecord.fields[crossBaseRollup.id]).toEqual(150);\n      expect(softwareRecord.fields[crossBaseRollup.id]).toEqual(70);\n    });\n  });\n\n  describe('conditional rollup aggregating formula fields', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let conditionalRollupField: IFieldVo;\n    let sumConditionalRollupField: IFieldVo;\n    let baseFieldId: string;\n    let taxFieldId: string;\n    let totalFormulaFieldId: string;\n    let categoryFieldId: string;\n    let hostCategoryFieldId: string;\n    let hardwareHostRecordId: string;\n    let softwareHostRecordId: string;\n\n    beforeAll(async () => {\n      baseFieldId = generateFieldId();\n      taxFieldId = generateFieldId();\n      totalFormulaFieldId = generateFieldId();\n\n      const baseField: IFieldRo = {\n        id: baseFieldId,\n        name: 'Base',\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      };\n      const taxField: IFieldRo = {\n        id: taxFieldId,\n        name: 'Tax',\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      };\n      foreign = await createTable(baseId, {\n        name: 'RefLookup_Formula_Foreign',\n        fields: [\n          { name: 'Category', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n          baseField,\n          taxField,\n        ],\n        records: [\n          { fields: { Category: 'Hardware', Base: 100, Tax: 10 } },\n          { fields: { Category: 'Software', Base: 50, Tax: 5 } },\n        ],\n      });\n      categoryFieldId = foreign.fields.find((f) => f.name === 'Category')!.id;\n\n      const totalFormulaField = await createField(foreign.id, {\n        id: totalFormulaFieldId,\n        name: 'Total',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${baseFieldId}} + {${taxFieldId}}`,\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      } as IFieldRo);\n      totalFormulaFieldId = totalFormulaField.id;\n      expect(totalFormulaField.cellValueType).toBe(CellValueType.Number);\n\n      host = await createTable(baseId, {\n        name: 'RefLookup_Formula_Host',\n        fields: [\n          { name: 'CategoryFilter', type: FieldType.SingleLineText, options: {} } as IFieldRo,\n        ],\n        records: [\n          { fields: { CategoryFilter: 'Hardware' } },\n          { fields: { CategoryFilter: 'Software' } },\n        ],\n      });\n      hostCategoryFieldId = host.fields.find((f) => f.name === 'CategoryFilter')!.id;\n      hardwareHostRecordId = host.records[0].id;\n      softwareHostRecordId = host.records[1].id;\n\n      const categoryMatchFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: categoryFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostCategoryFieldId },\n          },\n        ],\n      } as any;\n\n      conditionalRollupField = await createField(host.id, {\n        name: 'Total Formula Sum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: totalFormulaFieldId,\n          expression: 'array_join({values})',\n          filter: categoryMatchFilter,\n        },\n      } as IFieldRo);\n\n      sumConditionalRollupField = await createField(host.id, {\n        name: 'Total Formula Sum Value',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: totalFormulaFieldId,\n          expression: 'sum({values})',\n          filter: categoryMatchFilter,\n        },\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('aggregates formula results and reacts to updates', async () => {\n      const records = await getRecords(host.id, { fieldKeyType: FieldKeyType.Id });\n      const hardwareRecord = records.records.find((record) => record.id === hardwareHostRecordId)!;\n      const softwareRecord = records.records.find((record) => record.id === softwareHostRecordId)!;\n\n      expect(hardwareRecord.fields[conditionalRollupField.id]).toEqual('110.00');\n      expect(softwareRecord.fields[conditionalRollupField.id]).toEqual('55.00');\n      expect(hardwareRecord.fields[sumConditionalRollupField.id]).toEqual(110);\n      expect(softwareRecord.fields[sumConditionalRollupField.id]).toEqual(55);\n\n      await updateRecordByApi(foreign.id, foreign.records[0].id, baseFieldId, 120);\n\n      const updatedHardware = await getRecord(host.id, hardwareHostRecordId);\n      expect(updatedHardware.fields[conditionalRollupField.id]).toEqual('130.00');\n      expect(updatedHardware.fields[sumConditionalRollupField.id]).toEqual(130);\n\n      const updatedSoftware = await getRecord(host.id, softwareHostRecordId);\n      expect(updatedSoftware.fields[conditionalRollupField.id]).toEqual('55.00');\n      expect(updatedSoftware.fields[sumConditionalRollupField.id]).toEqual(55);\n    });\n  });\n\n  describe('sort dependency edge cases', () => {\n    it('recomputes when the sort field is converted through the API', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalRollup_SortConvert_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'RawScore', type: FieldType.Number } as IFieldRo,\n            { name: 'Bonus', type: FieldType.Number } as IFieldRo,\n            { name: 'EffectiveScore', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            {\n              fields: {\n                Title: 'Alpha',\n                Status: 'Active',\n                RawScore: 70,\n                Bonus: 0,\n                EffectiveScore: 70,\n              },\n            },\n            {\n              fields: {\n                Title: 'Beta',\n                Status: 'Active',\n                RawScore: 90,\n                Bonus: -60,\n                EffectiveScore: 90,\n              },\n            },\n            {\n              fields: {\n                Title: 'Gamma',\n                Status: 'Active',\n                RawScore: 40,\n                Bonus: 0,\n                EffectiveScore: 40,\n              },\n            },\n          ],\n        });\n\n        const titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const rawScoreId = foreign.fields.find((field) => field.name === 'RawScore')!.id;\n        const bonusId = foreign.fields.find((field) => field.name === 'Bonus')!.id;\n        const effectiveScoreId = foreign.fields.find(\n          (field) => field.name === 'EffectiveScore'\n        )!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalRollup_SortConvert_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n        const activeRecordId = host.records[0].id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        const rollupField = await createField(host.id, {\n          name: 'Converted Sort Rollup',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            expression: 'array_compact({values})',\n            filter: statusMatchFilter,\n            sort: { fieldId: effectiveScoreId, order: SortFunc.Desc },\n            limit: 1,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const baseline = await getRecord(host.id, activeRecordId);\n        expect(baseline.fields[rollupField.id]).toEqual(['Beta']);\n\n        await convertField(foreign.id, effectiveScoreId, {\n          name: 'EffectiveScore',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${rawScoreId}} + {${bonusId}}`,\n          },\n        } as IFieldRo);\n\n        const refreshed = await getRecord(host.id, activeRecordId);\n        expect(refreshed.fields[rollupField.id]).toEqual(['Alpha']);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n\n    it('drops ordering when converting an array rollup to a sum aggregation', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalRollup_SumConvert_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Score', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Alpha', Status: 'Active', Score: 70 } },\n            { fields: { Title: 'Beta', Status: 'Active', Score: 90 } },\n            { fields: { Title: 'Gamma', Status: 'Active', Score: 40 } },\n            { fields: { Title: 'Delta', Status: 'Closed', Score: 15 } },\n          ],\n        });\n\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const scoreId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalRollup_SumConvert_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n        const activeRecordId = host.records[0].id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        let rollupField = await createField(host.id, {\n          name: 'Top Scores Array',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: scoreId,\n            expression: 'array_compact({values})',\n            filter: statusMatchFilter,\n            sort: { fieldId: scoreId, order: SortFunc.Desc },\n            limit: 2,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const baseline = await getRecord(host.id, activeRecordId);\n        expect(baseline.fields[rollupField.id]).toEqual([90, 70]);\n\n        rollupField = await convertField(host.id, rollupField.id, {\n          name: 'Total Score',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: scoreId,\n            expression: 'sum({values})',\n            filter: statusMatchFilter,\n            // Simulate stale sort/limit payload coming from the client\n            sort: { fieldId: scoreId, order: SortFunc.Desc },\n            limit: 2,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const converted = await getField(host.id, rollupField.id);\n        const convertedOptions = converted.options as IConditionalRollupFieldOptions;\n        expect(convertedOptions.sort).toBeUndefined();\n        expect(convertedOptions.limit).toBeUndefined();\n        expect(converted.cellValueType).toBe(CellValueType.Number);\n\n        const updated = await getRecord(host.id, activeRecordId);\n        expect(updated.fields[rollupField.id]).toEqual(200);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n\n    it('ignores sorting after the sort field is deleted', async () => {\n      let foreign: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n\n      try {\n        foreign = await createTable(baseId, {\n          name: 'ConditionalRollup_DeleteSort_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'EffectiveScore', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Alpha', Status: 'Active', EffectiveScore: 70 } },\n            { fields: { Title: 'Beta', Status: 'Active', EffectiveScore: 90 } },\n            { fields: { Title: 'Gamma', Status: 'Active', EffectiveScore: 40 } },\n            { fields: { Title: 'Delta', Status: 'Closed', EffectiveScore: 100 } },\n          ],\n        });\n\n        const titleId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const effectiveScoreId = foreign.fields.find(\n          (field) => field.name === 'EffectiveScore'\n        )!.id;\n\n        host = await createTable(baseId, {\n          name: 'ConditionalRollup_DeleteSort_Host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }],\n        });\n        const statusFilterId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n        const activeRecordId = host.records[0].id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterId },\n            },\n          ],\n        };\n\n        const rollupField = await createField(host.id, {\n          name: 'Limit Without Sort Rollup',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleId,\n            expression: 'array_compact({values})',\n            filter: statusMatchFilter,\n            sort: { fieldId: effectiveScoreId, order: SortFunc.Desc },\n            limit: 1,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const baseline = await getRecord(host.id, activeRecordId);\n        expect(baseline.fields[rollupField.id]).toEqual(['Beta']);\n\n        await deleteField(foreign.id, effectiveScoreId);\n\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Closed');\n        await updateRecordByApi(host.id, activeRecordId, statusFilterId, 'Active');\n\n        let refreshedList: string[] | undefined;\n        for (let attempt = 0; attempt < 5; attempt++) {\n          const record = await getRecord(host.id, activeRecordId);\n          const candidate = record.fields[rollupField.id] as string[] | undefined;\n          if (Array.isArray(candidate)) {\n            refreshedList = candidate;\n            break;\n          }\n          await new Promise((resolve) => setTimeout(resolve, 50));\n        }\n        expect(Array.isArray(refreshedList)).toBe(true);\n        expect(refreshedList!.length).toBe(1);\n        expect(refreshedList![0]).not.toBe('Delta');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (foreign) {\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    });\n  });\n\n  describe('circular dependency detection', () => {\n    it('rejects converting conditional rollups into a cycle', async () => {\n      let alpha: ITableFullVo | undefined;\n      let beta: ITableFullVo | undefined;\n      let betaRollup: IFieldVo | undefined;\n      let alphaRollup: IFieldVo | undefined;\n\n      try {\n        alpha = await createTable(baseId, {\n          name: 'ConditionalRollup_Cycle_Alpha',\n          fields: [\n            { name: 'Alpha Key', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Alpha Value', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { 'Alpha Key': 'A', 'Alpha Value': 10 } },\n            { fields: { 'Alpha Key': 'B', 'Alpha Value': 20 } },\n          ],\n        });\n        const alphaKeyId = alpha.fields.find((field) => field.name === 'Alpha Key')!.id;\n        const alphaValueId = alpha.fields.find((field) => field.name === 'Alpha Value')!.id;\n\n        beta = await createTable(baseId, {\n          name: 'ConditionalRollup_Cycle_Beta',\n          fields: [\n            { name: 'Beta Key', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Beta Quantity', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { 'Beta Key': 'A', 'Beta Quantity': 1 } },\n            { fields: { 'Beta Key': 'B', 'Beta Quantity': 2 } },\n          ],\n        });\n        const betaKeyId = beta.fields.find((field) => field.name === 'Beta Key')!.id;\n\n        const matchAlphaToBeta: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: alphaKeyId,\n              operator: 'is',\n              value: { type: 'field', fieldId: betaKeyId },\n            },\n          ],\n        };\n\n        const matchBetaToAlpha: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: betaKeyId,\n              operator: 'is',\n              value: { type: 'field', fieldId: alphaKeyId },\n            },\n          ],\n        };\n\n        betaRollup = await createField(beta.id, {\n          name: 'Alpha Value Count',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: alpha.id,\n            lookupFieldId: alphaValueId,\n            expression: 'count({values})',\n            filter: matchAlphaToBeta,\n          },\n        } as IFieldRo);\n\n        alphaRollup = await createField(alpha.id, {\n          name: 'Beta Rollup Count',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: beta.id,\n            lookupFieldId: betaRollup.id,\n            expression: 'count({values})',\n            filter: matchBetaToAlpha,\n          },\n        } as IFieldRo);\n\n        await convertField(\n          beta.id,\n          betaRollup.id,\n          {\n            name: 'Alpha Value Count',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: alpha.id,\n              lookupFieldId: alphaRollup.id,\n              expression: 'count({values})',\n              filter: matchAlphaToBeta,\n            },\n          } as IFieldRo,\n          400\n        );\n\n        const rollupAfterFailure = await getField(beta.id, betaRollup.id);\n        const rollupOptions = rollupAfterFailure.options as IConditionalRollupFieldOptions;\n        expect(rollupOptions.lookupFieldId).toBe(alphaValueId);\n      } finally {\n        if (beta) {\n          await permanentDeleteTable(baseId, beta.id);\n        }\n        if (alpha) {\n          await permanentDeleteTable(baseId, alpha.id);\n        }\n      }\n    });\n  });\n\n  describe('user field filters', () => {\n    let foreign: ITableFullVo;\n    let host: ITableFullVo;\n    let rollupField: IFieldVo;\n    let hoursId: string;\n    let foreignOwnerId: string;\n    let hostOwnerId: string;\n    let assignedRecordId: string;\n    let emptyRecordId: string;\n\n    beforeAll(async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_User_Foreign',\n        fields: [\n          { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Owner', type: FieldType.User } as IFieldRo,\n          { name: 'Hours', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Task: 'Task Alpha', Owner: userCell, Hours: 3 } },\n          { fields: { Task: 'Task Beta', Owner: userCell, Hours: 2 } },\n          { fields: { Task: 'Task Gamma', Hours: 4 } },\n        ],\n      });\n\n      hoursId = foreign.fields.find((field) => field.name === 'Hours')!.id;\n      foreignOwnerId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n      host = await createTable(baseId, {\n        name: 'ConditionalRollup_User_Host',\n        fields: [{ name: 'Assigned', type: FieldType.User } as IFieldRo],\n        records: [{ fields: { Assigned: userCell } }, { fields: {} }],\n      });\n\n      hostOwnerId = host.fields.find((field) => field.name === 'Assigned')!.id;\n      assignedRecordId = host.records[0].id;\n      emptyRecordId = host.records[1].id;\n\n      const ownerMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignOwnerId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostOwnerId },\n          },\n        ],\n      };\n\n      rollupField = await createField(host.id, {\n        name: 'Assigned Hours',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: hoursId,\n          expression: 'sum({values})',\n          filter: ownerMatchFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    });\n\n    it('should create conditional rollup filtered by matching users', async () => {\n      expect(rollupField.id).toBeDefined();\n\n      const assignedRecord = await getRecord(host.id, assignedRecordId);\n      expect((assignedRecord.fields[rollupField.id] as number | null | undefined) ?? 0).toBe(5);\n\n      const emptyRecord = await getRecord(host.id, emptyRecordId);\n      expect((emptyRecord.fields[rollupField.id] as number | null | undefined) ?? 0).toBe(0);\n    });\n\n    it('should match single users against multi-user host references in conditional rollup filters', async () => {\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n      let multiHost: ITableFullVo | undefined;\n      let multiForeign: ITableFullVo | undefined;\n\n      try {\n        multiForeign = await createTable(baseId, {\n          name: 'ConditionalRollup_User_Multi_Foreign',\n          fields: [\n            { name: 'Task', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Owner', type: FieldType.User } as IFieldRo,\n            { name: 'Hours', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { Task: 'Task Alpha', Owner: userCell, Hours: 3 } },\n            { fields: { Task: 'Task Beta', Owner: userCell, Hours: 2 } },\n            { fields: { Task: 'Task Gamma', Hours: 4 } },\n          ],\n        });\n\n        const multiHoursId = multiForeign.fields.find((field) => field.name === 'Hours')!.id;\n        const multiOwnerId = multiForeign.fields.find((field) => field.name === 'Owner')!.id;\n\n        multiHost = await createTable(baseId, {\n          name: 'ConditionalRollup_User_Multi_Host',\n          fields: [\n            {\n              name: 'Assignees',\n              type: FieldType.User,\n              options: { isMultiple: true } as IUserFieldOptions,\n            } as IFieldRo,\n          ],\n          records: [{ fields: { Assignees: [userCell] } }, { fields: {} }],\n        });\n\n        const assigneesFieldId = multiHost.fields.find((field) => field.name === 'Assignees')!.id;\n\n        const ownerMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: multiOwnerId,\n              operator: 'is',\n              value: { type: 'field', fieldId: assigneesFieldId },\n            },\n          ],\n        };\n\n        const multiRollupField = await createField(multiHost.id, {\n          name: 'Assigned Hours',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: multiForeign.id,\n            lookupFieldId: multiHoursId,\n            expression: 'sum({values})',\n            filter: ownerMatchFilter,\n          } as IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const assignedRecord = await getRecord(multiHost.id, multiHost.records[0].id);\n        expect((assignedRecord.fields[multiRollupField.id] as number | null | undefined) ?? 0).toBe(\n          5\n        );\n\n        const emptyRecord = await getRecord(multiHost.id, multiHost.records[1].id);\n        expect((emptyRecord.fields[multiRollupField.id] as number | null | undefined) ?? 0).toBe(0);\n      } finally {\n        if (multiHost) {\n          await permanentDeleteTable(baseId, multiHost.id);\n        }\n        if (multiForeign) {\n          await permanentDeleteTable(baseId, multiForeign.id);\n        }\n      }\n    });\n\n    it('should delete conditional rollup filtered by matching text and user fields on the host table', async () => {\n      const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n      const { userId, userName, email } = globalThis.testConfig;\n      const userCell = { id: userId, title: userName, email };\n\n      const table = await createTable(baseId, {\n        name: 'ConditionalRollup_User_Delete',\n        fields: [\n          { name: 'Course', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Instructor', type: FieldType.User } as IFieldRo,\n        ],\n        records: [\n          { fields: { Course: 'Math', Instructor: userCell } },\n          { fields: { Course: 'Math', Instructor: userCell } },\n          { fields: { Course: 'Physics', Instructor: userCell } },\n        ],\n      });\n\n      const courseFieldId = table.fields.find((field) => field.name === 'Course')!.id;\n      const instructorFieldId = table.fields.find((field) => field.name === 'Instructor')!.id;\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: courseFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: courseFieldId },\n          },\n          {\n            fieldId: instructorFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: instructorFieldId },\n          },\n        ],\n      };\n\n      const conditionalRollup = await createField(table.id, {\n        name: 'Instructor Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: table.id,\n          lookupFieldId: instructorFieldId,\n          expression: 'countall({values})',\n          filter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      type TDeleteEventPayload = {\n        records?: unknown[];\n        fields: Array<\n          IFieldVo & {\n            columnMeta?: unknown;\n            references?: string[];\n          }\n        >;\n      };\n\n      let deleteEventPayload: TDeleteEventPayload | undefined;\n\n      try {\n        if (isForceV2) {\n          await deleteField(table.id, conditionalRollup.id);\n        } else {\n          const awaitFieldDeleteEvent = createAwaitWithEventWithResult<TDeleteEventPayload>(\n            eventEmitterService,\n            Events.OPERATION_FIELDS_DELETE\n          );\n          deleteEventPayload = await awaitFieldDeleteEvent(() =>\n            deleteField(table.id, conditionalRollup.id)\n          );\n        }\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n\n      if (!isForceV2) {\n        expect(deleteEventPayload).toBeDefined();\n        expect(deleteEventPayload?.records).toBeUndefined();\n      }\n    });\n  });\n\n  describe('field reference compatibility validation', () => {\n    it('marks rollup as errored when host reference field type changes', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Compat_Foreign',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Player: 'Alpha', Score: 10 } },\n          { fields: { Player: 'Beta', Score: 7 } },\n        ],\n      });\n      const scoreFieldId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalRollup_Compat_Host',\n        fields: [{ name: 'Threshold', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { Threshold: 8 } }],\n      });\n      const thresholdFieldId = host.fields.find((field) => field.name === 'Threshold')!.id;\n\n      try {\n        const filter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: scoreFieldId,\n              operator: isGreater.value,\n              value: { type: 'field', fieldId: thresholdFieldId },\n            },\n          ],\n        };\n\n        const rollupField = await createField(host.id, {\n          name: 'Scores Above Threshold',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: scoreFieldId,\n            expression: 'sum({values})',\n            filter,\n          } satisfies IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const initial = await getField(host.id, rollupField.id);\n        expect(initial.hasError).toBeFalsy();\n\n        await convertField(host.id, thresholdFieldId, {\n          name: 'Threshold',\n          type: FieldType.SingleLineText,\n          options: {},\n        } as IFieldRo);\n\n        const afterHostConvert = await getField(host.id, rollupField.id);\n        expect(afterHostConvert.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('marks rollup as errored when foreign filter field type changes', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'ConditionalRollup_Compat_ForeignField',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Player: 'Alpha', Score: 5 } },\n          { fields: { Player: 'Beta', Score: 15 } },\n        ],\n      });\n      const scoreFieldId = foreign.fields.find((field) => field.name === 'Score')!.id;\n\n      const host = await createTable(baseId, {\n        name: 'ConditionalRollup_Compat_HostField',\n        fields: [{ name: 'Threshold', type: FieldType.Number } as IFieldRo],\n        records: [{ fields: { Threshold: 10 } }],\n      });\n      const thresholdFieldId = host.fields.find((field) => field.name === 'Threshold')!.id;\n\n      try {\n        const filter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: scoreFieldId,\n              operator: isGreater.value,\n              value: { type: 'field', fieldId: thresholdFieldId },\n            },\n          ],\n        };\n\n        const rollupField = await createField(host.id, {\n          name: 'Filtered Scores',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: scoreFieldId,\n            expression: 'count({values})',\n            filter,\n          } satisfies IConditionalRollupFieldOptions,\n        } as IFieldRo);\n\n        const initial = await getField(host.id, rollupField.id);\n        expect(initial.hasError).toBeFalsy();\n\n        await convertField(foreign.id, scoreFieldId, {\n          name: 'Score',\n          type: FieldType.SingleLineText,\n          options: {},\n        } as IFieldRo);\n\n        const afterForeignConvert = await getField(host.id, rollupField.id);\n        expect(afterForeignConvert.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n  });\n\n  describe('self-referencing field reference filters', () => {\n    let table: ITableFullVo;\n    let linkField: IFieldVo;\n    let statusFieldId: string;\n    let scoreFieldId: string;\n    let rollupField: IFieldVo;\n    let recordIds: string[];\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'ConditionalRollup_SelfReference',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Name: 'Alpha', Status: 'todo', Score: 5 } },\n          { fields: { Name: 'Beta', Status: 'todo', Score: 5 } },\n          { fields: { Name: 'Gamma', Status: 'todo', Score: 8 } },\n          { fields: { Name: 'Delta', Status: 'done', Score: 5 } },\n        ],\n      });\n      statusFieldId = table.fields.find((field) => field.name === 'Status')!.id;\n      scoreFieldId = table.fields.find((field) => field.name === 'Score')!.id;\n      recordIds = table.records.map((record) => record.id);\n\n      linkField = await createField(table.id, {\n        name: 'Related',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table.id,\n        },\n      } as IFieldRo);\n\n      const linkTargets = recordIds.map((id) => ({ id }));\n      for (const recordId of recordIds) {\n        await updateRecordByApi(table.id, recordId, linkField.id, linkTargets);\n      }\n\n      const filter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFieldId, tableId: table.id },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: scoreFieldId, tableId: table.id },\n          },\n        ],\n      };\n\n      rollupField = await createField(table.id, {\n        name: 'Self Matching Count',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: table.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'countall({values})',\n          filter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('aggregates without recursion issues when comparing identical fields', async () => {\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const byId = new Map(records.records.map((record) => [record.id, record]));\n\n      expect(byId.get(recordIds[0])?.fields[rollupField.id]).toEqual(2);\n      expect(byId.get(recordIds[1])?.fields[rollupField.id]).toEqual(2);\n      expect(byId.get(recordIds[2])?.fields[rollupField.id]).toEqual(1);\n      expect(byId.get(recordIds[3])?.fields[rollupField.id]).toEqual(1);\n\n      await updateRecordByApi(table.id, recordIds[1], scoreFieldId, 6);\n\n      const updated = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const updatedById = new Map(updated.records.map((record) => [record.id, record]));\n\n      expect(updatedById.get(recordIds[0])?.fields[rollupField.id]).toEqual(1);\n      expect(updatedById.get(recordIds[1])?.fields[rollupField.id]).toEqual(1);\n      expect(updatedById.get(recordIds[2])?.fields[rollupField.id]).toEqual(1);\n      expect(updatedById.get(recordIds[3])?.fields[rollupField.id]).toEqual(1);\n    });\n  });\n\n  describe('numeric array field reference rollups', () => {\n    let games: ITableFullVo;\n    let summary: ITableFullVo;\n    let scoreFieldId: string;\n    let thresholdFieldId: string;\n    let ceilingFieldId: string;\n    let targetFieldId: string;\n    let exactFieldId: string;\n    let excludeFieldId: string;\n    let aliceSummaryId: string;\n    let bobSummaryId: string;\n    let sumAboveThresholdField: IFieldVo;\n    let sumWithinCeilingField: IFieldVo;\n    let sumEqualTargetField: IFieldVo;\n    let sumWithoutExactField: IFieldVo;\n    let sumWithoutExcludedField: IFieldVo;\n\n    beforeAll(async () => {\n      games = await createTable(baseId, {\n        name: 'ConditionalRollup_NumberArray_Games',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Player: 'Alice', Score: 10 } },\n          { fields: { Player: 'Alice', Score: 12 } },\n          { fields: { Player: 'Bob', Score: 7 } },\n        ],\n      });\n      scoreFieldId = games.fields.find((f) => f.name === 'Score')!.id;\n\n      const gamePlayerFieldId = games.fields.find((f) => f.name === 'Player')!.id;\n\n      summary = await createTable(baseId, {\n        name: 'ConditionalRollup_NumberArray_Summary',\n        fields: [\n          { name: 'Player', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Games',\n            type: FieldType.Link,\n            options: {\n              foreignTableId: games.id,\n              relationship: Relationship.ManyMany,\n            },\n          } as IFieldRo,\n          { name: 'Threshold', type: FieldType.Number } as IFieldRo,\n          { name: 'Ceiling', type: FieldType.Number } as IFieldRo,\n          { name: 'Target', type: FieldType.Number } as IFieldRo,\n          { name: 'Exact', type: FieldType.Number } as IFieldRo,\n          { name: 'Exclude', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Player: 'Alice',\n              Games: [{ id: games.records[0].id }, { id: games.records[1].id }],\n              Threshold: 11,\n              Ceiling: 12,\n              Target: 12,\n              Exact: 12,\n              Exclude: 10,\n            },\n          },\n          {\n            fields: {\n              Player: 'Bob',\n              Games: [{ id: games.records[2].id }],\n              Threshold: 8,\n              Ceiling: 8,\n              Target: 9,\n              Exact: 7,\n              Exclude: 5,\n            },\n          },\n        ],\n      });\n\n      const summaryPlayerFieldId = summary.fields.find((f) => f.name === 'Player')!.id;\n      thresholdFieldId = summary.fields.find((f) => f.name === 'Threshold')!.id;\n      ceilingFieldId = summary.fields.find((f) => f.name === 'Ceiling')!.id;\n      targetFieldId = summary.fields.find((f) => f.name === 'Target')!.id;\n      exactFieldId = summary.fields.find((f) => f.name === 'Exact')!.id;\n      excludeFieldId = summary.fields.find((f) => f.name === 'Exclude')!.id;\n      aliceSummaryId = summary.records[0].id;\n      bobSummaryId = summary.records[1].id;\n\n      const thresholdFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isGreater',\n            value: { type: 'field', fieldId: thresholdFieldId },\n          },\n        ],\n      };\n      sumAboveThresholdField = await createField(summary.id, {\n        name: 'Sum Above Threshold',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'sum({values})',\n          filter: thresholdFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      const ceilingFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isLessEqual',\n            value: { type: 'field', fieldId: ceilingFieldId },\n          },\n        ],\n      };\n      sumWithinCeilingField = await createField(summary.id, {\n        name: 'Sum Within Ceiling',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'sum({values})',\n          filter: ceilingFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      const equalTargetFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetFieldId },\n          },\n        ],\n      };\n      sumEqualTargetField = await createField(summary.id, {\n        name: 'Sum Equal Target',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'sum({values})',\n          filter: equalTargetFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      const excludeExactFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: exactFieldId },\n          },\n        ],\n      };\n      sumWithoutExactField = await createField(summary.id, {\n        name: 'Sum Without Exact',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'sum({values})',\n          filter: excludeExactFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      const withoutExcludedFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: gamePlayerFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: summaryPlayerFieldId },\n          },\n          {\n            fieldId: scoreFieldId,\n            operator: 'isNot',\n            value: { type: 'field', fieldId: excludeFieldId },\n          },\n        ],\n      };\n      sumWithoutExcludedField = await createField(summary.id, {\n        name: 'Sum Without Excluded',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: games.id,\n          lookupFieldId: scoreFieldId,\n          expression: 'sum({values})',\n          filter: withoutExcludedFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, summary.id);\n      await permanentDeleteTable(baseId, games.id);\n    });\n\n    it('aggregates numeric arrays with field references', async () => {\n      const records = await getRecords(summary.id, { fieldKeyType: FieldKeyType.Id });\n      const aliceSummary = records.records.find((record) => record.id === aliceSummaryId)!;\n      const bobSummary = records.records.find((record) => record.id === bobSummaryId)!;\n\n      expect(aliceSummary.fields[sumAboveThresholdField.id]).toEqual(12);\n      expect(\n        (bobSummary.fields[sumAboveThresholdField.id] as number | null | undefined) ?? 0\n      ).toEqual(0);\n\n      expect(aliceSummary.fields[sumWithinCeilingField.id]).toEqual(22);\n      expect(bobSummary.fields[sumWithinCeilingField.id]).toEqual(7);\n\n      expect(aliceSummary.fields[sumEqualTargetField.id]).toEqual(12);\n      expect((bobSummary.fields[sumEqualTargetField.id] as number | null | undefined) ?? 0).toEqual(\n        0\n      );\n\n      expect(aliceSummary.fields[sumWithoutExactField.id]).toEqual(10);\n      expect(\n        (bobSummary.fields[sumWithoutExactField.id] as number | null | undefined) ?? 0\n      ).toEqual(0);\n\n      expect(aliceSummary.fields[sumWithoutExcludedField.id]).toEqual(12);\n      expect(bobSummary.fields[sumWithoutExcludedField.id]).toEqual(7);\n    });\n  });\n\n  describe('v2 update field hasError propagation', () => {\n    const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n    const itV2Only = isForceV2 ? it : it.skip;\n\n    itV2Only('marks conditional rollup as errored when filter field is deleted', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'V2CondRollupFilterDel_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Amount: 2, Status: 'Active' } },\n          { fields: { Title: 'Beta', Amount: 4, Status: 'Active' } },\n          { fields: { Title: 'Gamma', Amount: 6, Status: 'Inactive' } },\n        ],\n      });\n      const host = await createTable(baseId, {\n        name: 'V2CondRollupFilterDel_Host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Label: 'Row 1' } }],\n      });\n      const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n      const statusId = foreign.fields.find((f) => f.name === 'Status')!.id;\n\n      try {\n        // Create conditional rollup without filter\n        let rollupField = await createField(host.id, {\n          name: 'Filtered Sum',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: amountId,\n            expression: 'sum({values})',\n          },\n        } as IFieldRo);\n\n        // Convert to add a filter referencing statusId\n        rollupField = await convertField(host.id, rollupField.id, {\n          name: 'Filtered Sum',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: amountId,\n            expression: 'sum({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: statusId, operator: 'is', value: 'Active' }],\n            },\n          },\n        } as IFieldRo);\n\n        const hostRecord = await getRecord(host.id, host.records[0].id);\n        expect(hostRecord.fields[rollupField.id]).toEqual(6);\n\n        // Delete the filter field from the foreign table\n        await deleteField(foreign.id, statusId);\n\n        const hostFields = await getFields(host.id);\n        const erroredField = hostFields.find((f) => f.id === rollupField.id)!;\n        expect(erroredField.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    itV2Only(\n      'marks conditional rollup as errored when lookup field type becomes incompatible',\n      async () => {\n        const foreign = await createTable(baseId, {\n          name: 'V2CondRollupTypeErr_Foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          ],\n          records: [\n            { fields: { Title: 'Alpha', Amount: 2 } },\n            { fields: { Title: 'Beta', Amount: 4 } },\n            { fields: { Title: 'Gamma', Amount: 6 } },\n          ],\n        });\n        const host = await createTable(baseId, {\n          name: 'V2CondRollupTypeErr_Host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'Row 1' } }],\n        });\n        const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id;\n        const hostRecordId = host.records[0].id;\n\n        try {\n          const rollupField = await createField(host.id, {\n            name: 'Sum Amount',\n            type: FieldType.ConditionalRollup,\n            options: {\n              foreignTableId: foreign.id,\n              lookupFieldId: amountId,\n              expression: 'sum({values})',\n            },\n          } as IFieldRo);\n\n          const baseline = await getRecord(host.id, hostRecordId);\n          expect(baseline.fields[rollupField.id]).toEqual(12);\n\n          // Convert numeric lookup field to SingleSelect (incompatible with sum)\n          await convertField(foreign.id, amountId, {\n            name: 'Amount (Select)',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: '2', color: Colors.Blue },\n                { name: '4', color: Colors.Green },\n                { name: '6', color: Colors.Orange },\n              ],\n            },\n          } as IFieldRo);\n\n          let erroredField: IFieldVo | undefined;\n          for (let attempt = 0; attempt < 10; attempt++) {\n            const fieldsAfterConversion = await getFields(host.id);\n            erroredField = fieldsAfterConversion.find((f) => f.id === rollupField.id);\n            if (erroredField?.hasError) break;\n            await new Promise((resolve) => setTimeout(resolve, 200));\n          }\n          expect(erroredField?.hasError).toBe(true);\n        } finally {\n          await permanentDeleteTable(baseId, host.id);\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/convert-field-transaction.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { MockInstance } from 'vitest';\nimport { vi } from 'vitest';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { FieldConvertingService } from '../src/features/field/field-calculate/field-converting.service';\nimport { FieldService } from '../src/features/field/field.service';\nimport { FieldOpenApiService } from '../src/features/field/open-api/field-open-api.service';\nimport type { IClsStore } from '../src/types/cls';\nimport { getError } from './utils/get-error';\nimport {\n  createBase,\n  createTable,\n  createField,\n  initApp,\n  permanentDeleteBase,\n  runWithTestUser,\n} from './utils/init-app';\n\ndescribe('Field convert transaction (e2e)', () => {\n  let app: INestApplication;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('rolls back convert when calculation fails mid-transaction', async () => {\n    const clsService = app.get<ClsService<IClsStore>>(ClsService);\n    const fieldOpenApiService = app.get(FieldOpenApiService);\n    const fieldConvertingService = app.get(FieldConvertingService);\n    const prismaService = app.get(PrismaService);\n\n    const base = await createBase({\n      spaceId: globalThis.testConfig.spaceId,\n      name: 'convert-field-tx',\n    });\n\n    let stageCalculateSpy: MockInstance | undefined;\n    try {\n      const table = await createTable(base.id, {\n        name: 'ConvertTxTable',\n        fields: [\n          {\n            name: 'Text',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n      const fieldId = table.fields?.[0].id as string;\n\n      stageCalculateSpy = vi\n        .spyOn(fieldConvertingService, 'stageCalculate')\n        .mockImplementationOnce(async () => {\n          throw new Error('force-convert-failure');\n        });\n\n      const error = await getError(() =>\n        runWithTestUser(clsService, () =>\n          fieldOpenApiService.convertField(table.id, fieldId, {\n            name: 'NumberAfterFail',\n            type: FieldType.Number,\n          })\n        )\n      );\n      expect(error).toBeTruthy();\n\n      const fieldAfter = await prismaService.field.findUniqueOrThrow({\n        where: { id: fieldId },\n        select: { type: true, name: true },\n      });\n      expect(fieldAfter.type).toBe(FieldType.SingleLineText);\n      expect(fieldAfter.name).toBe('Text');\n    } finally {\n      stageCalculateSpy?.mockRestore();\n      await permanentDeleteBase(base.id);\n    }\n  });\n\n  it('keeps junction table/field when link convert fails and rolls back', async () => {\n    const clsService = app.get<ClsService<IClsStore>>(ClsService);\n    const fieldOpenApiService = app.get(FieldOpenApiService);\n    const fieldConvertingService = app.get(FieldConvertingService);\n    const prismaService = app.get(PrismaService);\n    const dbProvider = app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n\n    const base = await createBase({\n      spaceId: globalThis.testConfig.spaceId,\n      name: 'convert-link-tx',\n    });\n\n    let stageAlterSpy: MockInstance | undefined;\n    try {\n      const tableA = await createTable(base.id, {\n        name: 'Host',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n      const tableB = await createTable(base.id, {\n        name: 'Foreign',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      const linkField = await createField(tableA.id, {\n        name: 'LinkToForeign',\n        type: FieldType.Link,\n        options: {\n          baseId: base.id,\n          relationship: Relationship.ManyMany,\n          foreignTableId: tableB.id,\n        },\n      });\n\n      const linkRaw = await prismaService.field.findUniqueOrThrow({\n        where: { id: linkField.id },\n        select: { options: true },\n      });\n      const parsedOptions: Record<string, unknown> =\n        (typeof linkRaw.options === 'string'\n          ? (JSON.parse(linkRaw.options) as Record<string, unknown> | null)\n          : (linkRaw.options as Record<string, unknown> | null)) ?? {};\n      const fkHostTableName = parsedOptions.fkHostTableName as string | undefined;\n      const symmetricFieldId = parsedOptions.symmetricFieldId as string | undefined;\n      const relationship = parsedOptions.relationship as Relationship | undefined;\n      const foreignKeyName = parsedOptions.foreignKeyName as string | undefined;\n      const selfKeyName = parsedOptions.selfKeyName as string | undefined;\n      const isOneWay = parsedOptions.isOneWay === true;\n      expect(fkHostTableName).toBeTruthy();\n\n      const isJunction =\n        relationship === Relationship.ManyMany ||\n        (relationship === Relationship.OneMany && isOneWay);\n      const columnToCheck =\n        relationship === Relationship.ManyOne\n          ? foreignKeyName\n          : relationship === Relationship.OneMany && !isOneWay\n            ? selfKeyName\n            : relationship === Relationship.OneOne\n              ? foreignKeyName === '__id'\n                ? selfKeyName\n                : foreignKeyName\n              : undefined;\n\n      const checkTableExists = async (tableName: string) =>\n        (\n          await prismaService.$queryRawUnsafe<{ exists: boolean }[]>(\n            dbProvider.checkTableExist(tableName)\n          )\n        )[0]?.exists ?? false;\n      const checkColumnExists = async (tableName: string, columnName: string) =>\n        dbProvider.checkColumnExist(tableName, columnName, prismaService.txClient());\n\n      const beforeExists = isJunction\n        ? await checkTableExists(fkHostTableName!)\n        : columnToCheck\n          ? await checkColumnExists(fkHostTableName!, columnToCheck)\n          : false;\n      expect(beforeExists).toBe(true);\n\n      stageAlterSpy = vi\n        .spyOn(fieldConvertingService, 'stageAlter')\n        .mockImplementationOnce(async () => {\n          throw new Error('force-link-convert-failure');\n        });\n\n      const error = await getError(() =>\n        runWithTestUser(clsService, () =>\n          fieldOpenApiService.convertField(tableA.id, linkField.id, {\n            name: 'AfterFail',\n            type: FieldType.SingleLineText,\n          })\n        )\n      );\n      expect(error).toBeTruthy();\n\n      const afterField = await prismaService.field.findUniqueOrThrow({\n        where: { id: linkField.id },\n        select: { type: true, name: true, options: true },\n      });\n      expect(afterField.type).toBe(FieldType.Link);\n      expect(afterField.name).toBe('LinkToForeign');\n\n      if (symmetricFieldId) {\n        const symmetricField = await prismaService.field.findUnique({\n          where: { id: symmetricFieldId },\n          select: { id: true },\n        });\n        expect(symmetricField?.id).toBe(symmetricFieldId);\n      }\n\n      const afterExists =\n        isJunction && fkHostTableName\n          ? await checkTableExists(fkHostTableName)\n          : columnToCheck && fkHostTableName\n            ? await checkColumnExists(fkHostTableName, columnToCheck)\n            : false;\n      expect(afterExists).toBe(true);\n    } finally {\n      stageAlterSpy?.mockRestore();\n      await permanentDeleteBase(base.id);\n    }\n  });\n\n  it('keeps column when delete field rolls back inside a single transaction', async () => {\n    const clsService = app.get<ClsService<IClsStore>>(ClsService);\n    const fieldOpenApiService = app.get(FieldOpenApiService);\n    const prismaService = app.get(PrismaService);\n    const dbProvider = app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n    const fieldService = app.get(FieldService);\n\n    const base = await createBase({\n      spaceId: globalThis.testConfig.spaceId,\n      name: 'delete-field-tx',\n    });\n\n    let alterSpy: MockInstance | undefined;\n    try {\n      const table = await createTable(base.id, {\n        name: 'DeleteTx',\n        fields: [\n          {\n            name: 'Keep',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'DropMe',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n      const dropFieldId = table.fields?.find((f) => f.name === 'DropMe')?.id as string;\n      expect(dropFieldId).toBeTruthy();\n\n      const fieldRaw = await prismaService.field.findUniqueOrThrow({\n        where: { id: dropFieldId },\n        select: { dbFieldName: true },\n      });\n\n      const hasColumn = async () =>\n        dbProvider.checkColumnExist(\n          table.dbTableName,\n          fieldRaw.dbFieldName,\n          prismaService.txClient()\n        );\n      expect(await hasColumn()).toBe(true);\n\n      const originalAlter = fieldService.alterTableDeleteField.bind(fieldService);\n      alterSpy = vi\n        .spyOn(fieldService, 'alterTableDeleteField')\n        .mockImplementationOnce(async (...args) => {\n          await originalAlter(...(args as Parameters<typeof originalAlter>));\n          throw new Error('force-delete-failure');\n        });\n\n      const error = await getError(() =>\n        runWithTestUser(clsService, () => fieldOpenApiService.deleteField(table.id, dropFieldId))\n      );\n      expect(error).toBeTruthy();\n\n      const fieldAfter = await prismaService.field.findUnique({\n        where: { id: dropFieldId },\n        select: { id: true },\n      });\n      expect(fieldAfter?.id).toBe(dropFieldId);\n      expect(await hasColumn()).toBe(true);\n    } finally {\n      alterSpy?.mockRestore();\n      await permanentDeleteBase(base.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/credit.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { createBase, createSpace, deleteBase, deleteSpace } from '@teable/openapi';\nimport { createRecords, createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\ndescribe('Credit limit (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  beforeAll(async () => {\n    process.env.MAX_FREE_ROW_LIMIT = '10';\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get<PrismaService>(PrismaService);\n  });\n\n  afterAll(async () => {\n    process.env.MAX_FREE_ROW_LIMIT = undefined;\n    await app.close();\n  });\n\n  describe('max row limit', () => {\n    let table: ITableFullVo;\n    let spaceId: string;\n    let baseId: string;\n    beforeEach(async () => {\n      const space = await createSpace({\n        name: 'space1',\n      });\n      spaceId = space.data.id;\n      const base = await createBase({\n        spaceId,\n      });\n      baseId = base.data.id;\n      table = await createTable(baseId, { name: 'table1' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await deleteBase(baseId);\n      await deleteSpace(spaceId);\n    });\n\n    it('should create a record', async () => {\n      // create 6 record succeed, 3(default) + 7 = 10\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: Array.from({ length: 7 }).map(() => ({ fields: {} })),\n      });\n\n      // limit exceed\n      await createRecords(\n        table.id,\n        {\n          fieldKeyType: FieldKeyType.Name,\n          records: [{ fields: {} }],\n        },\n        400\n      );\n    });\n\n    it('should create a record with credit', async () => {\n      await prisma.space.update({\n        where: {\n          id: spaceId,\n        },\n        data: {\n          credit: 11,\n        },\n      });\n\n      // create 6 record succeed, 3(default) + 8 = 11\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: Array.from({ length: 8 }).map(() => ({ fields: {} })),\n      });\n\n      // limit exceed\n      await createRecords(\n        table.id,\n        {\n          fieldKeyType: FieldKeyType.Name,\n          records: [{ fields: {} }],\n        },\n        400\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/dashboard.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createDashboard,\n  createDashboardVoSchema,\n  createPlugin,\n  createTable,\n  dashboardInstallPluginVoSchema,\n  deleteDashboard,\n  deletePlugin,\n  deleteTable,\n  duplicateDashboard,\n  duplicateDashboardInstalledPlugin,\n  getDashboard,\n  getDashboardInstallPlugin,\n  getDashboardVoSchema,\n  installPlugin,\n  PluginPosition,\n  publishPlugin,\n  removePlugin,\n  renameDashboard,\n  renameDashboardVoSchema,\n  renamePlugin,\n  submitPlugin,\n  updateDashboardPluginStorage,\n  updateLayoutDashboard,\n} from '@teable/openapi';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\nconst dashboardRo = {\n  name: 'dashboard',\n};\n\ndescribe('DashboardController', () => {\n  let app: INestApplication;\n  let dashboardId: string;\n  const baseId = globalThis.testConfig.baseId;\n  let prisma: PrismaService;\n  let table: ITableFullVo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n  });\n\n  beforeEach(async () => {\n    const res = await createDashboard(baseId, dashboardRo);\n    table = (\n      await createTable(baseId, {\n        name: 'table',\n      })\n    ).data;\n    dashboardId = res.data.id;\n  });\n\n  afterEach(async () => {\n    await deleteTable(baseId, table.id);\n    await deleteDashboard(baseId, dashboardId);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('/api/dashboard (POST)', async () => {\n    const res = await createDashboard(baseId, dashboardRo);\n    expect(createDashboardVoSchema.strict().safeParse(res.data).success).toBe(true);\n    expect(res.status).toBe(201);\n    await deleteDashboard(baseId, res.data.id);\n  });\n\n  it('/api/dashboard/:id (GET)', async () => {\n    const getRes = await getDashboard(baseId, dashboardId);\n    expect(getDashboardVoSchema.strict().safeParse(getRes.data).success).toBe(true);\n    expect(getRes.data.id).toBe(dashboardId);\n  });\n\n  it('/api/dashboard/:id (DELETE)', async () => {\n    const res = await createDashboard(baseId, dashboardRo);\n    await deleteDashboard(baseId, res.data.id);\n    const error = await getError(() => getDashboard(baseId, res.data.id));\n    expect(error?.status).toBe(404);\n  });\n\n  it('/api/dashboard/:id/rename (PATCH)', async () => {\n    const res = await createDashboard(baseId, dashboardRo);\n    const newName = 'new-dashboard';\n    const renameRes = await renameDashboard(baseId, res.data.id, newName);\n    expect(renameRes.data.name).toBe(newName);\n    await deleteDashboard(baseId, res.data.id);\n  });\n\n  it('/api/dashboard/:id/layout (PATCH)', async () => {\n    const res = await createDashboard(baseId, dashboardRo);\n    const layout = [{ pluginInstallId: 'plugin-install-id', x: 0, y: 0, w: 1, h: 1 }];\n    const updateRes = await updateLayoutDashboard(baseId, res.data.id, layout);\n    expect(updateRes.data.layout).toEqual(layout);\n    await deleteDashboard(baseId, res.data.id);\n  });\n\n  describe('plugin', () => {\n    let pluginId: string;\n    beforeEach(async () => {\n      const res = await createPlugin({\n        name: 'plugin',\n        logo: 'https://logo.com',\n        positions: [PluginPosition.Dashboard],\n      });\n      pluginId = res.data.id;\n      await submitPlugin(pluginId);\n      await publishPlugin(pluginId);\n    });\n\n    afterEach(async () => {\n      await deletePlugin(pluginId);\n    });\n\n    it('/api/dashboard/:id/plugin (POST)', async () => {\n      const installRes = await installPlugin(baseId, dashboardId, {\n        name: 'plugin1111',\n        pluginId,\n      });\n      const dashboard = await getDashboard(baseId, dashboardId);\n      expect(getDashboardVoSchema.safeParse(dashboard.data).success).toBe(true);\n      expect(installRes.data.name).toBe('plugin1111');\n      expect(dashboardInstallPluginVoSchema.safeParse(installRes.data).success).toBe(true);\n    });\n\n    it('api/base/:baseId/dashboard/:id/duplicate (POST) - duplicate dashboard', async () => {\n      const textField = table.fields.find((field) => field.name === 'Name')!;\n      const numberField = table.fields.find((field) => field.name === 'Count')!;\n      const res = (\n        await createDashboard(baseId, {\n          name: 'source-dashboard',\n        })\n      ).data;\n      const sourceDashboardId = res.id;\n      const installPluginRes = (\n        await installPlugin(baseId, sourceDashboardId, {\n          name: 'source-plugin-item',\n          pluginId: 'plgchart',\n        })\n      ).data;\n      await updateDashboardPluginStorage(\n        baseId,\n        sourceDashboardId,\n        installPluginRes.pluginInstallId,\n        {\n          config: {\n            type: 'bar',\n            xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n            yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n          },\n          query: {\n            from: table.id,\n            select: [\n              { column: textField.id, alias: 'Name', type: 'field' },\n              { column: numberField.id, alias: 'Count', type: 'field' },\n            ],\n          },\n        }\n      );\n      const duplicateRes = (\n        await duplicateDashboard(baseId, sourceDashboardId, {\n          name: 'source-plugin copy',\n        })\n      ).data;\n\n      const { id } = duplicateRes;\n\n      const duplicatedDashboard = (await getDashboard(baseId, id)).data;\n      const duplicatedInstallPlugin = await getDashboardInstallPlugin(\n        baseId,\n        duplicatedDashboard.id,\n        duplicatedDashboard.layout![0].pluginInstallId\n      );\n      expect(\n        duplicatedDashboard.pluginMap?.[duplicatedDashboard.layout![0].pluginInstallId]\n      ).toBeDefined();\n      expect(\n        duplicatedDashboard.pluginMap?.[duplicatedDashboard.layout![0].pluginInstallId]?.name\n      ).toBe('source-plugin-item');\n\n      expect(duplicatedInstallPlugin.data.storage).toEqual({\n        config: {\n          type: 'bar',\n          xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n          yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n        },\n        query: {\n          from: table.id,\n          select: [\n            { column: textField.id, alias: 'Name', type: 'field' },\n            { column: numberField.id, alias: 'Count', type: 'field' },\n          ],\n        },\n      });\n    });\n\n    it('api/base/:baseId/dashboard/:id/plugin/:pluginInstallId/duplicate (POST) - duplicate installed dashboard plugin', async () => {\n      const textField = table.fields.find((field) => field.name === 'Name')!;\n      const numberField = table.fields.find((field) => field.name === 'Count')!;\n      const res = (\n        await createDashboard(baseId, {\n          name: 'source-dashboard',\n        })\n      ).data;\n      const sourceDashboardId = res.id;\n      const installPluginRes = (\n        await installPlugin(baseId, sourceDashboardId, {\n          name: 'source-plugin-item',\n          pluginId: 'plgchart',\n        })\n      ).data;\n      await updateDashboardPluginStorage(\n        baseId,\n        sourceDashboardId,\n        installPluginRes.pluginInstallId,\n        {\n          config: {\n            type: 'bar',\n            xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n            yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n          },\n          query: {\n            from: table.id,\n            select: [\n              { column: textField.id, alias: 'Name', type: 'field' },\n              { column: numberField.id, alias: 'Count', type: 'field' },\n            ],\n          },\n        }\n      );\n      const duplicateInstalledPlugin = (\n        await duplicateDashboardInstalledPlugin(\n          baseId,\n          sourceDashboardId,\n          installPluginRes.pluginInstallId,\n          {\n            name: 'source-plugin-item copy',\n          }\n        )\n      ).data;\n\n      const { id } = duplicateInstalledPlugin;\n\n      const sourceDashboard = (await getDashboard(baseId, sourceDashboardId)).data;\n\n      const duplicatedInstallPlugin = await getDashboardInstallPlugin(\n        baseId,\n        sourceDashboard.id,\n        id\n      );\n      expect(sourceDashboard.pluginMap?.[sourceDashboard.layout![0].pluginInstallId]).toBeDefined();\n      expect(sourceDashboard.pluginMap?.[id]?.name).toBe('source-plugin-item copy');\n\n      expect(duplicatedInstallPlugin.data.storage).toEqual({\n        config: {\n          type: 'bar',\n          xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n          yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n        },\n        query: {\n          from: table.id,\n          select: [\n            { column: textField.id, alias: 'Name', type: 'field' },\n            { column: numberField.id, alias: 'Count', type: 'field' },\n          ],\n        },\n      });\n    });\n\n    it('/api/dashboard/:id/plugin (POST) - plugin not found', async () => {\n      const res = await createPlugin({\n        name: 'plugin-no',\n        logo: 'https://logo.com',\n        positions: [PluginPosition.Dashboard],\n      });\n      const installRes = await installPlugin(baseId, dashboardId, {\n        name: 'dddd',\n        pluginId: res.data.id,\n      });\n      await prisma.plugin.update({\n        where: { id: res.data.id },\n        data: { createdBy: 'test-user' },\n      });\n      const error = await getError(() =>\n        installPlugin(baseId, dashboardId, {\n          name: 'dddd',\n          pluginId: res.data.id,\n        })\n      );\n      await deletePlugin(res.data.id);\n      expect(error?.status).toBe(404);\n      expect(installRes.data.name).toBe('dddd');\n    });\n\n    it('/api/dashboard/:id/plugin/:pluginInstallId/rename (PATCH)', async () => {\n      const installRes = await installPlugin(baseId, dashboardId, {\n        name: 'plugin1111',\n        pluginId,\n      });\n      const newName = 'new-plugin';\n      const renameRes = await renamePlugin(\n        baseId,\n        dashboardId,\n        installRes.data.pluginInstallId,\n        newName\n      );\n      expect(renameDashboardVoSchema.safeParse(renameRes.data).success).toBe(true);\n      expect(renameRes.data.name).toBe(newName);\n    });\n\n    it('/api/dashboard/:id/plugin/:pluginInstallId (DELETE)', async () => {\n      const installRes = await installPlugin(baseId, dashboardId, {\n        name: 'plugin1111',\n        pluginId,\n      });\n      await removePlugin(baseId, dashboardId, installRes.data.pluginInstallId);\n      const dashboard = await getDashboard(baseId, dashboardId);\n      expect(dashboard?.data?.pluginMap?.[pluginId]).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/20x-link.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { FieldType, NumberFormattingType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\n\nconst textField = {\n  name: 'text field',\n  description: 'the text field',\n  type: FieldType.SingleLineText,\n};\n\nconst numberField = {\n  name: 'Number field',\n  description: 'the number field',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n  },\n};\n\nconst linkField = (foreignTableId: string) => {\n  return {\n    name: 'link field（from 20x）',\n    description: 'the link field',\n    type: FieldType.Link,\n    options: {\n      relationship: Relationship.ManyMany,\n      foreignTableId: foreignTableId,\n      isOneWay: false,\n    },\n  };\n};\n\nexport const DEFAULT_LINK_VALUE_INDEXS = [\n  [0],\n  [1],\n  [3],\n  [],\n  [0, 1],\n  [1, 2],\n  [2, 3],\n  [],\n  [0, 1, 2],\n  [1, 2, 3],\n  [2, 3, 4],\n  [],\n  [4, 5, 6],\n  [6, 7, 8],\n  [8, 9, 10],\n  [],\n  [10, 11, 12, 13],\n  [14, 15, 16, 17, 18],\n  [17, 18, 19, 20, 21, 22],\n];\n\nexport const x_20_link = (foreignTable: ITableFullVo) => {\n  const foreignRecords = foreignTable.records;\n\n  const link_field = linkField(foreignTable.id);\n\n  const records: any[] = [];\n  for (let i = 0; i < 20; i++) {\n    const fields: { [key: string]: any } = {\n      [textField.name]: `B-${i}`,\n      [numberField.name]: i,\n    };\n\n    DEFAULT_LINK_VALUE_INDEXS[i]?.forEach((index) => {\n      if (foreignRecords[index]) {\n        (fields[link_field.name] = fields[link_field.name] ?? []).push({\n          id: foreignRecords[index].id,\n        });\n      }\n    });\n\n    records.push({ fields });\n  }\n\n  return {\n    fields: [textField, numberField, link_field],\n\n    records: [\n      {\n        fields: {},\n      },\n      ...records,\n    ],\n  };\n};\n\nexport const x_20_link_from_lookups = (foreignTable: ITableFullVo, linkFieldId: string) => {\n  const fields: any[] = [];\n\n  foreignTable.fields.forEach((field) => {\n    const lookupField = {\n      name: `lookup ${field.name} (from x_20)`,\n      type: field.type,\n      isLookup: true,\n      isMultipleCellValue: field.isMultipleCellValue,\n      lookupOptions: {\n        foreignTableId: foreignTable.id,\n        lookupFieldId: field.id,\n        linkFieldId: linkFieldId,\n      },\n    };\n\n    fields.push(lookupField);\n  });\n\n  return { fields };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/20x.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  Colors,\n  DateFormattingPreset,\n  DateUtil,\n  FieldType,\n  NumberFormattingType,\n  TimeFormatting,\n} from '@teable/core';\n\nexport const textField = {\n  name: 'text field',\n  description: 'the text field',\n  type: FieldType.SingleLineText,\n};\n\nconst numberField = {\n  name: 'Number field',\n  description: 'the number field',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n  },\n};\n\nconst singleSelectField = {\n  name: 'singleSelect field',\n  description: 'the singleSelect field',\n  type: FieldType.SingleSelect,\n  options: {\n    choices: [\n      { id: 'choX', name: 'x', color: Colors.Cyan },\n      { id: 'choY', name: 'y', color: Colors.Blue },\n      { id: 'choZ', name: 'z', color: Colors.Gray },\n    ],\n  },\n};\n\nexport const dateField = {\n  name: 'date field',\n  description: 'the date field',\n  type: FieldType.Date,\n  options: {\n    formatting: {\n      date: DateFormattingPreset.ISO,\n      time: TimeFormatting.None,\n\n      timeZone: 'Asia/Singapore',\n    },\n  },\n};\n\nconst checkboxField = {\n  name: 'checkbox field',\n  description: 'the checkbox field',\n  type: FieldType.Checkbox,\n};\n\nconst userField = {\n  name: 'user field',\n  description: 'the user field',\n  type: FieldType.User,\n};\n\nconst multipleSelectField = {\n  name: 'multipleSelect field',\n  description: 'the multipleSelect field',\n  type: FieldType.MultipleSelect,\n  options: {\n    choices: [\n      { id: 'choX', name: 'rap', color: Colors.Cyan },\n      { id: 'choY', name: 'rock', color: Colors.Blue },\n      { id: 'choZ', name: 'hiphop', color: Colors.Gray },\n    ],\n  },\n};\n\nconst multipleUserField = {\n  name: 'multiple user field',\n  description: 'the multiple user field',\n  type: FieldType.User,\n  options: {\n    isMultiple: true,\n    shouldNotify: false,\n  },\n};\n\nconst formulaField = {\n  name: 'formula user field',\n  description: 'the formula user field',\n  type: FieldType.Formula,\n  options: {\n    expression: '1 + 1.1',\n    formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n  },\n};\n\nconst dateFieldWithYM = {\n  name: 'date field with YM',\n  description: 'the date field with YM',\n  type: FieldType.Date,\n  options: {\n    formatting: {\n      date: DateFormattingPreset.YM,\n      time: TimeFormatting.None,\n      timeZone: 'Asia/Singapore',\n    },\n  },\n};\nexport const x_20 = {\n  // textField                => 0\n  // numberField              => 1\n  // singleSelectField        => 2\n  // dateField                => 3\n  // checkboxField            => 4\n  // userField                => 5\n  // multipleSelectField      => 6\n  // multipleUserField        => 7\n  // formulaField             => 8\n  // dateFieldWithYM          => 9\n  fields: [\n    textField,\n    numberField,\n    singleSelectField,\n    dateField,\n    checkboxField,\n    userField,\n    multipleSelectField,\n    multipleUserField,\n    formulaField,\n    dateFieldWithYM,\n  ],\n\n  // actual number of items: 23\n  records: [\n    {\n      fields: {\n        [textField.name]: '',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 0',\n        [numberField.name]: 0,\n        [dateField.name]: '2019-12-31T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2019-12-31T16:00:00.000Z',\n        [userField.name]: { id: 'usrTestUserId', title: 'test' },\n        [multipleSelectField.name]: ['rap', 'rock', 'hiphop'],\n        [multipleUserField.name]: [\n          { id: 'usrTestUserId', title: 'test' },\n          { id: 'usrTestUserId_1', title: 'test_1' },\n        ],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 1',\n        [numberField.name]: 1,\n        [multipleSelectField.name]: ['rap', 'rock'],\n        [multipleUserField.name]: [{ id: 'usrTestUserId_1', title: 'test_1' }],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 2',\n        [numberField.name]: 2,\n        [checkboxField.name]: true,\n        [dateField.name]: '2022-11-28T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-11-28T16:00:00.000Z',\n        [multipleSelectField.name]: ['rap'],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 3',\n        [numberField.name]: 3,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: '2022-01-27T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-01-27T16:00:00.000Z',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 4',\n        [numberField.name]: 4,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: '2022-02-28T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-02-28T16:00:00.000Z',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 5',\n        [numberField.name]: 5,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: '2022-03-01T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-03-01T16:00:00.000Z',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 6',\n        [numberField.name]: 6,\n        [checkboxField.name]: true,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: '2022-03-11T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-03-11T16:00:00.000Z',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 7',\n        [numberField.name]: 7,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: '2022-05-08T16:00:00.000Z',\n        [dateFieldWithYM.name]: '2022-05-08T16:00:00.000Z',\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 8',\n        [numberField.name]: 8,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetDay(1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetDay(1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 9',\n        [numberField.name]: 9,\n        [singleSelectField.name]: 'x',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetDay(-1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetDay(-1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 10',\n        [numberField.name]: 10,\n        [singleSelectField.name]: 'y',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetWeek(1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetWeek(1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 11',\n        [numberField.name]: 11,\n        [singleSelectField.name]: 'z',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetWeek(-1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetWeek(-1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 12',\n        [numberField.name]: 12,\n        [checkboxField.name]: true,\n        [singleSelectField.name]: 'z',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetMonth(1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetMonth(1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 13',\n        [numberField.name]: 13,\n        [singleSelectField.name]: 'y',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offsetMonth(-1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offsetMonth(-1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 14',\n        [numberField.name]: 14,\n        [singleSelectField.name]: 'y',\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offset('year', 1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offset('year', 1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 15',\n        [numberField.name]: 15,\n        [multipleSelectField.name]: ['rock', 'hiphop'],\n        [dateField.name]: new DateUtil('Asia/Singapore', true).offset('year', -1),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).offset('year', -1),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 16',\n        [numberField.name]: 16,\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 17',\n        [numberField.name]: 17,\n        [multipleSelectField.name]: ['rock'],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 18',\n        [numberField.name]: 18,\n        [multipleSelectField.name]: ['hiphop'],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 19',\n        [numberField.name]: 19,\n        [multipleSelectField.name]: ['rap', 'hiphop'],\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 20',\n        [numberField.name]: 20,\n        [checkboxField.name]: true,\n        [dateField.name]: new DateUtil('Asia/Singapore', true).date().toISOString(),\n        [dateFieldWithYM.name]: new DateUtil('Asia/Singapore', true).date().toISOString(),\n      },\n    },\n    {\n      fields: {\n        [textField.name]: 'Text Field 10',\n        [numberField.name]: 10,\n        [dateField.name]: '2099-12-31T15:59:59.000Z',\n        [dateFieldWithYM.name]: '2099-12-31T15:59:59.000Z',\n        [multipleSelectField.name]: ['rap', 'rock', 'hiphop'],\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/checkbox-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const CHECKBOX_FIELD_CASES = [\n  {\n    fieldIndex: 4,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 4,\n    aggFunc: StatisticsFunc.Checked,\n    expectValue: 4,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 4,\n    aggFunc: StatisticsFunc.UnChecked,\n    expectValue: 19,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 4,\n    aggFunc: StatisticsFunc.PercentChecked,\n    expectValue: 17.391304,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 4,\n    aggFunc: StatisticsFunc.PercentUnChecked,\n    expectValue: 82.608695,\n    expectGroupedCount: 2,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/date-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const DATE_FIELD_CASES = [\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 6,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 17,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 17,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 26.086956,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 73.913043,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 73.913043,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.EarliestDate,\n    expectValue: '2019-12-31T16:00:00.000Z',\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.LatestDate,\n    expectValue: '2099-12-31T15:59:59.000Z',\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.DateRangeOfDays,\n    expectValue: 29219,\n    expectGroupedCount: 18,\n  },\n  {\n    fieldIndex: 3,\n    aggFunc: StatisticsFunc.DateRangeOfMonths,\n    expectValue: 959,\n    expectGroupedCount: 18,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/index.ts",
    "content": "export * from './text-field';\nexport * from './number-field';\nexport * from './single-select-field';\nexport * from './multiple-select-field';\nexport * from './checkbox-field';\nexport * from './date-field';\nexport * from './user-field';\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/multiple-select-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const MULTIPLE_SELECT_FIELD_CASES = [\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 15,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 8,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 3,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 65.217391,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 34.782608,\n    expectGroupedCount: 8,\n  },\n  {\n    fieldIndex: 6,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 20,\n    expectGroupedCount: 8,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/number-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const NUMBER_FIELD_CASES = [\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Sum,\n    expectValue: 220,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Average,\n    expectValue: 10,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Min,\n    expectValue: 0,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Max,\n    expectValue: 20,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 1,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 22,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 21,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 4.347826,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 95.652173,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 1,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 91.304347,\n    expectGroupedCount: 22,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/single-select-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const SINGLE_SELECT_FIELD_CASES = [\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 11,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 12,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 3,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 47.8260869,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 52.173913,\n    expectGroupedCount: 4,\n  },\n  {\n    fieldIndex: 2,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 13.043478,\n    expectGroupedCount: 4,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/text-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const TEXT_FIELD_CASES = [\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 1,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 22,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 21,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 4.347826,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 95.652173,\n    expectGroupedCount: 22,\n  },\n  {\n    fieldIndex: 0,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 91.304347,\n    expectGroupedCount: 22,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/aggregation-query/user-field.ts",
    "content": "import { StatisticsFunc } from '@teable/core';\n\nexport const USER_FIELD_CASES = [\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.Count,\n    expectValue: 23,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 22,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 1,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 95.652173,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 4.347826,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.Unique,\n    expectValue: 1,\n    expectGroupedCount: 2,\n  },\n  {\n    fieldIndex: 5,\n    aggFunc: StatisticsFunc.PercentUnique,\n    expectValue: 4.347826,\n    expectGroupedCount: 2,\n  },\n];\n\nexport const MULTIPLE_USER_FIELD_CASES = [\n  {\n    fieldIndex: 7,\n    aggFunc: StatisticsFunc.Empty,\n    expectValue: 1,\n  },\n  {\n    fieldIndex: 7,\n    aggFunc: StatisticsFunc.Filled,\n    expectValue: 22,\n  },\n  {\n    fieldIndex: 7,\n    aggFunc: StatisticsFunc.PercentEmpty,\n    expectValue: 21,\n  },\n  {\n    fieldIndex: 7,\n    aggFunc: StatisticsFunc.PercentFilled,\n    expectValue: 4.347826,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/checkbox-field.ts",
    "content": "import { is } from '@teable/core';\n\nexport const CHECKBOX_FIELD_CASES = [\n  {\n    fieldIndex: 4,\n    operator: is.value,\n    queryValue: null,\n    expectResultLength: 19,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: is.value,\n    queryValue: true,\n    expectResultLength: 4,\n    expectMoreResults: false,\n  },\n];\n\nexport const CHECKBOX_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 7,\n    operator: is.value,\n    queryValue: null,\n    expectResultLength: 14,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: is.value,\n    queryValue: true,\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/date-field.ts",
    "content": "import { isEmpty, isNotEmpty } from '@teable/core';\nimport { DATE_RANGE_SETS, LOOKUP_DATE_RANGE_SETS } from './date-range-sets';\nimport { IS_AFTER_SETS, LOOKUP_IS_AFTER_SETS } from './is-after-sets';\nimport { IS_BEFORE_SETS, LOOKUP_IS_BEFORE_SETS } from './is-before-sets';\nimport { IS_NOT_SETS, LOOKUP_IS_NOT_SETS } from './is-not-sets';\nimport { IS_ON_OR_AFTER_SETS, LOOKUP_IS_ON_OR_AFTER_SETS } from './is-on-or-after-sets';\nimport { IS_ON_OR_BEFORE_SETS, LOOKUP_IS_ON_OR_BEFORE_SETS } from './is-on-or-before-sets';\nimport { IS_SETS, LOOKUP_IS_SETS } from './is-sets';\nimport { IS_WITH_IN_SETS, LOOKUP_IS_WITH_IN_SETS } from './is-with-in-sets';\n\nexport const DATE_FIELD_CASES = [\n  {\n    fieldIndex: 3,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 6,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 17,\n  },\n  ...IS_SETS,\n  ...IS_NOT_SETS,\n  ...IS_WITH_IN_SETS,\n  ...IS_BEFORE_SETS,\n  ...IS_AFTER_SETS,\n  ...IS_ON_OR_BEFORE_SETS,\n  ...IS_ON_OR_AFTER_SETS,\n  ...DATE_RANGE_SETS,\n];\n\nexport const DATE_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 6,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 7,\n  },\n  {\n    fieldIndex: 6,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 14,\n  },\n  ...LOOKUP_IS_SETS.map((testCase) => ({ ...testCase, fieldIndex: testCase.fieldIndex ?? 6 })),\n  ...LOOKUP_IS_NOT_SETS.map((testCase) => ({ ...testCase, fieldIndex: testCase.fieldIndex ?? 6 })),\n  ...LOOKUP_IS_WITH_IN_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n  ...LOOKUP_IS_BEFORE_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n  ...LOOKUP_IS_AFTER_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n  ...LOOKUP_IS_ON_OR_BEFORE_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n  ...LOOKUP_IS_ON_OR_AFTER_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n  ...LOOKUP_DATE_RANGE_SETS.map((testCase) => ({\n    ...testCase,\n    fieldIndex: testCase.fieldIndex ?? 6,\n  })),\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/date-range-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { dateRange, is, isNot } from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\n// Date range: from 2020-01-01 to 2020-01-15\nconst rangeStart = dayjs.tz('2020-01-01', tz);\nconst rangeEnd = dayjs.tz('2020-01-15', tz);\n\nexport const DATE_RANGE_SETS = [\n  // Basic date range filter\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: rangeStart.toISOString(),\n      exactDateEnd: rangeEnd.toISOString(),\n      timeZone: tz,\n    },\n    expectResultLength: dates.filter(\n      (t) =>\n        (t.isAfter(rangeStart.startOf('day')) || t.isSame(rangeStart.startOf('day'))) &&\n        (t.isBefore(rangeEnd.endOf('day')) || t.isSame(rangeEnd.endOf('day')))\n    ).length,\n  },\n  // Date range: from yesterday to tomorrow\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: now.subtract(1, 'day').startOf('day').toISOString(),\n      exactDateEnd: now.add(1, 'day').endOf('day').toISOString(),\n      timeZone: tz,\n    },\n    expectResultLength: 3, // yesterday, today, tomorrow\n  },\n  // Date range: entire current month\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: now.startOf('month').toISOString(),\n      exactDateEnd: now.endOf('month').toISOString(),\n      timeZone: tz,\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now, 'month')).length,\n  },\n  // Single day range (start == end)\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: rangeStart.toISOString(),\n      exactDateEnd: rangeStart.endOf('day').toISOString(),\n      timeZone: tz,\n    },\n    expectResultLength: dates.filter((t) => t.isSame(rangeStart, 'day')).length,\n  },\n];\n\nexport const LOOKUP_DATE_RANGE_SETS = [\n  {\n    fieldIndex: 6,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: rangeStart.toISOString(),\n      exactDateEnd: rangeEnd.toISOString(),\n      timeZone: tz,\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some(\n        (t) =>\n          (t.isAfter(rangeStart.startOf('day')) || t.isSame(rangeStart.startOf('day'))) &&\n          (t.isBefore(rangeEnd.endOf('day')) || t.isSame(rangeEnd.endOf('day')))\n      )\n    ).length,\n  },\n];\n\n// Error cases for dateRange - these need special handling in tests\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const DATE_RANGE_ERROR_CASES = {\n  // start > end should throw error\n  invalidRange: {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: rangeEnd.toISOString(), // end date as start\n      exactDateEnd: rangeStart.toISOString(), // start date as end - INVALID!\n      timeZone: tz,\n    },\n  },\n  // dateRange with isNot operator should throw error\n  invalidOperator: {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: dateRange.value,\n      exactDate: rangeStart.toISOString(),\n      exactDateEnd: rangeEnd.toISOString(),\n      timeZone: tz,\n    },\n  },\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/index.ts",
    "content": "export * from './is-sets';\nexport * from './is-not-sets';\nexport * from './is-with-in-sets';\nexport * from './is-before-sets';\nexport * from './is-on-or-before-sets';\nexport * from './is-after-sets';\nexport * from './is-on-or-after-sets';\nexport * from './date-range-sets';\nexport * from './date-field';\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-after-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  isAfter,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_AFTER_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 6,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.add(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.add(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.add(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 7,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 8,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 6,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 3,\n    operator: isAfter.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 16,\n  },\n  {\n    fieldIndex: 9,\n    operator: isAfter.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 16,\n  },\n];\n\nexport const LOOKUP_IS_AFTER_SETS = [\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'week')))\n      .length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.add(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'month')))\n      .length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.add(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'year')))\n      .length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.add(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    operator: isAfter.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 10,\n  },\n  {\n    fieldIndex: 12,\n    operator: isAfter.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 10,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-before-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  isBefore,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_BEFORE_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 11,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 10,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.subtract(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.subtract(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.subtract(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 9,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 8,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 10,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    fieldIndex: 3,\n    operator: isBefore.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 0,\n  },\n  {\n    fieldIndex: 9,\n    operator: isBefore.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 0,\n  },\n];\n\nexport const LOOKUP_IS_BEFORE_SETS = [\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'week')))\n      .length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.subtract(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'month')))\n      .length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.subtract(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'year')))\n      .length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.subtract(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isBefore.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 0,\n  },\n  {\n    fieldIndex: 12,\n    operator: isBefore.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 0,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-not-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  isNot,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_NOT_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now.add(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now.subtract(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      23 - dates.filter((t) => t.isSame(now.subtract(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now.add(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now.subtract(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 23 - dates.filter((t) => t.isSame(now.add(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n  {\n    fieldIndex: 9,\n    operator: isNot.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 22,\n  },\n];\n\nexport const LOOKUP_IS_NOT_SETS = [\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 19,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 - lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'week'))).length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.add(1, 'week'), 'week'))).length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.subtract(1, 'week'), 'week')))\n        .length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 - lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'month'))).length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.subtract(1, 'month'), 'month')))\n        .length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.add(1, 'month'), 'month')))\n        .length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 - lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'year'))).length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.subtract(1, 'year'), 'year')))\n        .length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength:\n      21 -\n      lookupDates.filter((dates) => dates.some((t) => t.isSame(now.add(1, 'year'), 'year'))).length,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 19,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 20,\n  },\n  {\n    operator: isNot.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 16,\n  },\n  {\n    fieldIndex: 12,\n    operator: isNot.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 16,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-on-or-after-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  isOnOrAfter,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_ON_OR_AFTER_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 6,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 7,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(2, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(2, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now.subtract(2, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isAfter(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 8,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 9,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 7,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 17,\n  },\n  {\n    fieldIndex: 9,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 17,\n  },\n];\n\nexport const LOOKUP_IS_ON_OR_AFTER_SETS = [\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'week')))\n      .length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(2, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(2, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'month')))\n      .length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isAfter(now.subtract(2, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isAfter(now, 'year')))\n      .length,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    fieldIndex: 12,\n    operator: isOnOrAfter.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-on-or-before-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  isOnOrBefore,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_ON_OR_BEFORE_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 11,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(2, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(2, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isBefore(now.add(2, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 10,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 9,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 15,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 11,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    fieldIndex: 3,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 9,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n];\n\nexport const LOOKUP_IS_ON_OR_BEFORE_SETS = [\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(2, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'week')))\n      .length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'month')))\n      .length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(2, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isBefore(now, 'year')))\n      .length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isBefore(now.add(2, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 12,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 13,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 14,\n  },\n  {\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 12,\n    operator: isOnOrBefore.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  currentMonth,\n  currentWeek,\n  currentYear,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  is,\n  lastMonth,\n  lastWeek,\n  lastYear,\n  nextMonthPeriod,\n  nextWeekPeriod,\n  nextYearPeriod,\n  oneMonthAgo,\n  oneMonthFromNow,\n  oneWeekAgo,\n  oneWeekFromNow,\n  today,\n  tomorrow,\n  yesterday,\n} from '@teable/core';\nimport dayjs from 'dayjs';\nimport { getDates } from './utils';\n\nconst tz = 'Asia/Singapore';\nconst now = dayjs().tz(tz);\nconst { dates, lookupDates } = getDates();\n\nexport const IS_SETS = [\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now, 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.add(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.subtract(1, 'week'), 'week')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now, 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.subtract(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.add(1, 'month'), 'month')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now, 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.subtract(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: dates.filter((t) => t.isSame(now.add(1, 'year'), 'year')).length,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    fieldIndex: 9,\n    operator: is.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n];\n\nexport const LOOKUP_IS_SETS = [\n  {\n    operator: is.value,\n    queryValue: {\n      mode: today.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: tomorrow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: yesterday.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: currentWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'week')))\n      .length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: nextWeekPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.add(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: lastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.subtract(1, 'week'), 'week'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: currentMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'month')))\n      .length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: lastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.subtract(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: nextMonthPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.add(1, 'month'), 'month'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: currentYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) => dates.some((t) => t.isSame(now, 'year')))\n      .length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: lastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.subtract(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: nextYearPeriod.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: lookupDates.filter((dates) =>\n      dates.some((t) => t.isSame(now.add(1, 'year'), 'year'))\n    ).length,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: oneWeekAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: oneWeekFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: oneMonthAgo.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: oneMonthFromNow.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: daysAgo.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: daysFromNow.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 1,\n  },\n  {\n    operator: is.value,\n    queryValue: {\n      mode: exactDate.value,\n      exactDate: '2019-12-31T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 12,\n    operator: is.value,\n    queryValue: {\n      mode: exactFormatDate.value,\n      exactDate: '2020-01-10T16:00:00.000Z',\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/is-with-in-sets.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport {\n  isWithIn,\n  nextMonth,\n  nextNumberOfDays,\n  nextWeek,\n  nextYear,\n  pastMonth,\n  pastNumberOfDays,\n  pastWeek,\n  pastYear,\n} from '@teable/core';\n\nexport const IS_WITH_IN_SETS = [\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 5,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastNumberOfDays.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n  {\n    fieldIndex: 3,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextNumberOfDays.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n];\n\nexport const LOOKUP_IS_WITH_IN_SETS = [\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextWeek.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextMonth.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextYear.value,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 4,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: pastNumberOfDays.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 3,\n  },\n  {\n    fieldIndex: 6,\n    operator: isWithIn.value,\n    queryValue: {\n      mode: nextNumberOfDays.value,\n      numberOfDays: 1,\n      timeZone: 'Asia/Singapore',\n    },\n    expectResultLength: 2,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/date-field/utils.ts",
    "content": "import type { Dayjs } from 'dayjs';\nimport dayjs from 'dayjs';\nimport { x_20 } from '../../../20x';\nimport { DEFAULT_LINK_VALUE_INDEXS } from '../../../20x-link';\n\nexport const getDates = () => {\n  const tz = 'Asia/Singapore';\n  const dateFieldName = x_20.fields[3].name;\n  dayjs.locale(dayjs.locale(), {\n    weekStart: 1,\n  });\n  const dates = x_20.records\n    .filter((r) => r.fields?.[dateFieldName])\n    .map((r) => {\n      const date = r.fields[dateFieldName];\n      return typeof date === 'string' ? dayjs.utc(date).tz(tz) : date;\n    }) as Dayjs[];\n\n  const lookupDates = DEFAULT_LINK_VALUE_INDEXS.map((item) => {\n    const records = x_20.records;\n    const result = [] as Dayjs[];\n    if (item?.length) {\n      item.forEach((index) => {\n        const date = records[index].fields[dateFieldName];\n        if (date) {\n          result.push(typeof date === 'string' ? dayjs.utc(date).tz(tz) : (date as Dayjs));\n        }\n      });\n    }\n\n    return result?.length ? result : null;\n  }).filter((d) => d) as Dayjs[][];\n\n  return {\n    dates,\n    lookupDates,\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/index.ts",
    "content": "export * from './text-field';\nexport * from './number-field';\nexport * from './single-select-field';\nexport * from './date-field';\nexport * from './checkbox-field';\nexport * from './user-field';\nexport * from './multiple-select-field';\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/multiple-select-field.ts",
    "content": "import {\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  isNotExactly,\n  isEmpty,\n  isExactly,\n  isNotEmpty,\n} from '@teable/core';\n\nexport const MULTIPLE_SELECT_FIELD_CASES = [\n  {\n    fieldIndex: 6,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 15,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 6,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 8,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 6,\n    operator: hasAnyOf.value,\n    queryValue: ['rap', 'rock', 'hiphop'],\n    expectResultLength: 8,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 6,\n    operator: hasAllOf.value,\n    queryValue: ['rap', 'rock'],\n    expectResultLength: 3,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 6,\n    operator: hasNoneOf.value,\n    queryValue: ['rock'],\n    expectResultLength: 18,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 6,\n    operator: isExactly.value,\n    queryValue: ['rock', 'hiphop'],\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 6,\n    operator: isNotExactly.value,\n    queryValue: ['rap', 'rock'],\n    expectResultLength: 22,\n    expectMoreResults: true,\n  },\n];\n\nexport const MULTIPLE_SELECT_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 9,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 11,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 9,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 10,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 9,\n    operator: hasAnyOf.value,\n    queryValue: ['rap', 'rock', 'hiphop'],\n    expectResultLength: 10,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 9,\n    operator: hasAllOf.value,\n    queryValue: ['rap', 'rock'],\n    expectResultLength: 8,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 9,\n    operator: hasNoneOf.value,\n    queryValue: ['rock'],\n    expectResultLength: 12,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 9,\n    operator: isExactly.value,\n    queryValue: ['rock', 'hiphop'],\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 9,\n    operator: isNotExactly.value,\n    queryValue: ['rap'],\n    expectResultLength: 20,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/number-field.ts",
    "content": "import {\n  is,\n  isEmpty,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isNot,\n  isNotEmpty,\n} from '@teable/core';\n\nexport const NUMBER_FIELD_CASES = [\n  {\n    fieldIndex: 1,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: is.value,\n    queryValue: 9,\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isNot.value,\n    queryValue: 20,\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isGreater.value,\n    queryValue: 1,\n    expectResultLength: 20,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isGreaterEqual.value,\n    queryValue: 5,\n    expectResultLength: 17,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isLess.value,\n    queryValue: 10,\n    expectResultLength: 10,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 1,\n    operator: isLessEqual.value,\n    queryValue: 3,\n    expectResultLength: 4,\n    expectMoreResults: false,\n  },\n];\n\nexport const NUMBER_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 4,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 14,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: is.value,\n    queryValue: 9,\n    expectResultLength: 2,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isNot.value,\n    queryValue: 20,\n    expectResultLength: 20,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isGreater.value,\n    queryValue: 1,\n    expectResultLength: 10,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isGreaterEqual.value,\n    queryValue: 5,\n    expectResultLength: 6,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isLess.value,\n    queryValue: 10,\n    expectResultLength: 12,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 4,\n    operator: isLessEqual.value,\n    queryValue: 3,\n    expectResultLength: 9,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/single-select-field.ts",
    "content": "import {\n  is,\n  isAnyOf,\n  isEmpty,\n  isNoneOf,\n  isNot,\n  isNotEmpty,\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  isExactly,\n} from '@teable/core';\n\nexport const SINGLE_SELECT_FIELD_CASES = [\n  {\n    fieldIndex: 2,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 11,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 2,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 12,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 2,\n    operator: is.value,\n    queryValue: 'x',\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 2,\n    operator: isNot.value,\n    queryValue: 'x',\n    expectResultLength: 16,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 2,\n    operator: isAnyOf.value,\n    queryValue: ['x', 'y'],\n    expectResultLength: 10,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 2,\n    operator: isNoneOf.value,\n    queryValue: ['x', 'y'],\n    expectResultLength: 13,\n    expectMoreResults: false,\n  },\n];\n\nexport const SINGLE_SELECT_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 5,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 15,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 6,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: hasAnyOf.value,\n    queryValue: ['x'],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: hasAllOf.value,\n    queryValue: ['x'],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: hasNoneOf.value,\n    queryValue: ['x'],\n    expectResultLength: 16,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 5,\n    operator: isExactly.value,\n    queryValue: ['x'],\n    expectResultLength: 4,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/text-field.ts",
    "content": "import { contains, doesNotContain, is, isEmpty, isNot, isNotEmpty } from '@teable/core';\n\nexport const TEXT_FIELD_CASES = [\n  {\n    fieldIndex: 0,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: is.value,\n    queryValue: 'Text Field 0',\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: isNot.value,\n    queryValue: 'Text Field 1',\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: contains.value,\n    queryValue: 'Text',\n    expectResultLength: 22,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 0,\n    operator: doesNotContain.value,\n    queryValue: 'Text',\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  // test lower case\n  {\n    fieldIndex: 0,\n    operator: is.value,\n    queryValue: 'Text field 0',\n    expectResultLength: 0,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: isNot.value,\n    queryValue: 'Text field 1',\n    expectResultLength: 23,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 0,\n    operator: contains.value,\n    queryValue: 'text',\n    expectResultLength: 22,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 0,\n    operator: doesNotContain.value,\n    queryValue: 'text',\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n];\n\nexport const TEXT_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 3,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 14,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: 'Text Field 0',\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: 'Text Field 1',\n    expectResultLength: 16,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 3,\n    operator: contains.value,\n    queryValue: 'Text',\n    expectResultLength: 14,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 3,\n    operator: doesNotContain.value,\n    queryValue: 'Text',\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  // ignore case test\n  {\n    fieldIndex: 3,\n    operator: is.value,\n    queryValue: 'Text field 0',\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 3,\n    operator: isNot.value,\n    queryValue: 'Text field 1',\n    expectResultLength: 16,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 3,\n    operator: contains.value,\n    queryValue: 'text',\n    expectResultLength: 14,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 3,\n    operator: doesNotContain.value,\n    queryValue: 'text',\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/record-filter-query/user-field.ts",
    "content": "import {\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  is,\n  isAnyOf,\n  isEmpty,\n  isExactly,\n  isNoneOf,\n  isNot,\n  isNotEmpty,\n  isNotExactly,\n  Me,\n} from '@teable/core';\n\nexport const USER_FIELD_CASES = [\n  {\n    fieldIndex: 5,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: is.value,\n    queryValue: 'usrTestUserId',\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: is.value,\n    queryValue: Me,\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: isNot.value,\n    queryValue: 'usrTestUserId',\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 5,\n    operator: isAnyOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 1,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 5,\n    operator: isNoneOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n];\n\nexport const MULTIPLE_USER_FIELD_CASES = [\n  {\n    fieldIndex: 7,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 21,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 2,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: hasAnyOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: hasAnyOf.value,\n    queryValue: [Me],\n    expectResultLength: 1,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: hasAllOf.value,\n    queryValue: ['usrTestUserId_1'],\n    expectResultLength: 2,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 7,\n    operator: isExactly.value,\n    queryValue: ['usrTestUserId', 'usrTestUserId_1'],\n    expectResultLength: 1,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 7,\n    operator: isNotExactly.value,\n    queryValue: ['usrTestUserId', 'usrTestUserId_1'],\n    expectResultLength: 22,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 7,\n    operator: hasNoneOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 22,\n    expectMoreResults: false,\n  },\n];\n\nexport const USER_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 8,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 16,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 8,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 8,\n    operator: hasAllOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 8,\n    operator: hasAnyOf.value,\n    queryValue: [Me],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 8,\n    operator: hasAnyOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 8,\n    operator: isExactly.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 5,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 8,\n    operator: hasNoneOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 16,\n    expectMoreResults: false,\n  },\n];\n\nexport const MULTIPLE_USER_LOOKUP_FIELD_CASES = [\n  {\n    fieldIndex: 10,\n    operator: isEmpty.value,\n    queryValue: null,\n    expectResultLength: 14,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 10,\n    operator: isNotEmpty.value,\n    queryValue: null,\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 10,\n    operator: hasAnyOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 10,\n    operator: hasAnyOf.value,\n    queryValue: [Me],\n    expectResultLength: 5,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 10,\n    operator: hasAllOf.value,\n    queryValue: ['usrTestUserId_1'],\n    expectResultLength: 7,\n    expectMoreResults: false,\n  },\n  {\n    fieldIndex: 10,\n    operator: isExactly.value,\n    queryValue: ['usrTestUserId', 'usrTestUserId_1'],\n    expectResultLength: 5,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 10,\n    operator: isNotExactly.value,\n    queryValue: ['usrTestUserId', 'usrTestUserId_1'],\n    expectResultLength: 16,\n    expectMoreResults: true,\n  },\n  {\n    fieldIndex: 10,\n    operator: hasNoneOf.value,\n    queryValue: ['usrTestUserId'],\n    expectResultLength: 16,\n    expectMoreResults: false,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/data-helpers/caces/view-default-share-meta.ts",
    "content": "import { ViewType } from '@teable/core';\nimport type { IShareViewMeta } from '@teable/core';\n\nexport const VIEW_DEFAULT_SHARE_META: {\n  viewType: ViewType;\n  defaultShareMeta?: IShareViewMeta;\n}[] = [\n  {\n    viewType: ViewType.Form,\n    defaultShareMeta: {\n      submit: {\n        allow: true,\n      },\n    },\n  },\n  {\n    viewType: ViewType.Kanban,\n    defaultShareMeta: {\n      includeRecords: true,\n    },\n  },\n  {\n    viewType: ViewType.Gallery,\n    defaultShareMeta: {\n      includeRecords: true,\n    },\n  },\n  {\n    viewType: ViewType.Grid,\n    defaultShareMeta: {\n      includeRecords: true,\n    },\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/db-connection.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport type { IDbConnectionVo } from '@teable/openapi';\nimport {\n  createDbConnection as apiCreateDbConnection,\n  deleteDbConnection as apiDeleteDbConnection,\n  getDbConnection as apiGetDbConnection,\n} from '@teable/openapi';\nimport { initApp } from './utils/init-app';\n\ndescribe.skip('OpenAPI Db Connection (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it.skipIf(globalThis.testConfig.driver !== DriverClient.Pg)(\n    'should manage a db connection',\n    async () => {\n      console.log('PUBLIC_DATABASE_PROXY', process.env.PUBLIC_DATABASE_PROXY);\n\n      const postResult = (await apiCreateDbConnection(baseId)).data as IDbConnectionVo;\n      expect(postResult.url).toEqual(expect.stringContaining('postgresql://'));\n      expect(postResult.dsn.driver).toEqual('postgresql');\n\n      const getResult = (await apiGetDbConnection(baseId)).data as IDbConnectionVo;\n      expect(getResult.url).toEqual(postResult.url);\n      expect(getResult.dsn).toEqual(postResult.dsn);\n\n      expect((await apiDeleteDbConnection(baseId)).status).toEqual(200);\n      const result = (await apiGetDbConnection(baseId)).data;\n      expect(result).to.be.oneOf([undefined, '', {}]);\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/dead-lock.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILookupOptionsRo } from '@teable/core';\nimport { DriverClient, FieldType, Relationship } from '@teable/core';\nimport { Prisma, PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { retryOnDeadlock } from '../src/utils/retry-decorator';\nimport {\n  createBase,\n  createField,\n  createRecords,\n  createSpace,\n  createTable,\n  deleteBase,\n  deleteSpace,\n  getField,\n  initApp,\n  permanentDeleteBase,\n  permanentDeleteSpace,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst deadLockTableA = 'dead_lock_a';\nconst deadLockTableB = 'dead_lock_b';\nconst deadLockTableARecordId = 'dead_lock_a_record_id';\nconst deadLockTableBRecordId = 'dead_lock_b_record_id';\n\nclass DeadLockService {\n  async transaction1(prismaService: PrismaService) {\n    await prismaService.$transaction(\n      async (tx) => {\n        await tx.$executeRawUnsafe(`\n          UPDATE ${deadLockTableA} SET name = 'A1' WHERE id = '${deadLockTableARecordId}'\n        `);\n\n        await new Promise((resolve) => setTimeout(resolve, 1000));\n\n        await tx.$executeRawUnsafe(`\n          UPDATE ${deadLockTableB} SET name = 'B1' WHERE id = '${deadLockTableBRecordId}'\n          `);\n      },\n      {\n        timeout: 5000,\n      }\n    );\n  }\n\n  async transaction2(prismaService: PrismaService) {\n    await prismaService.$transaction(\n      async (tx) => {\n        await tx.$executeRawUnsafe(`\n          UPDATE ${deadLockTableB} SET name = 'B2' WHERE id = '${deadLockTableBRecordId}'\n        `);\n\n        await new Promise((resolve) => setTimeout(resolve, 1000));\n\n        await tx.$executeRawUnsafe(`\n          UPDATE ${deadLockTableA} SET name = 'A2' WHERE id = '${deadLockTableARecordId}'\n        `);\n      },\n      {\n        timeout: 5000,\n      }\n    );\n  }\n\n  @retryOnDeadlock()\n  async retryTransaction1(prismaService: PrismaService) {\n    await this.transaction1(prismaService);\n  }\n\n  @retryOnDeadlock()\n  async retryTransaction2(prismaService: PrismaService) {\n    await this.transaction2(prismaService);\n  }\n\n  async createDeadlock(prismaService: PrismaService) {\n    await Promise.all([this.transaction1(prismaService), this.transaction2(prismaService)]);\n  }\n\n  async createDeadlockWithRetry(prismaService: PrismaService) {\n    await Promise.all([\n      this.retryTransaction1(prismaService),\n      this.retryTransaction2(prismaService),\n    ]);\n  }\n}\n\ndescribe.skipIf(globalThis.testConfig.driver !== DriverClient.Pg)('DeadLock', () => {\n  let app: INestApplication;\n  let prismaService: PrismaService;\n  const deadLockService = new DeadLockService();\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prismaService = app.get(PrismaService);\n    await prismaService.$executeRawUnsafe(`\n      CREATE TABLE ${deadLockTableA} (\n        id VARCHAR(255) PRIMARY KEY,\n        name VARCHAR(255) NOT NULL\n      )\n    `);\n    await prismaService.$executeRawUnsafe(`\n      INSERT INTO ${deadLockTableA} (id, name) VALUES ('${deadLockTableARecordId}', 'A')\n    `);\n    await prismaService.$executeRawUnsafe(`\n      CREATE TABLE ${deadLockTableB} (\n        id VARCHAR(255) PRIMARY KEY,\n        name VARCHAR(255) NOT NULL\n      )\n    `);\n    await prismaService.$executeRawUnsafe(`\n      INSERT INTO ${deadLockTableB} (id, name) VALUES ('${deadLockTableBRecordId}', 'B')\n    `);\n  });\n\n  afterAll(async () => {\n    await prismaService.$executeRawUnsafe(`\n      DROP TABLE ${deadLockTableA}\n    `);\n    await prismaService.$executeRawUnsafe(`\n      DROP TABLE ${deadLockTableB}\n    `);\n    await app.close();\n  });\n\n  it('should throw error when dead lock', async () => {\n    const result = await new Promise((resolve) => {\n      deadLockService\n        .createDeadlock(prismaService)\n        .then(resolve)\n        .catch((e) => {\n          resolve(e);\n        });\n    });\n    expect(result).toBeInstanceOf(Prisma.PrismaClientKnownRequestError);\n    expect((result as Prisma.PrismaClientKnownRequestError).meta?.code).toBe('40P01');\n  });\n\n  it('should retry when dead lock', async () => {\n    await deadLockService.createDeadlockWithRetry(prismaService);\n  });\n\n  describe('record updates via API', () => {\n    let spaceId: string;\n    let baseId: string;\n    let tableA: ITableFullVo;\n    let tableB: ITableFullVo;\n\n    beforeEach(async () => {\n      const space = await createSpace({ name: `deadlock-space-${Date.now()}` });\n      spaceId = space.id;\n      const base = await createBase({ name: `deadlock-base-${Date.now()}`, spaceId });\n      baseId = base.id;\n      tableA = await createTable(baseId, { name: 'deadlock-table-a' });\n      tableB = await createTable(baseId, { name: 'deadlock-table-b' });\n    });\n\n    afterEach(async () => {\n      if (baseId && tableA) {\n        await permanentDeleteTable(baseId, tableA.id);\n      }\n      if (baseId && tableB) {\n        await permanentDeleteTable(baseId, tableB.id);\n      }\n      if (baseId) {\n        await deleteBase(baseId);\n        await permanentDeleteBase(baseId);\n      }\n      if (spaceId) {\n        await deleteSpace(spaceId);\n        await permanentDeleteSpace(spaceId);\n      }\n    });\n\n    it('should avoid deadlock when cross-table lookups recompute concurrently', async () => {\n      const alphaTextField = await createField(tableA.id, {\n        name: 'alpha-text',\n        type: FieldType.SingleLineText,\n      });\n      const betaTextField = await createField(tableB.id, {\n        name: 'beta-text',\n        type: FieldType.SingleLineText,\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'alpha-to-beta',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: tableB.id,\n        },\n      };\n      const linkFieldA = await createField(tableA.id, linkFieldRo);\n      const symmetricFieldId = (linkFieldA.options as { symmetricFieldId?: string })\n        .symmetricFieldId;\n      expect(symmetricFieldId).toBeTruthy();\n      const linkFieldB = await getField(tableB.id, symmetricFieldId as string);\n\n      const lookupOnA = await createField(tableA.id, {\n        name: 'beta-lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableB.id,\n          linkFieldId: linkFieldA.id,\n          lookupFieldId: betaTextField.id,\n        } as ILookupOptionsRo,\n      });\n      expect(lookupOnA).toBeDefined();\n\n      const lookupOnB = await createField(tableB.id, {\n        name: 'alpha-lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableA.id,\n          linkFieldId: linkFieldB.id,\n          lookupFieldId: alphaTextField.id,\n        } as ILookupOptionsRo,\n      });\n      expect(lookupOnB).toBeDefined();\n\n      const alphaRecords = await createRecords(tableA.id, {\n        records: [{ fields: { [alphaTextField.id]: 'Alpha initial' } }],\n      });\n      const betaRecords = await createRecords(tableB.id, {\n        records: [{ fields: { [betaTextField.id]: 'Beta initial' } }],\n      });\n      const alphaRecordId = alphaRecords.records[0].id;\n      const betaRecordId = betaRecords.records[0].id;\n\n      await updateRecordByApi(tableA.id, alphaRecordId, linkFieldA.id, [{ id: betaRecordId }]);\n\n      const iterations = 5;\n      for (let i = 0; i < iterations; i++) {\n        const alphaValue = `alpha-updated-${i}-${Date.now()}`;\n        const betaValue = `beta-updated-${i}-${Date.now()}`;\n        const results = await Promise.allSettled([\n          updateRecordByApi(tableA.id, alphaRecordId, alphaTextField.id, alphaValue),\n          updateRecordByApi(tableB.id, betaRecordId, betaTextField.id, betaValue),\n        ]);\n        const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');\n        expect(rejected).toHaveLength(0);\n      }\n    }, 20000);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/delete-field.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { convertField } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  deleteField,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('OpenAPI delete field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let prisma: PrismaService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('basic delete field tests', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Delete Field Test Table',\n        fields: [\n          {\n            name: 'Primary Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Text Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Number Field',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Primary Field': 'Record 1',\n              'Text Field': 'Text 1',\n              'Number Field': 100,\n            },\n          },\n          {\n            fields: {\n              'Primary Field': 'Record 2',\n              'Text Field': 'Text 2',\n              'Number Field': 200,\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('should delete a simple text field', async () => {\n      const textField = table.fields.find((f) => f.name === 'Text Field')!;\n\n      // Delete the field\n      await deleteField(table.id, textField.id);\n\n      // Verify field is marked as deleted in database\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: textField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n\n      // Verify records can still be retrieved\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records.records).toHaveLength(2);\n      expect(records.records[0].fields[textField.id]).toBeUndefined();\n    });\n\n    it('should delete a number field', async () => {\n      const numberField = table.fields.find((f) => f.name === 'Number Field')!;\n\n      // Delete the field\n      await deleteField(table.id, numberField.id);\n\n      // Verify field is marked as deleted in database\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: numberField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n\n      // Verify records can still be retrieved\n      const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records.records).toHaveLength(2);\n      expect(records.records[0].fields[numberField.id]).toBeUndefined();\n    });\n\n    it('should forbid deleting primary field', async () => {\n      const primaryField = table.fields.find((f) => f.name === 'Primary Field')!;\n\n      // Attempt to delete primary field should fail\n      await expect(deleteField(table.id, primaryField.id)).rejects.toMatchObject({\n        status: 403,\n      });\n    });\n  });\n\n  describe('delete field with formula dependencies', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Formula Dependencies Test Table',\n        fields: [\n          {\n            name: 'Primary Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Source Field',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Primary Field': 'Record 1',\n              'Source Field': 'Source 1',\n            },\n          },\n          {\n            fields: {\n              'Primary Field': 'Record 2',\n              'Source Field': 'Source 2',\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('should delete field referenced by formula', async () => {\n      const sourceField = table.fields.find((f) => f.name === 'Source Field')!;\n\n      // Create a formula field that references the source field\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Formula Field',\n        options: {\n          expression: `UPPER({${sourceField.id}})`,\n        },\n      });\n\n      // Verify formula field works\n      const recordsBefore = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsBefore.records[0].fields[formulaField.id]).toBe('SOURCE 1');\n\n      // Delete the source field\n      await deleteField(table.id, sourceField.id);\n\n      // Verify source field is deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: sourceField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n\n      // Verify reference is cleaned up\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { fromFieldId: sourceField.id },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // Verify records can still be retrieved\n      const recordsAfter = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsAfter.records).toHaveLength(2);\n    });\n  });\n\n  describe('special case: primary field converted to formula', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Primary Formula Test Table',\n        fields: [\n          {\n            name: 'Primary Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Reference Field 1',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Reference Field 2',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Primary Field': 'Original Primary 1',\n              'Reference Field 1': 'Ref1 Value 1',\n              'Reference Field 2': 'Ref2 Value 1',\n            },\n          },\n          {\n            fields: {\n              'Primary Field': 'Original Primary 2',\n              'Reference Field 1': 'Ref1 Value 2',\n              'Reference Field 2': 'Ref2 Value 2',\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('should handle deleting referenced field when primary field is converted to formula', async () => {\n      const primaryField = table.fields.find((f) => f.name === 'Primary Field')!;\n      const referenceField1 = table.fields.find((f) => f.name === 'Reference Field 1')!;\n      const referenceField2 = table.fields.find((f) => f.name === 'Reference Field 2')!;\n\n      // Create a formula field that references both reference fields\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Helper Formula',\n        options: {\n          expression: `CONCATENATE({${referenceField1.id}}, \" - \", {${referenceField2.id}})`,\n        },\n      });\n\n      // Verify the formula field works\n      const recordsBeforeConvert = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsBeforeConvert.records[0].fields[formulaField.id]).toBe(\n        'Ref1 Value 1 - Ref2 Value 1'\n      );\n\n      // Convert primary field to formula that references the helper formula\n      await convertField(table.id, primaryField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `UPPER({${formulaField.id}})`,\n        },\n      });\n\n      // Verify primary field is now a formula\n      const recordsAfterConvert = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsAfterConvert.records[0].fields[primaryField.id]).toBe(\n        'REF1 VALUE 1 - REF2 VALUE 1'\n      );\n      expect(recordsAfterConvert.records[1].fields[primaryField.id]).toBe(\n        'REF1 VALUE 2 - REF2 VALUE 2'\n      );\n\n      // Now delete the reference field that the helper formula depends on\n      await deleteField(table.id, referenceField2.id);\n\n      // Verify the reference field is deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: referenceField2.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n\n      // Verify references are cleaned up\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { fromFieldId: referenceField2.id },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // Most importantly: verify that the primary field still exists and records can be retrieved\n      const recordsAfterDelete = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsAfterDelete.records).toHaveLength(2);\n\n      // The primary field should still be accessible (even if its formula is broken)\n      expect(recordsAfterDelete.records[0].fields[primaryField.id]).toBeUndefined();\n      expect(recordsAfterDelete.records[1].fields[primaryField.id]).toBeUndefined();\n\n      // Verify the primary field still exists in the database\n      const primaryFieldRaw = await prisma.field.findUnique({\n        where: { id: primaryField.id },\n      });\n      expect(primaryFieldRaw?.deletedTime).toBeFalsy();\n      expect(primaryFieldRaw?.isPrimary).toBe(true);\n    });\n\n    it('should handle complex formula chain when deleting intermediate field', async () => {\n      const primaryField = table.fields.find((f) => f.name === 'Primary Field')!;\n      const referenceField1 = table.fields.find((f) => f.name === 'Reference Field 1')!;\n\n      // Create a chain: referenceField1 -> intermediateFormula -> primaryField (converted to formula)\n      const intermediateFormula = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Intermediate Formula',\n        options: {\n          expression: `UPPER({${referenceField1.id}})`,\n        },\n      });\n\n      // Convert primary field to reference the intermediate formula\n      await convertField(table.id, primaryField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `CONCATENATE(\"Primary: \", {${intermediateFormula.id}})`,\n        },\n      });\n\n      // Verify the chain works\n      const recordsBeforeDelete = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsBeforeDelete.records[0].fields[primaryField.id]).toBe('Primary: REF1 VALUE 1');\n\n      // Delete the intermediate formula field\n      await deleteField(table.id, intermediateFormula.id);\n\n      // Verify intermediate formula is deleted\n      const intermediateFieldRaw = await prisma.field.findUnique({\n        where: { id: intermediateFormula.id },\n      });\n      expect(intermediateFieldRaw?.deletedTime).toBeTruthy();\n\n      // Verify references are cleaned up\n      const referenceAfter = await prisma.reference.findFirst({\n        where: {\n          OR: [{ fromFieldId: intermediateFormula.id }, { toFieldId: intermediateFormula.id }],\n        },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // Most importantly: verify primary field still exists and table is accessible\n      const recordsAfterDelete = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordsAfterDelete.records).toHaveLength(2);\n\n      // Primary field should still exist even if its formula is broken\n      const primaryFieldRaw = await prisma.field.findUnique({\n        where: { id: primaryField.id },\n      });\n      expect(primaryFieldRaw?.deletedTime).toBeFalsy();\n      expect(primaryFieldRaw?.isPrimary).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/duplicate-field-transaction.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { ClsService } from 'nestjs-cls';\nimport type { MockInstance } from 'vitest';\nimport { vi } from 'vitest';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { FieldOpenApiService } from '../src/features/field/open-api/field-open-api.service';\nimport type { IClsStore } from '../src/types/cls';\nimport { getError } from './utils/get-error';\nimport {\n  createBase,\n  createTable,\n  initApp,\n  permanentDeleteBase,\n  runWithTestUser,\n} from './utils/init-app';\n\ndescribe('Field duplicate transaction (e2e)', () => {\n  let app: INestApplication;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('rolls back duplicateField when post-create steps fail', async () => {\n    const prismaService = app.get(PrismaService);\n    const fieldOpenApiService = app.get(FieldOpenApiService);\n    const clsService = app.get<ClsService<IClsStore>>(ClsService);\n    const dbProvider = app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n\n    const base = await createBase({\n      spaceId: globalThis.testConfig.spaceId,\n      name: 'duplicate-field-tx',\n    });\n\n    let duplicateSpy: MockInstance | undefined;\n    try {\n      const foreignTable = await createTable(base.id, {\n        name: 'foreign',\n      });\n      const hostTable = await createTable(base.id, {\n        name: 'host',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      const foreignNameField = await runWithTestUser(clsService, () =>\n        fieldOpenApiService.createField(foreignTable.id, {\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        })\n      );\n\n      const linkField = await runWithTestUser(clsService, () =>\n        fieldOpenApiService.createField(hostTable.id, {\n          name: 'Link',\n          type: FieldType.Link,\n          options: {\n            baseId: base.id,\n            foreignTableId: foreignTable.id,\n            relationship: Relationship.ManyMany,\n          },\n        })\n      );\n\n      const lookupField = await runWithTestUser(clsService, () =>\n        fieldOpenApiService.createField(hostTable.id, {\n          name: 'Lookup name',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreignTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: foreignNameField.id,\n          },\n        })\n      );\n\n      await runWithTestUser(clsService, () =>\n        fieldOpenApiService.createField(hostTable.id, {\n          name: 'Lookup length',\n          type: FieldType.Formula,\n          options: {\n            expression: `LEN({${lookupField.id}})`,\n          },\n        })\n      );\n\n      const tableMeta = await prismaService.tableMeta.findUniqueOrThrow({\n        where: { id: hostTable.id },\n        select: { dbTableName: true },\n      });\n\n      const getColumns = async () =>\n        (\n          await prismaService.$queryRawUnsafe<{ name: string }[]>(\n            dbProvider.columnInfo(tableMeta.dbTableName)\n          )\n        )\n          .map(({ name }) => name)\n          .sort();\n\n      const columnsBefore = await getColumns();\n      const fieldCountBefore = await prismaService.field.count({\n        where: { tableId: hostTable.id, deletedTime: null },\n      });\n\n      duplicateSpy = vi\n        .spyOn(fieldOpenApiService, 'duplicateFieldData')\n        .mockImplementationOnce(async () => {\n          throw new Error('force-duplicate-failure');\n        });\n\n      const error = await getError(() =>\n        runWithTestUser(clsService, () =>\n          fieldOpenApiService.duplicateField(hostTable.id, linkField.id, {\n            name: 'Link Copy',\n          })\n        )\n      );\n\n      expect(error?.message).toBe('force-duplicate-failure');\n\n      const fieldCountAfter = await prismaService.field.count({\n        where: { tableId: hostTable.id, deletedTime: null },\n      });\n      expect(fieldCountAfter).toBe(fieldCountBefore);\n\n      const columnsAfter = await getColumns();\n      expect(columnsAfter).toEqual(columnsBefore);\n\n      const copiedField = await prismaService.field.findFirst({\n        where: { tableId: hostTable.id, name: 'Link Copy', deletedTime: null },\n      });\n      expect(copiedField).toBeNull();\n    } finally {\n      duplicateSpy?.mockRestore();\n      await permanentDeleteBase(base.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-calculation.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldType, NumberFormattingType } from '@teable/core';\nimport type { IRecordsVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getFields,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\nimport { seeding } from './utils/record-mock';\n\ndescribe('OpenAPI Field calculation (e2e)', () => {\n  let app: INestApplication;\n  let tableId = '';\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    tableId = (await createTable(baseId, { name: 'table1' })).id;\n\n    await seeding(tableId, 1000);\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n    await app.close();\n  });\n\n  it('should calculate when add a non-reference formula field', async () => {\n    const fieldRo: IFieldRo = {\n      name: 'New formula field',\n      type: FieldType.Formula,\n      options: {\n        expression: '1 + 1',\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        },\n      },\n    };\n\n    const fieldVo: IFieldVo = await createField(tableId, fieldRo);\n    const recordsVo: IRecordsVo = await getRecords(tableId);\n\n    const equal = recordsVo.records.every((record) => record.fields[fieldVo.name] === 2);\n    expect(equal).toBeTruthy();\n  });\n\n  it('should calculate when add a referenced formula field', async () => {\n    const fieldsVo = await getFields(tableId);\n    const recordsVo = await getRecords(tableId);\n\n    await updateRecordByApi(tableId, recordsVo.records[0].id, fieldsVo[0].id, 'A1');\n    await updateRecordByApi(tableId, recordsVo.records[1].id, fieldsVo[0].id, 'A2');\n    await updateRecordByApi(tableId, recordsVo.records[2].id, fieldsVo[0].id, 'A3');\n\n    const fieldRo: IFieldRo = {\n      name: 'New formula field',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${fieldsVo[0].id}}`,\n      },\n    };\n\n    const fieldVo: IFieldVo = await createField(tableId, fieldRo);\n    const recordsVoAfter = await getRecords(tableId);\n\n    expect(recordsVoAfter.records[0].fields[fieldVo.name]).toEqual('A1');\n    expect(recordsVoAfter.records[1].fields[fieldVo.name]).toEqual('A2');\n    expect(recordsVoAfter.records[2].fields[fieldVo.name]).toEqual('A3');\n  });\n\n  it('should create formula referencing text * 2 and compute via numeric coercion', async () => {\n    // Create an isolated table to avoid interference with seeded data\n    const t = await createTable(baseId, {\n      name: 'text-mul',\n      fields: [{ name: 'T', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { T: '3' } }],\n    });\n\n    const textId = t.fields.find((f) => f.name === 'T')!.id;\n\n    // Create formula that multiplies text by 2; should succeed and coerce to number\n    const f = await createField(t.id, {\n      name: 'Mul2',\n      type: FieldType.Formula,\n      options: { expression: `{${textId}} * 2` },\n    } as IFieldRo);\n\n    const recs = await getRecords(t.id);\n    expect(recs.records[0].fields[f.name]).toBe(6);\n\n    await permanentDeleteTable(baseId, t.id);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-converting.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IButtonFieldCellValue,\n  IButtonFieldOptions,\n  IConditionalLookupOptions,\n  IConditionalRollupFieldOptions,\n  IFieldRo,\n  IFieldVo,\n  ILinkFieldOptions,\n  ILookupOptionsRo,\n  IRecord,\n  IRollupFieldOptions,\n  ISelectFieldOptions,\n  ITextFieldAIConfig,\n  IUserCellValue,\n} from '@teable/core';\nimport {\n  Relationship,\n  TimeFormatting,\n  DbFieldType,\n  Colors,\n  CellValueType,\n  FieldType,\n  NumberFormattingType,\n  SortFunc,\n  RatingIcon,\n  defaultDatetimeFormatting,\n  FieldKeyType,\n  SingleLineTextDisplayType,\n  DateFormattingPreset,\n  generateFieldId,\n  DriverClient,\n  CellFormat,\n  FieldAIActionType,\n  generateWorkflowId,\n  Role as baseRole,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IUserMeVo, ITableFullVo } from '@teable/openapi';\nimport {\n  axios,\n  emailBaseInvitation,\n  USER_ME,\n  buttonClick,\n  deleteBaseCollaborator,\n  PrincipalType,\n  X_CANARY_HEADER,\n} from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { FieldService } from '../src/features/field/field.service';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport {\n  getRecords,\n  createField,\n  createRecords,\n  getField,\n  getRecord,\n  initApp,\n  convertField,\n  deleteRecord,\n  updateRecordByApi,\n  createTable,\n  permanentDeleteTable,\n  deleteRecords,\n} from './utils/init-app';\n\ndescribe('OpenAPI Freely perform column transformations (e2e)', () => {\n  const canRunCanaryV2 =\n    process.env.FORCE_V2_ALL === 'true' || process.env.ENABLE_CANARY_FEATURE === 'true';\n  let app: INestApplication;\n  let table1: ITableFullVo;\n  let table2: ITableFullVo;\n  let table3: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n  let dbProvider: IDbProvider;\n  let prisma: PrismaService;\n  let fieldService: FieldService;\n  let knex: Knex;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    dbProvider = appCtx.app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n    prisma = appCtx.app.get<PrismaService>(PrismaService);\n    fieldService = appCtx.app.get<FieldService>(FieldService);\n    knex = appCtx.app.get('CUSTOM_KNEX');\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const bfAf = () => {\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n      table3 = await createTable(baseId, { name: 'table3' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n      await permanentDeleteTable(baseId, table3.id);\n    });\n  };\n\n  async function expectUpdate(\n    table: ITableFullVo,\n    sourceFieldRo: IFieldRo,\n    newFieldRo: IFieldRo,\n    values: unknown[] = [],\n    createdCallback?: (newField: IFieldVo) => Promise<void>,\n    appendBlankRow?: number\n  ) {\n    const sourceField = await createField(table.id, sourceFieldRo);\n    await createdCallback?.(sourceField);\n    if (appendBlankRow) {\n      const records = [];\n      for (let i = 0; i < appendBlankRow; i++) {\n        records.push({ fields: {} });\n      }\n      const createData = await createRecords(table.id, { records });\n      table.records.push(...createData.records);\n    }\n\n    for (const i in values) {\n      const value = values[i];\n      value != null &&\n        (await updateRecordByApi(table.id, table.records[i].id, sourceField.id, value));\n    }\n    await convertField(table.id, sourceField.id, newFieldRo);\n    const newField = await getField(table.id, sourceField.id);\n    const records: IRecord[] = [];\n    for (let i = 0; i < values.length; i++) {\n      const record = await getRecord(table.id, table.records[i].id);\n      records.push(record);\n    }\n\n    const result = records.map((record) => record.fields[newField.id]);\n    return {\n      newField,\n      sourceField,\n      values: result,\n      records,\n    };\n  }\n\n  async function convertFieldByCanaryV2(tableId: string, fieldId: string, fieldRo: IFieldRo) {\n    const res = await axios.put<IFieldVo>(`/table/${tableId}/field/${fieldId}/convert`, fieldRo, {\n      headers: {\n        [X_CANARY_HEADER]: 'true',\n      },\n    });\n\n    expect(res.status).toEqual(200);\n    expect(res.headers['x-teable-v2']).toEqual('true');\n    return res.data;\n  }\n\n  describe('modify general property', () => {\n    bfAf();\n    it('should modify field name and prevent name duplicate', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'New Name',\n        type: FieldType.SingleLineText,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.name).toEqual('New Name');\n      expect(newField.description).toEqual('hello');\n\n      await expect(\n        convertField(table1.id, table1.fields[0].id, {\n          name: 'New Name',\n          type: FieldType.SingleLineText,\n        })\n      ).rejects.toThrow();\n    });\n\n    it('should modify ai config', async () => {\n      const baseField = await createField(table1.id, { type: FieldType.SingleLineText }, 201);\n      const oldAIConfig: ITextFieldAIConfig = {\n        type: FieldAIActionType.Summary,\n        modelKey: 'openai@gpt-4o@gpt',\n        sourceFieldId: baseField.id,\n      };\n      const newAIConfig: ITextFieldAIConfig = {\n        ...oldAIConfig,\n        type: FieldAIActionType.Extraction,\n        attachPrompt: 'Please extract the email from the text',\n      };\n\n      const sourceFieldRo: IFieldRo = {\n        name: 'AITextField',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n        aiConfig: oldAIConfig,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'New AITextField',\n        type: FieldType.SingleLineText,\n        aiConfig: newAIConfig,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.aiConfig).toEqual(newAIConfig);\n    });\n\n    it('should modify options showAs', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n        options: {\n          showAs: {\n            type: SingleLineTextDisplayType.Email,\n          },\n        },\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'New Name',\n        type: FieldType.SingleLineText,\n        options: {},\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.options).toEqual({});\n    });\n\n    it('should modify options showAs in formula', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.Formula,\n        options: {\n          expression: '\"text\"',\n          showAs: {\n            type: SingleLineTextDisplayType.Email,\n          },\n        },\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: '\"text\"',\n        },\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.options).toMatchObject({\n        expression: '\"text\"',\n      });\n      expect((newField.options as { timeZone?: string }).timeZone?.toLowerCase()).toEqual(\n        Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase()\n      );\n    });\n\n    it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n      'should modify field validation',\n      async () => {\n        const sourceFieldRo: IFieldRo = {\n          name: 'TextField',\n          type: FieldType.SingleLineText,\n        };\n        const uniqueFieldRo: IFieldRo = {\n          ...sourceFieldRo,\n          unique: true,\n        };\n        const notNullFieldRo: IFieldRo = {\n          ...sourceFieldRo,\n          unique: false,\n          notNull: true,\n        };\n\n        const table2Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n        await deleteRecords(\n          table1.id,\n          table2Records.records.map((record) => record.id)\n        );\n\n        const sourceField = await createField(table1.id, sourceFieldRo);\n        const { records } = await createRecords(table1.id, {\n          records: [\n            {\n              fields: {\n                [sourceField.id]: '100',\n              },\n            },\n            {\n              fields: {\n                [sourceField.id]: '100',\n              },\n            },\n            {\n              fields: {},\n            },\n          ],\n        });\n\n        await convertField(table1.id, sourceField.id, uniqueFieldRo, 400);\n\n        await deleteRecord(table1.id, records[1].id);\n\n        await convertField(table1.id, sourceField.id, uniqueFieldRo);\n\n        await convertField(table1.id, sourceField.id, notNullFieldRo, 400);\n\n        await deleteRecord(table1.id, records[2].id);\n\n        await convertField(table1.id, sourceField.id, notNullFieldRo);\n      }\n    );\n\n    it('should modify attachment field name', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.Attachment,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'New Name',\n        type: FieldType.Attachment,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.name).toEqual('New Name');\n    });\n\n    it('should modify db field name', async () => {\n      const dbFieldName = generateFieldId();\n      const sourceFieldRo1: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        dbFieldName: dbFieldName,\n        type: FieldType.SingleLineText,\n      };\n\n      const field = await createField(table1.id, sourceFieldRo1);\n      expect(field.dbFieldName).toEqual(dbFieldName);\n\n      await createField(table1.id, sourceFieldRo1, 400);\n\n      const sourceFieldRo2: IFieldRo = {\n        name: 'TextField 2',\n        description: 'hello',\n        dbFieldName: dbFieldName + '2',\n        type: FieldType.SingleLineText,\n      };\n\n      const newFieldRo: IFieldRo = {\n        dbFieldName: generateFieldId(),\n        type: FieldType.SingleLineText,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo2, newFieldRo);\n      expect(newField.dbFieldName).toEqual(newFieldRo.dbFieldName);\n      expect(newField.name).toEqual('TextField 2');\n      expect(newField.description).toEqual('hello');\n    });\n\n    it('should modify formula field name', async () => {\n      const formulaFieldRo: IFieldRo = {\n        name: 'formulaField',\n        type: FieldType.Formula,\n        options: {\n          expression: '1+1',\n        },\n      };\n\n      const formulaFieldRo2: IFieldRo = {\n        name: 'new FormulaField',\n        type: FieldType.Formula,\n        options: {\n          expression: '1+1',\n        },\n      };\n\n      const { newField } = await expectUpdate(table1, formulaFieldRo, formulaFieldRo2);\n      expect(newField.name).toEqual('new FormulaField');\n    });\n\n    it.each([{ relationship: Relationship.OneOne }])(\n      'should modify $relationship link field name',\n      async ({ relationship }) => {\n        const linkFieldRo: IFieldRo = {\n          name: 'linkField',\n          type: FieldType.Link,\n          options: {\n            relationship,\n            foreignTableId: table2.id,\n          },\n        };\n\n        const linkFieldRo2: IFieldRo = {\n          name: 'other name',\n          type: FieldType.Link,\n          options: {\n            relationship,\n            foreignTableId: table2.id,\n          },\n        };\n\n        const linkField = await createField(table1.id, linkFieldRo);\n        await updateRecordByApi(\n          table1.id,\n          table1.records[0].id,\n          linkField.id,\n          linkField.isMultipleCellValue\n            ? [\n                {\n                  id: table2.records[0].id,\n                },\n              ]\n            : {\n                id: table2.records[0].id,\n              }\n        );\n        const symField = await getField(\n          table2.id,\n          (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n        );\n        const newField = await convertField(table1.id, linkField.id, linkFieldRo2);\n\n        expect(newField.name).toEqual('other name');\n\n        const { name: _, meta: _newFieldMeta, unique: _newUnique, ...newFieldOthers } = newField;\n        const { name: _0, meta: _oldFieldMeta, unique: _oldUnique, ...oldFieldOthers } = linkField;\n\n        expect(newFieldOthers).toEqual(oldFieldOthers);\n\n        const table2Records = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n        const newSymField = await getField(\n          table2.id,\n          (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n        );\n        expect(symField).toEqual(newSymField);\n        expect(table2Records.records[0].fields[newSymField.id]).toMatchObject(\n          newSymField.isMultipleCellValue\n            ? [{ id: table1.records[0].id }]\n            : { id: table1.records[0].id }\n        );\n      }\n    );\n\n    it.each([{ relationship: Relationship.ManyMany }])(\n      'should modify $relationship symmetric link field name',\n      async ({ relationship }) => {\n        const linkFieldRo: IFieldRo = {\n          name: 'linkField',\n          type: FieldType.Link,\n          options: {\n            relationship,\n            foreignTableId: table2.id,\n          },\n        };\n\n        const linkField = await createField(table1.id, linkFieldRo);\n        const symField = await getField(\n          table2.id,\n          (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n        );\n        await updateRecordByApi(\n          table1.id,\n          table1.records[0].id,\n          linkField.id,\n          linkField.isMultipleCellValue\n            ? [\n                {\n                  id: table2.records[0].id,\n                },\n              ]\n            : {\n                id: table2.records[0].id,\n              }\n        );\n        const newSymField = await convertField(table2.id, symField.id, {\n          ...symField,\n          name: 'other name',\n        });\n\n        expect(newSymField.name).toEqual('other name');\n\n        const { name: _, ...newFieldOthers } = newSymField;\n        const { name: _0, ...oldFieldOthers } = symField;\n\n        expect(newFieldOthers).toEqual(oldFieldOthers);\n\n        const table2Records = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n\n        expect(table2Records.records[0].fields[newSymField.id]).toMatchObject(\n          newSymField.isMultipleCellValue\n            ? [{ id: table1.records[0].id }]\n            : { id: table1.records[0].id }\n        );\n      }\n    );\n\n    it('should modify rollup field name', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'linkField',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      const rollupFieldRo: IFieldRo = {\n        name: 'rollUpField',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const rollupFieldRo2: IFieldRo = {\n        name: 'new rollUpField',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField } = await expectUpdate(table1, rollupFieldRo, rollupFieldRo2);\n      expect(newField.name).toEqual('new rollUpField');\n    });\n\n    it('should modify lookup field name', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'linkField',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'lookupField',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const lookupFieldRo2: IFieldRo = {\n        name: 'new lookupField',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField } = await expectUpdate(table1, lookupFieldRo, lookupFieldRo2);\n      expect(newField.name).toEqual('new lookupField');\n    });\n\n    it('should modify field description', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'my name',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        description: 'world',\n        type: FieldType.SingleLineText,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.name).toEqual('my name');\n      expect(newField.description).toEqual('world');\n    });\n\n    it('should clear field description', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'my name',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        description: null,\n        type: FieldType.SingleLineText,\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.name).toEqual('my name');\n      expect(newField.description).toBeUndefined();\n    });\n\n    // A -> B -> C\n    // D -> E -> C\n    // should not update E when A update\n    // all context: A, B, C, E\n    // update context: A, B, C\n    it('should not update E when A update', async () => {\n      const aField = await createField(table1.id, {\n        type: FieldType.Number,\n      });\n\n      const bField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${aField.id}}`,\n        },\n      });\n\n      const dField = await createField(table1.id, {\n        type: FieldType.Number,\n      });\n\n      const eField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${dField.id}}`,\n        },\n      });\n\n      const cField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${bField.id}} + {${eField.id}}`,\n        },\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, aField.id, 1);\n\n      // convert B field to formula field\n      await convertField(table1.id, bField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${aField.id}} & ''`,\n        },\n      });\n\n      const plusEmptySuffixField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${bField.id}} + ''`,\n        },\n      });\n\n      const plusEmptyPrefixField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `'' + {${bField.id}}`,\n        },\n      });\n\n      const plusNullField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${eField.id}} + ''`,\n        },\n      });\n\n      const record1 = await getRecord(table1.id, table1.records[0].id);\n      expect(record1.fields[cField.id]).toEqual('1');\n      expect(record1.fields[plusEmptySuffixField.id]).toEqual('1');\n      expect(record1.fields[plusEmptyPrefixField.id]).toEqual('1');\n      expect(record1.fields[plusNullField.id]).toEqual('');\n    });\n\n    it('should modify options of button field', async () => {\n      const buttonFieldRo1: IFieldRo = {\n        name: 'buttonField',\n        type: FieldType.Button,\n        options: {\n          label: 'buttonField1',\n          color: Colors.Teal,\n          maxCount: 10,\n          resetCount: true,\n        },\n      };\n\n      const buttonFieldRo2: IFieldRo = {\n        type: FieldType.Button,\n        options: {\n          label: 'buttonField2',\n          color: Colors.Red,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'workflow1',\n            isActive: true,\n          },\n        },\n      };\n      const { newField } = await expectUpdate(table1, buttonFieldRo1, buttonFieldRo2);\n      const options = newField.options as IButtonFieldOptions;\n      const options2 = buttonFieldRo2.options as IButtonFieldOptions;\n      expect(newField.name).toEqual(buttonFieldRo1.name);\n      expect(options).toEqual(options2);\n    });\n  });\n\n  describe('convert text field', () => {\n    bfAf();\n\n    const sourceFieldRo: IFieldRo = {\n      name: 'TextField',\n      type: FieldType.SingleLineText,\n    };\n\n    it('should convert text to number', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        '1',\n        'x',\n      ]);\n      expect(newField).toMatchObject({\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        name: 'TextField',\n        type: FieldType.Number,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should convert text to single select', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'x', color: Colors.Cyan }],\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'y',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        options: {\n          choices: [{ name: 'x', color: Colors.Cyan }, { name: 'y' }],\n        },\n        type: FieldType.SingleSelect,\n      });\n      expect(values[0]).toEqual('x');\n      expect(values[1]).toEqual('y');\n    });\n\n    it('should convert text to multiple select', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { name: 'x', color: Colors.Blue },\n            { name: 'y', color: Colors.Red },\n          ],\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'x, y',\n        'z',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        options: {\n          choices: [\n            { name: 'x', color: Colors.Blue },\n            { name: 'y', color: Colors.Red },\n            { name: 'z' },\n          ],\n        },\n        type: FieldType.MultipleSelect,\n      });\n      expect(values[0]).toEqual(['x']);\n      expect(values[1]).toEqual(['x', 'y']);\n      expect(values[2]).toEqual(['z']);\n    });\n\n    it('should convert text to attachment', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Attachment,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'y',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Attachment,\n      });\n      expect(values[0]).toEqual(undefined);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should convert text to checkbox', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Checkbox,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Boolean,\n        dbFieldType: DbFieldType.Boolean,\n        type: FieldType.Checkbox,\n      });\n      expect(values[0]).toEqual(true);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should not convert primary field to checkbox', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Checkbox,\n      };\n\n      await expect(convertField(table1.id, table1.fields[0].id, newFieldRo)).rejects.toThrow();\n    });\n\n    it('should convert text to date', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: 'M/D/YYYY',\n            time: TimeFormatting.None,\n            timeZone: 'utc',\n          },\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        '2023-08-31T08:32:32',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        type: FieldType.Date,\n      });\n      expect(values[0]).toEqual(undefined);\n      expect(values[1]).toEqual('2023-08-31T08:32:32.000Z');\n    });\n\n    it('should convert text to formula', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: '1',\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Formula,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(1);\n    });\n\n    it('should convert text to auto number', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.AutoNumber,\n        options: {},\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Integer,\n        type: FieldType.AutoNumber,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(2);\n    });\n\n    it('should convert text to created time', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.CreatedTime,\n        options: {\n          formatting: defaultDatetimeFormatting,\n        },\n      };\n      const { newField, values, records } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        type: FieldType.CreatedTime,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual(records[0].createdTime);\n      expect(values[1]).toEqual(records[1].createdTime);\n    });\n\n    it('should convert text to last modified time', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.LastModifiedTime,\n        options: {\n          formatting: defaultDatetimeFormatting,\n        },\n      };\n      const { newField, values, records } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'y',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        type: FieldType.LastModifiedTime,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual(records[0].lastModifiedTime);\n      expect(values[1]).toEqual(records[1].lastModifiedTime);\n    });\n\n    it('should convert text to many-one rollup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add 2 link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual(1);\n    });\n\n    it('should convert text to one-many rollup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'gg');\n      // add 2 link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual(2);\n    });\n  });\n\n  describe('convert long text field', () => {\n    bfAf();\n\n    const sourceFieldRo: IFieldRo = {\n      name: 'LongTextField',\n      type: FieldType.LongText,\n    };\n\n    it('should convert long text to text', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        '1 2 3',\n        'x\\ny\\nz',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        name: 'LongTextField',\n        type: FieldType.SingleLineText,\n      });\n      expect(values[0]).toEqual('1 2 3');\n      expect(values[1]).toEqual('x y z');\n    });\n\n    it('should convert long text to number', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        '1',\n        'x',\n      ]);\n      expect(newField).toMatchObject({\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        name: 'LongTextField',\n        type: FieldType.Number,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should convert long text to single select', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'A', color: Colors.Cyan }],\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'A',\n        'B',\n        'Hello\\nWorld',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleSelect,\n      });\n      expect((newField.options as { choices: { name: string }[] }).choices).toHaveLength(3);\n      expect(values[0]).toEqual('A');\n      expect(values[1]).toEqual('B');\n      expect(values[2]).toEqual('Hello World');\n    });\n\n    it('should convert long text to multiple select', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { name: 'x', color: Colors.Blue },\n            { name: 'y', color: Colors.Red },\n            { name: \"','\", color: Colors.Gray },\n            { name: ', ', color: Colors.Red },\n          ],\n        },\n      };\n      const { newField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        ['x', 'x, y', 'x\\nz', `x, \"','\"`, `x, y, \", \"`, `\"','\", \", \"`],\n        undefined,\n        3\n      );\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.MultipleSelect,\n      });\n\n      // Check that all expected choices are present (order and additional properties may vary)\n      const choices = (\n        newField.options as { choices: { name: string; color: string; id: string }[] }\n      ).choices;\n      const choiceNames = choices.map((choice) => choice.name);\n\n      // Check for expected choice names (allowing for variations in parsing)\n      expect(choiceNames).toContain('x');\n      expect(choiceNames).toContain('y');\n      expect(choiceNames).toContain(\"','\");\n      expect(choiceNames).toContain('z');\n\n      // Check for comma-related choices (could be \",\" or \", \" depending on parsing)\n      const hasCommaChoice = choiceNames.some((name) => name === ',' || name === ', ');\n      expect(hasCommaChoice).toBe(true);\n\n      // Check that the predefined choices maintain their colors\n      const xChoice = choices.find((choice) => choice.name === 'x');\n      const yChoice = choices.find((choice) => choice.name === 'y');\n      expect(xChoice?.color).toBe(Colors.Blue);\n      expect(yChoice?.color).toBe(Colors.Red);\n      expect(values[0]).toEqual(['x']);\n      expect(values[1]).toEqual(['x', 'y']);\n      expect(values[2]).toEqual(['x', 'z']);\n      expect(values[3]).toEqual(['x', \"','\"]);\n      // Allow for variations in comma parsing (could be \",\" or \", \")\n      expect(values[4]).toEqual(expect.arrayContaining(['x', 'y']));\n      expect(values[4]).toEqual(expect.arrayContaining([expect.stringMatching(/^,\\s?$/)]));\n      expect(values[5]).toEqual(expect.arrayContaining([\"','\"]));\n      expect(values[5]).toEqual(expect.arrayContaining([expect.stringMatching(/^,\\s?$/)]));\n    });\n\n    it('should convert long text to attachment', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Attachment,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'x\\ny',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Attachment,\n      });\n      expect(values[0]).toEqual(undefined);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should convert long text to checkbox', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Checkbox,\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Boolean,\n        dbFieldType: DbFieldType.Boolean,\n        type: FieldType.Checkbox,\n      });\n      expect(values[0]).toEqual(true);\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should convert long text to date', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: 'M/D/YYYY',\n            time: TimeFormatting.None,\n            timeZone: 'utc',\n          },\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        '2023-08-31T08:32:32',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        type: FieldType.Date,\n      });\n      expect(values[0]).toEqual(undefined);\n      expect(values[1]).toEqual('2023-08-31T08:32:32.000Z');\n    });\n\n    it('should convert long text to formula', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: '1',\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Formula,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(1);\n    });\n\n    it('should convert long text to many-one rollup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add 2 link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual(1);\n    });\n\n    it('should convert long text to one-many rollup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'gg');\n      // add 2 link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual(2);\n    });\n  });\n\n  describe('convert select field', () => {\n    bfAf();\n\n    it('should convert the dbFieldName and name with options change', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n        dbFieldName: 'selectDbFieldName',\n        name: 'selectFieldName',\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ id: 'choX', name: 'x', color: Colors.Cyan }],\n        },\n        dbFieldName: 'convertSelectDbFieldName',\n        name: 'convertSelectFieldName',\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField.dbFieldName).toEqual('convertSelectDbFieldName');\n      expect(newField.name).toEqual('convertSelectFieldName');\n    });\n\n    it('should convert select to number', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n        type: FieldType.Number,\n      });\n      expect(values[0]).toEqual(undefined);\n    });\n\n    it('should change choices for single select', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ id: 'choX', name: 'xx', color: Colors.Gray }],\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x',\n        'y',\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        options: {\n          choices: [{ name: 'xx', color: Colors.Gray }],\n        },\n        type: FieldType.SingleSelect,\n      });\n      expect(values[0]).toEqual('xx');\n      expect(values[1]).toEqual(undefined);\n    });\n\n    it('should change choices for multiple select', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [{ id: 'choX', name: 'xx', color: Colors.Cyan }],\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        ['x'],\n        ['x', 'y'],\n        ['y'],\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        options: {\n          choices: [{ name: 'xx', color: Colors.Cyan }],\n        },\n        type: FieldType.MultipleSelect,\n      });\n      expect(values[0]).toEqual(['xx']);\n      expect(values[1]).toEqual(['xx']);\n      expect(values[2]).toEqual(undefined);\n    });\n\n    it('should not accept duplicated name choices', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'y', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n      const sourceField = await createField(table1.id, sourceFieldRo);\n\n      await convertField(table1.id, sourceField.id, newFieldRo, 400);\n    });\n  });\n\n  describe('convert rating field', () => {\n    bfAf();\n\n    it('should convert the dbFieldName and name with options change', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 3,\n        },\n        dbFieldName: 'ratingDbFieldName1',\n        name: 'ratingFieldName1',\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.RedBright,\n          max: 5,\n        },\n        dbFieldName: 'convertRatingDbFieldName',\n        name: 'convertRatingFieldName',\n      };\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [1, 2]);\n      expect(newField.dbFieldName).toEqual('convertRatingDbFieldName');\n      expect(newField.name).toEqual('convertRatingFieldName');\n    });\n\n    it('should correctly update and format values when transitioning from a Number field to a Rating field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 5,\n        },\n      };\n      const { newField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [1.23, 8.88]\n      );\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        options: {\n          icon: RatingIcon.Star,\n          max: 5,\n        },\n        type: FieldType.Rating,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(5);\n    });\n\n    it('should correctly update and maintain values when transitioning from a Rating field to a Number field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 5,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [1, 2]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n        type: FieldType.Number,\n      });\n      expect(values[0]).toEqual(1);\n      expect(values[1]).toEqual(2);\n    });\n\n    it('should change max for rating', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 10,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 5,\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [2, 8]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        options: {\n          icon: RatingIcon.Star,\n          max: 5,\n        },\n        type: FieldType.Rating,\n      });\n      expect(values[0]).toEqual(2);\n      expect(values[1]).toEqual(5);\n    });\n  });\n\n  describe('convert formula field', () => {\n    const refField1Ro: IFieldRo = {\n      type: FieldType.SingleLineText,\n    };\n\n    const refField2Ro: IFieldRo = {\n      type: FieldType.Number,\n    };\n\n    const sourceFieldRo: IFieldRo = {\n      type: FieldType.Formula,\n      options: {\n        expression: '1',\n      },\n    };\n    let refField1: IFieldVo;\n    let refField2: IFieldVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n\n      refField1 = await createField(table1.id, refField1Ro);\n      refField2 = await createField(table1.id, refField2Ro);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, refField1.id, 'x');\n      await updateRecordByApi(table1.id, table1.records[1].id, refField1.id, 'y');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, refField2.id, 1);\n      await updateRecordByApi(table1.id, table1.records[1].id, refField2.id, 2);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n    });\n\n    it('should convert formula and modify expression', async () => {\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${refField1.id}}`,\n        },\n      };\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        null,\n        null,\n      ]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.Formula,\n        isComputed: true,\n      });\n      expect(values[0]).toEqual('x');\n      expect(values[1]).toEqual('y');\n\n      const newFieldRo2: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${refField2.id}}`,\n        },\n      };\n\n      const newField2 = await convertField(table1.id, newField.id, newFieldRo2);\n\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(newField2).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Formula,\n        isComputed: true,\n      });\n\n      expect(records.records[0].fields[newField2.id]).toEqual(1);\n      expect(records.records[1].fields[newField2.id]).toEqual(2);\n    });\n\n    it('should convert formula to text', async () => {\n      const dateTimeField = await createField(table1.id, {\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'America/Los_Angeles',\n          },\n        },\n      });\n\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${dateTimeField.id}}`,\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour12,\n            timeZone: 'America/Los_Angeles',\n          },\n        },\n      });\n\n      const updated = await updateRecordByApi(\n        table1.id,\n        table1.records[0].id,\n        dateTimeField.id,\n        '2024-02-28 16:00'\n      );\n\n      expect(updated.fields[dateTimeField.id]).toEqual('2024-02-29T00:00:00.000Z');\n      expect(updated.fields[formulaField.id]).toEqual('2024-02-29T00:00:00.000Z');\n\n      const textResult = await getRecord(table1.id, table1.records[0].id, CellFormat.Text);\n      expect(textResult.fields[dateTimeField.id]).toEqual('2024-02-28 16:00');\n      expect(textResult.fields[formulaField.id]).toEqual('2024-02-28 04:00 PM');\n\n      await convertField(table1.id, formulaField.id, {\n        type: FieldType.SingleLineText,\n      });\n\n      const results = await getRecord(table1.id, table1.records[0].id);\n      expect(results.fields[formulaField.id]).toEqual('2024-02-28 04:00 PM');\n    });\n  });\n\n  describe('convert link field', () => {\n    bfAf();\n\n    it('should convert empty text to many-one link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n    });\n\n    it('should convert text to many-one link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x, y',\n        'z',\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      // only match 'x' in table2, because many-one link only allowed one value\n      expect(values[0]).toEqual({ title: 'x', id: records[0].id });\n      // clean up invalid value\n      expect(values[1]).toBeUndefined();\n\n      const table2LinkField = await getField(\n        table2.id,\n        (newField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect(records[0].fields[table2LinkField.id]).toMatchObject([{ id: table1.records[0].id }]);\n    });\n\n    it('should convert text to one-many link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        'x, y',\n        'zz',\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(values[0]).toEqual([\n        { title: 'x', id: records[0].id },\n        { title: 'y', id: records[1].id },\n      ]);\n      // clean up invalid value - should return empty array for unmatched values\n      expect(values[1]).toBeUndefined();\n    });\n\n    it('should convert many-one link to text', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n\n      const { newField, sourceField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [{ id: table2.records[0].id }]\n      );\n\n      // make sure symmetricField have been deleted\n      const sourceFieldOptions = sourceField.options as ILinkFieldOptions;\n      await getField(sourceFieldOptions.foreignTableId, sourceFieldOptions.symmetricFieldId!, 404);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n      });\n\n      expect(values[0]).toEqual('x');\n    });\n\n    it('should convert one-many link to text', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n\n      const { newField, sourceField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [[{ id: table2.records[0].id }, { id: table2.records[1].id }]]\n      );\n\n      // make sure symmetricField have been deleted\n      const sourceFieldOptions = sourceField.options as ILinkFieldOptions;\n      await getField(sourceFieldOptions.foreignTableId, sourceFieldOptions.symmetricFieldId!, 404);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n      });\n\n      expect(values[0]).toEqual('x, y');\n    });\n\n    it('should convert many-one to one-many link with in cell illegal', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'xx');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'yy');\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        { id: table2.records[0].id },\n        { id: table2.records[0].id },\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(values[0]).toEqual([{ title: 'xx', id: records[0].id }]);\n      // values[1] should be remove because values[0] is selected to keep link consistency - should return empty array for unmatched values\n      expect(values[1]).toBeUndefined();\n    });\n\n    it('should convert one-many to many-one link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      let lookupField: IFieldVo;\n      const { newField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [\n          [{ id: table2.records[0].id }, { id: table2.records[1].id }],\n          [{ id: table2.records[2].id }],\n        ],\n        async (sourceField) => {\n          const lookupFieldRo: IFieldRo = {\n            type: FieldType.SingleLineText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          };\n          lookupField = await createField(table1.id, lookupFieldRo);\n          const rollupFieldRo: IFieldRo = {\n            type: FieldType.Rollup,\n            options: {\n              expression: `count({values})`,\n              formatting: {\n                precision: 2,\n                type: 'decimal',\n              },\n            } as IRollupFieldOptions,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          };\n          await createField(table1.id, rollupFieldRo);\n        }\n      );\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      expect(lookupField!).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isMultipleCellValue: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: newField.id,\n        },\n      });\n\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(values[0]).toEqual({ title: 'x', id: records[0].id });\n      expect(values[1]).toEqual({ title: 'zzz', id: records[2].id });\n    });\n\n    it('should convert one-many to many-one link with same link title', async () => {\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'test');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'test');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'test');\n\n      const linkField = await createField(table2.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[2].id, linkField.id, {\n        id: table1.records[1].id,\n      });\n\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      await convertField(table1.id, symmetricFieldId, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[symmetricFieldId]).toEqual([\n        { title: 'test', id: table2.records[0].id },\n        { title: 'test', id: table2.records[1].id },\n      ]);\n\n      const { records: records2 } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records2[1].fields[symmetricFieldId]).toEqual([\n        { title: 'test', id: table2.records[2].id },\n      ]);\n    });\n\n    it('should convert one-many to many-one link with same link title and cross table', async () => {\n      // set primary key in table2\n      const table3 = await createTable(baseId, { name: 'table3' });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'test');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'test');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'test');\n\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'test');\n      await updateRecordByApi(table3.id, table3.records[1].id, table3.fields[0].id, 'test');\n      await updateRecordByApi(table3.id, table3.records[2].id, table3.fields[0].id, 'test');\n\n      const linkField = await createField(table2.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[2].id, linkField.id, {\n        id: table1.records[1].id,\n      });\n\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      await convertField(table1.id, symmetricFieldId, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table3.id,\n        },\n      });\n\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[symmetricFieldId]).lengthOf(1);\n\n      const { records: records2 } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records2[1].fields[symmetricFieldId]).lengthOf(1);\n    });\n\n    it('should convert one-many to many-one link with 2 lookup and 2 formula fields', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[1].id, 1);\n\n      const linkField = await createField(table1.id, sourceFieldRo);\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      const lookupField1 = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const lookupField2 = await createField(table1.id, {\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const formulaField1 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'formulaField2',\n        options: {\n          expression: `{${lookupField1.id}}`,\n        },\n      });\n\n      const formulaField2 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'formulaField2',\n        options: {\n          expression: `{${lookupField2.id}}`,\n        },\n      });\n\n      expect(formulaField1.isMultipleCellValue).toBeTruthy();\n      expect(formulaField2.isMultipleCellValue).toBeTruthy();\n\n      const recordsBefore = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(recordsBefore.records[0].fields[formulaField1.id]).toEqual(['x']);\n      expect(recordsBefore.records[0].fields[formulaField2.id]).toEqual([1]);\n\n      const newField = await convertField(table1.id, linkField.id, newFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n      });\n\n      const newFormulaField2 = await getField(table1.id, formulaField2.id);\n\n      expect(newFormulaField2.isMultipleCellValue).toBeFalsy();\n      const recordsAfter = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(recordsAfter.records[0].fields[formulaField1.id]).toEqual('x');\n      expect(recordsAfter.records[0].fields[formulaField2.id]).toEqual(1);\n    });\n\n    it('should convert one-way one-many to two-way many-one link with link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        [{ id: table2.records[0].id }, { id: table2.records[1].id }],\n        [{ id: table2.records[2].id }],\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          symmetricFieldId: expect.any(String),\n        },\n      });\n\n      const symmetricFieldId = (newField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      const { records: t1records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const { records: t2records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(t1records[0].fields[newField.id]).toEqual({ title: 'x', id: t2records[0].id });\n      expect(t1records[1].fields[newField.id]).toEqual({ title: 'zzz', id: t2records[2].id });\n\n      expect(t2records[0].fields[symmetricFieldId]).toMatchObject([{ id: t1records[0].id }]);\n      expect(t2records[2].fields[symmetricFieldId]).toMatchObject([{ id: t1records[1].id }]);\n    });\n\n    it('should convert two-way one-one to one-way one-many link with link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const createdResult = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        { id: table2.records[2].id },\n      ]);\n\n      // convert back to two-way one-one\n      await convertField(table1.id, createdResult.newField.id, sourceFieldRo);\n\n      // junction should not exist when converting one-way one-many to tow-way one-one\n      const query = dbProvider.checkTableExist(\n        `${baseId}${globalThis.testConfig.driver === DriverClient.Sqlite ? '_' : '.'}junction_${createdResult.newField.id}`\n      );\n\n      const queryResult = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(query);\n      expect(queryResult[0].exists).toBeFalsy();\n\n      const newField = await convertField(table1.id, createdResult.newField.id, newFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      expect((newField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      const { records: t1records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const { records: t2records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(t1records[0].fields[newField.id]).toEqual([{ title: 'zzz', id: t2records[2].id }]);\n    });\n\n    it('should convert one-way link to two-way link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const sourceField = await createField(table1.id, sourceFieldRo);\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n      await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: `count({values})`,\n          formatting: {\n            precision: 2,\n            type: 'decimal',\n          },\n        } as IRollupFieldOptions,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n\n      const newField = await convertField(table1.id, sourceField.id, newFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          isOneWay: false,\n        },\n      });\n\n      const symmetricFieldId = (newField.options as ILinkFieldOptions).symmetricFieldId;\n      expect(symmetricFieldId).toBeDefined();\n\n      const symmetricField = await getField(table2.id, symmetricFieldId as string);\n\n      expect(symmetricField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n        },\n      });\n\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n      expect(records[1].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n    });\n\n    it('should convert one-way one-one to two-way one-one', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        { id: table2.records[0].id },\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          symmetricFieldId: expect.any(String),\n        },\n      });\n\n      const symmetricFieldId = (newField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      const { records: t1records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const { records: t2records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(t1records[0].fields[newField.id]).toEqual({ title: 'x', id: t2records[0].id });\n      expect(t2records[0].fields[symmetricFieldId]).toMatchObject({ id: t1records[0].id });\n    });\n\n    it('should convert one-way many-many to two-way many-many', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const { newField } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [\n        [{ id: table2.records[0].id }],\n      ]);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          symmetricFieldId: expect.any(String),\n        },\n      });\n\n      const symmetricFieldId = (newField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      const { records: t1records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const { records: t2records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id });\n      expect(t1records[0].fields[newField.id]).toEqual([{ title: 'x', id: t2records[0].id }]);\n      expect(t2records[0].fields[symmetricFieldId]).toMatchObject([{ id: t1records[0].id }]);\n    });\n\n    it('should convert one-way link to two-way link and to other table', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table3.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2/table3\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'x');\n      await updateRecordByApi(table3.id, table3.records[1].id, table3.fields[0].id, 'y');\n\n      const sourceField = await createField(table1.id, sourceFieldRo);\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n      await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: `count({values})`,\n          formatting: {\n            precision: 2,\n            type: 'decimal',\n          },\n        } as IRollupFieldOptions,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n\n      const newField = await convertField(table1.id, sourceField.id, newFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table3.id,\n          lookupFieldId: table3.fields[0].id,\n          isOneWay: false,\n        },\n      });\n\n      const symmetricFieldId = (newField.options as ILinkFieldOptions).symmetricFieldId;\n      expect(symmetricFieldId).toBeDefined();\n\n      const symmetricField = await getField(table3.id, symmetricFieldId as string);\n\n      expect(symmetricField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n        },\n      });\n\n      const { records } = await getRecords(table3.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n      expect(records[1].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n    });\n\n    it('should convert link from one table to another', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'z2');\n      // set primary key in table3\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'x');\n      await updateRecordByApi(table3.id, table3.records[1].id, table3.fields[0].id, 'y');\n      await updateRecordByApi(table3.id, table3.records[2].id, table3.fields[0].id, 'z3');\n\n      const { newField, sourceField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [{ id: table2.records[0].id }, { id: table2.records[1].id }, { id: table2.records[2].id }],\n        async (sourceField) => {\n          await createField(table1.id, {\n            type: FieldType.SingleLineText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n          await createField(table1.id, {\n            type: FieldType.Rollup,\n            options: {\n              expression: `count({values})`,\n              formatting: {\n                precision: 2,\n                type: 'decimal',\n              },\n            } as IRollupFieldOptions,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n        }\n      );\n\n      // make sure symmetricField have been deleted\n      const sourceFieldOptions = sourceField.options as ILinkFieldOptions;\n      const newFieldOptions = newField.options as ILinkFieldOptions;\n      await getField(sourceFieldOptions.foreignTableId, sourceFieldOptions.symmetricFieldId!, 404);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n          lookupFieldId: table3.fields[0].id,\n        },\n      });\n\n      // make sure symmetricField have been created\n      const symmetricField = await getField(table3.id, newFieldOptions.symmetricFieldId as string);\n      expect(symmetricField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          symmetricFieldId: newField.id,\n        },\n      });\n\n      const { records } = await getRecords(table3.id, { fieldKeyType: FieldKeyType.Id });\n      expect(values[0]).toEqual({ title: 'x', id: records[0].id });\n      expect(values[1]).toEqual({ title: 'y', id: records[1].id });\n      expect(values[2]).toBeUndefined();\n    });\n\n    it('should convert link from one table to another with selected link record', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'B3');\n      // set primary key in table3\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'C1');\n      await updateRecordByApi(table3.id, table3.records[1].id, table3.fields[0].id, 'C2');\n      await updateRecordByApi(table3.id, table3.records[2].id, table3.fields[0].id, 'C3');\n\n      const { sourceField } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [{ id: table2.records[0].id }],\n        async (sourceField) => {\n          await createField(table1.id, {\n            type: FieldType.SingleLineText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n          await createField(table1.id, {\n            type: FieldType.Rollup,\n            options: {\n              expression: `count({values})`,\n              formatting: {\n                precision: 2,\n                type: 'decimal',\n              },\n            } as IRollupFieldOptions,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n        }\n      );\n\n      // make sure records has been updated\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[sourceField.id]).toBeUndefined();\n    });\n\n    it('should mark lookupField error when convert link from one table to another', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      // set primary key in table3\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'C1');\n\n      const sourceLinkField = await createField(table1.id, sourceFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceLinkField.id,\n        },\n      };\n      const sourceLookupField = await createField(table1.id, lookupFieldRo);\n\n      const formulaLinkFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLinkField.id}}`,\n        },\n      };\n      const formulaLookupFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLookupField.id}}`,\n        },\n      };\n\n      const sourceFormulaLinkField = await createField(table1.id, formulaLinkFieldRo);\n      const sourceFormulaLookupField = await createField(table1.id, formulaLookupFieldRo);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceLinkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // make sure records has been updated\n      const { records: rs } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(rs[0].fields[sourceLinkField.id]).toEqual({ id: table2.records[0].id, title: 'B1' });\n      expect(rs[0].fields[sourceLookupField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLinkField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLookupField.id]).toEqual('B1');\n\n      const newLinkField = await convertField(table1.id, sourceLinkField.id, newFieldRo);\n\n      expect(newLinkField).toMatchObject({\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n          lookupFieldId: table3.fields[0].id,\n        },\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, newLinkField.id, {\n        id: table3.records[0].id,\n      });\n\n      const targetLookupField = await getField(table1.id, sourceLookupField.id);\n      const targetFormulaLinkField = await getField(table1.id, sourceFormulaLinkField.id);\n      const targetFormulaLookupField = await getField(table1.id, sourceFormulaLookupField.id);\n\n      expect(targetLookupField.hasError).toBeTruthy();\n      expect(targetFormulaLinkField.hasError).toBeUndefined();\n      expect(targetFormulaLookupField.hasError).toBeUndefined();\n\n      // make sure records has been updated\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[newLinkField.id]).toEqual({ id: table3.records[0].id, title: 'C1' });\n      expect(records[0].fields[targetLookupField.id]).toBeUndefined();\n      expect(records[0].fields[targetFormulaLinkField.id]).toEqual('C1');\n      expect(records[0].fields[targetFormulaLookupField.id]).toBeUndefined();\n    });\n\n    it('should mark lookupField error when convert link to text', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      const sourceLinkField = await createField(table1.id, sourceFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceLinkField.id,\n        },\n      };\n      const sourceLookupField = await createField(table1.id, lookupFieldRo);\n\n      const formulaLinkFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLinkField.id}}`,\n        },\n      };\n      const formulaLookupFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLookupField.id}}`,\n        },\n      };\n\n      const sourceFormulaLinkField = await createField(table1.id, formulaLinkFieldRo);\n      const sourceFormulaLookupField = await createField(table1.id, formulaLookupFieldRo);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceLinkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // make sure records has been updated\n      const { records: rs } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(rs[0].fields[sourceLinkField.id]).toEqual({ id: table2.records[0].id, title: 'B1' });\n      expect(rs[0].fields[sourceLookupField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLinkField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLookupField.id]).toEqual('B1');\n\n      const newField = await convertField(table1.id, sourceLinkField.id, newFieldRo);\n\n      expect(newField).toMatchObject({\n        type: FieldType.SingleLineText,\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, newField.id, 'txt');\n\n      const targetLookupField = await getField(table1.id, sourceLookupField.id);\n      const targetFormulaLinkField = await getField(table1.id, sourceFormulaLinkField.id);\n      const targetFormulaLookupField = await getField(table1.id, sourceFormulaLookupField.id);\n\n      expect(targetLookupField.hasError).toBeTruthy();\n      expect(targetFormulaLinkField.hasError).toBeUndefined();\n      expect(targetFormulaLookupField.hasError).toBeUndefined();\n\n      // make sure records has been updated\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[newField.id]).toEqual('txt');\n      expect(records[0].fields[targetLookupField.id]).toBeUndefined();\n      expect(records[0].fields[targetFormulaLinkField.id]).toEqual('txt');\n      expect(records[0].fields[targetFormulaLookupField.id]).toBeUndefined();\n    });\n\n    it('should convert link from one table to another and change relationship', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table3.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'z2');\n      // set primary key in table3\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'x');\n      await updateRecordByApi(table3.id, table3.records[1].id, table3.fields[0].id, 'y');\n      await updateRecordByApi(table3.id, table3.records[2].id, table3.fields[0].id, 'z3');\n\n      const { newField, sourceField, values } = await expectUpdate(\n        table1,\n        sourceFieldRo,\n        newFieldRo,\n        [{ id: table2.records[0].id }, { id: table2.records[1].id }, { id: table2.records[2].id }],\n        async (sourceField) => {\n          await createField(table1.id, {\n            type: FieldType.SingleLineText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n          await createField(table1.id, {\n            type: FieldType.Rollup,\n            options: {\n              expression: `count({values})`,\n              formatting: {\n                precision: 2,\n                type: 'decimal',\n              },\n            } as IRollupFieldOptions,\n            lookupOptions: {\n              foreignTableId: table2.id,\n              lookupFieldId: table2.fields[0].id,\n              linkFieldId: sourceField.id,\n            },\n          });\n        }\n      );\n\n      // make sure symmetricField have been deleted\n      const sourceFieldOptions = sourceField.options as ILinkFieldOptions;\n      const newFieldOptions = newField.options as ILinkFieldOptions;\n      await getField(sourceFieldOptions.foreignTableId, sourceFieldOptions.symmetricFieldId!, 404);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table3.id,\n          lookupFieldId: table3.fields[0].id,\n        },\n      });\n\n      // make sure symmetricField have been created\n      const symmetricField = await getField(table3.id, newFieldOptions.symmetricFieldId as string);\n      expect(symmetricField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          symmetricFieldId: newField.id,\n        },\n      });\n\n      const { records } = await getRecords(table3.id, { fieldKeyType: FieldKeyType.Id });\n      expect(values[0]).toEqual([{ title: 'x', id: records[0].id }]);\n      expect(values[1]).toEqual([{ title: 'y', id: records[1].id }]);\n      expect(values[2] ?? []).toEqual([]);\n    });\n  });\n\n  describe('convert lookup field', () => {\n    bfAf();\n\n    it('should convert text to many-one lookup', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual('x');\n    });\n\n    it('should convert text to one-many lookup', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x'/'y' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const { newField, values } = await expectUpdate(table1, sourceFieldRo, newFieldRo, [null]);\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(values[0]).toEqual(['x', 'y']);\n    });\n\n    it('should convert text field to select and relational one-many lookup field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const sourceField = await createField(table2.id, sourceFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'lookup ' + sourceField.name,\n        type: sourceField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n      const lookupField = await createField(table1.id, lookupFieldRo);\n\n      expect(lookupField).toMatchObject({\n        type: sourceField.type,\n        dbFieldType: DbFieldType.Json,\n        isMultipleCellValue: true,\n        isLookup: true,\n        lookupOptions: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      // update source field record before convert\n      await updateRecordByApi(table2.id, table2.records[0].id, sourceField.id, 'text 1');\n      await updateRecordByApi(table2.id, table2.records[1].id, sourceField.id, 'text 2');\n\n      const recordResult1 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordResult1.records[0].fields[lookupField.id]).toEqual(['text 1', 'text 2']);\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n      };\n\n      const newField = await convertField(table2.id, sourceField.id, newFieldRo);\n      const newLookupField = await getField(table1.id, lookupField.id);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'text 1' }, { name: 'text 2' }],\n        },\n      });\n\n      expect(newLookupField).toMatchObject({\n        type: newField.type,\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        cellValueType: newField.cellValueType,\n        isMultipleCellValue: true,\n        options: newField.options,\n        lookupOptions: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const recordResult2 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordResult2.records[0].fields[lookupField.id]).toEqual(['text 1', 'text 2']);\n    });\n\n    it('should convert text field to number and relational one-many lookup field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const sourceField = await createField(table2.id, sourceFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'lookup ' + sourceField.name,\n        type: sourceField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n      const lookupField = await createField(table1.id, lookupFieldRo);\n\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n      ]);\n\n      // update source field record before convert\n      await updateRecordByApi(table2.id, table2.records[0].id, sourceField.id, '1');\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n      };\n\n      const newField = await convertField(table2.id, sourceField.id, newFieldRo);\n      const newLookupField = await getField(table1.id, lookupField.id);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            precision: 2,\n            type: NumberFormattingType.Decimal,\n          },\n        },\n      });\n\n      expect(newLookupField).toMatchObject({\n        type: newField.type,\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        cellValueType: newField.cellValueType,\n        isMultipleCellValue: true,\n        options: newField.options,\n        lookupOptions: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const recordResult2 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordResult2.records[0].fields[lookupField.id]).toEqual([1]);\n    });\n\n    it('should convert date field to number and relational one-many lookup field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Date,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const sourceField = await createField(table2.id, sourceFieldRo);\n\n      expect(sourceField).toMatchObject({\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n          },\n        },\n      });\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'lookup ' + sourceField.name,\n        type: sourceField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n      const lookupField = await createField(table1.id, lookupFieldRo);\n\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n      ]);\n\n      // update source field record before convert\n      const now = new Date();\n      await updateRecordByApi(table2.id, table2.records[0].id, sourceField.id, now.toISOString());\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Number,\n      };\n\n      const newField = await convertField(table2.id, sourceField.id, newFieldRo);\n      const newLookupField = await getField(table1.id, lookupField.id);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Real,\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            precision: 2,\n            type: NumberFormattingType.Decimal,\n          },\n        },\n      });\n\n      expect(newLookupField).toMatchObject({\n        type: newField.type,\n        isLookup: true,\n        dbFieldType: DbFieldType.Json,\n        cellValueType: newField.cellValueType,\n        isMultipleCellValue: true,\n        options: newField.options,\n        lookupOptions: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const recordResult2 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const expectedNumber =\n        process.env.FORCE_V2_ALL === 'true' ? now.getTime() : now.getFullYear();\n      expect(recordResult2.records[0].fields[lookupField.id]).toEqual([expectedNumber]);\n    });\n\n    it('should convert number field to text and relational many-one lookup field and formula field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Number,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const sourceField = await createField(table2.id, sourceFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'lookup ' + sourceField.name,\n        type: sourceField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n      const lookupField = await createField(table1.id, lookupFieldRo);\n\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupField.id}}`,\n        },\n      };\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      expect(lookupField).toMatchObject({\n        type: sourceField.type,\n        dbFieldType: DbFieldType.Real,\n        cellValueType: CellValueType.Number,\n        isLookup: true,\n        lookupOptions: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(formulaField).toMatchObject({\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Real,\n        cellValueType: CellValueType.Number,\n      });\n\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // update source field record before convert\n      await updateRecordByApi(table2.id, table2.records[0].id, sourceField.id, 1);\n\n      const recordResult1 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordResult1.records[0].fields[lookupField.id]).toEqual(1);\n      expect(recordResult1.records[1].fields[lookupField.id]).toEqual(1);\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      const newField = await convertField(table2.id, sourceField.id, newFieldRo);\n      const newLookupField = await getField(table1.id, lookupField.id);\n      const newFormulaField = await getField(table1.id, formulaField.id);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n        options: {},\n      });\n\n      expect(newLookupField).toMatchObject({\n        type: newField.type,\n        isLookup: true,\n        dbFieldType: DbFieldType.Text,\n        cellValueType: newField.cellValueType,\n        options: newField.options,\n        lookupOptions: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: sourceField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      expect(newFormulaField).toMatchObject({\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Text,\n        cellValueType: newField.cellValueType,\n      });\n\n      const recordResult2 = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(recordResult2.records[0].fields[lookupField.id]).toEqual('1.00');\n      expect(recordResult2.records[1].fields[lookupField.id]).toEqual('1.00');\n    });\n\n    it('should mark all relational lookup field error when the link field is convert to others', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const extraLinkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n      const extraLinkField = await createField(table1.id, extraLinkFieldRo);\n      expect(extraLinkField).toMatchObject({\n        type: FieldType.Link,\n      });\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const lookupField = await createField(table1.id, lookupFieldRo);\n      expect(lookupField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n        },\n      });\n      const beforeRecord = await getRecord(table1.id, table1.records[0].id);\n      expect(beforeRecord.fields[lookupField.id]).toEqual('x');\n\n      const newField = await convertField(table1.id, linkField.id, sourceFieldRo);\n\n      expect(newField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n      });\n\n      const lookupFieldAfter = await getField(table1.id, lookupField.id);\n      expect(lookupFieldAfter).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        hasError: true,\n        lookupOptions: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n      expect(record.fields[newField.id]).toEqual('x');\n      expect(record.fields[lookupField.id]).toBeUndefined();\n    });\n\n    it('should update lookup when the options of the fields being lookup are updated', async () => {\n      const selectFieldRo: IFieldRo = {\n        name: 'SelectField',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'x', color: Colors.Cyan }],\n        },\n      };\n\n      const selectField = await createField(table1.id, selectFieldRo);\n\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: 'Lookup SelectField',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: selectField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const lookupField = await createField(table2.id, lookupFieldRo);\n\n      expect(lookupField).toMatchObject({\n        name: 'Lookup SelectField',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        options: {\n          choices: [{ name: 'x', color: Colors.Cyan }],\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: selectField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const selectFieldUpdateRo = {\n        ...selectFieldRo,\n        options: {\n          choices: [\n            ...(selectField.options as ISelectFieldOptions).choices,\n            { name: 'y', color: Colors.Blue },\n          ],\n        },\n      };\n\n      await convertField(table1.id, selectField.id, selectFieldUpdateRo);\n\n      const lookupFieldAfter = await getField(table2.id, lookupField.id);\n      expect((lookupFieldAfter.options as ISelectFieldOptions).choices.length).toEqual(2);\n      expect((lookupFieldAfter.options as ISelectFieldOptions).choices[0]).toMatchObject({\n        name: 'x',\n        color: Colors.Cyan,\n      });\n      expect((lookupFieldAfter.options as ISelectFieldOptions).choices[1]).toMatchObject({\n        name: 'y',\n        color: Colors.Blue,\n      });\n    });\n\n    it('should update lookup when the change lookupField', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'number',\n        type: FieldType.Number,\n      };\n\n      const textField = await createField(table1.id, textFieldRo);\n      const numberField = await createField(table1.id, numberFieldRo);\n\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, [\n        {\n          id: table1.records[0].id,\n        },\n        {\n          id: table1.records[1].id,\n        },\n      ]);\n      await updateRecordByApi(table1.id, table1.records[0].id, textField.id, 'text1');\n      await updateRecordByApi(table1.id, table1.records[0].id, numberField.id, 123);\n\n      const lookupFieldRo1: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: textField.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      };\n\n      const lookupField = await createField(table2.id, lookupFieldRo1);\n\n      const textRecord = await getRecord(table2.id, table2.records[0].id);\n      expect(textRecord.fields[lookupField.id]).toEqual(['text1']);\n\n      const lookupFieldRo2: IFieldRo = {\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: numberField.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      };\n\n      const updatedLookupField = await convertField(table2.id, lookupField.id, lookupFieldRo2);\n      expect(updatedLookupField).toMatchObject(lookupFieldRo2);\n      const numberRecord = await getRecord(table2.id, table2.records[0].id);\n      expect(numberRecord.fields[lookupField.id]).toEqual([123]);\n    });\n\n    it.skipIf(!canRunCanaryV2)(\n      'should remove lookup filter when convert payload omits filter in v2',\n      async () => {\n        const regionField = await createField(table2.id, {\n          name: 'Region',\n          type: FieldType.SingleLineText,\n        });\n        const linkField = await createField(table1.id, {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n          },\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1');\n        await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2');\n        await updateRecordByApi(table2.id, table2.records[0].id, regionField.id, 'South');\n        await updateRecordByApi(table2.id, table2.records[1].id, regionField.id, 'North');\n        await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n          id: table2.records[1].id,\n        });\n\n        const lookupField = await createField(table1.id, {\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            linkFieldId: linkField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: regionField.id, operator: 'is', value: 'South' }],\n            },\n          },\n        });\n\n        const beforeRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(beforeRecord.fields[lookupField.id]).toBeUndefined();\n\n        const updatedField = await convertFieldByCanaryV2(table1.id, lookupField.id, {\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            linkFieldId: linkField.id,\n          },\n        });\n\n        expect((updatedField.lookupOptions as ILookupOptionsRo).filter).toBeUndefined();\n\n        const refreshedField = await getField(table1.id, lookupField.id);\n        expect((refreshedField.lookupOptions as ILookupOptionsRo).filter).toBeUndefined();\n\n        const afterRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(afterRecord.fields[lookupField.id]).toEqual('row-2');\n      }\n    );\n\n    it.skipIf(!canRunCanaryV2)(\n      'should remove conditional lookup sort and limit when convert payload omits them in v2',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleLineText,\n        });\n        const scoreField = await createField(table2.id, {\n          name: 'Score',\n          type: FieldType.Number,\n        });\n        const statusFilterField = await createField(table1.id, {\n          name: 'Status Filter',\n          type: FieldType.SingleLineText,\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1');\n        await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2');\n        await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10);\n        await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20);\n        await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active');\n\n        const lookupField = await createField(table1.id, {\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n            sort: {\n              fieldId: scoreField.id,\n              order: SortFunc.Desc,\n            },\n            limit: 1,\n          },\n        });\n\n        const beforeRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(beforeRecord.fields[lookupField.id]).toEqual(['row-2']);\n\n        const updatedField = await convertFieldByCanaryV2(table1.id, lookupField.id, {\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n          },\n        });\n\n        const updatedLookupOptions = updatedField.lookupOptions as IConditionalLookupOptions;\n        expect(updatedLookupOptions.sort).toBeUndefined();\n        expect(updatedLookupOptions.limit).toBeUndefined();\n\n        const refreshedField = await getField(table1.id, lookupField.id);\n        const refreshedLookupOptions = refreshedField.lookupOptions as IConditionalLookupOptions;\n        expect(refreshedLookupOptions.sort).toBeUndefined();\n        expect(refreshedLookupOptions.limit).toBeUndefined();\n\n        const afterRecord = await getRecord(table1.id, table1.records[0].id);\n        expect([...(afterRecord.fields[lookupField.id] as string[])].sort()).toEqual([\n          'row-1',\n          'row-2',\n        ]);\n      }\n    );\n\n    it.skipIf(!canRunCanaryV2)(\n      'should remove conditional lookup sort and limit for formula inner type when switch is off in v2',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleLineText,\n        });\n        const scoreField = await createField(table2.id, {\n          name: 'Score',\n          type: FieldType.Number,\n        });\n        const datetimeFormulaField = await createField(table2.id, {\n          name: 'Datetime Formula',\n          type: FieldType.Formula,\n          options: {\n            expression: 'NOW()',\n            formatting: {\n              date: 'YYYY-MM-DD',\n              time: 'HH:mm',\n              timeZone: 'Asia/Shanghai',\n            },\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n        const statusFilterField = await createField(table1.id, {\n          name: 'Status Filter',\n          type: FieldType.SingleLineText,\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10);\n        await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20);\n        await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active');\n\n        const lookupField = await createField(table1.id, {\n          type: FieldType.Formula,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: datetimeFormulaField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n            sort: {\n              fieldId: scoreField.id,\n              order: SortFunc.Desc,\n            },\n            limit: 1,\n          },\n          options: {\n            expression: 'NOW()',\n            formatting: {\n              date: 'YYYY-MM-DD',\n              time: 'HH:mm',\n              timeZone: 'Asia/Shanghai',\n            },\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const beforeRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(Array.isArray(beforeRecord.fields[lookupField.id])).toBeTruthy();\n        expect((beforeRecord.fields[lookupField.id] as unknown[]).length).toBe(1);\n\n        const updatedField = await convertFieldByCanaryV2(table1.id, lookupField.id, {\n          type: FieldType.Formula,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: datetimeFormulaField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n          },\n          options: {\n            expression: 'NOW()',\n            formatting: {\n              date: 'YYYY-MM-DD',\n              time: 'HH:mm',\n              timeZone: 'Asia/Shanghai',\n            },\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const updatedLookupOptions = updatedField.lookupOptions as IConditionalLookupOptions;\n        expect(updatedLookupOptions.sort).toBeUndefined();\n        expect(updatedLookupOptions.limit).toBeUndefined();\n\n        const refreshedField = await getField(table1.id, lookupField.id);\n        const refreshedLookupOptions = refreshedField.lookupOptions as IConditionalLookupOptions;\n        expect(refreshedLookupOptions.sort).toBeUndefined();\n        expect(refreshedLookupOptions.limit).toBeUndefined();\n\n        const persistedField = await prisma.txClient().field.findFirstOrThrow({\n          where: { id: lookupField.id, deletedTime: null },\n          select: {\n            type: true,\n            isConditionalLookup: true,\n            lookupOptions: true,\n          },\n        });\n        expect(persistedField.type).toBe(FieldType.Formula);\n        expect(persistedField.isConditionalLookup).toBe(true);\n        const persistedLookupOptions =\n          typeof persistedField.lookupOptions === 'string'\n            ? JSON.parse(persistedField.lookupOptions)\n            : persistedField.lookupOptions;\n        expect(persistedLookupOptions?.sort).toBeUndefined();\n        expect(persistedLookupOptions?.limit).toBeUndefined();\n\n        const afterRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(Array.isArray(afterRecord.fields[lookupField.id])).toBeTruthy();\n        expect((afterRecord.fields[lookupField.id] as unknown[]).length).toBe(2);\n      }\n    );\n\n    it.skipIf(!canRunCanaryV2)(\n      'should preserve formula datetime formatting when converting conditional lookup inner type in v2',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleLineText,\n        });\n        const dueDateField = await createField(table2.id, {\n          name: 'Due Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.Hour24,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        });\n        const statusFilterField = await createField(table1.id, {\n          name: 'Status Filter',\n          type: FieldType.SingleLineText,\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active');\n        await updateRecordByApi(\n          table2.id,\n          table2.records[0].id,\n          dueDateField.id,\n          '2026-01-02T03:04:00.000Z'\n        );\n        await updateRecordByApi(\n          table2.id,\n          table2.records[1].id,\n          dueDateField.id,\n          '2026-01-03T05:06:00.000Z'\n        );\n        await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active');\n\n        const lookupField = await createField(table1.id, {\n          type: FieldType.Date,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: dueDateField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n          },\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.Hour24,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        });\n\n        const convertedField = await convertFieldByCanaryV2(table1.id, lookupField.id, {\n          type: FieldType.Formula,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: dueDateField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n          },\n          options: {\n            expression: 'NOW()',\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.Hour24,\n              timeZone: 'Asia/Shanghai',\n            },\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        expect(convertedField.type).toBe(FieldType.Formula);\n        expect(convertedField.isLookup).toBe(true);\n        expect(convertedField.isConditionalLookup).toBe(true);\n        expect(convertedField.options).toMatchObject({\n          expression: 'NOW()',\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const refreshedField = await getField(table1.id, lookupField.id);\n        expect(refreshedField.type).toBe(FieldType.Formula);\n        expect(refreshedField.isLookup).toBe(true);\n        expect(refreshedField.isConditionalLookup).toBe(true);\n        expect(refreshedField.options).toMatchObject({\n          expression: 'NOW()',\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const persistedField = await prisma.txClient().field.findFirstOrThrow({\n          where: { id: lookupField.id, deletedTime: null },\n          select: {\n            type: true,\n            isConditionalLookup: true,\n            options: true,\n          },\n        });\n        expect(persistedField.type).toBe(FieldType.Formula);\n        expect(persistedField.isConditionalLookup).toBe(true);\n        const persistedOptions =\n          typeof persistedField.options === 'string'\n            ? JSON.parse(persistedField.options)\n            : persistedField.options;\n        expect(persistedOptions).toMatchObject({\n          expression: 'NOW()',\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n      }\n    );\n\n    it.skipIf(!canRunCanaryV2)(\n      'should remove link filter options when convert payload omits them in v2',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleLineText,\n        });\n        await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active');\n\n        const linkField = await createField(table1.id, {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            filterByViewId: table2.defaultViewId,\n            visibleFieldIds: [table2.fields[0].id],\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }],\n            },\n          },\n        });\n\n        const updatedField = await convertFieldByCanaryV2(table1.id, linkField.id, {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n          },\n        });\n\n        const updatedOptions = updatedField.options as ILinkFieldOptions;\n        expect(updatedOptions.filterByViewId).toBeUndefined();\n        expect(updatedOptions.visibleFieldIds).toBeUndefined();\n        expect(updatedOptions.filter).toBeUndefined();\n\n        const refreshedField = await getField(table1.id, linkField.id);\n        const refreshedOptions = refreshedField.options as ILinkFieldOptions;\n        expect(refreshedOptions.filterByViewId).toBeUndefined();\n        expect(refreshedOptions.visibleFieldIds).toBeUndefined();\n        expect(refreshedOptions.filter).toBeUndefined();\n      }\n    );\n\n    it('should change lookupField from link to text', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const symmetricLinkField = await getField(\n        table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: symmetricLinkField.id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const lookupField = await createField(table1.id, lookupFieldRo);\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      const newLookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      await convertField(table1.id, lookupField.id, newLookupFieldRo);\n\n      const linkFieldAfter = await getField(table1.id, linkField.id);\n      const { meta: _linkFieldMeta, ...linkFieldWithoutMeta } = linkField;\n      expect(linkFieldAfter).toMatchObject(linkFieldWithoutMeta);\n      const records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).records;\n      expect(records[0].fields[linkField.id]).toEqual([\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n      expect(records[0].fields[lookupField.id]).toBeUndefined();\n    });\n\n    it('should change lookupField from link to other link', async () => {\n      const linkFieldRo1: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkFieldRo2: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n      const linkField1 = await createField(table1.id, linkFieldRo1);\n      const linkField2 = await createField(table1.id, linkFieldRo2);\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: (linkField1.options as ILinkFieldOptions).symmetricFieldId as string,\n          linkFieldId: linkField1.id,\n        },\n      };\n\n      const lookupField = await createField(table1.id, lookupFieldRo);\n      // add a link record\n      // record[0] for linkField1\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n      // record[1] for linkField2\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField2.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      const lookupFieldRo2: IFieldRo = {\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: (linkField2.options as ILinkFieldOptions).symmetricFieldId as string,\n          linkFieldId: linkField2.id,\n        },\n      };\n      const recordsPre = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).records;\n      expect(recordsPre[0].fields[lookupField.id]).toEqual([\n        { id: table1.records[0].id },\n        { id: table1.records[0].id },\n      ]);\n      await convertField(table1.id, lookupField.id, lookupFieldRo2);\n      const linkField1After = await getField(table1.id, linkField1.id);\n      const { meta: _linkField1Meta, ...linkField1WithoutMeta } = linkField1;\n      expect(linkField1After).toMatchObject(linkField1WithoutMeta);\n      const linkField2After = await getField(table1.id, linkField2.id);\n      const { meta: _linkField2Meta, ...linkField2WithoutMeta } = linkField2;\n      expect(linkField2After).toMatchObject(linkField2WithoutMeta);\n\n      const records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).records;\n      expect(records[0].fields[linkField1.id]).toEqual([\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n      expect(records[0].fields[linkField2.id] ?? []).toEqual([]);\n      expect(records[1].fields[linkField2.id]).toEqual([\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // record[0] for lookupField is to be undefined\n      expect(records[0].fields[lookupField.id] ?? []).toEqual([]);\n      // record[1] for lookupField\n      expect(records[1].fields[lookupField.id]).toEqual([\n        { id: table1.records[1].id },\n        { id: table1.records[1].id },\n      ]);\n    });\n\n    it('should lookupField link work when convert many-many to many-one link', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'A1');\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      const table2LinkTable1Field = await createField(table2.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      });\n      await updateRecordByApi(table2.id, table2.records[0].id, table2LinkTable1Field.id, {\n        id: table1.records[0].id,\n      });\n      const table2LinkTable1Record = await getRecord(table2.id, table2.records[0].id);\n      expect(table2LinkTable1Record.fields[table2LinkTable1Field.id]).toEqual({\n        id: table1.records[0].id,\n        title: 'A1',\n      });\n\n      const table3linkTable2Field = await createField(table3.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const table3lookupTable2Field = await createField(table3.id, {\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2LinkTable1Field.id,\n          linkFieldId: table3linkTable2Field.id,\n        },\n      });\n      await updateRecordByApi(table3.id, table3.records[0].id, table3linkTable2Field.id, [\n        {\n          id: table2.records[0].id,\n        },\n      ]);\n      const table3lookupTable2Record = await getRecord(table3.id, table3.records[0].id);\n      expect(table3lookupTable2Record.fields[table3linkTable2Field.id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'B1',\n        },\n      ]);\n      expect(table3lookupTable2Record.fields[table3lookupTable2Field.id]).toEqual([\n        {\n          id: table1.records[0].id,\n          title: 'A1',\n        },\n      ]);\n\n      await convertField(table3.id, table3linkTable2Field.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n      const table3lookupTable2RecordAfter = await getRecord(table3.id, table3.records[0].id);\n      expect(table3lookupTable2RecordAfter.fields[table3lookupTable2Field.id]).toEqual({\n        id: table1.records[0].id,\n        title: 'A1',\n      });\n    });\n\n    it('should reset show as for lookup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {\n          showAs: {\n            type: SingleLineTextDisplayType.Email,\n          },\n        },\n      };\n\n      const newLookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {},\n      };\n\n      const { newField } = await expectUpdate(table1, lookupFieldRo, newLookupFieldRo, []);\n      expect(newField.options).toEqual({});\n    });\n\n    it('should update show as for rollup and lookup', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // add a link record\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {\n          showAs: {\n            type: SingleLineTextDisplayType.Email,\n          },\n        },\n      };\n\n      const newLookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {},\n      };\n\n      const rollupFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {\n          expression: 'concatenate({values})',\n          showAs: {\n            type: SingleLineTextDisplayType.Email,\n          },\n        },\n      };\n\n      const newRollupFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n        options: {\n          expression: 'concatenate({values})',\n        },\n      };\n\n      const { newField: newRollupField } = await expectUpdate(\n        table1,\n        rollupFieldRo,\n        newRollupFieldRo,\n        []\n      );\n      expect(newRollupField.options).toEqual({\n        expression: 'concatenate({values})',\n      });\n\n      const { newField: newLookupField } = await expectUpdate(\n        table1,\n        lookupFieldRo,\n        newLookupFieldRo,\n        []\n      );\n      expect(newLookupField.options).toEqual({});\n    });\n  });\n\n  describe('convert rollup field', () => {\n    bfAf();\n\n    it('should update rollup change rollup to field', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'number',\n        type: FieldType.Number,\n      };\n\n      const textField = await createField(table1.id, textFieldRo);\n      const numberField = await createField(table1.id, numberFieldRo);\n\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      const linkField = await createField(table2.id, linkFieldRo);\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField.id, [\n        {\n          id: table1.records[0].id,\n        },\n        {\n          id: table1.records[1].id,\n        },\n      ]);\n\n      const rollupFieldRo1: IFieldRo = {\n        name: 'Roll up',\n        type: FieldType.Rollup,\n        options: {\n          expression: `count({values})`,\n          formatting: {\n            precision: 2,\n            type: 'decimal',\n          },\n        } as IRollupFieldOptions,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: textField.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      };\n\n      const rollupField = await createField(table2.id, rollupFieldRo1);\n\n      const rollupFieldRo2: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: `count({values})`,\n        } as IRollupFieldOptions,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: numberField.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      };\n\n      await convertField(table2.id, rollupField.id, rollupFieldRo2);\n    });\n\n    it.skipIf(!canRunCanaryV2)(\n      'should remove conditional rollup sort and limit when convert payload omits them in v2',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleLineText,\n        });\n        const scoreField = await createField(table2.id, {\n          name: 'Score',\n          type: FieldType.Number,\n        });\n        const statusFilterField = await createField(table1.id, {\n          name: 'Status Filter',\n          type: FieldType.SingleLineText,\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1');\n        await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2');\n        await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active');\n        await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10);\n        await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20);\n        await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active');\n\n        const conditionalRollupField = await createField(table1.id, {\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            expression: 'array_compact({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n            sort: {\n              fieldId: scoreField.id,\n              order: SortFunc.Desc,\n            },\n            limit: 1,\n          } as IConditionalRollupFieldOptions,\n        });\n\n        const beforeRecord = await getRecord(table1.id, table1.records[0].id);\n        expect(beforeRecord.fields[conditionalRollupField.id]).toEqual(['row-2']);\n\n        const updatedField = await convertFieldByCanaryV2(table1.id, conditionalRollupField.id, {\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            expression: 'array_compact({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterField.id },\n                },\n              ],\n            },\n          } as IConditionalRollupFieldOptions,\n        });\n\n        const updatedOptions = updatedField.options as IConditionalRollupFieldOptions;\n        expect(updatedOptions.sort).toBeUndefined();\n        expect(updatedOptions.limit).toBeUndefined();\n\n        const refreshedField = await getField(table1.id, conditionalRollupField.id);\n        const refreshedOptions = refreshedField.options as IConditionalRollupFieldOptions;\n        expect(refreshedOptions.sort).toBeUndefined();\n        expect(refreshedOptions.limit).toBeUndefined();\n\n        const afterRecord = await getRecord(table1.id, table1.records[0].id);\n        expect([...(afterRecord.fields[conditionalRollupField.id] as string[])].sort()).toEqual([\n          'row-1',\n          'row-2',\n        ]);\n      }\n    );\n  });\n\n  describe('rollup conversion regressions', () => {\n    bfAf();\n\n    it('should convert an errored rollup to text without type mismatch', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      // Seed a linked record to exercise rollup evaluation\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'seed');\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const rollupField = await createField(table1.id, {\n        name: 'Done Rate',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      // Break the link dependency via API so the rollup enters an errored state.\n      await convertField(table1.id, linkField.id, {\n        type: FieldType.SingleLineText,\n      });\n      const erroredRollup = await getField(table1.id, rollupField.id);\n      expect(erroredRollup.hasError).toBeTruthy();\n\n      const updatedField = await convertField(table1.id, rollupField.id, {\n        type: FieldType.SingleLineText,\n      });\n\n      expect(updatedField.type).toBe(FieldType.SingleLineText);\n      expect(updatedField.dbFieldType).toBe(DbFieldType.Text);\n      expect(updatedField.cellValueType).toBe(CellValueType.String);\n      expect(updatedField.hasError ?? null).toBeNull();\n    });\n  });\n\n  describe('convert user field', () => {\n    bfAf();\n\n    it('should convert the dbFieldName and name with options change', async () => {\n      const oldFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n        dbFieldName: 'textDbFieldName',\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.User,\n        dbFieldName: 'convertTextDbFieldName',\n        name: 'convertTextFieldName',\n      };\n\n      const { newField } = await expectUpdate(table1, oldFieldRo, newFieldRo, [\n        globalThis.testConfig.userName,\n        globalThis.testConfig.email,\n        globalThis.testConfig.userId,\n      ]);\n      expect(newField.name).toEqual('convertTextFieldName');\n      expect(newField.dbFieldName).toEqual('convertTextDbFieldName');\n    });\n\n    it('should convert user field', async () => {\n      const oldFieldRo: IFieldRo = {\n        name: 'TextField',\n        description: 'hello',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'New Name',\n        type: FieldType.User,\n      };\n\n      const { newField } = await expectUpdate(table1, oldFieldRo, newFieldRo, [\n        globalThis.testConfig.userName,\n        globalThis.testConfig.email,\n        globalThis.testConfig.userId,\n      ]);\n      expect(newField.type).toEqual(FieldType.User);\n\n      const { records } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [newField.id],\n      });\n      const notEmptyRecordsFields = records\n        .filter((r) => r.fields[newField.id] != null)\n        .map((r) => (r.fields[newField.id] as IUserCellValue).id);\n      expect(notEmptyRecordsFields).toHaveLength(3);\n      expect(notEmptyRecordsFields).toEqual([\n        globalThis.testConfig.userId,\n        globalThis.testConfig.userId,\n        globalThis.testConfig.userId,\n      ]);\n    });\n\n    it('should convert user field with multiple values', async () => {\n      // Create two new users\n      const user1Email = 'multiuser1@example.com';\n      const user2Email = 'multiuser2@example.com';\n      const user1Request = await createNewUserAxios({\n        email: user1Email,\n        password: '12345678',\n      });\n      const user2Request = await createNewUserAxios({\n        email: user2Email,\n        password: '12345678',\n      });\n\n      // Get user information\n      const user1Info = (await user1Request.get<IUserMeVo>(USER_ME)).data;\n      const user2Info = (await user2Request.get<IUserMeVo>(USER_ME)).data;\n\n      // Add users as collaborators to the base\n      await emailBaseInvitation({\n        baseId,\n        emailBaseInvitationRo: {\n          emails: [user1Email, user2Email],\n          role: baseRole.Editor,\n        },\n      });\n\n      const oldFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'UserField',\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          shouldNotify: false,\n        },\n      };\n      const { newField: newField, values: values } = await expectUpdate(\n        table1,\n        oldFieldRo,\n        newFieldRo,\n        [\n          `${user1Info.id}, ${user2Info.name}, ${globalThis.testConfig.email}`,\n          `${user1Info.email},${user2Info.id}`,\n        ]\n      );\n      expect(newField.type).toEqual(FieldType.User);\n      expect(values[0]).toHaveLength(3);\n      expect((values[0] as IUserCellValue[]).map((u) => u.id).sort()).toEqual(\n        [user1Info.id, user2Info.id, globalThis.testConfig.userId].sort()\n      );\n      expect(values[1]).toHaveLength(2);\n      expect((values[1] as IUserCellValue[]).map((u) => u.id).sort()).toEqual(\n        [user1Info.id, user2Info.id].sort()\n      );\n\n      // Delete users from collaborators\n      await deleteBaseCollaborator({\n        baseId,\n        deleteBaseCollaboratorRo: {\n          principalId: user1Info.id,\n          principalType: PrincipalType.User,\n        },\n      });\n      await deleteBaseCollaborator({\n        baseId,\n        deleteBaseCollaboratorRo: {\n          principalId: user2Info.id,\n          principalType: PrincipalType.User,\n        },\n      });\n    });\n\n    it('should convert user field with single value', async () => {\n      // Create two new users\n      const userEmail = 'singleuser@example.com';\n      const userRequest = await createNewUserAxios({\n        email: userEmail,\n        password: '12345678',\n      });\n\n      // Get user information\n      const userInfo = (await userRequest.get<IUserMeVo>(USER_ME)).data;\n\n      // Add users as collaborators to the base\n      await emailBaseInvitation({\n        baseId,\n        emailBaseInvitationRo: {\n          emails: [userEmail],\n          role: baseRole.Editor,\n        },\n      });\n\n      const oldFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n      };\n      const newFieldRo: IFieldRo = {\n        name: 'UserField',\n        type: FieldType.User,\n        options: {\n          isMultiple: false,\n          shouldNotify: false,\n        },\n      };\n      const { newField: newField, values: values } = await expectUpdate(\n        table1,\n        oldFieldRo,\n        newFieldRo,\n        [\n          `${userInfo.id}, ${globalThis.testConfig.email}`,\n          `${globalThis.testConfig.email},${userInfo.id}`,\n        ]\n      );\n\n      expect(newField.type).toEqual(FieldType.User);\n      expect((values[0] as IUserCellValue).id).toEqual(userInfo.id);\n      expect((values[1] as IUserCellValue).id).toEqual(globalThis.testConfig.userId);\n\n      // Delete user from collaborators\n      await deleteBaseCollaborator({\n        baseId,\n        deleteBaseCollaboratorRo: {\n          principalId: userInfo.id,\n          principalType: PrincipalType.User,\n        },\n      });\n    });\n  });\n\n  describe('convert button field', () => {\n    bfAf();\n\n    it('should convert the dbFieldName and name with options change', async () => {\n      const buttonFieldRo: IFieldRo = {\n        type: FieldType.Button,\n        options: {\n          label: 'buttonField2',\n          color: Colors.Red,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'workflow1',\n            isActive: true,\n          },\n        },\n        dbFieldName: 'buttonDbFieldName',\n        name: 'buttonFieldName',\n      };\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Button,\n        options: {\n          label: 'buttonField2',\n          color: Colors.Red,\n        },\n        dbFieldName: 'convertButtonDbFieldName',\n        name: 'convertButtonFieldName',\n      };\n      const { newField } = await expectUpdate(table1, buttonFieldRo, newFieldRo);\n      expect(newField.name).toEqual('convertButtonFieldName');\n      expect(newField.dbFieldName).toEqual('convertButtonDbFieldName');\n    });\n\n    it('should convert button field to text', async () => {\n      const buttonFieldRo: IFieldRo = {\n        type: FieldType.Button,\n        options: {\n          label: 'buttonField2',\n          color: Colors.Red,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'workflow1',\n            isActive: true,\n          },\n        },\n      };\n      const buttonField = await createField(table1.id, buttonFieldRo);\n\n      const clickRes = await buttonClick(table1.id, table1.records[0].id, buttonField.id);\n      const clickValue = clickRes.data.record.fields[buttonField.id] as IButtonFieldCellValue;\n      expect(clickValue.count).toEqual(1);\n\n      const newFieldRo: IFieldRo = {\n        ...buttonFieldRo,\n        options: {\n          ...buttonFieldRo.options,\n          workflow: null,\n        } as IButtonFieldOptions,\n      };\n\n      await convertField(table1.id, buttonField.id, newFieldRo);\n\n      const { records: newRecords } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [buttonField.id],\n      });\n\n      expect(newRecords[0].fields[buttonField.id]).toBeUndefined();\n    });\n  });\n\n  describe('modify primary field', () => {\n    bfAf();\n\n    it('should modify general property', async () => {\n      const primaryField = table1.fields[0];\n      const primaryFieldId = primaryField.id;\n      const newFieldRo: IFieldRo = {\n        ...primaryField,\n        dbFieldName: 'id',\n      };\n\n      const field = await convertField(table1.id, primaryField.id, newFieldRo);\n      expect(field.dbFieldName).toEqual('id');\n\n      const uniqueFieldRo: IFieldRo = {\n        ...field,\n        unique: true,\n      };\n\n      const uniqueField = await convertField(table1.id, primaryFieldId, uniqueFieldRo);\n      expect(uniqueField.unique).toEqual(true);\n      const matchedIndexes1 = await fieldService.findUniqueIndexesForField(\n        table1.dbTableName,\n        uniqueField.dbFieldName\n      );\n      expect(matchedIndexes1).toHaveLength(1);\n\n      const dropUniqueFieldRo: IFieldRo = {\n        ...uniqueField,\n        unique: false,\n      };\n\n      const dropUniqueField = await convertField(table1.id, primaryFieldId, dropUniqueFieldRo);\n      expect(dropUniqueField.unique).toEqual(false);\n      const matchedIndexes2 = await fieldService.findUniqueIndexesForField(\n        table1.dbTableName,\n        dropUniqueField.dbFieldName\n      );\n      expect(matchedIndexes2).toHaveLength(0);\n    });\n\n    it('should modify old unique property', async () => {\n      const field = table1.fields[0];\n      const matchedIndexes = await fieldService.findUniqueIndexesForField(\n        table1.dbTableName,\n        field.dbFieldName\n      );\n      expect(matchedIndexes).toHaveLength(0);\n\n      const sql = knex.schema\n        .alterTable(table1.dbTableName, (table) => {\n          table.unique([field.dbFieldName], {});\n        })\n        .toQuery();\n\n      await prisma.txClient().$executeRawUnsafe(sql);\n\n      const matchedIndexes1 = await fieldService.findUniqueIndexesForField(\n        table1.dbTableName,\n        field.dbFieldName\n      );\n      expect(matchedIndexes1).toHaveLength(1);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-delete-references.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport {\n  ColorConfigType,\n  FieldType,\n  Relationship,\n  SortFunc,\n  ViewType,\n  type IFilterRo,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createBase,\n  getFieldDeleteReferences,\n  permanentDeleteBase,\n  updateViewGroup,\n  updateViewSort,\n} from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  createView,\n  initApp,\n  permanentDeleteTable,\n  updateViewFilter,\n} from './utils/init-app';\n\ndescribe('OpenAPI get field delete references (e2e)', () => {\n  let app: INestApplication | undefined;\n  let prisma: PrismaService;\n  let baseId: string;\n  const spaceId = globalThis.testConfig.spaceId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = appCtx.app.get(PrismaService);\n    const base = await createBase({\n      spaceId,\n      name: 'DeleteRefBase',\n    });\n    baseId = base.data.id;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteBase(baseId);\n    if (app) {\n      await app.close();\n    }\n  });\n\n  describe('dependent field analysis', () => {\n    let hostTable: ITableFullVo | undefined;\n    let foreignTable: ITableFullVo | undefined;\n    let table: ITableFullVo | undefined;\n\n    afterEach(async () => {\n      if (hostTable?.id) {\n        await permanentDeleteTable(baseId, hostTable.id);\n      }\n      if (foreignTable?.id) {\n        await permanentDeleteTable(baseId, foreignTable.id);\n      }\n      if (table?.id) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n      hostTable = undefined;\n      foreignTable = undefined;\n      table = undefined;\n    });\n\n    it('detects one-way link display dependencies via lookupFieldId and visibleFieldIds', async () => {\n      foreignTable = await createTable(baseId, {\n        name: 'DeleteRefForeign',\n        fields: [\n          { name: 'Display Field', type: FieldType.SingleLineText },\n          { name: 'Other Field', type: FieldType.SingleLineText },\n        ],\n      });\n      hostTable = await createTable(baseId, {\n        name: 'DeleteRefHost',\n      });\n\n      const displayField = foreignTable.fields.find((f) => f.name === 'Display Field')!;\n      const otherField = foreignTable.fields.find((f) => f.name === 'Other Field')!;\n\n      const hostLinkField = await createField(hostTable.id, {\n        name: 'Foreign Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n          isOneWay: true,\n          lookupFieldId: displayField.id,\n          visibleFieldIds: [displayField.id],\n        },\n      });\n\n      const displayRefs = await getFieldDeleteReferences(foreignTable.id, [displayField.id]);\n      const displayDepItems = displayRefs.data[displayField.id].dependentFields.filter(\n        (item) => item.id === hostLinkField.id\n      );\n      expect(displayDepItems).toHaveLength(1);\n      expect(displayDepItems[0]).toMatchObject({\n        id: hostLinkField.id,\n        name: hostLinkField.name,\n        type: FieldType.Link,\n        source: {\n          id: hostTable.id,\n          name: hostTable.name,\n        },\n      });\n\n      const otherRefs = await getFieldDeleteReferences(foreignTable.id, [otherField.id]);\n      expect(\n        otherRefs.data[otherField.id].dependentFields.some((item) => item.id === hostLinkField.id)\n      ).toBeFalsy();\n    });\n\n    it('excludes fields that are deleted in the same batch from dependentFields', async () => {\n      table = await createTable(baseId, {\n        name: 'DeleteRefBatch',\n        fields: [{ name: 'Source', type: FieldType.SingleLineText }],\n      });\n\n      const sourceField = table.fields.find((f) => f.name === 'Source')!;\n      const formulaField = await createField(table.id, {\n        name: 'Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceField.id}}`,\n        },\n      });\n\n      const singleDeleteRefs = await getFieldDeleteReferences(table.id, [sourceField.id]);\n      expect(\n        singleDeleteRefs.data[sourceField.id].dependentFields.some(\n          (item) => item.id === formulaField.id\n        )\n      ).toBeTruthy();\n\n      const batchDeleteRefs = await getFieldDeleteReferences(table.id, [\n        sourceField.id,\n        formulaField.id,\n      ]);\n      expect(\n        batchDeleteRefs.data[sourceField.id].dependentFields.some(\n          (item) => item.id === formulaField.id\n        )\n      ).toBeFalsy();\n    });\n\n    it('returns empty references for out-of-table or missing field ids', async () => {\n      hostTable = await createTable(baseId, {\n        name: 'DeleteRefMainTable',\n      });\n      foreignTable = await createTable(baseId, {\n        name: 'DeleteRefOtherTable',\n      });\n\n      const foreignPrimaryFieldId = foreignTable.fields[0].id;\n      const missingFieldId = 'fld_missing_delete_ref';\n\n      const refs = await getFieldDeleteReferences(hostTable.id, [\n        foreignPrimaryFieldId,\n        missingFieldId,\n      ]);\n\n      expect(refs.data[foreignPrimaryFieldId]).toEqual({\n        workflowNodes: [],\n        authorityMatrixRoles: [],\n        views: [],\n        dependentFields: [],\n      });\n      expect(refs.data[missingFieldId]).toEqual({\n        workflowNodes: [],\n        authorityMatrixRoles: [],\n        views: [],\n        dependentFields: [],\n      });\n    });\n\n    it('detects view references from filters and all supported view options', async () => {\n      const textFieldName = 'Text Field';\n      const statusFieldName = 'Status';\n      const attachmentFieldName = 'Attachment';\n      const startDateFieldName = 'Start Date';\n      const endDateFieldName = 'End Date';\n      table = await createTable(baseId, {\n        name: 'DeleteRefViews',\n        fields: [\n          { name: textFieldName, type: FieldType.SingleLineText },\n          { name: statusFieldName, type: FieldType.SingleSelect },\n          { name: attachmentFieldName, type: FieldType.Attachment },\n          { name: startDateFieldName, type: FieldType.Date },\n          { name: endDateFieldName, type: FieldType.Date },\n        ],\n      });\n\n      const textField = table.fields.find((f) => f.name === textFieldName)!;\n      const statusField = table.fields.find((f) => f.name === statusFieldName)!;\n      const attachmentField = table.fields.find((f) => f.name === attachmentFieldName)!;\n      const startDateField = table.fields.find((f) => f.name === startDateFieldName)!;\n      const endDateField = table.fields.find((f) => f.name === endDateFieldName)!;\n\n      const filterView = await createView(table.id, { name: 'Filter View', type: ViewType.Grid });\n      const filterRo: IFilterRo = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [{ fieldId: textField.id, operator: 'is', value: 'x' }],\n        },\n      };\n      await updateViewFilter(table.id, filterView.id, filterRo);\n\n      const sortView = await createView(table.id, { name: 'Sort View', type: ViewType.Grid });\n      await updateViewSort(table.id, sortView.id, {\n        sort: { sortObjs: [{ fieldId: textField.id, order: SortFunc.Asc }] },\n      });\n\n      const groupView = await createView(table.id, { name: 'Group View', type: ViewType.Grid });\n      await updateViewGroup(table.id, groupView.id, {\n        group: [{ fieldId: textField.id, order: SortFunc.Desc }],\n      });\n\n      const gridView = await createView(table.id, {\n        name: 'Grid View',\n        type: ViewType.Grid,\n        options: { frozenFieldId: textField.id },\n      });\n\n      const kanbanView = await createView(table.id, {\n        name: 'Kanban View',\n        type: ViewType.Kanban,\n        options: { stackFieldId: statusField.id, coverFieldId: attachmentField.id },\n      });\n\n      const galleryView = await createView(table.id, {\n        name: 'Gallery View',\n        type: ViewType.Gallery,\n        options: { coverFieldId: attachmentField.id },\n      });\n\n      const calendarView = await createView(table.id, {\n        name: 'Calendar View',\n        type: ViewType.Calendar,\n        options: {\n          startDateFieldId: startDateField.id,\n          endDateFieldId: endDateField.id,\n          titleFieldId: textField.id,\n          colorConfig: {\n            type: ColorConfigType.Field,\n            fieldId: statusField.id,\n          },\n        },\n      });\n\n      const refs = await getFieldDeleteReferences(table.id, [\n        textField.id,\n        statusField.id,\n        attachmentField.id,\n        startDateField.id,\n        endDateField.id,\n      ]);\n      const textRefViewIds = refs.data[textField.id].views.map((view) => view.id);\n      expect(textRefViewIds).toEqual(\n        expect.arrayContaining([\n          filterView.id,\n          sortView.id,\n          groupView.id,\n          gridView.id,\n          calendarView.id,\n        ])\n      );\n\n      const statusRefViewIds = refs.data[statusField.id].views.map((view) => view.id);\n      expect(statusRefViewIds).toEqual(expect.arrayContaining([kanbanView.id, calendarView.id]));\n\n      const attachmentRefViewIds = refs.data[attachmentField.id].views.map((view) => view.id);\n      expect(attachmentRefViewIds).toEqual(expect.arrayContaining([kanbanView.id, galleryView.id]));\n\n      const startDateRefViewIds = refs.data[startDateField.id].views.map((view) => view.id);\n      expect(startDateRefViewIds).toContain(calendarView.id);\n\n      const endDateRefViewIds = refs.data[endDateField.id].views.map((view) => view.id);\n      expect(endDateRefViewIds).toContain(calendarView.id);\n    });\n\n    it('ignores malformed view JSON and still returns references safely', async () => {\n      const textFieldName = 'Text Field';\n      const malformedJson = '{broken-json';\n      table = await createTable(baseId, {\n        name: 'DeleteRefMalformedView',\n        fields: [{ name: textFieldName, type: FieldType.SingleLineText }],\n      });\n\n      const textField = table.fields.find((f) => f.name === textFieldName)!;\n\n      await prisma.view.update({\n        where: { id: table.defaultViewId! },\n        data: {\n          filter: malformedJson,\n          sort: malformedJson,\n          group: malformedJson,\n          options: malformedJson,\n        },\n      });\n\n      const refs = await getFieldDeleteReferences(table.id, [textField.id]);\n      expect(refs.data[textField.id]).toEqual({\n        workflowNodes: [],\n        authorityMatrixRoles: [],\n        views: [],\n        dependentFields: [],\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-duplicate.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IButtonFieldCellValue,\n  IFieldRo,\n  ILinkFieldOptions,\n  INumberFormatting,\n} from '@teable/core';\nimport {\n  Colors,\n  FieldKeyType,\n  FieldType,\n  generateFieldId,\n  generateWorkflowId,\n  Relationship,\n  ViewType,\n} from '@teable/core';\nimport type { ICreateBaseVo, ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  getFields,\n  duplicateField,\n  createView,\n  getView,\n  buttonClick,\n  createBase,\n} from '@teable/openapi';\nimport { omit, pick } from 'lodash';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\n\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  createRecords,\n  getRecords,\n} from './utils/init-app';\n\ndescribe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const spaceId = globalThis.testConfig.spaceId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  describe('duplicate formula fields with auto number metadata', () => {\n    let table: ITableFullVo;\n    let autoFieldId: string;\n    let autoLenFieldId: string;\n\n    beforeAll(async () => {\n      autoFieldId = generateFieldId();\n      table = await createTable(baseId, {\n        name: 'auto-len-duplicate',\n        fields: [\n          {\n            id: autoFieldId,\n            name: 'auto',\n            type: FieldType.AutoNumber,\n          },\n        ],\n      });\n\n      await createField(table.id, {\n        name: 'auto-len',\n        type: FieldType.Formula,\n        options: {\n          expression: `LEN({${autoFieldId}})`,\n        },\n      });\n      const fields = (await getFields(table.id)).data;\n      autoLenFieldId = fields.find((f) => f.name === 'auto-len')?.id ?? '';\n      expect(autoLenFieldId).toBeTruthy();\n\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should duplicate formula and preserve evaluation on auto number columns', async () => {\n      const duplicated = await duplicateField(table.id, autoLenFieldId, {\n        name: 'auto-len-copy',\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const first = records[0];\n\n      expect(first.fields[autoLenFieldId]).toEqual(1);\n      expect(first.fields[duplicated.data.id]).toEqual(1);\n    });\n  });\n\n  describe('duplicate field response compatibility under FORCE_V2', () => {\n    let table: ITableFullVo;\n    let foreignTable: ITableFullVo;\n    let linkFieldId: string;\n    let foreignPrimaryFieldId: string;\n\n    beforeAll(async () => {\n      foreignTable = await createTable(baseId, {\n        name: 'dup_force_v2_compat_foreign',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'foreign_name',\n          },\n        ],\n      });\n      foreignPrimaryFieldId = foreignTable.fields.find((f) => f.isPrimary)!.id;\n\n      table = await createTable(baseId, {\n        name: 'dup_force_v2_compat_main',\n      });\n\n      const linkField = (\n        await createField(table.id, {\n          type: FieldType.Link,\n          name: 'to_foreign',\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreignTable.id,\n            isOneWay: false,\n          },\n        })\n      ).data;\n      linkFieldId = linkField.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('keeps description but omits false/linked compatibility keys in duplicated fields', async () => {\n      const describedField = (\n        await createField(table.id, {\n          type: FieldType.Number,\n          name: 'number_with_description',\n          description: 'description_kept',\n        })\n      ).data;\n      const duplicatedDescribedField = (\n        await duplicateField(table.id, describedField.id, {\n          name: 'number_with_description_copy',\n        })\n      ).data;\n      expect(duplicatedDescribedField.description).toBe('description_kept');\n\n      const lookupField = (\n        await createField(table.id, {\n          type: FieldType.SingleLineText,\n          name: 'lookup_force_v2_compat',\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreignTable.id,\n            linkFieldId,\n            lookupFieldId: foreignPrimaryFieldId,\n          },\n        })\n      ).data;\n      const duplicatedLookupField = (\n        await duplicateField(table.id, lookupField.id, {\n          name: 'lookup_force_v2_compat_copy',\n        })\n      ).data;\n      const duplicatedLookupOptions = duplicatedLookupField.lookupOptions as\n        | Record<string, unknown>\n        | undefined;\n\n      expect(Object.prototype.hasOwnProperty.call(duplicatedLookupOptions ?? {}, 'isOneWay')).toBe(\n        false\n      );\n      expect(\n        Object.prototype.hasOwnProperty.call(duplicatedLookupOptions ?? {}, 'symmetricFieldId')\n      ).toBe(false);\n\n      const rollupField = (\n        await createField(table.id, {\n          type: FieldType.Rollup,\n          name: 'rollup_force_v2_compat',\n          lookupOptions: {\n            foreignTableId: foreignTable.id,\n            linkFieldId,\n            lookupFieldId: foreignPrimaryFieldId,\n          },\n          options: {\n            expression: 'countall({values})',\n          },\n        })\n      ).data;\n      const duplicatedRollupField = (\n        await duplicateField(table.id, rollupField.id, {\n          name: 'rollup_force_v2_compat_copy',\n        })\n      ).data;\n      const duplicatedRollupLookupOptions = duplicatedRollupField.lookupOptions as\n        | Record<string, unknown>\n        | undefined;\n\n      expect(\n        Object.prototype.hasOwnProperty.call(duplicatedRollupLookupOptions ?? {}, 'isOneWay')\n      ).toBe(false);\n      expect(\n        Object.prototype.hasOwnProperty.call(\n          duplicatedRollupLookupOptions ?? {},\n          'symmetricFieldId'\n        )\n      ).toBe(false);\n\n      const buttonField = (\n        await createField(table.id, {\n          type: FieldType.Button,\n          name: 'button_force_v2_compat',\n          options: {\n            label: 'go',\n            color: Colors.Blue,\n            workflow: {\n              id: generateWorkflowId(),\n              name: 'wf_for_compat',\n              isActive: true,\n            },\n          },\n        })\n      ).data;\n      const duplicatedButtonField = (\n        await duplicateField(table.id, buttonField.id, {\n          name: 'button_force_v2_compat_copy',\n        })\n      ).data;\n\n      expect(duplicatedButtonField.isMultipleCellValue).toBeUndefined();\n    });\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('duplicate all common fields', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const nonCommonFieldType = [\n        FieldType.Link,\n        FieldType.Rollup,\n        FieldType.Formula,\n        FieldType.Button,\n      ];\n      const commonFields = table.fields.filter((field) => !nonCommonFieldType.includes(field.type));\n\n      for (const field of commonFields) {\n        await duplicateField(table.id, field.id, {\n          name: `${field.name}_copy`,\n        });\n      }\n\n      const fields = (await getFields(table.id)).data;\n      const copiedFields = fields.filter((field) => field.name.endsWith('_copy'));\n\n      expect(copiedFields.length).toBe(commonFields.length);\n\n      expect(copiedFields.map((f) => omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']))).toEqual(\n        commonFields.map((f) => omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']))\n      );\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n  });\n\n  describe('duplicate cross-base link fields', () => {\n    let table: ITableFullVo;\n    let crossTable: ITableFullVo;\n    let otherBase: ICreateBaseVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'main_table',\n        fields: x_20.fields,\n      });\n\n      otherBase = (\n        await createBase({\n          spaceId,\n          name: 'other-base',\n        })\n      ).data;\n\n      crossTable = await createTable(otherBase.id, {\n        name: 'record_query_x_20',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'single_line_text',\n          },\n        ],\n      });\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, crossTable.id);\n    });\n\n    it('should duplicate link field with cross-base table', async () => {\n      const linkField = (\n        await createField(table.id, {\n          type: FieldType.Link,\n          name: 'link',\n          options: {\n            baseId: otherBase.id,\n            foreignTableId: crossTable.id,\n            relationship: Relationship.ManyMany,\n          },\n        })\n      ).data;\n\n      const copiedLinkField = (\n        await duplicateField(table.id, linkField.id, {\n          name: `${linkField.name}_copy`,\n        })\n      ).data;\n\n      expect(\n        pick(copiedLinkField.options, ['baseId', 'foreignTableId', 'relationship', 'isOneWay'])\n      ).toEqual({\n        baseId: otherBase.id,\n        foreignTableId: crossTable.id,\n        relationship: Relationship.ManyMany,\n        isOneWay: true,\n      });\n    });\n  });\n\n  describe('duplicate lookup with nested multi-hop dependencies', () => {\n    let seasonTable: ITableFullVo;\n    let productTable: ITableFullVo;\n    let mainTable: ITableFullVo;\n    let seasonNameFieldId: string;\n    let productNameFieldId: string;\n    let orderNameFieldId: string;\n    let productSeasonLinkId: string;\n    let productSeasonLookupId: string;\n    let mainProductLinkId: string;\n    let mainSeasonLookupId: string;\n\n    beforeAll(async () => {\n      seasonTable = await createTable(baseId, {\n        name: 'season_table_nested_lookup',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'season_name',\n          },\n        ],\n      });\n      seasonNameFieldId = seasonTable.fields.find((f) => f.name === 'season_name')!.id;\n      const seasonRecords = await createRecords(seasonTable.id, {\n        records: [\n          { fields: { [seasonNameFieldId]: 'Spring' } },\n          { fields: { [seasonNameFieldId]: 'Autumn' } },\n        ],\n      });\n\n      productTable = await createTable(baseId, {\n        name: 'product_table_nested_lookup',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'product_name',\n          },\n        ],\n      });\n      productNameFieldId = productTable.fields.find((f) => f.name === 'product_name')!.id;\n\n      const productSeasonLink = (\n        await createField(productTable.id, {\n          type: FieldType.Link,\n          name: 'season_link',\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: seasonTable.id,\n          },\n        })\n      ).data;\n      productSeasonLinkId = productSeasonLink.id;\n\n      const productSeasonLookup = (\n        await createField(productTable.id, {\n          type: FieldType.SingleLineText,\n          name: 'season_lookup',\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: seasonTable.id,\n            linkFieldId: productSeasonLinkId,\n            lookupFieldId: seasonNameFieldId,\n          },\n        })\n      ).data;\n      productSeasonLookupId = productSeasonLookup.id;\n\n      const productRecords = await createRecords(productTable.id, {\n        records: [\n          {\n            fields: {\n              [productNameFieldId]: 'Starter Pack',\n              [productSeasonLinkId]: [{ id: seasonRecords.records[0].id }],\n            },\n          },\n          {\n            fields: {\n              [productNameFieldId]: 'Advanced Pack',\n              [productSeasonLinkId]: [{ id: seasonRecords.records[1].id }],\n            },\n          },\n        ],\n      });\n\n      mainTable = await createTable(baseId, {\n        name: 'main_table_nested_lookup',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'order_name',\n          },\n        ],\n      });\n      orderNameFieldId = mainTable.fields.find((f) => f.name === 'order_name')!.id;\n\n      const mainProductLink = (\n        await createField(mainTable.id, {\n          type: FieldType.Link,\n          name: 'product_link',\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: productTable.id,\n          },\n        })\n      ).data;\n      mainProductLinkId = mainProductLink.id;\n\n      const mainSeasonLookup = (\n        await createField(mainTable.id, {\n          type: FieldType.SingleLineText,\n          name: 'season_lookup',\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: productTable.id,\n            linkFieldId: mainProductLinkId,\n            lookupFieldId: productSeasonLookupId,\n          },\n        })\n      ).data;\n      mainSeasonLookupId = mainSeasonLookup.id;\n\n      await createRecords(mainTable.id, {\n        records: [\n          {\n            fields: {\n              [orderNameFieldId]: 'Order-1',\n              [mainProductLinkId]: productRecords.records.map((rec) => ({ id: rec.id })),\n            },\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, mainTable.id);\n      await permanentDeleteTable(baseId, productTable.id);\n      await permanentDeleteTable(baseId, seasonTable.id);\n    });\n\n    it('duplicates multi-hop lookup field without missing CTEs', async () => {\n      const duplicatedLookup = (\n        await duplicateField(mainTable.id, mainSeasonLookupId, {\n          name: 'season_lookup_copy',\n        })\n      ).data;\n\n      expect(duplicatedLookup.isLookup).toBe(true);\n      expect(duplicatedLookup.lookupOptions?.lookupFieldId).toBe(productSeasonLookupId);\n\n      const records = await getRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [orderNameFieldId, mainSeasonLookupId, duplicatedLookup.id],\n      });\n\n      const orderRecord = records.records.find(\n        (record) => record.fields[orderNameFieldId] === 'Order-1'\n      );\n      expect(orderRecord).toBeDefined();\n      expect(orderRecord!.fields[duplicatedLookup.id]).toEqual(\n        orderRecord!.fields[mainSeasonLookupId]\n      );\n    });\n  });\n\n  describe('duplicate link fields', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const linkFields = table.fields.filter(\n        (field) => field.type === FieldType.Link && !field.isLookup\n      );\n\n      for (const field of linkFields) {\n        await duplicateField(table.id, field.id, {\n          name: `${field.name}_copy`,\n        });\n      }\n\n      const fields = (await getFields(table.id)).data;\n      const copiedFields = fields.filter((field) => field.name.endsWith('_copy'));\n\n      expect(copiedFields.length).toBe(linkFields.length);\n\n      const copiedLinkFields = copiedFields\n        .filter((field) => field.type === FieldType.Link)\n        .map((f) => {\n          return {\n            ...omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']),\n            options: {\n              ...pick(f.options, ['foreignTableId', 'isOneWay', 'relationship', 'lookupFieldId']),\n            },\n          };\n        });\n\n      const assertLinkFields = linkFields.map((f) => {\n        return {\n          ...omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']),\n          options: {\n            ...pick(f.options, ['foreignTableId', 'isOneWay', 'relationship', 'lookupFieldId']),\n            // all be one way\n            isOneWay: true,\n          },\n        };\n      });\n\n      expect(copiedLinkFields).toEqual(assertLinkFields);\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n  });\n\n  describe('duplicate link field should copy cell data', () => {\n    let foreignTable: ITableFullVo;\n    let mainTable: ITableFullVo;\n    let linkFieldId: string;\n\n    beforeAll(async () => {\n      // create foreign table with some records\n      foreignTable = await createTable(baseId, { name: 'dup_link_foreign' });\n      const primaryFieldId = foreignTable.fields.find((f) => f.isPrimary)!.id;\n      const created = await createRecords(foreignTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { fields: { [primaryFieldId]: 'A1' } },\n          { fields: { [primaryFieldId]: 'A2' } },\n          { fields: { [primaryFieldId]: 'A3' } },\n        ],\n      });\n\n      // create main table and a link field to foreignTable\n      mainTable = await createTable(baseId, { name: 'dup_link_main' });\n      const linkField = (\n        await createField(mainTable.id, {\n          type: FieldType.Link,\n          name: 'link_to_foreign',\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreignTable.id,\n          },\n        })\n      ).data;\n      linkFieldId = linkField.id;\n\n      // create records in main table with link values\n      await createRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [linkFieldId]: [{ id: created.records[0].id }, { id: created.records[1].id }],\n            },\n          },\n          {\n            fields: {\n              [linkFieldId]: [{ id: created.records[2].id }],\n            },\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, mainTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('should duplicate link field and preserve all cell values', async () => {\n      const copied = (\n        await duplicateField(mainTable.id, linkFieldId, {\n          name: 'link_to_foreign_copy',\n        })\n      ).data;\n\n      const { records } = await getRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      for (const r of records) {\n        expect(r.fields[copied.id]).toEqual(r.fields[linkFieldId]);\n      }\n    });\n  });\n\n  describe('duplicate common fields should copy cell data', () => {\n    let table: ITableFullVo;\n    let textFieldId: string;\n    let numberFieldId: string;\n    let checkboxFieldId: string;\n\n    beforeAll(async () => {\n      // create base table\n      table = await createTable(baseId, { name: 'dup_common_main' });\n\n      // add three common fields\n      textFieldId = (\n        await createField(table.id, {\n          type: FieldType.SingleLineText,\n          name: 'text_col',\n        })\n      ).data.id;\n\n      numberFieldId = (\n        await createField(table.id, {\n          type: FieldType.Number,\n          name: 'num_col',\n        })\n      ).data.id;\n\n      checkboxFieldId = (\n        await createField(table.id, {\n          type: FieldType.Checkbox,\n          name: 'bool_col',\n        })\n      ).data.id;\n\n      // seed a few records with mixed values (including nulls/false)\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [textFieldId]: 'hello',\n              [numberFieldId]: 42,\n              [checkboxFieldId]: true,\n            },\n          },\n          {\n            fields: {\n              [textFieldId]: 'world',\n              [numberFieldId]: null,\n              [checkboxFieldId]: false,\n            },\n          },\n          {\n            fields: {\n              [textFieldId]: null,\n              [numberFieldId]: 0,\n              [checkboxFieldId]: true,\n            },\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should duplicate text/number/checkbox fields and preserve all cell values', async () => {\n      const copiedText = (\n        await duplicateField(table.id, textFieldId, {\n          name: 'text_col_copy',\n        })\n      ).data;\n\n      const copiedNumber = (\n        await duplicateField(table.id, numberFieldId, {\n          name: 'num_col_copy',\n        })\n      ).data;\n\n      const copiedCheckbox = (\n        await duplicateField(table.id, checkboxFieldId, {\n          name: 'bool_col_copy',\n        })\n      ).data;\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n\n      for (const r of records) {\n        expect(r.fields[copiedText.id]).toEqual(r.fields[textFieldId]);\n        expect(r.fields[copiedNumber.id]).toEqual(r.fields[numberFieldId]);\n        expect(r.fields[copiedCheckbox.id]).toEqual(r.fields[checkboxFieldId]);\n      }\n    });\n  });\n\n  describe('duplicate lookup fields', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const lookupFields = table.fields.filter((field) => field.isLookup);\n\n      for (const field of lookupFields) {\n        await duplicateField(table.id, field.id, {\n          name: `${field.name}_copy`,\n        });\n      }\n\n      const fields = (await getFields(table.id)).data;\n      const copiedFields = fields.filter((field) => field.name.endsWith('_copy'));\n\n      expect(copiedFields.length).toBe(lookupFields.length);\n\n      expect(copiedFields.map((f) => omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']))).toEqual(\n        lookupFields.map((f) => omit(f, ['name', 'dbFieldName', 'id', 'isPrimary']))\n      );\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n  });\n\n  describe('duplicate rollup fields', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const linkField = table.fields.filter(\n        (field) => field.type === FieldType.Link && !field.isLookup\n      )[0]!;\n\n      const linkOption = linkField.options as ILinkFieldOptions;\n\n      const rollupField = (\n        await createField(table.id, {\n          type: FieldType.Rollup,\n          name: 'rollup_field',\n          lookupOptions: {\n            foreignTableId: linkOption.foreignTableId,\n            lookupFieldId: linkOption.lookupFieldId,\n            linkFieldId: linkField.id,\n          },\n          options: {\n            expression: 'countall({values})',\n            formatting: {\n              precision: 2,\n              type: 'decimal',\n            } as INumberFormatting,\n            timeZone: 'Asia/Shanghai',\n          },\n        })\n      ).data;\n\n      await duplicateField(table.id, rollupField.id, {\n        name: `${rollupField.name}_copy`,\n      });\n\n      const fields = (await getFields(table.id)).data;\n\n      const copiedRollupField = fields.find((f) => f.name.endsWith('_copy'))!;\n\n      const expectedRollupField = {\n        ...omit(copiedRollupField, ['name', 'dbFieldName', 'id', 'isPrimary', 'unique']),\n        options: {\n          ...rollupField.options,\n          expression: 'countall({values})',\n        },\n        isPending: true,\n      };\n      const assertRollupField = {\n        ...omit(rollupField, ['name', 'dbFieldName', 'id', 'isPrimary', 'unique']),\n      };\n\n      expect(expectedRollupField).toEqual(assertRollupField);\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n  });\n\n  describe('duplicate button field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should duplicate button field', async () => {\n      const buttonFieldRo: IFieldRo = {\n        name: 'button',\n        type: FieldType.Button,\n        options: {\n          label: 'button label',\n          color: Colors.Red,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'workflow1',\n            isActive: true,\n          },\n        },\n      };\n      const buttonField = (await createField(table1.id, buttonFieldRo)).data;\n\n      const clickRes = await buttonClick(table1.id, table1.records[0].id, buttonField.id);\n      const clickValue = clickRes.data.record.fields[buttonField.id] as IButtonFieldCellValue;\n      expect(clickValue.count).toEqual(1);\n\n      const copiedButtonField = (\n        await duplicateField(table1.id, buttonField.id, {\n          name: `${buttonField.name}_copy`,\n        })\n      ).data;\n\n      expect(copiedButtonField.name).toBe(`${buttonField.name}_copy`);\n      const expectedButtonField = {\n        ...buttonField,\n        options: {\n          ...buttonField.options,\n          workflow: undefined,\n        },\n      };\n\n      const keys = ['name', 'dbFieldName', 'id', 'isPrimary'];\n      expect(omit(expectedButtonField, keys)).toEqual(omit(copiedButtonField, keys));\n    });\n  });\n\n  describe('duplicate field with view new field order should next to the original field', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const view = (\n        await createView(table.id, {\n          name: 'view_x_20',\n          type: ViewType.Grid,\n        })\n      ).data;\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const textField = table.fields.find((f) => f.type === FieldType.SingleLineText)!;\n\n      const fieldCopy = (\n        await duplicateField(table.id, textField.id, {\n          name: `${textField.name}_copy`,\n          viewId: view.id,\n        })\n      ).data;\n\n      const afterDuplicateView = (await getView(table.id, view.id)).data;\n\n      const afterDuplicateFieldIndex = afterDuplicateView.columnMeta[fieldCopy.id]?.order;\n      const originalFieldIndex = view.columnMeta[textField.id]?.order;\n\n      const getterFieldViewOrders = Object.values(view.columnMeta)\n        .filter(({ order }) => originalFieldIndex < order)\n        .map(({ order }) => order);\n\n      const targetFieldViewOrder = getterFieldViewOrders?.length\n        ? (getterFieldViewOrders[0] + originalFieldIndex) / 2\n        : originalFieldIndex + 1;\n\n      expect(afterDuplicateFieldIndex).toBe(targetFieldViewOrder);\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-physical-columns.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { IFieldRo } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { preservedDbFieldNames } from '../src/features/field/constant';\nimport {\n  createField,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n  convertField,\n} from './utils/init-app';\n\ndescribe('Field -> Physical Columns mapping (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let db: IDbProvider;\n  const baseId = (globalThis as any).testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    db = app.get<IDbProvider>(DB_PROVIDER_SYMBOL as any);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const getDbTableName = async (tableId: string) => {\n    const { dbTableName } = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return dbTableName;\n  };\n\n  const getUserColumns = async (dbTableName: string) => {\n    const rows = await prisma.$queryRawUnsafe<{ name: string }[]>(db.columnInfo(dbTableName));\n    return rows.map((r) => r.name).filter((n) => !preservedDbFieldNames.has(n));\n  };\n\n  it('ensures each created field has exactly one physical column on the host table', async () => {\n    // Create main table and a secondary table for links\n    const tMain = await createTable(baseId, { name: 'phys_host' });\n    const tForeign = await createTable(baseId, {\n      name: 'phys_foreign',\n      fields: [{ name: 'FA', type: FieldType.Number } as IFieldRo],\n      records: [{ fields: { FA: 1 } }],\n    });\n    const mainDb = await getDbTableName(tMain.id);\n\n    const initialCols = await getUserColumns(mainDb);\n\n    // 1) Simple scalar fields (should each create a physical column)\n    const fNum = await createField(tMain.id, { name: 'C1', type: FieldType.Number } as IFieldRo);\n    const fText = await createField(tMain.id, {\n      name: 'S',\n      type: FieldType.SingleLineText,\n    } as IFieldRo);\n    const fLong = await createField(tMain.id, { name: 'L', type: FieldType.LongText } as IFieldRo);\n    const fDate = await createField(tMain.id, { name: 'D', type: FieldType.Date } as IFieldRo);\n    const fCheckbox = await createField(tMain.id, {\n      name: 'B',\n      type: FieldType.Checkbox,\n    } as IFieldRo);\n    const fAttach = await createField(tMain.id, {\n      name: 'AT',\n      type: FieldType.Attachment,\n    } as IFieldRo);\n    const fSS = await createField(tMain.id, {\n      name: 'SS',\n      type: FieldType.SingleSelect,\n      // minimal options for select types\n      options: { choices: [{ id: 'opt1', name: 'opt1' }] },\n    } as any);\n    const fMS = await createField(tMain.id, {\n      name: 'MS',\n      type: FieldType.MultipleSelect,\n      options: {\n        choices: [\n          { id: 'o1', name: 'o1' },\n          { id: 'o2', name: 'o2' },\n        ],\n      },\n    } as any);\n    // 2) Formula (simple; tends to be generated on PG)\n    const fFormula1 = await createField(tMain.id, {\n      name: 'F1',\n      type: FieldType.Formula,\n      options: { expression: `{${fNum.id}}` },\n    } as IFieldRo);\n    // 3) Link (ManyMany) -> expect host column\n    const fLinkMM = await createField(tMain.id, {\n      name: 'L_MM',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyMany, foreignTableId: tForeign.id },\n    } as IFieldRo);\n    // 4) Link (ManyOne) -> expect either FK name or host column\n    const fLinkMO = await createField(tMain.id, {\n      name: 'L_MO',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyOne, foreignTableId: tForeign.id },\n    } as IFieldRo);\n    // 5) Lookup on ManyMany link\n    const fLookup = await createField(tMain.id, {\n      name: 'LK',\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: tForeign.id,\n        linkFieldId: (fLinkMM as any).id,\n        lookupFieldId: (tForeign.fields![0] as any).id,\n      } as any,\n    } as any);\n    // 6) Rollup over link\n    const fRoll = await createField(tMain.id, {\n      name: 'R',\n      type: FieldType.Rollup,\n      lookupOptions: {\n        foreignTableId: tForeign.id,\n        linkFieldId: (fLinkMM as any).id,\n        lookupFieldId: (tForeign.fields![0] as any).id,\n      } as any,\n      options: { expression: 'sum({values})' } as any,\n    } as any);\n\n    // 7) A formula referencing lookup (unlikely to be generated)\n    const fFormula2 = await createField(tMain.id, {\n      name: 'F2',\n      type: FieldType.Formula,\n      options: { expression: `{${(fLookup as any).id}}` },\n    } as IFieldRo);\n\n    const finalCols = await getUserColumns(mainDb);\n    const newCols = finalCols.filter((c) => !initialCols.includes(c));\n\n    // Build expected column names on host table\n    const expectedNames = new Set<string>();\n    // Number\n    expectedNames.add((fNum as any).dbFieldName);\n    // Scalar fields\n    expectedNames.add((fText as any).dbFieldName);\n    expectedNames.add((fLong as any).dbFieldName);\n    expectedNames.add((fDate as any).dbFieldName);\n    expectedNames.add((fCheckbox as any).dbFieldName);\n    expectedNames.add((fAttach as any).dbFieldName);\n    expectedNames.add((fSS as any).dbFieldName);\n    expectedNames.add((fMS as any).dbFieldName);\n    // Formula fields (both should have a physical column with dbFieldName — either generated or normal)\n    expectedNames.add((fFormula1 as any).dbFieldName);\n    expectedNames.add((fFormula2 as any).dbFieldName);\n    // Link-ManyMany: we expect a host column reflecting the link field\n    expectedNames.add((fLinkMM as any).dbFieldName);\n    // Link-ManyOne: either the FK column equals dbFieldName (host) or a separate host column was created\n    // In either case, assert host has the dbFieldName to enforce one-to-one\n    expectedNames.add((fLinkMO as any).dbFieldName);\n    // Lookup + Rollup: persisted columns\n    expectedNames.add((fLookup as any).dbFieldName);\n    expectedNames.add((fRoll as any).dbFieldName);\n\n    // Assert: host table contains at least one physical column per created field\n    for (const name of expectedNames) {\n      expect(newCols).toContain(name);\n    }\n\n    await permanentDeleteTable(baseId, tMain.id);\n    await permanentDeleteTable(baseId, tForeign.id);\n  });\n\n  it('converts text -> link (ManyOne/OneOne/OneMany) and ensures physical columns are created without duplication', async () => {\n    const tMain = await createTable(baseId, { name: 'conv_host' });\n    const tForeign = await createTable(baseId, {\n      name: 'conv_foreign',\n      fields: [{ name: 'F', type: FieldType.Number } as IFieldRo],\n      records: [{ fields: { F: 1 } }],\n    });\n    const mainDb = await getDbTableName(tMain.id);\n\n    const initialCols = await getUserColumns(mainDb);\n\n    // Prepare three simple text fields\n    const fTextMO = await createField(tMain.id, { name: 'MO', type: FieldType.SingleLineText });\n    const fTextOO = await createField(tMain.id, { name: 'OO', type: FieldType.SingleLineText });\n    const fTextOM = await createField(tMain.id, { name: 'OM', type: FieldType.SingleLineText });\n\n    // Convert to links with different relationships\n    const linkMO = await convertField(tMain.id, (fTextMO as any).id, {\n      name: (fTextMO as any).name,\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyOne, foreignTableId: tForeign.id },\n    } as IFieldRo);\n\n    const linkOO = await convertField(tMain.id, (fTextOO as any).id, {\n      name: (fTextOO as any).name,\n      type: FieldType.Link,\n      options: { relationship: Relationship.OneOne, foreignTableId: tForeign.id },\n    } as IFieldRo);\n\n    const linkOM = await convertField(tMain.id, (fTextOM as any).id, {\n      name: (fTextOM as any).name,\n      type: FieldType.Link,\n      options: { relationship: Relationship.OneMany, foreignTableId: tForeign.id },\n    } as IFieldRo);\n\n    const finalCols = await getUserColumns(mainDb);\n    const newCols = finalCols.filter((c) => !initialCols.includes(c));\n\n    // Each converted field must have at least one physical column on host table.\n    // We accept either the dbFieldName itself (standard column) or\n    // implementation-specific FK columns (e.g., __fk_*, *_order).\n    const expectOnePhysical = (field: any) => {\n      const name = field.dbFieldName as string;\n      const ok = newCols.includes(name) || newCols.some((c) => c.startsWith('__fk_'));\n      expect(ok).toBe(true);\n    };\n\n    expectOnePhysical(linkMO);\n    expectOnePhysical(linkOO);\n    expectOnePhysical(linkOM);\n\n    await permanentDeleteTable(baseId, tMain.id);\n    await permanentDeleteTable(baseId, tForeign.id);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-reference.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { LinkFieldDto } from '../src/features/field/model/field-dto/link-field.dto';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getField,\n  initApp,\n} from './utils/init-app';\n\ndescribe('OpenAPI link field reference (e2e)', () => {\n  let app: INestApplication;\n  let table1Id = '';\n  let table2Id = '';\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    table1Id = (await createTable(baseId, { name: 'table1' })).id;\n    table2Id = (await createTable(baseId, { name: 'table2' })).id;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table1Id);\n    await permanentDeleteTable(baseId, table2Id);\n\n    await app.close();\n  });\n\n  it('/api/table/{tableId}/field (POST) create ManyOne', async () => {\n    const fieldRo: IFieldRo = {\n      name: 'New field',\n      description: 'the new field',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: table2Id,\n      },\n    };\n\n    const field1 = (await createField(table1Id, fieldRo)) as LinkFieldDto;\n    const field2 = (await getField(table2Id, field1.options.symmetricFieldId!)) as LinkFieldDto;\n\n    expect(field1.options.foreignTableId).toBe(table2Id);\n    expect(field1.options.symmetricFieldId).toBe(field2.id);\n    expect(field2.options.relationship).toBe(Relationship.OneMany);\n    expect(field2.options.foreignTableId).toBe(table1Id);\n    expect(field2.options.symmetricFieldId).toBe(field1.id);\n    expect(field1.options.foreignKeyName).toBe(`__fk_${field1.id}`);\n    expect(field2.options.selfKeyName).toBe(`__fk_${field1.id}`);\n  });\n\n  it('/api/table/{tableId}/field (POST) create OneMany', async () => {\n    const fieldRo: IFieldRo = {\n      name: 'New field',\n      description: 'the new field',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.OneMany,\n        foreignTableId: table2Id,\n      },\n    };\n\n    const field1 = (await createField(table1Id, fieldRo)) as LinkFieldDto;\n    const field2 = (await getField(table2Id, field1.options.symmetricFieldId!)) as LinkFieldDto;\n\n    expect(field1.options.foreignTableId).toBe(table2Id);\n    expect(field1.options.symmetricFieldId).toBe(field2.id);\n    expect(field2.options.relationship).toBe(Relationship.ManyOne);\n    expect(field2.options.foreignTableId).toBe(table1Id);\n    expect(field2.options.symmetricFieldId).toBe(field1.id);\n    expect(field1.options.selfKeyName).toBe(`__fk_${field2.id}`);\n    expect(field2.options.foreignKeyName).toBe(`__fk_${field2.id}`);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field-view-sync.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldVo,\n  IGridColumnMeta,\n  ISelectFieldChoice,\n  ISelectFieldOptions,\n  IFormColumn,\n} from '@teable/core';\nimport { FieldKeyType, FieldType, ViewType, SortFunc, Colors, StatisticsFunc } from '@teable/core';\nimport { updateRecords } from '@teable/openapi';\nimport {\n  createTable,\n  createView,\n  deleteField,\n  permanentDeleteTable,\n  initApp,\n  getViews,\n  updateViewColumnMeta,\n  convertField,\n  getRecords,\n} from './utils/init-app';\n\ndescribe('OpenAPI FieldController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let tableId: string;\n  let fields: IFieldVo[];\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    const { id, fields: fieldsVo } = await createTable(baseId, { name: 'table' });\n    tableId = id;\n    fields = fieldsVo;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it('should delete relative view conditions when deleting a field', async () => {\n    const numberField = fields.find(({ type }) => type === FieldType.Number) as IFieldVo;\n\n    const statusField = fields.find(({ type }) => type === FieldType.SingleSelect) as IFieldVo;\n\n    // create all views with some view conditions\n    const gridView = await createView(tableId, {\n      type: ViewType.Grid,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          { fieldId: numberField.id, operator: 'isGreater', value: 1 },\n          { fieldId: statusField.id, operator: 'is', value: 'done' },\n        ],\n      },\n      sort: {\n        sortObjs: [\n          { fieldId: numberField.id, order: SortFunc.Asc },\n          {\n            fieldId: statusField.id,\n            order: SortFunc.Asc,\n          },\n        ],\n      },\n      group: [\n        { fieldId: numberField.id, order: SortFunc.Asc },\n        { fieldId: statusField.id, order: SortFunc.Asc },\n      ],\n    });\n\n    const kanbanView = await createView(tableId, {\n      type: ViewType.Kanban,\n      options: {\n        stackFieldId: statusField.id,\n      },\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          { fieldId: numberField.id, operator: 'isGreater', value: 1 },\n          { fieldId: statusField.id, operator: 'is', value: 'done' },\n        ],\n      },\n      group: [\n        { fieldId: numberField.id, order: SortFunc.Asc },\n        { fieldId: statusField.id, order: SortFunc.Asc },\n      ],\n    });\n\n    const formView = await createView(tableId, {\n      type: ViewType.Form,\n    });\n\n    // delete the used field\n    await deleteField(tableId, numberField.id);\n\n    // get all views\n    const views = await getViews(tableId);\n\n    const gridViewAfterDelete = views.find(({ id }) => id === gridView.id);\n\n    const kanbanViewAfterDelete = views.find(({ id }) => id === kanbanView.id);\n\n    const formViewAfterDelete = views.find(({ id }) => id === formView.id);\n\n    // should delete the view conditions relative to the field\n    expect(gridViewAfterDelete).toEqual({\n      ...gridViewAfterDelete,\n      filter: {\n        conjunction: 'and',\n        filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'done' }],\n      },\n      sort: {\n        sortObjs: [\n          {\n            fieldId: statusField.id,\n            order: SortFunc.Asc,\n          },\n        ],\n        manualSort: false,\n      },\n      group: [\n        {\n          fieldId: statusField.id,\n          order: SortFunc.Asc,\n        },\n      ],\n    });\n\n    expect(kanbanViewAfterDelete).toEqual({\n      ...kanbanViewAfterDelete,\n      filter: {\n        conjunction: 'and',\n        filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'done' }],\n      },\n      group: [\n        {\n          fieldId: statusField.id,\n          order: SortFunc.Asc,\n        },\n      ],\n    });\n\n    expect(formViewAfterDelete?.columnMeta).not.haveOwnProperty(numberField.id);\n  });\n\n  it('should set form column visible after setting field notNull without default', async () => {\n    const textField = fields.find(({ type }) => type === FieldType.SingleLineText) as IFieldVo;\n\n    const formView = await createView(tableId, {\n      type: ViewType.Form,\n      name: 'Form',\n    });\n\n    const recordResult = await getRecords(tableId);\n    await updateRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records: recordResult.records.map((rec) => ({\n        id: rec.id,\n        fields: { [textField.id]: 'filled' },\n      })),\n    });\n\n    await convertField(tableId, textField.id, {\n      name: textField.name,\n      dbFieldName: textField.dbFieldName,\n      type: textField.type,\n      options: {},\n      notNull: true,\n    });\n\n    const views = await getViews(tableId);\n    const formAfter = views.find(({ id }) => id === formView.id)!;\n    const formColumnMeta = formAfter.columnMeta as unknown as Record<string, IFormColumn>;\n    expect(formColumnMeta[textField.id]?.visible ?? false).toBe(true);\n  });\n\n  it('should sync the selected value after update select type field option name', async () => {\n    const statusField = fields.find(({ type }) => type === FieldType.SingleSelect) as IFieldVo;\n    const defaultSelectValue = (statusField.options as ISelectFieldOptions)?.choices[0].name;\n\n    // create all views with some view conditions\n    const gridView = await createView(tableId, {\n      type: ViewType.Grid,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusField.id,\n            operator: 'is',\n            value: defaultSelectValue,\n          },\n        ],\n      },\n    });\n\n    await convertField(tableId, statusField.id, {\n      name: statusField.name,\n      dbFieldName: statusField.dbFieldName,\n      type: statusField.type,\n      options: {\n        choices: [\n          { id: (statusField.options as ISelectFieldOptions)?.choices[0].id, name: 'newName' },\n          { ...(statusField.options as ISelectFieldOptions)?.choices[1] },\n          { ...(statusField.options as ISelectFieldOptions)?.choices[2] },\n        ],\n      },\n    });\n\n    // get all views\n    const views = await getViews(tableId);\n\n    const gridViewAfterChange = views.find(({ id }) => id === gridView.id);\n\n    expect(gridViewAfterChange).toEqual({\n      ...gridViewAfterChange,\n      filter: {\n        conjunction: 'and',\n        filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'newName' }],\n      },\n    });\n  });\n\n  it('should delete filter item when the field convert to another field type', async () => {\n    const numberField = fields.find(({ type }) => type === FieldType.Number) as IFieldVo;\n    const selectField = fields.find(({ type }) => type === FieldType.SingleSelect) as IFieldVo;\n\n    // create all views with some view conditions\n    const gridView = await createView(tableId, {\n      type: ViewType.Grid,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          { fieldId: numberField.id, operator: 'isGreater', value: 1 },\n          {\n            fieldId: selectField.id,\n            operator: 'is',\n            value: (selectField.options as ISelectFieldOptions)?.choices[0].name,\n          },\n        ],\n      },\n    });\n\n    // number field convert to text field\n    await convertField(tableId, numberField.id, {\n      name: numberField.name,\n      dbFieldName: numberField.dbFieldName,\n      type: FieldType.SingleLineText,\n      options: {},\n    });\n\n    const views = await getViews(tableId);\n\n    const gridViewAfterChange = views.find(({ id }) => id === gridView.id);\n\n    expect(gridViewAfterChange).toEqual({\n      ...gridViewAfterChange,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: selectField.id,\n            operator: 'is',\n            value: (selectField.options as ISelectFieldOptions)?.choices[0].name,\n          },\n        ],\n      },\n    });\n  });\n\n  it('should still intact for filter condition when add select option', async () => {\n    const numberField = fields.find(({ type }) => type === FieldType.Number) as IFieldVo;\n    const selectField = fields.find(({ type }) => type === FieldType.SingleSelect) as IFieldVo;\n\n    // create all views with some view conditions\n    const gridView = await createView(tableId, {\n      type: ViewType.Grid,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          { fieldId: numberField.id, operator: 'isGreater', value: 1 },\n          {\n            fieldId: selectField.id,\n            operator: 'is',\n            value: (selectField.options as ISelectFieldOptions)?.choices[0].name,\n          },\n        ],\n      },\n    });\n\n    const newChoices = [\n      ...(selectField.options as ISelectFieldOptions).choices,\n    ] as Partial<ISelectFieldChoice>[];\n\n    newChoices.push({ name: 'test-add-choice', color: Colors.YellowLight2 });\n\n    // number field convert to text field\n    await convertField(tableId, selectField.id, {\n      name: selectField.name,\n      dbFieldName: selectField.dbFieldName,\n      type: FieldType.SingleSelect,\n      options: {\n        ...selectField.options,\n        choices: newChoices,\n      } as ISelectFieldOptions,\n    });\n\n    const views = await getViews(tableId);\n\n    const gridViewAfterChange = views.find(({ id }) => id === gridView.id);\n\n    expect(gridViewAfterChange?.filter).toEqual({\n      conjunction: 'and',\n      filterSet: [\n        { fieldId: numberField.id, operator: 'isGreater', value: 1 },\n        {\n          fieldId: selectField.id,\n          operator: 'is',\n          value: (selectField.options as ISelectFieldOptions)?.choices[0].name,\n        },\n      ],\n    });\n  });\n\n  it('should clear invalid statisticFunc in columnMeta when field type changes', async () => {\n    const numberField = fields.find(({ type }) => type === FieldType.Number) as IFieldVo;\n\n    const views = await getViews(tableId);\n    const gridView = views.find(({ type }) => type === ViewType.Grid) || views[0];\n\n    await updateViewColumnMeta(tableId, gridView.id, [\n      {\n        fieldId: numberField.id,\n        columnMeta: {\n          statisticFunc: StatisticsFunc.Sum,\n        },\n      },\n    ]);\n\n    await convertField(tableId, numberField.id, {\n      name: numberField.name,\n      dbFieldName: numberField.dbFieldName,\n      type: FieldType.SingleLineText,\n      options: {},\n    });\n\n    const updatedViews = await getViews(tableId);\n    const updatedGridView = updatedViews.find(({ id }) => id === gridView.id)!;\n    const updatedColumnMeta = updatedGridView.columnMeta as unknown as IGridColumnMeta;\n    expect(updatedColumnMeta[numberField.id]?.statisticFunc ?? null).toBe(null);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/field.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport type {\n  IDatetimeFormatting,\n  IFieldRo,\n  IFieldVo,\n  ILinkFieldOptions,\n  ILinkFieldOptionsRo,\n  ILookupOptionsRo,\n} from '@teable/core';\nimport {\n  Colors,\n  DateFormattingPreset,\n  DriverClient,\n  FieldAIActionType,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  SingleLineTextFieldCore,\n  TimeFormatting,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { convertField } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport type { FieldCreateEvent } from '../src/event-emitter/events';\nimport { Events } from '../src/event-emitter/events';\nimport {\n  createField,\n  createTable,\n  deleteField,\n  permanentDeleteTable,\n  getFields,\n  getRecord,\n  initApp,\n  updateRecordByApi,\n  createRecords,\n  getRecords,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('OpenAPI FieldController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let event: EventEmitter2;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    event = app.get(EventEmitter2);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('CRUD', () => {\n    let table1: ITableFullVo;\n\n    beforeAll(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n    });\n\n    it('/api/table/{tableId}/field (GET)', async () => {\n      const fields: IFieldVo[] = await getFields(table1.id);\n\n      expect(fields).toHaveLength(3);\n    });\n\n    it('/api/table/{tableId}/field (GET) with projection', async () => {\n      const firstFieldId = table1.fields[0].id;\n      const firstViewId = table1.views[0].id;\n\n      const fields: IFieldVo[] = await getFields(table1.id, undefined, undefined, [firstFieldId]);\n      const viewFields: IFieldVo[] = await getFields(table1.id, firstViewId, undefined, [\n        firstFieldId,\n      ]);\n\n      expect(fields).toHaveLength(1);\n      expect(fields[0].id).toEqual(firstFieldId);\n\n      expect(viewFields).toHaveLength(1);\n      expect(viewFields[0].id).toEqual(firstFieldId);\n    });\n\n    it('/api/table/{tableId}/field (POST)', async () => {\n      event.once(Events.TABLE_FIELD_CREATE, async (payload: FieldCreateEvent) => {\n        expect(payload).toBeDefined();\n        expect(payload?.payload).toBeDefined();\n        expect(payload?.payload?.tableId).toBeDefined();\n        expect(payload?.payload?.field).toBeDefined();\n      });\n\n      const fieldRo: IFieldRo = {\n        name: 'New field',\n        description: 'the new field',\n        type: FieldType.SingleLineText,\n        options: SingleLineTextFieldCore.defaultOptions(),\n      };\n\n      await createField(table1.id, fieldRo);\n\n      const fields: IFieldVo[] = await getFields(table1.id);\n      expect(fields).toHaveLength(4);\n    });\n\n    it('creates Date field with custom formatting and timezone without cast errors', async () => {\n      // Create a few records to ensure computed orchestrator runs updateFromSelect\n      await createRecords(table1.id, { records: [{ fields: {} }, { fields: {} }, { fields: {} }] });\n\n      const fieldRo: IFieldRo = {\n        name: '日期',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: 'None',\n            timeZone: 'Asia/Shanghai',\n          } as IDatetimeFormatting,\n        },\n      };\n\n      const field = await createField(table1.id, fieldRo, 201);\n      expect(field).toBeDefined();\n      expect(field.type).toBe(FieldType.Date);\n    });\n  });\n\n  describe('should generate default name and options for field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeAll(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    async function createFieldByType(\n      type: FieldType,\n      options?: IFieldRo['options']\n    ): Promise<IFieldVo> {\n      const fieldRo: IFieldRo = {\n        type,\n        options,\n      };\n\n      return await createField(table1.id, fieldRo);\n    }\n    it('basic field', async () => {\n      const textField = await createFieldByType(FieldType.SingleLineText);\n      expect(textField.name).toEqual('Label');\n      expect(textField.options).toEqual({});\n\n      const numberField = await createFieldByType(FieldType.Number);\n      expect(numberField.name).toEqual('Number');\n      expect(numberField.options).toEqual({\n        formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      });\n\n      // Test number field with empty options object (AI tool scenario)\n      // When AI passes options: {} without formatting, server should provide defaults\n      const numberFieldWithEmptyOptions = await createFieldByType(FieldType.Number, {});\n      expect(numberFieldWithEmptyOptions.options).toEqual({\n        formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      });\n\n      // Test number field with partial options (only showAs, no formatting)\n      const numberFieldWithPartialOptions = await createFieldByType(FieldType.Number, {\n        showAs: undefined,\n      } as IFieldRo['options']);\n      expect((numberFieldWithPartialOptions.options as { formatting: unknown }).formatting).toEqual(\n        {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        }\n      );\n\n      const selectField = await createFieldByType(FieldType.SingleSelect);\n      expect(selectField.name).toEqual('Select');\n      expect(selectField.options).toEqual({\n        choices: [],\n      });\n\n      const datetimeField = await createFieldByType(FieldType.Date);\n      expect(datetimeField.name).toEqual('Date');\n      expect(datetimeField.options).toEqual({\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.None,\n          timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      });\n\n      const checkboxField = await createFieldByType(FieldType.Checkbox);\n      expect(checkboxField.name).toEqual('Done');\n      expect(checkboxField.options).toEqual({});\n\n      const attachmentField = await createFieldByType(FieldType.Attachment);\n      expect(attachmentField.name).toEqual('Attachments');\n      expect(attachmentField.options).toEqual({});\n\n      const buttonField = await createFieldByType(FieldType.Button);\n      expect(buttonField.name).toEqual('Button');\n      expect(buttonField.options).toEqual({\n        label: 'Button',\n        color: Colors.Teal,\n      });\n      const autoNumberField = await createFieldByType(FieldType.AutoNumber);\n      expect(autoNumberField.name).toEqual('ID');\n      expect(autoNumberField.options).toEqual({\n        expression: 'AUTO_NUMBER()',\n      });\n    });\n\n    it('formula field', async () => {\n      const defaultTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n      const stringFormulaField = await createFieldByType(FieldType.Formula, {\n        expression: '\"A\"',\n      });\n      expect(stringFormulaField.name).toEqual('Calculation');\n      expect(stringFormulaField.options).toEqual({\n        expression: '\"A\"',\n        timeZone: defaultTimeZone,\n      });\n\n      const numberFormulaField = await createFieldByType(FieldType.Formula, {\n        expression: '1 + 1',\n      });\n      expect(numberFormulaField.options).toEqual({\n        expression: '1 + 1',\n        formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        timeZone: defaultTimeZone,\n      });\n\n      const booleanFormulaField = await createFieldByType(FieldType.Formula, {\n        expression: 'true',\n      });\n      expect(booleanFormulaField.options).toEqual({\n        expression: 'true',\n        timeZone: defaultTimeZone,\n      });\n\n      const datetimeField = await createFieldByType(FieldType.Date);\n      const datetimeFormulaField = await createFieldByType(FieldType.Formula, {\n        expression: `{${datetimeField.id}}`,\n      });\n      expect(datetimeFormulaField.options).toEqual({\n        expression: `{${datetimeField.id}}`,\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.None,\n          timeZone: defaultTimeZone,\n        },\n        timeZone: defaultTimeZone,\n      });\n    });\n\n    describe('relational field', () => {\n      it('should generate semantic field name for link and lookup and rollup field ', async () => {\n        const linkField = await createField(table1.id, {\n          type: FieldType.Link,\n          options: {\n            foreignTableId: table2.id,\n            relationship: Relationship.OneMany,\n          } as ILinkFieldOptionsRo,\n        });\n\n        expect(linkField.name).toEqual(`${table2.name}`);\n        table2.fields = await getFields(table2.id);\n        const symmetricalLinkField = table2.fields.find((f) => f.type === FieldType.Link);\n\n        expect(symmetricalLinkField?.name).toEqual(table1.name);\n        const lookupField = await createField(table1.id, {\n          type: FieldType.SingleLineText,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          isLookup: true,\n        });\n\n        expect(lookupField.name).toEqual(`${table2.fields[0].name} (from ${table2.name})`);\n        expect(lookupField.options).toEqual({});\n\n        const rollupField = await createField(table1.id, {\n          type: FieldType.Rollup,\n          options: {\n            expression: 'sum({values})',\n          },\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        });\n\n        expect(rollupField.name).toEqual(`${table2.fields[0].name} Rollup (from ${table2.name})`);\n        expect(rollupField.options).toEqual({\n          expression: 'sum({values})',\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        });\n      });\n    });\n  });\n\n  describe('v2 lookup option sync', () => {\n    const itIfForceV2 = isForceV2 ? it : it.skip;\n\n    itIfForceV2('ignores API-supplied choices for lookup-backed single select fields', async () => {\n      let hostTable: ITableFullVo | undefined;\n      let foreignTable: ITableFullVo | undefined;\n\n      try {\n        foreignTable = await createTable(baseId, {\n          name: 'lookup-option-sync-foreign',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText },\n            {\n              name: 'Importance',\n              type: FieldType.SingleSelect,\n              options: {\n                choices: [\n                  { id: 'choLookupCore', name: '核心', color: Colors.Blue },\n                  { id: 'choLookupImportant', name: '重要', color: Colors.Green },\n                  { id: 'choLookupReference', name: '参考', color: Colors.Orange },\n                ],\n              },\n            },\n          ],\n        });\n        hostTable = await createTable(baseId, {\n          name: 'lookup-option-sync-host',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        });\n\n        const foreignImportanceField = foreignTable.fields.find(\n          (field) => field.name === 'Importance'\n        )!;\n        const expectedChoices = (\n          foreignImportanceField.options as {\n            choices: Array<{ id: string; name: string; color: string }>;\n          }\n        ).choices;\n\n        const linkField = await createField(hostTable.id, {\n          name: 'Related',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreignTable.id,\n          } as ILinkFieldOptionsRo,\n        });\n\n        const createdLookupField = await createField(hostTable.id, {\n          name: '章节重要程度',\n          type: FieldType.SingleSelect,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreignTable.id,\n            lookupFieldId: foreignImportanceField.id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          options: {\n            choices: [\n              { id: 'choBroken1', name: 'Option 1', color: Colors.Blue },\n              { id: 'choBroken2', name: 'Option 2', color: Colors.Green },\n            ],\n          },\n        });\n\n        expect(createdLookupField.options).toEqual({\n          choices: expectedChoices,\n        });\n\n        const persistedLookupField = (await getFields(hostTable.id)).find(\n          (field) => field.id === createdLookupField.id\n        );\n        expect(persistedLookupField?.options).toEqual({\n          choices: expectedChoices,\n        });\n      } finally {\n        if (hostTable) {\n          await permanentDeleteTable(baseId, hostTable.id);\n        }\n        if (foreignTable) {\n          await permanentDeleteTable(baseId, foreignTable.id);\n        }\n      }\n    });\n\n    itIfForceV2(\n      'ignores API-supplied choices for conditional lookup-backed single select fields',\n      async () => {\n        let hostTable: ITableFullVo | undefined;\n        let foreignTable: ITableFullVo | undefined;\n\n        try {\n          foreignTable = await createTable(baseId, {\n            name: 'conditional-lookup-option-sync-foreign',\n            fields: [\n              { name: 'Title', type: FieldType.SingleLineText },\n              { name: 'Category', type: FieldType.SingleLineText },\n              {\n                name: 'Importance',\n                type: FieldType.SingleSelect,\n                options: {\n                  choices: [\n                    { id: 'choCondCore', name: '核心', color: Colors.Blue },\n                    { id: 'choCondImportant', name: '重要', color: Colors.Green },\n                    { id: 'choCondReference', name: '参考', color: Colors.Orange },\n                  ],\n                },\n              },\n            ],\n          });\n          hostTable = await createTable(baseId, {\n            name: 'conditional-lookup-option-sync-host',\n            fields: [\n              { name: 'Name', type: FieldType.SingleLineText },\n              { name: 'Category Filter', type: FieldType.SingleLineText },\n            ],\n          });\n\n          const foreignCategoryField = foreignTable.fields.find(\n            (field) => field.name === 'Category'\n          )!;\n          const foreignImportanceField = foreignTable.fields.find(\n            (field) => field.name === 'Importance'\n          )!;\n          const hostCategoryField = hostTable.fields.find(\n            (field) => field.name === 'Category Filter'\n          )!;\n          const expectedChoices = (\n            foreignImportanceField.options as {\n              choices: Array<{ id: string; name: string; color: string }>;\n            }\n          ).choices;\n\n          const createdConditionalLookupField = await createField(hostTable.id, {\n            name: '条件重要程度',\n            type: FieldType.SingleSelect,\n            isLookup: true,\n            isConditionalLookup: true,\n            lookupOptions: {\n              foreignTableId: foreignTable.id,\n              lookupFieldId: foreignImportanceField.id,\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: foreignCategoryField.id,\n                    operator: 'is',\n                    value: { type: 'field', fieldId: hostCategoryField.id },\n                  },\n                ],\n              },\n            } as ILookupOptionsRo,\n            options: {\n              choices: [\n                { id: 'choCondBroken1', name: 'Option 1', color: Colors.Blue },\n                { id: 'choCondBroken2', name: 'Option 2', color: Colors.Green },\n              ],\n            },\n          });\n\n          expect(createdConditionalLookupField.options).toEqual({\n            choices: expectedChoices,\n          });\n\n          const persistedConditionalLookupField = (await getFields(hostTable.id)).find(\n            (field) => field.id === createdConditionalLookupField.id\n          );\n          expect(persistedConditionalLookupField?.options).toEqual({\n            choices: expectedChoices,\n          });\n        } finally {\n          if (hostTable) {\n            await permanentDeleteTable(baseId, hostTable.id);\n          }\n          if (foreignTable) {\n            await permanentDeleteTable(baseId, foreignTable.id);\n          }\n        }\n      }\n    );\n  });\n\n  describe('long text markdown showAs API', () => {\n    const itIfForceV2 = isForceV2 ? it : it.skip;\n\n    itIfForceV2('should update and clear long text showAs via convert field API', async () => {\n      let table: ITableFullVo | undefined;\n\n      try {\n        table = await createTable(baseId, {\n          name: 'long-text-show-as-update-api',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        });\n\n        const longTextField = await createField(table.id, {\n          name: 'Body',\n          type: FieldType.LongText,\n        });\n\n        const markdownUpdatedResponse = await convertField(table.id, longTextField.id, {\n          name: longTextField.name,\n          type: FieldType.LongText,\n          options: {\n            showAs: {\n              type: 'markdown',\n            },\n          },\n        });\n        expect(markdownUpdatedResponse.status).toBe(200);\n\n        const persistedAfterEnable = (await getFields(table.id)).find(\n          (field) => field.id === longTextField.id\n        )!;\n        expect(persistedAfterEnable.options).toMatchObject({\n          showAs: {\n            type: 'markdown',\n          },\n        });\n\n        const clearedResponse = await convertField(table.id, longTextField.id, {\n          name: longTextField.name,\n          type: FieldType.LongText,\n          options: {\n            showAs: null,\n          },\n        });\n        expect(clearedResponse.status).toBe(200);\n\n        const persistedAfterClear = (await getFields(table.id)).find(\n          (field) => field.id === longTextField.id\n        )!;\n        expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined();\n      } finally {\n        if (table) {\n          await permanentDeleteTable(baseId, table.id);\n        }\n      }\n    });\n\n    itIfForceV2(\n      'should keep lookup long text showAs cleared when API attempts to set markdown',\n      async () => {\n        let hostTable: ITableFullVo | undefined;\n        let foreignTable: ITableFullVo | undefined;\n\n        try {\n          foreignTable = await createTable(baseId, {\n            name: 'lookup-long-text-show-as-foreign',\n            fields: [\n              { name: 'Title', type: FieldType.SingleLineText },\n              {\n                name: 'Foreign Long Text',\n                type: FieldType.LongText,\n                options: {\n                  showAs: {\n                    type: 'markdown',\n                  },\n                },\n              },\n            ],\n          });\n\n          hostTable = await createTable(baseId, {\n            name: 'lookup-long-text-show-as-host',\n            fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n          });\n\n          const foreignLongTextField = foreignTable.fields.find(\n            (field) => field.name === 'Foreign Long Text'\n          )!;\n\n          const linkField = await createField(hostTable.id, {\n            name: 'Related',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: foreignTable.id,\n            } as ILinkFieldOptionsRo,\n          });\n\n          const lookupLongTextField = await createField(hostTable.id, {\n            name: 'Lookup Long Text',\n            type: FieldType.LongText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: foreignTable.id,\n              lookupFieldId: foreignLongTextField.id,\n              linkFieldId: linkField.id,\n            } as ILookupOptionsRo,\n          });\n\n          expect(lookupLongTextField.options).toMatchObject({\n            showAs: {\n              type: 'markdown',\n            },\n          });\n\n          const lookupOptions = lookupLongTextField.lookupOptions as ILookupOptionsRo;\n          const clearedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, {\n            name: lookupLongTextField.name,\n            type: FieldType.LongText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: lookupOptions.foreignTableId,\n              lookupFieldId: lookupOptions.lookupFieldId,\n              linkFieldId: lookupOptions.linkFieldId,\n            },\n            options: {\n              showAs: null,\n            },\n          });\n          expect(clearedLookupResponse.status).toBe(200);\n\n          const persistedAfterClear = (await getFields(hostTable.id)).find(\n            (field) => field.id === lookupLongTextField.id\n          )!;\n          expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined();\n\n          const updatedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, {\n            name: lookupLongTextField.name,\n            type: FieldType.LongText,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: lookupOptions.foreignTableId,\n              lookupFieldId: lookupOptions.lookupFieldId,\n              linkFieldId: lookupOptions.linkFieldId,\n            },\n            options: {\n              showAs: {\n                type: 'markdown',\n              },\n            },\n          });\n          expect(updatedLookupResponse.status).toBe(200);\n\n          const persistedLookupField = (await getFields(hostTable.id)).find(\n            (field) => field.id === lookupLongTextField.id\n          )!;\n          expect((persistedLookupField.options as { showAs?: unknown }).showAs).toBeUndefined();\n        } finally {\n          if (hostTable) {\n            await permanentDeleteTable(baseId, hostTable.id);\n          }\n          if (foreignTable) {\n            await permanentDeleteTable(baseId, foreignTable.id);\n          }\n        }\n      }\n    );\n  });\n\n  describe('should decide whether to create field validation rules based on the field type', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeAll(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    async function createFieldWithUnique(\n      type: FieldType,\n      options?: IFieldRo['options'],\n      expectStatus = 201\n    ): Promise<IFieldVo> {\n      const fieldRo: IFieldRo = {\n        type,\n        unique: true,\n        options,\n      };\n\n      return await createField(table1.id, fieldRo, expectStatus);\n    }\n\n    async function createFieldWithNotNull(\n      type: FieldType,\n      options?: IFieldRo['options'],\n      expectStatus = 201\n    ): Promise<IFieldVo> {\n      const fieldRo: IFieldRo = {\n        type,\n        notNull: true,\n        options,\n      };\n\n      return await createField(table1.id, fieldRo, expectStatus);\n    }\n\n    it('should create successfully for field ai config', async () => {\n      const baseField = await createField(table1.id, { type: FieldType.SingleLineText }, 201);\n      const fieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        aiConfig: {\n          type: FieldAIActionType.Summary,\n          modelKey: 'openai@gpt-4o@gpt',\n          sourceFieldId: baseField.id,\n        },\n      };\n      const aiField = await createField(table1.id, fieldRo, 201);\n      expect(aiField.aiConfig).toEqual({\n        type: FieldAIActionType.Summary,\n        modelKey: 'openai@gpt-4o@gpt',\n        sourceFieldId: baseField.id,\n      });\n    });\n\n    it('should create fail for user field with ai config', async () => {\n      const baseField = await createField(table1.id, { type: FieldType.SingleLineText }, 201);\n      const fieldRo: IFieldRo = {\n        type: FieldType.Attachment,\n        aiConfig: {\n          type: FieldAIActionType.Summary,\n          modelKey: 'openai@gpt-4o@GPT',\n          sourceFieldId: baseField.id,\n        },\n      };\n      await createField(table1.id, fieldRo, 400);\n    });\n\n    it('should create successfully for a unique validation field with valid field types', async () => {\n      const textField = await createFieldWithUnique(FieldType.SingleLineText);\n      expect(textField.unique).toEqual(true);\n\n      const longTextField = await createFieldWithUnique(FieldType.LongText);\n      expect(longTextField.unique).toEqual(true);\n\n      const numberField = await createFieldWithUnique(FieldType.Number);\n      expect(numberField.unique).toEqual(true);\n\n      const datetimeField = await createFieldWithUnique(FieldType.Date);\n      expect(datetimeField.unique).toEqual(true);\n    });\n\n    it('should create fail for a unique validation field with invalid field types', async () => {\n      await createFieldWithUnique(FieldType.Attachment, undefined, 400);\n\n      await createFieldWithUnique(FieldType.User, undefined, 400);\n\n      await createFieldWithUnique(FieldType.Checkbox, undefined, 400);\n\n      await createFieldWithUnique(FieldType.SingleSelect, undefined, 400);\n\n      await createFieldWithUnique(FieldType.MultipleSelect, undefined, 400);\n\n      await createFieldWithUnique(FieldType.Rating, undefined, 400);\n\n      await createFieldWithUnique(\n        FieldType.Formula,\n        {\n          expression: '1 + 1',\n        },\n        400\n      );\n\n      await createFieldWithUnique(\n        FieldType.Link,\n        {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyOne,\n        },\n        400\n      );\n\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      });\n\n      const rollupFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'SUM({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n        unique: true,\n      };\n\n      await createField(table1.id, rollupFieldRo, 400);\n\n      await createFieldWithUnique(FieldType.CreatedTime, undefined, 400);\n\n      await createFieldWithUnique(FieldType.LastModifiedTime, undefined, 400);\n\n      await createFieldWithUnique(FieldType.AutoNumber, undefined, 400);\n    });\n\n    it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n      'should create fail for a not null validation field with all field types',\n      async () => {\n        await createFieldWithNotNull(FieldType.SingleLineText, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.LongText, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.Number, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.Date, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.User, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.Checkbox, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.SingleSelect, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.MultipleSelect, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.Rating, undefined, 400);\n\n        await createFieldWithNotNull(\n          FieldType.Formula,\n          {\n            expression: '1 + 1',\n          },\n          400\n        );\n\n        await createFieldWithNotNull(\n          FieldType.Link,\n          {\n            foreignTableId: table2.id,\n            relationship: Relationship.ManyOne,\n          },\n          400\n        );\n\n        const linkField = await createField(table1.id, {\n          type: FieldType.Link,\n          options: {\n            foreignTableId: table2.id,\n            relationship: Relationship.ManyOne,\n          } as ILinkFieldOptionsRo,\n        });\n\n        const rollupFieldRo: IFieldRo = {\n          type: FieldType.Rollup,\n          options: {\n            expression: 'SUM({values})',\n          },\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table2.fields[0].id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          notNull: true,\n        };\n\n        await createField(table1.id, rollupFieldRo, 400);\n\n        await createFieldWithNotNull(FieldType.CreatedTime, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.LastModifiedTime, undefined, 400);\n\n        await createFieldWithNotNull(FieldType.AutoNumber, undefined, 400);\n      }\n    );\n  });\n\n  describe('should safe delete field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeAll(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    let prisma: PrismaService;\n    let knex: Knex;\n\n    beforeAll(async () => {\n      prisma = app.get(PrismaService);\n      knex = app.get('CUSTOM_KNEX');\n    });\n\n    it('should delete a simple field', async () => {\n      const fieldRo: IFieldRo = {\n        name: 'New field',\n        description: 'the new field',\n        type: FieldType.SingleLineText,\n        options: SingleLineTextFieldCore.defaultOptions(),\n      };\n      const field = await createField(table1.id, fieldRo);\n      await deleteField(table1.id, field.id);\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: field.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n    });\n\n    it('should forbid to delete a primary field', async () => {\n      const fields = await prisma.field.findMany({\n        where: { tableId: table1.id },\n      });\n\n      const primaryFieldId = fields.find((f) => f.isPrimary)?.id as string;\n      const fn = async () => await deleteField(table1.id, primaryFieldId);\n      await expect(fn()).rejects.toMatchObject({\n        status: 403,\n      });\n    });\n\n    it('should delete a formula dependency field, a -> b delete a', async () => {\n      const textFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        options: SingleLineTextFieldCore.defaultOptions(),\n      };\n      const textField = await createField(table1.id, textFieldRo);\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textField.id}}`,\n        },\n      };\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      const referenceBefore = await prisma.reference.findMany({\n        where: { fromFieldId: textField.id },\n      });\n      expect(referenceBefore.length).toBe(1);\n      expect(referenceBefore[0].toFieldId).toBe(formulaField.id);\n\n      await deleteField(table1.id, textField.id);\n      // reference should be deleted\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { fromFieldId: textField.id },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // text field should be deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: textField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n    });\n\n    it('should delete a formula field, a -> b delete b', async () => {\n      const textFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        options: SingleLineTextFieldCore.defaultOptions(),\n      };\n      const textField = await createField(table1.id, textFieldRo);\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textField.id}}`,\n        },\n      };\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      const referenceBefore = await prisma.reference.findMany({\n        where: { toFieldId: formulaField.id },\n      });\n      expect(referenceBefore.length).toBe(1);\n      expect(referenceBefore[0].fromFieldId).toBe(textField.id);\n\n      await deleteField(table1.id, formulaField.id);\n      // reference should be deleted\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { fromFieldId: textField.id },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // formula field should be deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: formulaField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n    });\n\n    it('should delete a middle formula field, a -> b -> c delete b', async () => {\n      const textFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        options: SingleLineTextFieldCore.defaultOptions(),\n      };\n      const textField = await createField(table1.id, textFieldRo);\n      const formula1FieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textField.id}}`,\n        },\n      };\n      const formula1Field = await createField(table1.id, formula1FieldRo);\n      const formula2FieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${formula1Field.id}}`,\n        },\n      };\n      await createField(table1.id, formula2FieldRo);\n\n      const referenceBefore = await prisma.reference.findMany({\n        where: { OR: [{ toFieldId: formula1Field.id }, { fromFieldId: formula1Field.id }] },\n      });\n      expect(referenceBefore.length).toBe(2);\n\n      await deleteField(table1.id, formula1Field.id);\n\n      // reference should be deleted\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { OR: [{ toFieldId: formula1Field.id }, { fromFieldId: formula1Field.id }] },\n      });\n      expect(referenceAfter).toBeFalsy();\n\n      // formula field should be deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: formula1Field.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n    });\n\n    it('should delete a link field', async () => {\n      const table2PrimaryField = table2.fields[0];\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const referenceBefore = await prisma.reference.findMany({\n        where: { toFieldId: linkField.id },\n      });\n      expect(referenceBefore.length).toBe(1);\n      expect(referenceBefore[0].fromFieldId).toBe(table2PrimaryField.id);\n\n      // foreignKey should be created\n      const { fkHostTableName, foreignKeyName } = linkField.options as ILinkFieldOptions;\n      const linkedRecords = await prisma.$queryRawUnsafe<{ __id: string }[]>(\n        knex(fkHostTableName).select('*').where(foreignKeyName, table2.records[0].id).toQuery()\n      );\n      expect(linkedRecords.length).toBe(1);\n\n      await deleteField(table1.id, linkField.id);\n\n      // reference should be deleted\n      const referenceAfter = await prisma.reference.findFirst({\n        where: { fromFieldId: table2PrimaryField.id },\n      });\n      expect(referenceAfter).toBeFalsy();\n      const linkReferenceAfter = await prisma.reference.findFirst({\n        where: { OR: [{ fromFieldId: linkField.id }, { toFieldId: linkField.id }] },\n      });\n      expect(linkReferenceAfter).toBeFalsy();\n      const symLinkReferenceAfter = await prisma.reference.findFirst({\n        where: { OR: [{ fromFieldId: symmetricFieldId }, { toFieldId: symmetricFieldId }] },\n      });\n      expect(symLinkReferenceAfter).toBeFalsy();\n\n      // foreignKey should be removed\n      expect(\n        prisma.$queryRawUnsafe(\n          knex(fkHostTableName).select('*').whereNotNull(foreignKeyName).toQuery()\n        )\n      ).rejects.toThrow();\n\n      expect(\n        prisma.$queryRawUnsafe<{ __id: string }[]>(\n          knex(fkHostTableName).select('*').whereNotNull(linkField.dbFieldName).toQuery()\n        )\n      ).rejects.toThrow();\n\n      // formula field should be marked as deleted\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: linkField.id },\n      });\n      expect(fieldRaw?.deletedTime).toBeTruthy();\n      const symmetricalFieldRaw = await prisma.field.findUnique({\n        where: { id: symmetricFieldId },\n      });\n      expect(symmetricalFieldRaw?.deletedTime).toBeTruthy();\n    });\n\n    it('should delete a link with lookup field and a referenced formula', async () => {\n      const table1PrimaryField = table1.fields[0];\n      const table2PrimaryField = table2.fields[0];\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          foreignTableId: table2.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      };\n      const linkField = await createField(table1.id, linkFieldRo);\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n\n      const lookupFieldRo: IFieldRo = {\n        type: table2PrimaryField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2PrimaryField.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      };\n      const lookupField = await createField(table1.id, lookupFieldRo);\n      const symLookupFieldRo: IFieldRo = {\n        type: table1PrimaryField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: table1PrimaryField.id,\n          linkFieldId: symmetricFieldId,\n        } as ILookupOptionsRo,\n      };\n      const symLookupField = await createField(table2.id, symLookupFieldRo);\n\n      const formulaFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupField.id}} & {${table1.fields[0].id}}`,\n        },\n      };\n      const formulaField = await createField(table1.id, formulaFieldRo);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2PrimaryField.id, 'text');\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'formula');\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const referenceBefore = await prisma.reference.findMany({\n        where: { fromFieldId: table2PrimaryField.id },\n      });\n      expect(referenceBefore.length).toBe(2);\n\n      // lookup cell and formula cell should be updated\n      const record = await getRecord(table1.id, table1.records[0].id);\n      expect(record.fields[lookupField.id]).toBe('text');\n      expect(record.fields[formulaField.id]).toBe('textformula');\n\n      await deleteField(table1.id, linkField.id);\n\n      // link reference and all relational lookup reference should be deleted\n      const referenceAfter = await prisma.reference.findMany({\n        where: { fromFieldId: table2PrimaryField.id },\n      });\n      expect(referenceAfter.length).toBe(0);\n\n      // lookup cell and formula cell should be keep\n      const recordAfter = await getRecord(table1.id, table1.records[0].id);\n      expect(recordAfter.fields[lookupField.id]).toBeUndefined();\n      expect(recordAfter.fields[formulaField.id]).toBeUndefined();\n\n      // lookup field should be marked as error\n      const fieldRaw = await prisma.field.findUnique({\n        where: { id: lookupField.id },\n      });\n      expect(fieldRaw?.hasError).toBeTruthy();\n\n      const fieldRaw2 = await prisma.field.findUnique({\n        where: { id: symLookupField.id },\n      });\n      expect(fieldRaw2?.hasError).toBeTruthy();\n    });\n  });\n\n  describe('AutoNumber field functionality', () => {\n    let table1: ITableFullVo;\n\n    beforeAll(async () => {\n      table1 = await createTable(baseId, { name: 'AutoNumberTest' });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n    });\n\n    it('should create AutoNumber field successfully', async () => {\n      const autoNumberFieldRo: IFieldRo = {\n        type: FieldType.AutoNumber,\n        name: 'Auto ID',\n      };\n\n      const autoNumberField = await createField(table1.id, autoNumberFieldRo);\n\n      expect(autoNumberField.type).toEqual(FieldType.AutoNumber);\n      expect(autoNumberField.name).toEqual('Auto ID');\n      expect(autoNumberField.options).toEqual({\n        expression: 'AUTO_NUMBER()',\n      });\n      expect(autoNumberField.isComputed).toBe(true);\n      expect(autoNumberField.cellValueType).toEqual('number');\n      expect(autoNumberField.dbFieldType).toEqual('INTEGER');\n    });\n\n    it('should generate auto-incrementing numbers for new records', async () => {\n      // Create AutoNumber field\n      const autoNumberFieldRo: IFieldRo = {\n        type: FieldType.AutoNumber,\n        name: 'Auto ID',\n      };\n      const autoNumberField = await createField(table1.id, autoNumberFieldRo);\n\n      // Create multiple records and verify auto-incrementing behavior\n      const record1 = await createRecords(table1.id, {\n        records: [{ fields: {} }],\n      });\n      const record2 = await createRecords(table1.id, {\n        records: [{ fields: {} }],\n      });\n      const record3 = await createRecords(table1.id, {\n        records: [{ fields: {} }],\n      });\n\n      // Get the records to check their AutoNumber values\n      const fetchedRecord1 = await getRecord(table1.id, record1.records[0].id);\n      const fetchedRecord2 = await getRecord(table1.id, record2.records[0].id);\n      const fetchedRecord3 = await getRecord(table1.id, record3.records[0].id);\n\n      // Verify that AutoNumber values are auto-incrementing integers\n      const autoNum1 = fetchedRecord1.fields[autoNumberField.id] as number;\n      const autoNum2 = fetchedRecord2.fields[autoNumberField.id] as number;\n      const autoNum3 = fetchedRecord3.fields[autoNumberField.id] as number;\n\n      expect(typeof autoNum1).toBe('number');\n      expect(typeof autoNum2).toBe('number');\n      expect(typeof autoNum3).toBe('number');\n\n      // Verify auto-incrementing behavior\n      expect(autoNum2).toBeGreaterThan(autoNum1);\n      expect(autoNum3).toBeGreaterThan(autoNum2);\n\n      // Verify they are consecutive (assuming no other records were created)\n      expect(autoNum2 - autoNum1).toBe(1);\n      expect(autoNum3 - autoNum2).toBe(1);\n    });\n\n    it('should maintain auto-number sequence even with existing records', async () => {\n      // Get existing records count to understand the current sequence\n      const existingRecords = await getRecords(table1.id);\n      const existingCount = existingRecords.records.length;\n\n      // Create AutoNumber field on table with existing records\n      const autoNumberFieldRo: IFieldRo = {\n        type: FieldType.AutoNumber,\n        name: 'Sequential ID',\n      };\n      const autoNumberField = await createField(table1.id, autoNumberFieldRo);\n\n      // Create a new record\n      const newRecord = await createRecords(table1.id, {\n        records: [{ fields: {} }],\n      });\n\n      // Get the new record to check its AutoNumber value\n      const fetchedNewRecord = await getRecord(table1.id, newRecord.records[0].id);\n      const autoNumValue = fetchedNewRecord.fields[autoNumberField.id] as number;\n\n      // The new record should have an auto number that continues the sequence\n      expect(typeof autoNumValue).toBe('number');\n      expect(autoNumValue).toBeGreaterThan(existingCount);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/filter.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType, isEmpty, type IFieldVo, type IFilterRo } from '@teable/core';\nimport { updateViewFilter as apiSetViewFilter } from '@teable/openapi';\nimport { initApp, getView, createTable, permanentDeleteTable, createField } from './utils/init-app';\n\nlet app: INestApplication;\nconst baseId = globalThis.testConfig.baseId;\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\nasync function updateViewFilter(tableId: string, viewId: string, filterRo: IFilterRo) {\n  try {\n    const result = await apiSetViewFilter(tableId, viewId, filterRo);\n    return result.data;\n  } catch (e) {\n    console.log(e);\n  }\n}\n\ndescribe('OpenAPI ViewController (e2e) option (PUT)', () => {\n  let tableId: string;\n  let viewId: string;\n  let fields: IFieldVo[];\n  beforeAll(async () => {\n    const result = await createTable(baseId, {\n      name: 'Table',\n    });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    fields = result.fields;\n  });\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/filter (PUT) update filter`, async () => {\n    const assertFilter: IFilterRo = {\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: fields[0].id,\n            operator: 'is',\n            value: '2',\n          },\n        ],\n      },\n    };\n    await updateViewFilter(tableId, viewId, assertFilter);\n    const updatedView = await getView(tableId, viewId);\n    const viewFilter = updatedView.filter;\n    expect(viewFilter).toEqual(assertFilter.filter);\n  });\n\n  it('should not allow to modify filter for button field', async () => {\n    const buttonField = await createField(tableId, {\n      type: FieldType.Button,\n    });\n    const assertFilter: IFilterRo = {\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: buttonField.id,\n            operator: isEmpty.value,\n            value: null,\n          },\n        ],\n      },\n    };\n    await expect(apiSetViewFilter(tableId, viewId, assertFilter)).rejects.toThrow();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-boolean-numeric-coercion.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula boolean numeric coercion (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('compares checkbox values against numeric literals', async () => {\n    const fields: IFieldRo[] = [\n      {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      },\n      {\n        name: 'Notified',\n        type: FieldType.Checkbox,\n      },\n    ];\n\n    const table = await createTable(baseId, {\n      name: 'formula_boolean_numeric_coercion',\n      fields,\n      records: [\n        { fields: { Name: 'row-1', Notified: true } },\n        { fields: { Name: 'row-2', Notified: false } },\n      ],\n    });\n\n    try {\n      const fieldMap = new Map<string, IFieldVo>(table.fields.map((f) => [f.name, f]));\n      const checkboxField = fieldMap.get('Notified')!;\n\n      const formulaField = await createField(table.id, {\n        name: 'Notify Status',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${checkboxField.id}} = 1, 'already', 'pending')`,\n        },\n      });\n\n      const firstRecord = await getRecord(table.id, table.records[0].id);\n      expect(firstRecord.fields[formulaField.id]).toBe('already');\n\n      const secondRecord = await getRecord(table.id, table.records[1].id);\n      expect(secondRecord.fields[formulaField.id]).toBe('pending');\n\n      await updateRecordByApi(table.id, table.records[1].id, checkboxField.id, true);\n      const updatedRecord = await getRecord(table.id, table.records[1].id);\n      expect(updatedRecord.fields[formulaField.id]).toBe('already');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-conditional-lookup-numeric-if.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFilter, ILookupOptionsRo } from '@teable/core';\nimport { FieldType, getRandomString } from '@teable/core';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\n/**\n * Regression: numeric formulas containing IF branches that return conditional-lookup\n * (json/jsonb array) values must coerce both branches to a numeric type. Otherwise Postgres\n * errors with \"CASE types integer and jsonb cannot be matched\" during computed updates.\n */\ndescribe('Formula conditional lookup numeric IF (regression)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const ctx = await initApp();\n    app = ctx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('evaluates numeric IF branches containing conditional lookup arrays', async () => {\n    const suffix = getRandomString(8);\n\n    const foreign = await createTable(baseId, {\n      name: `t1326_cl_foreign_${suffix}`,\n      fields: [\n        { name: 'Key', type: FieldType.SingleLineText } as IFieldRo,\n        { name: 'Amount', type: FieldType.Number } as IFieldRo,\n      ],\n      records: [{ fields: { Key: 'A', Amount: 5 } }],\n    });\n\n    const host = await createTable(baseId, {\n      name: `t1326_cl_host_${suffix}`,\n      fields: [{ name: 'Key', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Key: 'A' } }, { fields: { Key: 'B' } }],\n    });\n\n    try {\n      const foreignKeyFieldId = foreign.fields.find((field) => field.name === 'Key')!.id;\n      const foreignAmountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n      const hostKeyFieldId = host.fields.find((field) => field.name === 'Key')!.id;\n\n      const keyMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: foreignKeyFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: hostKeyFieldId },\n          },\n        ],\n      };\n\n      const conditionalLookup = await createField(host.id, {\n        name: 'Lookup Amounts',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: foreignAmountFieldId,\n          filter: keyMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const formulaField = await createField(host.id, {\n        name: 'Amount Delta',\n        type: FieldType.Formula,\n        options: {\n          expression: `1 - IF({${conditionalLookup.id}}, {${conditionalLookup.id}}, 0)`,\n          formatting: { type: 'decimal', precision: 2 },\n        },\n      } as IFieldRo);\n\n      const recordA = await getRecord(host.id, host.records[0].id);\n      const recordB = await getRecord(host.id, host.records[1].id);\n\n      expect(recordA.fields[hostKeyFieldId]).toBe('A');\n      expect(recordB.fields[hostKeyFieldId]).toBe('B');\n\n      expect(recordA.fields[formulaField.id]).toBe(-4);\n      expect(recordB.fields[formulaField.id]).toBe(1);\n    } finally {\n      await permanentDeleteTable(baseId, host.id);\n      await permanentDeleteTable(baseId, foreign.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-conditional-numeric-cast-regression.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, generateFieldId } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createRecords,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Formula conditional numeric cast safety (regression)', () => {\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const ctx = await initApp();\n    app = ctx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it.skipIf(isForceV2)(\n    'creates rows successfully when conditional formulas compare malformed numeric text',\n    async () => {\n      const displayPriceFieldId = generateFieldId();\n      const table = (await createTable(baseId, {\n        name: 'formula_conditional_numeric_cast_regression',\n        fields: [\n          {\n            id: displayPriceFieldId,\n            name: 'DisplayPrice',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'MemberContribution',\n            type: FieldType.Formula,\n            options: {\n              expression: `(IF({${displayPriceFieldId}} < 40, 3, IF({${displayPriceFieldId}} < 50, 4, IF({${displayPriceFieldId}} < 75, 5, 8)))) * 1.6`,\n            },\n          },\n        ],\n      })) as ITableFullVo;\n\n      try {\n        await createRecords(table.id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                DisplayPrice: '39.9339.93',\n              },\n            },\n            {\n              fields: {\n                DisplayPrice: '39.93',\n              },\n            },\n          ],\n        });\n\n        const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Name });\n\n        const targetRecords = records.filter((record) => {\n          const displayPrice = record.fields.DisplayPrice;\n          return displayPrice === '39.9339.93' || displayPrice === '39.93';\n        });\n\n        expect(targetRecords).toHaveLength(2);\n        const malformedNumericRecord = targetRecords.find(\n          (record) => record.fields.DisplayPrice === '39.9339.93'\n        );\n        const validNumericRecord = targetRecords.find(\n          (record) => record.fields.DisplayPrice === '39.93'\n        );\n\n        expect(malformedNumericRecord?.fields.MemberContribution).toBeCloseTo(12.8, 6);\n        expect(validNumericRecord?.fields.MemberContribution).toBeCloseTo(4.8, 6);\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-counta-lookup-ancestry.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Formula COUNTA with lookup ancestors (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('counts every non-empty ancestor link even when the field is duplicated', async () => {\n    let tableId: string | undefined;\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-counta-lookup-ancestry',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      });\n      tableId = table.id;\n\n      const parentField = await createField(tableId, {\n        name: 'parent',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: tableId },\n      });\n\n      const ancestor1 = await createField(tableId, {\n        name: 'ancestor1',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableId,\n          linkFieldId: parentField.id,\n          lookupFieldId: parentField.id,\n        },\n      });\n\n      const ancestor2 = await createField(tableId, {\n        name: 'ancestor2',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableId,\n          linkFieldId: parentField.id,\n          lookupFieldId: ancestor1.id,\n        },\n      });\n\n      const ancestor3 = await createField(tableId, {\n        name: 'ancestor3',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableId,\n          linkFieldId: parentField.id,\n          lookupFieldId: ancestor2.id,\n        },\n      });\n\n      const ancestor4 = await createField(tableId, {\n        name: 'ancestor4',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableId,\n          linkFieldId: parentField.id,\n          lookupFieldId: ancestor3.id,\n        },\n      });\n\n      const ancestor5 = await createField(tableId, {\n        name: 'ancestor5',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableId,\n          linkFieldId: parentField.id,\n          lookupFieldId: ancestor4.id,\n        },\n      });\n\n      const levelExpression = `COUNTA({${ancestor5.id}},{${ancestor4.id}},{${ancestor3.id}},{${ancestor2.id}},{${ancestor1.id}},{${parentField.id}})+1`;\n\n      const level = await createField(tableId, {\n        name: 'level',\n        type: FieldType.Formula,\n        options: { expression: levelExpression },\n      });\n\n      const levelCopy = await createField(tableId, {\n        name: 'level_copy',\n        type: FieldType.Formula,\n        options: { expression: levelExpression },\n      });\n\n      const root = (\n        await createRecords(tableId, {\n          fieldKeyType: FieldKeyType.Name,\n          typecast: true,\n          records: [{ fields: { Title: 'root' } }],\n        })\n      ).records[0];\n\n      const child = (\n        await createRecords(tableId, {\n          fieldKeyType: FieldKeyType.Name,\n          typecast: true,\n          records: [{ fields: { Title: 'child', parent: { id: root.id } } }],\n        })\n      ).records[0];\n\n      const grandchild = (\n        await createRecords(tableId, {\n          fieldKeyType: FieldKeyType.Name,\n          typecast: true,\n          records: [{ fields: { Title: 'grandchild', parent: { id: child.id } } }],\n        })\n      ).records[0];\n\n      const greatGrandchild = (\n        await createRecords(tableId, {\n          fieldKeyType: FieldKeyType.Name,\n          typecast: true,\n          records: [{ fields: { Title: 'great-grandchild', parent: { id: grandchild.id } } }],\n        })\n      ).records[0];\n\n      // Allow computed lookups to propagate\n      await new Promise((resolve) => setTimeout(resolve, 200));\n\n      const leaf = await getRecord(tableId, greatGrandchild.id);\n      const fields = leaf.fields ?? {};\n      // eslint-disable-next-line no-console\n      console.log('leaf fields for debug', fields);\n\n      expect(fields[parentField.id]).toMatchObject({ id: grandchild.id });\n      expect(fields[level.id]).toBe(4);\n      expect(fields[levelCopy.id]).toBe(4);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-countall-user-link-lookup.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula COUNTALL user/link/lookup regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('counts values for multi-user field and linked lookup user field', async () => {\n    let sourceTableId: string | undefined;\n    let hostTableId: string | undefined;\n\n    try {\n      const sourceTable = await createTable(baseId, {\n        name: 'formula-countall-user-source',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n      sourceTableId = sourceTable.id;\n\n      const sourcePrimaryFieldId = sourceTable.fields.find((field) => field.isPrimary)?.id;\n      if (!sourcePrimaryFieldId) {\n        throw new Error('Missing source primary field');\n      }\n\n      const ownersField = await createField(sourceTable.id, {\n        name: 'Owners',\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          shouldNotify: false,\n        },\n      });\n\n      const directCountField = await createField(sourceTable.id, {\n        name: 'Owners Count',\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTALL({${ownersField.id}})`,\n        },\n      });\n\n      const createdSource = await createRecords(sourceTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [sourcePrimaryFieldId]: 'source-a',\n              [ownersField.id]: [globalThis.testConfig.userId],\n            },\n          },\n          {\n            fields: {\n              [sourcePrimaryFieldId]: 'source-b',\n            },\n          },\n        ],\n      });\n\n      const sourceRecordA = await getRecord(sourceTable.id, createdSource.records[0].id);\n      const sourceRecordB = await getRecord(sourceTable.id, createdSource.records[1].id);\n\n      expect(Number(sourceRecordA.fields[directCountField.id])).toBe(1);\n      expect(Number(sourceRecordB.fields[directCountField.id] ?? 0)).toBe(0);\n\n      const hostTable = await createTable(baseId, {\n        name: 'formula-countall-user-host',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      });\n      hostTableId = hostTable.id;\n\n      const hostPrimaryFieldId = hostTable.fields.find((field) => field.isPrimary)?.id;\n      if (!hostPrimaryFieldId) {\n        throw new Error('Missing host primary field');\n      }\n\n      const linkField = await createField(hostTable.id, {\n        name: 'People',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: sourceTable.id,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const lookupOwnersField = await createField(hostTable.id, {\n        name: 'Lookup Owners',\n        type: FieldType.User,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: sourceTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: ownersField.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const linkCountField = await createField(hostTable.id, {\n        name: 'People Count',\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTALL({${linkField.id}})`,\n        },\n      });\n\n      const lookupCountField = await createField(hostTable.id, {\n        name: 'Lookup Owners Count',\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTALL({${lookupOwnersField.id}})`,\n        },\n      });\n\n      const createdHost = await createRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [hostPrimaryFieldId]: 'host-1',\n              [linkField.id]: [\n                { id: createdSource.records[0].id },\n                { id: createdSource.records[1].id },\n              ],\n            },\n          },\n        ],\n      });\n\n      const hostRecordId = createdHost.records[0].id;\n      const hostRecord = await getRecord(hostTable.id, hostRecordId);\n\n      expect(Number(hostRecord.fields[linkCountField.id])).toBe(2);\n      expect(Number(hostRecord.fields[lookupCountField.id])).toBe(1);\n\n      await updateRecordByApi(hostTable.id, hostRecordId, linkField.id, null);\n\n      const clearedHostRecord = await getRecord(hostTable.id, hostRecordId);\n      expect(Number(clearedHostRecord.fields[linkCountField.id] ?? 0)).toBe(0);\n      expect(Number(clearedHostRecord.fields[lookupCountField.id] ?? 0)).toBe(0);\n    } finally {\n      if (hostTableId) {\n        await permanentDeleteTable(baseId, hostTableId);\n      }\n      if (sourceTableId) {\n        await permanentDeleteTable(baseId, sourceTableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-datetime-format.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, generateFieldId } from '@teable/core';\nimport {\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\nconst DATETIME_FORMAT_SPECIFIER_CASES = [\n  { token: 'YY', expected: '26' },\n  { token: 'YYYY', expected: '2026' },\n  { token: 'M', expected: '2' },\n  { token: 'MM', expected: '02' },\n  { token: 'MMM', expected: 'Feb' },\n  { token: 'MMMM', expected: 'February' },\n  { token: 'D', expected: '12' },\n  { token: 'DD', expected: '12' },\n  { token: 'd', expected: '4' },\n  { token: 'dd', expected: 'Th' },\n  { token: 'ddd', expected: 'Thu' },\n  { token: 'dddd', expected: 'Thursday' },\n  { token: 'H', expected: '15' },\n  { token: 'HH', expected: '15' },\n  { token: 'h', expected: '3' },\n  { token: 'hh', expected: '03' },\n  { token: 'm', expected: '4' },\n  { token: 'mm', expected: '04' },\n  { token: 's', expected: '5' },\n  { token: 'ss', expected: '05' },\n  { token: 'SSS', expected: '678' },\n  { token: 'Z', expected: '+00:00' },\n  { token: 'ZZ', expected: '+0000' },\n  { token: 'A', expected: 'PM' },\n  { token: 'a', expected: 'pm' },\n  { token: 'LT', expected: '3:04 PM' },\n  { token: 'LTS', expected: '3:04:05 PM' },\n  { token: 'L', expected: '02/12/2026' },\n  { token: 'LL', expected: 'February 12, 2026' },\n  { token: 'LLL', expected: 'February 12, 2026 3:04 PM' },\n  { token: 'LLLL', expected: 'Thursday, February 12, 2026 3:04 PM' },\n  { token: 'l', expected: '2/12/2026' },\n  { token: 'll', expected: 'Feb 12, 2026' },\n  { token: 'lll', expected: 'Feb 12, 2026 3:04 PM' },\n  { token: 'llll', expected: 'Thu, Feb 12, 2026 3:04 PM' },\n] as const;\n\ndescribe('Formula DATETIME_FORMAT token semantics (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('treats HH as 24-hour clock and mm as minutes like Airtable', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-24h',\n        fields: [\n          { id: dateFieldId, name: 'event_time', type: FieldType.Date },\n          {\n            name: 'formatted_24h',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_FORMAT({${dateFieldId}}, 'YYYY-MM-DD HH:mm:ss')`,\n              timeZone: 'UTC',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formattedFieldId =\n        table.fields.find((f) => f.name === 'formatted_24h')?.id ??\n        (() => {\n          throw new Error('formatted_24h field not found');\n        })();\n      const input = '2024-12-03T09:07:11.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_time: input } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const fields = record.fields;\n      expect(fields?.[formattedFieldId as string]).toBe('2024-12-03 09:07:11');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('defaults DATETIME_FORMAT to an ISO-like pattern when the format is omitted', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-default',\n        fields: [\n          { id: dateFieldId, name: 'handover_time', type: FieldType.Date },\n          {\n            name: 'handover_year',\n            type: FieldType.Formula,\n            options: {\n              expression: `LEFT(DATETIME_FORMAT({${dateFieldId}}), 4)`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formulaFieldId =\n        table.fields.find((f) => f.name === 'handover_year')?.id ??\n        (() => {\n          throw new Error('handover_year field not found');\n        })();\n\n      const input = '2024-10-10T16:00:00.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { handover_time: input } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const value = record.fields?.[formulaFieldId as string];\n      expect(value).toBe('2024');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('keeps hh with A as a 12-hour clock while mm stays minutes', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-12h',\n        fields: [\n          { id: dateFieldId, name: 'planned_time', type: FieldType.Date },\n          {\n            name: 'formatted_12h',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_FORMAT({${dateFieldId}}, 'YYYY-MM-DD hh:mm A')`,\n              timeZone: 'UTC',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formattedFieldId =\n        table.fields.find((f) => f.name === 'formatted_12h')?.id ??\n        (() => {\n          throw new Error('formatted_12h field not found');\n        })();\n      const input = '2024-05-06T15:04:05.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { planned_time: input } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const fields = record.fields;\n      expect(fields?.[formattedFieldId as string]).toBe('2024-05-06 03:04 PM');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('supports Postgres month/day name specifiers without corrupting them', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-postgres-names',\n        fields: [\n          { id: dateFieldId, name: 'event_date', type: FieldType.Date },\n          {\n            name: 'formatted_names',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_FORMAT({${dateFieldId}}, 'YY-Month-Day')`,\n              timeZone: 'UTC',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formattedFieldId =\n        table.fields.find((f) => f.name === 'formatted_names')?.id ??\n        (() => {\n          throw new Error('formatted_names field not found');\n        })();\n\n      const input = '2025-11-27T00:00:00.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_date: input } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const value = record.fields?.[formattedFieldId as string];\n      expect(value).toBe('25-November-Thursday');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('supports all documented DATETIME_FORMAT specifiers', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const formulaFields = DATETIME_FORMAT_SPECIFIER_CASES.map((item, index) => ({\n        name: `spec_${index.toString().padStart(2, '0')}`,\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_FORMAT({${dateFieldId}}, '${item.token}')`,\n          timeZone: 'UTC',\n        },\n      }));\n\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-all-specifiers',\n        fields: [{ id: dateFieldId, name: 'input_time', type: FieldType.Date }, ...formulaFields],\n      });\n      tableId = table.id;\n\n      const fieldIdByName = Object.fromEntries(table.fields.map((field) => [field.name, field.id]));\n      const input = '2026-02-12T15:04:05.678Z';\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { input_time: input } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      for (const [index, item] of DATETIME_FORMAT_SPECIFIER_CASES.entries()) {\n        const fieldName = `spec_${index.toString().padStart(2, '0')}`;\n        const fieldId = fieldIdByName[fieldName];\n        expect(record.fields?.[fieldId as string]).toBe(item.expected);\n      }\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('returns null instead of throwing when formatting non-datetime text', async () => {\n    let tableId: string | undefined;\n    const textFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-format-invalid-text',\n        fields: [\n          { id: textFieldId, name: 'raw_text', type: FieldType.SingleLineText },\n          {\n            name: 'formatted_invalid',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_FORMAT({${textFieldId}}, 'YYYY-MM-DD HH:mm')`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formattedFieldId =\n        table.fields.find((f) => f.name === 'formatted_invalid')?.id ??\n        (() => {\n          throw new Error('formatted_invalid field not found');\n        })();\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { raw_text: '2' } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const fields = record.fields;\n      const value = fields?.[formattedFieldId as string];\n      expect(value ?? null).toBeNull();\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-datetime-parse-update.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, generateFieldId } from '@teable/core';\nimport {\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\n/**\n * Tests for DATETIME_PARSE formula parsing and updates.\n *\n * This test suite verifies:\n * 1. DATETIME_PARSE correctly parses both single-digit (e.g., \"2026-9-15\") and\n *    double-digit (e.g., \"2026-09-15\") month/day formats.\n * 2. Formula fields using DATETIME_PARSE correctly recalculate when source fields change.\n *\n * Related fix: DEFAULT_DATETIME_PARSE_PATTERN was updated to accept [0-9]{1,2}\n * for month and day instead of requiring [0-9]{2}.\n */\ndescribe('Formula DATETIME_PARSE update semantics (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  /**\n   * Test basic DATETIME_PARSE functionality with zero-padded format.\n   * This should work in both v1 and v2.\n   */\n  it('parses zero-padded date format correctly', async () => {\n    let tableId: string | undefined;\n    const textFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-basic',\n        fields: [\n          { id: textFieldId, name: 'TextDate', type: FieldType.SingleLineText },\n          {\n            name: 'ParsedDate',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE({${textFieldId}})`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formulaFieldId =\n        table.fields.find((f) => f.name === 'ParsedDate')?.id ??\n        (() => {\n          throw new Error('ParsedDate field not found');\n        })();\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { TextDate: '2024-06-15' } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const formulaValue = record.fields?.[formulaFieldId as string];\n\n      expect(formulaValue).not.toBeNull();\n      expect(formulaValue).not.toBeUndefined();\n      expect(new Date(formulaValue as string).toISOString()).toBe('2024-06-15T00:00:00.000Z');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  /**\n   * Test DATETIME_PARSE without format and timezone.\n   * This test verifies Asia/Shanghai local time is used when no format is provided.\n   */\n  it('parses DATETIME_PARSE without format as local timezone', async () => {\n    let tableId: string | undefined;\n    const textFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-timezone',\n        fields: [\n          { id: textFieldId, name: 'TextDate', type: FieldType.SingleLineText },\n          {\n            name: 'ParsedDate',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE({${textFieldId}})`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formulaFieldId =\n        table.fields.find((f) => f.name === 'ParsedDate')?.id ??\n        (() => {\n          throw new Error('ParsedDate field not found');\n        })();\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { TextDate: '2026-01-15 08:30:00' } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const formulaValue = record.fields?.[formulaFieldId as string];\n\n      expect(formulaValue).not.toBeNull();\n      expect(formulaValue).not.toBeUndefined();\n      expect(new Date(formulaValue as string).toISOString()).toBe('2026-01-15T00:30:00.000Z');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('reparses date fields with explicit MMYYYY format into the first day of the month', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-month-bucket',\n        fields: [\n          { id: dateFieldId, name: 'TransactionDate', type: FieldType.Date },\n          {\n            name: 'MonthBucket',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE({${dateFieldId}}, \"MMYYYY\")`,\n              timeZone: 'UTC',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formulaFieldId =\n        table.fields.find((f) => f.name === 'MonthBucket')?.id ??\n        (() => {\n          throw new Error('MonthBucket field not found');\n        })();\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { TransactionDate: '2025-01-05T00:00:00.000Z' } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n      const formulaValue = record.fields?.[formulaFieldId as string];\n\n      expect(formulaValue).toBe('2025-01-01T00:00:00.000Z');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  /**\n   * Test DATETIME_PARSE with single-digit month format.\n   * This test verifies that single-digit months are correctly parsed.\n   */\n  it('parses single-digit month format correctly', async () => {\n    let tableId: string | undefined;\n    const singleDigitFieldId = generateFieldId();\n    const doubleDigitFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-format-compare',\n        fields: [\n          { id: singleDigitFieldId, name: 'SingleDigitDate', type: FieldType.SingleLineText },\n          { id: doubleDigitFieldId, name: 'DoubleDigitDate', type: FieldType.SingleLineText },\n          {\n            name: 'ParsedSingle',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE({${singleDigitFieldId}})`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n          {\n            name: 'ParsedDouble',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE({${doubleDigitFieldId}})`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [\n          {\n            fields: {\n              SingleDigitDate: '2026-9-15', // Single digit month\n              DoubleDigitDate: '2026-09-15', // Double digit month\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n\n      const parsedSingleField = table.fields.find((f) => f.name === 'ParsedSingle')!;\n      const parsedDoubleField = table.fields.find((f) => f.name === 'ParsedDouble')!;\n\n      // Double digit format should work\n      const parsedDouble = record.fields?.[parsedDoubleField.id];\n      expect(parsedDouble).not.toBeNull();\n      expect(parsedDouble).not.toBeUndefined();\n\n      // Single digit format should also work\n      const parsedSingle = record.fields?.[parsedSingleField.id];\n      expect(parsedSingle).not.toBeNull();\n      expect(parsedSingle).not.toBeUndefined();\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  /**\n   * Test DATETIME_PARSE with YEAR/MONTH/DAY concatenation.\n   * This test verifies the real-world scenario where MONTH() returns single-digit values.\n   */\n  it('DATETIME_PARSE with MONTH/DAY concatenation works', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-concat',\n        fields: [\n          { id: dateFieldId, name: 'Date', type: FieldType.Date },\n          {\n            name: 'ConcatFormula',\n            type: FieldType.Formula,\n            options: {\n              expression: `YEAR(TODAY()) & \"-\" & MONTH({${dateFieldId}}) & \"-\" & DAY({${dateFieldId}})`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n          {\n            name: 'ParsedDate',\n            type: FieldType.Formula,\n            options: {\n              expression: `DATETIME_PARSE(YEAR(TODAY()) & \"-\" & MONTH({${dateFieldId}}) & \"-\" & DAY({${dateFieldId}}))`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      // September 15 will generate \"2026-9-15\" (single digit month)\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { Date: '2025-09-15T09:47:06.000Z' } }],\n      });\n\n      const record = await getRecord(tableId, records[0].id);\n\n      const concatField = table.fields.find((f) => f.name === 'ConcatFormula')!;\n      const parsedField = table.fields.find((f) => f.name === 'ParsedDate')!;\n\n      // ConcatFormula should produce \"2026-9-15\"\n      const concatValue = record.fields?.[concatField.id];\n      expect(concatValue).toMatch(/^\\d{4}-9-15$/); // e.g., \"2026-9-15\"\n\n      // ParsedDate should parse the single-digit format correctly\n      const parsedValue = record.fields?.[parsedField.id];\n      expect(parsedValue).not.toBeNull();\n      expect(parsedValue).not.toBeUndefined();\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  /**\n   * Test formula update with double-digit months (this should work in v1).\n   * Uses December (month 12) which doesn't have the single-digit issue.\n   */\n  it('updates DATETIME_PARSE formula when date field changes (double-digit month)', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-datetime-parse-update-double',\n        fields: [\n          { id: dateFieldId, name: 'Date', type: FieldType.Date },\n          {\n            name: 'ParsedDate',\n            type: FieldType.Formula,\n            options: {\n              // Use a formula that always produces zero-padded format\n              expression: `DATETIME_PARSE(YEAR(TODAY()) & \"-12-\" & DAY({${dateFieldId}}))`,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const formulaFieldId =\n        table.fields.find((f) => f.name === 'ParsedDate')?.id ??\n        (() => {\n          throw new Error('ParsedDate field not found');\n        })();\n\n      // Create record with initial date\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { Date: '2025-12-15T09:47:06.000Z' } }],\n      });\n\n      // Verify formula computed correctly after creation\n      const recordAfterCreate = await getRecord(tableId, records[0].id);\n      const formulaValueAfterCreate = recordAfterCreate.fields?.[formulaFieldId as string];\n\n      expect(formulaValueAfterCreate).not.toBeNull();\n      expect(formulaValueAfterCreate).not.toBeUndefined();\n\n      // Verify the parsed date contains day 15\n      const parsedAfterCreate = new Date(formulaValueAfterCreate as string);\n      expect(parsedAfterCreate.getUTCDate()).toBe(15);\n\n      // Update the date to change the day\n      await updateRecordByApi(tableId, records[0].id, dateFieldId, '2025-12-28T09:48:15.000Z');\n\n      // Verify formula recalculated correctly after update\n      const recordAfterUpdate = await getRecord(tableId, records[0].id);\n      const formulaValueAfterUpdate = recordAfterUpdate.fields?.[formulaFieldId as string];\n\n      expect(formulaValueAfterUpdate).not.toBeNull();\n      expect(formulaValueAfterUpdate).not.toBeUndefined();\n\n      // Verify the parsed date now contains day 28\n      const parsedAfterUpdate = new Date(formulaValueAfterUpdate as string);\n      expect(parsedAfterUpdate.getUTCDate()).toBe(28);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-delete-chain.e2e-spec.ts",
    "content": "/* eslint-disable regexp/no-super-linear-backtracking */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport {\n  createField,\n  createTable,\n  deleteField,\n  deleteTable,\n  getField,\n  initApp,\n} from './utils/init-app';\n\ndescribe('Formula delete dependency chain (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let dbProvider: IDbProvider;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    dbProvider = app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('marks downstream formulas hasError and drops generated columns after deleting base field', async () => {\n    // 1) Create table with a non-primary text field and number field A (A is not primary)\n    const table: ITableFullVo = await createTable(baseId, {\n      name: 'Formula Chain Delete Test',\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText },\n        { name: 'A', type: FieldType.Number },\n      ],\n      records: [{ fields: { Title: 'r1', A: 1 } }],\n    });\n\n    const fieldA = table.fields.find((f) => f.name === 'A')!;\n\n    // 2) Create formula B = A * 2\n    const fieldB: IFieldVo = await createField(table.id, {\n      type: FieldType.Formula,\n      name: 'B',\n      options: { expression: `{${fieldA.id}} * 2` },\n    });\n\n    // 3) Create formula C = B * 2\n    const fieldC: IFieldVo = await createField(table.id, {\n      type: FieldType.Formula,\n      name: 'C',\n      options: { expression: `{${fieldB.id}} * 2` },\n    });\n\n    // Get dbTableName for the created table\n    const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: table.id },\n      select: { dbTableName: true },\n    });\n\n    const columnInfoSql = dbProvider.columnInfo(tableMeta.dbTableName);\n    const listColumns = async (): Promise<string[]> => {\n      const rows = await prisma.txClient().$queryRawUnsafe<{ name: string }[]>(columnInfoSql);\n      return rows.map((r) => r.name);\n    };\n\n    // 4) Expect B and C have physical columns initially\n    const initialCols = await listColumns();\n    expect(initialCols).toContain(fieldB.dbFieldName);\n    expect(initialCols).toContain(fieldC.dbFieldName);\n\n    // 5) Delete A\n    await deleteField(table.id, fieldA.id);\n\n    // 6) With generated columns disabled, columns remain but values should be cleared\n    const afterDeleteCols = await listColumns();\n    expect(afterDeleteCols).toContain(fieldB.dbFieldName);\n    expect(afterDeleteCols).toContain(fieldC.dbFieldName);\n\n    const parseSchemaAndTable = (dbTableName: string): [string, string] => {\n      const match = dbTableName.match(/^\"?(.*?)\"?\\.\"?(.*?)\"?$/);\n      if (match) {\n        return [match[1], match[2]];\n      }\n      const parts = dbTableName.split('.');\n      return [parts[0] ?? dbTableName, parts[1] ?? dbTableName];\n    };\n    const [schema, tableName] = parseSchemaAndTable(tableMeta.dbTableName);\n    const row = (\n      await prisma\n        .txClient()\n        .$queryRawUnsafe<\n          Record<string, unknown>[]\n        >(`SELECT * FROM \"${schema}\".\"${tableName}\" LIMIT 1`)\n    )[0];\n    expect(row?.[fieldB.dbFieldName]).toBeNull();\n    expect(row?.[fieldC.dbFieldName]).toBeNull();\n\n    // 7) Expect both B and C have hasError = true\n    const bVo = await getField(table.id, fieldB.id);\n    const cVo = await getField(table.id, fieldC.id);\n    expect(!!bVo.hasError).toBe(true);\n    expect(!!cVo.hasError).toBe(true);\n\n    // Cleanup\n    await deleteTable(baseId, table.id);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-field.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  FormulaFieldCore,\n  IFieldVo,\n  INumberFieldOptions,\n  IRatingFieldOptions,\n} from '@teable/core';\nimport {\n  Colors,\n  DateFormattingPreset,\n  FieldKeyType,\n  FieldType,\n  Relationship,\n  TimeFormatting,\n} from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getError } from './utils/get-error';\nimport {\n  createBase,\n  createField,\n  createRecords,\n  createTable,\n  deleteBase,\n  deleteTable,\n  getRecord,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI Formula Field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    app = (await initApp()).app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('create formula field', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      // Create a table with various field types for testing\n      table = await createTable(baseId, {\n        name: 'Formula Test Table',\n        fields: [\n          {\n            name: 'Text Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Number Field',\n            type: FieldType.Number,\n            options: {\n              formatting: { type: 'decimal', precision: 2 },\n            } as INumberFieldOptions,\n          },\n          {\n            name: 'Date Field',\n            type: FieldType.Date,\n          },\n          {\n            name: 'Rating Field',\n            type: FieldType.Rating,\n            options: {\n              icon: 'star',\n              max: 5,\n              color: 'yellowBright',\n            } as IRatingFieldOptions,\n          },\n          {\n            name: 'Checkbox Field',\n            type: FieldType.Checkbox,\n          },\n          {\n            name: 'Select Field',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'Option A', color: Colors.Blue },\n                { name: 'Option B', color: Colors.Red },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Text Field': 'Hello World',\n              'Number Field': 42.5,\n              'Date Field': '2024-01-15',\n              'Rating Field': 4,\n              'Checkbox Field': true,\n              'Select Field': 'Option A',\n            },\n          },\n          {\n            fields: {\n              'Text Field': 'Test String',\n              'Number Field': 100,\n              'Date Field': '2024-02-20',\n              'Rating Field': 3,\n              'Checkbox Field': false,\n              'Select Field': 'Option B',\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('should create formula referencing text field', async () => {\n      const textFieldId = table.fields.find((f) => f.name === 'Text Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Text Formula',\n        options: {\n          expression: `UPPER({${textFieldId}})`,\n        },\n      });\n\n      expect(formulaField.type).toBe(FieldType.Formula);\n      expect((formulaField as FormulaFieldCore).options.expression).toBe(`UPPER({${textFieldId}})`);\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('HELLO WORLD');\n      expect(records[1].fields[formulaField.id]).toBe('TEST STRING');\n    });\n\n    it('should create formula referencing number field', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Number Formula',\n        options: {\n          expression: `{${numberFieldId}} * 2`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe(85);\n      expect(records[1].fields[formulaField.id]).toBe(200);\n    });\n\n    it('should create formula referencing date field', async () => {\n      const dateFieldId = table.fields.find((f) => f.name === 'Date Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Date Formula',\n        options: {\n          expression: `YEAR({${dateFieldId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe(2024);\n      expect(records[1].fields[formulaField.id]).toBe(2024);\n    });\n\n    it('should create formula referencing rating field', async () => {\n      const ratingFieldId = table.fields.find((f) => f.name === 'Rating Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Rating Formula',\n        options: {\n          expression: `{${ratingFieldId}} + 1`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe(5);\n      expect(records[1].fields[formulaField.id]).toBe(4);\n    });\n\n    it('should create formula referencing checkbox field', async () => {\n      const checkboxFieldId = table.fields.find((f) => f.name === 'Checkbox Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Checkbox Formula',\n        options: {\n          expression: `IF({${checkboxFieldId}}, \"Yes\", \"No\")`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('Yes');\n      expect(records[1].fields[formulaField.id]).toBe('No');\n    });\n\n    it('should create formula referencing select field', async () => {\n      const selectFieldId = table.fields.find((f) => f.name === 'Select Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Select Formula',\n        options: {\n          expression: `CONCATENATE(\"Selected: \", {${selectFieldId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('Selected: Option A');\n      expect(records[1].fields[formulaField.id]).toBe('Selected: Option B');\n    });\n\n    it('should substitute numeric field as text', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Number Substitute',\n        options: {\n          expression: `SUBSTITUTE({${numberFieldId}}, \"0\", \"X\")`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('42.5');\n      expect(records[1].fields[formulaField.id]).toBe('1XX');\n    });\n\n    it('should create formula with multiple field references', async () => {\n      const textFieldId = table.fields.find((f) => f.name === 'Text Field')!.id;\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Multi Field Formula',\n        options: {\n          expression: `CONCATENATE({${textFieldId}}, \" - \", {${numberFieldId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('Hello World - 42.5');\n      expect(records[1].fields[formulaField.id]).toBe('Test String - 100');\n    });\n  });\n\n  describe('formula recalculation on record creation', () => {\n    let table: ITableFullVo;\n    let statusFieldId: string;\n    let statusFormulaFieldId: string;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Formula Status Table',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Status',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      statusFieldId = table.fields.find((f) => f.name === 'Status')!.id;\n\n      const statusFormulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Status Formula',\n        options: {\n          expression: `IF({${statusFieldId}}=\"\", 1, 222222)`,\n        },\n      });\n\n      statusFormulaFieldId = statusFormulaField.id;\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('should calculate formula when referenced field is omitted on creation', async () => {\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              Name: 'Missing status',\n            },\n          },\n        ],\n      });\n\n      const createdRecordId = created.records[0].id;\n      const record = await getRecord(table.id, createdRecordId);\n\n      expect(record.fields[statusFieldId]).toBeUndefined();\n      expect(record.fields[statusFormulaFieldId]).toBe(1);\n    });\n\n    it('should calculate alternate branch when referenced field has value', async () => {\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              Name: 'Has status',\n              Status: 'done',\n            },\n          },\n        ],\n      });\n\n      const createdRecordId = created.records[0].id;\n      const record = await getRecord(table.id, createdRecordId);\n\n      expect(record.fields[statusFormulaFieldId]).toBe(222222);\n    });\n  });\n\n  describe('formula recalculation referencing lookup dependencies', () => {\n    let mainTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let formulaFieldId: string;\n    let nameFieldId: string;\n\n    beforeEach(async () => {\n      foreignTable = await createTable(baseId, {\n        name: 'Lookup Source Table',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { Title: 'Item A' } }, { fields: { Title: 'Item B' } }],\n      });\n\n      mainTable = await createTable(baseId, {\n        name: 'Lookup Host Table',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      nameFieldId = mainTable.fields.find((f) => f.name === 'Name')!.id;\n\n      linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const titleFieldId = foreignTable.fields.find((f) => f.name === 'Title')!.id;\n\n      lookupField = await createField(mainTable.id, {\n        type: FieldType.SingleLineText,\n        name: 'Lookup Title',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: titleFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const formulaField = await createField(mainTable.id, {\n        type: FieldType.Formula,\n        name: 'Lookup Formula',\n        options: {\n          expression: `IF({${lookupField.id}}=\"\", \"no lookup\", {${lookupField.id}})`,\n        },\n      });\n\n      formulaFieldId = formulaField.id;\n    });\n\n    afterEach(async () => {\n      if (mainTable?.id) {\n        await deleteTable(baseId, mainTable.id);\n      }\n      if (foreignTable?.id) {\n        await deleteTable(baseId, foreignTable.id);\n      }\n    });\n\n    it('should compute lookup-based formula when link is omitted on creation', async () => {\n      const created = await createRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [nameFieldId]: 'No link',\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(mainTable.id, created.records[0].id);\n      expect(record.fields[formulaFieldId]).toBe('no lookup');\n    });\n\n    it('should compute lookup-based formula when link is provided on creation', async () => {\n      const created = await createRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [nameFieldId]: 'Linked record',\n              [linkField.id]: { id: foreignTable.records[0].id },\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(mainTable.id, created.records[0].id);\n      expect(record.fields[lookupField.id]).toBe('Item A');\n      expect(record.fields[formulaFieldId]).toBe('Item A');\n    });\n  });\n\n  describe('lookup formula with blank single select lookup', () => {\n    let foreignBaseId: string;\n    let ordersTable: ITableFullVo;\n    let followupTable: ITableFullVo;\n    let linkFieldId: string;\n    let statusLookupFieldId: string;\n    let planLookupFieldId: string;\n    let formulaFieldId: string;\n    let titleFieldId: string;\n\n    beforeEach(async () => {\n      const spaceId = globalThis.testConfig.spaceId;\n      const createdBase = await createBase({ spaceId, name: 'Cross Base Orders' });\n      foreignBaseId = createdBase.id;\n\n      ordersTable = await createTable(foreignBaseId, {\n        name: 'Orders',\n        fields: [\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'Paid', color: Colors.Green },\n                { name: 'Deposit', color: Colors.Blue },\n              ],\n            },\n          },\n          {\n            name: 'Plan',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'Plan2', color: Colors.Cyan },\n                { name: 'Plan3', color: Colors.Orange },\n                { name: 'Other', color: Colors.Gray },\n              ],\n            },\n          },\n        ],\n        records: [\n          { fields: { Status: 'Paid', Plan: 'Plan2' } },\n          { fields: { Status: 'Deposit', Plan: 'Plan3' } },\n        ],\n      });\n\n      followupTable = await createTable(baseId, {\n        name: 'Order Followups',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      titleFieldId = followupTable.fields.find((f) => f.name === 'Title')!.id;\n\n      const linkField = await createField(followupTable.id, {\n        name: 'Order',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: ordersTable.id,\n          isOneWay: true,\n        },\n      });\n\n      linkFieldId = linkField.id;\n\n      const statusFieldId = ordersTable.fields.find((f) => f.name === 'Status')!.id;\n      const planFieldId = ordersTable.fields.find((f) => f.name === 'Plan')!.id;\n\n      const statusLookupField = await createField(followupTable.id, {\n        name: 'Lookup Status',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: ordersTable.id,\n          lookupFieldId: statusFieldId,\n          linkFieldId,\n        },\n      });\n\n      statusLookupFieldId = statusLookupField.id;\n\n      const planLookupField = await createField(followupTable.id, {\n        name: 'Lookup Plan',\n        type: FieldType.SingleSelect,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: ordersTable.id,\n          lookupFieldId: planFieldId,\n          linkFieldId,\n        },\n      });\n\n      planLookupFieldId = planLookupField.id;\n\n      const formulaField = await createField(followupTable.id, {\n        name: 'Status Notice',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(\n            {${statusLookupFieldId}}=\"Paid\",\n            \"No reminder\",\n            IF(\n              AND(\n                {${statusLookupFieldId}}=\"Deposit\",\n                OR(\n                  {${planLookupFieldId}}=\"Plan2\",\n                  {${planLookupFieldId}}=\"Plan3\"\n                )\n              ),\n              \"Installment follow-up\",\n              IF(\n                AND(\n                  {${statusLookupFieldId}}=\"Deposit\",\n                  NOT(\n                    OR(\n                      {${planLookupFieldId}}=\"Plan2\",\n                      {${planLookupFieldId}}=\"Plan3\"\n                    )\n                  )\n                ),\n                \"Tail follow-up\",\n                IF(\n                  {${statusLookupFieldId}}=\"\",\n                  \"Tail follow-up\",\n                  \"Tail follow-up\"\n                )\n              )\n            )\n          )`,\n        },\n      });\n\n      formulaFieldId = formulaField.id;\n    });\n\n    afterEach(async () => {\n      if (followupTable?.id) {\n        await deleteTable(baseId, followupTable.id);\n      }\n      if (ordersTable?.id && foreignBaseId) {\n        await permanentDeleteTable(foreignBaseId, ordersTable.id);\n      }\n      if (foreignBaseId) {\n        await deleteBase(foreignBaseId);\n      }\n    });\n\n    it('should fallback when lookup is blank', async () => {\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: 'Unlinked order',\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[statusLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[planLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should use lookup value when record is linked', async () => {\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: 'Linked order',\n              [linkFieldId]: { id: ordersTable.records[0].id },\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[statusLookupFieldId]).toBe('Paid');\n      expect(record.fields[planLookupFieldId]).toBe('Plan2');\n      expect(record.fields[formulaFieldId]).toBe('No reminder');\n    });\n\n    it('should still fallback when record is created without other field values', async () => {\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[statusLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[planLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should fallback even if reference table is missing entries', async () => {\n      const prisma = app.get(PrismaService);\n      await prisma.reference.deleteMany({\n        where: { fromFieldId: linkFieldId },\n      });\n      await prisma.reference.deleteMany({\n        where: { toFieldId: { in: [statusLookupFieldId, planLookupFieldId] } },\n      });\n\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should fallback when the only field sent is explicitly null', async () => {\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: null,\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[statusLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[planLookupFieldId] ?? null).toBeNull();\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should fallback even if lookup-to-formula references are missing', async () => {\n      const prisma = app.get(PrismaService);\n      await prisma.reference.deleteMany({\n        where: {\n          OR: [\n            { fromFieldId: linkFieldId },\n            { toFieldId: linkFieldId },\n            { fromFieldId: { in: [statusLookupFieldId, planLookupFieldId] } },\n            { toFieldId: { in: [statusLookupFieldId, planLookupFieldId, formulaFieldId] } },\n          ],\n        },\n      });\n\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should fallback even if lookup fields are not marked computed', async () => {\n      const prisma = app.get(PrismaService);\n      await prisma.field.updateMany({\n        where: { id: { in: [statusLookupFieldId, planLookupFieldId] } },\n        data: { isComputed: false },\n      });\n      await prisma.reference.deleteMany({\n        where: { fromFieldId: { in: [linkFieldId, statusLookupFieldId, planLookupFieldId] } },\n      });\n\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n\n    it('should fallback even if reference graph is completely missing', async () => {\n      const prisma = app.get(PrismaService);\n      await prisma.reference.deleteMany({});\n\n      const created = await createRecords(followupTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(followupTable.id, created.records[0].id);\n      expect(record.fields[formulaFieldId]).toBe('Tail follow-up');\n    });\n  });\n\n  describe('create formula referencing formula', () => {\n    let table: ITableFullVo;\n    let baseFormulaField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Nested Formula Test Table',\n        fields: [\n          {\n            name: 'Number Field',\n            type: FieldType.Number,\n          },\n        ],\n        records: [{ fields: { 'Number Field': 10 } }, { fields: { 'Number Field': 20 } }],\n      });\n\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      // Create base formula field\n      baseFormulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Base Formula',\n        options: {\n          expression: `{${numberFieldId}} * 2`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('should create formula referencing another formula', async () => {\n      const nestedFormulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Nested Formula',\n        options: {\n          expression: `{${baseFormulaField.id}} + 5`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[nestedFormulaField.id]).toBe(25); // (10 * 2) + 5\n      expect(records[1].fields[nestedFormulaField.id]).toBe(45); // (20 * 2) + 5\n    });\n\n    it('should create complex nested formula', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const complexFormulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Complex Formula',\n        options: {\n          expression: `IF({${baseFormulaField.id}} > {${numberFieldId}}, \"Greater\", \"Not Greater\")`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[complexFormulaField.id]).toBe('Greater'); // 20 > 10\n      expect(records[1].fields[complexFormulaField.id]).toBe('Greater'); // 40 > 20\n    });\n  });\n\n  describe('create formula with link, lookup and rollup fields', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let rollupField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create first table\n      table1 = await createTable(baseId, {\n        name: 'Main Table',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { Name: 'Record 1' } }, { fields: { Name: 'Record 2' } }],\n      });\n\n      // Create second table\n      table2 = await createTable(baseId, {\n        name: 'Related Table',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Value',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          { fields: { Title: 'Item A', Value: 100 } },\n          { fields: { Title: 'Item B', Value: 200 } },\n        ],\n      });\n\n      // Create link field\n      linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      // Link records\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[1].id,\n      });\n\n      // Create lookup field\n      const titleFieldId = table2.fields.find((f) => f.name === 'Title')!.id;\n      lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        name: 'Lookup Title',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: titleFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      // Create rollup field\n      const valueFieldId = table2.fields.find((f) => f.name === 'Value')!.id;\n      rollupField = await createField(table1.id, {\n        type: FieldType.Rollup,\n        name: 'Rollup Value',\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: valueFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table1?.id) {\n        await deleteTable(baseId, table1.id);\n      }\n      if (table2?.id) {\n        await deleteTable(baseId, table2.id);\n      }\n    });\n\n    it('should create formula referencing lookup field', async () => {\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Lookup Formula',\n        options: {\n          expression: `{${lookupField.id}}`,\n        },\n      });\n\n      expect(formulaField.type).toBe(FieldType.Formula);\n      expect((formulaField as FormulaFieldCore).options.expression).toBe(`{${lookupField.id}}`);\n\n      // Verify the formula field calculates correctly\n      const records = await getRecords(table1.id);\n      expect(records.records).toHaveLength(2);\n\n      const record1 = records.records[0];\n      const formulaValue1 = record1.fields[formulaField.id];\n      const lookupValue1 = record1.fields[lookupField.id];\n\n      // Formula should return the same value as the lookup field\n      expect(formulaValue1).toEqual(lookupValue1);\n    });\n\n    it('should create formula referencing rollup field', async () => {\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Rollup Formula',\n        options: {\n          expression: `{${rollupField.id}} * 2`,\n        },\n      });\n\n      expect(formulaField.type).toBe(FieldType.Formula);\n      expect((formulaField as FormulaFieldCore).options.expression).toBe(`{${rollupField.id}} * 2`);\n\n      // Verify the formula field calculates correctly\n      const records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records.records).toHaveLength(2);\n\n      const record1 = records.records[0];\n      const formulaValue1 = record1.fields[formulaField.id];\n      const rollupValue1 = record1.fields[rollupField.id] as number;\n\n      // Formula should return rollup value multiplied by 2\n      expect(formulaValue1).toBe(rollupValue1 * 2);\n    });\n\n    it('should fallback when rollup-based formula has no linked data', async () => {\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Rollup Fallback',\n        options: {\n          expression: `IF({${rollupField.id}} > 0, \"Has rollup\", \"No rollup\")`,\n        },\n      });\n\n      const created = await createRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const record = await getRecord(table1.id, created.records[0].id);\n      expect(record.fields[formulaField.id]).toBe('No rollup');\n    });\n\n    it('should create formula referencing link field', async () => {\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Link Formula',\n        options: {\n          expression: `IF({${linkField.id}}, \"Has Link\", \"No Link\")`,\n        },\n      });\n\n      expect(formulaField.type).toBe(FieldType.Formula);\n\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('Has Link');\n      expect(records[1].fields[formulaField.id]).toBe('Has Link');\n    });\n  });\n\n  describe('formula referencing link display with nested lookup', () => {\n    let doctors: ITableFullVo;\n    let patients: ITableFullVo;\n    let orders: ITableFullVo;\n    let doctorLink: IFieldVo;\n    let doctorNameLookup: IFieldVo;\n    let patientDisplayFormula: IFieldVo;\n    let patientLink: IFieldVo;\n    let orderFormula: IFieldVo;\n    let doctorRecordId: string;\n    let patientRecordId: string;\n    let patientCodeFieldId: string;\n    let orderNoFieldId: string;\n    let doctorNameFieldId: string;\n\n    beforeAll(async () => {\n      doctors = await createTable(baseId, {\n        name: 'NestedLookup_Doctors',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { Name: 'Dr Smith' } }],\n      });\n      doctorNameFieldId = doctors.fields.find((f) => f.name === 'Name')!.id;\n      doctorRecordId = doctors.records[0].id;\n\n      patients = await createTable(baseId, {\n        name: 'NestedLookup_Patients',\n        fields: [{ name: 'Patient Code', type: FieldType.SingleLineText }],\n      });\n      patientCodeFieldId = patients.fields.find((f) => f.name === 'Patient Code')!.id;\n\n      doctorLink = await createField(patients.id, {\n        name: 'Doctor',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: doctors.id,\n        },\n      });\n\n      doctorNameLookup = await createField(patients.id, {\n        name: 'Doctor Name',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: doctors.id,\n          linkFieldId: doctorLink.id,\n          lookupFieldId: doctorNameFieldId,\n        },\n      });\n\n      patientDisplayFormula = await createField(patients.id, {\n        name: 'Display',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${patientCodeFieldId}} & \"-\" & {${doctorNameLookup.id}}`,\n        },\n      });\n\n      const createdPatients = await createRecords(patients.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [patientCodeFieldId]: 'P001',\n              [doctorLink.id]: { id: doctorRecordId },\n            },\n          },\n        ],\n      });\n      patientRecordId = createdPatients.records[0].id;\n\n      orders = await createTable(baseId, {\n        name: 'NestedLookup_Orders',\n        fields: [{ name: 'Order No', type: FieldType.SingleLineText }],\n      });\n      orderNoFieldId = orders.fields.find((f) => f.name === 'Order No')!.id;\n\n      patientLink = await createField(orders.id, {\n        name: 'Patient',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: patients.id,\n          lookupFieldId: patientDisplayFormula.id,\n        },\n      });\n\n      orderFormula = await createField(orders.id, {\n        name: 'Order Summary',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${orderNoFieldId}} & \"-\" & {${patientLink.id}}`,\n        },\n      });\n    });\n\n    afterAll(async () => {\n      if (orders?.id) {\n        await permanentDeleteTable(baseId, orders.id);\n      }\n      if (patients?.id) {\n        await permanentDeleteTable(baseId, patients.id);\n      }\n      if (doctors?.id) {\n        await permanentDeleteTable(baseId, doctors.id);\n      }\n    });\n\n    it('should compute formula when link display depends on lookup-of-link', async () => {\n      const createdOrders = await createRecords(orders.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [orderNoFieldId]: 'ORD-1',\n              [patientLink.id]: { id: patientRecordId },\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(orders.id, createdOrders.records[0].id);\n      expect(record.fields[orderFormula.id]).toBe('ORD-1-P001-Dr Smith');\n    });\n  });\n\n  describe('formula using lookup datetime formatting inside concatenation', () => {\n    let contractTable: ITableFullVo;\n    let projectTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let schoolLookupField: IFieldVo;\n    let dateLookupField: IFieldVo;\n    let projectNameFieldId: string;\n    let folderFormulaFieldId: string;\n\n    beforeEach(async () => {\n      contractTable = await createTable(baseId, {\n        name: 'contract-table',\n        fields: [\n          {\n            name: 'Contract Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'School',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Planning Date',\n            type: FieldType.Date,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Contract Name': 'Smart Campus Upgrade',\n              School: 'Shenzhen Institute',\n              'Planning Date': '2024-05-20T00:00:00.000Z',\n            },\n          },\n        ],\n      });\n\n      projectTable = await createTable(baseId, {\n        name: 'project-table',\n        fields: [\n          {\n            name: 'Project Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      projectNameFieldId = projectTable.fields.find((f) => f.name === 'Project Name')!.id;\n\n      linkField = await createField(projectTable.id, {\n        type: FieldType.Link,\n        name: 'Related Contract',\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: contractTable.id,\n        },\n      });\n\n      const schoolFieldId = contractTable.fields.find((f) => f.name === 'School')!.id;\n      schoolLookupField = await createField(projectTable.id, {\n        type: FieldType.SingleLineText,\n        name: 'School Lookup',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: contractTable.id,\n          lookupFieldId: schoolFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const planningDateFieldId = contractTable.fields.find((f) => f.name === 'Planning Date')!.id;\n      dateLookupField = await createField(projectTable.id, {\n        type: FieldType.Date,\n        name: 'Planning Date Lookup',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: contractTable.id,\n          lookupFieldId: planningDateFieldId,\n          linkFieldId: linkField.id,\n        },\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n            timeZone: 'Asia/Shanghai',\n          },\n        },\n      });\n\n      const folderFormulaField = await createField(projectTable.id, {\n        type: FieldType.Formula,\n        name: 'Folder Path',\n        options: {\n          expression: `\"NAS-\" & {${schoolLookupField.id}} & \"-\" & DATETIME_FORMAT({${dateLookupField.id}}, 'YYYYMMDD')`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n      folderFormulaFieldId = folderFormulaField.id;\n    });\n\n    afterEach(async () => {\n      if (projectTable?.id) {\n        await deleteTable(baseId, projectTable.id);\n      }\n      if (contractTable?.id) {\n        await deleteTable(baseId, contractTable.id);\n      }\n    });\n\n    it('should concatenate lookup datetime output safely', async () => {\n      const created = await createRecords(projectTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [projectNameFieldId]: 'NAS Folder',\n              [linkField.id]: { id: contractTable.records[0].id },\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(projectTable.id, created.records[0].id);\n      expect(record.fields[folderFormulaFieldId]).toBe('NAS-Shenzhen Institute-20240520');\n    });\n  });\n\n  describe('formula field indirect reference scenarios', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let rollupField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create first table\n      table1 = await createTable(baseId, {\n        name: 'Main Table',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Value',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          { fields: { Name: 'Record 1', Value: 10 } },\n          { fields: { Name: 'Record 2', Value: 20 } },\n        ],\n      });\n\n      // Create second table\n      table2 = await createTable(baseId, {\n        name: 'Related Table',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Value',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          { fields: { Title: 'Item A', Value: 100 } },\n          { fields: { Title: 'Item B', Value: 200 } },\n        ],\n      });\n\n      // Create link field\n      linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      // Link records\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n        id: table2.records[1].id,\n      });\n\n      // Create lookup field\n      const titleFieldId = table2.fields.find((f) => f.name === 'Title')!.id;\n      lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        name: 'Lookup Title',\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: titleFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      // Create rollup field\n      const valueFieldId = table2.fields.find((f) => f.name === 'Value')!.id;\n      rollupField = await createField(table1.id, {\n        type: FieldType.Rollup,\n        name: 'Rollup Value',\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: valueFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table1?.id) {\n        await deleteTable(baseId, table1.id);\n      }\n      if (table2?.id) {\n        await deleteTable(baseId, table2.id);\n      }\n    });\n\n    it('should successfully create formula that indirectly references link field through another formula', async () => {\n      // First create a formula that references the link field\n      const formula2 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 2',\n        options: {\n          expression: `IF({${linkField.id}}, \"Has Link\", \"No Link\")`,\n        },\n      });\n\n      // Then create a formula that references the first formula\n      const formula1 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 1',\n        options: {\n          expression: `CONCATENATE(\"Result: \", {${formula2.id}})`,\n        },\n      });\n\n      expect(formula1.type).toBe(FieldType.Formula);\n      expect(formula2.type).toBe(FieldType.Formula);\n\n      // Verify the formulas work correctly\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formula1.id]).toBe('Result: Has Link');\n      expect(records[1].fields[formula1.id]).toBe('Result: Has Link');\n    });\n\n    it('should successfully create formula that indirectly references lookup field through another formula', async () => {\n      // First create a formula that references the lookup field\n      const formula2 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 2',\n        options: {\n          expression: `CONCATENATE(\"Lookup: \", {${lookupField.id}})`,\n        },\n      });\n\n      // Then create a formula that references the first formula\n      const formula1 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 1',\n        options: {\n          expression: `UPPER({${formula2.id}})`,\n        },\n      });\n\n      expect(formula1.type).toBe(FieldType.Formula);\n      expect(formula2.type).toBe(FieldType.Formula);\n\n      // Verify the formulas work correctly\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formula1.id]).toBe('LOOKUP: ITEM A');\n      expect(records[1].fields[formula1.id]).toBe('LOOKUP: ITEM B');\n    });\n\n    it('should successfully create formula that indirectly references rollup field through another formula', async () => {\n      // First create a formula that references the rollup field\n      const formula2 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 2',\n        options: {\n          expression: `{${rollupField.id}} * 2`,\n        },\n      });\n\n      // Then create a formula that references the first formula\n      const formula1 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 1',\n        options: {\n          expression: `{${formula2.id}} + 10`,\n        },\n      });\n\n      expect(formula1.type).toBe(FieldType.Formula);\n      expect(formula2.type).toBe(FieldType.Formula);\n\n      // Verify the formulas work correctly\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formula1.id]).toBe(210); // (100 * 2) + 10\n      expect(records[1].fields[formula1.id]).toBe(410); // (200 * 2) + 10\n    });\n\n    it('should successfully create multi-level formula chain', async () => {\n      // Create a chain: formula1 -> formula2 -> formula3 -> rollup field\n      const formula3 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 3',\n        options: {\n          expression: `{${rollupField.id}}`,\n        },\n      });\n\n      const formula2 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 2',\n        options: {\n          expression: `{${formula3.id}} * 2`,\n        },\n      });\n\n      const formula1 = await createField(table1.id, {\n        type: FieldType.Formula,\n        name: 'Formula 1',\n        options: {\n          expression: `{${formula2.id}} + 5`,\n        },\n      });\n\n      expect(formula1.type).toBe(FieldType.Formula);\n      expect(formula2.type).toBe(FieldType.Formula);\n      expect(formula3.type).toBe(FieldType.Formula);\n\n      // Verify the formulas work correctly\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formula1.id]).toBe(205); // (100 * 2) + 5\n      expect(records[1].fields[formula1.id]).toBe(405); // (200 * 2) + 5\n    });\n  });\n\n  describe('formula field error scenarios', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Error Test Table',\n        fields: [\n          {\n            name: 'Text Field',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Number Field',\n            type: FieldType.Number,\n          },\n        ],\n        records: [{ fields: { 'Text Field': 'Test', 'Number Field': 42 } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('should fail with invalid expression syntax', async () => {\n      const error = await getError(() =>\n        createField(table.id, {\n          type: FieldType.Formula,\n          name: 'Invalid Formula',\n          options: {\n            expression: 'INVALID_FUNCTION({field})',\n          },\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should fail with non-existent field reference', async () => {\n      const error = await getError(() =>\n        createField(table.id, {\n          type: FieldType.Formula,\n          name: 'Invalid Field Reference',\n          options: {\n            expression: '{nonExistentFieldId}',\n          },\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n\n    it('should handle empty expression', async () => {\n      const error = await getError(() =>\n        createField(table.id, {\n          type: FieldType.Formula,\n          name: 'Empty Formula',\n          options: {\n            expression: '',\n          },\n        })\n      );\n\n      expect(error?.status).toBe(400);\n    });\n  });\n\n  describe('complex formula scenarios', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Complex Formula Table',\n        fields: [\n          {\n            name: 'First Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Last Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Age',\n            type: FieldType.Number,\n          },\n          {\n            name: 'Birth Date',\n            type: FieldType.Date,\n          },\n          {\n            name: 'Is Active',\n            type: FieldType.Checkbox,\n          },\n          {\n            name: 'Score',\n            type: FieldType.Rating,\n            options: { icon: 'star', max: 5, color: 'yellowBright' } as IRatingFieldOptions,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'First Name': 'John',\n              'Last Name': 'Doe',\n              Age: 30,\n              'Birth Date': '1994-01-15',\n              'Is Active': true,\n              Score: 4,\n            },\n          },\n          {\n            fields: {\n              'First Name': 'Jane',\n              'Last Name': 'Smith',\n              Age: 25,\n              'Birth Date': '1999-06-20',\n              'Is Active': false,\n              Score: 5,\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('should create formula with string concatenation', async () => {\n      const firstNameId = table.fields.find((f) => f.name === 'First Name')!.id;\n      const lastNameId = table.fields.find((f) => f.name === 'Last Name')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Full Name',\n        options: {\n          expression: `CONCATENATE({${firstNameId}}, \" \", {${lastNameId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('John Doe');\n      expect(records[1].fields[formulaField.id]).toBe('Jane Smith');\n    });\n\n    it('should create formula with conditional logic', async () => {\n      const ageId = table.fields.find((f) => f.name === 'Age')!.id;\n      const isActiveId = table.fields.find((f) => f.name === 'Is Active')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Status',\n        options: {\n          expression: `IF(AND({${ageId}} >= 18, {${isActiveId}}), \"Adult Active\", IF({${ageId}} >= 18, \"Adult Inactive\", \"Minor\"))`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe('Adult Active');\n      expect(records[1].fields[formulaField.id]).toBe('Adult Inactive');\n    });\n\n    it('should create formula with mathematical operations', async () => {\n      const ageId = table.fields.find((f) => f.name === 'Age')!.id;\n      const scoreId = table.fields.find((f) => f.name === 'Score')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Weighted Score',\n        options: {\n          expression: `ROUND(({${scoreId}} * {${ageId}}) / 10, 2)`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe(12); // (4 * 30) / 10 = 12\n      expect(records[1].fields[formulaField.id]).toBe(12.5); // (5 * 25) / 10 = 12.5\n    });\n\n    it('should create formula with date functions', async () => {\n      const birthDateId = table.fields.find((f) => f.name === 'Birth Date')!.id;\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: 'Birth Year',\n        options: {\n          expression: `YEAR({${birthDateId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      expect(records[0].fields[formulaField.id]).toBe(1994);\n      expect(records[1].fields[formulaField.id]).toBe(1999);\n    });\n  });\n\n  describe('localized single select numeric coercion', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'Localized Duration Formula',\n        fields: [\n          {\n            name: '定型时长',\n            type: FieldType.SingleSelect,\n            options: {\n              preventAutoNewOptions: true,\n              choices: [\n                { name: '0分钟', color: Colors.GrayDark1 },\n                { name: '20分钟', color: Colors.BlueLight1 },\n                { name: '30分钟', color: Colors.BlueBright },\n              ],\n            },\n          },\n        ],\n        records: [\n          { fields: { 定型时长: '0分钟' } },\n          { fields: { 定型时长: '20分钟' } },\n          { fields: { 定型时长: '30分钟' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('parses localized option labels through VALUE()', async () => {\n      const durationFieldId = table.fields.find((f) => f.name === '定型时长')!.id;\n\n      const numericField = await createField(table.id, {\n        type: FieldType.Formula,\n        name: '定型时长(数值)',\n        options: {\n          expression: `VALUE({${durationFieldId}})`,\n        },\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      const parsedValues = records.map((record) => record.fields[numericField.id]);\n      expect(parsedValues).toEqual([0, 20, 30]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-fromnow-tonow.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, generateFieldId } from '@teable/core';\nimport {\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\nconst toNumber = (value: unknown): number => {\n  const parsed = typeof value === 'number' ? value : Number(value);\n  expect(Number.isFinite(parsed)).toBe(true);\n  return parsed;\n};\n\nconst FLOAT_COMPARISON_TOLERANCE = 1e-9;\n\ndescribe('Formula FROMNOW / TONOW (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('supports unit conversion and keeps TONOW past-positive semantics', async () => {\n    let tableId: string | undefined;\n    const dateFieldId = generateFieldId();\n\n    try {\n      const table = await createTable(baseId, {\n        name: `formula-fromnow-tonow-${Date.now()}`,\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { id: dateFieldId, name: 'EventTime', type: FieldType.Date },\n          {\n            name: 'FROMNOW_day',\n            type: FieldType.Formula,\n            options: {\n              expression: `FROMNOW({${dateFieldId}}, 'day')`,\n            },\n          },\n          {\n            name: 'FROMNOW_hour',\n            type: FieldType.Formula,\n            options: {\n              expression: `FROMNOW({${dateFieldId}}, 'hour')`,\n            },\n          },\n          {\n            name: 'FROMNOW_second',\n            type: FieldType.Formula,\n            options: {\n              expression: `FROMNOW({${dateFieldId}}, 'second')`,\n            },\n          },\n          {\n            name: 'TONOW_day',\n            type: FieldType.Formula,\n            options: {\n              expression: `TONOW({${dateFieldId}}, 'day')`,\n            },\n          },\n        ],\n      });\n      tableId = table.id;\n\n      const fromNowDayId = table.fields.find((f) => f.name === 'FROMNOW_day')?.id;\n      const fromNowHourId = table.fields.find((f) => f.name === 'FROMNOW_hour')?.id;\n      const fromNowSecondId = table.fields.find((f) => f.name === 'FROMNOW_second')?.id;\n      const toNowDayId = table.fields.find((f) => f.name === 'TONOW_day')?.id;\n\n      expect(fromNowDayId).toBeTruthy();\n      expect(fromNowHourId).toBeTruthy();\n      expect(fromNowSecondId).toBeTruthy();\n      expect(toNowDayId).toBeTruthy();\n\n      const now = Date.now();\n      const pastDate = new Date(now - (3 * 24 + 2) * 60 * 60 * 1000).toISOString();\n      const futureDate = new Date(now + (2 * 24 + 1) * 60 * 60 * 1000).toISOString();\n\n      const pastCreate = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { Name: 'past', EventTime: pastDate } }],\n      });\n      const futureCreate = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { Name: 'future', EventTime: futureDate } }],\n      });\n\n      const pastRecord = await getRecord(tableId, pastCreate.records[0].id);\n      const futureRecord = await getRecord(tableId, futureCreate.records[0].id);\n\n      const pastDay = toNumber(pastRecord.fields?.[fromNowDayId as string]);\n      const pastHour = toNumber(pastRecord.fields?.[fromNowHourId as string]);\n      const pastSecond = toNumber(pastRecord.fields?.[fromNowSecondId as string]);\n      const pastToNow = toNumber(pastRecord.fields?.[toNowDayId as string]);\n\n      expect(pastDay).toBeGreaterThan(0);\n      expect(pastToNow).toBeGreaterThan(0);\n      expect(Math.abs(pastDay - pastToNow)).toBeLessThanOrEqual(1);\n\n      expect(pastHour + FLOAT_COMPARISON_TOLERANCE).toBeGreaterThanOrEqual(pastDay * 24);\n      expect(pastHour).toBeLessThan((pastDay + 1) * 24 + FLOAT_COMPARISON_TOLERANCE);\n      expect(pastSecond + FLOAT_COMPARISON_TOLERANCE).toBeGreaterThanOrEqual(pastHour * 3600);\n      expect(pastSecond).toBeLessThan((pastHour + 1) * 3600 + FLOAT_COMPARISON_TOLERANCE);\n\n      const futureToNow = toNumber(futureRecord.fields?.[toNowDayId as string]);\n      expect(futureToNow).toBeLessThan(0);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-int-search-link-regression.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport { DriverClient, FieldType, Relationship } from '@teable/core';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula INT(SEARCH(..)>0) on link fields regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  async function waitForFormulaValue(opts: {\n    tableId: string;\n    recordId: string;\n    fieldId: string;\n    expected: number;\n  }) {\n    const startedAt = Date.now();\n    // Formula computation is async; poll a little to avoid flaky assertions.\n    while (Date.now() - startedAt < 3000) {\n      const record = await getRecord(opts.tableId, opts.recordId);\n      if (record.fields?.[opts.fieldId] === opts.expected) {\n        return record;\n      }\n      await new Promise((r) => setTimeout(r, 100));\n    }\n\n    const record = await getRecord(opts.tableId, opts.recordId);\n    expect(record.fields?.[opts.fieldId]).toBe(opts.expected);\n    return record;\n  }\n\n  it.skipIf(globalThis.testConfig.driver !== DriverClient.Pg)(\n    'does not error with \"cannot cast type double precision to boolean\" during computed updates',\n    async () => {\n      let foreignTableId: string | undefined;\n      let mainTableId: string | undefined;\n\n      try {\n        const foreign = await createTable(baseId, {\n          name: 'formula-int-search-link-foreign',\n          fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n          records: [{ fields: { Title: '终止合同' } }, { fields: { Title: '持续合同' } }],\n        });\n        foreignTableId = foreign.id;\n\n        const main = await createTable(baseId, {\n          name: 'formula-int-search-link-main',\n          records: [],\n        });\n        mainTableId = main.id;\n\n        const link = await createField(main.id, {\n          name: 'Contract',\n          type: FieldType.Link,\n          options: { relationship: Relationship.ManyOne, foreignTableId: foreign.id },\n        } as IFieldRo);\n\n        const formula = await createField(main.id, {\n          name: 'HasTerminated',\n          type: FieldType.Formula,\n          options: {\n            expression: `INT(SEARCH('终止',{${link.id}})>0)`,\n          },\n        } as IFieldRo);\n\n        const created = await createRecords(main.id, {\n          records: [{ fields: { [link.id]: { id: foreign.records[0].id } } }],\n        });\n        const recordId = created.records[0].id;\n\n        await waitForFormulaValue({\n          tableId: main.id,\n          recordId,\n          fieldId: formula.id,\n          expected: 1,\n        });\n\n        await updateRecordByApi(main.id, recordId, link.id, { id: foreign.records[1].id });\n        await waitForFormulaValue({\n          tableId: main.id,\n          recordId,\n          fieldId: formula.id,\n          expected: 0,\n        });\n      } finally {\n        if (mainTableId) {\n          await permanentDeleteTable(baseId, mainTableId);\n        }\n        if (foreignTableId) {\n          await permanentDeleteTable(baseId, foreignTableId);\n        }\n      }\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-left-array-flatten.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Formula LEFT with ARRAY_FLATTEN parameters (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('returns the substring when earlier ARRAY_FLATTEN params are blank but later ones are populated', async () => {\n    let tableId: string | undefined;\n\n    try {\n      const table = await createTable(baseId, {\n        name: 'formula-left-array-flatten',\n        fields: [\n          { name: 'LeadingEmpty', type: FieldType.SingleLineText },\n          { name: 'TrailingValue', type: FieldType.SingleLineText },\n        ],\n      });\n      tableId = table.id;\n\n      const leadingField = table.fields.find((f) => f.name === 'LeadingEmpty')!;\n      const trailingField = table.fields.find((f) => f.name === 'TrailingValue')!;\n\n      const joined = await createField(tableId, {\n        name: 'Joined',\n        type: FieldType.Formula,\n        options: {\n          expression: `ARRAY_JOIN(ARRAY_FLATTEN({${leadingField.id}},{${trailingField.id}}), \".\")`,\n        },\n      });\n\n      const marker = await createField(tableId, {\n        name: 'Marker',\n        type: FieldType.Formula,\n        options: {\n          expression: `LEFT({${joined.id}}, 7)`,\n        },\n      });\n\n      const sample = 'ABCDEF123';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { TrailingValue: sample } }],\n      });\n\n      const recordId = records[0].id;\n\n      // Allow asynchronous formula computation to settle\n      await new Promise((resolve) => setTimeout(resolve, 200));\n\n      const record = await getRecord(tableId, recordId);\n      expect(record.fields[joined.id]).toBe(sample);\n      expect(record.fields[marker.id]).toBe(sample.slice(0, 7));\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-lookup-sum-regression.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\n/**\n * Regression: SUM over lookup-based multi-value fields should not emit malformed\n * numeric strings (e.g., \"3.7525002300010774+35\") when values contain scientific notation.\n * Prior to the numeric coercion fix, such inputs caused Postgres 22P02 errors during updates.\n */\ndescribe('Formula lookup SUM numeric coercion (regression)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const ctx = await initApp();\n    app = ctx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('safely sums lookup values containing scientific-notation strings during updates', async () => {\n    // Source table with text amounts (one contains scientific notation).\n    const invoiceTable = await createTable(baseId, {\n      name: 'sum_reg_invoices',\n      fields: [{ name: 'AmountText', type: FieldType.SingleLineText }],\n      records: [\n        { fields: { AmountText: '5250.00' } },\n        { fields: { AmountText: '4000.00' } },\n        { fields: { AmountText: '3.7525002300010774e+35' } }, // would previously coerce to invalid numeric\n      ],\n    });\n    const amountFieldId = invoiceTable.fields.find((f) => f.name === 'AmountText')!.id;\n\n    // Target table with link -> lookup -> formula SUM\n    const planTable = await createTable(baseId, {\n      name: 'sum_reg_plans',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      records: [{ fields: { Title: 'Plan A' } }],\n    });\n\n    const linkField = await createField(planTable.id, {\n      name: 'Invoices',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: invoiceTable.id,\n      },\n    });\n\n    const lookupField = await createField(planTable.id, {\n      name: 'InvoiceAmounts',\n      type: FieldType.SingleLineText, // lookup fields carry the base type and set isLookup\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: invoiceTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: amountFieldId,\n      },\n    });\n\n    const formulaField = await createField(planTable.id, {\n      name: 'Total',\n      type: FieldType.Formula,\n      options: {\n        expression: `SUM({${lookupField.id}})`,\n        formatting: { precision: 2, type: 'decimal' },\n      },\n    });\n\n    const planRecordId = planTable.records[0].id;\n\n    // Link all invoice records to the plan.\n    await updateRecordByApi(planTable.id, planRecordId, linkField.id, [\n      { id: invoiceTable.records[0].id },\n      { id: invoiceTable.records[1].id },\n      { id: invoiceTable.records[2].id },\n    ]);\n\n    // Trigger an additional update to simulate the PATCH scenario from the report.\n    await updateRecordByApi(planTable.id, planRecordId, planTable.fields[0].id, 'Plan A updated');\n\n    const updated = await getRecord(planTable.id, planRecordId);\n    const total = updated.fields?.[formulaField.id];\n\n    // The scientific-notation string is ignored (coerces to NULL -> 0), valid numbers are summed.\n    expect(total).toBe(9250);\n\n    await permanentDeleteTable(baseId, planTable.id);\n    await permanentDeleteTable(baseId, invoiceTable.id);\n  });\n\n  it('aggregates numeric multi-value lookups with SUM and AVERAGE', async () => {\n    const scores = [95, 88, 92];\n    const sourceTable = await createTable(baseId, {\n      name: 'sum_reg_scores',\n      fields: [\n        { name: 'Assignment', type: FieldType.SingleLineText },\n        { name: 'Score', type: FieldType.Number },\n      ],\n      records: scores.map((score, index) => ({\n        fields: { Assignment: `HW ${index + 1}`, Score: score },\n      })),\n    });\n    const scoreFieldId = sourceTable.fields.find((field) => field.name === 'Score')!.id;\n\n    const targetTable = await createTable(baseId, {\n      name: 'sum_reg_student',\n      fields: [{ name: 'Student', type: FieldType.SingleLineText }],\n      records: [{ fields: { Student: 'Alice' } }],\n    });\n\n    try {\n      const linkField = await createField(targetTable.id, {\n        name: 'Assignments',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: sourceTable.id,\n        },\n      });\n\n      const lookupField = await createField(targetTable.id, {\n        name: 'Scores Lookup',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: sourceTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: scoreFieldId,\n        },\n      });\n\n      const sumField = await createField(targetTable.id, {\n        name: 'Score Sum',\n        type: FieldType.Formula,\n        options: {\n          expression: `SUM({${lookupField.id}})`,\n        },\n      });\n\n      const avgField = await createField(targetTable.id, {\n        name: 'Score Avg',\n        type: FieldType.Formula,\n        options: {\n          expression: `AVERAGE({${lookupField.id}})`,\n        },\n      });\n\n      const maxField = await createField(targetTable.id, {\n        name: 'Score Max',\n        type: FieldType.Formula,\n        options: {\n          expression: `MAX({${lookupField.id}})`,\n        },\n      });\n\n      const minField = await createField(targetTable.id, {\n        name: 'Score Min',\n        type: FieldType.Formula,\n        options: {\n          expression: `MIN({${lookupField.id}})`,\n        },\n      });\n\n      const targetRecordId = targetTable.records[0].id;\n\n      await updateRecordByApi(\n        targetTable.id,\n        targetRecordId,\n        linkField.id,\n        sourceTable.records.map((record) => ({ id: record.id }))\n      );\n\n      const updated = await getRecord(targetTable.id, targetRecordId);\n      const fields = updated.fields ?? {};\n\n      const expectedSum = scores.reduce((acc, value) => acc + value, 0);\n      const expectedAvg = expectedSum / scores.length;\n      const expectedMax = Math.max(...scores);\n      const expectedMin = Math.min(...scores);\n\n      expect(fields[sumField.id]).toBeCloseTo(expectedSum, 6);\n      expect(fields[avgField.id]).toBeCloseTo(expectedAvg, 6);\n      expect(fields[maxField.id]).toBeCloseTo(expectedMax, 6);\n      expect(fields[minField.id]).toBeCloseTo(expectedMin, 6);\n    } finally {\n      await permanentDeleteTable(baseId, targetTable.id);\n      await permanentDeleteTable(baseId, sourceTable.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-meta.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\n/* eslint-disable no-useless-escape */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { duplicateField } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  deleteTable,\n  convertField,\n  initApp,\n  getRecords,\n  createRecords,\n} from './utils/init-app';\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nasync function waitForFormulaValue(\n  tableId: string,\n  fieldId: string,\n  expectedValue: number,\n  timeoutMs = 8000\n): Promise<void> {\n  const start = Date.now();\n  while (Date.now() - start < timeoutMs) {\n    const records = await getRecords(tableId, { fieldKeyType: FieldKeyType.Id });\n    const value = records.records?.[0]?.fields?.[fieldId];\n    if (value === expectedValue) {\n      return;\n    }\n    await sleep(200);\n  }\n  throw new Error(`Timed out waiting for formula value ${expectedValue}`);\n}\n\nasync function waitForFormulaText(\n  tableId: string,\n  fieldId: string,\n  expectedValue: string,\n  timeoutMs = 15000\n): Promise<void> {\n  const start = Date.now();\n  while (Date.now() - start < timeoutMs) {\n    const records = await getRecords(tableId, { fieldKeyType: FieldKeyType.Id });\n    const value = records.records?.[0]?.fields?.[fieldId];\n    if (value === expectedValue) {\n      return;\n    }\n    await sleep(200);\n  }\n  throw new Error(`Timed out waiting for formula value ${expectedValue}`);\n}\n\nconst parsePersistedMeta = (raw: unknown): { persistedAsGeneratedColumn?: boolean } | undefined => {\n  if (!raw) {\n    return undefined;\n  }\n  if (typeof raw === 'string') {\n    return JSON.parse(raw) as { persistedAsGeneratedColumn?: boolean };\n  }\n  if (typeof raw === 'object') {\n    return raw as { persistedAsGeneratedColumn?: boolean };\n  }\n  return undefined;\n};\n\ndescribe('Formula meta persistedAsGeneratedColumn (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    app = (await initApp()).app;\n    prisma = app.get(PrismaService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('create formula should avoid generated meta', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-create',\n        fields: [{ name: 'Number Field', type: FieldType.Number }],\n        records: [{ fields: { 'Number Field': 10 } }, { fields: { 'Number Field': 20 } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('does not persist generated-column meta for supported expression on create', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Generated Formula',\n        type: FieldType.Formula,\n        options: { expression: `{${numberFieldId}} * 2` },\n      });\n\n      const fieldRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n\n      const meta = parsePersistedMeta(fieldRaw.meta);\n      expect(meta?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('dateAdd should not be persisted as generated (immutability)', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-dateadd',\n        fields: [{ name: 'Start Date', type: FieldType.Date }],\n        records: [{ fields: { 'Start Date': '2024-01-10' } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('stores persistedAsGeneratedColumn=false for DATE_ADD formulas', async () => {\n      const startFieldId = table.fields.find((f) => f.name === 'Start Date')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Start Minus 7',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATE_ADD({${startFieldId}},-7,\\\"day\\\")`,\n          timeZone: 'Asia/Shanghai',\n          formatting: { date: 'YYYY-MM-DD', time: 'None', timeZone: 'Asia/Shanghai' },\n        },\n      });\n\n      const fieldRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n\n      const meta = parsePersistedMeta(fieldRaw.meta);\n      expect(meta?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('datetime concatenation should not use generated column', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-datetime-concat',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          {\n            name: 'Planned Time',\n            type: FieldType.Date,\n            options: {\n              formatting: { date: 'YYYY-MM-DD', time: 'HH:mm', timeZone: 'Asia/Shanghai' },\n            },\n          },\n        ],\n        records: [{ fields: { Title: 'Task', 'Planned Time': '2024-02-01 08:00' } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('marks CONCATENATE with datetime args as non-generated and duplicates safely', async () => {\n      const titleId = table.fields.find((f) => f.name === 'Title')!.id;\n      const plannedId = table.fields.find((f) => f.name === 'Planned Time')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Concat Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `CONCATENATE({${titleId}}, {${plannedId}})`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const createdRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(createdRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n\n      const duplicated = await duplicateField(table.id, created.id, { name: 'Concat Copy' });\n      const duplicatedRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: duplicated.data.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(duplicatedRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('user concat formulas avoid generated columns', () => {\n    let table: ITableFullVo;\n    const userId = globalThis.testConfig.userId;\n    const userName = globalThis.testConfig.userName;\n    const statusOption = { id: 'status-work', name: 'On Duty' };\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-user-concat',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          {\n            name: 'User',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          },\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: { choices: [statusOption] },\n          },\n        ],\n        records: [],\n      });\n\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields.find((f) => f.name === 'Title')!.id]: 'Row 1',\n              [table.fields.find((f) => f.name === 'User')!.id]: {\n                id: userId,\n                title: userName,\n              },\n              [table.fields.find((f) => f.name === 'Status')!.id]: statusOption,\n            },\n          },\n        ],\n        typecast: true,\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it.skip('creates and duplicates without generated-column meta', async () => {\n      const userFieldId = table.fields.find((f) => f.name === 'User')!.id;\n      const statusFieldId = table.fields.find((f) => f.name === 'Status')!.id;\n      const expression = `{${userFieldId}} & \"-\" & {${statusFieldId}}`;\n\n      const created = await createField(table.id, {\n        name: 'Title Formula',\n        type: FieldType.Formula,\n        options: { expression },\n      });\n\n      await waitForFormulaText(table.id, created.id, `${userName}-${statusOption.name}`);\n\n      const createdRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(createdRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n\n      const duplicated = await duplicateField(table.id, created.id, { name: 'Title Formula Copy' });\n      const duplicatedRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: duplicated.data.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(duplicatedRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n\n      await waitForFormulaText(table.id, duplicated.data.id, `${userName}-${statusOption.name}`);\n    });\n  });\n\n  describe('convert to formula should avoid generated meta', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-convert',\n        fields: [\n          { name: 'Text Field', type: FieldType.SingleLineText },\n          { name: 'Number Field', type: FieldType.Number },\n        ],\n        records: [\n          { fields: { 'Text Field': 'a', 'Number Field': 1 } },\n          { fields: { 'Text Field': 'b', 'Number Field': 2 } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('does not set generated-column meta when converting text->formula', async () => {\n      const textFieldId = table.fields.find((f) => f.name === 'Text Field')!.id;\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n\n      await convertField(table.id, textFieldId, {\n        type: FieldType.Formula,\n        options: { expression: `{${numberFieldId}} * 2` },\n      });\n\n      const fieldRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: textFieldId },\n        select: { meta: true },\n      });\n\n      const meta = parsePersistedMeta(fieldRaw.meta);\n      expect(meta?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('numeric generated formulas', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-numeric',\n        fields: [{ name: 'Remaining Minutes', type: FieldType.Number }],\n        records: [{ fields: { 'Remaining Minutes': 120 } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('supports creating and updating generated numeric formulas', async () => {\n      const minutesFieldId = table.fields.find((f) => f.name === 'Remaining Minutes')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Hours Remaining',\n        type: FieldType.Formula,\n        options: {\n          expression: `({${minutesFieldId}} * 45) / 60`,\n        },\n      });\n\n      expect(created.hasError).toBeFalsy();\n      await waitForFormulaValue(table.id, created.id, 90);\n\n      const createdRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      const createdMeta = parsePersistedMeta(createdRaw.meta);\n      expect(createdMeta?.persistedAsGeneratedColumn).not.toBe(true);\n\n      const updated = await convertField(table.id, created.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `({${minutesFieldId}} * 30) / 60`,\n        },\n      });\n\n      expect(updated.id).toBe(created.id);\n      expect(updated.hasError).toBeFalsy();\n      await waitForFormulaValue(table.id, created.id, 60);\n\n      const updatedRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      const updatedMeta = parsePersistedMeta(updatedRaw.meta);\n      expect(updatedMeta?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('generated formula duplication tolerates text that is not numeric', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-duplicate-text',\n        fields: [{ name: 'A', type: FieldType.SingleLineText }],\n        records: [{ fields: { A: '45629' } }, { fields: { A: '2024/12/03' } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    const waitForCopyValues = async (fieldId: string, timeoutMs = 15000) => {\n      const start = Date.now();\n      while (Date.now() - start < timeoutMs) {\n        const records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n        const recs = records.records ?? [];\n        if (recs.every((r) => r.fields && r.fields[fieldId] !== undefined)) {\n          return recs;\n        }\n        await sleep(200);\n      }\n      throw new Error('Timed out waiting for duplicated formula values');\n    };\n\n    it.skip('duplicates without throwing even when the base text cannot cast to numeric', async () => {\n      const aId = table.fields.find((f) => f.name === 'A')!.id;\n\n      const formula = await createField(table.id, {\n        name: 'Generated Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(INT({${aId}}), DATE_ADD(\"1990-01-01\", ROUND({${aId}}), \"day\"), {${aId}})`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const duplicateRes = await duplicateField(table.id, formula.id, { name: 'Generated Copy' });\n      const copyId = duplicateRes.data.id;\n\n      const records = await waitForCopyValues(copyId);\n      const originalValues = records.map((r) => r.fields?.[formula.id]);\n      const copyValues = records.map((r) => r.fields?.[copyId]);\n\n      expect(copyValues).toEqual(originalValues);\n      expect(copyValues[1]).toBe('2024/12/03');\n      expect(String(copyValues[0])).toMatch(/2114-12-0[56]/);\n    });\n  });\n\n  describe('formula metadata resets when expressions become unsupported', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula-meta-reset',\n        fields: [\n          { name: 'Number Field', type: FieldType.Number },\n          { name: 'Text Field', type: FieldType.SingleLineText },\n        ],\n        records: [{ fields: { 'Number Field': 5, 'Text Field': 'text' } }],\n      });\n    });\n\n    afterEach(async () => {\n      if (table?.id) {\n        await deleteTable(baseId, table.id);\n      }\n    });\n\n    it('clears persisted meta when converting generated formula to unsupported expression', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n      const textFieldId = table.fields.find((f) => f.name === 'Text Field')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Generated Numeric',\n        type: FieldType.Formula,\n        options: { expression: `{${numberFieldId}} * 2` },\n      });\n\n      const createdRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(createdRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n\n      await convertField(table.id, created.id, {\n        type: FieldType.Formula,\n        options: { expression: `AND({${numberFieldId}}, {${textFieldId}})` },\n      });\n\n      const updatedRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: created.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(updatedRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n      expect(updatedRaw.meta).toBeNull();\n    });\n\n    it('removes copied persisted meta for duplicated formulas after unsupported update', async () => {\n      const numberFieldId = table.fields.find((f) => f.name === 'Number Field')!.id;\n      const textFieldId = table.fields.find((f) => f.name === 'Text Field')!.id;\n\n      const created = await createField(table.id, {\n        name: 'Generated Base Formula',\n        type: FieldType.Formula,\n        options: { expression: `{${numberFieldId}} + 1` },\n      });\n\n      const duplicateRes = await duplicateField(table.id, created.id, { name: 'Generated Copy' });\n      const duplicatedField = duplicateRes.data;\n\n      const duplicateRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: duplicatedField.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(duplicateRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n\n      await convertField(table.id, duplicatedField.id, {\n        type: FieldType.Formula,\n        options: { expression: `AND({${numberFieldId}}, {${textFieldId}})` },\n      });\n\n      const postUpdateRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: duplicatedField.id },\n        select: { meta: true },\n      });\n      expect(parsePersistedMeta(postUpdateRaw.meta)?.persistedAsGeneratedColumn).not.toBe(true);\n      expect(postUpdateRaw.meta).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-metadata-coercion.e2e-spec.ts",
    "content": "/* eslint-disable regexp/no-super-linear-backtracking */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport {\n  FieldType,\n  FieldKeyType,\n  TableDomain,\n  TimeFormatting,\n  Relationship,\n  DbFieldType,\n} from '@teable/core';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { createFieldInstanceByVo } from '../src/features/field/model/factory';\nimport type { ISelectFormulaConversionContext } from '../src/features/record/query-builder/sql-conversion.visitor';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula metadata-aware coercion (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let dbProvider: IDbProvider;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    dbProvider = app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const parseSchemaAndTable = (dbTableName: string): [string, string] => {\n    const match = dbTableName.match(/^\"?(.*?)\"?\\.\"?(.*?)\"?$/);\n    if (match) {\n      return [match[1], match[2]];\n    }\n    const parts = dbTableName.split('.');\n    return [parts[0] ?? dbTableName, parts[1] ?? dbTableName];\n  };\n\n  describe('generated columns', () => {\n    it('avoids regex sanitizers for numeric operands', async () => {\n      const table: ITableFullVo = await createTable(baseId, {\n        name: 'formula_metadata_generated',\n        fields: [\n          {\n            name: 'Value',\n            type: FieldType.Number,\n          },\n        ],\n      });\n\n      try {\n        const valueField = table.fields.find((field) => field.name === 'Value') as IFieldVo;\n        const doubleField = (await createField(table.id, {\n          name: 'Double',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${valueField.id}} + {${valueField.id}}`,\n          },\n        })) as IFieldVo;\n\n        const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n          where: { id: table.id },\n          select: { dbTableName: true },\n        });\n        const [schema, rawTableName] = parseSchemaAndTable(tableMeta.dbTableName);\n        const rows = await prisma.$queryRaw<\n          { generation_expression: string }[]\n        >`SELECT generation_expression\n          FROM information_schema.columns\n          WHERE table_schema = ${schema}\n            AND table_name = ${rawTableName}\n            AND column_name = ${doubleField.dbFieldName}`;\n\n        expect(rows[0]?.generation_expression).toBeNull();\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n  });\n\n  describe('select query conversion', () => {\n    it('emits direct casts for numeric operands', async () => {\n      const seedFields: IFieldRo[] = [\n        { name: 'Revenue', type: FieldType.Number },\n        { name: 'Cost', type: FieldType.Number },\n      ];\n      const table: ITableFullVo = await createTable(baseId, {\n        name: 'formula_metadata_select',\n        fields: seedFields,\n      });\n\n      try {\n        const fieldMap = new Map(table.fields.map((field) => [field.name, field as IFieldVo]));\n        const revenueField = fieldMap.get('Revenue')!;\n        const costField = fieldMap.get('Cost')!;\n        const expression = `{${revenueField.id}} - {${costField.id}}`;\n\n        const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n          where: { id: table.id },\n          select: { dbTableName: true },\n        });\n\n        const tableDomain = new TableDomain({\n          id: table.id,\n          name: table.name,\n          dbTableName: tableMeta.dbTableName,\n          lastModifiedTime: table.lastModifiedTime ?? new Date().toISOString(),\n          fields: [revenueField, costField].map((field) => createFieldInstanceByVo(field)),\n        });\n\n        const tableAlias = 'main';\n        const selectionEntries = [revenueField, costField].map((field) => [\n          field.id,\n          `\"${tableAlias}\".\"${field.dbFieldName}\"`,\n        ]) as [string, string][];\n        const context: ISelectFormulaConversionContext = {\n          table: tableDomain,\n          selectionMap: new Map(selectionEntries),\n          tableAlias,\n          timeZone: 'UTC',\n          preferRawFieldReferences: true,\n        };\n\n        const sql = dbProvider.convertFormulaToSelectQuery(expression, context);\n        expect(sql).not.toContain('REGEXP_REPLACE');\n        expect(sql).toContain('::double precision');\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('emits boolean shortcuts for checkbox IF conditions', async () => {\n      const seedFields: IFieldRo[] = [\n        { name: 'Name', type: FieldType.SingleLineText },\n        { name: 'Enabled', type: FieldType.Checkbox },\n      ];\n      const table: ITableFullVo = await createTable(baseId, {\n        name: 'formula_metadata_boolean_select',\n        fields: seedFields,\n      });\n\n      try {\n        const flagField = table.fields.find((field) => field.name === 'Enabled') as IFieldVo;\n        const expression = `IF({${flagField.id}}, 'on', 'off')`;\n\n        const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n          where: { id: table.id },\n          select: { dbTableName: true },\n        });\n\n        const tableDomain = new TableDomain({\n          id: table.id,\n          name: table.name,\n          dbTableName: tableMeta.dbTableName,\n          lastModifiedTime: table.lastModifiedTime ?? new Date().toISOString(),\n          fields: [flagField].map((field) => createFieldInstanceByVo(field)),\n        });\n\n        const tableAlias = 'main';\n        const selectionEntries = [[flagField.id, `\"${tableAlias}\".\"${flagField.dbFieldName}\"`]] as [\n          string,\n          string,\n        ][];\n        const context: ISelectFormulaConversionContext = {\n          table: tableDomain,\n          selectionMap: new Map(selectionEntries),\n          tableAlias,\n          timeZone: 'UTC',\n          preferRawFieldReferences: true,\n        };\n\n        const sql = dbProvider.convertFormulaToSelectQuery(expression, context);\n        expect(sql).toContain(`COALESCE((\"${tableAlias}\".\"${flagField.dbFieldName}\"), FALSE)`);\n        expect(sql).not.toContain('pg_typeof');\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('emits numeric shortcuts for IF conditions referencing number fields', async () => {\n      const seedFields: IFieldRo[] = [\n        { name: 'Name', type: FieldType.SingleLineText },\n        { name: 'Quantity', type: FieldType.Number },\n      ];\n      const table: ITableFullVo = await createTable(baseId, {\n        name: 'formula_metadata_numeric_if_select',\n        fields: seedFields,\n      });\n\n      try {\n        const qtyField = table.fields.find((field) => field.name === 'Quantity') as IFieldVo;\n        const expression = `IF({${qtyField.id}}, 'in stock', 'out')`;\n\n        const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n          where: { id: table.id },\n          select: { dbTableName: true },\n        });\n\n        const tableDomain = new TableDomain({\n          id: table.id,\n          name: table.name,\n          dbTableName: tableMeta.dbTableName,\n          lastModifiedTime: table.lastModifiedTime ?? new Date().toISOString(),\n          fields: [qtyField].map((field) => createFieldInstanceByVo(field)),\n        });\n\n        const tableAlias = 'main';\n        const selectionEntries = [[qtyField.id, `\"${tableAlias}\".\"${qtyField.dbFieldName}\"`]] as [\n          string,\n          string,\n        ][];\n        const context: ISelectFormulaConversionContext = {\n          table: tableDomain,\n          selectionMap: new Map(selectionEntries),\n          tableAlias,\n          timeZone: 'UTC',\n          preferRawFieldReferences: true,\n        };\n\n        const sql = dbProvider.convertFormulaToSelectQuery(expression, context);\n        expect(sql).toContain(\n          `COALESCE((\"${tableAlias}\".\"${qtyField.dbFieldName}\")::double precision, 0)`\n        );\n        expect(sql).not.toContain('REGEXP_REPLACE');\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('does not wrap scalar lookup/rollup references in multi-value guards', () => {\n      const tableAlias = 'main';\n\n      const linkField = createFieldInstanceByVo({\n        id: 'fldLink',\n        name: 'Vehicle',\n        type: FieldType.Link,\n        dbFieldName: 'Vehicles',\n        dbFieldType: DbFieldType.Json,\n        isMultipleCellValue: false,\n        options: { relationship: Relationship.ManyOne },\n      } as unknown as IFieldVo);\n\n      const intervalField = createFieldInstanceByVo({\n        id: 'fldInterval',\n        name: 'Interval (Hrs)',\n        type: FieldType.Number,\n        cellValueType: 'number',\n        dbFieldName: 'Interval_Hrs',\n        dbFieldType: DbFieldType.Real,\n      } as unknown as IFieldVo);\n\n      const lookupRollupField = createFieldInstanceByVo({\n        id: 'fldRoll',\n        name: 'Current Hrs',\n        type: FieldType.Rollup,\n        cellValueType: 'number',\n        dbFieldName: `lookup_fldRoll`,\n        dbFieldType: DbFieldType.Real,\n        isLookup: true,\n        isMultipleCellValue: false,\n        lookupOptions: {\n          linkFieldId: linkField.id,\n          lookupFieldId: 'fldSrc',\n          relationship: Relationship.ManyOne,\n        },\n        options: { expression: 'max({values})' },\n      } as unknown as IFieldVo);\n\n      const tableDomain = new TableDomain({\n        id: 'tblMetaLookup',\n        name: 'meta_lookup_scalar',\n        dbTableName: '\"public\".\"meta_lookup_scalar\"',\n        lastModifiedTime: new Date().toISOString(),\n        fields: [intervalField, lookupRollupField, linkField],\n      });\n\n      const selectionEntries: [string, string][] = [\n        [intervalField.id, `\"${tableAlias}\".\"${intervalField.dbFieldName}\"`],\n        [lookupRollupField.id, `\"${tableAlias}\".\"${lookupRollupField.dbFieldName}\"`],\n      ];\n\n      const context: ISelectFormulaConversionContext = {\n        table: tableDomain,\n        selectionMap: new Map(selectionEntries),\n        tableAlias,\n        timeZone: 'UTC',\n        preferRawFieldReferences: true,\n      };\n\n      const expression = `IF({${intervalField.id}} > 0, {${intervalField.id}} + {${lookupRollupField.id}}, 0)`;\n      const sql = dbProvider.convertFormulaToSelectQuery(expression, context);\n\n      expect(sql).not.toContain('pg_typeof');\n      expect(sql).not.toContain('jsonb_build_array');\n      expect(sql).toContain(`\"${tableAlias}\".\"${lookupRollupField.dbFieldName}\"`);\n      expect(sql).toContain('::double precision');\n    });\n\n    it('treats BLANK() as NULL for select queries with mixed branch types', async () => {\n      const seedFields: IFieldRo[] = [\n        { name: 'Title', type: FieldType.SingleLineText },\n        { name: 'Amount', type: FieldType.Number },\n        {\n          name: 'Due Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: 'YYYY-MM-DD',\n              time: TimeFormatting.Hour24,\n              timeZone: 'UTC',\n            },\n          },\n        },\n      ];\n\n      const table: ITableFullVo = await createTable(baseId, {\n        name: 'formula_metadata_blank_select',\n        fields: seedFields,\n      });\n\n      try {\n        const fieldMap = new Map<string, IFieldVo>(\n          table.fields.map((field) => [field.name, field as IFieldVo])\n        );\n        const titleField = fieldMap.get('Title')!;\n        const amountField = fieldMap.get('Amount')!;\n        const dueField = fieldMap.get('Due Date')!;\n\n        const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n          where: { id: table.id },\n          select: { dbTableName: true },\n        });\n\n        const tableDomain = new TableDomain({\n          id: table.id,\n          name: table.name,\n          dbTableName: tableMeta.dbTableName,\n          lastModifiedTime: table.lastModifiedTime ?? new Date().toISOString(),\n          fields: [titleField, amountField, dueField].map((field) =>\n            createFieldInstanceByVo(field)\n          ),\n        });\n\n        const tableAlias = 'main';\n        const selectionEntries = [titleField, amountField, dueField].map((field) => [\n          field.id,\n          `\"${tableAlias}\".\"${field.dbFieldName}\"`,\n        ]) as [string, string][];\n\n        const context: ISelectFormulaConversionContext = {\n          table: tableDomain,\n          selectionMap: new Map(selectionEntries),\n          tableAlias,\n          timeZone: 'UTC',\n          preferRawFieldReferences: true,\n        };\n\n        const blankSql = dbProvider.convertFormulaToSelectQuery('BLANK()', context) as string;\n        expect(blankSql.trim()).toBe('NULL');\n\n        const branchAssertions: Array<{ expression: string; expectedBranch: string }> = [\n          {\n            expression: `IF(TRUE, BLANK(), {${titleField.id}})`,\n            expectedBranch: `\"${tableAlias}\".\"${titleField.dbFieldName}\"`,\n          },\n          {\n            expression: `IF(TRUE, BLANK(), {${amountField.id}})`,\n            expectedBranch: `\"${tableAlias}\".\"${amountField.dbFieldName}\"`,\n          },\n          {\n            expression: `IF(TRUE, BLANK(), {${dueField.id}})`,\n            expectedBranch: `\"${tableAlias}\".\"${dueField.dbFieldName}\"`,\n          },\n        ];\n\n        for (const { expression, expectedBranch } of branchAssertions) {\n          const sql = dbProvider.convertFormulaToSelectQuery(expression, context);\n          expect(sql).toMatch(/THEN\\s+\\(?NULL/i);\n          expect(sql).not.toMatch(/THEN\\s+''/i);\n          expect(sql).toContain(expectedBranch);\n        }\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n  });\n\n  describe('runtime formulas', () => {\n    it('concatenates typed fields without redundant casts', async () => {\n      const table = await createTable(baseId, {\n        name: 'formula_metadata_concat',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText },\n          { name: 'Qty', type: FieldType.Number },\n        ],\n        records: [\n          {\n            fields: {\n              Label: 'Widget',\n              Qty: 3,\n            },\n          },\n        ],\n      });\n\n      try {\n        const fieldMap = new Map<string, IFieldVo>(\n          table.fields.map((field) => [field.name, field])\n        );\n        const labelField = fieldMap.get('Label')!;\n        const qtyField = fieldMap.get('Qty')!;\n\n        const concatField = (await createField(table.id, {\n          name: 'Label Qty',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${labelField.id}} & ' x ' & {${qtyField.id}} & '!'`,\n          },\n        })) as IFieldVo;\n\n        const recordId = table.records[0].id;\n        const readValue = async () => {\n          const record = await getRecord(table.id, recordId);\n          return record.fields?.[concatField.id];\n        };\n\n        expect(await readValue()).toBe('Widget x 3!');\n\n        await updateRecordByApi(table.id, recordId, labelField.id, 'Gadget');\n        await updateRecordByApi(table.id, recordId, qtyField.id, 1);\n        expect(await readValue()).toBe('Gadget x 1!');\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('evaluates AND conditions using typed operands', async () => {\n      const table = await createTable(baseId, {\n        name: 'formula_metadata_logic',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          { name: 'Enabled', type: FieldType.Checkbox },\n          { name: 'Attempts', type: FieldType.Number },\n        ],\n      });\n\n      try {\n        const fieldMap = new Map<string, IFieldVo>(\n          table.fields.map((field) => [field.name, field])\n        );\n        const enabledField = fieldMap.get('Enabled')!;\n        const attemptsField = fieldMap.get('Attempts')!;\n\n        const logicField = (await createField(table.id, {\n          name: 'Should Trigger',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF(AND({${enabledField.id}}, {${attemptsField.id}}), 1, 0)`,\n          },\n        })) as IFieldVo;\n\n        const { records } = await createRecords(table.id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                Title: 'Row 1',\n                Enabled: true,\n                Attempts: 0,\n              },\n            },\n          ],\n        });\n\n        const recordId = records[0].id;\n        const readValue = async () => {\n          const record = await getRecord(table.id, recordId);\n          return record.fields?.[logicField.id];\n        };\n\n        expect(await readValue()).toBe(0);\n\n        await updateRecordByApi(table.id, recordId, attemptsField.id, 2);\n        expect(await readValue()).toBe(1);\n\n        await updateRecordByApi(table.id, recordId, enabledField.id, false);\n        expect(await readValue()).toBe(0);\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('keeps BLANK as null in standalone formulas and IF branches across types', async () => {\n      const dueDateValue = '2025-02-02T00:00:00.000Z';\n      const table = await createTable(baseId, {\n        name: 'formula_blank_runtime',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          {\n            name: 'Due',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: 'YYYY-MM-DD',\n                time: TimeFormatting.Hour24,\n                timeZone: 'UTC',\n              },\n            },\n          } as IFieldRo,\n        ],\n      });\n\n      try {\n        const titleField = table.fields.find((field) => field.name === 'Title')!;\n        const amountField = table.fields.find((field) => field.name === 'Amount')!;\n        const dueField = table.fields.find((field) => field.name === 'Due')!;\n\n        const blankField = (await createField(table.id, {\n          name: 'Standalone Blank',\n          type: FieldType.Formula,\n          options: { expression: 'BLANK()' },\n        })) as IFieldVo;\n\n        const dateWhenTrue = (await createField(table.id, {\n          name: 'Date When True',\n          type: FieldType.Formula,\n          options: { expression: `IF(TRUE, {${dueField.id}}, BLANK())` },\n        })) as IFieldVo;\n\n        const dateWhenFalse = (await createField(table.id, {\n          name: 'Blank When False',\n          type: FieldType.Formula,\n          options: { expression: `IF(FALSE, {${dueField.id}}, BLANK())` },\n        })) as IFieldVo;\n\n        const numberWhenTrue = (await createField(table.id, {\n          name: 'Number When True',\n          type: FieldType.Formula,\n          options: { expression: `IF(TRUE, {${amountField.id}}, BLANK())` },\n        })) as IFieldVo;\n\n        const numberWhenFalse = (await createField(table.id, {\n          name: 'Blank When False Number',\n          type: FieldType.Formula,\n          options: { expression: `IF(FALSE, {${amountField.id}}, BLANK())` },\n        })) as IFieldVo;\n\n        const { records } = await createRecords(table.id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [titleField.name]: 'Row 1',\n                [amountField.name]: 12,\n                [dueField.name]: dueDateValue,\n              },\n            },\n          ],\n        });\n\n        const recordId = records[0].id;\n\n        const readValue = async (fieldId: string) => {\n          const record = await getRecord(table.id, recordId);\n          return record.fields?.[fieldId] ?? null;\n        };\n\n        expect(await readValue(blankField.id)).toBeNull();\n        expect(await readValue(dateWhenTrue.id)).toBe(dueDateValue);\n        expect(await readValue(dateWhenFalse.id)).toBeNull();\n        expect(await readValue(numberWhenTrue.id)).toBe(12);\n        expect(await readValue(numberWhenFalse.id)).toBeNull();\n      } finally {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-numeric-blank-regression.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, generateFieldId } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { duplicateField } from '@teable/openapi';\nimport { createTable, getRecords, initApp, permanentDeleteTable } from './utils/init-app';\n\n/**\n * Regression: duplicating a formula that compares a numeric field to '' should not\n * produce 22P02 (invalid input syntax for type double precision).\n */\ndescribe('Formula numeric blank comparison duplication (regression)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId as string;\n\n  beforeAll(async () => {\n    const ctx = await initApp();\n    app = ctx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('duplicates formula comparing number field with empty string without errors', async () => {\n    const percentFieldId = generateFieldId();\n    const table = (await createTable(baseId, {\n      name: 'numeric_blank_dup',\n      fields: [\n        {\n          id: percentFieldId,\n          name: 'Percent',\n          type: FieldType.Number,\n        },\n        {\n          name: 'PercentColor',\n          type: FieldType.Formula,\n          options: {\n            // Use field id in expression to avoid name-resolution failures.\n            expression: `IF({${percentFieldId}}=\"\", \"empty\", \"filled\")`,\n          },\n        },\n      ],\n      records: [\n        { fields: {} }, // Percent is null\n        { fields: { Percent: 0.2 } },\n      ],\n    })) as ITableFullVo;\n\n    try {\n      const formulaFieldId = table.fields.find((f) => f.name === 'PercentColor')?.id as string;\n\n      const duplicated = await duplicateField(table.id, formulaFieldId, {\n        name: 'PercentColor Copy',\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n\n      const first = records[0];\n      const second = records[1];\n\n      expect(first.fields[formulaFieldId]).toBe('empty');\n      expect(first.fields[duplicated.data.id]).toBe('empty');\n      expect(second.fields[formulaFieldId]).toBe('filled');\n      expect(second.fields[duplicated.data.id]).toBe('filled');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('duplicates IF with blank fallback comparing number field with empty string without errors', async () => {\n    const percentFieldId = generateFieldId();\n    const table = (await createTable(baseId, {\n      name: 'numeric_blank_dup_two_arg',\n      fields: [\n        {\n          id: percentFieldId,\n          name: 'Percent',\n          type: FieldType.Number,\n        },\n        {\n          name: 'PercentColor',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${percentFieldId}}=\"\", \"empty\", BLANK())`,\n          },\n        },\n      ],\n      records: [\n        { fields: {} }, // Percent is null\n        { fields: { Percent: 0.2 } },\n      ],\n    })) as ITableFullVo;\n\n    try {\n      const formulaFieldId = table.fields.find((f) => f.name === 'PercentColor')?.id as string;\n\n      const duplicated = await duplicateField(table.id, formulaFieldId, {\n        name: 'PercentColor Copy 2',\n      });\n\n      const { records } = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n\n      const first = records[0];\n      const second = records[1];\n\n      expect(first.fields[formulaFieldId]).toBe('empty');\n      expect(first.fields[duplicated.data.id]).toBe('empty');\n      expect(second.fields[formulaFieldId] ?? null).toBeNull();\n      expect(second.fields[duplicated.data.id] ?? null).toBeNull();\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-single-select-regression.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { duplicateField } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  createRecords,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula single select string comparison regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('duplicate formulas comparing single select + text', () => {\n    let table: ITableFullVo;\n    let prevField: IFieldVo;\n    let availabilityField: IFieldVo;\n    let primaryFormula: IFieldVo;\n    let copyFormula: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula_select_copy_regression',\n        fields: [\n          {\n            name: 'Prev Status',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Availability',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'In Stock', color: 'grayBright' },\n                { name: 'Not Available', color: 'pink' },\n                { name: 'Low Stock', color: 'yellowLight1' },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Prev Status': 'In Stock',\n              Availability: 'Not Available',\n            },\n          },\n          {\n            fields: {\n              'Prev Status': 'In Stock',\n              Availability: 'In Stock',\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((f) => [f.name, f]));\n      prevField = fieldMap.get('Prev Status')!;\n      availabilityField = fieldMap.get('Availability')!;\n\n      const expression = `IF(AND({${prevField.id}} != \"Not Available\", {${availabilityField.id}} = \"Not Available\"), \"yes\", BLANK())`;\n\n      primaryFormula = await createField(table.id, {\n        name: 'some field',\n        type: FieldType.Formula,\n        options: { expression },\n      });\n\n      copyFormula = (\n        await duplicateField(table.id, primaryFormula.id, {\n          name: 'some field copy',\n        })\n      ).data;\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('evaluates identical formulas the same when comparing select titles', async () => {\n      const discontinuedRecord = await getRecord(table.id, table.records[0].id);\n      expect(discontinuedRecord.fields[primaryFormula.id]).toBe('yes');\n      expect(discontinuedRecord.fields[copyFormula.id]).toBe('yes');\n\n      const stockedRecord = await getRecord(table.id, table.records[1].id);\n      expect(stockedRecord.fields[primaryFormula.id]).toBeUndefined();\n      expect(stockedRecord.fields[copyFormula.id]).toBeUndefined();\n\n      await updateRecordByApi(table.id, table.records[1].id, availabilityField.id, 'Not Available');\n\n      const afterUpdate = await getRecord(table.id, table.records[1].id);\n      expect(afterUpdate.fields[primaryFormula.id]).toBe('yes');\n      expect(afterUpdate.fields[copyFormula.id]).toBe('yes');\n    });\n  });\n\n  describe('text != literal with null title value', () => {\n    let table: ITableFullVo;\n    let titleField: IFieldVo;\n    let branchField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'formula_text_not_equal_blank',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n\n      titleField = table.fields.find((f) => f.name === 'Title')!;\n\n      branchField = await createField(table.id, {\n        name: 'branch',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${titleField.id}} != \"hello\", \"world\", \"this\")`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('treats null text as blank when evaluating !=', async () => {\n      const { records } = await createRecords(table.id, {\n        records: [{ fields: {} }],\n      });\n\n      const created = await getRecord(table.id, records[0].id);\n      expect(created.fields[branchField.id]).toBe('world');\n\n      await updateRecordByApi(table.id, records[0].id, titleField.id, 'hello');\n      const helloRecord = await getRecord(table.id, records[0].id);\n      expect(helloRecord.fields[branchField.id]).toBe('this');\n\n      await updateRecordByApi(table.id, records[0].id, titleField.id, null);\n      const clearedRecord = await getRecord(table.id, records[0].id);\n      expect(clearedRecord.fields[branchField.id]).toBe('world');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula-timezone-convert.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  convertField,\n} from './utils/init-app';\n\ndescribe('Formula field timezone modification (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('should preserve formula values when changing timezone option', async () => {\n    let tableId: string | undefined;\n\n    try {\n      // Create table with a date field\n      const table = await createTable(baseId, {\n        name: 'formula-timezone-convert-test',\n      });\n      tableId = table.id;\n\n      // Create a date field\n      const dateField = await createField(tableId, {\n        name: 'event_date',\n        type: FieldType.Date,\n      });\n\n      // Create a formula field that formats the date\n      const formulaField = await createField(tableId, {\n        name: 'formatted_date',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_FORMAT({${dateField.id}}, 'YYYY-MM-DD HH:mm:ss')`,\n          timeZone: 'UTC',\n        },\n      });\n\n      // Create a record with a date value\n      const input = '2024-12-03T09:07:11.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_date: input } }],\n      });\n\n      // Verify the formula field has a value\n      const recordBefore = await getRecord(tableId, records[0].id);\n      const valueBefore = recordBefore.fields?.[formulaField.id];\n      expect(valueBefore).toBe('2024-12-03 09:07:11');\n\n      // Change the timezone option\n      await convertField(tableId, formulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_FORMAT({${dateField.id}}, 'YYYY-MM-DD HH:mm:ss')`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      // Verify the formula field still has a value (not cleared)\n      // The value should change due to timezone conversion (+8 hours)\n      const recordAfter = await getRecord(tableId, records[0].id);\n      const valueAfter = recordAfter.fields?.[formulaField.id];\n      // Asia/Shanghai is UTC+8, so 09:07:11 UTC becomes 17:07:11 Shanghai time\n      expect(valueAfter).toBe('2024-12-03 17:07:11');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('should preserve formula values when changing formatting option', async () => {\n    let tableId: string | undefined;\n\n    try {\n      // Create table with a number field\n      const table = await createTable(baseId, {\n        name: 'formula-formatting-convert-test',\n      });\n      tableId = table.id;\n\n      // Create a number field\n      const numberField = await createField(tableId, {\n        name: 'amount',\n        type: FieldType.Number,\n      });\n\n      // Create a formula field\n      const formulaField = await createField(tableId, {\n        name: 'doubled_amount',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${numberField.id}} * 2`,\n        },\n      });\n\n      // Create a record with a number value\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { amount: 100 } }],\n      });\n\n      // Verify the formula field has a value\n      const recordBefore = await getRecord(tableId, records[0].id);\n      const valueBefore = recordBefore.fields?.[formulaField.id];\n      expect(valueBefore).toBe(200);\n\n      // Change the formatting option\n      await convertField(tableId, formulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${numberField.id}} * 2`,\n          formatting: {\n            type: 'decimal',\n            precision: 2,\n          },\n        },\n      });\n\n      // Verify the formula field still has its value\n      const recordAfter = await getRecord(tableId, records[0].id);\n      const valueAfter = recordAfter.fields?.[formulaField.id];\n      expect(valueAfter).toBe(200);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('should preserve formula values when formula directly references date field and timezone changes', async () => {\n    let tableId: string | undefined;\n\n    try {\n      // Create table with a date field\n      const table = await createTable(baseId, {\n        name: 'formula-direct-date-ref-test',\n      });\n      tableId = table.id;\n\n      // Create a date field\n      const dateField = await createField(tableId, {\n        name: 'event_date',\n        type: FieldType.Date,\n      });\n\n      // Create a formula field that directly references the date (returns DateTime cellValueType)\n      const formulaField = await createField(tableId, {\n        name: 'date_ref',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${dateField.id}}`,\n          timeZone: 'UTC',\n        },\n      });\n\n      // Create a record with a date value\n      const input = '2024-12-03T09:07:11.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_date: input } }],\n      });\n\n      // Verify the formula field has a value\n      const recordBefore = await getRecord(tableId, records[0].id);\n      const valueBefore = recordBefore.fields?.[formulaField.id];\n      expect(valueBefore).toBe(input);\n\n      // Change the timezone option\n      await convertField(tableId, formulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${dateField.id}}`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      // Verify the formula field still has its value (should NOT be cleared)\n      const recordAfter = await getRecord(tableId, records[0].id);\n      const valueAfter = recordAfter.fields?.[formulaField.id];\n      // The underlying DateTime value should remain the same ISO string\n      expect(valueAfter).toBe(input);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('should preserve formula values when only timezone changes (no other option change)', async () => {\n    let tableId: string | undefined;\n\n    try {\n      // Create table with a date field\n      const table = await createTable(baseId, {\n        name: 'formula-only-timezone-change-test',\n      });\n      tableId = table.id;\n\n      // Create a date field\n      const dateField = await createField(tableId, {\n        name: 'event_date',\n        type: FieldType.Date,\n      });\n\n      // Create a formula field using YEAR function (affected by timezone)\n      const formulaField = await createField(tableId, {\n        name: 'event_year',\n        type: FieldType.Formula,\n        options: {\n          expression: `YEAR({${dateField.id}})`,\n          timeZone: 'UTC',\n        },\n      });\n\n      // Create a record with a date value\n      const input = '2024-12-31T23:00:00.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_date: input } }],\n      });\n\n      // Verify the formula field has a value (year 2024 in UTC)\n      const recordBefore = await getRecord(tableId, records[0].id);\n      const valueBefore = recordBefore.fields?.[formulaField.id];\n      expect(valueBefore).toBe(2024);\n\n      // Change the timezone to Asia/Shanghai (UTC+8)\n      await convertField(tableId, formulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `YEAR({${dateField.id}})`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      // Verify the formula field still has a value (should NOT be null/undefined)\n      // In Asia/Shanghai, 2024-12-31T23:00:00.000Z is 2025-01-01 07:00:00\n      const recordAfter = await getRecord(tableId, records[0].id);\n      const valueAfter = recordAfter.fields?.[formulaField.id];\n      expect(valueAfter).toBe(2025);\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n\n  it('should preserve formula values when partial options are sent (only timeZone without expression)', async () => {\n    let tableId: string | undefined;\n\n    try {\n      // Create table with a date field\n      const table = await createTable(baseId, {\n        name: 'formula-partial-options-test',\n      });\n      tableId = table.id;\n\n      // Create a date field\n      const dateField = await createField(tableId, {\n        name: 'event_date',\n        type: FieldType.Date,\n      });\n\n      // Create a formula field using DATETIME_FORMAT function\n      const formulaField = await createField(tableId, {\n        name: 'formatted_date',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_FORMAT({${dateField.id}}, 'YYYY-MM-DD HH:mm:ss')`,\n          timeZone: 'UTC',\n        },\n      });\n\n      // Create a record with a date value\n      const input = '2024-06-15T14:30:00.000Z';\n      const { records } = await createRecords(tableId, {\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n        records: [{ fields: { event_date: input } }],\n      });\n\n      // Verify the formula field has a value\n      const recordBefore = await getRecord(tableId, records[0].id);\n      const valueBefore = recordBefore.fields?.[formulaField.id];\n      expect(valueBefore).toBe('2024-06-15 14:30:00');\n\n      // Simulate sending only timeZone option without expression\n      // This mimics what the UI does when only changing the timezone\n      await convertField(tableId, formulaField.id, {\n        type: FieldType.Formula,\n        // @ts-expect-error - this is a test\n        options: {\n          timeZone: 'America/New_York', // Only send timeZone, no expression\n        },\n      });\n\n      // Verify the formula field still has a value (should NOT be null/undefined)\n      // America/New_York is UTC-4 in June (EDT), so 14:30:00 UTC becomes 10:30:00 EDT\n      const recordAfter = await getRecord(tableId, records[0].id);\n      const valueAfter = recordAfter.fields?.[formulaField.id];\n      expect(valueAfter).toBe('2024-06-15 10:30:00');\n    } finally {\n      if (tableId) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/formula.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFilter,\n  ILinkFieldOptionsRo,\n  ILookupOptionsRo,\n  ISelectFieldOptionsRo,\n} from '@teable/core';\nimport {\n  DateFormattingPreset,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  FunctionName,\n  generateFieldId,\n  NumberFormattingType,\n  Relationship,\n  TimeFormatting,\n} from '@teable/core';\nimport { getRecord, updateRecords, type ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createFields,\n  createRecords,\n  createTable,\n  permanentDeleteTable,\n  getRecords,\n  getField,\n  initApp,\n  updateRecord,\n  updateRecordByApi,\n  convertField,\n} from './utils/init-app';\n\ndescribe('OpenAPI formula (e2e)', () => {\n  let app: INestApplication;\n  let table1Id = '';\n  let table1: ITableFullVo;\n  let numberFieldRo: IFieldRo & { id: string; name: string };\n  let textFieldRo: IFieldRo & { id: string; name: string };\n  let formulaFieldRo: IFieldRo & { id: string; name: string };\n  let userFieldRo: IFieldRo & { id: string; name: string };\n  let multiSelectFieldRo: IFieldRo & { id: string; name: string };\n  const baseId = globalThis.testConfig.baseId;\n  const baseDate = new Date(Date.UTC(2025, 0, 3, 0, 0, 0, 0));\n  const dateAddMultiplier = 7;\n  const numberFieldSeedValue = 2;\n  const datetimeDiffStartIso = '2025-01-01T00:00:00.000Z';\n  const datetimeDiffEndIso = '2025-01-08T03:04:05.006Z';\n  const datetimeDiffStart = new Date(datetimeDiffStartIso);\n  const datetimeDiffEnd = new Date(datetimeDiffEndIso);\n  const diffMilliseconds = datetimeDiffEnd.getTime() - datetimeDiffStart.getTime();\n  const diffSeconds = diffMilliseconds / 1000;\n  const diffMinutes = diffSeconds / 60;\n  const diffHours = diffMinutes / 60;\n  const diffDays = diffHours / 24;\n  const diffWeeks = diffDays / 7;\n  const useV2BatchCreate = process.env.FORCE_V2_ALL === 'true' || process.env.FORCE_V2_ALL === '1';\n  type DateAddNormalizedUnit =\n    | 'millisecond'\n    | 'second'\n    | 'minute'\n    | 'hour'\n    | 'day'\n    | 'week'\n    | 'month'\n    | 'quarter'\n    | 'year';\n  const dateAddCases: Array<{ literal: string; normalized: DateAddNormalizedUnit }> = [\n    { literal: 'day', normalized: 'day' },\n    { literal: 'days', normalized: 'day' },\n    { literal: 'week', normalized: 'week' },\n    { literal: 'weeks', normalized: 'week' },\n    { literal: 'month', normalized: 'month' },\n    { literal: 'months', normalized: 'month' },\n    { literal: 'quarter', normalized: 'quarter' },\n    { literal: 'quarters', normalized: 'quarter' },\n    { literal: 'year', normalized: 'year' },\n    { literal: 'years', normalized: 'year' },\n    { literal: 'hour', normalized: 'hour' },\n    { literal: 'hours', normalized: 'hour' },\n    { literal: 'minute', normalized: 'minute' },\n    { literal: 'minutes', normalized: 'minute' },\n    { literal: 'second', normalized: 'second' },\n    { literal: 'seconds', normalized: 'second' },\n    { literal: 'millisecond', normalized: 'millisecond' },\n    { literal: 'milliseconds', normalized: 'millisecond' },\n    { literal: 'ms', normalized: 'millisecond' },\n    { literal: 'sec', normalized: 'second' },\n    { literal: 'secs', normalized: 'second' },\n    { literal: 'min', normalized: 'minute' },\n    { literal: 'mins', normalized: 'minute' },\n    { literal: 'hr', normalized: 'hour' },\n    { literal: 'hrs', normalized: 'hour' },\n  ];\n  const datetimeDiffCases: Array<{ literal: string; expected: number }> = [\n    { literal: 'millisecond', expected: diffMilliseconds },\n    { literal: 'milliseconds', expected: diffMilliseconds },\n    { literal: 'ms', expected: diffMilliseconds },\n    { literal: 's', expected: diffSeconds },\n    { literal: 'second', expected: diffSeconds },\n    { literal: 'seconds', expected: diffSeconds },\n    { literal: 'sec', expected: diffSeconds },\n    { literal: 'secs', expected: diffSeconds },\n    { literal: 'minute', expected: diffMinutes },\n    { literal: 'minutes', expected: diffMinutes },\n    { literal: 'min', expected: diffMinutes },\n    { literal: 'mins', expected: diffMinutes },\n    { literal: 'hour', expected: diffHours },\n    { literal: 'hours', expected: diffHours },\n    { literal: 'h', expected: diffHours },\n    { literal: 'hr', expected: diffHours },\n    { literal: 'hrs', expected: diffHours },\n    { literal: 'day', expected: diffDays },\n    { literal: 'days', expected: diffDays },\n    { literal: 'week', expected: diffWeeks },\n    { literal: 'weeks', expected: diffWeeks },\n  ];\n  const isSameCases: Array<{ literal: string; first: string; second: string; expected: boolean }> =\n    [\n      {\n        literal: 'day',\n        first: '2025-01-05T10:00:00Z',\n        second: '2025-01-05T23:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'days',\n        first: '2025-01-05T08:00:00Z',\n        second: '2025-01-05T12:34:56Z',\n        expected: true,\n      },\n      {\n        literal: 'hour',\n        first: '2025-01-05T10:05:00Z',\n        second: '2025-01-05T10:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'hours',\n        first: '2025-01-05T15:00:00Z',\n        second: '2025-01-05T15:45:00Z',\n        expected: true,\n      },\n      {\n        literal: 'hr',\n        first: '2025-01-05T18:01:00Z',\n        second: '2025-01-05T18:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'hrs',\n        first: '2025-01-05T21:00:00Z',\n        second: '2025-01-05T21:10:00Z',\n        expected: true,\n      },\n      {\n        literal: 'minute',\n        first: '2025-01-05T10:15:30Z',\n        second: '2025-01-05T10:15:59Z',\n        expected: true,\n      },\n      {\n        literal: 'minutes',\n        first: '2025-01-05T11:00:00Z',\n        second: '2025-01-05T11:00:59Z',\n        expected: true,\n      },\n      {\n        literal: 'min',\n        first: '2025-01-05T12:34:10Z',\n        second: '2025-01-05T12:34:50Z',\n        expected: true,\n      },\n      {\n        literal: 'mins',\n        first: '2025-01-05T13:00:00Z',\n        second: '2025-01-05T13:00:30Z',\n        expected: true,\n      },\n      {\n        literal: 'second',\n        first: '2025-01-05T14:15:30Z',\n        second: '2025-01-05T14:15:30Z',\n        expected: true,\n      },\n      {\n        literal: 'seconds',\n        first: '2025-01-05T14:15:45Z',\n        second: '2025-01-05T14:15:45Z',\n        expected: true,\n      },\n      {\n        literal: 'sec',\n        first: '2025-01-05T14:20:15Z',\n        second: '2025-01-05T14:20:15Z',\n        expected: true,\n      },\n      {\n        literal: 'secs',\n        first: '2025-01-05T14:25:40Z',\n        second: '2025-01-05T14:25:40Z',\n        expected: true,\n      },\n      {\n        literal: 'month',\n        first: '2025-01-05T10:00:00Z',\n        second: '2025-01-30T12:00:00Z',\n        expected: true,\n      },\n      {\n        literal: 'months',\n        first: '2025-01-01T00:00:00Z',\n        second: '2025-01-31T23:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'year',\n        first: '2025-01-01T00:00:00Z',\n        second: '2025-12-31T23:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'years',\n        first: '2025-03-15T00:00:00Z',\n        second: '2025-11-20T23:59:59Z',\n        expected: true,\n      },\n      {\n        literal: 'week',\n        first: '2025-01-06T08:00:00Z',\n        second: '2025-01-11T22:00:00Z',\n        expected: true,\n      },\n      {\n        literal: 'weeks',\n        first: '2025-01-06T00:00:00Z',\n        second: '2025-01-12T23:59:59Z',\n        expected: true,\n      },\n    ];\n  const addToDate = (date: Date, count: number, unit: DateAddNormalizedUnit): Date => {\n    const clone = new Date(date.getTime());\n    switch (unit) {\n      case 'millisecond':\n        clone.setUTCMilliseconds(clone.getUTCMilliseconds() + count);\n        break;\n      case 'second':\n        clone.setUTCSeconds(clone.getUTCSeconds() + count);\n        break;\n      case 'minute':\n        clone.setUTCMinutes(clone.getUTCMinutes() + count);\n        break;\n      case 'hour':\n        clone.setUTCHours(clone.getUTCHours() + count);\n        break;\n      case 'day':\n        clone.setUTCDate(clone.getUTCDate() + count);\n        break;\n      case 'week':\n        clone.setUTCDate(clone.getUTCDate() + count * 7);\n        break;\n      case 'month':\n        clone.setUTCMonth(clone.getUTCMonth() + count);\n        break;\n      case 'quarter':\n        clone.setUTCMonth(clone.getUTCMonth() + count * 3);\n        break;\n      case 'year':\n        clone.setUTCFullYear(clone.getUTCFullYear() + count);\n        break;\n      default:\n        throw new Error(`Unsupported unit: ${unit}`);\n    }\n    return clone;\n  };\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    // Ensure real timers are active before any API calls\n    // This prevents Keyv cache issues caused by vi.useFakeTimers()\n    vi.useRealTimers();\n\n    numberFieldRo = {\n      id: generateFieldId(),\n      name: 'Number field',\n      description: 'the number field',\n      type: FieldType.Number,\n      options: {\n        formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n      },\n    };\n\n    textFieldRo = {\n      id: generateFieldId(),\n      name: 'text field',\n      description: 'the text field',\n      type: FieldType.SingleLineText,\n    };\n\n    userFieldRo = {\n      id: generateFieldId(),\n      name: 'assignee',\n      description: 'the user field',\n      type: FieldType.User,\n      options: {\n        isMultiple: false,\n        shouldNotify: false,\n      },\n    };\n\n    multiSelectFieldRo = {\n      id: generateFieldId(),\n      name: 'tags',\n      description: 'the multi select field',\n      type: FieldType.MultipleSelect,\n      options: {\n        choices: [\n          { id: 'tag-alpha', name: 'Alpha' },\n          { id: 'tag-beta', name: 'Beta' },\n        ],\n      } as ISelectFieldOptionsRo,\n    };\n\n    formulaFieldRo = {\n      id: generateFieldId(),\n      name: 'New field',\n      description: 'the new field',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberFieldRo.id}} & {${textFieldRo.id}}`,\n      },\n    };\n\n    table1 = await createTable(baseId, {\n      name: `table-${Date.now()}`,\n      fields: [numberFieldRo, textFieldRo, userFieldRo, multiSelectFieldRo, formulaFieldRo],\n    });\n    table1Id = table1.id;\n  });\n\n  afterEach(async () => {\n    // IMPORTANT: Restore real timers before any API calls to prevent Keyv cache issues.\n    // vi.useFakeTimers() interferes with Keyv's Date.now()-based TTL checks,\n    // causing session data to be incorrectly treated as expired or deleted.\n    vi.useRealTimers();\n    await permanentDeleteTable(baseId, table1Id);\n  });\n\n  it('should response calculate record after create', async () => {\n    const recordResult = await createRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {\n            [numberFieldRo.name]: 1,\n            [textFieldRo.name]: 'x',\n          },\n        },\n      ],\n    });\n\n    const record = recordResult.records[0];\n    expect(record.fields[numberFieldRo.name]).toEqual(1);\n    expect(record.fields[textFieldRo.name]).toEqual('x');\n    // V1 returns '1x', V2 returns '1.0x' (applies number formatting)\n    expect(record.fields[formulaFieldRo.name]).toMatch(/^1(\\.0)?x$/);\n  });\n\n  it('should response calculate record after update multi record field', async () => {\n    const getResult = await getRecords(table1Id);\n\n    const existRecord = getResult.records[0];\n\n    const record = await updateRecord(table1Id, existRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [numberFieldRo.name]: 1,\n          [textFieldRo.name]: 'x',\n        },\n      },\n    });\n\n    expect(record.fields[numberFieldRo.name]).toEqual(1);\n    expect(record.fields[textFieldRo.name]).toEqual('x');\n    // V1 returns '1x', V2 returns '1.0x' (applies number formatting)\n    expect(record.fields[formulaFieldRo.name]).toMatch(/^1(\\.0)?x$/);\n  });\n\n  it('should response calculate record after update single record field', async () => {\n    const getResult = await getRecords(table1Id);\n\n    const existRecord = getResult.records[0];\n\n    const record1 = await updateRecord(table1Id, existRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [numberFieldRo.name]: 1,\n        },\n      },\n    });\n\n    expect(record1.fields[numberFieldRo.name]).toEqual(1);\n    expect(record1.fields[textFieldRo.name]).toBeUndefined();\n    // V1 returns '1', V2 returns '1.0' (applies number formatting)\n    expect(record1.fields[formulaFieldRo.name]).toMatch(/^1(\\.0)?$/);\n\n    const record2 = await updateRecord(table1Id, existRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [textFieldRo.name]: 'x',\n        },\n      },\n    });\n\n    // V1 returns all fields, V2 only returns updated fields + computed fields\n    // So numberFieldRo may be 1 (V1) or undefined (V2)\n    expect([1, undefined]).toContain(record2.fields[numberFieldRo.name]);\n    expect(record2.fields[textFieldRo.name]).toEqual('x');\n    // V1 returns '1x', V2 returns '1.0x' (applies number formatting)\n    expect(record2.fields[formulaFieldRo.name]).toMatch(/^1(\\.0)?x$/);\n  });\n\n  it('should batch update records referencing spaced curly field identifiers', async () => {\n    const spacedFormulaField = await createField(table1Id, {\n      name: 'spaced-curly-formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{ ${numberFieldRo.id} } & '-' & {   ${textFieldRo.id}   }`,\n      },\n    });\n\n    const { records } = await createRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {\n            [numberFieldRo.name]: 5,\n            [textFieldRo.name]: 'old',\n          },\n        },\n      ],\n    });\n    const recordId = records[0].id;\n\n    const response = await updateRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          id: recordId,\n          fields: {\n            [numberFieldRo.name]: 10,\n            [textFieldRo.name]: 'fresh',\n          },\n        },\n      ],\n    });\n\n    expect(response.status).toBe(200);\n\n    const { data: updatedRecord } = await getRecord(table1Id, recordId);\n    expect(updatedRecord.fields?.[formulaFieldRo.name]).toEqual('10fresh');\n    expect(updatedRecord.fields?.[spacedFormulaField.name]).toEqual('10-fresh');\n  });\n\n  it('should concatenate strings with plus operator when operands are blank', async () => {\n    const plusNumberSuffixField = await createField(table1Id, {\n      name: 'plus-number-suffix',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberFieldRo.id}} + ''`,\n      },\n    });\n\n    const plusNumberPrefixField = await createField(table1Id, {\n      name: 'plus-number-prefix',\n      type: FieldType.Formula,\n      options: {\n        expression: `'' + {${numberFieldRo.id}}`,\n      },\n    });\n\n    const plusTextSuffixField = await createField(table1Id, {\n      name: 'plus-text-suffix',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textFieldRo.id}} + ''`,\n      },\n    });\n\n    const plusTextPrefixField = await createField(table1Id, {\n      name: 'plus-text-prefix',\n      type: FieldType.Formula,\n      options: {\n        expression: `'' + {${textFieldRo.id}}`,\n      },\n    });\n\n    const plusMixedField = await createField(table1Id, {\n      name: 'plus-mixed-field',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberFieldRo.id}} + {${textFieldRo.id}}`,\n      },\n    });\n\n    const { records } = await createRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {\n            [numberFieldRo.name]: 1,\n          },\n        },\n      ],\n    });\n\n    const createdRecord = records[0];\n    expect(createdRecord.fields[plusNumberSuffixField.name]).toEqual('1');\n    expect(createdRecord.fields[plusNumberPrefixField.name]).toEqual('1');\n    expect(createdRecord.fields[plusTextSuffixField.name]).toEqual('');\n    expect(createdRecord.fields[plusTextPrefixField.name]).toEqual('');\n    expect(createdRecord.fields[plusMixedField.name]).toEqual('1');\n\n    await updateRecord(table1Id, createdRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [textFieldRo.name]: 'x',\n        },\n      },\n    });\n\n    // Fetch the full record to verify all computed field values\n    const updatedRecord = await getRecord(table1Id, createdRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n    });\n\n    expect(updatedRecord.data.fields[plusNumberSuffixField.name]).toEqual('1');\n    expect(updatedRecord.data.fields[plusNumberPrefixField.name]).toEqual('1');\n    expect(updatedRecord.data.fields[plusTextSuffixField.name]).toEqual('x');\n    expect(updatedRecord.data.fields[plusTextPrefixField.name]).toEqual('x');\n    expect(updatedRecord.data.fields[plusMixedField.name]).toEqual('1x');\n  });\n\n  it('should safely update numeric formulas that add multi-value fields', async () => {\n    let foreign: ITableFullVo | undefined;\n\n    try {\n      foreign = await createTable(baseId, {\n        name: 'lookup-multi-number-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Effort',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 0 } },\n          } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Task A', Effort: 3 } },\n          { fields: { Title: 'Task B', Effort: 7 } },\n        ],\n      });\n\n      const effortField = foreign.fields.find((field) => field.name === 'Effort');\n      expect(effortField).toBeDefined();\n\n      const linkField = await createField(table1Id, {\n        name: 'linked-tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreign.id,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const lookupField = await createField(table1Id, {\n        name: 'linked-effort',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: effortField!.id,\n          linkFieldId: linkField.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const numericFormulaField = await createField(table1Id, {\n        name: 'lookup-plus-number',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupField.id}} + {${numberFieldRo.id}}`,\n        },\n      });\n\n      const numericFormulaMeta = await getField(table1Id, numericFormulaField.id);\n      expect(numericFormulaMeta.dbFieldType).toBe(DbFieldType.Real);\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 5,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      await updateRecordByApi(\n        table1Id,\n        recordId,\n        linkField.id,\n        foreign.records.map((record) => ({ id: record.id }))\n      );\n\n      const updatedRecord = await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 9,\n          },\n        },\n      });\n\n      expect(updatedRecord.fields[numberFieldRo.name]).toEqual(9);\n      expect(updatedRecord.fields[numericFormulaField.name]).not.toBeUndefined();\n    } finally {\n      if (foreign) {\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    }\n  });\n\n  it('should treat empty string comparison as blank in formula condition', async () => {\n    const equalsEmptyField = await createField(table1Id, {\n      name: 'equals empty string',\n      type: FieldType.Formula,\n      options: {\n        expression: `IF({${textFieldRo.id}}=\"\", 1, 0)`,\n      },\n    });\n\n    const { records } = await createRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {},\n        },\n      ],\n    });\n\n    const createdRecord = records[0];\n    await getRecord(table1Id, createdRecord.id);\n\n    const filledRecord = await updateRecord(table1Id, createdRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [textFieldRo.name]: 'value',\n        },\n      },\n    });\n\n    expect(filledRecord.fields[equalsEmptyField.name]).toEqual(0);\n\n    const clearedRecord = await updateRecord(table1Id, createdRecord.id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [textFieldRo.name]: '',\n        },\n      },\n    });\n\n    expect(clearedRecord.fields[equalsEmptyField.name]).toEqual(1);\n  });\n\n  it('should calculate formula containing question mark literal', async () => {\n    const urlFormulaField = await createField(table1Id, {\n      name: 'url formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `'https://example.com/?id=' & {${textFieldRo.id}}`,\n      },\n    });\n\n    const { records } = await createRecords(table1Id, {\n      fieldKeyType: FieldKeyType.Name,\n      records: [\n        {\n          fields: {\n            [textFieldRo.name]: 'abc',\n          },\n        },\n      ],\n    });\n\n    expect(records[0].fields[urlFormulaField.name]).toEqual('https://example.com/?id=abc');\n  });\n\n  describe('binary operator coercion', () => {\n    type OperatorTestContext = {\n      tableId: string;\n      numberField: typeof numberFieldRo;\n      textField: typeof textFieldRo;\n      userField: typeof userFieldRo;\n      multiSelectField: typeof multiSelectFieldRo;\n    };\n\n    type ExtendedOperatorTestContext = OperatorTestContext & Record<string, unknown>;\n\n    type OperatorCase = {\n      name: string;\n      setup?: (\n        ctx: OperatorTestContext\n      ) => Promise<Record<string, unknown>> | Record<string, unknown>;\n      expression: (ctx: ExtendedOperatorTestContext) => string;\n      initialFields: (ctx: ExtendedOperatorTestContext) => Record<string, unknown>;\n      updatedFields: (ctx: ExtendedOperatorTestContext) => Record<string, unknown>;\n      assertInitial: (value: unknown, ctx: ExtendedOperatorTestContext) => void;\n      assertUpdated: (value: unknown, ctx: ExtendedOperatorTestContext) => void;\n    };\n\n    const sanitizeLabel = (label: string) => label.replace(/[^a-z0-9]+/gi, '-').toLowerCase();\n\n    const operatorCases: OperatorCase[] = [\n      {\n        name: 'text equals numeric literal',\n        expression: (ctx) => `{${ctx.textField.id}} = 0`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: '0',\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: '5',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(true);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(false);\n        },\n      },\n      {\n        name: 'text greater than numeric literal',\n        expression: (ctx) => `{${ctx.textField.id}} > 2`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: '10',\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: '1',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(true);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(false);\n        },\n      },\n      {\n        name: 'number less than string literal',\n        expression: (ctx) => `{${ctx.numberField.id}} < \"10\"`,\n        initialFields: (ctx) => ({\n          [ctx.numberField.name]: 3,\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.numberField.name]: 20,\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(true);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(false);\n        },\n      },\n      {\n        name: 'text minus numeric literal',\n        expression: (ctx) => `{${ctx.textField.id}} - 2`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: '5',\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: '1',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(3);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(-1);\n        },\n      },\n      {\n        name: 'number plus numeric literal',\n        expression: (ctx) => `{${ctx.numberField.id}} + 3`,\n        initialFields: (ctx) => ({\n          [ctx.numberField.name]: 4,\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.numberField.name]: 10,\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(7);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(13);\n        },\n      },\n      {\n        name: 'text divided by numeric literal',\n        expression: (ctx) => `{${ctx.textField.id}} / 2`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: '8',\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: '3',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(4);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBeCloseTo(1.5, 9);\n        },\n      },\n      {\n        name: 'text multiplied by numeric literal',\n        expression: (ctx) => `{${ctx.textField.id}} * 4`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: '3',\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: '5',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(12);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(20);\n        },\n      },\n      {\n        name: 'user equality against text',\n        expression: (ctx) => `TEXT_ALL({${ctx.userField.id}}) = {${ctx.textField.id}}`,\n        initialFields: (ctx) => ({\n          [ctx.userField.name]: {\n            id: globalThis.testConfig.userId,\n            title: globalThis.testConfig.userName,\n            email: globalThis.testConfig.email,\n          },\n          [ctx.textField.name]: globalThis.testConfig.userName,\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.userField.name]: {\n            id: globalThis.testConfig.userId,\n            title: globalThis.testConfig.userName,\n            email: globalThis.testConfig.email,\n          },\n          [ctx.textField.name]: 'someone else',\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(true);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(false);\n        },\n      },\n      {\n        name: 'multi select equality against text',\n        expression: (ctx) => `ARRAY_JOIN({${ctx.multiSelectField.id}}, '') = {${ctx.textField.id}}`,\n        initialFields: (ctx) => ({\n          [ctx.textField.name]: 'Alpha',\n          [ctx.multiSelectField.name]: ['Alpha'],\n        }),\n        updatedFields: (ctx) => ({\n          [ctx.textField.name]: 'Alpha',\n          [ctx.multiSelectField.name]: ['Beta'],\n        }),\n        assertInitial: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(true);\n        },\n        assertUpdated: (value) => {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(false);\n        },\n      },\n    ];\n\n    it.each(operatorCases)(\n      'should evaluate $name without type coercion errors',\n      async (testCase) => {\n        const baseContext: OperatorTestContext = {\n          tableId: table1Id,\n          numberField: numberFieldRo,\n          textField: textFieldRo,\n          userField: userFieldRo,\n          multiSelectField: multiSelectFieldRo,\n        };\n\n        const extraContext = (await testCase.setup?.(baseContext)) ?? {};\n        const context = { ...baseContext, ...extraContext } as ExtendedOperatorTestContext;\n\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: testCase.initialFields(context),\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `binary-op-${sanitizeLabel(testCase.name)}`,\n          type: FieldType.Formula,\n          options: {\n            expression: testCase.expression(context),\n          },\n        });\n\n        const readFormulaValue = async () => {\n          const record = await getRecord(table1Id, recordId);\n          return record.data.fields[formulaField.name];\n        };\n\n        const initialValue = await readFormulaValue();\n        testCase.assertInitial(initialValue, context);\n\n        await updateRecord(table1Id, recordId, {\n          fieldKeyType: FieldKeyType.Name,\n          record: {\n            fields: testCase.updatedFields(context),\n          },\n        });\n\n        const updatedValue = await readFormulaValue();\n        testCase.assertUpdated(updatedValue, context);\n      }\n    );\n  });\n\n  describe('boolean operator combinations', () => {\n    it('should evaluate nested AND/OR across heterogeneous fields', async () => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 3,\n              [textFieldRo.name]: 'Alpha announcement',\n              [multiSelectFieldRo.name]: ['Alpha'],\n              [userFieldRo.name]: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const booleanField = await createField(table1Id, {\n        name: 'boolean-nested-and-or',\n        type: FieldType.Formula,\n        options: {\n          expression:\n            `AND({${numberFieldRo.id}} > 0, ` +\n            `OR({${textFieldRo.id}} != \"\", ARRAY_JOIN({${multiSelectFieldRo.id}}, '') = \"Alpha\"), ` +\n            `LOWER({${userFieldRo.id}}) != \"\")`,\n        },\n      });\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      expect(initialRecord.data.fields[booleanField.name]).toBe(true);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 0,\n            [textFieldRo.name]: '',\n            [multiSelectFieldRo.name]: null,\n            [userFieldRo.name]: null,\n          },\n        },\n      });\n\n      const updatedRecord = await getRecord(table1Id, recordId);\n      expect(updatedRecord.data.fields[booleanField.name]).toBe(false);\n    });\n\n    it('should evaluate OR with nested NOT and date comparison', async () => {\n      const reviewDateField = await createField(table1Id, {\n        name: 'review-date',\n        type: FieldType.Date,\n      } as IFieldRo);\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: -2,\n              [textFieldRo.name]: '',\n              [multiSelectFieldRo.name]: null,\n              [reviewDateField.name]: '2025-05-01T00:00:00.000Z',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const numberBranchField = await createField(table1Id, {\n        name: 'boolean-branch-number',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${numberFieldRo.id}} < 0`,\n        },\n      });\n\n      const emptyStringBranchField = await createField(table1Id, {\n        name: 'boolean-branch-empty-text',\n        type: FieldType.Formula,\n        options: {\n          expression: `AND({${textFieldRo.id}} = \"\", NOT(ARRAY_JOIN({${multiSelectFieldRo.id}}, '') != \"\"))`,\n        },\n      });\n\n      const dateBranchField = await createField(table1Id, {\n        name: 'boolean-branch-date',\n        type: FieldType.Formula,\n        options: {\n          expression: `AND(IS_BEFORE({${reviewDateField.id}}, '2026-01-01'), {${numberFieldRo.id}} <= 5)`,\n        },\n      });\n\n      const complexBooleanField = await createField(table1Id, {\n        name: 'boolean-nested-or',\n        type: FieldType.Formula,\n        options: {\n          expression:\n            `OR(` +\n            `{${numberFieldRo.id}} < 0, ` +\n            `AND({${textFieldRo.id}} = \"\", NOT(ARRAY_JOIN({${multiSelectFieldRo.id}}, '') != \"\")), ` +\n            `AND(IS_BEFORE({${reviewDateField.id}}, '2026-01-01'), {${numberFieldRo.id}} <= 5)` +\n            `)`,\n        },\n      });\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      expect(initialRecord.data.fields[numberBranchField.name]).toBe(true);\n      expect(initialRecord.data.fields[emptyStringBranchField.name]).toBe(true);\n      expect(initialRecord.data.fields[dateBranchField.name]).toBe(true);\n      expect(initialRecord.data.fields[complexBooleanField.name]).toBe(true);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 12,\n            [textFieldRo.name]: 'Busy',\n            [multiSelectFieldRo.name]: ['Alpha'],\n            [reviewDateField.name]: '2026-02-01T00:00:00.000Z',\n          },\n        },\n      });\n\n      const updatedRecord = await getRecord(table1Id, recordId);\n      expect(updatedRecord.data.fields[numberFieldRo.name]).toEqual(12);\n      expect(updatedRecord.data.fields[textFieldRo.name]).toEqual('Busy');\n      expect(updatedRecord.data.fields[multiSelectFieldRo.name]).toEqual(['Alpha']);\n      expect(updatedRecord.data.fields[reviewDateField.name]).toEqual('2026-02-01T00:00:00.000Z');\n      expect(updatedRecord.data.fields[numberBranchField.name]).toBe(false);\n      expect(updatedRecord.data.fields[emptyStringBranchField.name]).toBe(false);\n      expect(updatedRecord.data.fields[dateBranchField.name]).toBe(false);\n      expect(updatedRecord.data.fields[complexBooleanField.name]).toBe(false);\n    });\n  });\n\n  describe('LAST_MODIFIED_TIME field parameter', () => {\n    // Helper to ensure time advances between operations (real time, not fake timers)\n    // Note: vi.useFakeTimers() is incompatible with Keyv cache - it uses Date.now()\n    // to check TTL, causing session data to be incorrectly deleted when fake time is set to the past.\n    const waitForTimestamp = () => new Promise((resolve) => setTimeout(resolve, 100));\n\n    it('should update when any referenced field changes', async () => {\n      const multiTrackedFormulaField = await createField(table1Id, {\n        name: 'multi-tracked-last-modified',\n        type: FieldType.Formula,\n        options: {\n          expression: `LAST_MODIFIED_TIME({${textFieldRo.id}}, {${numberFieldRo.id}})`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [textFieldRo.name]: 'initial text',\n              [numberFieldRo.name]: 1,\n              [multiSelectFieldRo.name]: ['Alpha'],\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      const initialFormulaValue = initialRecord.data.fields[multiTrackedFormulaField.name];\n      expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime);\n\n      // Wait for time to advance before untracked field update\n      await waitForTimestamp();\n\n      // Untracked field change should NOT update the formula\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [multiSelectFieldRo.name]: ['Beta'],\n          },\n        },\n      });\n\n      const afterUntrackedUpdate = await getRecord(table1Id, recordId);\n      expect(afterUntrackedUpdate.data.lastModifiedTime).not.toEqual(\n        initialRecord.data.lastModifiedTime\n      );\n      expect(afterUntrackedUpdate.data.fields[multiTrackedFormulaField.name]).toEqual(\n        initialFormulaValue\n      );\n\n      // Wait for time to advance before tracked field update\n      await waitForTimestamp();\n\n      // Any tracked field change should update the formula\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 2,\n          },\n        },\n      });\n\n      const afterTrackedUpdate = await getRecord(table1Id, recordId);\n      expect(afterTrackedUpdate.data.fields[multiTrackedFormulaField.name]).not.toEqual(\n        initialFormulaValue\n      );\n      expect(afterTrackedUpdate.data.fields[multiTrackedFormulaField.name]).toEqual(\n        afterTrackedUpdate.data.lastModifiedTime\n      );\n    });\n\n    it('should update only when the referenced field changes', async () => {\n      const lastModifiedFormulaField = await createField(table1Id, {\n        name: 'tracked-last-modified',\n        type: FieldType.Formula,\n        options: {\n          expression: `LAST_MODIFIED_TIME({${textFieldRo.id}})`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [textFieldRo.name]: 'initial text',\n              [numberFieldRo.name]: 1,\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      const initialFormulaValue = initialRecord.data.fields[lastModifiedFormulaField.name];\n      expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime);\n\n      // Wait for time to advance before unrelated field update\n      await waitForTimestamp();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 99,\n          },\n        },\n      });\n\n      const afterUnrelatedUpdate = await getRecord(table1Id, recordId);\n      expect(afterUnrelatedUpdate.data.lastModifiedTime).not.toEqual(\n        initialRecord.data.lastModifiedTime\n      );\n      expect(afterUnrelatedUpdate.data.fields[lastModifiedFormulaField.name]).toEqual(\n        initialFormulaValue\n      );\n\n      // Wait for time to advance before tracked field update\n      await waitForTimestamp();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'updated text',\n          },\n        },\n      });\n\n      const afterTrackedUpdate = await getRecord(table1Id, recordId);\n      expect(afterTrackedUpdate.data.fields[lastModifiedFormulaField.name]).not.toEqual(\n        initialFormulaValue\n      );\n      expect(afterTrackedUpdate.data.fields[lastModifiedFormulaField.name]).toEqual(\n        afterTrackedUpdate.data.lastModifiedTime\n      );\n    });\n\n    it('should continue to work without passing the optional parameter', async () => {\n      const defaultLastModifiedField = await createField(table1Id, {\n        name: 'default-last-modified',\n        type: FieldType.Formula,\n        options: {\n          expression: 'LAST_MODIFIED_TIME()',\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [textFieldRo.name]: 'plain text',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      const initialFormulaValue = initialRecord.data.fields[defaultLastModifiedField.name];\n      expect(initialFormulaValue).toEqual(initialRecord.data.lastModifiedTime);\n\n      // Wait for time to advance before first update\n      await waitForTimestamp();\n\n      // Any field change should update the default tracking formula\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 123,\n          },\n        },\n      });\n\n      const afterAnyUpdate = await getRecord(table1Id, recordId);\n      expect(afterAnyUpdate.data.fields[defaultLastModifiedField.name]).not.toEqual(\n        initialFormulaValue\n      );\n      expect(afterAnyUpdate.data.fields[defaultLastModifiedField.name]).toEqual(\n        afterAnyUpdate.data.lastModifiedTime\n      );\n\n      // Wait for time to advance before second update\n      await waitForTimestamp();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'changed text',\n          },\n        },\n      });\n\n      const afterDefaultUpdate = await getRecord(table1Id, recordId);\n      expect(afterDefaultUpdate.data.fields[defaultLastModifiedField.name]).not.toEqual(\n        afterAnyUpdate.data.fields[defaultLastModifiedField.name]\n      );\n      expect(afterDefaultUpdate.data.fields[defaultLastModifiedField.name]).toEqual(\n        afterDefaultUpdate.data.lastModifiedTime\n      );\n    });\n\n    it('should allow configuring Last Modified Time field to track specific fields only', async () => {\n      const specificLmt = await createField(table1Id, {\n        name: 'specific-lmt',\n        type: FieldType.LastModifiedTime,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n            timeZone: 'UTC',\n          },\n          trackedFieldIds: [textFieldRo.id],\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [textFieldRo.name]: 'initial text',\n              [numberFieldRo.name]: 1,\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const initialRecord = await getRecord(table1Id, recordId);\n      const initialLmt = initialRecord.data.fields[specificLmt.name];\n      expect(initialLmt).toEqual(initialRecord.data.lastModifiedTime);\n\n      // Wait for time to advance before untracked field update\n      await waitForTimestamp();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 2,\n          },\n        },\n      });\n\n      const afterUntrackedUpdate = await getRecord(table1Id, recordId);\n      expect(afterUntrackedUpdate.data.fields[specificLmt.name]).toEqual(initialLmt);\n\n      // Wait for time to advance before tracked field update\n      await waitForTimestamp();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'updated text',\n          },\n        },\n      });\n\n      const afterTrackedUpdate = await getRecord(table1Id, recordId);\n      expect(afterTrackedUpdate.data.fields[specificLmt.name]).not.toEqual(initialLmt);\n      expect(afterTrackedUpdate.data.fields[specificLmt.name]).toEqual(\n        afterTrackedUpdate.data.lastModifiedTime\n      );\n    });\n\n    it('should reject non-field parameters', async () => {\n      await createField(\n        table1Id,\n        {\n          name: 'invalid-last-modified',\n          type: FieldType.Formula,\n          options: {\n            expression: 'LAST_MODIFIED_TIME(\"literal param\")',\n          },\n        },\n        400\n      );\n    });\n  });\n\n  describe('numeric formula functions', () => {\n    const numericInput = 12.345;\n    const oddExpected = (() => {\n      const rounded = Math.ceil(numericInput / 3);\n      return rounded % 2 !== 0 ? rounded : rounded + 1;\n    })();\n\n    const numericCases = [\n      {\n        name: 'ROUND',\n        getExpression: () => `ROUND({${numberFieldRo.id}}, 2)`,\n        expected: Math.round(numericInput * 100) / 100,\n      },\n      {\n        name: 'ROUNDUP',\n        getExpression: () => `ROUNDUP({${numberFieldRo.id}} / 7, 2)`,\n        expected: Math.ceil((numericInput / 7) * 100) / 100,\n      },\n      {\n        name: 'ROUNDDOWN',\n        getExpression: () => `ROUNDDOWN({${numberFieldRo.id}} / 7, 2)`,\n        expected: Math.floor((numericInput / 7) * 100) / 100,\n      },\n      {\n        name: 'CEILING',\n        getExpression: () => `CEILING({${numberFieldRo.id}} / 3)`,\n        expected: Math.ceil(numericInput / 3),\n      },\n      {\n        name: 'FLOOR',\n        getExpression: () => `FLOOR({${numberFieldRo.id}} / 3)`,\n        expected: Math.floor(numericInput / 3),\n      },\n      {\n        name: 'EVEN',\n        getExpression: () => `EVEN({${numberFieldRo.id}} / 3)`,\n        expected: 4,\n      },\n      {\n        name: 'ODD',\n        getExpression: () => `ODD({${numberFieldRo.id}} / 3)`,\n        expected: oddExpected,\n      },\n      {\n        name: 'INT',\n        getExpression: () => `INT({${numberFieldRo.id}} / 3)`,\n        expected: Math.floor(numericInput / 3),\n      },\n      {\n        name: 'ABS',\n        getExpression: () => `ABS(-{${numberFieldRo.id}})`,\n        expected: Math.abs(-numericInput),\n      },\n      {\n        name: 'SQRT',\n        getExpression: () => `SQRT({${numberFieldRo.id}} * {${numberFieldRo.id}})`,\n        expected: Math.sqrt(numericInput * numericInput),\n      },\n      {\n        name: 'POWER',\n        getExpression: () => `POWER({${numberFieldRo.id}}, 2)`,\n        expected: Math.pow(numericInput, 2),\n      },\n      {\n        name: 'EXP',\n        getExpression: () => 'EXP(1)',\n        expected: Math.exp(1),\n      },\n      {\n        name: 'LOG',\n        getExpression: () => 'LOG(256, 2)',\n        expected: Math.log(256) / Math.log(2),\n      },\n      {\n        name: 'MOD',\n        getExpression: () => `MOD({${numberFieldRo.id}}, 5)`,\n        expected: numericInput % 5,\n      },\n      {\n        name: 'VALUE',\n        getExpression: () => 'VALUE(\"1234.5\")',\n        expected: 1234.5,\n      },\n    ] as const;\n\n    it.each(numericCases)('should evaluate $name', async ({ getExpression, expected, name }) => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: numericInput,\n              [textFieldRo.name]: 'numeric',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: `numeric-${name.toLowerCase()}`,\n        type: FieldType.Formula,\n        options: {\n          expression: getExpression(),\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const value = recordAfterFormula.data.fields[formulaField.name];\n      expect(typeof value).toBe('number');\n      expect(value as number).toBeCloseTo(expected, 9);\n    });\n\n    it('should evaluate SUM with multiple arguments and conditional logic', async () => {\n      const initialValue = 25;\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: initialValue,\n              [textFieldRo.name]: 'numeric',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: 'numeric-sum-if',\n        type: FieldType.Formula,\n        options: {\n          expression: `SUM(IF({${numberFieldRo.id}} > 20, {${numberFieldRo.id}} - 20, {${numberFieldRo.id}} + 20), {${numberFieldRo.id}})`,\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const firstValue = recordAfterFormula.data.fields[formulaField.name];\n      expect(firstValue).toBe(30);\n\n      const updatedRecord = await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 10,\n          },\n        },\n      });\n\n      expect(updatedRecord.fields[formulaField.name]).toBe(40);\n    });\n  });\n\n  describe('text formula functions', () => {\n    const numericInput = 12.345;\n    const textInput = 'Teable Rocks';\n    const encodeUrlInput =\n      'Been using Teable lately — honestly impressed @teableio \\u00A0 Scattered work → AI-native system (for projects, CRM & marketing) in minutes 🚀 teable.ai';\n\n    const textCases: Array<{\n      name: string;\n      getExpression: () => string;\n      expected: string | number;\n      textValue?: string;\n    }> = [\n      {\n        name: 'CONCATENATE',\n        getExpression: () => `CONCATENATE({${textFieldRo.id}}, \"-\", \"END\")`,\n        expected: `${textInput}-END`,\n      },\n      {\n        name: 'LEFT',\n        getExpression: () => `LEFT({${textFieldRo.id}}, 6)`,\n        expected: textInput.slice(0, 6),\n      },\n      {\n        name: 'RIGHT',\n        getExpression: () => `RIGHT({${textFieldRo.id}}, 5)`,\n        expected: textInput.slice(-5),\n      },\n      {\n        name: 'MID',\n        getExpression: () => `MID({${textFieldRo.id}}, 8, 3)`,\n        expected: textInput.slice(7, 10),\n      },\n      {\n        name: 'REPLACE',\n        getExpression: () => `REPLACE({${textFieldRo.id}}, 8, 5, \"World\")`,\n        expected: `${textInput.slice(0, 7)}World`,\n      },\n      {\n        name: 'REGEXP_REPLACE',\n        getExpression: () => `REGEXP_REPLACE({${textFieldRo.id}}, \"[aeiou]\", \"#\")`,\n        expected: textInput.replace(/[aeiou]/g, '#'),\n      },\n      {\n        name: 'REGEXP_REPLACE email local part',\n        textValue: 'olivia@example.com',\n        getExpression: () => `\"user name:\" & REGEXP_REPLACE({${textFieldRo.id}}, '@.*', '')`,\n        expected: 'user name:olivia',\n      },\n      {\n        name: 'SUBSTITUTE',\n        getExpression: () => `SUBSTITUTE({${textFieldRo.id}}, \"e\", \"E\")`,\n        expected: textInput.replace(/e/g, 'E'),\n      },\n      {\n        name: 'LOWER',\n        getExpression: () => `LOWER({${textFieldRo.id}})`,\n        expected: textInput.toLowerCase(),\n      },\n      {\n        name: 'UPPER',\n        getExpression: () => `UPPER({${textFieldRo.id}})`,\n        expected: textInput.toUpperCase(),\n      },\n      {\n        name: 'REPT',\n        getExpression: () => 'REPT(\"Na\", 3)',\n        expected: 'NaNaNa',\n      },\n      {\n        name: 'TRIM',\n        getExpression: () => 'TRIM(\"  spaced  \")',\n        expected: 'spaced',\n      },\n      {\n        name: 'LEN',\n        getExpression: () => `LEN({${textFieldRo.id}})`,\n        expected: textInput.length,\n      },\n      {\n        name: 'T',\n        getExpression: () => `T({${textFieldRo.id}})`,\n        expected: textInput,\n      },\n      {\n        name: 'T (non text)',\n        getExpression: () => `T({${numberFieldRo.id}})`,\n        expected: numericInput.toString(),\n      },\n      {\n        name: 'FIND',\n        getExpression: () => `FIND(\"R\", {${textFieldRo.id}})`,\n        expected: textInput.indexOf('R') + 1,\n      },\n      {\n        name: 'SEARCH',\n        getExpression: () => `SEARCH(\"rocks\", {${textFieldRo.id}})`,\n        expected: textInput.toLowerCase().indexOf('rocks') + 1,\n      },\n      {\n        name: 'ENCODE_URL_COMPONENT',\n        getExpression: () => `ENCODE_URL_COMPONENT({${textFieldRo.id}})`,\n        textValue: encodeUrlInput,\n        expected: encodeURIComponent(encodeUrlInput),\n      },\n    ];\n\n    it.each(textCases)(\n      'should evaluate $name',\n      async ({ getExpression, expected, name, textValue }) => {\n        const recordTextValue = textValue ?? textInput;\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: numericInput,\n                [textFieldRo.name]: recordTextValue,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `text-${name.toLowerCase().replace(/[^a-z]+/g, '-')}`,\n          type: FieldType.Formula,\n          options: {\n            expression: getExpression(),\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n\n        if (typeof expected === 'number') {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(expected);\n        } else {\n          expect(value ?? null).toEqual(expected);\n        }\n      }\n    );\n\n    it('should encode line breaks in long text with ENCODE_URL_COMPONENT', async () => {\n      const multilineInput = [\n        'Been using Teable lately — honestly impressed @teableio',\n        '\\u00A0',\n        'Scattered work → AI-native system (for projects, CRM & marketing) in minutes 🚀',\n        'teable.ai',\n      ].join('\\n');\n\n      const longTextField = await createField(table1Id, {\n        name: 'long-text-encode-source',\n        type: FieldType.LongText,\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'long-text-encode-result',\n        type: FieldType.Formula,\n        options: {\n          expression: `ENCODE_URL_COMPONENT({${longTextField.id}})`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [longTextField.id]: multilineInput,\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(table1Id, records[0].id);\n      expect(record.data.fields[formulaField.name]).toBe(encodeURIComponent(multilineInput));\n    });\n\n    it('should keep date field time formatting when concatenated with text', async () => {\n      const dateFormatting = {\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.Hour24,\n        timeZone: 'Asia/Shanghai',\n      };\n\n      const dateField = await createField(table1Id, {\n        name: 'formatted-date',\n        type: FieldType.Date,\n        options: {\n          formatting: dateFormatting,\n        },\n      });\n\n      const concatField = await createField(table1Id, {\n        name: 'text-date-concat',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textFieldRo.id}} & ' @ ' & {${dateField.id}}`,\n        },\n      });\n\n      const prefix = 'Kickoff';\n      const sourceIso = '2024-05-06T12:34:56.000Z';\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [textFieldRo.name]: prefix,\n              [dateField.name]: sourceIso,\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(table1Id, records[0].id);\n      expect(record.data.fields[concatField.name]).toBe(`Kickoff @ 2024-05-06 12:34`);\n    });\n\n    it('should evaluate nested FIND formula on select field consistently', async () => {\n      const assignmentField = await createField(table1Id, {\n        name: '归属/对接',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choice-bp', name: 'BP' },\n            { id: 'choice-tyh-1', name: 'TYH①' },\n            { id: 'choice-lwl', name: 'LWL' },\n            { id: 'choice-ella-1', name: 'Ella①' },\n            { id: 'choice-shop-1', name: 'shop①' },\n            { id: 'choice-lwl-plus', name: 'LWL+' },\n            { id: 'choice-ella-1-plus', name: 'Ella①+' },\n            { id: 'choice-shop-1-plus', name: 'shop①+' },\n            { id: 'choice-zjq', name: 'ZJQ' },\n            { id: 'choice-lk', name: 'LK' },\n            { id: 'choice-allen-2', name: 'Allen②' },\n            { id: 'choice-shop-2', name: 'shop②' },\n            { id: 'choice-zjq-plus', name: 'ZJQ+' },\n            { id: 'choice-allen-2-plus', name: 'Allen②+' },\n            { id: 'choice-shop-2-plus', name: 'shop②+' },\n            { id: 'choice-tyh-xf', name: 'TYH XF' },\n            { id: 'choice-tyh', name: 'TYH' },\n            { id: 'choice-xf', name: 'XF' },\n            { id: 'choice-lucy-3', name: 'Lucy③' },\n            { id: 'choice-shop-3', name: 'shop③' },\n            { id: 'choice-tyh-plus', name: 'TYH+' },\n            { id: 'choice-lucy-3-plus', name: 'Lucy③+' },\n            { id: 'choice-shop-3-plus', name: 'shop③+' },\n            { id: 'choice-jn', name: 'JN' },\n            { id: 'choice-jenny-4', name: 'Jenny④' },\n            { id: 'choice-jn-plus', name: 'JN+' },\n            { id: 'choice-jenny-4-plus', name: 'Jenny④+' },\n            { id: 'choice-other', name: 'Other' },\n          ],\n        } as ISelectFieldOptionsRo,\n      });\n\n      const expression = `IF(\n  OR(\n    FIND(\"BP\", {${assignmentField.id}})\n  ),\n  \"Young\",\n  IF(\n    OR(\n      FIND(\"TYH①\", {${assignmentField.id}}),\n      FIND(\"LWL\", {${assignmentField.id}}),\n      FIND(\"Ella①\", {${assignmentField.id}}),\n      FIND(\"shop①\", {${assignmentField.id}}),\n      FIND(\"LWL+\", {${assignmentField.id}}),\n      FIND(\"Ella①+\", {${assignmentField.id}}),\n      FIND(\"shop①+\", {${assignmentField.id}})\n    ),\n    \"Ella\",\n    IF(\n      OR(\n        FIND(\"ZJQ\", {${assignmentField.id}}),\n        FIND(\"LK\", {${assignmentField.id}}),\n        FIND(\"Allen②\", {${assignmentField.id}}),\n        FIND(\"shop②\", {${assignmentField.id}}),\n        FIND(\"ZJQ+\", {${assignmentField.id}}),\n        FIND(\"Allen②+\", {${assignmentField.id}}),\n        FIND(\"shop②+\", {${assignmentField.id}})\n      ),\n      \"Allen\",\n      IF(\n        OR(\n          FIND(\"TYH XF\", {${assignmentField.id}}),\n          FIND(\"TYH\", {${assignmentField.id}}),\n          FIND(\"XF\", {${assignmentField.id}}),\n          FIND(\"Lucy③\", {${assignmentField.id}}),\n          FIND(\"shop③\", {${assignmentField.id}}),\n          FIND(\"TYH+\", {${assignmentField.id}}),\n          FIND(\"Lucy③+\", {${assignmentField.id}}),\n          FIND(\"shop③+\", {${assignmentField.id}})\n        ),\n        \"Lucy\",\n        IF(\n          OR(\n            FIND(\"JN\", {${assignmentField.id}}),\n            FIND(\"Jenny④\", {${assignmentField.id}}),\n            FIND(\"JN+\", {${assignmentField.id}}),\n            FIND(\"Jenny④+\", {${assignmentField.id}})\n          ),\n          \"Jenny\",\n          \"未识别\"\n        )\n      )\n    )\n  )\n)`;\n\n      await convertField(table1Id, formulaFieldRo.id, {\n        type: FieldType.Formula,\n        options: {\n          expression,\n        },\n      });\n\n      const cases: Array<{ value: string; expected: string }> = [\n        { value: 'BP', expected: 'Young' },\n        { value: 'TYH', expected: 'Lucy' },\n        { value: 'TYH XF', expected: 'Lucy' },\n        { value: 'ZJQ+', expected: 'Allen' },\n        { value: 'Jenny④', expected: 'Jenny' },\n        { value: 'Other', expected: '未识别' },\n      ];\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: cases.map(({ value }) => ({\n          fields: {\n            [assignmentField.name]: value,\n          },\n        })),\n      });\n\n      cases.forEach(({ expected }, index) => {\n        expect(records[index].fields[formulaFieldRo.name]).toEqual(expected);\n      });\n    });\n\n    it('should concatenate date and text fields with ampersand', async () => {\n      const followDateField = await createField(table1Id, {\n        name: 'follow date',\n        type: FieldType.Date,\n      } as IFieldRo);\n\n      const followDateValue = '2025-10-24T00:00:00.000Z';\n      const followContentValue = 'hello';\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: numericInput,\n              [textFieldRo.name]: followContentValue,\n              [followDateField.name]: followDateValue,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: 'follow summary',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${followDateField.id}} & \"-\" & {${textFieldRo.id}}`,\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const formulaValue = recordAfterFormula.data.fields[formulaField.name];\n      expect(formulaValue).toBe('2025-10-24 00:00-hello');\n    });\n\n    it('should keep concatenated formula after updating referenced text field', async () => {\n      const followDateField = await createField(table1Id, {\n        name: 'follow date',\n        type: FieldType.Date,\n      } as IFieldRo);\n\n      const followDateValue = '2025-10-24T00:00:00.000Z';\n      const followContentValue = 'hello';\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: numericInput,\n              [textFieldRo.name]: followContentValue,\n              [followDateField.name]: followDateValue,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: 'follow summary',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${followDateField.id}} & \"-\" & {${textFieldRo.id}}`,\n        },\n      });\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'world',\n          },\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const formulaValue = recordAfterFormula.data.fields[formulaField.name];\n      expect(formulaValue).toBe('2025-10-24 00:00-world');\n    });\n\n    it('should flatten multi-value lookup single-select when concatenated', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'lookup-single-select-foreign',\n        fields: [\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { id: 'opt-a', name: 'Alpha' },\n                { id: 'opt-b', name: 'Beta' },\n              ],\n            } as ISelectFieldOptionsRo,\n          } as IFieldRo,\n        ],\n        records: [{ fields: { Status: 'Alpha' } }, { fields: { Status: 'Beta' } }],\n      });\n\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'lookup-single-select-host',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            {\n              name: 'Link',\n              type: FieldType.Link,\n              options: {\n                foreignTableId: foreign.id,\n                relationship: Relationship.ManyMany,\n              } as ILinkFieldOptionsRo,\n            } as IFieldRo,\n          ],\n          records: [{ fields: { Title: 'host row' } }],\n        });\n\n        const statusField = foreign.fields.find((f) => f.name === 'Status')!;\n        const linkField = host.fields.find((f) => f.name === 'Link')!;\n\n        const lookupField = await createField(host.id, {\n          name: 'Status Lookup',\n          type: FieldType.SingleSelect,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: statusField.id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const formulaField = await createField(host.id, {\n          name: 'Status Text',\n          type: FieldType.Formula,\n          options: {\n            expression: `'Statuses: ' & {${lookupField.id}}`,\n          },\n        });\n\n        const hostRecordId = host.records[0].id;\n\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((r) => ({ id: r.id }))\n        );\n\n        const record = await getRecord(host.id, hostRecordId);\n        const lookupValue = record.data.fields[lookupField.name];\n        expect(Array.isArray(lookupValue)).toBe(true);\n        expect(record.data.fields[formulaField.name]).toBe('Statuses: Alpha, Beta');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should flatten link titles when concatenated', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'concat-link-foreign',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Link-A' } }, { fields: { Title: 'Link-B' } }],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'concat-link-host',\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n            {\n              name: 'Links',\n              type: FieldType.Link,\n              options: {\n                foreignTableId: foreign.id,\n                relationship: Relationship.ManyMany,\n              } as ILinkFieldOptionsRo,\n            } as IFieldRo,\n          ],\n          records: [{ fields: { Title: 'host row' } }],\n        });\n\n        const linkField = host.fields.find((f) => f.name === 'Links')!;\n\n        const formulaField = await createField(host.id, {\n          name: 'Links Text',\n          type: FieldType.Formula,\n          options: {\n            expression: `'Links: ' & {${linkField.id}}`,\n          },\n        });\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((r) => ({ id: r.id }))\n        );\n\n        const record = await getRecord(host.id, hostRecordId);\n        expect(record.data.fields[linkField.name]).toHaveLength(2);\n        expect(record.data.fields[formulaField.name]).toBe('Links: Link-A, Link-B');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should normalize lookup link titles when used in formula', async () => {\n      const assets = await createTable(baseId, {\n        name: 'formula-lookup-link-assets',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Alpha' } }, { fields: { Title: 'Beta' } }],\n      });\n      let owners: ITableFullVo | undefined;\n      let requests: ITableFullVo | undefined;\n      try {\n        owners = await createTable(baseId, {\n          name: 'formula-lookup-link-owners',\n          fields: [{ name: 'Owner', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Owner: 'Owner A' } }],\n        });\n\n        const ownerAssetsLink = await createField(owners.id, {\n          name: 'Assets',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: assets.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        await updateRecordByApi(\n          owners.id,\n          owners.records[0].id,\n          ownerAssetsLink.id,\n          assets.records.map((record) => ({ id: record.id }))\n        );\n\n        requests = await createTable(baseId, {\n          name: 'formula-lookup-link-requests',\n          fields: [{ name: 'Request', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Request: 'Req-1' } }],\n        });\n\n        const requestOwnerLink = await createField(requests.id, {\n          name: 'Owner Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: owners.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        await updateRecordByApi(requests.id, requests.records[0].id, requestOwnerLink.id, {\n          id: owners.records[0].id,\n        });\n\n        const ownerAssetsLookup = await createField(requests.id, {\n          name: 'Owner Assets Lookup',\n          type: FieldType.Link,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: owners.id,\n            linkFieldId: requestOwnerLink.id,\n            lookupFieldId: ownerAssetsLink.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const formulaField = await createField(requests.id, {\n          name: 'Assets Text',\n          type: FieldType.Formula,\n          options: {\n            expression: `'Assets: ' & {${ownerAssetsLookup.id}}`,\n          },\n        } as IFieldRo);\n\n        const record = await getRecord(requests.id, requests.records[0].id);\n        const formulaValue = record.data.fields[formulaField.name] as string;\n        expect(formulaValue.startsWith('Assets: ')).toBe(true);\n        expect(formulaValue).toContain('Alpha');\n        expect(formulaValue).toContain('Beta');\n        expect(formulaValue).not.toContain('\"id\"');\n      } finally {\n        if (requests) {\n          await permanentDeleteTable(baseId, requests.id);\n        }\n        if (owners) {\n          await permanentDeleteTable(baseId, owners.id);\n        }\n        await permanentDeleteTable(baseId, assets.id);\n      }\n    });\n\n    it('should return title arrays when formula directly references a lookup link field', async () => {\n      const assets = await createTable(baseId, {\n        name: 'formula-direct-lookup-link-assets',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Alpha' } }, { fields: { Title: 'Beta' } }],\n      });\n      let owners: ITableFullVo | undefined;\n      let requests: ITableFullVo | undefined;\n      try {\n        owners = await createTable(baseId, {\n          name: 'formula-direct-lookup-link-owners',\n          fields: [{ name: 'Owner', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Owner: 'Owner A' } }],\n        });\n\n        const ownerAssetsLink = await createField(owners.id, {\n          name: 'Assets',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: assets.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        await updateRecordByApi(\n          owners.id,\n          owners.records[0].id,\n          ownerAssetsLink.id,\n          assets.records.map((record) => ({ id: record.id }))\n        );\n\n        requests = await createTable(baseId, {\n          name: 'formula-direct-lookup-link-requests',\n          fields: [{ name: 'Request', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Request: 'Req-1' } }],\n        });\n\n        const requestOwnerLink = await createField(requests.id, {\n          name: 'Owner Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: owners.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        await updateRecordByApi(requests.id, requests.records[0].id, requestOwnerLink.id, {\n          id: owners.records[0].id,\n        });\n\n        const ownerAssetsLookup = await createField(requests.id, {\n          name: 'Owner Assets Lookup',\n          type: FieldType.Link,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: owners.id,\n            linkFieldId: requestOwnerLink.id,\n            lookupFieldId: ownerAssetsLink.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const formulaField = await createField(requests.id, {\n          name: 'Assets Titles',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${ownerAssetsLookup.id}}`,\n          },\n        } as IFieldRo);\n\n        const record = await getRecord(requests.id, requests.records[0].id);\n        expect(record.data.fields[formulaField.name]).toEqual(['Alpha', 'Beta']);\n      } finally {\n        if (requests) {\n          await permanentDeleteTable(baseId, requests.id);\n        }\n        if (owners) {\n          await permanentDeleteTable(baseId, owners.id);\n        }\n        await permanentDeleteTable(baseId, assets.id);\n      }\n    });\n\n    it('should apply LEFT/RIGHT to lookup fields', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-left-foreign',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'AlphaBeta' } }],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-left-host',\n          fields: [\n            { name: 'Note', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'Left Count', type: FieldType.Number } as IFieldRo,\n            { name: 'Right Count', type: FieldType.Number } as IFieldRo,\n          ],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Foreign Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const foreignTitleFieldId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const lookupField = await createField(host.id, {\n          name: 'Linked Title',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: foreignTitleFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const leftCountFieldId = host.fields.find((field) => field.name === 'Left Count')!.id;\n        const rightCountFieldId = host.fields.find((field) => field.name === 'Right Count')!.id;\n\n        const { records } = await createRecords(host.id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                Note: 'host note',\n                'Left Count': 3,\n                'Right Count': 4,\n              },\n            },\n          ],\n        });\n        const hostRecordId = records[0].id;\n\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, {\n          id: foreign.records[0].id,\n        });\n\n        const leftFormula = await createField(host.id, {\n          name: 'lookup-left',\n          type: FieldType.Formula,\n          options: {\n            expression: `LEFT({${lookupField.id}}, {${leftCountFieldId}})`,\n          },\n        });\n\n        const rightFormula = await createField(host.id, {\n          name: 'lookup-right',\n          type: FieldType.Formula,\n          options: {\n            expression: `RIGHT({${lookupField.id}}, {${rightCountFieldId}})`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(host.id, hostRecordId);\n        expect(recordAfterFormula.data.fields[leftFormula.name]).toEqual('Alp');\n        expect(recordAfterFormula.data.fields[rightFormula.name]).toEqual('Beta');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should treat lookup user value as truthy in IF', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-user-foreign',\n        fields: [\n          { name: 'Asset Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Owner',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              'Asset Title': 'Laptop',\n              Owner: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-user-host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'row 1' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Owner Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const ownerFieldId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Owner Lookup',\n          type: FieldType.User,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: ownerFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const statusField = await createField(host.id, {\n          name: 'Owner Status',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${lookupField.id}}, '▶️ 在用', '✅ 闲置')`,\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, { id: foreign.records[0].id });\n\n        const linkedRecord = await getRecord(host.id, hostRecordId);\n        expect(linkedRecord.data.fields[statusField.name]).toBe('▶️ 在用');\n\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, null);\n\n        const clearedRecord = await getRecord(host.id, hostRecordId);\n        expect(clearedRecord.data.fields[statusField.name]).toBe('✅ 闲置');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should treat empty conditional lookup user as falsy in IF', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'conditional-lookup-user-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Owner',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Unavailable asset',\n              Status: 'Inactive',\n              Owner: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'conditional-lookup-user-host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'row 1' } }],\n        });\n\n        const ownerFieldId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n        const statusFieldId = foreign.fields.find((field) => field.name === 'Status')!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Filtered Owner',\n          type: FieldType.User,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: ownerFieldId,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusFieldId,\n                  operator: 'is',\n                  value: 'Active',\n                },\n              ],\n            },\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const statusField = await createField(host.id, {\n          name: 'Filtered Owner Status',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${lookupField.id}}, '▶️ 在用', '✅ 闲置')`,\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        const record = await getRecord(host.id, hostRecordId);\n        const lookupValue = record.data.fields[lookupField.name];\n\n        expect(\n          lookupValue == null || (Array.isArray(lookupValue) && lookupValue.length === 0)\n        ).toBe(true);\n        expect(record.data.fields[statusField.name]).toBe('✅ 闲置');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should evaluate IF for multi-value lookup user when links are empty', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'multi-lookup-user-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Owner',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Shared asset',\n              Owner: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'multi-lookup-user-host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'row 1' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Owners Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const ownerFieldId = foreign.fields.find((field) => field.name === 'Owner')!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Owners Lookup',\n          type: FieldType.User,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: ownerFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const statusField = await createField(host.id, {\n          name: 'Owners Status',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${lookupField.id}}, '▶️ 在用', '✅ 闲置')`,\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        const initialRecord = await getRecord(host.id, hostRecordId);\n        expect(initialRecord.data.fields[lookupField.name]).toBeUndefined();\n        expect(initialRecord.data.fields[statusField.name]).toBe('✅ 闲置');\n\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, [\n          { id: foreign.records[0].id },\n        ]);\n\n        const linkedRecord = await getRecord(host.id, hostRecordId);\n        expect(linkedRecord.data.fields[lookupField.name]).toHaveLength(1);\n        expect(linkedRecord.data.fields[statusField.name]).toBe('▶️ 在用');\n\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, null);\n        const clearedRecord = await getRecord(host.id, hostRecordId);\n        const clearedLookup = clearedRecord.data.fields[lookupField.name];\n        expect(\n          clearedLookup == null || (Array.isArray(clearedLookup) && clearedLookup.length === 0)\n        ).toBe(true);\n        expect(clearedRecord.data.fields[statusField.name]).toBe('✅ 闲置');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should treat nested conditional lookup arrays as falsy in IF', async () => {\n      const source = await createTable(baseId, {\n        name: 'nested-lookup-source',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Owner',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'source',\n              Status: 'Inactive',\n              Owner: {\n                id: globalThis.testConfig.userId,\n                title: globalThis.testConfig.userName,\n                email: globalThis.testConfig.email,\n              },\n            },\n          },\n        ],\n      });\n\n      let middle: ITableFullVo | undefined;\n      let host: ITableFullVo | undefined;\n      try {\n        middle = await createTable(baseId, {\n          name: 'nested-lookup-middle',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'middle' } }],\n        });\n\n        const sourceOwnerFieldId = source.fields.find((field) => field.name === 'Owner')!.id;\n        const sourceStatusFieldId = source.fields.find((field) => field.name === 'Status')!.id;\n\n        const activeOwner = await createField(middle.id, {\n          name: 'Active Owner',\n          type: FieldType.User,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: source.id,\n            lookupFieldId: sourceOwnerFieldId,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: sourceStatusFieldId,\n                  operator: 'is',\n                  value: 'Active',\n                },\n              ],\n            },\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        host = await createTable(baseId, {\n          name: 'nested-lookup-host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Label: 'host' } }],\n        });\n\n        const middleLabelId = middle.fields.find((field) => field.name === 'Label')!.id;\n\n        const nestedLookup = await createField(host.id, {\n          name: 'Nested Active Owner',\n          type: FieldType.User,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: middle.id,\n            lookupFieldId: activeOwner.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: middleLabelId,\n                  operator: 'is',\n                  value: 'middle',\n                },\n              ],\n            },\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const statusField = await createField(host.id, {\n          name: 'Nested Owner Status',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${nestedLookup.id}}, '▶️ 在用', '✅ 闲置')`,\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        const hostLabelFieldId = host.fields.find((field) => field.name === 'Label')!.id;\n        await updateRecordByApi(host.id, hostRecordId, hostLabelFieldId, 'host');\n\n        const record = await getRecord(host.id, hostRecordId);\n\n        const nestedValue = record.data.fields[nestedLookup.name];\n\n        expect(\n          nestedValue == null || (Array.isArray(nestedValue) && nestedValue.length === 0)\n        ).toBe(true);\n        expect(record.data.fields[statusField.name]).toBe('✅ 闲置');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        if (middle) {\n          await permanentDeleteTable(baseId, middle.id);\n        }\n        await permanentDeleteTable(baseId, source.id);\n      }\n    });\n\n    it('should return user lookup with empty filter target and drive IF truthiness', async () => {\n      const applicant = {\n        id: globalThis.testConfig.userId,\n        title: globalThis.testConfig.userName,\n        email: globalThis.testConfig.email,\n      };\n\n      const foreign = await createTable(baseId, {\n        name: 'lookup-filter-foreign',\n        fields: [\n          { name: 'Request No', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Return Date', type: FieldType.Date } as IFieldRo,\n          {\n            name: 'Applicant',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              'Request No': 'AP-null',\n              'Return Date': null,\n              Applicant: applicant,\n            },\n          },\n          {\n            fields: {\n              'Request No': 'AP-returned',\n              'Return Date': '2024-10-20T00:00:00.000Z',\n              Applicant: applicant,\n            },\n          },\n        ],\n      });\n\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'lookup-filter-host',\n          fields: [{ name: 'Asset', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Asset: 'A-null' } }, { fields: { Asset: 'A-returned' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Usage Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const returnFieldId = foreign.fields.find((f) => f.name === 'Return Date')!.id;\n        const applicantFieldId = foreign.fields.find((f) => f.name === 'Applicant')!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Active Applicant',\n          type: FieldType.User,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: applicantFieldId,\n            linkFieldId: linkField.id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: returnFieldId,\n                  operator: 'isEmpty',\n                  value: null,\n                },\n              ],\n            },\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const statusField = await createField(host.id, {\n          name: 'Active Status',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${lookupField.id}}, '▶️ 在用', '✅ 闲置')`,\n          },\n        } as IFieldRo);\n\n        const [assetNull, assetReturned] = host.records;\n\n        await updateRecordByApi(host.id, assetNull.id, linkField.id, { id: foreign.records[0].id });\n        await updateRecordByApi(host.id, assetReturned.id, linkField.id, {\n          id: foreign.records[1].id,\n        });\n\n        const recordNull = await getRecord(host.id, assetNull.id);\n        const recordReturned = await getRecord(host.id, assetReturned.id);\n\n        expect(recordNull.data.fields[lookupField.name]).toMatchObject(applicant);\n        expect(recordNull.data.fields[statusField.name]).toBe('▶️ 在用');\n\n        expect(recordReturned.data.fields[lookupField.name]).toBeUndefined();\n        expect(recordReturned.data.fields[statusField.name]).toBe('✅ 闲置');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should resolve filtered lookup user only when return link is empty', async () => {\n      const applicant = {\n        id: globalThis.testConfig.userId,\n        title: globalThis.testConfig.userName,\n        email: globalThis.testConfig.email,\n      };\n\n      const returnTable = await createTable(baseId, {\n        name: 'return-records',\n        fields: [{ name: 'Return ID', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { 'Return ID': 'RB-001' } }, { fields: { 'Return ID': 'RB-002' } }],\n      });\n\n      const usageTable = await createTable(baseId, {\n        name: 'usage-records',\n        fields: [\n          { name: 'Request No', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Applicant',\n            type: FieldType.User,\n            options: { isMultiple: false, shouldNotify: false },\n          } as IFieldRo,\n          {\n            name: 'Return Link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: returnTable.id,\n            } as ILinkFieldOptionsRo,\n          } as IFieldRo,\n        ],\n      });\n\n      const returnLinkFieldId = usageTable.fields.find((f) => f.name === 'Return Link')!.id;\n      const applicantFieldId = usageTable.fields.find((f) => f.name === 'Applicant')!.id;\n\n      const { records: usageRecords } = await createRecords(usageTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              'Request No': 'AP-returned',\n              Applicant: applicant,\n            },\n          },\n          {\n            fields: {\n              'Request No': 'AP-active',\n              Applicant: applicant,\n            },\n          },\n        ],\n      });\n\n      await updateRecordByApi(usageTable.id, usageRecords[0].id, returnLinkFieldId, {\n        id: returnTable.records[0].id,\n      });\n      await updateRecordByApi(usageTable.id, usageRecords[1].id, returnLinkFieldId, null);\n\n      let assetTable: ITableFullVo | undefined;\n      try {\n        assetTable = await createTable(baseId, {\n          name: 'asset-info',\n          fields: [\n            { name: 'Asset Code', type: FieldType.SingleLineText } as IFieldRo,\n            {\n              name: 'Usage Link',\n              type: FieldType.Link,\n              options: {\n                relationship: Relationship.ManyOne,\n                foreignTableId: usageTable.id,\n              } as ILinkFieldOptionsRo,\n            } as IFieldRo,\n          ],\n          records: [\n            { fields: { 'Asset Code': 'A-returned' } },\n            { fields: { 'Asset Code': 'A-active' } },\n          ],\n        });\n\n        const usageLinkFieldId = assetTable.fields.find((f) => f.name === 'Usage Link')!.id;\n\n        const lookupField = await createField(assetTable.id, {\n          name: 'Filtered User',\n          type: FieldType.User,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: usageTable.id,\n            lookupFieldId: applicantFieldId,\n            linkFieldId: usageLinkFieldId,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: returnLinkFieldId,\n                  operator: 'isEmpty',\n                  value: null,\n                },\n              ],\n            },\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        await updateRecordByApi(assetTable.id, assetTable.records[0].id, usageLinkFieldId, {\n          id: usageRecords[0].id,\n        });\n        await updateRecordByApi(assetTable.id, assetTable.records[1].id, usageLinkFieldId, {\n          id: usageRecords[1].id,\n        });\n\n        const returnedAsset = await getRecord(assetTable.id, assetTable.records[0].id);\n        const activeAsset = await getRecord(assetTable.id, assetTable.records[1].id);\n\n        expect(returnedAsset.data.fields[lookupField.name]).toBeUndefined();\n        expect(activeAsset.data.fields[lookupField.name]).toMatchObject(applicant);\n      } finally {\n        if (assetTable) {\n          await permanentDeleteTable(baseId, assetTable.id);\n        }\n        await permanentDeleteTable(baseId, usageTable.id);\n        await permanentDeleteTable(baseId, returnTable.id);\n      }\n    });\n\n    it('should flatten multi-value lookup formulas returning scalar text', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-flatten-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Scheduled', type: FieldType.Date } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Task A', Scheduled: '2025-10-31T08:10:24.894Z' } },\n          { fields: { Title: 'Task B', Scheduled: '2025-11-05T10:00:00.000Z' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        const scheduledFieldId = foreign.fields.find((field) => field.name === 'Scheduled')!.id;\n        const taggedFormula = await createField(foreign.id, {\n          name: 'Schedule Tag',\n          type: FieldType.Formula,\n          options: {\n            expression: `CONCATENATE(DATETIME_FORMAT({${scheduledFieldId}}, 'YYYY-MM-DD'), \"-tag\")`,\n          },\n        });\n\n        host = await createTable(baseId, {\n          name: 'formula-lookup-flatten-host',\n          fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Project: 'Main' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Related Tasks',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const lookupField = await createField(host.id, {\n          name: 'Tagged Schedules',\n          type: FieldType.Formula,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: taggedFormula.id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((record) => ({ id: record.id }))\n        );\n\n        const updatedRecord = await getRecord(host.id, hostRecordId);\n        expect(updatedRecord.data.fields[lookupField.name]).toEqual([\n          '2025-10-31-tag',\n          '2025-11-05-tag',\n        ]);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should format multi-value lookup dates with DATETIME_FORMAT', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-datetime-format-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Milestone Date', type: FieldType.Date } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', 'Milestone Date': '2023-10-11T16:00:00.000Z' } },\n          { fields: { Title: 'Beta', 'Milestone Date': '2023-10-11T16:00:00.000Z' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-datetime-format-host',\n          fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Project: 'Lookup timeline' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Related Milestones',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const milestoneDateFieldId = foreign.fields.find(\n          (field) => field.name === 'Milestone Date'\n        )!.id;\n\n        const lookupField = await createField(host.id, {\n          name: 'Milestone Dates',\n          type: FieldType.Date,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: milestoneDateFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        } as IFieldRo);\n\n        const formattedField = await createField(host.id, {\n          name: 'Milestone Day',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATETIME_FORMAT({${lookupField.id}}, 'DD')`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((record) => ({ id: record.id }))\n        );\n\n        const updatedRecord = await getRecord(host.id, hostRecordId);\n        expect(updatedRecord.data.fields[formattedField.name]).toEqual('12, 12');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    }, 120000);\n\n    it('applies timezone-aware formatting before slicing datetime values', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-datetime-slice-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Approval Date',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: DateFormattingPreset.ISO,\n                time: TimeFormatting.None,\n                timeZone: 'Asia/Shanghai',\n              },\n            },\n          } as IFieldRo,\n        ],\n        records: [{ fields: { Title: 'Milestone', 'Approval Date': '2023-02-25T16:00:00.000Z' } }],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        const approvalFieldId = foreign.fields.find((field) => field.name === 'Approval Date')!.id;\n\n        const directLeftField = await createField(foreign.id, {\n          name: 'Approval Left',\n          type: FieldType.Formula,\n          options: {\n            expression: `LEFT({${approvalFieldId}}, 4)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const directMidField = await createField(foreign.id, {\n          name: 'Approval Mid',\n          type: FieldType.Formula,\n          options: {\n            expression: `MID({${approvalFieldId}}, 6, 2)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const directSearchField = await createField(foreign.id, {\n          name: 'Approval Search',\n          type: FieldType.Formula,\n          options: {\n            expression: `SEARCH(\"02\", {${approvalFieldId}})`,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const directSliceField = await createField(foreign.id, {\n          name: 'Approval Day Tail',\n          type: FieldType.Formula,\n          options: {\n            expression: `RIGHT({${approvalFieldId}}, 2)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        });\n\n        const directRecord = await getRecord(foreign.id, foreign.records[0].id);\n        expect(directRecord.data.fields[directSliceField.name]).toBe('26');\n        expect(directRecord.data.fields[directLeftField.name]).toBe('2023');\n        expect(directRecord.data.fields[directMidField.name]).toBe('02');\n        expect(directRecord.data.fields[directSearchField.name]).toBeGreaterThan(0);\n\n        host = await createTable(baseId, {\n          name: 'formula-datetime-slice-host',\n          fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Project: 'Lookup slice' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Related Approval',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const lookupField = await createField(host.id, {\n          name: 'Approval Lookup',\n          type: FieldType.Date,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: approvalFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        } as IFieldRo);\n\n        const lookupLeftField = await createField(host.id, {\n          name: 'Approval Lookup Left',\n          type: FieldType.Formula,\n          options: {\n            expression: `LEFT({${lookupField.id}}, 4)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const lookupMidField = await createField(host.id, {\n          name: 'Approval Lookup Mid',\n          type: FieldType.Formula,\n          options: {\n            expression: `MID({${lookupField.id}}, 6, 2)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const lookupSearchField = await createField(host.id, {\n          name: 'Approval Lookup Search',\n          type: FieldType.Formula,\n          options: {\n            expression: `SEARCH(\"02\", {${lookupField.id}})`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const lookupSliceField = await createField(host.id, {\n          name: 'Approval Lookup Day Tail',\n          type: FieldType.Formula,\n          options: {\n            expression: `RIGHT({${lookupField.id}}, 2)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, [\n          { id: foreign.records[0].id },\n        ]);\n\n        const lookupRecord = await getRecord(host.id, hostRecordId);\n        expect(lookupRecord.data.fields[lookupSliceField.name]).toBe('26');\n        expect(lookupRecord.data.fields[lookupLeftField.name]).toBe('2023');\n        expect(lookupRecord.data.fields[lookupMidField.name]).toBe('02');\n        expect(lookupRecord.data.fields[lookupSearchField.name]).toBeGreaterThan(0);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('applies timezone-aware slicing on multi-value lookup datetimes', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-datetime-slice-multi-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Milestone',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: DateFormattingPreset.ISO,\n                time: TimeFormatting.None,\n                timeZone: 'Asia/Shanghai',\n              },\n            },\n          } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'A', Milestone: '2023-02-25T16:00:00.000Z' } },\n          { fields: { Title: 'B', Milestone: '2023-03-01T16:00:00.000Z' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        const milestoneFieldId = foreign.fields.find((field) => field.name === 'Milestone')!.id;\n\n        host = await createTable(baseId, {\n          name: 'formula-datetime-slice-multi-host',\n          fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Project: 'Lookup slice multi' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Related Milestones',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const lookupField = await createField(host.id, {\n          name: 'Milestone Dates Lookup',\n          type: FieldType.Date,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: milestoneFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'Asia/Shanghai',\n            },\n          },\n        } as IFieldRo);\n\n        const sliceField = await createField(host.id, {\n          name: 'Milestone Slice',\n          type: FieldType.Formula,\n          options: {\n            expression: `MID({${lookupField.id}}, 3, 4)`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, [\n          { id: foreign.records[0].id },\n          { id: foreign.records[1].id },\n        ]);\n\n        const lookupRecord = await getRecord(host.id, hostRecordId);\n        expect(lookupRecord.data.fields[sliceField.name]).toBe('23-0');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should format multi-value lookup numbers with VALUE', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-value-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Budget', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Phase A', Budget: 1200.45 } },\n          { fields: { Title: 'Phase B', Budget: 3400.51 } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-value-host',\n          fields: [{ name: 'Project', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Project: 'Budget run' } }],\n        });\n\n        const budgetFieldId = foreign.fields.find((field) => field.name === 'Budget')!.id;\n        const linkFieldId = generateFieldId();\n        const lookupFieldId = generateFieldId();\n        const formattedFieldId = generateFieldId();\n        const roundedFieldId = generateFieldId();\n        const roundUpFieldId = generateFieldId();\n        const roundDownFieldId = generateFieldId();\n        const floorFieldId = generateFieldId();\n        const ceilingFieldId = generateFieldId();\n        const intFieldId = generateFieldId();\n        const formulaFieldRos = [\n          {\n            id: formattedFieldId,\n            name: 'Budget Value Formula',\n            type: FieldType.Formula,\n            options: {\n              expression: `VALUE({${lookupFieldId}}) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: roundedFieldId,\n            name: 'Budget Rounded',\n            type: FieldType.Formula,\n            options: {\n              expression: `ROUND({${lookupFieldId}}, 0) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: roundUpFieldId,\n            name: 'Budget RoundUp',\n            type: FieldType.Formula,\n            options: {\n              expression: `ROUNDUP({${lookupFieldId}}, 0) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: roundDownFieldId,\n            name: 'Budget RoundDown',\n            type: FieldType.Formula,\n            options: {\n              expression: `ROUNDDOWN({${lookupFieldId}}, 0) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: floorFieldId,\n            name: 'Budget Floor',\n            type: FieldType.Formula,\n            options: {\n              expression: `FLOOR({${lookupFieldId}}) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: ceilingFieldId,\n            name: 'Budget Ceiling',\n            type: FieldType.Formula,\n            options: {\n              expression: `CEILING({${lookupFieldId}}) & ''`,\n            },\n          } as IFieldRo,\n          {\n            id: intFieldId,\n            name: 'Budget Int',\n            type: FieldType.Formula,\n            options: {\n              expression: `INT({${lookupFieldId}}) & ''`,\n            },\n          } as IFieldRo,\n        ];\n\n        const createFormulaFieldRos = (resolvedLookupFieldId: string) =>\n          formulaFieldRos.map((field) => ({\n            ...field,\n            options: {\n              expression: field.options!.expression.replaceAll(\n                lookupFieldId,\n                resolvedLookupFieldId\n              ),\n            },\n          })) as IFieldRo[];\n\n        let linkField;\n        let lookupField;\n        let formattedField;\n        let roundedField;\n        let roundUpField;\n        let roundDownField;\n        let floorField;\n        let ceilingField;\n        let intField;\n\n        if (useV2BatchCreate) {\n          linkField = await createField(host.id, {\n            id: linkFieldId,\n            name: 'Related Budgets',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: foreign.id,\n            } as ILinkFieldOptionsRo,\n          } as IFieldRo);\n\n          lookupField = await createField(host.id, {\n            id: lookupFieldId,\n            name: 'Budget Lookup',\n            type: FieldType.Number,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: foreign.id,\n              lookupFieldId: budgetFieldId,\n              linkFieldId: linkField.id,\n            } as ILookupOptionsRo,\n          } as IFieldRo);\n\n          const resolvedFormulaFieldRos = createFormulaFieldRos(lookupField.id);\n          const createdFields = [\n            ...(await createFields(host.id, resolvedFormulaFieldRos.slice(0, 4), app)),\n            ...(await createFields(host.id, resolvedFormulaFieldRos.slice(4), app)),\n          ];\n\n          const createdFieldsById = new Map(createdFields.map((field) => [field.id, field]));\n          formattedField = createdFieldsById.get(formattedFieldId)!;\n          roundedField = createdFieldsById.get(roundedFieldId)!;\n          roundUpField = createdFieldsById.get(roundUpFieldId)!;\n          roundDownField = createdFieldsById.get(roundDownFieldId)!;\n          floorField = createdFieldsById.get(floorFieldId)!;\n          ceilingField = createdFieldsById.get(ceilingFieldId)!;\n          intField = createdFieldsById.get(intFieldId)!;\n        } else {\n          linkField = await createField(host.id, {\n            id: linkFieldId,\n            name: 'Related Budgets',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: foreign.id,\n            } as ILinkFieldOptionsRo,\n          } as IFieldRo);\n\n          lookupField = await createField(host.id, {\n            id: lookupFieldId,\n            name: 'Budget Lookup',\n            type: FieldType.Number,\n            isLookup: true,\n            lookupOptions: {\n              foreignTableId: foreign.id,\n              lookupFieldId: budgetFieldId,\n              linkFieldId: linkField.id,\n            } as ILookupOptionsRo,\n          } as IFieldRo);\n\n          const resolvedFormulaFieldRos = createFormulaFieldRos(lookupField.id);\n          formattedField = await createField(host.id, resolvedFormulaFieldRos[0]);\n          roundedField = await createField(host.id, resolvedFormulaFieldRos[1]);\n          roundUpField = await createField(host.id, resolvedFormulaFieldRos[2]);\n          roundDownField = await createField(host.id, resolvedFormulaFieldRos[3]);\n          floorField = await createField(host.id, resolvedFormulaFieldRos[4]);\n          ceilingField = await createField(host.id, resolvedFormulaFieldRos[5]);\n          intField = await createField(host.id, resolvedFormulaFieldRos[6]);\n        }\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((record) => ({ id: record.id }))\n        );\n\n        const updatedRecord = await getRecord(host.id, hostRecordId);\n        expect(updatedRecord.data.fields[formattedField.name]).toEqual('1200.45, 3400.51');\n        expect(updatedRecord.data.fields[roundedField.name]).toEqual('1200, 3401');\n        expect(updatedRecord.data.fields[roundUpField.name]).toEqual('1201, 3401');\n        expect(updatedRecord.data.fields[roundDownField.name]).toEqual('1200, 3400');\n        expect(updatedRecord.data.fields[floorField.name]).toEqual('1200, 3400');\n        expect(updatedRecord.data.fields[ceilingField.name]).toEqual('1201, 3401');\n        expect(updatedRecord.data.fields[intField.name]).toEqual('1200, 3400');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    }, 60000);\n\n    it('should evaluate formulas referencing lookup formulas', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-formula-foreign',\n        fields: [\n          { name: 'First Name', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Last Name', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              'First Name': 'Ada',\n              'Last Name': 'Lovelace',\n            },\n          },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-formula-host',\n          fields: [{ name: 'Note', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Note: 'host note' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Linked Person',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const firstNameFieldId = foreign.fields.find((field) => field.name === 'First Name')!.id;\n        const lastNameFieldId = foreign.fields.find((field) => field.name === 'Last Name')!.id;\n        const fullNameFormula = await createField(foreign.id, {\n          name: 'Full Name',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${firstNameFieldId}} & \"-\" & {${lastNameFieldId}}`,\n          },\n        } as IFieldRo);\n\n        const lookupField = await createField(host.id, {\n          name: 'Full Name Lookup',\n          type: FieldType.Formula,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: fullNameFormula.id,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, {\n          id: foreign.records[0].id,\n        });\n\n        const hostFormula = await createField(host.id, {\n          name: 'Greeting',\n          type: FieldType.Formula,\n          options: {\n            expression: `CONCATENATE({${lookupField.id}}, \"!\")`,\n          },\n        } as IFieldRo);\n\n        const recordAfter = await getRecord(host.id, hostRecordId);\n        expect(recordAfter.data.fields[lookupField.name]).toBe('Ada-Lovelace');\n        expect(recordAfter.data.fields[hostFormula.name]).toBe('Ada-Lovelace!');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    }, 120000);\n\n    it('should calculate numeric formulas using lookup fields', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-lookup-numeric-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Total Units', type: FieldType.Number } as IFieldRo,\n          { name: 'Completed Units', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', 'Total Units': 12, 'Completed Units': 5 } },\n          { fields: { Title: 'Beta', 'Total Units': 20, 'Completed Units': 3 } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-lookup-numeric-host',\n          fields: [{ name: 'Note', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Note: 'host note' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Numeric Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const totalFieldId = foreign.fields.find((field) => field.name === 'Total Units')!.id;\n        const completedFieldId = foreign.fields.find(\n          (field) => field.name === 'Completed Units'\n        )!.id;\n\n        const totalLookup = await createField(host.id, {\n          name: 'Total Units Lookup',\n          type: FieldType.Number,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: totalFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const completedLookup = await createField(host.id, {\n          name: 'Completed Units Lookup',\n          type: FieldType.Number,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: completedFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(host.id, hostRecordId, linkField.id, {\n          id: foreign.records[0].id,\n        });\n\n        const formulaField = await createField(host.id, {\n          name: 'Remaining Units',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${totalLookup.id}} - {${completedLookup.id}}`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(host.id, hostRecordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        expect(typeof value).toBe('number');\n        expect(value).toBe(7);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should format lookup-to-link titles with DATETIME_FORMAT results', async () => {\n      const detailTitle = 'Example Asset';\n      const dateValue = '2025-03-14T00:00:00.000Z';\n      const detailTable = await createTable(baseId, {\n        name: 'Lookup Details',\n        fields: [{ name: 'Detail Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { 'Detail Title': detailTitle } }],\n      });\n      let platformTable: ITableFullVo | undefined;\n      let summaryTable: ITableFullVo | undefined;\n      try {\n        platformTable = await createTable(baseId, {\n          name: 'Link Layer',\n          fields: [{ name: 'Link Name', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { 'Link Name': 'Platform Alpha' } }],\n        });\n        summaryTable = await createTable(baseId, {\n          name: 'Aggregated Reports',\n          fields: [{ name: 'Report Date', type: FieldType.Date } as IFieldRo],\n          records: [{ fields: { 'Report Date': dateValue } }],\n        });\n\n        const platformToDetail = await createField(platformTable.id, {\n          name: 'Linked Detail',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: detailTable.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n        const reportToPlatform = await createField(summaryTable.id, {\n          name: 'Linked Platform',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: platformTable.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const lookupField = await createField(summaryTable.id, {\n          name: 'Platform Detail Lookup',\n          type: FieldType.Link,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: platformTable.id,\n            linkFieldId: reportToPlatform.id,\n            lookupFieldId: platformToDetail.id,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n        const dateFieldId = summaryTable.fields.find((f) => f.name === 'Report Date')!.id;\n        const labelField = await createField(summaryTable.id, {\n          name: 'Label',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${lookupField.id}} & '-' & DATETIME_FORMAT({${dateFieldId}}, \"YY-MM-DD\")`,\n          },\n        } as IFieldRo);\n\n        await updateRecordByApi(\n          platformTable.id,\n          platformTable.records[0].id,\n          platformToDetail.id,\n          [{ id: detailTable.records[0].id }]\n        );\n        await updateRecordByApi(summaryTable.id, summaryTable.records[0].id, reportToPlatform.id, {\n          id: platformTable.records[0].id,\n        });\n\n        const { data: record } = await getRecord(summaryTable.id, summaryTable.records[0].id);\n        const lookupValue = record.fields[lookupField.name] as Array<{ title: string }>;\n        expect(lookupValue).toHaveLength(1);\n        expect(lookupValue?.[0]?.title).toBe(detailTitle);\n        expect(record.fields[labelField.name]).toBe('Example Asset-25-03-14');\n      } finally {\n        if (summaryTable) {\n          await permanentDeleteTable(baseId, summaryTable.id);\n        }\n        if (platformTable) {\n          await permanentDeleteTable(baseId, platformTable.id);\n        }\n        await permanentDeleteTable(baseId, detailTable.id);\n      }\n    });\n\n    it('should keep concatenated formula after updating referenced date field', async () => {\n      const followDateField = await createField(table1Id, {\n        name: 'follow date',\n        type: FieldType.Date,\n      } as IFieldRo);\n\n      const followDateValue = '2025-10-24T00:00:00.000Z';\n      const followContentValue = 'hello';\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: numericInput,\n              [textFieldRo.name]: followContentValue,\n              [followDateField.name]: followDateValue,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: 'follow summary',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${followDateField.id}} & \"-\" & {${textFieldRo.id}}`,\n        },\n      });\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [followDateField.name]: '2025-10-26T00:00:00.000Z',\n          },\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const formulaValue = recordAfterFormula.data.fields[formulaField.name];\n      expect(formulaValue).toBe('2025-10-26 00:00-hello');\n    });\n  });\n\n  describe('logical and system formula functions', () => {\n    const numericInput = 12.345;\n    const textInput = 'Teable Rocks';\n\n    const logicalCases = [\n      {\n        name: 'IF',\n        getExpression: () => `IF({${numberFieldRo.id}} > 10, \"over\", \"under\")`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => 'over' as const,\n      },\n      {\n        name: 'SWITCH',\n        getExpression: () => 'SWITCH(2, 1, \"one\", 2, \"two\", \"other\")',\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => 'two' as const,\n      },\n      {\n        name: 'AND',\n        getExpression: () => `AND({${numberFieldRo.id}} > 10, {${textFieldRo.id}} != \"\")`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => true,\n      },\n      {\n        name: 'OR',\n        getExpression: () => `OR({${numberFieldRo.id}} < 0, {${textFieldRo.id}} = \"\")`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => false,\n      },\n      {\n        name: 'XOR',\n        getExpression: () => `XOR({${numberFieldRo.id}} > 10, {${textFieldRo.id}} = \"Other\")`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => true,\n      },\n      {\n        name: 'NOT',\n        getExpression: () => `NOT({${numberFieldRo.id}} > 10)`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => false,\n      },\n      {\n        name: 'BLANK',\n        getExpression: () => 'BLANK()',\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => null,\n      },\n      {\n        name: 'TEXT_ALL',\n        getExpression: () => `TEXT_ALL({${textFieldRo.id}})`,\n        resolveExpected: (_ctx: {\n          recordId: string;\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => textInput,\n      },\n      {\n        name: 'RECORD_ID',\n        getExpression: () => 'RECORD_ID()',\n        resolveExpected: ({ recordId }: { recordId: string }) => recordId,\n      },\n      {\n        name: 'AUTO_NUMBER',\n        getExpression: () => 'AUTO_NUMBER()',\n        resolveExpected: ({\n          recordAfter,\n        }: {\n          recordAfter: Awaited<ReturnType<typeof getRecord>>;\n        }) => recordAfter.data.autoNumber ?? null,\n      },\n    ] as const;\n\n    it.each(logicalCases)(\n      'should evaluate $name',\n      async ({ getExpression, resolveExpected, name }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: numericInput,\n                [textFieldRo.name]: textInput,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `logic-${name.toLowerCase()}`,\n          type: FieldType.Formula,\n          options: {\n            expression: getExpression(),\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        const expectedValue = resolveExpected({ recordId, recordAfter: recordAfterFormula });\n\n        if (typeof expectedValue === 'boolean') {\n          expect(typeof value).toBe('boolean');\n          expect(value).toBe(expectedValue);\n        } else if (typeof expectedValue === 'number') {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(expectedValue);\n        } else {\n          expect(value ?? null).toEqual(expectedValue);\n        }\n      }\n    );\n\n    it('should populate RECORD_ID formula for newly created records', async () => {\n      const formulaField = await createField(table1Id, {\n        name: 'logic-record-id-create',\n        type: FieldType.Formula,\n        options: {\n          expression: 'RECORD_ID()',\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: numericInput,\n              [textFieldRo.name]: textInput,\n            },\n          },\n        ],\n      });\n\n      const createdRecord = records[0];\n      expect(typeof createdRecord.id).toBe('string');\n      expect(createdRecord.id.length).toBeGreaterThan(0);\n\n      const formulaValue = createdRecord.fields?.[formulaField.name] as string | null;\n      expect(formulaValue).toBe(createdRecord.id);\n\n      const recordAfterCreate = await getRecord(table1Id, createdRecord.id);\n      const persistedValue = recordAfterCreate.data.fields?.[formulaField.name] as string | null;\n      expect(persistedValue).toBe(createdRecord.id);\n    });\n\n    it('should normalize truthiness for non-boolean logical inputs', async () => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 5,\n              [textFieldRo.name]: 'value',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const [andField, orField, notField] = await Promise.all([\n        createField(table1Id, {\n          name: 'logical-truthiness-and',\n          type: FieldType.Formula,\n          options: {\n            expression: `AND({${numberFieldRo.id}}, {${textFieldRo.id}})`,\n          },\n        }),\n        createField(table1Id, {\n          name: 'logical-truthiness-or',\n          type: FieldType.Formula,\n          options: {\n            expression: `OR({${numberFieldRo.id}}, {${textFieldRo.id}})`,\n          },\n        }),\n        createField(table1Id, {\n          name: 'logical-truthiness-not',\n          type: FieldType.Formula,\n          options: {\n            expression: `NOT({${numberFieldRo.id}})`,\n          },\n        }),\n      ]);\n\n      const readValues = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return {\n          and: record.data.fields[andField.name],\n          or: record.data.fields[orField.name],\n          not: record.data.fields[notField.name],\n        } as { and: boolean; or: boolean; not: boolean };\n      };\n\n      let values = await readValues();\n      expect(values.and).toBe(true);\n      expect(values.or).toBe(true);\n      expect(values.not).toBe(false);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: 0,\n            [textFieldRo.name]: '',\n          },\n        },\n      });\n\n      values = await readValues();\n      expect(values.and).toBe(false);\n      expect(values.or).toBe(false);\n      expect(values.not).toBe(true);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberFieldRo.name]: null,\n            [textFieldRo.name]: 'fallback',\n          },\n        },\n      });\n\n      values = await readValues();\n      expect(values.and).toBe(false);\n      expect(values.or).toBe(true);\n      expect(values.not).toBe(true);\n    });\n\n    it('should not persist logical coercion AND formula as generated column', async () => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 3,\n              [textFieldRo.name]: 'non-empty',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const formulaField = await createField(table1Id, {\n        name: 'logical-coercion-and-persisted',\n        type: FieldType.Formula,\n        options: {\n          expression: `AND({${numberFieldRo.id}}, {${textFieldRo.id}})`,\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const value = recordAfterFormula.data.fields[formulaField.name];\n      expect(typeof value).toBe('boolean');\n      expect(value).toBe(true);\n\n      const refreshed = await getField(table1Id, formulaField.id);\n      const rawMeta = refreshed.meta as unknown;\n      let persistedAsGeneratedColumn: boolean | undefined;\n      if (typeof rawMeta === 'string') {\n        persistedAsGeneratedColumn = (\n          JSON.parse(rawMeta) as { persistedAsGeneratedColumn?: boolean }\n        ).persistedAsGeneratedColumn;\n      } else if (rawMeta && typeof rawMeta === 'object') {\n        persistedAsGeneratedColumn = (rawMeta as { persistedAsGeneratedColumn?: boolean })\n          .persistedAsGeneratedColumn;\n      }\n      expect(persistedAsGeneratedColumn).not.toBe(true);\n    });\n\n    it('should evaluate logical formulas referencing boolean checkbox fields', async () => {\n      const checkboxField = await createField(table1Id, {\n        name: 'logical-checkbox',\n        type: FieldType.Checkbox,\n        options: {},\n      });\n\n      const booleanFormulaField = await createField(table1Id, {\n        name: 'logical-checkbox-formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `AND({${checkboxField.id}}, {${numberFieldRo.id}} > 0)`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [checkboxField.name]: true,\n              [numberFieldRo.name]: 5,\n              [textFieldRo.name]: 'flagged',\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n      const initialValue = records[0].fields[booleanFormulaField.name];\n      expect(typeof initialValue).toBe('boolean');\n      expect(initialValue).toBe(true);\n\n      const uncheckedRecord = await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [checkboxField.name]: null,\n          },\n        },\n      });\n      expect(uncheckedRecord.fields[booleanFormulaField.name]).toBe(false);\n\n      const recheckedRecord = await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [checkboxField.name]: true,\n          },\n        },\n      });\n      expect(recheckedRecord.fields[booleanFormulaField.name]).toBe(true);\n    });\n\n    it('should treat numeric IF fallbacks with blank branches as nulls', async () => {\n      const numericCondition = await createField(table1Id, {\n        name: 'numeric-condition',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n\n      const numericSubtrahend = await createField(table1Id, {\n        name: 'numeric-subtrahend',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n\n      const blankCondition = await createField(table1Id, {\n        name: 'blank-condition',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n\n      const fallbackNumeric = await createField(table1Id, {\n        name: 'fallback-numeric',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'numeric-if-fallback',\n        type: FieldType.Formula,\n        options: {\n          expression:\n            `IF({${numericCondition.id}} > 0, {${numericCondition.id}} - {${numericSubtrahend.id}}, ` +\n            `IF({${blankCondition.id}} > 0, '', {${fallbackNumeric.id}}))`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numericCondition.name]: 10,\n              [numericSubtrahend.name]: 3,\n              [blankCondition.name]: 0,\n              [fallbackNumeric.name]: 5,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const readFormulaValue = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[formulaField.name] as number | null;\n      };\n\n      // Numeric branch should compute the difference.\n      let value = await readFormulaValue();\n      expect(value).toBeCloseTo(7);\n\n      // Trigger the blank branch – it should evaluate to null rather than ''.\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numericCondition.name]: 0,\n            [blankCondition.name]: 8,\n          },\n        },\n      });\n\n      value = await readFormulaValue();\n      expect(value ?? null).toBeNull();\n\n      // Finally, the nested fallback should surface the numeric value unchanged.\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [blankCondition.name]: 0,\n            [fallbackNumeric.name]: -4,\n          },\n        },\n      });\n\n      value = await readFormulaValue();\n      const numericValue = typeof value === 'number' ? value : Number(value);\n      expect(numericValue).toBe(-4);\n    });\n\n    it('should treat null numeric operands as zero for comparison operators', async () => {\n      const leftNumber = await createField(table1Id, {\n        name: 'left-nullable-number',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n        },\n      });\n\n      const rightNumber = await createField(table1Id, {\n        name: 'right-nullable-number',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n        },\n      });\n\n      const gtFormula = await createField(table1Id, {\n        name: 'null-gt-zero-aware',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${leftNumber.id}} > {${rightNumber.id}}, 'left', 'right')`,\n        },\n      });\n\n      const ltFormula = await createField(table1Id, {\n        name: 'null-lt-zero-aware',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${leftNumber.id}} < {${rightNumber.id}}, 'less', 'not-less')`,\n        },\n      });\n\n      const eqFormula = await createField(table1Id, {\n        name: 'null-eq-zero-aware',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${leftNumber.id}} = {${rightNumber.id}}, 'equal', 'different')`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [rightNumber.name]: -1,\n            },\n          },\n          {\n            fields: {\n              [rightNumber.name]: 3,\n            },\n          },\n          {\n            fields: {\n              [rightNumber.name]: 0,\n            },\n          },\n          {\n            fields: {\n              [leftNumber.name]: 2,\n            },\n          },\n        ],\n      });\n\n      const expectations = [\n        { gt: 'left', lt: 'not-less', eq: 'different' }, // null > -1 should behave like 0 > -1\n        { gt: 'right', lt: 'less', eq: 'different' }, // null < 3 should behave like 0 < 3\n        { gt: 'right', lt: 'not-less', eq: 'equal' }, // null = 0 should behave like 0 = 0\n        { gt: 'left', lt: 'not-less', eq: 'different' }, // 2 > null should behave like 2 > 0\n      ];\n\n      records.forEach((record, index) => {\n        const expected = expectations[index];\n        expect(record.fields[gtFormula.name]).toBe(expected.gt);\n        expect(record.fields[ltFormula.name]).toBe(expected.lt);\n        expect(record.fields[eqFormula.name]).toBe(expected.eq);\n      });\n    });\n\n    it('should evaluate nested logical formulas with mixed field types', async () => {\n      const selectField = await createField(table1Id, {\n        name: 'logical-select',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { name: 'light', id: 'cho-light', color: 'grayBright' },\n            { name: 'medium', id: 'cho-medium', color: 'yellowBright' },\n            { name: 'heavy', id: 'cho-heavy', color: 'tealBright' },\n          ],\n        } as IFieldRo['options'],\n      });\n\n      const auxiliaryNumber = await createField(table1Id, {\n        name: 'aux-number',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n        },\n      });\n\n      const complexLogicField = await createField(table1Id, {\n        name: 'nested-mixed-logic',\n        type: FieldType.Formula,\n        options: {\n          expression:\n            `AND({${numberFieldRo.id}} > 0, ` +\n            `OR({${selectField.id}} = \"heavy\", {${selectField.id}} = \"medium\"), ` +\n            `{${textFieldRo.id}} != \"\", ` +\n            `IF({${auxiliaryNumber.id}}, {${auxiliaryNumber.id}}, \"\"))`,\n        },\n      });\n\n      const concatenationField = await createField(table1Id, {\n        name: 'nested-mixed-string',\n        type: FieldType.Formula,\n        options: {\n          expression: `2+2 & {${textFieldRo.id}} & {${selectField.id}} & 4 & \"xxxxxxx\"`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 12,\n              [textFieldRo.name]: 'Alpha',\n              [selectField.name]: 'heavy',\n              [auxiliaryNumber.name]: 9,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const readLogic = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[complexLogicField.name] as boolean;\n      };\n\n      const readConcat = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[concatenationField.name] as string;\n      };\n\n      let logicValue = await readLogic();\n      expect(logicValue).toBe(true);\n\n      let concatValue = await readConcat();\n      expect(concatValue).toBe('4Alphaheavy4xxxxxxx');\n\n      // Switch select choice to a value that should fail the OR expression.\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [selectField.name]: 'light',\n          },\n        },\n      });\n\n      logicValue = await readLogic();\n      expect(logicValue).toBe(false);\n\n      // Restore select, but clear the text field so another clause fails.\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [selectField.name]: 'medium',\n            [textFieldRo.name]: '',\n          },\n        },\n      });\n\n      logicValue = await readLogic();\n      expect(logicValue).toBe(false);\n\n      // Restore text, zero out auxiliary number so IF branch yields NULL (still falsy).\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'Restored',\n            [auxiliaryNumber.name]: 0,\n          },\n        },\n      });\n\n      logicValue = await readLogic();\n      expect(logicValue).toBe(false);\n\n      // Final update: all conditions satisfied again.\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [textFieldRo.name]: 'Ready',\n            [auxiliaryNumber.name]: 11,\n          },\n        },\n      });\n\n      logicValue = await readLogic();\n      expect(logicValue).toBe(true);\n\n      concatValue = await readConcat();\n      expect(concatValue).toBe('4Readymedium4xxxxxxx');\n    });\n\n    it('should compare multi select values against literals inside IF branches', async () => {\n      const equalityFormula = await createField(table1Id, {\n        name: 'if-multi-select-equals',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${multiSelectFieldRo.id}} = \"Alpha\", 1, 2)`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [multiSelectFieldRo.name]: ['Alpha'],\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const readValue = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[equalityFormula.name];\n      };\n\n      let value = await readValue();\n      expect(value).toBe(1);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [multiSelectFieldRo.name]: ['Beta'],\n          },\n        },\n      });\n\n      value = await readValue();\n      expect(value).toBe(2);\n    });\n\n    it('should evaluate SWITCH formulas with numeric branches and blank literals', async () => {\n      const statusField = await createField(table1Id, {\n        name: 'switch-select',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { name: 'light', id: 'cho-light', color: 'grayBright' },\n            { name: 'medium', id: 'cho-medium', color: 'yellowBright' },\n            { name: 'heavy', id: 'cho-heavy', color: 'tealBright' },\n          ],\n        } as IFieldRo['options'],\n      });\n\n      const amountField = await createField(table1Id, {\n        name: 'switch-amount',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n        },\n      });\n\n      const switchFormula = await createField(table1Id, {\n        name: 'switch-mixed-result',\n        type: FieldType.Formula,\n        options: {\n          expression:\n            `SWITCH({${statusField.id}}, ` +\n            `\"heavy\", '', ` +\n            `\"medium\", {${amountField.id}}, ` +\n            `123)`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [statusField.name]: 'medium',\n              [amountField.name]: 42,\n            },\n          },\n        ],\n      });\n\n      const recordId = records[0].id;\n\n      const readSwitchValue = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[switchFormula.name] as number | string | null;\n      };\n\n      let switchValue = await readSwitchValue();\n      expect(Number(switchValue)).toBe(42);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [statusField.name]: 'heavy',\n          },\n        },\n      });\n\n      switchValue = await readSwitchValue();\n      expect(switchValue ?? null).toBeNull();\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [statusField.name]: 'light',\n          },\n        },\n      });\n\n      switchValue = await readSwitchValue();\n      expect(Number(switchValue)).toBe(123);\n    });\n  });\n\n  describe('field reference formulas', () => {\n    const fieldCases = [\n      {\n        name: 'date field formatting',\n        createFieldInput: () => ({\n          name: 'Date Field',\n          type: FieldType.Date,\n        }),\n        setValue: '2025-06-15T00:00:00.000Z',\n        buildExpression: (fieldId: string) => `DATETIME_FORMAT({${fieldId}}, 'YYYY-MM-DD')`,\n        assert: (value: unknown) => {\n          expect(value).toBe('2025-06-15');\n        },\n      },\n      {\n        name: 'rating field numeric formula',\n        createFieldInput: () => ({\n          name: 'Rating Field',\n          type: FieldType.Rating,\n          options: { icon: 'star', max: 5, color: 'yellowBright' },\n        }),\n        setValue: 3,\n        buildExpression: (fieldId: string) => `ROUND({${fieldId}})`,\n        assert: (value: unknown) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(3);\n        },\n      },\n      {\n        name: 'checkbox field conditional',\n        createFieldInput: () => ({\n          name: 'Checkbox Field',\n          type: FieldType.Checkbox,\n        }),\n        setValue: true,\n        buildExpression: (fieldId: string) => `IF({${fieldId}}, \"checked\", \"unchecked\")`,\n        assert: (value: unknown) => {\n          expect(value).toBe('checked');\n        },\n      },\n    ] as const;\n\n    it.each(fieldCases)(\n      'should evaluate formula referencing $name',\n      async ({ createFieldInput, setValue, buildExpression, assert }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: 1,\n                [textFieldRo.name]: 'field-ref',\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const relatedField = await createField(table1Id, createFieldInput());\n\n        await updateRecord(table1Id, recordId, {\n          fieldKeyType: FieldKeyType.Name,\n          record: {\n            fields: {\n              [relatedField.name]: setValue,\n            },\n          },\n        });\n\n        const formulaField = await createField(table1Id, {\n          name: `field-ref-${relatedField.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,\n          type: FieldType.Formula,\n          options: {\n            expression: buildExpression(relatedField.id),\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        assert(value);\n      }\n    );\n\n    it('should evaluate IF formula on checkbox to numeric values', async () => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 1,\n              [textFieldRo.name]: 'checkbox-if-checked',\n            },\n          },\n          {\n            fields: {\n              [numberFieldRo.name]: 2,\n              [textFieldRo.name]: 'checkbox-if-unchecked',\n            },\n          },\n          {\n            fields: {\n              [numberFieldRo.name]: 3,\n              [textFieldRo.name]: 'checkbox-if-cleared',\n            },\n          },\n        ],\n      });\n\n      const [checkedSource, uncheckedSource, clearedSource] = records;\n\n      const checkboxField = await createField(table1Id, {\n        name: 'Checkbox Boolean',\n        type: FieldType.Checkbox,\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'Checkbox Numeric Result',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${checkboxField.id}}, 1, 0)`,\n        },\n      });\n\n      const getFieldValue = (\n        fields: Record<string, unknown>,\n        field: { id: string; name: string }\n      ): unknown => fields[field.name] ?? fields[field.id];\n\n      const scenarios = [\n        {\n          label: 'checked',\n          recordId: checkedSource.id,\n          nextValue: true,\n          expectedCheckbox: true,\n          expectedFormula: 1,\n        },\n        {\n          label: 'unchecked',\n          recordId: uncheckedSource.id,\n          nextValue: false,\n          expectedCheckbox: false,\n          expectedFormula: 0,\n        },\n        {\n          label: 'cleared',\n          recordId: clearedSource.id,\n          nextValue: null,\n          expectedCheckbox: null,\n          expectedFormula: 0,\n        },\n      ] as const;\n\n      for (const { recordId, nextValue, expectedCheckbox, expectedFormula, label } of scenarios) {\n        await updateRecord(table1Id, recordId, {\n          fieldKeyType: FieldKeyType.Name,\n          record: {\n            fields: {\n              [checkboxField.name]: nextValue,\n            },\n          },\n        });\n\n        const { data: recordAfterUpdate } = await getRecord(table1Id, recordId);\n\n        const checkboxValue = getFieldValue(recordAfterUpdate.fields, checkboxField);\n        const formulaValue = getFieldValue(recordAfterUpdate.fields, formulaField);\n\n        expect(getFieldValue(recordAfterUpdate.fields, textFieldRo)).toContain(label);\n\n        if (nextValue === null) {\n          expect(checkboxValue ?? null).toBeNull();\n        } else {\n          expect(Boolean(checkboxValue)).toBe(expectedCheckbox);\n        }\n        expect(formulaValue).toBe(expectedFormula);\n        expect(typeof formulaValue).toBe('number');\n      }\n\n      const refreshed = await getRecords(table1Id);\n\n      const recordMap = new Map(refreshed.records.map((record) => [record.id, record]));\n\n      for (const { recordId, expectedCheckbox, expectedFormula, label } of scenarios) {\n        const current = recordMap.get(recordId);\n        expect(current).toBeDefined();\n\n        const checkboxValue = getFieldValue(current!.fields, checkboxField);\n        const formulaValue = getFieldValue(current!.fields, formulaField);\n\n        if (expectedCheckbox === null) {\n          expect(checkboxValue ?? null).toBeNull();\n        } else {\n          expect(Boolean(checkboxValue)).toBe(expectedCheckbox);\n        }\n\n        expect(typeof formulaValue).toBe('number');\n        expect(formulaValue).toBe(expectedFormula);\n        expect(getFieldValue(current!.fields, textFieldRo)).toContain(label);\n      }\n    });\n  });\n\n  describe('IF truthiness normalization', () => {\n    type TruthinessExpectation = 'TRUE' | 'FALSE';\n    type TruthinessSetupResult = { condition: string; cleanup?: () => Promise<void> };\n    type TruthinessCase = {\n      name: string;\n      expected: TruthinessExpectation;\n      setup: (recordId: string) => Promise<TruthinessSetupResult>;\n    };\n\n    const truthinessCases: TruthinessCase[] = [\n      {\n        name: 'checkbox true',\n        expected: 'TRUE',\n        setup: async (recordId: string) => {\n          const checkboxField = await createField(table1Id, {\n            name: 'condition-checkbox-true',\n            type: FieldType.Checkbox,\n          });\n\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [checkboxField.name]: true } },\n          });\n\n          return { condition: `{${checkboxField.id}}` };\n        },\n      },\n      {\n        name: 'checkbox false',\n        expected: 'FALSE',\n        setup: async (recordId: string) => {\n          const checkboxField = await createField(table1Id, {\n            name: 'condition-checkbox-false',\n            type: FieldType.Checkbox,\n          });\n\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [checkboxField.name]: false } },\n          });\n\n          return { condition: `{${checkboxField.id}}` };\n        },\n      },\n      {\n        name: 'number zero',\n        expected: 'FALSE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [numberFieldRo.name]: 0 } },\n          });\n          return { condition: `{${numberFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'number positive',\n        expected: 'TRUE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [numberFieldRo.name]: 42 } },\n          });\n          return { condition: `{${numberFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'number null',\n        expected: 'FALSE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [numberFieldRo.name]: null } },\n          });\n          return { condition: `{${numberFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'text empty string',\n        expected: 'FALSE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [textFieldRo.name]: '' } },\n          });\n          return { condition: `{${textFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'text non-empty string',\n        expected: 'TRUE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [textFieldRo.name]: 'value' } },\n          });\n          return { condition: `{${textFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'text null',\n        expected: 'FALSE',\n        setup: async (recordId: string) => {\n          await updateRecord(table1Id, recordId, {\n            fieldKeyType: FieldKeyType.Name,\n            record: { fields: { [textFieldRo.name]: null } },\n          });\n          return { condition: `{${textFieldRo.id}}` };\n        },\n      },\n      {\n        name: 'link with record',\n        expected: 'TRUE',\n        setup: async (recordId: string) => {\n          const foreign = await createTable(baseId, {\n            name: 'if-link-condition-foreign',\n            fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n            records: [{ fields: { Title: 'Linked' } }],\n          });\n\n          const linkField = await createField(table1Id, {\n            name: 'condition-link',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: foreign.id,\n            } as ILinkFieldOptionsRo,\n          } as IFieldRo);\n\n          await updateRecordByApi(table1Id, recordId, linkField.id, {\n            id: foreign.records[0].id,\n          });\n\n          const cleanup = async () => {\n            await permanentDeleteTable(baseId, foreign.id);\n          };\n\n          return { condition: `{${linkField.id}}`, cleanup };\n        },\n      },\n    ] as const;\n\n    it('should evaluate IF condition truthiness across data types', async () => {\n      const cleanupTasks: Array<() => Promise<void>> = [];\n\n      try {\n        for (const { setup, expected, name } of truthinessCases) {\n          const { records } = await createRecords(table1Id, {\n            fieldKeyType: FieldKeyType.Name,\n            records: [\n              {\n                fields: {\n                  [numberFieldRo.name]: numberFieldSeedValue,\n                  [textFieldRo.name]: 'seed',\n                },\n              },\n            ],\n          });\n          const recordId = records[0].id;\n\n          const setupResult = await setup(recordId);\n          const { condition } = setupResult;\n          if (setupResult.cleanup) {\n            cleanupTasks.push(setupResult.cleanup);\n          }\n\n          const formulaField = await createField(table1Id, {\n            name: `if-truthiness-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,\n            type: FieldType.Formula,\n            options: {\n              expression: `IF(${condition}, \"TRUE\", \"FALSE\")`,\n            },\n          });\n\n          const recordAfterFormula = await getRecord(table1Id, recordId);\n          const value = recordAfterFormula.data.fields[formulaField.name];\n\n          expect(typeof value).toBe('string');\n          expect(value).toBe(expected);\n        }\n      } finally {\n        for (const task of cleanupTasks.reverse()) {\n          await task();\n        }\n      }\n    });\n  });\n\n  describe('conditional reference formulas', () => {\n    it('should evaluate formulas referencing conditional rollup fields', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-conditional-rollup-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Laptop', Status: 'Active', Amount: 70 } },\n          { fields: { Title: 'Mouse', Status: 'Active', Amount: 20 } },\n          { fields: { Title: 'Subscription', Status: 'Closed', Amount: 15 } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-conditional-rollup-host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n        });\n\n        const statusFieldId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n        const statusFilterFieldId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n\n        const rollupField = await createField(host.id, {\n          name: 'Matching Amount Sum',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: amountFieldId,\n            expression: 'sum({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusFieldId,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterFieldId },\n                },\n              ],\n            },\n          },\n        } as IFieldRo);\n\n        const formulaField = await createField(host.id, {\n          name: 'Rollup Sum Mirror',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${rollupField.id}}`,\n          },\n        });\n\n        const activeRecord = await getRecord(host.id, host.records[0].id);\n        expect(activeRecord.data.fields[formulaField.name]).toEqual(90);\n\n        const closedRecord = await getRecord(host.id, host.records[1].id);\n        expect(closedRecord.data.fields[formulaField.name]).toEqual(15);\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should evaluate formulas referencing conditional lookup fields', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-conditional-lookup-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active' } },\n          { fields: { Title: 'Beta', Status: 'Active' } },\n          { fields: { Title: 'Gamma', Status: 'Closed' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-conditional-lookup-host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Closed' } }],\n        });\n\n        const titleFieldId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusFieldId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const statusFilterFieldId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n\n        const statusMatchFilter: IFilter = {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusFieldId,\n              operator: 'is',\n              value: { type: 'field', fieldId: statusFilterFieldId },\n            },\n          ],\n        };\n\n        const lookupField = await createField(host.id, {\n          name: 'Matching Titles',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          isConditionalLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleFieldId,\n            filter: statusMatchFilter,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const formulaField = await createField(host.id, {\n          name: 'Lookup Joined Titles',\n          type: FieldType.Formula,\n          options: {\n            expression: `ARRAY_JOIN({${lookupField.id}}, \", \")`,\n          },\n        });\n\n        const activeRecord = await getRecord(host.id, host.records[0].id);\n        expect(activeRecord.data.fields[formulaField.name]).toEqual('Alpha, Beta');\n\n        const closedRecord = await getRecord(host.id, host.records[1].id);\n        expect(closedRecord.data.fields[formulaField.name]).toEqual('Gamma');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('should cascade checkbox formulas from numeric conditional rollup results', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-conditional-rollup-checkbox-foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Title: 'Task Active', Status: 'Active' } },\n          { fields: { Title: 'Task Closed', Status: 'Closed' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-conditional-rollup-checkbox-host',\n          fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText } as IFieldRo],\n          records: [\n            { fields: { StatusFilter: 'Active' } },\n            { fields: { StatusFilter: 'Pending' } },\n          ],\n        });\n\n        const statusFieldId = foreign.fields.find((field) => field.name === 'Status')!.id;\n        const titleFieldId = foreign.fields.find((field) => field.name === 'Title')!.id;\n        const statusFilterFieldId = host.fields.find((field) => field.name === 'StatusFilter')!.id;\n\n        const rollupField = await createField(host.id, {\n          name: 'Has Matching Number',\n          type: FieldType.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: titleFieldId,\n            expression: 'count({values})',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusFieldId,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: statusFilterFieldId },\n                },\n              ],\n            },\n          },\n        } as IFieldRo);\n\n        const checkboxFormulaField = await createField(host.id, {\n          name: 'Has Matching Checkbox',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${rollupField.id}} = 1`,\n          },\n        });\n\n        const numericFormulaField = await createField(host.id, {\n          name: 'Checkbox Numeric Mirror',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${checkboxFormulaField.id}}, 1, 0)`,\n          },\n        });\n\n        const activeRecord = await getRecord(host.id, host.records[0].id);\n        const pendingRecord = await getRecord(host.id, host.records[1].id);\n\n        expect(activeRecord.data.fields[rollupField.name]).toBe(1);\n        expect(typeof activeRecord.data.fields[rollupField.name]).toBe('number');\n        expect(activeRecord.data.fields[checkboxFormulaField.name]).toBe(true);\n        expect(typeof activeRecord.data.fields[checkboxFormulaField.name]).toBe('boolean');\n        expect(activeRecord.data.fields[numericFormulaField.name]).toBe(1);\n        expect(typeof activeRecord.data.fields[numericFormulaField.name]).toBe('number');\n\n        expect(pendingRecord.data.fields[rollupField.name]).toBe(0);\n        expect(typeof pendingRecord.data.fields[rollupField.name]).toBe('number');\n        expect(pendingRecord.data.fields[checkboxFormulaField.name]).toBe(false);\n        expect(typeof pendingRecord.data.fields[checkboxFormulaField.name]).toBe('boolean');\n        expect(pendingRecord.data.fields[numericFormulaField.name]).toBe(0);\n        expect(typeof pendingRecord.data.fields[numericFormulaField.name]).toBe('number');\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n  });\n  describe('datetime formula functions', () => {\n    it.each(dateAddCases)(\n      'should evaluate DATE_ADD with expression-based count argument for unit \"%s\"',\n      async ({ literal, normalized }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: numberFieldSeedValue,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const dateAddField = await createField(table1Id, {\n          name: `date-add-formula-${literal}`,\n          type: FieldType.Formula,\n          options: {\n            expression: `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), {${numberFieldRo.id}} * ${dateAddMultiplier}, '${literal}')`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const rawValue = recordAfterFormula.data.fields[dateAddField.name];\n        expect(typeof rawValue).toBe('string');\n        const value = rawValue as string;\n        const expectedCount = numberFieldSeedValue * dateAddMultiplier;\n        const expectedDate = addToDate(baseDate, expectedCount, normalized);\n        const expectedIso = expectedDate.toISOString();\n        expect(value).toEqual(expectedIso);\n      }\n    );\n\n    const dateAddArgumentMatrix: Array<{\n      label: string;\n      requiresFormulaField: boolean;\n      buildExpression: (ids: { numberFieldId: string; numberFormulaFieldId?: string }) => string;\n      expectedShift: (baseNumberValue: number) => number;\n    }> = [\n      {\n        label: `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), 1, 'day')`,\n        requiresFormulaField: false,\n        buildExpression: () => `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), 1, 'day')`,\n        expectedShift: () => 1,\n      },\n      {\n        label: `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), {NumberField}, 'day')`,\n        requiresFormulaField: false,\n        buildExpression: ({ numberFieldId }) =>\n          `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), {${numberFieldId}}, 'day')`,\n        expectedShift: (baseNumberValue) => baseNumberValue,\n      },\n      {\n        label: `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), {NumberFormulaField}, 'day')`,\n        requiresFormulaField: true,\n        buildExpression: ({ numberFormulaFieldId }) =>\n          `DATE_ADD(DATETIME_PARSE(\"2025-01-03\"), {${numberFormulaFieldId}}, 'day')`,\n        expectedShift: (baseNumberValue) => baseNumberValue * 2,\n      },\n    ];\n\n    it.each(dateAddArgumentMatrix)(\n      'should evaluate DATE_ADD when count argument comes from %s',\n      async ({ label, requiresFormulaField, buildExpression, expectedShift }) => {\n        const baseNumberValue = 3;\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: baseNumberValue,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        let numberFormulaFieldId: string | undefined;\n        if (requiresFormulaField) {\n          const numberFormulaField = await createField(table1Id, {\n            name: `date-add-count-formula-${label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`,\n            type: FieldType.Formula,\n            options: {\n              expression: `{${numberFieldRo.id}} * 2`,\n            },\n          });\n          numberFormulaFieldId = numberFormulaField.id;\n        }\n\n        const dateAddField = await createField(table1Id, {\n          name: `date-add-permutation-${label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`,\n          type: FieldType.Formula,\n          options: {\n            expression: buildExpression({\n              numberFieldId: numberFieldRo.id,\n              numberFormulaFieldId,\n            }),\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const rawValue = recordAfterFormula.data.fields[dateAddField.name];\n        expect(typeof rawValue).toBe('string');\n\n        const expectedDate = addToDate(\n          new Date('2025-01-03T00:00:00.000Z'),\n          expectedShift(baseNumberValue),\n          'day'\n        );\n        expect(rawValue).toBe(expectedDate.toISOString());\n      }\n    );\n\n    it('should apply DATE_ADD to the first value when lookup returns multiple dates', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'formula-date-add-lookup-foreign',\n        fields: [\n          { name: 'Order', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Signup Date', type: FieldType.Date } as IFieldRo,\n        ],\n        records: [\n          { fields: { Order: 'A', 'Signup Date': '2024-05-01T00:00:00.000Z' } },\n          { fields: { Order: 'B', 'Signup Date': '2024-05-03T12:00:00.000Z' } },\n        ],\n      });\n      let host: ITableFullVo | undefined;\n      try {\n        host = await createTable(baseId, {\n          name: 'formula-date-add-lookup-host',\n          fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n          records: [{ fields: { Name: 'Host row' } }],\n        });\n\n        const linkField = await createField(host.id, {\n          name: 'Related Orders',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreign.id,\n          } as ILinkFieldOptionsRo,\n        } as IFieldRo);\n\n        const signupDateFieldId = foreign.fields.find((field) => field.name === 'Signup Date')!.id;\n        const lookupField = await createField(host.id, {\n          name: 'Signup Dates',\n          type: FieldType.Date,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            lookupFieldId: signupDateFieldId,\n            linkFieldId: linkField.id,\n          } as ILookupOptionsRo,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        } as IFieldRo);\n\n        const dateAddField = await createField(host.id, {\n          name: 'Signup Date +14d',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATE_ADD({${lookupField.id}}, 14, 'day')`,\n          },\n        } as IFieldRo);\n\n        const hostRecordId = host.records[0].id;\n        await updateRecordByApi(\n          host.id,\n          hostRecordId,\n          linkField.id,\n          foreign.records.map((record) => ({ id: record.id }))\n        );\n\n        const recordAfter = await getRecord(host.id, hostRecordId);\n        expect(recordAfter.data.fields[lookupField.name]).toEqual([\n          '2024-05-01T00:00:00.000Z',\n          '2024-05-03T12:00:00.000Z',\n        ]);\n\n        expect(recordAfter.data.fields[dateAddField.name]).toBe(\n          addToDate(new Date('2024-05-01T00:00:00.000Z'), 14, 'day').toISOString()\n        );\n      } finally {\n        if (host) {\n          await permanentDeleteTable(baseId, host.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it.each(datetimeDiffCases)(\n      'should evaluate DATETIME_DIFF for unit \"%s\"',\n      async ({ literal, expected }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: 1,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const diffField = await createField(table1Id, {\n          name: `datetime-diff-${literal}`,\n          type: FieldType.Formula,\n          options: {\n            expression: `DATETIME_DIFF(DATETIME_PARSE(\"${datetimeDiffEndIso}\"), DATETIME_PARSE(\"${datetimeDiffStartIso}\"), '${literal}')`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const rawValue = recordAfterFormula.data.fields[diffField.name];\n        if (typeof rawValue === 'number') {\n          expect(rawValue).toBeCloseTo(expected, 6);\n        } else {\n          const numericValue = Number(rawValue);\n          expect(Number.isFinite(numericValue)).toBe(true);\n          expect(numericValue).toBeCloseTo(expected, 6);\n        }\n      }\n    );\n\n    it('should evaluate DATETIME_DIFF default unit when end precedes start', async () => {\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 1,\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const diffField = await createField(table1Id, {\n        name: `datetime-diff-default-order`,\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_DIFF(DATETIME_PARSE(\"${datetimeDiffEndIso}\"), DATETIME_PARSE(\"${datetimeDiffStartIso}\"))`,\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const rawValue = recordAfterFormula.data.fields[diffField.name];\n      if (typeof rawValue === 'number') {\n        expect(rawValue).toBeCloseTo(diffDays, 6);\n      } else {\n        const numericValue = Number(rawValue);\n        expect(Number.isFinite(numericValue)).toBe(true);\n        expect(numericValue).toBeCloseTo(diffDays, 6);\n      }\n    });\n\n    it.each([\n      {\n        unit: 'month',\n        start: '2024-01-31T00:00:00.000Z',\n        end: '2024-02-29T00:00:00.000Z',\n        expected: 1,\n      },\n      {\n        unit: 'months',\n        start: '2024-01-31T00:00:00.000Z',\n        end: '2024-02-29T00:00:00.000Z',\n        expected: 1,\n      },\n      {\n        unit: 'quarter',\n        start: '2025-01-01T00:00:00.000Z',\n        end: '2025-04-01T00:00:00.000Z',\n        expected: 1,\n      },\n      {\n        unit: 'quarters',\n        start: '2025-01-01T00:00:00.000Z',\n        end: '2025-04-01T00:00:00.000Z',\n        expected: 1,\n      },\n      {\n        unit: 'year',\n        start: '2024-01-01T00:00:00.000Z',\n        end: '2025-01-01T00:00:00.000Z',\n        expected: 1,\n      },\n      {\n        unit: 'years',\n        start: '2024-01-01T00:00:00.000Z',\n        end: '2025-01-01T00:00:00.000Z',\n        expected: 1,\n      },\n    ])(\n      'should evaluate DATETIME_DIFF for month/quarter/year spans using unit \"%s\"',\n      async ({ unit, start, end, expected }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: 1,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const diffField = await createField(table1Id, {\n          name: `datetime-diff-${unit}-span`,\n          type: FieldType.Formula,\n          options: {\n            expression: `DATETIME_DIFF(DATETIME_PARSE(\"${end}\"), DATETIME_PARSE(\"${start}\"), '${unit}')`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const rawValue = recordAfterFormula.data.fields[diffField.name];\n        if (typeof rawValue === 'number') {\n          expect(rawValue).toBeCloseTo(expected, 6);\n        } else {\n          const numericValue = Number(rawValue);\n          expect(Number.isFinite(numericValue)).toBe(true);\n          expect(numericValue).toBeCloseTo(expected, 6);\n        }\n      }\n    );\n\n    it('should not persist chained DATETIME_DIFF formula as generated column', async () => {\n      const startDateField = await createField(table1Id, {\n        name: 'shift-start',\n        type: FieldType.Date,\n      } as IFieldRo);\n      const endDateField = await createField(table1Id, {\n        name: 'shift-end',\n        type: FieldType.Date,\n      } as IFieldRo);\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [startDateField.name]: '2025-04-10T08:15:00.000Z',\n              [endDateField.name]: '2025-04-10T09:45:00.000Z',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const durationField = await createField(table1Id, {\n        name: 'shift-duration-minutes',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_DIFF({${endDateField.id}}, {${startDateField.id}}, 'minute')`,\n        },\n      });\n\n      const remainingField = await createField(table1Id, {\n        name: 'shift-remaining',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${durationField.id}} - 1`,\n        },\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, recordId);\n      const rawDuration = recordAfterFormula.data.fields[durationField.name];\n      const duration = typeof rawDuration === 'number' ? rawDuration : Number(rawDuration);\n      expect(duration).toBeCloseTo(90, 6);\n\n      const rawRemaining = recordAfterFormula.data.fields[remainingField.name];\n      const remaining = typeof rawRemaining === 'number' ? rawRemaining : Number(rawRemaining);\n      expect(remaining).toBeCloseTo(89, 6);\n\n      const refreshedRemainingField = await getField(table1Id, remainingField.id);\n      const rawMeta = refreshedRemainingField.meta as unknown;\n      let persistedAsGeneratedColumn: boolean | undefined;\n      if (typeof rawMeta === 'string') {\n        persistedAsGeneratedColumn = (\n          JSON.parse(rawMeta) as { persistedAsGeneratedColumn?: boolean }\n        ).persistedAsGeneratedColumn;\n      } else if (rawMeta && typeof rawMeta === 'object') {\n        persistedAsGeneratedColumn = (rawMeta as { persistedAsGeneratedColumn?: boolean })\n          .persistedAsGeneratedColumn;\n      }\n      expect(persistedAsGeneratedColumn).not.toBe(true);\n    });\n\n    it('should evaluate DATETIME_DIFF when referencing string formula fields using \"+\"', async () => {\n      let table: ITableFullVo | undefined;\n      try {\n        table = await createTable(baseId, {\n          name: 'datetime-diff-from-text-formulas',\n          fields: [\n            { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n            {\n              name: 'shift-date-only',\n              type: FieldType.Date,\n              options: {\n                formatting: {\n                  date: DateFormattingPreset.ISO,\n                  time: TimeFormatting.None,\n                  timeZone: 'Etc/GMT-8',\n                },\n              },\n            } as IFieldRo,\n            { name: 'shift-start-time', type: FieldType.SingleLineText } as IFieldRo,\n            { name: 'shift-end-time', type: FieldType.SingleLineText } as IFieldRo,\n          ],\n          records: [\n            {\n              fields: {\n                Name: 'row',\n                'shift-date-only': '2025-10-31T16:00:00.000Z',\n                'shift-start-time': '8:40',\n                'shift-end-time': '8:57',\n              },\n            },\n          ],\n        });\n\n        const dateField = table.fields.find((f) => f.name === 'shift-date-only')!;\n        const startTimeField = table.fields.find((f) => f.name === 'shift-start-time')!;\n        const endTimeField = table.fields.find((f) => f.name === 'shift-end-time')!;\n        const recordId = table.records[0].id;\n\n        const startDatetimeText = await createField(table.id, {\n          name: 'shift-start-datetime-text',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATESTR({${dateField.id}}) + \" \" + DATETIME_FORMAT(DATESTR({${dateField.id}}) + \" \" + {${startTimeField.id}}, \"HH:mm:ss\")`,\n            timeZone: 'Asia/Shanghai',\n          },\n        } as IFieldRo);\n\n        const endDatetimeText = await createField(table.id, {\n          name: 'shift-end-datetime-text',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATESTR({${dateField.id}}) + \" \" + DATETIME_FORMAT(DATESTR({${dateField.id}}) + \" \" + {${endTimeField.id}}, \"HH:mm:ss\")`,\n            timeZone: 'Etc/GMT-8',\n          },\n        } as IFieldRo);\n\n        const durationMinutes = await createField(table.id, {\n          name: 'shift-duration-minutes-from-text',\n          type: FieldType.Formula,\n          options: {\n            expression: `DATETIME_DIFF({${endDatetimeText.id}}, {${startDatetimeText.id}}, \"minute\")`,\n            timeZone: 'Etc/GMT-8',\n          },\n        } as IFieldRo);\n\n        const recordAfterFormula = await getRecord(table.id, recordId);\n        const rawDuration = recordAfterFormula.data.fields[durationMinutes.name];\n        const duration = typeof rawDuration === 'number' ? rawDuration : Number(rawDuration);\n        expect(duration).toBeCloseTo(17, 6);\n      } finally {\n        if (table) {\n          await permanentDeleteTable(baseId, table.id);\n        }\n      }\n    });\n\n    it.each(isSameCases)(\n      'should evaluate IS_SAME for unit \"%s\"',\n      async ({ literal, first, second, expected }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [textFieldRo.name]: 'value',\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const sameField = await createField(table1Id, {\n          name: `is-same-${literal}`,\n          type: FieldType.Formula,\n          options: {\n            expression: `IS_SAME(DATETIME_PARSE(\"${first}\"), DATETIME_PARSE(\"${second}\"), '${literal}')`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const rawValue = recordAfterFormula.data.fields[sameField.name];\n        expect(rawValue).toBe(expected);\n      }\n    );\n\n    const componentCases = [\n      {\n        name: 'YEAR',\n        expression: `YEAR(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 2025,\n      },\n      {\n        name: 'MONTH',\n        expression: `MONTH(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 4,\n      },\n      {\n        name: 'DAY',\n        expression: `DAY(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 15,\n      },\n      {\n        name: 'HOUR',\n        expression: `HOUR(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 10,\n      },\n      {\n        name: 'MINUTE',\n        expression: `MINUTE(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 20,\n      },\n      {\n        name: 'SECOND',\n        expression: `SECOND(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 30,\n      },\n      {\n        name: 'WEEKDAY',\n        expression: `WEEKDAY(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 2,\n      },\n      {\n        name: 'WEEKDAY_MONDAY',\n        expression: `WEEKDAY(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"), \"Monday\")`,\n        expected: 1,\n      },\n      {\n        name: 'WEEKDAY_SUNDAY',\n        expression: `WEEKDAY(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"), \"Sunday\")`,\n        expected: 2,\n      },\n      {\n        name: 'WEEKNUM',\n        expression: `WEEKNUM(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: 16,\n      },\n    ] as const;\n\n    it.each(componentCases)(\n      'should evaluate %s component function',\n      async ({ expression, expected, name }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [{ fields: {} }],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `datetime-component-${name.toLowerCase()}`,\n          type: FieldType.Formula,\n          // Use UTC timezone to ensure deterministic results across different local timezones\n          options: { expression, timeZone: 'UTC' },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        expect(typeof value).toBe('number');\n        expect(value).toBe(expected);\n      }\n    );\n\n    const formattingCases = [\n      {\n        name: 'DATESTR',\n        expression: `DATESTR(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: '2025-04-15',\n      },\n      {\n        name: 'TIMESTR',\n        expression: `TIMESTR(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: '10:20:30',\n      },\n      {\n        name: 'DATETIME_FORMAT',\n        expression: `DATETIME_FORMAT(DATETIME_PARSE(\"2025-04-15\"), 'YYYY-MM-DD')`,\n        expected: '2025-04-15',\n      },\n    ] as const;\n\n    it.each(formattingCases)(\n      'should evaluate %s formatting function',\n      async ({ expression, expected, name }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [{ fields: {} }],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `datetime-format-${name.toLowerCase()}`,\n          type: FieldType.Formula,\n          // Use UTC timezone to ensure deterministic results across different local timezones\n          options: { expression, timeZone: 'UTC' },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        expect(value).toBe(expected);\n      }\n    );\n\n    const comparisonCases = [\n      {\n        name: 'IS_AFTER',\n        expression: `IS_AFTER(DATETIME_PARSE(\"2025-04-16T12:30:45Z\"), DATETIME_PARSE(\"2025-04-15T10:20:30Z\"))`,\n        expected: true,\n      },\n      {\n        name: 'IS_BEFORE',\n        expression: `IS_BEFORE(DATETIME_PARSE(\"2025-04-15T10:20:30Z\"), DATETIME_PARSE(\"2025-04-16T12:30:45Z\"))`,\n        expected: true,\n      },\n    ] as const;\n\n    it.each(comparisonCases)(\n      'should evaluate %s boolean comparison',\n      async ({ expression, expected, name }) => {\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [{ fields: {} }],\n        });\n        const recordId = records[0].id;\n\n        const formulaField = await createField(table1Id, {\n          name: `datetime-compare-${name.toLowerCase()}`,\n          type: FieldType.Formula,\n          options: { expression },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        expect(value).toBe(expected);\n      }\n    );\n  });\n\n  describe('formula argument permutations', () => {\n    const literalNumberValue = 4;\n    const literalTextValue = 'literal-matrix';\n    const fallbackTextValue = 'fallback-matrix';\n\n    type SumArgSource = 'literal' | 'field' | 'formula';\n    const sumArgumentSources: Record<\n      SumArgSource,\n      {\n        toExpression: (ids: { numberFieldId: string; numberFormulaFieldId?: string }) => string;\n        toValue: (ctx: { numberValue: number; numberFormulaValue?: number }) => number;\n        requiresFormulaField?: boolean;\n      }\n    > = {\n      literal: {\n        toExpression: () => `${literalNumberValue}`,\n        toValue: () => literalNumberValue,\n      },\n      field: {\n        toExpression: ({ numberFieldId }) => `{${numberFieldId}}`,\n        toValue: ({ numberValue }) => numberValue,\n      },\n      formula: {\n        requiresFormulaField: true,\n        toExpression: ({ numberFormulaFieldId }) => `{${numberFormulaFieldId}}`,\n        toValue: ({ numberFormulaValue }) => numberFormulaValue ?? 0,\n      },\n    };\n\n    const sumArgumentCombinations = (['literal', 'field', 'formula'] as SumArgSource[]).flatMap(\n      (first) =>\n        (['literal', 'field', 'formula'] as SumArgSource[]).map((second) => ({\n          label: `${first} + ${second}`,\n          args: [first, second] as [SumArgSource, SumArgSource],\n        }))\n    );\n\n    it.each(sumArgumentCombinations)(\n      'should evaluate SUM when arguments come from %s',\n      async ({ args, label }) => {\n        const baseNumberValue = 3;\n        const baseTextValue = 'matrix-text';\n\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: baseNumberValue,\n                [textFieldRo.name]: baseTextValue,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        let numberFormulaFieldId: string | undefined;\n        if (args.some((source) => sumArgumentSources[source].requiresFormulaField)) {\n          const numberFormulaField = await createField(table1Id, {\n            name: `sum-argument-source-${label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`,\n            type: FieldType.Formula,\n            options: {\n              expression: `{${numberFieldRo.id}} * 2`,\n            },\n          });\n          numberFormulaFieldId = numberFormulaField.id;\n        }\n\n        const argExpressions = args.map((source) =>\n          sumArgumentSources[source].toExpression({\n            numberFieldId: numberFieldRo.id,\n            numberFormulaFieldId,\n          })\n        );\n\n        const formulaField = await createField(table1Id, {\n          name: `sum-argument-matrix-${label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`,\n          type: FieldType.Formula,\n          options: {\n            expression: `SUM(${argExpressions.join(', ')})`,\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        expect(typeof value).toBe('number');\n\n        const numberFormulaValue = numberFormulaFieldId ? baseNumberValue * 2 : undefined;\n        const expectedSum = args.reduce(\n          (acc, source) =>\n            acc +\n            sumArgumentSources[source].toValue({\n              numberValue: baseNumberValue,\n              numberFormulaValue,\n            }),\n          0\n        );\n        expect(value).toBeCloseTo(expectedSum, 6);\n      }\n    );\n\n    it('should treat boolean comparisons on single select fields as numeric inside SUM', async () => {\n      const selectFields = await Promise.all(\n        Array.from({ length: 3 }, (_, index) =>\n          createField(table1Id, {\n            name: `sum-select-${index + 1}`,\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { id: `select-${index + 1}-nb`, name: 'NB' },\n                { id: `select-${index + 1}-other`, name: 'WB' },\n              ],\n            } as ISelectFieldOptionsRo,\n          })\n        )\n      );\n\n      const equalityExpressions = selectFields.map((field) => `{${field.id}} = \"NB\"`);\n\n      const formulaField = await createField(table1Id, {\n        name: 'sum-select-boolean-coercion',\n        type: FieldType.Formula,\n        options: {\n          expression: `SUM(${equalityExpressions.join(', ')})`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [selectFields[0].name]: 'NB',\n              [selectFields[1].name]: 'NB',\n              [selectFields[2].name]: 'WB',\n            },\n          },\n        ],\n      });\n      const recordId = records[0].id;\n\n      const readSumValue = async () => {\n        const record = await getRecord(table1Id, recordId);\n        return record.data.fields[formulaField.name] as number;\n      };\n\n      let sumValue = await readSumValue();\n      expect(typeof sumValue).toBe('number');\n      expect(sumValue).toBe(2);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [selectFields[0].name]: 'NB',\n            [selectFields[1].name]: 'NB',\n            [selectFields[2].name]: 'NB',\n          },\n        },\n      });\n\n      sumValue = await readSumValue();\n      expect(sumValue).toBe(3);\n\n      await updateRecord(table1Id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [selectFields[0].name]: 'WB',\n            [selectFields[1].name]: 'WB',\n            [selectFields[2].name]: 'WB',\n          },\n        },\n      });\n\n      sumValue = await readSumValue();\n      expect(sumValue).toBe(0);\n    });\n\n    const mixedFunctionCases: Array<{\n      label: FunctionName;\n      expressionFactory: (ids: {\n        numberFieldId: string;\n        numberFormulaFieldId: string;\n        textFieldId: string;\n        textFormulaFieldId: string;\n      }) => string;\n      assert: (\n        value: unknown,\n        ctx: { numberValue: number; numberFormulaValue: number; textValue: string }\n      ) => void;\n    }> = [\n      {\n        label: FunctionName.Round,\n        expressionFactory: ({ numberFieldId, numberFormulaFieldId }) =>\n          `ROUND({${numberFormulaFieldId}} / {${numberFieldId}}, 0)`,\n        assert: (value) => {\n          expect(typeof value).toBe('number');\n          expect(value).toBe(2);\n        },\n      },\n      {\n        label: FunctionName.Concatenate,\n        expressionFactory: ({ numberFormulaFieldId, textFieldId, textFormulaFieldId }) =>\n          `CONCATENATE(\"${literalTextValue}\", \"-\", {${textFieldId}}, \"-\", {${numberFormulaFieldId}}, \"-\", {${textFormulaFieldId}})`,\n        assert: (value, ctx) => {\n          expect(typeof value).toBe('string');\n          const textFormulaValue = `${ctx.numberValue}${ctx.textValue}`;\n          expect(value).toBe(\n            `${literalTextValue}-${ctx.textValue}-${ctx.numberFormulaValue}-${textFormulaValue}`\n          );\n        },\n      },\n      {\n        label: FunctionName.If,\n        expressionFactory: ({ numberFieldId, numberFormulaFieldId, textFieldId }) =>\n          `IF({${numberFormulaFieldId}} > {${numberFieldId}}, {${textFieldId}}, \"${fallbackTextValue}\")`,\n        assert: (value, ctx) => {\n          expect(typeof value).toBe('string');\n          expect(value).toBe(\n            ctx.numberFormulaValue > ctx.numberValue ? ctx.textValue : fallbackTextValue\n          );\n        },\n      },\n    ];\n\n    it.each(mixedFunctionCases)(\n      'should evaluate %s with mixed literal and field arguments',\n      async ({ label, expressionFactory, assert }) => {\n        const baseNumberValue = 3;\n        const baseTextValue = 'matrix-text';\n\n        const { records } = await createRecords(table1Id, {\n          fieldKeyType: FieldKeyType.Name,\n          records: [\n            {\n              fields: {\n                [numberFieldRo.name]: baseNumberValue,\n                [textFieldRo.name]: baseTextValue,\n              },\n            },\n          ],\n        });\n        const recordId = records[0].id;\n\n        const numberFormulaField = await createField(table1Id, {\n          name: `mixed-function-source-${label.toLowerCase()}`,\n          type: FieldType.Formula,\n          options: {\n            expression: `{${numberFieldRo.id}} * 2`,\n          },\n        });\n\n        const formulaField = await createField(table1Id, {\n          name: `mixed-function-matrix-${label.toLowerCase()}`,\n          type: FieldType.Formula,\n          options: {\n            expression: expressionFactory({\n              numberFieldId: numberFieldRo.id,\n              numberFormulaFieldId: numberFormulaField.id,\n              textFieldId: textFieldRo.id,\n              textFormulaFieldId: formulaFieldRo.id,\n            }),\n          },\n        });\n\n        const recordAfterFormula = await getRecord(table1Id, recordId);\n        const value = recordAfterFormula.data.fields[formulaField.name];\n        assert(value, {\n          numberValue: baseNumberValue,\n          numberFormulaValue: baseNumberValue * 2,\n          textValue: baseTextValue,\n        });\n      }\n    );\n\n    it('should treat DATETIME_PARSE without format as null when generated string is invalid', async () => {\n      const dateField = await createField(table1Id, {\n        name: 'source-birthday',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n            timeZone: 'Asia/Shanghai',\n          },\n        },\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'birthday-anniversary',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_PARSE(YEAR(TODAY()) & '-' & MONTH({${dateField.id}}) & '-' & DAY({${dateField.id}}))`,\n        },\n      });\n\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, records[0].id);\n      const value = recordAfterFormula.data.fields[formulaField.name] ?? null;\n      expect(value).toBeNull();\n    });\n\n    it('should bypass DATETIME_PARSE guard for direct date field references', async () => {\n      const dateField = await createField(table1Id, {\n        name: 'source-date-field',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n            timeZone: 'UTC',\n          },\n        },\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'date-passthrough',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_PARSE({${dateField.id}})`,\n        },\n      });\n\n      const sourceIso = '2024-05-20T09:30:00.000Z';\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [dateField.name]: sourceIso,\n            },\n          },\n        ],\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, records[0].id);\n      const value = recordAfterFormula.data.fields[formulaField.name];\n      expect(value).toBe(sourceIso);\n    });\n\n    it('should allow DATETIME_PARSE to consume DATE_ADD output with literal time fragments', async () => {\n      const dateField = await createField(table1Id, {\n        name: 'month-end',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'UTC',\n          },\n        },\n      });\n\n      const formulaField = await createField(table1Id, {\n        name: 'month-start',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_PARSE(DATE_ADD({${dateField.id}}, 1 - DAY({${dateField.id}}), 'day'), 'YYYY-MM-DD 00:00')`,\n          // Use UTC timezone to ensure deterministic results across different local timezones\n          timeZone: 'UTC',\n        },\n      });\n\n      const sourceIso = '2025-11-19T00:00:00.000Z';\n      const expectedIso = '2025-11-01T00:00:00.000Z';\n      const { records } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [dateField.name]: sourceIso,\n            },\n          },\n        ],\n      });\n\n      const recordAfterFormula = await getRecord(table1Id, records[0].id);\n      const value = recordAfterFormula.data.fields?.[formulaField.name] ?? null;\n      expect(value).toBe(expectedIso);\n    });\n\n    it('should coerce blank IF branch to null for datetime results', async () => {\n      const dateField = await createField(table1Id, {\n        name: 'source-date',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.None,\n            timeZone: 'Asia/Shanghai',\n          },\n        },\n      });\n\n      const datetimeFormulaField = await createField(table1Id, {\n        name: 'nullable-datetime-formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(YEAR({${dateField.id}}) < 2020, '', {${dateField.id}})`,\n        },\n      });\n\n      const initialIso = '2019-05-01T00:00:00.000Z';\n      const { records: createdRecords } = await createRecords(table1Id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [numberFieldRo.name]: 10,\n              [textFieldRo.name]: 'trigger-null',\n              [dateField.name]: initialIso,\n            },\n          },\n        ],\n      });\n\n      const createdRecord = createdRecords[0];\n      const recordAfterCreate = await getRecord(table1Id, createdRecord.id);\n      const createdFormulaValue =\n        recordAfterCreate.data.fields?.[datetimeFormulaField.name] ?? null;\n      expect(createdFormulaValue).toBeNull();\n\n      const updatedIso = '2024-05-01T12:00:00.000Z';\n      const updatedRecord = await updateRecord(table1Id, createdRecord.id, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [dateField.name]: updatedIso,\n          },\n        },\n      });\n\n      const updatedValue = updatedRecord.fields?.[datetimeFormulaField.name] as string | null;\n      expect(updatedValue).not.toBeNull();\n      expect(typeof updatedValue).toBe('string');\n      expect(updatedValue).toContain('2024');\n\n      const recordAfterUpdate = await getRecord(table1Id, createdRecord.id);\n      const persistedValue = recordAfterUpdate.data.fields?.[datetimeFormulaField.name] as\n        | string\n        | null;\n      expect(persistedValue).not.toBeNull();\n      expect(typeof persistedValue).toBe('string');\n      expect(persistedValue).toContain('2024');\n    });\n  });\n\n  it('should evaluate link equality formula comparing link title and concatenated text', async () => {\n    const foreign = await createTable(baseId, {\n      name: 'link-equality-foreign',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Title: 'AlphaSet1' } }],\n    });\n    let host: ITableFullVo | undefined;\n    try {\n      host = await createTable(baseId, {\n        name: 'link-equality-host',\n        fields: [\n          { name: 'Ad', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Adset', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [{ fields: { Ad: 'Alpha', Adset: 'Set1' } }],\n      });\n\n      const adField = host.fields.find((field) => field.name === 'Ad')!;\n      const adsetField = host.fields.find((field) => field.name === 'Adset')!;\n\n      const concatenatedField = await createField(host.id, {\n        name: 'Ad & Adset',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${adField.id}} & {${adsetField.id}}`,\n        },\n      });\n\n      const linkField = await createField(host.id, {\n        name: 'Related Campaign',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: foreign.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const equalityField = await createField(host.id, {\n        name: 'Link Matches Text',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${linkField.id}} = {${concatenatedField.id}}`,\n        },\n      });\n\n      const recordId = host.records[0].id;\n      await updateRecordByApi(host.id, recordId, linkField.id, {\n        id: foreign.records[0].id,\n      });\n\n      let record = await getRecord(host.id, recordId);\n      expect(record.data.fields[concatenatedField.name]).toBe('AlphaSet1');\n      expect(record.data.fields[equalityField.name]).toBe(true);\n\n      await updateRecord(host.id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [adField.name]: 'Beta',\n          },\n        },\n      });\n\n      record = await getRecord(host.id, recordId);\n      expect(record.data.fields[concatenatedField.name]).toBe('BetaSet1');\n      expect(record.data.fields[equalityField.name]).toBe(false);\n    } finally {\n      if (host) {\n        await permanentDeleteTable(baseId, host.id);\n      }\n      await permanentDeleteTable(baseId, foreign.id);\n    }\n  });\n\n  it('should calculate primary field when have link relationship', async () => {\n    const table2: ITableFullVo = await createTable(baseId, { name: 'table2' });\n    const linkFieldRo: IFieldRo = {\n      type: FieldType.Link,\n      options: {\n        foreignTableId: table2.id,\n        relationship: Relationship.ManyOne,\n      } as ILinkFieldOptionsRo,\n    };\n\n    const formulaFieldRo: IFieldRo = {\n      type: FieldType.Formula,\n      options: {\n        expression: `{${table2.fields[0].id}}`,\n      },\n    };\n\n    await createField(table1Id, linkFieldRo);\n\n    const formulaField = await createField(table2.id, formulaFieldRo);\n\n    const record1 = await updateRecord(table2.id, table2.records[0].id, {\n      fieldKeyType: FieldKeyType.Name,\n      record: {\n        fields: {\n          [table2.fields[0].name]: 'text',\n        },\n      },\n    });\n    expect(record1.fields[formulaField.name]).toEqual('text');\n  });\n\n  it('should format link titles using foreign field formatting', async () => {\n    const foreignDate = await createTable(baseId, {\n      name: 'link-format-date-foreign',\n      fields: [\n        {\n          name: 'Due Date',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.Asian,\n              time: TimeFormatting.None,\n              timeZone: 'UTC',\n            },\n          },\n        } as IFieldRo,\n      ],\n      records: [\n        {\n          fields: {\n            'Due Date': '2024-05-06T01:23:45.000Z',\n          },\n        },\n        {\n          fields: {\n            'Due Date': '2024-05-07T09:00:00.000Z',\n          },\n        },\n      ],\n    });\n\n    const foreignNumber = await createTable(baseId, {\n      name: 'link-format-number-foreign',\n      fields: [\n        {\n          name: 'Completion',\n          type: FieldType.Number,\n          options: {\n            formatting: {\n              type: NumberFormattingType.Percent,\n              precision: 1,\n            },\n          },\n        } as IFieldRo,\n      ],\n      records: [\n        {\n          fields: {\n            Completion: 0.321,\n          },\n        },\n        {\n          fields: {\n            Completion: 0.875,\n          },\n        },\n      ],\n    });\n\n    let host: ITableFullVo | undefined;\n    try {\n      host = await createTable(baseId, {\n        name: 'link-format-host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Label: 'host row' } }],\n      });\n\n      const dateLinkField = await createField(host.id, {\n        name: 'Date Link',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: foreignDate.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const dateMultiLinkField = await createField(host.id, {\n        name: 'Date Links',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: foreignDate.id,\n          relationship: Relationship.ManyMany,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const numberLinkField = await createField(host.id, {\n        name: 'Number Link',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: foreignNumber.id,\n          relationship: Relationship.ManyOne,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const numberMultiLinkField = await createField(host.id, {\n        name: 'Number Links',\n        type: FieldType.Link,\n        options: {\n          foreignTableId: foreignNumber.id,\n          relationship: Relationship.ManyMany,\n        } as ILinkFieldOptionsRo,\n      } as IFieldRo);\n\n      const hostRecordId = host.records[0].id;\n\n      await updateRecordByApi(host.id, hostRecordId, dateLinkField.id, {\n        id: foreignDate.records[0].id,\n      });\n\n      await updateRecordByApi(\n        host.id,\n        hostRecordId,\n        dateMultiLinkField.id,\n        foreignDate.records.map((record) => ({ id: record.id }))\n      );\n\n      await updateRecordByApi(host.id, hostRecordId, numberLinkField.id, {\n        id: foreignNumber.records[0].id,\n      });\n\n      await updateRecordByApi(\n        host.id,\n        hostRecordId,\n        numberMultiLinkField.id,\n        foreignNumber.records.map((record) => ({ id: record.id }))\n      );\n\n      const record = await getRecord(host.id, hostRecordId);\n      const dateLink = record.data.fields[dateLinkField.name] as {\n        id: string;\n        title: string;\n      } | null;\n      expect(dateLink).toBeDefined();\n      expect(dateLink?.id).toBe(foreignDate.records[0].id);\n      expect(dateLink?.title).toBe('2024/05/06');\n\n      const numberLink = record.data.fields[numberLinkField.name] as {\n        id: string;\n        title: string;\n      } | null;\n      expect(numberLink).toBeDefined();\n      expect(numberLink?.id).toBe(foreignNumber.records[0].id);\n      expect(numberLink?.title).toBe('32.1%');\n\n      const dateMultiLink = record.data.fields[dateMultiLinkField.name] as Array<{\n        id: string;\n        title: string;\n      }> | null;\n      expect(Array.isArray(dateMultiLink)).toBe(true);\n      expect(dateMultiLink?.length).toBe(2);\n      const dateMultiTitles = dateMultiLink?.map((item) => item.title);\n      expect(dateMultiTitles).toEqual(['2024/05/06', '2024/05/07']);\n\n      const numberMultiLink = record.data.fields[numberMultiLinkField.name] as Array<{\n        id: string;\n        title: string;\n      }> | null;\n      expect(Array.isArray(numberMultiLink)).toBe(true);\n      expect(numberMultiLink?.length).toBe(2);\n      const numberMultiTitles = numberMultiLink?.map((item) => item.title);\n      expect(numberMultiTitles).toEqual(['32.1%', '87.5%']);\n    } finally {\n      if (host) {\n        await permanentDeleteTable(baseId, host.id);\n      }\n      await permanentDeleteTable(baseId, foreignDate.id);\n      await permanentDeleteTable(baseId, foreignNumber.id);\n    }\n  });\n\n  describe('safe calculate', () => {\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: 'table safe' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should safe calculate error function', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: \"'x'*10\",\n        },\n      });\n\n      expect(field).toBeDefined();\n    });\n\n    it('should calculate formula with timeZone', async () => {\n      const field1 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: \"DAY('2024-02-29T00:00:00+08:00')\",\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const record1 = await getRecord(table.id, table.records[0].id);\n      expect(record1.data.fields[field1.name]).toEqual(29);\n\n      const field2 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: \"DAY('2024-02-28T00:00:00+09:00')\",\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const record2 = await getRecord(table.id, table.records[0].id);\n      expect(record2.data.fields[field2.name]).toEqual(27);\n    });\n\n    it('should default formula timeZone when missing', async () => {\n      const inputIso = '2024-02-28T00:00:00+09:00';\n      // Use system default timezone instead of hardcoded 'UTC'\n      const defaultTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n      const field = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `DAY(\"${inputIso}\")`,\n        },\n      });\n\n      const fieldOptions = field.options as { timeZone?: string } | undefined;\n      expect(fieldOptions?.timeZone).toEqual(defaultTimeZone);\n\n      const record = await getRecord(table.id, table.records[0].id);\n      const expectedDay = Number(\n        new Intl.DateTimeFormat('en-GB', {\n          timeZone: defaultTimeZone,\n          day: '2-digit',\n        }).format(new Date(inputIso))\n      );\n\n      expect(record.data.fields[field.name]).toEqual(expectedDay);\n    });\n\n    it('should evaluate WORKDAY with weekend, holiday and negative offsets', async () => {\n      const dateAField = await createField(table.id, {\n        name: 'WORKDAY Date A',\n        type: FieldType.Date,\n      });\n      const dateBField = await createField(table.id, {\n        name: 'WORKDAY Date B',\n        type: FieldType.Date,\n      });\n      const dateCField = await createField(table.id, {\n        name: 'WORKDAY Date C',\n        type: FieldType.Date,\n      });\n\n      const scenarios = [\n        {\n          expression: `DATESTR(WORKDAY({${dateAField.id}}, 3))`,\n          expected: '2026-01-20',\n        },\n        {\n          expression: `DATESTR(WORKDAY({${dateAField.id}}, 3, \"2026-01-16\"))`,\n          expected: '2026-01-21',\n        },\n        {\n          expression: `DATESTR(WORKDAY({${dateAField.id}}, 3, \"2026-01-16,2026-01-19\"))`,\n          expected: '2026-01-22',\n        },\n        {\n          expression: `DATESTR(WORKDAY({${dateBField.id}}, 5))`,\n          expected: '2026-02-16',\n        },\n        {\n          expression: `DATESTR(WORKDAY({${dateCField.id}}, -1))`,\n          expected: '2026-02-13',\n        },\n      ] as const;\n\n      const createdFields = await Promise.all(\n        scenarios.map(({ expression }, index) =>\n          createField(table.id, {\n            name: `WORKDAY case ${index + 1}`,\n            type: FieldType.Formula,\n            options: {\n              expression,\n              timeZone: 'UTC',\n            },\n          })\n        )\n      );\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [dateAField.id]: '2026-01-15T00:00:00.000Z',\n              [dateBField.id]: '2026-02-09T00:00:00.000Z',\n              [dateCField.id]: '2026-02-16T00:00:00.000Z',\n            },\n          },\n        ],\n      });\n\n      const record = await getRecord(table.id, created.records[0].id);\n      createdFields.forEach((field, index) => {\n        expect(record.data.fields[field.name]).toEqual(scenarios[index].expected);\n      });\n    });\n\n    it('should bucket Created On records using NOW() formula', async () => {\n      const createdOnField = await createField(table.id, {\n        name: 'Created On',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'UTC',\n          },\n        },\n      });\n\n      const formulaField = await createField(table.id, {\n        name: 'Pitch Day',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(DATETIME_DIFF(NOW(), {${createdOnField.id}}, \"day\")<1, \"Today\", IF(DATETIME_DIFF(NOW(), {${createdOnField.id}}, \"day\")<2, \"Yesterday\", \"Older\"))`,\n          timeZone: 'UTC',\n        },\n      });\n\n      const now = Date.now();\n      const records = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [createdOnField.id]: new Date(now - 2 * 60 * 60 * 1000).toISOString(),\n            },\n          },\n          {\n            fields: {\n              [createdOnField.id]: new Date(now - 26 * 60 * 60 * 1000).toISOString(),\n            },\n          },\n          {\n            fields: {\n              [createdOnField.id]: new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(),\n            },\n          },\n        ],\n      });\n\n      const todayRecord = await getRecord(table.id, records.records[0].id);\n      expect(todayRecord.data.fields[formulaField.name]).toEqual('Today');\n\n      const yesterdayRecord = await getRecord(table.id, records.records[1].id);\n      expect(yesterdayRecord.data.fields[formulaField.name]).toEqual('Yesterday');\n\n      const olderRecord = await getRecord(table.id, records.records[2].id);\n      expect(olderRecord.data.fields[formulaField.name]).toEqual('Older');\n    });\n\n    it('should evaluate formula referencing created time on record create', async () => {\n      const createdTimeField = await createField(table.id, {\n        name: 'Created time',\n        type: FieldType.CreatedTime,\n      });\n\n      const formulaField = await createField(table.id, {\n        name: 'Created age (days)',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATETIME_DIFF(NOW(), {${createdTimeField.id}}, \"day\")`,\n          timeZone: 'UTC',\n        },\n      });\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: {} }],\n      });\n\n      const record = await getRecord(table.id, created.records[0].id);\n      expect(record.data.fields[formulaField.name]).toEqual(0);\n    });\n\n    it('should evaluate formula referencing created by on record create', async () => {\n      const createdByField = await createField(table.id, {\n        name: 'Created by',\n        type: FieldType.CreatedBy,\n      });\n\n      const formulaField = await createField(table.id, {\n        name: 'Creator Name',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${createdByField.id}}`,\n        },\n      });\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: {} }],\n      });\n\n      const record = await getRecord(table.id, created.records[0].id);\n      const createdByValue = record.data.fields[createdByField.name] as { title?: string } | null;\n      expect(createdByValue?.title).toBeTruthy();\n      expect(record.data.fields[formulaField.name]).toEqual(createdByValue?.title);\n    });\n\n    it('should evaluate formula referencing auto number on record create', async () => {\n      const autoNumberField = await createField(table.id, {\n        name: 'Auto number',\n        type: FieldType.AutoNumber,\n      });\n\n      const formulaField = await createField(table.id, {\n        name: 'Auto number x2',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${autoNumberField.id}} * 2`,\n        },\n      });\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: {} }],\n      });\n\n      const record = await getRecord(table.id, created.records[0].id);\n      const autoNumberValue = record.data.fields[autoNumberField.name] as number;\n      expect(record.data.fields[formulaField.name]).toEqual(autoNumberValue * 2);\n    });\n\n    it('should evaluate timezone-aware formatting formulas referencing fields', async () => {\n      const dateField = await createField(table.id, {\n        name: 'tz source',\n        type: FieldType.Date,\n        options: {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: TimeFormatting.Hour24,\n            timeZone: 'Asia/Tokyo',\n          },\n        },\n      });\n\n      const recordId = table.records[0].id;\n      const inputValue = '2024-03-01T00:30:00+09:00';\n      const updatedRecord = await updateRecord(table.id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [dateField.name]: inputValue,\n          },\n        },\n      });\n      const sourceValue = updatedRecord.fields?.[dateField.name] as string;\n      expect(typeof sourceValue).toBe('string');\n\n      const expectedDate = new Intl.DateTimeFormat('en-CA', {\n        timeZone: 'Asia/Shanghai',\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n      }).format(new Date(sourceValue));\n\n      const expectedTime = new Intl.DateTimeFormat('en-GB', {\n        timeZone: 'Asia/Shanghai',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n        hour12: false,\n      })\n        .format(new Date(sourceValue))\n        .replace(/\\./g, ':'); // ensure consistent separators on all locales\n\n      const dateStrField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `DATESTR({${dateField.id}})`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      let record = await getRecord(table.id, recordId);\n      expect(record.data.fields[dateStrField.name]).toEqual(expectedDate);\n\n      const timeStrField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `TIMESTR({${dateField.id}})`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      record = await getRecord(table.id, recordId);\n      expect(record.data.fields[timeStrField.name]).toEqual(expectedTime);\n\n      const workdayField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `DATESTR(WORKDAY({${dateField.id}}, 1))`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      record = await getRecord(table.id, recordId);\n      expect(record.data.fields[workdayField.name]).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n    });\n\n    it.skip('should evaluate boolean formulas with timezone aware date arguments', async () => {\n      const dateField = await createField(table.id, {\n        name: 'Boolean date',\n        type: FieldType.Date,\n      });\n\n      const recordId = table.records[0].id;\n      await updateRecord(table.id, recordId, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [dateField.name]: '2024-03-01T00:00:00+08:00',\n          },\n        },\n      });\n\n      const andField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `AND(IS_AFTER({${dateField.id}}, '2024-02-28T23:00:00+08:00'), IS_BEFORE({${dateField.id}}, '2024-03-01T12:00:00+08:00'))`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const recordAfterAnd = await getRecord(table.id, recordId);\n      expect(recordAfterAnd.data.fields[andField.name]).toEqual(true);\n\n      const orField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `OR(IS_AFTER({${dateField.id}}, '2024-03-01T12:00:00+08:00'), IS_SAME(DATETIME_PARSE('2024-03-01T00:00:00+08:00'), {${dateField.id}}, 'minute'))`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const recordAfterOr = await getRecord(table.id, recordId);\n      expect(recordAfterOr.data.fields[orField.name]).toEqual(true);\n\n      const ifField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(IS_AFTER({${dateField.id}}, '2024-02-29T00:00:00+09:00'), 'after', 'before')`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n\n      const recordAfterIf = await getRecord(table.id, recordId);\n      expect(recordAfterIf.data.fields[ifField.name]).toEqual('after');\n    });\n\n    it('should calculate auto number and number field', async () => {\n      const autoNumberField = await createField(table.id, {\n        name: 'ttttttt',\n        type: FieldType.AutoNumber,\n      });\n\n      const numberField = await createField(table.id, {\n        type: FieldType.Number,\n      });\n      const numberField1 = await createField(table.id, {\n        type: FieldType.Number,\n      });\n\n      await updateRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: table.records.map((record) => ({\n          id: record.id,\n          fields: {\n            [numberField.name]: 2,\n            [numberField1.name]: 3,\n          },\n        })),\n      });\n\n      const formulaField = await createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${autoNumberField.id}} & \"-\" & {${numberField.id}} & \"-\" & {${numberField1.id}}`,\n        },\n      });\n\n      const record = await getRecords(table.id);\n      expect(record.records[0].fields[formulaField.name]).toEqual('1-2-3');\n      expect(record.records[0].fields[autoNumberField.name]).toEqual(1);\n\n      await convertField(table.id, formulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${autoNumberField.id}} & \"-\" & {${numberField.id}}`,\n        },\n      });\n\n      const record2 = await getRecord(table.id, table.records[0].id);\n      expect(record2.data.fields[autoNumberField.name]).toEqual(1);\n      expect(record2.data.fields[formulaField.name]).toEqual('1-2');\n\n      await updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [numberField.name]: 22,\n          },\n        },\n      });\n\n      const record3 = await getRecord(table.id, table.records[0].id);\n      expect(record3.data.fields[formulaField.name]).toEqual('1-22');\n      expect(record2.data.fields[autoNumberField.name]).toEqual(1);\n    });\n\n    it('should convert blank-aware formulas referencing created time field', async () => {\n      const recordId = table.records[0].id;\n      const createdTimeField = await createField(table.id, {\n        name: 'created-time',\n        type: FieldType.CreatedTime,\n      });\n\n      const placeholderField = await createField(table.id, {\n        name: 'created-count',\n        type: FieldType.SingleLineText,\n      });\n\n      const countFormulaField = await convertField(table.id, placeholderField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTA({${createdTimeField.id}})`,\n        },\n      });\n\n      const recordAfterFirstConvert = await getRecord(table.id, recordId);\n      expect(recordAfterFirstConvert.data.fields[countFormulaField.name]).toEqual(1);\n\n      const updatedCountFormulaField = await convertField(table.id, countFormulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTA({${createdTimeField.id}}, {${createdTimeField.id}})`,\n        },\n      });\n\n      const recordAfterSecondConvert = await getRecord(table.id, recordId);\n      expect(recordAfterSecondConvert.data.fields[updatedCountFormulaField.name]).toEqual(2);\n\n      const countFormula = await convertField(table.id, updatedCountFormulaField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNT({${createdTimeField.id}})`,\n        },\n      });\n\n      const recordAfterCount = await getRecord(table.id, recordId);\n      expect(recordAfterCount.data.fields[countFormula.name]).toEqual(1);\n\n      const countAllFormula = await convertField(table.id, countFormula.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `COUNTALL({${createdTimeField.id}})`,\n        },\n      });\n\n      const recordAfterCountAll = await getRecord(table.id, recordId);\n      expect(recordAfterCountAll.data.fields[countAllFormula.name]).toEqual(1);\n    });\n\n    it('should update record by name wile have create last modified field', async () => {\n      await createField(table.id, {\n        type: FieldType.LastModifiedTime,\n      });\n\n      await updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Name,\n        record: {\n          fields: {\n            [table.fields[0].name]: '1',\n          },\n        },\n      });\n\n      const record = await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n      expect(record.data.fields[table.fields[0].name]).toEqual('1');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/generated-column-blank-if.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Generated column BLANK() branch stays null (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('IF + BLANK generated column', () => {\n    let table: ITableFullVo;\n    let statusAField: IFieldVo;\n    let statusBField: IFieldVo;\n    let markerField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'generated_blank_if',\n        fields: [\n          {\n            name: 'Status A',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Status B',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Status A': 'Not Available',\n              'Status B': 'In Stock',\n            },\n          },\n          {\n            fields: {\n              'Status A': 'Available',\n              'Status B': 'Not Available',\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((f) => [f.name, f]));\n      statusAField = fieldMap.get('Status A')!;\n      statusBField = fieldMap.get('Status B')!;\n\n      markerField = await createField(table.id, {\n        name: 'Restock Marker',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF(AND({${statusAField.id}} = \"Not Available\", {${statusBField.id}} != \"Not Available\"), \"是\", BLANK())`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('persists null (not empty string) when BLANK branch executes', async () => {\n      const [restockRecord, unavailableRecord] = table.records;\n\n      const freshRestock = await getRecord(table.id, restockRecord.id);\n      expect(freshRestock.fields[markerField.id]).toBe('是');\n\n      const freshUnavailable = await getRecord(table.id, unavailableRecord.id);\n      expect(freshUnavailable.fields[markerField.id]).toBeUndefined();\n\n      await expect(\n        updateRecordByApi(table.id, restockRecord.id, statusBField.id, 'Not Available')\n      ).resolves.toBeDefined();\n\n      const afterToggle = await getRecord(table.id, restockRecord.id);\n      expect(afterToggle.fields[markerField.id]).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/generated-column-numeric-coercion.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst toUtcDateString = (date: Date) => {\n  if (Number.isNaN(date.getTime())) {\n    throw new Error('Invalid date passed to toUtcDateString helper');\n  }\n  return date.toISOString().slice(0, 10);\n};\nconst addUtcDays = (date: Date, days: number) => {\n  const utcStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));\n  utcStart.setUTCDate(utcStart.getUTCDate() + days);\n  return utcStart;\n};\nconst shiftDateString = (value: unknown, days: number, fallback: Date) => {\n  let base = typeof value === 'string' ? new Date(value) : undefined;\n  if (!base || Number.isNaN(base.getTime())) {\n    base = new Date(fallback);\n  }\n  const utcStart = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate()));\n  utcStart.setUTCDate(utcStart.getUTCDate() + days);\n  return toUtcDateString(utcStart);\n};\n\ndescribe('Generated column numeric coercion (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('text fields in arithmetic formulas', () => {\n    let table: ITableFullVo;\n    let durationField: IFieldVo;\n    let consumedField: IFieldVo;\n    let remainingField: IFieldVo;\n    let progressField: IFieldVo;\n\n    beforeEach(async () => {\n      const seedFields: IFieldRo[] = [\n        {\n          name: 'Planned Duration',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Consumed Days',\n          type: FieldType.SingleLineText,\n        },\n      ];\n\n      table = await createTable(baseId, {\n        name: 'generated_numeric_coercion',\n        fields: seedFields,\n        records: [\n          {\n            fields: {\n              'Planned Duration': '10天',\n              'Consumed Days': '3',\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((field) => [field.name, field]));\n      durationField = fieldMap.get('Planned Duration')!;\n      consumedField = fieldMap.get('Consumed Days')!;\n\n      remainingField = await createField(table.id, {\n        name: 'Remaining Days',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${durationField.id}} - {${consumedField.id}}`,\n        },\n      });\n\n      progressField = await createField(table.id, {\n        name: 'Progress',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${consumedField.id}} / {${durationField.id}}`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('coerces numeric strings when updating generated columns', async () => {\n      const recordId = table.records[0].id;\n\n      const createdRecord = await getRecord(table.id, recordId);\n      expect(createdRecord.fields[remainingField.id]).toBe(7);\n      expect(createdRecord.fields[progressField.id]).toBeCloseTo(3 / 10, 2);\n\n      await expect(\n        updateRecordByApi(table.id, recordId, consumedField.id, '4天')\n      ).resolves.toBeDefined();\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      expect(updatedRecord.fields[remainingField.id]).toBe(6);\n      expect(updatedRecord.fields[progressField.id]).toBeCloseTo(4 / 10, 2);\n\n      await expect(\n        updateRecordByApi(table.id, recordId, durationField.id, '12周')\n      ).resolves.toBeDefined();\n\n      const finalRecord = await getRecord(table.id, recordId);\n      expect(finalRecord.fields[remainingField.id]).toBe(8);\n      expect(finalRecord.fields[progressField.id]).toBeCloseTo(4 / 12, 2);\n    });\n  });\n\n  describe('blank arithmetic operands', () => {\n    let table: ITableFullVo;\n    let valueField: IFieldVo;\n    let optionalField: IFieldVo;\n    let addField: IFieldVo;\n    let subtractField: IFieldVo;\n    let multiplyField: IFieldVo;\n    let divideValueByOptionalField: IFieldVo;\n    let divideOptionalByValueField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'generated_blank_arithmetic',\n        fields: [\n          {\n            name: 'Value',\n            type: FieldType.Number,\n          },\n          {\n            name: 'Optional',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Value: 10,\n            },\n          },\n          {\n            fields: {\n              Optional: 4,\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((field) => [field.name, field]));\n      valueField = fieldMap.get('Value')!;\n      optionalField = fieldMap.get('Optional')!;\n\n      addField = await createField(table.id, {\n        name: 'Add',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${valueField.id}} + {${optionalField.id}}`,\n        },\n      });\n\n      subtractField = await createField(table.id, {\n        name: 'Subtract',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${valueField.id}} - {${optionalField.id}}`,\n        },\n      });\n\n      multiplyField = await createField(table.id, {\n        name: 'Multiply',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${valueField.id}} * {${optionalField.id}}`,\n        },\n      });\n\n      divideValueByOptionalField = await createField(table.id, {\n        name: 'Value / Optional',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${valueField.id}} / {${optionalField.id}}`,\n        },\n      });\n\n      divideOptionalByValueField = await createField(table.id, {\n        name: 'Optional / Value',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${optionalField.id}} / {${valueField.id}}`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('treats blank operands as zero in arithmetic formulas', async () => {\n      const [valueOnlyRecord, optionalOnlyRecord] = table.records;\n\n      const recordWithValue = await getRecord(table.id, valueOnlyRecord.id);\n      expect(recordWithValue.fields[addField.id]).toBe(10);\n      expect(recordWithValue.fields[subtractField.id]).toBe(10);\n      expect(recordWithValue.fields[multiplyField.id]).toBe(0);\n      expect(recordWithValue.fields[divideOptionalByValueField.id]).toBe(0);\n      expect(recordWithValue.fields[divideValueByOptionalField.id]).toBeUndefined();\n\n      const recordWithOptional = await getRecord(table.id, optionalOnlyRecord.id);\n      expect(recordWithOptional.fields[addField.id]).toBe(4);\n      expect(recordWithOptional.fields[subtractField.id]).toBe(-4);\n      expect(recordWithOptional.fields[multiplyField.id]).toBe(0);\n      expect(recordWithOptional.fields[divideValueByOptionalField.id]).toBe(0);\n      expect(recordWithOptional.fields[divideOptionalByValueField.id]).toBeUndefined();\n    });\n  });\n\n  describe('date arithmetic with generated formulas', () => {\n    let table: ITableFullVo;\n    let dueDateField: IFieldVo;\n    let bufferDaysField: IFieldVo;\n    let startDateField: IFieldVo;\n    let statusField: IFieldVo;\n    let dueDateUtc!: Date;\n\n    beforeEach(async () => {\n      const todayUtc = new Date();\n      todayUtc.setUTCHours(0, 0, 0, 0);\n      dueDateUtc = addUtcDays(todayUtc, 5);\n      const dueDateValue = toUtcDateString(dueDateUtc);\n\n      table = await createTable(baseId, {\n        name: 'generated_date_arithmetic',\n        fields: [\n          {\n            name: 'Due Date',\n            type: FieldType.Date,\n          },\n          {\n            name: 'Buffer Days',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Due Date': dueDateValue,\n              'Buffer Days': 2,\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((field) => [field.name, field]));\n      dueDateField = fieldMap.get('Due Date')!;\n      bufferDaysField = fieldMap.get('Buffer Days')!;\n\n      startDateField = await createField(table.id, {\n        name: 'Start Date',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATESTR({${dueDateField.id}} - {${bufferDaysField.id}})`,\n        },\n      });\n\n      statusField = await createField(table.id, {\n        name: 'Status',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${dueDateField.id}} - {${bufferDaysField.id}} <= TODAY(),\"ready\",\"pending\")`,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('supports date minus numeric operands and comparisons with TODAY()', async () => {\n      const recordId = table.records[0].id;\n      const initialRecord = await getRecord(table.id, recordId);\n      const storedDueDate = initialRecord.fields[dueDateField.id] as string | undefined;\n      const expectedInitialLead = shiftDateString(storedDueDate, -2, dueDateUtc);\n      expect(initialRecord.fields[startDateField.id]).toBe(expectedInitialLead);\n      expect(initialRecord.fields[statusField.id]).toBe('pending');\n\n      await updateRecordByApi(table.id, recordId, bufferDaysField.id, 7);\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      const updatedDueDate = updatedRecord.fields[dueDateField.id] as string | undefined;\n      const expectedUpdatedLead = shiftDateString(updatedDueDate, -7, dueDateUtc);\n      expect(updatedRecord.fields[startDateField.id]).toBe(expectedUpdatedLead);\n      expect(updatedRecord.fields[statusField.id]).toBe('ready');\n    });\n  });\n\n  describe('workday diff with numeric inputs', () => {\n    let table: ITableFullVo;\n    let monthField: IFieldVo;\n    let workdayDiffField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'generated_workday_numeric',\n        fields: [\n          {\n            name: 'Month Number',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              'Month Number': 8,\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((field) => [field.name, field]));\n      monthField = fieldMap.get('Month Number')!;\n\n      workdayDiffField = await createField(table.id, {\n        name: 'Workdays Delta',\n        type: FieldType.Formula,\n        options: {\n          expression: `WORKDAY_DIFF({${monthField.id}} + 1, {${monthField.id}})`,\n          timeZone: 'Etc/GMT-8',\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('returns null instead of raising a cast error', async () => {\n      const recordId = table.records[0].id;\n\n      const createdRecord = await getRecord(table.id, recordId);\n      expect(createdRecord.fields[workdayDiffField.id] ?? null).toBeNull();\n\n      await expect(updateRecordByApi(table.id, recordId, monthField.id, 12)).resolves.toBeDefined();\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      expect(updatedRecord.fields[workdayDiffField.id] ?? null).toBeNull();\n    });\n  });\n\n  describe('workday with date and numeric field inputs (regression)', () => {\n    let table: ITableFullVo;\n    let dateField: IFieldVo;\n    let numberField: IFieldVo;\n    let workdayField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'generated_workday_date_number',\n        fields: [\n          {\n            name: 'Date',\n            type: FieldType.Date,\n          },\n          {\n            name: 'Number',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Date: '2026-01-22',\n              Number: 1,\n            },\n          },\n        ],\n      });\n\n      const fieldMap = new Map(table.fields.map((field) => [field.name, field]));\n      dateField = fieldMap.get('Date')!;\n      numberField = fieldMap.get('Number')!;\n\n      workdayField = await createField(table.id, {\n        name: 'Workday Date',\n        type: FieldType.Formula,\n        options: {\n          expression: `DATESTR(WORKDAY({${dateField.id}}, {${numberField.id}}))`,\n          timeZone: 'Asia/Shanghai',\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('creates field and computes date when days parameter references number field', async () => {\n      const recordId = table.records[0].id;\n      const createdRecord = await getRecord(table.id, recordId);\n      expect(createdRecord.fields[workdayField.id]).toBe('2026-01-23');\n\n      await expect(updateRecordByApi(table.id, recordId, numberField.id, 3)).resolves.toBeDefined();\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      expect(updatedRecord.fields[workdayField.id]).toBe('2026-01-27');\n    });\n  });\n\n  describe('workday diff referencing numeric formula (regression)', () => {\n    let table: ITableFullVo;\n    let monthFormulaField: IFieldVo;\n    let workdayDiffField: IFieldVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'generated_workday_formula_ref',\n        fields: [\n          {\n            name: 'Dummy',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Dummy: 1,\n            },\n          },\n        ],\n      });\n\n      monthFormulaField = await createField(table.id, {\n        name: 'Month Num',\n        type: FieldType.Formula,\n        options: {\n          expression: 'MONTH(TODAY())-1',\n          timeZone: 'Etc/GMT-8',\n        },\n      });\n\n      workdayDiffField = await createField(table.id, {\n        name: 'Month Workdays',\n        type: FieldType.Formula,\n        options: {\n          expression: `WORKDAY_DIFF({${monthFormulaField.id}} + 1, {${monthFormulaField.id}})`,\n          timeZone: 'Etc/GMT-8',\n        },\n      });\n    });\n\n    afterEach(async () => {\n      if (table) {\n        await permanentDeleteTable(baseId, table.id);\n      }\n    });\n\n    it('returns null when numeric formula is used as date input', async () => {\n      const recordId = table.records[0].id;\n      const createdRecord = await getRecord(table.id, recordId);\n      expect(createdRecord.fields[workdayDiffField.id] ?? null).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/graph.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship, type IFieldRo, FieldKeyType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { planField, planFieldCreate, planFieldConvert, updateRecord } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  deleteTable,\n  permanentDeleteTable,\n  initApp,\n} from './utils/init-app';\n\ndescribe('OpenAPI Graph (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  const baseId = globalThis.testConfig.baseId;\n  let table1: ITableFullVo;\n  let table2: ITableFullVo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table1 = await createTable(baseId, {\n      name: 'table1',\n    });\n    table2 = await createTable(baseId, {\n      name: 'table2',\n      records: Array.from({ length: 10 }).map(() => ({ fields: {} })),\n    });\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, table1.id);\n    await permanentDeleteTable(baseId, table2.id);\n  });\n\n  it('should create formula field plan', async () => {\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${table1.fields[0].id}}`,\n      },\n    };\n\n    const { data: plan } = await planFieldCreate(table1.id, formulaRo);\n\n    expect(plan.updateCellCount).toEqual(3);\n    expect(plan.graph?.nodes).toHaveLength(2);\n    expect(plan.graph?.edges).toHaveLength(1);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should create lookup field plan', async () => {\n    const linkFieldRo: IFieldRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n      },\n    };\n\n    const linkField = await createField(table1.id, linkFieldRo);\n\n    const lookupFieldRo: IFieldRo = {\n      isLookup: true,\n      type: FieldType.SingleLineText,\n      lookupOptions: {\n        foreignTableId: table2.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: table2.fields[0].id,\n      },\n    };\n\n    const { data: plan } = await planFieldCreate(table1.id, lookupFieldRo);\n\n    expect(plan).toMatchObject({\n      updateCellCount: table1.records.length,\n    });\n    expect(plan.graph?.nodes).toHaveLength(3);\n    expect(plan.graph?.edges).toHaveLength(2);\n    expect(plan.graph?.combos).toHaveLength(2);\n  });\n\n  it('should plan an empty simple field with no reference', async () => {\n    const numberField = table1.fields[1];\n\n    const { data: plan } = await planField(table1.id, numberField.id);\n\n    expect(plan).toMatchObject({\n      updateCellCount: 3,\n    });\n\n    expect(plan.graph?.nodes).toHaveLength(1);\n    expect(plan.graph?.edges).toHaveLength(0);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should plan simple field with ManyOne link', async () => {\n    const textField = table1.fields[0];\n    const linkFieldRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: table1.id,\n      },\n    };\n    const linkField = await createField(table2.id, linkFieldRo);\n\n    await updateRecord(table2.id, table2.records[0].id, {\n      record: {\n        fields: {\n          [linkField.id]: { id: table1.records[0].id },\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const { data: plan } = await planField(table1.id, textField.id);\n\n    expect(plan.updateCellCount).toEqual(4);\n\n    expect(plan.graph?.nodes).toHaveLength(2);\n    expect(plan.graph?.edges).toHaveLength(1);\n    expect(plan.graph?.combos).toHaveLength(2);\n  });\n\n  it('should plan simple field with OneMany link', async () => {\n    const textField = table1.fields[0];\n    const linkFieldRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.OneMany,\n        foreignTableId: table1.id,\n      },\n    };\n    const linkField = await createField(table2.id, linkFieldRo);\n\n    await updateRecord(table2.id, table2.records[0].id, {\n      record: {\n        fields: {\n          [linkField.id]: [{ id: table1.records[0].id }, { id: table1.records[1].id }],\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const { data: plan } = await planField(table1.id, textField.id);\n\n    expect(plan.updateCellCount).toEqual(4);\n\n    expect(plan.graph?.nodes).toHaveLength(2);\n    expect(plan.graph?.edges).toHaveLength(1);\n    expect(plan.graph?.combos).toHaveLength(2);\n  });\n\n  it('should plan text to number field reference by formula', async () => {\n    const textField = table1.fields[0];\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textField.id}}`,\n      },\n    };\n\n    const newFieldRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Number,\n    };\n\n    await createField(table1.id, formulaRo);\n\n    const { data: plan } = await planFieldConvert(table1.id, textField.id, newFieldRo);\n\n    expect(plan.skip).toBeUndefined();\n    expect(plan.updateCellCount).toEqual(6);\n    expect(plan.graph?.nodes).toHaveLength(2);\n    expect(plan.graph?.edges).toHaveLength(1);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should plan text to formula field', async () => {\n    const numberField = table1.fields[1];\n    const textFieldRo: IFieldRo = {\n      type: FieldType.SingleSelect,\n    };\n\n    const textField = await createField(table1.id, textFieldRo);\n\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${numberField.id}}`,\n      },\n    };\n\n    const { data: plan } = await planFieldConvert(table1.id, textField.id, formulaRo);\n\n    expect(plan.skip).toBeUndefined();\n    expect(plan).toMatchObject({\n      updateCellCount: 3,\n    });\n    expect(plan.graph?.nodes).toHaveLength(2);\n    expect(plan.graph?.edges).toHaveLength(1);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should plan formula update with more reference field', async () => {\n    const textField = table1.fields[0];\n    const numberField = table1.fields[1];\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textField.id}}`,\n      },\n    };\n\n    const newFormulaFieldRo: IFieldRo = {\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textField.id}} & {${numberField.id}}`,\n      },\n    };\n\n    const formulaField = await createField(table1.id, formulaRo);\n\n    const { data: plan } = await planFieldConvert(table1.id, formulaField.id, newFormulaFieldRo);\n\n    expect(plan.skip).toBeUndefined();\n    expect(plan).toMatchObject({\n      updateCellCount: 3,\n    });\n    expect(plan.graph?.nodes).toHaveLength(3);\n    expect(plan.graph?.edges).toHaveLength(2);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should plan formula with more reference field', async () => {\n    const textField = table1.fields[0];\n    const numberField = table1.fields[1];\n\n    const formulaRo: IFieldRo = {\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textField.id}} & {${numberField.id}}`,\n      },\n    };\n\n    const formulaField = await createField(table1.id, formulaRo);\n\n    const { data: plan } = await planField(table1.id, formulaField.id);\n\n    expect(plan).toMatchObject({\n      updateCellCount: 9,\n    });\n    expect(plan.graph?.nodes).toHaveLength(3);\n    expect(plan.graph?.edges).toHaveLength(2);\n    expect(plan.graph?.combos).toHaveLength(1);\n  });\n\n  it('should update normal field plan', async () => {\n    const textField = table1.fields[0];\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${textField.id}}`,\n      },\n    };\n\n    const newFieldRo: IFieldRo = {\n      name: 'new Name',\n      type: textField.type,\n    };\n\n    await createField(table1.id, formulaRo);\n\n    const { data: plan } = await planFieldConvert(table1.id, textField.id, newFieldRo);\n\n    expect(plan.skip).toBeTruthy();\n  });\n\n  it('should update lookup field plan', async () => {\n    const linkFieldRo: IFieldRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table2.id,\n      },\n    };\n\n    const linkField = await createField(table1.id, linkFieldRo);\n\n    const lookupFieldRo: IFieldRo = {\n      isLookup: true,\n      type: FieldType.SingleLineText,\n      lookupOptions: {\n        foreignTableId: table2.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: table2.fields[0].id,\n      },\n    };\n\n    const lookupField = await createField(table1.id, lookupFieldRo);\n\n    const formulaRo: IFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${lookupField.id}}`,\n      },\n    };\n    await createField(table1.id, formulaRo);\n\n    const lookupFieldRo2: IFieldRo = {\n      isLookup: true,\n      type: FieldType.Number,\n      lookupOptions: {\n        foreignTableId: table2.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: table2.fields[1].id,\n      },\n    };\n\n    const { data: plan } = await planFieldConvert(table1.id, lookupField.id, lookupFieldRo2);\n\n    expect(plan.skip).toBeUndefined();\n\n    expect(plan).toMatchObject({\n      updateCellCount: 6,\n    });\n    expect(plan.graph?.nodes).toHaveLength(3);\n    expect(plan.graph?.edges).toHaveLength(2);\n    expect(plan.graph?.combos).toHaveLength(2);\n  });\n\n  it('should ignore stale references to deleted fields when planning single select conversion', async () => {\n    const hostField = await createField(table1.id, {\n      name: 'stale source',\n      type: FieldType.SingleLineText,\n    });\n    const tempTable = await createTable(baseId, {\n      name: 'stale-temp-table',\n    });\n    const deletedFieldId = tempTable.fields[0].id;\n    const staleReferenceId = `ref-stale-${Date.now()}`;\n\n    try {\n      await deleteTable(baseId, tempTable.id);\n\n      const deletedField = await prisma.txClient().field.findUnique({\n        where: { id: deletedFieldId },\n        select: { id: true, deletedTime: true },\n      });\n      expect(deletedField?.deletedTime).toBeTruthy();\n\n      await prisma.txClient().reference.create({\n        data: {\n          id: staleReferenceId,\n          fromFieldId: hostField.id,\n          toFieldId: deletedFieldId,\n        },\n      });\n\n      const { data: plan } = await planFieldConvert(table1.id, hostField.id, {\n        type: FieldType.SingleSelect,\n      });\n\n      expect(plan.skip).toBeUndefined();\n      expect(plan.updateCellCount).toEqual(table1.records.length);\n      expect(plan.graph?.nodes).toHaveLength(1);\n      expect(plan.graph?.edges).toHaveLength(0);\n      expect(plan.graph?.combos).toHaveLength(1);\n    } finally {\n      await prisma.txClient().reference.deleteMany({\n        where: { id: staleReferenceId },\n      });\n      await permanentDeleteTable(baseId, tempTable.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/group.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, IGroup, IGroupItem, IViewGroupRo } from '@teable/core';\nimport {\n  CellValueType,\n  Colors,\n  FieldKeyType,\n  FieldType,\n  Relationship,\n  SortFunc,\n} from '@teable/core';\nimport type { IGetRecordsRo, IGroupHeaderPoint, IGroupPoint, ITableFullVo } from '@teable/openapi';\nimport { GroupPointType, updateViewGroup, updateViewSort } from '@teable/openapi';\nimport { isEmpty, orderBy } from 'lodash';\nimport { x_20 } from './data-helpers/20x';\nimport {\n  createTable,\n  permanentDeleteTable,\n  getRecords,\n  getView,\n  initApp,\n  createField,\n  getFields,\n  updateRecordByApi,\n} from './utils/init-app';\n\nlet app: INestApplication;\n\nconst baseId = globalThis.testConfig.baseId;\n\nconst typeTests = [\n  {\n    type: CellValueType.String,\n  },\n  {\n    type: CellValueType.Number,\n  },\n  {\n    type: CellValueType.DateTime,\n  },\n  {\n    type: CellValueType.Boolean,\n  },\n];\n\nconst getRecordsByOrder = (\n  records: ITableFullVo['records'],\n  conditions: IGroupItem[],\n  fields: ITableFullVo['fields']\n) => {\n  if (Array.isArray(records) && !records.length) return [];\n  const fns = conditions.map((condition) => {\n    const { fieldId } = condition;\n    const field = fields.find((field) => field.id === fieldId) as ITableFullVo['fields'][number];\n    const { id, isMultipleCellValue } = field;\n    return (record: ITableFullVo['records'][number]) => {\n      if (isEmpty(record?.fields?.[id])) {\n        return -Infinity;\n      }\n      if (isMultipleCellValue) {\n        return JSON.stringify(record?.fields?.[id]);\n      }\n    };\n  });\n  const orders = conditions.map((condition) => condition.order || 'asc');\n  return orderBy([...records], fns, orders);\n};\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\ndescribe('OpenAPI ViewController view group (e2e)', () => {\n  let tableId: string;\n  let viewId: string;\n  let fields: IFieldRo[];\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'Table' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    fields = result.fields!;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test('/api/table/{tableId}/view/{viewId}/viewGroup view group (PUT)', async () => {\n    const assertGroup = {\n      group: [\n        {\n          fieldId: fields[0].id as string,\n          order: SortFunc.Asc,\n        },\n      ],\n    };\n    await updateViewGroup(tableId, viewId, assertGroup);\n    const updatedView = await getView(tableId, viewId);\n    const viewGroup = updatedView.group;\n    expect(viewGroup).toEqual(assertGroup.group);\n  });\n\n  it('should not allow to modify group for button field', async () => {\n    const buttonField = await createField(tableId, {\n      type: FieldType.Button,\n    });\n\n    const assertGroup: IViewGroupRo = {\n      group: [\n        {\n          fieldId: buttonField.id,\n          order: SortFunc.Asc,\n        },\n      ],\n    };\n\n    await expect(updateViewGroup(tableId, viewId, assertGroup)).rejects.toThrow();\n  });\n});\n\ndescribe('Single select grouping respects choice order', () => {\n  const choiceOrder = ['Out of stock', 'In stock', 'Backordered'] as const;\n  const choiceDefinitions = choiceOrder.map((name, index) => ({\n    id: `choice-${index}`,\n    name,\n    color: index === 0 ? Colors.Red : index === 1 ? Colors.Green : Colors.Blue,\n  }));\n  const statusFieldName = 'Stock Status';\n  const quantityFieldName = 'Item';\n  const recordDefinitions: Record<(typeof choiceOrder)[number], string[]> = {\n    'Out of stock': ['record-out-1', 'record-out-2'],\n    'In stock': ['record-in-1'],\n    Backordered: ['record-back-1'],\n  };\n\n  let table: ITableFullVo;\n  let statusField: IFieldRo;\n\n  beforeAll(async () => {\n    table = await createTable(baseId, {\n      name: 'group_single_select_order',\n      fields: [\n        {\n          name: quantityFieldName,\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: statusFieldName,\n          type: FieldType.SingleSelect,\n          options: {\n            choices: choiceDefinitions,\n          },\n        },\n      ],\n      records: choiceOrder.flatMap((status) =>\n        recordDefinitions[status].map((recordName) => ({\n          fields: {\n            [quantityFieldName]: recordName,\n            [statusFieldName]: status,\n          },\n        }))\n      ),\n    });\n    statusField = table.fields!.find(\n      ({ name, type }) => name === statusFieldName && type === FieldType.SingleSelect\n    ) as IFieldRo;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  const assertGroupingOrder = async (\n    order: SortFunc,\n    expectedGroupOrder: (typeof choiceOrder)[number][]\n  ) => {\n    const query: IGetRecordsRo = {\n      fieldKeyType: FieldKeyType.Id,\n      groupBy: [{ fieldId: statusField.id!, order }],\n    };\n    const { records, extra } = await getRecords(table.id, query);\n    const headerValues =\n      extra?.groupPoints\n        ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header)\n        .map((point) => point.value as string) ?? [];\n    expect(headerValues).toEqual(expectedGroupOrder);\n\n    const statusSequence = records.map((record) => record.fields?.[statusField.id!] as string);\n    const expectedStatusSequence = expectedGroupOrder.flatMap((status) =>\n      recordDefinitions[status].map(() => status)\n    );\n    expect(statusSequence).toEqual(expectedStatusSequence);\n  };\n\n  it('orders groups by choice order when ascending', async () => {\n    await assertGroupingOrder(SortFunc.Asc, [...choiceOrder]);\n  });\n\n  it('orders groups by choice order when descending', async () => {\n    await assertGroupingOrder(SortFunc.Desc, [...choiceOrder].reverse());\n  });\n});\n\ndescribe('OpenAPI ViewController raw group (e2e) base cellValueType', () => {\n  let table: ITableFullVo;\n\n  beforeAll(async () => {\n    table = await createTable(baseId, {\n      name: 'group_x_20',\n      fields: x_20.fields,\n      records: x_20.records,\n    });\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/view/{viewId}/viewGroup view group (POST) Test CellValueType: $type`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2, defaultViewId: subTableDefaultViewId } = table;\n      const field = fields2.find(\n        (field) => field.cellValueType === type\n      ) as ITableFullVo['fields'][number];\n      const { id: fieldId } = field;\n\n      const ascGroups: IGetRecordsRo['groupBy'] = [{ fieldId, order: SortFunc.Asc }];\n      await updateViewGroup(subTableId, subTableDefaultViewId!, { group: ascGroups });\n      const ascOriginRecords = (\n        await getRecords(subTableId, { fieldKeyType: FieldKeyType.Id, groupBy: ascGroups })\n      ).records;\n      const descGroups: IGetRecordsRo['groupBy'] = [{ fieldId, order: SortFunc.Desc }];\n      await updateViewGroup(subTableId, subTableDefaultViewId!, { group: descGroups });\n      const descOriginRecords = (\n        await getRecords(subTableId, { fieldKeyType: FieldKeyType.Id, groupBy: descGroups })\n      ).records;\n\n      const resultAscRecords = getRecordsByOrder(ascOriginRecords, ascGroups, fields2);\n      const resultDescRecords = getRecordsByOrder(descOriginRecords, descGroups, fields2);\n\n      expect(ascOriginRecords).toEqual(resultAscRecords);\n      expect(descOriginRecords).toEqual(resultDescRecords);\n    }\n  );\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/view/{viewId}/viewGroup view group with order (POST) Test CellValueType: $type`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2, defaultViewId: subTableDefaultViewId } = table;\n      const field = fields2.find(\n        (field) => field.cellValueType === type\n      ) as ITableFullVo['fields'][number];\n      const { id: fieldId } = field;\n\n      const ascGroups: IGetRecordsRo['groupBy'] = [{ fieldId, order: SortFunc.Asc }];\n      const descGroups: IGetRecordsRo['groupBy'] = [{ fieldId, order: SortFunc.Desc }];\n\n      await updateViewGroup(subTableId, subTableDefaultViewId!, { group: ascGroups });\n      await updateViewSort(subTableId, subTableDefaultViewId!, { sort: { sortObjs: descGroups } });\n      const ascOriginRecords = (\n        await getRecords(subTableId, { fieldKeyType: FieldKeyType.Id, groupBy: ascGroups })\n      ).records;\n\n      await updateViewGroup(subTableId, subTableDefaultViewId!, { group: descGroups });\n      await updateViewSort(subTableId, subTableDefaultViewId!, { sort: { sortObjs: ascGroups } });\n      const descOriginRecords = (\n        await getRecords(subTableId, { fieldKeyType: FieldKeyType.Id, groupBy: descGroups })\n      ).records;\n\n      const resultAscRecords = getRecordsByOrder(ascOriginRecords, ascGroups, fields2);\n      const resultDescRecords = getRecordsByOrder(descOriginRecords, descGroups, fields2);\n\n      expect(ascOriginRecords).toEqual(resultAscRecords);\n      expect(descOriginRecords).toEqual(resultDescRecords);\n    }\n  );\n});\n\ndescribe('Lookup grouping keeps headers aligned', () => {\n  const categoryChoices = ['Teaching Contest', 'Faculty Contest', 'World Skills', 'Other'] as const;\n\n  const projectDefinitions = [\n    {\n      name: 'Ethics Deck',\n      category: categoryChoices[0],\n      subject: 'Ethics & Law',\n    },\n    {\n      name: 'Culinary Basics',\n      category: categoryChoices[1],\n      subject: 'Chinese Cuisine',\n    },\n    {\n      name: 'Vision Health',\n      category: categoryChoices[2],\n      subject: 'Optometry',\n    },\n    {\n      name: 'VR Deck A',\n      category: categoryChoices[3],\n      subject: 'VR Banking English',\n    },\n    {\n      name: 'VR Deck B',\n      category: categoryChoices[3],\n      subject: 'VR Banking English - Final',\n    },\n  ];\n\n  let projectTable: ITableFullVo;\n  let taskTable: ITableFullVo;\n  let categoryLookupFieldId: string;\n  let subjectLookupFieldId: string;\n\n  const simplifyValue = (value: unknown) => {\n    if (Array.isArray(value)) {\n      return value[0];\n    }\n    return value as string | number | null;\n  };\n\n  const extractGroupPaths = (points: IGroupPoint[]) => {\n    const paths: { path: (string | number | null)[]; count: number }[] = [];\n    const current: (string | number | null)[] = [];\n\n    points.forEach((point) => {\n      if (point.type === GroupPointType.Header) {\n        current[point.depth] = simplifyValue(point.value);\n        current.length = point.depth + 1;\n      }\n\n      if (point.type === GroupPointType.Row) {\n        paths.push({ path: [...current], count: point.count });\n      }\n    });\n\n    return paths;\n  };\n\n  beforeAll(async () => {\n    projectTable = await createTable(baseId, {\n      name: 'group_lookup_projects',\n      fields: [\n        {\n          name: 'Project Name',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Category',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: categoryChoices.map((name, index) => ({\n              id: `choice-${index}`,\n              name,\n              color: Colors.Blue,\n            })),\n          },\n        },\n        {\n          name: 'Subject',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      records: projectDefinitions.map((definition) => ({\n        fields: {\n          'Project Name': definition.name,\n          Category: definition.category,\n          Subject: definition.subject,\n        },\n      })),\n    });\n\n    taskTable = await createTable(baseId, {\n      name: 'group_lookup_tasks',\n      fields: [\n        {\n          name: 'Task Name',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      records: projectDefinitions.map((definition, index) => ({\n        fields: {\n          'Task Name': `Task-${index + 1}-${definition.name}`,\n        },\n      })),\n    });\n\n    const linkField = (await createField(taskTable.id, {\n      name: 'Linked Project',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: projectTable.id,\n      },\n    })) as IFieldVo;\n\n    await Promise.all(\n      taskTable.records.map((record, index) =>\n        updateRecordByApi(taskTable.id, record.id, linkField.id, [\n          { id: projectTable.records[index].id },\n        ])\n      )\n    );\n\n    const [projectFields] = await Promise.all([\n      getFields(projectTable.id),\n      getFields(taskTable.id),\n    ]);\n\n    const categoryField = projectFields.find(({ name }) => name === 'Category') as IFieldVo;\n    const subjectField = projectFields.find(({ name }) => name === 'Subject') as IFieldVo;\n\n    await createField(taskTable.id, {\n      name: 'Category',\n      type: categoryField.type,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: projectTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: categoryField.id,\n      },\n    });\n\n    await createField(taskTable.id, {\n      name: 'Subject',\n      type: subjectField.type,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: projectTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: subjectField.id,\n      },\n    });\n\n    const refreshedTaskFields = await getFields(taskTable.id);\n\n    categoryLookupFieldId = refreshedTaskFields.find(\n      ({ name, isLookup }) => name === 'Category' && isLookup\n    )?.id as string;\n\n    subjectLookupFieldId = refreshedTaskFields.find(\n      ({ name, isLookup }) => name === 'Subject' && isLookup\n    )?.id as string;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, taskTable.id);\n    await permanentDeleteTable(baseId, projectTable.id);\n  });\n\n  it('groups by lookup single select then lookup text in expected order', async () => {\n    const groupBy: IGroup = [\n      { fieldId: categoryLookupFieldId, order: SortFunc.Asc },\n      { fieldId: subjectLookupFieldId, order: SortFunc.Asc },\n    ];\n\n    const { records, extra } = await getRecords(taskTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      groupBy,\n    });\n\n    const groupPoints = extra?.groupPoints as IGroupPoint[] | undefined;\n    expect(groupPoints).toBeDefined();\n\n    const paths = extractGroupPaths(groupPoints ?? []);\n    const expectedPaths = projectDefinitions.map(({ category, subject }) => [category, subject]);\n    expect(paths.map(({ path }) => path)).toEqual(expectedPaths);\n    expect(paths.reduce((sum, { count }) => sum + count, 0)).toEqual(records.length);\n  });\n});\n\ndescribe('Lookup single select respects choice order when sorting groups', () => {\n  // Deliberately set choice order opposite to alphabetical to catch regressions\n  const choiceOrder = ['Z-Type', 'A-Type'] as const;\n\n  let sourceTable: ITableFullVo;\n  let targetTable: ITableFullVo;\n  let categoryLookupFieldId: string;\n\n  const normalize = (value: unknown) => (Array.isArray(value) ? value[0] : value) as string;\n\n  beforeAll(async () => {\n    sourceTable = await createTable(baseId, {\n      name: 'group_lookup_choice_source',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText },\n        {\n          name: 'Category',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: choiceOrder.map((name, index) => ({\n              id: `choice-${index}`,\n              name,\n              color: Colors.Blue,\n            })),\n          },\n        },\n      ],\n      records: [\n        { fields: { Name: 'Item-A', Category: choiceOrder[0] } },\n        { fields: { Name: 'Item-B', Category: choiceOrder[1] } },\n      ],\n    });\n\n    targetTable = await createTable(baseId, {\n      name: 'group_lookup_choice_target',\n      fields: [{ name: 'Task', type: FieldType.SingleLineText }],\n      records: [{ fields: { Task: 'Task-B-Second' } }, { fields: { Task: 'Task-A-First' } }],\n    });\n\n    const linkField = (await createField(targetTable.id, {\n      name: 'Link',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: sourceTable.id,\n      },\n    })) as IFieldVo;\n\n    // Deliberately link in reverse order to test sorting by choice order\n    await updateRecordByApi(targetTable.id, targetTable.records[0].id, linkField.id, [\n      { id: sourceTable.records[1].id },\n    ]);\n    await updateRecordByApi(targetTable.id, targetTable.records[1].id, linkField.id, [\n      { id: sourceTable.records[0].id },\n    ]);\n\n    const sourceFields = await getFields(sourceTable.id);\n    const categoryField = sourceFields.find(({ name }) => name === 'Category') as IFieldVo;\n\n    await createField(targetTable.id, {\n      name: 'Category',\n      type: categoryField.type,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: sourceTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: categoryField.id,\n      },\n    });\n\n    const refreshedTargetFields = await getFields(targetTable.id);\n    categoryLookupFieldId = refreshedTargetFields.find(\n      ({ name, isLookup }) => name === 'Category' && isLookup\n    )?.id as string;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, targetTable.id);\n    await permanentDeleteTable(baseId, sourceTable.id);\n  });\n\n  it('sorts group headers and records by the lookup choice order', async () => {\n    const { records, extra } = await getRecords(targetTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      groupBy: [{ fieldId: categoryLookupFieldId, order: SortFunc.Asc }],\n    });\n\n    const headerValues =\n      extra?.groupPoints\n        ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header)\n        .map((point) => normalize(point.value)) ?? [];\n    expect(headerValues).toEqual(choiceOrder);\n\n    const recordCategories = records.map((record) =>\n      normalize(record.fields?.[categoryLookupFieldId])\n    );\n    expect(recordCategories).toEqual([choiceOrder[0], choiceOrder[1]]);\n  });\n});\n\ndescribe('Lookup multiple select respects choice order when sorting groups', () => {\n  const choiceOrder = ['Option-One', 'Option-Two', 'Option-Three'] as const;\n\n  let sourceTable: ITableFullVo;\n  let targetTable: ITableFullVo;\n  let multiLookupFieldId: string;\n\n  const normalize = (value: unknown) => {\n    if (Array.isArray(value)) return value[0];\n    try {\n      const parsed = JSON.parse(String(value));\n      if (Array.isArray(parsed)) return parsed[0];\n    } catch {\n      /* ignore */\n    }\n    return value as string;\n  };\n\n  /**\n   * Build a lookup multi-select scenario where some records have multiple choices\n   * and ordering should use the smallest choice index present.\n   */\n  beforeAll(async () => {\n    sourceTable = await createTable(baseId, {\n      name: 'group_lookup_multi_src',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText },\n        {\n          name: 'Tags',\n          type: FieldType.MultipleSelect,\n          options: {\n            choices: choiceOrder.map((name, index) => ({\n              id: `choice-${index}`,\n              name,\n              color: Colors.Blue,\n            })),\n          },\n        },\n      ],\n      records: [\n        { fields: { Name: 'SRC-1', Tags: [choiceOrder[1], choiceOrder[0]] } }, // first Option-Two\n        { fields: { Name: 'SRC-2', Tags: [choiceOrder[0], choiceOrder[2]] } }, // first Option-One\n        { fields: { Name: 'SRC-3', Tags: [choiceOrder[2]] } }, // first Option-Three\n      ],\n    });\n\n    targetTable = await createTable(baseId, {\n      name: 'group_lookup_multi_dst',\n      fields: [{ name: 'Task', type: FieldType.SingleLineText }],\n      records: [\n        { fields: { Task: 'Task-TwoAndOne' } }, // first Option-Two\n        { fields: { Task: 'Task-OneAndThree' } }, // first Option-One\n        { fields: { Task: 'Task-ThreeSolo' } }, // first Option-Three\n      ],\n    });\n\n    const linkField = (await createField(targetTable.id, {\n      name: 'Link',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: sourceTable.id,\n      },\n    })) as IFieldVo;\n\n    // Reverse link order to rely solely on choice order, not insertion\n    await updateRecordByApi(targetTable.id, targetTable.records[0].id, linkField.id, [\n      { id: sourceTable.records[0].id },\n    ]);\n    await updateRecordByApi(targetTable.id, targetTable.records[1].id, linkField.id, [\n      { id: sourceTable.records[1].id },\n    ]);\n    await updateRecordByApi(targetTable.id, targetTable.records[2].id, linkField.id, [\n      { id: sourceTable.records[2].id },\n    ]);\n\n    const sourceFields = await getFields(sourceTable.id);\n    const multiField = sourceFields.find(({ name }) => name === 'Tags') as IFieldVo;\n\n    await createField(targetTable.id, {\n      name: 'Tags',\n      type: multiField.type,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: sourceTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: multiField.id,\n      },\n    });\n\n    const refreshedTargetFields = await getFields(targetTable.id);\n    multiLookupFieldId = refreshedTargetFields.find(\n      ({ name, isLookup }) => name === 'Tags' && isLookup\n    )?.id as string;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, targetTable.id);\n    await permanentDeleteTable(baseId, sourceTable.id);\n  });\n\n  it('sorts lookup multiple select groups by choice order (using first choice)', async () => {\n    const { records, extra } = await getRecords(targetTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      groupBy: [{ fieldId: multiLookupFieldId, order: SortFunc.Asc }],\n    });\n\n    const headerValues =\n      extra?.groupPoints\n        ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header)\n        .map((point) => normalize(point.value)) ?? [];\n\n    // Order should follow choiceOrder based on smallest choice index in the selection\n    expect(headerValues).toEqual([choiceOrder[0], choiceOrder[1], choiceOrder[2]]);\n\n    const recordCategories = records.map((record) =>\n      normalize(record.fields?.[multiLookupFieldId])\n    );\n    expect(recordCategories).toEqual([choiceOrder[0], choiceOrder[1], choiceOrder[2]]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/import-base.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IAttachmentItem, IConditionalRollupFieldOptions, IFilter } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship, SortFunc, ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { IImportBaseSSEEvent, INotifyVo, ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  getFields,\n  installViewPlugin,\n  exportBase,\n  importBase,\n  getTableList,\n  createBase,\n  createDashboard,\n  installPlugin,\n  createPluginPanel,\n  installPluginPanel,\n  getDashboardList,\n  getDashboard,\n  listPluginPanels,\n  getPluginPanel,\n  getPluginPanelPlugin,\n  getViewList,\n  createBaseNode,\n  getBaseNodeTree,\n  moveBaseNode,\n  BaseNodeResourceType,\n  IMPORT_BASE_STREAM,\n} from '@teable/openapi';\nimport { pick } from 'lodash';\nimport type { ClsStore } from 'nestjs-cls';\nimport { ClsService } from 'nestjs-cls';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { AttachmentsService } from '../src/features/attachments/attachments.service';\nimport { replaceStringByMap } from '../src/features/base/utils';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\nimport { createAwaitWithEventWithResult } from './utils/event-promise';\n\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  getViews,\n  getTable,\n  permanentDeleteBase,\n  getRecords,\n  getRecord,\n  deleteField,\n  convertField,\n} from './utils/init-app';\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nasync function waitForComputedRecord(\n  tableId: string,\n  recordId: string,\n  fieldIds: string[],\n  timeoutMs = 8000\n) {\n  const start = Date.now();\n  let latestRecord = await getRecord(tableId, recordId);\n  while (Date.now() - start < timeoutMs) {\n    const hasAllValues = fieldIds.every((fieldId) => latestRecord.fields?.[fieldId] !== undefined);\n    if (hasAllValues) {\n      return latestRecord;\n    }\n    await sleep(200);\n    latestRecord = await getRecord(tableId, recordId);\n  }\n  return latestRecord;\n}\n\nasync function waitForRecordWithFieldValue(\n  tableId: string,\n  fieldId: string,\n  expectedValue: unknown,\n  timeoutMs = 8000\n) {\n  const start = Date.now();\n  while (Date.now() - start < timeoutMs) {\n    const records = await getRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n    });\n    const matched = records.records.find((record) => record.fields?.[fieldId] === expectedValue);\n    if (matched) {\n      return matched;\n    }\n    await sleep(200);\n  }\n  return undefined;\n}\n\nfunction getAttachmentService(app: INestApplication) {\n  return app.get<AttachmentsService>(AttachmentsService);\n}\n\ndescribe('OpenAPI BaseController for base import (e2e)', () => {\n  let app: INestApplication;\n  let appUrl: string;\n  let cookie: string;\n  let sourceBaseId: string;\n  const spaceId = globalThis.testConfig.spaceId;\n  const userId = globalThis.testConfig.userId;\n  let eventEmitterService: EventEmitterService;\n  let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    appUrl = appCtx.appUrl;\n    cookie = appCtx.cookie;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('export table and import the table', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n\n    // let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      const sourceBase = (\n        await createBase({\n          name: 'source_base',\n          spaceId: spaceId,\n          icon: '😄',\n        })\n      ).data;\n      sourceBaseId = sourceBase.id;\n      table = await createTable(sourceBase.id, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(sourceBaseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n      eventEmitterService = app.get(EventEmitterService);\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      awaitWithEvent = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        eventEmitterService,\n        Events.BASE_EXPORT_COMPLETE\n      );\n\n      // dashboard init\n      const dashboard = (await createDashboard(sourceBaseId, { name: 'dashboard' })).data;\n      const dashboard2 = (await createDashboard(sourceBaseId, { name: 'dashboard2' })).data;\n\n      await installPlugin(sourceBaseId, dashboard.id, {\n        name: 'plugin1',\n        pluginId: 'plgchart',\n      });\n\n      await installPlugin(sourceBaseId, dashboard.id, {\n        name: 'plugin2',\n        pluginId: 'plgchart',\n      });\n\n      await installPlugin(sourceBaseId, dashboard2.id, {\n        name: 'plugin2_1',\n        pluginId: 'plgchart',\n      });\n\n      // pluginViews init\n      await installViewPlugin(table.id, { name: 'sheetView1', pluginId: 'plgsheetform' });\n      await installViewPlugin(table.id, { name: 'sheetView2', pluginId: 'plgsheetform' });\n\n      // pluginPanel init\n      const panel = (await createPluginPanel(table.id, { name: 'panel1' })).data;\n      const panel2 = (await createPluginPanel(table.id, { name: 'panel2' })).data;\n\n      await installPluginPanel(table.id, panel.id, {\n        name: 'plugin1',\n        pluginId: 'plgchart',\n      });\n\n      await installPluginPanel(table.id, panel.id, {\n        name: 'plugin2',\n        pluginId: 'plgchart',\n      });\n\n      await installPluginPanel(table.id, panel2.id, {\n        name: 'plugin2_1',\n        pluginId: 'plgchart',\n      });\n\n      table.fields = (await getFields(table.id)).data;\n      table.views = await getViews(table.id);\n      subTable.fields = (await getFields(subTable.id)).data;\n      subTable.views = await getViews(subTable.id);\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(sourceBaseId, table.id);\n      await permanentDeleteTable(sourceBaseId, subTable.id);\n    });\n    it('should export table and import the table', async () => {\n      const { previewUrl: url } = await awaitWithEvent(async () => {\n        await exportBase(sourceBaseId);\n      });\n      const previewUrl = appUrl + url;\n\n      const clsService = app.get(ClsService);\n\n      const attachmentService = getAttachmentService(app);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          // eslint-disable-next-line\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(previewUrl);\n        }\n      );\n\n      const { base, tableIdMap, viewIdMap, fieldIdMap } = (\n        await importBase({\n          notify: {\n            ...(notify as unknown as INotifyVo),\n          },\n          spaceId: spaceId,\n        })\n      ).data;\n\n      expect(base.spaceId).toBe(spaceId);\n\n      const tableList = (await getTableList(base.id)).data;\n\n      expect(tableList.length).toBe(2);\n\n      const table1 = await getTable(base.id, tableList[0].id, {\n        includeContent: true,\n      });\n      const table2 = await getTable(base.id, tableList[1].id, {\n        includeContent: true,\n      });\n\n      const table1Fields = table1.fields!;\n      const table2Fields = table2.fields!;\n\n      const table1Views = table1.views!;\n      const table2Views = table2.views!;\n\n      // fields\n      expect(table1Fields.length).toBe(table.fields.length);\n      expect(table2Fields.length).toBe(subTable.fields.length);\n      const testFieldProperties = [\n        'cellValueType',\n        'dbFieldName',\n        'dbFieldType',\n        'description',\n        'isLookup',\n        'isPrimary',\n        'name',\n        'unique',\n        'notNull',\n        'type',\n      ];\n\n      const duplicatedTable1Fields = table1Fields.map((field) => pick(field, testFieldProperties));\n      const duplicatedTable2Fields = table2Fields.map((field) => pick(field, testFieldProperties));\n\n      const sourceTable1Fields = table.fields.map((field) => pick(field, testFieldProperties));\n      const sourceTable2Fields = subTable.fields.map((field) => pick(field, testFieldProperties));\n\n      expect(duplicatedTable1Fields).toEqual(sourceTable1Fields);\n      expect(duplicatedTable2Fields).toEqual(sourceTable2Fields);\n\n      const testViewProperties = [\n        'id',\n        'columnMeta',\n        'filter',\n        'sort',\n        'group',\n        'options',\n        'pluginInstall',\n        'order',\n      ];\n\n      const duplicatedTable1Views = table1Views.map((view) => pick(view, testViewProperties));\n      const duplicatedTable2Views = table2Views.map((view) => pick(view, testViewProperties));\n\n      const sourceTable1Views = table.views\n        .map((view) => pick(view, testViewProperties))\n        .map((v) => {\n          const res = replaceStringByMap(v, {\n            tableIdMap,\n            viewIdMap,\n            fieldIdMap,\n          });\n          return res ? JSON.parse(res) : v;\n        });\n      const sourceTable2Views = subTable.views\n        .map((view) => pick(view, testViewProperties))\n        .map((v) => {\n          const res = replaceStringByMap(v, {\n            tableIdMap,\n            viewIdMap,\n            fieldIdMap,\n          });\n          return res ? JSON.parse(res) : v;\n        });\n\n      // views\n      expect(table1Views.length).toBe(table.views.length);\n      expect(table2Views.length).toBe(subTable.views.length);\n\n      expect(duplicatedTable1Views).toEqual(sourceTable1Views);\n      expect(duplicatedTable2Views).toEqual(sourceTable2Views);\n\n      // plugins\n      // dashboard\n      const sourceDashboardList = (await getDashboardList(sourceBaseId)).data;\n      const dashboardList = (await getDashboardList(base.id)).data;\n      expect(dashboardList.length).toBe(sourceDashboardList.length);\n      expect(sourceDashboardList.map((d) => d.name)).toEqual(dashboardList.map((d) => d.name));\n\n      const sourceDashboard1Info = (await getDashboard(sourceBaseId, sourceDashboardList[0].id))\n        .data;\n      const dashboard1Info = (await getDashboard(base.id, dashboardList[0].id)).data;\n\n      const sourceDashboard2Info = (await getDashboard(sourceBaseId, sourceDashboardList[1].id))\n        .data;\n      const dashboard2Info = (await getDashboard(base.id, dashboardList[1].id)).data;\n\n      const layoutProperties = ['h', 'w', 'x', 'y'];\n\n      expect(sourceDashboard1Info.layout?.map((l) => pick(l, layoutProperties))).toEqual(\n        dashboard1Info.layout?.map((l) => pick(l, layoutProperties))\n      );\n\n      expect(sourceDashboard2Info.layout?.map((l) => pick(l, layoutProperties))).toEqual(\n        dashboard2Info.layout?.map((l) => pick(l, layoutProperties))\n      );\n\n      // panel\n      const panelList = (await listPluginPanels(table.id)).data;\n\n      const panel1Info = (\n        await getPluginPanel(table.id, panelList.find(({ name }) => name === 'panel1')!.id)\n      ).data;\n\n      const installedPlugins = (\n        await getPluginPanelPlugin(\n          table.id,\n          panelList.find(({ name }) => name === 'panel1')!.id,\n          panel1Info.layout![0].pluginInstallId\n        )\n      ).data;\n\n      expect(installedPlugins.name).toBe('plugin1');\n      // pluginViews\n      const views = (await getViewList(table.id)).data;\n\n      const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);\n      expect(pluginViews.length).toBe(2);\n\n      expect(pluginViews.find(({ name }) => name === 'sheetView1')).toBeDefined();\n      expect(pluginViews.find(({ name }) => name === 'sheetView2')).toBeDefined();\n\n      for (const tableId of Object.values(tableIdMap)) {\n        await permanentDeleteTable(base.id, tableId);\n      }\n    });\n  });\n\n  describe('errored computed field import', () => {\n    const lookupFieldName = 'Errored Lookup';\n    const rollupFieldName = 'Errored Rollup';\n    let erroredBaseId: string;\n    let importedBaseId: string | undefined;\n    let hostTable: ITableFullVo;\n    let lookupTable: ITableFullVo;\n    let awaitErroredExport: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n    const waitForFieldHasError = async (tableId: string, fieldId: string) => {\n      const timeoutMs = 8000;\n      const start = Date.now();\n      while (Date.now() - start < timeoutMs) {\n        const fields = (await getFields(tableId)).data;\n        const field = fields.find((f) => f.id === fieldId);\n        if (field?.hasError) {\n          return field;\n        }\n        await sleep(200);\n      }\n      return undefined;\n    };\n\n    beforeAll(async () => {\n      const base = (\n        await createBase({\n          name: 'errored_computed_source',\n          spaceId,\n          icon: '📦',\n        })\n      ).data;\n      erroredBaseId = base.id;\n\n      hostTable = await createTable(erroredBaseId, {\n        name: 'Errored_Host',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const linkTemplate = x_20_link(hostTable);\n      lookupTable = await createTable(erroredBaseId, {\n        name: 'Errored_Lookup',\n        fields: linkTemplate.fields,\n        records: linkTemplate.records,\n      });\n\n      hostTable.fields = (await getFields(hostTable.id)).data;\n      lookupTable.fields = (await getFields(lookupTable.id)).data;\n\n      const linkField = lookupTable.fields.find((field) => field.type === FieldType.Link)!;\n      const hostNumberField = hostTable.fields.find((field) => field.type === FieldType.Number)!;\n\n      const lookupField = (\n        await createField(lookupTable.id, {\n          name: lookupFieldName,\n          type: hostNumberField.type,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: hostTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: hostNumberField.id,\n          },\n        })\n      ).data;\n\n      const rollupField = (\n        await createField(lookupTable.id, {\n          name: rollupFieldName,\n          type: FieldType.Rollup,\n          options: {\n            expression: 'count({values})',\n          },\n          lookupOptions: {\n            foreignTableId: hostTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: hostNumberField.id,\n          },\n        })\n      ).data;\n\n      await deleteField(hostTable.id, hostNumberField.id);\n\n      const erroredLookup = await waitForFieldHasError(lookupTable.id, lookupField.id);\n      const erroredRollup = await waitForFieldHasError(lookupTable.id, rollupField.id);\n      expect(erroredLookup?.hasError).toBe(true);\n      expect(erroredRollup?.hasError).toBe(true);\n\n      lookupTable.fields = (await getFields(lookupTable.id)).data;\n\n      awaitErroredExport = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n    });\n\n    afterAll(async () => {\n      if (importedBaseId) {\n        await permanentDeleteBase(importedBaseId);\n      }\n      if (erroredBaseId) {\n        await permanentDeleteBase(erroredBaseId);\n      }\n    });\n\n    it('converts errored lookup and rollup fields to text on import', async () => {\n      const { previewUrl } = await awaitErroredExport(async () => {\n        await exportBase(erroredBaseId);\n      });\n\n      const attachmentService = getAttachmentService(app);\n      const clsService = app.get(ClsService);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(appUrl + previewUrl);\n        }\n      );\n\n      const { base: importedBase } = (\n        await importBase({\n          notify: notify as unknown as INotifyVo,\n          spaceId,\n        })\n      ).data;\n\n      importedBaseId = importedBase.id;\n\n      const tableList = (await getTableList(importedBase.id)).data;\n      expect(tableList.map(({ name }) => name).sort()).toEqual(\n        [hostTable.name, lookupTable.name].sort()\n      );\n\n      const importedLookupMeta = tableList.find(\n        (tableMeta) => tableMeta.name === lookupTable.name\n      )!;\n      const importedLookupTable = await getTable(importedBase.id, importedLookupMeta.id, {\n        includeContent: true,\n      });\n\n      const importedFields = importedLookupTable.fields ?? [];\n\n      const importedLookupField = importedFields.find((field) => field.name === lookupFieldName)!;\n      expect(importedLookupField.type).toBe(FieldType.SingleLineText);\n      expect(importedLookupField.isLookup).toBeFalsy();\n      expect(importedLookupField.lookupOptions).toBeFalsy();\n      expect(importedLookupField.hasError).toBeFalsy();\n\n      const importedRollupField = importedFields.find((field) => field.name === rollupFieldName)!;\n      expect(importedRollupField.type).toBe(FieldType.SingleLineText);\n      expect(importedRollupField.lookupOptions).toBeFalsy();\n      expect(importedRollupField.hasError).toBeFalsy();\n      expect(importedRollupField.isLookup).toBeFalsy();\n    });\n  });\n\n  describe('conditional rollup import', () => {\n    let conditionalBaseId: string;\n    let importedBaseId: string | undefined;\n    let foreignTable: ITableFullVo;\n    let hostTable: ITableFullVo;\n    let awaitConditionalExport: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n    beforeAll(async () => {\n      const base = (\n        await createBase({\n          name: 'conditional_rollup_source',\n          spaceId,\n          icon: '🧮',\n        })\n      ).data;\n      conditionalBaseId = base.id;\n\n      foreignTable = await createTable(conditionalBaseId, {\n        name: 'CR_Foreign',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          { name: 'Status', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { Title: 'Alpha', Status: 'Active' } },\n          { fields: { Title: 'Beta', Status: 'Inactive' } },\n        ],\n      });\n\n      hostTable = await createTable(conditionalBaseId, {\n        name: 'CR_Host',\n        fields: [{ name: 'StatusFilter', type: FieldType.SingleLineText }],\n        records: [{ fields: { StatusFilter: 'Active' } }, { fields: { StatusFilter: 'Inactive' } }],\n      });\n\n      const titleFieldId = foreignTable.fields.find((field) => field.name === 'Title')!.id;\n      const statusFieldId = foreignTable.fields.find((field) => field.name === 'Status')!.id;\n      const statusFilterFieldId = hostTable.fields.find(\n        (field) => field.name === 'StatusFilter'\n      )!.id;\n\n      const statusMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: statusFilterFieldId },\n          },\n        ],\n      };\n\n      await createField(hostTable.id, {\n        name: 'Status Rollup',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: titleFieldId,\n          expression: 'array_join({values})',\n          filter: statusMatchFilter,\n        } as IConditionalRollupFieldOptions,\n      });\n\n      await createField(hostTable.id, {\n        name: 'Status Lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: titleFieldId,\n          filter: statusMatchFilter,\n          sort: { fieldId: titleFieldId, order: SortFunc.Asc },\n          limit: 1,\n        },\n      });\n\n      awaitConditionalExport = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n    });\n\n    afterAll(async () => {\n      if (importedBaseId) {\n        await permanentDeleteBase(importedBaseId);\n      }\n      if (conditionalBaseId) {\n        await permanentDeleteBase(conditionalBaseId);\n      }\n    });\n\n    it('imports base with conditional rollup without circular dependency', async () => {\n      const { previewUrl } = await awaitConditionalExport(async () => {\n        await exportBase(conditionalBaseId);\n      });\n\n      const attachmentService = getAttachmentService(app);\n      const clsService = app.get(ClsService);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(appUrl + previewUrl);\n        }\n      );\n\n      const { base: importedBase } = (\n        await importBase({\n          notify: notify as unknown as INotifyVo,\n          spaceId,\n        })\n      ).data;\n\n      importedBaseId = importedBase.id;\n\n      const tableList = (await getTableList(importedBase.id)).data;\n      expect(tableList.map(({ name }) => name).sort()).toEqual(\n        [hostTable.name, foreignTable.name].sort()\n      );\n\n      const importedHostMeta = tableList.find((tableMeta) => tableMeta.name === hostTable.name)!;\n      const importedHost = await getTable(importedBase.id, importedHostMeta.id, {\n        includeContent: true,\n      });\n\n      const importedFields = importedHost.fields ?? [];\n      const importedRollupField = importedFields.find((field) => field.name === 'Status Rollup')!;\n      expect(importedRollupField.type).toBe(FieldType.ConditionalRollup);\n      expect(importedRollupField.hasError).toBeFalsy();\n\n      const importedLookupField = importedFields.find((field) => field.name === 'Status Lookup')!;\n      expect(importedLookupField.isLookup).toBeTruthy();\n      expect(importedLookupField.isConditionalLookup).toBeTruthy();\n      expect(importedLookupField.hasError).toBeFalsy();\n      const lookupOptions =\n        typeof importedLookupField.lookupOptions === 'string'\n          ? (JSON.parse(importedLookupField.lookupOptions) as {\n              sort?: { fieldId: string; order?: SortFunc };\n            })\n          : (importedLookupField.lookupOptions as\n              | { sort?: { fieldId: string; order?: SortFunc } }\n              | undefined);\n      expect(lookupOptions?.sort?.order).toBe(SortFunc.Asc);\n\n      const importedStatusFilter = importedFields.find((field) => field.name === 'StatusFilter')!;\n\n      const activeRecordMeta = await waitForRecordWithFieldValue(\n        importedHostMeta.id,\n        importedStatusFilter.id,\n        'Active'\n      );\n      const inactiveRecordMeta = await waitForRecordWithFieldValue(\n        importedHostMeta.id,\n        importedStatusFilter.id,\n        'Inactive'\n      );\n\n      expect(activeRecordMeta).toBeDefined();\n      expect(inactiveRecordMeta).toBeDefined();\n\n      const activeRecord = await waitForComputedRecord(importedHostMeta.id, activeRecordMeta!.id, [\n        importedRollupField.id,\n        importedLookupField.id,\n      ]);\n      const inactiveRecord = await waitForComputedRecord(\n        importedHostMeta.id,\n        inactiveRecordMeta!.id,\n        [importedRollupField.id, importedLookupField.id]\n      );\n\n      expect(activeRecord.fields?.[importedRollupField.id]).toBe('Alpha');\n      expect(inactiveRecord.fields?.[importedRollupField.id]).toBe('Beta');\n      expect(activeRecord.fields?.[importedLookupField.id]).toEqual(['Alpha']);\n      expect(inactiveRecord.fields?.[importedLookupField.id]).toEqual(['Beta']);\n    });\n  });\n\n  describe('primary formula import', () => {\n    let sourceBaseId: string | undefined;\n    let importedBaseId: string | undefined;\n\n    afterEach(async () => {\n      if (importedBaseId) {\n        await permanentDeleteBase(importedBaseId);\n        importedBaseId = undefined;\n      }\n      if (sourceBaseId) {\n        await permanentDeleteBase(sourceBaseId);\n        sourceBaseId = undefined;\n      }\n    });\n\n    it('imports base with primary formula numeric expression using generated columns', async () => {\n      const sourceBase = (\n        await createBase({\n          name: 'primary_formula_source',\n          spaceId,\n          icon: '🧮',\n        })\n      ).data;\n      sourceBaseId = sourceBase.id;\n\n      const table = await createTable(sourceBase.id, {\n        name: 'Primary Formula Table',\n        fields: [\n          { name: 'Primary Field', type: FieldType.SingleLineText },\n          { name: 'Remaining Minutes', type: FieldType.Number },\n        ],\n      });\n\n      const primaryFieldId = table.fields.find((field) => field.isPrimary)!.id;\n      const remainingMinutesId = table.fields.find(\n        (field) => field.name === 'Remaining Minutes'\n      )!.id;\n\n      await convertField(table.id, primaryFieldId, {\n        type: FieldType.Formula,\n        options: {\n          expression: `({${remainingMinutesId}} * 45) / 60`,\n        },\n      });\n\n      const awaitExportWithPreview = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n\n      const { previewUrl } = await awaitExportWithPreview(async () => {\n        await exportBase(sourceBaseId!);\n      });\n\n      const attachmentService = getAttachmentService(app);\n      const clsService = app.get(ClsService);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(appUrl + previewUrl);\n        }\n      );\n\n      const { base: importedBase } = (\n        await importBase({\n          notify: notify as unknown as INotifyVo,\n          spaceId,\n        })\n      ).data;\n      importedBaseId = importedBase.id;\n\n      const tableList = (await getTableList(importedBaseId)).data;\n      expect(tableList).toHaveLength(1);\n\n      const importedTableMeta = tableList[0];\n      const importedTable = await getTable(importedBaseId, importedTableMeta.id, {\n        includeContent: true,\n      });\n\n      const importedPrimaryField = importedTable.fields?.find((field) => field.isPrimary);\n      expect(importedPrimaryField?.type).toBe(FieldType.Formula);\n\n      const importedRemainingField = importedTable.fields?.find(\n        (field) => field.name === 'Remaining Minutes'\n      );\n      expect(importedRemainingField).toBeDefined();\n\n      const primaryOptions =\n        typeof importedPrimaryField?.options === 'string'\n          ? (JSON.parse(importedPrimaryField.options) as { expression?: string })\n          : (importedPrimaryField?.options as { expression?: string }) ?? {};\n\n      expect(primaryOptions.expression).toBeDefined();\n      expect(primaryOptions.expression).toContain(`{${importedRemainingField!.id}}`);\n      expect(importedPrimaryField?.hasError).toBeFalsy();\n\n      const prisma = app.get(PrismaService);\n      const primaryFieldRaw = await prisma.field.findUniqueOrThrow({\n        where: { id: importedPrimaryField!.id },\n        select: { meta: true },\n      });\n      const persistedMeta =\n        typeof primaryFieldRaw.meta === 'string'\n          ? (JSON.parse(primaryFieldRaw.meta) as { persistedAsGeneratedColumn?: boolean })\n          : primaryFieldRaw.meta ?? {};\n      expect(persistedMeta?.persistedAsGeneratedColumn).not.toBe(true);\n    });\n  });\n\n  describe('export and import the base with nodes [Folder, Table, Dashboard]', () => {\n    let nodeBaseId: string | undefined;\n    let importedNodeBaseId: string | undefined;\n    let awaitNodeExport: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n    beforeAll(async () => {\n      awaitNodeExport = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n    });\n\n    afterAll(async () => {\n      if (importedNodeBaseId) {\n        await permanentDeleteBase(importedNodeBaseId);\n      }\n      if (nodeBaseId) {\n        await permanentDeleteBase(nodeBaseId);\n      }\n    });\n\n    it('should export and import base with node hierarchy correctly', async () => {\n      // 1. Create source base with node hierarchy\n      const sourceBase = await createBase({\n        name: 'node_hierarchy_source',\n        spaceId,\n        icon: '📁',\n      }).then((res) => res.data);\n      nodeBaseId = sourceBase.id;\n\n      // Create folders using createBaseNode\n      const folder1Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 1',\n      }).then((res) => res.data);\n      const folder2Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Folder,\n        name: 'Folder 2',\n      }).then((res) => res.data);\n\n      // Create tables using createBaseNode\n      const table1Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table 1',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      const table2Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Table,\n        name: 'Table 2',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        views: [{ name: 'Grid view', type: ViewType.Grid }],\n      }).then((res) => res.data);\n\n      // Create dashboards using createBaseNode\n      const dashboard1Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Dashboard,\n        name: 'Dashboard 1',\n      }).then((res) => res.data);\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      const dashboard2Node = await createBaseNode(nodeBaseId, {\n        resourceType: BaseNodeResourceType.Dashboard,\n        name: 'Dashboard 2',\n      }).then((res) => res.data);\n\n      // Move table1 into folder1 and dashboard1 into folder2\n      await moveBaseNode(nodeBaseId, table1Node.id, { parentId: folder1Node.id });\n      await moveBaseNode(nodeBaseId, dashboard1Node.id, { parentId: folder2Node.id });\n\n      // Get updated node tree\n      const updatedSourceNodeTree = await getBaseNodeTree(nodeBaseId).then((res) => res.data);\n      const updatedSourceNodes = updatedSourceNodeTree.nodes;\n\n      // 2. Export the base\n      const { previewUrl } = await awaitNodeExport(async () => {\n        await exportBase(nodeBaseId!);\n      });\n\n      // 3. Import the base\n      const attachmentService = getAttachmentService(app);\n      const clsService = app.get(ClsService);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(appUrl + previewUrl);\n        }\n      );\n\n      const { base: importedBase } = (\n        await importBase({\n          notify: notify as unknown as INotifyVo,\n          spaceId,\n        })\n      ).data;\n\n      importedNodeBaseId = importedBase.id;\n\n      // 4. Verify imported node tree\n      const importedNodeTree = await getBaseNodeTree(importedNodeBaseId).then((res) => res.data);\n      const importedNodes = importedNodeTree.nodes;\n\n      // Verify same number of nodes\n      expect(importedNodes.length).toBe(updatedSourceNodes.length);\n\n      // Verify resource types distribution\n      const sourceResourceTypes = updatedSourceNodes\n        .map((n) => n.resourceType)\n        .sort()\n        .join(',');\n      const importedResourceTypes = importedNodes\n        .map((n) => n.resourceType)\n        .sort()\n        .join(',');\n      expect(importedResourceTypes).toBe(sourceResourceTypes);\n\n      // Verify folder count\n      const sourceFolders = updatedSourceNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Folder\n      );\n      const importedFolders = importedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Folder\n      );\n      expect(importedFolders.length).toBe(sourceFolders.length);\n\n      // Verify table count\n      const sourceTables = updatedSourceNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Table\n      );\n      const importedTables = importedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Table\n      );\n      expect(importedTables.length).toBe(sourceTables.length);\n\n      // Verify dashboard count\n      const sourceDashboards = updatedSourceNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Dashboard\n      );\n      const importedDashboards = importedNodes.filter(\n        (n) => n.resourceType === BaseNodeResourceType.Dashboard\n      );\n      expect(importedDashboards.length).toBe(sourceDashboards.length);\n\n      // Verify hierarchy: nodes with parents should still have parents\n      const sourceNodesWithParent = updatedSourceNodes.filter((n) => n.parentId !== null);\n      const importedNodesWithParent = importedNodes.filter((n) => n.parentId !== null);\n      expect(importedNodesWithParent.length).toBe(sourceNodesWithParent.length);\n\n      // Verify folder names are preserved\n      const sourceFolderNames = sourceFolders.map((f) => f.resourceMeta?.name).sort();\n      const importedFolderNames = importedFolders.map((f) => f.resourceMeta?.name).sort();\n      expect(importedFolderNames).toEqual(sourceFolderNames);\n\n      // Verify that table inside folder1 exists in imported base\n      const importedFolder1 = importedFolders.find(\n        (f) => f.resourceMeta?.name === folder1Node.resourceMeta?.name\n      );\n      expect(importedFolder1).toBeDefined();\n      const tableInsideFolder = importedNodes.find((n) => {\n        return n.resourceType === BaseNodeResourceType.Table && n.parentId === importedFolder1!.id;\n      });\n      expect(tableInsideFolder).toBeDefined();\n\n      // Verify that dashboard inside folder2 exists in imported base\n      const importedFolder2 = importedFolders.find(\n        (f) => f.resourceMeta?.name === folder2Node.resourceMeta?.name\n      );\n      expect(importedFolder2).toBeDefined();\n      const dashboardInsideFolder = importedNodes.find((n) => {\n        return (\n          n.resourceType === BaseNodeResourceType.Dashboard && n.parentId === importedFolder2!.id\n        );\n      });\n      expect(dashboardInsideFolder).toBeDefined();\n\n      // Verify tables are accessible\n      const importedTableList = await getTableList(importedNodeBaseId).then((res) => res.data);\n      expect(importedTableList.length).toBe(2);\n      expect(importedTableList.map((t) => t.name).sort()).toEqual(\n        [table1Node.resourceMeta?.name, table2Node.resourceMeta?.name].sort()\n      );\n\n      // Verify dashboards are accessible\n      const importedDashboardList = await getDashboardList(importedNodeBaseId).then(\n        (res) => res.data\n      );\n      expect(importedDashboardList.length).toBe(2);\n      expect(importedDashboardList.map((d) => d.name).sort()).toEqual(\n        [dashboard1Node.resourceMeta?.name, dashboard2Node.resourceMeta?.name].sort()\n      );\n    });\n  });\n\n  describe('import base with multiple link fields targeting the same table', () => {\n    let multiLinkSourceBaseId: string;\n    let importedMultiLinkBaseId: string | undefined;\n    let awaitMultiLinkExport: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n    beforeAll(async () => {\n      awaitMultiLinkExport = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n    });\n\n    afterAll(async () => {\n      if (importedMultiLinkBaseId) {\n        await permanentDeleteBase(importedMultiLinkBaseId);\n      }\n      if (multiLinkSourceBaseId) {\n        await permanentDeleteBase(multiLinkSourceBaseId);\n      }\n    });\n\n    it('should import base where multiple links point to the same foreign table without dbFieldName collision', async () => {\n      const sourceBase = (await createBase({ name: 'multi_link_source', spaceId, icon: '🔗' }))\n        .data;\n      multiLinkSourceBaseId = sourceBase.id;\n\n      const foreignTable = await createTable(multiLinkSourceBaseId, {\n        name: 'SharedTarget',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n        records: [{ fields: { Title: 'Target A' } }, { fields: { Title: 'Target B' } }],\n      });\n\n      const hostTable = await createTable(multiLinkSourceBaseId, {\n        name: 'MultiLinkHost',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { Name: 'Host 1' } }],\n      });\n\n      await createField(hostTable.id, {\n        name: 'Link1',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      await createField(hostTable.id, {\n        name: 'Link2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      await createField(hostTable.id, {\n        name: 'Link3',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      // export & import\n      const { previewUrl } = await awaitMultiLinkExport(async () => {\n        await exportBase(multiLinkSourceBaseId);\n      });\n\n      const attachmentService = getAttachmentService(app);\n      const clsService = app.get(ClsService);\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: { id: userId, name: 'Test', email: 'test@example.com', isAdmin: null },\n        } as unknown as ClsStore,\n        async () => attachmentService.uploadFromUrl(appUrl + previewUrl)\n      );\n\n      const { base: importedBase } = (\n        await importBase({ notify: notify as unknown as INotifyVo, spaceId })\n      ).data;\n      importedMultiLinkBaseId = importedBase.id;\n\n      const tableList = (await getTableList(importedMultiLinkBaseId)).data;\n      expect(tableList.length).toBe(2);\n\n      const importedHostMeta = tableList.find((t) => t.name === 'MultiLinkHost')!;\n      const importedForeignMeta = tableList.find((t) => t.name === 'SharedTarget')!;\n\n      const importedHostFields = (await getFields(importedHostMeta.id)).data;\n      const importedForeignFields = (await getFields(importedForeignMeta.id)).data;\n\n      const hostLinkFields = importedHostFields.filter((f) => f.type === FieldType.Link);\n      expect(hostLinkFields.length).toBe(3);\n\n      // the foreign table should have 3 symmetric link fields, each with a unique dbFieldName\n      const foreignLinkFields = importedForeignFields.filter((f) => f.type === FieldType.Link);\n      expect(foreignLinkFields.length).toBe(3);\n\n      const foreignDbFieldNames = foreignLinkFields.map((f) => f.dbFieldName);\n      const uniqueDbFieldNames = new Set(foreignDbFieldNames);\n      expect(uniqueDbFieldNames.size).toBe(3);\n    });\n  });\n\n  describe('import base via SSE stream endpoint', () => {\n    let streamSourceBaseId: string;\n    let importedStreamBaseId: string | undefined;\n    let streamTable: ITableFullVo;\n    let awaitStreamExport: <T>(fn: () => Promise<T>) => Promise<{ previewUrl: string }>;\n\n    beforeAll(async () => {\n      const sourceBase = (\n        await createBase({\n          name: 'stream_source_base',\n          spaceId,\n          icon: '🔄',\n        })\n      ).data;\n      streamSourceBaseId = sourceBase.id;\n\n      streamTable = await createTable(streamSourceBaseId, {\n        name: 'stream_test_table',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      awaitStreamExport = createAwaitWithEventWithResult<{ previewUrl: string }>(\n        app.get(EventEmitterService),\n        Events.BASE_EXPORT_COMPLETE\n      );\n    });\n\n    afterAll(async () => {\n      if (importedStreamBaseId) {\n        await permanentDeleteBase(importedStreamBaseId);\n      }\n      if (streamSourceBaseId) {\n        await permanentDeleteBase(streamSourceBaseId);\n      }\n    });\n\n    it('should import base via SSE stream and receive progress + done events', async () => {\n      // 1. Export the source base\n      const { previewUrl } = await awaitStreamExport(async () => {\n        await exportBase(streamSourceBaseId);\n      });\n\n      // 2. Upload the .tea file\n      const clsService = app.get(ClsService);\n      const attachmentService = getAttachmentService(app);\n\n      const notify = await clsService.runWith<Promise<IAttachmentItem>>(\n        {\n          user: {\n            id: userId,\n            name: 'Test User',\n            email: 'test@example.com',\n            isAdmin: null,\n          },\n        } as unknown as ClsStore,\n        async () => {\n          return await attachmentService.uploadFromUrl(appUrl + previewUrl);\n        }\n      );\n\n      // 3. Call import-stream SSE endpoint with raw fetch\n      const streamUrl = `${appUrl}/api${IMPORT_BASE_STREAM}`;\n\n      const response = await fetch(streamUrl, {\n        method: 'POST',\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/json',\n          Accept: 'text/event-stream',\n          Cookie: cookie,\n        },\n        body: JSON.stringify({\n          notify: notify as unknown as INotifyVo,\n          spaceId,\n        }),\n      });\n\n      expect(response.ok).toBe(true);\n      expect(response.headers.get('content-type')).toContain('text/event-stream');\n\n      // 4. Parse SSE events\n      const reader = response.body!.getReader();\n      const decoder = new TextDecoder();\n      let buffer = '';\n      const progressEvents: { phase: string; detail?: string }[] = [];\n      let doneEvent: IImportBaseSSEEvent | null = null;\n      let errorEvent: IImportBaseSSEEvent | null = null;\n\n      // eslint-disable-next-line no-constant-condition\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n\n        buffer += decoder.decode(value, { stream: true });\n        const lines = buffer.split('\\n');\n        buffer = lines.pop() || '';\n\n        for (const line of lines) {\n          if (!line.startsWith('data: ')) continue;\n          const jsonStr = line.slice(6).trim();\n          if (!jsonStr || jsonStr === '[DONE]') continue;\n\n          const event = JSON.parse(jsonStr) as IImportBaseSSEEvent;\n          if (event.type === 'progress') {\n            progressEvents.push({ phase: event.phase, detail: event.detail });\n          } else if (event.type === 'done') {\n            doneEvent = event;\n          } else if (event.type === 'error') {\n            errorEvent = event;\n          }\n        }\n      }\n\n      // 5. Verify: no error events\n      expect(errorEvent).toBeNull();\n\n      // 6. Verify: received progress events\n      expect(progressEvents.length).toBeGreaterThan(0);\n\n      // Verify some expected phases appear\n      const phases = progressEvents.map((e) => e.phase);\n      expect(phases).toContain('creating_base');\n      expect(phases).toContain('creating_table');\n      expect(phases).toContain('structure_created');\n\n      // 7. Verify: received done event with proper structure\n      expect(doneEvent).not.toBeNull();\n      expect(doneEvent!.type).toBe('done');\n      const result = (doneEvent as any).data;\n      expect(result.base).toBeDefined();\n      expect(result.base.spaceId).toBe(spaceId);\n      expect(result.tableIdMap).toBeDefined();\n      expect(result.fieldIdMap).toBeDefined();\n      expect(result.viewIdMap).toBeDefined();\n\n      importedStreamBaseId = result.base.id;\n\n      // 8. Verify: imported base is accessible and correct\n      const tableList = (await getTableList(importedStreamBaseId!)).data;\n      expect(tableList.length).toBe(1);\n      expect(tableList[0].name).toBe('stream_test_table');\n\n      const importedTable = await getTable(importedStreamBaseId!, tableList[0].id, {\n        includeContent: true,\n      });\n      expect(importedTable.fields!.length).toBe(streamTable.fields.length);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/integrity.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions } from '@teable/core';\nimport { FieldType, Relationship } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  IntegrityIssueType,\n  checkBaseIntegrity,\n  convertField,\n  createBase,\n  deleteBase,\n  fixBaseIntegrity,\n  getRecord,\n  getRecords,\n  updateRecord,\n  updateRecords,\n} from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { FieldService } from '../src/features/field/field.service';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getField,\n  initApp,\n} from './utils/init-app';\n\ndescribe('OpenAPI integrity (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const spaceId = globalThis.testConfig.spaceId;\n\n  let prisma: PrismaService;\n  let dbProvider: IDbProvider;\n  let fieldService: FieldService;\n  let knex: Knex;\n\n  async function executeKnex(builder: Knex.SchemaBuilder | Knex.QueryBuilder) {\n    const compiled = builder.toSQL();\n    const sqlItems = Array.isArray(compiled) ? compiled : [compiled];\n    const statements = sqlItems\n      .map(({ sql, bindings }) => ({\n        sql,\n        bindings: bindings || [],\n      }))\n      .filter(({ sql }) => sql && !sql.startsWith('PRAGMA'));\n\n    let result: unknown;\n    for (const { sql, bindings } of statements) {\n      const executableSql = knex.raw(sql, bindings).toQuery();\n      result = await prisma.$executeRawUnsafe(executableSql);\n    }\n    return result;\n  }\n\n  async function getColumnValue(tableName: string, columnName: string, recordId: string) {\n    const query = knex(tableName).select(columnName).where('__id', recordId).toQuery();\n    const rows = await prisma.$queryRawUnsafe<Record<string, string | null>[]>(query);\n    return rows[0]?.[columnName] ?? null;\n  }\n\n  async function getJunctionForeignIds(\n    tableName: string,\n    selfKeyName: string,\n    foreignKeyName: string,\n    selfId: string\n  ) {\n    const query = knex(tableName).select(foreignKeyName).where(selfKeyName, selfId).toQuery();\n    const rows = await prisma.$queryRawUnsafe<Record<string, string | null>[]>(query);\n    return rows.map((row) => row[foreignKeyName]).filter(Boolean) as string[];\n  }\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    dbProvider = appCtx.app.get<IDbProvider>(DB_PROVIDER_SYMBOL);\n    prisma = appCtx.app.get<PrismaService>(PrismaService);\n    fieldService = appCtx.app.get<FieldService>(FieldService);\n    knex = appCtx.app.get('CUSTOM_KNEX');\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('link integrity', () => {\n    let base1table1: ITableFullVo;\n    let base2table1: ITableFullVo;\n    let base2table2: ITableFullVo;\n    let baseId2: string;\n    beforeEach(async () => {\n      baseId2 = (await createBase({ spaceId, name: 'base2' })).data.id;\n      base1table1 = await createTable(baseId, { name: 'base1table1' });\n      base2table1 = await createTable(baseId2, { name: 'base2table1' });\n      base2table2 = await createTable(baseId2, { name: 'base2table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, base1table1.id);\n      await permanentDeleteTable(baseId2, base2table1.id);\n      await permanentDeleteTable(baseId2, base2table2.id);\n      await deleteBase(baseId2);\n    });\n\n    it('should check integrity when create link cross base', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: base2table1.id,\n        },\n      };\n\n      const linkField = await createField(base1table1.id, linkFieldRo);\n      expect((linkField.options as ILinkFieldOptions).baseId).toEqual(baseId2);\n\n      const symLinkField = await getField(\n        base2table1.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((symLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId);\n\n      await convertField(base1table1.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneMany,\n          foreignTableId: base2table1.id,\n        },\n      });\n\n      const updatedLinkField = await getField(base1table1.id, linkField.id);\n      expect((updatedLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId2);\n\n      const symUpdatedLinkField = await getField(\n        base2table1.id,\n        (updatedLinkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n      expect((symUpdatedLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId);\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table1.id);\n      expect(integrity.data.hasIssues).toEqual(false);\n    });\n\n    it('should check integrity when a many-one link field cell value is more than foreignKey', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const symLinkField = await getField(\n        base2table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((symLinkField.options as ILinkFieldOptions).baseId).toBeUndefined();\n\n      await updateRecords(base2table1.id, {\n        records: [\n          {\n            id: base2table1.records[0].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a1',\n            },\n          },\n          {\n            id: base2table1.records[1].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a2',\n            },\n          },\n        ],\n      });\n\n      await updateRecord(base2table2.id, base2table2.records[0].id, {\n        record: {\n          fields: {\n            [base2table2.fields[0].name]: 'b1',\n            [symLinkField.name]: [\n              { id: base2table1.records[0].id },\n              { id: base2table1.records[1].id },\n            ],\n          },\n        },\n      });\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity.data.hasIssues).toEqual(false);\n\n      // test multiple link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table2.records[0].id],\n          dbTableName: base2table2.dbTableName,\n          field: symLinkField.dbFieldName,\n          value: 'xxx',\n          arrayIndex: 0,\n        })\n      );\n\n      const record = await getRecord(base2table2.id, base2table2.records[0].id);\n      expect(record.data.fields[symLinkField.name]).toEqual([\n        { id: 'xxx', title: 'a1' },\n        { id: base2table1.records[1].id, title: 'a2' },\n      ]);\n\n      const integrity2 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity2.data.hasIssues).toEqual(true);\n      expect(integrity2.data.linkFieldIssues.length).toEqual(1);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity3 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity3.data.hasIssues).toEqual(false);\n\n      // test single link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table1.records[0].id],\n          dbTableName: base2table1.dbTableName,\n          field: linkField.dbFieldName,\n          value: 'xxx',\n        })\n      );\n\n      const record2 = await getRecord(base2table1.id, base2table1.records[0].id);\n      expect(record2.data.fields[linkField.name]).toEqual({ id: 'xxx', title: 'b1' });\n\n      const integrity4 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity4.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity5 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity5.data.hasIssues).toEqual(false);\n    });\n\n    it('should check integrity when a one-one link field cell value is more than foreignKey', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneOne,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const symLinkField = await getField(\n        base2table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((symLinkField.options as ILinkFieldOptions).baseId).toBeUndefined();\n\n      await updateRecords(base2table1.id, {\n        records: [\n          {\n            id: base2table1.records[0].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a1',\n            },\n          },\n          {\n            id: base2table1.records[1].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a2',\n            },\n          },\n        ],\n      });\n\n      await updateRecords(base2table2.id, {\n        records: [\n          {\n            id: base2table2.records[0].id,\n            fields: {\n              [base2table2.fields[0].name]: 'b1',\n              [symLinkField.name]: { id: base2table1.records[0].id },\n            },\n          },\n          {\n            id: base2table2.records[1].id,\n            fields: {\n              [base2table2.fields[0].name]: 'b2',\n              [symLinkField.name]: { id: base2table1.records[1].id },\n            },\n          },\n        ],\n      });\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity.data.hasIssues).toEqual(false);\n\n      // test multiple link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table2.records[0].id, base2table2.records[1].id],\n          dbTableName: base2table2.dbTableName,\n          field: symLinkField.dbFieldName,\n          value: 'xxx',\n        })\n      );\n\n      const records = await getRecords(base2table2.id);\n      expect(records.data.records[0].fields[symLinkField.name]).toEqual({ id: 'xxx', title: 'a1' });\n      expect(records.data.records[1].fields[symLinkField.name]).toEqual({ id: 'xxx', title: 'a2' });\n\n      const integrity2 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity2.data.hasIssues).toEqual(true);\n      expect(integrity2.data.linkFieldIssues.length).toEqual(1);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity3 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity3.data.hasIssues).toEqual(false);\n\n      // test single link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table1.records[0].id, base2table1.records[1].id],\n          dbTableName: base2table1.dbTableName,\n          field: linkField.dbFieldName,\n          value: 'xxx',\n        })\n      );\n\n      const records2 = await getRecords(base2table1.id);\n      expect(records2.data.records[0].fields[linkField.name]).toEqual({ id: 'xxx', title: 'b1' });\n      expect(records2.data.records[1].fields[linkField.name]).toEqual({ id: 'xxx', title: 'b2' });\n\n      const integrity4 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity4.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity5 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity5.data.hasIssues).toEqual(false);\n    });\n\n    it('should check integrity when a many-many link field cell value is more than foreignKey', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const symLinkField = await getField(\n        base2table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((symLinkField.options as ILinkFieldOptions).baseId).toBeUndefined();\n\n      await updateRecords(base2table1.id, {\n        records: [\n          {\n            id: base2table1.records[0].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a1',\n            },\n          },\n          {\n            id: base2table1.records[1].id,\n            fields: {\n              [base2table1.fields[0].name]: 'a2',\n            },\n          },\n        ],\n      });\n\n      await updateRecord(base2table2.id, base2table2.records[0].id, {\n        record: {\n          fields: {\n            [base2table2.fields[0].name]: 'b1',\n            [symLinkField.name]: [\n              { id: base2table1.records[0].id },\n              { id: base2table1.records[1].id },\n            ],\n          },\n        },\n      });\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity.data.hasIssues).toEqual(false);\n\n      // test multiple link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table2.records[0].id],\n          dbTableName: base2table2.dbTableName,\n          field: symLinkField.dbFieldName,\n          value: 'xxx',\n          arrayIndex: 0,\n        })\n      );\n\n      const record = await getRecord(base2table2.id, base2table2.records[0].id);\n      expect(record.data.fields[symLinkField.name]).toEqual([\n        { id: 'xxx', title: 'a1' },\n        { id: base2table1.records[1].id, title: 'a2' },\n      ]);\n\n      const integrity2 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity2.data.hasIssues).toEqual(true);\n      expect(integrity2.data.linkFieldIssues.length).toEqual(1);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity3 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity3.data.hasIssues).toEqual(false);\n\n      // test single link\n      await executeKnex(\n        dbProvider.integrityQuery().updateJsonField({\n          recordIds: [base2table1.records[0].id],\n          dbTableName: base2table1.dbTableName,\n          field: linkField.dbFieldName,\n          value: 'xxx',\n          arrayIndex: 0,\n        })\n      );\n\n      const record2 = await getRecord(base2table1.id, base2table1.records[0].id);\n      expect(record2.data.fields[linkField.name]).toEqual([{ id: 'xxx', title: 'b1' }]);\n\n      const integrity4 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity4.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table2.id);\n\n      const integrity5 = await checkBaseIntegrity(baseId2, base2table2.id);\n      expect(integrity5.data.hasIssues).toEqual(false);\n    });\n\n    it('should surface and fix missing foreign key columns during link integrity check', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many many link',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropColumn(options.foreignKeyName);\n        })\n      );\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table1.id);\n      const issues = integrity.data.linkFieldIssues.flatMap((item) => item.issues);\n      expect(\n        issues.some(\n          (issue) =>\n            issue.type === IntegrityIssueType.ForeignKeyNotFound && issue.fieldId === linkField.id\n        )\n      ).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const integrityAfterFix = await checkBaseIntegrity(baseId2, base2table1.id);\n      expect(integrityAfterFix.data.hasIssues).toEqual(false);\n    });\n\n    it('should rebuild missing junction table during link integrity fix', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many many link (drop table)',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await executeKnex(knex.schema.dropTable(options.fkHostTableName));\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table1.id);\n      const issues = integrity.data.linkFieldIssues.flatMap((item) => item.issues);\n      expect(\n        issues.some(\n          (issue) =>\n            issue.type === IntegrityIssueType.ForeignKeyHostTableNotFound &&\n            issue.fieldId === linkField.id\n        )\n      ).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const integrityAfterFix = await checkBaseIntegrity(baseId2, base2table1.id);\n      expect(integrityAfterFix.data.hasIssues).toEqual(false);\n    });\n\n    it('should restore missing foreign key columns for ManyOne link host', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many one link (drop column)',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropColumn(options.foreignKeyName);\n          table.dropColumn(`${options.foreignKeyName}_order`);\n        })\n      );\n\n      const integrity = await checkBaseIntegrity(baseId2, base2table1.id);\n      const issues = integrity.data.linkFieldIssues.flatMap((item) => item.issues);\n      expect(\n        issues.some(\n          (issue) =>\n            issue.type === IntegrityIssueType.ForeignKeyNotFound && issue.fieldId === linkField.id\n        )\n      ).toEqual(true);\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const integrityAfterFix = await checkBaseIntegrity(baseId2, base2table1.id);\n      expect(integrityAfterFix.data.hasIssues).toEqual(false);\n    });\n\n    it('should backfill ManyOne foreign key values from link cell data', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many one link backfill',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table2.id, base2table2.records[0].id, {\n        record: {\n          fields: {\n            [base2table2.fields[0].name]: 'b1',\n          },\n        },\n      });\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: { id: base2table2.records[0].id },\n          },\n        },\n      });\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropColumn(options.foreignKeyName);\n        })\n      );\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const fkValue = await getColumnValue(\n        options.fkHostTableName,\n        options.foreignKeyName,\n        base2table1.records[0].id\n      );\n      expect(fkValue).toEqual(base2table2.records[0].id);\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      expect(record.data.fields[linkField.name]).toEqual(\n        expect.objectContaining({ id: base2table2.records[0].id })\n      );\n    });\n\n    it('should backfill OneMany (two-way) foreign key values from link cell data', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'one many link backfill',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [\n              { id: base2table2.records[0].id },\n              { id: base2table2.records[1].id },\n            ],\n          },\n        },\n      });\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropColumn(options.selfKeyName);\n        })\n      );\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const fkValue1 = await getColumnValue(\n        options.fkHostTableName,\n        options.selfKeyName,\n        base2table2.records[0].id\n      );\n      const fkValue2 = await getColumnValue(\n        options.fkHostTableName,\n        options.selfKeyName,\n        base2table2.records[1].id\n      );\n      expect([fkValue1, fkValue2]).toEqual([base2table1.records[0].id, base2table1.records[0].id]);\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      const linkIds = (record.data.fields[linkField.name] as { id: string }[])\n        .map((item) => item.id)\n        .sort();\n      expect(linkIds).toEqual([base2table2.records[0].id, base2table2.records[1].id].sort());\n    });\n\n    it('should backfill OneMany (one-way) junction rows from link cell data', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'one way link backfill',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneMany,\n          foreignTableId: base2table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [\n              { id: base2table2.records[0].id },\n              { id: base2table2.records[1].id },\n            ],\n          },\n        },\n      });\n\n      await executeKnex(knex.schema.dropTable(options.fkHostTableName));\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const foreignIds = await getJunctionForeignIds(\n        options.fkHostTableName,\n        options.selfKeyName,\n        options.foreignKeyName,\n        base2table1.records[0].id\n      );\n      expect(foreignIds.sort()).toEqual(\n        [base2table2.records[0].id, base2table2.records[1].id].sort()\n      );\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      const linkIds = (record.data.fields[linkField.name] as { id: string }[])\n        .map((item) => item.id)\n        .sort();\n      expect(linkIds).toEqual([base2table2.records[0].id, base2table2.records[1].id].sort());\n    });\n\n    it('should backfill ManyMany junction rows when foreign key column is missing', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many many link backfill (drop column)',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [\n              { id: base2table2.records[0].id },\n              { id: base2table2.records[1].id },\n            ],\n          },\n        },\n      });\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropForeign(options.foreignKeyName, `fk_${options.foreignKeyName}`);\n          table.dropColumn(options.foreignKeyName);\n        })\n      );\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const foreignIds = await getJunctionForeignIds(\n        options.fkHostTableName,\n        options.selfKeyName,\n        options.foreignKeyName,\n        base2table1.records[0].id\n      );\n      expect(foreignIds.sort()).toEqual(\n        [base2table2.records[0].id, base2table2.records[1].id].sort()\n      );\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      const linkIds = (record.data.fields[linkField.name] as { id: string }[])\n        .map((item) => item.id)\n        .sort();\n      expect(linkIds).toEqual([base2table2.records[0].id, base2table2.records[1].id].sort());\n    });\n\n    it('should backfill ManyMany junction rows when junction table is missing', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'many many link backfill (drop table)',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyMany,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: [\n              { id: base2table2.records[0].id },\n              { id: base2table2.records[1].id },\n            ],\n          },\n        },\n      });\n\n      await executeKnex(knex.schema.dropTable(options.fkHostTableName));\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const foreignIds = await getJunctionForeignIds(\n        options.fkHostTableName,\n        options.selfKeyName,\n        options.foreignKeyName,\n        base2table1.records[0].id\n      );\n      expect(foreignIds.sort()).toEqual(\n        [base2table2.records[0].id, base2table2.records[1].id].sort()\n      );\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      const linkIds = (record.data.fields[linkField.name] as { id: string }[])\n        .map((item) => item.id)\n        .sort();\n      expect(linkIds).toEqual([base2table2.records[0].id, base2table2.records[1].id].sort());\n    });\n\n    it('should backfill OneOne foreign key values from link cell data', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'one one link backfill',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneOne,\n          foreignTableId: base2table2.id,\n        },\n      };\n\n      const linkField = await createField(base2table1.id, linkFieldRo);\n      const options = linkField.options as ILinkFieldOptions;\n\n      await updateRecord(base2table2.id, base2table2.records[0].id, {\n        record: {\n          fields: {\n            [base2table2.fields[0].name]: 'b1',\n          },\n        },\n      });\n\n      await updateRecord(base2table1.id, base2table1.records[0].id, {\n        record: {\n          fields: {\n            [linkField.name]: { id: base2table2.records[0].id },\n          },\n        },\n      });\n\n      await executeKnex(\n        knex.schema.alterTable(options.fkHostTableName, (table) => {\n          table.dropColumn(options.foreignKeyName);\n        })\n      );\n\n      await fixBaseIntegrity(baseId2, base2table1.id);\n\n      const fkValue = await getColumnValue(\n        options.fkHostTableName,\n        options.foreignKeyName,\n        base2table1.records[0].id\n      );\n      expect(fkValue).toEqual(base2table2.records[0].id);\n\n      const record = await getRecord(base2table1.id, base2table1.records[0].id);\n      expect(record.data.fields[linkField.name]).toEqual(\n        expect.objectContaining({ id: base2table2.records[0].id })\n      );\n    });\n  });\n\n  describe('unique index', () => {\n    let baseId1: string;\n    let base1table: ITableFullVo;\n    beforeEach(async () => {\n      baseId1 = (await createBase({ spaceId, name: 'base1' })).data.id;\n      base1table = await createTable(baseId1, { name: 'base1table' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId1, base1table.id);\n      await deleteBase(baseId1);\n    });\n\n    it('should check integrity when __id unique index is not found', async () => {\n      const colId = '__id';\n      const matchedIndexes1 = await fieldService.findUniqueIndexesForField(\n        base1table.dbTableName,\n        colId\n      );\n\n      expect(matchedIndexes1.length).toEqual(1);\n\n      const fieldValidationQuery = knex.schema\n        .alterTable(base1table.dbTableName, (table) => {\n          matchedIndexes1.forEach((indexName) => table.dropUnique([colId], indexName));\n        })\n        .toSQL();\n      const executeSqls = fieldValidationQuery\n        .filter((s) => !s.sql.startsWith('PRAGMA'))\n        .map(({ sql }) => sql);\n\n      for (const sql of executeSqls) {\n        await prisma.txClient().$executeRawUnsafe(sql);\n      }\n      const matchedIndexes2 = await fieldService.findUniqueIndexesForField(\n        base1table.dbTableName,\n        colId\n      );\n      expect(matchedIndexes2.length).toEqual(0);\n\n      const integrity1 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity1.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId1, base1table.id);\n\n      const integrity2 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity2.data.hasIssues).toEqual(false);\n    });\n\n    it('should check integrity when id unique index is not found', async () => {\n      const field = await getField(base1table.id, base1table.fields[0].id);\n\n      await convertField(base1table.id, field.id, {\n        ...field,\n        unique: true,\n      });\n\n      const matchedIndexes1 = await fieldService.findUniqueIndexesForField(\n        base1table.dbTableName,\n        field.dbFieldName\n      );\n\n      expect(matchedIndexes1.length).toEqual(1);\n\n      const fieldValidationQuery = knex.schema\n        .alterTable(base1table.dbTableName, (table) => {\n          matchedIndexes1.forEach((indexName) => table.dropUnique([field.dbFieldName], indexName));\n        })\n        .toSQL();\n      const executeSqls = fieldValidationQuery\n        .filter((s) => !s.sql.startsWith('PRAGMA'))\n        .map(({ sql }) => sql);\n\n      for (const sql of executeSqls) {\n        await prisma.txClient().$executeRawUnsafe(sql);\n      }\n      const matchedIndexes2 = await fieldService.findUniqueIndexesForField(\n        base1table.dbTableName,\n        field.dbFieldName\n      );\n      expect(matchedIndexes2.length).toEqual(0);\n\n      const integrity1 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity1.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId1, base1table.id);\n\n      const integrity2 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity2.data.hasIssues).toEqual(false);\n    });\n  });\n\n  describe('fix empty string cell value', () => {\n    let baseId1: string;\n    let base1table: ITableFullVo;\n    beforeEach(async () => {\n      baseId1 = (await createBase({ spaceId, name: 'base1' })).data.id;\n      base1table = await createTable(baseId1, { name: 'base1table' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId1, base1table.id);\n      await deleteBase(baseId1);\n    });\n\n    it('should check integrity when empty string cell value is found', async () => {\n      const integrity = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity.data.hasIssues).toEqual(false);\n\n      const sql = knex(base1table.dbTableName)\n        .update({\n          [base1table.fields[0].dbFieldName]: '',\n        })\n        .toQuery();\n      await prisma.txClient().$executeRawUnsafe(sql);\n\n      const integrity2 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity2.data.hasIssues).toEqual(true);\n\n      await fixBaseIntegrity(baseId1, base1table.id);\n\n      const integrity3 = await checkBaseIntegrity(baseId1, base1table.id);\n      expect(integrity3.data.hasIssues).toEqual(false);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/invitation.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { Role } from '@teable/core';\nimport type { CreateSpaceInvitationLinkVo } from '@teable/openapi';\nimport {\n  ACCEPT_INVITATION_LINK,\n  createSpace as apiCreateSpace,\n  createSpaceInvitationLink as apiCreateSpaceInvitationLink,\n  deleteSpace as apiDeleteSpace,\n  getSpaceCollaboratorList as apiGetSpaceCollaboratorList,\n  PrincipalType,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { initApp } from './utils/init-app';\n\ndescribe('OpenAPI InvitationController (e2e)', () => {\n  let app: INestApplication;\n  let spaceId: string;\n  let user2Request: AxiosInstance;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const res = await apiCreateSpace({ name: 'new space' });\n    spaceId = res.data.id;\n\n    user2Request = await createNewUserAxios({\n      email: 'newuser@example.com',\n      password: '12345678',\n    });\n  });\n\n  afterAll(async () => {\n    await apiDeleteSpace(spaceId);\n    await app.close();\n  });\n\n  it('/api/invitation/link/accept (POST)', async () => {\n    const invitationLinkRes = await apiCreateSpaceInvitationLink({\n      spaceId,\n      createSpaceInvitationLinkRo: { role: Role.Owner },\n    });\n\n    const { invitationId, invitationCode } = invitationLinkRes.data as CreateSpaceInvitationLinkVo;\n    const data = await user2Request.post(ACCEPT_INVITATION_LINK, { invitationId, invitationCode });\n\n    expect(data.data.spaceId).toEqual(spaceId);\n    const { collaborators } = (await apiGetSpaceCollaboratorList(spaceId)).data;\n    const collaborator = collaborators.find(\n      (item) => item.type === PrincipalType.User && item.email === 'newuser@example.com'\n    );\n    expect(collaborator?.role).toEqual(Role.Owner);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/large-table-operations.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport {\n  Colors,\n  FieldKeyType,\n  FieldType,\n  NumberFormattingType,\n  RatingIcon,\n  Relationship,\n  generateFieldId,\n} from '@teable/core';\nimport type { ICreateRecordsVo, ITableFullVo } from '@teable/openapi';\nimport { getRecord as getRecordApi } from '@teable/openapi';\nimport { beforeAll, afterAll, describe, expect, test } from 'vitest';\nimport {\n  convertField,\n  createField,\n  createRecords,\n  createTable,\n  deleteField,\n  deleteRecords,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecord,\n} from './utils/init-app';\nimport { seeding } from './utils/record-mock';\n\ninterface ILargeTableContext {\n  app: INestApplication;\n  mainTable: ITableFullVo;\n  linkedTable: ITableFullVo;\n  linkFieldId: string;\n  lookupFieldId: string;\n  rollupFieldId: string;\n  formulaFieldId: string;\n  sampleRecordId: string;\n  linkedRecordIds: string[];\n  cleanup: () => Promise<void>;\n}\n\nconst baseId = globalThis.testConfig.baseId;\nconst TARGET_RECORDS = 10_000;\nconst INSERT_BATCH_SIZE = 200;\nconst INITIAL_LINKED_RECORDS = 50;\nconst LINK_SETUP_BATCH = 40;\n\nconst textField = {\n  id: generateFieldId(),\n  name: 'Bench Text',\n  type: FieldType.SingleLineText,\n} satisfies IFieldRo;\n\nconst numberField = {\n  id: generateFieldId(),\n  name: 'Bench Number',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n  },\n} satisfies IFieldRo;\n\nconst longTextField = {\n  id: generateFieldId(),\n  name: 'Bench Long Text',\n  type: FieldType.LongText,\n} satisfies IFieldRo;\n\nconst checkboxField = {\n  id: generateFieldId(),\n  name: 'Bench Checkbox',\n  type: FieldType.Checkbox,\n} satisfies IFieldRo;\n\nconst dateField = {\n  id: generateFieldId(),\n  name: 'Bench Date',\n  type: FieldType.Date,\n} satisfies IFieldRo;\n\nconst singleSelectField = {\n  id: generateFieldId(),\n  name: 'Bench Select',\n  type: FieldType.SingleSelect,\n  options: {\n    choices: [\n      { name: 'alpha', color: Colors.Blue },\n      { name: 'beta', color: Colors.Green },\n      { name: 'gamma', color: Colors.Red },\n    ],\n  },\n} satisfies IFieldRo;\n\nconst multiSelectField = {\n  id: generateFieldId(),\n  name: 'Bench Multi',\n  type: FieldType.MultipleSelect,\n  options: {\n    choices: [\n      { name: 'red', color: Colors.Red },\n      { name: 'green', color: Colors.Green },\n      { name: 'blue', color: Colors.Blue },\n      { name: 'orange', color: Colors.Orange },\n    ],\n  },\n} satisfies IFieldRo;\n\nconst textFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Text B',\n  type: FieldType.SingleLineText,\n} satisfies IFieldRo;\n\nconst numberFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Number B',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n  },\n} satisfies IFieldRo;\n\nconst longTextFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Long Text B',\n  type: FieldType.LongText,\n} satisfies IFieldRo;\n\nconst textFieldC = {\n  id: generateFieldId(),\n  name: 'Bench Text C',\n  type: FieldType.SingleLineText,\n} satisfies IFieldRo;\n\nconst numberFieldC = {\n  id: generateFieldId(),\n  name: 'Bench Number C',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 3 },\n  },\n} satisfies IFieldRo;\n\nconst dateFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Date B',\n  type: FieldType.Date,\n} satisfies IFieldRo;\n\nconst singleSelectFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Select B',\n  type: FieldType.SingleSelect,\n  options: {\n    choices: [\n      { name: 'spring', color: Colors.Green },\n      { name: 'summer', color: Colors.Orange },\n      { name: 'winter', color: Colors.Blue },\n    ],\n  },\n} satisfies IFieldRo;\n\nconst multiSelectFieldB = {\n  id: generateFieldId(),\n  name: 'Bench Multi B',\n  type: FieldType.MultipleSelect,\n  options: {\n    choices: [\n      { name: 'north', color: Colors.Blue },\n      { name: 'south', color: Colors.Green },\n      { name: 'east', color: Colors.Yellow },\n      { name: 'west', color: Colors.Red },\n    ],\n  },\n} satisfies IFieldRo;\n\nconst ratingField = {\n  id: generateFieldId(),\n  name: 'Bench Rating',\n  type: FieldType.Rating,\n  options: {\n    icon: RatingIcon.Star,\n    color: Colors.YellowBright,\n    max: 5,\n  },\n} satisfies IFieldRo;\n\nconst baseFields: IFieldRo[] = [\n  textField,\n  numberField,\n  longTextField,\n  checkboxField,\n  dateField,\n  singleSelectField,\n  multiSelectField,\n  textFieldB,\n  numberFieldB,\n  longTextFieldB,\n  textFieldC,\n  numberFieldC,\n  dateFieldB,\n  singleSelectFieldB,\n  multiSelectFieldB,\n  ratingField,\n];\n\nconst linkedNameField = {\n  id: generateFieldId(),\n  name: 'Linked Name',\n  type: FieldType.SingleLineText,\n} satisfies IFieldRo;\n\nconst linkedValueField = {\n  id: generateFieldId(),\n  name: 'Linked Value',\n  type: FieldType.Number,\n  options: {\n    formatting: { type: NumberFormattingType.Decimal, precision: 0 },\n  },\n} satisfies IFieldRo;\n\nconst LINK_FIELD_NAME = 'Benchmark Links';\nconst LOOKUP_FIELD_NAME = 'Benchmark Lookup';\nconst ROLLUP_FIELD_NAME = 'Benchmark Rollup';\nconst FORMULA_FIELD_NAME = 'Benchmark Formula';\nconst CONTEXT_NOT_INITIALIZED_MESSAGE = 'Large table context is not initialized';\n\nlet contextPromise: Promise<ILargeTableContext> | null = null;\n\nasync function ensureLargeTableContext(): Promise<ILargeTableContext> {\n  if (!contextPromise) {\n    contextPromise = (async () => {\n      const appCtx = await initApp();\n      const app = appCtx.app;\n\n      const linkedTable = await createTable(baseId, {\n        name: 'benchmark-linked',\n        fields: [linkedNameField, linkedValueField],\n        records: Array.from({ length: INITIAL_LINKED_RECORDS }, (_, index) => ({\n          fields: {\n            [linkedNameField.name]: `Linked ${index + 1}`,\n            [linkedValueField.name]: (index % 10) + 1,\n          },\n        })),\n      });\n\n      const linkedRecordIds = linkedTable.records?.map((record) => record.id) ?? [];\n\n      const mainTable = await createTable(baseId, {\n        name: 'benchmark-main',\n        fields: baseFields,\n      });\n\n      await seeding(mainTable.id, TARGET_RECORDS);\n\n      const linkField = await createField(mainTable.id, {\n        id: generateFieldId(),\n        name: LINK_FIELD_NAME,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: linkedTable.id,\n        },\n      });\n\n      const lookupField = await createField(mainTable.id, {\n        id: generateFieldId(),\n        name: LOOKUP_FIELD_NAME,\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: linkedTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: linkedValueField.id,\n        },\n      });\n\n      const rollupField = await createField(mainTable.id, {\n        id: generateFieldId(),\n        name: ROLLUP_FIELD_NAME,\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: linkedTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: linkedValueField.id,\n        },\n      });\n\n      const formulaField = await createField(mainTable.id, {\n        id: generateFieldId(),\n        name: FORMULA_FIELD_NAME,\n        type: FieldType.Formula,\n        options: {\n          expression: `({${numberField.id}}) + ({${numberFieldB.id}})`,\n        },\n      });\n\n      const seededRecords = await getRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        take: LINK_SETUP_BATCH,\n      });\n\n      const linkTargets = linkedRecordIds.length\n        ? linkedRecordIds\n        : linkedTable.records.map((record) => record.id);\n\n      if (!linkTargets.length) {\n        throw new Error('Benchmark setup failed: no linked records available.');\n      }\n\n      await Promise.all(\n        seededRecords.records.map((record, index) => {\n          const value = [\n            { id: linkTargets[index % linkTargets.length] },\n            { id: linkTargets[(index + 1) % linkTargets.length] },\n          ];\n\n          return updateRecord(mainTable.id, record.id, {\n            fieldKeyType: FieldKeyType.Id,\n            record: {\n              fields: {\n                [linkField.id]: value,\n              },\n            },\n          });\n        })\n      );\n\n      const sampleRecordId = seededRecords.records[0]?.id;\n\n      if (!sampleRecordId) {\n        throw new Error('Benchmark setup failed: missing sample record.');\n      }\n\n      const cleanup = async () => {\n        try {\n          await permanentDeleteTable(baseId, mainTable.id);\n        } catch (error) {\n          console.warn('[large-table] cleanup main table failed', error);\n        }\n        try {\n          await permanentDeleteTable(baseId, linkedTable.id);\n        } catch (error) {\n          console.warn('[large-table] cleanup linked table failed', error);\n        }\n        await app.close();\n      };\n\n      return {\n        app,\n        mainTable,\n        linkedTable,\n        linkFieldId: linkField.id,\n        lookupFieldId: lookupField.id,\n        rollupFieldId: rollupField.id,\n        formulaFieldId: formulaField.id,\n        sampleRecordId,\n        linkedRecordIds: linkTargets,\n        cleanup,\n      };\n    })();\n  }\n\n  return contextPromise;\n}\n\ndescribe('Large table operations timing (e2e)', () => {\n  let context: ILargeTableContext | undefined;\n\n  beforeAll(async () => {\n    context = await ensureLargeTableContext();\n  });\n\n  afterAll(async () => {\n    if (context) {\n      await context.cleanup();\n    }\n  });\n\n  test('convert dependent columns (timed)', { timeout: 300_000 }, async () => {\n    const activeContext = context;\n    if (!activeContext) {\n      throw new Error(CONTEXT_NOT_INITIALIZED_MESSAGE);\n    }\n\n    const timings: Record<string, number> = {};\n    const memoryStats: Record<string, number> = {};\n\n    const captureMemory = (label: string) => {\n      const stats = process.memoryUsage();\n      const rssMB = stats.rss / 1024 / 1024;\n      memoryStats[label] = Number(rssMB.toFixed(2));\n    };\n\n    const measure = async <T>(label: string, fn: () => Promise<T>): Promise<T> => {\n      const start = performance.now();\n      captureMemory(`${label}:start`);\n      try {\n        return await fn();\n      } finally {\n        timings[label] = performance.now() - start;\n        captureMemory(`${label}:end`);\n      }\n    };\n\n    const stringField = await measure('convertToText', () =>\n      convertField(activeContext.mainTable.id, numberField.id, {\n        type: FieldType.SingleLineText,\n      })\n    );\n    expect(stringField.type).toBe(FieldType.SingleLineText);\n\n    const numberAgain = await measure('convertToNumber', () =>\n      convertField(activeContext.mainTable.id, numberField.id, {\n        type: FieldType.Number,\n        options: { formatting: { type: NumberFormattingType.Decimal, precision: 0 } },\n      })\n    );\n    expect(numberAgain.type).toBe(FieldType.Number);\n\n    const finalRecord = await measure('fetchRecord', () =>\n      getRecordApi(activeContext.mainTable.id, activeContext.sampleRecordId, {\n        fieldKeyType: FieldKeyType.Id,\n      }).then((res) => res.data)\n    );\n\n    const finalFields = finalRecord.fields ?? {};\n    const requiredFieldIds = [activeContext.lookupFieldId, activeContext.rollupFieldId];\n\n    for (const fieldId of requiredFieldIds) {\n      expect(finalFields[fieldId]).toBeDefined();\n    }\n\n    const total = Object.values(timings).reduce((sum, current) => sum + current, 0);\n    console.info('[large-table] timings (ms):', {\n      ...Object.fromEntries(\n        Object.entries(timings).map(([label, value]) => [label, Number(value.toFixed(2))])\n      ),\n      total: Number(total.toFixed(2)),\n    });\n\n    console.info('[large-table] memory (MB):', memoryStats);\n  });\n\n  test('create formula column (timed)', { timeout: 300_000 }, async () => {\n    const activeContext = context;\n    if (!activeContext) {\n      throw new Error(CONTEXT_NOT_INITIALIZED_MESSAGE);\n    }\n\n    const start = performance.now();\n    const dynamicFormula = await createField(activeContext.mainTable.id, {\n      id: generateFieldId(),\n      name: `Timed Formula ${Date.now()}`,\n      type: FieldType.Formula,\n      options: {\n        expression: `({${numberField.id}}) + ({${numberFieldB.id}})`,\n      },\n    });\n\n    const elapsed = performance.now() - start;\n    console.info('[large-table] create formula field timing (ms):', Number(elapsed.toFixed(2)));\n\n    expect(dynamicFormula.type).toBe(FieldType.Formula);\n\n    await deleteField(activeContext.mainTable.id, dynamicFormula.id);\n  });\n\n  test(`create ${INSERT_BATCH_SIZE} records batch (timed)`, { timeout: 300_000 }, async () => {\n    if (!context) {\n      throw new Error(CONTEXT_NOT_INITIALIZED_MESSAGE);\n    }\n\n    const linkPool = context.linkedRecordIds.length\n      ? context.linkedRecordIds\n      : context.linkedTable.records.map((record) => record.id);\n\n    if (!linkPool.length) {\n      throw new Error('No linked records available for benchmark insert payload');\n    }\n\n    const now = Date.now();\n    const recordsPayload = Array.from({ length: INSERT_BATCH_SIZE }, (_, index) => {\n      const linkId = linkPool[index % linkPool.length] ?? null;\n      return {\n        fields: {\n          [textField.id]: `Bench row ${now}-${index}`,\n          [numberField.id]: index,\n          ...(linkId ? { [context!.linkFieldId]: [{ id: linkId }] } : {}),\n        },\n      };\n    });\n\n    const created = await getTimedRecordsCreation(context.mainTable.id, recordsPayload);\n    expect(created.records.length).toBe(INSERT_BATCH_SIZE);\n\n    const createdIds = created.records.map((record) => record.id);\n    await deleteRecords(context.mainTable.id, createdIds);\n  });\n});\n\nasync function getTimedRecordsCreation(\n  tableId: string,\n  recordsPayload: Array<{ fields: Record<string, unknown> }>\n): Promise<ICreateRecordsVo> {\n  const start = performance.now();\n  const created = await createRecords(tableId, {\n    fieldKeyType: FieldKeyType.Id,\n    typecast: true,\n    records: recordsPayload,\n  });\n\n  const elapsed = performance.now() - start;\n  console.info('[large-table] createRecords batch timing (ms):', Number(elapsed.toFixed(2)));\n\n  return created;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/legacy-created-time-create.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { RecordCreateService } from '../src/features/record/record-modify/record-create.service';\nimport type { IClsStore } from '../src/types/cls';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n  getRecords,\n  runWithTestUser,\n} from './utils/init-app';\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst parseSchemaAndTable = (dbTableName: string): [string, string] => {\n  const trimQuotes = (value: string) =>\n    value.startsWith('\"') && value.endsWith('\"') ? value.slice(1, -1) : value;\n  const parts = dbTableName.split('.');\n  return [trimQuotes(parts[0] ?? dbTableName), trimQuotes(parts[1] ?? dbTableName)];\n};\n\ndescribe('Legacy createdTime create compatibility (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let clsService: ClsService<IClsStore>;\n  let recordCreateService: RecordCreateService;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    app = (await initApp()).app;\n    prisma = app.get(PrismaService);\n    clsService = app.get<ClsService<IClsStore>>(ClsService);\n    recordCreateService = app.get(RecordCreateService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('fills legacy plain createdTime columns during create so dependent formulas stay correct', async () => {\n    const table: ITableFullVo = await createTable(baseId, {\n      name: 'legacy_created_time_create',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      records: [],\n    });\n\n    try {\n      const nameField = table.fields.find((field) => field.name === 'Name');\n      expect(nameField).toBeDefined();\n\n      const createdTimeField = await createField(table.id, {\n        name: 'Created Time',\n        type: FieldType.CreatedTime,\n      });\n      const statusField = await createField(table.id, {\n        name: 'Created Status',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${createdTimeField.id}}, \"ok\", \"bad\")`,\n        },\n      });\n\n      const tableMeta = await prisma.tableMeta.findUniqueOrThrow({\n        where: { id: table.id },\n        select: { dbTableName: true },\n      });\n      const [schemaName, rawTableName] = parseSchemaAndTable(tableMeta.dbTableName);\n      const quotedTableName = `\"${schemaName}\".\"${rawTableName}\"`;\n\n      await prisma.$executeRawUnsafe(\n        `ALTER TABLE ${quotedTableName} DROP COLUMN \"${createdTimeField.dbFieldName}\"`\n      );\n      await prisma.$executeRawUnsafe(\n        `ALTER TABLE ${quotedTableName} ADD COLUMN \"${createdTimeField.dbFieldName}\" TIMESTAMPTZ`\n      );\n      await prisma.$executeRawUnsafe(\n        `UPDATE field SET meta = NULL WHERE id = '${createdTimeField.id}'`\n      );\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [nameField!.id]: 'legacy-row',\n            },\n          },\n        ],\n      });\n\n      const recordId = created.records[0].id;\n      let row:\n        | {\n            created_time: Date | string | null;\n            legacy_created_time: Date | string | null;\n            created_status: string | null;\n          }\n        | undefined;\n\n      for (let i = 0; i < 20; i++) {\n        const rows = await prisma.$queryRawUnsafe<\n          {\n            created_time: Date | string | null;\n            legacy_created_time: Date | string | null;\n            created_status: string | null;\n          }[]\n        >(\n          `SELECT \"__created_time\" AS created_time,\n                  \"${createdTimeField.dbFieldName}\" AS legacy_created_time,\n                  \"${statusField.dbFieldName}\" AS created_status\n             FROM ${quotedTableName}\n            WHERE \"__id\" = '${recordId}'`\n        );\n        row = rows[0];\n        if (row?.legacy_created_time && row.created_status === 'ok') {\n          break;\n        }\n        await sleep(200);\n      }\n\n      expect(row?.created_time).toBeTruthy();\n      expect(row?.legacy_created_time).toBeTruthy();\n      expect(row?.created_status).toBe('ok');\n      expect(new Date(row!.legacy_created_time as string | Date).toISOString()).toEqual(\n        new Date(row!.created_time as string | Date).toISOString()\n      );\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('keeps createRecordsOnlySql working for tables without legacy createdTime columns', async () => {\n    const table: ITableFullVo = await createTable(baseId, {\n      name: 'create_records_only_sql_plain',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      records: [],\n    });\n\n    try {\n      const nameField = table.fields.find((field) => field.name === 'Name');\n      expect(nameField).toBeDefined();\n\n      await runWithTestUser(clsService, async () => {\n        await recordCreateService.createRecordsOnlySql(table.id, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [\n            {\n              fields: {\n                [nameField!.id]: 'plain-row',\n              },\n            },\n          ],\n        });\n      });\n\n      const result = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(result.records).toHaveLength(1);\n      expect(result.records[0].fields[nameField!.id]).toBe('plain-row');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/lin-field-not-null.e2e-spec.ts",
    "content": "/**\n * T1756: Link field NOT NULL constraint sync bug\n *\n * Steps to reproduce:\n * 1. Create a Number field\n * 2. Set notNull=true on the Number field\n * 3. Convert it to a Link field\n * 4. Edit the Link field and turn off notNull\n * 5. Try to create a record with empty Link value - FAILS because DB constraint still exists\n */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  convertField,\n  createRecords,\n  getField,\n  initApp,\n  permanentDeleteTable,\n  deleteRecords,\n  getRecords,\n} from './utils/init-app';\n\ndescribe('T1756: Link field NOT NULL constraint sync bug', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('bug reproduction', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: `table1-${Date.now()}` });\n      table2 = await createTable(baseId, { name: `table2-${Date.now()}` });\n\n      // Clear default records\n      const records1 = await getRecords(table1.id);\n      const records2 = await getRecords(table2.id);\n      if (records1.records.length) {\n        await deleteRecords(\n          table1.id,\n          records1.records.map((r) => r.id)\n        );\n      }\n      if (records2.records.length) {\n        await deleteRecords(\n          table2.id,\n          records2.records.map((r) => r.id)\n        );\n      }\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should allow creating record with empty Link after removing notNull constraint', async () => {\n      // Step 1: Create a Number field\n      const numberField = await createField(table1.id, {\n        name: 'TestField',\n        type: FieldType.Number,\n      });\n\n      // Step 2: Set notNull=true on the Number field\n      await convertField(table1.id, numberField.id, {\n        ...numberField,\n        notNull: true,\n      });\n\n      // Step 3: Convert to Link field\n      const linkField = await convertField(table1.id, numberField.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      // Step 4: Turn off notNull on the Link field\n      const linkFieldFull = await getField(table1.id, linkField.id);\n      const updatedLinkField = await convertField(table1.id, linkField.id, {\n        ...linkFieldFull,\n        notNull: false,\n      });\n\n      // Verify metadata shows notNull is false\n      expect(updatedLinkField.notNull).toBeFalsy();\n\n      // Step 5: Try to create a record with empty Link value\n      // BUG: This should succeed since notNull is false in metadata\n      // But it fails because DB still has NOT NULL constraint\n      const result = await createRecords(\n        table1.id,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: {} }], // Empty record, no Link value\n        },\n        201 // Expect success (201), but will get 500 due to DB constraint\n      );\n\n      expect(result.records).toHaveLength(1);\n    });\n\n    it('should not allow creating record with empty Link after setting notNull constraint', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n      const linkFieldFull = await getField(table1.id, linkField.id);\n      await convertField(table1.id, linkField.id, {\n        ...linkFieldFull,\n        notNull: true,\n      });\n      await createRecords(\n        table1.id,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: {} }], // Empty record, no Link value\n        },\n        400 // Expect success (201), but will get 500 due to DB constraint\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-api.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFieldVo,\n  ILinkFieldOptions,\n  ILookupLinkOptionsVo,\n  LinkFieldCore,\n} from '@teable/core';\nimport {\n  Colors,\n  DriverClient,\n  FieldKeyType,\n  FieldType,\n  getRandomString,\n  NumberFormattingType,\n  RatingIcon,\n  Relationship,\n  isLinkLookupOptions,\n} from '@teable/core';\nimport type { ITableFullVo, IRecordsVo } from '@teable/openapi';\nimport {\n  axios,\n  convertField,\n  createBase,\n  deleteBase,\n  deleteRecords,\n  planFieldConvert,\n  undo,\n  updateDbTableName,\n  updateRecords,\n} from '@teable/openapi';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  deleteField,\n  deleteRecord,\n  permanentDeleteTable,\n  getField,\n  getFields,\n  getRecord,\n  getRecords,\n  getTable,\n  initApp,\n  updateRecord,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('OpenAPI link (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const spaceId = globalThis.testConfig.spaceId;\n  const split = globalThis.testConfig.driver === 'postgresql' ? '.' : '_';\n  let eventEmitterService: EventEmitterService;\n  let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n    const windowId = 'win' + getRandomString(8);\n    axios.interceptors.request.use((config) => {\n      config.headers['X-Window-Id'] = windowId;\n      return config;\n    });\n    awaitWithEvent = isForceV2\n      ? async <T>(action: () => Promise<T>) => await action()\n      : createAwaitWithEvent(eventEmitterService, Events.OPERATION_PUSH);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('create table with link field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let table3: ITableFullVo;\n\n    afterEach(async () => {\n      table1 && (await permanentDeleteTable(baseId, table1.id));\n      table2 && (await permanentDeleteTable(baseId, table2.id));\n      table3 && (await permanentDeleteTable(baseId, table3.id));\n    });\n\n    it('should format lookup-of-link titles inside formulas when aggregating link records', async () => {\n      table1 = await createTable(baseId, {\n        name: 'tblA-link-api',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Label', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { Name: 'Alpha', Label: 'Alpha Label' } },\n          { fields: { Name: 'Beta', Label: 'Beta Label' } },\n        ],\n      });\n      // eslint-disable-next-line no-console\n\n      table2 = await createTable(baseId, {\n        name: 'tblB-link-api',\n        fields: [\n          { name: 'Capture', type: FieldType.SingleLineText },\n          { name: 'Shot Time', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { Capture: 'Screen 1', 'Shot Time': '2024-01-01' } },\n          { fields: { Capture: 'Screen 2', 'Shot Time': '2024-02-02' } },\n          { fields: { Capture: 'Screen 3', 'Shot Time': '2024-03-03' } },\n        ],\n      });\n\n      const linkToAField = await createField(table2.id, {\n        name: 'LinkToA',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      });\n      // eslint-disable-next-line no-console\n\n      await updateRecordByApi(table2.id, table2.records[0].id, linkToAField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, linkToAField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[2].id, linkToAField.id, {\n        id: table1.records[1].id,\n      });\n\n      table3 = await createTable(baseId, {\n        name: 'tblC-link-api',\n        fields: [{ name: 'Entry', type: FieldType.SingleLineText }],\n        records: [{ fields: { Entry: 'Group A' } }, { fields: { Entry: 'Group B' } }],\n      });\n\n      const linkToBField = await createField(table3.id, {\n        name: 'LinkToB',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      await updateRecordByApi(table3.id, table3.records[0].id, linkToBField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n      await updateRecordByApi(table3.id, table3.records[1].id, linkToBField.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      const lookupLinkToAField = await createField(table3.id, {\n        name: 'LookupLinkToA',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkToBField.id,\n          lookupFieldId: linkToAField.id,\n        },\n      });\n\n      const shotTimeFieldId = table2.fields.find((f) => f.name === 'Shot Time')!.id;\n      const lookupShotTimeField = await createField(table3.id, {\n        name: 'LookupShotTime',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkToBField.id,\n          lookupFieldId: shotTimeFieldId,\n        },\n      });\n\n      await createField(table3.id, {\n        name: 'Summary',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupLinkToAField.id}} & ' - ' & ARRAYJOIN({${lookupShotTimeField.id}}, ', ')`,\n        },\n      });\n\n      const recordsVo: IRecordsVo = await getRecords(table3.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      expect(recordsVo.records).toHaveLength(2);\n      const summaryA = recordsVo.records.find((r) => r.fields.Entry === 'Group A')!;\n      const summaryB = recordsVo.records.find((r) => r.fields.Entry === 'Group B')!;\n\n      expect(typeof summaryA.fields.Summary).toBe('string');\n      expect(typeof summaryB.fields.Summary).toBe('string');\n    });\n\n    it('should create foreign link field when create a new table with many-one link field', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      };\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo, linkFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      const getTable1FieldsResult = await getFields(table1.id);\n\n      expect(getTable1FieldsResult).toHaveLength(3);\n      expect(getTable1FieldsResult[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          selfKeyName: '__fk_' + table2.fields[2].id,\n          foreignKeyName: '__id',\n          symmetricFieldId: table2.fields[2].id,\n        },\n      });\n\n      expect(table2.fields[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          lookupFieldId: getTable1FieldsResult[0].id,\n          foreignKeyName: '__fk_' + table2.fields[2].id,\n          selfKeyName: '__id',\n          symmetricFieldId: getTable1FieldsResult[2].id,\n        },\n      });\n    });\n\n    it('should create foreign link field when create a new table with many-many link field', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      };\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo, linkFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      const getTable1FieldsResult = await getFields(table1.id);\n      expect(getTable1FieldsResult).toHaveLength(3);\n      table1.fields = getTable1FieldsResult;\n\n      const fkHostTableName = `${baseId}${split}junction_${table2.fields[2].id}_${\n        (table2.fields[2].options as ILinkFieldOptions).symmetricFieldId\n      }`;\n\n      expect(table1.fields[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + table2.fields[2].id,\n          foreignKeyName: '__fk_' + table1.fields[2].id,\n          symmetricFieldId: table2.fields[2].id,\n        },\n      });\n\n      expect(table2.fields[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + table1.fields[2].id,\n          foreignKeyName: '__fk_' + table2.fields[2].id,\n          symmetricFieldId: table1.fields[2].id,\n        },\n      });\n    });\n\n    it('should auto create foreign manyOne link field when create oneMany link field', async () => {\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [numberFieldRo, textFieldRo],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [numberFieldRo, textFieldRo, linkFieldRo],\n      });\n\n      const getTable1FieldsResult = await getFields(table1.id);\n\n      expect(getTable1FieldsResult).toHaveLength(3);\n      expect(getTable1FieldsResult[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          selfKeyName: '__id',\n          foreignKeyName: '__fk_' + getTable1FieldsResult[2].id,\n          symmetricFieldId: table2.fields[2].id,\n        },\n      });\n\n      expect(table2.fields[2]).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n          lookupFieldId: getTable1FieldsResult[0].id,\n          foreignKeyName: '__id',\n          selfKeyName: '__fk_' + getTable1FieldsResult[2].id,\n          symmetricFieldId: getTable1FieldsResult[2].id,\n        },\n      });\n    });\n\n    it('should set link record in foreign link field when create a new table with link field and link record', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo, linkFieldRo],\n        records: [\n          {\n            fields: {\n              'text field': 'table2_1',\n              'link field': [{ id: table1.records[0].id }, { id: table1.records[1].id }],\n            },\n          },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      expect(table2.records).toHaveLength(3);\n      expect(table2.records[0].fields['link field']).toEqual([\n        { id: table1.records[0].id, title: 'table1_1' },\n        { id: table1.records[1].id, title: 'table1_2' },\n      ]);\n    });\n\n    it('should throw error when create a new table with link field and error link record', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      await createTable(\n        baseId,\n        {\n          name: 'table2',\n          fields: [textFieldRo, numberFieldRo, linkFieldRo],\n          records: [\n            {\n              fields: {\n                'text field': 'table2_1',\n                'link field': [{ id: table1.records[0].id }, { id: table1.records[0].id }], // illegal link record\n              },\n            },\n            { fields: { 'text field': 'table2_2' } },\n            { fields: { 'text field': 'table2_3' } },\n          ],\n        },\n        400\n      );\n    });\n\n    it('should have correct title when create a new table with manyOne link field', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      };\n\n      const table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, linkFieldRo],\n        records: [\n          {\n            fields: {\n              'text field': 'table2_1',\n              'link field': { id: table1.records[0].id },\n            },\n          },\n        ],\n      });\n      expect(table2.records[0].fields['link field']).toEqual({\n        title: 'table1_1',\n        id: table1.records[0].id,\n      });\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const table1Fields = await getFields(table1.id);\n\n      expect(table1Records.records[0].fields[table1Fields[1].id]).toEqual([\n        {\n          title: 'table2_1',\n          id: table2.records[0].id,\n        },\n      ]);\n    });\n\n    it('should have correct title when create a new table with oneMany link field', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n      const table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, linkFieldRo],\n        records: [\n          {\n            fields: {\n              'text field': 'table2_1',\n              'link field': [{ id: table1.records[0].id }],\n            },\n          },\n        ],\n      });\n      expect(table2.records[0].fields['link field']).toEqual([\n        {\n          title: 'table1_1',\n          id: table1.records[0].id,\n        },\n      ]);\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      const table1Fields = await getFields(table1.id);\n\n      expect(table1Records.records[0].fields[table1Fields[1].id]).toEqual({\n        title: 'table2_1',\n        id: table2.records[0].id,\n      });\n    });\n\n    it('should create a new record with link field when primary field is a formula', async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n      const table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, linkFieldRo],\n        records: [\n          {\n            fields: {\n              'text field': 'table2_1',\n              'link field': [{ id: table1.records[0].id }],\n            },\n          },\n          {\n            fields: {\n              'text field': 'table2_2',\n            },\n          },\n        ],\n      });\n\n      const table1Fields = await getFields(table1.id);\n      const table1LinkField = table1Fields[1];\n\n      const table1PrimaryField = (\n        await convertField(table1.id, table1.fields[0].id, {\n          type: FieldType.Formula,\n          options: {\n            expression: `{${table1LinkField.id}}`,\n          },\n        })\n      ).data;\n\n      const table1Records = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n\n      expect(table1Records.records[0].fields[table1PrimaryField.id]).toEqual('table2_1');\n\n      // create with existing link cellValue in table2\n      await createRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table1LinkField.id]: { id: table2.records[0].id } } }],\n      });\n\n      // create with empty link cellValue in table2\n      await createRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table1LinkField.id]: { id: table2.records[1].id } } }],\n      });\n\n      // update with existing link cellValue in table2\n      await updateRecordByApi(table1.id, table1.records[0].id, table1LinkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const table1RecordsAfter = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id });\n      expect(table1RecordsAfter.records[0].fields[table1PrimaryField.id]).toEqual('table2_1');\n    });\n  });\n\n  describe('create link fields', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create two way, many many link', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n      const fkHostTableName = `${baseId}${split}junction_${linkField1.id}_${\n        (linkField1.options as ILinkFieldOptions).symmetricFieldId\n      }`;\n\n      const table2Fields = await getFields(table2.id);\n      const linkField2 = table2Fields[2];\n\n      expect(linkField1).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + linkField2.id,\n          foreignKeyName: '__fk_' + linkField1.id,\n          symmetricFieldId: linkField2.id,\n        },\n      });\n\n      expect(linkField2).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + linkField1.id,\n          foreignKeyName: '__fk_' + linkField2.id,\n          symmetricFieldId: linkField1.id,\n        },\n      });\n    });\n\n    it('should create two way, many many link to self', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n      const fkHostTableName = `${baseId}${split}junction_${linkField1.id}_${\n        (linkField1.options as ILinkFieldOptions).symmetricFieldId\n      }`;\n\n      const newFields = await getFields(table1.id, table1.views[0].id);\n      const linkField2 = newFields[3];\n\n      expect(linkField1).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + linkField2.id,\n          foreignKeyName: '__fk_' + linkField1.id,\n          symmetricFieldId: linkField2.id,\n        },\n      });\n\n      expect(linkField2).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          fkHostTableName: fkHostTableName,\n          selfKeyName: '__fk_' + linkField1.id,\n          foreignKeyName: '__fk_' + linkField2.id,\n          symmetricFieldId: linkField1.id,\n        },\n      });\n    });\n\n    it('should create one way, many many link', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n      const fkHostTableName = `${baseId}${split}junction_${linkField1.id}`;\n\n      expect(linkField1).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n          fkHostTableName: fkHostTableName,\n          lookupFieldId: table2.fields[0].id,\n          foreignKeyName: '__fk_' + linkField1.id,\n        },\n      });\n      expect((linkField1.options as ILinkFieldOptions).selfKeyName).toContain('rad');\n      expect((linkField1.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      const table2Fields = await getFields(table2.id);\n      expect(table2Fields.length).toEqual(2);\n    });\n\n    it('should create two way, one one link', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n      const table2Fields = await getFields(table2.id);\n      const linkField2 = table2Fields[2];\n\n      expect(linkField1).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          fkHostTableName: table1.dbTableName,\n          lookupFieldId: table2.fields[0].id,\n          selfKeyName: '__id',\n          foreignKeyName: `__fk_${linkField1.id}`,\n          symmetricFieldId: linkField2.id,\n        },\n      });\n\n      expect(linkField2).toMatchObject({\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table1.id,\n          fkHostTableName: table1.dbTableName,\n          lookupFieldId: table1.fields[0].id,\n          foreignKeyName: '__id',\n          selfKeyName: `__fk_${linkField1.id}`,\n          symmetricFieldId: linkField1.id,\n        },\n      });\n    });\n\n    it('should throw error when add a duplicate record in one way one one link field', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      // first update\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, {\n        title: 'B1',\n        id: table2.records[0].id,\n      });\n\n      // update a duplicated link record in other record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        linkField1.id,\n        { id: table2.records[0].id },\n        400\n      );\n    });\n\n    it('should throw error when add a duplicate record in one way one one link field in create record', async () => {\n      // create link field\n      const Link1FieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, Link1FieldRo);\n\n      await createRecords(\n        table1.id,\n        {\n          records: [\n            { fields: { [linkField1.id]: { id: table2.records[0].id } } },\n            { fields: { [linkField1.id]: { id: table2.records[0].id } } },\n          ],\n        },\n        400\n      );\n    });\n  });\n\n  describe('many one and one many link field cell update', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      // create link field\n      const table2LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n        },\n      };\n\n      await createField(table2.id, table2LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update foreign link field when set a new link in to link field cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        title: 'table1_2',\n        id: table1.records[1].id,\n      });\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toBeUndefined();\n      expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'table2_1',\n          id: table2.records[0].id,\n        },\n      ]);\n    });\n\n    it('should update foreign link field when change lookupField value', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      // add an extra link for table1 record1\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[2].id, {\n        title: 'table1_1',\n        id: table1.records[0].id,\n      });\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'B1',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'AX');\n\n      const table2RecordResult2 = await getRecords(table2.id);\n\n      expect(table2RecordResult2.records[0].fields[table2.fields[2].name!]).toEqual({\n        title: 'AX',\n        id: table1.records[0].id,\n      });\n    });\n\n    it('should update self foreign link with correct title', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'B1',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct formatted title', async () => {\n      // use number field as primary field\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      });\n\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 1);\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 2);\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, null);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: '1.0',\n          id: table2.records[0].id,\n        },\n        {\n          title: '2.0',\n          id: table2.records[1].id,\n        },\n        {\n          title: undefined,\n          id: table2.records[2].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct currency formatted title', async () => {\n      // use number field with currency formatting as primary field\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Currency, symbol: '$', precision: 2 },\n        },\n      });\n\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set values for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 100.5);\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 250.75);\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, null);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: '$100.50',\n          id: table2.records[0].id,\n        },\n        {\n          title: '$250.75',\n          id: table2.records[1].id,\n        },\n        {\n          title: undefined,\n          id: table2.records[2].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct percentage formatted title', async () => {\n      // use number field with percentage formatting as primary field\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Percent, precision: 1 },\n        },\n      });\n\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set values for lookup field (stored as decimal, displayed as percentage)\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 0.25);\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 0.8);\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, null);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: '25.0%',\n          id: table2.records[0].id,\n        },\n        {\n          title: '80.0%',\n          id: table2.records[1].id,\n        },\n        {\n          title: undefined,\n          id: table2.records[2].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct rating field formatted title', async () => {\n      // use rating field as primary field\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.Rating,\n        options: {\n          icon: RatingIcon.Star,\n          color: Colors.YellowBright,\n          max: 5,\n        },\n      });\n\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n      // set values for rating field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 3);\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 5);\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, null);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: '3',\n          id: table2.records[0].id,\n        },\n        {\n          title: '5',\n          id: table2.records[1].id,\n        },\n        {\n          title: undefined,\n          id: table2.records[2].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct auto number field formatted title', async () => {\n      // use auto number field as primary field\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.AutoNumber,\n      });\n\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n        { id: table2.records[2].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      // Auto number fields should be formatted as text\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: '1',\n          id: table2.records[0].id,\n        },\n        {\n          title: '2',\n          id: table2.records[1].id,\n        },\n        {\n          title: '3',\n          id: table2.records[2].id,\n        },\n      ]);\n    });\n\n    it('should update formula field when change manyOne link cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n\n      const table2FormulaFieldRo: IFieldRo = {\n        name: 'table2Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table2.fields[2].id}}`,\n        },\n      };\n      await createField(table2.id, table2FormulaFieldRo);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        title: 'illegal title',\n        id: table1.records[1].id,\n      });\n\n      const table1RecordResult = await getRecords(table1.id);\n      const table2RecordResult = await getRecords(table2.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toBeUndefined();\n      expect(table1RecordResult.records[1].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'table2_1',\n          id: table2.records[0].id,\n        },\n      ]);\n      expect(table2RecordResult.records[0].fields[table2FormulaFieldRo.name!]).toEqual('table1_2');\n    });\n\n    it('should update formula field when change oneMany link cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n\n      const table1FormulaFieldRo: IFieldRo = {\n        name: 'table1 formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table1.fields[2].id}}`,\n        },\n      };\n\n      await createField(table1.id, table1FormulaFieldRo);\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[2].name]: [\n              { title: 'illegal test1', id: table2.records[0].id },\n              { title: 'illegal test2', id: table2.records[1].id },\n            ],\n          },\n        },\n      });\n      const table1RecordResult = await getRecords(table1.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'table2_1', id: table2.records[0].id },\n        { title: 'table2_2', id: table2.records[1].id },\n      ]);\n\n      expect(table1RecordResult.records[0].fields[table1FormulaFieldRo.name!]).toEqual([\n        'table2_1',\n        'table2_2',\n      ]);\n    });\n\n    it('should throw error when add a duplicate record in oneMany link field', async () => {\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      // first update\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      // update a duplicated link record in other record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        table1.fields[2].id,\n        [{ title: 'B1', id: table2.records[0].id }],\n        400\n      );\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n      expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toBeUndefined();\n    });\n\n    it('should throw error when add a duplicate record in oneMany link field in create record', async () => {\n      await createRecords(\n        table1.id,\n        {\n          records: [\n            {\n              fields: {\n                [table1.fields[2].id]: [{ id: table2.records[0].id }, { id: table2.records[0].id }],\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      await createRecords(\n        table1.id,\n        {\n          records: [\n            { fields: { [table1.fields[2].id]: [{ id: table2.records[0].id }] } },\n            { fields: { [table1.fields[2].id]: [{ id: table2.records[0].id }] } },\n          ],\n        },\n        400\n      );\n    });\n\n    it('should preserve multiple linkages created by concurrent requests', async () => {\n      const [createResp1, createResp2] = await Promise.all([\n        createRecords(table2.id, {\n          records: [\n            {\n              fields: {\n                [table2.fields[0].id]: 'table2_4',\n                [table2.fields[2].id]: { id: table1.records[0].id },\n              },\n            },\n          ],\n        }),\n        createRecords(table2.id, {\n          records: [\n            {\n              fields: {\n                [table2.fields[0].id]: 'table2_5',\n                [table2.fields[2].id]: { id: table1.records[0].id },\n              },\n            },\n          ],\n        }),\n      ]);\n\n      const createdRecords = [createResp1.records[0], createResp2.records[0]];\n\n      expect(createdRecords).toHaveLength(2);\n      expect(createdRecords[0].id).not.toEqual(createdRecords[1].id);\n      for (const createdRecord of createdRecords) {\n        expect(createdRecord.fields[table2.fields[2].id] as { id: string }).toMatchObject({\n          id: table1.records[0].id,\n        });\n      }\n\n      const table1Record = await getRecord(table1.id, table1.records[0].id);\n      const linkedRecords = table1Record.fields[table1.fields[2].id] as Array<{\n        id: string;\n        title?: string;\n      }>;\n\n      expect(linkedRecords).toHaveLength(2);\n      expect(linkedRecords).toEqual(\n        expect.arrayContaining([\n          expect.objectContaining({ id: createdRecords[0].id, title: 'table2_4' }),\n          expect.objectContaining({ id: createdRecords[1].id, title: 'table2_5' }),\n        ])\n      );\n\n      const refreshedFirst = await getRecord(table2.id, createdRecords[0].id);\n      const refreshedSecond = await getRecord(table2.id, createdRecords[1].id);\n\n      for (const refreshed of [refreshedFirst, refreshedSecond]) {\n        expect(refreshed.fields[table2.fields[2].id] as { id: string }).toMatchObject({\n          id: table1.records[0].id,\n        });\n      }\n    });\n\n    it('should set a text value in a link record with typecast', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'A1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n      // // reject data when typecast is false\n      await createRecords(\n        table2.id,\n        {\n          typecast: false,\n          records: [\n            {\n              fields: {\n                [table2.fields[2].id]: ['A1'],\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      const { records } = await createRecords(table2.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table2.fields[2].id]: 'A1',\n            },\n          },\n        ],\n      });\n\n      expect(records[0].fields[table2.fields[2].id]).toEqual({\n        id: table1.records[0].id,\n        title: 'A1',\n      });\n\n      const { records: records2 } = await createRecords(table1.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table1.fields[2].id]: 'B2',\n            },\n          },\n        ],\n      });\n\n      expect(records2[0].fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[1].id,\n          title: 'B2',\n        },\n      ]);\n    });\n\n    it('should update link cellValue when change primary field value', async () => {\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1+');\n\n      const record1 = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record1.fields[table1.fields[2].id]).toEqual([\n        {\n          title: 'B1+',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2+');\n      const record2 = await getRecord(table1.id, table1.records[0].id);\n      expect(record2.fields[table1.fields[2].id]).toEqual([\n        {\n          title: 'B1+',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2+',\n          id: table2.records[1].id,\n        },\n      ]);\n    });\n\n    it('should not insert illegal value in link cel', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, ['NO'], 400);\n    });\n  });\n\n  describe('many many link field cell update', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      // create link field\n      const table2LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      await createField(table2.id, table2LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update foreign link field when set a new link in to link field cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          id: table1.records[0].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          title: 'table1_2',\n          id: table1.records[1].id,\n        },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toBeUndefined();\n      expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'table2_1',\n          id: table2.records[0].id,\n        },\n      ]);\n    });\n\n    it('should update foreign link field when change lookupField value', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          id: table1.records[0].id,\n        },\n      ]);\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      // add an extra link for table1 record1\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[2].id, [\n        {\n          title: 'table1_1',\n          id: table1.records[0].id,\n        },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'B1',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'AX');\n\n      const table2RecordResult2 = await getRecords(table2.id);\n\n      expect(table2RecordResult2.records[0].fields[table2.fields[2].name!]).toEqual([\n        {\n          title: 'AX',\n          id: table1.records[0].id,\n        },\n      ]);\n    });\n\n    it('should update self foreign link with correct title', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          id: table1.records[0].id,\n        },\n      ]);\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'B1',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n    });\n\n    it('should update formula field when change link cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          id: table1.records[0].id,\n        },\n      ]);\n\n      const table2FormulaFieldRo: IFieldRo = {\n        name: 'table2Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table2.fields[2].id}}`,\n        },\n      };\n\n      await createField(table2.id, table2FormulaFieldRo);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        {\n          title: 'illegal title',\n          id: table1.records[1].id,\n        },\n      ]);\n\n      const table1RecordResult = await getRecords(table1.id);\n\n      const table2RecordResult = await getRecords(table2.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toBeUndefined();\n      expect(table1RecordResult.records[1].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'table2_1',\n          id: table2.records[0].id,\n        },\n      ]);\n      expect(table2RecordResult.records[0].fields[table2FormulaFieldRo.name!]).toEqual([\n        'table1_2',\n      ]);\n    });\n\n    it('should update formula field with function when change link cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        { id: table1.records[0].id },\n      ]);\n\n      const table2FormulaFieldRo: IFieldRo = {\n        name: 'table2Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `AND({${table2.fields[2].id}})`,\n        },\n      };\n\n      await createField(table2.id, table2FormulaFieldRo);\n\n      const t2r1 = await getRecords(table2.id);\n\n      expect(t2r1.records[0].fields[table2FormulaFieldRo.name!]).toEqual(true);\n\n      // replace\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        { id: table1.records[1].id },\n      ]);\n\n      const t2r2 = await getRecords(table2.id);\n\n      expect(t2r2.records[0].fields[table2FormulaFieldRo.name!]).toEqual(true);\n\n      // add\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        { id: table1.records[1].id },\n        { id: table1.records[2].id },\n      ]);\n\n      const t2r3 = await getRecords(table2.id);\n\n      expect(t2r3.records[0].fields[table2FormulaFieldRo.name!]).toEqual(true);\n\n      // remove\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, [\n        { id: table1.records[1].id },\n      ]);\n\n      const t2r4 = await getRecords(table2.id);\n\n      expect(t2r4.records[0].fields[table2FormulaFieldRo.name!]).toEqual(true);\n    });\n\n    it('should update formula field when change many many link cell', async () => {\n      const table1FormulaFieldRo: IFieldRo = {\n        name: 'table1 formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table1.fields[2].id}}`,\n        },\n      };\n\n      const table2FormulaFieldRo: IFieldRo = {\n        name: 'table2 formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table2.fields[2].id}}`,\n        },\n      };\n\n      await createField(table1.id, table1FormulaFieldRo);\n      await createField(table2.id, table2FormulaFieldRo);\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[2].name]: [\n              { title: 'illegal test1', id: table2.records[0].id },\n              { title: 'illegal test2', id: table2.records[1].id },\n            ],\n          },\n        },\n      });\n\n      const table1RecordResult = await getRecords(table1.id);\n      const table2RecordResult = await getRecords(table2.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'table2_1', id: table2.records[0].id },\n        { title: 'table2_2', id: table2.records[1].id },\n      ]);\n\n      expect(table2RecordResult.records[0].fields[table2.fields[2].name]).toEqual([\n        { title: 'table1_1', id: table1.records[0].id },\n      ]);\n      expect(table2RecordResult.records[1].fields[table2.fields[2].name]).toEqual([\n        { title: 'table1_1', id: table1.records[0].id },\n      ]);\n\n      expect(table1RecordResult.records[0].fields[table1FormulaFieldRo.name!]).toEqual([\n        'table2_1',\n        'table2_2',\n      ]);\n\n      expect(table2RecordResult.records[0].fields[table2FormulaFieldRo.name!]).toEqual([\n        'table1_1',\n      ]);\n      expect(table2RecordResult.records[1].fields[table2FormulaFieldRo.name!]).toEqual([\n        'table1_1',\n      ]);\n    });\n\n    it('should throw error when add a duplicate record within one cell', async () => {\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      // first update\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      // allow to update a duplicated link record in other record\n      await updateRecordByApi(table1.id, table1.records[1].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n      ]);\n\n      // not allow to update a duplicated link record within one cell\n      await updateRecordByApi(\n        table1.id,\n        table1.records[2].id,\n        table1.fields[2].id,\n        [\n          { title: 'B2', id: table2.records[1].id },\n          { title: 'B2', id: table2.records[1].id },\n        ],\n        400\n      );\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      expect(table1RecordResult2.records[2].fields[table1.fields[2].name]).toBeUndefined();\n    });\n\n    it('should set a text value in a link record with typecast', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'A1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n      // // reject data when typecast is false\n      await createRecords(\n        table2.id,\n        {\n          typecast: false,\n          records: [\n            {\n              fields: {\n                [table2.fields[2].id]: ['A1'],\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      const { records } = await createRecords(table2.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table2.fields[2].id]: 'A1',\n            },\n          },\n        ],\n      });\n\n      expect(records[0].fields[table2.fields[2].id]).toEqual([\n        {\n          id: table1.records[0].id,\n          title: 'A1',\n        },\n      ]);\n\n      const { records: records2 } = await createRecords(table1.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table1.fields[2].id]: 'B2',\n            },\n          },\n        ],\n      });\n\n      expect(records2[0].fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[1].id,\n          title: 'B2',\n        },\n      ]);\n    });\n  });\n\n  describe.each([{ type: 'isTwoWay' }, { type: 'isOneWay' }])(\n    'one one $type link field cell update',\n    ({ type }) => {\n      let table1: ITableFullVo;\n      let table2: ITableFullVo;\n      beforeEach(async () => {\n        // create tables\n        const textFieldRo: IFieldRo = {\n          name: 'text field',\n          type: FieldType.SingleLineText,\n        };\n\n        const numberFieldRo: IFieldRo = {\n          name: 'Number field',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n          },\n        };\n\n        table1 = await createTable(baseId, {\n          fields: [textFieldRo, numberFieldRo],\n          records: [\n            { fields: { 'text field': 'table1_1' } },\n            { fields: { 'text field': 'table1_2' } },\n            { fields: { 'text field': 'table1_3' } },\n          ],\n        });\n\n        table2 = await createTable(baseId, {\n          name: 'table2',\n          fields: [textFieldRo, numberFieldRo],\n          records: [\n            { fields: { 'text field': 'table2_1' } },\n            { fields: { 'text field': 'table2_2' } },\n            { fields: { 'text field': 'table2_3' } },\n          ],\n        });\n\n        // create link field\n        const table2LinkFieldRo: IFieldRo = {\n          name: 'link field',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneOne,\n            foreignTableId: table1.id,\n            isOneWay: type === 'isOneWay',\n          },\n        };\n\n        await createField(table2.id, table2LinkFieldRo);\n\n        table1.fields = await getFields(table1.id);\n        table2.fields = await getFields(table2.id);\n      });\n\n      afterEach(async () => {\n        await permanentDeleteTable(baseId, table1.id);\n        await permanentDeleteTable(baseId, table2.id);\n      });\n\n      it('should update foreign link field when set a new link in to link field cell', async () => {\n        // table2 link field first record link to table1 first record\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n          id: table1.records[0].id,\n        });\n\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n          title: 'table1_2',\n          id: table1.records[1].id,\n        });\n\n        const table1RecordResult2 = await getRecords(table1.id);\n\n        if (type === 'isOneWay') {\n          expect(table1.fields[2]).toBeUndefined();\n        }\n\n        if (type === 'isTwoWay') {\n          expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toBeUndefined();\n          expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toEqual({\n            title: 'table2_1',\n            id: table2.records[0].id,\n          });\n        }\n      });\n\n      it('should update foreign link field when change lookupField value', async () => {\n        // table2 link field first record link to table1 first record\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n          id: table1.records[0].id,\n        });\n        await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'AX');\n\n        const table2RecordResult2 = await getRecords(table2.id);\n\n        expect(table2RecordResult2.records[0].fields[table2.fields[2].name!]).toEqual({\n          title: 'AX',\n          id: table1.records[0].id,\n        });\n      });\n\n      it('should update self foreign link with correct title', async () => {\n        // table2 link field first record link to table1 first record\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n          id: table1.records[0].id,\n        });\n        // set text for lookup field\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n        const table1RecordResult2 = await getRecords(table1.id);\n\n        if (type === 'isOneWay') {\n          expect(table1.fields[2]).toBeUndefined();\n        }\n\n        if (type === 'isTwoWay') {\n          expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual({\n            title: 'B1',\n            id: table2.records[0].id,\n          });\n        }\n      });\n\n      it('should throw error when add a duplicate record in one one link field', async () => {\n        // set text for lookup field\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n        if (type === 'isOneWay') {\n          // first update\n          await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n            title: 'A1',\n            id: table1.records[0].id,\n          });\n\n          // update a foreign table duplicated link record in other record\n          await updateRecordByApi(\n            table2.id,\n            table2.records[1].id,\n            table2.fields[2].id,\n            { id: table1.records[0].id },\n            400\n          );\n        }\n\n        if (type === 'isTwoWay') {\n          // first update\n          await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, {\n            title: 'B1',\n            id: table2.records[0].id,\n          });\n\n          // update a duplicated link record in other record\n          await updateRecordByApi(\n            table1.id,\n            table1.records[1].id,\n            table1.fields[2].id,\n            { id: table2.records[0].id },\n            400\n          );\n\n          // update a foreign table duplicated link record in other record\n          await updateRecordByApi(\n            table2.id,\n            table2.records[1].id,\n            table2.fields[2].id,\n            { id: table1.records[0].id },\n            400\n          );\n        }\n      });\n\n      it('should throw error when add a duplicate record in one one link field in create record', async () => {\n        if (type === 'isTwoWay') {\n          await createRecords(\n            table1.id,\n            {\n              records: [\n                { fields: { [table1.fields[2].id]: { id: table2.records[0].id } } },\n                { fields: { [table1.fields[2].id]: { id: table2.records[0].id } } },\n              ],\n            },\n            400\n          );\n        }\n\n        await createRecords(\n          table2.id,\n          {\n            records: [\n              { fields: { [table2.fields[2].id]: { id: table1.records[0].id } } },\n              { fields: { [table2.fields[2].id]: { id: table1.records[0].id } } },\n            ],\n          },\n          400\n        );\n      });\n    }\n  );\n\n  describe('many many link field cell update with a multiple-value lookupField', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      const multipleSelectFieldRo: IFieldRo = {\n        name: 'multiple select field',\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [\n            { name: 'A', color: Colors.Blue },\n            { name: 'B', color: Colors.Red },\n            { name: 'C', color: Colors.Green },\n          ],\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      // create link field\n      const table2LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo, multipleSelectFieldRo, table2LinkFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1', 'multiple select field': ['A'] } },\n          { fields: { 'text field': 'table2_2', 'multiple select field': ['B', 'C'] } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      await convertField(table2.id, table2.fields[0].id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table2.fields[2].id}}`,\n        },\n      });\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update foreign link field when set a new link in to link field cell', async () => {\n      expect(table2.fields[0].isMultipleCellValue).toEqual(true);\n      const table1LinkField = table1.fields.find((field) => field.type === FieldType.Link)!;\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table1.id, table1.records[0].id, table1LinkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[1].id, table1LinkField.id, [\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table1.id, table1.records[2].id, table1LinkField.id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      const table1RecordResult = await getRecords(table1.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'A',\n          id: table2.records[0].id,\n        },\n      ]);\n      expect(table1RecordResult.records[1].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'B, C',\n          id: table2.records[1].id,\n        },\n      ]);\n      expect(table1RecordResult.records[2].fields[table1.fields[2].name]).toEqual([\n        {\n          title: 'A',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B, C',\n          id: table2.records[1].id,\n        },\n      ]);\n    });\n  });\n\n  describe('isOneWay many one and one many link field cell update', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      // create link field\n      const table2LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          isOneWay: true,\n        },\n      };\n\n      await createField(table1.id, table1LinkFieldRo);\n      await createField(table2.id, table2LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update foreign link field when set a new link in to link field cell', async () => {\n      // table2 link field first record link to table1 first record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        id: table1.records[0].id,\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        title: 'table1_2',\n        id: table1.records[1].id,\n      });\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toBeUndefined();\n      expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toBeUndefined();\n    });\n\n    it('should update foreign link field when change lookupField value', async () => {\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        title: 'table1_1',\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[2].id, {\n        title: 'table1_1',\n        id: table1.records[0].id,\n      });\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toBeUndefined();\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'AX');\n\n      const table2RecordResult2 = await getRecords(table2.id);\n\n      expect(table2RecordResult2.records[0].fields[table2.fields[2].name!]).toEqual({\n        title: 'AX',\n        id: table1.records[0].id,\n      });\n    });\n\n    it('should update formula field when change manyOne link cell', async () => {\n      const table2FormulaFieldRo: IFieldRo = {\n        name: 'table2Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table2.fields[2].id}}`,\n        },\n      };\n\n      await createField(table2.id, table2FormulaFieldRo);\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[2].id, {\n        title: 'illegal title',\n        id: table1.records[1].id,\n      });\n\n      const table2RecordResult = await getRecords(table2.id);\n\n      expect(table2RecordResult.records[0].fields[table2FormulaFieldRo.name!]).toEqual('table1_2');\n    });\n\n    it('should update formula field when change oneMany link cell', async () => {\n      const table1FormulaFieldRo: IFieldRo = {\n        name: 'table1 formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table1.fields[2].id}}`,\n        },\n      };\n\n      await createField(table1.id, table1FormulaFieldRo);\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[2].name]: [\n              { title: 'illegal test1', id: table2.records[0].id },\n              { title: 'illegal test2', id: table2.records[1].id },\n            ],\n          },\n        },\n      });\n      const table1RecordResult = await getRecords(table1.id);\n\n      expect(table1RecordResult.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'table2_1', id: table2.records[0].id },\n        { title: 'table2_2', id: table2.records[1].id },\n      ]);\n      expect(table1RecordResult.records[0].fields[table1FormulaFieldRo.name!]).toEqual([\n        'table2_1',\n        'table2_2',\n      ]);\n    });\n\n    it('should throw error when add a duplicate record in oneMany link field', async () => {\n      // set text for lookup field\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      // first update\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      // update a duplicated link record in other record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        table1.fields[2].id,\n        [{ title: 'B1', id: table2.records[0].id }],\n        400\n      );\n\n      const table1RecordResult2 = await getRecords(table1.id);\n\n      expect(table1RecordResult2.records[0].fields[table1.fields[2].name]).toEqual([\n        { title: 'B1', id: table2.records[0].id },\n        { title: 'B2', id: table2.records[1].id },\n      ]);\n\n      expect(table1RecordResult2.records[1].fields[table1.fields[2].name]).toBeUndefined();\n    });\n\n    it('should throw error when add a duplicate record in oneMany link field in create record', async () => {\n      await createRecords(\n        table1.id,\n        {\n          records: [\n            {\n              fields: {\n                [table1.fields[2].id]: [{ id: table2.records[0].id }, { id: table2.records[0].id }],\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      await createRecords(\n        table1.id,\n        {\n          records: [\n            { fields: { [table1.fields[2].id]: [{ id: table2.records[0].id }] } },\n            { fields: { [table1.fields[2].id]: [{ id: table2.records[0].id }] } },\n          ],\n        },\n        400\n      );\n    });\n\n    it('should set a text value in a link record with typecast', async () => {\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'A1');\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'B3');\n      // reject data when typecast is false\n      await createRecords(\n        table2.id,\n        {\n          typecast: false,\n          records: [\n            {\n              fields: {\n                [table2.fields[2].id]: ['A1'],\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      const { records: records1 } = await createRecords(table2.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table2.fields[2].id]: 'A1',\n            },\n          },\n        ],\n      });\n\n      expect(records1[0].fields[table2.fields[2].id]).toEqual({\n        id: table1.records[0].id,\n        title: 'A1',\n      });\n\n      const { records: records2 } = await createRecords(table1.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table1.fields[2].id]: 'B1',\n            },\n          },\n        ],\n      });\n\n      expect(records2[0].fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'B1',\n        },\n      ]);\n\n      // typecast title[]\n      const { records: records3 } = await createRecords(table1.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [table1.fields[2].id]: 'B2,B3',\n            },\n          },\n        ],\n      });\n\n      expect(records3[0].fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[1].id,\n          title: 'B2',\n        },\n        {\n          id: table2.records[2].id,\n          title: 'B3',\n        },\n      ]);\n\n      // typecast id[]\n      const record4 = await updateRecord(table1.id, records3[0].id, {\n        typecast: true,\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table1.fields[2].id]: `${table2.records[2].id},${table2.records[1].id}`,\n          },\n        },\n      });\n\n      expect(record4.fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[2].id,\n          title: 'B3',\n        },\n        {\n          id: table2.records[1].id,\n          title: 'B2',\n        },\n      ]);\n    });\n\n    it('should update link cellValue when change primary field value', async () => {\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[2].id, [\n        {\n          id: table2.records[0].id,\n        },\n        {\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1+');\n\n      const record1 = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record1.fields[table1.fields[2].id]).toEqual([\n        {\n          title: 'B1+',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2',\n          id: table2.records[1].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'B2+');\n      const record2 = await getRecord(table1.id, table1.records[0].id);\n      expect(record2.fields[table1.fields[2].id]).toEqual([\n        {\n          title: 'B1+',\n          id: table2.records[0].id,\n        },\n        {\n          title: 'B2+',\n          id: table2.records[1].id,\n        },\n      ]);\n    });\n  });\n\n  describe('multi link with depends same field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update many-one record when add both many-one and many-one link', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const oneManyFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // get get a oneManyField involved\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n      await createField(table1.id, oneManyFieldRo);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'y');\n\n      const { records: table1Records } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(table1Records[0].fields[manyOneField.id]).toEqual({\n        title: 'y',\n        id: table2.records[0].id,\n      });\n    });\n\n    it('should update one-many record when add both many-one and many-one link', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const oneManyFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // get get a oneManyField involved\n      const oneManyField = await createField(table1.id, oneManyFieldRo);\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n\n      const lookupOneManyField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: oneManyField.id,\n        },\n      });\n\n      const rollupOneManyField = await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: oneManyField.id,\n        },\n      });\n\n      const lookupManyOneField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: manyOneField.id,\n        },\n      });\n\n      const rollupManyOneField = await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: manyOneField.id,\n        },\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, oneManyField.id, [\n        {\n          id: table2.records[0].id,\n        },\n      ]);\n      const { records: table1Records1 } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(table1Records1[0].fields[oneManyField.id]).toEqual([\n        {\n          title: 'x',\n          id: table2.records[0].id,\n        },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'y');\n\n      const { records: table1Records2 } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(table1Records2[0].fields[oneManyField.id]).toEqual([\n        {\n          title: 'y',\n          id: table2.records[0].id,\n        },\n      ]);\n      expect(table1Records2[0].fields[lookupOneManyField.id]).toEqual(['y']);\n      expect(table1Records2[0].fields[rollupOneManyField.id]).toEqual(1);\n      expect(table1Records2[0].fields[lookupManyOneField.id]).toEqual(undefined);\n      expect(table1Records2[0].fields[rollupManyOneField.id]).toEqual(0);\n    });\n  });\n\n  describe('single value link value shape', () => {\n    let table1: ITableFullVo | undefined;\n    let table2: ITableFullVo | undefined;\n\n    afterEach(async () => {\n      if (table1) {\n        await permanentDeleteTable(baseId, table1.id);\n        table1 = undefined;\n      }\n      if (table2) {\n        await permanentDeleteTable(baseId, table2.id);\n        table2 = undefined;\n      }\n    });\n\n    it('should return single object when many-one link uses formula lookup', async () => {\n      const expectedTitle = 'New Face - Stage';\n\n      table2 = await createTable(baseId, {\n        name: 'manyone-lookup-src',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Stage', type: FieldType.SingleLineText },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'New Face',\n              Stage: 'Stage',\n            },\n          },\n        ],\n      });\n\n      const nameField = table2.fields.find((f) => f.name === 'Name')!;\n      const stageField = table2.fields.find((f) => f.name === 'Stage')!;\n      const formulaField = await createField(table2.id, {\n        name: 'Display Title',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${nameField.id}} & \" - \" & {${stageField.id}}`,\n        },\n      });\n\n      table1 = await createTable(baseId, {\n        name: 'manyone-host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText }],\n        records: [{ fields: { Label: 'Row 1' } }],\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'Studio',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n          lookupFieldId: formulaField.id,\n        },\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const { records: hostRecords } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(hostRecords[0].fields[linkField.id]).toEqual({\n        id: table2.records[0].id,\n        title: expectedTitle,\n      });\n    });\n\n    it('should return single object when one-one link uses formula lookup', async () => {\n      const expectedTitle = 'New Face - Stage';\n\n      table2 = await createTable(baseId, {\n        name: 'oneone-lookup-src',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Stage', type: FieldType.SingleLineText },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'New Face',\n              Stage: 'Stage',\n            },\n          },\n        ],\n      });\n\n      const nameField = table2.fields.find((f) => f.name === 'Name')!;\n      const stageField = table2.fields.find((f) => f.name === 'Stage')!;\n      const formulaField = await createField(table2.id, {\n        name: 'Display Title',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${nameField.id}} & \" - \" & {${stageField.id}}`,\n        },\n      });\n\n      table1 = await createTable(baseId, {\n        name: 'oneone-host',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText }],\n        records: [{ fields: { Label: 'Row 1' } }],\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'Studio',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: formulaField.id,\n        },\n      });\n\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const { records: hostRecords } = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(hostRecords[0].fields[linkField.id]).toEqual({\n        id: table2.records[0].id,\n        title: expectedTitle,\n      });\n\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n      if (symmetricFieldId) {\n        const { records: foreignRecords } = await getRecords(table2.id, {\n          fieldKeyType: FieldKeyType.Id,\n        });\n\n        expect(foreignRecords[0].fields[symmetricFieldId]).toEqual({\n          id: table1.records[0].id,\n          title: 'Row 1',\n        });\n      }\n    });\n  });\n\n  describe('update link when delete record', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {\n        name: 'table1',\n      });\n      table2 = await createTable(baseId, {\n        name: 'table2',\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should clean single link record when delete a record', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // get get a oneManyField involved\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n      const symManyOneField = await getField(\n        table2.id,\n        (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      await updateRecordByApi(table1.id, table1.records[0].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n\n      await deleteRecord(table1.id, table1.records[0].id);\n\n      const table2Record = await getRecord(table2.id, table2.records[0].id);\n      expect(table2Record.fields[symManyOneField.id]).toBeUndefined();\n    });\n\n    it('should update single link record when delete a record', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'x1');\n      await updateRecordByApi(table1.id, table1.records[1].id, table1.fields[0].id, 'x2');\n\n      // get get a oneManyField involved\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n      const symManyOneField = await getField(\n        table2.id,\n        (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      await updateRecordByApi(table1.id, table1.records[0].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n\n      const table2RecordPre = await getRecord(table2.id, table2.records[0].id);\n      expect(table2RecordPre.fields[symManyOneField.id]).toEqual([\n        {\n          title: 'x1',\n          id: table1.records[0].id,\n        },\n        {\n          title: 'x2',\n          id: table1.records[1].id,\n        },\n      ]);\n\n      await deleteRecord(table1.id, table1.records[0].id);\n\n      const table2Record = await getRecord(table2.id, table2.records[0].id);\n      expect(table2Record.fields[symManyOneField.id]).toEqual([\n        {\n          title: 'x2',\n          id: table1.records[1].id,\n        },\n      ]);\n    });\n\n    it('should update single link record when delete multiple records', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      await updateRecordByApi(table1.id, table1.records[0].id, table1.fields[0].id, 'x1');\n      await updateRecordByApi(table1.id, table1.records[1].id, table1.fields[0].id, 'x2');\n      await updateRecordByApi(table1.id, table1.records[2].id, table1.fields[0].id, 'x3');\n\n      // get get a oneManyField involved\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n      const symManyOneField = await getField(\n        table2.id,\n        (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      await updateRecordByApi(table1.id, table1.records[0].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[1].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table1.id, table1.records[2].id, manyOneField.id, {\n        id: table2.records[0].id,\n      });\n\n      const table2RecordPre = await getRecord(table2.id, table2.records[0].id);\n      expect(table2RecordPre.fields[symManyOneField.id]).toEqual([\n        {\n          title: 'x1',\n          id: table1.records[0].id,\n        },\n        {\n          title: 'x2',\n          id: table1.records[1].id,\n        },\n        {\n          title: 'x3',\n          id: table1.records[2].id,\n        },\n      ]);\n\n      await deleteRecords(table1.id, [table1.records[0].id, table1.records[1].id]);\n\n      const table2Record = await getRecord(table2.id, table2.records[0].id);\n      expect(table2Record.fields[symManyOneField.id]).toEqual([\n        {\n          title: 'x3',\n          id: table1.records[2].id,\n        },\n      ]);\n    });\n\n    it('should clean multi link record when delete a record', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const oneManyFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      // set primary key 'x' in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      // get get a oneManyField involved\n      const manyOneField = await createField(table1.id, manyOneFieldRo);\n      const oneManyField = await createField(table1.id, oneManyFieldRo);\n\n      const symManyOneField = await getField(\n        table2.id,\n        (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n      const symOneManyField = await getField(\n        table2.id,\n        (oneManyField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      await updateRecordByApi(table2.id, table2.records[0].id, symOneManyField.id, {\n        id: table1.records[0].id,\n      });\n      await updateRecordByApi(table2.id, table2.records[0].id, symManyOneField.id, [\n        {\n          id: table1.records[0].id,\n        },\n      ]);\n\n      await deleteRecord(table1.id, table1.records[0].id);\n\n      const table2Record = await getRecord(table2.id, table2.records[0].id);\n      expect(table2Record.fields[symManyOneField.id]).toBeUndefined();\n      expect(table2Record.fields[symOneManyField.id]).toBeUndefined();\n    });\n\n    it.each([\n      { relationship: Relationship.OneOne },\n      { relationship: Relationship.ManyMany },\n      { relationship: Relationship.ManyOne },\n      { relationship: Relationship.OneMany },\n    ])(\n      'should clean one-way $relationship link record when delete a record',\n      async ({ relationship }) => {\n        const linkFieldRo: IFieldRo = {\n          type: FieldType.Link,\n          options: {\n            relationship,\n            foreignTableId: table2.id,\n            isOneWay: true,\n          },\n        };\n\n        // set primary key 'x' in table2\n        await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n        // get get a oneManyField involved\n        const linkField = await createField(table1.id, linkFieldRo);\n\n        if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) {\n          await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n            id: table2.records[0].id,\n          });\n          await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, {\n            id: table2.records[1].id,\n          });\n        } else {\n          await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n            {\n              id: table2.records[0].id,\n            },\n          ]);\n          await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, [\n            {\n              id: table2.records[1].id,\n            },\n          ]);\n        }\n\n        await deleteRecord(table2.id, table2.records[0].id);\n\n        const table1Record = await getRecord(table1.id, table1.records[0].id);\n        expect(table1Record.fields[linkField.id]).toBeUndefined();\n\n        // check if the record is successfully deleted\n        await deleteRecord(table1.id, table1.records[1].id);\n      }\n    );\n\n    it('should clean one-many link record when delete a record', async () => {\n      const table1TitleField = table1.fields[0];\n      const table2TitleField = table2.fields[0];\n\n      const table1RecordId1 = table1.records[0].id;\n      const table1RecordId2 = table1.records[1].id;\n      const table2RecordId1 = table2.records[0].id;\n      const table2RecordId2 = table2.records[1].id;\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: table1RecordId1, fields: { [table1TitleField.id]: 'table1:A1' } },\n          { id: table1RecordId2, fields: { [table1TitleField.id]: 'table1:A2' } },\n        ],\n      });\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: table2RecordId1, fields: { [table2TitleField.id]: 'table2:A1' } },\n          { id: table2RecordId2, fields: { [table2TitleField.id]: 'table2:A2' } },\n        ],\n      });\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n      const table1LinkField = await createField(table1.id, linkFieldRo);\n      const symmetricLinkFieldId = (table1LinkField.options as ILinkFieldOptions).symmetricFieldId!;\n\n      await updateRecordByApi(table1.id, table1RecordId1, table1LinkField.id, [\n        {\n          id: table2RecordId1,\n        },\n        {\n          id: table2RecordId2,\n        },\n      ]);\n\n      const table1Record1Res = await getRecord(table1.id, table1RecordId1);\n      expect(table1Record1Res.fields[table1LinkField.id]).toEqual([\n        { id: table2RecordId1, title: 'table2:A1' },\n        { id: table2RecordId2, title: 'table2:A2' },\n      ]);\n\n      await convertField(table2.id, table2TitleField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${symmetricLinkFieldId}}`,\n        },\n      });\n\n      const table2Record1Res1 = await getRecord(table2.id, table2RecordId1);\n      expect(table2Record1Res1.fields[symmetricLinkFieldId]).toEqual({\n        id: table1RecordId1,\n        title: 'table1:A1',\n      });\n      expect(table2Record1Res1.fields[table2TitleField.id]).toEqual('table1:A1');\n\n      await deleteRecord(table1.id, table1RecordId1);\n      const table2Record1Res2 = await getRecord(table2.id, table2RecordId1);\n      expect(table2Record1Res2.fields[symmetricLinkFieldId]).toBeUndefined();\n    });\n  });\n\n  describe('formula primary referencing link-derived fields', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      const textFieldRo: IFieldRo = {\n        name: 'Title',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Amount',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      };\n\n      // Table2: Title + Amount\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { Title: '21', Amount: 444 } },\n          { fields: { Title: '22', Amount: 555 } },\n          { fields: { Title: '23', Amount: 666 } },\n        ],\n      });\n\n      // Table1: Title\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: [textFieldRo],\n        records: [{ fields: { Title: 'A1' } }],\n      });\n\n      // Link: table1 (OneMany) -> table2\n      const linkField = await createField(table1.id, {\n        name: 't1->t2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      // Lookup: table1.lookup Amount via link (array of numbers)\n      const lookupAmount = await createField(table1.id, {\n        name: 'Amounts (lookup)',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id, // Amount\n          linkFieldId: linkField.id,\n        },\n      });\n      // eslint-disable-next-line no-console\n\n      // Formula: conditional rollup to produce number[]; its formatting should be applied when used as Link title\n      const formula = await createField(table1.id, {\n        name: 'Amounts Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupAmount.id}}`,\n        },\n      });\n      // eslint-disable-next-line no-console\n\n      // Attach two t2 records to t1 record\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: [{ id: table2.records[0].id }, { id: table2.records[1].id }],\n          },\n        },\n      });\n\n      // Point symmetric link (on table2) title to table1 formula\n      const t2Fields = await getFields(table2.id);\n      const t2Link = t2Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!;\n      await convertField(table2.id, t2Link.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: (t2Link.options as ILinkFieldOptions).relationship!,\n          foreignTableId: table1.id,\n          lookupFieldId: formula.id,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('reads table1 with formula referencing lookup (number array)', async () => {\n      const { records } = await getRecords(table1.id, { fieldKeyType: FieldKeyType.Name });\n      const rec = records[0];\n      expect(rec.fields['Amounts (lookup)']).toEqual([444, 555]);\n      expect(rec.fields['Amounts Formula']).toEqual([444, 555]);\n    });\n\n    it('reads table2 link with title formatted as decimals from formula', async () => {\n      const t2Fields = await getFields(table2.id);\n      const t2LinkName = t2Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!.name;\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Name });\n      const rec1 = records.find((r) => r.fields['Title'] === '21')!;\n      const rec2 = records.find((r) => r.fields['Title'] === '22')!;\n      // Both should link back to table1 A1 with title using formatted decimals\n      expect(rec1.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '444.00, 555.00',\n      });\n      expect(rec2.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '444.00, 555.00',\n      });\n    });\n\n    it('formula referencing rollup is formatted and usable as link title', async () => {\n      // Create rollup on table1: sum of Amount via link\n      const t1Fields = await getFields(table1.id);\n      const linkField = t1Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!;\n      const rollup = await createField(table1.id, {\n        name: 'Sum Amounts',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[1].id, // Amount\n          linkFieldId: linkField.id,\n        },\n      });\n\n      // Formula references rollup\n      const formulaRollup = await createField(table1.id, {\n        name: 'Sum Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${rollup.id}}`,\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n\n      // Point table2 symmetric link title to this formula\n      const t2Fields = await getFields(table2.id);\n      const t2Link = t2Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!;\n      await convertField(table2.id, t2Link.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: (t2Link.options as ILinkFieldOptions).relationship!,\n          foreignTableId: table1.id,\n          lookupFieldId: formulaRollup.id,\n        },\n      });\n\n      const t2LinkName = (await getFields(table2.id)).find(\n        (f) => f.type === FieldType.Link && !f.isLookup\n      )!.name;\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Name });\n      // For 21 and 22 both linked to table1.A1, sum is 444+555=999 => '999.00'\n      const rec1 = records.find((r) => r.fields['Title'] === '21')!;\n      const rec2 = records.find((r) => r.fields['Title'] === '22')!;\n      expect(rec1.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '999.00',\n      });\n      expect(rec2.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '999.00',\n      });\n    });\n\n    it('formula referencing text lookup renders comma-joined titles', async () => {\n      // Create text lookup on table1: Title via link\n      const t1Fields = await getFields(table1.id);\n      const linkField = t1Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!;\n      const lookupTitle = await createField(table1.id, {\n        name: 'Titles (lookup)',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id, // Title\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const formulaText = await createField(table1.id, {\n        name: 'Titles Formula',\n        type: FieldType.Formula,\n        options: { expression: `{${lookupTitle.id}}` },\n      });\n\n      // Point table2 symmetric link title to this formula\n      const t2Fields = await getFields(table2.id);\n      const t2Link = t2Fields.find((f) => f.type === FieldType.Link && !f.isLookup)!;\n      await convertField(table2.id, t2Link.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: (t2Link.options as ILinkFieldOptions).relationship!,\n          foreignTableId: table1.id,\n          lookupFieldId: formulaText.id,\n        },\n      });\n\n      const t2LinkName = (await getFields(table2.id)).find(\n        (f) => f.type === FieldType.Link && !f.isLookup\n      )!.name;\n      const { records } = await getRecords(table2.id, { fieldKeyType: FieldKeyType.Name });\n      const rec1 = records.find((r) => r.fields['Title'] === '21')!;\n      const rec2 = records.find((r) => r.fields['Title'] === '22')!;\n      expect(rec1.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '21, 22',\n      });\n      expect(rec2.fields[t2LinkName]).toEqual({\n        id: table1.records[0].id,\n        title: '21, 22',\n      });\n    });\n  });\n\n  it('clears link when primary formula embeds lookup value', async () => {\n    const tableB = await createTable(baseId, {\n      name: 'link-formula-lookup-b',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n        { name: 'Code', type: FieldType.SingleLineText } as IFieldRo,\n      ],\n      records: [{ fields: { Name: 'B1', Code: 'C1' } }],\n    });\n\n    const tableA = await createTable(baseId, {\n      name: 'link-formula-lookup-a',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Title: 'A1' } }],\n    });\n\n    try {\n      const linkField = await createField(tableA.id, {\n        name: 'A->B',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      const lookupField = await createField(tableA.id, {\n        name: 'B Code',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableB.id,\n          lookupFieldId: tableB.fields[1].id,\n          linkFieldId: linkField.id,\n        },\n      } as IFieldRo);\n\n      const primaryField = tableA.fields.find((field) => field.isPrimary)!;\n      await convertField(tableA.id, primaryField.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${lookupField.id}}`,\n        },\n      });\n\n      await updateRecordByApi(tableA.id, tableA.records[0].id, linkField.id, {\n        id: tableB.records[0].id,\n      });\n\n      const linked = await getRecord(tableA.id, tableA.records[0].id);\n      expect((linked.fields[linkField.id] as { id: string } | undefined)?.id).toBe(\n        tableB.records[0].id\n      );\n      expect(linked.fields[lookupField.id]).toBe('C1');\n      expect(linked.fields[primaryField.id]).toBe('C1');\n\n      await updateRecordByApi(tableA.id, tableA.records[0].id, linkField.id, null);\n\n      const cleared = await getRecord(tableA.id, tableA.records[0].id);\n      expect(cleared.fields[linkField.id]).toBeUndefined();\n      expect(cleared.fields[lookupField.id]).toBeUndefined();\n      expect(cleared.fields[primaryField.id]).toBeUndefined();\n    } finally {\n      await permanentDeleteTable(baseId, tableA.id);\n      await permanentDeleteTable(baseId, tableB.id);\n    }\n  });\n\n  describe('Create two bi-link for two tables', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update record in two same manyOne link', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      await createField(table1.id, table1LinkFieldRo);\n      await createField(table1.id, table1LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n\n      const record = await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table1.fields[1].id]: {\n              id: table2.records[0].id,\n            },\n            [table1.fields[2].id]: {\n              id: table2.records[0].id,\n            },\n          },\n        },\n      });\n      expect(record.fields[table1.fields[1].id]).toEqual({\n        id: table2.records[0].id,\n        title: 'table2_1',\n      });\n      expect(record.fields[table1.fields[2].id]).toEqual({\n        id: table2.records[0].id,\n        title: 'table2_1',\n      });\n    });\n\n    it('should update record in two same oneMany link', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      await createField(table1.id, table1LinkFieldRo);\n      await createField(table1.id, table1LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n\n      const record = await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table1.fields[1].id]: [\n              {\n                id: table2.records[0].id,\n              },\n            ],\n            [table1.fields[2].id]: [\n              {\n                id: table2.records[0].id,\n              },\n            ],\n          },\n        },\n      });\n      expect(record.fields[table1.fields[1].id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'table2_1',\n        },\n      ]);\n      expect(record.fields[table1.fields[2].id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'table2_1',\n        },\n      ]);\n    });\n\n    it('should delete a record when have a lookup field with link field', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const table1LinkField = (await createField(table1.id, table1LinkFieldRo)) as LinkFieldCore;\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table1LinkField.options.symmetricFieldId as string,\n          linkFieldId: table1LinkField.id,\n        },\n      };\n\n      await createField(table1.id, lookupFieldRo);\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table1LinkField.id]: { id: table2.records[0].id },\n          },\n        },\n      });\n\n      await deleteRecord(table1.id, table1.records[0].id);\n    });\n\n    it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n      'should delete a record with link field not null constraint',\n      async () => {\n        // create link field\n        const table1LinkFieldRo: IFieldRo = {\n          name: 'link field',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n          },\n        };\n\n        const table1LinkField = (await createField(table1.id, table1LinkFieldRo)) as LinkFieldCore;\n\n        const lookupFieldRo: IFieldRo = {\n          type: FieldType.Link,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            lookupFieldId: table1LinkField.options.symmetricFieldId as string,\n            linkFieldId: table1LinkField.id,\n          },\n        };\n\n        await createField(table1.id, lookupFieldRo);\n\n        await updateRecord(table1.id, table1.records[0].id, {\n          fieldKeyType: FieldKeyType.Id,\n          record: {\n            fields: {\n              [table1LinkField.id]: { id: table2.records[0].id },\n            },\n          },\n        });\n        await deleteRecord(table1.id, table1.records[1].id);\n        await deleteRecord(table1.id, table1.records[2].id);\n\n        await convertField(table1.id, table1LinkField.id, {\n          ...table1LinkFieldRo,\n          notNull: true,\n        });\n\n        await deleteRecord(table1.id, table1.records[0].id);\n      }\n    );\n  });\n\n  describe('update multi cell when contains link field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {\n        name: 'table1',\n      });\n      table2 = await createTable(baseId, {\n        name: 'table2',\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update primary field cell with another cell', async () => {\n      const manyOneFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const textFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      await createField(table1.id, manyOneFieldRo);\n      const textField = await createField(table1.id, textFieldRo);\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        record: {\n          fields: {\n            [table1.fields[0].id]: 'primary',\n            [textField.id]: 'text',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n      });\n    });\n  });\n\n  describe('delete field', () => {\n    describe.each([\n      { relationship: Relationship.OneOne, isOneWay: true },\n      { relationship: Relationship.OneOne, isOneWay: false },\n      { relationship: Relationship.ManyMany, isOneWay: true },\n      { relationship: Relationship.ManyMany, isOneWay: false },\n      { relationship: Relationship.ManyOne, isOneWay: true },\n      { relationship: Relationship.ManyOne, isOneWay: false },\n      { relationship: Relationship.OneMany, isOneWay: true },\n      { relationship: Relationship.OneMany, isOneWay: false },\n    ])('delete $relationship link field with isOneWay: $isOneWay', ({ relationship, isOneWay }) => {\n      let table1: ITableFullVo;\n      let table2: ITableFullVo;\n      beforeEach(async () => {\n        // create tables\n        const textFieldRo: IFieldRo = {\n          name: 'text field',\n          type: FieldType.SingleLineText,\n        };\n\n        const numberFieldRo: IFieldRo = {\n          name: 'Number field',\n          type: FieldType.Number,\n          options: {\n            formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n          },\n        };\n\n        table1 = await createTable(baseId, {\n          fields: [textFieldRo, numberFieldRo],\n          records: [\n            { fields: { 'text field': 'table1_1' } },\n            { fields: { 'text field': 'table1_2' } },\n            { fields: { 'text field': 'table1_3' } },\n          ],\n        });\n\n        table2 = await createTable(baseId, {\n          name: 'table2',\n          fields: [textFieldRo, numberFieldRo],\n          records: [\n            { fields: { 'text field': 'table2_1' } },\n            { fields: { 'text field': 'table2_2' } },\n            { fields: { 'text field': 'table2_3' } },\n          ],\n        });\n\n        // create link field\n        const table2LinkFieldRo: IFieldRo = {\n          name: 'link field',\n          type: FieldType.Link,\n          options: {\n            relationship: relationship,\n            foreignTableId: table1.id,\n            isOneWay: isOneWay,\n          },\n        };\n\n        await createField(table2.id, table2LinkFieldRo);\n\n        table1.fields = await getFields(table1.id);\n        table2.fields = await getFields(table2.id);\n      });\n\n      afterEach(async () => {\n        await permanentDeleteTable(baseId, table1.id);\n        await permanentDeleteTable(baseId, table2.id);\n      });\n\n      it('should safe delete link field', async () => {\n        await deleteField(table2.id, table2.fields[2].id);\n        const table1Fields = await getFields(table1.id);\n        expect(table1Fields.length).toEqual(2);\n      });\n    });\n  });\n\n  describe('change db table name', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [textFieldRo, numberFieldRo],\n        records: [],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [],\n      });\n\n      // create link field\n      const table2LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      await createField(table2.id, table2LinkFieldRo);\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should correct update db table name', async () => {\n      const table1LinkField = table1.fields[2];\n      const table2LinkField = table2.fields[2];\n      expect((table1LinkField.options as ILinkFieldOptions).fkHostTableName).toEqual(\n        table1.dbTableName\n      );\n      expect((table2LinkField.options as ILinkFieldOptions).fkHostTableName).toEqual(\n        table1.dbTableName\n      );\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          linkFieldId: table2LinkField.id,\n        },\n      };\n\n      const lookupField = await createField(table2.id, lookupFieldRo);\n\n      await updateDbTableName(baseId, table1.id, { dbTableName: 'newAwesomeName' });\n      const newTable1 = await getTable(baseId, table1.id);\n      const updatedLink1 = await getField(table1.id, table1LinkField.id);\n      const updatedLink2 = await getField(table2.id, table2LinkField.id);\n      const updatedLookupField = await getField(table2.id, lookupField.id);\n\n      expect(newTable1.dbTableName.split(/[._]/)).toEqual(['bseTestBaseId', 'newAwesomeName']);\n      expect((updatedLink1.options as ILinkFieldOptions).fkHostTableName.split(/[._]/)).toEqual([\n        'bseTestBaseId',\n        'newAwesomeName',\n      ]);\n      expect((updatedLink2.options as ILinkFieldOptions).fkHostTableName.split(/[._]/)).toEqual([\n        'bseTestBaseId',\n        'newAwesomeName',\n      ]);\n      expect(isLinkLookupOptions(updatedLookupField.lookupOptions)).toBe(true);\n      expect(\n        (updatedLookupField.lookupOptions as ILookupLinkOptionsVo).fkHostTableName.split(/[._]/)\n      ).toEqual(['bseTestBaseId', 'newAwesomeName']);\n    });\n  });\n\n  describe('cross base link db table name', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let baseId2: string;\n    beforeEach(async () => {\n      baseId2 = (await createBase({ spaceId, name: 'base2' })).data.id;\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId2, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId2, table2.id);\n      await deleteBase(baseId2);\n    });\n\n    it('should create link cross base', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n      expect((linkField.options as ILinkFieldOptions).baseId).toEqual(baseId2);\n\n      const symLinkField = await getField(\n        table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((symLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId);\n\n      await convertField(table1.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const updatedLinkField = await getField(table1.id, linkField.id);\n      expect((updatedLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId2);\n\n      const symUpdatedLinkField = await getField(\n        table2.id,\n        (updatedLinkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n      expect((symUpdatedLinkField.options as ILinkFieldOptions).baseId).toEqual(baseId);\n    });\n\n    it('should correct update db table name when link field is cross base', async () => {\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          baseId: baseId2,\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      const symLinkField = await getField(\n        table2.id,\n        (linkField.options as ILinkFieldOptions).symmetricFieldId as string\n      );\n\n      expect((linkField.options as ILinkFieldOptions).fkHostTableName).toEqual(table1.dbTableName);\n      expect((symLinkField.options as ILinkFieldOptions).fkHostTableName).toEqual(\n        table1.dbTableName\n      );\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n          linkFieldId: symLinkField.id,\n        },\n      };\n\n      const lookupField = await createField(table2.id, lookupFieldRo);\n\n      await updateDbTableName(baseId, table1.id, { dbTableName: 'newAwesomeName' });\n      const newTable1 = await getTable(baseId, table1.id);\n      const updatedLink1 = await getField(table1.id, linkField.id);\n      const updatedLink2 = await getField(table2.id, symLinkField.id);\n      const updatedLookupField = await getField(table2.id, lookupField.id);\n\n      expect(newTable1.dbTableName.split(/[._]/)).toEqual(['bseTestBaseId', 'newAwesomeName']);\n      expect((updatedLink1.options as ILinkFieldOptions).fkHostTableName.split(/[._]/)).toEqual([\n        'bseTestBaseId',\n        'newAwesomeName',\n      ]);\n      expect((updatedLink2.options as ILinkFieldOptions).fkHostTableName.split(/[._]/)).toEqual([\n        'bseTestBaseId',\n        'newAwesomeName',\n      ]);\n      expect(isLinkLookupOptions(updatedLookupField.lookupOptions)).toBe(true);\n      expect(\n        (updatedLookupField.lookupOptions as ILookupLinkOptionsVo).fkHostTableName.split(/[._]/)\n      ).toEqual(['bseTestBaseId', 'newAwesomeName']);\n    });\n  });\n\n  describe('lookup a link field cross 2 table', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let table3: ITableFullVo;\n    let table2LinkField: IFieldVo;\n    let table3LinkField: IFieldVo;\n\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const formulaFieldRo: IFieldRo = {\n        name: 'formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: '\"x\"',\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        fields: [formulaFieldRo],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo],\n        records: [\n          { fields: { ['text field']: 't2 r1' } },\n          { fields: { ['text field']: 't2 r2' } },\n          { fields: { ['text field']: 't2 r3' } },\n        ],\n      });\n\n      table3 = await createTable(baseId, {\n        name: 'table3',\n        fields: [textFieldRo],\n        records: [\n          { fields: { ['text field']: 't3 r1' } },\n          { fields: { ['text field']: 't3 r2' } },\n          { fields: { ['text field']: 't3 r3' } },\n        ],\n      });\n\n      // create link field\n\n      table2LinkField = await createField(table2.id, {\n        name: '1 - 2 link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      });\n\n      table3LinkField = await createField(table3.id, {\n        name: '2 - 3 link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      await createField(table3.id, {\n        name: 'lookup',\n        isLookup: true,\n        type: FieldType.Link,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2LinkField.id,\n          linkFieldId: table3LinkField.id,\n        },\n      });\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n      table3.fields = await getFields(table3.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n      await permanentDeleteTable(baseId, table3.id);\n    });\n\n    it('should work with cross table lookup', async () => {\n      await updateRecord(table3.id, table3.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table3LinkField.id]: [{ id: table2.records[0].id }, { id: table2.records[1].id }],\n          },\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table2LinkField.id]: [{ id: table1.records[0].id }, { id: table1.records[1].id }],\n          },\n        },\n      });\n\n      const newTable3LookupField = await convertField(table1.id, table1.fields[0].id, {\n        name: 'formula field',\n        type: FieldType.Formula,\n        options: {\n          expression: '\"xx\"',\n        },\n      });\n\n      expect(newTable3LookupField.data).toBeDefined();\n    });\n  });\n\n  describe('link field conversion plan', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let baseId2: string;\n    beforeEach(async () => {\n      baseId2 = (await createBase({ spaceId, name: 'base2' })).data.id;\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId2, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId2, table2.id);\n      await deleteBase(baseId2);\n    });\n\n    it('should plan conversion from bidirectional to unidirectional', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      });\n\n      const fieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n      await planFieldConvert(table1.id, linkField.id, fieldRo);\n    });\n\n    it('should plan conversion from  unidirectional to bidirectional', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      });\n\n      const fieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n      await planFieldConvert(table1.id, linkField.id, fieldRo);\n    });\n  });\n\n  describe('link field show by lookup field', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should work with link field show by field - create field', async () => {\n      const textField = await createField(table2.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H1',\n            [table2.fields[0].id]: 'A1',\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: { id: table2.records[0].id },\n          },\n        },\n      });\n\n      const res = await getRecord(table1.id, table1.records[0].id);\n      expect(res.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'H1' });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H2',\n          },\n        },\n      });\n\n      const res1 = await getRecord(table1.id, table1.records[0].id);\n      expect(res1.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'H2' });\n    });\n\n    it('should work with link field show by field - delete record', async () => {\n      const textField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          relationship: Relationship.OneOne,\n          foreignTableId: table1.id,\n          lookupFieldId: textField.id,\n        },\n      });\n      const table1RecordId1 = table1.records[0].id;\n      const table1RecordId2 = table1.records[1].id;\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table1RecordId1,\n            fields: {\n              [textField.id]: 'table1:A1',\n            },\n          },\n          {\n            id: table1RecordId2,\n            fields: {\n              [textField.id]: 'table1:A2',\n            },\n          },\n        ],\n      });\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table1RecordId1,\n            fields: {\n              [linkField.id]: { id: table1RecordId2 },\n            },\n          },\n          {\n            id: table1RecordId2,\n            fields: {\n              [linkField.id]: { id: table1RecordId1 },\n            },\n          },\n        ],\n      });\n\n      const res = await getRecord(table1.id, table1RecordId1);\n      expect(res.fields[linkField.id]).toEqual({ id: table1RecordId2, title: 'table1:A2' });\n\n      await deleteRecord(table1.id, table1RecordId1);\n    });\n\n    it('should work with link field show by field - convert field', async () => {\n      const textField = await createField(table2.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H1',\n            [table2.fields[0].id]: 'A1',\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: { id: table2.records[0].id },\n          },\n        },\n      });\n\n      const res1 = await getRecord(table1.id, table1.records[0].id);\n      expect(res1.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'A1' });\n\n      const newLinkField = await convertField(table1.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n        },\n      });\n      expect((newLinkField.data?.options as ILinkFieldOptions)?.lookupFieldId).toEqual(\n        textField.id\n      );\n\n      const res2 = await getRecord(table1.id, table1.records[0].id);\n      expect(res2.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'H1' });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H2',\n          },\n        },\n      });\n\n      const res3 = await getRecord(table1.id, table1.records[0].id);\n      expect(res3.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'H2' });\n    });\n\n    it('should work with link field show by field - delete lookuped field and undo', async () => {\n      const textField = await createField(table2.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H1',\n            [table2.fields[0].id]: 'A1',\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: { id: table2.records[0].id },\n          },\n        },\n      });\n\n      const res = await getRecord(table1.id, table1.records[0].id);\n      expect(res.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'H1' });\n\n      // await deleteField(table2.id, textField.id);\n      await awaitWithEvent(() => deleteField(table2.id, textField.id));\n\n      const res1 = await getRecord(table1.id, table1.records[0].id);\n      expect(res1.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'A1' });\n\n      const undoRes = await undo(table2.id);\n      expect(undoRes.data.status).toEqual('fulfilled');\n    });\n\n    it('should work with link field show by field - convert lookuped field', async () => {\n      const textField = await createField(table2.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n          isOneWay: true,\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: '11',\n            [table2.fields[0].id]: 'A1',\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: { id: table2.records[0].id },\n          },\n        },\n      });\n\n      const res = await getRecord(table1.id, table1.records[0].id);\n      expect(res.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: '11' });\n\n      await convertField(table2.id, textField.id, {\n        type: FieldType.Number,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 2,\n          },\n        },\n      });\n\n      const res1 = await getRecord(table1.id, table1.records[0].id);\n      expect(res1.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: '11.00' });\n\n      await convertField(table2.id, textField.id, {\n        type: FieldType.Checkbox,\n      });\n\n      const res2 = await getRecord(table1.id, table1.records[0].id);\n      expect(res2.fields[linkField.id]).toEqual({ id: table2.records[0].id, title: 'A1' });\n    });\n\n    it('should work with link field show by field - change lookuped field when link field is one-many way', async () => {\n      const textField = await createField(table2.id, {\n        type: FieldType.SingleLineText,\n        name: 'text field',\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      await updateRecord(table2.id, table2.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [textField.id]: 'H1',\n            [table2.fields[0].id]: 'A1',\n          },\n        },\n      });\n\n      await updateRecord(table1.id, table1.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: [{ id: table2.records[0].id }],\n          },\n        },\n      });\n\n      const res = await getRecord(table1.id, table1.records[0].id);\n      expect(res.fields[linkField.id]).toEqual([{ id: table2.records[0].id, title: 'A1' }]);\n\n      await convertField(table1.id, linkField.id, {\n        name: 'tabele1 link field',\n        type: FieldType.Link,\n        options: {\n          isOneWay: true,\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n        },\n      });\n\n      const res1 = await getRecord(table1.id, table1.records[0].id);\n      expect(res1.fields[linkField.id]).toEqual([{ id: table2.records[0].id, title: 'H1' }]);\n    });\n  });\n\n  describe('link field update', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should clean more link cellValue with link field many-many to many-one', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const symmetricLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n      const table2TitleField = table2.fields[0];\n      const table2RecordId1 = table2.records[0].id;\n      const table2RecordId2 = table2.records[1].id;\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table2RecordId1,\n            fields: {\n              [table2TitleField.id]: 'table2:A1',\n            },\n          },\n          {\n            id: table2RecordId2,\n            fields: {\n              [table2TitleField.id]: 'table2:A2',\n            },\n          },\n        ],\n      });\n\n      const table1TitleField = table1.fields[0];\n      const table1RecordId1 = table1.records[0].id;\n      const table1RecordId2 = table1.records[1].id;\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table1RecordId1,\n            fields: {\n              [table1TitleField.id]: 'table1:A1',\n            },\n          },\n          {\n            id: table1RecordId2,\n            fields: {\n              [table1TitleField.id]: 'table1:A2',\n            },\n          },\n        ],\n      });\n\n      const table1Record1Res = await updateRecord(table1.id, table1RecordId1, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: [{ id: table2RecordId1 }, { id: table2RecordId2 }],\n          },\n        },\n      });\n\n      expect(table1Record1Res.fields[linkField.id]).toEqual([\n        { id: table2RecordId1, title: 'table2:A1' },\n        { id: table2RecordId2, title: 'table2:A2' },\n      ]);\n\n      const table2Record2Res = await getRecord(table2.id, table2RecordId2);\n      expect(table2Record2Res.fields[symmetricLinkFieldId]).toEqual([\n        { id: table1RecordId1, title: 'table1:A1' },\n      ]);\n\n      await convertField(table1.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const table1Record1ResUpdated = await getRecord(table1.id, table1RecordId1);\n      expect(table1Record1ResUpdated.fields[linkField.id]).toEqual({\n        id: table2RecordId1,\n        title: 'table2:A1',\n      });\n\n      const table2Record2ResUpdated = await getRecord(table2.id, table2RecordId2);\n\n      expect(table2Record2ResUpdated.fields[symmetricLinkFieldId]).toBeUndefined();\n\n      const table1RecordRes2 = await updateRecord(table1.id, table1RecordId2, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: { id: table2RecordId2 },\n          },\n        },\n      });\n\n      expect(table1RecordRes2.fields[linkField.id]).toEqual({\n        id: table2RecordId2,\n        title: 'table2:A2',\n      });\n    });\n\n    it('should clean more link cellValue with link field many-many to one-one', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const symmetricLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n      const table2TitleField = table2.fields[0];\n      const table2RecordId1 = table2.records[0].id;\n      const table2RecordId2 = table2.records[1].id;\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table2RecordId1,\n            fields: {\n              [table2TitleField.id]: 'table2:A1',\n            },\n          },\n          {\n            id: table2RecordId2,\n            fields: {\n              [table2TitleField.id]: 'table2:A2',\n            },\n          },\n        ],\n      });\n\n      const table1TitleField = table1.fields[0];\n      const table1RecordId1 = table1.records[0].id;\n      const table1RecordId2 = table1.records[1].id;\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table1RecordId1,\n            fields: {\n              [table1TitleField.id]: 'table1:A1',\n            },\n          },\n          {\n            id: table1RecordId2,\n            fields: {\n              [table1TitleField.id]: 'table1:A2',\n            },\n          },\n        ],\n      });\n\n      const table1Record1Res = await updateRecord(table1.id, table1RecordId1, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: [{ id: table2RecordId1 }, { id: table2RecordId2 }],\n          },\n        },\n      });\n\n      expect(table1Record1Res.fields[linkField.id]).toEqual([\n        { id: table2RecordId1, title: 'table2:A1' },\n        { id: table2RecordId2, title: 'table2:A2' },\n      ]);\n\n      const table2Record2Res = await getRecord(table2.id, table2RecordId2);\n      expect(table2Record2Res.fields[symmetricLinkFieldId]).toEqual([\n        { id: table1RecordId1, title: 'table1:A1' },\n      ]);\n\n      await convertField(table1.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.OneOne,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const table1Record1ResUpdated = await getRecord(table1.id, table1RecordId1);\n      expect(table1Record1ResUpdated.fields[linkField.id]).toEqual({\n        id: table2RecordId1,\n        title: 'table2:A1',\n      });\n\n      const table2Record2ResUpdated = await getRecord(table2.id, table2RecordId2);\n      expect(table2Record2ResUpdated.fields[symmetricLinkFieldId]).toBeUndefined();\n    });\n\n    it('should update link cellValue with link field Many-One to Many-Many when isOneWay is false', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          isOneWay: false,\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const table1TitleField = table1.fields[0];\n      const table1RecordId1 = table1.records[0].id;\n      const table1RecordId2 = table1.records[1].id;\n\n      const symmetricLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId!;\n      const table2TitleField = table2.fields[0];\n      const table2RecordId1 = table2.records[0].id;\n      const table2RecordId2 = table2.records[1].id;\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table1RecordId1,\n            fields: {\n              [table1TitleField.id]: 'table1:A1',\n            },\n          },\n          {\n            id: table1RecordId2,\n            fields: {\n              [table1TitleField.id]: 'table1:A2',\n            },\n          },\n        ],\n      });\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table2RecordId1,\n            fields: {\n              [table2TitleField.id]: 'table2:A1',\n            },\n          },\n          {\n            id: table2RecordId2,\n            fields: {\n              [table2TitleField.id]: 'table2:A2',\n            },\n          },\n        ],\n      });\n\n      const table1Record1Res = await updateRecord(table1.id, table1RecordId1, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [linkField.id]: [{ id: table2RecordId1 }, { id: table2RecordId2 }],\n          },\n        },\n      });\n\n      expect(table1Record1Res.fields[linkField.id]).toEqual([\n        { id: table2RecordId1, title: 'table2:A1' },\n        { id: table2RecordId2, title: 'table2:A2' },\n      ]);\n\n      const table2Record2Res = await getRecord(table2.id, table2RecordId2);\n      expect(table2Record2Res.fields[symmetricLinkFieldId]).toEqual({\n        id: table1RecordId1,\n        title: 'table1:A1',\n      });\n\n      const symmetricLinkField = await getField(table2.id, symmetricLinkFieldId);\n      await convertField(table2.id, symmetricLinkField.id, {\n        type: FieldType.Link,\n        options: {\n          ...symmetricLinkField.options,\n          relationship: Relationship.ManyMany,\n        } as ILinkFieldOptions,\n      });\n\n      const table1Record1ResUpdated = await getRecord(table1.id, table1RecordId1);\n      expect(table1Record1ResUpdated.fields[linkField.id]).toEqual([\n        { id: table2RecordId1, title: 'table2:A1' },\n        { id: table2RecordId2, title: 'table2:A2' },\n      ]);\n\n      const table2Record2ResUpdated = await getRecord(table2.id, table2RecordId2);\n      expect(table2Record2ResUpdated.fields[symmetricLinkFieldId]).toEqual([\n        { id: table1RecordId1, title: 'table1:A1' },\n      ]);\n    });\n  });\n\n  describe('rollup -> formula -> rollup chain', () => {\n    it('should aggregate correctly through formula referencing a rollup across links', async () => {\n      // Table2: text + number with records\n      const t2Text: IFieldRo = { name: 't2 text', type: FieldType.SingleLineText };\n      const t2Number: IFieldRo = {\n        name: 't2 number',\n        type: FieldType.Number,\n        options: { formatting: { type: NumberFormattingType.Decimal, precision: 0 } },\n      };\n\n      const table2 = await createTable(baseId, {\n        name: 'table2_rfr',\n        fields: [t2Text, t2Number],\n        records: [\n          { fields: { 't2 text': 'r1', 't2 number': 5 } },\n          { fields: { 't2 text': 'r2', 't2 number': 7 } },\n        ],\n      });\n\n      // Table3: text + link(to t2) + rollup(sum t2.number) + formula(rollup*2)\n      const t3Text: IFieldRo = { name: 't3 text', type: FieldType.SingleLineText };\n      const table3 = await createTable(baseId, {\n        name: 'table3_rfr',\n        fields: [t3Text],\n        records: [{ fields: { 't3 text': 'a' } }],\n      });\n\n      const linkT3ToT2 = await createField(table3.id, {\n        name: 't3->t2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: table2.id },\n      });\n\n      const rollupT3 = await createField(table3.id, {\n        name: 't3 rollup',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields.find((f) => f.name === 't2 number')!.id,\n          linkFieldId: linkT3ToT2.id,\n        },\n      });\n\n      const formulaT3 = await createField(table3.id, {\n        name: 't3 formula x2',\n        type: FieldType.Formula,\n        options: { expression: `{${rollupT3.id}} * 2` },\n      });\n\n      // Link table3.r1 -> table2.r1 + table2.r2, so rollup=5+7=12, formula=24\n      await updateRecordByApi(table3.id, table3.records[0].id, linkT3ToT2.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Table4: text + link(to t3) + rollup(sum t3 formula)\n      const t4Text: IFieldRo = { name: 't4 text', type: FieldType.SingleLineText };\n      const table4 = await createTable(baseId, {\n        name: 'table4_rfr',\n        fields: [t4Text],\n        records: [{ fields: { 't4 text': 'x' } }],\n      });\n\n      const linkT4ToT3 = await createField(table4.id, {\n        name: 't4->t3',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: table3.id },\n      });\n\n      const rollupT4 = await createField(table4.id, {\n        name: 't4 rollup of t3 formula',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table3.id,\n          lookupFieldId: formulaT3.id,\n          linkFieldId: linkT4ToT3.id,\n        },\n      });\n\n      // Link table4.r1 -> table3.r1, so t4 rollup should be 24\n      await updateRecordByApi(table4.id, table4.records[0].id, linkT4ToT3.id, [\n        { id: table3.records[0].id },\n      ]);\n\n      const t4Fields = await getFields(table4.id);\n      const t4RollupField = t4Fields.find((f) => f.id === rollupT4.id)!;\n      const t4Res = await getRecords(table4.id);\n      expect(t4Res.records[0].fields[t4RollupField.name]).toEqual(24);\n    });\n\n    it('should sum formulas across multiple t3 records (OneMany)', async () => {\n      // Table2\n      const t2Text: IFieldRo = { name: 't2 text v2', type: FieldType.SingleLineText };\n      const t2Number: IFieldRo = {\n        name: 't2 number v2',\n        type: FieldType.Number,\n        options: { formatting: { type: NumberFormattingType.Decimal, precision: 0 } },\n      };\n      const table2 = await createTable(baseId, {\n        name: 'table2_rfrm_v2',\n        fields: [t2Text, t2Number],\n        records: [\n          { fields: { 't2 text v2': 'r1', 't2 number v2': 5 } },\n          { fields: { 't2 text v2': 'r2', 't2 number v2': 7 } },\n          { fields: { 't2 text v2': 'r3', 't2 number v2': 11 } },\n        ],\n      });\n\n      // Table3\n      const t3Text: IFieldRo = { name: 't3 text v2', type: FieldType.SingleLineText };\n      const table3 = await createTable(baseId, {\n        name: 'table3_rfrm_v2',\n        fields: [t3Text],\n        records: [{ fields: { 't3 text v2': 'a' } }, { fields: { 't3 text v2': 'b' } }],\n      });\n\n      const linkT3ToT2 = await createField(table3.id, {\n        name: 't3->t2 v2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: table2.id },\n      });\n\n      const rollupT3 = await createField(table3.id, {\n        name: 't3 rollup v2',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields.find((f) => f.name === 't2 number v2')!.id,\n          linkFieldId: linkT3ToT2.id,\n        },\n      });\n\n      const formulaT3 = await createField(table3.id, {\n        name: 't3 formula x2 v2',\n        type: FieldType.Formula,\n        options: { expression: `{${rollupT3.id}} * 2` },\n      });\n\n      // r1 -> t2(r1,r2) => 5+7=12 => 24; r2 -> t2(r3) => 11 => 22\n      await updateRecordByApi(table3.id, table3.records[0].id, linkT3ToT2.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n      await updateRecordByApi(table3.id, table3.records[1].id, linkT3ToT2.id, [\n        { id: table2.records[2].id },\n      ]);\n\n      // Table4: rollup of t3 formula across two t3 records => 24 + 22 = 46\n      const t4Text: IFieldRo = { name: 't4 text v2', type: FieldType.SingleLineText };\n      const table4 = await createTable(baseId, {\n        name: 'table4_rfrm_v2',\n        fields: [t4Text],\n        records: [{ fields: { 't4 text v2': 'x' } }],\n      });\n\n      const linkT4ToT3 = await createField(table4.id, {\n        name: 't4->t3 v2',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: table3.id },\n      });\n\n      const rollupT4 = await createField(table4.id, {\n        name: 't4 rollup of t3 formula v2',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table3.id,\n          lookupFieldId: formulaT3.id,\n          linkFieldId: linkT4ToT3.id,\n        },\n      });\n\n      // Also create lookup of t3 formula to test lookup->formula->rollup chain resolution\n      const lookupT4 = await createField(table4.id, {\n        name: 't4 lookup t3 formula v2',\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table3.id,\n          lookupFieldId: formulaT3.id,\n          linkFieldId: linkT4ToT3.id,\n        },\n      });\n\n      await updateRecordByApi(table4.id, table4.records[0].id, linkT4ToT3.id, [\n        { id: table3.records[0].id },\n        { id: table3.records[1].id },\n      ]);\n\n      const t4Fields = await getFields(table4.id);\n      const t4RollupField = t4Fields.find((f) => f.id === rollupT4.id)!;\n      const t4LookupField = t4Fields.find((f) => f.id === lookupT4.id)!;\n      const t4Res = await getRecords(table4.id);\n      expect(t4Res.records[0].fields[t4RollupField.name]).toEqual(46);\n      expect(t4Res.records[0].fields[t4LookupField.name]).toEqual([24, 22]);\n    });\n\n    it('should work when t3->t2 is ManyOne (single-value rollup)', async () => {\n      // Table2\n      const t2Text: IFieldRo = { name: 't2 text v3', type: FieldType.SingleLineText };\n      const t2Number: IFieldRo = {\n        name: 't2 number v3',\n        type: FieldType.Number,\n        options: { formatting: { type: NumberFormattingType.Decimal, precision: 0 } },\n      };\n      const table2 = await createTable(baseId, {\n        name: 'table2_rfrm_v3',\n        fields: [t2Text, t2Number],\n        records: [\n          { fields: { 't2 text v3': 'r1', 't2 number v3': 3 } },\n          { fields: { 't2 text v3': 'r2', 't2 number v3': 9 } },\n        ],\n      });\n\n      // Table3 with ManyOne link to t2\n      const t3Text: IFieldRo = { name: 't3 text v3', type: FieldType.SingleLineText };\n      const table3 = await createTable(baseId, {\n        name: 'table3_rfrm_v3',\n        fields: [t3Text],\n        records: [{ fields: { 't3 text v3': 'a' } }, { fields: { 't3 text v3': 'b' } }],\n      });\n\n      const linkT3ToT2 = await createField(table3.id, {\n        name: 't3->t2 v3',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: table2.id },\n      });\n\n      const rollupT3 = await createField(table3.id, {\n        name: 't3 rollup v3',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields.find((f) => f.name === 't2 number v3')!.id,\n          linkFieldId: linkT3ToT2.id,\n        },\n      });\n\n      const formulaT3 = await createField(table3.id, {\n        name: 't3 formula x2 v3',\n        type: FieldType.Formula,\n        options: { expression: `{${rollupT3.id}} * 2` },\n      });\n\n      // Link: r1 -> t2.r1 (3) => rollup 3 => formula 6; r2 -> t2.r2 (9) => formula 18\n      await updateRecordByApi(table3.id, table3.records[0].id, linkT3ToT2.id, {\n        id: table2.records[0].id,\n      });\n      await updateRecordByApi(table3.id, table3.records[1].id, linkT3ToT2.id, {\n        id: table2.records[1].id,\n      });\n\n      // Table4: OneMany to t3, rollup sum of t3 formula => 6 + 18 = 24\n      const t4Text: IFieldRo = { name: 't4 text v3', type: FieldType.SingleLineText };\n      const table4 = await createTable(baseId, {\n        name: 'table4_rfrm_v3',\n        fields: [t4Text],\n        records: [{ fields: { 't4 text v3': 'x' } }],\n      });\n\n      const linkT4ToT3 = await createField(table4.id, {\n        name: 't4->t3 v3',\n        type: FieldType.Link,\n        options: { relationship: Relationship.OneMany, foreignTableId: table3.id },\n      });\n\n      const rollupT4 = await createField(table4.id, {\n        name: 't4 rollup of t3 formula v3',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: table3.id,\n          lookupFieldId: formulaT3.id,\n          linkFieldId: linkT4ToT3.id,\n        },\n      });\n\n      await updateRecordByApi(table4.id, table4.records[0].id, linkT4ToT3.id, [\n        { id: table3.records[0].id },\n        { id: table3.records[1].id },\n      ]);\n\n      const t4Fields = await getFields(table4.id);\n      const t4RollupField = t4Fields.find((f) => f.id === rollupT4.id)!;\n      const t4Res = await getRecords(table4.id);\n      expect(t4Res.records[0].fields[t4RollupField.name]).toEqual(24);\n    });\n  });\n\n  describe('link filter sync on foreign field update', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {\n        name: 'LinkFilterSync_Host',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      });\n      table2 = await createTable(baseId, {\n        name: 'LinkFilterSync_Foreign',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n      });\n    });\n\n    afterEach(async () => {\n      table1 && (await permanentDeleteTable(baseId, table1.id));\n      table2 && (await permanentDeleteTable(baseId, table2.id));\n    });\n\n    it('should update link filter option values when referenced select option names change', async () => {\n      const statusField = await createField(table2.id, {\n        name: 'Status',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'cho_active', name: 'Active', color: Colors.Green },\n            { id: 'cho_closed', name: 'Closed', color: Colors.Blue },\n          ],\n        },\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'Filtered Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }],\n          },\n        },\n      });\n\n      await convertField(table2.id, statusField.id, {\n        name: 'Status',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'cho_active', name: 'Active Plus', color: Colors.Green },\n            { id: 'cho_closed', name: 'Closed', color: Colors.Blue },\n          ],\n        },\n      });\n\n      const refreshed = await getField(table1.id, linkField.id);\n      const filter = (refreshed.options as ILinkFieldOptions | undefined)?.filter as\n        | { filterSet?: Array<{ value?: unknown }> }\n        | undefined;\n      expect(filter?.filterSet?.[0]?.value).toBe('Active Plus');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-bulk-conversion.e2e-spec.ts",
    "content": "// https://app.teable.ai/base/bserJ2pmgiLHFHfXNwE/tblNHimLUhUDtC3K7Jk/viwE6eAa74PrTlVWGn3?recordId=recwzQGcuy0gk0b58oB\n// https://app.teable.ai/base/bserJ2pmgiLHFHfXNwE/tblNHimLUhUDtC3K7Jk/viwE6eAa74PrTlVWGn3?recordId=recJCD7VhrXShkk3zmw\n/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\n\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship, getRandomString } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { afterAll, beforeAll, describe, expect, test } from 'vitest';\nimport {\n  convertField,\n  createBase,\n  createRecords,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteBase,\n  permanentDeleteTable,\n} from './utils/init-app';\n\nconst AGENCY_CODES = [\n  { code: 'US', name: 'United States National Agency' },\n  { code: 'BR', name: 'Brazil National Agency' },\n  { code: 'TW', name: 'Taiwan Regional Agency' },\n  { code: 'CN', name: 'China National Agency' },\n  { code: 'JP', name: 'Japan National Agency' },\n  { code: 'DE', name: 'Germany Federal Agency' },\n  { code: 'FR', name: 'France National Agency' },\n  { code: 'IN', name: 'India National Agency' },\n  { code: 'AU', name: 'Australia National Agency' },\n  { code: 'ZA', name: 'South Africa National Agency' },\n] as const;\n\nconst TOTAL_RECORDS = 20_000;\nconst PAGE_SIZE = 1_000;\n\nconst spaceId = globalThis.testConfig.spaceId;\n\ndescribe('Bulk text to link conversion (e2e)', () => {\n  let app: INestApplication | undefined;\n  let nationalBaseId: string | undefined;\n  let dataBaseId: string | undefined;\n  let nationalTable: ITableFullVo | undefined;\n  let dataTable: ITableFullVo | undefined;\n\n  beforeAll(async () => {\n    const ctx = await initApp();\n    app = ctx.app;\n  });\n\n  afterAll(async () => {\n    const cleanupErrors: unknown[] = [];\n\n    if (dataTable && dataBaseId) {\n      try {\n        await permanentDeleteTable(dataBaseId, dataTable.id);\n      } catch (error) {\n        cleanupErrors.push({ scope: 'dataTable', error });\n      }\n    }\n\n    if (nationalTable && nationalBaseId) {\n      try {\n        await permanentDeleteTable(nationalBaseId, nationalTable.id);\n      } catch (error) {\n        cleanupErrors.push({ scope: 'nationalTable', error });\n      }\n    }\n\n    if (dataBaseId) {\n      try {\n        await permanentDeleteBase(dataBaseId);\n      } catch (error) {\n        cleanupErrors.push({ scope: 'dataBase', error });\n      }\n    }\n\n    if (nationalBaseId) {\n      try {\n        await permanentDeleteBase(nationalBaseId);\n      } catch (error) {\n        cleanupErrors.push({ scope: 'nationalBase', error });\n      }\n    }\n\n    if (app) {\n      await app.close();\n      app = undefined;\n    }\n\n    if (cleanupErrors.length) {\n      console.warn('link-bulk-conversion cleanup warnings', cleanupErrors);\n    }\n  });\n\n  test(\n    'converts 2k text cells into links referencing national agencies',\n    { timeout: 300_000 },\n    async () => {\n      const nationalBase = await createBase({\n        spaceId,\n        name: `National Agencies-${getRandomString(6)}`,\n      });\n      nationalBaseId = nationalBase.id;\n\n      nationalTable = await createTable(nationalBaseId, {\n        name: 'National Agencies Directory',\n        fields: [\n          { name: 'Agency Code', type: FieldType.SingleLineText },\n          { name: 'Agency Name', type: FieldType.SingleLineText },\n        ],\n        records: AGENCY_CODES.map(({ code, name }) => ({\n          fields: {\n            'Agency Code': code,\n            'Agency Name': name,\n          },\n        })),\n      });\n\n      const codeFieldId = nationalTable.fields[0].id;\n\n      const recordIdToCode = new Map<string, string>();\n      nationalTable.records?.forEach((record) => {\n        const code = record.fields[codeFieldId] as string;\n        recordIdToCode.set(record.id, code);\n      });\n\n      const dataBase = await createBase({\n        spaceId,\n        name: `Bulk Dataset-${getRandomString(6)}`,\n      });\n      dataBaseId = dataBase.id;\n\n      dataTable = await createTable(dataBaseId, {\n        name: 'Trade Records',\n        fields: [\n          { name: 'Record Title', type: FieldType.SingleLineText },\n          { name: 'Agency Code Text', type: FieldType.SingleLineText },\n        ],\n      });\n\n      const primaryFieldId = dataTable.fields[0].id;\n      const textFieldId = dataTable.fields[1].id;\n\n      const codes = AGENCY_CODES.map((agency) => agency.code);\n      const cycleLength = codes.length;\n\n      const getCodeForIndex = (index: number) => {\n        const rotation = Math.floor(index / cycleLength) % cycleLength;\n        const position = index % cycleLength;\n        return codes[(position + rotation) % cycleLength];\n      };\n\n      const payload = Array.from({ length: TOTAL_RECORDS }, (_, index) => {\n        const code = getCodeForIndex(index);\n        return {\n          fields: {\n            [primaryFieldId]: `Record-${index + 1}`,\n            [textFieldId]: code,\n          },\n        };\n      });\n\n      console.time('create-records');\n      const created = await createRecords(dataTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: payload,\n      });\n      console.timeEnd('create-records');\n\n      expect(created.records.length).toBe(TOTAL_RECORDS);\n\n      const expectedCodeByRecord = new Map<string, string>();\n      created.records.forEach((record, index) => {\n        expectedCodeByRecord.set(record.id, getCodeForIndex(index));\n      });\n\n      console.time('convert-to-link');\n      const convertedField = await convertField(dataTable.id, textFieldId, {\n        type: FieldType.Link,\n        options: {\n          baseId: nationalBaseId,\n          relationship: Relationship.ManyOne,\n          foreignTableId: nationalTable.id,\n          lookupFieldId: codeFieldId,\n        },\n      });\n      console.timeEnd('convert-to-link');\n\n      expect(convertedField.type).toBe(FieldType.Link);\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.ManyOne,\n        foreignTableId: nationalTable.id,\n        lookupFieldId: codeFieldId,\n      });\n\n      const { records: nationalRecordsAfter } = await getRecords(nationalTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        take: 200,\n      });\n      recordIdToCode.clear();\n      nationalRecordsAfter.forEach((record) => {\n        const code = record.fields[codeFieldId] as string | undefined;\n        if (code) {\n          recordIdToCode.set(record.id, code);\n        }\n      });\n\n      const verifyLinkedRecords = async (relationship: Relationship) => {\n        console.time(`verify-links-${relationship}`);\n        const matchedRecords = new Map<string, (typeof created.records)[number]>();\n        for (let skip = 0; matchedRecords.size < TOTAL_RECORDS; skip += PAGE_SIZE) {\n          const { records } = await getRecords(dataTable!.id, {\n            fieldKeyType: FieldKeyType.Id,\n            take: PAGE_SIZE,\n            skip,\n          });\n          for (const record of records) {\n            if (expectedCodeByRecord.has(record.id)) {\n              matchedRecords.set(record.id, record);\n            }\n          }\n          if (!records.length) {\n            break;\n          }\n        }\n        console.timeEnd(`verify-links-${relationship}`);\n\n        const occurrencesByCode = new Map<string, number>();\n        AGENCY_CODES.forEach(({ code }) => occurrencesByCode.set(code, 0));\n\n        expect(matchedRecords.size).toBe(TOTAL_RECORDS);\n\n        matchedRecords.forEach((record) => {\n          const expectedCode = expectedCodeByRecord.get(record.id);\n          const linkCellRaw = record.fields[textFieldId] as\n            | { id: string; title?: string }\n            | Array<{ id: string; title?: string }>\n            | null;\n\n          expect(expectedCode).toBeDefined();\n          expect(linkCellRaw, `record ${record.id} should have linked cell value`).toBeTruthy();\n\n          const linkEntries = Array.isArray(linkCellRaw) ? linkCellRaw : [linkCellRaw!];\n          expect(linkEntries.length).toBeGreaterThanOrEqual(1);\n\n          linkEntries.forEach((entry) => {\n            const linkedId = entry.id;\n            expect(recordIdToCode.has(linkedId)).toBe(true);\n            const linkedCode = recordIdToCode.get(linkedId)!;\n\n            expect(linkedCode).toBe(expectedCode);\n            occurrencesByCode.set(linkedCode, (occurrencesByCode.get(linkedCode) ?? 0) + 1);\n          });\n        });\n\n        occurrencesByCode.forEach((count, _code) => {\n          expect(count).toBe(TOTAL_RECORDS / AGENCY_CODES.length);\n        });\n      };\n\n      await verifyLinkedRecords(Relationship.ManyOne);\n\n      console.time('convert-to-manymany');\n      const multiLinkField = await convertField(dataTable.id, textFieldId, {\n        type: FieldType.Link,\n        options: {\n          baseId: nationalBaseId,\n          relationship: Relationship.ManyMany,\n          foreignTableId: nationalTable.id,\n          lookupFieldId: codeFieldId,\n        },\n      });\n      console.timeEnd('convert-to-manymany');\n\n      expect(multiLinkField.type).toBe(FieldType.Link);\n      expect(multiLinkField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n        foreignTableId: nationalTable.id,\n        lookupFieldId: codeFieldId,\n      });\n\n      await verifyLinkedRecords(Relationship.ManyMany);\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-events.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo } from '@teable/core';\nimport {\n  DateFormattingPreset,\n  FieldType,\n  Relationship,\n  TimeFormatting,\n  formatDateToString,\n} from '@teable/core';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events, type RecordUpdateEvent } from '../src/event-emitter/events';\nimport {\n  createField,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\ndescribe('Link events (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const waitForRecordUpdateOnTable = (tableId: string) => {\n    return new Promise<RecordUpdateEvent>((resolve) => {\n      const handler = (event: RecordUpdateEvent) => {\n        if (event.payload.tableId !== tableId) {\n          return;\n        }\n        eventEmitterService.eventEmitter.off(Events.TABLE_RECORD_UPDATE, handler);\n        resolve(event);\n      };\n      eventEmitterService.eventEmitter.on(Events.TABLE_RECORD_UPDATE, handler);\n    });\n  };\n\n  // Skip in v2 mode - this test verifies v1 event payload format\n  // v2 uses different event system (RecordUpdated/RecordsBatchUpdated)\n  const itWhenV1 = isForceV2 ? it.skip : it;\n\n  itWhenV1('emits formatted link titles in record update events', async () => {\n    const releaseFormatting = {\n      date: DateFormattingPreset.Asian,\n      time: TimeFormatting.Hour24,\n      timeZone: 'Asia/Shanghai',\n    };\n    const releaseValue = '2024-01-01T00:00:00.000Z';\n    const expectedTitle = formatDateToString(releaseValue, releaseFormatting);\n\n    let hostTable: Awaited<ReturnType<typeof createTable>> | undefined;\n    let foreignTable: Awaited<ReturnType<typeof createTable>> | undefined;\n    try {\n      foreignTable = await createTable(baseId, {\n        name: 'LinkEvents_Foreign',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText } as IFieldRo,\n          {\n            name: 'Release',\n            type: FieldType.Date,\n            options: {\n              formatting: releaseFormatting,\n            },\n          } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Foreign row',\n              Release: releaseValue,\n            },\n          },\n        ],\n      });\n\n      const releaseField = foreignTable.fields.find((field) => field.name === 'Release');\n      if (!releaseField) {\n        throw new Error('Release field not found');\n      }\n\n      hostTable = await createTable(baseId, {\n        name: 'LinkEvents_Host',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Title: 'Host row' } }],\n      });\n\n      const linkField = await createField(hostTable.id, {\n        name: 'Formatted Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n          lookupFieldId: releaseField.id,\n        },\n      } as IFieldRo);\n\n      const waitForHostUpdate = waitForRecordUpdateOnTable(hostTable.id);\n\n      await updateRecordByApi(hostTable.id, hostTable.records[0].id, linkField.id, {\n        id: foreignTable.records[0].id,\n      });\n\n      const hostEvent = await waitForHostUpdate;\n      const changeRecord = Array.isArray(hostEvent.payload.record)\n        ? hostEvent.payload.record[0]\n        : hostEvent.payload.record;\n      const linkChange = changeRecord.fields[linkField.id];\n      expect(linkChange).toBeDefined();\n\n      const newValue = Array.isArray(linkChange.newValue)\n        ? linkChange.newValue\n        : [linkChange.newValue];\n      expect(newValue[0]).toBeDefined();\n      expect(newValue[0]?.id).toBe(foreignTable.records[0].id);\n      expect(newValue[0]?.title).toBe(expectedTitle);\n    } finally {\n      if (hostTable) {\n        await permanentDeleteTable(baseId, hostTable.id);\n      }\n      if (foreignTable) {\n        await permanentDeleteTable(baseId, foreignTable.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-field-null-handling.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Link Field Null Handling (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Link field with OneMany relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 with text field\n      const textFieldRo: IFieldRo = {\n        name: 'Title',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Table1',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Title: 'Record 1' } },\n          { fields: { Title: 'Record 2' } },\n          { fields: { Title: 'Record 3' } },\n        ],\n      });\n\n      // Create table2 with text field\n      table2 = await createTable(baseId, {\n        name: 'Table2',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Title: 'A' } },\n          { fields: { Title: 'B' } },\n          { fields: { Title: 'C' } },\n        ],\n      });\n\n      // Create link field from table1 to table2 (OneMany relationship)\n      const linkFieldRo: IFieldRo = {\n        name: 'Link Field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField = await createField(table1.id, linkFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should return empty array for records with no links instead of null objects', async () => {\n      // Get records without any links established\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      expect(records.records).toHaveLength(3);\n\n      // All records should have empty arrays for the link field, not [{\"id\": null, \"title\": null}]\n      for (const record of records.records) {\n        const linkValue = record.fields[linkField.name];\n        expect(linkValue).toBeUndefined();\n        expect(linkValue).not.toEqual([{ id: null, title: null }]);\n      }\n    });\n  });\n\n  describe('Link field with ManyOne relationship', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let linkField: IFieldVo;\n\n    beforeEach(async () => {\n      // Create table1 with text field\n      const textFieldRo: IFieldRo = {\n        name: 'Title',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Table1',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Title: 'Record 1' } },\n          { fields: { Title: 'Record 2' } },\n          { fields: { Title: 'Record 3' } },\n        ],\n      });\n\n      // Create table2 with text field\n      table2 = await createTable(baseId, {\n        name: 'Table2',\n        fields: [textFieldRo],\n        records: [\n          { fields: { Title: 'A' } },\n          { fields: { Title: 'B' } },\n          { fields: { Title: 'C' } },\n        ],\n      });\n\n      // Create link field from table1 to table2 (ManyOne relationship)\n      const linkFieldRo: IFieldRo = {\n        name: 'Link Field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField = await createField(table1.id, linkFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should return null for records with no links instead of null objects', async () => {\n      // Get records without any links established\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      expect(records.records).toHaveLength(3);\n\n      // All records should have null or undefined for the link field, not [{\"id\": null, \"title\": null}]\n      for (const record of records.records) {\n        const linkValue = record.fields[linkField.name];\n        expect(linkValue == null).toBe(true); // null or undefined\n        expect(linkValue).not.toEqual([{ id: null, title: null }]);\n      }\n    });\n\n    it('should return proper single link object when link is established', async () => {\n      // Link first record to first target record (ManyOne only allows single link)\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // Get records after establishing link\n      const records = await getRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      expect(records.records).toHaveLength(3);\n\n      // First record should have single link object (not array)\n      const firstRecord = records.records.find((r) => r.fields.Title === 'Record 1');\n      expect(firstRecord?.fields[linkField.name]).toEqual({\n        id: table2.records[0].id,\n        title: 'A',\n      });\n\n      // Other records should have null (not empty array)\n      const secondRecord = records.records.find((r) => r.fields.Title === 'Record 2');\n      const thirdRecord = records.records.find((r) => r.fields.Title === 'Record 3');\n\n      expect(secondRecord?.fields[linkField.name] == null).toBe(true); // null or undefined\n      expect(thirdRecord?.fields[linkField.name] == null).toBe(true); // null or undefined\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-formula-if-boolean-context.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions, IFieldVo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship, getRandomString } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Formula IF link boolean context (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('keeps link titles when IF branches reference link fields', async () => {\n    const suffix = getRandomString(8);\n    let tableA: ITableFullVo | undefined;\n    let tableB: ITableFullVo | undefined;\n\n    try {\n      tableA = await createTable(baseId, {\n        name: `LinkIf_A_${suffix}`,\n        fields: [{ name: 'A Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'A Name': 'Alpha' } }],\n      });\n\n      tableB = await createTable(baseId, {\n        name: `LinkIf_B_${suffix}`,\n        fields: [\n          { name: 'B Primary', type: FieldType.SingleLineText },\n          { name: 'Active', type: FieldType.Checkbox },\n          { name: 'Empty Text', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { 'B Primary': 'Row-1', Active: true, 'Empty Text': 'ignore' } },\n          { fields: { 'B Primary': 'Row-2', Active: false, 'Empty Text': '' } },\n        ],\n      });\n\n      const primaryFieldB = tableB.fields[0];\n      const activeField = tableB.fields.find((field) => field.name === 'Active') as IFieldVo;\n      const emptyTextField = tableB.fields.find((field) => field.name === 'Empty Text') as IFieldVo;\n\n      const linkAtoB = await createField(tableA.id, {\n        name: 'Link to B',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      const symmetricLinkId = (linkAtoB.options as ILinkFieldOptions).symmetricFieldId as string;\n      if (!symmetricLinkId) {\n        throw new Error('Symmetric link field not created');\n      }\n\n      await convertField(tableB.id, primaryFieldB.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${activeField.id}}, {${symmetricLinkId}}, {${emptyTextField.id}})`,\n        },\n      });\n\n      // Include title so formula branch can resolve a display value without relying on CTE ordering.\n      await updateRecordByApi(tableB.id, tableB.records[0].id, symmetricLinkId, {\n        id: tableA.records[0].id,\n        title: 'Alpha',\n      });\n\n      const tableARecords = await getRecords(tableA.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [linkAtoB.id],\n      });\n\n      const aRecord = tableARecords.records.find((r) => r.id === tableA!.records[0].id);\n      expect(aRecord).toBeDefined();\n\n      const aLinkValues = aRecord?.fields[linkAtoB.id] as Array<{ id: string; title?: string }>;\n      expect(Array.isArray(aLinkValues)).toBe(true);\n      expect(aLinkValues).toHaveLength(1);\n      expect(aLinkValues[0].id).toBe(tableB.records[0].id);\n      expect(aLinkValues[0].title).toBe('Alpha');\n      expect(aLinkValues[0].title).not.toBe('true');\n\n      const tableBRecords = await getRecords(tableB.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [primaryFieldB.id],\n      });\n\n      expect(tableBRecords.records).toHaveLength(2);\n      const row1 = tableBRecords.records.find((record) => record.id === tableB!.records[0].id);\n      expect(row1?.fields[primaryFieldB.id]).toBe('Alpha');\n    } finally {\n      if (tableA) {\n        await permanentDeleteTable(baseId, tableA.id);\n      }\n      if (tableB) {\n        await permanentDeleteTable(baseId, tableB.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-formula-recursion.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, INumberFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getFields,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\n/**\n * Regression test: verifies FieldCteVisitor no longer overflows the stack when link/lookup/formula\n * dependencies form a cycle (calculation formula references lookups, the linked table looks the formula back up).\n */\ndescribe('Link/Formula circular dependency regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('handles circular link/lookups without overflowing the stack', async () => {\n    let calculationTable: ITableFullVo | undefined;\n    let salesTable: ITableFullVo | undefined;\n\n    try {\n      salesTable = await createTable(baseId, {\n        name: 'Sales',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Count',\n            type: FieldType.Number,\n            options: {\n              formatting: {\n                type: 'decimal',\n                precision: 0,\n              },\n            } as INumberFieldOptions,\n          },\n          {\n            name: 'Status',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Order A',\n              Count: 3,\n              Status: 'light',\n            },\n          },\n        ],\n      });\n\n      calculationTable = await createTable(baseId, {\n        name: 'Calculation',\n        fields: [\n          {\n            name: 'Project',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Project: 'X-001',\n            },\n          },\n        ],\n      });\n\n      const calculationToSalesLink = await createField(calculationTable.id, {\n        name: 'Sales Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: salesTable.id,\n        },\n      });\n\n      const salesFieldsAfterLink = await getFields(salesTable.id);\n      const salesToCalculationLink = salesFieldsAfterLink.find(\n        (field) =>\n          field.type === FieldType.Link &&\n          (field.options as { foreignTableId?: string })?.foreignTableId === calculationTable!.id\n      ) as IFieldVo | undefined;\n\n      expect(salesToCalculationLink).toBeDefined();\n\n      const salesNameFieldId = salesTable.fields.find((f) => f.name === 'Name')!.id;\n      const salesCountFieldId = salesTable.fields.find((f) => f.name === 'Count')!.id;\n\n      // Create lookups on the calculation table that pull data from Sales.\n      const countLookup = await createField(calculationTable.id, {\n        name: 'Sales Count',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: salesTable.id,\n          linkFieldId: calculationToSalesLink.id,\n          lookupFieldId: salesCountFieldId,\n        },\n      } as unknown as IFieldRo);\n\n      const nameLookup = await createField(calculationTable.id, {\n        name: 'Sales Name',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: salesTable.id,\n          linkFieldId: calculationToSalesLink.id,\n          lookupFieldId: salesNameFieldId,\n        },\n      } as unknown as IFieldRo);\n\n      const formulaField = await createField(calculationTable.id, {\n        name: 'Calculation Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: `2+2 & {${countLookup.id}}&{${nameLookup.id}} & 4 & 'xxxxxxx'`,\n        },\n      } as unknown as IFieldRo);\n\n      // Sales table looks up the calculation formula, closing the dependency cycle.\n      const calculationLookupOnSales = await createField(salesTable.id, {\n        name: 'Calculation Lookup',\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: calculationTable.id,\n          linkFieldId: salesToCalculationLink!.id,\n          lookupFieldId: formulaField.id,\n        },\n      } as unknown as IFieldRo);\n\n      // Link the calculation record to the sales record.\n      await updateRecordByApi(\n        calculationTable.id,\n        calculationTable.records[0].id,\n        calculationToSalesLink.id,\n        { id: salesTable.records[0].id }\n      );\n\n      // First query should succeed and the formula output should include expected content.\n      const calculationRecords = await getRecords(calculationTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(calculationRecords.records).toHaveLength(1);\n      const calcValue = calculationRecords.records[0].fields[formulaField.id];\n      expect(typeof calcValue).toBe('string');\n      expect(calcValue as string).toContain('xxxxxxx');\n      expect(calcValue as string).toContain('Order A');\n      expect(calcValue as string).toContain('3');\n\n      // Updating the sales count forces the entire chain to recompute.\n      await updateRecordByApi(salesTable.id, salesTable.records[0].id, salesCountFieldId, 7);\n\n      const calcRecordsAfterUpdate = await getRecords(calculationTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const updatedValue = calcRecordsAfterUpdate.records[0].fields[formulaField.id];\n      expect(typeof updatedValue).toBe('string');\n      expect(updatedValue as string).toContain('7');\n\n      // Ensure the lookup on the sales table resolves correctly as well.\n      const salesRecords = await getRecords(salesTable.id, { fieldKeyType: FieldKeyType.Id });\n      expect(salesRecords.records).toHaveLength(1);\n      const lookupValue = salesRecords.records[0].fields[calculationLookupOnSales.id];\n      expect(lookupValue).toBeTruthy();\n    } finally {\n      if (calculationTable) {\n        await permanentDeleteTable(baseId, calculationTable.id);\n      }\n      if (salesTable) {\n        await permanentDeleteTable(baseId, salesTable.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-multi-config-toggle-collaboration.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo, IRecord } from '@teable/openapi';\nimport type { Doc, Connection } from 'sharedb/lib/client';\nimport { ShareDbService } from '../src/share-db/share-db.service';\nimport {\n  convertField,\n  createField,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst createConnection = (\n  shareDbService: ShareDbService,\n  cookie: string,\n  port: string\n): Connection => {\n  return shareDbService.connect(undefined, {\n    url: `ws://localhost:${port}/socket`,\n    headers: { cookie },\n  });\n};\n\nconst fetchRecordSnapshot = async (\n  connection: Connection,\n  tableId: string,\n  recordId: string\n): Promise<IRecord> => {\n  const doc = connection.get(`rec_${tableId}`, recordId) as Doc<IRecord>;\n  return await new Promise<IRecord>((resolve, reject) => {\n    const timeout = setTimeout(() => {\n      doc.destroy();\n      reject(new Error('ShareDB record subscribe timed out'));\n    }, 5000);\n\n    doc.subscribe((error) => {\n      clearTimeout(timeout);\n      if (error) {\n        doc.destroy();\n        reject(error);\n        return;\n      }\n      if (!doc.data) {\n        doc.destroy();\n        reject(new Error('ShareDB record doc has no data'));\n        return;\n      }\n      const snapshot = doc.data;\n      doc.destroy();\n      resolve(snapshot);\n    });\n  });\n};\n\ndescribe('Link field multi-config toggle ShareDB regression (e2e)', () => {\n  let app: INestApplication;\n  let cookie: string;\n  let port: string;\n  let shareDbService: ShareDbService;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    cookie = appCtx.cookie;\n    port = process.env.PORT!;\n    shareDbService = app.get(ShareDbService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('keeps fresh ShareDB record snapshots populated after converting manyOne twoWay to manyMany oneWay', async () => {\n    let sourceTable: ITableFullVo | undefined;\n    let foreignTable: ITableFullVo | undefined;\n    let connection: Connection | undefined;\n\n    try {\n      sourceTable = await createTable(baseId, {\n        name: 'ShareDB Survey Responses',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo],\n        records: [{ fields: { Name: 'Response A' } }, { fields: { Name: 'Response B' } }],\n      });\n\n      foreignTable = await createTable(baseId, {\n        name: 'ShareDB Campuses',\n        fields: [\n          { name: 'Branch', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo,\n          { name: 'District', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Center', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Room', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Branch: 'Branch A',\n              District: 'District A',\n              Center: 'Center A',\n              Room: 'Room A',\n            },\n          },\n        ],\n      });\n\n      const branchField = foreignTable.fields.find((field) => field.name === 'Branch');\n      const districtField = foreignTable.fields.find((field) => field.name === 'District');\n      const centerField = foreignTable.fields.find((field) => field.name === 'Center');\n      const roomField = foreignTable.fields.find((field) => field.name === 'Room');\n      expect(branchField && districtField && centerField && roomField).toBeDefined();\n\n      const formulaField = await createField(foreignTable.id, {\n        name: 'Campus Info',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${branchField!.id}}&\"/\"&{${districtField!.id}}&\"/\"&{${centerField!.id}}&\"/\"&{${roomField!.id}}`,\n        },\n      } as IFieldRo);\n\n      const linkField = await createField(sourceTable.id, {\n        name: 'Campus Info',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n          lookupFieldId: formulaField.id,\n          isOneWay: false,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, {\n        id: foreignTable.records[0].id,\n      });\n      await updateRecordByApi(sourceTable.id, sourceTable.records[1].id, linkField.id, {\n        id: foreignTable.records[0].id,\n      });\n\n      connection = createConnection(shareDbService, cookie, port);\n      const initialSnapshot = await fetchRecordSnapshot(\n        connection,\n        sourceTable.id,\n        sourceTable.records[0].id\n      );\n      expect(initialSnapshot.fields[linkField.id]).toEqual(\n        expect.objectContaining({\n          id: foreignTable.records[0].id,\n          title: 'Branch A/District A/Center A/Room A',\n        })\n      );\n\n      await convertField(sourceTable.id, linkField.id, {\n        name: linkField.name,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n          lookupFieldId: formulaField.id,\n          isOneWay: true,\n        },\n      });\n\n      const afterConvertSnapshot = await fetchRecordSnapshot(\n        connection,\n        sourceTable.id,\n        sourceTable.records[0].id\n      );\n      expect(afterConvertSnapshot.fields[linkField.id]).toEqual([\n        expect.objectContaining({\n          id: foreignTable.records[0].id,\n          title: 'Branch A/District A/Center A/Room A',\n        }),\n      ]);\n    } finally {\n      connection?.close();\n      if (sourceTable) {\n        await permanentDeleteTable(baseId, sourceTable.id);\n      }\n      if (foreignTable) {\n        await permanentDeleteTable(baseId, foreignTable.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-multi-config-toggle.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createTable,\n  getField,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Link field multi-config toggle regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('preserves source links when converting manyOne twoWay to manyMany oneWay with formula lookup titles', async () => {\n    let sourceTable: ITableFullVo | undefined;\n    let foreignTable: ITableFullVo | undefined;\n\n    try {\n      sourceTable = await createTable(baseId, {\n        name: 'Survey Responses',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo],\n        records: [{ fields: { Name: 'Response A' } }, { fields: { Name: 'Response B' } }],\n      });\n\n      foreignTable = await createTable(baseId, {\n        name: 'Campuses',\n        fields: [\n          { name: 'Branch', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo,\n          { name: 'District', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Center', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Room', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          {\n            fields: {\n              Branch: 'Branch A',\n              District: 'District A',\n              Center: 'Center A',\n              Room: 'Room A',\n            },\n          },\n          {\n            fields: {\n              Branch: 'Branch B',\n              District: 'District B',\n              Center: 'Center B',\n              Room: 'Room B',\n            },\n          },\n        ],\n      });\n\n      const branchField = foreignTable.fields.find((field) => field.name === 'Branch');\n      const districtField = foreignTable.fields.find((field) => field.name === 'District');\n      const centerField = foreignTable.fields.find((field) => field.name === 'Center');\n      const roomField = foreignTable.fields.find((field) => field.name === 'Room');\n      expect(branchField && districtField && centerField && roomField).toBeDefined();\n\n      const formulaField = await createField(foreignTable.id, {\n        name: 'Campus Info',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${branchField!.id}}&\"/\"&{${districtField!.id}}&\"/\"&{${centerField!.id}}&\"/\"&{${roomField!.id}}`,\n        },\n      } as IFieldRo);\n\n      const linkField = await createField(sourceTable.id, {\n        name: 'Campus Info',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n          lookupFieldId: formulaField.id,\n          isOneWay: false,\n        },\n      } as IFieldRo);\n\n      const symmetricFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId;\n      expect(symmetricFieldId).toBeDefined();\n\n      await updateRecordByApi(sourceTable.id, sourceTable.records[0].id, linkField.id, {\n        id: foreignTable.records[0].id,\n      });\n      await updateRecordByApi(sourceTable.id, sourceTable.records[1].id, linkField.id, {\n        id: foreignTable.records[0].id,\n      });\n\n      const convertedField = await convertField(sourceTable.id, linkField.id, {\n        name: linkField.name,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n          lookupFieldId: formulaField.id,\n          isOneWay: true,\n        },\n      });\n\n      expect(convertedField.options).toMatchObject({\n        relationship: Relationship.ManyMany,\n        foreignTableId: foreignTable.id,\n        isOneWay: true,\n      });\n      expect((convertedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined();\n\n      const sourceRecords = await getRecords(sourceTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const firstRecord = sourceRecords.records.find(\n        (record) => record.id === sourceTable.records[0].id\n      );\n      const secondRecord = sourceRecords.records.find(\n        (record) => record.id === sourceTable.records[1].id\n      );\n\n      expect(firstRecord?.fields[linkField.id]).toEqual([\n        expect.objectContaining({\n          id: foreignTable.records[0].id,\n          title: 'Branch A/District A/Center A/Room A',\n        }),\n      ]);\n      expect(secondRecord?.fields[linkField.id]).toEqual([\n        expect.objectContaining({\n          id: foreignTable.records[0].id,\n          title: 'Branch A/District A/Center A/Room A',\n        }),\n      ]);\n\n      await expect(getField(foreignTable.id, symmetricFieldId!)).rejects.toThrow();\n    } finally {\n      if (sourceTable) {\n        await permanentDeleteTable(baseId, sourceTable.id);\n      }\n      if (foreignTable) {\n        await permanentDeleteTable(baseId, foreignTable.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/link-view-user-filter.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, IFilterRo } from '@teable/core';\nimport { FieldKeyType, FieldType, hasAnyOf, is, Me, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n  updateViewFilter,\n} from './utils/init-app';\n\ndescribe('Link field filtered by view with Me (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const userId = globalThis.testConfig.userId;\n  const userName = globalThis.testConfig.userName;\n  const userEmail = globalThis.testConfig.email;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('link with view filter referencing Me', () => {\n    let primaryTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n    let linkField: IFieldVo;\n\n    beforeEach(async () => {\n      const primaryFields: IFieldRo[] = [\n        {\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        },\n      ];\n\n      primaryTable = await createTable(baseId, {\n        name: 'link_me_primary',\n        fields: primaryFields,\n        records: [\n          {\n            fields: {\n              Name: 'Row 1',\n            },\n          },\n        ],\n      });\n\n      const foreignFields: IFieldRo[] = [\n        {\n          name: 'Title',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Assignee',\n          type: FieldType.User,\n        },\n      ];\n\n      foreignTable = await createTable(\n        baseId,\n        {\n          name: 'link_me_foreign',\n          fields: foreignFields,\n          records: [\n            {\n              fields: {\n                Title: 'Owned by me',\n                Assignee: {\n                  id: userId,\n                  title: userName,\n                  email: userEmail,\n                },\n              },\n            },\n            {\n              fields: {\n                Title: 'Unassigned record',\n              },\n            },\n          ],\n        },\n        201\n      );\n\n      const filterByMe: IFilterRo = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: foreignTable.fields[1].id,\n              operator: is.value,\n              value: Me,\n            },\n          ],\n        },\n      };\n\n      await updateViewFilter(foreignTable.id, foreignTable.defaultViewId!, filterByMe);\n\n      linkField = await createField(primaryTable.id, {\n        name: 'Filtered Tasks',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n          filterByViewId: foreignTable.defaultViewId,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, primaryTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('should link records respecting view filter with Me without SQL errors', async () => {\n      await expect(\n        updateRecordByApi(primaryTable.id, primaryTable.records[0].id, linkField.id, [\n          { id: foreignTable.records[0].id },\n        ])\n      ).resolves.toBeDefined();\n\n      const listResponse = await getRecords(primaryTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const currentRecord = listResponse.records.find(\n        (record) => record.id === primaryTable.records[0].id\n      );\n      const linked = currentRecord?.fields[linkField.id] as Array<{ id: string }> | undefined;\n      expect(linked).toBeDefined();\n      expect(linked).toHaveLength(1);\n      expect(linked?.[0].id).toBe(foreignTable.records[0].id);\n    });\n  });\n\n  describe('link field filter with multi-user equals Me', () => {\n    let primaryTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n    let linkField: IFieldVo;\n    let assigneesFieldId: string;\n    let filterByMe: IFilterRo;\n\n    beforeEach(async () => {\n      primaryTable = await createTable(baseId, {\n        name: 'link_me_multi_primary',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          {\n            fields: { Name: 'Row 1' },\n          },\n        ],\n      });\n\n      foreignTable = await createTable(baseId, {\n        name: 'link_me_multi_foreign',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Assignees',\n            type: FieldType.User,\n            options: { isMultiple: true },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Owned by me',\n              Assignees: [\n                {\n                  id: userId,\n                  title: userName,\n                  email: userEmail,\n                },\n              ],\n            },\n          },\n          {\n            fields: {\n              Title: 'Owned by others',\n              Assignees: null,\n            },\n          },\n        ],\n      });\n\n      assigneesFieldId =\n        foreignTable.fields.find((f) => f.name === 'Assignees')?.id ??\n        (() => {\n          throw new Error('Assignees field not found');\n        })();\n\n      filterByMe = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: assigneesFieldId,\n              operator: hasAnyOf.value,\n              value: [Me],\n            },\n          ],\n        },\n      };\n\n      linkField = await createField(primaryTable.id, {\n        name: 'Filtered Candidates',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n          filter: filterByMe.filter,\n        },\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, primaryTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('should return only records assigned to current user', async () => {\n      const { records } = await getRecords(foreignTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        filter: filterByMe.filter,\n        filterLinkCellCandidate: linkField.id,\n      });\n\n      expect(records).toHaveLength(1);\n      expect(records[0].id).toBe(foreignTable.records[0].id);\n    });\n  });\n\n  describe('user field filter equals Me (single user)', () => {\n    let table: ITableFullVo;\n    const userId = globalThis.testConfig.userId;\n    const userName = globalThis.testConfig.userName;\n    const userEmail = globalThis.testConfig.email;\n    let assigneeFieldId: string;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'user_me_filter_single',\n        fields: [\n          {\n            name: 'Title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Assignee',\n            type: FieldType.User,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Mine',\n              Assignee: {\n                id: userId,\n                title: userName,\n                email: userEmail,\n              },\n            },\n          },\n          {\n            fields: {\n              Title: 'Unassigned',\n              Assignee: null,\n            },\n          },\n        ],\n      });\n\n      assigneeFieldId =\n        table.fields.find((f) => f.name === 'Assignee')?.id ??\n        (() => {\n          throw new Error('Assignee field not found');\n        })();\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should filter records by Me without SQL errors', async () => {\n      const { records } = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: assigneeFieldId,\n              operator: is.value,\n              value: Me,\n            },\n          ],\n        },\n      });\n\n      expect(records).toHaveLength(1);\n      expect(records[0].fields[assigneeFieldId]).toMatchObject({ id: userId });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/lookup-cross-base-tiering.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILookupOptionsRo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createBase,\n  createField,\n  createTable,\n  deleteBase,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Lookup cross base tiering (e2e)', () => {\n  let app: INestApplication;\n  const hostBaseId = globalThis.testConfig.baseId;\n  const spaceId = globalThis.testConfig.spaceId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('one-way link to foreign tiering table with nested lookup', () => {\n    let foreignBaseId: string;\n\n    let productsTable: ITableFullVo;\n    let productPackagesTable: ITableFullVo;\n    let packageTieringTable: ITableFullVo;\n    let subscriptionTable: ITableFullVo;\n\n    let productLink: IFieldVo;\n    let packageIdLink: IFieldVo;\n    let packageTieringProductLookup: IFieldVo;\n    let tieringLink: IFieldVo;\n    let subscriptionProductLookup: IFieldVo;\n\n    beforeEach(async () => {\n      const foreignBase = await createBase({\n        spaceId,\n        name: 'Lookup Cross Base Tiering - Foreign',\n      });\n      foreignBaseId = foreignBase.id;\n\n      productsTable = await createTable(foreignBaseId, {\n        name: 'Products',\n        fields: [{ name: 'Product Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'Product Name': 'Prod-A' } }],\n      });\n\n      productPackagesTable = await createTable(foreignBaseId, {\n        name: 'Product Packages',\n        fields: [{ name: 'Package Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'Package Name': 'Pkg-A' } }],\n      });\n\n      productLink = await createField(productPackagesTable.id, {\n        name: 'Product',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: productsTable.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        productPackagesTable.id,\n        productPackagesTable.records[0].id,\n        productLink.id,\n        { id: productsTable.records[0].id }\n      );\n\n      packageTieringTable = await createTable(foreignBaseId, {\n        name: 'Package Tiering',\n        fields: [{ name: 'Tier', type: FieldType.SingleLineText }],\n        records: [{ fields: { Tier: 'Tier-1' } }],\n      });\n\n      packageIdLink = await createField(packageTieringTable.id, {\n        name: 'Package ID',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: productPackagesTable.id,\n        },\n      } as IFieldRo);\n\n      packageTieringProductLookup = await createField(packageTieringTable.id, {\n        name: 'Product (lookup)',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: productPackagesTable.id,\n          linkFieldId: packageIdLink.id,\n          lookupFieldId: productLink.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        packageTieringTable.id,\n        packageTieringTable.records[0].id,\n        packageIdLink.id,\n        { id: productPackagesTable.records[0].id }\n      );\n\n      subscriptionTable = await createTable(hostBaseId, {\n        name: 'Data Subscription',\n        fields: [{ name: 'Subscription Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'Subscription Name': 'Sub-1' } }],\n      });\n\n      tieringLink = await createField(subscriptionTable.id, {\n        name: 'Tiering',\n        type: FieldType.Link,\n        options: {\n          baseId: foreignBaseId,\n          relationship: Relationship.ManyOne,\n          foreignTableId: packageTieringTable.id,\n          isOneWay: true,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        subscriptionTable.id,\n        subscriptionTable.records[0].id,\n        tieringLink.id,\n        { id: packageTieringTable.records[0].id }\n      );\n\n      subscriptionProductLookup = await createField(subscriptionTable.id, {\n        name: 'Lookup Product',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: packageTieringTable.id,\n          linkFieldId: tieringLink.id,\n          lookupFieldId: packageTieringProductLookup.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n    });\n\n    afterEach(async () => {\n      if (subscriptionTable?.id) {\n        await permanentDeleteTable(hostBaseId, subscriptionTable.id);\n      }\n\n      if (packageTieringTable?.id) {\n        await permanentDeleteTable(foreignBaseId, packageTieringTable.id);\n      }\n\n      if (productPackagesTable?.id) {\n        await permanentDeleteTable(foreignBaseId, productPackagesTable.id);\n      }\n\n      if (productsTable?.id) {\n        await permanentDeleteTable(foreignBaseId, productsTable.id);\n      }\n\n      if (foreignBaseId) {\n        await deleteBase(foreignBaseId);\n      }\n    });\n\n    it('creates lookup on nested lookup-of-link chain across bases', async () => {\n      const records = await getRecords(subscriptionTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [tieringLink.id, subscriptionProductLookup.id],\n      });\n\n      expect(records.records).toHaveLength(1);\n      const record = records.records[0];\n\n      const lookupValue = record.fields[subscriptionProductLookup.id] as\n        | { id: string; title?: string }\n        | Array<{ id: string; title?: string }>;\n\n      expect(lookupValue).toBeDefined();\n\n      const normalizedValues = Array.isArray(lookupValue) ? lookupValue : [lookupValue];\n      const normalizedIds = normalizedValues.map((item) => item.id);\n      const normalizedTitles = normalizedValues.map((item) => item.title);\n\n      expect(normalizedIds).toContain(productsTable.records[0].id);\n      expect(normalizedTitles).toContain('Prod-A');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/lookup-nested-link-lookup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILookupOptionsRo } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Lookup on lookup-to-link chain (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('lookup targeting a lookup link field', () => {\n    beforeAll(() => {\n      process.env.DEBUG_LOOKUP_SQL = '1';\n    });\n\n    let productTable: ITableFullVo;\n    let packageTable: ITableFullVo;\n    let tieringTable: ITableFullVo;\n    let billingTable: ITableFullVo;\n\n    let packageToProductLink: IFieldVo;\n    let tieringToPackageLink: IFieldVo;\n    let tieringProductLookup: IFieldVo;\n    let billingToTieringLink: IFieldVo;\n    let billingProductLookup: IFieldVo;\n\n    beforeEach(async () => {\n      // Product table (final target)\n      productTable = await createTable(baseId, {\n        name: 'Products',\n        fields: [\n          {\n            name: 'Product Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { 'Product Name': 'Prod-A' } }],\n      });\n\n      // Package table links to product\n      packageTable = await createTable(baseId, {\n        name: 'Packages',\n        fields: [\n          {\n            name: 'Package Name',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { 'Package Name': 'Pkg-1' } }],\n      });\n\n      packageToProductLink = await createField(packageTable.id, {\n        name: 'Product Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: productTable.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        packageTable.id,\n        packageTable.records[0].id,\n        packageToProductLink.id,\n        {\n          id: productTable.records[0].id,\n        }\n      );\n\n      // Tiering table links to package and looks up the package's product link\n      tieringTable = await createTable(baseId, {\n        name: 'Tiering',\n        fields: [\n          {\n            name: 'Tiering Label',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { 'Tiering Label': 'T1' } }],\n      });\n\n      tieringToPackageLink = await createField(tieringTable.id, {\n        name: 'Package Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: packageTable.id,\n        },\n      } as IFieldRo);\n\n      tieringProductLookup = await createField(tieringTable.id, {\n        name: 'Product (lookup)',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: packageTable.id,\n          linkFieldId: tieringToPackageLink.id,\n          lookupFieldId: packageToProductLink.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        tieringTable.id,\n        tieringTable.records[0].id,\n        tieringToPackageLink.id,\n        {\n          id: packageTable.records[0].id,\n        }\n      );\n\n      // Billing table links to tiering and looks up tiering's product lookup\n      billingTable = await createTable(baseId, {\n        name: 'Billing',\n        fields: [\n          {\n            name: 'Billing Label',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { 'Billing Label': 'B1' } }],\n      });\n\n      billingToTieringLink = await createField(billingTable.id, {\n        name: 'Tiering Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: tieringTable.id,\n        },\n      } as IFieldRo);\n\n      billingProductLookup = await createField(billingTable.id, {\n        name: 'Product via Tiering',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tieringTable.id,\n          linkFieldId: billingToTieringLink.id,\n          lookupFieldId: tieringProductLookup.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await updateRecordByApi(\n        billingTable.id,\n        billingTable.records[0].id,\n        billingToTieringLink.id,\n        { id: tieringTable.records[0].id }\n      );\n    });\n\n    afterEach(async () => {\n      if (billingTable?.id) await permanentDeleteTable(baseId, billingTable.id);\n      if (tieringTable?.id) await permanentDeleteTable(baseId, tieringTable.id);\n      if (packageTable?.id) await permanentDeleteTable(baseId, packageTable.id);\n      if (productTable?.id) await permanentDeleteTable(baseId, productTable.id);\n    });\n\n    it('returns values when lookup targets a lookup-to-link field', async () => {\n      const tieringRecords = await getRecords(tieringTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(tieringRecords.records).toHaveLength(1);\n      const tieringRecord = tieringRecords.records[0];\n      const tieringLookupValue = tieringRecord.fields[tieringProductLookup.id] as\n        | { id: string; title?: string }\n        | Array<{ id: string; title?: string }>;\n\n      expect(tieringLookupValue).toBeDefined();\n\n      const tieringNormalizedIds = Array.isArray(tieringLookupValue)\n        ? tieringLookupValue.map((item) => item.id)\n        : [tieringLookupValue.id];\n\n      expect(tieringNormalizedIds).toContain(productTable.records[0].id);\n\n      const billingLabelField = billingTable.fields.find((f) => f.name === 'Billing Label');\n      const billingRecords = await getRecords(billingTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [\n          billingProductLookup.id,\n          billingToTieringLink.id,\n          billingLabelField?.id ?? '',\n        ].filter(Boolean),\n      });\n\n      expect(billingRecords.records).toHaveLength(1);\n      const billingRecord = billingRecords.records[0];\n      const lookupValue = billingRecord.fields[billingProductLookup.id] as\n        | { id: string; title?: string }\n        | Array<{ id: string; title?: string }>;\n\n      // eslint-disable-next-line no-console\n      console.log('billing fields snapshot', billingRecord.fields);\n\n      expect(lookupValue).toBeDefined();\n\n      const normalizedIds = Array.isArray(lookupValue)\n        ? lookupValue.map((item) => item.id)\n        : [lookupValue.id];\n\n      expect(normalizedIds).toContain(productTable.records[0].id);\n\n      const normalizedTitles = Array.isArray(lookupValue)\n        ? lookupValue.map((item) => item.title)\n        : [lookupValue.title];\n\n      expect(normalizedTitles).toContain('Prod-A');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/lookup-to-link.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { IFieldRo, LinkFieldCore } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  deleteTable,\n  getRecord,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI LookupToLink (e2e)', () => {\n  let app: INestApplication;\n  let table1: ITableFullVo;\n  let table2: ITableFullVo;\n  let table3: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    // Create table1 with basic fields\n    table1 = await createTable(baseId, {\n      name: 'Table1',\n      fields: [\n        {\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Count',\n          type: FieldType.Number,\n        },\n      ],\n      records: [\n        { fields: { Name: 'A1', Count: 10 } },\n        { fields: { Name: 'A2', Count: 20 } },\n        { fields: { Name: 'A3', Count: 30 } },\n      ],\n    });\n\n    // Create table2 with basic fields\n    table2 = await createTable(baseId, {\n      name: 'Table2',\n      fields: [\n        {\n          name: 'Title',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Value',\n          type: FieldType.Number,\n        },\n      ],\n      records: [\n        { fields: { Title: 'B1', Value: 100 } },\n        { fields: { Title: 'B2', Value: 200 } },\n        { fields: { Title: 'B3', Value: 300 } },\n      ],\n    });\n\n    // Create table3 with basic fields\n    table3 = await createTable(baseId, {\n      name: 'Table3',\n      fields: [\n        {\n          name: 'Description',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      records: [{ fields: { Description: 'C1' } }, { fields: { Description: 'C2' } }],\n    });\n  });\n\n  afterEach(async () => {\n    await deleteTable(baseId, table1.id);\n    await deleteTable(baseId, table2.id);\n    await deleteTable(baseId, table3.id);\n  });\n\n  describe('Lookup to Link Field Tests', () => {\n    it('should handle lookup field that targets a link field', async () => {\n      // Create link field from table1 to table2\n      const linkField1to2 = await createField(table1.id, {\n        name: 'Link to Table2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      } as IFieldRo);\n\n      // Wait a bit for the symmetric field to be created\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      // Get the symmetric field ID\n      const symmetricFieldId = (linkField1to2 as LinkFieldCore).options.symmetricFieldId;\n      if (!symmetricFieldId) {\n        throw new Error('Symmetric field ID not found');\n      }\n\n      // Create lookup field in table1 that looks up table2's symmetric link field\n\n      const lookupField = await createField(table1.id, {\n        name: 'Lookup Link to Table1',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField1to2.id,\n          lookupFieldId: symmetricFieldId,\n        },\n      } as IFieldRo);\n\n      // Establish link: table1[0] -> table2[0]\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1to2.id, {\n        id: table2.records[0].id,\n      });\n\n      // Test that the lookup field can be queried without errors\n      const record = await getRecord(table1.id, table1.records[0].id);\n\n      // The lookup field should exist and not cause query errors\n      expect(record.fields).toHaveProperty(lookupField.id);\n\n      // The value should be the linked table1 record (symmetric link)\n      // Use field name instead of field ID to access the value\n      const lookupValue = record.fields[lookupField.name];\n      if (lookupValue) {\n        expect(lookupValue).toHaveProperty('id', table1.records[0].id);\n        expect(lookupValue).toHaveProperty('title', 'A1');\n      }\n    });\n\n    it('should handle multiple records in lookup to link scenario', async () => {\n      // Create link field from table1 to table2\n      const linkField1to2 = await createField(table1.id, {\n        name: 'Link to Table2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      } as IFieldRo);\n\n      // Create link field from table2 to table3\n      const linkField2to3 = await createField(table2.id, {\n        name: 'Link to Table3',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table3.id,\n        },\n      } as IFieldRo);\n\n      // Create lookup field in table1 that looks up table2's link field\n      const lookupField = await createField(table1.id, {\n        name: 'Lookup Link to Table3',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField1to2.id,\n          lookupFieldId: linkField2to3.id,\n        },\n      } as IFieldRo);\n\n      // Establish multiple links\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField1to2.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField2to3.id, [\n        { id: table3.records[0].id },\n      ]);\n\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField2to3.id, [\n        { id: table3.records[1].id },\n      ]);\n\n      // Test that we can query all records without errors\n      const records = await getRecords(table1.id);\n      expect(records.records).toHaveLength(3);\n\n      // Check the first record has the expected lookup values\n      const firstRecord = records.records[0];\n      // Use field name instead of field ID to access the value\n      const lookupValueByName = firstRecord.fields[lookupField.name];\n      // Use the correct lookup value (by name, not by ID)\n      const actualLookupValue = lookupValueByName;\n      expect(Array.isArray(actualLookupValue)).toBe(true);\n      if (Array.isArray(actualLookupValue)) {\n        expect(actualLookupValue).toHaveLength(2);\n        const ids = actualLookupValue.map((v: { id: string }) => v.id);\n        expect(ids).toContain(table3.records[0].id);\n        expect(ids).toContain(table3.records[1].id);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/lookup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { type INestApplication } from '@nestjs/common';\nimport type {\n  IConditionalRollupFieldOptions,\n  IFieldRo,\n  IFieldVo,\n  IFilter,\n  ILinkFieldOptions,\n  ILookupLinkOptions,\n  ILookupOptionsRo,\n  INumberFieldOptions,\n  IUnionShowAs,\n  LinkFieldCore,\n} from '@teable/core';\nimport {\n  CellFormat,\n  Colors,\n  FieldKeyType,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  TimeFormatting,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getRecords, updateRecords } from '@teable/openapi';\nimport { RecordService } from '../src/features/record/record.service';\nimport {\n  createField,\n  deleteField,\n  createTable,\n  permanentDeleteTable,\n  getFields,\n  getField,\n  getRecord,\n  initApp,\n  createRecords,\n  updateRecordByApi,\n  convertField,\n} from './utils/init-app';\n\n// All kind of field type (except link)\nconst defaultFields: IFieldRo[] = [\n  {\n    name: FieldType.SingleLineText,\n    type: FieldType.SingleLineText,\n    options: {},\n  },\n  {\n    name: FieldType.Number,\n    type: FieldType.Number,\n    options: {\n      formatting: {\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n      },\n    },\n  },\n  {\n    name: FieldType.SingleSelect,\n    type: FieldType.SingleSelect,\n    options: {\n      choices: [\n        { name: 'todo', color: Colors.Yellow },\n        { name: 'doing', color: Colors.Orange },\n        { name: 'done', color: Colors.Green },\n      ],\n    },\n  },\n  {\n    name: FieldType.MultipleSelect,\n    type: FieldType.MultipleSelect,\n    options: {\n      choices: [\n        { name: 'rap', color: Colors.Yellow },\n        { name: 'rock', color: Colors.Orange },\n        { name: 'hiphop', color: Colors.Green },\n      ],\n    },\n  },\n  {\n    name: FieldType.Date,\n    type: FieldType.Date,\n    options: {\n      formatting: {\n        date: 'YYYY-MM-DD',\n        time: TimeFormatting.Hour24,\n        timeZone: 'America/New_York',\n      },\n      defaultValue: 'now',\n    },\n  },\n  {\n    name: FieldType.Attachment,\n    type: FieldType.Attachment,\n    options: {},\n  },\n  {\n    name: FieldType.Formula,\n    type: FieldType.Formula,\n    options: {\n      expression: '1 + 1',\n      formatting: {\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n      },\n    },\n  },\n];\nconst normalizeSingle = <T>(value: T | T[]) =>\n  Array.isArray(value) ? (value.length ? value[0] : undefined) : value;\n\ndescribe('OpenAPI Lookup field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  async function updateTableFields(table: ITableFullVo) {\n    const tableFields = await getFields(table.id);\n    table.fields = tableFields;\n    return tableFields;\n  }\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('general lookup', () => {\n    let table1: ITableFullVo = {} as any;\n    let table2: ITableFullVo = {} as any;\n    const tables: ITableFullVo[] = [];\n\n    beforeAll(async () => {\n      // create table1 with fundamental field\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: defaultFields.map((f) => ({ ...f, name: f.name + '[table1]' })),\n      });\n\n      // create table2 with fundamental field\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: defaultFields.map((f) => ({ ...f, name: f.name + '[table2]' })),\n      });\n\n      // create link field\n      await createField(table1.id, {\n        name: 'link[table1]',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n      // update fields in table after create link field\n      await updateTableFields(table1);\n      await updateTableFields(table2);\n      tables.push(table1, table2);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    beforeEach(async () => {\n      // remove all link\n      await updateRecordByApi(\n        table2.id,\n        table2.records[0].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        null\n      );\n      await updateRecordByApi(\n        table2.id,\n        table2.records[1].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        null\n      );\n      await updateRecordByApi(\n        table2.id,\n        table2.records[2].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        null\n      );\n      // add a link record to first row\n      await updateRecordByApi(\n        table1.id,\n        table1.records[0].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[0].id }]\n      );\n    });\n\n    function getFieldByType(fields: IFieldVo[], type: FieldType) {\n      const field = fields.find((field) => field.type === type);\n      if (!field) {\n        throw new Error('field not found');\n      }\n      return field;\n    }\n\n    function getFieldByName(fields: IFieldVo[], name: string) {\n      const field = fields.find((field) => field.name === name);\n      if (!field) {\n        throw new Error('field not found');\n      }\n      return field;\n    }\n\n    async function lookupFrom(table: ITableFullVo, lookupFieldId: string) {\n      const linkField = getFieldByType(table.fields, FieldType.Link) as LinkFieldCore;\n      const foreignTable = tables.find((t) => t.id === linkField.options.foreignTableId)!;\n      const lookupField = foreignTable.fields.find((f) => f.id === lookupFieldId)!;\n      const options = lookupField.options as INumberFieldOptions | undefined;\n      const lookupFieldRo: IFieldRo = {\n        name: `lookup ${lookupField.name} [${table.name}]`,\n        type: lookupField.type,\n        isLookup: true,\n        options: options?.formatting\n          ? {\n              formatting: options.formatting,\n            }\n          : undefined,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId, // getFieldByType(table2.fields, FieldType.SingleLineText).id,\n        } as ILookupOptionsRo,\n      };\n\n      // create lookup field\n      await createField(table.id, lookupFieldRo);\n\n      await updateTableFields(table);\n      return getFieldByName(table.fields, lookupFieldRo.name!);\n    }\n\n    async function expectLookup(table: ITableFullVo, fieldType: FieldType, updateValue: any) {\n      const linkField = getFieldByType(table.fields, FieldType.Link) as LinkFieldCore;\n      const foreignTable = tables.find((t) => t.id === linkField.options.foreignTableId)!;\n\n      const lookedUpToField = getFieldByType(foreignTable.fields, fieldType);\n      const lookupFieldVo = await lookupFrom(table, lookedUpToField.id);\n\n      // update a field that be lookup by previous field\n      await updateRecordByApi(\n        foreignTable.id,\n        foreignTable.records[0].id,\n        lookedUpToField.id,\n        updateValue\n      );\n\n      const record = await getRecord(table.id, table.records[0].id);\n      return expect(record.fields[lookupFieldVo.id]);\n    }\n\n    async function expectLinkText(\n      table: ITableFullVo,\n      recordId: string,\n      linkFieldId: string,\n      expectedText: string\n    ) {\n      const deadline = Date.now() + 15000;\n      let lastValue: unknown;\n      do {\n        const record = await getRecord(table.id, recordId, CellFormat.Text);\n        lastValue = record.fields[linkFieldId];\n        if (lastValue === expectedText) {\n          return;\n        }\n        await new Promise((resolve) => setTimeout(resolve, 100));\n      } while (Date.now() < deadline);\n\n      expect(lastValue).toEqual(expectedText);\n    }\n\n    it('should update lookupField by remove a linkRecord from cell', async () => {\n      const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n      const lookupFieldVo = await lookupFrom(table1, lookedUpToField.id);\n\n      // update a field that will be lookup by after field\n      await updateRecordByApi(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n      await updateRecordByApi(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n      // add a link record after\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n      );\n\n      const record = await getRecord(table1.id, table1.records[1].id);\n      expect(record.fields[lookupFieldVo.id]).toEqual([123, 456]);\n\n      // remove a link record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }]\n      );\n\n      const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n      expect(recordAfter1.fields[lookupFieldVo.id]).toEqual([123]);\n\n      // remove all link record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        null\n      );\n\n      const recordAfter2 = await getRecord(table1.id, table1.records[1].id);\n      expect(recordAfter2.fields[lookupFieldVo.id]).toEqual(undefined);\n\n      // add a link record from many - one field\n      await updateRecordByApi(\n        table2.id,\n        table2.records[1].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        { id: table1.records[1].id }\n      );\n\n      const recordAfter3 = await getRecord(table1.id, table1.records[1].id);\n      expect(recordAfter3.fields[lookupFieldVo.id]).toEqual([123]);\n    });\n\n    it('should update many - one lookupField by remove a linkRecord from cell', async () => {\n      const lookedUpToField = getFieldByType(table1.fields, FieldType.Number);\n      const lookupFieldVo = await lookupFrom(table2, lookedUpToField.id);\n\n      // update a field that will be lookup by after field\n      await updateRecordByApi(table1.id, table1.records[1].id, lookedUpToField.id, 123);\n\n      // add a link record after\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n      );\n\n      const record1 = await getRecord(table2.id, table2.records[1].id);\n      expect(record1.fields[lookupFieldVo.id]).toEqual(123);\n      const record2 = await getRecord(table2.id, table2.records[2].id);\n      expect(record2.fields[lookupFieldVo.id]).toEqual(123);\n      // remove a link record\n      const updatedRecord = await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }]\n      );\n\n      expect(updatedRecord.fields[getFieldByType(table1.fields, FieldType.Link).id]).toEqual([\n        { id: table2.records[1].id },\n      ]);\n\n      const record3 = await getRecord(table2.id, table2.records[1].id);\n      expect(record3.fields[lookupFieldVo.id]).toEqual(123);\n      const record4 = await getRecord(table2.id, table2.records[2].id);\n      expect(record4.fields[lookupFieldVo.id]).toEqual(undefined);\n\n      // remove all link record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        null\n      );\n\n      const record5 = await getRecord(table2.id, table2.records[1].id);\n      expect(record5.fields[lookupFieldVo.id]).toEqual(undefined);\n\n      // add a link record from many - one field\n      await updateRecordByApi(\n        table2.id,\n        table2.records[1].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        { id: table1.records[1].id }\n      );\n\n      const record6 = await getRecord(table2.id, table2.records[1].id);\n      expect(record6.fields[lookupFieldVo.id]).toEqual(123);\n    });\n\n    it('should preserve lookup metadata when renaming via convertField', async () => {\n      const linkField = getFieldByType(table1.fields, FieldType.Link) as LinkFieldCore;\n      const foreignTable = tables.find((t) => t.id === linkField.options.foreignTableId)!;\n      const lookedUpField = getFieldByType(foreignTable.fields, FieldType.SingleLineText);\n      const lookupName = 'lookup rename safeguard';\n\n      const lookupField = await createField(table1.id, {\n        name: lookupName,\n        type: lookedUpField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: lookedUpField.id,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await updateTableFields(table1);\n      const fieldId = lookupField.id;\n      const beforeDetail = await getField(table1.id, fieldId);\n      const rawLookupOptions = beforeDetail.lookupOptions as ILookupLinkOptions | undefined;\n      const normalizedLookupOptions: ILookupOptionsRo | undefined = rawLookupOptions\n        ? {\n            foreignTableId: rawLookupOptions.foreignTableId,\n            lookupFieldId: rawLookupOptions.lookupFieldId,\n            linkFieldId: rawLookupOptions.linkFieldId,\n            filter: rawLookupOptions.filter,\n          }\n        : undefined;\n      const recordBefore = await getRecord(table1.id, table1.records[0].id);\n      const baseline = recordBefore.fields[fieldId];\n\n      try {\n        const renamed = await convertField(table1.id, fieldId, {\n          name: `${lookupName} renamed`,\n          type: lookedUpField.type,\n          isLookup: true,\n          lookupOptions: normalizedLookupOptions,\n          options: beforeDetail.options,\n        } as IFieldRo);\n\n        expect(renamed.dbFieldType).toBe(beforeDetail.dbFieldType);\n        expect(renamed.isMultipleCellValue).toBe(beforeDetail.isMultipleCellValue);\n        expect(renamed.isComputed).toBe(true);\n        expect(renamed.lookupOptions).toMatchObject(\n          beforeDetail.lookupOptions as Record<string, unknown>\n        );\n\n        const recordAfter = await getRecord(table1.id, table1.records[0].id);\n        expect(recordAfter.fields[fieldId]).toEqual(baseline);\n      } finally {\n        await deleteField(table1.id, fieldId);\n        await updateTableFields(table1);\n      }\n    });\n\n    it('should update many - one lookupField by replace a linkRecord from cell', async () => {\n      const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n      const lookupFieldVo = await lookupFrom(table1, lookedUpToField.id);\n\n      // update a field that will be lookup by after field\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.SingleLineText).id,\n        'A2'\n      );\n      await updateRecordByApi(\n        table1.id,\n        table1.records[2].id,\n        getFieldByType(table1.fields, FieldType.SingleLineText).id,\n        'A3'\n      );\n      await updateRecordByApi(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n      await updateRecordByApi(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n      // add a link record after\n      await updateRecordByApi(\n        table2.id,\n        table2.records[1].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        { id: table1.records[1].id }\n      );\n\n      const record = await getRecord(table1.id, table1.records[1].id);\n      expect(record.fields[lookupFieldVo.id]).toEqual([123]);\n\n      // replace a link record\n      await updateRecordByApi(\n        table2.id,\n        table2.records[1].id,\n        getFieldByType(table2.fields, FieldType.Link).id,\n        { id: table1.records[2].id }\n      );\n\n      const record1 = await getRecord(table1.id, table1.records[1].id);\n      expect(record1.fields[lookupFieldVo.id]).toEqual(undefined);\n\n      const record2 = await getRecord(table1.id, table1.records[2].id);\n      expect(record2.fields[lookupFieldVo.id]).toEqual([123]);\n    });\n\n    it('should update one - many lookupField by add a linkRecord from cell', async () => {\n      const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n      const lookupFieldVo = await lookupFrom(table1, lookedUpToField.id);\n\n      // update a field that will be lookup by after field\n      await updateRecordByApi(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n      await updateRecordByApi(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n      // add a link record after\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }]\n      );\n\n      const record = await getRecord(table1.id, table1.records[1].id);\n      expect(record.fields[lookupFieldVo.id]).toEqual([123]);\n\n      // // add a link record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n      );\n\n      const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n      expect(recordAfter1.fields[lookupFieldVo.id]).toEqual([123, 456]);\n    });\n\n    it('should update one -many lookupField by replace a linkRecord from cell', async () => {\n      const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n      const lookupFieldVo = await lookupFrom(table1, lookedUpToField.id);\n\n      // update a field that will be lookup by after field\n      await updateRecordByApi(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n      await updateRecordByApi(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n      // add a link record after\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }]\n      );\n\n      const record = await getRecord(table1.id, table1.records[1].id);\n      expect(record.fields[lookupFieldVo.id]).toEqual([123]);\n\n      // replace a link record\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[2].id }]\n      );\n\n      const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n      expect(recordAfter1.fields[lookupFieldVo.id]).toEqual([456]);\n    });\n\n    it('should update lookupField by edit the a looked up text field', async () => {\n      (await expectLookup(table1, FieldType.SingleLineText, 'lookup text')).toEqual([\n        'lookup text',\n      ]);\n      (await expectLookup(table2, FieldType.SingleLineText, 'lookup text')).toEqual('lookup text');\n    });\n\n    it('should update lookupField by edit the a looked up number field', async () => {\n      (await expectLookup(table1, FieldType.Number, 123)).toEqual([123]);\n      (await expectLookup(table2, FieldType.Number, 123)).toEqual(123);\n    });\n\n    it('should update lookupField by edit the a looked up singleSelect field', async () => {\n      (await expectLookup(table1, FieldType.SingleSelect, 'todo')).toEqual(['todo']);\n      (await expectLookup(table2, FieldType.SingleSelect, 'todo')).toEqual('todo');\n    });\n\n    it('should update lookupField by edit the a looked up multipleSelect field', async () => {\n      (await expectLookup(table1, FieldType.MultipleSelect, ['rap'])).toEqual(['rap']);\n      (await expectLookup(table2, FieldType.MultipleSelect, ['rap'])).toEqual(['rap']);\n    });\n\n    it('should update lookupField by edit the a looked up date field', async () => {\n      const now = new Date().toISOString();\n      (await expectLookup(table1, FieldType.Date, now)).toEqual([now]);\n      (await expectLookup(table2, FieldType.Date, now)).toEqual(now);\n    });\n\n    // it('should update lookupField by edit the a looked up attachment field', async () => {\n    //   (await expectLookup(table1, FieldType.Attachment, 123)).toEqual([123]);\n    // });\n\n    // it('should update lookupField by edit the a looked up formula field', async () => {\n    //   (await expectLookup(table1, FieldType.Number, 123)).toEqual([123]);\n    // });\n\n    it('should expose link display text when requesting text cell format', async () => {\n      const linkField = getFieldByType(table1.fields, FieldType.Link);\n      const primaryField = getFieldByType(table2.fields, FieldType.SingleLineText);\n\n      await updateRecordByApi(table2.id, table2.records[1].id, primaryField.id, 'text');\n\n      await updateRecordByApi(table1.id, table1.records[1].id, linkField.id, [\n        { id: table2.records[1].id, title: 'text' },\n      ]);\n\n      await expectLinkText(table1, table1.records[1].id, linkField.id, 'text');\n\n      const recordJson = await getRecord(table1.id, table1.records[1].id, CellFormat.Json);\n      expect(recordJson.fields[linkField.id]).toEqual([\n        { id: table2.records[1].id, title: 'text' },\n      ]);\n    });\n\n    it('should calculate when add a lookup field', async () => {\n      const textField = getFieldByType(table1.fields, FieldType.SingleLineText);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, textField.id, 'A1');\n      await updateRecordByApi(table1.id, table1.records[1].id, textField.id, 'A2');\n      await updateRecordByApi(table1.id, table1.records[2].id, textField.id, 'A3');\n\n      const lookedUpToField = getFieldByType(table1.fields, FieldType.SingleLineText);\n\n      await updateRecordByApi(\n        table1.id,\n        table1.records[1].id,\n        getFieldByType(table1.fields, FieldType.Link).id,\n        [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n      );\n\n      const lookupFieldVo = await lookupFrom(table2, lookedUpToField.id);\n      const record1 = await getRecord(table2.id, table2.records[1].id);\n      expect(record1.fields[lookupFieldVo.id]).toEqual('A2');\n      const record2 = await getRecord(table2.id, table2.records[2].id);\n      expect(record2.fields[lookupFieldVo.id]).toEqual('A2');\n    });\n\n    it('should delete a field that be lookup', async () => {\n      const textFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n      const textField = await createField(table2.id, textFieldRo);\n      const lookupFieldRo = {\n        name: 'lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: getFieldByType(table1.fields, FieldType.Link).id,\n          lookupFieldId: textField.id,\n        } as ILookupOptionsRo,\n      };\n\n      const lookupField = await createField(table1.id, lookupFieldRo);\n\n      await deleteField(table2.id, textField.id);\n      await deleteField(table1.id, lookupField.id);\n    });\n\n    it('should set showAs when create field lookup to a rollup', async () => {\n      const rollupFieldRo: IFieldRo = {\n        name: 'rollup',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'countall({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: getFieldByType(table1.fields, FieldType.Link).id,\n          lookupFieldId: getFieldByType(table2.fields, FieldType.Number).id,\n        },\n      };\n\n      const rollupField = await createField(table1.id, rollupFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        name: `lookup ${rollupField.name} [${table1.name}]`,\n        type: rollupField.type,\n        isLookup: true,\n        options: {\n          showAs: {\n            color: Colors.Green,\n            maxValue: 100,\n            showValue: true,\n            type: 'ring',\n          } as IUnionShowAs,\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: getFieldByType(table2.fields, FieldType.Link).id,\n          lookupFieldId: rollupField.id,\n        } as ILookupOptionsRo,\n      };\n      const lookupField = await createField(table2.id, lookupFieldRo);\n\n      expect(lookupField).toMatchObject(lookupFieldRo);\n    });\n  });\n\n  describe('system field lookup propagation', () => {\n    const SOURCE_AUTO_FIELD = 'Auto Number Field';\n    const SOURCE_CREATED_TIME_FIELD = 'Created Time Field';\n    const SOURCE_LAST_MODIFIED_TIME_FIELD = 'Last Modified Time Field';\n    const SOURCE_CREATED_BY_FIELD = 'Created By Field';\n    const SOURCE_LAST_MODIFIED_BY_FIELD = 'Last Modified By Field';\n\n    const HOST_LOOKUP_AUTO = 'Lookup Auto Number';\n    const HOST_LOOKUP_CREATED_TIME = 'Lookup Created Time';\n    const HOST_LOOKUP_LAST_MODIFIED_TIME = 'Lookup Last Modified Time';\n    const HOST_LOOKUP_CREATED_BY = 'Lookup Created By';\n    const HOST_LOOKUP_LAST_MODIFIED_BY = 'Lookup Last Modified By';\n\n    const CONSUMER_LOOKUP_AUTO = 'Nested Lookup Auto Number';\n    const CONSUMER_LOOKUP_CREATED_TIME = 'Nested Lookup Created Time';\n    const CONSUMER_LOOKUP_LAST_MODIFIED_TIME = 'Nested Lookup Last Modified Time';\n    const CONSUMER_LOOKUP_CREATED_BY = 'Nested Lookup Created By';\n    const CONSUMER_LOOKUP_LAST_MODIFIED_BY = 'Nested Lookup Last Modified By';\n\n    let sourceTable: ITableFullVo;\n    let hostTable: ITableFullVo;\n    let consumerTable: ITableFullVo;\n    let hostLinkField: IFieldVo;\n    let consumerLinkField: IFieldVo;\n\n    const hostLookupFields: Record<string, IFieldVo> = {};\n\n    async function refreshFields(table: ITableFullVo) {\n      const updated = await getFields(table.id);\n      table.fields = updated;\n      return updated;\n    }\n\n    beforeAll(async () => {\n      sourceTable = await createTable(baseId, {\n        name: 'system-source',\n        fields: [\n          { name: 'Source Title', type: FieldType.SingleLineText, options: {} },\n          { name: SOURCE_AUTO_FIELD, type: FieldType.AutoNumber },\n          { name: SOURCE_CREATED_TIME_FIELD, type: FieldType.CreatedTime },\n          { name: SOURCE_LAST_MODIFIED_TIME_FIELD, type: FieldType.LastModifiedTime },\n          { name: SOURCE_CREATED_BY_FIELD, type: FieldType.CreatedBy },\n          { name: SOURCE_LAST_MODIFIED_BY_FIELD, type: FieldType.LastModifiedBy },\n        ],\n      });\n\n      hostTable = await createTable(baseId, {\n        name: 'system-host',\n        fields: [{ name: 'Host Title', type: FieldType.SingleLineText, options: {} }],\n      });\n\n      consumerTable = await createTable(baseId, {\n        name: 'system-consumer',\n        fields: [{ name: 'Consumer Title', type: FieldType.SingleLineText, options: {} }],\n      });\n\n      await refreshFields(sourceTable);\n      await refreshFields(hostTable);\n      await refreshFields(consumerTable);\n\n      hostLinkField = await createField(hostTable.id, {\n        name: 'Link To Source',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: sourceTable.id,\n        } as ILinkFieldOptions,\n      });\n      hostTable.fields.push(hostLinkField);\n\n      const lookupConfigs: Array<{ name: string; type: FieldType; targetName: string }> = [\n        { name: HOST_LOOKUP_AUTO, type: FieldType.AutoNumber, targetName: SOURCE_AUTO_FIELD },\n        {\n          name: HOST_LOOKUP_CREATED_TIME,\n          type: FieldType.CreatedTime,\n          targetName: SOURCE_CREATED_TIME_FIELD,\n        },\n        {\n          name: HOST_LOOKUP_LAST_MODIFIED_TIME,\n          type: FieldType.LastModifiedTime,\n          targetName: SOURCE_LAST_MODIFIED_TIME_FIELD,\n        },\n        {\n          name: HOST_LOOKUP_CREATED_BY,\n          type: FieldType.CreatedBy,\n          targetName: SOURCE_CREATED_BY_FIELD,\n        },\n        {\n          name: HOST_LOOKUP_LAST_MODIFIED_BY,\n          type: FieldType.LastModifiedBy,\n          targetName: SOURCE_LAST_MODIFIED_BY_FIELD,\n        },\n      ];\n\n      for (const config of lookupConfigs) {\n        const sourceField = sourceTable.fields.find((f) => f.name === config.targetName);\n        if (!sourceField) {\n          throw new Error(`Source field ${config.targetName} not found`);\n        }\n        const createdLookup = await createField(hostTable.id, {\n          name: config.name,\n          type: config.type,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: sourceTable.id,\n            linkFieldId: hostLinkField.id,\n            lookupFieldId: sourceField.id,\n          } satisfies ILookupOptionsRo,\n        });\n        hostLookupFields[config.name] = createdLookup;\n        hostTable.fields.push(createdLookup);\n      }\n\n      consumerLinkField = await createField(consumerTable.id, {\n        name: 'Link To Host',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: hostTable.id,\n        } as ILinkFieldOptions,\n      });\n      consumerTable.fields.push(consumerLinkField);\n\n      const nestedConfigs: Array<{ name: string; hostLookupName: string }> = [\n        { name: CONSUMER_LOOKUP_AUTO, hostLookupName: HOST_LOOKUP_AUTO },\n        { name: CONSUMER_LOOKUP_CREATED_TIME, hostLookupName: HOST_LOOKUP_CREATED_TIME },\n        {\n          name: CONSUMER_LOOKUP_LAST_MODIFIED_TIME,\n          hostLookupName: HOST_LOOKUP_LAST_MODIFIED_TIME,\n        },\n        { name: CONSUMER_LOOKUP_CREATED_BY, hostLookupName: HOST_LOOKUP_CREATED_BY },\n        {\n          name: CONSUMER_LOOKUP_LAST_MODIFIED_BY,\n          hostLookupName: HOST_LOOKUP_LAST_MODIFIED_BY,\n        },\n      ];\n\n      for (const config of nestedConfigs) {\n        const hostLookup = hostLookupFields[config.hostLookupName];\n        const nestedLookup = await createField(consumerTable.id, {\n          name: config.name,\n          type: hostLookup.type,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: hostTable.id,\n            linkFieldId: consumerLinkField.id,\n            lookupFieldId: hostLookup.id,\n          } satisfies ILookupOptionsRo,\n        });\n        consumerTable.fields.push(nestedLookup);\n      }\n\n      await updateRecordByApi(hostTable.id, hostTable.records[0].id, hostLinkField.id, [\n        { id: sourceTable.records[0].id },\n      ]);\n\n      await updateRecordByApi(consumerTable.id, consumerTable.records[0].id, consumerLinkField.id, [\n        { id: hostTable.records[0].id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, consumerTable.id);\n      await permanentDeleteTable(baseId, hostTable.id);\n      await permanentDeleteTable(baseId, sourceTable.id);\n    });\n\n    it('should resolve lookup values for system fields', async () => {\n      const sourceRecords = await getRecords(sourceTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n      const hostRecords = await getRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      const sourceRecord = sourceRecords.data.records.find(\n        (record) => record.id === sourceTable.records[0].id\n      );\n      const hostRecord = hostRecords.data.records.find(\n        (record) => record.id === hostTable.records[0].id\n      );\n      expect(sourceRecord).toBeTruthy();\n      expect(hostRecord).toBeTruthy();\n      expect(hostRecord!.fields[HOST_LOOKUP_AUTO]).toEqual(sourceRecord!.fields[SOURCE_AUTO_FIELD]);\n      expect(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_CREATED_TIME] as unknown)).toEqual(\n        sourceRecord!.fields[SOURCE_CREATED_TIME_FIELD]\n      );\n      expect(\n        normalizeSingle(hostRecord!.fields[HOST_LOOKUP_LAST_MODIFIED_TIME] as unknown)\n      ).toEqual(sourceRecord!.fields[SOURCE_LAST_MODIFIED_TIME_FIELD]);\n      expect(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_CREATED_BY] as unknown)).toEqual(\n        sourceRecord!.fields[SOURCE_CREATED_BY_FIELD]\n      );\n      expect(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_LAST_MODIFIED_BY] as unknown)).toEqual(\n        sourceRecord!.fields[SOURCE_LAST_MODIFIED_BY_FIELD]\n      );\n    });\n\n    it('should resolve nested lookup values for system fields', async () => {\n      const hostRecords = await getRecords(hostTable.id, { fieldKeyType: FieldKeyType.Name });\n      const consumerRecords = await getRecords(consumerTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n      });\n\n      const hostRecord = hostRecords.data.records.find(\n        (record) => record.id === hostTable.records[0].id\n      );\n      const consumerRecord = consumerRecords.data.records.find(\n        (record) => record.id === consumerTable.records[0].id\n      );\n      expect(hostRecord).toBeTruthy();\n      expect(consumerRecord).toBeTruthy();\n\n      expect(consumerRecord!.fields[CONSUMER_LOOKUP_AUTO]).toEqual(\n        hostRecord!.fields[HOST_LOOKUP_AUTO]\n      );\n      expect(\n        normalizeSingle(consumerRecord!.fields[CONSUMER_LOOKUP_CREATED_TIME] as unknown)\n      ).toEqual(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_CREATED_TIME] as unknown));\n      expect(\n        normalizeSingle(consumerRecord!.fields[CONSUMER_LOOKUP_LAST_MODIFIED_TIME] as unknown)\n      ).toEqual(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_LAST_MODIFIED_TIME] as unknown));\n      expect(\n        normalizeSingle(consumerRecord!.fields[CONSUMER_LOOKUP_CREATED_BY] as unknown)\n      ).toEqual(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_CREATED_BY] as unknown));\n      expect(\n        normalizeSingle(consumerRecord!.fields[CONSUMER_LOOKUP_LAST_MODIFIED_BY] as unknown)\n      ).toEqual(normalizeSingle(hostRecord!.fields[HOST_LOOKUP_LAST_MODIFIED_BY] as unknown));\n    });\n\n    it('should return created-by lookup value in updateRecords response', async () => {\n      expect(hostLinkField.isMultipleCellValue).toBe(true);\n      const linkedRecordIds = sourceTable.records.slice(0, 2).map((record) => ({ id: record.id }));\n      const response = await updateRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            id: hostTable.records[0].id,\n            fields: {\n              [hostLinkField.name]: linkedRecordIds,\n            },\n          },\n        ],\n      });\n\n      expect(response.status).toBe(200);\n      const lookupFieldId = hostLookupFields[HOST_LOOKUP_CREATED_BY].id;\n      const refreshedRecords = await getRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const refreshedRecord = refreshedRecords.data.records.find(\n        (record) => record.id === hostTable.records[0].id\n      );\n      expect(refreshedRecord).toBeTruthy();\n      const refreshedLookupValue = refreshedRecord!.fields[lookupFieldId];\n      expect(refreshedLookupValue).toBeTruthy();\n\n      const rawRecords = await getRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.DbFieldName,\n        projection: [hostLookupFields[HOST_LOOKUP_CREATED_BY].dbFieldName],\n      });\n      const rawRecord = rawRecords.data.records.find(\n        (record) => record.id === hostTable.records[0].id\n      );\n      expect(rawRecord).toBeTruthy();\n      const rawLookupValue =\n        rawRecord!.fields[hostLookupFields[HOST_LOOKUP_CREATED_BY].dbFieldName];\n      expect(typeof rawLookupValue).toBe('object');\n      if (Array.isArray(refreshedLookupValue) && Array.isArray(rawLookupValue)) {\n        expect(rawLookupValue).toHaveLength(refreshedLookupValue.length);\n      }\n    });\n\n    it('should resolve created-by lookup via table cache snapshot', async () => {\n      const linkedRecordIds = sourceTable.records.slice(0, 2).map((record) => ({ id: record.id }));\n      await updateRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: hostTable.records[0].id,\n            fields: {\n              [hostLinkField.id]: linkedRecordIds,\n            },\n          },\n        ],\n      });\n\n      const recordService = app.get<RecordService>(RecordService);\n      const snapshots = await recordService.getSnapshotBulkWithPermission(\n        hostTable.id,\n        [hostTable.records[0].id],\n        { [hostLookupFields[HOST_LOOKUP_CREATED_BY].id]: true },\n        FieldKeyType.Id,\n        CellFormat.Json,\n        true\n      );\n\n      expect(snapshots).toHaveLength(1);\n      const snapshot = snapshots[0];\n      const lookupFieldId = hostLookupFields[HOST_LOOKUP_CREATED_BY].id;\n      const lookupValue = snapshot.data.fields[lookupFieldId];\n      expect(lookupValue).toBeTruthy();\n      if (Array.isArray(lookupValue)) {\n        expect(lookupValue).toHaveLength(linkedRecordIds.length);\n        lookupValue.forEach((entry) => {\n          expect(entry).toMatchObject({\n            id: expect.any(String),\n            title: expect.any(String),\n          });\n        });\n      } else {\n        expect(lookupValue).toMatchObject({\n          id: expect.any(String),\n          title: expect.any(String),\n        });\n      }\n    });\n  });\n\n  describe('nested lookup dependencies', () => {\n    let usersTable: ITableFullVo;\n    let projectsTable: ITableFullVo;\n    let tasksTable: ITableFullVo;\n    let userNameField: IFieldVo;\n    let projectNameField: IFieldVo;\n    let taskNameField: IFieldVo;\n    let projectOwnerLookupField: IFieldVo;\n    let taskOwnerLookupField: IFieldVo;\n    let projectLinkFieldId: string;\n    let taskLinkFieldId: string;\n    let userRecordId: string;\n    let projectRecordId: string;\n    let taskRecordId: string;\n\n    const refreshFields = async (table: ITableFullVo) => {\n      table.fields = await getFields(table.id);\n    };\n\n    const getFieldByName = (fields: IFieldVo[], name: string) => {\n      const field = fields.find((f) => f.name === name);\n      if (!field) {\n        throw new Error(`Field ${name} not found`);\n      }\n      return field;\n    };\n\n    beforeAll(async () => {\n      usersTable = await createTable(baseId, {\n        name: 'lookup-nested-users',\n        fields: [\n          {\n            name: 'User Name',\n            type: FieldType.SingleLineText,\n            options: {},\n          },\n        ],\n      });\n\n      projectsTable = await createTable(baseId, {\n        name: 'lookup-nested-projects',\n        fields: [\n          {\n            name: 'Project Name',\n            type: FieldType.SingleLineText,\n            options: {},\n          },\n        ],\n      });\n\n      tasksTable = await createTable(baseId, {\n        name: 'lookup-nested-tasks',\n        fields: [\n          {\n            name: 'Task Name',\n            type: FieldType.SingleLineText,\n            options: {},\n          },\n        ],\n      });\n\n      await refreshFields(usersTable);\n      await refreshFields(projectsTable);\n      await refreshFields(tasksTable);\n\n      userNameField = getFieldByName(usersTable.fields, 'User Name');\n      projectNameField = getFieldByName(projectsTable.fields, 'Project Name');\n      taskNameField = getFieldByName(tasksTable.fields, 'Task Name');\n\n      const projectLinkField = await createField(projectsTable.id, {\n        name: 'Project -> User',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: usersTable.id,\n        },\n      });\n      projectLinkFieldId = projectLinkField.id;\n\n      await refreshFields(projectsTable);\n      await refreshFields(usersTable);\n\n      projectOwnerLookupField = await createField(projectsTable.id, {\n        name: 'Project Owner (lookup)',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: usersTable.id,\n          linkFieldId: projectLinkFieldId,\n          lookupFieldId: userNameField.id,\n        } as ILookupOptionsRo,\n      });\n\n      await refreshFields(projectsTable);\n\n      const taskLinkField = await createField(tasksTable.id, {\n        name: 'Task -> Project',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: projectsTable.id,\n        },\n      });\n      taskLinkFieldId = taskLinkField.id;\n\n      await refreshFields(tasksTable);\n      await refreshFields(projectsTable);\n\n      taskOwnerLookupField = await createField(tasksTable.id, {\n        name: 'Task Project Owner (lookup)',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: projectsTable.id,\n          linkFieldId: taskLinkFieldId,\n          lookupFieldId: projectOwnerLookupField.id,\n        } as ILookupOptionsRo,\n      });\n\n      await refreshFields(tasksTable);\n\n      const createdUsers = await createRecords(usersTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [userNameField.id]: 'Alice',\n            },\n          },\n        ],\n      });\n      userRecordId = createdUsers.records[0].id;\n\n      const createdProjects = await createRecords(projectsTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [projectNameField.id]: 'Project Alpha',\n            },\n          },\n        ],\n      });\n      projectRecordId = createdProjects.records[0].id;\n\n      await updateRecordByApi(projectsTable.id, projectRecordId, projectLinkFieldId, {\n        id: userRecordId,\n      });\n\n      const createdTasks = await createRecords(tasksTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [taskNameField.id]: 'Task 1',\n            },\n          },\n        ],\n      });\n      taskRecordId = createdTasks.records[0].id;\n\n      await updateRecordByApi(tasksTable.id, taskRecordId, taskLinkFieldId, {\n        id: projectRecordId,\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, tasksTable.id);\n      await permanentDeleteTable(baseId, projectsTable.id);\n      await permanentDeleteTable(baseId, usersTable.id);\n    });\n\n    it('should recompute nested lookup values after relinking', async () => {\n      let taskRecord = await getRecord(tasksTable.id, taskRecordId);\n      expect(taskRecord.fields[taskOwnerLookupField.id]).toEqual('Alice');\n\n      await updateRecordByApi(tasksTable.id, taskRecordId, taskLinkFieldId, null);\n\n      taskRecord = await getRecord(tasksTable.id, taskRecordId);\n      expect(taskRecord.fields[taskOwnerLookupField.id]).toBeUndefined();\n\n      await updateRecordByApi(tasksTable.id, taskRecordId, taskLinkFieldId, {\n        id: projectRecordId,\n      });\n\n      taskRecord = await getRecord(tasksTable.id, taskRecordId);\n      expect(taskRecord.fields[taskOwnerLookupField.id]).toEqual('Alice');\n    });\n  });\n\n  describe('lookup filter', () => {\n    const itV2OverrideOnly =\n      process.cwd().includes('/enterprise/backend-ee') && process.env.FORCE_V2_ALL === 'true'\n        ? it\n        : it.skip;\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {});\n      table2 = await createTable(baseId, {});\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should update a simple lookup field', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n        },\n      });\n\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n      expect(record.fields[lookupField.id]).toEqual(['B1']);\n    });\n\n    it('should create a lookup field with filter', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const symLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: table2.records.map((r, i) => ({\n          id: r.id,\n          fields: {\n            [table2.fields[0].id]: `B${i + 1}`,\n            [symLinkFieldId]: table1.records[0].id,\n          },\n        })),\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: table2.fields[0].id,\n                value: 'B1',\n                operator: 'isNot',\n              },\n            ],\n          },\n        },\n      });\n\n      const table1Records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records.records[0].fields[lookupField.id]).toEqual(['B2', 'B3']);\n    });\n\n    it('should create a many-many lookup field with filter', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const symLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: table2.records.map((r, i) => ({\n          id: r.id,\n          fields: {\n            [table2.fields[0].id]: `B${i + 1}`,\n            [symLinkFieldId]: [table1.records[0].id],\n          },\n        })),\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: table2.fields[0].id,\n                value: 'B1',\n                operator: 'isNot',\n              },\n            ],\n          },\n        },\n      });\n\n      const table1Records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records.records[0].fields[lookupField.id]).toEqual(['B2', 'B3']);\n    });\n\n    itV2OverrideOnly(\n      'should sync lookup filter option values when referenced select option names change',\n      async () => {\n        const statusField = await createField(table2.id, {\n          name: 'Status',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'cho_active', name: 'Active', color: Colors.Green },\n              { id: 'cho_closed', name: 'Closed', color: Colors.Blue },\n            ],\n          },\n        });\n\n        const linkField = await createField(table1.id, {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: table2.id,\n          },\n        });\n\n        const lookupField = await createField(table1.id, {\n          name: 'Filtered Lookup',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: table2.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: table2.fields[0].id,\n            filter: {\n              conjunction: 'and',\n              filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }],\n            },\n          },\n        });\n\n        await convertField(table2.id, statusField.id, {\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'cho_active', name: 'Active Plus', color: Colors.Green },\n              { id: 'cho_closed', name: 'Closed', color: Colors.Blue },\n            ],\n          },\n        });\n\n        const refreshed = await getField(table1.id, lookupField.id);\n        const filter = (refreshed.lookupOptions as ILookupLinkOptions | undefined)?.filter as\n          | { filterSet?: Array<{ value?: unknown }> }\n          | undefined;\n\n        expect(filter?.filterSet?.[0]?.value).toBe('Active Plus');\n      }\n    );\n\n    it('should update a lookup field with filter', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const symLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: table2.records.map((r, i) => ({\n          id: r.id,\n          fields: {\n            [table2.fields[0].id]: `B${i + 1}`,\n            [symLinkFieldId]: table1.records[0].id,\n          },\n        })),\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: table2.fields[0].id,\n                value: 'B1',\n                operator: 'isNot',\n              },\n            ],\n          },\n        },\n      });\n\n      const table1RecordsBefore = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id }))\n        .data;\n      expect(table1RecordsBefore.records[0].fields[lookupField.id]).toEqual(['B2', 'B3']);\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: table2.records.map((r, i) => ({\n          id: r.id,\n          fields: {\n            [table2.fields[0].id]: `BB${i + 1}`,\n          },\n        })),\n      });\n\n      const table1RecordsAfter = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id }))\n        .data;\n      expect(table1RecordsAfter.records[0].fields[lookupField.id]).toEqual(['BB1', 'BB2', 'BB3']);\n    });\n\n    it('should update a lookup field with filter when add or remove records link', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n      const symLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: table2.fields[0].id,\n                value: 'B1',\n                operator: 'isNot',\n              },\n            ],\n          },\n        },\n      });\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table2.records[1].id,\n            fields: {\n              [table2.fields[0].id]: 'B2',\n              [symLinkFieldId]: table1.records[0].id,\n            },\n          },\n          {\n            id: table2.records[2].id,\n            fields: {\n              [table2.fields[0].id]: 'B3',\n              [symLinkFieldId]: table1.records[0].id,\n            },\n          },\n        ],\n      });\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table2.records[0].id,\n            fields: {\n              [table2.fields[0].id]: 'B1',\n              [symLinkFieldId]: table1.records[0].id,\n            },\n          },\n        ],\n      });\n\n      const table1Records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records.records[0].fields[lookupField.id]).toEqual(['B2', 'B3']);\n\n      // remove a link\n\n      await updateRecords(table2.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table2.records[0].id,\n            fields: {\n              [symLinkFieldId]: null,\n            },\n          },\n        ],\n      });\n\n      const table1Records2 = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records2.records[0].fields[lookupField.id]).toEqual(['B2', 'B3']);\n\n      // set it to exist a filtered value (key state!)\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[0].id,\n            fields: {\n              [linkField.id]: [{ id: table2.records[0].id }],\n            },\n          },\n        ],\n      });\n\n      // add a link in a multiple value link cell\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[0].id,\n            fields: {\n              [linkField.id]: [{ id: table2.records[0].id }, { id: table2.records[1].id }],\n            },\n          },\n        ],\n      });\n\n      const table1Records3 = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records3.records[0].fields[lookupField.id]).toEqual(['B2']);\n\n      // set it to filtered null\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[0].id,\n            fields: { [linkField.id]: [{ id: table2.records[0].id }] },\n          },\n        ],\n      });\n\n      const table1Records4 = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records4.records[0].fields[lookupField.id]).toBeUndefined();\n\n      // set it to null\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[0].id,\n            fields: { [linkField.id]: null },\n          },\n        ],\n      });\n\n      const table1Records5 = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records5.records[0].fields[lookupField.id]).toBeUndefined();\n    });\n\n    it('should update a many-many self-link lookup field', async () => {\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      });\n      const symLinkFieldId = (linkField.options as ILinkFieldOptions).symmetricFieldId as string;\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table1.fields[0].id,\n        },\n      });\n\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[0].id,\n            fields: {\n              [table1.fields[0].id]: 'B1',\n              [symLinkFieldId]: [table1.records[0].id],\n            },\n          },\n        ],\n      });\n      await updateRecords(table1.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: [\n          {\n            id: table1.records[1].id,\n            fields: {\n              [table1.fields[0].id]: 'B2',\n              [symLinkFieldId]: [table1.records[0].id],\n            },\n          },\n        ],\n      });\n\n      const table1Records = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(table1Records.records[0].fields[lookupField.id]).toEqual(['B1', 'B2']);\n    });\n\n    it('should update a lookup field with fiter when update statusField in filterSet', async () => {\n      const statusField = await createField(table2.id, {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [\n            { id: 'choX', name: 'x', color: Colors.Cyan },\n            { id: 'choY', name: 'y', color: Colors.Blue },\n          ],\n        },\n      });\n\n      const linkField = await createField(table1.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      const lookupField = await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: table2.fields[0].id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: statusField.id,\n                value: 'x',\n                operator: 'is',\n              },\n            ],\n          },\n        },\n      });\n\n      // update from table record\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'A1');\n      await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'x');\n\n      // set to table link\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      //  check lookup field\n      const record = await getRecord(table1.id, table1.records[0].id);\n      expect(record.fields[lookupField.id]).toEqual(['A1']);\n\n      //  update from table record\n      await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'y');\n      console.log('e2euno tablel2 end');\n\n      //  check lookup field\n      const recordAfter = await getRecord(table1.id, table1.records[0].id);\n      expect(recordAfter.fields[lookupField.id]).toBeUndefined();\n    });\n  });\n\n  describe('conditional lookup chains', () => {\n    const normalizeLookupValues = (value: unknown): unknown[] | undefined => {\n      if (value === undefined) {\n        return undefined;\n      }\n      const normalized: unknown[] = [];\n      const collect = (item: unknown) => {\n        if (Array.isArray(item)) {\n          item.forEach(collect);\n        } else {\n          normalized.push(item);\n        }\n      };\n      collect(value);\n      return normalized;\n    };\n\n    let leaf: ITableFullVo;\n    let middle: ITableFullVo;\n    let root: ITableFullVo;\n\n    let middleLinkToLeaf: IFieldVo;\n    let leafNameFieldId: string;\n    let leafScoreFieldId: string;\n    let middleCategoryFieldId: string;\n    let rootCategoryFilterFieldId: string;\n\n    let middleLeafNameLookup: IFieldVo;\n    let middleLeafScoreLookup: IFieldVo;\n    let middleLeafScoreRollup: IFieldVo;\n\n    let rootConditionalNameLookup: IFieldVo;\n    let rootConditionalScoreLookup: IFieldVo;\n    let rootConditionalRollup: IFieldVo;\n\n    let hardwareRootRecordId: string;\n    let softwareRootRecordId: string;\n\n    let categoryMatchFilter: IFilter;\n\n    beforeAll(async () => {\n      leaf = await createTable(baseId, {\n        name: 'ConditionalLeaf',\n        fields: [\n          { name: 'LeafName', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'LeafScore', type: FieldType.Number } as IFieldRo,\n        ],\n        records: [\n          { fields: { LeafName: 'Alpha', LeafScore: 10 } },\n          { fields: { LeafName: 'Beta', LeafScore: 20 } },\n          { fields: { LeafName: 'Gamma', LeafScore: 30 } },\n        ],\n      });\n      leafNameFieldId = leaf.fields.find((field) => field.name === 'LeafName')!.id;\n      leafScoreFieldId = leaf.fields.find((field) => field.name === 'LeafScore')!.id;\n\n      middle = await createTable(baseId, {\n        name: 'ConditionalMiddle',\n        fields: [{ name: 'Category', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Category: 'Hardware' } },\n          { fields: { Category: 'Hardware' } },\n          { fields: { Category: 'Software' } },\n        ],\n      });\n      middleCategoryFieldId = middle.fields.find((field) => field.name === 'Category')!.id;\n\n      middleLinkToLeaf = await createField(middle.id, {\n        name: 'LeafLink',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: leaf.id,\n        },\n      });\n\n      middleLeafNameLookup = await createField(middle.id, {\n        name: 'LeafNames',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: leaf.id,\n          linkFieldId: middleLinkToLeaf.id,\n          lookupFieldId: leafNameFieldId,\n        } as ILookupOptionsRo,\n      });\n\n      middleLeafScoreLookup = await createField(middle.id, {\n        name: 'LeafScores',\n        type: FieldType.Number,\n        isLookup: true,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 0,\n          },\n        },\n        lookupOptions: {\n          foreignTableId: leaf.id,\n          linkFieldId: middleLinkToLeaf.id,\n          lookupFieldId: leafScoreFieldId,\n        } as ILookupOptionsRo,\n      });\n\n      middleLeafScoreRollup = await createField(middle.id, {\n        name: 'LeafScoreTotal',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: leaf.id,\n          linkFieldId: middleLinkToLeaf.id,\n          lookupFieldId: leafScoreFieldId,\n        },\n      } as IFieldRo);\n\n      // Connect middle records to leaf records for lookup resolution\n      await updateRecordByApi(middle.id, middle.records[0].id, middleLinkToLeaf.id, [\n        { id: leaf.records[0].id },\n      ]);\n      await updateRecordByApi(middle.id, middle.records[1].id, middleLinkToLeaf.id, [\n        { id: leaf.records[1].id },\n      ]);\n      await updateRecordByApi(middle.id, middle.records[2].id, middleLinkToLeaf.id, [\n        { id: leaf.records[2].id },\n      ]);\n\n      root = await createTable(baseId, {\n        name: 'ConditionalRoot',\n        fields: [{ name: 'CategoryFilter', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { CategoryFilter: 'Hardware' } },\n          { fields: { CategoryFilter: 'Software' } },\n        ],\n      });\n      rootCategoryFilterFieldId = root.fields.find((field) => field.name === 'CategoryFilter')!.id;\n      hardwareRootRecordId = root.records[0].id;\n      softwareRootRecordId = root.records[1].id;\n\n      categoryMatchFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: middleCategoryFieldId,\n            operator: 'is',\n            value: { type: 'field', fieldId: rootCategoryFilterFieldId },\n          },\n        ],\n      };\n\n      rootConditionalNameLookup = await createField(root.id, {\n        name: 'FilteredLeafNames',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: middle.id,\n          lookupFieldId: middleLeafNameLookup.id,\n          filter: categoryMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      rootConditionalScoreLookup = await createField(root.id, {\n        name: 'FilteredLeafScores',\n        type: FieldType.Number,\n        isLookup: true,\n        isConditionalLookup: true,\n        options: {\n          formatting: {\n            type: NumberFormattingType.Decimal,\n            precision: 0,\n          },\n        },\n        lookupOptions: {\n          foreignTableId: middle.id,\n          lookupFieldId: middleLeafScoreLookup.id,\n          filter: categoryMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      rootConditionalRollup = await createField(root.id, {\n        name: 'FilteredLeafScoreSum',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: middle.id,\n          lookupFieldId: middleLeafScoreRollup.id,\n          expression: 'sum({values})',\n          filter: categoryMatchFilter,\n        } as IConditionalRollupFieldOptions,\n      } as IFieldRo);\n\n      // Link root records to the appropriate middle records\n      const rootLinkToMiddle = await createField(root.id, {\n        name: 'MiddleLink',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: middle.id,\n        },\n      });\n      await updateRecordByApi(root.id, hardwareRootRecordId, rootLinkToMiddle.id, [\n        { id: middle.records[0].id },\n        { id: middle.records[1].id },\n      ]);\n      await updateRecordByApi(root.id, softwareRootRecordId, rootLinkToMiddle.id, [\n        { id: middle.records[2].id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, root.id);\n      await permanentDeleteTable(baseId, middle.id);\n      await permanentDeleteTable(baseId, leaf.id);\n    });\n\n    it('should resolve multi-layer conditional lookup returning text values', async () => {\n      const hardwareRecord = await getRecord(root.id, hardwareRootRecordId);\n      const softwareRecord = await getRecord(root.id, softwareRootRecordId);\n\n      expect(normalizeLookupValues(hardwareRecord.fields[rootConditionalNameLookup.id])).toEqual([\n        'Alpha',\n        'Beta',\n      ]);\n      expect(normalizeLookupValues(softwareRecord.fields[rootConditionalNameLookup.id])).toEqual([\n        'Gamma',\n      ]);\n    });\n\n    it('should resolve multi-layer conditional lookup returning number values', async () => {\n      const hardwareRecord = await getRecord(root.id, hardwareRootRecordId);\n      const softwareRecord = await getRecord(root.id, softwareRootRecordId);\n\n      expect(normalizeLookupValues(hardwareRecord.fields[rootConditionalScoreLookup.id])).toEqual([\n        10, 20,\n      ]);\n      expect(normalizeLookupValues(softwareRecord.fields[rootConditionalScoreLookup.id])).toEqual([\n        30,\n      ]);\n    });\n\n    it('should compute conditional rollup values from nested lookups', async () => {\n      const hardwareRecord = await getRecord(root.id, hardwareRootRecordId);\n      const softwareRecord = await getRecord(root.id, softwareRootRecordId);\n\n      expect(hardwareRecord.fields[rootConditionalRollup.id]).toEqual(30);\n      expect(softwareRecord.fields[rootConditionalRollup.id]).toEqual(30);\n    });\n  });\n\n  describe('lookup of multi-value datetime used inside formulas', () => {\n    let projectTable: ITableFullVo;\n    let contractTable: ITableFullVo;\n    let projectNameField: IFieldVo;\n    let contractNameField: IFieldVo;\n    let contractStartField: IFieldVo;\n    let linkField: IFieldVo;\n    let lookupField: IFieldVo;\n    let formulaField: IFieldVo;\n    let projectRecordId: string;\n    const contractRecordIds: string[] = [];\n\n    beforeAll(async () => {\n      contractTable = await createTable(baseId, {\n        name: 'lookup-contracts',\n        fields: [\n          { name: 'Contract Name', type: FieldType.SingleLineText, options: {} },\n          {\n            name: 'Contract Start',\n            type: FieldType.Date,\n            options: {\n              formatting: {\n                date: 'YYYY-MM-DD',\n                time: TimeFormatting.None,\n                timeZone: 'Asia/Shanghai',\n              },\n            },\n          },\n        ],\n      });\n\n      projectTable = await createTable(baseId, {\n        name: 'lookup-projects',\n        fields: [{ name: 'Project Name', type: FieldType.SingleLineText, options: {} }],\n      });\n\n      await updateTableFields(contractTable);\n      await updateTableFields(projectTable);\n\n      contractNameField = contractTable.fields.find((f) => f.name === 'Contract Name')!;\n      contractStartField = contractTable.fields.find((f) => f.name === 'Contract Start')!;\n      projectNameField = projectTable.fields.find((f) => f.name === 'Project Name')!;\n\n      linkField = await createField(projectTable.id, {\n        name: 'Contracts',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: contractTable.id,\n        },\n      });\n\n      const symmetricLinkFieldId = (linkField.options as ILinkFieldOptions)\n        .symmetricFieldId as string;\n\n      await updateTableFields(projectTable);\n      await updateTableFields(contractTable);\n\n      lookupField = await createField(projectTable.id, {\n        name: 'Contract Starts',\n        type: FieldType.Date,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: contractTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: contractStartField.id,\n        },\n      });\n\n      const formulaExpression = `\"prefix-\" & {${lookupField.id}}`;\n      formulaField = await createField(projectTable.id, {\n        name: 'Lookup Path',\n        type: FieldType.Formula,\n        options: { expression: formulaExpression },\n      });\n\n      await updateTableFields(projectTable);\n\n      const projectRecords = await createRecords(projectTable.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [projectNameField.id]: 'Project Alpha',\n            },\n          },\n        ],\n      });\n      projectRecordId = projectRecords.records[0].id;\n\n      const contractRecords = await createRecords(contractTable.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [contractNameField.id]: 'Contract A',\n              [contractStartField.id]: '2024-01-10T00:00:00.000Z',\n            },\n          },\n          {\n            fields: {\n              [contractNameField.id]: 'Contract B',\n              [contractStartField.id]: '2024-02-15T00:00:00.000Z',\n            },\n          },\n        ],\n      });\n\n      contractRecordIds.push(...contractRecords.records.map((r) => r.id));\n\n      await updateRecords(contractTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n        records: contractRecordIds.map((id) => ({\n          id,\n          fields: {\n            [symmetricLinkFieldId]: [projectRecordId],\n          },\n        })),\n      });\n    });\n\n    afterAll(async () => {\n      if (projectTable?.id) {\n        await permanentDeleteTable(baseId, projectTable.id);\n      }\n      if (contractTable?.id) {\n        await permanentDeleteTable(baseId, contractTable.id);\n      }\n    });\n\n    it('should return records when multi-value datetime lookup feeds a string formula', async () => {\n      const recordsVo = (await getRecords(projectTable.id, { fieldKeyType: FieldKeyType.Id })).data;\n      const projectRecord = recordsVo.records.find((r) => r.id === projectRecordId);\n      expect(projectRecord).toBeDefined();\n\n      const lookupValue = projectRecord!.fields[lookupField.id];\n      expect(Array.isArray(lookupValue)).toBe(true);\n      expect(lookupValue).toHaveLength(2);\n      expect(typeof (lookupValue as any[])[0]).toBe('string');\n\n      const formulaValue = projectRecord!.fields[formulaField.id];\n      expect(typeof formulaValue).toBe('string');\n      expect(formulaValue as string).toContain('prefix-');\n\n      await updateRecordByApi(\n        projectTable.id,\n        projectRecordId,\n        projectNameField.id,\n        'Project Beta'\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/mail.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ISetSettingMailTransportConfigRo, ITestMailTransportConfigRo } from '@teable/openapi';\nimport {\n  EmailVerifyCodeType,\n  MailTransporterType,\n  MailType,\n  setSettingMailTransportConfig,\n  SettingKey,\n  testMailTransportConfig,\n} from '@teable/openapi';\nimport dayjs from 'dayjs';\nimport { MailSenderService } from '../src/features/mail-sender/mail-sender.service';\nimport { initApp } from './utils/init-app';\n\nconst mockMailTransportConfig = {\n  sender: 'xxx',\n  senderName: 'TestSender',\n  host: 'smtp.qq.com',\n  port: 465,\n  secure: true,\n  auth: {\n    user: 'xxx',\n    pass: 'xxx',\n  },\n};\n\nconst mockMailTo = 'demo@teable.io';\n\nconst mockMailOptions = () => ({\n  to: mockMailTo,\n  title: 'Test',\n  message: 'hi, this is a test mail at ' + dayjs().format('YYYY-MM-DD HH:mm:ss'),\n  buttonUrl: 'https://teable.ai',\n  buttonText: 'Text',\n});\n\ndescribe.skip('Mail sender  (e2e)', () => {\n  let app: INestApplication;\n  let mailSenderService: MailSenderService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    mailSenderService = app.get(MailSenderService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('should test mail transporter', async () => {\n    const ro: ITestMailTransportConfigRo = {\n      to: mockMailTo,\n      message: mockMailOptions().message,\n      transportConfig: mockMailTransportConfig,\n    };\n\n    await testMailTransportConfig(ro);\n  });\n\n  it('should send mail by transport config', async () => {\n    const commonEmailOptions = await mailSenderService.htmlEmailOptions(mockMailOptions());\n    const mailOptions = {\n      transporterName: MailTransporterType.Notify,\n      to: mockMailTo,\n      ...commonEmailOptions,\n    };\n\n    const sendRes = await mailSenderService.sendMail(mailOptions, {\n      transportConfig: mockMailTransportConfig,\n    });\n    expect(sendRes).toBe(true);\n  });\n\n  it('should save setting mail transporter and send mail', async () => {\n    const ro: ISetSettingMailTransportConfigRo = {\n      name: SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG,\n      transportConfig: mockMailTransportConfig,\n    };\n\n    const setRes = await setSettingMailTransportConfig(ro);\n    expect(setRes.data).toMatchObject({\n      ...ro,\n      transportConfig: {\n        ...ro.transportConfig,\n        auth: {\n          ...ro.transportConfig.auth,\n          pass: '',\n        },\n      },\n    });\n\n    const commonEmailOptions = await mailSenderService.htmlEmailOptions(mockMailOptions());\n    const mailOptions = {\n      ...commonEmailOptions,\n      transporterName: MailTransporterType.Notify,\n      to: mockMailTo,\n    };\n    const sendRes = await mailSenderService.sendMail(mailOptions, {\n      transporterName: MailTransporterType.Notify,\n      type: MailType.NotifyMerge,\n    });\n    expect(sendRes).toBe(true);\n  });\n\n  it('should send notify merge mail', async () => {\n    const ro: ISetSettingMailTransportConfigRo = {\n      name: SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG,\n      transportConfig: mockMailTransportConfig,\n    };\n\n    const setRes = await setSettingMailTransportConfig(ro);\n    expect(setRes.data).toMatchObject({\n      ...ro,\n      transportConfig: {\n        ...ro.transportConfig,\n        auth: {\n          ...ro.transportConfig.auth,\n          pass: '',\n        },\n      },\n    });\n\n    const htmlEmailOptions = await mailSenderService.htmlEmailOptions(mockMailOptions());\n    const mailOptions1 = {\n      ...htmlEmailOptions,\n      transporterName: MailTransporterType.Notify,\n      to: mockMailTo,\n    };\n    const promises = [];\n    const promise1 = mailSenderService.sendMail(mailOptions1, {\n      transporterName: MailTransporterType.Notify,\n      type: MailType.Notify,\n    });\n    promises.push(promise1);\n    const commonEmailOptions = await mailSenderService.commonEmailOptions(mockMailOptions());\n    const mailOptions2 = {\n      ...commonEmailOptions,\n      transporterName: MailTransporterType.Notify,\n      to: mockMailTo,\n    };\n    const promise2 = mailSenderService.sendMail(mailOptions2, {\n      transporterName: MailTransporterType.Notify,\n      type: MailType.Notify,\n    });\n    promises.push(promise2);\n    const emailVerifyCodeEmailOptions = await mailSenderService.sendEmailVerifyCodeEmailOptions({\n      code: '123456',\n      expiresIn: '10 minutes',\n      type: EmailVerifyCodeType.ChangeEmail,\n    });\n    const mailOptions3 = {\n      ...emailVerifyCodeEmailOptions,\n      transporterName: MailTransporterType.Notify,\n      to: mockMailTo,\n    };\n    const promise3 = mailSenderService.sendMail(mailOptions3, {\n      transporterName: MailTransporterType.Notify,\n      type: MailType.Notify,\n    });\n    promises.push(promise3);\n\n    await Promise.all(promises);\n\n    await new Promise((resolve) => setTimeout(resolve, 1000 * 2));\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/nested-lookup-formula.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILookupOptionsRo, INumberFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship, NumberFormattingType } from '@teable/core';\nimport {\n  createField,\n  createTable,\n  getFields,\n  permanentDeleteTable,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\n/**\n * Covers: lookup(Table3 -> Table2) of a lookup(Table2 -> Table1) whose target is a Formula on Table1\n * Ensures nested CTEs are generated and NULL polymorphic issues are avoided in PG.\n */\ndescribe('Nested Lookup via Formula target (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('returns values for lookup->lookup(formula) chain', async () => {\n    // Table1 with a number and a formula that references the number\n    const numberField: IFieldRo = {\n      name: 'Count',\n      type: FieldType.Number,\n      options: { formatting: { type: 'decimal', precision: 0 } } as INumberFieldOptions,\n    };\n\n    const table1 = await createTable(baseId, {\n      name: 'T1',\n      fields: [numberField],\n      records: [{ fields: { Count: 10 } }, { fields: { Count: 20 } }],\n    });\n    const countFieldId = table1.fields.find((f) => f.name === 'Count')!.id;\n    const answerField = await createField(table1.id, {\n      name: 'Answer',\n      type: FieldType.Formula,\n      options: { expression: `{${countFieldId}}` },\n    } as any);\n\n    // Table2 with link -> T1 and lookup of T1.Answer (formula)\n    const table2 = await createTable(baseId, { name: 'T2', fields: [], records: [{ fields: {} }] });\n    const link2to1 = await createField(table2.id, {\n      name: 'Link T1',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyMany, foreignTableId: table1.id },\n    });\n    const lookup2: IFieldRo = {\n      name: 'Lookup Answer',\n      type: FieldType.Formula,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: table1.id,\n        linkFieldId: link2to1.id,\n        lookupFieldId: (answerField as any).id,\n      } as ILookupOptionsRo,\n    } as any;\n    const table2Lookup = await createField(table2.id, lookup2);\n\n    // Table3 with link -> T2 and lookup of T2.Lookup Answer\n    const table3 = await createTable(baseId, { name: 'T3', fields: [], records: [{ fields: {} }] });\n    const link3to2 = await createField(table3.id, {\n      name: 'Link T2',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyMany, foreignTableId: table2.id },\n    });\n    const lookup3: IFieldRo = {\n      name: 'Nested Lookup',\n      type: FieldType.Formula,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: table2.id,\n        linkFieldId: link3to2.id,\n        lookupFieldId: table2Lookup.id,\n      } as ILookupOptionsRo,\n    } as any;\n    const table3Lookup = await createField(table3.id, lookup3);\n\n    // Establish relationships\n    await updateRecordByApi(table2.id, table2.records[0].id, link2to1.id, [\n      { id: table1.records[0].id },\n      { id: table1.records[1].id },\n    ]);\n    await updateRecordByApi(table3.id, table3.records[0].id, link3to2.id, [\n      { id: table2.records[0].id },\n    ]);\n\n    const res = await getRecords(table3.id, { fieldKeyType: FieldKeyType.Id });\n    const record = res.records[0];\n    const val = record.fields[table3Lookup.id];\n    expect(val).toEqual(expect.arrayContaining([10, 20]));\n\n    // Cleanup\n    await permanentDeleteTable(baseId, table3.id);\n    await permanentDeleteTable(baseId, table2.id);\n    await permanentDeleteTable(baseId, table1.id);\n  });\n\n  it('resolves lookup of a rollup-driven formula across the same link chain', async () => {\n    const projectTable = await createTable(baseId, {\n      name: 'Projects',\n      fields: [\n        {\n          name: 'Project Name',\n          type: FieldType.SingleLineText,\n          options: {},\n        },\n      ],\n      records: [{ fields: {} }],\n    });\n\n    const taskTable = await createTable(baseId, {\n      name: 'Tasks',\n      fields: [\n        {\n          name: 'Task Name',\n          type: FieldType.SingleLineText,\n          options: {},\n        },\n        {\n          name: 'Hours',\n          type: FieldType.Number,\n          options: {\n            formatting: {\n              type: NumberFormattingType.Decimal,\n              precision: 0,\n            },\n          },\n        },\n      ],\n      records: [{ fields: {} }, { fields: {} }],\n    });\n\n    try {\n      const projectNameFieldId = projectTable.fields.find((f) => f.name === 'Project Name')!.id;\n      const taskNameFieldId = taskTable.fields.find((f) => f.name === 'Task Name')!.id;\n      const hoursFieldId = taskTable.fields.find((f) => f.name === 'Hours')!.id;\n\n      await updateRecordByApi(\n        projectTable.id,\n        projectTable.records[0].id,\n        projectNameFieldId,\n        'Alpha'\n      );\n      await updateRecordByApi(taskTable.id, taskTable.records[0].id, taskNameFieldId, 'Design');\n      await updateRecordByApi(taskTable.id, taskTable.records[1].id, taskNameFieldId, 'Review');\n      await updateRecordByApi(taskTable.id, taskTable.records[0].id, hoursFieldId, 4);\n      await updateRecordByApi(taskTable.id, taskTable.records[1].id, hoursFieldId, 6);\n\n      const projectToTaskLink = await createField(projectTable.id, {\n        name: 'Tasks link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: taskTable.id,\n        },\n      });\n\n      const taskFieldsAfterLink = await getFields(taskTable.id);\n      const taskToProjectLink = taskFieldsAfterLink.find(\n        (field) =>\n          field.type === FieldType.Link &&\n          (field.options as { foreignTableId?: string }).foreignTableId === projectTable.id\n      );\n      expect(taskToProjectLink).toBeDefined();\n\n      const sumRollup = await createField(projectTable.id, {\n        name: 'Total Hours',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: taskTable.id,\n          linkFieldId: projectToTaskLink.id,\n          lookupFieldId: hoursFieldId,\n        },\n      });\n\n      const countRollup = await createField(projectTable.id, {\n        name: 'Task Count',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'counta({values})',\n        },\n        lookupOptions: {\n          foreignTableId: taskTable.id,\n          linkFieldId: projectToTaskLink.id,\n          lookupFieldId: hoursFieldId,\n        },\n      });\n\n      const rollupFormula = await createField(projectTable.id, {\n        name: 'Effort Index',\n        type: FieldType.Formula,\n        options: {\n          expression: `({${sumRollup.id}} + {${countRollup.id}}) / 2`,\n        },\n      } as unknown as IFieldRo);\n\n      const projectRollupLookup = await createField(taskTable.id, {\n        name: 'Project Effort',\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: projectTable.id,\n          linkFieldId: taskToProjectLink!.id,\n          lookupFieldId: rollupFormula.id,\n        },\n      } as unknown as IFieldRo);\n\n      await updateRecordByApi(projectTable.id, projectTable.records[0].id, projectToTaskLink.id, [\n        { id: taskTable.records[0].id },\n        { id: taskTable.records[1].id },\n      ]);\n\n      const res = await getRecords(taskTable.id, { fieldKeyType: FieldKeyType.Id });\n      expect(res.records).toHaveLength(2);\n      const expectedValue = (4 + 6 + 2) / 2;\n      for (const record of res.records) {\n        expect(record.fields[projectRollupLookup.id]).toBeCloseTo(expectedValue);\n      }\n    } finally {\n      await permanentDeleteTable(baseId, taskTable.id);\n      await permanentDeleteTable(baseId, projectTable.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/nested-lookup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILookupOptionsRo } from '@teable/core';\nimport { FieldKeyType, FieldType, NumberFormattingType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Nested Lookup Field (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Nested lookup field (lookup -> lookup -> number)', () => {\n    let table1: ITableFullVo; // Final table\n    let table2: ITableFullVo; // Intermediate table\n    let table3: ITableFullVo; // Main table\n    let linkField1: IFieldVo; // Link field from table2 to table1\n    let linkField2: IFieldVo; // Link field from table3 to table2\n    let lookupField1: IFieldVo; // Lookup field in table2 that looks up table1's number field\n    let nestedLookupField: IFieldVo; // Nested lookup field in table3 that looks up table2's lookup field\n\n    beforeEach(async () => {\n      // Create table1 (final table) - contains a number field\n      const numberFieldRo: IFieldRo = {\n        name: 'Count',\n        type: FieldType.Number,\n        options: {\n          formatting: { precision: 0, type: NumberFormattingType.Decimal },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Table1',\n        fields: [numberFieldRo],\n        records: [{ fields: { Count: 10 } }, { fields: { Count: 20 } }, { fields: { Count: 30 } }],\n      });\n\n      // Create table2 (intermediate table)\n      table2 = await createTable(baseId, {\n        name: 'Table2',\n        fields: [],\n        records: [{ fields: {} }, { fields: {} }],\n      });\n\n      // Create table3 (main table)\n      table3 = await createTable(baseId, {\n        name: 'Table3',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      // Create link field from table2 to table1\n      const linkFieldRo1: IFieldRo = {\n        name: 'Link to Table1',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      };\n\n      linkField1 = await createField(table2.id, linkFieldRo1);\n\n      // Create lookup field in table2 that looks up table1's number field\n      const lookupFieldRo1: IFieldRo = {\n        name: 'Lookup Count from Table1',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField1.id,\n          lookupFieldId: table1.fields.find((f) => f.name === 'Count')!.id,\n        } as ILookupOptionsRo,\n      };\n\n      lookupField1 = await createField(table2.id, lookupFieldRo1);\n\n      // Create link field from table3 to table2\n      const linkFieldRo2: IFieldRo = {\n        name: 'Link to Table2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      linkField2 = await createField(table3.id, linkFieldRo2);\n\n      // Create nested lookup field in table3 that looks up table2's lookup field\n      const nestedLookupFieldRo: IFieldRo = {\n        name: 'Nested Lookup Count',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField2.id,\n          lookupFieldId: lookupField1.id,\n        } as ILookupOptionsRo,\n      };\n\n      nestedLookupField = await createField(table3.id, nestedLookupFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n      await permanentDeleteTable(baseId, table3.id);\n    });\n\n    it('should generate correct CTE for nested lookup field', async () => {\n      // Establish relationships\n      // Link table2's first record to table1's first record\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField1.id, [\n        { id: table1.records[0].id },\n      ]);\n\n      // Link table2's second record to table1's second record\n      await updateRecordByApi(table2.id, table2.records[1].id, linkField1.id, [\n        { id: table1.records[1].id },\n      ]);\n\n      // Link table3's record to both table2 records\n      await updateRecordByApi(table3.id, table3.records[0].id, linkField2.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      // Get table3 records, should see nested lookup values\n      const records = await getRecords(table3.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(1);\n      const record = records.records[0];\n\n      // Verify nested lookup field value\n      const nestedLookupValue = record.fields[nestedLookupField.id];\n      console.log('Nested lookup value:', nestedLookupValue);\n\n      // Should contain Count values from table1: [10, 20]\n      expect(nestedLookupValue).toEqual(expect.arrayContaining([10, 20]));\n    });\n\n    it('should handle empty nested lookup correctly', async () => {\n      // Query without establishing any relationships\n      const records = await getRecords(table3.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(1);\n      const record = records.records[0];\n\n      // Verify nested lookup field value should be empty array or null/undefined\n      const nestedLookupValue = record.fields[nestedLookupField.id];\n      console.log('Empty nested lookup value:', nestedLookupValue);\n\n      expect(nestedLookupValue).toBeUndefined();\n    });\n\n    it('should handle partial nested lookup correctly', async () => {\n      // Establish partial relationships only\n      // Link table2's first record to table1's first record\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField1.id, [\n        { id: table1.records[0].id },\n      ]);\n\n      // Link table3's record only to table2's first record\n      await updateRecordByApi(table3.id, table3.records[0].id, linkField2.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      // Get table3 records\n      const records = await getRecords(table3.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(1);\n      const record = records.records[0];\n\n      // Verify nested lookup field value\n      const nestedLookupValue = record.fields[nestedLookupField.id];\n      console.log('Partial nested lookup value:', nestedLookupValue);\n\n      // Should contain only one value [10]\n      expect(nestedLookupValue).toEqual([10]);\n    });\n  });\n\n  describe('Three-level nested lookup (lookup -> lookup -> lookup -> text)', () => {\n    let table1: ITableFullVo; // Final table\n    let table2: ITableFullVo; // Intermediate table 1\n    let table3: ITableFullVo; // Intermediate table 2\n    let table4: ITableFullVo; // Main table\n    let linkField1: IFieldVo; // Link field from table2 to table1\n    let linkField2: IFieldVo; // Link field from table3 to table2\n    let linkField3: IFieldVo; // Link field from table4 to table3\n    let lookupField1: IFieldVo; // Lookup field in table2 that looks up table1's text\n    let lookupField2: IFieldVo; // Lookup field in table3 that looks up table2's lookup\n    let nestedLookupField: IFieldVo; // Nested lookup field in table4 that looks up table3's lookup\n\n    beforeEach(async () => {\n      // Create table1 (final table) - contains a text field\n      const textFieldRo: IFieldRo = {\n        name: 'Name',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'Table1',\n        fields: [textFieldRo],\n        records: [{ fields: { Name: 'Alpha' } }, { fields: { Name: 'Beta' } }],\n      });\n\n      // Create table2 (intermediate table 1)\n      table2 = await createTable(baseId, {\n        name: 'Table2',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      // Create table3 (intermediate table 2)\n      table3 = await createTable(baseId, {\n        name: 'Table3',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      // Create table4 (main table)\n      table4 = await createTable(baseId, {\n        name: 'Table4',\n        fields: [],\n        records: [{ fields: {} }],\n      });\n\n      // Create link and lookup fields\n      linkField1 = await createField(table2.id, {\n        name: 'Link to Table1',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table1.id,\n        },\n      });\n\n      lookupField1 = await createField(table2.id, {\n        name: 'Lookup Name from Table1',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField1.id,\n          lookupFieldId: table1.fields.find((f) => f.name === 'Name')!.id,\n        } as ILookupOptionsRo,\n      });\n\n      linkField2 = await createField(table3.id, {\n        name: 'Link to Table2',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n        },\n      });\n\n      lookupField2 = await createField(table3.id, {\n        name: 'Lookup Name from Table2',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          linkFieldId: linkField2.id,\n          lookupFieldId: lookupField1.id,\n        } as ILookupOptionsRo,\n      });\n\n      linkField3 = await createField(table4.id, {\n        name: 'Link to Table3',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table3.id,\n        },\n      });\n\n      nestedLookupField = await createField(table4.id, {\n        name: 'Three Level Lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table3.id,\n          linkFieldId: linkField3.id,\n          lookupFieldId: lookupField2.id,\n        } as ILookupOptionsRo,\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n      await permanentDeleteTable(baseId, table3.id);\n      await permanentDeleteTable(baseId, table4.id);\n    });\n\n    it('should handle three-level nested lookup correctly', async () => {\n      // Establish complete relationship chain\n      await updateRecordByApi(table2.id, table2.records[0].id, linkField1.id, [\n        { id: table1.records[0].id },\n        { id: table1.records[1].id },\n      ]);\n\n      await updateRecordByApi(table3.id, table3.records[0].id, linkField2.id, [\n        { id: table2.records[0].id },\n      ]);\n\n      await updateRecordByApi(table4.id, table4.records[0].id, linkField3.id, [\n        { id: table3.records[0].id },\n      ]);\n\n      // Get table4 records\n      const records = await getRecords(table4.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.records).toHaveLength(1);\n      const record = records.records[0];\n\n      // Verify three-level nested lookup field value\n      const nestedLookupValue = record.fields[nestedLookupField.id];\n      console.log('Three-level nested lookup value:', nestedLookupValue);\n\n      // Should contain Name values from table1\n      expect(nestedLookupValue).toEqual(expect.arrayContaining(['Alpha', 'Beta']));\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/not-null-validation.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ISelectFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  convertField,\n  deleteRecords,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Not null validation (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('reject missing values for not-null fields', () => {\n    let table: ITableFullVo;\n    const fieldIds: Record<string, string> = {};\n\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: `not-null-${Date.now()}` });\n      const existing = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n      if (existing.records.length) {\n        await deleteRecords(\n          table.id,\n          existing.records.map((r) => r.id)\n        );\n      }\n      const text = await createField(table.id, {\n        name: 'Text',\n        type: FieldType.SingleLineText,\n      });\n      const num = await createField(table.id, {\n        name: 'Number',\n        type: FieldType.Number,\n      });\n      const date = await createField(table.id, {\n        name: 'Date',\n        type: FieldType.Date,\n      });\n      const rating = await createField(table.id, {\n        name: 'Rating',\n        type: FieldType.Rating,\n      });\n      const select = await createField(table.id, {\n        name: 'Select',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ id: 'optA', name: 'A' }],\n        },\n      });\n\n      // Toggle notNull after creation (creation forbids notNull directly)\n      const updatedText = await convertField(table.id, text.id, { ...text, notNull: true });\n      const updatedNum = await convertField(table.id, num.id, { ...num, notNull: true });\n      const updatedDate = await convertField(table.id, date.id, { ...date, notNull: true });\n      const updatedRating = await convertField(table.id, rating.id, { ...rating, notNull: true });\n      const updatedSelect = await convertField(table.id, select.id, {\n        ...select,\n        notNull: true,\n        options: {\n          ...select.options,\n          choices: [{ id: 'optA', name: 'A' }],\n        } as ISelectFieldOptions,\n      });\n\n      fieldIds.text = updatedText.id;\n      fieldIds.num = updatedNum.id;\n      fieldIds.date = updatedDate.id;\n      fieldIds.rating = updatedRating.id;\n      fieldIds.select = updatedSelect.id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should return validation error when required fields are missing', async () => {\n      await createRecords(\n        table.id,\n        {\n          fieldKeyType: FieldKeyType.Id,\n          records: [{ fields: {} }],\n        },\n        400\n      );\n    });\n\n    it('should succeed when all required fields are provided', async () => {\n      const { records } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [fieldIds.text]: 'hello',\n              [fieldIds.num]: 123,\n              [fieldIds.date]: new Date().toISOString(),\n              [fieldIds.rating]: 3,\n              [fieldIds.select]: 'A',\n            },\n          },\n        ],\n      });\n\n      expect(records).toHaveLength(1);\n      expect(records[0].fields[fieldIds.text]).toBe('hello');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/number-precision.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport {\n  CellFormat,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  type LinkFieldCore,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  deleteTable,\n  getRecord,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\nconst waitForRecalc = (ms = 400) => new Promise((resolve) => setTimeout(resolve, ms));\n\ndescribe('Number precision (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let table: ITableFullVo | undefined;\n  let childTable: ITableFullVo | undefined;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterEach(async () => {\n    if (table) {\n      await deleteTable(baseId, table.id);\n      table = undefined;\n    }\n    if (childTable) {\n      await deleteTable(baseId, childTable.id);\n      childTable = undefined;\n    }\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('keeps decimal precision on formula fields and respects string formatting', async () => {\n    table = await createTable(baseId, {\n      name: 'precision-formula',\n      fields: [\n        {\n          name: 'Hours',\n          type: FieldType.Number,\n          options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n        },\n        {\n          name: 'Rate',\n          type: FieldType.Number,\n          options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n        },\n      ],\n      records: [{ fields: { Hours: 10.1, Rate: 3 } }],\n    });\n\n    const grossField = await createField(table.id, {\n      name: 'GrossPay',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${table.fields[0].id}} * {${table.fields[1].id}}`,\n        formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      },\n    });\n    await waitForRecalc();\n\n    const record = await getRecord(table.id, table.records[0].id);\n    const grossValue = record.fields[grossField.id] as number;\n    expect(grossValue).toBeCloseTo(30.3, 8);\n\n    const textRecord = await getRecord(table.id, table.records[0].id, CellFormat.Text);\n    expect(textRecord.fields[grossField.id]).toBe('30.30');\n  });\n\n  it('keeps rollup sums stable with decimal inputs', async () => {\n    table = await createTable(baseId, {\n      name: 'precision-invoice',\n      fields: [{ name: 'Invoice', type: FieldType.SingleLineText }],\n      records: [{ fields: { Invoice: 'INV-001' } }],\n    });\n\n    childTable = await createTable(baseId, {\n      name: 'precision-items',\n      fields: [\n        { name: 'Item', type: FieldType.SingleLineText },\n        {\n          name: 'Amount',\n          type: FieldType.Number,\n          options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n        },\n      ],\n      records: [\n        { fields: { Item: 'Line 1', Amount: 10.1 } },\n        { fields: { Item: 'Line 2', Amount: 20.2 } },\n      ],\n    });\n\n    const linkField = (await createField(childTable.id, {\n      name: 'InvoiceLink',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: table.id,\n      },\n    })) as LinkFieldCore;\n\n    const symmetricFieldId = linkField.options.symmetricFieldId;\n    if (!symmetricFieldId) {\n      throw new Error('symmetric field not created');\n    }\n\n    const rollupField = await createField(table.id, {\n      name: 'Total',\n      type: FieldType.Rollup,\n      options: { expression: 'sum({values})' },\n      lookupOptions: {\n        foreignTableId: childTable.id,\n        linkFieldId: symmetricFieldId,\n        lookupFieldId: childTable.fields.find((f) => f.name === 'Amount')!.id,\n      },\n    });\n\n    for (const record of childTable.records) {\n      await updateRecordByApi(childTable.id, record.id, linkField.id, { id: table.records[0].id });\n    }\n    await waitForRecalc();\n\n    const invoiceRecord = await getRecord(table.id, table.records[0].id);\n    const totalValue = invoiceRecord.fields[rollupField.id] as number;\n    expect(totalValue).toBeCloseTo(30.3, 8);\n\n    const totalText = await getRecord(table.id, table.records[0].id, CellFormat.Text);\n    expect(totalText.fields[rollupField.id]).toBe('30.30');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/oauth-server.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport crypto from 'crypto';\nimport type { INestApplication } from '@nestjs/common';\nimport { HttpError } from '@teable/core';\nimport {\n  CREATE_BASE,\n  CREATE_SPACE,\n  CREATE_TABLE,\n  GET_TABLE_LIST,\n  GET_TRASH_ITEMS,\n  PERMANENT_DELETE_BASE,\n  PERMANENT_DELETE_SPACE,\n  REVOKE_TOKEN,\n  ResourceType,\n  generateOAuthSecret,\n  oauthCreate,\n  oauthDelete,\n  revokeAccess,\n  urlBuilder,\n} from '@teable/openapi';\nimport type {\n  ICreateBaseVo,\n  ICreateSpaceVo,\n  IGetBaseAllVo,\n  ITableListVo,\n  ITableVo,\n  ITrashVo,\n  OAuthCreateVo,\n} from '@teable/openapi';\nimport type { AxiosInstance, AxiosResponse } from 'axios';\nimport axiosInstance from 'axios';\nimport { omit } from 'lodash';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\nconst oauthData = {\n  name: 'test',\n  redirectUris: ['http://localhost:3000/callback'],\n  scopes: ['user|email_read'],\n  homepage: 'http://localhost:3000',\n};\n\nconst getAuthorize = async (axios: AxiosInstance, oauth: OAuthCreateVo, state?: string) => {\n  const res = await axios.get(\n    `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&scope=${oauth.scopes?.join(' ')}${state ? '&state=' + state : ''}`,\n    {\n      maxRedirects: 0,\n    }\n  );\n\n  const url = new URL(res.headers.location, oauth.homepage);\n  return {\n    transactionID: url.searchParams.get('transaction_id') as string | null,\n    code: url.searchParams.get('code') as string | null,\n  };\n};\n\nconst decision = async (axios: AxiosInstance, transactionID: string, cancel?: string) => {\n  return axios.post(\n    `/oauth/decision`,\n    {\n      transaction_id: transactionID,\n      cancel,\n    },\n    {\n      maxRedirects: 0,\n      headers: {\n        // eslint-disable-next-line @typescript-eslint/naming-convention\n        'Content-Type': 'application/x-www-form-urlencoded',\n      },\n    }\n  );\n};\nconst testEmail = `oauth-server+${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;\n\ndescribe('OpenAPI OAuthController (e2e)', () => {\n  let app: INestApplication;\n  let oauth: OAuthCreateVo;\n  let axios: AxiosInstance;\n  let spaceId: string;\n  let baseId: string;\n  let anonymousAxios: AxiosInstance;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const newUserAxios = await createNewUserAxios({\n      email: testEmail,\n      password: '12345678',\n    });\n    axios = axiosInstance.create({\n      baseURL: `${appCtx.appUrl}/api`,\n      headers: {\n        cookie: newUserAxios.defaults.headers.Cookie,\n      },\n      validateStatus: function (status) {\n        return (status >= 200 && status < 209) || status === 302;\n      },\n    });\n\n    anonymousAxios = axiosInstance.create({\n      baseURL: `${appCtx.appUrl}/api`,\n    });\n\n    const interceptorsRes = (response: AxiosResponse<any, any>) => {\n      return response;\n    };\n    const interceptorsError = (error: any) => {\n      const { data, status } = error?.response || {};\n      throw new HttpError(data || error?.message || 'no response from server', status || 500);\n    };\n\n    axios.interceptors.response.use(interceptorsRes, interceptorsError);\n    anonymousAxios.interceptors.response.use(interceptorsRes, interceptorsError);\n  });\n\n  beforeEach(async () => {\n    const res = await oauthCreate(oauthData);\n    oauth = res.data;\n    const spaceRes = await axios.post<ICreateSpaceVo>(CREATE_SPACE, {\n      name: 'test space',\n    });\n    spaceId = spaceRes.data.id;\n\n    const baseRes = await axios.post<ICreateBaseVo>(CREATE_BASE, {\n      name: 'test base',\n      spaceId,\n    });\n    baseId = baseRes.data.id;\n  });\n\n  afterEach(async () => {\n    await oauthDelete(oauth.clientId);\n    await axios.delete<null>(urlBuilder(PERMANENT_DELETE_BASE, { baseId }));\n    await axios.delete<null>(urlBuilder(PERMANENT_DELETE_SPACE, { spaceId }));\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('/api/oauth/authorize (GET)', async () => {\n    const res = await axios.get(\n      `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUris[0]}&scope=${oauth.scopes?.join(' ')}`,\n      { maxRedirects: 0 }\n    );\n    expect(res.status).toBe(302);\n    expect(res.headers.location).toContain(`/oauth/decision?transaction_id=`);\n  });\n\n  it('/api/oauth/authorize (GET) - redirect_uri invalid', async () => {\n    const error = await getError(() =>\n      axios.get(\n        `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=http://localhost:3000/callback-invalid&scope=user|email_read`,\n        { maxRedirects: 0 }\n      )\n    );\n    expect(error?.status).toBe(401);\n  });\n\n  it('/api/oauth/authorize (GET) - scope invalid', async () => {\n    const error = await getError(() =>\n      axios.get(\n        `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUris[0]}&scope=dddd`,\n        { maxRedirects: 0 }\n      )\n    );\n    expect(error?.status).toBe(400);\n  });\n\n  it('/api/oauth/decision (POST)', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const ensure = await decision(axios, transactionID!);\n    expect(ensure.status).toBe(302);\n    expect(ensure.headers.location).toContain(`${oauth.redirectUris[0]}?code=`);\n    // Trust Authorized\n    const { code } = await getAuthorize(axios, oauth);\n    expect(code).not.toBeNull();\n  });\n\n  it('/api/oauth/decision (POST) - state', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth, '123456');\n    const ensure = await decision(axios, transactionID!);\n    expect(ensure.status).toBe(302);\n    expect(ensure.headers.location).toContain(`${oauth.redirectUris[0]}?code=`);\n    const url = new URL(ensure.headers.location);\n    const state = url.searchParams.get('state');\n    expect(state).toBe('123456');\n  });\n\n  it('/api/oauth/decision (POST) - Deny', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const decisionRes = await decision(axios, transactionID!, 'Deny');\n    expect(decisionRes.status).toBe(302);\n    expect(decisionRes.headers.location).toContain(`${oauth.redirectUris[0]}?error=access_denied`);\n  });\n\n  it('/api/oauth/decision (POST) - transaction_id invalid', async () => {\n    const error = await getError(() => decision(axios, 'invalid'));\n    expect(error?.status).toBe(400);\n  });\n\n  it('/api/oauth/decision/:transactionId (GET)', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n\n    const res = await axios.get(`/oauth/decision/${transactionID}`);\n    expect(res.status).toBe(200);\n    expect(res.data).toEqual(omit(oauthData, 'redirectUris'));\n  });\n\n  it('/api/oauth/decision/:transactionId (GET) - transaction_id invalid', async () => {\n    const error = await getError(() => axios.get(`/oauth/decision/invalid`));\n    expect(error?.status).toBe(400);\n  });\n\n  it('/api/oauth/decision/:transactionId (GET) - transaction_id invalid', async () => {\n    const error = await getError(() => axios.get(`/oauth/decision/invalid`));\n    expect(error?.status).toBe(400);\n  });\n\n  it('/api/oauth/decision/:transactionId (GET) - user mismatch', async () => {\n    // Mismatch between user and transaction_id\n    const user2Request = await createNewUserAxios({\n      email: 'oauth1@example.com',\n      password: '12345678',\n    });\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const error = await getError(() => user2Request.get(`/oauth/decision/${transactionID}`));\n    expect(error?.status).toBe(400);\n    expect(error?.message).toBe('Invalid user');\n  });\n\n  it('/api/oauth/access_token (POST)', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n\n    const res = await decision(axios, transactionID!);\n\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(tokenRes.status).toBe(201);\n    expect(tokenRes.data).toEqual({\n      token_type: 'Bearer',\n      scopes: oauth.scopes,\n      access_token: expect.any(String),\n      refresh_token: expect.any(String),\n      expires_in: expect.any(Number),\n      refresh_expires_in: expect.any(Number),\n    });\n\n    const userInfo = await anonymousAxios.get(`/auth/user`, {\n      headers: {\n        Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n      },\n    });\n    expect(userInfo.data.email).toEqual(testEmail);\n  });\n\n  it('/api/oauth/access_token (POST) - has decision', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    await decision(axios, transactionID!);\n    const { code } = await getAuthorize(axios, oauth);\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(tokenRes.status).toBe(201);\n    expect(tokenRes.data).toEqual({\n      token_type: 'Bearer',\n      scopes: oauth.scopes,\n      access_token: expect.any(String),\n      refresh_token: expect.any(String),\n      expires_in: expect.any(Number),\n      refresh_expires_in: expect.any(Number),\n    });\n  });\n\n  it('/api/oauth/access_token (POST) - scope [no email]', async () => {\n    const oauthRes = await oauthCreate({\n      ...oauthData,\n      scopes: ['table|read'],\n    });\n    const { transactionID } = await getAuthorize(axios, oauthRes.data);\n\n    const res = await decision(axios, transactionID!);\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauthRes.data.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauthRes.data.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauthRes.data.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    const userInfo = await anonymousAxios.get(`/auth/user`, {\n      headers: {\n        Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n      },\n    });\n    expect(userInfo.data.email).toBeUndefined();\n    const tableListRes = await anonymousAxios.get<ITableListVo>(\n      urlBuilder(GET_TABLE_LIST, { baseId }),\n      {\n        headers: {\n          Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n        },\n      }\n    );\n    expect(tableListRes.status).toBe(200);\n    expect(tableListRes.data).toEqual(expect.any(Array));\n\n    // no scope table|create\n    const error = await getError(() =>\n      anonymousAxios.post(\n        `/base/${baseId}/table`,\n        {},\n        {\n          headers: {\n            Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n          },\n        }\n      )\n    );\n    expect(error?.status).toBe(403);\n    // base|read_all\n    const baseListRes = await anonymousAxios.get<IGetBaseAllVo>(`/base/access/all`, {\n      headers: {\n        Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n      },\n    });\n    expect(baseListRes.status).toBe(200);\n    expect(baseListRes.data).toEqual(expect.any(Array));\n  });\n\n  it('/api/oauth/access_token (POST) - scope [trash]', async () => {\n    const oauthRes = await oauthCreate({\n      ...oauthData,\n      scopes: ['table|trash_read'],\n    });\n    const { transactionID } = await getAuthorize(axios, oauthRes.data);\n\n    const res = await decision(axios, transactionID!);\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauthRes.data.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauthRes.data.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauthRes.data.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    const table = await axios\n      .post<ITableVo>(urlBuilder(CREATE_TABLE, { baseId }), {\n        name: 'test table',\n        records: [\n          {\n            fields: {},\n          },\n          {\n            fields: {},\n          },\n          {\n            fields: {},\n          },\n        ],\n      })\n      .then((res) => res.data);\n\n    const trashItemsRes = await anonymousAxios.get<ITrashVo>(GET_TRASH_ITEMS, {\n      params: {\n        resourceId: table.id,\n        resourceType: ResourceType.Table,\n      },\n      headers: {\n        Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n      },\n    });\n    expect(trashItemsRes.status).toBe(200);\n  });\n\n  it('/api/oauth/access_token (POST) - refresh token', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n\n    const res = await decision(axios, transactionID!);\n\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n\n    const refreshTokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'refresh_token',\n        refresh_token: `${tokenRes.data.refresh_token}`,\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(refreshTokenRes.status).toBe(201);\n    expect(refreshTokenRes.data).toEqual({\n      token_type: 'Bearer',\n      scopes: oauth.scopes,\n      access_token: expect.any(String),\n      refresh_token: expect.any(String),\n      expires_in: expect.any(Number),\n      refresh_expires_in: expect.any(Number),\n    });\n\n    // previous refresh token should be invalid\n    const error = await getError(() =>\n      anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'refresh_token',\n          refresh_token: `${tokenRes.data.refresh_token}`,\n          client_id: oauth.clientId,\n          client_secret: secret.data.secret,\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      )\n    );\n    expect(error?.status).toBe(401);\n  });\n\n  it('/api/oauth/access_token (POST) - confidential refresh token missing client_secret should fail', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const res = await decision(axios, transactionID!);\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(tokenRes.status).toBe(201);\n\n    const error = await getError(() =>\n      anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'refresh_token',\n          refresh_token: `${tokenRes.data.refresh_token}`,\n          client_id: oauth.clientId,\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      )\n    );\n    expect(error?.status).toBe(401);\n  });\n\n  it('/api/oauth/access_token (POST) - confidential refresh token wrong client_secret should fail', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const res = await decision(axios, transactionID!);\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(tokenRes.status).toBe(201);\n\n    const error = await getError(() =>\n      anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'refresh_token',\n          refresh_token: `${tokenRes.data.refresh_token}`,\n          client_id: oauth.clientId,\n          client_secret: 'invalid-secret',\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      )\n    );\n    expect(error?.status).toBe(401);\n  });\n\n  it('/api/oauth/access_token (POST) - confidential refresh token with only client_id should fail', async () => {\n    const { transactionID } = await getAuthorize(axios, oauth);\n    const res = await decision(axios, transactionID!);\n    const url = new URL(res.headers.location);\n    const code = url.searchParams.get('code');\n    const secret = await generateOAuthSecret(oauth.clientId);\n\n    const tokenRes = await anonymousAxios.post(\n      `/oauth/access_token`,\n      new URLSearchParams({\n        grant_type: 'authorization_code',\n        code: code ?? '',\n        client_id: oauth.clientId,\n        client_secret: secret.data.secret,\n        redirect_uri: oauth.redirectUris[0],\n      }),\n      {\n        maxRedirects: 0,\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n    expect(tokenRes.status).toBe(201);\n\n    const error = await getError(() =>\n      anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'refresh_token',\n          refresh_token: `${tokenRes.data.refresh_token}`,\n          client_id: oauth.clientId,\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      )\n    );\n    expect(error?.status).toBe(401);\n  });\n\n  describe('PKCE flow', () => {\n    const generateCodeVerifier = () => {\n      return crypto.randomBytes(32).toString('base64url');\n    };\n\n    const generateCodeChallenge = (verifier: string) => {\n      return crypto.createHash('sha256').update(verifier).digest('base64url');\n    };\n\n    const getAuthorizeWithPkce = async (\n      ax: AxiosInstance,\n      oa: OAuthCreateVo,\n      codeChallenge: string,\n      codeChallengeMethod = 'S256',\n      state?: string\n    ) => {\n      const res = await ax.get(\n        `/oauth/authorize?response_type=code&client_id=${oa.clientId}&scope=${oa.scopes?.join(' ')}&code_challenge=${codeChallenge}&code_challenge_method=${codeChallengeMethod}${state ? '&state=' + state : ''}`,\n        { maxRedirects: 0 }\n      );\n\n      const url = new URL(res.headers.location, oa.homepage);\n      return {\n        transactionID: url.searchParams.get('transaction_id') as string | null,\n        code: url.searchParams.get('code') as string | null,\n      };\n    };\n\n    it('/api/oauth/authorize (GET) - with PKCE params', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const res = await axios.get(\n        `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUris[0]}&scope=${oauth.scopes?.join(' ')}&code_challenge=${codeChallenge}&code_challenge_method=S256`,\n        { maxRedirects: 0 }\n      );\n      expect(res.status).toBe(302);\n      expect(res.headers.location).toContain(`/oauth/decision?transaction_id=`);\n    });\n\n    it('/api/oauth/authorize (GET) - invalid code_challenge_method', async () => {\n      const error = await getError(() =>\n        axios.get(\n          `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUris[0]}&scope=${oauth.scopes?.join(' ')}&code_challenge=abc&code_challenge_method=plain`,\n          { maxRedirects: 0 }\n        )\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('/api/oauth/authorize (GET) - code_challenge without method', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const error = await getError(() =>\n        axios.get(\n          `/oauth/authorize?response_type=code&client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUris[0]}&scope=${oauth.scopes?.join(' ')}&code_challenge=${codeChallenge}`,\n          { maxRedirects: 0 }\n        )\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE token exchange', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const { transactionID } = await getAuthorizeWithPkce(axios, oauth, codeChallenge);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      const tokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'authorization_code',\n          code: code ?? '',\n          client_id: oauth.clientId,\n          code_verifier: codeVerifier,\n          redirect_uri: oauth.redirectUris[0],\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      expect(tokenRes.status).toBe(201);\n      expect(tokenRes.data).toEqual({\n        token_type: 'Bearer',\n        scopes: oauth.scopes,\n        access_token: expect.any(String),\n        refresh_token: expect.any(String),\n        expires_in: expect.any(Number),\n        refresh_expires_in: expect.any(Number),\n      });\n\n      const userInfo = await anonymousAxios.get(`/auth/user`, {\n        headers: {\n          Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,\n        },\n      });\n      expect(userInfo.data.email).toEqual(testEmail);\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE with trusted authorization', async () => {\n      const codeVerifier1 = generateCodeVerifier();\n      const codeChallenge1 = generateCodeChallenge(codeVerifier1);\n\n      // First authorization - user approves\n      const { transactionID } = await getAuthorizeWithPkce(\n        axios,\n        oauth,\n        codeChallenge1,\n        'S256',\n        '123456'\n      );\n      await decision(axios, transactionID!);\n\n      // Second authorization - should be trusted (immediate)\n      const codeVerifier2 = generateCodeVerifier();\n      const codeChallenge2 = generateCodeChallenge(codeVerifier2);\n      const { code } = await getAuthorizeWithPkce(axios, oauth, codeChallenge2);\n      expect(code).not.toBeNull();\n\n      const tokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'authorization_code',\n          code: code ?? '',\n          client_id: oauth.clientId,\n          code_verifier: codeVerifier2,\n          redirect_uri: oauth.redirectUris[0],\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      expect(tokenRes.status).toBe(201);\n      expect(tokenRes.data.access_token).toBeDefined();\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE wrong code_verifier', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const { transactionID } = await getAuthorizeWithPkce(axios, oauth, codeChallenge);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      const wrongVerifier = generateCodeVerifier(); // different verifier\n\n      const error = await getError(() =>\n        anonymousAxios.post(\n          `/oauth/access_token`,\n          new URLSearchParams({\n            grant_type: 'authorization_code',\n            code: code ?? '',\n            client_id: oauth.clientId,\n            code_verifier: wrongVerifier,\n            redirect_uri: oauth.redirectUris[0],\n          }),\n          {\n            maxRedirects: 0,\n            headers: {\n              // eslint-disable-next-line @typescript-eslint/naming-convention\n              'Content-Type': 'application/x-www-form-urlencoded',\n            },\n          }\n        )\n      );\n      expect(error?.status).toBe(401);\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE missing code_verifier', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const { transactionID } = await getAuthorizeWithPkce(axios, oauth, codeChallenge);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      // Exchange without code_verifier but with client_secret — should fail because code_challenge was set\n      const secret = await generateOAuthSecret(oauth.clientId);\n      const error = await getError(() =>\n        anonymousAxios.post(\n          `/oauth/access_token`,\n          new URLSearchParams({\n            grant_type: 'authorization_code',\n            code: code ?? '',\n            client_id: oauth.clientId,\n            client_secret: secret.data.secret,\n            redirect_uri: oauth.redirectUris[0],\n          }),\n          {\n            maxRedirects: 0,\n            headers: {\n              // eslint-disable-next-line @typescript-eslint/naming-convention\n              'Content-Type': 'application/x-www-form-urlencoded',\n            },\n          }\n        )\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE refresh token', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const { transactionID } = await getAuthorizeWithPkce(axios, oauth, codeChallenge);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      const tokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'authorization_code',\n          code: code ?? '',\n          client_id: oauth.clientId,\n          code_verifier: codeVerifier,\n          redirect_uri: oauth.redirectUris[0],\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      expect(tokenRes.status).toBe(201);\n\n      // Refresh token using PKCE (no client_secret)\n      const refreshTokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'refresh_token',\n          refresh_token: tokenRes.data.refresh_token,\n          client_id: oauth.clientId,\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      expect(refreshTokenRes.status).toBe(201);\n      expect(refreshTokenRes.data).toEqual({\n        token_type: 'Bearer',\n        scopes: oauth.scopes,\n        access_token: expect.any(String),\n        refresh_token: expect.any(String),\n        expires_in: expect.any(Number),\n        refresh_expires_in: expect.any(Number),\n      });\n\n      // Old refresh token should be invalid\n      const error = await getError(() =>\n        anonymousAxios.post(\n          `/oauth/access_token`,\n          new URLSearchParams({\n            grant_type: 'refresh_token',\n            refresh_token: tokenRes.data.refresh_token,\n            client_id: oauth.clientId,\n            code_verifier: codeVerifier,\n          }),\n          {\n            maxRedirects: 0,\n            headers: {\n              // eslint-disable-next-line @typescript-eslint/naming-convention\n              'Content-Type': 'application/x-www-form-urlencoded',\n            },\n          }\n        )\n      );\n      expect(error?.status).toBe(401);\n    });\n\n    it('/api/oauth/access_token (POST) - non-PKCE code with only client_id should fail', async () => {\n      const { transactionID } = await getAuthorize(axios, oauth);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      const error = await getError(() =>\n        anonymousAxios.post(\n          `/oauth/access_token`,\n          new URLSearchParams({\n            grant_type: 'authorization_code',\n            code: code ?? '',\n            client_id: oauth.clientId,\n            redirect_uri: oauth.redirectUris[0],\n          }),\n          {\n            maxRedirects: 0,\n            headers: {\n              // eslint-disable-next-line @typescript-eslint/naming-convention\n              'Content-Type': 'application/x-www-form-urlencoded',\n            },\n          }\n        )\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('/api/oauth/access_token (POST) - non-PKCE code with code_verifier should fail', async () => {\n      const { transactionID } = await getAuthorize(axios, oauth);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n      const codeVerifier = generateCodeVerifier();\n\n      const error = await getError(() =>\n        anonymousAxios.post(\n          `/oauth/access_token`,\n          new URLSearchParams({\n            grant_type: 'authorization_code',\n            code: code ?? '',\n            client_id: oauth.clientId,\n            code_verifier: codeVerifier,\n            redirect_uri: oauth.redirectUris[0],\n          }),\n          {\n            maxRedirects: 0,\n            headers: {\n              // eslint-disable-next-line @typescript-eslint/naming-convention\n              'Content-Type': 'application/x-www-form-urlencoded',\n            },\n          }\n        )\n      );\n      expect(error?.status).toBe(400);\n    });\n\n    it('/api/oauth/access_token (POST) - PKCE revoke access', async () => {\n      const codeVerifier = generateCodeVerifier();\n      const codeChallenge = generateCodeChallenge(codeVerifier);\n\n      const { transactionID } = await getAuthorizeWithPkce(axios, oauth, codeChallenge);\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n\n      const tokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'authorization_code',\n          code: code ?? '',\n          client_id: oauth.clientId,\n          code_verifier: codeVerifier,\n          redirect_uri: oauth.redirectUris[0],\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n\n      const revokeRes = await anonymousAxios.get(`/oauth/client/${oauth.clientId}/revoke-token`, {\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n          Authorization: `Bearer ${tokenRes.data.access_token}`,\n        },\n      });\n      expect(revokeRes.status).toBe(200);\n\n      const error = await getError(() =>\n        anonymousAxios.get(`/auth/user`, {\n          headers: {\n            Authorization: `Bearer ${tokenRes.data.access_token}`,\n          },\n        })\n      );\n      expect(error?.status).toBe(401);\n    });\n  });\n\n  describe('revoke access', () => {\n    let accessToken: string;\n\n    beforeEach(async () => {\n      const { transactionID } = await getAuthorize(axios, oauth);\n\n      const res = await decision(axios, transactionID!);\n\n      const url = new URL(res.headers.location);\n      const code = url.searchParams.get('code');\n      const secret = await generateOAuthSecret(oauth.clientId);\n\n      const tokenRes = await anonymousAxios.post(\n        `/oauth/access_token`,\n        new URLSearchParams({\n          grant_type: 'authorization_code',\n          code: code ?? '',\n          client_id: oauth.clientId,\n          client_secret: secret.data.secret,\n          redirect_uri: oauth.redirectUris[0],\n        }),\n        {\n          maxRedirects: 0,\n          headers: {\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            'Content-Type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      accessToken = tokenRes.data.access_token;\n    });\n\n    it('/api/oauth/client/:clientId/revoke-access (GET)', async () => {\n      const revokeRes = await anonymousAxios.get(`/oauth/client/${oauth.clientId}/revoke-token`, {\n        headers: {\n          // eslint-disable-next-line @typescript-eslint/naming-convention\n          'Content-Type': 'application/x-www-form-urlencoded',\n          Authorization: `Bearer ${accessToken}`,\n        },\n      });\n\n      expect(revokeRes.status).toBe(200);\n\n      const error = await getError(() =>\n        anonymousAxios.get(`/auth/user`, {\n          headers: {\n            Authorization: `Bearer ${accessToken}`,\n          },\n        })\n      );\n      expect(error?.status).toBe(401);\n    });\n\n    it('/api/oauth/client/:clientId/revoke-access (POST)', async () => {\n      const revokeRes = await revokeAccess(oauth.clientId);\n      expect(revokeRes.status).toBe(200);\n\n      const error = await getError(() =>\n        anonymousAxios.get(`/auth/user`, {\n          headers: {\n            Authorization: `Bearer ${accessToken}`,\n          },\n        })\n      );\n      expect(error?.status).toBe(401);\n    });\n\n    it('/api/oauth/client/:clientId/revoke-token (POST)', async () => {\n      const revokeRes = await axios.post<void>(\n        urlBuilder(REVOKE_TOKEN, { clientId: oauth.clientId })\n      );\n      expect(revokeRes.status).toBe(200);\n\n      const error = await getError(() =>\n        anonymousAxios.get(`/auth/user`, {\n          headers: {\n            Authorization: `Bearer ${accessToken}`,\n          },\n        })\n      );\n      expect(error?.status).toBe(401);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/oauth.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { OAuthCreateVo } from '@teable/openapi';\nimport {\n  deleteOAuthSecret,\n  generateOAuthSecret,\n  oauthCreate,\n  oauthDelete,\n  oauthGet,\n  oauthUpdate,\n} from '@teable/openapi';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\nconst oauthData = {\n  name: 'test',\n  redirectUris: ['http://localhost:3000/callback'],\n  scopes: ['user|email_read'],\n  homepage: 'http://localhost:3000',\n};\n\ndescribe('OpenAPI OAuthController (e2e)', () => {\n  let app: INestApplication;\n  let oauth: OAuthCreateVo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const res = await oauthCreate(oauthData);\n    oauth = res.data;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('/api/oauth/client (POST)', async () => {\n    const res = await oauthCreate(oauthData);\n    expect(res.status).toBe(201);\n    expect(res.data).toHaveProperty('clientId');\n  });\n\n  it('/api/oauth/client/:clientId (GET)', async () => {\n    const res = await oauthGet(oauth.clientId);\n    expect(res.status).toBe(200);\n    expect(res.data).toMatchObject(oauth);\n  });\n\n  it('/api/oauth/client/:clientId (GET) - not found', async () => {\n    const error = await getError(() => oauthGet('xxxxxxx'));\n    expect(error?.status).toBe(404);\n  });\n\n  it('/api/oauth/client/:clientId (DELETE)', async () => {\n    const res = await oauthDelete(oauth.clientId);\n    expect(res.status).toBe(200);\n  });\n\n  it('/api/oauth/client/:clientId (PUT)', async () => {\n    const res = await oauthCreate(oauthData);\n    const updated = await oauthUpdate(res.data.clientId, { ...res.data, name: 'updated' });\n    expect(updated.data.name).toBe('updated');\n  });\n\n  it('/api/oauth/client/:clientId/secret (POST)', async () => {\n    const res = await oauthCreate(oauthData);\n    const secret = await generateOAuthSecret(res.data.clientId);\n    expect(secret.data).toHaveProperty('secret');\n    expect(secret.data.lastUsedTime).toBeUndefined();\n\n    const oauth = await oauthGet(res.data.clientId);\n    expect(oauth.data.secrets).toHaveLength(1);\n    expect(oauth.data.secrets?.[0].secret).toEqual(secret.data.maskedSecret);\n  });\n\n  it('/api/oauth/client/:clientId/secret (DELETE)', async () => {\n    const res = await oauthCreate(oauthData);\n    const secret = await generateOAuthSecret(res.data.clientId);\n    const deleted = await deleteOAuthSecret(res.data.clientId, secret.data.id);\n    expect(deleted.status).toBe(200);\n\n    const oauth = await oauthGet(res.data.clientId);\n    expect(oauth.data.secrets).toBeUndefined();\n  });\n\n  it('test oauth app foreign key', async () => {\n    const prisma = app.get(PrismaService);\n    const clientId = 'test-client-id-' + Date.now();\n    await prisma.oAuthApp.create({\n      data: {\n        name: 'test',\n        clientId,\n        createdBy: 'test',\n        homepage: 'http://localhost:3000',\n      },\n    });\n    const secret = await prisma.oAuthAppSecret.create({\n      data: {\n        clientId,\n        secret: 'test-secret-' + Date.now(),\n        maskedSecret: '**********',\n        createdBy: 'test',\n      },\n    });\n    await prisma.oAuthAppToken.create({\n      data: {\n        clientId,\n        appSecretId: secret.id,\n        refreshTokenSign: 'test-refresh-token-sign-' + Date.now(),\n        expiredTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),\n        createdBy: 'test',\n      },\n    });\n    await prisma.oAuthAppAuthorized.create({\n      data: {\n        clientId,\n        userId: 'test',\n        authorizedTime: new Date(),\n      },\n    });\n    await prisma.oAuthApp.delete({\n      where: {\n        clientId,\n      },\n    });\n\n    const oauthRes = await prisma.oAuthApp.findUnique({\n      where: {\n        clientId,\n      },\n    });\n    expect(oauthRes).toBeNull();\n\n    const secretRes = await prisma.oAuthAppSecret.findMany({\n      where: {\n        clientId,\n      },\n    });\n    expect(secretRes).toHaveLength(0);\n\n    const tokenRes = await prisma.oAuthAppToken.findMany({\n      where: {\n        appSecretId: secret.id,\n      },\n    });\n    expect(tokenRes).toHaveLength(0);\n\n    const authorizedRes = await prisma.oAuthAppAuthorized.findMany({\n      where: {\n        clientId,\n      },\n    });\n    expect(authorizedRes).toHaveLength(0);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/one-many-formula-symmetric-link.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILinkFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OneMany link with formula primary on symmetric link (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('primary formula referencing symmetric link', () => {\n    let tableA: ITableFullVo;\n    let tableB: ITableFullVo;\n    let linkAtoB: IFieldVo;\n    let symmetricLinkId: string;\n    let primaryFieldB: IFieldVo;\n\n    beforeEach(async () => {\n      tableA = await createTable(baseId, {\n        name: 'FormulaLink_A',\n        fields: [{ name: 'A Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'A Name': 'Alpha' } }],\n      });\n\n      tableB = await createTable(baseId, {\n        name: 'FormulaLink_B',\n        fields: [{ name: 'B Primary', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'B Primary': 'Row-1' } }],\n      });\n\n      primaryFieldB = tableB.fields[0];\n\n      linkAtoB = await createField(tableA.id, {\n        name: 'Link to B',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      symmetricLinkId = (linkAtoB.options as ILinkFieldOptions).symmetricFieldId as string;\n      if (!symmetricLinkId) {\n        throw new Error('Symmetric link field not created');\n      }\n\n      await convertField(tableB.id, primaryFieldB.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${symmetricLinkId}}`,\n        },\n      });\n\n      await updateRecordByApi(tableB.id, tableB.records[0].id, symmetricLinkId, {\n        id: tableA.records[0].id,\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableA.id);\n      await permanentDeleteTable(baseId, tableB.id);\n    });\n\n    it('resolves titles on both sides when linking from the symmetric side', async () => {\n      const tableBRecords = await getRecords(tableB.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [primaryFieldB.id, symmetricLinkId],\n      });\n\n      expect(tableBRecords.records).toHaveLength(1);\n      const bRecord = tableBRecords.records[0];\n\n      expect(bRecord.fields[primaryFieldB.id]).toBe('Alpha');\n      const linkValueB = bRecord.fields[symmetricLinkId] as { id: string; title?: string };\n      expect(linkValueB.id).toBe(tableA.records[0].id);\n      expect(linkValueB.title).toBe('Alpha');\n\n      const tableARecords = await getRecords(tableA.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [linkAtoB.id],\n      });\n\n      const aRecord = tableARecords.records.find((r) => r.id === tableA.records[0].id);\n      expect(aRecord).toBeDefined();\n\n      const aLinkValues = aRecord?.fields[linkAtoB.id] as Array<{ id: string; title?: string }>;\n      expect(Array.isArray(aLinkValues)).toBe(true);\n      expect(aLinkValues).toHaveLength(1);\n      expect(aLinkValues?.[0].id).toBe(tableB.records[0].id);\n      expect(aLinkValues?.[0].title).toBe('Alpha');\n    });\n  });\n\n  describe('lookup from symmetric link to another link column', () => {\n    let tableA: ITableFullVo;\n    let tableB: ITableFullVo;\n    let tableC: ITableFullVo;\n    let linkAtoB: IFieldVo;\n    let linkAtoC: IFieldVo;\n    let symmetricLinkId: string;\n    let lookupBCtoC: IFieldVo;\n\n    beforeEach(async () => {\n      tableA = await createTable(baseId, {\n        name: 'LookupChain_A',\n        fields: [{ name: 'A Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'A Name': 'Alpha' } }],\n      });\n\n      tableB = await createTable(baseId, {\n        name: 'LookupChain_B',\n        fields: [{ name: 'B Primary', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'B Primary': 'Row-1' } }],\n      });\n\n      tableC = await createTable(baseId, {\n        name: 'LookupChain_C',\n        fields: [{ name: 'C Name', type: FieldType.SingleLineText }],\n        records: [{ fields: { 'C Name': 'C1' } }],\n      });\n\n      linkAtoB = await createField(tableA.id, {\n        name: 'Link to B',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      symmetricLinkId = (linkAtoB.options as ILinkFieldOptions).symmetricFieldId as string;\n      if (!symmetricLinkId) {\n        throw new Error('Symmetric link field not created');\n      }\n\n      await convertField(tableB.id, tableB.fields[0].id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${symmetricLinkId}}`,\n        },\n      });\n\n      linkAtoC = await createField(tableA.id, {\n        name: 'Link to C',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: tableC.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordByApi(tableA.id, tableA.records[0].id, linkAtoC.id, {\n        id: tableC.records[0].id,\n      });\n\n      await updateRecordByApi(tableB.id, tableB.records[0].id, symmetricLinkId, {\n        id: tableA.records[0].id,\n      });\n\n      lookupBCtoC = await createField(tableB.id, {\n        name: 'Lookup C via A',\n        type: FieldType.Link,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: tableA.id,\n          linkFieldId: symmetricLinkId,\n          lookupFieldId: linkAtoC.id,\n        },\n      } as IFieldRo);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableA.id);\n      await permanentDeleteTable(baseId, tableB.id);\n      await permanentDeleteTable(baseId, tableC.id);\n    });\n\n    it('returns correct lookup and link titles after linking via symmetric field', async () => {\n      const bRecords = await getRecords(tableB.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [symmetricLinkId, lookupBCtoC.id],\n      });\n\n      expect(bRecords.records).toHaveLength(1);\n      const bRecord = bRecords.records[0];\n\n      const lookupValue = bRecord.fields[lookupBCtoC.id] as { id: string; title?: string };\n      expect(lookupValue.id).toBe(tableC.records[0].id);\n      expect(lookupValue.title).toBe('C1');\n\n      const bLinkValue = bRecord.fields[symmetricLinkId] as { id: string; title?: string };\n      expect(bLinkValue.id).toBe(tableA.records[0].id);\n      expect(bLinkValue.title).toBe('Alpha');\n\n      const aRecords = await getRecords(tableA.id, {\n        fieldKeyType: FieldKeyType.Id,\n        projection: [linkAtoB.id, linkAtoC.id],\n      });\n\n      const aRecord = aRecords.records.find((r) => r.id === tableA.records[0].id);\n      expect(aRecord).toBeDefined();\n      const aLinkToB = aRecord?.fields[linkAtoB.id] as Array<{ id: string; title?: string }>;\n      expect(Array.isArray(aLinkToB)).toBe(true);\n      expect(aLinkToB).toHaveLength(1);\n      expect(aLinkToB?.[0].id).toBe(tableB.records[0].id);\n      expect(aLinkToB?.[0].title).toBe('Alpha');\n\n      const aLinkToC = aRecord?.fields[linkAtoC.id] as { id: string; title?: string };\n      expect(aLinkToC.id).toBe(tableC.records[0].id);\n      expect(aLinkToC.title).toBe('C1');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/opportunity-rollup-regression.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\n\nimport type { INestApplication } from '@nestjs/common';\nimport type { LinkFieldCore } from '@teable/core';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createTable,\n  deleteTable,\n  getRecord,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Nested rollup regression (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  let customerTable: ITableFullVo | undefined;\n  let opportunityTable: ITableFullVo | undefined;\n  let contractTable: ITableFullVo | undefined;\n\n  const toFieldMap = (table: ITableFullVo) =>\n    table.fields.reduce<Record<string, string>>((acc, field) => {\n      acc[field.name] = field.id;\n      return acc;\n    }, {});\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  afterEach(async () => {\n    if (contractTable) {\n      await deleteTable(baseId, contractTable.id);\n      contractTable = undefined;\n    }\n    if (opportunityTable) {\n      await deleteTable(baseId, opportunityTable.id);\n      opportunityTable = undefined;\n    }\n    if (customerTable) {\n      await deleteTable(baseId, customerTable.id);\n      customerTable = undefined;\n    }\n  });\n\n  it(\n    'updates customer aliases even when contracts roll up opportunity rollups',\n    { timeout: 60000 },\n    async () => {\n      customerTable = await createTable(baseId, {\n        name: 'customers_rollup_regression',\n        fields: [\n          { name: 'Customer Alias', type: FieldType.SingleLineText },\n          { name: 'Customer Legal Name', type: FieldType.SingleLineText },\n        ],\n        records: [\n          {\n            fields: {\n              'Customer Alias': 'Acme',\n              'Customer Legal Name': 'Acme Holdings Ltd.',\n            },\n          },\n        ],\n      });\n\n      opportunityTable = await createTable(baseId, {\n        name: 'opportunities_rollup_regression',\n        fields: [{ name: 'Opportunity Title', type: FieldType.SingleLineText }],\n        records: [\n          {\n            fields: {\n              'Opportunity Title': 'Placeholder Title',\n            },\n          },\n        ],\n      });\n\n      contractTable = await createTable(baseId, {\n        name: 'contracts_rollup_regression',\n        fields: [{ name: 'Contract Name', type: FieldType.SingleLineText }],\n        records: [\n          {\n            fields: {\n              'Contract Name': 'Primary Contract',\n            },\n          },\n        ],\n      });\n\n      const customerFields = toFieldMap(customerTable);\n      const opportunityFields = toFieldMap(opportunityTable);\n      const contractFields = toFieldMap(contractTable);\n\n      const opportunityCustomerLink = (await createField(opportunityTable.id, {\n        name: 'Customer Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: customerTable.id,\n        },\n      })) as LinkFieldCore;\n      opportunityFields[opportunityCustomerLink.name] = opportunityCustomerLink.id;\n\n      await updateRecordByApi(\n        opportunityTable.id,\n        opportunityTable.records[0].id,\n        opportunityCustomerLink.id,\n        { id: customerTable.records[0].id }\n      );\n\n      const opportunityTitleField = await convertField(\n        opportunityTable.id,\n        opportunityFields['Opportunity Title'],\n        {\n          name: 'Opportunity Title',\n          type: FieldType.Formula,\n          options: {\n            expression: `ARRAYJOIN({${opportunityCustomerLink.id}}, ', ')`,\n          },\n        }\n      );\n      opportunityFields[opportunityTitleField.name] = opportunityTitleField.id;\n\n      const subjectRollup = await createField(opportunityTable.id, {\n        name: 'Subject Name',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'array_join({values})',\n        },\n        lookupOptions: {\n          foreignTableId: customerTable.id,\n          linkFieldId: opportunityCustomerLink.id,\n          lookupFieldId: customerFields['Customer Legal Name'],\n        },\n      });\n      opportunityFields[subjectRollup.name] = subjectRollup.id;\n\n      const contractOpportunityLink = (await createField(contractTable.id, {\n        name: 'Opportunity Link',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: opportunityTable.id,\n        },\n      })) as LinkFieldCore;\n      contractFields[contractOpportunityLink.name] = contractOpportunityLink.id;\n\n      await updateRecordByApi(\n        contractTable.id,\n        contractTable.records[0].id,\n        contractOpportunityLink.id,\n        { id: opportunityTable.records[0].id }\n      );\n\n      const signingSubjectField = await createField(contractTable.id, {\n        name: 'Signing Subject',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'array_join({values})',\n        },\n        lookupOptions: {\n          foreignTableId: opportunityTable.id,\n          linkFieldId: contractOpportunityLink.id,\n          lookupFieldId: subjectRollup.id,\n        },\n      });\n      contractFields[signingSubjectField.name] = signingSubjectField.id;\n\n      await expect(\n        updateRecordByApi(\n          customerTable.id,\n          customerTable.records[0].id,\n          customerFields['Customer Alias'],\n          'Acme Updated'\n        )\n      ).resolves.toBeDefined();\n\n      const updatedCustomer = await getRecord(customerTable.id, customerTable.records[0].id);\n      const updatedOpportunity = await getRecord(\n        opportunityTable.id,\n        opportunityTable.records[0].id\n      );\n      const updatedContract = await getRecord(contractTable.id, contractTable.records[0].id);\n\n      expect(updatedCustomer.fields[customerFields['Customer Alias']]).toBe('Acme Updated');\n      expect(updatedOpportunity.fields[opportunityTitleField.id]).toBe('Acme Updated');\n      expect(updatedOpportunity.fields[subjectRollup.id]).toBe('Acme Holdings Ltd.');\n      expect(updatedContract.fields[contractFields['Signing Subject']]).toBe('Acme Holdings Ltd.');\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/order-update.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { ViewType } from '@teable/core';\nimport type { ITableFullVo, ICreateBaseVo, ICreateSpaceVo } from '@teable/openapi';\nimport {\n  createBase,\n  createSpace,\n  createTable,\n  deleteBase,\n  deleteSpace,\n  getBaseList,\n  getTableList,\n  updateBaseOrder,\n  updateRecordOrders,\n  updateTableOrder,\n  updateViewOrder,\n} from '@teable/openapi';\nimport {\n  initApp,\n  createView,\n  permanentDeleteTable,\n  getViews,\n  getRecords,\n  createRecords,\n} from './utils/init-app';\n\ndescribe('order update', () => {\n  let app: INestApplication;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('record', () => {\n    const baseId = globalThis.testConfig.baseId;\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = (await createTable(baseId, { name: 'table1' })).data;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should update record order', async () => {\n      const viewId = table.views[0].id;\n      const record1 = { id: table.records[0].id };\n      const record2 = { id: table.records[1].id };\n      const record3 = { id: table.records[2].id };\n\n      await updateRecordOrders(table.id, viewId, {\n        anchorId: record2.id,\n        position: 'before',\n        recordIds: [record3.id],\n      });\n      const data1 = await getRecords(table.id, { viewId });\n      expect(data1.records).toMatchObject([record1, record3, record2]);\n\n      await updateRecordOrders(table.id, viewId, {\n        anchorId: record1.id,\n        position: 'before',\n        recordIds: [record3.id, record2.id],\n      });\n      const data2 = await getRecords(table.id, { viewId });\n      expect(data2.records).toMatchObject([record3, record2, record1]);\n\n      await updateRecordOrders(table.id, viewId, {\n        anchorId: record1.id,\n        position: 'after',\n        recordIds: [record3.id, record2.id],\n      });\n      const data3 = await getRecords(table.id, { viewId });\n      expect(data3.records).toMatchObject([record1, record3, record2]);\n\n      await updateRecordOrders(table.id, viewId, {\n        anchorId: record3.id,\n        position: 'after',\n        recordIds: [record2.id, record3.id],\n      });\n      const data4 = await getRecords(table.id, { viewId });\n      expect(data4.records).toMatchObject([record1, record2, record3]);\n\n      const result = await createRecords(table.id, {\n        records: [{ fields: {} }],\n        order: {\n          viewId,\n          anchorId: record1.id,\n          position: 'before',\n        },\n      });\n      const data5 = await getRecords(table.id, { viewId });\n      expect(data5.records).toMatchObject([\n        { id: result.records[0].id },\n        record1,\n        record2,\n        record3,\n      ]);\n    });\n\n    it('should create record with order', async () => {\n      const viewId = table.views[0].id;\n      const record1 = { id: table.records[0].id };\n      const record2 = { id: table.records[1].id };\n      const record3 = { id: table.records[2].id };\n\n      const result = await createRecords(table.id, {\n        records: [{ fields: {} }],\n        order: {\n          viewId,\n          anchorId: record1.id,\n          position: 'before',\n        },\n      });\n      const data1 = await getRecords(table.id, { viewId });\n      expect(data1.records).toMatchObject([\n        { id: result.records[0].id },\n        record1,\n        record2,\n        record3,\n      ]);\n\n      const result2 = await createRecords(table.id, {\n        records: [{ fields: {} }],\n        order: {\n          viewId,\n          anchorId: record3.id,\n          position: 'after',\n        },\n      });\n      const data2 = await getRecords(table.id, { viewId });\n      expect(data2.records).toMatchObject([\n        { id: result.records[0].id },\n        record1,\n        record2,\n        record3,\n        { id: result2.records[0].id },\n      ]);\n    });\n  });\n\n  describe('view', () => {\n    const baseId = globalThis.testConfig.baseId;\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = (await createTable(baseId, { name: 'table1' })).data;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should update view order', async () => {\n      const view1 = { id: table.views[0].id };\n\n      const view2 = {\n        id: (\n          await createView(table.id, {\n            name: 'view',\n            type: ViewType.Grid,\n          })\n        ).id,\n      };\n\n      const view3 = {\n        id: (\n          await createView(table.id, {\n            name: 'view',\n            type: ViewType.Grid,\n          })\n        ).id,\n      };\n\n      await updateViewOrder(table.id, view3.id, { anchorId: view2.id, position: 'before' });\n      const views = await getViews(table.id);\n      expect(views).toMatchObject([view1, view3, view2]);\n\n      await updateViewOrder(table.id, view3.id, { anchorId: view1.id, position: 'before' });\n      const views2 = await getViews(table.id);\n      expect(views2).toMatchObject([view3, view1, view2]);\n\n      await updateViewOrder(table.id, view3.id, { anchorId: view1.id, position: 'after' });\n      const views3 = await getViews(table.id);\n      expect(views3).toMatchObject([view1, view3, view2]);\n\n      await updateViewOrder(table.id, view3.id, { anchorId: view2.id, position: 'after' });\n      const views4 = await getViews(table.id);\n      expect(views4).toMatchObject([view1, view2, view3]);\n    });\n  });\n\n  describe('table', () => {\n    const spaceId = globalThis.testConfig.spaceId;\n    let base: ICreateBaseVo;\n    beforeEach(async () => {\n      base = (await createBase({ spaceId, name: 'base1' })).data;\n    });\n\n    afterEach(async () => {\n      await deleteBase(base.id);\n    });\n\n    it('should update table order', async () => {\n      const table1 = {\n        id: (await createTable(base.id)).data.id,\n      };\n\n      const table2 = {\n        id: (await createTable(base.id)).data.id,\n      };\n\n      const table3 = {\n        id: (await createTable(base.id)).data.id,\n      };\n\n      await updateTableOrder(base.id, table3.id, { anchorId: table2.id, position: 'before' });\n      const tables = (await getTableList(base.id)).data;\n      expect(tables).toMatchObject([table1, table3, table2]);\n\n      await updateTableOrder(base.id, table3.id, { anchorId: table1.id, position: 'before' });\n      const tables2 = (await getTableList(base.id)).data;\n      expect(tables2).toMatchObject([table3, table1, table2]);\n\n      await updateTableOrder(base.id, table3.id, { anchorId: table1.id, position: 'after' });\n      const tables3 = (await getTableList(base.id)).data;\n      expect(tables3).toMatchObject([table1, table3, table2]);\n\n      await updateTableOrder(base.id, table3.id, { anchorId: table2.id, position: 'after' });\n      const tables4 = (await getTableList(base.id)).data;\n      expect(tables4).toMatchObject([table1, table2, table3]);\n    });\n  });\n\n  describe('base', () => {\n    let space: ICreateSpaceVo;\n    beforeEach(async () => {\n      space = (await createSpace({})).data;\n    });\n\n    afterEach(async () => {\n      await deleteSpace(space.id);\n    });\n\n    it('should update base order', async () => {\n      const base1 = {\n        id: (await createBase({ spaceId: space.id })).data.id,\n      };\n\n      const base2 = {\n        id: (await createBase({ spaceId: space.id })).data.id,\n      };\n\n      const base3 = {\n        id: (await createBase({ spaceId: space.id })).data.id,\n      };\n\n      await updateBaseOrder({ baseId: base3.id, anchorId: base2.id, position: 'before' });\n      const bases = (await getBaseList({ spaceId: space.id })).data;\n      expect(bases).toMatchObject([base1, base3, base2]);\n\n      await updateBaseOrder({ baseId: base3.id, anchorId: base1.id, position: 'before' });\n      const bases2 = (await getBaseList({ spaceId: space.id })).data;\n      expect(bases2).toMatchObject([base3, base1, base2]);\n\n      await updateBaseOrder({ baseId: base3.id, anchorId: base1.id, position: 'after' });\n      const bases3 = (await getBaseList({ spaceId: space.id })).data;\n      expect(bases3).toMatchObject([base1, base3, base2]);\n\n      await updateBaseOrder({ baseId: base3.id, anchorId: base2.id, position: 'after' });\n      const bases4 = (await getBaseList({ spaceId: space.id })).data;\n      expect(bases4).toMatchObject([base1, base2, base3]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/performance.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport { faker } from '@faker-js/faker';\nimport type { INestApplication } from '@nestjs/common';\nimport { Colors, FieldType, RatingIcon, Relationship } from '@teable/core';\nimport { createRecords, createTable } from '@teable/openapi';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('OpenAPI RecordController (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const userId = globalThis.testConfig.userId;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('create records performance', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    const batchSize = 1000;\n\n    beforeEach(async () => {\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'Title',\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'A1',\n            },\n          },\n          {\n            fields: {\n              Title: 'A2',\n            },\n          },\n          {\n            fields: {\n              Title: 'A3',\n            },\n          },\n        ],\n      }).then((res) => res.data);\n\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'Title',\n          },\n          {\n            type: FieldType.Number,\n            name: 'Count',\n          },\n          {\n            type: FieldType.SingleSelect,\n            name: 'Status',\n            options: {\n              choices: [{ name: 'Not Started' }, { name: 'In Progress' }, { name: 'Completed' }],\n            },\n          },\n          {\n            type: FieldType.LongText,\n            name: 'Text',\n          },\n          {\n            type: FieldType.MultipleSelect,\n            name: 'Tags',\n            options: {\n              choices: [\n                { name: 'Tag 1' },\n                { name: 'Tag 2' },\n                { name: 'Tag 3' },\n                { name: 'Tag 4' },\n                { name: 'Tag 5' },\n              ],\n            },\n          },\n          {\n            type: FieldType.User,\n            name: 'Member',\n          },\n          {\n            type: FieldType.Date,\n            name: 'Date',\n          },\n          {\n            type: FieldType.Rating,\n            name: 'Rating',\n            options: {\n              icon: RatingIcon.Star,\n              color: Colors.YellowBright,\n              max: 5,\n            },\n          },\n          {\n            type: FieldType.Link,\n            name: 'One-way Link',\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: table2.id,\n              isOneWay: true,\n            },\n          },\n          {\n            type: FieldType.Link,\n            name: 'Two-way Link',\n            options: {\n              relationship: Relationship.ManyOne,\n              foreignTableId: table2.id,\n            },\n          },\n        ],\n      }).then((res) => res.data);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('batch create records', { timeout: 10000 }, async () => {\n      const { data } = await createRecords(table1.id, {\n        typecast: true,\n        records: Array.from({ length: batchSize }, () => ({\n          fields: {\n            Title: faker.lorem.sentence(),\n            Count: faker.number.int({ min: 1, max: 100 }),\n            Status: faker.helpers.arrayElement(['Not Started', 'In Progress', 'Completed']),\n            Text: faker.lorem.paragraph(),\n            Tags: faker.helpers.arrayElements(['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5'], {\n              min: 1,\n              max: 5,\n            }),\n            Member: userId,\n            Date: faker.date.recent().toISOString(),\n            Rating: faker.number.int({ min: 0, max: 5 }),\n            'One-way Link': faker.helpers.arrayElement(['A1', 'A2', 'A3']),\n            'Two-way Link': faker.helpers.arrayElement(['A1', 'A2', 'A3']),\n          },\n        })),\n      });\n\n      expect(data.records).toHaveLength(batchSize);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/personal-income-tax.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n\nimport type { INestApplication } from '@nestjs/common';\nimport type { LinkFieldCore } from '@teable/core';\nimport { FieldType, NumberFormattingType, Relationship } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createTable,\n  deleteTable,\n  getRecord,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Personal income tax computed update (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let memberTable: ITableFullVo | undefined;\n  let payrollTable: ITableFullVo | undefined;\n  const waitForRecalc = (ms = 400) => new Promise((resolve) => setTimeout(resolve, ms));\n\n  const toFieldMap = (table: ITableFullVo) =>\n    table.fields.reduce<Record<string, string>>((acc, field) => {\n      acc[field.name] = field.id;\n      return acc;\n    }, {});\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  afterEach(async () => {\n    if (payrollTable) {\n      await deleteTable(baseId, payrollTable.id);\n    }\n    if (memberTable) {\n      await deleteTable(baseId, memberTable.id);\n    }\n    payrollTable = undefined;\n    memberTable = undefined;\n  });\n\n  it(\n    'should update personal income tax via API without tripping lookup-rollup loops',\n    { timeout: 60000 },\n    async () => {\n      memberTable = await createTable(baseId, {\n        name: 'Members-e2e',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'AnnualTaxDue',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'BaseAmount',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Alice',\n              AnnualTaxDue: 12000,\n              BaseAmount: 8000,\n            },\n          },\n        ],\n      });\n\n      payrollTable = await createTable(baseId, {\n        name: 'Payroll-e2e',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          { name: 'PayrollMonth', type: FieldType.Date },\n          {\n            name: 'PayrollBase',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'Allowance',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'SocialSecurityEmployee',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'HousingFundEmployee',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'SocialSecurityEmployer',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n          {\n            name: 'PersonalIncomeTax',\n            type: FieldType.Number,\n            options: { formatting: { type: NumberFormattingType.Decimal, precision: 2 } },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Title: 'Alice-2024-05',\n              PayrollMonth: '2024-05-01',\n              PayrollBase: 10000,\n              Allowance: 500,\n              SocialSecurityEmployee: 800,\n              HousingFundEmployee: 500,\n              SocialSecurityEmployer: 1200,\n              PersonalIncomeTax: 1000,\n            },\n          },\n        ],\n      });\n\n      const memberFields = toFieldMap(memberTable);\n      const payrollFields = toFieldMap(payrollTable);\n\n      const linkPayrollToMember = (await createField(payrollTable.id, {\n        name: 'Name',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: memberTable.id,\n        },\n      })) as LinkFieldCore;\n      payrollFields[linkPayrollToMember.name] = linkPayrollToMember.id;\n\n      const symmetricFieldId = linkPayrollToMember.options.symmetricFieldId;\n      if (!symmetricFieldId) {\n        throw new Error('symmetric field not created');\n      }\n\n      const titleField = await convertField(payrollTable.id, payrollFields['Title'], {\n        name: 'Title',\n        type: FieldType.Formula,\n        options: {\n          expression: `ARRAYJOIN({${linkPayrollToMember.id}}, ',') & '-' & DATETIME_FORMAT({${payrollFields['PayrollMonth']}}, 'YYYY-MM')`,\n        },\n      });\n      payrollFields[titleField.name] = titleField.id;\n\n      const memberAnnualPaidField = await createField(memberTable.id, {\n        name: 'PaidYearToDate',\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: payrollTable.id,\n          linkFieldId: symmetricFieldId,\n          lookupFieldId: payrollFields['PersonalIncomeTax'],\n        },\n      });\n      memberFields[memberAnnualPaidField.name] = memberAnnualPaidField.id;\n\n      const memberMonthlyDueField = await createField(memberTable.id, {\n        name: 'MonthlyTaxDue',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${memberFields['AnnualTaxDue']}} - {${memberAnnualPaidField.id}}`,\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n      memberFields[memberMonthlyDueField.name] = memberMonthlyDueField.id;\n\n      const payrollGrossField = await createField(payrollTable.id, {\n        name: 'GrossPay',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${payrollFields['PayrollBase']}} + {${payrollFields['Allowance']}}`,\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n      payrollFields[payrollGrossField.name] = payrollGrossField.id;\n\n      const payrollNetField = await createField(payrollTable.id, {\n        name: 'NetPay',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${payrollGrossField.id}} - {${payrollFields['SocialSecurityEmployee']}} - {${payrollFields['HousingFundEmployee']}} - {${payrollFields['PersonalIncomeTax']}}`,\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n      payrollFields[payrollNetField.name] = payrollNetField.id;\n\n      const payrollCompanyCostField = await createField(payrollTable.id, {\n        name: 'CompanyLaborCost',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${payrollGrossField.id}} + {${payrollFields['SocialSecurityEmployer']}} + {${payrollFields['HousingFundEmployee']}}`,\n          formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n        },\n      });\n      payrollFields[payrollCompanyCostField.name] = payrollCompanyCostField.id;\n\n      const payrollBaseLookupField = await createField(payrollTable.id, {\n        name: 'MemberBaseLookup',\n        type: FieldType.Number,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: memberTable.id,\n          linkFieldId: linkPayrollToMember.id,\n          lookupFieldId: memberFields['BaseAmount'],\n        },\n      });\n      payrollFields[payrollBaseLookupField.name] = payrollBaseLookupField.id;\n\n      const payrollCumulativeTaxField = await createField(payrollTable.id, {\n        name: 'CumulativePaidTax',\n        type: FieldType.Rollup,\n        isLookup: true,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: memberTable.id,\n          linkFieldId: linkPayrollToMember.id,\n          lookupFieldId: memberAnnualPaidField.id,\n        },\n      });\n      payrollFields[payrollCumulativeTaxField.name] = payrollCumulativeTaxField.id;\n\n      await updateRecordByApi(payrollTable.id, payrollTable.records[0].id, linkPayrollToMember.id, {\n        id: memberTable.records[0].id,\n      });\n\n      await waitForRecalc();\n\n      const updatedPersonalTax = 1600;\n      await updateRecordByApi(\n        payrollTable.id,\n        payrollTable.records[0].id,\n        payrollFields['PersonalIncomeTax'],\n        updatedPersonalTax\n      );\n\n      await waitForRecalc();\n\n      const payrollRecord = await getRecord(payrollTable.id, payrollTable.records[0].id);\n      const memberRecord = await getRecord(memberTable.id, memberTable.records[0].id);\n\n      expect(payrollRecord.fields[payrollFields['PersonalIncomeTax']]).toEqual(updatedPersonalTax);\n      expect(payrollRecord.fields[payrollNetField.id]).toBeCloseTo(\n        10500 - 800 - 500 - updatedPersonalTax,\n        2\n      );\n      expect(payrollRecord.fields[payrollCumulativeTaxField.id]).toEqual(updatedPersonalTax);\n      expect(memberRecord.fields[memberAnnualPaidField.id]).toEqual(updatedPersonalTax);\n      expect(memberRecord.fields[memberMonthlyDueField.id]).toEqual(12000 - updatedPersonalTax);\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/pin.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { ViewType } from '@teable/core';\nimport {\n  addPin,\n  deletePin,\n  deleteView,\n  getPinList,\n  PinType,\n  updatePinOrder,\n} from '@teable/openapi';\nimport {\n  createBase,\n  createSpace,\n  createTable,\n  createView,\n  initApp,\n  permanentDeleteBase,\n  permanentDeleteSpace,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('OpenAPI PinController (e2e)', () => {\n  let app: INestApplication;\n  let spaceId: string;\n  let baseId: string;\n  let tableId: string;\n  let viewId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    const spaceRes = await createSpace({\n      name: 'test-space',\n    });\n    spaceId = spaceRes.id;\n\n    const baseRes = await createBase({\n      name: 'test-base',\n      spaceId,\n    });\n    baseId = baseRes.id;\n\n    const tableRes = await createTable(baseId, {\n      name: 'test-table',\n    });\n    tableId = tableRes.id;\n\n    const viewRes = await createView(tableId, {\n      name: 'test-view',\n      type: ViewType.Grid,\n    });\n    viewId = viewRes.id;\n\n    const pinBaseRes = await addPin({\n      id: baseId,\n      type: PinType.Base,\n    });\n    expect(pinBaseRes.status).toBe(201);\n    const pinSpaceRes = await addPin({\n      id: spaceId,\n      type: PinType.Space,\n    });\n    expect(pinSpaceRes.status).toBe(201);\n    const pinTableRes = await addPin({\n      id: tableId,\n      type: PinType.Table,\n    });\n    expect(pinTableRes.status).toBe(201);\n    const pinViewRes = await addPin({\n      id: viewId,\n      type: PinType.View,\n    });\n    expect(pinViewRes.status).toBe(201);\n  });\n\n  afterEach(async () => {\n    const pinBaseRes = await deletePin({\n      id: baseId,\n      type: PinType.Base,\n    });\n    expect(pinBaseRes.status).toBe(200);\n    const pinSpaceRes = await deletePin({\n      id: spaceId,\n      type: PinType.Space,\n    });\n    expect(pinSpaceRes.status).toBe(200);\n    const pinTableRes = await deletePin({\n      id: tableId,\n      type: PinType.Table,\n    });\n    expect(pinTableRes.status).toBe(200);\n    const pinViewRes = await deletePin({\n      id: viewId,\n      type: PinType.View,\n    });\n    expect(pinViewRes.status).toBe(200);\n    await deleteView(tableId, viewId);\n    await permanentDeleteTable(baseId, tableId);\n    await permanentDeleteBase(baseId);\n    await permanentDeleteSpace(spaceId);\n  });\n\n  it('should be able to get pin list', async () => {\n    const pinRes = await getPinList();\n    expect(pinRes.status).toBe(200);\n    expect(pinRes.data.length).toBe(4);\n    expect(pinRes.data).toEqual([\n      {\n        id: baseId,\n        type: PinType.Base,\n        order: 1,\n        name: 'test-base',\n      },\n      {\n        id: spaceId,\n        type: PinType.Space,\n        order: 2,\n        name: 'test-space',\n      },\n      {\n        id: tableId,\n        type: PinType.Table,\n        order: 3,\n        name: 'test-table',\n        parentBaseId: baseId,\n      },\n      {\n        id: viewId,\n        type: PinType.View,\n        order: 4,\n        name: 'test-view',\n        parentBaseId: baseId,\n        viewMeta: {\n          type: ViewType.Grid,\n          tableId,\n        },\n      },\n    ]);\n  });\n\n  it('should be able to update pin order', async () => {\n    await updatePinOrder({\n      id: tableId,\n      type: PinType.Table,\n      anchorId: baseId,\n      anchorType: PinType.Base,\n      position: 'before',\n    });\n    const pinRes = await getPinList();\n    expect(pinRes.status).toBe(200);\n    expect(pinRes.data.map((pin) => pin.id)).toEqual([tableId, baseId, spaceId, viewId]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/plugin-chart.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport type { IBaseQueryVo, ITableFullVo } from '@teable/openapi';\nimport {\n  createPluginPanel,\n  createDashboard,\n  deletePluginPanel,\n  getPluginPanelInstallPluginQuery,\n  getPluginPanelPlugin,\n  installPluginPanel,\n  pluginPanelPluginGetVoSchema,\n  updateDashboardPluginStorage,\n  updatePluginPanelStorage,\n  baseQuerySchemaVo,\n  urlBuilder,\n  GET_PLUGIN_PANEL_INSTALL_PLUGIN_QUERY,\n  deleteDashboard,\n  installPlugin,\n  getDashboardInstallPlugin,\n  getDashboardInstallPluginQuery,\n  GET_DASHBOARD_INSTALL_PLUGIN_QUERY,\n  getDashboardInstallPluginVoSchema,\n} from '@teable/openapi';\nimport { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('PluginController', () => {\n  let app: INestApplication;\n  let anonymousUser: ReturnType<typeof createAnonymousUserAxios>;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    anonymousUser = createAnonymousUserAxios(appCtx.appUrl);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Plugin Chart', () => {\n    let pluginPanelId: string;\n    let table: ITableFullVo;\n    const baseId = globalThis.testConfig.baseId;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'age',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              name: 'Alice',\n              age: 20,\n            },\n          },\n          {\n            fields: {\n              name: 'Bob',\n              age: 30,\n            },\n          },\n          {\n            fields: {\n              name: 'Charlie',\n              age: 40,\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await deletePluginPanel(table.id, pluginPanelId);\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    async function preparePluginPanel(table: ITableFullVo) {\n      const pluginPanelRes = await createPluginPanel(table.id, {\n        name: 'plugin panel',\n      });\n      pluginPanelId = pluginPanelRes.data.id;\n\n      const pluginId = 'plgchart';\n\n      const installRes = await installPluginPanel(table.id, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const pluginInstallId = installRes.data.pluginInstallId;\n      const textField = table.fields.find((field) => field.type === FieldType.SingleLineText)!;\n      const numberField = table.fields.find((field) => field.type === FieldType.Number)!;\n      const res = await getPluginPanelPlugin(table.id, pluginPanelId, pluginInstallId);\n      expect(res.status).toBe(200);\n      expect(pluginPanelPluginGetVoSchema.strict().safeParse(res.data).success).toBe(true);\n      expect(res.data.pluginId).toBe(pluginId);\n\n      await updatePluginPanelStorage(table.id, pluginPanelId, pluginInstallId, {\n        storage: {\n          config: {\n            type: 'bar',\n            xAxis: [{ column: textField.name, display: { type: 'bar', position: 'auto' } }],\n            yAxis: [{ column: numberField.name, display: { type: 'bar', position: 'auto' } }],\n          },\n          query: {\n            from: table.id,\n            select: [\n              { column: textField.id, alias: textField.name, type: 'field' },\n              { column: numberField.id, alias: numberField.name, type: 'field' },\n            ],\n          },\n        },\n      });\n\n      return { pluginPanelId, pluginId, pluginInstallId };\n    }\n\n    it('api/plugin/chart/:pluginInstallId/plugin-panel/:positionId/query (GET)', async () => {\n      const { pluginPanelId, pluginInstallId } = await preparePluginPanel(table);\n\n      const queryRes = await getPluginPanelInstallPluginQuery(pluginInstallId, pluginPanelId, {\n        tableId: table.id,\n      });\n      expect(queryRes.status).toBe(200);\n      expect(baseQuerySchemaVo.strict().safeParse(queryRes.data).success).toBe(true);\n\n      await expect(\n        anonymousUser.get<IBaseQueryVo>(\n          urlBuilder(GET_PLUGIN_PANEL_INSTALL_PLUGIN_QUERY, {\n            pluginInstallId: pluginInstallId,\n            positionId: pluginPanelId,\n          }),\n          {\n            params: { tableId: table.id },\n          }\n        )\n      ).rejects.toThrow();\n    });\n  });\n\n  describe('Dashboard Chart', () => {\n    let dashboardId: string;\n    let table: ITableFullVo;\n    const baseId = globalThis.testConfig.baseId;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'age',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              name: 'Alice',\n              age: 20,\n            },\n          },\n          {\n            fields: {\n              name: 'Bob',\n              age: 30,\n            },\n          },\n          {\n            fields: {\n              name: 'Charlie',\n              age: 40,\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await deleteDashboard(baseId, dashboardId);\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    async function prepareDashboard(table: ITableFullVo) {\n      const dashboardRes = await createDashboard(baseId, {\n        name: 'dashboard',\n      });\n      dashboardId = dashboardRes.data.id;\n\n      const pluginId = 'plgchart';\n      const installRes = await installPlugin(baseId, dashboardId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const pluginInstallId = installRes.data.pluginInstallId;\n      const textField = table.fields.find((field) => field.type === FieldType.SingleLineText)!;\n      const numberField = table.fields.find((field) => field.type === FieldType.Number)!;\n      const res = await getDashboardInstallPlugin(baseId, dashboardId, pluginInstallId);\n      expect(res.status).toBe(200);\n      expect(getDashboardInstallPluginVoSchema.strict().safeParse(res.data).success).toBe(true);\n      expect(res.data.pluginId).toBe(pluginId);\n\n      await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, {\n        config: {\n          type: 'bar',\n          xAxis: [{ column: textField.name, display: { type: 'bar', position: 'auto' } }],\n          yAxis: [{ column: numberField.name, display: { type: 'bar', position: 'auto' } }],\n        },\n        query: {\n          from: table.id,\n          select: [\n            { column: textField.id, alias: textField.name, type: 'field' },\n            { column: numberField.id, alias: numberField.name, type: 'field' },\n          ],\n        },\n      });\n\n      return { dashboardId, pluginId, pluginInstallId };\n    }\n\n    it('api/plugin/chart/:pluginInstallId/dashboard/:positionId/query (GET)', async () => {\n      const { pluginInstallId, dashboardId } = await prepareDashboard(table);\n      const queryRes = await getDashboardInstallPluginQuery(pluginInstallId, dashboardId, {\n        baseId,\n      });\n      expect(queryRes.status).toBe(200);\n      expect(baseQuerySchemaVo.strict().safeParse(queryRes.data).success).toBe(true);\n\n      await expect(\n        anonymousUser.get<IBaseQueryVo>(\n          urlBuilder(GET_DASHBOARD_INSTALL_PLUGIN_QUERY, {\n            pluginInstallId,\n            positionId: dashboardId,\n          }),\n          {\n            params: { baseId },\n          }\n        )\n      ).rejects.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/plugin-context-menu.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport {\n  createPlugin,\n  deletePlugin,\n  getPluginContextMenu,\n  getPluginContextMenuList,\n  installPluginContextMenu,\n  movePluginContextMenu,\n  pluginContextMenuGetItemSchema,\n  pluginContextMenuGetVoSchema,\n  pluginContextMenuInstallVoSchema,\n  PluginPosition,\n  publishPlugin,\n  removePluginContextMenu,\n  renamePluginContextMenu,\n  submitPlugin,\n  updatePluginContextMenuStorage,\n  z,\n} from '@teable/openapi';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Plugin Context Menu', () => {\n  let app: INestApplication;\n  let tableId: string;\n  const baseId = globalThis.testConfig.baseId;\n  let pluginId: string;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    const tableRes = await createTable(baseId, {\n      name: 'plugin-context-menu-table',\n    });\n    tableId = tableRes.id;\n\n    const res = await createPlugin({\n      name: 'plugin',\n      logo: 'https://logo.com',\n      positions: [PluginPosition.ContextMenu],\n    });\n    pluginId = res.data.id;\n    await submitPlugin(pluginId);\n    await publishPlugin(pluginId);\n  });\n\n  afterEach(async () => {\n    await deletePlugin(pluginId);\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it('api/table/:tableId/plugin-context-menu/install (POST)', async () => {\n    const res = await installPluginContextMenu(tableId, {\n      name: 'plugin',\n      pluginId,\n    });\n    expect(res.status).toBe(201);\n    expect(pluginContextMenuInstallVoSchema.strict().safeParse(res.data).success).toBe(true);\n  });\n\n  describe('other than install', () => {\n    let pluginInstallId: string;\n\n    beforeEach(async () => {\n      const res = await installPluginContextMenu(tableId, {\n        name: 'plugin',\n        pluginId,\n      });\n      pluginInstallId = res.data.pluginInstallId;\n    });\n\n    it('api/table/:tableId/plugin-context-menu (GET)', async () => {\n      const res = await getPluginContextMenuList(tableId);\n      expect(res.status).toBe(200);\n      expect(z.array(pluginContextMenuGetItemSchema.strict()).safeParse(res.data).success).toBe(\n        true\n      );\n      expect(res.data.length).toBe(1);\n    });\n\n    it('api/table/:tableId/plugin-context-menu/:pluginInstallId (GET)', async () => {\n      const res = await getPluginContextMenu(tableId, pluginInstallId);\n      expect(res.status).toBe(200);\n      expect(pluginContextMenuGetVoSchema.strict().safeParse(res.data).success).toBe(true);\n    });\n\n    it('api/table/:tableId/plugin-context-menu/:pluginInstallId/rename (PATCH)', async () => {\n      const res = await renamePluginContextMenu(tableId, pluginInstallId, {\n        name: 'new name',\n      });\n      expect(res.status).toBe(200);\n      expect(res.data.name).toBe('new name');\n    });\n\n    it('api/table/:tableId/plugin-context-menu/:pluginInstallId/update-storage (PUT)', async () => {\n      const res = await updatePluginContextMenuStorage(tableId, pluginInstallId, {\n        storage: {\n          name: 'new name',\n        },\n      });\n      expect(res.status).toBe(200);\n      expect(res.data.storage).toEqual({\n        name: 'new name',\n      });\n    });\n\n    it('api/table/:tableId/plugin-context-menu/:pluginInstallId (DELETE)', async () => {\n      const res = await removePluginContextMenu(tableId, pluginInstallId);\n      expect(res.status).toBe(200);\n    });\n\n    it('api/table/:tableId/plugin-context-menu/:pluginInstallId/move (PUT)', async () => {\n      const pluginInstallId2 = await installPluginContextMenu(tableId, {\n        name: 'plugin2',\n        pluginId,\n      }).then((res) => res.data.pluginInstallId);\n      const pluginInstallId3 = await installPluginContextMenu(tableId, {\n        name: 'plugin3',\n        pluginId,\n      }).then((res) => res.data.pluginInstallId);\n      const list = await getPluginContextMenuList(tableId);\n      expect(list.data.map((item) => item.pluginInstallId)).toEqual([\n        pluginInstallId,\n        pluginInstallId2,\n        pluginInstallId3,\n      ]);\n      const res = await movePluginContextMenu(tableId, pluginInstallId3, {\n        anchorId: pluginInstallId2,\n        position: 'before',\n      });\n      expect(res.status).toBe(200);\n      const list2 = await getPluginContextMenuList(tableId);\n      expect(list2.data.map((item) => item.pluginInstallId)).toEqual([\n        pluginInstallId,\n        pluginInstallId3,\n        pluginInstallId2,\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/plugin-panel.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createPlugin,\n  createPluginPanel,\n  deletePlugin,\n  deletePluginPanel,\n  duplicatePluginPanel,\n  duplicatePluginPanelInstalledPlugin,\n  getPluginPanel,\n  getPluginPanelPlugin,\n  installPluginPanel,\n  pluginPanelGetVoSchema,\n  pluginPanelPluginGetVoSchema,\n  PluginPosition,\n  publishPlugin,\n  removePluginPanelPlugin,\n  renamePluginPanel,\n  renamePluginPanelPlugin,\n  submitPlugin,\n  updatePluginPanelLayout,\n  updatePluginPanelStorage,\n} from '@teable/openapi';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('plugin panel', () => {\n  let app: INestApplication;\n  let pluginPanelId: string;\n  let tableId: string;\n  let table: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, {\n      name: 'plugin-panel-table',\n    });\n    tableId = table.id;\n    const res = await createPluginPanel(tableId, {\n      name: 'plugin panel',\n    });\n    pluginPanelId = res.data.id;\n  });\n\n  afterEach(async () => {\n    await deletePluginPanel(tableId, pluginPanelId);\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it('/api/table/:tableId/plugin-panel/:pluginPanelId/rename (PATCH)', async () => {\n    const res = await renamePluginPanel(tableId, pluginPanelId, {\n      name: 'new name',\n    });\n    expect(res.status).toBe(200);\n    expect(res.data.name).toBe('new name');\n  });\n\n  it('/api/table/:tableId/plugin-panel/:pluginPanelId (GET)', async () => {\n    const res = await getPluginPanel(tableId, pluginPanelId);\n    expect(res.status).toBe(200);\n    expect(res.data.id).toBe(pluginPanelId);\n    expect(pluginPanelGetVoSchema.strict().safeParse(res.data).success).toBe(true);\n  });\n\n  describe('plugin panel plugin', () => {\n    let pluginId: string;\n    beforeEach(async () => {\n      const res = await createPlugin({\n        name: 'plugin',\n        logo: 'https://logo.com',\n        positions: [PluginPosition.Panel],\n      });\n      pluginId = res.data.id;\n      await submitPlugin(pluginId);\n      await publishPlugin(pluginId);\n    });\n\n    afterEach(async () => {\n      await deletePlugin(pluginId);\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/install (POST)', async () => {\n      const res = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      expect(res.status).toBe(201);\n      expect(res.data.name).toBe('plugin');\n      expect(res.data.pluginInstallId).toBeDefined();\n\n      const pluginPanel = await getPluginPanel(tableId, pluginPanelId);\n      expect(pluginPanel.status).toBe(200);\n      expect(pluginPanelGetVoSchema.strict().safeParse(pluginPanel.data).success).toBe(true);\n      expect(pluginPanel.data.pluginMap?.[res.data.pluginInstallId].id).toBe(pluginId);\n      expect(pluginPanel.data.layout).toBeDefined();\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/duplicate (POST)', async () => {\n      const installedPlugin = (\n        await installPluginPanel(tableId, pluginPanelId, {\n          name: 'plugin',\n          pluginId,\n        })\n      ).data;\n      const textField = table.fields.find((field) => field.name === 'Name')!;\n      const numberField = table.fields.find((field) => field.name === 'Count')!;\n      await updatePluginPanelStorage(tableId, pluginPanelId, installedPlugin.pluginInstallId, {\n        storage: {\n          config: {\n            type: 'bar',\n            xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n            yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n          },\n          query: {\n            from: table.id,\n            select: [\n              { column: textField.id, alias: 'Name', type: 'field' },\n              { column: numberField.id, alias: 'Count', type: 'field' },\n            ],\n          },\n        },\n      });\n      const duplicatePanel = (\n        await duplicatePluginPanel(tableId, pluginPanelId, {\n          name: 'plugin-panel-copy',\n        })\n      ).data;\n      const duplicatedPluginPanel = (await getPluginPanel(tableId, duplicatePanel.id)).data;\n      const duplicateInstalledPlugin = await getPluginPanelPlugin(\n        tableId,\n        duplicatePanel.id,\n        duplicatedPluginPanel.layout![0].pluginInstallId!\n      );\n      expect(duplicateInstalledPlugin.data.storage).toEqual({\n        config: {\n          type: 'bar',\n          xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n          yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n        },\n        query: {\n          from: table.id,\n          select: [\n            { column: textField.id, alias: 'Name', type: 'field' },\n            { column: numberField.id, alias: 'Count', type: 'field' },\n          ],\n        },\n      });\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/plugin/:pluginInstallId/duplicate (POST)', async () => {\n      const installedPlugin = (\n        await installPluginPanel(tableId, pluginPanelId, {\n          name: 'plugin',\n          pluginId,\n        })\n      ).data;\n      const textField = table.fields.find((field) => field.name === 'Name')!;\n      const numberField = table.fields.find((field) => field.name === 'Count')!;\n      await updatePluginPanelStorage(tableId, pluginPanelId, installedPlugin.pluginInstallId, {\n        storage: {\n          config: {\n            type: 'bar',\n            xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n            yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n          },\n          query: {\n            from: table.id,\n            select: [\n              { column: textField.id, alias: 'Name', type: 'field' },\n              { column: numberField.id, alias: 'Count', type: 'field' },\n            ],\n          },\n        },\n      });\n      const duplicatedInstalledPlugin = (\n        await duplicatePluginPanelInstalledPlugin(\n          tableId,\n          pluginPanelId,\n          installedPlugin.pluginInstallId,\n          {\n            name: 'plugin copy',\n          }\n        )\n      ).data;\n      const duplicatedInstallPluginInfo = await getPluginPanelPlugin(\n        tableId,\n        pluginPanelId,\n        duplicatedInstalledPlugin.id\n      );\n      expect(duplicatedInstallPluginInfo.data.storage).toEqual({\n        config: {\n          type: 'bar',\n          xAxis: [{ column: 'Name', display: { type: 'bar', position: 'auto' } }],\n          yAxis: [{ column: 'Count', display: { type: 'bar', position: 'auto' } }],\n        },\n        query: {\n          from: table.id,\n          select: [\n            { column: textField.id, alias: 'Name', type: 'field' },\n            { column: numberField.id, alias: 'Count', type: 'field' },\n          ],\n        },\n      });\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/plugin/:pluginInstallId/rename (PATCH)', async () => {\n      const installRes = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const res = await renamePluginPanelPlugin(\n        tableId,\n        pluginPanelId,\n        installRes.data.pluginInstallId,\n        'new name'\n      );\n      expect(res.status).toBe(200);\n      expect(res.data.name).toBe('new name');\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/plugin/:pluginInstallId (DELETE)', async () => {\n      const installRes = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const res = await removePluginPanelPlugin(\n        tableId,\n        pluginPanelId,\n        installRes.data.pluginInstallId\n      );\n      expect(res.status).toBe(200);\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/plugin/:pluginInstallId (GET)', async () => {\n      const installRes = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const res = await getPluginPanelPlugin(\n        tableId,\n        pluginPanelId,\n        installRes.data.pluginInstallId\n      );\n      expect(res.status).toBe(200);\n      expect(pluginPanelPluginGetVoSchema.strict().safeParse(res.data).success).toBe(true);\n      expect(res.data.pluginId).toBe(pluginId);\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/update-layout (PATCH)', async () => {\n      const installRes = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const res = await updatePluginPanelLayout(tableId, pluginPanelId, {\n        layout: [\n          {\n            pluginInstallId: installRes.data.pluginInstallId,\n            x: 0,\n            y: 0,\n            w: 1,\n            h: 4,\n          },\n        ],\n      });\n      expect(res.status).toBe(200);\n      expect(res.data.layout).toBeDefined();\n    });\n\n    it('/api/table/:tableId/plugin-panel/:pluginPanelId/plugin/:pluginInstallId/storage (PATCH)', async () => {\n      const installRes = await installPluginPanel(tableId, pluginPanelId, {\n        name: 'plugin',\n        pluginId,\n      });\n      const res = await updatePluginPanelStorage(\n        tableId,\n        pluginPanelId,\n        installRes.data.pluginInstallId,\n        {\n          storage: {\n            test: 'test',\n          },\n        }\n      );\n      expect(res.status).toBe(200);\n      expect(res.data.storage).toEqual({ test: 'test' });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/plugin.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { ICreatePluginRo, IGetPluginCenterListVo } from '@teable/openapi';\nimport {\n  createPlugin,\n  createPluginVoSchema,\n  deletePlugin,\n  getPlugin,\n  getPluginCenterList,\n  getPluginCenterListVoSchema,\n  getPlugins,\n  getPluginsVoSchema,\n  getPluginVoSchema,\n  PLUGIN_CENTER_GET_LIST,\n  PluginPosition,\n  PluginStatus,\n  publishPlugin,\n  submitPlugin,\n  updatePlugin,\n} from '@teable/openapi';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { initApp } from './utils/init-app';\n\nconst mockPlugin: ICreatePluginRo = {\n  name: 'plugin',\n  logo: '/plugin/xxxxxxx',\n  description: 'desc',\n  detailDesc: 'detail',\n  helpUrl: 'https://help.com',\n  positions: [PluginPosition.Dashboard],\n  i18n: {\n    en: {\n      name: 'plugin',\n      description: 'desc',\n      detailDesc: 'detail',\n    },\n  },\n  autoCreateMember: true,\n};\ndescribe('PluginController', () => {\n  let app: INestApplication;\n  let pluginId: string;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  beforeEach(async () => {\n    const res = await createPlugin(mockPlugin);\n    pluginId = res.data.id;\n  });\n\n  afterEach(async () => {\n    await deletePlugin(pluginId);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('/api/plugin (POST)', async () => {\n    const res = await createPlugin(mockPlugin);\n    expect(createPluginVoSchema.strict().safeParse(res.data).success).toBe(true);\n    expect(res.data.status).toBe(PluginStatus.Developing);\n    expect(res.data.pluginUser).not.toBeUndefined();\n    await deletePlugin(res.data.id);\n  });\n\n  it('/api/plugin/{pluginId} (GET)', async () => {\n    const getRes = await getPlugin(pluginId);\n    expect(getPluginVoSchema.strict().safeParse(getRes.data).success).toBe(true);\n    expect(getRes.data.status).toBe(PluginStatus.Developing);\n    expect(getRes.data.pluginUser).not.toBeUndefined();\n    expect(getRes.data.pluginUser?.name).toEqual('plugin');\n  });\n\n  it('/api/plugin/{pluginId} (GET) - 404', async () => {\n    const error = await getError(() => getPlugin('invalid-id'));\n    expect(error?.status).toBe(404);\n  });\n\n  it('/api/plugin (GET)', async () => {\n    const getRes = await getPlugins();\n    expect(getPluginsVoSchema.safeParse(getRes.data).success).toBe(true);\n    expect(getRes.data).toHaveLength(3);\n  });\n\n  it('/api/plugin/{pluginId} (DELETE)', async () => {\n    const res = await createPlugin(mockPlugin);\n    await deletePlugin(res.data.id);\n    const error = await getError(() => getPlugin(res.data.id));\n    expect(error?.status).toBe(404);\n  });\n\n  it('/api/plugin/{pluginId} (PUT)', async () => {\n    const res = await createPlugin(mockPlugin);\n    const updatePluginRo = {\n      name: 'updated',\n      description: 'updated',\n      detailDesc: 'updated',\n      helpUrl: 'https://updated.com',\n      logo: 'https://updated.com/plugin/updated',\n      positions: [PluginPosition.Dashboard],\n      i18n: {\n        en: {\n          name: 'updated',\n          description: 'updated',\n          detailDesc: 'updated',\n        },\n      },\n    };\n    const putRes = await updatePlugin(res.data.id, updatePluginRo);\n    await deletePlugin(res.data.id);\n    expect(putRes.data.name).toBe(updatePluginRo.name);\n    expect(putRes.data.description).toBe(updatePluginRo.description);\n    expect(putRes.data.detailDesc).toBe(updatePluginRo.detailDesc);\n    expect(putRes.data.helpUrl).toBe(updatePluginRo.helpUrl);\n    expect(putRes.data.logo).toEqual(expect.stringContaining('plugin/updated'));\n    expect(putRes.data.i18n).toEqual(updatePluginRo.i18n);\n  });\n\n  it('/api/plugin/{pluginId}/submit (POST)', async () => {\n    const res = await createPlugin(mockPlugin);\n    const submitRes = await submitPlugin(res.data.id);\n    await deletePlugin(res.data.id);\n    expect(submitRes.status).toBe(200);\n  });\n\n  it('/api/admin/plugin/{pluginId}/publish (PATCH)', async () => {\n    const res = await createPlugin(mockPlugin);\n    await submitPlugin(res.data.id);\n    await publishPlugin(res.data.id);\n    const getRes = await getPlugin(res.data.id);\n    await deletePlugin(res.data.id);\n    expect(getRes.data.status).toBe(PluginStatus.Published);\n  });\n\n  it('/api/plugin/center/list (GET)', async () => {\n    const preList = await getPluginCenterList();\n    const res = await createPlugin(mockPlugin);\n    const postList = await getPluginCenterList();\n    await deletePlugin(res.data.id);\n    expect(postList.data).toHaveLength(preList.data.length + 1);\n    expect(\n      postList.data.find((p) => p.status === PluginStatus.Developing && p.id === res.data.id)\n    ).not.toBeUndefined();\n    expect(getPluginCenterListVoSchema.safeParse(preList.data).success).toBe(true);\n  });\n\n  it('/api/plugin/center/list (GET) - 404', async () => {\n    const preList = await getPluginCenterList(mockPlugin.positions);\n    const res = await createPlugin(mockPlugin);\n    const newUserAxios = await createNewUserAxios({\n      email: 'plugin-center-list@test.com',\n      password: '12345678',\n    });\n    const plugins = await newUserAxios.get<IGetPluginCenterListVo>(PLUGIN_CENTER_GET_LIST, {\n      params: {\n        positions: JSON.stringify(mockPlugin.positions),\n      },\n    });\n    await deletePlugin(res.data.id);\n    expect(plugins.data).toHaveLength(preList.data.length - 1);\n    expect(plugins.data.some((p) => p.id === res.data.id)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-bulk-delete.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { performance } from 'node:perf_hooks';\nimport type { INestApplication } from '@nestjs/common';\nimport { Colors, FieldKeyType, FieldType, RatingIcon, Relationship } from '@teable/core';\nimport type { IRecord } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { ClsService } from 'nestjs-cls';\nimport { RecordModifyService } from '../src/features/record/record-modify/record-modify.service';\nimport type { IClsStore } from '../src/types/cls';\nimport {\n  createRecords,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  runWithTestUser,\n} from './utils/init-app';\n\nconst PERF_PREFIX = '[Record bulk delete]';\n\ndescribe('Record bulk delete performance (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const userId = globalThis.testConfig.userId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it(\n    'deletes 8000 rows from a 10000-row table with all major column types',\n    { timeout: 180_000 },\n    async () => {\n      const linkedTable = await measure('create linked table', () =>\n        createTable(baseId, {\n          name: 'Bulk Delete Linked',\n          fields: [\n            {\n              name: 'Name',\n              type: FieldType.SingleLineText,\n            },\n          ],\n          records: Array.from({ length: 10 }, (_, index) => ({\n            fields: {\n              Name: `Linked ${index + 1}`,\n            },\n          })),\n        })\n      );\n\n      let mainTable: ITableFullVo | null = null;\n\n      try {\n        const recordModifyService = app.get<RecordModifyService>(RecordModifyService);\n        const clsService = app.get<ClsService<IClsStore>>(ClsService);\n\n        mainTable = await measure('create main table', () =>\n          createTable(baseId, {\n            name: 'Bulk Delete Main',\n            records: [],\n            fields: [\n              {\n                name: 'Title',\n                type: FieldType.SingleLineText,\n              },\n              {\n                name: 'Description',\n                type: FieldType.LongText,\n              },\n              {\n                name: 'Score',\n                type: FieldType.Number,\n              },\n              {\n                name: 'Completed',\n                type: FieldType.Checkbox,\n              },\n              {\n                name: 'Due Date',\n                type: FieldType.Date,\n              },\n              {\n                name: 'Status',\n                type: FieldType.SingleSelect,\n                options: {\n                  choices: [\n                    { name: 'Not Started', color: Colors.Gray },\n                    { name: 'In Progress', color: Colors.Blue },\n                    { name: 'Completed', color: Colors.Green },\n                  ],\n                },\n              },\n              {\n                name: 'Tags',\n                type: FieldType.MultipleSelect,\n                options: {\n                  choices: [\n                    { name: 'Tag 1', color: Colors.Red },\n                    { name: 'Tag 2', color: Colors.Orange },\n                    { name: 'Tag 3', color: Colors.Yellow },\n                    { name: 'Tag 4', color: Colors.Green },\n                    { name: 'Tag 5', color: Colors.Blue },\n                  ],\n                },\n              },\n              {\n                name: 'Member',\n                type: FieldType.User,\n              },\n              {\n                name: 'Rating',\n                type: FieldType.Rating,\n                options: {\n                  icon: RatingIcon.Star,\n                  color: Colors.YellowBright,\n                  max: 5,\n                },\n              },\n              {\n                name: 'Linked Item',\n                type: FieldType.Link,\n                options: {\n                  relationship: Relationship.ManyOne,\n                  foreignTableId: linkedTable.id,\n                },\n              },\n            ],\n          })\n        );\n\n        const mainTableRef = mainTable;\n        if (!mainTableRef) {\n          throw new Error('Main table creation failed');\n        }\n        const mainTableId = mainTableRef.id;\n\n        const totalRecords = 10_000;\n        const deleteCount = 8_000;\n        const batchSize = 1_000;\n        const statuses = ['Not Started', 'In Progress', 'Completed'];\n        const tagOptions = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5'];\n        const linkedRecords = linkedTable.records ?? [];\n        const allRecordIds: string[] = [];\n\n        await measure('insert 10k records', async () => {\n          for (let offset = 0; offset < totalRecords; offset += batchSize) {\n            const chunkSize = Math.min(batchSize, totalRecords - offset);\n            const batch = Array.from({ length: chunkSize }, (_, index) => {\n              const seq = offset + index;\n              const firstTag = tagOptions[seq % tagOptions.length];\n              const secondTag = tagOptions[(seq + 1) % tagOptions.length];\n              const linkedTarget =\n                seq < linkedRecords.length\n                  ? { id: linkedRecords[seq % linkedRecords.length].id }\n                  : null;\n              return {\n                fields: {\n                  Title: `Record ${seq + 1}`,\n                  Description: `Long description for record ${seq + 1}`,\n                  Score: seq,\n                  Completed: seq % 2 === 0,\n                  'Due Date': new Date(Date.UTC(2024, 0, (seq % 28) + 1)).toISOString(),\n                  Status: statuses[seq % statuses.length],\n                  Tags: firstTag === secondTag ? [firstTag] : [firstTag, secondTag],\n                  Member: userId,\n                  Rating: (seq % 5) + 1,\n                  'Linked Item': linkedTarget,\n                },\n              };\n            });\n\n            const { records } = await createRecords(mainTableId, {\n              fieldKeyType: FieldKeyType.Name,\n              typecast: true,\n              records: batch,\n            });\n\n            allRecordIds.push(...records.map((record) => record.id));\n          }\n        });\n\n        expect(allRecordIds).toHaveLength(totalRecords);\n        // eslint-disable-next-line no-console\n        console.info(`${PERF_PREFIX} Seeded ${allRecordIds.length} records`);\n\n        const recordsToDelete = allRecordIds.slice(0, deleteCount);\n\n        const deleteResult = await measure('delete 8000 records', () =>\n          runWithTestUser(clsService, () =>\n            recordModifyService.deleteRecords(mainTableId, recordsToDelete)\n          )\n        );\n        expect(deleteResult.records).toHaveLength(deleteCount);\n\n        const remainingRecords = await measure('fetch remaining records', () =>\n          collectAllRecords(mainTableId)\n        );\n        expect(remainingRecords).toHaveLength(totalRecords - deleteCount);\n\n        const remainingIds = new Set(remainingRecords.map((record) => record.id));\n        for (const deletedId of recordsToDelete) {\n          expect(remainingIds.has(deletedId)).toBe(false);\n        }\n      } finally {\n        if (mainTable) {\n          await measure('cleanup main table', () => permanentDeleteTable(baseId, mainTable!.id));\n        }\n        await measure('cleanup linked table', () => permanentDeleteTable(baseId, linkedTable.id));\n      }\n    }\n  );\n});\n\nasync function collectAllRecords(tableId: string): Promise<IRecord[]> {\n  const take = 1_000;\n  let skip = 0;\n  const aggregated: IRecord[] = [];\n\n  // eslint-disable-next-line no-constant-condition\n  while (true) {\n    const page = await getRecords(tableId, { skip, take });\n    aggregated.push(...page.records);\n    if (page.records.length < take) {\n      break;\n    }\n    skip += take;\n  }\n\n  return aggregated;\n}\n\nasync function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {\n  const start = performance.now();\n  try {\n    return await fn();\n  } finally {\n    const durationMs = performance.now() - start;\n    // eslint-disable-next-line no-console\n    console.info(`${PERF_PREFIX} ${label} took ${(durationMs / 1000).toFixed(2)}s`);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-delete-link-cleanup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, ILinkFieldOptions } from '@teable/core';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport type { Knex } from 'knex';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  deleteRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Record delete link cleanup (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let knex: Knex;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    knex = app.get('CUSTOM_KNEX' as any);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('deletes records with junction links even when link column is null', async () => {\n    let hostTable: ITableFullVo | null = null;\n    let foreignTable: ITableFullVo | null = null;\n\n    try {\n      foreignTable = await createTable(baseId, {\n        name: 'Delete Link Foreign',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      hostTable = await createTable(baseId, {\n        name: 'Delete Link Host',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      const linkField = await createField(hostTable.id, {\n        name: 'Links',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      } as IFieldRo);\n\n      const { records: foreignRecords } = await createRecords(foreignTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { Name: 'Target' } }],\n      });\n      const foreignRecord = foreignRecords[0];\n\n      const { records: hostRecords } = await createRecords(hostTable.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { Name: 'Host' } }],\n      });\n      const hostRecord = hostRecords[0];\n\n      await updateRecordByApi(hostTable.id, hostRecord.id, linkField.id, [\n        { id: foreignRecord.id },\n      ]);\n\n      const linkOptions = linkField.options as ILinkFieldOptions;\n      const beforeRows = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptions.fkHostTableName)\n          .where(linkOptions.selfKeyName, hostRecord.id)\n          .count({ count: '*' })\n          .toQuery()\n      );\n      expect(Number(beforeRows[0]?.count ?? 0)).toBe(1);\n\n      const hostMeta = await prisma.tableMeta.findUniqueOrThrow({\n        where: { id: hostTable.id },\n        select: { dbTableName: true },\n      });\n      const linkDbFieldName = (linkField as any).dbFieldName as string;\n      expect(linkDbFieldName).toBeTruthy();\n\n      const clearSql = knex(hostMeta.dbTableName)\n        .update({ [linkDbFieldName]: null })\n        .where('__id', hostRecord.id)\n        .toQuery();\n      await prisma.$executeRawUnsafe(clearSql);\n\n      const linkColRows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(\n        knex(hostMeta.dbTableName).select(linkDbFieldName).where('__id', hostRecord.id).toQuery()\n      );\n      expect(linkColRows[0]?.[linkDbFieldName]).toBeNull();\n\n      await deleteRecords(hostTable.id, [hostRecord.id]);\n\n      const afterRows = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptions.fkHostTableName)\n          .where(linkOptions.selfKeyName, hostRecord.id)\n          .count({ count: '*' })\n          .toQuery()\n      );\n      expect(Number(afterRows[0]?.count ?? 0)).toBe(0);\n    } finally {\n      if (hostTable) {\n        await permanentDeleteTable(baseId, hostTable.id);\n      }\n      if (foreignTable) {\n        await permanentDeleteTable(baseId, foreignTable.id);\n      }\n    }\n  });\n\n  it('deletes foreign record when junction has data but symmetric link column is null (ManyMany)', async () => {\n    // This test simulates the user's scenario:\n    // - Table A has a ManyMany link to Table B\n    // - Records are linked via junction table\n    // - The link column in Table B (symmetric field) is manually set to null\n    // - Deleting Table B record should succeed and clean up junction table\n    let tableA: ITableFullVo | null = null;\n    let tableB: ITableFullVo | null = null;\n\n    try {\n      tableA = await createTable(baseId, {\n        name: 'Table A',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      tableB = await createTable(baseId, {\n        name: 'Table B',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      // Create link field on Table A pointing to Table B\n      const linkFieldA = await createField(tableA.id, {\n        name: 'Link to B',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      const linkOptionsA = linkFieldA.options as ILinkFieldOptions;\n      const symmetricFieldId = linkOptionsA.symmetricFieldId;\n      expect(symmetricFieldId).toBeTruthy();\n\n      // Create records\n      const { records: recordsA } = await createRecords(tableA.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { Name: 'Record A' } }],\n      });\n      const recordA = recordsA[0];\n\n      const { records: recordsB } = await createRecords(tableB.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { Name: 'Record B' } }],\n      });\n      const recordB = recordsB[0];\n\n      // Establish link from A to B\n      await updateRecordByApi(tableA.id, recordA.id, linkFieldA.id, [{ id: recordB.id }]);\n\n      // Verify junction table has the link\n      const beforeJunctionCount = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptionsA.fkHostTableName)\n          .where(linkOptionsA.foreignKeyName, recordB.id)\n          .count({ count: '*' })\n          .toQuery()\n      );\n      expect(Number(beforeJunctionCount[0]?.count ?? 0)).toBe(1);\n\n      // Manually clear the symmetric link column on Table B (simulate data inconsistency)\n      const tableBMeta = await prisma.tableMeta.findUniqueOrThrow({\n        where: { id: tableB.id },\n        select: { dbTableName: true },\n      });\n\n      const symmetricField = await prisma.field.findUniqueOrThrow({\n        where: { id: symmetricFieldId! },\n        select: { dbFieldName: true },\n      });\n\n      const clearSymmetricSql = knex(tableBMeta.dbTableName)\n        .update({ [symmetricField.dbFieldName]: null })\n        .where('__id', recordB.id)\n        .toQuery();\n      await prisma.$executeRawUnsafe(clearSymmetricSql);\n\n      // Verify the symmetric link column is now null\n      const linkColRows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(\n        knex(tableBMeta.dbTableName)\n          .select(symmetricField.dbFieldName)\n          .where('__id', recordB.id)\n          .toQuery()\n      );\n      expect(linkColRows[0]?.[symmetricField.dbFieldName]).toBeNull();\n\n      // Delete record B - this should succeed even though symmetric link column is null\n      // but junction table still has the reference\n      await deleteRecords(tableB.id, [recordB.id]);\n\n      // Verify junction table is cleaned up\n      const afterJunctionCount = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptionsA.fkHostTableName)\n          .where(linkOptionsA.foreignKeyName, recordB.id)\n          .count({ count: '*' })\n          .toQuery()\n      );\n      expect(Number(afterJunctionCount[0]?.count ?? 0)).toBe(0);\n    } finally {\n      if (tableA) {\n        await permanentDeleteTable(baseId, tableA.id);\n      }\n      if (tableB) {\n        await permanentDeleteTable(baseId, tableB.id);\n      }\n    }\n  });\n\n  it('deletes multiple records with inconsistent junction data (ManyMany)', async () => {\n    // Test bulk deletion of records when some have inconsistent link column data\n    let tableA: ITableFullVo | null = null;\n    let tableB: ITableFullVo | null = null;\n\n    try {\n      tableA = await createTable(baseId, {\n        name: 'Bulk Delete Table A',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      tableB = await createTable(baseId, {\n        name: 'Bulk Delete Table B',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n      });\n\n      const linkField = await createField(tableA.id, {\n        name: 'Links',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: tableB.id,\n        },\n      } as IFieldRo);\n\n      const linkOptions = linkField.options as ILinkFieldOptions;\n\n      // Create multiple records in both tables\n      const { records: recordsB } = await createRecords(tableB.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          { fields: { Name: 'Target 1' } },\n          { fields: { Name: 'Target 2' } },\n          { fields: { Name: 'Target 3' } },\n        ],\n      });\n\n      const { records: recordsA } = await createRecords(tableA.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [{ fields: { Name: 'Source 1' } }, { fields: { Name: 'Source 2' } }],\n      });\n\n      // Link Source 1 to Target 1 and Target 2\n      await updateRecordByApi(tableA.id, recordsA[0].id, linkField.id, [\n        { id: recordsB[0].id },\n        { id: recordsB[1].id },\n      ]);\n\n      // Link Source 2 to Target 2 and Target 3\n      await updateRecordByApi(tableA.id, recordsA[1].id, linkField.id, [\n        { id: recordsB[1].id },\n        { id: recordsB[2].id },\n      ]);\n\n      // Verify junction table has 4 rows\n      const beforeCount = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptions.fkHostTableName).count({ count: '*' }).toQuery()\n      );\n      expect(Number(beforeCount[0]?.count ?? 0)).toBe(4);\n\n      // Clear link column for Source 1 (simulate inconsistency)\n      const tableAMeta = await prisma.tableMeta.findUniqueOrThrow({\n        where: { id: tableA.id },\n        select: { dbTableName: true },\n      });\n      const linkDbFieldName = (linkField as any).dbFieldName as string;\n\n      await prisma.$executeRawUnsafe(\n        knex(tableAMeta.dbTableName)\n          .update({ [linkDbFieldName]: null })\n          .where('__id', recordsA[0].id)\n          .toQuery()\n      );\n\n      // Delete both source records - should succeed and clean junction table\n      await deleteRecords(tableA.id, [recordsA[0].id, recordsA[1].id]);\n\n      // Verify all junction rows are cleaned up\n      const afterCount = await prisma.$queryRawUnsafe<{ count: bigint }[]>(\n        knex(linkOptions.fkHostTableName).count({ count: '*' }).toQuery()\n      );\n      expect(Number(afterCount[0]?.count ?? 0)).toBe(0);\n    } finally {\n      if (tableA) {\n        await permanentDeleteTable(baseId, tableA.id);\n      }\n      if (tableB) {\n        await permanentDeleteTable(baseId, tableB.id);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-field-key.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, SortFunc } from '@teable/core';\nimport { createRecords, updateRecord, type ITableFullVo } from '@teable/openapi';\nimport { createTable, permanentDeleteTable, getRecords, initApp } from './utils/init-app';\n\ndescribe('Record field key (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let table: ITableFullVo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    table = await createTable(baseId, {\n      fields: [\n        {\n          name: 'field1',\n          dbFieldName: 'db_field1',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      records: [\n        {\n          fields: {\n            field1: 'test1',\n          },\n        },\n        {\n          fields: {\n            field1: 'test2',\n          },\n        },\n      ],\n    });\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n    await app.close();\n  });\n\n  it('should get filtered records with db field name', async () => {\n    const records = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: 'db_field1',\n            operator: 'is',\n            value: 'test2',\n          },\n        ],\n      },\n    });\n\n    expect(records.records[0].fields.db_field1).toBe('test2');\n  });\n\n  it('should get sorted records with db field name', async () => {\n    const records = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      orderBy: [\n        {\n          fieldId: 'db_field1',\n          order: SortFunc.Desc,\n        },\n      ],\n    });\n\n    expect(records.records[0].fields.db_field1).toBe('test2');\n    expect(records.records[1].fields.db_field1).toBe('test1');\n  });\n\n  it('should get grouped records with db field name', async () => {\n    const records = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      groupBy: [{ fieldId: 'db_field1', order: SortFunc.Desc }],\n    });\n\n    expect(records.records[0].fields.db_field1).toBe('test2');\n    expect(records.records[1].fields.db_field1).toBe('test1');\n  });\n\n  it('should get searched records with db field name', async () => {\n    const records = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      search: ['test2', 'db_field1', true],\n    });\n\n    expect(records.records[0].fields.db_field1).toBe('test2');\n  });\n\n  it('should update record with db field name', async () => {\n    const records = await updateRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      record: { fields: { db_field1: 'test3' } },\n    });\n\n    expect(records.data.fields.db_field1).toBe('test3');\n  });\n\n  it('should create record with db field name', async () => {\n    const records = await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.DbFieldName,\n      records: [{ fields: { db_field1: 'test4' } }],\n    });\n\n    expect(records.data.records[0].fields.db_field1).toBe('test4');\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-filter-lookup-number-param.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship, and, is, isGreater } from '@teable/core';\nimport { createField, getRecords as apiGetRecords } from '@teable/openapi';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Record filter lookup multiple-number bindings (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  let foreignTableId: string | undefined;\n  let mainTableId: string | undefined;\n  let linkFieldId: string | undefined;\n  let foreignNumberFieldId: string | undefined;\n  let lookupNumberFieldId: string | undefined;\n\n  const foreignNumberFieldName = 'num';\n  const linkFieldName = 'links';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const foreign = await createTable(baseId, {\n      name: `lookup_num_foreign_${Date.now()}`,\n      fields: [{ name: foreignNumberFieldName, type: FieldType.Number }],\n      records: [\n        { fields: { [foreignNumberFieldName]: 9 } },\n        { fields: { [foreignNumberFieldName]: 11 } },\n        { fields: { [foreignNumberFieldName]: 1 } },\n      ],\n    });\n    foreignTableId = foreign.id;\n    foreignNumberFieldId = foreign.fields?.find((f) => f.name === foreignNumberFieldName)?.id;\n    if (!foreignTableId) throw new Error('foreignTableId not found');\n    if (!foreignNumberFieldId) throw new Error('foreignNumberFieldId not found');\n\n    const foreign9 = foreign.records?.[0]?.id;\n    const foreign11 = foreign.records?.[1]?.id;\n    const foreign1 = foreign.records?.[2]?.id;\n    if (!foreign9 || !foreign11 || !foreign1) throw new Error('foreign records not found');\n\n    const main = await createTable(baseId, {\n      name: `lookup_num_main_${Date.now()}`,\n      fields: [\n        { name: 'name', type: FieldType.SingleLineText },\n        {\n          name: linkFieldName,\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId: foreignTableId,\n            isOneWay: false,\n          },\n        },\n      ],\n      records: [\n        {\n          fields: {\n            name: 'a',\n            [linkFieldName]: [{ id: foreign9 }, { id: foreign11 }],\n          },\n        },\n        {\n          fields: {\n            name: 'b',\n            [linkFieldName]: [{ id: foreign9 }],\n          },\n        },\n        {\n          fields: {\n            name: 'c',\n            [linkFieldName]: [{ id: foreign1 }],\n          },\n        },\n        {\n          fields: {\n            name: 'd',\n          },\n        },\n      ],\n    });\n    mainTableId = main.id;\n    linkFieldId = main.fields?.find((f) => f.name === linkFieldName)?.id;\n    if (!mainTableId) throw new Error('mainTableId not found');\n    if (!linkFieldId) throw new Error('linkFieldId not found');\n\n    const lookupFieldRes = await createField(mainTableId, {\n      name: 'lookup_num',\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: foreignTableId,\n        lookupFieldId: foreignNumberFieldId,\n        linkFieldId: linkFieldId,\n      },\n    });\n    lookupNumberFieldId = lookupFieldRes.data.id;\n  });\n\n  afterAll(async () => {\n    if (mainTableId) {\n      await permanentDeleteTable(baseId, mainTableId);\n    }\n    if (foreignTableId) {\n      await permanentDeleteTable(baseId, foreignTableId);\n    }\n    await app.close();\n  });\n\n  it('filters lookup number array with `is`', async () => {\n    const res = await apiGetRecords(mainTableId!, {\n      fieldKeyType: FieldKeyType.Id,\n      filter: {\n        conjunction: and.value,\n        filterSet: [{ fieldId: lookupNumberFieldId!, operator: is.value, value: 9 }],\n      },\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.records).toHaveLength(2);\n  });\n\n  it('filters lookup number array with `isGreater`', async () => {\n    const res = await apiGetRecords(mainTableId!, {\n      fieldKeyType: FieldKeyType.Id,\n      filter: {\n        conjunction: and.value,\n        filterSet: [{ fieldId: lookupNumberFieldId!, operator: isGreater.value, value: 10 }],\n      },\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.records).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-filter-lookup-string-question-mark.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship, and, is } from '@teable/core';\nimport { getRecords as apiGetRecords } from '@teable/openapi';\nimport { createField, createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Record filter lookup string with question mark (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  let foreignTableId: string | undefined;\n  let mainTableId: string | undefined;\n  let lookupFieldId: string | undefined;\n\n  const valueWithQuestionMark = 'https://example.com/path?param=value';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const foreign = await createTable(baseId, {\n      name: `lookup_str_foreign_${Date.now()}`,\n      fields: [{ name: 'url', type: FieldType.SingleLineText }],\n      records: [\n        { fields: { url: valueWithQuestionMark } },\n        { fields: { url: 'https://example.com/other' } },\n      ],\n    });\n    foreignTableId = foreign.id;\n    const foreignUrlFieldId = foreign.fields?.find((f) => f.name === 'url')?.id;\n    if (!foreignTableId) throw new Error('foreignTableId not found');\n    if (!foreignUrlFieldId) throw new Error('foreignUrlFieldId not found');\n\n    const foreignUrlRecordId = foreign.records?.[0]?.id;\n    const foreignOtherRecordId = foreign.records?.[1]?.id;\n    if (!foreignUrlRecordId || !foreignOtherRecordId) throw new Error('foreign records not found');\n\n    const main = await createTable(baseId, {\n      name: `lookup_str_main_${Date.now()}`,\n      fields: [\n        { name: 'name', type: FieldType.SingleLineText },\n        {\n          name: 'links',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyMany,\n            foreignTableId,\n            isOneWay: false,\n          },\n        },\n      ],\n      records: [\n        { fields: { name: 'a', links: [{ id: foreignUrlRecordId }] } },\n        { fields: { name: 'b', links: [{ id: foreignOtherRecordId }] } },\n        {\n          fields: { name: 'c', links: [{ id: foreignUrlRecordId }, { id: foreignOtherRecordId }] },\n        },\n      ],\n    });\n    mainTableId = main.id;\n    const linkFieldId = main.fields?.find((f) => f.name === 'links')?.id;\n    if (!mainTableId) throw new Error('mainTableId not found');\n    if (!linkFieldId) throw new Error('linkFieldId not found');\n\n    const lookupField = await createField(mainTableId, {\n      name: 'lookup_url',\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId,\n        lookupFieldId: foreignUrlFieldId,\n        linkFieldId,\n      },\n    });\n    lookupFieldId = lookupField.id;\n  });\n\n  afterAll(async () => {\n    if (mainTableId) {\n      await permanentDeleteTable(baseId, mainTableId);\n    }\n    if (foreignTableId) {\n      await permanentDeleteTable(baseId, foreignTableId);\n    }\n    await app.close();\n  });\n\n  it('filters lookup string values containing \"?\" with `is`', async () => {\n    const res = await apiGetRecords(mainTableId!, {\n      fieldKeyType: FieldKeyType.Id,\n      filter: {\n        conjunction: and.value,\n        filterSet: [{ fieldId: lookupFieldId!, operator: is.value, value: valueWithQuestionMark }],\n      },\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.records).toHaveLength(2);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-filter-query-issues.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFilter, ILookupOptionsRo } from '@teable/core';\nimport {\n  and,\n  contains,\n  doesNotContain,\n  DriverClient,\n  FieldKeyType,\n  FieldType,\n  is,\n  Relationship,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  getRecords as apiGetRecords,\n  getAggregation,\n  StatisticsFunc,\n  toggleTableIndex,\n  TableIndex,\n} from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI Record-Filter-Query Issues (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  // T1613: Boolean formula field filter and aggregation not working correctly\n  describe('T1613: boolean field filter and aggregation', () => {\n    let formulaTable: ITableFullVo;\n    let formulaFieldId: string;\n    let checkboxTable: ITableFullVo;\n    let checkboxFieldId: string;\n    let lookupSourceTable: ITableFullVo;\n    let lookupMainTable: ITableFullVo;\n    let lookupFieldId: string;\n\n    beforeAll(async () => {\n      // Setup formula table (2 true, 2 false, 2 null)\n      formulaTable = await createTable(baseId, {\n        name: 'boolean_formula_test',\n        fields: [{ name: 'Num', type: FieldType.Number }],\n        records: [\n          { fields: { Num: 5 } },\n          { fields: { Num: 10 } },\n          { fields: { Num: 1 } },\n          { fields: { Num: 2 } },\n          { fields: { Num: null } },\n          { fields: {} },\n        ],\n      });\n      const numFieldId = formulaTable.fields.find((f) => f.name === 'Num')!.id;\n      const formulaField = await createField(formulaTable.id, {\n        name: 'Formula',\n        type: FieldType.Formula,\n        options: { expression: `{${numFieldId}} > 3` },\n      });\n      formulaFieldId = formulaField.id;\n\n      // Setup checkbox table (2 true, 2 null)\n      checkboxTable = await createTable(baseId, {\n        name: 'checkbox_test',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          { name: 'Check', type: FieldType.Checkbox },\n        ],\n        records: [\n          { fields: { Check: true } },\n          { fields: { Check: true } },\n          { fields: { Check: null } },\n          { fields: {} },\n        ],\n      });\n      checkboxFieldId = checkboxTable.fields.find((f) => f.name === 'Check')!.id;\n\n      // Setup lookup tables\n      lookupSourceTable = await createTable(baseId, {\n        name: 'lookup_source',\n        fields: [\n          { name: 'Title', type: FieldType.SingleLineText },\n          { name: 'Check', type: FieldType.Checkbox },\n        ],\n        records: [\n          { fields: { Check: true } },\n          { fields: { Check: true } },\n          { fields: { Check: null } },\n          { fields: {} },\n        ],\n      });\n      lookupMainTable = await createTable(baseId, {\n        name: 'lookup_main',\n        fields: [{ name: 'Title', type: FieldType.SingleLineText }],\n        records: [{ fields: {} }, { fields: {} }, { fields: {} }, { fields: {} }],\n      });\n      const linkField = await createField(lookupMainTable.id, {\n        name: 'Link',\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: lookupSourceTable.id },\n      } as IFieldRo);\n      const checkFieldId = lookupSourceTable.fields.find((f) => f.name === 'Check')!.id;\n      const lookupField = await createField(lookupMainTable.id, {\n        name: 'LookupCheck',\n        type: FieldType.Checkbox,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: lookupSourceTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: checkFieldId,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n      lookupFieldId = lookupField.id;\n\n      // Link: [0]->A,B(true,true), [1]->C,D(null,null), [2]->A,C(true,null), [3]->none\n      await updateRecordByApi(lookupMainTable.id, lookupMainTable.records[0].id, linkField.id, [\n        { id: lookupSourceTable.records[0].id },\n        { id: lookupSourceTable.records[1].id },\n      ]);\n      await updateRecordByApi(lookupMainTable.id, lookupMainTable.records[1].id, linkField.id, [\n        { id: lookupSourceTable.records[2].id },\n        { id: lookupSourceTable.records[3].id },\n      ]);\n      await updateRecordByApi(lookupMainTable.id, lookupMainTable.records[2].id, linkField.id, [\n        { id: lookupSourceTable.records[0].id },\n        { id: lookupSourceTable.records[2].id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, formulaTable.id);\n      await permanentDeleteTable(baseId, checkboxTable.id);\n      await permanentDeleteTable(baseId, lookupMainTable.id);\n      await permanentDeleteTable(baseId, lookupSourceTable.id);\n    });\n\n    // Helper functions\n    async function getFilteredRecords(tableId: string, filter: IFilter) {\n      return (await apiGetRecords(tableId, { fieldKeyType: FieldKeyType.Id, filter })).data;\n    }\n\n    async function getAggregationValue(tableId: string, fieldId: string, func: StatisticsFunc) {\n      const { data } = await getAggregation(tableId, { field: { [func]: [fieldId] } });\n      return data.aggregations?.find((a) => a.fieldId === fieldId)?.total;\n    }\n\n    // Boolean formula field tests\n    it.each([\n      { value: true, expected: 2 },\n      { value: null, expected: 4 },\n    ])('formula field: filter is $value -> $expected records', async ({ value, expected }) => {\n      const filter: IFilter = {\n        filterSet: [{ fieldId: formulaFieldId, operator: is.value, value }],\n        conjunction: and.value,\n      };\n      const { records } = await getFilteredRecords(formulaTable.id, filter);\n      expect(records.length).toBe(expected);\n    });\n\n    it.each([\n      { func: StatisticsFunc.Checked, expected: 2, isPercent: false },\n      { func: StatisticsFunc.UnChecked, expected: 4, isPercent: false },\n      { func: StatisticsFunc.PercentChecked, expected: 33.33, isPercent: true },\n      { func: StatisticsFunc.PercentUnChecked, expected: 66.67, isPercent: true },\n    ])('formula field: $func -> $expected', async ({ func, expected, isPercent }) => {\n      const result = await getAggregationValue(formulaTable.id, formulaFieldId, func);\n      expect(result?.aggFunc).toBe(func);\n      isPercent\n        ? expect(Number(result?.value)).toBeCloseTo(expected, 1)\n        : expect(Number(result?.value)).toBe(expected);\n    });\n\n    // Checkbox field regression tests\n    it.each([\n      { value: true, expected: 2 },\n      { value: null, expected: 2 },\n    ])('checkbox field: filter is $value -> $expected records', async ({ value, expected }) => {\n      const filter: IFilter = {\n        filterSet: [{ fieldId: checkboxFieldId, operator: is.value, value }],\n        conjunction: and.value,\n      };\n      const { records } = await getFilteredRecords(checkboxTable.id, filter);\n      expect(records.length).toBe(expected);\n    });\n\n    it.each([\n      { func: StatisticsFunc.PercentChecked, expected: 50 },\n      { func: StatisticsFunc.PercentUnChecked, expected: 50 },\n    ])('checkbox field: $func -> $expected%', async ({ func, expected }) => {\n      const result = await getAggregationValue(checkboxTable.id, checkboxFieldId, func);\n      expect(result?.aggFunc).toBe(func);\n      expect(Number(result?.value)).toBeCloseTo(expected, 1);\n    });\n\n    // Lookup checkbox (multiple value) tests\n    it.each([\n      { func: StatisticsFunc.PercentChecked, expected: 50 },\n      { func: StatisticsFunc.PercentUnChecked, expected: 50 },\n    ])('lookup checkbox: $func -> $expected%', async ({ func, expected }) => {\n      const result = await getAggregationValue(lookupMainTable.id, lookupFieldId, func);\n      expect(result?.aggFunc).toBe(func);\n      expect(Number(result?.value)).toBeCloseTo(expected, 1);\n    });\n  });\n\n  // T1781: SQL LIKE wildcards (%, _, \\) not escaped in contains filter and search\n  describe('T1781: SQL LIKE wildcard escape', () => {\n    let table: ITableFullVo;\n    let fieldId: string;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'like_wildcard_test',\n        fields: [{ name: 'Text', type: FieldType.SingleLineText }],\n        records: [\n          { fields: { Text: 'Contains % percent sign' } },\n          { fields: { Text: 'Contains _ underscore' } },\n          { fields: { Text: 'Contains \\\\ backslash' } },\n          { fields: { Text: 'Normal text' } },\n          { fields: { Text: '100%' } },\n          { fields: { Text: '50%' } },\n          { fields: { Text: 'file_name.txt' } },\n          { fields: { Text: 'path\\\\to\\\\file' } },\n          { fields: { Text: '%_%' } },\n          { fields: { Text: null } },\n        ],\n      });\n      fieldId = table.fields.find((f) => f.name === 'Text')!.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it.each([\n      { op: contains.value, value: '%', expected: 4 },\n      { op: contains.value, value: '_', expected: 3 },\n      { op: contains.value, value: '\\\\', expected: 2 },\n      { op: contains.value, value: '%_%', expected: 1 },\n      { op: contains.value, value: '0%', expected: 2 },\n      { op: doesNotContain.value, value: '%', expected: 6 },\n      { op: doesNotContain.value, value: '_', expected: 7 },\n    ])('filter $op \"$value\" -> $expected records', async ({ op, value, expected }) => {\n      const filter: IFilter = {\n        filterSet: [{ fieldId, operator: op, value }],\n        conjunction: and.value,\n      };\n      const { data } = await apiGetRecords(table.id, { fieldKeyType: FieldKeyType.Id, filter });\n      expect(data.records.length).toBe(expected);\n    });\n\n    it.each([\n      { value: '%', expected: 4 },\n      { value: '_', expected: 3 },\n      { value: '\\\\', expected: 2 },\n    ])('search \"$value\" -> $expected records', async ({ value, expected }) => {\n      const { data } = await apiGetRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        search: [value, fieldId, true],\n      });\n      expect(data.records.length).toBe(expected);\n    });\n\n    it('global search \"%\" -> 4 records', async () => {\n      const { data } = await apiGetRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        search: ['%', '', true],\n      });\n      expect(data.records.length).toBe(4);\n    });\n\n    describe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n      'with search index',\n      () => {\n        let indexedTable: ITableFullVo;\n\n        beforeAll(async () => {\n          indexedTable = await createTable(baseId, {\n            name: 'search_index_test',\n            fields: [{ name: 'Text', type: FieldType.SingleLineText }],\n            records: [\n              { fields: { Text: '50% off' } },\n              { fields: { Text: 'file_name.txt' } },\n              { fields: { Text: 'normal' } },\n            ],\n          });\n          await toggleTableIndex(baseId, indexedTable.id, { type: TableIndex.search });\n        });\n\n        afterAll(async () => {\n          await permanentDeleteTable(baseId, indexedTable.id);\n        });\n\n        it.each([\n          { value: '%', expected: 1 },\n          { value: '_', expected: 1 },\n        ])('global search \"$value\" with index -> $expected record', async ({ value, expected }) => {\n          const { data } = await apiGetRecords(indexedTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n            search: [value, '', true],\n          });\n          expect(data.records.length).toBe(expected);\n        });\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-filter-query.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFilter, IOperator } from '@teable/core';\nimport { and, FieldKeyType, FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getRecords as apiGetRecords, createField, getFields } from '@teable/openapi';\nimport { textField, x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\nimport {\n  CHECKBOX_FIELD_CASES,\n  CHECKBOX_LOOKUP_FIELD_CASES,\n  DATE_FIELD_CASES,\n  DATE_LOOKUP_FIELD_CASES,\n  DATE_RANGE_ERROR_CASES,\n  MULTIPLE_SELECT_FIELD_CASES,\n  MULTIPLE_SELECT_LOOKUP_FIELD_CASES,\n  MULTIPLE_USER_FIELD_CASES,\n  MULTIPLE_USER_LOOKUP_FIELD_CASES,\n  NUMBER_FIELD_CASES,\n  NUMBER_LOOKUP_FIELD_CASES,\n  SINGLE_SELECT_FIELD_CASES,\n  SINGLE_SELECT_LOOKUP_FIELD_CASES,\n  TEXT_FIELD_CASES,\n  TEXT_LOOKUP_FIELD_CASES,\n  USER_FIELD_CASES,\n  USER_LOOKUP_FIELD_CASES,\n} from './data-helpers/caces/record-filter-query';\nimport { createTable, permanentDeleteTable, initApp } from './utils/init-app';\n\nconst testDesc = `should filter [$operator], query value: $queryValue, expect result length: $expectResultLength`;\n\ndescribe('OpenAPI Record-Filter-Query (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n  const textLookupFieldCases = isForceV2\n    ? TEXT_LOOKUP_FIELD_CASES.map((testCase) => {\n        switch (testCase.operator) {\n          case 'isEmpty':\n            return { ...testCase, expectResultLength: 6 };\n          case 'isNotEmpty':\n            return { ...testCase, expectResultLength: 15 };\n          default:\n            return testCase;\n        }\n      })\n    : TEXT_LOOKUP_FIELD_CASES;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  async function getFilterRecord(tableId: string, viewId: string, filter: IFilter) {\n    return (\n      await apiGetRecords(tableId, {\n        fieldKeyType: FieldKeyType.Id,\n        filter: filter,\n      })\n    ).data;\n  }\n\n  const doTest = async (\n    table: ITableFullVo,\n    {\n      fieldIndex,\n      operator,\n      queryValue,\n      expectResultLength,\n      expectMoreResults = false,\n    }: {\n      fieldIndex: number;\n      operator: IOperator;\n      queryValue: any;\n      expectResultLength: number;\n      expectMoreResults?: boolean;\n    }\n  ) => {\n    const tableId = table.id;\n    const viewId = table.views[0].id;\n    const fieldId = table.fields[fieldIndex].id;\n    const conjunction = and.value;\n\n    const filter: IFilter = {\n      filterSet: [\n        {\n          fieldId: fieldId,\n          value: queryValue,\n          operator,\n        },\n      ],\n      conjunction,\n    };\n\n    const { records } = await getFilterRecord(tableId, viewId!, filter);\n    expect(records.length).toBe(expectResultLength);\n    if (!expectMoreResults) {\n      expect(records).not.toMatchObject([\n        expect.objectContaining({\n          fields: {\n            [fieldId]: queryValue,\n          },\n        }),\n      ]);\n    }\n  };\n\n  describe('basis field filter record', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    describe('simple filter text field record', () => {\n      test.each(TEXT_FIELD_CASES)(testDesc, async (param) => doTest(table, param));\n    });\n\n    describe('simple filter number field record', () => {\n      test.each(NUMBER_FIELD_CASES)(testDesc, async (param) => doTest(table, param));\n    });\n\n    describe('simple filter single select field record', () => {\n      test.each(SINGLE_SELECT_FIELD_CASES)(testDesc, async (param) => doTest(table, param));\n    });\n\n    describe('simple filter date field record', () => {\n      test.each(DATE_FIELD_CASES)(\n        `should filter [$operator], query mode: $queryValue.mode, expect result length: $expectResultLength`,\n        async (param) => doTest(table, param)\n      );\n    });\n\n    describe('simple filter checkbox field record', () => {\n      test.each(CHECKBOX_FIELD_CASES)(testDesc, async (param) => doTest(table, param));\n    });\n\n    describe('simple filter user field record', () => {\n      test.each([...USER_FIELD_CASES, ...MULTIPLE_USER_FIELD_CASES])(testDesc, async (param) =>\n        doTest(table, param)\n      );\n    });\n\n    describe('simple filter multiple select field record', () => {\n      test.each(MULTIPLE_SELECT_FIELD_CASES)(testDesc, async (param) => doTest(table, param));\n    });\n\n    describe('dateRange filter error cases', () => {\n      it('should throw error when start > end (invalid range)', async () => {\n        const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidRange;\n        const filter: IFilter = {\n          filterSet: [\n            {\n              fieldId: table.fields[fieldIndex].id,\n              value: queryValue,\n              operator,\n            },\n          ],\n          conjunction: and.value,\n        };\n        await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow();\n      });\n\n      it('should throw error when dateRange is used with isNot operator', async () => {\n        const { fieldIndex, operator, queryValue } = DATE_RANGE_ERROR_CASES.invalidOperator;\n        const filter: IFilter = {\n          filterSet: [\n            {\n              fieldId: table.fields[fieldIndex].id,\n              value: queryValue,\n              operator,\n            },\n          ],\n          conjunction: and.value,\n        };\n        await expect(getFilterRecord(table.id, table.views[0].id, filter)).rejects.toThrow();\n      });\n    });\n  });\n\n  describe('lookup field filter record', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n\n    describe('filter lookup text field record', () => {\n      test.each(textLookupFieldCases)(testDesc, async (param) => doTest(subTable, param));\n    });\n    describe('filter lookup number field record', () => {\n      test.each(NUMBER_LOOKUP_FIELD_CASES)(testDesc, async (param) => doTest(subTable, param));\n    });\n\n    describe('filter lookup single select field record', () => {\n      test.each(SINGLE_SELECT_LOOKUP_FIELD_CASES)(testDesc, async (param) =>\n        doTest(subTable, param)\n      );\n    });\n\n    describe('filter lookup date field record', () => {\n      test.each(DATE_LOOKUP_FIELD_CASES)(\n        `should filter [$operator], query mode: $queryValue.mode, expect result length: $expectResultLength`,\n        async (param) => doTest(subTable, param)\n      );\n    });\n\n    describe('filter lookup checkbox field record', () => {\n      test.each(CHECKBOX_LOOKUP_FIELD_CASES)(\n        `should filter [$operator], query mode: $queryValue.mode, expect result length: $expectResultLength`,\n        async (param) => doTest(subTable, param)\n      );\n    });\n\n    describe('filter lookup user field record', () => {\n      test.each([...USER_LOOKUP_FIELD_CASES, ...MULTIPLE_USER_LOOKUP_FIELD_CASES])(\n        testDesc,\n        async (param) => doTest(subTable, param)\n      );\n    });\n\n    describe('filter lookup multiple select field record', () => {\n      test.each(MULTIPLE_SELECT_LOOKUP_FIELD_CASES)(testDesc, async (param) =>\n        doTest(subTable, param)\n      );\n    });\n  });\n\n  describe('filter record with special characters', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      const newRecords = [...x_20.records];\n      newRecords.splice(\n        1,\n        3,\n        ...[\n          { fields: { [textField.name]: 'notepad++' } },\n          { fields: { [textField.name]: 'notepad++@' } },\n          { fields: { [textField.name]: 'notepad++@' } },\n        ]\n      );\n      table = await createTable(baseId, {\n        name: 'special_characters',\n        fields: x_20.fields,\n        records: newRecords,\n      });\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_special_characters',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      subTable.fields = (await getFields(subTable.id)).data;\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n\n    it('should filter record with special characters', async () => {\n      const linkField = subTable.fields.find((field) => field.type === FieldType.Link)!;\n      const { records } = await getFilterRecord(subTable.id, subTable.views[0].id, {\n        filterSet: [{ fieldId: linkField.id, value: 'notepad++', operator: 'contains' }],\n        conjunction: and.value,\n      });\n      expect(records.length).toBe(8);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-group-datetime-timezone.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport {\n  DateFormattingPreset,\n  FieldKeyType,\n  FieldType,\n  SortFunc,\n  TimeFormatting,\n  formatDateToString,\n} from '@teable/core';\nimport { GroupPointType } from '@teable/openapi';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { createTable, getRecords, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('OpenAPI Record-Group-DateTime-TimeZone (e2e)', async () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('should keep groupPoints datetime consistent when field timeZone differs from system', async () => {\n    const table: ITableFullVo = await createTable(baseId, {\n      name: 'record_group_datetime_timezone',\n      fields: [\n        {\n          name: 'id',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'dt',\n          type: FieldType.Date,\n          options: {\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: TimeFormatting.Hour24,\n              timeZone: 'UTC',\n            },\n          },\n        },\n      ],\n      records: [\n        {\n          fields: {\n            id: '1',\n            dt: '2025-12-15T11:00:00.000Z',\n          },\n        },\n      ],\n    });\n\n    try {\n      const dateField = table.fields.find((f) => f.name === 'dt');\n      expect(dateField?.id).toBeTruthy();\n\n      const res = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        groupBy: [{ fieldId: dateField!.id, order: SortFunc.Asc }],\n      });\n\n      const recordValue = res.records?.[0]?.fields?.[dateField!.id] as string | undefined;\n      expect(recordValue).toBeTruthy();\n\n      const groupHeader = res.extra?.groupPoints?.find(\n        (p) => p.type === GroupPointType.Header && (p as { depth?: number }).depth === 0\n      ) as { value?: unknown } | undefined;\n      expect(groupHeader?.value).toBeTruthy();\n\n      const formatting = {\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.Hour24,\n        timeZone: 'UTC',\n      };\n\n      expect(formatDateToString(groupHeader!.value as string, formatting)).toBe(\n        formatDateToString(recordValue!, formatting)\n      );\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-history.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport { getRecordHistory, getRecordListHistory, recordHistoryVoSchema } from '@teable/openapi';\nimport type { ITableFullVo } from '@teable/openapi';\nimport type { IBaseConfig } from '../src/configs/base.config';\nimport { baseConfig } from '../src/configs/base.config';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  updateRecord,\n} from './utils/init-app';\n\ndescribe('Record history (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n  let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    eventEmitterService = app.get(EventEmitterService);\n    const baseConfigService = app.get(baseConfig.KEY) as IBaseConfig;\n    baseConfigService.recordHistoryDisabled = false;\n\n    awaitWithEvent = createAwaitWithEvent(eventEmitterService, Events.RECORD_HISTORY_CREATE);\n  });\n\n  afterAll(async () => {\n    eventEmitterService.eventEmitter.removeAllListeners(Events.RECORD_HISTORY_CREATE);\n    await app.close();\n  });\n\n  describe('record history', () => {\n    let mainTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n\n    beforeEach(async () => {\n      mainTable = await createTable(baseId, { name: 'Main table' });\n      foreignTable = await createTable(baseId, { name: 'Foreign table' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, mainTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('should get record history of changes in the base cell values', async () => {\n      const recordId = mainTable.records[0].id;\n      const textField = await createField(mainTable.id, {\n        type: FieldType.SingleLineText,\n      });\n\n      const { data: originRecordHistory } = await getRecordHistory(mainTable.id, recordId, {});\n\n      expect(recordHistoryVoSchema.safeParse(originRecordHistory).success).toEqual(true);\n      expect(originRecordHistory.historyList.length).toEqual(0);\n\n      await awaitWithEvent(() =>\n        updateRecord(mainTable.id, recordId, {\n          record: {\n            fields: {\n              [textField.id]: 'new value',\n            },\n          },\n          fieldKeyType: FieldKeyType.Id,\n        })\n      );\n\n      const { data: recordHistory } = await getRecordHistory(mainTable.id, recordId, {});\n      const { data: tableRecordHistory } = await getRecordListHistory(mainTable.id, {});\n\n      expect(recordHistory.historyList.length).toEqual(1);\n      expect(tableRecordHistory.historyList.length).toEqual(1);\n    });\n\n    it('should get record history of changes in the modified cell values is referenced by a formula', async () => {\n      const recordId = mainTable.records[0].id;\n      const textField = await createField(mainTable.id, {\n        type: FieldType.SingleLineText,\n      });\n      await createField(mainTable.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${textField.id}}`,\n        },\n      });\n\n      await awaitWithEvent(() =>\n        updateRecord(mainTable.id, recordId, {\n          record: {\n            fields: {\n              [textField.id]: 'test',\n            },\n          },\n          fieldKeyType: FieldKeyType.Id,\n        })\n      );\n\n      const { data: mainTableRecordHistory } = await getRecordHistory(mainTable.id, recordId, {});\n\n      expect(mainTableRecordHistory.historyList.length).toEqual(1);\n    });\n\n    it('should get record history of changes in the link field cell values', async () => {\n      const recordId = mainTable.records[0].id;\n      const foreignRecordId = foreignTable.records[0].id;\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      await awaitWithEvent(() =>\n        updateRecord(mainTable.id, recordId, {\n          record: {\n            fields: {\n              [linkField.id]: { id: foreignRecordId },\n            },\n          },\n          fieldKeyType: FieldKeyType.Id,\n        })\n      );\n\n      const { data: mainTableRecordHistory } = await getRecordHistory(mainTable.id, recordId, {});\n      const { data: foreignTableRecordHistory } = await getRecordHistory(\n        foreignTable.id,\n        foreignRecordId,\n        {}\n      );\n\n      expect(recordHistoryVoSchema.safeParse(mainTableRecordHistory).success).toEqual(true);\n      expect(recordHistoryVoSchema.safeParse(foreignTableRecordHistory).success).toEqual(true);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-link-select-query.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldKeyType, FieldType, NumberFormattingType, Relationship } from '@teable/core';\nimport type { IGetRecordsRo, ITableFullVo } from '@teable/openapi';\nimport { getRowCount as apiGetRowCount } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getFields,\n  getRecords,\n  initApp,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI link Select (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('get records filter by link field Id', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      const numberFieldRo: IFieldRo = {\n        name: 'Number field',\n        type: FieldType.Number,\n        options: {\n          formatting: { type: NumberFormattingType.Decimal, precision: 1 },\n        },\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo, numberFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      table1.fields = await getFields(table1.id);\n      table2.fields = await getFields(table2.id);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    describe.each([\n      {\n        relationship: Relationship.OneMany,\n        reversRelationship: Relationship.ManyOne,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 3, s: 1 }, right: { c: 2, s: 1 } },\n        ],\n        direction: 'two way',\n        isOneWay: undefined,\n      },\n      {\n        relationship: Relationship.OneMany,\n        reversRelationship: Relationship.ManyOne,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 3, s: 1 }, right: { c: 2, s: 1 } },\n        ],\n        direction: 'one Way',\n        isOneWay: true,\n      },\n      {\n        relationship: Relationship.OneOne,\n        reversRelationship: Relationship.OneOne,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 2, s: 1 }, right: { c: 2, s: 1 } },\n        ],\n        direction: 'two way',\n        isOneWay: undefined,\n      },\n      {\n        relationship: Relationship.OneOne,\n        reversRelationship: Relationship.OneOne,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 2, s: 1 }, right: { c: 2, s: 1 } },\n        ],\n        direction: 'one Way',\n        isOneWay: true,\n      },\n      {\n        relationship: Relationship.ManyMany,\n\n        reversRelationship: Relationship.ManyMany,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n        ],\n        direction: 'two way',\n      },\n      {\n        relationship: Relationship.ManyMany,\n        reversRelationship: Relationship.ManyMany,\n        result: [\n          { left: { c: 3, s: 0 }, right: { c: 3, s: 0 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n          { left: { c: 3, s: 1 }, right: { c: 3, s: 1 } },\n        ],\n        isOneWay: true,\n      },\n    ])(\n      'fetch candidate records for $relationship, $reversRelationship, $direction field',\n      ({ relationship, reversRelationship, isOneWay, result }) => {\n        let linkField1: IFieldVo;\n        let linkField2: IFieldVo;\n        beforeEach(async () => {\n          // create link field\n          const Link1FieldRo: IFieldRo = {\n            name: 'link field',\n            type: FieldType.Link,\n            options: {\n              relationship,\n              foreignTableId: table2.id,\n              isOneWay,\n            },\n          };\n\n          linkField1 = await createField(table1.id, Link1FieldRo);\n\n          if (isOneWay) {\n            // create link field back\n            const Link2FieldRo: IFieldRo = {\n              name: 'link field',\n              type: FieldType.Link,\n              options: {\n                relationship: reversRelationship,\n                foreignTableId: table1.id,\n                isOneWay: true,\n              },\n            };\n            linkField2 = await createField(table2.id, Link2FieldRo);\n          } else {\n            const table2Fields = await getFields(table2.id);\n            linkField2 = table2Fields[2];\n          }\n        });\n\n        it('should fetch all candidate and selected records', async () => {\n          const table1Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: [linkField2.id, table2.records[0].id],\n          };\n\n          const table1Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: [linkField2.id, table2.records[0].id],\n          };\n\n          const table2Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: [linkField1.id, table1.records[0].id],\n          };\n\n          const table2Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: [linkField1.id, table1.records[0].id],\n          };\n\n          const table1CResult = await getRecords(table1.id, table1Candidate);\n          expect(table1CResult.records.length).toBe(result[0].left.c);\n\n          const table1CResultRow = (await apiGetRowCount(table1.id, table1Candidate)).data;\n          expect(table1CResultRow.rowCount).toBe(result[0].left.c);\n\n          const table1SResult = await getRecords(table1.id, table1Selected);\n          expect(table1SResult.records.length).toBe(result[0].left.s);\n\n          const table1SResultRow = (await apiGetRowCount(table1.id, table1Selected)).data;\n          expect(table1SResultRow.rowCount).toBe(result[0].left.s);\n\n          const table2CResult = await getRecords(table2.id, table2Candidate);\n          expect(table2CResult.records.length).toBe(result[0].right.c);\n\n          const table2CResultRow = (await apiGetRowCount(table2.id, table2Candidate)).data;\n          expect(table2CResultRow.rowCount).toBe(result[0].right.c);\n\n          const table2SResult = await getRecords(table2.id, table2Selected);\n          expect(table2SResult.records.length).toBe(result[0].right.s);\n\n          const table2SResultRow = (await apiGetRowCount(table2.id, table2Selected)).data;\n          expect(table2SResultRow.rowCount).toBe(result[0].right.s);\n        });\n\n        it('should fetch candidate and selected records after link', async () => {\n          const value =\n            relationship === Relationship.ManyMany\n              ? [{ id: table1.records[0].id }]\n              : { id: table1.records[0].id };\n          // table2 link field first record link to table1 first record\n          await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, value);\n          if (isOneWay) {\n            // table1 link field first record link to table2 first record\n            const value =\n              relationship === Relationship.OneOne\n                ? { id: table2.records[0].id }\n                : [{ id: table2.records[0].id }];\n            await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, value);\n          }\n\n          const table1Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: [linkField2.id, table2.records[0].id],\n          };\n\n          const table1Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: [linkField2.id, table2.records[0].id],\n          };\n\n          const table2Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: [linkField1.id, table1.records[0].id],\n          };\n\n          const table2Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: [linkField1.id, table1.records[0].id],\n          };\n\n          const table1CResult = await getRecords(table1.id, table1Candidate);\n          expect(table1CResult.records.length).toBe(result[1].left.c);\n\n          const table1SResult = await getRecords(table1.id, table1Selected);\n          expect(table1SResult.records.length).toBe(result[1].left.s);\n\n          const table2CResult = await getRecords(table2.id, table2Candidate);\n          expect(table2CResult.records.length).toBe(result[1].right.c);\n\n          const table2SResult = await getRecords(table2.id, table2Selected);\n          expect(table2SResult.records.length).toBe(result[1].right.s);\n        });\n\n        it('should fetch candidate and selected records after link without recordId', async () => {\n          const value =\n            relationship === Relationship.ManyMany\n              ? [{ id: table1.records[0].id }]\n              : { id: table1.records[0].id };\n          // table2 link field first record link to table1 first record\n          await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, value);\n          if (isOneWay) {\n            // table1 link field first record link to table2 first record\n            const value =\n              relationship === Relationship.OneOne\n                ? { id: table2.records[0].id }\n                : [{ id: table2.records[0].id }];\n            await updateRecordByApi(table1.id, table1.records[0].id, linkField1.id, value);\n          }\n\n          const table1Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: linkField2.id,\n          };\n\n          const table1Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: linkField2.id,\n          };\n\n          const table2Candidate: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellCandidate: linkField1.id,\n          };\n\n          const table2Selected: IGetRecordsRo = {\n            fieldKeyType: FieldKeyType.Id,\n            filterLinkCellSelected: linkField1.id,\n          };\n\n          const table1CResult = await getRecords(table1.id, table1Candidate);\n          expect(table1CResult.records.length).toBe(result[2].left.c);\n\n          const table1SResult = await getRecords(table1.id, table1Selected);\n          expect(table1SResult.records.length).toBe(result[2].left.s);\n\n          const table2CResult = await getRecords(table2.id, table2Candidate);\n          expect(table2CResult.records.length).toBe(result[2].right.c);\n\n          const table2SResult = await getRecords(table2.id, table2Selected);\n          expect(table2SResult.records.length).toBe(result[2].right.s);\n        });\n      }\n    );\n\n    describe('fetch selected records with sort', () => {\n      let linkField2: IFieldVo;\n      beforeEach(async () => {\n        // create link field\n        const Link1FieldRo: IFieldRo = {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n          },\n        };\n\n        await createField(table1.id, Link1FieldRo);\n\n        const table2Fields = await getFields(table2.id);\n        linkField2 = table2Fields[2];\n      });\n\n      it('should sort selected records', async () => {\n        // table2 link field first record link to table1 first record\n        const updateValue1 = [\n          { id: table1.records[2].id },\n          { id: table1.records[0].id },\n          { id: table1.records[1].id },\n        ];\n        await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, updateValue1);\n        const table1Selected: IGetRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          filterLinkCellSelected: [linkField2.id, table2.records[0].id],\n        };\n        const result = await getRecords(table1.id, table1Selected);\n        expect(result.records).toMatchObject(updateValue1);\n\n        const updateValue2 = [\n          { id: table1.records[2].id },\n          { id: table1.records[1].id },\n          { id: table1.records[0].id },\n        ];\n        await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, updateValue2);\n        const result2 = await getRecords(table1.id, table1Selected);\n        expect(result2.records).toMatchObject(updateValue2);\n      });\n    });\n\n    describe('fetch candidate records', () => {\n      let linkField2: IFieldVo;\n      beforeEach(async () => {\n        // create link field\n        const Link1FieldRo: IFieldRo = {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n          },\n        };\n\n        await createField(table1.id, Link1FieldRo);\n\n        const table2Fields = await getFields(table2.id);\n        // oneMany\n        linkField2 = table2Fields[2];\n      });\n\n      it('should filter candidate records that cannot be select', async () => {\n        // table2 link field first record link to table1 first record\n        const updateValue1 = [\n          { id: table1.records[2].id },\n          { id: table1.records[0].id },\n          { id: table1.records[1].id },\n        ];\n        await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, updateValue1);\n        const table1Record0Selected: IGetRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          filterLinkCellCandidate: [linkField2.id, table2.records[0].id],\n        };\n        const result0 = await getRecords(table1.id, table1Record0Selected);\n        expect(result0.records.length).toEqual(3);\n\n        const table1Record1Selected: IGetRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          filterLinkCellCandidate: [linkField2.id, table2.records[1].id],\n        };\n        const result1 = await getRecords(table1.id, table1Record1Selected);\n        expect(result1.records.length).toEqual(0);\n      });\n    });\n\n    describe('fetch selected records', () => {\n      let linkField2: IFieldVo;\n      beforeEach(async () => {\n        const Link1FieldRo: IFieldRo = {\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: table2.id,\n          },\n        };\n\n        await createField(table1.id, Link1FieldRo);\n\n        const table2Fields = await getFields(table2.id);\n        linkField2 = table2Fields[2];\n      });\n\n      it('should filter records by selected recordIds', async () => {\n        const recordRo: IGetRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          selectedRecordIds: [table1.records[0].id, table1.records[1].id],\n        };\n\n        const result = await getRecords(table1.id, recordRo);\n        expect(result.records.length).toEqual(2);\n\n        const rowCountResult = (await apiGetRowCount(table1.id, recordRo)).data;\n        expect(rowCountResult.rowCount).toBe(2);\n      });\n\n      it('should filter candidate records by selected recordIds', async () => {\n        const updateValue1 = [{ id: table1.records[2].id }];\n\n        await updateRecordByApi(table2.id, table2.records[0].id, linkField2.id, updateValue1);\n\n        const table1Record0Selected: IGetRecordsRo = {\n          fieldKeyType: FieldKeyType.Id,\n          filterLinkCellCandidate: [linkField2.id, table2.records[0].id],\n          selectedRecordIds: [table1.records[1].id],\n        };\n\n        const result = await getRecords(table1.id, table1Record0Selected);\n        expect(result.records.length).toEqual(2);\n\n        const rowCountResult = (await apiGetRowCount(table1.id, table1Record0Selected)).data;\n        expect(rowCountResult.rowCount).toBe(2);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-query-builder.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILinkFieldOptionsRo, ILookupOptionsRo } from '@teable/core';\nimport { FieldType as FT, Relationship, StatisticsFunc } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport { format as formatSql } from 'sql-formatter';\nimport type { IRecordQueryBuilder } from '../src/features/record/query-builder';\nimport { RECORD_QUERY_BUILDER_SYMBOL } from '../src/features/record/query-builder';\nimport {\n  createField,\n  createTable,\n  deleteField,\n  permanentDeleteTable,\n  initApp,\n} from './utils/init-app';\n\ndescribe('RecordQueryBuilder (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  let table: { id: string };\n  let f1: IFieldVo;\n  let f2: IFieldVo;\n  let f3: IFieldVo;\n  let dbTableName: string;\n  let rqb: IRecordQueryBuilder;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    // Create table and fields once\n    table = await createTable(baseId, { name: 'rqb_simple' });\n    f1 = (await createField(table.id, { type: FT.SingleLineText, name: 'c1' })) as IFieldVo;\n    f2 = (await createField(table.id, { type: FT.Number, name: 'c2' })) as IFieldVo;\n    f3 = (await createField(table.id, { type: FT.Date, name: 'c3' })) as IFieldVo;\n\n    const prisma = app.get(PrismaService);\n    const meta = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: table.id },\n      select: { dbTableName: true },\n    });\n    dbTableName = meta.dbTableName;\n\n    rqb = app.get<IRecordQueryBuilder>(RECORD_QUERY_BUILDER_SYMBOL);\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n    await app.close();\n  });\n\n  const normalizeSql = (rawSql: string, alias: string) => {\n    const stableTableId = 'tbl_TEST';\n    const stableAlias = 'TBL_ALIAS';\n    let sql = rawSql;\n    // Normalize alias — keeps column qualifiers intact\n    sql = sql.split(alias).join(stableAlias);\n    // Normalize ids (defensive; may not appear anymore)\n    sql = sql.split(table.id).join(stableTableId);\n    // Normalize field names\n    sql = sql\n      .split(f1.dbFieldName)\n      .join('col_c1')\n      .split(f2.dbFieldName)\n      .join('col_c2')\n      .split(f3.dbFieldName)\n      .join('col_c3');\n    return sql;\n  };\n\n  const pretty = (s: string) => formatSql(s, { language: 'postgresql' });\n\n  it('builds SELECT for a table with 3 simple fields', async () => {\n    const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n      tableId: table.id,\n      projection: [f1.id, f2.id, f3.id],\n    });\n    // Override FROM to stable name without touching alias\n    qb.from({ [alias]: 'db_table' });\n\n    const formatted = pretty(normalizeSql(qb.limit(1).toQuery(), alias));\n    expect(formatted).toMatchInlineSnapshot(`\n      \"select\n        \"TBL_ALIAS\".\"__id\",\n        \"TBL_ALIAS\".\"__version\",\n        \"TBL_ALIAS\".\"__auto_number\",\n        \"TBL_ALIAS\".\"__created_time\",\n        \"TBL_ALIAS\".\"__last_modified_time\",\n        \"TBL_ALIAS\".\"__created_by\",\n        \"TBL_ALIAS\".\"__last_modified_by\",\n        \"TBL_ALIAS\".\"col_c1\" as \"col_c1\",\n        \"TBL_ALIAS\".\"col_c2\" as \"col_c2\",\n        \"TBL_ALIAS\".\"col_c3\" as \"col_c3\"\n      from\n        \"db_table\" as \"TBL_ALIAS\"\n      limit\n        1\"\n    `);\n  });\n\n  it('builds SELECT with partial projection (only two fields)', async () => {\n    const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n      tableId: table.id,\n      projection: [f1.id, f3.id],\n    });\n    // Override FROM to stable name without touching alias\n    qb.from({ [alias]: 'db_table' });\n    const formatted = pretty(normalizeSql(qb.limit(1).toQuery(), alias));\n    expect(formatted).toMatchInlineSnapshot(`\n      \"select\n        \"TBL_ALIAS\".\"__id\",\n        \"TBL_ALIAS\".\"__version\",\n        \"TBL_ALIAS\".\"__auto_number\",\n        \"TBL_ALIAS\".\"__created_time\",\n        \"TBL_ALIAS\".\"__last_modified_time\",\n        \"TBL_ALIAS\".\"__created_by\",\n        \"TBL_ALIAS\".\"__last_modified_by\",\n        \"TBL_ALIAS\".\"col_c1\" as \"col_c1\",\n        \"TBL_ALIAS\".\"col_c3\" as \"col_c3\"\n      from\n        \"db_table\" as \"TBL_ALIAS\"\n      limit\n        1\"\n    `);\n  });\n\n  it('builds SELECT with partial projection (only two fields)', async () => {\n    const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n      tableId: table.id,\n      projection: [f1.id],\n    });\n    // Override FROM to stable name without touching alias\n    qb.from({ [alias]: 'db_table' });\n    const formatted = pretty(normalizeSql(qb.limit(1).toQuery(), alias));\n    expect(formatted).toMatchInlineSnapshot(`\n      \"select\n        \"TBL_ALIAS\".\"__id\",\n        \"TBL_ALIAS\".\"__version\",\n        \"TBL_ALIAS\".\"__auto_number\",\n        \"TBL_ALIAS\".\"__created_time\",\n        \"TBL_ALIAS\".\"__last_modified_time\",\n        \"TBL_ALIAS\".\"__created_by\",\n        \"TBL_ALIAS\".\"__last_modified_by\",\n        \"TBL_ALIAS\".\"col_c1\" as \"col_c1\"\n      from\n        \"db_table\" as \"TBL_ALIAS\"\n      limit\n        1\"\n    `);\n  });\n\n  it('pushes record id restriction into the base CTE', async () => {\n    const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n      tableId: table.id,\n      projection: [f1.id],\n      restrictRecordIds: ['rec_TEST_1'],\n    });\n\n    const formatted = pretty(normalizeSql(qb.limit(1).toQuery(), alias));\n\n    expect(formatted).toMatch(/with\\s+\"BASE_TBL_ALIAS\"\\s+as/i);\n    expect(formatted).toMatch(/where\\s+\"TBL_ALIAS\"\\.\"__id\"\\s+in\\s+\\('rec_TEST_1'\\)/i);\n    expect(formatted).toMatch(/from\\s+\"BASE_TBL_ALIAS\"\\s+as\\s+\"TBL_ALIAS\"/i);\n  });\n\n  it('pushes record id restriction into the aggregate base CTE', async () => {\n    const { qb, alias } = await rqb.createRecordAggregateBuilder(dbTableName, {\n      tableId: table.id,\n      aggregationFields: [\n        {\n          fieldId: '*',\n          statisticFunc: StatisticsFunc.Count,\n          alias: 'row_count',\n        },\n      ],\n      restrictRecordIds: ['rec_TEST_2'],\n    });\n\n    const formatted = pretty(normalizeSql(qb.toQuery(), alias));\n    expect(formatted).toMatch(/with\\s+\"BASE_TBL_ALIAS\"\\s+as/i);\n    expect(formatted).toMatch(/where\\s+\"TBL_ALIAS\"\\.\"__id\"\\s+in\\s+\\('rec_TEST_2'\\)/i);\n    expect(formatted).toMatch(/from\\s+\"BASE_TBL_ALIAS\"\\s+as\\s+\"TBL_ALIAS\"/i);\n  });\n\n  it('qualifies system columns inside lookup CTE formulas', async () => {\n    const foreignTable = await createTable(baseId, { name: 'rqb_lookup_src' });\n    const foreignFormulaRo: IFieldRo = {\n      name: 'Created Text',\n      type: FT.Formula,\n      options: {\n        expression: `DATETIME_FORMAT(CREATED_TIME(), 'YYYY-MM-DD')`,\n      },\n    };\n    const foreignFormula = await createField(foreignTable.id, foreignFormulaRo);\n\n    let linkField: IFieldVo | undefined;\n    let lookupField: IFieldVo | undefined;\n\n    try {\n      const linkOptions: ILinkFieldOptionsRo = {\n        relationship: Relationship.ManyMany,\n        foreignTableId: foreignTable.id,\n      };\n      const linkFieldRo: IFieldRo = {\n        name: 'Link Lookup Src',\n        type: FT.Link,\n        options: linkOptions,\n      };\n      linkField = await createField(table.id, linkFieldRo);\n\n      const lookupOptions: ILookupOptionsRo = {\n        foreignTableId: foreignTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId: foreignFormula.id,\n      };\n      const lookupFieldRo: IFieldRo = {\n        name: 'Lookup Created Text',\n        type: FT.Formula,\n        isLookup: true,\n        lookupOptions,\n      };\n      lookupField = await createField(table.id, lookupFieldRo);\n\n      const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n        tableId: table.id,\n        projection: [lookupField.id],\n      });\n\n      qb.from({ [alias]: 'db_table' });\n      const sql = qb.limit(1).toQuery();\n\n      expect(sql).not.toContain('TO_CHAR(\"__created_time\"');\n      expect(sql).toContain('\"__created_time\"');\n    } finally {\n      if (lookupField) {\n        await deleteField(table.id, lookupField.id);\n      }\n      if (linkField) {\n        await deleteField(table.id, linkField.id);\n      }\n      await permanentDeleteTable(baseId, foreignTable.id);\n    }\n  });\n\n  it('does not leak unbound placeholders from conditional rollup CTEs', async () => {\n    const foreignTable = await createTable(baseId, {\n      name: 'rqb_cond_rollup_src',\n      fields: [\n        { name: 'Label', type: FT.SingleLineText } as IFieldRo,\n        { name: 'Amount', type: FT.SingleLineText } as IFieldRo,\n      ],\n    });\n\n    let linkField: IFieldVo | undefined;\n    let conditionalRollup: IFieldVo | undefined;\n\n    try {\n      linkField = await createField(table.id, {\n        name: 'Cond Rollup Link',\n        type: FT.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: foreignTable.id,\n        },\n      } as IFieldRo);\n\n      const amountFieldId = foreignTable.fields.find((f) => f.name === 'Amount')!.id;\n\n      conditionalRollup = (await createField(table.id, {\n        name: 'Cond Rollup Array Join',\n        type: FT.ConditionalRollup,\n        options: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: amountFieldId,\n          expression: 'array_join({values})',\n        },\n      } as IFieldRo)) as IFieldVo;\n\n      const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n        tableId: table.id,\n        projection: [conditionalRollup.id],\n      });\n      qb.from({ [alias]: 'db_table' });\n\n      const sql = qb.limit(1).toQuery();\n      expect(sql).not.toMatch(/limit\\\\s+\\\\?/i);\n    } finally {\n      if (conditionalRollup) {\n        await deleteField(table.id, conditionalRollup.id);\n      }\n      if (linkField) {\n        await deleteField(table.id, linkField.id);\n      }\n      await permanentDeleteTable(baseId, foreignTable.id);\n    }\n  });\n\n  it('left joins link CTEs even when dependencies pre-generate them', async () => {\n    const selfLink = await createField(table.id, {\n      name: 'Self Link',\n      type: FT.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table.id,\n      },\n    } as IFieldRo);\n\n    try {\n      const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n        tableId: table.id,\n        projection: [selfLink.id],\n      });\n\n      qb.from({ [alias]: 'db_table' });\n      const sql = qb.limit(1).toQuery();\n\n      const linkCtePattern = new RegExp(\n        `LEFT JOIN \"CTE_[^\"]*_${selfLink.id}\" ON \"${alias}\"\\\\.\"__id\" = \"CTE_[^\"]*_${selfLink.id}\"\\\\.\"main_record_id\"`,\n        'i'\n      );\n      expect(sql).toMatch(linkCtePattern);\n    } finally {\n      await deleteField(table.id, selfLink.id);\n    }\n  });\n\n  it('uses grouped equality plan for array_unique conditional rollups with field references', async () => {\n    const foreign = await createTable(baseId, {\n      name: 'rqb_cond_rollup_unique_src',\n      fields: [\n        { name: 'Student Id', type: FT.SingleLineText } as IFieldRo,\n        { name: 'Subject', type: FT.SingleLineText } as IFieldRo,\n      ],\n    });\n\n    let conditionalRollup: IFieldVo | undefined;\n\n    try {\n      const studentIdField = foreign.fields.find((field) => field.name === 'Student Id')!;\n      const subjectField = foreign.fields.find((field) => field.name === 'Subject')!;\n\n      conditionalRollup = await createField(table.id, {\n        name: 'Cond Rollup Unique',\n        type: FT.ConditionalRollup,\n        options: {\n          foreignTableId: foreign.id,\n          lookupFieldId: subjectField.id,\n          expression: 'array_unique({values})',\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: studentIdField.id,\n                operator: 'is',\n                value: { type: 'field', fieldId: f1.id },\n              },\n            ],\n          },\n        },\n      } as IFieldRo);\n\n      const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n        tableId: table.id,\n        projection: [conditionalRollup.id],\n      });\n      qb.from({ [alias]: 'db_table' });\n\n      const sql = qb.limit(1).toQuery();\n      expect(sql).toContain(`__cr_counts_${conditionalRollup.id}`);\n      expect(sql).toContain('json_agg(DISTINCT');\n      expect(sql).toMatch(/group by/i);\n    } finally {\n      if (conditionalRollup) {\n        await deleteField(table.id, conditionalRollup.id);\n      }\n      await permanentDeleteTable(baseId, foreign.id);\n    }\n  });\n\n  it.each([\n    {\n      nameSuffix: 'counta',\n      expression: 'counta({values})',\n      lookupFieldName: 'Subject',\n      expectedSqlFragment: 'COALESCE(COUNT(',\n      expectedFallbackFragment: '0::double precision',\n    },\n    {\n      nameSuffix: 'and',\n      expression: 'and({values})',\n      lookupFieldName: 'Is Active',\n      expectedSqlFragment: 'BOOL_AND(',\n    },\n    {\n      nameSuffix: 'or',\n      expression: 'or({values})',\n      lookupFieldName: 'Is Active',\n      expectedSqlFragment: 'BOOL_OR(',\n    },\n    {\n      nameSuffix: 'xor',\n      expression: 'xor({values})',\n      lookupFieldName: 'Is Active',\n      expectedSqlFragment: '% 2 = 1',\n    },\n  ])(\n    'uses grouped equality plan for $expression conditional rollups with field references',\n    async ({\n      nameSuffix,\n      expression,\n      lookupFieldName,\n      expectedSqlFragment,\n      expectedFallbackFragment,\n    }) => {\n      const foreign = await createTable(baseId, {\n        name: `rqb_cond_rollup_eq_${nameSuffix}`,\n        fields: [\n          { name: 'Student Id', type: FT.SingleLineText } as IFieldRo,\n          { name: 'Subject', type: FT.SingleLineText } as IFieldRo,\n          { name: 'Is Active', type: FT.Checkbox } as IFieldRo,\n        ],\n      });\n\n      let conditionalRollup: IFieldVo | undefined;\n\n      try {\n        const studentIdField = foreign.fields.find((field) => field.name === 'Student Id')!;\n        const lookupField = foreign.fields.find((field) => field.name === lookupFieldName)!;\n\n        conditionalRollup = await createField(table.id, {\n          name: `Cond Rollup ${expression}`,\n          type: FT.ConditionalRollup,\n          options: {\n            foreignTableId: foreign.id,\n            lookupFieldId: lookupField.id,\n            expression,\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: studentIdField.id,\n                  operator: 'is',\n                  value: { type: 'field', fieldId: f1.id },\n                },\n              ],\n            },\n          },\n        } as IFieldRo);\n\n        const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n          tableId: table.id,\n          projection: [conditionalRollup.id],\n        });\n        qb.from({ [alias]: 'db_table' });\n\n        const sql = qb.limit(1).toQuery();\n        expect(sql).toContain(`__cr_counts_${conditionalRollup.id}`);\n        expect(sql).toContain(expectedSqlFragment);\n        if (expectedFallbackFragment) {\n          expect(sql).toContain(expectedFallbackFragment);\n        }\n      } finally {\n        if (conditionalRollup) {\n          await deleteField(table.id, conditionalRollup.id);\n        }\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    }\n  );\n\n  it('uses equality join for conditional lookup filters referencing user fields', async () => {\n    const foreign = await createTable(baseId, {\n      name: 'rqb_cond_lookup_user_src',\n      fields: [\n        { name: 'Owner', type: FT.User } as IFieldRo,\n        { name: 'Tutor', type: FT.User } as IFieldRo,\n      ],\n    });\n\n    let hostAssignee: IFieldVo | undefined;\n    let conditionalLookup: IFieldVo | undefined;\n\n    try {\n      const ownerField = foreign.fields.find((field) => field.name === 'Owner')!;\n      const tutorField = foreign.fields.find((field) => field.name === 'Tutor')!;\n\n      hostAssignee = await createField(table.id, {\n        name: 'Host Assignee',\n        type: FT.User,\n      } as IFieldRo);\n\n      conditionalLookup = await createField(table.id, {\n        name: 'Cond Lookup Tutor',\n        type: FT.User,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: foreign.id,\n          lookupFieldId: tutorField.id,\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: ownerField.id,\n                operator: 'is',\n                value: { type: 'field', fieldId: hostAssignee.id },\n              },\n            ],\n          },\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      const { qb, alias } = await rqb.createRecordQueryBuilder(dbTableName, {\n        tableId: table.id,\n        projection: [conditionalLookup.id],\n      });\n      qb.from({ [alias]: 'db_table' });\n\n      const sql = qb.limit(1).toQuery();\n      expect(sql).toContain(`__cl_${conditionalLookup.id}`);\n      expect(sql).toContain('ROW_NUMBER() OVER (PARTITION BY');\n      expect(sql).toContain('jsonb_extract_path_text');\n    } finally {\n      if (conditionalLookup) {\n        await deleteField(table.id, conditionalLookup.id);\n      }\n      if (hostAssignee) {\n        await deleteField(table.id, hostAssignee.id);\n      }\n      await permanentDeleteTable(baseId, foreign.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-search-query.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport {\n  CellValueType,\n  Colors,\n  DriverClient,\n  FieldKeyType,\n  FieldType,\n  Relationship,\n  SortFunc,\n} from '@teable/core';\nimport type { IExtraResult } from '@teable/core';\nimport type { IGetRecordsRo, ITableFullVo } from '@teable/openapi';\nimport {\n  getRecords as apiGetRecords,\n  createField,\n  toggleTableIndex,\n  getTableActivatedIndex,\n  TableIndex,\n  getTableAbnormalIndex,\n  repairTableIndex,\n  deleteField,\n  updateField,\n  convertField,\n  getSearchIndex,\n  urlBuilder,\n  axios,\n} from '@teable/openapi';\nimport { differenceWith } from 'lodash';\nimport type { IFieldInstance } from '../src/features/field/model/factory';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  getFields,\n  getTableIndexService,\n} from './utils/init-app';\n\nconst getSearchIndexName = (tableDbName: string, dbFieldName: string, fieldId: string) => {\n  const maxTableDbNameLen = 63 - fieldId.length - 3 - 'idx_trgm'.length;\n  const tableDbNameLen =\n    maxTableDbNameLen < tableDbName.length ? maxTableDbNameLen : tableDbName.length;\n  const maxDbFieldNameLen = 63 - tableDbNameLen - fieldId.length - 3 - 'idx_trgm'.length;\n  return `idx_trgm_${tableDbName.slice(0, tableDbNameLen)}_${dbFieldName.slice(0, maxDbFieldNameLen)}_${fieldId}`;\n};\n\ndescribe('OpenAPI Record-Search-Query (e2e)', async () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('basis field search record', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'sort_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = await getFields(table.id);\n      subTable.fields = await getFields(subTable.id);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n\n    describe('simple search fields', () => {\n      test.each([\n        {\n          fieldIndex: 0,\n          queryValue: 'field 19',\n          expectResultLength: 1,\n        },\n        {\n          fieldIndex: 1,\n          queryValue: '19',\n          expectResultLength: 1,\n        },\n        {\n          fieldIndex: 1,\n          queryValue: '19.0',\n          expectResultLength: 1,\n        },\n        {\n          fieldIndex: 1,\n          queryValue: '19.00',\n          expectResultLength: 0,\n        },\n        {\n          fieldIndex: 2,\n          queryValue: 'Z',\n          expectResultLength: 2,\n        },\n        {\n          fieldIndex: 3,\n          queryValue: '2022-03-02',\n          expectResultLength: 1,\n        },\n        {\n          fieldIndex: 3,\n          queryValue: '2022-02-28',\n          expectResultLength: 0,\n        },\n        {\n          fieldIndex: 4,\n          queryValue: 'true',\n          expectResultLength: 23,\n        },\n        {\n          fieldIndex: 5,\n          queryValue: 'test',\n          expectResultLength: 1,\n        },\n        {\n          fieldIndex: 6,\n          queryValue: 'hiphop',\n          expectResultLength: 5,\n        },\n        {\n          fieldIndex: 7,\n          queryValue: 'test',\n          expectResultLength: 2,\n        },\n        {\n          fieldIndex: 7,\n          queryValue: '\"',\n          expectResultLength: 0,\n        },\n        {\n          fieldIndex: 8,\n          queryValue: '2.1',\n          expectResultLength: 23,\n        },\n      ])(\n        'should search value: $queryValue in field: $fieldIndex, expect result length: $expectResultLength',\n        async ({ fieldIndex, queryValue, expectResultLength }) => {\n          const tableId = table.id;\n          const viewId = table.views[0].id;\n          const fieldId = table.fields[fieldIndex].id;\n\n          const { records } = (\n            await apiGetRecords(tableId, {\n              fieldKeyType: FieldKeyType.Id,\n              viewId,\n              search: [queryValue, fieldId, true],\n            })\n          ).data;\n\n          // console.log('records', records);\n          expect(records.length).toBe(expectResultLength);\n        }\n      );\n    });\n\n    describe('advanced search fields', () => {\n      test.each([\n        {\n          tableName: 'table',\n          fieldIndex: x_20.fields.length,\n          queryValue: 'B-18',\n          expectResultLength: 6,\n        },\n        {\n          tableName: 'table',\n          fieldIndex: x_20.fields.length,\n          queryValue: '\"',\n          expectResultLength: 0,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 4,\n          queryValue: '20.0',\n          expectResultLength: 1,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 5,\n          queryValue: 'z',\n          expectResultLength: 1,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 6,\n          queryValue: '2020',\n          expectResultLength: 5,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 8,\n          queryValue: 'test',\n          expectResultLength: 5,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 9,\n          queryValue: 'hiphop',\n          expectResultLength: 7,\n        },\n        {\n          tableName: 'subTable',\n          fieldIndex: 10,\n          queryValue: 'test_1, test_1',\n          expectResultLength: 3,\n        },\n      ])(\n        'should search $tableName value: $queryValue in field: $fieldIndex, expect result length: $expectResultLength',\n        async ({ tableName, fieldIndex, queryValue, expectResultLength }) => {\n          const curTable = tableName === 'table' ? table : subTable;\n          const viewId = curTable.views[0].id;\n          const field = curTable.fields[fieldIndex];\n\n          // console.log('currentField:', JSON.stringify(field, null, 2));\n\n          const { records } = (\n            await apiGetRecords(curTable.id, {\n              fieldKeyType: FieldKeyType.Id,\n              viewId,\n              search: [queryValue, field.id, true],\n            })\n          ).data;\n\n          expect(records.length).toBe(expectResultLength);\n        }\n      );\n    });\n  });\n\n  describe('basis field search highlight record', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'sort_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = await getFields(table.id);\n      subTable.fields = await getFields(subTable.id);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n    });\n\n    it('should get records with highlight records', async () => {\n      const res = (\n        await apiGetRecords(table.id, {\n          search: ['text field 10'],\n        })\n      ).data;\n\n      expect(res.extra?.searchHitIndex?.length).toBe(2);\n      expect(res.extra?.searchHitIndex).toEqual(\n        expect.arrayContaining([\n          { recordId: res.records[11].id, fieldId: table.fields[0].id },\n          { recordId: res.records[22].id, fieldId: table.fields[0].id },\n        ])\n      );\n    });\n\n    it('should get doc-ids with searchHitIndex when projection is provided (personal view)', async () => {\n      const projectionFieldIds = table.fields.slice(0, 3).map((f) => f.id);\n      const query: IGetRecordsRo = {\n        search: ['text field 10'],\n        projection: projectionFieldIds,\n        ignoreViewQuery: true,\n      };\n      const res = await axios.post<{ ids: string[]; extra?: IExtraResult }>(\n        urlBuilder('/table/{tableId}/record/socket/doc-ids', {\n          tableId: table.id,\n        }),\n        query\n      );\n\n      expect(res.data.extra?.searchHitIndex).toBeDefined();\n      expect(res.data.extra?.searchHitIndex?.length).toBeGreaterThan(0);\n      // searchHitIndex should only contain fields within the projection\n      res.data.extra?.searchHitIndex?.forEach((hit) => {\n        expect(projectionFieldIds).toContain(hit.fieldId);\n      });\n    });\n  });\n\n  describe('search value with special characters', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'special_characters',\n        fields: [\n          {\n            name: 'text',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'user',\n            type: FieldType.User,\n          },\n          {\n            name: 'multipleSelect',\n            type: FieldType.MultipleSelect,\n            options: {\n              choices: [\n                { id: 'choX', name: 'rap', color: Colors.Cyan },\n                { id: 'choY', name: 'rock', color: Colors.Blue },\n                { id: 'choZ', name: 'hiphop', color: Colors.Gray },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              text: 'notepad++',\n              multipleSelect: ['rap', 'rock'],\n            },\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should search value with special characters', async () => {\n      const { records } = (\n        await apiGetRecords(table.id, {\n          fieldKeyType: FieldKeyType.Id,\n          viewId: table.views[0].id,\n          search: ['notepad++', table.fields[0].id, true],\n        })\n      ).data;\n      expect(records.length).toBe(1);\n    });\n  });\n\n  describe('search linked record fields (#2015)', () => {\n    let peopleTable: ITableFullVo;\n    let projectsTable: ITableFullVo;\n    let linkFieldId: string;\n    let lookupFieldId: string;\n    let rollupFieldId: string;\n    let formulaFieldId: string;\n\n    const computedFieldConfigs: Array<{\n      label: string;\n      getFieldId: () => string;\n      searchValue: string;\n      assertValue: (value: unknown) => void;\n    }> = [\n      {\n        label: 'link field',\n        getFieldId: () => linkFieldId,\n        searchValue: 'Alice Johnson',\n        assertValue: (value: unknown) => {\n          expect(Array.isArray(value)).toBe(true);\n          expect(value).toEqual(\n            expect.arrayContaining([expect.objectContaining({ title: 'Alice Johnson' })])\n          );\n        },\n      },\n      {\n        label: 'lookup field',\n        getFieldId: () => lookupFieldId,\n        searchValue: 'Alice Johnson',\n        assertValue: (value: unknown) => {\n          expect(value).toEqual(['Alice Johnson']);\n        },\n      },\n      {\n        label: 'rollup field',\n        getFieldId: () => rollupFieldId,\n        searchValue: '100',\n        assertValue: (value: unknown) => {\n          expect(value).toBe(100);\n        },\n      },\n      {\n        label: 'formula field',\n        getFieldId: () => formulaFieldId,\n        searchValue: 'WEBSITE REDESIGN',\n        assertValue: (value: unknown) => {\n          expect(value).toBe('WEBSITE REDESIGN');\n        },\n      },\n    ];\n\n    beforeAll(async () => {\n      peopleTable = await createTable(baseId, {\n        name: 'search_link_people',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Score',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Alice Johnson',\n              Score: 100,\n            },\n          },\n          {\n            fields: {\n              Name: 'Bob Smith',\n              Score: 200,\n            },\n          },\n        ],\n      });\n\n      projectsTable = await createTable(baseId, {\n        name: 'search_link_projects',\n        fields: [\n          {\n            name: 'Project',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Owner',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.ManyMany,\n              foreignTableId: peopleTable.id,\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Project: 'Website Redesign',\n              Owner: [{ id: peopleTable.records[0].id }],\n            },\n          },\n          {\n            fields: {\n              Project: 'Mobile App',\n              Owner: [{ id: peopleTable.records[1].id }],\n            },\n          },\n        ],\n      });\n\n      projectsTable.fields = await getFields(projectsTable.id);\n      const projectField = projectsTable.fields.find((field) => field.name === 'Project')!;\n      linkFieldId = projectsTable.fields.find((field) => field.type === FieldType.Link)!.id;\n\n      const peopleNameField = peopleTable.fields.find((field) => field.name === 'Name')!;\n      const peopleScoreField = peopleTable.fields.find((field) => field.name === 'Score')!;\n\n      const ownerLookupField = await createField(projectsTable.id, {\n        name: 'Owner Name Lookup',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: peopleTable.id,\n          lookupFieldId: peopleNameField.id,\n          linkFieldId,\n        },\n      });\n\n      const ownerRollupField = await createField(projectsTable.id, {\n        name: 'Owner Score Total',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: peopleTable.id,\n          lookupFieldId: peopleScoreField.id,\n          linkFieldId,\n        },\n      });\n\n      const ownerFormulaField = await createField(projectsTable.id, {\n        name: 'Owner Uppercase',\n        type: FieldType.Formula,\n        options: {\n          expression: `UPPER({${projectField.id}})`,\n        },\n      });\n\n      lookupFieldId = ownerLookupField.data.id;\n      rollupFieldId = ownerRollupField.data.id;\n      formulaFieldId = ownerFormulaField.data.id;\n\n      projectsTable.fields = await getFields(projectsTable.id);\n\n      await toggleTableIndex(baseId, projectsTable.id, { type: TableIndex.search });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, projectsTable.id);\n      await permanentDeleteTable(baseId, peopleTable.id);\n    });\n\n    describe('get records search results', () => {\n      const recordTestCases = computedFieldConfigs.flatMap((config) => [\n        {\n          caseName: `${config.label} field search showing all rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => config.getFieldId(),\n          hideNotMatch: false,\n          expectedRecordCount: 2,\n          expectedFieldId: () => config.getFieldId(),\n          assertValue: config.assertValue,\n        },\n        {\n          caseName: `${config.label} field search hiding non-matching rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => config.getFieldId(),\n          hideNotMatch: true,\n          expectedRecordCount: 1,\n          expectedFieldId: () => config.getFieldId(),\n          assertValue: config.assertValue,\n        },\n        {\n          caseName: `${config.label} global search showing all rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => '',\n          hideNotMatch: false,\n          expectedRecordCount: 2,\n          expectedFieldId: () => config.getFieldId(),\n          assertValue: config.assertValue,\n        },\n        {\n          caseName: `${config.label} global search hiding non-matching rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => '',\n          hideNotMatch: true,\n          expectedRecordCount: 1,\n          expectedFieldId: () => config.getFieldId(),\n          assertValue: config.assertValue,\n        },\n      ]);\n\n      test.each(recordTestCases)(\n        'returns expected records for %s',\n        async ({\n          getSearchValue,\n          getSearchFieldId,\n          hideNotMatch,\n          expectedRecordCount,\n          expectedFieldId,\n          assertValue,\n        }) => {\n          const searchTuple: [string, string, boolean] = [\n            getSearchValue(),\n            getSearchFieldId(),\n            hideNotMatch,\n          ];\n\n          const { records } = (\n            await apiGetRecords(projectsTable.id, {\n              fieldKeyType: FieldKeyType.Id,\n              viewId: projectsTable.views[0].id,\n              search: searchTuple,\n            })\n          ).data;\n\n          const matchedRecord = records.find((record) => record.id === projectsTable.records[0].id);\n          expect(matchedRecord).toBeDefined();\n          assertValue(matchedRecord?.fields[expectedFieldId()] as unknown);\n          expect(records.length).toBe(expectedRecordCount);\n        }\n      );\n    });\n\n    describe('search index results', () => {\n      const searchIndexTestCases = computedFieldConfigs.flatMap((config) => [\n        {\n          caseName: `${config.label} field search showing all rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => config.getFieldId(),\n          hideNotMatch: false,\n          expectedFieldId: () => config.getFieldId(),\n        },\n        {\n          caseName: `${config.label} field search hiding non-matching rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => config.getFieldId(),\n          hideNotMatch: true,\n          expectedFieldId: () => config.getFieldId(),\n        },\n        {\n          caseName: `${config.label} global search showing all rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => '',\n          hideNotMatch: false,\n          expectedFieldId: () => config.getFieldId(),\n        },\n        {\n          caseName: `${config.label} global search hiding non-matching rows`,\n          getSearchValue: () => config.searchValue,\n          getSearchFieldId: () => '',\n          hideNotMatch: true,\n          expectedFieldId: () => config.getFieldId(),\n        },\n      ]);\n\n      test.each(searchIndexTestCases)(\n        'returns expected search index entries for %s',\n        async ({ getSearchValue, getSearchFieldId, hideNotMatch, expectedFieldId }) => {\n          const searchTuple: [string, string, boolean] = [\n            getSearchValue(),\n            getSearchFieldId(),\n            hideNotMatch,\n          ];\n\n          const payload = (\n            await getSearchIndex(projectsTable.id, {\n              viewId: projectsTable.views[0].id,\n              take: 10,\n              search: searchTuple,\n            })\n          ).data;\n\n          expect(Array.isArray(payload)).toBe(true);\n          expect(payload?.length ?? 0).toBeGreaterThan(0);\n          const matches =\n            payload?.filter(\n              (entry) =>\n                entry.recordId === projectsTable.records[0].id &&\n                entry.fieldId === expectedFieldId()\n            ) ?? [];\n          expect(matches.length).toBeGreaterThan(0);\n        }\n      );\n    });\n  });\n\n  describe('search value with line break', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'special_characters',\n        fields: [\n          {\n            name: 'text',\n            type: FieldType.LongText,\n          },\n          {\n            name: 'user',\n            type: FieldType.User,\n          },\n          {\n            name: 'multipleSelect',\n            type: FieldType.MultipleSelect,\n            options: {\n              choices: [\n                { id: 'choX', name: 'rap', color: Colors.Cyan },\n                { id: 'choY', name: 'rock', color: Colors.Blue },\n                { id: 'choZ', name: 'hiphop', color: Colors.Gray },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              text: `hello\\nnewYork, London\\nlove`,\n              multipleSelect: ['rap', 'rock'],\n            },\n          },\n        ],\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should search value with line break', async () => {\n      const { records } = (\n        await apiGetRecords(table.id, {\n          fieldKeyType: FieldKeyType.Id,\n          viewId: table.views[0].id,\n          search: ['hello newYork, London love', table.fields[0].id, true],\n        })\n      ).data;\n      expect(records.length).toBe(1);\n    });\n  });\n\n  describe('search quoting regressions', () => {\n    let table: ITableFullVo;\n    let descriptionFieldId: string;\n    let groupFieldId: string;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'search_quoting_regression',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Description',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Group',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { id: 'choAlpha', name: 'Alpha', color: Colors.Blue },\n                { id: 'choBeta', name: 'Beta', color: Colors.Cyan },\n              ],\n            },\n          },\n        ],\n        records: [\n          {\n            fields: {\n              Name: 'Alpha row',\n              Description: 'ce target',\n              Group: 'Alpha',\n            },\n          },\n          {\n            fields: {\n              Name: 'Beta row',\n              Description: 'other value',\n              Group: 'Beta',\n            },\n          },\n        ],\n      });\n\n      const descriptionField = table.fields.find((f) => f.name === 'Description')!;\n      const groupField = table.fields.find((f) => f.name === 'Group')!;\n      await updateField(table.id, descriptionField.id, { dbFieldName: 'DESCRIPTION' });\n      await updateField(table.id, groupField.id, { dbFieldName: 'GROUP' });\n\n      table.fields = await getFields(table.id);\n      descriptionFieldId = table.fields.find((f) => f.name === 'Description')!.id;\n      groupFieldId = table.fields.find((f) => f.name === 'Group')!.id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('returns results when searching uppercase db column', async () => {\n      const response = await apiGetRecords(table.id, {\n        viewId: table.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n        search: ['ce target', descriptionFieldId, true],\n      });\n\n      const { records } = response.data;\n      expect(records.length).toBe(1);\n      expect(records[0].fields[descriptionFieldId]).toBe('ce target');\n    });\n\n    it('sorts search index when single select column uses reserved name', async () => {\n      const result = await getSearchIndex(table.id, {\n        viewId: table.views[0].id,\n        take: 10,\n        search: ['ce', '', false],\n        orderBy: [{ fieldId: groupFieldId, order: SortFunc.Asc }],\n      });\n\n      const payload = result.data as unknown;\n      expect(Array.isArray(payload)).toBe(true);\n      const entries = payload as { fieldId: string }[];\n      expect(entries.length).toBeGreaterThan(0);\n      expect(entries[0]?.fieldId).toBe(descriptionFieldId);\n    });\n  });\n\n  describe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n    'search index relative',\n    () => {\n      let table: ITableFullVo;\n      let tableName: string;\n      beforeEach(async () => {\n        table = await createTable(baseId, {\n          name: 'record_query_x_20',\n          fields: x_20.fields,\n          records: x_20.records,\n        });\n        tableName = table?.dbTableName?.split('.').pop() as string;\n      });\n\n      afterEach(async () => {\n        await permanentDeleteTable(baseId, table.id);\n      });\n\n      it('should create trgm index', async () => {\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const result = await getTableActivatedIndex(baseId, table.id);\n        expect(result.data.includes(TableIndex.search)).toBe(true);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const result2 = await getTableActivatedIndex(baseId, table.id);\n        expect(result2.data.includes(TableIndex.search)).toBe(false);\n      });\n\n      it('should get abnormal index list', async () => {\n        const textfield = table.fields.find(\n          (f) => f.cellValueType === CellValueType.String\n        )! as IFieldInstance;\n        // enable search index\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n\n        // delete or update abnormal index\n        const tableIndexService = await getTableIndexService(app);\n        await tableIndexService.deleteSearchFieldIndex(table.id, textfield);\n\n        // expect get the abnormal list\n        const result = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result.data.length).toBe(1);\n        expect(result.data[0]).toEqual({\n          indexName: getSearchIndexName(tableName, textfield.dbFieldName, textfield.id),\n        });\n      });\n\n      it('should repair abnormal index', async () => {\n        const textfield = table.fields.find(\n          (f) => f.cellValueType === CellValueType.String\n        )! as IFieldInstance;\n        // enable search index\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n\n        // delete or update abnormal index\n        const tableIndexService = await getTableIndexService(app);\n        await tableIndexService.deleteSearchFieldIndex(table.id, textfield);\n\n        // expect get the abnormal list\n        const result = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result.data.length).toBe(1);\n        expect(result.data[0]).toEqual({\n          indexName: getSearchIndexName(tableName, textfield.dbFieldName, textfield.id),\n        });\n\n        await repairTableIndex(baseId, table.id, TableIndex.search);\n\n        const result2 = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result2.data.length).toBe(0);\n      });\n\n      // field relative operator with table index\n      it('should delete recoding field index when delete field', async () => {\n        const textfield = table.fields.find(\n          (f) => f.cellValueType === CellValueType.String && !f.isPrimary\n        )!;\n\n        const tableIndexService = await getTableIndexService(app);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const index = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        await deleteField(table.id, textfield.id);\n        const index2 = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        const diffIndex = differenceWith(index, index2, (a, b) => a?.indexname === b?.indexname);\n        expect(diffIndex[0]?.indexname).toEqual(\n          getSearchIndexName(tableName, textfield.dbFieldName, textfield.id)\n        );\n        const result2 = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result2.data.length).toBe(0);\n      });\n\n      it('should create new field index automatically when field be created with table index', async () => {\n        const tableIndexService = await getTableIndexService(app);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const index = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        const newField = await createField(table.id, {\n          name: 'newField',\n          type: FieldType.SingleLineText,\n        });\n        const index2 = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        const diffIndex = differenceWith(index2, index, (a, b) => a?.indexname === b?.indexname);\n        expect(diffIndex[0]?.indexname).toEqual(\n          getSearchIndexName(tableName, newField.data.dbFieldName, newField.data.id)\n        );\n        const result2 = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result2.data.length).toBe(0);\n      });\n\n      it('should convert field index automatically when field be convert with table index', async () => {\n        const textfield = table.fields.find(\n          (f) => f.cellValueType === CellValueType.String && !f.isPrimary\n        )!;\n        const tableIndexService = await getTableIndexService(app);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const index = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        await convertField(table.id, textfield.id, {\n          type: FieldType.Checkbox,\n        });\n        const index2 = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        const diffIndex = differenceWith(index, index2, (a, b) => a?.indexname === b?.indexname);\n        expect(diffIndex[0]?.indexname).toEqual(\n          getSearchIndexName(tableName, textfield.dbFieldName, textfield.id)\n        );\n\n        const result2 = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result2.data.length).toBe(0);\n      });\n\n      it('should update index name when dbFieldName to be changed', async () => {\n        const textfield = table.fields.find(\n          (f) => f.cellValueType === CellValueType.String && !f.isPrimary\n        )!;\n        const tableIndexService = await getTableIndexService(app);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const index = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        await updateField(table.id, textfield.id, {\n          dbFieldName: 'Test_Field',\n        });\n        const index2 = (await tableIndexService.getIndexInfo(table.id)) as { indexname: string }[];\n        const diffIndex = differenceWith(index2, index, (a, b) => a?.indexname === b?.indexname);\n        expect(diffIndex[0]?.indexname).toEqual(\n          getSearchIndexName(tableName, 'Test_Field', textfield.id)\n        );\n        const result2 = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result2.data.length).toBe(0);\n      });\n\n      it('should not create search index when field type is button', async () => {\n        const tableIndexService = await getTableIndexService(app);\n        await toggleTableIndex(baseId, table.id, { type: TableIndex.search });\n        const indexBefore = (await tableIndexService.getIndexInfo(table.id)) as {\n          indexname: string;\n        }[];\n\n        // create button type field\n        const buttonField = await createField(table.id, {\n          name: 'buttonField',\n          type: FieldType.Button,\n        });\n\n        const indexAfter = (await tableIndexService.getIndexInfo(table.id)) as {\n          indexname: string;\n        }[];\n\n        // verify index count has not changed (button field should not create index)\n        expect(indexAfter.length).toBe(indexBefore.length);\n\n        // verify no index was created for button field\n        const buttonIndexName = getSearchIndexName(\n          tableName,\n          buttonField.data.dbFieldName,\n          buttonField.data.id\n        );\n        const hasButtonIndex = indexAfter.some((idx) => idx.indexname === buttonIndexName);\n        expect(hasButtonIndex).toBe(false);\n\n        const result = await getTableAbnormalIndex(baseId, table.id, TableIndex.search);\n        expect(result.data.length).toBe(0);\n      });\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-search-question-mark.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldType } from '@teable/core';\nimport { getRecords as apiGetRecords } from '@teable/openapi';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Record search with question mark (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let tableId: string | undefined;\n  let viewId: string | undefined;\n  let urlFieldId: string | undefined;\n\n  const urlField = { name: 'url', type: FieldType.SingleLineText };\n  const urlWithQuestionMark = 'https://example.com/path?param=value';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const table = await createTable(baseId, {\n      name: `record_search_question_mark_${Date.now()}`,\n      fields: [urlField],\n      records: [\n        { fields: { [urlField.name]: urlWithQuestionMark } },\n        { fields: { [urlField.name]: 'https://example.com/other' } },\n      ],\n    });\n\n    tableId = table.id;\n    viewId = table.views?.[0]?.id;\n    urlFieldId = table.fields?.find((f) => f.name === urlField.name)?.id;\n  });\n\n  afterAll(async () => {\n    if (tableId) {\n      await permanentDeleteTable(baseId, tableId);\n    }\n    await app.close();\n  });\n\n  it('should search url containing \"?\" without failing', async () => {\n    const res = await apiGetRecords(tableId!, {\n      viewId,\n      take: 300,\n      skip: 0,\n      search: [urlWithQuestionMark, '', true],\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.data.records).toHaveLength(1);\n    expect(res.data.extra?.searchHitIndex).toEqual([\n      { fieldId: urlFieldId, recordId: res.data.records[0].id },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-typecast.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type { IAttachmentCellValue } from '@teable/core';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { updateRecord, uploadAttachment, type ITableFullVo } from '@teable/openapi';\nimport { pick } from 'lodash';\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { getError } from './utils/get-error';\nimport {\n  createBase,\n  createRecords,\n  createSpace,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteBase,\n  permanentDeleteSpace,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('Record Typecast', () => {\n  let app: INestApplication;\n\n  let baseId: string;\n  let spaceId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    const space = await createSpace({\n      name: 'test space Record Typecast',\n    });\n    spaceId = space.id;\n    const base = await createBase({\n      name: 'test base Record Typecast',\n      spaceId,\n    });\n    baseId = base.id;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteBase(baseId);\n    await permanentDeleteSpace(spaceId);\n    await app.close();\n  });\n\n  describe('user fields', () => {\n    let table: ITableFullVo;\n    const userId = globalThis.testConfig.userId;\n    const userName = globalThis.testConfig.userName;\n    const userEmail = globalThis.testConfig.email;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n        fields: [\n          {\n            name: 'title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'user',\n            type: FieldType.User,\n          },\n        ],\n        records: [],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('prefill user field', async () => {\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields[1].id]: {\n                id: userId,\n                title: userName,\n              },\n            },\n          },\n        ],\n      });\n\n      const { records } = await getRecords(table.id);\n      expect(records[0].fields.user).toEqual({\n        id: userId,\n        title: userName,\n        email: userEmail,\n        avatarUrl: expect.any(String),\n      });\n    });\n\n    it('error when user not in table', async () => {\n      const error = await getError(async () => {\n        await createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [table.fields[1].id]: {\n                  id: 'not-in-table',\n                  title: 'not-in-table',\n                },\n              },\n            },\n          ],\n        });\n      });\n      expect(error?.status).toBe(400);\n      expect(error?.message).toContain('User(not-in-table) not found in table');\n    });\n\n    it('error name and email', async () => {\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields[1].id]: {\n                id: userId,\n                title: '11111',\n                email: '11111',\n              },\n            },\n          },\n        ],\n      });\n\n      const { records } = await getRecords(table.id);\n      expect(records[0].fields.user).toEqual({\n        id: userId,\n        title: userName,\n        email: userEmail,\n        avatarUrl: expect.any(String),\n      });\n    });\n  });\n\n  describe('attachment field', () => {\n    let table: ITableFullVo;\n    let tmpPath: string;\n    beforeAll(async () => {\n      tmpPath = path.resolve(\n        path.join(StorageAdapter.TEMPORARY_DIR, `test-prefill-attachment-field.txt`)\n      );\n      fs.writeFileSync(tmpPath, 'xxxx');\n    });\n\n    afterAll(async () => {\n      fs.unlinkSync(tmpPath);\n    });\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n        fields: [\n          {\n            name: 'title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'attachment',\n            type: FieldType.Attachment,\n          },\n        ],\n        records: [\n          {\n            fields: {\n              title: 'title',\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('prefill attachment field', async () => {\n      const attachment = await uploadAttachment(\n        table.id,\n        table.records[0].id,\n        table.fields[1].id,\n        fs.createReadStream(tmpPath)\n      ).then((res) => res.data);\n\n      const cellValue = attachment.fields[table.fields[1].id] as IAttachmentCellValue;\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields[1].id]: [\n                {\n                  path: 'xxxxx',\n                  name: 'attachment',\n                  id: 'actattachment-id',\n                  size: 100,\n                  mimetype: 'text/plain',\n                  token: cellValue[0].token,\n                },\n              ],\n            },\n          },\n        ],\n      });\n\n      const { records } = await getRecords(table.id);\n      expect(records[1].fields.attachment).toHaveLength(1);\n      expect(records[1].fields.attachment).toEqual([\n        expect.objectContaining({\n          ...pick(cellValue[0], ['token', 'path', 'size', 'mimetype']),\n          name: 'attachment',\n        }),\n      ]);\n    });\n\n    it('error when attachment token not exist', async () => {\n      const error = await getError(async () => {\n        await createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [table.fields[1].id]: [\n                  {\n                    path: 'xxxxx',\n                    name: 'attachment',\n                    id: 'actattachment-id',\n                    size: 100,\n                    mimetype: 'text/plain',\n                    token: 'not-exist-token',\n                  },\n                ],\n              },\n            },\n          ],\n        });\n      });\n      expect(error?.status).toBe(400);\n      expect(error?.message).toContain('Attachment(not-exist-token) not found');\n    });\n  });\n\n  describe('single select field', () => {\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n        fields: [\n          {\n            name: 'title',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'singleSelect',\n            type: FieldType.SingleSelect,\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should create a record with typecast', async () => {\n      const record = await updateRecord(table.id, table.records[0].id, {\n        record: {\n          fields: {\n            [table.fields[0].id]: 'select value',\n            [table.fields[1].id]: '',\n          },\n        },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      }).then((res) => res.data);\n\n      expect(record.fields[table.fields[1].id]).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record-unary-filter.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IGetRecordsRo, ITableFullVo } from '@teable/openapi';\nimport { Colors, FieldKeyType, FieldType } from '@teable/core';\nimport { createTable, getRecords, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Record unary filter operators (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  let table: ITableFullVo;\n  let statusFieldId: string;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    table = await createTable(baseId, {\n      name: 'Unary Filter Table',\n      fields: [\n        {\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Status',\n          type: FieldType.SingleSelect,\n          options: {\n            choices: [\n              { id: 'opt_day0', name: 'Day0 sent', color: Colors.Blue },\n              { id: 'opt_pending', name: 'Pending', color: Colors.Gray },\n            ],\n          },\n        },\n      ],\n      records: [\n        {\n          fields: {\n            Name: 'Has Status',\n            Status: 'Day0 sent',\n          },\n        },\n        {\n          fields: {\n            Name: 'No Status',\n            Status: null,\n          },\n        },\n      ],\n    });\n\n    statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? '';\n    if (!statusFieldId) {\n      throw new Error('Status field not found');\n    }\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n    await app.close();\n  });\n\n  it('should allow isNotEmpty without value on singleSelect', async () => {\n    const query = {\n      fieldKeyType: FieldKeyType.Id,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'isNotEmpty',\n          },\n        ],\n      },\n    } as unknown as IGetRecordsRo;\n\n    const result = await getRecords(table.id, query);\n\n    expect(result.records).toHaveLength(1);\n    expect(result.records[0]?.fields?.[statusFieldId]).toBe('Day0 sent');\n  });\n\n  it('should allow isEmpty without value on singleSelect', async () => {\n    const query = {\n      fieldKeyType: FieldKeyType.Id,\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: statusFieldId,\n            operator: 'isEmpty',\n          },\n        ],\n      },\n    } as unknown as IGetRecordsRo;\n\n    const result = await getRecords(table.id, query);\n\n    expect(result.records).toHaveLength(1);\n    expect(result.records[0]?.fields?.[statusFieldId] ?? null).toBeNull();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/record.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IButtonFieldCellValue, IFieldRo, IFieldVo, ISelectFieldOptions } from '@teable/core';\nimport {\n  CellFormat,\n  Colors,\n  DriverClient,\n  FieldKeyType,\n  FieldType,\n  generateWorkflowId,\n  Relationship,\n} from '@teable/core';\nimport { buttonClick, buttonReset, updateRecords, type ITableFullVo } from '@teable/openapi';\nimport {\n  convertField,\n  createField,\n  createRecords,\n  createTable,\n  deleteField,\n  deleteRecord,\n  deleteRecords,\n  permanentDeleteTable,\n  duplicateRecord,\n  getField,\n  getRecord,\n  getRecords,\n  initApp,\n  updateRecord,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI RecordController (e2e)', () => {\n  let app: INestApplication;\n\n  const baseId = globalThis.testConfig.baseId;\n  const userId = globalThis.testConfig.userId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('simple crud', () => {\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: 'table1' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get records', async () => {\n      const result = await getRecords(table.id);\n      expect(result.records).toBeInstanceOf(Array);\n    });\n\n    it('should get string records', async () => {\n      const createdRecord = await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields[0].id]: 'text value',\n              [table.fields[1].id]: 123,\n            },\n          },\n        ],\n      });\n\n      const { records } = await getRecords(table.id, {\n        cellFormat: CellFormat.Text,\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(records[3].fields[table.fields[0].id]).toEqual('text value');\n      expect(records[3].fields[table.fields[1].id]).toEqual('123.00');\n\n      const record = await getRecord(table.id, createdRecord.records[0].id, CellFormat.Text);\n\n      expect(record.fields[table.fields[0].id]).toEqual('text value');\n      expect(record.fields[table.fields[1].id]).toEqual('123.00');\n    });\n\n    it('should get records with projections', async () => {\n      await updateRecord(table.id, table.records[0].id, {\n        record: {\n          fields: {\n            [table.fields[0].name]: 'text',\n            [table.fields[1].name]: 1,\n          },\n        },\n      });\n\n      const result = await getRecords(table.id, {\n        projection: [table.fields[0].name],\n      });\n\n      expect(Object.keys(result.records[0].fields).length).toEqual(1);\n    });\n\n    it('should get records with single projection parameter', async () => {\n      // Test case for when projection has only one value passed as query param\n      // This tests the fix for schema validation when projection=id is passed\n      const { axios } = await import('@teable/openapi');\n      await updateRecord(table.id, table.records[0].id, {\n        record: {\n          fields: {\n            [table.fields[0].name]: 'text',\n            [table.fields[1].name]: 1,\n          },\n        },\n      });\n\n      // Simulate HTTP query param: ?projection=fieldName\n      // When only one value is passed, it's parsed as string not array\n      const response = await axios.get(`/table/${table.id}/record`, {\n        params: {\n          projection: table.fields[0].name, // Single string value\n          fieldKeyType: FieldKeyType.Name,\n        },\n      });\n\n      expect(response.status).toEqual(200);\n      expect(response.data.records).toBeInstanceOf(Array);\n      expect(response.data.records.length).toBeGreaterThan(0);\n      // Should only return the projected field\n      expect(Object.keys(response.data.records[0].fields).length).toEqual(1);\n      expect(response.data.records[0].fields[table.fields[0].name]).toBeDefined();\n    });\n\n    it('should create a record', async () => {\n      const value1 = 'New Record' + new Date();\n      const res1 = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [table.fields[0].name]: value1,\n            },\n          },\n        ],\n      });\n      expect(res1.records[0].fields[table.fields[0].name]).toEqual(value1);\n\n      const result = await getRecords(table.id, { skip: 0, take: 1000 });\n      expect(result.records).toHaveLength(4);\n\n      const value2 = 'New Record' + new Date();\n      // test fieldKeyType is id\n      const res2 = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [table.fields[0].id]: value2,\n            },\n          },\n        ],\n      });\n\n      expect(res2.records[0].fields[table.fields[0].id]).toEqual(value2);\n    });\n\n    it('should update record', async () => {\n      const record = await updateRecordByApi(\n        table.id,\n        table.records[0].id,\n        table.fields[0].id,\n        'new value'\n      );\n\n      expect(record.fields[table.fields[0].id]).toEqual('new value');\n\n      const result = await getRecords(table.id, { skip: 0, take: 1000 });\n\n      expect(result.records).toHaveLength(3);\n      expect(result.records[0].fields[table.fields[0].name]).toEqual('new value');\n    });\n\n    it('should update and typecast record', async () => {\n      const singleUserField = await createField(table.id, {\n        type: FieldType.User,\n        options: {\n          isMultiple: false,\n        },\n      });\n\n      const multiUserField = await createField(table.id, {\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n        },\n      });\n\n      const dateField = await createField(table.id, {\n        type: FieldType.Date,\n      });\n\n      const res1 = await updateRecord(table.id, table.records[0].id, {\n        record: { fields: { [singleUserField.id]: 'test' } },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      });\n\n      const res2 = await updateRecord(table.id, table.records[0].id, {\n        record: { fields: { [multiUserField.id]: 'test@e2e.com' } },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      });\n\n      const res3 = await updateRecord(table.id, table.records[0].id, {\n        record: { fields: { [dateField.id]: 'now' } },\n        fieldKeyType: FieldKeyType.Id,\n        typecast: true,\n      });\n\n      expect(res1.fields[singleUserField.id]).toMatchObject({\n        email: 'test@e2e.com',\n        title: 'test',\n      });\n      expect(res2.fields[multiUserField.id]).toMatchObject([\n        {\n          email: 'test@e2e.com',\n          title: 'test',\n        },\n      ]);\n\n      expect(res3.fields[dateField.id]).toBeDefined();\n      expect(new Date(res3.fields[dateField.id] as string).toISOString().slice(0, -7)).toEqual(\n        new Date().toISOString().slice(0, -7)\n      );\n    });\n\n    it('should not auto create options when preventAutoNewOptions is true', async () => {\n      const singleSelectField = await createField(table.id, {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'red' }],\n          preventAutoNewOptions: true,\n        },\n      });\n\n      const multiSelectField = await createField(table.id, {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [{ name: 'red' }],\n          preventAutoNewOptions: true,\n        },\n      });\n\n      const records1 = (\n        await updateRecords(table.id, {\n          records: [\n            {\n              id: table.records[0].id,\n              fields: { [singleSelectField.id]: 'red' },\n            },\n            {\n              id: table.records[1].id,\n              fields: { [singleSelectField.id]: 'blue' },\n            },\n          ],\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n        })\n      ).data;\n\n      expect(records1[0].fields[singleSelectField.id]).toEqual('red');\n      expect(records1[1].fields[singleSelectField.id]).toBeUndefined();\n\n      const records2 = (\n        await updateRecords(table.id, {\n          records: [\n            {\n              id: table.records[0].id,\n              fields: { [multiSelectField.id]: ['red', 'blue'] },\n            },\n          ],\n          fieldKeyType: FieldKeyType.Id,\n          typecast: true,\n        })\n      ).data;\n\n      expect(records2[0].fields[multiSelectField.id]).toEqual(['red']);\n    });\n\n    it('should batch create records', async () => {\n      const count = 100;\n      console.time(`create ${count} records`);\n      const records = Array.from({ length: count }).map((_, i) => ({\n        fields: {\n          [table.fields[0].name]: 'New Record' + new Date(),\n          [table.fields[1].name]: i,\n          [table.fields[2].name]: 'light',\n        },\n      }));\n\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records,\n      });\n\n      console.timeEnd(`create ${count} records`);\n    });\n\n    it('should delete a record', async () => {\n      const value1 = 'New Record' + new Date();\n      const addRecordRes = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [table.fields[0].name]: value1,\n            },\n          },\n        ],\n      });\n\n      await getRecord(table.id, addRecordRes.records[0].id, undefined, 200);\n\n      await deleteRecord(table.id, addRecordRes.records[0].id);\n\n      await getRecord(table.id, addRecordRes.records[0].id, undefined, 404);\n    });\n\n    it('should batch delete records', async () => {\n      const value1 = 'New Record' + new Date();\n      const addRecordsRes = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [table.fields[0].name]: value1,\n            },\n          },\n          {\n            fields: {\n              [table.fields[0].name]: value1,\n            },\n          },\n        ],\n      });\n      const records = addRecordsRes.records;\n\n      await getRecord(table.id, records[0].id, undefined, 200);\n      await getRecord(table.id, records[1].id, undefined, 200);\n\n      await deleteRecords(\n        table.id,\n        records.map((record) => record.id)\n      );\n\n      await getRecord(table.id, records[0].id, undefined, 404);\n      await getRecord(table.id, records[1].id, undefined, 404);\n    });\n\n    it('should create a record after delete a record', async () => {\n      const value1 = 'New Record' + new Date();\n      await deleteRecord(table.id, table.records[0].id);\n\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Name,\n        records: [\n          {\n            fields: {\n              [table.fields[0].name]: value1,\n            },\n          },\n        ],\n      });\n    });\n\n    it('should duplicate a record', async () => {\n      const value1 = 'New Record';\n      const addRecordRes = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [table.fields[0].id]: value1,\n            },\n          },\n        ],\n      });\n      const addRecord = await getRecord(table.id, addRecordRes.records[0].id, undefined, 200);\n      expect(addRecord.fields[table.fields[0].id]).toEqual(value1);\n\n      const viewId = table.views[0].id;\n      const duplicateRes = await duplicateRecord(table.id, addRecord.id, {\n        viewId,\n        anchorId: addRecord.id,\n        position: 'after',\n      });\n      const record = await getRecord(table.id, duplicateRes.id, undefined, 200);\n      expect(record.fields[table.fields[0].id]).toEqual(value1);\n    });\n  });\n\n  describe('validate record value by field validation', () => {\n    let table: ITableFullVo;\n\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    const clearRecords = async () => {\n      const table2Records = await getRecords(table.id, { fieldKeyType: FieldKeyType.Id });\n\n      await deleteRecords(\n        table.id,\n        table2Records.records.map((record) => record.id)\n      );\n    };\n\n    it('should validate the unique values of the unique field', async () => {\n      const sourceFieldRo: IFieldRo = {\n        name: 'TextField',\n        type: FieldType.SingleLineText,\n        unique: true,\n      };\n\n      await clearRecords();\n\n      const sourceField = await createField(table.id, sourceFieldRo);\n\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [sourceField.id]: '100',\n            },\n          },\n        ],\n      });\n\n      await createRecords(\n        table.id,\n        {\n          records: [\n            {\n              fields: {\n                [sourceField.id]: '100',\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [sourceField.id]: '200',\n            },\n          },\n        ],\n      });\n    });\n\n    it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n      'should validate the not null values of the not null field',\n      async () => {\n        const sourceFieldRo: IFieldRo = {\n          name: 'TextField2',\n          type: FieldType.SingleLineText,\n        };\n        const convertFieldRo: IFieldRo = {\n          name: 'TextField2',\n          type: FieldType.SingleLineText,\n          notNull: true,\n        };\n\n        await clearRecords();\n\n        const sourceField = await createField(table.id, sourceFieldRo);\n        await convertField(table.id, sourceField.id, convertFieldRo);\n\n        await createRecords(\n          table.id,\n          {\n            records: [\n              {\n                fields: {},\n              },\n            ],\n          },\n          400\n        );\n\n        await createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [sourceField.id]: '100',\n              },\n            },\n          ],\n        });\n      }\n    );\n  });\n\n  describe('calculate', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should create a record and auto calculate computed field', async () => {\n      const formulaFieldRo1: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `1 + 1`,\n        },\n      };\n\n      const formulaFieldRo2: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table.fields[0].id}} + 1`,\n        },\n      };\n\n      const formulaField1 = await createField(table.id, formulaFieldRo1);\n      const formulaField2 = await createField(table.id, formulaFieldRo2);\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: {\n              [table.fields[0].id]: 'text value',\n            },\n          },\n        ],\n      });\n\n      expect(records[0].fields[formulaField1.id]).toEqual(2);\n      expect(records[0].fields[formulaField2.id]).toEqual('text value1');\n    });\n\n    it('should create a record with typecast', async () => {\n      const selectFieldRo: IFieldRo = {\n        type: FieldType.SingleSelect,\n      };\n\n      const selectField = await createField(table.id, selectFieldRo);\n\n      // reject data when typecast is false\n      await createRecords(\n        table.id,\n        {\n          records: [\n            {\n              fields: {\n                [selectField.id]: 'select value',\n              },\n            },\n          ],\n        },\n        400\n      );\n\n      const { records } = await createRecords(table.id, {\n        typecast: true,\n        records: [\n          {\n            fields: {\n              [selectField.id]: 'select value',\n            },\n          },\n        ],\n      });\n\n      const fieldAfter = await getField(table.id, selectField.id);\n\n      expect(records[0].fields[selectField.id]).toEqual('select value');\n      expect((fieldAfter.options as ISelectFieldOptions).choices.length).toEqual(1);\n      expect((fieldAfter.options as ISelectFieldOptions).choices).toMatchObject([\n        { name: 'select value' },\n      ]);\n    });\n  });\n\n  describe('calculate when create', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {\n        name: 'table1',\n      });\n      table2 = await createTable(baseId, {\n        name: 'table2',\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create a record with error field formula', async () => {\n      const fieldToDel = table1.fields[2];\n\n      const formulaRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${fieldToDel.id}}`,\n        },\n      };\n\n      const formulaField = await createField(table1.id, formulaRo);\n\n      await deleteField(table1.id, fieldToDel.id);\n\n      const data = await createRecords(table1.id, {\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      expect(data.records[0].fields[formulaField.id]).toBeUndefined();\n    });\n\n    it('should create a record with error lookup and rollup field', async () => {\n      const fieldToDel = table2.fields[2];\n\n      const linkFieldRo: IFieldRo = {\n        name: 'linkField',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = await createField(table1.id, linkFieldRo);\n\n      const lookupFieldRo: IFieldRo = {\n        type: fieldToDel.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: fieldToDel.id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const rollupFieldRo: IFieldRo = {\n        type: FieldType.Rollup,\n        options: {\n          expression: 'sum({values})',\n        },\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: fieldToDel.id,\n          linkFieldId: linkField.id,\n        },\n      };\n\n      const lookupField = await createField(table1.id, lookupFieldRo);\n      const rollup = await createField(table1.id, rollupFieldRo);\n\n      await deleteField(table2.id, fieldToDel.id);\n\n      const data = await createRecords(table1.id, {\n        records: [\n          {\n            fields: {\n              [linkField.id]: { id: table2.records[0].id },\n            },\n          },\n        ],\n      });\n\n      expect(data.records[0].fields[lookupField.id]).toBeUndefined();\n      expect(data.records[0].fields[rollup.id]).toBeUndefined();\n    });\n\n    it('should create a record by name when duplicate name field is deleted', async () => {\n      const fieldName = 'test-field';\n      const fieldRo: IFieldRo = {\n        name: fieldName,\n        type: FieldType.SingleLineText,\n      };\n      for (let i = 0; i < 10; i++) {\n        const field = await createField(table1.id, fieldRo);\n        await deleteField(table1.id, field.id);\n      }\n\n      await createField(table1.id, fieldRo);\n      const cellValue = 'test';\n      const res = await createRecords(table1.id, {\n        records: [\n          {\n            fields: {\n              [fieldName]: cellValue,\n            },\n          },\n        ],\n        fieldKeyType: FieldKeyType.Name,\n        typecast: true,\n      });\n\n      expect(res.records[0].fields[fieldName]).toEqual(cellValue);\n    });\n  });\n\n  describe('create record with default value', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should create a record with default single select', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'default value' }],\n          defaultValue: 'default value',\n        },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      expect(records[0].fields[field.id]).toEqual('default value');\n    });\n\n    it('should create a record with default multiple select', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.MultipleSelect,\n        options: {\n          choices: [{ name: 'default value' }, { name: 'default value2' }],\n          defaultValue: ['default value', 'default value2'],\n        },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      expect(records[0].fields[field.id]).toEqual(['default value', 'default value2']);\n    });\n\n    it('should create a record with default number', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Number,\n        options: {\n          defaultValue: 1,\n        },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      expect(records[0].fields[field.id]).toEqual(1);\n    });\n\n    it('should create a record with default user', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.User,\n        options: {\n          defaultValue: userId,\n        },\n      });\n      const field2 = await createField(table.id, {\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          defaultValue: ['me'],\n        },\n      });\n      const field3 = await createField(table.id, {\n        type: FieldType.User,\n        options: {\n          isMultiple: true,\n          defaultValue: [userId],\n        },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: {},\n          },\n        ],\n      });\n\n      expect(records[0].fields[field.id]).toMatchObject({\n        id: userId,\n        title: expect.any(String),\n        email: expect.any(String),\n        avatarUrl: expect.any(String),\n      });\n      expect(records[0].fields[field2.id]).toMatchObject([\n        {\n          id: userId,\n          title: expect.any(String),\n          email: expect.any(String),\n          avatarUrl: expect.any(String),\n        },\n      ]);\n      expect(records[0].fields[field3.id]).toMatchObject([\n        {\n          id: userId,\n          title: expect.any(String),\n          email: expect.any(String),\n          avatarUrl: expect.any(String),\n        },\n      ]);\n    });\n\n    it('should use false to reset checkbox field', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Checkbox,\n      });\n      const { records } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [field.id]: true,\n            },\n          },\n        ],\n      });\n      expect(records[0].fields[field.id]).toEqual(true);\n\n      await updateRecord(table.id, records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [field.id]: false,\n          },\n        },\n      });\n\n      const { records: records2 } = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n      expect(records2[0].fields[field.id]).toEqual(undefined);\n    });\n  });\n\n  describe('create record with link field', () => {\n    let table: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n        records: [],\n      });\n      table2 = await createTable(baseId, {\n        name: 'table2',\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should create a record with constraint link field', async () => {\n      const linkField = await createField(table.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n        name: 'link field',\n        dbFieldName: 'link_field',\n      });\n\n      await convertField(table.id, linkField.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n        name: 'link field',\n        dbFieldName: 'link_field',\n        notNull: true,\n      });\n\n      const textField = await table2.fields[0];\n      await createField(table.id, {\n        dbFieldName: 'lookup_field',\n        type: textField.type,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: textField.id,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const { records } = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [linkField.id]: [{ id: table2.records[0].id, title: '' }],\n            },\n          },\n        ],\n      });\n\n      expect(records).toBeDefined();\n    });\n  });\n\n  describe('ops index conflict', () => {\n    let table: ITableFullVo;\n    let tableLinkField: IFieldVo;\n    let linkTable: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'field1',\n          },\n        ],\n      });\n      linkTable = await createTable(baseId, {\n        name: 'linkTable',\n        fields: [\n          {\n            type: FieldType.SingleLineText,\n            name: 'field1',\n          },\n        ],\n        records: [\n          {\n            fields: {\n              field1: 'test1',\n            },\n          },\n          {\n            fields: {\n              field1: 'test2',\n            },\n          },\n          {\n            fields: {\n              field1: 'test3',\n            },\n          },\n          {\n            fields: {\n              field1: 'test4',\n            },\n          },\n        ],\n      });\n      tableLinkField = await createField(table.id, {\n        name: 'linkField',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: linkTable.id,\n        },\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, linkTable.id);\n    });\n\n    it('should create a record with link field', async () => {\n      await Promise.all([\n        createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[0].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[1].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[2].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[3].id }],\n              },\n            },\n          ],\n        }),\n        createRecords(table.id, {\n          records: [\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[0].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[1].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[2].id }],\n              },\n            },\n            {\n              fields: {\n                [tableLinkField.id]: [{ id: linkTable.records[3].id }],\n              },\n            },\n          ],\n        }),\n      ]);\n    });\n  });\n\n  describe('button field click and reset', () => {\n    let table: ITableFullVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'table1',\n      });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should click a button field', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Button,\n        options: {\n          label: 'Button',\n          color: Colors.Teal,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'Workflow',\n            isActive: true,\n          },\n        },\n      });\n\n      const res = await buttonClick(table.id, table.records[0].id, field.id);\n      const value = res.data.record.fields[field.id] as IButtonFieldCellValue;\n      expect(value.count).toEqual(1);\n    });\n\n    it('should not click a button field without workflow', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Button,\n        options: {\n          label: 'Button',\n          color: Colors.Teal,\n        },\n      });\n\n      expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow();\n    });\n\n    it('should not click a button field with exceed max count', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Button,\n        options: {\n          label: 'Button',\n          color: Colors.Teal,\n          maxCount: 1,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'Workflow',\n            isActive: true,\n          },\n        },\n      });\n\n      const res = await buttonClick(table.id, table.records[0].id, field.id);\n      const value = res.data.record.fields[field.id] as IButtonFieldCellValue;\n      expect(value.count).toEqual(1);\n\n      expect(buttonClick(table.id, table.records[0].id, field.id)).rejects.toThrow();\n    });\n\n    it('should reset a button field', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Button,\n        options: {\n          label: 'Button',\n          color: Colors.Teal,\n          resetCount: true,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'Workflow',\n            isActive: true,\n          },\n        },\n      });\n\n      const clickRes = await buttonClick(table.id, table.records[0].id, field.id);\n      const clickValue = clickRes.data.record.fields[field.id] as IButtonFieldCellValue;\n      expect(clickValue.count).toEqual(1);\n\n      const resetRes = await buttonReset(table.id, table.records[0].id, field.id);\n      const resetValue = resetRes.data.fields[field.id] as IButtonFieldCellValue;\n      expect(resetValue).toBeUndefined();\n    });\n\n    it('should not reset a button field without resetCount', async () => {\n      const field = await createField(table.id, {\n        type: FieldType.Button,\n        options: {\n          label: 'Button',\n          color: Colors.Teal,\n          workflow: {\n            id: generateWorkflowId(),\n            name: 'Workflow',\n            isActive: true,\n          },\n        },\n      });\n\n      expect(buttonReset(table.id, table.records[0].id, field.id)).rejects.toThrow();\n    });\n  });\n\n  describe('duplicate updates merging', () => {\n    let mainTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n\n    beforeEach(async () => {\n      mainTable = await createTable(baseId, { name: 'dup-main' });\n      foreignTable = await createTable(baseId, { name: 'dup-foreign' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, mainTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('merges duplicate basic field updates to the latest', async () => {\n      const recordId = mainTable.records[0].id;\n      const textField = await createField(mainTable.id, { type: FieldType.SingleLineText });\n\n      const res = await updateRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: recordId, fields: { [textField.id]: 'v1' } },\n          { id: recordId, fields: { [textField.id]: 'v2' } },\n        ],\n      });\n      expect(res.status).toBe(200);\n\n      const updated = await getRecord(mainTable.id, recordId);\n      expect(updated.fields[textField.id]).toEqual('v2');\n    });\n\n    it('merges duplicate link updates (ManyOne) so the last wins', async () => {\n      const recordId = mainTable.records[0].id;\n      const foreignId1 = foreignTable.records[0].id;\n      const foreignId2 = foreignTable.records[1].id;\n\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const res = await updateRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: recordId, fields: { [linkField.id]: { id: foreignId1 } } },\n          { id: recordId, fields: { [linkField.id]: { id: foreignId2 } } },\n        ],\n      });\n      expect(res.status).toBe(200);\n\n      const updated = await getRecord(mainTable.id, recordId);\n      expect(updated.fields[linkField.id]).toMatchObject({ id: foreignId2 });\n    });\n\n    it('merges duplicate updates with formula: computed value reflects the latest', async () => {\n      const recordId = mainTable.records[0].id;\n      const textField = await createField(mainTable.id, { type: FieldType.SingleLineText });\n      const formulaField = await createField(mainTable.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${textField.id}}` },\n      });\n\n      const res = await updateRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: recordId, fields: { [textField.id]: 'first' } },\n          { id: recordId, fields: { [textField.id]: 'second' } },\n        ],\n      });\n      expect(res.status).toBe(200);\n\n      const updated = await getRecord(mainTable.id, recordId);\n      expect(updated.fields[formulaField.id]).toEqual('second');\n    });\n\n    it('merges duplicate updates with lookup: value reflects the latest link target', async () => {\n      const recordId = mainTable.records[0].id;\n      const foreignLabelFieldId = foreignTable.fields[0].id; // text label\n\n      // Prepare foreign labels\n      await updateRecord(foreignTable.id, foreignTable.records[0].id, {\n        record: { fields: { [foreignLabelFieldId]: 'A' } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n      await updateRecord(foreignTable.id, foreignTable.records[1].id, {\n        record: { fields: { [foreignLabelFieldId]: 'B' } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const lookupField = await createField(mainTable.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: foreignLabelFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const res = await updateRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          { id: recordId, fields: { [linkField.id]: { id: foreignTable.records[0].id } } },\n          { id: recordId, fields: { [linkField.id]: { id: foreignTable.records[1].id } } },\n        ],\n      });\n      expect(res.status).toBe(200);\n\n      const updated = await getRecord(mainTable.id, recordId);\n      expect(updated.fields[lookupField.id]).toEqual('B');\n    });\n\n    it('merges duplicate updates with rollup: sum reflects the latest link set', async () => {\n      const recordId = mainTable.records[0].id;\n      const foreignNumberFieldId = foreignTable.fields[1].id; // number\n\n      // Prepare foreign numbers\n      await updateRecord(foreignTable.id, foreignTable.records[0].id, {\n        record: { fields: { [foreignNumberFieldId]: 10 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n      await updateRecord(foreignTable.id, foreignTable.records[1].id, {\n        record: { fields: { [foreignNumberFieldId]: 7 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n      await updateRecord(foreignTable.id, foreignTable.records[2].id, {\n        record: { fields: { [foreignNumberFieldId]: 5 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const rollupField = await createField(mainTable.id, {\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: foreignNumberFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const res = await updateRecords(mainTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: recordId,\n            fields: {\n              [linkField.id]: [\n                { id: foreignTable.records[0].id },\n                { id: foreignTable.records[1].id },\n              ],\n            },\n          },\n          {\n            id: recordId,\n            fields: {\n              [linkField.id]: [{ id: foreignTable.records[2].id }],\n            },\n          },\n        ],\n      });\n      expect(res.status).toBe(200);\n\n      const updated = await getRecord(mainTable.id, recordId);\n      expect(updated.fields[rollupField.id]).toEqual(5);\n    });\n  });\n\n  describe('compute on create: link + lookup + rollup', () => {\n    let mainTable: ITableFullVo;\n    let foreignTable: ITableFullVo;\n\n    beforeEach(async () => {\n      mainTable = await createTable(baseId, { name: 'create-main' });\n      foreignTable = await createTable(baseId, { name: 'create-foreign' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, mainTable.id);\n      await permanentDeleteTable(baseId, foreignTable.id);\n    });\n\n    it('creates with link and computes lookup immediately', async () => {\n      const foreignLabelFieldId = foreignTable.fields[0].id; // text\n      const foreignId = foreignTable.records[0].id;\n\n      // Set known label\n      await updateRecord(foreignTable.id, foreignId, {\n        record: { fields: { [foreignLabelFieldId]: 'LABEL_A' } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const lookupField = await createField(mainTable.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: foreignLabelFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const { records } = await createRecords(mainTable.id, {\n        records: [{ fields: { [linkField.id]: { id: foreignId } } }],\n      });\n\n      expect(records[0].fields[lookupField.id]).toEqual('LABEL_A');\n    });\n\n    it('creates with link and computes rollup immediately', async () => {\n      const foreignNumberFieldId = foreignTable.fields[1].id; // number\n      // Set numbers\n      await updateRecord(foreignTable.id, foreignTable.records[0].id, {\n        record: { fields: { [foreignNumberFieldId]: 11 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n      await updateRecord(foreignTable.id, foreignTable.records[1].id, {\n        record: { fields: { [foreignNumberFieldId]: 9 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const linkField = await createField(mainTable.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: foreignTable.id,\n        },\n      });\n\n      const rollupField = await createField(mainTable.id, {\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: foreignTable.id,\n          lookupFieldId: foreignNumberFieldId,\n          linkFieldId: linkField.id,\n        },\n      });\n\n      const { records } = await createRecords(mainTable.id, {\n        records: [\n          {\n            fields: {\n              [linkField.id]: [\n                { id: foreignTable.records[0].id },\n                { id: foreignTable.records[1].id },\n              ],\n            },\n          },\n        ],\n      });\n\n      expect(records[0].fields[rollupField.id]).toEqual(20);\n    });\n  });\n\n  describe('compute on create: chained formulas', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: 'create-formula-chain' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('creates with chained numeric formulas (f2 depends on f1)', async () => {\n      const baseNum = await createField(table.id, { type: FieldType.Number });\n\n      const f1 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${baseNum.id}} + 1` },\n      });\n\n      const f2 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${f1.id}} + 2` },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: { [baseNum.id]: 10 },\n          },\n        ],\n      });\n\n      expect(records[0].fields[f1.id]).toEqual(11);\n      expect(records[0].fields[f2.id]).toEqual(13);\n    });\n\n    it('creates with chained string formulas', async () => {\n      const txt = await createField(table.id, { type: FieldType.SingleLineText });\n\n      const f1 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${txt.id}} & '-x'` },\n      });\n\n      const f2 = await createField(table.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${f1.id}} & '-y'` },\n      });\n\n      const { records } = await createRecords(table.id, {\n        records: [\n          {\n            fields: { [txt.id]: 'abc' },\n          },\n        ],\n      });\n\n      expect(records[0].fields[f1.id]).toEqual('abc-x');\n      expect(records[0].fields[f2.id]).toEqual('abc-x-y');\n    });\n  });\n\n  describe('compute on update: cascades across tables', () => {\n    let t1: ITableFullVo;\n    let t2: ITableFullVo;\n    let t3: ITableFullVo;\n\n    beforeEach(async () => {\n      t1 = await createTable(baseId, { name: 'cascade-t1' });\n      t2 = await createTable(baseId, { name: 'cascade-t2' });\n      t3 = await createTable(baseId, { name: 'cascade-t3' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, t1.id);\n      await permanentDeleteTable(baseId, t2.id);\n      await permanentDeleteTable(baseId, t3.id);\n    });\n\n    it('updates cascade: formula -> formula -> lookup -> nested lookup', async () => {\n      // Table 1: base number, f1 = n1 + 1, f2 = f1 * 2\n      const n1 = await createField(t1.id, { type: FieldType.Number });\n      const f1 = await createField(t1.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${n1.id}} + 1` },\n      });\n      const f2 = await createField(t1.id, {\n        type: FieldType.Formula,\n        options: { expression: `{${f1.id}} * 2` },\n      });\n\n      // Set base value\n      const t1RecId = t1.records[0].id;\n      await updateRecord(t1.id, t1RecId, {\n        record: { fields: { [n1.id]: 3 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Table 2: link -> t1 (ManyOne), lookup f2\n      const link12 = await createField(t2.id, {\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: t1.id },\n      });\n      const lookup2 = await createField(t2.id, {\n        type: FieldType.Formula,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: t1.id,\n          lookupFieldId: f2.id,\n          linkFieldId: link12.id,\n        },\n      });\n\n      const t2RecId = t2.records[0].id;\n      await updateRecord(t2.id, t2RecId, {\n        record: { fields: { [link12.id]: { id: t1RecId } } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Verify initial computed values at t1 and t2: n1=3 -> f1=4 -> f2=8 -> lookup2=8\n      const t1Rec0 = await getRecord(t1.id, t1RecId);\n      const t2Rec0 = await getRecord(t2.id, t2RecId);\n      expect(t1Rec0.fields[f1.id]).toEqual(4);\n      expect(t1Rec0.fields[f2.id]).toEqual(8);\n      expect(t2Rec0.fields[lookup2.id]).toEqual(8);\n\n      // Update base: n1=10 -> f1=11 -> f2=22, and lookup2 should update\n      await updateRecord(t1.id, t1RecId, {\n        record: { fields: { [n1.id]: 10 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const t1Rec = await getRecord(t1.id, t1RecId);\n      const t2Rec = await getRecord(t2.id, t2RecId);\n      expect(t1Rec.fields[f1.id]).toEqual(11);\n      expect(t1Rec.fields[f2.id]).toEqual(22);\n      expect(t2Rec.fields[lookup2.id]).toEqual(22);\n    });\n\n    it('updates cascade with rollup across link set and nested lookup', async () => {\n      // Table 1: number field\n      const n = await createField(t1.id, { type: FieldType.Number });\n\n      // Create two specific records in t1 with values 5 and 7\n      const created = await createRecords(t1.id, {\n        records: [{ fields: { [n.id]: 5 } }, { fields: { [n.id]: 7 } }],\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const t1IdA = created.records[0].id;\n      const t1IdB = created.records[1].id;\n\n      // Table 2: ManyMany link to t1, rollup sum of n\n      const link = await createField(t2.id, {\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyMany, foreignTableId: t1.id },\n      });\n      const roll = await createField(t2.id, {\n        type: FieldType.Rollup,\n        options: { expression: 'sum({values})' },\n        lookupOptions: {\n          foreignTableId: t1.id,\n          lookupFieldId: n.id,\n          linkFieldId: link.id,\n        },\n      });\n\n      const t2RecId2 = t2.records[0].id;\n      await updateRecord(t2.id, t2RecId2, {\n        record: { fields: { [link.id]: [{ id: t1IdA }, { id: t1IdB }] } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Table 3: link to t2, lookup rollup\n      const link2 = await createField(t3.id, {\n        type: FieldType.Link,\n        options: { relationship: Relationship.ManyOne, foreignTableId: t2.id },\n      });\n      const nested = await createField(t3.id, {\n        type: FieldType.Rollup,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: t2.id,\n          lookupFieldId: roll.id,\n          linkFieldId: link2.id,\n        },\n      });\n\n      const t3RecId2 = t3.records[0].id;\n      await updateRecord(t3.id, t3RecId2, {\n        record: { fields: { [link2.id]: { id: t2RecId2 } } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Initial: 5 + 7 = 12\n      let rec2 = await getRecord(t2.id, t2RecId2);\n      let rec3 = await getRecord(t3.id, t3RecId2);\n      expect(rec2.fields[roll.id]).toEqual(12);\n      expect(rec3.fields[nested.id]).toEqual(12);\n\n      // Update one base number to 20 -> rollup becomes 25, nested lookup 25\n      await updateRecord(t1.id, t1IdA, {\n        record: { fields: { [n.id]: 20 } },\n        fieldKeyType: FieldKeyType.Id,\n      });\n      rec2 = await getRecord(t2.id, t2RecId2);\n      rec3 = await getRecord(t3.id, t3RecId2);\n      expect(rec2.fields[roll.id]).toEqual(27);\n      expect(rec3.fields[nested.id]).toEqual(27);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/rollup.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFieldVo,\n  IFilter,\n  ILookupOptionsRo,\n  IRecord,\n  LinkFieldCore,\n} from '@teable/core';\nimport {\n  Colors,\n  FieldKeyType,\n  FieldType,\n  NumberFormattingType,\n  Relationship,\n  TimeFormatting,\n} from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  convertField,\n  createTable,\n  permanentDeleteTable,\n  getField,\n  getFields,\n  initApp,\n  updateRecord,\n  getRecord,\n} from './utils/init-app';\n\n// All kind of field type (except link)\nconst defaultFields: IFieldRo[] = [\n  {\n    name: FieldType.SingleLineText,\n    type: FieldType.SingleLineText,\n  },\n  {\n    name: FieldType.Number,\n    type: FieldType.Number,\n    options: {\n      formatting: {\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n      },\n    },\n  },\n  {\n    name: FieldType.SingleSelect,\n    type: FieldType.SingleSelect,\n    options: {\n      choices: [\n        { name: 'todo', color: Colors.Yellow },\n        { name: 'doing', color: Colors.Orange },\n        { name: 'done', color: Colors.Green },\n      ],\n    },\n  },\n  {\n    name: FieldType.MultipleSelect,\n    type: FieldType.MultipleSelect,\n    options: {\n      choices: [\n        { name: 'rap', color: Colors.Yellow },\n        { name: 'rock', color: Colors.Orange },\n        { name: 'hiphop', color: Colors.Green },\n      ],\n    },\n  },\n  {\n    name: FieldType.Date,\n    type: FieldType.Date,\n    options: {\n      formatting: {\n        date: 'YYYY-MM-DD',\n        time: TimeFormatting.Hour24,\n        timeZone: 'America/New_York',\n      },\n    },\n  },\n  {\n    name: FieldType.Attachment,\n    type: FieldType.Attachment,\n  },\n  {\n    name: FieldType.Formula,\n    type: FieldType.Formula,\n    options: {\n      expression: '1 + 1',\n      formatting: {\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n      },\n    },\n  },\n];\n\ndescribe('OpenAPI Rollup field (e2e)', () => {\n  let app: INestApplication;\n  let table1: ITableFullVo = {} as any;\n  let table2: ITableFullVo = {} as any;\n  const tables: ITableFullVo[] = [];\n  const baseId = globalThis.testConfig.baseId;\n\n  async function updateTableFields(table: ITableFullVo) {\n    const tableFields = await getFields(table.id);\n    table.fields = tableFields;\n    return tableFields;\n  }\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    // create table1 with fundamental field\n    table1 = await createTable(baseId, {\n      name: 'table1',\n      fields: defaultFields.map((f) => ({ ...f, name: f.name + '[table1]' })),\n    });\n\n    // create table2 with fundamental field\n    table2 = await createTable(baseId, {\n      name: 'table2',\n      fields: defaultFields.map((f) => ({ ...f, name: f.name + '[table2]' })),\n    });\n\n    // create link field\n    await createField(table1.id, {\n      name: 'link[table1]',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.OneMany,\n        foreignTableId: table2.id,\n      },\n    });\n    // update fields in table after create link field\n    await updateTableFields(table1);\n    await updateTableFields(table2);\n    tables.push(table1, table2);\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table1.id);\n    await permanentDeleteTable(baseId, table2.id);\n\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    // remove all link\n    await updateRecordField(\n      table2.id,\n      table2.records[0].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      null\n    );\n    await updateRecordField(\n      table2.id,\n      table2.records[1].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      null\n    );\n    await updateRecordField(\n      table2.id,\n      table2.records[2].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      null\n    );\n  });\n\n  function getFieldByType(fields: IFieldVo[], type: FieldType) {\n    const field = fields.find((field) => field.type === type);\n    if (!field) {\n      throw new Error('field not found');\n    }\n    return field;\n  }\n\n  function getFieldByName(fields: IFieldVo[], name: string) {\n    const field = fields.find((field) => field.name === name);\n    if (!field) {\n      throw new Error('field not found');\n    }\n    return field;\n  }\n\n  async function updateRecordField(\n    tableId: string,\n    recordId: string,\n    fieldId: string,\n    newValues: any\n  ): Promise<IRecord> {\n    return updateRecord(tableId, recordId, {\n      fieldKeyType: FieldKeyType.Id,\n      record: {\n        fields: {\n          [fieldId]: newValues,\n        },\n      },\n    });\n  }\n\n  async function rollupFrom(\n    table: ITableFullVo,\n    lookupFieldId: string,\n    expression = 'countall({values})'\n  ) {\n    const linkField = getFieldByType(table.fields, FieldType.Link) as LinkFieldCore;\n    const foreignTable = tables.find((t) => t.id === linkField.options.foreignTableId)!;\n    const lookupField = foreignTable.fields.find((f) => f.id === lookupFieldId)!;\n    const rollupFieldRo: IFieldRo = {\n      name: `rollup ${lookupField.name} ${expression} [${table.name}]`,\n      type: FieldType.Rollup,\n      options: {\n        expression,\n        formatting: ['count', 'sum', 'average'].some((prefix) => expression.startsWith(prefix))\n          ? {\n              type: NumberFormattingType.Decimal,\n              precision: 0,\n            }\n          : undefined,\n      },\n      lookupOptions: {\n        foreignTableId: foreignTable.id,\n        linkFieldId: linkField.id,\n        lookupFieldId, // getFieldByType(table2.fields, FieldType.SingleLineText).id,\n      } as ILookupOptionsRo,\n    };\n\n    // create rollup field\n    await createField(table.id, rollupFieldRo);\n\n    await updateTableFields(table);\n    return getFieldByName(table.fields, rollupFieldRo.name!);\n  }\n\n  it('should update rollupField by remove a linkRecord from cell', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id, 'countall({values})');\n\n    // update a field that will be rollup by after field\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual(2);\n\n    // remove a link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }]\n    );\n\n    const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter1.fields[rollupFieldVo.id]).toEqual(1);\n\n    // remove all link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      null\n    );\n\n    const recordAfter2 = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter2.fields[rollupFieldVo.id]).toEqual(0);\n\n    // add a link record from many - one field\n    await updateRecordField(\n      table2.id,\n      table2.records[1].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      { id: table1.records[1].id }\n    );\n\n    const recordAfter3 = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter3.fields[rollupFieldVo.id]).toEqual(1);\n  });\n\n  it('should update many - one rollupField by remove a linkRecord from cell', async () => {\n    const lookedUpToField = getFieldByType(table1.fields, FieldType.Number);\n    const rollupFieldVo = await rollupFrom(table2, lookedUpToField.id, 'sum({values})');\n\n    // update a field that will be lookup by after field\n    await updateRecordField(table1.id, table1.records[1].id, lookedUpToField.id, 123);\n\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n\n    const record1 = await getRecord(table2.id, table2.records[1].id);\n    expect(record1.fields[rollupFieldVo.id]).toEqual(123);\n    const record2 = await getRecord(table2.id, table2.records[2].id);\n    expect(record2.fields[rollupFieldVo.id]).toEqual(123);\n\n    // remove a link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }]\n    );\n\n    const record3 = await getRecord(table2.id, table2.records[1].id);\n    expect(record3.fields[rollupFieldVo.id]).toEqual(123);\n    const record4 = await getRecord(table2.id, table2.records[2].id);\n    expect(record4.fields[rollupFieldVo.id]).toEqual(0);\n\n    // remove all link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      null\n    );\n\n    const record5 = await getRecord(table2.id, table2.records[1].id);\n    expect(record5.fields[rollupFieldVo.id]).toEqual(0);\n\n    // add a link record from many - one field\n    await updateRecordField(\n      table2.id,\n      table2.records[1].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      { id: table1.records[1].id }\n    );\n\n    const record6 = await getRecord(table2.id, table2.records[1].id);\n    expect(record6.fields[rollupFieldVo.id]).toEqual(123);\n  });\n\n  it('should calculate average in one - many rollup field', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    const linkFieldId = getFieldByType(table1.fields, FieldType.Link).id;\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id, 'average({values})');\n\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, 20);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, 40);\n\n    await updateRecordField(table1.id, table1.records[1].id, linkFieldId, [\n      { id: table2.records[1].id },\n      { id: table2.records[2].id },\n    ]);\n\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual(30);\n\n    await updateRecordField(table1.id, table1.records[1].id, linkFieldId, [\n      { id: table2.records[2].id },\n    ]);\n\n    const recordAfter = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter.fields[rollupFieldVo.id]).toEqual(40);\n  });\n\n  it('should update many - one rollupField by replace a linkRecord from cell', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id);\n\n    // update a field that will be lookup by after field\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.SingleLineText).id,\n      'A2'\n    );\n    await updateRecordField(\n      table1.id,\n      table1.records[2].id,\n      getFieldByType(table1.fields, FieldType.SingleLineText).id,\n      'A3'\n    );\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n    // add a link record after\n    await updateRecordField(\n      table2.id,\n      table2.records[1].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      { id: table1.records[1].id }\n    );\n\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual(1);\n\n    // replace a link record\n    await updateRecordField(\n      table2.id,\n      table2.records[1].id,\n      getFieldByType(table2.fields, FieldType.Link).id,\n      { id: table1.records[2].id }\n    );\n\n    const record1 = await getRecord(table1.id, table1.records[1].id);\n    expect(record1.fields[rollupFieldVo.id]).toEqual(0);\n    const record2 = await getRecord(table1.id, table1.records[2].id);\n    expect(record2.fields[rollupFieldVo.id]).toEqual(1);\n  });\n\n  it('should update one - many rollupField by add a linkRecord from cell', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id, 'concatenate({values})');\n\n    // update a field that will be lookup by after field\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }]\n    );\n\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual('123');\n\n    // add a link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n\n    const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter1.fields[rollupFieldVo.id]).toEqual('123, 456');\n  });\n\n  it('concatenates link titles when rolling up a link field', async () => {\n    const services = await createTable(baseId, {\n      name: 'rollup_link_services',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Title: 'International' } }, { fields: { Title: 'BtoB' } }],\n    });\n\n    const employees = await createTable(baseId, {\n      name: 'rollup_link_employees',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Name: 'Alice' } }],\n    });\n\n    const departments = await createTable(baseId, {\n      name: 'rollup_link_departments',\n      fields: [{ name: 'Dept', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Dept: 'HR' } }],\n    });\n\n    try {\n      const serviceLink = await createField(employees.id, {\n        name: 'Services',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: services.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(employees.id, employees.records[0].id, serviceLink.id, [\n        { id: services.records[0].id },\n        { id: services.records[1].id },\n      ]);\n\n      const employeeLink = await createField(departments.id, {\n        name: 'Employees',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: employees.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(departments.id, departments.records[0].id, employeeLink.id, [\n        { id: employees.records[0].id },\n      ]);\n\n      const rollup = await createField(departments.id, {\n        name: 'service_titles',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'concatenate({values})',\n        },\n        lookupOptions: {\n          foreignTableId: employees.id,\n          linkFieldId: employeeLink.id,\n          lookupFieldId: serviceLink.id,\n        },\n      } as IFieldRo);\n\n      const record = await getRecord(departments.id, departments.records[0].id);\n      expect(record.fields[rollup.id]).toEqual('International, BtoB');\n    } finally {\n      await permanentDeleteTable(baseId, departments.id);\n      await permanentDeleteTable(baseId, employees.id);\n      await permanentDeleteTable(baseId, services.id);\n    }\n  });\n\n  it('joins link titles with array_join when rolling up a link field', async () => {\n    const services = await createTable(baseId, {\n      name: 'rollup_link_services_array_join',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Title: 'International' } }, { fields: { Title: 'BtoB' } }],\n    });\n\n    const employees = await createTable(baseId, {\n      name: 'rollup_link_employees_array_join',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Name: 'Alice' } }],\n    });\n\n    const departments = await createTable(baseId, {\n      name: 'rollup_link_departments_array_join',\n      fields: [{ name: 'Dept', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Dept: 'HR' } }],\n    });\n\n    try {\n      const serviceLink = await createField(employees.id, {\n        name: 'Services',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: services.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(employees.id, employees.records[0].id, serviceLink.id, [\n        { id: services.records[0].id },\n        { id: services.records[1].id },\n      ]);\n\n      const employeeLink = await createField(departments.id, {\n        name: 'Employees',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: employees.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(departments.id, departments.records[0].id, employeeLink.id, [\n        { id: employees.records[0].id },\n      ]);\n\n      const rollup = await createField(departments.id, {\n        name: 'service_titles_join',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'array_join({values})',\n        },\n        lookupOptions: {\n          foreignTableId: employees.id,\n          linkFieldId: employeeLink.id,\n          lookupFieldId: serviceLink.id,\n        },\n      } as IFieldRo);\n\n      const record = await getRecord(departments.id, departments.records[0].id);\n      expect(record.fields[rollup.id]).toEqual('International, BtoB');\n    } finally {\n      await permanentDeleteTable(baseId, departments.id);\n      await permanentDeleteTable(baseId, employees.id);\n      await permanentDeleteTable(baseId, services.id);\n    }\n  });\n\n  it('deduplicates link titles with array_unique when rolling up a link field', async () => {\n    const services = await createTable(baseId, {\n      name: 'rollup_link_services_unique',\n      fields: [{ name: 'Title', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Title: 'International' } }, { fields: { Title: 'BtoB' } }],\n    });\n\n    const employees = await createTable(baseId, {\n      name: 'rollup_link_employees_unique',\n      fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Name: 'Alice' } }, { fields: { Name: 'Bob' } }],\n    });\n\n    const departments = await createTable(baseId, {\n      name: 'rollup_link_departments_unique',\n      fields: [{ name: 'Dept', type: FieldType.SingleLineText } as IFieldRo],\n      records: [{ fields: { Dept: 'HR' } }],\n    });\n\n    try {\n      const serviceLink = await createField(employees.id, {\n        name: 'Services',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: services.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(employees.id, employees.records[0].id, serviceLink.id, [\n        { id: services.records[0].id },\n      ]);\n      await updateRecordField(employees.id, employees.records[1].id, serviceLink.id, [\n        { id: services.records[1].id },\n      ]);\n\n      const employeeLink = await createField(departments.id, {\n        name: 'Employees',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: employees.id,\n        },\n      } as IFieldRo);\n\n      await updateRecordField(departments.id, departments.records[0].id, employeeLink.id, [\n        { id: employees.records[0].id },\n        { id: employees.records[1].id },\n      ]);\n\n      const rollup = await createField(departments.id, {\n        name: 'service_titles_unique',\n        type: FieldType.Rollup,\n        options: {\n          expression: 'array_unique({values})',\n        },\n        lookupOptions: {\n          foreignTableId: employees.id,\n          linkFieldId: employeeLink.id,\n          lookupFieldId: serviceLink.id,\n        },\n      } as IFieldRo);\n\n      const record = await getRecord(departments.id, departments.records[0].id);\n      const values = record.fields[rollup.id] as string[];\n      expect(values).toHaveLength(2);\n      expect(values).toEqual(expect.arrayContaining(['International', 'BtoB']));\n    } finally {\n      await permanentDeleteTable(baseId, departments.id);\n      await permanentDeleteTable(baseId, employees.id);\n      await permanentDeleteTable(baseId, services.id);\n    }\n  });\n\n  describe('rollup expression coverage', () => {\n    const baseId = globalThis.testConfig.baseId;\n    const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\n    const setupRollupFixtures = async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RollupExpr_Foreign',\n        fields: [\n          { name: 'Label', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Amount', type: FieldType.Number } as IFieldRo,\n          { name: 'Flag', type: FieldType.Checkbox } as IFieldRo,\n        ],\n        records: [\n          { fields: { Label: 'Alpha', Amount: 10, Flag: true } },\n          { fields: { Label: 'Beta', Amount: 20, Flag: false } },\n        ],\n      });\n\n      const host = await createTable(baseId, {\n        name: 'RollupExpr_Host',\n        fields: [{ name: 'Name', type: FieldType.SingleLineText } as IFieldRo],\n        records: [{ fields: { Name: 'Rollup Holder' } }],\n      });\n\n      const linkField = await createField(host.id, {\n        name: 'Links',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: foreign.id,\n        },\n      } as IFieldRo);\n\n      const hostRecordId = host.records[0].id;\n      await updateRecordField(\n        host.id,\n        hostRecordId,\n        linkField.id,\n        foreign.records.map((record) => ({ id: record.id }))\n      );\n\n      const amountId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n      const labelId = foreign.fields.find((field) => field.name === 'Label')!.id;\n      const flagId = foreign.fields.find((field) => field.name === 'Flag')!.id;\n\n      return { foreign, host, linkField, hostRecordId, amountId, labelId, flagId };\n    };\n\n    const rollupCases: Array<{\n      expression: string;\n      lookupFieldKey: 'amountId' | 'labelId' | 'flagId';\n      expected: unknown;\n    }> = [\n      { expression: 'countall({values})', lookupFieldKey: 'amountId', expected: 2 },\n      { expression: 'counta({values})', lookupFieldKey: 'labelId', expected: 2 },\n      { expression: 'count({values})', lookupFieldKey: 'amountId', expected: 2 },\n      { expression: 'sum({values})', lookupFieldKey: 'amountId', expected: 30 },\n      { expression: 'average({values})', lookupFieldKey: 'amountId', expected: 15 },\n      { expression: 'max({values})', lookupFieldKey: 'amountId', expected: 20 },\n      { expression: 'min({values})', lookupFieldKey: 'amountId', expected: 10 },\n      { expression: 'and({values})', lookupFieldKey: 'flagId', expected: isForceV2 ? false : true },\n      { expression: 'or({values})', lookupFieldKey: 'flagId', expected: true },\n      { expression: 'xor({values})', lookupFieldKey: 'flagId', expected: true },\n      { expression: 'array_join({values})', lookupFieldKey: 'labelId', expected: 'Alpha, Beta' },\n      {\n        expression: 'array_unique({values})',\n        lookupFieldKey: 'labelId',\n        expected: ['Alpha', 'Beta'],\n      },\n      {\n        expression: 'array_compact({values})',\n        lookupFieldKey: 'labelId',\n        expected: ['Alpha', 'Beta'],\n      },\n      { expression: 'concatenate({values})', lookupFieldKey: 'labelId', expected: 'Alpha, Beta' },\n    ];\n\n    it.each(rollupCases)(\n      'should compute rollup using %s',\n      async ({ expression, lookupFieldKey, expected }) => {\n        let fixtures: Awaited<ReturnType<typeof setupRollupFixtures>> | undefined;\n        try {\n          fixtures = await setupRollupFixtures();\n          const { foreign, host, linkField, hostRecordId } = fixtures;\n          const lookupFieldId = fixtures[lookupFieldKey];\n\n          const field = await createField(host.id, {\n            name: `rollup ${expression}`,\n            type: FieldType.Rollup,\n            options: { expression },\n            lookupOptions: {\n              foreignTableId: foreign.id,\n              linkFieldId: linkField.id,\n              lookupFieldId,\n            } as ILookupOptionsRo,\n          } as IFieldRo);\n\n          const record = await getRecord(host.id, hostRecordId);\n          const value = record.fields[field.id];\n\n          if (Array.isArray(expected)) {\n            expect(Array.isArray(value)).toBe(true);\n            const sortedExpected = [...expected].sort();\n            const sortedValue = [...(value as unknown[])].sort();\n            expect(sortedValue).toEqual(sortedExpected);\n          } else if (typeof expected === 'string') {\n            if (expected.includes(', ')) {\n              expect((value as string).split(', ').sort()).toEqual(expected.split(', ').sort());\n            } else {\n              expect(value).toEqual(expected);\n            }\n          } else {\n            expect(value).toEqual(expected);\n          }\n        } finally {\n          if (fixtures?.host) {\n            await permanentDeleteTable(baseId, fixtures.host.id);\n          }\n          if (fixtures?.foreign) {\n            await permanentDeleteTable(baseId, fixtures.foreign.id);\n          }\n        }\n      }\n    );\n  });\n\n  it('should create rollup fields with array join, unique, and compact expressions', async () => {\n    const textField = getFieldByType(table2.fields, FieldType.SingleLineText);\n    const linkFieldId = getFieldByType(table1.fields, FieldType.Link).id;\n\n    // Link all foreign records to a host record for evaluation\n    await updateRecordField(table1.id, table1.records[1].id, linkFieldId, [\n      { id: table2.records[0].id },\n      { id: table2.records[1].id },\n      { id: table2.records[2].id },\n    ]);\n\n    // Populate duplicate values to verify join & unique behaviours\n    await updateRecordField(table2.id, table2.records[0].id, textField.id, 'Alpha');\n    await updateRecordField(table2.id, table2.records[1].id, textField.id, 'Alpha');\n    await updateRecordField(table2.id, table2.records[2].id, textField.id, 'Beta');\n\n    const arrayJoinRollup = await rollupFrom(table1, textField.id, 'array_join({values})');\n    const arrayUniqueRollup = await rollupFrom(table1, textField.id, 'array_unique({values})');\n\n    let record = await getRecord(table1.id, table1.records[1].id);\n    const joinedValues = (record.fields[arrayJoinRollup.id] as string).split(', ').sort();\n    expect(joinedValues).toEqual(['Alpha', 'Alpha', 'Beta'].sort());\n    const uniqueValues = [...(record.fields[arrayUniqueRollup.id] as string[])].sort();\n    expect(uniqueValues).toEqual(['Alpha', 'Beta']);\n\n    // Update values to include blanks and verify compact removes empty entries\n    await updateRecordField(table2.id, table2.records[0].id, textField.id, 'Gamma');\n    await updateRecordField(table2.id, table2.records[1].id, textField.id, '');\n    await updateRecordField(table2.id, table2.records[2].id, textField.id, null);\n\n    const arrayCompactRollup = await rollupFrom(table1, textField.id, 'array_compact({values})');\n    record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[arrayCompactRollup.id]).toEqual(['Gamma']);\n  });\n\n  it('should roll up a flat array  multiple select field -> one - many rollup field', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.MultipleSelect);\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id, 'countall({values})');\n    // update a field that will be lookup by after field\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, ['rap', 'rock']);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, ['rap', 'hiphop']);\n\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual(4);\n  });\n\n  it('should update one - many rollupField by replace a linkRecord from cell', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    const rollupFieldVo = await rollupFrom(table1, lookedUpToField.id, 'sum({values})');\n\n    // update a field that will be lookup by after field\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, 123);\n    await updateRecordField(table2.id, table2.records[2].id, lookedUpToField.id, 456);\n\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }]\n    );\n\n    const record = await getRecord(table1.id, table1.records[1].id);\n    expect(record.fields[rollupFieldVo.id]).toEqual(123);\n\n    // replace a link record\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[2].id }]\n    );\n\n    const recordAfter1 = await getRecord(table1.id, table1.records[1].id);\n    expect(recordAfter1.fields[rollupFieldVo.id]).toEqual(456);\n  });\n\n  it('should calculate when add a rollup field', async () => {\n    const textField = getFieldByType(table1.fields, FieldType.SingleLineText);\n\n    await updateRecordField(table1.id, table1.records[0].id, textField.id, 'A1');\n    await updateRecordField(table1.id, table1.records[1].id, textField.id, 'A2');\n    await updateRecordField(table1.id, table1.records[2].id, textField.id, 'A3');\n\n    const lookedUpToField = getFieldByType(table1.fields, FieldType.SingleLineText);\n\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n\n    const rollupFieldVo = await rollupFrom(table2, lookedUpToField.id);\n    const record0 = await getRecord(table2.id, table2.records[0].id);\n    expect(record0.fields[rollupFieldVo.id]).toEqual(0);\n    const record1 = await getRecord(table2.id, table2.records[1].id);\n    expect(record1.fields[rollupFieldVo.id]).toEqual(1);\n    const record2 = await getRecord(table2.id, table2.records[2].id);\n    expect(record2.fields[rollupFieldVo.id]).toEqual(1);\n  });\n\n  it('should rollup a number field in  one - many relationship', async () => {\n    const lookedUpToField = getFieldByType(table2.fields, FieldType.Number);\n    await updateRecordField(table2.id, table2.records[1].id, lookedUpToField.id, null);\n    // add a link record after\n    await updateRecordField(\n      table1.id,\n      table1.records[1].id,\n      getFieldByType(table1.fields, FieldType.Link).id,\n      [{ id: table2.records[1].id }, { id: table2.records[2].id }]\n    );\n\n    await rollupFrom(table1, lookedUpToField.id, 'count({values})');\n    // update a field that will be lookup by after field\n    const lookedUpToField2 = getFieldByType(table2.fields, FieldType.SingleLineText);\n\n    await rollupFrom(table1, lookedUpToField2.id, 'count({values})');\n  });\n\n  describe('rollup targeting conditional computed fields', () => {\n    let leaf: ITableFullVo;\n    let middle: ITableFullVo;\n    let root: ITableFullVo;\n    let activeScoreConditionalRollup: IFieldVo;\n    let activeItemConditionalLookup: IFieldVo;\n    let rootLinkFieldId: string;\n\n    beforeAll(async () => {\n      leaf = await createTable(baseId, {\n        name: 'RollupConditional_Leaf',\n        fields: [\n          { name: 'Item', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Category', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Score', type: FieldType.Number } as IFieldRo,\n          { name: 'Status', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Item: 'Alpha', Category: 'Hardware', Score: 60, Status: 'Active' } },\n          { fields: { Item: 'Beta', Category: 'Hardware', Score: 40, Status: 'Inactive' } },\n          { fields: { Item: 'Gamma', Category: 'Software', Score: 80, Status: 'Active' } },\n        ],\n      });\n\n      const leafItemId = leaf.fields.find((field) => field.name === 'Item')!.id;\n      const leafCategoryId = leaf.fields.find((field) => field.name === 'Category')!.id;\n      const leafScoreId = leaf.fields.find((field) => field.name === 'Score')!.id;\n      const leafStatusId = leaf.fields.find((field) => field.name === 'Status')!.id;\n\n      middle = await createTable(baseId, {\n        name: 'RollupConditional_Middle',\n        fields: [\n          { name: 'Summary', type: FieldType.SingleLineText } as IFieldRo,\n          { name: 'Target Category', type: FieldType.SingleLineText } as IFieldRo,\n        ],\n        records: [\n          { fields: { Summary: 'Hardware Overview', 'Target Category': 'Hardware' } },\n          { fields: { Summary: 'Software Overview', 'Target Category': 'Software' } },\n        ],\n      });\n      const targetCategoryFieldId = middle.fields.find(\n        (field) => field.name === 'Target Category'\n      )!.id;\n\n      const categoryMatchFilter: IFilter = {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: leafCategoryId,\n            operator: 'is',\n            value: { type: 'field', fieldId: targetCategoryFieldId },\n          },\n          {\n            fieldId: leafStatusId,\n            operator: 'is',\n            value: 'Active',\n          },\n        ],\n      } as any;\n\n      activeScoreConditionalRollup = await createField(middle.id, {\n        name: 'Active Category Score',\n        type: FieldType.ConditionalRollup,\n        options: {\n          foreignTableId: leaf.id,\n          lookupFieldId: leafScoreId,\n          expression: 'sum({values})',\n          filter: categoryMatchFilter,\n        },\n      } as IFieldRo);\n\n      activeItemConditionalLookup = await createField(middle.id, {\n        name: 'Active Item Names',\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        isConditionalLookup: true,\n        lookupOptions: {\n          foreignTableId: leaf.id,\n          lookupFieldId: leafItemId,\n          filter: categoryMatchFilter,\n        } as ILookupOptionsRo,\n      } as IFieldRo);\n\n      await updateTableFields(middle);\n      tables.push(middle);\n\n      root = await createTable(baseId, {\n        name: 'RollupConditional_Root',\n        fields: [{ name: 'Region', type: FieldType.SingleLineText } as IFieldRo],\n        records: [\n          { fields: { Region: 'North' } },\n          { fields: { Region: 'Global' } },\n          { fields: { Region: 'Unlinked' } },\n        ],\n      });\n\n      const rootLinkField = await createField(root.id, {\n        name: 'Middle Connection',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: middle.id,\n        },\n      });\n      rootLinkFieldId = rootLinkField.id;\n\n      await updateTableFields(root);\n      tables.push(root);\n\n      await updateRecordField(root.id, root.records[0].id, rootLinkFieldId, [\n        { id: middle.records[0].id },\n      ]);\n      await updateRecordField(root.id, root.records[1].id, rootLinkFieldId, [\n        { id: middle.records[0].id },\n        { id: middle.records[1].id },\n      ]);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, root.id);\n      await permanentDeleteTable(baseId, middle.id);\n      await permanentDeleteTable(baseId, leaf.id);\n    });\n\n    it('should roll up conditional rollup values across linked tables', async () => {\n      const hardwareSummary = await getRecord(middle.id, middle.records[0].id);\n      const softwareSummary = await getRecord(middle.id, middle.records[1].id);\n      expect(hardwareSummary.fields[activeScoreConditionalRollup.id]).toEqual(60);\n      expect(softwareSummary.fields[activeScoreConditionalRollup.id]).toEqual(80);\n\n      const rollupFieldVo = await rollupFrom(\n        root,\n        activeScoreConditionalRollup.id,\n        'sum({values})'\n      );\n\n      const north = await getRecord(root.id, root.records[0].id);\n      const global = await getRecord(root.id, root.records[1].id);\n      const unlinked = await getRecord(root.id, root.records[2].id);\n\n      expect(north.fields[rollupFieldVo.id]).toEqual(60);\n      expect(global.fields[rollupFieldVo.id]).toEqual(140);\n      expect(unlinked.fields[rollupFieldVo.id]).toEqual(0);\n    });\n\n    it('should aggregate conditional lookup chains with rollup fields', async () => {\n      const hardwareSummary = await getRecord(middle.id, middle.records[0].id);\n      const softwareSummary = await getRecord(middle.id, middle.records[1].id);\n      expect(hardwareSummary.fields[activeItemConditionalLookup.id]).toEqual(['Alpha']);\n      expect(softwareSummary.fields[activeItemConditionalLookup.id]).toEqual(['Gamma']);\n\n      const rollupFieldVo = await rollupFrom(\n        root,\n        activeItemConditionalLookup.id,\n        'countall({values})'\n      );\n\n      const north = await getRecord(root.id, root.records[0].id);\n      const global = await getRecord(root.id, root.records[1].id);\n      const unlinked = await getRecord(root.id, root.records[2].id);\n\n      expect(north.fields[rollupFieldVo.id]).toEqual(1);\n      expect(global.fields[rollupFieldVo.id]).toEqual(2);\n      expect(unlinked.fields[rollupFieldVo.id]).toEqual(0);\n    });\n\n    it('should concatenate conditional lookup values when rolled up', async () => {\n      const decodeRollupValue = (value: unknown) => {\n        if (value == null) return [];\n        if (Array.isArray(value)) return value;\n        if (typeof value === 'string') {\n          if (value === '') return [];\n          const tryParse = (input: string) => {\n            try {\n              return JSON.parse(input);\n            } catch {\n              return undefined;\n            }\n          };\n\n          const direct = tryParse(value);\n          if (direct !== undefined) return direct;\n\n          const parts = value.split('],').map((part) => {\n            const normalized = part.trim();\n            const withBracket = normalized.endsWith(']') ? normalized : `${normalized}]`;\n            const parsed = tryParse(withBracket);\n            return parsed ?? [normalized.replace(/^\\[|\"|'|\\]$/g, '')];\n          });\n          return parts.flat();\n        }\n        return value;\n      };\n\n      const rollupFieldVo = await rollupFrom(\n        root,\n        activeItemConditionalLookup.id,\n        'concatenate({values})'\n      );\n\n      const north = await getRecord(root.id, root.records[0].id);\n      const global = await getRecord(root.id, root.records[1].id);\n      const unlinked = await getRecord(root.id, root.records[2].id);\n\n      expect(decodeRollupValue(north.fields[rollupFieldVo.id])).toEqual(['Alpha']);\n      expect(decodeRollupValue(global.fields[rollupFieldVo.id])).toEqual(['Alpha', 'Gamma']);\n      expect(decodeRollupValue(unlinked.fields[rollupFieldVo.id])).toEqual([]);\n    });\n  });\n\n  describe('Rollup aggregation validation', () => {\n    it('keeps numeric aggregation valid for numeric sources', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RollupValidationForeign',\n        fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo],\n      });\n      const host = await createTable(baseId, {\n        name: 'RollupValidationHost',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n      });\n      const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n\n      try {\n        const linkField = await createField(host.id, {\n          name: 'Link to Foreign',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: foreign.id,\n          },\n        } as IFieldRo);\n\n        const rollupField = await createField(host.id, {\n          name: 'Sum Amount',\n          type: FieldType.Rollup,\n          options: {\n            expression: 'sum({values})',\n          },\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: amountFieldId,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const fetched = await getField(host.id, rollupField.id);\n        expect(fetched.hasError).toBeFalsy();\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n\n    it('marks rollup as errored when numeric source becomes text', async () => {\n      const foreign = await createTable(baseId, {\n        name: 'RollupValidationForeignConversion',\n        fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo],\n      });\n      const host = await createTable(baseId, {\n        name: 'RollupValidationHostConversion',\n        fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n      });\n      const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n\n      try {\n        const linkField = await createField(host.id, {\n          name: 'Link to Foreign',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.OneMany,\n            foreignTableId: foreign.id,\n          },\n        } as IFieldRo);\n\n        const rollupField = await createField(host.id, {\n          name: 'Sum Amount',\n          type: FieldType.Rollup,\n          options: {\n            expression: 'sum({values})',\n          },\n          lookupOptions: {\n            foreignTableId: foreign.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: amountFieldId,\n          } as ILookupOptionsRo,\n        } as IFieldRo);\n\n        const initial = await getField(host.id, rollupField.id);\n        expect(initial.hasError).toBeFalsy();\n\n        await convertField(foreign.id, amountFieldId, {\n          name: 'Amount',\n          type: FieldType.SingleLineText,\n          options: {},\n        } as IFieldRo);\n\n        const afterConvert = await getField(host.id, rollupField.id);\n        expect(afterConvert.hasError).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, host.id);\n        await permanentDeleteTable(baseId, foreign.id);\n      }\n    });\n  });\n\n  describe('Roll up corner case', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {});\n      table2 = await createTable(baseId, {});\n    });\n\n    it('should update multiple field when rollup  to sum a formula field', async () => {\n      const numberField = await createField(table1.id, {\n        type: FieldType.Number,\n      });\n\n      const formulaField = await createField(table1.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${numberField.id}}`,\n        },\n      });\n\n      const linkField = await createField(table2.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      });\n\n      const rollup1 = await createField(table2.id, {\n        name: `rollup 1`,\n        type: FieldType.Rollup,\n        options: {\n          expression: `sum({values})`,\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: formulaField.id,\n        } as ILookupOptionsRo,\n      });\n\n      const rollup2 = await createField(table2.id, {\n        name: `rollup 2`,\n        type: FieldType.Rollup,\n        options: {\n          expression: `sum({values})`,\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: formulaField.id,\n        } as ILookupOptionsRo,\n      });\n\n      await updateRecordField(table1.id, table1.records[0].id, numberField.id, 1);\n      await updateRecordField(table1.id, table1.records[1].id, numberField.id, 2);\n\n      // add a link record after\n      await updateRecordField(table2.id, table2.records[0].id, linkField.id, [\n        { id: table1.records[0].id },\n        { id: table1.records[1].id },\n      ]);\n\n      const record1 = await getRecord(table2.id, table2.records[0].id);\n\n      expect(record1.fields[rollup1.id]).toEqual(3);\n      expect(record1.fields[rollup2.id]).toEqual(3);\n\n      await updateRecordField(table1.id, table1.records[1].id, numberField.id, 3);\n\n      const record2 = await getRecord(table2.id, table2.records[0].id);\n      expect([record2.fields[rollup1.id], record2.fields[rollup2.id]]).toEqual([4, 4]);\n    });\n\n    it('should calculate rollup event has no link record', async () => {\n      const numberField = await createField(table1.id, {\n        type: FieldType.Number,\n      });\n\n      const linkField = await createField(table2.id, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table1.id,\n        },\n      });\n\n      const rollup1 = await createField(table2.id, {\n        name: `rollup 1`,\n        type: FieldType.Rollup,\n        options: {\n          expression: `sum({values})`,\n        },\n        lookupOptions: {\n          foreignTableId: table1.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: numberField.id,\n        } as ILookupOptionsRo,\n      });\n\n      const record1 = await getRecord(table2.id, table2.records[0].id);\n      expect(record1.fields[rollup1.id]).toEqual(0);\n    });\n  });\n\n  describe('v2 update field hasError propagation', () => {\n    const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n    const itV2Only = isForceV2 ? it : it.skip;\n\n    itV2Only(\n      'marks rollup as errored when foreign lookup field type becomes incompatible via v2 convert',\n      async () => {\n        const foreign = await createTable(baseId, {\n          name: 'V2RollupHasError_Foreign',\n          fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo],\n        });\n        const host = await createTable(baseId, {\n          name: 'V2RollupHasError_Host',\n          fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],\n        });\n        const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id;\n\n        try {\n          const linkField = await createField(host.id, {\n            name: 'Link to Foreign',\n            type: FieldType.Link,\n            options: {\n              relationship: Relationship.OneMany,\n              foreignTableId: foreign.id,\n            },\n          } as IFieldRo);\n\n          const rollupField = await createField(host.id, {\n            name: 'Sum Amount',\n            type: FieldType.Rollup,\n            options: {\n              expression: 'sum({values})',\n            },\n            lookupOptions: {\n              foreignTableId: foreign.id,\n              linkFieldId: linkField.id,\n              lookupFieldId: amountFieldId,\n            } as ILookupOptionsRo,\n          } as IFieldRo);\n\n          expect((await getField(host.id, rollupField.id)).hasError).toBeFalsy();\n\n          // Convert the foreign lookup field to an incompatible type via v2\n          await convertField(foreign.id, amountFieldId, {\n            name: 'Amount',\n            type: FieldType.SingleLineText,\n            options: {},\n          } as IFieldRo);\n\n          const afterConvert = await getField(host.id, rollupField.id);\n          expect(afterConvert.hasError).toBe(true);\n        } finally {\n          await permanentDeleteTable(baseId, host.id);\n          await permanentDeleteTable(baseId, foreign.id);\n        }\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/scheduled-computing.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { getRecords } from '@teable/openapi';\nimport {\n  initApp,\n  createTable,\n  createField,\n  deleteField,\n  convertField,\n  permanentDeleteTable,\n} from './utils/init-app';\nimport { seeding } from './utils/record-mock';\n\ndescribe('Test Scheduled Computing', () => {\n  let app: INestApplication;\n  let table: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, { name: 'table1', records: [] });\n    // await seeding(table.id, 3);\n    await seeding(table.id, 10_000);\n  }, 100_000);\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, table.id);\n    console.log('clear table: ', table.id);\n  });\n\n  it('should create/modify/delete formula field with 10000 rows scheduled', async () => {\n    const formulaFieldRo = {\n      name: 'formula',\n      type: FieldType.Formula,\n      options: {\n        expression: `{${table.fields[0].id}} & (1 + 1)`,\n      },\n    };\n\n    const formulaField = await createField(table.id, formulaFieldRo);\n    const result = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      skip: 0,\n      take: 10,\n    });\n    expect(result.data.records[1].fields[formulaField.id]).toBeTruthy();\n\n    const newFormulaFieldRo = {\n      type: FieldType.Formula,\n      options: {\n        expression: `2 + 2`,\n      },\n    };\n    const newFormulaField = await convertField(table.id, formulaField.id, newFormulaFieldRo);\n    const newResult = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      skip: 0,\n      take: 10,\n    });\n    expect(newResult.data.records[1].fields[newFormulaField.id]).toEqual(4);\n\n    await deleteField(table.id, formulaField.id);\n  }, 1_000_000);\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/select-formula-numeric-coercion.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  createTable,\n  getField,\n  getRecord,\n  initApp,\n  permanentDeleteTable,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('Select formula numeric coercion (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('coerces numeric strings when evaluating select formulas', async () => {\n    const seedFields: IFieldRo[] = [\n      {\n        name: 'Planned Duration',\n        type: FieldType.SingleLineText,\n      },\n      {\n        name: 'Consumed Days',\n        type: FieldType.SingleLineText,\n      },\n    ];\n\n    const table: ITableFullVo = await createTable(baseId, {\n      name: 'select_numeric_coercion',\n      fields: seedFields,\n      records: [\n        {\n          fields: {\n            'Planned Duration': '10天',\n            'Consumed Days': '3',\n          },\n        },\n      ],\n    });\n\n    try {\n      const fieldMap = new Map<string, IFieldVo>(table.fields.map((field) => [field.name, field]));\n      const durationField = fieldMap.get('Planned Duration')!;\n      const consumedField = fieldMap.get('Consumed Days')!;\n\n      const remainingField = await createField(table.id, {\n        name: 'Remaining Days (runtime)',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${durationField.id}} - {${consumedField.id}}`,\n        },\n      });\n\n      const negativeField = await createField(table.id, {\n        name: 'Negative Consumed (runtime)',\n        type: FieldType.Formula,\n        options: {\n          expression: `-{${consumedField.id}}`,\n        },\n      });\n\n      const refreshedRemaining = await getField(table.id, remainingField.id);\n      const remainingMeta =\n        typeof refreshedRemaining.meta === 'string'\n          ? (JSON.parse(refreshedRemaining.meta) as { persistedAsGeneratedColumn?: boolean })\n          : (refreshedRemaining.meta as { persistedAsGeneratedColumn?: boolean } | undefined);\n      expect(remainingMeta?.persistedAsGeneratedColumn).not.toBe(true);\n\n      const recordId = table.records[0].id;\n\n      const initialRecord = await getRecord(table.id, recordId);\n      expect(initialRecord.fields[remainingField.id]).toBe(7);\n      expect(initialRecord.fields[negativeField.id]).toBe(-3);\n\n      await expect(\n        updateRecordByApi(table.id, recordId, consumedField.id, '4天')\n      ).resolves.toBeDefined();\n\n      const updatedRecord = await getRecord(table.id, recordId);\n      expect(updatedRecord.fields[remainingField.id]).toBe(6);\n      expect(updatedRecord.fields[negativeField.id]).toBe(-4);\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/selection.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport {\n  Colors,\n  FieldKeyType,\n  FieldType,\n  MultiNumberDisplayType,\n  Relationship,\n  Role,\n  SortFunc,\n  defaultNumberFormatting,\n} from '@teable/core';\nimport type { IFieldRo, IUserCellValue } from '@teable/core';\nimport type { IPasteRo, IPasteVo, ITableFullVo, IUserMeVo } from '@teable/openapi';\nimport {\n  RangeType,\n  IdReturnType,\n  CLEAR_URL,\n  DELETE_URL,\n  PASTE_URL,\n  X_CANARY_HEADER,\n  axios,\n  getIdsFromRanges as apiGetIdsFromRanges,\n  copy as apiCopy,\n  paste as apiPaste,\n  getFields,\n  deleteSelection,\n  clear,\n  updateViewFilter,\n  updateViewSort,\n  USER_ME,\n  UPDATE_USER_NAME,\n  createSpace,\n  createBase,\n  emailSpaceInvitation,\n  getRecords,\n  urlBuilder,\n} from '@teable/openapi';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport {\n  permanentDeleteBase,\n  createField,\n  getRecord,\n  initApp,\n  createTable,\n  permanentDeleteTable,\n  permanentDeleteSpace,\n  updateRecordByApi,\n} from './utils/init-app';\n\ndescribe('OpenAPI SelectionController (e2e)', () => {\n  let app: INestApplication;\n  let table: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, { name: 'table1' });\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const pasteWithCanary = async (tableId: string, pasteRo: IPasteRo, useV2: boolean) => {\n    return axios.patch<IPasteVo>(\n      urlBuilder(PASTE_URL, {\n        tableId,\n      }),\n      pasteRo,\n      {\n        headers: {\n          [X_CANARY_HEADER]: useV2 ? 'true' : 'false',\n        },\n      }\n    );\n  };\n\n  const clearWithCanary = async (\n    tableId: string,\n    clearRo: Parameters<typeof clear>[1],\n    useV2: boolean\n  ) => {\n    return axios.patch<null>(\n      urlBuilder(CLEAR_URL, {\n        tableId,\n      }),\n      clearRo,\n      {\n        headers: {\n          [X_CANARY_HEADER]: useV2 ? 'true' : 'false',\n        },\n      }\n    );\n  };\n\n  const deleteWithCanary = async (\n    tableId: string,\n    deleteRo: Parameters<typeof deleteSelection>[1],\n    useV2: boolean\n  ) => {\n    return axios.delete<{ ids: string[] }>(\n      urlBuilder(DELETE_URL, {\n        tableId,\n      }),\n      {\n        headers: {\n          [X_CANARY_HEADER]: useV2 ? 'true' : 'false',\n        },\n        params: {\n          ...deleteRo,\n          filter: JSON.stringify(deleteRo.filter),\n          orderBy: JSON.stringify(deleteRo.orderBy),\n          groupBy: JSON.stringify(deleteRo.groupBy),\n          ranges: JSON.stringify(deleteRo.ranges),\n          collapsedGroupIds: JSON.stringify(deleteRo.collapsedGroupIds),\n        },\n      }\n    );\n  };\n\n  describe('getIdsFromRanges', () => {\n    it('should return all ids for cell range ', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [\n            [0, 0],\n            [0, 0],\n          ],\n          returnType: IdReturnType.All,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(1);\n      expect(data.fieldIds).toHaveLength(1);\n    });\n\n    it('should return all ids for row range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 1]],\n          type: RangeType.Rows,\n          returnType: IdReturnType.All,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(2);\n      expect(data.fieldIds).toHaveLength(table.fields.length);\n    });\n\n    it('should return all ids for column range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 1]],\n          type: RangeType.Columns,\n          returnType: IdReturnType.All,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(table.records.length);\n      expect(data.fieldIds).toHaveLength(2);\n    });\n\n    it('should return record ids for cell range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [\n            [0, 0],\n            [0, 1],\n          ],\n          returnType: IdReturnType.RecordId,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(2);\n      expect(data.fieldIds).toBeUndefined();\n    });\n\n    it('should return record ids for row range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 1]],\n          type: RangeType.Rows,\n          returnType: IdReturnType.RecordId,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(2);\n      expect(data.fieldIds).toBeUndefined();\n    });\n\n    it('should return record ids for column range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 0]],\n          type: RangeType.Columns,\n          returnType: IdReturnType.RecordId,\n        })\n      ).data;\n\n      expect(data.recordIds).toHaveLength(table.records.length);\n      expect(data.fieldIds).toBeUndefined();\n    });\n\n    it('should return field ids for cell range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [\n            [0, 0],\n            [0, 1],\n          ],\n          returnType: IdReturnType.FieldId,\n        })\n      ).data;\n\n      expect(data.fieldIds).toHaveLength(1);\n      expect(data.recordIds).toBeUndefined();\n    });\n\n    it('should return field ids for row range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 1]],\n          type: RangeType.Rows,\n          returnType: IdReturnType.FieldId,\n        })\n      ).data;\n\n      expect(data.fieldIds).toHaveLength(table.fields.length);\n      expect(data.recordIds).toBeUndefined();\n    });\n\n    it('should return record ids for column range', async () => {\n      const viewId = table.views[0].id;\n\n      const data = (\n        await apiGetIdsFromRanges(table.id, {\n          viewId,\n          ranges: [[0, 0]],\n          type: RangeType.Columns,\n          returnType: IdReturnType.FieldId,\n        })\n      ).data;\n\n      expect(data.fieldIds).toHaveLength(1);\n      expect(data.recordIds).toBeUndefined();\n    });\n  });\n\n  describe('past link records', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let table3: ITableFullVo;\n    beforeEach(async () => {\n      // create tables\n      const textFieldRo: IFieldRo = {\n        name: 'text field',\n        type: FieldType.SingleLineText,\n      };\n\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table1_1' } },\n          { fields: { 'text field': 'table1_2' } },\n          { fields: { 'text field': 'table1_3' } },\n        ],\n      });\n\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table2_1' } },\n          { fields: { 'text field': 'table2_2' } },\n          { fields: { 'text field': 'table2_3' } },\n        ],\n      });\n\n      table3 = await createTable(baseId, {\n        name: 'table3',\n        fields: [textFieldRo],\n        records: [\n          { fields: { 'text field': 'table3' } },\n          { fields: { 'text field': 'table3' } },\n          { fields: { 'text field': 'table3' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should paste 2 manyOne link field in same time', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, table1LinkFieldRo);\n      const linkField2 = await createField(table1.id, table1LinkFieldRo);\n\n      await apiPaste(table1.id, {\n        viewId: table1.views[0].id,\n        content: 'table2_1\\ttable2_2',\n        ranges: [\n          [1, 0],\n          [1, 0],\n        ],\n      });\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record.fields[linkField1.id]).toEqual({\n        id: table2.records[0].id,\n        title: 'table2_1',\n      });\n      expect(record.fields[linkField2.id]).toEqual({\n        id: table2.records[1].id,\n        title: 'table2_2',\n      });\n    });\n\n    it('should paste 2 oneMany link field in same time', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, table1LinkFieldRo);\n      const linkField2 = await createField(table1.id, table1LinkFieldRo);\n\n      await apiPaste(table1.id, {\n        viewId: table1.views[0].id,\n        content: 'table2_1\\ttable2_2',\n        ranges: [\n          [1, 0],\n          [1, 0],\n        ],\n      });\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record.fields[linkField1.id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'table2_1',\n        },\n      ]);\n      expect(record.fields[linkField2.id]).toEqual([\n        {\n          id: table2.records[1].id,\n          title: 'table2_2',\n        },\n      ]);\n    });\n\n    it('should paste 2 oneMany link field with same value in same time', async () => {\n      // create link field\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table3.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, table1LinkFieldRo);\n      const linkField2 = await createField(table1.id, table1LinkFieldRo);\n\n      await apiPaste(table1.id, {\n        viewId: table1.views[0].id,\n        content: [[{ id: table3.records[0].id }, { id: table3.records[1].id }]],\n        ranges: [\n          [1, 0],\n          [1, 0],\n        ],\n        header: [linkField1, linkField2],\n      });\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record.fields[linkField1.id]).toEqual([\n        {\n          id: table3.records[0].id,\n          title: 'table3',\n        },\n      ]);\n      expect(record.fields[linkField2.id]).toEqual([\n        {\n          id: table3.records[1].id,\n          title: 'table3',\n        },\n      ]);\n    });\n\n    it('paste link field with same value', async () => {\n      const table1LinkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField1 = await createField(table1.id, table1LinkFieldRo);\n\n      await apiPaste(table1.id, {\n        viewId: table1.views[0].id,\n        content: [['table2_1']],\n        ranges: [\n          [1, 0],\n          [1, 0],\n        ],\n        header: [table1.fields[0]],\n      });\n\n      const record = await getRecord(table1.id, table1.records[0].id);\n\n      expect(record.fields[linkField1.id]).toEqual([\n        {\n          id: table2.records[0].id,\n          title: 'table2_1',\n        },\n      ]);\n    });\n  });\n\n  describe('api/table/:tableId/selection/clear (PATCH)', () => {\n    it('should clear a standalone column without touching other fields', async () => {\n      const clearTable = await createTable(baseId, {\n        name: 'clear-basic',\n        fields: [\n          {\n            name: 'Status',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Notes',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { Status: 'todo', Notes: 'keep-1' } },\n          { fields: { Status: 'doing', Notes: 'keep-2' } },\n        ],\n      });\n\n      try {\n        const viewId = clearTable.views[0].id;\n        const statusFieldId = clearTable.fields.find((f) => f.name === 'Status')!.id;\n        const notesFieldId = clearTable.fields.find((f) => f.name === 'Notes')!.id;\n\n        await clear(clearTable.id, {\n          viewId,\n          type: RangeType.Columns,\n          ranges: [[0, 0]],\n        });\n\n        const { data } = await getRecords(clearTable.id, {\n          viewId,\n          fieldKeyType: FieldKeyType.Id,\n        });\n\n        expect(data.records.map((record) => record.fields[statusFieldId] ?? null)).toEqual([\n          null,\n          null,\n        ]);\n        expect(data.records.map((record) => record.fields[notesFieldId])).toEqual([\n          'keep-1',\n          'keep-2',\n        ]);\n      } finally {\n        await permanentDeleteTable(baseId, clearTable.id);\n      }\n    });\n\n    it('should refresh formula and lookup dependents after clearing a column', async () => {\n      const companyTable = await createTable(baseId, {\n        name: 'companies-clear',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'City', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { Name: 'Alpha', City: 'Paris' } },\n          { fields: { Name: 'Beta', City: 'Berlin' } },\n        ],\n      });\n      const nameFieldId = companyTable.fields.find((f) => f.name === 'Name')!.id;\n      const cityFieldId = companyTable.fields.find((f) => f.name === 'City')!.id;\n\n      const nameFormulaField = await createField(companyTable.id, {\n        name: 'Name Tag',\n        type: FieldType.Formula,\n        options: {\n          expression: `IF({${nameFieldId}}, {${nameFieldId}}, \"empty\")`,\n        },\n      });\n      companyTable.fields.push(nameFormulaField);\n\n      const contactTable = await createTable(baseId, {\n        name: 'contacts-clear',\n        fields: [{ name: 'Person', type: FieldType.SingleLineText }],\n        records: [{ fields: { Person: 'Alice' } }, { fields: { Person: 'Bob' } }],\n      });\n      const personFieldId = contactTable.fields.find((f) => f.name === 'Person')!.id;\n\n      try {\n        const linkField = await createField(contactTable.id, {\n          name: 'Company',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: companyTable.id,\n          },\n        });\n        contactTable.fields.push(linkField);\n\n        const companyLookupField = await createField(contactTable.id, {\n          name: 'Company Name',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: companyTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: nameFieldId,\n          },\n        });\n        contactTable.fields.push(companyLookupField);\n\n        await updateRecordByApi(contactTable.id, contactTable.records[0].id, linkField.id, {\n          id: companyTable.records[0].id,\n        });\n        await updateRecordByApi(contactTable.id, contactTable.records[1].id, linkField.id, {\n          id: companyTable.records[1].id,\n        });\n\n        const companyViewId = companyTable.views[0].id;\n        await clear(companyTable.id, {\n          viewId: companyViewId,\n          type: RangeType.Columns,\n          ranges: [[0, 0]],\n        });\n\n        const companyRecords = await getRecords(companyTable.id, {\n          viewId: companyViewId,\n          fieldKeyType: FieldKeyType.Id,\n        });\n        expect(\n          companyRecords.data.records.map((record) => record.fields[nameFieldId] ?? null)\n        ).toEqual([null, null]);\n        expect(\n          companyRecords.data.records.map((record) => record.fields[nameFormulaField.id])\n        ).toEqual(['empty', 'empty']);\n        expect(companyRecords.data.records.map((record) => record.fields[cityFieldId])).toEqual([\n          'Paris',\n          'Berlin',\n        ]);\n\n        const contactViewId = contactTable.views[0].id;\n        const contactRecords = await getRecords(contactTable.id, {\n          viewId: contactViewId,\n          fieldKeyType: FieldKeyType.Id,\n        });\n        const lookupValues = contactRecords.data.records.map(\n          (record) => record.fields[companyLookupField.id] ?? null\n        );\n        expect(lookupValues).toEqual([null, null]);\n        expect(contactRecords.data.records.map((record) => record.fields[personFieldId])).toEqual([\n          'Alice',\n          'Bob',\n        ]);\n      } finally {\n        await permanentDeleteTable(baseId, contactTable.id);\n        await permanentDeleteTable(baseId, companyTable.id);\n      }\n    });\n\n    it.each(\n      isForceV2\n        ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n        : [\n            { label: 'v1', useV2: false, v2Header: 'false' },\n            { label: 'v2', useV2: true, v2Header: 'true' },\n          ]\n    )(\n      'should respect search hidden-row offsets in clear for $label',\n      async ({ useV2, v2Header }) => {\n        const clearTable = await createTable(baseId, {\n          name: `clear-search-${useV2 ? 'v2' : 'v1'}`,\n          fields: [{ name: 'Name', type: FieldType.SingleLineText }],\n          records: [\n            { fields: { Name: 'Alpha' } },\n            { fields: { Name: 'target-one' } },\n            { fields: { Name: 'Bravo' } },\n            { fields: { Name: 'target-two' } },\n            { fields: { Name: 'Charlie' } },\n          ],\n        });\n\n        try {\n          const viewId = clearTable.views[0].id;\n          const nameField = clearTable.fields.find((field) => field.name === 'Name')!;\n\n          const clearRes = await clearWithCanary(\n            clearTable.id,\n            {\n              viewId,\n              ranges: [\n                [0, 0],\n                [0, 1],\n              ],\n              search: ['target', '', true],\n            },\n            useV2\n          );\n\n          expect(clearRes.status).toBe(200);\n          expect(clearRes.headers['x-teable-v2']).toBe(v2Header);\n\n          const records = await getRecords(clearTable.id, {\n            viewId,\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          expect(records.data.records[0].fields[nameField.id]).toBe('Alpha');\n          expect(records.data.records[1].fields[nameField.id] ?? null).toBeNull();\n          expect(records.data.records[2].fields[nameField.id]).toBe('Bravo');\n          expect(records.data.records[3].fields[nameField.id] ?? null).toBeNull();\n          expect(records.data.records[4].fields[nameField.id]).toBe('Charlie');\n        } finally {\n          await permanentDeleteTable(baseId, clearTable.id);\n        }\n      }\n    );\n\n    it.each(\n      isForceV2\n        ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n        : [\n            { label: 'v1', useV2: false, v2Header: 'false' },\n            { label: 'v2', useV2: true, v2Header: 'true' },\n          ]\n    )(\n      'should clear correct row in $label when ignoreViewQuery+collapsed groups are provided',\n      async ({ useV2, v2Header }) => {\n        const clearTable = await createTable(baseId, {\n          name: `clear-ignore-range-${useV2 ? 'v2' : 'v1'}`,\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText },\n            {\n              name: 'Status',\n              type: FieldType.SingleSelect,\n              options: {\n                choices: [\n                  { name: 'GroupA', color: Colors.Blue },\n                  { name: 'GroupB', color: Colors.Green },\n                ],\n              },\n            },\n            { name: 'Marker', type: FieldType.SingleLineText },\n          ],\n          records: [\n            { fields: { Title: 'A-01', Status: 'GroupA', Marker: 'mA01' } },\n            { fields: { Title: 'A-02', Status: 'GroupA', Marker: 'mA02' } },\n            { fields: { Title: 'B-01', Status: 'GroupB', Marker: 'mB01' } },\n            { fields: { Title: 'B-02', Status: 'GroupB', Marker: 'mB02' } },\n          ],\n        });\n\n        try {\n          const viewId = clearTable.views[0].id;\n          const titleField = clearTable.fields.find((f) => f.name === 'Title')!;\n          const statusField = clearTable.fields.find((f) => f.name === 'Status')!;\n          const markerField = clearTable.fields.find((f) => f.name === 'Marker')!;\n\n          await updateViewSort(clearTable.id, viewId, {\n            sort: {\n              sortObjs: [{ fieldId: titleField.id, order: SortFunc.Desc }],\n              manualSort: false,\n            },\n          });\n          await updateViewFilter(clearTable.id, viewId, {\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: 'GroupA',\n                },\n              ],\n            },\n          });\n\n          const groupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const;\n          const orderBy = [{ fieldId: titleField.id, order: SortFunc.Asc }] as const;\n\n          const groupedResult = await getRecords(clearTable.id, {\n            viewId,\n            ignoreViewQuery: true,\n            groupBy: [...groupBy],\n            orderBy: [...orderBy],\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find(\n            (point) => point.type === 0 && 'id' in point\n          );\n          expect(firstGroupHeader).toBeDefined();\n          const collapsedGroupIds = [(firstGroupHeader as { id: string }).id];\n\n          const clearRes = await clearWithCanary(\n            clearTable.id,\n            {\n              viewId,\n              ignoreViewQuery: true,\n              ranges: [\n                [0, 0],\n                [0, 0],\n              ],\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: statusField.id,\n                    operator: 'isAnyOf',\n                    value: ['GroupA', 'GroupB'],\n                  },\n                ],\n              },\n              orderBy: [...orderBy],\n              groupBy: [...groupBy],\n              projection: [markerField.id, statusField.id, titleField.id],\n              collapsedGroupIds,\n            },\n            useV2\n          );\n          expect(clearRes.status).toBe(200);\n          expect(clearRes.headers['x-teable-v2']).toBe(v2Header);\n\n          const allRecords = await getRecords(clearTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          const b01 = allRecords.data.records.find(\n            (record) => record.fields[titleField.id] === 'B-01'\n          );\n          const a01 = allRecords.data.records.find(\n            (record) => record.fields[titleField.id] === 'A-01'\n          );\n\n          expect(b01?.fields[markerField.id] ?? null).toBeNull();\n          expect(a01?.fields[markerField.id]).toBe('mA01');\n        } finally {\n          await permanentDeleteTable(baseId, clearTable.id);\n        }\n      }\n    );\n  });\n\n  describe('past expand col formula', () => {\n    let table1: ITableFullVo;\n    const numberField = {\n      name: 'count',\n      type: FieldType.Number,\n      options: {\n        formatting: defaultNumberFormatting,\n        showAs: {\n          type: MultiNumberDisplayType.Bar,\n          color: Colors.Blue,\n          showValue: true,\n          maxValue: 100,\n        },\n      },\n    };\n    beforeEach(async () => {\n      // create tables\n      const fields: IFieldRo[] = [\n        {\n          name: 'name',\n          type: FieldType.SingleLineText,\n        },\n        numberField,\n      ];\n\n      table1 = await createTable(baseId, {\n        name: 'table1',\n        fields: fields,\n        records: [{ fields: { count: 1 } }, { fields: { count: 2 } }, { fields: { count: 3 } }],\n      });\n\n      const numberFieldId = table1.fields.find((f) => f.name === 'count')!.id;\n      const formulaField: IFieldRo = {\n        type: FieldType.Formula,\n        name: 'formula',\n        options: {\n          expression: `{${numberFieldId}}`,\n          formatting: numberField.options.formatting,\n          showAs: numberField.options.showAs,\n        },\n      };\n      await createField(table1.id, formulaField);\n      await createField(table1.id, {\n        type: FieldType.SingleLineText,\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n    });\n\n    it('should paste expand col formula', async () => {\n      const { content, header } = (\n        await apiCopy(table1.id, {\n          viewId: table1.views[0].id,\n          ranges: [\n            [1, 0],\n            [2, 3],\n          ],\n        })\n      ).data;\n      await apiPaste(table1.id, {\n        viewId: table1.views[0].id,\n        content,\n        header,\n        ranges: [\n          [3, 0],\n          [3, 0],\n        ],\n      });\n      const fields = (await getFields(table1.id, { viewId: table1.views[0].id })).data;\n      expect(fields[4].type).toEqual(numberField.type);\n      expect(fields[4].options).toEqual(numberField.options);\n    });\n  });\n\n  describe('paste computed numeric coercion regression (v2)', () => {\n    let table1: ITableFullVo;\n    let scoreFieldId: string;\n    let weightFieldId: string;\n    let weightedScoreFieldId: string;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, {\n        name: 'paste-numeric-coercion',\n        fields: [\n          {\n            name: 'Name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'Score',\n            type: FieldType.Number,\n            options: {\n              formatting: defaultNumberFormatting,\n            },\n          },\n          {\n            name: 'WeightText',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [{ fields: { Name: 'row-1', Score: 10, WeightText: '0.5' } }],\n      });\n\n      scoreFieldId = table1.fields.find((field) => field.name === 'Score')!.id;\n      weightFieldId = table1.fields.find((field) => field.name === 'WeightText')!.id;\n\n      const weightedScoreField = await createField(table1.id, {\n        name: 'WeightedScore',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${scoreFieldId}}*{${weightFieldId}}`,\n          formatting: defaultNumberFormatting,\n        },\n      });\n\n      weightedScoreFieldId = weightedScoreField.id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n    });\n\n    it('should recompute numeric formula without 500 when pasted text contains multiple numeric fragments in v2', async () => {\n      const viewId = table1.views[0].id;\n\n      const res = await pasteWithCanary(\n        table1.id,\n        {\n          viewId,\n          projection: [weightFieldId],\n          content: '0.4/0.6',\n          ranges: [\n            [0, 0],\n            [0, 0],\n          ],\n        },\n        true\n      );\n\n      expect(res.status).toBe(200);\n      expect(res.headers['x-teable-v2']).toBe('true');\n\n      const records = await getRecords(table1.id, {\n        viewId,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      expect(records.data.records[0].fields[weightFieldId]).toBe('0.4/0.6');\n      expect(records.data.records[0].fields[weightedScoreFieldId]).toBeCloseTo(4, 10);\n    });\n  });\n\n  describe('api/table/:tableId/selection/delete (DELETE)', () => {\n    let table: ITableFullVo;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'table2',\n        fields: [\n          {\n            name: 'name',\n            type: FieldType.SingleLineText,\n          },\n          {\n            name: 'number',\n            type: FieldType.Number,\n          },\n        ],\n        records: [\n          { fields: { name: 'test', number: 1 } },\n          { fields: { name: 'test2', number: 2 } },\n          { fields: { name: 'test', number: 1 } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should delete selected data', async () => {\n      const viewId = table.views[0].id;\n      const result = await deleteSelection(table.id, {\n        viewId,\n        type: RangeType.Rows,\n        ranges: [\n          [0, 0],\n          [2, 2],\n        ],\n      });\n      expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);\n    });\n\n    it('should delete selected data with filter', async () => {\n      const viewId = table.views[0].id;\n      const result = await deleteSelection(table.id, {\n        viewId,\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'test',\n              operator: 'is',\n            },\n          ],\n        },\n      });\n      expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);\n    });\n\n    it('should delete selected data with orderBy', async () => {\n      const viewId = table.views[0].id;\n      const result = await deleteSelection(table.id, {\n        viewId,\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n        orderBy: [\n          {\n            fieldId: table.fields[0].id,\n            order: SortFunc.Desc,\n          },\n        ],\n      });\n      expect(result.data.ids).toEqual([table.records[1].id, table.records[0].id]);\n    });\n\n    it('should delete selected data with view filter', async () => {\n      const viewId = table.views[0].id;\n      await updateViewFilter(table.id, viewId, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'test',\n              operator: 'is',\n            },\n          ],\n        },\n      });\n      const result = await deleteSelection(table.id, {\n        viewId,\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n      });\n      expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);\n    });\n\n    it.each(\n      isForceV2\n        ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n        : [\n            { label: 'v1', useV2: false, v2Header: 'false' },\n            { label: 'v2', useV2: true, v2Header: 'true' },\n          ]\n    )(\n      'should delete rows matched by hide-not-match search in $label even when matches are beyond base range',\n      async ({ useV2, v2Header }) => {\n        const searchTable = await createTable(baseId, {\n          name: `search-delete-${useV2 ? 'v2' : 'v1'}`,\n          fields: [\n            {\n              name: 'name',\n              type: FieldType.SingleLineText,\n            },\n          ],\n          records: [\n            { fields: { name: 'alpha' } },\n            { fields: { name: 'beta' } },\n            { fields: { name: 'gamma' } },\n            { fields: { name: 'target one' } },\n            { fields: { name: 'target two' } },\n          ],\n        });\n        try {\n          const viewId = searchTable.views[0].id;\n          const result = await deleteWithCanary(\n            searchTable.id,\n            {\n              viewId,\n              type: RangeType.Rows,\n              ranges: [[0, 1]],\n              search: ['target', searchTable.fields[0].id, true],\n            },\n            useV2\n          );\n\n          expect(result.status).toBe(200);\n          expect(result.headers['x-teable-v2']).toBe(v2Header);\n          expect(result.data.ids).toEqual([searchTable.records[3].id, searchTable.records[4].id]);\n        } finally {\n          await permanentDeleteTable(baseId, searchTable.id);\n        }\n      }\n    );\n\n    it('should delete selection when filter compares text field to lookup-backed formula', async () => {\n      await permanentDeleteTable(baseId, table.id);\n      table = await createTable(baseId, {\n        name: 'orders',\n        fields: [\n          {\n            name: 'Order Number',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { 'Order Number': 'ORD-001' } },\n          { fields: { 'Order Number': 'ORD-002' } },\n        ],\n      });\n\n      const detailTable = await createTable(baseId, {\n        name: 'order details',\n        fields: [\n          {\n            name: 'External Number',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { 'External Number': 'ORD-001' } },\n          { fields: { 'External Number': 'ORD-002' } },\n        ],\n      });\n\n      try {\n        const orderNumberField = table.fields.find((f) => f.name === 'Order Number')!;\n        const externalNumberField = detailTable.fields.find((f) => f.name === 'External Number')!;\n\n        const linkField = await createField(table.id, {\n          name: 'Detail Link',\n          type: FieldType.Link,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: detailTable.id,\n          },\n        });\n\n        const lookupField = await createField(table.id, {\n          name: 'External Number Lookup',\n          type: FieldType.SingleLineText,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: detailTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: externalNumberField.id,\n          },\n        });\n\n        const formulaField = await createField(table.id, {\n          name: 'Match Flag',\n          type: FieldType.Formula,\n          options: {\n            expression: `IF({${orderNumberField.id}} = {${lookupField.id}}, \"match\", \"not-match\")`,\n          },\n        });\n\n        await updateRecordByApi(table.id, table.records[0].id, linkField.id, {\n          id: detailTable.records[0].id,\n        });\n\n        const record = await getRecord(table.id, table.records[0].id);\n        expect(record.fields[formulaField.id]).toBe('match');\n\n        const viewId = table.views[0].id;\n        const result = await deleteSelection(table.id, {\n          viewId,\n          ranges: [\n            [0, 0],\n            [0, 0],\n          ],\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: formulaField.id,\n                value: 'match',\n                operator: 'is',\n              },\n            ],\n          },\n        });\n\n        expect(result.status).toBe(200);\n        expect(Array.isArray(result.data.ids)).toBe(true);\n      } finally {\n        await permanentDeleteTable(baseId, detailTable.id);\n      }\n    });\n\n    it.each(\n      isForceV2\n        ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n        : [\n            { label: 'v1', useV2: false, v2Header: 'false' },\n            { label: 'v2', useV2: true, v2Header: 'true' },\n          ]\n    )(\n      'should delete correct row in $label when ignoreViewQuery+collapsed groups are provided',\n      async ({ useV2, v2Header }) => {\n        const deleteTable = await createTable(baseId, {\n          name: `delete-ignore-range-${useV2 ? 'v2' : 'v1'}`,\n          fields: [\n            { name: 'Title', type: FieldType.SingleLineText },\n            {\n              name: 'Status',\n              type: FieldType.SingleSelect,\n              options: {\n                choices: [\n                  { name: 'GroupA', color: Colors.Blue },\n                  { name: 'GroupB', color: Colors.Green },\n                ],\n              },\n            },\n          ],\n          records: [\n            { fields: { Title: 'A-01', Status: 'GroupA' } },\n            { fields: { Title: 'A-02', Status: 'GroupA' } },\n            { fields: { Title: 'B-01', Status: 'GroupB' } },\n            { fields: { Title: 'B-02', Status: 'GroupB' } },\n          ],\n        });\n\n        try {\n          const viewId = deleteTable.views[0].id;\n          const titleField = deleteTable.fields.find((f) => f.name === 'Title')!;\n          const statusField = deleteTable.fields.find((f) => f.name === 'Status')!;\n\n          await updateViewSort(deleteTable.id, viewId, {\n            sort: {\n              sortObjs: [{ fieldId: titleField.id, order: SortFunc.Desc }],\n              manualSort: false,\n            },\n          });\n          await updateViewFilter(deleteTable.id, viewId, {\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'is',\n                  value: 'GroupA',\n                },\n              ],\n            },\n          });\n\n          const groupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const;\n          const orderBy = [{ fieldId: titleField.id, order: SortFunc.Asc }] as const;\n\n          const groupedResult = await getRecords(deleteTable.id, {\n            viewId,\n            ignoreViewQuery: true,\n            groupBy: [...groupBy],\n            orderBy: [...orderBy],\n            fieldKeyType: FieldKeyType.Id,\n          });\n          const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find(\n            (point) => point.type === 0 && 'id' in point\n          );\n          expect(firstGroupHeader).toBeDefined();\n          const collapsedGroupIds = [(firstGroupHeader as { id: string }).id];\n\n          const deleteRes = await deleteWithCanary(\n            deleteTable.id,\n            {\n              viewId,\n              ignoreViewQuery: true,\n              ranges: [[0, 0]],\n              type: RangeType.Rows,\n              filter: {\n                conjunction: 'and',\n                filterSet: [\n                  {\n                    fieldId: statusField.id,\n                    operator: 'isAnyOf',\n                    value: ['GroupA', 'GroupB'],\n                  },\n                ],\n              },\n              orderBy: [...orderBy],\n              groupBy: [...groupBy],\n              collapsedGroupIds,\n            },\n            useV2\n          );\n          expect(deleteRes.status).toBe(200);\n          expect(deleteRes.headers['x-teable-v2']).toBe(v2Header);\n          expect(deleteRes.data.ids).toHaveLength(1);\n\n          const recordsAfter = await getRecords(deleteTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n          });\n\n          expect(\n            recordsAfter.data.records.some((record) => record.fields[titleField.id] === 'B-01')\n          ).toBe(false);\n          expect(\n            recordsAfter.data.records.some((record) => record.fields[titleField.id] === 'A-01')\n          ).toBe(true);\n        } finally {\n          await permanentDeleteTable(baseId, deleteTable.id);\n        }\n      }\n    );\n  });\n\n  describe('paste user', () => {\n    let spaceId: string;\n    let baseId: string;\n    let tableData: ITableFullVo;\n    let user1Info: IUserMeVo;\n    let user2Info: IUserMeVo;\n    beforeAll(async () => {\n      spaceId = await createSpace({\n        name: 'paste-same-name-user',\n      }).then((res) => res.data.id);\n      baseId = await createBase({\n        name: 'paste-same-name-user',\n        spaceId,\n      }).then((res) => res.data.id);\n\n      const user1 = await createNewUserAxios({\n        email: 'paste-same-name-user@test.com',\n        password: '12345678',\n      });\n      user1Info = await user1.get<IUserMeVo>(USER_ME).then((res) => res.data);\n      const user2 = await createNewUserAxios({\n        email: 'paste-same-name-user2@test.com',\n        password: '12345678',\n      });\n      await user2.patch(UPDATE_USER_NAME, {\n        name: 'paste-same-name-user',\n      });\n      user2Info = await user2.get<IUserMeVo>(USER_ME).then((res) => res.data);\n\n      await emailSpaceInvitation({\n        spaceId,\n        emailSpaceInvitationRo: {\n          emails: [user1Info.email, user2Info.email],\n          role: Role.Editor,\n        },\n      });\n    });\n\n    beforeEach(async () => {\n      tableData = await createTable(baseId, {\n        name: 'table3',\n        fields: [\n          { name: 'name', type: FieldType.SingleLineText },\n          { name: 'number', type: FieldType.Number },\n          { name: 'user', type: FieldType.User },\n        ],\n        records: [\n          {\n            fields: {\n              name: '1',\n              number: 1,\n              user: { id: user1Info.id, title: user1Info.name, email: user1Info.email },\n            },\n          },\n          {\n            fields: {\n              name: '2',\n              number: 2,\n              user: { id: user2Info.id, title: user2Info.name, email: user2Info.email },\n            },\n          },\n          {\n            fields: {\n              name: '3',\n              number: 1,\n            },\n          },\n          {\n            fields: {\n              name: '4',\n              number: 2,\n            },\n          },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableData.id);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteBase(baseId);\n      await permanentDeleteSpace(spaceId);\n    });\n\n    it('api/table/:tableId/selection/paste (POST) - exist same name user', async () => {\n      await apiPaste(tableData.id, {\n        viewId: tableData.defaultViewId!,\n        content: 'paste-same-name-user',\n        ranges: [\n          [2, 2],\n          [2, 2],\n        ],\n        header: [tableData.fields[0]],\n      });\n      const record = await getRecord(tableData.id, tableData.records[2].id);\n      expect((record.fields[tableData.fields[2].id] as IUserCellValue)?.title).toBe(\n        'paste-same-name-user'\n      );\n    });\n\n    it('api/table/:tableId/selection/paste (POST) - exist same name user with cell value', async () => {\n      await apiPaste(tableData.id, {\n        viewId: tableData.defaultViewId!,\n        content: [\n          [\n            {\n              id: user2Info.id,\n              title: user2Info.name,\n              email: user2Info.email,\n            },\n          ],\n          [\n            {\n              id: user1Info.id,\n              title: user1Info.name,\n              email: user1Info.email,\n            },\n          ],\n        ],\n        ranges: [\n          [2, 2],\n          [2, 2],\n        ],\n      });\n      const recordsData = await getRecords(tableData.id, {\n        viewId: tableData.defaultViewId!,\n        skip: 2,\n        take: 2,\n      }).then((res) => res.data);\n      expect(\n        recordsData.records.map((r) => (r.fields[tableData.fields[2].name] as IUserCellValue)?.id)\n      ).toEqual([user2Info.id, user1Info.id]);\n    });\n  });\n\n  it('paste content end with newline', async () => {\n    await apiPaste(table.id, {\n      viewId: table.defaultViewId!,\n      content: 'test\\ntest2',\n      ranges: [\n        [0, 0],\n        [0, 0],\n      ],\n    });\n    await apiPaste(table.id, {\n      viewId: table.defaultViewId!,\n      content: 'test3\\n',\n      ranges: [\n        [0, 0],\n        [0, 0],\n      ],\n    });\n    const records = await getRecords(table.id, {\n      viewId: table.defaultViewId!,\n    });\n    expect(records.data.records.map((r) => r.fields[table.fields[0].name])).toEqual([\n      'test3',\n      'test2',\n      undefined,\n    ]);\n  });\n\n  describe('paste with projection', () => {\n    let projectionTable: ITableFullVo;\n\n    beforeEach(async () => {\n      // Create a table with 4 fields: A, B, C, D\n      projectionTable = await createTable(baseId, {\n        name: 'projection-table',\n        fields: [\n          { name: 'Field A', type: FieldType.SingleLineText },\n          { name: 'Field B', type: FieldType.SingleLineText },\n          { name: 'Field C', type: FieldType.SingleLineText },\n          { name: 'Field D', type: FieldType.SingleLineText },\n        ],\n        records: [\n          { fields: { 'Field A': 'A1', 'Field B': 'B1', 'Field C': 'C1', 'Field D': 'D1' } },\n          { fields: { 'Field A': 'A2', 'Field B': 'B2', 'Field C': 'C2', 'Field D': 'D2' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, projectionTable.id);\n    });\n\n    it('should paste correctly when projection order is shuffled', async () => {\n      const fieldA = projectionTable.fields.find((f) => f.name === 'Field A')!;\n      const fieldB = projectionTable.fields.find((f) => f.name === 'Field B')!;\n      const fieldC = projectionTable.fields.find((f) => f.name === 'Field C')!;\n      const fieldD = projectionTable.fields.find((f) => f.name === 'Field D')!;\n\n      // Projection order is shuffled: D, B, A (skip C)\n      // Original order in table: A, B, C, D\n      const projection = [fieldD.id, fieldB.id, fieldA.id];\n\n      // Paste 3 columns of data: should map to D, B, A respectively\n      await apiPaste(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        content: 'NewD1\\tNewB1\\tNewA1',\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ],\n        projection,\n      });\n\n      const recordsData = await getRecords(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const firstRecord = recordsData.data.records[0];\n\n      // Verify: should update according to projection order\n      expect(firstRecord.fields[fieldA.id]).toBe('NewA1'); // projection column 3\n      expect(firstRecord.fields[fieldB.id]).toBe('NewB1'); // projection column 2\n      expect(firstRecord.fields[fieldC.id]).toBe('C1'); // not in projection, should remain unchanged\n      expect(firstRecord.fields[fieldD.id]).toBe('NewD1'); // projection column 1\n    });\n\n    it('should paste correctly when projection order is reversed', async () => {\n      const fieldA = projectionTable.fields.find((f) => f.name === 'Field A')!;\n      const fieldB = projectionTable.fields.find((f) => f.name === 'Field B')!;\n      const fieldC = projectionTable.fields.find((f) => f.name === 'Field C')!;\n      const fieldD = projectionTable.fields.find((f) => f.name === 'Field D')!;\n\n      // Projection completely reversed: D, C, B, A\n      const projection = [fieldD.id, fieldC.id, fieldB.id, fieldA.id];\n\n      // Paste 2x2 data\n      await apiPaste(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        content: 'NewD1\\tNewC1\\nNewD2\\tNewC2',\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n        projection,\n      });\n\n      const recordsData = await getRecords(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Verify first row: column 0 (index 0) maps to D, column 1 (index 1) maps to C\n      const firstRecord = recordsData.data.records[0];\n      expect(firstRecord.fields[fieldA.id]).toBe('A1'); // not in paste range, should remain unchanged\n      expect(firstRecord.fields[fieldB.id]).toBe('B1'); // not in paste range, should remain unchanged\n      expect(firstRecord.fields[fieldC.id]).toBe('NewC1');\n      expect(firstRecord.fields[fieldD.id]).toBe('NewD1');\n\n      // Verify second row\n      const secondRecord = recordsData.data.records[1];\n      expect(secondRecord.fields[fieldA.id]).toBe('A2');\n      expect(secondRecord.fields[fieldB.id]).toBe('B2');\n      expect(secondRecord.fields[fieldC.id]).toBe('NewC2');\n      expect(secondRecord.fields[fieldD.id]).toBe('NewD2');\n    });\n\n    it('should paste to correct field when using shuffled projection with column offset', async () => {\n      const fieldA = projectionTable.fields.find((f) => f.name === 'Field A')!;\n      const fieldB = projectionTable.fields.find((f) => f.name === 'Field B')!;\n      const fieldC = projectionTable.fields.find((f) => f.name === 'Field C')!;\n      const fieldD = projectionTable.fields.find((f) => f.name === 'Field D')!;\n\n      // Projection shuffled order: C, A, D\n      const projection = [fieldC.id, fieldA.id, fieldD.id];\n\n      // Paste to column index 1 (maps to Field A in projection)\n      await apiPaste(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        content: 'UpdatedA1',\n        ranges: [\n          [1, 0],\n          [1, 0],\n        ],\n        projection,\n      });\n\n      const recordsData = await getRecords(projectionTable.id, {\n        viewId: projectionTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const firstRecord = recordsData.data.records[0];\n\n      // Field A should be updated (projection index 1)\n      expect(firstRecord.fields[fieldA.id]).toBe('UpdatedA1');\n      // Other fields should remain unchanged\n      expect(firstRecord.fields[fieldB.id]).toBe('B1');\n      expect(firstRecord.fields[fieldC.id]).toBe('C1');\n      expect(firstRecord.fields[fieldD.id]).toBe('D1');\n    });\n  });\n\n  describe('paste with orderBy (view row order)', () => {\n    /**\n     * Critical test for ensuring paste operations target the correct rows\n     * when a view has custom sort order.\n     *\n     * Without the orderBy parameter, paste would use the default __auto_number order,\n     * causing updates to go to the wrong records.\n     */\n    let sortTable: ITableFullVo;\n\n    beforeEach(async () => {\n      // Create a table for sort tests with explicit records\n      // Creation order: A(100), B(200), C(300), D(400), E(500)\n      // Default order (by auto_number): A, B, C, D, E\n      // Descending by Value: E(500), D(400), C(300), B(200), A(100)\n      sortTable = await createTable(baseId, {\n        name: 'sort-paste-table',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Value', type: FieldType.Number },\n        ],\n        records: [\n          { fields: { Name: 'RecordA', Value: 100 } },\n          { fields: { Name: 'RecordB', Value: 200 } },\n          { fields: { Name: 'RecordC', Value: 300 } },\n          { fields: { Name: 'RecordD', Value: 400 } },\n          { fields: { Name: 'RecordE', Value: 500 } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, sortTable.id);\n    });\n\n    it('should paste to correct rows when orderBy is specified (descending)', async () => {\n      /**\n       * Test scenario:\n       * - Records in creation order: A(100), B(200), C(300), D(400), E(500)\n       * - View sorted by Value DESC: E(500), D(400), C(300), B(200), A(100)\n       * - Paste \"Updated\" to row 0 with orderBy=[{fieldId: valueFieldId, order: 'desc'}]\n       * - Should update E (first in DESC order), NOT A (first in creation order)\n       */\n      const nameField = sortTable.fields.find((f) => f.name === 'Name')!;\n      const valueField = sortTable.fields.find((f) => f.name === 'Value')!;\n\n      await apiPaste(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        content: 'SortTestUpdated',\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ],\n        orderBy: [{ fieldId: valueField.id, order: SortFunc.Desc }],\n      });\n\n      // Verify E was updated (not A)\n      const records = await getRecords(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500);\n      const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100);\n\n      expect(recordE?.fields[nameField.id]).toBe('SortTestUpdated');\n      expect(recordA?.fields[nameField.id]).toBe('RecordA'); // Should remain unchanged\n    });\n\n    it('should paste multiple rows in correct sort order', async () => {\n      /**\n       * Test scenario:\n       * - View sorted by Value DESC: E(500), D(400), C(300), B(200), A(100)\n       * - Paste to rows 1-3 with orderBy DESC\n       * - Should update D, C, B (rows 1-3 in DESC order)\n       */\n      const nameField = sortTable.fields.find((f) => f.name === 'Name')!;\n      const valueField = sortTable.fields.find((f) => f.name === 'Value')!;\n\n      await apiPaste(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        content: 'SortRow1\\nSortRow2\\nSortRow3',\n        ranges: [\n          [0, 1],\n          [0, 3],\n        ],\n        orderBy: [{ fieldId: valueField.id, order: SortFunc.Desc }],\n      });\n\n      // Verify D, C, B were updated in order\n      const records = await getRecords(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const recordD = records.data.records.find((r) => r.fields[valueField.id] === 400);\n      const recordC = records.data.records.find((r) => r.fields[valueField.id] === 300);\n      const recordB = records.data.records.find((r) => r.fields[valueField.id] === 200);\n      const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500);\n      const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100);\n\n      expect(recordD?.fields[nameField.id]).toBe('SortRow1'); // First in paste range (row 1 in DESC)\n      expect(recordC?.fields[nameField.id]).toBe('SortRow2'); // Second in paste range (row 2 in DESC)\n      expect(recordB?.fields[nameField.id]).toBe('SortRow3'); // Third in paste range (row 3 in DESC)\n      expect(recordE?.fields[nameField.id]).toBe('RecordE'); // Row 0, not in paste range\n      expect(recordA?.fields[nameField.id]).toBe('RecordA'); // Row 4, not in paste range\n    });\n\n    it('should paste to correct rows with ascending sort', async () => {\n      /**\n       * Test scenario:\n       * - View sorted by Value ASC: A(100), B(200), C(300), D(400), E(500)\n       * - This matches creation order, so row 0 should be A\n       * - Paste to row 0 with orderBy ASC\n       * - Should update A (first in ASC order)\n       */\n      const nameField = sortTable.fields.find((f) => f.name === 'Name')!;\n      const valueField = sortTable.fields.find((f) => f.name === 'Value')!;\n\n      await apiPaste(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        content: 'AscTestUpdated',\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ],\n        orderBy: [{ fieldId: valueField.id, order: SortFunc.Asc }],\n      });\n\n      const records = await getRecords(sortTable.id, {\n        viewId: sortTable.views[0].id,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100);\n      const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500);\n\n      expect(recordA?.fields[nameField.id]).toBe('AscTestUpdated');\n      expect(recordE?.fields[nameField.id]).toBe('RecordE'); // Should remain unchanged\n    });\n  });\n\n  describe('paste with view-level sort and filter (no client orderBy)', () => {\n    /**\n     * Regression test: when the view has a saved sort/filter but the client\n     * does NOT send orderBy/filter in the paste request, the paste should\n     * still target the correct rows using the view's saved configuration.\n     *\n     * This tests the v1-to-v2 adapter path where the adapter passes\n     * sort:undefined to v2 core, which should then fall back to view defaults.\n     */\n    let viewSortTable: ITableFullVo;\n\n    beforeEach(async () => {\n      viewSortTable = await createTable(baseId, {\n        name: 'view-sort-paste-table',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          { name: 'Value', type: FieldType.Number },\n        ],\n        records: [\n          { fields: { Name: 'RecordA', Value: 100 } },\n          { fields: { Name: 'RecordB', Value: 200 } },\n          { fields: { Name: 'RecordC', Value: 300 } },\n          { fields: { Name: 'RecordD', Value: 400 } },\n          { fields: { Name: 'RecordE', Value: 500 } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, viewSortTable.id);\n    });\n\n    it('should paste to correct row when view has sort+filter and client omits orderBy', async () => {\n      const nameField = viewSortTable.fields.find((f) => f.name === 'Name')!;\n      const valueField = viewSortTable.fields.find((f) => f.name === 'Value')!;\n      const viewId = viewSortTable.views[0].id;\n\n      // Set view-level sort: Value DESC\n      await updateViewSort(viewSortTable.id, viewId, {\n        sort: {\n          sortObjs: [{ fieldId: valueField.id, order: SortFunc.Desc }],\n          manualSort: false,\n        },\n      });\n\n      // Set view-level filter: Value >= 200 (filters out RecordA=100)\n      await updateViewFilter(viewSortTable.id, viewId, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: valueField.id,\n              value: 200,\n              operator: 'isGreaterEqual',\n            },\n          ],\n        },\n      });\n\n      // Paste at row 0 WITHOUT orderBy — rely on view defaults\n      // Filtered DESC order: E(500), D(400), C(300), B(200)\n      // Row 0 should be E(500)\n      await apiPaste(viewSortTable.id, {\n        viewId,\n        content: 'ViewSortUpdated',\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ],\n        // No orderBy or filter — the view's saved sort/filter should be used\n      });\n\n      // Query WITHOUT viewId to see all records (including those filtered out by view)\n      const records = await getRecords(viewSortTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const recordE = records.data.records.find((r) => r.fields[valueField.id] === 500);\n      const recordA = records.data.records.find((r) => r.fields[valueField.id] === 100);\n\n      // E should be updated (first in DESC among filtered)\n      expect(recordE?.fields[nameField.id]).toBe('ViewSortUpdated');\n      // A should remain unchanged (filtered out by the view)\n      expect(recordA?.fields[nameField.id]).toBe('RecordA');\n    });\n\n    it('should paste to correct middle row when view has sort and client omits orderBy', async () => {\n      const nameField = viewSortTable.fields.find((f) => f.name === 'Name')!;\n      const valueField = viewSortTable.fields.find((f) => f.name === 'Value')!;\n      const viewId = viewSortTable.views[0].id;\n\n      // Set view-level sort: Value DESC (no filter this time)\n      await updateViewSort(viewSortTable.id, viewId, {\n        sort: {\n          sortObjs: [{ fieldId: valueField.id, order: SortFunc.Desc }],\n          manualSort: false,\n        },\n      });\n\n      // Paste at row 2 WITHOUT orderBy — rely on view sort\n      // DESC order: E(500), D(400), C(300), B(200), A(100)\n      // Row 2 should be C(300)\n      await apiPaste(viewSortTable.id, {\n        viewId,\n        content: 'ViewSortMiddle',\n        ranges: [\n          [0, 2],\n          [0, 2],\n        ],\n        // No orderBy — the view's saved sort should be used\n      });\n\n      const records = await getRecords(viewSortTable.id, {\n        viewId,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      const recordC = records.data.records.find((r) => r.fields[valueField.id] === 300);\n\n      // C should be updated (row 2 in DESC order)\n      expect(recordC?.fields[nameField.id]).toBe('ViewSortMiddle');\n    });\n  });\n\n  describe('paste with isNoneOf filter and NULL values (production regression)', () => {\n    /**\n     * Regression test for the production bug where paste targets the wrong record.\n     *\n     * Production scenario:\n     * - A SingleSelect \"Status\" field with choices [\"Open\", \"InProgress\", \"Closed\"]\n     * - Some records have Status = NULL (not set)\n     * - View filter: Status isNoneOf [\"Closed\"]\n     * - View sort: Name ASC\n     *\n     * v1 behavior: `COALESCE(Status, '') NOT IN ('Closed')` — NULL records are INCLUDED\n     * v2 bug:      `Status NOT IN ('Closed')` — NULL records are EXCLUDED\n     *               (because NULL NOT IN (...) returns NULL which is falsy)\n     *\n     * The different filtered sets cause row offsets to shift, making paste hit the wrong record.\n     */\n    let filterTable: ITableFullVo;\n\n    beforeEach(async () => {\n      filterTable = await createTable(baseId, {\n        name: 'isNoneOf-filter-paste-table',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'Open', color: Colors.Blue },\n                { name: 'InProgress', color: Colors.Yellow },\n                { name: 'Closed', color: Colors.Red },\n              ],\n            },\n          },\n        ],\n        records: [\n          { fields: { Name: 'Alpha', Status: 'Open' } },\n          { fields: { Name: 'Bravo', Status: null } }, // NULL status — must be included by isNoneOf\n          { fields: { Name: 'Charlie', Status: 'InProgress' } },\n          { fields: { Name: 'Delta', Status: null } }, // NULL status — must be included by isNoneOf\n          { fields: { Name: 'Echo', Status: 'Closed' } }, // This should be excluded by filter\n          { fields: { Name: 'Foxtrot', Status: 'Open' } },\n        ],\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, filterTable.id);\n    });\n\n    it('should include NULL records in isNoneOf filter and paste to correct row', async () => {\n      const nameField = filterTable.fields.find((f) => f.name === 'Name')!;\n      const statusField = filterTable.fields.find((f) => f.name === 'Status')!;\n      const viewId = filterTable.views[0].id;\n\n      // Set view-level sort: Name ASC\n      await updateViewSort(filterTable.id, viewId, {\n        sort: {\n          sortObjs: [{ fieldId: nameField.id, order: SortFunc.Asc }],\n          manualSort: false,\n        },\n      });\n\n      // Set view-level filter: Status isNoneOf [\"Closed\"]\n      await updateViewFilter(filterTable.id, viewId, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusField.id,\n              value: ['Closed'],\n              operator: 'isNoneOf',\n            },\n          ],\n        },\n      });\n\n      // Verify the filtered+sorted order first\n      const beforeRecords = await getRecords(filterTable.id, {\n        viewId,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Expected ASC order after filtering out \"Closed\" (Echo):\n      // Row 0: Alpha (Open)\n      // Row 1: Bravo (NULL) — v1 includes NULL in isNoneOf\n      // Row 2: Charlie (InProgress)\n      // Row 3: Delta (NULL) — v1 includes NULL in isNoneOf\n      // Row 4: Foxtrot (Open)\n      expect(beforeRecords.data.records).toHaveLength(5); // 6 - 1 (Closed)\n      expect(beforeRecords.data.records[0].fields[nameField.id]).toBe('Alpha');\n      expect(beforeRecords.data.records[1].fields[nameField.id]).toBe('Bravo');\n      expect(beforeRecords.data.records[2].fields[nameField.id]).toBe('Charlie');\n      expect(beforeRecords.data.records[3].fields[nameField.id]).toBe('Delta');\n      expect(beforeRecords.data.records[4].fields[nameField.id]).toBe('Foxtrot');\n\n      // Paste at row 3 (Delta, a NULL-status record) WITHOUT client orderBy\n      // This is the critical test: if isNoneOf excludes NULLs, the row indices shift\n      // and we would incorrectly target a different record\n      await apiPaste(filterTable.id, {\n        viewId,\n        content: 'PastedToDelta',\n        ranges: [\n          [0, 3],\n          [0, 3],\n        ],\n        // No orderBy or filter — rely on view defaults\n      });\n\n      // Re-fetch records without viewId to see all records including filtered ones\n      const afterRecords = await getRecords(filterTable.id, {\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Find all records to check which one was actually updated\n      const updatedRecord = afterRecords.data.records.find(\n        (r) => r.fields[nameField.id] === 'PastedToDelta'\n      );\n\n      // Verify Delta was the one updated (not some other record)\n      expect(updatedRecord).toBeDefined();\n      // The updated record should have NULL status (was Delta)\n      expect(updatedRecord?.fields[statusField.id]).toBeUndefined();\n\n      // Echo (Closed) should remain unchanged — it was filtered out\n      const echo = afterRecords.data.records.find((r) => r.fields[statusField.id] === 'Closed');\n      expect(echo?.fields[nameField.id]).toBe('Echo');\n\n      // Alpha should remain unchanged\n      const alpha = afterRecords.data.records.find(\n        (r) => r.fields[statusField.id] === 'Open' && r.fields[nameField.id] !== 'PastedToDelta'\n      );\n      expect(alpha).toBeDefined();\n    });\n\n    it('should paste to first NULL row correctly with isNoneOf filter', async () => {\n      const nameField = filterTable.fields.find((f) => f.name === 'Name')!;\n      const statusField = filterTable.fields.find((f) => f.name === 'Status')!;\n      const viewId = filterTable.views[0].id;\n\n      // Set view-level sort: Name ASC\n      await updateViewSort(filterTable.id, viewId, {\n        sort: {\n          sortObjs: [{ fieldId: nameField.id, order: SortFunc.Asc }],\n          manualSort: false,\n        },\n      });\n\n      // Set view-level filter: Status isNoneOf [\"Closed\"]\n      await updateViewFilter(filterTable.id, viewId, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: statusField.id,\n              value: ['Closed'],\n              operator: 'isNoneOf',\n            },\n          ],\n        },\n      });\n\n      // Paste at row 1 (Bravo, first NULL-status record)\n      await apiPaste(filterTable.id, {\n        viewId,\n        content: 'PastedToBravo',\n        ranges: [\n          [0, 1],\n          [0, 1],\n        ],\n      });\n\n      const afterRecords = await getRecords(filterTable.id, {\n        viewId,\n        fieldKeyType: FieldKeyType.Id,\n      });\n\n      // Row 1 in the filtered ASC order should be Bravo (NULL status)\n      // After paste, Bravo's Name should be updated\n      // Note: since the Name changed, re-sort may change order\n      // But we can verify by checking what was at row 1 got updated\n      const updatedRecord = afterRecords.data.records.find(\n        (r) => r.fields[nameField.id] === 'PastedToBravo'\n      );\n      expect(updatedRecord).toBeDefined();\n      // The updated record should have NULL status (was Bravo)\n      expect(updatedRecord?.fields[statusField.id]).toBeUndefined();\n    });\n  });\n\n  describe('paste with ignoreViewQuery and collapsed groups (v1/v2)', () => {\n    let groupedTable: ITableFullVo;\n\n    beforeEach(async () => {\n      groupedTable = await createTable(baseId, {\n        name: 'ignore-view-query-paste-table',\n        fields: [\n          { name: 'Name', type: FieldType.SingleLineText },\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [\n                { name: 'GroupA', color: Colors.Blue },\n                { name: 'GroupB', color: Colors.Green },\n              ],\n            },\n          },\n        ],\n        records: [\n          { fields: { Name: 'A-01', Status: 'GroupA' } },\n          { fields: { Name: 'A-02', Status: 'GroupA' } },\n          { fields: { Name: 'A-03', Status: 'GroupA' } },\n          { fields: { Name: 'A-04', Status: 'GroupA' } },\n          { fields: { Name: 'A-05', Status: 'GroupA' } },\n          { fields: { Name: 'B-01', Status: 'GroupB' } },\n          { fields: { Name: 'B-02', Status: 'GroupB' } },\n          { fields: { Name: 'B-03', Status: 'GroupB' } },\n          { fields: { Name: 'B-04', Status: 'GroupB' } },\n          { fields: { Name: 'B-05', Status: 'GroupB' } },\n        ],\n      });\n    });\n\n    describe('paste with search hideNotMatchRow (v1/v2)', () => {\n      let searchTable: ITableFullVo;\n\n      beforeEach(async () => {\n        searchTable = await createTable(baseId, {\n          name: 'search-hide-not-match-paste-table',\n          fields: [\n            { name: 'Name', type: FieldType.SingleLineText },\n            { name: 'Count', type: FieldType.Number },\n            { name: 'Notes', type: FieldType.LongText },\n          ],\n          records: [\n            { fields: { Name: 'Alpha', Count: 10 } },\n            { fields: { Name: 'target-one', Count: 20 } },\n            { fields: { Name: 'Bravo', Count: 30 } },\n            { fields: { Name: 'target-two', Count: 40 } },\n            { fields: { Name: 'Charlie', Count: 50 } },\n          ],\n        });\n      });\n\n      afterEach(async () => {\n        await permanentDeleteTable(baseId, searchTable.id);\n      });\n\n      it.each(\n        isForceV2\n          ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n          : [\n              { label: 'v1', useV2: false, v2Header: 'false' },\n              { label: 'v2', useV2: true, v2Header: 'true' },\n            ]\n      )('should respect search hidden-row offsets in $label', async ({ useV2, v2Header }) => {\n        const nameField = searchTable.fields.find((field) => field.name === 'Name')!;\n        const viewId = searchTable.views[0].id;\n\n        const res = await pasteWithCanary(\n          searchTable.id,\n          {\n            viewId,\n            content: 'SearchBridge1\\nSearchBridge2',\n            ranges: [\n              [0, 0],\n              [0, 1],\n            ],\n            search: ['target', '', true],\n          },\n          useV2\n        );\n\n        expect(res.status).toBe(200);\n        expect(res.headers['x-teable-v2']).toBe(v2Header);\n\n        const records = await getRecords(searchTable.id, {\n          viewId,\n          fieldKeyType: FieldKeyType.Id,\n        });\n\n        expect(records.data.records[0].fields[nameField.id]).toBe('Alpha');\n        expect(records.data.records[1].fields[nameField.id]).toBe('SearchBridge1');\n        expect(records.data.records[2].fields[nameField.id]).toBe('Bravo');\n        expect(records.data.records[3].fields[nameField.id]).toBe('SearchBridge2');\n        expect(records.data.records[4].fields[nameField.id]).toBe('Charlie');\n      });\n\n      it.each(\n        isForceV2\n          ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n          : [\n              { label: 'v1', useV2: false, v2Header: 'false' },\n              { label: 'v2', useV2: true, v2Header: 'true' },\n            ]\n      )(\n        'should paste to the second physical row in $label when it is also the second visible search hit',\n        async ({ useV2, v2Header }) => {\n          const adjacentTable = await createTable(baseId, {\n            name: `search-adjacent-visible-hit-paste-${Date.now()}`,\n            fields: [\n              { name: 'Name', type: FieldType.SingleLineText },\n              { name: 'Count', type: FieldType.Number },\n              { name: 'Notes', type: FieldType.LongText },\n            ],\n            records: [\n              { fields: { Name: '1', Count: 0 } },\n              { fields: { Name: '', Count: 1 } },\n              { fields: { Name: 'skip-me', Count: 0 } },\n            ],\n          });\n\n          try {\n            const nameField = adjacentTable.fields.find((field) => field.name === 'Name')!;\n            const viewId = adjacentTable.views[0].id;\n\n            const res = await pasteWithCanary(\n              adjacentTable.id,\n              {\n                viewId,\n                content: 'VisibleSecondRow',\n                ranges: [\n                  [0, 1],\n                  [0, 1],\n                ],\n                search: ['1', '', true],\n              },\n              useV2\n            );\n\n            expect(res.status).toBe(200);\n            expect(res.headers['x-teable-v2']).toBe(v2Header);\n\n            const records = await getRecords(adjacentTable.id, {\n              viewId,\n              fieldKeyType: FieldKeyType.Id,\n            });\n\n            expect(records.data.records[0].fields[nameField.id]).toBe('1');\n            expect(records.data.records[1].fields[nameField.id]).toBe('VisibleSecondRow');\n            expect(records.data.records[2].fields[nameField.id]).toBe('skip-me');\n          } finally {\n            await permanentDeleteTable(baseId, adjacentTable.id);\n          }\n        }\n      );\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, groupedTable.id);\n    });\n\n    it.each(\n      isForceV2\n        ? [{ label: 'v2-forced', useV2: true, v2Header: 'true' }]\n        : [\n            { label: 'v1', useV2: false, v2Header: 'false' },\n            { label: 'v2', useV2: true, v2Header: 'true' },\n          ]\n    )(\n      'should target the correct row in $label when client query overrides view defaults',\n      async ({ useV2, v2Header }) => {\n        const nameField = groupedTable.fields.find((f) => f.name === 'Name')!;\n        const statusField = groupedTable.fields.find((f) => f.name === 'Status')!;\n        const viewId = groupedTable.views[0].id;\n\n        // Deliberately keep a conflicting view default sort; request sort must win when ignoreViewQuery=true.\n        await updateViewSort(groupedTable.id, viewId, {\n          sort: {\n            sortObjs: [{ fieldId: nameField.id, order: SortFunc.Desc }],\n            manualSort: false,\n          },\n        });\n        await updateViewFilter(groupedTable.id, viewId, {\n          filter: {\n            conjunction: 'and',\n            filterSet: [\n              {\n                fieldId: statusField.id,\n                operator: 'is',\n                value: 'GroupA',\n              },\n            ],\n          },\n        });\n\n        const groupBy = [{ fieldId: statusField.id, order: SortFunc.Asc }] as const;\n        const orderBy = [{ fieldId: nameField.id, order: SortFunc.Asc }] as const;\n\n        const groupedResult = await getRecords(groupedTable.id, {\n          viewId,\n          ignoreViewQuery: true,\n          groupBy: [...groupBy],\n          orderBy: [...orderBy],\n          fieldKeyType: FieldKeyType.Id,\n        });\n\n        const firstGroupHeader = groupedResult.data.extra?.groupPoints?.find(\n          (point) => point.type === 0 && 'id' in point\n        );\n        expect(firstGroupHeader).toBeDefined();\n\n        const collapsedGroupIds = [(firstGroupHeader as { id: string }).id];\n\n        const pasteRes = await pasteWithCanary(\n          groupedTable.id,\n          {\n            viewId,\n            ignoreViewQuery: true,\n            ranges: [\n              [0, 0],\n              [0, 0],\n            ],\n            content: 'Pasted-Target',\n            filter: {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: statusField.id,\n                  operator: 'isAnyOf',\n                  value: ['GroupA', 'GroupB'],\n                },\n              ],\n            },\n            orderBy: [...orderBy],\n            groupBy: [...groupBy],\n            projection: [nameField.id, statusField.id],\n            collapsedGroupIds,\n          },\n          useV2\n        );\n        expect(pasteRes.status).toBe(200);\n        expect(pasteRes.headers['x-teable-v2']).toBe(v2Header);\n\n        const allRecords = await getRecords(groupedTable.id, {\n          fieldKeyType: FieldKeyType.Id,\n        });\n\n        expect(allRecords.data.records).toHaveLength(10);\n\n        const updated = allRecords.data.records.find((record) => {\n          return record.fields[nameField.id] === 'Pasted-Target';\n        });\n        expect(updated).toBeDefined();\n        expect(updated?.fields[statusField.id]).toBe('GroupB');\n\n        // If collapsed groups are ignored, GroupA rows are usually targeted first.\n        expect(\n          allRecords.data.records.some((record) => record.fields[nameField.id] === 'A-01')\n        ).toBe(true);\n        expect(\n          allRecords.data.records.some((record) => record.fields[nameField.id] === 'B-01')\n        ).toBe(false);\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/set-column-meta.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo, IFormColumnMeta, IGridColumnMeta } from '@teable/core';\nimport { StatisticsFunc, ViewType } from '@teable/core';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { sortBy } from 'lodash';\nimport {\n  initApp,\n  updateViewColumnMeta,\n  getFields,\n  getView,\n  createTable,\n  permanentDeleteTable,\n} from './utils/init-app';\n\nlet app: INestApplication;\n\nconst baseId = globalThis.testConfig.baseId;\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta (PUT) update order', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table1' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test update order and field should return by order`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, columnMeta]) => ({\n      fieldId,\n      columnMeta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          order: 10,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const updatedOrder = updatedView.columnMeta[fieldColumnMetas[0].fieldId].order;\n\n    const fields: IFieldVo[] = await getFields(tableId, viewId);\n\n    const sortedFields = sortBy(fields, (field) => {\n      return updatedView.columnMeta[field.id].order;\n    }).map((field) => field.id);\n\n    const fieldIds = fields.map((field) => field.id);\n    expect(updatedOrder).toBe(10);\n    expect(sortedFields).toEqual(fieldIds);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update hidden', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table2' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test update hidden`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[1].fieldId,\n        columnMeta: {\n          hidden: true,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const fieldVisible = (updatedView.columnMeta as IGridColumnMeta)[fieldColumnMetas[1].fieldId]\n      .hidden;\n\n    expect(fieldVisible).toBe(true);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) should not hidden primary field for grid view`, async () => {\n    const { fields } = tableMeta;\n    const primaryFieldId = fields.find((field) => field.isPrimary)?.id;\n    const fieldColumnMetas = [\n      {\n        fieldId: primaryFieldId as string,\n        columnMeta: {\n          hidden: true,\n        },\n      },\n    ];\n    await expect(updateViewColumnMeta(tableId, viewId, fieldColumnMetas)).rejects.toMatchObject({\n      status: 400,\n    });\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update width', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table3' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test update width`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          width: 200,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const fieldVisible = (updatedView.columnMeta as IGridColumnMeta)[fieldColumnMetas[0].fieldId]\n      .width;\n    expect(fieldVisible).toBe(200);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update statisticFunc', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table4' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test update statisticFunc`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          statisticFunc: StatisticsFunc.Empty,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const fieldStatisticFunc = (updatedView.columnMeta as IGridColumnMeta)[\n      fieldColumnMetas[0].fieldId\n    ].statisticFunc;\n    expect(fieldStatisticFunc).toBe(StatisticsFunc.Empty);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update required for the form view', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, {\n      name: 'table5',\n      views: [\n        {\n          name: 'Form view',\n          type: ViewType.Form,\n          columnMeta: {},\n        },\n      ],\n    });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test required`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          required: true,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const fieldRequired = (updatedView.columnMeta as IFormColumnMeta)[fieldColumnMetas[0].fieldId]\n      .required;\n    expect(fieldRequired).toBe(true);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update visible for the form view', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, {\n      name: 'Test table for form',\n      views: [\n        {\n          name: 'Form view',\n          type: ViewType.Form,\n          columnMeta: {},\n        },\n      ],\n    });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test visible`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          visible: true,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const fieldVisible = (updatedView.columnMeta as IFormColumnMeta)[fieldColumnMetas[0].fieldId]\n      .visible;\n    expect(fieldVisible).toBe(true);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) update multiple single', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table6' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test update should not cover`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          order: 7,\n        },\n      },\n    ]);\n\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          required: true,\n        },\n      },\n    ]);\n\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[0].fieldId,\n        columnMeta: {\n          width: 100,\n        },\n      },\n    ]);\n\n    const assertData = {\n      required: true,\n      width: 100,\n      order: 7,\n    };\n\n    const updatedView = await getView(tableId, viewId);\n    const fieldColumnMeta = updatedView.columnMeta[fieldColumnMetas[0].fieldId];\n    expect(fieldColumnMeta).toEqual(assertData);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) multiple update', () => {\n  let tableId: string;\n  let viewId: string;\n  let tableMeta: ITableFullVo;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table7' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    tableMeta = result;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test multiple data`, async () => {\n    const { views } = tableMeta;\n    const { columnMeta } = views[0];\n    const fieldColumnMetas = Object.entries(columnMeta!).map(([fieldId, meta]) => ({\n      fieldId: fieldId,\n      meta: meta,\n    }));\n    const assertData = {\n      width: 200,\n      statisticFunc: StatisticsFunc.Empty,\n      hidden: true,\n      order: 100,\n    };\n    await updateViewColumnMeta(tableId, viewId, [\n      {\n        fieldId: fieldColumnMetas[1].fieldId,\n        columnMeta: {\n          ...assertData,\n        },\n      },\n    ]);\n    const updatedView = await getView(tableId, viewId);\n    const updatedColumnMeta = updatedView.columnMeta[fieldColumnMetas[1].fieldId];\n    expect(updatedColumnMeta).toEqual(assertData);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) columnMeta(PUT) params validate', () => {\n  let tableId: string;\n  let viewId: string;\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'table8' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n  });\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test(`/table/{tableId}/view/{viewId}/columnMeta (PUT) test validate fieldId legitimacy`, async () => {\n    const columnMeta = {\n      width: 200,\n    };\n\n    await expect(\n      updateViewColumnMeta(tableId, viewId, [{ fieldId: 'fakeFieldID', columnMeta }])\n    ).rejects.toMatchObject({\n      status: 400,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/share-socket.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { IdPrefix, ViewType } from '@teable/core';\nimport {\n  enableShareView as apiEnableShareView,\n  disableShareView as apiDisableShareView,\n} from '@teable/openapi';\nimport { map } from 'lodash';\nimport type { Connection, Doc } from 'sharedb/lib/client';\nimport { ShareDbService } from '../src/share-db/share-db.service';\nimport { getError } from './utils/get-error';\nimport { initApp, updateViewColumnMeta, createTable, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Share (socket-e2e) (e2e)', () => {\n  let app: INestApplication;\n  let tableId: string;\n  let shareId: string;\n  let viewId: string;\n  let port: string;\n  const baseId = globalThis.testConfig.baseId;\n  const defaultTimeout = 2000;\n  const timeoutErrorMessage = 'connection timeout';\n  let fieldIds: string[] = [];\n  let shareDbService!: ShareDbService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    port = process.env.PORT!;\n    shareDbService = app.get(ShareDbService);\n\n    const table = await createTable(baseId, {\n      name: 'table1',\n      views: [\n        {\n          type: ViewType.Grid,\n          name: 'view1',\n        },\n        {\n          type: ViewType.Form,\n          name: 'view2',\n        },\n      ],\n    });\n    tableId = table.id;\n    viewId = table.defaultViewId!;\n    const shareResult = await apiEnableShareView({ tableId, viewId });\n    fieldIds = map(table.fields, 'id');\n    // hidden last one field\n    const field = table.fields[fieldIds.length - 1];\n    await updateViewColumnMeta(tableId, viewId, [\n      { fieldId: field.id, columnMeta: { hidden: true } },\n    ]);\n    shareId = shareResult.data.shareId;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n\n    await app.close();\n  });\n\n  const createConnection = (shareId: string): Connection => {\n    return shareDbService.connect(undefined, {\n      url: `ws://localhost:${port}/socket?shareId=${shareId}`,\n      headers: {},\n    });\n  };\n\n  const getQuery = (collection: string, shareId: string, timeout = defaultTimeout) => {\n    return new Promise<Doc<any>[]>((resolve, reject) => {\n      const connection = createConnection(shareId);\n      const cleanup = () => {\n        connection.removeAllListeners('error');\n        connection.agent?.stream.removeAllListeners('error');\n      };\n\n      connection.createFetchQuery(collection, {}, {}, (err, result) => {\n        cleanup();\n        if (err) return reject(err);\n        resolve(result);\n      });\n\n      connection.on('error', (err) => {\n        cleanup();\n        reject(err);\n      });\n\n      connection.agent?.stream.on('error', (err) => {\n        cleanup();\n        reject(err);\n      });\n\n      shareDbService.once('error', (err) => {\n        cleanup();\n        reject(err);\n      });\n\n      setTimeout(() => {\n        cleanup();\n        reject(new Error(timeoutErrorMessage));\n      }, timeout);\n    });\n  };\n\n  const getDocument = (\n    collection: string,\n    docId: string,\n    shareId: string,\n    timeout = defaultTimeout\n  ) => {\n    return new Promise<Doc<any>>((resolve, reject) => {\n      const connection = createConnection(shareId);\n      const cleanup = () => {\n        connection.removeAllListeners('error');\n        connection.agent?.stream.removeAllListeners('error');\n      };\n\n      const doc = connection.get(collection, docId);\n      doc.fetch((err) => {\n        cleanup();\n        if (err) return reject(err);\n        resolve(doc);\n      });\n\n      connection.on('error', (err) => {\n        cleanup();\n        reject(err);\n      });\n\n      setTimeout(() => {\n        cleanup();\n        reject(new Error(timeoutErrorMessage));\n      }, timeout);\n    });\n  };\n\n  describe('Field queries', () => {\n    it('should retrieve fields other than those that are hidden', async () => {\n      const collection = `${IdPrefix.Field}_${tableId}`;\n      const fields = await getQuery(collection, shareId);\n      expect(fields.length).toEqual(fieldIds.length - 1);\n    });\n\n    it('should not include hidden field in query results', async () => {\n      const hiddenFieldId = fieldIds[fieldIds.length - 1];\n      const collection = `${IdPrefix.Field}_${tableId}`;\n      const fields = await getQuery(collection, shareId);\n\n      const hiddenField = fields.find((f) => f.id === hiddenFieldId);\n      expect(hiddenField).toBeUndefined();\n    });\n  });\n\n  describe('View queries', () => {\n    it('should only get the shared view', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const views = await getQuery(collection, shareId);\n\n      expect(views.length).toEqual(1);\n      expect(views[0].id).toEqual(viewId);\n    });\n\n    it('should get view document by id', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const doc = await getDocument(collection, viewId, shareId);\n\n      expect(doc.data).toBeDefined();\n      expect(doc.id).toEqual(viewId);\n    });\n  });\n\n  describe('Record queries', () => {\n    it('should be able to query records from shared view', async () => {\n      const collection = `${IdPrefix.Record}_${tableId}`;\n      const records = await getQuery(collection, shareId);\n\n      // Records may be empty, but the query should succeed\n      expect(Array.isArray(records)).toBe(true);\n    });\n  });\n\n  describe('Error handling', () => {\n    it('should reject with validation error for invalid shareId', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const error = await getError(() => getQuery(collection, 'invalid-share-id'));\n      expect(error?.code).toEqual('validation_error');\n    });\n\n    it('should reject with error for malformed shareId', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n      const error = await getError(() => getQuery(collection, ''));\n      expect(error).toBeDefined();\n    });\n\n    it('should handle non-existent collection gracefully', async () => {\n      const collection = `${IdPrefix.Field}_non_existent_table`;\n      const error = await getError(() => getQuery(collection, shareId));\n      // Should either return empty results or throw an appropriate error\n      expect(error !== undefined || true).toBe(true);\n    });\n  });\n\n  describe('Connection lifecycle', () => {\n    it('should successfully create and use connection', async () => {\n      const connection = createConnection(shareId);\n      expect(connection).toBeDefined();\n      expect(connection.state).toBeDefined();\n    });\n\n    it('should handle multiple concurrent connections', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n\n      const queries = await Promise.all([\n        getQuery(collection, shareId),\n        getQuery(collection, shareId),\n        getQuery(collection, shareId),\n      ]);\n\n      expect(queries.length).toEqual(3);\n      queries.forEach((views) => {\n        expect(views.length).toEqual(1);\n        expect(views[0].id).toEqual(viewId);\n      });\n    });\n\n    it('should timeout if query takes too long', async () => {\n      const collection = `${IdPrefix.View}_${tableId}`;\n      // Use a very short timeout to trigger timeout error\n      const error = await getError(() => getQuery(collection, shareId, 1));\n      // Either succeeds very quickly or times out\n      expect(error === undefined || error?.message === timeoutErrorMessage).toBe(true);\n    });\n  });\n\n  describe('Share state changes', () => {\n    let tempTableId: string;\n    let tempViewId: string;\n    let tempShareId: string;\n\n    beforeAll(async () => {\n      const table = await createTable(baseId, {\n        name: 'temp-share-test-table',\n        views: [\n          {\n            type: ViewType.Grid,\n            name: 'temp-view',\n          },\n        ],\n      });\n      tempTableId = table.id;\n      tempViewId = table.defaultViewId!;\n      const shareResult = await apiEnableShareView({ tableId: tempTableId, viewId: tempViewId });\n      tempShareId = shareResult.data.shareId;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, tempTableId);\n    });\n\n    it('should reject queries after share is disabled', async () => {\n      // First verify share works\n      const collection = `${IdPrefix.View}_${tempTableId}`;\n      const views = await getQuery(collection, tempShareId);\n      expect(views.length).toEqual(1);\n\n      // Disable share\n      await apiDisableShareView({ tableId: tempTableId, viewId: tempViewId });\n\n      // Query should fail\n      const error = await getError(() => getQuery(collection, tempShareId));\n      expect(error).toBeDefined();\n\n      // Re-enable share for cleanup\n      const shareResult = await apiEnableShareView({ tableId: tempTableId, viewId: tempViewId });\n      tempShareId = shareResult.data.shareId;\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/share.e2e-spec.ts",
    "content": "import { type INestApplication } from '@nestjs/common';\nimport type {\n  IFieldRo,\n  IFilterRo,\n  ILinkFieldOptions,\n  IRecord,\n  IUserFieldOptions,\n  IViewRo,\n} from '@teable/core';\nimport {\n  ANONYMOUS_USER_ID,\n  FieldKeyType,\n  FieldType,\n  is,\n  Relationship,\n  SortFunc,\n  ViewType,\n} from '@teable/core';\nimport {\n  urlBuilder,\n  SHARE_VIEW_GET,\n  SHARE_VIEW_FORM_SUBMIT,\n  SHARE_VIEW_RECORDS,\n  createRecords as apiCreateRecords,\n  deleteRecords as apiDeleteRecords,\n  enableShareView as apiEnableShareView,\n  getShareViewLinkRecords as apiGetShareViewLinkRecords,\n  getShareViewCollaborators as apiGetShareViewCollaborators,\n  getShareViewRecords as apiGetShareViewRecords,\n  getBaseCollaboratorList as apiGetBaseCollaboratorList,\n  updateViewColumnMeta as apiUpdateViewColumnMeta,\n  updateViewShareMeta as apiUpdateViewShareMeta,\n  SHARE_VIEW_COPY,\n  SHARE_VIEW_AUTH,\n  getShareView,\n  createField,\n  updateViewShareMeta,\n  shareViewFormSubmit,\n  deleteView,\n  PrincipalType,\n  createBase,\n  getShareViewRowCount,\n} from '@teable/openapi';\nimport type { ITableFullVo, ShareViewAuthVo, ShareViewGetVo } from '@teable/openapi';\nimport { map } from 'lodash';\nimport { x_20 } from './data-helpers/20x';\nimport { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport {\n  createTable,\n  createView,\n  permanentDeleteTable,\n  initApp,\n  updateViewColumnMeta,\n  updateViewFilter,\n  getField,\n  deleteField,\n  convertField,\n  permanentDeleteBase,\n} from './utils/init-app';\n\nconst formViewRo: IViewRo = {\n  name: 'Form view',\n  description: 'the form view',\n  type: ViewType.Form,\n};\n\nconst gridViewRo: IViewRo = {\n  name: 'Grid view',\n  description: 'the grid view',\n  type: ViewType.Grid,\n};\n\ndescribe('OpenAPI ShareController (e2e)', () => {\n  let app: INestApplication;\n  let tableId: string;\n  let shareId: string;\n  let viewId: string;\n  let baseId: string;\n  const spaceId = globalThis.testConfig.spaceId;\n  const userId = globalThis.testConfig.userId;\n  const userName = globalThis.testConfig.userName;\n  const userEmail = globalThis.testConfig.email;\n  let fieldIds: string[] = [];\n  let anonymousUser: ReturnType<typeof createAnonymousUserAxios>;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    anonymousUser = createAnonymousUserAxios(appCtx.appUrl);\n    baseId = await createBase({\n      name: 'share-e2e',\n      spaceId,\n    }).then((res) => res.data.id);\n    const table = await createTable(baseId, { name: 'table1' });\n\n    tableId = table.id;\n    viewId = table.defaultViewId!;\n\n    const shareResult = await apiEnableShareView({ tableId, viewId });\n    fieldIds = map(table.fields, 'id');\n    // hidden last one field\n    const field = table.fields[fieldIds.length - 1];\n    await updateViewColumnMeta(tableId, viewId, [\n      { fieldId: field.id, columnMeta: { hidden: true } },\n    ]);\n    shareId = shareResult.data.shareId;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteBase(baseId);\n    await permanentDeleteTable(baseId, tableId);\n    await app.close();\n  });\n\n  describe('api/:shareId/view (GET)', async () => {\n    it('should return view', async () => {\n      const result = await anonymousUser.get<ShareViewGetVo>(\n        urlBuilder(SHARE_VIEW_GET, { shareId })\n      );\n      const shareViewData = result.data;\n      // filter hidden field\n      expect(shareViewData.fields.length).toEqual(fieldIds.length - 1);\n      expect(shareViewData.viewId).toEqual(viewId);\n    });\n\n    it('records return [] in not includeRecords', async () => {\n      const result = await createView(tableId, gridViewRo);\n      const viewId = result.id;\n      const shareResult = await apiEnableShareView({ tableId, viewId });\n      await updateViewShareMeta(tableId, viewId, { includeRecords: false });\n      const viewShareId = shareResult.data.shareId;\n      const resultData = await anonymousUser.get<ShareViewGetVo>(\n        urlBuilder(SHARE_VIEW_GET, { shareId: viewShareId })\n      );\n      expect(resultData.data.records).toEqual([]);\n    });\n\n    it('password in grid view', async () => {\n      const result = await createView(tableId, gridViewRo);\n      const gridViewId = result.id;\n      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });\n      const gridViewShareId = shareResult.data.shareId;\n      await apiUpdateViewShareMeta(tableId, gridViewId, { password: '123123123' });\n      const error = await getError(() =>\n        anonymousUser.get<ShareViewGetVo>(urlBuilder(SHARE_VIEW_GET, { shareId: gridViewShareId }))\n      );\n      expect(error?.status).toEqual(401);\n    });\n\n    it('password in grid view had auth', async () => {\n      const result = await createView(tableId, gridViewRo);\n      const gridViewId = result.id;\n      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });\n      const gridViewShareId = shareResult.data.shareId;\n      await apiUpdateViewShareMeta(tableId, gridViewId, { password: '123123123' });\n      const res = await anonymousUser.post<ShareViewAuthVo>(\n        urlBuilder(SHARE_VIEW_AUTH, { shareId: gridViewShareId }),\n        {\n          password: '123123123',\n        }\n      );\n      const resultData = await anonymousUser.get<ShareViewGetVo>(\n        urlBuilder(SHARE_VIEW_GET, { shareId: gridViewShareId }),\n        {\n          headers: {\n            cookie: res.headers['set-cookie'],\n          },\n        }\n      );\n      expect(resultData.data.viewId).toEqual(gridViewId);\n    });\n  });\n\n  describe('api/:shareId/view/form-submit (POST)', () => {\n    let formViewId: string;\n    let fromViewShareId: string;\n\n    beforeEach(async () => {\n      const result = await createView(tableId, formViewRo);\n      formViewId = result.id;\n\n      const shareResult = await apiEnableShareView({ tableId, viewId: formViewId });\n      fromViewShareId = shareResult.data.shareId;\n    });\n\n    it('submit form view', async () => {\n      const result = await anonymousUser.post(\n        urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }),\n        {\n          fields: {},\n        }\n      );\n      const record = result.data as IRecord;\n      expect(record.createdBy).toEqual(ANONYMOUS_USER_ID);\n    });\n\n    it('submit exclude form view', async () => {\n      const result = await createView(tableId, gridViewRo);\n      const gridViewId = result.id;\n      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });\n      const gridViewShareId = shareResult.data.shareId;\n      const error = await getError(() =>\n        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: gridViewShareId }), {\n          fields: {},\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('submit include hidden field', async () => {\n      const hiddenFieldId = fieldIds[fieldIds.length - 1];\n      await updateViewColumnMeta(tableId, formViewId, [\n        { fieldId: fieldIds[fieldIds.length - 1], columnMeta: { visible: false } },\n      ]);\n      const error = await getError(() =>\n        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }), {\n          fields: {\n            [hiddenFieldId]: null,\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n\n    it('required login', async () => {\n      await updateViewShareMeta(tableId, formViewId, {\n        submit: {\n          requireLogin: true,\n          allow: true,\n        },\n      });\n      const error = await getError(() =>\n        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }), {\n          fields: {},\n        })\n      );\n      expect(error?.status).toEqual(401);\n      const res = await shareViewFormSubmit({\n        shareId: fromViewShareId,\n        fields: {},\n      });\n      expect(res.status).toEqual(201);\n    });\n  });\n\n  describe('api/:shareId/view/records (GET)', () => {\n    let recordsTableId: string;\n    let recordsViewId: string;\n    let recordsShareId: string;\n    let primaryFieldId: string;\n    const primaryFieldName = 'Name';\n\n    beforeAll(async () => {\n      const table = await createTable(baseId, {\n        name: 'records-test-table',\n        fields: [\n          {\n            name: primaryFieldName,\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [\n          { fields: { [primaryFieldName]: 'Record 1' } },\n          { fields: { [primaryFieldName]: 'Record 2' } },\n          { fields: { [primaryFieldName]: 'Record 3' } },\n        ],\n      });\n      recordsTableId = table.id;\n      recordsViewId = table.defaultViewId!;\n      primaryFieldId = table.fields[0].id;\n\n      const shareResult = await apiEnableShareView({\n        tableId: recordsTableId,\n        viewId: recordsViewId,\n      });\n      recordsShareId = shareResult.data.shareId;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, recordsTableId);\n    });\n\n    it('should return records with pagination', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 2,\n        skip: 0,\n      });\n\n      expect(result.data.records.length).toEqual(2);\n    });\n\n    it('should return records with skip', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n        skip: 1,\n      });\n\n      expect(result.data.records.length).toEqual(2);\n    });\n\n    it('should return empty array when includeRecords is false', async () => {\n      await apiUpdateViewShareMeta(recordsTableId, recordsViewId, { includeRecords: false });\n\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n      });\n\n      expect(result.data.records).toEqual([]);\n\n      // Restore includeRecords\n      await apiUpdateViewShareMeta(recordsTableId, recordsViewId, { includeRecords: true });\n    });\n\n    it('should return records with projection', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n      });\n\n      expect(result.data.records.length).toEqual(3);\n      expect(result.data.records[0].fields).toHaveProperty(primaryFieldId);\n    });\n\n    it('should return records with filter', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: primaryFieldId,\n              operator: is.value,\n              value: 'Record 1',\n            },\n          ],\n        },\n      });\n\n      expect(result.data.records.length).toEqual(1);\n      expect(result.data.records[0].fields[primaryFieldId]).toEqual('Record 1');\n    });\n\n    it('should return records with orderBy', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n        orderBy: [{ fieldId: primaryFieldId, order: SortFunc.Desc }],\n      });\n\n      expect(result.data.records.length).toEqual(3);\n      expect(result.data.records[0].fields[primaryFieldId]).toEqual('Record 3');\n      expect(result.data.records[1].fields[primaryFieldId]).toEqual('Record 2');\n      expect(result.data.records[2].fields[primaryFieldId]).toEqual('Record 1');\n    });\n\n    it('should return records with groupBy', async () => {\n      const result = await apiGetShareViewRecords(recordsShareId, {\n        take: 10,\n        groupBy: [{ fieldId: primaryFieldId, order: SortFunc.Desc }],\n      });\n\n      expect(result.data.records.length).toEqual(3);\n      // groupBy with desc order should return records in descending order\n      expect(result.data.records[0].fields[primaryFieldId]).toEqual('Record 3');\n      expect(result.data.records[1].fields[primaryFieldId]).toEqual('Record 2');\n      expect(result.data.records[2].fields[primaryFieldId]).toEqual('Record 1');\n    });\n\n    it('should not allow anonymous access without share auth when password protected', async () => {\n      await apiUpdateViewShareMeta(recordsTableId, recordsViewId, { password: 'test123' });\n\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(SHARE_VIEW_RECORDS, { shareId: recordsShareId }), {\n          params: { take: 10 },\n        })\n      );\n\n      expect(error?.status).toEqual(401);\n\n      // Restore no password\n      await apiUpdateViewShareMeta(recordsTableId, recordsViewId, { password: undefined });\n    });\n  });\n\n  describe('api/:shareId/view/link-records (GET)', () => {\n    let linkTableRes: ITableFullVo;\n    const primaryFieldName = 'Text1';\n    let linkFieldId: string;\n    let tableRes: ITableFullVo;\n\n    const tableRecords = [\n      { fields: { [primaryFieldName]: '1' } },\n      { fields: { [primaryFieldName]: '2' } },\n      { fields: { [primaryFieldName]: '3' } },\n    ];\n\n    beforeAll(async () => {\n      tableRes = await createTable(baseId, {\n        records: tableRecords,\n        fields: [\n          {\n            name: primaryFieldName,\n            type: FieldType.SingleLineText,\n          },\n        ],\n      });\n      const linkFieldRo: IFieldRo = {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: tableRes.id,\n        },\n      };\n\n      linkTableRes = await createTable(baseId, {\n        name: 'linkTable',\n        fields: [\n          {\n            name: 'primary',\n            type: FieldType.SingleLineText,\n          },\n          linkFieldRo,\n        ],\n        records: [\n          { fields: { primary: '1', [linkFieldRo.name!]: { id: tableRes.records[0].id } } },\n          { fields: { primary: '2', [linkFieldRo.name!]: { id: tableRes.records[1].id } } },\n        ],\n      });\n      linkFieldId = linkTableRes.fields[1].id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, linkTableRes.id);\n      await permanentDeleteTable(baseId, tableRes.id);\n    });\n\n    describe('form view', () => {\n      let formViewId: string;\n      let fromViewShareId: string;\n      beforeAll(async () => {\n        const result = await createView(linkTableRes.id, formViewRo);\n        formViewId = result.id;\n        await apiUpdateViewColumnMeta(linkTableRes.id, formViewId, [\n          {\n            fieldId: linkFieldId,\n            columnMeta: { visible: true },\n          },\n        ]);\n        const shareResult = await apiEnableShareView({\n          tableId: linkTableRes.id,\n          viewId: formViewId,\n        });\n        fromViewShareId = shareResult.data.shareId;\n      });\n      it('should return link records', async () => {\n        const result = await apiGetShareViewLinkRecords(fromViewShareId, {\n          fieldId: linkFieldId,\n        });\n        const linkRecords = result.data;\n        expect(linkRecords.map((record) => record.title)).toEqual(\n          tableRecords.map((record) => record.fields[primaryFieldName])\n        );\n      });\n    });\n\n    describe('grid view', () => {\n      let gridViewId: string;\n      let gridViewShareId: string;\n      beforeAll(async () => {\n        const result = await createView(linkTableRes.id, gridViewRo);\n        gridViewId = result.id;\n        const shareResult = await apiEnableShareView({\n          tableId: linkTableRes.id,\n          viewId: gridViewId,\n        });\n        gridViewShareId = shareResult.data.shareId;\n      });\n\n      it('should return link records', async () => {\n        const result = await apiGetShareViewLinkRecords(gridViewShareId, {\n          fieldId: linkFieldId,\n        });\n        const linkRecords = result.data;\n        expect(linkRecords.map((record) => record.title)).toEqual(\n          tableRecords.slice(0, 2).map((record) => record.fields[primaryFieldName])\n        );\n      });\n    });\n  });\n\n  describe('api/:shareId/view/collaborators (GET)', () => {\n    let userTableRes: ITableFullVo;\n    const userFieldName = 'normal user';\n    const multipleUserFieldName = 'multiple user';\n    let userFieldId: string;\n    let multipleUserFieldId: string;\n    const userFieldRo: IFieldRo = {\n      name: userFieldName,\n      type: FieldType.User,\n      options: {\n        isMultiple: false,\n        shouldNotify: false,\n      } as IUserFieldOptions,\n    };\n\n    const multipleUserFieldRo: IFieldRo = {\n      name: multipleUserFieldName,\n      type: FieldType.User,\n      options: {\n        isMultiple: true,\n        shouldNotify: false,\n      } as IUserFieldOptions,\n    };\n    beforeAll(async () => {\n      userTableRes = await createTable(baseId, {\n        name: 'user table',\n        fields: [\n          {\n            name: 'primary',\n            type: FieldType.SingleLineText,\n          },\n          userFieldRo,\n          multipleUserFieldRo,\n        ],\n        records: [],\n      });\n      userFieldId = userTableRes.fields[1].id;\n      multipleUserFieldId = userTableRes.fields[2].id;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, userTableRes.id);\n    });\n    describe('grid view', () => {\n      let gridViewId: string;\n      let gridViewShareId: string;\n      beforeAll(async () => {\n        const result = await createView(userTableRes.id, gridViewRo);\n        gridViewId = result.id;\n        const shareResult = await apiEnableShareView({\n          tableId: userTableRes.id,\n          viewId: gridViewId,\n        });\n        gridViewShareId = shareResult.data.shareId;\n      });\n      it('should return [], no user cell with a value exists', async () => {\n        const result = await apiGetShareViewCollaborators(gridViewShareId, {\n          fieldId: userFieldId,\n        });\n        expect(result.data).toEqual([]);\n      });\n\n      it('should return the value that exists and there will be no duplicates of the', async () => {\n        const { data: createRes } = await apiCreateRecords(userTableRes.id, {\n          records: [\n            {\n              fields: {\n                [multipleUserFieldId]: [{ id: userId, title: userName }],\n                [userFieldId]: { id: userId, title: userName },\n              },\n            },\n            {\n              fields: {\n                [multipleUserFieldId]: [{ id: userId, title: userName }],\n                [userFieldId]: { id: userId, title: userName },\n              },\n            },\n          ],\n          fieldKeyType: FieldKeyType.Id,\n        });\n        const result = await apiGetShareViewCollaborators(gridViewShareId, {\n          fieldId: userFieldId,\n        });\n        const mulResult = await apiGetShareViewCollaborators(gridViewShareId, {\n          fieldId: multipleUserFieldId,\n        });\n        expect(result.data).toEqual([\n          { userId, userName, email: userEmail, avatar: expect.any(String) },\n        ]);\n        expect(mulResult.data).toEqual([\n          { userId, userName, email: userEmail, avatar: expect.any(String) },\n        ]);\n\n        await apiDeleteRecords(\n          userTableRes.id,\n          createRes.records.map((record) => record.id)\n        );\n      });\n    });\n\n    describe('Form view', () => {\n      let formViewId: string;\n      let fromViewShareId: string;\n      beforeAll(async () => {\n        const result = await createView(userTableRes.id, formViewRo);\n        formViewId = result.id;\n        const shareResult = await apiEnableShareView({\n          tableId: userTableRes.id,\n          viewId: formViewId,\n        });\n        fromViewShareId = shareResult.data.shareId;\n      });\n      it('should return [], no user cell visible', async () => {\n        await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [\n          {\n            fieldId: userFieldId,\n            columnMeta: { visible: false },\n          },\n        ]);\n        const result = await apiGetShareViewCollaborators(fromViewShareId, {\n          fieldId: userFieldId,\n        });\n        expect(result.data).toEqual([]);\n      });\n      it('should return the base collaborators', async () => {\n        await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [\n          {\n            fieldId: userFieldId,\n            columnMeta: { visible: true },\n          },\n        ]);\n        const result = await apiGetShareViewCollaborators(fromViewShareId, {});\n        const baseCollaborators = await apiGetBaseCollaboratorList(baseId, {\n          type: PrincipalType.User,\n        });\n        expect(result.data.map((user) => user.userId)).toEqual(\n          baseCollaborators.data.collaborators.map((item) => item.userId)\n        );\n        await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [\n          {\n            fieldId: userFieldId,\n            columnMeta: { visible: false },\n          },\n        ]);\n      });\n    });\n  });\n\n  describe('api/:shareId/view/copy (PATCH)', () => {\n    let gridViewId: string;\n    let gridViewShareId: string;\n\n    beforeEach(async () => {\n      const result = await createView(tableId, gridViewRo);\n      gridViewId = result.id;\n\n      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });\n      await apiUpdateViewShareMeta(tableId, gridViewId, { allowCopy: true });\n      gridViewShareId = shareResult.data.shareId;\n    });\n\n    it('should return 200', async () => {\n      const result = await anonymousUser.get(\n        urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }),\n        {\n          params: {\n            ranges: JSON.stringify([\n              [0, 0],\n              [1, 1],\n            ]),\n          },\n        }\n      );\n      expect(result.status).toEqual(200);\n    });\n\n    it('share not allow copy', async () => {\n      const result = await createView(tableId, gridViewRo);\n      const gridViewId = result.id;\n\n      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });\n      const gridViewShareId = shareResult.data.shareId;\n      const error = await getError(() =>\n        anonymousUser.get(urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }), {\n          params: {\n            ranges: JSON.stringify([\n              [0, 0],\n              [1, 1],\n            ]),\n          },\n        })\n      );\n      expect(error?.status).toEqual(403);\n    });\n  });\n\n  describe('link view permission', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should get link view', async () => {\n      const linkField = await createField(table1.id, {\n        name: 'link field',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n      const shareResult = await getShareView(linkField.data.id);\n\n      // should not allow access by other user\n      const user2Request = await createNewUserAxios({\n        email: 'newuser@example.com',\n        password: '12345678',\n      });\n      expect(\n        user2Request.get(urlBuilder(SHARE_VIEW_GET, { shareId: shareResult.data.shareId }))\n      ).rejects.toThrow();\n    });\n\n    it('search and filterLinkCellSelected', async () => {\n      const linkField = await createField(table1.id, {\n        name: 'link field1',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      });\n      const rowCountRes = await getShareViewRowCount(linkField.data.id, {\n        search: ['1', table2.fields[0].id, true],\n        filterLinkCellSelected: linkField.data.id,\n      });\n      expect(rowCountRes.data.rowCount).toEqual(0);\n    });\n  });\n\n  describe('link view limit', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, {\n        name: 'table2',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n    });\n\n    it('should get link view limit by view', async () => {\n      const filterByViewId = table2.defaultViewId;\n      const singleSelectField = table2.fields[2];\n      const filter: IFilterRo = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: singleSelectField.id,\n              operator: is.value,\n              value: 'x',\n            },\n          ],\n        },\n      };\n\n      await updateViewFilter(table2.id, table2.defaultViewId!, filter);\n\n      const linkField = await createField(table1.id, {\n        name: 'link field limit by view',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filterByViewId,\n        },\n      });\n      const shareResult = await getShareView(linkField.data.id);\n\n      expect(shareResult.data.records.length).toEqual(7);\n    });\n\n    it('should get link view limit by filter', async () => {\n      const singleSelectField = table2.fields[2];\n      const filter = {\n        conjunction: 'and' as const,\n        filterSet: [\n          {\n            fieldId: singleSelectField.id,\n            operator: is.value,\n            value: 'x',\n          },\n        ],\n      };\n      const linkField = await createField(table1.id, {\n        name: 'link field limit by filter',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filter,\n        },\n      });\n      const shareResult = await getShareView(linkField.data.id);\n\n      expect(shareResult.data.records.length).toEqual(7);\n    });\n\n    it('should get link view limit by visible fields', async () => {\n      const fields = table2.fields;\n      const visibleFieldIds = fields.slice(0, 3).map((field) => field.id);\n      const linkField = await createField(table1.id, {\n        name: 'link field limit by hidden fields',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          visibleFieldIds,\n        },\n      });\n      const shareResult = await getShareView(linkField.data.id);\n\n      expect(shareResult.data.fields.length).toEqual(3);\n    });\n\n    it('should get link view limited by multiple conditions', async () => {\n      const filterByViewId = table2.defaultViewId;\n      const textField = table2.fields[0];\n      const singleSelectField = table2.fields[2];\n      const filter: IFilterRo = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: singleSelectField.id,\n              operator: is.value,\n              value: 'x',\n            },\n          ],\n        },\n      };\n\n      await updateViewFilter(table2.id, table2.defaultViewId!, filter);\n\n      const fields = table2.fields;\n      const visibleFieldIds = fields.slice(0, 3).map((field) => field.id);\n\n      const additionalFilter = {\n        conjunction: 'and' as const,\n        filterSet: [\n          {\n            fieldId: textField.id,\n            operator: is.value,\n            value: '6',\n          },\n        ],\n      };\n\n      const linkField = await createField(table1.id, {\n        name: 'link field with multiple limits',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filterByViewId,\n          filter: additionalFilter,\n          visibleFieldIds,\n        },\n      });\n      const shareResult = await getShareView(linkField.data.id);\n\n      expect(shareResult.data.records.length).toBeLessThanOrEqual(1);\n      expect(shareResult.data.fields.length).toEqual(3);\n    });\n\n    it('should clean link options after filterByViewId is deleted', async () => {\n      const view = await createView(table2.id, {\n        name: 'view',\n        type: ViewType.Grid,\n      });\n\n      const linkField = await createField(table1.id, {\n        name: 'clean link options filterByViewId',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filterByViewId: view.id,\n        },\n      });\n\n      expect((linkField.data.options as ILinkFieldOptions).filterByViewId).toEqual(view.id);\n\n      await deleteView(table2.id, view.id);\n      const currentLinkField = await getField(table1.id, linkField.data.id);\n\n      expect((currentLinkField.options as ILinkFieldOptions).filterByViewId).toBeNull();\n    });\n\n    it('should clean link options after filtering field is deleted', async () => {\n      const singleSelectField = table2.fields[2];\n      const filter = {\n        conjunction: 'and' as const,\n        filterSet: [\n          {\n            fieldId: singleSelectField.id,\n            operator: is.value,\n            value: 'x',\n          },\n        ],\n      };\n\n      const linkField = await createField(table1.id, {\n        name: 'clean link options filter',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filter,\n          visibleFieldIds: [singleSelectField.id],\n        },\n      });\n\n      expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter);\n      expect((linkField.data.options as ILinkFieldOptions).visibleFieldIds).toEqual([\n        singleSelectField.id,\n      ]);\n\n      await deleteField(table2.id, singleSelectField.id);\n      const currentLinkField = await getField(table1.id, linkField.data.id);\n\n      expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull();\n      expect((currentLinkField.options as ILinkFieldOptions).visibleFieldIds).toBeNull();\n    });\n\n    it('should clean link options after filtering field is converted', async () => {\n      const singleSelectField = table2.fields[2];\n      const filter = {\n        conjunction: 'and' as const,\n        filterSet: [\n          {\n            fieldId: singleSelectField.id,\n            operator: is.value,\n            value: 'x',\n          },\n        ],\n      };\n\n      const linkField = await createField(table1.id, {\n        name: 'convert link options filter',\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId: table2.id,\n          filter,\n        },\n      });\n\n      expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter);\n\n      await convertField(table2.id, singleSelectField.id, {\n        type: FieldType.MultipleSelect,\n      });\n      const currentLinkField = await getField(table1.id, linkField.data.id);\n\n      expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/sort.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IDateFieldOptions,\n  IFieldRo,\n  IFieldVo,\n  INumberFieldOptions,\n  ISelectFieldOptions,\n  ISortItem,\n} from '@teable/core';\nimport {\n  CellValueType,\n  SortFunc,\n  FieldType,\n  formatNumberToString,\n  formatDateToString,\n  DateFormattingPreset,\n  TimeFormatting,\n  FieldKeyType,\n} from '@teable/core';\nimport type { IGetRecordsRo, ITableFullVo, IViewSortRo } from '@teable/openapi';\nimport {\n  updateViewSort as apiSetViewSort,\n  convertField,\n  createRecords,\n  updateRecords,\n  updateViewGroup,\n} from '@teable/openapi';\nimport { isEmpty, orderBy } from 'lodash';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\nimport {\n  createField,\n  createTable,\n  permanentDeleteTable,\n  getFields,\n  getRecords,\n  getView,\n  initApp,\n} from './utils/init-app';\n\nlet app: INestApplication;\nconst baseId = globalThis.testConfig.baseId;\n\nconst delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\n// cellValueType which need to test\nconst typeTests = [\n  {\n    type: CellValueType.String,\n  },\n  {\n    type: CellValueType.Number,\n  },\n  {\n    type: CellValueType.DateTime,\n  },\n  {\n    type: CellValueType.Boolean,\n  },\n];\n\nconst getSortRecords = async (\n  tableId: string,\n  query?: Pick<IGetRecordsRo, 'viewId' | 'orderBy'>\n) => {\n  const result = await getRecords(tableId, {\n    fieldKeyType: FieldKeyType.Id,\n    viewId: query?.viewId,\n    orderBy: query?.orderBy,\n  });\n  return result.records;\n};\n\nconst setRecordsOrder = async (tableId: string, viewId: string, orderBy: ISortItem[]) => {\n  await apiSetViewSort(tableId, viewId, {\n    sort: { sortObjs: orderBy },\n  });\n};\n\nconst getRecordsByOrder = (\n  records: ITableFullVo['records'],\n  conditions: ISortItem[],\n  fields: ITableFullVo['fields']\n) => {\n  if (Array.isArray(records) && !records.length) return [];\n  const fns = conditions.map((condition) => {\n    const { fieldId } = condition;\n    const field = fields.find((field) => field.id === fieldId) as ITableFullVo['fields'][number];\n    const { name, type, options, isMultipleCellValue } = field;\n    return (record: ITableFullVo['records'][number]) => {\n      const cellValue = record?.fields?.[name];\n      if (isEmpty(cellValue)) {\n        return -Infinity;\n      }\n      if (type === FieldType.SingleSelect && !isMultipleCellValue) {\n        const { choices } = options as ISelectFieldOptions;\n        return choices.map(({ name }) => name).indexOf(cellValue as string);\n      }\n      if (type === FieldType.Number) {\n        if (isMultipleCellValue && Array.isArray(cellValue)) {\n          return cellValue\n            .map((v) => formatNumberToString(v, (options as INumberFieldOptions).formatting))\n            .join(', ');\n        }\n        return formatNumberToString(\n          cellValue as number,\n          (options as INumberFieldOptions).formatting\n        );\n      }\n      if (type === FieldType.Date) {\n        if (isMultipleCellValue && Array.isArray(cellValue)) {\n          return cellValue\n            .map((v) => formatDateToString(v, (options as IDateFieldOptions).formatting))\n            .join(', ');\n        }\n        return formatDateToString(cellValue as string, (options as IDateFieldOptions).formatting);\n      }\n      if (isMultipleCellValue) {\n        // return JSON.stringify(record?.fields?.[name]);\n        return (cellValue as any)[0];\n      }\n    };\n  });\n  const orders = conditions.map((condition) => condition.order || SortFunc.Asc);\n  return orderBy([...records], fns, orders);\n};\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\ndescribe('OpenAPI ViewController view order sort (e2e)', () => {\n  let tableId: string;\n  let viewId: string;\n  let fields: IFieldRo[];\n\n  beforeEach(async () => {\n    const result = await createTable(baseId, { name: 'Table' });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    fields = result.fields!;\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it('/api/table/{tableId}/view/{viewId}/sort sort view order (PUT)', async () => {\n    const assertSort = {\n      sort: {\n        sortObjs: [\n          {\n            fieldId: fields[0].id as string,\n            order: SortFunc.Asc,\n          },\n        ],\n        manualSort: false,\n      },\n    };\n    await apiSetViewSort(tableId, viewId, assertSort);\n    const updatedView = await getView(tableId, viewId);\n    const viewSort = updatedView.sort;\n    expect(viewSort).toEqual(assertSort.sort);\n  });\n\n  it('sort date should always use a second precision when formatting time is not none', async () => {\n    await createRecords(tableId, {\n      records: [\n        {\n          fields: {},\n        },\n      ],\n    });\n\n    await delay(1000);\n\n    await createRecords(tableId, {\n      records: [\n        {\n          fields: {},\n        },\n      ],\n    });\n\n    const createdTimeField = await createField(tableId, {\n      name: 'createdTime',\n      type: FieldType.CreatedTime,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.Hour24,\n          timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      },\n    });\n\n    // asc\n    const ascOrders: IGetRecordsRo['orderBy'] = [\n      { fieldId: createdTimeField.id, order: SortFunc.Asc },\n    ];\n\n    const originRecords = await getSortRecords(tableId, {\n      viewId,\n      orderBy: ascOrders,\n    });\n\n    const assertSort = orderBy(\n      originRecords,\n      ['createdTime', 'autoNumber'],\n      [SortFunc.Asc, SortFunc.Asc]\n    );\n\n    const originId = originRecords.map((record) => record.id);\n\n    const assertId = assertSort.map((record) => record.id);\n\n    expect(originId).toEqual(assertId);\n\n    // desc\n    const descOrders: IGetRecordsRo['orderBy'] = [\n      { fieldId: createdTimeField.id, order: SortFunc.Desc },\n    ];\n\n    const descOriginRecords = await getSortRecords(tableId, {\n      viewId,\n      orderBy: descOrders,\n    });\n\n    const assertDescSort = orderBy(\n      descOriginRecords,\n      ['createdTime', 'autoNumber'],\n      [SortFunc.Desc, SortFunc.Asc]\n    );\n\n    const originDescId = descOriginRecords.map((record) => record.id);\n\n    const assertDescId = assertDescSort.map((record) => record.id);\n\n    expect(originDescId).toEqual(assertDescId);\n  });\n\n  it('sort date should precision should be day when formatting time is none', async () => {\n    await createRecords(tableId, {\n      records: [\n        {\n          fields: {},\n        },\n      ],\n    });\n\n    await delay(1000);\n\n    await createRecords(tableId, {\n      records: [\n        {\n          fields: {},\n        },\n      ],\n    });\n\n    const createdTimeField = await createField(tableId, {\n      name: 'createdTime',\n      type: FieldType.CreatedTime,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.None,\n          timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      },\n    });\n\n    // asc\n    const ascOrders: IGetRecordsRo['orderBy'] = [\n      { fieldId: createdTimeField.id, order: SortFunc.Asc },\n    ];\n\n    const originRecords = await getSortRecords(tableId, {\n      viewId,\n      orderBy: ascOrders,\n    });\n\n    const assertSort = orderBy(\n      originRecords,\n      ['createdTime', 'autoNumber'],\n      [SortFunc.Asc, SortFunc.Asc]\n    );\n\n    const originId = originRecords.map((record) => record.id);\n\n    const assertId = assertSort.map((record) => record.id);\n\n    expect(originId).toEqual(assertId);\n\n    // desc\n    const descOrders: IGetRecordsRo['orderBy'] = [\n      { fieldId: createdTimeField.id, order: SortFunc.Desc },\n    ];\n\n    const descOriginRecords = await getSortRecords(tableId, {\n      viewId,\n      orderBy: descOrders,\n    });\n\n    const ascOriginRecords = await getSortRecords(tableId, {\n      viewId,\n      orderBy: ascOrders,\n    });\n\n    const descRecordsDescId = descOriginRecords.map((record) => record.id);\n\n    const ascRecordsDescId = ascOriginRecords.map((record) => record.id);\n\n    // if time is none, the sort precision should be day, meaning that the sort by day instead of second\n    expect(descRecordsDescId).toEqual(ascRecordsDescId);\n\n    // then group by createdTime, and sort by single select field\n    const fields = await getFields(tableId);\n    const singleSelectField = fields.find((field) => field.type === FieldType.SingleSelect)!;\n    await convertField(tableId, singleSelectField.id, {\n      dbFieldName: singleSelectField.dbFieldName,\n      type: singleSelectField.type as FieldType,\n      options: {\n        choices: [\n          { name: '1', color: 'cyanLight2' },\n          { name: '2', color: 'yellowDark1' },\n          { name: '3', color: 'yellowLight1' },\n          { name: '4', color: 'orangeBright' },\n          { name: '5', color: 'yellowLight2' },\n        ],\n      },\n    });\n    await updateRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      typecast: true,\n      records: ascRecordsDescId.reverse().map((id, index) => ({\n        id,\n        fields: {\n          [singleSelectField.id!]: index + 1,\n        },\n      })),\n    });\n    const createTimeField = await createField(tableId, {\n      name: 'createdTime',\n      type: FieldType.CreatedTime,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.None,\n          timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      },\n    });\n\n    await apiSetViewSort(tableId, viewId, {\n      sort: {\n        sortObjs: [{ fieldId: singleSelectField.id, order: SortFunc.Asc }],\n      },\n    });\n\n    await updateViewGroup(tableId, viewId, {\n      group: [{ fieldId: createTimeField.id, order: SortFunc.Asc }],\n    });\n\n    const records = await getRecords(tableId, {\n      viewId,\n    });\n\n    const assertRecordIds = orderBy(records.records, [`fields.${singleSelectField.name}`], ['asc']);\n\n    expect(records.records.map((r) => r.id)).toEqual(assertRecordIds.map((r) => r.id));\n  });\n\n  it('should not allow to modify sort for button field', async () => {\n    const buttonField = await createField(tableId, {\n      type: FieldType.Button,\n    });\n    const assertSort: IViewSortRo = {\n      sort: {\n        sortObjs: [{ fieldId: buttonField.id, order: SortFunc.Asc }],\n      },\n    };\n\n    await expect(apiSetViewSort(tableId, viewId, assertSort)).rejects.toThrow();\n  });\n});\n\ndescribe('OpenAPI Sort (e2e) Base CellValueType', () => {\n  let table: ITableFullVo;\n\n  beforeAll(async () => {\n    table = await createTable(baseId, {\n      name: 'sort_x_20',\n      fields: x_20.fields,\n      records: x_20.records,\n    });\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/record sort (GET) Test CellValueType: $type`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2 } = table;\n      const field = fields2.find((field) => field.cellValueType === type);\n      const { id: fieldId } = field!;\n\n      const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Asc }];\n      const descOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Desc }];\n      const ascOriginRecords = await getSortRecords(subTableId, { orderBy: ascOrders });\n      const descOriginRecords = await getSortRecords(subTableId, { orderBy: descOrders });\n\n      const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields2);\n      const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields2);\n\n      expect(ascOriginRecords).toEqual(ascManualSortRecords);\n      expect(descOriginRecords).toEqual(descManualSortRecords);\n    }\n  );\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/view/{viewId}/sort sort view raw order (POST) Test CellValueType: $type`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2, defaultViewId } = table;\n      const field = fields2.find(\n        (field) => field.cellValueType === type\n      ) as ITableFullVo['fields'][number];\n      const { id: fieldId } = field;\n\n      const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Asc }];\n      await setRecordsOrder(subTableId, defaultViewId!, ascOrders);\n      const ascOriginRecords = await getSortRecords(subTableId, { viewId: defaultViewId });\n      const descOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Desc }];\n      await setRecordsOrder(subTableId, defaultViewId!, descOrders);\n      const descOriginRecords = await getSortRecords(subTableId, { viewId: defaultViewId });\n\n      const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields2);\n      const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields2);\n\n      expect(ascOriginRecords).toEqual(ascManualSortRecords);\n      expect(descOriginRecords).toEqual(descManualSortRecords);\n    }\n  );\n\n  test('SingleSelect field sorting should be sorted based on option value', async () => {\n    const { id: subTableId, fields: fields2 } = table;\n    const singleSelectField = fields2.find((field) => field.type === FieldType.SingleSelect);\n    const { id: fieldId } = singleSelectField!;\n\n    const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Asc }];\n    const descOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Desc }];\n    const ascOriginRecords = await getSortRecords(subTableId, { orderBy: ascOrders });\n    const descOriginRecords = await getSortRecords(subTableId, { orderBy: descOrders });\n\n    const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields2);\n    const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields2);\n\n    expect(ascOriginRecords).toEqual(ascManualSortRecords);\n    expect(descOriginRecords).toEqual(descManualSortRecords);\n  });\n\n  test('view sort property should be merged after by interface parameter orderBy', async () => {\n    const { id: subTableId, fields: fields2, defaultViewId } = table;\n    const field = fields2.find(\n      (field) => field.type === FieldType.Number\n    ) as ITableFullVo['fields'][number];\n    const { id: fieldId } = field;\n\n    const booleanField = fields2.find((field) => field.type === FieldType.Checkbox);\n    const { id: booleanFieldId } = booleanField!;\n\n    const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId, order: SortFunc.Asc }];\n    const descOrders: IGetRecordsRo['orderBy'] = [\n      { fieldId: booleanFieldId, order: SortFunc.Desc },\n    ];\n    await setRecordsOrder(subTableId, defaultViewId!, ascOrders);\n    const originRecords = await getSortRecords(subTableId, {\n      viewId: defaultViewId,\n      orderBy: descOrders,\n    });\n    const manualSortRecords = getRecordsByOrder(\n      originRecords,\n      [...descOrders, ...ascOrders],\n      fields2\n    );\n    expect(originRecords).toEqual(manualSortRecords);\n  });\n});\n\ndescribe('OpenAPI Sort (e2e) Multiple CellValueType', () => {\n  let mainTable: ITableFullVo;\n  let subTable: ITableFullVo;\n\n  beforeAll(async () => {\n    mainTable = await createTable(baseId, {\n      name: 'sort_x_20',\n      fields: x_20.fields,\n      records: x_20.records,\n    });\n\n    const x20Link = x_20_link(mainTable);\n    subTable = await createTable(baseId, {\n      name: 'sort_x_20',\n      fields: x20Link.fields,\n      records: x20Link.records,\n    });\n\n    const x20LinkFromLookups = x_20_link_from_lookups(mainTable, subTable.fields[2].id);\n    for (const field of x20LinkFromLookups.fields) {\n      await createField(subTable.id, field);\n    }\n\n    subTable.fields = await getFields(subTable.id);\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, mainTable.id);\n    await permanentDeleteTable(baseId, subTable.id);\n  });\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/record sort (GET) Test CellValueType: $type - Multiple`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2 } = subTable;\n\n      const field = fields2.find((field) => field.cellValueType === type && field.isLookup);\n      const { id: lookupFieldId } = field!;\n\n      const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId: lookupFieldId, order: SortFunc.Asc }];\n      const descOrders: IGetRecordsRo['orderBy'] = [\n        { fieldId: lookupFieldId, order: SortFunc.Desc },\n      ];\n      const ascOriginRecords = await getSortRecords(subTableId, { orderBy: ascOrders });\n      const descOriginRecords = await getSortRecords(subTableId, { orderBy: descOrders });\n\n      const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields2);\n      const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields2);\n\n      expect(ascOriginRecords).toEqual(ascManualSortRecords);\n      expect(descOriginRecords).toEqual(descManualSortRecords);\n    }\n  );\n\n  test.each(typeTests)(\n    `/api/table/{tableId}/view/{viewId}/sort sort view raw order (POST) Test CellValueType: $type - Multiple`,\n    async ({ type }) => {\n      const { id: subTableId, fields: fields2, defaultViewId: subDefaultViewId } = subTable;\n\n      const field = fields2.find((field) => field.cellValueType === type && field.isLookup);\n      const { id: lookupFieldId } = field!;\n\n      const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId: lookupFieldId, order: SortFunc.Asc }];\n      await setRecordsOrder(subTableId, subDefaultViewId!, ascOrders);\n      const ascOriginRecords = await getSortRecords(subTableId, { viewId: subDefaultViewId });\n      const descOrders: IGetRecordsRo['orderBy'] = [\n        { fieldId: lookupFieldId, order: SortFunc.Desc },\n      ];\n      await setRecordsOrder(subTableId, subDefaultViewId!, descOrders);\n      const descOriginRecords = await getSortRecords(subTableId, { viewId: subDefaultViewId });\n\n      const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields2);\n      const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields2);\n\n      expect(ascOriginRecords).toEqual(ascManualSortRecords);\n      expect(descOriginRecords).toEqual(descManualSortRecords);\n    }\n  );\n});\n\ndescribe('OpenAPI Sort (e2e) Date Formatting', () => {\n  let tableId: string;\n  let viewId: string;\n  let fields: IFieldVo[];\n\n  const generateDateField = (name: string, date: DateFormattingPreset) => {\n    return {\n      name,\n      type: FieldType.Date,\n      options: {\n        formatting: {\n          date,\n          time: TimeFormatting.None,\n          timeZone: 'Asia/Singapore',\n        },\n      },\n    };\n  };\n\n  const originFields = [\n    generateDateField('Year', DateFormattingPreset.Y),\n    generateDateField('Month', DateFormattingPreset.YM),\n    generateDateField('Day', DateFormattingPreset.ISO),\n  ];\n\n  const generateFieldValues = (dateString: string) => {\n    return {\n      fields: {\n        [originFields[0].name!]: new Date(dateString).toISOString(),\n        [originFields[1].name!]: new Date(dateString).toISOString(),\n        [originFields[2].name!]: new Date(dateString).toISOString(),\n      },\n    };\n  };\n\n  beforeEach(async () => {\n    const result = await createTable(baseId, {\n      name: 'sort_by_date',\n      fields: originFields,\n      records: [\n        generateFieldValues('2024-01-10 10:00:00'),\n        generateFieldValues('2024-01-10 08:00:00'),\n        generateFieldValues('2023-05-01 09:00:00'),\n        generateFieldValues('2022-08-01 06:00:00'),\n        generateFieldValues('2022-05-01 10:00:00'),\n        generateFieldValues('2024-01-01 10:00:00'),\n      ],\n    });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    fields = result.fields!;\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  test.each([\n    { index: 0, fieldName: originFields[0].name as string },\n    { index: 1, fieldName: originFields[1].name as string },\n    { index: 2, fieldName: originFields[2].name as string },\n  ])(\n    '/api/table/{tableId}/view/{viewId}/sort sort by date with different formatting: $fieldName',\n    async ({ index }) => {\n      const sortByFieldId = fields[index].id as string;\n      const ascOrders: IGetRecordsRo['orderBy'] = [{ fieldId: sortByFieldId, order: SortFunc.Asc }];\n      const descOrders: IGetRecordsRo['orderBy'] = [\n        { fieldId: sortByFieldId, order: SortFunc.Desc },\n      ];\n\n      await setRecordsOrder(tableId, viewId, ascOrders);\n\n      const ascOriginRecords = await getSortRecords(tableId, { orderBy: ascOrders });\n      const descOriginRecords = await getSortRecords(tableId, { orderBy: descOrders });\n\n      const ascManualSortRecords = getRecordsByOrder(ascOriginRecords, ascOrders, fields);\n      const descManualSortRecords = getRecordsByOrder(descOriginRecords, descOrders, fields);\n\n      expect(ascOriginRecords).toEqual(ascManualSortRecords);\n      expect(descOriginRecords).toEqual(descManualSortRecords);\n    }\n  );\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/space.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport { getPluginEmail, IdPrefix, Role } from '@teable/core';\nimport type {\n  ICreateSpaceVo,\n  IUserMeVo,\n  ListSpaceCollaboratorVo,\n  ListSpaceInvitationLinkVo,\n  UserCollaboratorItem,\n} from '@teable/openapi';\nimport {\n  createSpace as apiCreateSpace,\n  createSpaceInvitationLink as apiCreateSpaceInvitationLink,\n  createSpaceInvitationLinkVoSchema,\n  deleteSpace as apiDeleteSpace,\n  deleteSpaceInvitationLink as apiDeleteSpaceInvitationLink,\n  emailSpaceInvitation as apiEmailSpaceInvitation,\n  getSpaceById as apiGetSpaceById,\n  getSpaceCollaboratorList as apiGetSpaceCollaboratorList,\n  getSpaceList as apiGetSpaceList,\n  getSpaceVoSchema,\n  listSpaceInvitationLink as apiListSpaceInvitationLink,\n  updateSpace as apiUpdateSpace,\n  updateSpaceInvitationLink as apiUpdateSpaceInvitationLink,\n  CREATE_SPACE,\n  EMAIL_SPACE_INVITATION,\n  urlBuilder,\n  listSpaceInvitationLink,\n  updateSpaceCollaborator,\n  USER_ME,\n  deleteSpaceCollaborator,\n  createBase,\n  emailBaseInvitation,\n  emailSpaceInvitation,\n  getBaseCollaboratorList,\n  CollaboratorType,\n  getSpaceCollaboratorList,\n  deleteBase,\n  UPDATE_SPACE_COLLABORATE,\n  DELETE_SPACE_COLLABORATOR,\n  PrincipalType,\n  PERMANENT_DELETE_SPACE,\n  getIntegrationList,\n  createIntegration,\n  LLMProviderType,\n  IntegrationType,\n  updateIntegration,\n  deleteIntegration,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { Events } from '../src/event-emitter/events';\nimport type { SpaceDeleteEvent, SpaceUpdateEvent } from '../src/event-emitter/events';\nimport { chartConfig } from '../src/features/plugin/official/config/chart';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { getError } from './utils/get-error';\nimport { createSpace, initApp, permanentDeleteSpace } from './utils/init-app';\n\ndescribe('OpenAPI SpaceController (e2e)', () => {\n  let app: INestApplication;\n  const globalSpaceId: string = testConfig.spaceId;\n  let spaceId: string;\n  let event: EventEmitter2;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    spaceId = (await apiCreateSpace({ name: 'new space' })).data.id;\n    event = app.get(EventEmitter2);\n  });\n\n  afterAll(async () => {\n    await permanentDeleteSpace(spaceId);\n\n    await app.close();\n  });\n\n  it('/api/space (POST)', async () => {\n    expect(spaceId.startsWith(IdPrefix.Space)).toEqual(true);\n  });\n\n  it('/api/space/:spaceId (PUT)', async () => {\n    event.once(Events.SPACE_UPDATE, async (payload: SpaceUpdateEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.SPACE_UPDATE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.space).toBeDefined();\n    });\n\n    const res = await apiUpdateSpace({\n      spaceId,\n      updateSpaceRo: { name: 'new space1' },\n    });\n\n    spaceId = res.data.id;\n    expect(res.data.name).toEqual('new space1');\n  });\n\n  it('/api/space/:spaceId (GET)', async () => {\n    const res = await apiGetSpaceById(globalSpaceId);\n\n    expect(getSpaceVoSchema.safeParse(res.data).success).toEqual(true);\n  });\n\n  it('/api/space/:spaceId (GET) - deleted', async () => {\n    const newSpaceRes = await apiCreateSpace({ name: 'delete space' });\n    await apiDeleteSpace(newSpaceRes.data.id);\n    const error = await getError(() => apiGetSpaceById(newSpaceRes.data.id));\n    await permanentDeleteSpace(newSpaceRes.data.id);\n    expect(error?.status).toEqual(403);\n  });\n\n  it('/api/space (GET)', async () => {\n    const res = await apiGetSpaceList();\n    expect(res.data.length > 0).toEqual(true);\n  });\n\n  it('/api/space/:spaceId (DELETE)', async () => {\n    event.once(Events.SPACE_DELETE, async (payload: SpaceDeleteEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.SPACE_DELETE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.spaceId).toBeDefined();\n    });\n\n    const newSpaceRes = await apiCreateSpace({ name: 'delete space' });\n    const res = await apiDeleteSpace(newSpaceRes.data.id);\n    expect(res.status).toEqual(200);\n    const error = await getError(() => apiDeleteSpace(newSpaceRes.data.id));\n    expect(error?.status).toEqual(403);\n  });\n\n  it('/api/space/:spaceId/collaborators (GET)', async () => {\n    const { collaborators, total } = (await apiGetSpaceCollaboratorList(spaceId)).data;\n    expect(collaborators).toHaveLength(1);\n    expect(total).toBe(1);\n  });\n\n  it('/api/space/:spaceId/collaborators (GET) - includeSystem', async () => {\n    const base = await createBase({ spaceId, name: 'new base' });\n    await emailBaseInvitation({\n      baseId: base.data.id,\n      emailBaseInvitationRo: { emails: [getPluginEmail(chartConfig.id)], role: Role.Creator },\n    });\n    const { collaborators } = (\n      await apiGetSpaceCollaboratorList(spaceId, { includeSystem: true, includeBase: true })\n    ).data;\n    await deleteBase(base.data.id);\n    expect(collaborators).toHaveLength(2);\n  });\n\n  it('/api/space/:spaceId/collaborators (GET) - includeBase', async () => {\n    const base = await createBase({ spaceId, name: 'new base' });\n    await emailBaseInvitation({\n      baseId: base.data.id,\n      emailBaseInvitationRo: { emails: ['space-coll-base@example.com'], role: Role.Creator },\n    });\n    const collaborators: ListSpaceCollaboratorVo = (\n      await apiGetSpaceCollaboratorList(spaceId, { includeBase: true })\n    ).data;\n    await deleteBase(base.data.id);\n    expect(collaborators.collaborators).toHaveLength(2);\n    expect(collaborators.total).toBe(2);\n  });\n\n  it('/api/space/:spaceId/collaborators (GET) - pagination', async () => {\n    const base = await createBase({ spaceId, name: 'new base' });\n    await emailBaseInvitation({\n      baseId: base.data.id,\n      emailBaseInvitationRo: { emails: ['space-coll-base@example.com'], role: Role.Creator },\n    });\n    const collaborators: ListSpaceCollaboratorVo = (\n      await apiGetSpaceCollaboratorList(spaceId, { includeBase: true, skip: 1, take: 1 })\n    ).data;\n    await deleteBase(base.data.id);\n    expect(collaborators.collaborators).toHaveLength(1);\n    expect(collaborators.total).toBe(2);\n  });\n\n  it('/api/space/:spaceId/collaborators (GET) - search', async () => {\n    const base = await createBase({ spaceId, name: 'new base' });\n    await emailBaseInvitation({\n      baseId: base.data.id,\n      emailBaseInvitationRo: { emails: ['space-coll-base@example.com'], role: Role.Creator },\n    });\n    const collaborators: ListSpaceCollaboratorVo = (\n      await apiGetSpaceCollaboratorList(spaceId, { includeBase: true, search: 'space-coll-base' })\n    ).data;\n    await deleteBase(base.data.id);\n    expect(collaborators.collaborators).toHaveLength(1);\n    expect((collaborators.collaborators[0] as UserCollaboratorItem).email).toBe(\n      'space-coll-base@example.com'\n    );\n    expect(collaborators.total).toBe(1);\n  });\n\n  describe('Space Invitation and operator collaborators', () => {\n    const newUserEmail = 'newuser@example.com';\n    const newUser3Email = 'newuser2@example.com';\n\n    let userRequest: AxiosInstance;\n    let userRequestId: string;\n    let user3Request: AxiosInstance;\n    let space2Id: string;\n    beforeEach(async () => {\n      user3Request = await createNewUserAxios({\n        email: newUser3Email,\n        password: '12345678',\n      });\n      userRequest = await createNewUserAxios({\n        email: newUserEmail,\n        password: '12345678',\n      });\n      userRequestId = (await userRequest.get<IUserMeVo>(USER_ME)).data.id;\n      const res = await userRequest.post<ICreateSpaceVo>(CREATE_SPACE, {\n        name: 'new space',\n      });\n      space2Id = res.data.id;\n      await userRequest.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId: space2Id }), {\n        emails: [globalThis.testConfig.email],\n        role: Role.Creator,\n      });\n    });\n\n    afterEach(async () => {\n      await userRequest.delete<null>(\n        urlBuilder(PERMANENT_DELETE_SPACE, {\n          spaceId: space2Id,\n        })\n      );\n    });\n\n    it('/api/space/:spaceId/invitation/link (POST)', async () => {\n      const res = await apiCreateSpaceInvitationLink({\n        spaceId: space2Id,\n        createSpaceInvitationLinkRo: { role: Role.Creator },\n      });\n      expect(createSpaceInvitationLinkVoSchema.safeParse(res.data).success).toEqual(true);\n\n      const linkList = await listSpaceInvitationLink(space2Id);\n      expect(linkList.data).toHaveLength(1);\n    });\n\n    it('/api/space/{spaceId}/invitation/link (POST) - exceeds limit role', async () => {\n      const error = await getError(() =>\n        apiCreateSpaceInvitationLink({\n          spaceId: space2Id,\n          createSpaceInvitationLinkRo: { role: Role.Owner },\n        })\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/space/:spaceId/invitation/link/:invitationId (PATCH)', async () => {\n      const res = await apiCreateSpaceInvitationLink({\n        spaceId,\n        createSpaceInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      const newSpaceUpdate = await apiUpdateSpaceInvitationLink({\n        spaceId,\n        invitationId: newInvitationId,\n        updateSpaceInvitationLinkRo: { role: Role.Editor },\n      });\n      expect(newSpaceUpdate.data.role).toEqual(Role.Editor);\n    });\n\n    it('/api/space/:spaceId/invitation/link/:invitationId (PATCH) - exceeds limit role', async () => {\n      const res = await apiCreateSpaceInvitationLink({\n        spaceId: space2Id,\n        createSpaceInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      const error = await getError(() =>\n        apiUpdateSpaceInvitationLink({\n          spaceId: space2Id,\n          invitationId: newInvitationId,\n          updateSpaceInvitationLinkRo: { role: Role.Owner },\n        })\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/space/:spaceId/invitation/link (GET)', async () => {\n      const res = await apiGetSpaceCollaboratorList(space2Id);\n      expect(res.data.collaborators).toHaveLength(2);\n    });\n\n    it('/api/space/:spaceId/invitation/link/:invitationId (DELETE)', async () => {\n      const res = await apiCreateSpaceInvitationLink({\n        spaceId: space2Id,\n        createSpaceInvitationLinkRo: { role: Role.Editor },\n      });\n      const newInvitationId = res.data.invitationId;\n\n      await apiDeleteSpaceInvitationLink({ spaceId: space2Id, invitationId: newInvitationId });\n\n      const list: ListSpaceInvitationLinkVo = (await apiListSpaceInvitationLink(space2Id)).data;\n      expect(list.find((v) => v.invitationId === newInvitationId)).toBeUndefined();\n    });\n\n    it('/api/space/:spaceId/invitation/email (POST)', async () => {\n      await apiEmailSpaceInvitation({\n        spaceId: space2Id,\n        emailSpaceInvitationRo: { role: Role.Creator, emails: [newUser3Email] },\n      });\n\n      const { collaborators } = (await apiGetSpaceCollaboratorList(space2Id)).data;\n\n      const newCollaboratorInfo = (collaborators as UserCollaboratorItem[]).find(\n        ({ email }) => email === newUser3Email\n      );\n\n      expect(newCollaboratorInfo).not.toBeUndefined();\n      expect(newCollaboratorInfo?.role).toEqual(Role.Creator);\n    });\n\n    it('/api/space/:spaceId/invitation/email (POST) - exceeds limit role', async () => {\n      const error = await getError(() =>\n        apiEmailSpaceInvitation({\n          spaceId: space2Id,\n          emailSpaceInvitationRo: { emails: [newUser3Email], role: Role.Owner },\n        })\n      );\n      expect(error?.status).toBe(403);\n    });\n\n    it('/api/space/:spaceId/invitation/email (POST) - not exist email', async () => {\n      await apiEmailSpaceInvitation({\n        spaceId: space2Id,\n        emailSpaceInvitationRo: { emails: ['not.exist@email.com'], role: Role.Creator },\n      });\n      const { collaborators } = (await apiGetSpaceCollaboratorList(space2Id)).data;\n      expect(collaborators).toHaveLength(3);\n    });\n\n    it('/api/space/:spaceId/invitation/email (POST) - user in base', async () => {\n      const base = await createBase({ spaceId: space2Id, name: 'new base' });\n      await emailBaseInvitation({\n        baseId: base.data.id,\n        emailBaseInvitationRo: {\n          emails: [newUser3Email],\n          role: Role.Editor,\n        },\n      });\n      const baseColl = await getBaseCollaboratorList(base.data.id);\n      const spaceColl = await getSpaceCollaboratorList(space2Id);\n      expect(spaceColl.data.collaborators).toHaveLength(2);\n      expect(baseColl.data.collaborators).toHaveLength(3);\n      expect(\n        (baseColl.data.collaborators as UserCollaboratorItem[]).find(\n          (v) => v.email === newUser3Email\n        )?.resourceType\n      ).toEqual(CollaboratorType.Base);\n\n      await emailSpaceInvitation({\n        spaceId: space2Id,\n        emailSpaceInvitationRo: {\n          emails: [newUser3Email],\n          role: Role.Editor,\n        },\n      });\n      const newBaseColl = await getBaseCollaboratorList(base.data.id);\n      const newSpaceColl = await getSpaceCollaboratorList(space2Id);\n      expect(newSpaceColl.data.collaborators).toHaveLength(3);\n      expect(newBaseColl.data.collaborators).toHaveLength(3);\n      expect(\n        (newBaseColl.data.collaborators as UserCollaboratorItem[]).find(\n          (v) => v.email === newUser3Email\n        )?.resourceType\n      ).toEqual(CollaboratorType.Space);\n    });\n\n    describe('operator collaborators', () => {\n      let newUser3Id: string;\n      beforeEach(async () => {\n        await userRequest.post(urlBuilder(EMAIL_SPACE_INVITATION, { spaceId: space2Id }), {\n          emails: [newUser3Email],\n          role: Role.Editor,\n        });\n        const res = await user3Request.get<IUserMeVo>(USER_ME);\n        newUser3Id = res.data.id;\n      });\n\n      it('/api/space/:spaceId/collaborators (PATCH)', async () => {\n        const res = await updateSpaceCollaborator({\n          spaceId: space2Id,\n          updateSpaceCollaborateRo: {\n            role: Role.Creator,\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        expect(res.status).toBe(200);\n      });\n\n      it('/api/space/:spaceId/collaborators (PATCH) - exceeds limit role', async () => {\n        const error = await getError(() =>\n          updateSpaceCollaborator({\n            spaceId: space2Id,\n            updateSpaceCollaborateRo: {\n              role: Role.Owner,\n              principalId: newUser3Id,\n              principalType: PrincipalType.User,\n            },\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/space/:spaceId/collaborators (PATCH) - last owner', async () => {\n        const error = await getError(() =>\n          userRequest.patch<void>(\n            urlBuilder(UPDATE_SPACE_COLLABORATE, {\n              spaceId: space2Id,\n            }),\n            {\n              role: Role.Editor,\n              principalId: userRequestId,\n              principalType: PrincipalType.User,\n            }\n          )\n        );\n        expect(error?.status).toBe(400);\n        expect(error?.message).toBe('Cannot change the role of the only owner of the space');\n      });\n\n      it('/api/space/:spaceId/collaborators (DELETE)', async () => {\n        const res = await deleteSpaceCollaborator({\n          spaceId: space2Id,\n          deleteSpaceCollaboratorRo: {\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        expect(res.status).toBe(200);\n        const collList = await apiGetSpaceCollaboratorList(space2Id);\n        expect(collList.data.collaborators).toHaveLength(2);\n      });\n\n      it('/api/space/:spaceId/collaborators (DELETE) - exceeds limit role', async () => {\n        await updateSpaceCollaborator({\n          spaceId: space2Id,\n          updateSpaceCollaborateRo: {\n            role: Role.Creator,\n            principalId: newUser3Id,\n            principalType: PrincipalType.User,\n          },\n        });\n        const error = await getError(() =>\n          deleteSpaceCollaborator({\n            spaceId: space2Id,\n            deleteSpaceCollaboratorRo: {\n              principalId: newUser3Id,\n              principalType: PrincipalType.User,\n            },\n          })\n        );\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/space/:spaceId/collaborators (DELETE) - self', async () => {\n        await deleteSpaceCollaborator({\n          spaceId: space2Id,\n          deleteSpaceCollaboratorRo: {\n            principalId: globalThis.testConfig.userId,\n            principalType: PrincipalType.User,\n          },\n        });\n        const error = await getError(() => apiGetSpaceCollaboratorList(space2Id));\n        expect(error?.status).toBe(403);\n      });\n\n      it('/api/space/:spaceId/collaborators (DELETE) - last owner', async () => {\n        const error = await getError(() =>\n          userRequest.delete(urlBuilder(DELETE_SPACE_COLLABORATOR, { spaceId: space2Id }), {\n            params: { principalId: userRequestId, principalType: PrincipalType.User },\n          })\n        );\n        expect(error?.status).toBe(400);\n        expect(error?.message).toBe('Cannot delete the only owner of the space');\n      });\n    });\n  });\n\n  describe('Space integrations', () => {\n    let spaceId: string;\n\n    const aiIntegrationConfig = {\n      llmProviders: [\n        {\n          type: LLMProviderType.OPENAI,\n          name: 'GPT',\n          apiKey: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n          baseUrl: 'https://api.openai.com/v1',\n          models: 'gpt-4o,gpt-4o-mini,text-embedding-3-small',\n        },\n      ],\n      embeddingModel: 'openai@text-embedding-3-small@GPT',\n      chatModel: {\n        lg: 'openai@gpt-4o@GPT',\n      },\n    };\n\n    beforeEach(async () => {\n      spaceId = (await createSpace({ name: 'Test Space' })).id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteSpace(spaceId);\n    });\n\n    it('/api/space/:spaceId/integration (GET)', async () => {\n      const integrations = (await getIntegrationList(spaceId)).data;\n\n      expect(integrations).toBeDefined();\n      expect(integrations[0].type).toBe(IntegrationType.AI);\n    });\n\n    it('/api/space/:spaceId/integration (POST)', async () => {\n      await createIntegration(spaceId, {\n        type: IntegrationType.AI,\n        config: aiIntegrationConfig,\n        enable: true,\n      });\n\n      const integrations = (await getIntegrationList(spaceId)).data;\n\n      expect(integrations).toBeDefined();\n      expect(integrations.length).toBe(1);\n    });\n\n    it('/api/space/:spaceId/integration/:integrationId (PATCH)', async () => {\n      await createIntegration(spaceId, {\n        type: IntegrationType.AI,\n        config: aiIntegrationConfig,\n        enable: false,\n      });\n\n      const originIntegrations = (await getIntegrationList(spaceId)).data;\n\n      await updateIntegration(spaceId, originIntegrations[0].id, {\n        enable: true,\n      });\n\n      const integrations = (await getIntegrationList(spaceId)).data;\n      expect(integrations).toBeDefined();\n      expect(integrations.length).toBe(1);\n      expect(integrations[0].enable).toBe(true);\n    });\n\n    it('/api/space/:spaceId/integration/:integrationId (DELETE)', async () => {\n      await createIntegration(spaceId, {\n        type: IntegrationType.AI,\n        config: aiIntegrationConfig,\n        enable: false,\n      });\n\n      const originIntegrations = (await getIntegrationList(spaceId)).data;\n\n      expect(originIntegrations).toBeDefined();\n      expect(originIntegrations.length).toBe(1);\n\n      await deleteIntegration(spaceId, originIntegrations[0].id);\n\n      const integrations = (await getIntegrationList(spaceId)).data;\n\n      expect(integrations.length).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-concurrency.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { DriverClient } from '@teable/core';\nimport { createTable, initApp, permanentDeleteTable } from './utils/init-app';\n\ndescribe('Table Creation Concurrency (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('should avoid db name collisions when creating tables concurrently', async () => {\n    if (globalThis.testConfig.driver !== DriverClient.Pg) {\n      return;\n    }\n\n    const sharedName = `Concurrent Table ${Math.random().toString(36).slice(2, 8)}`;\n    const createdTableIds: string[] = [];\n\n    try {\n      const createTasks = Array.from({ length: 3 }, () =>\n        createTable(baseId, { name: sharedName }, 201)\n      );\n      const results = await Promise.allSettled(createTasks);\n\n      const rejected = results.filter(\n        (result): result is PromiseRejectedResult => result.status === 'rejected'\n      );\n      expect(rejected.map((result) => result.reason)).toEqual([]);\n\n      const tables = results\n        .filter(\n          (result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof createTable>>> =>\n            result.status === 'fulfilled'\n        )\n        .map((result) => result.value);\n\n      createdTableIds.push(...tables.map((table) => table.id));\n\n      const dbTableNames = tables.map((table) => table.dbTableName);\n      expect(new Set(dbTableNames).size).toBe(tables.length);\n\n      const tableNames = tables.map((table) => table.name);\n      if (isForceV2) {\n        expect(tableNames).toEqual(Array.from({ length: tables.length }, () => sharedName));\n      } else {\n        expect(new Set(tableNames).size).toBe(tables.length);\n      }\n    } finally {\n      for (const tableId of createdTableIds) {\n        await permanentDeleteTable(baseId, tableId);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-duplicate.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable sonarjs/cognitive-complexity */\nimport type { INestApplication } from '@nestjs/common';\nimport type {\n  IButtonFieldCellValue,\n  IButtonFieldOptions,\n  IFieldVo,\n  IFilterRo,\n  ILinkFieldOptions,\n  IViewGroupRo,\n  IViewVo,\n} from '@teable/core';\nimport {\n  FieldType,\n  ViewType,\n  RowHeightLevel,\n  SortFunc,\n  FieldKeyType,\n  Colors,\n  generateWorkflowId,\n  Relationship,\n} from '@teable/core';\nimport type { ICreateBaseVo, IDuplicateTableVo, ITableFullVo } from '@teable/openapi';\nimport {\n  createField,\n  getFields,\n  duplicateTable,\n  installViewPlugin,\n  updateViewColumnMeta,\n  updateViewSort,\n  updateViewGroup,\n  updateViewOptions,\n  updateRecord,\n  getRecords,\n  buttonClick,\n  createBase,\n} from '@teable/openapi';\nimport { omit } from 'lodash';\nimport { x_20 } from './data-helpers/20x';\nimport { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';\n\nimport {\n  createTable,\n  permanentDeleteTable,\n  initApp,\n  getViews,\n  deleteField,\n  createView,\n  updateViewFilter,\n  convertField,\n} from './utils/init-app';\n\ndescribe('OpenAPI TableController for duplicate (e2e)', () => {\n  let app: INestApplication;\n  const baseId = globalThis.testConfig.baseId;\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n\n  const normalizeComparedField = <T extends Record<string, any>>(field: T) => {\n    const normalized = { ...field };\n    if (isForceV2 && normalized.isMultipleCellValue === false) {\n      delete normalized.isMultipleCellValue;\n    }\n    return normalized;\n  };\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('duplicate table with all kind field', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        // over 63 characters\n        name: 'record_query_long_long_long_long_long_long_long_long_long_long_long_long',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const singleTextField = table.fields.find((f) => f.name === 'text field')!;\n\n      await updateRecord(table.id, table.records[22].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [singleTextField.id]: 'Text Field 21',\n          },\n        },\n      });\n\n      await updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [singleTextField.id]: 'Text Field -1',\n          },\n        },\n      });\n\n      // convert field to notNull and unique, need to test constraint field duplicate\n      await convertField(table.id, singleTextField.id, {\n        dbFieldName: singleTextField.dbFieldName,\n        name: singleTextField.name,\n        options: singleTextField.options,\n        type: FieldType.SingleLineText,\n        notNull: true,\n        unique: true,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const subTableLinkField = subTable.fields.find((f) => f.type === FieldType.Link)!;\n\n      const linkField = (\n        await createField(table.id, {\n          name: 'link field',\n          type: FieldType.Link,\n          options: {\n            foreignTableId: subTable.id,\n            relationship: Relationship.ManyMany,\n          },\n        })\n      ).data;\n\n      // test changed link field\n      await convertField(table.id, linkField.id, {\n        dbFieldName: `${linkField.dbFieldName}_converted`,\n        name: linkField.name,\n        options: linkField.options,\n        type: FieldType.Link,\n      });\n\n      await createField(table.id, {\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: subTable.id,\n          linkFieldId: linkField.id,\n          lookupFieldId: subTableLinkField.id,\n        },\n        name: 'lookup link field',\n        type: FieldType.Link,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      table.views = await getViews(table.id);\n      subTable.fields = (await getFields(subTable.id)).data;\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: false,\n        })\n      ).data;\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it('should duplicate all fields and views', () => {\n      const { fields: sourceFields, views: sourceViews } = table;\n      const { fields: targetFields, views: targetViews, viewMap, fieldMap } = duplicateTableData;\n\n      expect(targetFields.length).toBe(sourceFields.length);\n      expect(sourceViews.length).toBe(targetViews.length);\n\n      let sourceViewsString = JSON.stringify(sourceViews);\n      let sourceFieldsString = JSON.stringify(sourceFields);\n      for (const [key, value] of Object.entries(viewMap)) {\n        sourceViewsString = sourceViewsString.replaceAll(key, value);\n        sourceFieldsString = sourceFieldsString.replaceAll(key, value);\n      }\n\n      for (const [key, value] of Object.entries(fieldMap)) {\n        sourceViewsString = sourceViewsString.replaceAll(key, value);\n        sourceFieldsString = sourceFieldsString.replaceAll(key, value);\n      }\n\n      const assertField = JSON.parse(sourceFieldsString) as IFieldVo[];\n      const assertViews = JSON.parse(sourceViewsString) as IViewVo[];\n\n      const assertLinkField = assertField\n        .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n        .map((f) => ({\n          ...f,\n          options: omit(\n            {\n              ...f.options,\n              // all be one way link\n              isOneWay: false,\n            },\n            ['fkHostTableName', 'selfKeyName', 'symmetricFieldId']\n          ),\n        }));\n      const duplicatedLinkField = targetFields\n        .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup)\n        .map((f) => ({\n          ...f,\n          options: omit(\n            {\n              ...f.options,\n              // all be one way link\n              isOneWay: false,\n            },\n            ['fkHostTableName', 'selfKeyName', 'symmetricFieldId']\n          ),\n        }));\n\n      const otherFieldsWithOutLink = assertField\n        .filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)\n        .map((f) =>\n          normalizeComparedField(\n            omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy'])\n          )\n        );\n      const otherAssertFieldsWithOutLink = targetFields\n        .filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)\n        .map((f) =>\n          normalizeComparedField(\n            omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy'])\n          )\n        );\n\n      const duplicatedViews = targetViews.map((v) =>\n        omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])\n      );\n\n      const assertPureViews = assertViews.map((v) =>\n        omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])\n      );\n\n      const sortById = (a: any, b: any) => a.id.localeCompare(b.id);\n\n      expect(assertPureViews).toEqual(duplicatedViews);\n      expect(assertLinkField).toEqual(duplicatedLinkField);\n      expect(otherFieldsWithOutLink.sort(sortById)).toEqual(\n        otherAssertFieldsWithOutLink.sort(sortById)\n      );\n    });\n    // it.skip('should create a link field in linked table when link field is two-way-link', async () => {\n    //   const fields = (await getFields(subTable.id)).data;\n    //   const { fields: targetFields } = duplicateTableData;\n    //   const assertField = targetFields.find(({ type }) => type === FieldType.Link)!;\n    //   const duplicatedLinkField = fields.find(\n    //     (f) =>\n    //       f.type === FieldType.Link &&\n    //       (f.options as ILinkFieldOptions).symmetricFieldId === assertField.id!\n    //   );\n    //   expect(duplicatedLinkField).toBeDefined();\n    // });\n  });\n\n  describe('duplicate table with error field(formula or lookup field)', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    let lookupField: IFieldVo;\n    let formulaField: IFieldVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      table.views = await getViews(table.id);\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      const primaryField = table.fields.find((f) => f.isPrimary)!;\n      const numberField = table.fields.find((f) => f.type === FieldType.Number)!;\n      const linkField = table.fields.find((f) => f.type === FieldType.Link)!;\n      const lookupedField = subTable.fields.find((f) => f.type === FieldType.Number)!;\n\n      // create a formula field and a lookup field both in degree same field, then delete the field, causing field hasError\n      formulaField = (\n        await createField(table.id, {\n          name: 'error_formulaField',\n          type: FieldType.Formula,\n          options: {\n            expression: `{${primaryField.id}}+{${numberField.id}}`,\n            timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n          },\n        })\n      ).data;\n      lookupField = (\n        await createField(table.id, {\n          name: 'error_lookupField',\n          type: lookupedField.type,\n          isLookup: true,\n          lookupOptions: {\n            foreignTableId: subTable.id,\n            linkFieldId: linkField.id,\n            lookupFieldId: lookupedField.id,\n          },\n        })\n      ).data;\n\n      await deleteField(table.id, numberField.id);\n      await deleteField(subTable.id, lookupedField.id);\n\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: false,\n        })\n      ).data;\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it('duplicated formula and lookup field should has error', async () => {\n      const sourceFields = (await getFields(table.id)).data;\n\n      const { fields: targetFields, fieldMap } = duplicateTableData;\n      const sourceErrorFormulaField = sourceFields.find((f) => f.id === formulaField.id);\n      const sourceErrorLookupField = sourceFields.find((f) => f.id === lookupField.id);\n      expect(sourceErrorFormulaField?.hasError).toBe(true);\n      expect(sourceErrorLookupField?.hasError).toBe(true);\n\n      const targetErrorFormulaField = targetFields.find((f) => f.id === fieldMap[formulaField.id]);\n      const targetErrorLookupField = targetFields.find((f) => f.id === fieldMap[lookupField.id]);\n      expect(targetErrorFormulaField?.hasError).toBe(true);\n      expect(targetErrorLookupField?.hasError).toBe(true);\n\n      let assertErrorFormulaFieldString = JSON.stringify(sourceErrorFormulaField);\n      // let assertErrorLookupFieldString = JSON.stringify(sourceErrorLookupField);\n      for (const [key, value] of Object.entries(fieldMap)) {\n        assertErrorFormulaFieldString = assertErrorFormulaFieldString.replaceAll(key, value);\n        // assertErrorLookupFieldString = assertErrorLookupFieldString.replaceAll(key, value);\n      }\n\n      const assertErrorFormulaField = JSON.parse(assertErrorFormulaFieldString);\n      // const assertErrorLookupField = JSON.parse(assertErrorLookupFieldString);\n      expect(assertErrorFormulaField).toEqual(targetErrorFormulaField);\n      expect(targetErrorLookupField?.hasError).toBe(true);\n    });\n  });\n\n  describe('duplicate table with self link', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      table.views = await getViews(table.id);\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      await createField(table.id, {\n        name: 'self_link',\n        type: FieldType.Link,\n        options: {\n          visibleFieldIds: null,\n          foreignTableId: table.id,\n          relationship: Relationship.ManyMany,\n          filter: null,\n          filterByViewId: null,\n        },\n      });\n\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: false,\n        })\n      ).data;\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it('should duplicate self link fields', async () => {\n      const { fields, id } = duplicateTableData;\n\n      const selfLinkFields = fields.filter(\n        (f) => f.type === FieldType.Link && (f.options as ILinkFieldOptions)?.foreignTableId === id\n      );\n\n      expect(selfLinkFields.length).toBe(2);\n      expect((selfLinkFields[0].options as ILinkFieldOptions).fkHostTableName).toBe(\n        (selfLinkFields[1].options as ILinkFieldOptions).fkHostTableName\n      );\n    });\n  });\n\n  describe('duplicate table with all type view', () => {\n    let table: ITableFullVo;\n    let subTable: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n\n      const x20Link = x_20_link(table);\n      subTable = await createTable(baseId, {\n        name: 'lookup_filter_x_20',\n        fields: x20Link.fields,\n        records: x20Link.records,\n      });\n\n      const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);\n      for (const field of x20LinkFromLookups.fields) {\n        await createField(subTable.id, field);\n      }\n\n      table.fields = (await getFields(table.id)).data;\n      table.views = await getViews(table.id);\n      subTable.fields = (await getFields(subTable.id)).data;\n\n      await createField(table.id, {\n        name: 'self_link',\n        type: FieldType.Link,\n        options: {\n          visibleFieldIds: null,\n          foreignTableId: table.id,\n          relationship: Relationship.ManyMany,\n          filter: null,\n          filterByViewId: null,\n        },\n      });\n    });\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, subTable.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it('should duplicate all kind of views', async () => {\n      const gridView = (await getViews(table.id))[0];\n\n      const filterRo: IFilterRo = {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: table.fields.find((f) => f.isPrimary)!.id,\n              operator: 'contains',\n              value: 'text field',\n            },\n            {\n              conjunction: 'and',\n              filterSet: [\n                {\n                  fieldId: table.fields.find((f) => f.type === FieldType.Number)!.id,\n                  operator: 'isGreater',\n                  value: 1,\n                },\n              ],\n            },\n            {\n              fieldId: table.fields.find((f) => f.type === FieldType.SingleSelect)!.id,\n              operator: 'is',\n              value: 'x',\n            },\n            {\n              fieldId: table.fields.find((f) => f.type === FieldType.Checkbox)!.id,\n              operator: 'is',\n              value: null,\n            },\n          ],\n        },\n      };\n\n      const groupRo: IViewGroupRo = {\n        group: [\n          {\n            fieldId: table.fields.find((f) => f.isPrimary)!.id,\n            order: SortFunc.Asc,\n          },\n        ],\n      };\n\n      const sortRo = {\n        sort: {\n          sortObjs: [\n            {\n              fieldId: table.fields.find((f) => f.type === FieldType.MultipleSelect)!.id,\n              order: SortFunc.Asc,\n            },\n            {\n              fieldId: table.fields.find((f) => f.type === FieldType.Formula)!.id,\n              order: SortFunc.Desc,\n            },\n          ],\n        },\n      };\n\n      await createView(table.id, {\n        name: 'gallery',\n        type: ViewType.Gallery,\n        filter: filterRo.filter,\n        group: groupRo.group,\n        sort: sortRo.sort,\n        enableShare: true,\n      });\n\n      await createView(table.id, {\n        name: 'kanban',\n        type: ViewType.Kanban,\n        group: groupRo.group,\n        sort: sortRo.sort,\n        options: {\n          stackFieldId: table.fields.find((f) => f.isPrimary)!.id,\n        },\n      });\n\n      await createView(table.id, {\n        name: 'calendar',\n        type: ViewType.Calendar,\n        filter: filterRo.filter,\n      });\n\n      await createView(table.id, {\n        name: 'table',\n        type: ViewType.Form,\n        columnMeta: {\n          [table.fields.find((f) => f.isPrimary)!.id]: {\n            visible: true,\n            order: 1,\n          },\n          [table.fields.find((f) => f.type === FieldType.Number)!.id]: {\n            visible: true,\n            order: 2,\n          },\n          [table.fields.find((f) => f.type === FieldType.SingleSelect)!.id]: {\n            visible: true,\n            order: 3,\n          },\n        },\n      });\n\n      await installViewPlugin(table.id, {\n        name: 'sheet',\n        pluginId: 'plgsheetform',\n      });\n\n      await updateViewFilter(table.id, gridView.id, filterRo);\n\n      await updateViewColumnMeta(table.id, gridView.id, [\n        {\n          fieldId: table.fields.find((f) => f.type === FieldType.User)!.id,\n          columnMeta: { hidden: true },\n        },\n      ]);\n\n      await updateViewSort(table.id, gridView.id, sortRo);\n\n      await updateViewGroup(table.id, gridView.id, groupRo);\n\n      await updateViewOptions(table.id, gridView.id, {\n        options: {\n          rowHeight: RowHeightLevel.Tall,\n        },\n      });\n\n      const sourceViews = await getViews(table.id);\n\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: false,\n        })\n      ).data;\n\n      const targetViews = await getViews(duplicateTableData.id);\n\n      const { fieldMap } = duplicateTableData;\n      expect(sourceViews.length).toBe(targetViews.length);\n      let assertViewsString = JSON.stringify(\n        sourceViews\n          .filter((f) => f.type !== ViewType.Plugin)\n          .map((v) => ({\n            ...omit(v, [\n              'createdBy',\n              'createdTime',\n              'lastModifiedBy',\n              'lastModifiedTime',\n              'shareId',\n              'id',\n            ]),\n            options: omit(v.options, ['pluginId', 'pluginInstallId']),\n          }))\n      );\n\n      for (const [key, value] of Object.entries(fieldMap)) {\n        assertViewsString = assertViewsString.replaceAll(key, value);\n      }\n\n      const assertViews = JSON.parse(assertViewsString);\n\n      expect(assertViews).toEqual(\n        targetViews\n          .filter((f) => f.type !== ViewType.Plugin)\n          .map((v) => ({\n            ...omit(v, [\n              'createdBy',\n              'createdTime',\n              'lastModifiedBy',\n              'lastModifiedTime',\n              'shareId',\n              'id',\n            ]),\n            options: omit(v.options, ['pluginId', 'pluginInstallId']),\n          }))\n      );\n    });\n  });\n\n  describe('duplicate formula field relative', () => {\n    let table: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'mainTable',\n      });\n\n      const numberField = table.fields.find((f) => f.type === FieldType.Number)!;\n\n      await createField(table.id, {\n        name: 'formulaField',\n        type: FieldType.Formula,\n        options: {\n          expression: `{${numberField.id}}`,\n          timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      });\n\n      await updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [numberField.id]: 1,\n          },\n        },\n      });\n\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: true,\n        })\n      ).data;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it.skip('should duplicate formula field calculate normally', async () => {\n      const { id, fields } = duplicateTableData;\n      const waitForFormula = async (timeoutMs = 15000) => {\n        const start = Date.now();\n        while (Date.now() - start < timeoutMs) {\n          const recs = (await getRecords(id)).data.records;\n          if (\n            recs?.[0]?.fields?.[fields.find((f) => f.type === FieldType.Formula)!.name] !==\n            undefined\n          ) {\n            return recs;\n          }\n          await new Promise((r) => setTimeout(r, 200));\n        }\n        throw new Error('Timed out waiting for duplicated formula value');\n      };\n      const records = await waitForFormula();\n\n      const numberField = fields.find((f) => f.type === FieldType.Number)!;\n      const formulaField = fields.find((f) => f.type === FieldType.Formula)!;\n      expect(records[0].fields[formulaField.name]).toBe(1);\n      await updateRecord(id, records[2].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [numberField.id]: 3,\n          },\n        },\n      });\n\n      const newRecords = (await getRecords(id)).data.records;\n      expect(newRecords[0].fields[formulaField.name]).toBe(1);\n      expect(newRecords[2].fields[formulaField.name]).toBe(3);\n    });\n  });\n\n  describe('duplicate table with cross base link field', () => {\n    let table: ITableFullVo;\n    let base2: ICreateBaseVo;\n    let crossBaseTable: ITableFullVo;\n    beforeAll(async () => {\n      base2 = (\n        await createBase({\n          spaceId: globalThis.testConfig.spaceId,\n          name: 'base2',\n        })\n      ).data;\n\n      table = await createTable(baseId, {\n        name: 'mainTable',\n      });\n\n      crossBaseTable = await createTable(base2.id, {\n        name: 'crossBaseTable',\n      });\n\n      await createField(table.id, {\n        name: 'crossBaseLinkField',\n        type: FieldType.Link,\n        options: {\n          baseId: base2.id,\n          foreignTableId: crossBaseTable.id,\n          relationship: Relationship.ManyOne,\n          lookupFieldId: crossBaseTable.fields[0].id,\n          isOneWay: false,\n        },\n      });\n    });\n\n    it('should duplicate cross base link field', async () => {\n      const duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: true,\n        })\n      ).data;\n\n      const linkField = duplicateTableData.fields.find((f) => f.type === FieldType.Link)!;\n      expect((linkField.options as ILinkFieldOptions).baseId).toBe(base2.id);\n      expect((linkField.options as ILinkFieldOptions).foreignTableId).toBe(crossBaseTable.id);\n      expect((linkField.options as ILinkFieldOptions).isOneWay).toBe(true);\n    });\n  });\n\n  describe('duplicate table with button field', () => {\n    let table: ITableFullVo;\n    let duplicateTableData: IDuplicateTableVo;\n    beforeAll(async () => {\n      table = await createTable(baseId, {\n        name: 'mainTable',\n      });\n\n      const field = (\n        await createField(table.id, {\n          type: FieldType.Button,\n          options: {\n            label: 'click me',\n            color: Colors.Teal,\n            workflow: {\n              id: generateWorkflowId(),\n              name: 'test',\n              isActive: true,\n            },\n          },\n        })\n      ).data;\n\n      const res = await buttonClick(table.id, table.records[0].id, field.id);\n      const value = res.data.record.fields[field.id] as IButtonFieldCellValue;\n      expect(value.count).toEqual(1);\n\n      duplicateTableData = (\n        await duplicateTable(baseId, table.id, {\n          name: 'duplicated_table',\n          includeRecords: true,\n        })\n      ).data;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, duplicateTableData.id);\n    });\n\n    it('should duplicate button field without workflow and clear click count', async () => {\n      const { id, fields } = duplicateTableData;\n\n      const buttonField = fields.find((f) => f.type === FieldType.Button)!;\n      expect((buttonField.options as IButtonFieldOptions).workflow).toBeUndefined();\n\n      const records = (\n        await getRecords(id, {\n          fieldKeyType: FieldKeyType.Id,\n        })\n      ).data.records;\n      expect(records[0].fields[buttonField.id]).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-export.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldVo, IViewRo } from '@teable/core';\nimport { FieldType, Colors, Relationship, ViewType, DriverClient, SortFunc } from '@teable/core';\nimport type { INotifyVo } from '@teable/openapi';\nimport {\n  exportCsvFromTable as apiExportCsvFromTable,\n  createTable as apiCreateTable,\n  createField as apiCreateField,\n  getSignature as apiGetSignature,\n  uploadFile as apiUploadFile,\n  notify as apiNotify,\n  createRecords as apiCreateRecords,\n  deleteTable as apiDeleteTable,\n  UploadType,\n} from '@teable/openapi';\n\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { createView, initApp, getTable } from './utils/init-app';\n\nlet app: INestApplication;\nconst baseId = globalThis.testConfig.baseId;\nconst userId = globalThis.testConfig.userId;\nlet txtFileData: INotifyVo;\nconst contentDispositionKey = 'content-disposition';\nconst contentTypeKey = 'content-type';\n\nconst subFields = [\n  {\n    type: FieldType.SingleLineText,\n    name: 'sub_Name',\n  },\n  {\n    type: FieldType.Number,\n    name: 'sub_Number',\n  },\n  {\n    type: FieldType.Checkbox,\n    name: 'sub_Checkbox',\n  },\n  {\n    type: FieldType.SingleSelect,\n    name: 'sub_SingleSelect',\n    options: {\n      choices: [\n        { id: 'choX', name: 'sub_x', color: Colors.Cyan },\n        { id: 'choY', name: 'sub_y', color: Colors.Blue },\n        { id: 'choZ', name: 'sub_z', color: Colors.Gray },\n      ],\n    },\n  },\n];\n\nconst mainFields = [\n  {\n    type: FieldType.Number,\n    name: 'Number field',\n  },\n  {\n    type: FieldType.Checkbox,\n    name: 'Checkbox field',\n  },\n  {\n    type: FieldType.SingleSelect,\n    name: 'Select field',\n    options: {\n      choices: [\n        { id: 'choX', name: 'x', color: Colors.Cyan },\n        { id: 'choY', name: 'y', color: Colors.Blue },\n        { id: 'choZ', name: 'z', color: Colors.Gray },\n      ],\n    },\n  },\n  {\n    type: FieldType.Date,\n    name: 'Date field',\n    options: {\n      formatting: {\n        timeZone: 'Asia/Shanghai',\n        date: 'MMMM D, YYYY',\n        time: 'None',\n      },\n    },\n  },\n  {\n    type: FieldType.Attachment,\n    name: 'Attachment field',\n  },\n  {\n    type: FieldType.User,\n    name: 'User Field',\n    options: {\n      isMultiple: false,\n      shouldNotify: false,\n    },\n  },\n];\n\nconst createTables = async (mainTableName?: string, subTableName?: string) => {\n  const finalMainTableName = mainTableName ?? 'mainTable';\n  const finalSubTableName = subTableName ?? 'subTable';\n  const mainTable = await apiCreateTable(baseId, {\n    name: finalMainTableName,\n    fields: [\n      {\n        type: FieldType.SingleLineText,\n        name: 'Text field',\n      },\n    ],\n    records: [],\n  });\n\n  for (let i = 0; i < mainFields.length; i++) {\n    await apiCreateField(mainTable.data.id, mainFields[i]);\n  }\n\n  const subTable = await apiCreateTable(baseId, {\n    name: finalSubTableName,\n    fields: subFields,\n    records: [\n      {\n        fields: {\n          ['sub_Name']: 'Name1',\n          ['sub_Number']: 1,\n          ['sub_Checkbox']: true,\n          ['sub_SingleSelect']: 'sub_y',\n        },\n      },\n      {\n        fields: {\n          ['sub_Name']: 'Name2',\n          ['sub_Number']: 2,\n          ['sub_Checkbox']: true,\n          ['sub_SingleSelect']: 'sub_x',\n        },\n      },\n      {\n        fields: {\n          ['sub_Name']: 'Name3',\n          ['sub_Number']: 3,\n        },\n      },\n    ],\n  });\n\n  const {\n    data: { id: linkFieldId },\n  } = await apiCreateField(mainTable.data.id, {\n    type: FieldType.Link,\n    name: 'Link field',\n    options: {\n      relationship: Relationship.ManyMany,\n      foreignTableId: subTable.data.id,\n      isOneWay: false,\n    },\n  });\n\n  for (let i = 0; i < subFields.length; i++) {\n    const { name, type } = subFields[i];\n    await apiCreateField(mainTable.data.id, {\n      name: `Link field from lookups ${name}`,\n      type: type,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: subTable.data.id,\n        lookupFieldId: subTable.data.fields[i].id,\n        linkFieldId: linkFieldId,\n      },\n    });\n  }\n\n  await createRecordsWithLink(mainTable.data.id, subTable.data.records[0].id);\n\n  const latestMainTable = await getTable(baseId, mainTable.data.id, { includeContent: true });\n  const latestSubTable = await getTable(baseId, subTable.data.id, { includeContent: true });\n\n  return { mainTable: latestMainTable, subTable: latestSubTable };\n};\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n\n  const format = 'txt';\n  const tmpPath = path.resolve(path.join(StorageAdapter.TEMPORARY_DIR, `test.${format}`));\n  const txtData = `field_1,field_2,field_3,field_4,field_5,field_6\n  1,string_1,true,2022-11-10 16:00:00,,\"long\n  text\"\n  2,string_2,false,2022-11-11 16:00:00,,`;\n  const contentType = 'text/plain';\n\n  fs.writeFileSync(tmpPath, txtData);\n\n  const file = fs.readFileSync(tmpPath);\n  const stats = fs.statSync(tmpPath);\n\n  const { token, requestHeaders } = (\n    await apiGetSignature(\n      {\n        type: UploadType.Import,\n        contentLength: stats.size,\n        contentType: contentType,\n      },\n      undefined\n    )\n  ).data;\n\n  await apiUploadFile(token, file, requestHeaders);\n\n  const { data } = await apiNotify(token);\n  txtFileData = data;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\nconst createRecordsWithLink = async (mainTableId: string, subTableId: string) => {\n  return apiCreateRecords(mainTableId, {\n    typecast: true,\n    records: [\n      {\n        fields: {\n          ['Attachment field']: [{ ...txtFileData, id: 'actxxxxxx', name: 'test.txt' }],\n          ['Date field']: '2022-11-28',\n          ['Text field']: 'txt1',\n          ['Number field']: 1,\n          ['Checkbox field']: true,\n          ['Select field']: 'x',\n          ['Link field']: [\n            {\n              id: subTableId,\n            },\n          ],\n        },\n      },\n      {\n        fields: {\n          ['Date field']: '2022-11-28',\n          ['Text field']: 'txt2',\n          ['Select field']: 'y',\n          ['User Field']: {\n            title: 'test',\n            id: userId,\n          },\n        },\n      },\n      {\n        fields: {\n          ['Select field']: 'z',\n          ['Checkbox field']: true,\n        },\n      },\n    ],\n  });\n};\n\ndescribe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(\n  '/export/${tableId} OpenAPI ExportController (e2e) Get csv stream from table (Get) ',\n  () => {\n    it(`should return a csv stream from table and compatible all fields`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const exportRes = await apiExportCsvFromTable(mainTable.id);\n      const disposition = exportRes?.headers[contentDispositionKey];\n      const contentType = exportRes?.headers[contentTypeKey];\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      expect(disposition).toBe(`attachment; filename=${encodeURIComponent(mainTable.name)}.csv`);\n      expect(contentType).toBe('text/csv; charset=utf-8');\n      expect(csvData).toBe(\n        `Text field,Number field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\\r\\ntxt1,1.00,true,x,\"November 28, 2022\",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y\\r\\ntxt2,,,y,\"November 28, 2022\",,test,,,,,\\r\\n,,true,z,,,,,,,,`\n      );\n    });\n\n    it(`should return a csv stream from table with special character table name`, async () => {\n      const { mainTable, subTable } = await createTables('测试😄', 'subTable');\n\n      const exportRes = await apiExportCsvFromTable(mainTable.id);\n      const disposition = exportRes?.headers['content-disposition'];\n      const contentType = exportRes?.headers['content-type'];\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      expect(disposition).toBe(`attachment; filename=${encodeURIComponent(mainTable.name)}.csv`);\n      expect(contentType).toBe('text/csv; charset=utf-8');\n      expect(csvData).toBe(\n        `Text field,Number field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\\r\\ntxt1,1.00,true,x,\"November 28, 2022\",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y\\r\\ntxt2,,,y,\"November 28, 2022\",,test,,,,,\\r\\n,,true,z,,,,,,,,`\n      );\n    });\n\n    it(`should return a csv stream from a particular view`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const numberField = mainTable?.fields?.find(\n        (field) => field.name === 'Number field'\n      ) as IFieldVo;\n\n      const oldColumnMeta = mainTable?.views?.[0]?.columnMeta;\n      const view2 = await createView(mainTable.id, {\n        columnMeta: {\n          ...oldColumnMeta,\n          [numberField.id]: {\n            ...oldColumnMeta?.[numberField.id],\n            order: 0.5,\n          },\n        },\n        type: ViewType.Grid,\n      });\n\n      const exportRes = await apiExportCsvFromTable(mainTable.id, { viewId: view2.id });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      expect(csvData).toBe(\n        `Text field,Number field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\\r\\ntxt1,1.00,true,x,\"November 28, 2022\",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y\\r\\ntxt2,,,y,\"November 28, 2022\",,test,,,,,\\r\\n,,true,z,,,,,,,,`\n      );\n    });\n\n    it(`should return a csv stream without hidden fields`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const numberField = mainTable?.fields?.find(\n        (field) => field.name === 'Number field'\n      ) as IFieldVo;\n\n      const oldColumnMeta = mainTable?.views?.[0]?.columnMeta;\n      const view2 = await createView(mainTable.id, {\n        columnMeta: {\n          ...oldColumnMeta,\n          [numberField.id]: {\n            ...oldColumnMeta?.[numberField.id],\n            hidden: true,\n          },\n        } as IViewRo['columnMeta'],\n        type: ViewType.Grid,\n      });\n\n      const exportRes = await apiExportCsvFromTable(mainTable.id, { viewId: view2.id });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      expect(csvData).toBe(\n        `Text field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\\r\\ntxt1,true,x,\"November 28, 2022\",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y\\r\\ntxt2,,y,\"November 28, 2022\",,test,,,,,\\r\\n,true,z,,,,,,,,`\n      );\n    });\n\n    it(`should return a csv stream with filter parameter (personal view filter)`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;\n\n      // Export with filter to only include records where Text field = 'txt1'\n      const exportRes = await apiExportCsvFromTable(mainTable.id, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: textField.id,\n              operator: 'is',\n              value: 'txt1',\n            },\n          ],\n        },\n      });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      // Should only contain the first record with txt1\n      expect(csvData).toBe(\n        `Text field,Number field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\\r\\ntxt1,1.00,true,x,\"November 28, 2022\",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y`\n      );\n    });\n\n    it(`should return a csv stream with projection parameter (only specified fields)`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;\n      const numberField = mainTable?.fields?.find((f) => f.name === 'Number field') as IFieldVo;\n      const selectField = mainTable?.fields?.find((f) => f.name === 'Select field') as IFieldVo;\n\n      // Export with projection to only include specific fields\n      const exportRes = await apiExportCsvFromTable(mainTable.id, {\n        projection: [textField.id, numberField.id, selectField.id],\n      });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      // Should only contain the specified fields in projection order\n      expect(csvData).toBe(`Text field,Number field,Select field\\r\\ntxt1,1.00,x\\r\\ntxt2,,y\\r\\n,,z`);\n    });\n\n    it(`should return a csv stream with orderBy parameter (sorted export)`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;\n\n      // Export with orderBy to sort by Text field descending\n      const exportRes = await apiExportCsvFromTable(mainTable.id, {\n        orderBy: [\n          {\n            fieldId: textField.id,\n            order: SortFunc.Desc,\n          },\n        ],\n        projection: [textField.id], // Use projection to simplify test assertion\n      });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      // Records should be sorted: txt2, txt1, empty\n      expect(csvData).toBe(`Text field\\r\\ntxt2\\r\\ntxt1\\r\\n`);\n    });\n\n    it(`should return a csv stream with ignoreViewQuery parameter (ignore view filter)`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;\n\n      // Create a view with filter\n      const viewWithFilter = await createView(mainTable.id, {\n        type: ViewType.Grid,\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: textField.id,\n              operator: 'is',\n              value: 'txt1',\n            },\n          ],\n        },\n      });\n\n      // Export with ignoreViewQuery=true should return all records despite view filter\n      const exportRes = await apiExportCsvFromTable(mainTable.id, {\n        viewId: viewWithFilter.id,\n        ignoreViewQuery: true,\n        projection: [textField.id],\n      });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      // Should return all records since view query is ignored\n      expect(csvData).toBe(`Text field\\r\\ntxt1\\r\\ntxt2\\r\\n`);\n    });\n\n    it(`should return a csv stream with combined filter and projection (personal view scenario)`, async () => {\n      const { mainTable, subTable } = await createTables();\n\n      const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;\n      const selectField = mainTable?.fields?.find((f) => f.name === 'Select field') as IFieldVo;\n      const numberField = mainTable?.fields?.find((f) => f.name === 'Number field') as IFieldVo;\n\n      // Simulate personal view export with filter + projection + orderBy\n      const exportRes = await apiExportCsvFromTable(mainTable.id, {\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: selectField.id,\n              operator: 'isAnyOf',\n              value: ['x', 'y'],\n            },\n          ],\n        },\n        projection: [textField.id, numberField.id, selectField.id],\n        orderBy: [\n          {\n            fieldId: textField.id,\n            order: SortFunc.Asc,\n          },\n        ],\n      });\n      const { data: csvData } = exportRes;\n\n      await apiDeleteTable(baseId, mainTable.id);\n      await apiDeleteTable(baseId, subTable.id);\n\n      // Should only return records with select 'x' or 'y', sorted by text field ascending\n      expect(csvData).toBe(`Text field,Number field,Select field\\r\\ntxt1,1.00,x\\r\\ntxt2,,y`);\n    });\n  }\n);\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-import.e2e-spec.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, TimeFormatting, defaultDatetimeFormatting } from '@teable/core';\nimport type { IInplaceImportOptionRo } from '@teable/openapi';\nimport {\n  getSignature as apiGetSignature,\n  uploadFile as apiUploadFile,\n  notify as apiNotify,\n  analyzeFile as apiAnalyzeFile,\n  importTableFromFile as apiImportTableFromFile,\n  getImportStatus as apiGetImportStatus,\n  createBase as apiCreateBase,\n  createSpace as apiCreateSpace,\n  deleteBase as apiDeleteBase,\n  createTable as apiCreateTable,\n  inplaceImportTableFromFile as apiInplaceImportTableFromFile,\n  SUPPORTEDTYPE,\n  UploadType,\n} from '@teable/openapi';\nimport dayjs, { extend } from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport { noop } from 'lodash';\nimport * as XLSX from 'xlsx';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { CsvImporter } from '../src/features/import/open-api/import.class';\nimport { createAwaitWithEventWithResult } from './utils/event-promise';\nimport { initApp, permanentDeleteTable, getTable as apiGetTableById } from './utils/init-app';\n\nextend(timezone);\n\nenum TestFileFormat {\n  'CSV' = 'csv',\n  'TSV' = 'tsv',\n  'TXT' = 'txt',\n  'XLSX' = 'xlsx',\n}\n\nconst defaultTestSheetKey = 'Sheet1';\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst testSupportTypeMap = {\n  [TestFileFormat.CSV]: {\n    fileType: SUPPORTEDTYPE.CSV,\n    defaultSheetKey: CsvImporter.DEFAULT_SHEETKEY,\n  },\n  [TestFileFormat.TSV]: {\n    fileType: SUPPORTEDTYPE.CSV,\n    defaultSheetKey: CsvImporter.DEFAULT_SHEETKEY,\n  },\n  [TestFileFormat.TXT]: {\n    fileType: SUPPORTEDTYPE.CSV,\n    defaultSheetKey: CsvImporter.DEFAULT_SHEETKEY,\n  },\n  [TestFileFormat.XLSX]: {\n    fileType: SUPPORTEDTYPE.EXCEL,\n    defaultSheetKey: defaultTestSheetKey,\n  },\n};\n\nconst testFileFormats = [\n  TestFileFormat.CSV,\n  TestFileFormat.TSV,\n  TestFileFormat.TXT,\n  TestFileFormat.XLSX,\n];\n\ninterface ITestFile {\n  [key: string]: {\n    path: string;\n    url: string;\n  };\n}\nconst data = `field_1,field_2,field_3,field_4,field_5,field_6\n1,string_1,true,2022-11-10 16:00:00,,\"long\ntext\"\n2,string_2,\"false\",2022-11-11 16:00:00,,`;\nconst tsvData = `field_1\tfield_2\tfield_3\tfield_4\tfield_5\tfield_6\n1\tstring_1\ttrue\t2022-11-10 16:00:00\t\t\"long\\ntext\"\n2\tstring_2\tfalse\t2022-11-11 16:00:00\t\t`;\nconst workbook = XLSX.utils.book_new();\n\nconst worksheet = XLSX.utils.aoa_to_sheet([\n  ['field_1', 'field_2', 'field_3', 'field_4', 'field_5', 'field_6'],\n  [1, 'string_1', true, '2022-11-10 16:00:00', '', `long\\ntext`],\n  [2, 'string_2', false, '2022-11-11 16:00:00', '', ''],\n]);\n\nXLSX.utils.book_append_sheet(workbook, worksheet, defaultTestSheetKey);\n\nlet app: INestApplication;\nlet testFiles: ITestFile = {};\nconst genTestFiles = async () => {\n  const result: ITestFile = {};\n  const fileDataMap = {\n    [TestFileFormat.CSV]: data,\n    [TestFileFormat.TSV]: tsvData,\n    [TestFileFormat.TXT]: data,\n    [TestFileFormat.XLSX]: await XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }),\n  };\n  const contentTypeMap = {\n    [TestFileFormat.CSV]: 'text/csv',\n    [TestFileFormat.TSV]: 'text/tab-separated-values',\n    [TestFileFormat.TXT]: 'text/plain',\n    [TestFileFormat.XLSX]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n  };\n  for (let i = 0; i < testFileFormats.length; i++) {\n    const format = testFileFormats[i];\n    const tmpPath = path.resolve(path.join(StorageAdapter.TEMPORARY_DIR, `test.${format}`));\n    const data = fileDataMap[format];\n    const contentType = contentTypeMap[format];\n\n    fs.writeFileSync(tmpPath, data);\n\n    const file = fs.createReadStream(tmpPath);\n    const stats = fs.statSync(tmpPath);\n\n    const { token, requestHeaders } = (\n      await apiGetSignature(\n        {\n          type: UploadType.Import,\n          contentLength: stats.size,\n          contentType: contentType,\n        },\n        undefined\n      )\n    ).data;\n\n    await apiUploadFile(token, file, requestHeaders);\n\n    const {\n      data: { presignedUrl },\n    } = await apiNotify(token, undefined, 'Import Table.csv');\n\n    result[format] = {\n      path: tmpPath,\n      url: presignedUrl,\n    };\n  }\n  return result;\n};\n\nconst assertHeaders = [\n  {\n    type: 'number',\n    name: 'field_1',\n  },\n  {\n    type: 'singleLineText',\n    name: 'field_2',\n  },\n  {\n    type: 'checkbox',\n    name: 'field_3',\n  },\n  {\n    type: 'date',\n    name: 'field_4',\n  },\n  {\n    type: 'singleLineText',\n    name: 'field_5',\n  },\n  {\n    type: 'longText',\n    name: 'field_6',\n  },\n];\n\ndescribe('OpenAPI ImportController (e2e)', () => {\n  const bases: [string, string][] = [];\n  let eventEmitterService: EventEmitterService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n    testFiles = await genTestFiles();\n  });\n\n  afterAll(async () => {\n    testFileFormats.forEach((type) => {\n      fs.unlink(testFiles[type].path, (err) => {\n        if (err) throw err;\n        console.log(`delete ${type} test file success!`);\n      });\n    });\n    for (let i = 0; i < bases.length; i++) {\n      const [baseId, id] = bases[i];\n      await permanentDeleteTable(baseId, id);\n      await apiDeleteBase(baseId);\n    }\n    await app.close();\n  });\n\n  describe('/import/analyze OpenAPI ImportController (e2e) Get a column info from analyze sheet (Get) ', () => {\n    it(`should return column header info from csv file`, async () => {\n      const {\n        data: { worksheets },\n      } = await apiAnalyzeFile({\n        attachmentUrl: testFiles[TestFileFormat.CSV].url,\n        fileType: SUPPORTEDTYPE.CSV,\n      });\n      const calculatedColumnHeaders = worksheets[CsvImporter.DEFAULT_SHEETKEY].columns;\n      expect(calculatedColumnHeaders).toEqual(assertHeaders);\n    });\n\n    it(`should return 400, when url file type is not csv`, async () => {\n      await expect(\n        apiAnalyzeFile({\n          attachmentUrl: testFiles[TestFileFormat.TXT].url,\n          fileType: SUPPORTEDTYPE.CSV,\n        })\n      ).rejects.toMatchObject({\n        status: 400,\n        code: 'validation_error',\n      });\n    });\n\n    it(`should return column header info from excel file`, async () => {\n      const {\n        data: { worksheets },\n      } = await apiAnalyzeFile({\n        attachmentUrl: testFiles[TestFileFormat.XLSX].url,\n        fileType: SUPPORTEDTYPE.EXCEL,\n      });\n      const calculatedColumnHeaders = worksheets['Sheet1'].columns;\n      expect(calculatedColumnHeaders).toEqual(assertHeaders);\n    });\n  });\n\n  describe('/import/{baseId} OpenAPI ImportController (e2e) (Post)', () => {\n    let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<void>;\n\n    it.each(testFileFormats.filter((format) => format !== TestFileFormat.TXT))(\n      'should create a new Table from %s file',\n      async (format) => {\n        awaitWithEvent = createAwaitWithEventWithResult<void>(\n          eventEmitterService,\n          Events.TABLE_RECORD_CREATE_RELATIVE\n        );\n        const spaceRes = await apiCreateSpace({ name: `test${format}` });\n        const spaceId = spaceRes?.data?.id;\n        const baseRes = await apiCreateBase({ spaceId });\n        const baseId = baseRes.data.id;\n\n        const fileType = testSupportTypeMap[format].fileType;\n        const attachmentUrl = testFiles[format].url;\n        const defaultSheetKey = testSupportTypeMap[format].defaultSheetKey;\n\n        const {\n          data: { worksheets },\n        } = await apiAnalyzeFile({\n          attachmentUrl,\n          fileType,\n        });\n        const calculatedColumnHeaders = worksheets[defaultSheetKey].columns;\n\n        const table = await apiImportTableFromFile(baseId, {\n          attachmentUrl,\n          fileType,\n          worksheets: {\n            [defaultSheetKey]: {\n              name: defaultSheetKey,\n              columns: calculatedColumnHeaders.map((column, index) => ({\n                ...column,\n                sourceColumnIndex: index,\n              })),\n              useFirstRowAsHeader: true,\n              importData: true,\n            },\n          },\n          tz: 'Asia/Shanghai',\n        });\n\n        const { fields, id } = table.data[0];\n\n        const createdFields = fields.map((field) => ({\n          type: field.type,\n          name: field.name,\n        }));\n\n        await awaitWithEvent(async () => {\n          noop();\n        });\n\n        const { records } = await apiGetTableById(baseId, table.data[0].id, {\n          includeContent: true,\n        });\n\n        bases.push([baseId, id]);\n\n        expect(records?.length).toBe(2);\n        expect(createdFields).toEqual(assertHeaders);\n      }\n    );\n\n    it('should query import status until completed for imported table', async () => {\n      const spaceRes = await apiCreateSpace({ name: 'status-check' });\n      const spaceId = spaceRes?.data?.id;\n      const baseRes = await apiCreateBase({ spaceId });\n      const baseId = baseRes.data.id;\n\n      const format = TestFileFormat.CSV;\n      const fileType = testSupportTypeMap[format].fileType;\n      const attachmentUrl = testFiles[format].url;\n      const sheetKey = testSupportTypeMap[format].defaultSheetKey;\n\n      const {\n        data: { worksheets },\n      } = await apiAnalyzeFile({\n        attachmentUrl,\n        fileType,\n      });\n      const columns = worksheets[sheetKey].columns.map((column, index) => ({\n        ...column,\n        sourceColumnIndex: index,\n      }));\n\n      const importRes = await apiImportTableFromFile(baseId, {\n        attachmentUrl,\n        fileType,\n        worksheets: {\n          [sheetKey]: {\n            name: sheetKey,\n            columns,\n            useFirstRowAsHeader: true,\n            importData: true,\n          },\n        },\n        tz: 'Asia/Shanghai',\n      });\n\n      const tableId = importRes.data[0].id;\n      bases.push([baseId, tableId]);\n\n      const timeoutMs = 30000;\n      const intervalMs = 1000;\n      const start = Date.now();\n      let latestStatus: string | undefined;\n\n      while (Date.now() - start < timeoutMs) {\n        const { data } = await apiGetImportStatus(tableId);\n        latestStatus = data.status;\n        if (data.status === 'completed' || data.status === 'failed') {\n          expect(data.successCount).toBeDefined();\n          expect(data.failedCount).toBeDefined();\n          expect((data.successCount ?? 0) + (data.failedCount ?? 0)).toBeGreaterThan(0);\n          expect(data.status).toBe('completed');\n          return;\n        }\n        expect(data.status).not.toBe('not_found');\n        await sleep(intervalMs);\n      }\n\n      throw new Error(\n        `Import status polling timed out, latest status: ${latestStatus ?? 'unknown'}`\n      );\n    });\n  });\n\n  describe('/import/{baseId}/{tableId} OpenAPI ImportController (e2e) (Patch)', () => {\n    let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<void>;\n\n    it('should import data into Table from file', async () => {\n      awaitWithEvent = createAwaitWithEventWithResult<void>(\n        eventEmitterService,\n        Events.TABLE_RECORD_CREATE_RELATIVE\n      );\n      const spaceRes = await apiCreateSpace({ name: 'test1' });\n      const spaceId = spaceRes?.data?.id;\n      const baseRes = await apiCreateBase({ spaceId });\n      const baseId = baseRes.data.id;\n\n      const format = SUPPORTEDTYPE.CSV;\n      const attachmentUrl = testFiles[format].url;\n      const fileType = testSupportTypeMap[format].fileType;\n\n      // create a table\n      const tableRes = await apiCreateTable(baseId, {\n        fields: [\n          {\n            type: FieldType.Number,\n            name: 'field_1',\n          },\n          {\n            type: FieldType.SingleLineText,\n            name: 'field_2',\n          },\n          {\n            type: FieldType.Checkbox,\n            name: 'field_3',\n          },\n          {\n            type: FieldType.Date,\n            name: 'field_4',\n            options: {\n              formatting: {\n                ...defaultDatetimeFormatting,\n                time: TimeFormatting.Hour24,\n              },\n            },\n          },\n          {\n            type: FieldType.SingleLineText,\n            name: 'field_5',\n          },\n          {\n            type: FieldType.LongText,\n            name: 'field_6',\n          },\n        ],\n        records: [],\n      });\n      const tableId = tableRes.data.id;\n      const fields = tableRes?.data?.fields;\n      const sourceColumnMap: IInplaceImportOptionRo['insertConfig']['sourceColumnMap'] = {};\n      fields.forEach((field, index) => {\n        sourceColumnMap[field.id] = index;\n      });\n\n      // import data into table\n      await awaitWithEvent(async () => {\n        await apiInplaceImportTableFromFile(baseId, tableId, {\n          attachmentUrl,\n          fileType,\n          insertConfig: {\n            sourceWorkSheetKey: CsvImporter.DEFAULT_SHEETKEY,\n            excludeFirstRow: true,\n            sourceColumnMap,\n          },\n        });\n      });\n\n      const { records } = await apiGetTableById(baseId, tableId, {\n        includeContent: true,\n      });\n\n      bases.push([baseId, tableId]);\n\n      const tableRecords = records?.map((r) => {\n        const newFields = { ...r.fields };\n        if (newFields['field_4']) {\n          newFields['field_4'] = new Date(newFields['field_4'] as string).getTime();\n        }\n        return newFields;\n      });\n\n      const assertRecords = [\n        {\n          field_1: 1,\n          field_2: 'string_1',\n          field_3: true,\n          field_4: dayjs\n            .tz('2022-11-10 16:00:00', defaultDatetimeFormatting.timeZone)\n            .toDate()\n            .getTime(),\n          field_6: 'long\\ntext',\n        },\n        {\n          field_1: 2,\n          field_2: 'string_2',\n          field_4: dayjs\n            .tz('2022-11-11 16:00:00', defaultDatetimeFormatting.timeZone)\n            .toDate()\n            .getTime(),\n        },\n      ];\n\n      expect(records?.length).toBe(2);\n      expect(tableRecords).toEqual(assertRecords);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-lifecycle-full.e2e-spec.ts",
    "content": "/*\n  A comprehensive end-to-end test that exercises a full table lifecycle:\n  - Create tables\n  - Create and update columns (including formulas)\n  - Create link fields for all relationship types (MM/MO/OM/OO)\n  - Create lookup and rollup\n  - CRUD on records with link data\n  - Verify cascading effects on computed fields\n  - Verify underlying DB has expected columns and values\n  - Verify API getRecords returns detailed expected results\n  - Clean up by permanently deleting tables\n*/\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, Relationship } from '@teable/core';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { Knex } from 'knex';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  deleteRecord,\n  getFields,\n  getRecord,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n  updateRecord,\n  updateRecordByApi,\n  convertField,\n} from './utils/init-app';\n\ndescribe('Table Lifecycle Comprehensive (e2e)', () => {\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let knex: Knex;\n  let db: IDbProvider;\n  const baseId = (globalThis as any).testConfig.baseId as string;\n\n  const getDbTableName = async (tableId: string) => {\n    const { dbTableName } = await prisma.tableMeta.findUniqueOrThrow({\n      where: { id: tableId },\n      select: { dbTableName: true },\n    });\n    return dbTableName;\n  };\n\n  const getRow = async (dbTableName: string, id: string) => {\n    return (\n      await prisma.$queryRawUnsafe<any[]>(knex(dbTableName).select('*').where('__id', id).toQuery())\n    )[0];\n  };\n\n  const getUserColumns = async (dbTableName: string) => {\n    const rows = await prisma.$queryRawUnsafe<{ name: string }[]>(db.columnInfo(dbTableName));\n    // keep all user columns except preserved\n    const { preservedDbFieldNames } = await import('../src/features/field/constant');\n    return rows.map((r) => r.name).filter((n) => !preservedDbFieldNames.has(n));\n  };\n\n  const parseMaybe = (v: unknown) => {\n    if (typeof v === 'string') {\n      try {\n        return JSON.parse(v);\n      } catch {\n        return v;\n      }\n    }\n    return v;\n  };\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    knex = app.get('CUSTOM_KNEX' as any);\n    db = app.get<IDbProvider>(DB_PROVIDER_SYMBOL as any);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  it('complete lifecycle from create to delete with detailed expectations', async () => {\n    // 1) Create two tables: Host(A) and Foreign(B)\n    const tableA = await createTable(baseId, { name: 'lifecycle_A' });\n    const tableB = await createTable(baseId, {\n      name: 'lifecycle_B',\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText },\n        { name: 'UnitPrice', type: FieldType.Number },\n        { name: 'Stock', type: FieldType.Number },\n      ] as IFieldRo[],\n      records: [\n        { fields: { Title: 'P1', UnitPrice: 100, Stock: 5 } },\n        { fields: { Title: 'P2', UnitPrice: 50, Stock: 7 } },\n      ],\n    });\n\n    expect(tableA.id).toBeDefined();\n    expect(tableB.id).toBeDefined();\n\n    const aDb = await getDbTableName(tableA.id);\n    const bDb = await getDbTableName(tableB.id);\n    expect(typeof aDb).toBe('string');\n    expect(typeof bDb).toBe('string');\n\n    // 2) Create columns on A: Qty(Number), PriceLocal(Number), Date(Date), Flag(Checkbox)\n    const fQty = await createField(tableA.id, { name: 'Qty', type: FieldType.Number } as IFieldRo);\n    const fPriceLocal = await createField(tableA.id, {\n      name: 'PriceLocal',\n      type: FieldType.Number,\n    } as IFieldRo);\n    const fDate = await createField(tableA.id, { name: 'Date', type: FieldType.Date } as IFieldRo);\n    const fFlag = await createField(tableA.id, {\n      name: 'Flag',\n      type: FieldType.Checkbox,\n    } as IFieldRo);\n\n    // 3) Link fields on A covering all relationship types to B\n    const lMM = await createField(tableA.id, {\n      name: 'L_MM',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyMany, foreignTableId: tableB.id },\n    } as IFieldRo);\n    const lMO = await createField(tableA.id, {\n      name: 'L_MO',\n      type: FieldType.Link,\n      options: { relationship: Relationship.ManyOne, foreignTableId: tableB.id },\n    } as IFieldRo);\n    const lOM = await createField(tableA.id, {\n      name: 'L_OM',\n      type: FieldType.Link,\n      options: { relationship: Relationship.OneMany, foreignTableId: tableB.id },\n    } as IFieldRo);\n    const lOO = await createField(tableA.id, {\n      name: 'L_OO',\n      type: FieldType.Link,\n      options: { relationship: Relationship.OneOne, foreignTableId: tableB.id },\n    } as IFieldRo);\n\n    // 4) Lookup and Rollup on A based on links to B\n    const fLookupPrice = await createField(tableA.id, {\n      name: 'LookupPrice',\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: tableB.id,\n        linkFieldId: (lMO as any).id,\n        lookupFieldId: tableB.fields.find((f) => f.name === 'UnitPrice')!.id,\n      } as any,\n    } as any);\n\n    const fRollupStock = await createField(tableA.id, {\n      name: 'RollupStock',\n      type: FieldType.Rollup,\n      lookupOptions: {\n        foreignTableId: tableB.id,\n        linkFieldId: (lMM as any).id,\n        lookupFieldId: tableB.fields.find((f) => f.name === 'Stock')!.id,\n      } as any,\n      options: { expression: 'sum({values})' } as any,\n    } as any);\n\n    // 5) Formula fields: simple (likely generated) and referencing lookup (non-generated-ish)\n    const fTotalLocal = await createField(tableA.id, {\n      name: 'F_TotalLocal',\n      type: FieldType.Formula,\n      options: { expression: `{${(fQty as any).id}} * {${(fPriceLocal as any).id}}` },\n    } as IFieldRo);\n    const fCombined = await createField(tableA.id, {\n      name: 'F_Combined',\n      type: FieldType.Formula,\n      options: { expression: `{${(fTotalLocal as any).id}} + {${(fLookupPrice as any).id}}` },\n    } as IFieldRo);\n\n    // Verify physical columns were created for new fields on A\n    const aCols = await getUserColumns(aDb);\n    const expectedCols = [\n      (fQty as any).dbFieldName,\n      (fPriceLocal as any).dbFieldName,\n      (fDate as any).dbFieldName,\n      (fFlag as any).dbFieldName,\n      (lMM as any).dbFieldName,\n      (lMO as any).dbFieldName,\n      (lOM as any).dbFieldName,\n      (lOO as any).dbFieldName,\n      (fLookupPrice as any).dbFieldName,\n      (fRollupStock as any).dbFieldName,\n      (fTotalLocal as any).dbFieldName,\n      (fCombined as any).dbFieldName,\n    ];\n    for (const c of expectedCols) expect(aCols).toContain(c);\n\n    // 6) Create/Update records on A; include link data\n    // Use the default 3 records from A; set values for first two\n    const aRec1 = tableA.records[0].id;\n    const aRec2 = tableA.records[1].id;\n    const bRec1 = tableB.records[0].id; // P1\n    const bRec2 = tableB.records[1].id; // P2\n\n    // Set Qty=2, PriceLocal=80, links: MO=P1, MM=[P1,P2], OM=[P2], OO=P2\n    await updateRecord(tableA.id, aRec1, {\n      record: {\n        fields: {\n          [(fQty as any).id]: 2,\n          [(fPriceLocal as any).id]: 80,\n          [(lMO as any).id]: { id: bRec1 },\n          [(lMM as any).id]: [{ id: bRec1 }, { id: bRec2 }],\n          [(lOM as any).id]: [{ id: bRec2 }],\n          [(lOO as any).id]: { id: bRec2 },\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    // Second record: Qty=3, PriceLocal=120, MO=P2, MM=[P2]\n    await updateRecord(tableA.id, aRec2, {\n      record: {\n        fields: {\n          [(fQty as any).id]: 3,\n          [(fPriceLocal as any).id]: 120,\n          [(lMO as any).id]: { id: bRec2 },\n          [(lMM as any).id]: [{ id: bRec2 }],\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    // 7) Verify getRecords for A with detailed expectations\n    const { records: aRecords0 } = await getRecords(tableA.id, { fieldKeyType: FieldKeyType.Id });\n    const rec1 = aRecords0.find((r) => r.id === aRec1)!;\n    const rec2 = aRecords0.find((r) => r.id === aRec2)!;\n    expect(rec1.fields[(fQty as any).id]).toEqual(2);\n    expect(rec1.fields[(fPriceLocal as any).id]).toEqual(80);\n    expect(rec1.fields[(lMO as any).id]).toMatchObject({ id: bRec1, title: expect.any(String) });\n    expect(rec1.fields[(lMM as any).id]).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({ id: bRec1 }),\n        expect.objectContaining({ id: bRec2 }),\n      ])\n    );\n    expect(rec1.fields[(lOM as any).id]).toEqual(\n      expect.arrayContaining([expect.objectContaining({ id: bRec2 })])\n    );\n    expect(rec1.fields[(lOO as any).id]).toMatchObject({ id: bRec2, title: expect.any(String) });\n    // lookup/rollup/formulas\n    expect(rec1.fields[(fLookupPrice as any).id]).toEqual(100);\n    expect(rec1.fields[(fRollupStock as any).id]).toEqual(5 + 7);\n    expect(rec1.fields[(fTotalLocal as any).id]).toEqual(2 * 80);\n    expect(rec1.fields[(fCombined as any).id]).toEqual(2 * 80 + 100);\n\n    expect(rec2.fields[(fLookupPrice as any).id]).toEqual(50);\n    expect(rec2.fields[(fRollupStock as any).id]).toEqual(7);\n    expect(rec2.fields[(fTotalLocal as any).id]).toEqual(3 * 120);\n    expect(rec2.fields[(fCombined as any).id]).toEqual(3 * 120 + 50);\n\n    // 8) Verify DB row values on A for the first record\n    const row1 = await getRow(aDb, aRec1);\n    const cell = (field: IFieldVo) => parseMaybe((row1 as any)[(field as any).dbFieldName]);\n    expect(cell(fQty)).toEqual(2);\n    expect(cell(fPriceLocal)).toEqual(80);\n    expect(Array.isArray(cell(lMM)) ? cell(lMM).map((v: any) => v.id) : []).toEqual(\n      expect.arrayContaining([bRec1, bRec2])\n    );\n    // Computed fields (lookup/rollup/formula) are verified via API responses above.\n    // Persisted DB row should reflect scalar/link values reliably.\n\n    // 9) Update a column (formula) and verify recomputation\n    await convertField(tableA.id, (fTotalLocal as any).id, {\n      name: (fTotalLocal as any).name,\n      type: FieldType.Formula,\n      options: { expression: `{${(fQty as any).id}} * 2` },\n    } as IFieldRo);\n\n    // Also update Qty to see cascade reflected in formula and combined\n    await updateRecord(tableA.id, aRec1, {\n      record: { fields: { [(fQty as any).id]: 5 } },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const recAfterFormula = await getRecord(tableA.id, aRec1);\n    expect(recAfterFormula.fields[(fTotalLocal as any).id]).toEqual(5 * 2);\n    // F_Combined references F_TotalLocal + LookupPrice -> 10 + 100 = 110\n    expect(recAfterFormula.fields[(fCombined as any).id]).toEqual(10 + 100);\n\n    // Persisted DB values for computed fields may not be stored; rely on API checks for those.\n\n    // 10) Update linked foreign values & link sets; validate cascading effects\n    // Change B.P1 UnitPrice from 100 -> 150; affects LookupPrice and Combined on rec1\n    const bUnitPrice = tableB.fields.find((f) => f.name === 'UnitPrice')!;\n    await updateRecord(tableB.id, bRec1, {\n      record: { fields: { [bUnitPrice.id]: 150 } },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    const recAfterForeignChange = await getRecord(tableA.id, aRec1);\n    expect(recAfterForeignChange.fields[(fLookupPrice as any).id]).toEqual(150);\n    expect(recAfterForeignChange.fields[(fCombined as any).id]).toEqual(10 + 150);\n\n    // Remove P2 from L_MM, rollup should become 5\n    await updateRecord(tableA.id, aRec1, {\n      record: { fields: { [(lMM as any).id]: [{ id: bRec1 }] } },\n      fieldKeyType: FieldKeyType.Id,\n    });\n    const recAfterLinkChange = await getRecord(tableA.id, aRec1);\n    expect(recAfterLinkChange.fields[(fRollupStock as any).id]).toEqual(5);\n\n    // 11) Record CRUD with link data\n    // Create a new record with link + scalar values\n    const created = await createRecords(tableA.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [(fQty as any).id]: 4,\n            [(fPriceLocal as any).id]: 50,\n            [(lMO as any).id]: { id: bRec2 },\n            [(lMM as any).id]: [{ id: bRec2 }],\n          },\n        },\n      ],\n    });\n    const newId = created.records[0].id;\n    const newRec = await getRecord(tableA.id, newId);\n    expect(newRec.fields[(fQty as any).id]).toEqual(4);\n    expect(newRec.fields[(fLookupPrice as any).id]).toEqual(50);\n    expect(newRec.fields[(fRollupStock as any).id]).toEqual(7);\n\n    // Update the new record's link to include P1 as well; rollup should be 5 + 7 = 12\n    await updateRecord(tableA.id, newId, {\n      record: { fields: { [(lMM as any).id]: [{ id: bRec2 }, { id: bRec1 }] } },\n      fieldKeyType: FieldKeyType.Id,\n    });\n    const newRec2 = await getRecord(tableA.id, newId);\n    expect(newRec2.fields[(fRollupStock as any).id]).toEqual(12);\n\n    // Delete the new record\n    await deleteRecord(tableA.id, newId, 200);\n    await getRecord(tableA.id, newId, undefined, 404);\n\n    // 12) Update record by API for link/object shape (OneOne)\n    await updateRecordByApi(tableA.id, aRec2, (lOO as any).id, { id: bRec1 });\n    const rec2b = await getRecord(tableA.id, aRec2);\n    expect(rec2b.fields[(lOO as any).id]).toMatchObject({ id: bRec1 });\n\n    // 13) Final DB inspection (spot check) and fields listing\n    const fieldsA = await getFields(tableA.id);\n    const names = fieldsA.map((f) => f.name);\n    expect(names).toEqual(\n      expect.arrayContaining([\n        'Qty',\n        'PriceLocal',\n        'L_MM',\n        'L_MO',\n        'L_OM',\n        'L_OO',\n        'LookupPrice',\n        'RollupStock',\n        'F_TotalLocal',\n        'F_Combined',\n      ])\n    );\n\n    // Spot check scalar persistence on another record\n    const row2 = await getRow(aDb, aRec2);\n    expect(parseMaybe((row2 as any)[(fQty as any).dbFieldName])).toEqual(3);\n\n    // 14) Clean up: permanently delete tables\n    await permanentDeleteTable(baseId, tableA.id);\n    await permanentDeleteTable(baseId, tableB.id);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table-trash.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { faker } from '@faker-js/faker';\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableTrashItemVo } from '@teable/openapi';\nimport {\n  RangeType,\n  SettingKey,\n  createRecords,\n  deleteFields,\n  deleteRecords,\n  deleteSelection,\n  deleteView,\n  getTrashItems,\n  resetTrashItems,\n  ResourceType,\n  restoreTrash,\n  updateSetting,\n} from '@teable/openapi';\nimport { vi } from 'vitest';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport {\n  initApp,\n  createTable,\n  permanentDeleteTable,\n  getViews,\n  getFields,\n  getRecords,\n  createField,\n} from './utils/init-app';\n\nconst tableVo = {\n  fields: [\n    {\n      name: 'SingleLineText',\n      type: FieldType.SingleLineText,\n    },\n    {\n      name: 'Number',\n      type: FieldType.Number,\n    },\n    {\n      name: 'Checkbox',\n      type: FieldType.Checkbox,\n    },\n  ],\n  views: [\n    {\n      name: 'Grid',\n      type: ViewType.Grid,\n    },\n    {\n      name: 'Gallery',\n      type: ViewType.Gallery,\n    },\n  ],\n  records: Array.from({ length: 10 }).map(() => ({\n    fields: {\n      SingleLineText: faker.lorem.words(),\n      Number: faker.number.int(),\n      Checkbox: true,\n    },\n  })),\n};\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst waitForTableTrashItems = async (tableId: string, expectedCount = 1, maxRetries = 100) => {\n  for (let i = 0; i < maxRetries; i++) {\n    const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n    if (result.data.trashItems.length >= expectedCount) {\n      return result;\n    }\n    await sleep(100);\n  }\n\n  return await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n};\n\ndescribe('Trash (e2e)', () => {\n  const isForceV2 = process.env.FORCE_V2_ALL === 'true';\n  let app: INestApplication;\n  let prisma: PrismaService;\n  let eventEmitterService: EventEmitterService;\n\n  const baseId = globalThis.testConfig.baseId;\n\n  let awaitWithViewEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  let awaitWithFieldEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  const awaitWithFieldDeleteSync = async <T>(fn: () => Promise<T>) =>\n    isForceV2 ? fn() : awaitWithFieldEvent(fn);\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n\n    app = appCtx.app;\n    prisma = app.get(PrismaService);\n    eventEmitterService = app.get(EventEmitterService);\n\n    awaitWithViewEvent = createAwaitWithEvent(eventEmitterService, Events.OPERATION_VIEW_DELETE);\n    awaitWithFieldEvent = createAwaitWithEvent(eventEmitterService, Events.OPERATION_FIELDS_DELETE);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Retrieving table trash items', () => {\n    let tableId: string;\n\n    beforeEach(async () => {\n      tableId = (await createTable(baseId, tableVo)).id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableId);\n    });\n\n    it('should retrieve table trash items when a view is deleted', async () => {\n      const views = await getViews(tableId);\n      const deletedViewId = views[0].id;\n\n      await awaitWithViewEvent(() => deleteView(tableId, deletedViewId));\n\n      const result = await waitForTableTrashItems(tableId, 1);\n\n      expect(result.data.trashItems.length).toBe(1);\n      expect((result.data.trashItems[0] as ITableTrashItemVo).resourceIds[0]).toBe(deletedViewId);\n    });\n\n    it('should retrieve table trash items when fields are deleted', async () => {\n      const fields = await getFields(tableId);\n      const deletedFieldIds = fields.filter((f) => !f.isPrimary).map((f) => f.id);\n\n      await awaitWithFieldDeleteSync(async () => deleteFields(tableId, deletedFieldIds));\n\n      const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n\n      expect(result.data.trashItems.length).toBe(1);\n      expect((result.data.trashItems[0] as ITableTrashItemVo).resourceIds).toEqual(deletedFieldIds);\n    });\n\n    it('should retrieve table trash items when records are deleted', async () => {\n      const recordsData = await getRecords(tableId);\n      const deletedRecordIds = recordsData.records.map((r) => r.id);\n\n      await deleteRecords(tableId, deletedRecordIds);\n\n      const result = await waitForTableTrashItems(tableId, 1);\n\n      expect(result.data.trashItems.length).toBe(1);\n      expect((result.data.trashItems[0] as ITableTrashItemVo).resourceIds).toEqual(\n        deletedRecordIds\n      );\n    });\n\n    it('should expose the primary-field display name for V2 record trash and legacy snapshots', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: [globalThis.testConfig.spaceId],\n        },\n      });\n\n      const primaryValue = `v2-trash-name-${Date.now()}`;\n\n      try {\n        const createRes = await createRecords(tableId, {\n          records: [\n            {\n              fields: {\n                SingleLineText: primaryValue,\n              },\n            },\n          ],\n        });\n        expect(createRes.headers['x-teable-v2']).toBe('true');\n\n        const createdRecordId = createRes.data.records[0].id;\n\n        const deleteRes = await deleteRecords(tableId, [createdRecordId]);\n        expect(deleteRes.headers['x-teable-v2']).toBe('true');\n\n        const trashRes = await waitForTableTrashItems(tableId, 1);\n        expect(trashRes.data.resourceMap[createdRecordId]).toMatchObject({\n          id: createdRecordId,\n          name: primaryValue,\n        });\n\n        const recordTrash = await prisma.recordTrash.findFirst({\n          where: { tableId, recordId: createdRecordId },\n          select: {\n            id: true,\n            snapshot: true,\n          },\n        });\n\n        expect(recordTrash).toBeTruthy();\n\n        const snapshotWithName = JSON.parse(recordTrash!.snapshot) as {\n          name?: string;\n          fields: Record<string, unknown>;\n        };\n        expect(snapshotWithName.name).toBe(primaryValue);\n\n        delete snapshotWithName.name;\n\n        await prisma.recordTrash.update({\n          where: { id: recordTrash!.id },\n          data: { snapshot: JSON.stringify(snapshotWithName) },\n        });\n\n        const legacyTrashRes = await getTrashItems({\n          resourceId: tableId,\n          resourceType: ResourceType.Table,\n        });\n        expect(legacyTrashRes.data.resourceMap[createdRecordId]).toMatchObject({\n          id: createdRecordId,\n          name: primaryValue,\n        });\n      } finally {\n        await updateSetting({\n          [SettingKey.CANARY_CONFIG]: {\n            enabled: false,\n            spaceIds: [],\n          },\n        });\n      }\n    });\n\n    it('should add V2-created records to table trash when deleting by range', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: [globalThis.testConfig.spaceId],\n        },\n      });\n\n      try {\n        const createRes = await createRecords(tableId, {\n          records: [\n            {\n              fields: {\n                SingleLineText: `v2-trash-${Date.now()}`,\n              },\n            },\n          ],\n        });\n        expect(createRes.headers['x-teable-v2']).toBe('true');\n\n        const createdRecordId = createRes.data.records[0].id;\n        const recordsData = await getRecords(tableId);\n        const rowIndex = recordsData.records.findIndex((record) => record.id === createdRecordId);\n\n        expect(rowIndex).toBeGreaterThanOrEqual(0);\n\n        const deleteRes = await deleteSelection(tableId, {\n          type: RangeType.Rows,\n          ranges: [[rowIndex, rowIndex]],\n        });\n        expect(deleteRes.headers['x-teable-v2']).toBe('true');\n\n        const trashRes = await getTrashItems({\n          resourceId: tableId,\n          resourceType: ResourceType.Table,\n        });\n        expect(trashRes.data.trashItems.length).toBe(1);\n        const recordTrash = trashRes.data.trashItems.find(\n          (item) => (item as ITableTrashItemVo).resourceType === ResourceType.Record\n        ) as ITableTrashItemVo | undefined;\n\n        expect(recordTrash).toBeTruthy();\n        expect(recordTrash?.resourceIds).toContain(createdRecordId);\n      } finally {\n        await updateSetting({\n          [SettingKey.CANARY_CONFIG]: {\n            enabled: false,\n            spaceIds: [],\n          },\n        });\n      }\n    });\n\n    it('should rely on V2 projection for record-id delete without emitting OPERATION_RECORDS_DELETE', async () => {\n      await updateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: true,\n          spaceIds: [globalThis.testConfig.spaceId],\n        },\n      });\n\n      const emitSpy = vi.spyOn(eventEmitterService, 'emitAsync');\n      let hasOperationDeleteEvent = false;\n\n      try {\n        const createRes = await createRecords(tableId, {\n          records: [\n            {\n              fields: {\n                SingleLineText: `v2-trash-delete-${Date.now()}`,\n              },\n            },\n            {\n              fields: {\n                SingleLineText: `v2-trash-delete-${Date.now()}-2`,\n              },\n            },\n          ],\n        });\n        expect(createRes.headers['x-teable-v2']).toBe('true');\n\n        const createdRecordIds = createRes.data.records.map((record) => record.id);\n        const deleteRes = await deleteRecords(tableId, createdRecordIds);\n        expect(deleteRes.headers['x-teable-v2']).toBe('true');\n\n        hasOperationDeleteEvent = emitSpy.mock.calls.some(\n          ([eventName]) => eventName === Events.OPERATION_RECORDS_DELETE\n        );\n\n        const trashRes = await getTrashItems({\n          resourceId: tableId,\n          resourceType: ResourceType.Table,\n        });\n        expect(trashRes.data.trashItems.length).toBe(1);\n\n        const recordTrash = trashRes.data.trashItems.find(\n          (item) => (item as ITableTrashItemVo).resourceType === ResourceType.Record\n        ) as ITableTrashItemVo | undefined;\n        expect(recordTrash).toBeTruthy();\n        expect(recordTrash?.resourceIds).toEqual(createdRecordIds);\n      } finally {\n        emitSpy.mockRestore();\n        await updateSetting({\n          [SettingKey.CANARY_CONFIG]: {\n            enabled: false,\n            spaceIds: [],\n          },\n        });\n      }\n\n      expect(hasOperationDeleteEvent).toBe(false);\n    });\n  });\n\n  describe('Restoring table trash items', () => {\n    let tableId: string;\n\n    beforeEach(async () => {\n      tableId = (await createTable(baseId, tableVo)).id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableId);\n    });\n\n    it('should restore view successfully', async () => {\n      const views = await getViews(tableId);\n      const deletedViewId = views[0].id;\n\n      await awaitWithViewEvent(() => deleteView(tableId, deletedViewId));\n\n      const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n      const restored = await restoreTrash(result.data.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should restore fields successfully', async () => {\n      const fields = await getFields(tableId);\n      const deletedFieldIds = fields.filter((f) => !f.isPrimary).map((f) => f.id);\n\n      await awaitWithFieldDeleteSync(async () => deleteFields(tableId, deletedFieldIds));\n\n      const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n      const restored = await restoreTrash(result.data.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should restore formula fields successfully', async () => {\n      const formulaField = await createField(tableId, {\n        name: 'Formula',\n        type: FieldType.Formula,\n        options: {\n          expression: '1 + 1',\n        },\n      });\n\n      await awaitWithFieldDeleteSync(async () => deleteFields(tableId, [formulaField.id]));\n\n      const result = await getTrashItems({ resourceId: tableId, resourceType: ResourceType.Table });\n      const restored = await restoreTrash(result.data.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should restore field when some records were deleted after field deletion', async () => {\n      const field = await createField(tableId, {\n        name: 'restore field',\n        type: FieldType.SingleSelect,\n        options: {\n          choices: [{ name: 'A' }, { name: 'B' }],\n        },\n      });\n\n      const options = (field.options as unknown as { choices: { id: string }[] }).choices;\n\n      const created = await createRecords(tableId, {\n        records: [\n          { fields: { [field.id]: options[0].id } },\n          { fields: { [field.id]: options[1].id } },\n        ],\n        typecast: true,\n        fieldKeyType: FieldKeyType.Id,\n      });\n      const createdRecordIds = created.data.records.map((r) => r.id);\n\n      await awaitWithFieldDeleteSync(async () => deleteFields(tableId, [field.id]));\n\n      await deleteRecords(tableId, [createdRecordIds[0]]);\n\n      const itemsRes = await waitForTableTrashItems(tableId, 2);\n      const fieldTrashItem = itemsRes.data.trashItems.find(\n        (t) => (t as ITableTrashItemVo).resourceType === ResourceType.Field\n      ) as ITableTrashItemVo | undefined;\n\n      expect(fieldTrashItem).toBeTruthy();\n\n      const restored = await restoreTrash(fieldTrashItem!.id);\n      expect(restored.status).toEqual(201);\n\n      const afterFields = await getFields(tableId);\n      expect(afterFields.find((f) => f.id === field.id)).toBeTruthy();\n    });\n\n    it('should restore fields successfully', async () => {\n      const recordsData = await getRecords(tableId);\n      const deletedRecordIds = recordsData.records.map((r) => r.id);\n\n      await deleteRecords(tableId, deletedRecordIds);\n\n      const result = await waitForTableTrashItems(tableId, 1);\n      const restored = await restoreTrash(result.data.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n  });\n\n  describe('Reset table trash items', () => {\n    let tableId: string;\n\n    beforeEach(async () => {\n      tableId = (await createTable(baseId, tableVo)).id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, tableId);\n    });\n\n    it('should reset table trash items successfully', async () => {\n      const views = await getViews(tableId);\n      const fields = await getFields(tableId);\n      const recordsData = await getRecords(tableId);\n\n      const deletedViewId = views[0].id;\n      const deletedFieldIds = fields.filter((f) => !f.isPrimary).map((f) => f.id);\n      const deletedRecordIds = recordsData.records.map((r) => r.id);\n\n      await awaitWithViewEvent(() => deleteView(tableId, deletedViewId));\n      await awaitWithFieldDeleteSync(async () => deleteFields(tableId, deletedFieldIds));\n      await deleteRecords(tableId, deletedRecordIds);\n\n      const result = await waitForTableTrashItems(tableId, 3);\n\n      expect(result.data.trashItems.length).toEqual(3);\n\n      await resetTrashItems({ resourceType: ResourceType.Table, resourceId: tableId });\n\n      const resetedResult = await getTrashItems({\n        resourceId: tableId,\n        resourceType: ResourceType.Table,\n      });\n\n      expect(resetedResult.data.trashItems.length).toEqual(0);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/table.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport type { INestApplication } from '@nestjs/common';\nimport { EventEmitter2 } from '@nestjs/event-emitter';\nimport { FieldKeyType, FieldType, Relationship, RowHeightLevel, ViewType } from '@teable/core';\nimport type { ICreateTableRo } from '@teable/openapi';\nimport {\n  updateTableDescription,\n  updateTableIcon,\n  updateTableName,\n  deleteTable as apiDeleteTable,\n} from '@teable/openapi';\nimport { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres';\nimport type { ComputedUpdateWorker } from '@teable/v2-adapter-table-repository-postgres';\nimport { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';\nimport type { IDbProvider } from '../src/db-provider/db.provider.interface';\nimport { Events } from '../src/event-emitter/events';\nimport type {\n  FieldCreateEvent,\n  TableCreateEvent,\n  ViewCreateEvent,\n  RecordCreateEvent,\n} from '../src/event-emitter/events';\nimport { V2ContainerService } from '../src/features/v2/v2-container.service';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  permanentDeleteTable,\n  getFields,\n  getRecords,\n  getTable,\n  initApp,\n  updateRecord,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst assertData: ICreateTableRo = {\n  name: 'Project Management',\n  description: 'A table for managing projects',\n  fields: [\n    {\n      name: 'Project Name',\n      description: 'The name of the project',\n      type: FieldType.SingleLineText,\n    },\n    {\n      name: 'Project Description',\n      description: 'A brief description of the project',\n      type: FieldType.SingleLineText,\n    },\n    {\n      name: 'Project Status',\n      description: 'The current status of the project',\n      type: FieldType.SingleSelect,\n      options: {\n        choices: [\n          {\n            name: 'Not Started',\n            color: 'gray',\n          },\n          {\n            name: 'In Progress',\n            color: 'blue',\n          },\n          {\n            name: 'Completed',\n            color: 'green',\n          },\n        ],\n      },\n    },\n    {\n      name: 'Start Date',\n      description: 'The date the project started',\n      type: FieldType.Date,\n    },\n    {\n      name: 'End Date',\n      description: 'The date the project is expected to end',\n      type: FieldType.Date,\n    },\n  ],\n  views: [\n    {\n      name: 'Grid View',\n      description: 'A grid view of all projects',\n      type: ViewType.Grid,\n      options: {\n        rowHeight: RowHeightLevel.Short,\n      },\n    },\n    {\n      name: 'Kanban View',\n      description: 'A kanban view of all projects',\n      type: ViewType.Kanban,\n      options: {\n        stackFieldId: 'Project Status',\n        isFieldNameHidden: true,\n        isEmptyStackHidden: true,\n      },\n    },\n  ],\n  records: [\n    {\n      fields: {\n        'Project Name': 'Project A',\n        'Project Description': 'A project to develop a new product',\n        'Project Status': 'Not Started',\n      },\n    },\n    {\n      fields: {\n        'Project Name': 'Project B',\n        'Project Description': 'A project to improve customer service',\n        'Project Status': 'In Progress',\n      },\n    },\n  ],\n};\n\ndescribe('OpenAPI TableController (e2e)', () => {\n  let app: INestApplication;\n  let tableId = '';\n  let dbProvider: IDbProvider;\n  let event: EventEmitter2;\n  let v2ContainerService: V2ContainerService;\n\n  const baseId = globalThis.testConfig.baseId;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    dbProvider = app.get(DB_PROVIDER_SYMBOL);\n    event = app.get(EventEmitter2);\n    v2ContainerService = app.get(V2ContainerService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  async function processV2Outbox(times = 1): Promise<void> {\n    if (!isForceV2) return;\n\n    const container = await v2ContainerService.getContainer();\n    const worker = container.resolve<ComputedUpdateWorker>(\n      v2RecordRepositoryPostgresTokens.computedUpdateWorker\n    );\n\n    for (let i = 0; i < times; i++) {\n      const maxIterations = 100;\n      let iterations = 0;\n\n      while (iterations < maxIterations) {\n        const result = await worker.runOnce({\n          workerId: 'table-delete-test-worker',\n          limit: 100,\n        });\n\n        if (result.isErr()) {\n          throw new Error(`Outbox processing failed: ${result.error.message}`);\n        }\n\n        if (result.value === 0) {\n          break;\n        }\n\n        iterations++;\n      }\n    }\n  }\n\n  async function waitForDeleteTableCleanup(\n    targetTableId: string,\n    options: {\n      twoWayLinkFieldId: string;\n      oneWayLinkFieldId: string;\n      lookupFieldId: string;\n      rollupFieldId: string;\n    }\n  ) {\n    const maxRetries = isForceV2 ? 40 : 1;\n\n    for (let i = 0; i < maxRetries; i++) {\n      if (isForceV2) {\n        await processV2Outbox();\n      }\n\n      const fields = await getFields(targetTableId);\n      const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });\n      const twoWayLinkField = fields.find((field) => field.id === options.twoWayLinkFieldId);\n      const oneWayLinkField = fields.find((field) => field.id === options.oneWayLinkFieldId);\n      const lookupField = fields.find((field) => field.id === options.lookupFieldId);\n      const rollupField = fields.find((field) => field.id === options.rollupFieldId);\n\n      const deleteSettled =\n        twoWayLinkField?.type === FieldType.SingleLineText &&\n        oneWayLinkField?.type === FieldType.SingleLineText &&\n        records[0]?.fields[options.twoWayLinkFieldId] === 'A' &&\n        records[0]?.fields[options.oneWayLinkFieldId] === 'A' &&\n        Boolean(lookupField?.hasError) &&\n        Boolean(rollupField?.hasError);\n\n      if (deleteSettled) {\n        return { fields, records };\n      }\n\n      await sleep(100);\n    }\n\n    const fields = await getFields(targetTableId);\n    const { records } = await getRecords(targetTableId, { fieldKeyType: FieldKeyType.Id });\n    return { fields, records };\n  }\n\n  it('/api/table/ (POST) with assertData data', async () => {\n    let eventCount = 0;\n    event.once(Events.TABLE_CREATE, async (payload: TableCreateEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.TABLE_CREATE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.baseId).toBeDefined();\n      expect(payload?.payload?.table).toBeDefined();\n      eventCount++;\n    });\n\n    event.once(Events.TABLE_FIELD_CREATE, async (payload: FieldCreateEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.TABLE_FIELD_CREATE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.tableId).toBeDefined();\n      expect(payload?.payload?.field).toHaveLength(5);\n      eventCount++;\n    });\n\n    event.once(Events.TABLE_VIEW_CREATE, async (payload: ViewCreateEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.TABLE_VIEW_CREATE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.tableId).toBeDefined();\n      expect(payload?.payload?.view).toHaveLength(2);\n      eventCount++;\n    });\n\n    event.once(Events.TABLE_RECORD_CREATE, async (payload: RecordCreateEvent) => {\n      expect(payload).toBeDefined();\n      expect(payload.name).toBe(Events.TABLE_RECORD_CREATE);\n      expect(payload?.payload).toBeDefined();\n      expect(payload?.payload?.tableId).toBeDefined();\n      expect(payload?.payload?.record).toHaveLength(2);\n      eventCount++;\n    });\n\n    const result = await createTable(baseId, assertData);\n\n    tableId = result.id;\n    const recordResult = await getRecords(tableId);\n\n    expect(recordResult.records).toHaveLength(2);\n    expect(eventCount).toBe(isForceV2 ? 0 : 4);\n  });\n\n  it('/api/table/ (POST) empty', async () => {\n    const result = await createTable(baseId, { name: 'new table' });\n\n    tableId = result.id;\n    const recordResult = await getRecords(tableId);\n    expect(recordResult.records).toHaveLength(3);\n  });\n\n  it('should refresh table lastModifyTime when add a record', async () => {\n    const result = await createTable(baseId, { name: 'new table' });\n    tableId = result.id;\n\n    await createRecords(tableId, {\n      records: [{ fields: {} }],\n    });\n\n    const tableResult = await getTable(baseId, tableId);\n    const currTime = tableResult.lastModifiedTime;\n    expect(new Date(currTime!).getTime() > 0).toBeTruthy();\n  });\n\n  it('should create table with add a record', async () => {\n    const timeStr = new Date().getTime() + '';\n    const result = await createTable(baseId, {\n      name: 'new table',\n      dbTableName: 'my_awesome_table_name' + timeStr,\n    });\n\n    tableId = result.id;\n\n    const tableResult = await getTable(baseId, tableId);\n\n    expect(tableResult.dbTableName).toEqual(\n      dbProvider.generateDbTableName(baseId, 'my_awesome_table_name' + timeStr)\n    );\n  });\n\n  it('should create table with ordered fields', async () => {\n    const table = await createTable(baseId, {\n      name: 'ordered fields table',\n      fields: [\n        {\n          name: 'Single line text',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'Formula',\n          options: {\n            expression: '1 + 1',\n          },\n          type: FieldType.Formula,\n        },\n        {\n          name: 'Long text',\n          type: FieldType.LongText,\n        },\n      ],\n    });\n\n    const tableResult = await getTable(baseId, table.id, { includeContent: true });\n    const fields = tableResult.fields!;\n\n    expect(fields.length).toEqual(3);\n    expect(fields[0].type).toEqual(FieldType.SingleLineText);\n    expect(fields[1].type).toEqual(FieldType.Formula);\n    expect(fields[2].type).toEqual(FieldType.LongText);\n  });\n\n  it('should update table simple properties', async () => {\n    const result = await createTable(baseId, {\n      name: 'table',\n    });\n\n    tableId = result.id;\n\n    await updateTableName(baseId, tableId, { name: 'newTableName' });\n    await updateTableDescription(baseId, tableId, { description: 'newDescription' });\n    await updateTableIcon(baseId, tableId, { icon: '😀' });\n\n    const table = await getTable(baseId, tableId);\n\n    expect(table.name).toEqual('newTableName');\n    expect(table.description).toEqual('newDescription');\n    expect(table.icon).toEqual('😀');\n  });\n\n  it('should delete table and clean up link and lookup fields', async () => {\n    const table1 = await createTable(baseId, {\n      fields: [\n        {\n          name: 'name',\n          type: FieldType.SingleLineText,\n        },\n        {\n          name: 'other',\n          type: FieldType.SingleLineText,\n        },\n      ],\n      records: [\n        {\n          fields: {\n            name: 'A',\n            other: 'Other',\n          },\n        },\n        {\n          fields: {\n            name: 'B',\n          },\n        },\n      ],\n    });\n\n    const table2 = await createTable(baseId, {\n      fields: [\n        {\n          name: 'name',\n          type: FieldType.SingleLineText,\n        },\n      ],\n    });\n    tableId = table2.id;\n\n    const twoWayLinkRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyMany,\n        foreignTableId: table1.id,\n      },\n    };\n\n    const oneWayLinkRo = {\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.OneOne,\n        foreignTableId: table1.id,\n        isOneWay: true,\n      },\n    };\n\n    const twoWayLink = await createField(table2.id, twoWayLinkRo);\n    const oneWayLink = await createField(table2.id, oneWayLinkRo);\n\n    const lookupFieldRo = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: table1.id,\n        lookupFieldId: table1.fields[1].id,\n        linkFieldId: twoWayLink.id,\n      },\n    };\n\n    const rollupFieldRo = {\n      type: FieldType.Rollup,\n      options: {\n        expression: 'countall({values})',\n      },\n      lookupOptions: {\n        foreignTableId: table1.id,\n        lookupFieldId: table1.fields[1].id,\n        linkFieldId: twoWayLink.id,\n      },\n    };\n\n    const lookupField = await createField(table2.id, lookupFieldRo);\n    const rollupField = await createField(table2.id, rollupFieldRo);\n    const lookupFieldId = lookupField.id;\n    const rollupFieldId = rollupField.id;\n\n    await updateRecord(table2.id, table2.records[0].id, {\n      record: {\n        fields: {\n          [twoWayLink.id]: [{ id: table1.records[0].id }],\n          [oneWayLink.id]: { id: table1.records[0].id },\n        },\n      },\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    await apiDeleteTable(baseId, table1.id);\n\n    const { fields, records } = await waitForDeleteTableCleanup(table2.id, {\n      twoWayLinkFieldId: twoWayLink.id,\n      oneWayLinkFieldId: oneWayLink.id,\n      lookupFieldId,\n      rollupFieldId,\n    });\n    const twoWayLinkField = fields.find((field) => field.id === twoWayLink.id);\n    const oneWayLinkField = fields.find((field) => field.id === oneWayLink.id);\n    const refreshedLookupField = fields.find((field) => field.id === lookupFieldId);\n    const refreshedRollupField = fields.find((field) => field.id === rollupFieldId);\n\n    if (!isForceV2) {\n      expect(fields[1].type).toEqual(FieldType.SingleLineText);\n      expect(records[0].fields[fields[1].id]).toEqual('A');\n      expect(fields[2].hasError).toBeTruthy();\n      expect(fields[3].hasError).toBeTruthy();\n      return;\n    }\n\n    expect(twoWayLinkField?.type).toEqual(FieldType.SingleLineText);\n    expect(oneWayLinkField?.type).toEqual(FieldType.SingleLineText);\n    expect(records[0].fields[twoWayLink.id]).toEqual('A');\n    expect(records[0].fields[oneWayLink.id]).toEqual('A');\n    expect(refreshedLookupField?.hasError).toBeTruthy();\n    expect(refreshedRollupField?.hasError).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/template-cover-crop.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport { generateAttachmentId, getRandomString } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  createBase,\n  createSpace,\n  deleteBase,\n  getSignature,\n  notify,\n  publishBase,\n  uploadFile,\n  UploadType,\n} from '@teable/openapi';\nimport type { ITemplateCoverRo } from '@teable/openapi';\nimport { ATTACHMENT_LG_THUMBNAIL_HEIGHT } from '../src/features/attachments/constant';\nimport StorageAdapter from '../src/features/attachments/plugins/adapter';\nimport { deleteSpace, initApp } from './utils/init-app';\n\ndescribe('Template Cover Crop (e2e)', () => {\n  let app: INestApplication;\n  let prismaService: PrismaService;\n  let spaceId: string;\n  let baseId: string;\n\n  beforeAll(async () => {\n    const appContext = await initApp();\n    app = appContext.app;\n    prismaService = app.get(PrismaService);\n\n    // Create a space for testing\n    const spaceData = await createSpace({\n      name: 'Template Cover Crop Test Space',\n    });\n    spaceId = spaceData.data.id;\n  });\n\n  afterAll(async () => {\n    await deleteSpace(spaceId);\n  });\n\n  beforeEach(async () => {\n    // Create a base for testing\n    const { id } = (\n      await createBase({\n        name: 'Template Cover Crop Test Base',\n        spaceId,\n      })\n    ).data;\n    baseId = id;\n  });\n\n  afterEach(async () => {\n    // Clean up templates\n    const tx = prismaService.txClient();\n    await tx.template.deleteMany({\n      where: { baseId },\n    });\n    await deleteBase(baseId);\n  });\n\n  /**\n   * Helper function to upload an image to Template bucket\n   */\n  async function uploadTemplateCoverImage(imageHeight: number) {\n    // Create an SVG image with the specified height\n    // SVG is easy to create with specific dimensions\n    const imageWidth = Math.round(imageHeight * 1.5); // 3:2 aspect ratio\n    const imagePath = path.join(\n      StorageAdapter.TEMPORARY_DIR,\n      `template-cover-${getRandomString(8)}.svg`\n    );\n\n    const svgContent = `<svg width=\"${imageWidth}\" height=\"${imageHeight}\" xmlns=\"http://www.w3.org/2000/svg\">\n  <rect width=\"100%\" height=\"100%\" fill=\"#4a90d9\"/>\n  <circle cx=\"${imageWidth / 2}\" cy=\"${imageHeight / 2}\" r=\"${Math.min(imageWidth, imageHeight) / 4}\" fill=\"#ffffff\"/>\n  <text x=\"${imageWidth / 2}\" y=\"${imageHeight / 2}\" font-size=\"24\" text-anchor=\"middle\" fill=\"#333\">${imageWidth}x${imageHeight}</text>\n</svg>`;\n\n    fs.writeFileSync(imagePath, svgContent);\n\n    try {\n      const stats = fs.statSync(imagePath);\n\n      // Get upload signature\n      const signatureResult = await getSignature({\n        type: UploadType.Template,\n        contentType: 'image/svg+xml',\n        contentLength: stats.size,\n      });\n\n      const { token, requestHeaders } = signatureResult.data;\n\n      // Upload the file\n      const fileStream = fs.createReadStream(imagePath);\n      await uploadFile(token, fileStream, requestHeaders);\n\n      // Notify to get file info\n      const notifyResult = await notify(token, undefined, `cover-${imageHeight}.svg`);\n\n      return {\n        token,\n        notifyData: notifyResult.data,\n      };\n    } finally {\n      // Clean up temp file\n      if (fs.existsSync(imagePath)) {\n        fs.unlinkSync(imagePath);\n      }\n    }\n  }\n\n  describe('cropTemplateCoverImage', () => {\n    it('should generate thumbnails when cover image height > ATTACHMENT_LG_THUMBNAIL_HEIGHT', async () => {\n      // Upload an image taller than the threshold (525px)\n      const largeImageHeight = ATTACHMENT_LG_THUMBNAIL_HEIGHT + 200; // 725px\n      const { notifyData } = await uploadTemplateCoverImage(largeImageHeight);\n\n      // Prepare cover data\n      const cover: ITemplateCoverRo = {\n        id: generateAttachmentId(),\n        name: `cover-${largeImageHeight}.svg`,\n        token: notifyData.token,\n        size: notifyData.size,\n        url: notifyData.url,\n        path: notifyData.path,\n        mimetype: notifyData.mimetype,\n        width: notifyData.width,\n        height: notifyData.height,\n      };\n\n      // Publish base with cover\n      const result = await publishBase(baseId, {\n        title: 'Test Template with Large Cover',\n        description: 'Testing crop template cover image',\n        cover,\n      });\n\n      expect(result.status).toBe(201);\n\n      // Verify the template has thumbnail paths in cover\n      const template = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n\n      expect(template).toBeDefined();\n      expect(template?.cover).toBeDefined();\n\n      const savedCover = JSON.parse(template!.cover as string) as ITemplateCoverRo;\n      expect(savedCover.thumbnailPath).toBeDefined();\n      expect(savedCover.thumbnailPath?.lg).toBeDefined();\n      expect(savedCover.thumbnailPath?.sm).toBeDefined();\n      expect(savedCover.thumbnailPath?.lg).toContain('_lg');\n      expect(savedCover.thumbnailPath?.sm).toContain('_sm');\n    });\n\n    it('should NOT generate thumbnails when cover image height <= ATTACHMENT_LG_THUMBNAIL_HEIGHT', async () => {\n      // Upload a small image (below threshold)\n      const smallImageHeight = ATTACHMENT_LG_THUMBNAIL_HEIGHT - 100; // 425px\n      const { notifyData } = await uploadTemplateCoverImage(smallImageHeight);\n\n      // Prepare cover data\n      const cover: ITemplateCoverRo = {\n        id: generateAttachmentId(),\n        name: `cover-${smallImageHeight}.svg`,\n        token: notifyData.token,\n        size: notifyData.size,\n        url: notifyData.url,\n        path: notifyData.path,\n        mimetype: notifyData.mimetype,\n        width: notifyData.width,\n        height: notifyData.height,\n      };\n\n      // Publish base with cover\n      const result = await publishBase(baseId, {\n        title: 'Test Template with Small Cover',\n        description: 'Testing crop template cover image with small image',\n        cover,\n      });\n\n      expect(result.status).toBe(201);\n\n      // Verify the template does NOT have thumbnail paths (image too small)\n      const template = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n\n      expect(template).toBeDefined();\n      expect(template?.cover).toBeDefined();\n\n      const savedCover = JSON.parse(template!.cover as string) as ITemplateCoverRo;\n      // thumbnailPath should be undefined because image height <= threshold\n      expect(savedCover.thumbnailPath).toBeUndefined();\n    });\n\n    it('should NOT generate thumbnails when cover has no height info', async () => {\n      // Upload an image but manually remove height info\n      const imageHeight = ATTACHMENT_LG_THUMBNAIL_HEIGHT + 200;\n      const { notifyData } = await uploadTemplateCoverImage(imageHeight);\n\n      // Prepare cover data WITHOUT height\n      const cover: ITemplateCoverRo = {\n        id: generateAttachmentId(),\n        name: `cover-no-height.svg`,\n        token: notifyData.token,\n        size: notifyData.size,\n        url: notifyData.url,\n        path: notifyData.path,\n        mimetype: notifyData.mimetype,\n        width: notifyData.width,\n        // height intentionally omitted\n      };\n\n      // Publish base with cover\n      const result = await publishBase(baseId, {\n        title: 'Test Template without Height Info',\n        description: 'Testing crop template cover image without height',\n        cover,\n      });\n\n      expect(result.status).toBe(201);\n\n      // Verify the template does NOT have thumbnail paths (no height info)\n      const template = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n\n      expect(template).toBeDefined();\n      expect(template?.cover).toBeDefined();\n\n      const savedCover = JSON.parse(template!.cover as string) as ITemplateCoverRo;\n      expect(savedCover.thumbnailPath).toBeUndefined();\n    });\n\n    it('should NOT generate thumbnails for non-image mimetype', async () => {\n      // Create a non-image file\n      const filePath = path.join(StorageAdapter.TEMPORARY_DIR, `template-cover-text.txt`);\n      fs.writeFileSync(filePath, 'This is not an image');\n\n      try {\n        const stats = fs.statSync(filePath);\n\n        // Get upload signature\n        const signatureResult = await getSignature({\n          type: UploadType.Template,\n          contentType: 'text/plain',\n          contentLength: stats.size,\n        });\n\n        const { token, requestHeaders } = signatureResult.data;\n\n        // Upload the file\n        const fileStream = fs.createReadStream(filePath);\n        await uploadFile(token, fileStream, requestHeaders);\n\n        // Notify to get file info\n        const notifyResult = await notify(token, undefined, 'cover.txt');\n\n        // Prepare cover data with non-image mimetype\n        const cover: ITemplateCoverRo = {\n          id: generateAttachmentId(),\n          name: 'cover.txt',\n          token: notifyResult.data.token,\n          size: notifyResult.data.size,\n          url: notifyResult.data.url,\n          path: notifyResult.data.path,\n          mimetype: notifyResult.data.mimetype, // text/plain\n          width: 1000, // Fake dimensions\n          height: 1000,\n        };\n\n        // Publish base with non-image cover\n        const result = await publishBase(baseId, {\n          title: 'Test Template with Non-Image Cover',\n          description: 'Testing crop template cover image with non-image file',\n          cover,\n        });\n\n        expect(result.status).toBe(201);\n\n        // Verify the template does NOT have thumbnail paths (not an image)\n        const template = await prismaService.txClient().template.findFirst({\n          where: { baseId },\n          select: { cover: true },\n        });\n\n        expect(template).toBeDefined();\n        expect(template?.cover).toBeDefined();\n\n        const savedCover = JSON.parse(template!.cover as string) as ITemplateCoverRo;\n        expect(savedCover.thumbnailPath).toBeUndefined();\n      } finally {\n        if (fs.existsSync(filePath)) {\n          fs.unlinkSync(filePath);\n        }\n      }\n    });\n\n    it('should update thumbnails when republishing with new cover', async () => {\n      // First publish with a large image\n      const firstImageHeight = ATTACHMENT_LG_THUMBNAIL_HEIGHT + 100;\n      const { notifyData: firstNotifyData } = await uploadTemplateCoverImage(firstImageHeight);\n\n      const firstCover: ITemplateCoverRo = {\n        id: generateAttachmentId(),\n        name: `cover-first.svg`,\n        token: firstNotifyData.token,\n        size: firstNotifyData.size,\n        url: firstNotifyData.url,\n        path: firstNotifyData.path,\n        mimetype: firstNotifyData.mimetype,\n        width: firstNotifyData.width,\n        height: firstNotifyData.height,\n      };\n\n      await publishBase(baseId, {\n        title: 'Test Template First Publish',\n        description: 'First publish',\n        cover: firstCover,\n      });\n\n      // Get first template's thumbnail paths\n      const firstTemplate = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n      const firstSavedCover = JSON.parse(firstTemplate!.cover as string) as ITemplateCoverRo;\n      const firstThumbnailPaths = firstSavedCover.thumbnailPath;\n\n      expect(firstThumbnailPaths).toBeDefined();\n\n      // Republish with a different large image\n      const secondImageHeight = ATTACHMENT_LG_THUMBNAIL_HEIGHT + 300;\n      const { notifyData: secondNotifyData } = await uploadTemplateCoverImage(secondImageHeight);\n\n      const secondCover: ITemplateCoverRo = {\n        id: generateAttachmentId(),\n        name: `cover-second.svg`,\n        token: secondNotifyData.token,\n        size: secondNotifyData.size,\n        url: secondNotifyData.url,\n        path: secondNotifyData.path,\n        mimetype: secondNotifyData.mimetype,\n        width: secondNotifyData.width,\n        height: secondNotifyData.height,\n      };\n\n      await publishBase(baseId, {\n        title: 'Test Template Second Publish',\n        description: 'Second publish',\n        cover: secondCover,\n      });\n\n      // Verify the template has NEW thumbnail paths\n      const secondTemplate = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n\n      const secondSavedCover = JSON.parse(secondTemplate!.cover as string) as ITemplateCoverRo;\n      expect(secondSavedCover.thumbnailPath).toBeDefined();\n      expect(secondSavedCover.thumbnailPath?.lg).toBeDefined();\n      expect(secondSavedCover.thumbnailPath?.sm).toBeDefined();\n\n      // Thumbnail paths should be different from the first publish\n      expect(secondSavedCover.thumbnailPath?.lg).not.toBe(firstThumbnailPaths?.lg);\n      expect(secondSavedCover.thumbnailPath?.sm).not.toBe(firstThumbnailPaths?.sm);\n    });\n\n    it('should publish without cover successfully', async () => {\n      // Publish base without cover\n      const result = await publishBase(baseId, {\n        title: 'Test Template without Cover',\n        description: 'Testing publish without cover',\n      });\n\n      expect(result.status).toBe(201);\n\n      // Verify the template has no cover\n      const template = await prismaService.txClient().template.findFirst({\n        where: { baseId },\n        select: { cover: true },\n      });\n\n      expect(template).toBeDefined();\n      expect(template?.cover).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/template-preview.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, ViewType, NumberFormattingType, HttpError } from '@teable/core';\nimport {\n  IS_TEMPLATE_HEADER,\n  axios as defaultAxios,\n  createAxios,\n  createBase,\n  createField,\n  createRecords,\n  createSpace,\n  createTemplate,\n  createTemplateSnapshot,\n  deleteBase,\n  getBaseById,\n  getTemplateDetail,\n  updateTemplate,\n  deleteTemplate,\n  permanentDeleteSpace,\n} from '@teable/openapi';\nimport type { IGetBaseVo, ITableFullVo, ITableListVo } from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { TemplateAppTokenNotAllowedException } from '../src/custom.exception';\nimport { AuthService } from '../src/features/auth/auth.service';\nimport { PermissionService } from '../src/features/auth/permission.service';\nimport { JwtAuthInternalType } from '../src/features/auth/strategies/types';\nimport { createNewUserAxios } from './utils/axios-instance/new-user';\nimport { createTable, createView, initApp, permanentDeleteBase } from './utils/init-app';\n\ndescribe('Template Preview Permission (e2e)', () => {\n  let app: INestApplication;\n  let permissionService: PermissionService;\n  let spaceId: string;\n  let baseId: string;\n  let templateBaseId: string;\n  let templateId: string;\n  let templateHeader: string;\n  let table: ITableFullVo;\n  let tableId: string;\n\n  // Factory function to create apiRequest with specific axios instance\n  const createApiRequest = (axiosInstance: AxiosInstance) => {\n    return async <T = any>(\n      method: string,\n      url: string,\n      data?: any\n    ): Promise<{ status: number; data: T }> => {\n      try {\n        const res = await axiosInstance.request<T>({\n          method,\n          url,\n          data,\n          headers: {\n            [IS_TEMPLATE_HEADER]: templateHeader,\n          },\n        });\n        return { status: res.status, data: res.data };\n      } catch (err: any) {\n        if (err instanceof HttpError) {\n          return { status: err.status, data: err.data as T };\n        }\n        return { status: err.response?.status || 500, data: err.response?.data as T };\n      }\n    };\n  };\n\n  beforeAll(async () => {\n    const appContext = await initApp();\n    app = appContext.app;\n    permissionService = app.get(PermissionService);\n\n    const spaceData = await createSpace({\n      name: 'test Template Space',\n    });\n    spaceId = spaceData.data.id;\n  });\n\n  afterAll(async () => {\n    await permanentDeleteSpace(spaceId);\n  });\n\n  beforeEach(async () => {\n    // Create a normal base\n    const { id } = (\n      await createBase({\n        name: 'test base',\n        spaceId,\n      })\n    ).data;\n    baseId = id;\n\n    // Create a table in the base\n    table = await createTable(baseId, {\n      name: 'Table 1',\n      fields: [\n        {\n          name: 'Name',\n          type: FieldType.SingleLineText,\n        },\n      ],\n    });\n\n    tableId = table.id;\n\n    // Add more fields\n    await createField(tableId, {\n      name: 'NumberField',\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        },\n      },\n    });\n\n    // Create some records\n    await createRecords(tableId, {\n      records: [\n        { fields: { Name: 'Record 1', NumberField: 100 } },\n        { fields: { Name: 'Record 2', NumberField: 200 } },\n      ],\n    });\n\n    // Create a template from this base\n    const template = await createTemplate({});\n    templateId = template.data.id;\n\n    await updateTemplate(templateId, {\n      name: 'Test Template',\n      description: 'Test Template Description',\n      baseId: baseId,\n    });\n\n    await createTemplateSnapshot(templateId);\n    await updateTemplate(templateId, {\n      isPublished: true,\n    });\n\n    const templateDetail = await getTemplateDetail(templateId);\n    templateBaseId = templateDetail.data.snapshot.baseId!;\n\n    // Generate template header for authentication\n    templateHeader = permissionService.generateTemplateHeader(templateId);\n  });\n\n  afterEach(async () => {\n    await deleteTemplate(templateId);\n    await permanentDeleteBase(baseId);\n  });\n\n  // Test suite factory that runs with different axios instances\n  const runTemplatePermissionTests = (\n    description: string,\n    getAxios: () => AxiosInstance,\n    isAnonymous?: boolean\n  ) => {\n    describe(description, () => {\n      let apiRequest: ReturnType<typeof createApiRequest>;\n\n      beforeAll(() => {\n        const axiosInstance = getAxios();\n        axiosInstance.defaults.baseURL = defaultAxios.defaults.baseURL;\n        apiRequest = createApiRequest(axiosInstance);\n      });\n\n      describe('Base Read Operations', () => {\n        it('should allow getBaseById with valid template header', async () => {\n          const res = await apiRequest('GET', `/base/${templateBaseId}`);\n          expect(res.status).toBe(200);\n          expect(res.data.id).toBe(templateBaseId);\n          expect(res.data.name).toBe('Test Template');\n        });\n\n        it('should allow reading base permission with template header', async () => {\n          const res = await apiRequest('GET', `/base/${templateBaseId}/permission`);\n          expect(res.status).toBe(200);\n          // Template should only have read permissions\n          expect(res.data['base|read']).toBe(true);\n          expect(res.data['base|update']).toBe(false);\n          expect(res.data['base|delete']).toBe(false);\n          expect(res.data['table|create']).toBe(false);\n        });\n      });\n\n      describe('Base Write Operations - Should be Denied', () => {\n        it('should deny updateBase with template header', async () => {\n          const res = await apiRequest('PATCH', `/base/${templateBaseId}`, {\n            name: 'Updated Name',\n          });\n          expect([401, 403]).toContain(res.status);\n        });\n\n        it('should deny deleteBase with template header', async () => {\n          const res = await apiRequest('DELETE', `/base/${templateBaseId}`);\n          expect([401, 403]).toContain(res.status);\n        });\n\n        it('should deny creating invitation link with template header', async () => {\n          const res = await apiRequest('POST', `/base/${templateBaseId}/invitation/link`, {\n            role: 'viewer',\n          });\n          expect([401, 403]).toContain(res.status);\n        });\n      });\n\n      describe('Table Read Operations', () => {\n        it('should allow getTableList with template header', async () => {\n          const res = await apiRequest('GET', `/base/${templateBaseId}/table`);\n          expect(res.status).toBe(200);\n          expect(res.data.length).toBeGreaterThan(0);\n        });\n\n        it('should allow getting single table with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('GET', `/base/${templateBaseId}/table/${testTableId}`);\n          expect(res.status).toBe(200);\n          expect(res.data.id).toBe(testTableId);\n        });\n\n        it('should allow reading table permission with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest(\n            'GET',\n            `/base/${templateBaseId}/table/${testTableId}/permission`\n          );\n          expect(res.status).toBe(200);\n          expect(res.data.table['table|read']).toBe(true);\n          expect(res.data.table['table|create']).toBe(false);\n          expect(res.data.table['table|update']).toBe(false);\n          expect(res.data.table['table|delete']).toBe(false);\n        });\n      });\n\n      describe('Table Write Operations - Should be Denied', () => {\n        it('should deny createTable with template header', async () => {\n          const res = await apiRequest('POST', `/base/${templateBaseId}/table`, {\n            name: 'New Table',\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny updateTable with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('PUT', `/base/${templateBaseId}/table/${testTableId}/name`, {\n            name: 'Updated Table Name',\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny deleteTable with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('DELETE', `/base/${templateBaseId}/table/${testTableId}`);\n          expect(res.status).toBe(403);\n        });\n      });\n\n      describe('Field Read Operations', () => {\n        it('should allow getFields with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/field`);\n          expect(res.status).toBe(200);\n          expect(res.data.length).toBeGreaterThan(0);\n        });\n\n        it('should allow getting single field with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const fieldsRes = await apiRequest<any[]>('GET', `/table/${testTableId}/field`);\n          const fieldId = fieldsRes.data[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/field/${fieldId}`);\n          expect(res.status).toBe(200);\n        });\n      });\n\n      describe('Field Write Operations - Should be Denied', () => {\n        it('should deny createField with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('POST', `/table/${testTableId}/field`, {\n            name: 'New Field',\n            type: FieldType.SingleLineText,\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny updateField with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const fieldsRes = await apiRequest<any[]>('GET', `/table/${testTableId}/field`);\n          const fieldId = fieldsRes.data[0].id;\n\n          const res = await apiRequest('PATCH', `/table/${testTableId}/field/${fieldId}`, {\n            name: 'Updated Field Name',\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny deleteField with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const fieldsRes = await apiRequest<any[]>('GET', `/table/${testTableId}/field`);\n          const fieldId = fieldsRes.data[0].id;\n\n          const res = await apiRequest('DELETE', `/table/${testTableId}/field/${fieldId}`);\n          expect(res.status).toBe(403);\n        });\n      });\n\n      describe('View Read Operations', () => {\n        it('should allow getViews with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/view`);\n          expect(res.status).toBe(200);\n          expect(res.data.length).toBeGreaterThan(0);\n        });\n\n        it('should allow getting single view with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const viewsRes = await apiRequest<any[]>('GET', `/table/${testTableId}/view`);\n          const viewId = viewsRes.data[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/view/${viewId}`);\n          expect(res.status).toBe(200);\n        });\n      });\n\n      describe('View Write Operations - Should be Denied', () => {\n        it('should deny createView with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('POST', `/table/${testTableId}/view`, {\n            name: 'New View',\n            type: ViewType.Grid,\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny updateView with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const viewsRes = await apiRequest<any[]>('GET', `/table/${testTableId}/view`);\n          const viewId = viewsRes.data[0].id;\n\n          const res = await apiRequest('PUT', `/table/${testTableId}/view/${viewId}/name`, {\n            name: 'Updated View Name',\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny deleteView with template header', async () => {\n          // Create a new view first to avoid deleting the default view\n          const newView = await createView(tableId, { name: 'Test View', type: ViewType.Grid });\n          const viewId = newView.id;\n\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('DELETE', `/table/${testTableId}/view/${viewId}`);\n          expect(res.status).toBe(403);\n        });\n      });\n\n      describe('Record Read Operations', () => {\n        it('should allow getRecords with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/record`);\n          expect(res.status).toBe(200);\n          expect(res.data.records.length).toBeGreaterThan(0);\n        });\n\n        it('should allow getting single record with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const recordsRes = await apiRequest<any>('GET', `/table/${testTableId}/record`);\n          const recordId = recordsRes.data.records[0].id;\n\n          const res = await apiRequest('GET', `/table/${testTableId}/record/${recordId}`);\n          expect(res.status).toBe(200);\n        });\n      });\n\n      describe('Record Write Operations - Should be Denied', () => {\n        it('should deny createRecords with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const res = await apiRequest('POST', `/table/${testTableId}/record`, {\n            records: [{ fields: { Name: 'New Record' } }],\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny updateRecord with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const recordsRes = await apiRequest<any>('GET', `/table/${testTableId}/record`);\n          const recordId = recordsRes.data.records[0].id;\n\n          const res = await apiRequest('PATCH', `/table/${testTableId}/record/${recordId}`, {\n            fields: { Name: 'Updated Name' },\n          });\n          expect(res.status).toBe(403);\n        });\n\n        it('should deny deleteRecord with template header', async () => {\n          const tablesRes = await apiRequest<any[]>('GET', `/base/${templateBaseId}/table`);\n          const testTableId = tablesRes.data[0].id;\n\n          const recordsRes = await apiRequest<any>('GET', `/table/${testTableId}/record`);\n          const recordId = recordsRes.data.records[0].id;\n\n          const res = await apiRequest('DELETE', `/table/${testTableId}/record/${recordId}`);\n          expect(res.status).toBe(403);\n        });\n      });\n\n      describe('Permission Isolation - No Cross-Resource Permission Leakage', () => {\n        it('should not allow accessing other bases with template header', async () => {\n          const anotherBase = await createBase({\n            name: 'Another Base',\n            spaceId,\n          });\n\n          const res = await apiRequest('GET', `/base/${anotherBase.data.id}`);\n          expect(res.status).toBe(isAnonymous ? 401 : 403);\n\n          await deleteBase(anotherBase.data.id);\n        });\n\n        it('should not allow accessing tables from other bases', async () => {\n          const anotherBase = await createBase({\n            name: 'Another Base',\n            spaceId,\n          });\n          await createTable(anotherBase.data.id, {\n            name: 'Another Table',\n          });\n\n          const res = await apiRequest('GET', `/base/${anotherBase.data.id}/table`);\n          expect(res.status).toBe(isAnonymous ? 401 : 403);\n\n          await deleteBase(anotherBase.data.id);\n        });\n      });\n\n      describe('Template Header Validation', () => {\n        it('should reject expired or malformed template headers', async () => {\n          const invalidHeaders = [\n            'invalid-jwt',\n            'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature',\n            'xxxxx',\n            'Bearer token',\n          ];\n\n          for (const invalidHeader of invalidHeaders) {\n            try {\n              const axiosInstance = getAxios();\n              await axiosInstance.get(`/base/${templateBaseId}/table`, {\n                headers: {\n                  [IS_TEMPLATE_HEADER]: invalidHeader,\n                },\n              });\n              throw new Error('Should have thrown 403');\n            } catch (error: any) {\n              expect(error.status).toBe(isAnonymous ? 401 : 403);\n            }\n          }\n        });\n      });\n    });\n  };\n\n  // Run tests with anonymous user (no authentication)\n  describe('Anonymous User Tests', () => {\n    let anonymousAxios: AxiosInstance;\n\n    beforeAll(() => {\n      anonymousAxios = createAxios();\n    });\n\n    runTemplatePermissionTests('Anonymous user with template header', () => anonymousAxios, true);\n  });\n\n  // Run tests with authenticated new user (not a collaborator)\n  describe('Authenticated Non-Collaborator Tests', () => {\n    let newUserAxios: AxiosInstance;\n    const newUserEmail = 'template-test-user@example.com';\n\n    beforeAll(async () => {\n      newUserAxios = await createNewUserAxios({\n        email: newUserEmail,\n        password: '12345678',\n      });\n    });\n\n    runTemplatePermissionTests('Authenticated user with template header', () => newUserAxios);\n  });\n\n  describe('Normal Base Access (Without Template Header)', () => {\n    it('should work without template header for authenticated requests', async () => {\n      const res = await getBaseById(templateBaseId);\n      expect(res.status).toBe(200);\n      expect(res.data.id).toBe(templateBaseId);\n      expect(res.data.template).toBeDefined();\n    });\n\n    it('should work without template header for anonymous requests', async () => {\n      const anonymousAxios = createAxios();\n      anonymousAxios.defaults.baseURL = defaultAxios.defaults.baseURL;\n      const res = await anonymousAxios.get<IGetBaseVo>(`/base/${templateBaseId}`);\n      expect(res.status).toBe(200);\n      expect(res.data.id).toBe(templateBaseId);\n      expect(res.data.template).toBeDefined();\n    });\n  });\n\n  describe('Template preview app token operations', () => {\n    let appToken: string;\n    const anonymousAxios = createAxios();\n    let authService: AuthService;\n\n    beforeAll(async () => {\n      authService = app.get(AuthService);\n    });\n\n    beforeEach(async () => {\n      const { accessToken } = await authService.getTempInternalToken(\n        templateBaseId,\n        JwtAuthInternalType.App\n      );\n      appToken = accessToken;\n      anonymousAxios.defaults.baseURL = defaultAxios.defaults.baseURL;\n    });\n    it('should allow getTableList with valid app token', async () => {\n      const res = await anonymousAxios.get<ITableListVo>(`/base/${templateBaseId}/table`, {\n        headers: {\n          Authorization: `Bearer ${appToken}`,\n        },\n      });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBeGreaterThan(0);\n    });\n\n    it('should allow createTable with valid app token', async () => {\n      const res = await anonymousAxios.post<ITableFullVo>(\n        `/base/${templateBaseId}/table`,\n        {\n          name: 'New Table',\n        },\n        {\n          headers: {\n            Authorization: `Bearer ${appToken}`,\n          },\n        }\n      );\n      expect(res.status).toBe(200);\n      expect(res.data).toMatchObject({\n        message: new TemplateAppTokenNotAllowedException().message,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/template.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ITableFullVo } from '@teable/openapi';\nimport {\n  createBase,\n  createBaseFromTemplate,\n  createSpace,\n  createTable,\n  createTemplate,\n  createTemplateCategory,\n  createTemplateSnapshot,\n  deleteBase,\n  deleteTemplate,\n  deleteTemplateCategory,\n  getBaseById,\n  getFields,\n  getPublishedTemplateList,\n  getTableList,\n  getTemplateCategoryList,\n  getTemplateList,\n  getTemplatePermalink,\n  pinTopTemplate,\n  updateTemplate,\n  updateTemplateCategory,\n  updateTemplateCategoryOrder,\n  updateTemplateOrder,\n} from '@teable/openapi';\nimport { omit } from 'lodash';\nimport { deleteSpace, initApp } from './utils/init-app';\n\ndescribe('Template Open API Controller (e2e)', () => {\n  let app: INestApplication;\n  let prismaService: PrismaService;\n  const spaceId = globalThis.testConfig.spaceId;\n  let baseId: string;\n  let templateSpaceId: string;\n\n  beforeAll(async () => {\n    const appContext = await initApp();\n    app = appContext.app;\n    prismaService = app.get(PrismaService);\n    const tx = prismaService.txClient();\n    await tx.space.update({\n      where: {\n        id: 'spcDefaultTempSpcId',\n      },\n      data: {\n        isTemplate: null,\n      },\n    });\n    const spaceData = await createSpace({\n      name: 'test Template Space',\n    });\n    await tx.space.update({\n      where: {\n        id: spaceData.data.id,\n      },\n      data: {\n        createdBy: 'system',\n        isTemplate: true,\n      },\n    });\n    templateSpaceId = spaceData.data.id;\n  });\n\n  afterAll(async () => {\n    await deleteSpace(templateSpaceId);\n  });\n\n  beforeEach(async () => {\n    const { id } = (\n      await createBase({\n        name: 'test base',\n        spaceId,\n      })\n    ).data;\n    baseId = id;\n  });\n\n  afterEach(async () => {\n    const tx = prismaService.txClient();\n    await tx.templateCategory.deleteMany({\n      where: {},\n    });\n    await tx.template.deleteMany({\n      where: {},\n    });\n    await deleteBase(baseId);\n  });\n\n  it('should create a empty template', async () => {\n    const res = await createTemplate({});\n    expect(res.status).toBe(201);\n    expect(res.data).toBeDefined();\n  });\n\n  it('should get template list', async () => {\n    const res1 = await getTemplateList();\n    expect(res1.status).toBe(200);\n    expect(res1.data.length).toBe(0);\n\n    await createTemplate({});\n    const res2 = await getTemplateList();\n    expect(res2.status).toBe(200);\n    expect(res2.data.length).toBe(1);\n  });\n\n  it('should get published template list', async () => {\n    const res1 = await getPublishedTemplateList();\n    expect(res1.status).toBe(200);\n    expect(res1.data.length).toBe(0);\n\n    const template = await createTemplate({});\n    await updateTemplate(template.data.id, {\n      name: 'test Template',\n      description: 'test Template description',\n      baseId: baseId,\n    });\n\n    await createTemplateSnapshot(template.data.id);\n    await updateTemplate(template.data.id, {\n      isPublished: true,\n    });\n    const res2 = await getPublishedTemplateList();\n    expect(res2.status).toBe(200);\n    expect(res2.data.length).toBe(1);\n  });\n\n  it('should pin-top template', async () => {\n    const tmp1 = await createTemplate({});\n    const tmp2 = await createTemplate({});\n    const tmp3 = await createTemplate({});\n\n    const tmpList = await getTemplateList();\n    expect(tmpList.status).toBe(200);\n    expect(tmpList.data.length).toBe(3);\n    expect(tmpList.data.map(({ id }) => id)).toEqual([tmp1.data.id, tmp2.data.id, tmp3.data.id]);\n\n    await pinTopTemplate(tmp3.data.id);\n\n    const tmpList2 = await getTemplateList();\n    expect(tmpList2.status).toBe(200);\n    expect(tmpList2.data.length).toBe(3);\n    expect(tmpList2.data.map(({ id }) => id)).toEqual([tmp3.data.id, tmp1.data.id, tmp2.data.id]);\n  });\n\n  describe('Template Order', () => {\n    beforeEach(async () => {\n      // Ensure database is clean before each test\n      const tx = prismaService.txClient();\n      await tx.template.deleteMany({\n        where: {},\n      });\n    });\n\n    it('should update template order - move to before anchor', async () => {\n      // Create 3 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n\n      // Initial order: [tmp1, tmp2, tmp3]\n      const initialList = await getTemplateList();\n      expect(initialList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n      ]);\n\n      // Move tmp3 before tmp1\n      await updateTemplateOrder({\n        templateId: tmp3.data.id,\n        anchorId: tmp1.data.id,\n        position: 'before',\n      });\n\n      // Expected order: [tmp3, tmp1, tmp2]\n      const updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp3.data.id,\n        tmp1.data.id,\n        tmp2.data.id,\n      ]);\n    });\n\n    it('should update template order - move to after anchor', async () => {\n      // Create 3 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n\n      // Initial order: [tmp1, tmp2, tmp3]\n      const initialList = await getTemplateList();\n      expect(initialList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n      ]);\n\n      // Move tmp1 after tmp3\n      await updateTemplateOrder({\n        templateId: tmp1.data.id,\n        anchorId: tmp3.data.id,\n        position: 'after',\n      });\n\n      // Expected order: [tmp2, tmp3, tmp1]\n      const updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp2.data.id,\n        tmp3.data.id,\n        tmp1.data.id,\n      ]);\n    });\n\n    it('should update template order - move middle item before first', async () => {\n      // Create 3 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n\n      // Initial order: [tmp1, tmp2, tmp3]\n      // Move tmp2 before tmp1\n      await updateTemplateOrder({\n        templateId: tmp2.data.id,\n        anchorId: tmp1.data.id,\n        position: 'before',\n      });\n\n      // Expected order: [tmp2, tmp1, tmp3]\n      const updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp2.data.id,\n        tmp1.data.id,\n        tmp3.data.id,\n      ]);\n    });\n\n    it('should update template order - move middle item after last', async () => {\n      // Create 3 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n\n      // Initial order: [tmp1, tmp2, tmp3]\n      // Move tmp2 after tmp3\n      await updateTemplateOrder({\n        templateId: tmp2.data.id,\n        anchorId: tmp3.data.id,\n        position: 'after',\n      });\n\n      // Expected order: [tmp1, tmp3, tmp2]\n      const updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp3.data.id,\n        tmp2.data.id,\n      ]);\n    });\n\n    it('should update template order - complex reordering', async () => {\n      // Create 5 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n      const tmp4 = await createTemplate({});\n      const tmp5 = await createTemplate({});\n\n      // Initial order: [tmp1, tmp2, tmp3, tmp4, tmp5]\n      const initialList = await getTemplateList();\n      expect(initialList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n        tmp4.data.id,\n        tmp5.data.id,\n      ]);\n\n      // Move tmp5 before tmp2\n      await updateTemplateOrder({\n        templateId: tmp5.data.id,\n        anchorId: tmp2.data.id,\n        position: 'before',\n      });\n\n      // Expected order: [tmp1, tmp5, tmp2, tmp3, tmp4]\n      let updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp5.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n        tmp4.data.id,\n      ]);\n\n      // Move tmp1 after tmp4\n      await updateTemplateOrder({\n        templateId: tmp1.data.id,\n        anchorId: tmp4.data.id,\n        position: 'after',\n      });\n\n      // Expected order: [tmp5, tmp2, tmp3, tmp4, tmp1]\n      updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp5.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n        tmp4.data.id,\n        tmp1.data.id,\n      ]);\n\n      // Move tmp3 before tmp5\n      await updateTemplateOrder({\n        templateId: tmp3.data.id,\n        anchorId: tmp5.data.id,\n        position: 'before',\n      });\n\n      // Expected order: [tmp3, tmp5, tmp2, tmp4, tmp1]\n      updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp3.data.id,\n        tmp5.data.id,\n        tmp2.data.id,\n        tmp4.data.id,\n        tmp1.data.id,\n      ]);\n    });\n\n    it('should handle adjacent template reordering', async () => {\n      // Create 3 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n\n      // Move tmp2 after tmp1 (already in this position, but should work)\n      await updateTemplateOrder({\n        templateId: tmp2.data.id,\n        anchorId: tmp1.data.id,\n        position: 'after',\n      });\n\n      // Order should remain: [tmp1, tmp2, tmp3]\n      let updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp1.data.id,\n        tmp2.data.id,\n        tmp3.data.id,\n      ]);\n\n      // Swap tmp1 and tmp2 by moving tmp1 after tmp2\n      await updateTemplateOrder({\n        templateId: tmp1.data.id,\n        anchorId: tmp2.data.id,\n        position: 'after',\n      });\n\n      // Expected order: [tmp2, tmp1, tmp3]\n      updatedList = await getTemplateList();\n      expect(updatedList.data.map(({ id }) => id)).toEqual([\n        tmp2.data.id,\n        tmp1.data.id,\n        tmp3.data.id,\n      ]);\n    });\n\n    it('should maintain order consistency after multiple operations', async () => {\n      // Create 4 templates\n      const tmp1 = await createTemplate({});\n      const tmp2 = await createTemplate({});\n      const tmp3 = await createTemplate({});\n      const tmp4 = await createTemplate({});\n\n      // Perform multiple reordering operations\n      await updateTemplateOrder({\n        templateId: tmp4.data.id,\n        anchorId: tmp1.data.id,\n        position: 'before',\n      });\n      // Order: [tmp4, tmp1, tmp2, tmp3]\n\n      await updateTemplateOrder({\n        templateId: tmp2.data.id,\n        anchorId: tmp4.data.id,\n        position: 'before',\n      });\n      // Order: [tmp2, tmp4, tmp1, tmp3]\n\n      await updateTemplateOrder({\n        templateId: tmp3.data.id,\n        anchorId: tmp2.data.id,\n        position: 'after',\n      });\n      // Order: [tmp2, tmp3, tmp4, tmp1]\n\n      const finalList = await getTemplateList();\n      expect(finalList.data.map(({ id }) => id)).toEqual([\n        tmp2.data.id,\n        tmp3.data.id,\n        tmp4.data.id,\n        tmp1.data.id,\n      ]);\n    });\n  });\n\n  it('should support update template markdown description and get ', async () => {\n    const template = await createTemplate({});\n    await updateTemplate(template.data.id, {\n      markdownDescription: '# test markdown description',\n    });\n    const tmpList = await getTemplateList();\n    expect(tmpList.status).toBe(200);\n    expect(tmpList.data.length).toBe(1);\n    expect(tmpList.data[0].markdownDescription).toBe('# test markdown description');\n  });\n\n  it('should delete template', async () => {\n    const template = await createTemplate({});\n    const res1 = await getTemplateList();\n    expect(res1.status).toBe(200);\n    expect(res1.data.length).toBe(1);\n    await deleteTemplate(template.data.id);\n    const res2 = await getTemplateList();\n    expect(res2.status).toBe(200);\n    expect(res2.data.length).toBe(0);\n  });\n\n  describe('Template List Pagination', () => {\n    it('should paginate template list with skip and take', async () => {\n      // Create 5 templates\n      await Promise.all([\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n      ]);\n\n      // Get all templates for verification\n      const allTemplates = await getTemplateList();\n      const allTemplateIds = allTemplates.data.map((t) => t.id);\n      expect(allTemplateIds.length).toBe(5);\n\n      // Get first 2 templates\n      const res1 = await getTemplateList({ skip: 0, take: 2 });\n      expect(res1.status).toBe(200);\n      expect(res1.data.length).toBe(2);\n      const res1Ids = res1.data.map((t) => t.id);\n\n      // Skip 2, get next 2 templates\n      const res2 = await getTemplateList({ skip: 2, take: 2 });\n      expect(res2.status).toBe(200);\n      expect(res2.data.length).toBe(2);\n      const res2Ids = res2.data.map((t) => t.id);\n\n      // Skip 4, get last 1 template\n      const res3 = await getTemplateList({ skip: 4, take: 2 });\n      expect(res3.status).toBe(200);\n      expect(res3.data.length).toBe(1);\n      const res3Ids = res3.data.map((t) => t.id);\n\n      // Verify all returned IDs are in the total list\n      const paginatedIds = [...res1Ids, ...res2Ids, ...res3Ids];\n      expect(paginatedIds.every((id) => allTemplateIds.includes(id))).toBe(true);\n\n      // Verify pagination results have no duplicates\n      expect(new Set(paginatedIds).size).toBe(5);\n\n      // Verify pagination results cover all templates\n      expect(paginatedIds.sort()).toEqual(allTemplateIds.sort());\n    });\n\n    it('should handle skip beyond total count', async () => {\n      // Create 3 templates\n      await Promise.all([createTemplate({}), createTemplate({}), createTemplate({})]);\n\n      // Skip 10 (beyond total count)\n      const res = await getTemplateList({ skip: 10, take: 5 });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(0);\n    });\n\n    it('should handle take with 0', async () => {\n      // Create 3 templates\n      await Promise.all([createTemplate({}), createTemplate({}), createTemplate({})]);\n\n      // Take is 0\n      const res = await getTemplateList({ skip: 0, take: 0 });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(0);\n    });\n\n    it('should return all templates when skip and take not provided', async () => {\n      // Create 5 templates\n      await Promise.all([\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n        createTemplate({}),\n      ]);\n\n      const res = await getTemplateList();\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(5);\n    });\n  });\n\n  describe('Published Template List Pagination', () => {\n    const publishedBases: string[] = [];\n\n    beforeEach(async () => {\n      // Create separate base for each template because base_id has unique constraint\n      for (let i = 0; i < 5; i++) {\n        const base = await createBase({\n          name: `test base ${i}`,\n          spaceId,\n        });\n        publishedBases.push(base.data.id);\n\n        const template = await createTemplate({});\n        await updateTemplate(template.data.id, {\n          name: `test Template ${i}`,\n          description: `test Template description ${i}`,\n          baseId: base.data.id,\n        });\n        await createTemplateSnapshot(template.data.id);\n        await updateTemplate(template.data.id, {\n          isPublished: true,\n        });\n      }\n    });\n\n    afterEach(async () => {\n      // Clean up created bases\n      for (const publishedBaseId of publishedBases) {\n        await deleteBase(publishedBaseId);\n      }\n      publishedBases.length = 0;\n    });\n\n    it('should paginate published template list with skip and take', async () => {\n      // Get first 2 templates\n      const res1 = await getPublishedTemplateList({ skip: 0, take: 2 });\n      expect(res1.status).toBe(200);\n      expect(res1.data.length).toBe(2);\n\n      // Skip 2, get next 2 templates\n      const res2 = await getPublishedTemplateList({ skip: 2, take: 2 });\n      expect(res2.status).toBe(200);\n      expect(res2.data.length).toBe(2);\n\n      // Skip 4, get last 1 template\n      const res3 = await getPublishedTemplateList({ skip: 4, take: 2 });\n      expect(res3.status).toBe(200);\n      expect(res3.data.length).toBe(1);\n    });\n\n    it('should handle skip beyond total published count', async () => {\n      // Skip 50 (beyond total count)\n      const res = await getPublishedTemplateList({ skip: 50, take: 5 });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(0);\n    });\n\n    it('should only return published templates with pagination', async () => {\n      // Create an unpublished template (without baseId to avoid unique constraint conflict)\n      const unpublishedTemplate = await createTemplate({});\n      await updateTemplate(unpublishedTemplate.data.id, {\n        name: 'unpublished template',\n        description: 'unpublished description',\n      });\n\n      // Get all published templates\n      const res = await getPublishedTemplateList({ skip: 0, take: 50 });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(5); // Should only have 5 published templates\n      expect(res.data.every((t) => t.id !== unpublishedTemplate.data.id)).toBe(true);\n    });\n\n    it('should paginate with search parameter', async () => {\n      // Search for templates containing 'Template 2'\n      const res = await getPublishedTemplateList({ skip: 0, take: 10, search: 'Template 2' });\n      expect(res.status).toBe(200);\n      expect(res.data.length).toBe(1);\n      expect(res.data[0].name).toBe('test Template 2');\n    });\n  });\n\n  describe('Template Category', () => {\n    it('should create template category', async () => {\n      const res = await createTemplateCategory({\n        name: 'crm',\n      });\n      expect(res.status).toBe(201);\n      expect(res.data?.name).toBe('crm');\n      expect(res.data?.order).toBe(1);\n\n      const res2 = await getTemplateCategoryList();\n      expect(res2.status).toBe(200);\n      expect(res2.data.length).toBe(1);\n    });\n\n    it('should update template category', async () => {\n      const res = await createTemplateCategory({\n        name: 'crm',\n      });\n      expect(res.status).toBe(201);\n      expect(res.data?.name).toBe('crm');\n\n      await updateTemplateCategory(res.data.id, {\n        name: 'crm2',\n      });\n\n      const res2 = await getTemplateCategoryList();\n      expect(res2.status).toBe(200);\n      expect(res2.data?.[0].name).toBe('crm2');\n    });\n\n    it('should delete template category', async () => {\n      const res = await createTemplateCategory({\n        name: 'crm',\n      });\n      expect(res.status).toBe(201);\n      expect(res.data?.name).toBe('crm');\n\n      await deleteTemplateCategory(res.data.id);\n\n      const res2 = await getTemplateCategoryList();\n      expect(res2.status).toBe(200);\n      expect(res2.data.length).toBe(0);\n    });\n\n    describe('Template Category Order', () => {\n      it('should update template category order - move to before anchor', async () => {\n        // Create 3 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n\n        // Initial order: [cat1, cat2, cat3]\n        const initialList = await getTemplateCategoryList();\n        expect(initialList.data.map(({ id }) => id)).toEqual([\n          cat1.data.id,\n          cat2.data.id,\n          cat3.data.id,\n        ]);\n\n        // Move cat3 before cat1\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat3.data.id,\n          anchorId: cat1.data.id,\n          position: 'before',\n        });\n\n        // Expected order: [cat3, cat1, cat2]\n        const updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat3.data.id,\n          cat1.data.id,\n          cat2.data.id,\n        ]);\n      });\n\n      it('should update template category order - move to after anchor', async () => {\n        // Create 3 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n\n        // Initial order: [cat1, cat2, cat3]\n        const initialList = await getTemplateCategoryList();\n        expect(initialList.data.map(({ id }) => id)).toEqual([\n          cat1.data.id,\n          cat2.data.id,\n          cat3.data.id,\n        ]);\n\n        // Move cat1 after cat3\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat1.data.id,\n          anchorId: cat3.data.id,\n          position: 'after',\n        });\n\n        // Expected order: [cat2, cat3, cat1]\n        const updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat2.data.id,\n          cat3.data.id,\n          cat1.data.id,\n        ]);\n      });\n\n      it('should update template category order - move middle item before first', async () => {\n        // Create 3 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n\n        // Initial order: [cat1, cat2, cat3]\n        // Move cat2 before cat1\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat2.data.id,\n          anchorId: cat1.data.id,\n          position: 'before',\n        });\n\n        // Expected order: [cat2, cat1, cat3]\n        const updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat2.data.id,\n          cat1.data.id,\n          cat3.data.id,\n        ]);\n      });\n\n      it('should update template category order - complex reordering', async () => {\n        // Create 5 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n        const cat4 = await createTemplateCategory({ name: 'category4' });\n        const cat5 = await createTemplateCategory({ name: 'category5' });\n\n        // Initial order: [cat1, cat2, cat3, cat4, cat5]\n        const initialList = await getTemplateCategoryList();\n        expect(initialList.data.map(({ id }) => id)).toEqual([\n          cat1.data.id,\n          cat2.data.id,\n          cat3.data.id,\n          cat4.data.id,\n          cat5.data.id,\n        ]);\n\n        // Move cat5 before cat2\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat5.data.id,\n          anchorId: cat2.data.id,\n          position: 'before',\n        });\n\n        // Expected order: [cat1, cat5, cat2, cat3, cat4]\n        let updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat1.data.id,\n          cat5.data.id,\n          cat2.data.id,\n          cat3.data.id,\n          cat4.data.id,\n        ]);\n\n        // Move cat1 after cat4\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat1.data.id,\n          anchorId: cat4.data.id,\n          position: 'after',\n        });\n\n        // Expected order: [cat5, cat2, cat3, cat4, cat1]\n        updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat5.data.id,\n          cat2.data.id,\n          cat3.data.id,\n          cat4.data.id,\n          cat1.data.id,\n        ]);\n      });\n\n      it('should handle adjacent category reordering', async () => {\n        // Create 3 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n\n        // Move cat2 after cat1 (already in this position, but should work)\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat2.data.id,\n          anchorId: cat1.data.id,\n          position: 'after',\n        });\n\n        // Order should remain: [cat1, cat2, cat3]\n        let updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat1.data.id,\n          cat2.data.id,\n          cat3.data.id,\n        ]);\n\n        // Swap cat1 and cat2 by moving cat1 after cat2\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat1.data.id,\n          anchorId: cat2.data.id,\n          position: 'after',\n        });\n\n        // Expected order: [cat2, cat1, cat3]\n        updatedList = await getTemplateCategoryList();\n        expect(updatedList.data.map(({ id }) => id)).toEqual([\n          cat2.data.id,\n          cat1.data.id,\n          cat3.data.id,\n        ]);\n      });\n\n      it('should maintain order consistency after multiple operations', async () => {\n        // Create 4 categories\n        const cat1 = await createTemplateCategory({ name: 'category1' });\n        const cat2 = await createTemplateCategory({ name: 'category2' });\n        const cat3 = await createTemplateCategory({ name: 'category3' });\n        const cat4 = await createTemplateCategory({ name: 'category4' });\n\n        // Perform multiple reordering operations\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat4.data.id,\n          anchorId: cat1.data.id,\n          position: 'before',\n        });\n        // Order: [cat4, cat1, cat2, cat3]\n\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat2.data.id,\n          anchorId: cat4.data.id,\n          position: 'before',\n        });\n        // Order: [cat2, cat4, cat1, cat3]\n\n        await updateTemplateCategoryOrder({\n          templateCategoryId: cat3.data.id,\n          anchorId: cat2.data.id,\n          position: 'after',\n        });\n        // Order: [cat2, cat3, cat4, cat1]\n\n        const finalList = await getTemplateCategoryList();\n        expect(finalList.data.map(({ id }) => id)).toEqual([\n          cat2.data.id,\n          cat3.data.id,\n          cat4.data.id,\n          cat1.data.id,\n        ]);\n      });\n    });\n  });\n\n  describe('Create Base From Template', () => {\n    let templateId: string;\n    let templateBaseId: string;\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    beforeEach(async () => {\n      // create a template in a base\n      const templateBase = await createBase({\n        name: 'Template Base',\n        icon: '🚀',\n        spaceId,\n      });\n      templateBaseId = templateBase.data.id;\n      table1 = (\n        await createTable(templateBaseId, {\n          name: 'table1',\n        })\n      ).data;\n\n      table2 = (\n        await createTable(templateBaseId, {\n          name: 'table2',\n        })\n      ).data;\n\n      // use this base to be a template\n      const template = await createTemplate({});\n      templateId = template.data.id;\n\n      await updateTemplate(template.data.id, {\n        name: 'test Template',\n        description: 'test Template description',\n        baseId: templateBaseId,\n      });\n\n      await createTemplateSnapshot(template.data.id);\n\n      await updateTemplate(template.data.id, {\n        isPublished: true,\n      });\n    });\n\n    afterEach(async () => {\n      await deleteBase(templateBaseId);\n    });\n\n    it('should create base from template', async () => {\n      const createBaseRes = (\n        await createBaseFromTemplate({\n          spaceId,\n          templateId,\n          withRecords: true,\n        })\n      ).data;\n      const createdBaseId = createBaseRes.id;\n      const tables = (await getTableList(createdBaseId)).data;\n      // table\n      expect(tables.length).toBe(2);\n      expect(tables[0].name).toBe('table1');\n      expect(tables[1].name).toBe('table2');\n      const table1Fields = (await getFields(tables[0].id)).data?.map((f) => omit(f, ['id']));\n      const table2Fields = (await getFields(tables[1].id)).data?.map((f) => omit(f, ['id']));\n\n      // fields\n      const originalTable1Fields = table1.fields.map((f) => omit(f, ['id']));\n      const originalTable2Fields = table2.fields.map((f) => omit(f, ['id']));\n      expect(table1Fields).toEqual(originalTable1Fields);\n      expect(table2Fields).toEqual(originalTable2Fields);\n    });\n\n    it('should apply template to a base', async () => {\n      const applyBase = await createBase({\n        name: 'Apply Base',\n        spaceId,\n      });\n\n      // remain original base table\n      await createTable(applyBase.data.id, {\n        name: 'table3',\n      });\n\n      const createBaseRes = (\n        await createBaseFromTemplate({\n          spaceId,\n          templateId,\n          withRecords: true,\n          baseId: applyBase.data.id,\n        })\n      ).data;\n\n      const createdBaseId = createBaseRes.id;\n      const tables = (await getTableList(createdBaseId)).data;\n      // table\n      expect(tables.length).toBe(3);\n      expect(tables[1].name).toBe('table1');\n      expect(tables[2].name).toBe('table2');\n      const table1Fields = (await getFields(tables[1].id)).data?.map((f) => omit(f, ['id']));\n      const table2Fields = (await getFields(tables[2].id)).data?.map((f) => omit(f, ['id']));\n\n      // fields\n      const originalTable1Fields = table1.fields.map((f) => omit(f, ['id']));\n      const originalTable2Fields = table2.fields.map((f) => omit(f, ['id']));\n      expect(table1Fields).toEqual(originalTable1Fields);\n      expect(table2Fields).toEqual(originalTable2Fields);\n\n      // base icon and name\n      const applyBaseInfo = (await getBaseById(applyBase.data.id)).data;\n      expect(applyBaseInfo.icon).toBe('🚀');\n      expect(applyBaseInfo.name).toBe('test Template');\n    });\n  });\n\n  describe('Template Permalink', () => {\n    let templateId: string;\n    let snapshotBaseId: string;\n\n    beforeEach(async () => {\n      // Create a base with a table\n      await createTable(baseId, {\n        name: 'Test Table',\n      });\n\n      // Create and publish a template\n      const template = await createTemplate({\n        name: 'Test Permalink Template',\n        description: 'Template for testing permalink',\n      });\n      templateId = template.data.id;\n\n      // Link template to base\n      await updateTemplate(templateId, {\n        baseId: baseId,\n      });\n\n      // Create snapshot\n      await createTemplateSnapshot(templateId);\n\n      // Get snapshot baseId from template\n      const updatedTemplate = await prismaService.txClient().template.findUnique({\n        where: { id: templateId },\n        select: { snapshot: true },\n      });\n      const snapshot = updatedTemplate?.snapshot\n        ? JSON.parse(updatedTemplate.snapshot as string)\n        : {};\n      snapshotBaseId = snapshot.baseId;\n\n      // Publish the template\n      await updateTemplate(templateId, {\n        isPublished: true,\n      });\n    });\n\n    it('should resolve permalink and return redirect URL', async () => {\n      const result = await getTemplatePermalink(templateId);\n\n      expect(result.status).toBe(200);\n      expect(result.data).toBeDefined();\n      expect(result.data.redirectUrl).toBeDefined();\n      expect(typeof result.data.redirectUrl).toBe('string');\n      // Should redirect to the snapshot base\n      expect(result.data.redirectUrl).toContain('/base/');\n      expect(result.data.redirectUrl).toContain(snapshotBaseId);\n    });\n\n    it('should return 404 for non-existent template', async () => {\n      const fakeTemplateId = 'tplxxxxxxxxxxxxxx';\n      await expect(getTemplatePermalink(fakeTemplateId)).rejects.toMatchObject({\n        status: 404,\n      });\n    });\n\n    it('should return error for unpublished template', async () => {\n      // Create a separate base for this template to avoid unique constraint error\n      const unpublishedBase = await createBase({\n        name: 'Unpublished Template Base',\n        spaceId,\n      });\n\n      // Create an unpublished template\n      const unpublishedTemplate = await createTemplate({\n        name: 'Unpublished Template',\n      });\n\n      await updateTemplate(unpublishedTemplate.data.id, {\n        baseId: unpublishedBase.data.id,\n      });\n\n      await createTemplateSnapshot(unpublishedTemplate.data.id);\n\n      await expect(getTemplatePermalink(unpublishedTemplate.data.id)).rejects.toMatchObject({\n        status: 403,\n      });\n\n      // Cleanup\n      await deleteBase(unpublishedBase.data.id);\n    });\n\n    it('should return custom defaultUrl when publishInfo exists', async () => {\n      // Update template with custom publishInfo\n      const customUrl = `/base/${snapshotBaseId}/table/tblxxxxxx/viwxxxxxx`;\n      await prismaService.txClient().template.update({\n        where: { id: templateId },\n        data: {\n          publishInfo: {\n            defaultUrl: customUrl,\n          },\n        },\n      });\n\n      const result = await getTemplatePermalink(templateId);\n\n      expect(result.status).toBe(200);\n      expect(result.data.redirectUrl).toBe(customUrl);\n    });\n\n    it('should return error for invalid identifier format', async () => {\n      const invalidId = 'invalid-id-format';\n      await expect(getTemplatePermalink(invalidId)).rejects.toMatchObject({\n        status: 404,\n      });\n    });\n\n    it('should cache permalink results', async () => {\n      // First call\n      const result1 = await getTemplatePermalink(templateId);\n      expect(result1.status).toBe(200);\n\n      // Second call (should hit cache)\n      const result2 = await getTemplatePermalink(templateId);\n      expect(result2.status).toBe(200);\n      expect(result2.data.redirectUrl).toBe(result1.data.redirectUrl);\n    });\n\n    it('should handle template without publishInfo gracefully', async () => {\n      // Create a separate base for this template to avoid unique constraint error\n      const simpleBase = await createBase({\n        name: 'Simple Template Base',\n        spaceId,\n      });\n\n      // Create template without publishInfo\n      const simpleTemplate = await createTemplate({\n        name: 'Simple Template',\n      });\n\n      await updateTemplate(simpleTemplate.data.id, {\n        baseId: simpleBase.data.id,\n      });\n\n      await createTemplateSnapshot(simpleTemplate.data.id);\n\n      // Get snapshot baseId from template\n      const updatedTemplate = await prismaService.txClient().template.findUnique({\n        where: { id: simpleTemplate.data.id },\n        select: { snapshot: true },\n      });\n      const snapshot = updatedTemplate?.snapshot\n        ? JSON.parse(updatedTemplate.snapshot as string)\n        : {};\n      const simpleSnapshotBaseId = snapshot.baseId;\n\n      await updateTemplate(simpleTemplate.data.id, {\n        isPublished: true,\n      });\n\n      const result = await getTemplatePermalink(simpleTemplate.data.id);\n\n      expect(result.status).toBe(200);\n      expect(result.data.redirectUrl).toBe(`/base/${simpleSnapshotBaseId}`);\n\n      // Cleanup\n      await deleteBase(simpleBase.data.id);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/trash.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldType, Relationship } from '@teable/core';\nimport type { ITrashItemVo } from '@teable/openapi';\nimport {\n  getTrash,\n  getTrashItems,\n  resetTrashItems,\n  ResourceType,\n  restoreTrash,\n  trashVoSchema,\n} from '@teable/openapi';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport {\n  initApp,\n  createSpace,\n  createBase,\n  permanentDeleteSpace,\n  deleteSpace,\n  deleteBase,\n  deleteTable,\n  createTable,\n  createField,\n} from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst waitForBaseTrashItems = async (baseId: string, expectedCount = 1, maxRetries = 100) => {\n  for (let i = 0; i < maxRetries; i++) {\n    const result = await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });\n    if (result.data.trashItems.length >= expectedCount) {\n      return result;\n    }\n    await sleep(100);\n  }\n\n  return await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base });\n};\n\ndescribe('Trash (e2e)', () => {\n  let app: INestApplication;\n  let eventEmitterService: EventEmitterService;\n\n  let awaitWithSpaceEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  let awaitWithBaseEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  let awaitWithTableEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  const awaitWithTableDeleteSync = async <T>(fn: () => Promise<T>) =>\n    isForceV2 ? await fn() : awaitWithTableEvent(fn);\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n\n    awaitWithSpaceEvent = createAwaitWithEvent(eventEmitterService, Events.SPACE_DELETE);\n    awaitWithBaseEvent = createAwaitWithEvent(eventEmitterService, Events.BASE_DELETE);\n    awaitWithTableEvent = createAwaitWithEvent(eventEmitterService, Events.TABLE_DELETE);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  describe('Retrieving trash items', () => {\n    let spaceId: string;\n    let baseId: string;\n\n    beforeEach(async () => {\n      spaceId = (await createSpace({})).id;\n      baseId = (await createBase({ spaceId })).id;\n    });\n\n    afterEach(async () => {\n      try {\n        await permanentDeleteSpace(spaceId);\n      } catch (e) {\n        console.log('Space not found');\n      }\n    });\n\n    it('should get trash for space', async () => {\n      await awaitWithSpaceEvent(() => deleteSpace(spaceId));\n\n      const res = await getTrash({ resourceType: ResourceType.Space });\n\n      expect(trashVoSchema.safeParse(res.data).success).toEqual(true);\n    });\n\n    it('should get trash for base', async () => {\n      await awaitWithBaseEvent(() => deleteBase(baseId));\n\n      const res = await getTrash({ resourceType: ResourceType.Base });\n\n      expect(trashVoSchema.safeParse(res.data).success).toEqual(true);\n    });\n\n    it('should retrieve trash items for base when a table is deleted', async () => {\n      const tableId = (await createTable(baseId, {})).id;\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));\n\n      const res = await waitForBaseTrashItems(baseId, 1);\n\n      expect(res.data.trashItems.length).toBe(1);\n      expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(tableId);\n    });\n\n    it('should retrieve trash items for base when a linked foreign table is deleted', async () => {\n      const mainTableId = (await createTable(baseId, {})).id;\n      const foreignTableId = (await createTable(baseId, {})).id;\n\n      await createField(mainTableId, {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyMany,\n          foreignTableId,\n        },\n      });\n\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, foreignTableId));\n\n      const res = await waitForBaseTrashItems(baseId, 1);\n\n      expect(res.data.trashItems.length).toBe(1);\n      expect((res.data.trashItems[0] as ITrashItemVo).resourceId).toBe(foreignTableId);\n    });\n  });\n\n  describe('Restoring trash items', () => {\n    let spaceId: string;\n    let baseId: string;\n    let tableId: string;\n\n    beforeEach(async () => {\n      spaceId = (await createSpace({})).id;\n      baseId = (await createBase({ spaceId })).id;\n      tableId = (await createTable(baseId, {})).id;\n    });\n\n    afterEach(async () => {\n      try {\n        await permanentDeleteSpace(spaceId);\n      } catch (e) {\n        console.log('Space not found');\n      }\n    });\n\n    it('should restore space successfully', async () => {\n      await awaitWithSpaceEvent(() => deleteSpace(spaceId));\n\n      const trash = (await getTrash({ resourceType: ResourceType.Space })).data;\n      const restored = await restoreTrash(trash.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should restore base successfully', async () => {\n      await awaitWithBaseEvent(() => deleteBase(baseId));\n\n      const trash = (await getTrash({ resourceType: ResourceType.Base })).data;\n      const restored = await restoreTrash(trash.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should restore table successfully', async () => {\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));\n\n      const trash = (await waitForBaseTrashItems(baseId, 1)).data;\n      const restored = await restoreTrash(trash.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n    });\n\n    it('should expose restore-table canary headers when restoring a table trash item', async () => {\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId));\n\n      const trash = (await waitForBaseTrashItems(baseId, 1)).data;\n      const restored = await restoreTrash(trash.trashItems[0].id);\n\n      expect(restored.status).toEqual(201);\n      expect(restored.headers['x-teable-v2']).toBe(isForceV2 ? 'true' : 'false');\n      expect(restored.headers['x-teable-v2-feature']).toBe('restoreTable');\n      expect(restored.headers['x-teable-v2-reason']).toBeTruthy();\n    });\n  });\n\n  describe('Reset trash items for base', () => {\n    let spaceId: string;\n    let baseId: string;\n\n    beforeEach(async () => {\n      spaceId = (await createSpace({})).id;\n      baseId = (await createBase({ spaceId })).id;\n    });\n\n    afterEach(async () => {\n      try {\n        await permanentDeleteSpace(spaceId);\n      } catch (e) {\n        console.log('Space not found');\n      }\n    });\n\n    it('should reset trash items successfully', async () => {\n      const tableId1 = (await createTable(baseId, {})).id;\n      const tableId2 = (await createTable(baseId, {})).id;\n      const tableId3 = (await createTable(baseId, {})).id;\n\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId1));\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId2));\n      await awaitWithTableDeleteSync(() => deleteTable(baseId, tableId3));\n\n      const trash = (await waitForBaseTrashItems(baseId, 3)).data;\n\n      expect(trash.trashItems.length).toEqual(3);\n\n      await resetTrashItems({ resourceType: ResourceType.Base, resourceId: baseId });\n\n      const resetTrash = (\n        await getTrashItems({ resourceId: baseId, resourceType: ResourceType.Base })\n      ).data;\n\n      expect(resetTrash.trashItems.length).toEqual(0);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/undo-redo.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo, ILinkFieldOptions, IRollupFieldOptions } from '@teable/core';\nimport {\n  CellValueType,\n  DbFieldType,\n  FieldKeyType,\n  FieldType,\n  getRandomString,\n  Relationship,\n  ViewType,\n} from '@teable/core';\nimport {\n  axios,\n  clear,\n  convertField,\n  copy,\n  createField,\n  createRecords,\n  createView,\n  deleteField,\n  deleteFields,\n  deleteRecord,\n  deleteRecords,\n  deleteSelection,\n  deleteView,\n  getField,\n  getFields,\n  getRecord,\n  getRecords,\n  getView,\n  getViewList,\n  paste,\n  redo,\n  undo,\n  updateRecord,\n  updateRecordOrders,\n  updateRecords,\n  updateViewColumnMeta,\n  updateViewDescription,\n  updateViewFilter,\n  updateViewName,\n  updateViewOrder,\n  X_CANARY_HEADER,\n} from '@teable/openapi';\nimport type { ITableFullVo } from '@teable/openapi';\nimport { EventEmitterService } from '../src/event-emitter/event-emitter.service';\nimport { Events } from '../src/event-emitter/events';\nimport { X_TEABLE_V2_HEADER } from '../src/features/canary/interceptors/v2-indicator.interceptor';\nimport { X_TEABLE_UNDO_REDO_ENGINE_HEADER } from '../src/features/undo-redo/open-api/undo-redo.service';\nimport { createAwaitWithEvent } from './utils/event-promise';\nimport { initApp, permanentDeleteTable, createTable, updateRecordByApi } from './utils/init-app';\n\nconst isForceV2 = process.env.FORCE_V2_ALL === 'true';\nconst canRunCanaryV2 =\n  process.env.FORCE_V2_ALL === 'true' || process.env.ENABLE_CANARY_FEATURE === 'true';\n\ndescribe('Undo Redo (e2e)', () => {\n  let app: INestApplication;\n  let table: ITableFullVo;\n  let eventEmitterService: EventEmitterService;\n  let awaitWithEvent: <T>(fn: () => Promise<T>) => Promise<T>;\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    eventEmitterService = app.get(EventEmitterService);\n    const windowId = 'win' + getRandomString(8);\n    axios.interceptors.request.use((config) => {\n      config.headers['X-Window-Id'] = windowId;\n      return config;\n    });\n    awaitWithEvent = isForceV2\n      ? async <T>(action: () => Promise<T>) => await action()\n      : createAwaitWithEvent(eventEmitterService, Events.OPERATION_PUSH);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, { name: 'table1' });\n  });\n\n  afterEach(async () => {\n    await permanentDeleteTable(baseId, table.id);\n  });\n\n  it('should undo / redo create records', async () => {\n    await createField(table.id, { type: FieldType.CreatedTime });\n    await createField(table.id, { type: FieldType.LastModifiedTime });\n\n    const createRecordsRes = await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: { [table.fields[0].id]: 'record1' } }],\n      order: {\n        viewId: table.views[0].id,\n        anchorId: table.records[0].id,\n        position: 'after',\n      },\n    });\n    const expectedUndoRedoEngine =\n      createRecordsRes.headers[X_TEABLE_V2_HEADER] === 'true' ? 'v2' : 'v1';\n    const record1 = createRecordsRes.data.records[0];\n\n    const allRecords = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecords.data.records).toHaveLength(4);\n\n    const undoRes = await undo(table.id);\n    expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedUndoRedoEngine);\n\n    const allRecordsAfterUndo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecordsAfterUndo.data.records).toHaveLength(3);\n    expect(allRecordsAfterUndo.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n\n    const redoRes = await redo(table.id);\n    expect(redoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe(expectedUndoRedoEngine);\n\n    const allRecordsAfterRedo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecordsAfterRedo.data.records).toHaveLength(4);\n\n    // back to index 1\n    expect(allRecordsAfterRedo.data.records[1]).toMatchObject(record1);\n\n    await updateRecord(table.id, record1.id, {\n      fieldKeyType: FieldKeyType.Id,\n      record: { fields: { [table.fields[0].id]: 'new value' } },\n    });\n  });\n\n  it('should undo / redo delete record', async () => {\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.CreatedTime }));\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.LastModifiedTime }));\n\n    // index 1\n    const record1 = (\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table.fields[0].id]: 'record1' } }],\n        order: {\n          viewId: table.views[0].id,\n          anchorId: table.records[0].id,\n          position: 'after',\n        },\n      })\n    ).data.records[0];\n\n    await awaitWithEvent(() => deleteRecord(table.id, record1.id));\n\n    const allRecords = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    // 4 -> 3\n    expect(allRecords.data.records).toHaveLength(3);\n\n    await undo(table.id);\n\n    const allRecordsAfterUndo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    // 3 -> 4\n    expect(allRecordsAfterUndo.data.records).toHaveLength(4);\n    // back to index 1\n    expect(allRecordsAfterUndo.data.records[1]).toMatchObject(record1);\n\n    await redo(table.id);\n\n    const allRecordsAfterRedo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecordsAfterRedo.data.records).toHaveLength(3);\n    expect(allRecordsAfterRedo.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n  });\n\n  it('should undo / redo delete selection records', async () => {\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.CreatedTime }));\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.LastModifiedTime }));\n\n    // index 1\n    const record1 = (\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table.fields[0].id]: 'record1' } }],\n        order: {\n          viewId: table.views[0].id,\n          anchorId: table.records[0].id,\n          position: 'after',\n        },\n      })\n    ).data.records[0];\n\n    // delete index 1\n    await awaitWithEvent(() =>\n      deleteSelection(table.id, {\n        viewId: table.views[0].id,\n        ranges: [\n          [0, 1],\n          [1, 1],\n        ],\n      })\n    );\n\n    const allRecords = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n\n    expect(allRecords.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n\n    // 4 -> 3\n    expect(allRecords.data.records).toHaveLength(3);\n\n    await undo(table.id);\n\n    const allRecordsAfterUndo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    // 3 -> 4\n    expect(allRecordsAfterUndo.data.records).toHaveLength(4);\n    // back to index 1\n    expect(allRecordsAfterUndo.data.records[1]).toMatchObject(record1);\n\n    await redo(table.id);\n\n    const allRecordsAfterRedo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecordsAfterRedo.data.records).toHaveLength(3);\n    expect(allRecordsAfterRedo.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n  });\n\n  it('should undo / redo delete multiple records', async () => {\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.CreatedTime }));\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.LastModifiedTime }));\n\n    // index 1\n    const record1 = (\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [{ fields: { [table.fields[0].id]: 'record1' } }],\n        order: {\n          viewId: table.views[0].id,\n          anchorId: table.records[0].id,\n          position: 'after',\n        },\n      })\n    ).data.records[0];\n\n    // delete index 1\n    await awaitWithEvent(() => deleteRecords(table.id, [record1.id]));\n\n    const allRecords = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n\n    expect(allRecords.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n\n    // 4 -> 3\n    expect(allRecords.data.records).toHaveLength(3);\n\n    await undo(table.id);\n\n    const allRecordsAfterUndo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    // 3 -> 4\n    expect(allRecordsAfterUndo.data.records).toHaveLength(4);\n    // back to index 1\n    expect(allRecordsAfterUndo.data.records[1]).toMatchObject(record1);\n\n    await redo(table.id);\n\n    const allRecordsAfterRedo = await getRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      viewId: table.views[0].id,\n    });\n    expect(allRecordsAfterRedo.data.records).toHaveLength(3);\n    expect(allRecordsAfterRedo.data.records.find((r) => r.id === record1.id)).toBeUndefined();\n  });\n\n  it('should undo / redo update record', async () => {\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.CreatedTime }));\n    await awaitWithEvent(() => createField(table.id, { type: FieldType.LastModifiedTime }));\n\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[0].id]: 'A' } },\n      })\n    );\n\n    const updatedRecord = (\n      await awaitWithEvent(() =>\n        updateRecord(table.id, table.records[0].id, {\n          fieldKeyType: FieldKeyType.Id,\n          record: { fields: { [table.fields[0].id]: 'B' } },\n        })\n      )\n    ).data;\n\n    expect(updatedRecord.fields[table.fields[0].id]).toEqual('B');\n\n    await undo(table.id);\n\n    const updatedRecordAfter = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfter.fields[table.fields[0].id]).toEqual('A');\n\n    await undo(table.id);\n\n    const updatedRecordAfter2 = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfter2.fields[table.fields[0].id]).toBeUndefined();\n\n    await redo(table.id);\n\n    const updatedRecordAfterRedo = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfterRedo.fields[table.fields[0].id]).toEqual('A');\n\n    await redo(table.id);\n\n    const updatedRecordAfterRedo2 = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfterRedo2.fields[table.fields[0].id]).toEqual('B');\n  });\n\n  it('should undo / redo clear records', async () => {\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[0].id]: 'A' } },\n      })\n    );\n\n    await awaitWithEvent(() =>\n      clear(table.id, {\n        viewId: table.views[0].id,\n        ranges: [\n          [0, 0],\n          [1, 0],\n        ],\n      })\n    );\n\n    const record = await getRecord(table.id, table.records[0].id, {\n      fieldKeyType: FieldKeyType.Id,\n    });\n\n    expect(record.data.fields[table.fields[0].id]).toBeUndefined();\n\n    await undo(table.id);\n\n    const updatedRecordAfter = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfter.fields[table.fields[0].id]).toEqual('A');\n\n    await redo(table.id);\n\n    const updatedRecordAfterRedo = (\n      await getRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n\n    expect(updatedRecordAfterRedo.fields[table.fields[0].id]).toBeUndefined();\n  });\n\n  it('should undo / redo update record value with order', async () => {\n    // update and move 0 to 2\n    const recordId = table.records[0].id;\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[0].id]: 'A' } },\n        order: {\n          viewId: table.views[0].id,\n          anchorId: table.records[2].id,\n          position: 'after',\n        },\n      })\n    );\n\n    const records = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(records.records[2].fields[table.fields[0].id]).toEqual('A');\n\n    await undo(table.id);\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[0].id).toEqual(recordId);\n    expect(recordsAfterUndo.records[0].fields[table.fields[0].id]).toBeUndefined();\n\n    await redo(table.id);\n\n    const recordsAfterRedo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterRedo.records[2].fields[table.fields[0].id]).toEqual('A');\n  });\n\n  it('should undo / redo update record order in view', async () => {\n    // update and move 0 to 2\n    const recordId = table.records[0].id;\n    await awaitWithEvent(() =>\n      updateRecordOrders(table.id, table.views[0].id, {\n        anchorId: table.records[2].id,\n        position: 'after',\n        recordIds: [table.records[0].id],\n      })\n    );\n\n    const records = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(records.records[2].id).toEqual(recordId);\n\n    await undo(table.id);\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[0].id).toEqual(recordId);\n\n    await redo(table.id);\n\n    const recordsAfterRedo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterRedo.records[2].id).toEqual(recordId);\n  });\n\n  it('should undo / redo delete field', async () => {\n    // update and move 0 to 2\n    const fieldId = table.fields[1].id;\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[1].id]: 666 } },\n      })\n    );\n\n    await awaitWithEvent(() => deleteField(table.id, fieldId));\n\n    const fields = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fields.length).toEqual(2);\n\n    await undo(table.id);\n\n    const fieldsAfterUndo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterUndo[1].id).toEqual(fieldId);\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[0].fields[fieldId]).toEqual(666);\n\n    await redo(table.id);\n\n    const fieldsAfterRedo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterRedo.length).toEqual(2);\n  });\n\n  it.skipIf(!canRunCanaryV2)(\n    'should undo / redo delete field with not-null and unique constraints',\n    async () => {\n      const constrainedTable = await createTable(baseId, {\n        name: `undo-constrained-${getRandomString(6)}`,\n        fields: [{ type: FieldType.SingleLineText, name: 'Title', isPrimary: true }],\n        records: [],\n      });\n      const previousCanaryHeader = axios.defaults.headers.common[X_CANARY_HEADER];\n      axios.defaults.headers.common[X_CANARY_HEADER] = 'true';\n\n      try {\n        const titleFieldId = constrainedTable.fields.find((field) => field.name === 'Title')?.id;\n        const createCodeFieldRes = await createField(constrainedTable.id, {\n          type: FieldType.SingleLineText,\n          name: 'Code',\n          notNull: true,\n          unique: true,\n        });\n        expect(createCodeFieldRes.headers[X_TEABLE_V2_HEADER]).toBe('true');\n        const codeField = createCodeFieldRes.data;\n        const codeFieldId = codeField.id;\n\n        expect(titleFieldId).toBeTruthy();\n        expect(codeFieldId).toBeTruthy();\n        if (!titleFieldId || !codeFieldId) {\n          return;\n        }\n\n        await createRecords(constrainedTable.id, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [\n            {\n              fields: {\n                [titleFieldId]: 'Alpha',\n                [codeFieldId]: 'CODE-001',\n              },\n            },\n            {\n              fields: {\n                [titleFieldId]: 'Beta',\n                [codeFieldId]: 'CODE-002',\n              },\n            },\n          ],\n        });\n\n        const deleteFieldRes = await deleteField(constrainedTable.id, codeFieldId);\n        expect(deleteFieldRes.headers[X_TEABLE_V2_HEADER]).toBe('true');\n\n        const fieldsAfterDelete = (\n          await getFields(constrainedTable.id, {\n            viewId: constrainedTable.views[0].id,\n          })\n        ).data;\n        expect(fieldsAfterDelete.some((field) => field.id === codeFieldId)).toBe(false);\n\n        const undoRes = await undo(constrainedTable.id);\n        expect(undoRes.data.status).toEqual('fulfilled');\n        expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2');\n\n        const restoredField = (await getField(constrainedTable.id, codeFieldId)).data;\n        expect(restoredField.notNull).toBe(true);\n        expect(restoredField.unique).toBe(true);\n\n        const recordsAfterUndo = (\n          await getRecords(constrainedTable.id, {\n            fieldKeyType: FieldKeyType.Id,\n            viewId: constrainedTable.views[0].id,\n          })\n        ).data;\n        expect(recordsAfterUndo.records[0].fields[codeFieldId]).toEqual('CODE-001');\n        expect(recordsAfterUndo.records[1].fields[codeFieldId]).toEqual('CODE-002');\n\n        const redoRes = await redo(constrainedTable.id);\n        expect(redoRes.data.status).toEqual('fulfilled');\n        expect(redoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v2');\n\n        const fieldsAfterRedo = (\n          await getFields(constrainedTable.id, {\n            viewId: constrainedTable.views[0].id,\n          })\n        ).data;\n        expect(fieldsAfterRedo.some((field) => field.id === codeFieldId)).toBe(false);\n      } finally {\n        if (previousCanaryHeader == null) {\n          delete axios.defaults.headers.common[X_CANARY_HEADER];\n        } else {\n          axios.defaults.headers.common[X_CANARY_HEADER] = previousCanaryHeader;\n        }\n        await permanentDeleteTable(baseId, constrainedTable.id);\n      }\n    }\n  );\n\n  it('should undo / redo create field', async () => {\n    const field = await awaitWithEvent(() =>\n      createField(table.id, {\n        type: FieldType.SingleLineText,\n        order: {\n          viewId: table.views[0].id,\n          orderIndex: 0.5,\n        },\n      })\n    );\n    const fieldId = field.data.id;\n\n    const fields = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fields[1].id).toEqual(fieldId);\n\n    await undo(table.id);\n\n    const fieldsAfterUndo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterUndo.length).toEqual(3);\n\n    await redo(table.id);\n\n    const fieldsAfterRedo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterRedo[1].id).toEqual(fieldId);\n  });\n\n  it('should undo / redo delete multiple fields', async () => {\n    const fieldId = table.fields[1].id;\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[1].id]: 666 } },\n      })\n    );\n\n    const formulaField = (\n      await awaitWithEvent(() =>\n        createField(table.id, {\n          type: FieldType.Formula,\n          options: {\n            expression: `{${table.fields[1].id}}`,\n          },\n        })\n      )\n    ).data;\n\n    // delete 1 3\n    await awaitWithEvent(() => deleteFields(table.id, [fieldId, formulaField.id]));\n\n    const fields = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fields.length).toEqual(2);\n\n    const result = await undo(table.id);\n    expect(result.data.status).toEqual('fulfilled');\n\n    // get back 1 3\n    const fieldsAfterUndo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterUndo[1].id).toEqual(fieldId);\n    expect(fieldsAfterUndo[3].id).toEqual(formulaField.id);\n    expect(fieldsAfterUndo[3].hasError).toBeFalsy();\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[0].fields[fieldId]).toEqual(666);\n\n    await redo(table.id);\n\n    const fieldsAfterRedo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterRedo.length).toEqual(2);\n  });\n\n  it('should undo / redo convert field to formula field', async () => {\n    const tableId = table.id;\n    const fieldId = table.fields[1].id;\n    const recordId = table.records[0].id;\n    const res = await awaitWithEvent(() =>\n      updateRecord(tableId, recordId, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [fieldId]: 666 } },\n      })\n    );\n    expect(res.data.fields[fieldId]).toEqual(666);\n\n    await awaitWithEvent(() =>\n      convertField(tableId, fieldId, {\n        type: FieldType.Formula,\n        options: {\n          expression: `1+1`,\n        },\n      })\n    );\n    const recordAfterConvert = (\n      await getRecord(tableId, recordId, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n    expect(recordAfterConvert.fields[fieldId]).toEqual(2);\n\n    await undo(tableId);\n    const recordAfterUndo = (\n      await getRecord(tableId, recordId, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n    expect(recordAfterUndo.fields[fieldId]).toEqual(666);\n\n    await redo(tableId);\n    const recordAfterRedo = (\n      await getRecord(tableId, recordId, {\n        fieldKeyType: FieldKeyType.Id,\n      })\n    ).data;\n    expect(recordAfterRedo.fields[fieldId]).toEqual(2);\n  });\n\n  // event throw error because of sqlite(record history create many)\n  it('should undo / redo delete field with outgoing references', async () => {\n    // update and move 0 to 2\n    const fieldId = table.fields[1].id;\n    await awaitWithEvent(() =>\n      updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: { [table.fields[1].id]: 666 } },\n      })\n    );\n\n    const formulaField = await awaitWithEvent(() =>\n      createField(table.id, {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${table.fields[1].id}}`,\n        },\n      })\n    );\n\n    await awaitWithEvent(() => deleteField(table.id, fieldId));\n\n    const fields = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fields.length).toEqual(3);\n    expect(fields[2].hasError).toBeTruthy();\n\n    await undo(table.id);\n\n    const fieldsAfterUndo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterUndo[1].id).toEqual(fieldId);\n    expect(fieldsAfterUndo[3].id).toEqual(formulaField.data.id);\n    expect(fieldsAfterUndo[3].hasError).toBeFalsy();\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[0].fields[fieldId]).toEqual(666);\n\n    await redo(table.id);\n\n    const fieldsAfterRedo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(fieldsAfterRedo.length).toEqual(3);\n  });\n\n  it('should undo / redo paste simple selection', async () => {\n    await updateRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          id: table.records[0].id,\n          fields: { [table.fields[0].id]: 'A', [table.fields[1].id]: 1 },\n        },\n      ],\n    });\n\n    const { content, header } = (\n      await copy(table.id, {\n        viewId: table.views[0].id,\n        ranges: [\n          [0, 0],\n          [0, 0],\n        ],\n      })\n    ).data;\n\n    await awaitWithEvent(() =>\n      paste(table.id, {\n        viewId: table.views[0].id,\n        content,\n        header,\n        ranges: [\n          [0, 1],\n          [0, 1],\n        ],\n      })\n    );\n\n    const records = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(records.records[1].fields[table.fields[0].id]).toEqual('A');\n\n    await undo(table.id);\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[1].fields[table.fields[0].id]).toBeUndefined();\n\n    await redo(table.id);\n\n    const recordsAfterRedo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterRedo.records[1].fields[table.fields[0].id]).toEqual('A');\n  });\n\n  it('should undo / redo paste expanding selection', async () => {\n    await awaitWithEvent(() =>\n      updateRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table.records[0].id,\n            fields: { [table.fields[0].id]: 'A', [table.fields[1].id]: 1 },\n          },\n          {\n            id: table.records[1].id,\n            fields: { [table.fields[0].id]: 'B', [table.fields[1].id]: 2 },\n          },\n        ],\n      })\n    );\n\n    const { content, header } = (\n      await copy(table.id, {\n        viewId: table.views[0].id,\n        ranges: [\n          [0, 0],\n          [1, 1],\n        ],\n      })\n    ).data;\n\n    await awaitWithEvent(() =>\n      paste(table.id, {\n        viewId: table.views[0].id,\n        content,\n        header,\n        ranges: [\n          [2, 2],\n          [2, 2],\n        ],\n      })\n    );\n\n    const records = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n    const fields = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(records.records[2].fields[fields[2].id]).toEqual('A');\n    expect(records.records[2].fields[fields[3].id]).toEqual(1);\n    expect(records.records[3].fields[fields[2].id]).toEqual('B');\n    expect(records.records[3].fields[fields[3].id]).toEqual(2);\n\n    await undo(table.id);\n\n    const recordsAfterUndo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    const fieldsAfterUndo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterUndo.records[2].fields[fieldsAfterUndo[2].id]).toBeUndefined();\n    expect(recordsAfterUndo.records.length).toEqual(3);\n    expect(fieldsAfterUndo.length).toEqual(3);\n\n    await redo(table.id);\n\n    const recordsAfterRedo = (\n      await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    const fieldsAfterRedo = (\n      await getFields(table.id, {\n        viewId: table.views[0].id,\n      })\n    ).data;\n\n    expect(recordsAfterRedo.records[2].fields[fieldsAfterRedo[2].id]).toEqual('A');\n    expect(recordsAfterRedo.records[2].fields[fieldsAfterRedo[3].id]).toEqual(1);\n    expect(recordsAfterRedo.records[3].fields[fieldsAfterRedo[2].id]).toEqual('B');\n    expect(recordsAfterRedo.records[3].fields[fieldsAfterRedo[3].id]).toEqual(2);\n  });\n\n  it('should undo / redo create view', async () => {\n    const view = (\n      await awaitWithEvent(() =>\n        createView(table.id, {\n          type: ViewType.Grid,\n          name: 'view1',\n        })\n      )\n    ).data;\n\n    const undoRes = await undo(table.id);\n    expect(undoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v1');\n\n    const viewsAfterUndo = (await getViewList(table.id)).data;\n    expect(viewsAfterUndo.find((v) => v.id === view.id)).toBeUndefined();\n\n    const redoRes = await redo(table.id);\n    expect(redoRes.headers[X_TEABLE_UNDO_REDO_ENGINE_HEADER]).toBe('v1');\n\n    const viewsAfterRedo = (await getViewList(table.id)).data;\n    expect(viewsAfterRedo.find((v) => v.id === view.id)).toMatchObject({\n      id: view.id,\n      name: view.name,\n      type: view.type,\n    });\n  });\n\n  it('should undo / redo delete view', async () => {\n    const view = (\n      await awaitWithEvent(() =>\n        createView(table.id, {\n          type: ViewType.Grid,\n          name: 'view1',\n        })\n      )\n    ).data;\n\n    await awaitWithEvent(() => deleteView(table.id, view.id));\n\n    await undo(table.id);\n\n    const viewsAfterUndo = (await getViewList(table.id)).data;\n    expect(viewsAfterUndo.find((v) => v.id === view.id)).toMatchObject({\n      id: view.id,\n      name: view.name,\n      type: view.type,\n    });\n\n    await redo(table.id);\n\n    const viewsAfterRedo = (await getViewList(table.id)).data;\n    expect(viewsAfterRedo.find((v) => v.id === view.id)).toBeUndefined();\n  });\n\n  it('should undo / redo update view property', async () => {\n    // name\n    const view = table.views[0];\n    (await awaitWithEvent(() => updateViewName(table.id, view.id, { name: 'newName' }))).data;\n\n    await undo(table.id);\n\n    expect((await getView(table.id, view.id)).data.name).toEqual(view.name);\n\n    await redo(table.id);\n\n    expect((await getView(table.id, view.id)).data.name).toEqual('newName');\n\n    // description\n    (\n      await awaitWithEvent(() =>\n        updateViewDescription(table.id, view.id, { description: 'newName' })\n      )\n    ).data;\n\n    await undo(table.id);\n\n    expect((await getView(table.id, view.id)).data.description).toEqual(view.description);\n\n    await redo(table.id);\n\n    expect((await getView(table.id, view.id)).data.description).toEqual('newName');\n\n    // filter\n\n    (\n      await awaitWithEvent(() =>\n        updateViewFilter(table.id, view.id, {\n          filter: {\n            filterSet: [\n              {\n                fieldId: table.fields![0].id,\n                value: 'text',\n                operator: 'is',\n              },\n            ],\n            conjunction: 'and',\n          },\n        })\n      )\n    ).data;\n\n    await undo(table.id);\n\n    expect((await getView(table.id, view.id)).data.filter).toEqual(view.filter);\n\n    await redo(table.id);\n\n    expect((await getView(table.id, view.id)).data.filter).toEqual({\n      filterSet: [\n        {\n          fieldId: table.fields![0].id,\n          value: 'text',\n          operator: 'is',\n        },\n      ],\n      conjunction: 'and',\n    });\n  });\n\n  it('should undo / redo update view column meta', async () => {\n    const view = table.views[0];\n    (\n      await awaitWithEvent(() =>\n        updateViewColumnMeta(table.id, view.id, [\n          {\n            fieldId: table.fields[1].id,\n            columnMeta: {\n              order: 10,\n            },\n          },\n        ])\n      )\n    ).data;\n\n    const fields = (await getFields(table.id, { viewId: view.id })).data;\n\n    expect(fields[2].id).toEqual(table.fields[1].id);\n\n    await undo(table.id);\n\n    const fieldsAfterUndo = (await getFields(table.id, { viewId: view.id })).data;\n\n    expect(fieldsAfterUndo[1].id).toEqual(table.fields[1].id);\n\n    await redo(table.id);\n\n    const fieldsAfterRedo = (await getFields(table.id, { viewId: view.id })).data;\n\n    expect(fieldsAfterRedo[2].id).toEqual(table.fields[1].id);\n  });\n\n  it('should undo / redo update view order', async () => {\n    const view = table.views[0];\n    const view1 = (\n      await awaitWithEvent(() =>\n        createView(table.id, {\n          type: ViewType.Grid,\n          name: 'view1',\n        })\n      )\n    ).data;\n\n    (\n      await awaitWithEvent(() =>\n        updateViewOrder(table.id, view.id, { anchorId: view1.id, position: 'after' })\n      )\n    ).data;\n\n    await undo(table.id);\n\n    const viewsAfterUndo = (await getViewList(table.id)).data;\n    expect(viewsAfterUndo[0].id).equal(view.id);\n\n    await redo(table.id);\n\n    const viewsAfterRedo = (await getViewList(table.id)).data;\n    expect(viewsAfterRedo[1].id).equal(view.id);\n  });\n\n  describe('modify field constraint', () => {\n    it('should undo modify field constraint', async () => {\n      await awaitWithEvent(() =>\n        convertField(table.id, table.fields[0].id, {\n          ...table.fields[0],\n          unique: true,\n        })\n      );\n\n      await expect(\n        updateRecords(table.id, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [\n            {\n              id: table.records[0].id,\n              fields: { [table.fields[0].id]: 'A' },\n            },\n            {\n              id: table.records[1].id,\n              fields: { [table.fields[0].id]: 'A' },\n            },\n          ],\n        })\n      ).rejects.toThrowError();\n\n      await undo(table.id);\n\n      await updateRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            id: table.records[0].id,\n            fields: { [table.fields[0].id]: 'A' },\n          },\n          {\n            id: table.records[1].id,\n            fields: { [table.fields[0].id]: 'A' },\n          },\n        ],\n      });\n    });\n\n    it('should redo modify field constraint', async () => {\n      await awaitWithEvent(() =>\n        convertField(table.id, table.fields[0].id, {\n          ...table.fields[0],\n          unique: true,\n        })\n      );\n\n      await undo(table.id);\n      await redo(table.id);\n\n      await expect(\n        updateRecords(table.id, {\n          fieldKeyType: FieldKeyType.Id,\n          records: [\n            {\n              id: table.records[0].id,\n              fields: { [table.fields[0].id]: 'A' },\n            },\n            {\n              id: table.records[1].id,\n              fields: { [table.fields[0].id]: 'A' },\n            },\n          ],\n        })\n      ).rejects.toThrowError();\n    });\n  });\n\n  describe('link related', () => {\n    let table1: ITableFullVo;\n    let table2: ITableFullVo;\n    let table3: ITableFullVo;\n    const refField1Ro: IFieldRo = {\n      type: FieldType.SingleLineText,\n    };\n\n    const refField2Ro: IFieldRo = {\n      type: FieldType.Number,\n    };\n\n    let refField1: IFieldVo;\n    let refField2: IFieldVo;\n\n    beforeEach(async () => {\n      table1 = await createTable(baseId, { name: 'table1' });\n      table2 = await createTable(baseId, { name: 'table2' });\n      table3 = await createTable(baseId, { name: 'table3' });\n\n      refField1 = (await createField(table1.id, refField1Ro)).data;\n      refField2 = (await createField(table1.id, refField2Ro)).data;\n\n      await updateRecordByApi(table1.id, table1.records[0].id, refField1.id, 'x');\n      await updateRecordByApi(table1.id, table1.records[1].id, refField1.id, 'y');\n\n      await updateRecordByApi(table1.id, table1.records[0].id, refField2.id, 1);\n      await updateRecordByApi(table1.id, table1.records[1].id, refField2.id, 2);\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table1.id);\n      await permanentDeleteTable(baseId, table2.id);\n      await permanentDeleteTable(baseId, table3.id);\n    });\n\n    it('should undo / redo delete record with link', async () => {\n      const linkFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const linkField = (await createField(table1.id, linkFieldRo)).data;\n\n      await updateRecordByApi(table1.id, table1.records[0].id, linkField.id, {\n        id: table2.records[0].id,\n      });\n\n      await deleteRecord(table1.id, table1.records[0].id);\n\n      await undo(table1.id);\n\n      const recordAfterUndo = (\n        await getRecord(table1.id, table1.records[0].id, { fieldKeyType: FieldKeyType.Id })\n      ).data;\n      expect(recordAfterUndo.fields[linkField.id]).toMatchObject({\n        id: table2.records[0].id,\n      });\n\n      await redo(table1.id);\n\n      const recordsAfterRedo = (\n        await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id, viewId: table1.views[0].id })\n      ).data;\n      expect(recordsAfterRedo.records.length).toEqual(2);\n    });\n\n    it('should undo / redo convert link to single line text', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n\n      const sourceLinkField = (await createField(table1.id, sourceFieldRo)).data;\n\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceLinkField.id, {\n        id: table2.records[0].id,\n      });\n\n      const newLinkField = (\n        await awaitWithEvent(() => convertField(table1.id, sourceLinkField.id, newFieldRo))\n      ).data;\n\n      await undo(table1.id);\n\n      const newLinkFieldAfterUndo = (await getField(table1.id, newLinkField.id)).data;\n      const { meta: _sourceLinkMeta, ...sourceLinkWithoutMeta } = sourceLinkField;\n      expect(newLinkFieldAfterUndo).toMatchObject(sourceLinkWithoutMeta);\n\n      // make sure records has been updated\n      const recordsAfterUndo = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id }))\n        .data;\n      expect(recordsAfterUndo.records[0].fields[newLinkFieldAfterUndo.id]).toEqual({\n        id: table2.records[0].id,\n        title: 'B1',\n      });\n\n      await redo(table1.id);\n\n      const newLinkFieldAfterRedo = (await getField(table1.id, newLinkField.id)).data;\n\n      const { meta: _newLinkMeta, ...newLinkWithoutMeta } = newLinkField;\n      expect(newLinkFieldAfterRedo).toMatchObject(newLinkWithoutMeta);\n\n      // make sure records has been updated\n      const recordsAfterRedo = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id }))\n        .data;\n      expect(recordsAfterRedo.records[0].fields[newLinkFieldAfterRedo.id]).toEqual('B1');\n    });\n\n    it('should undo / redo convert link when convert link from one table to another', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table3.id,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'B1');\n      // set primary key in table3\n      await updateRecordByApi(table3.id, table3.records[0].id, table3.fields[0].id, 'C1');\n\n      const sourceLinkField = (await createField(table1.id, sourceFieldRo)).data;\n\n      const lookupFieldRo: IFieldRo = {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceLinkField.id,\n        },\n      };\n      const sourceLookupField = (await awaitWithEvent(() => createField(table1.id, lookupFieldRo)))\n        .data;\n\n      const formulaLinkFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLinkField.id}}`,\n        },\n      };\n      const formulaLookupFieldRo: IFieldRo = {\n        type: FieldType.Formula,\n        options: {\n          expression: `{${sourceLookupField.id}}`,\n        },\n      };\n\n      const sourceFormulaLinkField = (\n        await awaitWithEvent(() => createField(table1.id, formulaLinkFieldRo))\n      ).data;\n      const sourceFormulaLookupField = (\n        await awaitWithEvent(() => createField(table1.id, formulaLookupFieldRo))\n      ).data;\n\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceLinkField.id, {\n        id: table2.records[0].id,\n      });\n\n      // make sure records has been updated\n      const { records: rs } = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(rs[0].fields[sourceLinkField.id]).toEqual({ id: table2.records[0].id, title: 'B1' });\n      expect(rs[0].fields[sourceLookupField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLinkField.id]).toEqual('B1');\n      expect(rs[0].fields[sourceFormulaLookupField.id]).toEqual('B1');\n\n      const newLinkField = (\n        await awaitWithEvent(() => convertField(table1.id, sourceLinkField.id, newFieldRo))\n      ).data;\n\n      const { meta: _sourceLinkMeta2, ...sourceLinkWithoutMeta } = sourceLinkField;\n      const { meta: _newLinkMeta2, ...newLinkWithoutMeta } = newLinkField;\n\n      await undo(table1.id);\n\n      const newLinkFieldAfterUndo = (await getField(table1.id, newLinkField.id)).data;\n\n      expect(newLinkFieldAfterUndo).toMatchObject(sourceLinkWithoutMeta);\n      const targetLookupFieldAfterUndo = (await getField(table1.id, sourceLookupField.id)).data;\n      expect(targetLookupFieldAfterUndo.hasError).toBeUndefined();\n\n      await redo(table1.id);\n\n      const newLinkFieldAfterRedo = (await getField(table1.id, newLinkField.id)).data;\n\n      expect(newLinkFieldAfterRedo).toMatchObject(newLinkWithoutMeta);\n\n      await updateRecordByApi(table1.id, table1.records[0].id, newLinkFieldAfterRedo.id, {\n        id: table3.records[0].id,\n      });\n\n      const targetLookupField = (await getField(table1.id, sourceLookupField.id)).data;\n      const targetFormulaLinkField = (await getField(table1.id, sourceFormulaLinkField.id)).data;\n      const targetFormulaLookupField = (await getField(table1.id, sourceFormulaLookupField.id))\n        .data;\n\n      expect(targetLookupField.hasError).toBeTruthy();\n      expect(targetFormulaLinkField.hasError).toBeUndefined();\n      expect(targetFormulaLookupField.hasError).toBeUndefined();\n\n      // make sure records has been updated\n      const { records } = (await getRecords(table1.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(records[0].fields[newLinkFieldAfterRedo.id]).toEqual({\n        id: table3.records[0].id,\n        title: 'C1',\n      });\n      // Lookup becomes errored after link converted to another table;\n      // in base-table query path (no view cache), it resolves to undefined\n      expect(records[0].fields[targetLookupField.id]).toBeUndefined();\n      // Formula on link should still resolve with the new link\n      expect(records[0].fields[targetFormulaLinkField.id]).toEqual('C1');\n      // Formula on lookup should also be undefined when lookup is errored\n      expect(records[0].fields[targetFormulaLookupField.id]).toBeUndefined();\n    });\n\n    it('should undo / redo convert two-way to one-way link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const sourceField = (await createField(table1.id, sourceFieldRo)).data;\n\n      (await convertField(table1.id, sourceField.id, newFieldRo)).data;\n\n      await undo(table1.id);\n\n      const fieldAfterUndo = (await getField(table1.id, sourceField.id)).data;\n\n      expect(fieldAfterUndo).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          isOneWay: false,\n        },\n      });\n\n      await redo(table1.id);\n\n      const fieldAfterRedo = (await getField(table1.id, sourceField.id)).data;\n\n      expect(fieldAfterRedo).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          isOneWay: true,\n        },\n      });\n\n      const symmetricFieldId = (fieldAfterRedo.options as ILinkFieldOptions).symmetricFieldId;\n      expect(symmetricFieldId).toBeUndefined();\n    });\n\n    // Skip for now since it's flaky\n    it.skip('should undo / redo convert one-way link to two-way link', async () => {\n      const sourceFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: true,\n        },\n      };\n\n      const newFieldRo: IFieldRo = {\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          isOneWay: false,\n        },\n      };\n\n      // set primary key in table2\n      await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'x');\n      await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'y');\n      await updateRecordByApi(table2.id, table2.records[2].id, table2.fields[0].id, 'zzz');\n\n      const sourceField = (await createField(table1.id, sourceFieldRo)).data;\n      await updateRecordByApi(table1.id, table1.records[0].id, sourceField.id, [\n        { id: table2.records[0].id },\n        { id: table2.records[1].id },\n      ]);\n\n      await createField(table1.id, {\n        type: FieldType.SingleLineText,\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n      await createField(table1.id, {\n        type: FieldType.Rollup,\n        options: {\n          expression: `count({values})`,\n          formatting: {\n            precision: 2,\n            type: 'decimal',\n          },\n        } as IRollupFieldOptions,\n        lookupOptions: {\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          linkFieldId: sourceField.id,\n        },\n      });\n\n      (await convertField(table1.id, sourceField.id, newFieldRo)).data;\n\n      await undo(table1.id);\n      const fieldAfterUndo = (await getField(table1.id, sourceField.id)).data;\n\n      expect(fieldAfterUndo).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          isOneWay: true,\n        },\n      });\n\n      // perform redo\n      await redo(table1.id);\n      const fieldAfterRedo = (await getField(table1.id, sourceField.id)).data;\n\n      expect(fieldAfterRedo).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.OneMany,\n          foreignTableId: table2.id,\n          lookupFieldId: table2.fields[0].id,\n          isOneWay: false,\n        },\n      });\n\n      const symmetricFieldId = (fieldAfterRedo.options as ILinkFieldOptions).symmetricFieldId;\n      expect(symmetricFieldId).toBeDefined();\n\n      const symmetricField = (await getField(table2.id, symmetricFieldId as string)).data;\n\n      expect(symmetricField).toMatchObject({\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Json,\n        type: FieldType.Link,\n        options: {\n          relationship: Relationship.ManyOne,\n          foreignTableId: table1.id,\n          lookupFieldId: table1.fields[0].id,\n        },\n      });\n\n      const { records } = (await getRecords(table2.id, { fieldKeyType: FieldKeyType.Id })).data;\n      expect(records[0].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n      expect(records[1].fields[symmetricField.id]).toMatchObject({ id: table1.records[0].id });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/user-last-visit.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IViewVo } from '@teable/core';\nimport { ViewType } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport type { ICreateBaseVo, ITableFullVo } from '@teable/openapi';\nimport {\n  createBase,\n  createTable,\n  createView,\n  deleteBase,\n  deleteView,\n  getUserLastVisit,\n  getUserLastVisitBaseNode,\n  getUserLastVisitListBase,\n  getUserLastVisitMap,\n  LastVisitResourceType,\n  updateUserLastVisit,\n  userLastVisitListBaseVoSchema,\n} from '@teable/openapi';\nimport { isEmpty } from 'lodash';\nimport { getViews, initApp, permanentDeleteBase, permanentDeleteTable } from './utils/init-app';\n\ndescribe('OpenAPI OAuthController (e2e)', () => {\n  let app: INestApplication;\n  let table1: ITableFullVo;\n  let table2: ITableFullVo;\n  let view1: IViewVo;\n  let base: ICreateBaseVo;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    base = await createBase({ spaceId: globalThis.testConfig.spaceId, name: 'base' }).then(\n      (res) => res.data\n    );\n    table1 = await createTable(base.id, { name: 'table1' }).then((res) => res.data);\n    table2 = await createTable(base.id, { name: 'table2' }).then((res) => res.data);\n    view1 = await createView(table1.id, { type: ViewType.Grid, name: 'view2', order: 1 }).then(\n      (res) => res.data\n    );\n  });\n\n  afterAll(async () => {\n    await permanentDeleteTable(base.id, table1.id);\n    await permanentDeleteTable(base.id, table2.id);\n    await deleteBase(base.id);\n    await app.close();\n  });\n\n  it('should get default last visit', async () => {\n    const res = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res.data).toEqual({\n      resourceId: table1.id,\n      childResourceId: table1.views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    });\n  });\n\n  it('should get last visit', async () => {\n    await updateUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n      resourceId: table2.id,\n    });\n\n    const res = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res.data).toEqual({\n      resourceId: table2.id,\n      childResourceId: table2.views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    });\n\n    await updateUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n      resourceId: table1.id,\n    });\n\n    const res2 = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res2.data).toEqual({\n      resourceId: table1.id,\n      childResourceId: table1.views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    });\n  });\n\n  it('should get last visit with child resource', async () => {\n    await updateUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n      resourceId: table1.id,\n      childResourceId: view1.id,\n    });\n\n    const res = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res.data).toEqual({\n      resourceId: table1.id,\n      childResourceId: view1.id,\n      resourceType: LastVisitResourceType.Table,\n    });\n\n    const res2 = await getUserLastVisit({\n      resourceType: LastVisitResourceType.View,\n      parentResourceId: table1.id,\n    });\n\n    expect(res2.data).toEqual({\n      resourceId: view1.id,\n      resourceType: LastVisitResourceType.View,\n    });\n  });\n\n  it('should fallback to default view when delete a view', async () => {\n    await updateUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n      resourceId: table1.id,\n      childResourceId: view1.id,\n    });\n\n    await deleteView(table1.id, view1.id);\n\n    const res = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res.data).toEqual({\n      resourceId: table1.id,\n      childResourceId: table1.views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    });\n\n    const res2 = await getUserLastVisit({\n      resourceType: LastVisitResourceType.View,\n      parentResourceId: table1.id,\n    });\n\n    expect(res2.data).toEqual({\n      resourceId: table1.views[0].id,\n      resourceType: LastVisitResourceType.View,\n    });\n\n    const res3 = await getUserLastVisitMap({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res3.data).toEqual({\n      [table1.id]: {\n        parentResourceId: table1.id,\n        resourceId: table1.views[0].id,\n        resourceType: LastVisitResourceType.View,\n      },\n      [table2.id]: {\n        parentResourceId: table2.id,\n        resourceId: table2.views[0].id,\n        resourceType: LastVisitResourceType.View,\n      },\n    });\n  });\n\n  it('should fallback to default view when delete a view without any visit', async () => {\n    await createView(table1.id, { type: ViewType.Grid, name: 'view2', order: 1 });\n\n    await deleteView(table1.id, table1.views[0].id);\n    const views = await getViews(table1.id);\n\n    const res = await getUserLastVisit({\n      resourceType: LastVisitResourceType.Table,\n      parentResourceId: base.id,\n    });\n\n    expect(res.data).toEqual({\n      resourceId: table1.id,\n      childResourceId: views[0].id,\n      resourceType: LastVisitResourceType.Table,\n    });\n  });\n\n  it('should get last visit list base', async () => {\n    // eslint-disable-next-line @typescript-eslint/naming-convention\n    const base_21: ICreateBaseVo[] = [];\n\n    for (let i = 0; i < 21; i++) {\n      const base = await createBase({\n        spaceId: globalThis.testConfig.spaceId,\n        name: `base_${i}`,\n      }).then((res) => res.data);\n      base_21.push(base);\n    }\n\n    for (const base of base_21) {\n      await updateUserLastVisit({\n        resourceType: LastVisitResourceType.Base,\n        parentResourceId: base.spaceId,\n        resourceId: base.id,\n      });\n    }\n\n    const res = await getUserLastVisitListBase();\n\n    for (const base of base_21) {\n      await permanentDeleteBase(base.id);\n    }\n    expect(userLastVisitListBaseVoSchema.safeParse(res.data).success).toEqual(true);\n    expect(res.data.list.length).toEqual(21);\n    expect(res.data.total).toEqual(21);\n    expect(res.data.list[0].resource.id).toEqual(base_21[20].id);\n    expect(res.data.list[20].resource.id).toEqual(base_21[0].id);\n\n    const res2 = await getUserLastVisitListBase();\n\n    expect(res2.data.list.length).toEqual(0);\n\n    const prisma = app.get(PrismaService);\n    const userLastVisit = await prisma.userLastVisit.findMany({\n      where: {\n        parentResourceId: base_21[0].spaceId,\n      },\n    });\n    expect(userLastVisit.length).toEqual(0);\n  });\n\n  describe('getUserLastVisitBaseNode', () => {\n    let testBase: ICreateBaseVo;\n    let testTable: ITableFullVo;\n\n    beforeAll(async () => {\n      testBase = await createBase({\n        spaceId: globalThis.testConfig.spaceId,\n        name: 'base_node_test',\n      }).then((res) => res.data);\n      testTable = await createTable(testBase.id, { name: 'test_table' }).then((res) => res.data);\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(testBase.id, testTable.id);\n      await permanentDeleteBase(testBase.id);\n    });\n\n    it('should return undefined when no visit record exists', async () => {\n      const newBase = await createBase({\n        spaceId: globalThis.testConfig.spaceId,\n        name: 'empty_base',\n      }).then((res) => res.data);\n\n      const res = await getUserLastVisitBaseNode({\n        parentResourceId: newBase.id,\n      }).then((res) => res.data);\n\n      expect(isEmpty(res)).toBe(true);\n\n      await permanentDeleteBase(newBase.id);\n    });\n\n    it('should return table visit after visiting a table', async () => {\n      await updateUserLastVisit({\n        resourceType: LastVisitResourceType.Table,\n        parentResourceId: testBase.id,\n        resourceId: testTable.id,\n      });\n\n      const res = await getUserLastVisitBaseNode({\n        parentResourceId: testBase.id,\n      });\n\n      expect(res.data).toEqual({\n        resourceId: testTable.id,\n        resourceType: LastVisitResourceType.Table,\n      });\n    });\n\n    it('should return most recent visit when multiple base nodes visited', async () => {\n      const table2 = await createTable(testBase.id, { name: 'test_table_2' }).then(\n        (res) => res.data\n      );\n\n      // Visit first table\n      await updateUserLastVisit({\n        resourceType: LastVisitResourceType.Table,\n        parentResourceId: testBase.id,\n        resourceId: testTable.id,\n      });\n\n      // Visit second table\n      await updateUserLastVisit({\n        resourceType: LastVisitResourceType.Table,\n        parentResourceId: testBase.id,\n        resourceId: table2.id,\n      });\n\n      const res = await getUserLastVisitBaseNode({\n        parentResourceId: testBase.id,\n      });\n\n      // Should return the most recent visit (table2)\n      expect(res.data).toEqual({\n        resourceId: table2.id,\n        resourceType: LastVisitResourceType.Table,\n      });\n\n      await permanentDeleteTable(testBase.id, table2.id);\n    });\n\n    it('should not include view visits in base node results', async () => {\n      // Clear previous visits by creating a fresh base\n      const freshBase = await createBase({\n        spaceId: globalThis.testConfig.spaceId,\n        name: 'fresh_base',\n      }).then((res) => res.data);\n      const freshTable = await createTable(freshBase.id, { name: 'fresh_table' }).then(\n        (res) => res.data\n      );\n\n      // Only visit a view (not a base node type)\n      await updateUserLastVisit({\n        resourceType: LastVisitResourceType.View,\n        parentResourceId: freshTable.id,\n        resourceId: freshTable.views[0].id,\n      });\n\n      const res = await getUserLastVisitBaseNode({\n        parentResourceId: freshBase.id,\n      }).then((res) => res.data);\n\n      expect(isEmpty(res)).toBe(true);\n\n      await permanentDeleteTable(freshBase.id, freshTable.id);\n      await permanentDeleteBase(freshBase.id);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/axios-instance/anonymous-user.ts",
    "content": "import { createAxios } from '@teable/openapi';\n\nexport const createAnonymousUserAxios = (appUrl: string) => {\n  const anonymousAxios = createAxios();\n\n  anonymousAxios.interceptors.request.use((config) => {\n    config.baseURL = appUrl + '/api';\n    return config;\n  });\n\n  anonymousAxios.interceptors.request.use((config) => {\n    config.headers['X-Anonymous-User'] = true;\n    return config;\n  });\n  return anonymousAxios;\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/axios-instance/new-user.ts",
    "content": "import {\n  axios,\n  SIGN_UP,\n  createAxios,\n  USER_ME,\n  SIGN_IN,\n  signupPasswordSchema,\n} from '@teable/openapi';\nimport type { AxiosHeaderValue } from 'axios';\n\nexport async function createNewUserAxios({ email, password }: { email: string; password: string }) {\n  if (!signupPasswordSchema.safeParse(password).success) {\n    password = `${password}a`;\n  }\n  const signAxios = createAxios();\n  signAxios.defaults.baseURL = axios.defaults.baseURL;\n  const signupRes = await signAxios.post(SIGN_UP, { email, password }).catch(async (err) => {\n    if (err.status === 409 && err.message.includes('is already registered')) {\n      return await signAxios.post(SIGN_IN, {\n        email,\n        password,\n      });\n    }\n    throw err;\n  });\n\n  const cookie = signupRes.headers['set-cookie'];\n\n  const newUserAxios = createAxios();\n\n  newUserAxios.defaults.headers.Cookie = cookie as AxiosHeaderValue;\n\n  newUserAxios.interceptors.request.use((config) => {\n    config.headers.Cookie = cookie;\n    config.baseURL = signupRes.config.baseURL;\n    return config;\n  });\n\n  const axiosResponse = await newUserAxios.get(USER_ME);\n  console.log('new user signed session', JSON.stringify(axiosResponse.data, null, 2));\n\n  return newUserAxios;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/data.generator.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IFieldRo, IFieldVo } from '@teable/core';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { cloneDeep } from 'lodash';\nimport { FIELD_MOCK_DATA } from './field-mock';\nimport { createTable, initApp, createRecords, createField, getFields } from './init-app';\n\ndescribe('Performance test data generator', () => {\n  let app: INestApplication;\n  let tableId = '';\n  let fields: IFieldVo[] = [];\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n\n    const table = await createTable(baseId, { name: 'table1' });\n\n    tableId = table.id;\n    console.log('createTable', table);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  function addRecords(count: number) {\n    const records = Array.from({ length: count }).map((_, i) => {\n      const value = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => {\n        switch (field.type) {\n          case FieldType.SingleLineText:\n            acc[field.id] = 'New Record' + new Date();\n            break;\n          case FieldType.Number:\n            acc[field.id] = i;\n            break;\n          case FieldType.SingleSelect:\n            acc[field.id] = ['light', 'medium', 'heavy'][i % 3];\n            break;\n        }\n        return acc;\n      }, {});\n      return { fields: value };\n    });\n\n    return createRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records,\n    });\n  }\n\n  it('/api/table/{tableId}/record (POST) (1000x)', async () => {\n    const fieldCount = 20;\n    const batchCount = 100;\n    const count = 1000;\n\n    for (let i = 0; i < fieldCount; i++) {\n      const fieldRo: IFieldRo = cloneDeep(FIELD_MOCK_DATA[i % 3]);\n      fieldRo.name = 'field' + i;\n\n      await createField(tableId, fieldRo);\n    }\n\n    fields = await getFields(tableId);\n\n    console.time(`create ${count} records`);\n    for (let i = 0; i < count / batchCount; i++) {\n      await addRecords(batchCount);\n    }\n    console.timeEnd(`create ${count} records`);\n    console.log(`new table: ${tableId} created`);\n  }, 1000000);\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/event-promise.ts",
    "content": "import type { EventEmitterService } from '../../src/event-emitter/event-emitter.service';\nimport type { Events } from '../../src/event-emitter/events';\n\nexport function createEventPromise(eventEmitterService: EventEmitterService, event: Events) {\n  let theResolve: (value: unknown) => void;\n\n  const promise = new Promise((resolve) => {\n    theResolve = resolve;\n  });\n\n  eventEmitterService.eventEmitter.once(event, (payload) => {\n    theResolve(payload);\n  });\n\n  return promise;\n}\n\nexport function createAwaitWithEvent(eventEmitterService: EventEmitterService, event: Events) {\n  return async function runWithEvent<T>(action: () => Promise<T>) {\n    const promise = createEventPromise(eventEmitterService, event);\n    const result = await action();\n    await promise;\n    return result;\n  };\n}\n\nexport function createAwaitWithEventWithResult<R = unknown>(\n  eventEmitterService: EventEmitterService,\n  event: Events\n) {\n  return async function runWithEventResult<T>(action: () => Promise<T>) {\n    const promise = createEventPromise(eventEmitterService, event);\n    await action();\n    await promise;\n    return (await promise) as R;\n  };\n}\n\nconst createEventPromiseWithCount = (\n  eventEmitterService: EventEmitterService,\n  event: Events,\n  count: number = 1\n) => {\n  let theResolve: (value: unknown) => void;\n\n  const promise = new Promise((resolve) => {\n    theResolve = resolve;\n  });\n\n  const payloads: unknown[] = [];\n  eventEmitterService.eventEmitter.on(event, (payload) => {\n    payloads.push(payload);\n    if (payloads.length === count) {\n      theResolve(payloads);\n    }\n  });\n\n  return promise;\n};\nexport function createAwaitWithEventWithResultWithCount(\n  eventEmitterService: EventEmitterService,\n  event: Events,\n  count: number = 1\n) {\n  return async function runWithEventResultCount<T>(action: () => Promise<T>) {\n    const promise = createEventPromiseWithCount(eventEmitterService, event, count);\n    const result = await action();\n    const payloads = await promise;\n    return {\n      result,\n      payloads,\n    };\n  };\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/field-mock.ts",
    "content": "import type { IFieldRo, INumberFieldOptions, ISelectFieldOptions } from '@teable/core';\nimport { Colors, FieldType, NumberFormattingType } from '@teable/core';\n\nexport const FIELD_MOCK_DATA: IFieldRo[] = [\n  {\n    name: 'description',\n    type: FieldType.SingleLineText,\n    description: 'first field',\n  },\n  {\n    name: 'wight',\n    type: FieldType.SingleSelect,\n    options: {\n      choices: [\n        {\n          name: 'light',\n          color: Colors.Gray,\n        },\n        {\n          name: 'medium',\n          color: Colors.Yellow,\n        },\n        {\n          name: 'heavy',\n          color: Colors.Red,\n        },\n      ],\n    } as ISelectFieldOptions,\n  },\n  {\n    name: 'count',\n    type: FieldType.Number,\n    options: {\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n    } as INumberFieldOptions,\n  },\n];\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/get-error.ts",
    "content": "import type { HttpError } from '@teable/core';\n\nexport const getError = async (call: () => unknown) => {\n  try {\n    await call();\n    return;\n  } catch (error: unknown) {\n    return error as HttpError;\n  }\n};\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/init-app.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\nimport { ValidationPipe } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { WsAdapter } from '@nestjs/platform-ws';\nimport type { TestingModule } from '@nestjs/testing';\nimport { Test } from '@nestjs/testing';\nimport type {\n  IFieldRo,\n  IFieldVo,\n  IRecord,\n  CellFormat,\n  HttpError,\n  IColumnMetaRo,\n  IViewVo,\n  IFilterRo,\n  IViewRo,\n  IConditionalRollupFieldOptions,\n  IFilter,\n} from '@teable/core';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type {\n  ICreateRecordsRo,\n  ICreateRecordsVo,\n  ICreateTableRo,\n  IGetRecordsRo,\n  IRecordsVo,\n  IUpdateRecordRo,\n  ITableFullVo,\n  ICreateSpaceRo,\n  ICreateBaseRo,\n  IRecordInsertOrderRo,\n} from '@teable/openapi';\nimport {\n  axios,\n  signin as apiSignin,\n  getRecord as apiGetRecord,\n  deleteRecord as apiDeleteRecord,\n  deleteRecords as apiDeleteRecords,\n  updateRecord as apiUpdateRecord,\n  getRecords as apiGetRecords,\n  createRecords as apiCreateRecords,\n  createField as apiCreateField,\n  deleteField as apiDeleteField,\n  convertField as apiConvertField,\n  duplicateRecord as apiDuplicateRecord,\n  getFields as apiGetFields,\n  getField as apiGetField,\n  getViewList as apiGetViewList,\n  getView as apiGetViewById,\n  updateViewColumnMeta as apiSetViewColumnMeta,\n  createTable as apiCreateTable,\n  deleteTable as apiDeleteTable,\n  permanentDeleteTable as apiPermanentDeleteTable,\n  getTableById as apiGetTableById,\n  updateViewFilter as apiSetViewFilter,\n  createView as apiCreateView,\n  createSpace as apiCreateSpace,\n  deleteSpace as apiDeleteSpace,\n  createBase as apiCreateBase,\n  deleteBase as apiDeleteBase,\n  permanentDeleteSpace as apiPermanentDeleteSpace,\n  permanentDeleteBase as apiPermanentDeleteBase,\n} from '@teable/openapi';\nimport { json, urlencoded } from 'express';\nimport type { ClsService } from 'nestjs-cls';\nimport { AppModule } from '../../src/app.module';\nimport type { IBaseConfig } from '../../src/configs/base.config';\nimport { baseConfig } from '../../src/configs/base.config';\nimport { SessionHandleService } from '../../src/features/auth/session/session-handle.service';\nimport { BaseSqlExecutorModule } from '../../src/features/base-sql-executor/base-sql-executor.module';\nimport { FieldOpenApiV2Service } from '../../src/features/field/open-api/field-open-api-v2.service';\nimport { NextService } from '../../src/features/next/next.service';\nimport { TableIndexService } from '../../src/features/table/table-index.service';\nimport { GlobalExceptionFilter } from '../../src/filter/global-exception.filter';\nimport type { IClsStore } from '../../src/types/cls';\nimport { WsGateway } from '../../src/ws/ws.gateway';\nimport { DevWsGateway } from '../../src/ws/ws.gateway.dev';\nimport { TestingLogger } from './testing-logger';\n\nexport async function initApp() {\n  // eslint-disable-next-line @typescript-eslint/no-misused-promises\n  if (globalThis.initApp) return await globalThis.initApp();\n\n  const moduleFixture: TestingModule = await Test.createTestingModule({\n    imports: [AppModule, BaseSqlExecutorModule],\n  })\n    .overrideProvider(NextService)\n    .useValue({\n      onModuleInit: () => {\n        return;\n      },\n    })\n    .overrideProvider(DevWsGateway)\n    .useClass(WsGateway)\n    .compile();\n\n  const app = moduleFixture.createNestApplication({\n    logger: new TestingLogger(),\n  });\n\n  const configService = app.get(ConfigService);\n\n  app.useGlobalFilters(new GlobalExceptionFilter(configService));\n  app.useWebSocketAdapter(new WsAdapter(app));\n  app.useGlobalPipes(\n    new ValidationPipe({ transform: true, stopAtFirstError: true, forbidUnknownValues: false })\n  );\n\n  app.use(json({ limit: '50mb' }));\n  app.use(urlencoded({ limit: '50mb', extended: true }));\n\n  await app.listen(0);\n  const nestUrl = await app.getUrl();\n  const port = new URL(nestUrl).port;\n  const url = `http://127.0.0.1:${port}`;\n\n  process.env.PORT = port;\n  // for attachment origin set\n  process.env.STORAGE_PREFIX = url;\n  const baseConfigService = app.get(baseConfig.KEY) as IBaseConfig;\n  baseConfigService.storagePrefix = url;\n  baseConfigService.recordHistoryDisabled = true;\n\n  axios.defaults.baseURL = url + '/api';\n\n  const cookie = (\n    await getCookie(globalThis.testConfig.email, globalThis.testConfig.password)\n  ).cookie.join(';');\n\n  axios.interceptors.request.use((config) => {\n    config.headers.Cookie = cookie;\n    return config;\n  });\n\n  const now = new Date();\n  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n  console.log(`> Test NODE_ENV is ${process.env.NODE_ENV}`);\n  console.log(`> Test V2_COMPUTED_UPDATE_MODE is ${process.env.V2_COMPUTED_UPDATE_MODE}`);\n  console.log(`> Test FORCE_V2_ALL is ${process.env.FORCE_V2_ALL}`);\n  console.log(`> Test Ready on ${url}`);\n  console.log('> Test System Time Zone:', timeZone);\n  console.log('> Test Current System Time:', now.toString());\n\n  const sessionHandleService = app.get<SessionHandleService>(SessionHandleService);\n  return {\n    app,\n    appUrl: url,\n    cookie,\n    sessionID: await sessionHandleService.getSessionIdFromRequest({\n      headers: { cookie },\n      url: `${url}/socket`,\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any),\n  };\n}\n\n/**\n * Helper function to run code within CLS context with test user\n */\nexport async function runWithTestUser<T>(\n  clsService: ClsService<IClsStore>,\n  fn: () => Promise<T>,\n  userOverrides?: Partial<IClsStore['user']>\n): Promise<T> {\n  const testUser: IClsStore['user'] = {\n    id: globalThis.testConfig.userId,\n    name: globalThis.testConfig.userName,\n    email: globalThis.testConfig.email,\n    isAdmin: false,\n    ...userOverrides,\n  };\n\n  const clsStore: IClsStore = {\n    user: testUser,\n    origin: {\n      ip: '127.0.0.1',\n      byApi: false,\n      userAgent: 'test-agent',\n      referer: '',\n    },\n    tx: {},\n    permissions: [],\n  };\n\n  return clsService.runWith(clsStore, fn);\n}\n\nexport async function getTableIndexService(app: INestApplication) {\n  return app.get<TableIndexService>(TableIndexService);\n}\n\nexport async function createTable(baseId: string, tableVo: ICreateTableRo, expectStatus = 201) {\n  try {\n    const res = await apiCreateTable(baseId, tableVo);\n    expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as ITableFullVo;\n  }\n}\n\nexport async function deleteTable(baseId: string, tableId: string, expectStatus?: number) {\n  try {\n    const res = await apiDeleteTable(baseId, tableId);\n    expectStatus && expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if (expectStatus && (e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function permanentDeleteTable(baseId: string, tableId: string, expectStatus?: number) {\n  try {\n    const res = await apiPermanentDeleteTable(baseId, tableId);\n    expectStatus && expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if (expectStatus && (e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\ntype IMakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n\nexport async function getTable(\n  baseId: string,\n  tableId: string,\n  query?: { includeContent?: boolean; viewId?: string }\n): Promise<IMakeOptional<ITableFullVo, 'records' | 'views' | 'fields'>> {\n  const result = await apiGetTableById(baseId, tableId);\n  if (query?.includeContent) {\n    const { records } = await getRecords(tableId);\n    const fields = await getFields(tableId, query.viewId);\n    const views = await getViews(tableId);\n    return {\n      ...result.data,\n      records,\n      views,\n      fields,\n    };\n  }\n  return result.data;\n}\n\nexport async function getCookie(email: string, password: string) {\n  const sessionResponse = await apiSignin({ email, password });\n  return {\n    access_token: sessionResponse.data,\n    cookie: sessionResponse.headers['set-cookie'] as string[],\n  };\n}\n\nexport async function updateRecordByApi(\n  tableId: string,\n  recordId: string,\n  fieldId: string,\n  newValue: unknown,\n  expectStatus = 200,\n  fieldKeyType = FieldKeyType.Id\n) {\n  try {\n    const res = await apiUpdateRecord(tableId, recordId, {\n      record: { fields: { [fieldId]: newValue } },\n      fieldKeyType,\n    });\n    expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function updateRecord(\n  tableId: string,\n  recordId: string,\n  recordRo: IUpdateRecordRo,\n  expectStatus = 200\n) {\n  try {\n    const res = await apiUpdateRecord(tableId, recordId, recordRo);\n    expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function deleteRecord(tableId: string, recordId: string, expectStatus = 200) {\n  try {\n    const res = await apiDeleteRecord(tableId, recordId);\n    expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function deleteRecords(tableId: string, recordIds: string[], expectStatus = 200) {\n  try {\n    const res = await apiDeleteRecords(tableId, recordIds);\n    expect(res.status).toEqual(expectStatus);\n\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function getRecord(\n  tableId: string,\n  recordId: string,\n  cellFormat?: CellFormat,\n  expectStatus = 200\n): Promise<IRecord> {\n  try {\n    const query: { fieldKeyType: FieldKeyType; cellFormat?: CellFormat } = {\n      fieldKeyType: FieldKeyType.Id,\n    };\n    if (cellFormat) {\n      query.cellFormat = cellFormat;\n    }\n    const res = await apiGetRecord(tableId, recordId, {\n      ...query,\n    });\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function getRecords(tableId: string, query?: IGetRecordsRo): Promise<IRecordsVo> {\n  const result = await apiGetRecords(tableId, query);\n\n  return result.data;\n}\n\nexport async function duplicateRecord(\n  tableId: string,\n  recordId: string,\n  order: IRecordInsertOrderRo,\n  expectStatus = 201\n) {\n  try {\n    const res = await apiDuplicateRecord(tableId, recordId, order);\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IRecord;\n  }\n}\n\nexport async function createRecords(\n  tableId: string,\n  recordsRo: ICreateRecordsRo,\n  expectStatus = 201\n): Promise<ICreateRecordsVo> {\n  try {\n    const res = await apiCreateRecords(tableId, {\n      ...recordsRo,\n      fieldKeyType: recordsRo.fieldKeyType ?? FieldKeyType.Id,\n      records: recordsRo.records,\n      typecast: recordsRo.typecast ?? false,\n    });\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as ICreateRecordsVo;\n  }\n}\n\nconst createDefaultConditionalRollupFilter = (fieldId: string): IFilter => ({\n  conjunction: 'and',\n  filterSet: [\n    {\n      fieldId,\n      operator: 'isNotEmpty',\n      value: null,\n    },\n  ],\n});\n\nconst ensureConditionalRollupOptions = (fieldRo: IFieldRo): IFieldRo => {\n  if (fieldRo.type !== FieldType.ConditionalRollup) {\n    return fieldRo;\n  }\n\n  const options = fieldRo.options as Partial<IConditionalRollupFieldOptions> | undefined;\n  if (!options?.lookupFieldId) {\n    return fieldRo;\n  }\n\n  if (options.filter === null) {\n    return {\n      ...fieldRo,\n      options: {\n        ...options,\n        filter: undefined,\n      } as IConditionalRollupFieldOptions,\n    };\n  }\n\n  const hasFilterConditions =\n    options.filter?.filterSet != null && options.filter.filterSet.length > 0;\n\n  if (hasFilterConditions) {\n    return fieldRo;\n  }\n\n  return {\n    ...fieldRo,\n    options: {\n      ...options,\n      filter: createDefaultConditionalRollupFilter(options.lookupFieldId),\n    } as IConditionalRollupFieldOptions,\n  };\n};\n\nexport async function createField(\n  tableId: string,\n  fieldRo: IFieldRo,\n  expectStatus = 201\n): Promise<IFieldVo> {\n  try {\n    const normalizedField = ensureConditionalRollupOptions(fieldRo);\n    const res = await apiCreateField(tableId, normalizedField);\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IFieldVo;\n  }\n}\n\nexport async function createFields(\n  tableId: string,\n  fieldRos: IFieldRo[],\n  appInstance?: INestApplication\n): Promise<IFieldVo[]> {\n  const normalizedFields = fieldRos.map((field) => ensureConditionalRollupOptions(field));\n  const app = appInstance ?? (await initApp()).app;\n  const fieldOpenApiV2Service = app.get(FieldOpenApiV2Service);\n  const clsService = (fieldOpenApiV2Service as unknown as { cls: ClsService<IClsStore> }).cls;\n  return await runWithTestUser(clsService, async () =>\n    fieldOpenApiV2Service.createFields(tableId, normalizedFields)\n  );\n}\n\nexport async function deleteField(tableId: string, fieldId: string) {\n  const result = await apiDeleteField(tableId, fieldId);\n\n  if (result.status !== 200) {\n    console.error(result.data);\n  }\n\n  expect(result.status).toEqual(200);\n  return result.data;\n}\n\nexport async function convertField(\n  tableId: string,\n  fieldId: string,\n  fieldRo: IFieldRo,\n  expectStatus = 200\n): Promise<IFieldVo> {\n  try {\n    const normalizedField = ensureConditionalRollupOptions(fieldRo);\n    const res = await apiConvertField(tableId, fieldId, normalizedField);\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IFieldVo;\n  }\n}\n\nexport async function getFields(\n  tableId: string,\n  viewId?: string,\n  filterHidden?: boolean,\n  projection?: string[]\n): Promise<IFieldVo[]> {\n  const result = await apiGetFields(tableId, { viewId, filterHidden, projection });\n\n  return result.data;\n}\n\nexport async function getField(\n  tableId: string,\n  fieldId: string,\n  expectStatus = 200\n): Promise<IFieldVo> {\n  try {\n    const res = await apiGetField(tableId, fieldId);\n\n    expect(res.status).toEqual(expectStatus);\n    return res.data;\n  } catch (e: unknown) {\n    if ((e as HttpError).status !== expectStatus) {\n      throw e;\n    }\n    return {} as IFieldVo;\n  }\n}\n\nexport async function getViews(tableId: string): Promise<IViewVo[]> {\n  const result = await apiGetViewList(tableId);\n  return result.data;\n}\n\nexport async function getView(tableId: string, viewId: string): Promise<IViewVo> {\n  const result = await apiGetViewById(tableId, viewId);\n  return result.data;\n}\n\nexport async function createView(tableId: string, viewRo: IViewRo) {\n  const result = await apiCreateView(tableId, viewRo);\n  return result.data;\n}\n\nexport async function updateViewColumnMeta(\n  tableId: string,\n  viewId: string,\n  columnMetaRo: IColumnMetaRo\n) {\n  const result = await apiSetViewColumnMeta(tableId, viewId, columnMetaRo);\n  return result.data;\n}\n\nexport async function updateViewFilter(tableId: string, viewId: string, filterRo: IFilterRo) {\n  const result = await apiSetViewFilter(tableId, viewId, filterRo);\n  return result.data;\n}\n\nexport async function createSpace(spaceRo: ICreateSpaceRo) {\n  const result = await apiCreateSpace(spaceRo);\n  return result.data;\n}\n\nexport async function deleteSpace(spaceId: string) {\n  const result = await apiDeleteSpace(spaceId);\n  return result.data;\n}\n\nexport async function permanentDeleteSpace(spaceId: string) {\n  const result = await apiPermanentDeleteSpace(spaceId);\n  return result.data;\n}\n\nexport async function createBase(baseRo: ICreateBaseRo) {\n  const result = await apiCreateBase(baseRo);\n  return result.data;\n}\n\nexport async function deleteBase(baseId: string) {\n  const result = await apiDeleteBase(baseId);\n  return result.data;\n}\n\nexport async function permanentDeleteBase(baseId: string) {\n  const result = await apiPermanentDeleteBase(baseId);\n  return result.data;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/record-mock.ts",
    "content": "import { faker } from '@faker-js/faker';\nimport type { Field } from '@prisma/client';\nimport { PrismaClient } from '@prisma/client';\nimport type { IRatingFieldOptions, ISelectFieldOptions } from '@teable/core';\nimport { parseDsn, Colors, FieldType, generateRecordId } from '@teable/core';\nimport * as dotenv from 'dotenv-flow';\nimport Knex from 'knex';\nimport { chunk, flatten, groupBy } from 'lodash';\n\ndotenv.config({ path: '../../../nextjs-app', default_node_env: 'development' });\n\n// eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n\nasync function rectifyField(\n  prisma: PrismaClient,\n  fields: Field[],\n  selectOptions: ISelectFieldOptions\n) {\n  const fieldByType = groupBy(fields, 'type');\n\n  const rectifySelectField = [\n    ...(fieldByType?.['singleSelect'] || []),\n    ...(fieldByType?.['multipleSelect'] || []),\n  ]\n    .filter((value) => value)\n    .map((value) => value.id);\n\n  if (rectifySelectField) {\n    await prisma.field.updateMany({\n      where: { id: { in: rectifySelectField } },\n      data: {\n        options: JSON.stringify(selectOptions),\n      },\n    });\n  }\n}\n\nasync function generateFieldData(params: {\n  mockDataNum: number;\n  fields: Field[];\n  selectOptions: ISelectFieldOptions;\n}) {\n  const { fields, selectOptions, mockDataNum } = params;\n\n  return fields.reduce<{ [dbFieldName: string]: unknown }>((pre, cur) => {\n    const selectArray = selectOptions.choices.map((value) => value.name);\n\n    let fieldData: unknown = undefined;\n    switch (cur.type as FieldType) {\n      case FieldType.SingleLineText:\n      case FieldType.LongText: {\n        fieldData = faker.internet.userName();\n        break;\n      }\n      case FieldType.Number: {\n        fieldData = faker.number.float({ min: 1, max: mockDataNum });\n        break;\n      }\n      case FieldType.SingleSelect: {\n        fieldData = faker.helpers.arrayElement(selectArray);\n        break;\n      }\n      case FieldType.MultipleSelect: {\n        fieldData = JSON.stringify(faker.helpers.arrayElements(selectArray, { min: 2, max: 9 }));\n        break;\n      }\n      case FieldType.Checkbox: {\n        fieldData = faker.helpers.arrayElement([1, 'null']);\n        break;\n      }\n      case FieldType.Date: {\n        fieldData = faker.date.anytime().toISOString();\n        break;\n      }\n      case FieldType.Rating: {\n        const ratingFieldOptions = JSON.parse(cur.options!) as IRatingFieldOptions;\n        fieldData = faker.number.int({ min: 0, max: ratingFieldOptions.max });\n        break;\n      }\n    }\n\n    (fieldData || fieldData === 0) && (pre[cur.dbFieldName] = fieldData);\n    return pre;\n  }, {});\n}\n\nexport async function seeding(tableId: string, mockDataNum: number) {\n  const databaseUrl = process.env.PRISMA_DATABASE_URL!;\n  console.log('database-url: ', databaseUrl);\n  const { driver } = parseDsn(databaseUrl);\n  console.log('driver: ', driver);\n  const prisma = new PrismaClient();\n\n  console.log(`Start seeding ...`);\n\n  const selectOptions: ISelectFieldOptions = {\n    choices: [\n      { id: 'chobird', name: 'bird', color: Colors.GreenDark1 },\n      { id: 'chofish', name: 'fish', color: Colors.PurpleLight2 },\n      { id: 'cholion', name: 'lion', color: Colors.OrangeLight1 },\n      { id: 'choelephant', name: 'elephant', color: Colors.CyanLight2 },\n      { id: 'chotiger', name: 'tiger', color: Colors.Yellow },\n      { id: 'chorabbit', name: 'rabbit', color: Colors.Red },\n      { id: 'chobear', name: 'bear', color: Colors.YellowLight1 },\n      { id: 'chohorse', name: 'horse', color: Colors.RedBright },\n      { id: 'chosnake', name: 'snake', color: Colors.RedLight2 },\n      { id: 'chomonkey', name: 'monkey', color: Colors.Gray },\n    ],\n  };\n\n  const fields = await prisma.field.findMany({\n    where: {\n      tableId,\n      deletedTime: null,\n    },\n  });\n  await rectifyField(prisma, fields, selectOptions);\n\n  const { dbTableName, name: tableName } = await prisma.tableMeta.findUniqueOrThrow({\n    select: { dbTableName: true, name: true },\n    where: { id: tableId },\n  });\n  console.log(`Table: ${tableName}, mockDataNum: ${mockDataNum}`);\n\n  const knex = Knex({\n    client: driver,\n  });\n\n  console.time(`Table: ${tableName}, Ready Install Data`);\n  const data: { [dbFieldName: string]: unknown }[] = [];\n  for (let i = 0; i < mockDataNum; i++) {\n    const fieldData = await generateFieldData({ mockDataNum, fields, selectOptions });\n\n    data.push({\n      __id: generateRecordId(),\n      __created_time: new Date().toISOString(),\n      __created_by: 'admin',\n      __last_modified_by: 'admin',\n      __version: 1,\n      ...fieldData,\n    });\n  }\n  console.timeEnd(`Table: ${tableName}, Ready Install Data`);\n\n  console.time(`Table: ${tableName}, Install Data Num: ${mockDataNum}`);\n  const pages = chunk(data, 50000);\n\n  const promises = pages.map((page) => {\n    const sql = `\n        INSERT INTO ${knex.ref(dbTableName)}\n        (\"${Object.keys(page[0]).join('\", \"')}\")\n        VALUES\n        ${page\n          .map((d) => `('${Object.values(d).join(`', '`)}')`)\n          .join(', ')\n          .replace(/'null'/g, 'null')} \n      `;\n\n    return [prisma.$executeRawUnsafe(sql)];\n  });\n\n  await prisma.$transaction(flatten(promises));\n  console.timeEnd(`Table: ${tableName}, Install Data Num: ${mockDataNum}`);\n  return tableId;\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/seed.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { seeding } from './record-mock';\n\nasync function run() {\n  const [, , tableId, mockDataNum = 250000] = process.argv as any[];\n  if (!tableId) {\n    throw new Error('💥No bugs. No bugs at all.💥');\n  }\n\n  await seeding(tableId, mockDataNum);\n}\n\nrun().catch((e) => {\n  console.error(e);\n  process.exit(1);\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/testing-logger.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { LogLevel } from '@nestjs/common';\nimport { ConsoleLogger } from '@nestjs/common';\n\nexport class TestingLogger extends ConsoleLogger {\n  constructor() {\n    const testLogLevel = (process.env.TEST_LOG_LEVEL ?? '').split(',') as LogLevel[];\n\n    super('Testing', {\n      logLevels: testLogLevel?.length > 0 ? testLogLevel : undefined,\n    });\n  }\n\n  log(message: string, ...optionalParams: any[]) {\n    if (!this.isLevelEnabled('log')) {\n      return;\n    }\n    console.log(message, optionalParams);\n  }\n\n  warn(message: string) {\n    if (!this.isLevelEnabled('warn')) {\n      return;\n    }\n    console.warn(message);\n  }\n\n  debug(message: string, ...optionalParams: any[]) {\n    if (!this.isLevelEnabled('debug')) {\n      return;\n    }\n    console.debug(message, optionalParams);\n  }\n\n  verbose(message: string) {\n    if (!this.isLevelEnabled('verbose')) {\n      return;\n    }\n    console.log(message);\n  }\n\n  error(message: string, ...optionalParams: any[]) {\n    if (!this.isLevelEnabled('error')) {\n      return;\n    }\n    console.error(message, optionalParams);\n  }\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/utils/wait.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Doc } from 'sharedb/lib/client';\n\nexport async function waitFor(\n  predicate: () => boolean,\n  timeoutMs = 8000,\n  intervalMs = 50\n): Promise<void> {\n  const start = Date.now();\n  return new Promise<void>((resolve, reject) => {\n    const check = () => {\n      try {\n        if (predicate()) return resolve();\n        if (Date.now() - start > timeoutMs)\n          return reject(new Error('timeout waiting for condition'));\n        setTimeout(check, intervalMs);\n      } catch (e) {\n        reject(e as Error);\n      }\n    };\n    check();\n  });\n}\n\nexport async function subscribeDocs(docs: Doc<any>[], timeoutMs = 4000): Promise<void> {\n  return new Promise<void>((resolve, reject) => {\n    let count = 0;\n    const done = () => {\n      count++;\n      if (count === docs.length) resolve();\n    };\n    docs.forEach((doc) => doc.subscribe((err) => (err ? reject(err) : done())));\n    setTimeout(() => reject(new Error('subscribe timeout')), timeoutMs);\n  });\n}\n"
  },
  {
    "path": "apps/nestjs-backend/test/v2-action-trigger-field-conversion.e2e-spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType, getActionTriggerChannel } from '@teable/core';\nimport { axios, X_CANARY_HEADER } from '@teable/openapi';\nimport type { Connection } from 'sharedb/lib/client';\nimport { ShareDbService } from '../src/share-db/share-db.service';\nimport {\n  createField,\n  createRecords,\n  createTable,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ninterface IActionTrigger {\n  actionKey: string;\n  payload?: Record<string, unknown>;\n}\n\nconst amountTextFieldName = 'Amount Text';\n\nlet fieldIdCounter = 0;\n\nconst createFieldId = () => {\n  const suffix = fieldIdCounter.toString(36).padStart(16, '0');\n  fieldIdCounter += 1;\n  return `fld${suffix}`;\n};\n\nconst createConnection = (\n  shareDbService: ShareDbService,\n  cookie: string,\n  port: string\n): Connection => {\n  return shareDbService.connect(undefined, {\n    url: `ws://localhost:${port}/socket`,\n    headers: { cookie },\n  });\n};\n\nconst collectActionTriggers = async (params: {\n  shareDbService: ShareDbService;\n  cookie: string;\n  port: string;\n  tableId: string;\n  act: () => Promise<unknown>;\n  idleMs?: number;\n  timeoutMs?: number;\n  until?: (actions: ReadonlyArray<IActionTrigger>) => boolean;\n}): Promise<IActionTrigger[]> => {\n  const {\n    shareDbService,\n    cookie,\n    port,\n    tableId,\n    act,\n    idleMs = 300,\n    timeoutMs = 5000,\n    until,\n  } = params;\n\n  return new Promise<IActionTrigger[]>((resolve, reject) => {\n    const connection = createConnection(shareDbService, cookie, port);\n    const presence = connection.getPresence(getActionTriggerChannel(tableId));\n    const received: IActionTrigger[] = [];\n    let capture = false;\n    let settled = false;\n    let actCompleted = false;\n    let idleTimer: NodeJS.Timeout | undefined;\n\n    const cleanup = () => {\n      clearTimeout(timeout);\n      if (idleTimer) clearTimeout(idleTimer);\n      presence.removeListener('receive', onReceive);\n      try {\n        presence.unsubscribe();\n        presence.destroy();\n      } catch {\n        void 0;\n      }\n      connection.close();\n    };\n\n    const finish = (error?: unknown) => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n      if (error) {\n        reject(error instanceof Error ? error : new Error(String(error)));\n        return;\n      }\n      resolve(received);\n    };\n\n    const onReceive = (_id: string, batch: IActionTrigger[]) => {\n      if (!capture) {\n        return;\n      }\n      received.push(...batch);\n      if (until?.(received)) {\n        finish();\n        return;\n      }\n      if (!actCompleted) {\n        return;\n      }\n      if (idleTimer) clearTimeout(idleTimer);\n      idleTimer = setTimeout(() => finish(), idleMs);\n    };\n\n    const timeout = setTimeout(() => {\n      finish(new Error('Action trigger timeout'));\n    }, timeoutMs);\n\n    presence.subscribe(async (error: unknown) => {\n      if (error) {\n        finish(error);\n        return;\n      }\n\n      presence.on('receive', onReceive);\n\n      try {\n        capture = true;\n        await act();\n        actCompleted = true;\n        if (until?.(received)) {\n          finish();\n          return;\n        }\n        if (idleTimer) clearTimeout(idleTimer);\n        idleTimer = setTimeout(() => finish(), idleMs);\n      } catch (actError) {\n        finish(actError);\n      }\n    });\n  });\n};\n\ndescribe('V2 action trigger field conversion (e2e)', () => {\n  let app: INestApplication;\n  let cookie: string;\n  let port: string;\n  let shareDbService: ShareDbService;\n  const tableIds = new Set<string>();\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    cookie = appCtx.cookie;\n    port = process.env.PORT!;\n    shareDbService = app.get(ShareDbService);\n  });\n\n  afterAll(async () => {\n    for (const tableId of [...tableIds].reverse()) {\n      await permanentDeleteTable(baseId, tableId);\n    }\n    await app.close();\n  });\n\n  it('emits field update and schema-refresh setField presence for type conversion without record events', async () => {\n    const table = await createTable(baseId, {\n      name: 'v2-action-trigger-field-conversion',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText, isPrimary: true },\n        { name: amountTextFieldName, type: FieldType.SingleLineText },\n      ],\n    });\n    tableIds.add(table.id);\n\n    const amountFieldId = table.fields.find((field) => field.name === amountTextFieldName)?.id;\n    if (!amountFieldId) {\n      throw new Error('Amount Text field not found');\n    }\n\n    await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: { [amountFieldId]: '100' } }, { fields: { [amountFieldId]: '' } }],\n    });\n\n    const actions = await collectActionTriggers({\n      shareDbService,\n      cookie,\n      port,\n      tableId: table.id,\n      until: (actions) =>\n        actions.some(\n          (action) =>\n            action.actionKey === 'setField' &&\n            Array.isArray(\n              (action.payload?.field as { updatedProperties?: string[] } | undefined)\n                ?.updatedProperties\n            )\n        ) &&\n        actions.some(\n          (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n        ),\n      act: async () => {\n        const response = await axios.put(\n          `/table/${table.id}/field/${amountFieldId}/convert`,\n          {\n            name: amountTextFieldName,\n            type: FieldType.Number,\n          },\n          {\n            headers: {\n              [X_CANARY_HEADER]: 'true',\n            },\n          }\n        );\n\n        expect(response.status).toBe(200);\n        expect(response.headers['x-teable-v2']).toBe('true');\n      },\n    });\n\n    expect(actions.some((action) => action.actionKey === 'setField')).toBe(true);\n    expect(actions.some((action) => action.actionKey === 'setRecord')).toBe(false);\n\n    const setFieldAction = actions.find(\n      (action) =>\n        action.actionKey === 'setField' &&\n        Array.isArray(\n          (action.payload?.field as { updatedProperties?: string[] } | undefined)?.updatedProperties\n        )\n    );\n    expect(setFieldAction?.payload).toMatchObject({\n      tableId: table.id,\n      field: {\n        id: amountFieldId,\n      },\n    });\n\n    const updatedProperties = (setFieldAction?.payload?.field as { updatedProperties?: string[] })\n      ?.updatedProperties;\n    expect(updatedProperties).toEqual(expect.arrayContaining(['type']));\n\n    const schemaRefreshAction = actions.find(\n      (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n    );\n    expect(schemaRefreshAction?.payload).toMatchObject({\n      tableId: table.id,\n      field: {\n        id: amountFieldId,\n      },\n      fieldIds: [amountFieldId],\n    });\n  });\n\n  it('emits field update and schema-refresh setField presence when converting text to formula', async () => {\n    const table = await createTable(baseId, {\n      name: 'v2-action-trigger-field-conversion-formula',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText, isPrimary: true },\n        { name: amountTextFieldName, type: FieldType.SingleLineText },\n      ],\n    });\n    tableIds.add(table.id);\n\n    const amountFieldId = table.fields.find((field) => field.name === amountTextFieldName)?.id;\n    if (!amountFieldId) {\n      throw new Error('Amount Text field not found');\n    }\n\n    await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: { [amountFieldId]: '100' } }, { fields: { [amountFieldId]: '' } }],\n    });\n\n    const actions = await collectActionTriggers({\n      shareDbService,\n      cookie,\n      port,\n      tableId: table.id,\n      until: (actions) =>\n        actions.some(\n          (action) =>\n            action.actionKey === 'setField' &&\n            Array.isArray(\n              (action.payload?.field as { updatedProperties?: string[] } | undefined)\n                ?.updatedProperties\n            )\n        ) &&\n        actions.some(\n          (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n        ),\n      act: async () => {\n        const response = await axios.put(\n          `/table/${table.id}/field/${amountFieldId}/convert`,\n          {\n            name: amountTextFieldName,\n            type: FieldType.Formula,\n            options: {\n              expression: '1 + 1',\n            },\n          },\n          {\n            headers: {\n              [X_CANARY_HEADER]: 'true',\n            },\n          }\n        );\n\n        expect(response.status).toBe(200);\n        expect(response.headers['x-teable-v2']).toBe('true');\n      },\n    });\n\n    expect(actions.some((action) => action.actionKey === 'setField')).toBe(true);\n    expect(actions.some((action) => action.actionKey === 'setRecord')).toBe(false);\n\n    const schemaRefreshAction = actions.find(\n      (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n    );\n    expect(schemaRefreshAction?.payload).toMatchObject({\n      tableId: table.id,\n      field: {\n        id: amountFieldId,\n      },\n      fieldIds: [amountFieldId],\n    });\n  });\n\n  it('emits schema-refresh setField for host tables when foreign schema updates recompute lookup values', async () => {\n    const optionOpen = { id: 'choOpen', name: 'Open', color: 'blueBright' as const };\n    const optionDone = { id: 'choDone', name: 'Done', color: 'greenBright' as const };\n\n    const foreignTable = await createTable(baseId, {\n      name: 'v2-action-trigger-foreign-schema-source',\n      fields: [\n        { name: 'Name', type: 'singleLineText', isPrimary: true },\n        {\n          name: 'Status',\n          type: 'singleSelect',\n          options: { choices: [optionOpen, optionDone] },\n        },\n      ],\n    });\n    tableIds.add(foreignTable.id);\n\n    const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.name === 'Name')?.id;\n    const foreignStatusFieldId = foreignTable.fields.find((field) => field.name === 'Status')?.id;\n    if (!foreignPrimaryFieldId || !foreignStatusFieldId) {\n      throw new Error('Foreign fields not found');\n    }\n\n    const hostPrimaryFieldId = createFieldId();\n    const linkFieldId = createFieldId();\n    const lookupFieldId = createFieldId();\n    const hostTable = await createTable(baseId, {\n      name: 'v2-action-trigger-foreign-schema-host',\n      fields: [\n        {\n          id: hostPrimaryFieldId,\n          name: 'Name',\n          type: 'singleLineText',\n          isPrimary: true,\n        },\n        {\n          id: linkFieldId,\n          name: 'Link',\n          type: 'link',\n          options: {\n            relationship: 'manyOne',\n            foreignTableId: foreignTable.id,\n            lookupFieldId: foreignPrimaryFieldId,\n            isOneWay: true,\n          },\n        },\n      ],\n    });\n    tableIds.add(hostTable.id);\n\n    await createField(hostTable.id, {\n      id: lookupFieldId,\n      name: 'Lookup Status',\n      type: FieldType.SingleSelect,\n      isLookup: true,\n      lookupOptions: {\n        linkFieldId,\n        foreignTableId: foreignTable.id,\n        lookupFieldId: foreignStatusFieldId,\n      },\n    });\n\n    const foreignRecord = await createRecords(foreignTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [foreignPrimaryFieldId]: 'Source 1',\n            [foreignStatusFieldId]: 'Open',\n          },\n        },\n      ],\n    });\n\n    await createRecords(hostTable.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [hostPrimaryFieldId]: 'Host 1',\n            [linkFieldId]: { id: foreignRecord.records[0].id },\n          },\n        },\n      ],\n    });\n\n    const actions = await collectActionTriggers({\n      shareDbService,\n      cookie,\n      port,\n      tableId: hostTable.id,\n      until: (actions) =>\n        actions.some(\n          (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n        ),\n      act: async () => {\n        const response = await axios.put(\n          `/table/${foreignTable.id}/field/${foreignStatusFieldId}/convert`,\n          {\n            name: 'Status',\n            type: FieldType.SingleSelect,\n            options: {\n              choices: [{ ...optionOpen, name: 'Closed' }, optionDone],\n            },\n          },\n          {\n            headers: {\n              [X_CANARY_HEADER]: 'true',\n            },\n          }\n        );\n\n        expect(response.status).toBe(200);\n        expect(response.headers['x-teable-v2']).toBe('true');\n      },\n    });\n\n    expect(actions.some((action) => action.actionKey === 'setRecord')).toBe(false);\n    expect(actions.some((action) => action.actionKey === 'setField')).toBe(true);\n\n    const schemaRefreshAction = actions.find(\n      (action) => action.actionKey === 'setField' && Array.isArray(action.payload?.fieldIds)\n    );\n    expect(schemaRefreshAction?.payload).toMatchObject({\n      tableId: hostTable.id,\n      field: {\n        id: lookupFieldId,\n      },\n      fieldIds: [lookupFieldId],\n    });\n  });\n\n  it('emits addField and schema-driven setRecord when creating a formula field', async () => {\n    const sourceFieldId = createFieldId();\n    const formulaFieldId = createFieldId();\n    const table = await createTable(baseId, {\n      name: 'v2-action-trigger-create-formula-field',\n      fields: [\n        { name: 'Name', type: FieldType.SingleLineText, isPrimary: true },\n        { id: sourceFieldId, name: amountTextFieldName, type: FieldType.Number },\n      ],\n    });\n    tableIds.add(table.id);\n\n    await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: { [sourceFieldId]: 100 } }, { fields: { [sourceFieldId]: 50 } }],\n    });\n\n    const actions = await collectActionTriggers({\n      shareDbService,\n      cookie,\n      port,\n      tableId: table.id,\n      until: (actions) =>\n        actions.some((action) => action.actionKey === 'addField') &&\n        actions.some((action) => action.actionKey === 'setRecord'),\n      act: async () => {\n        const response = await axios.post(\n          `/table/${table.id}/field`,\n          {\n            id: formulaFieldId,\n            name: 'Amount x 2',\n            type: FieldType.Formula,\n            options: {\n              expression: `{${sourceFieldId}} * 2`,\n            },\n          },\n          {\n            headers: {\n              [X_CANARY_HEADER]: 'true',\n            },\n          }\n        );\n\n        expect(response.status).toBe(201);\n        expect(response.headers['x-teable-v2']).toBe('true');\n      },\n    });\n\n    const addFieldAction = actions.find((action) => action.actionKey === 'addField');\n    expect(addFieldAction?.payload).toMatchObject({\n      tableId: table.id,\n      field: {\n        id: formulaFieldId,\n      },\n    });\n\n    const setRecordAction = actions.find((action) => action.actionKey === 'setRecord');\n    expect(setRecordAction?.payload).toMatchObject({\n      tableId: table.id,\n      fieldIds: [formulaFieldId],\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/v2-update-records.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport { updateRecordsOkResponseSchema } from '@teable/v2-contract-http';\n\nimport {\n  createRecords,\n  createTable,\n  getRecords,\n  initApp,\n  permanentDeleteTable,\n} from './utils/init-app';\n\ndescribe('V2Controller updateRecords (e2e)', () => {\n  let app: INestApplication;\n  let appUrl: string;\n  let cookie: string;\n\n  const baseId = globalThis.testConfig.baseId;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    appUrl = appCtx.appUrl;\n    cookie = appCtx.cookie;\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  const createFilterVariantTable = async (name: string) => {\n    const table = await createTable(baseId, {\n      name,\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText, isPrimary: true },\n        { name: 'Amount', type: FieldType.Number },\n        { name: 'Status', type: FieldType.SingleLineText },\n      ],\n    });\n\n    const titleFieldId = table.fields.find((field) => field.name === 'Title')?.id ?? '';\n    const amountFieldId = table.fields.find((field) => field.name === 'Amount')?.id ?? '';\n    const statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? '';\n\n    await createRecords(table.id, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: {\n            [titleFieldId]: 'Alpha',\n            [amountFieldId]: 2,\n            [statusFieldId]: 'Open',\n          },\n        },\n        {\n          fields: {\n            [titleFieldId]: 'Beta',\n            [amountFieldId]: 8,\n            [statusFieldId]: 'Open',\n          },\n        },\n        {\n          fields: {\n            [titleFieldId]: 'Gamma',\n            [amountFieldId]: 12,\n            [statusFieldId]: 'Done',\n          },\n        },\n        {\n          fields: {\n            [titleFieldId]: 'Delta',\n            [amountFieldId]: 5,\n            [statusFieldId]: 'InProgress',\n          },\n        },\n      ],\n    });\n\n    return {\n      table,\n      titleFieldId,\n      amountFieldId,\n      statusFieldId,\n    };\n  };\n\n  const getStatusByTitle = async (tableId: string, titleFieldId: string, statusFieldId: string) => {\n    const records = await getRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      skip: 0,\n      take: 100,\n    });\n    return new Map(\n      records.records.map((record) => [record.fields[titleFieldId], record.fields[statusFieldId]])\n    );\n  };\n\n  it('updates records through /api/v2/tables/updateRecords', async () => {\n    const table = await createTable(baseId, {\n      name: 'v2 update records',\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText, isPrimary: true },\n        { name: 'Amount', type: FieldType.Number },\n        { name: 'Status', type: FieldType.SingleLineText },\n      ],\n    });\n\n    try {\n      const titleFieldId = table.fields.find((field) => field.name === 'Title')?.id ?? '';\n      const amountFieldId = table.fields.find((field) => field.name === 'Amount')?.id ?? '';\n      const statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? '';\n\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: 'Alpha',\n              [amountFieldId]: 1,\n              [statusFieldId]: 'Open',\n            },\n          },\n          {\n            fields: {\n              [titleFieldId]: 'Beta',\n              [amountFieldId]: 8,\n              [statusFieldId]: 'Open',\n            },\n          },\n          {\n            fields: {\n              [titleFieldId]: 'Gamma',\n              [amountFieldId]: 12,\n              [statusFieldId]: 'Open',\n            },\n          },\n        ],\n      });\n\n      const response = await fetch(`${appUrl}/api/v2/tables/updateRecords`, {\n        method: 'POST',\n        headers: {\n          cookie,\n          'content-type': 'application/json',\n        },\n        body: JSON.stringify({\n          tableId: table.id,\n          fields: {\n            [statusFieldId]: 'Done',\n          },\n          filter: {\n            fieldId: amountFieldId,\n            operator: 'isGreater',\n            value: 5,\n          },\n        }),\n      });\n\n      expect(response.status).toBe(200);\n\n      const rawBody = await response.json();\n      const parsed = updateRecordsOkResponseSchema.safeParse(rawBody);\n      expect(parsed.success).toBe(true);\n      if (!parsed.success) return;\n\n      expect(parsed.data.data.updatedCount).toBe(2);\n\n      const records = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        skip: 0,\n        take: 100,\n      });\n      const statusByTitle = new Map(\n        records.records.map((record) => [record.fields[titleFieldId], record.fields[statusFieldId]])\n      );\n\n      expect(statusByTitle.get('Alpha')).toBe('Open');\n      expect(statusByTitle.get('Beta')).toBe('Done');\n      expect(statusByTitle.get('Gamma')).toBe('Done');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('updates records through /api/v2/tables/updateRecords with nested filter groups', async () => {\n    const { table, titleFieldId, amountFieldId, statusFieldId } = await createFilterVariantTable(\n      'v2 update records nested filters'\n    );\n\n    try {\n      const response = await fetch(`${appUrl}/api/v2/tables/updateRecords`, {\n        method: 'POST',\n        headers: {\n          cookie,\n          'content-type': 'application/json',\n        },\n        body: JSON.stringify({\n          tableId: table.id,\n          fields: {\n            [statusFieldId]: 'Escalated',\n          },\n          filter: {\n            conjunction: 'or',\n            items: [\n              {\n                fieldId: statusFieldId,\n                operator: 'is',\n                value: 'InProgress',\n              },\n              {\n                conjunction: 'and',\n                items: [\n                  {\n                    fieldId: amountFieldId,\n                    operator: 'isGreater',\n                    value: 10,\n                  },\n                  {\n                    fieldId: titleFieldId,\n                    operator: 'contains',\n                    value: 'mm',\n                  },\n                ],\n              },\n            ],\n          },\n        }),\n      });\n\n      expect(response.status).toBe(200);\n\n      const rawBody = await response.json();\n      const parsed = updateRecordsOkResponseSchema.safeParse(rawBody);\n      expect(parsed.success).toBe(true);\n      if (!parsed.success) return;\n\n      expect(parsed.data.data.updatedCount).toBe(2);\n\n      const statusByTitle = await getStatusByTitle(table.id, titleFieldId, statusFieldId);\n\n      expect(statusByTitle.get('Alpha')).toBe('Open');\n      expect(statusByTitle.get('Beta')).toBe('Open');\n      expect(statusByTitle.get('Gamma')).toBe('Escalated');\n      expect(statusByTitle.get('Delta')).toBe('Escalated');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('updates records through /api/v2/tables/updateRecords with negated filters', async () => {\n    const { table, titleFieldId, statusFieldId } = await createFilterVariantTable(\n      'v2 update records negated filter'\n    );\n\n    try {\n      const response = await fetch(`${appUrl}/api/v2/tables/updateRecords`, {\n        method: 'POST',\n        headers: {\n          cookie,\n          'content-type': 'application/json',\n        },\n        body: JSON.stringify({\n          tableId: table.id,\n          fields: {\n            [statusFieldId]: 'Queued',\n          },\n          filter: {\n            not: {\n              fieldId: statusFieldId,\n              operator: 'is',\n              value: 'Done',\n            },\n          },\n        }),\n      });\n\n      expect(response.status).toBe(200);\n\n      const rawBody = await response.json();\n      const parsed = updateRecordsOkResponseSchema.safeParse(rawBody);\n      expect(parsed.success).toBe(true);\n      if (!parsed.success) return;\n\n      expect(parsed.data.data.updatedCount).toBe(3);\n\n      const statusByTitle = await getStatusByTitle(table.id, titleFieldId, statusFieldId);\n\n      expect(statusByTitle.get('Alpha')).toBe('Queued');\n      expect(statusByTitle.get('Beta')).toBe('Queued');\n      expect(statusByTitle.get('Gamma')).toBe('Done');\n      expect(statusByTitle.get('Delta')).toBe('Queued');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('updates explicit recordIds through /api/v2/tables/updateRecords', async () => {\n    const table = await createTable(baseId, {\n      name: 'v2 update records by ids',\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText, isPrimary: true },\n        { name: 'Status', type: FieldType.SingleLineText },\n      ],\n    });\n\n    try {\n      const titleFieldId = table.fields.find((field) => field.name === 'Title')?.id ?? '';\n      const statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? '';\n\n      const created = await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: 'Alpha',\n              [statusFieldId]: 'Open',\n            },\n          },\n          {\n            fields: {\n              [titleFieldId]: 'Beta',\n              [statusFieldId]: 'Open',\n            },\n          },\n          {\n            fields: {\n              [titleFieldId]: 'Gamma',\n              [statusFieldId]: 'Open',\n            },\n          },\n        ],\n      });\n      const records = created.records;\n\n      const response = await fetch(`${appUrl}/api/v2/tables/updateRecords`, {\n        method: 'POST',\n        headers: {\n          cookie,\n          'content-type': 'application/json',\n        },\n        body: JSON.stringify({\n          tableId: table.id,\n          fields: {\n            [statusFieldId]: 'Done',\n          },\n          recordIds: [records[0]!.id, records[2]!.id],\n        }),\n      });\n\n      expect(response.status).toBe(200);\n\n      const rawBody = await response.json();\n      const parsed = updateRecordsOkResponseSchema.safeParse(rawBody);\n      expect(parsed.success).toBe(true);\n      if (!parsed.success) return;\n\n      expect(parsed.data.data.updatedCount).toBe(2);\n\n      const refreshed = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        skip: 0,\n        take: 100,\n      });\n      const statusByTitle = new Map(\n        refreshed.records.map((record) => [\n          record.fields[titleFieldId],\n          record.fields[statusFieldId],\n        ])\n      );\n\n      expect(statusByTitle.get('Alpha')).toBe('Done');\n      expect(statusByTitle.get('Beta')).toBe('Open');\n      expect(statusByTitle.get('Gamma')).toBe('Done');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n\n  it('rejects empty filters through /api/v2/tables/updateRecords', async () => {\n    const table = await createTable(baseId, {\n      name: 'v2 update records empty filter',\n      fields: [\n        { name: 'Title', type: FieldType.SingleLineText, isPrimary: true },\n        { name: 'Status', type: FieldType.SingleLineText },\n      ],\n    });\n\n    try {\n      const titleFieldId = table.fields.find((field) => field.name === 'Title')?.id ?? '';\n      const statusFieldId = table.fields.find((field) => field.name === 'Status')?.id ?? '';\n\n      await createRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [titleFieldId]: 'Alpha',\n              [statusFieldId]: 'Open',\n            },\n          },\n          {\n            fields: {\n              [titleFieldId]: 'Beta',\n              [statusFieldId]: 'Open',\n            },\n          },\n        ],\n      });\n\n      const response = await fetch(`${appUrl}/api/v2/tables/updateRecords`, {\n        method: 'POST',\n        headers: {\n          cookie,\n          'content-type': 'application/json',\n        },\n        body: JSON.stringify({\n          tableId: table.id,\n          fields: {\n            [statusFieldId]: 'Done',\n          },\n          filter: {\n            conjunction: 'and',\n            items: [],\n          },\n        }),\n      });\n\n      expect(response.status).toBe(400);\n      await expect(response.json()).resolves.toMatchObject({\n        ok: false,\n        error: expect.stringContaining('filter.items'),\n      });\n\n      const records = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        skip: 0,\n        take: 100,\n      });\n      const statusByTitle = new Map(\n        records.records.map((record) => [record.fields[titleFieldId], record.fields[statusFieldId]])\n      );\n\n      expect(statusByTitle.get('Alpha')).toBe('Open');\n      expect(statusByTitle.get('Beta')).toBe('Open');\n    } finally {\n      await permanentDeleteTable(baseId, table.id);\n    }\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/view-option.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport type { IViewOptions, IGridView, IFormView } from '@teable/core';\nimport { RowHeightLevel, ViewType } from '@teable/core';\nimport { updateViewOptions as apiSetViewOption } from '@teable/openapi';\nimport {\n  initApp,\n  getView,\n  getFields,\n  createTable,\n  permanentDeleteTable,\n  updateViewColumnMeta,\n  deleteField,\n} from './utils/init-app';\n\nlet app: INestApplication;\nconst baseId = globalThis.testConfig.baseId;\n\nbeforeAll(async () => {\n  const appCtx = await initApp();\n  app = appCtx.app;\n});\n\nafterAll(async () => {\n  await app.close();\n});\n\nasync function updateViewOptions(tableId: string, viewId: string, viewOptionRo: IViewOptions) {\n  const result = await apiSetViewOption(tableId, viewId, { options: viewOptionRo });\n  return result.data;\n}\n\ndescribe('OpenAPI ViewController (e2e) option (PUT) update grid view option', () => {\n  let tableId: string;\n  let viewId: string;\n  let viewIds: string[];\n  beforeAll(async () => {\n    const result = await createTable(baseId, {\n      name: 'Table',\n      views: [{ type: ViewType.Grid }, { type: ViewType.Form }],\n    });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n    viewIds = result.views.map((view) => view.id);\n  });\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update option rowHeight`, async () => {\n    await updateViewOptions(tableId, viewId, { rowHeight: RowHeightLevel.Short });\n    const updatedView = await getView(tableId, viewId);\n    const rowHeight = (updatedView.options as IGridView['options']).rowHeight;\n    expect(rowHeight).toBe(RowHeightLevel.Short);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update other type options should return 400`, async () => {\n    const [, formViewId] = viewIds;\n    await expect(\n      updateViewOptions(tableId, formViewId, { rowHeight: RowHeightLevel.Short })\n    ).rejects.toMatchObject({\n      status: 400,\n    });\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update option frozenFieldId`, async () => {\n    const fields = await getFields(tableId);\n    const anchorFieldId = fields[1]?.id ?? fields[0].id;\n    await updateViewOptions(tableId, viewId, { frozenFieldId: anchorFieldId });\n    const updatedView = await getView(tableId, viewId);\n    const frozenFieldId = (updatedView.options as IGridView['options']).frozenFieldId;\n    expect(frozenFieldId).toBe(anchorFieldId);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/columnMeta (PUT) changing frozen field order should shift frozenFieldId to previous`, async () => {\n    const initialView = await getView(tableId, viewId);\n    const originOrders = Object.entries(initialView.columnMeta)\n      .sort((a, b) => a[1].order - b[1].order)\n      .map(([fieldId]) => fieldId);\n    const targetFrozen = originOrders[1] ?? originOrders[0];\n    const prevNeighbor = originOrders[0];\n\n    await updateViewOptions(tableId, viewId, { frozenFieldId: targetFrozen });\n\n    await updateViewColumnMeta(tableId, viewId, [\n      { fieldId: targetFrozen, columnMeta: { order: 9999 } },\n    ]);\n\n    const updatedView = await getView(tableId, viewId);\n    const frozenFieldId = (updatedView.options as IGridView['options']).frozenFieldId;\n    expect(frozenFieldId).toBe(prevNeighbor);\n  });\n\n  it(`/table/{tableId}/field/{fieldId} (DELETE) deleting frozen field should update or clear frozenFieldId`, async () => {\n    const initialView = await getView(tableId, viewId);\n    const originOrders = Object.entries(initialView.columnMeta)\n      .sort((a, b) => a[1].order - b[1].order)\n      .map(([fieldId]) => fieldId);\n\n    const middleFrozen = originOrders[1];\n    const expectedAfterDelete = originOrders[0];\n    await updateViewOptions(tableId, viewId, { frozenFieldId: middleFrozen });\n    await deleteField(tableId, middleFrozen);\n    const viewAfterDelete = await getView(tableId, viewId);\n    const frozenAfter = (viewAfterDelete.options as IGridView['options']).frozenFieldId;\n    expect(frozenAfter).toBe(expectedAfterDelete);\n  });\n});\n\ndescribe('OpenAPI ViewController (e2e) option (PUT) update form view option', () => {\n  let tableId: string;\n  let viewId: string;\n  beforeAll(async () => {\n    const result = await createTable(baseId, { name: 'Table', views: [{ type: ViewType.Form }] });\n    tableId = result.id;\n    viewId = result.defaultViewId!;\n  });\n  afterAll(async () => {\n    await permanentDeleteTable(baseId, tableId);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update option coverUrl`, async () => {\n    const assertUrl = '/form/test';\n    await updateViewOptions(tableId, viewId, { coverUrl: assertUrl });\n    const updatedView = await getView(tableId, viewId);\n    const coverUrl = (updatedView.options as IFormView['options']).coverUrl;\n    expect(coverUrl?.endsWith(assertUrl)).toBe(true);\n    expect(coverUrl?.startsWith('http://')).toBe(true);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update option logoUrl`, async () => {\n    const assertUrl = '/form/test';\n    await updateViewOptions(tableId, viewId, { logoUrl: assertUrl });\n    const updatedView = await getView(tableId, viewId);\n    const logoUrl = (updatedView.options as IFormView['options']).logoUrl;\n    expect(logoUrl?.endsWith(assertUrl)).toBe(true);\n    expect(logoUrl?.startsWith('http://')).toBe(true);\n  });\n\n  it(`/table/{tableId}/view/{viewId}/option (PUT) update option submitLabel`, async () => {\n    const assertLabel = 'Confirm';\n    await updateViewOptions(tableId, viewId, { submitLabel: assertLabel });\n    const updatedView = await getView(tableId, viewId);\n    const submitLabel = (updatedView.options as IFormView['options']).submitLabel;\n    expect(submitLabel).toBe(assertLabel);\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/view.e2e-spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { INestApplication } from '@nestjs/common';\n\nimport type {\n  IColumn,\n  IFieldRo,\n  IFieldVo,\n  IFormColumn,\n  IFormColumnMeta,\n  IPluginViewOptions,\n  IViewRo,\n} from '@teable/core';\nimport {\n  ColorConfigType,\n  Colors,\n  FieldKeyType,\n  FieldType,\n  generateViewId,\n  Relationship,\n  RowHeightLevel,\n  SortFunc,\n  ViewType,\n} from '@teable/core';\nimport { PrismaService, type Prisma } from '@teable/db-main-prisma';\nimport type { ICreateTableRo, ITableFullVo } from '@teable/openapi';\nimport {\n  updateViewDescription,\n  updateViewName,\n  getViewFilterLinkRecords,\n  updateViewShareMeta,\n  enableShareView,\n  updateViewColumnMeta,\n  updateRecord,\n  getRecords,\n  updateViewLocked,\n  duplicateView,\n  installViewPlugin,\n  deleteView,\n} from '@teable/openapi';\nimport { sample } from 'lodash';\nimport { ViewService } from '../src/features/view/view.service';\nimport { x_20 } from './data-helpers/20x';\nimport { VIEW_DEFAULT_SHARE_META } from './data-helpers/caces/view-default-share-meta';\nimport {\n  createField,\n  getFields,\n  initApp,\n  createView,\n  permanentDeleteTable,\n  createTable,\n  getViews,\n  getView,\n  getTable,\n} from './utils/init-app';\n\nconst defaultViews = [\n  {\n    name: 'Grid view',\n    type: ViewType.Grid,\n  },\n];\n\ndescribe('OpenAPI ViewController (e2e)', () => {\n  let app: INestApplication;\n  let table: ITableFullVo;\n  const baseId = globalThis.testConfig.baseId;\n  let prismaService: PrismaService;\n  let viewService: ViewService;\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prismaService = app.get(PrismaService);\n    viewService = app.get(ViewService);\n  });\n\n  afterAll(async () => {\n    await app.close();\n  });\n\n  beforeEach(async () => {\n    table = await createTable(baseId, { name: 'table1' });\n  });\n\n  afterEach(async () => {\n    const result = await permanentDeleteTable(baseId, table.id);\n    console.log('clear table: ', result);\n  });\n\n  it('/api/table/{tableId}/view (GET)', async () => {\n    const viewsResult = await getViews(table.id);\n    expect(viewsResult).toMatchObject(defaultViews);\n  });\n\n  it('/api/table/{tableId}/view (POST)', async () => {\n    const viewRo: IViewRo = {\n      name: 'New view',\n      description: 'the new view',\n      type: ViewType.Grid,\n    };\n\n    await createView(table.id, viewRo);\n\n    const result = await getViews(table.id);\n    expect(result).toMatchObject([\n      ...defaultViews,\n      {\n        name: 'New view',\n        description: 'the new view',\n        type: ViewType.Grid,\n      },\n    ]);\n  });\n\n  it('/api/table/{tableId}/view (POST) with gallery view', async () => {\n    const viewRo: IViewRo = {\n      name: 'New gallery view',\n      description: 'the new gallery view',\n      type: ViewType.Gallery,\n    };\n\n    const fieldVo = await createField(table.id, {\n      name: 'Attachment',\n      type: FieldType.Attachment,\n    });\n    await createView(table.id, viewRo);\n\n    const result = await getViews(table.id);\n    expect(result).toMatchObject([\n      ...defaultViews,\n      {\n        name: 'New gallery view',\n        description: 'the new gallery view',\n        type: ViewType.Gallery,\n        options: {\n          coverFieldId: fieldVo.id,\n        },\n      },\n    ]);\n  });\n\n  it('should update view simple properties', async () => {\n    const viewRo: IViewRo = {\n      name: 'New view',\n      description: 'the new view',\n      type: ViewType.Grid,\n    };\n\n    const view = await createView(table.id, viewRo);\n\n    await updateViewName(table.id, view.id, { name: 'New view 2' });\n    await updateViewDescription(table.id, view.id, { description: 'description2' });\n    await updateViewLocked(table.id, view.id, { isLocked: true });\n    const viewNew = await getView(table.id, view.id);\n\n    expect(viewNew.name).toEqual('New view 2');\n    expect(viewNew.description).toEqual('description2');\n    expect(viewNew.isLocked).toBeTruthy();\n  });\n\n  it('should create view with field order', async () => {\n    // get fields\n    const fields = await getFields(table.id);\n    const testFieldId = fields?.[0].id;\n    const assertOrder = 10;\n    const columnMeta = fields.reduce<Record<string, IColumn>>(\n      (pre, cur, index) => {\n        pre[cur.id] = {} as IColumn;\n        pre[cur.id].order = index === 0 ? assertOrder : index;\n        return pre;\n      },\n      {} as Record<string, IColumn>\n    );\n\n    const viewResponse = await createView(table.id, {\n      name: 'view',\n      columnMeta,\n      type: ViewType.Grid,\n    });\n\n    const { columnMeta: columnMetaResponse } = viewResponse;\n    const order = columnMetaResponse?.[testFieldId]?.order;\n    expect(order).toEqual(assertOrder);\n    expect(fields.length).toEqual(Object.keys(columnMetaResponse).length);\n  });\n\n  it('should set all eligible fields visible when creating form view', async () => {\n    const formView = await createView(table.id, {\n      name: 'Form view',\n      type: ViewType.Form,\n    });\n\n    const views = await getViews(table.id);\n    const createdForm = views.find(({ id }) => id === formView.id)!;\n    const formColumnMeta = createdForm.columnMeta as unknown as Record<string, IFormColumn>;\n\n    const eligibleFieldIds = table.fields\n      .filter((f) => !f.isComputed && !f.isLookup && f.type !== FieldType.Button)\n      .map((f) => f.id);\n\n    eligibleFieldIds.forEach((fieldId) => {\n      expect(formColumnMeta[fieldId]?.visible ?? false).toBe(true);\n    });\n  });\n\n  it('should batch update view when create field', async () => {\n    const initialColumnMeta = await viewService.generateViewOrderColumnMeta(table.id);\n    const createData: Prisma.ViewCreateManyInput[] = [];\n    const num = 100;\n    for (let i = 0; i < num; i++) {\n      const data: Prisma.ViewCreateManyInput = {\n        id: generateViewId(),\n        tableId: table.id,\n        name: `New view ${i}`,\n        type: ViewType.Grid,\n        version: 1,\n        order: i + 1,\n        createdBy: globalThis.testConfig.userId,\n        columnMeta: JSON.stringify(initialColumnMeta ?? {}),\n      };\n\n      createData.push(data);\n    }\n    const result = await prismaService.txClient().view.createMany({ data: createData });\n    expect(result.count).toEqual(num);\n\n    await createField(table.id, { type: FieldType.SingleLineText });\n    const fields = await getFields(table.id);\n    const assertFieldIds = fields.map((field) => field.id).sort();\n    const randomViewId = sample(createData.map((data) => data.id));\n    const view = await getView(table.id, randomViewId!);\n    const columnMetaFieldIds = Object.keys(view.columnMeta).sort();\n    expect(columnMetaFieldIds).toEqual(assertFieldIds);\n  });\n\n  it('fields in new view should sort by created time and primary field is always first', async () => {\n    const viewRo: IViewRo = {\n      name: 'New view',\n      description: 'the new view',\n      type: ViewType.Grid,\n    };\n\n    const oldFields: IFieldVo[] = [];\n    oldFields.push(await createField(table.id, { type: FieldType.SingleLineText }));\n    oldFields.push(await createField(table.id, { type: FieldType.SingleLineText }));\n    oldFields.push(await createField(table.id, { type: FieldType.SingleLineText }));\n\n    const newView = await createView(table.id, viewRo);\n    const newFields = await getFields(table.id, newView.id);\n\n    expect(newFields.slice(3)).toMatchObject(oldFields);\n  });\n\n  describe('/api/table/{tableId}/view/:viewId/filter-link-records (GET)', () => {\n    let table: ITableFullVo;\n    let linkTable1: ITableFullVo;\n    let linkTable2: ITableFullVo;\n\n    const linkTable1FieldRo: IFieldRo[] = [\n      {\n        name: 'single_line_text_field',\n        type: FieldType.SingleLineText,\n      },\n    ];\n\n    const linkTable2FieldRo: IFieldRo[] = [\n      {\n        name: 'single_line_text_field',\n        type: FieldType.SingleLineText,\n      },\n    ];\n\n    const linkTable1RecordRo: ICreateTableRo['records'] = [\n      {\n        fields: {\n          single_line_text_field: 'link_table1_record1',\n        },\n      },\n      {\n        fields: {\n          single_line_text_field: 'link_table1_record2',\n        },\n      },\n      {\n        fields: {\n          single_line_text_field: 'link_table1_record3',\n        },\n      },\n    ];\n    const linkTable2RecordRo: ICreateTableRo['records'] = [\n      {\n        fields: {\n          single_line_text_field: 'link_table2_record1',\n        },\n      },\n      {\n        fields: {\n          single_line_text_field: 'link_table2_record2',\n        },\n      },\n      {\n        fields: {\n          single_line_text_field: 'link_table2_record3',\n        },\n      },\n    ];\n\n    beforeAll(async () => {\n      const fullTable = await createTable(baseId, {\n        name: 'filter_link_records',\n        fields: [\n          {\n            name: 'link_field1',\n            type: FieldType.SingleLineText,\n          },\n        ],\n        records: [],\n      });\n\n      linkTable1 = await createTable(baseId, {\n        name: 'link_table1',\n        fields: [\n          ...linkTable1FieldRo,\n          {\n            type: FieldType.Link,\n            options: {\n              foreignTableId: fullTable.id,\n              relationship: Relationship.OneMany,\n            },\n          },\n        ],\n        records: linkTable1RecordRo,\n      });\n\n      linkTable2 = await createTable(baseId, {\n        name: 'link_table2',\n        fields: [\n          ...linkTable2FieldRo,\n          {\n            type: FieldType.Link,\n            options: {\n              foreignTableId: fullTable.id,\n              relationship: Relationship.OneMany,\n            },\n          },\n        ],\n        records: linkTable2RecordRo,\n      });\n\n      table = (await getTable(baseId, fullTable.id, { includeContent: true })) as ITableFullVo;\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, table.id);\n      await permanentDeleteTable(baseId, linkTable1.id);\n      await permanentDeleteTable(baseId, linkTable2.id);\n    });\n\n    it('should return filter link records', async () => {\n      const viewRo: IViewRo = {\n        name: 'New view',\n        description: 'the new view',\n        type: ViewType.Grid,\n        filter: {\n          filterSet: [\n            {\n              fieldId: table.fields![1].id,\n              value: linkTable1.records[0].id,\n              operator: 'is',\n            },\n            {\n              filterSet: [\n                {\n                  fieldId: table.fields![1].id,\n                  value: [linkTable1.records[1].id, linkTable1.records[2].id],\n                  operator: 'isAnyOf',\n                },\n              ],\n              conjunction: 'and',\n            },\n            {\n              fieldId: table.fields![2].id,\n              value: linkTable2.records[0].id,\n              operator: 'is',\n            },\n            {\n              filterSet: [\n                {\n                  fieldId: table.fields![2].id,\n                  value: [linkTable2.records[2].id],\n                  operator: 'isAnyOf',\n                },\n              ],\n              conjunction: 'and',\n            },\n          ],\n          conjunction: 'and',\n        },\n      };\n\n      const view = await createView(table.id, viewRo);\n\n      const { data: records } = await getViewFilterLinkRecords(table.id, view.id);\n\n      expect(records).toMatchObject([\n        {\n          tableId: linkTable1.id,\n          records: linkTable1.records.map(({ id, name }) => ({ id, title: name })),\n        },\n        {\n          tableId: linkTable2.id,\n          records: [\n            { id: linkTable2.records[0].id, title: linkTable2.records[0].name },\n            {\n              id: linkTable2.records[2].id,\n              title: linkTable2.records[2].name,\n            },\n          ],\n        },\n      ]);\n    });\n  });\n\n  describe('/api/table/{tableId}/view/:viewId/column-meta (PUT)', () => {\n    let tableId: string;\n    let gridViewId: string;\n    let formViewId: string;\n    beforeAll(async () => {\n      const table = await createTable(baseId, { name: 'table' });\n      tableId = table.id;\n      const gridView = await createView(table.id, {\n        name: 'Grid view',\n        type: ViewType.Grid,\n      });\n      gridViewId = gridView.id;\n      const formView = await createView(table.id, {\n        name: 'Form view',\n        type: ViewType.Form,\n      });\n      formViewId = formView.id;\n      await enableShareView({ tableId, viewId: formViewId });\n      await enableShareView({ tableId, viewId: gridViewId });\n    });\n\n    afterAll(async () => {\n      await permanentDeleteTable(baseId, tableId);\n    });\n\n    it('update allowCopy success', async () => {\n      await updateViewShareMeta(tableId, gridViewId, { allowCopy: true });\n      const view = await getView(tableId, gridViewId);\n      expect(view.shareMeta?.allowCopy).toBe(true);\n    });\n\n    it.each(VIEW_DEFAULT_SHARE_META)(\n      'viewType($viewType) with enabled share with default shareMeta',\n      async (viewShareDefault) => {\n        const view = await createView(tableId, {\n          name: `${viewShareDefault.viewType} view`,\n          type: viewShareDefault.viewType,\n        });\n        await enableShareView({ tableId, viewId: view.id });\n        const { shareMeta } = await getView(tableId, view.id);\n        expect(shareMeta).toEqual(viewShareDefault.defaultShareMeta);\n      }\n    );\n  });\n\n  describe('filter by view ', () => {\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: 'table1' });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should get records with a field filtered view', async () => {\n      const res = await createView(table.id, {\n        name: 'view1',\n        type: ViewType.Grid,\n      });\n\n      await updateViewColumnMeta(table.id, res.id, [\n        {\n          fieldId: table.fields[1].id,\n          columnMeta: {\n            hidden: true,\n          },\n        },\n      ]);\n\n      await updateRecord(table.id, table.records[0].id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [table.fields[0].id]: 'text',\n            [table.fields[1].id]: 1,\n          },\n        },\n      });\n\n      const recordResult = await getRecords(table.id, {\n        fieldKeyType: FieldKeyType.Id,\n        viewId: res.id,\n      });\n      const fieldResult = await getFields(table.id, res.id);\n\n      expect(recordResult.data.records[0].fields[table.fields[0].id]).toEqual('text');\n      expect(recordResult.data.records[0].fields[table.fields[1].id]).toBeUndefined();\n\n      expect(fieldResult.length).toEqual(table.fields.length - 1);\n      expect(fieldResult.find((field) => field.id === table.fields[1].id)).toBeUndefined();\n    });\n  });\n\n  describe('/api/table/{tableId}/view/:viewId/duplicate (POST)', () => {\n    let table: ITableFullVo;\n    beforeEach(async () => {\n      table = await createTable(baseId, {\n        name: 'record_query_x_20',\n        fields: x_20.fields,\n        records: x_20.records,\n      });\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should duplicate grid view', async () => {\n      const view = await createView(table.id, {\n        name: 'grid_view',\n        type: ViewType.Grid,\n        filter: {\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'text',\n              operator: 'is',\n            },\n          ],\n          conjunction: 'and',\n        },\n        isLocked: true,\n        sort: {\n          sortObjs: [\n            {\n              fieldId: table.fields[0].id,\n              order: SortFunc.Asc,\n            },\n          ],\n        },\n        group: [\n          {\n            fieldId: table.fields[0].id,\n            order: SortFunc.Asc,\n          },\n        ],\n        options: {\n          rowHeight: RowHeightLevel.Medium,\n        },\n        columnMeta: {\n          [table.fields[0].id]: {\n            hidden: true,\n            order: 1,\n          },\n        },\n      });\n\n      const duplicatedView = (await duplicateView(table.id, view.id)).data;\n      expect(duplicatedView.name).toEqual('grid_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Grid);\n      expect(duplicatedView.filter).toEqual(view.filter);\n      expect(duplicatedView.sort).toEqual(view.sort);\n      expect(duplicatedView.group).toEqual(view.group);\n      expect(duplicatedView.options).toEqual(view.options);\n      expect(duplicatedView.columnMeta).toEqual(view.columnMeta);\n      expect(duplicatedView.isLocked).toBeTruthy();\n    });\n\n    it('should duplicate form view', async () => {\n      const initialColumnMeta = table.fields.reduce<Record<string, IFormColumnMeta>>(\n        (pre, cur, index) => {\n          pre[cur.id] = {\n            order: index,\n          } as unknown as IFormColumnMeta;\n          if (index === 0) {\n            (pre[cur.id] as unknown as IFormColumn).required = true;\n          }\n          if (!cur.isComputed && cur.type !== FieldType.Button) {\n            (pre[cur.id] as unknown as IFormColumn).visible = true;\n          }\n          return pre;\n        },\n        {} as Record<string, IFormColumnMeta>\n      );\n      const formView = await createView(table.id, {\n        name: 'form_view',\n        type: ViewType.Form,\n        columnMeta: {\n          ...(initialColumnMeta as unknown as Record<string, IColumn>),\n        },\n      });\n\n      const duplicatedView = (await duplicateView(table.id, formView.id)).data;\n\n      expect(duplicatedView.name).toEqual('form_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Form);\n      expect(duplicatedView.options).toEqual(formView.options);\n      expect(duplicatedView.columnMeta).toEqual(initialColumnMeta);\n    });\n\n    it('should duplicate gallery view', async () => {\n      const attachmentField = await createField(table.id, {\n        name: 'Attachment',\n        type: FieldType.Attachment,\n      });\n      const galleryView = await createView(table.id, {\n        name: 'gallery_view',\n        type: ViewType.Gallery,\n        filter: {\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'text',\n              operator: 'is',\n            },\n          ],\n          conjunction: 'and',\n        },\n        sort: {\n          sortObjs: [\n            {\n              fieldId: table.fields[0].id,\n              order: SortFunc.Asc,\n            },\n          ],\n        },\n        options: {\n          coverFieldId: attachmentField.id,\n        },\n      });\n\n      const duplicatedView = (await duplicateView(table.id, galleryView.id)).data;\n      expect(duplicatedView.name).toEqual('gallery_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Gallery);\n      expect(duplicatedView.filter).toEqual(galleryView.filter);\n      expect(duplicatedView.sort).toEqual(galleryView.sort);\n      expect(duplicatedView.options).toEqual({\n        coverFieldId: attachmentField.id,\n      });\n    });\n\n    it('should duplicate kanban view', async () => {\n      const kanbanView = await createView(table.id, {\n        name: 'kanban_view',\n        type: ViewType.Kanban,\n        filter: {\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'text',\n              operator: 'is',\n            },\n          ],\n          conjunction: 'and',\n        },\n        sort: {\n          sortObjs: [\n            {\n              fieldId: table.fields[0].id,\n              order: SortFunc.Asc,\n            },\n          ],\n        },\n        options: {\n          stackFieldId: table.fields[0].id,\n        },\n      });\n\n      const duplicatedView = (await duplicateView(table.id, kanbanView.id)).data;\n      expect(duplicatedView.name).toEqual('kanban_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Kanban);\n      expect(duplicatedView.filter).toEqual(kanbanView.filter);\n      expect(duplicatedView.sort).toEqual(kanbanView.sort);\n      expect(duplicatedView.columnMeta).toEqual(kanbanView.columnMeta);\n      expect(duplicatedView.options).toEqual({\n        stackFieldId: table.fields[0].id,\n      });\n    });\n\n    it('should duplicate calendar view', async () => {\n      const startDateField = await createField(table.id, {\n        name: 'Start Date',\n        type: FieldType.Date,\n      });\n      const endDateField = await createField(table.id, {\n        name: 'End Date',\n        type: FieldType.Date,\n      });\n      const calendarView = await createView(table.id, {\n        name: 'calendar_view',\n        type: ViewType.Calendar,\n        filter: {\n          filterSet: [\n            {\n              fieldId: table.fields[0].id,\n              value: 'text',\n              operator: 'is',\n            },\n          ],\n          conjunction: 'and',\n        },\n        options: {\n          startDateFieldId: startDateField.id,\n          endDateFieldId: endDateField.id,\n          colorConfig: {\n            type: ColorConfigType.Custom,\n            color: Colors.PurpleLight2,\n          },\n          titleFieldId: table.fields[0].id,\n        },\n      });\n\n      const duplicatedView = (await duplicateView(table.id, calendarView.id)).data;\n      expect(duplicatedView.name).toEqual('calendar_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Calendar);\n      expect(duplicatedView.filter).toEqual(calendarView.filter);\n      expect(duplicatedView.sort).toEqual(calendarView.sort);\n      expect(duplicatedView.options).toEqual(calendarView.options);\n      expect(duplicatedView.columnMeta).toEqual(calendarView.columnMeta);\n      expect(duplicatedView.options).toEqual({\n        startDateFieldId: startDateField.id,\n        endDateFieldId: endDateField.id,\n        colorConfig: {\n          type: ColorConfigType.Custom,\n          color: Colors.PurpleLight2,\n        },\n        titleFieldId: table.fields[0].id,\n      });\n    });\n\n    it('should duplicate plugin view', async () => {\n      const sheetPlugin = (\n        await installViewPlugin(table.id, {\n          name: 'sheet_view',\n          pluginId: 'plgsheetform',\n        })\n      ).data;\n\n      const sheetView = await getView(table.id, sheetPlugin.viewId);\n\n      const duplicatedView = (await duplicateView(table.id, sheetView.id)).data;\n      expect(duplicatedView.name).toEqual('sheet_view 2');\n      expect(duplicatedView.type).toEqual(ViewType.Plugin);\n      expect(duplicatedView.options).contain({\n        pluginLogo: (sheetView.options as IPluginViewOptions).pluginLogo,\n      });\n    });\n  });\n\n  describe('concurrent view deletion with row-level locking', () => {\n    let table: ITableFullVo;\n    let view1Id: string;\n    let view2Id: string;\n\n    beforeEach(async () => {\n      table = await createTable(baseId, { name: 'concurrent_test_table' });\n      const view1 = await createView(table.id, {\n        name: 'View 1',\n        type: ViewType.Grid,\n      });\n      view1Id = view1.id;\n      const view2 = await createView(table.id, {\n        name: 'View 2',\n        type: ViewType.Grid,\n      });\n      view2Id = view2.id;\n    });\n\n    afterEach(async () => {\n      await permanentDeleteTable(baseId, table.id);\n    });\n\n    it('should prevent concurrent deletion of the last view using SELECT FOR UPDATE', async () => {\n      // Delete view1 first (should succeed since there are still 2 views left)\n      await deleteView(table.id, view1Id);\n\n      // Verify view1 was deleted\n      const views = await getViews(table.id);\n      expect(views.length).toBe(2); // default view + view2\n\n      // Try to delete the second custom view (should succeed, leaving only the default view)\n      await deleteView(table.id, view2Id);\n\n      const finalViews = await getViews(table.id);\n      expect(finalViews.length).toBe(1);\n      expect(finalViews[0].name).toBe('Grid view'); // Only default view remains\n\n      // Try to delete the last view (should fail)\n      await expect(deleteView(table.id, finalViews[0].id)).rejects.toThrow(\n        'Cannot delete the last view in a table'\n      );\n    });\n\n    it('should handle concurrent deletion attempts with proper locking', async () => {\n      // Create a scenario with exactly 2 views (default + view1)\n      // Delete view2 first to have only 2 views\n      await deleteView(table.id, view2Id);\n\n      const remainingViews = await getViews(table.id);\n      expect(remainingViews.length).toBe(2); // default view + view1\n\n      // Attempt to delete both views concurrently\n      // One should succeed, one should fail because it would be the last view\n      const deletePromises = remainingViews.map((view) =>\n        deleteView(table.id, view.id).catch((error) => error)\n      );\n\n      const results = await Promise.all(deletePromises);\n\n      // One should succeed (undefined or success), one should fail with error\n      const successCount = results.filter((r) => !r || r.message === undefined).length;\n      const failureCount = results.filter(\n        (r) => r && r.message && r.message.includes('Cannot delete the last view')\n      ).length;\n\n      expect(successCount).toBe(1);\n      expect(failureCount).toBe(1);\n\n      // Verify exactly one view remains\n      const finalViews = await getViews(table.id);\n      expect(finalViews.length).toBe(1);\n    });\n\n    it('should use SELECT FOR UPDATE to prevent race conditions', async () => {\n      // This test verifies that the locking mechanism works correctly\n      // by attempting rapid concurrent deletions\n      const view3 = await createView(table.id, {\n        name: 'View 3',\n        type: ViewType.Grid,\n      });\n\n      // Now we have 4 views: default, view1, view2, view3\n      const allViews = await getViews(table.id);\n      expect(allViews.length).toBe(4);\n\n      // Delete 3 views concurrently, leaving only 1\n      const viewsToDelete = [view1Id, view2Id, view3.id];\n      const deleteResults = await Promise.allSettled(\n        viewsToDelete.map((viewId) => deleteView(table.id, viewId))\n      );\n\n      // All 3 deletions should succeed\n      const successfulDeletions = deleteResults.filter((r) => r.status === 'fulfilled').length;\n      expect(successfulDeletions).toBe(3);\n\n      // Verify only the default view remains\n      const finalViews = await getViews(table.id);\n      expect(finalViews.length).toBe(1);\n      expect(finalViews[0].name).toBe('Grid view');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/test/waitlist.e2e-spec.ts",
    "content": "import type { INestApplication } from '@nestjs/common';\nimport { getRandomString } from '@teable/core';\nimport { PrismaService } from '@teable/db-main-prisma';\nimport {\n  inviteWaitlist,\n  getWaitlist,\n  joinWaitlist as joinWaitlistApi,\n  signup,\n} from '@teable/openapi';\nimport { vi } from 'vitest';\nimport { SettingService } from '../src/features/setting/setting.service';\nimport { initApp } from './utils/init-app';\n\ndescribe('Auth Controller (e2e) api/auth waitlist', () => {\n  let app: INestApplication;\n  let prismaService: PrismaService;\n  let settingService: SettingService;\n\n  beforeAll(async () => {\n    const appCtx = await initApp();\n    app = appCtx.app;\n    prismaService = app.get(PrismaService);\n    settingService = app.get(SettingService);\n    const originalGetSetting = await settingService.getSetting();\n    vi.spyOn(settingService, 'getSetting').mockImplementation(async () => {\n      return {\n        ...originalGetSetting,\n        enableWaitlist: true,\n      };\n    });\n  });\n\n  afterAll(async () => {\n    vi.restoreAllMocks();\n    await app.close();\n  });\n\n  const joinWaitlist = async (handler?: (email: string) => Promise<void>) => {\n    const demoEmail = getRandomString(10) + '@demo.com';\n    const res = await joinWaitlistApi({\n      email: demoEmail,\n    });\n    expect(res.data.email).toBe(demoEmail);\n    const item = await prismaService.waitlist.findFirst({\n      where: {\n        email: demoEmail,\n      },\n    });\n    expect(item?.email).toBe(demoEmail);\n    if (handler) {\n      await handler(demoEmail);\n    }\n\n    await prismaService.waitlist.delete({\n      where: {\n        email: demoEmail,\n      },\n    });\n  };\n\n  it('api/auth/join-waitlist', async () => {\n    await joinWaitlist();\n  });\n\n  it('api/auth/get-waitlist', async () => {\n    await joinWaitlist(async (email) => {\n      const res = await getWaitlist();\n      const list = res.data.map((item) => item.email);\n      expect(list).toContain(email);\n    });\n  });\n\n  it('api/auth/approve-waitlist', async () => {\n    await joinWaitlist(async (email) => {\n      const res = await inviteWaitlist({\n        list: [email],\n      });\n      // const mailSenderService = app.get(MailSenderService);\n      // expect(mailSenderService.sendMail).toHaveBeenCalled();\n      expect(res.data.length).toEqual(1);\n      expect(res.data[0].email).toEqual(email);\n      expect(res.data[0].code.length).toBeGreaterThan(0);\n      expect(res.data[0].times).toBeGreaterThan(0);\n    });\n  });\n\n  it('api/auth/join-waitlist - user already exist', async () => {\n    const email = globalThis.testConfig.email;\n    await expect(\n      joinWaitlistApi({\n        email,\n      })\n    ).rejects.toThrow();\n  });\n\n  it('api/auth/signup - invite code is not correct when waitlist is enabled', async () => {\n    const fackCode = getRandomString(10);\n    const demoEmail = getRandomString(10).toLowerCase() + '@local.com';\n    const password = '12345678a';\n\n    // no invite code\n    await expect(\n      signup({\n        email: demoEmail,\n        password,\n      })\n    ).rejects.toThrow();\n\n    await joinWaitlistApi({\n      email: demoEmail,\n    });\n\n    // invite code is not correct\n    await expect(\n      signup({\n        email: demoEmail,\n        password,\n        inviteCode: fackCode,\n      })\n    ).rejects.toThrow();\n\n    const res = await inviteWaitlist({\n      list: [demoEmail],\n    });\n    expect(res.data.length).toEqual(1);\n    expect(res.data[0].email).toEqual(demoEmail);\n    const code = res.data[0].code;\n\n    // invite code is correct\n    const signupRes = await signup({\n      email: demoEmail,\n      password,\n      inviteCode: code,\n    });\n\n    expect(signupRes.data.email).toBe(demoEmail);\n    await prismaService.user.delete({\n      where: { email: signupRes.data.email },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nestjs-backend/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\n    \"node_modules\",\n    \"test\",\n    \"dist\",\n    \"**/*spec.ts\",\n    \"vitest-e2e.setup.ts\",\n    \"vitest-e2e.config.ts\",\n    \"vitest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "apps/nestjs-backend/tsconfig.eslint.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"isolatedModules\": false,\n    \"noEmit\": false,\n    \"allowJs\": false\n  },\n  \"exclude\": [\"node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\"\n  ]\n}\n"
  },
  {
    "path": "apps/nestjs-backend/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"isolatedModules\": false,\n    \"target\": \"es2022\",\n    \"declaration\": true,\n    \"declarationDir\": \"./dist\",\n    \"noEmit\": false,\n    \"sourceMap\": true,\n    \"allowJs\": false,\n    \"outDir\": \"./dist\",\n    \"paths\": {\n      \"@teable/core\": [\"../../packages/core/src\"],\n      \"@teable/openapi\": [\"../../packages/openapi/src\"],\n      \"@teable/db-main-prisma\": [\"../../packages/db-main-prisma/src\"],\n      \"@teable/v2-*\": [\"../../packages/v2/*/src/index\"],\n      \"@teable/v2-contract-http-implementation/handlers\": [\n        \"../../packages/v2/contract-http-implementation/src/handlers/index.ts\"\n      ],\n      \"@teable/formula\": [\"../../packages/formula/src\"],\n      \"@teable/i18n-keys\": [\"../../packages/i18n-keys/src\"]\n    },\n    \"types\": [\"vitest/globals\", \"node\"]\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/\", \"dist\"]\n}\n"
  },
  {
    "path": "apps/nestjs-backend/vitest-bench.config.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport swc from 'unplugin-swc';\nimport tsconfigPaths from 'vite-tsconfig-paths';\nimport type { Plugin } from 'vitest/config';\nimport { configDefaults, defineConfig } from 'vitest/config';\n\nconst benchFiles = ['**/test/**/*.bench.{js,ts}'];\n\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  plugins: [\n    swc.vite({\n      jsc: {\n        target: 'es2022',\n      },\n    }) as unknown as Plugin,\n    tsconfigPaths(),\n  ],\n  cacheDir: '../../.cache/vitest/nestjs-backend/bench',\n  test: {\n    globals: true,\n    environment: 'node',\n    setupFiles: './vitest-e2e.setup.ts',\n    testTimeout: 60000, // Longer timeout for benchmarks\n    passWithNoTests: true,\n    pool: 'forks',\n    sequence: {\n      hooks: 'stack',\n    },\n    logHeapUsage: true,\n    reporters: ['verbose'],\n    include: benchFiles,\n    exclude: [...configDefaults.exclude, '**/.next/**'],\n  },\n});\n"
  },
  {
    "path": "apps/nestjs-backend/vitest-e2e.config.ts",
    "content": "import swc from 'unplugin-swc';\nimport tsconfigPaths from 'vite-tsconfig-paths';\nimport { configDefaults, defineConfig } from 'vitest/config';\n\n// Set timezone to UTC for deterministic datetime test results\n// This must be set before any datetime operations\nprocess.env.TZ = 'UTC';\n\nif (!process.env.CONDITIONAL_QUERY_MAX_LIMIT) {\n  process.env.CONDITIONAL_QUERY_MAX_LIMIT = '7';\n}\n\nif (!process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT) {\n  process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT = process.env.CONDITIONAL_QUERY_MAX_LIMIT;\n}\n\nconst timeout = process.env.CI ? 60000 : 10000;\nconst testFiles = ['**/test/**/*.{e2e-test,e2e-spec}.{js,ts}'];\n\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  plugins: [\n    swc.vite({\n      jsc: {\n        target: 'es2022',\n      },\n    }),\n    tsconfigPaths(),\n  ],\n  cacheDir: '../../.cache/vitest/nestjs-backend/e2e',\n  test: {\n    globals: true,\n    environment: 'node',\n    setupFiles: './vitest-e2e.setup.ts',\n    testTimeout: timeout,\n    hookTimeout: timeout,\n    passWithNoTests: true,\n    pool: 'threads',\n    fileParallelism: false,\n    coverage: {\n      provider: 'v8',\n      reportsDirectory: './coverage/e2e',\n      include: ['src/**/*.{js,ts}'],\n    },\n    sequence: {\n      hooks: 'stack',\n    },\n    logHeapUsage: true,\n    reporters: ['verbose'],\n    include: testFiles,\n    exclude: [...configDefaults.exclude, '**/.next/**'],\n  },\n});\n"
  },
  {
    "path": "apps/nestjs-backend/vitest-e2e.setup.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport type { INestApplication } from '@nestjs/common';\nimport { DriverClient, getRandomString, parseDsn } from '@teable/core';\nimport dotenv from 'dotenv-flow';\nimport { buildSync } from 'esbuild';\n\n// Handle ConditionalModule timeout errors that occur sporadically in CI\n// These errors are thrown from setTimeout callbacks and cannot be caught normally\n// See: @nestjs/config ConditionalModule.registerWhen\nconst originalUncaughtExceptionListeners = process.listeners('uncaughtException');\nprocess.removeAllListeners('uncaughtException');\nprocess.on('uncaughtException', (error: Error) => {\n  // Ignore ConditionalModule timeout errors - they are sporadic in CI and don't affect test results\n  if (\n    error.message?.includes('Nest was not able to resolve the config variables') &&\n    error.message?.includes('ConditionalModule')\n  ) {\n    console.warn('[vitest-e2e.setup] Ignoring ConditionalModule timeout error:', error.message);\n    return;\n  }\n  // Re-throw other uncaught exceptions\n  for (const listener of originalUncaughtExceptionListeners) {\n    listener.call(process, error, 'uncaughtException');\n  }\n  // If no original listeners, throw the error\n  if (originalUncaughtExceptionListeners.length === 0) {\n    throw error;\n  }\n});\n\ninterface ITestConfig {\n  driver: string;\n  email: string;\n  userName: string;\n  userId: string;\n  password: string;\n  spaceId: string;\n  baseId: string;\n}\n\ninterface IInitAppReturnType {\n  app: INestApplication<unknown>;\n  appUrl: string;\n  cookie: string;\n  sessionID: string;\n}\n\ndeclare global {\n  // eslint-disable-next-line no-var\n  var testConfig: ITestConfig;\n  // eslint-disable-next-line no-var\n  var initApp: undefined | (() => Promise<IInitAppReturnType>);\n}\n\n// Set global variables (if needed)\nglobalThis.testConfig = {\n  userName: 'test',\n  email: 'test@e2e.com',\n  password: '12345678',\n  userId: 'usrTestUserId',\n  spaceId: 'spcTestSpaceId',\n  baseId: 'bseTestBaseId',\n  driver: DriverClient.Sqlite,\n};\n\nfunction prepareSqliteEnv() {\n  if (!process.env.PRISMA_DATABASE_URL?.startsWith('file:')) {\n    return;\n  }\n  const prevFilePath = '../../db/main.db';\n  const prevDir = path.dirname(prevFilePath);\n  const baseName = path.basename(prevFilePath);\n\n  const newFileName = 'test-' + getRandomString(12) + '-' + baseName;\n  const newFilePath = path.join(prevDir, 'test', newFileName);\n\n  process.env.PRISMA_DATABASE_URL = 'file:' + newFilePath;\n  console.log('TEST PRISMA_DATABASE_URL:', process.env.PRISMA_DATABASE_URL);\n\n  const dbPath = '../../packages/db-main-prisma/db/';\n  const testDbPath = path.join(dbPath, 'test');\n  if (!fs.existsSync(testDbPath)) {\n    fs.mkdirSync(testDbPath, { recursive: true });\n  }\n  fs.copyFileSync(path.join(dbPath, baseName), path.join(testDbPath, newFileName));\n}\n\nfunction compileWorkerFile() {\n  const entryFile = path.join(__dirname, 'src/worker/**.ts');\n  const outFile = path.join(__dirname, 'dist/worker');\n\n  buildSync({\n    entryPoints: [entryFile],\n    outdir: outFile,\n    bundle: true,\n    platform: 'node',\n    target: 'node20',\n  });\n}\n\nasync function setup() {\n  dotenv.config({ path: '../nextjs-app' });\n\n  // Use sync mode for v2 computed updates in tests\n  process.env.V2_COMPUTED_UPDATE_MODE = 'sync';\n\n  if (!process.env.CONDITIONAL_QUERY_MAX_LIMIT) {\n    process.env.CONDITIONAL_QUERY_MAX_LIMIT = '7';\n  }\n  if (!process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT) {\n    process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT = process.env.CONDITIONAL_QUERY_MAX_LIMIT;\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n  const databaseUrl = process.env.PRISMA_DATABASE_URL!;\n\n  console.log('database-url: ', databaseUrl);\n  const { driver } = parseDsn(databaseUrl);\n  console.log('driver: ', driver);\n  globalThis.testConfig.driver = driver;\n\n  prepareSqliteEnv();\n\n  compileWorkerFile();\n}\n\nexport default setup();\n"
  },
  {
    "path": "apps/nestjs-backend/vitest.config.ts",
    "content": "import swc from 'unplugin-swc';\nimport tsconfigPaths from 'vite-tsconfig-paths';\nimport { configDefaults, defineConfig } from 'vitest/config';\n\nconst testFiles = ['**/src/**/*.{test,spec}.{js,ts}'];\n\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  plugins: [\n    swc.vite({\n      jsc: {\n        target: 'es2022',\n      },\n    }),\n    tsconfigPaths(),\n  ],\n  cacheDir: '../../.cache/vitest/nestjs-backend/unit',\n  test: {\n    globals: true,\n    environment: 'node',\n    passWithNoTests: true,\n    pool: 'forks',\n    coverage: {\n      provider: 'v8',\n      reportsDirectory: './coverage/unit',\n      include: ['src/**/*.{js,ts}'],\n    },\n    include: testFiles,\n    exclude: [\n      ...configDefaults.exclude,\n      '**/*.controller.spec.ts', // exclude controller test\n      '**/.next/**',\n    ],\n  },\n});\n"
  },
  {
    "path": "apps/nestjs-backend/webpack.config.js",
    "content": "const path = require('path');\nconst CopyPlugin = require('copy-webpack-plugin');\nconst glob = require('glob');\n\nmodule.exports = function (options) {\n  const workerFiles = glob.sync(path.join(__dirname, 'src/worker/**.ts'));\n  const workerEntries = workerFiles.reduce((acc, file) => {\n    const relativePath = path.relative(path.join(__dirname, 'src/worker'), file);\n    const entryName = `worker/${path.dirname(relativePath)}/${path.basename(relativePath, '.ts')}`;\n    acc[entryName] = file;\n    return acc;\n  }, {});\n\n  return {\n    ...options,\n    entry: {\n      index: options.entry,\n      ...workerEntries,\n    },\n    output: {\n      path: path.join(__dirname, 'dist'),\n      filename: '[name].js',\n    },\n    plugins: [\n      new CopyPlugin({\n        patterns: [{ from: 'src/features/mail-sender/templates', to: 'templates' }],\n      }),\n    ],\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/webpack.dev.js",
    "content": "const path = require('path');\nconst CopyPlugin = require('copy-webpack-plugin');\nconst ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');\nconst glob = require('glob');\nconst nodeExternals = require('webpack-node-externals');\n\nmodule.exports = function (options, webpack) {\n  const workerFiles = glob.sync(path.join(__dirname, 'src/worker/**.ts'));\n  const workerEntries = workerFiles.reduce((acc, file) => {\n    const relativePath = path.relative(path.join(__dirname, 'src/worker'), file);\n    const entryName = `worker/${path.dirname(relativePath)}/${path.basename(relativePath, '.ts')}`;\n    acc[entryName] = file;\n    return acc;\n  }, {});\n  return {\n    ...options,\n    entry: {\n      index: ['webpack/hot/poll?100', options.entry],\n      ...workerEntries,\n    },\n    output: {\n      path: path.join(__dirname, 'dist'),\n      filename: '[name].js',\n    },\n    mode: 'development',\n    devtool: 'source-map',\n    externals: [\n      nodeExternals({\n        allowlist: ['webpack/hot/poll?100', /^@teable/],\n      }),\n    ],\n    // ignore tests hot reload\n    watchOptions: {\n      ignored: ['**/test/**', '**/*.spec.ts', '**/node_modules/**', '**/i18n.generated.ts'],\n      poll: 1000,\n    },\n    module: {\n      rules: [\n        {\n          test: /\\.ts?$/,\n          loader: 'ts-loader',\n          options: {\n            transpileOnly: true,\n            happyPackMode: true,\n          },\n          exclude: [/node_modules/, /.e2e-spec.ts$/],\n        },\n      ],\n    },\n    cache: {\n      type: 'filesystem',\n      allowCollectingMemory: true,\n      buildDependencies: {\n        // This makes all dependencies of this file - build dependencies\n        config: [__filename],\n      },\n    },\n    plugins: [\n      // filter default ForkTsCheckerWebpackPlugin to rewrite the ts config file path\n      // nest default tsconfig path is tsconfig.build.json\n      ...options.plugins.filter((plugin) => !(plugin instanceof ForkTsCheckerWebpackPlugin)),\n      new webpack.HotModuleReplacementPlugin(),\n      new ForkTsCheckerWebpackPlugin({\n        typescript: {\n          configFile: 'tsconfig.json',\n          memoryLimit: 4096,\n        },\n      }),\n      new CopyPlugin({\n        patterns: [{ from: 'src/features/mail-sender/templates', to: 'templates' }],\n      }),\n    ],\n  };\n};\n"
  },
  {
    "path": "apps/nestjs-backend/webpack.swc.js",
    "content": "const path = require('path');\nconst CopyPlugin = require('copy-webpack-plugin');\nconst ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');\nconst glob = require('glob');\nconst nodeExternals = require('webpack-node-externals');\n\nmodule.exports = function (options, webpack) {\n  const workerFiles = glob.sync(path.join(__dirname, 'src/worker/**.ts'));\n  const workerEntries = workerFiles.reduce((acc, file) => {\n    const relativePath = path.relative(path.join(__dirname, 'src/worker'), file);\n    const entryName = `worker/${path.dirname(relativePath)}/${path.basename(relativePath, '.ts')}`;\n    acc[entryName] = file;\n    return acc;\n  }, {});\n\n  return {\n    ...options,\n    resolve: {\n      ...options.resolve,\n      conditionNames: (() => {\n        const base = options.resolve?.conditionNames ?? ['require', 'node', 'default'];\n        if (base.includes('import')) return base;\n        const next = [...base];\n        const defaultIndex = next.indexOf('default');\n        if (defaultIndex === -1) {\n          next.push('import');\n        } else {\n          next.splice(defaultIndex, 0, 'import');\n        }\n        return next;\n      })(),\n    },\n    entry: {\n      index: ['webpack/hot/poll?100', options.entry],\n      ...workerEntries,\n    },\n    output: {\n      path: path.join(__dirname, 'dist'),\n      filename: '[name].js',\n    },\n    mode: 'development',\n    devtool: 'eval-cheap-module-source-map',\n    externals: [\n      nodeExternals({\n        allowlist: ['webpack/hot/poll?100', /^@teable/, /^@orpc/],\n      }),\n    ],\n    // ignore tests hot reload\n    watchOptions: {\n      ignored: ['**/test/**', '**/*.spec.ts', '**/node_modules/**', '**/*.d.ts'],\n      poll: false,\n      aggregateTimeout: 200,\n    },\n    module: {\n      rules: [\n        {\n          test: /\\.ts?$/,\n          exclude: [/node_modules/, /.e2e-spec.ts$/],\n          use: {\n            loader: 'swc-loader',\n            options: {\n              jsc: {\n                parser: {\n                  syntax: 'typescript',\n                  tsx: false,\n                  decorators: true,\n                  dynamicImport: true,\n                },\n                transform: {\n                  legacyDecorator: true,\n                  decoratorMetadata: true,\n                },\n                target: 'es2020',\n                keepClassNames: true,\n                loose: false,\n              },\n              module: {\n                type: 'commonjs',\n                strict: false,\n                strictMode: true,\n                lazy: false,\n                noInterop: false,\n              },\n              sourceMaps: 'inline',\n            },\n          },\n        },\n      ],\n    },\n    cache: {\n      type: 'filesystem',\n      allowCollectingMemory: true,\n      maxMemoryGenerations: 1,\n      buildDependencies: {\n        config: [__filename],\n      },\n      cacheDirectory: path.resolve(__dirname, '.webpack-cache'),\n    },\n    plugins: [\n      // filter default ForkTsCheckerWebpackPlugin to disable type checking for faster builds\n      ...options.plugins.filter((plugin) => !(plugin instanceof ForkTsCheckerWebpackPlugin)),\n      new webpack.HotModuleReplacementPlugin(),\n      new CopyPlugin({\n        patterns: [{ from: 'src/features/mail-sender/templates', to: 'templates' }],\n      }),\n    ],\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/.escheckrc",
    "content": "{\n  \"ecmaVersion\": \"es2018\",\n  \"module\": false,\n  \"files\": \"./.next/static/chunks/**/*.js\",\n  \"not\": [\"./.next/static/chunks/**/89fde0c3*.js\"]\n}"
  },
  {
    "path": "apps/nextjs-app/.eslintrc.js",
    "content": "/**\n * Specific eslint rules for this app/package, extends the base rules\n * @see https://github.com/teableio/teable/blob/main/docs/about-linters.md\n */\n\n// Workaround for https://github.com/eslint/eslint/issues/3458 (re-export of @rushstack/eslint-patch)\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [\n    ...getDefaultIgnorePatterns(),\n    '.next',\n    '.out',\n    'main',\n    'tailwind.shadcnui.config.js',\n    'public/streamsaver',\n  ],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    '@teable/eslint-config-bases/sonar',\n    '@teable/eslint-config-bases/regexp',\n    '@teable/eslint-config-bases/jest',\n    '@teable/eslint-config-bases/react',\n    '@teable/eslint-config-bases/tailwind',\n    '@teable/eslint-config-bases/rtl',\n    // Add specific rules for nextjs\n    'plugin:@next/next/core-web-vitals',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    '@typescript-eslint/naming-convention': 'off',\n    // https://github.com/vercel/next.js/discussions/16832\n    '@next/next/no-img-element': 'off',\n    // For the sake of example\n    // https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md\n    'jsx-a11y/anchor-is-valid': 'off',\n    'jsx-a11y/label-has-associated-control': 'off',\n  },\n  overrides: [\n    {\n      files: ['src/pages/\\\\_*.{ts,tsx}'],\n      rules: {\n        'react/display-name': 'off',\n      },\n    },\n    {\n      files: ['**/*.{spec,test}.{ts,tsx}'],\n      rules: {\n        'react/display-name': 'off',\n        '@typescript-eslint/no-explicit-any': 'off',\n        'jsx-a11y/click-events-have-key-events': 'off',\n        'jsx-a11y/no-static-element-interactions': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "apps/nextjs-app/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# electron \n/dist/\n/main\n\n# dependencies\nnode_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n/e2e/.out\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env.local\n.env.*.local\n\n\n# Sentry\n.sentryclirc\n\n.vscode\n"
  },
  {
    "path": "apps/nextjs-app/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/nextjs-app.iml\" filepath=\"$PROJECT_DIR$/.idea/nextjs-app.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "apps/nextjs-app/.idea/nextjs-app.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "apps/nextjs-app/.size-limit.js",
    "content": "// Just a basic example for size limit with simple file preset\n// @link https://github.com/ai/size-limit\n\nlet manifest;\ntry {\n  manifest = require('./.next/build-manifest.json');\n} catch (e) {\n  throw new Error(\n    'Cannot find a NextJs build folder, did you forget to build ?'\n  );\n}\nconst pages = manifest.pages;\n\nconst limitCfg = {\n  defaultSize: '120kb',\n  pages: {\n    '/': '200kb',\n    '/404': '120kb',\n    '/_app': '200kb',\n    '/_error': '120kb',\n    '/_monitor/sentry/csr-page': '120kb',\n    '/_monitor/sentry/ssr-page': '120kb',\n    '/admin': '120kb',\n    '/auth/login': '200kb',\n    '/home': '120kb',\n  },\n};\nconst getPageLimits = () => {\n  let pageLimits = [];\n  for (const [uri, paths] of Object.entries(pages)) {\n    pageLimits.push({\n      name: `Page '${uri}'`,\n      limit: limitCfg.pages?.[uri] ?? limitCfg.defaultSize,\n      path: paths.map((p) => `.next/${p}`),\n    });\n  }\n  return pageLimits;\n};\n\nmodule.exports = [\n  ...getPageLimits(),\n  // {\n  //   name: 'CSS',\n  //   path: ['.next/static/css/**/*.css'],\n  //   limit: '10 kB',\n  // },\n];\n"
  },
  {
    "path": "apps/nextjs-app/README.md",
    "content": "# The web-app\n\nYou don't need start this app when developing locally, it's started by the `nestjs-backend`.\n\nall env is maintained in the .env\\* file, it is shared with the backend.\n"
  },
  {
    "path": "apps/nextjs-app/babel.config.backup.js",
    "content": "module.exports = function (api) {\n  // const isTest = api.env('test');\n  // const isDevelopment = api.env('development');\n  // const isServer = api.caller((caller) => caller?.isServer);\n  // const isCallerDevelopment = api.caller((caller) => caller?.isDev);\n\n  api.cache(true);\n\n  return {\n    presets: [['next/babel']],\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.shadcnui.config.js\",\n    \"css\": \"./src/styles/global.shadcn.css\",\n    \"baseColor\": \"gray\",\n    \"cssVariables\": true\n  },\n  \"aliases\": {\n    \"components\": \"components\",\n    \"utils\": \"@/lib/utils\"\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/config/tests/AppTestProviders.tsx",
    "content": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport type { IAppContext } from '@teable/sdk/context';\nimport { AppContext, FieldContext, TableContext, ViewContext } from '@teable/sdk/context';\nimport { defaultLocale } from '@teable/sdk/context/app/i18n';\nimport type { IFieldInstance, IViewInstance } from '@teable/sdk/model';\nimport type { FC, PropsWithChildren } from 'react';\nimport { useRef } from 'react';\nimport { I18nextTestStubProvider } from './I18nextTestStubProvider';\n\nexport const createAppContext = (context: Partial<IAppContext> = {}) => {\n  const defaultContext: IAppContext = {\n    locale: defaultLocale,\n  };\n  // eslint-disable-next-line react/display-name\n  return ({ children }: { children: React.ReactNode }) => (\n    <AppContext.Provider value={{ ...defaultContext, ...context }}>{children}</AppContext.Provider>\n  );\n};\n\nconst MockProvider = createAppContext();\n\nexport const AppTestProviders: FC<PropsWithChildren> = ({ children }) => {\n  const queryClientRef = useRef<QueryClient>();\n\n  if (!queryClientRef.current) {\n    queryClientRef.current = new QueryClient({\n      defaultOptions: {\n        queries: {\n          retry: false,\n          refetchOnWindowFocus: false,\n        },\n      },\n    });\n  }\n  const queryClient = queryClientRef.current;\n\n  return (\n    <I18nextTestStubProvider>\n      <MockProvider>\n        <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n      </MockProvider>\n    </I18nextTestStubProvider>\n  );\n};\n\nexport const TestAnchorProvider: FC<\n  PropsWithChildren & {\n    fields?: IFieldInstance[];\n    views?: IViewInstance[];\n  }\n> = ({ children, fields = [], views = [] }) => {\n  return (\n    <TableContext.Provider value={{ tables: [] }}>\n      <ViewContext.Provider value={{ views }}>\n        <FieldContext.Provider value={{ fields }}>{children}</FieldContext.Provider>\n      </ViewContext.Provider>\n    </TableContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/config/tests/I18nextTestStubProvider.tsx",
    "content": "import i18n from 'i18next';\nimport type { FC, ReactNode } from 'react';\nimport { initReactI18next, I18nextProvider } from 'react-i18next';\nimport type { I18nNamespace } from '@/lib/i18n';\n\n/**\n * Fully wrapped strategy for i18next, you can use stub/mocks as well\n * @link {https://react.i18next.com/misc/testing}\n */\ni18n.use(initReactI18next).init({\n  lng: 'en',\n  fallbackLng: 'en',\n  ns: ['common'],\n  defaultNS: 'common',\n  debug: false,\n  interpolation: {\n    escapeValue: false, // not needed for react!!\n  },\n  // Let empty so you can test on translation keys rather than translated strings\n  resources: {\n    en: { common: {} } as Record<I18nNamespace, Record<string, never>>,\n  },\n});\n\nexport const I18nextTestStubProvider: FC<{ children: ReactNode }> = ({ children }) => {\n  return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/config/tests/ReactSvgrMock.tsx",
    "content": "/**\n * This mock is useful if you're relying on https://react-svgr.com/.\n *\n * @link {https://react-svgr.com/docs/jest/|SVGR Jest doc}\n * @link {https://github.com/gregberge/svgr/issues/83#issuecomment-785996587|Config that actually works}\n */\n\nimport type { SVGProps } from 'react';\nimport React from 'react';\n\nconst SvgrMock = React.forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>((props, ref) => (\n  <svg ref={ref} {...props} />\n));\n\nSvgrMock.displayName = 'SvgrMock';\n\nexport const ReactComponent = SvgrMock;\nexport default SvgrMock;\n"
  },
  {
    "path": "apps/nextjs-app/config/tests/setupVitest.ts",
    "content": "import '@testing-library/jest-dom';\n"
  },
  {
    "path": "apps/nextjs-app/config/tests/test-utils.tsx",
    "content": "/**\n * Automatically add app-providers\n * @see https://testing-library.com/docs/react-testing-library/setup#configuring-jest-with-test-utils\n */\nimport { render } from '@testing-library/react';\nimport type { ReactElement } from 'react';\nimport { AppTestProviders, TestAnchorProvider } from './AppTestProviders';\n\n/** Recommended in vitest only for cleanup\nimport { cleanup, render } from '@testing-library/react';\nimport { afterEach } from 'vitest';\n\nafterEach(() => {\n  cleanup();\n}); */\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst customRender = (ui: ReactElement, options?: any) =>\n  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument\n  render(ui, {\n    wrapper: AppTestProviders,\n    ...options,\n  });\n\n// re-export everything\nexport * from '@testing-library/react';\nexport { default as userEvent } from '@testing-library/user-event';\n\n// override render method\nexport { customRender as render, TestAnchorProvider };\n"
  },
  {
    "path": "apps/nextjs-app/e2e/pages/index/index-chinese.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport commonJsonZh from '@teable/common-i18n/locales/zh/common.json';\n\ntest.use({\n  locale: 'zh',\n});\n\ntest.describe('Demo page', () => {\n  test('should have the title in english by default', async ({ page }) => {\n    await page.goto('/');\n    const title = await page.title();\n    expect(title).toBe(commonJsonZh.system.notFound.title);\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/e2e/pages/index/index.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport commonJsonEn from '@teable/common-i18n/locales/en/common.json';\n\ntest.describe('404 page', () => {\n  test('should have the title in english by default', async ({ page }) => {\n    await page.goto('/404');\n    const title = await page.title();\n    expect(title).toBe(commonJsonEn.system.notFound.title);\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/e2e/pages/system/404.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport commonJsonEn from '@teable/common-i18n/locales/en/common.json';\n\nconst pageSlug = 'this-page-does-not-exist';\n\ntest.describe('404 not found page', () => {\n  test('should have the title in english any way', async ({ page }) => {\n    await page.goto(`/${pageSlug}`);\n    const title = await page.title();\n    expect(title).toBe(commonJsonEn.system.notFound.title);\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/instrumentation.ts",
    "content": "// This file is required by Next.js 15+ for Sentry integration\n// https://docs.sentry.io/platforms/javascript/guides/nextjs/\n\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === 'nodejs') {\n    await import('./sentry.server.config');\n  }\n\n  // Edge Runtime config - create sentry.edge.config.ts if using Middleware or Edge API Routes\n  // if (process.env.NEXT_RUNTIME === 'edge') {\n  //   await import('./sentry.edge.config');\n  // }\n}\n\nexport const onRequestError = async (\n  err: Error,\n  request: {\n    path: string;\n    method: string;\n    headers: Record<string, string>;\n  },\n  context: {\n    routerKind: string;\n    routePath: string;\n    routeType: string;\n    renderSource?: string;\n    revalidateReason?: string;\n    serverComponentType?: string;\n  }\n) => {\n  const Sentry = await import('@sentry/nextjs');\n  Sentry.captureException(err, {\n    extra: {\n      request,\n      context,\n    },\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/lint-staged.config.js",
    "content": "// @ts-check\n\n/**\n * This files overrides the base lint-staged.config.js present in the root directory.\n * It allows to run eslint based the package specific requirements.\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/nextjs-monorepo-example/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier, getEslintFixCmd } = require('../../lint-staged.common.js');\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '**/*.{js,jsx,ts,tsx,mjs,cjs}': (filenames) => {\n    return getEslintFixCmd({\n      cwd: __dirname,\n      fix: true,\n      cache: true,\n      // when autofixing staged-files a good tip is to disable react-hooks/exhaustive-deps, cause\n      // a change here can potentially break things without proper visibility.\n      rules: ['react-hooks/exhaustive-deps: off'],\n      maxWarnings: 25,\n      files: filenames,\n    });\n  },\n  '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nmodule.exports = rules;\n"
  },
  {
    "path": "apps/nextjs-app/next-i18next.config.js",
    "content": "const defaultLocale = 'en';\nconst debugI18n = ['true', 1].includes(process?.env?.NEXTJS_DEBUG_I18N ?? 'false');\nconst path = require('path');\nconst localePublicFolder = undefined;\n\nconst localPaths = [\n  path.resolve('../../packages/common-i18n/src/locales'),\n  path.join(process.cwd(), 'packages/common-i18n/src/locales'),\n  path.join(__dirname, '../../../node_modules/@teable/common-i18n/src/locales'),\n  path.join(__dirname, '../../../../node_modules/@teable/common-i18n/src/locales'),\n  process.env.I18N_LOCALES_PATH,\n];\n\nfunction getLocalPath() {\n  if (typeof window === 'undefined') {\n    const fs = require('node:fs');\n    return localPaths.find((str) => {\n      return fs.existsSync(str);\n    });\n  }\n\n  return localePublicFolder;\n}\n\nconst localePath = getLocalPath();\n\n/**\n * @type {import('next-i18next').UserConfig}\n */\nmodule.exports = {\n  i18n: {\n    defaultLocale,\n    locales: ['en', 'it', 'zh', 'fr', 'ja', 'ru', 'de', 'uk', 'tr', 'es'],\n  },\n  saveMissing: false,\n  strictMode: true,\n  serializeConfig: false,\n  reloadOnPrerender: process?.env?.NODE_ENV === 'development',\n  react: {\n    useSuspense: false,\n  },\n  debug: debugI18n,\n  /*\n  interpolation: {\n    escapeValue: false,\n  },\n  */\n  localePath,\n};\n"
  },
  {
    "path": "apps/nextjs-app/next.config.js",
    "content": "// @ts-check\n\nconst { readFileSync } = require('fs');\nconst path = require('path');\nconst { createSecureHeaders } = require('next-secure-headers');\nconst pc = require('picocolors');\n\nconst workspaceRoot = path.resolve(__dirname, '..', '..');\n/**\n * Once supported replace by node / eslint / ts and out of experimental, replace by\n * `import packageJson from './package.json' assert { type: 'json' };`\n * @type {import('type-fest').PackageJson}\n */\nconst packageJson = JSON.parse(\n  readFileSync(path.join(__dirname, './package.json')).toString('utf-8')\n);\n\nconst trueEnv = ['true', '1', 'yes'];\n\nconst isProd = process.env.NODE_ENV === 'production';\nconst isCI = trueEnv.includes(process.env?.CI ?? 'false');\n\nconst NEXT_BUILD_ENV_OUTPUT = process.env?.NEXT_BUILD_ENV_OUTPUT ?? 'classic';\nconst NEXT_BUILD_ENV_TSCONFIG = process.env?.NEXT_BUILD_ENV_TSCONFIG ?? 'tsconfig.json';\n\nconst NEXT_BUILD_ENV_TYPECHECK = trueEnv.includes(process.env?.NEXT_BUILD_ENV_TYPECHECK ?? 'true');\nconst NEXT_BUILD_ENV_SOURCEMAPS = trueEnv.includes(\n  process.env?.NEXT_BUILD_ENV_SOURCEMAPS ?? String(isProd)\n);\n\nconst NEXT_BUILD_ENV_CSP = trueEnv.includes(process.env?.NEXT_BUILD_ENV_CSP ?? 'true');\n\nconst NEXT_BUILD_ENV_SENTRY_ENABLED = trueEnv.includes(\n  process.env?.NEXT_BUILD_ENV_SENTRY_ENABLED ?? 'false'\n);\n\nconst NEXT_BUILD_ENV_SENTRY_DEBUG = trueEnv.includes(\n  process.env?.NEXT_BUILD_ENV_SENTRY_DEBUG ?? 'false'\n);\nconst NEXT_BUILD_ENV_SENTRY_TRACING = trueEnv.includes(\n  process.env?.NEXT_BUILD_ENV_SENTRY_TRACING ?? 'false'\n);\n// Whether to upload sourcemaps to Sentry (default: false for security)\nconst NEXT_BUILD_ENV_SENTRY_SOURCEMAPS_UPLOAD = trueEnv.includes(\n  process.env?.NEXT_BUILD_ENV_SENTRY_SOURCEMAPS_UPLOAD ?? 'false'\n);\n\nconst NEXTJS_SOCKET_PORT = process.env.SOCKET_PORT || '3001';\n\nif (!NEXT_BUILD_ENV_SOURCEMAPS) {\n  console.log(\n    `- ${pc.green(\n      'info'\n    )} Sourcemaps generation have been disabled through NEXT_BUILD_ENV_SOURCEMAPS`\n  );\n}\n\n// Tell webpack to compile those packages\n// @link https://www.npmjs.com/package/next-transpile-modules\nconst tmModules = [\n  // for legacy browsers support (only in prod and none electron)\n  ...(isProd && !process.versions['electron'] ? [] : []),\n  // ESM only packages are not yet supported by NextJs if you're not\n  // using experimental esmExternals\n  // @link {https://nextjs.org/blog/next-11-1#es-modules-support|Blog 11.1.0}\n  // @link {https://github.com/vercel/next.js/discussions/27876|Discussion}\n  // @link https://github.com/vercel/next.js/issues/23725\n  // @link https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c\n  ...[\n    // ie: newer versions of https://github.com/sindresorhus packages\n  ],\n];\n\n// @link https://github.com/jagaapple/next-secure-headers\nconst secureHeaders = createSecureHeaders({\n  contentSecurityPolicy: {\n    directives: NEXT_BUILD_ENV_CSP\n      ? {\n          defaultSrc: \"'self'\",\n          styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n          scriptSrc: [\n            \"'self'\",\n            \"'unsafe-eval'\",\n            \"'unsafe-inline'\",\n            'https://www.clarity.ms',\n            'https://*.teable.io',\n            'https://*.teable.ai',\n            'https://*.teable.cn',\n          ],\n          frameSrc: [\"'self'\", 'blob:', '*'],\n          connectSrc: [\n            \"'self'\",\n            'https://*.sentry.io',\n            'https://*.teable.io',\n            'https://*.teable.ai',\n            'https://*.teable.cn',\n            'https://*.clarity.ms',\n          ],\n          mediaSrc: [\"'self'\", 'https:', 'http:', 'data:'],\n          imgSrc: [\"'self'\", 'https:', 'http:', 'data:'],\n          workerSrc: ['blob:'],\n        }\n      : {},\n  },\n  ...(NEXT_BUILD_ENV_CSP && isProd\n    ? {\n        forceHTTPSRedirect: [true, { maxAge: 60 * 60 * 24 * 4, includeSubDomains: true }],\n      }\n    : {}),\n  referrerPolicy: 'same-origin',\n});\n\n/**\n * @type {import('next').NextConfig}\n */\nconst nextConfig = {\n  assetPrefix:\n    isProd && process.env.NEXT_BUILD_ENV_ASSET_PREFIX\n      ? process.env.NEXT_BUILD_ENV_ASSET_PREFIX\n      : undefined,\n  crossOrigin: 'anonymous',\n  reactStrictMode: true,\n  productionBrowserSourceMaps: NEXT_BUILD_ENV_SOURCEMAPS === true,\n  // Transpile packages that use React to ensure single React instance\n  transpilePackages: [\n    'streamdown',\n    'd3-interpolate',\n    'd3-color',\n    // Fix Turbopack \"unexpected export *\" warnings for CommonJS modules\n    '@dnd-kit/core',\n    '@dnd-kit/sortable',\n    '@dnd-kit/utilities',\n  ],\n\n  httpAgentOptions: {\n    // @link https://nextjs.org/blog/next-11-1#builds--data-fetching\n    keepAlive: true,\n  },\n\n  onDemandEntries: {\n    // period (in ms) where the server will keep pages in the buffer\n    maxInactiveAge: (isCI ? 3600 : 25) * 1000,\n  },\n\n  // Note: sentry configuration moved to withSentryConfig wrapper\n  // See: https://docs.sentry.io/platforms/javascript/guides/nextjs/\n\n  // @link https://nextjs.org/docs/basic-features/image-optimization\n  images: {\n    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],\n    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],\n    minimumCacheTTL: 60,\n    formats: ['image/webp'],\n    loader: 'default',\n    dangerouslyAllowSVG: false,\n    disableStaticImages: false,\n    contentSecurityPolicy: \"default-src 'self'; script-src 'none'; sandbox;\",\n    unoptimized: false,\n  },\n\n  // Standalone build\n  // @link https://nextjs.org/docs/advanced-features/output-file-tracing#automatically-copying-traced-files-experimental\n  ...(NEXT_BUILD_ENV_OUTPUT === 'standalone'\n    ? { output: 'standalone', outputFileTracing: true }\n    : {}),\n\n  // Server-only packages that should not be bundled for the browser\n  // @link https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages\n  serverExternalPackages: ['next-i18next', 'i18next-fs-backend'],\n\n  experimental: {\n    // @link https://nextjs.org/docs/advanced-features/output-file-tracing#caveats\n    ...(NEXT_BUILD_ENV_OUTPUT === 'standalone' ? { outputFileTracingRoot: workspaceRoot } : {}),\n\n    // Prefer loading of ES Modules over CommonJS\n    // @link {https://nextjs.org/blog/next-11-1#es-modules-support|Blog 11.1.0}\n    // @link {https://github.com/vercel/next.js/discussions/27876|Discussion}\n    esmExternals: true,\n    // Experimental monorepo support\n    // @link {https://github.com/vercel/next.js/pull/22867|Original PR}\n    // @link {https://github.com/vercel/next.js/discussions/26420|Discussion}\n    externalDir: true,\n\n    // Increase middleware client max body size for large file uploads (e.g., .tea import files)\n    // @link https://nextjs.org/docs/app/api-reference/config/next-config-js/proxyClientMaxBodySize\n    proxyClientMaxBodySize: '1024mb',\n\n    // Optimize package imports for better bundle size and faster builds\n    // @link https://vercel.com/blog/how-we-optimized-package-imports-in-next-js\n    optimizePackageImports: ['lucide-react', 'date-fns', '@tanstack/react-virtual'],\n\n    // Experimental /app dir\n    // appDir: true,\n  },\n\n  // Turbopack configuration (Next.js 16 default bundler)\n  turbopack: {\n    root: workspaceRoot,\n    rules: {\n      '*.svg': {\n        loaders: ['@svgr/webpack'],\n        as: '*.js',\n      },\n    },\n    resolveAlias: {\n      // Required: next-i18next and i18next-fs-backend require 'fs' at top level\n      fs: './turbopack-empty-stub.js',\n    },\n  },\n\n  typescript: {\n    ignoreBuildErrors: !NEXT_BUILD_ENV_TYPECHECK,\n    tsconfigPath: NEXT_BUILD_ENV_TSCONFIG,\n  },\n\n  // Note: eslint configuration is no longer supported in next.config.js\n  // Use ESLint CLI directly: npx eslint .\n\n  // @link https://nextjs.org/docs/api-reference/next.config.js/rewrites\n  async rewrites() {\n    const socketProxy = {\n      source: '/socket/:path*',\n      destination: `http://localhost:${NEXTJS_SOCKET_PORT}/socket/:path*`,\n    };\n\n    return isProd ? [] : [socketProxy];\n  },\n\n  // @link https://nextjs.org/docs/api-reference/next.config.js/headers\n  async headers() {\n    return [\n      {\n        // StreamSaver service worker files - needs relaxed CORS for iframe/popup\n        source: '/streamsaver/:path*',\n        headers: [\n          { key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },\n          { key: 'Cross-Origin-Embedder-Policy', value: 'credentialless' },\n          { key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' },\n        ],\n      },\n      {\n        // All page routes, not the api ones\n        source: '/:path((?!api|streamsaver).*)*',\n        headers: [\n          ...secureHeaders,\n          { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },\n          { key: 'Cross-Origin-Embedder-Policy', value: 'same-origin' },\n        ],\n      },\n      {\n        source: '/images/(.*)',\n        headers: [\n          { key: 'Access-Control-Allow-Origin', value: '*' },\n          { key: 'Access-Control-Allow-Methods', value: 'GET' },\n          // Override the restrictive CORS policies for images\n          { key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' },\n          { key: 'Cross-Origin-Embedder-Policy', value: 'credentialless' },\n          { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' },\n        ],\n      },\n    ];\n  },\n\n  webpack: (config, { isServer }) => {\n    if (!isServer) {\n      // Fixes npm packages that depend on `fs` module\n      // @link https://github.com/vercel/next.js/issues/36514#issuecomment-1112074589\n      config.resolve.fallback = { ...config.resolve.fallback, fs: false };\n    }\n\n    // Grab the existing rule that handles SVG imports\n    const fileLoaderRule = config.module.rules.find(\n      (/** @type {{ test: { test: (arg0: string) => any; }; }} */ rule) => rule.test?.test?.('.svg')\n    );\n\n    config.module.rules.push(\n      // Reapply the existing rule, but only for svg imports ending in ?url\n      {\n        ...fileLoaderRule,\n        test: /\\.svg$/i,\n        resourceQuery: /url/, // *.svg?url\n      },\n      // Convert all other *.svg imports to React components\n      {\n        test: /\\.svg$/i,\n        issuer: fileLoaderRule.issuer,\n        resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url\n        use: ['@svgr/webpack'],\n      }\n    );\n\n    // Modify the file loader rule to ignore *.svg, since we have it handled now.\n    fileLoaderRule.exclude = /\\.svg$/i;\n\n    return config;\n  },\n  env: {\n    APP_NAME: packageJson.name ?? 'not-in-package.json',\n    APP_VERSION: packageJson.version ?? 'not-in-package.json',\n    BUILD_TIME: new Date().toISOString(),\n    // Note: Sentry debug/tracing variables are handled via webpack DefinePlugin\n    // and cannot be set via Next.js env config (reserved key format)\n  },\n};\n\nlet config = nextConfig;\n\nif (NEXT_BUILD_ENV_SENTRY_ENABLED === true) {\n  try {\n    // https://docs.sentry.io/platforms/javascript/guides/nextjs/\n    const { withSentryConfig } = require('@sentry/nextjs');\n    // @ts-ignore because sentry does not match nextjs current definitions\n    config = withSentryConfig(config, {\n      // Additional config options for the Sentry webpack plugin. Keep in mind that\n      // the following options are set automatically, and overriding them is not\n      // recommended:\n      //   release, url, org, project, authToken, configFile, stripPrefix,\n      //   urlPrefix, include, ignore\n      // For all available options, see:\n      // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/build/\n      // silent: isProd, // Suppresses all logs\n      sourcemaps: {\n        // Upload only when explicitly enabled (default: disabled for security)\n        disable: !NEXT_BUILD_ENV_SENTRY_SOURCEMAPS_UPLOAD,\n        deleteSourcemapsAfterUpload: true, // Prevent .map files from leaking source code\n      },\n      bundleSizeOptimizations: {\n        excludeDebugStatements: !NEXT_BUILD_ENV_SENTRY_DEBUG,\n        excludeTracing: !NEXT_BUILD_ENV_SENTRY_TRACING,\n      },\n      silent: NEXT_BUILD_ENV_SENTRY_DEBUG === false,\n    });\n    console.log(`- ${pc.green('info')} Sentry enabled for this build`);\n  } catch {\n    console.log(`- ${pc.red('error')} Could not enable sentry, import failed`);\n  }\n}\n\nif (tmModules.length > 0) {\n  console.info(`${pc.green('notice')}- Will transpile [${tmModules.join(',')}]`);\n  const withNextTranspileModules = require('next-transpile-modules');\n\n  config = withNextTranspileModules(tmModules, {\n    resolveSymlinks: true,\n    debug: false,\n  })(config);\n}\n\nif (process.env.ANALYZE === 'true') {\n  const withBundleAnalyzer = require('@next/bundle-analyzer');\n  config = withBundleAnalyzer({\n    enabled: true,\n  })(config);\n}\n\nmodule.exports = config;\n"
  },
  {
    "path": "apps/nextjs-app/package.json",
    "content": "{\n  \"name\": \"@teable/app\",\n  \"version\": \"1.10.0\",\n  \"license\": \"AGPL-3.0\",\n  \"private\": true,\n  \"main\": \"main/index.js\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"apps/nextjs-app\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.3%\",\n      \"not ie 11\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"scripts\": {\n    \"build\": \"next build\",\n    \"build-fast\": \"cross-env NEXT_BUILD_ENV_SENTRY_ENABLED=0 NEXT_BUILD_ENV_SOURCEMAPS=0 NEXT_BUILD_ENV_TYPECHECK=0 next build\",\n    \"bundle-analyze\": \"cross-env ANALYZE=true NEXT_BUILD_ENV_SENTRY_ENABLED=1 NEXT_BUILD_ENV_TYPECHECK=0 pnpm build\",\n    \"check-dist\": \"es-check -v\",\n    \"check-size\": \"size-limit --highlight-less\",\n    \"clean\": \"rimraf ./.next ./out ./coverage ./tsconfig.tsbuildinfo ./node_modules/.cache ./.eslintcache\",\n    \"clean:backend\": \"rimraf --no-glob ./main\",\n    \"test\": \"run-s test-unit\",\n    \"test-unit\": \"vitest run --silent\",\n    \"test-unit-cover\": \"pnpm test-unit --coverage\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\",\n    \"lint\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs,.mdx --cache --cache-location ../../.cache/eslint/nextjs-app.eslintcache\",\n    \"fix-all-files\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs,.mdx --fix\",\n    \"flamegraph-home\": \"npx 0x --output-dir './.debug/flamegraph/{pid}.0x' --on-port 'autocannon http://localhost:$PORT --duration 20' -- node ../../node_modules/.bin/next start\"\n  },\n  \"devDependencies\": {\n    \"@next/bundle-analyzer\": \"16.1.6\",\n    \"@next/env\": \"16.1.6\",\n    \"@playwright/test\": \"1.57.0\",\n    \"@size-limit/file\": \"11.1.2\",\n    \"@svgr/webpack\": \"8.1.0\",\n    \"@testing-library/dom\": \"9.3.4\",\n    \"@testing-library/jest-dom\": \"6.4.2\",\n    \"@testing-library/react\": \"14.2.2\",\n    \"@testing-library/user-event\": \"14.5.2\",\n    \"@types/canvas-confetti\": \"1.9.0\",\n    \"@types/cors\": \"2.8.17\",\n    \"@types/express\": \"4.17.21\",\n    \"@types/lodash\": \"4.17.0\",\n    \"@types/ms\": \"0.7.34\",\n    \"@types/node\": \"22.18.0\",\n    \"@types/nprogress\": \"0.2.3\",\n    \"@types/react\": \"18.3.18\",\n    \"@types/react-dom\": \"18.3.5\",\n    \"@types/react-grid-layout\": \"1.3.5\",\n    \"@types/react-syntax-highlighter\": \"15.5.11\",\n    \"@types/react-test-renderer\": \"18.3.1\",\n    \"@types/sharedb\": \"5.1.0\",\n    \"@types/streamsaver\": \"2.0.5\",\n    \"@vitejs/plugin-react-swc\": \"3.6.0\",\n    \"@vitest/coverage-v8\": \"4.0.17\",\n    \"autoprefixer\": \"10.4.19\",\n    \"cross-env\": \"7.0.3\",\n    \"dotenv-flow\": \"4.1.0\",\n    \"dotenv-flow-cli\": \"1.1.1\",\n    \"es-check\": \"7.1.1\",\n    \"eslint\": \"8.57.0\",\n    \"eslint-config-next\": \"15.5.9\",\n    \"get-tsconfig\": \"4.7.3\",\n    \"happy-dom\": \"15.11.6\",\n    \"npm-run-all2\": \"6.1.2\",\n    \"postcss\": \"8.4.38\",\n    \"postcss-flexbugs-fixes\": \"5.0.2\",\n    \"postcss-preset-env\": \"9.5.2\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"size-limit\": \"11.1.2\",\n    \"symlink-dir\": \"5.2.1\",\n    \"sync-directory\": \"6.0.5\",\n    \"ts-node\": \"10.9.2\",\n    \"typescript\": \"5.4.3\",\n    \"vite-plugin-svgr\": \"4.2.0\",\n    \"vite-tsconfig-paths\": \"4.3.2\",\n    \"vitest\": \"4.0.17\"\n  },\n  \"dependencies\": {\n    \"@asteasolutions/zod-to-openapi\": \"8.1.0\",\n    \"@belgattitude/http-exception\": \"1.5.0\",\n    \"@codemirror/autocomplete\": \"6.15.0\",\n    \"@codemirror/commands\": \"6.3.3\",\n    \"@codemirror/lang-json\": \"6.0.1\",\n    \"@codemirror/language\": \"6.10.1\",\n    \"@codemirror/lint\": \"6.8.2\",\n    \"@codemirror/state\": \"6.4.1\",\n    \"@codemirror/view\": \"6.26.0\",\n    \"@dnd-kit/core\": \"6.1.0\",\n    \"@dnd-kit/sortable\": \"8.0.0\",\n    \"@dnd-kit/utilities\": \"3.2.2\",\n    \"@emoji-mart/data\": \"1.1.2\",\n    \"@emoji-mart/react\": \"1.1.1\",\n    \"@fontsource-variable/inter\": \"5.0.17\",\n    \"@fullcalendar/core\": \"6.1.15\",\n    \"@fullcalendar/daygrid\": \"6.1.15\",\n    \"@fullcalendar/interaction\": \"6.1.15\",\n    \"@fullcalendar/react\": \"6.1.15\",\n    \"@glideapps/glide-data-grid\": \"6.0.3\",\n    \"@hello-pangea/dnd\": \"16.6.0\",\n    \"@hookform/resolvers\": \"3.3.4\",\n    \"@nem035/gpt-3-encoder\": \"1.1.7\",\n    \"@radix-ui/react-icons\": \"1.3.0\",\n    \"@sentry/nextjs\": \"10.33.0\",\n    \"@sentry/react\": \"10.33.0\",\n    \"@tailwindcss/container-queries\": \"0.1.1\",\n    \"@tanstack/react-query\": \"5.90.16\",\n    \"@tanstack/react-table\": \"8.11.7\",\n    \"@tanstack/react-virtual\": \"3.2.0\",\n    \"@teable/common-i18n\": \"workspace:^\",\n    \"@teable/core\": \"workspace:^\",\n    \"@teable/icons\": \"workspace:^\",\n    \"@teable/next-themes\": \"0.3.5\",\n    \"@teable/openapi\": \"workspace:^\",\n    \"@teable/sdk\": \"workspace:^\",\n    \"@teable/ui-lib\": \"workspace:^\",\n    \"allotment\": \"1.20.0\",\n    \"axios\": \"1.7.7\",\n    \"canvas-confetti\": \"1.9.4\",\n    \"class-variance-authority\": \"0.7.0\",\n    \"date-fns\": \"4.1.0\",\n    \"date-fns-tz\": \"3.2.0\",\n    \"dayjs\": \"1.11.10\",\n    \"echarts\": \"5.5.0\",\n    \"emoji-mart\": \"5.5.2\",\n    \"eventsource-parser\": \"1.1.2\",\n    \"express\": \"4.21.1\",\n    \"fflate\": \"0.8.2\",\n    \"filesize\": \"10.1.1\",\n    \"fuse.js\": \"7.0.0\",\n    \"i18next\": \"23.10.1\",\n    \"is-port-reachable\": \"3.1.0\",\n    \"knex\": \"3.1.0\",\n    \"lodash\": \"4.17.21\",\n    \"lru-cache\": \"10.2.0\",\n    \"lucide-react\": \"0.363.0\",\n    \"ms\": \"2.1.3\",\n    \"next\": \"16.1.6\",\n    \"next-i18next\": \"15.2.0\",\n    \"next-secure-headers\": \"2.2.0\",\n    \"next-seo\": \"6.5.0\",\n    \"next-transpile-modules\": \"10.0.1\",\n    \"nprogress\": \"0.2.0\",\n    \"penpal\": \"6.2.2\",\n    \"picocolors\": \"1.0.0\",\n    \"qrcode.react\": \"3.1.0\",\n    \"re-resizable\": \"6.10.3\",\n    \"react\": \"18.3.1\",\n    \"react-confetti\": \"6.1.0\",\n    \"react-day-picker\": \"9.5.1\",\n    \"react-dom\": \"18.3.1\",\n    \"react-error-boundary\": \"4.0.13\",\n    \"react-grid-layout\": \"1.4.4\",\n    \"react-hook-form\": \"7.51.1\",\n    \"react-hotkeys-hook\": \"4.5.0\",\n    \"react-i18next\": \"14.1.0\",\n    \"react-joyride\": \"2.8.0\",\n    \"react-resizable\": \"3.0.5\",\n    \"react-responsive-carousel\": \"3.2.23\",\n    \"react-rnd\": \"10.4.14\",\n    \"react-syntax-highlighter\": \"15.5.0\",\n    \"react-textarea-autosize\": \"8.5.3\",\n    \"react-use\": \"17.5.1\",\n    \"react-virtuoso\": \"4.7.10\",\n    \"reactflow\": \"11.11.1\",\n    \"recharts\": \"2.12.3\",\n    \"reconnecting-websocket\": \"4.4.0\",\n    \"reflect-metadata\": \"0.2.1\",\n    \"sharedb\": \"5.2.2\",\n    \"streamsaver\": \"2.0.6\",\n    \"tailwind-scrollbar\": \"3.1.0\",\n    \"tailwindcss\": \"3.4.1\",\n    \"type-fest\": \"4.14.0\",\n    \"zod\": \"4.1.8\",\n    \"zod-validation-error\": \"4.0.2\",\n    \"zustand\": \"4.5.2\"\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/playwright.config.ts",
    "content": "// @ts-check\n\nimport path from 'path';\nimport { loadEnvConfig } from '@next/env';\nimport type { PlaywrightTestConfig } from '@playwright/test';\nimport { devices } from '@playwright/test';\nimport pc from 'picocolors';\n\nconst webServerModes = ['DEV', 'START', 'BUILD_AND_START'] as const;\ntype IWebServerMode = (typeof webServerModes)[number];\n\nconst isCI = ['true', '1'].includes(process.env?.CI ?? '');\nconst webServerMode = (process.env?.E2E_WEBSERVER_MODE as IWebServerMode) ?? 'NOT_SET';\n\nconst webServerPort = 3000;\nconst outputDir = path.join(__dirname, 'e2e/.out');\n\ntype IWebServerConfig = { cmd: string; timeout: number; retries: number };\nconst webServerConfigs: Record<IWebServerMode, IWebServerConfig> = {\n  START: {\n    cmd: `yarn start -p ${webServerPort}`,\n    timeout: isCI ? 90_000 : 30_000,\n    retries: isCI ? 3 : 1,\n  },\n  DEV: {\n    cmd: `yarn dev -p ${webServerPort}`,\n    timeout: 30_000,\n    retries: 1,\n  },\n  BUILD_AND_START: {\n    cmd: `NEXT_IGNORE_TYPECHECKS=1 yarn build --no-lint && yarn start -p ${webServerPort}`,\n    timeout: isCI ? 180_000 : 120_000,\n    retries: isCI ? 3 : 1,\n  },\n};\n\nif (typeof webServerConfigs?.[webServerMode] !== 'object') {\n  console.error(\n    `${pc.red('error')} - E2E_WEBSERVER_MODE must be one of '${webServerModes.join(', ')}'`\n  );\n  process.exit(1);\n} else {\n  console.log(`${pc.green('notice')} - Using E2E_WEBSERVER_MODE: '${webServerMode}'`);\n}\n\nconst webServerConfig = webServerConfigs[webServerMode];\n\nfunction getNextJsEnv(): Record<string, string> {\n  const { combinedEnv, loadedEnvFiles } = loadEnvConfig(__dirname);\n  loadedEnvFiles.forEach((file) => {\n    console.log(`${pc.green('notice')}- Loaded nextjs environment file: './${file.path}'`);\n  });\n  return Object.keys(combinedEnv).reduce<Record<string, string>>((acc, key) => {\n    const v = combinedEnv[key];\n    if (v !== undefined) acc[key] = v;\n    return acc;\n  }, {});\n}\n\n// Reference: https://playwright.dev/docs/test-configuration\n/**\n * @type {Partial<import('@playwright/test').PlaywrightTestConfig>}\n */\nconst config: PlaywrightTestConfig = {\n  testDir: path.join(__dirname, 'e2e'),\n  /* Maximum time one test can run for. */\n  timeout: webServerConfig.timeout,\n  retries: webServerConfig.retries,\n  /* Opt out of parallel tests on CI. */\n  workers: process.env.CI ? 1 : undefined,\n  // Artifacts folder where screenshots, videos, and traces are stored.\n  outputDir: `${outputDir}/output`,\n  preserveOutput: 'always',\n  reporter: [\n    isCI ? ['github'] : ['list'],\n    ['json', { outputFile: `${outputDir}/reports/test-results.json` }],\n    [\n      'html',\n      {\n        outputFolder: `${outputDir}/reports/html`,\n        open: isCI ? 'never' : 'on-failure',\n      },\n    ],\n  ],\n\n  // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests\n  webServer: {\n    command: webServerConfig.cmd,\n    port: webServerPort,\n    timeout: webServerConfig.timeout,\n    reuseExistingServer: !isCI,\n    env: getNextJsEnv(),\n  },\n\n  use: {\n    // Retry a test if it's failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc.\n    // More information: https://playwright.dev/docs/trace-viewer\n    trace: 'retry-with-trace',\n\n    contextOptions: {\n      ignoreHTTPSErrors: true,\n    },\n  },\n\n  projects: [\n    {\n      name: 'Desktop Chrome',\n      use: {\n        ...devices['Desktop Chrome'],\n      },\n    },\n    // {\n    //  name: 'Desktop Firefox',\n    //  use: {\n    //    ...devices['Desktop Firefox'],\n    //  },\n    // },\n    // {\n    //  name: 'Desktop Safari',\n    //  use: {\n    //    ...devices['Desktop Safari'],\n    //  },\n    // },\n    // Test against mobile viewports.\n    {\n      name: 'Mobile Chrome',\n      use: {\n        ...devices['Pixel 5'],\n      },\n    },\n    // Mobile Safari is not supported on CI/Linux yet.\n    // {\n    //  name: 'Mobile Safari',\n    //  use: devices['iPhone 12'],\n    // },\n  ],\n};\nexport default config;\n"
  },
  {
    "path": "apps/nextjs-app/postcss.config.js",
    "content": "// Customized postcss\n// @link https://nextjs.org/docs/advanced-features/customizing-postcss-config\n// @link https://tailwindcss.com/docs/using-with-preprocessors\n\nconst isProd = process.env.NODE_ENV === 'production';\nconst supportsIE11 = false;\nconst enableCssGrid = false;\nconst path = require('path');\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: {\n      config: path.join(__dirname, 'tailwind.config.js'),\n    },\n    ...(isProd\n      ? {\n          'postcss-flexbugs-fixes': {},\n          'postcss-preset-env': {\n            autoprefixer: {\n              flexbox: 'no-2009',\n              // https://github.com/postcss/autoprefixer#does-autoprefixer-polyfill-grid-layout-for-ie\n              ...(enableCssGrid\n                ? {\n                    grid: 'autoplace',\n                  }\n                : {}),\n            },\n            stage: 3,\n            features: {\n              'custom-properties': supportsIE11,\n            },\n          },\n        }\n      : {}),\n  },\n};\n"
  },
  {
    "path": "apps/nextjs-app/public/images/favicon/.readme",
    "content": "icons generate by https://cthedot.de/icongen/\n"
  },
  {
    "path": "apps/nextjs-app/public/images/favicon/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/images/favicon/apple-touch-icon.png\"/>\n            <TileColor>#da532c</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "apps/nextjs-app/public/images/favicon/site.webmanifest",
    "content": "{\n    \"name\": \"\",\n    \"short_name\": \"\",\n    \"icons\": [\n        {\n            \"src\": \"/images/favicon/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/images/favicon/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "apps/nextjs-app/public/robots.txt",
    "content": "# Robots.txt for app.teable.ai\n# Allow crawling of public pages only, disallow all other private pages\n\nUser-agent: *\nAllow: /public/\nDisallow: /\n"
  },
  {
    "path": "apps/nextjs-app/public/streamsaver/mitm.html",
    "content": "<!--\n\tmitm.html is the lite \"man in the middle\"\n\n\tThis is only meant to signal the opener's messageChannel to\n\tthe service worker - when that is done this mitm can be closed\n    but it's better to keep it alive since this also stops the sw\n    from restarting\n\n\tThe service worker is capable of intercepting all request and fork their\n\town \"fake\" response - wish we are going to craft\n\twhen the worker then receives a stream then the worker will tell the opener\n\tto open up a link that will start the download\n-->\n<script>\n  // This will prevent the sw from restarting\n  let keepAlive = () => {\n    keepAlive = () => {};\n    var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping';\n    var interval = setInterval(() => {\n      if (sw) {\n        sw.postMessage('ping');\n      } else {\n        fetch(ping).then((res) => res.text(!res.ok && clearInterval(interval)));\n      }\n    }, 10000);\n  };\n\n  // message event is the first thing we need to setup a listner for\n  // don't want the opener to do a random timeout - instead they can listen for\n  // the ready event\n  // but since we need to wait for the Service Worker registration, we store the\n  // message for later\n  let messages = [];\n  window.onmessage = (evt) => messages.push(evt);\n\n  let sw = null;\n  let scope = '';\n\n  function registerWorker() {\n    return navigator.serviceWorker\n      .getRegistration('./')\n      .then((swReg) => {\n        return swReg || navigator.serviceWorker.register('sw.js', { scope: './' });\n      })\n      .then((swReg) => {\n        const swRegTmp = swReg.installing || swReg.waiting;\n\n        scope = swReg.scope;\n\n        return (\n          (sw = swReg.active) ||\n          new Promise((resolve) => {\n            swRegTmp.addEventListener(\n              'statechange',\n              (fn = () => {\n                if (swRegTmp.state === 'activated') {\n                  swRegTmp.removeEventListener('statechange', fn);\n                  sw = swReg.active;\n                  resolve();\n                }\n              })\n            );\n          })\n        );\n      });\n  }\n\n  // Now that we have the Service Worker registered we can process messages\n  function onMessage(event) {\n    let { data, ports, origin } = event;\n\n    // It's important to have a messageChannel, don't want to interfere\n    // with other simultaneous downloads\n    if (!ports || !ports.length) {\n      throw new TypeError(\"[StreamSaver] You didn't send a messageChannel\");\n    }\n\n    if (typeof data !== 'object') {\n      throw new TypeError(\"[StreamSaver] You didn't send a object\");\n    }\n\n    // the default public service worker for StreamSaver is shared among others.\n    // so all download links needs to be prefixed to avoid any other conflict\n    data.origin = origin;\n\n    // if we ever (in some feature versoin of streamsaver) would like to\n    // redirect back to the page of who initiated a http request\n    data.referrer = data.referrer || document.referrer || origin;\n\n    // pass along version for possible backwards compatibility in sw.js\n    data.streamSaverVersion = new URLSearchParams(location.search).get('version');\n\n    if (data.streamSaverVersion === '1.2.0') {\n      console.warn('[StreamSaver] please update streamsaver');\n    }\n\n    /** @since v2.0.0 */\n    if (!data.headers) {\n      console.warn(\n        \"[StreamSaver] pass `data.headers` that you would like to pass along to the service worker\\nit should be a 2D array or a key/val object that fetch's Headers api accepts\"\n      );\n    } else {\n      // test if it's correct\n      // should thorw a typeError if not\n      new Headers(data.headers);\n    }\n\n    /** @since v2.0.0 */\n    if (typeof data.filename === 'string') {\n      console.warn(\n        \"[StreamSaver] You shouldn't send `data.filename` anymore. It should be included in the Content-Disposition header option\"\n      );\n      // Do what File constructor do with fileNames\n      data.filename = data.filename.replace(/\\//g, ':');\n    }\n\n    /** @since v2.0.0 */\n    if (data.size) {\n      console.warn(\n        \"[StreamSaver] You shouldn't send `data.size` anymore. It should be included in the content-length header option\"\n      );\n    }\n\n    /** @since v2.0.0 */\n    if (data.readableStream) {\n      console.warn(\n        '[StreamSaver] You should send the readableStream in the messageChannel, not throught mitm'\n      );\n    }\n\n    /** @since v2.0.0 */\n    if (!data.pathname) {\n      console.warn('[StreamSaver] Please send `data.pathname` (eg: /pictures/summer.jpg)');\n      data.pathname = Math.random().toString().slice(-6) + '/' + data.filename;\n    }\n\n    // remove all leading slashes\n    data.pathname = data.pathname.replace(/^\\/+/g, '');\n\n    // remove protocol\n    let org = origin.replace(/(^\\w+:|^)\\/\\//, '');\n\n    // set the absolute pathname to the download url.\n    data.url = new URL(`${scope + org}/${data.pathname}`).toString();\n\n    if (!data.url.startsWith(`${scope + org}/`)) {\n      throw new TypeError('[StreamSaver] bad `data.pathname`');\n    }\n\n    // This sends the message data as well as transferring\n    // messageChannel.port2 to the service worker. The service worker can\n    // then use the transferred port to reply via postMessage(), which\n    // will in turn trigger the onmessage handler on messageChannel.port1.\n\n    const transferable = data.readableStream ? [ports[0], data.readableStream] : [ports[0]];\n\n    if (!(data.readableStream || data.transferringReadable)) {\n      keepAlive();\n    }\n\n    return sw.postMessage(data, transferable);\n  }\n\n  if (window.opener) {\n    // The opener can't listen to onload event, so we need to help em out!\n    // (telling them that we are ready to accept postMessage's)\n    window.opener.postMessage('StreamSaver::loadedPopup', '*');\n  }\n\n  if (navigator.serviceWorker) {\n    registerWorker().then(() => {\n      window.onmessage = onMessage;\n      messages.forEach(window.onmessage);\n    });\n  } else {\n    // FF can ping sw with fetch from a secure hidden iframe\n    // shouldn't really be possible?\n    keepAlive();\n  }\n</script>\n"
  },
  {
    "path": "apps/nextjs-app/public/streamsaver/sw.js",
    "content": "/* global self ReadableStream Response */\n\nself.addEventListener('install', () => {\n  self.skipWaiting();\n});\n\nself.addEventListener('activate', (event) => {\n  event.waitUntil(self.clients.claim());\n});\n\nconst map = new Map();\n\n// This should be called once per download\n// Each event has a dataChannel that the data will be piped through\nself.onmessage = (event) => {\n  // We send a heartbeat every x second to keep the\n  // service worker alive if a transferable stream is not sent\n  if (event.data === 'ping') {\n    return;\n  }\n\n  const data = event.data;\n  const downloadUrl =\n    data.url ||\n    self.registration.scope +\n      Math.random() +\n      '/' +\n      (typeof data === 'string' ? data : data.filename);\n  const port = event.ports[0];\n  const metadata = new Array(3); // [stream, data, port]\n\n  metadata[1] = data;\n  metadata[2] = port;\n\n  // Note to self:\n  // old streamsaver v1.2.0 might still use `readableStream`...\n  // but v2.0.0 will always transfer the stream through MessageChannel #94\n  if (event.data.readableStream) {\n    metadata[0] = event.data.readableStream;\n  } else if (event.data.transferringReadable) {\n    port.onmessage = (evt) => {\n      port.onmessage = null;\n      metadata[0] = evt.data.readableStream;\n    };\n  } else {\n    metadata[0] = createStream(port);\n  }\n\n  map.set(downloadUrl, metadata);\n  port.postMessage({ download: downloadUrl });\n};\n\nfunction createStream(port) {\n  // ReadableStream is only supported by chrome 52\n  return new ReadableStream({\n    start(controller) {\n      // When we receive data on the messageChannel, we write\n      port.onmessage = ({ data }) => {\n        if (data === 'end') {\n          return controller.close();\n        }\n\n        if (data === 'abort') {\n          controller.error('Aborted the download');\n          return;\n        }\n\n        controller.enqueue(data);\n      };\n    },\n    cancel(reason) {\n      console.log('user aborted', reason);\n      port.postMessage({ abort: true });\n    },\n  });\n}\n\nself.onfetch = (event) => {\n  const url = event.request.url;\n\n  // this only works for Firefox\n  if (url.endsWith('/ping')) {\n    return event.respondWith(new Response('pong'));\n  }\n\n  const hijacke = map.get(url);\n\n  if (!hijacke) return null;\n\n  const [stream, data, port] = hijacke;\n\n  map.delete(url);\n\n  // Not comfortable letting any user control all headers\n  // so we only copy over the length & disposition\n  const responseHeaders = new Headers({\n    'Content-Type': 'application/octet-stream; charset=utf-8',\n\n    // To be on the safe side, The link can be opened in a iframe.\n    // but octet-stream should stop it.\n    'Content-Security-Policy': \"default-src 'none'\",\n    'X-Content-Security-Policy': \"default-src 'none'\",\n    'X-WebKit-CSP': \"default-src 'none'\",\n    'X-XSS-Protection': '1; mode=block',\n  });\n\n  let headers = new Headers(data.headers || {});\n\n  if (headers.has('Content-Length')) {\n    responseHeaders.set('Content-Length', headers.get('Content-Length'));\n  }\n\n  if (headers.has('Content-Disposition')) {\n    responseHeaders.set('Content-Disposition', headers.get('Content-Disposition'));\n  }\n\n  // data, data.filename and size should not be used anymore\n  if (data.size) {\n    console.warn('Depricated');\n    responseHeaders.set('Content-Length', data.size);\n  }\n\n  let fileName = typeof data === 'string' ? data : data.filename;\n  if (fileName) {\n    console.warn('Depricated');\n    // Make filename RFC5987 compatible\n    fileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\\*/g, '%2A');\n    responseHeaders.set('Content-Disposition', \"attachment; filename*=UTF-8''\" + fileName);\n  }\n\n  event.respondWith(new Response(stream, { headers: responseHeaders }));\n\n  port.postMessage({ debug: 'Download started' });\n};\n"
  },
  {
    "path": "apps/nextjs-app/sentry.client.config.ts",
    "content": "// This file configures the initialization of Sentry on the client.\n// The config you add here will be used whenever a users loads a page in their browser.\n// https://docs.sentry.io/platforms/javascript/guides/nextjs/\n\nimport * as Sentry from '@sentry/nextjs';\n\ndeclare global {\n  interface Window {\n    __TE__: { sentryDsn: string };\n  }\n}\n\nSentry.init({\n  release: process.env.NEXT_PUBLIC_BUILD_VERSION,\n  dsn: process.env.SENTRY_DSN || window.__TE__.sentryDsn,\n  // Adjust this value in production, or use tracesSampler for greater control\n  tracesSampleRate: 1,\n\n  // Setting this option to true will print useful information to the console while you're setting up Sentry.\n  debug: false,\n\n  replaysOnErrorSampleRate: 1.0,\n\n  // This sets the sample rate to be 10%. You may want this to be 100% while\n  // in development and sample at a lower rate in production\n  replaysSessionSampleRate: 0.1,\n\n  // You can remove this option if you're not planning to use the Sentry Session Replay feature:\n  integrations: [],\n});\n"
  },
  {
    "path": "apps/nextjs-app/sentry.server.config.ts",
    "content": "// Sentry server-side config for Next.js\n// https://docs.sentry.io/platforms/javascript/guides/nextjs/\n\nimport * as Sentry from '@sentry/nextjs';\n\nSentry.init({\n  release: process.env.NEXT_PUBLIC_BUILD_VERSION,\n  dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,\n  tracesSampleRate: 1,\n  debug: false,\n  // Use Next.js built-in OTEL instead of Sentry's\n  skipOpenTelemetrySetup: true,\n  // Disable HttpServer to avoid conflict with Next.js OTEL (causes stack overflow)\n  integrations: (defaults) => defaults.filter((i) => i.name !== 'HttpServer'),\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/AppProviders.tsx",
    "content": "import { ThemeProvider } from '@teable/next-themes';\nimport { ConfirmModalProvider } from '@teable/ui-lib';\nimport { Toaster as SoonerToaster } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Toaster } from '@teable/ui-lib/shadcn/ui/toaster';\nimport { useSearchParams } from 'next/navigation';\nimport type { FC, PropsWithChildren } from 'react';\nimport type { IServerEnv } from './lib/server-env';\nimport { EnvContext } from './lib/server-env';\n\ntype Props = PropsWithChildren;\n\nexport const AppProviders: FC<Props & { env: IServerEnv }> = (props) => {\n  const { children, env } = props;\n  const searchParams = useSearchParams();\n  const theme = searchParams?.get('theme') ?? undefined;\n\n  return (\n    <ThemeProvider\n      attribute=\"class\"\n      themeColor={{\n        light: '#ffffff',\n        dark: '#09090b',\n      }}\n      forcedTheme={theme}\n    >\n      <EnvContext.Provider value={env}>\n        <ConfirmModalProvider>\n          {children}\n          <Toaster />\n          <SoonerToaster />\n        </ConfirmModalProvider>\n      </EnvContext.Provider>\n    </ThemeProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/backend/api/rest/axios.ts",
    "content": "import { createAxios } from '@teable/openapi';\n\nexport const getAxios = () => {\n  const axios = createAxios();\n  axios.defaults.baseURL = `http://localhost:${process.env.PORT}/api`;\n  return axios;\n};\n\nexport const axios = getAxios();\n"
  },
  {
    "path": "apps/nextjs-app/src/backend/api/rest/get-user.ts",
    "content": "import type { IUser } from '@teable/sdk';\nimport { axios } from './axios';\n\nexport async function getUserMe(cookie?: string) {\n  return await axios\n    .get<IUser>(`/auth/user/me`, {\n      headers: { cookie },\n    })\n    .then(({ data }) => data);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/backend/api/rest/ssr-api.ts",
    "content": "import type { IFieldVo, IGetFieldsQuery, IRecord, IViewVo } from '@teable/core';\nimport { FieldKeyType } from '@teable/core';\nimport type {\n  AcceptInvitationLinkRo,\n  AcceptInvitationLinkVo,\n  IGetBaseVo,\n  IGetDefaultViewIdVo,\n  IGetSpaceVo,\n  IUpdateNotifyStatusRo,\n  ListSpaceCollaboratorVo,\n  ShareViewGetVo,\n  ITableFullVo,\n  ITableListVo,\n  ISettingVo,\n  IUserMeVo,\n  IRecordsVo,\n  ITableVo,\n  IGetSharedBaseVo,\n  IGroupPointsRo,\n  IGroupPointsVo,\n  ListSpaceCollaboratorRo,\n  IPublicSettingVo,\n  IGetDashboardVo,\n  IGetDashboardListVo,\n  IGetBasePermissionVo,\n  ITablePermissionVo,\n  IGetPinListVo,\n  ISubscriptionSummaryVo,\n  LastVisitResourceType,\n  IUserLastVisitVo,\n  IUsageVo,\n  IUserLastVisitListBaseVo,\n  IUserLastVisitBaseNodeVo,\n  IGetUserLastVisitBaseNodeRo,\n  IBaseNodeListVo,\n  ICreateBaseRo,\n  ICreateBaseVo,\n  ITemplatePermalinkVo,\n  IGetBaseShareVo,\n} from '@teable/openapi';\nimport {\n  IS_TEMPLATE_HEADER,\n  X_CANARY_HEADER,\n  BASE_SHARE_ID_HEADER,\n  ACCEPT_INVITATION_LINK,\n  CREATE_BASE,\n  GET_BASE,\n  GET_BASE_ALL,\n  GET_BASE_SHARE,\n  GET_DASHBOARD,\n  GET_DASHBOARD_LIST,\n  GET_DEFAULT_VIEW_ID,\n  GET_FIELD_LIST,\n  GET_GROUP_POINTS,\n  GET_PUBLIC_SETTING,\n  GET_RECORDS_URL,\n  GET_RECORD_URL,\n  GET_SETTING,\n  GET_SHARED_BASE,\n  GET_SPACE,\n  GET_SPACE_LIST,\n  GET_TABLE,\n  GET_TABLE_LIST,\n  GET_VIEW_LIST,\n  SHARE_VIEW_GET,\n  SPACE_COLLABORATE_LIST,\n  UPDATE_NOTIFICATION_STATUS,\n  USER_ME,\n  GET_BASE_PERMISSION,\n  GET_TABLE_PERMISSION,\n  urlBuilder,\n  GET_PIN_LIST,\n  GET_SUBSCRIPTION_SUMMARY,\n  GET_SUBSCRIPTION_SUMMARY_LIST,\n  GET_USER_LAST_VISIT,\n  GET_INSTANCE_USAGE,\n  GET_USER_LAST_VISIT_LIST_BASE,\n  GET_USER_LAST_VISIT_BASE_NODE,\n  GET_BASE_NODE_LIST,\n  GET_TEMPLATE_PERMALINK,\n} from '@teable/openapi';\nimport type { AxiosInstance } from 'axios';\nimport { getAxios } from './axios';\n\nexport class SsrApi {\n  axios: AxiosInstance;\n\n  disableLastVisit: boolean = false;\n\n  constructor() {\n    this.axios = getAxios();\n  }\n\n  /**\n   * Configure axios interceptors for base-specific headers (template, canary, etc.)\n   */\n  configureBaseHeaders(base: IGetBaseVo | undefined) {\n    const templateHeader = base?.template?.headers;\n    if (templateHeader) {\n      this.disableLastVisit = true;\n      this.axios.interceptors.request.use((config) => {\n        config.headers[IS_TEMPLATE_HEADER] = templateHeader;\n        return config;\n      });\n    }\n\n    if (base?.isCanary) {\n      this.axios.interceptors.request.use((config) => {\n        config.headers[X_CANARY_HEADER] = 'true';\n        return config;\n      });\n    }\n  }\n\n  /**\n   * Configure axios interceptors for share-specific headers\n   */\n  configureShareHeaders(shareId: string) {\n    this.disableLastVisit = true;\n    this.axios.interceptors.request.use((config) => {\n      config.headers[BASE_SHARE_ID_HEADER] = shareId;\n      return config;\n    });\n  }\n\n  async getTable(\n    baseId: string,\n    tableId: string,\n    viewId?: string\n  ): Promise<ITableFullVo & { extra: IRecordsVo['extra'] }> {\n    const fields = await this.getFields(tableId, { viewId });\n    const views = await this.axios\n      .get<IViewVo[]>(urlBuilder(GET_VIEW_LIST, { tableId }))\n      .then(({ data }) => data);\n    const table = await this.axios\n      .get<ITableVo>(urlBuilder(GET_TABLE, { baseId, tableId }), {\n        params: {\n          includeContent: true,\n          viewId,\n          fieldKeyType: FieldKeyType.Id,\n        },\n      })\n      .then(({ data }) => data);\n\n    const currentView = views.find((view) => view.id === viewId);\n\n    // Gracefully handle records fetch errors (e.g., invalid filter in view)\n    // This prevents SSR crash when view has corrupted filter data\n    let records: IRecord[] = [];\n    let extra: IRecordsVo['extra'] = undefined;\n    try {\n      const recordsResult = await this.axios\n        .get<IRecordsVo>(urlBuilder(GET_RECORDS_URL, { baseId, tableId }), {\n          params: {\n            viewId,\n            fieldKeyType: FieldKeyType.Id,\n            groupBy: currentView?.group ? JSON.stringify(currentView.group) : undefined,\n          },\n        })\n        .then(({ data }) => data);\n      records = recordsResult.records;\n      extra = recordsResult.extra;\n    } catch (error) {\n      // Log error but continue - client-side will show appropriate error toast\n      console.error('[SSR] Failed to fetch records, view may have invalid filter:', error);\n    }\n\n    return {\n      ...table,\n      records,\n      views,\n      fields,\n      extra,\n    };\n  }\n\n  async getFields(tableId: string, query?: IGetFieldsQuery) {\n    return this.axios\n      .get<IFieldVo[]>(urlBuilder(GET_FIELD_LIST, { tableId }), { params: query })\n      .then(({ data }) => data);\n  }\n\n  async getViewList(tableId: string) {\n    return this.axios\n      .get<IViewVo[]>(urlBuilder(GET_VIEW_LIST, { tableId }))\n      .then(({ data }) => data);\n  }\n\n  async getTables(baseId: string) {\n    return this.axios\n      .get<ITableListVo>(urlBuilder(GET_TABLE_LIST, { baseId }))\n      .then(({ data }) => data);\n  }\n\n  async getDefaultViewId(baseId: string, tableId: string) {\n    return this.axios\n      .get<IGetDefaultViewIdVo>(urlBuilder(GET_DEFAULT_VIEW_ID, { baseId, tableId }))\n      .then(({ data }) => data);\n  }\n\n  async getRecord(tableId: string, recordId: string) {\n    return this.axios\n      .get<IRecord>(urlBuilder(GET_RECORD_URL, { tableId, recordId }), {\n        params: { fieldKeyType: FieldKeyType.Id },\n      })\n      .then(({ data }) => data);\n  }\n\n  async getBaseById(baseId: string) {\n    return await this.axios\n      .get<IGetBaseVo>(urlBuilder(GET_BASE, { baseId }))\n      .then(({ data }) => data);\n  }\n\n  async getSpaceById(spaceId: string) {\n    return await this.axios\n      .get<IGetSpaceVo>(urlBuilder(GET_SPACE, { spaceId }))\n      .then(({ data }) => data);\n  }\n\n  async getSpaceList() {\n    return await this.axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST)).then(({ data }) => data);\n  }\n\n  async getBaseList() {\n    return await this.axios.get<IGetBaseVo[]>(GET_BASE_ALL).then(({ data }) => data);\n  }\n\n  async getPinList() {\n    return await this.axios.get<IGetPinListVo[]>(GET_PIN_LIST).then(({ data }) => data);\n  }\n\n  async getBasePermission(baseId: string) {\n    return await this.axios\n      .get<IGetBasePermissionVo>(urlBuilder(GET_BASE_PERMISSION, { baseId }))\n      .then((res) => res.data);\n  }\n\n  async getTablePermission(baseId: string, tableId: string) {\n    return await this.axios\n      .get<ITablePermissionVo>(urlBuilder(GET_TABLE_PERMISSION, { baseId, tableId }))\n      .then((res) => res.data);\n  }\n\n  async getSpaceCollaboratorList(spaceId: string, query?: ListSpaceCollaboratorRo) {\n    return await this.axios\n      .get<ListSpaceCollaboratorVo>(urlBuilder(SPACE_COLLABORATE_LIST, { spaceId }), {\n        params: query,\n      })\n      .then(({ data }) => data);\n  }\n\n  async getSubscriptionSummary(spaceId: string) {\n    return await this.axios\n      .get<ISubscriptionSummaryVo>(urlBuilder(GET_SUBSCRIPTION_SUMMARY, { spaceId }))\n      .then(({ data }) => data);\n  }\n\n  async getSubscriptionSummaryList() {\n    return await this.axios\n      .get<ISubscriptionSummaryVo[]>(urlBuilder(GET_SUBSCRIPTION_SUMMARY_LIST))\n      .then(({ data }) => data);\n  }\n\n  async acceptInvitationLink(acceptInvitationLinkRo: AcceptInvitationLinkRo) {\n    return this.axios\n      .post<AcceptInvitationLinkVo>(ACCEPT_INVITATION_LINK, acceptInvitationLinkRo)\n      .then(({ data }) => data);\n  }\n\n  async getShareView(shareId: string) {\n    return this.axios\n      .get<ShareViewGetVo>(urlBuilder(SHARE_VIEW_GET, { shareId }))\n      .then(({ data }) => data);\n  }\n\n  async getBaseShare(shareId: string) {\n    return this.axios\n      .get<IGetBaseShareVo>(urlBuilder(GET_BASE_SHARE, { shareId }))\n      .then(({ data }) => data);\n  }\n\n  async updateNotificationStatus(notificationId: string, data: IUpdateNotifyStatusRo) {\n    return this.axios\n      .patch<void>(urlBuilder(UPDATE_NOTIFICATION_STATUS, { notificationId }), data)\n      .then(({ data }) => data);\n  }\n\n  async getSetting() {\n    return this.axios.get<ISettingVo>(GET_SETTING).then(({ data }) => data);\n  }\n\n  async getPublicSetting() {\n    return this.axios.get<IPublicSettingVo>(GET_PUBLIC_SETTING).then(({ data }) => data);\n  }\n\n  async getUserMe() {\n    return this.axios.get<IUserMeVo>(USER_ME).then(({ data }) => data);\n  }\n\n  async getSharedBase() {\n    return this.axios.get<IGetSharedBaseVo[]>(GET_SHARED_BASE).then(({ data }) => data);\n  }\n\n  async getGroupPoints(tableId: string, query: IGroupPointsRo) {\n    return this.axios\n      .get<IGroupPointsVo>(urlBuilder(GET_GROUP_POINTS, { tableId }), {\n        params: {\n          ...query,\n          filter: JSON.stringify(query?.filter),\n          groupBy: JSON.stringify(query?.groupBy),\n        },\n      })\n      .then(({ data }) => data);\n  }\n\n  async getDashboard(baseId: string, dashboardId: string) {\n    return this.axios\n      .get<IGetDashboardVo>(urlBuilder(GET_DASHBOARD, { baseId, id: dashboardId }))\n      .then(({ data }) => data);\n  }\n\n  async getDashboardList(baseId: string) {\n    return this.axios\n      .get<IGetDashboardListVo>(urlBuilder(GET_DASHBOARD_LIST, { baseId }))\n      .then(({ data }) => data);\n  }\n\n  async getUserLastVisit(resourceType: LastVisitResourceType, parentResourceId: string) {\n    if (this.disableLastVisit) return undefined;\n    return this.axios\n      .get<IUserLastVisitVo | undefined>(GET_USER_LAST_VISIT, {\n        params: { resourceType, parentResourceId },\n      })\n      .then(({ data }) => data);\n  }\n\n  async getUserLastVisitBaseNode(params: IGetUserLastVisitBaseNodeRo) {\n    if (this.disableLastVisit) return undefined;\n    return this.axios\n      .get<IUserLastVisitBaseNodeVo | undefined>(GET_USER_LAST_VISIT_BASE_NODE, { params })\n      .then(({ data }) => data);\n  }\n\n  async getBaseNodeList(baseId: string) {\n    return this.axios\n      .get<IBaseNodeListVo>(urlBuilder(GET_BASE_NODE_LIST, { baseId }))\n      .then(({ data }) => data);\n  }\n\n  async getInstanceUsage() {\n    return this.axios.get<IUsageVo>(GET_INSTANCE_USAGE).then(({ data }) => data);\n  }\n\n  async getRecentlyBase() {\n    return this.axios\n      .get<IUserLastVisitListBaseVo>(GET_USER_LAST_VISIT_LIST_BASE)\n      .then(({ data }) => data);\n  }\n\n  async createBase(createBaseRo: ICreateBaseRo) {\n    return this.axios.post<ICreateBaseVo>(CREATE_BASE, createBaseRo).then(({ data }) => data);\n  }\n\n  async getTemplatePermalink(identifier: string) {\n    return this.axios\n      .get<ITemplatePermalinkVo>(urlBuilder(GET_TEMPLATE_PERMALINK, { identifier }))\n      .then(({ data }) => data);\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/components/Banner.tsx",
    "content": "import type { FC } from 'react';\n\ntype Props = {\n  children?: never;\n};\n\nexport const Banner: FC<Props> = () => {\n  return (\n    <div className=\"bg-indigo-600\">\n      <div className=\"mx-auto max-w-7xl p-3 sm:px-6 lg:px-8\">\n        <div className=\"flex flex-wrap items-center justify-between\">\n          <div className=\"flex w-0 flex-1 items-center\">\n            <span className=\"flex rounded-lg bg-indigo-800 p-2\"></span>\n            <p className=\"ml-3 truncate font-medium text-white\">\n              <span className=\"md:hidden\">We announced a new product!</span>\n              <span className=\"hidden md:inline\">\n                Big news! We're excited to announce a brand new product.\n              </span>\n            </p>\n          </div>\n          <div className=\"order-3 mt-2 w-full shrink-0 sm:order-2 sm:mt-0 sm:w-auto\">\n            <a\n              href=\"#\"\n              className=\"flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-2 text-sm font-medium text-indigo-600 shadow-sm hover:bg-indigo-50\"\n            >\n              Learn more\n            </a>\n          </div>\n          <div className=\"order-2 shrink-0 sm:order-3 sm:ml-3\">\n            <button\n              type=\"button\"\n              className=\"-mr-1 flex rounded-md p-2 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-white sm:-mr-2\"\n            >\n              <span className=\"sr-only\">Dismiss</span>\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/Guide.tsx",
    "content": "import type { IUserMeVo } from '@teable/openapi';\nimport dynamic from 'next/dynamic';\nimport { useRouter } from 'next/router';\nimport { useTranslation, Trans } from 'next-i18next';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { ACTIONS, EVENTS, STATUS } from 'react-joyride';\nimport type { CallBackProps, Step, StoreHelpers } from 'react-joyride';\nimport colors from 'tailwindcss/colors';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useCompletedGuideMapStore } from './store';\n\nconst JoyRideNoSSR = dynamic(() => import('react-joyride'), { ssr: false });\n\nexport const GUIDE_PREFIX = 't-guide-';\n\nexport const GUIDE_CREATE_SPACE = GUIDE_PREFIX + 'create-space';\nexport const GUIDE_CREATE_BASE = GUIDE_PREFIX + 'create-base';\nexport const GUIDE_CREATE_TABLE = GUIDE_PREFIX + 'create-table';\nexport const GUIDE_CREATE_VIEW = GUIDE_PREFIX + 'create-view';\nexport const GUIDE_VIEW_FILTERING = GUIDE_PREFIX + 'view-filtering';\nexport const GUIDE_VIEW_SORTING = GUIDE_PREFIX + 'view-sorting';\nexport const GUIDE_VIEW_GROUPING = GUIDE_PREFIX + 'view-grouping';\nexport const GUIDE_API_BUTTON = GUIDE_PREFIX + 'api-button';\n\nexport enum StepKey {\n  CreateSpace = 'createSpace',\n  CreateBase = 'createBase',\n  CreateTable = 'createTable',\n  CreateView = 'createView',\n  ViewFiltering = 'viewFiltering',\n  ViewSorting = 'viewSorting',\n  ViewGrouping = 'viewGrouping',\n  ApiButton = 'apiButton',\n}\n\ntype EnhanceStep = { key: StepKey; step: Step };\n\nconst findStepsForPath = (\n  guideMap: Record<string, EnhanceStep[]>,\n  path: string\n): EnhanceStep[] | null => {\n  if (guideMap[path]) {\n    return guideMap[path];\n  }\n\n  const includePath = Object.keys(guideMap).find((p) => path.includes(p));\n\n  if (includePath) {\n    return guideMap[includePath];\n  }\n\n  return null;\n};\n\nexport const Guide = ({ user }: { user?: IUserMeVo }) => {\n  const router = useRouter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { completedGuideMap, setCompletedGuideMap } = useCompletedGuideMapStore();\n\n  const helpers = useRef<StoreHelpers>();\n  const [run, setRun] = useState(false);\n  const [steps, setSteps] = useState<Step[]>([]);\n  const [stepIndex, setStepIndex] = useState(0);\n\n  const userId = user?.id;\n  const { pathname, isReady } = router;\n\n  const guideStepMap: Record<StepKey, Step> = useMemo(\n    () => ({\n      [StepKey.CreateSpace]: {\n        target: `.${GUIDE_CREATE_SPACE}`,\n        title: <div className=\"text-base\">{t('guide.createSpaceTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">\n            <Trans\n              ns=\"common\"\n              i18nKey=\"guide.createSpaceTooltipContent\"\n              components={{ br: <br /> }}\n            />\n          </div>\n        ),\n        disableBeacon: true,\n      },\n      [StepKey.CreateBase]: {\n        target: `.${GUIDE_CREATE_BASE}`,\n        title: <div className=\"text-base\">{t('guide.createBaseTooltipTitle')}</div>,\n        content: <div className=\"text-left text-[13px]\">{t('guide.createBaseTooltipContent')}</div>,\n        disableBeacon: true,\n      },\n      [StepKey.CreateTable]: {\n        target: `.${GUIDE_CREATE_TABLE}`,\n        title: <div className=\"text-base\">{t('guide.createTableTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">{t('guide.createTableTooltipContent')}</div>\n        ),\n        disableBeacon: true,\n        placement: 'right',\n      },\n      [StepKey.CreateView]: {\n        target: `.${GUIDE_CREATE_VIEW}`,\n        title: <div className=\"text-base\">{t('guide.createViewTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">\n            <Trans\n              ns=\"common\"\n              i18nKey=\"guide.createViewTooltipContent\"\n              components={{ br: <br /> }}\n            />\n          </div>\n        ),\n        disableBeacon: true,\n      },\n      [StepKey.ViewFiltering]: {\n        target: `.${GUIDE_VIEW_FILTERING}`,\n        title: <div className=\"text-base\">{t('guide.viewFilteringTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">\n            <Trans\n              ns=\"common\"\n              i18nKey=\"guide.viewFilteringTooltipContent\"\n              components={{ br: <br /> }}\n            />\n          </div>\n        ),\n        disableBeacon: true,\n      },\n      [StepKey.ViewSorting]: {\n        target: `.${GUIDE_VIEW_SORTING}`,\n        title: <div className=\"text-base\">{t('guide.viewSortingTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">\n            <Trans\n              ns=\"common\"\n              i18nKey=\"guide.viewSortingTooltipContent\"\n              components={{ br: <br /> }}\n            />\n          </div>\n        ),\n        disableBeacon: true,\n      },\n      [StepKey.ViewGrouping]: {\n        target: `.${GUIDE_VIEW_GROUPING}`,\n        title: <div className=\"text-base\">{t('guide.viewGroupingTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">{t('guide.viewGroupingTooltipContent')}</div>\n        ),\n        disableBeacon: true,\n      },\n      [StepKey.ApiButton]: {\n        target: `.${GUIDE_API_BUTTON}`,\n        title: <div className=\"text-base\">{t('guide.apiButtonTooltipTitle')}</div>,\n        content: (\n          <div className=\"text-left text-[13px]\">\n            <Trans\n              ns=\"common\"\n              i18nKey=\"guide.apiButtonTooltipContent\"\n              components={{\n                a: (\n                  // eslint-disable-next-line jsx-a11y/anchor-has-content\n                  <a\n                    className=\"text-violet-500\"\n                    href=\"/setting/personal-access-token\"\n                    target=\"_blank\"\n                  />\n                ),\n              }}\n            />\n          </div>\n        ),\n        disableBeacon: true,\n      },\n    }),\n    [t]\n  );\n\n  const orderedGuideMap: Record<string, EnhanceStep[]> = useMemo(\n    () => ({\n      '/space': [\n        { key: StepKey.CreateSpace, step: guideStepMap[StepKey.CreateSpace] },\n        { key: StepKey.CreateBase, step: guideStepMap[StepKey.CreateBase] },\n      ],\n      '/base/[baseId]': [{ key: StepKey.CreateTable, step: guideStepMap[StepKey.CreateTable] }],\n      '/base/[baseId]/[[...slug]]': [\n        { key: StepKey.CreateTable, step: guideStepMap[StepKey.CreateTable] },\n        { key: StepKey.CreateView, step: guideStepMap[StepKey.CreateView] },\n        { key: StepKey.ViewFiltering, step: guideStepMap[StepKey.ViewFiltering] },\n        { key: StepKey.ViewSorting, step: guideStepMap[StepKey.ViewSorting] },\n        { key: StepKey.ViewGrouping, step: guideStepMap[StepKey.ViewGrouping] },\n        { key: StepKey.ApiButton, step: guideStepMap[StepKey.ApiButton] },\n      ],\n    }),\n    [guideStepMap]\n  );\n\n  const getHelpers = (storeHelpers: StoreHelpers) => {\n    helpers.current = storeHelpers;\n  };\n\n  const onCallback = (data: CallBackProps) => {\n    const { action, index, status, type } = data;\n\n    if ([ACTIONS.CLOSE, ACTIONS.SKIP].includes(action as never)) {\n      setRun(false);\n      if (!userId) return;\n      return setCompletedGuideMap(userId, Object.keys(guideStepMap));\n    }\n\n    if ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND].includes(type as never)) {\n      setStepIndex(index + (action === ACTIONS.PREV ? -1 : 1));\n    } else if (status === STATUS.FINISHED || type === EVENTS.TOUR_END) {\n      setRun(false);\n\n      if (!userId) return;\n      const prevCompletedStepKeys = completedGuideMap[userId] || [];\n      const enhanceSteps = findStepsForPath(orderedGuideMap, pathname);\n      if (!enhanceSteps?.length) return;\n      setCompletedGuideMap(userId, [\n        ...new Set([...prevCompletedStepKeys, ...enhanceSteps.map(({ key }) => key)]),\n      ]);\n    }\n  };\n\n  useEffect(() => {\n    const resetGuide = () => {\n      setStepIndex(0);\n      helpers.current?.reset(false);\n    };\n\n    router.events.on('routeChangeStart', resetGuide);\n\n    return () => {\n      router.events.off('routeChangeStart', resetGuide);\n    };\n  }, [router.events, setStepIndex]);\n\n  useEffect(() => {\n    if (!isReady) return;\n\n    let enhanceSteps = findStepsForPath(orderedGuideMap, pathname);\n\n    if (!enhanceSteps?.length) return;\n\n    if (userId) {\n      const prevCompletedSteps = completedGuideMap[userId] || [];\n\n      if (prevCompletedSteps.length) {\n        enhanceSteps = enhanceSteps.filter(({ key }) => !prevCompletedSteps.includes(key));\n      }\n    }\n\n    if (!enhanceSteps.length) return;\n\n    const steps = enhanceSteps.map(({ step }) => step);\n\n    let retryCount = 0;\n    let timer: number | undefined;\n\n    timer = window.setInterval(() => {\n      const step = steps[stepIndex];\n\n      if (!step) {\n        clearInterval(timer);\n        timer = undefined;\n        return;\n      }\n\n      const targetElement = document.querySelector(step.target as string);\n\n      if (targetElement) {\n        clearInterval(timer);\n        timer = undefined;\n        setSteps(steps);\n        setRun(true);\n        setTimeout(() => helpers.current?.reset(true), 100);\n      } else {\n        if (++retryCount >= 100) {\n          clearInterval(timer);\n          timer = undefined;\n        }\n      }\n    }, 50);\n\n    return () => {\n      clearInterval(timer);\n      timer = undefined;\n    };\n  }, [completedGuideMap, isReady, orderedGuideMap, pathname, stepIndex, userId]);\n\n  return (\n    <JoyRideNoSSR\n      run={run}\n      steps={steps}\n      stepIndex={stepIndex}\n      spotlightPadding={8}\n      continuous\n      showSkipButton\n      hideBackButton\n      hideCloseButton\n      disableCloseOnEsc\n      disableOverlayClose\n      disableScrollParentFix\n      styles={{\n        options: {\n          primaryColor: colors.black,\n          width: 320,\n        },\n        tooltip: {\n          padding: 12,\n        },\n        tooltipContent: {\n          padding: 8,\n          lineHeight: '22px',\n        },\n        buttonClose: {\n          width: 10,\n          height: 10,\n          outline: 'none',\n        },\n        buttonNext: {\n          fontSize: 13,\n          padding: '8px 16px',\n          outline: 'none',\n        },\n        buttonBack: {\n          fontSize: 13,\n          padding: '8px 16px',\n          outline: 'none',\n        },\n        buttonSkip: {\n          fontSize: 13,\n          padding: '8px 16px',\n          outline: 'none',\n        },\n        tooltipFooter: {\n          marginTop: 8,\n        },\n        spotlight: {\n          border: `1px solid ${colors.white}`,\n          borderRadius: 8,\n        },\n      }}\n      getHelpers={getHelpers}\n      callback={onCallback}\n      locale={{\n        back: t('guide.prev'),\n        next: t('guide.next'),\n        last: t('guide.done'),\n        skip: t('guide.skip'),\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/Metrics.tsx",
    "content": "import Script from 'next/script';\n\nexport const MicrosoftClarity = ({\n  clarityId,\n  user,\n}: {\n  clarityId?: string;\n  user?: {\n    id?: string;\n    name?: string;\n    email?: string;\n  };\n}) => {\n  if (!clarityId) {\n    return null;\n  }\n\n  return (\n    <>\n      <Script\n        id=\"microsoft-clarity-init\"\n        strategy=\"afterInteractive\"\n        dangerouslySetInnerHTML={{\n          __html: `\n        (function(c,l,a,r,i,t,y){\n            c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\n            t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;\n            y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);\n        })(window, document, \"clarity\", \"script\", \"${clarityId}\");\n        `,\n        }}\n      />\n      <Script\n        id=\"microsoft-clarity-identify\"\n        dangerouslySetInnerHTML={{\n          __html: `window.clarity && window.clarity(\"identify\", \"${user?.email || user?.id}\");`,\n        }}\n      />\n    </>\n  );\n};\n\nexport const Umami = ({\n  umamiWebSiteId,\n  umamiUrl,\n  user,\n}: {\n  umamiWebSiteId?: string;\n  umamiUrl?: string;\n  user?: {\n    id?: string;\n    name?: string;\n    email?: string;\n  };\n}) => {\n  if (!umamiWebSiteId || !umamiUrl) {\n    return null;\n  }\n\n  return (\n    <>\n      <Script\n        id=\"umami-init\"\n        defer\n        src={umamiUrl}\n        data-website-id={umamiWebSiteId}\n        onLoad={() => {\n          if (user) {\n            window.umami &&\n              window.umami.identify({ email: user.email, id: user.id, name: user.name });\n          }\n        }}\n      />\n    </>\n  );\n};\n\nexport const GoogleAnalytics = ({\n  gaId,\n  user,\n}: {\n  gaId?: string;\n  user?: {\n    id?: string;\n    name?: string;\n    email?: string;\n  };\n}) => {\n  if (!gaId) {\n    return null;\n  }\n\n  return (\n    <>\n      <Script\n        id=\"google-analytics\"\n        strategy=\"afterInteractive\"\n        src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}\n      />\n      <Script\n        id=\"google-analytics-init\"\n        strategy=\"afterInteractive\"\n        dangerouslySetInnerHTML={{\n          __html: `\n            window.dataLayer = window.dataLayer || [];\n            function gtag(){dataLayer.push(arguments);}\n            gtag('js', new Date());\n            gtag('config', '${gaId}');\n            ${user ? `gtag('config', '${gaId}', { user_id: '${user.id}', custom_map: { custom_dimension_1: 'user_email' } }); gtag('event', 'login', { custom_dimension_1: '${user.email}' });` : ''}\n          `,\n        }}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/RouterProgress.tsx",
    "content": "'use client';\n\nimport { useRouter } from 'next/router';\nimport NProgress from 'nprogress';\nimport { useEffect } from 'react';\n\nNProgress.configure({ showSpinner: false });\n\nexport default function RouterProgressBar() {\n  const router = useRouter();\n\n  useEffect(() => {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const handleStart = (...args: any[]) => {\n      !args?.[1]?.shallow && NProgress.start();\n    };\n    const handleStop = () => NProgress.done();\n\n    router.events.on('routeChangeStart', handleStart);\n    router.events.on('routeChangeComplete', handleStop);\n    router.events.on('routeChangeError', handleStop);\n\n    return () => {\n      router.events.off('routeChangeStart', handleStart);\n      router.events.off('routeChangeComplete', handleStop);\n      router.events.off('routeChangeError', handleStop);\n    };\n  }, [router.events]);\n\n  return (\n    <style>\n      {`\n        #nprogress .bar {\n          height: 2px;\n        }\n     `}\n    </style>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/components/Selector.tsx",
    "content": "import type { ISelectorProps as IUISelectorProps } from '@teable/ui-lib/base';\nimport { Selector as UISelector } from '@teable/ui-lib/base';\nimport { useTranslation } from 'next-i18next';\n\nexport type ISelectorProps = IUISelectorProps;\n\nexport const Selector: React.FC<ISelectorProps> = (props) => {\n  const { t } = useTranslation('common');\n  const {\n    onChange,\n    readonly,\n    selectedId = '',\n    placeholder,\n    searchTip = t('actions.search'),\n    emptyTip = t('noResult'),\n    defaultName = t('untitled'),\n    className,\n    contentClassName,\n    candidates = [],\n  } = props;\n\n  return (\n    <UISelector\n      onChange={onChange}\n      readonly={readonly}\n      selectedId={selectedId}\n      placeholder={placeholder}\n      searchTip={searchTip}\n      emptyTip={emptyTip}\n      defaultName={defaultName}\n      className={className}\n      contentClassName={contentClassName}\n      candidates={candidates}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/TeableLogo.tsx",
    "content": "import { TeableNew } from '@teable/icons';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useBrand } from '@/features/app/hooks/useBrand';\n\nexport const TeableLogo = ({ className }: { className: string }) => {\n  const { brandName, brandLogo } = useBrand();\n\n  if (!brandLogo) {\n    return <TeableNew className={cn('text-black', className)} />;\n  }\n\n  return (\n    <img\n      src={brandLogo}\n      alt={brandName}\n      width={64}\n      height={64}\n      className={cn('size-6', className)}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/changelog/ChangelogNotification.tsx",
    "content": "import { ArrowUpRight, X } from '@teable/icons';\nimport { LocalStorageKeys } from '@teable/sdk/config';\nimport { useIsHydrated, useShareId } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { Rocket } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\n\nexport const ChangelogNotification = () => {\n  const { t } = useTranslation('common');\n  const isHydrated = useIsHydrated();\n  const isCloud = useIsCloud();\n  const shareId = useShareId();\n  const [visible, setVisible] = useState(false);\n\n  const changelogId = t('changelog.id');\n  const title = t('changelog.title');\n  const url = t('changelog.url');\n\n  useEffect(() => {\n    if (!changelogId) return;\n    try {\n      const dismissedId = localStorage.getItem(LocalStorageKeys.DismissedChangelog);\n      if (dismissedId !== changelogId) {\n        setVisible(true);\n      }\n    } catch {\n      // ignore\n    }\n  }, [changelogId, title]);\n\n  const handleDismiss = useCallback(() => {\n    setVisible(false);\n    try {\n      localStorage.setItem(LocalStorageKeys.DismissedChangelog, changelogId);\n    } catch {\n      // ignore\n    }\n  }, [changelogId]);\n\n  if (!isCloud || !isHydrated || !visible || shareId) {\n    return null;\n  }\n\n  return (\n    <div className=\"mt-2 flex w-full shrink-0 flex-col items-center gap-2 !border-0 px-4\">\n      <a\n        href={url}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"group relative flex w-full items-center justify-between rounded-md border border-transparent bg-surface p-4 py-3 transition-colors hover:border-border hover:bg-accent dark:hover:bg-white/10\"\n      >\n        <div className=\"flex min-w-0 flex-1 flex-col items-start gap-2\">\n          <span className=\"flex w-full items-center gap-1.5 truncate text-left text-xs font-semibold uppercase text-muted-foreground\">\n            <Rocket className=\"size-4\" />\n            <span>{t('changelog.newUpdate')}</span>\n          </span>\n          <div className=\"flex w-full min-w-0 items-center gap-1\">\n            <span\n              className=\"min-w-0 truncate text-left text-sm font-medium text-foreground\"\n              title={title}\n            >\n              {title}\n            </span>\n            <ArrowUpRight className=\"hidden size-4 shrink-0 rounded-sm border border-primary stroke-2 text-primary group-hover:inline\" />\n          </div>\n        </div>\n\n        {/* Close Button */}\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"absolute right-2 top-2 size-5 p-0 text-muted-foreground hover:bg-transparent\"\n          aria-label=\"Close\"\n          onClick={(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            handleDismiss();\n          }}\n        >\n          <X className=\"size-4\" />\n        </Button>\n      </a>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/changelog/index.ts",
    "content": "export { ChangelogNotification } from './ChangelogNotification';\n"
  },
  {
    "path": "apps/nextjs-app/src/components/google-ads.tsx",
    "content": "// Declare gtag types\ndeclare global {\n  interface Window {\n    gtag: (command: string, targetId: string | Date, config?: Record<string, unknown>) => void;\n    dataLayer: unknown[];\n  }\n}\n\ninterface IUserInfo {\n  id: string;\n  email: string;\n  name?: string;\n}\n\n// SHA-256 hash function for email\nasync function sha256(message: string): Promise<string> {\n  const msgBuffer = new TextEncoder().encode(message);\n  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Cookie helper function\nfunction getCookie(name: string): string | null {\n  if (typeof document === 'undefined') return null;\n  const nameEQ = name + '=';\n  const ca = document.cookie.split(';');\n  for (let i = 0; i < ca.length; i++) {\n    let c = ca[i];\n    while (c.charAt(0) === ' ') c = c.substring(1, c.length);\n    if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);\n  }\n  return null;\n}\n\n// Check if user has consented to ad tracking\nfunction hasAdConsent(): boolean {\n  if (typeof window === 'undefined') return false;\n\n  const CONSENT_COOKIE_NAME = 'teable_consent';\n  const savedConsent = getCookie(CONSENT_COOKIE_NAME);\n\n  if (!savedConsent) {\n    return false;\n  }\n\n  try {\n    const preferences = JSON.parse(decodeURIComponent(savedConsent));\n    return preferences.ad_user_data === 'granted';\n  } catch {\n    return false;\n  }\n}\n\n// Export function to track sign-up conversion with user information\nexport async function trackSignUpConversion(conversionId?: string, userInfo?: IUserInfo) {\n  if (typeof window === 'undefined' || !window.gtag || !conversionId || !userInfo) {\n    return;\n  }\n\n  // Hash email for privacy (Google Ads Enhanced Conversions)\n  const hashedEmail = await sha256(userInfo.email.toLowerCase().trim());\n  let conversionData: Record<string, unknown> = {\n    send_to: conversionId,\n  };\n\n  if (hasAdConsent()) {\n    conversionData = {\n      ...conversionData,\n      user_id: userInfo.id,\n      user_data: {\n        email: hashedEmail,\n      },\n    };\n  }\n\n  window.gtag('event', 'conversion', conversionData);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/components/layout/MainFooter.tsx",
    "content": "import GithubIcon from '@teable/ui-lib/icons/social/github.svg';\nimport type { FC } from 'react';\n\nexport const MainFooter: FC = () => {\n  return (\n    <div>\n      <div className={'bgImage'}></div>\n      <div className={'content'}>\n        <a\n          href={'https://github.com/teableio/teable'}\n          target={'_blank'}\n          rel={'noopener noreferrer'}\n        >\n          <GithubIcon />\n        </a>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/layout/MainLayout.tsx",
    "content": "import type { FC, ReactNode } from 'react';\n\nexport const MainLayout: FC<{ children: ReactNode }> = (props) => {\n  const { children } = props;\n  return (\n    <div className=\"flex h-screen flex-col\">\n      <main>{children}</main>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/components/layout/__tests__/MainLayout.test.tsx",
    "content": "import { MainLayout } from '@/components/layout/MainLayout';\nimport { render, screen } from '@/test-utils';\n\ndescribe('main layout tests', () => {\n  it('should render children', async () => {\n    render(\n      <MainLayout>\n        <div role=\"article\">Hello</div>\n      </MainLayout>\n    );\n    const appContent = screen.getByRole('article');\n    expect(appContent).toHaveTextContent('Hello');\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/components/layout/index.ts",
    "content": "export { MainLayout } from './MainLayout';\n"
  },
  {
    "path": "apps/nextjs-app/src/components/store/guide.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface ICompletedGuideMapState {\n  completedGuideMap: Record<string, string[]>;\n  setCompletedGuideMap: (userId: string, stepKeys: string[]) => void;\n}\n\nexport const useCompletedGuideMapStore = create<ICompletedGuideMapState>()(\n  persist(\n    (set, get) => ({\n      completedGuideMap: {},\n      setCompletedGuideMap: (userId: string, stepKeys: string[]) => {\n        set({\n          completedGuideMap: {\n            ...get().completedGuideMap,\n            [userId]: stepKeys,\n          },\n        });\n      },\n    }),\n    {\n      name: LocalStorageKeys.CompletedGuideMap,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/components/store/index.ts",
    "content": "export * from './guide';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/automation/Pages.tsx",
    "content": "import { useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { Alert, AlertTitle, AlertDescription } from '@teable/ui-lib/shadcn/ui/alert';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport Head from 'next/head';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\n\nexport function AutomationPage() {\n  const { t } = useTranslation('common');\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  // In template/share preview mode, don't show upgrade prompt\n  // Allow the actual automation component to be rendered (if available via override)\n  if (isReadOnlyPreview) {\n    return (\n      <div className=\"h-full flex-col md:flex\">\n        <Head>\n          <title>{t('noun.automation')}</title>\n        </Head>\n        <div className=\"flex flex-col gap-2 lg:gap-4\">\n          <div className=\"items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex\">\n            <h2 className=\"text-3xl font-bold tracking-tight\">{t('noun.automation')}</h2>\n          </div>\n        </div>\n        <div className=\"flex h-full items-center justify-center p-4\">\n          {/* In preview mode, the actual WorkFlowPanel component will be rendered via override */}\n          <div className=\"text-sm text-muted-foreground\">{t('noun.automation')}</div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"h-full flex-col md:flex\">\n      <Head>\n        <title>{t('noun.automation')}</title>\n      </Head>\n      <div className=\"flex flex-col gap-2 lg:gap-4\">\n        <div className=\"items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex\">\n          <h2 className=\"text-3xl font-bold tracking-tight\">{t('noun.automation')}</h2>\n        </div>\n      </div>\n      <div className=\"flex h-full items-center justify-center p-4\">\n        <Alert className=\"w-[400px]\">\n          <AlertTitle>\n            <span className=\"text-lg\">✨</span> {t('billing.enterpriseFeature')}\n          </AlertTitle>\n          <AlertDescription className=\"flex flex-col gap-3 text-xs\">\n            <p>{t('billing.automationRequiresUpgrade')}</p>\n            <Button className=\"w-fit\" variant=\"default\" asChild size=\"xs\">\n              <Link href={`${t('help.appLink')}/setting/license-plan`} target=\"_blank\">\n                {t('billing.viewPricing')}\n              </Link>\n            </Button>\n          </AlertDescription>\n        </Alert>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/automation/workflow-panel/WorkFlowPanel.tsx",
    "content": "import { forwardRef, useImperativeHandle } from 'react';\nimport { AutomationPage } from '../Pages';\n\nexport interface WorkFlowPanelRef {\n  getWorkflow?: () => unknown | undefined;\n  checkCanActive?: () => {\n    canActive: boolean;\n    message: string;\n  };\n  activeWorkflow?: () => Promise<void>;\n}\n\ninterface WorkFlowPanelProps {\n  baseId: string;\n  workflowId: string;\n  headLeft?: React.ReactNode;\n}\n\nconst WorkFlowPanel = forwardRef<WorkFlowPanelRef, WorkFlowPanelProps>((_props, ref) => {\n  useImperativeHandle(\n    ref,\n    () => {\n      return {};\n    },\n    []\n  );\n\n  return <AutomationPage />;\n});\n\nWorkFlowPanel.displayName = 'WorkFlowPanel';\n\nexport { WorkFlowPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/automation/workflow-panel/WorkFlowPanelModal.tsx",
    "content": "import { useIsHydrated } from '@teable/sdk/hooks';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  Button,\n  Dialog,\n  DialogContent,\n  Spin,\n} from '@teable/ui-lib';\nimport { XIcon } from 'lucide-react';\nimport { forwardRef, lazy, Suspense, useImperativeHandle, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { WorkFlowPanelRef } from '@overridable/WorkFlowPanel';\nimport { useWorkFlowPanelStore } from './useWorkFlowPaneStore';\n\nconst WorkFlowPanelLazy = lazy(() =>\n  import('@overridable/WorkFlowPanel').then((module) => ({\n    default: module.WorkFlowPanel,\n  }))\n);\n\ninterface AlertCloseDialogProps {\n  handleCancel: () => void;\n  handleConfirm: () => void;\n}\ninterface AlertCloseDialogRef {\n  open: () => void;\n}\n\nconst AlertCloseWorkflowDialog = forwardRef<AlertCloseDialogRef, AlertCloseDialogProps>(\n  (props, ref) => {\n    const { handleCancel, handleConfirm } = props;\n    const [open, setOpen] = useState(false);\n    const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n    useImperativeHandle(\n      ref,\n      () => {\n        return {\n          open: () => setOpen(true),\n        };\n      },\n      []\n    );\n    return (\n      <AlertDialog open={open} onOpenChange={setOpen}>\n        <AlertDialogTrigger asChild></AlertDialogTrigger>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('common:automation.turnOnTip')}</AlertDialogTitle>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel className=\"px-5 py-0.5 text-[13px]\" onClick={handleCancel}>\n              {t('common:actions.exit')}\n            </AlertDialogCancel>\n            <AlertDialogAction className=\"px-5 py-0.5 text-[13px]\" onClick={handleConfirm}>\n              {t('common:actions.turnOn')}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    );\n  }\n);\n\nAlertCloseWorkflowDialog.displayName = 'AlertCloseWorkflowDialog';\n\nexport const WorkFlowPanelModal = () => {\n  const { baseId = '', workflowId = '', closeModal, open } = useWorkFlowPanelStore();\n  const isHydrated = useIsHydrated();\n  const workflowRef = useRef<WorkFlowPanelRef>(null);\n  const alertCloseWorkflowDialogRef = useRef<AlertCloseDialogRef>(null);\n  if (!isHydrated || !baseId || !workflowId || !open) {\n    return null;\n  }\n\n  const handleClose = () => {\n    const workflow = workflowRef.current?.getWorkflow?.();\n    const isActive = workflow && (workflow as { isActive: boolean }).isActive;\n    if (!isActive) {\n      const checkRes = workflowRef.current?.checkCanActive?.();\n      if (checkRes?.canActive) {\n        alertCloseWorkflowDialogRef.current?.open();\n        return;\n      }\n    }\n    closeModal();\n  };\n\n  return (\n    <Dialog open={open}>\n      <DialogContent\n        closeable={false}\n        className=\"flex max-w-7xl p-2\"\n        style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }}\n      >\n        <div className=\"flex-1\">\n          <Suspense\n            fallback={\n              <div className=\"flex size-full items-center justify-center\">\n                <Spin />\n              </div>\n            }\n          >\n            <WorkFlowPanelLazy\n              baseId={baseId}\n              workflowId={workflowId}\n              headLeft={\n                <Button variant={'ghost'} size={'icon-xs'} onClick={handleClose}>\n                  <XIcon className=\"size-4 shrink-0\" />\n                </Button>\n              }\n              ref={workflowRef}\n            />\n          </Suspense>\n        </div>\n\n        <AlertCloseWorkflowDialog\n          ref={alertCloseWorkflowDialogRef}\n          handleCancel={() => {\n            closeModal();\n          }}\n          handleConfirm={() => {\n            workflowRef.current?.activeWorkflow?.();\n            closeModal();\n          }}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/automation/workflow-panel/useWorkFlowPaneStore.ts",
    "content": "import { z } from 'zod';\nimport { create } from 'zustand';\n\nconst from = ['buttonFieldOptions'] as const;\nconst fromSchema = z.enum(from);\ntype From = z.infer<typeof fromSchema>;\n\ninterface IWorkFlowPanelState {\n  baseId?: string;\n  workflowId?: string;\n  open?: boolean;\n  from?: From;\n  closeModal: () => void;\n  openModal: (baseId: string, workflowId: string) => void;\n  setModal: (props: Pick<IWorkFlowPanelState, 'baseId' | 'workflowId' | 'open' | 'from'>) => void;\n}\n\nexport const useWorkFlowPanelStore = create<IWorkFlowPanelState>((set) => ({\n  closeModal: () => {\n    set((state) => {\n      return {\n        ...state,\n        baseId: undefined,\n        workflowId: undefined,\n        open: false,\n        from: undefined,\n      };\n    });\n  },\n  openModal: (baseId: string, workflowId: string) => {\n    set((state) => {\n      return {\n        ...state,\n        baseId,\n        workflowId,\n        open: true,\n      };\n    });\n  },\n  setModal: (props: Pick<IWorkFlowPanelState, 'baseId' | 'workflowId' | 'open' | 'from'>) => {\n    set((state) => {\n      return {\n        ...state,\n        ...props,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base/CommunityPage.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport Image from 'next/image';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const CommunityPage = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n  return (\n    <div className=\"h-full flex-col md:flex\">\n      <div className=\"flex h-full flex-1 flex-col gap-2 lg:gap-4\">\n        <div className=\"items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex\">\n          <h2 className=\"text-3xl font-bold tracking-tight\">{t('table:welcome.title')}</h2>\n        </div>\n        <div className=\"flex h-full min-w-80 flex-col items-center justify-center p-4 \">\n          <Image\n            src={isDark ? '/images/layout/welcome-dark.png' : '/images/layout/welcome-light.png'}\n            alt=\"No roles available\"\n            width={240}\n            height={240}\n          />\n          <ul className=\"my-4 flex max-w-[720px] flex-col items-center justify-center space-y-2 text-center\">\n            <li className=\"text-lg font-semibold\">{t('table:welcome.emptyTitle')}</li>\n            <li>{t('table:welcome.description')}</li>\n            <li>\n              <Trans\n                ns=\"table\"\n                i18nKey=\"welcome.help\"\n                components={{\n                  HelpCenter: (\n                    <a\n                      href={t('help.mainLink')}\n                      className=\"text-blue-500 hover:text-blue-700\"\n                      target=\"_blank\"\n                      rel=\"noreferrer\"\n                    >\n                      {t('table:welcome.helpCenter')}\n                    </a>\n                  ),\n                }}\n              ></Trans>\n            </li>\n          </ul>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/BasePage.tsx",
    "content": "import { dehydrate } from '@tanstack/react-query';\nimport { getDefaultNodeUrl, redirect } from './helper';\nimport type { ISSRContext, SSRResult } from './types';\n\nexport const getBaseServerSideProps = async (ctx: ISSRContext): Promise<SSRResult> => {\n  const { base } = ctx;\n\n  // Try to redirect to the default node (last visited or first non-folder node)\n  const defaultUrl = await getDefaultNodeUrl(ctx);\n  if (defaultUrl) {\n    return redirect(defaultUrl);\n  }\n\n  return {\n    props: {\n      ...(await ctx.getTranslationsProps()),\n      dehydratedState: dehydrate(ctx.queryClient),\n      base,\n    },\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/DashBoardPage.tsx",
    "content": "import { dehydrate } from '@tanstack/react-query';\nimport { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport dynamic from 'next/dynamic';\nimport type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource';\nimport { redirect } from './helper';\nimport type { ISSRContext, SSRResult } from './types';\n\nexport const getDashboardServerSideProps = async (\n  ctx: ISSRContext,\n  parsed: IBaseResourceParsed\n): Promise<SSRResult> => {\n  const { ssrApi, baseId, queryClient, base } = ctx;\n  if (parsed.resourceType !== BaseNodeResourceType.Dashboard) return { notFound: true };\n\n  const { dashboardId } = parsed;\n\n  if (!dashboardId) {\n    const [lastVisit, dashboardList] = await Promise.all([\n      ssrApi.getUserLastVisit(LastVisitResourceType.Dashboard, baseId),\n      queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.getDashboardList(baseId),\n        queryFn: () => ssrApi.getDashboardList(baseId),\n      }),\n    ]);\n\n    const ids = dashboardList.map((d) => d.id);\n    const defaultId =\n      lastVisit?.resourceId && ids.includes(lastVisit.resourceId) ? lastVisit.resourceId : ids[0];\n    if (defaultId) return redirect(`/base/${baseId}/dashboard/${defaultId}`);\n\n    return {\n      props: {\n        ...(await ctx.getTranslationsProps()),\n        dehydratedState: dehydrate(ctx.queryClient),\n        base,\n      },\n    };\n  }\n\n  const dashboardList = await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.getDashboardList(baseId),\n    queryFn: () => ssrApi.getDashboardList(baseId),\n  });\n\n  const dashboardIds = dashboardList.map((d) => d.id);\n\n  // If dashboard doesn't exist, redirect to default node\n  if (!dashboardIds.includes(dashboardId)) {\n    const { getDefaultNodeUrl } = await import('./helper');\n    const defaultUrl = await getDefaultNodeUrl(ctx);\n    if (defaultUrl) {\n      return redirect(defaultUrl);\n    }\n    return { notFound: true };\n  }\n\n  await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.getDashboard(dashboardId),\n    queryFn: () => ssrApi.getDashboard(baseId, dashboardId),\n  });\n\n  return {\n    props: {\n      ...(await ctx.getTranslationsProps()),\n      dehydratedState: dehydrate(ctx.queryClient),\n      base,\n    },\n  };\n};\n\nconst DynamicDashboard = dynamic(\n  () => import('@/features/app/dashboard/Pages').then((mod) => mod.DashboardPage),\n  {\n    ssr: false,\n  }\n);\n\nexport const DashBoardPage = () => {\n  return <DynamicDashboard />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/TablePage.tsx",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport { dehydrate } from '@tanstack/react-query';\nimport { ViewType } from '@teable/core';\nimport { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport dynamic from 'next/dynamic';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\nimport type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource';\nimport { getViewPageServerData } from '@/lib/view-pages-data';\nimport { redirect, validateResourceExists } from './helper';\nimport type { ISSRContext, SSRResult, ITablePageProps } from './types';\n\ninterface IQueryParams {\n  recordId?: string;\n  fromNotify?: string;\n  [key: string]: unknown;\n}\n\nconst getDefaultViewId = async (ssrApi: SsrApi, tableId: string, queryParams?: IQueryParams) => {\n  const { recordId } = queryParams ?? {};\n  const [lastVisit, viewList] = await Promise.all([\n    ssrApi.getUserLastVisit(LastVisitResourceType.View, tableId),\n    ssrApi.getViewList(tableId),\n  ]);\n  if (viewList.length === 0) {\n    return undefined;\n  }\n  const nonFormViews = viewList.filter((v) => v.type !== ViewType.Form);\n  const candidateViews = recordId && nonFormViews.length > 0 ? nonFormViews : viewList;\n  const viewIds = candidateViews.map((v) => v.id);\n\n  return lastVisit?.resourceId && viewIds.includes(lastVisit.resourceId)\n    ? lastVisit.resourceId\n    : viewIds[0]!;\n};\n\nexport const getTableServerSideProps = async (\n  ctx: ISSRContext,\n  parsed: IBaseResourceParsed,\n  queryParams?: IQueryParams\n): Promise<SSRResult> => {\n  const { ssrApi, baseId, queryClient, base } = ctx;\n  if (parsed.resourceType !== BaseNodeResourceType.Table) return { notFound: true };\n  const { tableId, viewId } = parsed;\n  const { recordId, fromNotify: notifyId } = queryParams ?? {};\n  const queryString = queryParams\n    ? new URLSearchParams(queryParams as Record<string, string>).toString()\n    : '';\n  const query = queryString ? `?${queryString}` : '';\n\n  if (!tableId) {\n    const [lastVisit, tableList] = await Promise.all([\n      ssrApi.getUserLastVisit(LastVisitResourceType.Table, baseId),\n      ssrApi.getTables(baseId),\n    ]);\n    const tableIds = tableList.map((t) => t.id);\n    const defaultTableId =\n      lastVisit?.resourceId && tableIds.includes(lastVisit.resourceId)\n        ? lastVisit.resourceId\n        : tableIds[0];\n\n    const defaultViewId = defaultTableId\n      ? await getDefaultViewId(ssrApi, defaultTableId)\n      : undefined;\n    if (defaultTableId && defaultViewId) {\n      return redirect(`/base/${baseId}/table/${defaultTableId}/${defaultViewId}`);\n    }\n    return redirect(`/base/${baseId}`);\n  }\n\n  // check table exists first\n  const tableList = await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.tableList(baseId),\n    queryFn: () => ssrApi.getTables(baseId),\n  });\n\n  if (tableList.length === 0) return { notFound: true };\n\n  // If table doesn't exist, redirect to default node\n  const validationResult = await validateResourceExists(ctx, {\n    resourceId: tableId,\n    queryKey: ReactQueryKeys.tableList(baseId),\n    fetchList: () => ssrApi.getTables(baseId),\n    extractIds: (list) => list.map((t) => t.id),\n  });\n\n  if (validationResult) {\n    return validationResult;\n  }\n\n  // Table exists, now handle viewId\n  if (!viewId) {\n    const defaultViewId = await getDefaultViewId(ssrApi, tableId, queryParams);\n    if (defaultViewId) {\n      return redirect(`/base/${baseId}/table/${tableId}/${defaultViewId}${query}`);\n    }\n    return { notFound: true };\n  }\n\n  const tableIds = tableList.map((t) => t.id);\n  if (tableIds.length === 0) {\n    return redirect(`/base/${baseId}`);\n  }\n  if (!tableIds.includes(tableId)) {\n    return redirect(`/base/${baseId}/table/${tableIds[0]}`);\n  }\n\n  // check view exists\n  const viewList = await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.viewList(tableId),\n    queryFn: () => ssrApi.getViewList(tableId),\n  });\n  const viewIds = viewList.map((v) => v.id);\n  if (viewIds.length === 0) return { notFound: true };\n  if (!viewIds.includes(viewId)) {\n    return redirect(`/base/${baseId}/table/${tableId}/${viewIds[0]}${query}`);\n  }\n\n  // handle recordId\n  let recordServerData: ITablePageProps['recordServerData'];\n  if (recordId) {\n    if (notifyId) await ssrApi.updateNotificationStatus(notifyId, { isRead: true });\n    recordServerData = await ssrApi.getRecord(tableId, recordId);\n    if (!recordServerData) return redirect(`/base/${baseId}/table/${tableId}/${viewId}`);\n  }\n\n  const serverData = await getViewPageServerData(ssrApi, baseId, tableId, viewId);\n  if (!serverData) return { notFound: true };\n\n  await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.getTablePermission(baseId, tableId),\n    queryFn: () => ssrApi.getTablePermission(baseId, tableId),\n  });\n  return {\n    props: {\n      ...serverData,\n      ...(recordServerData ? { recordServerData } : {}),\n      ...(await ctx.getTranslationsProps()),\n      dehydratedState: dehydrate(ctx.queryClient),\n      base,\n    },\n  };\n};\n\nconst DynamicTable = dynamic(\n  () => import('@/features/app/blocks/table/Table').then((mod) => mod.Table),\n  {\n    ssr: false,\n  }\n);\n\nexport const TablePage = ({\n  fieldServerData,\n  viewServerData,\n  recordsServerData,\n  recordServerData,\n  groupPointsServerDataMap,\n}: ITablePageProps) => {\n  return (\n    <DynamicTable\n      fieldServerData={fieldServerData ?? []}\n      viewServerData={viewServerData ?? []}\n      recordsServerData={recordsServerData ?? { records: [] }}\n      recordServerData={recordServerData}\n      groupPointsServerDataMap={groupPointsServerDataMap}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/WorkflowPage.tsx",
    "content": "import { dehydrate } from '@tanstack/react-query';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { AutomationPage } from '@/features/app/automation/Pages';\nimport type { IBaseResourceParsed } from '../hooks/useBaseResource';\nimport type { ISSRContext, SSRResult } from './types';\n\nexport const getWorkflowServerSideProps = async (\n  ctx: ISSRContext,\n  parsed: IBaseResourceParsed\n): Promise<SSRResult> => {\n  if (parsed.resourceType !== BaseNodeResourceType.Workflow) return { notFound: true };\n\n  return {\n    props: {\n      ...(await ctx.getTranslationsProps()),\n      dehydratedState: dehydrate(ctx.queryClient),\n    },\n  };\n};\n\nexport const WorkflowPage = () => {\n  return <AutomationPage />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/helper.ts",
    "content": "import type { QueryClient } from '@tanstack/react-query';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { getNodeUrl } from '@/features/app/blocks/base/base-node/hooks';\nimport type { SSRResult, ISSRContext } from './types';\n\nexport const redirect = (destination: string): SSRResult => ({\n  redirect: { destination, permanent: false },\n});\n\n/**\n * Get the default node URL when a specific node is not found\n * This function will redirect to the first available non-folder node in the base\n */\nexport const getDefaultNodeUrl = async (ctx: ISSRContext): Promise<string | null> => {\n  const { ssrApi, baseId } = ctx;\n\n  try {\n    const [lastVisitNode, nodes] = await Promise.all([\n      ssrApi.getUserLastVisitBaseNode({ parentResourceId: baseId }),\n      ssrApi.getBaseNodeList(baseId),\n    ]);\n\n    // Try to find the last visited node, but skip if it's a folder\n    let findNode = nodes.find(\n      (n) =>\n        n.resourceId === lastVisitNode?.resourceId && n.resourceType !== BaseNodeResourceType.Folder\n    );\n\n    // If not found, find the first non-folder node\n    if (!findNode) {\n      findNode = nodes.find((n) => n.resourceType !== BaseNodeResourceType.Folder);\n    }\n\n    if (findNode) {\n      const url = getNodeUrl({\n        baseId,\n        resourceType: findNode.resourceType,\n        resourceId: findNode.resourceId,\n      });\n      return url?.pathname || null;\n    }\n  } catch (error) {\n    console.error('Failed to get default node:', error);\n  }\n\n  return null;\n};\n\n/**\n * Validate if a resource exists in the list, redirect to default node if not found\n * @param ctx - SSR context\n * @param options - Validation options\n * @returns SSRResult if resource not found, null if resource exists\n */\nexport const validateResourceExists = async <T>(\n  ctx: ISSRContext,\n  options: {\n    resourceId: string;\n    queryKey: readonly unknown[];\n    fetchList: (queryClient: QueryClient) => Promise<T[]>;\n    extractIds: (list: T[]) => string[];\n  }\n): Promise<SSRResult | null> => {\n  const { queryClient } = ctx;\n\n  const list = await queryClient.fetchQuery({\n    queryKey: options.queryKey,\n    queryFn: () => options.fetchList(queryClient),\n  });\n\n  const ids = options.extractIds(list);\n\n  // If resource doesn't exist, redirect to default node\n  if (!ids.includes(options.resourceId)) {\n    const defaultUrl = await getDefaultNodeUrl(ctx);\n    if (defaultUrl) {\n      return redirect(defaultUrl);\n    }\n    return { notFound: true };\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/index.ts",
    "content": "export type { ISSRContext, SSRHandler, SSRResult } from './types';\n\nexport { redirect, getDefaultNodeUrl, validateResourceExists } from './helper';\n\nexport { TablePage, getTableServerSideProps } from './TablePage';\nexport { DashBoardPage, getDashboardServerSideProps } from './DashBoardPage';\nexport { WorkflowPage, getWorkflowServerSideProps } from './WorkflowPage';\nexport { getBaseServerSideProps } from './BasePage';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/base-node/types.ts",
    "content": "import type { QueryClient } from '@tanstack/react-query';\nimport type { IFieldVo, IRecord, IViewVo } from '@teable/core';\nimport type { IGetBaseVo, IGroupPointsVo } from '@teable/openapi';\nimport type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';\nimport type { SSRConfig } from 'next-i18next';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\nimport type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource';\nimport type { IBasePageProps } from '@/lib/type';\nexport interface ITablePageProps {\n  fieldServerData?: IFieldVo[];\n  viewServerData?: IViewVo[];\n  recordsServerData?: { records: IRecord[] };\n  recordServerData?: IRecord;\n  groupPointsServerDataMap?: { [viewId: string]: IGroupPointsVo | null };\n}\n\nexport type IBaseNodePageProps = IBasePageProps & Partial<ITablePageProps>;\n\nexport interface ISSRContext {\n  context: GetServerSidePropsContext;\n  queryClient: QueryClient;\n  baseId: string;\n  ssrApi: SsrApi;\n  getTranslationsProps: () => Promise<SSRConfig>;\n  base: IGetBaseVo;\n}\n\nexport type SSRResult = GetServerSidePropsResult<IBaseNodePageProps>;\n\nexport type SSRHandler = (\n  ctx: ISSRContext,\n  parsed: IBaseResourceParsed,\n  queryParams?: Record<string, string | string[] | undefined>\n) => Promise<SSRResult>;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/App.tsx",
    "content": "import { Button } from '@teable/ui-lib/shadcn';\nimport { Alert, AlertTitle, AlertDescription } from '@teable/ui-lib/shadcn/ui/alert';\nimport Head from 'next/head';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\n\nexport function AppPage() {\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"h-full flex-col md:flex\">\n      <Head>\n        <title>{t('noun.app')}</title>\n      </Head>\n      <div className=\"flex flex-col gap-2 lg:gap-4\">\n        <div className=\"items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex\">\n          <h2 className=\"text-3xl font-bold tracking-tight\">{t('noun.authorityMatrix')}</h2>\n        </div>\n      </div>\n      <div className=\"flex h-full items-center justify-center p-4\">\n        <Alert className=\"w-[400px]\">\n          <AlertTitle>\n            <span className=\"text-lg\">✨</span> {t('billing.enterpriseFeature')}\n          </AlertTitle>\n          <AlertDescription className=\"flex flex-col gap-3 text-xs\">\n            <p>{t('billing.authorityMatrixRequiresUpgrade')}</p>\n            <Button className=\"w-fit\" variant=\"default\" asChild size=\"xs\">\n              <Link href={`${t('help.appLink')}/setting/license-plan`} target=\"_blank\">\n                {t('billing.viewPricing')}\n              </Link>\n            </Button>\n          </AlertDescription>\n        </Alert>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/AuthorityMatrix.tsx",
    "content": "import { Button } from '@teable/ui-lib/shadcn';\nimport { Alert, AlertTitle, AlertDescription } from '@teable/ui-lib/shadcn/ui/alert';\nimport Head from 'next/head';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\n\nexport function AuthorityMatrixPage() {\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"h-full flex-col md:flex\">\n      <Head>\n        <title>{t('noun.authorityMatrix')}</title>\n      </Head>\n      <div className=\"flex flex-col gap-2 lg:gap-4\">\n        <div className=\"items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex\">\n          <h2 className=\"text-3xl font-bold tracking-tight\">{t('noun.authorityMatrix')}</h2>\n        </div>\n      </div>\n      <div className=\"flex h-full items-center justify-center p-4\">\n        <Alert className=\"w-[400px]\">\n          <AlertTitle>\n            <span className=\"text-lg\">✨</span> {t('billing.enterpriseFeature')}\n          </AlertTitle>\n          <AlertDescription className=\"flex flex-col gap-3 text-xs\">\n            <p>{t('billing.authorityMatrixRequiresUpgrade')}</p>\n            <Button className=\"w-fit\" variant=\"default\" asChild size=\"xs\">\n              <Link href={`${t('help.appLink')}/setting/license-plan`} target=\"_blank\">\n                {t('billing.viewPricing')}\n              </Link>\n            </Button>\n          </AlertDescription>\n        </Alert>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/Error.tsx",
    "content": "import type { FC } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\n\nexport const Error: FC<{ message: string }> = (props) => {\n  const { message } = props;\n  const { brandName } = useBrand();\n\n  return (\n    <div className=\"mer flex h-screen flex-col items-center justify-center\">\n      <div>\n        <div className=\"flex w-full\">\n          <TeableLogo className=\"text-4xl\" />\n          <p className=\"ml-1 truncate text-4xl font-semibold\">{brandName}</p>\n        </div>\n        <h1 className=\"scroll-m-20 text-3xl tracking-tight\">{message}</h1>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/index.ts",
    "content": "export * from './setting';\nexport * from './template';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IUpdateSettingRo, ISettingVo } from '@teable/openapi';\nimport {\n  BillingProductLevel,\n  getInstanceUsage,\n  getSetting,\n  SettingKey,\n  updateSetting,\n} from '@teable/openapi';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { Button, Label, Switch } from '@teable/ui-lib/shadcn';\nimport { RotateCcwIcon } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useRef } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { useIsEE } from '@/features/app/hooks/useIsEE';\nimport { CopyInstance } from './components';\nimport { Branding } from './components/Branding';\nimport { CanarySettings } from './components/canary';\nimport type { IList } from './components/ConfigurationList';\nimport { ConfigurationList } from './components/ConfigurationList';\nimport { MailConfigDialog } from './components/mail-config/MailConfig';\nimport { InviteCodeManage } from './components/waitlist/InviteCodeManage';\nimport { WaitlistManage } from './components/waitlist/WaitlistManage';\nimport { scrollToTarget } from './utils';\n\nexport interface ISettingPageProps {\n  settingServerData?: ISettingVo;\n  rewardManage?: React.ReactNode;\n}\n\nexport const SettingPage = (props: ISettingPageProps) => {\n  const { settingServerData, rewardManage } = props;\n  const queryClient = useQueryClient();\n  const { t } = useTranslation('common');\n\n  const { data: setting = settingServerData } = useQuery({\n    queryKey: ['setting'],\n    queryFn: () => getSetting().then(({ data }) => data),\n  });\n\n  const { mutateAsync: mutateUpdateSetting } = useMutation({\n    mutationFn: (props: IUpdateSettingRo) => updateSetting(props),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['setting'] });\n    },\n  });\n\n  const isEE = useIsEE();\n  const isCloud = useIsCloud();\n\n  const { data: instanceUsage } = useQuery({\n    queryKey: ['instance-usage'],\n    queryFn: () => getInstanceUsage().then(({ data }) => data),\n    enabled: isEE,\n  });\n\n  const onValueChange = (key: string, value: unknown) => {\n    mutateUpdateSetting({ [key]: value });\n  };\n\n  const emailRef = useRef<HTMLDivElement>(null);\n  const { publicOrigin, publicDatabaseProxy } = useEnv();\n\n  const isHydrated = useIsHydrated();\n\n  const todoLists = useMemo(\n    () => [\n      {\n        title: t('admin.configuration.list.publicOrigin.title'),\n        key: 'publicOrigin' as const,\n        values: {\n          envPublicOrigin: publicOrigin,\n          currentPublicOrigin: isHydrated ? location?.origin : '',\n        },\n        isRequired: true,\n        isComplete: isHydrated ? location?.origin === publicOrigin : false,\n        group: 'system' as const,\n        path: '/admin/setting',\n      },\n      {\n        title: t('admin.configuration.list.https.title'),\n        key: 'https' as const,\n        isRequired: true,\n        isComplete: isHydrated ? location?.protocol === 'https:' : false,\n        group: 'system' as const,\n        path: '/admin/setting',\n      },\n      {\n        title: t('admin.configuration.list.databaseProxy.title'),\n        key: 'databaseProxy' as const,\n        isRequired: true,\n        isComplete: Boolean(publicDatabaseProxy),\n        group: 'system' as const,\n        path: '/admin/setting',\n      },\n      {\n        title: t('admin.configuration.list.llmApi.title'),\n        key: 'llmApi' as const,\n        isRequired: true,\n        isComplete: (() => {\n          const aiConfig = setting?.aiConfig;\n          const hasLlmApi =\n            Boolean(aiConfig?.aiGatewayApiKey) || (aiConfig?.llmProviders?.length ?? 0) > 0;\n          const hasModelPool = aiConfig?.aiGatewayApiKey\n            ? (aiConfig?.gatewayModels ?? []).some((m) => m.enabled)\n            : (aiConfig?.llmProviders?.length ?? 0) > 0;\n          const hasChatModel = Boolean(aiConfig?.chatModel?.lg);\n          return hasLlmApi && hasModelPool && hasChatModel;\n        })(),\n        group: 'ai' as const,\n        path: '/admin/ai-setting?anchor=llm',\n      },\n      {\n        title: t('admin.configuration.list.app.title'),\n        key: 'app' as const,\n        isRequired: true,\n        isComplete: Boolean(setting?.appConfig?.apiKey),\n        group: 'appBuilder' as const,\n        path: '/admin/ai-setting?anchor=app',\n      },\n      {\n        title: t('admin.configuration.list.email.title'),\n        key: 'email' as const,\n        anchor: emailRef,\n        isRequired: true,\n        isComplete: Boolean(setting?.notifyMailTransportConfig),\n        group: 'system' as const,\n        path: '/admin/setting?anchor=email',\n      },\n    ],\n    [\n      isHydrated,\n      publicDatabaseProxy,\n      publicOrigin,\n      setting?.aiConfig,\n      setting?.appConfig,\n      setting?.notifyMailTransportConfig,\n      t,\n    ]\n  );\n\n  const router = useRouter();\n\n  useEffect(() => {\n    const { anchor } = router.query;\n    if (anchor === 'email') {\n      setTimeout(() => {\n        emailRef.current && scrollToTarget(emailRef.current);\n      }, 500);\n    }\n  }, [router.query]);\n\n  const finalList = todoLists;\n\n  if (!setting || !isHydrated) return null;\n\n  const {\n    instanceId,\n    disallowSignUp,\n    disallowSpaceCreation,\n    disallowSpaceInvitation,\n    enableEmailVerification,\n    enableWaitlist,\n    brandName,\n    brandLogo,\n  } = setting;\n\n  return (\n    <div className=\"flex h-screen flex-1 flex-col overflow-y-auto overflow-x-hidden p-4 sm:p-8\">\n      <div className=\"pb-6\">\n        <h1 className=\"text-2xl font-semibold\">{t('settings.title')}</h1>\n        <div className=\"mt-2 text-sm text-muted-foreground\">{t('admin.setting.description')}</div>\n      </div>\n\n      <div className=\"relative flex flex-1 flex-col overflow-hidden sm:flex-row\">\n        <div className=\"setting-page-left-container flex-1 overflow-y-auto overflow-x-hidden sm:pr-10\">\n          {/* General Settings Section */}\n          <div className=\"pb-6\">\n            <h2 className=\"mb-4 text-lg font-medium\">{t('admin.setting.generalSettings')}</h2>\n            <div className=\"flex w-full flex-col space-y-4\">\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label htmlFor=\"allow-sign-up\">{t('admin.setting.allowSignUp')}</Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {t('admin.setting.allowSignUpDescription')}\n                  </div>\n                </div>\n                <Switch\n                  id=\"allow-sign-up\"\n                  checked={!disallowSignUp}\n                  onCheckedChange={(checked) => onValueChange('disallowSignUp', !checked)}\n                />\n              </div>\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label htmlFor=\"allow-space-invitation\">\n                    {t('admin.setting.allowSpaceInvitation')}\n                  </Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {t('admin.setting.allowSpaceInvitationDescription')}\n                  </div>\n                </div>\n                <Switch\n                  id=\"allow-space-invitation\"\n                  checked={!disallowSpaceInvitation}\n                  onCheckedChange={(checked) => onValueChange('disallowSpaceInvitation', !checked)}\n                />\n              </div>\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label htmlFor=\"allow-space-creation\">\n                    {t('admin.setting.allowSpaceCreation')}\n                  </Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {t('admin.setting.allowSpaceCreationDescription')}\n                  </div>\n                </div>\n                <Switch\n                  id=\"allow-space-creation\"\n                  checked={!disallowSpaceCreation}\n                  onCheckedChange={(checked) => onValueChange('disallowSpaceCreation', !checked)}\n                />\n              </div>\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label htmlFor=\"enable-email-verification\">\n                    {t('admin.setting.enableEmailVerification')}\n                  </Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {t('admin.setting.enableEmailVerificationDescription')}\n                  </div>\n                </div>\n                <Switch\n                  id=\"enable-email-verification\"\n                  checked={Boolean(enableEmailVerification)}\n                  onCheckedChange={(checked) => onValueChange('enableEmailVerification', checked)}\n                />\n              </div>\n            </div>\n          </div>\n\n          {isCloud && (\n            <div className=\"pb-6\">\n              <h2 className=\"mb-4 text-lg font-medium\">{t('waitlist.title')}</h2>\n              <div className=\"flex flex-col gap-4 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"flex items-center justify-between \">\n                  <div className=\"space-y-1\">\n                    <Label htmlFor=\"enable-waitlist\">{t('admin.setting.enableWaitlist')}</Label>\n                    <div className=\"text-xs text-muted-foreground\">\n                      {t('admin.setting.enableWaitlistDescription')}\n                    </div>\n                  </div>\n                  <Switch\n                    id=\"enable-waitlist\"\n                    checked={Boolean(enableWaitlist)}\n                    onCheckedChange={(checked) => onValueChange('enableWaitlist', checked)}\n                  />\n                </div>\n                {enableWaitlist && (\n                  <>\n                    <div className=\"flex items-center justify-between \">\n                      <div className=\"space-y-1\">\n                        <Label htmlFor=\"enable-waitlist\">{t('waitlist.title')}</Label>\n                      </div>\n                      <WaitlistManage />\n                    </div>\n\n                    <div className=\"flex items-center justify-between \">\n                      <div className=\"space-y-1\">\n                        <Label htmlFor=\"enable-waitlist\">{t('waitlist.generateCode')}</Label>\n                      </div>\n                      <InviteCodeManage />\n                    </div>\n                  </>\n                )}\n              </div>\n            </div>\n          )}\n\n          {rewardManage}\n\n          <CanarySettings setting={setting} />\n\n          {/* email config */}\n          <div className=\"pb-6\" ref={emailRef}>\n            <h2 className=\"mb-4 text-lg font-medium\">{t('email.config')}</h2>\n            <div className=\"flex w-full flex-col space-y-4\">\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label>{t('email.notify')}</Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {setting.notifyMailTransportConfig\n                      ? setting.notifyMailTransportConfig.host\n                      : t('email.customNotifyConfig')}\n                  </div>\n                </div>\n                <div className=\"flex gap-1\">\n                  {setting.notifyMailTransportConfig && (\n                    <Button\n                      variant=\"outline\"\n                      size=\"icon\"\n                      onClick={() => onValueChange(SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG, null)}\n                    >\n                      <RotateCcwIcon className=\"size-4\" />\n                    </Button>\n                  )}\n                  <MailConfigDialog\n                    name={SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG}\n                    emailConfig={setting.notifyMailTransportConfig ?? undefined}\n                  />\n                </div>\n              </div>\n\n              <div className=\"flex items-center justify-between space-x-2 rounded-lg border bg-card p-4 shadow-sm\">\n                <div className=\"space-y-1\">\n                  <Label>{t('email.automation')}</Label>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {setting.automationMailTransportConfig\n                      ? setting.automationMailTransportConfig.host\n                      : t('email.customAutomationConfig')}\n                  </div>\n                </div>\n                <div className=\"flex gap-1\">\n                  {setting.automationMailTransportConfig && (\n                    <Button\n                      variant=\"outline\"\n                      size=\"icon\"\n                      onClick={() =>\n                        onValueChange(SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG, null)\n                      }\n                    >\n                      <RotateCcwIcon className=\"size-4\" />\n                    </Button>\n                  )}\n                  <MailConfigDialog\n                    name={SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG}\n                    emailConfig={setting.automationMailTransportConfig ?? undefined}\n                  />\n                </div>\n              </div>\n            </div>\n            {!setting.notifyMailTransportConfig && (\n              <div className=\"pt-2 text-xs text-destructive\">\n                {t('admin.configuration.list.email.errorTips')}\n              </div>\n            )}\n          </div>\n\n          {/* Branding Settings Section */}\n          {instanceUsage?.level === BillingProductLevel.Enterprise && (\n            <Branding\n              brandName={brandName}\n              brandLogo={brandLogo}\n              onChange={(brandName) => onValueChange('brandName', brandName)}\n            />\n          )}\n\n          <CopyInstance instanceId={instanceId} />\n        </div>\n        {finalList.length > 0 && <ConfigurationList list={finalList as IList[]} />}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/Branding.tsx",
    "content": "import { Label, sonner } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport { BrandingLogo } from './BrandingLogo';\n\nexport const Branding = ({\n  brandName,\n  brandLogo,\n  onChange,\n}: {\n  brandName?: string | null;\n  brandLogo?: string | null;\n  onChange: (brandName: string) => void;\n}) => {\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const [name, setName] = useState(brandName || '');\n\n  return (\n    <div className=\"pb-6\">\n      <h2 className=\"mb-4 text-lg font-medium\">{t('admin.setting.brandingSettings.title')}</h2>\n      <div className=\"flex w-full flex-col space-y-4\">\n        <div className=\"space-y-2 rounded-lg border p-4 shadow-sm\">\n          <p className=\"text-xs text-gray-500\">{t('admin.setting.brandingSettings.description')}</p>\n          <div className=\"flex items-center justify-between\">\n            <Label htmlFor=\"brand-name\">{t('admin.setting.brandingSettings.brandName')}</Label>\n            <input\n              id=\"brand-name\"\n              type=\"text\"\n              className=\"rounded-md border px-3 py-2\"\n              placeholder=\"Teable\"\n              value={name}\n              onChange={(e) => {\n                setName(e.target.value);\n              }}\n              onBlur={() => {\n                onChange(name);\n                sonner.toast(t('common:actions.saveSucceed'));\n              }}\n            />\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <Label htmlFor=\"brand-logo\">{t('admin.setting.brandingSettings.logo')}</Label>\n            <BrandingLogo value={brandLogo || undefined} />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/BrandingLogo.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { useMutation } from '@tanstack/react-query';\nimport { Plus } from '@teable/icons';\nimport { uploadLogo } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\n\nexport const BrandingLogo = (props: { value?: string }) => {\n  const { value } = props;\n  const [logoUrl, setLogoUrl] = useState(value);\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const fileInput = useRef<HTMLInputElement>(null);\n\n  const { mutate: uploadLogoMutation, isPending: isLoading } = useMutation({\n    mutationFn: async (file: File) => {\n      const formData = new FormData();\n      formData.append('file', file);\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      return uploadLogo(formData as any);\n    },\n    onSuccess: (res) => {\n      if (res.data.url) {\n        console.log('res.data.url', res.data.url);\n        setLogoUrl(res.data.url + '?v=' + Date.now());\n      }\n    },\n  });\n\n  const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    if (!file.type.startsWith('image/')) {\n      toast.warning(t('common:noun.unknownError'));\n      return;\n    }\n    uploadLogoMutation(file);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center gap-3\">\n        <input\n          type=\"file\"\n          className=\"hidden\"\n          accept=\"image/*\"\n          ref={fileInput}\n          onChange={handleLogoChange}\n        />\n        <div\n          className=\"group relative flex h-fit items-center justify-center\"\n          onClick={() => fileInput.current?.click()}\n        >\n          {logoUrl ? (\n            <div className=\"relative size-14 overflow-hidden rounded-md border border-border\">\n              <img src={logoUrl} alt=\"logo\" className=\"absolute inset-0 size-full object-contain\" />\n            </div>\n          ) : (\n            <div className=\"flex size-14 items-center justify-center rounded-md border border-border\">\n              {isLoading ? <Spin /> : <Plus className=\"size-8 text-foreground\" />}\n            </div>\n          )}\n          <div className=\"absolute left-0 top-0 size-full rounded-md bg-transparent group-hover:bg-muted-foreground/20\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ConfigurationList.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { Check, AlertCircle } from '@teable/icons';\nimport { cn, Progress } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { Trans, useTranslation } from 'next-i18next';\nimport type { RefObject } from 'react';\nimport { useMemo } from 'react';\nimport { scrollToTarget } from '../utils';\n\nexport interface IList {\n  title: string;\n  key:\n    | 'publicOrigin'\n    | 'https'\n    | 'databaseProxy'\n    | 'llmApi'\n    | 'aiEnable'\n    | 'aiLlmApi'\n    | 'aiModelPool'\n    | 'aiChatModel'\n    | 'app'\n    | 'appBuilderV0'\n    | 'appBuilderDomain'\n    | 'appBuilderApiProxy'\n    | 'email';\n  anchor?: RefObject<HTMLDivElement>;\n  values?: Record<string, string>;\n  path: string;\n  isComplete: boolean;\n  isRequired?: boolean;\n  group?: 'system' | 'ai' | 'appBuilder';\n}\n\nexport interface IConfigurationListProps {\n  list: IList[];\n}\n\nexport const ConfigurationList = (props: IConfigurationListProps) => {\n  const { list } = props;\n  const { t } = useTranslation('common');\n\n  const router = useRouter();\n\n  const requiredList = list.filter((item) => item.isRequired !== false);\n  const requiredComplete = requiredList.filter((item) => item.isComplete).length;\n  const progress = requiredList.length > 0 ? (requiredComplete / requiredList.length) * 100 : 0;\n  const allRequiredComplete = requiredList.length > 0 && requiredComplete === requiredList.length;\n\n  const grouped = useMemo(() => {\n    const groups: Array<{ key: IList['group']; items: IList[] }> = [\n      { key: 'system', items: [] },\n      { key: 'ai', items: [] },\n      { key: 'appBuilder', items: [] },\n    ];\n\n    // Filter out optional items - they shouldn't appear in the pending configuration list\n    const requiredItems = list.filter((item) => item.isRequired !== false);\n\n    for (const item of requiredItems) {\n      const g = item.group ?? 'system';\n      const group = groups.find((x) => x.key === g);\n      (group?.items ?? groups[0].items).push(item);\n    }\n\n    return groups.filter((g) => g.items.length > 0);\n  }, [list]);\n\n  return (\n    <div className=\"flex h-44 w-full min-w-full flex-col space-y-4 overflow-y-auto rounded-lg border bg-secondary p-4 sm:h-auto sm:max-h-[80vh] sm:w-[360px] sm:min-w-[360px]\">\n      <div className=\"flex flex-col\">\n        <span className=\"mb-1 justify-start self-stretch text-base font-semibold text-foreground\">\n          {t('admin.configuration.title')}\n        </span>\n        <span className=\"justify-start self-stretch text-xs text-muted-foreground\">\n          {t('admin.configuration.description')}\n        </span>\n      </div>\n\n      {/* Progress */}\n      {requiredList.length > 0 && (\n        <div className=\"rounded-lg border bg-card p-4\">\n          <div className=\"mb-3 flex items-center justify-between\">\n            <span className=\"text-sm font-medium\">\n              {t('admin.configuration.progressTitle', '配置进度')}\n            </span>\n            <span className=\"text-xs text-muted-foreground\">\n              {requiredComplete}/{requiredList.length}\n            </span>\n          </div>\n          <Progress value={progress} className=\"h-2\" />\n\n          <div\n            className={cn(\n              'mt-3 flex items-start gap-2 rounded-md p-2 text-xs',\n              allRequiredComplete\n                ? 'bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400'\n                : 'bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400'\n            )}\n          >\n            {allRequiredComplete ? (\n              <Check className=\"mt-0.5 size-3 shrink-0\" />\n            ) : (\n              <AlertCircle className=\"mt-0.5 size-3 shrink-0\" />\n            )}\n            <span>\n              {allRequiredComplete\n                ? t('admin.configuration.allComplete', '所有配置已完成')\n                : t('admin.configuration.incomplete', '还有配置未完成')}\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Grouped checklist */}\n      {grouped.map((group: { key: IList['group']; items: IList[] }) => (\n        <div key={group.key ?? 'system'} className=\"space-y-1\">\n          <div className=\"px-2 text-xs font-medium text-muted-foreground\">\n            {t(`admin.configuration.group.${group.key ?? 'system'}` as any)}\n          </div>\n          {group.items.map((item: IList) => (\n            <div key={`${group.key}-${item.title}`} className=\"flex flex-col\">\n              <button\n                type=\"button\"\n                className={cn(\n                  'flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted/40',\n                  item.isComplete && 'opacity-80'\n                )}\n                onClick={() => {\n                  const { path } = item;\n                  if (path && !path.includes(router.pathname)) {\n                    router.push(path);\n                  }\n                  item.anchor?.current && scrollToTarget(item.anchor.current);\n                }}\n              >\n                <div\n                  className={cn(\n                    'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full',\n                    item.isComplete\n                      ? 'bg-green-500 text-white'\n                      : 'border-2 border-muted-foreground/30'\n                  )}\n                >\n                  {item.isComplete ? <Check className=\"size-3\" /> : null}\n                </div>\n\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <span className=\"truncate text-sm font-medium text-foreground\">\n                      {item.title}\n                    </span>\n                  </div>\n\n                  <div className=\"mt-1 text-xs text-muted-foreground\">\n                    {item.isComplete ? (\n                      <span>{t('admin.configuration.completed', '已完成')}</span>\n                    ) : (\n                      <Trans\n                        ns=\"common\"\n                        i18nKey={`admin.configuration.list.${item.key}.description` as any}\n                        values={item.values ?? undefined}\n                        components={{\n                          anchor: (\n                            <span\n                              className=\"cursor-pointer text-blue-500\"\n                              onClick={(e) => {\n                                e.preventDefault();\n                                const { path } = item;\n                                if (path && !path.includes(router.pathname)) {\n                                  router.push(path);\n                                }\n                                item.anchor?.current && scrollToTarget(item.anchor.current);\n                              }}\n                              role=\"button\"\n                              tabIndex={0}\n                              onKeyDown={(e) => {\n                                if (e.key === 'Enter' || e.key === ' ') {\n                                  const { path } = item;\n                                  if (path && !path.includes(router.pathname)) {\n                                    router.push(path);\n                                  }\n                                  item.anchor?.current && scrollToTarget(item.anchor.current);\n                                }\n                              }}\n                            />\n                          ),\n                          strong: <span className=\"font-bold\" />,\n                          underline: <span className=\"underline\" />,\n                          a: (\n                            <Link\n                              className=\"cursor-pointer text-blue-500\"\n                              href={t(`admin.configuration.list.${item.key}.href` as any) as any}\n                              target=\"_blank\"\n                              rel=\"noreferrer\"\n                            />\n                          ),\n                        }}\n                      />\n                    )}\n                  </div>\n                </div>\n              </button>\n            </div>\n          ))}\n        </div>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport { CopyButton } from '@/features/app/components/CopyButton';\n\ninterface ICopyInstanceProps {\n  instanceId: string;\n}\n\nexport const CopyInstance = (props: ICopyInstanceProps) => {\n  const { instanceId } = props;\n  const { t } = useTranslation('common');\n\n  return (\n    <div className=\"flex w-full shrink-0 items-center justify-between gap-x-2 overflow-hidden rounded-md bg-secondary p-4\">\n      <div className=\"flex flex-col gap-y-1\">\n        <span>\n          <span className=\"text-sm font-semibold\">{t('noun.instanceId')} </span>\n          <span className=\"flex-1 truncate text-sm text-muted-foreground\">{instanceId}</span>\n        </span>\n        <p className=\"text-left text-xs text-muted-foreground\">\n          {t('settings.setting.version')}: {process.env.NEXT_PUBLIC_BUILD_VERSION}\n        </p>\n      </div>\n      <CopyButton\n        size=\"xs\"\n        text={instanceId}\n        className=\"bg-surface hover:bg-surface hover:opacity-80\"\n        iconClassName=\"text-foreground\"\n        label={t('admin.configuration.copyInstance')}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AIConfigurationStatus.tsx",
    "content": "import { CheckCircle2, Circle, AlertCircle, ChevronRight } from '@teable/icons';\nimport type { ISettingVo } from '@teable/openapi';\nimport { LLMProviderType } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\nexport type ConfigStatus = 'complete' | 'incomplete' | 'warning';\n\nexport interface IConfigCheckItem {\n  id: string;\n  label: string;\n  status: ConfigStatus;\n  description?: string;\n  onClick?: () => void;\n}\n\ninterface IAIConfigurationStatusProps {\n  aiConfig: ISettingVo['aiConfig'];\n  onNavigate?: (section: string) => void;\n}\n\nexport const AIConfigurationStatus = ({ aiConfig, onNavigate }: IAIConfigurationStatusProps) => {\n  const { t } = useTranslation('common');\n\n  const hasGatewayKey = Boolean(aiConfig?.aiGatewayApiKey);\n  const hasGatewayModels = (aiConfig?.gatewayModels?.filter((m) => m.enabled)?.length ?? 0) > 0;\n  const hasLegacyProviders = (aiConfig?.llmProviders?.length ?? 0) > 0;\n  const hasChatModel = Boolean(aiConfig?.chatModel?.lg);\n  const isEnabled = Boolean(aiConfig?.chatModel?.lg);\n\n  // Check if chat model is a gateway model\n  const chatModelIsGateway = useMemo(() => {\n    if (!aiConfig?.chatModel?.lg) return false;\n    const [type] = aiConfig.chatModel.lg.split('@');\n    return type === LLMProviderType.AI_GATEWAY;\n  }, [aiConfig?.chatModel?.lg]);\n\n  // Determine overall mode\n  const mode = useMemo(() => {\n    if (hasGatewayKey && hasGatewayModels && !hasLegacyProviders) return 'gateway';\n    if (!hasGatewayKey && hasLegacyProviders) return 'provider';\n    if (hasGatewayKey && hasLegacyProviders) return 'hybrid';\n    return 'unconfigured';\n  }, [hasGatewayKey, hasGatewayModels, hasLegacyProviders]);\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const checkItems: IConfigCheckItem[] = useMemo(() => {\n    const items: IConfigCheckItem[] = [];\n\n    // AI Enabled\n    items.push({\n      id: 'ai-enabled',\n      label: t('admin.setting.ai.guide.aiEnabled'),\n      status: isEnabled ? 'complete' : 'incomplete',\n      description: isEnabled\n        ? t('admin.setting.ai.guide.aiEnabledDesc')\n        : t('admin.setting.ai.guide.aiDisabledDesc'),\n      onClick: () => onNavigate?.('enable'),\n    });\n\n    // Gateway Configuration (if using gateway or hybrid)\n    if (hasGatewayKey || mode === 'unconfigured') {\n      items.push({\n        id: 'gateway-key',\n        label: t('admin.setting.ai.guide.gatewayKey'),\n        status: hasGatewayKey ? 'complete' : 'incomplete',\n        description: hasGatewayKey\n          ? t('admin.setting.ai.guide.gatewayKeyConfigured')\n          : t('admin.setting.ai.guide.gatewayKeyMissing'),\n        onClick: () => onNavigate?.('gateway'),\n      });\n\n      if (hasGatewayKey) {\n        items.push({\n          id: 'gateway-models',\n          label: t('admin.setting.ai.guide.gatewayModels'),\n          status: hasGatewayModels ? 'complete' : 'warning',\n          description: hasGatewayModels\n            ? t('admin.setting.ai.guide.gatewayModelsConfigured', {\n                count: aiConfig?.gatewayModels?.filter((m) => m.enabled)?.length ?? 0,\n              })\n            : t('admin.setting.ai.guide.gatewayModelsEmpty'),\n          onClick: () => onNavigate?.('gateway-models'),\n        });\n      }\n    }\n\n    // Legacy Providers (if using provider or hybrid)\n    if (hasLegacyProviders || mode === 'unconfigured') {\n      items.push({\n        id: 'providers',\n        label: t('admin.setting.ai.guide.providers'),\n        status: hasLegacyProviders ? 'complete' : 'incomplete',\n        description: hasLegacyProviders\n          ? t('admin.setting.ai.guide.providersConfigured', {\n              count: aiConfig?.llmProviders?.length ?? 0,\n            })\n          : t('admin.setting.ai.guide.providersEmpty'),\n        onClick: () => onNavigate?.('providers'),\n      });\n    }\n\n    // Chat Model\n    items.push({\n      id: 'chat-model',\n      label: t('admin.setting.ai.guide.chatModel'),\n      status: hasChatModel ? 'complete' : 'incomplete',\n      description: hasChatModel\n        ? chatModelIsGateway\n          ? t('admin.setting.ai.guide.chatModelGateway')\n          : t('admin.setting.ai.guide.chatModelProvider')\n        : t('admin.setting.ai.guide.chatModelMissing'),\n      onClick: () => onNavigate?.('chat-model'),\n    });\n\n    return items;\n  }, [\n    t,\n    isEnabled,\n    hasGatewayKey,\n    hasGatewayModels,\n    hasLegacyProviders,\n    hasChatModel,\n    chatModelIsGateway,\n    mode,\n    aiConfig?.gatewayModels,\n    aiConfig?.llmProviders?.length,\n    onNavigate,\n  ]);\n\n  const allComplete = checkItems.every((item) => item.status === 'complete');\n  const hasWarning = checkItems.some((item) => item.status === 'warning');\n\n  return (\n    <div className=\"rounded-lg border bg-card p-4\">\n      <div className=\"mb-3 flex items-center justify-between\">\n        <h3 className=\"text-sm font-medium text-foreground\">\n          {t('admin.setting.ai.guide.configStatus')}\n        </h3>\n        <div\n          className={cn(\n            'rounded-full px-2 py-0.5 text-xs',\n            allComplete && !hasWarning\n              ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-500'\n              : hasWarning\n                ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'\n                : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'\n          )}\n        >\n          {allComplete && !hasWarning\n            ? t('admin.setting.ai.guide.ready')\n            : hasWarning\n              ? t('admin.setting.ai.guide.needsAttention')\n              : t('admin.setting.ai.guide.incomplete')}\n        </div>\n      </div>\n\n      <div className=\"space-y-2\">\n        {checkItems.map((item) => (\n          <button\n            key={item.id}\n            type=\"button\"\n            onClick={item.onClick}\n            className={cn(\n              'flex w-full items-center gap-3 rounded-md p-2 text-left transition-colors',\n              item.onClick && 'hover:bg-muted/50 cursor-pointer'\n            )}\n          >\n            <div className=\"shrink-0\">\n              {item.status === 'complete' ? (\n                <CheckCircle2 className=\"size-4 text-green-500\" />\n              ) : item.status === 'warning' ? (\n                <AlertCircle className=\"size-4 text-amber-500\" />\n              ) : (\n                <Circle className=\"size-4 text-muted-foreground\" />\n              )}\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"text-sm font-medium text-foreground\">{item.label}</div>\n              {item.description && (\n                <div className=\"truncate text-xs text-muted-foreground\">{item.description}</div>\n              )}\n            </div>\n            {item.onClick && <ChevronRight className=\"size-4 shrink-0 text-muted-foreground\" />}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AIControlCard.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  Label,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { CircleHelp } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo } from 'react';\n\ninterface SwitchListProps {\n  disableActions: string[];\n  instanceDisableActions?: string[];\n  onChange: (value: { disableActions: string[] }) => void;\n}\n\nexport enum AIActions {\n  AIField = 'ai-field',\n  AIChat = 'ai-chat',\n}\n\nconst AIFeatureList = [AIActions.AIField, AIActions.AIChat];\n\nconst SwitchableActions = [AIActions.AIField, AIActions.AIChat];\n\nconst TooltipWrap = ({\n  children,\n  description,\n}: {\n  children: React.ReactNode;\n  description: string;\n}) => {\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>{children}</TooltipTrigger>\n        <TooltipPortal>\n          <TooltipContent>{description}</TooltipContent>\n        </TooltipPortal>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nconst SwitchList = (props: SwitchListProps) => {\n  const { onChange, disableActions, instanceDisableActions = [] } = props;\n  const { t } = useTranslation('common');\n\n  const AIFeatureListNameMap = useMemo(() => {\n    return {\n      [AIActions.AIField]: t('admin.setting.ai.actions.aiField.title'),\n      [AIActions.AIChat]: t('admin.setting.ai.actions.aiChat.title'),\n    };\n  }, [t]);\n\n  const AIFeatureListDescriptionMap = useMemo(() => {\n    return {\n      [AIActions.AIField]: t('admin.setting.ai.actions.aiField.description'),\n      [AIActions.AIChat]: t('admin.setting.ai.actions.aiChat.description'),\n    };\n  }, [t]);\n\n  const AIFeatureListWithOptions = useMemo(() => {\n    return AIFeatureList.map((item) => ({\n      name: AIFeatureListNameMap[item],\n      key: item,\n      description: AIFeatureListDescriptionMap[item],\n      disabled: !SwitchableActions.includes(item) || instanceDisableActions.includes(item),\n    }));\n  }, [AIFeatureListDescriptionMap, AIFeatureListNameMap, instanceDisableActions]);\n\n  const onCheckItemHandler = useCallback(\n    (actionName: AIActions, open: boolean) => {\n      if (open && disableActions.find((action) => action === actionName)) {\n        const index = disableActions.findIndex((action) => action === actionName);\n        if (index !== -1) {\n          const newDisableActions = [...disableActions];\n          newDisableActions.splice(index, 1);\n          onChange({ disableActions: newDisableActions });\n        }\n      }\n\n      if (!open && !disableActions.find((action) => action === actionName)) {\n        const newDisableActions = [...disableActions, actionName];\n        onChange({ disableActions: newDisableActions });\n      }\n    },\n    [disableActions, onChange]\n  );\n\n  return AIFeatureListWithOptions.map(({ name, description, disabled, key }) => (\n    <div className=\"flex items-center justify-between\" key={key}>\n      <div className=\"flex items-center gap-x-1\">\n        <Label\n          htmlFor={key}\n          className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n        >\n          {name}\n        </Label>\n        <TooltipWrap description={description}>\n          <CircleHelp className=\"size-4 cursor-pointer text-muted-foreground\" />\n        </TooltipWrap>\n      </div>\n      <Switch\n        id={key}\n        onCheckedChange={(open) => {\n          onCheckItemHandler(key, open);\n        }}\n        checked={!disableActions?.includes(key) && !instanceDisableActions.includes(key)}\n        disabled={disabled}\n      />\n    </div>\n  ));\n};\n\nexport const AIControlCard = ({\n  disableActions,\n  instanceDisableActions,\n  onChange,\n}: {\n  disableActions: string[];\n  instanceDisableActions?: string[];\n  onChange: (value: { disableActions: string[] }) => void;\n}) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <Card className=\"p-5 shadow-none\">\n      <CardContent className=\"flex flex-col gap-4 p-0\">\n        <p className=\"font-medium\">{t('admin.setting.ai.actions.title')}</p>\n        <div className=\"flex flex-col gap-3\">\n          <SwitchList\n            onChange={onChange}\n            disableActions={disableActions}\n            instanceDisableActions={instanceDisableActions}\n          />\n        </div>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AIModelPreferencesCard.tsx",
    "content": "import type { IAIIntegrationConfig } from '@teable/openapi';\nimport {\n  Button,\n  Card,\n  CardContent,\n  CardHeader,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormDescription,\n  FormControl,\n  FormMessage,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { Control } from 'react-hook-form';\nimport type { IModelOption } from './AiModelSelect';\nimport { AIModelSelect } from './AiModelSelect';\nimport { CodingModels } from './CodingModels';\n\ninterface IAIModelPreferencesCardProps {\n  control: Control<IAIIntegrationConfig>;\n  models: IModelOption[];\n  onChange?: () => void;\n  needGroup?: boolean;\n  hideEmbeddingModel?: boolean;\n  /** Optional header title */\n  title?: string;\n  /** Show a reset button to clear chatModel */\n  onReset?: () => void;\n  /** Custom placeholder for model selector when no model selected */\n  modelPlaceholder?: string;\n}\n\nexport const AIModelPreferencesCard = ({\n  control,\n  models,\n  onChange,\n  needGroup,\n  hideEmbeddingModel,\n  title,\n  onReset,\n  modelPlaceholder,\n}: IAIModelPreferencesCardProps) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <Card className=\"shadow-sm\">\n      {title && (\n        <CardHeader className=\"px-4 pb-0 pt-4\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"text-sm font-semibold\">{title}</div>\n            {onReset && (\n              <Button variant=\"outline\" size=\"xs\" onClick={onReset}>\n                Reset\n              </Button>\n            )}\n          </div>\n        </CardHeader>\n      )}\n      <CardContent className=\"p-4\">\n        <div className=\"space-y-6\">\n          <FormField\n            control={control}\n            name={'chatModel'}\n            render={({ field }) => (\n              <FormItem>\n                <div className=\"flex w-full flex-col justify-between\">\n                  <div className=\"flex flex-1 space-x-2\">\n                    <FormControl className=\"grow \">\n                      <CodingModels\n                        value={field.value}\n                        onChange={(value) => {\n                          field.onChange(value);\n                          onChange?.();\n                        }}\n                        models={models}\n                        needGroup={needGroup}\n                        placeholder={modelPlaceholder}\n                      />\n                    </FormControl>\n                  </div>\n                </div>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          {!hideEmbeddingModel && (\n            <FormField\n              control={control}\n              name=\"embeddingModel\"\n              render={({ field }) => (\n                <FormItem>\n                  <div className=\"flex flex-col items-center justify-between\">\n                    <FormLabel className=\"flex w-full flex-col items-start justify-start gap-2\">\n                      <span>{t('admin.setting.ai.embeddingModel')}</span>\n                      <FormDescription className=\"text-left text-xs text-muted-foreground\">\n                        {t('admin.setting.ai.embeddingModelDescription')}\n                      </FormDescription>\n                    </FormLabel>\n                    <div className=\"flex w-full space-x-2 pt-2\">\n                      <FormControl className=\"grow\">\n                        <AIModelSelect\n                          value={field.value ?? ''}\n                          onValueChange={(value) => {\n                            field.onChange(value);\n                            onChange?.();\n                          }}\n                          options={models}\n                          needGroup={needGroup}\n                        />\n                      </FormControl>\n                    </div>\n                  </div>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          )}\n        </div>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AIProviderCard.tsx",
    "content": "import type {\n  IAIIntegrationConfig,\n  IChatModelAbility,\n  IImageModelAbility,\n  ITestLLMRo,\n  ITestLLMVo,\n  LLMProvider,\n} from '@teable/openapi';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from '@teable/ui-lib/shadcn';\nimport type { ReactNode } from 'react';\nimport type { Control } from 'react-hook-form';\nimport type { IModelTestResult } from './LlmproviderManage';\nimport { LLMProviderManage } from './LlmproviderManage';\n\ninterface IAIProviderCardProps {\n  control: Control<IAIIntegrationConfig>;\n  onChange?: (value: LLMProvider[]) => void;\n  /** Test function - accepts full ITestLLMRo for capability testing */\n  onTest?: (data: ITestLLMRo) => Promise<ITestLLMVo>;\n  modelTestResults?: Map<string, IModelTestResult>;\n  onToggleImageModel?: (modelKey: string, isImageModel: boolean) => void;\n  onTestProvider?: (provider: LLMProvider) => void;\n  onTestModel?: (provider: LLMProvider, model: string, modelKey: string) => Promise<void>;\n  testingProviders?: Set<string>;\n  testingModels?: Set<string>;\n  /** Hide model rates config (for space-level settings where billing doesn't apply) */\n  hideModelRates?: boolean;\n  /** Callback to save model test results */\n  onSaveTestResult?: (\n    modelKey: string,\n    ability: IChatModelAbility | undefined,\n    imageAbility: IImageModelAbility | undefined\n  ) => void;\n  /** Optional header title */\n  title?: string;\n  /** Optional header actions (e.g., BatchTestModels) */\n  headerActions?: ReactNode;\n}\n\nexport const AIProviderCard = ({\n  control,\n  onChange,\n  onTest,\n  modelTestResults,\n  onToggleImageModel,\n  onTestProvider,\n  onTestModel,\n  testingProviders,\n  testingModels,\n  hideModelRates,\n  onSaveTestResult,\n  title,\n  headerActions,\n}: IAIProviderCardProps) => {\n  return (\n    <Card className=\"shadow-sm\">\n      {(title || headerActions) && (\n        <CardHeader className=\"flex flex-row items-center justify-between space-y-0 px-4 pb-0 pt-4\">\n          {title && <div className=\"text-sm font-semibold\">{title}</div>}\n          {headerActions}\n        </CardHeader>\n      )}\n      <CardContent className=\"p-4\">\n        <FormField\n          control={control}\n          name=\"llmProviders\"\n          render={({ field }) => (\n            <FormItem className=\"flex flex-col\">\n              <FormControl>\n                <LLMProviderManage\n                  {...field}\n                  onChange={(value) => onChange?.(value)}\n                  onTest={onTest}\n                  modelTestResults={modelTestResults}\n                  onToggleImageModel={onToggleImageModel}\n                  onTestProvider={onTestProvider}\n                  onTestModel={onTestModel}\n                  testingProviders={testingProviders}\n                  testingModels={testingModels}\n                  hideModelRates={hideModelRates}\n                  onSaveTestResult={onSaveTestResult}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AISetupWizard.tsx",
    "content": "'use client';\n\nimport type { IGatewayModel, ISettingVo, LLMProvider } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport type { ReactNode } from 'react';\nimport { useMemo } from 'react';\n\nexport interface IAISetupStep {\n  id: string;\n  title: string;\n  description: string;\n  isComplete: boolean;\n  isRequired: boolean;\n}\n\ninterface IAISetupWizardProps {\n  children: ReactNode;\n}\n\nexport function AISetupWizard({ children }: IAISetupWizardProps) {\n  // Keep a stable wrapper for future layout needs.\n  useMemo(() => children, [children]);\n  return <div className=\"min-w-0\">{children}</div>;\n}\n\nexport type LLMApiMode = 'gateway' | 'custom';\n\n// Hook to compute step completion status\nexport function useAISetupSteps({\n  aiConfig,\n  gatewayModels,\n  llmProviders,\n  llmApiMode = 'gateway',\n}: {\n  aiConfig?: ISettingVo['aiConfig'];\n  gatewayModels: IGatewayModel[];\n  llmProviders: LLMProvider[];\n  llmApiMode?: LLMApiMode;\n}) {\n  const { t } = useTranslation('common');\n\n  const hasGatewayKey = Boolean(aiConfig?.aiGatewayApiKey);\n  const hasGatewayModels = gatewayModels.filter((m) => m.enabled).length > 0;\n  const hasChatModel = Boolean(aiConfig?.chatModel?.lg);\n  const hasProviders = llmProviders.length > 0;\n\n  // Step 1 is complete if:\n  // - Gateway mode: has Gateway API Key\n  // - Custom mode: has at least one Provider configured\n  const isStep1Complete = llmApiMode === 'gateway' ? hasGatewayKey : hasProviders;\n\n  // Step 2 is complete if:\n  // - Gateway mode: has Gateway models\n  // - Custom mode: has provider models (auto-complete since providers bring models)\n  const isStep2Complete = llmApiMode === 'gateway' ? hasGatewayModels : hasProviders;\n\n  // Unified 3-step structure for both Cloud and EE\n  // Only difference is pricing-related UI which is controlled separately\n  const steps: IAISetupStep[] = useMemo(() => {\n    return [\n      {\n        id: 'llmApi',\n        title: t('admin.setting.ai.wizard.step.llmApi'),\n        description: t('admin.setting.ai.wizard.step.llmApiDesc'),\n        isComplete: isStep1Complete,\n        isRequired: true,\n      },\n      {\n        id: 'models',\n        title: t('admin.setting.ai.wizard.step.modelPool'),\n        description: t('admin.setting.ai.wizard.step.modelPoolDesc'),\n        isComplete: isStep2Complete,\n        isRequired: true,\n      },\n      {\n        id: 'chatModel',\n        title: t('admin.setting.ai.wizard.step.chatModel'),\n        description: t('admin.setting.ai.wizard.step.chatModelDesc'),\n        isComplete: hasChatModel,\n        isRequired: true,\n      },\n    ];\n  }, [isStep1Complete, isStep2Complete, hasChatModel, t]);\n\n  return { steps, hasGatewayKey, hasGatewayModels, hasChatModel, isStep1Complete, isStep2Complete };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiFormWizard.tsx",
    "content": "'use client';\n\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { MessageSquareDot, Zap, Box } from '@teable/icons';\nimport { aiConfigVoSchema } from '@teable/openapi';\nimport type {\n  IGatewayModel,\n  IChatModelAbility,\n  IImageModelAbility,\n  LLMProvider,\n  ISettingVo,\n} from '@teable/openapi';\nimport { Form } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { AISetupWizard, useAISetupSteps, type LLMApiMode } from './AISetupWizard';\nimport { DefaultModelsStep } from './DefaultModelsStep';\nimport { GatewayModelsStep } from './GatewayModelsStep';\nimport { LLMApiConfigStep } from './LLMApiConfigStep';\nimport type { IModelTestResult } from './LlmproviderManage';\nimport { SetupStepCard } from './SetupStepCard';\nimport { generateModelKeyList, generateGatewayModelKeyList, parseModelKey } from './utils';\n\n// Props to control whether to show pricing-related UI\ninterface IAIConfigFormWizardProps {\n  aiConfig: ISettingVo['aiConfig'];\n  setAiConfig: (data: NonNullable<ISettingVo['aiConfig']>) => void;\n  /** Whether to show pricing/billing related UI. Defaults to isCloud. */\n  showPricing?: boolean;\n}\n\nexport function AIConfigFormWizard({\n  aiConfig,\n  setAiConfig,\n  showPricing,\n}: IAIConfigFormWizardProps) {\n  const isCloud = useIsCloud();\n  // showPricing defaults to isCloud if not explicitly provided\n  const shouldShowPricing = showPricing ?? isCloud;\n  const defaultValues = useMemo(\n    () =>\n      aiConfig ?? {\n        llmProviders: [],\n        gatewayModels: [],\n      },\n    [aiConfig]\n  );\n\n  const form = useForm<NonNullable<ISettingVo['aiConfig']>>({\n    resolver: zodResolver(aiConfigVoSchema),\n    defaultValues: defaultValues,\n  });\n\n  const llmProviders = form.watch('llmProviders') ?? [];\n  const gatewayModels = form.watch('gatewayModels') ?? [];\n  const chatModel = form.watch('chatModel');\n  const providerModels = generateModelKeyList(llmProviders);\n  const gatewayModelsList = generateGatewayModelKeyList(gatewayModels);\n\n  const { reset } = form;\n  const { t } = useTranslation(['common', 'space']);\n\n  const [modelTestResults, setModelTestResults] = useState<Map<string, IModelTestResult>>(\n    new Map()\n  );\n  const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set());\n  const [testingModels, setTestingModels] = useState<Set<string>>(new Set());\n  const testProviderCallbackRef = useRef<((provider: LLMProvider) => void) | null>(null);\n  const testModelCallbackRef = useRef<\n    ((provider: LLMProvider, model: string, modelKey: string) => Promise<void>) | null\n  >(null);\n\n  // LLM API mode: gateway or custom\n  // Auto-detect initial mode based on existing config\n  const [llmApiMode, setLlmApiModeRaw] = useState<LLMApiMode>(() => {\n    if (aiConfig?.aiGatewayApiKey) return 'gateway';\n    if (llmProviders.length > 0) return 'custom';\n    return 'gateway'; // Default to gateway\n  });\n\n  const setLlmApiMode = useCallback((mode: LLMApiMode) => {\n    setLlmApiModeRaw(mode);\n  }, []);\n\n  const handleResetGateway = useCallback(() => {\n    const clearedConfig: NonNullable<ISettingVo['aiConfig']> = {\n      enable: false,\n      llmProviders: [],\n      gatewayModels: [],\n      aiGatewayApiKey: null,\n      aiGatewayBaseUrl: null,\n      chatModel: null,\n      attachmentTest: null,\n      attachmentTransferMode: null,\n    };\n    form.reset(clearedConfig);\n    setAiConfig(clearedConfig);\n  }, [form, setAiConfig]);\n\n  // Current step state\n  // Default collapsed on page load, user can expand steps manually.\n  const [currentStep, setCurrentStep] = useState(-1);\n\n  // Compute step completion status\n  const { hasGatewayKey, isStep1Complete, isStep2Complete } = useAISetupSteps({\n    aiConfig,\n    gatewayModels,\n    llmProviders,\n    llmApiMode,\n  });\n\n  // Models available for Step 3 Chat Model selection\n  // Strictly comes from Step 2's \"model pool\"\n  const availableModels = useMemo(() => {\n    if (llmApiMode === 'gateway') {\n      return gatewayModelsList;\n    }\n    return providerModels;\n  }, [llmApiMode, gatewayModelsList, providerModels]);\n\n  useEffect(() => {\n    reset(defaultValues);\n  }, [defaultValues, reset]);\n\n  const onSubmit = useCallback(\n    (data: NonNullable<ISettingVo['aiConfig']>) => {\n      console.log('onSubmit', data);\n      setAiConfig(data);\n      toast.success(t('admin.setting.ai.configUpdated'));\n    },\n    [setAiConfig, t]\n  );\n\n  const updateProviders = useCallback(\n    (providers: LLMProvider[]) => {\n      form.setValue('llmProviders', providers);\n      form.trigger('llmProviders');\n      onSubmit(form.getValues());\n    },\n    [form, onSubmit]\n  );\n\n  const updateGatewayModels = useCallback(\n    (models: IGatewayModel[]) => {\n      form.setValue('gatewayModels', models);\n      onSubmit(form.getValues());\n    },\n    [form, onSubmit]\n  );\n\n  const updateChatModel = useCallback(\n    (chatModel: { lg?: string; md?: string; sm?: string }) => {\n      form.setValue('chatModel', chatModel);\n      onSubmit(form.getValues());\n    },\n    [form, onSubmit]\n  );\n\n  const onSaveTestResult = useCallback(\n    (\n      modelKey: string,\n      ability: IChatModelAbility | undefined,\n      imageAbility: IImageModelAbility | undefined\n    ) => {\n      const parsed = parseModelKey(modelKey);\n      if (!parsed.type || !parsed.model || !parsed.name) return;\n\n      const { type, model, name } = parsed;\n      const currentProviders = form.getValues('llmProviders') ?? [];\n      const providerIndex = currentProviders.findIndex((p) => p.type === type && p.name === name);\n\n      if (providerIndex === -1) return;\n\n      const provider = currentProviders[providerIndex];\n      const updatedProvider = {\n        ...provider,\n        modelConfigs: {\n          ...provider.modelConfigs,\n          [model]: {\n            ...provider.modelConfigs?.[model],\n            ability,\n            imageAbility,\n            testedAt: Date.now(),\n          },\n        },\n      };\n\n      const newProviders = [...currentProviders];\n      newProviders[providerIndex] = updatedProvider;\n\n      form.setValue('llmProviders', newProviders);\n      setAiConfig(form.getValues());\n    },\n    [form, setAiConfig]\n  );\n\n  const onToggleImageModel = useCallback(\n    (modelKey: string, isImageModel: boolean) => {\n      const parsed = parseModelKey(modelKey);\n      if (!parsed.type || !parsed.model || !parsed.name) return;\n\n      const { type, model, name } = parsed;\n      const currentProviders = form.getValues('llmProviders') ?? [];\n      const providerIndex = currentProviders.findIndex((p) => p.type === type && p.name === name);\n\n      if (providerIndex === -1) return;\n\n      const provider = currentProviders[providerIndex];\n      const updatedProvider = {\n        ...provider,\n        modelConfigs: {\n          ...provider.modelConfigs,\n          [model]: {\n            ...provider.modelConfigs?.[model],\n            isImageModel,\n            ability: isImageModel ? undefined : provider.modelConfigs?.[model]?.ability,\n            imageAbility: isImageModel ? provider.modelConfigs?.[model]?.imageAbility : undefined,\n          },\n        },\n      };\n\n      const newProviders = [...currentProviders];\n      newProviders[providerIndex] = updatedProvider;\n\n      form.setValue('llmProviders', newProviders);\n      setAiConfig(form.getValues());\n    },\n    [form, setAiConfig]\n  );\n\n  // Handler for updating gateway-related fields in aiConfig\n  const updateAiConfig = useCallback(\n    (updates: Partial<NonNullable<ISettingVo['aiConfig']>>) => {\n      const currentValues = form.getValues();\n      const updatedConfig = { ...currentValues, ...updates };\n      // Update form values\n      Object.entries(updates).forEach(([key, value]) => {\n        form.setValue(key as keyof typeof updates, value);\n      });\n      // Save to backend\n      setAiConfig(updatedConfig);\n    },\n    [form, setAiConfig]\n  );\n\n  // Unified wizard view for both Cloud and EE\n  // The only difference is `shouldShowPricing` controls whether to display pricing UI\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)}>\n        <AISetupWizard>\n          <div className=\"space-y-4\">\n            {/* Step 1: Configure LLM API (Gateway OR Custom Provider) */}\n            <SetupStepCard\n              icon={<Zap className=\"size-4\" />}\n              title={t('admin.setting.ai.wizard.step.llmApi')}\n              description={t('admin.setting.ai.wizard.step.llmApiDesc')}\n              isComplete={isStep1Complete}\n              isExpanded={currentStep === 0}\n              onToggle={() => setCurrentStep(currentStep === 0 ? -1 : 0)}\n            >\n              <LLMApiConfigStep\n                mode={llmApiMode}\n                onModeChange={setLlmApiMode}\n                aiConfig={form.getValues()}\n                onAiConfigChange={updateAiConfig}\n                onResetGateway={handleResetGateway}\n                llmProviders={llmProviders}\n                onProvidersChange={updateProviders}\n                control={form.control}\n                modelTestResults={modelTestResults}\n                onModelTestResultsChange={setModelTestResults}\n                testingProviders={testingProviders}\n                onTestingProvidersChange={setTestingProviders}\n                testingModels={testingModels}\n                onTestingModelsChange={setTestingModels}\n                onSaveTestResult={onSaveTestResult}\n                onToggleImageModel={onToggleImageModel}\n                testProviderCallbackRef={testProviderCallbackRef}\n                testModelCallbackRef={testModelCallbackRef}\n                onComplete={() => setCurrentStep(1)}\n                showPricing={shouldShowPricing}\n              />\n            </SetupStepCard>\n\n            {/* Step 2: Configure Model Pool */}\n            <SetupStepCard\n              icon={<Box className=\"size-4\" />}\n              title={t('admin.setting.ai.wizard.step.modelPool')}\n              description={t('admin.setting.ai.wizard.step.modelPoolDesc')}\n              isComplete={isStep2Complete}\n              isExpanded={currentStep === 1}\n              onToggle={() => setCurrentStep(currentStep === 1 ? -1 : 1)}\n              disabled={!isStep1Complete}\n            >\n              {llmApiMode === 'gateway' ? (\n                <GatewayModelsStep\n                  gatewayModels={gatewayModels}\n                  onChange={updateGatewayModels}\n                  disabled={!hasGatewayKey}\n                  apiKey={form.getValues().aiGatewayApiKey ?? undefined}\n                  showPricing={shouldShowPricing}\n                />\n              ) : (\n                <div className=\"space-y-4\">\n                  <div className=\"rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-500/10 dark:text-green-400\">\n                    <p>{t('admin.setting.ai.wizard.customModelsAutoImported')}</p>\n                    <p className=\"mt-1 font-medium\">\n                      {t('admin.setting.ai.wizard.modelsCount', {\n                        count: providerModels.length,\n                      })}\n                    </p>\n                  </div>\n                  <p className=\"text-sm text-muted-foreground\">\n                    {t('admin.setting.ai.wizard.customModelsHint')}\n                  </p>\n                </div>\n              )}\n            </SetupStepCard>\n\n            {/* Step 3: Set Chat Model */}\n            <SetupStepCard\n              icon={<MessageSquareDot className=\"size-4\" />}\n              title={t('admin.setting.ai.wizard.step.chatModel')}\n              description={t('admin.setting.ai.wizard.step.chatModelDesc')}\n              isComplete={Boolean(chatModel?.lg)}\n              isExpanded={currentStep === 2}\n              onToggle={() => setCurrentStep(currentStep === 2 ? -1 : 2)}\n              disabled={!isStep2Complete}\n            >\n              <DefaultModelsStep\n                chatModel={chatModel ?? undefined}\n                models={availableModels}\n                onChange={updateChatModel}\n                disabled={!isStep2Complete}\n              />\n            </SetupStepCard>\n          </div>\n        </AISetupWizard>\n      </form>\n    </Form>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/AiModelSelect.tsx",
    "content": "'use client';\n\nimport { Plus } from '@teable/icons';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  ScrollArea,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useCallback, useMemo, useState } from 'react';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport {\n  GatewayModelOption,\n  ModelSelectTrigger,\n  ProviderModelOption,\n  useGatewayModels,\n  useModelCategories,\n} from './ai-model-select';\nimport type { IAIModelSelectProps, IModelOption } from './ai-model-select/types';\nimport type { IPickerModel } from './GatewayModelPickerDialog';\nimport { GatewayModelPickerDialog } from './GatewayModelPickerDialog';\n\n// Re-export types for backward compatibility\nexport type { IModelOption } from './ai-model-select/types';\n\nexport function AIModelSelect({\n  value = '',\n  onValueChange: setValue,\n  size = 'default',\n  className,\n  options = [],\n  disabled,\n  modelDefinationMap,\n  needGroup,\n  children,\n  onlyImageOutput = false,\n  placeholder,\n}: IAIModelSelectProps) {\n  const isCloud = useIsCloud();\n  const { t } = useTranslation('common');\n\n  const [open, setOpen] = useState(false);\n  const [pickerOpen, setPickerOpen] = useState(false);\n\n  // Use custom hooks for gateway models and model categories\n  const {\n    isLoadingGateway,\n    gatewayConfigured,\n    pickerModels,\n    selectedModelIdForPicker,\n    findGatewayModel,\n  } = useGatewayModels({\n    needGroup,\n    onlyImageOutput,\n    value,\n    options,\n  });\n\n  const { gatewayOptions, spaceOptions, instanceOptions } = useModelCategories({\n    options,\n    onlyImageOutput,\n    modelDefinationMap,\n  });\n\n  // Find current model\n  const currentModel = useMemo(() => findGatewayModel(value), [findGatewayModel, value]);\n\n  // Handle model selection\n  const handleSelect = useCallback(\n    (modelKey: string, isSelected: boolean) => {\n      setValue(isSelected ? '' : modelKey);\n      setOpen(false);\n    },\n    [setValue]\n  );\n\n  // Handle model selection from picker dialog\n  const handlePickerModelSelect = useCallback(\n    (model: IPickerModel) => {\n      const modelKey = `aiGateway@${model.id}@teable`;\n      setValue(modelKey);\n      setPickerOpen(false);\n      setOpen(false);\n    },\n    [setValue]\n  );\n\n  // Check if model is selected\n  const isModelSelected = useCallback(\n    (modelKey: string) => value.toLowerCase() === modelKey.toLowerCase(),\n    [value]\n  );\n\n  // Render gateway model options\n  const renderGatewayOptions = (\n    options: IModelOption[],\n    showGroupHeading = false,\n    showSeparator = false\n  ) => {\n    const content = options.map((option) => (\n      <GatewayModelOption\n        key={option.modelKey}\n        option={option}\n        isSelected={isModelSelected(option.modelKey)}\n        showPrice={isCloud}\n        onSelect={handleSelect}\n      />\n    ));\n\n    if (showGroupHeading) {\n      return (\n        <Fragment>\n          {showSeparator && <CommandSeparator />}\n          <CommandGroup heading={<span>{t('admin.setting.ai.recommended')}</span>}>\n            {content}\n          </CommandGroup>\n        </Fragment>\n      );\n    }\n\n    if (!options.length) return null;\n    return content;\n  };\n\n  // Render space model options\n  const renderSpaceOptions = (options: IModelOption[], showSeparator = false) => {\n    if (!options.length) return null;\n\n    return (\n      <Fragment>\n        {showSeparator && <CommandSeparator />}\n        <CommandGroup heading={t('noun.space')}>\n          {options.map((option) => (\n            <ProviderModelOption\n              key={option.modelKey}\n              option={option}\n              isSelected={isModelSelected(option.modelKey)}\n              onSelect={handleSelect}\n            />\n          ))}\n        </CommandGroup>\n      </Fragment>\n    );\n  };\n\n  // Render instance model options\n  const renderInstanceOptions = (options: IModelOption[]) => {\n    if (!options.length) return null;\n\n    return (\n      <Fragment>\n        <CommandSeparator />\n        <CommandGroup\n          heading={<div className=\"flex items-center\">{t('settings.setting.system')}</div>}\n        >\n          {options.map((option) => (\n            <ProviderModelOption\n              key={option.modelKey}\n              option={option}\n              isSelected={isModelSelected(option.modelKey)}\n              onSelect={handleSelect}\n              modelDefinationMap={modelDefinationMap}\n              t={t}\n              showPriceInfo\n            />\n          ))}\n        </CommandGroup>\n      </Fragment>\n    );\n  };\n\n  // Render all provider options (space + instance) for non-grouped view\n  const renderProviderOptions = (spaceOpts: IModelOption[], instanceOpts: IModelOption[]) => {\n    const allOptions = [...spaceOpts, ...instanceOpts];\n    if (!allOptions.length) return null;\n\n    return allOptions.map((option) => (\n      <ProviderModelOption\n        key={option.modelKey}\n        option={option}\n        isSelected={isModelSelected(option.modelKey)}\n        onSelect={handleSelect}\n      />\n    ));\n  };\n\n  const hasAnyOptions =\n    gatewayOptions.length > 0 || spaceOptions.length > 0 || instanceOptions.length > 0;\n\n  return (\n    <>\n      <Popover open={open} onOpenChange={setOpen} modal>\n        <PopoverTrigger asChild disabled={disabled}>\n          {children ?? (\n            <ModelSelectTrigger\n              currentModel={currentModel}\n              value={value}\n              size={size}\n              className={className}\n              open={open}\n              placeholder={placeholder}\n            />\n          )}\n        </PopoverTrigger>\n        <PopoverContent className=\"w-[--radix-popover-trigger-width] p-0\">\n          <Command>\n            <CommandInput placeholder={t('admin.setting.ai.searchModel')} />\n            <CommandEmpty>{t('admin.setting.ai.noModelFound')}</CommandEmpty>\n            <ScrollArea className=\"w-full\">\n              <div className=\"max-h-[500px]\">\n                <CommandList>\n                  {needGroup ? (\n                    <Fragment>\n                      {renderSpaceOptions(spaceOptions, false)}\n                      {renderGatewayOptions(gatewayOptions, true, !!spaceOptions.length)}\n                      {renderInstanceOptions(instanceOptions)}\n                    </Fragment>\n                  ) : (\n                    <Fragment>\n                      {renderGatewayOptions(gatewayOptions)}\n                      {renderProviderOptions(spaceOptions, instanceOptions)}\n                    </Fragment>\n                  )}\n                </CommandList>\n              </div>\n            </ScrollArea>\n            {needGroup && gatewayConfigured === true && (\n              <Fragment>\n                {hasAnyOptions && <CommandSeparator />}\n                <CommandItem\n                  className=\"flex items-center justify-center gap-2 text-[13px] text-muted-foreground\"\n                  onSelect={() => {\n                    setPickerOpen(true);\n                  }}\n                >\n                  <Plus className=\"size-4\" />\n                  {t('admin.setting.ai.moreModels')}\n                  {!isLoadingGateway && pickerModels.length > 0 && (\n                    <span className=\"text-xs text-muted-foreground\">({pickerModels.length})</span>\n                  )}\n                </CommandItem>\n              </Fragment>\n            )}\n          </Command>\n        </PopoverContent>\n      </Popover>\n\n      {/* Gateway Model Picker Dialog */}\n      <GatewayModelPickerDialog\n        open={pickerOpen}\n        onOpenChange={setPickerOpen}\n        models={pickerModels}\n        isLoading={isLoadingGateway}\n        selectedModelId={selectedModelIdForPicker}\n        onSelectModel={handlePickerModelSelect}\n        priceMode={isCloud ? 'credits' : 'none'}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/BatchTestModels.tsx",
    "content": "import { Loader2, Play, Square } from '@teable/icons';\nimport { chatModelAbilityType } from '@teable/openapi';\nimport type {\n  IChatModelAbility,\n  IImageModelAbility,\n  ITestLLMRo,\n  ITestLLMVo,\n  LLMProvider,\n} from '@teable/openapi';\nimport { Button, Progress } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useCallback, useRef, useEffect } from 'react';\nimport type { IModelTestResult } from './LlmproviderManage';\nimport { generateModelKeyList, parseModelKey } from './utils';\n\ninterface IBatchTestModelsProps {\n  providers: LLMProvider[];\n  disabled?: boolean;\n  /** Test function - accepts full ITestLLMRo for capability testing */\n  onTest: (data: ITestLLMRo) => Promise<ITestLLMVo>;\n  onResultsChange?: (results: Map<string, IModelTestResult>) => void;\n  onSaveResult?: (\n    modelKey: string,\n    ability: IChatModelAbility | undefined,\n    imageAbility: IImageModelAbility | undefined\n  ) => void;\n  onTestingProvidersChange?: (testingProviders: Set<string>) => void;\n  onTestingModelsChange?: (testingModels: Set<string>) => void;\n  onTestProvider?: (callback: (provider: LLMProvider) => void) => void;\n  onTestModel?: (\n    callback: (provider: LLMProvider, model: string, modelKey: string) => Promise<void>\n  ) => void;\n}\n\nconst CONCURRENCY = 5;\nconst TEXT_MODEL_TIMEOUT_MS = 30000; // 30 seconds timeout for text models\nconst IMAGE_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for image models\n\n// Helper to wrap promise with timeout\nconst withTimeout = <T,>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> => {\n  return Promise.race([\n    promise,\n    new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),\n  ]);\n};\n\nexport const BatchTestModels = ({\n  providers,\n  disabled,\n  onTest,\n  onResultsChange,\n  onSaveResult,\n  onTestingProvidersChange,\n  onTestingModelsChange,\n  onTestProvider,\n  onTestModel,\n}: IBatchTestModelsProps) => {\n  const { t } = useTranslation('common');\n  const [isRunning, setIsRunning] = useState(false);\n  const [progress, setProgress] = useState({ current: 0, total: 0 });\n  const abortRef = useRef(false);\n  const resultsRef = useRef<Map<string, IModelTestResult>>(new Map());\n  const isRunningRef = useRef(false);\n  const initializedRef = useRef(false);\n  const testingProvidersRef = useRef<Set<string>>(new Set());\n  const testingModelsRef = useRef<Set<string>>(new Set());\n\n  // Build model list from providers\n  const modelList = generateModelKeyList(providers);\n\n  // Load persisted results only on initial mount\n  useEffect(() => {\n    // Skip if test is running or already initialized\n    if (isRunningRef.current || initializedRef.current) return;\n\n    initializedRef.current = true;\n    const initialResults = new Map<string, IModelTestResult>();\n    providers.forEach((provider) => {\n      const models =\n        provider.models\n          ?.split(',')\n          .map((m) => m.trim())\n          .filter(Boolean) || [];\n      models.forEach((model) => {\n        const modelKey = `${provider.type}@${model}@${provider.name}`;\n        const config = provider.modelConfigs?.[model];\n        // Load text model results\n        if (config?.ability) {\n          initialResults.set(modelKey, {\n            modelKey,\n            status: 'success',\n            ability: config.ability,\n            isImageModel: false,\n          });\n        }\n        // Load image model results\n        if (config?.imageAbility) {\n          initialResults.set(modelKey, {\n            modelKey,\n            status: 'success',\n            imageAbility: config.imageAbility,\n            isImageModel: true,\n          });\n        }\n      });\n    });\n    resultsRef.current = initialResults;\n    onResultsChange?.(new Map(initialResults));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const updateResult = useCallback(\n    (modelKey: string, update: Partial<IModelTestResult>) => {\n      const existing = resultsRef.current.get(modelKey) || {\n        modelKey,\n        status: 'idle' as const,\n      };\n      resultsRef.current.set(modelKey, { ...existing, ...update });\n      onResultsChange?.(new Map(resultsRef.current));\n    },\n    [onResultsChange]\n  );\n\n  const testTextModel = useCallback(\n    async (\n      modelKey: string,\n      provider: Required<LLMProvider>\n    ): Promise<Partial<IModelTestResult>> => {\n      try {\n        const { type, name, apiKey, baseUrl, models } = provider;\n\n        const result = await withTimeout(\n          onTest({\n            type,\n            name,\n            apiKey,\n            baseUrl,\n            models,\n            modelKey,\n            // Test all chat model abilities\n            ability: chatModelAbilityType.options,\n          }),\n          TEXT_MODEL_TIMEOUT_MS,\n          `Timeout after ${TEXT_MODEL_TIMEOUT_MS / 1000}s`\n        );\n\n        if (!result.success) {\n          return {\n            status: 'failed',\n            error: result.response || 'Test failed',\n          };\n        }\n\n        return {\n          status: 'success',\n          ability: result.ability,\n        };\n      } catch (error) {\n        return {\n          status: 'failed',\n          error: error instanceof Error ? error.message : 'Unknown error',\n        };\n      }\n    },\n    [onTest]\n  );\n\n  const testImageModel = useCallback(\n    async (\n      modelKey: string,\n      provider: Required<LLMProvider>\n    ): Promise<Partial<IModelTestResult>> => {\n      try {\n        const { type, name, apiKey, baseUrl, models } = provider;\n\n        // Test image generation (text-to-image)\n        const generationResult = await withTimeout(\n          onTest({\n            type,\n            name,\n            apiKey,\n            baseUrl,\n            models,\n            modelKey,\n            testImageGeneration: true,\n          }),\n          IMAGE_MODEL_TIMEOUT_MS,\n          `Timeout after ${IMAGE_MODEL_TIMEOUT_MS / 1000}s`\n        );\n\n        // Test image-to-image if generation works\n        let imageToImage = false;\n        if (generationResult.success) {\n          try {\n            const i2iResult = await withTimeout(\n              onTest({\n                type,\n                name,\n                apiKey,\n                baseUrl,\n                models,\n                modelKey,\n                testImageGeneration: true,\n                testImageToImage: true,\n              }),\n              IMAGE_MODEL_TIMEOUT_MS,\n              `Timeout`\n            );\n            imageToImage = i2iResult.success;\n          } catch {\n            // Image-to-image not supported, that's ok\n          }\n        }\n\n        if (!generationResult.success) {\n          return {\n            status: 'failed',\n            error: generationResult.response || 'Image generation test failed',\n            isImageModel: true,\n          };\n        }\n\n        return {\n          status: 'success',\n          isImageModel: true,\n          imageAbility: {\n            generation: true,\n            imageToImage,\n          },\n        };\n      } catch (error) {\n        return {\n          status: 'failed',\n          isImageModel: true,\n          error: error instanceof Error ? error.message : 'Unknown error',\n        };\n      }\n    },\n    [onTest]\n  );\n\n  const handleBatchTest = useCallback(async () => {\n    if (modelList.length === 0) {\n      return;\n    }\n\n    abortRef.current = false;\n    isRunningRef.current = true;\n    setIsRunning(true);\n\n    // Initialize all as pending\n    modelList.forEach(({ modelKey }) => {\n      updateResult(modelKey, { status: 'pending' });\n    });\n    setProgress({ current: 0, total: modelList.length });\n\n    // Sliding window concurrent execution\n    let completedCount = 0;\n    let nextIndex = 0;\n    // eslint-disable-next-line sonarjs/no-unused-collection\n    const inProgress = new Set<string>();\n\n    const startNextTest = async () => {\n      if (abortRef.current || nextIndex >= modelList.length) return;\n\n      const currentIndex = nextIndex++;\n      const { modelKey } = modelList[currentIndex];\n      const { type, name } = parseModelKey(modelKey);\n      const provider = providers.find(\n        (p) => p.type === type && p.name === name\n      ) as Required<LLMProvider>;\n\n      inProgress.add(modelKey);\n      updateResult(modelKey, { status: 'testing' });\n\n      let result: Partial<IModelTestResult>;\n      if (!provider) {\n        result = { status: 'failed', error: 'Provider not found' };\n      } else {\n        // Check if this is an image model\n        const modelInfo = modelList.find((m) => m.modelKey === modelKey);\n        const isImageModel = modelInfo?.isImageModel;\n        result = isImageModel\n          ? await testImageModel(modelKey, provider)\n          : await testTextModel(modelKey, provider);\n      }\n\n      // Update result and progress\n      updateResult(modelKey, result);\n      inProgress.delete(modelKey);\n      completedCount++;\n      setProgress({ current: completedCount, total: modelList.length });\n\n      // Persist successful test results\n      if (result.status === 'success') {\n        onSaveResult?.(modelKey, result.ability, result.imageAbility);\n      }\n\n      // Start next test if there are more\n      if (!abortRef.current && nextIndex < modelList.length) {\n        await startNextTest();\n      }\n    };\n\n    // Start initial concurrent tests\n    const initialPromises: Promise<void>[] = [];\n    for (let i = 0; i < Math.min(CONCURRENCY, modelList.length); i++) {\n      initialPromises.push(startNextTest());\n    }\n\n    await Promise.all(initialPromises);\n    isRunningRef.current = false;\n    setIsRunning(false);\n  }, [modelList, providers, updateResult, onSaveResult, testTextModel, testImageModel]);\n\n  const handleStop = useCallback(() => {\n    abortRef.current = true;\n    isRunningRef.current = false;\n    setIsRunning(false);\n  }, []);\n\n  // Test a single provider's models\n  const testSingleProvider = useCallback(\n    async (provider: LLMProvider) => {\n      const providerKey = `${provider.type}@${provider.name}`;\n      const providerModels =\n        provider.models\n          ?.split(',')\n          .map((m) => m.trim())\n          .filter(Boolean) || [];\n\n      if (providerModels.length === 0) return;\n\n      // Mark provider as testing\n      testingProvidersRef.current.add(providerKey);\n      onTestingProvidersChange?.(new Set(testingProvidersRef.current));\n\n      // Initialize models as pending\n      providerModels.forEach((model) => {\n        const modelKey = `${provider.type}@${model}@${provider.name}`;\n        updateResult(modelKey, { status: 'pending' });\n      });\n\n      // Test each model with sliding window\n      let nextIndex = 0;\n\n      const startNextTest = async () => {\n        if (nextIndex >= providerModels.length) return;\n\n        const currentIndex = nextIndex++;\n        const model = providerModels[currentIndex];\n        const modelKey = `${provider.type}@${model}@${provider.name}`;\n\n        updateResult(modelKey, { status: 'testing' });\n\n        // Check if this is an image model\n        const isImageModel = provider.modelConfigs?.[model]?.isImageModel;\n        const result = isImageModel\n          ? await testImageModel(modelKey, provider as Required<LLMProvider>)\n          : await testTextModel(modelKey, provider as Required<LLMProvider>);\n\n        updateResult(modelKey, result);\n\n        // Persist successful test results\n        if (result.status === 'success') {\n          onSaveResult?.(modelKey, result.ability, result.imageAbility);\n        }\n\n        // Start next test if there are more\n        if (nextIndex < providerModels.length) {\n          await startNextTest();\n        }\n      };\n\n      // Start concurrent tests\n      const initialPromises: Promise<void>[] = [];\n      for (let i = 0; i < Math.min(CONCURRENCY, providerModels.length); i++) {\n        initialPromises.push(startNextTest());\n      }\n\n      await Promise.all(initialPromises);\n\n      // Mark provider as done\n      testingProvidersRef.current.delete(providerKey);\n      onTestingProvidersChange?.(new Set(testingProvidersRef.current));\n    },\n    [updateResult, onSaveResult, onTestingProvidersChange, testTextModel, testImageModel]\n  );\n\n  // Test a single model\n  const testSingleModel = useCallback(\n    async (provider: LLMProvider, model: string, modelKey: string) => {\n      // Mark model as testing\n      testingModelsRef.current.add(modelKey);\n      onTestingModelsChange?.(new Set(testingModelsRef.current));\n\n      updateResult(modelKey, { status: 'testing' });\n\n      // Check if this is an image model\n      const isImageModel = provider.modelConfigs?.[model]?.isImageModel;\n      const result = isImageModel\n        ? await testImageModel(modelKey, provider as Required<LLMProvider>)\n        : await testTextModel(modelKey, provider as Required<LLMProvider>);\n\n      updateResult(modelKey, result);\n\n      // Persist successful test results\n      if (result.status === 'success') {\n        onSaveResult?.(modelKey, result.ability, result.imageAbility);\n      }\n\n      // Mark model as done\n      testingModelsRef.current.delete(modelKey);\n      onTestingModelsChange?.(new Set(testingModelsRef.current));\n    },\n    [updateResult, onSaveResult, onTestingModelsChange, testTextModel, testImageModel]\n  );\n\n  // Expose the testSingleProvider function to parent via callback\n  useEffect(() => {\n    onTestProvider?.((provider: LLMProvider) => {\n      testSingleProvider(provider);\n    });\n  }, [onTestProvider, testSingleProvider]);\n\n  // Expose the testSingleModel function to parent via callback\n  useEffect(() => {\n    onTestModel?.((provider: LLMProvider, model: string, modelKey: string) =>\n      testSingleModel(provider, model, modelKey)\n    );\n  }, [onTestModel, testSingleModel]);\n\n  const successCount = Array.from(resultsRef.current.values()).filter(\n    (r) => r.status === 'success'\n  ).length;\n  const failedCount = Array.from(resultsRef.current.values()).filter(\n    (r) => r.status === 'failed'\n  ).length;\n  const progressPercent =\n    progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;\n  const hasResults =\n    resultsRef.current.size > 0 &&\n    Array.from(resultsRef.current.values()).some((r) => r.status !== 'idle');\n\n  if (modelList.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-3\">\n      {/* Header with test button and progress */}\n      <div className=\"flex items-center gap-4\">\n        {isRunning ? (\n          <Button variant=\"destructive\" size=\"xs\" onClick={handleStop} className=\"gap-2\">\n            <Square className=\"size-3\" />\n            {t('admin.setting.ai.stopTest')}\n          </Button>\n        ) : (\n          <Button\n            variant=\"outline\"\n            size=\"xs\"\n            onClick={handleBatchTest}\n            disabled={disabled}\n            className=\"gap-2\"\n          >\n            <Play className=\"size-4\" />\n            {t('admin.setting.ai.batchTest')}\n          </Button>\n        )}\n\n        {/* Progress inline */}\n        {(isRunning || hasResults) && progress.total > 0 && (\n          <div className=\"flex flex-1 items-center gap-3\">\n            <Progress value={progressPercent} className=\"h-1.5 flex-1\" />\n            <div className=\"flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground\">\n              {isRunning && <Loader2 className=\"size-4 animate-spin\" />}\n              <span>{progressPercent}%</span>\n              <span className=\"text-green-600 dark:text-green-400\">{successCount} ✓</span>\n              <span className=\"text-red-600 dark:text-red-400\">{failedCount} ✗</span>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx",
    "content": "import { AlertTriangle, Check, Image, File, Settings } from '@teable/icons';\nimport { chatModelAbilityType } from '@teable/openapi';\nimport type { IAIIntegrationConfig, IChatModelAbility, IAbilityDetail } from '@teable/openapi';\nimport {\n  cn,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Cpu } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { AIModelSelect, type IModelOption } from './AiModelSelect';\n\n// Helper to check if ability is supported (handles both boolean and detailed format)\nconst isAbilitySupported = (ability: boolean | IAbilityDetail | undefined): boolean => {\n  if (typeof ability === 'boolean') return ability;\n  if (ability && typeof ability === 'object') {\n    return ability.url === true || ability.base64 === true;\n  }\n  return false;\n};\n\n// Helper to get support details for display\nconst getAbilitySupportDetails = (ability: boolean | IAbilityDetail | undefined): string | null => {\n  if (typeof ability === 'boolean') return null;\n  if (ability && typeof ability === 'object') {\n    const supports: string[] = [];\n    if (ability.url) supports.push('URL');\n    if (ability.base64) supports.push('Base64');\n    return supports.length > 0 ? supports.join(', ') : null;\n  }\n  return null;\n};\n\nexport const CodingModels = ({\n  value,\n  onChange,\n  models,\n  needGroup,\n  placeholder,\n}: {\n  value: IAIIntegrationConfig['chatModel'];\n  onChange: (value: IAIIntegrationConfig['chatModel']) => void;\n  models?: IModelOption[];\n  // Kept for backward compatibility, but not used since testing happens in provider config\n  onTestChatModelAbility?: (\n    chatModel: IAIIntegrationConfig['chatModel']\n  ) => Promise<IChatModelAbility | undefined>;\n  needGroup?: boolean;\n  placeholder?: string;\n}) => {\n  const { t } = useTranslation('common');\n\n  const abilityIconMap = useMemo(() => {\n    return {\n      image: <Image className=\"size-4\" />,\n      pdf: <File className=\"size-4\" />,\n      toolCall: <Settings className=\"size-4\" />,\n    };\n  }, []);\n\n  // Get ability from the selected model's capabilities or from value.ability\n  // Priority: value.ability (from selection) > model capabilities (from provider config)\n  const selectedModelAbility = useMemo(() => {\n    // First check value.ability (set when model is selected)\n    if (value?.ability && Object.keys(value.ability).length > 0) {\n      return value.ability as IChatModelAbility;\n    }\n    // Fallback to model's capabilities from provider config\n    if (!value?.lg || !models) return undefined;\n    const selectedModel = models.find((m) => m.modelKey === value.lg);\n    return selectedModel?.capabilities as IChatModelAbility | undefined;\n  }, [value?.lg, value?.ability, models]);\n\n  const handleModelChange = (model: string) => {\n    // Get ability from the model's capabilities (already tested)\n    const selectedModel = models?.find((m) => m.modelKey === model);\n    const ability = (selectedModel?.capabilities as IChatModelAbility) || {};\n\n    // Set all sizes to the same model (simplified selection)\n    onChange({ ...value, lg: model, md: model, sm: model, ability });\n  };\n\n  // Icon for chat model selection\n  const chatModelIcon = useMemo(() => <Cpu className=\"size-4 text-purple-500\" />, []);\n\n  // Check if model has been tested\n  const isModelTested = useMemo(() => {\n    return selectedModelAbility && Object.keys(selectedModelAbility).length > 0;\n  }, [selectedModelAbility]);\n\n  // Check if model has missing critical abilities\n  const hasMissingAbilities = useMemo(() => {\n    if (!value?.lg) return false;\n    // If model is not tested, show warning\n    if (!isModelTested) return true;\n    // Model should support toolCall (critical for AI features)\n    const hasToolCall = isAbilitySupported(selectedModelAbility?.toolCall);\n    return !hasToolCall;\n  }, [value?.lg, isModelTested, selectedModelAbility]);\n\n  const getMissingAbilitiesMessage = useMemo(() => {\n    if (!value?.lg) return null;\n    const missing: string[] = [];\n\n    // If model is not tested, show \"not tested\" warning\n    if (!isModelTested) {\n      missing.push(t('admin.setting.ai.chatModelAbility.notTested'));\n      return missing;\n    }\n\n    // Check for missing abilities\n    if (\n      !isAbilitySupported(selectedModelAbility?.image) &&\n      !isAbilitySupported(selectedModelAbility?.pdf)\n    ) {\n      missing.push(t('admin.setting.ai.chatModelAbility.missingVision'));\n    }\n    if (!isAbilitySupported(selectedModelAbility?.toolCall)) {\n      missing.push(t('admin.setting.ai.chatModelAbility.missingToolCall'));\n    }\n    return missing.length > 0 ? missing : null;\n  }, [value?.lg, isModelTested, selectedModelAbility, t]);\n\n  // Abilities to test and display\n  const testableAbilities = chatModelAbilityType.options;\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-4\">\n      {/* Chat model selection - simplified to one model */}\n      <div className=\"relative flex flex-col gap-2\">\n        <div className=\"flex shrink-0 items-center gap-2 truncate text-sm\">\n          {chatModelIcon}\n          <span>{t('admin.setting.ai.chatModel')}</span>\n          <div className=\"h-4 text-red-500\">*</div>\n        </div>\n        <div className=\"text-left text-xs text-muted-foreground\">\n          {t('admin.setting.ai.chatModelDescription')}\n        </div>\n\n        <AIModelSelect\n          value={value?.lg ?? ''}\n          onValueChange={handleModelChange}\n          options={models}\n          className=\"flex-1\"\n          needGroup={needGroup}\n          placeholder={placeholder}\n        />\n\n        {/* Model Ability Section - directly under model select */}\n        {value?.lg && (\n          <div className=\"mt-2 rounded-md border bg-muted p-3\">\n            <div className=\"flex items-center justify-between\">\n              <span className=\"text-sm font-medium\">\n                {t('admin.setting.ai.chatModelAbility.lgModelAbility')}\n              </span>\n            </div>\n\n            {/* Ability badges - from pre-tested results in provider config */}\n            <div className=\"mt-2 flex flex-wrap gap-2\">\n              <TooltipProvider>\n                {testableAbilities.map((type) => {\n                  const abilityValue = selectedModelAbility?.[type];\n                  const supported = isAbilitySupported(abilityValue);\n                  const supportDetails = getAbilitySupportDetails(abilityValue);\n\n                  const badge = (\n                    <div\n                      className={cn(\n                        'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs transition-colors',\n                        supported\n                          ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-500'\n                          : 'bg-muted text-muted-foreground'\n                      )}\n                    >\n                      {supported ? (\n                        <Check className=\"size-3\" />\n                      ) : (\n                        abilityIconMap[type as keyof typeof abilityIconMap]\n                      )}\n                      <span>{t(`admin.setting.ai.chatModelAbility.${type}`)}</span>\n                      {supportDetails && (\n                        <span className=\"ml-0.5 opacity-70\">({supportDetails})</span>\n                      )}\n                    </div>\n                  );\n\n                  // Show tooltip with details for image/pdf\n                  if (supportDetails) {\n                    return (\n                      <Tooltip key={type}>\n                        <TooltipTrigger asChild>{badge}</TooltipTrigger>\n                        <TooltipContent>\n                          <p>\n                            {t('admin.setting.ai.chatModelAbility.supportedFormats')}:{' '}\n                            {supportDetails}\n                          </p>\n                        </TooltipContent>\n                      </Tooltip>\n                    );\n                  }\n\n                  return <div key={type}>{badge}</div>;\n                })}\n              </TooltipProvider>\n            </div>\n\n            {/* Warning for missing abilities */}\n            {hasMissingAbilities && getMissingAbilitiesMessage && (\n              <div className=\"mt-3 flex items-start gap-2 rounded-md border border-amber-500/50 bg-amber-50/50 p-2.5 dark:bg-amber-900/20\">\n                <AlertTriangle className=\"mt-0.5 size-4 shrink-0 text-amber-600\" />\n                <div className=\"text-xs text-amber-700 dark:text-amber-400\">\n                  <p className=\"font-medium\">\n                    {t('admin.setting.ai.chatModelTest.modelNotSuitable')}\n                  </p>\n                  <ul className=\"mt-1 list-inside list-disc\">\n                    {getMissingAbilitiesMessage.map((msg, i) => (\n                      <li key={i}>{msg}</li>\n                    ))}\n                  </ul>\n                </div>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx",
    "content": "'use client';\n\nimport { Zap, MessageSquare, Star, HelpCircle } from '@teable/icons';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\nimport type { IModelOption } from './AiModelSelect';\nimport { AIModelSelect } from './AiModelSelect';\n\ninterface IChatModel {\n  lg?: string;\n  md?: string;\n  sm?: string;\n}\n\ninterface IDefaultModelsStepProps {\n  chatModel?: IChatModel;\n  models: IModelOption[];\n  onChange: (chatModel: IChatModel) => void;\n  disabled?: boolean;\n}\n\nexport function DefaultModelsStep({\n  chatModel,\n  models,\n  onChange,\n  disabled,\n}: IDefaultModelsStepProps) {\n  const { t } = useTranslation('common');\n\n  // Filter to only text models (not image models)\n  const textModels = models.filter((m) => !m.isImageModel);\n\n  // Find a recommended default (first gateway model, or first model)\n  const recommendedDefault = textModels.find((m) => m.isGateway) || textModels[0];\n\n  const handleUseRecommended = useCallback(() => {\n    if (recommendedDefault) {\n      // Set the same model for all sizes\n      onChange({\n        lg: recommendedDefault.modelKey,\n        md: recommendedDefault.modelKey,\n        sm: recommendedDefault.modelKey,\n      });\n    }\n  }, [recommendedDefault, onChange]);\n\n  const handleModelChange = useCallback(\n    (value: string) => {\n      // Set all sizes to the same model for simplicity\n      onChange({\n        lg: value,\n        md: value,\n        sm: value,\n      });\n    },\n    [onChange]\n  );\n\n  if (disabled) {\n    return (\n      <div className=\"rounded-lg border border-dashed bg-muted/30 p-6 text-center\">\n        <p className=\"text-sm text-muted-foreground\">\n          {t('admin.setting.ai.wizard.completeStep2First')}\n        </p>\n      </div>\n    );\n  }\n\n  if (textModels.length === 0) {\n    return (\n      <div className=\"rounded-lg border border-dashed bg-muted/30 p-6 text-center\">\n        <p className=\"text-sm text-muted-foreground\">\n          {t('admin.setting.ai.wizard.noModelsAvailable')}\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Quick Setup - only show if no model selected */}\n      {recommendedDefault && !chatModel?.lg && (\n        <div className=\"rounded-lg border bg-muted p-4\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-2 text-sm font-medium\">\n                <Star className=\"size-4 shrink-0 text-primary\" />\n                {t('admin.setting.ai.wizard.quickSetup')}\n              </div>\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                {t('admin.setting.ai.wizard.useRecommendedDesc', {\n                  model: recommendedDefault.label || recommendedDefault.modelKey,\n                })}\n              </p>\n            </div>\n            <Button onClick={handleUseRecommended} size=\"sm\" className=\"shrink-0\">\n              {t('admin.setting.ai.wizard.useRecommended')}\n            </Button>\n          </div>\n        </div>\n      )}\n\n      {/* Model Selection - simplified to one model */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center gap-2 text-sm font-medium text-muted-foreground\">\n          <MessageSquare className=\"size-4\" />\n          {t('admin.setting.ai.wizard.chatModels')}\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <HelpCircle className=\"size-3.5 cursor-help\" />\n              </TooltipTrigger>\n              <TooltipContent className=\"max-w-xs\">\n                <p>{t('admin.setting.ai.wizard.chatModelTip')}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        </div>\n\n        <AIModelSelect\n          value={chatModel?.lg || ''}\n          onValueChange={handleModelChange}\n          options={textModels}\n          className=\"w-full\"\n        />\n      </div>\n\n      {/* Status */}\n      {chatModel?.lg && (\n        <div className=\"flex h-8 items-center justify-center gap-2 rounded-md bg-green-100 p-2 text-sm text-green-600 dark:bg-green-500/10 dark:text-green-400\">\n          <Zap className=\"size-4\" />\n          {t('admin.setting.ai.wizard.readyToUse')}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/GatewayModelPickerDialog.tsx",
    "content": "'use client';\n\nimport type {\n  GatewayModelProvider,\n  GatewayModelTag,\n  GatewayModelType,\n  IModelPricing,\n} from '@teable/openapi';\nimport { USD_PER_CREDIT, TOKENS_PER_RATE_UNIT } from '@teable/openapi';\nimport {\n  Badge,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  ScrollArea,\n} from '@teable/ui-lib/shadcn';\nimport Fuse from 'fuse.js';\nimport { Check, DollarSign, Loader2, Search, Coins } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { GATEWAY_PROVIDER_ICONS } from './constant';\n\n// Capability labels for display\nexport const CAPABILITY_LABELS: Record<string, string> = {\n  image: 'Vision',\n  pdf: 'PDF',\n  webSearch: 'Web',\n  toolCall: 'Tools',\n  reasoning: 'Reasoning',\n  imageGeneration: 'Image Gen',\n};\n\n/**\n * Unified model interface for the picker dialog.\n * This abstracts the differences between API response models and configured models.\n */\nexport interface IPickerModel {\n  id: string;\n  name?: string;\n  description?: string;\n  ownedBy?: GatewayModelProvider;\n  modelType?: GatewayModelType;\n  tags?: GatewayModelTag[];\n  isImageModel?: boolean;\n  capabilities?: Record<string, boolean | undefined>;\n  // Pricing info from Vercel AI Gateway API (USD per token)\n  pricing?: IModelPricing;\n}\n\nexport type PriceDisplayMode = 'usd' | 'credits' | 'none';\n\ninterface IGatewayModelPickerDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  models: IPickerModel[];\n  isLoading?: boolean;\n  selectedModelId?: string;\n  onSelectModel: (model: IPickerModel) => void;\n  title?: string;\n  /**\n   * Price display mode:\n   * - 'usd': Show USD pricing (for admin panel)\n   * - 'credits': Show credit pricing (for end-user)\n   * - 'none': Don't show pricing\n   */\n  priceMode?: PriceDisplayMode;\n  /**\n   * Filter function to exclude certain models (e.g., already added models)\n   */\n  isModelDisabled?: (model: IPickerModel) => boolean;\n  /**\n   * Custom badge to show for disabled models\n   */\n  disabledBadgeText?: string;\n  /**\n   * Empty state message when no models found\n   */\n  emptyMessage?: string;\n}\n\n/**\n * Convert USD per token to credits per 1M tokens\n * Formula: credits/1M = (USD/token * 1M) / USD_PER_CREDIT\n */\nfunction usdToCreditsPerMillion(usdPerToken: string | undefined): number | undefined {\n  if (!usdPerToken) return undefined;\n  const usd = parseFloat(usdPerToken);\n  if (isNaN(usd) || usd === 0) return 0;\n  return (usd * TOKENS_PER_RATE_UNIT) / USD_PER_CREDIT;\n}\n\n/**\n * Format credit rate for display (per 1M tokens)\n */\nfunction formatCreditRate(credits: number | undefined, freeLabel: string): string {\n  if (credits === undefined) return '-';\n  if (credits === 0) return freeLabel;\n  if (credits < 1) return credits.toFixed(2);\n  if (credits < 100) return credits.toFixed(1);\n  return Math.round(credits).toString();\n}\n\n// Helper to format USD price for display (per 1M tokens)\nfunction formatUsdPriceShort(price: string | undefined, freeLabel: string): string {\n  if (!price) return '-';\n  const num = parseFloat(price);\n  if (isNaN(num) || num === 0) return freeLabel;\n  // Convert to per-million rate for readability\n  const perMillion = num * TOKENS_PER_RATE_UNIT;\n  if (perMillion < 1) return `$${perMillion.toFixed(2)}/M`;\n  if (perMillion < 100) return `$${perMillion.toFixed(1)}/M`;\n  return `$${Math.round(perMillion)}/M`;\n}\n\n// Generate display label from model ID\nfunction generateLabel(modelId: string, apiName?: string): string {\n  if (apiName) return apiName;\n  const parts = modelId.split('/');\n  const modelName = parts[parts.length - 1];\n  return modelName\n    .replace(/-\\d{8}$/, '')\n    .replace(/-/g, ' ')\n    .replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\n// Detect if model is an image model based on type, tags, or ID\nfunction detectIsImageModel(model: IPickerModel): boolean {\n  if (model.isImageModel || model.modelType === 'image') return true;\n  if (model.tags?.includes('image-generation')) return true;\n  return false;\n}\n\nexport function GatewayModelPickerDialog({\n  open,\n  onOpenChange,\n  models,\n  isLoading = false,\n  selectedModelId,\n  onSelectModel,\n  title,\n  priceMode = 'none',\n  isModelDisabled,\n  disabledBadgeText,\n  emptyMessage,\n}: IGatewayModelPickerDialogProps) {\n  const { t } = useTranslation('common');\n  const freeLabel = t('level.free');\n  const creditsLabel = t('noun.credits');\n  const [searchQuery, setSearchQuery] = useState('');\n  const scrollAreaRef = useRef<HTMLDivElement>(null);\n\n  // Scroll to top when search query changes\n  useEffect(() => {\n    if (scrollAreaRef.current) {\n      const viewport = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');\n      if (viewport) {\n        viewport.scrollTop = 0;\n      }\n    }\n  }, [searchQuery]);\n\n  // Filter and search models\n  const filteredModels = useMemo(() => {\n    const filtered = [...models];\n\n    // Sort by created time if available, otherwise by name\n    filtered.sort((a, b) => {\n      const nameA = generateLabel(a.id, a.name);\n      const nameB = generateLabel(b.id, b.name);\n      return nameA.localeCompare(nameB);\n    });\n\n    // Apply fuzzy search\n    if (searchQuery) {\n      const fuse = new Fuse(filtered, {\n        keys: [\n          { name: 'name', weight: 2 },\n          { name: 'id', weight: 1.5 },\n          { name: 'description', weight: 1 },\n        ],\n        threshold: 0.4,\n        includeScore: true,\n        ignoreLocation: true,\n      });\n      const results = fuse.search(searchQuery);\n      return results.map((r) => r.item).slice(0, 50);\n    }\n\n    return filtered.slice(0, 100);\n  }, [models, searchQuery]);\n\n  // Render price badge based on mode\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const renderPriceBadge = (model: IPickerModel) => {\n    if (priceMode === 'none') return null;\n\n    const { pricing } = model;\n    const isImage = detectIsImageModel(model);\n\n    if (priceMode === 'usd') {\n      // Show USD pricing from API (per 1M tokens for text, per image for images)\n      if (!pricing || (!pricing.input && !pricing.output && !pricing.image)) return null;\n\n      return (\n        <Badge variant=\"outline\" className=\"text-[10px]\">\n          <DollarSign className=\"mr-0.5 size-2.5\" />\n          {isImage && pricing.image\n            ? t('admin.setting.ai.imageOutput', { credits: `$${pricing.image}/img` })\n            : `${t('admin.setting.ai.input', {\n                ratio: formatUsdPriceShort(pricing.input, freeLabel),\n              })} / ${t('admin.setting.ai.output', {\n                ratio: formatUsdPriceShort(pricing.output, freeLabel),\n              })}`}\n        </Badge>\n      );\n    }\n\n    if (priceMode === 'credits') {\n      // Calculate credits from pricing (USD per token -> credits per 1M tokens)\n      // Priority: pricing field > legacy rates field\n      if (pricing && (pricing.input || pricing.output || pricing.image)) {\n        const inputCredits = usdToCreditsPerMillion(pricing.input);\n        const outputCredits = usdToCreditsPerMillion(pricing.output);\n        const imageUsd = pricing.image ? parseFloat(pricing.image) : undefined;\n        // Image price is per image, convert to credits: USD / USD_PER_CREDIT\n        const imageCredits = imageUsd ? imageUsd / USD_PER_CREDIT : undefined;\n\n        return (\n          <Badge variant=\"outline\" className=\"text-[10px]\">\n            <Coins className=\"mr-0.5 size-2.5\" />\n            {isImage && imageCredits !== undefined\n              ? `${t('admin.setting.ai.imageOutput', {\n                  credits: formatCreditRate(imageCredits, freeLabel),\n                })} ${creditsLabel}`\n              : `${t('admin.setting.ai.input', {\n                  ratio: `${formatCreditRate(inputCredits, freeLabel)}/${'M'} ${creditsLabel}`,\n                })} / ${t('admin.setting.ai.output', {\n                  ratio: `${formatCreditRate(outputCredits, freeLabel)}/${'M'} ${creditsLabel}`,\n                })}`}\n          </Badge>\n        );\n      }\n      return null;\n    }\n\n    return null;\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange} modal>\n      <DialogContent className=\"max-w-max px-4\">\n        <DialogHeader>\n          <DialogTitle>{title || t('admin.setting.ai.moreModels')}</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"flex w-[600px] max-w-[90vw] flex-col gap-4\">\n          {/* Search Input */}\n          <div className=\"relative px-1\">\n            <Search className=\"absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n            <input\n              type=\"text\"\n              placeholder={t('admin.setting.ai.searchModelPlaceholder')}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-9 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n            />\n          </div>\n\n          {/* Model List */}\n          <ScrollArea ref={scrollAreaRef} className=\"h-[450px] pr-1\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-12\">\n                <Loader2 className=\"size-6 animate-spin text-muted-foreground\" />\n              </div>\n            ) : filteredModels.length === 0 ? (\n              <div className=\"py-12 text-center text-sm text-muted-foreground\">\n                {emptyMessage ||\n                  (searchQuery\n                    ? t('admin.setting.ai.noMatchingModels')\n                    : t('admin.setting.ai.noModelsAvailable'))}\n              </div>\n            ) : (\n              <div className=\"\">\n                {filteredModels.map((model) => {\n                  const isSelected = selectedModelId === model.id;\n                  const isDisabled = isModelDisabled?.(model) ?? false;\n                  const ProviderIcon = model.ownedBy\n                    ? GATEWAY_PROVIDER_ICONS[model.ownedBy as keyof typeof GATEWAY_PROVIDER_ICONS]\n                    : undefined;\n\n                  return (\n                    <button\n                      key={model.id}\n                      onClick={() => !isDisabled && onSelectModel(model)}\n                      disabled={isDisabled}\n                      className={cn(\n                        'flex w-full flex-col rounded-sm p-2 py-1.5 text-left transition-colors hover:bg-accent focus:bg-accent focus:outline-none',\n                        isSelected && 'bg-accent',\n                        isDisabled && 'cursor-not-allowed opacity-50'\n                      )}\n                    >\n                      {/* First row: Icon, Name, Type badges, Check mark */}\n                      <div className=\"flex items-center justify-between gap-2\">\n                        <div className=\"flex items-center gap-2 truncate\">\n                          {ProviderIcon && <ProviderIcon className=\"size-4 shrink-0\" />}\n                          <span className=\"truncate text-xs\">\n                            {generateLabel(model.id, model.name)}\n                          </span>\n                        </div>\n                        <div className=\"flex items-center gap-2\">\n                          {isDisabled && disabledBadgeText && (\n                            <Badge variant=\"secondary\" className=\"text-[10px]\">\n                              {disabledBadgeText}\n                            </Badge>\n                          )}\n                          {isSelected && <Check className=\"size-4 shrink-0 text-primary\" />}\n                        </div>\n                      </div>\n\n                      {/* Second row: Model ID, Price badge, Capability badges */}\n                      <div className=\"flex items-center justify-between gap-2 text-xs text-muted-foreground\">\n                        <code className=\"truncate pl-6\">{model.id}</code>\n                        {/* Price badge */}\n                        {renderPriceBadge(model)}\n                        {/* Capability badges */}\n                        {model.capabilities && (\n                          <div className=\"flex gap-1\">\n                            {Object.entries(model.capabilities)\n                              .filter(([, v]) => v)\n                              .slice(0, 3)\n                              .map(([key]) => (\n                                <Badge key={key} variant=\"outline\" className=\"text-[10px]\">\n                                  {CAPABILITY_LABELS[key] || key}\n                                </Badge>\n                              ))}\n                          </div>\n                        )}\n                      </div>\n                    </button>\n                  );\n                })}\n              </div>\n            )}\n          </ScrollArea>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/GatewayModelsStep.tsx",
    "content": "'use client';\n\nimport type { DragEndEvent } from '@dnd-kit/core';\nimport { verticalListSortingStrategy } from '@dnd-kit/sortable';\nimport type { IGatewayModel } from '@teable/openapi';\nimport { DndKitContext, Droppable, arrayMove } from '@teable/ui-lib/base/dnd-kit';\nimport { Label } from '@teable/ui-lib/shadcn';\nimport Fuse from 'fuse.js';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useCallback, useEffect, useMemo } from 'react';\nimport { AddModelDialog } from './gateway-models-step/AddModelDialog';\nimport { ModelCard } from './gateway-models-step/ModelCard';\nimport { QuickAddButtons } from './gateway-models-step/QuickAddButtons';\nimport type {\n  IGatewayModelAPI,\n  IGatewayModelsStepProps,\n  ITestState,\n} from './gateway-models-step/types';\nimport { RECOMMENDED_MODEL_IDS } from './gateway-models-step/types';\nimport {\n  generateLabelFromId,\n  getPricingFromApiModel,\n  detectIsImageModel,\n  detectCapabilitiesFromTags,\n} from './gateway-models-step/utils';\n\nexport function GatewayModelsStep({\n  gatewayModels,\n  onChange,\n  disabled,\n  apiKey,\n  showPricing = true,\n}: IGatewayModelsStepProps) {\n  const { t } = useTranslation('common');\n  const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);\n  const [newModel, setNewModel] = useState<Partial<IGatewayModel>>({\n    id: '',\n    label: '',\n    enabled: true,\n    capabilities: {},\n    rates: {},\n  });\n  // Local state for expanding pricing input section in the form\n  const [pricingExpanded, setPricingExpanded] = useState(false);\n  const [testState, setTestState] = useState<ITestState>({ testing: false });\n\n  // Model search state\n  const [availableModels, setAvailableModels] = useState<IGatewayModelAPI[]>([]);\n  const [isLoadingModels, setIsLoadingModels] = useState(false);\n  const [modelSearchOpen, setModelSearchOpen] = useState(false);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [modelsLoadError, setModelsLoadError] = useState<string | null>(null);\n\n  // Fetch available models from backend API (cached)\n  const fetchModels = useCallback(async () => {\n    if (!apiKey) return;\n\n    setIsLoadingModels(true);\n    setModelsLoadError(null);\n\n    try {\n      // Use backend API which has in-memory caching\n      const response = await fetch('/api/admin/setting/gateway-models');\n\n      if (!response.ok) {\n        throw new Error(`Failed to fetch models: ${response.status}`);\n      }\n\n      const data = await response.json();\n      const models: IGatewayModelAPI[] = data.models || [];\n      setAvailableModels(models);\n    } catch (error) {\n      console.error('Failed to fetch gateway models:', error);\n      setModelsLoadError(error instanceof Error ? error.message : 'Failed to fetch models');\n    } finally {\n      setIsLoadingModels(false);\n    }\n  }, [apiKey]);\n\n  // Load models on component mount (for quick add buttons) and when dialog opens\n  useEffect(() => {\n    if (apiKey && availableModels.length === 0) {\n      fetchModels();\n    }\n  }, [apiKey, availableModels.length, fetchModels]);\n\n  // Sort models by created timestamp (newest first)\n  const sortedModels = useMemo(() => {\n    return [...availableModels].sort((a, b) => (b.created ?? 0) - (a.created ?? 0));\n  }, [availableModels]);\n\n  // Fuse.js instance for fuzzy search on name, id, and description\n  const fuse = useMemo(() => {\n    return new Fuse(sortedModels, {\n      keys: [\n        { name: 'name', weight: 2 },\n        { name: 'id', weight: 1.5 },\n        { name: 'description', weight: 1 },\n      ],\n      threshold: 0.4,\n      includeScore: true,\n      ignoreLocation: true,\n    });\n  }, [sortedModels]);\n\n  // Filter models based on search query using Fuse.js\n  const filteredModels = useMemo(() => {\n    if (!searchQuery) return sortedModels.slice(0, 50);\n    const results = fuse.search(searchQuery);\n    return results.map((r) => r.item).slice(0, 50);\n  }, [sortedModels, searchQuery, fuse]);\n\n  // Find API model by ID\n  const findApiModel = useCallback(\n    (modelId: string): IGatewayModelAPI | undefined => {\n      return availableModels.find((m) => m.id === modelId);\n    },\n    [availableModels]\n  );\n\n  const handleSelectModel = useCallback(\n    (modelId: string) => {\n      const apiModel = findApiModel(modelId);\n      const suggestedLabel = generateLabelFromId(modelId, apiModel?.name);\n      const apiPricing = getPricingFromApiModel(apiModel);\n      const isImageModel = detectIsImageModel(modelId, apiModel);\n      const capabilities = detectCapabilitiesFromTags(apiModel?.tags);\n\n      setNewModel((prev) => ({\n        ...prev,\n        id: modelId,\n        label: suggestedLabel,\n        pricing: apiPricing || prev.pricing,\n        isImageModel,\n        capabilities: capabilities || prev.capabilities,\n        // Store API metadata for later use (e.g., provider detection, capability checks)\n        ownedBy: apiModel?.ownedBy,\n        modelType: apiModel?.type,\n        tags: apiModel?.tags,\n        contextWindow: apiModel?.contextWindow,\n        maxTokens: apiModel?.maxTokens,\n        description: apiModel?.description,\n      }));\n      setModelSearchOpen(false);\n      setSearchQuery('');\n      setTestState({ testing: false }); // Reset test state on model change\n\n      // Show pricing section if we have pricing data\n      if (apiPricing) {\n        setPricingExpanded(true);\n      }\n    },\n    [findApiModel]\n  );\n\n  // Filter recommended models: only show those not already added and found in API\n  const availableRecommendedIds = useMemo(\n    () =>\n      RECOMMENDED_MODEL_IDS.filter(\n        (id) =>\n          !gatewayModels.some((m) => m.id === id) &&\n          (availableModels.length === 0 || availableModels.some((m) => m.id === id))\n      ),\n    [gatewayModels, availableModels]\n  );\n\n  const handleAddModel = useCallback(() => {\n    if (!newModel.id || !newModel.label) return;\n\n    // Auto-detect capabilities if not manually set\n    const capabilities =\n      newModel.capabilities && Object.keys(newModel.capabilities).length > 0\n        ? newModel.capabilities\n        : detectCapabilitiesFromTags(newModel.tags);\n\n    const model: IGatewayModel = {\n      id: newModel.id,\n      label: newModel.label,\n      enabled: newModel.enabled ?? true,\n      capabilities,\n      isImageModel: newModel.isImageModel,\n      pricing: newModel.pricing,\n      // API metadata\n      ownedBy: newModel.ownedBy,\n      modelType: newModel.modelType,\n      tags: newModel.tags,\n      contextWindow: newModel.contextWindow,\n      maxTokens: newModel.maxTokens,\n      description: newModel.description,\n    };\n\n    onChange([...gatewayModels, model]);\n    setNewModel({ id: '', label: '', enabled: true, capabilities: {}, pricing: {} });\n    setPricingExpanded(false);\n    setTestState({ testing: false });\n    setIsAddDialogOpen(false);\n  }, [newModel, gatewayModels, onChange]);\n\n  const handleQuickAdd = useCallback(\n    (modelId: string) => {\n      // Get all model info from API\n      const apiModel = findApiModel(modelId);\n      const apiPricing = getPricingFromApiModel(apiModel);\n      const isImageModel =\n        apiModel?.type === 'image' || apiModel?.tags?.includes('image-generation');\n      const capabilities = detectCapabilitiesFromTags(apiModel?.tags);\n\n      const model: IGatewayModel = {\n        id: modelId,\n        label: apiModel?.name || generateLabelFromId(modelId),\n        enabled: true,\n        capabilities,\n        isImageModel,\n        pricing: apiPricing,\n        // API metadata\n        ownedBy: apiModel?.ownedBy,\n        modelType: apiModel?.type,\n        tags: apiModel?.tags,\n        contextWindow: apiModel?.contextWindow,\n        maxTokens: apiModel?.maxTokens,\n        description: apiModel?.description,\n      };\n      onChange([...gatewayModels, model]);\n    },\n    [gatewayModels, onChange, findApiModel]\n  );\n\n  const handleRemoveModel = useCallback(\n    (modelId: string) => {\n      onChange(gatewayModels.filter((m) => m.id !== modelId));\n    },\n    [gatewayModels, onChange]\n  );\n\n  const handleToggleEnabled = useCallback(\n    (modelId: string, enabled: boolean) => {\n      onChange(gatewayModels.map((m) => (m.id === modelId ? { ...m, enabled } : m)));\n    },\n    [gatewayModels, onChange]\n  );\n\n  const handleDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active, over } = event;\n      if (over && active.id !== over.id) {\n        const oldIndex = gatewayModels.findIndex((m) => m.id === active.id);\n        const newIndex = gatewayModels.findIndex((m) => m.id === over.id);\n        if (oldIndex !== -1 && newIndex !== -1) {\n          onChange(arrayMove(gatewayModels, oldIndex, newIndex));\n        }\n      }\n    },\n    [gatewayModels, onChange]\n  );\n\n  // Check if selected model ID is valid (exists in API)\n  const isModelIdValid = useMemo(() => {\n    if (!newModel.id) return true;\n    if (availableModels.length === 0) return true;\n    return availableModels.some((m) => m.id === newModel.id);\n  }, [newModel.id, availableModels]);\n\n  // Update pricing in newModel (USD format - string values)\n  const updatePricing = useCallback(\n    (\n      field: 'input' | 'output' | 'inputCacheRead' | 'inputCacheWrite' | 'image' | 'webSearch',\n      value: string\n    ) => {\n      setNewModel((prev) => ({\n        ...prev,\n        pricing: {\n          ...prev.pricing,\n          [field]: value || undefined,\n        },\n      }));\n    },\n    []\n  );\n\n  if (disabled) {\n    return (\n      <div className=\"rounded-lg border border-dashed bg-muted/30 p-6 text-center\">\n        <p className=\"text-sm text-muted-foreground\">\n          {t('admin.setting.ai.wizard.completeStep1First')}\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Quick Add Buttons */}\n      <QuickAddButtons\n        availableRecommendedIds={availableRecommendedIds}\n        isLoadingModels={isLoadingModels}\n        findApiModel={findApiModel}\n        onQuickAdd={handleQuickAdd}\n        onOpenDialog={() => setIsAddDialogOpen(true)}\n        showPricing={showPricing}\n        t={t}\n      />\n\n      {/* Model List */}\n      {gatewayModels.length > 0 ? (\n        <div className=\"space-y-2\">\n          <Label className=\"text-xs text-muted-foreground\">\n            {t('admin.setting.ai.wizard.enabledModels')} (\n            {gatewayModels.filter((m) => m.enabled).length})\n          </Label>\n          <DndKitContext onDragEnd={handleDragEnd}>\n            <Droppable\n              items={gatewayModels.map((m) => m.id)}\n              strategy={verticalListSortingStrategy}\n            >\n              <div className=\"space-y-2\">\n                {gatewayModels.map((model) => (\n                  <ModelCard\n                    key={model.id}\n                    model={model}\n                    showPricing={showPricing}\n                    onToggleEnabled={handleToggleEnabled}\n                    onRemove={handleRemoveModel}\n                  />\n                ))}\n              </div>\n            </Droppable>\n          </DndKitContext>\n        </div>\n      ) : (\n        availableRecommendedIds.length === 0 && (\n          <div className=\"rounded-lg border border-dashed p-6 text-center\">\n            <p className=\"text-sm text-muted-foreground\">{t('admin.setting.ai.noGatewayModels')}</p>\n          </div>\n        )\n      )}\n\n      {/* Add Model Dialog */}\n      <AddModelDialog\n        open={isAddDialogOpen}\n        onOpenChange={setIsAddDialogOpen}\n        newModel={newModel}\n        onNewModelChange={setNewModel}\n        modelSearchOpen={modelSearchOpen}\n        onModelSearchOpenChange={setModelSearchOpen}\n        searchQuery={searchQuery}\n        onSearchQueryChange={setSearchQuery}\n        isModelIdValid={isModelIdValid}\n        isLoadingModels={isLoadingModels}\n        modelsLoadError={modelsLoadError}\n        filteredModels={filteredModels}\n        gatewayModels={gatewayModels}\n        availableModelsCount={availableModels.length}\n        onSelectModel={handleSelectModel}\n        onRetry={fetchModels}\n        showPricing={showPricing}\n        pricingExpanded={pricingExpanded}\n        onPricingExpandedChange={setPricingExpanded}\n        onPricingChange={updatePricing}\n        testState={testState}\n        onAddModel={handleAddModel}\n        t={t}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LLMApiConfigStep.tsx",
    "content": "'use client';\n\nimport { Check, ArrowUpRight, Database, Zap, AlertTriangle, RotateCw } from '@teable/icons';\nimport type {\n  ISettingVo,\n  LLMProvider,\n  IChatModelAbility,\n  IImageModelAbility,\n  IAIIntegrationConfig,\n  IAttachmentTestResult,\n  ITestLLMRo,\n} from '@teable/openapi';\nimport { Button, Input, Label, cn, Switch } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useCallback, useMemo, useEffect } from 'react';\nimport type { Control } from 'react-hook-form';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { AIProviderCard } from './AIProviderCard';\nimport type { LLMApiMode } from './AISetupWizard';\nimport { BatchTestModels } from './BatchTestModels';\nimport type { IModelTestResult } from './LlmproviderManage';\n\ninterface ILLMApiConfigStepProps {\n  // Mode selection\n  mode: LLMApiMode;\n  onModeChange: (mode: LLMApiMode) => void;\n\n  // Gateway config (now part of aiConfig)\n  aiConfig?: ISettingVo['aiConfig'];\n  onAiConfigChange?: (config: Partial<NonNullable<ISettingVo['aiConfig']>>) => void;\n\n  // Custom provider config\n  llmProviders: LLMProvider[];\n  onProvidersChange: (providers: LLMProvider[]) => void;\n  control: Control<IAIIntegrationConfig>;\n  modelTestResults: Map<string, IModelTestResult>;\n  onModelTestResultsChange: (results: Map<string, IModelTestResult>) => void;\n  testingProviders: Set<string>;\n  onTestingProvidersChange: (providers: Set<string>) => void;\n  testingModels: Set<string>;\n  onTestingModelsChange: (models: Set<string>) => void;\n  onSaveTestResult: (\n    modelKey: string,\n    ability: IChatModelAbility | undefined,\n    imageAbility: IImageModelAbility | undefined\n  ) => void;\n  onToggleImageModel: (modelKey: string, isImageModel: boolean) => void;\n  testProviderCallbackRef: React.MutableRefObject<((provider: LLMProvider) => void) | null>;\n  testModelCallbackRef: React.MutableRefObject<\n    ((provider: LLMProvider, model: string, modelKey: string) => Promise<void>) | null\n  >;\n\n  // Callbacks\n  onComplete?: () => void;\n  onResetGateway?: () => void;\n\n  /** Whether to show pricing-related UI. Defaults to true (Cloud). */\n  showPricing?: boolean;\n}\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nexport function LLMApiConfigStep({\n  mode,\n  onModeChange,\n  aiConfig,\n  onAiConfigChange,\n  llmProviders,\n  onProvidersChange,\n  control,\n  modelTestResults,\n  onModelTestResultsChange,\n  testingProviders,\n  onTestingProvidersChange,\n  testingModels,\n  onTestingModelsChange,\n  onSaveTestResult,\n  onToggleImageModel,\n  testProviderCallbackRef,\n  testModelCallbackRef,\n  onComplete,\n  onResetGateway,\n  showPricing = true,\n}: ILLMApiConfigStepProps) {\n  const { t } = useTranslation('common');\n  const { publicOrigin } = useEnv();\n  const [isTesting, setIsTesting] = useState(false);\n  const [testResult, setTestResult] = useState<'success' | 'error' | null>(null);\n  const [testErrorMessage, setTestErrorMessage] = useState<string | null>(null);\n  const [attachmentTestResult, setAttachmentTestResult] = useState<IAttachmentTestResult | null>(\n    null\n  );\n\n  // Local state for gateway fields — only persisted after a successful test\n  const [localGatewayKey, setLocalGatewayKey] = useState(aiConfig?.aiGatewayApiKey || '');\n  const [localGatewayBaseUrl, setLocalGatewayBaseUrl] = useState(aiConfig?.aiGatewayBaseUrl || '');\n\n  useEffect(() => {\n    setLocalGatewayKey(aiConfig?.aiGatewayApiKey || '');\n  }, [aiConfig?.aiGatewayApiKey]);\n\n  useEffect(() => {\n    setLocalGatewayBaseUrl(aiConfig?.aiGatewayBaseUrl || '');\n  }, [aiConfig?.aiGatewayBaseUrl]);\n\n  const hasLocalGatewayKey = Boolean(localGatewayKey);\n  const hasProviders = llmProviders.length > 0;\n\n  const isGatewayKeyVerified =\n    testResult === 'success' ||\n    (testResult !== 'error' &&\n      Boolean(aiConfig?.aiGatewayApiKey) &&\n      localGatewayKey === (aiConfig?.aiGatewayApiKey || ''));\n\n  const canProceed = mode === 'gateway' ? isGatewayKeyVerified : hasProviders;\n\n  // Get saved attachment test from aiConfig\n  const savedAttachmentTest = useMemo(() => aiConfig?.attachmentTest, [aiConfig?.attachmentTest]);\n  const currentTransferMode = aiConfig?.attachmentTransferMode || 'url';\n\n  // Check if PUBLIC_ORIGIN has changed since last test\n  const originChanged = useMemo(() => {\n    const testedOrigin = savedAttachmentTest?.testedOrigin;\n    if (!testedOrigin || !publicOrigin) return false;\n    return testedOrigin !== publicOrigin;\n  }, [savedAttachmentTest?.testedOrigin, publicOrigin]);\n\n  const handleClearGatewayKey = useCallback(() => {\n    setLocalGatewayKey('');\n    setLocalGatewayBaseUrl('');\n    setTestResult(null);\n    setTestErrorMessage(null);\n    setAttachmentTestResult(null);\n    onResetGateway?.();\n  }, [onResetGateway]);\n\n  const handleTestGateway = useCallback(async () => {\n    if (!localGatewayKey) return;\n\n    setIsTesting(true);\n    setTestResult(null);\n    setTestErrorMessage(null);\n    setAttachmentTestResult(null);\n\n    try {\n      const { testApiKey } = await import('@teable/openapi');\n      const result = await testApiKey({\n        type: 'aiGateway',\n        apiKey: localGatewayKey,\n        baseUrl: localGatewayBaseUrl || undefined,\n        testAttachment: true,\n      });\n\n      if (result.success) {\n        setTestResult('success');\n        const updates: Partial<NonNullable<ISettingVo['aiConfig']>> = {\n          ...aiConfig,\n          aiGatewayApiKey: localGatewayKey,\n          aiGatewayBaseUrl: localGatewayBaseUrl || undefined,\n        };\n        if (result.attachmentTest) {\n          setAttachmentTestResult(result.attachmentTest);\n          updates.attachmentTest = result.attachmentTest;\n          if (\n            !result.attachmentTest.urlMode?.success &&\n            result.attachmentTest.base64Mode?.success\n          ) {\n            updates.attachmentTransferMode = 'base64';\n          }\n        }\n        onAiConfigChange?.(updates);\n        return;\n      }\n\n      // Handle error codes\n      setTestResult('error');\n      switch (result.error?.code) {\n        case 'unauthorized':\n          setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorUnauthorized'));\n          break;\n        case 'forbidden':\n          setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorForbidden'));\n          break;\n        case 'need_credit_card':\n          setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorNeedCreditCard'));\n          break;\n        case 'insufficient_quota':\n          setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorInsufficientQuota'));\n          break;\n        case 'network_error':\n          setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorNetwork'));\n          break;\n        default:\n          setTestErrorMessage(result.error?.message || t('admin.setting.ai.wizard.keyInvalid'));\n      }\n    } catch {\n      setTestResult('error');\n      setTestErrorMessage(t('admin.setting.ai.wizard.gatewayErrorNetwork'));\n    } finally {\n      setIsTesting(false);\n    }\n  }, [localGatewayKey, localGatewayBaseUrl, aiConfig, onAiConfigChange, t]);\n\n  // Handle manual mode switch\n  const handleTransferModeChange = useCallback(\n    (useBase64: boolean) => {\n      const newMode = useBase64 ? 'base64' : 'url';\n      onAiConfigChange?.({ ...aiConfig, attachmentTransferMode: newMode });\n    },\n    [aiConfig, onAiConfigChange]\n  );\n\n  // Determine the effective attachment test result (from current test or saved)\n  const effectiveAttachmentTest = attachmentTestResult || savedAttachmentTest;\n\n  const handleTest = async (data: ITestLLMRo) => {\n    const { testLLM } = await import('@teable/openapi');\n    return testLLM(data);\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Mode Selection - using div buttons instead of RadioGroup to avoid double-trigger */}\n      <div className=\"grid grid-cols-2 gap-3\">\n        {/* Gateway Option */}\n        <div\n          className={cn(\n            'relative flex cursor-pointer flex-col rounded-lg border p-4 transition-all',\n            mode === 'gateway'\n              ? 'border-primary bg-accent'\n              : 'border-border hover:border-primary/30'\n          )}\n          role=\"button\"\n          tabIndex={0}\n          onClick={() => onModeChange('gateway')}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault();\n              onModeChange('gateway');\n            }\n          }}\n        >\n          <div className=\"flex items-center gap-2\">\n            <div\n              className={cn(\n                'flex size-8 shrink-0 items-center justify-center rounded-lg',\n                mode === 'gateway' ? 'bg-primary text-primary-foreground' : 'bg-surface'\n              )}\n            >\n              <Zap className=\"size-4\" />\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"truncate font-semibold\">AI Gateway</span>\n                <span className=\"shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary\">\n                  {t('admin.setting.ai.recommended')}\n                </span>\n              </div>\n            </div>\n          </div>\n          <p className=\"mt-2 text-xs text-muted-foreground\">\n            {t('admin.setting.ai.wizard.gatewayOption.desc')}\n          </p>\n        </div>\n\n        {/* Custom Provider Option */}\n        <div\n          className={cn(\n            'relative flex cursor-pointer flex-col rounded-lg border p-4 transition-all',\n            mode === 'custom' ? 'border-primary bg-accent' : 'border-border hover:border-primary/30'\n          )}\n          role=\"button\"\n          tabIndex={0}\n          onClick={() => onModeChange('custom')}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault();\n              onModeChange('custom');\n            }\n          }}\n        >\n          <div className=\"flex items-center gap-2\">\n            <div\n              className={cn(\n                'flex size-9 shrink-0 items-center justify-center rounded-lg',\n                mode === 'custom' ? 'bg-primary text-primary-foreground' : 'bg-surface'\n              )}\n            >\n              <Database className=\"size-4\" />\n            </div>\n            <span className=\"truncate font-semibold\">\n              {t('admin.setting.ai.wizard.customOption.title')}\n            </span>\n          </div>\n          <p className=\"mt-2 text-xs text-muted-foreground\">\n            {t('admin.setting.ai.wizard.customOption.desc')}\n          </p>\n        </div>\n      </div>\n\n      {/* Gateway Configuration */}\n      {mode === 'gateway' && (\n        <div className=\"space-y-4 rounded-lg border bg-muted/30 p-4\">\n          {/* Help text */}\n          <div className=\"rounded-md bg-background text-sm text-muted-foreground\">\n            {t('admin.setting.ai.wizard.gatewayHelp')}{' '}\n            <Link\n              href=\"https://vercel.com/docs/ai-gateway/byok\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-primary hover:underline\"\n            >\n              BYOK\n            </Link>\n            {t('admin.setting.ai.wizard.gatewayByok')}\n          </div>\n\n          {/* API Key Input */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2\">\n              <Label htmlFor=\"gateway-key\">{t('admin.setting.app.aiGatewayApiKey')} *</Label>\n              <Button variant=\"outline\" size=\"xs\" asChild className=\"h-6 gap-1\">\n                <Link\n                  href=\"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys\"\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                >\n                  {t('admin.setting.ai.wizard.getApiKey')}\n                  <ArrowUpRight className=\"size-3\" />\n                </Link>\n              </Button>\n            </div>\n            <div className=\"flex gap-2\">\n              <Input\n                id=\"gateway-key\"\n                type=\"password\"\n                value={localGatewayKey}\n                placeholder={t('admin.action.enterApiKey')}\n                onChange={(e) => {\n                  setLocalGatewayKey(e.target.value?.trim() || '');\n                  setTestResult(null);\n                }}\n                className=\"flex-1\"\n              />\n              <Button\n                variant=\"outline\"\n                onClick={handleTestGateway}\n                disabled={!hasLocalGatewayKey || isTesting}\n              >\n                {isTesting\n                  ? t('admin.setting.ai.wizard.testing')\n                  : t('admin.setting.ai.wizard.test')}\n              </Button>\n            </div>\n\n            {/* Status indicator - only show after test */}\n            {testResult === 'success' && (\n              <div className=\"flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400\">\n                <Check className=\"size-4\" />\n                <span>{t('admin.setting.app.aiGatewayKeyConfigured')}</span>\n              </div>\n            )}\n            {testResult === 'error' && (\n              <div className=\"text-sm text-destructive\">\n                {testErrorMessage || t('admin.setting.ai.wizard.keyInvalid')}\n              </div>\n            )}\n            {hasLocalGatewayKey && !testResult && (\n              <p className=\"text-xs text-muted-foreground\">\n                {t('admin.setting.ai.wizard.pleaseTest')}\n              </p>\n            )}\n\n            {/* Origin Changed Warning */}\n            {originChanged && savedAttachmentTest && (\n              <div className=\"mt-3 flex items-start gap-2 rounded-md bg-amber-50 p-3 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400\">\n                <AlertTriangle className=\"mt-0.5 size-4 shrink-0\" />\n                <div className=\"text-sm\">\n                  <div className=\"font-medium\">\n                    {t('admin.setting.ai.wizard.attachmentTest.originChanged')}\n                  </div>\n                  <div className=\"mt-1 text-xs\">\n                    {t('admin.setting.ai.wizard.attachmentTest.originChangedDesc')}\n                  </div>\n                </div>\n              </div>\n            )}\n\n            {/* Attachment Transfer Mode Test Results */}\n            {effectiveAttachmentTest && !originChanged && (\n              <div className=\"mt-3 rounded-md border bg-muted p-3\">\n                <div className=\"mb-2 text-sm font-medium\">\n                  {t('admin.setting.ai.wizard.attachmentTest.title')}\n                </div>\n                <div className=\"space-y-2 text-sm\">\n                  {/* URL Mode Result */}\n                  <div className=\"flex items-center gap-2\">\n                    {effectiveAttachmentTest.urlMode?.success ? (\n                      <Check className=\"size-4 text-green-600 dark:text-green-400\" />\n                    ) : (\n                      <AlertTriangle className=\"size-4 text-amber-500\" />\n                    )}\n                    <span>\n                      {t('admin.setting.ai.wizard.attachmentTest.urlMode')}:{' '}\n                      {effectiveAttachmentTest.urlMode?.success\n                        ? t('admin.setting.ai.wizard.attachmentTest.accessible')\n                        : t('admin.setting.ai.wizard.attachmentTest.inaccessible')}\n                    </span>\n                  </div>\n                  {/* Base64 Mode Result */}\n                  <div className=\"flex items-center gap-2\">\n                    {effectiveAttachmentTest.base64Mode?.success ? (\n                      <Check className=\"size-4 text-green-600 dark:text-green-400\" />\n                    ) : (\n                      <AlertTriangle className=\"size-4 text-amber-500\" />\n                    )}\n                    <span>\n                      {t('admin.setting.ai.wizard.attachmentTest.base64Mode')}:{' '}\n                      {effectiveAttachmentTest.base64Mode?.success\n                        ? t('admin.setting.ai.wizard.attachmentTest.accessible')\n                        : t('admin.setting.ai.wizard.attachmentTest.inaccessible')}\n                    </span>\n                  </div>\n                  {/* Warning if URL mode failed but Base64 works */}\n                  {!effectiveAttachmentTest.urlMode?.success &&\n                    effectiveAttachmentTest.base64Mode?.success && (\n                      <div className=\"mt-2 flex items-start gap-2 rounded-md bg-amber-50 p-2 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400\">\n                        <AlertTriangle className=\"mt-0.5 size-4 shrink-0\" />\n                        <span className=\"text-xs\">\n                          {t('admin.setting.ai.wizard.attachmentTest.urlNotAccessibleWarning')}\n                        </span>\n                      </div>\n                    )}\n                  {/* Mode Switch */}\n                  <div className=\"mt-3 flex items-center justify-between border-t pt-3\">\n                    <div>\n                      <div className=\"text-sm font-medium\">\n                        {t('admin.setting.ai.wizard.attachmentTest.useBase64Mode')}\n                      </div>\n                      <div className=\"text-xs text-muted-foreground\">\n                        {t('admin.setting.ai.wizard.attachmentTest.base64ModeDescription')}\n                      </div>\n                    </div>\n                    <Switch\n                      checked={currentTransferMode === 'base64'}\n                      onCheckedChange={handleTransferModeChange}\n                    />\n                  </div>\n                </div>\n              </div>\n            )}\n          </div>\n\n          {/* Base URL (Optional) */}\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"gateway-url\">\n              {t('admin.setting.app.aiGatewayBaseUrl')}\n              <span className=\"ml-1 text-xs text-muted-foreground\">\n                ({t('admin.setting.ai.wizard.optional')})\n              </span>\n            </Label>\n            <Input\n              id=\"gateway-url\"\n              type=\"text\"\n              value={localGatewayBaseUrl}\n              placeholder=\"https://ai-gateway.vercel.sh/v1\"\n              onChange={(e) => {\n                setLocalGatewayBaseUrl(e.target.value?.trim() || '');\n                setTestResult(null);\n              }}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* Custom Provider Configuration */}\n      {mode === 'custom' && (\n        <div className=\"space-y-4 rounded-lg border bg-muted/30 p-4\">\n          <p className=\"text-sm text-muted-foreground\">\n            {t('admin.setting.ai.wizard.customProviderHelp')}\n          </p>\n\n          <AIProviderCard\n            control={control}\n            onChange={onProvidersChange}\n            onTest={handleTest}\n            modelTestResults={modelTestResults}\n            onToggleImageModel={onToggleImageModel}\n            onTestProvider={(provider) => testProviderCallbackRef.current?.(provider)}\n            onTestModel={(provider, model, modelKey) =>\n              testModelCallbackRef.current?.(provider, model, modelKey) ?? Promise.resolve()\n            }\n            testingProviders={testingProviders}\n            testingModels={testingModels}\n            hideModelRates={!showPricing}\n            onSaveTestResult={onSaveTestResult}\n          />\n\n          {/* Test Model Capabilities - moved to bottom */}\n          {llmProviders.length > 0 && (\n            <div className=\"space-y-3 border-t pt-4\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"text-sm font-medium\">\n                  {t('admin.setting.ai.wizard.testModelCapabilities')}\n                </div>\n                <BatchTestModels\n                  providers={llmProviders}\n                  disabled={!llmProviders?.length}\n                  onTest={handleTest}\n                  onResultsChange={(results) => {\n                    onModelTestResultsChange(results);\n                    // Check if any model supports URL mode for attachments\n                    const hasUrlSupport = Array.from(results.values()).some((result) => {\n                      if (result.status === 'success' && result.ability?.image) {\n                        // ability.image can be a boolean or an object with url/base64 properties\n                        const imageAbility = result.ability.image;\n                        if (typeof imageAbility === 'object') {\n                          return imageAbility.url === true;\n                        }\n                        // If it's a boolean true, assume URL mode works\n                        return imageAbility === true;\n                      }\n                      return false;\n                    });\n                    // If any model supports URL mode, set global mode to 'url'\n                    // Otherwise, set to 'base64' (safer for internal networks)\n                    if (results.size > 0) {\n                      const recommendedMode = hasUrlSupport ? 'url' : 'base64';\n                      onAiConfigChange?.({\n                        ...aiConfig,\n                        attachmentTransferMode: recommendedMode,\n                        attachmentTest: {\n                          urlMode: { success: hasUrlSupport },\n                          base64Mode: { success: true }, // Base64 always works for custom providers\n                          recommendedMode,\n                          testedOrigin: publicOrigin,\n                          testedAt: new Date().toISOString(),\n                        },\n                      });\n                    }\n                  }}\n                  onSaveResult={onSaveTestResult}\n                  onTestingProvidersChange={onTestingProvidersChange}\n                  onTestingModelsChange={onTestingModelsChange}\n                  onTestProvider={(callback) => {\n                    testProviderCallbackRef.current = callback;\n                  }}\n                  onTestModel={(callback) => {\n                    testModelCallbackRef.current = callback;\n                  }}\n                />\n              </div>\n\n              {/* Custom Provider Attachment Test Results (reuse same UI) */}\n              {aiConfig?.attachmentTest && (\n                <div className=\"rounded-md border bg-background p-3\">\n                  <div className=\"mb-2 text-sm font-medium\">\n                    {t('admin.setting.ai.wizard.attachmentTest.title')}\n                  </div>\n                  <div className=\"space-y-2 text-sm\">\n                    {/* URL Mode Result */}\n                    <div className=\"flex items-center gap-2\">\n                      {aiConfig.attachmentTest.urlMode?.success ? (\n                        <Check className=\"size-4 text-green-600 dark:text-green-400\" />\n                      ) : (\n                        <AlertTriangle className=\"size-4 text-amber-500\" />\n                      )}\n                      <span>\n                        {t('admin.setting.ai.wizard.attachmentTest.urlMode')}:{' '}\n                        {aiConfig.attachmentTest.urlMode?.success\n                          ? t('admin.setting.ai.wizard.attachmentTest.accessible')\n                          : t('admin.setting.ai.wizard.attachmentTest.inaccessible')}\n                      </span>\n                    </div>\n                    {/* Base64 Mode Result */}\n                    <div className=\"flex items-center gap-2\">\n                      {aiConfig.attachmentTest.base64Mode?.success ? (\n                        <Check className=\"size-4 text-green-600 dark:text-green-400\" />\n                      ) : (\n                        <AlertTriangle className=\"size-4 text-amber-500\" />\n                      )}\n                      <span>\n                        {t('admin.setting.ai.wizard.attachmentTest.base64Mode')}:{' '}\n                        {aiConfig.attachmentTest.base64Mode?.success\n                          ? t('admin.setting.ai.wizard.attachmentTest.accessible')\n                          : t('admin.setting.ai.wizard.attachmentTest.inaccessible')}\n                      </span>\n                    </div>\n                    {/* Warning if URL mode not supported */}\n                    {!aiConfig.attachmentTest.urlMode?.success &&\n                      aiConfig.attachmentTest.base64Mode?.success && (\n                        <div className=\"mt-2 flex items-start gap-2 rounded-md bg-amber-50 p-2 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400\">\n                          <AlertTriangle className=\"mt-0.5 size-4 shrink-0\" />\n                          <span className=\"text-xs\">\n                            {t('admin.setting.ai.wizard.attachmentTest.urlNotAccessibleWarning')}\n                          </span>\n                        </div>\n                      )}\n                    {/* Mode Switch */}\n                    <div className=\"mt-3 flex items-center justify-between border-t pt-3\">\n                      <div>\n                        <div className=\"text-sm font-medium\">\n                          {t('admin.setting.ai.wizard.attachmentTest.useBase64Mode')}\n                        </div>\n                        <div className=\"text-xs text-muted-foreground\">\n                          {t('admin.setting.ai.wizard.attachmentTest.base64ModeDescription')}\n                        </div>\n                      </div>\n                      <Switch\n                        checked={currentTransferMode === 'base64'}\n                        onCheckedChange={handleTransferModeChange}\n                      />\n                    </div>\n                  </div>\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Continue Button */}\n      <div className=\"flex justify-end gap-2\">\n        {mode === 'gateway' && Boolean(aiConfig?.aiGatewayApiKey) && (\n          <Button variant=\"outline\" onClick={handleClearGatewayKey}>\n            <RotateCw className=\"mr-1.5 size-3.5\" />\n            Reset\n          </Button>\n        )}\n        <Button onClick={onComplete} disabled={!canProceed}>\n          {t('admin.setting.ai.wizard.saveAndContinue')}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmProviderForm.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-unused-vars */\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { AlertCircle, Check, Loader2, Plus, X, Eye, Image, HelpCircle } from '@teable/icons';\nimport { llmProviderSchema, LLMProviderType, chatModelAbilityType } from '@teable/openapi';\nimport type {\n  ITestLLMVo,\n  ITestLLMRo,\n  LLMProvider,\n  IModelConfig,\n  IChatModelAbility,\n  IImageModelAbility,\n} from '@teable/openapi';\nimport {\n  Button,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  Input,\n  Progress,\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { ChevronDown, ChevronUp, Square } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport type { PropsWithChildren } from 'react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { LLM_PROVIDERS } from './constant';\n\nconst CUSTOM_MODEL_DOC_URL = 'https://help.teable.ai/en/basic/ai/custom-model';\n\ninterface TestResult {\n  success: boolean;\n  message?: string;\n  suggestions?: string[];\n}\n\n// Model test result interface for full capability testing\ninterface IModelTestStatus {\n  model: string;\n  status: 'idle' | 'pending' | 'testing' | 'success' | 'failed';\n  error?: string;\n  ability?: IChatModelAbility;\n  imageAbility?: IImageModelAbility;\n  isImageModel?: boolean;\n}\n\nconst TEXT_MODEL_TIMEOUT_MS = 30000; // 30 seconds timeout for text models\nconst IMAGE_MODEL_TIMEOUT_MS = 120000; // 2 minutes timeout for image models\nconst CONCURRENCY = 3; // Concurrent test count\n\n// Helper to wrap promise with timeout\nconst withTimeout = <T,>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> => {\n  return Promise.race([\n    promise,\n    new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),\n  ]);\n};\n\ntype ErrorPattern = {\n  keywords: string[];\n  suggestion: string;\n  condition?: (ctx: { type: LLMProviderType; lowerUrl: string }) => boolean;\n};\n\nconst ERROR_PATTERNS: ErrorPattern[] = [\n  {\n    keywords: ['401', 'unauthorized', 'invalid api key', 'authentication'],\n    suggestion: 'hint.checkApiKey',\n  },\n  {\n    keywords: ['401', 'unauthorized'],\n    suggestion: 'hint.azureDeployment',\n    condition: ({ type }) => type === LLMProviderType.AZURE,\n  },\n  {\n    keywords: ['403', 'forbidden', 'quota', 'rate limit'],\n    suggestion: 'hint.checkQuotaOrPermission',\n  },\n  {\n    keywords: ['econnrefused', 'enotfound', 'timeout', 'network'],\n    suggestion: 'hint.checkConnection',\n  },\n  {\n    keywords: ['econnrefused', 'enotfound'],\n    suggestion: 'hint.ollamaRunning',\n    condition: ({ type }) => type === LLMProviderType.OLLAMA,\n  },\n  {\n    keywords: ['ssl', 'certificate'],\n    suggestion: 'hint.sslCertificate',\n  },\n];\n\nfunction matchesKeywords(text: string, keywords: string[]): boolean {\n  return keywords.some((kw) => text.includes(kw));\n}\n\nfunction checkMissingV1Suffix(\n  lowerError: string,\n  lowerUrl: string,\n  type: LLMProviderType\n): string | null {\n  const is404 = matchesKeywords(lowerError, ['404', 'not found', 'invalid url']);\n  const hasV1 = lowerUrl.endsWith('/v1') || lowerUrl.endsWith('/v1/');\n  const needsV1 = type !== LLMProviderType.OLLAMA && type !== LLMProviderType.GOOGLE;\n  if (!is404 || hasV1 || !needsV1) return null;\n\n  const placeholder = LLM_PROVIDERS.find((p) => p.value === type)?.baseUrlPlaceholder;\n  return placeholder?.includes('/v1') ? 'hint.missingV1Suffix' : null;\n}\n\nfunction analyzeError(\n  error: string,\n  baseUrl: string,\n  type: LLMProviderType\n): { message: string; suggestions: string[] } {\n  const suggestions: string[] = [];\n  const lowerError = error.toLowerCase();\n  const lowerUrl = baseUrl.toLowerCase();\n  const ctx = { type, lowerUrl };\n\n  // Check for missing /v1 suffix\n  const v1Hint = checkMissingV1Suffix(lowerError, lowerUrl, type);\n  if (v1Hint) suggestions.push(v1Hint);\n\n  // Check for trailing slash\n  if (lowerUrl.endsWith('/') && lowerError.includes('404')) {\n    suggestions.push('hint.removeTrailingSlash');\n  }\n\n  // Check model not found\n  const isModelNotFound =\n    lowerError.includes('model') &&\n    (lowerError.includes('not found') || lowerError.includes('does not exist'));\n  if (isModelNotFound) suggestions.push('hint.checkModelName');\n\n  // Match other patterns\n  for (const pattern of ERROR_PATTERNS) {\n    const matches = matchesKeywords(lowerError, pattern.keywords);\n    const conditionMet = !pattern.condition || pattern.condition(ctx);\n    if (matches && conditionMet && !suggestions.includes(pattern.suggestion)) {\n      suggestions.push(pattern.suggestion);\n    }\n  }\n\n  // Fallback\n  if (suggestions.length === 0) suggestions.push('hint.checkConfiguration');\n\n  return { message: error, suggestions };\n}\n\ninterface LLMProviderFormProps {\n  value?: LLMProvider;\n  onChange?: (value: LLMProvider) => void;\n  onAdd?: (data: LLMProvider) => void;\n  /** Test function - accepts full ITestLLMRo for capability testing */\n  onTest?: (data: ITestLLMRo) => Promise<ITestLLMVo>;\n  /** Hide model rates config (for space-level settings where billing doesn't apply) */\n  hideModelRates?: boolean;\n  /** Callback to save model test results */\n  onSaveTestResult?: (\n    modelKey: string,\n    ability: IChatModelAbility | undefined,\n    imageAbility: IImageModelAbility | undefined\n  ) => void;\n}\n\nexport const UpdateLLMProviderForm = ({\n  value,\n  children,\n  onChange,\n  onTest,\n  hideModelRates,\n  onSaveTestResult,\n}: PropsWithChildren<Omit<LLMProviderFormProps, 'onAdd'>>) => {\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation('common');\n  const handleChange = (data: LLMProvider) => {\n    onChange?.(data);\n    setOpen(false);\n  };\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            {t('admin.setting.ai.updateLLMProvider')}\n            <a href={CUSTOM_MODEL_DOC_URL} target=\"_blank\" rel=\"noopener noreferrer\">\n              <HelpCircle className=\"size-4 text-muted-foreground hover:text-foreground\" />\n            </a>\n          </DialogTitle>\n        </DialogHeader>\n        <LLMProviderForm\n          value={value}\n          onChange={handleChange}\n          onTest={onTest}\n          hideModelRates={hideModelRates}\n          onSaveTestResult={onSaveTestResult}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport const NewLLMProviderForm = ({\n  children,\n  onAdd,\n  onTest,\n  hideModelRates,\n  onSaveTestResult,\n}: PropsWithChildren<Omit<LLMProviderFormProps, 'onChange'>>) => {\n  const { t } = useTranslation();\n  const [open, setOpen] = useState(false);\n  const handleAdd = (data: LLMProvider) => {\n    onAdd?.(data);\n    setOpen(false);\n  };\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        {children ?? (\n          <Button size=\"sm\" variant=\"outline\" className=\"w-fit gap-2\">\n            <Plus className=\"size-4\" />\n            {t('admin.setting.ai.addProvider')}\n          </Button>\n        )}\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-[450px]\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            {t('admin.setting.ai.addProvider')}\n            <a href={CUSTOM_MODEL_DOC_URL} target=\"_blank\" rel=\"noopener noreferrer\">\n              <HelpCircle className=\"size-4 text-muted-foreground hover:text-foreground\" />\n            </a>\n          </DialogTitle>\n          <DialogDescription>{t('admin.setting.ai.addProviderDescription')}</DialogDescription>\n        </DialogHeader>\n        <LLMProviderForm\n          onAdd={handleAdd}\n          onTest={onTest}\n          hideModelRates={hideModelRates}\n          onSaveTestResult={onSaveTestResult}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n\n// Rate field keys for model configuration\ntype RateFieldKey =\n  | 'inputRate'\n  | 'outputRate'\n  | 'cacheReadRate'\n  | 'cacheWriteRate'\n  | 'reasoningRate'\n  | 'imageRate';\n\n// Component for configuring rates per model\ninterface ModelRatesConfigProps {\n  models: string;\n  modelConfigs: Record<string, IModelConfig> | undefined;\n  onChange: (configs: Record<string, IModelConfig>) => void;\n}\n\nconst ModelRatesConfig = ({ models, modelConfigs = {}, onChange }: ModelRatesConfigProps) => {\n  const { t } = useTranslation();\n  const [expanded, setExpanded] = useState(false);\n  const [showAdvanced, setShowAdvanced] = useState(false);\n\n  const modelList = useMemo(() => {\n    return models\n      .split(',')\n      .map((m) => m.trim())\n      .filter(Boolean);\n  }, [models]);\n\n  if (modelList.length === 0) return null;\n\n  const handleRateChange = (model: string, field: RateFieldKey, value: string) => {\n    const numValue = value === '' ? undefined : parseFloat(value) || 0;\n    const currentConfig = modelConfigs[model] || {};\n    onChange({\n      ...modelConfigs,\n      [model]: {\n        ...currentConfig,\n        [field]: numValue,\n      },\n    });\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <button\n        type=\"button\"\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground\"\n      >\n        {expanded ? <ChevronUp className=\"size-4\" /> : <ChevronDown className=\"size-4\" />}\n        {t('admin.setting.ai.modelRates')} ({modelList.length})\n      </button>\n\n      {expanded && (\n        <div className=\"space-y-3 rounded-md border bg-muted/20 p-3\">\n          {/* Rate explanation */}\n          <div className=\"rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-950 dark:text-blue-200\">\n            <div className=\"font-medium\">{t('admin.setting.ai.rateExplanationTitle')}</div>\n            <div className=\"mt-1 space-y-0.5 text-[11px] opacity-90\">\n              <div>• {t('admin.setting.ai.rateExplanationFormula')}</div>\n              <div>• {t('admin.setting.ai.rateExplanationExample')}</div>\n            </div>\n          </div>\n\n          {/* Basic rates */}\n          <div className=\"space-y-2\">\n            <div className=\"grid grid-cols-[1fr,80px,80px] gap-2 text-xs font-medium text-muted-foreground\">\n              <div>{t('admin.setting.ai.model')}</div>\n              <div title={t('admin.setting.ai.inputRateTip')}>\n                {t('admin.setting.ai.inputRate')}\n              </div>\n              <div title={t('admin.setting.ai.outputRateTip')}>\n                {t('admin.setting.ai.outputRate')}\n              </div>\n            </div>\n            {modelList.map((model) => {\n              const config = modelConfigs[model] || {};\n              return (\n                <div key={model} className=\"grid grid-cols-[1fr,80px,80px] items-center gap-2\">\n                  <div className=\"truncate text-sm\" title={model}>\n                    {model}\n                  </div>\n                  <Input\n                    type=\"number\"\n                    step=\"0.0001\"\n                    min=\"0\"\n                    value={config.inputRate ?? ''}\n                    onChange={(e) => handleRateChange(model, 'inputRate', e.target.value)}\n                    placeholder=\"0\"\n                    size=\"sm\"\n                  />\n                  <Input\n                    type=\"number\"\n                    step=\"0.0001\"\n                    min=\"0\"\n                    value={config.outputRate ?? ''}\n                    onChange={(e) => handleRateChange(model, 'outputRate', e.target.value)}\n                    placeholder=\"0\"\n                    size=\"sm\"\n                  />\n                </div>\n              );\n            })}\n          </div>\n\n          {/* Advanced rates toggle */}\n          <button\n            type=\"button\"\n            onClick={() => setShowAdvanced(!showAdvanced)}\n            className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground\"\n          >\n            {showAdvanced ? <ChevronUp className=\"size-3\" /> : <ChevronDown className=\"size-3\" />}\n            {t('admin.setting.ai.advancedRates')}\n          </button>\n\n          {/* Advanced rates (cache, reasoning, image) */}\n          {showAdvanced && (\n            <div className=\"space-y-2 rounded border bg-background/50 p-2\">\n              <div className=\"grid grid-cols-[1fr,70px,70px,70px,70px] gap-1 text-[10px] font-medium text-muted-foreground\">\n                <div>{t('admin.setting.ai.model')}</div>\n                <div title={t('admin.setting.ai.cacheReadRateTip')}>\n                  {t('admin.setting.ai.cacheRead')}\n                </div>\n                <div title={t('admin.setting.ai.cacheWriteRateTip')}>\n                  {t('admin.setting.ai.cacheWrite')}\n                </div>\n                <div title={t('admin.setting.ai.reasoningRateTip')}>\n                  {t('admin.setting.ai.reasoning')}\n                </div>\n                <div title={t('admin.setting.ai.imageRateTip')}>\n                  {t('admin.setting.ai.perImage')}\n                </div>\n              </div>\n              {modelList.map((model) => {\n                const config = modelConfigs[model] || {};\n                return (\n                  <div\n                    key={`adv-${model}`}\n                    className=\"grid grid-cols-[1fr,70px,70px,70px,70px] items-center gap-1\"\n                  >\n                    <div className=\"truncate text-xs\" title={model}>\n                      {model}\n                    </div>\n                    <Input\n                      type=\"number\"\n                      step=\"0.0001\"\n                      min=\"0\"\n                      value={config.cacheReadRate ?? ''}\n                      onChange={(e) => handleRateChange(model, 'cacheReadRate', e.target.value)}\n                      placeholder=\"auto\"\n                      size=\"xs\"\n                      className=\"text-[10px]\"\n                    />\n                    <Input\n                      type=\"number\"\n                      step=\"0.0001\"\n                      min=\"0\"\n                      value={config.cacheWriteRate ?? ''}\n                      onChange={(e) => handleRateChange(model, 'cacheWriteRate', e.target.value)}\n                      placeholder=\"auto\"\n                      size=\"xs\"\n                      className=\"text-[10px]\"\n                    />\n                    <Input\n                      type=\"number\"\n                      step=\"0.0001\"\n                      min=\"0\"\n                      value={config.reasoningRate ?? ''}\n                      onChange={(e) => handleRateChange(model, 'reasoningRate', e.target.value)}\n                      placeholder=\"auto\"\n                      size=\"xs\"\n                      className=\"text-[10px]\"\n                    />\n                    <Input\n                      type=\"number\"\n                      step=\"0.01\"\n                      min=\"0\"\n                      value={config.imageRate ?? ''}\n                      onChange={(e) => handleRateChange(model, 'imageRate', e.target.value)}\n                      placeholder=\"0\"\n                      size=\"xs\"\n                      className=\"text-[10px]\"\n                    />\n                  </div>\n                );\n              })}\n              <p className=\"text-[10px] text-muted-foreground\">\n                {t('admin.setting.ai.advancedRatesDescription')}\n              </p>\n            </div>\n          )}\n\n          <p className=\"text-xs text-muted-foreground\">{t('admin.setting.ai.ratesDescription')}</p>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport const LLMProviderForm = ({\n  value,\n  onAdd,\n  onChange,\n  onTest,\n  hideModelRates,\n  onSaveTestResult,\n}: LLMProviderFormProps) => {\n  const { t } = useTranslation();\n  const isCloud = useIsCloud();\n  const [isTestLoading, setIsTestLoading] = useState(false);\n  const [testResult, setTestResult] = useState<TestResult | null>(null);\n  const [testPassed, setTestPassed] = useState(false);\n  const [modelTestStatuses, setModelTestStatuses] = useState<IModelTestStatus[]>([]);\n  const [testProgress, setTestProgress] = useState({ current: 0, total: 0 });\n  const abortRef = useRef(false);\n\n  const form = useForm<LLMProvider>({\n    resolver: zodResolver(llmProviderSchema),\n    defaultValues: value || {\n      name: '',\n      type: LLMProviderType.OPENAI,\n      apiKey: '',\n      baseUrl: '',\n      models: '',\n      modelConfigs: {},\n    },\n  });\n\n  // Clear test result when form values change\n  const baseUrl = form.watch('baseUrl');\n  const apiKey = form.watch('apiKey');\n  const models = form.watch('models');\n  const formType = form.watch('type');\n  useEffect(() => {\n    setTestResult(null);\n    setTestPassed(false);\n    setModelTestStatuses([]);\n    setTestProgress({ current: 0, total: 0 });\n  }, [baseUrl, apiKey, models, formType]);\n\n  function onSubmit(data: LLMProvider) {\n    onChange ? onChange(data) : onAdd?.(data);\n  }\n\n  function handleSubmit() {\n    const data = form.getValues();\n    onSubmit(data);\n  }\n\n  // Test a single text model\n  const testTextModel = useCallback(\n    async (model: string, provider: Required<LLMProvider>): Promise<Partial<IModelTestStatus>> => {\n      if (!onTest) {\n        return { status: 'failed', error: 'Test function not provided' };\n      }\n      try {\n        const { type, name, apiKey, baseUrl, models } = provider;\n        const modelKey = `${type}@${model}@${name}`;\n\n        const result = await withTimeout(\n          onTest({\n            type,\n            name,\n            apiKey,\n            baseUrl,\n            models,\n            modelKey,\n            // Test all chat model abilities\n            ability: chatModelAbilityType.options,\n          }),\n          TEXT_MODEL_TIMEOUT_MS,\n          `Timeout after ${TEXT_MODEL_TIMEOUT_MS / 1000}s`\n        );\n\n        if (!result.success) {\n          return {\n            status: 'failed',\n            error: result.response || 'Test failed',\n          };\n        }\n\n        return {\n          status: 'success',\n          ability: result.ability,\n        };\n      } catch (error) {\n        return {\n          status: 'failed',\n          error: error instanceof Error ? error.message : 'Unknown error',\n        };\n      }\n    },\n    [onTest]\n  );\n\n  // Test a single image model\n  const testImageModel = useCallback(\n    async (model: string, provider: Required<LLMProvider>): Promise<Partial<IModelTestStatus>> => {\n      if (!onTest) {\n        return { status: 'failed', error: 'Test function not provided', isImageModel: true };\n      }\n      try {\n        const { type, name, apiKey, baseUrl, models } = provider;\n        const modelKey = `${type}@${model}@${name}`;\n\n        // Test image generation (text-to-image)\n        const generationResult = await withTimeout(\n          onTest({\n            type,\n            name,\n            apiKey,\n            baseUrl,\n            models,\n            modelKey,\n            testImageGeneration: true,\n          }),\n          IMAGE_MODEL_TIMEOUT_MS,\n          `Timeout after ${IMAGE_MODEL_TIMEOUT_MS / 1000}s`\n        );\n\n        // Test image-to-image if generation works\n        let imageToImage = false;\n        if (generationResult.success) {\n          try {\n            const i2iResult = await withTimeout(\n              onTest({\n                type,\n                name,\n                apiKey,\n                baseUrl,\n                models,\n                modelKey,\n                testImageGeneration: true,\n                testImageToImage: true,\n              }),\n              IMAGE_MODEL_TIMEOUT_MS,\n              `Timeout`\n            );\n            imageToImage = i2iResult.success;\n          } catch {\n            // Image-to-image not supported, that's ok\n          }\n        }\n\n        if (!generationResult.success) {\n          return {\n            status: 'failed',\n            error: generationResult.response || 'Image generation test failed',\n            isImageModel: true,\n          };\n        }\n\n        return {\n          status: 'success',\n          isImageModel: true,\n          imageAbility: {\n            generation: true,\n            imageToImage,\n          },\n        };\n      } catch (error) {\n        return {\n          status: 'failed',\n          isImageModel: true,\n          error: error instanceof Error ? error.message : 'Unknown error',\n        };\n      }\n    },\n    [onTest]\n  );\n\n  // Full capability test for all models\n  const handleFullTest = useCallback(async () => {\n    const formData = form.getValues();\n    setTestResult(null);\n\n    // Validate required fields\n    if (\n      !formData.name ||\n      !formData.type ||\n      !formData.baseUrl ||\n      (!formData.apiKey && formData.type !== LLMProviderType.OLLAMA)\n    ) {\n      setTestResult({\n        success: false,\n        message: t('admin.setting.ai.fillRequiredFields'),\n      });\n      return;\n    }\n\n    if (!formData.models) {\n      setTestResult({\n        success: false,\n        message: t('admin.setting.ai.modelsRequired'),\n      });\n      return;\n    }\n\n    const modelList = formData.models\n      .split(',')\n      .map((m) => m.trim())\n      .filter(Boolean);\n\n    if (modelList.length === 0) {\n      setTestResult({\n        success: false,\n        message: t('admin.setting.ai.noValidModel'),\n      });\n      return;\n    }\n\n    // Initialize test state\n    abortRef.current = false;\n    setIsTestLoading(true);\n    setTestPassed(false);\n    setTestProgress({ current: 0, total: modelList.length });\n\n    // Initialize all models as pending\n    const initialStatuses: IModelTestStatus[] = modelList.map((model) => ({\n      model,\n      status: 'pending',\n      isImageModel: formData.modelConfigs?.[model]?.isImageModel,\n    }));\n    setModelTestStatuses(initialStatuses);\n\n    const provider = formData as Required<LLMProvider>;\n    let completedCount = 0;\n    let successCount = 0;\n    let nextIndex = 0;\n\n    const updateModelStatus = (model: string, update: Partial<IModelTestStatus>) => {\n      setModelTestStatuses((prev) =>\n        prev.map((s) => (s.model === model ? { ...s, ...update } : s))\n      );\n    };\n\n    const startNextTest = async () => {\n      if (abortRef.current || nextIndex >= modelList.length) return;\n\n      const currentIndex = nextIndex++;\n      const model = modelList[currentIndex];\n      const isImageModel = formData.modelConfigs?.[model]?.isImageModel;\n\n      updateModelStatus(model, { status: 'testing' });\n\n      const result = isImageModel\n        ? await testImageModel(model, provider)\n        : await testTextModel(model, provider);\n\n      updateModelStatus(model, result);\n      completedCount++;\n      if (result.status === 'success') {\n        successCount++;\n        // Save test result to form's modelConfigs so it persists on submit\n        const currentConfigs = form.getValues('modelConfigs') ?? {};\n        form.setValue('modelConfigs', {\n          ...currentConfigs,\n          [model]: {\n            ...currentConfigs[model],\n            ability: result.ability,\n            imageAbility: result.imageAbility,\n            testedAt: Date.now(),\n          },\n        });\n        // Save test result to parent provider config (for already-added providers)\n        const modelKey = `${provider.type}@${model}@${provider.name}`;\n        onSaveTestResult?.(modelKey, result.ability, result.imageAbility);\n      }\n      setTestProgress({ current: completedCount, total: modelList.length });\n\n      // Start next test if there are more\n      if (!abortRef.current && nextIndex < modelList.length) {\n        await startNextTest();\n      }\n    };\n\n    // Start concurrent tests\n    const initialPromises: Promise<void>[] = [];\n    for (let i = 0; i < Math.min(CONCURRENCY, modelList.length); i++) {\n      initialPromises.push(startNextTest());\n    }\n\n    await Promise.all(initialPromises);\n\n    setIsTestLoading(false);\n\n    // Check results\n    if (successCount > 0) {\n      setTestPassed(true);\n      toast.success(\n        t('admin.setting.ai.testCompleteWithCount', {\n          success: successCount,\n          total: modelList.length,\n        })\n      );\n    } else {\n      setTestResult({\n        success: false,\n        message: t('admin.setting.ai.allTestsFailed'),\n      });\n    }\n  }, [form, t, testTextModel, testImageModel, onSaveTestResult]);\n\n  const handleStopTest = useCallback(() => {\n    abortRef.current = true;\n    setIsTestLoading(false);\n  }, []);\n\n  const mode = onChange ? t('actions.update') : t('actions.add');\n  const type = form.watch('type');\n  const currentProvider = LLM_PROVIDERS.find(\n    (provider) => provider.value === type\n  ) as (typeof LLM_PROVIDERS)[number] & { apiKeyPlaceholder?: string };\n\n  // Calculate test statistics\n  const successCount = modelTestStatuses.filter((s) => s.status === 'success').length;\n  const failedCount = modelTestStatuses.filter((s) => s.status === 'failed').length;\n  const progressPercent =\n    testProgress.total > 0 ? Math.round((testProgress.current / testProgress.total) * 100) : 0;\n\n  return (\n    <Form {...form}>\n      <FormField\n        name=\"name\"\n        render={({ field }) => (\n          <FormItem>\n            <div>\n              <FormLabel>{t('admin.setting.ai.name')}</FormLabel>\n              <FormDescription>{t('admin.setting.ai.nameDescription')}</FormDescription>\n            </div>\n            <FormControl>\n              <Input {...field} autoComplete=\"off\" placeholder=\"openai/claude/gemini...\" />\n            </FormControl>\n            <FormMessage />\n          </FormItem>\n        )}\n      />\n      <FormField\n        name=\"type\"\n        render={({ field }) => (\n          <FormItem>\n            <FormLabel>{t('admin.setting.ai.providerType')}</FormLabel>\n            <FormControl>\n              <Select\n                {...field}\n                onValueChange={(value) => {\n                  form.setValue('type', value as unknown as LLMProvider['type']);\n                }}\n              >\n                <SelectTrigger className=\"w-[180px]\">\n                  <SelectValue placeholder={t('admin.setting.ai.providerType')} />\n                </SelectTrigger>\n                <SelectContent>\n                  {LLM_PROVIDERS.map(({ value, label, Icon }) => (\n                    <SelectItem key={value} value={value}>\n                      <div className=\"flex flex-row items-center text-[13px]\">\n                        <Icon className=\"size-5 shrink-0 pr-1\" />\n                        {label}\n                      </div>\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </FormControl>\n            <FormMessage />\n          </FormItem>\n        )}\n      />\n      {!!currentProvider && (\n        <>\n          <FormField\n            name=\"baseUrl\"\n            render={({ field }) => (\n              <FormItem>\n                <div>\n                  <FormLabel>{t('admin.setting.ai.baseUrl')}</FormLabel>\n                  <FormDescription>{t('admin.setting.ai.baseUrlDescription')}</FormDescription>\n                </div>\n                <FormControl>\n                  <Input {...field} placeholder={currentProvider.baseUrlPlaceholder} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          {type !== LLMProviderType.OLLAMA && (\n            <FormField\n              name=\"apiKey\"\n              render={({ field }) => (\n                <FormItem>\n                  <div>\n                    <FormLabel>{t('admin.setting.ai.apiKey')}</FormLabel>\n                    <FormDescription>{t('admin.setting.ai.apiKeyDescription')}</FormDescription>\n                  </div>\n                  <FormControl>\n                    <Input\n                      {...field}\n                      type=\"password\"\n                      placeholder={currentProvider?.apiKeyPlaceholder ?? ''}\n                    />\n                  </FormControl>\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          )}\n          <FormField\n            name=\"models\"\n            render={({ field }) => (\n              <FormItem>\n                <div>\n                  <FormLabel>{t('admin.setting.ai.models')}</FormLabel>\n                  <FormDescription>{t('admin.setting.ai.modelsDescription')}</FormDescription>\n                </div>\n                <FormControl>\n                  <Input {...field} placeholder={currentProvider.modelsPlaceholder} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n\n          {/* Model Rates Configuration (Cloud only - for billing, hidden in space settings) */}\n          {isCloud && !hideModelRates && (\n            <ModelRatesConfig\n              models={form.watch('models') || ''}\n              modelConfigs={form.watch('modelConfigs')}\n              onChange={(configs) => form.setValue('modelConfigs', configs)}\n            />\n          )}\n\n          {/* Test Error Display */}\n          {testResult && !testResult.success && (\n            <div className=\"space-y-2 rounded-md border bg-muted p-3 text-sm\">\n              <div className=\"flex items-start gap-2\">\n                <AlertCircle className=\"mt-0.5 size-4 shrink-0\" />\n                <p className=\"break-all font-medium\">{testResult.message}</p>\n              </div>\n              {testResult.suggestions && testResult.suggestions.length > 0 && (\n                <div className=\"text-muted-foreground\">\n                  {testResult.suggestions.map((suggestion, index) => {\n                    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                    const key = `admin.setting.ai.${suggestion}` as any;\n                    return (\n                      <p key={index} className=\"text-xs\">\n                        💡 {t(key)}\n                      </p>\n                    );\n                  })}\n                </div>\n              )}\n            </div>\n          )}\n\n          {/* Test Progress Display */}\n          {modelTestStatuses.length > 0 && (\n            <div className=\"space-y-3 rounded-md border bg-muted p-3\">\n              {/* Progress bar */}\n              {testProgress.total > 0 && (\n                <div className=\"flex items-center gap-3\">\n                  <Progress value={progressPercent} className=\"h-1.5 flex-1\" />\n                  <div className=\"flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground\">\n                    {isTestLoading && <Loader2 className=\"size-3 animate-spin\" />}\n                    <span>{progressPercent}%</span>\n                    <span className=\"text-green-600 dark:text-green-400\">{successCount} ✓</span>\n                    <span className=\"text-red-600 dark:text-red-400\">{failedCount} ✗</span>\n                  </div>\n                </div>\n              )}\n              {/* Model test results */}\n              <div className=\"flex flex-wrap gap-2\">\n                {modelTestStatuses.map((status) => (\n                  <ModelTestPill key={status.model} status={status} />\n                ))}\n              </div>\n            </div>\n          )}\n\n          <div className=\"flex w-full flex-row gap-2\">\n            {onTest && (\n              <>\n                {isTestLoading ? (\n                  <Button\n                    className=\"flex-1\"\n                    onClick={handleStopTest}\n                    type=\"button\"\n                    variant=\"destructive\"\n                  >\n                    <Square className=\"mr-1 size-3\" />\n                    {t('admin.setting.ai.stopTest')}\n                  </Button>\n                ) : (\n                  <Button\n                    className=\"flex-1\"\n                    onClick={handleFullTest}\n                    disabled={isTestLoading}\n                    type=\"button\"\n                    variant={testPassed ? 'outline' : 'default'}\n                  >\n                    {testPassed ? (\n                      <>\n                        <Check className=\"size-4 text-green-600\" />\n                        {t('admin.setting.ai.testSuccess')}\n                      </>\n                    ) : (\n                      t('admin.setting.ai.testConnection')\n                    )}\n                  </Button>\n                )}\n              </>\n            )}\n            {testPassed && (\n              <Button className=\"flex-1\" onClick={handleSubmit}>\n                {mode}\n              </Button>\n            )}\n          </div>\n        </>\n      )}\n    </Form>\n  );\n};\n\n// Component for displaying individual model test status\ninterface IModelTestPillProps {\n  status: IModelTestStatus;\n}\n\nconst ModelTestPill = ({ status }: IModelTestPillProps) => {\n  const { model, status: testStatus, error, ability, imageAbility, isImageModel } = status;\n\n  const getStatusStyles = () => {\n    switch (testStatus) {\n      case 'idle':\n        return 'bg-primary/5 text-muted-foreground border-transparent';\n      case 'pending':\n        return 'bg-primary/5 text-foreground border-transparent';\n      case 'testing':\n        return 'bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20';\n      case 'success':\n        return 'bg-green-50 text-green-600 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20';\n      case 'failed':\n        return 'bg-red-50 text-red-600 border-red-100 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20';\n    }\n  };\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const getImageIcon = () => {\n    if (testStatus !== 'success') return null;\n\n    // For text models: show vision support\n    if (!isImageModel && ability?.image) {\n      const { url, base64 } = ability.image as { url?: boolean; base64?: boolean };\n      if (url && base64) {\n        return <Eye className=\"size-3 text-green-600 dark:text-green-400\" />;\n      }\n      if (url || base64) {\n        return <Eye className=\"size-3 text-yellow-600 dark:text-yellow-400\" />;\n      }\n      return <Eye className=\"size-3 opacity-30\" />;\n    }\n\n    // For image models: show generation support\n    if (isImageModel && imageAbility) {\n      const { generation, imageToImage } = imageAbility;\n      if (generation && imageToImage) {\n        return <Image className=\"size-3 text-green-600 dark:text-green-400\" />;\n      }\n      if (generation || imageToImage) {\n        return <Image className=\"size-3 text-yellow-600 dark:text-yellow-400\" />;\n      }\n      return <Image className=\"size-3 opacity-30\" />;\n    }\n\n    return null;\n  };\n\n  return (\n    <div\n      className={cn(\n        'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium',\n        getStatusStyles(),\n        isImageModel && 'ring-1 ring-blue-200 dark:bg-blue-500/10 dark:ring-blue-500/20'\n      )}\n      title={error || model}\n    >\n      <span className=\"max-w-[100px] truncate\">{model}</span>\n\n      {/* Status indicator */}\n      {testStatus === 'testing' && <Loader2 className=\"size-3 animate-spin\" />}\n      {testStatus === 'success' && <Check className=\"size-3\" />}\n      {testStatus === 'failed' && <X className=\"size-3\" />}\n\n      {/* Image support indicator */}\n      {getImageIcon()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/LlmproviderManage.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { Check, Loader2, Play, X } from '@teable/icons';\nimport { chatModelAbilityType } from '@teable/openapi';\nimport type {\n  IChatModelAbility,\n  IImageModelAbility,\n  ITestLLMRo,\n  ITestLLMVo,\n  LLMProvider,\n} from '@teable/openapi';\n\n// Image model ability types\nconst imageModelAbilities = ['generation', 'imageToImage'] as const;\nimport {\n  Button,\n  Checkbox,\n  cn,\n  Label,\n  toast,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { SlidersHorizontalIcon, XIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\n\nimport { NewLLMProviderForm, UpdateLLMProviderForm } from './LlmProviderForm';\n\n// Model test result interface\nexport interface IModelTestResult {\n  modelKey: string;\n  status: 'idle' | 'pending' | 'testing' | 'success' | 'failed';\n  error?: string;\n  ability?: IChatModelAbility;\n  imageAbility?: IImageModelAbility;\n  isImageModel?: boolean;\n}\n\ninterface ILLMProviderManageProps {\n  value: LLMProvider[];\n  onChange: (value: LLMProvider[]) => void;\n  /** Test function - accepts full ITestLLMRo for capability testing */\n  onTest?: (data: ITestLLMRo) => Promise<ITestLLMVo>;\n  modelTestResults?: Map<string, IModelTestResult>;\n  onToggleImageModel?: (modelKey: string, isImageModel: boolean) => void;\n  onTestProvider?: (provider: LLMProvider) => void;\n  onTestModel?: (provider: LLMProvider, model: string, modelKey: string) => Promise<void>;\n  testingProviders?: Set<string>;\n  testingModels?: Set<string>;\n  /** Hide model rates config (for space-level settings where billing doesn't apply) */\n  hideModelRates?: boolean;\n  /** Callback to save model test results */\n  onSaveTestResult?: (\n    modelKey: string,\n    ability: IChatModelAbility | undefined,\n    imageAbility: IImageModelAbility | undefined\n  ) => void;\n}\n\nexport const LLMProviderManage = ({\n  value,\n  onChange,\n  onTest,\n  modelTestResults,\n  onToggleImageModel,\n  onTestProvider,\n  onTestModel,\n  testingProviders,\n  testingModels,\n  hideModelRates,\n  onSaveTestResult,\n}: ILLMProviderManageProps) => {\n  const { t } = useTranslation('common');\n  const handleAdd = (data: LLMProvider) => {\n    const newData = [...value, data];\n    onChange(newData);\n  };\n\n  const handleUpdate = (index: number) => (data: LLMProvider) => {\n    const newData = value.map((provider, i) => (i === index ? data : provider));\n    onChange(newData);\n  };\n\n  const handleRemove = (index: number) => {\n    const newData = value.filter((_, i) => i !== index);\n    onChange(newData);\n  };\n\n  if (value.length === 0) {\n    return (\n      <NewLLMProviderForm\n        onAdd={handleAdd}\n        onTest={onTest}\n        hideModelRates={hideModelRates}\n        onSaveTestResult={onSaveTestResult}\n      />\n    );\n  }\n\n  return (\n    <div>\n      <div className=\"flex w-full flex-col gap-3\">\n        {value.map((provider, index) => {\n          // Get models for this provider\n          const models =\n            provider.models\n              ?.split(',')\n              .map((m) => m.trim())\n              .filter(Boolean) || [];\n          const providerKey = `${provider.type}@${provider.name}`;\n          const isTesting = testingProviders?.has(providerKey);\n\n          return (\n            <div\n              className=\"group rounded-lg border p-4 pr-3 hover:border-primary/50\"\n              key={provider.name}\n            >\n              {/* Provider header */}\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"font-medium\">\n                    {provider.name} - {provider.type}\n                  </span>\n                  {models.length > 0 && (\n                    <TooltipProvider>\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <Button\n                            size=\"xs\"\n                            variant=\"outline\"\n                            onClick={() => onTestProvider?.(provider)}\n                            disabled={isTesting}\n                            className=\"h-6 gap-1 px-2 text-xs shadow-none\"\n                          >\n                            {isTesting ? (\n                              <Loader2 className=\"size-3 animate-spin\" />\n                            ) : (\n                              <Play className=\"size-3\" />\n                            )}\n                            {isTesting\n                              ? t('admin.setting.ai.testing')\n                              : t('admin.setting.ai.testProvider')}\n                          </Button>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          {t('admin.setting.ai.testProviderTooltip', { count: models.length })}\n                        </TooltipContent>\n                      </Tooltip>\n                    </TooltipProvider>\n                  )}\n                </div>\n                <div className=\"flex shrink-0 gap-1\">\n                  <Button\n                    onClick={() => handleRemove(index)}\n                    size=\"xs\"\n                    variant=\"ghost\"\n                    className=\"w-7 p-0 opacity-0 group-hover:opacity-100\"\n                  >\n                    <XIcon className=\"size-4 text-muted-foreground\" />\n                  </Button>\n                  <UpdateLLMProviderForm\n                    value={provider}\n                    onChange={handleUpdate(index)}\n                    onTest={onTest}\n                    hideModelRates={hideModelRates}\n                    onSaveTestResult={onSaveTestResult}\n                  >\n                    <Button size=\"icon-xs\" variant=\"ghost\">\n                      <SlidersHorizontalIcon className=\"size-4 text-muted-foreground\" />\n                    </Button>\n                  </UpdateLLMProviderForm>\n                </div>\n              </div>\n\n              {/* Model rows - each model on its own line with capabilities */}\n              {models.length > 0 && (\n                <div className=\"mt-3 flex flex-col gap-2\">\n                  {models.map((model) => {\n                    const modelKey = `${provider.type}@${model}@${provider.name}`;\n                    const testResult =\n                      modelTestResults?.get(modelKey) ??\n                      (() => {\n                        // Fall back to persisted modelConfigs if no transient test result\n                        const config = provider.modelConfigs?.[model];\n                        if (config?.ability || config?.imageAbility) {\n                          return {\n                            modelKey,\n                            status: 'success' as const,\n                            ability: config.ability,\n                            imageAbility: config.imageAbility,\n                            isImageModel: config.isImageModel,\n                          };\n                        }\n                        return undefined;\n                      })();\n                    const isImageModel = provider.modelConfigs?.[model]?.isImageModel;\n                    const isModelTesting = testingModels?.has(modelKey);\n                    return (\n                      <ModelRow\n                        key={modelKey}\n                        model={model}\n                        modelKey={modelKey}\n                        testResult={testResult}\n                        isImageModel={isImageModel}\n                        onToggleImageModel={onToggleImageModel}\n                        onTestModel={\n                          onTestModel ? () => onTestModel(provider, model, modelKey) : undefined\n                        }\n                        isTesting={isModelTesting}\n                      />\n                    );\n                  })}\n                </div>\n              )}\n            </div>\n          );\n        })}\n        <NewLLMProviderForm\n          onAdd={handleAdd}\n          onTest={onTest}\n          hideModelRates={hideModelRates}\n          onSaveTestResult={onSaveTestResult}\n        />\n      </div>\n    </div>\n  );\n};\n\ninterface IModelRowProps {\n  model: string;\n  modelKey: string;\n  testResult?: IModelTestResult;\n  isImageModel?: boolean;\n  onToggleImageModel?: (modelKey: string, isImageModel: boolean) => void;\n  onTestModel?: () => Promise<void>;\n  isTesting?: boolean;\n}\n\n// Helper to check if ability is supported (handles both boolean and detailed format)\nconst isAbilitySupported = (\n  ability: boolean | { url?: boolean; base64?: boolean } | undefined\n): boolean => {\n  if (typeof ability === 'boolean') return ability;\n  if (ability && typeof ability === 'object') {\n    return ability.url === true || ability.base64 === true;\n  }\n  return false;\n};\n\n// Helper to get support details for display\nconst getAbilitySupportDetails = (\n  ability: boolean | { url?: boolean; base64?: boolean } | undefined\n): string | null => {\n  if (typeof ability === 'boolean') return null;\n  if (ability && typeof ability === 'object') {\n    const parts: string[] = [];\n    if (ability.url) parts.push('URL');\n    if (ability.base64) parts.push('Base64');\n    return parts.length > 0 ? parts.join(', ') : null;\n  }\n  return null;\n};\n\nconst ModelRow = ({\n  model,\n  modelKey,\n  testResult,\n  isImageModel,\n  onToggleImageModel,\n  onTestModel,\n  isTesting,\n}: IModelRowProps) => {\n  const { t } = useTranslation('common');\n  const status = testResult?.status || 'idle';\n  const isCurrentlyTesting = isTesting || status === 'testing';\n\n  const handleCheckboxChange = (checked: boolean) => {\n    onToggleImageModel?.(modelKey, checked);\n\n    // Show friendly toast notification\n    if (checked) {\n      toast({\n        title: `🎨 ${model}`,\n        description: t('admin.setting.ai.markedAsImageModel'),\n      });\n    } else {\n      toast({\n        title: `💬 ${model}`,\n        description: t('admin.setting.ai.markedAsTextModel'),\n      });\n    }\n  };\n\n  // Get abilities to display based on model type\n  const textAbilities = chatModelAbilityType.options;\n  const imgAbilities = imageModelAbilities;\n\n  // Get ability value from test result for text models\n  const getTextAbilityValue = (abilityType: (typeof textAbilities)[number]) => {\n    return testResult?.ability?.[abilityType];\n  };\n\n  // Get ability value from test result for image models\n  const getImageAbilityValue = (abilityType: (typeof imgAbilities)[number]) => {\n    return testResult?.imageAbility?.[abilityType];\n  };\n\n  const getStatusIcon = () => {\n    switch (status) {\n      case 'testing':\n        return <Loader2 className=\"size-3.5 animate-spin text-blue-500\" />;\n      case 'success':\n        return <Check className=\"size-3.5 text-green-500\" />;\n      case 'failed':\n        return <X className=\"size-3.5 text-red-500\" />;\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"rounded-md border bg-muted p-3\">\n      {/* Model header row */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-3\">\n          {/* Model name with status */}\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-medium\">{model}</span>\n            {getStatusIcon()}\n            {status === 'failed' && testResult?.error && (\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <span className=\"text-xs text-red-500\">\n                      ({t('admin.setting.ai.testFailed')})\n                    </span>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p className=\"text-xs\">{testResult.error}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            )}\n          </div>\n\n          {/* Test button for single model */}\n          <Button\n            size=\"xs\"\n            variant=\"ghost\"\n            onClick={() => onTestModel?.()}\n            disabled={isCurrentlyTesting}\n            className=\"h-5 gap-1 px-1.5 text-[10px] text-muted-foreground hover:text-foreground\"\n          >\n            {isCurrentlyTesting ? (\n              <Loader2 className=\"size-3 animate-spin\" />\n            ) : (\n              <Play className=\"size-3\" />\n            )}\n            {t('admin.setting.ai.testProvider')}\n          </Button>\n        </div>\n\n        {/* Image model checkbox */}\n        <div className=\"flex items-center gap-2\">\n          <Checkbox\n            id={`image-model-${modelKey}`}\n            checked={isImageModel}\n            onCheckedChange={handleCheckboxChange}\n            className=\"size-4 border-muted-foreground/50 data-[state=checked]:border-purple-500 data-[state=checked]:bg-purple-500\"\n          />\n          <Label\n            htmlFor={`image-model-${modelKey}`}\n            className=\"cursor-pointer text-xs text-muted-foreground\"\n          >\n            {t('admin.setting.ai.imageGenerationModel')}\n          </Label>\n        </div>\n      </div>\n\n      {/* Capability badges */}\n      <div className=\"mt-2 flex flex-wrap gap-1.5\">\n        <TooltipProvider>\n          {isImageModel\n            ? // Image model abilities\n              imgAbilities.map((abilityType) => {\n                const abilityValue = getImageAbilityValue(abilityType);\n                const supported = abilityValue === true;\n                const tested = status === 'success';\n\n                return (\n                  <div\n                    key={abilityType}\n                    className={cn(\n                      'flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] transition-colors',\n                      tested && supported\n                        ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-500'\n                        : 'bg-muted text-muted-foreground'\n                    )}\n                  >\n                    {tested && supported && <Check className=\"size-2.5\" />}\n                    <span>{t(`admin.setting.ai.imageModelAbility.${abilityType}`)}</span>\n                  </div>\n                );\n              })\n            : // Text model abilities\n              textAbilities.map((abilityType) => {\n                const abilityValue = getTextAbilityValue(abilityType);\n                const supported = isAbilitySupported(abilityValue);\n                const supportDetails = getAbilitySupportDetails(abilityValue);\n                const tested = status === 'success';\n\n                const badge = (\n                  <div\n                    className={cn(\n                      'flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] transition-colors',\n                      tested && supported\n                        ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-500'\n                        : 'bg-muted text-muted-foreground'\n                    )}\n                  >\n                    {tested && supported && <Check className=\"size-2.5\" />}\n                    <span>{t(`admin.setting.ai.chatModelAbility.${abilityType}`)}</span>\n                    {tested && supportDetails && (\n                      <span className=\"ml-0.5 opacity-70\">({supportDetails})</span>\n                    )}\n                  </div>\n                );\n\n                // Show tooltip with details for image/pdf\n                if (tested && supportDetails) {\n                  return (\n                    <Tooltip key={abilityType}>\n                      <TooltipTrigger asChild>{badge}</TooltipTrigger>\n                      <TooltipContent>\n                        <p>\n                          {t('admin.setting.ai.chatModelAbility.supportedFormats')}:{' '}\n                          {supportDetails}\n                        </p>\n                      </TooltipContent>\n                    </Tooltip>\n                  );\n                }\n\n                return <div key={abilityType}>{badge}</div>;\n              })}\n        </TooltipProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/SetupStepCard.tsx",
    "content": "'use client';\n\nimport { Check, ChevronDown } from '@teable/icons';\nimport { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@teable/ui-lib/shadcn';\nimport type { ReactNode } from 'react';\n\ninterface ISetupStepCardProps {\n  title: string;\n  description?: string;\n  isComplete: boolean;\n  isExpanded: boolean;\n  onToggle: () => void;\n  children: ReactNode;\n  badge?: ReactNode;\n  disabled?: boolean;\n  icon?: ReactNode;\n}\n\nexport function SetupStepCard({\n  title,\n  description,\n  isComplete,\n  isExpanded,\n  onToggle,\n  children,\n  badge,\n  disabled,\n  icon,\n}: ISetupStepCardProps) {\n  return (\n    <Collapsible open={isExpanded} onOpenChange={disabled ? undefined : onToggle}>\n      <div\n        className={cn(\n          'rounded-lg border bg-card transition-colors overflow-hidden',\n          isExpanded && 'border-primary/50',\n          isExpanded && !isComplete && 'border-primary shadow-sm',\n          disabled && 'opacity-50'\n        )}\n      >\n        <CollapsibleTrigger asChild disabled={disabled}>\n          <button\n            className={cn(\n              'flex w-full items-center gap-4 p-4 text-left',\n              !disabled && 'hover:bg-muted'\n            )}\n          >\n            {/* Step indicator */}\n            <div\n              className={cn(\n                'flex size-7 shrink-0 items-center justify-center border rounded-full text-sm font-medium text-muted-foreground transition-colors',\n                isComplete &&\n                  'border-green-600 text-green-600 dark:text-green-400 dark:border-green-400'\n              )}\n            >\n              {isComplete ? <Check className=\"size-4\" /> : null}\n            </div>\n\n            {/* Title and description */}\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-2\">\n                {icon && <span className=\"size-4 shrink-0\">{icon}</span>}\n                <span className=\"font-medium\">{title}</span>\n                {badge}\n              </div>\n              {description && <p className=\"mt-0.5 text-sm text-muted-foreground\">{description}</p>}\n            </div>\n            {/* Expand indicator */}\n            <ChevronDown\n              className={cn(\n                'size-5 shrink-0 text-muted-foreground transition-transform',\n                isExpanded && 'rotate-180'\n              )}\n            />\n          </button>\n        </CollapsibleTrigger>\n\n        <CollapsibleContent>\n          <div className=\"border-t bg-background p-4\">{children}</div>\n        </CollapsibleContent>\n      </div>\n    </Collapsible>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/TestButton.tsx",
    "content": ""
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/GatewayModelOption.tsx",
    "content": "'use client';\n\nimport {\n  cn,\n  CommandItem,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Check } from 'lucide-react';\nimport { parseModelKey } from '../utils';\nimport type { IModelOption } from './types';\nimport { formatPriceToCredits, getModelIcon } from './utils';\n\ninterface IGatewayModelOptionProps {\n  option: IModelOption;\n  isSelected: boolean;\n  showPrice: boolean;\n  onSelect: (modelKey: string, isSelected: boolean) => void;\n}\n\n/**\n * Gateway model option with full tooltip (context, tokens, tags, pricing)\n */\nexport function GatewayModelOption({\n  option,\n  isSelected,\n  showPrice,\n  onSelect,\n}: IGatewayModelOptionProps) {\n  const { modelKey, label, pricing, contextWindow, maxTokens, tags, ownedBy } = option;\n  const { model } = parseModelKey(modelKey);\n  const displayName = label || model;\n  const hasPrice = pricing?.input || pricing?.output || pricing?.image;\n  const Icon = getModelIcon(modelKey, ownedBy);\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <CommandItem\n            value={modelKey}\n            onSelect={(key) => {\n              onSelect(key, isSelected);\n            }}\n          >\n            <div className=\"flex items-center\">\n              <Check className={cn('mr-2 size-4', isSelected ? 'opacity-100' : 'opacity-0')} />\n              {Icon && <Icon className=\"mr-1.5 size-4 shrink-0\" />}\n              <span className=\"max-w-[280px] truncate font-medium\">{displayName}</span>\n            </div>\n          </CommandItem>\n        </TooltipTrigger>\n        <TooltipContent side=\"right\" className=\"max-w-xs\">\n          <div className=\"space-y-1.5 text-xs\">\n            <code className=\"block rounded bg-muted px-1 py-0.5\">{model}</code>\n            {/* Context & Tokens */}\n            {(contextWindow || maxTokens) && (\n              <div className=\"flex gap-2 text-muted-foreground\">\n                {contextWindow && <span>Context: {(contextWindow / 1000).toFixed(0)}k</span>}\n                {maxTokens && <span>Max: {(maxTokens / 1000).toFixed(0)}k</span>}\n              </div>\n            )}\n            {/* Tags */}\n            {tags && tags.length > 0 && (\n              <div className=\"flex flex-wrap gap-1\">\n                {tags.slice(0, 4).map((tag) => (\n                  <span key={tag} className=\"rounded bg-muted px-1 py-0.5 text-[10px]\">\n                    {tag}\n                  </span>\n                ))}\n              </div>\n            )}\n            {/* Pricing (only show when showPrice is true - for Cloud) */}\n            {showPrice && hasPrice && (\n              <div className=\"text-muted-foreground\">{formatPriceToCredits(pricing)}</div>\n            )}\n          </div>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx",
    "content": "'use client';\n\nimport { ChevronDown } from '@teable/icons';\nimport { Button } from '@teable/ui-lib';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { ComponentPropsWithoutRef } from 'react';\nimport { forwardRef } from 'react';\nimport { parseModelKey } from '../utils';\nimport type { IModelOption } from './types';\nimport { getModelIcon } from './utils';\n\ninterface IModelSelectTriggerProps extends ComponentPropsWithoutRef<typeof Button> {\n  currentModel: IModelOption | undefined;\n  value: string;\n  open: boolean;\n  placeholder?: string;\n}\n\n/**\n * Trigger button for model select dropdown\n */\nexport const ModelSelectTrigger = forwardRef<HTMLButtonElement, IModelSelectTriggerProps>(\n  ({ currentModel, value, size = 'default', className, open, placeholder, ...props }, ref) => {\n    const { t } = useTranslation('common');\n    const { name, model } = parseModelKey(currentModel?.modelKey || value);\n    const Icon = getModelIcon(currentModel?.modelKey || value, currentModel?.ownedBy);\n    // Display name priority: label (configured name) > model (ID) > name (provider name)\n    const displayName = currentModel?.label || model || name;\n\n    return (\n      <Button\n        ref={ref}\n        variant=\"outline\"\n        role=\"combobox\"\n        aria-expanded={open}\n        size={size}\n        className={cn('grow justify-between font-normal flex', className)}\n        {...props}\n      >\n        <div className=\"flex flex-1 items-center truncate\">\n          {!currentModel ? (\n            placeholder ?? t('admin.setting.ai.selectModel')\n          ) : (\n            <>\n              {Icon && <Icon className=\"mr-1.5 size-4 shrink-0\" />}\n              <span className=\"truncate\" title={model}>\n                {displayName}\n              </span>\n            </>\n          )}\n        </div>\n        <ChevronDown className=\"ml-2 size-4 shrink-0 opacity-50\" />\n      </Button>\n    );\n  }\n);\n\nModelSelectTrigger.displayName = 'ModelSelectTrigger';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ProviderModelOption.tsx",
    "content": "'use client';\n\nimport type { IModelDefinationMap } from '@teable/openapi';\nimport {\n  cn,\n  CommandItem,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Check } from 'lucide-react';\nimport type { TFunction } from 'next-i18next';\nimport { LLM_PROVIDER_ICONS } from '../constant';\nimport { parseModelKey, processModelDefinition } from '../utils';\nimport type { IModelOption } from './types';\n\ninterface IProviderModelOptionProps {\n  option: IModelOption;\n  isSelected: boolean;\n  onSelect: (modelKey: string, isSelected: boolean) => void;\n  /** Model definition map for price info (instance models only) */\n  modelDefinationMap?: IModelDefinationMap;\n  /** Translation function for price info */\n  t?: TFunction;\n  /** Whether to show detailed price info (for instance models) */\n  showPriceInfo?: boolean;\n}\n\n/**\n * Provider model option (space or instance) with simple tooltip\n */\nexport function ProviderModelOption({\n  option,\n  isSelected,\n  onSelect,\n  modelDefinationMap,\n  t,\n  showPriceInfo = false,\n}: IProviderModelOptionProps) {\n  const { modelKey, label } = option;\n  const { type, model } = parseModelKey(modelKey);\n  const Icon = LLM_PROVIDER_ICONS[type as keyof typeof LLM_PROVIDER_ICONS];\n  const displayName = label || model;\n\n  // Get price info for instance models\n  const priceInfo =\n    showPriceInfo && modelDefinationMap && t\n      ? (() => {\n          const modelDefination = modelDefinationMap[model as string];\n          const { usageTags } = processModelDefinition(modelDefination, t);\n          return usageTags\n            .map(({ text }) => text)\n            .filter(Boolean)\n            .join(' | ');\n        })()\n      : '';\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <CommandItem\n            value={modelKey}\n            onSelect={(key) => {\n              onSelect(key, isSelected);\n            }}\n          >\n            <div className=\"flex items-center\">\n              <Check className={cn('mr-2 size-4', isSelected ? 'opacity-100' : 'opacity-0')} />\n              {Icon && <Icon className=\"mr-1.5 size-4 shrink-0\" />}\n              <span className=\"max-w-[280px] truncate\">{displayName}</span>\n            </div>\n          </CommandItem>\n        </TooltipTrigger>\n        <TooltipContent side=\"right\" className=\"max-w-xs\">\n          <div className=\"space-y-1 text-xs\">\n            <code className=\"block rounded bg-muted px-1 py-0.5\">{model}</code>\n            {priceInfo && <div className=\"text-muted-foreground\">{priceInfo}</div>}\n          </div>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/index.ts",
    "content": "// Types\nexport type {\n  IModelOption,\n  IAIModelSelectProps,\n  IModelCategories,\n  IGatewayModelAPI,\n} from './types';\n\n// Utils\nexport {\n  getModelIcon,\n  formatPriceToCredits,\n  checkIsImageModel,\n  checkIsLanguageModel,\n} from './utils';\n\n// Hooks\nexport { useGatewayModels } from './useGatewayModels';\nexport { useModelCategories } from './useModelCategories';\n\n// Components\nexport { GatewayModelOption } from './GatewayModelOption';\nexport { ProviderModelOption } from './ProviderModelOption';\nexport { ModelSelectTrigger } from './ModelSelectTrigger';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/types.ts",
    "content": "import type {\n  IModelDefinationMap,\n  GatewayModelType,\n  GatewayModelTag,\n  GatewayModelProvider,\n} from '@teable/openapi';\nimport type { ReactNode } from 'react';\n\n// API response model structure from backend (camelCase)\nexport interface IGatewayModelAPI {\n  id: string;\n  created?: number;\n  ownedBy?: GatewayModelProvider;\n  name?: string;\n  description?: string;\n  contextWindow?: number;\n  maxTokens?: number;\n  type?: GatewayModelType;\n  tags?: GatewayModelTag[];\n  pricing?: Record<string, string>; // Pricing from Vercel AI Gateway (input, output, image, etc.)\n}\n\nexport interface IModelOption {\n  isInstance?: boolean;\n  modelKey: string;\n  isImageModel?: boolean; // User-configured image model flag from admin panel\n  label?: string; // Display label for gateway models\n  capabilities?: Record<string, unknown>; // Model capabilities\n  isGateway?: boolean; // Is this a gateway model\n  pricing?: {\n    input?: string;\n    output?: string;\n    image?: string;\n  }; // Pricing format (USD per token/image)\n  // API metadata for enhanced display and functionality\n  ownedBy?: GatewayModelProvider; // Provider (e.g., \"anthropic\", \"google\", \"openai\")\n  modelType?: GatewayModelType; // Model type (e.g., \"language\", \"image\")\n  tags?: GatewayModelTag[]; // Capability tags (e.g., [\"image-generation\", \"vision\", \"tool-use\"])\n  contextWindow?: number; // Context window size\n  maxTokens?: number; // Maximum output tokens\n  description?: string; // Model description\n}\n\nexport interface IAIModelSelectProps {\n  value: string;\n  onValueChange: (value: string) => void;\n  size?: 'xs' | 'sm' | 'lg' | 'default' | null | undefined;\n  className?: string;\n  options?: IModelOption[];\n  disabled?: boolean;\n  needGroup?: boolean;\n  modelDefinationMap?: IModelDefinationMap;\n  children?: ReactNode;\n  onlyImageOutput?: boolean; // if true, only show image output models\n  placeholder?: string; // Custom placeholder when no model is selected\n}\n\n// Categorized model options\nexport interface IModelCategories {\n  gatewayOptions: IModelOption[];\n  spaceOptions: IModelOption[];\n  instanceOptions: IModelOption[];\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/useGatewayModels.ts",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport type { IPickerModel } from '../GatewayModelPickerDialog';\nimport { parseModelKey, isGatewayModelKey } from '../utils';\nimport type { IGatewayModelAPI, IModelOption } from './types';\n\ninterface IUseGatewayModelsOptions {\n  needGroup?: boolean;\n  onlyImageOutput?: boolean;\n  value?: string;\n  options?: IModelOption[];\n}\n\ninterface IUseGatewayModelsReturn {\n  gatewayModels: IGatewayModelAPI[];\n  isLoadingGateway: boolean;\n  gatewayConfigured: boolean | null;\n  pickerModels: IPickerModel[];\n  selectedModelIdForPicker: string | undefined;\n  fetchGatewayModels: () => Promise<void>;\n  findGatewayModel: (value: string) => IModelOption | undefined;\n}\n\n/**\n * Hook to manage gateway models fetching and state\n */\nexport function useGatewayModels({\n  needGroup,\n  onlyImageOutput,\n  value,\n  options,\n}: IUseGatewayModelsOptions): IUseGatewayModelsReturn {\n  const [gatewayModels, setGatewayModels] = useState<IGatewayModelAPI[]>([]);\n  const [isLoadingGateway, setIsLoadingGateway] = useState(false);\n  const [gatewayConfigured, setGatewayConfigured] = useState<boolean | null>(null);\n\n  // Fetch gateway models from backend API\n  const fetchGatewayModels = useCallback(async () => {\n    setIsLoadingGateway(true);\n    try {\n      const response = await fetch('/api/admin/setting/gateway-models');\n\n      if (!response.ok) {\n        throw new Error(`Failed to fetch models: ${response.status}`);\n      }\n\n      const data = await response.json();\n      setGatewayConfigured(data.configured);\n      setGatewayModels(data.models || []);\n    } catch (error) {\n      console.error('Failed to fetch gateway models:', error);\n      setGatewayConfigured(false);\n    } finally {\n      setIsLoadingGateway(false);\n    }\n  }, []);\n\n  // Pre-fetch gateway status on mount when needGroup is enabled\n  useEffect(() => {\n    if (needGroup && gatewayConfigured === null) {\n      fetchGatewayModels();\n    }\n  }, [needGroup, gatewayConfigured, fetchGatewayModels]);\n\n  // Transform gateway models to picker format and filter by type\n  const pickerModels = useMemo((): IPickerModel[] => {\n    // Filter by type based on context\n    const filtered = gatewayModels.filter((m) => {\n      if (onlyImageOutput) {\n        // For attachment fields: show image type or models with image-generation tag\n        return m.type === 'image' || m.tags?.includes('image-generation');\n      } else {\n        // For regular fields: show language type models only, exclude image-generation models\n        return m.type === 'language' && !m.tags?.includes('image-generation');\n      }\n    });\n\n    // Transform to IPickerModel format\n    return filtered.map((m) => ({\n      ...m,\n      modelType: m.type,\n    }));\n  }, [gatewayModels, onlyImageOutput]);\n\n  // Extract model ID from current value for picker selection state\n  const selectedModelIdForPicker = useMemo(() => {\n    if (value && isGatewayModelKey(value)) {\n      const { model: modelId } = parseModelKey(value);\n      return modelId;\n    }\n    return undefined;\n  }, [value]);\n\n  // Find gateway model from value if not in options\n  const findGatewayModel = useCallback(\n    (searchValue: string): IModelOption | undefined => {\n      // First try to find in options\n      const fromOptions = options?.find(\n        ({ modelKey }) => modelKey.toLowerCase() === searchValue.toLowerCase()\n      );\n      if (fromOptions) return fromOptions;\n\n      // If not found and value looks like a gateway model, check gatewayModels\n      if (searchValue && isGatewayModelKey(searchValue)) {\n        const { model: modelId } = parseModelKey(searchValue);\n        const gatewayModel = gatewayModels.find((m) => m.id === modelId);\n        if (gatewayModel) {\n          return {\n            modelKey: searchValue,\n            label: gatewayModel.name,\n            isGateway: true,\n            tags: gatewayModel.tags,\n            ownedBy: gatewayModel.ownedBy,\n          } as IModelOption;\n        }\n      }\n\n      return undefined;\n    },\n    [options, gatewayModels]\n  );\n\n  return {\n    gatewayModels,\n    isLoadingGateway,\n    gatewayConfigured,\n    pickerModels,\n    selectedModelIdForPicker,\n    fetchGatewayModels,\n    findGatewayModel,\n  };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/useModelCategories.ts",
    "content": "import type { IModelDefinationMap } from '@teable/openapi';\nimport { useMemo } from 'react';\nimport { isGatewayModelKey } from '../utils';\nimport type { IModelCategories, IModelOption } from './types';\nimport { checkIsImageModel, checkIsLanguageModel } from './utils';\n\ninterface IUseModelCategoriesOptions {\n  options: IModelOption[];\n  onlyImageOutput: boolean;\n  modelDefinationMap?: IModelDefinationMap;\n}\n\n/**\n * Hook to categorize models into gateway, space, and instance options\n */\nexport function useModelCategories({\n  options,\n  onlyImageOutput,\n  modelDefinationMap,\n}: IUseModelCategoriesOptions): IModelCategories {\n  return useMemo(() => {\n    // Filter models based on field type context\n    const filterByFieldType = (option: IModelOption): boolean => {\n      // For attachment fields: show image models\n      // For regular fields: show language models\n      return onlyImageOutput\n        ? checkIsImageModel(option, modelDefinationMap)\n        : checkIsLanguageModel(option, modelDefinationMap);\n    };\n\n    return {\n      // Gateway models (Recommended) - from AI Gateway\n      gatewayOptions: options.filter((option) => {\n        const { isGateway, modelKey } = option;\n        if (!isGateway && !isGatewayModelKey(modelKey)) return false;\n        return filterByFieldType(option);\n      }),\n      // Space models (Custom) - from space integration\n      spaceOptions: options.filter((option) => {\n        const { isInstance, modelKey, isGateway } = option;\n        if (isInstance || isGateway || isGatewayModelKey(modelKey)) return false;\n        return filterByFieldType(option);\n      }),\n      // Instance models (Legacy Provider) - from admin settings\n      instanceOptions: options.filter((option) => {\n        const { isInstance, modelKey, isGateway } = option;\n        if (!isInstance || isGateway || isGatewayModelKey(modelKey)) return false;\n        return filterByFieldType(option);\n      }),\n    };\n  }, [options, onlyImageOutput, modelDefinationMap]);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/utils.ts",
    "content": "import type { IModelDefinationMap } from '@teable/openapi';\nimport { GATEWAY_PROVIDER_ICONS, LLM_PROVIDER_ICONS } from '../constant';\nimport { parseModelKey, isGatewayModelKey, isImageOutputModel } from '../utils';\nimport type { IModelOption } from './types';\n\n/**\n * Parse provider from gateway model ID (e.g., \"anthropic/claude-sonnet-4\" → \"anthropic\")\n */\nconst parseProviderFromModelId = (modelId: string): string | undefined => {\n  if (modelId.includes('/')) {\n    return modelId.split('/')[0];\n  }\n  return undefined;\n};\n\n/**\n * Get icon component for a model (gateway or standard provider)\n */\nexport const getModelIcon = (\n  modelKey: string,\n  ownedBy?: string\n): React.ComponentType<{ className?: string }> | undefined => {\n  const { type, model } = parseModelKey(modelKey);\n\n  // For gateway models, try to get icon from ownedBy or parse from model ID\n  if (isGatewayModelKey(modelKey)) {\n    // First try ownedBy if available\n    if (ownedBy) {\n      const icon = GATEWAY_PROVIDER_ICONS[ownedBy as keyof typeof GATEWAY_PROVIDER_ICONS];\n      if (icon) return icon;\n    }\n    // Fallback: parse provider from model ID (e.g., \"anthropic/claude-sonnet-4\")\n    if (model) {\n      const parsedProvider = parseProviderFromModelId(model);\n      if (parsedProvider) {\n        const icon = GATEWAY_PROVIDER_ICONS[parsedProvider as keyof typeof GATEWAY_PROVIDER_ICONS];\n        if (icon) return icon;\n      }\n    }\n  }\n\n  // For standard providers, use LLM_PROVIDER_ICONS\n  return LLM_PROVIDER_ICONS[type as keyof typeof LLM_PROVIDER_ICONS];\n};\n\n/**\n * Convert USD per token pricing to credits display string\n * 1 credit = $0.01\n */\nexport const formatPriceToCredits = (\n  pricing:\n    | {\n        input?: string;\n        output?: string;\n        image?: string;\n      }\n    | undefined\n): string => {\n  if (!pricing) return '';\n\n  // Convert USD per token to credits per 1M tokens\n  const usdToCredits = (usd: string | undefined) => {\n    if (!usd) return 0;\n    const val = parseFloat(usd);\n    if (isNaN(val) || val === 0) return 0;\n    // Convert USD per token to credits per 1M tokens\n    return Math.round((val * 1_000_000) / 0.01);\n  };\n\n  // For image pricing\n  if (pricing.image) {\n    const imgCredits = Math.round(parseFloat(pricing.image) / 0.01);\n    return `${imgCredits} credits/img`;\n  }\n\n  // For text pricing\n  const inputCredits = usdToCredits(pricing.input);\n  const outputCredits = usdToCredits(pricing.output);\n  return `${inputCredits}/${outputCredits} credits/1M`;\n};\n\n/**\n * Check if a model option is an image generation model\n */\nexport const checkIsImageModel = (\n  option: IModelOption,\n  modelDefinationMap?: IModelDefinationMap\n): boolean => {\n  const { modelKey, isImageModel, modelType, tags } = option;\n  if (isImageModel || modelType === 'image') return true;\n  if (tags?.includes('image-generation')) return true;\n  const { model = '' } = parseModelKey(modelKey);\n  const modelDefination = modelDefinationMap?.[model];\n  return isImageOutputModel(modelDefination);\n};\n\n/**\n * Check if a model option is a language model\n */\nexport const checkIsLanguageModel = (\n  option: IModelOption,\n  modelDefinationMap?: IModelDefinationMap\n): boolean => {\n  const { modelType, tags } = option;\n  // If model has image-generation tag, it's not a pure language model\n  // (e.g., gemini-3-pro-image is a multimodal model primarily for image generation)\n  if (tags?.includes('image-generation')) return false;\n  // If modelType is explicitly set, use it\n  if (modelType) return modelType === 'language';\n  // If no modelType, it's a language model if it's not an image model\n  return !checkIsImageModel(option, modelDefinationMap);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/constant.ts",
    "content": "import {\n  Anthropic,\n  Azure,\n  Cohere,\n  Deepseek,\n  GoogleLogo,\n  Lingyiwanwu,\n  Mistral,\n  Openai,\n  Qwen,\n  Zhipu,\n  Xai,\n  Ollama,\n  AmazonBedrock,\n  OpenRouter,\n  Zap,\n  // Gateway provider icons\n  Meta,\n  Moonshot,\n  Perplexity,\n  Nvidia,\n  Minimax,\n  Vercel,\n  Voyage,\n  Bfl,\n  Bytedance,\n  Xiaomi,\n  Meituan,\n  Kwaipilot,\n  ArceeAi,\n  PrimeIntellect,\n  Morph,\n  Inception,\n  Stealth,\n} from '@teable/icons';\nimport type { GatewayModelProvider } from '@teable/openapi';\nimport { LLMProviderType } from '@teable/openapi';\n\nexport const LLM_PROVIDER_ICONS = {\n  [LLMProviderType.OPENAI]: Openai,\n  [LLMProviderType.ANTHROPIC]: Anthropic,\n  [LLMProviderType.GOOGLE]: GoogleLogo,\n  [LLMProviderType.AZURE]: Azure,\n  [LLMProviderType.COHERE]: Cohere,\n  [LLMProviderType.MISTRAL]: Mistral,\n  [LLMProviderType.DEEPSEEK]: Deepseek,\n  [LLMProviderType.QWEN]: Qwen,\n  [LLMProviderType.ZHIPU]: Zhipu,\n  [LLMProviderType.LINGYIWANWU]: Lingyiwanwu,\n  [LLMProviderType.XAI]: Xai,\n  [LLMProviderType.TOGETHERAI]: Openai,\n  [LLMProviderType.OLLAMA]: Ollama,\n  [LLMProviderType.AMAZONBEDROCK]: AmazonBedrock,\n  [LLMProviderType.OPENROUTER]: OpenRouter,\n  [LLMProviderType.OPENAI_COMPATIBLE]: Openai,\n  [LLMProviderType.AI_GATEWAY]: Zap, // AI Gateway uses Zap icon\n};\n\nexport const LLM_PROVIDERS = [\n  {\n    value: LLMProviderType.DEEPSEEK,\n    label: 'DeepSeek',\n    baseUrlPlaceholder: 'https://api.deepseek.ai/v1',\n    modelsPlaceholder: 'deepseek-chat,deepseek-reasoner',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.DEEPSEEK],\n  },\n  {\n    value: LLMProviderType.OPENAI,\n    label: 'OpenAI',\n    baseUrlPlaceholder: 'https://api.openai.com/v1',\n    modelsPlaceholder: 'gpt-5.2, o3, gpt-5-mini',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.OPENAI],\n  },\n  {\n    value: LLMProviderType.ANTHROPIC,\n    label: 'Anthropic',\n    baseUrlPlaceholder: 'https://api.anthropic.com/v1',\n    modelsPlaceholder: 'claude-sonnet-4-6,claude-opus-4-6,claude-sonnet-4-6',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.ANTHROPIC],\n  },\n  {\n    value: LLMProviderType.GOOGLE,\n    label: 'Google',\n    baseUrlPlaceholder: 'https://generativelanguage.googleapis.com/v1beta',\n    modelsPlaceholder: 'gemini-3-flash-preview,gemini-2.5-pro',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.GOOGLE],\n  },\n  {\n    value: LLMProviderType.AZURE,\n    label: 'Azure',\n    baseUrlPlaceholder: 'https://{your-resource-name}.openai.azure.com',\n    modelsPlaceholder: 'gpt-4,gpt-35-turbo',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.AZURE],\n  },\n  {\n    value: LLMProviderType.COHERE,\n    label: 'Cohere',\n    baseUrlPlaceholder: 'https://api.cohere.ai/v1',\n    modelsPlaceholder: 'command-r,command-r-plus,command-r-plus-online',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.COHERE],\n  },\n  {\n    value: LLMProviderType.MISTRAL,\n    label: 'Mistral',\n    baseUrlPlaceholder: 'https://api.mistral.ai/v1',\n    modelsPlaceholder: 'mistral-large-latest,codestral-latest',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.MISTRAL],\n  },\n  {\n    value: LLMProviderType.QWEN,\n    label: 'Qwen',\n    baseUrlPlaceholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n    modelsPlaceholder: 'qwen3.5-plus,qwen3-max',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.QWEN],\n  },\n  {\n    value: LLMProviderType.ZHIPU,\n    label: 'Zhipu',\n    baseUrlPlaceholder: 'https://open.bigmodel.cn/api/paas/v4',\n    modelsPlaceholder: 'glm-3-turbo,glm-4,glm-4-air',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.ZHIPU],\n  },\n  {\n    value: LLMProviderType.LINGYIWANWU,\n    label: 'Yi',\n    baseUrlPlaceholder: 'https://api.lingyiwanwu.com/v1',\n    modelsPlaceholder: 'yi-lightning,yi-large',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.LINGYIWANWU],\n  },\n  {\n    value: LLMProviderType.XAI,\n    label: 'XAI',\n    baseUrlPlaceholder: 'https://api.x.ai/v1',\n    modelsPlaceholder: 'grok-4-1-fast,grok-3-beta',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.XAI],\n  },\n  {\n    value: LLMProviderType.TOGETHERAI,\n    label: 'TogetherAI',\n    baseUrlPlaceholder: 'https://api.together.xyz/v1',\n    modelsPlaceholder: 'deepseek-ai/DeepSeek-R1,meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.TOGETHERAI],\n  },\n  {\n    value: LLMProviderType.OLLAMA,\n    label: 'Ollama',\n    baseUrlPlaceholder: 'http://localhost:11434',\n    modelsPlaceholder: 'deepseek-r1,qwen3:8b,llama4:8b',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.OLLAMA],\n  },\n  {\n    value: LLMProviderType.AMAZONBEDROCK,\n    label: 'Amazon Bedrock',\n    baseUrlPlaceholder: 'https://bedrock-runtime.us-east-1.amazonaws.com',\n    modelsPlaceholder: 'amazon.titan-embed-image-v1,amazon.titan-embed-text-v1',\n    apiKeyPlaceholder: 'REGION.ACCESS_KEY_ID.SECRET_ACCESS_KEY',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.AMAZONBEDROCK],\n  },\n  {\n    value: LLMProviderType.OPENROUTER,\n    label: 'OpenRouter',\n    baseUrlPlaceholder: 'https://openrouter.ai/api/v1',\n    modelsPlaceholder: 'anthropic/claude-sonnet-4-6,google/gemini-2.5-flash',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.OPENROUTER],\n  },\n  {\n    value: LLMProviderType.OPENAI_COMPATIBLE,\n    label: 'OpenAI Compatible',\n    baseUrlPlaceholder: 'https://api.openai.com/v1',\n    modelsPlaceholder: 'gpt-4.1,o3,gpt-4.1-mini',\n    Icon: LLM_PROVIDER_ICONS[LLMProviderType.OPENAI_COMPATIBLE],\n  },\n] as const;\n\n// Gateway provider icons (owned_by field from AI Gateway API)\nexport const GATEWAY_PROVIDER_ICONS: Record<\n  GatewayModelProvider,\n  React.ComponentType<{ className?: string }>\n> = {\n  alibaba: Qwen,\n  amazon: AmazonBedrock,\n  anthropic: Anthropic,\n  'arcee-ai': ArceeAi,\n  bfl: Bfl,\n  bytedance: Bytedance,\n  cohere: Cohere,\n  deepseek: Deepseek,\n  google: GoogleLogo,\n  inception: Inception,\n  kwaipilot: Kwaipilot,\n  meituan: Meituan,\n  meta: Meta,\n  minimax: Minimax,\n  mistral: Mistral,\n  moonshotai: Moonshot,\n  morph: Morph,\n  nvidia: Nvidia,\n  openai: Openai,\n  perplexity: Perplexity,\n  'prime-intellect': PrimeIntellect,\n  stealth: Stealth,\n  vercel: Vercel,\n  voyage: Voyage,\n  xai: Xai,\n  xiaomi: Xiaomi,\n  zai: Zhipu,\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/AddModelDialog.tsx",
    "content": "'use client';\n\nimport { Plus, AlertCircle, CheckCircle2 } from '@teable/icons';\nimport type { IGatewayModel } from '@teable/openapi';\nimport {\n  Button,\n  Input,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport type { TFunction } from 'next-i18next';\nimport { ModelSearchPopover } from './ModelSearchPopover';\nimport { PricingSection } from './PricingSection';\nimport type { IGatewayModelAPI, ITestState } from './types';\n\ninterface IAddModelDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  newModel: Partial<IGatewayModel>;\n  onNewModelChange: (model: Partial<IGatewayModel>) => void;\n  // Search popover props\n  modelSearchOpen: boolean;\n  onModelSearchOpenChange: (open: boolean) => void;\n  searchQuery: string;\n  onSearchQueryChange: (query: string) => void;\n  isModelIdValid: boolean;\n  isLoadingModels: boolean;\n  modelsLoadError: string | null;\n  filteredModels: IGatewayModelAPI[];\n  gatewayModels: IGatewayModel[];\n  availableModelsCount: number;\n  onSelectModel: (modelId: string) => void;\n  onRetry: () => void;\n  // Pricing props\n  showPricing: boolean;\n  pricingExpanded: boolean;\n  onPricingExpandedChange: (expanded: boolean) => void;\n  onPricingChange: (\n    field: 'input' | 'output' | 'inputCacheRead' | 'inputCacheWrite' | 'image' | 'webSearch',\n    value: string\n  ) => void;\n  // Test state\n  testState: ITestState;\n  // Actions\n  onAddModel: () => void;\n  t: TFunction;\n}\n\nexport function AddModelDialog({\n  open,\n  onOpenChange,\n  newModel,\n  onNewModelChange,\n  modelSearchOpen,\n  onModelSearchOpenChange,\n  searchQuery,\n  onSearchQueryChange,\n  isModelIdValid,\n  isLoadingModels,\n  modelsLoadError,\n  filteredModels,\n  gatewayModels,\n  availableModelsCount,\n  onSelectModel,\n  onRetry,\n  showPricing,\n  pricingExpanded,\n  onPricingExpandedChange,\n  onPricingChange,\n  testState,\n  onAddModel,\n  t,\n}: IAddModelDialogProps) {\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-lg\">\n        <DialogHeader>\n          <DialogTitle>{t('admin.setting.ai.addGatewayModel')}</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          {/* Model ID with search */}\n          <div>\n            <Label>{t('admin.setting.ai.modelId')}</Label>\n            <ModelSearchPopover\n              open={modelSearchOpen}\n              onOpenChange={onModelSearchOpenChange}\n              selectedModelId={newModel.id || ''}\n              isModelIdValid={isModelIdValid}\n              isLoadingModels={isLoadingModels}\n              modelsLoadError={modelsLoadError}\n              filteredModels={filteredModels}\n              gatewayModels={gatewayModels}\n              searchQuery={searchQuery}\n              onSearchQueryChange={onSearchQueryChange}\n              onSelectModel={onSelectModel}\n              onRetry={onRetry}\n              t={t}\n            />\n            {!isModelIdValid && availableModelsCount > 0 && (\n              <p className=\"mt-1 text-xs text-amber-600\">{t('admin.setting.ai.modelNotFound')}</p>\n            )}\n            <p className=\"mt-1 text-xs text-muted-foreground\">\n              {t('admin.setting.ai.modelIdHint')}\n            </p>\n          </div>\n\n          <div>\n            <Label>{t('admin.setting.ai.displayLabel')}</Label>\n            <Input\n              value={newModel.label}\n              onChange={(e) => onNewModelChange({ ...newModel, label: e.target.value })}\n              placeholder=\"Model Display Name\"\n              className=\"mt-1\"\n            />\n          </div>\n\n          {/* Note: isImageModel is auto-detected from API type and tags */}\n\n          {/* Pricing Section - USD per token (only show in Cloud) */}\n          {showPricing && (\n            <PricingSection\n              expanded={pricingExpanded}\n              onExpandedChange={onPricingExpandedChange}\n              pricing={newModel.pricing}\n              modelType={newModel.modelType}\n              onPricingChange={onPricingChange}\n            />\n          )}\n        </div>\n\n        {/* Test Result */}\n        {testState.result && (\n          <div\n            className={cn(\n              'flex items-center gap-2 rounded-lg p-3 text-sm',\n              testState.result === 'success'\n                ? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'\n                : 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'\n            )}\n          >\n            {testState.result === 'success' ? (\n              <CheckCircle2 className=\"size-4\" />\n            ) : (\n              <AlertCircle className=\"size-4\" />\n            )}\n            {testState.message}\n          </div>\n        )}\n\n        <DialogFooter className=\"gap-2 sm:gap-0\">\n          <div className=\"flex-1\" />\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t('actions.cancel')}\n          </Button>\n          <Button onClick={onAddModel} disabled={!newModel.id || !newModel.label}>\n            <Plus className=\"mr-1 size-4\" />\n            {t('admin.setting.ai.addModel')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/ModelCard.tsx",
    "content": "'use client';\n\nimport { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { DraggableHandle, Trash2, Image as ImageIcon } from '@teable/icons';\nimport type { IGatewayModel, IModelAbility, GatewayModelProvider } from '@teable/openapi';\nimport { Button, Switch, Badge, cn } from '@teable/ui-lib/shadcn';\nimport { GATEWAY_PROVIDER_ICONS } from '../constant';\nimport { CAPABILITY_LABELS } from '../GatewayModelPickerDialog';\nimport { formatUsdPriceShort } from './utils';\n\n// Extract provider from model ID (e.g., \"anthropic/claude-sonnet-4.5\" -> \"anthropic\")\nfunction getProviderFromModelId(modelId: string): GatewayModelProvider | undefined {\n  const provider = modelId.split('/')[0];\n  if (provider && provider in GATEWAY_PROVIDER_ICONS) {\n    return provider as GatewayModelProvider;\n  }\n  return undefined;\n}\n\ninterface IModelCardProps {\n  model: IGatewayModel;\n  showPricing: boolean;\n  onToggleEnabled: (modelId: string, enabled: boolean) => void;\n  onRemove: (modelId: string) => void;\n}\n\nexport function ModelCard({ model, showPricing, onToggleEnabled, onRemove }: IModelCardProps) {\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id: model.id,\n  });\n\n  const style = {\n    transition,\n    transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),\n  };\n\n  // Try ownedBy first, fallback to extracting from model ID\n  const provider = model.ownedBy || getProviderFromModelId(model.id);\n  const ProviderIcon = provider\n    ? GATEWAY_PROVIDER_ICONS[provider as keyof typeof GATEWAY_PROVIDER_ICONS]\n    : undefined;\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={cn(\n        'flex items-center gap-3 rounded-lg border p-4 transition-colors',\n        model.enabled ? 'bg-card' : 'bg-muted text-muted-foreground',\n        isDragging && 'z-10 opacity-50 shadow-lg'\n      )}\n    >\n      <button\n        type=\"button\"\n        className=\"shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing\"\n        {...attributes}\n        {...listeners}\n      >\n        <DraggableHandle className=\"size-4\" />\n      </button>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n        <div className=\"flex items-center gap-2\">\n          {ProviderIcon && <ProviderIcon className=\"size-4 shrink-0\" />}\n          <span className=\"font-medium\">{model.label}</span>\n          {/* Show pricing (only in Cloud) */}\n          {showPricing &&\n            model.pricing &&\n            (model.pricing.input || model.pricing.output || model.pricing.image) && (\n              <Badge\n                variant=\"outline\"\n                className={cn(\n                  'px-2 text-[11px]',\n                  model.enabled ? 'text-foreground' : 'text-muted-foreground'\n                )}\n              >\n                {model.modelType === 'image' && model.pricing.image\n                  ? `$${model.pricing.image}/img`\n                  : `${formatUsdPriceShort(model.pricing.input)}/${formatUsdPriceShort(model.pricing.output)}`}\n              </Badge>\n            )}\n          {/* Show Image badge based on modelType or isImageModel flag */}\n          {(model.modelType === 'image' ||\n            model.isImageModel ||\n            model.tags?.includes('image-generation')) && (\n            <Badge variant=\"secondary\" className=\"text-xs\">\n              <ImageIcon className=\"mr-1 size-3\" />\n              Image\n            </Badge>\n          )}\n          {/* Show Embedding badge for embedding models */}\n          {(model.modelType === 'embedding' || model.id.toLowerCase().includes('embedding')) && (\n            <Badge variant=\"secondary\" className=\"text-xs\">\n              Embed\n            </Badge>\n          )}\n        </div>\n\n        <code className=\"text-xs text-muted-foreground\">{model.id}</code>\n\n        {model.capabilities && (\n          <div className=\"flex gap-1\">\n            {Object.entries(model.capabilities)\n              .filter(([, v]) => v)\n              .map(([key]) => (\n                <Badge\n                  key={key}\n                  variant=\"outline\"\n                  className={cn(\n                    'bg-muted text-[11px] font-normal',\n                    model.enabled ? 'text-foreground' : 'text-muted-foreground'\n                  )}\n                >\n                  {CAPABILITY_LABELS[key as keyof IModelAbility] || key}\n                </Badge>\n              ))}\n          </div>\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <Switch\n          checked={model.enabled}\n          onCheckedChange={(checked) => onToggleEnabled(model.id, checked)}\n        />\n\n        <Button\n          size=\"sm\"\n          variant=\"ghost\"\n          className=\"size-7 p-0 text-muted-foreground\"\n          onClick={() => onRemove(model.id)}\n        >\n          <Trash2 className=\"size-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/ModelSearchPopover.tsx",
    "content": "'use client';\n\nimport { Search, Loader2 } from '@teable/icons';\nimport type { IGatewayModel } from '@teable/openapi';\nimport {\n  Button,\n  Badge,\n  cn,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport type { TFunction } from 'next-i18next';\nimport { useEffect, useRef } from 'react';\nimport { GATEWAY_PROVIDER_ICONS } from '../constant';\nimport type { IGatewayModelAPI } from './types';\nimport { formatUsdPriceShort, detectIsImageModel, getPricingFromApiModel } from './utils';\n\ninterface IModelSearchPopoverProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  selectedModelId: string;\n  isModelIdValid: boolean;\n  isLoadingModels: boolean;\n  modelsLoadError: string | null;\n  filteredModels: IGatewayModelAPI[];\n  gatewayModels: IGatewayModel[];\n  searchQuery: string;\n  onSearchQueryChange: (query: string) => void;\n  onSelectModel: (modelId: string) => void;\n  onRetry: () => void;\n  t: TFunction;\n}\n\nexport function ModelSearchPopover({\n  open,\n  onOpenChange,\n  selectedModelId,\n  isModelIdValid,\n  isLoadingModels,\n  modelsLoadError,\n  filteredModels,\n  gatewayModels,\n  searchQuery,\n  onSearchQueryChange,\n  onSelectModel,\n  onRetry,\n  t,\n}: IModelSearchPopoverProps) {\n  const commandListRef = useRef<HTMLDivElement>(null);\n\n  // Scroll to top when search query changes\n  useEffect(() => {\n    if (commandListRef.current) {\n      commandListRef.current.scrollTop = 0;\n    }\n  }, [searchQuery]);\n\n  return (\n    <Popover open={open} onOpenChange={onOpenChange} modal>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className={cn(\n            'mt-1 w-full justify-between font-normal',\n            !selectedModelId && 'text-muted-foreground',\n            !isModelIdValid && 'border-amber-500'\n          )}\n        >\n          {selectedModelId || t('admin.setting.ai.searchModel')}\n          {isLoadingModels ? (\n            <Loader2 className=\"ml-2 size-4 shrink-0 animate-spin opacity-50\" />\n          ) : (\n            <Search className=\"ml-2 size-4 shrink-0 opacity-50\" />\n          )}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[--radix-popover-trigger-width] p-0\" align=\"start\">\n        <Command shouldFilter={false}>\n          <CommandInput\n            placeholder={t('admin.setting.ai.searchModelPlaceholder')}\n            value={searchQuery}\n            onValueChange={onSearchQueryChange}\n          />\n          <CommandList ref={commandListRef} className=\"max-h-[300px]\">\n            {isLoadingModels ? (\n              <div className=\"flex items-center justify-center py-6\">\n                <Loader2 className=\"size-6 animate-spin text-muted-foreground\" />\n              </div>\n            ) : modelsLoadError ? (\n              <div className=\"p-4 text-center text-sm text-muted-foreground\">\n                <p>{modelsLoadError}</p>\n                <Button size=\"sm\" variant=\"ghost\" className=\"mt-2\" onClick={onRetry}>\n                  Retry\n                </Button>\n              </div>\n            ) : filteredModels.length === 0 ? (\n              <CommandEmpty>\n                {searchQuery ? (\n                  <div className=\"space-y-2\">\n                    <p>{t('admin.setting.ai.noMatchingModels')}</p>\n                    <Button size=\"sm\" variant=\"outline\" onClick={() => onSelectModel(searchQuery)}>\n                      {t('admin.setting.ai.useCustomId', { id: searchQuery })}\n                    </Button>\n                  </div>\n                ) : (\n                  t('admin.setting.ai.typeToSearch')\n                )}\n              </CommandEmpty>\n            ) : (\n              <CommandGroup>\n                {filteredModels.map((model) => {\n                  const isAlreadyAdded = gatewayModels.some((m) => m.id === model.id);\n                  const pricing = getPricingFromApiModel(model);\n                  const isImage = detectIsImageModel(model.id, model);\n                  const ModelIcon = model.ownedBy\n                    ? GATEWAY_PROVIDER_ICONS[model.ownedBy as keyof typeof GATEWAY_PROVIDER_ICONS]\n                    : undefined;\n                  return (\n                    <CommandItem\n                      key={model.id}\n                      value={model.id}\n                      onSelect={() => onSelectModel(model.id)}\n                      disabled={isAlreadyAdded}\n                      className={cn(isAlreadyAdded && 'opacity-50')}\n                    >\n                      <div className=\"flex flex-1 items-center justify-between\">\n                        <div className=\"flex items-center gap-2\">\n                          {ModelIcon && <ModelIcon className=\"size-5 shrink-0\" />}\n                          <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                            <div className=\"flex items-center gap-2\">\n                              <code className=\"text-xs\">{model.id}</code>\n                              {isImage && (\n                                <Badge variant=\"secondary\" className=\"h-5 border p-1.5 text-[10px]\">\n                                  Image\n                                </Badge>\n                              )}\n                            </div>\n                            {model.name && (\n                              <div className=\"text-xs text-muted-foreground\">{model.name}</div>\n                            )}\n                          </div>\n                        </div>\n                        <div className=\"flex items-center gap-2\">\n                          {pricing && (pricing.input || pricing.output) && (\n                            <Badge variant=\"outline\" className=\"text-[10px]\">\n                              {formatUsdPriceShort(pricing.input)}/\n                              {formatUsdPriceShort(pricing.output)}\n                            </Badge>\n                          )}\n                          {isAlreadyAdded && (\n                            <Badge variant=\"secondary\" className=\"text-[10px]\">\n                              Added\n                            </Badge>\n                          )}\n                        </div>\n                      </div>\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/PricingSection.tsx",
    "content": "'use client';\n\nimport { DollarSign } from '@teable/icons';\nimport type { IGatewayModel } from '@teable/openapi';\nimport {\n  Button,\n  Input,\n  Badge,\n  Label,\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from '@teable/ui-lib/shadcn';\n\ninterface IPricingSectionProps {\n  expanded: boolean;\n  onExpandedChange: (expanded: boolean) => void;\n  pricing: IGatewayModel['pricing'];\n  modelType?: string;\n  onPricingChange: (\n    field: 'input' | 'output' | 'inputCacheRead' | 'inputCacheWrite' | 'image' | 'webSearch',\n    value: string\n  ) => void;\n}\n\nexport function PricingSection({\n  expanded,\n  onExpandedChange,\n  pricing,\n  modelType,\n  onPricingChange,\n}: IPricingSectionProps) {\n  return (\n    <Collapsible open={expanded} onOpenChange={onExpandedChange}>\n      <CollapsibleTrigger asChild>\n        <Button variant=\"ghost\" size=\"sm\" className=\"w-full justify-between\">\n          <span className=\"flex items-center gap-2\">\n            <DollarSign className=\"size-4\" />\n            Pricing (USD per token)\n            {pricing?.input && (\n              <Badge variant=\"secondary\" className=\"text-xs\">\n                ✓\n              </Badge>\n            )}\n          </span>\n        </Button>\n      </CollapsibleTrigger>\n      <CollapsibleContent>\n        <div className=\"mt-2 space-y-3 rounded-lg border bg-muted/30 p-3\">\n          <p className=\"text-xs text-muted-foreground\">\n            USD per token (same format as Vercel AI Gateway API). e.g., 0.000003 = $3/1M tokens\n          </p>\n\n          {modelType === 'image' ? (\n            <div>\n              <Label className=\"text-xs\">Per Image (USD)</Label>\n              <Input\n                type=\"text\"\n                value={pricing?.image ?? ''}\n                onChange={(e) => onPricingChange('image', e.target.value)}\n                placeholder=\"0.04\"\n                className=\"mt-1\"\n              />\n            </div>\n          ) : (\n            <div className=\"grid grid-cols-2 gap-3\">\n              <div>\n                <Label className=\"text-xs\">Input ($/token)</Label>\n                <Input\n                  type=\"text\"\n                  value={pricing?.input ?? ''}\n                  onChange={(e) => onPricingChange('input', e.target.value)}\n                  placeholder=\"0.000003\"\n                  className=\"mt-1\"\n                />\n              </div>\n              <div>\n                <Label className=\"text-xs\">Output ($/token)</Label>\n                <Input\n                  type=\"text\"\n                  value={pricing?.output ?? ''}\n                  onChange={(e) => onPricingChange('output', e.target.value)}\n                  placeholder=\"0.000015\"\n                  className=\"mt-1\"\n                />\n              </div>\n              <div>\n                <Label className=\"text-xs\">Cache Read</Label>\n                <Input\n                  type=\"text\"\n                  value={pricing?.inputCacheRead ?? ''}\n                  onChange={(e) => onPricingChange('inputCacheRead', e.target.value)}\n                  placeholder=\"0.0000003\"\n                  className=\"mt-1\"\n                />\n              </div>\n              <div>\n                <Label className=\"text-xs\">Cache Write</Label>\n                <Input\n                  type=\"text\"\n                  value={pricing?.inputCacheWrite ?? ''}\n                  onChange={(e) => onPricingChange('inputCacheWrite', e.target.value)}\n                  placeholder=\"0.00000375\"\n                  className=\"mt-1\"\n                />\n              </div>\n            </div>\n          )}\n        </div>\n      </CollapsibleContent>\n    </Collapsible>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/QuickAddButtons.tsx",
    "content": "'use client';\n\nimport { Plus } from '@teable/icons';\nimport type { IGatewayModel, GatewayModelProvider } from '@teable/openapi';\nimport {\n  Button,\n  Label,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport type { TFunction } from 'next-i18next';\nimport { GATEWAY_PROVIDER_ICONS } from '../constant';\nimport type { IGatewayModelAPI } from './types';\nimport { formatUsdPriceShort, generateLabelFromId, getPricingFromApiModel } from './utils';\n\n// Extract provider from model ID (e.g., \"anthropic/claude-sonnet-4.5\" -> \"anthropic\")\nfunction getProviderFromModelId(modelId: string): GatewayModelProvider | undefined {\n  const provider = modelId.split('/')[0];\n  if (provider && provider in GATEWAY_PROVIDER_ICONS) {\n    return provider as GatewayModelProvider;\n  }\n  return undefined;\n}\n\ninterface IQuickAddButtonsProps {\n  availableRecommendedIds: string[];\n  isLoadingModels: boolean;\n  findApiModel: (modelId: string) => IGatewayModelAPI | undefined;\n  onQuickAdd: (modelId: string) => void;\n  onOpenDialog: () => void;\n  showPricing: boolean;\n  t: TFunction;\n}\n\nexport function QuickAddButtons({\n  availableRecommendedIds,\n  isLoadingModels,\n  findApiModel,\n  onQuickAdd,\n  onOpenDialog,\n  showPricing,\n  t,\n}: IQuickAddButtonsProps) {\n  return (\n    <div>\n      <Label className=\"text-xs text-muted-foreground\">{t('admin.setting.ai.quickAdd')}</Label>\n      <div className=\"mt-2 flex flex-wrap gap-2\">\n        {isLoadingModels ? (\n          // Show skeleton buttons while loading\n          <>\n            {[1, 2, 3, 4, 5, 6].map((i) => (\n              <div key={i} className=\"h-8 w-32 animate-pulse rounded-md bg-muted\" />\n            ))}\n          </>\n        ) : (\n          availableRecommendedIds.slice(0, 6).map((modelId) => {\n            const apiModel = findApiModel(modelId);\n            const pricing = getPricingFromApiModel(apiModel);\n            const provider = apiModel?.ownedBy || getProviderFromModelId(modelId);\n            const ProviderIcon = provider\n              ? GATEWAY_PROVIDER_ICONS[provider as keyof typeof GATEWAY_PROVIDER_ICONS]\n              : Plus;\n            return (\n              <TooltipProvider key={modelId}>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <Button size=\"sm\" variant=\"outline\" onClick={() => onQuickAdd(modelId)}>\n                      <ProviderIcon className=\"size-4\" />\n                      {apiModel?.name || generateLabelFromId(modelId)}\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <div className=\"text-xs\">\n                      <div>{modelId}</div>\n                      {showPricing && pricing && (\n                        <div className=\"text-muted-foreground\">\n                          In: {formatUsdPriceShort(pricing.input)} / Out:{' '}\n                          {formatUsdPriceShort(pricing.output)}\n                        </div>\n                      )}\n                    </div>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            );\n          })\n        )}\n        <Button\n          size=\"sm\"\n          variant=\"outline\"\n          onClick={(e) => {\n            e.preventDefault();\n            onOpenDialog();\n          }}\n        >\n          {t('admin.setting.ai.wizard.addCustom')}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/index.ts",
    "content": "export { AddModelDialog } from './AddModelDialog';\nexport { ModelCard } from './ModelCard';\nexport { ModelSearchPopover } from './ModelSearchPopover';\nexport { PricingSection } from './PricingSection';\nexport { QuickAddButtons } from './QuickAddButtons';\nexport * from './types';\nexport * from './utils';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/types.ts",
    "content": "import type {\n  IGatewayModel,\n  IModelPricing,\n  GatewayModelType,\n  GatewayModelTag,\n  GatewayModelProvider,\n} from '@teable/openapi';\n\n// Recommended model IDs - all details fetched from API\nexport const RECOMMENDED_MODEL_IDS = [\n  // Language models\n  'anthropic/claude-opus-4.6', // Best quality\n  'openai/gpt-5.2-chat', // OpenAI flagship\n  'google/gemini-3.1-pro-preview', // Google flagship\n  // Image generation\n  'google/gemini-3-pro-image', // Multimodal image generation\n];\n\n// API response model structure from backend (camelCase, converted from Vercel AI Gateway snake_case)\nexport interface IGatewayModelAPI {\n  id: string;\n  object?: string;\n  created?: number;\n  ownedBy?: GatewayModelProvider;\n  name?: string;\n  description?: string;\n  contextWindow?: number;\n  maxTokens?: number;\n  type?: GatewayModelType;\n  tags?: GatewayModelTag[];\n  pricing?: IModelPricing;\n}\n\nexport interface IGatewayModelsStepProps {\n  gatewayModels: IGatewayModel[];\n  onChange: (models: IGatewayModel[]) => void;\n  disabled?: boolean;\n  apiKey?: string;\n  baseUrl?: string;\n  /** Whether to show pricing-related UI. Defaults to true (Cloud). */\n  showPricing?: boolean;\n}\n\nexport interface ITestState {\n  testing: boolean;\n  result?: 'success' | 'error';\n  message?: string;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/gateway-models-step/utils.ts",
    "content": "import type { IGatewayModel } from '@teable/openapi';\nimport type { IGatewayModelAPI } from './types';\n\n// Helper to format USD price for display\nexport function formatUsdPriceShort(price: string | undefined): string {\n  if (!price) return '-';\n  const num = parseFloat(price);\n  if (isNaN(num) || num === 0) return 'Free';\n  // Convert to per-million rate for readability\n  // e.g., \"0.000003\" -> \"$3/M\"\n  const perMillion = num * 1_000_000;\n  if (perMillion < 1) return `$${perMillion.toFixed(2)}/M`;\n  if (perMillion < 100) return `$${perMillion.toFixed(1)}/M`;\n  return `$${Math.round(perMillion)}/M`;\n}\n\n// Generate a display label from model ID or API name\nexport function generateLabelFromId(modelId: string, apiName?: string): string {\n  if (apiName) return apiName;\n  const parts = modelId.split('/');\n  const modelName = parts[parts.length - 1];\n  return modelName\n    .replace(/-\\d{8}$/, '')\n    .replace(/-/g, ' ')\n    .replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\n// Extract pricing from API model - returns the full IModelPricing as-is\nexport function getPricingFromApiModel(\n  apiModel: IGatewayModelAPI | undefined\n): IGatewayModel['pricing'] | undefined {\n  if (!apiModel?.pricing) return undefined;\n  return Object.keys(apiModel.pricing).length > 0 ? apiModel.pricing : undefined;\n}\n\n// Detect if a model is an image generation model (via API type and tags only, not keywords)\nexport function detectIsImageModel(_modelId: string, apiModel?: IGatewayModelAPI): boolean {\n  // Check API type first - pure image models\n  if (apiModel?.type === 'image') return true;\n  // Check tags for image-generation capability (multimodal LLMs)\n  if (apiModel?.tags?.some((tag) => ['image-generation', 'text-to-image'].includes(tag))) {\n    return true;\n  }\n  return false;\n}\n\n// Auto-detect capabilities from tags\nexport function detectCapabilitiesFromTags(\n  tags?: string[]\n): IGatewayModel['capabilities'] | undefined {\n  if (!tags || tags.length === 0) return undefined;\n\n  const capabilities: IGatewayModel['capabilities'] = {};\n\n  // Map API tags to our capability fields\n  const tagMapping: Record<string, keyof NonNullable<IGatewayModel['capabilities']>> = {\n    vision: 'image',\n    'file-input': 'pdf',\n    'tool-use': 'toolCall',\n    reasoning: 'reasoning',\n    'image-generation': 'imageGeneration',\n  };\n\n  for (const tag of tags) {\n    const capability = tagMapping[tag];\n    if (capability) {\n      capabilities[capability] = true;\n    }\n  }\n\n  return Object.keys(capabilities).length > 0 ? capabilities : undefined;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/utils.tsx",
    "content": "import { DeepThinking, Eye, ImageGeneration, Audio } from '@teable/icons';\nimport type {\n  IGatewayModel,\n  IImageModelDefination,\n  ISimpleLLMProvider,\n  ITextModelDefination,\n  LLMProvider,\n} from '@teable/openapi';\nimport { LLMProviderType } from '@teable/openapi';\nimport type { TFunction } from 'next-i18next';\nimport type { ReactNode } from 'react';\nimport { Trans } from 'react-i18next';\n\n// Fixed name for AI Gateway provider in modelKey\nexport const AI_GATEWAY_PROVIDER_NAME = 'teable';\n\nexport const generateModelKeyList = (llmProviders: ISimpleLLMProvider[] | LLMProvider[]) => {\n  return llmProviders\n    .map((provider) => {\n      const { models, type, name, isInstance } = provider;\n      const modelConfigs = 'modelConfigs' in provider ? provider.modelConfigs : undefined;\n      return models.split(',').map((model) => {\n        const config = modelConfigs?.[model];\n        return {\n          modelKey: `${type}@${model}@${name}`,\n          isInstance,\n          isImageModel: config?.isImageModel,\n          // Use configured label if available, otherwise use model ID\n          label: config?.label || model,\n          // Include test results from provider config\n          capabilities: config?.ability,\n          // Include metadata from modelConfigs\n          modelType: config?.modelType,\n          tags: config?.tags,\n          contextWindow: config?.contextWindow,\n          maxTokens: config?.maxTokens,\n          description: config?.description,\n        };\n      });\n    })\n    .flat();\n};\n\n/**\n * Generate model key list from gateway models\n * Format: aiGateway@<modelId>@teable\n */\nexport const generateGatewayModelKeyList = (gatewayModels: IGatewayModel[] | undefined) => {\n  if (!gatewayModels) return [];\n\n  return gatewayModels\n    .filter((model) => model.enabled)\n    .map((model) => ({\n      modelKey: `${LLMProviderType.AI_GATEWAY}@${model.id}@${AI_GATEWAY_PROVIDER_NAME}`,\n      isInstance: true, // Gateway models are instance-level\n      isImageModel: model.isImageModel,\n      label: model.label,\n      capabilities: model.capabilities,\n      isGateway: true,\n      pricing: model.pricing, // Pricing format (USD per token)\n      // API metadata for enhanced functionality\n      ownedBy: model.ownedBy,\n      modelType: model.modelType,\n      tags: model.tags,\n      contextWindow: model.contextWindow,\n      maxTokens: model.maxTokens,\n      description: model.description,\n    }));\n};\n\n/**\n * Check if a modelKey is a gateway model\n */\nexport const isGatewayModelKey = (modelKey: string): boolean => {\n  const { type, name } = parseModelKey(modelKey);\n  return (\n    type?.toLowerCase() === LLMProviderType.AI_GATEWAY.toLowerCase() &&\n    name?.toLowerCase() === AI_GATEWAY_PROVIDER_NAME.toLowerCase()\n  );\n};\n\nexport const parseModelKey = (modelKey: string | undefined) => {\n  if (!modelKey) return {};\n  const [type, model, name] = modelKey.split('@');\n  return { type, model, name };\n};\n\nexport const decimalToRatio = (decimal: number): string => {\n  if (decimal >= 1 || decimal <= 0) return '1:1';\n\n  const decimalStr = decimal.toString();\n\n  const parts = decimalStr.split('.');\n  const decimalPlaces = parts[1]?.length || 0;\n\n  const numerator = 1;\n  const denominator = Math.ceil(Math.pow(10, decimalPlaces) / Number(decimalStr.replace('.', '')));\n\n  return `${numerator}:${denominator}`;\n};\n\nexport const isImageOutputModel = (\n  modelDefination: IImageModelDefination | ITextModelDefination | undefined\n): boolean => {\n  return !!(modelDefination && 'outputType' in modelDefination);\n};\n\nexport const processModelDefinition = (\n  modelDefination: IImageModelDefination | ITextModelDefination | undefined,\n  t: TFunction\n) => {\n  if (!modelDefination) return { usageTags: [], featureTags: [] };\n\n  const usageTags: { key: string; text: string; tooltip: ReactNode }[] = [];\n  const featureTags: { key: string; tooltip: string; icon: ReactNode }[] = [];\n\n  if ('outputType' in modelDefination) {\n    const { usagePerUnit } = modelDefination as IImageModelDefination;\n    usageTags.push({\n      key: 'output',\n      text: t('admin.setting.ai.imageOutput', { credits: usagePerUnit }),\n      tooltip: t('admin.setting.ai.imageOutputTip', { credits: usagePerUnit }),\n    });\n\n    featureTags.push({\n      key: 'imageGeneration',\n      tooltip: t('admin.setting.ai.supportImageOutputTip'),\n      icon: <ImageGeneration className=\"size-4\" />,\n    });\n  }\n\n  if ('inputRate' in modelDefination) {\n    const { inputRate, outputRate, visionEnable, audioEnable, deepThinkEnable } =\n      modelDefination as ITextModelDefination;\n\n    const inputRateRatio = decimalToRatio(inputRate as number);\n    const outputRateRatio = decimalToRatio(outputRate as number);\n\n    usageTags.push(\n      {\n        key: 'input',\n        text: t('admin.setting.ai.input', { ratio: inputRateRatio }),\n        tooltip: (\n          <Trans\n            ns=\"common\"\n            i18nKey=\"admin.setting.ai.inputOrOutputTip\"\n            components={{ br: <br /> }}\n          />\n        ),\n      },\n      {\n        key: 'output',\n        text: t('admin.setting.ai.output', { ratio: outputRateRatio }),\n        tooltip: (\n          <Trans\n            ns=\"common\"\n            i18nKey=\"admin.setting.ai.inputOrOutputTip\"\n            components={{ br: <br /> }}\n          />\n        ),\n      }\n    );\n\n    const featureMap = [\n      {\n        condition: visionEnable,\n        key: 'vision',\n        tooltip: t('admin.setting.ai.supportVisionTip'),\n        icon: <Eye className=\"size-4\" />,\n      },\n      {\n        condition: audioEnable,\n        key: 'audio',\n        tooltip: t('admin.setting.ai.supportAudioTip'),\n        icon: <Audio className=\"size-4\" />,\n      },\n      {\n        condition: deepThinkEnable,\n        key: 'deepThink',\n        tooltip: t('admin.setting.ai.supportDeepThinkTip'),\n        icon: <DeepThinking className=\"size-4\" />,\n      },\n    ];\n\n    featureTags.push(\n      ...featureMap\n        .filter((feature) => feature.condition)\n        .map((feature) => ({\n          key: feature.key,\n          tooltip: feature.tooltip,\n          icon: feature.icon,\n        }))\n    );\n  }\n\n  return { usageTags, featureTags };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/canary/CanarySettings.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { ICanaryConfig, ISettingVo, IUpdateSettingRo } from '@teable/openapi';\nimport { SettingKey, updateSetting } from '@teable/openapi';\nimport {\n  Button,\n  Label,\n  Switch,\n  Textarea,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Settings } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\n\nconst parseSpaceIds = (input: string): string[] => {\n  return input\n    .split(/[,\\s]+/)\n    .map((id) => id.trim())\n    .filter(Boolean);\n};\n\ninterface ICanarySettingsProps {\n  setting: ISettingVo;\n}\n\nexport const CanarySettings = ({ setting }: ICanarySettingsProps) => {\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { enableCanaryFeature } = useEnv() as { enableCanaryFeature?: boolean };\n\n  const { mutateAsync: mutateUpdateSetting } = useMutation({\n    mutationFn: (props: IUpdateSettingRo) => updateSetting(props),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['setting'] });\n    },\n  });\n\n  const canaryConfig = setting.canaryConfig as ICanaryConfig | null | undefined;\n\n  const handleEnabledChange = useCallback(\n    (enabled: boolean) => {\n      mutateUpdateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled,\n          spaceIds: canaryConfig?.spaceIds ?? [],\n        },\n      });\n    },\n    [canaryConfig?.spaceIds, mutateUpdateSetting]\n  );\n\n  const handleSpaceIdsChange = useCallback(\n    (spaceIds: string[]) => {\n      mutateUpdateSetting({\n        [SettingKey.CANARY_CONFIG]: {\n          enabled: canaryConfig?.enabled ?? false,\n          spaceIds,\n        },\n      });\n    },\n    [canaryConfig?.enabled, mutateUpdateSetting]\n  );\n\n  // Only show if canary feature is enabled via environment variable\n  if (!enableCanaryFeature) {\n    return null;\n  }\n\n  const selectedCount = canaryConfig?.spaceIds?.length ?? 0;\n\n  return (\n    <div className=\"pb-6\">\n      <h2 className=\"mb-4 text-lg font-medium\">{t('admin.canary.title')}</h2>\n      <div className=\"flex flex-col gap-4 rounded-lg border bg-card p-4 shadow-sm\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"enable-canary\">{t('admin.canary.enable')}</Label>\n            <div className=\"text-xs text-muted-foreground\">\n              {t('admin.canary.enableDescription')}\n            </div>\n          </div>\n          <Switch\n            id=\"enable-canary\"\n            checked={Boolean(canaryConfig?.enabled)}\n            onCheckedChange={handleEnabledChange}\n          />\n        </div>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"space-y-1\">\n            <Label>{t('admin.canary.spaces')}</Label>\n            <div className=\"text-xs text-muted-foreground\">\n              {t('admin.canary.spacesDescription', { count: selectedCount })}\n            </div>\n          </div>\n          <SpaceIdsEditor spaceIds={canaryConfig?.spaceIds ?? []} onSave={handleSpaceIdsChange} />\n        </div>\n      </div>\n    </div>\n  );\n};\n\ninterface ISpaceIdsEditorProps {\n  spaceIds: string[];\n  onSave: (spaceIds: string[]) => void;\n}\n\nconst SpaceIdsEditor = ({ spaceIds, onSave }: ISpaceIdsEditorProps) => {\n  const { t } = useTranslation('common');\n  const [open, setOpen] = useState(false);\n  const [value, setValue] = useState('');\n\n  // Parse and preview space IDs in real-time\n  const parsedSpaceIds = useMemo(() => parseSpaceIds(value), [value]);\n\n  const handleOpenChange = useCallback(\n    (isOpen: boolean) => {\n      if (isOpen) {\n        // Convert array to newline-separated string\n        setValue(spaceIds.join('\\n'));\n      }\n      setOpen(isOpen);\n    },\n    [spaceIds]\n  );\n\n  const handleSave = useCallback(() => {\n    onSave(parsedSpaceIds);\n    setOpen(false);\n  }, [parsedSpaceIds, onSave]);\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger asChild>\n        <Button variant=\"outline\" size=\"sm\">\n          <Settings className=\"mr-1 size-4\" />\n          {t('admin.canary.configure')}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-96\" align=\"end\">\n        <div className=\"flex flex-col gap-3\">\n          <div className=\"space-y-1\">\n            <Label>{t('admin.canary.spaceIds')}</Label>\n            <div className=\"text-xs text-muted-foreground\">\n              {t('admin.canary.spaceIdsDescription')}\n            </div>\n          </div>\n          <Textarea\n            value={value}\n            onChange={(e) => setValue(e.target.value)}\n            placeholder={t('admin.canary.spaceIdsPlaceholder')}\n            className=\"min-h-[100px] font-mono text-sm\"\n          />\n          {/* Preview section */}\n          <div className=\"space-y-1\">\n            <Label className=\"text-xs\">\n              {t('admin.canary.preview' as never, { count: parsedSpaceIds.length })}\n            </Label>\n            <div className=\"max-h-[120px] overflow-y-auto rounded-md border bg-muted/50 p-2\">\n              {parsedSpaceIds.length > 0 ? (\n                <div className=\"flex flex-wrap gap-1\">\n                  {parsedSpaceIds.map((id, index) => (\n                    <span\n                      key={`${id}-${index}`}\n                      className=\"inline-flex items-center rounded bg-primary/10 px-2 py-0.5 font-mono text-xs text-primary\"\n                    >\n                      {id}\n                    </span>\n                  ))}\n                </div>\n              ) : (\n                <span className=\"text-xs text-muted-foreground\">\n                  {t('admin.canary.noSpaceIds' as never)}\n                </span>\n              )}\n            </div>\n          </div>\n          <div className=\"flex justify-end gap-2\">\n            <Button variant=\"outline\" size=\"sm\" onClick={() => setOpen(false)}>\n              {t('actions.cancel')}\n            </Button>\n            <Button size=\"sm\" onClick={handleSave}>\n              {t('actions.save')}\n            </Button>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/canary/index.ts",
    "content": "export * from './CanarySettings';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/index.ts",
    "content": "export * from './CopyInstance';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/mail-config/MailConfig.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type {\n  IMailTransportConfig,\n  SettingKey,\n  ISetSettingMailTransportConfigRo,\n} from '@teable/openapi';\nimport { mailTransportConfigSchema, setSettingMailTransportConfig } from '@teable/openapi';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  DialogFooter,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { PencilIcon, PlusIcon } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { MailConfigForm } from './MailConfigForm';\n\nexport const MailConfigDialog = (props: {\n  name: SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG | SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG;\n  emailConfig?: IMailTransportConfig;\n}) => {\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n\n  const [open, setOpen] = useState(false);\n  const [emailConfig, setEmailConfig] = useState<IMailTransportConfig | undefined>(\n    props.emailConfig\n  );\n\n  useEffect(() => {\n    setEmailConfig(props.emailConfig);\n  }, [props.emailConfig]);\n\n  const { mutateAsync: updateEmailConfig } = useMutation({\n    mutationFn: (ro: ISetSettingMailTransportConfigRo) => setSettingMailTransportConfig(ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['setting'] });\n    },\n  });\n\n  const cancel = () => {\n    setOpen(false);\n  };\n\n  const save = async () => {\n    if (emailConfig && mailTransportConfigSchema.safeParse(emailConfig).success) {\n      await updateEmailConfig({\n        name: props.name,\n        transportConfig: emailConfig,\n      });\n      setOpen(false);\n    } else {\n      toast.error(t('email.configError'));\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" size=\"icon\">\n          {props.emailConfig ? <PencilIcon className=\"size-4\" /> : <PlusIcon className=\"size-4\" />}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader className=\"px-1\">\n          <DialogTitle>{t('email.config')}</DialogTitle>\n        </DialogHeader>\n        <div className=\"max-h-[60vh] overflow-y-auto overflow-x-hidden p-1\">\n          <MailConfigForm value={emailConfig} onChange={setEmailConfig} />\n        </div>\n        <DialogFooter className=\"flex justify-end px-1\">\n          <Button variant=\"secondary\" onClick={cancel}>\n            {t('actions.cancel')}\n          </Button>\n          <Button variant=\"default\" onClick={save}>\n            {t('actions.save')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/mail-config/MailConfigForm.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useMutation } from '@tanstack/react-query';\nimport type { IMailTransportConfig, ITestMailTransportConfigRo } from '@teable/openapi';\nimport { mailTransportConfigSchema, testMailTransportConfig, z } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  FormField,\n  FormItem,\n  FormLabel,\n  FormControl,\n  Switch,\n  Form,\n  Input,\n  Button,\n  FormDescription,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { SendIcon } from 'lucide-react';\nimport { useEffect, useState, useMemo } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useTranslation } from 'react-i18next';\n\nexport const MailConfigForm = (props: {\n  value?: IMailTransportConfig;\n  onChange: (value?: IMailTransportConfig) => void;\n}) => {\n  const { t } = useTranslation('common');\n  const { onChange } = props;\n  const defaultValues = useMemo(\n    () =>\n      props.value ?? {\n        senderName: '',\n        sender: '',\n        host: '',\n        port: 0,\n        secure: false,\n        auth: {\n          user: '',\n          pass: '',\n        },\n      },\n    [props.value]\n  );\n\n  const form = useForm<IMailTransportConfig>({\n    resolver: zodResolver(mailTransportConfigSchema),\n    defaultValues: defaultValues,\n  });\n  const { reset } = form;\n\n  useEffect(() => {\n    reset(defaultValues);\n  }, [defaultValues, reset]);\n\n  const [testEmail, setTestEmail] = useState<string | null>(null);\n\n  const { mutateAsync: testEmailConfig, isPending: isTestEmailLoading } = useMutation({\n    mutationFn: (ro: ITestMailTransportConfigRo) => testMailTransportConfig(ro),\n    onSuccess: () => {\n      toast.success(t('email.testEmailSend'));\n    },\n  });\n\n  const testEmailSend = async () => {\n    if (!testEmail) {\n      return;\n    }\n\n    const transporter = form.getValues();\n    const checkTransporter = mailTransportConfigSchema.safeParse(transporter);\n    if (!checkTransporter.success) {\n      toast.error(t('email.testEmailError'));\n      return;\n    }\n\n    const checkTestEmail = z.string().email().safeParse(testEmail);\n    if (!checkTestEmail.success) {\n      toast.error(t('email.configError'));\n      return;\n    }\n    await testEmailConfig({\n      to: testEmail,\n      transportConfig: transporter,\n    });\n  };\n\n  const onSubmit = () => {\n    onChange(form.getValues());\n  };\n\n  return (\n    <Form {...form}>\n      <FormField\n        control={form.control}\n        name=\"host\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <div>\n              <FormLabel className=\"text-sm font-medium\">{t('email.host')}</FormLabel>\n              <FormDescription className=\"text-sm text-muted-foreground\">\n                {t('email.hostDescription')}\n              </FormDescription>\n            </div>\n\n            <FormControl>\n              <Input\n                value={field.value}\n                onChange={(e) => {\n                  field.onChange(e.target.value);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n\n      <FormField\n        control={form.control}\n        name=\"port\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.port')}</FormLabel>\n            <FormControl>\n              <Input\n                type=\"number\"\n                value={field.value || undefined}\n                onChange={(e) => {\n                  field.onChange(Number(e.target.value));\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n\n      <FormField\n        control={form.control}\n        name=\"secure\"\n        render={({ field }) => (\n          <FormItem className=\"flex items-center justify-between space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.secure')}</FormLabel>\n            <FormControl>\n              <Switch\n                checked={field.value}\n                onCheckedChange={(checked) => {\n                  field.onChange(checked);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n\n      <FormField\n        control={form.control}\n        name=\"auth.user\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.username')}</FormLabel>\n            <FormControl>\n              <Input\n                value={field.value}\n                onChange={(e) => {\n                  field.onChange(e.target.value);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n      <FormField\n        control={form.control}\n        name=\"auth.pass\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.password')}</FormLabel>\n            <FormControl>\n              <Input\n                type=\"password\"\n                value={field.value}\n                onChange={(e) => {\n                  field.onChange(e.target.value);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n      <FormField\n        control={form.control}\n        name=\"sender\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.sender')}</FormLabel>\n            <FormControl>\n              <Input\n                value={field.value}\n                onChange={(e) => {\n                  field.onChange(e.target.value);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n      <FormField\n        control={form.control}\n        name=\"senderName\"\n        render={({ field }) => (\n          <FormItem className=\"space-y-2\">\n            <FormLabel className=\"text-sm font-medium\">{t('email.senderName')}</FormLabel>\n            <FormControl>\n              <Input\n                value={field.value}\n                onChange={(e) => {\n                  field.onChange(e.target.value);\n                  onSubmit();\n                }}\n              />\n            </FormControl>\n          </FormItem>\n        )}\n      />\n      <div className=\"mt-2 flex items-center gap-2\">\n        <Input\n          className=\"flex-1\"\n          type=\"email\"\n          value={testEmail ?? ''}\n          onChange={(e) => setTestEmail(e.target.value)}\n          placeholder={t('email.testEmailPlaceholder')}\n        />\n        <Button\n          variant=\"outline\"\n          onClick={testEmailSend}\n          disabled={!testEmail || isTestEmailLoading}\n        >\n          {isTestEmailLoading ? <Spin className=\"size-4\" /> : <SendIcon className=\"size-4\" />}\n          {t('email.send')}\n        </Button>\n      </div>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/waitlist/InviteCodeManage.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { IWaitlistInviteCodeVo } from '@teable/openapi';\nimport { genWaitlistInviteCode } from '@teable/openapi';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableRow,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Button,\n  TableHead,\n  TableHeader,\n  Input,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { PencilIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\nconst CodeTable = (props: { list: IWaitlistInviteCodeVo }) => {\n  const { t } = useTranslation('common');\n  const { list } = props;\n  return (\n    <div className=\"w-full \">\n      <div className=\"mt-4 max-h-[420px] overflow-y-auto overflow-x-hidden rounded-md border \">\n        <Table className=\"relative scroll-smooth\">\n          <TableHeader className=\"sticky top-0 z-10 bg-background\">\n            <TableRow className=\"h-8 text-sm font-semibold \">\n              <TableHead className=\"text-center\">{t('waitlist.code')}</TableHead>\n              <TableHead className=\"text-center\">{t('waitlist.times')}</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {list.map((item) => {\n              const { code, times } = item;\n              return (\n                <TableRow key={code} className=\"h-8\">\n                  <TableCell className=\"text-center\">{code}</TableCell>\n                  <TableCell className=\"text-center\">{times}</TableCell>\n                </TableRow>\n              );\n            })}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n};\n\nexport const InviteCodeManage = () => {\n  const { t } = useTranslation('common');\n  const [count, setCount] = useState(10);\n  const [times, setTimes] = useState(10);\n  const [list, setList] = useState<IWaitlistInviteCodeVo>([]);\n  const { mutateAsync: genWaitlistInviteCodeMutation } = useMutation({\n    mutationFn: () => genWaitlistInviteCode({ count, times }),\n    onSuccess: ({ data }) => {\n      setList(data);\n    },\n  });\n  const [open, setOpen] = useState(false);\n\n  const cancel = () => {\n    setList([]);\n    setOpen(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" size=\"icon\" onClick={() => setOpen(true)}>\n          <PencilIcon className=\"size-4\" />\n        </Button>\n      </DialogTrigger>\n      <DialogContent onInteractOutside={(e) => e.preventDefault()}>\n        <DialogHeader>\n          <DialogTitle>{t('waitlist.generateCode')}</DialogTitle>\n        </DialogHeader>\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"grid grid-cols-2 gap-4\">\n            <div className=\"flex items-center gap-2\">\n              <Label>{t('waitlist.count')}</Label>\n              <Input\n                className=\"flex-1\"\n                type=\"number\"\n                value={count}\n                onChange={(e) => setCount(Number(e.target.value))}\n              />\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Label>{t('waitlist.times')}</Label>\n              <Input\n                type=\"number\"\n                className=\"flex-1\"\n                value={times}\n                onChange={(e) => setTimes(Number(e.target.value))}\n              />\n            </div>\n          </div>\n          {list.length > 0 && <CodeTable list={list} />}\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={cancel}>\n            {t('actions.cancel')}\n          </Button>\n          <Button onClick={() => genWaitlistInviteCodeMutation()}>{t('waitlist.generate')}</Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/components/waitlist/WaitlistManage.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IInviteWaitlistRo } from '@teable/openapi';\nimport { inviteWaitlist, getWaitlist } from '@teable/openapi';\nimport {\n  Input,\n  Table,\n  TableBody,\n  TableCell,\n  TableRow,\n  Checkbox,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Button,\n  TableHead,\n  TableHeader,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport dayjs from 'dayjs';\nimport { keyBy } from 'lodash';\nimport { SettingsIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\n\ninterface TableRow {\n  email: string;\n  invite: string;\n  inviteTime: string;\n  createdTime: string;\n}\n\ninterface IWaitlistTableProps {\n  list: TableRow[];\n  onSelect: (selected: string[]) => void;\n}\n\nconst WaitlistTable = (props: IWaitlistTableProps) => {\n  const { t } = useTranslation('common');\n  const { list, onSelect } = props;\n  const [search, setSearch] = useState('');\n  const [selected, setSelected] = useState<Set<string>>(new Set());\n\n  const filteredList = useMemo(() => {\n    return list.filter((item) => item.email.toLowerCase().includes(search.toLowerCase()));\n  }, [list, search]);\n\n  const handleSelect = (email: string) => {\n    setSelected((prev) => {\n      if (prev.has(email)) {\n        prev.delete(email);\n      } else {\n        prev.add(email);\n      }\n      return new Set(prev);\n    });\n    onSelect(Array.from(selected));\n  };\n\n  const handleSelectAll = () => {\n    setSelected((prev) => {\n      if (prev.size === filteredList.length) {\n        return new Set();\n      }\n      return new Set(filteredList.map((item) => item.email));\n    });\n    onSelect(Array.from(selected));\n  };\n\n  return (\n    <div className=\"w-full \">\n      <div className=\"flex items-center\">\n        <Input\n          placeholder={t('actions.search')}\n          value={search}\n          onChange={(event) => setSearch(event.target.value)}\n        />\n      </div>\n      <div className=\"mt-4 max-h-[400px] overflow-y-auto overflow-x-hidden rounded-md border \">\n        <Table className=\"relative scroll-smooth\">\n          <TableHeader className=\"sticky top-0 z-10 bg-background\">\n            <TableRow className=\"h-8 text-sm font-semibold \">\n              <TableCell className=\"text-center\">\n                <Checkbox\n                  checked={\n                    (selected.size === filteredList.length && selected.size > 0) ||\n                    (selected.size > 0 && 'indeterminate')\n                  }\n                  onCheckedChange={() => handleSelectAll()}\n                />\n              </TableCell>\n              <TableHead className=\"text-center\">{t('waitlist.email')}</TableHead>\n              <TableHead className=\"text-center\">{t('waitlist.invite')}</TableHead>\n              <TableHead className=\"text-center\">{t('waitlist.inviteTime')}</TableHead>\n              <TableHead className=\"text-center\">{t('waitlist.createdTime')}</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {filteredList.map((item) => {\n              const { email, invite, inviteTime, createdTime } = item;\n              return (\n                <TableRow\n                  key={email}\n                  className=\"h-8 cursor-pointer\"\n                  onClick={() => handleSelect(email)}\n                >\n                  <TableCell className=\"text-center\">\n                    <Checkbox checked={selected.has(email)} />\n                  </TableCell>\n                  <TableCell className=\"text-center\">{email}</TableCell>\n                  <TableCell className=\"text-center\">{invite}</TableCell>\n                  <TableCell className=\"text-center\">{inviteTime}</TableCell>\n                  <TableCell className=\"text-center\">{createdTime}</TableCell>\n                </TableRow>\n              );\n            })}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n};\n\nexport const WaitlistManage = () => {\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { data: list = [] } = useQuery({\n    queryKey: ['waitlist'],\n    queryFn: () => getWaitlist().then(({ data }) => data),\n  });\n  const inviteMap = keyBy(list, 'email');\n  const [selectedEmails, setSelectedEmails] = useState<string[]>([]);\n  const [open, setOpen] = useState(false);\n\n  const { mutateAsync: inviteWaitlistMutation } = useMutation({\n    mutationFn: (ro: IInviteWaitlistRo) => inviteWaitlist(ro),\n    onSuccess: ({ data }) => {\n      if (data.length > 0) {\n        toast.success(t('waitlist.inviteSuccess'));\n      }\n      queryClient.invalidateQueries({ queryKey: ['waitlist'] });\n    },\n  });\n\n  const canInvite = useMemo(() => {\n    return selectedEmails.some((email) => !inviteMap[email]?.invite);\n  }, [selectedEmails, inviteMap]);\n\n  const handleInvite = async () => {\n    await inviteWaitlistMutation({\n      list: selectedEmails,\n    });\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" size=\"icon\">\n          <SettingsIcon className=\"size-4\" />\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\" max-w-3xl\" onInteractOutside={(e) => e.preventDefault()}>\n        <DialogHeader>\n          <DialogTitle>{t('waitlist.title')}</DialogTitle>\n        </DialogHeader>\n        <WaitlistTable\n          list={list.map((item) => ({\n            email: item.email,\n            invite: item.invite ? t('waitlist.yes') : t('waitlist.no'),\n            inviteTime: item.inviteTime ? dayjs(item.inviteTime).format('YYYY-MM-DD ') : '',\n            createdTime: item.createdTime ? dayjs(item.createdTime).format('YYYY-MM-DD') : '',\n          }))}\n          onSelect={setSelectedEmails}\n        />\n        <DialogFooter>\n          <Button variant=\"secondary\" onClick={() => setOpen(false)}>\n            {t('actions.close')}\n          </Button>\n          <Button onClick={handleInvite} disabled={!canInvite}>\n            {t('waitlist.invite')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/index.ts",
    "content": "export * from './SettingPage';\nexport * from './components';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/setting/utils.ts",
    "content": "export const scrollToTarget = (targetElement: HTMLElement) => {\n  const leftScrollContainer = document.querySelector('.setting-page-left-container') as HTMLElement;\n  if (leftScrollContainer) {\n    const containerRect = leftScrollContainer.getBoundingClientRect();\n    const targetRect = targetElement.getBoundingClientRect();\n    const scrollTop = leftScrollContainer.scrollTop + (targetRect.top - containerRect.top);\n\n    leftScrollContainer.scrollTo({\n      top: scrollTop,\n      behavior: 'smooth',\n    });\n  } else {\n    targetElement?.scrollIntoView({ behavior: 'smooth' });\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/TemplatePage.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport { TemplateTable } from './components';\n\nexport const TemplatePage = () => {\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"flex size-full flex-col overflow-auto px-8 py-6\">\n      <div className=\"flex items-center justify-between p-2\">\n        <div className=\"text-2xl font-semibold\">{t('settings.templateAdmin.title')}</div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <TemplateTable />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/BaseSelectPanel.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Database } from '@teable/icons';\nimport type { IGetBaseVo, IGetSpaceVo } from '@teable/openapi';\nimport { updateTemplate } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Button,\n  Input,\n  cn,\n} from '@teable/ui-lib';\nimport { groupBy, keyBy, mapValues } from 'lodash';\nimport { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\ninterface IBaseSelectPanelProps {\n  baseId?: string;\n  baseList: IGetBaseVo[];\n  templateId: string;\n  spaceList: IGetSpaceVo[];\n  disabled?: boolean;\n}\n\nexport const BaseSelectPanel = (props: IBaseSelectPanelProps) => {\n  const { baseId, baseList, templateId, spaceList, disabled } = props;\n  const { t } = useTranslation('common');\n  const [open, setOpen] = useState(false);\n\n  const queryClient = useQueryClient();\n\n  const spaceId2NameMap = mapValues(keyBy(spaceList, 'id'), 'name');\n\n  const groupedBaseListMap = groupBy(baseList, 'spaceId');\n\n  const groupedBaseList = Object.values(\n    mapValues(groupedBaseListMap, (bases, spaceId) => {\n      return {\n        spaceId: spaceId,\n        spaceName: spaceId2NameMap[spaceId],\n        bases: bases,\n      };\n    })\n  );\n\n  const { mutateAsync: updateTemplateFn } = useMutation({\n    mutationFn: (baseId: string) => updateTemplate(templateId, { baseId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n      setOpen(false);\n    },\n  });\n\n  const baseName = useMemo(() => {\n    return baseId\n      ? baseList.find((base) => base.id === baseId)?.name\n      : t('settings.templateAdmin.baseSelectPanel.selectBase');\n  }, [baseId, baseList, t]);\n\n  const [search, setSearch] = useState('');\n\n  const filteredGroupedBaseList = useMemo(() => {\n    return (\n      groupedBaseList\n        .map((group) => {\n          const { bases } = group;\n          return {\n            ...group,\n            bases: search\n              ? bases.filter((base) => base.name.toLowerCase().includes(search.toLowerCase()))\n              : bases,\n          };\n        })\n        // the spaces has been deleted\n        .filter((group) => group.spaceName)\n        .filter((group) => group.bases.length > 0)\n    );\n  }, [groupedBaseList, search]);\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button\n          variant=\"outline\"\n          size={'xs'}\n          className={cn('w-32 overflow-hidden truncate', {\n            'border-red-500': !baseName,\n          })}\n          disabled={disabled}\n        >\n          <span\n            className={cn('truncate', {\n              'text-red-500': !baseName,\n            })}\n            title={baseName ?? t('settings.templateAdmin.baseSelectPanel.abnormalBase')}\n          >\n            {baseName ?? t('settings.templateAdmin.baseSelectPanel.abnormalBase')}\n          </span>\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"flex h-[550px] min-w-[750px] flex-col\">\n        <DialogHeader>\n          <DialogTitle>{t('settings.templateAdmin.baseSelectPanel.title')}</DialogTitle>\n          <DialogDescription>\n            {t('settings.templateAdmin.baseSelectPanel.description')}\n          </DialogDescription>\n        </DialogHeader>\n        <Input\n          placeholder={t('settings.templateAdmin.baseSelectPanel.search')}\n          value={search}\n          onChange={(e) => setSearch(e.target.value)}\n        />\n        <div className=\"w-full flex-1 flex-col overflow-y-auto\">\n          <div className=\"flex w-full flex-col gap-2\">\n            {filteredGroupedBaseList.map((group) => (\n              <div key={group.spaceId} className=\"flex w-full flex-col gap-2\">\n                <div className=\"text-md font-medium\">{group.spaceName}</div>\n                <div className=\"grid w-full grid-cols-4 gap-2\">\n                  {group.bases.map((base) => (\n                    <Button\n                      key={base.id}\n                      variant={'ghost'}\n                      className={cn('truncate w-full flex overflow-hidden gap-1', {\n                        'bg-secondary': baseId === base.id,\n                      })}\n                      onClick={() => updateTemplateFn(base.id)}\n                    >\n                      <span className=\"shrink-0\">{base.icon ?? <Database />}</span>\n                      <span className=\"truncate\" title={base.name}>\n                        {base.name}\n                      </span>\n                    </Button>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/CategorySettingDialog.tsx",
    "content": "import type { DropResult } from '@hello-pangea/dnd';\nimport { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { DraggableHandle, Settings, Plus, Edit, Trash } from '@teable/icons';\nimport type { ITemplateCategoryListVo } from '@teable/openapi';\nimport {\n  createTemplateCategory,\n  deleteTemplateCategory,\n  getTemplateCategoryList,\n  updateTemplateCategory,\n  updateTemplateCategoryOrder,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useEffect, useRef } from 'react';\n\nconst CategoryDraggableItem = ({\n  category,\n  isDragging,\n  isClone,\n  onEdit,\n  onDelete,\n}: {\n  category: ITemplateCategoryListVo;\n  isDragging?: boolean;\n  isClone?: boolean;\n  onEdit?: (id: string, name: string) => void;\n  onDelete?: (id: string) => void;\n}) => {\n  const [isEditing, setIsEditing] = useState(false);\n  const [name, setName] = useState(category.name);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const handleSave = () => {\n    if (name && name !== category.name) {\n      onEdit?.(category.id, name);\n    }\n    setIsEditing(false);\n  };\n\n  return (\n    <div\n      className={cn('group flex items-center gap-2 rounded-md border bg-background px-3 py-2', {\n        'opacity-50': isDragging,\n      })}\n    >\n      <DraggableHandle className=\"size-4 shrink-0 cursor-grab text-gray-400 active:cursor-grabbing\" />\n      {isEditing && !isClone ? (\n        <Input\n          className=\"h-6 flex-1 py-0\"\n          value={name}\n          ref={inputRef}\n          onChange={(e) => setName(e.target.value)}\n          onBlur={handleSave}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              handleSave();\n            }\n            if (e.key === 'Escape') {\n              setName(category.name);\n              setIsEditing(false);\n            }\n          }}\n        />\n      ) : (\n        <>\n          <span className=\"flex h-6 flex-1 items-center truncate text-sm\">{category.name}</span>\n          {!isClone && (\n            <div className=\"flex h-6 shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100\">\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                className=\"size-6 p-0\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  setIsEditing(true);\n                  setTimeout(() => inputRef.current?.focus(), 0);\n                }}\n              >\n                <Edit className=\"size-3\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                className=\"size-6 p-0 text-red-500 hover:text-red-600\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onDelete?.(category.id);\n                }}\n              >\n                <Trash className=\"size-3\" />\n              </Button>\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n};\n\nexport const CategorySettingDialog = ({ children }: { children?: React.ReactNode }) => {\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const [open, setOpen] = useState(false);\n  const [newCategoryName, setNewCategoryName] = useState('');\n\n  const { data: categoryList } = useQuery({\n    queryKey: ReactQueryKeys.templateCategoryList(),\n    queryFn: () => getTemplateCategoryList().then((data) => data.data),\n  });\n\n  const [innerCategories, setInnerCategories] = useState<ITemplateCategoryListVo[]>([]);\n\n  useEffect(() => {\n    setInnerCategories(categoryList ?? []);\n  }, [categoryList]);\n\n  const { mutate: createCategoryFn } = useMutation({\n    mutationFn: (name: string) => createTemplateCategory({ name }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n      setNewCategoryName('');\n    },\n  });\n\n  const { mutate: updateCategoryFn } = useMutation({\n    mutationFn: ({ id, name }: { id: string; name: string }) =>\n      updateTemplateCategory(id, { name }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n    },\n  });\n\n  const { mutate: deleteCategoryFn } = useMutation({\n    mutationFn: (id: string) => deleteTemplateCategory(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n    },\n  });\n\n  const { mutateAsync: updateCategoryOrderFn } = useMutation({\n    mutationFn: ({\n      templateCategoryId,\n      anchorId,\n      position,\n    }: {\n      templateCategoryId: string;\n      anchorId: string;\n      position: 'before' | 'after';\n    }) => updateTemplateCategoryOrder({ templateCategoryId, anchorId, position }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n    },\n  });\n\n  const handleAddCategory = () => {\n    const trimmedName = newCategoryName.trim();\n    if (!trimmedName) {\n      toast.warning(t('settings.templateAdmin.tips.addCategoryTips'));\n      return;\n    }\n    const isDuplicate = innerCategories.some(\n      (category) => category.name.toLowerCase() === trimmedName.toLowerCase()\n    );\n    if (isDuplicate) {\n      toast.warning(t('settings.templateAdmin.tips.duplicateCategoryName'));\n      return;\n    }\n    createCategoryFn(trimmedName);\n  };\n\n  const onDragEnd = async (result: DropResult) => {\n    const { source, destination } = result;\n\n    if (!destination || source.index === destination.index) {\n      return;\n    }\n\n    const list = [...innerCategories];\n    const [category] = list.splice(source.index, 1);\n    list.splice(destination.index, 0, category);\n    setInnerCategories(list);\n\n    const categoryIndex = list.findIndex((v) => v.id === category.id);\n    if (categoryIndex === 0) {\n      await updateCategoryOrderFn({\n        templateCategoryId: category.id,\n        anchorId: list[1].id,\n        position: 'before',\n      });\n    } else {\n      await updateCategoryOrderFn({\n        templateCategoryId: category.id,\n        anchorId: list[categoryIndex - 1].id,\n        position: 'after',\n      });\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        {children ?? (\n          <Button size=\"icon-xs\" variant=\"outline\" className=\"ml-1 size-6 p-0 hover:bg-accent\">\n            <Settings className=\"size-4 shrink-0\" />\n          </Button>\n        )}\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('settings.templateAdmin.actions.manageCategory')}</DialogTitle>\n        </DialogHeader>\n\n        {/* Add new category */}\n        <div className=\"flex items-center gap-2\">\n          <Input\n            className=\"flex-1\"\n            placeholder={t('settings.templateAdmin.tips.categoryNamePlaceholder')}\n            value={newCategoryName}\n            onChange={(e) => setNewCategoryName(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') {\n                handleAddCategory();\n              }\n            }}\n          />\n          <Button size=\"icon-sm\" onClick={handleAddCategory}>\n            <Plus className=\"size-4 shrink-0\" />\n          </Button>\n        </div>\n\n        <div className=\"max-h-[400px] overflow-y-auto\">\n          {innerCategories.length > 0 ? (\n            <DragDropContext onDragEnd={onDragEnd}>\n              <Droppable\n                droppableId=\"category-sort-list\"\n                renderClone={(provided, snapshot, rubric) => (\n                  <div\n                    ref={provided.innerRef}\n                    {...provided.draggableProps}\n                    {...provided.dragHandleProps}\n                  >\n                    <CategoryDraggableItem\n                      category={innerCategories[rubric.source.index]}\n                      isDragging={snapshot.isDragging}\n                      isClone\n                    />\n                  </div>\n                )}\n              >\n                {(droppableProvided) => (\n                  <div\n                    {...droppableProvided.droppableProps}\n                    ref={droppableProvided.innerRef}\n                    className=\"space-y-2\"\n                  >\n                    {innerCategories.map((category, index) => (\n                      <Draggable key={category.id} draggableId={category.id} index={index}>\n                        {(draggableProvided, draggableSnapshot) => (\n                          <div\n                            ref={draggableProvided.innerRef}\n                            {...draggableProvided.draggableProps}\n                            {...draggableProvided.dragHandleProps}\n                          >\n                            <CategoryDraggableItem\n                              category={category}\n                              isDragging={draggableSnapshot.isDragging}\n                              onEdit={(id, name) => updateCategoryFn({ id, name })}\n                              onDelete={deleteCategoryFn}\n                            />\n                          </div>\n                        )}\n                      </Draggable>\n                    ))}\n                    {droppableProvided.placeholder}\n                  </div>\n                )}\n              </Droppable>\n            </DragDropContext>\n          ) : (\n            <div className=\"py-8 text-center text-sm text-muted-foreground\">\n              {t('settings.templateAdmin.noData')}\n            </div>\n          )}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/MarkdownEditor.tsx",
    "content": "import { Edit } from '@teable/icons';\nimport { MarkDownEditor as MarkdownEditorComponent, MarkdownPreview } from '@teable/sdk';\nimport {\n  cn,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Button,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\ninterface IMarkdownEditorProps {\n  value?: string;\n  onChange: (value: string) => void;\n}\n\nexport const MarkdownEditor = ({ value, onChange }: IMarkdownEditorProps) => {\n  const { t } = useTranslation('common');\n  const [innerValue, setInnerValue] = useState(value || '');\n  const [isEditing, setIsEditing] = useState(false);\n\n  return (\n    <div className=\"flex w-full items-center gap-2 overflow-hidden\">\n      <div\n        className={cn('overflow-auto', {\n          'flex-1': value || value === '0',\n        })}\n      >\n        {value || value === '0' ? (\n          <MarkdownPreview className=\"max-h-40 overflow-auto\">{value}</MarkdownPreview>\n        ) : (\n          <span className=\"px-3 py-2 text-sm text-gray-500\">{t('noDescription')}</span>\n        )}\n      </div>\n\n      <Dialog open={isEditing} onOpenChange={setIsEditing}>\n        <DialogTrigger>\n          <Edit\n            className=\"size-3 shrink-0 cursor-pointer\"\n            onClick={() => {\n              setInnerValue(value || '');\n            }}\n          />\n        </DialogTrigger>\n        <DialogContent className=\"flex max-h-[80%] min-h-[80%] max-w-[70%] flex-col overflow-hidden\">\n          <DialogHeader>\n            <DialogTitle>{t('actions.edit')}</DialogTitle>\n            <DialogDescription>\n              {t('settings.templateAdmin.header.markdownDescription')}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"flex flex-1 gap-4 overflow-hidden\">\n            <MarkdownEditorComponent\n              value={innerValue}\n              onChange={(value) => {\n                setInnerValue(value);\n              }}\n              autoFocusLastNode\n            />\n          </div>\n\n          <DialogFooter>\n            <Button\n              onClick={() => {\n                onChange(innerValue);\n                setIsEditing(false);\n              }}\n            >\n              {t('actions.save')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/MarkdownPreviewButton.tsx",
    "content": "import { Eye, Edit } from '@teable/icons';\nimport { MarkDownEditor as MarkdownEditorComponent, MarkdownPreview } from '@teable/sdk';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Button,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\ninterface IMarkdownPreviewButtonProps {\n  value?: string;\n  onChange: (value: string) => void;\n}\n\nexport const MarkdownPreviewButton = ({ value, onChange }: IMarkdownPreviewButtonProps) => {\n  const { t } = useTranslation('common');\n  const [innerValue, setInnerValue] = useState(value || '');\n  const [isOpen, setIsOpen] = useState(false);\n  const [activeTab, setActiveTab] = useState<'preview' | 'edit'>('preview');\n\n  const handleOpen = () => {\n    setInnerValue(value || '');\n    setActiveTab(value ? 'preview' : 'edit');\n    setIsOpen(true);\n  };\n\n  const handleSave = () => {\n    onChange(innerValue);\n    setIsOpen(false);\n  };\n\n  return (\n    <>\n      <Button variant=\"outline\" size=\"sm\" onClick={handleOpen} className=\"gap-2\">\n        <Eye className=\"size-4\" />\n        {t('actions.view')}\n      </Button>\n\n      <Dialog open={isOpen} onOpenChange={setIsOpen}>\n        <DialogContent className=\"flex max-h-[80%] min-h-[80%] max-w-[70%] flex-col overflow-hidden\">\n          <DialogHeader>\n            <DialogTitle>{t('settings.templateAdmin.header.markdownDescription')}</DialogTitle>\n          </DialogHeader>\n\n          <Tabs\n            value={activeTab}\n            onValueChange={(v) => setActiveTab(v as 'preview' | 'edit')}\n            className=\"flex flex-1 flex-col overflow-hidden\"\n          >\n            <TabsList className=\"w-fit\">\n              <TabsTrigger value=\"preview\" className=\"gap-2\">\n                <Eye className=\"size-4\" />\n                {t('actions.preview')}\n              </TabsTrigger>\n              <TabsTrigger value=\"edit\" className=\"gap-2\">\n                <Edit className=\"size-4\" />\n                {t('actions.edit')}\n              </TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"preview\" className=\"flex-1 overflow-auto\">\n              {innerValue ? (\n                <MarkdownPreview className=\"overflow-auto p-4\">{innerValue}</MarkdownPreview>\n              ) : (\n                <div className=\"flex items-center justify-center p-8 text-gray-500\">\n                  {t('noDescription')}\n                </div>\n              )}\n            </TabsContent>\n\n            <TabsContent value=\"edit\" className=\"flex flex-1 overflow-hidden\">\n              <MarkdownEditorComponent\n                value={innerValue}\n                onChange={(value) => {\n                  setInnerValue(value);\n                }}\n                autoFocusLastNode\n              />\n            </TabsContent>\n          </Tabs>\n\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => setIsOpen(false)}>\n              {t('actions.cancel')}\n            </Button>\n            <Button onClick={handleSave}>{t('actions.save')}</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TemplateCategorySelect.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Check, ChevronsUpDown, Plus, Trash, Edit } from '@teable/icons';\nimport type { ITemplateCategoryListVo, IUpdateTemplateRo } from '@teable/openapi';\nimport {\n  createTemplateCategory,\n  deleteTemplateCategory,\n  getTemplateCategoryList,\n  updateTemplate,\n  updateTemplateCategory,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Command,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Button,\n  cn,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useRef, useState } from 'react';\n\ninterface ITemplateCategorySelectProps {\n  templateId: string;\n  value?: string[];\n  onChange: (ids: string[]) => void;\n}\n\ninterface ICategoryCommandItemProps {\n  selectedIds?: string[];\n  onToggle: (id: string) => void;\n  templateCategory: ITemplateCategoryListVo;\n}\n\nconst CategoryCommandItem = (props: ICategoryCommandItemProps) => {\n  const { selectedIds, onToggle, templateCategory } = props;\n  const [isEditing, setIsEditing] = useState(false);\n  const queryClient = useQueryClient();\n  const { mutate: deleteTemplateCategoryFn } = useMutation({\n    mutationFn: (id: string) => deleteTemplateCategory(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n    },\n  });\n  const { mutate: updateTemplateCategoryFn } = useMutation({\n    mutationFn: (id: string) => updateTemplateCategory(id, { name }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n      setIsEditing(false);\n    },\n  });\n  const [name, setName] = useState(templateCategory.name);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const isSelected = selectedIds?.includes(templateCategory.id);\n\n  return (\n    <CommandItem\n      key={templateCategory.id}\n      value={templateCategory.name}\n      onSelect={() => {\n        onToggle(templateCategory.id);\n      }}\n      className=\"flex h-8 items-center justify-between gap-1\"\n    >\n      {isEditing ? (\n        <Input\n          className=\"h-6\"\n          value={name}\n          ref={inputRef}\n          onChange={(e) => {\n            setName(e.target.value);\n          }}\n          onBlur={(e) => {\n            e.stopPropagation();\n            if (name) {\n              updateTemplateCategoryFn(templateCategory.id);\n            }\n          }}\n          onKeyDown={(e) => {\n            e.stopPropagation();\n            if (e.key === 'Enter' && name) {\n              updateTemplateCategoryFn(templateCategory.id);\n            }\n\n            if (e.key === 'Escape') {\n              setIsEditing(false);\n            }\n          }}\n        />\n      ) : (\n        <div className=\"group flex size-full items-center justify-between gap-1\">\n          <span className=\"flex-1 truncate\">{templateCategory.name}</span>\n          <Button\n            variant=\"outline\"\n            size={'xs'}\n            className=\"hidden h-full group-hover:block\"\n            onClick={(e) => {\n              e.stopPropagation();\n              setIsEditing(true);\n              setTimeout(() => {\n                inputRef.current?.focus();\n              }, 0);\n            }}\n          >\n            <Edit className=\"size-3\" />\n          </Button>\n          <Button\n            variant=\"outline\"\n            size={'xs'}\n            className=\"hidden h-full group-hover:block\"\n            onClick={(e) => {\n              e.stopPropagation();\n              deleteTemplateCategoryFn(templateCategory.id);\n            }}\n          >\n            <Trash className=\"size-3 text-red-500\" />\n          </Button>\n          <Check className={cn('shrink-0', isSelected ? 'block' : 'hidden')} />\n        </div>\n      )}\n    </CommandItem>\n  );\n};\n\nexport const TemplateCategorySelect = (props: ITemplateCategorySelectProps) => {\n  const { value = [], onChange, templateId } = props;\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { data: templateCategoryList } = useQuery({\n    queryKey: ReactQueryKeys.templateCategoryList(),\n    queryFn: () => getTemplateCategoryList().then((data) => data.data),\n  });\n\n  const { mutateAsync: updateTemplateFn } = useMutation({\n    mutationFn: ({ templateId, updateRo }: { templateId: string; updateRo: IUpdateTemplateRo }) =>\n      updateTemplate(templateId, { ...updateRo }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n    },\n  });\n\n  const { mutate: createTemplateCategoryFn } = useMutation({\n    mutationFn: (name: string) => createTemplateCategory({ name }),\n    onSuccess: (res) => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateCategoryList() });\n      setSearchValue('');\n      const newCategoryId = [...value, res.data.id];\n      updateTemplateFn({ templateId, updateRo: { categoryId: newCategoryId } });\n    },\n  });\n\n  const [open, setOpen] = useState(false);\n  const [searchValue, setSearchValue] = useState('');\n\n  const searchInList = useMemo(() => {\n    return Boolean(templateCategoryList?.find((tmp) => tmp.name === searchValue));\n  }, [templateCategoryList, searchValue]);\n\n  const selectedCategories = useMemo(() => {\n    return templateCategoryList?.filter((tmp) => value.includes(tmp.id)) || [];\n  }, [templateCategoryList, value]);\n\n  const handleToggleCategory = (categoryId: string) => {\n    const newValue = value.includes(categoryId)\n      ? value.filter((id) => id !== categoryId)\n      : [...value, categoryId];\n    onChange(newValue);\n  };\n\n  const displayText =\n    selectedCategories.length > 0\n      ? selectedCategories.map((cat) => cat.name).join(', ')\n      : t('settings.templateAdmin.actions.selectCategory');\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className=\"w-[200px] justify-between\"\n        >\n          <span className=\"truncate text-xs\">{displayText}</span>\n          <ChevronsUpDown className=\"opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[200px] p-0\">\n        <Command>\n          <CommandInput\n            value={searchValue}\n            placeholder={t('actions.search')}\n            onValueChange={(value) => setSearchValue(value)}\n          />\n          <CommandList className=\"w-full p-1\">\n            <CommandGroup className=\"p-0\">\n              {templateCategoryList?.map((tmp) => (\n                <CategoryCommandItem\n                  key={tmp.id}\n                  templateCategory={tmp}\n                  onToggle={handleToggleCategory}\n                  selectedIds={value}\n                />\n              ))}\n            </CommandGroup>\n\n            {!searchInList && (\n              <Button\n                className=\"mt-1 flex w-full justify-center gap-1\"\n                variant=\"ghost\"\n                size={'xs'}\n                onClick={() => {\n                  if (!searchValue) {\n                    toast.warning(t('settings.templateAdmin.tips.addCategoryTips'));\n                  } else {\n                    createTemplateCategoryFn(searchValue);\n                  }\n                }}\n              >\n                <Plus className=\"size-4 shrink-0\" />\n                <span className=\"truncate\" title={searchValue}>\n                  {t('settings.templateAdmin.actions.addCategory')}\n                  <span className=\"ml-2 text-sm text-gray-500\">\n                    {searchValue ? `\"${searchValue}\"` : ''}\n                  </span>\n                </span>\n              </Button>\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TemplateCover.tsx",
    "content": "import type { ITemplateCoverRo, ITemplateCoverVo } from '@teable/openapi';\nimport { useState } from 'react';\nimport { UploadPanel } from './upload-panel/UploadPanel';\n\ninterface ITemplateCoverProps {\n  cover?: ITemplateCoverVo;\n  onChange: (notify: ITemplateCoverRo | null) => void;\n}\n\nexport const TemplateCover = (props: ITemplateCoverProps) => {\n  const { onChange, cover } = props;\n\n  const [file, setFile] = useState<File | null>(null);\n  return (\n    <div className=\"size-16\">\n      {\n        <UploadPanel\n          cover={cover}\n          file={file}\n          onClose={() => {\n            setFile(null);\n            onChange(null);\n          }}\n          onFinished={(res) => {\n            onChange(res);\n          }}\n          onChange={(file) => {\n            setFile(file);\n          }}\n          accept=\"image/*\"\n        />\n      }\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TemplateTable.tsx",
    "content": "import type { DropResult } from '@hello-pangea/dnd';\nimport { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';\nimport { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { MoreHorizontal, Trash2, ArrowUp, DraggableHandle, Link } from '@teable/icons';\nimport type { ITemplateCoverRo, IUpdateTemplateRo } from '@teable/openapi';\nimport {\n  deleteTemplate,\n  getTemplateList,\n  pinTopTemplate,\n  updateTemplate,\n  updateTemplateOrder,\n} from '@teable/openapi';\nimport { ReactQueryKeys, useIsHydrated } from '@teable/sdk';\nimport {\n  Spin,\n  Button,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n  Switch,\n  Avatar,\n  AvatarImage,\n  AvatarFallback,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  TooltipPortal,\n  cn,\n} from '@teable/ui-lib';\nimport dayjs from 'dayjs';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState, useEffect } from 'react';\nimport { CategorySettingDialog } from './CategorySettingDialog';\nimport { MarkdownPreviewButton } from './MarkdownPreviewButton';\nimport { TemplateCategorySelect } from './TemplateCategorySelect';\nimport { TemplateCover } from './TemplateCover';\nimport { TemplateTooltips } from './TemplateTooltips';\nimport { TextEditor } from './TextEditor';\nimport { TextEditorDialog } from './TextEditorDialog';\n\nconst PAGE_SIZE = 20;\n\nexport const TemplateTable = () => {\n  const { t } = useTranslation(['common']);\n\n  const isHydrated = useIsHydrated();\n\n  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.templateList(),\n    queryFn: ({ pageParam }) =>\n      getTemplateList({\n        skip: pageParam,\n        take: PAGE_SIZE,\n      }).then((res) => res.data),\n    initialPageParam: 0,\n    getNextPageParam: (lastPage, allPages) => {\n      if (lastPage.length < PAGE_SIZE) {\n        return undefined;\n      }\n      return allPages.length * PAGE_SIZE;\n    },\n  });\n\n  const displayedData = useMemo(() => {\n    return data?.pages.flatMap((page) => page) ?? [];\n  }, [data]);\n\n  const [innerTemplates, setInnerTemplates] = useState(displayedData);\n\n  useEffect(() => {\n    setInnerTemplates(displayedData);\n  }, [displayedData]);\n\n  const queryClient = useQueryClient();\n\n  const { mutateAsync: deleteTemplateFn } = useMutation({\n    mutationFn: (templateId: string) => deleteTemplate(templateId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n    },\n  });\n\n  const { mutateAsync: updateTemplateFn } = useMutation({\n    mutationFn: ({ templateId, updateRo }: { templateId: string; updateRo: IUpdateTemplateRo }) =>\n      updateTemplate(templateId, { ...updateRo }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n    },\n  });\n\n  const handlePublishTemplate = (templateId: string, isPublished: boolean) => {\n    updateTemplateFn({ templateId, updateRo: { isPublished } });\n  };\n\n  const handleFeaturedTemplate = (templateId: string, featured: boolean) => {\n    updateTemplateFn({ templateId, updateRo: { featured } });\n  };\n\n  const onChangeTemplateName = (templateId: string, name: string) => {\n    updateTemplateFn({ templateId, updateRo: { name } });\n  };\n\n  const onChangeTemplateDescription = (templateId: string, description: string) => {\n    updateTemplateFn({ templateId, updateRo: { description } });\n  };\n\n  const onChangeTemplateCover = (templateId: string, cover: ITemplateCoverRo | null) => {\n    updateTemplateFn({ templateId, updateRo: { cover } });\n  };\n\n  const onChangeTemplateCategory = (templateId: string, categoryId: string[]) => {\n    updateTemplateFn({ templateId, updateRo: { categoryId } });\n  };\n\n  const onChangeTemplateMarkdownDescription = (templateId: string, markdownDescription: string) => {\n    updateTemplateFn({ templateId, updateRo: { markdownDescription } });\n  };\n\n  const { mutateAsync: pinTopTemplateFn } = useMutation({\n    mutationFn: (templateId: string) => pinTopTemplate(templateId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n    },\n  });\n\n  const { mutateAsync: updateTemplateOrderFn } = useMutation({\n    mutationFn: ({\n      templateId,\n      anchorId,\n      position,\n    }: {\n      templateId: string;\n      anchorId: string;\n      position: 'before' | 'after';\n    }) => updateTemplateOrder({ templateId, anchorId, position }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.templateList() });\n    },\n  });\n\n  const onDragEnd = async (result: DropResult) => {\n    const { source, destination } = result;\n\n    if (!destination || source.index === destination.index) {\n      return;\n    }\n\n    const list = [...innerTemplates];\n    const [template] = list.splice(source.index, 1);\n    list.splice(destination.index, 0, template);\n    setInnerTemplates(list);\n\n    const templateIndex = list.findIndex((v) => v.id === template.id);\n    if (templateIndex === 0) {\n      await updateTemplateOrderFn({\n        templateId: template.id,\n        anchorId: list[1].id,\n        position: 'before',\n      });\n    } else {\n      await updateTemplateOrderFn({\n        templateId: template.id,\n        anchorId: list[templateIndex - 1].id,\n        position: 'after',\n      });\n    }\n  };\n\n  const renderTableRow = (row: (typeof innerTemplates)[number]) => {\n    return (\n      <>\n        <TableCell\n          className=\"sticky left-16 w-[140px] min-w-[140px] bg-background\"\n          style={{ zIndex: 2 }}\n        >\n          <TemplateCover\n            cover={row.cover}\n            onChange={(res) => {\n              onChangeTemplateCover(row.id, res);\n            }}\n          />\n        </TableCell>\n        <TableCell\n          className=\"sticky left-[204px] min-w-48 bg-background after:pointer-events-none after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-border after:content-['']\"\n          style={{ zIndex: 2 }}\n        >\n          <TextEditor\n            value={row.name}\n            onChange={(value) => {\n              onChangeTemplateName(row.id, value);\n            }}\n            singleLine\n            maxLength={50}\n          />\n        </TableCell>\n        <TableCell className=\"max-w-80\">\n          <TextEditorDialog\n            value={row.description}\n            onChange={(value) => {\n              onChangeTemplateDescription(row.id, value);\n            }}\n            title={t('settings.templateAdmin.header.description')}\n            maxLines={2}\n          />\n        </TableCell>\n        <TableCell>\n          <MarkdownPreviewButton\n            value={row.markdownDescription}\n            onChange={(value) => {\n              onChangeTemplateMarkdownDescription(row.id, value);\n            }}\n          />\n        </TableCell>\n        <TableCell>\n          <TemplateCategorySelect\n            templateId={row.id}\n            value={row.categoryId}\n            onChange={(ids) => onChangeTemplateCategory(row.id, ids)}\n          />\n        </TableCell>\n        <TableCell className=\"text-center align-middle\">\n          <TemplateTooltips\n            content={t('settings.templateAdmin.tips.needPublish')}\n            disabled={!row.isPublished}\n          >\n            <div>\n              <Switch\n                className=\"scale-80\"\n                defaultChecked={Boolean(row.featured)}\n                disabled={!row.isPublished}\n                onCheckedChange={(checked: boolean) => {\n                  handleFeaturedTemplate(row?.id, checked);\n                }}\n              />\n            </div>\n          </TemplateTooltips>\n        </TableCell>\n        <TableCell className=\"text-center align-middle\">\n          <TemplateTooltips\n            content={t('settings.templateAdmin.tips.needSnapshot')}\n            disabled={!row.snapshot || !row.name || !row.description}\n          >\n            <div>\n              <Switch\n                className=\"scale-80\"\n                defaultChecked={Boolean(row.isPublished)}\n                disabled={!row.snapshot || !row.name || !row.description}\n                onCheckedChange={(checked: boolean) => {\n                  handlePublishTemplate(row?.id, checked);\n                }}\n              />\n            </div>\n          </TemplateTooltips>\n        </TableCell>\n        <TableCell>\n          {row.snapshot?.snapshotTime ? (\n            dayjs(row.snapshot.snapshotTime).format('YYYY-MM-DD HH:mm:ss')\n          ) : (\n            <span className=\"text-gray-500\">{t('settings.templateAdmin.noData')}</span>\n          )}\n        </TableCell>\n        <TableCell>\n          {row.createdBy && row.createdBy.name ? (\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <div className=\"flex cursor-pointer items-center gap-2\">\n                    <Avatar className=\"size-6\">\n                      <AvatarImage src={row.createdBy.avatar} alt={row.createdBy.name} />\n                      <AvatarFallback className=\"text-xs\">\n                        {row.createdBy.name.charAt(0).toUpperCase()}\n                      </AvatarFallback>\n                    </Avatar>\n                    <span className=\"text-sm\">{row.createdBy.name}</span>\n                  </div>\n                </TooltipTrigger>\n                {row.createdBy.email && (\n                  <TooltipPortal>\n                    <TooltipContent>\n                      <p>{row.createdBy.email}</p>\n                    </TooltipContent>\n                  </TooltipPortal>\n                )}\n              </Tooltip>\n            </TooltipProvider>\n          ) : (\n            <span className=\"text-gray-500\">\n              {t('settings.templateAdmin.header.userNonExistent')}\n            </span>\n          )}\n        </TableCell>\n        <TableCell\n          className=\"sticky bg-background text-center before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-full before:w-px before:bg-border before:content-['']\"\n          style={{ zIndex: 2, right: 144, width: 100, minWidth: 100, maxWidth: 100 }}\n        >\n          {row.usageCount ?? 0}/{row.visitCount ?? 0}\n        </TableCell>\n        <TableCell\n          className=\"sticky bg-background text-center\"\n          style={{ zIndex: 2, right: 72, width: 72, minWidth: 72, maxWidth: 72 }}\n        >\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"outline\"\n                  size={'xs'}\n                  disabled={!row.snapshot?.baseId}\n                  onClick={() => {\n                    const defaultUrl = row.publishInfo?.defaultUrl;\n                    const url =\n                      defaultUrl || (row.snapshot?.baseId ? `/base/${row.snapshot.baseId}` : '');\n                    if (url) {\n                      window.open(`${window.location.origin}${url}`, '_blank');\n                    }\n                  }}\n                >\n                  <Link className=\"size-4\" />\n                </Button>\n              </TooltipTrigger>\n              <TooltipPortal>\n                <TooltipContent>\n                  <p>{t('settings.templateAdmin.actions.viewTemplate')}</p>\n                </TooltipContent>\n              </TooltipPortal>\n            </Tooltip>\n          </TooltipProvider>\n        </TableCell>\n        <TableCell\n          className=\"sticky bg-background\"\n          style={{ zIndex: 2, right: 0, width: 72, minWidth: 72, maxWidth: 72 }}\n        >\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"outline\" size={'icon-xs'}>\n                <MoreHorizontal className=\"size-4 shrink-0\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent className=\"w-40\">\n              <DropdownMenuGroup>\n                <DropdownMenuItem\n                  className=\"flex items-center gap-2\"\n                  onClick={() => {\n                    pinTopTemplateFn(row.id);\n                  }}\n                >\n                  <ArrowUp className=\"size-3.5\" />\n                  <span className=\"text-sm\">{t('settings.templateAdmin.actions.pinTop')}</span>\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  className=\"flex items-center gap-2 text-red-500\"\n                  onClick={() => {\n                    deleteTemplateFn(row.id);\n                  }}\n                >\n                  <Trash2 className=\"size-3.5\" />\n                  <span className=\"text-sm\">{t('settings.templateAdmin.actions.delete')}</span>\n                </DropdownMenuItem>\n              </DropdownMenuGroup>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </TableCell>\n      </>\n    );\n  };\n\n  return (\n    <div className=\"h-full overflow-auto\">\n      <Table className=\"relative w-max min-w-full scroll-smooth rounded-sm\">\n        <TableHeader className=\"sticky top-0 z-20 bg-background after:pointer-events-none after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-border after:content-['']\">\n          <TableRow className=\"h-16 bg-background\" style={{ zIndex: 2 }}>\n            <TableHead\n              className=\"sticky left-0 w-16 min-w-16 bg-background\"\n              style={{ zIndex: 3 }}\n            ></TableHead>\n            <TableHead\n              className=\"sticky left-16 w-[140px] min-w-[140px] bg-background\"\n              style={{ zIndex: 3 }}\n            >\n              {t('settings.templateAdmin.header.cover')}\n            </TableHead>\n            <TableHead\n              className=\"sticky left-[204px] min-w-48 shrink-0 bg-background after:pointer-events-none after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-border after:content-['']\"\n              style={{ zIndex: 3 }}\n            >\n              {t('settings.templateAdmin.header.name')}\n            </TableHead>\n            <TableHead className=\"min-w-48 shrink-0\">\n              {t('settings.templateAdmin.header.description')}\n            </TableHead>\n            <TableHead className=\"w-32 shrink-0\">\n              {t('settings.templateAdmin.header.markdownDescription')}\n            </TableHead>\n            <TableHead className=\"min-w-32\">\n              <div className=\"flex items-center justify-between\">\n                {t('settings.templateAdmin.header.category')}\n                <CategorySettingDialog />\n              </div>\n            </TableHead>\n            <TableHead className=\"min-w-24 text-center\">\n              {t('settings.templateAdmin.header.featured')}\n            </TableHead>\n            <TableHead className=\"min-w-24 text-center\">\n              {t('settings.templateAdmin.header.status')}\n            </TableHead>\n            <TableHead className=\"min-w-48\">\n              {t('settings.templateAdmin.header.snapshotTime')}\n            </TableHead>\n            <TableHead className=\"min-w-32\">\n              {t('settings.templateAdmin.header.createdBy')}\n            </TableHead>\n            <TableHead\n              className=\"sticky bg-background text-center before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-full before:w-px before:bg-border before:content-['']\"\n              style={{ zIndex: 3, right: 144, width: 100, minWidth: 100, maxWidth: 100 }}\n            >\n              {t('settings.templateAdmin.header.usage')}/{t('settings.templateAdmin.header.visit')}\n            </TableHead>\n            <TableHead\n              className=\"sticky bg-background text-center\"\n              style={{ zIndex: 3, right: 72, width: 72, minWidth: 72, maxWidth: 72 }}\n            >\n              {t('settings.templateAdmin.header.preview')}\n            </TableHead>\n            <TableHead\n              className=\"sticky bg-background\"\n              style={{ zIndex: 3, right: 0, width: 72, minWidth: 72, maxWidth: 72 }}\n            >\n              {t('settings.templateAdmin.header.actions')}\n            </TableHead>\n          </TableRow>\n        </TableHeader>\n\n        {isHydrated ? (\n          <DragDropContext onDragEnd={onDragEnd}>\n            <Droppable droppableId=\"template-list\">\n              {(droppableProvided) => (\n                <TableBody {...droppableProvided.droppableProps} ref={droppableProvided.innerRef}>\n                  {innerTemplates?.map((row, index) => (\n                    <Draggable key={row.id} draggableId={row.id} index={index}>\n                      {(draggableProvided, draggableSnapshot) => (\n                        <TableRow\n                          ref={draggableProvided.innerRef}\n                          {...draggableProvided.draggableProps}\n                          className={cn('max-h-24', {\n                            'opacity-50': draggableSnapshot.isDragging,\n                          })}\n                        >\n                          <TableCell\n                            className=\"sticky left-0 w-16 min-w-16 cursor-grab bg-background active:cursor-grabbing\"\n                            style={{ zIndex: 2 }}\n                            {...draggableProvided.dragHandleProps}\n                          >\n                            <DraggableHandle className=\"size-4 text-gray-400\" />\n                          </TableCell>\n                          {renderTableRow(row)}\n                        </TableRow>\n                      )}\n                    </Draggable>\n                  ))}\n                  {droppableProvided.placeholder}\n                  {innerTemplates?.length === 0 && (\n                    <TableRow>\n                      <TableCell colSpan={100} className=\"h-48 text-center\">\n                        {t('settings.templateAdmin.noData')}\n                      </TableCell>\n                    </TableRow>\n                  )}\n                </TableBody>\n              )}\n            </Droppable>\n          </DragDropContext>\n        ) : (\n          <TableBody>\n            {innerTemplates?.map((row) => (\n              <TableRow key={row.id} className=\"max-h-24\">\n                <TableCell\n                  className=\"sticky left-0 w-16 min-w-16 bg-background\"\n                  style={{ zIndex: 2 }}\n                ></TableCell>\n                {renderTableRow(row)}\n              </TableRow>\n            ))}\n            {innerTemplates?.length === 0 && (\n              <TableRow>\n                <TableCell colSpan={100} className=\"h-48 text-center\">\n                  {t('settings.templateAdmin.noData')}\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        )}\n      </Table>\n\n      {/* Load more  */}\n      {hasNextPage && (\n        <div className=\"flex justify-center border-t py-4\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"flex gap-2 px-4\"\n            onClick={() => fetchNextPage()}\n            disabled={isFetchingNextPage}\n          >\n            {isFetchingNextPage ? <Spin className=\"size-4\" /> : t('actions.loadMore')}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TemplateTooltips.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib';\n\ninterface ITooltipsProps {\n  children: React.ReactNode;\n  content: string;\n  disabled?: boolean;\n}\n\nexport function TemplateTooltips(props: ITooltipsProps) {\n  const { children, content, disabled } = props;\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <div>{children}</div>\n        </TooltipTrigger>\n        {disabled && <TooltipContent>{content}</TooltipContent>}\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TextEditor.tsx",
    "content": "import { Edit } from '@teable/icons';\nimport { cn, Input } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\ninterface ITextEditorProps {\n  value?: string;\n  onChange: (value: string) => void;\n  defaultPlaceholder?: string;\n  singleLine?: boolean;\n  maxLength?: number;\n}\n\nexport const TextEditor = (props: ITextEditorProps) => {\n  const { t } = useTranslation('common');\n  const { value, onChange, defaultPlaceholder, singleLine = false, maxLength } = props;\n  const [isEditing, setIsEditing] = useState(false);\n  const [isHovered, setIsHovered] = useState(false);\n  const [currentValue, setCurrentValue] = useState(value || '');\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const handleStartEdit = () => {\n    setCurrentValue(value || '');\n    setIsEditing(true);\n    setTimeout(() => {\n      inputRef?.current?.focus();\n      inputRef?.current?.select();\n    }, 0);\n  };\n\n  const handleSave = (newValue: string) => {\n    if (newValue !== value) {\n      onChange(newValue);\n    }\n    setIsEditing(false);\n  };\n\n  const handleCancel = () => {\n    setIsEditing(false);\n  };\n\n  return (\n    <div\n      className=\"group flex size-full items-center gap-2\"\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n    >\n      {isEditing ? (\n        <div className=\"flex flex-1 items-center gap-2\">\n          <Input\n            value={currentValue}\n            className=\"flex-1\"\n            maxLength={maxLength}\n            onChange={(e) => {\n              setCurrentValue(e.target.value);\n            }}\n            onKeyDown={(e) => {\n              const newValue = (e.target as HTMLInputElement).value;\n              if (e.key === 'Enter') {\n                handleSave(newValue);\n              } else if (e.key === 'Escape') {\n                handleCancel();\n              }\n            }}\n            onBlur={(e) => {\n              const newValue = e.target.value;\n              handleSave(newValue);\n            }}\n            ref={inputRef}\n          />\n          {maxLength && (\n            <span className=\"shrink-0 text-xs text-muted-foreground\">\n              {currentValue.length}/{maxLength}\n            </span>\n          )}\n        </div>\n      ) : (\n        <span\n          className={cn(\n            'flex-1 cursor-pointer',\n            singleLine ? 'truncate' : 'line-clamp-6 break-words',\n            {\n              'text-muted-foreground': !value && value !== '0',\n            }\n          )}\n          title={value}\n          role=\"button\"\n          tabIndex={0}\n          onClick={handleStartEdit}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault();\n              handleStartEdit();\n            }\n          }}\n        >\n          {value || defaultPlaceholder || t('untitled')}\n        </span>\n      )}\n\n      <Edit\n        className={cn(\n          'size-3 shrink-0 cursor-pointer transition-opacity',\n          isHovered || isEditing ? 'opacity-100' : 'opacity-0'\n        )}\n        onClick={handleStartEdit}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/TextEditorDialog.tsx",
    "content": "import { Edit } from '@teable/icons';\nimport {\n  cn,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Button,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\ninterface ITextEditorDialogProps {\n  value?: string;\n  onChange: (value: string) => void;\n  title: string;\n  placeholder?: string;\n  maxLines?: number;\n}\n\nexport const TextEditorDialog = ({\n  value,\n  onChange,\n  title,\n  placeholder,\n  maxLines = 1,\n}: ITextEditorDialogProps) => {\n  const { t } = useTranslation('common');\n  const [innerValue, setInnerValue] = useState(value || '');\n  const [isOpen, setIsOpen] = useState(false);\n\n  const handleOpen = () => {\n    setInnerValue(value || '');\n    setIsOpen(true);\n  };\n\n  const handleSave = () => {\n    onChange(innerValue);\n    setIsOpen(false);\n  };\n\n  return (\n    <>\n      <div\n        className=\"group flex size-full cursor-pointer items-center gap-2\"\n        onClick={handleOpen}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter' || e.key === ' ') {\n            e.preventDefault();\n            handleOpen();\n          }\n        }}\n        role=\"button\"\n        tabIndex={0}\n      >\n        <span\n          className={cn('flex-1', {\n            truncate: maxLines === 1,\n            'line-clamp-2': maxLines === 2,\n            'line-clamp-3': maxLines === 3,\n            'text-gray-500': !value && value !== '0',\n          })}\n          title={value}\n        >\n          {value || placeholder || t('untitled')}\n        </span>\n        <Edit className=\"size-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\" />\n      </div>\n\n      <Dialog open={isOpen} onOpenChange={setIsOpen}>\n        <DialogContent className=\"max-w-2xl\">\n          <DialogHeader>\n            <DialogTitle>{title}</DialogTitle>\n          </DialogHeader>\n\n          <div className=\"space-y-4\">\n            <Textarea\n              value={innerValue}\n              onChange={(e) => setInnerValue(e.target.value)}\n              placeholder={placeholder || t('untitled')}\n              className=\"min-h-[200px] resize-none\"\n            />\n            <div className=\"text-sm text-gray-500\">\n              {innerValue.length} {t('characters')}\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => setIsOpen(false)}>\n              {t('actions.cancel')}\n            </Button>\n            <Button onClick={handleSave}>{t('actions.save')}</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/index.ts",
    "content": "export * from './TemplateTable';\nexport * from './BaseSelectPanel';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/upload-panel/Process.tsx",
    "content": "import { cn, Progress } from '@teable/ui-lib';\n\ninterface IFileItemProps {\n  process: number;\n}\n\nexport const Process = (props: IFileItemProps) => {\n  const { process } = props;\n\n  return (\n    <div className=\"relative size-full\">\n      <div\n        className={cn('absolute inset-0 z-10 flex justify-center flex-col items-center', {\n          hidden: process === 100,\n        })}\n      >\n        <Progress value={process} />\n        <span className=\"text-center text-xs\">{process}%</span>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/upload-panel/TemplateCoverPreview.tsx",
    "content": "import { X } from '@teable/icons';\nimport type { ITemplateCoverVo } from '@teable/openapi';\nimport { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';\nimport { cn, FilePreviewItem, FilePreviewProvider } from '@teable/ui-lib';\n\ninterface ITemplatePreviewProps {\n  cover: ITemplateCoverVo;\n  onClose: () => void;\n}\nexport const TemplateCoverPreview = (props: ITemplatePreviewProps) => {\n  const { cover, onClose } = props;\n  const i18nMap = useAttachmentPreviewI18Map();\n  const { presignedUrl, mimetype, size, name } = cover;\n\n  return (\n    <FilePreviewProvider i18nMap={i18nMap}>\n      <div className=\"group relative size-full rounded-sm text-sm\">\n        {presignedUrl && (\n          <FilePreviewItem\n            className={cn(\n              'shrink-0 size-full rounded border-slate-200 overflow-hidden cursor-pointer border'\n            )}\n            src={presignedUrl}\n            name={name}\n            mimetype={mimetype!}\n            size={size}\n          >\n            <img className=\"size-full object-contain\" src={presignedUrl} alt={name} />\n          </FilePreviewItem>\n        )}\n        <X\n          className=\"absolute -right-2 -top-2 hidden size-4 cursor-pointer rounded-full bg-secondary p-0.5 group-hover:block hover:opacity-70\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onClose();\n          }}\n        />\n      </div>\n    </FilePreviewProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/upload-panel/Trigger.tsx",
    "content": "import { useRef } from 'react';\nimport { useDropArea } from 'react-use';\n\ninterface IUploadProps {\n  accept?: string;\n  onChange: (file: File | null) => void;\n  onBeforeUpload?: () => void;\n  children: React.ReactElement;\n}\n\nexport const Trigger = (props: IUploadProps) => {\n  const { onChange, children, accept, onBeforeUpload } = props;\n  const uploadRef = useRef<HTMLInputElement>(null);\n\n  const [bound] = useDropArea({\n    onFiles: (files: File[]) => onChange(files[0]),\n  });\n\n  return (\n    <>\n      <input\n        className=\"hidden\"\n        ref={uploadRef}\n        type=\"file\"\n        accept={accept}\n        multiple={false}\n        autoComplete=\"off\"\n        tabIndex={-1}\n        onChange={async (e) => {\n          onBeforeUpload?.();\n          const files = (e.target.files && Array.from(e.target.files)) || null;\n          if (files && files.length > 0) {\n            onChange(files[0]);\n          }\n        }}\n      ></input>\n      <div\n        role=\"button\"\n        tabIndex={0}\n        className=\"size-full\"\n        onClick={() => {\n          if (uploadRef?.current) {\n            uploadRef.current.value = '';\n            uploadRef?.current?.click();\n          }\n        }}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter') {\n            uploadRef?.current?.click();\n          }\n        }}\n        {...bound}\n      >\n        {children}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/upload-panel/UploadPanel.tsx",
    "content": "import { generateAttachmentId } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport type { ITemplateCoverRo, ITemplateCoverVo } from '@teable/openapi';\nimport { UploadType } from '@teable/openapi';\nimport { AttachmentManager } from '@teable/sdk/components';\nimport { cn } from '@teable/ui-lib';\nimport { pick } from 'lodash';\nimport { useState } from 'react';\nimport { Process } from './Process';\nimport { TemplateCoverPreview } from './TemplateCoverPreview';\nimport { Trigger } from './Trigger';\n\ninterface IUploadPanelProps {\n  file: File | null;\n  accept?: string;\n  onClose: () => void;\n  onFinished: (result: ITemplateCoverRo) => void;\n  onChange: (file: File | null) => void;\n  cover?: ITemplateCoverVo;\n}\nconst attchmentManager = new AttachmentManager(1);\n\nconst UploadPanel = (props: IUploadPanelProps) => {\n  const { file, accept, onChange, onFinished, onClose, cover } = props;\n  const [process, setProcess] = useState(0);\n  const [isImporting, setIsImporting] = useState(false);\n\n  return (\n    <div\n      className={cn('relative flex h-full w-full items-center justify-center', {\n        'pointer-events-none': isImporting,\n      })}\n    >\n      <Trigger\n        onBeforeUpload={() => {\n          setIsImporting(true);\n        }}\n        accept={accept}\n        onChange={async (file) => {\n          setIsImporting(false);\n          if (file) {\n            const attachmentId = generateAttachmentId();\n            attchmentManager.upload([{ id: attachmentId, instance: file }], UploadType.Template, {\n              successCallback: (_, result) => {\n                const res = {\n                  ...pick(result, ['token', 'path', 'size', 'url', 'mimetype']),\n                  name: file.name,\n                  id: attachmentId,\n                };\n                onFinished?.(res);\n              },\n              progressCallback: (_, process) => {\n                setProcess(process);\n              },\n            });\n          }\n          onChange(file);\n        }}\n      >\n        <div\n          className={cn(\n            'flex h-full items-center justify-center rounded-sm border-2 border-none hover:border-secondary',\n            {\n              'border-dashed': !cover,\n            }\n          )}\n        >\n          {!cover && !file && <Plus className=\"size-4\" />}\n          {cover && <TemplateCoverPreview cover={cover} onClose={onClose} />}\n          {file && !cover && <Process process={process} />}\n        </div>\n      </Trigger>\n    </div>\n  );\n};\n\nexport { UploadPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/components/upload-panel/index.ts",
    "content": "export * from './Process';\nexport * from './Trigger';\nexport * from './UploadPanel';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/admin/template/index.ts",
    "content": "export * from './TemplatePage';\nexport * from './components';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/BasePermissionListener.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getBaseById } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, usePermissionUpdateListener, useSession } from '@teable/sdk/hooks';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useRef } from 'react';\nimport { baseConfig } from '@/features/i18n/base.config';\n\nexport const BasePermissionListener = () => {\n  const baseId = useBaseId();\n  const router = useRouter();\n  const { t } = useTranslation(baseConfig.i18nNamespaces);\n  const { user } = useSession();\n\n  // Use ref to store the current active toast ID to prevent duplicates\n  const activeToastIdRef = useRef<string | number | undefined>();\n\n  const { data: base, refetch } = useQuery({\n    queryKey: ReactQueryKeys.base(baseId!),\n    queryFn: ({ queryKey }) => getBaseById(queryKey[1]).then((res) => res.data),\n    enabled: !!baseId,\n  });\n\n  const restrictedAuthority = base?.restrictedAuthority;\n\n  const onPermissionUpdate = useCallback(\n    async (operatorUserId?: string) => {\n      // Skip notification if the current user is the one who made the permission change\n      if (operatorUserId === user?.id) {\n        return;\n      }\n\n      const base = await refetch();\n\n      if (\n        Boolean(restrictedAuthority) === Boolean(base.data?.restrictedAuthority) &&\n        !restrictedAuthority\n      ) {\n        return;\n      }\n\n      // Show toast notification instead of modal dialog\n      // eslint-disable-next-line sonarjs/cognitive-complexity\n      // Use the same ID to prevent duplicate notifications\n      const toastId = toast.warning(t('common:pagePermissionChangeTip'), {\n        id: activeToastIdRef.current,\n        position: 'top-center',\n        closeButton: true,\n        duration: 500000,\n        action: {\n          label: t('common:actions.refreshPage'),\n          onClick: () => router.reload(),\n        },\n        dismissible: true,\n      });\n\n      // Store the toast ID for future deduplication\n      activeToastIdRef.current = toastId;\n    },\n    [user?.id, refetch, restrictedAuthority, t, router]\n  );\n\n  usePermissionUpdateListener(baseId, onPermissionUpdate);\n\n  return null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeContext.ts",
    "content": "import { noop } from 'lodash';\nimport { createContext } from 'react';\nimport type { TreeItemData } from './hooks';\n\nexport const BaseNodeContext = createContext<{\n  isLoading: boolean;\n  maxFolderDepth: number;\n  treeItems: Record<string, TreeItemData>;\n  setTreeItems: (\n    updater: (prev: Record<string, TreeItemData>) => Record<string, TreeItemData>\n  ) => void;\n  invalidateMenu: () => void;\n}>({\n  isLoading: false,\n  maxFolderDepth: 2,\n  treeItems: {},\n  setTreeItems: noop,\n  invalidateMenu: noop,\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeProvider.tsx",
    "content": "import { useBaseId } from '@teable/sdk/hooks';\nimport { useMemo } from 'react';\nimport { useShareNodeId } from '@/features/app/context/ShareContext';\nimport { BaseNodeContext } from './BaseNodeContext';\nimport { useBaseNode } from './hooks';\nimport { ROOT_ID } from './hooks/helper';\n\nexport const BaseNodeProvider: React.FC<{\n  children: React.ReactNode;\n  isRestrictedAuthority?: boolean;\n}> = ({ children, isRestrictedAuthority }) => {\n  const baseId = useBaseId() as string;\n  const context = useBaseNode(baseId, isRestrictedAuthority);\n  const shareNodeId = useShareNodeId();\n\n  // Filter treeItems based on share nodeId (include the node and all its descendants)\n  const filteredContext = useMemo(() => {\n    if (!shareNodeId) {\n      // No filtering needed\n      return context;\n    }\n\n    const filteredTreeItems: typeof context.treeItems = {};\n\n    // Helper to collect all descendant node IDs\n    const collectDescendants = (nodeId: string, descendantIds: Set<string>) => {\n      descendantIds.add(nodeId);\n      const node = context.treeItems[nodeId];\n      if (!node) return;\n      for (const childId of node.children) {\n        collectDescendants(childId, descendantIds);\n      }\n    };\n\n    // Collect the shared node and all its descendants\n    const allowedNodeIds = new Set<string>();\n    collectDescendants(shareNodeId, allowedNodeIds);\n\n    // Add all allowed nodes\n    for (const nodeId of allowedNodeIds) {\n      if (context.treeItems[nodeId]) {\n        filteredTreeItems[nodeId] = {\n          ...context.treeItems[nodeId],\n          // Filter children to only include allowed nodes\n          children: context.treeItems[nodeId].children.filter((childId) =>\n            allowedNodeIds.has(childId)\n          ),\n        };\n      }\n    }\n\n    // Add ROOT_ID with filtered children (only the shared node at root level)\n    if (context.treeItems[ROOT_ID]) {\n      filteredTreeItems[ROOT_ID] = {\n        ...context.treeItems[ROOT_ID],\n        children: [shareNodeId],\n      };\n    }\n\n    return {\n      ...context,\n      treeItems: filteredTreeItems,\n    };\n  }, [context, shareNodeId]);\n\n  return <BaseNodeContext.Provider value={filteredContext}>{children}</BaseNodeContext.Provider>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/helper.spec.ts",
    "content": "import { BaseNodeResourceType } from '@teable/openapi';\nimport { vi } from 'vitest';\nimport { findAdjacentNonFolderNode, ROOT_ID } from './helper';\nimport type { TreeItemData } from './useBaseNode';\n\n/**\n * Helper function to create a TreeItemData node for testing\n */\nconst createNode = (\n  id: string,\n  resourceType: BaseNodeResourceType,\n  children: string[] = [],\n  parentId: string | null = null\n): TreeItemData => ({\n  id,\n  resourceType,\n  resourceId: id,\n  resourceMeta: { name: `Node ${id}` },\n  order: 0,\n  parentId,\n  children,\n});\n\n/**\n * Helper function to create a root node\n */\nconst createRootNode = (children: string[]): TreeItemData => ({\n  id: ROOT_ID,\n  resourceType: BaseNodeResourceType.Folder,\n  resourceId: ROOT_ID,\n  resourceMeta: { name: 'baseMenuRoot' },\n  order: 0,\n  parentId: null,\n  children,\n});\n\ndescribe('findAdjacentNonFolderNode', () => {\n  describe('basic traversal', () => {\n    it('should return the next sibling if it is not a folder', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should return the previous sibling if next sibling does not exist', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1\n       *   └── table2 (current)\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table2');\n      expect(result?.id).toBe('table1');\n    });\n\n    it('should return null when only one non-folder node exists', () => {\n      /**\n       * Tree structure:\n       * root\n       *   └── table1 (current)\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result).toBeNull();\n    });\n  });\n\n  describe('folder handling', () => {\n    it('should skip folder and return the next non-folder node', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   ├── folder1\n       *   └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'folder1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should enter folder children when searching below', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── folder1\n       *       └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'folder1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table2']),\n        table2: createNode('table2', BaseNodeResourceType.Table, [], 'folder1'),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should skip empty folders and continue searching', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   ├── folder1 (empty)\n       *   └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'folder1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, []),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should return null when only folders exist besides current node', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── folder1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'folder1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result).toBeNull();\n    });\n  });\n\n  describe('nested folder traversal', () => {\n    it('should find node in deeply nested folder', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── folder1\n       *       └── folder2\n       *           └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'folder1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['folder2']),\n        folder2: createNode('folder2', BaseNodeResourceType.Folder, ['table2'], 'folder1'),\n        table2: createNode('table2', BaseNodeResourceType.Table, [], 'folder2'),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should find parent sibling after reaching end of nested structure', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   │   └── table1 (current)\n       *   └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table2']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder1'),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should traverse up and find previous sibling last descendant', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   │   └── table1\n       *   └── table2 (current)\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table2']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder1'),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table2');\n      expect(result?.id).toBe('table1');\n    });\n  });\n\n  describe('alternating search pattern', () => {\n    it('should prefer below over above when both exist', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1\n       *   ├── table2 (current)\n       *   └── table3\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'table2', 'table3']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n        table3: createNode('table3', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table2');\n      expect(result?.id).toBe('table3');\n    });\n\n    it('should fall back to above when below is folder', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1\n       *   ├── table2 (current)\n       *   └── folder1 (empty)\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'table2', 'folder1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table2');\n      expect(result?.id).toBe('table1');\n    });\n  });\n\n  describe('different resource types', () => {\n    it('should return dashboard node', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── dashboard1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'dashboard1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        dashboard1: createNode('dashboard1', BaseNodeResourceType.Dashboard),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('dashboard1');\n    });\n\n    it('should return workflow node', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── workflow1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'workflow1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        workflow1: createNode('workflow1', BaseNodeResourceType.Workflow),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('workflow1');\n    });\n\n    it('should return app node', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── table1 (current)\n       *   └── app1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'app1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        app1: createNode('app1', BaseNodeResourceType.App),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('app1');\n    });\n\n    it('should handle mixed resource types', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   ├── table1 (current)\n       *   ├── folder2\n       *   ├── dashboard1\n       *   └── workflow1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table1', 'folder2', 'dashboard1', 'workflow1']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        folder2: createNode('folder2', BaseNodeResourceType.Folder),\n        dashboard1: createNode('dashboard1', BaseNodeResourceType.Dashboard),\n        workflow1: createNode('workflow1', BaseNodeResourceType.Workflow),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('dashboard1');\n    });\n  });\n\n  describe('complex tree structures', () => {\n    it('should handle complex nested structure with multiple folders', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   │   ├── folder2\n       *   │   │   └── table1 (current)\n       *   │   └── table2\n       *   └── table3\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table3']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['folder2', 'table2']),\n        folder2: createNode('folder2', BaseNodeResourceType.Folder, ['table1'], 'folder1'),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder2'),\n        table2: createNode('table2', BaseNodeResourceType.Table, [], 'folder1'),\n        table3: createNode('table3', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should find last descendant when going up from last child', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   │   ├── table1\n       *   │   └── table2\n       *   └── table3 (current)\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table3']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder1'),\n        table2: createNode('table2', BaseNodeResourceType.Table, [], 'folder1'),\n        table3: createNode('table3', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table3');\n      // Should find table2 as it's the last descendant of folder1\n      expect(result?.id).toBe('table2');\n    });\n\n    it('should handle sibling at same level after nested children', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1\n       *   │   └── table1 (current)\n       *   ├── folder2\n       *   │   └── table2\n       *   └── table3\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'folder2', 'table3']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder1'),\n        folder2: createNode('folder2', BaseNodeResourceType.Folder, ['table2']),\n        table2: createNode('table2', BaseNodeResourceType.Table, [], 'folder2'),\n        table3: createNode('table3', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      // Should go to folder2's first child (table2)\n      expect(result?.id).toBe('table2');\n    });\n  });\n\n  describe('edge cases', () => {\n    it('should return null for non-existent node', () => {\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'nonexistent');\n      expect(result).toBeNull();\n    });\n\n    it('should return null for empty tree', () => {\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode([]),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result).toBeNull();\n    });\n\n    it('should handle tree with only folders', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1 (current)\n       *   └── folder2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'folder2']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder),\n        folder2: createNode('folder2', BaseNodeResourceType.Folder),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'folder1');\n      expect(result).toBeNull();\n    });\n\n    it('should find node when current is a folder with children', () => {\n      /**\n       * Tree structure:\n       * root\n       *   ├── folder1 (current)\n       *   │   └── table1\n       *   └── table2\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table2']),\n        folder1: createNode('folder1', BaseNodeResourceType.Folder, ['table1']),\n        table1: createNode('table1', BaseNodeResourceType.Table, [], 'folder1'),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      const result = findAdjacentNonFolderNode(treeItems, 'folder1');\n      expect(result?.id).toBe('table1');\n    });\n  });\n\n  describe('circular reference protection', () => {\n    it('should handle circular reference in children (prevent infinite recursion)', () => {\n      /**\n       * Malformed tree with circular reference:\n       * folder1.children includes folder1 itself\n       */\n      const folder1Node = createNode('folder1', BaseNodeResourceType.Folder, ['folder1']);\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table1']),\n        folder1: folder1Node,\n        table1: createNode('table1', BaseNodeResourceType.Table),\n      };\n\n      // Should not crash, should handle gracefully\n      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {\n        // Suppress error logs during test\n      });\n      const result = findAdjacentNonFolderNode(treeItems, 'folder1');\n\n      // Should return table1 or null, but not crash\n      expect(result?.id === 'table1' || result === null).toBe(true);\n      consoleSpy.mockRestore();\n    });\n\n    it('should handle circular reference in parent chain (prevent infinite loop in getItemBelow)', () => {\n      /**\n       * Malformed tree where node points to itself as parent\n       */\n      const circularNode = createNode('circular', BaseNodeResourceType.Folder, [], 'circular');\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['circular', 'table1']),\n        circular: circularNode,\n        table1: createNode('table1', BaseNodeResourceType.Table),\n      };\n\n      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {\n        // Suppress error logs during test\n      });\n      const result = findAdjacentNonFolderNode(treeItems, 'circular');\n\n      // Should handle gracefully without infinite loop\n      expect(result?.id === 'table1' || result === null).toBe(true);\n      consoleSpy.mockRestore();\n    });\n\n    it('should not exceed max iterations for deeply nested structure', () => {\n      /**\n       * Create a very deep structure to test max iteration limit\n       * root -> folder1 -> folder2 -> ... -> folder10 -> table1\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['folder1', 'table2']),\n      };\n\n      let currentParent = 'folder1';\n      for (let i = 1; i <= 10; i++) {\n        const folderId = `folder${i}`;\n        const nextId = i === 10 ? 'table1' : `folder${i + 1}`;\n\n        treeItems[folderId] = createNode(\n          folderId,\n          BaseNodeResourceType.Folder,\n          [nextId],\n          i === 1 ? null : `folder${i - 1}`\n        );\n        currentParent = folderId;\n      }\n\n      treeItems['table1'] = createNode('table1', BaseNodeResourceType.Table, [], 'folder10');\n      treeItems['table2'] = createNode('table2', BaseNodeResourceType.Table);\n\n      // Should handle deep nesting without hitting max iterations\n      const result = findAdjacentNonFolderNode(treeItems, 'folder5');\n      expect(result?.id).toBeDefined();\n\n      // Verify currentParent is used (eslint fix)\n      expect(currentParent).toBe('folder10');\n    });\n\n    it('should handle two-node circular reference in main loop', () => {\n      /**\n       * Test case where getItemBelow/getItemAbove might revisit nodes\n       */\n      const treeItems: Record<string, TreeItemData> = {\n        [ROOT_ID]: createRootNode(['table1', 'table2']),\n        table1: createNode('table1', BaseNodeResourceType.Table),\n        table2: createNode('table2', BaseNodeResourceType.Table),\n      };\n\n      // Normal case should work\n      const result = findAdjacentNonFolderNode(treeItems, 'table1');\n      expect(result?.id).toBe('table2');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/helper.ts",
    "content": "import type { UrlObject } from 'url';\nimport { Table2 } from '@teable/icons';\nimport type { IBaseNodeResourceMeta, IBaseNodeVo } from '@teable/openapi';\nimport { BaseNodeResourceType, LastVisitResourceType, ResourceType } from '@teable/openapi';\nimport { keyBy } from 'lodash';\nimport { AppWindowMacIcon, BotIcon, CircleGaugeIcon, FolderClosedIcon } from 'lucide-react';\nimport type { TreeItemData } from './useBaseNode';\n\ntype TreeRootItem = {\n  id: typeof ROOT_ID;\n  resourceType: BaseNodeResourceType.Folder;\n  resourceId: typeof ROOT_ID;\n  resourceMeta: IBaseNodeResourceMeta;\n  children: string[];\n};\n\nexport const ROOT_ID = '__root__';\n\nexport const BaseNodeResourceIconMap = {\n  [BaseNodeResourceType.Folder]: FolderClosedIcon,\n  [BaseNodeResourceType.Dashboard]: CircleGaugeIcon,\n  [BaseNodeResourceType.Workflow]: BotIcon,\n  [BaseNodeResourceType.App]: AppWindowMacIcon,\n  [BaseNodeResourceType.Table]: Table2,\n};\n\nexport const BaseNodeResourceLastVisitMap = {\n  [BaseNodeResourceType.Table]: LastVisitResourceType.Table,\n  [BaseNodeResourceType.Dashboard]: LastVisitResourceType.Dashboard,\n  [BaseNodeResourceType.Workflow]: LastVisitResourceType.Workflow,\n  [BaseNodeResourceType.App]: LastVisitResourceType.App,\n};\n\nexport const getNodeName = (node: { resourceMeta?: IBaseNodeResourceMeta }): string => {\n  return node.resourceMeta?.name ?? '';\n};\n\nexport const getNodeIcon = (node: {\n  resourceMeta?: IBaseNodeResourceMeta;\n}): string | null | undefined => {\n  return node.resourceMeta?.icon;\n};\n\nexport const getNodeUrl = (props: {\n  baseId: string;\n  resourceType: string;\n  resourceId: string;\n  viewId?: string | null;\n  urlPrefix?: string;\n}): UrlObject | null => {\n  const { baseId, resourceId, resourceType, viewId, urlPrefix = '' } = props;\n  switch (resourceType) {\n    case ResourceType.Table:\n      if (viewId) {\n        return {\n          pathname: `${urlPrefix}/base/${baseId}/table/${resourceId}/${viewId}`,\n        };\n      }\n      return {\n        pathname: `${urlPrefix}/base/${baseId}/table/${resourceId}`,\n      };\n    case ResourceType.Dashboard:\n      return {\n        pathname: `${urlPrefix}/base/${baseId}/dashboard/${resourceId}`,\n      };\n    case ResourceType.Workflow:\n      return {\n        pathname: `${urlPrefix}/base/${baseId}/automation/${resourceId}`,\n      };\n    case ResourceType.App:\n      return {\n        pathname: `${urlPrefix}/base/${baseId}/app/${resourceId}`,\n      };\n    case ResourceType.Base:\n      return {\n        pathname: `/base/${resourceId}`,\n      };\n    default:\n      return null;\n  }\n};\n\nexport const parseNodeUrl = (props: {\n  baseId: string;\n  url: string;\n  urlParams: {\n    dashboardId?: string;\n    automationId?: string;\n    appId?: string;\n    tableId?: string;\n  };\n}) => {\n  const { baseId, url, urlParams } = props;\n  const { dashboardId, automationId, appId, tableId } = urlParams;\n  if (url.includes(`/base/${baseId}/dashboard/${dashboardId}`)) {\n    return {\n      resourceType: BaseNodeResourceType.Dashboard,\n      resourceId: dashboardId,\n    };\n  }\n  if (url.includes(`/base/${baseId}/automation/${automationId}`)) {\n    return {\n      resourceType: BaseNodeResourceType.Workflow,\n      resourceId: automationId,\n    };\n  }\n  if (url.includes(`/base/${baseId}/app/${appId}`)) {\n    return {\n      resourceType: BaseNodeResourceType.App,\n      resourceId: appId,\n    };\n  }\n  if (url.includes(`/base/${baseId}/table/${tableId}`)) {\n    return {\n      resourceType: BaseNodeResourceType.Table,\n      resourceId: tableId,\n    };\n  }\n  return null;\n};\n\nexport const cleanParentId = (parentId?: string | null) => {\n  if (parentId === ROOT_ID) {\n    return null;\n  }\n  return parentId;\n};\n\nconst cleanNodes = (nodes: IBaseNodeVo[], nodeMap: Record<string, IBaseNodeVo>): IBaseNodeVo[] => {\n  return nodes.map((node) => {\n    let parentId = null;\n    if (node.parentId) {\n      const parentNode = nodeMap[node.parentId];\n      if (\n        parentNode?.id === node.parentId &&\n        parentNode.resourceType === BaseNodeResourceType.Folder\n      ) {\n        parentId = node.parentId;\n      } else {\n        console.error(\n          `base menu node ${node.id} parentId is not valid, node: ${JSON.stringify(node)}, parentNode: ${JSON.stringify(parentNode)}`\n        );\n      }\n    }\n    const originalChildren = node.children ?? [];\n    let children = originalChildren;\n    if (children) {\n      children = children.filter((child) => nodeMap[child.id]?.id === child.id);\n      if (children.length !== originalChildren.length) {\n        console.error('base menu node children is not valid', node);\n      }\n    }\n    return {\n      ...node,\n      parentId,\n      children,\n    };\n  });\n};\n\nexport const buildTreeItems = (nodes: IBaseNodeVo[]): Record<string, TreeItemData> => {\n  const nodeMap = keyBy(nodes, 'id');\n  const cleanedNodes = cleanNodes(nodes, nodeMap);\n  const result: Record<string, TreeRootItem | TreeItemData> = {\n    [ROOT_ID]: {\n      id: ROOT_ID,\n      resourceType: BaseNodeResourceType.Folder,\n      resourceId: ROOT_ID,\n      resourceMeta: {\n        name: 'baseMenuRoot',\n      },\n      children: [],\n    },\n  };\n\n  for (const node of cleanedNodes) {\n    if (!node.parentId) {\n      result[ROOT_ID].children.push(node.id);\n    }\n    result[node.id] = {\n      ...node,\n      children: (node.children ?? []).map((child) => child.id),\n    };\n  }\n  return result as Record<string, TreeItemData>;\n};\n\n/**\n * Find adjacent non-folder node after deletion using alternating below/above traversal.\n * This matches the exact behavior of BaseNodeTree's deleteSuccefulyCallback.\n *\n * Uses depth-first traversal order (visual tree order):\n * - getItemBelow: first child > next sibling > parent's next sibling\n * - getItemAbove: previous sibling's last descendant > parent\n *\n * Note: Assumes treeItems is already validated by buildTreeItems/cleanNodes.\n */\nexport const findAdjacentNonFolderNode = (\n  treeItems: Record<string, TreeItemData>,\n  currentNodeId: string\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n): TreeItemData | null => {\n  const isFolder = (nodeId: string) =>\n    treeItems[nodeId]?.resourceType === BaseNodeResourceType.Folder;\n\n  /**\n   * Get the next node in depth-first (visual) order\n   * Order: first child > next sibling > parent's next sibling (recursive)\n   */\n  const getItemBelow = (nodeId: string): string | null => {\n    const node = treeItems[nodeId];\n    if (!node) return null;\n\n    // 1. If has children, return first child\n    if (node.children.length > 0) {\n      return node.children[0];\n    }\n\n    // 2. Find next sibling or ancestor's next sibling\n    let currentId: string | null = nodeId;\n    let levels = 0;\n\n    while (currentId && levels < 100) {\n      const current: TreeItemData | undefined = treeItems[currentId];\n      if (!current) return null;\n\n      const parentId: string = current.parentId ?? ROOT_ID;\n      const parent: TreeItemData | undefined = treeItems[parentId];\n      if (!parent) return null;\n\n      const currentIndex = parent.children.indexOf(currentId);\n\n      // If has next sibling, return it\n      if (currentIndex >= 0 && currentIndex < parent.children.length - 1) {\n        return parent.children[currentIndex + 1];\n      }\n\n      // Go up to parent and continue searching\n      if (parentId === ROOT_ID) return null;\n      currentId = parentId;\n      levels++;\n    }\n\n    return null;\n  };\n\n  /**\n   * Get the previous node in depth-first (visual) order\n   * Order: previous sibling's last descendant > parent\n   */\n  const getItemAbove = (nodeId: string): string | null => {\n    const node = treeItems[nodeId];\n    if (!node) return null;\n\n    const parentId = node.parentId ?? ROOT_ID;\n    const parent = treeItems[parentId];\n    if (!parent) return null;\n\n    const currentIndex = parent.children.indexOf(nodeId);\n\n    // 1. If has previous sibling, return its last descendant\n    if (currentIndex > 0) {\n      const prevSiblingId = parent.children[currentIndex - 1];\n      return getLastDescendant(prevSiblingId);\n    }\n\n    // 2. Return parent (if not root)\n    if (parentId === ROOT_ID) return null;\n    return parentId;\n  };\n\n  /**\n   * Get the last descendant of a node (depth-first, rightmost)\n   */\n  const getLastDescendant = (nodeId: string, depth = 0): string => {\n    // Depth limit as safety net (tree shouldn't be deeper than reasonable limit)\n    if (depth > 100) return nodeId;\n\n    const node = treeItems[nodeId];\n    if (!node || node.children.length === 0) return nodeId;\n\n    const lastChildId = node.children[node.children.length - 1];\n    return getLastDescendant(lastChildId, depth + 1);\n  };\n\n  // Alternating search: below first, then above, repeat\n  // visited set prevents revisiting nodes in case of data anomalies\n  const visited = new Set<string>([currentNodeId]);\n  let belowId: string | null = currentNodeId;\n  let aboveId: string | null = currentNodeId;\n\n  while (belowId || aboveId) {\n    // Try below first\n    if (belowId) {\n      belowId = getItemBelow(belowId);\n      if (belowId && !visited.has(belowId)) {\n        visited.add(belowId);\n        if (!isFolder(belowId)) {\n          return treeItems[belowId];\n        }\n      } else {\n        belowId = null; // Stop this direction\n      }\n    }\n\n    // Then try above\n    if (aboveId) {\n      aboveId = getItemAbove(aboveId);\n      if (aboveId && !visited.has(aboveId)) {\n        visited.add(aboveId);\n        if (!isFolder(aboveId)) {\n          return treeItems[aboveId];\n        }\n      } else {\n        aboveId = null; // Stop this direction\n      }\n    }\n  }\n\n  return null;\n};\n\nexport const hasChildrenNode = (parentId: string, nodes: IBaseNodeVo[]): boolean => {\n  const parentNode = nodes.find((node) => node.id === parentId);\n  if (!parentNode) return false;\n\n  // Check if parent has any children\n  if (!parentNode.children || parentNode.children.length === 0) {\n    return false;\n  }\n\n  // Check each child node\n  for (const child of parentNode.children) {\n    const childNode = nodes.find((node) => node.id === child.id);\n    if (!childNode) continue;\n\n    // If child is not a folder, we found a non-folder node\n    if (childNode.resourceType !== BaseNodeResourceType.Folder) {\n      return true;\n    }\n\n    // If child is a folder, recursively check if it has non-folder children\n    if (hasChildrenNode(childNode.id, nodes)) {\n      return true;\n    }\n  }\n\n  return false;\n};\n\nexport const getChildrenNodes = (parentId: string, nodes: IBaseNodeVo[]): IBaseNodeVo[] => {\n  const parentNode = nodes.find((node) => node.id === parentId);\n  if (!parentNode) return [];\n  const children = [];\n  for (const child of parentNode.children ?? []) {\n    const childNode = nodes.find((node) => node.id === child.id);\n    if (!childNode) continue;\n    if (childNode.children && childNode.children.length > 0) {\n      children.push(...getChildrenNodes(childNode.id, nodes));\n    }\n    if (childNode.resourceType === BaseNodeResourceType.Folder) {\n      continue;\n    }\n    children.push(childNode);\n  }\n  return children;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/index.ts",
    "content": "export * from './useBaseNode';\nexport * from './useBaseNodeCrud';\nexport * from './helper';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { getBaseNodeChannel } from '@teable/core';\nimport type { IBaseNodeTreeVo, IBaseNodeVo } from '@teable/openapi';\nimport { BaseNodeResourceType, getBaseNodeTree } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useConnection } from '@teable/sdk/hooks';\nimport { isEmpty, get } from 'lodash';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { buildTreeItems, hasChildrenNode } from './helper';\n\nexport type TreeItemData = Omit<IBaseNodeVo, 'children'> & { children: string[] };\n\nexport const useBaseNode = (baseId: string, isRestrictedAuthority?: boolean) => {\n  const { connection } = useConnection();\n  const channel = getBaseNodeChannel(baseId);\n  const presence = connection?.getPresence(channel);\n  const [nodes, setNodes] = useState<IBaseNodeVo[]>([]);\n  const queryClient = useQueryClient();\n\n  // Initialize treeItems from cache to avoid flash of empty state on remount\n  const [treeItems, setTreeItems] = useState<Record<string, TreeItemData>>(() => {\n    const cachedData = queryClient.getQueryData<IBaseNodeTreeVo>(\n      ReactQueryKeys.baseNodeTree(baseId)\n    );\n    if (cachedData?.nodes && cachedData.nodes.length > 0) {\n      return buildTreeItems(cachedData.nodes);\n    }\n    return {};\n  });\n\n  const { data: queryData, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.baseNodeTree(baseId),\n    queryFn: ({ queryKey }) => getBaseNodeTree(queryKey[1]).then((res) => res.data),\n    enabled: Boolean(baseId),\n  });\n\n  const invalidateMenu = useCallback(() => {\n    if (baseId) {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseNodeTree(baseId) });\n    }\n  }, [baseId, queryClient]);\n\n  const maxFolderDepth = useMemo(() => {\n    return queryData?.maxFolderDepth ?? 2;\n  }, [queryData?.maxFolderDepth]);\n\n  useEffect(() => {\n    if (queryData?.nodes) {\n      setNodes(queryData?.nodes);\n    }\n  }, [queryData?.nodes, setNodes]);\n\n  useEffect(() => {\n    if (nodes.length > 0) {\n      setTreeItems(\n        buildTreeItems(\n          isRestrictedAuthority\n            ? nodes.filter((node) => {\n                if (node.resourceType === BaseNodeResourceType.Folder) {\n                  return hasChildrenNode(node.id, nodes);\n                }\n                return true;\n              })\n            : nodes\n        )\n      );\n    } else {\n      setTreeItems({});\n    }\n  }, [nodes, setTreeItems, isRestrictedAuthority]);\n\n  useEffect(() => {\n    if (!presence || !channel) {\n      return;\n    }\n\n    if (presence.subscribed) {\n      return;\n    }\n\n    presence.subscribe();\n\n    const receiveHandler = () => {\n      const { remotePresences } = presence;\n      if (!isEmpty(remotePresences)) {\n        const remotePayload = get(remotePresences, channel);\n        if (remotePayload) {\n          invalidateMenu();\n        }\n      }\n    };\n\n    presence.on('receive', receiveHandler);\n\n    return () => {\n      presence?.removeListener('receive', receiveHandler);\n      presence?.listenerCount('receive') === 0 && presence?.unsubscribe();\n      presence?.listenerCount('receive') === 0 && presence?.destroy();\n    };\n  }, [connection, presence, channel, setNodes, invalidateMenu]);\n\n  return useMemo(() => {\n    return {\n      isLoading,\n      maxFolderDepth,\n      treeItems,\n      setTreeItems,\n      invalidateMenu,\n    };\n  }, [isLoading, maxFolderDepth, treeItems, setTreeItems, invalidateMenu]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeContext.ts",
    "content": "import { useContext } from 'react';\nimport { BaseNodeContext } from '../BaseNodeContext';\n\nexport const useBaseNodeContext = () => {\n  return useContext(BaseNodeContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeCrud.ts",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport type {\n  IMoveBaseNodeRo,\n  ICreateBaseNodeRo,\n  IDuplicateBaseNodeRo,\n  IUpdateBaseNodeRo,\n  IBaseNodeVo,\n} from '@teable/openapi';\nimport {\n  moveBaseNode,\n  createBaseNode,\n  deleteBaseNode,\n  duplicateBaseNode,\n  updateBaseNode,\n  permanentDeleteBaseNode,\n} from '@teable/openapi';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo } from 'react';\nimport { cleanParentId, getNodeName } from './helper';\nimport { useBaseNodeContext } from './useBaseNodeContext';\n\ninterface IUseBaseNodeCrudOptions {\n  onCreateSuccess?: (node: IBaseNodeVo) => void;\n  onDuplicateSuccess?: (node: IBaseNodeVo) => void;\n  onUpdateSuccess?: (node: IBaseNodeVo) => void;\n  onUpdateError?: (error: unknown, variables: { nodeId: string; ro: IUpdateBaseNodeRo }) => void;\n  onMoveSuccess?: (node: IBaseNodeVo) => void;\n  onMoveError?: (error: unknown, variables: { nodeId: string; ro: IMoveBaseNodeRo }) => void;\n  onDeleteSuccess?: (nodeId: string) => void;\n}\n\nexport const useBaseNodeCrud = (props?: IUseBaseNodeCrudOptions) => {\n  const baseId = useBaseId() as string;\n  const { t } = useTranslation(['table', 'common']);\n\n  const { treeItems, invalidateMenu } = useBaseNodeContext();\n\n  const { mutateAsync: createNodeFn } = useMutation({\n    mutationFn: (ro: ICreateBaseNodeRo) => createBaseNode(baseId, ro).then((res) => res.data),\n    onSuccess: (node) => {\n      props?.onCreateSuccess?.(node);\n    },\n  });\n\n  const { mutateAsync: updateNodeFn } = useMutation({\n    mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IUpdateBaseNodeRo }) =>\n      updateBaseNode(baseId, nodeId, ro).then((res) => res.data),\n    onSuccess: (node) => {\n      props?.onUpdateSuccess?.(node);\n    },\n    onError: (error, variables) => {\n      invalidateMenu();\n      props?.onUpdateError?.(error, variables);\n    },\n  });\n\n  const { mutateAsync: duplicateNodeFn } = useMutation({\n    mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IDuplicateBaseNodeRo }) =>\n      duplicateBaseNode(baseId, nodeId, ro).then((res) => res.data),\n    onSuccess: (node) => {\n      invalidateMenu();\n      props?.onDuplicateSuccess?.(node);\n    },\n  });\n\n  const { mutateAsync: moveNodeFn } = useMutation({\n    mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IMoveBaseNodeRo }) =>\n      moveBaseNode(baseId, nodeId, ro).then((res) => res.data),\n    onSuccess: (node) => {\n      props?.onMoveSuccess?.(node);\n    },\n    onError: (error, variables) => {\n      invalidateMenu();\n      props?.onMoveError?.(error, variables);\n    },\n  });\n\n  const { mutateAsync: deleteNodeFn } = useMutation({\n    mutationFn: ({ nodeId, permanent }: { nodeId: string; permanent?: boolean }) =>\n      permanent ? permanentDeleteBaseNode(baseId, nodeId) : deleteBaseNode(baseId, nodeId),\n    onSuccess: (_, { nodeId }) => {\n      props?.onDeleteSuccess?.(nodeId);\n      invalidateMenu();\n    },\n  });\n\n  const createNode = useCallback(\n    async (params: ICreateBaseNodeRo) => {\n      const { name: rawName, parentId: rawParentId } = params;\n      const parentId = cleanParentId(rawParentId);\n      const name = rawName ?? t('common:untitled');\n      const nodes = Object.values(treeItems);\n      await createNodeFn({\n        ...params,\n        parentId,\n        name: getUniqName(\n          name,\n          nodes.map((node) => getNodeName(node))\n        ),\n      });\n    },\n    [createNodeFn, treeItems, t]\n  );\n\n  return useMemo(() => {\n    return {\n      createNode,\n      duplicateNode: async (nodeId: string, ro: IDuplicateBaseNodeRo) => {\n        return duplicateNodeFn({ nodeId, ro });\n      },\n      updateNode: async (nodeId: string, ro: IUpdateBaseNodeRo) => {\n        return updateNodeFn({ nodeId, ro });\n      },\n      deleteNode: async (nodeId: string, permanent?: boolean) => {\n        return deleteNodeFn({ nodeId, permanent });\n      },\n      moveNode: async (nodeId: string, ro: IMoveBaseNodeRo) => {\n        return moveNodeFn({ nodeId, ro });\n      },\n    };\n  }, [createNode, duplicateNodeFn, updateNodeFn, deleteNodeFn, moveNodeFn]);\n};\n\nexport type BaseNodeCrudHooks = ReturnType<typeof useBaseNodeCrud>;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeAddResourceButton.tsx",
    "content": "import { getUniqName, ViewType } from '@teable/core';\nimport { FileCsv, FileExcel, Slack, Table2 } from '@teable/icons';\nimport type { ICreateBaseNodeRo } from '@teable/openapi';\nimport { BaseNodeResourceType, SUPPORTEDTYPE } from '@teable/openapi';\nimport { useTables } from '@teable/sdk';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { TableImport } from '../../import-table';\nimport { useDefaultFields } from '../../table-list/useAddTable';\nimport { BaseNodeResourceIconMap, ROOT_ID } from '../base-node/hooks';\n\ninterface BaseNodeAddResourceButtonProps {\n  parentId?: string;\n  canCreateFolder?: boolean;\n  canCreateTable?: boolean;\n  canCreateDashboard?: boolean;\n  canCreateWorkflow?: boolean;\n  canCreateApp?: boolean;\n  createNode: (params: ICreateBaseNodeRo) => Promise<void>;\n  children: React.ReactNode;\n}\n\nexport const BaseNodeAddResourceButton = (props: BaseNodeAddResourceButtonProps) => {\n  const {\n    createNode,\n    parentId,\n    canCreateFolder,\n    children,\n    canCreateTable,\n    canCreateDashboard,\n    canCreateWorkflow,\n    canCreateApp,\n  } = props;\n  const { t } = useTranslation(['table', 'common']);\n  const [tableImportdialogVisible, setTableImportdialogVisible] = useState(false);\n  const [fileType, setFileType] = useState<SUPPORTEDTYPE>(SUPPORTEDTYPE.CSV);\n  const importFile = (type: SUPPORTEDTYPE) => {\n    setTableImportdialogVisible(true);\n    setFileType(type);\n  };\n\n  const fieldRos = useDefaultFields();\n  const tables = useTables();\n\n  const AddTableMenuItems = () => {\n    if (!canCreateTable) return null;\n    return (\n      <>\n        <DropdownMenuItem\n          onClick={() => {\n            createNode({\n              resourceType: BaseNodeResourceType.Table,\n              parentId,\n              fields: fieldRos,\n              views: [{ name: t('view.category.table'), type: ViewType.Grid }],\n              name: getUniqName(\n                t('table:table.newTableLabel'),\n                tables.map((table) => table.name)\n              ),\n            });\n          }}\n          className=\"cursor-pointer\"\n        >\n          <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n            <Table2 className=\"size-4\" />\n            {t('table.operator.createBlank')}\n          </Button>\n        </DropdownMenuItem>\n      </>\n    );\n  };\n\n  const AddResourceMenuItems = () => {\n    const list: Array<{\n      resourceType:\n        | BaseNodeResourceType.Workflow\n        | BaseNodeResourceType.App\n        | BaseNodeResourceType.Dashboard\n        | BaseNodeResourceType.Folder;\n      label: string;\n      trailingIcon?: React.ReactNode;\n    }> = [];\n\n    if (canCreateWorkflow) {\n      list.push({\n        resourceType: BaseNodeResourceType.Workflow,\n        label: t('common:noun.newAutomation'),\n        trailingIcon: <Slack className=\"size-4\" />,\n      });\n    }\n    if (canCreateApp) {\n      list.push({\n        resourceType: BaseNodeResourceType.App,\n        label: t('common:noun.newApp'),\n      });\n    }\n    if (canCreateDashboard) {\n      list.push({\n        resourceType: BaseNodeResourceType.Dashboard,\n        label: t('common:noun.dashboard'),\n      });\n    }\n\n    if (canCreateFolder) {\n      list.push({\n        resourceType: BaseNodeResourceType.Folder,\n        label: t('common:noun.newFolder'),\n      });\n    }\n\n    if (list.length === 0) {\n      return null;\n    }\n\n    return list.map((item) => {\n      const { resourceType, label, trailingIcon } = item;\n      const IconComponent = BaseNodeResourceIconMap[resourceType];\n      return (\n        <DropdownMenuItem\n          key={resourceType}\n          className=\"flex cursor-pointer items-center\"\n          onClick={() => {\n            createNode({\n              resourceType,\n              parentId,\n              name: label,\n            });\n          }}\n        >\n          <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n            <IconComponent className=\"size-4\" />\n            {label}\n          </Button>\n          {trailingIcon}\n        </DropdownMenuItem>\n      );\n    });\n  };\n\n  const ImportTableMenuItems = () => {\n    if (!canCreateTable) return null;\n    if (parentId && parentId !== ROOT_ID) return null;\n    return (\n      <>\n        <DropdownMenuSeparator />\n        <DropdownMenuLabel className=\"px-4 text-xs font-normal text-muted-foreground\">\n          {t('table:import.menu.addFromOtherSource')}\n        </DropdownMenuLabel>\n        <DropdownMenuItem className=\"cursor-pointer\" onClick={() => importFile(SUPPORTEDTYPE.CSV)}>\n          <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n            <FileCsv className=\"size-4\" />\n            {t('table:import.menu.csvFile')}\n          </Button>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          className=\"cursor-pointer\"\n          onClick={() => importFile(SUPPORTEDTYPE.EXCEL)}\n        >\n          <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n            <FileExcel className=\"size-4\" />\n            {t('table:import.menu.excelFile')}\n          </Button>\n        </DropdownMenuItem>\n      </>\n    );\n  };\n\n  return (\n    <div className=\"flex w-full flex-col\">\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n        <DropdownMenuContent className=\"w-64\">\n          <AddTableMenuItems />\n          <AddResourceMenuItems />\n          <ImportTableMenuItems />\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {tableImportdialogVisible && (\n        <TableImport\n          fileType={fileType}\n          open={tableImportdialogVisible}\n          onOpenChange={(open) => setTableImportdialogVisible(open)}\n        />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport {\n  Copy,\n  FileCsv,\n  FileExcel,\n  Pencil,\n  History,\n  Code2,\n  Trash2,\n  Download,\n  Share2,\n} from '@teable/icons';\nimport type { IBaseNodeVo, IDuplicateBaseNodeRo } from '@teable/openapi';\nimport { BaseNodeResourceType, SUPPORTEDTYPE } from '@teable/openapi';\nimport { RecordHistory } from '@teable/sdk/components/expand-record/RecordHistory';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission, useTables } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport { useConfirm } from '@teable/ui-lib/base/dialog/confirm-modal';\nimport {\n  Button,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuPortal,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n  Input,\n  Label,\n  Sheet,\n  SheetContent,\n  SheetHeader,\n  SheetTrigger,\n  Switch,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { FileInputIcon } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { useSetting } from '@/features/app/hooks/useSetting';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useDownload } from '../../../hooks/useDownLoad';\nimport { TableImport } from '../../import-table';\nimport { useTableHref } from '../../table-list/useTableHref';\nimport { TableTrash } from '../../trash/components/TableTrash';\nimport { TableTrashDialog } from '../../trash/components/TableTrashDialog';\nimport { APIDialog } from '../../view/tool-bar/APIDialog';\nimport type { TreeItemData } from '../base-node/hooks';\nimport { findAdjacentNonFolderNode, getNodeUrl, useBaseNodeCrud } from '../base-node/hooks';\nimport { useBaseNodeContext } from '../base-node/hooks/useBaseNodeContext';\nimport { NodeShareDialog } from './NodeShareDialog';\n\n// Hook to get nodeId from resourceId\nconst useNodeId = (resourceId: string) => {\n  const { treeItems } = useBaseNodeContext();\n  return useMemo(() => {\n    const node = Object.values(treeItems).find((item) => item.resourceId === resourceId);\n    return node?.id ?? '';\n  }, [treeItems, resourceId]);\n};\n\n// Menu item component for list variant (mobile)\nconst ListMenuItem = ({\n  icon,\n  label,\n  onClick,\n  destructive,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  onClick: () => void;\n  destructive?: boolean;\n}) => (\n  <Button\n    variant=\"ghost\"\n    className={cn(\n      'h-auto w-full justify-start gap-3 rounded-none border-b p-3',\n      destructive && 'text-destructive'\n    )}\n    onClick={onClick}\n  >\n    {icon}\n    <span>{label}</span>\n  </Button>\n);\n\ninterface IBaseNodeMoreProps {\n  children?: React.ReactNode;\n  resourceType: BaseNodeResourceType;\n  resourceId: string;\n\n  className?: string;\n\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n\n  // 'dropdown' for desktop, 'list' for mobile (renders flat list without dropdown wrapper)\n  variant?: 'dropdown' | 'list';\n\n  contentAlign?: 'start' | 'end';\n\n  onRename?: () => void;\n  onDelete?: (permanent: boolean, confirm?: boolean) => Promise<void>;\n  onDuplicate?: (ro?: IDuplicateBaseNodeRo) => Promise<void>;\n\n  // Success callbacks for customizing behavior after operations\n  onCreateSuccess?: (node: IBaseNodeVo) => void;\n  onDeleteSuccess?: (nodeId: string) => void;\n  onDuplicateSuccess?: (node: IBaseNodeVo) => void;\n  onUpdateSuccess?: (node: IBaseNodeVo) => void;\n}\n\ninterface ICommonOperationProps extends IBaseNodeMoreProps {\n  children?: React.ReactNode;\n  canRename?: boolean;\n  canDelete?: boolean;\n  canPermanentDelete?: boolean;\n  canDuplicate?: boolean;\n  canShare?: boolean;\n  nodeTypeLabel?: string; // Node type label (Dashboard/Workflow/App)\n}\n\nconst CommonOperation = (props: ICommonOperationProps) => {\n  const {\n    resourceId,\n    open,\n    setOpen,\n    onRename,\n    onDuplicate,\n    onDelete,\n    children,\n    variant = 'dropdown',\n    contentAlign = 'end',\n    canRename = false,\n    canDelete = false,\n    canPermanentDelete = false,\n    canDuplicate = false,\n    canShare = false,\n    nodeTypeLabel,\n  } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { treeItems } = useBaseNodeContext();\n\n  const [duplicateSetting, setDuplicateSetting] = useState(false);\n  const [shareDialogOpen, setShareDialogOpen] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const nodeId = useNodeId(resourceId);\n\n  // Get node name from treeItems\n  const nodeName = useMemo(() => {\n    const node = Object.values(treeItems).find((n) => n.resourceId === resourceId);\n    return node?.resourceMeta?.name;\n  }, [treeItems, resourceId]);\n\n  const defaultName = useMemo(\n    () => `${nodeName ?? nodeTypeLabel} ${t('space:baseModal.copy')}`,\n    [nodeName, nodeTypeLabel, t]\n  );\n\n  const { mutateAsync: duplicateFn, isPending } = useMutation({\n    mutationFn: async (ro?: IDuplicateBaseNodeRo) => onDuplicate?.(ro),\n    onSuccess: () => setDuplicateSetting(false),\n  });\n\n  const handleDuplicateClick = useCallback(() => {\n    setDuplicateSetting(true);\n  }, []);\n\n  const duplicateDialog = duplicateSetting && canDuplicate && (\n    <ConfirmDialog\n      open={duplicateSetting}\n      onOpenChange={setDuplicateSetting}\n      title={`${t('common:actions.duplicate')} ${nodeName ?? nodeTypeLabel}`}\n      cancelText={t('common:actions.cancel')}\n      confirmText={t('common:actions.duplicate')}\n      confirmLoading={isPending}\n      content={\n        <div className=\"flex flex-col space-y-2 text-sm\">\n          <div className=\"flex flex-col gap-2\">\n            <Label>\n              {nodeTypeLabel} {t('common:name')}\n            </Label>\n            <Input ref={inputRef} defaultValue={defaultName} />\n          </div>\n        </div>\n      }\n      onCancel={() => setDuplicateSetting(false)}\n      onConfirm={async () => {\n        const name = inputRef.current?.value?.trim();\n        if (!name) {\n          toast.error(t('common:name') + ' ' + t('common:required'));\n          return;\n        }\n        await duplicateFn({ name });\n      }}\n    />\n  );\n\n  if (!canRename && !canDelete && !canPermanentDelete && !canDuplicate && !canShare) {\n    return null;\n  }\n\n  // List variant for mobile - renders flat list\n  if (variant === 'list') {\n    return (\n      <>\n        {canRename && (\n          <ListMenuItem\n            icon={<Pencil className=\"size-4\" />}\n            label={t('table:table.rename')}\n            onClick={() => onRename?.()}\n          />\n        )}\n        {canDuplicate && (\n          <ListMenuItem\n            icon={<Copy className=\"size-4\" />}\n            label={t('table:import.menu.duplicate')}\n            onClick={handleDuplicateClick}\n          />\n        )}\n        {canShare && (\n          <ListMenuItem\n            icon={<Share2 className=\"size-4\" />}\n            label={t('common:template.non.share')}\n            onClick={() => setShareDialogOpen(true)}\n          />\n        )}\n        {canPermanentDelete && (\n          <ListMenuItem\n            icon={<Trash2 className=\"size-4\" />}\n            label={t('common:actions.permanentDelete')}\n            onClick={() => onDelete?.(true)}\n            destructive\n          />\n        )}\n        {canDelete && (\n          <ListMenuItem\n            icon={<Trash2 className=\"size-4\" />}\n            label={t('common:actions.delete')}\n            onClick={() => onDelete?.(false)}\n          />\n        )}\n        {duplicateDialog}\n        {canShare && (\n          <NodeShareDialog\n            open={shareDialogOpen}\n            onOpenChange={setShareDialogOpen}\n            nodeId={nodeId}\n          />\n        )}\n      </>\n    );\n  }\n\n  // Dropdown variant for desktop\n  return (\n    <>\n      <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n        <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n        <DropdownMenuContent\n          align={contentAlign}\n          className=\"min-w-[160px]\"\n          onClick={(e) => e.stopPropagation()}\n          onCloseAutoFocus={(e) => e.preventDefault()}\n        >\n          {canRename && (\n            <DropdownMenuItem onClick={() => onRename?.()}>\n              <Pencil className=\"mr-2\" />\n              {t('table:table.rename')}\n            </DropdownMenuItem>\n          )}\n          {canDuplicate && (\n            <DropdownMenuItem onClick={handleDuplicateClick}>\n              <Copy className=\"mr-2\" />\n              {t('table:import.menu.duplicate')}\n            </DropdownMenuItem>\n          )}\n          {canShare && (\n            <DropdownMenuItem onClick={() => setShareDialogOpen(true)}>\n              <Share2 className=\"mr-2 size-4\" />\n              {t('common:template.non.share')}\n            </DropdownMenuItem>\n          )}\n          {canPermanentDelete && (\n            <DropdownMenuItem\n              className=\"text-destructive focus:text-destructive\"\n              onClick={() => onDelete?.(true)}\n            >\n              <Trash2 className=\"mr-2\" />\n              {t('common:actions.permanentDelete')}\n            </DropdownMenuItem>\n          )}\n          {canDelete && (\n            <DropdownMenuItem\n              className=\"text-destructive focus:text-destructive\"\n              onClick={() => onDelete?.(false)}\n            >\n              <Trash2 className=\"mr-2\" />\n              {t('common:actions.delete')}\n            </DropdownMenuItem>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n      {duplicateDialog}\n      {canShare && (\n        <NodeShareDialog open={shareDialogOpen} onOpenChange={setShareDialogOpen} nodeId={nodeId} />\n      )}\n    </>\n  );\n};\n\nexport const DashboardOperation = (props: IBaseNodeMoreProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useBasePermission();\n  const { disallowDashboard } = useSetting();\n  const canRename = Boolean(permission?.['base|update']);\n  const canDelete = false;\n  const canPermanentDelete = Boolean(permission?.['base|delete']);\n  const canDuplicate = Boolean(permission?.['base|update'] && !disallowDashboard);\n  const canShare = Boolean(permission?.['base|update']);\n\n  return (\n    <CommonOperation\n      {...props}\n      nodeTypeLabel={t('common:noun.dashboard')}\n      canRename={canRename}\n      canDelete={canDelete}\n      canPermanentDelete={canPermanentDelete}\n      canDuplicate={canDuplicate}\n      canShare={canShare}\n    />\n  );\n};\n\nexport const WorkflowOperation = (props: IBaseNodeMoreProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useBasePermission();\n  const canRename = Boolean(permission?.['automation|update']);\n  const canDelete = Boolean(permission?.['automation|delete']);\n  const canPermanentDelete = false;\n  const canDuplicate = Boolean(permission?.['automation|create']);\n  const canShare = Boolean(permission?.['base|update']);\n\n  return (\n    <CommonOperation\n      {...props}\n      nodeTypeLabel={t('common:noun.automation')}\n      canRename={canRename}\n      canDelete={canDelete}\n      canPermanentDelete={canPermanentDelete}\n      canDuplicate={canDuplicate}\n      canShare={canShare}\n    />\n  );\n};\n\nexport const AppOperation = (props: IBaseNodeMoreProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useBasePermission();\n\n  const canRename = Boolean(permission?.['app|update']);\n  const canDelete = Boolean(permission?.['app|delete']);\n  const canPermanentDelete = false;\n  const canDuplicate = Boolean(permission?.['app|create']);\n  const canShare = Boolean(permission?.['base|update']);\n\n  return (\n    <CommonOperation\n      {...props}\n      nodeTypeLabel={t('common:noun.app')}\n      canRename={canRename}\n      canDelete={canDelete}\n      canPermanentDelete={canPermanentDelete}\n      canDuplicate={canDuplicate}\n      canShare={canShare}\n    />\n  );\n};\n\nexport const FolderOperation = (props: IBaseNodeMoreProps) => {\n  const { resourceId } = props;\n  const { treeItems } = useBaseNodeContext();\n  const node = useMemo(\n    () => Object.values(treeItems).find((n) => n.resourceId === resourceId),\n    [treeItems, resourceId]\n  );\n  const permission = useBasePermission();\n  const canRename = Boolean(permission?.['base|update']);\n  const canDelete = false;\n  const canPermanentDelete = !node?.children?.length && Boolean(permission?.['base|update']);\n  const canDuplicate = false;\n  const canShare = Boolean(permission?.['base|update']);\n\n  return (\n    <CommonOperation\n      {...props}\n      canRename={canRename}\n      canDelete={canDelete}\n      canPermanentDelete={canPermanentDelete}\n      canDuplicate={canDuplicate}\n      canShare={canShare}\n    />\n  );\n};\n\nexport const TableOperation = (props: IBaseNodeMoreProps) => {\n  const {\n    resourceId,\n    open,\n    setOpen,\n    onRename,\n    children,\n    onDelete,\n    onDuplicate,\n    variant = 'dropdown',\n    contentAlign = 'end',\n  } = props;\n\n  const baseId = useBaseId() as string;\n  const tables = useTables();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const basePermission = useBasePermission();\n  const canTableRecordHistoryRead = basePermission?.['table_record_history|read'];\n  const canTableTrashRead = basePermission?.['table|trash_read'];\n  const nodeId = useNodeId(resourceId);\n\n  const router = useRouter();\n  const [apiDialogOpen, setApiDialogOpen] = useState(false);\n  const [tableHistoryDialogOpen, setTableHistoryDialogOpen] = useState(false);\n  const [tableTrashDialogOpen, setTableTrashDialogOpen] = useState(false);\n  const [deleteConfirm, setDeleteConfirm] = useState(false);\n  const [importVisible, setImportVisible] = useState(false);\n  const [duplicateSetting, setDuplicateSetting] = useState(false);\n  const [importType, setImportType] = useState(SUPPORTEDTYPE.CSV);\n  const [shareDialogOpen, setShareDialogOpen] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const table = useMemo(() => tables.find((t) => t.id === resourceId), [tables, resourceId]);\n  const { trigger } = useDownload({ downloadUrl: `/api/export/${resourceId}`, key: 'table' });\n\n  const defaultTableName = useMemo(\n    () =>\n      getUniqName(\n        `${table?.name} ${t('space:baseModal.copy')}`,\n        tables.map((t) => t.name)\n      ),\n    [t, table?.name, tables]\n  );\n\n  const [duplicateOption, setDuplicateOption] = useState({\n    includeRecords: true,\n  });\n\n  const menuPermission = useMemo(() => {\n    return {\n      deleteTable: table?.permission?.['table|delete'],\n      updateTable: table?.permission?.['table|update'],\n      duplicateTable: table?.permission?.['table|read'] && basePermission?.['table|create'],\n      exportTable: table?.permission?.['table|export'],\n      importTable: table?.permission?.['table|import'],\n      tableRecordHistory: canTableRecordHistoryRead,\n      tableTrash: canTableTrashRead,\n      shareTable: basePermission?.['base|update'],\n    };\n  }, [basePermission, table?.permission, canTableRecordHistoryRead, canTableTrashRead]);\n\n  const deleteTable = async (permanent: boolean) => {\n    if (!resourceId) return;\n    await onDelete?.(permanent, false);\n    setDeleteConfirm(false);\n    queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(baseId as string) });\n  };\n\n  const { mutateAsync: duplicateTableFn, isPending: isLoading } = useMutation({\n    mutationFn: async (ro?: IDuplicateBaseNodeRo) => onDuplicate?.(ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.tableList(baseId as string),\n      });\n      setDuplicateSetting(false);\n    },\n  });\n\n  const onRecordClick = (recordId: string) => {\n    router.push(\n      {\n        pathname: router.pathname,\n        query: { ...router.query, recordId },\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  };\n\n  if (!table) {\n    return null;\n  }\n\n  if (!Object.values(menuPermission).some(Boolean)) {\n    return null;\n  }\n\n  // Dialogs - shared between both variants\n  const dialogs = (\n    <>\n      {importVisible && (\n        <TableImport\n          open={importVisible}\n          tableId={resourceId}\n          fileType={importType}\n          onOpenChange={(visible: boolean) => setImportVisible(visible)}\n        />\n      )}\n\n      {deleteConfirm && (\n        <ConfirmDialog\n          open={deleteConfirm}\n          onOpenChange={setDeleteConfirm}\n          title={t('table:table.deleteConfirm', { tableName: table?.name })}\n          content={\n            <>\n              <div className=\"space-y-2 text-sm\">\n                <p>{t('table:table.deleteTip1')}</p>\n                <p>{t('common:trash.description')}</p>\n              </div>\n              <DialogFooter>\n                <Button size={'sm'} variant={'ghost'} onClick={() => setDeleteConfirm(false)}>\n                  {t('common:actions.cancel')}\n                </Button>\n                <Button size={'sm'} onClick={() => deleteTable(false)}>\n                  {t('common:trash.addToTrash')}\n                </Button>\n              </DialogFooter>\n            </>\n          }\n        />\n      )}\n\n      {duplicateSetting && (\n        <ConfirmDialog\n          open={duplicateSetting}\n          onOpenChange={setDuplicateSetting}\n          title={`${t('common:actions.duplicate')} ${table?.name}`}\n          cancelText={t('common:actions.cancel')}\n          confirmText={t('common:actions.duplicate')}\n          confirmLoading={isLoading}\n          content={\n            <div className=\"flex flex-col space-y-2 text-sm\">\n              <div className=\"flex flex-col gap-2\">\n                <Label>\n                  {t('common:noun.table')} {t('common:name')}\n                </Label>\n                <Input ref={inputRef} defaultValue={defaultTableName} />\n              </div>\n              <div className=\"flex items-center gap-1\">\n                <Switch\n                  id=\"include-record\"\n                  checked={duplicateOption.includeRecords}\n                  onCheckedChange={(val) => {\n                    setDuplicateOption((prev) => ({ ...prev, includeRecords: val }));\n                  }}\n                />\n                <Label htmlFor=\"include-record\">{t('table:import.menu.includeRecords')}</Label>\n              </div>\n            </div>\n          }\n          onCancel={() => setDuplicateSetting(false)}\n          onConfirm={async () => {\n            await duplicateTableFn({\n              name: inputRef.current?.value?.trim() || defaultTableName,\n              includeRecords: duplicateOption.includeRecords,\n            });\n          }}\n        />\n      )}\n\n      {menuPermission.tableRecordHistory && (\n        <Dialog open={tableHistoryDialogOpen} onOpenChange={setTableHistoryDialogOpen}>\n          <DialogContent className=\"flex h-[90%] max-w-4xl flex-col gap-0 p-0\">\n            <DialogHeader className=\"border-b p-4\">\n              <DialogTitle>{t('table:table.tableRecordHistory')}</DialogTitle>\n            </DialogHeader>\n            <RecordHistory tableId={resourceId} onRecordClick={onRecordClick} />\n          </DialogContent>\n        </Dialog>\n      )}\n\n      {menuPermission.tableTrash && (\n        <TableTrashDialog\n          open={tableTrashDialogOpen}\n          onOpenChange={setTableTrashDialogOpen}\n          tableId={resourceId}\n        />\n      )}\n\n      {apiDialogOpen && (\n        <APIDialog open={apiDialogOpen} setOpen={setApiDialogOpen}>\n          <span className=\"hidden text-sm\">API</span>\n        </APIDialog>\n      )}\n\n      {menuPermission.shareTable && (\n        <NodeShareDialog open={shareDialogOpen} onOpenChange={setShareDialogOpen} nodeId={nodeId} />\n      )}\n    </>\n  );\n\n  // List variant for mobile - renders flat list without dropdown wrapper\n  if (variant === 'list') {\n    return (\n      <>\n        {menuPermission.duplicateTable && (\n          <ListMenuItem\n            icon={<Copy className=\"size-4\" />}\n            label={t('table:import.menu.duplicate')}\n            onClick={() => setDuplicateSetting(true)}\n          />\n        )}\n        {menuPermission.exportTable && (\n          <ListMenuItem\n            icon={<Download className=\"size-4\" />}\n            label={t('table:import.menu.downAsCsv')}\n            onClick={() => trigger?.()}\n          />\n        )}\n        {menuPermission.importTable && (\n          <>\n            <ListMenuItem\n              icon={<FileCsv className=\"size-4\" />}\n              label={t('table:import.menu.importCsvData')}\n              onClick={() => {\n                setImportVisible(true);\n                setImportType(SUPPORTEDTYPE.CSV);\n              }}\n            />\n            <ListMenuItem\n              icon={<FileExcel className=\"size-4\" />}\n              label={t('table:import.menu.importExcelData')}\n              onClick={() => {\n                setImportVisible(true);\n                setImportType(SUPPORTEDTYPE.EXCEL);\n              }}\n            />\n          </>\n        )}\n        <ListMenuItem\n          icon={<Code2 className=\"size-4\" />}\n          label=\"API\"\n          onClick={() => setApiDialogOpen(true)}\n        />\n        {menuPermission.tableRecordHistory && (\n          <Sheet modal={true}>\n            <SheetTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                className=\"h-auto w-full justify-start gap-3 rounded-none border-b p-3\"\n              >\n                <History className=\"size-4\" />\n                <span>{t('table:table.tableRecordHistory')}</span>\n              </Button>\n            </SheetTrigger>\n            <SheetContent\n              className=\"h-5/6 overflow-hidden rounded-t-lg p-0\"\n              side=\"bottom\"\n              closeable={false}\n            >\n              <SheetHeader className=\"h-16 justify-center border-b text-2xl\">\n                {t('table:table.tableRecordHistory')}\n              </SheetHeader>\n              <RecordHistory tableId={resourceId} onRecordClick={onRecordClick} />\n            </SheetContent>\n          </Sheet>\n        )}\n        {menuPermission.tableTrash && (\n          <Sheet modal={true}>\n            <SheetTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                className=\"h-auto w-full justify-start gap-3 rounded-none border-b p-3\"\n              >\n                <Trash2 className=\"size-4\" />\n                <span>{t('table:tableTrash.title')}</span>\n              </Button>\n            </SheetTrigger>\n            <SheetContent\n              className=\"h-5/6 overflow-hidden rounded-t-lg p-0\"\n              side=\"bottom\"\n              closeable={false}\n            >\n              <SheetHeader className=\"h-16 justify-center border-b text-2xl\">\n                {t('table:tableTrash.title')}\n              </SheetHeader>\n              <TableTrash tableId={resourceId} />\n            </SheetContent>\n          </Sheet>\n        )}\n        {menuPermission.shareTable && (\n          <ListMenuItem\n            icon={<Share2 className=\"size-4\" />}\n            label={t('common:template.non.share')}\n            onClick={() => setShareDialogOpen(true)}\n          />\n        )}\n        {menuPermission.deleteTable && (\n          <ListMenuItem\n            icon={<Trash2 className=\"size-4\" />}\n            label={t('common:actions.delete')}\n            onClick={() => setDeleteConfirm(true)}\n            destructive\n          />\n        )}\n        {dialogs}\n      </>\n    );\n  }\n\n  // Dropdown variant for desktop\n  return (\n    <>\n      <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n        <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n        <DropdownMenuContent\n          align={contentAlign}\n          className=\"min-w-[160px]\"\n          onClick={(e) => e.stopPropagation()}\n          onCloseAutoFocus={(e) => e.preventDefault()}\n        >\n          {menuPermission.updateTable && (\n            <DropdownMenuItem onClick={() => onRename?.()}>\n              <Pencil className=\"mr-2 size-4\" />\n              {t('table:table.rename')}\n            </DropdownMenuItem>\n          )}\n          {menuPermission.duplicateTable && (\n            <DropdownMenuItem onClick={() => setDuplicateSetting(true)}>\n              <Copy className=\"mr-2 size-4\" />\n              {t('table:import.menu.duplicate')}\n            </DropdownMenuItem>\n          )}\n          {(menuPermission.updateTable || menuPermission.duplicateTable) &&\n            menuPermission.exportTable && <DropdownMenuSeparator />}\n\n          {menuPermission.exportTable && (\n            <DropdownMenuItem onClick={() => trigger?.()}>\n              <Download className=\"mr-2 size-4\" />\n              {t('table:import.menu.downAsCsv')}\n            </DropdownMenuItem>\n          )}\n          {menuPermission.importTable && (\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger>\n                <FileInputIcon className=\"mr-2 size-4\" />\n                <span>{t('table:import.menu.importData')}</span>\n              </DropdownMenuSubTrigger>\n              <DropdownMenuPortal>\n                <DropdownMenuSubContent>\n                  <DropdownMenuItem\n                    onClick={() => {\n                      setImportVisible(true);\n                      setImportType(SUPPORTEDTYPE.CSV);\n                    }}\n                  >\n                    <FileCsv className=\"mr-2 size-4\" />\n                    <span>{t('table:import.menu.csvFile')}</span>\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    onClick={() => {\n                      setImportVisible(true);\n                      setImportType(SUPPORTEDTYPE.EXCEL);\n                    }}\n                  >\n                    <FileExcel className=\"mr-2 size-4\" />\n                    <span>{t('table:import.menu.excelFile')}</span>\n                  </DropdownMenuItem>\n                </DropdownMenuSubContent>\n              </DropdownMenuPortal>\n            </DropdownMenuSub>\n          )}\n\n          <DropdownMenuItem onClick={() => setApiDialogOpen(true)}>\n            <Code2 className=\"mr-2 size-4\" />\n            API\n          </DropdownMenuItem>\n\n          {(menuPermission.tableRecordHistory || menuPermission.tableTrash) && (\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger>\n                <History className=\"mr-2 size-4\" />\n                <span>{t('sdk:noun.history')}</span>\n              </DropdownMenuSubTrigger>\n              <DropdownMenuPortal>\n                <DropdownMenuSubContent>\n                  {menuPermission.tableRecordHistory && (\n                    <DropdownMenuItem\n                      onClick={() => {\n                        setTableHistoryDialogOpen(true);\n                      }}\n                    >\n                      <History className=\"mr-1 size-4\" />\n                      {t('table:table.tableRecordHistory')}\n                    </DropdownMenuItem>\n                  )}\n                  {menuPermission.tableTrash && (\n                    <DropdownMenuItem\n                      onClick={() => {\n                        setTableTrashDialogOpen(true);\n                      }}\n                    >\n                      <Trash2 className=\"mr-1 size-4\" />\n                      {t('table:tableTrash.title')}\n                    </DropdownMenuItem>\n                  )}\n                </DropdownMenuSubContent>\n              </DropdownMenuPortal>\n            </DropdownMenuSub>\n          )}\n\n          {menuPermission.shareTable && (\n            <DropdownMenuItem onClick={() => setShareDialogOpen(true)}>\n              <Share2 className=\"mr-2 size-4\" />\n              {t('common:template.non.share')}\n            </DropdownMenuItem>\n          )}\n\n          {menuPermission.deleteTable && (\n            <DropdownMenuItem\n              className=\"text-destructive focus:text-destructive\"\n              onClick={() => setDeleteConfirm(true)}\n            >\n              <Trash2 className=\"mr-2 size-4\" />\n              {t('common:actions.delete')}\n            </DropdownMenuItem>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {dialogs}\n    </>\n  );\n};\n\nconst getNode = (treeItems: Record<string, TreeItemData>, resourceId: string) => {\n  return Object.values(treeItems).find((node) => node.resourceId === resourceId);\n};\n\nexport const BaseNodeMore = (props: IBaseNodeMoreProps) => {\n  const {\n    resourceType,\n    resourceId,\n    children,\n    onDelete,\n    onDuplicate,\n    onCreateSuccess: onCreateSuccessProp,\n    onDeleteSuccess: onDeleteSuccessProp,\n    onDuplicateSuccess: onDuplicateSuccessProp,\n    onUpdateSuccess: onUpdateSuccessProp,\n    ...rest\n  } = props;\n  const { confirm: comfirmModal } = useConfirm();\n  const { t } = useTranslation('common');\n  const router = useRouter();\n  const { treeItems } = useBaseNodeContext();\n  const { hrefMap: tableHrefMap, viewIdMap: tableViewIdsMap } = useTableHref();\n  const queryClient = useQueryClient();\n  const baseResource = useBaseResource();\n\n  const currentResourceId = useMemo(() => {\n    switch (baseResource.resourceType) {\n      case BaseNodeResourceType.Table:\n        return baseResource.tableId;\n      case BaseNodeResourceType.Dashboard:\n        return baseResource.dashboardId;\n      case BaseNodeResourceType.Workflow:\n        return baseResource.workflowId;\n      case BaseNodeResourceType.App:\n        return baseResource.appId;\n      default:\n        return undefined;\n    }\n  }, [baseResource]);\n  const { baseId } = baseResource;\n\n  const createSuccefulyCallback = useCallback(\n    (node: IBaseNodeVo) => {\n      const { resourceType, resourceId, resourceMeta } = node;\n      const viewId =\n        resourceType === BaseNodeResourceType.Table ? resourceMeta?.defaultViewId : undefined;\n\n      const url = getNodeUrl({\n        baseId,\n        resourceType,\n        resourceId,\n        viewId,\n      });\n      if (url) {\n        if (resourceType === BaseNodeResourceType.Table) {\n          router.push(url, undefined, { shallow: Boolean(viewId) });\n        } else {\n          router.push(url, undefined, { shallow: true });\n        }\n      }\n    },\n    [baseId, router]\n  );\n\n  const duplicateSuccessCallback = useCallback(\n    (node: IBaseNodeVo) => {\n      const { resourceType, resourceId, resourceMeta } = node;\n      const viewId =\n        resourceType === BaseNodeResourceType.Table ? resourceMeta?.defaultViewId : undefined;\n      const url = getNodeUrl({\n        baseId,\n        resourceType,\n        resourceId,\n        viewId,\n      });\n      if (url) {\n        if (resourceType === BaseNodeResourceType.Table) {\n          router.push(url, undefined, { shallow: Boolean(viewId) });\n        } else {\n          router.push(url, undefined, { shallow: true });\n        }\n      }\n    },\n    [baseId, router]\n  );\n\n  const deleteSuccessCallback = useCallback(\n    (nodeId: string) => {\n      if (resourceId !== currentResourceId) {\n        return;\n      }\n\n      const adjacentNode = findAdjacentNonFolderNode(treeItems, nodeId);\n      if (!adjacentNode) {\n        router.push(`/base/${baseId}`, undefined, { shallow: true });\n        return;\n      }\n\n      const { resourceType: adjResourceType, resourceId: adjResourceId } = adjacentNode;\n      if (adjResourceType === BaseNodeResourceType.Table) {\n        const viewId = tableViewIdsMap[adjResourceId];\n        const url = tableHrefMap[adjResourceId];\n        if (url) {\n          router.push({ pathname: url }, undefined, {\n            shallow: Boolean(viewId),\n          });\n          return;\n        }\n      }\n\n      const url = getNodeUrl({\n        baseId,\n        resourceType: adjResourceType,\n        resourceId: adjResourceId,\n      });\n      if (url) {\n        router.push(url, undefined, { shallow: true });\n      }\n    },\n    [resourceId, currentResourceId, treeItems, baseId, router, tableHrefMap, tableViewIdsMap]\n  );\n\n  const updateSuccefulyCallback = useCallback(\n    (node: IBaseNodeVo) => {\n      const { resourceType, resourceId } = node;\n      switch (resourceType) {\n        case BaseNodeResourceType.Dashboard:\n          queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(resourceId) });\n          break;\n        case BaseNodeResourceType.Workflow:\n          queryClient.invalidateQueries({\n            queryKey: ReactQueryKeys.workflowItem(baseId, resourceId),\n          });\n          break;\n        case BaseNodeResourceType.App:\n          queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getApp(baseId, resourceId) });\n          break;\n      }\n    },\n    [baseId, queryClient]\n  );\n\n  const curdHooks = useBaseNodeCrud({\n    onDuplicateSuccess: onDuplicateSuccessProp ?? duplicateSuccessCallback,\n    onDeleteSuccess: onDeleteSuccessProp ?? deleteSuccessCallback,\n    onCreateSuccess: onCreateSuccessProp ?? createSuccefulyCallback,\n    onUpdateSuccess: onUpdateSuccessProp ?? updateSuccefulyCallback,\n  });\n\n  const mergedProps: IBaseNodeMoreProps = {\n    ...rest,\n    resourceType,\n    resourceId,\n    onDelete:\n      onDelete ??\n      (async (permanent: boolean, confirm: boolean = true) => {\n        const node = getNode(treeItems, resourceId);\n        if (!node) return;\n        const nodeName = node.resourceMeta?.name;\n        const titleMap = {\n          [BaseNodeResourceType.Folder]: t('noun.folder'),\n          [BaseNodeResourceType.Table]: t('noun.table'),\n          [BaseNodeResourceType.Dashboard]: t('noun.dashboard'),\n          [BaseNodeResourceType.Workflow]: t('noun.automation'),\n          [BaseNodeResourceType.App]: t('noun.app'),\n        };\n        const result = !confirm\n          ? true\n          : await comfirmModal({\n              title: `${t('actions.delete')} ${titleMap[resourceType]?.toLowerCase()}`,\n              description: t('actions.deleteTip', {\n                name: nodeName,\n              }),\n              confirmText: permanent ? t('actions.delete') : t('trash.addToTrash'),\n              cancelText: t('actions.cancel'),\n              confirmButtonVariant: permanent ? 'destructive' : 'default',\n            });\n        if (result) {\n          await curdHooks.deleteNode(node.id, permanent);\n        }\n      }),\n    onDuplicate:\n      onDuplicate ??\n      (async (ro) => {\n        const node = getNode(treeItems, resourceId);\n        if (!node) return;\n        await curdHooks.duplicateNode(node.id, ro ?? {});\n      }),\n  };\n\n  switch (resourceType) {\n    case BaseNodeResourceType.Table:\n      return <TableOperation {...mergedProps}>{children}</TableOperation>;\n    case BaseNodeResourceType.Dashboard:\n      return <DashboardOperation {...mergedProps}>{children}</DashboardOperation>;\n    case BaseNodeResourceType.Workflow:\n      return <WorkflowOperation {...mergedProps}>{children}</WorkflowOperation>;\n    case BaseNodeResourceType.App:\n      return <AppOperation {...mergedProps}>{children}</AppOperation>;\n    case BaseNodeResourceType.Folder:\n      return <FolderOperation {...mergedProps}>{children}</FolderOperation>;\n    default:\n      return null;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeShareIndicator.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Share2 } from '@teable/icons';\nimport type { IBaseNodeAppResourceMeta } from '@teable/openapi';\nimport { BaseNodeResourceType, listBaseShare } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useShareUrlPrefix } from '@/features/app/context/ShareContext';\nimport type { TreeItemData } from '../base-node/hooks';\n\n// Hook to get all shared node IDs for the current base\nexport const useSharedNodeIds = () => {\n  const baseId = useBaseId();\n  const shareUrlPrefix = useShareUrlPrefix();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  // Don't fetch share list when in share mode (viewing shared page) or template mode\n  const { data: shareList } = useQuery({\n    queryKey: ReactQueryKeys.baseShareList(baseId as string),\n    queryFn: () => listBaseShare(baseId as string).then((res) => res.data),\n    enabled: !!baseId && !shareUrlPrefix && !isReadOnlyPreview,\n  });\n\n  return useMemo(() => {\n    if (!shareList) return new Set<string>();\n    return new Set(shareList.map((share) => share.nodeId));\n  }, [shareList]);\n};\n\ninterface IBaseNodeShareIndicatorProps {\n  nodeId: string;\n  sharedNodeIds: Set<string>;\n  node?: TreeItemData;\n  className?: string;\n}\n\nexport const BaseNodeShareIndicator = ({\n  nodeId,\n  sharedNodeIds,\n  node,\n  className,\n}: IBaseNodeShareIndicatorProps) => {\n  const { t } = useTranslation(['table']);\n\n  // For App nodes, check if it's published (has publicUrl) instead of checking sharedNodeIds\n  const isAppNode = node?.resourceType === BaseNodeResourceType.App;\n  const isAppPublished = isAppNode\n    ? !!(node?.resourceMeta as IBaseNodeAppResourceMeta)?.publicUrl\n    : false;\n\n  // Show indicator for App nodes if published, or for other nodes if they have a share\n  const shouldShow = isAppNode ? isAppPublished : sharedNodeIds.has(nodeId);\n\n  if (!shouldShow) {\n    return null;\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            className={cn(\n              'flex size-4 shrink-0 items-center justify-center text-muted-foreground',\n              className\n            )}\n          >\n            <Share2 className=\"size-3.5\" />\n          </span>\n        </TooltipTrigger>\n        <TooltipContent side=\"top\">\n          <p>{isAppNode ? t('table:baseShare.appPublished') : t('table:baseShare.sharedNode')}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeStarButton.tsx",
    "content": "import { BaseNodeResourceType, PinType } from '@teable/openapi';\nimport { useIsAnonymous, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useMemo } from 'react';\nimport { StarButton } from '../../space/space-side-bar/StarButton';\n\ninterface IBaseNodeStarButtonProps {\n  resourceType: BaseNodeResourceType;\n  resourceId: string;\n  className?: string;\n}\n\nexport const BaseNodeStarButton = (props: IBaseNodeStarButtonProps) => {\n  const { resourceType, resourceId, className } = props;\n  const isAnonymous = useIsAnonymous();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const pinType = useMemo(() => {\n    switch (resourceType) {\n      case BaseNodeResourceType.Table:\n        return PinType.Table;\n      case BaseNodeResourceType.Dashboard:\n        return PinType.Dashboard;\n      case BaseNodeResourceType.Workflow:\n        return PinType.Workflow;\n      case BaseNodeResourceType.App:\n        return PinType.App;\n      default:\n        return null;\n    }\n  }, [resourceType]);\n\n  if (!pinType || isAnonymous || isReadOnlyPreview) {\n    return null;\n  }\n\n  return <StarButton id={resourceId} type={pinType} className={cn('size-3.5', className)} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx",
    "content": "'use client';\n\nimport { useQueryClient } from '@tanstack/react-query';\nimport { MoreHorizontal } from '@teable/icons';\nimport type {\n  IBaseNodeVo,\n  IBaseNodeWorkflowResourceMeta,\n  IBaseNodeAppResourceMeta,\n} from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { LocalStorageKeys, ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport {\n  AssistiveTreeDescription,\n  createOnDropHandler,\n  dragAndDropFeature,\n  hotkeysCoreFeature,\n  keyboardDragAndDropFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n  useTree,\n} from '@teable/ui-lib/base/headless-tree';\nimport type { DragTarget, ItemInstance } from '@teable/ui-lib/base/headless-tree';\nimport AddBoldIcon from '@teable/ui-lib/icons/app/add-bold.svg';\nimport { Button, cn, Input, Skeleton } from '@teable/ui-lib/shadcn';\nimport { ScrollArea, ScrollBar } from '@teable/ui-lib/shadcn/ui/scroll-area';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn/ui/tooltip';\nimport { Tree, TreeDragLine, TreeItem, TreeItemLabel } from '@teable/ui-lib/shadcn/ui/tree';\nimport { ChevronDownIcon } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { useClickAway, useLocalStorage } from 'react-use';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { EmojiPicker } from '@/features/app/components/emoji/EmojiPicker';\nimport { useShareUrlPrefix } from '@/features/app/context/ShareContext';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction';\nimport { useIsCommunity } from '@/features/app/hooks/useIsCommunity';\nimport { useSetting } from '@/features/app/hooks/useSetting';\nimport { usePinMap } from '../../space/usePinMap';\nimport { useTableHref } from '../../table-list/useTableHref';\nimport { useGridSearchStore } from '../../view/grid/useGridSearchStore';\nimport {\n  BaseNodeResourceIconMap,\n  getNodeIcon,\n  getNodeName,\n  getNodeUrl,\n  ROOT_ID,\n  useBaseNodeCrud,\n} from '../base-node/hooks';\nimport type { TreeItemData } from '../base-node/hooks';\nimport { useBaseNodeContext } from '../base-node/hooks/useBaseNodeContext';\nimport { BaseNodeAddResourceButton } from './BaseNodeAddResourceButton';\nimport { BaseNodeMore } from './BaseNodeMore';\nimport { BaseNodeShareIndicator, useSharedNodeIds } from './BaseNodeShareIndicator';\nimport { BaseNodeStarButton } from './BaseNodeStarButton';\n\nconst INDENTATION_WIDTH = 24;\nconst GROUP_ACTIVE_WIDTH_CLS =\n  'group-hover:w-auto group-has-[[data-state=open]]:w-auto group-data-[context-menu]:w-auto';\nconst GROUP_ACTIVE_OPACITY_CLS =\n  'group-hover:opacity-100 group-has-[[data-state=open]]:opacity-100 group-data-[context-menu]:opacity-100';\nconst GROUP_ACTIVE_HIDDEN_CLS =\n  'group-hover:hidden group-has-[[data-state=open]]:hidden group-data-[context-menu]:hidden';\nconst SCROLL_EDGE_THRESHOLD = 60; // pixels from edge to trigger scroll\nconst SCROLL_MAX_SPEED = 15; // max pixels per frame\n\n// Custom hook for auto-scroll during drag\nconst useDragAutoScroll = (viewportRef: React.RefObject<HTMLDivElement | null>) => {\n  const rafRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n\n    let scrollSpeed = 0;\n\n    const scroll = () => {\n      if (scrollSpeed !== 0) {\n        viewport.scrollTop += scrollSpeed;\n        rafRef.current = requestAnimationFrame(scroll);\n      } else {\n        rafRef.current = null;\n      }\n    };\n\n    const handleDragOver = (e: DragEvent) => {\n      const rect = viewport.getBoundingClientRect();\n      const y = e.clientY;\n      const distanceFromTop = y - rect.top;\n      const distanceFromBottom = rect.bottom - y;\n\n      if (distanceFromTop < SCROLL_EDGE_THRESHOLD) {\n        // Accelerate based on proximity to edge\n        const ratio = 1 - distanceFromTop / SCROLL_EDGE_THRESHOLD;\n        scrollSpeed = -Math.round(SCROLL_MAX_SPEED * ratio);\n        if (!rafRef.current) rafRef.current = requestAnimationFrame(scroll);\n      } else if (distanceFromBottom < SCROLL_EDGE_THRESHOLD) {\n        const ratio = 1 - distanceFromBottom / SCROLL_EDGE_THRESHOLD;\n        scrollSpeed = Math.round(SCROLL_MAX_SPEED * ratio);\n        if (!rafRef.current) rafRef.current = requestAnimationFrame(scroll);\n      } else {\n        scrollSpeed = 0;\n      }\n    };\n\n    const stopScroll = () => {\n      scrollSpeed = 0;\n      if (rafRef.current) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n    };\n\n    viewport.addEventListener('dragover', handleDragOver);\n    viewport.addEventListener('dragend', stopScroll);\n    viewport.addEventListener('drop', stopScroll);\n\n    return () => {\n      viewport.removeEventListener('dragover', handleDragOver);\n      viewport.removeEventListener('dragend', stopScroll);\n      viewport.removeEventListener('drop', stopScroll);\n      stopScroll();\n    };\n  }, [viewportRef]);\n};\n\ntype TreeMode = 'view' | 'edit';\n\ninterface IBaseNodeTreeProps {\n  mode?: TreeMode;\n  emptyText?: string;\n  skeleton?: React.ReactNode;\n  onPrimaryAction?: (item: ItemInstance<TreeItemData>) => void;\n}\n\nexport const BaseNodeTree = (props: IBaseNodeTreeProps) => {\n  const { mode = 'edit', emptyText, onPrimaryAction } = props;\n  const isEditMode = mode === 'edit';\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(['common']);\n  const baseId = useBaseId() as string;\n  const router = useRouter();\n  const baseResource = useBaseResource();\n  const { highlightedTableId } = useGridSearchStore();\n  const { hrefMap: tableHrefMap, viewIdMap: tableViewIdsMap } = useTableHref();\n  const permission = useBasePermission();\n  const { aiChat: aiChatEnabled } = useDisableAIAction();\n  const { disallowDashboard } = useSetting();\n  const pinMap = usePinMap();\n  const isCommunity = useIsCommunity();\n  const shareUrlPrefix = useShareUrlPrefix();\n  const canCreateTable = Boolean(permission?.['table|create']);\n  const canCreateDashboard = Boolean(permission?.['base|update'] && !disallowDashboard);\n  const canCreateWorkflow = !isCommunity && Boolean(permission?.['automation|create']);\n  const canCreateApp = !isCommunity && Boolean(aiChatEnabled && permission?.['app|create']);\n  const canCreateFolder = Boolean(permission?.['base|update']);\n  const canUpdateTable = Boolean(permission?.['table|update']);\n\n  const canCreateResource =\n    isEditMode &&\n    Boolean(\n      canCreateTable || canCreateDashboard || canCreateWorkflow || canCreateApp || canCreateFolder\n    );\n  const canMoveNode = isEditMode && Boolean(permission?.['base|update']);\n  const sharedNodeIds = useSharedNodeIds();\n\n  const { isLoading, maxFolderDepth, treeItems, setTreeItems } = useBaseNodeContext();\n  const [editingNodeId, setEditingNodeId] = useState<string | null>(null);\n  const [contextMenu, setContextMenu] = useState<{\n    x: number;\n    y: number;\n    nodeId: string;\n    resourceType: BaseNodeResourceType;\n    resourceId: string;\n  } | null>(null);\n  const [contextMenuOpen, setContextMenuOpen] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const draggedItemsRef = useRef<ItemInstance<TreeItemData>[]>([]);\n  const treeItemsRef = useRef(treeItems);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n  const [expandedItemsMap, setExpandedItemsMap] = useLocalStorage<Record<string, string[]>>(\n    LocalStorageKeys.BaseNodeTreeExpandedItems,\n    {}\n  );\n  const [expandedItems, setExpandedItems] = useState<string[]>(expandedItemsMap?.[baseId] ?? []);\n  useEffect(() => {\n    setExpandedItemsMap((prev) => {\n      return {\n        ...prev,\n        [baseId]: expandedItems,\n      };\n    });\n  }, [expandedItems, baseId, setExpandedItemsMap]);\n\n  const handlePrimaryAction = useCallback(\n    (item: ItemInstance<TreeItemData>) => {\n      if (onPrimaryAction) {\n        onPrimaryAction(item);\n        return;\n      }\n      const node = item.getItemData();\n      const { resourceType, resourceId } = node;\n      if (resourceType === BaseNodeResourceType.Table) {\n        const viewId = tableViewIdsMap[resourceId];\n        const url = tableHrefMap[resourceId];\n        if (url) {\n          router.push({ pathname: url }, undefined, {\n            shallow: Boolean(viewId),\n          });\n          return;\n        }\n      }\n\n      const url = getNodeUrl({\n        baseId,\n        resourceType,\n        resourceId,\n        urlPrefix: shareUrlPrefix,\n      });\n      if (!url) return;\n      router.push(url, undefined, {\n        shallow: true,\n      });\n    },\n    [baseId, router, tableHrefMap, tableViewIdsMap, onPrimaryAction, shareUrlPrefix]\n  );\n\n  const handleDrop = (items: ItemInstance<TreeItemData>[], target: DragTarget<TreeItemData>) => {\n    const handler = createOnDropHandler<TreeItemData>((parentItem, newChildrenIds) => {\n      setTreeItems((prevItems) => ({\n        ...prevItems,\n        [parentItem.getId()]: {\n          ...prevItems[parentItem.getId()],\n          children: newChildrenIds,\n        },\n      }));\n\n      if (draggedItemsRef.current.length > 0) {\n        const draggedItem = draggedItemsRef.current[0];\n        const draggedNodeId = draggedItem.getId();\n        const newIndex = newChildrenIds.indexOf(draggedNodeId);\n\n        if (newIndex !== -1) {\n          const parentId = parentItem.getId() === ROOT_ID ? null : parentItem.getId();\n          let anchorId: string | undefined;\n          let position: 'before' | 'after' | undefined;\n\n          if (newIndex > 0 && newChildrenIds[newIndex - 1]) {\n            anchorId = newChildrenIds[newIndex - 1];\n            position = 'after';\n          } else if (newChildrenIds[newIndex + 1]) {\n            anchorId = newChildrenIds[newIndex + 1];\n            position = 'before';\n          }\n          curdHooks.moveNode(draggedNodeId, {\n            parentId: anchorId ? undefined : parentId,\n            anchorId,\n            position,\n          });\n        }\n      }\n    });\n    if (!canMoveNode) return Promise.resolve();\n    draggedItemsRef.current = items;\n    return handler(items, target);\n  };\n\n  const tree = useTree<TreeItemData>({\n    state: {\n      selectedItems,\n      expandedItems,\n    },\n    setSelectedItems,\n    setExpandedItems,\n    rootItemId: ROOT_ID,\n    indent: INDENTATION_WIDTH,\n    dataLoader: {\n      getItem: (itemId) => treeItemsRef.current[itemId] ?? {},\n      getChildren: (itemId) => treeItemsRef.current[itemId]?.children ?? [],\n    },\n    getItemName: (item) => getNodeName(item.getItemData()),\n    isItemFolder: (item) => item.getItemData().resourceType === BaseNodeResourceType.Folder,\n    canReorder: true,\n    canDrop: (items, target) => {\n      // Basic validation\n      if (editingNodeId || !canMoveNode || items.length !== 1) return false;\n\n      const isDraggingFolder = items[0].isFolder();\n      const isReordering = 'childIndex' in target;\n\n      // === Non-folder items ===\n      if (!isDraggingFolder) {\n        // Reorder: ✅ allowed at any level\n        if (isReordering) return true;\n        // Drop into folder: ✅ | Drop into non-folder: ❌\n        return target.item.isFolder();\n      }\n\n      // === Folder items ===\n      if (isReordering) {\n        // Reorder at level 0, 1: ✅ | Reorder at level >= 2: ❌\n        return target.dragLineLevel < maxFolderDepth;\n      }\n\n      // Drop into level 0 folder: ✅ | Drop into level 1+ folder or non-folder: ❌\n      return target.item.isFolder() && getItemLevel(target.item) < maxFolderDepth - 1;\n    },\n    onDrop: handleDrop,\n    onPrimaryAction: handlePrimaryAction,\n    features: [\n      syncDataLoaderFeature,\n      selectionFeature,\n      hotkeysCoreFeature,\n      dragAndDropFeature,\n      keyboardDragAndDropFeature,\n    ],\n  });\n\n  const createSuccefulyCallback = useCallback(\n    (node: IBaseNodeVo) => {\n      const { resourceType, resourceId, parentId, resourceMeta } = node;\n      const viewId =\n        resourceType === BaseNodeResourceType.Table ? resourceMeta?.defaultViewId : undefined;\n      const parentItem = parentId ? treeItemsRef.current[parentId] : null;\n\n      const url = getNodeUrl({\n        baseId,\n        resourceType,\n        resourceId,\n        viewId,\n        urlPrefix: shareUrlPrefix,\n      });\n      if (url) {\n        if (resourceType === BaseNodeResourceType.Table) {\n          router.push(url, undefined, { shallow: Boolean(viewId) });\n        } else {\n          router.push(url, undefined, { shallow: true });\n        }\n      }\n\n      if (parentItem && parentItem.resourceType === BaseNodeResourceType.Folder) {\n        setExpandedItems((prev) => [...(prev ?? []), parentItem.id]);\n      }\n      setSelectedItems([node.id]);\n    },\n    [baseId, router, setExpandedItems, setSelectedItems, shareUrlPrefix]\n  );\n\n  const updateSuccefulyCallback = useCallback(\n    (node: IBaseNodeVo) => {\n      const { resourceType, resourceId } = node;\n      switch (resourceType) {\n        case BaseNodeResourceType.Dashboard:\n          queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(resourceId) });\n          break;\n        case BaseNodeResourceType.Workflow:\n          queryClient.invalidateQueries({\n            queryKey: ReactQueryKeys.workflowItem(baseId, resourceId),\n          });\n          break;\n        case BaseNodeResourceType.App:\n          queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getApp(baseId, resourceId) });\n          break;\n      }\n    },\n    [baseId, queryClient]\n  );\n\n  const getAllParentIds = useCallback((nodeId: string) => {\n    const parentIds: string[] = [];\n    let parentId = treeItemsRef.current[nodeId]?.parentId;\n    while (parentId) {\n      parentIds.push(parentId);\n      parentId = treeItemsRef.current[parentId]?.parentId;\n    }\n    return parentIds;\n  }, []);\n\n  const curdHooks = useBaseNodeCrud({\n    onCreateSuccess: createSuccefulyCallback,\n    onUpdateSuccess: updateSuccefulyCallback,\n  });\n\n  useEffect(() => {\n    treeItemsRef.current = treeItems;\n  }, [treeItems]);\n\n  const currentResourceId = useMemo(() => {\n    switch (baseResource.resourceType) {\n      case BaseNodeResourceType.Table:\n        return baseResource.tableId;\n      case BaseNodeResourceType.Dashboard:\n        return baseResource.dashboardId;\n      case BaseNodeResourceType.Workflow:\n        return baseResource.workflowId;\n      case BaseNodeResourceType.App:\n        return baseResource.appId;\n      default:\n        return undefined;\n    }\n  }, [baseResource]);\n\n  useEffect(() => {\n    if (Object.keys(treeItems).length === 0) return;\n    const nodes = Object.values(treeItems);\n    const { resourceType } = baseResource;\n    const node = nodes.find(\n      (node) => node.resourceType === resourceType && node.resourceId === currentResourceId\n    );\n    if (!node) {\n      setSelectedItems([]);\n      return;\n    }\n\n    const parentIds = getAllParentIds(node.id);\n    if (parentIds.length > 0) {\n      setExpandedItems((prev) => [...new Set([...(prev ?? []), ...parentIds])]);\n    }\n    setSelectedItems([node.id]);\n  }, [\n    treeItems,\n    baseResource,\n    currentResourceId,\n    getAllParentIds,\n    setExpandedItems,\n    setSelectedItems,\n  ]);\n\n  useEffect(() => {\n    if (selectedItems.length === 0) return;\n    if (Object.keys(treeItems).length === 0) return;\n    const focusItem = tree.getItemInstance(selectedItems[0]);\n    if (focusItem) {\n      focusItem.setFocused();\n      focusItem.scrollTo({ block: 'nearest', inline: 'nearest' });\n    }\n  }, [selectedItems, tree, treeItems]);\n\n  useEffect(() => {\n    if (isLoading) return;\n    tree.rebuildTree();\n  }, [tree, treeItems, isLoading]);\n\n  useEffect(() => {\n    let timeout: NodeJS.Timeout | null = null;\n    if (editingNodeId) {\n      timeout = setTimeout(() => {\n        if (inputRef.current) {\n          inputRef.current.focus();\n          inputRef.current.select();\n        }\n      }, 200);\n    }\n    return () => {\n      if (timeout) {\n        clearTimeout(timeout);\n      }\n    };\n  }, [editingNodeId]);\n\n  useClickAway(inputRef, () => {\n    const update = (editingNodeId: string) => {\n      const item = tree.getItemInstance(editingNodeId);\n      if (!item) return;\n      const oldVal = item?.getItemName() ?? '';\n      const newVal = inputRef.current?.value ?? '';\n      if (oldVal === newVal) return;\n      const nodeId = item.getId();\n      curdHooks.updateNode(nodeId, {\n        name: newVal,\n      });\n    };\n    if (editingNodeId) {\n      update(editingNodeId);\n      setEditingNodeId(null);\n    }\n  });\n\n  useDragAutoScroll(viewportRef);\n\n  if (!baseId) {\n    return null;\n  }\n\n  const ItemIcon = ({ item }: { item: ItemInstance<TreeItemData> }) => {\n    const nodeId = item.getId();\n    const data = item.getItemData();\n    if (!data) return null;\n    const IconComponent = BaseNodeResourceIconMap[data.resourceType];\n    const { resourceType } = data;\n    const icon = getNodeIcon(data);\n    const isFolder = item.isFolder();\n    if (isFolder) {\n      return (\n        <ChevronDownIcon className=\"size-4 text-muted-foreground group-aria-[expanded=false]:-rotate-90\" />\n      );\n    }\n    return (\n      // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events\n      <div\n        className=\"flex size-4 shrink-0 cursor-pointer items-center justify-center\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {resourceType === BaseNodeResourceType.Table && (\n          <EmojiPicker\n            className=\"flex size-full items-center justify-center hover:bg-muted-foreground/60\"\n            onChange={(icon: string) => curdHooks.updateNode(nodeId, { icon })}\n            disabled={!canUpdateTable}\n          >\n            {icon ? <Emoji emoji={icon} size=\"1rem\" /> : <IconComponent className=\"size-full\" />}\n          </EmojiPicker>\n        )}\n        {resourceType !== BaseNodeResourceType.Table && <IconComponent className=\"size-full\" />}\n      </div>\n    );\n  };\n\n  const ItemStatus = ({ item }: { item: ItemInstance<TreeItemData> }) => {\n    const node = item.getItemData();\n    if (!node) return null;\n    const { resourceType, resourceMeta } = node;\n    const isWorkflowActive =\n      resourceType === BaseNodeResourceType.Workflow &&\n      (resourceMeta as IBaseNodeWorkflowResourceMeta)?.isActive;\n    const isAppPublished =\n      resourceType === BaseNodeResourceType.App &&\n      (resourceMeta as IBaseNodeAppResourceMeta)?.publicUrl;\n    if (isWorkflowActive || isAppPublished) {\n      return <span className=\"size-1.5 shrink-0 rounded-full bg-emerald-500\" />;\n    }\n    return null;\n  };\n\n  const renderEmpty = () => {\n    if (isLoading) {\n      return (\n        <>\n          {props.skeleton ? (\n            props.skeleton\n          ) : (\n            <div className=\"flex w-full flex-col gap-2 !border-none px-2\">\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n            </div>\n          )}\n        </>\n      );\n    } else if (emptyText) {\n      return (\n        <div className=\"flex min-h-16 w-full flex-col items-center justify-center gap-2 px-2 \">\n          <p className=\"text-sm text-muted-foreground\">{emptyText}</p>\n        </div>\n      );\n    }\n  };\n\n  const renderViewTree = () => {\n    return (\n      <ScrollArea\n        viewportRef={viewportRef}\n        className=\"flex w-full !border-none px-2 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0\"\n        scrollBar=\"none\"\n      >\n        <Tree indent={INDENTATION_WIDTH} tree={tree} className=\"py-1\">\n          <AssistiveTreeDescription tree={tree} />\n          {tree.getItems().map((item) => {\n            const nodeId = item.getId();\n            const node = item.getItemData();\n            if (!node || Object.keys(node).length === 0) return null;\n            const { resourceType, resourceId } = node;\n            const name = getNodeName(node);\n            const isPinned = pinMap?.[resourceId];\n            return (\n              <TreeItem asChild key={nodeId} item={item}>\n                <div className=\"h-8 w-full cursor-pointer\">\n                  <TreeItemLabel className={cn('size-full min-w-0 py-0')}>\n                    <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n                      <ItemIcon item={item} />\n                      <div className=\"flex min-w-0 grow items-center gap-1\" title={name}>\n                        <span className=\"truncate text-left\">{name}</span>\n\n                        <ItemStatus item={item} />\n                        {\n                          // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events\n                          <div\n                            onClick={(e) => {\n                              e.stopPropagation();\n                            }}\n                            className={cn('flex shrink-0 cursor-pointer items-center', {\n                              'w-0 group-hover:w-auto': !isPinned,\n                            })}\n                          >\n                            <BaseNodeStarButton\n                              resourceType={resourceType}\n                              resourceId={resourceId}\n                            />\n                          </div>\n                        }\n                      </div>\n                    </div>\n                  </TreeItemLabel>\n                </div>\n              </TreeItem>\n            );\n          })}\n          <TreeDragLine />\n        </Tree>\n        <ScrollBar className=\"z-30\" />\n      </ScrollArea>\n    );\n  };\n\n  const renderEditTree = () => {\n    return (\n      <ScrollArea\n        viewportRef={viewportRef}\n        className={cn(\n          'flex w-full px-2 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0',\n          {\n            '!border-none': canCreateResource,\n          }\n        )}\n        scrollBar=\"none\"\n      >\n        <Tree indent={INDENTATION_WIDTH} tree={tree} className=\"py-1\">\n          <AssistiveTreeDescription tree={tree} />\n          {tree.getItems().map((item) => {\n            const nodeId = item.getId();\n            const node = item.getItemData();\n            if (!node || Object.keys(node).length === 0) return null;\n            const { resourceType, resourceId } = node;\n            const name = getNodeName(node);\n            const isHighlighted = isEditMode && highlightedTableId === resourceId;\n            const isPinned = pinMap?.[resourceId];\n            const showShareIndicator = !shareUrlPrefix;\n            const isContextMenuTarget = contextMenuOpen && contextMenu?.nodeId === nodeId;\n            return (\n              <TreeItem asChild key={nodeId} item={item}>\n                <div\n                  className=\"h-8 w-full cursor-pointer\"\n                  data-context-menu={isContextMenuTarget ? '' : undefined}\n                  onContextMenu={(e) => {\n                    e.preventDefault();\n                    setContextMenu({\n                      x: e.clientX,\n                      y: e.clientY,\n                      nodeId,\n                      resourceType,\n                      resourceId,\n                    });\n                    setContextMenuOpen(true);\n                  }}\n                >\n                  <TreeItemLabel\n                    className={cn('size-full min-w-0 py-0', {\n                      'bg-orange-300/40 hover:bg-orange-300/40': isHighlighted,\n                      'group-has-[[data-state=open]]:bg-accent': !isHighlighted,\n                      'bg-accent': isContextMenuTarget && !isHighlighted,\n                    })}\n                  >\n                    <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n                      {editingNodeId === nodeId ? (\n                        <Input\n                          ref={inputRef}\n                          type=\"text\"\n                          placeholder=\"name\"\n                          defaultValue={item.getItemName()}\n                          className=\"rounded-none size-full cursor-text\"\n                          onKeyDown={(e) => {\n                            if (e.key === 'Enter') {\n                              const newVal = e.currentTarget.value;\n                              if (newVal && newVal !== item.getItemName()) {\n                                curdHooks.updateNode(nodeId, { name: newVal });\n                              }\n                              setEditingNodeId(null);\n                            } else if (e.key === 'Escape') {\n                              setEditingNodeId(null);\n                            }\n                          }}\n                          onClick={(e) => {\n                            e.stopPropagation();\n                          }}\n                          onMouseDown={(e) => {\n                            e.stopPropagation();\n                          }}\n                        />\n                      ) : (\n                        <>\n                          <ItemIcon item={item} />\n                          <div className=\"flex min-w-0 grow items-center gap-1\" title={name}>\n                            <span\n                              className=\"truncate text-left\"\n                              onDoubleClick={() => {\n                                setEditingNodeId(nodeId);\n                              }}\n                            >\n                              {name}\n                            </span>\n\n                            <ItemStatus item={item} />\n                          </div>\n                          {\n                            // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events\n                            <div\n                              onClick={(e) => {\n                                e.stopPropagation();\n                              }}\n                              className=\"flex shrink-0 items-center\"\n                            >\n                              <BaseNodeStarButton\n                                resourceType={resourceType}\n                                resourceId={resourceId}\n                                className={cn(\n                                  isPinned ? 'w-auto' : 'w-0',\n                                  !isPinned && GROUP_ACTIVE_WIDTH_CLS,\n                                  GROUP_ACTIVE_OPACITY_CLS\n                                )}\n                              />\n                              <div\n                                className={cn(\n                                  'flex shrink-0 items-center overflow-hidden gap-1',\n                                  GROUP_ACTIVE_WIDTH_CLS,\n                                  isContextMenuTarget ? 'w-auto' : 'w-0'\n                                )}\n                              >\n                                {canCreateResource && (\n                                  <BaseNodeAddResourceButton\n                                    createNode={curdHooks.createNode}\n                                    parentId={nodeId === ROOT_ID ? undefined : nodeId}\n                                    canCreateFolder={\n                                      canCreateFolder && checkCanCreateFolder(item, maxFolderDepth)\n                                    }\n                                    canCreateTable={canCreateTable}\n                                    canCreateDashboard={canCreateDashboard}\n                                    canCreateWorkflow={canCreateWorkflow}\n                                    canCreateApp={canCreateApp}\n                                  >\n                                    <Button\n                                      variant=\"ghost\"\n                                      size=\"icon-xs\"\n                                      className=\"size-4 shrink-0 p-0 group-data-[folder=false]:hidden\"\n                                    >\n                                      <AddBoldIcon className=\"size-full\" />\n                                    </Button>\n                                  </BaseNodeAddResourceButton>\n                                )}\n                                <BaseNodeMore\n                                  resourceType={resourceType}\n                                  resourceId={resourceId}\n                                  onRename={() => setEditingNodeId(nodeId)}\n                                  onCreateSuccess={createSuccefulyCallback}\n                                  onUpdateSuccess={updateSuccefulyCallback}\n                                >\n                                  <Button\n                                    variant=\"ghost\"\n                                    size=\"icon-xs\"\n                                    className=\"size-4 shrink-0 p-0\"\n                                  >\n                                    <MoreHorizontal className=\"size-full\" />\n                                  </Button>\n                                </BaseNodeMore>\n                              </div>\n                              {showShareIndicator && !isContextMenuTarget && (\n                                <BaseNodeShareIndicator\n                                  nodeId={nodeId}\n                                  sharedNodeIds={sharedNodeIds}\n                                  node={node}\n                                  className={cn('ml-1', GROUP_ACTIVE_HIDDEN_CLS)}\n                                />\n                              )}\n                            </div>\n                          }\n                        </>\n                      )}\n                    </div>\n                  </TreeItemLabel>\n                </div>\n              </TreeItem>\n            );\n          })}\n          <TreeDragLine />\n        </Tree>\n        {contextMenu &&\n          typeof document !== 'undefined' &&\n          createPortal(\n            <div\n              style={{\n                position: 'fixed',\n                left: contextMenu.x,\n                top: contextMenu.y,\n                width: 0,\n                height: 0,\n                zIndex: 50,\n              }}\n            >\n              <BaseNodeMore\n                key={`${contextMenu.nodeId}-${contextMenu.x}-${contextMenu.y}`}\n                resourceType={contextMenu.resourceType}\n                resourceId={contextMenu.resourceId}\n                open={contextMenuOpen}\n                setOpen={setContextMenuOpen}\n                contentAlign=\"start\"\n                onRename={() => {\n                  setEditingNodeId(contextMenu.nodeId);\n                  setContextMenu(null);\n                  setContextMenuOpen(false);\n                }}\n                onCreateSuccess={createSuccefulyCallback}\n                onUpdateSuccess={updateSuccefulyCallback}\n              >\n                <span className=\"absolute size-0 overflow-hidden\" />\n              </BaseNodeMore>\n            </div>,\n            document.body\n          )}\n        <ScrollBar className=\"z-30\" />\n      </ScrollArea>\n    );\n  };\n\n  return (\n    <>\n      {canCreateResource && (\n        <div className=\"flex w-full flex-col px-4 pb-2 pt-4\">\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <span className=\"w-full\">\n                  <BaseNodeAddResourceButton\n                    createNode={curdHooks.createNode}\n                    parentId={ROOT_ID}\n                    canCreateFolder={canCreateFolder}\n                    canCreateTable={canCreateTable}\n                    canCreateDashboard={canCreateDashboard}\n                    canCreateWorkflow={canCreateWorkflow}\n                    canCreateApp={canCreateApp}\n                  >\n                    <Button\n                      variant={'outline'}\n                      size={'xs'}\n                      className=\"w-full\"\n                      disabled={!canCreateResource}\n                    >\n                      <AddBoldIcon className=\"size-4\" />\n                      <span className=\"truncate text-left\">{t('common:base.createResource')}</span>\n                    </Button>\n                  </BaseNodeAddResourceButton>\n                </span>\n              </TooltipTrigger>\n              {!canCreateResource && (\n                <TooltipContent>{t('common:base.noPermissionToCreateResource')}</TooltipContent>\n              )}\n            </Tooltip>\n          </TooltipProvider>\n        </div>\n      )}\n      {Object.keys(treeItems).length === 0\n        ? renderEmpty()\n        : isEditMode\n          ? renderEditTree()\n          : renderViewTree()}\n    </>\n  );\n};\n\nconst getItemLevel = (item: ItemInstance<TreeItemData>) => {\n  const meta = item.getItemMeta();\n  return meta.level;\n};\n\nconst checkCanCreateFolder = (item: ItemInstance<TreeItemData>, maxFolderDepth: number) => {\n  const level = getItemLevel(item);\n  return level < maxFolderDepth - 1;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BasePageRouter.tsx",
    "content": "import { Lock, MoreHorizontal, Settings, Trash2 } from '@teable/icons';\nimport { BillingProductLevel } from '@teable/openapi';\nimport { useBasePermission, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { UpgradeWrapper } from '@/features/app/components/billing/UpgradeWrapper';\nimport { ShareBaseDialog } from '@/features/app/components/collaborator/share/ShareBaseDialog';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nconst MoreMenu = () => {\n  const router = useRouter();\n  const { baseId } = router.query;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const basePermission = useBasePermission();\n\n  const canUpdateBase = Boolean(basePermission?.['base|update']);\n  if (!canUpdateBase) {\n    return null;\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"my-[2px] w-full justify-start text-sm font-normal\"\n        >\n          <MoreHorizontal className=\"size-4 shrink-0\" />\n          <p className=\"truncate\">{t('common:actions.more')}</p>\n          <div className=\"grow basis-0\"></div>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"center\" className=\"min-w-[200px]\">\n        {basePermission?.['base|delete'] && (\n          <DropdownMenuItem asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"xs\"\n              asChild\n              className=\"my-[2px] w-full justify-start text-sm\"\n            >\n              <Link href={`/base/${baseId}/trash`} className=\"font-normal\">\n                <Trash2 className=\"size-4 shrink-0\" />\n                <p className=\"truncate\">{t('common:noun.trash')}</p>\n                <div className=\"grow basis-0\"></div>\n              </Link>\n            </Button>\n          </DropdownMenuItem>\n        )}\n        <DropdownMenuItem asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            asChild\n            className=\"my-[2px] w-full justify-start text-sm\"\n          >\n            <Link href={`/base/${baseId}/design`} className=\"font-normal\">\n              <Settings className=\"size-4 shrink-0\" />\n              <p className=\"truncate\">{t('common:noun.design')}</p>\n              <div className=\"grow basis-0\"></div>\n            </Link>\n          </Button>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n\nexport const BasePageRouter = () => {\n  const router = useRouter();\n  const { baseId } = router.query;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const basePermission = useBasePermission();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  const pageRoutes: {\n    href: string;\n    label: string;\n    Icon: React.FC<{ className?: string }>;\n    billingLevel?: BillingProductLevel;\n  }[] = useMemo(\n    () =>\n      [\n        {\n          href: `/base/${baseId}/authority-matrix`,\n          label: t('common:noun.authorityMatrix'),\n          Icon: Lock,\n          hidden: !basePermission?.['base|authority_matrix_config'],\n          billingLevel: BillingProductLevel.Business,\n        },\n      ].filter((item) => !item.hidden),\n    [baseId, basePermission, t]\n  );\n\n  if (isReadOnlyPreview) {\n    return null;\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-2 px-3\">\n        <ul>\n          {pageRoutes.map(({ href, label, Icon, billingLevel }) => {\n            return (\n              <UpgradeWrapper\n                key={href}\n                baseId={baseId as string}\n                targetBillingLevel={billingLevel}\n              >\n                {({ badge }) => (\n                  <li key={href}>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"xs\"\n                      asChild\n                      className={cn(\n                        'w-full justify-start text-sm my-[2px]',\n                        router.asPath.startsWith(href) && 'bg-secondary'\n                      )}\n                    >\n                      <Link href={href} className=\"font-normal\">\n                        <Icon className=\"size-4 shrink-0\" />\n                        <p className=\"truncate\">{label}</p>\n                        <div className=\"grow basis-0\"></div>\n                        {badge}\n                      </Link>\n                    </Button>\n                  </li>\n                )}\n              </UpgradeWrapper>\n            );\n          })}\n          <ShareBaseDialog />\n          <MoreMenu />\n        </ul>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx",
    "content": "// import { TableList } from '../../table-list/TableList';\nimport { useBase } from '@teable/sdk/hooks';\nimport { ChangelogNotification } from '@/components/changelog';\nimport { BaseNodeTree } from './BaseNodeTree';\nimport { BasePageRouter } from './BasePageRouter';\n\nexport const BaseSideBar = (props: {\n  renderWinFreeCredit?: (spaceId: string) => React.ReactNode;\n}) => {\n  const { renderWinFreeCredit } = props;\n  const base = useBase();\n  return (\n    <>\n      <BasePageRouter />\n      {/* <TableList /> */}\n      <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n        <BaseNodeTree />\n      </div>\n      {renderWinFreeCredit && renderWinFreeCredit(base.spaceId)}\n      <ChangelogNotification />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { ChevronsLeft, ChevronDown, Database, HelpCircle, Pencil } from '@teable/icons';\nimport { CollaboratorType, getBaseList, getSharedBase, updateBase } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBase } from '@teable/sdk/hooks';\nimport { useIsReadOnlyPreview } from '@teable/sdk/hooks/use-is-readonly-preview';\nimport {\n  cn,\n  DropdownMenu,\n  DropdownMenuItem,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  Input,\n  DropdownMenuSeparator,\n} from '@teable/ui-lib';\nimport { ArrowLeft, Send } from 'lucide-react';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { PublishBaseDialog } from '../../table/table-header/publish-base/PublishBaseDialog';\n\nconst BaseDropdownMenu = ({\n  children,\n  showRename,\n  onRename,\n  backSpace,\n  creditUsage,\n  spaceId,\n  collaboratorType,\n  currentBaseId,\n  disabled,\n}: {\n  children: React.ReactNode;\n  showRename: boolean;\n  onRename: () => void;\n  backSpace: () => void;\n  spaceId: string;\n  creditUsage?: React.ReactNode;\n  collaboratorType?: CollaboratorType;\n  currentBaseId: string;\n  disabled?: boolean;\n}) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isCloud = useIsCloud();\n  const [open, setOpen] = useState(false);\n\n  const isSpaceCollaborator = collaboratorType === CollaboratorType.Space;\n  const { data: spaceBases } = useQuery({\n    queryKey: ReactQueryKeys.baseList(spaceId),\n    queryFn: ({ queryKey }) => getBaseList({ spaceId: queryKey[1] }).then((res) => res.data),\n    enabled: open && isSpaceCollaborator,\n  });\n\n  const { data: sharedBases } = useQuery({\n    queryKey: ReactQueryKeys.getSharedBase(),\n    queryFn: () => getSharedBase().then((res) => res.data),\n    enabled: open && collaboratorType === CollaboratorType.Base,\n  });\n\n  const bases = spaceBases || sharedBases;\n\n  return (\n    <DropdownMenu open={open} onOpenChange={disabled ? undefined : setOpen}>\n      <DropdownMenuTrigger asChild disabled={disabled}>\n        {children}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        className=\"min-w-[260px]\"\n        align=\"start\"\n        alignOffset={0}\n        sideOffset={4}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <DropdownMenuItem onClick={backSpace}>\n          <div className=\"flex w-full cursor-pointer items-center gap-2\">\n            <ArrowLeft className=\"size-4\" />\n            {t('common:actions.backToSpace')}\n          </div>\n        </DropdownMenuItem>\n        <DropdownMenuSeparator />\n        {isCloud && isSpaceCollaborator && creditUsage && (\n          <>\n            <div className=\"px-2 py-1\">{creditUsage}</div>\n            <DropdownMenuSeparator />\n          </>\n        )}\n        <DropdownMenuSub>\n          <DropdownMenuSubTrigger>\n            <div className=\"flex w-full cursor-pointer items-center gap-2\">\n              <Database className=\"size-4\" />\n              {t('common:actions.switchBase')}\n            </div>\n          </DropdownMenuSubTrigger>\n          <DropdownMenuSubContent className=\"max-h-[300px] w-56 overflow-y-auto\">\n            {bases?.map((base) => (\n              <DropdownMenuItem\n                key={base.id}\n                className={cn('cursor-pointer', {\n                  'bg-accent': base.id === currentBaseId,\n                })}\n                asChild\n              >\n                <Link href={`/base/${base.id}`} className=\"flex items-center gap-2\">\n                  <span className=\"shrink-0\">\n                    {base.icon ? (\n                      <Emoji emoji={base.icon} size=\"1rem\" />\n                    ) : (\n                      <Database className=\"size-4\" />\n                    )}\n                  </span>\n                  <span className=\"truncate\" title={base.name}>\n                    {base.name}\n                  </span>\n                </Link>\n              </DropdownMenuItem>\n            ))}\n          </DropdownMenuSubContent>\n        </DropdownMenuSub>\n        {showRename && (\n          <DropdownMenuItem onClick={onRename}>\n            <div className=\"flex w-full cursor-pointer items-center gap-2\">\n              <Pencil className=\"size-4\" />\n              {t('actions.rename')}\n            </div>\n          </DropdownMenuItem>\n        )}\n        <PublishBaseDialog onClose={() => setOpen(false)} closeOnSuccess={false}>\n          <DropdownMenuItem onSelect={(e) => e.preventDefault()}>\n            <div className=\"flex w-full cursor-pointer items-center gap-2\">\n              <Send className=\"size-4\" />\n              {t('space:publishBase.publishToCommunity')}\n            </div>\n          </DropdownMenuItem>\n        </PublishBaseDialog>\n\n        <DropdownMenuSeparator />\n        <DropdownMenuItem asChild>\n          <Link\n            href={t('help.mainLink')}\n            title={t('help.title')}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"flex w-full cursor-pointer items-center gap-2\"\n          >\n            <HelpCircle className=\"size-4\" />\n            {t('help.title')}\n          </Link>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n\nexport const BaseSidebarHeaderLeft = ({ creditUsage }: { creditUsage?: React.ReactNode }) => {\n  const base = useBase();\n  const router = useRouter();\n  const [renaming, setRenaming] = useState<boolean>();\n  const [baseName, setBaseName] = useState<string>(base.name);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const queryClient = useQueryClient();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { mutateAsync: updateBaseMutator } = useMutation({\n    mutationFn: updateBase,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.base(base.id),\n      });\n    },\n  });\n\n  const toggleRenameBase = async () => {\n    if (baseName && baseName !== base.name) {\n      await updateBaseMutator({\n        baseId: base.id,\n        updateBaseRo: { name: baseName },\n      });\n    }\n    setTimeout(() => setRenaming(false), 200);\n  };\n\n  const onRename = () => {\n    setRenaming(true);\n    setTimeout(() => inputRef.current?.focus(), 200);\n  };\n\n  const hasUpdatePermission = hasPermission(base.role, 'base|update');\n\n  const backSpace = () => {\n    if (isReadOnlyPreview) {\n      return;\n    }\n    if (base.collaboratorType === CollaboratorType.Base) {\n      router.push({\n        pathname: '/space/shared-base',\n      });\n    } else {\n      router.push({\n        pathname: '/space/[spaceId]',\n        query: { spaceId: base.spaceId },\n      });\n    }\n  };\n\n  return (\n    <div className=\"flex min-w-0 shrink grow items-center\">\n      <div\n        className=\"relative mr-1 size-6 shrink-0 cursor-pointer\"\n        onClick={backSpace}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter' || e.key === ' ') {\n            backSpace();\n          }\n        }}\n        role=\"button\"\n        tabIndex={0}\n      >\n        <div\n          className={cn('absolute top-0 size-6 transition-all group-hover/sidebar:opacity-0', {\n            'group-hover/sidebar:opacity-100': isReadOnlyPreview,\n          })}\n        >\n          {base.icon ? (\n            <Emoji emoji={base.icon} size={'1.5rem'} />\n          ) : (\n            <TeableLogo className=\"size-6 text-black\" />\n          )}\n        </div>\n        <ChevronsLeft\n          className={cn(\n            'absolute top-0 size-6 opacity-0 transition-all group-hover/sidebar:opacity-100',\n            {\n              'group-hover/sidebar:opacity-0': isReadOnlyPreview,\n            }\n          )}\n        />\n      </div>\n      <div className=\"flex shrink grow items-center gap-1 overflow-hidden\">\n        {renaming ? (\n          <form\n            className=\"w-full\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              toggleRenameBase();\n            }}\n          >\n            <Input\n              ref={inputRef}\n              className=\"flex-1 shrink\"\n              size=\"sm\"\n              value={baseName}\n              onChange={(e) => setBaseName(e.target.value)}\n              onBlur={toggleRenameBase}\n            />\n          </form>\n        ) : (\n          <BaseDropdownMenu\n            backSpace={backSpace}\n            showRename={hasUpdatePermission}\n            onRename={onRename}\n            spaceId={base.spaceId}\n            creditUsage={creditUsage}\n            collaboratorType={base.collaboratorType}\n            currentBaseId={base.id}\n            disabled={isReadOnlyPreview}\n          >\n            <div\n              className={cn(\n                'flex h-7 max-w-full overflow-hidden px-2 py-1 hover:bg-accent hover:cursor-pointer rounded-md items-center gap-2',\n                {\n                  'cursor-default': isReadOnlyPreview,\n                }\n              )}\n            >\n              <span className=\"min-w-0 shrink truncate text-sm\" title={base.name}>\n                {base.name}\n              </span>\n              {!isReadOnlyPreview && <ChevronDown className=\"size-4 shrink-0\" />}\n            </div>\n          </BaseDropdownMenu>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { sharePasswordSchema } from '@teable/core';\nimport { ArrowUpRight, Copy, Edit, Qrcode, RefreshCcw } from '@teable/icons';\nimport type {\n  IBaseNodeAppResourceMeta,\n  ICreateBaseShareRo,\n  IUpdateBaseShareRo,\n} from '@teable/openapi';\nimport {\n  BaseNodeResourceType,\n  createBaseShare,\n  deleteBaseShare,\n  getBaseShareByNodeId,\n  refreshBaseShare,\n  updateBaseShare,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin } from '@teable/ui-lib';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  Button,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  Input,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Separator,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Check, ChevronDown, ChevronRight, Eye, HelpCircle } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { QRCodeSVG } from 'qrcode.react';\nimport { useMemo, useState } from 'react';\nimport { useAppPublishContext } from '@/features/app/blocks/table/table-header/publish-base/AppPublishContext';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { BaseNodeResourceIconMap, getNodeIcon, getNodeName } from '../base-node/hooks';\nimport type { TreeItemData } from '../base-node/hooks';\nimport { useSharedNodeIds } from './BaseNodeShareIndicator';\n\nconst getShareUrl = (shareId: string) => {\n  const origin = typeof window !== 'undefined' ? window.location.origin : 'https://app.teable.ai';\n  return `${origin}/share/${shareId}/base`;\n};\n\nconst getEmbedUrl = (shareUrl: string) => {\n  const url = new URL(shareUrl);\n  url.searchParams.append('embed', 'true');\n  return url.toString();\n};\n\nconst getEmbedHtml = (shareUrl: string) => {\n  const embedUrl = getEmbedUrl(shareUrl);\n  return `<iframe src=\"${embedUrl}\" width=\"100%\" height=\"533\" style=\"border: 0\"></iframe>`;\n};\n\n// Embed Config Popover Component\nconst EmbedConfigPopover = ({ shareUrl }: { shareUrl: string }) => {\n  const { t } = useTranslation(['common', 'table']);\n  const [previewOpen, setPreviewOpen] = useState(false);\n\n  const embedHtml = getEmbedHtml(shareUrl);\n\n  const handleCopyCode = () => {\n    navigator.clipboard.writeText(embedHtml);\n    toast.success(t('common:actions.copySuccess'));\n  };\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button variant=\"ghost\" className=\"flex w-full items-center justify-between px-0 py-1\">\n          <Label className=\"cursor-pointer text-sm font-normal\">\n            {t('table:baseShare.embedConfig')}\n          </Label>\n          <ChevronRight className=\"size-4 text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"right\" align=\"start\" className=\"w-80\">\n        {/* iframe code preview */}\n        <div className=\"mb-3 rounded-md bg-muted p-3\">\n          <code className=\"break-all text-xs\">{embedHtml}</code>\n        </div>\n\n        {/* Action buttons */}\n        <div className=\"flex gap-2\">\n          <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"flex-1\"\n              onClick={() => setPreviewOpen(true)}\n            >\n              <Eye className=\"mr-1 size-4\" />\n              {t('table:toolbar.others.share.embedPreview')}\n            </Button>\n            <DialogContent className=\"sm:max-w-[425px] md:max-w-[600px] lg:max-w-[800px]\">\n              <DialogHeader>\n                <DialogTitle>{t('table:toolbar.others.share.embedPreview')}</DialogTitle>\n              </DialogHeader>\n              <div className=\"h-[500px]\">\n                <iframe\n                  src={getEmbedUrl(shareUrl)}\n                  title=\"embed preview\"\n                  width=\"100%\"\n                  height=\"100%\"\n                  style={{ border: 0 }}\n                />\n              </div>\n            </DialogContent>\n          </Dialog>\n          <Button variant=\"outline\" size=\"sm\" className=\"flex-1\" onClick={handleCopyCode}>\n            <Copy className=\"mr-1 size-4\" />\n            {t('table:toolbar.others.share.copyCode')}\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport const NodeShareHeader = ({ node }: { node: TreeItemData }) => {\n  const { t } = useTranslation(['common', 'table']);\n  const nodeName = getNodeName(node);\n  const nodeIcon = getNodeIcon(node);\n  const NodeTypeIcon = BaseNodeResourceIconMap[node.resourceType];\n\n  return (\n    <div className=\"flex w-full items-center gap-2\">\n      <span className=\"shrink-0 text-base font-medium\">{t('table:baseShare.shareTitle')}</span>\n      <span className=\"shrink-0\">\n        {nodeIcon ? (\n          <Emoji emoji={nodeIcon} size={16} className=\"size-4\" />\n        ) : (\n          NodeTypeIcon && <NodeTypeIcon className=\"size-4 text-muted-foreground\" />\n        )}\n      </span>\n      <span className=\"truncate text-base font-medium\" title={nodeName}>\n        {nodeName}\n      </span>\n    </div>\n  );\n};\n\nexport const NodeShareContent = ({\n  baseId,\n  nodeId,\n  node,\n  hideHeader,\n}: {\n  baseId: string;\n  nodeId: string;\n  node: TreeItemData;\n  hideHeader?: boolean;\n}) => {\n  const { t } = useTranslation(['common', 'table']);\n  const queryClient = useQueryClient();\n  const { publishApp } = useAppPublishContext();\n\n  const [showPasswordDialog, setShowPasswordDialog] = useState(false);\n  const [sharePassword, setSharePassword] = useState('');\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [isPublishing, setIsPublishing] = useState(false);\n\n  const sharedNodeIds = useSharedNodeIds();\n  const isNodeShared = sharedNodeIds.has(nodeId);\n\n  const { data: share, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.baseShareByNodeId(baseId, nodeId),\n    queryFn: () =>\n      getBaseShareByNodeId(baseId, nodeId)\n        .then((res) => res.data)\n        .catch(() => null),\n    enabled: isNodeShared,\n  });\n\n  const shareUrl = useMemo(() => {\n    if (!share) return '';\n    return getShareUrl(share.shareId);\n  }, [share]);\n\n  const { mutate: createShare, isPending: isCreateLoading } = useMutation({\n    mutationFn: (data: ICreateBaseShareRo) => createBaseShare(baseId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareByNodeId(baseId, nodeId),\n      });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareList(baseId),\n        exact: true,\n      });\n      toast.success(t('table:baseShare.createSuccess'));\n    },\n    onError: () => {\n      toast.error(t('table:baseShare.createFailed'));\n    },\n  });\n\n  const { mutate: updateShare } = useMutation({\n    mutationFn: (data: IUpdateBaseShareRo) => updateBaseShare(baseId, share!.shareId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareByNodeId(baseId, nodeId),\n      });\n    },\n    onError: () => {\n      toast.error(t('table:baseShare.updateFailed'));\n    },\n  });\n\n  const { mutate: deleteShare, isPending: isDeleteLoading } = useMutation({\n    mutationFn: () => deleteBaseShare(baseId, share!.shareId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareByNodeId(baseId, nodeId),\n      });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareList(baseId),\n        exact: true,\n      });\n      toast.success(t('table:baseShare.deleteSuccess'));\n      setShowDeleteConfirm(false);\n    },\n    onError: () => {\n      toast.error(t('table:baseShare.deleteFailed'));\n    },\n  });\n\n  const { mutate: refreshShare, isPending: isRefreshLoading } = useMutation({\n    mutationFn: () => refreshBaseShare(baseId, share!.shareId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseShareByNodeId(baseId, nodeId),\n      });\n      toast.success(t('table:baseShare.refreshSuccess'));\n    },\n    onError: () => {\n      toast.error(t('table:baseShare.refreshFailed'));\n    },\n  });\n\n  const handleToggleShare = (enabled: boolean) => {\n    if (enabled) {\n      createShare({ nodeId });\n    } else {\n      setShowDeleteConfirm(true);\n    }\n  };\n\n  const handleUpdateSetting = (data: Partial<IUpdateBaseShareRo>) => {\n    if (!share) return;\n    updateShare(data);\n  };\n\n  const handlePasswordSwitchChange = (checked: boolean) => {\n    if (checked) {\n      setShowPasswordDialog(true);\n    } else {\n      handleUpdateSetting({ password: null });\n    }\n  };\n\n  const confirmSharePassword = () => {\n    handleUpdateSetting({ password: sharePassword });\n    setShowPasswordDialog(false);\n    setSharePassword('');\n  };\n\n  const closeSharePasswordDialog = () => {\n    setSharePassword('');\n    setShowPasswordDialog(false);\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <Spin className=\"size-6\" />\n      </div>\n    );\n  }\n\n  const isAppNode = node.resourceType === BaseNodeResourceType.App;\n  const appPublicUrl = isAppNode\n    ? (node.resourceMeta as IBaseNodeAppResourceMeta)?.publicUrl\n    : null;\n\n  const handlePublishApp = async () => {\n    if (!publishApp) return;\n\n    setIsPublishing(true);\n    try {\n      await publishApp({\n        nodeId,\n        name: (node.resourceMeta as IBaseNodeAppResourceMeta)?.name || '',\n        resourceId: node.resourceId,\n      });\n      queryClient.invalidateQueries({ queryKey: ['baseNodeTree', baseId] });\n      toast.success(t('table:baseShare.publishSuccess'));\n    } catch {\n      toast.error(t('table:baseShare.publishFailed'));\n    } finally {\n      setIsPublishing(false);\n    }\n  };\n\n  if (isAppNode) {\n    return (\n      <div className=\"flex w-full flex-col gap-4 py-4\">\n        {!hideHeader && <NodeShareHeader node={node} />}\n\n        {appPublicUrl ? (\n          <div className=\"flex flex-col gap-2\">\n            <Label className=\"text-sm font-semibold\">{t('table:baseShare.appPublicLink')}</Label>\n            <div className=\"flex items-center gap-2\">\n              <div className=\"flex h-9 min-w-0 flex-1 items-center rounded-md border bg-card p-2 pl-3\">\n                <span className=\"truncate text-sm text-muted-foreground\">{appPublicUrl}</span>\n              </div>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <Button\n                      className=\"size-9 shrink-0 p-0\"\n                      variant=\"outline\"\n                      onClick={() => window.open(appPublicUrl, '_blank')}\n                    >\n                      <ArrowUpRight className=\"size-4\" />\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p>{t('table:baseShare.openLink')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <Popover>\n                <PopoverTrigger asChild>\n                  <Button variant=\"outline\" size=\"icon\" className=\"shrink-0\">\n                    <Qrcode className=\"size-4 shrink-0\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"size-48 bg-white p-2\">\n                  <QRCodeSVG value={appPublicUrl} className=\"size-full\" />\n                </PopoverContent>\n              </Popover>\n              <CopyButton text={appPublicUrl} variant=\"outline\" size=\"icon\" className=\"shrink-0\" />\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col items-center gap-4 py-6\">\n            <p className=\"text-center text-sm text-muted-foreground\">\n              {t('table:baseShare.appNotPublished')}\n            </p>\n            <Button onClick={handlePublishApp} disabled={isPublishing || !publishApp}>\n              {isPublishing && <Spin className=\"mr-2 size-4\" />}\n              {t('table:baseShare.goToPublish')}\n            </Button>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  const isShareEnabled = !!share;\n\n  return (\n    <div className=\"flex w-full flex-col gap-4 py-4\">\n      {!hideHeader && <NodeShareHeader node={node} />}\n\n      <div className=\"flex items-center gap-2\">\n        {isCreateLoading ? (\n          <Spin className=\"size-5\" />\n        ) : (\n          <Switch id=\"share-switch\" checked={isShareEnabled} onCheckedChange={handleToggleShare} />\n        )}\n        <Label htmlFor=\"share-switch\" className=\"text-sm\">\n          {t('table:baseShare.shareToWeb')}\n        </Label>\n      </div>\n\n      {isShareEnabled && share && (\n        <>\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"flex items-center gap-1.5 text-sm\">\n              <span className=\"text-muted-foreground\">{t('table:baseShare.linkHolderLabel')}</span>\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <button className=\"inline-flex items-center gap-0.5 font-medium text-blue-500 hover:text-blue-600\">\n                    {share.allowSave\n                      ? t('table:baseShare.linkHolderCanCopyAndSave')\n                      : t('table:baseShare.linkHolderCanView')}\n                    <ChevronDown className=\"size-3.5\" />\n                  </button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"start\">\n                  <DropdownMenuItem\n                    className={!share.allowSave ? 'text-blue-500' : ''}\n                    onClick={() => handleUpdateSetting({ allowSave: false })}\n                  >\n                    {!share.allowSave ? (\n                      <Check className=\"mr-1.5 size-4\" />\n                    ) : (\n                      <span className=\"mr-1.5 size-4\" />\n                    )}\n                    {t('table:baseShare.linkHolderCanView')}\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    className={share.allowSave ? 'text-blue-500' : ''}\n                    onClick={() => handleUpdateSetting({ allowSave: true })}\n                  >\n                    {share.allowSave ? (\n                      <Check className=\"mr-1.5 size-4\" />\n                    ) : (\n                      <span className=\"mr-1.5 size-4\" />\n                    )}\n                    {t('table:baseShare.linkHolderCanCopyAndSave')}\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Input className=\"min-w-0 flex-1\" size=\"lg\" value={shareUrl} readOnly />\n              <CopyButton text={shareUrl} variant=\"outline\" size=\"icon\" className=\"shrink-0\" />\n              <Popover>\n                <PopoverTrigger asChild>\n                  <Button variant=\"outline\" size=\"icon\" className=\"shrink-0\">\n                    <Qrcode className=\"size-4 shrink-0\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"size-48 bg-white p-2\">\n                  <QRCodeSVG value={shareUrl} className=\"size-full\" />\n                </PopoverContent>\n              </Popover>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <Button\n                      variant=\"outline\"\n                      size=\"icon\"\n                      className=\"shrink-0\"\n                      onClick={() => refreshShare()}\n                      disabled={isRefreshLoading}\n                    >\n                      {isRefreshLoading ? (\n                        <Spin className=\"size-4\" />\n                      ) : (\n                        <RefreshCcw className=\"size-4 shrink-0\" />\n                      )}\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"bottom\">\n                    <p>{t('table:baseShare.refreshLink')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"flex flex-col gap-3\">\n            <Label className=\"text-sm font-medium\">{t('table:baseShare.advanced')}</Label>\n\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                id=\"share-allowCopy\"\n                checked={Boolean(share.allowCopy)}\n                onCheckedChange={(checked) => handleUpdateSetting({ allowCopy: checked })}\n              />\n              <Label className=\"text-sm font-normal\" htmlFor=\"share-allowCopy\">\n                {t('table:baseShare.allowCopyData')}\n              </Label>\n            </div>\n\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                id=\"share-password\"\n                checked={Boolean(share.password)}\n                onCheckedChange={handlePasswordSwitchChange}\n              />\n              <Label className=\"text-sm font-normal\" htmlFor=\"share-password\">\n                {t('table:baseShare.restrictByPassword')}\n              </Label>\n              {Boolean(share.password) && (\n                <Button\n                  className=\"h-5 px-1 hover:text-muted-foreground\"\n                  variant=\"link\"\n                  size=\"xs\"\n                  onClick={() => setShowPasswordDialog(true)}\n                >\n                  <Edit className=\"size-3\" />\n                </Button>\n              )}\n            </div>\n\n            <EmbedConfigPopover shareUrl={shareUrl} />\n          </div>\n        </>\n      )}\n\n      <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('table:baseShare.deleteConfirmTitle')}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {t('table:baseShare.deleteConfirmDescription')}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>{t('common:actions.cancel')}</AlertDialogCancel>\n            <AlertDialogAction\n              className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n              onClick={() => deleteShare()}\n              disabled={isDeleteLoading}\n            >\n              {isDeleteLoading && <Spin className=\"mr-2 size-4\" />}\n              {t('common:actions.confirm')}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n\n      <Dialog\n        open={showPasswordDialog}\n        onOpenChange={(open) => !open && closeSharePasswordDialog()}\n      >\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>{t('table:toolbar.others.share.passwordTitle')}</DialogTitle>\n          </DialogHeader>\n          <Input\n            size=\"lg\"\n            type=\"password\"\n            value={sharePassword}\n            onChange={(e) => setSharePassword(e.target.value)}\n            placeholder={t('table:baseShare.enterPassword')}\n          />\n          <DialogFooter>\n            <Button size=\"sm\" variant=\"ghost\" onClick={closeSharePasswordDialog}>\n              {t('common:actions.cancel')}\n            </Button>\n            <Button\n              size=\"sm\"\n              onClick={confirmSharePassword}\n              disabled={!sharePasswordSchema.safeParse(sharePassword).success}\n            >\n              {t('common:actions.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareDialog.tsx",
    "content": "export { NodeShareContent, NodeShareHeader } from './NodeShareContent';\nexport { UnifiedShareDialog as NodeShareDialog } from '@/features/app/blocks/view/tool-bar/UnifiedShareDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/base-side-bar/QuickAction.tsx",
    "content": "import { LaptopIcon } from '@radix-ui/react-icons';\nimport { Moon, Search, Settings, Sun } from '@teable/icons';\nimport { useTheme } from '@teable/next-themes';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { useBaseId, useIsAnonymous, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport {\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandSeparator,\n  Button,\n  TooltipProvider,\n  Tooltip,\n  TooltipTrigger,\n  TooltipContent,\n} from '@teable/ui-lib/shadcn';\nimport { groupBy } from 'lodash';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { useSettingStore } from '@/features/app/components/setting/useSettingStore';\nimport { useModKeyStr } from '@/features/app/utils/get-mod-key-str';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { BaseNodeResourceIconMap, getNodeIcon, getNodeName, getNodeUrl } from '../base-node/hooks';\nimport { useBaseNodeContext } from '../base-node/hooks/useBaseNodeContext';\n\nexport const QuickAction = () => {\n  const baseId = useBaseId() as string;\n  const [open, setOpen] = useState(false);\n  const setting = useSettingStore();\n  const router = useRouter();\n  const theme = useTheme();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isAnonymous = useIsAnonymous();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const modKeyStr = useModKeyStr();\n  useHotkeys(\n    `mod+k`,\n    () => {\n      setOpen(!open);\n    },\n    {\n      enableOnFormTags: ['input', 'select', 'textarea'],\n    }\n  );\n\n  const { treeItems } = useBaseNodeContext();\n  const baseNodeTypeItems = groupBy(\n    Object.values(treeItems).filter((item) => item.resourceType !== BaseNodeResourceType.Folder),\n    'resourceType'\n  );\n\n  return (\n    <>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              className=\"size-7 shrink-0 p-0\"\n              variant=\"ghost\"\n              size=\"xs\"\n              onClick={() => setOpen(true)}\n            >\n              <Search className=\"size-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent hideWhenDetached={true}>\n            {t('common:quickAction.title')}\n            <span>{modKeyStr}+K</span>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <CommandDialog\n        closeable={false}\n        open={open}\n        onOpenChange={setOpen}\n        commandProps={{\n          filter: (value, search, keywords) => {\n            const searchLower = search.toLowerCase();\n            if (keywords?.some((keyword) => keyword.toLowerCase().includes(searchLower))) {\n              return 1;\n            }\n            return 0;\n          },\n        }}\n      >\n        <CommandInput placeholder={t('common:quickAction.placeHolder')} />\n        <CommandList>\n          <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n          {Object.entries(baseNodeTypeItems).map(([resourceType, items]) => {\n            const heading = () => {\n              switch (resourceType) {\n                case BaseNodeResourceType.Table:\n                  return t('common:noun.table');\n                case BaseNodeResourceType.Dashboard:\n                  return t('common:noun.dashboard');\n                case BaseNodeResourceType.App:\n                  return t('common:noun.app');\n                case BaseNodeResourceType.Workflow:\n                  return t('common:noun.automation');\n                default:\n                  return '';\n              }\n            };\n            return (\n              <CommandGroup heading={heading()} key={resourceType}>\n                {items.map((item) => {\n                  const { id, resourceType, resourceId } = item;\n                  const name = getNodeName(item);\n                  const icon = getNodeIcon(item);\n                  const IconComponent = BaseNodeResourceIconMap[resourceType];\n                  const url = getNodeUrl({\n                    baseId,\n                    resourceType,\n                    resourceId,\n                  });\n                  return (\n                    <CommandItem\n                      className=\"flex h-8 gap-2\"\n                      key={id}\n                      value={id}\n                      keywords={[name]}\n                      onSelect={() => {\n                        setOpen(false);\n                        if (url) {\n                          router.push(url);\n                        }\n                      }}\n                    >\n                      <div className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n                        {icon ? (\n                          <Emoji emoji={icon} size=\"1em\" />\n                        ) : IconComponent ? (\n                          <IconComponent className=\"size-full\" />\n                        ) : null}\n                      </div>\n                      <span>{name}</span>\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            );\n          })}\n          <CommandSeparator />\n          <CommandGroup heading={t('common:settings.setting.theme')}>\n            <CommandItem\n              className=\"flex h-8 gap-2\"\n              onSelect={() => {\n                setOpen(false);\n                theme.setTheme('light');\n              }}\n              value={t('common:settings.setting.light')}\n              keywords={[t('common:settings.setting.light')]}\n            >\n              <div className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n                <Sun className=\"size-full\" />\n              </div>\n              <span>{t('common:settings.setting.light')}</span>\n            </CommandItem>\n            <CommandItem\n              className=\"flex h-8 gap-2\"\n              onSelect={() => {\n                setOpen(false);\n                theme.setTheme('dark');\n              }}\n              value={t('common:settings.setting.dark')}\n              keywords={[t('common:settings.setting.dark')]}\n            >\n              <div className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n                <Moon className=\"size-full\" />\n              </div>\n              <span>{t('common:settings.setting.dark')}</span>\n            </CommandItem>\n            <CommandItem\n              className=\"flex h-8 gap-2\"\n              onSelect={() => {\n                setOpen(false);\n                theme.setTheme('system');\n              }}\n              value={t('common:settings.setting.system')}\n              keywords={[t('common:settings.setting.system')]}\n            >\n              <div className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n                <LaptopIcon className=\"size-full\" />\n              </div>\n              <span>{t('common:settings.setting.system')}</span>\n            </CommandItem>\n          </CommandGroup>\n          <CommandSeparator />\n          {!isAnonymous && !isReadOnlyPreview && (\n            <CommandGroup heading={t('common:settings.nav.settings')}>\n              <CommandItem\n                className=\"flex h-8 gap-2\"\n                onSelect={() => {\n                  setOpen(false);\n                  setting.setOpen(true);\n                }}\n                value={t('common:settings.personal.title')}\n                keywords={[t('common:settings.personal.title')]}\n              >\n                <div className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">\n                  <Settings className=\"size-full\" />\n                </div>\n                <span>{t('common:settings.personal.title')}</span>\n              </CommandItem>\n            </CommandGroup>\n          )}\n        </CommandList>\n      </CommandDialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/duplicate/DuplicateBaseModal.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { Check, Database } from '@teable/icons';\nimport { duplicateBase, getSpaceList, type IGetBaseVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Input,\n  Label,\n  Switch,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useDuplicateBaseStore } from './useDuplicateBaseStore';\n\nconst DuplicateBase = ({ base }: { base: IGetBaseVo }) => {\n  const { closeModal } = useDuplicateBaseStore();\n  const [withRecords, setWithRecords] = useState(true);\n  const [targetSpaceId, setTargetSpaceId] = useState<string>();\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const [baseName, setBaseName] = useState(`${base.name} (${t('space:baseModal.copy')})`);\n  const [successDuplicate, setSuccessDuplicate] = useState(false);\n  const [newBaseId, setNewBaseId] = useState<string>();\n\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((res) => res.data),\n  });\n\n  const queryClient = useQueryClient();\n\n  const { mutateAsync: duplicateBaseMutator, isPending: isLoading } = useMutation({\n    mutationFn: duplicateBase,\n    onSuccess: ({ data }) => {\n      targetSpaceId &&\n        queryClient.invalidateQueries({\n          queryKey: ReactQueryKeys.baseList(targetSpaceId),\n        });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseAll(),\n      });\n      setSuccessDuplicate(true);\n      setNewBaseId(data.id);\n    },\n  });\n\n  const editableSpaceList = useMemo(() => {\n    return spaceList?.filter((space) => hasPermission(space.role, 'base|create')) || [];\n  }, [spaceList]);\n\n  const onSubmit = () => {\n    if (!targetSpaceId) {\n      toast.error(t('space:baseModal.missTargetTip'));\n      return;\n    }\n\n    // toast.message(t('space:baseModal.copying'));\n\n    duplicateBaseMutator({\n      fromBaseId: base.id,\n      spaceId: targetSpaceId,\n      name: baseName,\n      withRecords,\n    });\n  };\n\n  useEffect(() => {\n    if (!targetSpaceId && editableSpaceList?.length) {\n      const currentSpace = editableSpaceList.find((space) => space.id === base.spaceId);\n      if (currentSpace) {\n        setTargetSpaceId(currentSpace.id);\n      } else {\n        setTargetSpaceId(editableSpaceList[0].id);\n      }\n    }\n  }, [base.spaceId, editableSpaceList, targetSpaceId]);\n  return (\n    <DialogContent className=\"sm:max-w-[425px]\">\n      <DialogHeader>\n        <DialogTitle>\n          {t('space:baseModal.duplicate', {\n            baseName: base.name,\n          })}\n        </DialogTitle>\n      </DialogHeader>\n      <div className=\"flex flex-col items-center gap-4 py-4\">\n        {base.icon ? (\n          <div className=\"size-14 min-w-14 text-[3.5rem] leading-none\">\n            <Emoji emoji={base.icon} size={56} />\n          </div>\n        ) : (\n          <Database className=\"size-14 min-w-14\" />\n        )}\n        <div>\n          <Input value={baseName} onChange={(e) => setBaseName(e.target.value)} />\n        </div>\n      </div>\n      <hr />\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-4\">\n          <Label htmlFor=\"duplicate-records-mode\">{t('space:baseModal.duplicateRecords')}</Label>\n          <Switch\n            id=\"duplicate-records-mode\"\n            checked={withRecords}\n            onCheckedChange={(v) => setWithRecords(v)}\n          />\n        </div>\n        <p className=\"text-xs text-secondary-foreground\">\n          {t('space:baseModal.duplicateRecordsTip')}\n        </p>\n        <div className=\"flex items-center gap-4\">\n          <Label htmlFor=\"username\" className=\"text-right\">\n            {t('space:baseModal.copyToSpace')}\n          </Label>\n          <Selector\n            candidates={editableSpaceList}\n            selectedId={targetSpaceId}\n            onChange={(id) => setTargetSpaceId(id)}\n          />\n        </div>\n      </div>\n      <DialogFooter className=\"mt-4\">\n        <DialogClose asChild>\n          <Button size=\"sm\" type=\"button\" variant=\"ghost\">\n            {t('common:actions.cancel')}\n          </Button>\n        </DialogClose>\n        <Button\n          size=\"sm\"\n          type=\"submit\"\n          onClick={() => {\n            if (successDuplicate && newBaseId) {\n              closeModal();\n              router.push({\n                pathname: '/base/[baseId]',\n                query: { baseId: newBaseId },\n              });\n            } else {\n              onSubmit();\n            }\n          }}\n          className=\"flex items-center gap-2\"\n        >\n          {successDuplicate\n            ? t('space:baseModal.duplicateBaseSucceedAndJump')\n            : t('space:baseModal.duplicateBase')}\n\n          {successDuplicate && <Check className=\"size-3 text-green-300\" />}\n\n          {isLoading && <Spin className=\"size-4\" />}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  );\n};\n\nexport const DuplicateBaseModal = () => {\n  const { base, closeModal } = useDuplicateBaseStore();\n  return (\n    <Dialog open={Boolean(base)} onOpenChange={(isOpen) => !isOpen && closeModal()}>\n      {base && <DuplicateBase base={base} />}\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/duplicate/TemplateCreateBaseModal.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { createBaseFromTemplate, getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Label,\n  Switch,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Loader } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useTemplateCreateBaseStore } from './useTemplateCreateBaseStore';\n\nconst TemplateBase = ({\n  templateId,\n  fixedSpaceId,\n}: {\n  templateId: string;\n  fixedSpaceId?: string;\n}) => {\n  const { closeModal } = useTemplateCreateBaseStore();\n  const [withRecords, setWithRecords] = useState(true);\n  const [targetSpaceId, setTargetSpaceId] = useState<string | undefined>(fixedSpaceId);\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  // Only fetch space list if no fixed spaceId provided\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n    enabled: !fixedSpaceId,\n  });\n\n  const { mutateAsync: templateCreateBaseMutator, isPending: isLoading } = useMutation({\n    mutationFn: createBaseFromTemplate,\n    onSuccess: ({ data }) => {\n      closeModal();\n      const { id: baseId, defaultUrl } = data;\n\n      // If defaultUrl is provided, navigate to it directly\n      if (defaultUrl) {\n        router.push(defaultUrl);\n        return;\n      }\n\n      // Otherwise, navigate to base home\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId },\n      });\n    },\n  });\n\n  const editableSpaceList = useMemo(() => {\n    return spaceList?.filter((space) => hasPermission(space.role, 'base|create')) || [];\n  }, [spaceList]);\n\n  useEffect(() => {\n    if (!fixedSpaceId && !targetSpaceId) {\n      setTargetSpaceId(editableSpaceList[0]?.id);\n    }\n  }, [editableSpaceList, targetSpaceId, fixedSpaceId]);\n\n  const onSubmit = () => {\n    if (!targetSpaceId) {\n      toast.error(t('space:baseModal.missTargetTip'));\n      return;\n    }\n\n    templateCreateBaseMutator({\n      templateId,\n      spaceId: targetSpaceId,\n      withRecords,\n    });\n  };\n\n  return (\n    <DialogContent className=\"sm:max-w-[425px]\">\n      <DialogHeader>\n        <DialogTitle>{t('space:baseModal.createBaseFromTemplate')}</DialogTitle>\n      </DialogHeader>\n      <div className=\"space-y-4 pt-4\">\n        <div className=\"flex items-center gap-4\">\n          <Label htmlFor=\"duplicate-records-mode\">{t('space:baseModal.duplicateRecords')}</Label>\n          <Switch\n            id=\"duplicate-records-mode\"\n            checked={withRecords}\n            onCheckedChange={(v) => setWithRecords(v)}\n            disabled={isLoading}\n          />\n        </div>\n        {/* Only show space selector if no fixed spaceId */}\n        {!fixedSpaceId && (\n          <div className=\"flex items-center gap-4\">\n            <Label htmlFor=\"username\" className=\"text-right\">\n              {t('space:baseModal.toSpace')}\n            </Label>\n            <Selector\n              className=\"min-w-40\"\n              candidates={editableSpaceList}\n              selectedId={targetSpaceId}\n              onChange={(id) => setTargetSpaceId(id)}\n            />\n          </div>\n        )}\n      </div>\n      <DialogFooter className=\"mt-4\">\n        <DialogClose asChild>\n          <Button size=\"sm\" type=\"button\" variant=\"ghost\" disabled={isLoading}>\n            {t('common:actions.cancel')}\n          </Button>\n        </DialogClose>\n        <Button size=\"sm\" type=\"submit\" onClick={() => onSubmit()} disabled={isLoading}>\n          {isLoading ? <Loader className=\"size-4 animate-spin\" /> : t('common:actions.confirm')}\n        </Button>\n      </DialogFooter>\n    </DialogContent>\n  );\n};\n\nexport const TemplateCreateBaseModal = () => {\n  const { templateId, spaceId, closeModal } = useTemplateCreateBaseStore();\n  return (\n    <Dialog open={Boolean(templateId)} onOpenChange={(isOpen) => !isOpen && closeModal()}>\n      {templateId && <TemplateBase templateId={templateId} fixedSpaceId={spaceId} />}\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/duplicate/useDuplicateBaseStore.ts",
    "content": "import type { IGetBaseVo } from '@teable/openapi';\nimport { create } from 'zustand';\n\ninterface IDuplicateBaseState {\n  base?: IGetBaseVo;\n  closeModal: () => void;\n  openModal: (base: IGetBaseVo) => void;\n}\n\nexport const useDuplicateBaseStore = create<IDuplicateBaseState>((set) => ({\n  closeModal: () => {\n    set((state) => {\n      return {\n        ...state,\n        base: undefined,\n      };\n    });\n  },\n  openModal: (base: IGetBaseVo) => {\n    set((state) => {\n      return {\n        ...state,\n        base,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/duplicate/useTemplateCreateBaseStore.ts",
    "content": "import { create } from 'zustand';\n\ninterface ITemplateCreateBaseState {\n  templateId?: string;\n  spaceId?: string;\n  closeModal: () => void;\n  openModal: (templateId: string, spaceId?: string) => void;\n}\n\nexport const useTemplateCreateBaseStore = create<ITemplateCreateBaseState>((set) => ({\n  closeModal: () => {\n    set((state) => {\n      return {\n        ...state,\n        templateId: undefined,\n        spaceId: undefined,\n      };\n    });\n  },\n  openModal: (templateId: string, spaceId?: string) => {\n    set((state) => {\n      return {\n        ...state,\n        templateId,\n        spaceId,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/duplicate/useTemplateMonitor.ts",
    "content": "import { useRouter } from 'next/router';\nimport { useMount } from 'react-use';\nimport { useTemplateCreateBaseStore } from './useTemplateCreateBaseStore';\n\nexport const useTemplateMonitor = () => {\n  const router = useRouter();\n  const { tid, action, spaceId } = router.query as {\n    tid: string;\n    action: string;\n    spaceId?: string;\n  };\n  const { openModal } = useTemplateCreateBaseStore();\n\n  useMount(() => {\n    if (action === 'createFromTemplate' && tid && spaceId) {\n      openModal(tid, spaceId);\n      // Clear URL params while preserving spaceId\n      router.push(\n        {\n          pathname: router.pathname,\n          query: { spaceId },\n        },\n        undefined,\n        { shallow: true }\n      );\n    }\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/hooks/index.ts",
    "content": "export * from './useLastVisitBase';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/base/hooks/useLastVisitBase.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\n\nimport { getUserLastVisitListBase } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { keyBy } from 'lodash';\nimport { useMemo } from 'react';\n\nexport const useLastVisitBase = () => {\n  const { data: recentlyBase } = useQuery({\n    queryKey: ReactQueryKeys.recentlyBase(),\n    queryFn: () => getUserLastVisitListBase().then((res) => res.data),\n  });\n\n  return useMemo(() => {\n    if (!recentlyBase) {\n      return {\n        total: 0,\n        list: [],\n      };\n    }\n    const { list: resourceList, total } = recentlyBase;\n\n    const list = resourceList.map((item) => {\n      const base = item.resource;\n      return {\n        ...base,\n        lastVisitTime: item.lastVisitTime,\n      };\n    });\n\n    const map = keyBy(list, 'id');\n\n    return {\n      total,\n      list,\n      map,\n    };\n  }, [recentlyBase]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/billing/SpaceSubscriptionModal.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useSpaceSubscriptionStore } from './useSpaceSubscriptionStore';\n\nexport const SpaceSubscriptionModal = () => {\n  const { subscribeLevel, closeModal } = useSpaceSubscriptionStore();\n  const [targetSpaceId, setTargetSpaceId] = useState<string>();\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const ownerSpaceList = useMemo(() => {\n    return spaceList?.filter((space) => hasPermission(space.role, 'space|update')) || [];\n  }, [spaceList]);\n\n  useEffect(() => {\n    if (!targetSpaceId) {\n      setTargetSpaceId(ownerSpaceList[0]?.id);\n    }\n  }, [ownerSpaceList, targetSpaceId]);\n\n  const onSubmit = () => {\n    if (!targetSpaceId) {\n      toast.error(t('space:baseModal.missTargetTip'));\n      return;\n    }\n\n    closeModal();\n    router.push({\n      pathname: '/space/[spaceId]/setting/plan',\n      query: { spaceId: targetSpaceId, subscribeLevel: subscribeLevel },\n    });\n  };\n\n  return (\n    <Dialog open={Boolean(subscribeLevel)} onOpenChange={(isOpen) => !isOpen && closeModal()}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"flex flex-col gap-y-2\">\n          <DialogTitle>{t('billing.spaceSubscriptionModal.title')}</DialogTitle>\n          <DialogDescription>{t('billing.spaceSubscriptionModal.description')}</DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-4 py-2\">\n          <div className=\"flex items-center gap-4\">\n            <Label htmlFor=\"username\" className=\"text-right\">\n              {t('space:baseModal.toSpace')}\n            </Label>\n            <Selector\n              className=\"min-w-40\"\n              candidates={ownerSpaceList}\n              selectedId={targetSpaceId}\n              onChange={(id) => setTargetSpaceId(id)}\n            />\n          </div>\n        </div>\n        <DialogFooter>\n          <DialogClose asChild>\n            <Button size=\"sm\" type=\"button\" variant=\"ghost\">\n              {t('common:actions.cancel')}\n            </Button>\n          </DialogClose>\n          <Button size=\"sm\" type=\"submit\" onClick={() => onSubmit()}>\n            {t('common:actions.confirm')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/billing/useSpaceSubscriptionMonitor.ts",
    "content": "import type { BillingProductLevel } from '@teable/openapi';\nimport { useRouter } from 'next/router';\nimport { useMount } from 'react-use';\nimport { useSpaceSubscriptionStore } from './useSpaceSubscriptionStore';\n\nexport const useSpaceSubscriptionMonitor = () => {\n  const router = useRouter();\n  const { subscribeLevel } = router.query as { subscribeLevel?: BillingProductLevel };\n  const { openModal } = useSpaceSubscriptionStore();\n  useMount(() => {\n    if (subscribeLevel) {\n      openModal(subscribeLevel);\n      router.push(router.pathname, undefined, { shallow: true });\n    }\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/billing/useSpaceSubscriptionStore.ts",
    "content": "import type { BillingProductLevel } from '@teable/openapi';\nimport { create } from 'zustand';\n\ninterface ISpaceSubscriptionState {\n  subscribeLevel?: BillingProductLevel;\n  closeModal: () => void;\n  openModal: (subscribeLevel: BillingProductLevel) => void;\n}\n\nexport const useSpaceSubscriptionStore = create<ISpaceSubscriptionState>((set) => ({\n  closeModal: () => {\n    set((state) => {\n      return {\n        ...state,\n        subscribeLevel: undefined,\n      };\n    });\n  },\n  openModal: (subscribeLevel: BillingProductLevel) => {\n    set((state) => {\n      return {\n        ...state,\n        subscribeLevel,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/Chart.tsx",
    "content": "import type { IParentBridgeMethods, IUIConfig } from '@teable/sdk';\nimport { Spin } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useEnv } from '../hooks/useEnv';\nimport { usePluginInstall } from '../hooks/usePluginInstall';\nimport type { IPageParams, IChartStorage } from '../types';\nimport { ChartLayout } from './chart/ChartLayout';\nimport { ChartPage } from './chart/ChartPage';\nimport { ChartProvider } from './ChartProvider';\nimport { EnvProvider } from './EnvProvider';\n\nconst ChartInner = (props: { parentBridgeMethods: IParentBridgeMethods; uiConfig: IUIConfig }) => {\n  const { parentBridgeMethods, uiConfig } = props;\n  const { baseId, positionId, pluginInstallId } = useEnv();\n  const { t } = useTranslation('chart');\n  const { pluginInstall, isLoading } = usePluginInstall();\n\n  if (!baseId) {\n    return <div className=\"text-center text-muted-foreground\">{t('notBaseId')}</div>;\n  }\n\n  if (!positionId) {\n    return <div className=\"text-center text-muted-foreground\">{t('notPositionId')}</div>;\n  }\n\n  if (!pluginInstallId) {\n    return <div className=\"text-center text-muted-foreground\">{t('notPluginInstallId')}</div>;\n  }\n\n  if (isLoading || !pluginInstall) {\n    return (\n      <div className=\"flex size-full items-center justify-center\">\n        <Spin />\n      </div>\n    );\n  }\n\n  return (\n    <ChartProvider\n      storage={pluginInstall.storage as unknown as IChartStorage}\n      uiConfig={{\n        ...uiConfig,\n        isShowingSettings: !!uiConfig?.isShowingSettings,\n      }}\n      parentBridgeMethods={parentBridgeMethods}\n    >\n      <ChartLayout {...props}>\n        <ChartPage />\n      </ChartLayout>\n    </ChartProvider>\n  );\n};\nexport const Chart = (props: {\n  pageParams: IPageParams;\n  parentBridgeMethods: IParentBridgeMethods;\n  uiConfig: IUIConfig;\n}) => {\n  const { pageParams, parentBridgeMethods, uiConfig } = props;\n  return (\n    <EnvProvider pageParams={pageParams}>\n      <ChartInner parentBridgeMethods={parentBridgeMethods} uiConfig={uiConfig} />\n    </EnvProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/ChartProvider.tsx",
    "content": "import type { IParentBridgeMethods, IUIConfig } from '@teable/sdk';\nimport React, { useRef } from 'react';\nimport type { IChartStorage } from '../types';\n\nexport interface IChartContext {\n  tab: 'chart' | 'query';\n  storage?: IChartStorage;\n  uiConfig?: IUIConfig;\n  queryError?: string;\n  onQueryError?: (error?: string) => void;\n  onTabChange: (tab: 'chart' | 'query') => void;\n  onStorageChange: (storage: IChartStorage) => Promise<unknown>;\n  parentBridgeMethods?: IParentBridgeMethods;\n}\n\nexport const ChartContext = React.createContext<IChartContext>({\n  tab: 'chart',\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  onTabChange: () => {},\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  onStorageChange: (storage: IChartStorage) => Promise.resolve(storage),\n});\n\nexport const ChartProvider = (props: {\n  children: React.ReactNode;\n  storage?: IChartStorage;\n  uiConfig?: IUIConfig;\n  parentBridgeMethods?: IParentBridgeMethods;\n}) => {\n  const { children, storage, uiConfig, parentBridgeMethods } = props;\n  const [tab, setTab] = React.useState<'chart' | 'query'>(storage?.query ? 'chart' : 'query');\n  const [storageState, setStorageState] = React.useState<IChartStorage | undefined>(storage);\n  const [queryError, setQueryError] = React.useState<string | undefined>();\n  const preStorage = useRef<IChartStorage | undefined>();\n\n  const updateStorage = async (storage: IChartStorage) => {\n    try {\n      preStorage.current = storage;\n      setStorageState(storage);\n      await parentBridgeMethods?.updateStorage(storage as unknown as Record<string, unknown>);\n    } catch (error) {\n      console.error('Failed to update storage', error);\n      setStorageState(preStorage.current);\n    }\n  };\n\n  return (\n    <ChartContext.Provider\n      value={{\n        tab,\n        uiConfig,\n        storage: storageState,\n        queryError,\n        parentBridgeMethods,\n        onTabChange: setTab,\n        onQueryError: setQueryError,\n        onStorageChange: updateStorage,\n      }}\n    >\n      {children}\n    </ChartContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/EnvProvider.tsx",
    "content": "'use client';\nimport React from 'react';\nimport type { IPageParams } from '../types';\n\nexport const EnvContext = React.createContext<IPageParams>({} as IPageParams);\n\nexport const EnvProvider = (props: { pageParams: IPageParams; children: React.ReactNode }) => {\n  const { pageParams, children } = props;\n  return <EnvContext.Provider value={pageParams}>{children}</EnvContext.Provider>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/ChartLayout.tsx",
    "content": "'use client';\nimport { Button } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport React, { useContext } from 'react';\nimport { ChartContext } from '../ChartProvider';\n\nexport const ChartLayout: React.FC<{\n  children: React.ReactNode;\n}> = ({ children }) => {\n  const { t } = useTranslation('chart');\n  const { uiConfig, storage, parentBridgeMethods } = useContext(ChartContext);\n\n  if (!storage && !uiConfig?.isShowingSettings) {\n    return (\n      <div className=\"flex flex-col items-center gap-2 px-4\">\n        <div className=\"text-center text-muted-foreground\">{t('noStorage')}</div>\n        <Button\n          className=\"m-auto h-7\"\n          size=\"sm\"\n          onClick={() => parentBridgeMethods?.expandPlugin()}\n        >\n          {t('goConfig')}\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <div id=\"portal\" className=\"relative flex size-full items-start\">\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/ChartPage.tsx",
    "content": "'use client';\n\nimport { Table2 } from '@teable/icons';\nimport { cn, Toggle } from '@teable/ui-lib';\nimport { useContext, useState } from 'react';\nimport { useUIConfig } from '../../hooks/useUIConfig';\nimport { ChartContext } from '../ChartProvider';\nimport { ChartSetting } from './chart-config/ChartSetting';\nimport { ChartDisplay } from './chart-show/ChartDisplay';\nimport { ChartQuery } from './ChartQuery';\n\nexport const ChartPage = () => {\n  const { tab } = useContext(ChartContext);\n  const { isShowingSettings } = useUIConfig();\n  const [isTable, setIsTable] = useState(false);\n  const { storage } = useContext(ChartContext);\n  const hasTable = storage?.config?.type === 'table';\n\n  if (tab === 'query' && isShowingSettings) {\n    return <ChartQuery />;\n  }\n\n  return (\n    <div className=\"flex size-full\">\n      <div className=\"relative flex-1 overflow-hidden\">\n        <ChartDisplay previewTable={isTable} />\n        {!hasTable && isShowingSettings && (\n          <Toggle\n            size=\"sm\"\n            variant=\"outline\"\n            pressed={isTable}\n            onPressedChange={setIsTable}\n            className=\"absolute bottom-0.5 right-0.5 h-auto p-1.5 data-[state=on]:bg-foreground data-[state=on]:text-background\"\n            aria-label=\"Toggle bold\"\n          >\n            <Table2 />\n          </Toggle>\n        )}\n      </div>\n      {isShowingSettings && (\n        <ChartSetting\n          className={cn({\n            hidden: !isShowingSettings,\n          })}\n        />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/ChartQuery.tsx",
    "content": "import type { IBaseQuery } from '@teable/openapi';\nimport type { IBaseQueryBuilderRef } from '@teable/sdk';\nimport { BaseQueryBuilder } from '@teable/sdk';\nimport { Button, cn } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useContext, useEffect, useRef, useState } from 'react';\nimport { ChartContext } from '../ChartProvider';\n\nexport const ChartQuery = () => {\n  const { tab, storage, onTabChange, onStorageChange } = useContext(ChartContext);\n  const [query, setQuery] = useState<IBaseQuery | undefined>(storage?.query);\n  const [isLoading, setLoading] = useState(false);\n  const queryBuilderRef = useRef<IBaseQueryBuilderRef>(null);\n  const { t } = useTranslation('chart');\n\n  useEffect(() => {\n    if (tab === 'query') {\n      // TODO: refactor query builder, remove setTimeout\n      setTimeout(() => {\n        queryBuilderRef.current?.initContext();\n      });\n    }\n  }, [tab]);\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-10 w-full items-center justify-between border-b px-6\">\n        <div>{t('queryTitle')}</div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            className={cn({\n              hidden: !storage?.query,\n            })}\n            variant=\"ghost\"\n            size=\"xs\"\n            onClick={() => onTabChange('chart')}\n          >\n            {t('actions.cancel')}\n          </Button>\n          <Button\n            size=\"xs\"\n            disabled={isLoading || !query}\n            onClick={async () => {\n              if (!query) {\n                return;\n              }\n              setLoading(true);\n              await onStorageChange(\n                storage\n                  ? {\n                      ...storage,\n                      query,\n                    }\n                  : { query }\n              );\n              setLoading(false);\n              onTabChange('chart');\n            }}\n          >\n            {t('actions.save')}\n          </Button>\n        </div>\n      </div>\n      {tab === 'query' && (\n        <div className=\"flex-1 overflow-auto\">\n          <BaseQueryBuilder\n            ref={queryBuilderRef}\n            className=\"border-none p-8\"\n            query={query}\n            onChange={setQuery}\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/ChartForm.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { IChartConfig } from '../../../types';\nimport { AreaForm } from './form/AreaForm';\nimport { BarForm } from './form/BarForm';\nimport { LineForm } from './form/LineForm';\nimport { PieForm } from './form/PieForm';\nimport { TableForm } from './form/TableForm';\n\nexport const ChartForm = (props: {\n  value: IChartConfig;\n  onChange: (value: IChartConfig) => void;\n}) => {\n  const { value, onChange } = props;\n  const { t } = useTranslation('chart');\n  switch (value.type) {\n    case 'bar':\n      return <BarForm config={value} onChange={onChange} />;\n    case 'line':\n      return <LineForm config={value} onChange={onChange} />;\n    case 'area':\n      return <AreaForm config={value} onChange={onChange} />;\n    case 'pie':\n      return <PieForm config={value} onChange={onChange} />;\n    case 'table':\n      return <TableForm config={value} onChange={onChange} />;\n    default:\n      throw new Error(t('form.typeError'));\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/ChartSetting.tsx",
    "content": "import { cn, ScrollArea } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useContext } from 'react';\nimport { ChartContext } from '../../ChartProvider';\nimport { ChartForm } from './ChartForm';\nimport { ConfigItem } from './common/ConfigItem';\nimport { QueryStatus } from './QueryStatus';\nimport { TypeSelector } from './TypeSelector';\n\nexport const ChartSetting = (props: { className?: string }) => {\n  const { className } = props;\n  const { storage, onStorageChange } = useContext(ChartContext);\n  const { t } = useTranslation('chart');\n  const config = storage?.config;\n  if (!storage) {\n    return;\n  }\n\n  return (\n    <ScrollArea className={cn('border-l p-4 w-80', className)}>\n      <QueryStatus />\n      <div className=\"mt-9 space-y-4\">\n        <ConfigItem label={t('form.chartType.label')}>\n          <TypeSelector\n            type={config?.type}\n            onChange={(type) =>\n              onStorageChange({\n                ...storage,\n                config: { type },\n              })\n            }\n          />\n        </ConfigItem>\n        <div>\n          {config && (\n            <ChartForm\n              value={config}\n              onChange={(config) => {\n                onStorageChange({\n                  ...storage,\n                  config,\n                });\n              }}\n            />\n          )}\n        </div>\n      </div>\n    </ScrollArea>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/QueryStatus.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { RefreshCcw } from '@teable/icons';\nimport { Button, cn } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useContext } from 'react';\nimport { useEnv } from '../../../hooks/useEnv';\nimport { ChartContext } from '../../ChartProvider';\n\nexport const QueryStatus = () => {\n  const { queryError, onTabChange } = useContext(ChartContext);\n  const { baseId } = useEnv();\n  const { storage } = useContext(ChartContext);\n  const query = storage?.query;\n  const queryClient = useQueryClient();\n  const { t } = useTranslation('chart');\n  const refreshQuery = () => {\n    queryClient.invalidateQueries({ queryKey: ['baseQuery', baseId, query] });\n  };\n\n  return (\n    <div\n      className={cn(\n        'absolute inset-x-0 top-0 flex h-10 items-center justify-center bg-green-100 text-sm text-green-900 dark:bg-green-900 dark:text-green-100 z-10',\n        {\n          'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100': queryError,\n        }\n      )}\n    >\n      {queryError ? t('form.queryError') : t('form.querySuccess')}\n      <Button\n        className={cn('h-auto text-green-900 underline dark:text-green-100', {\n          'text-red-900 dark:text-red-100': queryError,\n        })}\n        size={'xs'}\n        variant=\"link\"\n        onClick={() => onTabChange('query')}\n      >\n        {t('form.updateQuery')}\n      </Button>\n      <Button\n        title={t('reloadQuery')}\n        className=\"h-auto p-0 pt-0.5\"\n        size={'icon-xs'}\n        variant=\"link\"\n        onClick={refreshQuery}\n      >\n        <RefreshCcw className=\"size-4 shrink-0\" />\n      </Button>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/TypeSelector.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events */\n/* eslint-disable jsx-a11y/no-static-element-interactions */\nimport { ChevronsUpDown, Table2 } from '@teable/icons';\nimport { Button, cn, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport { BarChart, LineChart, PieChart, AreaChart } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport type { IChartConfig } from '../../../types';\n\nexport const TypeSelector = (props: {\n  className?: string;\n  type?: IChartConfig['type'];\n  onChange: (type: IChartConfig['type']) => void;\n}) => {\n  const { className, type, onChange } = props;\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation('chart');\n\n  const options = useMemo(() => {\n    return [\n      {\n        label: t('chart.bar'),\n        value: 'bar',\n        Icon: BarChart,\n      },\n      {\n        label: t('chart.line'),\n        value: 'line',\n        Icon: LineChart,\n      },\n      {\n        label: t('chart.pie'),\n        value: 'pie',\n        Icon: PieChart,\n      },\n      {\n        label: t('chart.area'),\n        value: 'area',\n        Icon: AreaChart,\n      },\n      {\n        label: t('chart.table'),\n        value: 'table',\n        Icon: Table2,\n      },\n    ] as const;\n  }, [t]);\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className={cn('w-full justify-between h-8 font-normal', className)}\n        >\n          {options.find((o) => o.value === type)?.label ?? (\n            <span className=\"text-muted-foreground\">{t('form.chartType.placeholder')}</span>\n          )}\n          <ChevronsUpDown className=\"ml-2 size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent>\n        <div className=\"flex flex-wrap gap-4\">\n          {options.map(({ label, Icon, value }) => (\n            <div\n              key={value}\n              onClick={() => {\n                onChange(value);\n                setOpen(false);\n              }}\n            >\n              <div\n                className={cn('hover:border-primary cursor-pointer rounded-full border p-3', {\n                  'border-primary': type === value,\n                })}\n              >\n                <Icon />\n              </div>\n              <div className=\"text-center text-sm\">{label}</div>\n            </div>\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/AxisDisplayBaseContent.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { IChartBaseAxisDisplay } from '../../../../types';\nimport { ComboLineStyleEditor } from './ComboLineStyleEditor';\nimport { ComboTypeEditor } from './ComboTypeEditor';\nimport { ConfigItem } from './ConfigItem';\nimport { YAxisPositionEditor } from './YAxisPositionEditor';\n\nexport const AxisDisplayBaseContent = (props: {\n  value: IChartBaseAxisDisplay;\n  onChange: (value: IChartBaseAxisDisplay) => void;\n}) => {\n  const { value: displayValue, onChange } = props;\n  const { t } = useTranslation('chart');\n  return (\n    <>\n      <ConfigItem label={t('form.combo.displayType')}>\n        <ComboTypeEditor\n          value={displayValue.type}\n          onChange={(type) => {\n            switch (type) {\n              case 'bar': {\n                return onChange({\n                  type,\n                  position: displayValue.position,\n                });\n              }\n\n              case 'area':\n              case 'line': {\n                return onChange({\n                  lineStyle: 'normal',\n                  ...displayValue,\n                  position: displayValue.position,\n                  type,\n                });\n              }\n              default:\n                throw new Error('Invalid display type');\n            }\n          }}\n        />\n      </ConfigItem>\n      {displayValue.type !== 'bar' && (\n        <ConfigItem label={t('form.combo.lineStyle.label')}>\n          <ComboLineStyleEditor\n            value={displayValue.lineStyle}\n            onChange={(val) => {\n              onChange({\n                ...displayValue,\n                lineStyle: val,\n              });\n            }}\n          />\n        </ConfigItem>\n      )}\n      <ConfigItem label={t('form.combo.yAxis.position')}>\n        <YAxisPositionEditor\n          value={displayValue.position}\n          onChange={(val) => {\n            if (!displayValue) {\n              return;\n            }\n            onChange({\n              ...displayValue,\n              position: val,\n            });\n          }}\n        />\n      </ConfigItem>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ColumnSelector.tsx",
    "content": "import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@teable/ui-lib';\n\nexport const ColumnSelector = (props: {\n  value?: string;\n  onChange: (value: string) => void;\n  columns: {\n    name: string;\n    column: string;\n  }[];\n  className?: string;\n}) => {\n  const { className, value, onChange, columns } = props;\n\n  return (\n    <Select value={value} onValueChange={onChange}>\n      <SelectTrigger\n        className={cn(\n          'h-8',\n          {\n            'text-muted-foreground': !value,\n          },\n          className\n        )}\n      >\n        <SelectValue placeholder=\"Select a column\" />\n      </SelectTrigger>\n      <SelectContent>\n        {columns.map((column) => (\n          <SelectItem key={column.column} value={column.column}>\n            {column.name}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ComboLineStyleEditor.tsx",
    "content": "import { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { IChartBaseAxisDisplay } from '../../../../types';\n\ntype ILineStyle = Extract<IChartBaseAxisDisplay, { lineStyle: unknown }>['lineStyle'];\n\nexport const ComboLineStyleEditor = (props: {\n  value?: ILineStyle;\n  onChange: (value: ILineStyle) => void;\n}) => {\n  const { value: displayValue, onChange } = props;\n  const { t } = useTranslation('chart');\n  const lineStyles = useMemo(() => {\n    return [\n      {\n        label: t('form.combo.lineStyle.normal'),\n        value: 'normal',\n      },\n      {\n        label: t('form.combo.lineStyle.linear'),\n        value: 'linear',\n      },\n      {\n        label: t('form.combo.lineStyle.step'),\n        value: 'step',\n      },\n    ];\n  }, [t]);\n\n  return (\n    <RadioGroup className=\"flex gap-4\" value={displayValue} onValueChange={onChange}>\n      {lineStyles.map(({ label, value }) => (\n        <div key={value} className=\"flex items-center gap-2\">\n          <RadioGroupItem value={value} id={value} />\n          <Label\n            title={label}\n            htmlFor={value}\n            className=\"flex items-center gap-2 text-xs font-normal\"\n          >\n            {label}\n          </Label>\n        </div>\n      ))}\n    </RadioGroup>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ComboTypeEditor.tsx",
    "content": "import { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib';\nimport { AreaChart, BarChart, LineChart } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { IChartBaseAxisDisplay } from '../../../../types';\n\nexport const ComboTypeEditor = (props: {\n  value?: IChartBaseAxisDisplay['type'];\n  onChange: (value: IChartBaseAxisDisplay['type']) => void;\n}) => {\n  const { value: displayType, onChange } = props;\n  const { t } = useTranslation('chart');\n  const displayTypes = useMemo(() => {\n    return [\n      {\n        label: t('chart.bar'),\n        value: 'bar',\n        Icon: BarChart,\n      },\n      {\n        label: t('chart.line'),\n        value: 'line',\n        Icon: LineChart,\n      },\n      {\n        label: t('chart.area'),\n        value: 'area',\n        Icon: AreaChart,\n      },\n    ] as const;\n  }, [t]);\n\n  return (\n    <RadioGroup className=\"flex gap-4\" value={displayType} onValueChange={onChange}>\n      {displayTypes.map(({ label, Icon, value }) => (\n        <div key={value} className=\"flex items-center gap-2\">\n          <RadioGroupItem value={value} id={value} />\n          <Label\n            title={label}\n            htmlFor={value}\n            className=\"flex items-center rounded border p-1 text-xs font-normal\"\n          >\n            <Icon className=\"size-5\" />\n          </Label>\n        </div>\n      ))}\n    </RadioGroup>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ComboXAxisDisplayEditor.tsx",
    "content": "import { Input } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport type { IChartXAxisDisplay } from '../../../../types';\nimport { ConfigItem } from './ConfigItem';\n\nexport const ComboXAxisDisplayEditor = (props: {\n  value?: IChartXAxisDisplay;\n  onChange: (value?: IChartXAxisDisplay) => void;\n}) => {\n  const { value: display, onChange } = props;\n  const { t } = useTranslation('chart');\n  const [value, setValue] = useState(display?.label || '');\n\n  return (\n    <ConfigItem label={t('form.label')}>\n      <Input\n        className=\"text-[13px]\"\n        value={value || ''}\n        onBlur={() =>\n          onChange({\n            ...display,\n            label: value,\n          })\n        }\n        onChange={(e) => {\n          setValue(e.target.value);\n        }}\n      />\n    </ConfigItem>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ComboYAisxDisplayEditor.tsx",
    "content": "import { Input } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport type { IChartYAxisDisplay } from '../../../../types';\nimport { ConfigItem } from './ConfigItem';\n\nexport const ComboYAxisDisplayEditor = (props: {\n  value?: IChartYAxisDisplay;\n  onChange: (value?: IChartYAxisDisplay) => void;\n}) => {\n  const { value, onChange } = props;\n  const { t } = useTranslation('chart');\n  const [label, setLabel] = useState(value?.label || '');\n  const [max, setMax] = useState(value?.range?.max);\n  const [min, setMin] = useState(value?.range?.min);\n\n  return (\n    <div className=\"space-y-2\">\n      <ConfigItem label={t('form.label')}>\n        <Input\n          className=\"text-[13px]\"\n          value={label}\n          onBlur={() => onChange({ ...value, label })}\n          onChange={(e) => setLabel(e.target.value)}\n        />\n      </ConfigItem>\n      <ConfigItem label={t('form.combo.range.label')}>\n        <div className=\"flex items-center gap-1\">\n          <span className=\"whitespace-nowrap text-muted-foreground\">\n            {t('form.combo.range.min')}\n          </span>\n          <Input\n            type=\"number\"\n            className=\"text-[13px]\"\n            size=\"sm\"\n            value={min ?? ''}\n            onChange={(e) => setMin(e.target.value.length ? parseFloat(e.target.value) : undefined)}\n            onBlur={() => {\n              onChange({\n                ...value,\n                range: {\n                  ...value?.range,\n                  min,\n                },\n              });\n            }}\n          />\n          <span className=\"ml-2 whitespace-nowrap text-muted-foreground\">\n            {t('form.combo.range.max')}\n          </span>\n          <Input\n            type=\"number\"\n            className=\"text-[13px]\"\n            size=\"sm\"\n            value={max ?? ''}\n            onChange={(e) => setMax(e.target.value.length ? parseFloat(e.target.value) : undefined)}\n            onBlur={() => {\n              onChange({\n                ...value,\n                range: {\n                  ...value?.range,\n                  max,\n                },\n              });\n            }}\n          />\n        </div>\n      </ConfigItem>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/ConfigItem.tsx",
    "content": "import { cn } from '@teable/ui-lib';\n\nexport const ConfigItem = (props: {\n  className?: string;\n  children: React.ReactNode;\n  label: string | React.ReactNode;\n}) => {\n  const { className, children, label } = props;\n  return (\n    <div className={cn('space-y-2 px-0.5', className)}>\n      <label className=\"text-sm font-normal\">{label}</label>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/GoalLineEditor.tsx",
    "content": "import { Input, Label, Switch } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport type { IGoalLine } from '../../../../types';\nimport { ConfigItem } from './ConfigItem';\n\nexport const GoalLineEditor = (props: {\n  value?: IGoalLine;\n  onChange: (value?: IGoalLine) => void;\n}) => {\n  const { value, onChange } = props;\n  const { t } = useTranslation('chart');\n  const [text, setText] = useState(value?.label);\n  const [number, setNumber] = useState(value?.value);\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between\">\n        <Label className=\"text-sm font-normal\" htmlFor=\"goal-line-switch\">\n          {t('form.combo.goalLine.label')}\n        </Label>\n        <Switch\n          id=\"goal-line-switch\"\n          checked={value?.enabled}\n          onCheckedChange={(e) => {\n            onChange({\n              ...value,\n              enabled: e,\n            });\n          }}\n        />\n      </div>\n      {value?.enabled && (\n        <div className=\"space-y-3\">\n          <ConfigItem label={t('form.value')}>\n            <Input\n              type=\"number\"\n              className=\"text-[13px]\"\n              size=\"sm\"\n              value={number || ''}\n              onBlur={() =>\n                onChange({\n                  ...props.value,\n                  value: number,\n                })\n              }\n              onChange={(e) => {\n                const number = parseFloat(e.target.value);\n                setNumber(isNaN(number) ? undefined : number);\n              }}\n            />\n          </ConfigItem>\n          <ConfigItem label={t('form.label')}>\n            <Input\n              className=\"text-[13px]\"\n              size=\"sm\"\n              value={text || ''}\n              onBlur={() =>\n                onChange({\n                  ...value,\n                  label: text,\n                })\n              }\n              onChange={(e) => setText(e.target.value)}\n            />\n          </ConfigItem>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/NumberInput.tsx",
    "content": "import { cn, Input } from '@teable/ui-lib';\nimport { useState } from 'react';\n\nexport interface INumberInputProps {\n  className?: string;\n  value?: number;\n  onValueChange?: (value: number | undefined) => void;\n  decimal?: number;\n  min?: number;\n  max?: number;\n}\n\nexport const NumberInput = (props: INumberInputProps) => {\n  const { className, decimal, min, max, onValueChange } = props;\n  const [value, setValue] = useState(props.value);\n\n  return (\n    <Input\n      type=\"number\"\n      className={cn('text-[13px]', className)}\n      size=\"sm\"\n      value={value ?? ''}\n      onBlur={() => value !== props.value && onValueChange?.(value)}\n      onChange={(e) => {\n        if (decimal) {\n          const number = parseFloat(parseFloat(e.target.value).toFixed(decimal));\n          setValue(\n            isNaN(number)\n              ? undefined\n              : min && number < min\n                ? min\n                : max && number > max\n                  ? max\n                  : number\n          );\n          return;\n        }\n        const number = parseInt(e.target.value);\n        setValue(isNaN(number) ? undefined : number);\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/PaddingEditor.tsx",
    "content": "import { Input, Label } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport type { IChartPadding } from '../../../../types';\n\nexport const PaddingEditor = (props: {\n  value?: IChartPadding;\n  onChange: (value?: IChartPadding) => void;\n}) => {\n  const { value, onChange } = props;\n  const { t } = useTranslation('chart');\n  const [padding, setPadding] = useState(value);\n  return (\n    <div className=\"flex flex-col gap-2 p-0.5\">\n      <div className=\"flex items-center gap-2\">\n        <Label className=\"w-14 text-right text-xs\">{t('form.padding.top')}</Label>\n        <Input\n          type=\"number\"\n          className=\"text-[13px]\"\n          size=\"sm\"\n          value={padding?.top || ''}\n          onBlur={() => onChange(padding)}\n          onChange={(e) => {\n            const number = parseFloat(e.target.value);\n            setPadding({\n              ...padding,\n              top: isNaN(number) ? undefined : number,\n            });\n          }}\n        />\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Label className=\"w-14 text-right text-xs\">{t('form.padding.right')}</Label>\n        <Input\n          type=\"number\"\n          className=\"text-[13px]\"\n          size=\"sm\"\n          value={padding?.right || ''}\n          onBlur={() => onChange(padding)}\n          onChange={(e) => {\n            const number = parseFloat(e.target.value);\n            setPadding({\n              ...padding,\n              right: isNaN(number) ? undefined : number,\n            });\n          }}\n        />\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Label className=\"w-14 text-right text-xs\">{t('form.padding.bottom')}</Label>\n        <Input\n          type=\"number\"\n          className=\"text-[13px]\"\n          size=\"sm\"\n          value={padding?.bottom || ''}\n          onBlur={() => onChange(padding)}\n          onChange={(e) => {\n            const number = parseFloat(e.target.value);\n            setPadding({\n              ...padding,\n              bottom: isNaN(number) ? undefined : number,\n            });\n          }}\n        />\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Label className=\"w-14 text-right text-xs\">{t('form.padding.left')}</Label>\n        <Input\n          type=\"number\"\n          className=\"text-[13px]\"\n          size=\"sm\"\n          value={padding?.left || ''}\n          onBlur={() => onChange(padding)}\n          onChange={(e) => {\n            const number = parseFloat(e.target.value);\n            setPadding({\n              ...padding,\n              left: isNaN(number) ? undefined : number,\n            });\n          }}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/SwitchEditor.tsx",
    "content": "import { Switch, Label } from '@teable/ui-lib';\nimport { useRef } from 'react';\n\nexport const SwitchEditor = (props: {\n  label?: string;\n  value?: boolean;\n  onChange: (value?: boolean) => void;\n}) => {\n  const { label, value, onChange } = props;\n  const randomRef = useRef(Math.random().toString(36).substring(7));\n  return (\n    <div className=\"flex items-center justify-between\">\n      <Label className=\"text-sm font-normal\" htmlFor={`${randomRef.current}`}>\n        {label}\n      </Label>\n      <Switch\n        id={`${randomRef.current}`}\n        checked={value}\n        onCheckedChange={(checked) => {\n          onChange(checked);\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/common/YAxisPositionEditor.tsx",
    "content": "import { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { IChartBaseAxisDisplay } from '../../../../types';\n\nexport const YAxisPositionEditor = (props: {\n  value?: IChartBaseAxisDisplay['position'];\n  onChange: (value: IChartBaseAxisDisplay['position']) => void;\n}) => {\n  const { value: position, onChange } = props;\n  const { t } = useTranslation(['chart']);\n  const positions = useMemo(() => {\n    return [\n      {\n        label: t('form.combo.position.auto'),\n        value: 'auto',\n      },\n      {\n        label: t('form.combo.position.left'),\n        value: 'left',\n      },\n      {\n        label: t('form.combo.position.right'),\n        value: 'right',\n      },\n    ] as const;\n  }, [t]);\n\n  return (\n    <RadioGroup className=\"flex gap-4\" value={position} onValueChange={onChange}>\n      {positions.map(({ label, value }) => (\n        <div key={value} className=\"flex items-center gap-2\">\n          <RadioGroupItem value={value} id={value} />\n          <Label\n            title={label}\n            htmlFor={value}\n            className=\"flex items-center gap-2 text-xs font-normal\"\n          >\n            {label}\n          </Label>\n        </div>\n      ))}\n    </RadioGroup>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/AreaForm.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { IAreaConfig } from '../../../../types';\nimport { SwitchEditor } from '../common/SwitchEditor';\nimport { ComboForm } from './ComboForm';\n\nexport const AreaForm = (props: {\n  config: IAreaConfig;\n  onChange: (config: IAreaConfig) => void;\n}) => {\n  const { config, onChange } = props;\n  const { t } = useTranslation('chart');\n\n  return (\n    <div className=\"space-y-5\">\n      <ComboForm\n        type=\"area\"\n        config={config}\n        onChange={(val) => {\n          onChange({\n            type: 'area',\n            ...val,\n          });\n        }}\n      />\n      <SwitchEditor\n        label={t('form.combo.stack')}\n        value={config.stack}\n        onChange={(checked) => {\n          onChange({\n            ...config,\n            stack: checked,\n          });\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/BarForm.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { IBarConfig } from '../../../../types';\nimport { SwitchEditor } from '../common/SwitchEditor';\nimport { ComboForm } from './ComboForm';\n\nexport const BarForm = (props: { config: IBarConfig; onChange: (config: IBarConfig) => void }) => {\n  const { config, onChange } = props;\n  const { t } = useTranslation('chart');\n  return (\n    <div className=\"space-y-5\">\n      <ComboForm\n        type=\"bar\"\n        config={config}\n        onChange={(val) => {\n          onChange({\n            type: 'bar',\n            ...val,\n          });\n        }}\n      />\n      <SwitchEditor\n        label={t('form.combo.stack')}\n        value={config.stack}\n        onChange={(checked) => {\n          onChange({\n            ...config,\n            stack: checked,\n          });\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/ComboForm.tsx",
    "content": "import {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n  Button,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  Separator,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport { useFilterNumberColumns } from '../../../../hooks/useFilterNumberColumns';\nimport type { IComboConfig, IComboType } from '../../../../types';\nimport { ComboXAxisDisplayEditor } from '../common/ComboXAxisDisplayEditor';\nimport { ComboYAxisDisplayEditor } from '../common/ComboYAisxDisplayEditor';\nimport { ConfigItem } from '../common/ConfigItem';\nimport { GoalLineEditor } from '../common/GoalLineEditor';\nimport { PaddingEditor } from '../common/PaddingEditor';\nimport { SwitchEditor } from '../common/SwitchEditor';\nimport { ComboXAxisEditor } from './ComboXAxisEditor';\nimport { ComboYAxisEditor } from './ComboYAxisEditor';\nimport type { ComboYAxis } from './utils';\nimport { getComboXAxisDefaultDisplay, getComboYAxisDefaultDisplay } from './utils';\n\nexport const ComboForm = (props: {\n  type: IComboType;\n  config: IComboConfig;\n  onChange: (config: IComboConfig) => void;\n}) => {\n  const { type, config, onChange } = props;\n  const { t } = useTranslation('chart');\n  const baseQueryData = useBaseQueryData();\n  const xColumns = baseQueryData?.columns ?? [];\n  const yColumns = useFilterNumberColumns(baseQueryData?.columns);\n  const selectedXColumns = config.xAxis?.map((x) => x.column) ?? [];\n  const selectedYColumns = config.yAxis?.map((y) => y.column) ?? [];\n\n  const canAddXColumns = xColumns.filter((v) => !selectedXColumns.includes(v.column));\n  const canAddYColumns = yColumns.filter((v) => !selectedYColumns.includes(v.column));\n  const onChangeXAxis = (xAxisItem: ComboYAxis, index: number) => {\n    const newXAxis = config.xAxis ? [...config.xAxis!] : [];\n    newXAxis[index] = xAxisItem;\n    onChange({\n      ...config,\n      xAxis: newXAxis,\n    });\n  };\n\n  const onChangeYAxis = (yAxisItem: ComboYAxis, index: number) => {\n    const newYAxis = config.yAxis ? [...config.yAxis!] : [];\n    newYAxis[index] = yAxisItem;\n    onChange({\n      ...config,\n      yAxis: newYAxis,\n    });\n  };\n\n  const onAddXAxis = (column: string) => {\n    const newItem = {\n      column: column,\n      display: getComboXAxisDefaultDisplay(type),\n    };\n    onChange({\n      ...config,\n      xAxis: config.xAxis ? [...config.xAxis, newItem] : [newItem],\n    });\n  };\n\n  const onAddYAxis = (column: string) => {\n    const newItem = {\n      column: column,\n      display: getComboYAxisDefaultDisplay(type),\n    };\n    onChange({\n      ...config,\n      yAxis: config.yAxis ? [...config.yAxis, newItem] : [newItem],\n    });\n  };\n\n  const onDeleteXAxis = (index: number) => {\n    const newXAxis = config.xAxis ? [...config.xAxis] : [];\n    newXAxis.splice(index, 1);\n    onChange({\n      ...config,\n      xAxis: newXAxis,\n    });\n  };\n\n  const onDeleteYAxis = (index: number) => {\n    const newYAxis = config.yAxis ? [...config.yAxis] : [];\n    newYAxis.splice(index, 1);\n    onChange({\n      ...config,\n      yAxis: newYAxis,\n    });\n  };\n\n  const xAxisLen = config.xAxis?.length ?? 0;\n  const yAxisLen = config.yAxis?.length ?? 0;\n  const hiddenDeleteXAxisBtn = xAxisLen && xAxisLen < 2;\n  const hiddenDeleteYAxisBtn = yAxisLen && yAxisLen < 2;\n  // TODO: Support multiple x-axis\n  const hiddenAddXAxisBtn = canAddXColumns.length === 0 || xAxisLen === 1;\n  const hiddenAddYAxisBtn = canAddYColumns.length === 0;\n\n  return (\n    <div className=\"space-y-6\">\n      <ConfigItem label={t('form.combo.xAxis.label')}>\n        <div>\n          <div className=\"space-y-2\">\n            {config.xAxis?.map((xAxisItem, index) => (\n              <ComboXAxisEditor\n                key={xAxisItem.column}\n                value={xAxisItem}\n                selectedColumns={selectedXColumns}\n                onChange={(xAxisItem) => {\n                  onChangeXAxis(xAxisItem, index);\n                }}\n                onDelete={() => onDeleteXAxis(index)}\n                hiddenDelete={!!hiddenDeleteXAxisBtn}\n                hiddenSettings\n              />\n            ))}\n          </div>\n          {!hiddenAddXAxisBtn && (\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button className=\"mt-2 block h-auto p-0\" variant=\"link\">\n                  {t('form.combo.addXAxis')}\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"start\" className=\"w-52\">\n                {canAddXColumns.map((column) => (\n                  <DropdownMenuItem\n                    key={column.column}\n                    onClick={() => {\n                      onAddXAxis(column.column);\n                    }}\n                  >\n                    {column.name}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuContent>\n            </DropdownMenu>\n          )}\n        </div>\n      </ConfigItem>\n      <ConfigItem label={t('form.combo.yAxis.label')}>\n        <div>\n          <div className=\"space-y-3\">\n            {config.yAxis?.map((yAxisItem, index) => (\n              <ComboYAxisEditor\n                key={yAxisItem.column}\n                value={yAxisItem}\n                selectedColumns={selectedYColumns}\n                onChange={(yAxisItem) => {\n                  onChangeYAxis(yAxisItem, index);\n                }}\n                onDelete={() => onDeleteYAxis(index)}\n                hiddenDelete={!!hiddenDeleteYAxisBtn}\n              />\n            ))}\n          </div>\n          {!hiddenAddYAxisBtn && (\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button className=\"mt-2 block h-auto p-0\" variant=\"link\">\n                  {t('form.combo.addYAxis')}\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"start\" className=\"w-52\">\n                {canAddYColumns.map((column) => (\n                  <DropdownMenuItem\n                    key={column.column}\n                    onClick={() => {\n                      onAddYAxis(column.column);\n                    }}\n                  >\n                    {column.name}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuContent>\n            </DropdownMenu>\n          )}\n        </div>\n      </ConfigItem>\n      <div>\n        <Separator />\n        <Accordion type=\"multiple\" className=\"w-full\">\n          <AccordionItem value=\"item-1\">\n            <AccordionTrigger>{t('form.combo.xDisplay.label')}</AccordionTrigger>\n            <AccordionContent>\n              <ComboXAxisDisplayEditor\n                value={config.xAxisDisplay}\n                onChange={(val) => {\n                  onChange({\n                    ...config,\n                    xAxisDisplay: val,\n                  });\n                }}\n              />\n            </AccordionContent>\n          </AccordionItem>\n          <AccordionItem value=\"item-2\">\n            <AccordionTrigger>{t('form.combo.yDisplay.label')}</AccordionTrigger>\n            <AccordionContent>\n              <ComboYAxisDisplayEditor\n                value={config.yAxisDisplay}\n                onChange={(val) => {\n                  onChange({\n                    ...config,\n                    yAxisDisplay: val,\n                  });\n                }}\n              />\n            </AccordionContent>\n          </AccordionItem>\n          <AccordionItem value=\"item-3\">\n            <AccordionTrigger>{t('form.padding.label')}</AccordionTrigger>\n            <AccordionContent>\n              <PaddingEditor\n                value={config.padding}\n                onChange={(val) => {\n                  onChange({\n                    ...config,\n                    padding: val,\n                  });\n                }}\n              />\n            </AccordionContent>\n          </AccordionItem>\n        </Accordion>\n      </div>\n      <SwitchEditor\n        label={t('form.showLabel')}\n        value={config.showLabel}\n        onChange={(val) => {\n          onChange({\n            ...config,\n            showLabel: val,\n          });\n        }}\n      />\n      <GoalLineEditor\n        value={config.goalLine}\n        onChange={(val) => {\n          onChange({\n            ...config,\n            goalLine: val,\n          });\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/ComboXAxisEditor.tsx",
    "content": "import { Settings, X } from '@teable/icons';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport type { IChartBaseAxisDisplay, IComboConfig } from '../../../../types';\nimport { AxisDisplayBaseContent } from '../common/AxisDisplayBaseContent';\nimport { ColumnSelector } from '../common/ColumnSelector';\n\ntype ComboXAxis = NonNullable<IComboConfig['xAxis']>[number];\n\nexport const ComboXAxisEditor = (props: {\n  value: ComboXAxis;\n  selectedColumns: string[];\n  onChange: (value: ComboXAxis) => void;\n  onDelete: () => void;\n  hiddenSettings?: boolean;\n  hiddenDelete?: boolean;\n}) => {\n  const { value, selectedColumns, onChange, onDelete, hiddenDelete, hiddenSettings } = props;\n\n  const baseQueryData = useBaseQueryData();\n  const columns =\n    baseQueryData?.columns?.filter(\n      ({ column }) => column === value.column || !selectedColumns.includes(column)\n    ) ?? [];\n\n  const displayValue = value?.display;\n  const onChangeDisplay = (display: IChartBaseAxisDisplay) => {\n    if (!value) {\n      return;\n    }\n    onChange({\n      ...value,\n      display,\n    });\n  };\n\n  return (\n    <div className=\"relative flex items-center gap-2\">\n      <ColumnSelector\n        className=\"flex-1\"\n        value={value?.column}\n        onChange={(xAxisCol) =>\n          onChange({\n            ...value,\n            column: xAxisCol,\n          })\n        }\n        columns={columns}\n      />\n      {!hiddenSettings && value?.column && displayValue && (\n        <XAxisDisplayEditor value={displayValue} onChange={onChangeDisplay} />\n      )}\n      {!hiddenDelete && (\n        <Button size=\"icon-xs\" variant=\"outline\" onClick={onDelete}>\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      )}\n    </div>\n  );\n};\n\nexport const XAxisDisplayEditor = (props: {\n  className?: string;\n  value: IChartBaseAxisDisplay;\n  onChange: (value: IChartBaseAxisDisplay) => void;\n}) => {\n  const { value: displayValue, onChange, className } = props;\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button className={className} size=\"icon-xs\" variant={'outline'}>\n          <Settings className=\"size-4 shrink-0\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"max-h-64 space-y-4 overflow-auto\">\n        <AxisDisplayBaseContent value={displayValue} onChange={onChange} />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/ComboYAxisEditor.tsx",
    "content": "import { Settings, X } from '@teable/icons';\nimport { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport { useFilterNumberColumns } from '../../../../hooks/useFilterNumberColumns';\nimport { AxisDisplayBaseContent } from '../common/AxisDisplayBaseContent';\nimport { ColumnSelector } from '../common/ColumnSelector';\nimport { ConfigItem } from '../common/ConfigItem';\nimport type { ComboYAxis } from './utils';\n\nexport const ComboYAxisEditor = (props: {\n  value: ComboYAxis;\n  selectedColumns: string[];\n  onChange: (value: ComboYAxis) => void;\n  onDelete: () => void;\n  hiddenDelete?: boolean;\n}) => {\n  const { value, selectedColumns, onChange, onDelete, hiddenDelete } = props;\n\n  const baseQueryData = useBaseQueryData();\n  const allColumns = useFilterNumberColumns(baseQueryData?.columns ?? []);\n\n  const columns = allColumns.filter(\n    ({ column }) => column === value.column || !selectedColumns.includes(column)\n  );\n\n  const displayValue = value?.display;\n  const onChangeConfig = (config: Omit<ComboYAxis, 'column'>) => {\n    if (!value) {\n      return;\n    }\n    onChange({\n      ...value,\n      decimal: config.decimal,\n      prefix: config.prefix,\n      suffix: config.suffix,\n      display: config.display,\n      label: config.label,\n    });\n  };\n\n  return (\n    <div className=\"relative flex items-center gap-2\">\n      <ColumnSelector\n        className=\"flex-1\"\n        value={value?.column}\n        onChange={(yAxisCol) =>\n          onChange({\n            ...value,\n            column: yAxisCol,\n          })\n        }\n        columns={columns}\n      />\n      {value?.column && displayValue && (\n        <YAxisConfigEditor value={value} onChange={onChangeConfig} />\n      )}\n      {!hiddenDelete && (\n        <Button size=\"icon-xs\" variant=\"outline\" onClick={onDelete}>\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      )}\n    </div>\n  );\n};\n\nconst YAxisConfigEditor = (props: {\n  className?: string;\n  value: Omit<ComboYAxis, 'column'>;\n  onChange: (value: Omit<ComboYAxis, 'column'>) => void;\n}) => {\n  const { value, onChange, className } = props;\n  const { t } = useTranslation('chart');\n  const [suffix, setSuffix] = useState(value.suffix);\n  const [prefix, setPrefix] = useState(value.prefix);\n  const [decimal, setDecimal] = useState(value.decimal);\n  const [label, setLabel] = useState(value.label);\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button className={className} size=\"icon-xs\" variant={'outline'}>\n          <Settings className=\"size-4 shrink-0\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"max-h-64 space-y-4 overflow-auto\">\n        <AxisDisplayBaseContent\n          value={value.display}\n          onChange={(val) => {\n            onChange({ ...value, display: val });\n          }}\n        />\n        <ConfigItem label={t('form.label')}>\n          <Input\n            className=\"text-[13px]\"\n            size=\"sm\"\n            value={label || ''}\n            onBlur={() => onChange({ ...value, label })}\n            onChange={(e) => setLabel(e.target.value)}\n          />\n        </ConfigItem>\n        <ConfigItem label={t('form.prefix')}>\n          <Input\n            className=\"text-[13px]\"\n            size=\"sm\"\n            value={prefix || ''}\n            onBlur={() => onChange({ ...value, prefix })}\n            onChange={(e) => setPrefix(e.target.value)}\n          />\n        </ConfigItem>\n        <ConfigItem label={t('form.suffix')}>\n          <Input\n            className=\"text-[13px]\"\n            size=\"sm\"\n            value={suffix || ''}\n            onBlur={() => onChange({ ...value, suffix })}\n            onChange={(e) => setSuffix(e.target.value)}\n          />\n        </ConfigItem>\n        <ConfigItem label={t('form.decimal')}>\n          <Input\n            value={decimal ?? ''}\n            className=\"text-[13px]\"\n            size=\"sm\"\n            type=\"number\"\n            onBlur={() => {\n              const newValue = decimal ? Math.max(0, Math.min(decimal, 10)) : undefined;\n              onChange({\n                ...value,\n                decimal: newValue,\n              });\n              setDecimal(newValue);\n            }}\n            onChange={(e) => {\n              const number = parseInt(e.target.value);\n              setDecimal(isNaN(number) ? undefined : number);\n            }}\n          />\n        </ConfigItem>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/LineForm.tsx",
    "content": "import type { ILineConfig } from '../../../../types';\nimport { ComboForm } from './ComboForm';\n\nexport const LineForm = (props: {\n  config: ILineConfig;\n  onChange: (config: ILineConfig) => void;\n}) => {\n  const { config, onChange } = props;\n\n  return (\n    <ComboForm\n      type=\"line\"\n      config={config}\n      onChange={(val) => {\n        onChange({\n          type: 'line',\n          ...val,\n        });\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/PieForm.tsx",
    "content": "import { Settings } from '@teable/icons';\nimport { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport { useFilterNumberColumns } from '../../../../hooks/useFilterNumberColumns';\nimport type { IPieConfig } from '../../../../types';\nimport { ColumnSelector } from '../common/ColumnSelector';\nimport { ConfigItem } from '../common/ConfigItem';\nimport { PaddingEditor } from '../common/PaddingEditor';\nimport { SwitchEditor } from '../common/SwitchEditor';\n\nexport const PieForm = (props: { config: IPieConfig; onChange: (config: IPieConfig) => void }) => {\n  const { config, onChange } = props;\n  const { t } = useTranslation('chart');\n  const baseQueryData = useBaseQueryData();\n  const xColumns = baseQueryData?.columns ?? [];\n  const yColumns = useFilterNumberColumns(baseQueryData?.columns);\n  const [decimal, setDecimal] = useState<number | undefined>(config.measure?.decimal);\n  const [prefix, setPrefix] = useState<string | undefined>(config.measure?.prefix);\n  const [suffix, setSuffix] = useState<string | undefined>(config.measure?.suffix);\n  return (\n    <div className=\"space-y-4\">\n      <ConfigItem label={t('form.pie.dimension')}>\n        <ColumnSelector\n          value={config.dimension}\n          onChange={(val) => {\n            onChange({ ...config, dimension: val });\n          }}\n          columns={xColumns}\n        />\n      </ConfigItem>\n      <ConfigItem label={t('form.pie.measure')}>\n        <div className=\"flex items-center gap-2 \">\n          <ColumnSelector\n            className=\"flex-1\"\n            value={config.measure?.column}\n            onChange={(val) => {\n              onChange({\n                ...config,\n                measure: {\n                  ...config.measure,\n                  column: val,\n                },\n              });\n            }}\n            columns={yColumns}\n          />\n          {config.measure && config.measure.column && (\n            <Popover>\n              <PopoverTrigger asChild>\n                <Button size=\"icon-xs\" variant={'outline'}>\n                  <Settings className=\"size-4 shrink-0\" />\n                </Button>\n              </PopoverTrigger>\n              <PopoverContent className=\"space-y-4\">\n                <ConfigItem label={t('form.decimal')}>\n                  <Input\n                    className=\"text-[13px]\"\n                    size=\"sm\"\n                    type=\"number\"\n                    step={1}\n                    value={decimal ?? ''}\n                    onBlur={() => {\n                      const newValue = decimal ? Math.max(0, Math.min(decimal, 10)) : undefined;\n\n                      onChange({\n                        ...config,\n                        measure: {\n                          ...config.measure!,\n                          decimal: newValue,\n                        },\n                      });\n                      setDecimal(newValue);\n                    }}\n                    onChange={(e) => {\n                      const number = parseInt(e.target.value);\n                      setDecimal(isNaN(number) ? undefined : number);\n                    }}\n                  />\n                </ConfigItem>\n                <ConfigItem label={t('form.prefix')}>\n                  <Input\n                    className=\"text-[13px]\"\n                    size=\"sm\"\n                    value={prefix ?? ''}\n                    onBlur={() => {\n                      onChange({\n                        ...config,\n                        measure: {\n                          ...config.measure!,\n                          prefix,\n                        },\n                      });\n                    }}\n                    onChange={(e) => {\n                      setPrefix(e.target.value);\n                    }}\n                  />\n                </ConfigItem>\n                <ConfigItem label={t('form.suffix')}>\n                  <Input\n                    className=\"text-[13px]\"\n                    size=\"sm\"\n                    value={suffix ?? ''}\n                    onBlur={() => {\n                      onChange({\n                        ...config,\n                        measure: {\n                          ...config.measure!,\n                          suffix,\n                        },\n                      });\n                    }}\n                    onChange={(e) => {\n                      setSuffix(e.target.value);\n                    }}\n                  />\n                </ConfigItem>\n              </PopoverContent>\n            </Popover>\n          )}\n        </div>\n      </ConfigItem>\n      <SwitchEditor\n        label={t('form.showLabel')}\n        value={config.showLabel}\n        onChange={(val) => {\n          onChange({\n            ...config,\n            showLabel: val,\n          });\n        }}\n      />\n      <SwitchEditor\n        label={t('form.pie.showTotal')}\n        value={config.showTotal}\n        onChange={(val) => {\n          onChange({\n            ...config,\n            showTotal: val,\n          });\n        }}\n      />\n      <SwitchEditor\n        label={t('form.showLegend')}\n        value={config.showLegend}\n        onChange={(val) => {\n          onChange({\n            ...config,\n            showLegend: val,\n          });\n        }}\n      />\n      <ConfigItem label={t('form.padding.label')}>\n        <PaddingEditor\n          value={config.padding}\n          onChange={(val) => {\n            onChange({\n              ...config,\n              padding: val,\n            });\n          }}\n        />\n      </ConfigItem>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/TableForm.tsx",
    "content": "import { DraggableHandle, Settings } from '@teable/icons';\nimport type { IBaseQueryColumn } from '@teable/openapi';\nimport { useIsHydrated } from '@teable/sdk';\nimport type { DragEndEvent } from '@teable/ui-lib';\nimport {\n  DndKitContext,\n  Droppable,\n  Draggable,\n  Button,\n  Popover,\n  PopoverTrigger,\n  PopoverContent,\n  Input,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport type { ITableConfig } from '../../../../types';\nimport { sortTableColumns, tableConfigColumnsToMap } from '../../utils';\nimport { ConfigItem } from '../common/ConfigItem';\nimport { NumberInput } from '../common/NumberInput';\n\nexport const TableForm = (props: {\n  config: ITableConfig;\n  onChange: (config: ITableConfig) => void;\n}) => {\n  const { config, onChange } = props;\n  const { columns: configColumns } = config;\n  const queryData = useBaseQueryData();\n  const columns = queryData?.columns;\n  const [sortedColumns, setSortedColumns] = useState<IBaseQueryColumn[]>([]);\n  const isHydrated = useIsHydrated();\n  const { t } = useTranslation('chart');\n  const configColumnMap = useMemo(() => tableConfigColumnsToMap(configColumns), [configColumns]);\n\n  useEffect(() => {\n    if (!columns) {\n      return setSortedColumns([]);\n    }\n    setSortedColumns(sortTableColumns(columns, configColumnMap));\n  }, [columns, configColumnMap]);\n\n  if (!isHydrated) {\n    return null;\n  }\n\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n\n    if (!over || !sortedColumns || from === to) {\n      return;\n    }\n\n    const list = [...sortedColumns];\n    const [base] = list.splice(from, 1);\n\n    list.splice(to, 0, base);\n\n    setSortedColumns(list);\n\n    onChange({\n      ...config,\n      columns: list.map((v) => configColumnMap[v.column] ?? { column: v.column }),\n    });\n  };\n\n  const onWidthChange = (column: string, width?: number) => {\n    if (!config.columns) {\n      onChange({\n        ...config,\n        columns: [{ column, width }],\n      });\n      return;\n    }\n    const newColumns = config.columns.map((v) => {\n      if (v.column === column) {\n        return {\n          ...v,\n          width,\n        };\n      }\n      return v;\n    });\n\n    onChange({\n      ...config,\n      columns: newColumns,\n    });\n  };\n\n  const onLabelChange = (column: string, label?: string) => {\n    if (!config.columns) {\n      onChange({\n        ...config,\n        columns: [{ column, label }],\n      });\n      return;\n    }\n    const newColumns = config.columns.map((v) => {\n      if (v.column === column) {\n        return {\n          ...v,\n          label,\n        };\n      }\n      return v;\n    });\n\n    onChange({\n      ...config,\n      columns: newColumns,\n    });\n  };\n\n  return (\n    <ConfigItem label={t('form.tableConfig')}>\n      <div className=\"space-y-4\">\n        <DndKitContext onDragEnd={onDragEndHandler}>\n          <Droppable items={sortedColumns.map((v) => v.column)}>\n            {sortedColumns.map((column) => (\n              <Draggable key={column.column} id={column.column}>\n                {({ setNodeRef, attributes, listeners, style }) => (\n                  <div\n                    ref={setNodeRef}\n                    {...attributes}\n                    {...listeners}\n                    style={style}\n                    className=\"flex items-center gap-2 rounded border bg-background p-1\"\n                  >\n                    <DraggableHandle />\n                    <div className=\"flex-1 text-[13px]\">{column.name}</div>\n                    <Popover>\n                      <PopoverTrigger>\n                        <Button variant=\"ghost\" size=\"icon-xs\">\n                          <Settings className=\"size-4 shrink-0\" />\n                        </Button>\n                      </PopoverTrigger>\n                      <PopoverContent className=\"space-y-2\">\n                        <ConfigItem label={t('form.width')}>\n                          <NumberInput\n                            value={configColumnMap[column.column]?.width}\n                            onValueChange={(val) => onWidthChange(column.column, val)}\n                          />\n                        </ConfigItem>\n                        <ConfigItem label={t('form.label')}>\n                          <LabelInput\n                            value={configColumnMap[column.column]?.label}\n                            onChange={(val) => onLabelChange(column.column, val)}\n                          />\n                        </ConfigItem>\n                      </PopoverContent>\n                    </Popover>\n                  </div>\n                )}\n              </Draggable>\n            ))}\n          </Droppable>\n        </DndKitContext>\n      </div>\n    </ConfigItem>\n  );\n};\n\nconst LabelInput = (props: { value?: string; onChange: (value?: string) => void }) => {\n  const { value, onChange } = props;\n  const [inputValue, setInputValue] = useState(value);\n  return (\n    <Input\n      className=\"text-[13px]\"\n      size=\"sm\"\n      value={inputValue}\n      onBlur={() => inputValue !== value && onChange(inputValue)}\n      onChange={(e) => setInputValue(e.target.value)}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-config/form/utils.ts",
    "content": "import type { IComboConfig, IComboType } from '../../../../types';\n\nexport type ComboXAxis = NonNullable<IComboConfig['xAxis']>[number];\nexport type ComboYAxis = NonNullable<IComboConfig['yAxis']>[number];\n\nexport const getComboXAxisDefaultDisplay = (type: IComboType): ComboXAxis['display'] => {\n  switch (type) {\n    case 'bar':\n      return {\n        type,\n        position: 'auto',\n      };\n    case 'area':\n    case 'line':\n      return {\n        lineStyle: 'normal',\n        type,\n        position: 'auto',\n      };\n    default:\n      throw new Error('Invalid type');\n  }\n};\n\n// eslint-disable-next-line sonarjs/no-identical-functions\nexport const getComboYAxisDefaultDisplay = (type: IComboType): ComboYAxis['display'] => {\n  switch (type) {\n    case 'bar':\n      return {\n        type,\n        position: 'auto',\n      };\n    case 'area':\n    case 'line':\n      return {\n        lineStyle: 'normal',\n        type,\n        position: 'auto',\n      };\n    default:\n      throw new Error('Invalid type');\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/ChartDisplay.tsx",
    "content": "import { Spin } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useContext } from 'react';\nimport { useBaseQueryData } from '../../../hooks/useBaseQueryData';\nimport { ChartContext } from '../../ChartProvider';\nimport { ChartCombo } from './combo/Combo';\nimport { ChartPie } from './pie/Pie';\nimport { ChartTable } from './table/ChartTable';\n\nexport const ChartDisplay = (props: { previewTable?: boolean }) => {\n  const { previewTable } = props;\n  const { storage, queryError } = useContext(ChartContext);\n  const queryData = useBaseQueryData();\n\n  const { t } = useTranslation('chart');\n\n  if (queryError) {\n    return (\n      <div className=\"font-sm flex size-full items-center justify-center text-center text-destructive\">\n        Error: {queryError}\n      </div>\n    );\n  }\n\n  if (!queryData) {\n    return (\n      <div>\n        <Spin />\n      </div>\n    );\n  }\n\n  if (previewTable) {\n    return <ChartTable />;\n  }\n  if (!storage?.config?.type) {\n    return;\n  }\n  switch (storage?.config?.type) {\n    case 'bar':\n    case 'line':\n    case 'area':\n      return <ChartCombo config={storage.config} defaultType={storage?.config?.type} />;\n    case 'pie':\n      return <ChartPie config={storage.config} />;\n    case 'table':\n      return <ChartTable config={storage.config} />;\n    default:\n      return <div>{t('notSupport')}</div>;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/combo/Combo.tsx",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport {\n  ChartContainer,\n  ChartLegend,\n  ChartLegendContent,\n  ChartTooltip,\n  ChartTooltipContent,\n  cn,\n} from '@teable/ui-lib';\nimport React, { useMemo, useState } from 'react';\nimport {\n  Area,\n  Bar,\n  CartesianGrid,\n  ComposedChart,\n  Label,\n  LabelList,\n  Line,\n  Rectangle,\n  ReferenceLine,\n  XAxis,\n  YAxis,\n} from 'recharts';\nimport type { Payload } from 'recharts/types/component/DefaultLegendContent';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport { useUIConfig } from '../../../../hooks/useUIConfig';\nimport type {\n  IAreaConfig,\n  IBarConfig,\n  IChartBaseAxisDisplayLine,\n  IComboConfig,\n  IComboType,\n} from '../../../../types';\nimport { TooltipItem } from './TooltipItem';\nimport { useComboConfig } from './useComboConfig';\n\nexport const ChartCombo = (props: { config: IComboConfig; defaultType?: IComboType }) => {\n  const { config, defaultType = 'bar' } = props;\n\n  const queryData = useBaseQueryData();\n  const chartConfig = useComboConfig(config, queryData?.columns);\n  const { isExpand } = useUIConfig();\n  const [hoverLegend, setHoverLegend] = useState<string>();\n  const [hiddenLegends, setHiddenLegends] = useState<string[]>([]);\n  const [hoverBarIndex, setHoverBarIndex] = useState<number>();\n\n  const handleLegendMouseEnter = (o: Payload) => {\n    const { dataKey } = o;\n    setHoverLegend(dataKey as string);\n  };\n\n  const handleLegendMouseLeave = () => {\n    setHoverLegend(undefined);\n  };\n\n  const handleLegendClick = (o: Payload) => {\n    const { dataKey } = o;\n    if (hiddenLegends.includes(dataKey as string)) {\n      setHiddenLegends(hiddenLegends.filter((legend) => legend !== dataKey));\n    } else {\n      setHiddenLegends([...hiddenLegends, dataKey as string]);\n    }\n  };\n\n  const yAxisMap = useMemo(() => {\n    if (!config.yAxis) {\n      return {};\n    }\n    return config.yAxis?.reduce(\n      (acc, yAxis) => {\n        return {\n          ...acc,\n          [yAxis.column]: yAxis,\n        };\n      },\n      {} as Record<string, NonNullable<IComboConfig['yAxis']>[number]>\n    );\n  }, [config.yAxis]);\n\n  const chartYAxis = useMemo(() => {\n    const chartYAxis: (NonNullable<IComboConfig['yAxis']>[number] & {\n      position: 'left' | 'right';\n    })[] = [];\n    config.yAxis?.forEach((yAxisItem) => {\n      const position = yAxisItem.display.position === 'auto' ? 'left' : yAxisItem.display.position;\n      if (chartYAxis.find((axis) => axis.position === position)) {\n        return;\n      }\n      chartYAxis.push({\n        ...yAxisItem,\n        position,\n      });\n    });\n    return chartYAxis;\n  }, [config.yAxis]);\n\n  const defaultYAxisId = chartYAxis.find((axis) => axis.position === 'left')?.column;\n  const showGoalLine = defaultYAxisId && config.goalLine?.enabled;\n  const xAxisConfig = config.xAxis?.[0];\n\n  const defaultMargin = isExpand\n    ? {\n        top: 20,\n        left: 12,\n        right: 12,\n        bottom: 25,\n      }\n    : {\n        top: 20,\n        left: 10,\n        right: 4,\n        bottom: 25,\n      };\n  return (\n    <div\n      className={cn('size-full', {\n        'p-4': isExpand,\n      })}\n    >\n      <ChartContainer className=\"size-full\" config={chartConfig}>\n        <ComposedChart\n          margin={{\n            top: config.padding?.top ?? defaultMargin.top,\n            left: config.padding?.left ?? defaultMargin.left,\n            right: config.padding?.right ?? defaultMargin.right,\n            bottom: config.padding?.bottom ?? defaultMargin.bottom,\n          }}\n          accessibilityLayer\n          data={queryData?.rows}\n        >\n          <CartesianGrid vertical={false} />\n          {xAxisConfig && (\n            <XAxis\n              dataKey={xAxisConfig.column}\n              tickLine={false}\n              tickMargin={isExpand ? 10 : undefined}\n              axisLine={false}\n            >\n              {<Label value={config.xAxisDisplay?.label} position=\"bottom\" fontSize={12} />}\n            </XAxis>\n          )}\n          {chartYAxis.map(({ column, position, prefix, suffix, decimal }, index) => (\n            <YAxis\n              key={column}\n              yAxisId={column}\n              label={{\n                value: config.yAxisDisplay?.label,\n                offset: config?.padding?.left ? defaultMargin.left - config.padding.left + 5 : 5,\n                angle: -90,\n                position: 'insideLeft',\n              }}\n              domain={\n                index === 0\n                  ? [\n                      config.yAxisDisplay?.range?.min ?? 0,\n                      config.yAxisDisplay?.range?.max ?? 'auto',\n                    ]\n                  : undefined\n              }\n              orientation={position}\n              fontSize={12}\n              tickLine={false}\n              axisLine={false}\n              tickMargin={10}\n              tickFormatter={(value) => {\n                return `${prefix ?? ''}${decimal ? value.toFixed(decimal) : value}${suffix ?? ''}`;\n              }}\n            />\n          ))}\n          <ChartLegend\n            verticalAlign=\"top\"\n            onMouseEnter={handleLegendMouseEnter}\n            onMouseLeave={handleLegendMouseLeave}\n            onClick={handleLegendClick}\n            content={<ChartLegendContent className=\"cursor-pointer\" />}\n          />\n          <ChartTooltip\n            cursor={false}\n            content={\n              <ChartTooltipContent\n                indicator=\"dashed\"\n                formatter={(value, name, item) => {\n                  const { prefix, decimal, suffix } = yAxisMap[name];\n                  return (\n                    <TooltipItem\n                      label={chartConfig[name].label as string}\n                      indicatorColor={item.color}\n                    >\n                      {`${prefix ?? ''}${decimal && typeof value === 'number' ? value.toFixed(decimal) : value}${suffix ?? ''}`}\n                    </TooltipItem>\n                  );\n                }}\n              />\n            }\n          />\n          {Object.keys(chartConfig).map((column) => {\n            const display = yAxisMap[column]?.display;\n            const { prefix, decimal, suffix } = yAxisMap[column];\n            const type = display.type || defaultType;\n            const lineStyle = (display as IChartBaseAxisDisplayLine).lineStyle;\n            const yAxisId = chartYAxis.find((axis) => axis.column === column)\n              ? column\n              : defaultYAxisId;\n            switch (type) {\n              case 'bar':\n                return (\n                  <Bar\n                    isAnimationActive\n                    activeIndex={hoverBarIndex}\n                    key={column}\n                    yAxisId={yAxisId}\n                    dataKey={column}\n                    strokeWidth={2}\n                    fill={`var(--color-${column})`}\n                    stackId={(config as IBarConfig).stack ? 'stack' : undefined}\n                    radius={(config as IBarConfig).stack ? 0 : 4}\n                    className=\"transition-[fill-opacity]\"\n                    fillOpacity={hoverLegend && hoverLegend !== column ? 0.5 : 1}\n                    hide={hiddenLegends.includes(column)}\n                    onMouseEnter={(o) => setHoverBarIndex(o.index)}\n                    activeBar={({ ...params }) => {\n                      return (\n                        <Rectangle\n                          {...params}\n                          stroke={params.fill}\n                          strokeDasharray={4}\n                          strokeDashoffset={4}\n                        />\n                      );\n                    }}\n                  >\n                    {config.showLabel && (\n                      <LabelList\n                        position=\"top\"\n                        offset={12}\n                        className=\"fill-foreground\"\n                        fontSize={12}\n                        formatter={(value: number) => {\n                          return `${prefix ?? ''}${decimal ? value.toFixed(decimal) : value}${suffix ?? ''}`;\n                        }}\n                      />\n                    )}\n                  </Bar>\n                );\n              case 'line':\n                return (\n                  <Line\n                    key={column}\n                    yAxisId={yAxisId}\n                    dataKey={column}\n                    type={lineStyle === 'normal' ? 'natural' : lineStyle}\n                    stroke={`var(--color-${column})`}\n                    strokeWidth={2}\n                    dot={{ fill: `var(--color-${column})` }}\n                    activeDot={{ r: 6 }}\n                    fillOpacity={hoverLegend && hoverLegend !== column ? 0.5 : 1}\n                    hide={hiddenLegends.includes(column)}\n                  >\n                    {config.showLabel && (\n                      <LabelList\n                        position=\"top\"\n                        offset={12}\n                        className=\"fill-foreground\"\n                        fontSize={12}\n                        formatter={(value: number) => {\n                          return `${prefix ?? ''}${decimal ? value.toFixed(decimal) : value}${suffix ?? ''}`;\n                        }}\n                      />\n                    )}\n                  </Line>\n                );\n              case 'area':\n                return (\n                  <Area\n                    key={column}\n                    yAxisId={yAxisId}\n                    dataKey={column}\n                    type={lineStyle === 'normal' ? 'natural' : lineStyle}\n                    stackId={(config as IAreaConfig).stack ? 'stack' : undefined}\n                    stroke={`var(--color-${column})`}\n                    fill={`var(--color-${column})`}\n                    className=\"transition-[fill-opacity]\"\n                    fillOpacity={hoverLegend === column ? 1 : 0.4}\n                    hide={hiddenLegends.includes(column)}\n                  >\n                    {config.showLabel && (\n                      <LabelList\n                        position=\"top\"\n                        offset={12}\n                        className=\"fill-foreground\"\n                        fontSize={12}\n                        formatter={(value: number) => {\n                          return `${prefix ?? ''}${decimal ? value.toFixed(decimal) : value}${suffix ?? ''}`;\n                        }}\n                      />\n                    )}\n                  </Area>\n                );\n            }\n          })}\n          {showGoalLine && (\n            <ReferenceLine\n              yAxisId={defaultYAxisId}\n              y={config.goalLine?.value ?? 0}\n              stroke=\"hsl(var(--foreground))\"\n              strokeDasharray=\"2 6\"\n            >\n              <Label\n                value={config.goalLine?.label}\n                position=\"top\"\n                fontSize={12}\n                fill=\"hsl(var(--foreground))\"\n              />\n            </ReferenceLine>\n          )}\n        </ComposedChart>\n      </ChartContainer>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/combo/TooltipItem.tsx",
    "content": "export const TooltipItem = (props: {\n  label: string;\n  indicatorColor?: string;\n  children: React.ReactNode;\n}) => {\n  const { label, indicatorColor, children } = props;\n\n  return (\n    <>\n      <div\n        className=\"size-2.5 shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]\"\n        style={\n          {\n            '--color-bg': indicatorColor,\n            '--color-border': indicatorColor,\n          } as React.CSSProperties\n        }\n      />\n      <div className=\"flex flex-1 items-center justify-between gap-2 leading-none\">\n        <div className=\"grid gap-1.5\">\n          <span className=\"text-muted-foreground\">{label}</span>\n        </div>\n        <span className=\"font-mono font-medium tabular-nums text-foreground\">{children}</span>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/combo/useComboConfig.ts",
    "content": "import type { IBaseQueryColumn } from '@teable/openapi';\nimport type { ChartConfig } from '@teable/ui-lib';\nimport { useMemo } from 'react';\nimport type { IComboConfig } from '../../../../types';\nimport { getColor } from '../utils';\n\nexport const useComboConfig = (config: IComboConfig, columns?: IBaseQueryColumn[]): ChartConfig => {\n  const { xAxis, yAxis } = config;\n\n  return useMemo(() => {\n    if (!xAxis || !yAxis || !columns) {\n      return {};\n    }\n    const columnMap = columns.reduce(\n      (acc, column) => {\n        return {\n          ...acc,\n          [column.column]: column,\n        };\n      },\n      {} as Record<string, IBaseQueryColumn>\n    );\n    return yAxis.reduce((acc, y, index) => {\n      return {\n        ...acc,\n        [y.column]: {\n          color: getColor(index),\n          label: y.label || columnMap[y.column]?.name || y.column,\n        },\n      };\n    }, {} as ChartConfig);\n  }, [columns, xAxis, yAxis]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/pie/Pie.tsx",
    "content": "import { ChartContainer, ChartLegend, ChartTooltip, ChartTooltipContent } from '@teable/ui-lib';\nimport { useMemo, useState } from 'react';\nimport { PieChart, Pie, Label, Sector } from 'recharts';\nimport type { Payload } from 'recharts/types/component/DefaultLegendContent';\nimport type { PieSectorDataItem } from 'recharts/types/polar/Pie';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport { useUIConfig } from '../../../../hooks/useUIConfig';\nimport type { IPieConfig } from '../../../../types';\nimport { TooltipItem } from '../combo/TooltipItem';\nimport { PieLegendContent } from './PieLegendContent';\nimport { usePieConfig } from './usePieConfig';\nimport { useRefObserve } from './useRefObserve';\n\nexport const ChartPie = (props: { config: IPieConfig }) => {\n  const { config } = props;\n  const queryData = useBaseQueryData();\n  const pieConfig = usePieConfig(config.dimension, queryData?.rows);\n  const [hoverLegend, setHoverLegend] = useState<number>();\n  const [hoverPieIndex, setHoverPieIndex] = useState<number>();\n\n  const { isExpand } = useUIConfig();\n  const total = useMemo(() => {\n    const measure = config.measure;\n    if (!queryData?.rows || !measure) {\n      return 0;\n    }\n    return queryData.rows.reduce((acc, cur) => acc + (cur[measure.column] as number), 0);\n  }, [queryData?.rows, config.measure]);\n\n  const chartData = useMemo(() => {\n    const dimension = config.dimension;\n    if (!queryData?.rows || !dimension) {\n      return [];\n    }\n    return queryData.rows.map((row, index) => ({\n      ...row,\n      value: `pie-${index}`,\n      fill: `var(--color-pie-${index})`,\n    }));\n  }, [config.dimension, queryData?.rows]);\n\n  const handleLegendMouseEnter = (_o: Payload, index: number) => {\n    setHoverLegend(index);\n  };\n\n  const handleLegendMouseLeave = () => {\n    setHoverLegend(undefined);\n  };\n\n  const [totalRef, { width: totalWidth }] = useRefObserve();\n  const defaultMargin = isExpand\n    ? {\n        top: 20,\n        right: 20,\n        bottom: 20,\n        left: 20,\n      }\n    : {\n        top: 0,\n        right: 0,\n        bottom: 0,\n        left: 0,\n      };\n  return (\n    <div className=\"chart-pie flex size-full items-center justify-center\">\n      {/* calculate total width */}\n      <svg className=\"pointer-events-none absolute -z-10\" style={{ visibility: 'hidden' }}>\n        <text\n          fontSize=\"20\"\n          style={{\n            visibility: 'hidden',\n          }}\n          textAnchor=\"middle\"\n          dominantBaseline=\"middle\"\n        >\n          <tspan ref={totalRef} className=\"fill-foreground text-3xl font-bold\">\n            {total}\n          </tspan>\n          <tspan className=\"fill-muted-foreground\">Total</tspan>\n        </text>\n      </svg>\n      <ChartContainer config={pieConfig} className=\"size-full\">\n        <PieChart\n          margin={{\n            top: config.padding?.top ?? defaultMargin.top,\n            left: config.padding?.left ?? defaultMargin.left,\n            right: config.padding?.right ?? defaultMargin.right,\n            bottom: config.padding?.bottom ?? defaultMargin.bottom,\n          }}\n        >\n          <ChartTooltip\n            cursor={false}\n            content={\n              <ChartTooltipContent\n                indicator=\"dashed\"\n                formatter={(value, name, item) => {\n                  const { prefix, decimal, suffix } = config.measure ?? {};\n                  return (\n                    <TooltipItem\n                      label={name as string}\n                      indicatorColor={item.color ?? item.payload.fill}\n                    >\n                      {`${prefix ?? ''}${decimal && typeof value === 'number' ? value.toFixed(decimal) : value}${suffix ?? ''}`}\n                      {`(${total ? (((value as number) / total) * 100).toFixed(2) : 0}%)`}\n                    </TooltipItem>\n                  );\n                }}\n              />\n            }\n          />\n          {config.showLegend && (\n            <ChartLegend\n              verticalAlign=\"top\"\n              onMouseEnter={handleLegendMouseEnter}\n              onMouseLeave={handleLegendMouseLeave}\n              content={<PieLegendContent className=\"cursor-pointer\" nameKey={'pieColorKey'} />}\n            />\n          )}\n          <Pie\n            data={chartData}\n            dataKey={config.measure?.column ?? ''}\n            nameKey={config.dimension}\n            innerRadius={'50%'}\n            label={\n              config.showLabel\n                ? ({ value, ...props }) => {\n                    const { prefix, decimal, suffix } = config.measure ?? {};\n                    return (\n                      <text\n                        cx={props.cx}\n                        cy={props.cy}\n                        x={props.x}\n                        y={props.y}\n                        textAnchor={props.textAnchor}\n                        dominantBaseline={props.dominantBaseline}\n                        fill={props.fill}\n                      >\n                        {`${prefix ?? ''}${decimal && typeof value === 'number' ? value.toFixed(decimal) : value}${suffix ?? ''}`}\n                        {`(${total ? ((value / total) * 100).toFixed(2) : 0}%)`}\n                      </text>\n                    );\n                  }\n                : false\n            }\n            activeIndex={hoverPieIndex ?? hoverLegend}\n            onMouseEnter={(o) => setHoverPieIndex(o.index)}\n            activeShape={({ outerRadius = 0, ...props }: PieSectorDataItem) => (\n              <Sector {...props} outerRadius={outerRadius + 10} />\n            )}\n          >\n            {config.showTotal && (\n              <Label\n                content={({ viewBox }) => {\n                  if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {\n                    const { prefix, decimal, suffix } = config.measure ?? {};\n                    const totalDisplay = `${prefix ?? ''}${decimal ? total.toFixed(decimal) : total}${suffix ?? ''}`;\n                    return (\n                      <text\n                        style={{\n                          visibility:\n                            totalWidth > viewBox.innerRadius! * 2 || 70 > viewBox.innerRadius! * 2\n                              ? 'hidden'\n                              : 'visible',\n                        }}\n                        x={viewBox.cx}\n                        y={viewBox.cy}\n                        textAnchor=\"middle\"\n                        dominantBaseline=\"middle\"\n                      >\n                        <tspan\n                          x={viewBox.cx}\n                          y={viewBox.cy}\n                          className=\"fill-foreground text-3xl font-bold\"\n                        >\n                          {totalDisplay}\n                        </tspan>\n                        <tspan\n                          x={viewBox.cx}\n                          y={(viewBox.cy || 0) + 24}\n                          className=\"fill-muted-foreground\"\n                        >\n                          Total\n                        </tspan>\n                      </text>\n                    );\n                  }\n                }}\n              />\n            )}\n          </Pie>\n        </PieChart>\n      </ChartContainer>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/pie/PieLegendContent.tsx",
    "content": "import { ChartLegendContent } from '@teable/ui-lib';\nimport React from 'react';\n\nexport const PieLegendContent = React.forwardRef<\n  React.ElementRef<typeof ChartLegendContent>,\n  React.ComponentProps<typeof ChartLegendContent>\n>((props, ref) => {\n  const { payload, ...rest } = props;\n  return (\n    <ChartLegendContent\n      ref={ref}\n      {...rest}\n      payload={payload?.map((item) => ({\n        ...item,\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        pieColorKey: `${(item.payload as any)?.payload?.value}`,\n      }))}\n    />\n  );\n});\n\nPieLegendContent.displayName = 'PieLegendContent';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/pie/usePieConfig.tsx",
    "content": "import type { ChartConfig } from '@teable/ui-lib';\nimport { useMemo } from 'react';\nimport { getColor } from '../utils';\n\nexport const usePieConfig = (dimension?: string, rows?: Record<string, unknown>[]): ChartConfig => {\n  return useMemo(() => {\n    if (!dimension || !rows) {\n      return {};\n    }\n    const labels = rows.map((row) => row[dimension]) as string[];\n    return labels.reduce((acc, label, index) => {\n      return {\n        ...acc,\n        [`pie-${index}`]: {\n          color: getColor(index),\n          label,\n        },\n      };\n    }, {} as ChartConfig);\n  }, [dimension, rows]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/pie/useRefObserve.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\n\nexport const useRefObserve = () => {\n  const ref = useRef<SVGTextElement>(null);\n  const [width, setWidth] = useState(0);\n  const [height, setHeight] = useState(0);\n  useEffect(() => {\n    const currentElement = ref.current;\n\n    const observer = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        setWidth(entry.contentRect.width);\n        setHeight(entry.contentRect.height);\n      }\n    });\n\n    if (currentElement) {\n      const { width, height } = currentElement.getBoundingClientRect();\n      setWidth(width);\n      setHeight(height);\n      observer.observe(currentElement);\n    }\n    return () => {\n      if (currentElement) {\n        observer.unobserve(currentElement);\n      }\n    };\n  }, []);\n  return [\n    ref,\n    {\n      width,\n      height,\n    },\n  ] as const;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/table/ChartTable.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CellFormat } from '@teable/core';\nimport { CellValue } from '@teable/sdk';\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow, Table } from '@teable/ui-lib';\nimport { useMemo } from 'react';\nimport { useBaseQueryData } from '../../../../hooks/useBaseQueryData';\nimport type { ITableConfig } from '../../../../types';\nimport { sortTableColumns, tableConfigColumnsToMap } from '../../utils';\n\nexport const ChartTable = (props: { config?: ITableConfig }) => {\n  const queryData = useBaseQueryData(CellFormat.Json);\n  const { config } = props;\n  const { columns: configColumns } = config ?? {};\n  const columns = queryData?.columns;\n  const configColumnMap = useMemo(() => tableConfigColumnsToMap(configColumns), [configColumns]);\n\n  const sortedColumns = useMemo(\n    () => (columns ? sortTableColumns(columns, configColumnMap) : []),\n    [columns, configColumnMap]\n  );\n\n  return (\n    <div className=\"size-full overflow-auto p-4\">\n      <Table>\n        <TableHeader>\n          <TableRow>\n            {sortedColumns.map(({ column, name }) => (\n              <TableHead\n                style={{\n                  width: configColumnMap[column]?.width\n                    ? `${configColumnMap[column]?.width}px`\n                    : 'auto',\n                }}\n                key={column}\n              >\n                {configColumnMap[column]?.label || name}\n              </TableHead>\n            ))}\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {queryData?.rows.slice(0, 50).map((row, index) => (\n            <TableRow key={index}>\n              {queryData.columns.map(({ column, fieldSource }) => (\n                <TableCell key={column}>\n                  {fieldSource ? (\n                    <CellValue\n                      formatImageUrl={(url) => url}\n                      field={fieldSource as any}\n                      value={row[column]}\n                    />\n                  ) : (\n                    `${row[column]}`\n                  )}\n                </TableCell>\n              ))}\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/types.ts",
    "content": "export interface IChartBase {\n  rows: Record<string, unknown>[];\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/chart-show/utils.ts",
    "content": "import { COLOR_MAXIMUM } from '../../../constant';\n\nexport const getColor = (index: number) => {\n  return `hsl(var(--chart-${(index % COLOR_MAXIMUM) + 1}))`;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/components/chart/utils.ts",
    "content": "import type { IBaseQueryColumn } from '@teable/openapi';\nimport type { ITableConfigColumn } from '../../types';\n\nconst isEmptyObject = (obj: Record<string, unknown>) => {\n  return Object.keys(obj).length === 0 && obj.constructor === Object;\n};\n\nexport const sortTableColumns = (\n  columns: IBaseQueryColumn[],\n  configColumnMap: Record<string, ITableConfigColumn & { index: number }>\n) => {\n  if (isEmptyObject(configColumnMap)) {\n    return columns;\n  }\n  return columns.sort((a, b) => {\n    const aIndex = configColumnMap[a.column]?.index ?? -1;\n    const bIndex = configColumnMap[b.column]?.index ?? -1;\n    return aIndex - bIndex;\n  });\n};\n\nexport const tableConfigColumnsToMap = (configColumns?: ITableConfigColumn[]) => {\n  if (!configColumns) {\n    return {};\n  }\n  return configColumns.reduce(\n    (acc, column, index) => {\n      acc[column.column] = {\n        ...column,\n        index,\n      };\n      return acc;\n    },\n    {} as Record<string, ITableConfigColumn & { index: number }>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/constant.ts",
    "content": "export const COLOR_MAXIMUM = 30;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/globals.css",
    "content": ":root {\n  --chart-1: 173 58% 39%;\n  --chart-2: 12 76% 61%;\n  --chart-3: 197 37% 24%;\n  --chart-4: 43 74% 66%;\n  --chart-5: 27 87% 67%;\n  --chart-6: 340 65% 47%;\n  --chart-7: 220 80% 30%;\n  --chart-8: 190 70% 55%;\n  --chart-9: 50 80% 40%;\n  --chart-10: 10 90% 60%;\n  --chart-11: 300 60% 70%;\n  --chart-12: 25 95% 65%;\n  --chart-13: 120 85% 50%;\n  --chart-14: 210 75% 35%;\n  --chart-15: 60 65% 80%;\n  --chart-16: 210 60% 55%;\n  --chart-17: 30 90% 70%;\n  --chart-18: 240 50% 40%;\n  --chart-19: 80 70% 50%;\n  --chart-20: 290 65% 60%;\n  --chart-21: 60 85% 30%;\n  --chart-22: 320 75% 50%;\n  --chart-23: 150 60% 70%;\n  --chart-24: 170 80% 40%;\n  --chart-25: 140 85% 55%;\n  --chart-26: 110 75% 45%;\n  --chart-27: 260 70% 50%;\n  --chart-28: 200 60% 35%;\n  --chart-29: 90 80% 55%;\n  --chart-30: 130 70% 60%;\n}\n\n.dark {\n  --chart-1: 220 70% 50%;\n  --chart-5: 160 60% 45%;\n  --chart-3: 30 80% 55%;\n  --chart-4: 280 65% 60%;\n  --chart-2: 340 75% 55%;\n  --chart-6: 200 60% 50%;\n  --chart-7: 320 85% 40%;\n  --chart-8: 100 70% 55%;\n  --chart-9: 60 75% 60%;\n  --chart-10: 330 80% 45%;\n  --chart-11: 140 65% 65%;\n  --chart-12: 50 85% 70%;\n  --chart-13: 230 75% 55%;\n  --chart-14: 90 80% 35%;\n  --chart-15: 170 60% 75%;\n  --chart-16: 220 65% 60%;\n  --chart-17: 310 70% 55%;\n  --chart-18: 250 60% 45%;\n  --chart-19: 130 80% 50%;\n  --chart-20: 80 85% 65%;\n  --chart-21: 190 75% 55%;\n  --chart-22: 70 70% 50%;\n  --chart-23: 260 80% 40%;\n  --chart-24: 160 65% 60%;\n  --chart-25: 210 70% 35%;\n  --chart-26: 100 75% 55%;\n  --chart-27: 300 65% 45%;\n  --chart-28: 180 80% 50%;\n  --chart-29: 240 60% 55%;\n  --chart-30: 110 85% 70%;\n}\n\n.chart-pie .recharts-legend-wrapper {\n  margin-top: 0;\n}\n\n.recharts-legend-wrapper {\n  margin-top: -12px;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/hooks/useBaseQueryData.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { CellFormat } from '@teable/core';\nimport {\n  getDashboardInstallPluginQuery,\n  getPluginPanelInstallPluginQuery,\n  PluginPosition,\n} from '@teable/openapi';\nimport { useMemo } from 'react';\nimport { formatRes } from '../query';\nimport { useEnv } from './useEnv';\n\nexport const useBaseQueryData = (cellFormat?: CellFormat) => {\n  const { baseId, positionId, positionType, tableId, pluginInstallId } = useEnv();\n  const { data: dashboardQueryData } = useQuery({\n    queryKey: ['dashboard-plugin-query', baseId, positionId, pluginInstallId],\n    queryFn: () =>\n      getDashboardInstallPluginQuery(pluginInstallId, positionId, {\n        baseId,\n        cellFormat,\n      }).then((res) => res.data),\n    enabled: Boolean(\n      positionType === PluginPosition.Dashboard && baseId && positionId && pluginInstallId\n    ),\n  });\n\n  const { data: pluginPanelQueryData } = useQuery({\n    queryKey: ['plugin-panel-plugin-query', tableId, positionId, pluginInstallId],\n    queryFn: () =>\n      getPluginPanelInstallPluginQuery(pluginInstallId, positionId, {\n        tableId: tableId!,\n        cellFormat,\n      }).then((res) => res.data),\n    enabled: Boolean(\n      positionType === PluginPosition.Panel && tableId && positionId && pluginInstallId\n    ),\n  });\n\n  return useMemo(() => {\n    if (positionType === PluginPosition.Dashboard) {\n      return formatRes(dashboardQueryData);\n    }\n    return formatRes(pluginPanelQueryData);\n  }, [positionType, pluginPanelQueryData, dashboardQueryData]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/hooks/useEnv.ts",
    "content": "import { useContext } from 'react';\nimport { EnvContext } from '../components/EnvProvider';\n\nexport const useEnv = () => {\n  return useContext(EnvContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/hooks/useFilterNumberColumns.ts",
    "content": "import type { IBaseQueryColumn } from '@teable/openapi';\nimport { BaseQueryColumnType } from '@teable/openapi';\nimport { useMemo } from 'react';\n\nexport const useFilterNumberColumns = (columns?: IBaseQueryColumn[]) => {\n  return useMemo(() => {\n    return (\n      columns?.filter(\n        (column) =>\n          column.type === BaseQueryColumnType.Aggregation ||\n          (column.fieldSource &&\n            column.fieldSource?.cellValueType === 'number' &&\n            !column.fieldSource.isMultipleCellValue)\n      ) ?? []\n    );\n  }, [columns]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/hooks/usePluginInstall.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getDashboardInstallPlugin, getPluginPanelPlugin, PluginPosition } from '@teable/openapi';\nimport { useEnv } from './useEnv';\n\nexport const usePluginInstall = () => {\n  const { baseId, positionId, positionType, tableId, pluginInstallId } = useEnv();\n  const { data: dashboardPluginInstall, isLoading: isDashboardPluginInstallLoading } = useQuery({\n    queryKey: ['plugin-install', baseId, positionId, pluginInstallId],\n    queryFn: () =>\n      getDashboardInstallPlugin(baseId, positionId, pluginInstallId).then((res) => res.data),\n    enabled: Boolean(\n      positionType === PluginPosition.Dashboard && baseId && positionId && pluginInstallId\n    ),\n  });\n\n  const { data: pluginPanelPluginInstall, isLoading: isPluginPanelPluginLoading } = useQuery({\n    queryKey: ['plugin-panel-plugin', tableId, positionId, pluginInstallId],\n    queryFn: () =>\n      getPluginPanelPlugin(tableId!, positionId, pluginInstallId).then((res) => res.data),\n    enabled: Boolean(\n      positionType === PluginPosition.Panel && tableId && positionId && pluginInstallId\n    ),\n  });\n\n  if (positionType === PluginPosition.Dashboard) {\n    return {\n      pluginInstall: dashboardPluginInstall,\n      isLoading: isDashboardPluginInstallLoading,\n    };\n  }\n\n  return {\n    pluginInstall: pluginPanelPluginInstall,\n    isLoading: isPluginPanelPluginLoading,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/hooks/useUIConfig.ts",
    "content": "import { useContext, useMemo } from 'react';\nimport { ChartContext } from '../components/ChartProvider';\n\nexport const useUIConfig = () => {\n  const { uiConfig } = useContext(ChartContext);\n  return useMemo(() => uiConfig ?? {}, [uiConfig]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/query.ts",
    "content": "import type { IBaseQueryVo } from '@teable/openapi';\n\nexport const formatRes = (res?: IBaseQueryVo): IBaseQueryVo => {\n  if (!res) {\n    return {\n      rows: [],\n      columns: [],\n    };\n  }\n  const { columns, rows } = res;\n  // recharts does not support column name with space\n  const formatColumn = (column: string) => column.replaceAll(' ', '_');\n  return {\n    columns: columns.map((column) => ({\n      ...column,\n      column: formatColumn(column.column),\n    })),\n    rows: rows.map((row) => {\n      const newRow: Record<string, unknown> = {};\n      columns.forEach((column) => {\n        newRow[formatColumn(column.column)] = row[column.column];\n      });\n      return newRow;\n    }),\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/chart/types.ts",
    "content": "import type { IBaseQuery, PluginPosition } from '@teable/openapi';\nimport { z } from 'zod';\n\nexport const chartBaseAxisSchema = z.object({\n  column: z.string(),\n});\n\nexport const chartBaseAxisDisplayLineSchema = z.object({\n  type: z.union([z.literal('line'), z.literal('area')]),\n  position: z.union([z.literal('auto'), z.literal('left'), z.literal('right')]),\n  lineStyle: z.union([z.literal('normal'), z.literal('linear'), z.literal('step')]),\n});\n\nexport type IChartBaseAxisDisplayLine = z.infer<typeof chartBaseAxisDisplayLineSchema>;\n\nexport const chartBaseAxisDisplaySchema = z.union([\n  z.object({\n    type: z.literal('bar'),\n    position: z.union([z.literal('auto'), z.literal('left'), z.literal('right')]),\n  }),\n  chartBaseAxisDisplayLineSchema,\n]);\n\nexport type IChartBaseAxisDisplay = z.infer<typeof chartBaseAxisDisplaySchema>;\n\nexport const chartXAxisDisplaySchema = z.object({\n  label: z.string().optional(),\n});\n\nexport type IChartXAxisDisplay = z.infer<typeof chartXAxisDisplaySchema>;\n\nexport const chartYAxisDisplaySchema = z.object({\n  label: z.string().optional(),\n  range: z\n    .object({\n      max: z.number().optional(),\n      min: z.number().optional(),\n    })\n    .optional(),\n});\n\nexport type IChartYAxisDisplay = z.infer<typeof chartYAxisDisplaySchema>;\n\nexport const goalLineSchema = z.object({\n  enabled: z.boolean().optional(),\n  value: z.number().optional(),\n  label: z.string().optional(),\n});\n\nexport const chartPaddingSchema = z.object({\n  top: z.number().optional(),\n  right: z.number().optional(),\n  bottom: z.number().optional(),\n  left: z.number().optional(),\n});\n\nexport type IChartPadding = z.infer<typeof chartPaddingSchema>;\n\nexport type IGoalLine = z.infer<typeof goalLineSchema>;\n\nexport const comboConfigSchema = z.object({\n  xAxis: z\n    .array(\n      chartBaseAxisSchema.extend({\n        display: chartBaseAxisDisplaySchema,\n      })\n    )\n    .optional(),\n  xAxisDisplay: chartXAxisDisplaySchema.optional(),\n  yAxis: z\n    .array(\n      chartBaseAxisSchema\n        .extend({\n          label: z.string().optional(),\n          prefix: z.string().optional(),\n          suffix: z.string().optional(),\n          decimal: z.number().max(10).min(0).optional(),\n        })\n        .extend({ display: chartBaseAxisDisplaySchema })\n    )\n    .optional(),\n  yAxisDisplay: chartYAxisDisplaySchema.optional(),\n  goalLine: goalLineSchema.optional(),\n  showLabel: z.boolean().optional(),\n  padding: chartPaddingSchema.optional(),\n});\n\nexport type IComboConfig = z.infer<typeof comboConfigSchema>;\n\nexport const comboTypeSchema = z.union([z.literal('bar'), z.literal('line'), z.literal('area')]);\n\nexport type IComboType = z.infer<typeof comboTypeSchema>;\n\nexport const barConfigSchema = comboConfigSchema.extend({\n  type: z.literal('bar'),\n  stack: z.boolean().optional(),\n});\n\nexport type IBarConfig = z.infer<typeof barConfigSchema>;\n\nexport const lineConfigSchema = comboConfigSchema.extend({\n  type: z.literal('line'),\n});\n\nexport type ILineConfig = z.infer<typeof lineConfigSchema>;\n\nexport const areaConfigSchema = comboConfigSchema.extend({\n  type: z.literal('area'),\n  stack: z.boolean().optional(),\n});\n\nexport type IAreaConfig = z.infer<typeof areaConfigSchema>;\n\nexport const pieConfigSchema = z.object({\n  type: z.literal('pie'),\n  dimension: z.string().optional(),\n  measure: z\n    .object({\n      column: z.string(),\n      decimal: z.number().max(10).min(0).optional(),\n      prefix: z.string().optional(),\n      suffix: z.string().optional(),\n    })\n    .optional(),\n  showLabel: z.boolean().optional(),\n  showTotal: z.boolean().optional(),\n  showLegend: z.boolean().optional(),\n  padding: chartPaddingSchema.optional(),\n});\n\nexport type IPieConfig = z.infer<typeof pieConfigSchema>;\n\nexport const tableConfigColumn = z.object({\n  column: z.string(),\n  width: z.number().optional(),\n  label: z.string().optional(),\n  hidden: z.boolean().optional(),\n});\nexport type ITableConfigColumn = z.infer<typeof tableConfigColumn>;\nexport const tableConfigSchema = z.object({\n  type: z.literal('table'),\n  columns: z.array(tableConfigColumn).optional(),\n});\n\nexport type ITableConfig = z.infer<typeof tableConfigSchema>;\n\nexport const chartConfigSchema = z.union([\n  barConfigSchema,\n  lineConfigSchema,\n  areaConfigSchema,\n  pieConfigSchema,\n  tableConfigSchema,\n]);\n\nexport type IChartConfig = z.infer<typeof chartConfigSchema>;\n\nexport interface IChartStorage {\n  config?: IChartConfig;\n  query: IBaseQuery;\n}\n\nexport interface IPageParams {\n  baseId: string;\n  pluginInstallId: string;\n  positionId: string;\n  positionType: PluginPosition;\n  pluginId: string;\n  tableId?: string;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/dashboard/Dashboard.tsx",
    "content": "import { getRandomString } from '@teable/core';\nimport { useEffect, useRef, useState } from 'react';\nimport type { Layout } from 'react-grid-layout';\nimport { Responsive, WidthProvider } from 'react-grid-layout';\nimport type { Bar } from '../../components/Chart/bar';\nimport { Chart } from '../../components/Chart/Chart';\nimport { createChart } from '../../components/Chart/createChart';\nimport type { Line } from '../../components/Chart/line';\nimport type { Pie } from '../../components/Chart/pie';\n\nconst ReactGridLayout = WidthProvider(Responsive);\n\nclass DashboardCharts {\n  private initialized = false;\n\n  charts: { [chartId: string]: { instance: Bar | Pie | Line } } = {};\n\n  init() {\n    if (this.initialized) {\n      return;\n    }\n    const chartsStr = localStorage.getItem('dashboard-charts');\n    if (chartsStr) {\n      const chartsJSonMap = JSON.parse(chartsStr);\n      Object.keys(chartsJSonMap).forEach((key) => {\n        const { type, options, data } = chartsJSonMap[key].instance;\n        this.addChart(createChart(type, { options, data }), key);\n      });\n    }\n    this.initialized = true;\n  }\n\n  addChart(instance: Bar | Pie | Line, key?: string) {\n    this.charts[key || getRandomString(20)] = { instance };\n    localStorage.setItem('dashboard-charts', JSON.stringify(this.charts));\n  }\n}\n\nexport const dashboardCharts = new DashboardCharts();\n\ninterface ILayout extends Layout {\n  chartInstance: Bar | Pie | Line | undefined;\n}\n\nexport const Dashboard = () => {\n  const [layout, setLayout] = useState<ILayout[]>([]);\n  const [loading, setLoading] = useState<boolean>(true);\n  const dashboardRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    dashboardCharts.init();\n    const values = Object.values(dashboardCharts.charts);\n    const chartLayout = values.map((item, i) => {\n      return {\n        x: ((values.length + i) * 6) % 12,\n        y: 0,\n        w: 6,\n        h: 12,\n        i: i.toString(),\n        chartInstance: item.instance,\n        static: Math.random() < 0.05,\n      };\n    });\n    setLayout(chartLayout);\n    setLoading(false);\n  }, []);\n\n  useEffect(() => {\n    const resizeObserver = new ResizeObserver((entries) => {\n      entries.forEach(() => {\n        setTimeout(() => {\n          window.dispatchEvent(new Event('resize'));\n        }, 200);\n      });\n    });\n\n    if (dashboardRef.current) {\n      resizeObserver.observe(dashboardRef.current);\n    }\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  const layoutChange = (_currentLayout: Layout[], allLayouts: ReactGridLayout.Layouts) => {\n    const currentLayout = allLayouts['sm'];\n    if (!layout.length) {\n      return;\n    }\n    setLayout(\n      currentLayout.map((item: Layout, i) => {\n        return {\n          ...layout[i],\n          x: item.x,\n          y: item.y,\n          w: item.w,\n          h: item.h,\n          i: item.i,\n          static: item.static,\n        };\n      })\n    );\n  };\n  return (\n    <div className=\"h-full overflow-y-auto\" ref={dashboardRef}>\n      {!loading && (\n        <ReactGridLayout\n          layouts={{\n            sm: layout,\n          }}\n          cols={{\n            sm: 12,\n          }}\n          breakpoints={{\n            sm: 576,\n          }}\n          rowHeight={16}\n          onLayoutChange={layoutChange}\n          useCSSTransforms\n          isBounded\n        >\n          {layout.map((v) => (\n            <div className=\"rounded-lg border border-slate-600\" key={v.i}>\n              {v.chartInstance && <Chart chartInstance={v.chartInstance} />}\n            </div>\n          ))}\n        </ReactGridLayout>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/db-connection/Panel.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { AlertCircle, Code2, HelpCircle } from '@teable/icons';\nimport { deleteDbConnection, createDbConnection, BillingProductLevel } from '@teable/openapi';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { Fragment } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { CopyButton } from '../../components/CopyButton';\nimport { useBaseUsage } from '../../hooks/useBaseUsage';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useIsCommunity } from '../../hooks/useIsCommunity';\nimport { useDbConnection } from './hooks';\n\nexport const DbConnectionPanel = ({ className }: { className?: string }) => {\n  const permissions = useBasePermission();\n  const baseId = useBaseId() as string;\n  const isCloud = useIsCloud();\n  const usage = useBaseUsage();\n  const queryClient = useQueryClient();\n  const { data, dataArray } = useDbConnection();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isCommunity = useIsCommunity();\n  const maxNumDatabaseConnections = isCommunity ? Infinity : usage?.limit.maxNumDatabaseConnections;\n  const hasPermission = permissions?.['base|db_connection'];\n  const isUnavailable = isCloud && usage?.level !== BillingProductLevel.Enterprise;\n\n  const mutationCreate = useMutation({\n    mutationFn: createDbConnection,\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['connection', baseId] });\n      if (!data.data) {\n        toast.error(t('table:connection.createFailed'));\n      }\n    },\n  });\n\n  const mutationDelete = useMutation({\n    mutationFn: deleteDbConnection,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['connection', baseId] });\n    },\n  });\n\n  const content = (\n    <div className=\"flex flex-col gap-4\">\n      {data ? (\n        <>\n          <div className=\"grid gap-2 overflow-x-auto\">\n            {dataArray.map(({ label, value, display }) => (\n              <div key={label} className=\"flex items-center justify-between gap-2 text-sm\">\n                <span className=\"text-muted-foreground\">{label}</span>\n                <div className=\"flex items-center gap-2\">\n                  <code className=\"rounded bg-muted px-1.5 py-0.5 text-xs\">\n                    {label === 'pass' || label === 'url' ? (\n                      <span className=\"group relative\">\n                        <span className=\"group-hover:hidden\">{display}</span>\n                        <span className=\"hidden group-hover:inline\">{value}</span>\n                      </span>\n                    ) : (\n                      value\n                    )}\n                  </code>\n                  <CopyButton variant=\"ghost\" size=\"icon\" className=\"size-6\" text={value} />\n                </div>\n              </div>\n            ))}\n          </div>\n          <div className=\"flex items-center justify-between text-sm\">\n            <div className=\"text-sm text-muted-foreground\">\n              <Trans\n                ns=\"table\"\n                i18nKey=\"connection.connectionCountTip\"\n                components={{ b: <b /> }}\n                values={{\n                  max: data.connection.max,\n                  current: data.connection.current,\n                }}\n              />\n            </div>\n            <Button size=\"sm\" variant=\"link\" onClick={() => mutationDelete.mutate(baseId)}>\n              {t('common:actions.delete')}\n            </Button>\n          </div>\n        </>\n      ) : (\n        <div className=\"flex justify-end\">\n          <Button size=\"sm\" variant=\"outline\" onClick={() => mutationCreate.mutate(baseId)}>\n            {t('common:actions.create')}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n\n  return (\n    <Fragment>\n      {!isUnavailable || data ? (\n        <div className={className}>\n          <div className=\"mb-2 flex items-center gap-2\">\n            <div className=\"flex items-center gap-2\">\n              <Code2 className=\"size-4\" />\n              <h2 className=\"font-semibold\">{t('table:connection.title')}</h2>\n            </div>\n            <Button variant=\"ghost\" size=\"icon\">\n              <a\n                href={`${t('common:help.mainLink')}/api-doc/sql-query`}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n              >\n                <HelpCircle className=\"size-4\" />\n              </a>\n            </Button>\n          </div>\n          {isUnavailable && (\n            <p className=\"mb-2 flex items-center gap-2 text-sm text-destructive\">\n              <AlertCircle className=\"size-4\" />\n              {t('common:billing.unavailableConnectionTips')}\n            </p>\n          )}\n          <p className=\"mb-2 text-sm text-muted-foreground\">{t('table:connection.description')}</p>\n          {hasPermission && Boolean(maxNumDatabaseConnections)\n            ? content\n            : t('table:connection.noPermission')}\n        </div>\n      ) : null}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/db-connection/hooks/index.ts",
    "content": "export * from './useDbConnection';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/db-connection/hooks/useDbConnection.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { BillingProductLevel, getDbConnection } from '@teable/openapi';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { useBaseUsage } from '@/features/app/hooks/useBaseUsage';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\n\nexport const useDbConnection = () => {\n  const baseId = useBaseId() as string;\n  const permissions = useBasePermission();\n  const hasPermission = permissions?.['base|db_connection'];\n  const isCloud = useIsCloud();\n  const usage = useBaseUsage();\n  const isUnavailable = isCloud && usage?.level !== BillingProductLevel.Enterprise;\n\n  const { data, isLoading } = useQuery({\n    queryKey: ['connection', baseId],\n    queryFn: ({ queryKey }) => getDbConnection(queryKey[1]).then((data) => data.data),\n    enabled: hasPermission && !isUnavailable,\n  });\n\n  const dataArray = data?.dsn\n    ? Object.entries(data?.dsn).map(([label, value]) => {\n        if (label === 'params') {\n          const display = Object.entries(value)\n            .map((v) => v.join('='))\n            .join('&');\n          return {\n            label,\n            display,\n            value: display,\n          };\n        }\n        if (label === 'pass') {\n          return {\n            label,\n            display: '********',\n            value: String(value ?? ''),\n          };\n        }\n        return { label, value: String(value ?? ''), display: String(value ?? '') };\n      })\n    : [];\n\n  dataArray.unshift({\n    label: 'url',\n    display: (data?.url || '').replace(data?.dsn?.pass || '', '********'),\n    value: data?.url || '',\n  });\n\n  return { data, dataArray, isLoading };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/BaseDetail.tsx",
    "content": "import { Database } from '@teable/icons';\nimport { useBase } from '@teable/sdk/hooks';\nimport { useTranslation } from 'next-i18next';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { IntegrityButton } from './components/Integrity';\n\nexport const BaseDetail = () => {\n  const { t } = useTranslation(['table']);\n  const base = useBase();\n  const { driver } = useEnv();\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"flex items-center gap-2\">\n        <Database className=\"size-4\" />\n        <h2 className=\"font-semibold\">{t('table:table.baseInfo')}</h2>\n      </div>\n      <div className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-sm\">\n        <div className=\"text-muted-foreground\">{t('table:table.schemaName')}</div>\n        <div>{base.id}</div>\n        <div className=\"text-muted-foreground\">{t('table:table.typeOfDatabase')}</div>\n        <div>{driver}</div>\n        <div className=\"text-muted-foreground\">{t('table:table.integrity.title')}</div>\n        <div>\n          <IntegrityButton />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/Design.tsx",
    "content": "import { AnchorContext, TablePermissionProvider } from '@teable/sdk/context';\nimport { Button, Separator } from '@teable/ui-lib/shadcn';\nimport { ChevronLeft } from 'lucide-react';\nimport Head from 'next/head';\nimport { useSearchParams } from 'next/navigation';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useBaseResource } from '../../hooks/useBaseResource';\nimport { DbConnectionPanel } from '../db-connection/Panel';\nimport { BaseDetail } from './BaseDetail';\nimport { TableTabs } from './TableTabs';\n\nexport const Design = () => {\n  const router = useRouter();\n  const { baseId } = useBaseResource();\n  const searchParams = useSearchParams();\n  const tableId = searchParams.get('tableId') ?? '';\n  const { t } = useTranslation(['table', 'common']);\n\n  const handleBack = () => {\n    if (tableId) {\n      router.push(`/base/${baseId}/table/${tableId}`);\n    } else {\n      router.push(`/base/${baseId}`);\n    }\n  };\n\n  return (\n    <AnchorContext.Provider value={{ baseId }}>\n      <TablePermissionProvider baseId={baseId}>\n        <div className=\"h-screen overflow-y-auto bg-background\">\n          {/* Header */}\n          <Head>\n            <title>{t('common:noun.design')}</title>\n          </Head>\n          <div className=\"sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60\">\n            <div className=\"flex items-center gap-2 px-4 py-1\">\n              <Button variant=\"ghost\" size=\"icon\" onClick={handleBack}>\n                <ChevronLeft className=\"size-4\" />\n              </Button>\n              <h1 className=\"text-lg font-semibold\">{t('common:noun.design')}</h1>\n            </div>\n          </div>\n\n          <div className=\"space-y-4 p-4 pb-8\">\n            {/* Top Section: Base Info & Connection */}\n            <div className=\"grid gap-4 md:grid-cols-2\">\n              {/* Base Info */}\n              <BaseDetail />\n\n              {/* Connection Info */}\n              <DbConnectionPanel className=\"overflow-hidden\" />\n            </div>\n\n            <Separator />\n\n            <TableTabs />\n          </div>\n        </div>\n      </TablePermissionProvider>\n    </AnchorContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/TableDetail.tsx",
    "content": "import { useLanDayjs, useTable, useTablePermission } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Input,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Pencil } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\ninterface EditableFieldProps {\n  label: string;\n  value: string | undefined;\n  multiline?: boolean;\n  editable?: boolean;\n  onSave: (value: string | null) => Promise<void>;\n}\n\nconst EditableField = ({\n  label,\n  value,\n  multiline = false,\n  editable = true,\n  onSave,\n}: EditableFieldProps) => {\n  const [isEditing, setIsEditing] = useState(false);\n  const [editValue, setEditValue] = useState(value);\n  const [isLoading, setIsLoading] = useState(false);\n  const { t } = useTranslation(['common']);\n\n  const handleSave = async () => {\n    try {\n      setIsLoading(true);\n      await onSave(editValue || null);\n      setIsEditing(false);\n    } catch (error) {\n      console.error('Failed to save:', error);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <div>\n      <Popover open={isEditing} onOpenChange={setIsEditing}>\n        <label className=\"relative flex items-center gap-2 text-xs text-muted-foreground\">\n          {label}\n          <PopoverTrigger asChild disabled={!editable}>\n            <Pencil className=\"size-5 cursor-pointer p-1\" />\n          </PopoverTrigger>\n        </label>\n        <PopoverContent className=\"w-80\">\n          <form\n            className=\"space-y-4\"\n            onSubmit={async (e) => {\n              e.preventDefault();\n              await handleSave();\n            }}\n          >\n            {multiline ? (\n              <Textarea\n                value={editValue}\n                onChange={(e) => setEditValue(e.target.value)}\n                data-1p-ignore=\"true\"\n                autoComplete=\"off\"\n                placeholder={label}\n              />\n            ) : (\n              <Input\n                value={editValue}\n                onChange={(e) => setEditValue(e.target.value)}\n                data-1p-ignore=\"true\"\n                autoComplete=\"off\"\n                placeholder={label}\n              />\n            )}\n            <div className=\"flex justify-end gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => setIsEditing(false)}\n                disabled={isLoading}\n              >\n                {t('actions.cancel')}\n              </Button>\n              <Button type=\"submit\" size=\"sm\" disabled={isLoading}>\n                {t('actions.submit')}\n              </Button>\n            </div>\n          </form>\n        </PopoverContent>\n      </Popover>\n      {value != null ? (\n        multiline ? (\n          <pre className=\"text-sm\">{value}</pre>\n        ) : (\n          <p className=\"text-sm\">{value}</p>\n        )\n      ) : (\n        <p className=\"text-sm opacity-20\">-</p>\n      )}\n    </div>\n  );\n};\n\nexport const TableDetail = () => {\n  const table = useTable();\n  const { t } = useTranslation(['common', 'table']);\n  const dayjs = useLanDayjs();\n  const permission = useTablePermission();\n  const canUpdate = permission['table|update'];\n  if (!table) return null;\n\n  const handleUpdateTableName = async (newName: string | null) => {\n    if (newName == null) return;\n    await table.updateName(newName);\n    toast(t('actions.updateSucceed'));\n  };\n\n  const handleUpdateDescription = async (newDescription: string | null) => {\n    await table.updateDescription(newDescription);\n    toast(t('actions.updateSucceed'));\n  };\n\n  const handleUpdateDbTableName = async (newDbTableName: string | null) => {\n    if (newDbTableName == null) return;\n    await table.updateDbTableName(newDbTableName);\n    toast(t('actions.updateSucceed'));\n  };\n\n  const dbTableName = table.dbTableName.split('.')[1];\n\n  return (\n    <div className=\"grid gap-4 md:grid-cols-2\">\n      <div className=\"space-y-2\">\n        <EditableField\n          label={t('table:table.nameForTable')}\n          value={table.name}\n          editable={canUpdate}\n          onSave={handleUpdateTableName}\n        />\n        <EditableField\n          label={t('table:table.dbTableName')}\n          value={dbTableName}\n          editable={canUpdate}\n          onSave={handleUpdateDbTableName}\n        />\n      </div>\n      <div>\n        <EditableField\n          label={t('table:table.descriptionForTable')}\n          value={table.description}\n          editable={canUpdate}\n          multiline={true}\n          onSave={handleUpdateDescription}\n        />\n        <div>\n          <label className=\"text-xs text-muted-foreground\">{t('table:lastModifiedTime')}</label>\n          <pre className=\"max-h-0h-[72px] overflow-y-auto text-sm\">\n            {dayjs(table?.lastModifiedTime).fromNow()}\n          </pre>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/TableTabs.tsx",
    "content": "import { Table2 } from '@teable/icons';\nimport { AnchorContext, FieldProvider, TablePermissionProvider } from '@teable/sdk/context';\nimport { useTables } from '@teable/sdk/hooks';\nimport { Selector } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogTrigger,\n  Tabs,\n  TabsContent,\n} from '@teable/ui-lib/shadcn';\nimport { useSearchParams } from 'next/navigation';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useBaseResource } from '../../hooks/useBaseResource';\nimport { DynamicBaseErd } from '../erd/DynamicBaseErd';\nimport { FieldSetting } from '../view/field/FieldSetting';\nimport { DataTable } from './data-table/DataTable';\nimport { TableDetail } from './TableDetail';\n\nconst BaseErdialog = ({ baseId }: { baseId: string }) => {\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\">ERD</Button>\n      </DialogTrigger>\n      <DialogContent\n        className=\"flex max-w-7xl p-0\"\n        style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }}\n      >\n        <DynamicBaseErd baseId={baseId} />\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst TablePicker = ({\n  tableId,\n  readonly,\n  onChange,\n}: {\n  tableId: string;\n  readonly: boolean;\n  onChange: (tableId: string) => void;\n}) => {\n  const { t } = useTranslation(['table']);\n  let tables = useTables() as { id: string; name: string; icon?: string }[];\n\n  if (tableId && !tables.find((table) => table.id === tableId)) {\n    tables = tables.concat({\n      id: tableId!,\n      name: t('table:field.editor.tableNoPermission'),\n    });\n  }\n\n  return (\n    <Selector\n      className=\"w-[200px]\"\n      readonly={readonly}\n      selectedId={tableId}\n      onChange={(tableId) => onChange?.(tableId)}\n      candidates={tables.map((table) => ({\n        id: table.id,\n        name: table.name,\n        icon: table.icon || <Table2 className=\"size-4 shrink-0\" />,\n      }))}\n      placeholder={t('table:field.editor.selectTable')}\n    />\n  );\n};\n\nexport const TableTabs = () => {\n  const tables = useTables();\n  const router = useRouter();\n  const { baseId } = useBaseResource();\n  const searchParams = useSearchParams();\n  const tableId = searchParams.get('tableId') ?? '';\n\n  return (\n    <Tabs\n      value={tableId}\n      onValueChange={(tableId) =>\n        router.push({ pathname: router.pathname, query: { ...router.query, tableId } })\n      }\n      className=\"space-y-4\"\n    >\n      <div className=\"flex items-center gap-2\">\n        <TablePicker\n          tableId={tableId}\n          readonly={false}\n          onChange={(tableId) =>\n            router.push({ pathname: router.pathname, query: { ...router.query, tableId } })\n          }\n        />\n        <BaseErdialog baseId={baseId} />\n      </div>\n\n      {tables.map((table) => (\n        <AnchorContext.Provider key={table.id} value={{ baseId, tableId: table.id }}>\n          <TablePermissionProvider baseId={baseId}>\n            <TabsContent value={table.id} className=\"space-y-4\">\n              {/* Table Details */}\n              <TableDetail />\n\n              {/* Fields Table */}\n              <div className=\"overflow-x-auto rounded-md border\">\n                <FieldProvider>\n                  <DataTable />\n                  <FieldSetting />\n                </FieldProvider>\n              </div>\n            </TabsContent>\n          </TablePermissionProvider>\n        </AnchorContext.Provider>\n      ))}\n    </Tabs>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/components/Actions.tsx",
    "content": "import { DotsHorizontalIcon } from '@radix-ui/react-icons';\nimport { useTableId, useFieldOperations } from '@teable/sdk/hooks';\nimport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  Button,\n  DropdownMenuContent,\n  DropdownMenuItem,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { FieldOperator } from '@/features/app/components/field-setting';\nimport { useFieldSettingStore } from '../../view/field/useFieldSettingStore';\n\nexport const Actions = ({ fieldId }: { fieldId: string }) => {\n  const { openSetting } = useFieldSettingStore();\n  const tableId = useTableId() as string;\n  const { t } = useTranslation(['common']);\n  const { deleteField } = useFieldOperations();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"ghost\" size=\"icon-sm\">\n          <DotsHorizontalIcon className=\"size-5\" />\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem onClick={() => openSetting({ fieldId, operator: FieldOperator.Edit })}>\n          {t('actions.edit')}\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          className=\"text-destructive\"\n          onClick={() => {\n            deleteField({ tableId, fieldId });\n          }}\n        >\n          {t('actions.delete')}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/components/FieldPropertyEditor.tsx",
    "content": "import { Edit } from '@teable/icons';\nimport { useField, useFieldPermission } from '@teable/sdk/hooks';\nimport { Button, Input } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const FieldPropertyEditor = ({\n  fieldId,\n  propKey,\n}: {\n  fieldId: string;\n  propKey: 'name' | 'dbFieldName';\n}) => {\n  const field = useField(fieldId);\n  const permission = useFieldPermission();\n  const canUpdate = permission['field|update'];\n  const [newValue, setNewValue] = useState(field?.[propKey]);\n  const [isEditing, setIsEditing] = useState(false);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const timeout = setTimeout(() => {\n      if (isEditing) {\n        inputRef.current?.select();\n        inputRef.current?.focus();\n      }\n    }, 200);\n    return () => clearTimeout(timeout);\n  }, [isEditing]);\n\n  const handleBlur = useCallback(\n    (e: React.FocusEvent) => {\n      // Check if focus is moving to another element within the container\n      const relatedTarget = e.relatedTarget as Node | null;\n      if (containerRef.current?.contains(relatedTarget)) {\n        return;\n      }\n      // Exit editing mode and reset value to original\n      setNewValue(field?.[propKey]);\n      setIsEditing(false);\n    },\n    [field, propKey]\n  );\n\n  if (!field) {\n    return <></>;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {!isEditing ? (\n        <div className=\"flex gap-2 text-nowrap\">\n          {newValue}\n          {canUpdate && <Edit className=\"size-4\" onClick={() => setIsEditing(true)} />}\n        </div>\n      ) : (\n        <div ref={containerRef} className=\"flex gap-2\" onBlur={handleBlur}>\n          <Input\n            ref={inputRef}\n            className=\"w-40\"\n            size=\"sm\"\n            readOnly={!canUpdate}\n            value={newValue}\n            onChange={(e) => setNewValue(e.target.value)}\n          />\n          <Button\n            size=\"xs\"\n            disabled={!canUpdate}\n            onClick={async () => {\n              if (newValue === field?.[propKey]) {\n                setIsEditing(false);\n                return;\n              }\n              await field.update({ [propKey]: newValue });\n              setIsEditing(false);\n              toast(t('common:actions.updateSucceed'));\n            }}\n          >\n            {t('actions.submit')}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/components/Integrity.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { checkBaseIntegrity, fixBaseIntegrity } from '@teable/openapi';\nimport { useBase } from '@teable/sdk/hooks';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { Loader2, Check } from 'lucide-react';\nimport { useSearchParams } from 'next/navigation';\nimport { useTranslation } from 'next-i18next';\n\nexport const IntegrityButton = () => {\n  const base = useBase();\n  const { t } = useTranslation(['table', 'common']);\n  const searchParams = useSearchParams();\n  const tableId = searchParams.get('tableId') ?? '';\n\n  const { data, isLoading, refetch } = useQuery({\n    queryKey: ['baseIntegrity', base.id],\n    queryFn: () => checkBaseIntegrity(base.id, tableId).then(({ data }) => data),\n    enabled: false,\n  });\n\n  const { mutateAsync: fixIntegrity } = useMutation({\n    mutationFn: () => fixBaseIntegrity(base.id, tableId),\n    onSuccess: () => {\n      refetch();\n    },\n  });\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button size=\"xs\" variant=\"outline\" onClick={() => refetch()}>\n          {t('table:table.integrity.check')}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-96\">\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-2\">\n            <Loader2 className=\"size-6 animate-spin\" />\n            <span className=\"ml-2\">{t('table:table.integrity.loading')}</span>\n          </div>\n        ) : (\n          <div className=\"py-2\">\n            {data?.hasIssues ? (\n              <>\n                {data?.linkFieldIssues?.[0]?.baseName && (\n                  <div className=\"mb-2 font-medium\">{data.linkFieldIssues[0].baseName}</div>\n                )}\n\n                <div className=\"max-h-96  max-w-md overflow-y-auto\">\n                  {data?.linkFieldIssues?.map((issues, index) => (\n                    <div key={index} className=\"mb-2 ml-4 text-sm\">\n                      {issues.issues.map((issue) => (\n                        <div key={issue.type}>\n                          <div>\n                            {t('table:table.integrity.type')}:{' '}\n                            {t(`table:table.integrity.errorType.${issue.type}`)}\n                          </div>\n                          <div>\n                            {t('table:table.integrity.message')}: {issue.message}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  ))}\n                </div>\n\n                <div className=\"flex justify-end\">\n                  <Button onClick={() => fixIntegrity()} size=\"sm\" className=\"mt-2\">\n                    {t('table:table.integrity.fixIssues')}\n                  </Button>\n                </div>\n              </>\n            ) : (\n              <div className=\"flex items-center justify-center py-2\">\n                <Check className=\"size-6 text-green-500\" />\n                <span className=\"ml-2 text-green-500\">{t('table:table.integrity.allGood')}</span>\n              </div>\n            )}\n          </div>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/data-table/DataTable.tsx",
    "content": "import type { ColumnFiltersState, SortingState, VisibilityState } from '@tanstack/react-table';\nimport { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';\nimport { useFields } from '@teable/sdk/hooks';\n\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport * as React from 'react';\nimport { baseConfig } from '@/features/i18n/base.config';\nimport { useDataColumns } from './useDataColumns';\n\nexport function DataTable() {\n  const columns = useDataColumns();\n  const fields = useFields();\n  const [rowSelection, setRowSelection] = React.useState({});\n  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});\n  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);\n  const [sorting, setSorting] = React.useState<SortingState>([]);\n  const { t } = useTranslation(baseConfig.i18nNamespaces);\n  const table = useReactTable({\n    data: fields,\n    columns,\n    state: {\n      sorting,\n      columnVisibility,\n      rowSelection,\n      columnFilters,\n    },\n    columnResizeMode: 'onChange',\n    enableRowSelection: true,\n    onRowSelectionChange: setRowSelection,\n    onSortingChange: setSorting,\n    onColumnFiltersChange: setColumnFilters,\n    onColumnVisibilityChange: setColumnVisibility,\n    getCoreRowModel: getCoreRowModel(),\n  });\n\n  return (\n    <Table>\n      <TableHeader>\n        {table.getHeaderGroups().map((headerGroup) => (\n          <TableRow key={headerGroup.id}>\n            {headerGroup.headers.map((header) => {\n              return (\n                <TableHead key={header.id} colSpan={header.colSpan}>\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(header.column.columnDef.header, header.getContext())}\n                </TableHead>\n              );\n            })}\n          </TableRow>\n        ))}\n      </TableHeader>\n      <TableBody>\n        {table.getRowModel().rows?.length ? (\n          table.getRowModel().rows.map((row) => (\n            <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>\n              {row.getVisibleCells().map((cell) => (\n                <TableCell key={cell.id}>\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </TableCell>\n              ))}\n            </TableRow>\n          ))\n        ) : (\n          <TableRow>\n            <TableCell colSpan={columns.length} className=\"h-24 text-center\">\n              {t('noResult')}\n            </TableCell>\n          </TableRow>\n        )}\n      </TableBody>\n    </Table>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/data-table/FieldGraph.tsx",
    "content": "import { useTable } from '@teable/sdk/hooks';\nimport {\n  Dialog,\n  DialogTrigger,\n  Button,\n  DialogContent,\n  DialogFooter,\n  DialogClose,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { DynamicFieldGraph } from '../../graph/DynamicFieldGraph';\nexport const FieldGraph = ({ fieldId }: { fieldId: string }) => {\n  const table = useTable();\n  const { t } = useTranslation(['common', 'table']);\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button size={'xs'} variant={'outline'}>\n          {t('table:field.editor.graph')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"max-w-6xl\">\n        <DynamicFieldGraph tableId={table?.id as string} fieldId={fieldId} />\n        <DialogFooter>\n          <DialogClose asChild>\n            <Button type=\"button\" variant=\"secondary\">\n              {t('common:actions.close')}\n            </Button>\n          </DialogClose>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/design/data-table/useDataColumns.tsx",
    "content": "import type { ColumnDef } from '@tanstack/react-table';\nimport type { IFieldVo } from '@teable/core';\nimport { Checked, Lock } from '@teable/icons';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Actions } from '../components/Actions';\nimport { FieldPropertyEditor } from '../components/FieldPropertyEditor';\n\nfunction checkBox(key: string) {\n  return {\n    accessorKey: key,\n    header: key,\n    cell: ({ row }: { row: { getValue: (key: string) => boolean } }) =>\n      row.getValue(key) && <Checked className=\"size-5\" />,\n  };\n}\n\nexport function useDataColumns() {\n  const { t } = useTranslation(['sdk']);\n  const columns: ColumnDef<IFieldVo>[] = [\n    {\n      accessorKey: 'isPrimary',\n      header: '',\n      cell: ({ row }) =>\n        row.getValue('isPrimary') && (\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <div>\n                  <Lock className=\"size-5\" />\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <pre>{t('hidden.primaryKey')}</pre>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        ),\n    },\n    {\n      accessorKey: 'id',\n      header: 'id',\n    },\n    {\n      accessorKey: 'name',\n      header: 'name',\n      maxSize: 500,\n      cell: ({ row }) => <FieldPropertyEditor fieldId={row.getValue('id')} propKey=\"name\" />,\n    },\n    {\n      accessorKey: 'dbFieldName',\n      header: 'dbFieldName',\n      cell: ({ row }) => <FieldPropertyEditor fieldId={row.getValue('id')} propKey=\"dbFieldName\" />,\n    },\n    {\n      accessorKey: 'type',\n      header: 'type',\n    },\n    {\n      accessorKey: 'description',\n      header: 'description',\n      cell: ({ row }) => (\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div className=\"max-w-[150px] overflow-hidden text-ellipsis text-nowrap\">\n                {row.getValue('description')}\n              </div>\n            </TooltipTrigger>\n            <TooltipContent>\n              <pre>{row.getValue('description')}</pre>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      ),\n    },\n    {\n      accessorKey: 'dbFieldType',\n      header: 'dbFieldType',\n    },\n    {\n      accessorKey: 'cellValueType',\n      header: 'cellValueType',\n    },\n    checkBox('isLookup'),\n    checkBox('isMultipleCellValue'),\n    checkBox('isComputed'),\n    checkBox('isPending'),\n    checkBox('hasError'),\n    checkBox('notNull'),\n    checkBox('unique'),\n    {\n      id: 'actions',\n      header: '',\n      enableHiding: false,\n      cell: ({ row }) => <Actions fieldId={row.getValue('id')} />,\n    },\n  ];\n  return columns;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/erd/BaseErd.tsx",
    "content": "import { Relationship } from '@teable/core';\nimport { getBaseErd } from '@teable/openapi';\nimport type { IBaseErdEdge, IBaseErdVo, IBaseErdTableNode } from '@teable/openapi';\nimport { useFieldStaticGetter } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Switch,\n} from '@teable/ui-lib/shadcn';\nimport { uniq } from 'lodash';\nimport { FilterIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useEffect, useMemo, useCallback } from 'react';\nimport {\n  ReactFlow,\n  Controls,\n  useNodesState,\n  useEdgesState,\n  Background,\n  BackgroundVariant,\n  MarkerType,\n} from 'reactflow';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { BaseErdTableNode } from './BaseErdTableNode';\nimport { CustomMarkers, getMarker } from './CustomMakers';\nimport { SelfConnectingEdge } from './SelfConnectingEdge';\n\nconst openTable = (baseId: string, tableId: string) => {\n  const url = new URL(`/base/${baseId}/table/${tableId}`, window.location.origin);\n  window.open(url.toString(), '_blank');\n};\n\nconst buildNodes = (\n  baseId: string,\n  nodes: IBaseErdTableNode[],\n  fieldStaticGetter: ReturnType<typeof useFieldStaticGetter>,\n  openTable: (baseId: string, tableId: string) => void\n) => {\n  const col = Math.ceil(Math.sqrt(nodes.length));\n  const yMap: Record<number, { rowIndex: number; height: number }> = {};\n  const resultNodes = [];\n  for (let i = 0; i < nodes.length; i++) {\n    const node = nodes[i];\n    const colIndex = i % col;\n    const x = colIndex * 300;\n    const y = yMap[colIndex]?.height ?? 0;\n    resultNodes.push({\n      id: node.id,\n      type: 'tableNode',\n      data: {\n        ...node,\n        baseId,\n        fieldStaticGetter,\n        openTable,\n      },\n      position: { x, y },\n    });\n    const rowIndex = yMap[colIndex]?.rowIndex ?? 0;\n    // 24(h6) is the height of a field, 8(gap-2) is the gap between fields, 100 is the gap between tables\n    const height = node.fields.length * 24 + (node.fields.length + 1) * 8 + 100;\n    yMap[colIndex] = {\n      rowIndex: rowIndex + 1,\n      height: y + height,\n    };\n  }\n  return resultNodes;\n};\n\nconst buildEdges = (\n  baseId: string,\n  edges: IBaseErdEdge[],\n  showEdgeTypes: IBaseErdEdge['type'][],\n  translationMap: Record<string, string>,\n  getEdgeTypeInfo: (type: IBaseErdEdge['type']) => { title: string }\n) => {\n  return edges\n    .filter((edge) => {\n      return Boolean(edge.relationship) || showEdgeTypes.includes(edge.type);\n    })\n    .map((edge) => {\n      const { source, target } = edge;\n\n      const wayLabel = edge.isOneWay ? translationMap['oneWay'] : translationMap['twoWay'];\n      const relationshipLabel = edge.relationship\n        ? `${translationMap[edge.relationship]}(${wayLabel})`\n        : '';\n      // `[${source.tableName}]${source.fieldName} - ${relationshipLabel} - [${target.tableName}]${target.fieldName}`\n\n      const defaultMarkerEnd = {\n        type: MarkerType.ArrowClosed,\n        width: 16,\n        height: 16,\n      };\n      const { start: markerStart, end: markerEnd } = edge.relationship\n        ? getMarker(baseId, edge.relationship)\n        : { start: undefined, end: defaultMarkerEnd };\n\n      const isSelfConnecting = source.tableId === target.tableId;\n      const { title } = getEdgeTypeInfo(edge.type);\n      return {\n        id: `${source.tableId}-${source.fieldId}-${target.tableId}-${target.fieldId}`,\n        type: isSelfConnecting ? 'selfConnecting' : 'default',\n        source: source.tableId,\n        target: target.tableId,\n        sourceHandle: source.fieldId,\n        targetHandle: target.fieldId,\n        style: { strokeWidth: 1 },\n        label: relationshipLabel ? relationshipLabel : title,\n        markerStart,\n        markerEnd,\n      };\n    });\n};\n\nconst connectionLineStyle = { stroke: '#fff' };\nconst nodeTypes = {\n  tableNode: BaseErdTableNode,\n};\nconst edgeTypes = {\n  selfConnecting: SelfConnectingEdge,\n};\n\nexport const BaseErd = (props: { baseId: string }) => {\n  const { baseId } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const fieldStaticGetter = useFieldStaticGetter();\n  const [showEdgeTypes, setShowEdgeTypes] = useState<IBaseErdEdge['type'][]>([]);\n  const [baseErd, setBaseErd] = useState<IBaseErdVo | null>(null);\n  const [nodes, setNodes, onNodesChange] = useNodesState<IBaseErdTableNode>([]);\n  const [edges, setEdges, onEdgesChange] = useEdgesState([]);\n\n  const translationMap = useMemo(() => {\n    return {\n      [Relationship.OneOne]: t('table:field.editor.oneToOne'),\n      [Relationship.OneMany]: t('table:field.editor.oneToMany'),\n      [Relationship.ManyOne]: t('table:field.editor.manyToOne'),\n      [Relationship.ManyMany]: t('table:field.editor.manyToMany'),\n      lookup: t('sdk:field.title.lookup'),\n      oneWay: t('sdk:field.link.oneWay'),\n      twoWay: t('sdk:field.link.twoWay'),\n    };\n  }, [t]);\n\n  useEffect(() => {\n    getBaseErd(baseId).then((baseErd) => {\n      setBaseErd(baseErd.data);\n    });\n  }, [baseId]);\n\n  const getEdgeTypeInfo = useCallback(\n    (type: IBaseErdEdge['type']) => {\n      const { title } =\n        type === 'lookup'\n          ? { title: translationMap['lookup'] }\n          : fieldStaticGetter(type, {\n              isLookup: false,\n              hasAiConfig: false,\n              deniedReadRecord: false,\n            });\n      return { type, title };\n    },\n    [translationMap, fieldStaticGetter]\n  );\n\n  const allEdgeTypes = useMemo(() => {\n    const { edges = [] } = baseErd ?? {};\n    return uniq(edges.filter((edge) => !edge.relationship).map((edge) => edge.type))\n      .sort()\n      .map((type) => getEdgeTypeInfo(type));\n  }, [baseErd, getEdgeTypeInfo]);\n\n  useEffect(() => {\n    if (baseErd) {\n      const { baseId, nodes } = baseErd;\n      setNodes(buildNodes(baseId, nodes, fieldStaticGetter, openTable));\n    } else {\n      setNodes([]);\n    }\n  }, [baseErd, fieldStaticGetter, setNodes]);\n\n  useEffect(() => {\n    if (baseErd) {\n      const { baseId, edges } = baseErd;\n      setEdges(buildEdges(baseId, edges, showEdgeTypes, translationMap, getEdgeTypeInfo));\n    } else {\n      setEdges([]);\n    }\n  }, [baseErd, translationMap, setEdges, showEdgeTypes, getEdgeTypeInfo]);\n\n  return (\n    <ReactFlow\n      nodes={nodes}\n      edges={edges}\n      nodeTypes={nodeTypes}\n      edgeTypes={edgeTypes}\n      connectionLineStyle={connectionLineStyle}\n      onNodesChange={onNodesChange}\n      onEdgesChange={onEdgesChange}\n      fitView\n      minZoom={0.25}\n      maxZoom={1.25}\n    >\n      <CustomMarkers baseId={baseId} />\n      <Background variant={BackgroundVariant.Dots} className=\"bg-secondary\" />\n      <Controls\n        className=\"Controls\"\n        fitViewOptions={{\n          duration: 500,\n        }}\n      />\n      <div className=\"absolute right-10 top-10 z-10 flex \">\n        {allEdgeTypes.length > 0 && (\n          <Popover modal>\n            <PopoverTrigger asChild>\n              <Button variant=\"outline\" size=\"icon\" className=\"flex items-center gap-2\">\n                <FilterIcon className=\"size-4\" />\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-fit min-w-24 p-0\">\n              {allEdgeTypes.map(({ type, title }) => (\n                <div key={type} className=\"w-min-content flex items-center gap-2 p-2\">\n                  <Switch\n                    checked={showEdgeTypes.includes(type)}\n                    onCheckedChange={(checked) => {\n                      setShowEdgeTypes((prev) =>\n                        checked ? [...prev, type] : prev.filter((t) => t !== type)\n                      );\n                    }}\n                  />\n                  <Label>{title}</Label>\n                </div>\n              ))}\n            </PopoverContent>\n          </Popover>\n        )}\n      </div>\n    </ReactFlow>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/erd/BaseErdTableNode.tsx",
    "content": "import { Table2 } from '@teable/icons';\nimport type { IBaseErdTableNode } from '@teable/openapi';\nimport type { useFieldStaticGetter } from '@teable/sdk/hooks';\nimport { memo } from 'react';\nimport type { NodeProps } from 'reactflow';\nimport { Handle, Position } from 'reactflow';\nimport { Emoji } from '../../components/emoji/Emoji';\n\ninterface IBaseErdTableNodeProps extends IBaseErdTableNode {\n  baseId: string;\n  fieldStaticGetter: ReturnType<typeof useFieldStaticGetter>;\n  openTable: (baseId: string, tableId: string) => void;\n}\n\nexport const BaseErdTableNode = memo(({ data }: NodeProps<IBaseErdTableNodeProps>) => {\n  const {\n    id: tableId,\n    name,\n    fields,\n    fieldStaticGetter,\n    icon,\n    crossBaseId,\n    crossBaseName,\n    openTable,\n    baseId,\n  } = data;\n\n  const title = crossBaseName ? `${name}(${crossBaseName})` : name;\n  const fieldComponents = fields.map((field) => {\n    const { Icon } = fieldStaticGetter(field.type, {\n      isLookup: field.isLookup,\n      isConditionalLookup: field.isConditionalLookup,\n      hasAiConfig: false,\n      deniedReadRecord: false,\n    });\n\n    return (\n      <div key={field.id} className=\"relative flex h-6 w-full items-center p-2\">\n        <div className=\"flex w-full items-center gap-2\">\n          <Icon className=\"size-4 shrink-0\" />\n          <span className=\" truncate\" title={field.name}>\n            {field.name}\n          </span>\n        </div>\n        <Handle\n          id={field.id}\n          type=\"source\"\n          position={Position.Right}\n          isConnectable={false}\n          className=\"opacity-0\"\n        />\n        <Handle\n          id={field.id}\n          type=\"target\"\n          position={Position.Left}\n          isConnectable={false}\n          className=\"opacity-0\"\n        />\n      </div>\n    );\n  });\n\n  return (\n    <div key={tableId} className=\"min-w-28 max-w-36 rounded-md border bg-background \">\n      <div\n        className=\" flex h-10 items-center gap-2 border-b px-2 py-4\"\n        onDoubleClick={() => openTable(crossBaseId ?? baseId, tableId)}\n      >\n        {icon ? (\n          <Emoji className=\"size-4 shrink-0\" emoji={icon} size={'1rem'} />\n        ) : (\n          <Table2 className=\"size-4 shrink-0\" />\n        )}\n        <span className=\"text-md  truncate font-semibold\" title={title}>\n          {title}\n        </span>\n      </div>\n      <div className=\"flex w-full cursor-default flex-col gap-2 py-2 text-sm\">\n        {fieldComponents}\n      </div>\n    </div>\n  );\n});\n\nBaseErdTableNode.displayName = 'BaseErdTableNode';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/erd/CustomMakers.tsx",
    "content": "import { Relationship } from '@teable/core';\n\nconst buildMarkerId = (baseId: string) => {\n  return {\n    one: `${baseId}-one`,\n    many: `${baseId}-many`,\n  };\n};\n\nexport const getMarker = (baseId: string, relationship: Relationship) => {\n  const { one, many } = buildMarkerId(baseId);\n  switch (relationship) {\n    case Relationship.OneOne:\n      return { start: one, end: one };\n    case Relationship.ManyMany:\n      return { start: many, end: many };\n    case Relationship.ManyOne:\n      return { start: many, end: one };\n    case Relationship.OneMany:\n      return { start: one, end: many };\n  }\n};\nexport const CustomMarkers = ({ baseId }: { baseId: string }) => {\n  // same color as reactflow default marker\n  const color = 'rgb(177, 177, 183)';\n  return (\n    <svg style={{ position: 'absolute', top: 0, left: 0 }}>\n      <defs>\n        <marker\n          id={buildMarkerId(baseId).one}\n          markerWidth=\"16\"\n          markerHeight=\"16\"\n          viewBox=\"-10 -10 20 20\"\n          markerUnits=\"strokeWidth\"\n          orient=\"auto-start-reverse\"\n          refX=\"-5\"\n          refY=\"0\"\n        >\n          <circle cx=\"0\" cy=\"0\" r=\"5\" fill=\"none\" stroke={color} strokeWidth=\"1\"></circle>\n        </marker>\n        <marker\n          id={buildMarkerId(baseId).many}\n          markerWidth=\"16\"\n          markerHeight=\"16\"\n          viewBox=\"-10 -10 20 20\"\n          markerUnits=\"strokeWidth\"\n          orient=\"auto-start-reverse\"\n          refX=\"0\"\n          refY=\"4\"\n        >\n          <rect\n            x=\"0\"\n            y=\"0\"\n            width=\"8\"\n            height=\"8\"\n            fill=\"none\"\n            stroke={color}\n            strokeWidth=\"1\"\n            transform=\"rotate(45,4,4)\"\n          ></rect>\n        </marker>\n      </defs>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/erd/DynamicBaseErd.tsx",
    "content": "import { Skeleton } from '@teable/ui-lib/shadcn';\nimport dynamic from 'next/dynamic';\n\nexport const DynamicBaseErd = dynamic(() => import('./BaseErd').then((mod) => mod.BaseErd), {\n  loading: () => (\n    <div className=\"space-y-2 p-4\">\n      <Skeleton className=\"h-6 w-full\" />\n      <Skeleton className=\"h-6 w-full\" />\n      <Skeleton className=\"h-6 w-full\" />\n      <Skeleton className=\"h-6 w-full\" />\n      <Skeleton className=\"h-6 w-full\" />\n    </div>\n  ),\n  ssr: false,\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/erd/SelfConnectingEdge.tsx",
    "content": "import { BezierEdge, BaseEdge, type EdgeProps } from 'reactflow';\n\nexport const SelfConnectingEdge = (props: EdgeProps) => {\n  if (props.source !== props.target) {\n    return <BezierEdge {...props} />;\n  }\n\n  const { sourceX, sourceY, targetX, targetY } = props;\n  const x = Math.max(sourceX, targetX);\n  const part = (targetY - sourceY) / 3;\n  const point1 = { x: x + 50, y: sourceY + part };\n  const point2 = { x: x + 50, y: sourceY + part * 2 };\n  const labelX = (point1.x + point2.x) / 2;\n  const labelY = (point1.y + point2.y) / 2;\n\n  const edgePath = `M ${x} ${sourceY} C ${point1.x} ${point1.y} ${point2.x} ${point2.y} ${x} ${targetY}`;\n  return <BaseEdge {...props} path={edgePath} labelX={labelX} labelY={labelY} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/graph/DynamicFieldGraph.tsx",
    "content": "import { Skeleton } from '@teable/ui-lib/shadcn';\nimport dynamic from 'next/dynamic';\n\nexport const DynamicFieldGraph = dynamic(\n  () => import('./FieldGraph').then((mod) => mod.FieldGraph),\n  {\n    loading: () => (\n      <div className=\"space-y-2 p-4\">\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n      </div>\n    ),\n    ssr: false,\n  }\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/graph/FieldGraph.tsx",
    "content": "import type { FieldAction, IFieldRo } from '@teable/core';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Badge } from '@teable/ui-lib/shadcn';\nimport dayjs from 'dayjs';\nimport duration from 'dayjs/plugin/duration';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport { useTranslation } from 'next-i18next';\nimport { usePlan } from './usePlan';\n\ndayjs.extend(duration);\ndayjs.extend(relativeTime);\n\nexport const FieldGraph = (params: {\n  tableId: string;\n  fieldId?: string;\n  fieldRo?: IFieldRo;\n  fieldAction?: FieldAction;\n}) => {\n  const planData = usePlan(params);\n  const updateCellCount = planData?.updateCellCount;\n  const linkFieldCount = planData?.linkFieldCount;\n  const estimateTime = planData?.estimateTime || 0;\n  const { t, i18n } = useTranslation(['table']);\n  const dayjs = useLanDayjs();\n  const formatDuration = dayjs(Date.now() + estimateTime).fromNow();\n\n  return (\n    <div className=\"flex flex-col gap-2 pb-2\">\n      <div className=\"flex items-center gap-2 text-xs\">\n        <div>\n          {t('table.graph.effectCells')}:{' '}\n          <Badge>{Intl.NumberFormat(i18n.language).format(updateCellCount || 0)}</Badge>\n        </div>\n        <div>\n          {t('table.graph.estimatedTime')}: <b>{formatDuration}</b>\n        </div>\n      </div>\n      {linkFieldCount && linkFieldCount > 0 ? (\n        <div className=\"flex items-center gap-2 text-xs\">\n          {t('table.graph.linkFieldCount')}:{' '}\n          <Badge>{Intl.NumberFormat(i18n.language).format(linkFieldCount)}</Badge>\n        </div>\n      ) : null}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/graph/ProgressBar.tsx",
    "content": "import { Progress } from '@teable/ui-lib/shadcn';\nimport React, { useState, useEffect } from 'react';\n\nexport function ProgressBar({ duration, cellCount }: { duration: number; cellCount: number }) {\n  const [progress, setProgress] = useState(0);\n\n  useEffect(() => {\n    if (!duration) {\n      return;\n    }\n    const interval = 100;\n    const step = (interval / duration) * 100;\n\n    const intervalId = setInterval(() => {\n      setProgress((prevProgress) => {\n        const nextProgress = prevProgress + step;\n        return nextProgress > 100 ? 100 : nextProgress;\n      });\n    }, interval);\n\n    return () => clearInterval(intervalId);\n  }, [duration]);\n\n  const format = (count: number) => {\n    return Intl.NumberFormat().format(Math.floor(count));\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2 text-sm\">\n      <p>\n        Progress: {format((progress / 100) * cellCount)} / {format(cellCount)}\n      </p>\n      {progress === 100 && (\n        <p>Please be patient, the system needs a little more time to process...</p>\n      )}\n      <Progress value={progress} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/graph/usePlan.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { FieldAction, IFieldRo } from '@teable/core';\nimport { planField, planFieldCreate, planFieldConvert, planFieldDelete } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\n\nexport function usePlan({\n  tableId,\n  fieldId,\n  fieldRo,\n  fieldAction,\n}: {\n  tableId: string;\n  fieldId?: string;\n  fieldRo?: IFieldRo;\n  fieldAction?: FieldAction;\n}) {\n  // if fieldAction is not provided, we need to infer it from fieldId and fieldRo\n  let action = fieldAction;\n  if (!action && fieldId && fieldRo) {\n    action = 'field|update';\n  }\n  if (!action && !fieldId && fieldRo) {\n    action = 'field|create';\n  }\n  if (!action && fieldId && !fieldRo) {\n    action = 'field|read';\n  }\n\n  const { data: deletePlan } = useQuery({\n    queryKey: ReactQueryKeys.planFieldDelete(tableId, fieldId as string),\n    queryFn: ({ queryKey }) => planFieldDelete(queryKey[1], queryKey[2]).then((data) => data.data),\n    refetchOnWindowFocus: false,\n    enabled: action === 'field|delete',\n  });\n\n  const { data: updatePlan } = useQuery({\n    queryKey: ReactQueryKeys.planFieldConvert(tableId, fieldId as string, fieldRo as IFieldRo),\n    queryFn: ({ queryKey }) =>\n      planFieldConvert(queryKey[1], queryKey[2], queryKey[3]).then((data) => data.data),\n    refetchOnWindowFocus: false,\n    enabled: action === 'field|update',\n  });\n\n  const { data: createPlan } = useQuery({\n    queryKey: ReactQueryKeys.planFieldCreate(tableId, fieldRo as IFieldRo),\n    queryFn: ({ queryKey }) => planFieldCreate(queryKey[1], queryKey[2]).then((data) => data.data),\n    refetchOnWindowFocus: false,\n    enabled: action === 'field|create',\n  });\n\n  const { data: staticPlan } = useQuery({\n    queryKey: ReactQueryKeys.planField(tableId, fieldId as string),\n    queryFn: ({ queryKey }) => planField(queryKey[1], queryKey[2]).then((data) => data.data),\n    refetchOnWindowFocus: false,\n    enabled: action === 'field|read',\n  });\n\n  return deletePlan || updatePlan || createPlan || staticPlan;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/TableImport.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { ITimeZoneString } from '@teable/core';\nimport type {\n  IInplaceImportOptionRo,\n  IImportOptionRo,\n  IAnalyzeRo,\n  IImportSheetItem,\n  SUPPORTEDTYPE,\n  IAnalyzeVo,\n  IImportOption,\n  INotifyVo,\n} from '@teable/openapi';\nimport {\n  importTypeMap,\n  analyzeFile,\n  importTableFromFile,\n  inplaceImportTableFromFile,\n  BaseNodeResourceType,\n} from '@teable/openapi';\nimport { useBase, LocalStorageKeys } from '@teable/sdk';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogTrigger,\n  Button,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n  Spin,\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  Checkbox,\n} from '@teable/ui-lib';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useRef, useCallback } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { getNodeUrl } from '../base/base-node/hooks';\nimport { FieldConfigPanel, InplaceFieldConfigPanel } from './field-config-panel';\nimport { UploadPanel } from './upload-panel';\nimport { UrlPanel } from './UrlPanel';\n\ninterface ITableImportProps {\n  open?: boolean;\n  tableId?: string;\n  children?: React.ReactElement;\n  fileType: SUPPORTEDTYPE;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport type ITableImportOptions = IImportOption & {\n  autoSelectType: boolean;\n};\n\nenum Step {\n  UPLOAD = 'upload',\n  CONFIG = 'config',\n}\n\nexport const TableImport = (props: ITableImportProps) => {\n  const base = useBase();\n  const router = useRouter();\n  const { t } = useTranslation(['table']);\n  const [step, setStep] = useState(Step.UPLOAD);\n  const { children, open, onOpenChange, fileType, tableId } = props;\n  const [errorMessage, setErrorMessage] = useState('');\n  const [file, setFile] = useState<File | null>(null);\n  const [fileInfo, setFileInfo] = useState<IAnalyzeRo>({} as IAnalyzeRo);\n  const primitiveWorkSheets = useRef<IAnalyzeVo['worksheets']>({});\n  const [workSheets, setWorkSheets] = useState<IImportOptionRo['worksheets']>({});\n  const [insertConfig, setInsertConfig] = useState<IInplaceImportOptionRo['insertConfig']>({\n    excludeFirstRow: true,\n    sourceWorkSheetKey: '',\n    sourceColumnMap: {},\n  });\n  const [shouldAlert, setShouldAlert] = useLocalStorage(LocalStorageKeys.ImportAlert, true);\n  const [shouldTips, setShouldTips] = useState(false);\n\n  const { mutateAsync: importNewTableFn, isPending: isLoading } = useMutation({\n    mutationFn: async ({ baseId, importRo }: { baseId: string; importRo: IImportOptionRo }) => {\n      return (await importTableFromFile(baseId, importRo)).data;\n    },\n    onSuccess: (data) => {\n      const { defaultViewId: viewId, id: tableId } = data[0];\n      onOpenChange?.(false);\n      const url = getNodeUrl({\n        baseId: base.id,\n        resourceType: BaseNodeResourceType.Table,\n        resourceId: tableId,\n        viewId,\n      });\n      if (url) {\n        router.push(url, undefined, { shallow: true });\n      }\n    },\n  });\n\n  const { mutateAsync: inplaceImportFn, isPending: inplaceLoading } = useMutation({\n    mutationFn: (args: Parameters<typeof inplaceImportTableFromFile>) => {\n      return inplaceImportTableFromFile(...args);\n    },\n    onSuccess: () => {\n      onOpenChange?.(false);\n      const { tableId: routerTableId } = router.query;\n      routerTableId !== tableId && router.push(`/base/${base.id}/table/${tableId}`);\n    },\n  });\n\n  const importTable = async () => {\n    const importNewTable = () => {\n      for (const [, value] of Object.entries(workSheets)) {\n        const { columns } = value;\n\n        if (columns.some((col) => !col.name)) {\n          setErrorMessage(t('table:import.form.error.fieldNameEmpty'));\n          return;\n        }\n        if (new Set(columns.map((col) => col.name.trim())).size !== columns.length) {\n          setErrorMessage(t('table:import.form.error.uniqueFieldName'));\n          return;\n        }\n      }\n\n      importNewTableFn({\n        baseId: base.id,\n        importRo: {\n          worksheets: workSheets,\n          ...fileInfo,\n          notification: true,\n          tz: Intl.DateTimeFormat().resolvedOptions().timeZone as ITimeZoneString,\n        },\n      });\n    };\n\n    const inplaceImportTable = () => {\n      const { sourceColumnMap } = insertConfig;\n      if (Object.values(sourceColumnMap).every((col) => col === null)) {\n        setErrorMessage(t('table:import.form.error.atLeastAImportField'));\n        return;\n      }\n      const preInsertConfig = {\n        ...insertConfig,\n        sourceColumnMap: Object.fromEntries(\n          Object.entries(sourceColumnMap).filter(([, value]) => value !== null)\n        ),\n      };\n      inplaceImportFn([\n        base.id,\n        tableId as string,\n        {\n          ...fileInfo,\n          insertConfig: preInsertConfig,\n          notification: true,\n        },\n      ]);\n    };\n\n    tableId ? inplaceImportTable() : importNewTable();\n  };\n\n  const { mutateAsync: analyzeByUrl, isPending: analyzeLoading } = useMutation({\n    mutationFn: analyzeFile,\n    onSuccess: (data, params) => {\n      const { attachmentUrl, fileType } = params;\n      setFileInfo({\n        attachmentUrl,\n        fileType,\n      });\n      const {\n        data: { worksheets },\n      } = data;\n\n      const workSheetsWithIndex: IImportOptionRo['worksheets'] = {};\n      for (const [key, value] of Object.entries(worksheets)) {\n        const item = { ...value, importData: true, useFirstRowAsHeader: true } as IImportSheetItem;\n        item.columns = item.columns.map((col, index) => ({\n          ...col,\n          sourceColumnIndex: index,\n        }));\n\n        workSheetsWithIndex[key] = item;\n      }\n      setInsertConfig({ ...insertConfig, ['sourceWorkSheetKey']: Object.keys(worksheets)[0] });\n      setWorkSheets(workSheetsWithIndex);\n      primitiveWorkSheets.current = worksheets;\n      setStep(Step.CONFIG);\n    },\n  });\n\n  const fileFinishedHandler = useCallback(\n    async (result: INotifyVo) => {\n      const { presignedUrl } = result;\n\n      await analyzeByUrl({\n        attachmentUrl: presignedUrl,\n        fileType,\n      });\n    },\n    [analyzeByUrl, fileType]\n  );\n\n  const fileCloseHandler = useCallback(() => {\n    setFile(null);\n  }, []);\n\n  const fileChangeHandler = useCallback(\n    (file: File | null) => {\n      const { exceedSize, accept } = importTypeMap[fileType];\n\n      const acceptGroup = accept.split(',');\n\n      if (file && !acceptGroup.includes(file.type)) {\n        toast.error(t('table:import.form.error.errorFileFormat'));\n        return;\n      }\n\n      if (exceedSize && file && file.size > exceedSize * 1024 * 1024) {\n        toast.error(`${t('table:import.tips.fileExceedSizeTip')} ${exceedSize}MB`);\n        return;\n      }\n\n      setFile(file);\n    },\n    [fileType, t]\n  );\n\n  const fieldChangeHandler = (value: IImportOptionRo['worksheets']) => {\n    setWorkSheets(value);\n  };\n\n  const inplaceFieldChangeHandler = (value: IInplaceImportOptionRo['insertConfig']) => {\n    setInsertConfig(value);\n  };\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={(open) => onOpenChange?.(open)}>\n        {children && <DialogTrigger>{children}</DialogTrigger>}\n        {open && (\n          <DialogContent\n            className=\"z-50 flex max-h-[80%] max-w-[800px] flex-col overflow-hidden\"\n            overlayStyle={{\n              pointerEvents: 'none',\n            }}\n            onPointerDownOutside={(e) => e.preventDefault()}\n            onInteractOutside={(e) => e.preventDefault()}\n            onClick={(e) => e.stopPropagation()}\n          >\n            <Tabs defaultValue=\"localFile\" className=\"flex-1 overflow-auto\">\n              {step === Step.UPLOAD && (\n                <TabsList>\n                  <TabsTrigger value=\"localFile\">{t('table:import.title.localFile')}</TabsTrigger>\n                  <TabsTrigger value=\"url\">{t('table:import.title.linkUrl')}</TabsTrigger>\n                </TabsList>\n              )}\n\n              <TabsContent value=\"localFile\">\n                {step === Step.UPLOAD && (\n                  <UploadPanel\n                    fileType={fileType}\n                    file={file}\n                    onChange={fileChangeHandler}\n                    onClose={fileCloseHandler}\n                    analyzeLoading={analyzeLoading}\n                    onFinished={fileFinishedHandler}\n                  />\n                )}\n                {step === Step.CONFIG &&\n                  (tableId ? (\n                    <InplaceFieldConfigPanel\n                      tableId={tableId}\n                      workSheets={workSheets}\n                      insertConfig={insertConfig}\n                      errorMessage={errorMessage}\n                      onChange={inplaceFieldChangeHandler}\n                    ></InplaceFieldConfigPanel>\n                  ) : (\n                    <FieldConfigPanel\n                      tableId={tableId}\n                      workSheets={workSheets}\n                      errorMessage={errorMessage}\n                      onChange={fieldChangeHandler}\n                    ></FieldConfigPanel>\n                  ))}\n              </TabsContent>\n              <TabsContent value=\"url\">\n                {step === Step.UPLOAD && (\n                  <UrlPanel\n                    analyzeFn={analyzeByUrl}\n                    isFinished={analyzeLoading}\n                    fileType={fileType}\n                  ></UrlPanel>\n                )}\n                {step === Step.CONFIG &&\n                  (tableId ? (\n                    <InplaceFieldConfigPanel\n                      tableId={tableId}\n                      workSheets={workSheets}\n                      insertConfig={insertConfig}\n                      errorMessage={errorMessage}\n                      onChange={inplaceFieldChangeHandler}\n                    ></InplaceFieldConfigPanel>\n                  ) : (\n                    <FieldConfigPanel\n                      tableId={tableId}\n                      workSheets={workSheets}\n                      errorMessage={errorMessage}\n                      onChange={fieldChangeHandler}\n                    ></FieldConfigPanel>\n                  ))}\n              </TabsContent>\n            </Tabs>\n            {step === Step.CONFIG && (\n              <DialogFooter>\n                <footer className=\"mt-1 flex items-center justify-end\">\n                  <Button size=\"sm\" variant=\"secondary\" onClick={() => onOpenChange?.(false)}>\n                    {t('table:import.menu.cancel')}\n                  </Button>\n                  <AlertDialog>\n                    {shouldAlert ? (\n                      <AlertDialogTrigger asChild>\n                        <Button\n                          size=\"sm\"\n                          className=\"ml-1\"\n                          disabled={tableId ? inplaceLoading : isLoading}\n                        >\n                          {(tableId ? inplaceLoading : isLoading) && (\n                            <Spin className=\"mr-1 size-4\" />\n                          )}\n                          {t('table:import.title.import')}\n                        </Button>\n                      </AlertDialogTrigger>\n                    ) : (\n                      <Button\n                        size=\"sm\"\n                        className=\"ml-1\"\n                        onClick={() => importTable()}\n                        disabled={tableId ? inplaceLoading : isLoading}\n                      >\n                        {(tableId ? inplaceLoading : isLoading) && <Spin className=\"mr-1 size-4\" />}\n                        {t('table:import.title.import')}\n                      </Button>\n                    )}\n                    <AlertDialogContent>\n                      <AlertDialogHeader>\n                        <AlertDialogTitle>{t('table:import.title.tipsTitle')}</AlertDialogTitle>\n                        <AlertDialogDescription>\n                          {t('table:import.tips.importAlert')}\n                        </AlertDialogDescription>\n                      </AlertDialogHeader>\n                      <div className=\"flex items-center\">\n                        <Checkbox\n                          id=\"noTips\"\n                          checked={shouldTips}\n                          onCheckedChange={(res: boolean) => {\n                            setShouldTips(res);\n                          }}\n                        />\n                        <label\n                          htmlFor=\"noTips\"\n                          className=\"pl-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                        >\n                          {t('table:import.tips.noTips')}\n                        </label>\n                      </div>\n                      <AlertDialogFooter>\n                        <AlertDialogCancel>{t('table:import.menu.cancel')}</AlertDialogCancel>\n                        <AlertDialogAction\n                          onClick={() => {\n                            importTable();\n                            if (shouldTips) {\n                              setShouldAlert(false);\n                            }\n                          }}\n                        >\n                          {t('table:import.title.confirm')}\n                        </AlertDialogAction>\n                      </AlertDialogFooter>\n                    </AlertDialogContent>\n                  </AlertDialog>\n                </footer>\n              </DialogFooter>\n            )}\n          </DialogContent>\n        )}\n      </Dialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/UrlPanel.tsx",
    "content": "import type { UseMutateAsyncFunction } from '@tanstack/react-query';\nimport type { FieldType } from '@teable/core';\nimport type { SUPPORTEDTYPE } from '@teable/openapi';\nimport { importTypeMap } from '@teable/openapi';\nimport { Input, Button, Spin } from '@teable/ui-lib';\nimport type { AxiosResponse } from 'axios';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { z } from 'zod';\n\ninterface IUrlPanel {\n  fileType: SUPPORTEDTYPE;\n  analyzeFn: UseMutateAsyncFunction<\n    AxiosResponse<\n      {\n        worksheets: Record<\n          string,\n          {\n            name: string;\n            columns: {\n              name: string;\n              type: FieldType;\n            }[];\n          }\n        >;\n      },\n      unknown\n    >,\n    unknown,\n    {\n      fileType: SUPPORTEDTYPE;\n      attachmentUrl: string;\n    },\n    unknown\n  >;\n  isFinished: boolean;\n}\n\nconst UrlPanel = (props: IUrlPanel) => {\n  const { fileType, analyzeFn, isFinished } = props;\n  const [linkUrl, setLinkUrl] = useState('');\n  const [errorMessage, setErrorMessage] = useState('');\n  const { t } = useTranslation(['table']);\n\n  return (\n    <div className=\"flex h-32 w-full flex-col items-start px-2\">\n      <h4 className=\"m-2 text-sm\">{t('table:import.title.linkUrlInputTitle')}</h4>\n      <div className=\"flex w-full\">\n        <Input\n          type=\"url\"\n          placeholder={importTypeMap[fileType].exampleUrl}\n          className=\"mr-2\"\n          value={linkUrl}\n          onChange={(e) => {\n            const { value } = e.target;\n            setLinkUrl(value);\n          }}\n        />\n        <Button\n          variant=\"outline\"\n          disabled={isFinished || !linkUrl}\n          onClick={() => {\n            if (!linkUrl) {\n              setErrorMessage(t('table:import.form.error.urlEmptyTip'));\n              return;\n            }\n            if (!z.string().url().safeParse(linkUrl).success) {\n              setErrorMessage(t('table:import.form.error.urlValidateTip'));\n              return;\n            }\n            analyzeFn({\n              attachmentUrl: linkUrl,\n              fileType,\n            });\n          }}\n        >\n          {isFinished && <Spin className=\"mr-1 size-4\" />}\n          {t('table:import.title.upload')}\n        </Button>\n      </div>\n      {errorMessage && <p className=\"p-2 text-sm text-red-500\">{errorMessage}</p>}\n    </div>\n  );\n};\n\nexport { UrlPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/CollapsePanel.tsx",
    "content": "import { ChevronRight } from '@teable/icons';\nimport type { IImportOptionRo } from '@teable/openapi';\nimport { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component/base/BaseSingleSelect';\nimport {\n  Button,\n  cn,\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n  Switch,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport React, { useState } from 'react';\nimport type { ITableImportOptions } from '../TableImport';\nimport type { IInplaceOption } from './inplace-panel/InplaceFieldConfigPanel';\n\ninterface ICollapsePanel {\n  onChange: (value: boolean, propertyName: keyof ITableImportOptions) => void;\n  options: ITableImportOptions;\n}\n\ninterface IInplaceCollapsePanel {\n  onChange: (value: IInplaceOption, propertyName: keyof IInplaceOption) => void;\n  options: IInplaceOption;\n  workSheets: IImportOptionRo['worksheets'];\n}\n\nconst CollapseWraper = (props: { children: React.ReactElement }) => {\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation(['table']);\n  const { children } = props;\n\n  return (\n    <Collapsible open={open} onOpenChange={(open) => setOpen(open)} className=\"w-full rounded-sm\">\n      <CollapsibleTrigger className=\"w-full\" asChild>\n        <Button variant=\"ghost\" className=\"flex w-full justify-start\">\n          <ChevronRight className={cn('h-4 w-4 transition', open ? 'rotate-90' : 'rotate-0')} />\n          {t('table:import.title.optionsTitle')}\n        </Button>\n      </CollapsibleTrigger>\n      <CollapsibleContent className=\"flex flex-col items-start\">{children}</CollapsibleContent>\n    </Collapsible>\n  );\n};\n\nexport const ImportOptionPanel = (props: ICollapsePanel) => {\n  const { options, onChange } = props;\n  const { t } = useTranslation(['table']);\n\n  return (\n    <CollapseWraper>\n      <div>\n        <label\n          htmlFor=\"autoSelectType\"\n          className=\"flex w-56 cursor-pointer items-center rounded p-2 text-sm hover:bg-accent\"\n        >\n          <Switch\n            id=\"autoSelectType\"\n            checked={options.autoSelectType}\n            onCheckedChange={(value) => onChange(value, 'autoSelectType')}\n          />\n          <span className=\"pl-2\">{t('table:import.options.autoSelectFieldOptionName')}</span>\n        </label>\n\n        <label\n          htmlFor=\"useFirstRowAsHeader\"\n          className=\"flex w-56 cursor-pointer items-center rounded p-2 text-sm hover:bg-accent\"\n        >\n          <Switch\n            id=\"useFirstRowAsHeader\"\n            checked={options.useFirstRowAsHeader}\n            onCheckedChange={(value) => onChange(value, 'useFirstRowAsHeader')}\n          />\n          <span className=\"pl-2\">{t('table:import.options.useFirstRowAsHeaderOptionName')}</span>\n        </label>\n\n        <label\n          htmlFor=\"importData\"\n          className=\"flex w-56 cursor-pointer items-center rounded p-2 text-sm hover:bg-accent\"\n        >\n          <Switch\n            id=\"importData\"\n            checked={options.importData}\n            onCheckedChange={(value) => onChange(value, 'importData')}\n          />\n          <span className=\"pl-2\">{t('table:import.options.importDataOptionName')}</span>\n        </label>\n      </div>\n    </CollapseWraper>\n  );\n};\n\nexport const InplaceImportOptionPanel = (props: IInplaceCollapsePanel) => {\n  const { options, workSheets, onChange } = props;\n  const { t } = useTranslation(['table']);\n\n  const sheetKeyOptions = Object.keys(workSheets).map((key) => ({\n    label: key,\n    value: key,\n    icon: null,\n  }));\n\n  const onChangeHandler = (\n    propertyName: keyof IInplaceOption,\n    value: IInplaceOption[keyof IInplaceOption]\n  ) => {\n    const newOptions = { ...options, [propertyName]: value };\n    onChange(newOptions, propertyName);\n  };\n\n  return (\n    <CollapseWraper>\n      <div>\n        {sheetKeyOptions?.length > 1 ? (\n          <div className=\"pl-4\">\n            <span className=\"text-xs\">{t('table:import.options.sheetKey')}</span>\n            <BaseSingleSelect\n              modal\n              value={options.sourceWorkSheetKey}\n              options={sheetKeyOptions}\n              onSelect={(value) => {\n                onChangeHandler('sourceWorkSheetKey', value || '');\n              }}\n              className=\"m-1 w-56 truncate\"\n              popoverClassName=\"w-56\"\n            />\n          </div>\n        ) : null}\n\n        <label\n          htmlFor=\"excludeFirstRow\"\n          className=\"ml-4 flex w-56 cursor-pointer items-center rounded py-2 text-sm hover:bg-accent\"\n        >\n          <Switch\n            id=\"excludeFirstRow\"\n            checked={options.excludeFirstRow}\n            onCheckedChange={(value) => onChangeHandler('excludeFirstRow', value)}\n          />\n          <span className=\"pl-2\">{t('table:import.options.excludeFirstRow')}</span>\n        </label>\n      </div>\n    </CollapseWraper>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/index.ts",
    "content": "export * from './CollapsePanel';\nexport * from './new-create-panel/FieldConfigPanel';\nexport * from './new-create-panel/PreviewColumn';\nexport * from './inplace-panel/InplaceFieldConfigPanel';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/inplace-panel/FieldSelector.tsx",
    "content": "import { DivideCircle } from '@teable/icons';\nimport type { IFieldStatic } from '@teable/sdk';\nimport {\n  Command,\n  CommandEmpty,\n  CommandInput,\n  CommandItem,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Button,\n  CommandList,\n  cn,\n} from '@teable/ui-lib';\nimport { Check, ChevronsUpDown } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useMemo } from 'react';\n\ninterface IFieldSelector {\n  options: {\n    value: string | null;\n    label: string;\n    icon: IFieldStatic['Icon'];\n  }[];\n  value: string | null;\n  onSelect: (value: string | null) => void;\n  disabled?: boolean;\n}\n\nexport function FieldSelector(props: IFieldSelector) {\n  const { options, onSelect, value, disabled = false } = props;\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation(['table', 'common']);\n\n  const comOptions = useMemo(() => {\n    const result = [...options];\n    result.unshift({\n      value: null,\n      label: t('table:import.form.option.doNotImport'),\n      icon: DivideCircle,\n    });\n    return result;\n  }, [options, t]);\n\n  if (!options.length) {\n    return;\n  }\n\n  return (\n    <Popover open={open} onOpenChange={setOpen} modal={true}>\n      <PopoverTrigger className=\"w-full\" asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          disabled={disabled}\n          className=\"w-full justify-between truncate\"\n        >\n          <span className=\"truncate\">{comOptions.find((o) => o.value === value)?.label}</span>\n          <ChevronsUpDown className=\"ml-2 size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-96 p-0\">\n        <Command>\n          <CommandInput placeholder={t('table:import.tips.searchPlaceholder')} />\n          <CommandEmpty>{t('table:import.tips.resultEmpty')}</CommandEmpty>\n          <CommandList>\n            {comOptions.map((o) => {\n              const { icon: Icon } = o;\n              return (\n                <CommandItem\n                  key={o.label}\n                  value={o.value ?? ''}\n                  onSelect={(value) => {\n                    onSelect(value);\n                    setOpen(false);\n                  }}\n                  className=\"flex hover:bg-accent\"\n                >\n                  <Check\n                    className={cn(\n                      'mr-2 h-4 w-4 shrink-0',\n                      value === o.value ? 'opacity-100' : 'opacity-0'\n                    )}\n                  />\n                  <div className=\"flex items-center truncate\">\n                    <Icon className=\"mr-1 shrink-0\" />\n                    <span className=\"truncate\">{o.label}</span>\n                  </div>\n                </CommandItem>\n              );\n            })}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/inplace-panel/InplaceFieldConfigPanel.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IInplaceImportOptionRo, IImportOptionRo } from '@teable/openapi';\nimport { getTableById as apiGetTableById, getFields as apiGetFields } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { isEqual } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { InplaceImportOptionPanel } from '../CollapsePanel';\nimport { InplacePreviewColumn } from './InplacePreviewColumn';\n\ninterface IInplaceFieldConfigPanel {\n  tableId: string;\n  workSheets: IImportOptionRo['worksheets'];\n  errorMessage: string;\n  insertConfig: IInplaceImportOptionRo['insertConfig'];\n  onChange: (value: IInplaceImportOptionRo['insertConfig']) => void;\n}\n\nexport type IInplaceOption = Pick<\n  IInplaceImportOptionRo['insertConfig'],\n  'excludeFirstRow' | 'sourceWorkSheetKey'\n>;\n\nconst InplaceFieldConfigPanel = (props: IInplaceFieldConfigPanel) => {\n  const baseId = useBaseId() as string;\n  const { t } = useTranslation(['table']);\n  const { tableId, workSheets, insertConfig, onChange, errorMessage } = props;\n\n  const options: IInplaceOption = useMemo(\n    () => ({\n      excludeFirstRow: insertConfig.excludeFirstRow,\n      sourceWorkSheetKey: insertConfig.sourceWorkSheetKey,\n    }),\n    [insertConfig]\n  );\n\n  const { data: table } = useQuery({\n    queryKey: ReactQueryKeys.tableInfo(baseId, tableId),\n    queryFn: () => apiGetTableById(baseId, tableId).then((data) => data.data),\n  });\n\n  const { data: fields } = useQuery({\n    queryKey: ReactQueryKeys.field(tableId),\n    queryFn: () => apiGetFields(tableId).then((data) => data.data),\n  });\n\n  const fieldWithPermission = fields?.filter(({ recordRead }) => recordRead !== false);\n\n  const optionHandler = (value: IInplaceOption, propertyName: keyof IInplaceOption) => {\n    const newInsertConfig = {\n      ...insertConfig,\n      ...value,\n    };\n    if (propertyName === 'sourceWorkSheetKey') {\n      newInsertConfig.sourceColumnMap = {};\n    }\n    onChange(newInsertConfig);\n  };\n\n  const columnHandler = (value: IInplaceImportOptionRo['insertConfig']['sourceColumnMap']) => {\n    if (\n      !isEqual(insertConfig.sourceColumnMap, {\n        ...insertConfig.sourceColumnMap,\n        ...value,\n      })\n    ) {\n      onChange({\n        ...insertConfig,\n        ['sourceColumnMap']: {\n          ...insertConfig.sourceColumnMap,\n          ...value,\n        },\n      });\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col\">\n      <div>\n        <p className=\"text-base font-bold\">\n          {t('table:import.title.incrementImportTitle')}\n          {table?.name}\n        </p>\n      </div>\n\n      {fieldWithPermission && (\n        <div className=\"my-2 h-[400px] overflow-y-auto rounded-sm border border-secondary\">\n          <InplacePreviewColumn\n            onChange={columnHandler}\n            workSheets={workSheets}\n            fields={fieldWithPermission}\n            insertConfig={insertConfig}\n          ></InplacePreviewColumn>\n        </div>\n      )}\n\n      {errorMessage && <p className=\"pl-2 text-sm text-red-500\">{errorMessage}</p>}\n\n      <InplaceImportOptionPanel\n        options={options}\n        workSheets={workSheets}\n        onChange={optionHandler}\n      />\n    </div>\n  );\n};\n\nexport { InplaceFieldConfigPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/inplace-panel/InplacePreviewColumn.tsx",
    "content": "import { type IFieldVo } from '@teable/core';\nimport { ArrowLeft } from '@teable/icons';\nimport type { IInplaceImportOptionRo, IImportOptionRo } from '@teable/openapi';\nimport { useFieldStaticGetter } from '@teable/sdk';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect } from 'react';\nimport { FieldSelector } from './FieldSelector';\n\ninterface IPreviewColumnProps {\n  fields: IFieldVo[];\n  workSheets: IImportOptionRo['worksheets'];\n  insertConfig: IInplaceImportOptionRo['insertConfig'];\n  onChange: (columns: IInplaceImportOptionRo['insertConfig']['sourceColumnMap']) => void;\n}\n\nexport const InplacePreviewColumn = (props: IPreviewColumnProps) => {\n  const { onChange, fields, workSheets, insertConfig } = props;\n  const fieldStaticGetter = useFieldStaticGetter();\n  const { t } = useTranslation(['table']);\n\n  const columns = fields.map((col) => ({\n    label: col.name,\n    type: col.type,\n    name: col.name,\n    id: col.id,\n    isComputed: col.isComputed,\n    aiConfig: col.aiConfig,\n  }));\n\n  const sourceColumnMap = workSheets?.[insertConfig.sourceWorkSheetKey] || {};\n\n  const options =\n    sourceColumnMap?.columns?.map((col) => ({\n      label: col.name,\n      value: col.name,\n      icon: fieldStaticGetter(col.type, {\n        isLookup: false,\n        hasAiConfig: false,\n      }).Icon,\n    })) || [];\n\n  useEffect(() => {\n    const isEmptySourceColumnMap = !Object.keys(insertConfig.sourceColumnMap).length;\n    const initSourceColumnMap: Record<string, number | null> = {};\n    const analyzeColumns = sourceColumnMap?.columns;\n    // init sourceColumnMap automatically\n    // TODO add more match logic\n    if (isEmptySourceColumnMap && analyzeColumns?.length) {\n      columns.forEach((col) => {\n        if (!col.isComputed) {\n          const matchIndex = analyzeColumns.findIndex(\n            (c) => c.name.toLowerCase().trim() === col.name.toLowerCase().trim()\n          );\n          // only match the same name, others need to be set manually\n          initSourceColumnMap[col.id] = matchIndex > -1 ? matchIndex : null;\n        }\n      });\n      onChange(initSourceColumnMap);\n    }\n  }, [columns, insertConfig.sourceColumnMap, onChange, sourceColumnMap?.columns]);\n\n  return (\n    <Table className=\"relative scroll-smooth\">\n      <TableHeader>\n        <TableRow>\n          <TableHead className=\"shrink-0\">{t('table:import.title.primitiveFields')}</TableHead>\n          <TableHead></TableHead>\n          <TableHead>{t('table:import.title.importFields')}</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody className=\"w-96 overflow-hidden\">\n        {columns.map((column, index) => {\n          const { Icon } = fieldStaticGetter(column.type, {\n            isLookup: false,\n            hasAiConfig: Boolean(column.aiConfig),\n          });\n          const selectIndex = insertConfig.sourceColumnMap[column.id] ?? null;\n          const value = typeof selectIndex === 'number' ? options[selectIndex].value : null;\n\n          return (\n            <TableRow key={index} className=\"items-center overflow-hidden\">\n              <TableCell className=\"w-48 truncate\">\n                <div className=\"flex w-48 items-center truncate\">\n                  <Icon className=\"size-4 shrink-0\" />\n                  <div className=\"flex-1 truncate pl-2\">\n                    <div className=\"truncate\">{column.name}</div>\n                    <span className=\"truncate text-gray-500\">\n                      {index === 0 ? t('table:import.title.primaryField') : null}\n                    </span>\n                  </div>\n                </div>\n              </TableCell>\n\n              <TableCell>\n                <ArrowLeft />\n              </TableCell>\n\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <TableCell className=\"w-full max-w-[480px]\">\n                      <FieldSelector\n                        value={value}\n                        options={options}\n                        disabled={column.isComputed}\n                        onSelect={(value) => {\n                          const result: Record<string, number | null> = {};\n                          const selectedIndex = options.findIndex((o) => o.value === value);\n                          result[column.id] = selectedIndex > -1 ? selectedIndex : null;\n                          onChange(result);\n                        }}\n                      />\n                      {column.isComputed && (\n                        <TooltipContent>\n                          <p>{t('table:import.tips.notSupportFieldType')}</p>\n                        </TooltipContent>\n                      )}\n                    </TableCell>\n                  </TooltipTrigger>\n                </Tooltip>\n              </TooltipProvider>\n            </TableRow>\n          );\n        })}\n      </TableBody>\n    </Table>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/new-create-panel/FieldConfigPanel.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport { X } from '@teable/icons';\nimport type { IImportOption, IImportOptionRo, IImportSheetItem } from '@teable/openapi';\nimport { Button, cn } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useRef } from 'react';\nimport { ImportOptionPanel } from '../CollapsePanel';\nimport { PreviewColumn } from './PreviewColumn';\n\nexport type ITableImportOptions = IImportOption & {\n  autoSelectType: boolean;\n};\n\ninterface IFieldConfigPanel {\n  tableId?: string;\n  workSheets: IImportOptionRo['worksheets'];\n  errorMessage: string;\n  onChange: (sheets: IImportOptionRo['worksheets']) => void;\n}\n\nconst FieldConfigPanel = (props: IFieldConfigPanel) => {\n  const { onChange, workSheets, errorMessage } = props;\n  const { t } = useTranslation(['table']);\n  const [autoSelectTypes, setAutoSelectTypes] = useState<Record<string, boolean>>({});\n  const [selectedSheetKey, setSelectedSheetKey] = useState(Object.keys(workSheets)[0]);\n  const lastColumnsMap = useRef<Record<string, IImportSheetItem>>(workSheets);\n\n  const data = workSheets[selectedSheetKey];\n\n  const options = {\n    importData: data.importData,\n    useFirstRowAsHeader: data.useFirstRowAsHeader,\n    autoSelectType: autoSelectTypes[selectedSheetKey] ?? true,\n  };\n\n  const sheets = Object.keys(workSheets);\n\n  const columnHandler = (columns: IImportSheetItem['columns']) => {\n    const newSheets = { ...workSheets };\n    newSheets[selectedSheetKey].columns = columns;\n    onChange(newSheets);\n  };\n\n  const optionHandler = (value: boolean, propertyName: keyof ITableImportOptions) => {\n    const updateSheet = () => {\n      const newSheets = {\n        ...workSheets,\n        [selectedSheetKey]: { ...workSheets[selectedSheetKey], [propertyName]: value },\n      };\n      onChange(newSheets);\n    };\n    switch (propertyName) {\n      case 'importData':\n        updateSheet();\n        break;\n      case 'autoSelectType':\n        {\n          const newColumns = !value\n            ? data.columns.map((column) => ({\n                ...column,\n                type: FieldType.SingleLineText,\n              }))\n            : lastColumnsMap.current[selectedSheetKey].columns;\n          setAutoSelectTypes({ ...autoSelectTypes, [selectedSheetKey]: value });\n          onChange({\n            ...workSheets,\n            [selectedSheetKey]: { ...workSheets[selectedSheetKey], columns: newColumns },\n          });\n        }\n        break;\n      case 'useFirstRowAsHeader':\n        {\n          const newColumns = !value\n            ? data.columns.map((column, index) => ({\n                ...column,\n                name: `${t('table:import.form.defaultFieldName')} ${index + 1}`,\n              }))\n            : lastColumnsMap.current[selectedSheetKey].columns;\n\n          onChange({\n            ...workSheets,\n            [selectedSheetKey]: {\n              ...workSheets[selectedSheetKey],\n              [propertyName]: value,\n              columns: newColumns,\n            },\n          });\n        }\n        break;\n      default:\n        break;\n    }\n  };\n\n  const removeSheet = (sheetKey: string) => {\n    const newSheets = { ...workSheets };\n    delete newSheets[sheetKey];\n    const newSheetsKeys = Object.keys(newSheets);\n    if (selectedSheetKey === sheetKey) {\n      setSelectedSheetKey(newSheetsKeys[0]);\n    }\n    onChange(newSheets);\n  };\n\n  return (\n    <div className=\"flex flex-col\">\n      <div>\n        <p className=\"text-base font-bold\">{t('table:import.title.importTitle')}</p>\n      </div>\n\n      <div className=\"relative mt-2 flex w-full gap-1 overflow-x-auto\">\n        {sheets.map((sheetKey) => (\n          <Button\n            variant={'outline'}\n            key={sheetKey}\n            size=\"xs\"\n            onClick={() => setSelectedSheetKey(sheetKey)}\n            className={cn('group max-w-32 shrink-0 cursor-pointer truncate rounded-sm px-2', {\n              'bg-secondary': sheetKey === selectedSheetKey,\n            })}\n            title={workSheets[sheetKey].name}\n          >\n            <span className=\"truncate\">{workSheets[sheetKey].name}</span>\n            {sheets.length !== 1 && (\n              <X\n                className=\"size-3 shrink-0 rounded-full\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  removeSheet(sheetKey);\n                }}\n              />\n            )}\n          </Button>\n        ))}\n      </div>\n\n      <div className=\"my-2 h-[400px] overflow-y-auto rounded-sm border border-secondary\">\n        <PreviewColumn columns={data.columns} onChange={columnHandler}></PreviewColumn>\n      </div>\n\n      {errorMessage && <p className=\"pl-2 text-sm text-red-500\">{errorMessage}</p>}\n\n      <ImportOptionPanel onChange={optionHandler} options={options} />\n    </div>\n  );\n};\n\nexport { FieldConfigPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/field-config-panel/new-create-panel/PreviewColumn.tsx",
    "content": "import { IMPORT_SUPPORTED_TYPES } from '@teable/core';\nimport type { FieldType } from '@teable/core';\nimport { Trash2, Lock } from '@teable/icons';\nimport type { IImportColumn } from '@teable/openapi';\nimport { useFieldStaticGetter } from '@teable/sdk';\nimport { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component/base/BaseSingleSelect';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n  Button,\n  Input,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\ninterface IPreviewColumnProps {\n  columns: IImportColumn[];\n  onChange: (columns: IImportColumn[]) => void;\n}\n\nexport const PreviewColumn = (props: IPreviewColumnProps) => {\n  const { columns, onChange } = props;\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(['table']);\n  const candidates = useMemo(\n    () =>\n      IMPORT_SUPPORTED_TYPES.map<{ value: FieldType; label: string; icon: JSX.Element }>((type) => {\n        const { title, Icon } = getFieldStatic(type, {\n          isLookup: false,\n          hasAiConfig: false,\n        });\n        return {\n          value: type,\n          label: title,\n          icon: <Icon />,\n        };\n      }),\n    [getFieldStatic]\n  );\n\n  const onChangeHandler = (data: IImportColumn[]) => {\n    onChange(data);\n  };\n\n  return (\n    <Table className=\"scroll-smooth\">\n      <TableHeader>\n        <TableRow>\n          <TableHead className=\"w-56\">{t('table:field.fieldName')}</TableHead>\n          <TableHead>{t('table:field.fieldType')}</TableHead>\n          <TableHead className=\"text-right\"></TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {columns.map((column, index) => (\n          <TableRow key={index}>\n            <TableCell className=\"relative min-w-56 font-medium\">\n              <Input\n                placeholder=\"fieldName\"\n                value={column.name}\n                onChange={(e) => {\n                  const newColumns = [...columns];\n                  newColumns[index].name = e.target.value;\n                  onChangeHandler(newColumns);\n                }}\n              />\n            </TableCell>\n            <TableCell className=\"w-full max-w-md\">\n              <BaseSingleSelect\n                modal\n                className=\"m-1 w-full\"\n                options={candidates}\n                popoverClassName=\"w-96 truncate\"\n                value={column.type}\n                onSelect={(value) => {\n                  const newColumns = [...columns];\n                  newColumns[index].type = value as FieldType;\n                  onChangeHandler(newColumns);\n                }}\n                optionRender={(option) => {\n                  return (\n                    <div className=\"flex items-center truncate\">\n                      <span className=\"mr-1 shrink-0\">{option.icon}</span>\n                      <span>{option.label}</span>\n                    </div>\n                  );\n                }}\n              ></BaseSingleSelect>\n            </TableCell>\n            <TableCell className=\"text-right\">\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                disabled={index === 0}\n                onClick={() => {\n                  const newColumns = [...columns];\n                  newColumns.splice(index, 1);\n                  onChange(newColumns);\n                }}\n              >\n                {index === 0 ? <Lock className=\"size-4\" /> : <Trash2 className=\"size-4\" />}\n              </Button>\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/index.ts",
    "content": "export * from './TableImport';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/upload-panel/Process.tsx",
    "content": "import { X } from '@teable/icons';\nimport { getFieldIconString } from '@teable/sdk';\nimport { Progress } from '@teable/ui-lib';\nimport { filesize } from 'filesize';\n\ninterface IFileItemProps {\n  file: File;\n  process: number;\n  onClose: () => void;\n}\n\nexport const Process = (props: IFileItemProps) => {\n  const { file, onClose, process } = props;\n  const { name, size, type } = file;\n\n  return (\n    <>\n      <div className=\"group relative rounded-sm text-sm\">\n        <img\n          className=\"size-full rounded-sm bg-secondary object-contain p-2\"\n          src={getFieldIconString(type)}\n          alt={name}\n        />\n        <div>{name}</div>\n        <div>{filesize(size)}</div>\n        <X\n          className=\"absolute -right-2 -top-2 hidden size-4 cursor-pointer rounded-full bg-secondary p-0.5 group-hover:block hover:opacity-70\"\n          onClick={() => onClose()}\n        />\n      </div>\n      {<Progress className=\"absolute top-0\" value={process}></Progress>}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/upload-panel/Trigger.tsx",
    "content": "import type { SUPPORTEDTYPE } from '@teable/openapi';\nimport { importTypeMap } from '@teable/openapi';\nimport { useRef } from 'react';\nimport { useDropArea } from 'react-use';\n\ninterface IUploadProps {\n  fileType: SUPPORTEDTYPE;\n  onChange: (file: File | null) => void;\n  onBeforeUpload?: () => void;\n  children: React.ReactElement;\n}\n\nexport const Trigger = (props: IUploadProps) => {\n  const { onChange, children, fileType, onBeforeUpload } = props;\n  const uploadRef = useRef<HTMLInputElement>(null);\n\n  const [bound] = useDropArea({\n    onFiles: (files: File[]) => onChange(files[0]),\n  });\n\n  return (\n    <>\n      <input\n        className=\"hidden\"\n        ref={uploadRef}\n        type=\"file\"\n        accept={importTypeMap[fileType].accept}\n        multiple={false}\n        autoComplete=\"off\"\n        tabIndex={-1}\n        onChange={(e) => {\n          onBeforeUpload?.();\n          const files = (e.target.files && Array.from(e.target.files)) || null;\n          if (files && files.length > 0) {\n            onChange(files[0]);\n          }\n        }}\n      ></input>\n      <div\n        role=\"button\"\n        tabIndex={0}\n        className=\"size-full\"\n        onClick={() => {\n          if (uploadRef?.current) {\n            uploadRef.current.value = '';\n            uploadRef?.current?.click();\n          }\n        }}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter') {\n            uploadRef?.current?.click();\n          }\n        }}\n        {...bound}\n      >\n        {children}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/upload-panel/UploadPanel.tsx",
    "content": "import { generateAttachmentId } from '@teable/core';\nimport type { SUPPORTEDTYPE, INotifyVo } from '@teable/openapi';\nimport { UploadType } from '@teable/openapi';\nimport { AttachmentManager } from '@teable/sdk/components';\nimport { Spin, Button, cn } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { Process } from './Process';\nimport { Trigger } from './Trigger';\n\ninterface IUploadPanelProps {\n  file: File | null;\n  fileType: SUPPORTEDTYPE;\n  analyzeLoading: boolean;\n  onClose: () => void;\n  onFinished: (result: INotifyVo) => void;\n  onChange: (file: File | null) => void;\n}\nconst attchmentManager = new AttachmentManager(1);\n\nconst UploadPanel = (props: IUploadPanelProps) => {\n  const { file, fileType, onChange, onFinished, onClose, analyzeLoading } = props;\n  const { t } = useTranslation(['table']);\n  const [process, setProcess] = useState(0);\n  const [isImporting, setIsImporting] = useState(false);\n\n  return (\n    <div\n      className={cn('relative flex h-96 items-center justify-center', {\n        'pointer-events-none': isImporting,\n      })}\n    >\n      {!file ? (\n        <Trigger\n          onBeforeUpload={() => {\n            setIsImporting(true);\n          }}\n          fileType={fileType}\n          onChange={async (file) => {\n            setIsImporting(false);\n            if (file) {\n              attchmentManager.upload(\n                [{ id: generateAttachmentId(), instance: file }],\n                UploadType.Import,\n                {\n                  successCallback: (_, result) => {\n                    onFinished?.(result);\n                  },\n                  progressCallback: (_, process) => {\n                    setProcess(process);\n                  },\n                }\n              );\n            }\n            onChange(file);\n          }}\n        >\n          <div className=\"flex h-full items-center justify-center rounded-sm border-2 border-dashed hover:border-secondary\">\n            {!isImporting ? (\n              <Button variant=\"ghost\">{t('table:import.tips.importWayTip')}</Button>\n            ) : (\n              <div className=\"absolute flex size-full items-center justify-center bg-secondary opacity-90\">\n                <span className=\"mr-1 size-4 animate-spin\">\n                  <Spin className=\"size-4\" />\n                </span>\n                <span>{t('table:import.tips.importing')}</span>\n              </div>\n            )}\n          </div>\n        </Trigger>\n      ) : (\n        <>\n          <Process file={file} onClose={onClose} process={process}></Process>\n          {analyzeLoading && (\n            <div className=\"absolute flex size-full items-center justify-center bg-secondary opacity-90\">\n              <Spin className=\"mr-1 size-4\" />\n              <span>{t('table:import.tips.analyzing')}</span>\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n};\n\nexport { UploadPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/import-table/upload-panel/index.ts",
    "content": "export * from './Process';\nexport * from './Trigger';\nexport * from './UploadPanel';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/index.ts",
    "content": "export {};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/SettingRight.tsx",
    "content": "import { useSession } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\n\ninterface ISettingRight {\n  header?: React.ReactNode;\n  actions?: React.ReactNode;\n  children: React.ReactNode;\n  contentClassName?: string;\n}\n\nexport const SettingRight = (props: ISettingRight) => {\n  const { header, actions, children, contentClassName } = props;\n  const { user } = useSession();\n  return (\n    <div className=\"size-full\">\n      <div className=\"flex h-full flex-col\">\n        {header && (\n          <div className=\"flex items-start justify-between gap-4 border-b px-8 py-4\">\n            <div className=\"flex flex-1 items-start gap-2\">{header}</div>\n            <div className=\"flex shrink-0 items-center gap-2\">\n              {actions}\n              <UserAvatar user={user} />\n            </div>\n          </div>\n        )}\n        <div className={cn('flex-1 overflow-y-auto px-8 py-4', contentClassName)}>{children}</div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/SettingRightTitle.tsx",
    "content": "import { ArrowLeft, HelpCircle } from '@teable/icons';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport Head from 'next/head';\n\ninterface ISettingRightTitle {\n  title?: React.ReactNode;\n  onBack?: () => void;\n  description?: React.ReactNode;\n  helpLink?: string;\n  className?: string;\n  titleClassName?: string;\n  descriptionClassName?: string;\n  withHead?: boolean;\n}\nexport const SettingRightTitle = (props: ISettingRightTitle) => {\n  const {\n    title,\n    onBack,\n    description,\n    helpLink,\n    className,\n    titleClassName,\n    descriptionClassName,\n    withHead = true,\n  } = props;\n  return (\n    <div className={cn('flex flex-1 items-start gap-2', className)}>\n      {onBack && (\n        <Button className=\"px-0 text-base\" variant={'link'} onClick={onBack}>\n          <ArrowLeft className=\"size-4 shrink-0\" />\n        </Button>\n      )}\n      <div className=\"flex flex-1 flex-col gap-1\">\n        <div className=\"flex items-center gap-2\">\n          {withHead && typeof title === 'string' && (\n            <Head>\n              <title>{title}</title>\n            </Head>\n          )}\n          <h2 className={cn('flex-1 text-base', titleClassName)}>{title}</h2>\n          {helpLink && (\n            <Button variant=\"ghost\" size=\"xs\" asChild>\n              <a href={helpLink} target=\"_blank\" rel=\"noreferrer\">\n                <HelpCircle className=\"size-4\" />\n              </a>\n            </Button>\n          )}\n        </div>\n        {description && (\n          <div className={cn('text-sm text-muted-foreground', descriptionClassName)}>\n            {description}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/AccessTokenList.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { Action } from '@teable/core';\nimport { deleteAccessToken, listAccessToken } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { usePermissionActionsStatic } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Table,\n  TableHeader,\n  TableRow,\n  TableHead,\n  TableBody,\n  TableCell,\n  TableCaption,\n  Button,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\n\nexport const PersonAccessTokenListQueryKey = 'person-access-token-list';\n\ninterface IAccessTokenListProps {\n  newToken?: string;\n  onEdit: (id: string) => void;\n}\n\nexport const AccessTokenList = (props: IAccessTokenListProps) => {\n  const { newToken: defaultNewToken, onEdit } = props;\n  const newTokenRef = useRef<string | undefined>(defaultNewToken);\n  const newToken = newTokenRef.current;\n  const { actionStaticMap } = usePermissionActionsStatic();\n  const { t } = useTranslation(personalAccessTokenConfig.i18nNamespaces);\n  const [deleteId, setDeleteId] = useState<string>();\n  const queryClient = useQueryClient();\n  const { data: listResult } = useQuery({\n    queryKey: ReactQueryKeys.personAccessTokenList(),\n    queryFn: () => listAccessToken().then((data) => data.data),\n  });\n\n  const { mutate: deleteAccessTokenMutate, isPending: deleteLoading } = useMutation({\n    mutationFn: deleteAccessToken,\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.personAccessTokenList() });\n      deleteId &&\n        (await queryClient.invalidateQueries({\n          queryKey: ReactQueryKeys.personAccessToken(deleteId),\n        }));\n      setDeleteId(undefined);\n    },\n  });\n\n  const onDelete = () => {\n    if (!deleteId) {\n      setDeleteId(undefined);\n      return;\n    }\n    deleteAccessTokenMutate(deleteId);\n  };\n\n  return (\n    <>\n      {newToken && (\n        <div className=\"rounded border border-green-300 bg-green-300/20 p-3 text-sm dark:border-green-700 dark:bg-green-700/20\">\n          <div>{t('token:new.success.title')}</div>\n          <div className=\"mb-4 mt-2\">{t('token:new.success.description')}</div>\n          <div className=\"flex items-center gap-3\">\n            <Input className=\"w-[26rem] text-muted-foreground\" readOnly value={newToken} />\n            <CopyButton variant=\"outline\" text={newToken} size=\"xs\" iconClassName=\"size-4\" />\n          </div>\n        </div>\n      )}\n      <Table>\n        {!listResult?.length && (\n          <TableCaption className=\"text-center\">{t('token:empty.list')}</TableCaption>\n        )}\n        <TableHeader className=\"sticky top-0 z-10 bg-background\">\n          <TableRow>\n            <TableHead>{t('token:name')}</TableHead>\n            <TableHead>{t('token:access')}</TableHead>\n            <TableHead>{t('token:scopes')}</TableHead>\n            <TableHead>{t('token:createdTime')}</TableHead>\n            <TableHead>{t('token:expiration')}</TableHead>\n            <TableHead>{t('token:lastUse')}</TableHead>\n            <TableHead></TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {listResult?.map(\n            ({\n              id,\n              name,\n              baseIds,\n              spaceIds,\n              hasFullAccess,\n              scopes,\n              expiredTime,\n              lastUsedTime,\n              createdTime,\n            }) => {\n              const accessArr: string[] = [];\n              if (hasFullAccess) {\n                accessArr.push(t('token:accessSelect.fullAccess.title'));\n              } else {\n                if (baseIds?.length) {\n                  accessArr.push(`${baseIds.length} ${t('common:noun.base')}`);\n                }\n                if (spaceIds?.length) {\n                  accessArr.push(`${spaceIds.length} ${t('common:noun.space')}`);\n                }\n              }\n              const scopesMoreLen = scopes.slice(2).length;\n              return (\n                <TableRow key={id}>\n                  <TableCell>\n                    <Button\n                      className=\"underline\"\n                      variant={'link'}\n                      size={'sm'}\n                      onClick={() => onEdit(id)}\n                    >\n                      {name}\n                    </Button>\n                  </TableCell>\n                  <TableCell>\n                    {accessArr.length ? accessArr.join(', ') : t('token:empty.access')}\n                  </TableCell>\n                  <TableCell title={scopes.join('; ')}>\n                    {scopes\n                      .slice(0, 2)\n                      .map((action) => actionStaticMap[action as Action].description)\n                      .join('; ')}\n                    {scopesMoreLen ? ` ${t('token:moreScopes', { len: scopesMoreLen })}` : ''}\n                  </TableCell>\n                  <TableCell>{new Date(createdTime).toLocaleDateString()}</TableCell>\n                  <TableCell>{new Date(expiredTime).toLocaleDateString()}</TableCell>\n                  <TableCell>\n                    {lastUsedTime ? new Date(lastUsedTime).toLocaleDateString() : '-'}\n                  </TableCell>\n                  <TableCell>\n                    <Button\n                      size={'sm'}\n                      variant=\"outline\"\n                      className=\"h-7 text-destructive hover:bg-destructive hover:text-destructive-foreground\"\n                      onClick={() => setDeleteId(id)}\n                    >\n                      {t('common:actions.delete')}\n                    </Button>\n                  </TableCell>\n                </TableRow>\n              );\n            }\n          )}\n        </TableBody>\n      </Table>\n      <ConfirmDialog\n        open={Boolean(deleteId)}\n        closeable={true}\n        onOpenChange={(val) => {\n          if (!val) {\n            setDeleteId(undefined);\n          }\n        }}\n        title={t('token:deleteConfirm.title')}\n        description={t('token:deleteConfirm.description')}\n        onCancel={() => setDeleteId(undefined)}\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.confirm')}\n        confirmLoading={deleteLoading}\n        onConfirm={onDelete}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/PersonAccessTokenForm.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  createAccessToken,\n  updateAccessToken,\n  type CreateAccessTokenVo,\n  type UpdateAccessTokenRo,\n  type UpdateAccessTokenVo,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useRouter } from 'next/router';\nimport type { IFormType } from './form/AccessTokenForm';\nimport { AccessTokenForm } from './form/AccessTokenForm';\nimport { AccessTokenFormEdit } from './form/AccessTokenFormEdit';\n\ninterface IAccessTokenFormProps {\n  formType?: IFormType;\n  accessTokenId: string;\n  onSubmit?: (data: CreateAccessTokenVo | UpdateAccessTokenVo) => void;\n  onRefresh?: (token: string) => void;\n  onCancel?: () => void;\n}\n\nexport const PersonAccessTokenForm = (props: IAccessTokenFormProps) => {\n  const { formType: propFormType, accessTokenId, onSubmit, onCancel, onRefresh } = props;\n  const queryClient = useQueryClient();\n  const router = useRouter();\n  const type = propFormType ?? (router.query.form as IFormType);\n\n  const { mutate: createAccessTokenMutate, isPending: createAccessTokenLoading } = useMutation({\n    mutationFn: createAccessToken,\n    onSuccess: async (data) => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.personAccessTokenList() });\n      onSubmit?.(data.data);\n    },\n  });\n\n  const { mutate: updateAccessTokenMutate, isPending: updateAccessTokenLoading } = useMutation({\n    mutationFn: (updateRo: UpdateAccessTokenRo) => updateAccessToken(accessTokenId, updateRo),\n    onSuccess: async (data) => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.personAccessTokenList() });\n      await queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.personAccessToken(data.data.id),\n      });\n      onSubmit?.(data.data);\n    },\n  });\n\n  if (type === 'new') {\n    return (\n      <AccessTokenForm\n        type=\"new\"\n        onSubmit={createAccessTokenMutate}\n        isLoading={createAccessTokenLoading}\n        onCancel={onCancel}\n      />\n    );\n  }\n  if (type === 'edit') {\n    return (\n      <AccessTokenFormEdit\n        accessTokenId={accessTokenId}\n        type=\"edit\"\n        onSubmit={updateAccessTokenMutate}\n        isLoading={updateAccessTokenLoading}\n        onCancel={onCancel}\n        onRefresh={onRefresh}\n      />\n    );\n  }\n  return <></>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/PersonAccessTokenPage.tsx",
    "content": "import { ArrowUpRight, Plus } from '@teable/icons';\nimport type { CreateAccessTokenVo, UpdateAccessTokenVo } from '@teable/openapi';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useRef } from 'react';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\nimport { SettingRight } from '../SettingRight';\nimport { SettingRightTitle } from '../SettingRightTitle';\nimport { AccessTokenList } from './AccessTokenList';\nimport type { IFormType } from './form/AccessTokenForm';\nimport { PersonAccessTokenForm } from './PersonAccessTokenForm';\n\nexport const PersonAccessTokenPage = () => {\n  const router = useRouter();\n  const accessTokenId = router.query.id as string;\n  const formType = router.query.form as IFormType;\n  const newTokenRef = useRef<string>();\n  const { t } = useTranslation(personalAccessTokenConfig.i18nNamespaces);\n\n  const backList = () => {\n    newTokenRef.current = undefined;\n    router.push({ pathname: router.pathname });\n  };\n\n  const onSubmit = (params: CreateAccessTokenVo | UpdateAccessTokenVo) => {\n    if (formType === 'new') {\n      newTokenRef.current = (params as CreateAccessTokenVo).token;\n    }\n    router.push({ pathname: router.pathname });\n  };\n\n  const onRefresh = (token: string) => {\n    newTokenRef.current = token;\n    router.push({ pathname: router.pathname });\n  };\n\n  useEffect(() => {\n    if (router.query) {\n      newTokenRef.current = undefined;\n    }\n  }, [router.query]);\n\n  const title = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return t('token:new.headerTitle');\n      case 'edit':\n        return t('token:edit.title');\n      default:\n        return t('setting:personalAccessToken');\n    }\n  }, [formType, t]);\n\n  return (\n    <SettingRight\n      contentClassName=\"py-0\"\n      header={\n        <SettingRightTitle\n          title={title}\n          onBack={formType ? backList : undefined}\n          description={\n            !formType ? (\n              <Trans\n                ns=\"token\"\n                i18nKey=\"list.description\"\n                components={{\n                  a: (\n                    <Link\n                      href={t('token:help.link')}\n                      className=\"text-violet-500 underline underline-offset-4\"\n                      target=\"_blank\"\n                    />\n                  ),\n                }}\n              />\n            ) : undefined\n          }\n          className=\"h-auto items-center gap-x-2\"\n          titleClassName=\"text-lg font-medium\"\n        />\n      }\n      actions={\n        !formType ? (\n          <>\n            <Button size=\"xs\" variant=\"ghost\" asChild>\n              <Link href=\"/developer/tool/query-builder\" target=\"_blank\" className=\"gap-1\">\n                <ArrowUpRight className=\"size-4\" />\n                {t('developer:apiQueryBuilder')}\n              </Link>\n            </Button>\n            <Button\n              size=\"xs\"\n              onClick={() =>\n                router.push({\n                  pathname: router.pathname,\n                  query: { form: 'new' },\n                })\n              }\n            >\n              <Plus className=\"size-4 shrink-0\" />\n              {t('token:new.button')}\n            </Button>\n          </>\n        ) : undefined\n      }\n    >\n      <div className=\"flex-1 py-4\">\n        {formType ? (\n          <PersonAccessTokenForm\n            accessTokenId={accessTokenId}\n            onSubmit={onSubmit}\n            onRefresh={onRefresh}\n            onCancel={backList}\n          />\n        ) : (\n          <AccessTokenList\n            newToken={newTokenRef.current}\n            onEdit={(id) =>\n              router.push({\n                pathname: router.pathname,\n                query: { form: 'edit', id },\n              })\n            }\n          />\n        )}\n      </div>\n    </SettingRight>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/AccessList.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Component, Database, X } from '@teable/icons';\nimport type { IGetBaseVo, IGetSpaceVo } from '@teable/openapi';\nimport { getBaseAll, getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { isEmpty } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\n\ninterface IAccessListProps {\n  baseIds?: string[];\n  spaceIds?: string[];\n  hasFullAccess?: boolean;\n  onDeleteFullAccess: () => void;\n  onDeleteBaseId: (baseId: string) => void;\n  onDeleteSpaceId: (spaceId: string) => void;\n}\n\nexport const AccessList = (props: IAccessListProps) => {\n  const { baseIds, spaceIds, hasFullAccess, onDeleteBaseId, onDeleteSpaceId, onDeleteFullAccess } =\n    props;\n  const { t } = useTranslation('token');\n\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n  const { data: baseList } = useQuery({\n    queryKey: ReactQueryKeys.baseAll(),\n    queryFn: () => getBaseAll().then((data) => data.data),\n  });\n  const spaceMap = useMemo(() => {\n    const spaceMap: Record<string, IGetSpaceVo> = {};\n    spaceList?.forEach((item) => {\n      spaceMap[item.id] = item;\n    });\n    return spaceMap;\n  }, [spaceList]);\n\n  const baseMap = useMemo(() => {\n    const baseMap: Record<string, IGetBaseVo> = {};\n    baseList?.forEach((item) => {\n      baseMap[item.id] = item;\n    });\n    return baseMap;\n  }, [baseList]);\n\n  const { displaySpaceMap, displayBaseMap, allDisplaySpaceIds } = useMemo(() => {\n    if (isEmpty(baseMap) && isEmpty(spaceMap)) {\n      return { displayBaseMap: {}, displaySpaceMap: {}, allDisplaySpaceIds: [] };\n    }\n    const displaySpaceMap: Record<string, IGetSpaceVo> = {};\n    const displayBaseMap: Record<string, IGetBaseVo[]> = {};\n    const allDisplaySpaceIds = new Set<string>();\n    spaceIds?.forEach((spaceId) => {\n      displaySpaceMap[spaceId] = spaceMap[spaceId];\n      allDisplaySpaceIds.add(spaceId);\n    });\n\n    baseIds?.forEach((baseId) => {\n      const base = baseMap[baseId];\n      if (!base) {\n        return;\n      }\n      const cur = displayBaseMap[base.spaceId];\n      allDisplaySpaceIds.add(base.spaceId);\n      displayBaseMap[base.spaceId] = cur ? [...cur, base] : [base];\n    });\n\n    return { displayBaseMap, displaySpaceMap, allDisplaySpaceIds: Array.from(allDisplaySpaceIds) };\n  }, [spaceIds, baseIds, spaceMap, baseMap]);\n\n  return (\n    <div className=\"pl-1 text-sm\">\n      {hasFullAccess && (\n        <div className=\"space-y-1\">\n          <div className=\"text-xs text-muted-foreground\">{t('accessSelect.fullAccess.title')}</div>\n\n          <div className=\"flex h-8 items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Component className=\"size-4 shrink-0\" />\n              {t('accessSelect.fullAccess.description')}\n            </div>\n            <Button variant={'ghost'} size={'sm'} onClick={() => onDeleteFullAccess()}>\n              <X className=\"size-4 shrink-0\" />\n            </Button>\n          </div>\n        </div>\n      )}\n      {allDisplaySpaceIds.map((spaceId) => {\n        const space = spaceMap[spaceId];\n        const displaySpace = displaySpaceMap[spaceId];\n        const displayBases = displayBaseMap[spaceId];\n        return (\n          <div key={spaceId} className=\"space-y-1\">\n            <div className=\"text-xs text-muted-foreground\">{space?.name}</div>\n            <div>\n              {displaySpace && (\n                <div className=\"flex h-8 items-center justify-between\">\n                  <div className=\"flex items-center gap-2\">\n                    <Component className=\"size-4 shrink-0\" />\n                    {t('allSpace')}\n                  </div>\n                  <Button\n                    variant={'ghost'}\n                    size={'sm'}\n                    onClick={() => onDeleteSpaceId(displaySpace.id)}\n                  >\n                    <X className=\"size-4 shrink-0\" />\n                  </Button>\n                </div>\n              )}\n              {displayBases?.map((base) => (\n                <div key={base.id} className=\"flex h-8 items-center justify-between\">\n                  <div className=\"flex items-center gap-2\">\n                    {base.icon ? (\n                      <Emoji className=\"w-4 shrink-0\" emoji={base.icon} size={16} />\n                    ) : (\n                      <Database className=\"size-4 shrink-0\" />\n                    )}\n                    {base.name}\n                  </div>\n                  <Button variant={'ghost'} size={'sm'} onClick={() => onDeleteBaseId(base.id)}>\n                    <X className=\"size-4 shrink-0\" />\n                  </Button>\n                </div>\n              ))}\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/AccessSelect.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Component, Database, Plus } from '@teable/icons';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport { getBaseAll, getSharedBase, getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { AccessList } from './AccessList';\n\ninterface IValue {\n  spaceIds?: string[];\n  baseIds?: string[];\n  hasFullAccess?: boolean;\n}\n\ninterface IFormAccess {\n  value?: IValue;\n  onChange: (value: IValue) => void;\n}\n\nexport const AccessSelect = (props: IFormAccess) => {\n  const { onChange, value } = props;\n  const { t } = useTranslation('token');\n  const [bases, setBases] = useState<string[]>(value?.baseIds || []);\n  const [spaces, setSpaces] = useState<string[]>(value?.spaceIds || []);\n  const [open, setOpen] = useState(false);\n\n  const { data: spaceList, isLoading: spaceListLoading } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const { data: baseList, isLoading: baseListLoading } = useQuery({\n    queryKey: ['base-all'],\n    queryFn: () => getBaseAll().then((data) => data.data),\n  });\n\n  const { data: sharedBaseList, isLoading: sharedBaseListLoading } = useQuery({\n    queryKey: ReactQueryKeys.getSharedBase(),\n    queryFn: () => getSharedBase().then((data) => data.data),\n  });\n\n  const baseMap = useMemo(\n    () =>\n      baseList?.reduce(\n        (acc, cur) => {\n          const space = acc[cur.spaceId];\n          acc[cur.spaceId] = space ? [...space, cur] : [cur];\n          return acc;\n        },\n        {} as Record<string, IGetBaseVo[]>\n      ) ?? {},\n    [baseList]\n  );\n\n  const onChangeInner = (spaceId?: string, baseId?: string) => {\n    onChange({\n      ...value,\n      spaceIds: spaceId ? [...spaces, spaceId] : spaces,\n      baseIds: baseId ? [...bases, baseId] : bases,\n    });\n  };\n\n  const onDeleteBaseId = (baseId: string) => {\n    const newBases = bases.filter((id) => id !== baseId);\n    setBases(newBases);\n    onChange({\n      ...value,\n      spaceIds: spaces,\n      baseIds: newBases,\n    });\n  };\n\n  const onDeleteSpaceId = (spaceId: string) => {\n    const newSpaces = spaces.filter((id) => id !== spaceId);\n    setSpaces(newSpaces);\n    onChange({\n      ...value,\n      spaceIds: newSpaces,\n      baseIds: bases,\n    });\n  };\n\n  if (spaceListLoading || baseListLoading || sharedBaseListLoading) {\n    return <Spin className=\"size-5\" />;\n  }\n\n  const onFullAccessChange = (hasFullAccess?: boolean) => {\n    onChange({\n      ...value,\n      hasFullAccess,\n    });\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <AccessList\n        hasFullAccess={value?.hasFullAccess}\n        spaceIds={spaces}\n        baseIds={bases}\n        onDeleteBaseId={onDeleteBaseId}\n        onDeleteSpaceId={onDeleteSpaceId}\n        onDeleteFullAccess={() => {\n          onFullAccessChange(false);\n        }}\n      />\n      <div className=\"flex items-center gap-2\">\n        {!value?.hasFullAccess && (\n          <Button\n            size={'sm'}\n            variant=\"outline\"\n            onClick={() => {\n              onFullAccessChange(true);\n            }}\n          >\n            <Plus className=\"size-4 shrink-0\" />\n            {t('accessSelect.fullAccess.button')}\n          </Button>\n        )}\n        <Popover open={open} onOpenChange={setOpen}>\n          <PopoverTrigger asChild>\n            <Button size={'sm'} variant=\"outline\" role=\"combobox\" aria-expanded={open}>\n              <Plus className=\"size-4 shrink-0\" />\n              {t('accessSelect.button')}\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent className=\"w-96 p-0\">\n            <Command>\n              <CommandInput placeholder={t('accessSelect.inputPlaceholder')} className=\"h-9\" />\n              <CommandEmpty>{t('accessSelect.empty')}</CommandEmpty>\n              <CommandList>\n                {Boolean(sharedBaseList?.length) && (\n                  <CommandGroup\n                    heading={\n                      <div className=\"truncate text-sm font-bold\">\n                        {t('accessSelect.sharedBase')}\n                      </div>\n                    }\n                  >\n                    {sharedBaseList\n                      ?.filter(({ id: baseId }) => !bases.includes(baseId))\n                      ?.map((base) => (\n                        <CommandItem\n                          key={base.id}\n                          value={`${base.id}-${base.name}`}\n                          title={base.name}\n                          onSelect={() => {\n                            setBases((prev) => [...prev, base.id]);\n                            setOpen(false);\n                            onChangeInner(undefined, base.id);\n                          }}\n                        >\n                          {base.name}\n                        </CommandItem>\n                      ))}\n                  </CommandGroup>\n                )}\n                {spaceList\n                  ?.filter(({ id: spaceId }) => !spaces.includes(spaceId))\n                  ?.map(({ id, name }) => (\n                    <CommandGroup\n                      key={id}\n                      heading={<div className=\"truncate text-sm font-bold\">{name}</div>}\n                      title={name}\n                    >\n                      <CommandItem\n                        className=\"gap-1\"\n                        key={`${id}-all`}\n                        value={name}\n                        onSelect={() => {\n                          setSpaces((prev) => [...prev, id]);\n                          setOpen(false);\n                          onChangeInner(id);\n                        }}\n                      >\n                        <Component className=\"size-4 shrink-0\" />\n                        {t('accessSelect.spaceSelectItem')}\n                      </CommandItem>\n                      {baseMap[id]\n                        ?.filter(({ id: baseId }) => !bases.includes(baseId))\n                        ?.map((base) => (\n                          <CommandItem\n                            className=\"gap-1\"\n                            key={base.id}\n                            value={`${base.id}-${base.name}`}\n                            title={base.name}\n                            onSelect={() => {\n                              setBases((prev) => [...prev, base.id]);\n                              setOpen(false);\n                              onChangeInner(undefined, base.id);\n                            }}\n                          >\n                            {base.icon ? (\n                              <Emoji className=\"w-4 shrink-0\" emoji={base.icon} size={16} />\n                            ) : (\n                              <Database className=\"size-4 shrink-0\" />\n                            )}\n                            <div className=\"truncate\">{base.name}</div>\n                          </CommandItem>\n                        ))}\n                    </CommandGroup>\n                  ))}\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/AccessTokenForm.tsx",
    "content": "import { ActionPrefix, type Action } from '@teable/core';\nimport {\n  createAccessTokenRoSchema,\n  type CreateAccessTokenRo,\n  type UpdateAccessTokenRo,\n  updateAccessTokenRoSchema,\n} from '@teable/openapi';\nimport { useSession, useOrganization } from '@teable/sdk/hooks';\nimport { ConfirmDialog, Spin } from '@teable/ui-lib/base';\nimport { Button, Input, Label, Separator } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\nimport { RequireCom } from '../../components/RequireCom';\nimport { ScopesSelect } from '../../components/ScopesSelect';\nimport { AccessSelect } from './AccessSelect';\nimport { ExpirationSelect } from './ExpirationSelect';\nimport { RefreshToken } from './RefreshToken';\n\nexport type IFormType = 'new' | 'edit';\n\ntype ISubmitData = {\n  new: CreateAccessTokenRo;\n  edit: UpdateAccessTokenRo;\n};\n\nexport interface IAccessTokenForm<T extends IFormType = 'new'> {\n  id?: string;\n  type: T;\n  isLoading?: boolean;\n  onCancel?: () => void;\n  onRefresh?: (token: string) => void;\n  onSubmit?: (data: ISubmitData[T]) => void;\n  defaultData?: {\n    name?: string;\n    description?: string;\n    scopes?: string[];\n    spaceIds?: string[];\n    baseIds?: string[];\n    expiredTime?: string;\n    hasFullAccess?: boolean;\n  };\n}\n\nexport const AccessTokenForm = <T extends IFormType>(props: IAccessTokenForm<T>) => {\n  const { type, isLoading, onCancel, onSubmit, onRefresh, defaultData, id } = props;\n  const { t } = useTranslation(personalAccessTokenConfig.i18nNamespaces);\n\n  const { user } = useSession();\n  const { organization } = useOrganization();\n\n  const [spaceIds, setSpaceIds] = useState<string[] | undefined | null>(defaultData?.spaceIds);\n  const [baseIds, setBaseIds] = useState<string[] | undefined | null>(defaultData?.baseIds);\n  const [expiredTime, setExpiredTime] = useState<string | undefined>(defaultData?.expiredTime);\n  const [name, setName] = useState<string | undefined>(defaultData?.name || '');\n  const [description, setDescription] = useState<string | undefined>(\n    defaultData?.description || ''\n  );\n  const [scopes, setScopes] = useState<string[]>(defaultData?.scopes || []);\n  const [hasFullAccess, setHasFullAccess] = useState<boolean | undefined>(\n    defaultData?.hasFullAccess\n  );\n  const [showNoAccessConfirm, setShowNoAccessConfirm] = useState(false);\n\n  const actionsPrefixes = useMemo(() => {\n    const prefixes = [\n      ActionPrefix.Space,\n      ActionPrefix.Base,\n      ActionPrefix.Table,\n      ActionPrefix.View,\n      ActionPrefix.Field,\n      ActionPrefix.Record,\n      ActionPrefix.TableRecordHistory,\n      ActionPrefix.User,\n      ActionPrefix.Automation,\n      ActionPrefix.App,\n    ];\n\n    if (user.isAdmin) {\n      prefixes.push(ActionPrefix.Instance);\n    }\n    if (organization?.isAdmin) {\n      prefixes.push(ActionPrefix.Enterprise);\n    }\n    return prefixes;\n  }, [user.isAdmin, organization?.isAdmin]);\n\n  const disableSubmit = useMemo(() => {\n    if (type === 'new') {\n      return !createAccessTokenRoSchema.safeParse({\n        name,\n        description,\n        scopes,\n        expiredTime,\n        spaceIds,\n        baseIds,\n      }).success;\n    }\n    return !updateAccessTokenRoSchema.safeParse({\n      name,\n      description,\n      scopes,\n      spaceIds,\n      baseIds,\n    }).success;\n  }, [type, name, description, scopes, expiredTime, spaceIds, baseIds]);\n\n  const hasDataAccess = useMemo(() => {\n    return hasFullAccess || (spaceIds && spaceIds.length > 0) || (baseIds && baseIds.length > 0);\n  }, [hasFullAccess, spaceIds, baseIds]);\n\n  const handleSubmit = () => {\n    if (!hasDataAccess) {\n      setShowNoAccessConfirm(true);\n      return;\n    }\n    onSubmitInner();\n  };\n\n  const onSubmitInner = () => {\n    if (type === 'new') {\n      return onSubmit?.({\n        name: name!,\n        description,\n        scopes,\n        expiredTime: expiredTime!,\n        spaceIds,\n        baseIds,\n        hasFullAccess,\n      });\n    }\n    if (type === 'edit') {\n      return onSubmit?.({\n        name: name!,\n        description,\n        scopes,\n        spaceIds,\n        baseIds,\n        hasFullAccess,\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      } as any);\n    }\n  };\n\n  return (\n    <div className=\"w-full max-w-5xl space-y-3 pb-10 pl-1\">\n      {type === 'new' && (\n        <>\n          <p>{t('token:new.title')}</p>\n          <p className=\"text-xs text-muted-foreground\">{t('token:new.description')}</p>\n        </>\n      )}\n      <div className=\"space-y-2\">\n        <Label>\n          {t('token:name')} <RequireCom />\n          <div className=\"text-xs font-normal text-muted-foreground\">\n            {t('token:formLabelTips.name')}\n          </div>\n        </Label>\n        <Input value={name} onChange={(e) => setName(e.target.value)} />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>\n          {t('token:description')}\n          <div className=\"text-xs font-normal text-muted-foreground\">\n            {t('token:formLabelTips.description')}\n          </div>\n        </Label>\n        <Input value={description} onChange={(e) => setDescription(e.target.value)}></Input>\n      </div>\n      {type === 'new' && (\n        <div className=\"space-y-2\">\n          <Label>\n            {t('token:expiration')} <RequireCom />\n          </Label>\n          <ExpirationSelect onChange={setExpiredTime} />\n        </div>\n      )}\n      <div className=\"space-y-2\">\n        <Label>\n          {t('token:scopes')} <RequireCom />\n          <div className=\"text-xs font-normal text-muted-foreground\">\n            {t('token:formLabelTips.scopes')}\n          </div>\n        </Label>\n        <ScopesSelect\n          initValue={scopes as Action[]}\n          onChange={setScopes}\n          actionsPrefixes={actionsPrefixes}\n        />\n      </div>\n      <Separator className=\"y-2\" />\n      <div className=\"space-y-2\">\n        <Label aria-required>\n          {t('token:access')}\n          <div className=\"text-xs font-normal text-muted-foreground\">\n            {t('token:formLabelTips.access')}\n          </div>\n        </Label>\n        <div>\n          <AccessSelect\n            value={{ spaceIds: spaceIds || [], baseIds: baseIds || [], hasFullAccess }}\n            onChange={({ spaceIds, baseIds, hasFullAccess }) => {\n              setSpaceIds(spaceIds?.length ? spaceIds : null);\n              setBaseIds(baseIds?.length ? baseIds : null);\n              setHasFullAccess(hasFullAccess ?? undefined);\n            }}\n          />\n        </div>\n      </div>\n      <Separator />\n      <div className=\"space-x-3 text-right\">\n        {id && <RefreshToken accessTokenId={id} onRefresh={onRefresh} />}\n        <Button size={'sm'} variant={'ghost'} onClick={onCancel}>\n          {t('common:actions.cancel')}\n        </Button>\n        <Button size={'sm'} onClick={handleSubmit} disabled={disableSubmit || isLoading}>\n          {isLoading && <Spin />}\n          {t('common:actions.submit')}\n        </Button>\n      </div>\n\n      <ConfirmDialog\n        open={showNoAccessConfirm}\n        onOpenChange={setShowNoAccessConfirm}\n        title={t('token:noAccessConfirm.title')}\n        content={t('token:noAccessConfirm.description')}\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.continue')}\n        onCancel={() => setShowNoAccessConfirm(false)}\n        onConfirm={() => {\n          setShowNoAccessConfirm(false);\n          onSubmitInner();\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/AccessTokenFormEdit.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getAccessToken } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Skeleton } from '@teable/ui-lib/shadcn';\nimport type { IAccessTokenForm } from './AccessTokenForm';\nimport { AccessTokenForm } from './AccessTokenForm';\n\ninterface IAccessTokenFormEditProps extends IAccessTokenForm<'edit'> {\n  accessTokenId: string;\n}\n\nexport const AccessTokenFormEdit = (props: IAccessTokenFormEditProps) => {\n  const { accessTokenId, ...restProps } = props;\n  const { data: accessTokenData, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.personAccessToken(accessTokenId),\n    queryFn: () => getAccessToken(accessTokenId).then((data) => data.data),\n  });\n  if (isLoading) {\n    return (\n      <div className=\"max-w-5xl space-y-3\">\n        <Skeleton className=\"h-8 w-full\" />\n        <Skeleton className=\"h-8 w-full\" />\n        <Skeleton className=\"h-8 w-full\" />\n      </div>\n    );\n  }\n  return <AccessTokenForm {...restProps} id={accessTokenId} defaultData={accessTokenData} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/ExpirationSelect.tsx",
    "content": "import { CalendarIcon } from '@radix-ui/react-icons';\nimport {\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n  Button,\n  Calendar,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport dayjs from 'dayjs';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\n\ninterface IExpirationSelect {\n  onChange?: (value: string | undefined) => void;\n}\n\nexport const ExpirationSelect = (props: IExpirationSelect) => {\n  const { onChange } = props;\n  const [isCustom, setIsCustom] = useState<boolean>(false);\n  const [date, setDate] = useState<Date>();\n  const { t } = useTranslation('token');\n\n  const options = useMemo(() => {\n    return [\n      {\n        label: `7 ${t('new.expirationList.days')}`,\n        value: '7',\n      },\n      {\n        label: `30 ${t('new.expirationList.days')}`,\n        value: '30',\n      },\n      {\n        label: `60 ${t('new.expirationList.days')}`,\n        value: '60',\n      },\n      {\n        label: `90 ${t('new.expirationList.days')}`,\n        value: '90',\n      },\n      {\n        label: t('new.expirationList.permanent'),\n        value: 'permanent',\n      },\n      {\n        label: t('new.expirationList.custom'),\n        value: '-1',\n      },\n    ];\n  }, [t]);\n\n  const onValueChange = (value: string) => {\n    setDate(undefined);\n    setIsCustom(false);\n    if (value === '-1') {\n      setIsCustom(true);\n      return;\n    }\n    if (value === 'permanent') {\n      onChange?.(dayjs('2099-12-31').format('YYYY-MM-DD'));\n      return;\n    }\n    onChange?.(dayjs().add(Number(value), 'day').format('YYYY-MM-DD'));\n  };\n\n  const onDateChange = (date: Date | undefined) => {\n    setDate(date);\n    onChange?.(date ? dayjs(date).format('YYYY-MM-DD') : undefined);\n  };\n\n  return (\n    <div className=\"flex gap-6\">\n      <Select onValueChange={onValueChange}>\n        <SelectTrigger className=\"w-44\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {options.map(({ label, value }) => {\n            return (\n              <SelectItem key={value} value={value}>\n                {label}\n              </SelectItem>\n            );\n          })}\n        </SelectContent>\n      </Select>\n      {isCustom && (\n        <Popover>\n          <PopoverTrigger asChild>\n            <Button\n              variant={'outline'}\n              size={'sm'}\n              className={cn(\n                'w-[240px] justify-start text-left font-normal',\n                !date && 'text-muted-foreground'\n              )}\n            >\n              <CalendarIcon className=\"mr-2 size-4\" />\n              {date ? (\n                new Date(date).toLocaleDateString()\n              ) : (\n                <span>{t('new.expirationList.pick')}</span>\n              )}\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent className=\"w-auto p-0\" align=\"start\">\n            <Calendar\n              mode=\"single\"\n              selected={date}\n              defaultMonth={date}\n              onSelect={onDateChange}\n              fromYear={new Date().getFullYear()}\n              initialFocus\n            />\n          </PopoverContent>\n        </Popover>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/access-token/form/RefreshToken.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { RefreshAccessTokenRo } from '@teable/openapi';\nimport { refreshAccessToken } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Dialog,\n  DialogTrigger,\n  Button,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\nimport { RequireCom } from '../../components/RequireCom';\nimport { ExpirationSelect } from './ExpirationSelect';\n\ninterface IRefreshTokenProps {\n  accessTokenId: string;\n  onRefresh?: (token: string) => void;\n}\n\nexport const RefreshToken = (props: IRefreshTokenProps) => {\n  const { accessTokenId, onRefresh } = props;\n  const { t } = useTranslation(personalAccessTokenConfig.i18nNamespaces);\n  const [expiredTime, setExpiredTime] = useState<string | undefined>(undefined);\n  const queryClient = useQueryClient();\n  const { mutate: refreshTokenMute, isPending: isLoading } = useMutation({\n    mutationFn: (body?: RefreshAccessTokenRo) => refreshAccessToken(accessTokenId, body),\n    onSuccess: async (data) => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.personAccessTokenList() });\n      await queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.personAccessToken(accessTokenId),\n      });\n      onRefresh?.(data.data.token);\n    },\n  });\n\n  const refreshToken = () => {\n    expiredTime && refreshTokenMute({ expiredTime });\n  };\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button size={'xs'} variant=\"destructive\">\n          {t('token:refresh.button')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"w-96 text-sm\">\n        <DialogHeader>\n          <DialogTitle>{t('token:refresh.title')}</DialogTitle>\n          <DialogDescription>{t('token:refresh.description')}</DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-2\">\n          <Label>\n            {t('token:expiration')} <RequireCom />\n          </Label>\n          <ExpirationSelect onChange={setExpiredTime} />\n        </div>\n        <DialogFooter>\n          <Button\n            size={'sm'}\n            type=\"submit\"\n            onClick={refreshToken}\n            disabled={!expiredTime || isLoading}\n          >\n            {isLoading && <Spin />}\n            {t('common:actions.save')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/components/FormItem.tsx",
    "content": "import { Error } from '@teable/ui-lib/base';\nimport { Label } from '@teable/ui-lib/shadcn';\nimport { forwardRef, useImperativeHandle, useRef, useState } from 'react';\nimport type { z } from 'zod';\nimport { RequireCom } from './RequireCom';\n\ninterface IFormItemProps {\n  title: string;\n  description?: string;\n  required?: boolean;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  validateSchema?: z.Schema<any, any>;\n  children: React.ReactNode;\n}\n\nexport interface IFormItemRef {\n  validate: (value: unknown) => boolean;\n  reset: () => void;\n}\n\nexport const FormItem = forwardRef<IFormItemRef, IFormItemProps>((props, ref) => {\n  const { title, description, required, children, validateSchema } = props;\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [error, setError] = useState<string>();\n\n  useImperativeHandle(ref, () => ({\n    validate: (value: unknown) => {\n      if (!validateSchema) {\n        return true;\n      }\n      const res = validateSchema.safeParse(value);\n      if (!res.success) {\n        containerRef.current?.scrollIntoView({ behavior: 'smooth' });\n        setError(res.error.issues[0]?.message ?? 'Invalid');\n      }\n      return res.success;\n    },\n    reset: () => {\n      setError(undefined);\n    },\n  }));\n\n  return (\n    <div ref={containerRef}>\n      <div className=\"space-y-2\">\n        <Label>\n          {title} {required && <RequireCom />}\n          {description && (\n            <div className=\"text-xs font-normal text-muted-foreground\">{description}</div>\n          )}\n        </Label>\n        {children}\n      </div>\n      <Error className=\"text-xs\" error={error} />\n    </div>\n  );\n});\n\nFormItem.displayName = 'OAuthFormItem';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/components/FormPageLayout.tsx",
    "content": "import { Spin } from '@teable/ui-lib/base';\nimport { Button, Separator } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\ninterface IFormPageLayoutProps {\n  children: React.ReactNode | React.ReactNode[] | null;\n  loading?: boolean;\n  onSubmit?: () => void;\n  onCancel?: () => void;\n}\n\nexport const FormPageLayout = (props: IFormPageLayoutProps) => {\n  const { children, onCancel, onSubmit, loading } = props;\n\n  const { t } = useTranslation('common');\n\n  return (\n    <div className=\"mx-auto max-w-3xl space-y-10 px-0.5\">\n      {children}\n      <Separator />\n      <div className=\"space-x-3 text-right\">\n        <Button size={'sm'} variant={'ghost'} onClick={onCancel}>\n          {t('actions.cancel')}\n        </Button>\n        <Button size={'sm'} onClick={onSubmit} disabled={loading}>\n          {loading && <Spin />}\n          {t('actions.submit')}\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/components/RequireCom.tsx",
    "content": "export const RequireCom = () => <span className=\"ml-0.5 text-red-500\">*</span>;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/components/ScopesSelect.tsx",
    "content": "import { actionPrefixMap } from '@teable/core';\nimport type { Action, ActionPrefix } from '@teable/core';\nimport { usePermissionActionsStatic } from '@teable/sdk/hooks';\nimport { Checkbox, Label, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\n\ninterface IScopesSelectProps {\n  initValue?: Action[];\n  onChange?: (value: string[]) => void;\n  actionsPrefixes?: ActionPrefix[];\n  allowedActions?: readonly Action[];\n}\n\nexport const ScopesSelect = (props: IScopesSelectProps) => {\n  const { onChange, initValue, actionsPrefixes, allowedActions } = props;\n  const { t } = useTranslation('token');\n  const [value, setValue] = useState<Record<Action, boolean>>(() => {\n    if (initValue) {\n      return initValue.reduce(\n        (acc, cur) => {\n          acc[cur] = true;\n          return acc;\n        },\n        {} as Record<Action, boolean>\n      );\n    }\n    return {} as Record<Action, boolean>;\n  });\n  const { actionPrefixStaticMap, actionStaticMap, actionPrefixDisplayOrder } =\n    usePermissionActionsStatic();\n\n  const onCheckBoxChange = (status: boolean, val: Action) => {\n    const actionMap = { ...value };\n    actionMap[val] = status;\n    setValue(actionMap);\n    const actions = Object.keys(actionMap).filter((key) => actionMap[key as Action]);\n    onChange?.(actions);\n  };\n\n  const getActions = (prefix: ActionPrefix) => {\n    const actions = actionPrefixMap[prefix];\n    if (allowedActions) {\n      return actions.filter((action) => (allowedActions as readonly string[]).includes(action));\n    }\n    return actions;\n  };\n\n  const handleSelectAll = (prefix: ActionPrefix, shouldSelect: boolean) => {\n    const actionMap = { ...value };\n    getActions(prefix).forEach((action) => {\n      actionMap[action] = shouldSelect;\n    });\n    setValue(actionMap);\n    const actions = Object.keys(actionMap).filter((key) => actionMap[key as Action]);\n    onChange?.(actions);\n  };\n\n  const actionsPrefix = useMemo(() => {\n    const availableKeys = Object.keys(actionPrefixStaticMap) as ActionPrefix[];\n\n    const orderedKeys = actionPrefixDisplayOrder.filter((prefix) => availableKeys.includes(prefix));\n\n    if (actionsPrefixes) {\n      return orderedKeys.filter((prefix) => actionsPrefixes.includes(prefix));\n    }\n\n    return orderedKeys;\n  }, [actionPrefixStaticMap, actionPrefixDisplayOrder, actionsPrefixes]);\n\n  return (\n    <div className=\"space-y-3 pl-2\">\n      {actionsPrefix.map((actionPrefix) => {\n        const actions = getActions(actionPrefix);\n        const isAllSelected = actions.every((action) => value[action]);\n        return (\n          <div key={actionPrefix} className=\"group space-y-1\">\n            <div className=\"flex items-center\">\n              <Label>{actionPrefixStaticMap[actionPrefix].title}</Label>\n              <Button\n                variant=\"link\"\n                className=\"invisible h-6 px-2 text-xs text-muted-foreground group-hover:visible\"\n                onClick={() => handleSelectAll(actionPrefix, !isAllSelected)}\n              >\n                {isAllSelected ? t('edit.cancelSelectAll') : t('edit.selectAll')}\n              </Button>\n            </div>\n            <div className=\"flex flex-wrap gap-3\">\n              {actions.map((action) => (\n                <div className=\"flex items-center gap-1 text-sm\" key={action}>\n                  <Checkbox\n                    id={action}\n                    value={action}\n                    checked={value[action]}\n                    onCheckedChange={(val: boolean) => {\n                      onCheckBoxChange(val, action);\n                    }}\n                  />\n                  <Label htmlFor={action} className=\"text-xs font-normal\">\n                    {actionStaticMap[action].description}\n                  </Label>\n                </div>\n              ))}\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/OAuthAppDecisionPage.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { HelpCircle } from '@teable/icons';\nimport { decisionInfoGet } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button, Card, Separator, cn } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { OAuthScope } from '@/features/app/components/oauth/OAuthScope';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport { BrandFooter } from '../../view/form/components/BrandFooter';\n\nexport const OAuthAppDecisionPage = () => {\n  const router = useRouter();\n  const { user } = useSession();\n  const transactionId = router.query.transaction_id as string;\n  const getPreviewUrl = usePreviewUrl();\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const { data } = useQuery({\n    queryKey: ['oauth-app-decision-info', transactionId],\n    queryFn: ({ queryKey }) => decisionInfoGet(queryKey[1]).then((data) => data.data),\n    enabled: !!transactionId,\n    refetchOnWindowFocus: false,\n  });\n\n  const decisionInfo = data;\n\n  const scopesTypeLen = useMemo(() => {\n    if (!decisionInfo?.scopes) {\n      return 0;\n    }\n    const types: string[] = [];\n    decisionInfo.scopes.forEach((scope) => {\n      const type = scope.split('|')[0];\n      if (!types.includes(type)) {\n        types.push(type);\n      }\n    });\n    return types.length;\n  }, [decisionInfo?.scopes]);\n\n  if (!transactionId) {\n    return <div>Transaction ID is required</div>;\n  }\n\n  if (!decisionInfo) {\n    return <Spin />;\n  }\n\n  return (\n    <div\n      className={cn('h-screen w-full overflow-auto px-4', {\n        'pt-8': scopesTypeLen && scopesTypeLen < 3,\n      })}\n    >\n      <Card className=\"mx-auto my-8 min-w-72 max-w-xl space-y-4\">\n        <TeableLogo className=\"ml-8 mt-3 size-8\" />\n        <div className=\"relative mx-auto size-28 overflow-hidden\">\n          {decisionInfo.logo ? (\n            <img\n              src={getPreviewUrl(decisionInfo.logo)}\n              alt=\"card cover\"\n              className=\"absolute inset-0 size-full object-contain\"\n            />\n          ) : (\n            <HelpCircle className=\"size-28\" />\n          )}\n        </div>\n        <h2 className=\"mt-4 px-2 text-center text-2xl\">\n          {t('oauth:decision.title', { name: decisionInfo.name })}\n          <div className=\"flex items-center justify-center gap-2\">\n            <UserAvatar user={user} />\n            <div className=\"text-sm text-muted-foreground\">@{user?.name}</div>\n          </div>\n        </h2>\n\n        <Separator />\n        <OAuthScope scopes={decisionInfo.scopes} description={t('oauth:decision.scopes')} />\n        <div className=\"space-y-4 border-t p-8\">\n          <form action=\"/api/oauth/decision\" className=\"flex items-center gap-4\" method=\"post\">\n            <input name=\"transaction_id\" type=\"hidden\" value={transactionId} />\n            <Button\n              type=\"submit\"\n              value={'Deny'}\n              name=\"cancel\"\n              className=\"flex-1\"\n              size={'xs'}\n              variant={'outline'}\n            >\n              {t('common:actions.cancel')}\n            </Button>\n            <Button type=\"submit\" value={'Allow'} className=\"flex-1\" size={'xs'}>\n              {t('oauth:decision.authorize')}\n            </Button>\n          </form>\n          <div className=\"text-center\">\n            <p className=\"text-sm\">{t('oauth:decision.redirectDescription')}</p>\n            <Button variant={'link'}>\n              <Link target=\"_blank\" href={decisionInfo.homepage}>\n                {decisionInfo.homepage}\n              </Link>\n            </Button>\n          </div>\n        </div>\n      </Card>\n      <BrandFooter />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/OAuthAppPage.tsx",
    "content": "import { Plus } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport { SettingRight } from '../SettingRight';\nimport { SettingRightTitle } from '../SettingRightTitle';\nimport { OAuthAppList } from './manage/List';\nimport { OAuthAppEdit } from './manage/OAuthAppEdit';\nimport { OAuthAppNew } from './manage/OAuthAppNew';\n\nexport type IFormType = 'new' | 'edit';\n\nexport const OAuthAppPage = () => {\n  const router = useRouter();\n  const formType = router.query.form as IFormType;\n  const clientId = router.query.id as string;\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  useInitializationZodI18n();\n\n  const onBack = () => {\n    router.push({ pathname: router.pathname });\n  };\n\n  const title = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return t('oauth:title.add');\n      case 'edit':\n        return t('oauth:title.edit');\n      default:\n        return t('setting:oauthApps');\n    }\n  }, [formType, t]);\n\n  const description = useMemo(() => {\n    if (formType) {\n      return undefined;\n    }\n    return (\n      <Trans\n        ns=\"oauth\"\n        i18nKey=\"title.description\"\n        components={{\n          a: (\n            <Link\n              href={t('oauth:help.link')}\n              className=\"text-violet-500 underline underline-offset-4\"\n              target=\"_blank\"\n            />\n          ),\n        }}\n      />\n    );\n  }, [formType, t]);\n\n  const FormPage = useMemo(() => {\n    const onBack = () => {\n      router.push({ pathname: router.pathname });\n    };\n    switch (formType) {\n      case 'new':\n        return <OAuthAppNew onBack={onBack} />;\n      case 'edit':\n        return <OAuthAppEdit clientId={clientId} onBack={onBack} />;\n      default:\n        return (\n          <OAuthAppList\n            onEdit={(id) =>\n              router.push({\n                pathname: router.pathname,\n                query: { form: 'edit', id },\n              })\n            }\n          />\n        );\n    }\n  }, [formType, router, clientId]);\n\n  return (\n    <SettingRight\n      header={\n        <SettingRightTitle\n          title={title}\n          onBack={formType ? onBack : undefined}\n          description={description}\n          className=\"h-auto items-center gap-x-2\"\n          titleClassName=\"text-lg font-medium\"\n        />\n      }\n      actions={\n        !formType ? (\n          <Button\n            size=\"xs\"\n            onClick={() =>\n              router.push({\n                pathname: router.pathname,\n                query: { form: 'new' },\n              })\n            }\n          >\n            <Plus className=\"size-4 shrink-0\" />\n            {t('oauth:add')}\n          </Button>\n        ) : undefined\n      }\n    >\n      <div className=\"space-y-1\">{FormPage}</div>\n    </SettingRight>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/constant.ts",
    "content": "import { ActionPrefix } from '@teable/core';\n\nexport const OAuthActionsPrefixes = [\n  ActionPrefix.App,\n  ActionPrefix.Base,\n  ActionPrefix.Table,\n  ActionPrefix.View,\n  ActionPrefix.Field,\n  ActionPrefix.Record,\n  ActionPrefix.Automation,\n  ActionPrefix.User,\n];\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/CallbackEditor.tsx",
    "content": "import { Plus, Trash2 } from '@teable/icons';\nimport { Button, Input } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\n\ninterface ICallbackEditorProps {\n  value: string[];\n  onChange?: (callbackURLs: string[]) => void;\n}\n\nexport const CallbackEditor = (props: ICallbackEditorProps) => {\n  const { value, onChange } = props;\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const [callbackURLs, setCallbackURLs] = useState(\n    value.length === 0 ? [''] : value.map((url) => url || '')\n  );\n\n  const change = (value: string[]) => {\n    setCallbackURLs(value);\n    onChange?.(value);\n  };\n\n  const updateCallbackURL = (index: number, value: string) => {\n    const newCallbackURLs = [...callbackURLs];\n    newCallbackURLs[index] = value;\n    change(newCallbackURLs);\n  };\n\n  const deleteCallbackURL = (index: number) => {\n    if (callbackURLs.length === 1) {\n      change(['']);\n      return;\n    }\n    const newCallbackURLs = [...callbackURLs];\n    newCallbackURLs.splice(index, 1);\n    change(newCallbackURLs);\n  };\n\n  const addCallbackURL = () => {\n    change([...callbackURLs, '']);\n  };\n\n  return (\n    <>\n      {callbackURLs.map((callbackURL, index) => (\n        <div key={index} className=\"flex items-center gap-4\">\n          <Input\n            type=\"text\"\n            value={callbackURL}\n            onChange={(e) => updateCallbackURL(index, e.target.value)}\n          />\n          <Button variant={'destructive'} size={'icon-xs'} onClick={() => deleteCallbackURL(index)}>\n            <Trash2 className=\"size-4 shrink-0\" />\n          </Button>\n        </div>\n      ))}\n      <Button\n        className=\"h-6 gap-0.5 text-[11px]\"\n        size={'xs'}\n        variant={'ghost'}\n        onClick={() => addCallbackURL()}\n      >\n        <Plus className=\"size-4 shrink-0\" /> {t('oauth:form.callbackUrl.add')}\n      </Button>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/List.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Settings, Trash2 } from '@teable/icons';\nimport type { OAuthGetListVo } from '@teable/openapi';\nimport { oauthGetList, oauthDelete } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport { Button, Card, CardContent } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\n\ninterface IOAuthAppListProps {\n  onEdit: (clientId: string) => void;\n}\n\nexport const OAuthAppList = (props: IOAuthAppListProps) => {\n  const { onEdit } = props;\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const [selectedApp, setSelectedApp] = useState<OAuthGetListVo[number] | null>(null);\n  const { data: oauthApps } = useQuery({\n    queryKey: ReactQueryKeys.oauthAppList(),\n    queryFn: () => oauthGetList().then((data) => data.data),\n    staleTime: 0,\n    refetchOnWindowFocus: false,\n  });\n\n  const { mutate: deleteOAuthAppMutate, isPending: deleteLoading } = useMutation({\n    mutationFn: oauthDelete,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.oauthAppList() });\n      setShowDeleteModal(false);\n      setSelectedApp(null);\n    },\n  });\n\n  const getPreviewUrl = usePreviewUrl();\n\n  useEffect(() => {\n    if (!showDeleteModal) {\n      setSelectedApp(null);\n    }\n  }, [showDeleteModal]);\n\n  return (\n    <div>\n      <div className=\"grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-3\">\n        {oauthApps?.map((app) => (\n          <Card key={app.clientId} className=\"group shadow-none hover:shadow-md\">\n            <CardContent className=\"relative flex size-full items-center gap-5 px-2 py-3\">\n              <div className=\"relative size-16 overflow-hidden rounded-sm\">\n                {app.logo ? (\n                  <img\n                    src={getPreviewUrl(app.logo)}\n                    alt={app.name}\n                    className=\"absolute inset-0 size-full object-contain\"\n                  />\n                ) : (\n                  <TeableLogo className=\"size-16\" />\n                )}\n              </div>\n              <div className=\"h-full flex-1 overflow-hidden\">\n                <div className=\"line-clamp-2 break-words text-sm\">{app.name}</div>\n                <div\n                  className=\"line-clamp-3 break-words text-xs text-muted-foreground\"\n                  title={app.description}\n                >\n                  {app.description}\n                </div>\n              </div>\n              <div className=\"absolute right-2 top-2 flex items-center gap-2\">\n                <Button\n                  className=\"h-5 p-0.5 text-destructive hover:text-destructive\"\n                  variant={'ghost'}\n                  onClick={() => {\n                    setSelectedApp(app);\n                    setShowDeleteModal(true);\n                  }}\n                >\n                  <Trash2 className=\"size-4 shrink-0\" />\n                </Button>\n                <Button\n                  className=\"h-5 p-0.5\"\n                  variant={'ghost'}\n                  onClick={() => {\n                    onEdit(app.clientId);\n                  }}\n                >\n                  <Settings className=\"size-4 shrink-0\" />\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        ))}\n      </div>\n      <ConfirmDialog\n        open={showDeleteModal}\n        onOpenChange={setShowDeleteModal}\n        title={t('oauth:deleteConfirm.title')}\n        description={t('oauth:deleteConfirm.description', { name: selectedApp?.name })}\n        confirmText={t('common:actions.confirm')}\n        cancelText={t('common:actions.cancel')}\n        confirmLoading={deleteLoading}\n        onConfirm={() => {\n          if (selectedApp) {\n            deleteOAuthAppMutate(selectedApp.clientId);\n          }\n        }}\n        onCancel={() => {\n          setShowDeleteModal(false);\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { AlertTriangle, Check, Key } from '@teable/icons';\nimport type { GenerateOAuthSecretVo, OAuthUpdateRo } from '@teable/openapi';\nimport { deleteOAuthSecret, generateOAuthSecret, oauthGet, oauthUpdate } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Badge, Button, Separator, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport { FormPageLayout } from '../../components/FormPageLayout';\nimport type { IOAuthAppFormRef } from './OAuthAppForm';\nimport { OAuthAppForm } from './OAuthAppForm';\n\ninterface IOAuthAppEditProps {\n  onBack: () => void;\n  clientId: string;\n}\n\nexport const OAuthAppEdit = (props: IOAuthAppEditProps) => {\n  const { onBack, clientId } = props;\n  const formRef = useRef<IOAuthAppFormRef>(null);\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const dayjs = useLanDayjs();\n  const queryClient = useQueryClient();\n  const [updatedForm, setUpdatedForm] = useState<OAuthUpdateRo>();\n  const [newSecret, setNewSecret] = useState<GenerateOAuthSecretVo>();\n\n  const { data: oauthApp, isLoading: queryLoading } = useQuery({\n    queryKey: ReactQueryKeys.oauthApp(clientId),\n    queryFn: ({ queryKey }) => oauthGet(queryKey[1]).then((data) => data.data),\n    gcTime: 0,\n  });\n\n  const { mutate: updateMutate, isPending: isLoading } = useMutation({\n    mutationFn: (ro: OAuthUpdateRo) => oauthUpdate(clientId, ro),\n    onSuccess: () => {\n      onBack();\n    },\n  });\n\n  const { mutate: generateSecretMutate, isPending: generateSecretLoading } = useMutation({\n    mutationFn: generateOAuthSecret,\n    onSuccess: (res) => {\n      setNewSecret(res.data);\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.oauthApp(clientId) });\n    },\n  });\n\n  const { mutate: deleteSecretMutate } = useMutation({\n    mutationFn: (secretId: string) => deleteOAuthSecret(clientId, secretId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.oauthApp(clientId) });\n    },\n  });\n\n  return (\n    <FormPageLayout\n      onCancel={onBack}\n      loading={isLoading}\n      onSubmit={() => {\n        if (!updatedForm) {\n          return onBack();\n        }\n        updatedForm && formRef.current?.validate() && updateMutate(updatedForm);\n      }}\n    >\n      <div>\n        <div className=\"space-y-2\">\n          <div className=\"space-y-1\">\n            <h3 className=\"font-semibold\">{t('oauth:formType.clientInfo')}</h3>\n            <Separator />\n          </div>\n          <div className=\"text-sm\">\n            <strong>{t('oauth:form.clientId.label')}</strong>\n            {oauthApp?.clientId}\n          </div>\n        </div>\n        <div className=\"space-y-4 pt-10\">\n          <div className=\"flex items-center justify-between\">\n            <strong>{t('oauth:form.secret.label')}</strong>\n            <Button\n              variant={'outline'}\n              size={'xs'}\n              disabled={generateSecretLoading}\n              onClick={() => generateSecretMutate(clientId)}\n            >\n              {generateSecretLoading && <Spin />}\n              {t('oauth:form.secret.add')}\n            </Button>\n          </div>\n          {!oauthApp?.secrets?.length && (\n            <div className=\"text-sm\">{t('oauth:form.secret.empty')}</div>\n          )}\n          <div className=\"rounded-lg border\">\n            {oauthApp?.secrets?.map((secret, index) => {\n              const isNewSecret = newSecret?.id === secret.id;\n              return (\n                <div\n                  key={secret.id}\n                  className={cn('flex items-center gap-4 p-4', {\n                    'bg-green-300/20 p-3 text-sm dark:bg-green-700/20': isNewSecret,\n                    'border-t': index !== 0,\n                  })}\n                >\n                  <div className=\"flex flex-col items-center justify-center gap-2\">\n                    <div>\n                      <Key className=\"size-8\" />\n                    </div>\n                    <Badge variant=\"outline\">{t('oauth:form.secret.tag')}</Badge>\n                  </div>\n                  <div className=\"flex-1 text-xs\">\n                    <div\n                      className={cn({\n                        'flex items-center gap-2': isNewSecret,\n                      })}\n                    >\n                      {isNewSecret && <Check className=\"text-green-400 dark:text-green-600\" />}\n                      {isNewSecret ? newSecret?.secret : secret.secret}\n                      {isNewSecret && (\n                        <CopyButton className=\"h-6 p-0\" variant={'link'} text={newSecret.secret} />\n                      )}\n                    </div>\n                    {isNewSecret && (\n                      <div className=\"flex items-center gap-2\">\n                        <AlertTriangle className=\"text-warning\" />\n                        {t('oauth:form.secret.newDescription')}\n                      </div>\n                    )}\n                    <div className=\"text-muted-foreground\">\n                      {secret.lastUsedTime\n                        ? t('oauth:form.secret.lastUsed', {\n                            date: dayjs(secret.lastUsedTime).fromNow(),\n                          })\n                        : t('oauth:form.secret.neverUsed')}\n                    </div>\n                  </div>\n                  <Button\n                    size={'xs'}\n                    variant={'destructive'}\n                    onClick={() => deleteSecretMutate(secret.id)}\n                  >\n                    {t('common:actions.delete')}\n                  </Button>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n      {!queryLoading && (\n        <OAuthAppForm ref={formRef} showBasicTitle value={oauthApp} onChange={setUpdatedForm} />\n      )}\n    </FormPageLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppForm.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { OAUTH_ACTIONS, type Action } from '@teable/core';\nimport {\n  UploadType,\n  oauthCreateRoSchema,\n  type OAuthCreateRo,\n  type OAuthUpdateRo,\n} from '@teable/openapi';\nimport { FileZone } from '@teable/sdk/components/FileZone';\nimport { Button, Input, Separator, Textarea } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useImperativeHandle, useRef, useState } from 'react';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { uploadFiles } from '@/features/app/utils/uploadFile';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport type { IFormItemRef } from '../../components/FormItem';\nimport { FormItem } from '../../components/FormItem';\nimport { ScopesSelect } from '../../components/ScopesSelect';\nimport { OAuthActionsPrefixes } from '../constant';\nimport { CallbackEditor } from './CallbackEditor';\n\ninterface IOAuthAppFormProps {\n  value?: OAuthCreateRo | OAuthUpdateRo;\n  onChange?: (value: OAuthCreateRo | OAuthUpdateRo) => void;\n  showBasicTitle?: boolean;\n}\n\nexport interface IOAuthAppFormRef {\n  validate: () => boolean;\n}\n\nexport const OAuthAppForm = forwardRef<IOAuthAppFormRef, IOAuthAppFormProps>((props, ref) => {\n  const { showBasicTitle, value, onChange } = props;\n\n  const validateRefs = useRef<Partial<{ [key in keyof OAuthCreateRo]: IFormItemRef | null }>>({});\n  const errorRef = useRef<IFormItemRef | null>();\n\n  const [form, setForm] = useState<OAuthCreateRo>(\n    value ?? {\n      name: '',\n      homepage: '',\n      redirectUris: [],\n    }\n  );\n\n  useImperativeHandle(ref, () => ({\n    validate: () => {\n      errorRef.current = Object.entries(validateRefs.current).find(\n        ([key, ref]) => !ref?.validate(form?.[key as keyof OAuthCreateRo])\n      )?.[1];\n      return !errorRef.current;\n    },\n  }));\n\n  const updateForm = (key: keyof OAuthCreateRo, value: OAuthCreateRo[keyof OAuthCreateRo]) => {\n    errorRef.current?.reset();\n    setForm((prev) => {\n      const newForm = { ...prev, [key]: value };\n      onChange?.(newForm);\n      return newForm;\n    });\n  };\n\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const getPreviewUrl = usePreviewUrl();\n  const fileInput = useRef<HTMLInputElement>(null);\n\n  const { mutateAsync: uploadLogo, isPending: uploadLogoLoading } = useMutation({\n    mutationFn: (files: File[]) => uploadFiles(files, UploadType.OAuth),\n    onSuccess: (res) => {\n      if (res?.[0]?.path) {\n        updateForm('logo', res[0].path);\n      }\n      return res;\n    },\n  });\n\n  const logoChange = (files: File[]) => {\n    if (files.length === 0) return;\n    if (files.length > 1) {\n      toast.warning(t('oauth:form.logo.lengthError'));\n      return;\n    }\n    if (files[0].type.indexOf('image') === -1) {\n      toast.warning(t('oauth:form.logo.typeError'));\n      return;\n    }\n    uploadLogo(files);\n  };\n\n  return (\n    <>\n      <div className=\"space-y-4\">\n        {showBasicTitle && (\n          <div className=\"space-y-1\">\n            <h3 className=\"font-semibold\">{t('oauth:formType.basic')}</h3>\n            <Separator />\n          </div>\n        )}\n        <FormItem\n          validateSchema={oauthCreateRoSchema.shape.name}\n          ref={(el) => {\n            validateRefs.current['name'] = el;\n          }}\n          title={t('oauth:form.name.label')}\n          description={t('oauth:form.name.description')}\n          required\n        >\n          <Input\n            type=\"text\"\n            value={form.name}\n            onChange={(e) => updateForm('name', e.target.value)}\n          />\n        </FormItem>\n\n        <FormItem title={t('oauth:form.description.label')}>\n          <Textarea\n            className=\"h-32\"\n            value={form.description}\n            onChange={(e) => updateForm('description', e.target.value)}\n          />\n        </FormItem>\n        <FormItem\n          validateSchema={oauthCreateRoSchema.shape.homepage}\n          ref={(el) => {\n            validateRefs.current['homepage'] = el;\n          }}\n          title={t('oauth:form.homePageUrl.label')}\n          description={t('oauth:form.homePageUrl.description')}\n          required\n        >\n          <Input\n            type=\"text\"\n            value={form.homepage}\n            onChange={(e) => updateForm('homepage', e.target.value)}\n          />\n        </FormItem>\n        <FormItem title={t('oauth:form.logo.label')} description={t('oauth:form.logo.description')}>\n          <div className=\"flex items-center gap-3\">\n            <Button\n              variant={'outline'}\n              size={'xs'}\n              className=\"m-1 gap-2 font-normal\"\n              onClick={() => fileInput.current?.click()}\n            >\n              <input\n                type=\"file\"\n                className=\"hidden\"\n                accept=\"image/*,\"\n                ref={fileInput}\n                onChange={(e) => logoChange(Array.from(e.target.files || []))}\n              />\n              {t('oauth:form.logo.button')}\n            </Button>\n            {form.logo && (\n              <Button\n                size={'xs'}\n                variant={'destructive'}\n                onClick={() => updateForm('logo', undefined)}\n              >\n                {t('oauth:form.logo.clear')}\n              </Button>\n            )}\n          </div>\n          <FileZone\n            className=\"size-52\"\n            fileInputProps={{\n              accept: 'image/*,',\n              multiple: false,\n            }}\n            action={['click', 'drop']}\n            onChange={logoChange}\n            disabled={uploadLogoLoading}\n            defaultText={t('oauth:form.logo.placeholder')}\n          >\n            {form.logo && (\n              <div className=\"relative size-full overflow-hidden rounded-md border border-border\">\n                <img\n                  src={getPreviewUrl(form.logo)}\n                  alt=\"card cover\"\n                  className=\"absolute inset-0 size-full object-contain\"\n                />\n              </div>\n            )}\n          </FileZone>\n        </FormItem>\n      </div>\n      <div className=\"space-y-2\">\n        <div className=\"space-y-1\">\n          <h3 className=\"font-semibold\">{t('oauth:formType.identify')}</h3>\n          <Separator />\n        </div>\n        <div className=\"space-y-2\">\n          <FormItem\n            title={t('oauth:form.callbackUrl.label')}\n            description={t('oauth:form.callbackUrl.description')}\n            validateSchema={oauthCreateRoSchema.shape.redirectUris}\n            ref={(el) => {\n              validateRefs.current['redirectUris'] = el;\n            }}\n            required\n          >\n            <CallbackEditor\n              value={form.redirectUris}\n              onChange={(value) => updateForm('redirectUris', value ?? [])}\n            />\n          </FormItem>\n        </div>\n      </div>\n      <div className=\"space-y-4\">\n        <div className=\"space-y-1\">\n          <h3 className=\"font-semibold\">{t('oauth:formType.scopes')}</h3>\n          <Separator />\n        </div>\n        <ScopesSelect\n          actionsPrefixes={OAuthActionsPrefixes}\n          allowedActions={OAUTH_ACTIONS}\n          initValue={form.scopes as Action[]}\n          onChange={(value) =>\n            updateForm(\n              'scopes',\n              value.filter((v) => OAUTH_ACTIONS.includes(v as (typeof OAUTH_ACTIONS)[number]))\n            )\n          }\n        />\n      </div>\n    </>\n  );\n});\n\nOAuthAppForm.displayName = 'OAuthAppForm';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/oauth-app/manage/OAuthAppNew.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { oauthCreate, type OAuthCreateRo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useRef, useState } from 'react';\nimport { FormPageLayout } from '../../components/FormPageLayout';\nimport type { IOAuthAppFormRef } from './OAuthAppForm';\nimport { OAuthAppForm } from './OAuthAppForm';\n\ninterface IOAuthAppNewProps {\n  onBack: () => void;\n}\n\nexport const OAuthAppNew = (props: IOAuthAppNewProps) => {\n  const { onBack } = props;\n  const formRef = useRef<IOAuthAppFormRef>(null);\n  const queryClient = useQueryClient();\n  const [form, setForm] = useState<OAuthCreateRo>({\n    name: '',\n    homepage: '',\n    redirectUris: [],\n  });\n\n  const { mutate, isPending: isLoading } = useMutation({\n    mutationFn: oauthCreate,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.oauthAppList() });\n      onBack();\n    },\n  });\n  return (\n    <FormPageLayout\n      onCancel={onBack}\n      onSubmit={() => {\n        formRef.current?.validate() && mutate(form);\n      }}\n      loading={isLoading}\n    >\n      <OAuthAppForm ref={formRef} value={form} onChange={setForm} />\n    </FormPageLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/MarkDownEditor.tsx",
    "content": "import { MarkdownPreview } from '@teable/sdk';\nimport { Tabs, TabsContent, TabsList, TabsTrigger, Textarea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\n\nexport const MarkDownEditor = (props: {\n  defaultStatus?: 'write' | 'preview';\n  value?: string;\n  onChange: (value: string) => void;\n}) => {\n  const { defaultStatus = 'write', value, onChange } = props;\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n\n  return (\n    <div>\n      <Tabs defaultValue={defaultStatus}>\n        <TabsList className=\"grid w-56 grid-cols-2\">\n          <TabsTrigger className=\"h-full text-xs\" value=\"write\">\n            {t('plugin:markdown.write')}\n          </TabsTrigger>\n          <TabsTrigger className=\"h-full text-xs\" value=\"preview\">\n            {t('plugin:markdown.preview')}\n          </TabsTrigger>\n        </TabsList>\n        <TabsContent value=\"write\">\n          <Textarea\n            className=\"h-[200px] max-h-[700px] w-full\"\n            value={value}\n            onChange={(e) => onChange(e.target.value)}\n          />\n        </TabsContent>\n        <TabsContent value=\"preview\">\n          <MarkdownPreview>{value}</MarkdownPreview>\n        </TabsContent>\n      </Tabs>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginEdit.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { RefreshCcw } from '@teable/icons';\nimport type { IUpdatePluginRo } from '@teable/openapi';\nimport {\n  getPlugin,\n  pluginRegenerateSecret,\n  PluginStatus,\n  submitPlugin,\n  updatePlugin,\n  updatePluginRoSchema,\n} from '@teable/openapi';\nimport { UserAvatar } from '@teable/sdk/components';\nimport { Spin } from '@teable/ui-lib';\nimport {\n  Button,\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  Input,\n  Label,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { Send } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useMount } from 'react-use';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport { FormPageLayout } from '../components/FormPageLayout';\nimport { RequireCom } from '../components/RequireCom';\nimport { JsonEditor } from './component/JsonEditor';\nimport { LogoEditor } from './component/LogoEditor';\nimport { NewSecret } from './component/NewSecret';\nimport { PositionSelector } from './component/PositionSelector';\nimport { StatusBadge } from './component/StatusBadge';\nimport { MarkDownEditor } from './MarkDownEditor';\n\nexport const PluginEdit = (props: { secret?: string }) => {\n  const router = useRouter();\n  const pluginId = router.query.id as string;\n  const queryClient = useQueryClient();\n  const [newSecret, setNewSecret] = useState<string | undefined>(props.secret);\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const secretRef = useRef<HTMLDivElement>(null);\n\n  useMount(() => {\n    secretRef.current?.scrollIntoView({ block: 'center', inline: 'start' });\n  });\n\n  const { data: initFormValue } = useQuery({\n    queryKey: ['plugin', pluginId],\n    queryFn: () => getPlugin(pluginId).then((res) => res.data),\n    enabled: !!pluginId,\n  });\n\n  const form = useForm<IUpdatePluginRo>({\n    resolver: zodResolver(updatePluginRoSchema),\n    mode: 'onChange',\n    values: initFormValue,\n  });\n\n  const { mutate } = useMutation({\n    mutationFn: (ro: IUpdatePluginRo) => updatePlugin(pluginId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['plugin', pluginId] });\n      router.push({ pathname: router.pathname });\n    },\n  });\n\n  const { mutate: regenerateSecret } = useMutation({\n    mutationFn: pluginRegenerateSecret,\n    onSuccess: (res) => {\n      setNewSecret(res.data.secret);\n      queryClient.invalidateQueries({ queryKey: ['plugin', pluginId] });\n    },\n  });\n\n  const { mutate: submitApproved, isPending: submitApprovedLoading } = useMutation({\n    mutationFn: submitPlugin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['plugin', pluginId] });\n    },\n  });\n\n  const onSubmit = async (data: IUpdatePluginRo) => {\n    mutate(data);\n  };\n\n  const pluginUser = initFormValue?.pluginUser;\n\n  return (\n    <FormPageLayout\n      onSubmit={form.handleSubmit(onSubmit)}\n      onCancel={() => router.push({ pathname: router.pathname })}\n    >\n      {initFormValue?.status && (\n        <div className=\"absolute right-10 flex items-center gap-2 bg-background\">\n          <StatusBadge status={initFormValue.status} />\n          {initFormValue.status === PluginStatus.Developing && (\n            <Button\n              className=\"h-[22px]\"\n              size={'xs'}\n              variant={'outline'}\n              disabled={submitApprovedLoading}\n              onClick={() => {\n                submitApproved(pluginId);\n              }}\n            >\n              {submitApprovedLoading ? <Spin /> : <Send className=\"text-green-500\" size={12} />}\n              {t('plugin:button.submitApproved')}\n            </Button>\n          )}\n        </div>\n      )}\n      <div className=\"space-y-2\">\n        <NewSecret secret={newSecret} ref={secretRef} />\n        <div>\n          {pluginUser && (\n            <div className=\"space-y-2\">\n              <Label>{t('plugin:pluginUser.name')}</Label>\n              <div className=\"text-xs text-muted-foreground\">\n                {t('plugin:pluginUser.description')}\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <UserAvatar avatar={pluginUser.avatar} name={pluginUser.name} />\n                <div className=\"text-sm font-normal\">{pluginUser.name}</div>\n              </div>\n            </div>\n          )}\n        </div>\n        <div className=\"space-y-2\">\n          <Label>\n            {t('plugin:secret')}\n            <Button\n              className=\"ml-2 h-auto p-1.5\"\n              title={t('plugin:regenerateSecret')}\n              size={'xs'}\n              variant={'outline'}\n              onClick={() => regenerateSecret(pluginId)}\n            >\n              <RefreshCcw />\n            </Button>\n          </Label>\n          <div className=\"text-sm font-normal\">{initFormValue?.secret}</div>\n        </div>\n      </div>\n      <Form {...form}>\n        <form className=\"space-y-6\">\n          <FormField\n            control={form.control}\n            name=\"name\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.name.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.name.description')}</FormDescription>\n                <FormControl>\n                  <Input\n                    {...field}\n                    value={field.value ?? ''}\n                    onChange={(e) => field.onChange(e.target.value || null)}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"description\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.description.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.description.description')}</FormDescription>\n                <FormControl>\n                  <Textarea {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"detailDesc\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.detailDesc.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.detailDesc.description')}</FormDescription>\n                <FormControl>\n                  <MarkDownEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"logo\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.logo.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.logo.description')}</FormDescription>\n                <FormControl>\n                  <LogoEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"helpUrl\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.helpUrl.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.helpUrl.description')}</FormDescription>\n                <FormControl>\n                  <Input\n                    {...field}\n                    value={field.value ?? ''}\n                    onChange={(e) => field.onChange(e.target.value || null)}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"positions\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.positions.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.positions.description')}</FormDescription>\n                <FormControl>\n                  <PositionSelector value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"config\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.config.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.config.description')}</FormDescription>\n                <FormControl>\n                  <JsonEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"i18n\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.i18n.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.i18n.description')}</FormDescription>\n                <FormControl>\n                  <JsonEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"url\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.url.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.url.description')}</FormDescription>\n                <FormControl>\n                  <Input value={field.value ?? ''} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </form>\n      </Form>\n    </FormPageLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginList.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Plus, Settings, Trash2 } from '@teable/icons';\nimport { deletePlugin, getPlugins } from '@teable/openapi';\nimport type { PluginStatus } from '@teable/openapi';\nimport { Button, Card, CardContent } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport { StatusBadge } from './component/StatusBadge';\nimport { StatusDot } from './component/StatusDot';\nimport { useStatusStatic } from './hooks/useStatusStatic';\n\nexport const PluginList = () => {\n  const router = useRouter();\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const statusStatic = useStatusStatic();\n\n  const { data: pluginList } = useQuery({\n    queryKey: ['plugin-list'],\n    queryFn: () => getPlugins().then((res) => res.data),\n  });\n\n  const { mutate: deletePluginMutate } = useMutation({\n    mutationFn: deletePlugin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['plugin-list'] });\n    },\n  });\n\n  return (\n    <div>\n      <div className=\"flex justify-between\">\n        <div className=\"flex gap-1\">\n          {Object.keys(statusStatic).map((status) => (\n            <StatusBadge key={status} status={status as PluginStatus} />\n          ))}\n        </div>\n        <Button\n          size={'xs'}\n          onClick={() => {\n            router.push({ pathname: router.pathname, query: { form: 'new' } });\n          }}\n        >\n          <Plus className=\"size-4 shrink-0\" />\n          {t('plugin:add')}\n        </Button>\n      </div>\n      <div className=\"mt-6 grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-3\">\n        {pluginList?.map((plugin) => (\n          <Card key={plugin.id} className=\"group shadow-none hover:shadow-md\">\n            <CardContent className=\"relative flex size-full items-center gap-5 px-2 py-3\">\n              <div className=\"relative size-16 overflow-hidden rounded-sm\">\n                <img\n                  src={plugin.logo}\n                  alt={plugin.name}\n                  className=\"absolute inset-0 size-full object-contain\"\n                />\n              </div>\n              <div className=\"h-full flex-1 overflow-hidden\">\n                <div className=\"flex h-6 items-center gap-1 pr-11\">\n                  <StatusDot className=\"shrink-0\" status={plugin.status} />\n                  <div className=\"line-clamp-1 break-words text-sm\">{plugin.name}</div>\n                </div>\n                <div\n                  className=\"line-clamp-3 break-words text-xs text-muted-foreground\"\n                  title={plugin.description}\n                >\n                  {plugin.description}\n                </div>\n              </div>\n              <div className=\"absolute right-2 top-3 space-x-1.5\">\n                <Button\n                  className=\"h-5 p-0.5\"\n                  variant={'ghost'}\n                  onClick={() => {\n                    router.push({\n                      pathname: router.pathname,\n                      query: { form: 'edit', id: plugin.id },\n                    });\n                  }}\n                >\n                  <Settings className=\"size-4 shrink-0\" />\n                </Button>\n                <Button\n                  className=\"h-5 p-0.5\"\n                  variant={'ghost'}\n                  onClick={() => {\n                    deletePluginMutate(plugin.id);\n                  }}\n                >\n                  <Trash2 className=\"text-destructive\" />\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginNew.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useMutation } from '@tanstack/react-query';\nimport type { ICreatePluginRo } from '@teable/openapi';\nimport { createPlugin, createPluginRoSchema } from '@teable/openapi';\nimport {\n  Checkbox,\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n  Input,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useForm } from 'react-hook-form';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport { FormPageLayout } from '../components/FormPageLayout';\nimport { RequireCom } from '../components/RequireCom';\nimport { JsonEditor } from './component/JsonEditor';\nimport { LogoEditor } from './component/LogoEditor';\nimport { PositionSelector } from './component/PositionSelector';\nimport { MarkDownEditor } from './MarkDownEditor';\n\nexport const PluginNew = (props: { onCreated?: (secret: string) => void }) => {\n  const { onCreated } = props;\n  const router = useRouter();\n  const form = useForm<ICreatePluginRo>({\n    resolver: zodResolver(createPluginRoSchema),\n  });\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n\n  const { mutate } = useMutation({\n    mutationFn: createPlugin,\n    onSuccess: (res) => {\n      router.push({\n        pathname: router.pathname,\n        query: { form: 'edit', id: res.data.id },\n      });\n      onCreated?.(res.data.secret);\n    },\n  });\n  const onSubmit = async (data: ICreatePluginRo) => {\n    mutate(data);\n  };\n\n  return (\n    <FormPageLayout\n      onSubmit={form.handleSubmit(onSubmit)}\n      onCancel={() => router.push({ pathname: router.pathname })}\n    >\n      <Form {...form}>\n        <form className=\"space-y-6\">\n          <FormField\n            control={form.control}\n            name=\"name\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.name.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.name.description')}</FormDescription>\n                <FormControl>\n                  <Input\n                    {...field}\n                    value={field.value ?? ''}\n                    onChange={(e) => field.onChange(e.target.value || null)}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"description\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.description.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.description.description')}</FormDescription>\n                <FormControl>\n                  <Textarea {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"detailDesc\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.detailDesc.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.detailDesc.description')}</FormDescription>\n                <FormControl>\n                  <MarkDownEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"logo\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.logo.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.logo.description')}</FormDescription>\n                <FormControl>\n                  <LogoEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"helpUrl\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.helpUrl.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.helpUrl.description')}</FormDescription>\n                <FormControl>\n                  <Input {...field} onChange={(e) => field.onChange(e.target.value || undefined)} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"positions\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  {t('plugin:form.positions.label')}\n                  <RequireCom />\n                </FormLabel>\n                <FormDescription>{t('plugin:form.positions.description')}</FormDescription>\n                <FormControl>\n                  <PositionSelector value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"config\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.config.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.config.description')}</FormDescription>\n                <FormControl>\n                  <JsonEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"i18n\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.i18n.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.i18n.description')}</FormDescription>\n                <FormControl>\n                  <JsonEditor value={field.value} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"url\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.url.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.url.description')}</FormDescription>\n                <FormControl>\n                  <Input value={field.value ?? ''} onChange={field.onChange} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"autoCreateMember\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>{t('plugin:form.autoCreateMember.label')}</FormLabel>\n                <FormDescription>{t('plugin:form.autoCreateMember.description')}</FormDescription>\n                <FormControl>\n                  <Checkbox\n                    checked={field.value ?? false}\n                    onCheckedChange={(checked) => field.onChange(checked ?? false)}\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </form>\n      </Form>\n    </FormPageLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/PluginPage.tsx",
    "content": "import { Plus } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport { SettingRight } from '../SettingRight';\nimport { SettingRightTitle } from '../SettingRightTitle';\nimport { PluginEdit } from './PluginEdit';\nimport { PluginList } from './PluginList';\nimport { PluginNew } from './PluginNew';\n\nexport type IFormType = 'new' | 'edit';\n\nexport const PluginPage = () => {\n  const router = useRouter();\n  const [createdSecret, setCreatedSecret] = useState<string>();\n  const formType = router.query.form as IFormType;\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  useInitializationZodI18n();\n\n  const onBack = () => {\n    router.push({ pathname: router.pathname });\n  };\n\n  useEffect(() => {\n    const handleRouteChange = (path: string) => {\n      if (router.query.form !== 'new' || !path.startsWith('/setting/plugin?form=edit')) {\n        setCreatedSecret(undefined);\n      }\n    };\n\n    router.events.on('routeChangeStart', handleRouteChange);\n\n    return () => {\n      router.events.off('routeChangeStart', handleRouteChange);\n    };\n  }, [router]);\n\n  const title = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return t('plugin:title.add');\n      case 'edit':\n        return t('plugin:title.edit');\n      default:\n        return t('setting:plugins');\n    }\n  }, [formType, t]);\n\n  const FormPage = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return <PluginNew onCreated={(secret) => setCreatedSecret(secret)} />;\n      case 'edit':\n        return <PluginEdit secret={createdSecret} />;\n      default:\n        return <PluginList />;\n    }\n  }, [formType, createdSecret]);\n\n  return (\n    <SettingRight\n      header={\n        <SettingRightTitle\n          title={title}\n          onBack={formType ? onBack : undefined}\n          className=\"h-auto items-center gap-x-2\"\n          titleClassName=\"text-lg font-medium\"\n        />\n      }\n      actions={\n        !formType ? (\n          <Button\n            size=\"xs\"\n            onClick={() =>\n              router.push({\n                pathname: router.pathname,\n                query: { form: 'new' },\n              })\n            }\n          >\n            <Plus className=\"size-4 shrink-0\" />\n            {t('plugin:add')}\n          </Button>\n        ) : undefined\n      }\n    >\n      <div className=\"space-y-1\">{FormPage}</div>\n    </SettingRight>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/JsonEditor.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { defaultKeymap, historyKeymap, indentWithTab, history } from '@codemirror/commands';\nimport { json, jsonParseLinter } from '@codemirror/lang-json';\nimport { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';\nimport { linter } from '@codemirror/lint';\nimport { EditorView, keymap } from '@codemirror/view';\nimport { isObject } from 'lodash';\nimport { useEffect, useRef } from 'react';\n\nexport const JsonEditor = (props: {\n  value?: Record<string, any>;\n  onChange: (value: Record<string, any>) => void;\n}) => {\n  const { value, onChange } = props;\n  const editorRef = useRef<HTMLDivElement>(null);\n  const editorViewRef = useRef<EditorView | null>(null);\n\n  const onBlur = () => {\n    const value = editorViewRef.current?.state.toJSON().doc;\n    try {\n      onChange(value ? JSON.parse(value) : undefined);\n    } catch (e: any) {\n      console.log(e.message);\n      onChange(value);\n    }\n  };\n\n  useEffect(() => {\n    if (!editorViewRef.current) return;\n    editorViewRef.current.dispatch({\n      changes: {\n        from: 0,\n        to: editorViewRef.current.state.doc.length,\n        insert: isObject(value) ? JSON.stringify(value, null, 2) : value,\n      },\n    });\n  }, [value]);\n\n  useEffect(() => {\n    if (!editorRef.current) return;\n    editorViewRef.current = new EditorView({\n      parent: editorRef.current,\n      doc: isObject(value) ? JSON.stringify(value, null, 2) : value,\n      extensions: [\n        EditorView.theme({\n          '&': {\n            minHeight: '56px',\n            maxHeight: '220px',\n            fontSize: '13px',\n            backgroundColor: 'transparent',\n            padding: '0px',\n          },\n          '.cm-scroller': { overflow: 'auto' },\n          '&.cm-focused': { outline: 'none' },\n          '.cm-line': { padding: '0px', lineHeight: '20px' },\n          '.cm-tooltip': {\n            borderWidth: '1px',\n            borderColor: 'hsl(var(--input))',\n            borderRadius: 'calc(var(--radius) - 2px)',\n            backgroundColor: 'hsl(var(--primary))',\n            color: 'hsl(var(--primary-foreground))',\n            fontSize: '13px',\n            overflow: 'hidden',\n          },\n          '.cm-content': { padding: '8px 10px', caretColor: 'hsl(var(--primary))' },\n        }),\n        syntaxHighlighting(defaultHighlightStyle, { fallback: true }),\n        keymap.of([...defaultKeymap, indentWithTab, ...historyKeymap]),\n        json(),\n        history(),\n        linter(jsonParseLinter()),\n      ],\n    });\n    return () => {\n      editorViewRef.current?.destroy();\n      editorViewRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return <div ref={editorRef} className=\"rounded-md border\" onBlur={onBlur} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/LogoEditor.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { UploadType } from '@teable/openapi';\nimport { FileZone } from '@teable/sdk/components/FileZone';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { uploadFiles } from '@/features/app/utils/uploadFile';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\n\nexport const LogoEditor = (props: {\n  value?: string;\n  onChange: (value?: string | null) => void;\n}) => {\n  const { value, onChange } = props;\n  const previewUrl = usePreviewUrl();\n  const [uploadedPath, setUploadedPath] = useState<string | null>(null);\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const fileInput = useRef<HTMLInputElement>(null);\n  const { mutateAsync: uploadLogo, isPending: uploadLogoLoading } = useMutation({\n    mutationFn: (files: File[]) => uploadFiles(files, UploadType.Plugin),\n    onSuccess: (res) => {\n      if (res?.[0]) {\n        onChange(res[0].path);\n        setUploadedPath(res[0].path);\n      }\n      return res;\n    },\n  });\n\n  const logoChange = (files: File[]) => {\n    if (files.length === 0) return;\n    if (files.length > 1) {\n      toast.warning(t('plugin:form.logo.lengthError'));\n      return;\n    }\n    if (files[0].type.indexOf('image') === -1) {\n      toast.warning(t('plugin:form.logo.typeError'));\n      return;\n    }\n    uploadLogo(files);\n  };\n\n  return (\n    <div>\n      <div className=\"flex items-center gap-3\">\n        <input\n          type=\"file\"\n          className=\"hidden\"\n          accept=\"image/*,\"\n          ref={fileInput}\n          onChange={(e) => logoChange(Array.from(e.target.files || []))}\n        />\n        <Button\n          type=\"button\"\n          variant={'outline'}\n          size={'xs'}\n          className=\"m-1 gap-2 font-normal\"\n          onClick={(e) => {\n            fileInput.current?.click();\n            e.stopPropagation();\n            e.preventDefault();\n          }}\n        >\n          {t('plugin:form.logo.upload')}\n        </Button>\n        {value && (\n          <Button type=\"button\" size={'xs'} variant={'destructive'} onClick={() => onChange(null)}>\n            {t('plugin:form.logo.clear')}\n          </Button>\n        )}\n      </div>\n      <FileZone\n        className=\"size-52\"\n        fileInputProps={{\n          accept: 'image/*,',\n          multiple: false,\n        }}\n        action={['click', 'drop']}\n        onChange={logoChange}\n        disabled={uploadLogoLoading}\n        defaultText={t('plugin:form.logo.placeholder')}\n      >\n        {value && (\n          <div className=\"relative size-full overflow-hidden rounded-md border border-border\">\n            <img\n              src={previewUrl(uploadedPath || value)}\n              alt=\"card cover\"\n              className=\"absolute inset-0 size-full object-contain\"\n            />\n          </div>\n        )}\n      </FileZone>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/NewSecret.tsx",
    "content": "import { Input } from '@teable/ui-lib/shadcn';\nimport { forwardRef } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\n\nexport const NewSecret = forwardRef<HTMLDivElement, { secret?: string }>((props, ref) => {\n  const { secret } = props;\n  if (!secret) return;\n  return (\n    <div\n      ref={ref}\n      className=\"rounded border border-green-300 bg-green-300/20 p-3 text-sm dark:border-green-700 dark:bg-green-700/20\"\n    >\n      <div className=\"flex items-center gap-3\">\n        <Input className=\"w-[26rem] text-muted-foreground\" readOnly value={secret} />\n        <CopyButton variant=\"outline\" text={secret} size=\"xs\" iconClassName=\"size-4\" />\n      </div>\n    </div>\n  );\n});\n\nNewSecret.displayName = 'NewSecret';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/PositionSelector.tsx",
    "content": "import { PluginPosition } from '@teable/openapi';\nimport { Checkbox } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\n\nexport const PositionSelector = (props: {\n  value?: PluginPosition[];\n  onChange: (value?: PluginPosition[]) => void;\n}) => {\n  const { value = [], onChange } = props;\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n  const positionStatic = useMemo(() => {\n    return {\n      [PluginPosition.Dashboard]: t('common:noun.dashboard'),\n      [PluginPosition.View]: t('common:noun.view'),\n      [PluginPosition.ContextMenu]: t('common:noun.pluginContextMenu'),\n      [PluginPosition.Panel]: t('common:noun.pluginPanel'),\n    };\n  }, [t]);\n  return (\n    <div>\n      {Object.values(PluginPosition).map((position) => (\n        <div key={position} className=\"flex items-center gap-2\">\n          <Checkbox\n            id={`position-${position}`}\n            checked={value.includes(position)}\n            onCheckedChange={() => {\n              const newValue = value.includes(position)\n                ? value.filter((v) => v !== position)\n                : [...value, position];\n              onChange(newValue);\n            }}\n          />\n\n          <label htmlFor={`position-${position}`} className=\"text-sm font-normal\">\n            {positionStatic[position]}\n          </label>\n        </div>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/StatusBadge.tsx",
    "content": "import type { PluginStatus } from '@teable/openapi';\nimport { Badge, cn } from '@teable/ui-lib/shadcn';\nimport { useStatusStatic } from '../hooks/useStatusStatic';\nimport { StatusDot } from './StatusDot';\n\nexport const StatusBadge = ({\n  status,\n  className,\n}: {\n  status: PluginStatus;\n  className?: string;\n}) => {\n  const statusStatic = useStatusStatic();\n  const text = statusStatic[status];\n  return (\n    <Badge variant={'outline'} className={cn('gap-1.5', className)}>\n      <StatusDot status={status} />\n      {text}\n    </Badge>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/component/StatusDot.tsx",
    "content": "import { PluginStatus } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\n\nexport const StatusDot = ({ status, className }: { status: PluginStatus; className?: string }) => {\n  switch (status) {\n    case PluginStatus.Developing:\n      return (\n        <span\n          className={cn('size-1.5 rounded-full bg-gray-500', className)}\n          aria-hidden=\"true\"\n        ></span>\n      );\n    case PluginStatus.Reviewing:\n      return (\n        <span\n          className={cn('size-1.5 rounded-full bg-yellow-500', className)}\n          aria-hidden=\"true\"\n        ></span>\n      );\n    case PluginStatus.Published:\n      return (\n        <span\n          className={cn('size-1.5 rounded-full bg-emerald-500', className)}\n          aria-hidden=\"true\"\n        ></span>\n      );\n    default:\n      return <></>;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/plugin/hooks/useStatusStatic.ts",
    "content": "import { PluginStatus } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\n\nexport const useStatusStatic = (): Record<PluginStatus, string> => {\n  const { t } = useTranslation(settingPluginConfig.i18nNamespaces);\n\n  return useMemo(\n    () => ({\n      [PluginStatus.Developing]: t('plugin:status.developing'),\n      [PluginStatus.Reviewing]: t('plugin:status.reviewing'),\n      [PluginStatus.Published]: t('plugin:status.published'),\n    }),\n    [t]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/AIContextPanel.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { FieldType } from '@teable/core';\nimport { Copy, Check } from '@teable/icons';\nimport { getFields, getTableById } from '@teable/openapi';\nimport { Button, Skeleton, Tabs, TabsList, TabsTrigger, TabsContent } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useMemo, useEffect } from 'react';\nimport { developerConfig } from '@/features/i18n/developer.config';\nimport { CodeBlock } from './PreviewScript';\n\ninterface IAIContextPanelProps {\n  tableId: string;\n  baseId: string;\n}\n\ninterface IFieldInfo {\n  id: string;\n  name: string;\n  type: string;\n  description?: string;\n  options?: unknown;\n  isPrimary?: boolean;\n  isComputed?: boolean;\n}\n\nconst getFieldTypeDescription = (type: FieldType, options?: unknown): string => {\n  switch (type) {\n    case FieldType.SingleLineText:\n      return 'Single line text';\n    case FieldType.LongText:\n      return 'Long text / Rich text';\n    case FieldType.Number:\n      return 'Number';\n    case FieldType.SingleSelect: {\n      const opts = options as { choices?: { name: string }[] };\n      const choices = opts?.choices?.map((c) => c.name).join(', ') || '';\n      return choices ? `Single select (options: ${choices})` : 'Single select';\n    }\n    case FieldType.MultipleSelect: {\n      const opts = options as { choices?: { name: string }[] };\n      const choices = opts?.choices?.map((c) => c.name).join(', ') || '';\n      return choices ? `Multiple select (options: ${choices})` : 'Multiple select';\n    }\n    case FieldType.Checkbox:\n      return 'Checkbox (true/false)';\n    case FieldType.Date:\n      return 'Date/Time';\n    case FieldType.Attachment:\n      return 'File attachments';\n    case FieldType.Link:\n      return 'Link to another table';\n    case FieldType.Formula:\n      return 'Computed formula field';\n    case FieldType.Rollup:\n    case FieldType.ConditionalRollup:\n      return 'Rollup (aggregation from linked records)';\n    case FieldType.User:\n      return 'User reference';\n    case FieldType.CreatedTime:\n      return 'Created time (auto-generated)';\n    case FieldType.LastModifiedTime:\n      return 'Last modified time (auto-generated)';\n    case FieldType.CreatedBy:\n      return 'Created by (auto-generated)';\n    case FieldType.LastModifiedBy:\n      return 'Last modified by (auto-generated)';\n    case FieldType.AutoNumber:\n      return 'Auto-incrementing number';\n    case FieldType.Rating:\n      return 'Rating (1-5 stars)';\n    case FieldType.Button:\n      return 'Button (trigger actions)';\n    default:\n      return type;\n  }\n};\n\nconst generateAIContext = (\n  tableName: string,\n  tableDescription: string | undefined,\n  fields: IFieldInfo[],\n  baseUrl: string,\n  tableId: string\n): string => {\n  const fieldDescriptions = fields\n    .map((field) => {\n      const typeDesc = getFieldTypeDescription(field.type as FieldType, field.options);\n      const primary = field.isPrimary ? ' [PRIMARY]' : '';\n      const computed = field.isComputed ? ' [READ-ONLY]' : '';\n      const desc = field.description ? ` - ${field.description}` : '';\n      return `  - \"${field.name}\" [id: ${field.id}] (${typeDesc})${primary}${computed}${desc}`;\n    })\n    .join('\\n');\n\n  const editableFields = fields\n    .filter((f) => !f.isComputed)\n    .map((f) => `\"${f.name}\"`)\n    .join(', ');\n\n  return `# Table: ${tableName}\n${tableDescription ? `\\nDescription: ${tableDescription}\\n` : ''}\n## Fields\n${fieldDescriptions}\n\n## API Information\n- Base URL: ${baseUrl}\n- Table ID: ${tableId}\n- API Endpoint: ${baseUrl}/api/table/${tableId}/record\n\n## How to Interact with This Table\n\n### Read Records (GET)\n\\`\\`\\`\nGET ${baseUrl}/api/table/${tableId}/record?fieldKeyType=name\nAuthorization: Bearer YOUR_API_TOKEN\n\\`\\`\\`\n\nQuery Parameters:\n- filter: JSON filter object for filtering records (⚠️ use field ID, not name)\n- orderBy: JSON array for sorting (⚠️ use field ID, not name)\n- search: Search text across all fields\n- take: Number of records to return (default: 100, max: 1000)\n- skip: Number of records to skip for pagination\n\n**Important**: The \\`fieldId\\` in filter/orderBy MUST use the actual field ID (e.g., \"fldXXXX\"), not the field name. Field IDs are listed above in the Fields section.\n\n### Create Record (POST)\n\\`\\`\\`\nPOST ${baseUrl}/api/table/${tableId}/record\nAuthorization: Bearer YOUR_API_TOKEN\nContent-Type: application/json\n\n{\n  \"fieldKeyType\": \"name\",\n  \"records\": [\n    {\n      \"fields\": {\n        // Editable fields: ${editableFields || 'None'}\n      }\n    }\n  ]\n}\n\\`\\`\\`\n\n### Update Record (PATCH)\n\\`\\`\\`\nPATCH ${baseUrl}/api/table/${tableId}/record/{recordId}\nAuthorization: Bearer YOUR_API_TOKEN\nContent-Type: application/json\n\n{\n  \"fieldKeyType\": \"name\",\n  \"record\": {\n    \"fields\": {\n      // Include only fields you want to update\n    }\n  }\n}\n\\`\\`\\`\n\n### Delete Record (DELETE)\n\\`\\`\\`\nDELETE ${baseUrl}/api/table/${tableId}/record/{recordId}\nAuthorization: Bearer YOUR_API_TOKEN\n\\`\\`\\`\n\n## Notes for AI\n- Fields marked [PRIMARY] are the main identifier field\n- Fields marked [READ-ONLY] are computed and cannot be directly modified\n- Use fieldKeyType=name to reference fields by their display name in request/response body\n- **Important**: filter and orderBy parameters MUST use field IDs (e.g., \"fldXXXX\"), not field names\n- Dates should be in ISO 8601 format\n- For select fields, use the exact option names listed\n- For link fields, provide an array of record IDs from the linked table\n`;\n};\n\nconst generateCompactAIContext = (\n  tableName: string,\n  tableDescription: string | undefined,\n  fields: IFieldInfo[],\n  baseUrl: string,\n  tableId: string\n): string => {\n  const fieldList = fields\n    .map((f) => {\n      const typeDesc = getFieldTypeDescription(f.type as FieldType, f.options);\n      const flags = [f.isPrimary && 'PRIMARY', f.isComputed && 'READ-ONLY']\n        .filter(Boolean)\n        .join(',');\n      return `${f.name} (${typeDesc})${flags ? ` [${flags}]` : ''}`;\n    })\n    .join('; ');\n\n  return `Table \"${tableName}\"${tableDescription ? ` - ${tableDescription}` : ''}\nFields: ${fieldList}\nAPI: ${baseUrl}/api/table/${tableId}/record (GET/POST/PATCH/DELETE)\nAuth: Bearer token required. Use fieldKeyType=name for field references.`;\n};\n\nexport const AIContextPanel = ({ tableId, baseId }: IAIContextPanelProps) => {\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n  const [copied, setCopied] = useState<'full' | 'compact' | null>(null);\n  const [currentUrl, setCurrentUrl] = useState('');\n  const [contextFormat, setContextFormat] = useState<'full' | 'compact'>('full');\n\n  useEffect(() => {\n    setCurrentUrl(window.location.origin);\n  }, []);\n\n  const { data: tableInfo, isLoading: isTableLoading } = useQuery({\n    queryKey: ['table-info-ai', baseId, tableId],\n    queryFn: () => getTableById(baseId, tableId).then((res) => res.data),\n    enabled: Boolean(tableId) && Boolean(baseId),\n  });\n\n  const { data: fieldsData, isLoading: isFieldsLoading } = useQuery({\n    queryKey: ['fields-ai', tableId],\n    queryFn: () => getFields(tableId).then((res) => res.data),\n    enabled: Boolean(tableId),\n  });\n\n  const isLoading = isTableLoading || isFieldsLoading;\n\n  const { fullContext, compactContext } = useMemo(() => {\n    if (!tableInfo || !fieldsData) {\n      return { fullContext: '', compactContext: '' };\n    }\n\n    const fields: IFieldInfo[] = fieldsData.map((field) => ({\n      id: field.id,\n      name: field.name,\n      type: field.type,\n      description: field.description,\n      options: field.options,\n      isPrimary: field.isPrimary,\n      isComputed: field.isComputed,\n    }));\n\n    return {\n      fullContext: generateAIContext(\n        tableInfo.name,\n        tableInfo.description,\n        fields,\n        currentUrl,\n        tableId\n      ),\n      compactContext: generateCompactAIContext(\n        tableInfo.name,\n        tableInfo.description,\n        fields,\n        currentUrl,\n        tableId\n      ),\n    };\n  }, [tableInfo, fieldsData, currentUrl, tableId]);\n\n  const handleCopy = async (type: 'full' | 'compact') => {\n    const text = type === 'full' ? fullContext : compactContext;\n    await navigator.clipboard.writeText(text);\n    setCopied(type);\n    setTimeout(() => setCopied(null), 2000);\n  };\n\n  if (!tableId) {\n    return (\n      <div className=\"rounded-lg border border-dashed p-8 text-center text-muted-foreground\">\n        {t('developer:aiContext.selectTableFirst')}\n      </div>\n    );\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"space-y-4\">\n        <Skeleton className=\"h-8 w-48\" />\n        <Skeleton className=\"h-64 w-full\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"rounded-lg border bg-muted/30 p-4\">\n        <h3 className=\"mb-2 font-medium\">{t('developer:aiContext.title')}</h3>\n        <p className=\"text-sm text-muted-foreground\">{t('developer:aiContext.description')}</p>\n      </div>\n\n      <Tabs value={contextFormat} onValueChange={(v) => setContextFormat(v as 'full' | 'compact')}>\n        <div className=\"flex items-center justify-between\">\n          <TabsList>\n            <TabsTrigger value=\"full\">{t('developer:aiContext.fullContext')}</TabsTrigger>\n            <TabsTrigger value=\"compact\">{t('developer:aiContext.compactContext')}</TabsTrigger>\n          </TabsList>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => handleCopy(contextFormat)}\n            className=\"gap-2\"\n          >\n            {copied === contextFormat ? (\n              <>\n                <Check className=\"size-4\" />\n                {t('developer:aiContext.copied')}\n              </>\n            ) : (\n              <>\n                <Copy className=\"size-4\" />\n                {t('developer:aiContext.copyToClipboard')}\n              </>\n            )}\n          </Button>\n        </div>\n\n        <TabsContent value=\"full\" className=\"mt-4\">\n          <CodeBlock\n            code={fullContext}\n            language=\"markdown\"\n            className=\"max-h-[600px] overflow-auto\"\n          />\n        </TabsContent>\n\n        <TabsContent value=\"compact\" className=\"mt-4\">\n          <div className=\"rounded-lg border bg-muted/50 p-4\">\n            <pre className=\"whitespace-pre-wrap text-sm\">{compactContext}</pre>\n          </div>\n          <p className=\"mt-2 text-xs text-muted-foreground\">\n            {t('developer:aiContext.compactDescription')}\n          </p>\n        </TabsContent>\n      </Tabs>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/FilterBuilder.tsx",
    "content": "import type { IFilterSet } from '@teable/core';\nimport { BaseViewFilter, useViewFilterLinkContext } from '@teable/sdk';\nimport { useFields, useViewId, useTableId, useTablePermission } from '@teable/sdk/hooks';\n\nexport const FilterBuilder = ({\n  filter,\n  onChange,\n}: {\n  filter: IFilterSet | null;\n  onChange: (filter: IFilterSet | null) => void;\n}) => {\n  const fields = useFields({ withHidden: true, withDenied: true });\n\n  const viewId = useViewId();\n  const tableId = useTableId();\n  const permission = useTablePermission();\n  const viewFilterLinkContext = useViewFilterLinkContext(tableId, viewId, {\n    disabled: !permission['view|update'],\n  });\n\n  return (\n    <BaseViewFilter\n      value={filter}\n      onChange={onChange}\n      fields={fields}\n      viewFilterLinkContext={viewFilterLinkContext}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/PreviewScript.tsx",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\nimport { Button, cn, Input, ToggleGroup, ToggleGroupItem } from '@teable/ui-lib/shadcn';\nimport { ArrowUpRight } from 'lucide-react';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport React, { useState, useMemo, useEffect } from 'react';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { developerConfig } from '@/features/i18n/developer.config';\nimport { useTransformFieldKey } from './useTransformFieldKey';\n\nexport const CodeBlock = ({\n  code,\n  className,\n  language,\n}: {\n  code: string;\n  className?: string;\n  language?: string;\n}) => (\n  <div className={cn('relative', className)}>\n    <CopyButton text={code} className=\"absolute right-4 top-4\" />\n    <SyntaxHighlighter\n      language={language}\n      style={vscDarkPlus}\n      customStyle={{\n        margin: 0,\n        borderRadius: '0.5rem',\n        padding: '1rem',\n        wordBreak: 'break-all',\n      }}\n      codeTagProps={{ style: { wordBreak: 'break-all' } }}\n      wrapLines\n      wrapLongLines\n    >\n      {code}\n    </SyntaxHighlighter>\n  </div>\n);\n\nconst LanguageSelector = ({\n  selectedLanguage,\n  onLanguageChange,\n}: {\n  selectedLanguage: string;\n  onLanguageChange: (language: string) => void;\n}) => (\n  <ToggleGroup\n    className=\"w-auto\"\n    type=\"single\"\n    variant=\"outline\"\n    size=\"sm\"\n    value={selectedLanguage}\n    onValueChange={(v) => onLanguageChange(v || 'curl')}\n  >\n    <ToggleGroupItem value=\"curl\" aria-label=\"Toggle curl\">\n      cURL\n    </ToggleGroupItem>\n    <ToggleGroupItem value=\"javascript\" aria-label=\"Toggle javascript\">\n      JavaScript\n    </ToggleGroupItem>\n    <ToggleGroupItem value=\"python\" aria-label=\"Toggle python\">\n      Python\n    </ToggleGroupItem>\n  </ToggleGroup>\n);\n\nconst generateCurlCode = (endpoint: string, params: Record<string, unknown>, token: string) => {\n  const queryParams = new URLSearchParams();\n  Object.entries(params)\n    .filter(([_, value]) => value != null)\n    .forEach(([key, value]) => {\n      if (key === 'filter' || key === 'orderBy') {\n        queryParams.append(key, JSON.stringify(value));\n      } else if (Array.isArray(value)) {\n        value.forEach((item) => queryParams.append(key, item.toString()));\n      } else {\n        queryParams.append(key, value as string);\n      }\n    });\n  const queryString = queryParams.toString();\n  const url = `${endpoint}${queryString ? `?${queryString}` : ''}`;\n  return `curl -X GET \\\\\n  \"${url}\" \\\\\n  -H \"Authorization: Bearer ${token || 'YOUR_API_TOKEN'}\" \\\\\n  -H \"Accept: application/json\"`;\n};\n\nconst generateJavaScriptCode = (\n  endpoint: string,\n  params: Record<string, unknown>,\n  token: string\n) => {\n  const paramEntries = Object.entries(params).filter(([_, value]) => value != null);\n\n  const paramStrings = paramEntries.map(([key, value]) => {\n    if (key === 'filter' || key === 'orderBy') {\n      return `  ${key}: JSON.stringify(${JSON.stringify(value)})`;\n    }\n    return `  ${key}: ${JSON.stringify(value)}`;\n  });\n\n  const paramsCode =\n    paramStrings.length > 0\n      ? `const params = {\n${paramStrings.join(',\\n')}\n};`\n      : '';\n\n  const urlParamsCode =\n    paramStrings.length > 0\n      ? `\nObject.entries(params).forEach(([key, value]) => {\n  url.searchParams.append(key, value);\n});`\n      : '';\n\n  return `\nconst url = new URL(\"${endpoint}\");\n${paramsCode}\n${urlParamsCode}\n\nfetch(url, {\n  method: \"GET\",\n  headers: {\n    \"Authorization\": \"Bearer ${token || 'YOUR_API_TOKEN'}\",\n    \"Accept\": \"application/json\"\n  }\n})\n.then(response => response.json())\n.then(data => console.log(data))\n.catch(error => console.error('Error:', error));\n`.slice(1);\n};\n\nconst generatePythonCode = (endpoint: string, params: Record<string, unknown>, token: string) => {\n  const paramEntries = Object.entries(params).filter(([_, value]) => value != null);\n\n  const paramStrings = paramEntries.map(([key, value]) => {\n    if (key === 'filter' || key === 'orderBy') {\n      return `    \"${key}\": json.dumps(${JSON.stringify(value)})`;\n    }\n    return `    \"${key}\": ${JSON.stringify(value)}`;\n  });\n\n  const paramsCode =\n    paramStrings.length > 0\n      ? `params = {\n${paramStrings.join(',\\n')}\n}`\n      : '';\n\n  return `\nimport requests\nimport json\n\nurl = \"${endpoint}\"\n${paramsCode}\n\nheaders = {\n    \"Authorization\": \"Bearer ${token || 'YOUR_API_TOKEN'}\",\n    \"Accept\": \"application/json\"\n}\n\nresponse = requests.get(url${paramsCode ? ', params=params' : ''}, headers=headers)\nprint(response.json())\n`.slice(1);\n};\n\ninterface QueryParamsTableProps {\n  query: IGetRecordsRo;\n}\n\nexport const QueryParamsTable: React.FC<QueryParamsTableProps> = ({ query }) => {\n  const renderValue = (key: string, value: unknown): string => {\n    if (key === 'filter' || key === 'orderBy') {\n      return value ? JSON.stringify(value) : '';\n    }\n    return String(value);\n  };\n\n  return (\n    <table className=\"w-full border-collapse\">\n      <thead>\n        <tr>\n          <th className=\"w-60 border p-2 text-left\">Key</th>\n          <th className=\"border p-2 text-left\">Value</th>\n        </tr>\n      </thead>\n      <tbody>\n        {Object.entries(query)\n          .filter(([_, value]) => value != null)\n          .map(([key, value]) => (\n            <tr key={key}>\n              <td className=\"border p-2\">{key}</td>\n              <td className=\"text-wrap break-all border p-2 font-mono text-sm\">\n                {renderValue(key, value)}\n              </td>\n            </tr>\n          ))}\n      </tbody>\n    </table>\n  );\n};\n\nexport const PreviewScript = ({\n  tableId,\n  query: queryRaw,\n}: {\n  tableId: string;\n  token?: string;\n  query: IGetRecordsRo;\n}) => {\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n  const [currentUrl, setCurrentUrl] = useState('');\n  const query = useTransformFieldKey()(queryRaw);\n\n  useEffect(() => {\n    if (process) {\n      setCurrentUrl(window.location.origin);\n    }\n  }, []);\n\n  const [selectedLanguage, setSelectedLanguage] = useState<'curl' | 'javascript' | 'python'>(\n    'curl'\n  );\n\n  const [token, setToken] = useState<string>('_YOUR_API_TOKEN_');\n\n  const endpoint = `${currentUrl}/api/table/${tableId}/record`;\n\n  const codeExamples = useMemo(\n    () => ({\n      curl: { code: generateCurlCode(endpoint, query, token), language: 'bash' },\n      javascript: { code: generateJavaScriptCode(endpoint, query, token), language: 'javascript' },\n      python: { code: generatePythonCode(endpoint, query, token), language: 'python' },\n    }),\n    [endpoint, query, token]\n  );\n\n  return (\n    <div className=\"w-full space-y-4\">\n      <QueryParamsTable query={query} />\n      <div className=\"flex items-center gap-4\">\n        <LanguageSelector\n          selectedLanguage={selectedLanguage}\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          onLanguageChange={(language) => setSelectedLanguage(language as any)}\n        />\n        {t('developer:replaceToken')}:\n        <Input\n          className=\"w-80\"\n          type=\"text\"\n          value={token}\n          onChange={(v) => setToken(v.target.value)}\n        />\n        <Button variant=\"link\" asChild>\n          <Link href=\"/setting/personal-access-token\" target=\"_blank\">\n            <ArrowUpRight className=\"size-4\" />\n            {t('developer:createNewToken')}\n          </Link>\n        </Button>\n      </div>\n\n      <CodeBlock\n        className=\"overflow-hidden text-wrap break-all rounded-lg border text-sm\"\n        code={codeExamples[selectedLanguage].code}\n        language={codeExamples[selectedLanguage].language}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/PreviewTable.tsx",
    "content": "import { nullsToUndefinedShallow } from '@teable/core';\nimport { ChevronLeft, ChevronRight } from '@teable/icons';\nimport { getRecords, type IGetRecordsRo, type IQueryBaseRo } from '@teable/openapi';\nimport type { ICell, ICellItem } from '@teable/sdk/components';\nimport {\n  CellType,\n  DraggableType,\n  Grid,\n  SelectableType,\n  useGridAsyncRecords,\n  useGridColumns,\n  useGridIcons,\n  useGridTheme,\n} from '@teable/sdk/components';\nimport { useIsHydrated, useTableId } from '@teable/sdk/hooks';\nimport { Table } from '@teable/sdk/model/table';\nimport { ToggleGroup, ToggleGroupItem, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { developerConfig } from '@/features/i18n/developer.config';\nimport { CodeBlock } from './PreviewScript';\nimport { useTransformFieldKey } from './useTransformFieldKey';\n\nexport const PreviewTable = ({ query: queryRaw }: { query: IGetRecordsRo }) => {\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n  const theme = useGridTheme();\n  const { columns, cellValue2GridDisplay } = useGridColumns(false);\n  const transformFieldKey = useTransformFieldKey();\n  const query = useMemo(() => transformFieldKey(queryRaw), [transformFieldKey, queryRaw]);\n\n  const [rowCount, setRowCount] = useState<number>(0);\n  const [recordRes, setRecordRes] = useState<unknown>(null);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const tableId = useTableId();\n  const [mode, setMode] = useState<string>('json');\n  const [page, setPage] = useState<number>(1);\n  const [pageSize, setPageSize] = useState<number>(10);\n\n  useEffect(() => {\n    if (tableId == null) return;\n\n    // Extract only IQueryBaseRo fields from query\n    const { projection, cellFormat, fieldKeyType, take, skip, ...queryBaseParams } = query;\n    Table.getRowCount(tableId, nullsToUndefinedShallow(queryBaseParams) as IQueryBaseRo).then(\n      (res) => {\n        setRowCount(res.data.rowCount);\n      }\n    );\n  }, [tableId, query]);\n\n  const isHydrated = useIsHydrated();\n\n  const customIcons = useGridIcons();\n\n  const { recordMap, onVisibleRegionChanged } = useGridAsyncRecords(undefined, query);\n\n  // Fetch JSON data when mode is json or when tableId/query/page/pageSize changes\n  useEffect(() => {\n    if (mode !== 'json' || !tableId) {\n      return;\n    }\n\n    setIsLoading(true);\n    const queryParams: IGetRecordsRo = {\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    };\n\n    // Only add non-null/undefined parameters\n    if (query.filter) queryParams.filter = query.filter;\n    if (query.orderBy) queryParams.orderBy = query.orderBy;\n    if (query.viewId) queryParams.viewId = query.viewId;\n    if (query.search) queryParams.search = query.search;\n    if (query.cellFormat) queryParams.cellFormat = query.cellFormat;\n    if (query.fieldKeyType) queryParams.fieldKeyType = query.fieldKeyType;\n\n    getRecords(tableId, queryParams)\n      .then((res) => {\n        setRecordRes(res.data);\n      })\n      .catch((err) => {\n        console.error('Failed to fetch records:', err);\n        setRecordRes({ error: 'Failed to fetch records' });\n      })\n      .finally(() => {\n        setIsLoading(false);\n      });\n  }, [mode, query, tableId, page, pageSize]);\n\n  const getCellContent = useCallback<(cell: ICellItem) => ICell>(\n    (cell) => {\n      const [colIndex, rowIndex] = cell;\n      const record = recordMap[rowIndex];\n      if (record !== undefined) {\n        const fieldId = columns[colIndex]?.id;\n        if (!fieldId) return { type: CellType.Loading };\n        return cellValue2GridDisplay(record, colIndex);\n      }\n      return { type: CellType.Loading };\n    },\n    [recordMap, columns, cellValue2GridDisplay]\n  );\n\n  const totalPages = Math.ceil((rowCount || 0) / pageSize);\n\n  const handlePageChange = (newPage: number) => {\n    setPage(Math.max(1, Math.min(newPage, totalPages)));\n  };\n\n  return (\n    <>\n      <div className=\"flex\">\n        <ToggleGroup\n          className=\"w-auto\"\n          type=\"single\"\n          variant=\"outline\"\n          size=\"sm\"\n          value={mode}\n          onValueChange={(v) => setMode(v || 'grid')}\n        >\n          <ToggleGroupItem value=\"grid\" aria-label=\"Toggle view\">\n            Grid\n          </ToggleGroupItem>\n          <ToggleGroupItem value=\"json\" aria-label=\"Toggle json\">\n            JSON\n          </ToggleGroupItem>\n        </ToggleGroup>\n      </div>\n      {mode === 'grid' && isHydrated && (\n        <>\n          <p>{t('developer:showPagination')}</p>\n          <div className=\"relative h-[500px] w-full overflow-hidden rounded-lg border\">\n            <Grid\n              style={{\n                width: '100%',\n                height: '100%',\n              }}\n              scrollBufferX={0}\n              scrollBufferY={0}\n              theme={theme}\n              columns={columns}\n              freezeColumnCount={0}\n              rowCount={rowCount ?? 0}\n              rowIndexVisible={false}\n              customIcons={customIcons}\n              draggable={DraggableType.None}\n              selectable={SelectableType.None}\n              isMultiSelectionEnable={false}\n              onVisibleRegionChanged={onVisibleRegionChanged}\n              getCellContent={getCellContent}\n            />\n          </div>\n        </>\n      )}\n      {mode === 'json' && (\n        <div>\n          <div className=\"flex items-center gap-4 pb-4\">\n            <div className=\"flex items-center gap-2\">\n              <Button\n                size=\"xs\"\n                variant=\"outline\"\n                onClick={() => handlePageChange(page - 1)}\n                disabled={page === 1 || isLoading}\n              >\n                <ChevronLeft className=\"size-4\" />\n              </Button>\n              <span>\n                {page} / {totalPages || 1}\n              </span>\n              <Button\n                size=\"xs\"\n                variant=\"outline\"\n                onClick={() => handlePageChange(page + 1)}\n                disabled={page >= totalPages || isLoading}\n              >\n                <ChevronRight className=\"size-4\" />\n              </Button>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <span>skip: {(page - 1) * pageSize}</span>\n              <span>take:</span>\n              <select\n                value={pageSize}\n                onChange={(e) => setPageSize(Number(e.target.value))}\n                className=\"rounded border p-1 text-sm\"\n                disabled={isLoading}\n              >\n                {[10, 20, 50, 100].map((size) => (\n                  <option key={size} value={size}>\n                    {size}\n                  </option>\n                ))}\n              </select>\n            </div>\n            {isLoading && <span className=\"text-sm text-muted-foreground\">Loading...</span>}\n          </div>\n          <CodeBlock\n            code={recordRes ? JSON.stringify(recordRes, null, 2) : '// Loading...'}\n            language=\"json\"\n          />\n        </div>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/QueryBuilder.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { CellFormat, FieldKeyType, type IFilterSet, type ISortItem } from '@teable/core';\nimport { ArrowUpRight, Code2, MagicAi } from '@teable/icons';\nimport type { IQueryBaseRo } from '@teable/openapi';\nimport { getBaseAll, getTableList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { StandaloneViewProvider } from '@teable/sdk/context';\nimport {\n  Button,\n  ToggleGroup,\n  ToggleGroupItem,\n  Tabs,\n  TabsList,\n  TabsTrigger,\n  TabsContent,\n} from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useSearchParams } from 'next/navigation';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { developerConfig } from '@/features/i18n/developer.config';\nimport { SettingRight } from '../SettingRight';\nimport { SettingRightTitle } from '../SettingRightTitle';\nimport { AIContextPanel } from './AIContextPanel';\nimport { FilterBuilder } from './FilterBuilder';\nimport { PreviewScript } from './PreviewScript';\nimport { PreviewTable } from './PreviewTable';\nimport { SearchBuilder } from './SearchBuilder';\nimport { OrderByBuilder } from './SortBuilder';\nimport { ViewBuilder } from './ViewBuilder';\n\nexport const QueryBuilder = () => {\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n  const searchParams = useSearchParams();\n  const [baseId, setBaseId] = useState<string>(searchParams.get('baseId') ?? '');\n  const [tableId, setTableId] = useState<string>(searchParams.get('tableId') ?? '');\n  const [viewId, setViewId] = useState<string>();\n  const [filter, setFilter] = useState<IFilterSet | null>(null);\n  const [fieldKeyType, setFieldKeyType] = useState<FieldKeyType>();\n  const [cellFormat, setCellFormat] = useState<CellFormat>();\n  const [orderBy, setOrderBy] = useState<ISortItem[]>();\n  const [search, setSearch] = useState<IQueryBaseRo['search']>();\n  const { data: baseListReq } = useQuery({\n    queryKey: ReactQueryKeys.baseAll(),\n    queryFn: () => getBaseAll().then((data) => data.data),\n  });\n\n  const { data: tableListReq } = useQuery({\n    queryKey: ReactQueryKeys.tableList(baseId as string),\n    queryFn: () => getTableList(baseId as string).then((data) => data.data),\n    enabled: Boolean(baseId),\n  });\n\n  const [activeTab, setActiveTab] = useState<string>('api-builder');\n\n  const query = useMemo(\n    () => ({\n      fieldKeyType,\n      viewId,\n      filter,\n      orderBy,\n      search,\n      cellFormat,\n    }),\n    [fieldKeyType, viewId, filter, orderBy, search, cellFormat]\n  );\n\n  return (\n    <SettingRight\n      header={\n        <SettingRightTitle\n          title={t('developer:apiQueryBuilder')}\n          className=\"h-auto items-center gap-x-2\"\n          titleClassName=\"text-lg font-medium\"\n        />\n      }\n    >\n      <StandaloneViewProvider baseId={baseId} tableId={tableId} viewId={viewId}>\n        <div className=\"flex w-full flex-col gap-4 pb-8\">\n          {/* Data Source Selection */}\n          <div className=\"text-sm\">\n            {t('developer:subTitle')}{' '}\n            <Button variant=\"link\" size=\"xs\" asChild>\n              <Link href={t('common:help.apiLink')} target=\"_blank\">\n                <ArrowUpRight className=\"size-4\" />\n                {t('developer:apiList')}\n              </Link>\n            </Button>\n          </div>\n          <p>{t('developer:chooseSource')}</p>\n          <div className=\"flex flex-wrap gap-4\">\n            <div className=\"flex flex-col gap-2\">\n              <h1 className=\"text-sm font-medium\">1. {t('common:noun.base')}</h1>\n              <Selector\n                className=\"w-80\"\n                placeholder={t('developer:action.selectBase')}\n                candidates={baseListReq}\n                selectedId={baseId}\n                onChange={(id) => {\n                  setBaseId(id);\n                  setTableId('');\n                }}\n              />\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <h1 className=\"text-sm font-medium\">2. {t('common:noun.table')}</h1>\n              <Selector\n                className=\"w-80\"\n                placeholder={t('developer:action.selectTable')}\n                candidates={tableListReq}\n                selectedId={tableId}\n                onChange={(id) => setTableId(id)}\n              />\n            </div>\n          </div>\n\n          <hr className=\"my-4\" />\n\n          {/* Feature Tabs */}\n          <Tabs value={activeTab} onValueChange={setActiveTab} className=\"w-full\">\n            <TabsList className=\"mb-4\">\n              <TabsTrigger value=\"api-builder\" className=\"gap-2\">\n                <Code2 className=\"size-4\" />\n                {t('developer:tabs.apiBuilder')}\n              </TabsTrigger>\n              <TabsTrigger value=\"ai-context\" className=\"gap-2\">\n                <MagicAi className=\"size-4\" />\n                {t('developer:tabs.aiContext')}\n              </TabsTrigger>\n            </TabsList>\n\n            {/* API Builder Tab */}\n            <TabsContent value=\"api-builder\" className=\"space-y-6\">\n              {tableId ? (\n                <>\n                  <p className=\"text-sm text-muted-foreground\">{t('developer:pickParams')}</p>\n                  <div className=\"grid gap-6 md:grid-cols-2\">\n                    <div className=\"flex flex-col gap-2\">\n                      <h2 className=\"text-sm font-medium\">{t('common:noun.view')}</h2>\n                      <ViewBuilder viewId={viewId} onChange={setViewId} />\n                    </div>\n                    <div className=\"flex flex-col gap-2\">\n                      <h2 className=\"text-sm font-medium\">{t('common:actions.search')}</h2>\n                      <SearchBuilder search={search} onChange={setSearch} />\n                    </div>\n                  </div>\n\n                  <div className=\"flex flex-col gap-2\">\n                    <h2 className=\"text-sm font-medium\">{t('sdk:filter.label')}</h2>\n                    <FilterBuilder\n                      filter={filter}\n                      onChange={(f) => {\n                        setFilter(f);\n                      }}\n                    />\n                  </div>\n\n                  <div className=\"flex flex-col gap-2\">\n                    <h2 className=\"text-sm font-medium\">{t('sdk:sort.label')}</h2>\n                    <OrderByBuilder\n                      orderBy={orderBy}\n                      onChange={(o) => {\n                        setOrderBy(o);\n                      }}\n                    />\n                  </div>\n\n                  <div className=\"grid gap-6 md:grid-cols-2\">\n                    <div className=\"flex flex-col gap-2\">\n                      <h2 className=\"text-sm font-medium\">{t('developer:cellFormat')}</h2>\n                      <ToggleGroup\n                        className=\"w-auto justify-start\"\n                        variant=\"outline\"\n                        type=\"single\"\n                        size=\"sm\"\n                        value={cellFormat || CellFormat.Json}\n                        onValueChange={(v) => setCellFormat((v as CellFormat) || CellFormat.Json)}\n                      >\n                        <ToggleGroupItem value=\"json\" aria-label=\"Toggle json\">\n                          JSON\n                        </ToggleGroupItem>\n                        <ToggleGroupItem value=\"text\" aria-label=\"Toggle text\">\n                          Text\n                        </ToggleGroupItem>\n                      </ToggleGroup>\n                    </div>\n                    <div className=\"flex flex-col gap-2\">\n                      <h2 className=\"text-sm font-medium\">{t('developer:fieldKeyType')}</h2>\n                      <ToggleGroup\n                        className=\"w-auto justify-start\"\n                        variant=\"outline\"\n                        type=\"single\"\n                        size=\"sm\"\n                        value={fieldKeyType || FieldKeyType.Name}\n                        onValueChange={(v) => {\n                          setFieldKeyType(v as FieldKeyType);\n                        }}\n                      >\n                        <ToggleGroupItem value=\"name\" aria-label=\"Toggle name\">\n                          name\n                        </ToggleGroupItem>\n                        <ToggleGroupItem value=\"id\" aria-label=\"Toggle id\">\n                          id\n                        </ToggleGroupItem>\n                        <ToggleGroupItem value=\"dbFieldName\" aria-label=\"Toggle dbFieldName\">\n                          dbFieldName\n                        </ToggleGroupItem>\n                      </ToggleGroup>\n                    </div>\n                  </div>\n\n                  <hr className=\"my-4\" />\n\n                  <div className=\"flex flex-col gap-4\">\n                    <h2 className=\"text-sm font-medium\">{t('developer:buildResult')}</h2>\n                    <PreviewScript tableId={tableId} query={query} />\n                  </div>\n\n                  <hr className=\"my-4\" />\n\n                  <div className=\"flex w-full flex-col gap-4\">\n                    <h2 className=\"text-sm font-medium\">{t('developer:previewReturnValue')}</h2>\n                    <PreviewTable query={query} />\n                  </div>\n                </>\n              ) : (\n                <div className=\"rounded-lg border border-dashed p-8 text-center text-muted-foreground\">\n                  {t('developer:buildResultEmpty')}\n                </div>\n              )}\n            </TabsContent>\n\n            {/* AI Context Tab */}\n            <TabsContent value=\"ai-context\">\n              <AIContextPanel tableId={tableId} baseId={baseId} />\n            </TabsContent>\n          </Tabs>\n        </div>\n      </StandaloneViewProvider>\n    </SettingRight>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/SearchBuilder.tsx",
    "content": "import type { IQueryBaseRo } from '@teable/openapi';\nimport { SearchInput } from '@teable/sdk/components';\nimport { useSearch } from '@teable/sdk/hooks';\nimport { isEqual } from 'lodash';\nimport { useEffect } from 'react';\n\nexport const SearchBuilder = ({\n  search,\n  onChange,\n}: {\n  search?: IQueryBaseRo['search'];\n  onChange: (search?: IQueryBaseRo['search']) => void;\n}) => {\n  const { searchQuery } = useSearch();\n\n  useEffect(() => {\n    if (!isEqual(searchQuery, search)) {\n      onChange(searchQuery);\n    }\n  }, [onChange, search, searchQuery]);\n\n  return <SearchInput className=\"w-80\" />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/SortBuilder.tsx",
    "content": "import type { ISort } from '@teable/core';\nimport { SortFunc } from '@teable/core';\nimport { DraggableSortList } from '@teable/sdk/components/sort/DraggableSortList';\nimport { SortFieldAddButton } from '@teable/sdk/components/sort/SortFieldAddButton';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { developerConfig } from '@/features/i18n/developer.config';\n\ninterface ISortProps {\n  orderBy?: NonNullable<ISort>['sortObjs'];\n  onChange: (orderBy?: NonNullable<ISort>['sortObjs']) => void;\n}\n\nexport function OrderByBuilder(props: ISortProps) {\n  const { onChange, orderBy = [] } = props;\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n\n  const selectedFieldIds = useMemo(() => orderBy.map((sort) => sort.fieldId) || [], [orderBy]);\n\n  const onFieldAdd = (value: string) => {\n    onChange(\n      orderBy.concat({\n        fieldId: value,\n        order: SortFunc.Asc,\n      })\n    );\n  };\n\n  const onSortChange = (sorts: NonNullable<ISort>['sortObjs']) => {\n    onChange(sorts?.length ? sorts : undefined);\n  };\n\n  return (\n    <div className=\"flex w-96 flex-col\">\n      <div className=\"max-h-96 overflow-auto p-3\">\n        <DraggableSortList\n          sorts={orderBy}\n          selectedFields={selectedFieldIds}\n          onChange={onSortChange}\n        />\n      </div>\n      <SortFieldAddButton\n        addBtnText={t('developer:addSort')}\n        selectedFieldIds={selectedFieldIds}\n        onSelect={onFieldAdd}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/ViewBuilder.tsx",
    "content": "import { useViews } from '@teable/sdk/hooks';\nimport { useTranslation } from 'next-i18next';\nimport { Selector } from '@/components/Selector';\nimport { developerConfig } from '@/features/i18n/developer.config';\n\nexport const ViewBuilder = ({\n  viewId,\n  onChange,\n}: {\n  viewId?: string;\n  onChange: (viewId: string | undefined) => void;\n}) => {\n  const views = useViews();\n  const { t } = useTranslation(developerConfig.i18nNamespaces);\n\n  return (\n    <Selector\n      className=\"w-80\"\n      selectedId={viewId}\n      onChange={(id) => onChange(id || undefined)}\n      candidates={views}\n      placeholder={t('sdk:common.selectPlaceHolder')}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/setting/query-builder/useTransformFieldKey.ts",
    "content": "import {\n  FieldKeyType,\n  replaceFilter,\n  replaceGroupBy,\n  replaceOrderBy,\n  replaceSearch,\n} from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { useFields } from '@teable/sdk/hooks';\nimport { keyBy } from 'lodash';\nimport { useCallback } from 'react';\n\nexport function useTransformFieldKey() {\n  const fields = useFields();\n\n  return useCallback(\n    (query: IGetRecordsRo) => {\n      const fieldKeyType = query?.fieldKeyType ?? FieldKeyType.Name;\n      const fieldMap = keyBy(fields, 'id');\n\n      if (fieldKeyType === FieldKeyType.Id) {\n        return query;\n      }\n\n      const transformedValue = { ...query };\n\n      if (query.filter) {\n        transformedValue.filter = replaceFilter(query.filter, fieldMap, fieldKeyType);\n      }\n\n      if (query.search) {\n        transformedValue.search = replaceSearch(query.search, fieldMap, fieldKeyType);\n      }\n\n      if (query.groupBy) {\n        transformedValue.groupBy = replaceGroupBy(query.groupBy, fieldMap, fieldKeyType);\n      }\n\n      if (query.orderBy) {\n        transformedValue.orderBy = replaceOrderBy(query.orderBy, fieldMap, fieldKeyType);\n      }\n\n      return transformedValue;\n    },\n    [fields]\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/base/BaseShareAuthPage.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { HttpError, sharePasswordSchema } from '@teable/core';\nimport { baseShareAuth } from '@teable/openapi';\nimport { Button, Input, Label } from '@teable/ui-lib';\nimport { Spin } from '@teable/ui-lib/base';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { shareConfig } from '@/features/i18n/share.config';\n\nexport const BaseShareAuthPage = () => {\n  const [error, setError] = useState('');\n  const router = useRouter();\n  const shareId = router.query.shareId as string;\n  const { mutateAsync: authBaseShare, isPending: isLoading } = useMutation({\n    mutationFn: ({ shareId, password }: { shareId: string; password: string }) =>\n      baseShareAuth(shareId, password),\n  });\n  const { t } = useTranslation(shareConfig.i18nNamespaces);\n\n  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    const password = (event.currentTarget.elements.namedItem('password') as HTMLInputElement).value;\n    const validatePassword = sharePasswordSchema.safeParse(password);\n    if (!validatePassword.success) {\n      setError(t('share:auth.passwordTooShort'));\n      return;\n    }\n    try {\n      await authBaseShare({ shareId, password });\n      router.push({\n        pathname: '/share/[shareId]/base',\n        query: { shareId },\n      });\n    } catch (error) {\n      if (error instanceof HttpError) {\n        const localization = (error.data as { localization?: { i18nKey?: string } })?.localization;\n        if (localization?.i18nKey) {\n          setError(t(`sdk:${localization.i18nKey}` as never));\n        } else {\n          setError(error.message);\n        }\n      } else {\n        setError(error as string);\n      }\n    }\n  };\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8\">\n      <div className=\"w-full max-w-md space-y-8\">\n        <h2 className=\"text-center text-3xl font-extrabold\">{t('share:auth.title')}</h2>\n        <form className=\"relative space-y-6\" onSubmit={onSubmit}>\n          <div className=\"-space-y-px rounded-md shadow-sm\">\n            <div>\n              <Label className=\"sr-only\" htmlFor=\"password\">\n                {t('share:auth.password')}\n              </Label>\n              <Input\n                id=\"password\"\n                name=\"password\"\n                placeholder=\"Password\"\n                required\n                type=\"password\"\n                readOnly={isLoading}\n                autoComplete={`${shareId}-password}`}\n              />\n            </div>\n          </div>\n          <Button className=\"w-full\" type=\"submit\" disabled={isLoading}>\n            {isLoading && <Spin />}\n            {t('share:auth.submit')}\n          </Button>\n          {error && (\n            <div className=\"absolute -bottom-1 w-full translate-y-full text-center text-sm text-destructive\">\n              {error}\n            </div>\n          )}\n        </form>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/base/share-base-ssr.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\nimport type { IHttpError } from '@teable/core';\nimport { ANONYMOUS_USER } from '@teable/core';\nimport type { IGetBaseShareVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { IUser } from '@teable/sdk/context';\nimport type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\nimport type { ISSRContext } from '@/features/app/base-node';\nimport type { IBaseNodePageProps } from '@/features/app/base-node/types';\nimport { parseBaseSlug } from '@/features/app/hooks/useBaseResource';\nimport { baseAllConfig } from '@/features/i18n/base-all.config';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { I18nNamespace } from '@/lib/i18n';\n\nexport interface IShareBasePagePropsBase extends IBaseNodePageProps {\n  shareId: string;\n  user?: IUser;\n  shareNodeId?: string;\n  allowSave?: boolean;\n  allowCopy?: boolean;\n}\n\nexport const getCurrentUser = async (ssrApi: SsrApi) => {\n  try {\n    return await ssrApi.getUserMe();\n  } catch {\n    return ANONYMOUS_USER;\n  }\n};\n\nexport const handleShareError = <T extends IShareBasePagePropsBase>(\n  e: unknown,\n  shareId: string\n): GetServerSidePropsResult<T> => {\n  const error = e as IHttpError;\n  if (error.status === 401) {\n    return { redirect: { destination: `/share/${shareId}/base/auth`, permanent: false } };\n  }\n  return { notFound: true };\n};\n\nexport const buildShareProps = async <T extends IShareBasePagePropsBase>(\n  pageProps: { props: IBaseNodePageProps | Promise<IBaseNodePageProps> },\n  shareId: string,\n  user: IUser,\n  shareData: IGetBaseShareVo,\n  extraProps?: Partial<T>\n): Promise<GetServerSidePropsResult<T>> => {\n  const props = await pageProps.props;\n  return {\n    props: {\n      ...props,\n      shareId,\n      user,\n      shareNodeId: shareData.shareMeta?.nodeId,\n      allowSave: !!shareData?.shareMeta?.allowSave,\n      allowCopy: !!shareData?.shareMeta?.allowCopy,\n      ...extraProps,\n    } as T,\n  };\n};\n\nexport interface IShareBaseSSROptions<T extends IShareBasePagePropsBase> {\n  ssrApi: SsrApi;\n  context: GetServerSidePropsContext;\n  getResourcePageProps: (\n    ctx: ISSRContext,\n    parsed: ReturnType<typeof parseBaseSlug>,\n    queryParams: Record<string, string | string[] | undefined>\n  ) => Promise<GetServerSidePropsResult<IBaseNodePageProps> | null>;\n  getExtraProps?: (pageProps: IBaseNodePageProps) => Partial<T>;\n  i18nNamespaces?: I18nNamespace[];\n}\n\nexport const createShareBaseSSR = async <T extends IShareBasePagePropsBase>(\n  options: IShareBaseSSROptions<T>\n): Promise<GetServerSidePropsResult<T>> => {\n  const { ssrApi, context, getResourcePageProps, getExtraProps, i18nNamespaces } = options;\n  const { baseId, shareId, slug, ...queryParams } = context.query;\n\n  context.res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n  ssrApi.axios.defaults.headers['cookie'] = context.req.headers.cookie || '';\n\n  try {\n    const shareData = await ssrApi.getBaseShare(shareId as string);\n    if (shareData.baseId !== baseId || !shareData?.defaultUrl) {\n      return { notFound: true };\n    }\n\n    ssrApi.configureShareHeaders(shareId as string);\n\n    const queryClient = new QueryClient();\n    const base = await ssrApi.getBaseById(baseId as string);\n    const parsed = parseBaseSlug(slug as string[]);\n    const baseIdStr = baseId as string;\n\n    await Promise.all([\n      queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.base(baseIdStr),\n        queryFn: () => base,\n      }),\n      queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.getBasePermission(baseIdStr),\n        queryFn: () => ssrApi.getBasePermission(baseIdStr),\n      }),\n    ]);\n\n    const ctx: ISSRContext = {\n      context,\n      queryClient,\n      baseId: baseIdStr,\n      ssrApi,\n      getTranslationsProps: () =>\n        getTranslationsProps(context, i18nNamespaces ?? baseAllConfig.i18nNamespaces),\n      base,\n    };\n\n    const pageProps = await getResourcePageProps(ctx, parsed, queryParams);\n    if (!pageProps) {\n      return { notFound: true };\n    }\n\n    if ('redirect' in pageProps) {\n      const destination = pageProps.redirect.destination.replace(\n        `/base/${baseId}`,\n        `/share/${shareId}/base/${baseId}`\n      );\n      return { redirect: { ...pageProps.redirect, destination } };\n    }\n\n    if ('props' in pageProps) {\n      const user = (await getCurrentUser(ssrApi)) as IUser;\n      const resolvedProps = await pageProps.props;\n      const extraProps = getExtraProps?.(resolvedProps);\n      return await buildShareProps<T>(pageProps, shareId as string, user, shareData, extraProps);\n    }\n\n    return pageProps as GetServerSidePropsResult<T>;\n  } catch (e) {\n    return handleShareError<T>(e, shareId as string);\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/AuthPage.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { HttpError, sharePasswordSchema } from '@teable/core';\nimport { shareViewAuth } from '@teable/openapi';\nimport { Button, Input, Label } from '@teable/ui-lib';\nimport { Spin } from '@teable/ui-lib/base';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { shareConfig } from '@/features/i18n/share.config';\n\nexport const AuthPage = () => {\n  const [error, setError] = useState('');\n  const router = useRouter();\n  const shareId = router.query.shareId as string;\n  const { mutateAsync: authShareView, isPending: isLoading } = useMutation({\n    mutationFn: shareViewAuth,\n  });\n  const { t } = useTranslation(shareConfig.i18nNamespaces);\n\n  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    const password = (event.currentTarget.elements.namedItem('password') as HTMLInputElement).value;\n    const validatePassword = sharePasswordSchema.safeParse(password);\n    if (!validatePassword.success) {\n      setError(t('share:auth.passwordTooShort'));\n      return;\n    }\n    try {\n      await authShareView({ shareId, password });\n      router.push({\n        pathname: '/share/[shareId]/view',\n        query: { shareId },\n      });\n    } catch (error) {\n      if (error instanceof HttpError) {\n        const localization = (error.data as { localization?: { i18nKey?: string } })?.localization;\n        if (localization?.i18nKey) {\n          setError(t(`sdk:${localization.i18nKey}` as never));\n        } else {\n          setError(error.message);\n        }\n      } else {\n        setError(error as string);\n      }\n    }\n  };\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8\">\n      <div className=\"w-full max-w-md space-y-8\">\n        <h2 className=\"text-center text-3xl font-extrabold\">{t('share:auth.title')}</h2>\n        <form className=\"relative space-y-6\" onSubmit={onSubmit}>\n          <div className=\"-space-y-px rounded-md shadow-sm\">\n            <div>\n              <Label className=\"sr-only\" htmlFor=\"password\">\n                {t('share:auth.password')}\n              </Label>\n              <Input\n                id=\"password\"\n                name=\"password\"\n                placeholder=\"Password\"\n                required\n                type=\"password\"\n                readOnly={isLoading}\n                autoComplete={`${shareId}-password}`}\n              />\n            </div>\n          </div>\n          <Button className=\"w-full\" type=\"submit\" disabled={isLoading}>\n            {isLoading && <Spin />}\n            {t('share:auth.submit')}\n          </Button>\n          {error && (\n            <div className=\"absolute -bottom-1 w-full translate-y-full text-center text-sm text-destructive\">\n              {error}\n            </div>\n          )}\n        </form>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/EmbedFooter.tsx",
    "content": "import { ArrowUpRight } from '@teable/icons';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { shareConfig } from '@/features/i18n/share.config';\n\nexport const EmbedFooter = ({\n  hideBranding,\n  hideNewPage,\n}: {\n  hideBranding?: boolean;\n  hideNewPage?: boolean;\n}) => {\n  const router = useRouter();\n  const { t } = useTranslation(shareConfig.i18nNamespaces);\n  const fullPath = router.asPath;\n  const url = new URL(fullPath, 'https://app.teable.ai'); // Use a dummy base URL\n  url.searchParams.delete('embed');\n  const pathWithoutEmbed = `${url.pathname}${url.search}`;\n  const { brandName } = useBrand();\n\n  return (\n    <div className=\"flex items-center justify-between border-t px-2 py-1 text-xs\">\n      {!hideBranding && (\n        <Link href=\"/\" className=\"flex items-center gap-1\" target=\"_blank\">\n          <TeableLogo className=\"size-4\" />\n          {brandName}\n        </Link>\n      )}\n      {!hideNewPage && (\n        <Link className=\"flex gap-1\" href={pathWithoutEmbed} target=\"_blank\">\n          <ArrowUpRight className=\"size-4\" />\n          {t('share:openOnNewPage')}\n        </Link>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/ShareTablePermissionProvider.tsx",
    "content": "import type { ITablePermissionVo } from '@teable/openapi';\nimport {\n  TablePermissionContext,\n  TablePermissionContextDefaultValue,\n} from '@teable/sdk/context/table-permission';\nimport { useFields } from '@teable/sdk/hooks';\nimport { map } from 'lodash';\nimport { useMemo } from 'react';\n\nexport const ShareTablePermissionProvider = ({ children }: { children: React.ReactNode }) => {\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const fieldIds = map(fields, 'id');\n\n  const value = useMemo(() => {\n    return {\n      ...TablePermissionContextDefaultValue,\n      field: {\n        'field|read': true,\n      },\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [JSON.stringify(fieldIds)]) as ITablePermissionVo;\n\n  return (\n    <TablePermissionContext.Provider value={value}>{children}</TablePermissionContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx",
    "content": "import { ViewType } from '@teable/core';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport { useContext } from 'react';\nimport { DownloadAllAttachmentsDialog } from '@/features/app/components/download-attachments';\nimport { CalendarView } from './component/calendar/CalendarView';\nimport { FormView } from './component/form/FormView';\nimport { GalleryView } from './component/gallery/GalleryView';\nimport { GridView } from './component/grid/GridView';\nimport { KanbanView } from './component/kanban/KanbanView';\nimport { PluginView } from './component/plugin/SharePluginView';\n\nexport const ShareView = () => {\n  const { view, shareId, extra } = useContext(ShareViewContext);\n  const viewType = view?.type;\n  const getViewComponent = () => {\n    // eslint-disable-next-line sonarjs/no-small-switch\n    switch (viewType) {\n      case ViewType.Form:\n        return <FormView />;\n      case ViewType.Grid:\n        return <GridView />;\n      case ViewType.Kanban:\n        return <KanbanView />;\n      case ViewType.Gallery:\n        return <GalleryView />;\n      case ViewType.Calendar:\n        return <CalendarView />;\n      case ViewType.Plugin:\n        return <PluginView shareId={shareId} plugin={extra?.plugin} />;\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"h-screen w-full\">\n      {getViewComponent()}\n      <DownloadAllAttachmentsDialog />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/ShareViewPage.tsx",
    "content": "import { ANONYMOUS_USER_ID, type DriverClient } from '@teable/core';\nimport type { ShareViewGetVo } from '@teable/openapi';\nimport {\n  AnchorContext,\n  AppProvider,\n  FieldProvider,\n  SessionProvider,\n  ShareViewProxy,\n  ViewProvider,\n  ShareViewContext,\n} from '@teable/sdk/context';\nimport { getWsPath } from '@teable/sdk/context/app/useConnection';\nimport { addQueryParamsToWebSocketUrl } from '@teable/sdk/utils';\nimport Head from 'next/head';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useAutoFavicon } from '@/features/app/hooks/useAutoFavicon';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { useSdkLocale } from '@/features/app/hooks/useSdkLocale';\nimport { AppLayout } from '@/features/app/layouts';\nimport { ShareTablePermissionProvider } from './ShareTablePermissionProvider';\nimport { ShareView } from './ShareView';\n\nexport interface IShareViewPageProps {\n  shareViewData: ShareViewGetVo;\n  driver: DriverClient;\n}\n\nexport const ShareViewPage = (props: IShareViewPageProps) => {\n  const { tableId, viewId, view, fields, shareId } = props.shareViewData;\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { maxSearchFieldCount } = useEnv();\n\n  const { query } = useRouter();\n  const { brandName } = useBrand();\n  useAutoFavicon();\n\n  const wsPath = useMemo(() => {\n    if (typeof window === 'object') {\n      return addQueryParamsToWebSocketUrl(getWsPath(), { shareId });\n    }\n    return undefined;\n  }, [shareId]);\n\n  return (\n    <AppProvider\n      lang={i18n.language}\n      wsPath={wsPath}\n      locale={sdkLocale}\n      forcedTheme={query.theme as string}\n      maxSearchFieldCount={maxSearchFieldCount}\n    >\n      <ShareViewContext.Provider value={props.shareViewData}>\n        <Head>\n          <title>{view?.name ? `${view.name} - ${brandName}` : brandName}</title>\n        </Head>\n        <AppLayout>\n          <SessionProvider\n            user={{\n              id: ANONYMOUS_USER_ID,\n              name: ANONYMOUS_USER_ID,\n              email: '',\n              notifyMeta: {},\n              hasPassword: false,\n              isAdmin: false,\n            }}\n            disabledApi\n          >\n            <AnchorContext.Provider\n              value={{\n                tableId,\n                viewId,\n              }}\n            >\n              {view && (\n                <ViewProvider serverData={[view]}>\n                  <ShareViewProxy serverData={[view]}>\n                    <FieldProvider serverSideData={fields}>\n                      <ShareTablePermissionProvider>\n                        <ShareView />\n                      </ShareTablePermissionProvider>\n                    </FieldProvider>\n                  </ShareViewProxy>\n                </ViewProvider>\n              )}\n            </AnchorContext.Provider>\n          </SessionProvider>\n        </AppLayout>\n      </ShareViewContext.Provider>\n    </AppProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/CalendarView.tsx",
    "content": "/* eslint-disable @next/next/no-html-link-for-pages */\nimport { RecordProvider, ShareViewContext } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useContext } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { CalendarViewBase } from '@/features/app/blocks/view/calendar/CalendarViewBase';\nimport { CalendarProvider } from '@/features/app/blocks/view/calendar/context';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { CalendarToolbar } from './toolbar';\n\nexport const CalendarView = () => {\n  const { view } = useContext(ShareViewContext);\n  const isHydrated = useIsHydrated();\n  const { brandName } = useBrand();\n  const {\n    query: { hideToolBar, embed },\n  } = useRouter();\n\n  return (\n    <div className={cn('flex size-full flex-col', embed ? '' : 'md:px-3 md:pb-3')}>\n      {!embed && (\n        <div className=\"flex w-full justify-between px-1 py-2 md:px-0 md:py-3\">\n          <h1 className=\"font-semibold md:text-lg\">{view?.name}</h1>\n          <a href=\"/\" className=\"flex items-center\">\n            <TeableLogo className=\"md:text-2xl\" />\n            <p className=\"ml-1 font-semibold\">{brandName}</p>\n          </a>\n        </div>\n      )}\n      <div className=\"flex w-full grow flex-col overflow-hidden border md:rounded md:shadow-md\">\n        <SearchProvider>\n          <RecordProvider>\n            {!hideToolBar && <CalendarToolbar />}\n            <CalendarProvider>\n              <div className=\"w-full grow overflow-hidden\">\n                {isHydrated && <CalendarViewBase />}\n              </div>\n            </CalendarProvider>\n          </RecordProvider>\n        </SearchProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/toolbar/Toolbar.tsx",
    "content": "import { Filter as FilterIcon } from '@teable/icons';\nimport type { CalendarView } from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange';\nimport { SearchButton } from '@/features/app/blocks/view/search/SearchButton';\nimport { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton';\nimport { ShareViewFilter } from '../../share-view-filter';\n\nexport const CalendarToolbar: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView() as CalendarView | undefined;\n  const { onFilterChange } = useToolbarChange();\n\n  if (!view) return null;\n\n  return (\n    <div className=\"flex w-full items-center justify-between gap-2 border-b px-4 py-2 @container/toolbar\">\n      <ShareViewFilter filters={view?.filter || null} onChange={onFilterChange}>\n        {(text, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <FilterIcon className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </ShareViewFilter>\n      <div className=\"flex w-10 flex-1 justify-end\">\n        <SearchButton shareView />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/calendar/toolbar/index.ts",
    "content": "export * from './Toolbar';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/form/FormView.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { ANONYMOUS_USER_ID, isAnonymous } from '@teable/core';\nimport { Lock } from '@teable/icons';\nimport { shareViewFormSubmit, userMe } from '@teable/openapi';\nimport { SessionProvider, ShareViewContext } from '@teable/sdk/context';\nimport { Button } from '@teable/ui-lib';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useContext, useMemo } from 'react';\nimport { FormPreviewer } from '@/features/app/blocks/view/form/components';\nimport { shareConfig } from '@/features/i18n/share.config';\nimport { FormViewBase } from './FormViewBase';\n\nexport const FormView = () => {\n  const { shareId, shareMeta } = useContext(ShareViewContext);\n  const { mutateAsync } = useMutation({\n    mutationFn: shareViewFormSubmit,\n  });\n  const requireLogin = shareMeta?.submit?.requireLogin;\n  const { t } = useTranslation(shareConfig.i18nNamespaces);\n  const router = useRouter();\n  const { embed } = router.query;\n  // Check real auth state via API when requireLogin is enabled,\n  // since SessionProvider in share view always uses ANONYMOUS_USER_ID\n  const { data: authUser, isLoading: isAuthLoading } = useQuery({\n    queryKey: ['share-form-auth'],\n    queryFn: () => userMe().then((res) => res.data),\n    enabled: !!requireLogin,\n    retry: false,\n  });\n\n  const needLogin =\n    requireLogin && !isAuthLoading && (!authUser || (authUser && isAnonymous(authUser.id)));\n\n  const onSubmit = async (fields: Record<string, unknown>) => {\n    await mutateAsync({ shareId, fields });\n  };\n\n  const handleLogin = () => {\n    const loginUrl = `/auth/login?redirect=${encodeURIComponent(router.asPath)}`;\n    if (embed) {\n      window.open(loginUrl, '_blank');\n    } else {\n      router.push(loginUrl);\n    }\n  };\n\n  const user = useMemo(() => {\n    return {\n      id: authUser?.id ?? ANONYMOUS_USER_ID,\n      name: authUser?.name ?? ANONYMOUS_USER_ID,\n      email: authUser?.email ?? '',\n      notifyMeta: authUser?.notifyMeta ?? {},\n      hasPassword: authUser?.hasPassword ?? false,\n      isAdmin: authUser?.isAdmin ?? false,\n    };\n  }, [authUser]);\n\n  return (\n    <div className=\"relative flex size-full\">\n      <SessionProvider user={user} disabledApi>\n        {embed ? (\n          <FormViewBase submit={needLogin ? undefined : onSubmit} />\n        ) : (\n          <FormPreviewer submit={needLogin ? undefined : onSubmit} />\n        )}\n      </SessionProvider>\n      {needLogin && (\n        <div className=\"absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm\">\n          <div className=\"flex flex-col items-center gap-4 rounded-lg border bg-background p-8 shadow-lg\">\n            <Lock className=\"size-10 text-muted-foreground\" />\n            <p className=\"text-center text-sm text-muted-foreground\">\n              {t('share:form.requireLoginTip')}\n            </p>\n            <Button onClick={handleLogin}>{t('share:form.login')}</Button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/form/FormViewBase.tsx",
    "content": "import { useRef } from 'react';\nimport { FormBody } from '@/features/app/blocks/view/form/components/FromBody';\nimport { EmbedFooter } from '../../EmbedFooter';\n\ninterface IFormViewBaseProps {\n  submit?: (fields: Record<string, unknown>) => Promise<void>;\n}\n\nexport const FormViewBase = (props: IFormViewBaseProps) => {\n  const { submit } = props;\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const onSubmit = async (formData: Record<string, unknown>) => {\n    await submit?.(formData);\n    setTimeout(() => {\n      containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });\n    }, 1000);\n  };\n\n  return (\n    <div className=\"flex grow flex-col border\" ref={containerRef}>\n      <FormBody className=\"grow overflow-auto pb-8\" submit={submit ? onSubmit : undefined} />\n      <EmbedFooter hideNewPage />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx",
    "content": "import { RecordProvider, RowCountProvider, ShareViewContext } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useContext } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { GalleryProvider } from '@/features/app/blocks/view/gallery/context';\nimport { GalleryViewBase } from '@/features/app/blocks/view/gallery/GalleryViewBase';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { GalleryToolbar } from './toolbar';\n\nexport const GalleryView = () => {\n  const { view } = useContext(ShareViewContext);\n  const isHydrated = useIsHydrated();\n  const {\n    query: { hideToolBar, embed },\n  } = useRouter();\n  const { brandName } = useBrand();\n  return (\n    <div className={cn('flex size-full flex-col', embed ? '' : 'md:px-3 md:pb-3')}>\n      {!embed && (\n        <div className=\"flex w-full justify-between px-1 py-2 md:px-0 md:py-3\">\n          <h1 className=\"font-semibold md:text-lg\">{view?.name}</h1>\n          <Link href=\"/\" className=\"flex items-center\">\n            <TeableLogo className=\"md:text-2xl\" />\n            <p className=\"ml-1 font-semibold\">{brandName}</p>\n          </Link>\n        </div>\n      )}\n      <div className=\"flex w-full grow flex-col overflow-hidden border md:rounded md:shadow-md\">\n        <SearchProvider>\n          <RecordProvider>\n            <RowCountProvider>\n              {!hideToolBar && <GalleryToolbar />}\n              <GalleryProvider>\n                <div className=\"w-full grow overflow-hidden\">\n                  {isHydrated && <GalleryViewBase />}\n                </div>\n              </GalleryProvider>\n            </RowCountProvider>\n          </RecordProvider>\n        </SearchProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx",
    "content": "import { ArrowUpDown, Filter as FilterIcon } from '@teable/icons';\nimport type { GalleryView } from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange';\nimport { SearchButton } from '@/features/app/blocks/view/search/SearchButton';\nimport { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton';\nimport { Sort } from '../../grid/toolbar/Sort';\nimport { ShareViewFilter } from '../../share-view-filter';\n\nexport const GalleryToolbar: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView() as GalleryView | undefined;\n  const { onFilterChange, onSortChange } = useToolbarChange();\n\n  if (!view) return null;\n\n  return (\n    <div className=\"flex w-full items-center justify-between gap-2 border-b px-4 py-2 @container/toolbar\">\n      <ShareViewFilter filters={view?.filter || null} onChange={onFilterChange}>\n        {(text, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <FilterIcon className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </ShareViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-orange-100 dark:bg-orange-600/30 hover:bg-orange-200 dark:hover:bg-orange-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n      <div className=\"flex w-10 flex-1 justify-end\">\n        <SearchButton shareView />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/index.ts",
    "content": "export * from './Toolbar';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridView.tsx",
    "content": "import { RecordProvider, RowCountProvider, ShareViewContext } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useContext } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { EmbedFooter } from '../../EmbedFooter';\nimport { AggregationProvider } from './aggregation';\nimport { GridViewBase } from './GridViewBase';\nimport { Toolbar } from './toolbar';\n\nexport const GridView = () => {\n  const { records, view, extra } = useContext(ShareViewContext);\n  const isHydrated = useIsHydrated();\n  const { brandName } = useBrand();\n  const {\n    query: { hideToolBar, embed },\n  } = useRouter();\n\n  return (\n    <div className={cn('flex size-full flex-col', embed ? '' : 'md:px-3 md:pb-3')}>\n      {!embed && (\n        <div className=\"flex w-full justify-between px-1 py-2 md:px-0 md:py-3\">\n          <h1 className=\"font-semibold md:text-lg\">{view?.name}</h1>\n          <Link href=\"/\" className=\"flex items-center\">\n            <TeableLogo className=\"md:text-2xl\" />\n            <p className=\"ml-1 font-semibold\">{brandName}</p>\n          </Link>\n        </div>\n      )}\n      <div className=\"flex w-full grow flex-col overflow-hidden border md:rounded md:shadow-md\">\n        <SearchProvider>\n          <RecordProvider serverRecords={records}>\n            <AggregationProvider>\n              <RowCountProvider>\n                {!hideToolBar && <Toolbar />}\n                {isHydrated && (\n                  <div className=\"w-full grow overflow-hidden\">\n                    <GridViewBase groupPointsServerData={extra?.groupPoints} />\n                  </div>\n                )}\n                {embed && <EmbedFooter />}\n              </RowCountProvider>\n            </AggregationProvider>\n          </RecordProvider>\n        </SearchProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { IGridViewOptions } from '@teable/core';\nimport { RowHeightLevel } from '@teable/core';\nimport type { IGetRecordsRo, IGroupPointsVo, IRangesRo } from '@teable/openapi';\nimport { saveQueryParams, shareViewCopy } from '@teable/openapi';\nimport type {\n  CombinedSelection,\n  IButtonCell,\n  ICell,\n  ICellItem,\n  IGridRef,\n  IGroupPoint,\n  IPosition,\n  IRectangle,\n} from '@teable/sdk/components';\nimport {\n  DraggableType,\n  Grid,\n  useGridAsyncRecords,\n  useGridColumnResize,\n  useGridColumnStatistics,\n  useGridColumns,\n  useGridIcons,\n  useGridTheme,\n  RowControlType,\n  CellType,\n  useGridGroupCollection,\n  useGridCollapsedGroup,\n  RowCounter,\n  useGridColumnOrder,\n  generateLocalId,\n  useGridTooltipStore,\n  RegionType,\n  useGridViewStore,\n  LARGE_QUERY_THRESHOLD,\n} from '@teable/sdk/components';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport {\n  useButtonClickStatus,\n  useFields,\n  useIsHydrated,\n  useIsTouchDevice,\n  useRowCount,\n  useSSRRecord,\n  useSSRRecords,\n  useSearch,\n  useTableId,\n  useView,\n} from '@teable/sdk/hooks';\nimport { Skeleton } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { uniqueId } from 'lodash';\nimport { useRouter } from 'next/router';\nimport { useCallback, useContext, useEffect, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useClickAway } from 'react-use';\nimport { DomBox } from '@/features/app/blocks/view/grid/DomBox';\nimport { useGridSearchStore } from '@/features/app/blocks/view/grid/useGridSearchStore';\nimport { computeFrozenColumnCount } from '@/features/app/blocks/view/grid/utils/computeFrozenFields';\nimport { ExpandRecordContainer } from '@/features/app/components/expand-record-container';\nimport type { IExpandRecordContainerRef } from '@/features/app/components/expand-record-container/types';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport {\n  GIRD_FIELD_NAME_HEIGHT_DEFINITIONS,\n  GIRD_ROW_HEIGHT_DEFINITIONS,\n} from '../../../../view/grid/const';\nimport { useSelectionOperation } from '../../../../view/grid/hooks';\n\ninterface IGridViewProps {\n  groupPointsServerData?: IGroupPointsVo;\n}\n\nexport const GridViewBase = (props: IGridViewProps) => {\n  const { groupPointsServerData } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const view = useView();\n  const tableId = useTableId() as string;\n  const { shareId } = useContext(ShareViewContext);\n  const router = useRouter();\n  const isHydrated = useIsHydrated();\n  const gridRef = useRef<IGridRef>(null);\n  const container = useRef<HTMLDivElement>(null);\n  const expandRecordRef = useRef<IExpandRecordContainerRef>(null);\n  const theme = useGridTheme();\n  const rowCount = useRowCount();\n  const ssrRecords = useSSRRecords();\n  const ssrRecord = useSSRRecord();\n  const isTouchDevice = useIsTouchDevice();\n  const { setSelection, openStatisticMenu, openGroupHeaderMenu, openHeaderMenu } =\n    useGridViewStore();\n  const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns();\n  const { columns, onColumnResize } = useGridColumnResize(originalColumns);\n  const { columnStatistics } = useGridColumnStatistics(columns);\n  const { onColumnOrdered } = useGridColumnOrder();\n  const { searchQuery: search } = useSearch();\n  const visibleFields = useFields();\n  const allFields = useFields({ withHidden: true });\n  const customIcons = useGridIcons();\n  const { openTooltip, closeTooltip } = useGridTooltipStore();\n  const { setGridRef, searchCursor } = useGridSearchStore();\n  const buttonClickStatusHook = useButtonClickStatus(tableId, shareId);\n\n  const prepare = isHydrated && view && columns.length;\n  const { filter, sort } = view ?? {};\n  const realRowCount = rowCount ?? ssrRecords?.length ?? 0;\n  const {\n    rowHeight: rowHeightLevel = RowHeightLevel.Short,\n    fieldNameDisplayLines = 1,\n    frozenFieldId,\n    frozenColumnCount: frozenColumnCountOption,\n  } = (view?.options ?? {}) as IGridViewOptions;\n  const rowHeight = GIRD_ROW_HEIGHT_DEFINITIONS[rowHeightLevel];\n  const columnHeaderHeight = GIRD_FIELD_NAME_HEIGHT_DEFINITIONS[fieldNameDisplayLines];\n\n  const frozenColumnCount = useMemo(() => {\n    return computeFrozenColumnCount({\n      isTouchDevice,\n      frozenFieldId,\n      frozenColumnCount: frozenColumnCountOption,\n      visibleColumns: columns,\n      allFields,\n    });\n  }, [isTouchDevice, frozenFieldId, columns, allFields, frozenColumnCountOption]);\n\n  const groupCollection = useGridGroupCollection();\n\n  useEffect(() => {\n    setGridRef(gridRef);\n  }, [setGridRef]);\n\n  const {\n    viewQuery: viewQueryWithGroup,\n    collapsedGroupIds,\n    onCollapsedGroupChanged,\n  } = useGridCollapsedGroup(generateLocalId(tableId, view?.id));\n\n  const { mutateAsync: copyReq } = useMutation({\n    mutationFn: async (copyRo: IRangesRo) => {\n      const collapsedGroupIds = viewQueryWithGroup?.collapsedGroupIds;\n      const { collapsedGroupIds: originalCollapsedGroupIds, ...rest } = copyRo;\n      const params = {\n        ...rest,\n        orderBy: view?.sort?.sortObjs,\n        groupBy: view?.group,\n        filter: view?.filter,\n        search,\n        projection: visibleFields.map((field) => field.id),\n      };\n      if (collapsedGroupIds && collapsedGroupIds.length > LARGE_QUERY_THRESHOLD) {\n        const { data } = await saveQueryParams({ params: { collapsedGroupIds } });\n        return shareViewCopy(shareId, { ...params, queryId: data.queryId });\n      }\n      return shareViewCopy(shareId, { ...params, collapsedGroupIds });\n    },\n  });\n  const { copy } = useSelectionOperation({\n    copyReq,\n    collapsedGroupIds: collapsedGroupIds ? Array.from(collapsedGroupIds) : undefined,\n  });\n\n  const viewQuery = useMemo(() => {\n    return {\n      filter,\n      orderBy: sort?.sortObjs as IGetRecordsRo['orderBy'],\n      ...viewQueryWithGroup,\n    };\n  }, [filter, sort?.sortObjs, viewQueryWithGroup]);\n\n  const { recordMap, groupPoints, searchHitIndex, allGroupHeaderRefs, onVisibleRegionChanged } =\n    useGridAsyncRecords(ssrRecords, undefined, viewQuery, groupPointsServerData);\n\n  useClickAway(container, () => {\n    gridRef.current?.resetState();\n  });\n\n  useEffect(() => {\n    const recordIds = Object.keys(recordMap)\n      .sort((a, b) => Number(a) - Number(b))\n      .map((key) => recordMap[key]?.id)\n      .filter(Boolean);\n    expandRecordRef.current?.updateRecordIds?.(recordIds);\n  }, [expandRecordRef, recordMap]);\n\n  const onRowExpandInner = (rowIndex: number) => {\n    const recordId = recordMap[rowIndex]?.id;\n    if (!recordId) {\n      return;\n    }\n    router.push(\n      {\n        pathname: router.pathname,\n        query: { ...router.query, recordId },\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  };\n\n  const onSelectionChanged = useCallback(\n    (selection: CombinedSelection) => {\n      setSelection(selection);\n    },\n    [setSelection]\n  );\n\n  const onColumnFreeze = useCallback(\n    (count: number) => {\n      const anchorId = columns[Math.max(0, count - 1)]?.id;\n      if (!view || !anchorId) return;\n      view.updateOption({ frozenFieldId: anchorId });\n    },\n    [view, columns]\n  );\n\n  const rowControls = useMemo(\n    () => [\n      {\n        type: RowControlType.Checkbox,\n        icon: RowControlType.Checkbox,\n      },\n      {\n        type: RowControlType.Expand,\n        icon: RowControlType.Expand,\n      },\n    ],\n    []\n  );\n\n  const getCellContent = useCallback<(cell: ICellItem) => ICell>(\n    (cell) => {\n      const [colIndex, rowIndex] = cell;\n      const record = recordMap[rowIndex];\n      if (record !== undefined) {\n        const fieldId = columns[colIndex]?.id;\n        if (!fieldId) return { type: CellType.Loading };\n        return cellValue2GridDisplay(record, colIndex, false, undefined, buttonClickStatusHook);\n      }\n      return { type: CellType.Loading };\n    },\n    [recordMap, columns, cellValue2GridDisplay, buttonClickStatusHook]\n  );\n\n  const onCopy = useCallback(\n    async (selection: CombinedSelection) => {\n      const allowCopy = view?.shareMeta?.allowCopy;\n      if (!allowCopy) {\n        toast.warning(\"Sorry, the table's owner has disabled copying\");\n        return;\n      }\n      await copy(selection);\n    },\n    [copy, view?.shareMeta?.allowCopy]\n  );\n\n  const onColumnStatisticClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      const { x, y, width, height } = bounds;\n      const fieldId = columns[colIndex].id;\n      openStatisticMenu({ fieldId, position: { x, y, width, height } });\n    },\n    [columns, openStatisticMenu]\n  );\n\n  const onCellValueHovered = (bounds: IRectangle, cellItem: ICellItem) => {\n    const cellInfo = getCellContent(cellItem);\n    if (!cellInfo?.id) {\n      return;\n    }\n\n    if (cellInfo.type === CellType.Button) {\n      const { data } = cellInfo as IButtonCell;\n      const { fieldOptions, cellValue } = data;\n      const { label } = fieldOptions;\n      const count = cellValue?.count ?? 0;\n      const maxCount = fieldOptions?.maxCount ?? 0;\n      openTooltip({\n        id: componentId,\n        text: t('sdk:common.clickedCount', {\n          label,\n          text: maxCount > 0 ? `${count}/${maxCount}` : `${count}`,\n        }),\n        position: bounds,\n      });\n    }\n  };\n\n  const componentId = useMemo(() => uniqueId('shared-grid-view-'), []);\n\n  const onItemHovered = (type: RegionType, bounds: IRectangle, cellItem: ICellItem) => {\n    const [columnIndex] = cellItem;\n    const { description } = columns[columnIndex] ?? {};\n\n    closeTooltip();\n\n    if (type === RegionType.ColumnDescription && description) {\n      openTooltip({\n        id: componentId,\n        text: description,\n        position: bounds,\n      });\n    }\n\n    if (type === RegionType.CellValue) {\n      onCellValueHovered(bounds, cellItem);\n    }\n  };\n\n  const onGroupHeaderContextMenu = (groupId: string, position: IPosition) => {\n    openGroupHeaderMenu({\n      groupId,\n      position,\n      allGroupHeaderRefs,\n    });\n  };\n\n  const onColumnHeaderMenuClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      const fieldId = columns[colIndex].id;\n      const { x, height } = bounds;\n      const selectedFields = visibleFields.filter((field) => field.id === fieldId);\n      openHeaderMenu({\n        fields: selectedFields,\n        position: { x, y: height },\n      });\n    },\n    [columns, visibleFields, openHeaderMenu]\n  );\n\n  const onColumnHeaderClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      if (!isTouchDevice) return;\n      const fieldId = columns[colIndex].id;\n      const { x, height } = bounds;\n      const selectedFields = visibleFields.filter((field) => field.id === fieldId);\n      openHeaderMenu({ fields: selectedFields, position: { x, y: height } });\n    },\n    [isTouchDevice, columns, visibleFields, openHeaderMenu]\n  );\n\n  const onContextMenu = useCallback(\n    (selection: CombinedSelection, position: IPosition) => {\n      const { isColumnSelection, ranges } = selection;\n\n      if (isColumnSelection) {\n        const [start, end] = ranges[0];\n        const startIdx = Math.min(start, end);\n        const endIdx = Math.max(start, end);\n        const selectColumns = Array.from({ length: endIdx - startIdx + 1 })\n          .map((_, index) => columns[startIdx + index])\n          .filter(Boolean);\n        const indexedColumns = new Set(selectColumns.map((c) => c.id));\n        const selectFields = visibleFields.filter((field) => indexedColumns.has(field.id));\n        const onSelectionClear = () => gridRef.current?.resetState();\n        openHeaderMenu({\n          position,\n          fields: selectFields,\n          onSelectionClear,\n        });\n      }\n    },\n    [columns, visibleFields, openHeaderMenu]\n  );\n\n  return (\n    <div ref={container} className=\"relative size-full overflow-hidden\">\n      {prepare ? (\n        <>\n          <Grid\n            ref={gridRef}\n            theme={theme}\n            draggable={DraggableType.Column}\n            isTouchDevice={isTouchDevice}\n            rowCount={realRowCount}\n            rowHeight={rowHeight}\n            columnHeaderHeight={columnHeaderHeight}\n            columnStatistics={columnStatistics}\n            freezeColumnCount={frozenColumnCount}\n            columns={columns}\n            searchCursor={searchCursor}\n            searchHitIndex={searchHitIndex}\n            customIcons={customIcons}\n            rowControls={rowControls}\n            style={{\n              width: '100%',\n              height: '100%',\n            }}\n            collapsedGroupIds={collapsedGroupIds}\n            groupCollection={groupCollection}\n            groupPoints={groupPoints as unknown as IGroupPoint[]}\n            getCellContent={getCellContent}\n            onVisibleRegionChanged={onVisibleRegionChanged}\n            onSelectionChanged={onSelectionChanged}\n            onCopy={onCopy}\n            onItemHovered={onItemHovered}\n            onRowExpand={onRowExpandInner}\n            onColumnResize={onColumnResize}\n            onColumnFreeze={onColumnFreeze}\n            onColumnOrdered={onColumnOrdered}\n            onColumnStatisticClick={onColumnStatisticClick}\n            onCollapsedGroupChanged={onCollapsedGroupChanged}\n            onGroupHeaderContextMenu={onGroupHeaderContextMenu}\n            onColumnHeaderMenuClick={onColumnHeaderMenuClick}\n            onColumnHeaderClick={onColumnHeaderClick}\n            onContextMenu={onContextMenu}\n          />\n          <RowCounter rowCount={realRowCount} className=\"absolute bottom-3 left-0\" />\n        </>\n      ) : (\n        <div className=\"flex w-full items-center space-x-4\">\n          <div className=\"w-full space-y-3 px-2\">\n            <Skeleton className=\"h-7 w-full\" />\n            <Skeleton className=\"h-7 w-full\" />\n            <Skeleton className=\"h-7 w-full\" />\n          </div>\n        </div>\n      )}\n      <DomBox id={componentId} />\n      <ExpandRecordContainer\n        ref={expandRecordRef}\n        recordServerData={ssrRecord}\n        buttonClickStatusHook={buttonClickStatusHook}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/aggregation/AggregationProvider.tsx",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IGridColumnMeta, ITableActionKey, IViewActionKey } from '@teable/core';\nimport type { IShareViewAggregationsRo, StatisticsFunc } from '@teable/openapi';\nimport { getShareViewAggregations } from '@teable/openapi';\nimport {\n  useView,\n  ReactQueryKeys,\n  AggregationContext,\n  useSearch,\n  useViewListener,\n  useTableListener,\n  ShareViewContext,\n} from '@teable/sdk';\nimport type { ReactNode } from 'react';\nimport { useCallback, useContext, useMemo, useRef } from 'react';\n\ninterface IAggregationProviderProps {\n  children: ReactNode;\n}\n\nconst useAggregationQuery = (): IShareViewAggregationsRo => {\n  const view = useView();\n  const { searchQuery } = useSearch();\n\n  const field = useMemo(\n    () =>\n      view?.columnMeta\n        ? Object.entries(view.columnMeta as IGridColumnMeta).reduce<\n            Record<StatisticsFunc, string[]>\n          >(\n            (acc, [fieldId, { statisticFunc }]) => {\n              if (statisticFunc && acc) {\n                const existingArr = acc[statisticFunc] || [];\n                acc[statisticFunc] = [...existingArr, fieldId];\n              }\n              return acc;\n            },\n            {} as Record<StatisticsFunc, string[]>\n          )\n        : undefined,\n    [view?.columnMeta]\n  );\n  return useMemo(\n    () => ({ filter: view?.filter, field, search: searchQuery, groupBy: view?.group }),\n    [field, searchQuery, view?.filter, view?.group]\n  );\n};\n\nexport const AggregationProvider = ({ children }: IAggregationProviderProps) => {\n  const { tableId, shareId } = useContext(ShareViewContext);\n  const queryClient = useQueryClient();\n  const query = useAggregationQuery();\n  const queryRef = useRef(query);\n  queryRef.current = query;\n\n  const { data: shareViewAggregations } = useQuery({\n    queryKey: ReactQueryKeys.shareViewAggregations(shareId, query),\n    queryFn: ({ queryKey }) =>\n      getShareViewAggregations(queryKey[1], queryKey[2]).then((data) => data.data),\n    refetchOnWindowFocus: false,\n  });\n\n  const updateViewAggregations = useCallback(\n    () =>\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.shareViewAggregations(shareId, query),\n      }),\n    [query, queryClient, shareId]\n  );\n\n  const tableMatches = useMemo<ITableActionKey[]>(\n    () => ['setRecord', 'addRecord', 'deleteRecord'],\n    []\n  );\n  useTableListener(tableId, tableMatches, updateViewAggregations);\n\n  const viewMatches = useMemo<IViewActionKey[]>(\n    () => ['applyViewFilter', 'showViewField', 'applyViewStatisticFunc'],\n    []\n  );\n  useViewListener(tableId, viewMatches, updateViewAggregations);\n\n  const viewAggregation = useMemo(() => {\n    if (!shareViewAggregations) {\n      return {};\n    }\n    const { aggregations } = shareViewAggregations;\n    return {\n      aggregations: aggregations ?? [],\n    };\n  }, [shareViewAggregations]);\n\n  return (\n    <AggregationContext.Provider value={viewAggregation}>{children}</AggregationContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/aggregation/GroupPointProvider.tsx",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IKanbanViewOptions, ITableActionKey, IViewActionKey } from '@teable/core';\nimport { SortFunc, ViewType } from '@teable/core';\nimport { getShareViewGroupPoints } from '@teable/openapi';\nimport {\n  ReactQueryKeys,\n  GroupPointContext,\n  useView,\n  useSearch,\n  useTableListener,\n  useViewListener,\n  ShareViewContext,\n} from '@teable/sdk';\nimport type { ReactNode } from 'react';\nimport { useCallback, useContext, useMemo } from 'react';\n\ninterface GroupPointProviderProps {\n  children: ReactNode;\n}\n\nexport const GroupPointProvider = ({ children }: GroupPointProviderProps) => {\n  const { tableId, viewId, shareId } = useContext(ShareViewContext);\n  const queryClient = useQueryClient();\n  const view = useView(viewId);\n  const { searchQuery } = useSearch();\n  const { type, filter, group, options } = view || {};\n\n  const groupBy = useMemo(() => {\n    if (type === ViewType.Kanban) {\n      const { stackFieldId } = (options ?? {}) as IKanbanViewOptions;\n      if (stackFieldId == null) return;\n      return [{ order: SortFunc.Asc, fieldId: stackFieldId }];\n    }\n    return group;\n  }, [group, options, type]);\n\n  const query = useMemo(() => {\n    return {\n      filter,\n      groupBy,\n      search: searchQuery,\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [filter, JSON.stringify(groupBy), searchQuery]);\n\n  const { data: resGroupPoints } = useQuery({\n    queryKey: ReactQueryKeys.shareViewGroupPoints(shareId, query),\n    queryFn: ({ queryKey }) =>\n      getShareViewGroupPoints(queryKey[1], queryKey[2]).then((data) => data.data),\n    enabled: Boolean(tableId && groupBy?.length),\n    refetchOnWindowFocus: false,\n    retry: 1,\n  });\n\n  const updateGroupPoints = useCallback(\n    () =>\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.shareViewGroupPoints(shareId, query),\n      }),\n    [query, queryClient, shareId]\n  );\n\n  const tableMatches = useMemo<ITableActionKey[]>(\n    () => ['setRecord', 'addRecord', 'deleteRecord', 'setField'],\n    []\n  );\n  useTableListener(tableId, tableMatches, updateGroupPoints);\n\n  const viewMatches = useMemo<IViewActionKey[]>(() => ['applyViewFilter'], []);\n  useViewListener(viewId, viewMatches, updateGroupPoints);\n\n  const groupPoints = useMemo(() => resGroupPoints || null, [resGroupPoints]);\n\n  return <GroupPointContext.Provider value={groupPoints}>{children}</GroupPointContext.Provider>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/aggregation/index.ts",
    "content": "export * from './AggregationProvider';\nexport * from './GroupPointProvider';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/toolbar/Sort.tsx",
    "content": "import type { ISort } from '@teable/core';\nimport { SortBase, useSortNode } from '@teable/sdk/components';\nimport { isEqual } from 'lodash';\nimport React, { useEffect, useState } from 'react';\nimport { useDebounce } from 'react-use';\n\ninterface ISortProps {\n  children: (text: string, isActive: boolean) => React.ReactElement;\n  sorts: ISort | null;\n  onChange: (sort: ISort | null) => void;\n}\n\nfunction Sort(props: ISortProps) {\n  const { children, onChange, sorts: outerSorts } = props;\n\n  const [innerSorts, setInnerSorts] = useState(outerSorts);\n\n  const { text, isActive } = useSortNode(outerSorts);\n\n  useEffect(() => {\n    setInnerSorts(outerSorts);\n  }, [outerSorts]);\n\n  useDebounce(\n    () => {\n      if (isEqual(innerSorts, outerSorts)) {\n        return;\n      }\n      onChange(innerSorts);\n    },\n    50,\n    [innerSorts]\n  );\n\n  const onChangeInner = (sorts: ISort) => {\n    if (sorts && !Object.hasOwnProperty.call(sorts, 'manualSort')) {\n      setInnerSorts({\n        sortObjs: sorts?.sortObjs || [],\n        manualSort: outerSorts?.manualSort,\n      });\n      return;\n    }\n    setInnerSorts(sorts);\n  };\n\n  return (\n    <SortBase sorts={innerSorts} onChange={onChangeInner} hiddenManual={true}>\n      {children?.(text, isActive)}\n    </SortBase>\n  );\n}\n\nexport { Sort };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/toolbar/Toolbar.tsx",
    "content": "import type { RowHeightLevel, IGridViewOptions } from '@teable/core';\nimport {\n  ArrowUpDown,\n  EyeOff,\n  Filter as FilterIcon,\n  LayoutList,\n  AlertTriangle,\n} from '@teable/icons';\nimport { useView, RowHeight, Group, HideFields } from '@teable/sdk';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useEffect, useRef } from 'react';\nimport { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange';\nimport { SearchButton } from '@/features/app/blocks/view/search/SearchButton';\nimport { useToolBarStore } from '@/features/app/blocks/view/tool-bar/components/useToolBarStore';\nimport { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton';\nimport { ShareViewFilter } from '../../share-view-filter';\nimport { Sort } from './Sort';\n\nexport const Toolbar = () => {\n  const view = useView();\n  const { setFilterRef, setSortRef, setGroupRef } = useToolBarStore();\n  const filterRef = useRef<HTMLButtonElement>(null);\n  const sortRef = useRef<HTMLButtonElement>(null);\n  const groupRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    setFilterRef(filterRef);\n    setSortRef(sortRef);\n    setGroupRef(groupRef);\n  }, [setFilterRef, setGroupRef, setSortRef]);\n\n  const {\n    onFilterChange,\n    onRowHeightChange,\n    onSortChange,\n    onGroupChange,\n    onFieldNameDisplayLinesChange,\n  } = useToolbarChange();\n\n  if (!view) {\n    return <></>;\n  }\n\n  return (\n    <div className=\"flex w-full items-center justify-between gap-2 border-b px-4 py-2 @container/toolbar\">\n      <HideFields>\n        {(text, isActive) => (\n          <ToolBarButton isActive={isActive} text={text} textClassName=\"@2xl/toolbar:inline\">\n            <EyeOff className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </HideFields>\n      <ShareViewFilter filters={view?.filter || null} onChange={onFilterChange}>\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            ref={filterRef}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ShareViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            ref={sortRef}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-orange-100 dark:bg-orange-600/30 hover:bg-orange-200 dark:hover:bg-orange-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n      <Group group={view?.group || null} onChange={onGroupChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            ref={groupRef}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-green-100 dark:bg-green-600/30 hover:bg-green-200 dark:hover:bg-green-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <LayoutList className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Group>\n      <RowHeight\n        rowHeight={(view?.options as IGridViewOptions)?.rowHeight}\n        fieldNameDisplayLines={(view?.options as IGridViewOptions)?.fieldNameDisplayLines}\n        onChange={(type, value) => {\n          if (type === 'rowHeight') onRowHeightChange(value as RowHeightLevel);\n          if (type === 'fieldNameDisplayLines') onFieldNameDisplayLinesChange(value as number);\n        }}\n      >\n        {(_, isActive, Icon) => (\n          <ToolBarButton isActive={isActive}>\n            <Icon className=\"text-sm\" />\n          </ToolBarButton>\n        )}\n      </RowHeight>\n      <div className=\"flex w-10 flex-1 justify-end\">\n        <SearchButton shareView />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/grid/toolbar/index.ts",
    "content": "export * from './Toolbar';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/KanbanView.tsx",
    "content": "import { RecordProvider, ShareViewContext } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useContext } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { KanbanProvider } from '@/features/app/blocks/view/kanban/context';\nimport { KanbanViewBase } from '@/features/app/blocks/view/kanban/KanbanViewBase';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { EmbedFooter } from '../../EmbedFooter';\nimport { GroupPointProvider } from '../grid/aggregation';\nimport { KanbanToolbar } from './toolbar';\n\nexport const KanbanView = () => {\n  const { view } = useContext(ShareViewContext);\n  const isHydrated = useIsHydrated();\n  const { brandName } = useBrand();\n  const {\n    query: { hideToolBar, embed },\n  } = useRouter();\n\n  return (\n    <div className={cn('flex size-full flex-col', embed ? '' : 'md:px-3 md:pb-3')}>\n      {!embed && (\n        <div className=\"flex w-full justify-between px-1 py-2 md:px-0 md:py-3\">\n          <h1 className=\"font-semibold md:text-lg\">{view?.name}</h1>\n          <Link href=\"/\" className=\"flex items-center\">\n            <TeableLogo className=\"md:text-2xl\" />\n            <p className=\"ml-1 font-semibold\">{brandName}</p>\n          </Link>\n        </div>\n      )}\n      <div className=\"flex w-full grow flex-col overflow-hidden border md:rounded md:shadow-md\">\n        <SearchProvider>\n          <RecordProvider>\n            <GroupPointProvider>\n              {!hideToolBar && <KanbanToolbar />}\n              <KanbanProvider>\n                <div className=\"w-full grow overflow-hidden\">\n                  {isHydrated && <KanbanViewBase />}\n                </div>\n              </KanbanProvider>\n              {embed && <EmbedFooter />}\n            </GroupPointProvider>\n          </RecordProvider>\n        </SearchProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/toolbar/Toolbar.tsx",
    "content": "import { ArrowUpDown, Filter as FilterIcon, Layers, AlertTriangle } from '@teable/icons';\nimport type { KanbanView } from '@teable/sdk';\nimport { useFields } from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Trans } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange';\nimport { SearchButton } from '@/features/app/blocks/view/search/SearchButton';\nimport { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton';\nimport { Sort } from '../../grid/toolbar/Sort';\nimport { ShareViewFilter } from '../../share-view-filter';\n\nexport const KanbanToolbar: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView() as KanbanView | undefined;\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { onFilterChange, onSortChange } = useToolbarChange();\n  const { stackFieldId } = view?.options ?? {};\n  const stackFieldName = useMemo(() => {\n    if (stackFieldId == null) return '';\n    const groupField = allFields.find(({ id }) => id === stackFieldId);\n    return groupField != null ? groupField.name : '';\n  }, [allFields, stackFieldId]);\n\n  if (!view) return null;\n\n  return (\n    <div className=\"flex w-full items-center justify-between gap-2 border-b px-4 py-2 @container/toolbar\">\n      <ToolBarButton\n        disabled\n        text={\n          <Trans ns=\"table\" i18nKey={'kanban.toolbar.stackedBy'}>\n            {stackFieldName}\n          </Trans>\n        }\n        textClassName=\"@2xl/toolbar:inline\"\n      >\n        <Layers className=\"size-4 text-sm\" />\n      </ToolBarButton>\n      <ShareViewFilter filters={view?.filter || null} onChange={onFilterChange}>\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ShareViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-xs',\n              isActive &&\n                'bg-orange-100 dark:bg-orange-600/30 hover:bg-orange-200 dark:hover:bg-orange-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n      <div className=\"flex w-10 flex-1 justify-end\">\n        <SearchButton shareView />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/kanban/toolbar/index.ts",
    "content": "export * from './Toolbar';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/plugin/SharePluginView.tsx",
    "content": "import { PluginPosition, type IShareViewPlugin } from '@teable/openapi';\nimport { useView } from '@teable/sdk/hooks';\nimport type { PluginView as PluginViewInstance } from '@teable/sdk/model/view/plugin.view';\nimport { PluginContent } from '@/features/app/components/plugin/PluginContent';\n\nexport const PluginView = (props: { shareId: string; plugin?: IShareViewPlugin }) => {\n  const { shareId, plugin } = props;\n  const view = useView();\n\n  if (!view || !plugin) {\n    return;\n  }\n\n  const { options, id } = view as PluginViewInstance;\n  const { pluginId, pluginInstallId } = options;\n\n  return (\n    <PluginContent\n      pluginId={pluginId}\n      pluginInstallId={pluginInstallId}\n      positionId={id}\n      pluginUrl={plugin.url}\n      shareId={shareId}\n      positionType={PluginPosition.View}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/FilterUser.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { FieldType } from '@teable/core';\nimport { getShareViewCollaborators } from '@teable/openapi';\nimport type { IFilterComponents } from '@teable/sdk/components';\nimport { FilterUserSelectBase } from '@teable/sdk/components/filter/view-filter/component';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport { useContext, useState } from 'react';\n\nexport const FilterUser: IFilterComponents[FieldType.User] = (props) => {\n  const { shareId } = useContext(ShareViewContext);\n  const [search, setSearch] = useState('');\n  const { data: userQuery } = useQuery({\n    queryKey: ReactQueryKeys.shareViewCollaborators(shareId, {\n      fieldId: props.field.id,\n      skip: 0,\n      take: 100,\n      search,\n    }),\n    queryFn: ({ queryKey }) =>\n      getShareViewCollaborators(queryKey[1], {\n        fieldId: queryKey[2]?.fieldId,\n        skip: queryKey[2]?.skip,\n        take: queryKey[2]?.take,\n        search: queryKey[2]?.search,\n      }).then((data) => data.data),\n  });\n  return <FilterUserSelectBase {...props} data={userQuery} disableMe onSearch={setSearch} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/ShareViewFilter.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport { ViewFilter } from '@teable/sdk/components';\nimport { FieldValue } from '@teable/sdk/components/filter/view-filter/custom-component';\nimport { type ComponentProps } from 'react';\nimport { FilterLink } from './filter-link';\nimport { FilterUser } from './FilterUser';\n\ntype IShareViewFilterProps = ComponentProps<typeof ViewFilter>;\ntype ICustomerValueComponentProps = ComponentProps<typeof FieldValue>;\n\nconst CustomValueComponent = (props: ICustomerValueComponentProps) => {\n  const components = {\n    [FieldType.User]: FilterUser,\n    [FieldType.CreatedBy]: FilterUser,\n    [FieldType.LastModifiedBy]: FilterUser,\n    [FieldType.Link]: FilterLink,\n  };\n  return <FieldValue {...props} components={components} />;\n};\n\nexport const ShareViewFilter = (props: IShareViewFilterProps) => {\n  return (\n    <ViewFilter\n      {...props}\n      customValueComponent={CustomValueComponent}\n      viewFilterLinkContext={undefined}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/filter-link/FilterLink.tsx",
    "content": "import type { FieldType } from '@teable/core';\nimport type { IFilterComponents } from '@teable/sdk/components';\nimport {\n  FilterLinkBase,\n  FilterLinkSelect,\n} from '@teable/sdk/components/filter/view-filter/component';\nimport { FilterLinkSelectList } from './FilterLinkSelectList';\nimport { FilterLinkSelectTrigger } from './FilterLinkSelectTrigger';\n\nconst FilterLinkSelectCom: IFilterComponents[FieldType.Link] = (props) => {\n  return (\n    <FilterLinkSelect\n      {...props}\n      components={{\n        Trigger: FilterLinkSelectTrigger,\n        List: FilterLinkSelectList,\n      }}\n    />\n  );\n};\n\nexport const FilterLink: IFilterComponents[FieldType.Link] = (props) => {\n  return (\n    <FilterLinkBase\n      {...props}\n      components={{\n        Select: FilterLinkSelectCom,\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/filter-link/FilterLinkSelectList.tsx",
    "content": "import { getShareViewLinkRecords } from '@teable/openapi';\nimport { ApiRecordList } from '@teable/sdk/components';\nimport type { IFilterLinkSelectListProps } from '@teable/sdk/components/filter/view-filter/component/filter-link/types';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport { useCallback, useContext, useState } from 'react';\n\nconst pageSize = 50;\nexport const StorageSelected: Record<string, string | undefined> = {};\n\nexport const FilterLinkSelectList = (props: IFilterLinkSelectListProps) => {\n  const { shareId } = useContext(ShareViewContext);\n  const { field, value, onClick } = props;\n\n  const [search, setSearch] = useState<string>();\n\n  const queryFn = useCallback(\n    async ({ pageParam, queryKey }: { pageParam?: number; queryKey: readonly unknown[] }) => {\n      const res = await getShareViewLinkRecords(queryKey[1] as string, {\n        fieldId: queryKey[2] as string,\n        skip: (pageParam ?? 0) * pageSize,\n        take: pageSize,\n        search: queryKey[3] as string,\n      });\n      return res.data;\n    },\n    []\n  );\n\n  const selectedRecordIds = typeof value === 'string' ? [value] : value || undefined;\n\n  return (\n    <ApiRecordList\n      queryKey={ReactQueryKeys.shareViewLinkRecords(shareId!, field.id, search)}\n      pageSize={pageSize}\n      selectedRecordIds={selectedRecordIds}\n      queryFn={queryFn}\n      onSearch={setSearch}\n      onClick={({ id, title }) => {\n        StorageSelected[id] = title;\n        onClick(id);\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/filter-link/FilterLinkSelectTrigger.tsx",
    "content": "import type { FieldType } from '@teable/core';\nimport { SelectTag, type IFilterComponents } from '@teable/sdk/components';\nimport { useTranslation } from 'next-i18next';\nimport { shareConfig } from '@/features/i18n/share.config';\nimport { StorageSelected } from './FilterLinkSelectList';\n\nexport const FilterLinkSelectTrigger: IFilterComponents[FieldType.Link] = (props) => {\n  const { value } = props;\n  const { t } = useTranslation(shareConfig.i18nNamespaces);\n\n  if (!value) {\n    return <>{t('share:toolbar.filterLinkSelectPlaceholder')}</>;\n  }\n\n  const values = typeof value === 'string' ? [value] : value;\n\n  return (\n    <>\n      {values?.map((id) => (\n        <SelectTag\n          className=\"flex items-center\"\n          key={id}\n          label={StorageSelected[id] || t('sdk:common.unnamedRecord')}\n        />\n      ))}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/filter-link/index.ts",
    "content": "export * from './FilterLink';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/share/view/component/share-view-filter/index.ts",
    "content": "export * from './ShareViewFilter';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/BaseCard.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { Database, MoreHorizontal } from '@teable/icons';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport { PinType, deleteBase, permanentDeleteBase, updateBase } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Button, Card, CardContent, cn, Input } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useState, type FC, useRef } from 'react';\nimport { Emoji } from '../../components/emoji/Emoji';\nimport { EmojiPicker } from '../../components/emoji/EmojiPicker';\nimport { ColorBg } from './ColorBg';\nimport { BaseActionTrigger } from './component/BaseActionTrigger';\nimport { StarButton } from './space-side-bar/StarButton';\n\ninterface IBaseCard {\n  base: IGetBaseVo;\n  className?: string;\n  spaceName?: string;\n}\n\nexport const BaseCard: FC<IBaseCard> = (props) => {\n  const { base, className, spaceName } = props;\n  const queryClient = useQueryClient();\n  const [renaming, setRenaming] = useState<boolean>();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [baseName, setBaseName] = useState<string>(base.name);\n  const router = useRouter();\n\n  const { mutateAsync: updateBaseMutator } = useMutation({\n    mutationFn: updateBase,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseAll(),\n      });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.recentlyBase(),\n      });\n    },\n  });\n\n  const { mutate: deleteBaseMutator } = useMutation({\n    mutationFn: ({ baseId, permanent }: { baseId: string; permanent?: boolean }) =>\n      permanent ? permanentDeleteBase(baseId) : deleteBase(baseId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseAll(),\n      });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.recentlyBase(),\n      });\n    },\n  });\n\n  const toggleRenameBase = async () => {\n    if (baseName && baseName !== base.name) {\n      await updateBaseMutator({\n        baseId: base.id,\n        updateBaseRo: { name: baseName },\n      });\n    }\n    setTimeout(() => setRenaming(false), 200);\n  };\n\n  const onRename = () => {\n    setRenaming(true);\n    setTimeout(() => inputRef.current?.focus(), 200);\n  };\n\n  const clickStopPropagation = (e: React.MouseEvent) => {\n    e.stopPropagation();\n  };\n\n  const iconChange = (icon: string) => {\n    updateBaseMutator({\n      baseId: base.id,\n      updateBaseRo: { icon },\n    });\n  };\n\n  const intoBase = () => {\n    if (renaming) {\n      return;\n    }\n    router.push({\n      pathname: '/base/[baseId]',\n      query: {\n        baseId: base.id,\n      },\n    });\n  };\n\n  const hasUpdatePermission = base.restrictedAuthority\n    ? false\n    : hasPermission(base.role, 'base|update');\n  const hasDeletePermission = base.restrictedAuthority\n    ? false\n    : hasPermission(base.role, 'base|delete');\n  const hasMovePermission = base.restrictedAuthority\n    ? false\n    : hasPermission(base.role, 'space|create');\n\n  return (\n    <Card\n      className={cn(\n        'relative group cursor-pointer hover:shadow-md overflow-x-hidden shadow-none',\n        className\n      )}\n      onClick={intoBase}\n    >\n      <ColorBg emoji={base.icon || undefined} />\n      <CardContent className=\"relative flex size-full items-center gap-3 px-4 py-0\">\n        <div onClick={(e) => hasUpdatePermission && clickStopPropagation(e)}>\n          <EmojiPicker disabled={!hasUpdatePermission || renaming} onChange={iconChange}>\n            <div className=\"size-12 rounded-lg bg-background bg-gradient-to-br from-background to-muted p-3 outline outline-1 outline-border transition-all group-hover:outline-border hover:shadow-lg\">\n              {base.icon ? <Emoji emoji={base.icon} size={24} /> : <Database className=\"size-6\" />}\n            </div>\n          </EmojiPicker>\n        </div>\n        <div className=\"flex grow\">\n          <div className=\"flex grow justify-between\">\n            {renaming ? (\n              <form\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  toggleRenameBase();\n                }}\n              >\n                <Input\n                  ref={inputRef}\n                  className=\"flex-1\"\n                  size=\"sm\"\n                  value={baseName}\n                  onChange={(e) => setBaseName(e.target.value)}\n                  onBlur={toggleRenameBase}\n                  onClick={clickStopPropagation}\n                  onMouseDown={clickStopPropagation}\n                />\n              </form>\n            ) : (\n              <div className=\"flex-1\">\n                <h3 className=\"line-clamp-2 text-sm\" title={base.name}>\n                  {base.name}\n                </h3>\n                {spaceName && (\n                  <p className=\"mt-0.5 truncate text-xs text-muted-foreground\" title={spaceName}>\n                    {spaceName}\n                  </p>\n                )}\n              </div>\n            )}\n          </div>\n          <div className=\"absolute right-0 top-1 flex gap-2 px-1 md:opacity-0 md:group-hover:opacity-100\">\n            <StarButton\n              className=\"size-6 rounded-full bg-gray-100/50 p-1 shadow backdrop-blur-sm transition-colors hover:bg-gray-200/80\"\n              id={base.id}\n              type={PinType.Base}\n            />\n            <div\n              className=\"shrink-0\"\n              onClick={(e) => e.stopPropagation()}\n              onMouseDown={(e) => e.stopPropagation()}\n            >\n              <BaseActionTrigger\n                base={base}\n                showRename={hasUpdatePermission}\n                showDuplicate={hasUpdatePermission}\n                showDelete={hasDeletePermission}\n                showExport={hasUpdatePermission}\n                showMove={hasMovePermission}\n                onDelete={(permanent) => deleteBaseMutator({ baseId: base.id, permanent })}\n                onRename={onRename}\n              >\n                <Button\n                  variant=\"ghost\"\n                  size={'xs'}\n                  className=\"size-6 rounded-full bg-gray-100/50 p-1 shadow backdrop-blur-sm transition-colors hover:bg-gray-200/80\"\n                >\n                  <MoreHorizontal className=\"size-4\" />\n                </Button>\n              </BaseActionTrigger>\n            </div>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { hasPermission } from '@teable/core';\nimport {\n  ChevronDown,\n  ChevronRight,\n  Database,\n  DraggableHandle,\n  MoreHorizontal,\n} from '@teable/icons';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport { PinType } from '@teable/openapi';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Avatar, AvatarFallback, AvatarImage, Button, cn, Input } from '@teable/ui-lib/shadcn';\nimport { ArrowRight } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { useState, useRef, useEffect } from 'react';\nimport { useClickAway } from 'react-use';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { Emoji } from '../../components/emoji/Emoji';\nimport { EmojiPicker } from '../../components/emoji/EmojiPicker';\nimport { BaseActionTrigger } from './component/BaseActionTrigger';\nimport { StarButton } from './space-side-bar/StarButton';\nimport { usePinMap } from './usePinMap';\n\nexport interface IBaseItemProps {\n  base: IGetBaseVo;\n  lastVisitTime?: string;\n  className?: string;\n  isExpanded?: boolean;\n  showDragHandle?: boolean;\n  dragHandleListeners?: Record<string, unknown>;\n  onToggleExpand?: () => void;\n  onEnterBase?: () => void;\n  onUpdate?: (data: { name?: string; icon?: string }) => void;\n  onDelete?: (permanent?: boolean) => void;\n}\n\nexport const BaseItem: FC<IBaseItemProps> = (props) => {\n  const {\n    base,\n    lastVisitTime,\n    className,\n    isExpanded = false,\n    showDragHandle = false,\n    dragHandleListeners,\n    onToggleExpand,\n    onEnterBase,\n    onUpdate,\n    onDelete,\n  } = props;\n  const dayjs = useLanDayjs();\n  const pinMap = usePinMap();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isEditing, setIsEditing] = useState(false);\n  const [editingValue, setEditingValue] = useState('');\n\n  const hasUpdatePermission = !base.restrictedAuthority && hasPermission(base.role, 'base|update');\n  const hasDeletePermission = !base.restrictedAuthority && hasPermission(base.role, 'base|delete');\n  const hasMovePermission = !base.restrictedAuthority && hasPermission(base.role, 'space|create');\n\n  const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();\n\n  const startEditing = () => {\n    setIsEditing(true);\n    setEditingValue(base.name);\n  };\n\n  const finishEditing = () => {\n    if (!isEditing) return;\n    if (editingValue && editingValue !== base.name) {\n      onUpdate?.({ name: editingValue });\n    }\n    setIsEditing(false);\n    setEditingValue('');\n  };\n\n  useEffect(() => {\n    const timeout = setTimeout(() => {\n      if (isEditing && inputRef.current) {\n        inputRef.current.focus();\n        inputRef.current.select();\n      }\n    }, 200);\n    return () => clearTimeout(timeout);\n  }, [isEditing]);\n\n  useClickAway(inputRef, finishEditing);\n\n  return (\n    <div\n      className={cn(\n        'relative group flex h-12 items-center cursor-pointer hover:bg-accent dark:hover:bg-popover',\n        className\n      )}\n      onClick={() => onToggleExpand?.()}\n    >\n      {showDragHandle && (\n        <div\n          className=\"flex w-6 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing\"\n          onClick={stopPropagation}\n          {...dragHandleListeners}\n        >\n          <DraggableHandle className=\"size-3.5 text-muted-foreground opacity-0 group-hover:opacity-100\" />\n        </div>\n      )}\n      <Button\n        variant=\"ghost\"\n        size=\"xs\"\n        className=\"size-4 shrink-0 p-0\"\n        onClick={(e) => {\n          stopPropagation(e);\n          onToggleExpand?.();\n        }}\n      >\n        {isExpanded ? (\n          <ChevronDown className=\"size-4 text-muted-foreground\" />\n        ) : (\n          <ChevronRight className=\"size-4 text-muted-foreground\" />\n        )}\n      </Button>\n      {/* Name Column */}\n      <div className=\"flex h-8 w-full flex-1 items-center gap-2 overflow-hidden px-2\">\n        <div\n          className=\"flex items-center\"\n          onClick={(e) => hasUpdatePermission && stopPropagation(e)}\n        >\n          <EmojiPicker\n            className=\"flex items-center justify-center\"\n            disabled={!hasUpdatePermission || isEditing}\n            onChange={(icon) => onUpdate?.({ icon })}\n          >\n            {base.icon ? <Emoji emoji={base.icon} size=\"1rem\" /> : <Database className=\"size-4\" />}\n          </EmojiPicker>\n        </div>\n\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          {isEditing ? (\n            <Input\n              ref={inputRef}\n              className=\"size-full\"\n              value={editingValue}\n              onClick={stopPropagation}\n              onMouseDown={stopPropagation}\n              onChange={(e) => setEditingValue(e.target.value)}\n              onBlur={finishEditing}\n              onKeyDown={(e) => {\n                e.stopPropagation();\n                if (e.key === 'Enter') finishEditing();\n                if (e.key === 'Escape') {\n                  setIsEditing(false);\n                  setEditingValue('');\n                }\n              }}\n            />\n          ) : (\n            <>\n              <div\n                className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-2\"\n                title={base.name}\n              >\n                <span className=\"truncate text-sm font-medium\">{base.name}</span>\n                <StarButton\n                  className={cn(\n                    'size-4 w-0 shrink-0 opacity-0 group-hover:w-auto group-hover:opacity-100',\n                    {\n                      'w-auto opacity-100': pinMap?.[base.id],\n                    }\n                  )}\n                  id={base.id}\n                  type={PinType.Base}\n                />\n              </div>\n\n              <Button\n                variant=\"outline\"\n                size=\"xs\"\n                className=\"hidden h-7 w-0 shrink-0 gap-1 opacity-0 group-hover:w-auto sm:flex sm:group-hover:opacity-100\"\n                onClick={(e) => {\n                  stopPropagation(e);\n                  onEnterBase?.();\n                }}\n              >\n                <ArrowRight className=\"size-4\" />\n                {t('space:baseList.enter')}\n              </Button>\n            </>\n          )}\n        </div>\n      </div>\n\n      {/* Creator Column */}\n      <div className=\"hidden w-10 shrink-0 items-center gap-2 px-2 sm:flex sm:w-24\">\n        <Avatar className=\"size-6 border\">\n          <AvatarImage src={base.createdUser?.avatar ?? ''} />\n          <AvatarFallback className=\"text-xs\">{base.createdUser?.name?.slice(0, 1)}</AvatarFallback>\n        </Avatar>\n        <span className=\"hidden truncate text-xs sm:block\" title={base.createdUser?.name}>\n          {base.createdUser?.name}\n        </span>\n      </div>\n\n      {/* Created Time Column */}\n      <div className=\"hidden w-24 shrink-0 truncate px-2 text-xs sm:flex\">\n        {base.createdTime ? dayjs(base.createdTime).fromNow() : '-'}\n      </div>\n\n      {/* Last Opened Column */}\n      <div className=\"hidden w-32 shrink-0 truncate px-2 text-xs sm:flex\">\n        {lastVisitTime ? dayjs(lastVisitTime).fromNow() : '-'}\n      </div>\n\n      {/* Actions Column */}\n      <div\n        className=\"absolute right-0 flex shrink-0 items-center gap-2 bg-accent px-4 opacity-0 group-hover:opacity-100 dark:bg-popover\"\n        onClick={stopPropagation}\n        onMouseDown={stopPropagation}\n      >\n        <Button\n          variant=\"outline\"\n          size=\"xs\"\n          className=\"h-7 gap-1 sm:hidden\"\n          onClick={(e) => {\n            stopPropagation(e);\n            onEnterBase?.();\n          }}\n        >\n          <ArrowRight className=\"size-4\" />\n          {t('space:baseList.enter')}\n        </Button>\n\n        <BaseActionTrigger\n          base={base}\n          showRename={hasUpdatePermission}\n          showDuplicate={hasUpdatePermission}\n          showDelete={hasDeletePermission}\n          showExport={hasUpdatePermission}\n          showMove={hasMovePermission}\n          onDelete={onDelete}\n          onRename={startEditing}\n        >\n          <Button variant=\"outline\" size=\"icon-xs\">\n            <MoreHorizontal className=\"size-4\" />\n          </Button>\n        </BaseActionTrigger>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { ChevronDown, Clock4, LayoutList } from '@teable/icons';\nimport {\n  deleteBase,\n  getSpaceById,\n  permanentDeleteBase,\n  updateBase,\n  updateBaseOrder,\n  type IGetBaseVo,\n  type IGetBaseAllVo,\n} from '@teable/openapi';\nimport { LocalStorageKeys, ReactQueryKeys } from '@teable/sdk/config';\nimport { AnchorContext } from '@teable/sdk/context';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport type { DragEndEvent } from '@teable/ui-lib/base';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Collapsible,\n  CollapsibleContent,\n  ScrollArea,\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  Skeleton,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { keyBy } from 'lodash';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useMemo, useCallback } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { BaseNodeProvider } from '../base/base-node/BaseNodeProvider';\nimport { getNodeUrl } from '../base/base-node/hooks';\nimport { BaseNodeTree } from '../base/base-side-bar/BaseNodeTree';\nimport { useLastVisitBase } from '../base/hooks';\nimport { BaseItem } from './BaseItem';\nimport { DraggableBaseRows } from './DraggableBaseRows';\nimport { useBaseList } from './useBaseList';\n\nenum ViewMode {\n  Recent = 'recent',\n  Manual = 'manual',\n}\n\ninterface IBaseListProps {\n  baseIds: string[];\n  spaceId?: string;\n  showToolbar?: boolean;\n}\n\nexport const BaseList = (props: IBaseListProps) => {\n  const { baseIds, spaceId, showToolbar = false } = props;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const [expandedBases, setExpandedBases] = useState<Set<string>>(new Set());\n\n  const isHydrated = useIsHydrated();\n  const [viewModeMap, setViewModeMap] = useLocalStorage<Record<string, ViewMode>>(\n    LocalStorageKeys.SpaceBaseListViewMode,\n    {}\n  );\n  const viewMode = viewModeMap?.[spaceId || ''] ?? ViewMode.Recent;\n  const isManual = isHydrated && viewMode === ViewMode.Manual;\n\n  const setViewMode = useCallback(\n    (mode: ViewMode) => {\n      if (!spaceId) return;\n      setViewModeMap((prev) => ({\n        ...prev,\n        [spaceId]: mode,\n      }));\n    },\n    [spaceId, setViewModeMap]\n  );\n\n  const allBaseList = useBaseList();\n  const { map: lastVisitBaseMap = {} } = useLastVisitBase();\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId!),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n    enabled: !!spaceId,\n  });\n  const canReorder = space && hasPermission(space.role, 'base|update');\n\n  const { mutate: updateOrder } = useMutation({\n    mutationFn: updateBaseOrder,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() });\n    },\n  });\n\n  const allBaseMap = useMemo(() => {\n    return keyBy(allBaseList, 'id');\n  }, [allBaseList]);\n\n  // Get all bases in their stored order (backend returns sorted by base.order)\n  const orderedBases = useMemo(() => {\n    return baseIds\n      .map((baseId) => {\n        const base = allBaseMap[baseId];\n        if (!base) return null;\n        return {\n          ...base,\n          lastVisitTime: lastVisitBaseMap[baseId]?.lastVisitTime,\n        };\n      })\n      .filter((item) => item !== null) as (IGetBaseVo & { lastVisitTime?: string })[];\n  }, [baseIds, allBaseMap, lastVisitBaseMap]);\n\n  // Sort bases by recent visit time\n  const sortedRecentList = useMemo(() => {\n    const withTime = baseIds\n      .map((baseId) => {\n        const base = allBaseMap[baseId];\n        if (!base) return null;\n        const lastVisitTime = lastVisitBaseMap[baseId]?.lastVisitTime;\n\n        return {\n          ...base,\n          lastVisitTime,\n        };\n      })\n      .filter((item) => item !== null) as (IGetBaseVo & { lastVisitTime?: string })[];\n\n    /**\n     * 1. Both have lastVisitTime: compare by lastVisitTime (recent first)\n     * 2. One has lastVisitTime: prioritize the one with lastVisitTime\n     * 3. Both have lastModifiedTime: compare by lastModifiedTime (recent first)\n     * 4. One has lastModifiedTime: prioritize the one with lastModifiedTime\n     * 5. Finally, sort by createdTime (recent first)\n     */\n    return withTime.sort((a, b) => {\n      if (a.lastVisitTime && b.lastVisitTime) {\n        return new Date(b.lastVisitTime).getTime() - new Date(a.lastVisitTime).getTime();\n      }\n\n      if (a.lastVisitTime && !b.lastVisitTime) return -1;\n      if (!a.lastVisitTime && b.lastVisitTime) return 1;\n\n      if (a.lastModifiedTime && b.lastModifiedTime) {\n        return new Date(b.lastModifiedTime).getTime() - new Date(a.lastModifiedTime).getTime();\n      }\n\n      if (a.lastModifiedTime && !b.lastModifiedTime) return -1;\n      if (!a.lastModifiedTime && b.lastModifiedTime) return 1;\n\n      const aCreated = a.createdTime ? new Date(a.createdTime).getTime() : 0;\n      const bCreated = b.createdTime ? new Date(b.createdTime).getTime() : 0;\n      return bCreated - aCreated;\n    });\n  }, [baseIds, allBaseMap, lastVisitBaseMap]);\n\n  // Get list based on view mode\n  const currentList = viewMode === ViewMode.Manual ? orderedBases : sortedRecentList;\n\n  const { mutate: updateBaseMutator } = useMutation({\n    mutationFn: updateBase,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.recentlyBase() });\n    },\n  });\n\n  const { mutate: deleteBaseMutator } = useMutation({\n    mutationFn: ({ baseId, permanent }: { baseId: string; permanent?: boolean }) =>\n      permanent ? permanentDeleteBase(baseId) : deleteBase(baseId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.recentlyBase() });\n    },\n  });\n\n  const intoBase = (baseId: string) => {\n    router.push(`/base/${baseId}`);\n  };\n\n  const toggleExpanded = (baseId: string) => {\n    setExpandedBases((prev) => {\n      const next = new Set(prev);\n      next.has(baseId) ? next.delete(baseId) : next.add(baseId);\n      return next;\n    });\n  };\n\n  // Handle drag end for manual order view reordering\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n\n    if (!over || from === to || from === undefined || to === undefined) {\n      return;\n    }\n\n    const draggedBase = currentList[from];\n\n    // 1. Compute the reordered list to get the expected final result\n    const reorderedList = [...currentList];\n    const [removed] = reorderedList.splice(from, 1);\n    reorderedList.splice(to, 0, removed);\n\n    // 2. Determine anchor and position based on the reordered list\n    const newIndex = reorderedList.findIndex((b) => b.id === draggedBase.id);\n    const anchorId = newIndex === 0 ? reorderedList[1].id : reorderedList[newIndex - 1].id;\n    const position = newIndex === 0 ? 'before' : 'after';\n\n    // 3. Call API\n    updateOrder({\n      baseId: draggedBase.id,\n      anchorId,\n      position,\n    });\n\n    // 4. Optimistic update for baseAll query\n    queryClient.setQueryData(ReactQueryKeys.baseAll(), (prev: IGetBaseAllVo | undefined) => {\n      if (!prev) return [];\n      const newList = [...prev];\n      const draggedIndex = newList.findIndex((b) => b.id === draggedBase.id);\n      const anchorIndex = newList.findIndex((b) => b.id === anchorId);\n      if (draggedIndex === -1 || anchorIndex === -1) return newList;\n\n      const [movedItem] = newList.splice(draggedIndex, 1);\n      // Adjust anchor index after removal, then insert based on position\n      const adjustedAnchorIndex = draggedIndex < anchorIndex ? anchorIndex - 1 : anchorIndex;\n      const insertIndex = position === 'after' ? adjustedAnchorIndex + 1 : adjustedAnchorIndex;\n      newList.splice(insertIndex, 0, movedItem);\n      return newList;\n    });\n  };\n\n  const renderBaseRow = (\n    base: IGetBaseVo & { lastVisitTime?: string },\n    options?: {\n      showDragHandle?: boolean;\n      isDragging?: boolean;\n      listeners?: Record<string, unknown>;\n    }\n  ) => {\n    const showDragHandle = options?.showDragHandle && canReorder;\n\n    return (\n      <Collapsible\n        key={base.id}\n        open={expandedBases.has(base.id)}\n        onOpenChange={() => toggleExpanded(base.id)}\n      >\n        <BaseItem\n          base={base}\n          lastVisitTime={lastVisitBaseMap[base.id]?.lastVisitTime}\n          isExpanded={expandedBases.has(base.id)}\n          showDragHandle={showDragHandle}\n          dragHandleListeners={showDragHandle ? options?.listeners : undefined}\n          onToggleExpand={() => toggleExpanded(base.id)}\n          onEnterBase={() => intoBase(base.id)}\n          onUpdate={(data) => updateBaseMutator({ baseId: base.id, updateBaseRo: data })}\n          onDelete={(permanent) => deleteBaseMutator({ baseId: base.id, permanent })}\n        />\n        <CollapsibleContent>\n          <AnchorContext.Provider value={{ baseId: base.id }}>\n            <BaseNodeProvider isRestrictedAuthority={base.restrictedAuthority}>\n              <div className={cn('bg-muted', isManual ? 'px-8' : 'px-2')}>\n                <BaseNodeTree\n                  mode=\"view\"\n                  emptyText={t('space:baseList.noTables')}\n                  skeleton={\n                    <div className=\"flex w-full flex-col items-center justify-center gap-2 p-2\">\n                      <Spin className=\"size-4\" />\n                    </div>\n                  }\n                  onPrimaryAction={(item) => {\n                    const node = item.getItemData();\n                    const { resourceType, resourceId } = node;\n                    const url = getNodeUrl({\n                      baseId: base.id,\n                      resourceType,\n                      resourceId,\n                    });\n                    if (url) {\n                      router.push(url);\n                    }\n                  }}\n                />\n              </div>\n            </BaseNodeProvider>\n          </AnchorContext.Provider>\n        </CollapsibleContent>\n      </Collapsible>\n    );\n  };\n  return (\n    <ScrollArea className=\"h-full !border-none bg-background [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-h-0 [&>[data-radix-scroll-area-viewport]>div]:!min-w-0\">\n      {/* Toolbar: View Mode Select */}\n      {showToolbar && (\n        <div className=\"sticky top-0 z-10 flex items-center gap-4 bg-background\">\n          {isHydrated ? (\n            <Select value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>\n              <SelectTrigger className=\"w-auto gap-1 border-none bg-transparent hover:bg-accent hover:text-accent-foreground dark:bg-transparent [&>svg]:hidden\">\n                <div className=\"flex items-center gap-1\">\n                  {viewMode === ViewMode.Recent ? (\n                    <Clock4 className=\"size-3.5\" />\n                  ) : (\n                    <LayoutList className=\"size-3.5\" />\n                  )}\n                  <span>\n                    {viewMode === ViewMode.Recent\n                      ? t('space:baseList.recent')\n                      : t('space:baseList.manual')}\n                  </span>\n                  <ChevronDown className=\"size-4 opacity-50\" />\n                </div>\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"recent\">\n                  <div className=\"flex items-center gap-1\">\n                    <Clock4 className=\"size-3.5\" />\n                    {t('space:baseList.recent')}\n                  </div>\n                </SelectItem>\n                <SelectItem value=\"manual\">\n                  <div className=\"flex items-center gap-1\">\n                    <LayoutList className=\"size-3.5\" />\n                    {t('space:baseList.manual')}\n                  </div>\n                </SelectItem>\n              </SelectContent>\n            </Select>\n          ) : (\n            <Skeleton className=\"h-6 w-24\" />\n          )}\n        </div>\n      )}\n\n      {/* Header */}\n      <div\n        className={cn(\n          'sticky z-10 flex h-8 items-center border-b bg-background text-xs font-medium text-muted-foreground',\n          showToolbar ? 'top-8' : 'top-0'\n        )}\n      >\n        <div className=\"flex-1 truncate pl-6 pr-2\">{t('space:baseList.allBases')}</div>\n        <div className=\"hidden shrink-0 px-2 sm:block sm:w-24\">{t('space:baseList.owner')}</div>\n        <div className=\"hidden w-24 shrink-0 px-2 sm:block\">{t('space:baseList.createdTime')}</div>\n        <div className=\"hidden w-32 shrink-0 px-2 sm:block\">{t('space:baseList.lastOpened')}</div>\n      </div>\n\n      {/* Rows */}\n      {!isHydrated ? (\n        <div className=\"divide-y\">\n          {[1, 2, 3].map((i) => (\n            <div key={i} className=\"flex h-12 items-center px-6\">\n              <Skeleton className=\"h-4 w-48\" />\n            </div>\n          ))}\n        </div>\n      ) : isManual ? (\n        <DraggableBaseRows\n          items={currentList}\n          onDragEnd={onDragEndHandler}\n          renderRow={renderBaseRow}\n        />\n      ) : (\n        <div className=\"divide-y\">{currentList.map((base) => renderBaseRow(base))}</div>\n      )}\n\n      {/* Empty state */}\n      {isHydrated && currentList.length === 0 && (\n        <div className=\"flex h-40 items-center justify-center text-muted-foreground\">\n          {t('space:baseList.empty')}\n        </div>\n      )}\n    </ScrollArea>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/ColorBg.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { getEmojiColor } from '@/lib/emoji-color';\n\nconst colorCache: Record<string, string> = {};\n\nexport function ColorBg({ emoji }: { emoji?: string }) {\n  const [color, setColor] = useState<string>('');\n\n  useEffect(() => {\n    if (!emoji) return;\n\n    if (colorCache[emoji]) {\n      setColor(colorCache[emoji]);\n      return;\n    }\n\n    try {\n      const emojiColor = getEmojiColor(emoji);\n      colorCache[emoji] = emojiColor;\n      setColor(emojiColor);\n    } catch (error) {\n      console.error('Error calculating emoji color:', error);\n      setColor('#0000001A');\n    }\n  }, [emoji]);\n\n  if (!emoji) {\n    return (\n      <div className=\"absolute inset-0 z-0 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100\"></div>\n    );\n  }\n\n  return (\n    <div\n      className=\"absolute inset-0 z-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100\"\n      style={{\n        background: color\n          ? `linear-gradient(to right, ${color}1A, ${color}0A, transparent)`\n          : undefined,\n      }}\n    ></div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/DraggableBaseGrid.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IGetBaseAllVo } from '@teable/openapi';\nimport { updateBaseOrder } from '@teable/openapi';\nimport { useIsHydrated } from '@teable/sdk';\nimport { DndKitContext, Droppable, Draggable } from '@teable/ui-lib/base';\nimport type { DragEndEvent } from '@teable/ui-lib/base';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useEffect, useState } from 'react';\nimport { BaseCard } from './BaseCard';\n\ninterface IDraggableBaseGridProps {\n  className?: string;\n  bases: IGetBaseAllVo;\n}\n\nconst DraggableBaseGrid = (props: IDraggableBaseGridProps) => {\n  const { bases, className } = props;\n  const queryClient = useQueryClient();\n  const isHydrated = useIsHydrated();\n  const [innerBases, setInnerBases] = useState<IGetBaseAllVo>(bases);\n\n  useEffect(() => {\n    setInnerBases(bases);\n  }, [bases]);\n\n  const { mutateAsync: updateBaseFn } = useMutation({\n    mutationFn: updateBaseOrder,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ['base-list'],\n      });\n    },\n  });\n\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n\n    if (!over || !innerBases || from === to) {\n      return;\n    }\n\n    const list = [...innerBases];\n    const [base] = list.splice(from, 1);\n\n    list.splice(to, 0, base);\n\n    setInnerBases(list);\n\n    const baseIndex = list.findIndex((v) => v.id === base.id);\n\n    if (baseIndex == 0) {\n      await updateBaseFn({ baseId: base.id, anchorId: list[1].id, position: 'before' });\n    } else {\n      await updateBaseFn({ baseId: base.id, anchorId: list[baseIndex - 1].id, position: 'after' });\n    }\n  };\n\n  return isHydrated ? (\n    <div\n      className={cn(\n        'grid grid-cols-[repeat(auto-fill,minmax(min(100%,17rem),1fr))] gap-3',\n        className\n      )}\n    >\n      <DndKitContext onDragEnd={onDragEndHandler}>\n        <Droppable items={innerBases.map(({ id }) => id)}>\n          {innerBases.map((base) => (\n            <Draggable key={base.id} id={base.id}>\n              {({ setNodeRef, attributes, listeners, style }) => (\n                <div ref={setNodeRef} {...attributes} {...listeners} style={style}>\n                  <BaseCard\n                    key={base.id}\n                    className=\"h-20 max-w-[34rem] flex-1 sm:min-w-[17rem]\"\n                    base={base}\n                  />\n                </div>\n              )}\n            </Draggable>\n          ))}\n        </Droppable>\n      </DndKitContext>\n    </div>\n  ) : (\n    <div className=\"grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3\">\n      {innerBases.map((base) => (\n        <div key={base.id}>\n          <BaseCard\n            key={base.id}\n            className=\"h-20 max-w-[34rem] flex-1 sm:min-w-[17rem]\"\n            base={base}\n          />\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport { DraggableBaseGrid };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/DraggableBaseRows.tsx",
    "content": "import type { IGetBaseVo } from '@teable/openapi';\nimport type { DragEndEvent } from '@teable/ui-lib/base';\nimport {\n  DndKitContext,\n  Draggable,\n  Droppable,\n  verticalListSortingStrategy,\n} from '@teable/ui-lib/base';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\n\ninterface IDraggableBaseRowsProps {\n  className?: string;\n  items: (IGetBaseVo & { lastVisitTime?: string })[];\n  onDragEnd: (event: DragEndEvent) => void;\n  renderRow: (\n    base: IGetBaseVo & { lastVisitTime?: string },\n    options: { showDragHandle: boolean; isDragging?: boolean; listeners?: Record<string, unknown> }\n  ) => React.ReactNode;\n}\n\nexport const DraggableBaseRows = (props: IDraggableBaseRowsProps) => {\n  const { className, items, onDragEnd, renderRow } = props;\n  const [activeId, setActiveId] = useState<string | null>(null);\n\n  const activeItem = activeId ? items.find((item) => item.id === activeId) : null;\n\n  return (\n    <DndKitContext\n      onDragStart={(event) => setActiveId(String(event.active.id))}\n      onDragEnd={(event) => {\n        onDragEnd(event);\n        setActiveId(null);\n      }}\n      onDragCancel={() => setActiveId(null)}\n    >\n      <Droppable items={items.map((base) => base.id)} strategy={verticalListSortingStrategy}>\n        <div className={cn('divide-y', className)}>\n          {items.map((base) => (\n            <Draggable key={base.id} id={base.id}>\n              {({ setNodeRef, attributes, listeners, style, isDragging }) => (\n                <div\n                  ref={setNodeRef}\n                  {...attributes}\n                  style={style}\n                  className={cn(\n                    'transition-opacity duration-200',\n                    isDragging && 'opacity-40 bg-muted'\n                  )}\n                >\n                  {renderRow(base, { showDragHandle: true, isDragging, listeners })}\n                </div>\n              )}\n            </Draggable>\n          ))}\n        </div>\n      </Droppable>\n    </DndKitContext>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/FreshSettingGuideDialog.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Button, Dialog, DialogContent } from '@teable/ui-lib/shadcn';\nimport dayjs from 'dayjs';\nimport Image from 'next/image';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { useBrand } from '../../hooks/useBrand';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useSetting } from '../../hooks/useSetting';\n\nconst FRESH_INSTANCE_WINDOW_HOURS = 4;\n\nexport const FreshSettingGuideDialog = () => {\n  const isCloud = useIsCloud();\n  const {\n    user: { isAdmin },\n  } = useSession();\n\n  const { createdTime } = useSetting();\n  const isNewInstance =\n    createdTime && dayjs().isBefore(dayjs(createdTime).add(FRESH_INSTANCE_WINDOW_HOURS, 'hour'));\n\n  const [freshAdmin, setFreshAdmin] = useLocalStorage('freshAdmin', true);\n  const showGuideModal = Boolean(freshAdmin && isAdmin && !isCloud && isNewInstance);\n  const [isModalOpen, setIsModalOpen] = useState(showGuideModal);\n  const { t } = useTranslation('common');\n\n  const router = useRouter();\n  const { brandName } = useBrand();\n\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n\n  if (!showGuideModal) return null;\n\n  return (\n    <div className=\"fixed inset-0 z-50\">\n      <Dialog\n        open={isModalOpen}\n        onOpenChange={(open) => {\n          if (!open) {\n            setFreshAdmin(false);\n          }\n          setIsModalOpen(open);\n        }}\n      >\n        <DialogContent\n          className=\"flex h-[482px] w-[560px] flex-col justify-between p-10\"\n          onInteractOutside={(e) => e.preventDefault()}\n          onEscapeKeyDown={(e) => e.preventDefault()}\n        >\n          <div className=\"flex flex-col items-center\">\n            <Image\n              src={isDark ? '/images/layout/welcome-dark.png' : '/images/layout/welcome-light.png'}\n              alt=\"Init setting guide\"\n              width={240}\n              height={240}\n            />\n            <h1 className=\"text-base-foreground justify-start self-stretch pt-4 text-center font-['Inter'] text-xl font-semibold leading-7\">\n              {t('admin.tips.thankYouForUsingTeable', { brandName })}\n            </h1>\n            <p className=\"justify-start self-stretch pt-[6px] text-center font-['Inter'] text-sm font-normal leading-tight text-muted-foreground\">\n              {t('admin.tips.pleaseGoToConfiguration')}\n            </p>\n          </div>\n          <Button\n            onClick={() => {\n              router.push('/admin/setting');\n              setFreshAdmin(false);\n            }}\n            className=\"h-[44px] w-[194px] self-center\"\n          >\n            {t('admin.action.goToConfiguration')}\n          </Button>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/NoBasesPlaceholder.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { getUniqName, hasPermission } from '@teable/core';\nimport { useTheme } from '@teable/next-themes';\nimport { createBase } from '@teable/openapi';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useBaseList } from './useBaseList';\n\ninterface INoBasesPlaceholderProps {\n  space: IGetSpaceVo;\n}\n\nexport const NoBasesPlaceholder: FC<INoBasesPlaceholderProps> = ({ space }) => {\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n\n  const { user } = useSession();\n  const router = useRouter();\n  const allBases = useBaseList();\n  const bases = allBases?.filter((base) => base.spaceId === space.id);\n\n  const { mutate: createBaseMutator, isPending: createBaseLoading } = useMutation({\n    mutationFn: createBase,\n    onSuccess: ({ data }) => {\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId: data.id },\n      });\n    },\n  });\n\n  const handleCreateBase = () => {\n    const name = getUniqName(t('common:noun.base'), bases?.map((base) => base.name) || []);\n    createBaseMutator({ spaceId: space.id, name });\n  };\n\n  const canCreateBase = hasPermission(space.role, 'base|create');\n\n  return (\n    <div className=\"flex h-full min-h-[60vh] flex-col items-center justify-center px-8\">\n      <Image\n        src={isDark ? '/images/layout/welcome-dark.png' : '/images/layout/welcome-light.png'}\n        alt=\"no bases\"\n        width={240}\n        height={240}\n      />\n\n      <div className=\"flex max-w-md flex-col items-center text-center\">\n        <h3 className=\"mb-2 mt-6 text-2xl font-semibold\">\n          {t('space:noBases.title', { userName: user.name })}\n        </h3>\n\n        <p className=\"mb-6 leading-relaxed text-muted-foreground\">\n          {t('space:noBases.description')}\n        </p>\n\n        {canCreateBase && (\n          <Button\n            onClick={handleCreateBase}\n            disabled={createBaseLoading}\n            size=\"lg\"\n            className=\"px-8\"\n          >\n            {createBaseLoading && <Spin />} {t('space:action.createBase')}\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/NoSpacesPlaceholder.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport { createSpace } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useSession } from '@teable/sdk/hooks';\nimport { ConfirmDialog, Spin } from '@teable/ui-lib/base';\nimport { Button, Input } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useSetting } from '../../hooks/useSetting';\nimport { useSpaceListOrdered } from './useSpaceListOrdered';\n\nexport const NoSpacesPlaceholder = () => {\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const { user } = useSession();\n  const { disallowSpaceCreation } = useSetting();\n  const spaceList = useSpaceListOrdered();\n  const [spaceName, setSpaceName] = useState('');\n  const [showCreateDialog, setShowCreateDialog] = useState(false);\n\n  const { mutate: createSpaceMutator, isPending: createSpaceLoading } = useMutation({\n    mutationFn: createSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n    },\n  });\n\n  const handleOpenCreateDialog = () => {\n    setShowCreateDialog(true);\n    setSpaceName('');\n  };\n\n  const handleCreateSpace = () => {\n    const name =\n      spaceName.trim() ||\n      getUniqName(t('noun.space'), spaceList?.length ? spaceList?.map((space) => space?.name) : []);\n    createSpaceMutator({ name });\n  };\n\n  return (\n    <div className=\"flex min-h-[60vh] flex-col items-center justify-center px-8\">\n      <div className=\"flex max-w-md flex-col items-center text-center\">\n        <h3 className=\"mb-2 text-2xl font-semibold\">\n          {t('space:noSpaces.title', { userName: user.name })}\n        </h3>\n\n        <p className=\"mb-8 leading-relaxed text-muted-foreground\">\n          {t('space:noSpaces.description')}\n        </p>\n\n        {!disallowSpaceCreation && (\n          <Button\n            onClick={handleOpenCreateDialog}\n            disabled={createSpaceLoading}\n            size=\"lg\"\n            className=\"mb-8 px-8\"\n          >\n            {createSpaceLoading && <Spin />} {t('space:action.createSpace')}\n          </Button>\n        )}\n\n        <div className=\"relative\">\n          <Image\n            src=\"/images/layout/pointer.png\"\n            alt=\"no spaces\"\n            width={120}\n            height={120}\n            className=\"opacity-80 dark:invert\"\n          />\n        </div>\n      </div>\n      <ConfirmDialog\n        open={showCreateDialog}\n        onOpenChange={setShowCreateDialog}\n        title={t('actions.create') + ' ' + t('noun.space')}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        confirmLoading={createSpaceLoading}\n        onCancel={() => {\n          setShowCreateDialog(false);\n          setSpaceName('');\n        }}\n        onConfirm={handleCreateSpace}\n        content={\n          <div className=\"space-y-2\">\n            <div className=\"flex flex-col gap-2\">\n              <Input\n                placeholder={getUniqName(\n                  t('noun.space'),\n                  spaceList?.length ? spaceList?.map((space) => space?.name) : []\n                )}\n                value={spaceName}\n                onChange={(e) => setSpaceName(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') {\n                    handleCreateSpace();\n                  }\n                }}\n              />\n            </div>\n          </div>\n        }\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/RecentlyBase.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getUserLastVisitListBase, getSpaceList, getSharedBase } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { BaseList } from './BaseList';\n\nexport const RecentlyBase = () => {\n  const { t } = useTranslation(['space']);\n\n  // Recently visited bases data\n  const { data: recentlyBase } = useQuery({\n    queryKey: ReactQueryKeys.recentlyBase(),\n    queryFn: () => getUserLastVisitListBase().then((res) => res.data),\n  });\n  const recentlyBases = useMemo(() => {\n    return recentlyBase?.list || [];\n  }, [recentlyBase]);\n\n  // Shared bases data\n  const { data: sharedBases } = useQuery({\n    queryKey: ReactQueryKeys.getSharedBase(),\n    queryFn: () => getSharedBase().then((res) => res.data),\n  });\n\n  // Don't render if neither recent nor shared bases exist\n  if (\n    (!recentlyBases?.length || recentlyBases?.length === 0) &&\n    (!sharedBases?.length || sharedBases?.length === 0)\n  ) {\n    return null;\n  }\n\n  return (\n    <Card className=\"w-full bg-muted/30 shadow-none\">\n      <Tabs defaultValue=\"recent\" className=\"w-full\">\n        <CardHeader className=\"pb-3 pt-5\">\n          <div className=\"flex items-center justify-between\">\n            <CardTitle>\n              <TabsList>\n                <TabsTrigger value=\"recent\">{t('space:recentlyBase.title')}</TabsTrigger>\n                <TabsTrigger value=\"shared\" className=\"relative\">\n                  {t('space:sharedBase.title')}\n                  {sharedBases && sharedBases.length > 0 && (\n                    <span className=\"absolute right-1 top-0 ml-2 text-xs font-medium text-muted-foreground\">\n                      {sharedBases.length}\n                    </span>\n                  )}\n                </TabsTrigger>\n              </TabsList>\n            </CardTitle>\n          </div>\n        </CardHeader>\n\n        <CardContent className=\"pt-0\">\n          <TabsContent value=\"recent\" className=\"mt-0\">\n            {!recentlyBases?.length || recentlyBases?.length === 0 ? (\n              <div className=\"flex items-center justify-center text-muted-foreground\">\n                {t('space:baseList.empty')}\n              </div>\n            ) : (\n              <div className=\"flex flex-col gap-2\">\n                <BaseList baseIds={recentlyBases.map((item) => item.resourceId)} />\n              </div>\n            )}\n          </TabsContent>\n\n          <TabsContent value=\"shared\" className=\"mt-0\">\n            {!sharedBases?.length || sharedBases?.length === 0 ? (\n              <div className=\"flex items-center justify-center text-muted-foreground\">\n                {t('space:sharedBase.empty')}\n              </div>\n            ) : (\n              <div className=\"flex flex-col gap-2\">\n                <BaseList baseIds={sharedBases.map((base) => base.id)} />\n              </div>\n            )}\n          </TabsContent>\n        </CardContent>\n      </Tabs>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { ChevronLeft } from '@teable/icons';\nimport { getSharedBase } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { BaseList } from './BaseList';\n\nexport const SharedBasePage = () => {\n  const { data: sharedBases } = useQuery({\n    queryKey: ReactQueryKeys.getSharedBase(),\n    queryFn: () => getSharedBase().then((res) => res.data),\n  });\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const router = useRouter();\n\n  const onBack = () => {\n    router.push({ pathname: '/space' });\n  };\n\n  return (\n    <div className=\"flex h-screen flex-1 flex-col space-y-4 overflow-hidden p-8\">\n      <div className=\"flex flex-col items-start justify-between gap-2\">\n        <Button\n          className=\"h-6 p-0 text-sm text-muted-foreground hover:no-underline hover:opacity-75\"\n          variant=\"link\"\n          onClick={onBack}\n        >\n          <ChevronLeft className=\"size-4\" />\n          <span>{t('common:settings.back')}</span>\n        </Button>\n        <h1 className=\"text-2xl font-semibold\">{t('space:sharedBase.title')}</h1>\n        <p className=\"shrink-0 grow-0 text-left text-sm text-zinc-500\">\n          {t('space:sharedBase.description')}\n        </p>\n      </div>\n      <div className=\"min-h-0 flex-1\">\n        {sharedBases && sharedBases.length > 0 ? (\n          <BaseList baseIds={sharedBases.map((base) => base.id)} />\n        ) : (\n          <p className=\"flex h-24 items-center justify-center text-xl text-muted-foreground\">\n            {t('space:sharedBase.empty')}\n          </p>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Role } from '@teable/core';\nimport type { IGetBaseVo, IGetSpaceVo, ISubscriptionSummaryVo } from '@teable/openapi';\nimport {\n  PinType,\n  deleteSpace,\n  permanentDeleteSpace,\n  updateSpace,\n  getSpaceCollaboratorList,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Card, CardContent, CardHeader, CardTitle } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { type FC, useEffect, useState } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { LevelWithUpgrade } from '../../components/billing/LevelWithUpgrade';\nimport { InviteSpacePopover } from '../../components/collaborator/space/InviteSpacePopover';\nimport { CollaboratorAvatars } from '../../components/space/CollaboratorAvatars';\nimport { SpaceActionBar } from '../../components/space/SpaceActionBar';\nimport { SpaceRenaming } from '../../components/space/SpaceRenaming';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { BaseList } from './BaseList';\nimport { StarButton } from './space-side-bar/StarButton';\n\ninterface ISpaceCard {\n  space: IGetSpaceVo;\n  bases?: IGetBaseVo[];\n  subscription?: ISubscriptionSummaryVo;\n  disallowSpaceInvitation?: boolean | null;\n}\nexport const SpaceCard: FC<ISpaceCard> = (props) => {\n  const { space, bases, subscription, disallowSpaceInvitation } = props;\n  const router = useRouter();\n  const isCloud = useIsCloud();\n  const queryClient = useQueryClient();\n  const [renaming, setRenaming] = useState<boolean>(false);\n  const [spaceName, setSpaceName] = useState<string>(space.name);\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  // Get all collaborators including those from bases\n  const { data: collaboratorsData } = useQuery({\n    queryKey: ReactQueryKeys.spaceCollaboratorList(space.id, {\n      skip: 0,\n      take: 100,\n      includeBase: true,\n    }),\n    queryFn: ({ queryKey }) =>\n      getSpaceCollaboratorList(queryKey[1], queryKey[2]).then((res) => res.data),\n  });\n\n  const collaborators = collaboratorsData?.collaborators || [];\n\n  const { mutate: deleteSpaceMutator } = useMutation({\n    mutationFn: deleteSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n    },\n  });\n\n  const { mutate: permanentDeleteSpaceMutator } = useMutation({\n    mutationFn: permanentDeleteSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n    },\n  });\n\n  const { mutateAsync: updateSpaceMutator } = useMutation({\n    mutationFn: updateSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.space(space.id) });\n    },\n  });\n\n  useEffect(() => setSpaceName(space?.name), [renaming, space?.name]);\n\n  const toggleUpdateSpace = async (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    const name = e.target.value;\n    if (!name || name === space.name) {\n      setRenaming(false);\n      return;\n    }\n    await updateSpaceMutator({\n      spaceId: space.id,\n      updateSpaceRo: { name },\n    });\n\n    setRenaming(false);\n  };\n\n  return (\n    <Card className=\"w-full bg-muted/30 shadow-none\">\n      <CardHeader className=\"pt-5\">\n        <div className=\"flex w-full items-center justify-between gap-3\">\n          <div className=\"group flex flex-1 items-center gap-2 overflow-hidden\">\n            <SpaceRenaming\n              spaceName={spaceName!}\n              isRenaming={renaming}\n              onChange={(e) => setSpaceName(e.target.value)}\n              onBlur={(e) => toggleUpdateSpace(e)}\n            >\n              <CardTitle className=\"truncate leading-5\" title={space.name}>\n                {space.name}\n              </CardTitle>\n            </SpaceRenaming>\n            <StarButton className=\"opacity-100\" id={space.id} type={PinType.Space} />\n            {isCloud && (\n              <LevelWithUpgrade\n                level={subscription?.level}\n                status={subscription?.status}\n                spaceId={space.id}\n                withUpgrade={space.role === Role.Owner}\n                organization={space?.organization}\n                appSumoTier={subscription?.appSumoTier}\n              />\n            )}\n            {!isCloud && space?.organization && (\n              <div className=\"text-sm text-gray-500\">{space.organization.name}</div>\n            )}\n          </div>\n          <SpaceActionBar\n            buttonSize=\"xs\"\n            space={space}\n            invQueryFilters={ReactQueryKeys.baseAll() as unknown as string[]}\n            disallowSpaceInvitation={disallowSpaceInvitation}\n            onDelete={() => deleteSpaceMutator(space.id)}\n            onPermanentDelete={() => permanentDeleteSpaceMutator(space.id)}\n            onRename={() => setRenaming(true)}\n          />\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {bases?.length ? (\n          <BaseList baseIds={bases.map((base) => base.id)} />\n        ) : (\n          <div className=\"flex h-24 w-full items-center justify-center\">\n            {t('space:spaceIsEmpty')}\n          </div>\n        )}\n\n        {collaborators.length > 0 && (\n          <InviteSpacePopover space={space}>\n            <div className=\"cursor-pointer\">\n              <CollaboratorAvatars collaborators={collaborators} maxDisplay={15} />\n            </div>\n          </InviteSpacePopover>\n        )}\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName, hasPermission, Role } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { useTheme } from '@teable/next-themes';\nimport {\n  createBase,\n  PinType,\n  deleteSpace,\n  getSpaceById,\n  getSubscriptionSummary,\n  permanentDeleteSpace,\n  updateSpace,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsMobile } from '@teable/sdk/hooks';\nimport { cn, ScrollArea } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport Image from 'next/image';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { SpaceInnerSettingModal, SettingTab } from '@overridable/SpaceInnerSettingModal';\nimport { LevelWithUpgrade } from '../../components/billing/LevelWithUpgrade';\nimport { Collaborators } from '../../components/collaborator-manage/space-inner/Collaborators';\nimport { SpaceActionBar } from '../../components/space/SpaceActionBar';\nimport { SpaceRenaming } from '../../components/space/SpaceRenaming';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useSetting } from '../../hooks/useSetting';\nimport { useTemplateMonitor } from '../base/duplicate/useTemplateMonitor';\nimport { BaseList } from './BaseList';\nimport { StarButton } from './space-side-bar/StarButton';\nimport { useBaseList } from './useBaseList';\n\nexport const SpaceInnerPage: React.FC = () => {\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const isCloud = useIsCloud();\n  useTemplateMonitor();\n  const ref = useRef<HTMLDivElement>(null);\n  const spaceId = router.query.spaceId as string;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const isMobile = useIsMobile();\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n\n  const [renaming, setRenaming] = useState<boolean>(false);\n  const [spaceName, setSpaceName] = useState<string>();\n  const [settingModalOpen, setSettingModalOpen] = useState(false);\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n  });\n\n  const bases = useBaseList();\n\n  const { disallowSpaceInvitation } = useSetting();\n\n  const basesInSpace = useMemo(() => {\n    return bases?.filter((base) => base.spaceId === spaceId);\n  }, [bases, spaceId]);\n\n  const { data: subscriptionSummary } = useQuery({\n    queryKey: ReactQueryKeys.subscriptionSummary(spaceId),\n    queryFn: () => getSubscriptionSummary(spaceId).then((res) => res.data),\n    enabled: isCloud,\n  });\n\n  const { mutate: deleteSpaceMutator } = useMutation({\n    mutationFn: deleteSpace,\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      router.push({\n        pathname: '/space',\n      });\n    },\n  });\n\n  const { mutate: permanentDeleteSpaceMutator } = useMutation({\n    mutationFn: permanentDeleteSpace,\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      router.push({\n        pathname: '/space',\n      });\n    },\n  });\n\n  const { mutateAsync: updateSpaceMutator } = useMutation({\n    mutationFn: updateSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.space(spaceId) });\n    },\n  });\n\n  const { mutate: createBaseMutator, isPending: createBaseLoading } = useMutation({\n    mutationFn: createBase,\n    onSuccess: ({ data }) => {\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId: data.id },\n      });\n    },\n  });\n\n  const handleCreateBase = () => {\n    const name = getUniqName(t('common:noun.base'), basesInSpace?.map((base) => base.name) || []);\n    createBaseMutator({ spaceId, name });\n  };\n\n  const canCreateBase = space && hasPermission(space.role, 'base|create');\n\n  useEffect(() => setSpaceName(space?.name), [renaming, space?.name]);\n\n  const toggleUpdateSpace = async (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    if (space) {\n      const name = e.target.value;\n      if (!name || name === space.name) {\n        setRenaming(false);\n        return;\n      }\n      await updateSpaceMutator({\n        spaceId: space.id,\n        updateSpaceRo: { name },\n      });\n    }\n    setRenaming(false);\n  };\n\n  const handleOpenUpgrade = useCallback(() => {\n    if (space?.role === Role.Owner) {\n      setSettingModalOpen(true);\n    }\n  }, [space?.role]);\n\n  const renderSubscription = () => {\n    if (space && isCloud) {\n      return (\n        <LevelWithUpgrade\n          level={subscriptionSummary?.level}\n          status={subscriptionSummary?.status}\n          spaceId={space.id}\n          withUpgrade={space.role === Role.Owner}\n          organization={space.organization}\n          onUpgradeClick={handleOpenUpgrade}\n          appSumoTier={subscriptionSummary?.appSumoTier}\n        />\n      );\n    }\n    return null;\n  };\n\n  const renderOrganization = () => {\n    if (!isCloud && space && space.organization) {\n      return <div className=\"text-sm text-gray-500\">{space.organization.name}</div>;\n    }\n    return null;\n  };\n\n  return (\n    space && (\n      <div ref={ref} className={cn('flex h-full min-w-0 flex-1 flex-col py-6 sm:min-w-[760px]')}>\n        <div\n          className={cn(\n            'flex shrink-0 px-8 items-start sm:items-center justify-between gap-4  sm:pb-4'\n          )}\n        >\n          {isMobile ? (\n            <div className=\"flex min-w-0 flex-col items-start justify-start gap-2\">\n              <div className=\"flex items-center justify-start gap-2 text-left\">\n                <SpaceRenaming\n                  spaceName={spaceName!}\n                  isRenaming={renaming}\n                  onChange={(e) => setSpaceName(e.target.value)}\n                  onBlur={(e) => toggleUpdateSpace(e)}\n                  className=\"h-8\"\n                >\n                  <h1 className=\"truncate text-2xl font-semibold\">{space.name}</h1>\n                </SpaceRenaming>\n              </div>\n              {renderSubscription()}\n              {renderOrganization()}\n            </div>\n          ) : (\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <SpaceRenaming\n                spaceName={spaceName!}\n                isRenaming={renaming}\n                onChange={(e) => setSpaceName(e.target.value)}\n                onBlur={(e) => toggleUpdateSpace(e)}\n                className=\"h-8\"\n              >\n                <h1 className=\"truncate text-2xl font-semibold\">{space.name}</h1>\n              </SpaceRenaming>\n              <StarButton className=\"opacity-100\" id={space.id} type={PinType.Space} />\n              {renderSubscription()}\n              {renderOrganization()}\n            </div>\n          )}\n\n          <SpaceActionBar\n            space={space}\n            buttonSize={'xs'}\n            invQueryFilters={ReactQueryKeys.baseAll() as unknown as string[]}\n            disallowSpaceInvitation={disallowSpaceInvitation}\n            onDelete={() => deleteSpaceMutator(space.id)}\n            onPermanentDelete={() => permanentDeleteSpaceMutator(space.id)}\n            onRename={() => setRenaming(true)}\n          />\n        </div>\n\n        <div className=\"flex min-h-0 flex-1 gap-8 px-4 pt-4 sm:px-8\">\n          <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n            {basesInSpace?.length ? (\n              <BaseList\n                key={spaceId}\n                baseIds={basesInSpace.map((base) => base.id)}\n                spaceId={spaceId}\n                showToolbar={true}\n              />\n            ) : (\n              <div className=\"flex min-h-0 flex-1 flex-col items-center justify-center gap-4\">\n                <Image\n                  src={\n                    isDark\n                      ? '/images/layout/empty-base-dark.png'\n                      : '/images/layout/empty-base-light.png'\n                  }\n                  alt=\"No bases available\"\n                  width={240}\n                  height={240}\n                />\n                <div className=\"flex flex-col items-center justify-center gap-2\">\n                  <p className=\"text-base font-semibold text-foreground\">\n                    {t('space:emptySpaceTitle')}\n                  </p>\n                  <p className=\"text-sm text-muted-foreground\">{t('space:spaceIsEmpty')}</p>\n                </div>\n                {canCreateBase && (\n                  <Button onClick={handleCreateBase} disabled={createBaseLoading}>\n                    <Plus className=\"size-4\" />\n                    {t('space:action.createBase')}\n                  </Button>\n                )}\n              </div>\n            )}\n          </div>\n\n          <div className=\"hidden w-[200px] min-w-[200px] flex-col sm:flex\">\n            <ScrollArea className=\"flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0\">\n              <div className=\"text-left\">\n                <Collaborators spaceId={spaceId} space={space} />\n              </div>\n            </ScrollArea>\n          </div>\n        </div>\n\n        <SpaceInnerSettingModal\n          open={settingModalOpen}\n          setOpen={setSettingModalOpen}\n          defaultTab={SettingTab.Plan}\n        >\n          <span className=\"hidden\" />\n        </SpaceInnerSettingModal>\n      </div>\n    )\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getSubscriptionSummaryList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { keyBy } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, type FC, useMemo } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useSetting } from '../../hooks/useSetting';\nimport { useTemplateMonitor } from '../base/duplicate/useTemplateMonitor';\nimport { useSpaceSubscriptionMonitor } from '../billing/useSpaceSubscriptionMonitor';\nimport { FreshSettingGuideDialog } from './FreshSettingGuideDialog';\nimport { NoBasesPlaceholder } from './NoBasesPlaceholder';\nimport { NoSpacesPlaceholder } from './NoSpacesPlaceholder';\nimport { RecentlyBase } from './RecentlyBase';\nimport { SpaceCard } from './SpaceCard';\nimport { useBaseList } from './useBaseList';\nimport { useSpaceListOrdered } from './useSpaceListOrdered';\n\nexport const SpacePage: FC = () => {\n  const isCloud = useIsCloud();\n  const ref = useRef<HTMLDivElement>(null);\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const isHydrated = useIsHydrated();\n\n  useTemplateMonitor();\n  useSpaceSubscriptionMonitor();\n\n  const orderedSpaceList = useSpaceListOrdered();\n\n  const baseList = useBaseList();\n\n  const { data: subscriptionList } = useQuery({\n    queryKey: ReactQueryKeys.subscriptionSummaryList(),\n    queryFn: () => getSubscriptionSummaryList().then((data) => data.data),\n    enabled: isCloud,\n  });\n\n  const { disallowSpaceInvitation } = useSetting();\n\n  const subscriptionMap = useMemo(() => {\n    if (subscriptionList == null) return {};\n    return keyBy(subscriptionList, 'spaceId');\n  }, [subscriptionList]);\n\n  // Check if we should show the empty workspace placeholder (no spaces)\n  const shouldShowEmptyWorkspace = useMemo(() => {\n    return orderedSpaceList.length === 0;\n  }, [orderedSpaceList]);\n\n  // Check if we should show the empty space placeholder\n  const shouldShowEmptyPlaceholder = useMemo(() => {\n    if (orderedSpaceList.length === 1 && baseList) {\n      const singleSpace = orderedSpaceList[0];\n      const basesInSpace = baseList.filter(({ spaceId }) => spaceId === singleSpace.id);\n      return basesInSpace.length === 0;\n    }\n    return false;\n  }, [orderedSpaceList, baseList]);\n\n  if (shouldShowEmptyWorkspace || shouldShowEmptyPlaceholder) {\n    return (\n      <div ref={ref} className=\"flex h-screen flex-1 flex-col overflow-hidden py-8\">\n        <div className=\"flex items-center justify-between px-12\">\n          <h1 className=\"text-2xl font-semibold\">\n            {shouldShowEmptyWorkspace\n              ? t('space:allSpaces')\n              : orderedSpaceList[0]?.name || t('space:allSpaces')}\n          </h1>\n        </div>\n        <div className=\"flex-1 overflow-y-auto px-8 pt-8 sm:px-12\">\n          {shouldShowEmptyWorkspace ? (\n            <NoSpacesPlaceholder />\n          ) : (\n            <NoBasesPlaceholder space={orderedSpaceList[0]} />\n          )}\n        </div>\n        {isHydrated && <FreshSettingGuideDialog />}\n      </div>\n    );\n  }\n\n  return (\n    <div ref={ref} className=\"flex h-screen flex-1 flex-col overflow-hidden py-8\">\n      <div className=\"flex items-center justify-between px-12\">\n        <h1 className=\"text-2xl font-semibold\">{t('space:allSpaces')}</h1>\n      </div>\n      <div className=\"flex-1 space-y-8 overflow-y-auto px-8 pt-8 sm:px-12\">\n        <RecentlyBase />\n        {orderedSpaceList.map((space) => (\n          <SpaceCard\n            key={space.id}\n            space={space}\n            bases={baseList?.filter(({ spaceId }) => spaceId === space.id)}\n            subscription={subscriptionMap[space.id]}\n            disallowSpaceInvitation={disallowSpaceInvitation}\n          />\n        ))}\n      </div>\n      {isHydrated && <FreshSettingGuideDialog />}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/BaseActionTrigger.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Copy, Export, Pencil, Trash2, ArrowRight } from '@teable/icons';\nimport { exportBase, getSpaceList, moveBase } from '@teable/openapi';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Button,\n  DialogFooter,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n  Switch,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { useDuplicateBaseStore } from '../../base/duplicate/useDuplicateBaseStore';\nimport { EditableSpaceSelect } from './EditableSpaceSelect';\n\ninterface IBaseActionTrigger {\n  base: IGetBaseVo;\n  showRename: boolean;\n  showDelete: boolean;\n  showDuplicate: boolean;\n  showExport: boolean;\n  showMove: boolean;\n  onRename?: () => void;\n  onDelete?: (permanent?: boolean) => void;\n  align?: 'center' | 'end' | 'start';\n}\n\nexport const BaseActionTrigger: React.FC<React.PropsWithChildren<IBaseActionTrigger>> = (props) => {\n  const {\n    base,\n    children,\n    showRename,\n    showDelete,\n    showDuplicate,\n    showExport,\n    showMove,\n    onDelete,\n    onRename,\n    align = 'end',\n  } = props;\n  const { t } = useTranslation(['common', 'space']);\n  const [deleteConfirm, setDeleteConfirm] = React.useState(false);\n  const [exportConfirm, setExportConfirm] = React.useState(false);\n  const [moveConfirm, setMoveConfirm] = React.useState(false);\n  const [spaceId, setSpaceId] = React.useState<string | null>(null);\n  const [includeData, setIncludeData] = React.useState(true);\n  const baseStore = useDuplicateBaseStore();\n  const queryClient = useQueryClient();\n  const { mutateAsync: exportBaseFn } = useMutation({\n    mutationFn: ({ baseId, includeData }: { baseId: string; includeData: boolean }) =>\n      exportBase(baseId, { includeData }),\n  });\n\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const { mutateAsync: moveBaseFn, isPending: moveBaseLoading } = useMutation({\n    mutationFn: (baseId: string) => moveBase(baseId, spaceId!),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseList(spaceId!) });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() });\n      const newSpace = spaceList?.find((space) => space.id === spaceId)?.name;\n      toast.success(t('space:tip.moveBaseSuccessTitle'), {\n        description: t('space:tip.moveBaseSuccessDescription', {\n          baseName: base.name,\n          spaceName: newSpace,\n        }),\n      });\n    },\n  });\n\n  React.useEffect(() => {\n    if (!exportConfirm) {\n      setIncludeData(true);\n    }\n  }, [exportConfirm]);\n\n  if (!showDelete && !showRename && !showDuplicate && !showExport && !showMove) {\n    return null;\n  }\n\n  const handleDelete = (permanent?: boolean) => {\n    if (onDelete) {\n      onDelete(permanent);\n    }\n    setDeleteConfirm(false);\n  };\n\n  const exportContent = (\n    <div className=\"space-y-4 text-sm\">\n      <div className=\"space-y-2 text-wrap\">\n        <p>{t('space:tip.exportTips1')}</p>\n        <p>{t('space:tip.exportTips2')}</p>\n        <div>\n          <p>Tips:</p>\n          <p>{t('space:tip.exportTips3')}</p>\n        </div>\n      </div>\n      <div className=\"flex items-center justify-between rounded-md border border-border px-3 py-2\">\n        <div className=\"max-w-[240px] space-y-1\">\n          <p className=\"text-sm font-medium\">{t('space:tip.exportIncludeDataLabel')}</p>\n          <p className=\"text-xs text-muted-foreground\">\n            {t('space:tip.exportIncludeDataDescription')}\n          </p>\n        </div>\n        <Switch checked={includeData} onCheckedChange={setIncludeData} />\n      </div>\n    </div>\n  );\n\n  const moveBaseContent = (\n    <div className=\"flex flex-col justify-start gap-2\">\n      <span className=\"text-sm text-gray-400\">{t('space:baseModal.chooseSpace')}</span>\n      <EditableSpaceSelect\n        spaceId={base.spaceId}\n        value={spaceId}\n        onChange={(spaceId) => {\n          setSpaceId(spaceId);\n        }}\n      />\n    </div>\n  );\n\n  return (\n    <>\n      <DropdownMenu modal>\n        <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n        <DropdownMenuContent\n          align={align}\n          className=\"w-[160px]\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          {showRename && (\n            <DropdownMenuItem onClick={onRename}>\n              <Pencil className=\"mr-2\" />\n              {t('actions.rename')}\n            </DropdownMenuItem>\n          )}\n          {showDuplicate && (\n            <DropdownMenuItem onClick={() => baseStore.openModal(base)}>\n              <Copy className=\"mr-2\" />\n              {t('actions.duplicate')}\n            </DropdownMenuItem>\n          )}\n          {showExport && (\n            <DropdownMenuItem\n              onClick={() => {\n                setExportConfirm(true);\n              }}\n            >\n              <Export className=\"mr-2\" />\n              {t('actions.export')}\n            </DropdownMenuItem>\n          )}\n          {showMove && (\n            <DropdownMenuItem\n              onClick={() => {\n                setMoveConfirm(true);\n              }}\n            >\n              <ArrowRight className=\"mr-2\" />\n              {t('actions.move')}\n            </DropdownMenuItem>\n          )}\n          {showDelete && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem className=\"text-destructive\" onClick={() => setDeleteConfirm(true)}>\n                <Trash2 className=\"mr-2\" />\n                {t('actions.delete')}\n              </DropdownMenuItem>\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <ConfirmDialog\n        open={deleteConfirm}\n        onOpenChange={setDeleteConfirm}\n        title={t('base.deleteTip', { name: base.name })}\n        onCancel={() => setDeleteConfirm(false)}\n        content={\n          <>\n            <div className=\"space-y-2 text-sm\">\n              <p>{t('common:trash.description')}</p>\n            </div>\n            <DialogFooter>\n              <Button size={'sm'} variant={'ghost'} onClick={() => setDeleteConfirm(false)}>\n                {t('common:actions.cancel')}\n              </Button>\n              <Button size={'sm'} onClick={() => handleDelete()}>\n                {t('common:trash.addToTrash')}\n              </Button>\n            </DialogFooter>\n          </>\n        }\n      />\n\n      <ConfirmDialog\n        open={exportConfirm}\n        onOpenChange={setExportConfirm}\n        content={exportContent}\n        title={t('space:tip.title')}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setExportConfirm(false)}\n        onConfirm={() => {\n          exportBaseFn({ baseId: base.id, includeData });\n          setExportConfirm(false);\n        }}\n      />\n\n      <ConfirmDialog\n        open={moveConfirm}\n        onOpenChange={setMoveConfirm}\n        content={moveBaseContent}\n        title={t('space:baseModal.moveBaseToAnotherSpace', { baseName: base.name })}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setMoveConfirm(false)}\n        confirmLoading={moveBaseLoading}\n        onConfirm={() => {\n          base.id && spaceId && moveBaseFn(base.id);\n          setMoveConfirm(false);\n        }}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/EditableSpaceSelect.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Selector } from '@teable/ui-lib/base';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\n\ninterface IEditableSpaceSelect {\n  spaceId: string;\n  value: string | null;\n  onChange: (spaceId: string) => void;\n}\n\nexport const EditableSpaceSelect: React.FC<React.PropsWithChildren<IEditableSpaceSelect>> = (\n  props\n) => {\n  const { value, onChange, spaceId } = props;\n  const { t } = useTranslation(['sdk']);\n  const [targetSpaceId, setTargetSpaceId] = useState<string>(value || '');\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const editableSpaceList = useMemo(() => {\n    return (\n      spaceList?.filter(\n        (space) => hasPermission(space.role, 'base|create') && spaceId !== space.id\n      ) || []\n    );\n  }, [spaceId, spaceList]);\n\n  return (\n    <Selector\n      className=\"min-w-40\"\n      candidates={editableSpaceList}\n      selectedId={targetSpaceId}\n      placeholder={t('sdk:common.selectPlaceHolder')}\n      onChange={(id) => {\n        setTargetSpaceId(id);\n        onChange(id);\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/SpaceActionTrigger.tsx",
    "content": "import { Trash2, Import, Settings, Pencil } from '@teable/icons';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React, { useCallback, useState } from 'react';\nimport { DeleteSpaceConfirm } from '@/features/app/components/space/DeleteSpaceConfirm';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { SpaceInnerSettingModal, SettingTab } from '@overridable/SpaceInnerSettingModal';\n\ninterface ISpaceActionTrigger {\n  space: IGetSpaceVo;\n  showRename?: boolean;\n  showDelete?: boolean;\n  showImportBase?: boolean;\n  showSettings?: boolean;\n  onRename?: () => void;\n  onDelete?: () => void;\n  onPermanentDelete?: () => void;\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n  onImportBase?: () => void;\n}\n\nexport const SpaceActionTrigger: React.FC<React.PropsWithChildren<ISpaceActionTrigger>> = (\n  props\n) => {\n  const {\n    space,\n    children,\n    showDelete,\n    showRename,\n    showImportBase,\n    onDelete,\n    onPermanentDelete,\n    onRename,\n    showSettings,\n    open,\n    setOpen,\n    onImportBase,\n  } = props;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const [deleteConfirm, setDeleteConfirm] = React.useState(false);\n\n  const [settingModalOpen, setSettingModalOpen] = useState(false);\n  const handleOpenSettings = useCallback(() => {\n    setOpen?.(false);\n    setSettingModalOpen(true);\n  }, [setOpen, setSettingModalOpen]);\n\n  if (!showDelete && !showRename && !showSettings) {\n    return null;\n  }\n\n  return (\n    <>\n      <DropdownMenu open={open} onOpenChange={setOpen}>\n        <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\">\n          {showRename && (\n            <DropdownMenuItem onClick={onRename}>\n              <Pencil className=\"mr-2\" />\n              {t('actions.rename')}\n            </DropdownMenuItem>\n          )}\n          {showImportBase && (\n            <DropdownMenuItem onClick={() => onImportBase?.()}>\n              <Import className=\"mr-2\" />\n              {t('space:spaceSetting.importBase')}\n            </DropdownMenuItem>\n          )}\n          {showSettings && (\n            <DropdownMenuItem onClick={handleOpenSettings}>\n              <Settings className=\"mr-2\" />\n              {t('space:spaceSetting.title')}\n            </DropdownMenuItem>\n          )}\n          {showDelete && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem className=\"text-destructive\" onClick={() => setDeleteConfirm(true)}>\n                <Trash2 className=\"mr-2\" />\n                {t('actions.delete')}\n              </DropdownMenuItem>\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n      <DeleteSpaceConfirm\n        open={deleteConfirm}\n        onOpenChange={setDeleteConfirm}\n        spaceId={space.id}\n        spaceName={space.name}\n        onConfirm={onDelete}\n        onPermanentConfirm={onPermanentDelete}\n      />\n      <SpaceInnerSettingModal\n        open={settingModalOpen}\n        setOpen={setSettingModalOpen}\n        defaultTab={SettingTab.General}\n      >\n        <span className=\"hidden\" />\n      </SpaceInnerSettingModal>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/ImportLogPanel.tsx",
    "content": "import { AlertCircle, CheckCircle2 } from '@teable/icons';\nimport { Spin } from '@teable/ui-lib/index';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\n\nexport interface ILogEntry {\n  message: string;\n  type: 'info' | 'error' | 'done';\n  timestamp: number;\n}\n\ninterface IImportLogPanelProps {\n  logs: ILogEntry[];\n  isImporting: boolean;\n}\n\nexport const ImportLogPanel = ({ logs, isImporting }: IImportLogPanelProps) => {\n  const logEndRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    logEndRef.current?.scrollIntoView({ behavior: 'smooth' });\n  }, [logs]);\n\n  if (logs.length === 0) return null;\n\n  return (\n    <div className=\"absolute inset-0 flex flex-col overflow-hidden rounded-md border bg-background\">\n      <div className=\"flex-1 overflow-y-auto p-3 font-mono text-xs leading-6\">\n        {logs.map((log, i) => (\n          <div key={log.timestamp + i} className=\"flex items-start gap-2\">\n            {log.type === 'error' ? (\n              <AlertCircle className=\"mt-1 size-3.5 shrink-0 text-destructive\" />\n            ) : log.type === 'done' ? (\n              <CheckCircle2 className=\"mt-1 size-3.5 shrink-0 text-green-500\" />\n            ) : i === logs.length - 1 && isImporting ? (\n              <Spin className=\"mt-1 size-3.5 shrink-0\" />\n            ) : (\n              <CheckCircle2 className=\"mt-1 size-3.5 shrink-0 text-muted-foreground/50\" />\n            )}\n            <span\n              className={cn('break-all', {\n                'text-destructive': log.type === 'error',\n                'text-green-500': log.type === 'done',\n                'text-foreground': log.type === 'info' && i === logs.length - 1,\n                'text-muted-foreground': log.type === 'info' && i !== logs.length - 1,\n              })}\n            >\n              {log.message}\n            </span>\n          </div>\n        ))}\n        <div ref={logEndRef} />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/Process.tsx",
    "content": "import { X, TeableNew } from '@teable/icons';\nimport { cn, Progress } from '@teable/ui-lib';\nimport { filesize } from 'filesize';\nimport { renderToString } from 'react-dom/server';\n\ninterface IFileItemProps {\n  file: File;\n  process: number;\n  onClose: () => void;\n}\n\nexport const Process = (props: IFileItemProps) => {\n  const { file, onClose, process } = props;\n  const { name, size } = file;\n\n  const teaIcon = 'data:image/svg+xml,' + encodeURIComponent(renderToString(TeableNew({})));\n\n  return (\n    <>\n      <div className=\"group relative rounded-sm text-sm\">\n        <img\n          className=\"size-full rounded-sm bg-secondary object-contain p-2\"\n          src={teaIcon}\n          alt={name}\n        />\n        <div>{name}</div>\n        <div>{filesize(size)}</div>\n        <X\n          className=\"absolute -right-2 -top-2 hidden size-4 cursor-pointer rounded-full bg-secondary p-0.5 group-hover:block hover:opacity-70\"\n          onClick={() => onClose()}\n        />\n      </div>\n      {<Progress className={cn('absolute top-0', { hidden: process === 100 })} value={process} />}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/Trigger.tsx",
    "content": "import { useRef } from 'react';\nimport { useDropArea } from 'react-use';\n\ninterface IUploadProps {\n  accept?: string;\n  onChange: (file: File | null) => void;\n  onBeforeUpload?: () => void;\n  children: React.ReactElement;\n}\n\nexport const Trigger = (props: IUploadProps) => {\n  const { onChange, children, accept, onBeforeUpload } = props;\n  const uploadRef = useRef<HTMLInputElement>(null);\n\n  const [bound] = useDropArea({\n    onFiles: (files: File[]) => onChange(files[0]),\n  });\n\n  return (\n    <>\n      <input\n        className=\"hidden\"\n        ref={uploadRef}\n        type=\"file\"\n        accept={accept}\n        multiple={false}\n        autoComplete=\"off\"\n        tabIndex={-1}\n        onChange={async (e) => {\n          onBeforeUpload?.();\n          const files = (e.target.files && Array.from(e.target.files)) || null;\n          if (files && files.length > 0) {\n            onChange(files[0]);\n          }\n        }}\n      />\n      <div\n        role=\"button\"\n        tabIndex={0}\n        className=\"size-full\"\n        onClick={() => {\n          if (uploadRef?.current) {\n            uploadRef.current.value = '';\n            uploadRef?.current?.click();\n          }\n        }}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter') {\n            uploadRef?.current?.click();\n          }\n        }}\n        {...bound}\n      >\n        {children}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/UploadPanel.tsx",
    "content": "import { generateAttachmentId } from '@teable/core';\nimport type { INotifyVo } from '@teable/openapi';\nimport { UploadType } from '@teable/openapi';\nimport { AttachmentManager } from '@teable/sdk/components';\nimport { Spin, Button, cn } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { Process } from './Process';\nimport { Trigger } from './Trigger';\n\ninterface IUploadPanelProps {\n  file: File | null;\n  accept?: string;\n  onClose: () => void;\n  onFinished: (result: INotifyVo) => void;\n  onChange: (file: File | null) => void;\n}\nconst attchmentManager = new AttachmentManager(1);\n\nconst UploadPanel = (props: IUploadPanelProps) => {\n  const { file, accept, onChange, onFinished, onClose } = props;\n  const { t } = useTranslation(['space']);\n  const [process, setProcess] = useState(0);\n  const [isImporting, setIsImporting] = useState(false);\n\n  return (\n    <div\n      className={cn('relative flex h-96 items-center justify-center', {\n        'pointer-events-none': isImporting,\n      })}\n    >\n      {!file ? (\n        <Trigger\n          onBeforeUpload={() => {\n            setIsImporting(true);\n          }}\n          accept={accept}\n          onChange={async (file) => {\n            setIsImporting(false);\n            if (file) {\n              attchmentManager.upload(\n                [{ id: generateAttachmentId(), instance: file }],\n                UploadType.Import,\n                {\n                  successCallback: (_, result) => {\n                    onFinished?.(result);\n                  },\n                  progressCallback: (_, process) => {\n                    setProcess(process);\n                  },\n                }\n              );\n            }\n            onChange(file);\n          }}\n        >\n          <div className=\"flex h-full items-center justify-center rounded-sm border-2 border-dashed hover:border-secondary\">\n            {!isImporting ? (\n              <Button variant=\"ghost\">{t('space:import.baseImportTips')}</Button>\n            ) : (\n              <div className=\"absolute flex size-full items-center justify-center bg-secondary opacity-90\">\n                <span className=\"mr-1 size-4 animate-spin\">\n                  <Spin className=\"size-4\" />\n                </span>\n                <span>{t('space:import.importing')}</span>\n              </div>\n            )}\n          </div>\n        </Trigger>\n      ) : (\n        <Process file={file} onClose={onClose} process={process} />\n      )}\n    </div>\n  );\n};\n\nexport { UploadPanel };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/UploadPanelDialog.tsx",
    "content": "import { importBaseStream, type INotifyVo } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/index';\nimport {\n  Button,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { ImportLogPanel, type ILogEntry } from './ImportLogPanel';\nimport { UploadPanel } from './UploadPanel';\n\nconst PHASE_I18N_MAP: Record<string, string> = {\n  parsing_structure: 'space:import.phase.parsingStructure',\n  creating_base: 'space:import.phase.creatingBase',\n  creating_table: 'space:import.phase.creatingTable',\n  creating_common_fields: 'space:import.phase.creatingCommonFields',\n  creating_formula_fields: 'space:import.phase.creatingFormulaFields',\n  creating_button_fields: 'space:import.phase.creatingButtonFields',\n  creating_link_fields: 'space:import.phase.creatingLinkFields',\n  creating_lookup_fields: 'space:import.phase.creatingLookupFields',\n  creating_table_views: 'space:import.phase.creatingTableViews',\n  creating_plugins: 'space:import.phase.creatingPlugins',\n  creating_folders: 'space:import.phase.creatingFolders',\n  creating_workflows: 'space:import.phase.creatingWorkflows',\n  creating_apps: 'space:import.phase.creatingApps',\n  creating_authority_matrix: 'space:import.phase.creatingAuthorityMatrix',\n  queuing_attachments: 'space:import.phase.queuingAttachments',\n  uploading_app_files: 'space:import.phase.uploadingAppFiles',\n  queuing_data_import: 'space:import.phase.queuingDataImport',\n};\n\ninterface IUploadPanelDialogProps {\n  spaceId: string;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const UploadPanelDialog = (props: IUploadPanelDialogProps) => {\n  const { open, onOpenChange, spaceId } = props;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const [file, setFile] = React.useState<File | null>(null);\n  const [notify, setNotify] = React.useState<INotifyVo | null>(null);\n  const [isImporting, setIsImporting] = React.useState(false);\n  const [logs, setLogs] = React.useState<ILogEntry[]>([]);\n  const createdBaseIdRef = React.useRef<string | null>(null);\n  const createdBaseNameRef = React.useRef<string | null>(null);\n  const openRef = React.useRef(open);\n  openRef.current = open;\n\n  const router = useRouter();\n\n  // t() expects compile-time literal keys, but i18nKey is a runtime string from the map,\n  // so we widen t to accept any string key once here instead of scattering `as any` at every call.\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const tAny = t as (key: string, options?: Record<string, any>) => string;\n\n  const translatePhase = React.useCallback(\n    (phase: string, detail?: string) => {\n      const i18nKey = PHASE_I18N_MAP[phase];\n      if (!i18nKey) return phase;\n\n      if (detail) {\n        try {\n          const parsed = JSON.parse(detail);\n          if (parsed && typeof parsed === 'object' && 'table' in parsed) {\n            return tAny(i18nKey, { table: parsed.table, fields: parsed.fields });\n          }\n        } catch {\n          // not JSON, use as plain detail\n        }\n        return tAny(i18nKey, { detail });\n      }\n      return tAny(i18nKey);\n    },\n    [tAny]\n  );\n\n  const addLog = React.useCallback((message: string, type: ILogEntry['type'] = 'info') => {\n    setLogs((prev) => [...prev, { message, type, timestamp: Date.now() }]);\n  }, []);\n\n  const showImportSuccessToast = React.useCallback(\n    (baseId: string, baseName?: string) => {\n      const label = baseName\n        ? `🎉 ${baseName} ${tAny('space:import.phase.done')}`\n        : `🎉 ${tAny('space:import.phase.done')}`;\n\n      toast.info(\n        // eslint-disable-next-line jsx-a11y/click-events-have-key-events\n        <div\n          className=\"cursor-pointer\"\n          role=\"button\"\n          tabIndex={0}\n          onClick={() => router.push(`/base/${baseId}`)}\n        >\n          {label}\n          <span className=\"ml-1 text-blue-500 underline\">\n            {tAny('space:import.phase.clickToView')}\n          </span>\n        </div>,\n        {\n          position: 'top-center',\n          duration: 1000 * 5,\n          closeButton: true,\n          style: { height: 70, display: 'flex', alignItems: 'center' },\n        }\n      );\n    },\n    [tAny, router]\n  );\n\n  const handleImport = React.useCallback(async () => {\n    if (!notify) return;\n\n    setIsImporting(true);\n    createdBaseIdRef.current = null;\n    createdBaseNameRef.current = null;\n    setLogs([]);\n\n    try {\n      const result = await importBaseStream({ spaceId, notify }, (phase, detail) => {\n        if (phase === 'creating_base') {\n          createdBaseNameRef.current = detail ?? null;\n        }\n        if (phase === 'structure_created') {\n          createdBaseIdRef.current = detail ?? null;\n          return;\n        }\n        addLog(translatePhase(phase, detail));\n      });\n\n      const baseId = result.data.base.id;\n\n      addLog(tAny('space:import.phase.done'), 'done');\n\n      if (openRef.current) {\n        // Dialog still open: auto navigate\n        setFile(null);\n        setNotify(null);\n        setLogs([]);\n        onOpenChange(false);\n        router.push(`/base/${baseId}`);\n      } else {\n        // Dialog already closed: clean up state and show toast\n        setFile(null);\n        setNotify(null);\n        setLogs([]);\n        showImportSuccessToast(baseId, result.data.base.name);\n      }\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : 'Unknown error';\n      addLog(msg, 'error');\n\n      if (createdBaseIdRef.current) {\n        const navBaseId = createdBaseIdRef.current;\n        const navBaseName = createdBaseNameRef.current;\n        if (openRef.current) {\n          setFile(null);\n          setNotify(null);\n          setLogs([]);\n          onOpenChange(false);\n          router.push(`/base/${navBaseId}`);\n        } else {\n          setFile(null);\n          setNotify(null);\n          setLogs([]);\n          showImportSuccessToast(navBaseId, navBaseName ?? undefined);\n        }\n      }\n      // else: structure failed, stay on dialog for user to see error\n    } finally {\n      setIsImporting(false);\n    }\n  }, [notify, spaceId, addLog, translatePhase, tAny, onOpenChange, router, showImportSuccessToast]);\n\n  const showLogs = logs.length > 0;\n\n  return (\n    <Dialog\n      open={open}\n      onOpenChange={(open) => {\n        onOpenChange(open);\n        if (!open && !isImporting) {\n          setFile(null);\n          setNotify(null);\n          setLogs([]);\n        }\n      }}\n    >\n      <DialogContent\n        className=\"min-w-[700px]\"\n        onInteractOutside={(e) => e.preventDefault()}\n        onEscapeKeyDown={(e) => e.preventDefault()}\n      >\n        <DialogHeader>\n          <DialogTitle>{t('space:spaceSetting.importBase')}</DialogTitle>\n        </DialogHeader>\n        <div className=\"relative w-full\">\n          <div className={cn({ 'pointer-events-none': showLogs })}>\n            <UploadPanel\n              file={file}\n              onClose={() => {\n                setFile(null);\n                setNotify(null);\n              }}\n              onChange={(file) => {\n                setFile(file);\n              }}\n              accept=\".tea\"\n              onFinished={(notify) => {\n                setNotify(notify);\n              }}\n            />\n          </div>\n          <ImportLogPanel logs={logs} isImporting={isImporting} />\n        </div>\n        <DialogFooter>\n          {/* Before import: confirm button */}\n          {!showLogs && notify && (\n            <Button\n              variant={'default'}\n              size={'sm'}\n              onClick={handleImport}\n              className=\"flex items-center gap-2\"\n            >\n              {t('space:import.confirm')}\n            </Button>\n          )}\n          {/* During import: disabled button with spinner */}\n          {isImporting && (\n            <Button variant={'default'} size={'sm'} disabled className=\"flex items-center gap-2\">\n              {t('space:import.confirm')}\n              <Spin className=\"size-4\" />\n            </Button>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/component/upload-panel/index.ts",
    "content": "export * from './Process';\nexport * from './Trigger';\nexport * from './UploadPanel';\nexport * from './UploadPanelDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/hooks/index.ts",
    "content": "export * from './useSpaceList';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/hooks/useSpaceList.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\n\nexport const useSpaceList = () => {\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  return { spaceList };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/index.ts",
    "content": "export { SpacePage } from './SpacePage';\nexport { SpaceInnerPage } from './SpaceInnerPage';\nexport { FreshSettingGuideDialog } from './FreshSettingGuideDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/ItemButton.tsx",
    "content": "import { Button, cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\n\ninterface IItemButtonProps {\n  className?: string;\n  isActive?: boolean;\n  children?: React.ReactNode;\n}\n\nexport const ItemButton = React.forwardRef<HTMLButtonElement, IItemButtonProps>((props, ref) => {\n  const { className, isActive, children } = props;\n\n  return (\n    <Button\n      ref={ref}\n      variant={'ghost'}\n      size={'xs'}\n      asChild\n      className={cn(\n        'my-[2px] w-full px-2 justify-start text-sm font-normal gap-2',\n        {\n          'bg-accent': isActive,\n        },\n        className\n      )}\n    >\n      {children}\n    </Button>\n  );\n});\n\nItemButton.displayName = 'Item';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinItem.tsx",
    "content": "import { ViewType } from '@teable/core';\nimport { Component, Database, Table2 } from '@teable/icons';\nimport type { IGetPinListVo } from '@teable/openapi';\nimport { BaseNodeResourceType, PinType } from '@teable/openapi';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { BaseNodeResourceIconMap, getNodeUrl } from '../../base/base-node/hooks';\nimport { VIEW_ICON_MAP } from '../../view/constant';\nimport { ItemButton } from './ItemButton';\n\ninterface IPinItemProps {\n  className?: string;\n  right?: React.ReactNode;\n  pin: IGetPinListVo[number];\n}\n\nexport const PinItem = (props: IPinItemProps) => {\n  const { className, pin, right } = props;\n  const router = useRouter();\n\n  switch (pin.type) {\n    case PinType.Space: {\n      return (\n        <ItemButton isActive={router.query.spaceId === pin.id} className={className}>\n          <Link\n            className=\"gap-1\"\n            href={{\n              pathname: '/space/[spaceId]',\n              query: {\n                spaceId: pin.id,\n              },\n            }}\n            title={pin.name}\n          >\n            <Component className=\"size-4 shrink-0\" />\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.Base: {\n      return (\n        <ItemButton className={className}>\n          <Link\n            className=\"gap-1\"\n            href={{\n              pathname: '/base/[baseId]',\n              query: {\n                baseId: pin.id,\n              },\n            }}\n            title={pin.name}\n          >\n            {pin.icon ? (\n              <div className=\"size-4 shrink-0 text-[3.5rem] leading-none\">\n                <Emoji emoji={pin.icon} size={16} />\n              </div>\n            ) : (\n              <Database className=\"size-4 shrink-0\" />\n            )}\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.Table: {\n      return (\n        <ItemButton className={className}>\n          <Link href={`/base/${pin.parentBaseId}/table/${pin.id}`} title={pin.name}>\n            {pin.icon ? (\n              <div className=\"size-4 shrink-0 text-[3.5rem] leading-none\">\n                <Emoji emoji={pin.icon} size={16} />\n              </div>\n            ) : (\n              <Table2 className=\"size-4 shrink-0\" />\n            )}\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.View: {\n      if (!pin.viewMeta) {\n        return;\n      }\n      const ViewIcon = VIEW_ICON_MAP[pin.viewMeta.type];\n      return (\n        <ItemButton className={className}>\n          <Link\n            href={\n              getNodeUrl({\n                baseId: pin.parentBaseId!,\n                resourceType: BaseNodeResourceType.Table,\n                resourceId: pin.viewMeta.tableId,\n                viewId: pin.id,\n              }) ?? {}\n            }\n            title={pin.name}\n          >\n            {pin.viewMeta?.type === ViewType.Plugin && pin.viewMeta?.pluginLogo ? (\n              <img className=\"mr-1 size-4 shrink-0\" src={pin.viewMeta?.pluginLogo} alt={pin.name} />\n            ) : (\n              <ViewIcon className=\"size-4 shrink-0\" />\n            )}\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.Dashboard: {\n      const IconComponent = BaseNodeResourceIconMap.dashboard;\n      return (\n        <ItemButton className={className}>\n          <Link\n            href={\n              getNodeUrl({\n                baseId: pin.parentBaseId!,\n                resourceType: BaseNodeResourceType.Dashboard,\n                resourceId: pin.id,\n              }) ?? {}\n            }\n            title={pin.name}\n          >\n            <IconComponent className=\"size-4 shrink-0\" />\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.Workflow: {\n      const IconComponent = BaseNodeResourceIconMap.workflow;\n      return (\n        <ItemButton className={className}>\n          <Link\n            href={\n              getNodeUrl({\n                baseId: pin.parentBaseId!,\n                resourceType: BaseNodeResourceType.Workflow,\n                resourceId: pin.id,\n              }) ?? {}\n            }\n            title={pin.name}\n          >\n            <IconComponent className=\"size-4 shrink-0\" />\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    case PinType.App: {\n      const IconComponent = BaseNodeResourceIconMap.app;\n      return (\n        <ItemButton className={className}>\n          <Link\n            href={\n              getNodeUrl({\n                baseId: pin.parentBaseId!,\n                resourceType: BaseNodeResourceType.App,\n                resourceId: pin.id,\n              }) ?? {}\n            }\n            title={pin.name}\n          >\n            <IconComponent className=\"size-4 shrink-0\" />\n            <p className=\"grow truncate\">{pin.name}</p>\n            {right}\n          </Link>\n        </ItemButton>\n      );\n    }\n    default:\n      return <div>unknown</div>;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { DraggableHandle, Star } from '@teable/icons';\nimport type { IGetPinListVo } from '@teable/openapi';\nimport { getPinList, updatePinOrder } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport type { DragEndEvent } from '@teable/ui-lib/base';\nimport { DndKitContext, Draggable, Droppable } from '@teable/ui-lib/base';\nimport { cn, ScrollArea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { PinItem } from './PinItem';\nimport { StarButton } from './StarButton';\n\nexport const PinList = (props: { className?: string }) => {\n  const { className } = props;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const isHydrated = useIsHydrated();\n  const { data: pinListData } = useQuery({\n    queryKey: ReactQueryKeys.pinList(),\n    queryFn: () => getPinList().then((data) => data.data),\n  });\n\n  const { mutate: updateOrder } = useMutation({\n    mutationFn: updatePinOrder,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n    onError: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n  });\n\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n    const list = pinListData ?? [];\n\n    if (!over || !list.length || from === to) {\n      return;\n    }\n\n    const pin = list[from];\n    const anchorPin = list[to];\n    const position = to > from ? 'after' : 'before';\n\n    updateOrder({\n      id: pin.id,\n      type: pin.type,\n      anchorId: anchorPin.id,\n      anchorType: anchorPin.type,\n      position,\n    });\n    queryClient.setQueryData(ReactQueryKeys.pinList(), (prev: IGetPinListVo | undefined) => {\n      if (!prev) {\n        return [];\n      }\n      const pre = [...prev];\n      pre.splice(from, 1);\n      pre.splice(to, 0, pin);\n      return pre;\n    });\n  };\n\n  if (!isHydrated) {\n    return null;\n  }\n\n  return (\n    <div className={cn('flex min-h-0 w-full flex-1 flex-col', className)}>\n      <div className=\"flex h-10 items-center gap-2 px-3 text-sm \">\n        <Star className=\"size-4 fill-yellow-400 text-yellow-400\" />\n        {t('space:pin.pin')}\n      </div>\n      <ScrollArea className=\"flex w-full !border-none px-2 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0\">\n        <div className=\"flex min-h-0 flex-1 flex-col overflow-y-auto\">\n          {pinListData?.length === 0 && (\n            <div className=\"text-center text-xs text-muted-foreground\">{t('space:pin.empty')}</div>\n          )}\n          <DndKitContext onDragEnd={onDragEndHandler}>\n            <Droppable\n              items={pinListData?.map(({ id }) => id) ?? []}\n              overlayRender={(active) => {\n                const activePin = pinListData?.find((pin) => pin.id === active?.id);\n                if (!activePin) {\n                  return <div />;\n                }\n                return (\n                  <div className=\"flex items-center gap-2 border bg-background\">\n                    <PinItem\n                      className=\"group\"\n                      pin={activePin}\n                      right={\n                        <>\n                          <StarButton\n                            className=\"opacity-0 group-hover:opacity-100\"\n                            id={activePin.id}\n                            type={activePin.type}\n                          />\n                          <DraggableHandle className=\"opacity-0 group-hover:opacity-100\" />\n                        </>\n                      }\n                    />\n                  </div>\n                );\n              }}\n            >\n              {pinListData?.map((pin) => (\n                <Draggable key={pin.id} id={pin.id}>\n                  {({ setNodeRef, attributes, listeners, style }) => (\n                    <div ref={setNodeRef} {...attributes} style={style}>\n                      <div className=\"flex items-center gap-2\">\n                        <PinItem\n                          className=\"group\"\n                          pin={pin}\n                          right={\n                            <>\n                              <StarButton\n                                className=\"opacity-0 group-hover:opacity-100\"\n                                id={pin.id}\n                                type={pin.type}\n                              />\n                              <DraggableHandle\n                                {...listeners}\n                                className=\"opacity-0 group-hover:opacity-100\"\n                              />\n                            </>\n                          }\n                        />\n                      </div>\n                    </div>\n                  )}\n                </Draggable>\n              ))}\n            </Droppable>\n          </DndKitContext>\n        </div>\n      </ScrollArea>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceInnerSideBar.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { getUniqName, hasPermission } from '@teable/core';\nimport { Plus, Settings, Trash2, LayoutTemplate } from '@teable/icons';\nimport { createBase, getSpaceById } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn/ui/tooltip';\nimport { useParams } from 'next/navigation';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { ChangelogNotification } from '@/components/changelog';\nimport { SpaceInnerTrashModal } from '@/features/app/blocks/trash/SpaceInnerTrashModal';\nimport { TemplateModal } from '@/features/app/components/space/template';\nimport { TemplateContext } from '@/features/app/components/space/template/context';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useBaseList } from '../useBaseList';\nimport { PinList } from './PinList';\n\nexport const SpaceInnerSideBar = (props: {\n  renderSettingModal?: (children: React.ReactNode) => React.ReactNode;\n  renderWinFreeCredit?: (spaceId: string) => React.ReactNode;\n}) => {\n  const { renderSettingModal, renderWinFreeCredit } = props;\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const { spaceId } = useParams<{ spaceId: string }>();\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n    enabled: !!spaceId,\n  });\n\n  const allBases = useBaseList();\n  const bases = allBases?.filter((base) => base.spaceId === spaceId);\n\n  const { mutate: createBaseMutator, isPending: createBaseLoading } = useMutation({\n    mutationFn: createBase,\n    onSuccess: ({ data }) => {\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId: data.id },\n      });\n    },\n  });\n\n  const handleCreateBase = () => {\n    if (!spaceId) return;\n    const name = getUniqName(t('common:noun.base'), bases?.map((base) => base.name) || []);\n    createBaseMutator({ spaceId, name });\n  };\n\n  const canCreateBase = space && hasPermission(space?.role, 'base|create');\n  const canUpdateSpace = space && hasPermission(space.role, 'space|update');\n\n  return (\n    <>\n      <div className=\"flex flex-col justify-center px-2\">\n        {space && (\n          <div className=\"p-2\">\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <span className=\"w-full\">\n                    <Button\n                      variant={'outline'}\n                      size={'sm'}\n                      className=\"w-full\"\n                      disabled={!canCreateBase || createBaseLoading}\n                      onClick={handleCreateBase}\n                    >\n                      <Plus className=\"size-4 shrink-0\" />\n                      {t('space:action.createBase')}\n                    </Button>\n                  </span>\n                </TooltipTrigger>\n                {!canCreateBase && (\n                  <TooltipContent>{t('space:tooltip.noPermissionToCreateBase')}</TooltipContent>\n                )}\n              </Tooltip>\n            </TooltipProvider>\n          </div>\n        )}\n        <ul className=\"py-1\">\n          {canUpdateSpace && renderSettingModal && (\n            <li key=\"settings\">\n              {renderSettingModal(\n                <Button\n                  variant=\"ghost\"\n                  size={'xs'}\n                  className={cn('w-full justify-start h-8 text-sm font-normal')}\n                >\n                  <Settings className=\"size-4 shrink-0\" />\n                  <p className=\"truncate\">{t('space:spaceSetting.title')}</p>\n                </Button>\n              )}\n            </li>\n          )}\n          <li key=\"trash\">\n            <SpaceInnerTrashModal spaceId={spaceId}>\n              <Button\n                variant=\"ghost\"\n                size={'xs'}\n                className={cn('w-full justify-start h-8 text-sm font-normal')}\n              >\n                <Trash2 className=\"size-4 shrink-0\" />\n                <p className=\"truncate\">{t('noun.trash')}</p>\n              </Button>\n            </SpaceInnerTrashModal>\n          </li>\n          <li key=\"template\">\n            <TemplateContext.Provider value={{ spaceId }}>\n              <TemplateModal spaceId={spaceId}>\n                <Button\n                  variant=\"ghost\"\n                  size={'xs'}\n                  className={cn('w-full justify-start h-8 text-sm font-normal')}\n                >\n                  <LayoutTemplate className=\"size-4 shrink-0\" />\n                  <p className=\"truncate\">{t('common:noun.template')}</p>\n                </Button>\n              </TemplateModal>\n            </TemplateContext.Provider>\n          </li>\n        </ul>\n      </div>\n      <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n        <PinList />\n      </div>\n      {renderWinFreeCredit && renderWinFreeCredit(spaceId)}\n      <ChangelogNotification />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceItem.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { Component } from '@teable/icons';\nimport { PinType, updateSpace } from '@teable/openapi';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk';\nimport { Input } from '@teable/ui-lib';\nimport Link from 'next/link';\nimport React, { useEffect, useRef, useState } from 'react';\nimport { useClickAway, useMount } from 'react-use';\nimport { SpaceOperation } from '@/features/app/blocks/space/space-side-bar/SpaceOperation';\nimport { UploadPanelDialog } from '../component/upload-panel';\nimport { ItemButton } from './ItemButton';\nimport { StarButton } from './StarButton';\ninterface IProps {\n  space: IGetSpaceVo;\n  isActive: boolean;\n}\n\nexport const SpaceItem: React.FC<IProps> = ({ space, isActive }) => {\n  const { id, name } = space;\n  const ref = useRef<HTMLButtonElement>(null);\n  const [open, setOpen] = useState(false);\n  const [isEditing, setIsEditing] = useState(false);\n  const queryClient = useQueryClient();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [importBaseOpen, setImportBaseOpen] = React.useState(false);\n\n  const { mutateAsync: updateSpaceMutator } = useMutation({\n    mutationFn: updateSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.space(space.id) });\n    },\n  });\n\n  useEffect(() => {\n    if (isEditing) setTimeout(() => inputRef.current?.focus());\n  }, [isEditing]);\n\n  useClickAway(inputRef, async () => {\n    if (isEditing && inputRef.current?.value && inputRef.current.value !== space.name) {\n      await updateSpaceMutator({\n        spaceId: space.id,\n        updateSpaceRo: { name: inputRef.current.value },\n      });\n    }\n    setIsEditing(false);\n  });\n\n  useMount(() => {\n    isActive && ref.current?.scrollIntoView({ block: 'center' });\n  });\n\n  const openImportBase = () => {\n    setImportBaseOpen(true);\n  };\n\n  return (\n    <div className=\"relative overflow-y-auto\">\n      <ItemButton className=\"group\" isActive={isActive} ref={ref}>\n        <Link\n          href={{\n            pathname: '/space/[spaceId]',\n            query: {\n              spaceId: id,\n            },\n          }}\n          title={name}\n          onContextMenu={() => setOpen(true)}\n          onDoubleClick={() => hasPermission(space.role, 'space|update') && setIsEditing(true)}\n        >\n          <Component className=\"size-4 shrink-0\" />\n          <p className=\"grow truncate\">{' ' + name}</p>\n          <StarButton id={id} type={PinType.Space} />\n\n          <SpaceOperation\n            space={space}\n            onRename={() => setIsEditing(true)}\n            open={open}\n            setOpen={setOpen}\n            className=\"size-4 shrink-0 sm:opacity-0 sm:group-hover:opacity-100\"\n            onImportBase={openImportBase}\n          />\n        </Link>\n      </ItemButton>\n      {isEditing && (\n        <Input\n          ref={inputRef}\n          type=\"text\"\n          placeholder=\"name\"\n          defaultValue={space.name}\n          className=\"rounded-none absolute left-0 top-0 size-full cursor-text px-4\"\n          onKeyDown={async (e) => {\n            if (e.key === 'Enter') {\n              if (e.currentTarget.value && e.currentTarget.value !== space.name)\n                await updateSpaceMutator({\n                  spaceId: space.id,\n                  updateSpaceRo: { name: e.currentTarget.value },\n                });\n              setIsEditing(false);\n            }\n          }}\n        />\n      )}\n      <UploadPanelDialog spaceId={id} open={importBaseOpen} onOpenChange={setImportBaseOpen} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName, hasPermission } from '@teable/core';\nimport { Plus, Database, Component } from '@teable/icons';\nimport { createSpace, createBase, getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin, ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Button,\n  cn,\n  Input,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuSeparator,\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { type FC, useState, useMemo } from 'react';\nimport { useSetting } from '@/features/app/hooks/useSetting';\nimport { SpaceItem } from './SpaceItem';\n\nexport const SpaceList: FC = () => {\n  const router = useRouter();\n  const { disallowSpaceCreation } = useSetting();\n  const { t } = useTranslation('common');\n  const [showCreateDialog, setShowCreateDialog] = useState(false);\n  const [spaceName, setSpaceName] = useState('');\n  const [showCreateBaseDialog, setShowCreateBaseDialog] = useState(false);\n  const [selectedSpaceId, setSelectedSpaceId] = useState('');\n\n  const queryClient = useQueryClient();\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const { mutate: addSpace, isPending: isLoading } = useMutation({\n    mutationFn: createSpace,\n    onSuccess: async (data) => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      setShowCreateDialog(false);\n      setSpaceName('');\n      router.push({\n        pathname: '/space/[spaceId]',\n        query: {\n          spaceId: data.data.id,\n        },\n      });\n    },\n  });\n\n  const { mutate: addBase, isPending: isLoadingBase } = useMutation({\n    mutationFn: createBase,\n    onSuccess: async (data) => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      setShowCreateBaseDialog(false);\n      setSelectedSpaceId('');\n      router.push({\n        pathname: '/base/[baseId]',\n        query: {\n          baseId: data.data.id,\n        },\n      });\n    },\n  });\n\n  // Filter spaces where user has permission to create bases\n  const spacesWithBaseCreatePermission = useMemo(() => {\n    return spaceList?.filter((space) => hasPermission(space.role, 'base|create')) || [];\n  }, [spaceList]);\n\n  const handleCreateSpace = () => {\n    const name =\n      spaceName.trim() ||\n      getUniqName(t('noun.space'), spaceList?.length ? spaceList?.map((space) => space?.name) : []);\n    addSpace({ name });\n  };\n\n  const handleOpenCreateDialog = () => {\n    setShowCreateDialog(true);\n    setSpaceName('');\n  };\n\n  const handleCreateBase = () => {\n    const spaceId = selectedSpaceId;\n    const name = getUniqName(t('noun.base'), []);\n    addBase({ spaceId, name });\n  };\n\n  const handleOpenCreateBaseDialog = () => {\n    if (spacesWithBaseCreatePermission.length === 1) {\n      // If only one space has permission, use it directly\n      const spaceId = spacesWithBaseCreatePermission[0].id;\n      const name = getUniqName(t('noun.base'), []);\n      addBase({ spaceId, name });\n    } else if (spacesWithBaseCreatePermission.length > 1) {\n      // Multiple spaces available, show selection dialog\n      setShowCreateBaseDialog(true);\n      setSelectedSpaceId('');\n    }\n  };\n\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-hidden\">\n      <div className=\"shrink-0 px-3\">\n        {!disallowSpaceCreation && (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant={'outline'}\n                size={'xs'}\n                disabled={isLoading || isLoadingBase}\n                className={cn('w-full')}\n              >\n                {isLoading || isLoadingBase ? (\n                  <Spin className=\"size-4\" />\n                ) : (\n                  <Plus className=\"size-4\" />\n                )}\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"start\" className=\"w-48\">\n              {spacesWithBaseCreatePermission.length > 0 ? (\n                <DropdownMenuItem onClick={handleOpenCreateBaseDialog}>\n                  <Database className=\"mr-2 size-4\" />\n                  {t('actions.create')} {t('noun.base')}\n                </DropdownMenuItem>\n              ) : (\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <div>\n                        <DropdownMenuItem disabled className=\"cursor-not-allowed\">\n                          <Database className=\"mr-2 size-4\" />\n                          {t('actions.create')} {t('noun.base')}\n                        </DropdownMenuItem>\n                      </div>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <p>{t('noPermissionToCreateBase')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              )}\n              <DropdownMenuSeparator />\n              <DropdownMenuItem onClick={handleOpenCreateDialog}>\n                <Component className=\"mr-2 size-4\" />\n                {t('actions.create')} {t('noun.space')}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n      <div className=\"min-h-0 flex-1 overflow-y-auto px-3\">\n        <ul>\n          {spaceList?.map((space) => (\n            <li key={space.id}>\n              <SpaceItem space={space} isActive={space.id === router.query.spaceId} />\n            </li>\n          ))}\n        </ul>\n      </div>\n\n      <ConfirmDialog\n        open={showCreateDialog}\n        onOpenChange={setShowCreateDialog}\n        title={t('actions.create') + ' ' + t('noun.space')}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        confirmLoading={isLoading}\n        onCancel={() => {\n          setShowCreateDialog(false);\n          setSpaceName('');\n        }}\n        onConfirm={handleCreateSpace}\n        content={\n          <div className=\"space-y-2\">\n            <div className=\"flex flex-col gap-2\">\n              <Input\n                placeholder={getUniqName(\n                  t('noun.space'),\n                  spaceList?.length ? spaceList?.map((space) => space?.name) : []\n                )}\n                value={spaceName}\n                onChange={(e) => setSpaceName(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') {\n                    handleCreateSpace();\n                  }\n                }}\n              />\n            </div>\n          </div>\n        }\n      />\n\n      <Dialog open={showCreateBaseDialog} onOpenChange={setShowCreateBaseDialog}>\n        <DialogContent\n          onPointerDownOutside={(e) => e.preventDefault()}\n          onInteractOutside={(e) => e.preventDefault()}\n          onMouseDown={(e) => e.stopPropagation()}\n          onClick={(e) => e.stopPropagation()}\n        >\n          <DialogHeader>\n            <DialogTitle>\n              {t('actions.create')} {t('noun.base')}\n            </DialogTitle>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <div className=\"flex flex-col gap-2\">\n              <Select value={selectedSpaceId} onValueChange={setSelectedSpaceId}>\n                <SelectTrigger size=\"lg\">\n                  <SelectValue\n                    placeholder={`${t('actions.select')} ${t('noun.space').toLowerCase()}...`}\n                  />\n                </SelectTrigger>\n                <SelectContent>\n                  {spacesWithBaseCreatePermission.map((space) => (\n                    <SelectItem key={space.id} value={space.id}>\n                      {space.name}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              size={'sm'}\n              variant={'ghost'}\n              onClick={() => {\n                setShowCreateBaseDialog(false);\n                setSelectedSpaceId('');\n              }}\n            >\n              {t('actions.cancel')}\n            </Button>\n            <Button\n              size={'sm'}\n              onClick={handleCreateBase}\n              disabled={!selectedSpaceId || isLoadingBase}\n            >\n              {isLoadingBase && <Spin />}\n              {t('actions.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceOperation.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { MoreHorizontal } from '@teable/icons';\nimport { deleteSpace, permanentDeleteSpace, type IGetSpaceVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useRouter } from 'next/router';\nimport React, { useMemo } from 'react';\nimport { SpaceActionTrigger } from '@/features/app/blocks/space/component/SpaceActionTrigger';\n\ninterface ISpaceOperationProps {\n  className?: string;\n  space: IGetSpaceVo;\n  onRename?: () => void;\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n  onImportBase?: () => void;\n}\n\nexport const SpaceOperation = (props: ISpaceOperationProps) => {\n  const { space, className, onRename, open, setOpen, onImportBase } = props;\n  const queryClient = useQueryClient();\n  const router = useRouter();\n  const currentSpaceId = router.query.spaceId as string;\n  const menuPermission = useMemo(() => {\n    return {\n      spaceUpdate: hasPermission(space.role, 'space|update'),\n      spaceDelete: hasPermission(space.role, 'space|delete'),\n    };\n  }, [space.role]);\n\n  const { mutate: deleteSpaceMutator } = useMutation({\n    mutationFn: deleteSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      if (currentSpaceId === space.id) {\n        router.push({\n          pathname: '/space',\n        });\n      }\n    },\n  });\n\n  const { mutate: permanentDeleteSpaceMutator } = useMutation({\n    mutationFn: permanentDeleteSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      if (currentSpaceId === space.id) {\n        router.push({\n          pathname: '/space',\n        });\n      }\n    },\n  });\n\n  if (!Object.values(menuPermission).some(Boolean)) {\n    return null;\n  }\n\n  return (\n    // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions\n    <div\n      onClick={(e) => {\n        e.stopPropagation();\n        e.preventDefault();\n      }}\n    >\n      <SpaceActionTrigger\n        space={space}\n        showRename={menuPermission.spaceUpdate}\n        showDelete={menuPermission.spaceDelete}\n        showImportBase={menuPermission.spaceUpdate}\n        onDelete={() => deleteSpaceMutator(space.id)}\n        onPermanentDelete={() => permanentDeleteSpaceMutator(space.id)}\n        onRename={onRename}\n        open={open}\n        setOpen={setOpen}\n        onImportBase={onImportBase}\n      >\n        <div>\n          <MoreHorizontal className={className} />\n        </div>\n      </SpaceActionTrigger>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceQuickSearch.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { Database, Search, Table2 } from '@teable/icons';\nimport { type ISpaceSearchItem } from '@teable/openapi';\nimport { spaceSearch } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  Button,\n  TooltipProvider,\n  Tooltip,\n  TooltipTrigger,\n  TooltipContent,\n} from '@teable/ui-lib/shadcn';\nimport { debounce } from 'lodash';\nimport { AppWindowMacIcon, BotIcon, CircleGaugeIcon } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useMemo, useCallback, useRef, type FC } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\nimport { useModKeyStr } from '@/features/app/utils/get-mod-key-str';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { getNodeUrl } from '../../base/base-node/hooks';\n\ninterface Props {\n  spaceId: string;\n}\n\nconst SearchTypeIconMap = {\n  base: Database,\n  table: Table2,\n  dashboard: CircleGaugeIcon,\n  workflow: BotIcon,\n  app: AppWindowMacIcon,\n};\n\nexport const SpaceQuickSearch: FC<Props> = ({ spaceId }) => {\n  const [open, setOpen] = useState(false);\n  const [search, setSearch] = useState('');\n  const [debouncedSearch, setDebouncedSearch] = useState('');\n  const isComposingRef = useRef(false);\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const modKeyStr = useModKeyStr();\n  const debouncedSetSearch = useMemo(\n    () =>\n      debounce((value: string) => {\n        setDebouncedSearch(value);\n      }, 300),\n    []\n  );\n\n  const handleSearchChange = useCallback(\n    (value: string) => {\n      setSearch(value);\n      // Only trigger debounced search when not composing (e.g., during IME input)\n      if (!isComposingRef.current) {\n        debouncedSetSearch(value);\n      }\n    },\n    [debouncedSetSearch]\n  );\n\n  const handleCompositionStart = useCallback(() => {\n    isComposingRef.current = true;\n  }, []);\n\n  const handleCompositionEnd = useCallback(\n    (e: React.CompositionEvent<HTMLInputElement>) => {\n      isComposingRef.current = false;\n      // Trigger search with the final composed value\n      debouncedSetSearch(e.currentTarget.value);\n    },\n    [debouncedSetSearch]\n  );\n\n  useHotkeys(\n    'mod+k',\n    () => {\n      setOpen(!open);\n    },\n    {\n      enableOnFormTags: ['input', 'select', 'textarea'],\n    }\n  );\n\n  const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({\n    queryKey: ['space-search', spaceId, debouncedSearch],\n    queryFn: ({ pageParam }) =>\n      spaceSearch(spaceId, {\n        search: debouncedSearch,\n        pageSize: 10,\n        cursor: pageParam,\n      }).then((r) => r.data),\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,\n    enabled: open && debouncedSearch.length > 0,\n  });\n\n  const allResults = useMemo(() => data?.pages?.flatMap((page) => page.list) ?? [], [data]);\n\n  const navigateTo = (item: ISpaceSearchItem) => {\n    setOpen(false);\n    setSearch('');\n    setDebouncedSearch('');\n\n    const { type, id, baseId } = item;\n\n    const url = getNodeUrl({\n      baseId,\n      resourceType: type,\n      resourceId: id,\n    });\n    if (url) {\n      router.push(url);\n    }\n  };\n\n  const handleOpenChange = (isOpen: boolean) => {\n    setOpen(isOpen);\n    if (!isOpen) {\n      setSearch('');\n      setDebouncedSearch('');\n    }\n  };\n\n  return (\n    <>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              className=\"w-6 shrink-0 px-0\"\n              variant=\"ghost\"\n              size=\"xs\"\n              onClick={() => setOpen(true)}\n            >\n              <Search className=\"size-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent hideWhenDetached={true}>\n            {t('common:quickAction.title')}\n            <span>{modKeyStr}+K</span>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <CommandDialog\n        closeable={false}\n        open={open}\n        onOpenChange={handleOpenChange}\n        commandProps={{\n          filter: () => 1,\n          shouldFilter: false,\n        }}\n      >\n        <CommandInput\n          placeholder={t('common:quickAction.placeHolder')}\n          value={search}\n          onValueChange={handleSearchChange}\n          onCompositionStart={handleCompositionStart}\n          onCompositionEnd={handleCompositionEnd}\n        />\n        <CommandList>\n          {debouncedSearch.length > 0 && !isLoading && allResults.length === 0 && (\n            <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n          )}\n\n          {allResults.map((item) => {\n            const IconComponent = SearchTypeIconMap[item.type as keyof typeof SearchTypeIconMap];\n            return (\n              <div className=\"px-2\" key={`${item.type}-${item.id}`}>\n                <CommandItem\n                  className=\"flex flex-col items-start gap-1\"\n                  value={`${item.type}-${item.id}`}\n                  onSelect={() => navigateTo(item)}\n                >\n                  <div className=\"flex w-full items-center gap-2\">\n                    <div className=\"flex size-4 shrink-0 items-center justify-center\">\n                      {item.icon ? (\n                        <Emoji emoji={item.icon} size=\"1em\" />\n                      ) : IconComponent ? (\n                        <IconComponent className=\"size-full\" />\n                      ) : null}\n                    </div>\n                    <span className=\"truncate\">{item.name}</span>\n                  </div>\n                  <div className=\"flex w-full items-center gap-2 pl-6 text-xs text-muted-foreground\">\n                    {item.createdUser && (\n                      <div className=\"flex shrink-0 items-center gap-1\">\n                        <span>{t('space:baseList.owner')}:</span>\n                        <UserAvatar user={item.createdUser} className=\"size-4 border\" />\n                        <span className=\"truncate\">{item.createdUser.name}</span>\n                      </div>\n                    )}\n                    {item.type !== 'base' && (\n                      <>\n                        {item.createdUser && <span>·</span>}\n                        <span className=\"truncate\">{item.baseName}</span>\n                      </>\n                    )}\n                  </div>\n                </CommandItem>\n              </div>\n            );\n          })}\n\n          {hasNextPage && (\n            <div className=\"flex justify-center py-2\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => fetchNextPage()}\n                disabled={isFetchingNextPage}\n              >\n                {t('common:actions.loadMore')}\n                {isFetchingNextPage && <Spin className=\"ml-2 size-4\" />}\n              </Button>\n            </div>\n          )}\n        </CommandList>\n      </CommandDialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSideBar.tsx",
    "content": "import { Database, Home, Trash2, ShieldUser } from '@teable/icons';\nimport { useSession } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { Building2 } from 'lucide-react';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { PinList } from './PinList';\nimport { SpaceList } from './SpaceList';\n\nexport const SpaceSideBar = (props: { isAdmin?: boolean | null }) => {\n  const { isAdmin } = props;\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const { user } = useSession();\n  const organization = user?.organization;\n\n  const pageRoutes: {\n    href: string;\n    text: string;\n    Icon: React.FC<{ className?: string }>;\n    hidden?: boolean;\n  }[] = [\n    {\n      href: '/space',\n      text: t('space:allSpaces'),\n      Icon: Home,\n    },\n    {\n      href: '/space/shared-base',\n      text: t('space:sharedBase.title'),\n      Icon: Database,\n    },\n    {\n      href: `/enterprise/${organization?.id}`,\n      text: t('noun.organizationPanel'),\n      Icon: Building2,\n      hidden: !organization?.isAdmin,\n    },\n    {\n      href: '/admin/setting',\n      text: t('noun.adminPanel'),\n      Icon: ShieldUser,\n      hidden: !isAdmin,\n    },\n    {\n      href: '/space/trash',\n      text: t('noun.trash'),\n      Icon: Trash2,\n    },\n  ];\n  return (\n    <>\n      <div className=\"flex flex-col gap-2 px-3\">\n        <ul>\n          {pageRoutes.map(({ href, text, Icon, hidden }) => {\n            if (hidden) return null;\n            return (\n              <li key={href}>\n                <Button\n                  variant=\"ghost\"\n                  size={'xs'}\n                  asChild\n                  className={cn(\n                    'w-full justify-start text-sm px-2 my-[2px]',\n                    href === router.pathname && 'bg-accent'\n                  )}\n                >\n                  <Link href={href} className=\"font-normal\">\n                    <Icon className=\"size-4 shrink-0\" />\n                    <p className=\"truncate\">{text}</p>\n                    <div className=\"grow basis-0\"></div>\n                  </Link>\n                </Button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n      <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n        <PinList className=\"max-h-[30vh] flex-none\" />\n        <SpaceList />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport { Check, ChevronDown, Database, Plus, ShieldUser, Trash2 } from '@teable/icons';\nimport {\n  createSpace,\n  getSubscriptionSummaryList,\n  PinType,\n  type IGetSpaceVo,\n  type ISubscriptionSummaryVo,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk';\nimport { useSession } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Button,\n  cn,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Input,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Building2 } from 'lucide-react';\nimport Link from 'next/link';\nimport { useParams } from 'next/navigation';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { ReactNode } from 'react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport {\n  SpaceInnerSettingModal as SpaceInnerSettingModalComponent,\n  SettingTab,\n} from '@overridable/SpaceInnerSettingModal';\nimport { Level } from '../../../components/billing/Level';\nimport { SpaceAvatar } from '../../../components/space/SpaceAvatar';\nimport { useSpaceList } from '../hooks';\nimport { usePinMap } from '../usePinMap';\nimport { StarButton } from './StarButton';\n\ninterface ISpaceSwitcherProps {\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n  upgradeTip?: ReactNode;\n  creditUsage?: ReactNode;\n  spaceInnerSettingModal?: ReactNode;\n}\n\nexport const SpaceSwitcher = (props: ISpaceSwitcherProps) => {\n  const {\n    open: controlledOpen,\n    setOpen: controlledSetOpen,\n    upgradeTip,\n    creditUsage,\n    spaceInnerSettingModal,\n  } = props;\n  const router = useRouter();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const { user } = useSession();\n  const isCloud = useIsCloud();\n  const queryClient = useQueryClient();\n\n  const [internalOpen, setInternalOpen] = useState(false);\n  const isControlled = controlledOpen !== undefined;\n  const open = isControlled ? controlledOpen : internalOpen;\n  const setOpen = useCallback(\n    (value: boolean) => {\n      if (controlledSetOpen) {\n        controlledSetOpen(value);\n      }\n      if (!isControlled) {\n        setInternalOpen(value);\n      }\n    },\n    [controlledSetOpen, isControlled, setInternalOpen]\n  );\n\n  const [settingModalOpen, setSettingModalOpen] = useState(false);\n  const [showCreateDialog, setShowCreateDialog] = useState(false);\n  const [spaceName, setSpaceName] = useState('');\n  const [highlightedValue, setHighlightedValue] = useState<string | undefined>();\n\n  const pinMap = usePinMap();\n  const { spaceList } = useSpaceList();\n  const { spaceId: currentSpaceId } = useParams<{ spaceId: string }>();\n\n  const { data: subscriptionList } = useQuery({\n    queryKey: ['subscription-summary-list'],\n    queryFn: () => getSubscriptionSummaryList().then((res) => res.data),\n    enabled: isCloud,\n  });\n\n  const subscriptionMap = useMemo(() => {\n    const map = new Map<string, ISubscriptionSummaryVo>();\n    subscriptionList?.forEach((item) => {\n      map.set(item.spaceId, item);\n    });\n    return map;\n  }, [subscriptionList]);\n\n  const currentSpace = useMemo(() => {\n    return spaceList?.find((space) => space.id === currentSpaceId);\n  }, [spaceList, currentSpaceId]);\n\n  const sortedSpaceList = useMemo(() => {\n    if (!spaceList || !currentSpaceId) return spaceList;\n    const currentSpaceItem = spaceList.find((s) => s.id === currentSpaceId);\n    if (!currentSpaceItem) return spaceList;\n    return [currentSpaceItem, ...spaceList.filter((s) => s.id !== currentSpaceId)];\n  }, [spaceList, currentSpaceId]);\n\n  const organization = user?.organization;\n\n  const { mutate: addSpace, isPending: isLoading } = useMutation({\n    mutationFn: createSpace,\n    onSuccess: async (data) => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      setShowCreateDialog(false);\n      setSpaceName('');\n      setOpen(false);\n      router.push({\n        pathname: '/space/[spaceId]',\n        query: {\n          spaceId: data.data.id,\n        },\n      });\n    },\n  });\n\n  const handleOpenChange = (isOpen: boolean) => {\n    setOpen(isOpen);\n    if (isOpen) {\n      setHighlightedValue(currentSpaceId);\n    }\n  };\n\n  const handleCreateSpace = () => {\n    const name =\n      spaceName.trim() ||\n      getUniqName(t('common:noun.space'), spaceList?.length ? spaceList?.map((s) => s.name) : []);\n    addSpace({ name });\n  };\n\n  const handleOpenCreateDialog = () => {\n    setShowCreateDialog(true);\n    setSpaceName('');\n  };\n\n  const handleSelectSpace = (space: IGetSpaceVo) => {\n    setOpen(false);\n    if (space.id === currentSpaceId) return;\n    router.push({\n      pathname: '/space/[spaceId]',\n      query: {\n        spaceId: space.id,\n      },\n    });\n  };\n\n  const searchPlaceholder = `${t('common:actions.search')} ${t('common:noun.space').toLowerCase()}`;\n\n  return (\n    <>\n      <Popover open={open} onOpenChange={handleOpenChange}>\n        <PopoverTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-10 max-w-full justify-start overflow-hidden p-2 text-base\"\n          >\n            <SpaceAvatar name={currentSpace?.name ?? ''} className=\"size-8 shrink-0\" />\n            <p className=\"min-w-0 truncate text-left font-semibold\">{currentSpace?.name}</p>\n            <ChevronDown className=\"size-4 shrink-0\" />\n          </Button>\n        </PopoverTrigger>\n\n        <PopoverContent\n          className=\"w-[360px] p-0\"\n          align=\"start\"\n          onOpenAutoFocus={(e) => e.preventDefault()}\n        >\n          <Command\n            value={highlightedValue}\n            onValueChange={setHighlightedValue}\n            filter={(value, search, keywords) => {\n              const searchLower = search.toLowerCase();\n              if (keywords?.some((keyword) => keyword.toLowerCase().includes(searchLower))) {\n                return 1;\n              }\n              return 0;\n            }}\n          >\n            {isCloud && (upgradeTip || creditUsage) && (\n              <div className=\"flex flex-col gap-2 border-b p-4\">\n                {upgradeTip}\n                {creditUsage}\n              </div>\n            )}\n\n            <div>\n              <div className=\"px-4 pb-2 pt-4\">\n                <CommandInput\n                  placeholder={searchPlaceholder}\n                  containerClassName=\"h-8 px-2 border rounded-md\"\n                />\n              </div>\n\n              <CommandList className=\"max-h-[200px]\">\n                <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n\n                <CommandGroup className=\"px-2 py-0\">\n                  {sortedSpaceList?.map((space) => {\n                    const isSelected = space.id === currentSpaceId;\n                    const subscription = subscriptionMap.get(space.id);\n                    const spaceIsPinned = pinMap?.[space.id];\n\n                    return (\n                      <CommandItem\n                        key={space.id}\n                        value={space.id}\n                        keywords={[space.name]}\n                        onSelect={() => handleSelectSpace(space)}\n                        className={cn('group flex items-center gap-2 rounded-md h-10')}\n                      >\n                        <div className=\"flex min-w-0 grow items-center gap-2\">\n                          <SpaceAvatar name={space.name} className=\"size-6\" />\n                          <span className=\"truncate text-sm\">{space.name}</span>\n                          <StarButton\n                            id={space.id}\n                            type={PinType.Space}\n                            className={cn('w-0 shrink-0 group-hover:w-auto', {\n                              'opacity-100 w-auto': spaceIsPinned,\n                            })}\n                          />\n                          {isCloud && (\n                            <Level\n                              level={subscription?.level}\n                              appSumoTier={subscription?.appSumoTier}\n                            />\n                          )}\n                        </div>\n\n                        <div className=\"flex shrink-0 items-center gap-2\">\n                          {isSelected && <Check className=\"size-5\" />}\n                        </div>\n                      </CommandItem>\n                    );\n                  })}\n                </CommandGroup>\n              </CommandList>\n\n              <div className=\"w-full px-2 py-1\">\n                <Button\n                  onClick={handleOpenCreateDialog}\n                  variant=\"ghost\"\n                  className=\"hover:text-blue-700dark:hover:text-blue-400 flex h-8 w-full items-center justify-start rounded-md p-2 text-blue-500\"\n                >\n                  <Plus className=\"size-4 shrink-0\" />\n                  {t('space:action.createSpace')}\n                </Button>\n              </div>\n            </div>\n\n            <CommandSeparator />\n\n            <div className=\"flex flex-col px-2 py-1\">\n              <Link\n                href=\"/space/shared-base\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                onClick={() => setOpen(false)}\n                className=\"flex h-8 items-center gap-2 rounded-md px-2 hover:bg-accent\"\n              >\n                <Database className=\"size-4 shrink-0\" />\n                <span className=\"text-sm\">{t('space:sharedBase.title')}</span>\n              </Link>\n\n              {user?.isAdmin && (\n                <Link\n                  href=\"/admin/setting\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  onClick={() => setOpen(false)}\n                  className=\"flex h-8 items-center gap-2 rounded-md px-2 hover:bg-accent\"\n                >\n                  <ShieldUser className=\"size-4 shrink-0\" />\n                  <span className=\"text-sm\">{t('common:noun.adminPanel')}</span>\n                </Link>\n              )}\n\n              {organization?.isAdmin && (\n                <Link\n                  href={`/enterprise/${organization.id}`}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  onClick={() => setOpen(false)}\n                  className=\"flex h-8 items-center gap-2 rounded-md px-2 hover:bg-accent\"\n                >\n                  <Building2 className=\"size-4 shrink-0\" />\n                  <span className=\"text-sm\">{t('common:noun.organizationPanel')}</span>\n                </Link>\n              )}\n\n              <Link\n                href=\"/space/trash\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                onClick={() => setOpen(false)}\n                className=\"flex h-8 items-center gap-2 rounded-md p-2 hover:bg-accent\"\n              >\n                <Trash2 className=\"size-4 shrink-0\" />\n                <span className=\"text-sm\">{t('common:trash.spaceTrash')}</span>\n              </Link>\n            </div>\n          </Command>\n        </PopoverContent>\n      </Popover>\n\n      <ConfirmDialog\n        open={showCreateDialog}\n        onOpenChange={setShowCreateDialog}\n        title={t('space:action.createSpace')}\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.confirm')}\n        confirmLoading={isLoading}\n        onCancel={() => {\n          setShowCreateDialog(false);\n          setSpaceName('');\n        }}\n        onConfirm={handleCreateSpace}\n        content={\n          <div className=\"space-y-2\">\n            <div className=\"flex flex-col gap-2\">\n              <Input\n                placeholder={getUniqName(\n                  t('common:noun.space'),\n                  spaceList?.length ? spaceList?.map((s) => s.name) : []\n                )}\n                value={spaceName}\n                onChange={(e) => setSpaceName(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') {\n                    handleCreateSpace();\n                  }\n                }}\n              />\n            </div>\n          </div>\n        }\n      />\n\n      {spaceInnerSettingModal ? (\n        spaceInnerSettingModal\n      ) : (\n        <SpaceInnerSettingModalComponent\n          open={settingModalOpen}\n          setOpen={setSettingModalOpen}\n          defaultTab={SettingTab.General}\n        >\n          <span className=\"hidden\" />\n        </SpaceInnerSettingModalComponent>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/space-side-bar/StarButton.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Star } from '@teable/icons';\nimport type { PinType } from '@teable/openapi';\nimport { addPin, deletePin } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { usePinMap } from '../usePinMap';\n\ninterface IStarButtonProps {\n  id: string;\n  type: PinType;\n  className?: string;\n}\n\nexport const StarButton = (props: IStarButtonProps) => {\n  const { className, id, type } = props;\n  const queryClient = useQueryClient();\n  const pinMap = usePinMap();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  const isPin = pinMap?.[id];\n\n  const { mutate: addPinMutation, isPending: addPinLoading } = useMutation({\n    mutationFn: addPin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n  });\n\n  const { mutate: deletePinMutation, isPending: deletePinLoading } = useMutation({\n    mutationFn: deletePin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n  });\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger\n          onClick={(e) => {\n            e.stopPropagation();\n            e.preventDefault();\n            if (addPinLoading || deletePinLoading) return;\n            isPin ? deletePinMutation({ id, type }) : addPinMutation({ id, type });\n          }}\n        >\n          <Star\n            className={cn(\n              'size-4 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 transition-colors',\n              {\n                'opacity-100': isPin,\n                'fill-yellow-400 text-yellow-400': isPin,\n              },\n              className\n            )}\n          />\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{isPin ? t('space:pin.remove') : t('space:pin.add')}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/useBaseList.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getBaseAll } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\n\nexport const useBaseList = () => {\n  const { data: baseList } = useQuery({\n    queryKey: ReactQueryKeys.baseAll(),\n    queryFn: () => getBaseAll().then((res) => res.data),\n  });\n\n  return baseList;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/usePinMap.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IGetPinListVo } from '@teable/openapi';\nimport { getPinList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsReadOnlyPreview } from '@teable/sdk/hooks';\n\nexport const usePinMap = () => {\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { data: pinListData } = useQuery({\n    queryKey: ReactQueryKeys.pinList(),\n    queryFn: () => getPinList().then((data) => data.data),\n    enabled: !isReadOnlyPreview,\n  });\n\n  return pinListData?.reduce(\n    (acc, pin) => {\n      acc[pin.id] = pin;\n      return acc;\n    },\n    {} as Record<string, IGetPinListVo[number]>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space/useSpaceListOrdered.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getSpaceList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useMemo } from 'react';\nimport { usePinMap } from './usePinMap';\n\nexport const useSpaceListOrdered = () => {\n  const { data: spaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n\n  const pinMap = usePinMap();\n\n  return useMemo(() => {\n    if (!spaceList || !pinMap) {\n      return [];\n    }\n    return [...spaceList].sort((a, b) => {\n      const aPin = pinMap[a.id];\n      const bPin = pinMap[b.id];\n      if (!aPin && !bPin) {\n        return 0; // Both a and b do not have a pin, maintain original order\n      }\n      if (!aPin) {\n        return 1; // a does not have a pin, place a after b\n      }\n      if (!bPin) {\n        return -1; // b does not have a pin, place b after a\n      }\n      return aPin.order - bPin.order;\n    });\n  }, [pinMap, spaceList]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/SpaceInnerSettingModal.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogTrigger,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Settings, Users } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { CollaboratorPage } from './collaborator';\nimport { GeneralPage } from './general';\n\ninterface ISpaceInnerSettingModalProps {\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n  defaultTab?: SettingTab;\n  children: React.ReactNode;\n}\n\nexport enum SettingTab {\n  General = 'general',\n  Collaborator = 'collaborator',\n  Plan = 'plan',\n}\n\nexport const SpaceInnerSettingModal = (props: ISpaceInnerSettingModalProps) => {\n  const {\n    children,\n    open: controlledOpen,\n    setOpen: controlledSetOpen,\n    defaultTab = SettingTab.General,\n  } = props;\n\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  const [internalOpen, setInternalOpen] = useState(false);\n  const isControlled = controlledOpen !== undefined;\n  const open = isControlled ? controlledOpen : internalOpen;\n  const setOpen = useCallback(\n    (value: boolean) => {\n      if (controlledSetOpen) {\n        controlledSetOpen(value);\n      }\n      if (!isControlled) {\n        setInternalOpen(value);\n      }\n    },\n    [controlledSetOpen, isControlled, setInternalOpen]\n  );\n\n  const [tab, setTab] = useState(defaultTab);\n  useEffect(() => {\n    if (open) {\n      setTab(defaultTab);\n    }\n  }, [open, defaultTab]);\n\n  const tabList = useMemo(() => {\n    return [\n      {\n        key: SettingTab.General,\n        name: t('space:spaceSetting.general'),\n        Icon: Settings,\n      },\n      {\n        key: SettingTab.Collaborator,\n        name: t('space:spaceSetting.collaborators'),\n        Icon: Users,\n      },\n    ];\n  }, [t]);\n\n  const content = (\n    <Tabs\n      defaultValue={SettingTab.General}\n      value={tab}\n      onValueChange={(value) => setTab(value as SettingTab)}\n      className=\"flex h-full gap-0 overflow-hidden\"\n    >\n      <TabsList className=\"flex h-full w-72 flex-col items-start justify-start gap-1 rounded-none border-none bg-muted p-4\">\n        {tabList.map(({ key, name, Icon }) => {\n          return (\n            <TabsTrigger\n              key={key}\n              value={key}\n              className=\"h-8 w-full cursor-pointer justify-start gap-2 rounded-md font-normal data-[state=active]:bg-surface data-[state=active]:font-medium data-[state=active]:shadow-none hover:bg-surface\"\n            >\n              <Icon className=\"size-4 shrink-0\" />\n              <span>{name}</span>\n            </TabsTrigger>\n          );\n        })}\n      </TabsList>\n      <TabsContent tabIndex={-1} value={SettingTab.General} className=\"mt-0 size-full\">\n        <GeneralPage />\n      </TabsContent>\n      <TabsContent tabIndex={-1} value={SettingTab.Collaborator} className=\"mt-0 size-full\">\n        <CollaboratorPage />\n      </TabsContent>\n    </Tabs>\n  );\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent\n        className=\"flex h-[85%] max-h-[85%] max-w-[80%] flex-col gap-0 p-0 transition-[max-width] duration-300\"\n        onOpenAutoFocus={(e) => e.preventDefault()}\n      >\n        {content}\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/CollaboratorPage.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { UserPlus } from '@teable/icons';\nimport { getSpaceById, getSpaceCollaboratorList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { InviteSpacePopover } from '@/features/app/components/collaborator/space/InviteSpacePopover';\nimport { Collaborators } from '@/features/app/components/collaborator-manage/space/Collaborators';\nimport { SpaceSettingContainer } from '@/features/app/components/SpaceSettingContainer';\nimport { spaceConfig } from '@/features/i18n/space.config';\n\nexport const CollaboratorPage = () => {\n  const router = useRouter();\n  const isHydrated = useIsHydrated();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const spaceId = router.query.spaceId as string;\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n  });\n\n  const { data: collaborators } = useQuery({\n    queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId, { includeBase: true }),\n    queryFn: ({ queryKey }) =>\n      getSpaceCollaboratorList(queryKey[1], { includeBase: true }).then((res) => res.data),\n  });\n\n  return (\n    <SpaceSettingContainer\n      title={t('space:spaceSetting.collaborators')}\n      description={\n        <Trans\n          ns=\"common\"\n          i18nKey={'invite.dialog.desc'}\n          count={collaborators?.uniqTotal}\n          components={{ b: <b /> }}\n        />\n      }\n      className=\"overflow-hidden\"\n    >\n      {isHydrated && !!space && (\n        <div className=\"size-full\">\n          <Collaborators\n            spaceId={spaceId}\n            role={space.role}\n            collaboratorQuery={{ includeBase: true }}\n          >\n            <InviteSpacePopover space={space}>\n              <Button size=\"sm\">\n                <UserPlus className=\"size-4\" /> {t('space:action.invite')}\n              </Button>\n            </InviteSpacePopover>\n          </Collaborators>\n        </div>\n      )}\n    </SpaceSettingContainer>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/collaborator/index.ts",
    "content": "export * from './CollaboratorPage';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/general/GeneralPage.tsx",
    "content": "/* eslint-disable jsx-a11y/no-autofocus */\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { hasPermission } from '@teable/core';\nimport { deleteSpace, getSpaceById, permanentDeleteSpace, updateSpace } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Button, Input } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { DeleteSpaceConfirm } from '@/features/app/components/space/DeleteSpaceConfirm';\nimport { SpaceSettingContainer } from '@/features/app/components/SpaceSettingContainer';\nimport { spaceConfig } from '@/features/i18n/space.config';\n\nexport const GeneralPage = () => {\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const spaceId = router.query.spaceId as string;\n  const [isEditing, setIsEditing] = useState(false);\n  const [deleteConfirm, setDeleteConfirm] = useState(false);\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n  });\n\n  const { mutateAsync: updateSpaceMutator } = useMutation({\n    mutationFn: updateSpace,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.space(spaceId) });\n    },\n  });\n\n  const { mutate: deleteSpaceMutator } = useMutation({\n    mutationFn: deleteSpace,\n    onSuccess: () => {\n      router.push('/space');\n    },\n  });\n\n  const { mutate: permanentDeleteSpaceMutator } = useMutation({\n    mutationFn: permanentDeleteSpace,\n    onSuccess: () => {\n      router.push('/space');\n    },\n  });\n\n  const onBlur = async (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    const value = e.target.value;\n    if (!value || value === space?.name) {\n      return setIsEditing(false);\n    }\n    await updateSpaceMutator({\n      spaceId,\n      updateSpaceRo: { name: value },\n    });\n    setIsEditing(false);\n  };\n\n  const onKeydown = async (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      const value = e.currentTarget.value;\n      if (!value || value === space?.name) {\n        return setIsEditing(false);\n      }\n      await updateSpaceMutator({\n        spaceId,\n        updateSpaceRo: { name: value },\n      });\n      setIsEditing(false);\n    }\n  };\n\n  return (\n    <>\n      <SpaceSettingContainer\n        title={t('space:spaceSetting.general')}\n        description={t('space:spaceSetting.generalDescription')}\n      >\n        {!!space && (\n          <div className=\"flex h-full flex-col justify-between\">\n            <div className=\"flex flex-col gap-y-4\">\n              {/* Avatar */}\n              <div className=\"flex size-14 items-center justify-center rounded-md border text-2xl font-medium\">\n                {space.name.charAt(0).toUpperCase()}\n              </div>\n\n              {/* Space name */}\n              <div className=\"flex max-w-sm flex-col gap-y-1 overflow-visible\">\n                <label className=\"text-sm font-medium\">{t('space:spaceSetting.spaceName')}</label>\n                {isEditing ? (\n                  <Input\n                    defaultValue={space.name}\n                    onBlur={onBlur}\n                    onKeyDown={onKeydown}\n                    autoFocus\n                    size=\"lg\"\n                    className=\"px-3\"\n                  />\n                ) : (\n                  <Input\n                    value={space.name}\n                    readOnly\n                    onClick={() => hasPermission(space.role, 'space|update') && setIsEditing(true)}\n                    size=\"lg\"\n                    className={`px-3 ${hasPermission(space.role, 'space|update') ? 'cursor-pointer' : 'cursor-default'}`}\n                  />\n                )}\n              </div>\n\n              {/* Space ID */}\n              <div className=\"flex max-w-sm flex-col gap-y-1\">\n                <label className=\"text-sm font-medium\">{t('space:spaceSetting.spaceId')}</label>\n                <div className=\"relative\">\n                  <Input\n                    value={spaceId}\n                    readOnly\n                    tabIndex={-1}\n                    size=\"lg\"\n                    className=\"cursor-default px-3 pr-10\"\n                  />\n                  <CopyButton\n                    variant=\"ghost\"\n                    text={spaceId}\n                    size=\"xs\"\n                    iconClassName=\"size-4\"\n                    className=\"absolute right-1 top-1/2 -translate-y-1/2\"\n                  />\n                </div>\n              </div>\n            </div>\n\n            {/* Delete space button */}\n            {hasPermission(space.role, 'space|delete') && (\n              <Button\n                variant=\"outline\"\n                className=\"w-fit text-destructive hover:text-destructive/80\"\n                onClick={() => setDeleteConfirm(true)}\n              >\n                {t('space:deleteSpaceModal.title')}\n              </Button>\n            )}\n          </div>\n        )}\n      </SpaceSettingContainer>\n\n      {space && (\n        <DeleteSpaceConfirm\n          open={deleteConfirm}\n          onOpenChange={setDeleteConfirm}\n          spaceId={space.id}\n          spaceName={space.name}\n          onConfirm={() => deleteSpaceMutator(space.id)}\n          onPermanentConfirm={() => permanentDeleteSpaceMutator(space.id)}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/general/index.ts",
    "content": "export * from './GeneralPage';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/index.ts",
    "content": "export * from './general';\nexport * from './collaborator';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/integration/components/AiConfig.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useQuery } from '@tanstack/react-query';\nimport { testIntegrationLLM, aiConfigVoSchema, getPublicSetting } from '@teable/openapi';\nimport type {\n  IAIIntegrationConfig,\n  IChatModelAbility,\n  IImageModelAbility,\n  ITestLLMRo,\n  LLMProvider,\n} from '@teable/openapi';\nimport { Form, toast } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { AIControlCard } from '../../../admin/setting/components/ai-config/AIControlCard';\nimport { AIModelPreferencesCard } from '../../../admin/setting/components/ai-config/AIModelPreferencesCard';\nimport type { IModelOption } from '../../../admin/setting/components/ai-config/AiModelSelect';\nimport { AIProviderCard } from '../../../admin/setting/components/ai-config/AIProviderCard';\nimport { BatchTestModels } from '../../../admin/setting/components/ai-config/BatchTestModels';\nimport type { IModelTestResult } from '../../../admin/setting/components/ai-config/LlmproviderManage';\nimport {\n  generateModelKeyList,\n  generateGatewayModelKeyList,\n  parseModelKey,\n} from '../../../admin/setting/components/ai-config/utils';\n\ninterface IAIConfigProps {\n  config: IAIIntegrationConfig;\n  onChange: (value: IAIIntegrationConfig) => void;\n}\n\nexport const AIConfig = (props: IAIConfigProps) => {\n  const { config, onChange } = props;\n  const router = useRouter();\n  const spaceId = router.query.spaceId as string;\n\n  const defaultValues = useMemo(\n    () =>\n      config ?? {\n        enable: false,\n        llmProviders: [],\n      },\n    [config]\n  );\n\n  const form = useForm<IAIIntegrationConfig>({\n    resolver: zodResolver(aiConfigVoSchema),\n    defaultValues: defaultValues,\n  });\n  const llmProviders = form.watch('llmProviders') ?? [];\n  const { reset } = form;\n  const { t } = useTranslation('common');\n\n  // Get public setting for instance AI config (includes gateway models)\n  const { data: setting } = useQuery({\n    queryKey: ['public-setting'],\n    queryFn: () => getPublicSetting().then(({ data }) => data),\n  });\n\n  // Generate combined model list: space models, gateway models, instance models\n  const models = useMemo((): IModelOption[] => {\n    const providerModels = generateModelKeyList(llmProviders);\n\n    // Get gateway models from public settings (enabled models only)\n    const publicGatewayModels = setting?.aiConfig?.gatewayModels || [];\n    const gatewayModels = generateGatewayModelKeyList(publicGatewayModels);\n\n    // Get instance-level providers from public settings\n    const instanceProviders = setting?.aiConfig?.llmProviders || [];\n    const instanceModelsFromSetting = generateModelKeyList(instanceProviders);\n\n    // Separate space and instance models from space integration\n    const spaceModels = providerModels.filter((m) => !m.isInstance);\n\n    // Combine in order: space models, gateway models, instance models\n    return [...spaceModels, ...gatewayModels, ...instanceModelsFromSetting];\n  }, [llmProviders, setting?.aiConfig?.gatewayModels, setting?.aiConfig?.llmProviders]);\n\n  // State for batch testing models\n  const [modelTestResults, setModelTestResults] = useState<Map<string, IModelTestResult>>(\n    new Map()\n  );\n  const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set());\n  const [testingModels, setTestingModels] = useState<Set<string>>(new Set());\n  const testProviderCallbackRef = useRef<((provider: LLMProvider) => void) | null>(null);\n  const testModelCallbackRef = useRef<\n    ((provider: LLMProvider, model: string, modelKey: string) => Promise<void>) | null\n  >(null);\n\n  useEffect(() => {\n    reset(defaultValues);\n  }, [defaultValues, reset]);\n\n  const onSubmit = useCallback(\n    async (data: IAIIntegrationConfig) => {\n      onChange(data);\n      toast({\n        title: t('admin.setting.ai.configUpdated'),\n      });\n    },\n    [onChange, t]\n  );\n\n  const onProvidersUpdate = (providers: LLMProvider[]) => {\n    form.setValue('llmProviders', providers);\n    form.trigger('llmProviders');\n    onSubmit(form.getValues());\n  };\n\n  const onTest = async (data: ITestLLMRo) => testIntegrationLLM(spaceId, data);\n\n  // Save test result to provider config (silent save without toast)\n  const onSaveTestResult = useCallback(\n    (\n      modelKey: string,\n      ability: IChatModelAbility | undefined,\n      imageAbility: IImageModelAbility | undefined\n    ) => {\n      const parsed = parseModelKey(modelKey);\n      if (!parsed.type || !parsed.model || !parsed.name) return;\n\n      const { type, model, name } = parsed;\n      const currentProviders = form.getValues('llmProviders') ?? [];\n      const providerIndex = currentProviders.findIndex((p) => p.type === type && p.name === name);\n\n      if (providerIndex === -1) return;\n\n      const provider = currentProviders[providerIndex];\n      const updatedProvider = {\n        ...provider,\n        modelConfigs: {\n          ...provider.modelConfigs,\n          [model]: {\n            ...provider.modelConfigs?.[model],\n            ability,\n            imageAbility,\n            testedAt: Date.now(),\n          },\n        },\n      };\n\n      const newProviders = [...currentProviders];\n      newProviders[providerIndex] = updatedProvider;\n\n      form.setValue('llmProviders', newProviders);\n      // Silent save without toast\n      onChange(form.getValues());\n    },\n    [form, onChange]\n  );\n\n  // Toggle image model flag\n  const onToggleImageModel = useCallback(\n    (modelKey: string, isImageModel: boolean) => {\n      const parsed = parseModelKey(modelKey);\n      if (!parsed.type || !parsed.model || !parsed.name) return;\n\n      const { type, model, name } = parsed;\n      const currentProviders = form.getValues('llmProviders') ?? [];\n      const providerIndex = currentProviders.findIndex((p) => p.type === type && p.name === name);\n\n      if (providerIndex === -1) return;\n\n      const provider = currentProviders[providerIndex];\n      const updatedProvider = {\n        ...provider,\n        modelConfigs: {\n          ...provider.modelConfigs,\n          [model]: {\n            ...provider.modelConfigs?.[model],\n            isImageModel,\n            // Clear previous test results when toggling\n            ability: isImageModel ? undefined : provider.modelConfigs?.[model]?.ability,\n            imageAbility: isImageModel ? provider.modelConfigs?.[model]?.imageAbility : undefined,\n          },\n        },\n      };\n\n      const newProviders = [...currentProviders];\n      newProviders[providerIndex] = updatedProvider;\n\n      form.setValue('llmProviders', newProviders);\n      onChange(form.getValues());\n    },\n    [form, onChange]\n  );\n\n  const instanceAIDisableActions = setting?.aiConfig?.capabilities?.disableActions || [];\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"flex flex-col gap-6\">\n        <AIControlCard\n          disableActions={config?.capabilities?.disableActions || instanceAIDisableActions}\n          instanceDisableActions={instanceAIDisableActions}\n          onChange={(value: { disableActions: string[] }) => {\n            form.setValue('capabilities', value);\n            onSubmit(form.getValues());\n          }}\n        />\n        <AIProviderCard\n          control={form.control}\n          onChange={onProvidersUpdate}\n          onTest={onTest}\n          modelTestResults={modelTestResults}\n          onToggleImageModel={onToggleImageModel}\n          onTestProvider={(provider) => testProviderCallbackRef.current?.(provider)}\n          onTestModel={(provider, model, modelKey) =>\n            testModelCallbackRef.current?.(provider, model, modelKey) ?? Promise.resolve()\n          }\n          testingProviders={testingProviders}\n          testingModels={testingModels}\n          hideModelRates\n          onSaveTestResult={onSaveTestResult}\n          title={t('admin.setting.ai.provider')}\n          headerActions={\n            <BatchTestModels\n              providers={llmProviders}\n              disabled={!llmProviders?.length}\n              onTest={onTest}\n              onResultsChange={setModelTestResults}\n              onSaveResult={onSaveTestResult}\n              onTestingProvidersChange={setTestingProviders}\n              onTestingModelsChange={setTestingModels}\n              onTestProvider={(callback) => {\n                testProviderCallbackRef.current = callback;\n              }}\n              onTestModel={(callback) => {\n                testModelCallbackRef.current = callback;\n              }}\n            />\n          }\n        />\n        <AIModelPreferencesCard\n          control={form.control}\n          models={models}\n          onChange={() => onSubmit(form.getValues())}\n          needGroup={true}\n          hideEmbeddingModel\n          title={t('admin.setting.ai.modelPreferences')}\n          modelPlaceholder=\"Default\"\n          onReset={() => {\n            form.setValue('chatModel', undefined);\n            onSubmit(form.getValues());\n          }}\n        />\n      </form>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/integration/components/IntegrationCard.tsx",
    "content": "import { MoreHorizontal, Trash2 } from '@teable/icons';\nimport type { IIntegrationConfig } from '@teable/openapi';\nimport {\n  Switch,\n  Button,\n  Card,\n  CardHeader,\n  CardTitle,\n  CardContent,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\ninterface IIntegrationCardProps {\n  title: React.ReactNode;\n  enable?: boolean;\n  config: IIntegrationConfig;\n  children?: React.ReactNode;\n  onCheckedChange?: (checked: boolean) => void;\n  onDelete?: () => void;\n}\n\nexport const IntegrationCard = (props: IIntegrationCardProps) => {\n  const { title, enable, children, onCheckedChange, onDelete } = props;\n\n  const { t } = useTranslation('common');\n\n  return (\n    <Card className=\"shadow-sm\">\n      <CardHeader className=\"flex-row items-center justify-between border-b p-4\">\n        <CardTitle className=\"text-lg\">{title}</CardTitle>\n        <div className=\"flex items-center space-x-1\">\n          <Switch checked={enable} onCheckedChange={onCheckedChange} />\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-xs\"\n                className=\"my-[2px] w-full justify-start text-sm font-normal\"\n              >\n                <MoreHorizontal className=\"size-4 shrink-0\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"center\" className=\"min-w-[200px]\">\n              <DropdownMenuItem className=\"text-destructive\" onClick={onDelete}>\n                <Trash2 className=\"mr-2 size-4 shrink-0\" />\n                <p className=\"truncate\">{t('actions.delete')}</p>\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </CardHeader>\n      <CardContent className=\"max-h-[360px] overflow-y-auto overflow-x-hidden p-4\">\n        {children}\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/space-setting/integration/components/index.ts",
    "content": "export * from './IntegrationCard';\nexport * from './AiConfig';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/FailAlert.tsx",
    "content": "import { Frown } from '@teable/icons';\nimport { Alert, AlertDescription, AlertTitle } from '@teable/ui-lib/shadcn/ui/alert';\nimport { useTranslation } from 'next-i18next';\n\nexport const FailAlert: React.FC = () => {\n  const { t } = useTranslation(['table']);\n  return (\n    <div className=\"flex size-full items-center justify-center\">\n      <Alert className=\"w-[400px]\">\n        <Frown className=\"size-5\" />\n        <AlertTitle>{t('view.crash.title')}</AlertTitle>\n        <AlertDescription>{t('view.crash.description')}</AlertDescription>\n      </Alert>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/Table.tsx",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IFieldVo, IRecord, IViewVo } from '@teable/core';\nimport {\n  getBaseById,\n  LastVisitResourceType,\n  updateUserLastVisit,\n  type IGroupPointsVo,\n} from '@teable/openapi';\nimport {\n  AnchorContext,\n  FieldProvider,\n  useUndoRedo,\n  ViewProvider,\n  PersonalViewProxy,\n  PersonalViewProvider,\n  ReactQueryKeys,\n  useTables,\n  useIsReadOnlyPreview,\n} from '@teable/sdk';\nimport { TablePermissionProvider } from '@teable/sdk/context/table-permission';\nimport Head from 'next/head';\nimport { useEffect } from 'react';\nimport { ErrorBoundary } from 'react-error-boundary';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport {\n  CellDownloadHandler,\n  DownloadAllAttachmentsDialog,\n} from '../../components/download-attachments';\nimport { PluginContextMenu } from '../../components/plugin-context-menu/PluginContextMenu';\nimport { PluginPanel } from '../../components/plugin-panel/PluginPanel';\nimport type { IBaseResourceTable } from '../../hooks/useBaseResource';\nimport { useBaseResource } from '../../hooks/useBaseResource';\nimport { useBrand } from '../../hooks/useBrand';\nimport { View } from '../view/View';\nimport { FailAlert } from './FailAlert';\nimport { useViewErrorHandler } from './hooks/use-view-error-handler';\nimport { TableHeader } from './table-header/TableHeader';\n\nexport interface ITableProps {\n  fieldServerData: IFieldVo[];\n  viewServerData: IViewVo[];\n  recordsServerData: { records: IRecord[] };\n  recordServerData?: IRecord;\n  groupPointsServerDataMap?: { [viewId: string]: IGroupPointsVo | null };\n}\n\nexport const Table: React.FC<ITableProps> = ({\n  fieldServerData,\n  viewServerData,\n  recordsServerData,\n  recordServerData,\n  groupPointsServerDataMap,\n}) => {\n  const tables = useTables();\n  const { undo, redo } = useUndoRedo();\n  const queryClient = useQueryClient();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { baseId, tableId, viewId } = useBaseResource() as IBaseResourceTable;\n\n  const table = tables.find((t) => t.id === tableId);\n\n  const { data: base } = useQuery({\n    queryKey: ReactQueryKeys.base(baseId as string),\n    queryFn: ({ queryKey }) => getBaseById(queryKey[1]).then((res) => res.data),\n  });\n\n  const { brandName } = useBrand();\n\n  useEffect(() => {\n    // Skip last visit tracking in template or share mode\n    if (isReadOnlyPreview) return;\n    updateUserLastVisit({\n      resourceId: tableId,\n      childResourceId: viewId,\n      parentResourceId: baseId,\n      resourceType: LastVisitResourceType.Table,\n    }).then(() => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.userLastVisitMap(baseId) });\n    });\n  }, [tableId, viewId, baseId, queryClient, isReadOnlyPreview]);\n\n  useViewErrorHandler(baseId, tableId, viewId);\n  useHotkeys(`mod+z`, () => undo(), {\n    preventDefault: true,\n  });\n\n  useHotkeys([`mod+shift+z`, `mod+y`], () => redo(), {\n    preventDefault: true,\n  });\n\n  return (\n    <AnchorContext.Provider value={{ tableId, viewId, baseId }}>\n      <Head>\n        <title>\n          {table?.name\n            ? `${table?.icon ? table.icon + ' ' : ''}${table.name}: ${base?.name} - ${brandName}`\n            : `${brandName}`}\n        </title>\n        <style data-fullcalendar></style>\n      </Head>\n      <TablePermissionProvider baseId={baseId}>\n        <ViewProvider serverData={viewServerData}>\n          <PersonalViewProxy serverData={viewServerData}>\n            <FieldProvider serverSideData={fieldServerData}>\n              <PersonalViewProvider>\n                <div className=\"flex h-full grow basis-[500px]\">\n                  <div\n                    className=\"flex flex-1 flex-col overflow-hidden\"\n                    data-screenshot-target=\"base-view\"\n                  >\n                    <TableHeader />\n                    <ErrorBoundary\n                      fallback={\n                        <div className=\"flex size-full items-center justify-center\">\n                          <FailAlert />\n                        </div>\n                      }\n                    >\n                      <View\n                        recordServerData={recordServerData}\n                        recordsServerData={recordsServerData}\n                        groupPointsServerDataMap={groupPointsServerDataMap}\n                      />\n                    </ErrorBoundary>\n                  </div>\n                  <PluginPanel tableId={tableId} />\n                  <PluginContextMenu tableId={tableId} baseId={baseId} />\n                  <DownloadAllAttachmentsDialog />\n                  <CellDownloadHandler />\n                  {/* <ChatPanel /> */}\n                </div>\n              </PersonalViewProvider>\n            </FieldProvider>\n          </PersonalViewProxy>\n        </ViewProvider>\n      </TablePermissionProvider>\n    </AnchorContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/hooks/use-aggregations-query.ts",
    "content": "import type { IAggregationVo } from '@teable/openapi';\nimport { Table } from '@teable/sdk/model';\nimport { useEffect, useState } from 'react';\n\nexport const useAggregationsQuery = (tableId: string, viewId: string) => {\n  const [viewAggregation, setViewAggregation] = useState<IAggregationVo>();\n\n  useEffect(() => {\n    Table.getAggregations(tableId, { viewId }).then((res) => {\n      const { aggregations } = res.data;\n      setViewAggregation({\n        [viewId]: {\n          viewId: viewId,\n          aggregations: aggregations ?? [],\n          executionTime: new Date().getTime(),\n        },\n      });\n    });\n  }, [tableId, viewId]);\n\n  return viewAggregation;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/hooks/use-import-status.ts",
    "content": "import { getTableImportChannel } from '@teable/core';\nimport { useConnection } from '@teable/sdk/hooks';\nimport { isEmpty, get } from 'lodash';\nimport { useEffect, useState } from 'react';\n\nexport const useImportStatus = (tableId: string) => {\n  const { connection } = useConnection();\n  const presence = connection?.getPresence(getTableImportChannel(tableId));\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    if (!presence || !tableId) {\n      return;\n    }\n\n    if (presence.subscribed) {\n      return;\n    }\n\n    presence.subscribe();\n\n    const receiveHandler = () => {\n      const { remotePresences } = presence;\n      if (!isEmpty(remotePresences)) {\n        const remoteStatus = get(remotePresences, [getTableImportChannel(tableId), 'loading']);\n        setLoading(remoteStatus === undefined ? false : remoteStatus);\n      }\n    };\n\n    presence.on('receive', receiveHandler);\n\n    return () => {\n      presence.unsubscribe();\n      presence?.removeListener('receive', receiveHandler);\n    };\n  }, [connection, presence, tableId]);\n\n  return { loading };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/hooks/use-row-count-query.ts",
    "content": "import { Table } from '@teable/sdk/model';\nimport { useEffect, useState } from 'react';\n\nexport const useRowCountQuery = (tableId: string, viewId: string) => {\n  const [rowCount, setRowCount] = useState<number>();\n\n  useEffect(() => {\n    Table.getRowCount(tableId, { viewId }).then((res) => {\n      setRowCount(res.data?.rowCount);\n    });\n  }, [tableId, viewId]);\n\n  return rowCount;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/hooks/use-view-error-handler.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { HttpError, HttpErrorCode } from '@teable/core';\nimport { BaseNodeResourceType, getTableById } from '@teable/openapi';\nimport { useConnection } from '@teable/sdk/hooks';\nimport { useRouter } from 'next/router';\nimport { useEffect, useRef } from 'react';\nimport type { ConnectionReceiveRequest } from 'sharedb/lib/sharedb';\nimport { getNodeUrl } from '../../base/base-node/hooks';\n\nexport const useViewErrorHandler = (baseId: string, tableId: string, viewId: string) => {\n  const router = useRouter();\n  const { connection } = useConnection();\n  const redirectLockRef = useRef(false);\n  const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const { mutate: redirectDefaultView } = useMutation({\n    mutationFn: ({ baseId, tableId }: { baseId: string; tableId: string }) =>\n      getTableById(baseId, tableId),\n    onSuccess: (data) => {\n      redirectLockRef.current = false;\n      const defaultViewId = data.data.defaultViewId;\n      const url = getNodeUrl({\n        baseId,\n        resourceType: BaseNodeResourceType.Table,\n        resourceId: tableId,\n        viewId: defaultViewId,\n      });\n      if (url) {\n        router.replace(url, undefined, { shallow: true });\n      }\n    },\n    onError: () => {\n      redirectLockRef.current = false;\n    },\n  });\n\n  useEffect(() => {\n    if (!tableId || !baseId || !connection) {\n      return;\n    }\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const errorHandler = (error: any) => {\n      const httpError = new HttpError(error, error?.status || 500);\n      if (httpError.code === HttpErrorCode.VIEW_NOT_FOUND) {\n        if (redirectLockRef.current) return;\n        redirectLockRef.current = true;\n        redirectDefaultView({ baseId, tableId });\n      }\n    };\n\n    const handleViewDeletion = (data: unknown) => {\n      if (typeof data === 'object' && data !== null) {\n        const { d, del } = data as { d?: string; del?: boolean };\n        if (d === viewId && del === true) {\n          // If user deletes the view and immediately navigates to another view,\n          // don't compete; delay and check we are still on the deleted view.\n          if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current);\n          redirectTimerRef.current = setTimeout(() => {\n            if (redirectLockRef.current) return;\n            if (router.asPath.includes(`/${tableId}/${viewId}`)) {\n              redirectLockRef.current = true;\n              redirectDefaultView({ baseId, tableId });\n            }\n          }, 100);\n        }\n      }\n    };\n\n    const onReceive = (request: ConnectionReceiveRequest) => {\n      if (request.data.error) {\n        errorHandler(request.data.error);\n      } else {\n        handleViewDeletion(request.data);\n      }\n    };\n    connection.on('receive', onReceive);\n\n    return () => {\n      if (redirectTimerRef.current) {\n        clearTimeout(redirectTimerRef.current);\n        redirectTimerRef.current = null;\n      }\n      connection.removeListener('receive', onReceive);\n    };\n  }, [baseId, connection, redirectDefaultView, router.asPath, tableId, viewId]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/store/index.ts",
    "content": "export * from './use-locked-view-tip-store';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/store/use-locked-view-tip-store.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface ILockedViewTipState {\n  visible: boolean;\n  setVisible: (visible: boolean) => void;\n}\n\nexport const useLockedViewTipStore = create<ILockedViewTipState>()(\n  persist(\n    (set) => ({\n      visible: true,\n      setVisible: (visible) => set({ visible }),\n    }),\n    {\n      name: LocalStorageKeys.LockedViewTipVisible,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/AddPluginView.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { ViewType } from '@teable/core';\nimport { BaseNodeResourceType, type IViewInstallPluginRo } from '@teable/openapi';\nimport { installViewPlugin, PluginPosition } from '@teable/openapi';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useRef } from 'react';\nimport type { IPluginCenterDialogRef } from '@/features/app/components/plugin/PluginCenterDialog';\nimport { PluginCenterDialog } from '@/features/app/components/plugin/PluginCenterDialog';\nimport type { IBaseResourceTable } from '@/features/app/hooks/useBaseResource';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { getNodeUrl } from '../../base/base-node/hooks';\nimport { VIEW_ICON_MAP } from '../../view/constant';\n\nconst PluginViewIcon = VIEW_ICON_MAP[ViewType.Plugin];\n\ninterface IAddPluginViewProps {\n  onClose: () => void;\n}\n\nexport const AddPluginView = (props: IAddPluginViewProps) => {\n  const { onClose } = props;\n  const router = useRouter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const ref = useRef<IPluginCenterDialogRef>(null);\n  const { baseId, tableId } = useBaseResource() as IBaseResourceTable;\n  const { mutate: installViewPluginMutate } = useMutation({\n    mutationFn: (ro: IViewInstallPluginRo) => installViewPlugin(tableId, ro),\n    onSuccess: (res) => {\n      ref.current?.close();\n      const { viewId } = res.data;\n      const url = getNodeUrl({\n        baseId,\n        resourceType: BaseNodeResourceType.Table,\n        resourceId: tableId,\n        viewId,\n      });\n      if (url) {\n        router.push(url, undefined, { shallow: true });\n      }\n    },\n  });\n  return (\n    <PluginCenterDialog\n      positionType={PluginPosition.View}\n      onInstall={(id, name) => {\n        installViewPluginMutate({\n          pluginId: id,\n          name,\n        });\n        onClose();\n      }}\n    >\n      <Button variant={'ghost'} size={'xs'} className=\"w-full justify-start font-normal\">\n        <PluginViewIcon className=\"pr-1 text-lg\" />\n        {t('table:view.addPluginView')}\n      </Button>\n    </PluginCenterDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/AddView.tsx",
    "content": "import { ViewType, getUniqName } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { useViews } from '@teable/sdk';\nimport { useTablePermission } from '@teable/sdk/hooks';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { VIEW_ICON_MAP } from '../../view/constant';\nimport { useAddView } from '../../view/list/useAddView';\n\nexport const AddView: React.FC = () => {\n  const addView = useAddView();\n  const views = useViews();\n  const permission = useTablePermission();\n  const [isOpen, setOpen] = useState(false);\n  const { t } = useTranslation('table');\n\n  const viewInfoList = [\n    {\n      name: t('view.category.table'),\n      type: ViewType.Grid,\n      Icon: VIEW_ICON_MAP[ViewType.Grid],\n    },\n    {\n      name: t('view.category.gallery'),\n      type: ViewType.Gallery,\n      Icon: VIEW_ICON_MAP[ViewType.Gallery],\n    },\n    {\n      name: t('view.category.kanban'),\n      type: ViewType.Kanban,\n      Icon: VIEW_ICON_MAP[ViewType.Kanban],\n    },\n    {\n      name: t('view.category.calendar'),\n      type: ViewType.Calendar,\n      Icon: VIEW_ICON_MAP[ViewType.Calendar],\n    },\n    {\n      name: t('view.category.form'),\n      type: ViewType.Form,\n      Icon: VIEW_ICON_MAP[ViewType.Form],\n    },\n  ];\n\n  const onClick = (type: ViewType, name: string) => {\n    const uniqueName = getUniqName(\n      name.split(' ')[0],\n      views?.map((view) => view.name)\n    );\n    addView(type, uniqueName);\n    setOpen(false);\n  };\n\n  if (!permission['view|create']) {\n    return null;\n  }\n\n  return (\n    <Popover open={isOpen} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button className=\"shrink-0\" size=\"icon-xs\" variant=\"outline\">\n          <Plus className=\"size-4\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"start\" className=\"w-36 p-1\">\n        {viewInfoList.map((item) => {\n          const { name, type, Icon } = item;\n          return (\n            <Button\n              key={type}\n              variant={'ghost'}\n              size={'xs'}\n              className=\"w-full justify-start font-normal\"\n              onClick={() => onClick(type, name)}\n            >\n              <Icon className=\"pr-1 text-lg\" />\n              {name}\n            </Button>\n          );\n        })}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/BaseShare.tsx",
    "content": "import { Button } from '@teable/ui-lib/shadcn';\nimport { ChevronRight, Send } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { PublishBaseDialog } from './publish-base/PublishBaseDialog';\n\nexport const BaseShare = ({ onClose }: { onClose: () => void }) => {\n  const { t } = useTranslation(['table', 'common', 'space']);\n  return (\n    <PublishBaseDialog onClose={onClose} closeOnSuccess={false}>\n      <Button\n        variant=\"outline\"\n        className=\"flex h-10 w-full items-center gap-2 bg-muted px-3 py-[10px]\"\n      >\n        <div className=\"flex-start flex flex-1 items-center gap-2\">\n          <Send className=\"size-4\" />\n          {t('space:publishBase.publishToCommunity')}\n        </div>\n\n        <ChevronRight className=\"size-4 shrink-0\" />\n      </Button>\n    </PublishBaseDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/Collaborators.tsx",
    "content": "import { ColorUtils, contractColorForTheme, getCollaboratorsChannel } from '@teable/core';\nimport { useTheme } from '@teable/next-themes';\nimport type { ICollaboratorUser } from '@teable/sdk';\nimport { useSession, CollaboratorWithHoverCard } from '@teable/sdk';\nimport { useConnection, useTableId } from '@teable/sdk/hooks';\nimport { cn, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { chunk, isEmpty } from 'lodash';\nimport React, { useEffect, useMemo, useState } from 'react';\nimport type { Presence } from 'sharedb/lib/client';\n\ninterface CollaboratorsProps {\n  className?: string;\n  maxAvatarLen?: number;\n}\n\nexport const Collaborators: React.FC<CollaboratorsProps> = ({ className, maxAvatarLen = 3 }) => {\n  const { connection } = useConnection();\n  const tableId = useTableId();\n  const { user: sessionUser } = useSession();\n  const { resolvedTheme } = useTheme();\n\n  const [presence, setPresence] = useState<Presence>();\n  const user = useMemo(\n    () => ({\n      id: sessionUser.id,\n      avatar: sessionUser.avatar,\n      name: sessionUser.name,\n      email: sessionUser.email,\n    }),\n    [sessionUser]\n  );\n  const [users, setUsers] = useState<ICollaboratorUser[]>([{ ...user }]);\n  const [boardUsers, hiddenUser] = chunk(users, maxAvatarLen);\n\n  useEffect(() => {\n    if (!connection || !tableId || !user) {\n      return;\n    }\n    const channel = getCollaboratorsChannel(tableId as string);\n    setPresence(connection.getPresence(channel));\n    setUsers([{ ...user }]);\n  }, [connection, tableId, user]);\n\n  useEffect(() => {\n    if (!presence) {\n      return;\n    }\n\n    const channelTableId = presence.channel.split('_').pop();\n\n    if (presence.subscribed && tableId !== channelTableId) {\n      return;\n    }\n\n    presence.subscribe();\n\n    const presenceKey = `${tableId}_${user.id}`;\n    const localPresence = presence.create(presenceKey);\n    localPresence.submit(user, (error) => {\n      error && console.error('submit error:', error);\n    });\n\n    const receiveHandler = () => {\n      let newUser;\n      const { remotePresences } = presence;\n      if (isEmpty(remotePresences)) {\n        newUser = [{ ...user }];\n      } else {\n        const remoteUsers = Object.values(remotePresences);\n        newUser = [{ ...user }, ...remoteUsers];\n      }\n      setUsers(newUser);\n    };\n\n    presence.on('receive', receiveHandler);\n\n    return () => {\n      presence.unsubscribe();\n      presence?.removeListener('receive', receiveHandler);\n    };\n  }, [connection, presence, tableId, user]);\n\n  return (\n    <div className={cn('gap-1 items-center flex', className)}>\n      {boardUsers?.map(({ id, name, avatar, email }) => {\n        const borderColor = contractColorForTheme(\n          ColorUtils.getRandomHexFromStr(`${tableId}_${id}`),\n          resolvedTheme\n        );\n        return (\n          <CollaboratorWithHoverCard\n            key={id}\n            id={id}\n            name={name}\n            avatar={avatar}\n            email={email}\n            borderColor={borderColor}\n          />\n        );\n      })}\n      {hiddenUser ? (\n        <Popover>\n          <PopoverTrigger asChild>\n            <div className=\"relative size-6 shrink-0 grow-0 cursor-pointer select-none overflow-hidden rounded-full border-slate-200\">\n              <p className=\"flex size-full items-center justify-center rounded-full border-2 text-center text-xs\">\n                +{hiddenUser.length}\n              </p>\n            </div>\n          </PopoverTrigger>\n          <PopoverContent className=\"max-h-64 w-36 overflow-y-auto\">\n            {hiddenUser.map(({ id, name, avatar, email }) => {\n              const borderColor = contractColorForTheme(\n                ColorUtils.getRandomHexFromStr(`${tableId}_${id}`),\n                resolvedTheme\n              );\n              return (\n                <div key={id} className=\"flex items-center truncate p-1\">\n                  <CollaboratorWithHoverCard\n                    id={id}\n                    name={name}\n                    avatar={avatar}\n                    email={email}\n                    borderColor={borderColor}\n                  />\n                  <div className=\"flex-1 truncate pl-1\">{name}</div>\n                </div>\n              );\n            })}\n          </PopoverContent>\n        </Popover>\n      ) : null}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/LockedViewTip.tsx",
    "content": "import { X } from '@teable/icons';\nimport { usePersonalView } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useLockedViewTipStore } from '../store';\n\nexport const LockedViewTip = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { setVisible } = useLockedViewTipStore();\n  const { openPersonalView } = usePersonalView();\n\n  return (\n    <div className=\"relative flex w-full items-center justify-between py-1 text-xs text-gray-500 duration-500 animate-in fade-in dark:text-gray-400 sm:justify-center\">\n      <div className=\"relative px-2\">{t('table:view.locked.tip')}</div>\n      <div className=\"mr-2 flex sm:absolute sm:right-2 sm:top-1/2 sm:mr-0 sm:-translate-y-1/2\">\n        <Button\n          size=\"xs\"\n          className=\"flex h-5\"\n          onClick={() => {\n            openPersonalView();\n            setVisible(false);\n          }}\n        >\n          {t('table:view.action.enable')}\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"ml-2 flex size-5 p-[2px]\"\n          onClick={() => setVisible(false)}\n        >\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/TableHeader.tsx",
    "content": "import { Copy, HelpCircle, MoreHorizontal, UserPlus } from '@teable/icons';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport {\n  useBase,\n  useIsHydrated,\n  useIsReadOnlyPreview,\n  useIsTouchDevice,\n  useTemplate,\n  useView,\n} from '@teable/sdk/hooks';\nimport {\n  Button,\n  cn,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  ScrollArea,\n  ScrollBar,\n  Sheet,\n  SheetContent,\n  SheetHeader,\n  SheetTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\n\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useEffect, useState } from 'react';\nimport { ShareBasePopover } from '@/features/app/components/collaborator/share/ShareBasePopover';\nimport { PublicOperateButton } from '@/features/app/components/PublicOperateButton';\nimport type { IBaseResourceTable } from '@/features/app/hooks/useBaseResource';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { useIsInIframe } from '@/features/app/hooks/useIsInIframe';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { BaseNodeMore } from '../../base/base-side-bar/BaseNodeMore';\nimport { ExpandViewList } from '../../view/list/ExpandViewList';\nimport { ViewList } from '../../view/list/ViewList';\nimport { useLockedViewTipStore } from '../store';\nimport { AddView } from './AddView';\nimport { Collaborators } from './Collaborators';\nimport { LockedViewTip } from './LockedViewTip';\nimport { TableInfo } from './TableInfo';\n\nconst RightActions = ({ setIsEditing }: { setIsEditing?: (isEditing: boolean) => void }) => {\n  const base = useBase();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [open, setOpen] = useState(false);\n  const { tableId } = useBaseResource() as IBaseResourceTable;\n  const isTouchDevice = useIsTouchDevice();\n  const isHydrated = useIsHydrated();\n  const router = useRouter();\n  const url = router.asPath;\n\n  const collapsedTrigger = (\n    <Button\n      variant=\"ghost\"\n      size=\"icon-xs\"\n      className=\"shrink-0 truncate font-normal @md/view-header:hidden\"\n    >\n      <MoreHorizontal className=\"size-4 shrink-0\" />\n    </Button>\n  );\n\n  useEffect(() => {\n    setOpen(false);\n  }, [url, setOpen]);\n\n  // Collapsed menu content (for small screens)\n  const collapsedContent = isTouchDevice ? (\n    // Touch device: Sheet\n    <Sheet open={open} onOpenChange={setOpen}>\n      <SheetTrigger asChild>{collapsedTrigger}</SheetTrigger>\n      <SheetContent side=\"bottom\" className=\"h-auto max-h-[80vh] overflow-y-auto rounded-t-lg p-0\">\n        <SheetHeader className=\"sticky top-0 z-10 flex h-12 items-center justify-center border-b bg-background text-lg font-medium\">\n          {t('common:actions.more')}\n        </SheetHeader>\n        <div className=\"pb-safe flex flex-col\">\n          <Collaborators className=\"flex border-b p-3\" />\n          <ShareBasePopover\n            base={{\n              name: base.name,\n              role: base.role,\n              id: base.id,\n              enabledAuthority: base.enabledAuthority,\n            }}\n          >\n            <Button\n              variant=\"ghost\"\n              className=\"flex w-full items-center justify-start gap-3 border-b p-3\"\n            >\n              <UserPlus className=\"size-4\" />\n              <span>{t('space:action.invite')}</span>\n            </Button>\n          </ShareBasePopover>\n          <Button\n            asChild\n            variant=\"ghost\"\n            className=\"flex w-full items-center justify-start gap-3 border-b p-3\"\n          >\n            <Link\n              href={t('help.mainLink')}\n              title={t('help.title')}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              <HelpCircle className=\"size-4\" />\n              <span>{t('help.title')}</span>\n            </Link>\n          </Button>\n\n          <BaseNodeMore\n            resourceType={BaseNodeResourceType.Table}\n            resourceId={tableId}\n            variant=\"list\"\n            onRename={() => {\n              setOpen(false);\n              setIsEditing?.(true);\n            }}\n          />\n        </div>\n      </SheetContent>\n    </Sheet>\n  ) : (\n    // Non-touch device: Popover\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>{collapsedTrigger}</PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"start\" className=\"w-40 p-0\">\n        <div className=\"flex flex-col\">\n          <Collaborators className=\"flex p-2\" />\n          <ShareBasePopover\n            base={{\n              name: base.name,\n              role: base.role,\n              id: base.id,\n              enabledAuthority: base.enabledAuthority,\n            }}\n          >\n            <Button variant=\"ghost\" size=\"xs\" className=\"flex justify-start\">\n              <UserPlus className=\"size-4\" /> {t('space:action.invite')}\n            </Button>\n          </ShareBasePopover>\n          <Button asChild variant=\"ghost\" size=\"xs\" className=\"flex justify-start\">\n            <a href={t('help.mainLink')} title={t('help.title')} target=\"_blank\" rel=\"noreferrer\">\n              <HelpCircle className=\"size-4\" /> {t('help.title')}\n            </a>\n          </Button>\n          <BaseNodeMore\n            resourceType={BaseNodeResourceType.Table}\n            resourceId={tableId}\n            onRename={() => {\n              setOpen(false);\n              setIsEditing?.(true);\n            }}\n          >\n            <Button variant=\"ghost\" size=\"xs\" className=\"flex justify-start\">\n              <MoreHorizontal className=\"size-4\" /> {t('common:actions.more')}\n            </Button>\n          </BaseNodeMore>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n\n  return (\n    <>\n      {/* Expanded layout for large screens (always visible on non-touch devices) */}\n      <div className={cn('gap-2 md:gap-3', isTouchDevice ? 'hidden @md/view-header:flex' : 'flex')}>\n        {isHydrated && <Collaborators className=\"flex\" />}\n        <div className=\"flex items-center gap-1\">\n          <ShareBasePopover\n            base={{\n              name: base.name,\n              role: base.role,\n              id: base.id,\n              enabledAuthority: base.enabledAuthority,\n            }}\n          >\n            <Button variant=\"default\" className=\"mr-1 px-2 @md/view-header:px-3\" size=\"sm\">\n              <UserPlus className=\"size-4\" />\n              <span className=\"hidden @md/view-header:inline\">{t('space:action.invite')}</span>\n            </Button>\n          </ShareBasePopover>\n          <Button asChild variant=\"ghost\" size=\"icon-xs\">\n            <Link\n              href={t('help.mainLink')}\n              title={t('help.title')}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              <HelpCircle className=\"size-4\" />\n            </Link>\n          </Button>\n          <BaseNodeMore\n            resourceType={BaseNodeResourceType.Table}\n            resourceId={tableId}\n            onRename={() => setIsEditing?.(true)}\n          >\n            <Button variant=\"ghost\" size=\"icon-xs\">\n              <MoreHorizontal className=\"size-4\" />\n            </Button>\n          </BaseNodeMore>\n        </div>\n      </div>\n\n      {/* Collapsed menu for small screens (only on touch devices) */}\n      {isTouchDevice && collapsedContent}\n    </>\n  );\n};\n\nexport const TableHeader: React.FC = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const view = useView();\n  const { visible } = useLockedViewTipStore();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const template = useTemplate();\n  const isInIframe = useIsInIframe();\n  // Only show PublicOperateButton for real templates, not for share mode\n  const isRealTemplate = !!template && !isInIframe;\n  const tipVisible = view?.isLocked && visible;\n  const [isEditing, setIsEditing] = useState(false);\n  return (\n    <Fragment>\n      <div\n        className={cn(\n          'flex h-12 shrink-0 flex-row items-center gap-2 pl-4 pr-2 @container/view-header',\n          tipVisible && 'border-b'\n        )}\n      >\n        <TableInfo className=\"shrink-0 grow-0\" isEditing={isEditing} setIsEditing={setIsEditing} />\n        <ExpandViewList />\n        <ScrollArea className=\"h-[42px]\">\n          <div className=\"flex h-[42px] items-center gap-2\">\n            <ViewList />\n          </div>\n          <ScrollBar orientation=\"horizontal\" />\n        </ScrollArea>\n        <AddView />\n        <div className=\"grow basis-0\"></div>\n        {!isReadOnlyPreview && <RightActions setIsEditing={setIsEditing} />}\n        {isRealTemplate && (\n          <div className=\"flex min-w-20 items-center gap-1\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"text-[13px] font-normal\"\n              onClick={() => {\n                const link = `${window.location.origin}/t/${template!.id}`;\n                navigator.clipboard.writeText(link);\n                toast.success(t('common:actions.copyLink'));\n              }}\n            >\n              <Copy className=\"size-4\" />\n              {t('common:actions.copyLink')}\n            </Button>\n            <PublicOperateButton />\n          </div>\n        )}\n      </div>\n      {tipVisible && <LockedViewTip />}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/TableInfo.tsx",
    "content": "import { Table2 } from '@teable/icons';\nimport {\n  useConnection,\n  useTable,\n  useTablePermission,\n  useLanDayjs,\n  useIsHydrated,\n} from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { cn, Input } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { EmojiPicker } from '@/features/app/components/emoji/EmojiPicker';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useImportStatus } from '../hooks/use-import-status';\n\ninterface ITableInfoProps {\n  className?: string;\n  isEditing?: boolean;\n  setIsEditing?: (isEditing: boolean) => void;\n}\n\nexport const TableInfo: React.FC<ITableInfoProps> = (props: ITableInfoProps) => {\n  const { className, isEditing: isEditingProp, setIsEditing: setIsEditingProp } = props;\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [internalIsEditing, setInternalIsEditing] = useState(false);\n  const isControlled = isEditingProp !== undefined;\n  const isEditing = isControlled ? isEditingProp : internalIsEditing;\n  const setIsEditing = useCallback(\n    (isEditing: boolean) => {\n      if (isControlled) {\n        setIsEditingProp?.(isEditing);\n      } else {\n        setInternalIsEditing(isEditing);\n      }\n    },\n    [isControlled, setIsEditingProp, setInternalIsEditing]\n  );\n\n  const { connected } = useConnection();\n  const permission = useTablePermission();\n  const table = useTable();\n  const dayjs = useLanDayjs();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isHydrated = useIsHydrated();\n\n  const { loading: isImporting } = useImportStatus(table?.id as string);\n\n  const icon = table?.icon ? (\n    <Emoji size={'1.25rem'} emoji={table.icon} />\n  ) : (\n    <Table2 className=\"size-5\" />\n  );\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      if (isEditing && inputRef.current) {\n        inputRef.current.focus();\n        inputRef.current.select();\n      }\n    }, 200);\n    return () => clearTimeout(timer);\n  }, [isEditing]);\n\n  return (\n    <div\n      className={cn('flex justify-center items-center relative overflow-hidden gap-2', className)}\n    >\n      {connected && !isImporting ? (\n        <EmojiPicker\n          className=\"flex size-5 cursor-pointer items-center justify-center hover:bg-muted-foreground/60\"\n          onChange={(icon: string) => table?.updateIcon(icon)}\n          disabled={!permission['table|update']}\n        >\n          {icon}\n        </EmojiPicker>\n      ) : (\n        <Spin />\n      )}\n      <div\n        className={cn(\n          'relative flex h-8 shrink-0 grow-0 flex-col items-start justify-center gap-1',\n          { 'min-w-16': isEditing }\n        )}\n      >\n        {isEditing ? (\n          <Input\n            ref={inputRef}\n            type=\"text\"\n            defaultValue={table?.name}\n            className=\"rounded-none absolute left-0 top-0 size-full cursor-text\"\n            // eslint-disable-next-line jsx-a11y/no-autofocus\n            autoFocus\n            onBlur={(e) => {\n              if (e.target.value && e.target.value !== table?.name) {\n                table?.updateName(e.target.value);\n              }\n              setIsEditing(false);\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') {\n                if (e.currentTarget.value && e.currentTarget.value !== table?.name) {\n                  table?.updateName(e.currentTarget.value);\n                }\n                setIsEditing(false);\n              }\n            }}\n            onMouseDown={(e) => {\n              e.stopPropagation();\n            }}\n          />\n        ) : (\n          <div\n            className=\"text-sm leading-none\"\n            onDoubleClick={() => {\n              permission['table|update'] && setIsEditing(true);\n            }}\n          >\n            {table?.name}\n          </div>\n        )}\n        <div className=\"hidden text-[11px] leading-3 text-muted-foreground @xl/view-header:block\">\n          {t('table:lastModify')} {isHydrated ? dayjs(table?.lastModifiedTime).fromNow() : ''}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/publish-base/AppPublishContext.tsx",
    "content": "import { createContext, useContext } from 'react';\nimport type { IUnpublishedApp } from './UnpublishedAppsDialog';\n\nexport interface IAppPublishContextValue {\n  publishApp?: (app: IUnpublishedApp) => Promise<void>;\n  onAppStateChange?: (callback: (apps: IUnpublishedApp[]) => void) => void;\n  onPublishComplete?: (callback: () => void) => void;\n}\n\nexport const AppPublishContext = createContext<IAppPublishContextValue>({});\n\nexport const useAppPublishContext = () => {\n  return useContext(AppPublishContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/publish-base/NodeSelect.tsx",
    "content": "'use client';\n\nimport { ChevronDown } from '@teable/icons';\nimport type { IBaseNodeVo } from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport { BaseNodeResourceIconMap, getNodeIcon, getNodeName } from '../../../base/base-node/hooks';\nimport { useBaseNodeContext } from '../../../base/base-node/hooks/useBaseNodeContext';\n\ninterface INodeSelectProps {\n  value?: string;\n  onChange?: (nodeId: string) => void;\n  placeholder?: string;\n  /** List of node IDs (from NodeTreeSelect's selected items) */\n  nodeIds?: string[];\n  className?: string;\n  disabled?: boolean;\n}\n\nexport const NodeSelect = (props: INodeSelectProps) => {\n  const { value, onChange, placeholder, nodeIds, className, disabled = false } = props;\n  const { t } = useTranslation(['common']);\n  const { treeItems } = useBaseNodeContext();\n\n  // Get nodes based on node ID list and filter out folder type nodes\n  const availableNodes = useMemo(() => {\n    if (!nodeIds || nodeIds.length === 0) {\n      return [];\n    }\n\n    return nodeIds\n      .map((nodeId) => {\n        const node = treeItems[nodeId];\n        // Only return node if it's not a folder, otherwise return undefined\n        if (node && node.resourceType !== BaseNodeResourceType.Folder) {\n          return node as unknown as IBaseNodeVo;\n        }\n        return undefined;\n      })\n      .filter((node): node is IBaseNodeVo => node !== undefined);\n  }, [nodeIds, treeItems]);\n\n  // Render node icon\n  const renderNodeIcon = (node: IBaseNodeVo) => {\n    const IconComponent = BaseNodeResourceIconMap[node.resourceType];\n    const icon = getNodeIcon(node);\n\n    if (node.resourceType === BaseNodeResourceType.Table && icon) {\n      return <Emoji emoji={icon} size={16} className=\"size-4 shrink-0\" />;\n    }\n\n    return <IconComponent className=\"size-4 shrink-0\" />;\n  };\n\n  return (\n    <Select\n      value={value}\n      onValueChange={onChange}\n      disabled={disabled || availableNodes.length === 0}\n    >\n      <SelectTrigger size=\"lg\" className={cn(className, '[&_svg:last-child]:hidden')}>\n        <SelectValue placeholder={placeholder || t('common:actions.select')} />\n        <ChevronDown className=\"ml-2 size-4 shrink-0 opacity-50\" />\n      </SelectTrigger>\n      <SelectContent>\n        {availableNodes.length === 0 ? (\n          <div className=\"py-6 text-center text-sm text-muted-foreground\">\n            {t('common:noResult')}\n          </div>\n        ) : (\n          availableNodes.map((node) => (\n            <SelectItem key={node.id} value={node.id}>\n              <div className=\"flex items-center gap-2\">\n                {renderNodeIcon(node)}\n                <span className=\"truncate\">{getNodeName(node)}</span>\n              </div>\n            </SelectItem>\n          ))\n        )}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/publish-base/NodeTreeSelect.tsx",
    "content": "'use client';\n\nimport type { IBaseNodeVo } from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport type { ItemInstance } from '@teable/ui-lib/base/headless-tree';\nimport {\n  checkboxesFeature,\n  hotkeysCoreFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n  useTree,\n} from '@teable/ui-lib/base/headless-tree';\nimport { Button, cn, Input, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { ChevronDown, Search, X } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Emoji } from '@/features/app/components/emoji/Emoji';\nimport {\n  BaseNodeResourceIconMap,\n  getNodeIcon,\n  getNodeName,\n  ROOT_ID,\n} from '../../../base/base-node/hooks';\nimport type { TreeItemData } from '../../../base/base-node/hooks';\nimport { useBaseNodeContext } from '../../../base/base-node/hooks/useBaseNodeContext';\n\ninterface INodeSelectProps {\n  value?: string;\n  onChange?: (nodeId: string, node: IBaseNodeVo) => void;\n  placeholder?: string;\n  allowedTypes?: BaseNodeResourceType[];\n  className?: string;\n  disabled?: boolean;\n  // Checkbox\n  showCheckbox?: boolean;\n  checkedItems?: string[];\n  onCheckedItemsChange?: (checkedItems: string[]) => void;\n  // Total node count for \"All Nodes\" display\n  totalNodeCount?: number;\n}\n\nconst INDENTATION_WIDTH = 16;\n\nexport const NodeTreeSelect = (props: INodeSelectProps) => {\n  const {\n    value,\n    onChange,\n    placeholder,\n    allowedTypes,\n    className,\n    disabled = false,\n    showCheckbox = false,\n    checkedItems: externalCheckedItems,\n    onCheckedItemsChange,\n    totalNodeCount,\n  } = props;\n  const { t } = useTranslation(['common']);\n  const [open, setOpen] = useState(false);\n  const { treeItems } = useBaseNodeContext();\n  const treeItemsRef = useRef(treeItems);\n  const [expandedItems, setExpandedItems] = useState<string[]>([]);\n  const [internalCheckedItems, setInternalCheckedItems] = useState<string[]>([]);\n  const [searchQuery, setSearchQuery] = useState('');\n\n  // use external checkedItems or internal state\n  const checkedItems = externalCheckedItems ?? internalCheckedItems;\n  const setCheckedItems = useCallback(\n    (updater: string[] | ((prev: string[]) => string[])) => {\n      const newCheckedItems = typeof updater === 'function' ? updater(checkedItems) : updater;\n      if (onCheckedItemsChange) {\n        onCheckedItemsChange(newCheckedItems);\n      } else {\n        setInternalCheckedItems(newCheckedItems);\n      }\n    },\n    [checkedItems, onCheckedItemsChange]\n  );\n\n  // filter nodes: filter by allowedTypes and search query\n  const filteredTreeItems = useMemo(() => {\n    const normalizedQuery = searchQuery.toLowerCase().trim();\n\n    // filter by type\n    let filtered: Record<string, TreeItemData> = treeItems;\n\n    if (allowedTypes && allowedTypes.length > 0) {\n      filtered = {};\n\n      const filterByType = (nodeId: string): boolean => {\n        const node = treeItems[nodeId];\n        if (!node) return false;\n\n        const isFolder = node.resourceType === BaseNodeResourceType.Folder;\n\n        if (isFolder && node.children) {\n          const filteredChildren = node.children.filter((childId) => filterByType(childId));\n          if (filteredChildren.length > 0) {\n            filtered[nodeId] = {\n              ...node,\n              children: filteredChildren,\n            };\n            return true;\n          }\n          return false;\n        }\n\n        if (allowedTypes.includes(node.resourceType)) {\n          filtered[nodeId] = node;\n          return true;\n        }\n\n        return false;\n      };\n\n      const rootNode = treeItems[ROOT_ID];\n      if (rootNode?.children) {\n        const filteredChildren = rootNode.children.filter((childId) => filterByType(childId));\n        filtered[ROOT_ID] = {\n          ...rootNode,\n          children: filteredChildren,\n        };\n      }\n    }\n\n    // filter by search query\n    if (normalizedQuery) {\n      const searchFiltered: Record<string, TreeItemData> = {};\n      const matchedNodes = new Set<string>();\n\n      // find all matched nodes\n      const findMatches = (nodeId: string) => {\n        const node = filtered[nodeId];\n        if (!node) return;\n\n        const nodeName = getNodeName(node).toLowerCase();\n        if (nodeName.includes(normalizedQuery)) {\n          matchedNodes.add(nodeId);\n        }\n\n        if (node.children) {\n          node.children.forEach((childId) => findMatches(childId));\n        }\n      };\n\n      // collect all ancestors of matched nodes\n      const collectAncestors = (nodeId: string) => {\n        let currentId = nodeId;\n        while (currentId && currentId !== ROOT_ID) {\n          const node = filtered[currentId];\n          if (node) {\n            matchedNodes.add(currentId);\n            currentId = node.parentId || '';\n          } else {\n            break;\n          }\n        }\n      };\n\n      // build filtered tree\n      const buildFilteredTree = (nodeId: string): boolean => {\n        const node = filtered[nodeId];\n        if (!node) return false;\n\n        if (node.children) {\n          const filteredChildren = node.children.filter((childId) => {\n            if (matchedNodes.has(childId)) {\n              return buildFilteredTree(childId) || true;\n            }\n            return false;\n          });\n\n          if (filteredChildren.length > 0 || matchedNodes.has(nodeId)) {\n            searchFiltered[nodeId] = {\n              ...node,\n              children: filteredChildren,\n            };\n            return true;\n          }\n          return false;\n        }\n\n        if (matchedNodes.has(nodeId)) {\n          searchFiltered[nodeId] = node;\n          return true;\n        }\n\n        return false;\n      };\n\n      // execute search\n      const rootNode = filtered[ROOT_ID];\n      if (rootNode?.children) {\n        rootNode.children.forEach((childId) => findMatches(childId));\n        matchedNodes.forEach((nodeId) => collectAncestors(nodeId));\n\n        const filteredChildren = rootNode.children.filter((childId) => buildFilteredTree(childId));\n        searchFiltered[ROOT_ID] = {\n          ...rootNode,\n          children: filteredChildren,\n        };\n      }\n\n      return searchFiltered;\n    }\n\n    return filtered;\n  }, [treeItems, allowedTypes, searchQuery]);\n\n  useEffect(() => {\n    treeItemsRef.current = filteredTreeItems;\n  }, [filteredTreeItems]);\n\n  // search: automatically expand all matched nodes\n  useEffect(() => {\n    if (searchQuery.trim()) {\n      const nodesToExpand: string[] = [];\n      const collectFolders = (nodeId: string) => {\n        const node = filteredTreeItems[nodeId];\n        if (!node) return;\n\n        if (node.resourceType === BaseNodeResourceType.Folder && node.children) {\n          nodesToExpand.push(nodeId);\n          node.children.forEach((childId) => collectFolders(childId));\n        }\n      };\n\n      const rootNode = filteredTreeItems[ROOT_ID];\n      if (rootNode?.children) {\n        rootNode.children.forEach((childId) => collectFolders(childId));\n      }\n\n      setExpandedItems(nodesToExpand);\n    }\n  }, [searchQuery, filteredTreeItems]);\n\n  // handle node click\n  const handlePrimaryAction = useCallback(\n    (item: ItemInstance<TreeItemData>) => {\n      const node = item.getItemData();\n      const isFolder = node.resourceType === BaseNodeResourceType.Folder;\n\n      // folder node: only expand/collapse\n      if (isFolder) {\n        return;\n      }\n\n      // non-folder node: trigger selection, but do not close popover\n      onChange?.(item.getId(), node as unknown as IBaseNodeVo);\n    },\n    [onChange]\n  );\n\n  // initialize tree\n  const features = [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature];\n  if (showCheckbox) {\n    features.push(checkboxesFeature);\n  }\n\n  const tree = useTree<TreeItemData>({\n    state: {\n      expandedItems,\n      selectedItems: value ? [value] : [],\n      ...(showCheckbox && { checkedItems }),\n    },\n    setExpandedItems,\n    setSelectedItems: () => {\n      // do not use default setSelectedItems, but handle with onPrimaryAction\n    },\n    ...(showCheckbox && { setCheckedItems }),\n    rootItemId: ROOT_ID,\n    indent: INDENTATION_WIDTH,\n    dataLoader: {\n      getItem: (itemId) => treeItemsRef.current[itemId] ?? {},\n      getChildren: (itemId) => treeItemsRef.current[itemId]?.children ?? [],\n    },\n    getItemName: (item) => getNodeName(item.getItemData()),\n    isItemFolder: (item) => item.getItemData().resourceType === BaseNodeResourceType.Folder,\n    onPrimaryAction: handlePrimaryAction,\n    features,\n  });\n\n  // when treeItems changes, rebuild tree\n  useEffect(() => {\n    if (Object.keys(filteredTreeItems).length > 0) {\n      tree.rebuildTree();\n    }\n  }, [tree, filteredTreeItems]);\n\n  // get selected node information\n  const selectedNode = useMemo(() => {\n    if (!value) return null;\n    return treeItems[value];\n  }, [value, treeItems]);\n\n  // render node icon\n  const renderNodeIcon = useCallback((item: ItemInstance<TreeItemData>) => {\n    const node = item.getItemData();\n    if (!node) return null;\n\n    const IconComponent = BaseNodeResourceIconMap[node.resourceType];\n    const icon = getNodeIcon(node);\n    const isFolder = item.isFolder();\n\n    if (isFolder) {\n      return <IconComponent className=\"size-4 shrink-0\" />;\n    }\n\n    if (node.resourceType === BaseNodeResourceType.Table && icon) {\n      return <Emoji emoji={icon} size={16} className=\"size-4 shrink-0\" />;\n    }\n\n    return <IconComponent className=\"size-4 shrink-0\" />;\n  }, []);\n\n  // render selected nodes (for button display)\n  const renderSelectedNodes = useCallback(() => {\n    if (!showCheckbox || checkedItems.length === 0) {\n      if (selectedNode) {\n        const IconComponent = BaseNodeResourceIconMap[selectedNode.resourceType];\n        const icon = getNodeIcon(selectedNode);\n        return (\n          <>\n            {selectedNode.resourceType === BaseNodeResourceType.Table && icon ? (\n              <Emoji emoji={icon} size={16} className=\"size-4 shrink-0\" />\n            ) : (\n              <IconComponent className=\"size-5 shrink-0\" />\n            )}\n            <span className=\"min-w-0 truncate\">{getNodeName(selectedNode)}</span>\n          </>\n        );\n      }\n      return (\n        <span className=\"truncate text-sm font-normal text-muted-foreground\">\n          {placeholder || t('common:actions.select')}\n        </span>\n      );\n    }\n\n    // If all nodes are selected, show \"All Nodes\"\n    if (totalNodeCount && checkedItems.length === totalNodeCount) {\n      return (\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <div className=\"flex items-center rounded-md bg-surface px-2 py-1\">\n            <span className=\"text-xs font-normal\">{t('common:allNodes')}</span>\n          </div>\n        </div>\n      );\n    }\n\n    // multi-select mode: show first 2, remaining show +N\n    const maxShow = 2;\n    const selectedNodes = checkedItems\n      .map((id) => treeItems[id])\n      .filter((node) => node)\n      .slice(0, maxShow);\n    const remainingCount = checkedItems.length - maxShow;\n\n    return (\n      <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n        {selectedNodes.map((node) => {\n          const IconComponent = BaseNodeResourceIconMap[node.resourceType];\n          const icon = getNodeIcon(node);\n          return (\n            <div\n              key={node.id}\n              className=\"flex min-w-0 items-center gap-1.5 rounded-md bg-surface px-2 py-1\"\n            >\n              {node.resourceType === BaseNodeResourceType.Table && icon ? (\n                <Emoji emoji={icon} size={16} className=\"size-4 shrink-0\" />\n              ) : (\n                <IconComponent className=\"size-4 shrink-0\" />\n              )}\n              <span className=\"min-w-0 truncate text-xs\">{getNodeName(node)}</span>\n            </div>\n          );\n        })}\n        {remainingCount > 0 && (\n          <div className=\"flex items-center rounded-md bg-transparent py-1\">\n            <span className=\"text-sm\">+{remainingCount}</span>\n          </div>\n        )}\n      </div>\n    );\n  }, [showCheckbox, checkedItems, selectedNode, treeItems, placeholder, t, totalNodeCount]);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className={cn('h-9 w-full justify-between p-2', className)}\n          disabled={disabled}\n        >\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">{renderSelectedNodes()}</div>\n          <ChevronDown className=\"ml-2 size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[354px] overflow-hidden p-0\" align=\"start\">\n        {/* search input */}\n        <div className=\"px-4 pt-4\">\n          <div className=\"relative\">\n            <Search className=\"absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n            <Input\n              placeholder={t('common:actions.search')}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              className=\"px-8\"\n            />\n            {searchQuery && (\n              <button\n                onClick={() => setSearchQuery('')}\n                className=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n              >\n                <X className=\"size-4\" />\n              </button>\n            )}\n          </div>\n        </div>\n\n        <div className=\"max-h-[240px] w-full overflow-x-hidden px-4 py-2\">\n          <div\n            {...tree.getContainerProps()}\n            className=\"flex w-full flex-col p-1\"\n            onWheelCapture={(e) => {\n              e.stopPropagation();\n            }}\n          >\n            {tree.getItems().length === 0 ? (\n              <div className=\"py-6 text-center text-sm text-muted-foreground\">\n                {t('common:noResult')}\n              </div>\n            ) : (\n              tree.getItems().map((item) => {\n                const node = item.getItemData();\n                if (!node || Object.keys(node).length === 0) return null;\n\n                const isFolder = item.isFolder();\n                const isExpanded = item.isExpanded();\n                const isSelected = item.isSelected();\n\n                // Check if this is an empty folder (folder with no children)\n                const isEmptyFolder = isFolder && (!node.children || node.children.length === 0);\n\n                // For empty folders, we need to manually handle checkbox since headless-tree\n                // doesn't support checking folders without children\n                const handleEmptyFolderCheckboxChange = () => {\n                  const itemId = item.getId();\n                  if (checkedItems.includes(itemId)) {\n                    setCheckedItems(checkedItems.filter((id) => id !== itemId));\n                  } else {\n                    setCheckedItems([...checkedItems, itemId]);\n                  }\n                };\n\n                return (\n                  <div key={item.getId()} className=\"flex w-full min-w-0 items-center gap-0.5\">\n                    {showCheckbox && (\n                      <input\n                        type=\"checkbox\"\n                        {...(isEmptyFolder\n                          ? {\n                              checked: checkedItems.includes(item.getId()),\n                              onChange: handleEmptyFolderCheckboxChange,\n                            }\n                          : item.getCheckboxProps\n                            ? item.getCheckboxProps()\n                            : {})}\n                        className=\"size-4 shrink-0 cursor-pointer rounded border-gray-300 accent-black\"\n                        onClick={(e) => e.stopPropagation()}\n                      />\n                    )}\n                    <button\n                      {...item.getProps()}\n                      type=\"button\"\n                      className={cn(\n                        'flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent',\n                        isSelected && 'bg-accent',\n                        'cursor-pointer'\n                      )}\n                      style={{\n                        paddingLeft: `${item.getItemMeta().level * INDENTATION_WIDTH + 8}px`,\n                      }}\n                    >\n                      {isFolder && (\n                        <ChevronDown\n                          className={cn(\n                            'size-4 shrink-0 transition-transform',\n                            !isExpanded && '-rotate-90'\n                          )}\n                        />\n                      )}\n\n                      {renderNodeIcon(item)}\n\n                      <div className=\"min-w-0 flex-1 truncate text-left\" title={item.getItemName()}>\n                        {item.getItemName()}\n                      </div>\n                    </button>\n                  </div>\n                );\n              })\n            )}\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/publish-base/PublishBaseDialog.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { generateAttachmentId } from '@teable/core';\nimport { Discord, Heart, InIcon, Link, Plus, Twitter } from '@teable/icons';\nimport type { ITemplateCoverRo, INotifyVo } from '@teable/openapi';\nimport {\n  getTemplateByBaseId,\n  publishBase,\n  unpublishTemplate,\n  UploadType,\n  BaseNodeResourceType,\n} from '@teable/openapi';\nimport { AttachmentManager } from '@teable/sdk/components';\nimport { useBase } from '@teable/sdk/hooks';\nimport { Spin, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  Button,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n  Switch,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport confetti from 'canvas-confetti';\nimport { Camera, Send, Copy, ExternalLink } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useRef, useEffect, useMemo, useCallback } from 'react';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { ROOT_ID } from '../../../base/base-node/hooks';\nimport { useBaseNodeContext } from '../../../base/base-node/hooks/useBaseNodeContext';\nimport { useAppPublishContext } from './AppPublishContext';\nimport { NodeSelect } from './NodeSelect';\nimport { NodeTreeSelect } from './NodeTreeSelect';\nimport type { IUnpublishedApp } from './UnpublishedAppsDialog';\nimport { UnpublishedAppsDialog, getUnpublishedAppNodes } from './UnpublishedAppsDialog';\n\nconst attachmentManager = new AttachmentManager(1);\n\nconst generateShareUrl = (\n  permalink?: string,\n  defaultUrl?: string,\n  snapshotBaseId?: string\n): string => {\n  const origin = typeof window !== 'undefined' ? window.location.origin : '';\n  // Prioritize permalink for stable sharing URL\n  const relativeUrl =\n    permalink || defaultUrl || (snapshotBaseId && `/base/${snapshotBaseId}`) || '';\n  return relativeUrl ? `${origin}${relativeUrl}` : '';\n};\n\ninterface IPublishBaseDialogProps {\n  children: React.ReactNode;\n  onClose: () => void;\n  closeOnSuccess?: boolean;\n}\n\nexport const PublishBaseDialog = (props: IPublishBaseDialogProps) => {\n  const { children, onClose, closeOnSuccess = false } = props;\n  const { t } = useTranslation(['space', 'common']);\n  const base = useBase();\n  const baseId = base?.id;\n  const { treeItems } = useBaseNodeContext();\n  const isCloud = useIsCloud();\n  const appPublishContext = useAppPublishContext();\n\n  const queryClient = useQueryClient();\n\n  const allNodeIds = useMemo(() => {\n    const nodeIds: string[] = [];\n    Object.entries(treeItems).forEach(([id]) => {\n      if (id !== ROOT_ID) {\n        nodeIds.push(id);\n      }\n    });\n    return nodeIds;\n  }, [treeItems]);\n\n  const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);\n  const [open, setOpen] = useState(false);\n  const [title, setTitle] = useState(base?.name || '');\n  const [description, setDescription] = useState<string | undefined>('');\n  const [screenshotUrl, setScreenshotUrl] = useState<string | undefined>();\n  const [uploadProgress, setUploadProgress] = useState(0);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadedCover, setUploadedCover] = useState<\n    | (INotifyVo & {\n        id: string;\n        name: string;\n      })\n    | null\n  >(null);\n  const [includeData, setIncludeData] = useState(true);\n  const [defaultActiveNodeId, setDefaultActiveNodeId] = useState<string | null | undefined>(null);\n  const uploadRef = useRef<HTMLInputElement>(null);\n  const [hasLoadedTemplate, setHasLoadedTemplate] = useState(false);\n  const [successDialogOpen, setSuccessDialogOpen] = useState(false);\n  const [shareUrl, setShareUrl] = useState('');\n  const [unpublishedAppsDialogOpen, setUnpublishedAppsDialogOpen] = useState(false);\n  const [unpublishedApps, setUnpublishedApps] = useState<IUnpublishedApp[]>([]);\n  const [externalApps, setExternalApps] = useState<IUnpublishedApp[] | undefined>(undefined);\n\n  const { data: templateDetail } = useQuery({\n    queryKey: ['template-by-base', baseId],\n    staleTime: 0,\n    refetchOnWindowFocus: false,\n    queryFn: () => getTemplateByBaseId(baseId!).then((res) => res.data),\n    enabled: !!baseId,\n  });\n  const isTemplatePublished = templateDetail?.isPublished;\n  const isTemplateFeatured = templateDetail?.featured ?? false;\n\n  // Handle template data changes (replaces onSuccess callback removed in React Query v5)\n  useEffect(() => {\n    if (!templateDetail) return;\n\n    setTitle(templateDetail?.name || base?.name || '');\n    setDescription(templateDetail?.description);\n    // only update with server data when no manual upload of image\n    if (!uploadedCover) {\n      setScreenshotUrl(templateDetail?.cover?.presignedUrl || undefined);\n    }\n\n    const savedNodes = templateDetail?.publishInfo?.nodes;\n    const nodesToSelect = savedNodes && savedNodes.length > 0 ? savedNodes : allNodeIds;\n    if (nodesToSelect.length > 0) {\n      setSelectedNodeIds(nodesToSelect);\n\n      // Set default active node: use saved data if available and it's in selected nodes\n      const savedDefaultNodeId = templateDetail?.publishInfo?.defaultActiveNodeId;\n      if (savedDefaultNodeId && nodesToSelect.includes(savedDefaultNodeId)) {\n        setDefaultActiveNodeId(savedDefaultNodeId);\n      } else {\n        // Find first non-folder node in selected nodes\n        const firstNonFolderNode = nodesToSelect.find((id: string) => {\n          const node = treeItems[id];\n          return node && node.resourceType !== BaseNodeResourceType.Folder;\n        });\n        setDefaultActiveNodeId(firstNonFolderNode || null);\n      }\n\n      // Only mark as loaded when nodes are actually set\n      setHasLoadedTemplate(true);\n    }\n    setIncludeData(templateDetail?.publishInfo?.includeData ?? true);\n    // Use permalink for stable share URL\n    const permalink = templateDetail?.id ? `/t/${templateDetail.id}` : undefined;\n    setShareUrl(\n      generateShareUrl(\n        permalink,\n        templateDetail?.publishInfo?.defaultUrl,\n        templateDetail?.snapshot?.baseId\n      )\n    );\n  }, [templateDetail, base?.name, allNodeIds, treeItems, uploadedCover]);\n\n  const { mutateAsync: unpublishTemplateMutate, isPending: unpublishTemplateLoading } = useMutation(\n    {\n      mutationFn: () => unpublishTemplate(templateDetail?.id as string).then((res) => res.data),\n      onSuccess: () => {\n        toast.success(t('publishBase.unPublishSuccess'));\n        queryClient.invalidateQueries({ queryKey: ['template-by-base', baseId] });\n        setTitle('');\n        setDescription('');\n        setScreenshotUrl(undefined);\n        setUploadedCover(null);\n      },\n    }\n  );\n\n  const { mutateAsync: publishBaseMutate, isPending: publishBaseLoading } = useMutation({\n    mutationFn: async ({ title, description }: { title: string; description: string }) => {\n      // if user manually uploaded a new image, use the new cover; otherwise use the existing cover\n      const cover: ITemplateCoverRo | null = uploadedCover\n        ? {\n            id: uploadedCover.id,\n            name: uploadedCover.name,\n            token: uploadedCover.token,\n            size: uploadedCover.size,\n            url: uploadedCover.url,\n            path: uploadedCover.path,\n            mimetype: uploadedCover.mimetype,\n            width: uploadedCover.width,\n            height: uploadedCover.height,\n          }\n        : templateDetail?.cover || null;\n\n      return publishBase(baseId!, {\n        title,\n        description,\n        cover,\n        nodes: selectedNodeIds.length > 0 ? selectedNodeIds : undefined,\n        includeData,\n        defaultActiveNodeId,\n      }).then((res) => res.data);\n    },\n    onSuccess: (data) => {\n      const { baseId: templateBaseId, defaultUrl, permalink } = data;\n      queryClient.invalidateQueries({ queryKey: ['template-by-base', baseId] });\n      // after publish success, clear the uploaded cover, use server data next time\n      setUploadedCover(null);\n      // Close the publish dialog and show success dialog\n      setOpen(false);\n      // Generate share URL with permalink\n      setShareUrl(generateShareUrl(permalink, defaultUrl, templateBaseId));\n      setSuccessDialogOpen(true);\n      // Trigger fireworks effect\n      fireConfetti();\n      // Close parent dialog if closeOnSuccess is true\n      if (closeOnSuccess) {\n        onClose();\n      }\n    },\n  });\n\n  // Initialize selected nodes on first load\n  useEffect(() => {\n    if (allNodeIds.length > 0 && !hasLoadedTemplate && selectedNodeIds.length === 0) {\n      setSelectedNodeIds(allNodeIds);\n\n      // Also set default active node when initializing selected nodes\n      const firstNonFolderNode = allNodeIds.find((id) => {\n        const node = treeItems[id];\n        return node && node.resourceType !== BaseNodeResourceType.Folder;\n      });\n      setDefaultActiveNodeId(firstNonFolderNode || null);\n      setHasLoadedTemplate(true);\n    }\n  }, [allNodeIds, hasLoadedTemplate, selectedNodeIds.length, treeItems]);\n\n  // Ensure defaultActiveNodeId is always within selectedNodeIds (selected non-folder nodes only)\n  useEffect(() => {\n    // Skip if no selected nodes\n    if (selectedNodeIds.length === 0) return;\n\n    // Calculate selected non-folder nodes to avoid dependency on memoized array\n    const currentSelectedNonFolderNodes = selectedNodeIds.filter((id) => {\n      const node = treeItems[id];\n      return node && node.resourceType !== BaseNodeResourceType.Folder;\n    });\n\n    // If no default active node is set, or the current one is not in selected nodes, set the first selected non-folder node\n    if (!defaultActiveNodeId || !selectedNodeIds.includes(defaultActiveNodeId)) {\n      if (currentSelectedNonFolderNodes.length > 0) {\n        setDefaultActiveNodeId(currentSelectedNonFolderNodes[0]);\n      } else {\n        setDefaultActiveNodeId(null);\n      }\n    }\n  }, [defaultActiveNodeId, selectedNodeIds, treeItems]);\n\n  useEffect(() => {\n    if (!open) {\n      // when dialog is closed, reset upload state\n      setUploadedCover(null);\n      setUploadProgress(0);\n      setIsUploading(false);\n      setHasLoadedTemplate(false);\n    }\n  }, [open]);\n\n  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = e.target.files;\n    if (!files || files.length === 0) return;\n\n    const file = files[0];\n    // validate file type\n    if (!file.type.startsWith('image/')) {\n      toast.error(t('publishBase.invalidImageType'));\n      return;\n    }\n\n    setIsUploading(true);\n    setUploadProgress(0);\n\n    const attachmentId = generateAttachmentId();\n    const fileName = file.name;\n    const toastId = toast.loading(t('publishBase.uploading'));\n\n    attachmentManager.upload(\n      [{ id: attachmentId, instance: file }],\n      UploadType.Template,\n      {\n        successCallback: (_, result: INotifyVo) => {\n          setScreenshotUrl(result.presignedUrl);\n          setUploadedCover({\n            ...result,\n            id: attachmentId,\n            name: fileName,\n          });\n          setIsUploading(false);\n          toast.success(t('publishBase.uploadSuccess'), { id: toastId });\n        },\n        errorCallback: (_, error) => {\n          setIsUploading(false);\n          toast.error(error || t('publishBase.uploadFailed'), { id: toastId });\n        },\n        progressCallback: (_, progress) => {\n          setUploadProgress(progress);\n        },\n      },\n      baseId\n    );\n  };\n\n  const handleUploadClick = () => {\n    if (uploadRef.current) {\n      uploadRef.current.value = '';\n      uploadRef.current.click();\n    }\n  };\n\n  useEffect(() => {\n    if (defaultActiveNodeId && !selectedNodeIds.includes(defaultActiveNodeId)) {\n      setDefaultActiveNodeId(null);\n    }\n  }, [selectedNodeIds, defaultActiveNodeId]);\n\n  const handleCopyUrl = () => {\n    navigator.clipboard.writeText(shareUrl);\n    toast.success(t('publishBase.urlCopied'));\n  };\n\n  const handleShareToX = () => {\n    const text = encodeURIComponent(`Check out this template: ${title}`);\n    window.open(\n      `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(shareUrl)}`,\n      '_blank'\n    );\n  };\n\n  const handleShareToLinkedIn = () => {\n    window.open(\n      `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`,\n      '_blank'\n    );\n  };\n\n  const handleShareToDiscord = () => {\n    // Discord doesn't have a direct share URL, so we just copy the URL\n    navigator.clipboard.writeText(shareUrl);\n    toast.success(t('publishBase.urlCopiedForDiscord'));\n  };\n\n  const fireConfetti = () => {\n    confetti({\n      particleCount: 100,\n      spread: 70,\n      origin: { y: 0.6 },\n    });\n  };\n\n  const handlePublishClick = useCallback(() => {\n    if (!title || !description) {\n      toast.error(t('publishBase.tips.publishValidation'));\n      return;\n    }\n\n    if (selectedNodeIds.length === 0) {\n      toast.error(t('publishBase.tips.atLeastOneNode'));\n      return;\n    }\n\n    // Check for unpublished app nodes\n    const unpublishedAppNodes = getUnpublishedAppNodes(selectedNodeIds, treeItems);\n    if (unpublishedAppNodes.length > 0) {\n      setUnpublishedApps(unpublishedAppNodes);\n      setExternalApps(undefined); // Reset external apps state\n      setUnpublishedAppsDialogOpen(true);\n      return;\n    }\n\n    // No unpublished apps, proceed with publishing\n    publishBaseMutate({ title, description: description || '' });\n  }, [title, description, selectedNodeIds, treeItems, publishBaseMutate, t]);\n\n  const handleContinuePublish = useCallback(() => {\n    setUnpublishedAppsDialogOpen(false);\n    publishBaseMutate({ title, description: description || '' });\n  }, [title, description, publishBaseMutate]);\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent className=\"max-w-[960px] gap-0\">\n          <DialogHeader className=\"h-20\">\n            <DialogTitle>{t('publishBase.title')}</DialogTitle>\n            <DialogDescription className=\"text-sm text-muted-foreground\">\n              {t('publishBase.description')}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"flex w-full gap-10 overflow-x-hidden\">\n            <div className=\"relative flex min-w-[358px] flex-1 flex-col gap-6 px-0.5\">\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"text-sm font-semibold\">{t('publishBase.infoTitle')}</div>\n                <div className=\"flex flex-col gap-2\">\n                  <span className=\"text-sm\">{t('publishBase.form.title')}</span>\n                  <Input\n                    value={title}\n                    onChange={(e) => setTitle(e.target.value)}\n                    placeholder={t('publishBase.form.titlePlaceholder')}\n                  />\n                </div>\n\n                <div className=\"flex flex-col gap-2\">\n                  <span className=\"text-sm\">{t('publishBase.form.description')}</span>\n                  <Textarea\n                    className=\"min-h-12 resize-y\"\n                    value={description}\n                    onChange={(e) => setDescription(e.target.value)}\n                    placeholder={t('publishBase.form.descriptionPlaceholder')}\n                  />\n                </div>\n              </div>\n\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-sm font-semibold\">{t('publishBase.form.publishNode')}</span>\n                </div>\n                <NodeTreeSelect\n                  showCheckbox\n                  checkedItems={selectedNodeIds}\n                  onCheckedItemsChange={(ids) => {\n                    setSelectedNodeIds(ids);\n                  }}\n                  placeholder={t('common:actions.select')}\n                  totalNodeCount={allNodeIds.length}\n                />\n              </div>\n\n              <div className=\"flex flex-col gap-2\">\n                <span className=\"text-sm font-semibold\">{t('publishBase.form.security')}</span>\n                <div className=\"flex items-center space-x-2\">\n                  <Switch\n                    id=\"include-data\"\n                    checked={includeData}\n                    onCheckedChange={setIncludeData}\n                  />\n                  <Label htmlFor=\"include-data\">{t('publishBase.form.includeData')}</Label>\n                  {/* <QuestionMarkCircledIcon className=\"size-4\" /> */}\n                </div>\n              </div>\n\n              <div className=\"flex flex-col gap-2\">\n                <span className=\"text-sm font-semibold\">{t('publishBase.form.advanced')}</span>\n                <span className=\"text-sm\">{t('publishBase.form.defaultActiveNode')}</span>\n                <NodeSelect\n                  nodeIds={selectedNodeIds}\n                  value={defaultActiveNodeId || ''}\n                  onChange={setDefaultActiveNodeId}\n                />\n              </div>\n\n              <div className=\"absolute inset-x-0 bottom-0 flex w-full gap-3\">\n                {templateDetail && (\n                  <AlertDialog>\n                    <AlertDialogTrigger asChild>\n                      <Button\n                        className=\"flex w-full items-center gap-2\"\n                        variant=\"outline\"\n                        disabled={unpublishTemplateLoading}\n                      >\n                        {t('publishBase.unPublish')}\n                        {unpublishTemplateLoading && <Spin className=\"size-4\" />}\n                      </Button>\n                    </AlertDialogTrigger>\n                    <AlertDialogContent>\n                      <AlertDialogHeader>\n                        <AlertDialogTitle>\n                          {t('publishBase.unPublishConfirmTitle')}\n                        </AlertDialogTitle>\n                        <AlertDialogDescription>\n                          {t('publishBase.unPublishConfirmDescription')}\n                        </AlertDialogDescription>\n                      </AlertDialogHeader>\n                      <AlertDialogFooter>\n                        <AlertDialogCancel>{t('common:actions.cancel')}</AlertDialogCancel>\n                        <AlertDialogAction\n                          className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n                          onClick={() => unpublishTemplateMutate()}\n                        >\n                          {t('common:actions.confirm')}\n                        </AlertDialogAction>\n                      </AlertDialogFooter>\n                    </AlertDialogContent>\n                  </AlertDialog>\n                )}\n                <Button\n                  className=\"flex w-full items-center gap-2\"\n                  onClick={handlePublishClick}\n                  disabled={publishBaseLoading}\n                >\n                  <Send className=\"size-4\" />\n                  {templateDetail ? t('publishBase.update') : t('publishBase.publish')}\n\n                  {publishBaseLoading && <Spin className=\"size-4\" />}\n                </Button>\n              </div>\n            </div>\n\n            <div className=\"relative h-[520px] w-[512px] shrink-0 overflow-hidden rounded-lg border bg-muted\">\n              <input\n                ref={uploadRef}\n                type=\"file\"\n                accept=\"image/*\"\n                className=\"hidden\"\n                onChange={handleFileSelect}\n              />\n              <div className=\"relative flex size-full flex-col items-center justify-center gap-6 p-5\">\n                <div className=\"text-base font-semibold\">{t('publishBase.previewTips')}</div>\n\n                <div className=\"flex w-[432px] flex-col gap-3 bg-transparent\">\n                  <div\n                    className=\"group relative h-[240px] cursor-pointer overflow-hidden rounded-lg bg-surface\"\n                    onClick={handleUploadClick}\n                    role=\"button\"\n                    tabIndex={0}\n                    onKeyDown={(e) => {\n                      if (e.key === 'Enter' || e.key === ' ') {\n                        handleUploadClick();\n                      }\n                    }}\n                  >\n                    {screenshotUrl ? (\n                      <>\n                        <img\n                          src={screenshotUrl}\n                          className=\"size-full object-cover\"\n                          alt=\"published base preview\"\n                        />\n                        <div className=\"absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100\">\n                          <div className=\"flex flex-col items-center gap-2\">\n                            <Camera className=\"size-8 text-white\" />\n                            <span className=\"text-sm text-white\">\n                              {t('publishBase.changeCover')}\n                            </span>\n                          </div>\n                        </div>\n                      </>\n                    ) : (\n                      <div className=\"flex size-full flex-col items-center justify-center gap-3 transition-colors hover:bg-black/5 dark:hover:bg-white/10\">\n                        {isUploading ? (\n                          <>\n                            <Spin className=\"size-8\" />\n                            <span className=\"text-sm text-muted-foreground\">{uploadProgress}%</span>\n                          </>\n                        ) : (\n                          <>\n                            <Plus className=\"size-8 text-muted-foreground\" />\n                            <span className=\"text-sm text-muted-foreground\">\n                              {t('publishBase.uploadCover')}\n                            </span>\n                          </>\n                        )}\n                      </div>\n                    )}\n                  </div>\n                  <div className=\"flex flex-1 flex-col gap-1 px-1\">\n                    <div className=\"flex items-center justify-between\">\n                      <p\n                        className={cn(\n                          'text-base font-medium',\n                          title ? 'text-foreground' : 'text-muted-foreground'\n                        )}\n                      >\n                        {title || t('publishBase.form.titlePlaceholder')}\n                      </p>\n\n                      <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                        {isTemplatePublished && (\n                          <TooltipProvider>\n                            <Tooltip>\n                              <TooltipTrigger asChild>\n                                <span\n                                  className={cn(\n                                    'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',\n                                    isTemplateFeatured\n                                      ? 'border-amber-200 bg-amber-50 text-amber-700'\n                                      : 'border-muted-foreground/20 bg-muted text-muted-foreground'\n                                  )}\n                                >\n                                  {isTemplateFeatured\n                                    ? t('publishBase.featuredLabel')\n                                    : t('publishBase.unfeaturedLabel')}\n                                </span>\n                              </TooltipTrigger>\n                              <TooltipContent hideWhenDetached={true}>\n                                {isTemplateFeatured\n                                  ? t('publishBase.featuredTip')\n                                  : t('publishBase.unfeaturedTip')}\n                              </TooltipContent>\n                            </Tooltip>\n                          </TooltipProvider>\n                        )}\n                        <div className=\"flex items-center gap-1\">\n                          <Heart className=\"size-4\" />\n                          {templateDetail?.usageCount || 0}\n                        </div>\n                      </div>\n                    </div>\n                    <span\n                      className={cn(\n                        'text-nowrap break-words text-sm truncate',\n                        description ? 'text-muted-foreground' : 'text-foreground/50'\n                      )}\n                      title={description}\n                    >\n                      {description || t('publishBase.form.descriptionPlaceholder')}\n                    </span>\n                  </div>\n                </div>\n                {templateDetail?.isPublished && (\n                  <div className=\"z-50 flex h-9 w-[432px] items-center gap-2 overflow-hidden rounded-md border bg-background pl-3\">\n                    <Link className=\"size-4 shrink-0\" />\n                    <div className=\"grow truncate text-sm text-muted-foreground\">{shareUrl}</div>\n                    <Button\n                      size=\"icon\"\n                      variant=\"ghost\"\n                      className=\"size-9 shrink-0 rounded-none border-l p-0\"\n                      onClick={handleCopyUrl}\n                    >\n                      <Copy className=\"size-4\" />\n                    </Button>\n                    <Button\n                      size=\"icon\"\n                      variant=\"ghost\"\n                      className=\"size-9 shrink-0 rounded-none border-l p-0\"\n                      onClick={() => window.open(shareUrl, '_blank')}\n                    >\n                      <ExternalLink className=\"size-4\" />\n                    </Button>\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      <Dialog\n        open={successDialogOpen}\n        onOpenChange={(open) => {\n          setSuccessDialogOpen(open);\n          // When success dialog closes and closeOnSuccess is false (Popover version),\n          // close the parent component\n          if (!open && !closeOnSuccess) {\n            onClose();\n          }\n        }}\n      >\n        <DialogContent className=\"max-w-[512px] gap-0 p-0\">\n          <DialogHeader className=\"flex h-[60px] flex-col justify-center px-6\">\n            <DialogTitle className=\"text-left text-lg font-semibold\">\n              {t('publishBase.publishSuccess')}\n            </DialogTitle>\n          </DialogHeader>\n          <div className=\"flex w-full flex-col overflow-hidden px-6 pb-4\">\n            <span className=\"text-sm text-muted-foreground\">\n              {t('publishBase.publishSuccessDescription')}\n            </span>\n\n            <div className=\"flex w-full items-center gap-2 py-2\">\n              <div className=\"flex h-9 flex-1 items-center gap-2 truncate rounded-md border px-3 text-sm\">\n                <Link className=\"size-4 shrink-0\" />\n                <div className=\"flex-1 truncate\">{shareUrl}</div>\n              </div>\n              <Button size=\"icon\" variant=\"outline\" onClick={handleCopyUrl}>\n                <Copy className=\"size-4\" />\n              </Button>\n              <Button size=\"icon\" variant=\"outline\" onClick={() => window.open(shareUrl, '_blank')}>\n                <ExternalLink className=\"size-4\" />\n              </Button>\n            </div>\n\n            {isCloud && (\n              <div className=\"flex flex-col gap-3 pt-6\">\n                <div className=\"text-sm font-medium\">{t('publishBase.shareWith')}</div>\n                <div className=\"flex gap-3\">\n                  <Button\n                    size=\"lg\"\n                    variant=\"outline\"\n                    className=\"size-9 rounded-lg p-0\"\n                    onClick={handleShareToX}\n                  >\n                    <Twitter className=\"size-6 p-0.5\" />\n                  </Button>\n                  <Button\n                    size=\"lg\"\n                    variant=\"outline\"\n                    className=\"size-9 rounded-lg p-0\"\n                    onClick={handleShareToLinkedIn}\n                  >\n                    <InIcon className=\"size-6 fill-[#0A66C2] p-0.5\" />\n                  </Button>\n                  <Button\n                    size=\"lg\"\n                    variant=\"outline\"\n                    className=\"size-9 rounded-lg p-0\"\n                    onClick={handleShareToDiscord}\n                  >\n                    <Discord className=\"size-6 fill-[#5865F2] p-0.5\" />\n                  </Button>\n                </div>\n              </div>\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      <UnpublishedAppsDialog\n        open={unpublishedAppsDialogOpen}\n        onOpenChange={setUnpublishedAppsDialogOpen}\n        unpublishedApps={unpublishedApps}\n        treeItems={treeItems}\n        onContinue={handleContinuePublish}\n        onPublishApp={appPublishContext.publishApp}\n        externalApps={externalApps}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table/table-header/publish-base/UnpublishedAppsDialog.tsx",
    "content": "import type { IBaseNodeAppResourceMeta } from '@teable/openapi';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { useBase } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib';\nimport {\n  Button,\n  cn,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { AlertTriangle, Check, ExternalLink, Loader2, Rocket } from 'lucide-react';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useEffect } from 'react';\nimport type { TreeItemData } from '../../../base/base-node/hooks';\n\nexport interface IUnpublishedApp {\n  nodeId: string;\n  name: string;\n  resourceId: string;\n  isPublishing?: boolean;\n  isPublished?: boolean;\n  error?: string;\n}\n\ninterface IUnpublishedAppsDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  unpublishedApps: IUnpublishedApp[];\n  treeItems: Record<string, TreeItemData>;\n  onContinue: () => void;\n  // Optional publish handler - if not provided, publish buttons won't be shown\n  onPublishApp?: (app: IUnpublishedApp) => Promise<void>;\n  // Allow external control of app states (for EE polling)\n  externalApps?: IUnpublishedApp[];\n}\n\nexport const UnpublishedAppsDialog = (props: IUnpublishedAppsDialogProps) => {\n  const {\n    open,\n    onOpenChange,\n    unpublishedApps: initialApps,\n    treeItems,\n    onContinue,\n    onPublishApp,\n    externalApps,\n  } = props;\n  const { t } = useTranslation(['space', 'common']);\n  const base = useBase();\n  const baseId = base?.id;\n\n  const [apps, setApps] = useState<IUnpublishedApp[]>(initialApps);\n  const [isPublishingAll, setIsPublishingAll] = useState(false);\n\n  // Update apps when initialApps changes\n  useEffect(() => {\n    setApps(initialApps);\n  }, [initialApps]);\n\n  // Allow external control of app states (for EE polling updates)\n  useEffect(() => {\n    if (externalApps) {\n      setApps(externalApps);\n    }\n  }, [externalApps]);\n\n  const handlePublishApp = async (app: IUnpublishedApp) => {\n    if (!onPublishApp) return;\n\n    setApps((prev) =>\n      prev.map((a) =>\n        a.resourceId === app.resourceId ? { ...a, isPublishing: true, error: undefined } : a\n      )\n    );\n\n    try {\n      await onPublishApp(app);\n      // Update state to published on success\n      setApps((prev) =>\n        prev.map((a) =>\n          a.resourceId === app.resourceId\n            ? { ...a, isPublishing: false, isPublished: true, error: undefined }\n            : a\n        )\n      );\n    } catch {\n      setApps((prev) =>\n        prev.map((a) =>\n          a.resourceId === app.resourceId\n            ? { ...a, isPublishing: false, error: t('publishBase.unpublishedApps.publishFailed') }\n            : a\n        )\n      );\n    }\n  };\n\n  const handlePublishAllApps = async () => {\n    const unpublishedList = apps.filter((app) => !app.isPublished && !app.isPublishing);\n\n    setIsPublishingAll(true);\n    try {\n      // Trigger all deploys in parallel for better performance\n      await Promise.all(unpublishedList.map((app) => handlePublishApp(app)));\n    } finally {\n      setIsPublishingAll(false);\n    }\n  };\n\n  const allPublished = apps.every((app) => app.isPublished);\n  const somePublishing = apps.some((app) => app.isPublishing);\n  const canPublish = Boolean(onPublishApp);\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-[480px]\">\n        <DialogHeader className=\"flex flex-row items-start gap-4 space-y-0 text-left\">\n          <div className=\"flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30\">\n            <AlertTriangle className=\"size-5 text-amber-600 dark:text-amber-500\" />\n          </div>\n          <div className=\"flex flex-col gap-1.5 pt-0.5\">\n            <DialogTitle className=\"leading-none\">\n              {t('publishBase.unpublishedApps.title')}\n            </DialogTitle>\n            <DialogDescription className=\"text-sm leading-normal text-muted-foreground\">\n              {t('publishBase.unpublishedApps.description')}\n            </DialogDescription>\n          </div>\n        </DialogHeader>\n\n        <div className=\"my-4 flex max-h-[400px] flex-col gap-2 overflow-auto\">\n          {apps.map((app) => {\n            const node = treeItems[app.nodeId];\n            const nodeName =\n              node?.resourceMeta?.name || app.name || t('publishBase.unpublishedApps.unnamedApp');\n\n            return (\n              <div\n                key={app.nodeId}\n                className={cn(\n                  'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3',\n                  app.isPublished &&\n                    'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',\n                  app.error && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950'\n                )}\n              >\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"truncate text-sm font-medium\">{nodeName}</div>\n                  {app.error && <div className=\"truncate text-xs text-red-600\">{app.error}</div>}\n                </div>\n                <div className=\"flex shrink-0 items-center gap-1\">\n                  {app.isPublished ? (\n                    <div className=\"flex items-center gap-1 text-green-600\">\n                      <Check className=\"size-4 shrink-0\" />\n                      <span className=\"text-xs\">{t('publishBase.unpublishedApps.published')}</span>\n                    </div>\n                  ) : app.isPublishing ? (\n                    <div className=\"flex items-center gap-1 text-muted-foreground\">\n                      <Loader2 className=\"size-4 shrink-0 animate-spin\" />\n                      <span className=\"text-xs\">{t('publishBase.unpublishedApps.publishing')}</span>\n                    </div>\n                  ) : app.error ? (\n                    <>\n                      {canPublish && (\n                        <Button\n                          size=\"xs\"\n                          variant=\"outline\"\n                          onClick={() => handlePublishApp(app)}\n                          className=\"h-7 shrink-0 gap-1 px-2\"\n                        >\n                          <Rocket className=\"size-3 shrink-0\" />\n                          <span className=\"truncate\">\n                            {t('publishBase.unpublishedApps.redeploy')}\n                          </span>\n                        </Button>\n                      )}\n                      {baseId && (\n                        <Button\n                          size=\"xs\"\n                          variant=\"outline\"\n                          className=\"h-7 shrink-0 gap-1 px-2\"\n                          asChild\n                        >\n                          <Link href={`/base/${baseId}/app/${app.resourceId}`} target=\"_blank\">\n                            <ExternalLink className=\"size-3 shrink-0\" />\n                            <span className=\"truncate\">\n                              {t('publishBase.unpublishedApps.goToFix')}\n                            </span>\n                          </Link>\n                        </Button>\n                      )}\n                    </>\n                  ) : canPublish ? (\n                    <Button\n                      size=\"xs\"\n                      variant=\"outline\"\n                      onClick={() => handlePublishApp(app)}\n                      className=\"h-7 gap-1\"\n                    >\n                      <Rocket className=\"size-3\" />\n                      {t('publishBase.unpublishedApps.publish')}\n                    </Button>\n                  ) : (\n                    <span className=\"text-xs text-muted-foreground\">\n                      {t('publishBase.unpublishedApps.notPublished')}\n                    </span>\n                  )}\n                </div>\n              </div>\n            );\n          })}\n        </div>\n\n        <DialogFooter className=\"flex-col gap-2 sm:flex-row\">\n          {canPublish && (\n            <Button\n              onClick={handlePublishAllApps}\n              disabled={allPublished || somePublishing || isPublishingAll}\n              className=\"gap-1\"\n            >\n              <Rocket className=\"size-4\" />\n              {t('publishBase.unpublishedApps.publishAll')}\n              {(somePublishing || isPublishingAll) && <Spin className=\"ml-1 size-4\" />}\n            </Button>\n          )}\n          <div className=\"flex flex-1 justify-end gap-2\">\n            <Button\n              variant=\"ghost\"\n              onClick={() => onOpenChange(false)}\n              disabled={somePublishing || isPublishingAll}\n            >\n              {t('common:actions.cancel')}\n            </Button>\n            <Button\n              onClick={onContinue}\n              disabled={somePublishing || isPublishingAll}\n              variant={allPublished ? 'default' : 'outline'}\n            >\n              {allPublished\n                ? t('common:actions.continue')\n                : t('publishBase.unpublishedApps.ignoreAndContinue')}\n            </Button>\n          </div>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\n// Helper function to get unpublished app nodes\nexport const getUnpublishedAppNodes = (\n  selectedNodeIds: string[],\n  treeItems: Record<string, TreeItemData>\n): IUnpublishedApp[] => {\n  const unpublishedApps: IUnpublishedApp[] = [];\n\n  selectedNodeIds.forEach((nodeId) => {\n    const node = treeItems[nodeId];\n    if (node && node.resourceType === BaseNodeResourceType.App) {\n      const resourceMeta = node.resourceMeta as IBaseNodeAppResourceMeta;\n      // Check if app is not published (no publicUrl or no publishedVersion)\n      if (!resourceMeta?.publicUrl || !resourceMeta?.publishedVersion) {\n        unpublishedApps.push({\n          nodeId,\n          name: resourceMeta?.name || '',\n          resourceId: node.resourceId,\n        });\n      }\n    }\n  });\n\n  return unpublishedApps;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/DraggableList.tsx",
    "content": "import { useTableId, useTables, useIsHydrated } from '@teable/sdk';\nimport {\n  DndKitContext,\n  Droppable,\n  Draggable,\n  type DragEndEvent,\n} from '@teable/ui-lib/base/dnd-kit';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useState, useEffect } from 'react';\nimport { TableListItem } from './TableListItem';\nimport { useTableHref } from './useTableHref';\n\nexport const DraggableList = () => {\n  const tables = useTables();\n\n  const tableId = useTableId();\n\n  const isHydrated = useIsHydrated();\n\n  const [innerTables, setInnerTables] = useState([...tables]);\n\n  const { hrefMap: tableHrefMap } = useTableHref();\n\n  useEffect(() => {\n    setInnerTables(tables);\n  }, [tables]);\n\n  const onDragEnd = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n\n    if (!over) {\n      return;\n    }\n\n    const list = [...tables];\n    const [table] = list.splice(from, 1);\n    list.splice(to, 0, table);\n    setInnerTables(list);\n    const tableIndex = list.findIndex((v) => v.id === table.id);\n    if (tableIndex == 0) {\n      await table.updateOrder({ anchorId: list[1].id, position: 'before' });\n    } else {\n      await table.updateOrder({ anchorId: list[tableIndex - 1].id, position: 'after' });\n    }\n  };\n\n  return isHydrated ? (\n    <DndKitContext onDragEnd={onDragEnd}>\n      <Droppable items={innerTables.map(({ id }) => ({ id }))}>\n        {innerTables.map((table) => (\n          <Draggable key={table.id} id={table.id} disabled={!table.permission?.['table|update']}>\n            {({ setNodeRef, attributes, listeners, style, isDragging }) => (\n              <div\n                ref={setNodeRef}\n                {...attributes}\n                {...listeners}\n                style={style}\n                className={cn('group relative overflow-y-auto cursor-pointer', {\n                  'opacity-60': isDragging,\n                })}\n              >\n                <TableListItem\n                  href={tableHrefMap[table.id]}\n                  table={table}\n                  isActive={table.id === tableId}\n                  isDragging={isDragging}\n                />\n              </div>\n            )}\n          </Draggable>\n        ))}\n      </Droppable>\n    </DndKitContext>\n  ) : null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/NoDraggableList.tsx",
    "content": "import { useTableId, useTables } from '@teable/sdk';\nimport { TableListItem } from './TableListItem';\nimport { useTableHref } from './useTableHref';\n\nexport const NoDraggableList: React.FC = () => {\n  const tables = useTables();\n  const tableId = useTableId();\n  const { hrefMap: tableHrefMap } = useTableHref();\n\n  return (\n    <ul>\n      {tables.map((table) => (\n        <li key={table.id}>\n          <TableListItem\n            table={table}\n            href={tableHrefMap[table.id]}\n            isActive={table.id === tableId}\n            className=\"cursor-pointer\"\n          />\n        </li>\n      ))}\n    </ul>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/TableList.tsx",
    "content": "import { File, FileCsv, FileExcel } from '@teable/icons';\nimport { SUPPORTEDTYPE } from '@teable/openapi';\nimport { useBasePermission, useConnection } from '@teable/sdk';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib';\nimport AddBoldIcon from '@teable/ui-lib/icons/app/add-bold.svg';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { TableImport } from '../import-table';\nimport { DraggableList } from './DraggableList';\nimport { NoDraggableList } from './NoDraggableList';\nimport { useAddTable } from './useAddTable';\n\nexport const TableList: React.FC = () => {\n  const { connected } = useConnection();\n  const addTable = useAddTable();\n  const permission = useBasePermission();\n  const { t } = useTranslation(['table']);\n  const [dialogVisible, setDialogVisible] = useState(false);\n  const [fileType, setFileType] = useState<SUPPORTEDTYPE>(SUPPORTEDTYPE.CSV);\n  const importFile = (type: SUPPORTEDTYPE) => {\n    setDialogVisible(true);\n    setFileType(type);\n  };\n\n  return (\n    <div className=\"flex w-full flex-col gap-2 overflow-auto pt-4\">\n      <DropdownMenu modal={false}>\n        <DropdownMenuTrigger asChild>\n          <div className=\"px-3\">\n            {permission?.['table|create'] && (\n              <Button variant={'outline'} size={'icon-xs'} className=\"w-full\">\n                <AddBoldIcon className=\"size-4 shrink-0\" />\n              </Button>\n            )}\n          </div>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent className=\"w-64\">\n          <DropdownMenuItem\n            onClick={() => {\n              addTable();\n            }}\n            className=\"cursor-pointer\"\n          >\n            <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n              <File className=\"size-4\" />\n              {t('table.operator.createBlank')}\n            </Button>\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuLabel className=\"px-4 text-xs font-normal text-muted-foreground\">\n            {t('table:import.menu.addFromOtherSource')}\n          </DropdownMenuLabel>\n          <DropdownMenuItem\n            className=\"cursor-pointer\"\n            onClick={() => importFile(SUPPORTEDTYPE.CSV)}\n          >\n            <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n              <FileCsv className=\"size-4\" />\n              {t('table:import.menu.csvFile')}\n            </Button>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            className=\"cursor-pointer\"\n            onClick={() => importFile(SUPPORTEDTYPE.EXCEL)}\n          >\n            <Button variant=\"ghost\" size=\"xs\" className=\"h-4\">\n              <FileExcel className=\"size-4\" />\n              {t('table:import.menu.excelFile')}\n            </Button>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {dialogVisible && (\n        <TableImport\n          fileType={fileType}\n          open={dialogVisible}\n          onOpenChange={(open) => setDialogVisible(open)}\n        />\n      )}\n\n      <div className=\"overflow-y-auto px-3\">\n        {connected && permission?.['table|update'] ? <DraggableList /> : <NoDraggableList />}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/TableListItem.tsx",
    "content": "import { Table2 } from '@teable/icons';\nimport { PinType } from '@teable/openapi';\nimport type { Table } from '@teable/sdk/model';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { Input } from '@teable/ui-lib/shadcn/ui/input';\nimport { useRouter } from 'next/router';\nimport { useEffect, useRef, useState } from 'react';\nimport { useClickAway } from 'react-use';\nimport { Emoji } from '../../components/emoji/Emoji';\nimport { EmojiPicker } from '../../components/emoji/EmojiPicker';\nimport { StarButton } from '../space/space-side-bar/StarButton';\nimport { useGridSearchStore } from '../view/grid/useGridSearchStore';\nimport { TableOperation } from './TableOperation';\n\ninterface IProps {\n  table: Table;\n  isActive: boolean;\n  isDragging?: boolean;\n  className?: string;\n  open?: boolean;\n  href: string;\n}\n\nexport const TableListItem: React.FC<IProps> = ({\n  table,\n  isActive,\n  className,\n  isDragging,\n  href,\n}) => {\n  const [isEditing, setIsEditing] = useState(false);\n  const [open, setOpen] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const router = useRouter();\n  const { highlightedTableId } = useGridSearchStore();\n  const isHighlighted = highlightedTableId === table.id;\n\n  const navigateHandler = async () => {\n    router.push(href, undefined, { shallow: true });\n  };\n\n  useEffect(() => {\n    if (isEditing) {\n      setTimeout(() => inputRef.current?.focus());\n    }\n  }, [isEditing]);\n\n  useClickAway(inputRef, () => {\n    if (isEditing && inputRef.current?.value && inputRef.current.value !== table.name) {\n      table.updateName(inputRef.current.value);\n    }\n    setIsEditing(false);\n  });\n\n  return (\n    <>\n      <Button\n        variant={'ghost'}\n        size={'xs'}\n        asChild\n        className={cn(\n          'my-[2px] w-full px-2 justify-start text-sm font-normal gap-2 group bg-transparent hover:bg-accent',\n          className,\n          {\n            'bg-accent': isActive && !isHighlighted,\n            'bg-orange-300/40 hover:bg-orange-300/40': isHighlighted,\n          }\n        )}\n        onClick={navigateHandler}\n        onContextMenu={() => setOpen(true)}\n      >\n        <div>\n          {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}\n          <div onClick={(e) => e.stopPropagation()}>\n            <EmojiPicker\n              className=\"flex size-5 items-center justify-center hover:bg-muted-foreground/60\"\n              onChange={(icon: string) => table.updateIcon(icon)}\n              disabled={!table.permission?.['table|update']}\n            >\n              {table.icon ? (\n                <Emoji emoji={table.icon} size={'1rem'} />\n              ) : (\n                <Table2 className=\"size-4 shrink-0\" />\n              )}\n            </EmojiPicker>\n          </div>\n          <p\n            className=\"grow truncate\"\n            onDoubleClick={() => {\n              table.permission?.['table|update'] && setIsEditing(true);\n            }}\n          >\n            {' ' + table.name}\n          </p>\n          {!isDragging && (\n            // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n            <div className=\"flex items-center gap-1\" onMouseDown={(e) => e.stopPropagation()}>\n              <StarButton id={table.id} type={PinType.Table} className=\"size-3.5\" />\n              <TableOperation\n                table={table}\n                className=\"size-4 shrink-0 sm:opacity-0 sm:group-hover:opacity-100\"\n                onRename={() => setIsEditing(true)}\n                open={open}\n                setOpen={setOpen}\n              />\n            </div>\n          )}\n        </div>\n      </Button>\n      {isEditing && (\n        <Input\n          ref={inputRef}\n          type=\"text\"\n          placeholder=\"name\"\n          defaultValue={table.name}\n          className=\"rounded-none absolute left-0 top-0 size-full cursor-text px-4\"\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              if (e.currentTarget.value && e.currentTarget.value !== table.name) {\n                table.updateName(e.currentTarget.value);\n              }\n              setIsEditing(false);\n            }\n          }}\n          onMouseDown={(e) => {\n            e.stopPropagation();\n          }}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport {\n  MoreHorizontal,\n  Pencil,\n  Settings,\n  Trash2,\n  Export,\n  Import,\n  FileCsv,\n  FileExcel,\n  Copy,\n} from '@teable/icons';\nimport { duplicateTable, SUPPORTEDTYPE } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBase, useBasePermission, useTables } from '@teable/sdk/hooks';\nimport type { Table } from '@teable/sdk/model';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuSub,\n  DropdownMenuPortal,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  Switch,\n  Label,\n  Input,\n  DialogFooter,\n  Button,\n} from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useMemo, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useDownload } from '../../hooks/useDownLoad';\nimport { TableImport } from '../import-table';\n\ninterface ITableOperationProps {\n  className?: string;\n  table: Table;\n  onRename?: () => void;\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n}\n\nexport const TableOperation = (props: ITableOperationProps) => {\n  const { table, className, onRename, open, setOpen } = props;\n  const [deleteConfirm, setDeleteConfirm] = useState(false);\n  const [importVisible, setImportVisible] = useState(false);\n  const [duplicateSetting, setDuplicateSetting] = useState(false);\n  const [importType, setImportType] = useState(SUPPORTEDTYPE.CSV);\n  const base = useBase();\n  const permission = useBasePermission();\n  const tables = useTables();\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const { baseId, tableId: routerTableId } = router.query;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { trigger } = useDownload({ downloadUrl: `/api/export/${table.id}`, key: 'table' });\n\n  const defaultTableName = useMemo(\n    () =>\n      getUniqName(\n        `${table?.name} ${t('space:baseModal.copy')}`,\n        tables.map((t) => t.name)\n      ),\n    [t, table?.name, tables]\n  );\n\n  const [duplicateOption, setDuplicateOption] = useState({\n    name: defaultTableName,\n    includeRecords: true,\n  });\n\n  const menuPermission = useMemo(() => {\n    return {\n      deleteTable: table.permission?.['table|delete'],\n      updateTable: table.permission?.['table|update'],\n      duplicateTable: table.permission?.['table|read'] && permission?.['table|create'],\n      exportTable: table.permission?.['table|export'],\n      importTable: table.permission?.['table|import'],\n    };\n  }, [permission, table.permission]);\n\n  const deleteTable = async (permanent?: boolean) => {\n    const tableId = table?.id;\n\n    if (!tableId) return;\n\n    await base.deleteTable(tableId, permanent);\n    setDeleteConfirm(false);\n\n    queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(baseId as string) });\n\n    const firstTableId = tables.find((t) => t.id !== tableId)?.id;\n    if (routerTableId === tableId) {\n      router.push(firstTableId ? `/base/${baseId}/table/${firstTableId}` : `/base/${baseId}`);\n    }\n  };\n\n  const { mutateAsync: duplicateTableFn, isPending: isLoading } = useMutation({\n    mutationFn: () => duplicateTable(baseId as string, table.id, duplicateOption),\n    onSuccess: (data) => {\n      const {\n        data: { id },\n      } = data;\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.tableList(baseId as string),\n      });\n      setDuplicateSetting(false);\n      router.push(`/base/${baseId}/table/${id}`);\n    },\n  });\n\n  if (!Object.values(menuPermission).some(Boolean)) {\n    return null;\n  }\n\n  return (\n    <>\n      <DropdownMenu open={open} onOpenChange={setOpen}>\n        <DropdownMenuTrigger asChild>\n          <div>\n            <MoreHorizontal className={className} />\n          </div>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"min-w-[160px]\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          {menuPermission.updateTable && (\n            <DropdownMenuItem onClick={() => onRename?.()}>\n              <Pencil className=\"mr-2\" />\n              {t('table:table.rename')}\n            </DropdownMenuItem>\n          )}\n          <DropdownMenuItem asChild>\n            <Link\n              href={{\n                pathname: '/base/[baseId]/design',\n                query: { baseId, tableId: table.id },\n              }}\n              title={t('common:noun.design')}\n            >\n              <Settings className=\"mr-2\" />\n              {t('common:noun.design')}\n            </Link>\n          </DropdownMenuItem>\n          {menuPermission.duplicateTable && (\n            <DropdownMenuItem onClick={() => setDuplicateSetting(true)}>\n              <Copy className=\"mr-2\" />\n              {t('table:import.menu.duplicate')}\n            </DropdownMenuItem>\n          )}\n          {menuPermission.exportTable && (\n            <DropdownMenuItem\n              onClick={() => {\n                trigger?.();\n              }}\n            >\n              <Export className=\"mr-2\" />\n              {t('table:import.menu.downAsCsv')}\n            </DropdownMenuItem>\n          )}\n          {menuPermission.importTable && (\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger>\n                <Import className=\"mr-2\" />\n                <span>{t('table:import.menu.importData')}</span>\n              </DropdownMenuSubTrigger>\n              <DropdownMenuPortal>\n                <DropdownMenuSubContent>\n                  <DropdownMenuItem\n                    onClick={() => {\n                      setImportVisible(true);\n                      setImportType(SUPPORTEDTYPE.CSV);\n                    }}\n                  >\n                    <FileCsv className=\"mr-2 size-4\" />\n                    <span>{t('table:import.menu.csvFile')}</span>\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    onClick={() => {\n                      setImportVisible(true);\n                      setImportType(SUPPORTEDTYPE.EXCEL);\n                    }}\n                  >\n                    <FileExcel className=\"mr-2 size-4\" />\n                    <span>{t('table:import.menu.excelFile')}</span>\n                  </DropdownMenuItem>\n                </DropdownMenuSubContent>\n              </DropdownMenuPortal>\n            </DropdownMenuSub>\n          )}\n          {menuPermission.deleteTable && (\n            <DropdownMenuItem className=\"text-destructive\" onClick={() => setDeleteConfirm(true)}>\n              <Trash2 className=\"mr-2\" />\n              {t('common:actions.delete')}\n            </DropdownMenuItem>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {importVisible && (\n        <TableImport\n          open={importVisible}\n          tableId={table.id}\n          fileType={importType}\n          onOpenChange={(open: boolean) => setImportVisible(open)}\n        ></TableImport>\n      )}\n\n      <ConfirmDialog\n        open={deleteConfirm}\n        onOpenChange={setDeleteConfirm}\n        title={t('table:table.deleteConfirm', { tableName: table?.name })}\n        content={\n          <>\n            <div className=\"space-y-2 text-sm\">\n              <p>{t('table:table.deleteTip1')}</p>\n              <p>{t('common:trash.description')}</p>\n            </div>\n            <DialogFooter>\n              <Button size={'sm'} variant={'ghost'} onClick={() => setDeleteConfirm(false)}>\n                {t('common:actions.cancel')}\n              </Button>\n              <Button size={'sm'} onClick={() => deleteTable()}>\n                {t('common:trash.addToTrash')}\n              </Button>\n            </DialogFooter>\n          </>\n        }\n      />\n\n      <ConfirmDialog\n        open={duplicateSetting}\n        onOpenChange={setDuplicateSetting}\n        title={`${t('common:actions.duplicate')} ${table?.name}`}\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.duplicate')}\n        confirmLoading={isLoading}\n        content={\n          <div className=\"flex flex-col space-y-2 text-sm\">\n            <div className=\"flex flex-col gap-2\">\n              <Label>\n                {t('common:noun.table')} {t('common:name')}\n              </Label>\n              <Input\n                defaultValue={defaultTableName}\n                onChange={(e) => {\n                  const value = e.target.value;\n                  setDuplicateOption((prev) => ({ ...prev, name: value }));\n                }}\n              />\n            </div>\n\n            <div className=\"flex items-center gap-1\">\n              <Switch\n                id=\"include-record\"\n                checked={duplicateOption.includeRecords}\n                onCheckedChange={(val) => {\n                  setDuplicateOption((prev) => ({ ...prev, includeRecords: val }));\n                }}\n              />\n              <Label htmlFor=\"include-record\">{t('table:import.menu.includeRecords')}</Label>\n            </div>\n          </div>\n        }\n        onCancel={() => setDuplicateSetting(false)}\n        onConfirm={async () => {\n          duplicateTableFn();\n        }}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/useAddTable.ts",
    "content": "import type { IFieldRo } from '@teable/core';\nimport { Colors, FieldType, getUniqName, NumberFormattingType, ViewType } from '@teable/core';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { useBase, useBaseId, useTables } from '@teable/sdk/hooks';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\nimport { getNodeUrl } from '../base/base-node/hooks';\n\nexport const useDefaultFields = (): IFieldRo[] => {\n  const { t } = useTranslation('table');\n  return [\n    { name: t('field.default.singleLineText.title'), type: FieldType.SingleLineText },\n    {\n      name: t('field.default.number.title'),\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          precision: 0,\n          type: NumberFormattingType.Decimal,\n        },\n      },\n    },\n    {\n      name: t('field.default.singleSelect.title'),\n      type: FieldType.SingleSelect,\n      options: {\n        choices: [\n          {\n            name: t('field.default.singleSelect.options.todo'),\n            color: Colors.OrangeDark1,\n          },\n          {\n            name: t('field.default.singleSelect.options.inProgress'),\n            color: Colors.CyanBright,\n          },\n          {\n            name: t('field.default.singleSelect.options.done'),\n            color: Colors.Teal,\n          },\n        ],\n      },\n    },\n  ];\n};\n\nexport function useAddTable() {\n  const base = useBase();\n  const baseId = useBaseId() as string;\n  const tables = useTables();\n  const router = useRouter();\n  const { t } = useTranslation('table');\n  const fieldRos = useDefaultFields();\n  return useCallback(async () => {\n    const uniqueName = getUniqName(\n      t('table.newTableLabel'),\n      tables.map((table) => table.name)\n    );\n    const tableData = (\n      await base.createTable({\n        name: uniqueName,\n        views: [{ name: t('view.category.table'), type: ViewType.Grid }],\n        fields: fieldRos,\n      })\n    ).data;\n    const tableId = tableData.id;\n    const viewId = tableData.defaultViewId;\n    const url = getNodeUrl({\n      baseId,\n      resourceType: BaseNodeResourceType.Table,\n      resourceId: tableId,\n      viewId,\n    });\n    if (url) {\n      router.push(url, undefined, { shallow: true });\n    }\n  }, [t, tables, base, fieldRos, router, baseId]);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/table-list/useTableHref.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getUserLastVisitMap, LastVisitResourceType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useTables } from '@teable/sdk/hooks';\nimport { useIsReadOnlyPreview } from '@teable/sdk/hooks/use-is-readonly-preview';\nimport { useMemo } from 'react';\nimport { useShareUrlPrefix } from '../../context/ShareContext';\n\nexport const useTableHref = (): {\n  hrefMap: Record<string, string>;\n  viewIdMap: Record<string, string>;\n} => {\n  const baseId = useBaseId();\n  const tables = useTables();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const shareUrlPrefix = useShareUrlPrefix();\n  const { data: userLastVisitMap } = useQuery({\n    queryKey: ReactQueryKeys.userLastVisitMap(baseId as string),\n    queryFn: ({ queryKey }) =>\n      getUserLastVisitMap({\n        resourceType: LastVisitResourceType.Table,\n        parentResourceId: queryKey[1],\n      }).then((res) => res.data),\n    enabled: !isReadOnlyPreview && !shareUrlPrefix,\n  });\n\n  return useMemo(() => {\n    const hrefMap: Record<string, string> = {};\n    const viewIdMap: Record<string, string> = {};\n    tables.forEach((table) => {\n      const viewId = userLastVisitMap?.[table.id]?.resourceId || table.defaultViewId;\n      viewIdMap[table.id] = viewId;\n      // Add share URL prefix if present\n      hrefMap[table.id] = `${shareUrlPrefix}/base/${baseId}/table/${table.id}/${viewId}`;\n    });\n    return { hrefMap, viewIdMap };\n  }, [baseId, tables, userLastVisitMap, shareUrlPrefix]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/trash/BaseTrashPage.tsx",
    "content": "import type { QueryFunctionContext } from '@tanstack/react-query';\nimport { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { ColumnDef } from '@tanstack/react-table';\nimport type { ITrashItemVo, ITrashVo } from '@teable/openapi';\nimport {\n  getTrashItems,\n  PrincipalType,\n  resetTrashItems,\n  restoreTrash,\n  TrashType,\n} from '@teable/openapi';\nimport { InfiniteTable } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission, useIsHydrated } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport dayjs from 'dayjs';\nimport Head from 'next/head';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { Collaborator } from '../../components/collaborator-manage/components/Collaborator';\nimport { useIsCommunity } from '../../hooks/useIsCommunity';\n\nexport const BaseTrashPage = () => {\n  const baseId = useBaseId() as string;\n  const isHydrated = useIsHydrated();\n  const queryClient = useQueryClient();\n  const permission = useBasePermission();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const { brandName } = useBrand();\n  const isCommunity = useIsCommunity();\n  const { trash } = useEnv();\n  const retentionDays = trash?.retentionDays ?? 0;\n  const [userMap, setUserMap] = useState<ITrashVo['userMap']>({});\n  const [resourceMap, setResourceMap] = useState<ITrashVo['resourceMap']>({});\n  const [nextCursor, setNextCursor] = useState<string | null | undefined>(undefined);\n  const [isConfirmVisible, setConfirmVisible] = useState(false);\n\n  const queryFn = async ({ queryKey, pageParam }: QueryFunctionContext) => {\n    const res = await getTrashItems({\n      resourceType: TrashType.Base,\n      resourceId: queryKey[1] as string,\n      cursor: pageParam as string | undefined,\n      pageSize: 20,\n    });\n    const {\n      trashItems,\n      nextCursor: newNextCursor,\n      userMap: newUserMap,\n      resourceMap: newResourceMap,\n    } = res.data;\n\n    setNextCursor(newNextCursor);\n    setUserMap((prev) => ({ ...prev, ...newUserMap }));\n    setResourceMap((prev) => ({ ...prev, ...newResourceMap }));\n\n    return trashItems;\n  };\n\n  const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.getTrashItems(baseId),\n    queryFn,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: true,\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: () => nextCursor ?? undefined,\n  });\n\n  const { mutateAsync: mutateRestore } = useMutation({\n    mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(baseId) });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseNodeTree(baseId) });\n      toast.success(t('actions.restoreSucceed'));\n    },\n  });\n\n  const { mutateAsync: mutateResetTrash, isPending: isResetting } = useMutation({\n    mutationFn: () => resetTrashItems({ resourceType: TrashType.Base, resourceId: baseId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(baseId) });\n      toast.success(t('actions.resetSucceed'));\n    },\n  });\n\n  const allRows = useMemo(\n    () => (data ? data.pages.flatMap((d) => d) : []) as ITrashItemVo[],\n    [data]\n  );\n\n  const canReset =\n    permission?.['table|delete'] && permission?.['app|delete'] && permission?.['automation|delete'];\n\n  const canRestore = useCallback(\n    (resourceType: string) => {\n      switch (resourceType) {\n        case TrashType.Table:\n          return permission?.['table|create'];\n        case TrashType.App:\n          return permission?.['app|create'];\n        case TrashType.Workflow:\n          return permission?.['automation|create'];\n        default:\n          return false;\n      }\n    },\n    [permission]\n  );\n\n  const columns: ColumnDef<ITrashItemVo>[] = useMemo(() => {\n    const tableColumns: ColumnDef<ITrashItemVo>[] = [\n      {\n        accessorKey: 'resourceId',\n        header: t('name'),\n        size: Number.MAX_SAFE_INTEGER,\n        minSize: 156,\n        cell: ({ row }) => {\n          const resourceId = row.getValue<string>('resourceId');\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n\n          const { name } = resourceInfo;\n\n          return (\n            <div className=\"truncate text-wrap text-sm\" title={name}>\n              {name}\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: 'resourceType',\n        header: t('trash.type'),\n        size: 96,\n        cell: ({ row }) => {\n          const resourceType = row.getValue<string>('resourceType');\n          const resourceName = () => {\n            switch (resourceType) {\n              case TrashType.Table:\n                return t('common:noun.table');\n              case TrashType.App:\n                return t('common:noun.app');\n              case TrashType.Workflow:\n                return t('common:noun.automation');\n              default:\n                return '';\n            }\n          };\n\n          return <div className=\"text-wrap pr-2 text-sm\">{resourceName()}</div>;\n        },\n      },\n      {\n        accessorKey: 'deletedBy',\n        header: t('trash.deletedBy'),\n        size: 196,\n        cell: ({ row }) => {\n          const createdBy = row.getValue<string>('deletedBy');\n          const user = userMap[createdBy];\n\n          if (!user) return null;\n\n          const { name, avatar, email } = user;\n\n          return (\n            <Collaborator\n              item={{ name, email, avatar, type: PrincipalType.User }}\n              className=\"flex-1\"\n            />\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedTime',\n        header: t('trash.deletedTime'),\n        size: 156,\n        cell: ({ row }) => {\n          const deletedTime = row.getValue<string>('deletedTime');\n          const deletedDateStr = dayjs(deletedTime).format('YYYY/MM/DD HH:mm');\n          return <div title={deletedDateStr}>{deletedDateStr}</div>;\n        },\n      },\n      {\n        id: 'actions',\n        header: t('actions.title'),\n        size: 108,\n        cell: ({ row }) => {\n          const { id: trashId, resourceId, resourceType } = row.original;\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n\n          const showRestore = canRestore(resourceType);\n\n          if (!showRestore) return null;\n\n          return (\n            <Button\n              size=\"xs\"\n              variant=\"outline\"\n              title={t('actions.restore')}\n              onClick={() => mutateRestore({ trashId })}\n            >\n              {t('actions.restore')}\n            </Button>\n          );\n        },\n      },\n    ];\n\n    return tableColumns;\n  }, [t, resourceMap, userMap, mutateRestore, canRestore]);\n\n  const fetchNextPageInner = useCallback(() => {\n    if (!isFetching && nextCursor) {\n      fetchNextPage();\n    }\n  }, [fetchNextPage, isFetching, nextCursor]);\n\n  const handleResetTrash = useCallback(() => {\n    setConfirmVisible(true);\n  }, []);\n\n  const handleConfirmReset = useCallback(() => {\n    setConfirmVisible(false);\n    mutateResetTrash();\n  }, [mutateResetTrash]);\n\n  if (!isHydrated || isLoading) return null;\n\n  return (\n    <>\n      <div className=\"flex h-screen w-full flex-1 flex-col space-y-4 overflow-hidden pt-8\">\n        <Head>\n          <title>{`${t('noun.trash')} - ${brandName}`}</title>\n        </Head>\n        <div className=\"flex w-full items-center justify-between px-8 pb-2\">\n          <div className=\"flex flex-col items-start gap-2\">\n            <h1 className=\"text-2xl font-semibold\">{t('noun.trash')}</h1>\n            {!isCommunity && retentionDays > 0 && (\n              <p className=\"shrink-0 grow-0 text-left text-sm text-zinc-500\">\n                {t('common:trash.baseDescription', { retentionDays })}\n              </p>\n            )}\n          </div>\n          {canReset && (\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onClick={handleResetTrash}\n              disabled={allRows.length === 0 || isResetting}\n            >\n              {t('trash.resetTrash')}\n            </Button>\n          )}\n        </div>\n        <InfiniteTable\n          rows={allRows}\n          columns={columns}\n          className=\"px-8\"\n          fetchNextPage={fetchNextPageInner}\n        />\n      </div>\n      <ConfirmDialog\n        open={isConfirmVisible}\n        onOpenChange={setConfirmVisible}\n        title={t('trash.resetTrashConfirm')}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setConfirmVisible(false)}\n        onConfirm={handleConfirmReset}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashModal.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { ColumnDef } from '@tanstack/react-table';\nimport { Database, Trash2 } from '@teable/icons';\nimport type { ITrashItemVo, ITrashVo } from '@teable/openapi';\nimport { getTrash, TrashType, restoreTrash, deleteTrash, PrincipalType } from '@teable/openapi';\nimport { InfiniteTable } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { ConfirmDialog, Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport dayjs from 'dayjs';\nimport { IterationCcwIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useState } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { Collaborator } from '../../components/collaborator-manage/components/Collaborator';\nimport { useEnv } from '../../hooks/useEnv';\nimport { useIsCommunity } from '../../hooks/useIsCommunity';\n\ninterface ISpaceInnerTrashModalProps {\n  children: React.ReactNode;\n  spaceId: string;\n}\n\nexport const SpaceInnerTrashModal = (props: ISpaceInnerTrashModalProps) => {\n  const { children, spaceId } = props;\n  const isHydrated = useIsHydrated();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const resourceType = TrashType.Base;\n  const { trash } = useEnv();\n  const retentionDays = trash?.retentionDays ?? 0;\n  const isCommunity = useIsCommunity();\n  const [open, setOpen] = useState(false);\n  const [userMap, setUserMap] = useState<ITrashVo['userMap']>({});\n  const [resourceMap, setResourceMap] = useState<ITrashVo['resourceMap']>({});\n  const [nextCursor, setNextCursor] = useState<string | null | undefined>();\n  const [isConfirmVisible, setConfirmVisible] = useState(false);\n  const [deletingResource, setDeletingResource] = useState<\n    { trashId: string; name: string } | undefined\n  >();\n\n  const queryFn = async () => {\n    const res = await getTrash({ spaceId, resourceType });\n    const { trashItems, nextCursor } = res.data;\n\n    setNextCursor(() => nextCursor);\n    setUserMap((prev) => ({ ...prev, ...res.data.userMap }));\n    setResourceMap((prev) => ({ ...prev, ...res.data.resourceMap }));\n\n    return trashItems;\n  };\n\n  const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.getSpaceTrash(resourceType, spaceId),\n    queryFn,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: false,\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: () => nextCursor,\n    enabled: open,\n  });\n\n  const { mutateAsync: mutateRestore } = useMutation({\n    mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getSpaceTrash(resourceType, spaceId),\n      });\n      toast.success(t('actions.restoreSucceed'));\n    },\n  });\n\n  const { mutateAsync: mutatePermanentDelete } = useMutation({\n    mutationFn: (props: { trashId: string }) => deleteTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getSpaceTrash(resourceType, spaceId),\n      });\n      toast.success(t('actions.deleteSucceed'));\n    },\n  });\n\n  const allRows = useMemo(\n    () => (data ? (data.pages.flatMap((d) => d) as ITrashItemVo[]) : []),\n    [data]\n  );\n\n  const columns: ColumnDef<ITrashItemVo>[] = useMemo(() => {\n    const tableColumns: ColumnDef<ITrashItemVo>[] = [\n      {\n        accessorKey: 'resourceId',\n        header: t('name'),\n        size: Number.MAX_SAFE_INTEGER,\n        minSize: 300,\n        cell: ({ row }) => {\n          const resourceId = row.getValue<string>('resourceId');\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n          const { name } = resourceInfo;\n          return (\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <Database className=\"size-6 rounded-md border p-1\" />\n              <span className=\"truncate text-sm \">{name}</span>\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedBy',\n        header: t('trash.deletedBy'),\n        size: 196,\n        cell: ({ row }) => {\n          const createdBy = row.getValue<string>('deletedBy');\n          const user = userMap[createdBy];\n\n          if (!user) return null;\n\n          const { name, avatar, email } = user;\n\n          return (\n            <Collaborator\n              item={{ name, email, avatar, type: PrincipalType.User }}\n              className=\"flex-1\"\n            />\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedTime',\n        header: t('trash.deletedTime'),\n        size: 156,\n        cell: ({ row }) => {\n          const deletedTime = row.getValue<string>('deletedTime');\n          const deletedDateStr = dayjs(deletedTime).format('YYYY/MM/DD HH:mm');\n          return <div title={deletedDateStr}>{deletedDateStr}</div>;\n        },\n      },\n      {\n        id: 'actions',\n        header: t('actions.title'),\n        size: 108,\n        cell: ({ row }) => {\n          const { id: trashId, resourceId } = row.original;\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n\n          return (\n            <div className=\"flex items-center gap-1\">\n              <Button\n                size=\"xs\"\n                variant=\"ghost\"\n                className=\"size-8 p-0\"\n                title={t('actions.restore')}\n                onClick={() => mutateRestore({ trashId })}\n              >\n                <IterationCcwIcon className=\"size-4\" />\n              </Button>\n              <Button\n                size=\"xs\"\n                variant=\"ghost\"\n                className=\"size-8 p-0\"\n                title={t('actions.permanentDelete')}\n                onClick={() => {\n                  setConfirmVisible(true);\n                  setDeletingResource({\n                    trashId,\n                    name: resourceInfo.name,\n                  });\n                }}\n              >\n                <Trash2 className=\"size-4\" />\n              </Button>\n            </div>\n          );\n        },\n      },\n    ];\n\n    return tableColumns;\n  }, [t, resourceMap, userMap, mutateRestore]);\n\n  const fetchNextPageInner = useCallback(() => {\n    if (!isFetching && nextCursor) {\n      fetchNextPage();\n    }\n  }, [fetchNextPage, isFetching, nextCursor]);\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent className=\"flex h-[85%] max-h-[85%] max-w-[80%] flex-col gap-0 p-0 transition-[max-width] duration-300\">\n          <DialogHeader className=\"flex w-full border-b p-4\">\n            <DialogTitle>{t('noun.trash')}</DialogTitle>\n            {!isCommunity && retentionDays > 0 && (\n              <DialogDescription>\n                {t('common:trash.spaceInnerDescription', { retentionDays })}\n              </DialogDescription>\n            )}\n          </DialogHeader>\n          <div className=\"h-full flex-col overflow-hidden p-2\">\n            {isHydrated && !isLoading ? (\n              <InfiniteTable rows={allRows} columns={columns} fetchNextPage={fetchNextPageInner} />\n            ) : (\n              <Spin className=\"size-4\" />\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n      <ConfirmDialog\n        open={isConfirmVisible}\n        onOpenChange={setConfirmVisible}\n        title={t('trash.permanentDeleteTips', {\n          name: deletingResource?.name,\n          resource: t('noun.base'),\n        })}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setConfirmVisible(false)}\n        onConfirm={() => {\n          if (deletingResource == null) return;\n          const { trashId } = deletingResource;\n          setConfirmVisible(false);\n          mutatePermanentDelete({\n            trashId,\n          });\n        }}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { ColumnDef } from '@tanstack/react-table';\nimport { ChevronLeft, Trash2 } from '@teable/icons';\nimport type { ITrashItemVo, ITrashVo } from '@teable/openapi';\nimport { getTrash, restoreTrash, deleteTrash, PrincipalType, TrashType } from '@teable/openapi';\nimport { InfiniteTable } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport dayjs from 'dayjs';\nimport { IterationCcwIcon } from 'lucide-react';\nimport Head from 'next/head';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { Collaborator } from '../../components/collaborator-manage/components/Collaborator';\nimport { SpaceAvatar } from '../../components/space/SpaceAvatar';\nimport { useEnv } from '../../hooks/useEnv';\nimport { useIsCommunity } from '../../hooks/useIsCommunity';\n\nexport const SpaceTrashPage = () => {\n  const isHydrated = useIsHydrated();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const { brandName } = useBrand();\n  const router = useRouter();\n  const resourceType = TrashType.Space;\n\n  const onBack = () => {\n    router.push({ pathname: '/space' });\n  };\n  const { trash } = useEnv();\n  const retentionDays = trash?.retentionDays ?? 0;\n  const isCommunity = useIsCommunity();\n  const [userMap, setUserMap] = useState<ITrashVo['userMap']>({});\n  const [resourceMap, setResourceMap] = useState<ITrashVo['resourceMap']>({});\n  const [nextCursor, setNextCursor] = useState<string | null | undefined>();\n  const [isConfirmVisible, setConfirmVisible] = useState(false);\n  const [deletingResource, setDeletingResource] = useState<\n    { trashId: string; name: string } | undefined\n  >();\n\n  const queryFn = async () => {\n    const res = await getTrash({ resourceType });\n    const { trashItems, nextCursor } = res.data;\n\n    setNextCursor(() => nextCursor);\n    setUserMap({ ...userMap, ...res.data.userMap });\n    setResourceMap({ ...resourceMap, ...res.data.resourceMap });\n\n    return trashItems;\n  };\n\n  const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.getSpaceTrash(resourceType),\n    queryFn,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: false,\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: () => nextCursor,\n  });\n\n  const { mutateAsync: mutateRestore } = useMutation({\n    mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getSpaceTrash(resourceType) });\n      toast.success(t('actions.restoreSucceed'));\n    },\n  });\n\n  const { mutateAsync: mutatePermanentDelete } = useMutation({\n    mutationFn: (props: { trashId: string }) => deleteTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getSpaceTrash(resourceType) });\n      toast.success(t('actions.deleteSucceed'));\n    },\n  });\n\n  const allRows = useMemo(\n    () => (data ? (data.pages.flatMap((d) => d) as ITrashItemVo[]) : []),\n    [data]\n  );\n\n  const columns: ColumnDef<ITrashItemVo>[] = useMemo(() => {\n    const tableColumns: ColumnDef<ITrashItemVo>[] = [\n      {\n        accessorKey: 'resourceId',\n        header: t('name'),\n        size: Number.MAX_SAFE_INTEGER,\n        minSize: 300,\n        cell: ({ row }) => {\n          const resourceId = row.getValue<string>('resourceId');\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n\n          const { name } = resourceInfo;\n\n          return (\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <SpaceAvatar name={name} className=\"size-6\" />\n              <span className=\"truncate text-sm \">{name}</span>\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedBy',\n        header: t('trash.deletedBy'),\n        size: 196,\n        cell: ({ row }) => {\n          const createdBy = row.getValue<string>('deletedBy');\n          const user = userMap[createdBy];\n\n          if (!user) return null;\n\n          const { name, avatar, email } = user;\n\n          return (\n            <Collaborator\n              item={{ name, email, avatar, type: PrincipalType.User }}\n              className=\"flex-1\"\n            />\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedTime',\n        header: t('trash.deletedTime'),\n        size: 156,\n        cell: ({ row }) => {\n          const deletedTime = row.getValue<string>('deletedTime');\n          const deletedDateStr = dayjs(deletedTime).format('YYYY/MM/DD HH:mm');\n          return <div title={deletedDateStr}>{deletedDateStr}</div>;\n        },\n      },\n      {\n        id: 'actions',\n        header: t('actions.title'),\n        size: 108,\n        cell: ({ row }) => {\n          const { id: trashId, resourceId } = row.original;\n          const resourceInfo = resourceMap[resourceId];\n\n          if (!resourceInfo) return null;\n\n          return (\n            <div className=\"flex items-center gap-1\">\n              <Button\n                size=\"xs\"\n                variant=\"ghost\"\n                className=\"size-8 p-0\"\n                title={t('actions.restore')}\n                onClick={() => mutateRestore({ trashId })}\n              >\n                <IterationCcwIcon className=\"size-4\" />\n              </Button>\n              <Button\n                size=\"xs\"\n                variant=\"ghost\"\n                className=\"size-8 p-0\"\n                title={t('actions.permanentDelete')}\n                onClick={() => {\n                  setConfirmVisible(true);\n                  setDeletingResource({\n                    trashId,\n                    name: resourceInfo.name,\n                  });\n                }}\n              >\n                <Trash2 className=\"size-4\" />\n              </Button>\n            </div>\n          );\n        },\n      },\n    ];\n\n    return tableColumns;\n  }, [t, resourceMap, userMap, mutateRestore]);\n\n  const fetchNextPageInner = useCallback(() => {\n    if (!isFetching && nextCursor) {\n      fetchNextPage();\n    }\n  }, [fetchNextPage, isFetching, nextCursor]);\n\n  if (!isHydrated || isLoading) return null;\n\n  return (\n    <div className=\"flex h-screen flex-1 flex-col space-y-4 overflow-hidden p-8\">\n      <Head>\n        <title>{`${t('common:trash.spaceTrash')} - ${brandName}`}</title>\n      </Head>\n      <div className=\"flex flex-col items-start justify-between gap-2 \">\n        <Button\n          className=\"h-6 p-0 text-sm text-muted-foreground hover:no-underline hover:opacity-75\"\n          variant=\"link\"\n          onClick={onBack}\n        >\n          <ChevronLeft className=\"size-4\" />\n          <span>{t('common:settings.back')}</span>\n        </Button>\n        <h1 className=\"text-2xl font-semibold\">{t('noun.trash')}</h1>\n        {!isCommunity && retentionDays > 0 && (\n          <p className=\"shrink-0 grow-0 text-left text-sm text-zinc-500\">\n            {t('common:trash.spaceDescription', { retentionDays })}\n          </p>\n        )}\n      </div>\n      <InfiniteTable rows={allRows} columns={columns} fetchNextPage={fetchNextPageInner} />\n      <ConfirmDialog\n        open={isConfirmVisible}\n        onOpenChange={setConfirmVisible}\n        title={t('trash.permanentDeleteTips', {\n          name: deletingResource?.name,\n          resource: t('noun.space'),\n        })}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setConfirmVisible(false)}\n        onConfirm={() => {\n          if (deletingResource == null) return;\n          const { trashId } = deletingResource;\n          setConfirmVisible(false);\n          mutatePermanentDelete({\n            trashId,\n          });\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx",
    "content": "import type { QueryFunctionContext } from '@tanstack/react-query';\nimport { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { ColumnDef } from '@tanstack/react-table';\nimport type {\n  ITrashVo,\n  ITableTrashItemVo,\n  IViewSnapshotItemVo,\n  IFieldSnapshotItemVo,\n} from '@teable/openapi';\nimport { getTrashItems, TrashType, restoreTrash, TableTrashType } from '@teable/openapi';\nimport { CollaboratorWithHoverCard, InfiniteTable } from '@teable/sdk/components';\nimport { VIEW_ICON_MAP } from '@teable/sdk/components/view/constant';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBasePermission, useFieldStaticGetter, useIsHydrated } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport dayjs from 'dayjs';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useCallback, useMemo, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface ITableTrashProps {\n  tableId: string;\n}\n\nexport const TableTrash = (props: ITableTrashProps) => {\n  const { tableId } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isHydrated = useIsHydrated();\n  const queryClient = useQueryClient();\n  const getFieldStatic = useFieldStaticGetter();\n  const permission = useBasePermission();\n\n  const hasRestorePermission = permission?.['table|trash_update'];\n\n  const [nextCursor, setNextCursor] = useState<string | null | undefined>();\n  const [userMap, setUserMap] = useState<ITrashVo['userMap']>({});\n  const [resourceMap, setResourceMap] = useState<ITrashVo['resourceMap']>({});\n\n  const queryFn = async ({\n    queryKey,\n    pageParam,\n  }: QueryFunctionContext<readonly ['trash-items', string], string | undefined>) => {\n    const res = await getTrashItems({\n      resourceType: TrashType.Table,\n      resourceId: queryKey[1] as string,\n      cursor: pageParam,\n    });\n    const { trashItems, nextCursor } = res.data;\n    setNextCursor(() => nextCursor);\n    setUserMap({ ...userMap, ...res.data.userMap });\n    setResourceMap({ ...resourceMap, ...res.data.resourceMap });\n    return trashItems;\n  };\n\n  const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.getTrashItems(tableId),\n    queryFn,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: false,\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: () => nextCursor,\n  });\n\n  const { mutateAsync: mutateRestore } = useMutation({\n    mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(tableId) });\n      toast.success(t('actions.restoreSucceed'));\n    },\n  });\n\n  const allRows = useMemo(\n    () => (data ? data.pages.flatMap((d) => d) : []) as ITableTrashItemVo[],\n    [data]\n  );\n\n  const columns: ColumnDef<ITableTrashItemVo>[] = useMemo(() => {\n    const result: ColumnDef<ITableTrashItemVo>[] = [\n      {\n        accessorKey: 'deletedTime',\n        header: t('trash.deletedTime'),\n        size: 90,\n        cell: ({ row }) => {\n          const deletedTime = row.getValue<string>('deletedTime');\n          const deletedDate = dayjs(deletedTime);\n          const isToday = deletedDate.isSame(dayjs(), 'day');\n          return (\n            <div className=\"text-xs\" title={deletedDate.format('YYYY/MM/DD HH:mm')}>\n              {deletedDate.format(isToday ? 'HH:mm' : 'YYYY/MM/DD')}\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: 'deletedBy',\n        header: t('trash.deletedBy'),\n        size: 80,\n        cell: ({ row }) => {\n          const deletedBy = row.getValue<string>('deletedBy');\n          const user = userMap[deletedBy];\n\n          if (!user) return null;\n\n          const { id, name, avatar, email } = user;\n\n          return (\n            <div className=\"flex justify-center\">\n              <CollaboratorWithHoverCard id={id} name={name} avatar={avatar} email={email} />\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: 'resourceType',\n        header: t('table:tableTrash.resourceType'),\n        size: 100,\n        cell: ({ row }) => {\n          const resourceType = row.getValue<string>('resourceType');\n          const resourceStringMap: Record<string, string> = {\n            [TableTrashType.View]: t('noun.view'),\n            [TableTrashType.Field]: t('noun.field'),\n            [TableTrashType.Record]: t('noun.record'),\n          };\n\n          return <div className=\"flex items-center gap-x-1\">{resourceStringMap[resourceType]}</div>;\n        },\n      },\n      {\n        accessorKey: 'resourceIds',\n        header: t('table:tableTrash.deletedResource'),\n        size: Number.MAX_SAFE_INTEGER,\n        minSize: 200,\n        cell: ({ row }) => {\n          const resourceType = row.getValue<TableTrashType>('resourceType');\n          const resourceIds = row.getValue<ITableTrashItemVo['resourceIds']>('resourceIds');\n          const resourceList = resourceIds\n            .map((resourceId) => {\n              return resourceMap[resourceId];\n            })\n            .filter(Boolean);\n          return (\n            <Fragment>\n              {resourceList.length ? (\n                <div className=\"flex w-full flex-wrap gap-1\">\n                  {resourceList.map((resource) => {\n                    const { id, name } = resource;\n                    const Icon =\n                      resourceType === TableTrashType.Field\n                        ? getFieldStatic((resource as IFieldSnapshotItemVo).type, {\n                            isLookup: Boolean((resource as IFieldSnapshotItemVo).isLookup),\n                            isConditionalLookup: Boolean(\n                              (resource as IFieldSnapshotItemVo).isConditionalLookup\n                            ),\n                            hasAiConfig: false,\n                          }).Icon\n                        : resourceType === TableTrashType.View\n                          ? VIEW_ICON_MAP[(resource as IViewSnapshotItemVo).type]\n                          : null;\n                    return (\n                      <div\n                        key={id}\n                        className=\"flex items-center rounded-sm bg-muted px-2 py-[2px] text-xs\"\n                      >\n                        {Icon && <Icon className=\"mr-1 size-3\" />}\n                        {name || t('sdk:common.unnamedRecord')}\n                      </div>\n                    );\n                  })}\n                </div>\n              ) : (\n                <span className=\"text-gray-500\">{t('common.empty')}</span>\n              )}\n            </Fragment>\n          );\n        },\n      },\n    ];\n\n    if (hasRestorePermission) {\n      result.push({\n        accessorKey: 'id',\n        header: t('actions.title'),\n        size: 80,\n        cell: ({ row }) => {\n          const trashId = row.getValue<string>('id');\n          return (\n            <Button size=\"sm\" onClick={() => mutateRestore({ trashId })}>\n              {t('actions.restore')}\n            </Button>\n          );\n        },\n      });\n    }\n    return result;\n  }, [t, userMap, resourceMap, hasRestorePermission, getFieldStatic, mutateRestore]);\n\n  const fetchNextPageInner = useCallback(() => {\n    if (!isFetching && nextCursor) {\n      fetchNextPage();\n    }\n  }, [fetchNextPage, isFetching, nextCursor]);\n\n  if (!isHydrated || isLoading) return null;\n\n  return (\n    <InfiniteTable\n      rows={allRows}\n      columns={columns}\n      className=\"sm:overflow-x-hidden\"\n      fetchNextPage={fetchNextPageInner}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/trash/components/TableTrashDialog.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { resetTrashItems, TrashType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBasePermission } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { TableTrash } from './TableTrash';\n\ninterface ITableTrashDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  tableId: string;\n}\n\nexport const TableTrashDialog = (props: ITableTrashDialogProps) => {\n  const { open, onOpenChange, tableId } = props;\n  const permission = useBasePermission();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const [isConfirmVisible, setConfirmVisible] = useState(false);\n\n  const hasResetPermission = permission?.['table|trash_reset'];\n\n  const { mutateAsync: mutateResetTrash } = useMutation({\n    mutationFn: () => resetTrashItems({ resourceType: TrashType.Table, resourceId: tableId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(tableId) });\n      toast.success(t('actions.resetSucceed'));\n    },\n  });\n\n  return (\n    <Fragment>\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent className=\"flex h-[90%] max-w-4xl flex-col gap-0 p-0\">\n          <DialogHeader className=\"flex flex-row items-center justify-between gap-x-2 space-y-0 border-b p-4\">\n            <DialogTitle className=\"flex items-center\">{t('table:tableTrash.title')}</DialogTitle>\n            {hasResetPermission && (\n              <Button\n                size=\"xs\"\n                className=\"mr-8\"\n                variant=\"secondary\"\n                onClick={() => setConfirmVisible(true)}\n              >\n                {t('trash.resetTrash')}\n              </Button>\n            )}\n          </DialogHeader>\n          <TableTrash tableId={tableId} />\n        </DialogContent>\n      </Dialog>\n      <ConfirmDialog\n        open={isConfirmVisible}\n        onOpenChange={setConfirmVisible}\n        title={t('trash.resetTrashConfirm')}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setConfirmVisible(false)}\n        onConfirm={() => {\n          setConfirmVisible(false);\n          mutateResetTrash();\n        }}\n      />\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/View.tsx",
    "content": "import { IdPrefix, ViewType } from '@teable/core';\nimport {\n  useConnection,\n  useIsReadOnlyPreview,\n  usePersonalView,\n  useTableId,\n  useView,\n  useViews,\n} from '@teable/sdk';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect } from 'react';\nimport type { Query } from 'sharedb';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { CalendarView } from './calendar/CalendarView';\nimport { FormView } from './form/FormView';\nimport { GalleryView } from './gallery/GalleryView';\nimport { GridView } from './grid/GridView';\nimport { KanbanView } from './kanban/KanbanView';\nimport { PluginView } from './plugin/PluginView';\nimport type { IViewBaseProps } from './types';\n\nexport const View = (props: IViewBaseProps) => {\n  const view = useView();\n  const views = useViews();\n  const viewType = view?.type;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { connection } = useConnection();\n  const tableId = useTableId();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { openPersonalView, isPersonalView } = usePersonalView();\n\n  useEffect(() => {\n    if (isReadOnlyPreview && !isPersonalView) {\n      openPersonalView?.();\n    }\n  }, [isReadOnlyPreview, openPersonalView, isPersonalView]);\n\n  if (tableId && connection?.queries) {\n    const query = Object.values(connection?.queries).find(\n      (query: Query) => query.collection === `${IdPrefix.View}_${tableId}`\n    );\n\n    if (query?.ready && !views.length) {\n      return (\n        <>\n          <div className=\"flex h-full flex-col items-center justify-center gap-y-4 text-center\">\n            <h3 data-testid=\"not-found-title\" className=\"text-xl font-semibold text-foreground\">\n              {t('table:view.noView')}\n            </h3>\n            <p className=\"max-w-md text-sm text-muted-foreground\">\n              {t('common:admin.tips.pleaseContactAdmin')}\n            </p>\n          </div>\n        </>\n      );\n    }\n  }\n\n  const getViewComponent = () => {\n    switch (viewType) {\n      case ViewType.Grid:\n        return <GridView {...props} />;\n      case ViewType.Form:\n        return <FormView />;\n      case ViewType.Kanban:\n        return <KanbanView />;\n      case ViewType.Gallery:\n        return <GalleryView />;\n      case ViewType.Calendar:\n        return <CalendarView />;\n      case ViewType.Plugin:\n        return <PluginView />;\n      default:\n        return null;\n    }\n  };\n\n  return getViewComponent();\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/CalendarView.tsx",
    "content": "import { RecordProvider } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { CalendarToolBar } from '../tool-bar/CalendarToolBar';\nimport { CalendarViewBase } from './CalendarViewBase';\nimport { CalendarProvider } from './context';\n\nexport const CalendarView = () => {\n  const isHydrated = useIsHydrated();\n\n  return (\n    <SearchProvider>\n      <RecordProvider>\n        <CalendarToolBar />\n        <CalendarProvider>\n          <div className=\"w-full grow overflow-hidden\">{isHydrated && <CalendarViewBase />}</div>\n        </CalendarProvider>\n      </RecordProvider>\n    </SearchProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/CalendarViewBase.tsx",
    "content": "import { CalendarDailyCollectionProvider } from '@teable/sdk/context';\nimport { usePersonalView } from '@teable/sdk/hooks';\nimport { Fragment, useMemo, useState } from 'react';\nimport { AddDateFieldDialog } from './components/AddDateFieldDialog';\nimport { Calendar } from './components/Calendar';\nimport { useCalendar } from './hooks';\n\nexport const CalendarViewBase = () => {\n  const { startDateField, endDateField } = useCalendar();\n  const { personalViewCommonQuery } = usePersonalView();\n  const [dateRange, setDateRange] = useState<{\n    startDate: string;\n    endDate: string;\n  }>();\n\n  const query = useMemo(() => {\n    return {\n      startDate: dateRange?.startDate || '',\n      endDate: dateRange?.endDate || '',\n      startDateFieldId: startDateField?.id || '',\n      endDateFieldId: endDateField?.id || '',\n      filter: personalViewCommonQuery?.filter,\n      ignoreViewQuery: personalViewCommonQuery?.ignoreViewQuery || false,\n    };\n  }, [dateRange, startDateField, endDateField, personalViewCommonQuery]);\n\n  return (\n    <Fragment>\n      <CalendarDailyCollectionProvider query={query}>\n        <Calendar dateRange={dateRange} setDateRange={setDateRange} />\n      </CalendarDailyCollectionProvider>\n      <AddDateFieldDialog />\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddDateFieldDialog.tsx",
    "content": "import { FieldType, TimeFormatting } from '@teable/core';\nimport { useFieldOperations, useTableId, useTablePermission, useView } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\nimport {\n  getFormatStringForLanguage,\n  localFormatStrings,\n  systemTimeZone,\n} from '@/features/app/components/field-setting/formatting/DatetimeFormatting';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useCalendar } from '../hooks';\n\nexport const AddDateFieldDialog = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const view = useView();\n  const tableId = useTableId();\n  const permission = useTablePermission();\n  const { startDateField, endDateField } = useCalendar();\n  const { createField } = useFieldOperations();\n  const [open, setOpen] = useState(false);\n\n  const hasDateField = startDateField || endDateField;\n  const fieldCreatable = Boolean(permission['field|create']);\n  const viewUpdatable = Boolean(permission['view|update']);\n\n  useEffect(() => {\n    if (hasDateField || !fieldCreatable) return;\n    setOpen(true);\n  }, [hasDateField, fieldCreatable]);\n\n  const onClick = async () => {\n    if (!tableId) return;\n\n    const localDateFormatting = getFormatStringForLanguage(navigator.language, localFormatStrings);\n\n    const defaultFormatting = {\n      date: localDateFormatting,\n      time: TimeFormatting.None,\n      timeZone: systemTimeZone,\n    };\n\n    const startDateField = await createField({\n      tableId,\n      fieldRo: {\n        name: t('table:calendar.dialog.startDate'),\n        type: FieldType.Date,\n        options: {\n          formatting: defaultFormatting,\n        },\n      },\n    });\n    const endDateField = await createField({\n      tableId,\n      fieldRo: {\n        name: t('table:calendar.dialog.endDate'),\n        type: FieldType.Date,\n        options: {\n          formatting: defaultFormatting,\n        },\n      },\n    });\n\n    if (view != null && viewUpdatable) {\n      await view.updateOption({\n        startDateFieldId: startDateField.id,\n        endDateFieldId: endDateField.id,\n      });\n    }\n    setOpen(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent\n        className=\"flex max-w-xl flex-col\"\n        onMouseDown={(e) => e.stopPropagation()}\n        onKeyDown={(e) => e.stopPropagation()}\n        onPointerDownOutside={(e) => e.preventDefault()}\n        onInteractOutside={(e) => e.preventDefault()}\n        closeable={false}\n      >\n        <DialogHeader>\n          <DialogTitle>{t('table:calendar.dialog.addDateField')}</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"py-1\">{t('table:calendar.dialog.content')}</div>\n\n        <DialogFooter>\n          <DialogClose asChild>\n            <Button size=\"sm\" type=\"button\" variant=\"ghost\">\n              {t('table:calendar.dialog.notAdd')}\n            </Button>\n          </DialogClose>\n          <Button size=\"sm\" onClick={onClick}>\n            {t('table:calendar.dialog.addDateField')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/AddEventButton.tsx",
    "content": "import { FieldKeyType } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { useRecordOperations } from '@teable/sdk/hooks';\nimport type { DateField } from '@teable/sdk/model';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { createPortal } from 'react-dom';\n\ninterface IAddEventButtonProps {\n  date: Date;\n  containerEl: HTMLElement;\n  tableId?: string;\n  startDateField?: DateField;\n  endDateField?: DateField;\n  setExpandRecordId?: (id: string) => void;\n}\n\nexport const ADD_EVENT_BUTTON_CLASS_NAME = 'add-event-btn';\n\nexport const AddEventButton = (props: IAddEventButtonProps) => {\n  const { date, tableId, startDateField, endDateField, containerEl, setExpandRecordId } = props;\n\n  const { createRecords } = useRecordOperations();\n\n  const onClick = async (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (!tableId || !startDateField || !endDateField) return;\n\n    const { data } = await createRecords({\n      tableId,\n      recordsRo: {\n        fieldKeyType: FieldKeyType.Id,\n        records: [\n          {\n            fields: {\n              [startDateField.id]: date.toISOString(),\n              [endDateField.id]: date.toISOString(),\n            },\n          },\n        ],\n      },\n    });\n\n    setExpandRecordId?.(data.records[0].id);\n  };\n\n  return createPortal(\n    <Button\n      size=\"icon-sm\"\n      variant=\"secondary\"\n      className={cn(\n        ADD_EVENT_BUTTON_CLASS_NAME,\n        'invisible absolute left-[2px] top-[2px] z-10 size-5 rounded-sm p-0'\n      )}\n      onClick={onClick}\n    >\n      <Plus className=\"size-4 shrink-0\" />\n    </Button>,\n    containerEl\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/Calendar.tsx",
    "content": "/* eslint-disable import/no-duplicates */\nimport type { EventDropArg, EventInput, EventMountArg } from '@fullcalendar/core/index.js';\nimport enGbLocale from '@fullcalendar/core/locales/en-gb';\nimport frLocale from '@fullcalendar/core/locales/fr';\nimport jaLocale from '@fullcalendar/core/locales/ja';\nimport ruLocale from '@fullcalendar/core/locales/ru';\nimport zhCnLocale from '@fullcalendar/core/locales/zh-cn';\nimport dayGridPlugin from '@fullcalendar/daygrid';\nimport type { EventResizeDoneArg } from '@fullcalendar/interaction';\nimport interactionPlugin from '@fullcalendar/interaction';\nimport FullCalendar from '@fullcalendar/react';\nimport { FieldKeyType } from '@teable/core';\nimport { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from '@teable/icons';\nimport { updateRecord } from '@teable/openapi';\nimport { AppContext, CalendarDailyCollectionContext } from '@teable/sdk/context';\nimport { useTableId, useRecordOperations } from '@teable/sdk/hooks';\nimport type { Record } from '@teable/sdk/model';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Calendar as DatePicker,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { addDays, subDays, format, set } from 'date-fns';\nimport { enUS, zhCN, ja, ru, fr } from 'date-fns/locale';\nimport { toZonedTime, fromZonedTime } from 'date-fns-tz';\nimport { useTranslation } from 'next-i18next';\nimport { useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { EventListContainer } from '../components/EventListContainer';\nimport { EventMenu } from '../components/EventMenu';\nimport { useCalendar, useEventMenuStore } from '../hooks';\nimport { getColorByConfig, getDateByTimezone, getEventTitle } from '../util';\n\nconst ADD_EVENT_BUTTON_CLASS_NAME = 'calendar-add-event-button';\nconst MORE_LINK_TEXT_CLASS_NAME = 'calendar-custom-more-link-text';\n\nconst FULL_CALENDAR_LOCALE_MAP = {\n  zh: zhCnLocale,\n  en: enGbLocale,\n  ja: jaLocale,\n  ru: ruLocale,\n  fr: frLocale,\n};\n\n// Remember to update in @sdk/src/components/editor/date/EditorMain.tsx\nconst DATE_PICKER_LOCAL_MAP = {\n  zh: zhCN,\n  en: enUS,\n  ja: ja,\n  ru: ru,\n  fr: fr,\n};\n\nexport interface ICalendarProps {\n  dateRange?: { startDate: string; endDate: string };\n  setDateRange?: (dateRange: { startDate: string; endDate: string }) => void;\n}\n\nexport const Calendar = (props: ICalendarProps) => {\n  const { dateRange, setDateRange } = props;\n  const {\n    titleField,\n    startDateField,\n    endDateField,\n    colorConfig,\n    colorField,\n    permission,\n    setExpandRecordId,\n  } = useCalendar();\n  const tableId = useTableId();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { lang = 'en' } = useContext(AppContext);\n  const calendarDailyCollection = useContext(CalendarDailyCollectionContext);\n  const { openEventMenu } = useEventMenuStore();\n  const { createRecords } = useRecordOperations();\n  const [positionDate, setPositionDate] = useState<Date>();\n  const [moreLinkDate, setMoreLinkDate] = useState<Date>();\n  const [title, setTitle] = useState<string>('');\n\n  const calendarRef = useRef<FullCalendar>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const { isComputed: isStartComputed } = startDateField ?? {};\n  const { eventCreatable, eventResizable, eventDraggable } = permission ?? {};\n\n  useEffect(() => {\n    if (!calendarRef.current) return;\n\n    const calendarApi = calendarRef.current.getApi();\n\n    const resizeObserver = new ResizeObserver(() => {\n      calendarApi.updateSize();\n    });\n\n    if (containerRef.current) {\n      resizeObserver.observe(containerRef.current);\n    }\n\n    return () => {\n      if (containerRef.current) {\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        resizeObserver.unobserve(containerRef.current);\n      }\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!containerRef.current || !eventCreatable) return;\n\n    const addButtonToDay = (dayEl: HTMLElement) => {\n      if (dayEl.querySelector(`.${ADD_EVENT_BUTTON_CLASS_NAME}`)) return;\n\n      const dateAttr = dayEl.getAttribute('data-date');\n      if (!dateAttr) return;\n\n      const date = new Date(dateAttr);\n      dayEl.style.position = 'relative';\n\n      const button = document.createElement('button');\n      button.className = `${ADD_EVENT_BUTTON_CLASS_NAME} invisible absolute left-[2px] top-[2px] z-10 rounded-md bg-secondary text-secondary-foreground size-6 hover:bg-secondary/80 text-lg leading-[0.75] pb-[2px]`;\n      button.textContent = '+';\n\n      button.onclick = async (e: MouseEvent) => {\n        e.preventDefault();\n        e.stopPropagation();\n\n        if (!tableId || !startDateField || !endDateField) return;\n\n        const { timeZone } = startDateField.options.formatting;\n        const newDate = set(date, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 });\n        const newDateStr = fromZonedTime(newDate, timeZone).toISOString();\n\n        const { data } = await createRecords({\n          tableId,\n          recordsRo: {\n            fieldKeyType: FieldKeyType.Id,\n            records: [\n              {\n                fields: {\n                  [startDateField.id]: newDateStr,\n                  [endDateField.id]: newDateStr,\n                },\n              },\n            ],\n          },\n        });\n\n        setExpandRecordId?.(data.records[0].id);\n      };\n\n      dayEl.appendChild(button);\n\n      dayEl.addEventListener('mouseover', (e: MouseEvent) => {\n        if (e.target instanceof Element && e.target.classList.contains('fc-daygrid-day-frame')) {\n          button.classList.remove('invisible');\n        }\n      });\n\n      dayEl.addEventListener('mouseleave', () => {\n        button.classList.add('invisible');\n      });\n    };\n\n    const dayElements = containerRef.current.querySelectorAll('.fc-day');\n    dayElements.forEach((dayEl) => {\n      addButtonToDay(dayEl as HTMLElement);\n    });\n\n    const observer = new MutationObserver((mutations) => {\n      mutations.forEach((mutation) => {\n        mutation.addedNodes.forEach((node) => {\n          if (node instanceof HTMLElement && node.classList.contains('fc-day')) {\n            addButtonToDay(node);\n          }\n        });\n      });\n    });\n\n    observer.observe(containerRef.current, {\n      childList: true,\n      subtree: true,\n    });\n\n    return () => {\n      observer.disconnect();\n      document\n        .querySelectorAll(`.${ADD_EVENT_BUTTON_CLASS_NAME}`)\n        .forEach((button) => button.remove());\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [tableId, endDateField, startDateField, eventCreatable, dateRange, setExpandRecordId]);\n\n  const onDatesChanged = (data: { start: Date; end: Date }) => {\n    const { start, end } = data;\n\n    if (calendarRef.current) {\n      setTitle(calendarRef.current.getApi().view.title);\n    }\n\n    const startStr = start.toISOString();\n    const endStr = end.toISOString();\n\n    setDateRange?.({\n      startDate: startStr,\n      endDate: endStr,\n    });\n  };\n\n  const isLoading = startDateField && endDateField && !calendarDailyCollection;\n  const { countMap, records = [] } = calendarDailyCollection ?? {};\n\n  const events = useMemo(() => {\n    return records\n      .map((r) => {\n        if (!titleField || !startDateField || !endDateField) return;\n\n        const title = r.fields[titleField.id];\n        const start = r.fields[startDateField.id];\n        const end = r.fields[endDateField.id];\n        const { timeZone } = startDateField.options.formatting;\n\n        const { color: textColor, backgroundColor } = getColorByConfig(\n          r as unknown as Record,\n          colorConfig,\n          colorField\n        );\n        const endDate = end ? addDays(new Date(end as string), 1).toISOString() : undefined;\n\n        return {\n          id: r.id,\n          title: getEventTitle(\n            titleField.cellValue2String(title) || t('sdk:common.unnamedRecord'),\n            start as string,\n            startDateField\n          ),\n          start: start ? toZonedTime(new Date(start as string), timeZone) : undefined,\n          end: endDate ? toZonedTime(new Date(endDate), timeZone) : undefined,\n          textColor,\n          backgroundColor,\n          allDay: true,\n          meta: {\n            start,\n            end,\n          },\n        };\n      })\n      .filter(Boolean) as EventInput[];\n  }, [records, colorConfig, titleField, colorField, startDateField, endDateField, t]);\n\n  useEffect(() => {\n    if (!countMap) return;\n\n    const updateMoreLinkText = () => {\n      const elements = document.getElementsByClassName(MORE_LINK_TEXT_CLASS_NAME);\n      Array.from(elements).forEach((element) => {\n        const dayEl = element.closest('.fc-day') as HTMLElement;\n        const date = dayEl?.dataset.date;\n        if (date && countMap[date]) {\n          const newText = t('table:calendar.moreLinkText', { count: countMap[date] });\n          if (element.textContent !== newText) {\n            element.textContent = newText;\n          }\n        }\n      });\n    };\n\n    const calendarContainer = containerRef.current;\n\n    if (!calendarContainer) return;\n\n    const observer = new MutationObserver((mutations) => {\n      const relevantMutations = mutations.filter((mutation) =>\n        Array.from(mutation.addedNodes).some(\n          (node) =>\n            node instanceof HTMLElement &&\n            (node.classList.contains(MORE_LINK_TEXT_CLASS_NAME) ||\n              node.querySelector(`.${MORE_LINK_TEXT_CLASS_NAME}`))\n        )\n      );\n\n      if (relevantMutations.length > 0) {\n        updateMoreLinkText();\n      }\n    });\n\n    observer.observe(calendarContainer, {\n      subtree: true,\n      childList: true,\n    });\n\n    return () => observer.disconnect();\n  }, [countMap, t, containerRef]);\n\n  const onEventDidMount = (info: EventMountArg) => {\n    const element = info.el as HTMLElement;\n\n    element.addEventListener('contextmenu', (e) => {\n      e.preventDefault();\n      if (!containerRef.current) return;\n\n      const containerRect = containerRef.current.getBoundingClientRect();\n      const relativeX = e.clientX - containerRect.left;\n      const relativeY = e.clientY - containerRect.top;\n\n      openEventMenu({\n        eventId: info.event.id,\n        permission,\n        position: {\n          x: relativeX,\n          y: relativeY,\n        },\n      });\n    });\n  };\n\n  const onEventResize = (info: EventResizeDoneArg) => {\n    const { event, startDelta, endDelta } = info;\n\n    if (!tableId || !startDateField || !endDateField) return;\n\n    const { timeZone } = startDateField.options.formatting;\n\n    // resize start date\n    if (startDelta.days !== 0) {\n      const newDate = getDateByTimezone(\n        new Date(event.startStr),\n        timeZone,\n        event.extendedProps.meta.start\n      );\n\n      updateRecord(tableId, event.id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [startDateField.id]: newDate,\n          },\n        },\n      });\n    }\n\n    // resize end date\n    if (endDelta.days !== 0) {\n      const newDate = getDateByTimezone(\n        subDays(new Date(event.endStr), 1),\n        timeZone,\n        event.extendedProps.meta.end\n      );\n\n      updateRecord(tableId, event.id, {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [endDateField.id]: newDate,\n          },\n        },\n      });\n    }\n  };\n\n  const onEventDrop = (info: EventDropArg) => {\n    const { event } = info;\n\n    if (!tableId || !startDateField || !endDateField) return;\n\n    const { timeZone } = startDateField.options.formatting;\n\n    const { start, end } = event.extendedProps.meta;\n    const newStart = getDateByTimezone(new Date(event.startStr), timeZone, start);\n    const newEnd = end\n      ? getDateByTimezone(subDays(new Date(event.endStr), 1), timeZone, end)\n      : undefined;\n\n    updateRecord(tableId, event.id, {\n      fieldKeyType: FieldKeyType.Id,\n      record: {\n        fields: {\n          [startDateField.id]: newStart,\n          ...(newEnd && { [endDateField.id]: newEnd }),\n        },\n      },\n    });\n  };\n\n  const onPrevHandler = () => {\n    const calendarApi = calendarRef.current?.getApi();\n    calendarApi?.prev();\n  };\n\n  const onNextHandler = () => {\n    const calendarApi = calendarRef.current?.getApi();\n    calendarApi?.next();\n  };\n\n  const onTodayHandler = () => {\n    const calendarApi = calendarRef.current?.getApi();\n    calendarApi?.today();\n  };\n\n  const onDateSelect = (date: Date | undefined) => {\n    if (!date || !calendarRef.current) return;\n    const calendarApi = calendarRef.current.getApi();\n    calendarApi.gotoDate(date);\n    setPositionDate(date);\n  };\n\n  useEffect(() => {\n    if (calendarRef.current) {\n      setTitle(calendarRef.current.getApi().view.title);\n    }\n  }, []);\n\n  return (\n    <div className=\"relative flex size-full flex-col overflow-hidden p-4 pt-2\" ref={containerRef}>\n      <div className=\"mb-2 flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h2 className=\"flex items-center text-xl font-semibold\">\n            {title || calendarRef.current?.getApi().view.title}\n            <Loader2\n              className={cn(\n                'ml-1 size-5 animate-spin transition-opacity duration-1000',\n                isLoading ? 'opacity-100' : 'opacity-0'\n              )}\n            />\n          </h2>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Popover>\n            <PopoverTrigger asChild>\n              <Button variant=\"outline\" size=\"icon-sm\">\n                <CalendarIcon className=\"size-4 shrink-0\" />\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-auto p-0\" align=\"start\">\n              <DatePicker\n                mode=\"single\"\n                locale={DATE_PICKER_LOCAL_MAP[lang as keyof typeof DATE_PICKER_LOCAL_MAP]}\n                initialFocus\n                selected={positionDate}\n                defaultMonth={positionDate}\n                onSelect={onDateSelect}\n              />\n            </PopoverContent>\n          </Popover>\n          <Button variant=\"outline\" size=\"sm\" className=\"text-sm\" onClick={onTodayHandler}>\n            {t('sdk:editor.date.today')}\n          </Button>\n          <div className=\"flex items-center gap-1\">\n            <Button variant=\"outline\" size=\"icon-sm\" onClick={onPrevHandler}>\n              <ChevronLeft className=\"size-4 shrink-0\" />\n            </Button>\n            <Button variant=\"outline\" size=\"icon-sm\" onClick={onNextHandler}>\n              <ChevronRight className=\"size-4 shrink-0\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n      <div className=\"flex-1 overflow-x-auto overflow-y-hidden sm:overflow-hidden\">\n        <div className=\"size-full min-w-[640px]\">\n          <FullCalendar\n            ref={calendarRef}\n            locale={FULL_CALENDAR_LOCALE_MAP[lang as keyof typeof FULL_CALENDAR_LOCALE_MAP]}\n            initialView=\"dayGridMonth\"\n            plugins={[dayGridPlugin, interactionPlugin]}\n            height=\"100%\"\n            dayMaxEventRows\n            dayHeaderClassNames=\"!py-1\"\n            headerToolbar={false}\n            events={events}\n            eventClassNames=\"outline-none text-xs px-2 h-5 border-none leading-[18px]\"\n            eventDurationEditable={eventResizable}\n            eventResizableFromStart={eventResizable && !isStartComputed}\n            editable={eventDraggable}\n            datesSet={onDatesChanged}\n            eventDidMount={onEventDidMount}\n            eventResize={onEventResize}\n            eventDrop={onEventDrop}\n            eventClick={(info) => setExpandRecordId(info.event.id)}\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            eventOrder={(a: any, b: any) => {\n              if (a.start < b.start) return -1;\n              if (a.start > b.start) return 1;\n              return 0;\n            }}\n            moreLinkClick={(info) => {\n              setMoreLinkDate(info.date);\n              return 'popover';\n            }}\n            moreLinkContent={() => {\n              return (\n                <Button\n                  size=\"xs\"\n                  variant=\"ghost\"\n                  className=\"h-[18px] w-full gap-1 rounded-sm text-xs font-normal text-muted-foreground\"\n                >\n                  <span className={MORE_LINK_TEXT_CLASS_NAME}>{t('notification.showMore')}</span>\n                </Button>\n              );\n            }}\n          />\n        </div>\n      </div>\n      {moreLinkDate && (\n        <Dialog\n          open={Boolean(moreLinkDate)}\n          onOpenChange={(open) => !open && setMoreLinkDate(undefined)}\n        >\n          <DialogContent\n            container={containerRef.current}\n            className=\"max-h-4/5 flex h-[520px] max-w-xl flex-col p-4\"\n            onMouseDown={(e) => e.stopPropagation()}\n            onKeyDown={(e) => e.stopPropagation()}\n          >\n            <DialogHeader className=\"px-2 py-1\">\n              <DialogTitle>{format(moreLinkDate, 'yyyy-MM-dd')}</DialogTitle>\n            </DialogHeader>\n            <EventListContainer date={moreLinkDate} />\n          </DialogContent>\n        </Dialog>\n      )}\n      <EventMenu />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/CalendarConfig.tsx",
    "content": "import type { IColorConfig } from '@teable/core';\nimport { CellValueType, ColorConfigType, Colors, FieldType } from '@teable/core';\nimport { useFields, useFieldStaticGetter, useView } from '@teable/sdk/hooks';\nimport type { CalendarView } from '@teable/sdk/model';\nimport {\n  Popover,\n  PopoverTrigger,\n  PopoverContent,\n  Select,\n  SelectItem,\n  SelectContent,\n  SelectTrigger,\n  SelectValue,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { FC, PropsWithChildren } from 'react';\nimport { Fragment, useMemo } from 'react';\nimport { ColorPicker } from '@/features/app/components/field-setting/options/SelectOptions';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useCalendarFields } from '../hooks';\n\nexport const DEFAULT_COLOR = Colors.PurpleLight2;\n\nexport const CalendarConfig: FC<PropsWithChildren> = (props) => {\n  const { children } = props;\n  const { startDateField, endDateField, titleField, colorConfig } = useCalendarFields();\n  const view = useView() as CalendarView | undefined;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const fieldStaticGetter = useFieldStaticGetter();\n\n  const { primaryField, filteredDateFields, filteredSelectFields } = useMemo(\n    () => ({\n      primaryField: fields.find((field) => field.isPrimary)!,\n      filteredDateFields: fields.filter(\n        (field) => field.cellValueType === CellValueType.DateTime && !field.isMultipleCellValue\n      ),\n      filteredSelectFields: fields.filter(\n        (field) => field.type === FieldType.SingleSelect && !field.isMultipleCellValue\n      ),\n    }),\n    [fields]\n  );\n\n  const onSelectChange = (key: string, value: string) => {\n    view?.updateOption({ [key]: value });\n  };\n\n  const onColorTypeChange = (type: ColorConfigType) => {\n    let config: IColorConfig = null;\n\n    if (type === ColorConfigType.Field) {\n      const singleSelectField = fields.find(\n        ({ type, isMultipleCellValue }) => type === FieldType.SingleSelect && !isMultipleCellValue\n      );\n      config = { type, fieldId: singleSelectField?.id };\n    } else {\n      config = { type, color: DEFAULT_COLOR };\n    }\n\n    view?.updateOption({ colorConfig: config });\n  };\n\n  const onColorChange = (value: string) => {\n    view?.updateOption({ colorConfig: { type: ColorConfigType.Custom, color: value as Colors } });\n  };\n\n  const onColorFieldIdChange = (value: string) => {\n    view?.updateOption({\n      colorConfig: { type: ColorConfigType.Field, color: null, fieldId: value },\n    });\n  };\n\n  const dateSelects = [\n    {\n      label: t('table:calendar.toolbar.startDateField'),\n      key: 'startDateFieldId',\n      value: startDateField?.id,\n    },\n    {\n      label: t('table:calendar.toolbar.endDateField'),\n      key: 'endDateFieldId',\n      value: endDateField?.id,\n    },\n  ];\n\n  const colorTypeSelects = [\n    { label: t('table:calendar.toolbar.customColor'), value: ColorConfigType.Custom },\n    { label: t('table:calendar.toolbar.alignWithRecords'), value: ColorConfigType.Field },\n  ];\n\n  const {\n    type: colorType = ColorConfigType.Custom,\n    fieldId: colorFieldId,\n    color,\n  } = colorConfig ?? {};\n\n  return (\n    <Popover modal>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"start\" className=\"flex w-[272px] flex-col gap-4 p-4\">\n        {fields.length > 0 ? (\n          <Fragment>\n            {dateSelects.map(({ label, key, value }) => (\n              <div key={key} className=\"flex flex-col gap-2\">\n                <span className=\"text-xs text-muted-foreground\">{label}</span>\n                <Select\n                  value={value ?? undefined}\n                  onValueChange={(value) => onSelectChange(key, value)}\n                >\n                  <SelectTrigger className=\"bg-background\">\n                    <SelectValue placeholder={t('sdk:editor.date.placeholder')} />\n                  </SelectTrigger>\n                  <SelectContent className=\"w-full\">\n                    {filteredDateFields.map(\n                      ({\n                        id,\n                        type,\n                        name,\n                        isLookup,\n                        isConditionalLookup,\n                        aiConfig,\n                        canReadFieldRecord,\n                      }) => {\n                        const { Icon } = fieldStaticGetter(type, {\n                          isLookup,\n                          isConditionalLookup,\n                          hasAiConfig: Boolean(aiConfig),\n                          deniedReadRecord: !canReadFieldRecord,\n                        });\n                        return (\n                          <SelectItem key={id} value={id}>\n                            <div className=\"flex flex-row items-center text-[13px]\">\n                              <Icon className=\"size-5 shrink-0 pr-1\" />\n                              {name}\n                            </div>\n                          </SelectItem>\n                        );\n                      }\n                    )}\n                  </SelectContent>\n                </Select>\n              </div>\n            ))}\n            <div className=\"flex flex-col gap-2\">\n              <span className=\"text-xs text-muted-foreground\">\n                {t('table:calendar.toolbar.titleField')}\n              </span>\n              <Select\n                value={titleField?.id ?? primaryField.id}\n                onValueChange={(value) => onSelectChange('titleFieldId', value)}\n              >\n                <SelectTrigger className=\"bg-background\">\n                  <SelectValue placeholder={t('sdk:editor.date.placeholder')} />\n                </SelectTrigger>\n                <SelectContent className=\"w-full\">\n                  {fields.map(({ id, type, name, isLookup, isConditionalLookup, aiConfig }) => {\n                    const { Icon } = fieldStaticGetter(type, {\n                      isLookup,\n                      isConditionalLookup,\n                      hasAiConfig: Boolean(aiConfig),\n                    });\n                    return (\n                      <SelectItem key={id} value={id}>\n                        <div className=\"flex flex-row items-center text-[13px]\">\n                          <Icon className=\"size-5 shrink-0 pr-1\" />\n                          {name}\n                        </div>\n                      </SelectItem>\n                    );\n                  })}\n                </SelectContent>\n              </Select>\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <span className=\"text-xs text-muted-foreground\">\n                {t('table:calendar.toolbar.colorType')}\n              </span>\n              <Select\n                value={colorType}\n                onValueChange={(value) => onColorTypeChange(value as ColorConfigType)}\n              >\n                <SelectTrigger className=\"bg-background\">\n                  <SelectValue placeholder={t('sdk:editor.date.placeholder')} />\n                </SelectTrigger>\n                <SelectContent className=\"w-full\">\n                  {colorTypeSelects.map(({ label, value }) => (\n                    <SelectItem key={value} value={value} className=\"text-sm\">\n                      {label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n            {colorType === ColorConfigType.Custom && (\n              <ColorPicker\n                color={color ?? DEFAULT_COLOR}\n                onSelect={(color) => onColorChange(color)}\n                className=\"p-0\"\n              />\n            )}\n            {colorType === ColorConfigType.Field && (\n              <div className=\"flex flex-col gap-2\">\n                <span className=\"text-xs text-muted-foreground\">\n                  {t('table:calendar.toolbar.colorField')}\n                </span>\n                <Select\n                  value={colorFieldId ?? filteredSelectFields[0]?.id}\n                  onValueChange={(value) => onColorFieldIdChange(value)}\n                >\n                  <SelectTrigger className=\"bg-background\">\n                    <SelectValue placeholder={t('table:calendar.placeholder.selectColorField')} />\n                  </SelectTrigger>\n                  <SelectContent className=\"w-full\">\n                    {filteredSelectFields.map(\n                      ({ id, type, name, isLookup, isConditionalLookup, aiConfig }) => {\n                        const { Icon } = fieldStaticGetter(type, {\n                          isLookup,\n                          isConditionalLookup,\n                          hasAiConfig: Boolean(aiConfig),\n                        });\n                        return (\n                          <SelectItem key={id} value={id}>\n                            <div className=\"flex flex-row items-center text-[13px]\">\n                              <Icon className=\"size-5 shrink-0 pr-1\" />\n                              {name}\n                            </div>\n                          </SelectItem>\n                        );\n                      }\n                    )}\n                  </SelectContent>\n                </Select>\n              </div>\n            )}\n          </Fragment>\n        ) : null}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/EventList.tsx",
    "content": "import type { IQueryBaseRo } from '@teable/openapi';\nimport { RecordItem, RecordList } from '@teable/sdk/components';\nimport { useRowCount } from '@teable/sdk/hooks';\nimport { useInfiniteRecords } from '@teable/sdk/hooks/use-infinite-records';\nimport { Skeleton } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useCalendar } from '../hooks';\nimport { getEventTitle } from '../util';\n\ninterface IEventListProps {\n  query?: IQueryBaseRo;\n}\n\nexport const EventList = (props: IEventListProps) => {\n  const { query } = props;\n  const rowCount = useRowCount();\n  const { titleField, startDateField, endDateField, setExpandRecordId } = useCalendar();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const { onVisibleRegionChanged, recordMap } = useInfiniteRecords(query);\n\n  return (\n    <RecordList\n      className=\"h-full\"\n      itemHeight={36}\n      itemClassName=\"p-0 rounded-md aria-selected:bg-transparent aria-selected:text-inherit\"\n      itemRender={(index) => {\n        const record = recordMap[index];\n\n        if (!record || !titleField || !startDateField || !endDateField) {\n          return <Skeleton className=\"h-[30px] w-full\" />;\n        }\n\n        const title = record.fields[titleField.id];\n        const start = record.fields[startDateField.id];\n        const displayTitle = getEventTitle(\n          titleField.cellValue2String(title) || t('sdk:common.unnamedRecord'),\n          start as string,\n          startDateField\n        );\n\n        return <RecordItem title={displayTitle} className=\"bg-background py-1\" />;\n      }}\n      rowCount={rowCount ?? 0}\n      onSelect={(index) => {\n        setExpandRecordId(recordMap[index]?.id);\n      }}\n      onVisibleChange={(range) => {\n        const [startIndex, endIndex] = range;\n        onVisibleRegionChanged({\n          y: startIndex,\n          height: endIndex - startIndex,\n        });\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/EventListContainer.tsx",
    "content": "import type { IFilter } from '@teable/core';\nimport { mergeFilter, and, exactDate, isOnOrBefore, isOnOrAfter, or, is } from '@teable/core';\nimport { RowCountProvider } from '@teable/sdk/context';\nimport { format } from 'date-fns';\nimport { fromZonedTime } from 'date-fns-tz';\nimport { useMemo } from 'react';\nimport { useCalendar } from '../hooks';\nimport { EventList } from './EventList';\n\ninterface IEventListContainerProps {\n  date: Date;\n}\n\nexport const EventListContainer = (props: IEventListContainerProps) => {\n  const { date } = props;\n  const { recordQuery, startDateField, endDateField } = useCalendar();\n\n  const query = useMemo(() => {\n    if (!startDateField || !endDateField) return;\n\n    const { timeZone } = startDateField.options.formatting;\n\n    const dateStr = format(date, 'yyyy-MM-dd');\n    const startDateUtc = fromZonedTime(`${dateStr} 00:00:00`, timeZone);\n    const endDateUtc = fromZonedTime(`${dateStr} 23:59:59.999`, timeZone);\n\n    const filter = mergeFilter(recordQuery?.filter, {\n      conjunction: and.value,\n      filterSet: [\n        {\n          conjunction: or.value,\n          filterSet: [\n            {\n              conjunction: and.value,\n              filterSet: [\n                {\n                  fieldId: startDateField.id,\n                  operator: isOnOrBefore.value,\n                  value: {\n                    exactDate: endDateUtc.toISOString(),\n                    mode: exactDate.value,\n                    timeZone,\n                  },\n                },\n                {\n                  fieldId: endDateField.id,\n                  operator: isOnOrAfter.value,\n                  value: {\n                    exactDate: startDateUtc.toISOString(),\n                    mode: exactDate.value,\n                    timeZone,\n                  },\n                },\n              ],\n            },\n            {\n              fieldId: startDateField.id,\n              operator: is.value,\n              value: {\n                exactDate: startDateUtc.toISOString(),\n                mode: exactDate.value,\n                timeZone,\n              },\n            },\n          ],\n        },\n      ],\n    }) as IFilter;\n\n    return {\n      ...recordQuery,\n      filter,\n      orderBy: [\n        {\n          fieldId: startDateField.id,\n          order: 'asc',\n        },\n      ],\n    };\n  }, [date, recordQuery, endDateField, startDateField]);\n\n  return (\n    <RowCountProvider query={query}>\n      <EventList query={query} />\n    </RowCountProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/components/EventMenu.tsx",
    "content": "import { Trash, Copy } from '@teable/icons';\nimport { deleteRecord, duplicateRecord } from '@teable/openapi';\nimport { useTableId, useView } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useRef } from 'react';\nimport { useClickAway } from 'react-use';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useEventMenuStore } from '../hooks';\n\nexport interface IMenuItemProps<T> {\n  type: T;\n  name: string;\n  icon: React.ReactNode;\n  hidden?: boolean;\n  className?: string;\n  onClick: () => void;\n}\n\nenum MenuItemType {\n  Delete = 'Delete',\n  Duplicate = 'Duplicate',\n}\n\nconst iconClassName = 'mr-2 h-4 w-4 shrink-0';\n\nexport const EventMenu = () => {\n  const { eventMenu, closeEventMenu } = useEventMenuStore();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const tableId = useTableId();\n  const view = useView();\n  const viewId = view?.id;\n  const recordMenuRef = useRef<HTMLDivElement>(null);\n\n  const { eventId } = eventMenu ?? {};\n\n  useClickAway(recordMenuRef, () => {\n    closeEventMenu();\n  });\n\n  if (eventMenu == null || !eventId) return null;\n\n  const { permission, position } = eventMenu;\n  const visible = Boolean(eventMenu);\n  const style = position\n    ? {\n        left: position.x,\n        top: position.y,\n      }\n    : {};\n\n  const menuItemGroups: IMenuItemProps<MenuItemType>[][] = [\n    [\n      {\n        type: MenuItemType.Duplicate,\n        name: t('sdk:expandRecord.duplicateRecord'),\n        icon: <Copy className={iconClassName} />,\n        hidden: !permission.eventCreatable,\n        onClick: async () => {\n          if (!tableId || !viewId) return;\n          await duplicateRecord(tableId, eventId, {\n            viewId,\n            position: 'after',\n            anchorId: eventId,\n          });\n        },\n      },\n    ],\n    [\n      {\n        type: MenuItemType.Delete,\n        name: t('table:menu.deleteRecord'),\n        icon: <Trash className={iconClassName} />,\n        hidden: !permission.eventDeletable,\n        className: 'text-red-500 aria-selected:text-red-500',\n        onClick: async () => {\n          if (!tableId) return;\n          await deleteRecord(tableId, eventId);\n        },\n      },\n    ],\n  ].map((items) => (items as IMenuItemProps<MenuItemType>[]).filter(({ hidden }) => !hidden));\n\n  if (menuItemGroups.every((menuItemGroup) => menuItemGroup.length === 0)) {\n    return null;\n  }\n\n  return (\n    <Popover open={visible}>\n      <PopoverTrigger asChild style={style} className=\"absolute\">\n        <div className=\"size-0 opacity-0\" />\n      </PopoverTrigger>\n      <PopoverContent className=\"h-auto w-56 rounded-md p-0\" align=\"start\">\n        <Command ref={recordMenuRef} className=\"rounded-md border-none shadow-none\" style={style}>\n          <CommandList>\n            {menuItemGroups.map((items, index) => {\n              const nextItems = menuItemGroups[index + 1] ?? [];\n              if (!items.length) return null;\n\n              return (\n                <Fragment key={index}>\n                  <CommandGroup aria-valuetext=\"name\">\n                    {items.map(({ type, name, icon, className, onClick }) => {\n                      return (\n                        <CommandItem\n                          className={cn('px-4 py-2', className)}\n                          key={type}\n                          value={name}\n                          onSelect={async () => {\n                            onClick();\n                            closeEventMenu();\n                          }}\n                        >\n                          {icon}\n                          {name}\n                        </CommandItem>\n                      );\n                    })}\n                  </CommandGroup>\n                  {nextItems.length > 0 && <CommandSeparator />}\n                </Fragment>\n              );\n            })}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/context/CalendarContext.ts",
    "content": "import type { IColorConfig } from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport type { DateField, IFieldInstance, SingleSelectField } from '@teable/sdk/model';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { createContext } from 'react';\nimport type { ICalendarPermission } from '../type';\n\nexport interface ICalendarContext {\n  recordQuery?: Pick<IGetRecordsRo, 'filter' | 'orderBy'>;\n  titleField?: IFieldInstance;\n  startDateField?: DateField;\n  endDateField?: DateField;\n  colorField?: SingleSelectField;\n  colorConfig?: IColorConfig;\n  permission: ICalendarPermission;\n  setExpandRecordId: Dispatch<SetStateAction<string | undefined>>;\n}\n\nexport const CalendarContext = createContext<ICalendarContext>(null!);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/context/CalendarProvider.tsx",
    "content": "import { ColorConfigType, FieldType } from '@teable/core';\nimport { ExpandRecorder } from '@teable/sdk/components';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport {\n  useTableId,\n  useView,\n  useFields,\n  useTablePermission,\n  usePersonalView,\n  useButtonClickStatus,\n} from '@teable/sdk/hooks';\nimport type { CalendarView } from '@teable/sdk/model';\nimport { useContext, useMemo, useState, type ReactNode } from 'react';\nimport { useCalendarFields } from '../hooks';\nimport { CalendarContext } from './CalendarContext';\n\nexport const CalendarProvider = ({ children }: { children: ReactNode }) => {\n  const tableId = useTableId();\n  const view = useView() as CalendarView | undefined;\n  const { personalViewCommonQuery } = usePersonalView();\n  const { shareId } = useContext(ShareViewContext) ?? {};\n  const { sort, filter } = view ?? {};\n  const permission = useTablePermission();\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const [expandRecordId, setExpandRecordId] = useState<string>();\n  const buttonClickStatusHook = useButtonClickStatus(tableId!, shareId);\n\n  const { startDateField, endDateField, titleField, colorConfig } = useCalendarFields();\n\n  const recordQuery = useMemo(() => {\n    const { ignoreViewQuery } = personalViewCommonQuery ?? {};\n    const baseQuery = {\n      orderBy: sort?.sortObjs,\n      filter: filter,\n    };\n\n    if (shareId) return baseQuery;\n\n    if (ignoreViewQuery) {\n      return {\n        ...baseQuery,\n        ignoreViewQuery,\n      };\n    }\n  }, [shareId, sort, filter, personalViewCommonQuery]);\n\n  const calendarPermission = useMemo(() => {\n    const startDateEditable = Boolean(startDateField && !startDateField.isComputed);\n    const endDateEditable = Boolean(endDateField && !endDateField.isComputed);\n    const isSameField = startDateField?.id === endDateField?.id;\n\n    return {\n      eventCreatable: Boolean(permission['record|create']) && startDateEditable && endDateEditable,\n      eventResizable:\n        Boolean(permission['record|update']) &&\n        (startDateEditable || endDateEditable) &&\n        !isSameField,\n      eventDeletable: Boolean(permission['record|delete']),\n      eventDraggable: Boolean(permission['record|update']) && startDateEditable && endDateEditable,\n    };\n  }, [permission, startDateField, endDateField]);\n\n  const colorField = useMemo(() => {\n    const { type: colorType, fieldId: colorFieldId } = colorConfig ?? {};\n\n    if (colorType === ColorConfigType.Field) {\n      const field = allFields.find((f) => f.id === colorFieldId);\n      if (!field || field.type !== FieldType.SingleSelect || field.isMultipleCellValue) {\n        return;\n      }\n\n      return field;\n    }\n  }, [colorConfig, allFields]);\n\n  const value = useMemo(() => {\n    return {\n      recordQuery,\n      startDateField,\n      endDateField,\n      titleField,\n      colorField,\n      colorConfig,\n      permission: calendarPermission,\n      setExpandRecordId,\n    };\n  }, [\n    recordQuery,\n    startDateField,\n    endDateField,\n    titleField,\n    colorField,\n    colorConfig,\n    calendarPermission,\n  ]);\n\n  return (\n    <CalendarContext.Provider value={value}>\n      {allFields.length > 0 && children}\n      {tableId && (\n        <ExpandRecorder\n          tableId={tableId}\n          viewId={view?.id}\n          recordId={expandRecordId}\n          recordIds={expandRecordId ? [expandRecordId] : []}\n          buttonClickStatusHook={buttonClickStatusHook}\n          onClose={() => setExpandRecordId(undefined)}\n        />\n      )}\n    </CalendarContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/context/index.ts",
    "content": "export * from './CalendarContext';\nexport * from './CalendarProvider';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/hooks/index.ts",
    "content": "export * from './useCalendar';\nexport * from './useCalendarFields';\nexport * from './useEventMenuStore';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/hooks/useCalendar.ts",
    "content": "import { useContext } from 'react';\nimport { CalendarContext } from '../context';\n\nexport const useCalendar = () => {\n  return useContext(CalendarContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/hooks/useCalendarFields.ts",
    "content": "import { CellValueType } from '@teable/core';\nimport { useFields, useView } from '@teable/sdk/hooks';\nimport type { CalendarView, DateField } from '@teable/sdk/model';\nimport { useMemo } from 'react';\n\nexport const useCalendarFields = () => {\n  const view = useView() as CalendarView | undefined;\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { startDateFieldId, endDateFieldId, titleFieldId, colorConfig } = view?.options ?? {};\n\n  const { startDateField, endDateField, titleField } = useMemo(() => {\n    const findDateField = (fieldId?: string | null) =>\n      fieldId\n        ? (allFields.find(\n            (f) =>\n              f.id === fieldId &&\n              f.cellValueType === CellValueType.DateTime &&\n              !f.isMultipleCellValue\n          ) as DateField | undefined)\n        : undefined;\n    const titleField = titleFieldId\n      ? allFields.find((f) => f.id === titleFieldId)\n      : allFields.find((f) => f.isPrimary);\n\n    const startField = findDateField(startDateFieldId);\n    const endField = findDateField(endDateFieldId);\n\n    return {\n      startDateField: startField ?? endField,\n      endDateField: endField ?? startField,\n      titleField,\n    };\n  }, [startDateFieldId, endDateFieldId, titleFieldId, allFields]);\n\n  return useMemo(\n    () => ({ startDateField, endDateField, titleField, colorConfig }),\n    [startDateField, endDateField, titleField, colorConfig]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/hooks/useEventMenuStore.ts",
    "content": "import { create } from 'zustand';\nimport type { ICalendarPermission } from '../type';\n\ninterface IEventMenu {\n  eventId: string;\n  permission: ICalendarPermission;\n  position: {\n    x: number;\n    y: number;\n  };\n}\n\ninterface IEventMenuState {\n  eventMenu?: IEventMenu;\n  openEventMenu: (props: IEventMenu) => void;\n  closeEventMenu: () => void;\n}\n\nexport const useEventMenuStore = create<IEventMenuState>((set) => ({\n  openEventMenu: (props) => {\n    set((state) => {\n      return {\n        ...state,\n        eventMenu: props,\n      };\n    });\n  },\n  closeEventMenu: () => {\n    set((state) => {\n      if (state.eventMenu == null) {\n        return state;\n      }\n      return {\n        ...state,\n        eventMenu: undefined,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/type.ts",
    "content": "export interface ICalendarPermission {\n  eventCreatable: boolean;\n  eventResizable: boolean;\n  eventDeletable: boolean;\n  eventDraggable: boolean;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/calendar/util.ts",
    "content": "import type { IColorConfig } from '@teable/core';\nimport { ColorConfigType, TimeFormatting } from '@teable/core';\nimport { getColorPairs } from '@teable/sdk/components';\nimport type { DateField, Record, SingleSelectField } from '@teable/sdk/model';\nimport { set } from 'date-fns';\nimport { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz';\nimport { DEFAULT_COLOR } from './components/CalendarConfig';\n\nexport const getColorByConfig = (\n  record: Record,\n  colorConfig: IColorConfig,\n  colorField?: SingleSelectField\n) => {\n  const { type: colorType, fieldId: colorFieldId, color } = colorConfig ?? {};\n\n  if (colorType === ColorConfigType.Field) {\n    if (colorFieldId && colorField) {\n      const colorFieldValue = record.fields[colorFieldId];\n      const { color, backgroundColor } =\n        colorField.displayChoiceMap[colorFieldValue as string] ?? {};\n      return color && backgroundColor ? { color, backgroundColor } : getColorPairs(DEFAULT_COLOR);\n    }\n    return getColorPairs(DEFAULT_COLOR);\n  }\n  return getColorPairs(color ?? DEFAULT_COLOR);\n};\n\nexport const getEventTitle = (title: string, startDate: string | null, dateField: DateField) => {\n  const { time, timeZone } = dateField.options.formatting;\n  const includeTime = time !== TimeFormatting.None;\n  const timeStr = time === TimeFormatting.Hour24 ? time : 'hh:mm a';\n  const prefixStr =\n    includeTime && startDate\n      ? `${formatInTimeZone(new Date(startDate as string), timeZone, timeStr)} `\n      : '';\n\n  return `${prefixStr}${title}`;\n};\n\nexport const getDateByTimezone = (date: Date, timeZone: string, originalDate?: string) => {\n  const originalTime = toZonedTime(\n    originalDate\n      ? new Date(originalDate)\n      : set(new Date(), { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),\n    timeZone\n  );\n  const newDate = set(date, {\n    hours: originalTime.getHours(),\n    minutes: originalTime.getMinutes(),\n    seconds: originalTime.getSeconds(),\n  });\n  return fromZonedTime(newDate, timeZone).toISOString();\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/constant.ts",
    "content": "import { ViewType } from '@teable/core';\nimport {\n  Sheet,\n  ClipboardList as Form,\n  LayoutGrid as Gallery,\n  Kanban,\n  Component,\n  Calendar,\n} from '@teable/icons';\n\nexport const VIEW_ICON_MAP = {\n  [ViewType.Grid]: Sheet,\n  [ViewType.Kanban]: Kanban,\n  [ViewType.Gallery]: Gallery,\n  [ViewType.Calendar]: Calendar,\n  [ViewType.Form]: Form,\n  [ViewType.Plugin]: Component,\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/field/FieldSetting.tsx",
    "content": "import { FieldType, fieldVoSchema, type IButtonFieldOptions, type IFieldVo } from '@teable/core';\nimport { createWorkflow } from '@teable/openapi';\nimport type { IFieldInstance } from '@teable/sdk';\nimport { useBaseId, useField, useTableId } from '@teable/sdk';\nimport { isEmpty } from 'lodash';\nimport { useWorkFlowPanelStore } from '@/features/app/automation/workflow-panel/useWorkFlowPaneStore';\nimport {\n  FieldSetting as FieldSettingInner,\n  FieldOperator,\n} from '@/features/app/components/field-setting';\nimport { useFieldSettingStore } from './useFieldSettingStore';\n\nexport const FieldSetting = () => {\n  const { setting, closeSetting } = useFieldSettingStore();\n  const field = useField(setting?.fieldId);\n  const order = setting?.order;\n  const baseId = useBaseId() as string;\n  const tableId = useTableId() as string;\n\n  const handleOpenWorkflowPanel = async (field?: IFieldVo | IFieldInstance) => {\n    const { from = '', openModal } = useWorkFlowPanelStore.getState();\n    if (from === 'buttonFieldOptions' && field && field.type === FieldType.Button) {\n      const options = field.options as IButtonFieldOptions;\n      const workflow = options.workflow ?? {};\n      let workflowId = workflow.id ?? '';\n      if (isEmpty(workflowId)) {\n        const result = await createWorkflow(baseId, {\n          name: field.name,\n          trigger: {\n            type: 'buttonClick', // WorkflowTriggerType.ButtonClick\n            config: {\n              tableId,\n              watchFieldIds: [field.id],\n            },\n          },\n        });\n        const workflow = result.data as { id: string };\n        workflowId = workflow.id;\n      }\n      openModal(baseId, workflowId);\n    }\n  };\n\n  const onCancel = () => {\n    closeSetting();\n    handleOpenWorkflowPanel(field);\n  };\n\n  const onConfirm = (fieldVo?: IFieldVo) => {\n    closeSetting();\n    handleOpenWorkflowPanel(fieldVo);\n  };\n\n  const visible = Boolean(setting);\n  if (!visible) {\n    return <></>;\n  }\n\n  const fieldVo = fieldVoSchema.safeParse(field);\n  if (!fieldVo.success) {\n    console.log('errorField:', field);\n    console.error(fieldVo.error);\n  }\n\n  return (\n    <FieldSettingInner\n      visible={visible}\n      field={fieldVo.success ? fieldVo.data : undefined}\n      order={order}\n      operator={setting?.operator || FieldOperator.Add}\n      onCancel={onCancel}\n      onConfirm={onConfirm}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/field/useFieldSettingStore.ts",
    "content": "import { create } from 'zustand';\nimport type { FieldOperator } from '@/features/app/components/field-setting';\n\nexport interface IFieldSetting {\n  operator: FieldOperator;\n  fieldId?: string;\n  order?: number;\n}\n\ninterface IGridViewState {\n  setting?: IFieldSetting;\n  openSetting: (props: IFieldSetting) => void;\n  closeSetting: () => void;\n}\n\nexport const useFieldSettingStore = create<IGridViewState>((set) => ({\n  openSetting: (props) => {\n    set((state) => {\n      return {\n        ...state,\n        setting: props,\n      };\n    });\n  },\n  closeSetting: () => {\n    set((state) => {\n      if (state.setting == undefined) {\n        return state;\n      }\n      return {\n        ...state,\n        setting: undefined,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/FormView.tsx",
    "content": "import { useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { FormToolBar } from '../tool-bar/FormToolBar';\nimport { FormViewBase } from './FormViewBase';\n\nexport const FormView = () => {\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  return (\n    <>\n      {!isReadOnlyPreview && <FormToolBar />}\n      <div className=\"w-full grow overflow-hidden\">\n        <FormViewBase />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/FormViewBase.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { formSubmit } from '@teable/openapi';\nimport { useViewId, useTableId, useIsMobile, useTablePermission } from '@teable/sdk/hooks';\nimport { FormMode, useFormModeStore } from '../tool-bar/store';\nimport { FormEditor, FormPreviewer } from './components';\nimport { generateUniqLocalKey } from './util';\n\nexport const FormViewBase = () => {\n  const tableId = useTableId();\n  const activeViewId = useViewId();\n  const { modeMap } = useFormModeStore();\n  const isMobile = useIsMobile();\n  const permission = useTablePermission();\n\n  const { mutateAsync: createRecords } = useMutation({\n    mutationFn: (fields: Record<string, unknown>) =>\n      formSubmit(tableId!, {\n        viewId: activeViewId!,\n        fields,\n      }),\n  });\n\n  const modeKey = generateUniqLocalKey(tableId, activeViewId);\n  const mode = modeMap[modeKey] ?? FormMode.Edit;\n  const isEditMode = permission['view|update'] && mode === FormMode.Edit;\n\n  const submitForm = async (fields: Record<string, unknown>) => {\n    if (!tableId || !activeViewId) return;\n    await createRecords(fields);\n  };\n\n  return (\n    <div className=\"flex size-full\">\n      {isEditMode && !isMobile ? <FormEditor /> : <FormPreviewer submit={submitForm} />}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/BrandFooter.tsx",
    "content": "import Link from 'next/link';\nimport { Trans } from 'next-i18next';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\n\nexport const BrandFooter = () => {\n  const { brandName } = useBrand();\n\n  return (\n    <div className=\"flex w-full items-center justify-center\">\n      <span className=\"h-px w-16 bg-border\" />\n      <div className=\"mx-4 flex items-center gap-2 text-xs text-muted-foreground\">\n        {brandName.toLowerCase() === 'teable' ? (\n          <Trans\n            ns=\"common\"\n            i18nKey=\"poweredBy\"\n            components={[\n              <Link\n                key={'brandFooter'}\n                href=\"/\"\n                target=\"_blank\"\n                className=\"flex items-center text-sm text-black dark:text-white\"\n              >\n                <TeableLogo className=\"text-xl\" />\n                <span className=\"ml-1 font-semibold\">{brandName}</span>\n              </Link>,\n            ]}\n          />\n        ) : (\n          <Link href=\"/\" target=\"_blank\" className=\"flex items-center\">\n            <TeableLogo className=\"text-xl\" />\n            <span className=\"ml-1 font-semibold\">{brandName}</span>\n          </Link>\n        )}\n      </div>\n      <span className=\"h-px w-16 bg-border\" />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/Drag.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport type { UniqueIdentifier } from '@dnd-kit/core';\nimport { useDraggable } from '@dnd-kit/core';\nimport { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\n\nexport const DraggableItem = (props: {\n  id: string;\n  field: IFieldInstance;\n  children: React.ReactElement;\n  className?: string;\n  draggingClassName?: string;\n}) => {\n  const { id, field, children, className, draggingClassName } = props;\n  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n    id,\n    data: {\n      field,\n      fromSidebar: true,\n    },\n  });\n\n  return (\n    <div\n      ref={setNodeRef}\n      {...attributes}\n      {...listeners}\n      className={cn(\n        'group relative overflow-y-auto',\n        className,\n        isDragging ? draggingClassName : null\n      )}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport const DroppableContainer = ({\n  id,\n  items,\n  children,\n  style,\n  ...props\n}: {\n  id: UniqueIdentifier;\n  items: { id: UniqueIdentifier }[];\n  children: React.ReactElement;\n  style?: React.CSSProperties;\n}) => {\n  const { attributes, isDragging, listeners, setNodeRef, transition, transform } = useSortable({\n    id,\n    data: {\n      parent: null,\n      isContainer: true,\n    },\n  });\n\n  return (\n    <div\n      ref={setNodeRef}\n      {...attributes}\n      {...listeners}\n      style={{\n        ...style,\n        transition,\n        transform: CSS.Translate.toString(transform),\n        opacity: isDragging ? 0.5 : undefined,\n        minHeight: 50,\n      }}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormCellEditor.tsx",
    "content": "import type {\n  IAttachmentCellValue,\n  ILinkCellValue,\n  ILinkFieldOptions,\n  IUserCellValue,\n} from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { AttachmentManager, CellEditor, LinkDisplayType, LinkEditor } from '@teable/sdk/components';\nimport { UploadAttachment } from '@teable/sdk/components/editor/attachment/upload-attachment/UploadAttachment';\nimport type { Field, LinkField, UserField } from '@teable/sdk/model';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { ShareFormLinkEditor } from './share-link-editor/FormLinkEditor';\nimport { ShareUserEditor } from './ShareUserEditor';\n\ninterface IFormCellEditor {\n  className?: string;\n  cellValue?: unknown;\n  field: Field;\n  onChange?: (cellValue?: unknown) => void;\n}\n\nconst attachmentManager = new AttachmentManager(2);\n\nexport const FormCellEditor = (props: IFormCellEditor) => {\n  const { cellValue, field, className, onChange } = props;\n  const router = useRouter();\n  const shareId = router.query.shareId;\n  const { id, type, options } = field;\n\n  if (shareId) {\n    switch (type) {\n      case FieldType.Link:\n        return (\n          <ShareFormLinkEditor\n            shareId={shareId as string}\n            cellValue={cellValue as ILinkCellValue | ILinkCellValue[] | undefined}\n            field={field as LinkField}\n            onChange={onChange}\n            className={className}\n          />\n        );\n      case FieldType.Attachment:\n        attachmentManager.shareId = shareId as string;\n        return (\n          <UploadAttachment\n            mode=\"local\"\n            className={cn('max-h-64', className)}\n            attachments={(cellValue ?? []) as IAttachmentCellValue}\n            onChange={onChange}\n            attachmentManager={attachmentManager}\n          />\n        );\n      case FieldType.User:\n        return (\n          <ShareUserEditor\n            shareId={shareId as string}\n            cellValue={cellValue as IUserCellValue | IUserCellValue[]}\n            field={field as UserField}\n            onChange={onChange}\n            className={className}\n          />\n        );\n      default:\n        return (\n          <CellEditor\n            cellValue={cellValue}\n            field={field}\n            onChange={onChange}\n            className={className}\n          />\n        );\n    }\n  }\n\n  if (type === FieldType.Link) {\n    return (\n      <LinkEditor\n        className={className}\n        cellValue={cellValue as ILinkCellValue | ILinkCellValue[]}\n        options={options as ILinkFieldOptions}\n        onChange={onChange}\n        fieldId={id}\n        displayType={LinkDisplayType.List}\n      />\n    );\n  }\n\n  return (\n    <CellEditor cellValue={cellValue} field={field} onChange={onChange} className={className} />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormEditor.tsx",
    "content": "import type {\n  DragEndEvent,\n  DragOverEvent,\n  DragStartEvent,\n  DropAnimation,\n  UniqueIdentifier,\n} from '@dnd-kit/core';\nimport {\n  DndContext,\n  DragOverlay,\n  PointerSensor,\n  defaultDropAnimationSideEffects,\n  useSensor,\n  useSensors,\n} from '@dnd-kit/core';\nimport {\n  useView,\n  useFieldStaticGetter,\n  useFields,\n  useIsHydrated,\n  swapReorder,\n  reorder,\n} from '@teable/sdk';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { useEffect, useMemo, useState } from 'react';\nimport { FieldSetting } from '../../grid/components';\nimport { FORM_SIDEBAR_DROPPABLE_ID } from '../constant';\nimport { isProtectedField } from '../util';\nimport { FormEditorMain } from './FormEditorMain';\nimport { FormFieldEditor } from './FormFieldEditor';\nimport { DragItem, FormSidebar } from './FormSidebar';\n\nconst dropAnimation: DropAnimation = {\n  sideEffects: defaultDropAnimationSideEffects({\n    styles: {\n      active: {\n        opacity: '0.5',\n      },\n    },\n  }),\n};\n\nexport const FormEditor = () => {\n  const view = useView();\n  const isHydrated = useIsHydrated();\n  const visibleFields = useFields();\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const getFieldStatic = useFieldStaticGetter();\n  const [innerVisibleFields, setInnerVisibleFields] = useState([...visibleFields]);\n  const [activeField, setActiveField] = useState<IFieldInstance | null>(null);\n  const [activeSidebarField, setActiveSidebarField] = useState<IFieldInstance | null>(null);\n  const [sidebarAdditionalFieldId, setSidebarAdditionalFieldId] = useState<string | null>(null);\n  const [additionalFieldData, setAdditionalFieldData] = useState<{\n    field: IFieldInstance;\n    index: number;\n  } | null>(null);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    })\n  );\n\n  useEffect(() => {\n    setInnerVisibleFields(visibleFields);\n  }, [visibleFields]);\n\n  const renderFields = useMemo(() => {\n    const fields = [\n      ...innerVisibleFields.filter(({ isComputed, isLookup }) => !isComputed && !isLookup),\n    ];\n    if (additionalFieldData) {\n      const { field, index } = additionalFieldData;\n      fields.splice(index, 0, field);\n    }\n    return fields;\n  }, [additionalFieldData, innerVisibleFields]);\n\n  const onClean = () => {\n    setActiveField(null);\n    setActiveSidebarField(null);\n    setAdditionalFieldData(null);\n    setSidebarAdditionalFieldId(null);\n  };\n\n  const onDragStart = (event: DragStartEvent) => {\n    const { active } = event;\n    const activeData = active.data?.current || {};\n\n    if (activeData?.fromSidebar) {\n      const { field } = activeData;\n      setActiveSidebarField(field);\n      return;\n    }\n\n    const { field } = activeData;\n    setActiveField(field);\n  };\n\n  const onDragOver = (event: DragOverEvent) => {\n    const { over, active } = event;\n    const activeData = active.data?.current || {};\n    const overData = over?.data?.current || {};\n    const overId: UniqueIdentifier | undefined = over?.id;\n    const { fromSidebar, field } = activeData;\n    const { index, isContainer } = overData;\n\n    if (fromSidebar && (index != null || isContainer) && !sidebarAdditionalFieldId) {\n      setAdditionalFieldData({ field, index: index ?? 0 });\n    }\n\n    if (activeField && overId === FORM_SIDEBAR_DROPPABLE_ID && !additionalFieldData) {\n      const isProtected = isProtectedField(activeField);\n      if (!isProtected) {\n        const sourceDragId = activeField.id;\n        setSidebarAdditionalFieldId(sourceDragId);\n      }\n    }\n  };\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const onDragEnd = async (event: DragEndEvent) => {\n    const { over } = event;\n    const overId: UniqueIdentifier | undefined = over?.id;\n    const overData = over?.data?.current || {};\n\n    const { index: targetIndex, isContainer } = overData;\n\n    if (!view) {\n      return;\n    }\n\n    onClean();\n\n    if (activeSidebarField && (targetIndex != null || isContainer)) {\n      const newFields = [...innerVisibleFields];\n      const sourceDragId = activeSidebarField.id;\n      const sourceIndex = allFields.findIndex((f) => f.id === sourceDragId);\n      const draggingField = allFields[sourceIndex];\n\n      if (draggingField == null) return;\n\n      newFields.splice(targetIndex, 0, draggingField);\n      setInnerVisibleFields(newFields);\n\n      await view.updateColumnMeta([\n        {\n          fieldId: draggingField.id,\n          columnMeta: {\n            visible: true,\n          },\n        },\n      ]);\n\n      if (!visibleFields.length) return;\n\n      const finalIndex = targetIndex ?? 0;\n      const newOrders = reorder(1, finalIndex, visibleFields.length, (index) => {\n        const fieldId = visibleFields[index].id;\n        return view?.columnMeta[fieldId].order;\n      });\n      await view.updateColumnMeta([\n        {\n          fieldId: draggingField.id,\n          columnMeta: {\n            order: newOrders[0],\n          },\n        },\n      ]);\n    }\n\n    if (activeField && targetIndex != null) {\n      const newFields = [...innerVisibleFields];\n      const sourceDragId = activeField.id;\n      const sourceIndex = visibleFields.findIndex((f) => f.id === sourceDragId);\n\n      if (sourceIndex === targetIndex) return;\n\n      const [moveField] = newFields.splice(sourceIndex, 1);\n      const newOrders = swapReorder(\n        1,\n        sourceIndex,\n        targetIndex ?? 0,\n        visibleFields.length,\n        (index: number) => {\n          const fieldId = visibleFields[index].id;\n          return view?.columnMeta[fieldId].order;\n        }\n      );\n\n      newFields.splice(targetIndex, 0, moveField);\n\n      setInnerVisibleFields(newFields);\n\n      await view?.updateColumnMeta([\n        {\n          fieldId: sourceDragId,\n          columnMeta: {\n            order: newOrders[0],\n          },\n        },\n      ]);\n    }\n\n    if (activeField && overId === FORM_SIDEBAR_DROPPABLE_ID) {\n      const isProtected = isProtectedField(activeField);\n      if (!isProtected) {\n        const sourceDragId = activeField.id;\n        await view?.updateColumnMeta([\n          {\n            fieldId: sourceDragId,\n            columnMeta: {\n              visible: false,\n            },\n          },\n        ]);\n      }\n    }\n  };\n\n  return (\n    <>\n      {isHydrated && (\n        <DndContext\n          onDragStart={onDragStart}\n          onDragOver={onDragOver}\n          onDragEnd={onDragEnd}\n          sensors={sensors}\n          autoScroll\n        >\n          <FormSidebar sidebarAdditionalFieldId={sidebarAdditionalFieldId} />\n          <FormEditorMain fields={renderFields} />\n          <FieldSetting />\n          <DragOverlay adjustScale={false} dropAnimation={dropAnimation}>\n            {activeSidebarField ? (\n              <DragItem field={activeSidebarField} getFieldStatic={getFieldStatic} />\n            ) : null}\n            {activeField ? (\n              <div className=\"w-full overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800\">\n                <FormFieldEditor field={activeField} />\n              </div>\n            ) : null}\n          </DragOverlay>\n        </DndContext>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormEditorMain.tsx",
    "content": "import { useDroppable } from '@dnd-kit/core';\nimport { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';\nimport { generateAttachmentId } from '@teable/core';\nimport { Pencil, Plus, Undo2 } from '@teable/icons';\nimport type { INotifyVo } from '@teable/openapi';\nimport { UploadType } from '@teable/openapi';\nimport type { IFile } from '@teable/sdk/components';\nimport { AttachmentManager } from '@teable/sdk/components';\nimport { useIsHydrated, useView } from '@teable/sdk/hooks';\nimport type { FormView, IFieldInstance } from '@teable/sdk/model';\nimport {\n  Button,\n  Input,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Textarea,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef, useState } from 'react';\nimport { FieldOperator } from '@/features/app/components/field-setting';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useFieldSettingStore } from '../../field/useFieldSettingStore';\nimport { FORM_EDITOR_DROPPABLE_ID } from '../constant';\nimport { DroppableContainer } from './Drag';\nimport { FormFieldEditor } from './FormFieldEditor';\nimport { SortableItem } from './SortableItem';\n\nconst attachmentManager = new AttachmentManager(2);\n\nexport const FormEditorMain = (props: { fields: IFieldInstance[] }) => {\n  const { fields } = props;\n  const view = useView() as FormView | undefined;\n  const isHydrated = useIsHydrated();\n  const { openSetting } = useFieldSettingStore();\n\n  const coverInput = useRef<HTMLInputElement>(null);\n  const logoInput = useRef<HTMLInputElement>(null);\n  const viewRef = useRef(view);\n  viewRef.current = view;\n\n  const [name, setName] = useState(view?.name ?? '');\n  const [isNameEditing, setNameEditing] = useState(false);\n  const [description, setDescription] = useState(view?.description ?? '');\n  const [coverUrl, setCoverUrl] = useState(view?.options?.coverUrl ?? '');\n  const [logoUrl, setLogoUrl] = useState(view?.options?.logoUrl ?? '');\n  const [submitLabel, setSubmitLabel] = useState(view?.options?.submitLabel);\n\n  const { setNodeRef } = useDroppable({ id: FORM_EDITOR_DROPPABLE_ID });\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const previewUrl = usePreviewUrl();\n\n  useEffect(() => {\n    if (viewRef.current == null) return;\n    const { name = '', description = '', options } = viewRef.current;\n    const { coverUrl = '', logoUrl = '', submitLabel } = options ?? {};\n    setName(name);\n    setNameEditing(false);\n    setDescription(description);\n    setCoverUrl(coverUrl);\n    setLogoUrl(logoUrl);\n    setSubmitLabel(submitLabel);\n  }, [view?.id]);\n\n  if (view == null) return null;\n\n  const onNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value;\n    setName(value);\n  };\n\n  const onNameInputBlur = async () => {\n    if (name === view.name) return setNameEditing(false);\n    if (!name) {\n      return setName(view.name);\n    }\n    await view.updateName(name);\n    setNameEditing(false);\n  };\n\n  const onDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const value = e.target.value;\n    setDescription(value);\n  };\n\n  const onDescriptionBlur = async () => {\n    if (description === view.description) return;\n    await view.updateDescription(description);\n  };\n\n  const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'cover') => {\n    const fileList = e.target.files;\n\n    if (fileList == null) return;\n\n    const isCover = type === 'cover';\n    const files = Array.from(fileList);\n    const uploadItem = { instance: files[0], id: generateAttachmentId() };\n    attachmentManager.upload([uploadItem], UploadType.Form, {\n      successCallback: (_file: IFile, attachment: INotifyVo) => {\n        const { path } = attachment;\n        const optionProp = isCover ? 'coverUrl' : 'logoUrl';\n        isCover ? setCoverUrl(path) : setLogoUrl(path);\n        view.updateOption({ [optionProp]: path });\n      },\n    });\n    e.target.value = '';\n  };\n\n  const onCoverReset = async () => {\n    setCoverUrl('');\n    await view.updateOption({ coverUrl: '' });\n  };\n\n  const onLogoReset = async () => {\n    setLogoUrl('');\n    await view.updateOption({ logoUrl: '' });\n  };\n\n  const onSubmitTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value;\n    setSubmitLabel(value);\n  };\n\n  const onSubmitTextInputBlur = async () => {\n    if (submitLabel === view.options?.submitLabel) return;\n    if (!submitLabel) {\n      await view.updateOption({ submitLabel: t('common:actions.submit') });\n      return setSubmitLabel(t('common:actions.submit'));\n    }\n    await view.updateOption({ submitLabel });\n  };\n\n  const onNameClick = () => {\n    if (!isNameEditing) {\n      setNameEditing(true);\n    }\n  };\n\n  return (\n    <div className=\"w-full overflow-y-auto bg-muted sm:py-8\">\n      <div className=\"relative mx-auto flex w-full max-w-screen-sm flex-col items-center overflow-hidden border bg-background pb-12 shadow-md sm:rounded-lg\">\n        <div\n          className={cn(\n            'relative h-36 w-full',\n            !coverUrl &&\n              'bg-gradient-to-tr from-green-400 via-blue-400 to-blue-600 dark:from-green-600 dark:via-blue-600 dark:to-blue-900'\n          )}\n        >\n          {coverUrl && (\n            <img\n              src={previewUrl(coverUrl)}\n              alt=\"card cover\"\n              className=\"absolute inset-0 size-full object-cover\"\n            />\n          )}\n          <Button\n            variant={'ghost'}\n            size={'icon'}\n            className={cn(\n              'absolute right-2 top-2 m-1 bg-accent font-normal',\n              coverUrl && 'right-12'\n            )}\n            onClick={() => coverInput.current?.click()}\n          >\n            <input\n              type=\"file\"\n              className=\"hidden\"\n              ref={coverInput}\n              accept=\"image/*\"\n              onChange={(e) => onFileSelected(e, 'cover')}\n            />\n            <Pencil className=\"size-4 shrink-0\" />\n          </Button>\n          {coverUrl && (\n            <Button\n              variant={'ghost'}\n              size={'icon'}\n              className=\"absolute right-2 top-2 m-1 bg-accent font-normal\"\n              onClick={onCoverReset}\n            >\n              <Undo2 className=\"size-4 shrink-0\" />\n            </Button>\n          )}\n        </div>\n\n        <div className=\"group absolute left-1/2 top-[104px] ml-[-40px] size-20 rounded-lg bg-muted\">\n          {logoUrl ? (\n            <>\n              <img\n                className=\"absolute inset-0 size-full rounded-lg object-cover shadow-sm\"\n                src={previewUrl(logoUrl)}\n                alt=\"card cover\"\n              />\n              <Button\n                variant={'ghost'}\n                size={'icon'}\n                className=\"absolute left-0 top-0 size-full font-normal opacity-0 group-hover:opacity-100 hover:bg-black/50\"\n                onClick={() => logoInput.current?.click()}\n              >\n                <Pencil className=\"size-6\" />\n              </Button>\n              <Button\n                variant={'ghost'}\n                size={'xs'}\n                className=\"absolute -right-1 -top-1 size-6 bg-accent font-normal opacity-0 group-hover:opacity-100 \"\n                onClick={onLogoReset}\n              >\n                <Undo2 className=\"size-3\" />\n              </Button>\n            </>\n          ) : (\n            <Button\n              variant={'outline'}\n              size={'icon'}\n              className=\"size-full rounded-lg font-normal\"\n              onClick={() => logoInput.current?.click()}\n            >\n              <Plus className=\"size-8\" />\n            </Button>\n          )}\n          <input\n            type=\"file\"\n            className=\"hidden\"\n            ref={logoInput}\n            accept=\"image/*\"\n            onChange={(e) => onFileSelected(e, 'logo')}\n          />\n        </div>\n\n        {isNameEditing ? (\n          <Input\n            className=\"mb-6 mt-16 w-2/3 text-center text-3xl\"\n            value={name}\n            // eslint-disable-next-line jsx-a11y/no-autofocus\n            autoFocus\n            onChange={onNameChange}\n            onBlur={onNameInputBlur}\n          />\n        ) : (\n          <div\n            className=\"mb-6 mt-16 w-full px-6 text-center text-3xl sm:px-12\"\n            style={{ overflowWrap: 'break-word' }}\n            tabIndex={0}\n            role={'button'}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                onNameClick();\n              }\n            }}\n            onClick={onNameClick}\n          >\n            {name ?? t('untitled')}\n          </div>\n        )}\n\n        <div className=\"mb-4 w-full px-12\">\n          <Textarea\n            className=\"min-h-[80px] w-full resize-none\"\n            value={description}\n            placeholder={t('table:form.descriptionPlaceholder')}\n            onChange={onDescriptionChange}\n            onBlur={onDescriptionBlur}\n          />\n        </div>\n\n        <div className=\"w-full px-4\">\n          {isHydrated && (\n            <DroppableContainer id={FORM_EDITOR_DROPPABLE_ID} items={fields}>\n              <SortableContext items={fields} strategy={verticalListSortingStrategy}>\n                {!fields.length && (\n                  <div className=\"flex h-20 w-full items-center justify-center rounded border border-dashed text-sm text-slate-400 dark:text-slate-600\">\n                    {t('table:form.dragToFormTip')}\n                  </div>\n                )}\n                <div ref={setNodeRef}>\n                  {fields.map((field, index) => {\n                    const { id } = field;\n                    return (\n                      <SortableItem\n                        key={id}\n                        id={id}\n                        index={index}\n                        field={field}\n                        className=\"w-full overflow-hidden rounded-md hover:bg-accent\"\n                        draggingClassName=\"bg-slate-100 dark:bg-slate-800 border border-black border-dashed opacity-50\"\n                        onClick={() => openSetting({ operator: FieldOperator.Edit, fieldId: id })}\n                      >\n                        <FormFieldEditor field={field} />\n                      </SortableItem>\n                    );\n                  })}\n                </div>\n              </SortableContext>\n            </DroppableContainer>\n          )}\n        </div>\n\n        <div className=\"mb-12 mt-8 flex w-full items-center justify-center sm:mb-0 sm:px-12\">\n          <Button className=\"mr-2 w-full text-base sm:w-56\" size={'lg'}>\n            {submitLabel ?? t('common:actions.submit')}\n          </Button>\n          <Popover>\n            <PopoverTrigger asChild>\n              <Button variant={'ghost'} size={'icon'} className=\"font-normal\">\n                <Pencil className=\"size-4 shrink-0\" />\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-auto p-0\" align=\"start\" side=\"right\">\n              <Input\n                maxLength={12}\n                value={submitLabel}\n                onChange={onSubmitTextChange}\n                onBlur={onSubmitTextInputBlur}\n              />\n            </PopoverContent>\n          </Popover>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormField.tsx",
    "content": "import { useFieldStaticGetter, useView } from '@teable/sdk/hooks';\nimport type { FormView, IFieldInstance } from '@teable/sdk/model';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { isProtectedField } from '../util';\nimport { FormCellEditor } from './FormCellEditor';\n\ninterface IFormFieldEditorProps {\n  field: IFieldInstance;\n  value: unknown;\n  errors: Set<string>;\n  onChange: (value: unknown) => void;\n}\n\nexport const FormField: FC<IFormFieldEditorProps> = (props) => {\n  const { field, value, errors, onChange } = props;\n  const view = useView() as FormView | undefined;\n  const activeViewId = view?.id;\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  if (!activeViewId || !view) return null;\n\n  const { id: fieldId, type, name, description, isLookup, aiConfig } = field;\n  const Icon = getFieldStatic(type, {\n    isLookup,\n    isConditionalLookup: field.isConditionalLookup,\n    hasAiConfig: Boolean(aiConfig),\n  }).Icon;\n\n  const isProtected = isProtectedField(field);\n  const required = isProtected || view?.columnMeta[fieldId]?.required;\n  const isError = errors.has(fieldId);\n\n  return (\n    <div className=\"relative w-full py-5\" id={`form-field-${fieldId}`}>\n      <div className=\"mb-2 flex w-full overflow-hidden\">\n        <div className=\"flex h-6 shrink-0 items-center\">\n          <Icon className=\"size-4 shrink-0\" />\n        </div>\n        <h3 className=\"ml-1\">{name}</h3>\n      </div>\n\n      {description && (\n        <div className=\"mb-2 whitespace-pre-line text-xs text-slate-400\">{description}</div>\n      )}\n\n      <FormCellEditor\n        cellValue={value}\n        field={field}\n        onChange={onChange}\n        className={isError ? 'border-red-500 focus-visible:ring-transparent' : ''}\n      />\n\n      {isError && <div className=\"mt-1 text-xs text-red-500\">{t('required')}</div>}\n\n      {required && <span className=\"absolute left-[-10px] top-5 text-red-500\">*</span>}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormFieldEditor.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport { DraggableHandle, EyeOff } from '@teable/icons';\nimport { CellEditor } from '@teable/sdk/components';\nimport { useFieldStaticGetter, useTableId, useView } from '@teable/sdk/hooks';\nimport type { FormView, IFieldInstance } from '@teable/sdk/model';\nimport {\n  Label,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { isProtectedField } from '../util';\n\ninterface IFormFieldEditorProps {\n  field: IFieldInstance;\n}\n\nexport const FormFieldEditor: FC<IFormFieldEditorProps> = (props) => {\n  const { field } = props;\n  const view = useView() as FormView | undefined;\n  const tableId = useTableId();\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  if (!view || !tableId) return null;\n\n  const { type, name, description, isComputed, isLookup, id: fieldId, aiConfig } = field;\n  const isProtected = isProtectedField(field);\n  const required = isProtected || view.columnMeta[fieldId]?.required;\n  const Icon = getFieldStatic(type, {\n    isLookup,\n    isConditionalLookup: field.isConditionalLookup,\n    hasAiConfig: Boolean(aiConfig),\n  }).Icon;\n\n  const onHidden = (event: React.MouseEvent<SVGSVGElement, MouseEvent>) => {\n    event.stopPropagation();\n    view.updateColumnMeta([\n      {\n        fieldId: fieldId,\n        columnMeta: {\n          visible: false,\n        },\n      },\n    ]);\n  };\n\n  const onRequiredChange = (checked: boolean) => {\n    view.updateColumnMeta([\n      {\n        fieldId: fieldId,\n        columnMeta: {\n          required: checked,\n        },\n      },\n    ]);\n  };\n\n  return (\n    <div className=\"relative w-full px-8 py-5\">\n      <div className=\"mb-2 flex w-full items-center justify-between\">\n        <div className=\"flex overflow-hidden\">\n          <div className=\"flex h-6 shrink-0 items-center\">\n            <Icon className=\"size-4 shrink-0\" />\n          </div>\n          <h3 className=\"mx-1\">{name}</h3>\n        </div>\n        <div className=\"flex items-center\">\n          {!isComputed && (\n            <div className=\"flex shrink-0 items-center\" onClick={(e) => e.stopPropagation()}>\n              <Label htmlFor=\"form-field-required\">{t('required')}</Label>\n              {isProtected ? (\n                <TooltipProvider>\n                  <Tooltip delayDuration={200}>\n                    <TooltipTrigger asChild>\n                      <span className=\"flex items-center\">\n                        <Switch\n                          id=\"form-field-required\"\n                          className=\"ml-1 mr-2 cursor-not-allowed\"\n                          checked={required}\n                          disabled={isProtected}\n                        />\n                        <EyeOff className=\"size-6 cursor-not-allowed rounded p-1 opacity-50\" />\n                      </span>\n                    </TooltipTrigger>\n                    <TooltipContent sideOffset={8} className=\"max-w-xs\">\n                      {t('table:form.protectedFieldTip')}\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              ) : (\n                <Switch\n                  id=\"form-field-required\"\n                  className=\"ml-1 mr-2\"\n                  checked={required}\n                  onCheckedChange={onRequiredChange}\n                />\n              )}\n            </div>\n          )}\n          {!isProtected && (\n            <TooltipProvider>\n              <Tooltip delayDuration={200}>\n                <TooltipTrigger asChild>\n                  <span>\n                    <EyeOff\n                      className=\"size-6 cursor-pointer rounded p-1 hover:bg-accent\"\n                      onClick={onHidden}\n                    />\n                  </span>\n                </TooltipTrigger>\n                <TooltipContent sideOffset={8}>{t('table:form.removeFromFormTip')}</TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          )}\n        </div>\n      </div>\n      {description && (\n        <div className=\"mb-2 whitespace-pre-line text-xs text-slate-400\">{description}</div>\n      )}\n      <CellEditor field={field} wrapClassName=\"pointer-events-none\" />\n      {required && <span className=\"absolute left-[22px] top-5 text-red-500\">*</span>}\n      <DraggableHandle className=\"absolute left-1 top-6\" />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormPreviewer.tsx",
    "content": "import { useRef } from 'react';\nimport { BrandFooter } from './BrandFooter';\nimport { FormBody } from './FromBody';\n\ninterface IFormPreviewerProps {\n  submit?: (fields: Record<string, unknown>) => Promise<void>;\n}\n\nexport const FormPreviewer = (props: IFormPreviewerProps) => {\n  const { submit } = props;\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const onSubmit = async (formData: Record<string, unknown>) => {\n    await submit?.(formData);\n    setTimeout(() => {\n      containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });\n    }, 1000);\n  };\n\n  return (\n    <div className=\"w-full overflow-y-auto bg-muted pb-8 sm:pt-8\" ref={containerRef}>\n      <FormBody\n        className=\"sm:shadow-mdw-full relative mx-auto mb-12 flex max-w-screen-sm flex-col items-center overflow-hidden bg-background sm:rounded-lg sm:border sm:pb-12\"\n        submit={submit ? (formData) => onSubmit(formData) : undefined}\n      />\n      <BrandFooter />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FormSidebar.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport { useDroppable } from '@dnd-kit/core';\nimport { FieldType } from '@teable/core';\nimport { DraggableHandle, Plus } from '@teable/icons';\nimport { useView } from '@teable/sdk';\nimport type { IFieldStatic } from '@teable/sdk/hooks';\nimport { useFieldStaticGetter, useFields, useIsHydrated } from '@teable/sdk/hooks';\nimport type { FormView, IFieldInstance } from '@teable/sdk/model';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { useMemo } from 'react';\nimport { FORM_SIDEBAR_DROPPABLE_ID } from '@/features/app/blocks/view/form/constant';\nimport { FieldOperator } from '@/features/app/components/field-setting';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useFieldSettingStore } from '../../field/useFieldSettingStore';\nimport { DraggableItem } from './Drag';\n\ninterface IDragItemProps {\n  field: IFieldInstance;\n  disabled?: boolean;\n  onClick?: () => void;\n  getFieldStatic: (\n    type: FieldType,\n    config: {\n      isLookup: boolean | undefined;\n      isConditionalLookup?: boolean;\n      hasAiConfig: boolean | undefined;\n      deniedReadRecord?: boolean;\n    }\n  ) => IFieldStatic;\n}\n\nexport const DragItem: FC<IDragItemProps> = (props) => {\n  const { field, disabled, onClick, getFieldStatic } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { type, name, isLookup, aiConfig } = field;\n  const Icon = getFieldStatic(type, {\n    isLookup,\n    isConditionalLookup: field.isConditionalLookup,\n    hasAiConfig: Boolean(aiConfig),\n  }).Icon;\n  const content = (\n    <div\n      className={cn(\n        'mb-[6px] flex items-center justify-between rounded-md bg-secondary border p-2 ',\n        disabled && 'cursor-not-allowed text-gray-400'\n      )}\n      onClick={() => !disabled && onClick?.()}\n    >\n      <div className=\"flex items-center overflow-hidden\">\n        <Icon className=\"ml-1 mr-2 size-4 shrink-0\" />\n        <span className=\"truncate text-sm\">{name}</span>\n      </div>\n      {!disabled && <DraggableHandle className=\"ml-1 shrink-0\" />}\n    </div>\n  );\n\n  return (\n    <>\n      {disabled ? (\n        <TooltipProvider>\n          <Tooltip delayDuration={200}>\n            <TooltipTrigger asChild>{content}</TooltipTrigger>\n            <TooltipContent side=\"right\" sideOffset={8}>\n              {t('table:form.unableAddFieldTip')}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      ) : (\n        content\n      )}\n    </>\n  );\n};\n\ninterface IFormSidebarProps {\n  sidebarAdditionalFieldId: string | null;\n}\n\nexport const FormSidebar: FC<IFormSidebarProps> = (props) => {\n  const { sidebarAdditionalFieldId } = props;\n  const isHydrated = useIsHydrated();\n  const view = useView() as FormView | undefined;\n  const activeViewId = view?.id;\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const getFieldStatic = useFieldStaticGetter();\n  const { openSetting } = useFieldSettingStore();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { setNodeRef } = useDroppable({ id: FORM_SIDEBAR_DROPPABLE_ID });\n\n  const { hiddenFields, visibleFields, unavailableFields } = useMemo(() => {\n    if (!activeViewId) {\n      return {\n        hiddenFields: [],\n        visibleFields: [],\n        unavailableFields: [],\n      };\n    }\n    const hiddenFields: IFieldInstance[] = [];\n    const visibleFields: IFieldInstance[] = [];\n    const unavailableFields: IFieldInstance[] = [];\n    allFields.forEach((field) => {\n      const { isComputed, isLookup, id, type } = field;\n      if (isComputed || isLookup || type === FieldType.Button) {\n        return unavailableFields.push(field);\n      }\n      if (view.columnMeta?.[id]?.visible) {\n        if (sidebarAdditionalFieldId && sidebarAdditionalFieldId === id) {\n          hiddenFields.push(field);\n        }\n        return visibleFields.push(field);\n      }\n      hiddenFields.push(field);\n    });\n    return {\n      hiddenFields,\n      visibleFields,\n      unavailableFields,\n    };\n  }, [activeViewId, allFields, view?.columnMeta, sidebarAdditionalFieldId]);\n\n  const onFieldShown = (field: IFieldInstance) => {\n    view &&\n      view.updateColumnMeta([\n        {\n          fieldId: field.id,\n          columnMeta: {\n            visible: true,\n          },\n        },\n      ]);\n  };\n\n  const onFieldsVisibleChange = (fields: IFieldInstance[], visible: boolean) => {\n    view &&\n      view.updateColumnMeta(\n        fields.map((field) => ({ fieldId: field.id, columnMeta: { visible } }))\n      );\n  };\n\n  return (\n    <div className=\"flex h-full w-64 shrink-0 flex-col border-r py-3\">\n      <div className=\"mb-2 flex justify-between px-4\">\n        <h2 className=\"text-lg\">{t('table:form.fieldsManagement')}</h2>\n        <div>\n          <Button\n            variant={'ghost'}\n            size={'xs'}\n            className=\"font-normal\"\n            disabled={!hiddenFields.length}\n            onClick={() => onFieldsVisibleChange(hiddenFields, true)}\n          >\n            {t('table:form.addAll')}\n          </Button>\n          <Button\n            variant={'ghost'}\n            size={'xs'}\n            className=\"font-normal\"\n            disabled={!visibleFields.length}\n            onClick={() => onFieldsVisibleChange(visibleFields, false)}\n          >\n            {t('table:form.removeAll')}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"mb-4 h-auto grow overflow-y-auto px-4\">\n        {isHydrated && (\n          <div ref={setNodeRef}>\n            {hiddenFields.map((field) => {\n              const { id } = field;\n              return (\n                <DraggableItem\n                  key={'sidebar_' + id}\n                  id={id}\n                  field={field}\n                  draggingClassName={'opacity-50'}\n                >\n                  <DragItem\n                    field={field}\n                    onClick={() => onFieldShown(field)}\n                    getFieldStatic={getFieldStatic}\n                  />\n                </DraggableItem>\n              );\n            })}\n            {unavailableFields.map((field) => {\n              const { id } = field;\n              return (\n                <DragItem\n                  key={id}\n                  disabled\n                  field={field}\n                  onClick={() => onFieldShown(field)}\n                  getFieldStatic={getFieldStatic}\n                />\n              );\n            })}\n            <div className=\"flex h-16 w-full items-center justify-center rounded border-2 border-dashed text-[13px] text-muted-foreground\">\n              {t('table:form.hideFieldTip')}\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div className=\"w-full px-4\">\n        <Button\n          variant={'outline'}\n          className=\"w-full\"\n          onClick={() => openSetting({ operator: FieldOperator.Add })}\n        >\n          <Plus fontSize={16} />\n          {t('table:field.editor.addField')}\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/FromBody.tsx",
    "content": "import { Loader2 } from '@teable/icons';\nimport { LocalStorageKeys } from '@teable/sdk/config';\nimport { useFields, useTableId, useView } from '@teable/sdk/hooks';\nimport { type FormView } from '@teable/sdk/model';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { omit } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { useLocalStorage, useMap, useSet } from 'react-use';\nimport { usePreviewUrl } from '@/features/app/hooks/usePreviewUrl';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { generateUniqLocalKey } from '../util';\nimport { FormField } from './FormField';\n\ninterface IFormBodyProps {\n  className?: string;\n  submit?: (fields: Record<string, unknown>) => Promise<void>;\n}\n\nexport const FormBody = (props: IFormBodyProps) => {\n  const { className, submit } = props;\n  const tableId = useTableId();\n  const view = useView() as FormView | undefined;\n  const fields = useFields();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const localKey = generateUniqLocalKey(tableId, view?.id);\n  const [formDataMap, setFormDataMap] = useLocalStorage<Record<string, Record<string, unknown>>>(\n    LocalStorageKeys.ViewFromData,\n    {}\n  );\n  const [formData, { set: setFormData, setAll: initFormData, remove: removeFormData }] = useMap<\n    Record<string, unknown>\n  >(formDataMap?.[localKey] ?? {});\n  const [errors, { add: addError, remove: removeError, reset: resetErrors }] = useSet<string>(\n    new Set([])\n  );\n  const [loading, setLoading] = useState(false);\n  const previewUrl = usePreviewUrl();\n\n  const visibleFields = useMemo(\n    () => fields.filter(({ isComputed, isLookup }) => !isComputed && !isLookup),\n    [fields]\n  );\n\n  if (view == null) return null;\n\n  const { name, description, columnMeta } = view;\n\n  const onChange = (fieldId: string, value: unknown) => {\n    if (errors.has(fieldId) && value != null && value != '') {\n      removeError(fieldId);\n    }\n\n    if (value == null) {\n      removeFormData(fieldId);\n      return setTimeout(() =>\n        setFormDataMap({ ...formDataMap, [localKey]: omit(formData, fieldId) })\n      );\n    }\n\n    setFormData(fieldId, value);\n\n    // Store to local storage\n    setTimeout(() =>\n      setFormDataMap({\n        ...formDataMap,\n        [localKey]: {\n          ...formData,\n          [fieldId]: value,\n        },\n      })\n    );\n  };\n\n  const onVerify = () => {\n    resetErrors();\n\n    const requiredFieldIds = visibleFields.reduce((acc, field) => {\n      if (field.notNull || columnMeta[field.id].required) acc.push(field.id);\n      return acc;\n    }, [] as string[]);\n\n    if (!requiredFieldIds.length) return true;\n\n    let firstErrorFieldId = '';\n\n    requiredFieldIds.forEach((fieldId) => {\n      if (formData[fieldId] != null) return;\n      if (!firstErrorFieldId) firstErrorFieldId = fieldId;\n      addError(fieldId);\n    });\n\n    if (!firstErrorFieldId) return true;\n\n    document\n      .getElementById(`form-field-${firstErrorFieldId}`)\n      ?.scrollIntoView({ behavior: 'smooth' });\n    return false;\n  };\n\n  const onReset = () => {\n    setLoading(false);\n    initFormData({});\n    setFormDataMap(omit(formDataMap, [localKey]));\n  };\n\n  const onSubmit = async () => {\n    if (!onVerify()) return;\n\n    setLoading(true);\n    if (submit) {\n      const finalData = visibleFields.reduce(\n        (acc, field) => {\n          acc[field.id] = formData[field.id];\n          return acc;\n        },\n        {} as Record<string, unknown>\n      );\n      await submit(finalData);\n      setTimeout(() => {\n        onReset();\n        toast.success(t('actions.submitSucceed'));\n      }, 1000);\n    }\n  };\n\n  const { coverUrl, logoUrl, submitLabel } = view?.options ?? {};\n\n  return (\n    <div className={className}>\n      <div\n        className={cn(\n          'relative h-36 w-full',\n          !coverUrl &&\n            'bg-gradient-to-tr from-green-400 via-blue-400 to-blue-600 dark:from-green-600 dark:via-blue-600 dark:to-blue-900'\n        )}\n      >\n        {coverUrl && (\n          <img\n            src={previewUrl(coverUrl)}\n            alt=\"card cover\"\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n        )}\n      </div>\n\n      {logoUrl && (\n        <div className=\"group absolute left-1/2 top-[104px] ml-[-40px] size-20\">\n          <img\n            className=\"absolute inset-0 size-full rounded-lg object-cover shadow-sm\"\n            src={previewUrl(logoUrl)}\n            alt=\"card cover\"\n          />\n        </div>\n      )}\n\n      <div\n        className={cn(\n          'mb-6 w-full px-6 text-center text-3xl leading-9 sm:px-12',\n          logoUrl ? 'mt-16' : 'mt-8'\n        )}\n        style={{ overflowWrap: 'break-word' }}\n      >\n        {name ?? t('untitled')}\n      </div>\n\n      {description && <div className=\"mb-4 w-full whitespace-pre-line px-12\">{description}</div>}\n\n      {Boolean(visibleFields.length) && (\n        <div className=\"w-full px-6 sm:px-12\">\n          {visibleFields.map((field) => {\n            const { id: fieldId } = field;\n            return (\n              <FormField\n                key={fieldId}\n                field={field}\n                value={formData[fieldId] ?? null}\n                errors={errors}\n                onChange={(value) => onChange(fieldId, value)}\n              />\n            );\n          })}\n\n          <div className=\"mb-12 mt-8 flex w-full justify-center sm:mb-0 sm:px-12\">\n            <Button\n              className=\"w-full text-base sm:w-56\"\n              size={'lg'}\n              onClick={onSubmit}\n              disabled={loading || !submit}\n            >\n              {loading && <Loader2 className=\"size-4 animate-spin\" />}\n              {submitLabel || t('common:actions.submit')}\n            </Button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/ShareUserEditor.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { FieldType, type IUserCellValue } from '@teable/core';\nimport { getShareViewCollaborators, PrincipalType } from '@teable/openapi';\nimport { CellEditor } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { UserField } from '@teable/sdk/model';\nimport { useState } from 'react';\n\ninterface IShareUserEditor {\n  shareId: string;\n  field: UserField;\n  cellValue?: IUserCellValue | IUserCellValue[];\n  className?: string;\n  onChange?: (value?: unknown) => void;\n}\n\nexport const ShareUserEditor = (props: IShareUserEditor) => {\n  const { className, shareId, cellValue, field, onChange } = props;\n  const [search, setSearch] = useState('');\n\n  const { data: userQuery, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.shareViewCollaborators(shareId, {\n      search,\n      skip: 0,\n      take: 100,\n      type: PrincipalType.User,\n    }),\n    queryFn: ({ queryKey }) =>\n      getShareViewCollaborators(queryKey[1], queryKey[2]).then((data) => data.data),\n  });\n  return (\n    <CellEditor\n      cellValue={cellValue}\n      field={field}\n      onChange={onChange}\n      className={className}\n      context={{\n        [FieldType.User]: {\n          data: userQuery,\n          onSearch: setSearch,\n          isLoading,\n        },\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/SortableItem.tsx",
    "content": "import { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\n\nexport const SortableItem = (props: {\n  id: string;\n  index: number;\n  field: IFieldInstance;\n  children: React.ReactElement;\n  className?: string;\n  draggingClassName?: string;\n  onClick?: () => void;\n}) => {\n  const { id, index, field, children, className, draggingClassName, onClick } = props;\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id,\n    data: {\n      id,\n      index,\n      field,\n    },\n  });\n\n  const itemStyle = {\n    transition,\n    transform: CSS.Transform.toString(transform),\n  };\n\n  return (\n    // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions\n    <div\n      style={itemStyle}\n      ref={setNodeRef}\n      {...attributes}\n      {...listeners}\n      className={cn(\n        'group relative overflow-y-auto',\n        className,\n        isDragging ? draggingClassName : null\n      )}\n      onClick={onClick}\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/index.ts",
    "content": "export * from './FormSidebar';\nexport * from './FormEditorMain';\nexport * from './FormEditor';\nexport * from './FormPreviewer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/share-link-editor/FormLinkEditor.tsx",
    "content": "import type { ILinkCellValue } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { LinkCard } from '@teable/sdk/components';\nimport type { LinkField } from '@teable/sdk/model';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { LinkRecordList } from './LinkRecordList';\n\ninterface IShareFormLinkEditorProps {\n  shareId: string;\n  field: LinkField;\n  cellValue?: ILinkCellValue | ILinkCellValue[];\n  className?: string;\n  onChange?: (value?: ILinkCellValue | ILinkCellValue[]) => void;\n}\n\nexport const ShareFormLinkEditor = (props: IShareFormLinkEditorProps) => {\n  const { cellValue, shareId, className, field, onChange } = props;\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const isMultiple = field.isMultipleCellValue;\n\n  const cvArray = useMemo(() => {\n    return isMultiple || !cellValue\n      ? (cellValue as ILinkCellValue[] | undefined)\n      : [cellValue as ILinkCellValue];\n  }, [cellValue, isMultiple]);\n\n  const onDeleteRecord = (recordId: string) => {\n    onChange?.(\n      isMultiple ? (cellValue as ILinkCellValue[])?.filter((cv) => cv.id !== recordId) : undefined\n    );\n  };\n\n  const selectedRecordIds = useMemo(() => {\n    return cvArray?.map((cv) => cv.id);\n  }, [cvArray]);\n\n  const onSelected = (selectedCellValue: ILinkCellValue) => {\n    if (isMultiple) {\n      const arr = (cellValue as ILinkCellValue[]) || [];\n      onChange?.([...arr, selectedCellValue]);\n      return;\n    }\n    setOpen(false);\n    onChange?.(selectedCellValue);\n  };\n\n  return (\n    <div className=\"space-y-3\">\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger asChild>\n          <Button variant=\"outline\" size={'sm'} className={className}>\n            <Plus className=\"size-4 shrink-0\" />\n            {t('table:view.addRecord')}\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent className=\"h-[350px] w-screen md:w-[480px]\">\n          <LinkRecordList\n            shareId={shareId}\n            fieldId={field.id}\n            selectedRecordIds={selectedRecordIds}\n            onSelected={onSelected}\n          />\n        </PopoverContent>\n      </Popover>\n      {cvArray?.map(({ id, title }) => (\n        <LinkCard key={id} title={title} className=\"truncate\" onDelete={() => onDeleteRecord(id)} />\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/components/share-link-editor/LinkRecordList.tsx",
    "content": "import type { ILinkCellValue } from '@teable/core';\nimport { getShareViewLinkRecords } from '@teable/openapi';\nimport { ApiRecordList } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useCallback, useMemo, useState } from 'react';\n\ninterface ILinkRecordListProps {\n  shareId: string;\n  fieldId: string;\n  selectedRecordIds?: string[];\n  onSelected?: (record: ILinkCellValue) => void;\n}\n\nconst pageSize = 50;\n\nexport const LinkRecordList = (props: ILinkRecordListProps) => {\n  const { shareId, fieldId, selectedRecordIds, onSelected } = props;\n  const [searchParam, setSearchParam] = useState<string>();\n\n  const queryKey = useMemo(\n    () => ReactQueryKeys.shareViewLinkRecords(shareId, fieldId, searchParam),\n    [fieldId, searchParam, shareId]\n  );\n\n  const queryFn = useCallback(\n    async ({ pageParam, queryKey }: { pageParam?: number; queryKey: readonly unknown[] }) => {\n      const res = await getShareViewLinkRecords(queryKey[1] as string, {\n        fieldId: queryKey[2] as string,\n        skip: (pageParam ?? 0) * pageSize,\n        take: pageSize,\n        search: queryKey[3] as string,\n      });\n      return res.data;\n    },\n    []\n  );\n\n  return (\n    <ApiRecordList\n      queryKey={queryKey}\n      pageSize={pageSize}\n      selectedRecordIds={selectedRecordIds}\n      queryFn={queryFn}\n      onSearch={setSearchParam}\n      onSelected={onSelected}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/constant.ts",
    "content": "export const FORM_SIDEBAR_DROPPABLE_ID = 'form-sidebar';\nexport const FORM_EDITOR_DROPPABLE_ID = 'form-editor';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/form/util.ts",
    "content": "import type { IFieldInstance } from '@teable/sdk/model';\n\nexport const generateUniqLocalKey = (tableId?: string, viewId?: string) => `${tableId}-${viewId}`;\n\nexport const isProtectedField = (field: IFieldInstance) => {\n  const { options, notNull } = field;\n  const defaultValue = (options as { defaultValue?: string })?.defaultValue;\n  return Boolean(notNull) && !defaultValue;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx",
    "content": "import { RecordProvider, RowCountProvider } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated, usePersonalView } from '@teable/sdk/hooks';\nimport { GalleryToolBar } from '../tool-bar/GalleryToolBar';\nimport { GalleryProvider } from './context';\nimport { GalleryViewBase } from './GalleryViewBase';\n\nexport const GalleryView = () => {\n  const isHydrated = useIsHydrated();\n  const { personalViewCommonQuery } = usePersonalView();\n\n  return (\n    <SearchProvider>\n      <RecordProvider>\n        <RowCountProvider query={personalViewCommonQuery}>\n          <GalleryToolBar />\n          <GalleryProvider>\n            <div className=\"w-full grow overflow-hidden\">{isHydrated && <GalleryViewBase />}</div>\n          </GalleryProvider>\n        </RowCountProvider>\n      </RecordProvider>\n    </SearchProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewBase.tsx",
    "content": "import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';\nimport {\n  DndContext,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragOverlay,\n  closestCenter,\n} from '@dnd-kit/core';\nimport { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { FieldKeyType } from '@teable/core';\nimport { useRowCount, useTableId, useViewId, useRecordOperations } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport React, { useCallback, useEffect, useRef, useState } from 'react';\nimport { Card } from './components/Card';\nimport { SortableItem } from './components/SortableItem';\nimport { useGallery, useCacheRecords } from './hooks';\nimport { calculateColumns, getCardHeight } from './utils';\n\nexport const GalleryViewBase = () => {\n  const { recordQuery, displayFields, coverField, isFieldNameHidden, permission } = useGallery();\n  const tableId = useTableId() as string;\n  const viewId = useViewId() as string;\n  const rowCount = useRowCount() ?? 0;\n  const { updateRecord } = useRecordOperations();\n  const { cardDraggable } = permission;\n\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const parentRef = useRef<HTMLDivElement>(null);\n  const [columnsPerRow, setColumnsPerRow] = useState(4);\n\n  const { skip, recordIds, loadedRecordMap, updateSkipIndex, updateRecordOrder } =\n    useCacheRecords(recordQuery);\n\n  const virtualizer = useVirtualizer({\n    count: Math.ceil(rowCount / columnsPerRow),\n    getScrollElement: () => parentRef.current,\n    estimateSize: () => getCardHeight(displayFields, Boolean(coverField), isFieldNameHidden),\n    overscan: 5,\n  });\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    })\n  );\n\n  const updateGridColumns = useCallback(() => {\n    if (!parentRef.current) return;\n    const containerWidth = parentRef.current.offsetWidth;\n    setColumnsPerRow(calculateColumns(containerWidth));\n  }, []);\n\n  useEffect(() => {\n    virtualizer.measure();\n  }, [displayFields, coverField, isFieldNameHidden, virtualizer]);\n\n  useEffect(() => {\n    const container = parentRef.current;\n    if (!container) return;\n\n    const resizeObserver = new ResizeObserver(() => {\n      updateGridColumns();\n    });\n\n    resizeObserver.observe(container);\n    updateGridColumns();\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, [updateGridColumns]);\n\n  useEffect(() => {\n    if (!virtualizer.range) return;\n    const { startIndex } = virtualizer.range;\n    const actualStartIndex = startIndex * columnsPerRow;\n    updateSkipIndex(actualStartIndex, rowCount);\n  }, [columnsPerRow, rowCount, virtualizer.range, updateSkipIndex]);\n\n  const handleDragStart = (event: DragStartEvent) => {\n    setActiveId(event.active.id as string);\n  };\n\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event;\n    const activeId = active.id;\n    const overId = over?.id;\n\n    if (!activeId || !overId || activeId === overId) return;\n\n    const oldIndex = recordIds.findIndex((id) => id === activeId);\n    const newIndex = recordIds.findIndex((id) => id === overId);\n\n    if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;\n\n    const actualOldIndex = oldIndex + skip;\n    const actualNewIndex = newIndex + skip;\n\n    updateRecordOrder(actualOldIndex, actualNewIndex);\n\n    updateRecord({\n      tableId,\n      recordId: activeId as string,\n      recordRo: {\n        fieldKeyType: FieldKeyType.Id,\n        record: { fields: {} },\n        order: {\n          viewId,\n          anchorId: overId as string,\n          position: actualOldIndex > actualNewIndex ? 'before' : 'after',\n        },\n      },\n    });\n  };\n\n  const activeIndex = activeId ? recordIds.findIndex((id) => id === activeId) : null;\n  const activeRecord = activeIndex != null ? loadedRecordMap[activeIndex + skip] : null;\n\n  return (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={closestCenter}\n      onDragStart={handleDragStart}\n      onDragEnd={handleDragEnd}\n    >\n      <div ref={parentRef} className=\"size-full overflow-auto p-4\">\n        <SortableContext items={recordIds} strategy={rectSortingStrategy} disabled={!cardDraggable}>\n          <div\n            className=\"relative w-full\"\n            style={{\n              height: `${virtualizer.getTotalSize()}px`,\n            }}\n          >\n            {virtualizer.getVirtualItems().map((virtualRow) => (\n              <div\n                key={virtualRow.index}\n                className=\"absolute left-0 top-0 flex w-full gap-x-4 pb-4\"\n                style={{\n                  height: `${virtualRow.size}px`,\n                  transform: `translateY(${virtualRow.start}px)`,\n                }}\n              >\n                {Array.from({ length: columnsPerRow }).map((_, i) => {\n                  const actualIndex = virtualRow.index * columnsPerRow + i;\n                  const card = loadedRecordMap[actualIndex];\n                  const isOverflowItem = virtualRow.index * columnsPerRow + i >= rowCount;\n\n                  return !isOverflowItem && card ? (\n                    <SortableItem key={card.id} id={card.id}>\n                      <Card card={card} />\n                    </SortableItem>\n                  ) : (\n                    <div\n                      key={`placeholder-${virtualRow.index}-${i}`}\n                      className={cn(\n                        'flex-1 rounded-md',\n                        actualIndex >= rowCount ? 'bg-transparent' : 'bg-muted'\n                      )}\n                    />\n                  );\n                })}\n              </div>\n            ))}\n          </div>\n        </SortableContext>\n      </div>\n      <DragOverlay>{activeRecord ? <Card card={activeRecord} /> : null}</DragOverlay>\n    </DndContext>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/components/Card.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */\nimport type { IAttachmentCellValue } from '@teable/core';\nimport { FieldKeyType } from '@teable/core';\nimport {\n  ArrowDown,\n  ArrowUp,\n  Copy,\n  History,\n  Image,\n  Link,\n  Maximize2,\n  MessageSquare,\n  Trash2,\n} from '@teable/icons';\nimport type { IRecordInsertOrderRo } from '@teable/openapi';\nimport { createRecords, deleteRecord, duplicateRecord } from '@teable/openapi';\nimport { CellValue } from '@teable/sdk/components';\nimport { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks';\nimport type { Record } from '@teable/sdk/model';\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useContextMenu } from '../../hooks/useContextMenu';\nimport { useGallery } from '../hooks';\nimport { CARD_COVER_HEIGHT, CARD_STYLE } from '../utils';\nimport { CardCarousel } from './CardCarousel';\n\ninterface IKanbanCardProps {\n  card: Record;\n}\n\nexport const Card = (props: IKanbanCardProps) => {\n  const { card } = props;\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const {\n    coverField,\n    primaryField,\n    displayFields,\n    permission,\n    isCoverFit,\n    isFieldNameHidden,\n    setExpandRecordId,\n  } = useGallery();\n  const { copyRecordUrl, viewRecordHistory, addRecordComment } = useContextMenu();\n\n  const { cardCreatable, cardDeletable, cardEditable, cardCommentCreatable } = permission;\n  const coverFieldId = coverField?.id;\n  const coverCellValue = card.getCellValue(coverFieldId as string) as\n    | IAttachmentCellValue\n    | undefined;\n\n  const titleComponent = useMemo(() => {\n    if (primaryField == null) return t('untitled');\n    const value = card.getCellValue(primaryField.id);\n    if (value == null) return t('untitled');\n    return (\n      <CellValue field={primaryField} value={value} className=\"text-base\" ellipsis plainLongText />\n    );\n  }, [card, primaryField, t]);\n\n  const onExpand = () => {\n    setExpandRecordId(card.id);\n  };\n\n  const onDelete = () => {\n    if (tableId == null) return;\n    deleteRecord(tableId, card.id);\n  };\n\n  const onDuplicate = () => {\n    if (tableId == null || viewId == null) return;\n    duplicateRecord(tableId, card.id, { viewId, anchorId: card.id, position: 'after' });\n  };\n\n  const onInsert = async (position: IRecordInsertOrderRo['position']) => {\n    if (tableId == null || viewId == null) return;\n    const res = await createRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [{ fields: {} }],\n      order: {\n        viewId,\n        anchorId: card.id,\n        position,\n      },\n    });\n    const record = res.data.records[0];\n\n    if (record != null) {\n      setExpandRecordId(record.id);\n    }\n  };\n\n  const onCopyRecordUrl = async () => {\n    await copyRecordUrl(card.id);\n  };\n\n  const onViewRecordHistory = async () => {\n    setExpandRecordId(card.id);\n    await viewRecordHistory(card.id);\n  };\n\n  const onAddRecordComment = async () => {\n    setExpandRecordId(card.id);\n    await addRecordComment(card.id);\n  };\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger>\n        <div\n          className=\"size-full cursor-pointer overflow-hidden rounded-md border border-input bg-card hover:border-primary/15\"\n          onClick={onExpand}\n        >\n          {coverFieldId && (\n            <Fragment>\n              {coverCellValue?.length ? (\n                <CardCarousel value={coverCellValue} isCoverFit={isCoverFit} />\n              ) : (\n                <div\n                  style={{ height: CARD_COVER_HEIGHT }}\n                  className=\"flex w-full items-center justify-center bg-muted\"\n                >\n                  <Image className=\"size-20 text-gray-300 dark:text-gray-700\" />\n                </div>\n              )}\n            </Fragment>\n          )}\n          <div className=\"flex flex-col gap-1 px-3 py-2\">\n            <div\n              className=\"flex pb-2 text-base font-semibold\"\n              style={{ height: CARD_STYLE.titleHeight }}\n            >\n              {titleComponent}\n            </div>\n            {displayFields.map((field) => {\n              const {\n                id: fieldId,\n                name,\n                type,\n                isLookup,\n                isConditionalLookup,\n                aiConfig,\n                canReadFieldRecord,\n              } = field;\n              const { Icon } = getFieldStatic(type, {\n                isLookup,\n                isConditionalLookup,\n                hasAiConfig: Boolean(aiConfig),\n                deniedReadRecord: !canReadFieldRecord,\n              });\n              const cellValue = card.getCellValue(fieldId);\n\n              if (cellValue == null) return null;\n\n              return (\n                <div key={fieldId} className=\"mb-2\">\n                  {!isFieldNameHidden && (\n                    <div className=\"mb-1 flex items-center space-x-1 text-muted-foreground\">\n                      <Icon className=\"size-4 text-sm\" />\n                      <span className=\"text-xs\">{name}</span>\n                    </div>\n                  )}\n                  <CellValue field={field} value={cellValue} ellipsis plainLongText />\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </ContextMenuTrigger>\n      <ContextMenuContent className=\"w-52\">\n        {cardCreatable && (\n          <>\n            <ContextMenuItem onClick={() => onInsert('before')}>\n              <ArrowUp className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.insertCardAbove')}\n            </ContextMenuItem>\n            <ContextMenuItem onClick={() => onInsert('after')}>\n              <ArrowDown className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.insertCardBelow')}\n            </ContextMenuItem>\n            <ContextMenuSeparator />\n            <ContextMenuItem onClick={onDuplicate}>\n              <Copy className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.duplicateCard')}\n            </ContextMenuItem>\n          </>\n        )}\n        <ContextMenuItem onClick={onExpand}>\n          <Maximize2 className=\"mr-2 size-4\" />\n          {t('table:kanban.cardMenu.expandCard')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem onClick={onCopyRecordUrl}>\n          <Link className=\"mr-2 size-4\" />\n          {t('sdk:expandRecord.copyRecordUrl')}\n        </ContextMenuItem>\n        {cardEditable && (\n          <ContextMenuItem onClick={onViewRecordHistory}>\n            <History className=\"mr-2 size-4\" />\n            {t('sdk:expandRecord.viewRecordHistory')}\n          </ContextMenuItem>\n        )}\n        {cardCommentCreatable && (\n          <ContextMenuItem onClick={onAddRecordComment}>\n            <MessageSquare className=\"mr-2 size-4\" />\n            {t('sdk:expandRecord.addRecordComment')}\n          </ContextMenuItem>\n        )}\n        {cardDeletable && (\n          <>\n            <ContextMenuSeparator />\n            <ContextMenuItem className=\"text-destructive focus:text-destructive\" onClick={onDelete}>\n              <Trash2 className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.deleteCard')}\n            </ContextMenuItem>\n          </>\n        )}\n      </ContextMenuContent>\n    </ContextMenu>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/components/CardCarousel.tsx",
    "content": "import type { IAttachmentCellValue } from '@teable/core';\nimport { useTheme } from '@teable/next-themes';\nimport { isSystemFileIcon, getFileCover } from '@teable/sdk/components';\nimport { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';\nimport { FilePreviewProvider, FilePreviewItem } from '@teable/ui-lib/base';\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext,\n} from '@teable/ui-lib/shadcn';\nimport { Fragment } from 'react';\nimport { CARD_COVER_HEIGHT } from '../utils';\n\ninterface ICardCarouselProps {\n  value: IAttachmentCellValue;\n  isCoverFit?: boolean;\n}\n\nexport const CardCarousel = (props: ICardCarouselProps) => {\n  const { value, isCoverFit } = props;\n  const i18nMap = useAttachmentPreviewI18Map();\n  const { resolvedTheme } = useTheme();\n  return (\n    <FilePreviewProvider i18nMap={i18nMap}>\n      <Carousel\n        opts={{\n          watchDrag: false,\n          watchResize: false,\n          watchSlides: false,\n        }}\n        className=\"border-b\"\n      >\n        <CarouselContent className=\"ml-0\">\n          {value.map(({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {\n            const isSystemFile = isSystemFileIcon(mimetype);\n            const url =\n              lgThumbnailUrl ??\n              getFileCover(mimetype, presignedUrl, resolvedTheme as 'light' | 'dark');\n            return (\n              <CarouselItem\n                key={id}\n                style={{ height: CARD_COVER_HEIGHT }}\n                className=\"relative size-full pl-0\"\n              >\n                <FilePreviewItem\n                  key={id}\n                  className=\"flex size-full cursor-pointer items-center justify-center\"\n                  src={presignedUrl || ''}\n                  name={name}\n                  mimetype={mimetype}\n                  size={size}\n                >\n                  <img\n                    src={url}\n                    alt=\"card cover\"\n                    className={isSystemFile ? 'size-20' : 'size-full'}\n                    style={{\n                      objectFit: isCoverFit ? 'contain' : 'cover',\n                    }}\n                  />\n                </FilePreviewItem>\n              </CarouselItem>\n            );\n          })}\n        </CarouselContent>\n        {value.length > 1 && (\n          <Fragment>\n            <CarouselPrevious className=\"left-1 size-7\" onClick={(e) => e.stopPropagation()} />\n            <CarouselNext className=\"right-1 size-7\" onClick={(e) => e.stopPropagation()} />\n          </Fragment>\n        )}\n      </Carousel>\n    </FilePreviewProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/components/SortableItem.tsx",
    "content": "import { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\n\ninterface SortableProps {\n  id: string;\n  children: React.ReactNode;\n}\n\nexport const SortableItem = (props: SortableProps) => {\n  const { id, children } = props;\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id,\n  });\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n    flex: 1,\n  };\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      className=\"overflow-hidden rounded-md shadow-sm transition-shadow duration-200 ease-out hover:shadow-lg\"\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/components/index.ts",
    "content": "export * from './Card';\nexport * from './CardCarousel';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryContext.ts",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\nimport type { AttachmentField, IFieldInstance } from '@teable/sdk/model';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { createContext } from 'react';\nimport type { IGalleryPermission } from '../type';\n\nexport interface IGalleryContext {\n  recordQuery?: Pick<IGetRecordsRo, 'filter' | 'orderBy'>;\n  coverField?: AttachmentField;\n  isCoverFit?: boolean;\n  isFieldNameHidden?: boolean;\n  permission: IGalleryPermission;\n  primaryField: IFieldInstance;\n  displayFields: IFieldInstance[];\n  setExpandRecordId: Dispatch<SetStateAction<string | undefined>>;\n}\n\nexport const GalleryContext = createContext<IGalleryContext>(null!);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryProvider.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport { ExpandRecorder } from '@teable/sdk/components';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport {\n  useTableId,\n  useView,\n  useFields,\n  useTablePermission,\n  usePersonalView,\n  useButtonClickStatus,\n} from '@teable/sdk/hooks';\nimport type { AttachmentField, GalleryView, IFieldInstance } from '@teable/sdk/model';\nimport { useRouter } from 'next/router';\nimport { useContext, useEffect, useMemo, useState, type ReactNode } from 'react';\nimport { GalleryContext } from './GalleryContext';\n\nexport const GalleryProvider = ({ children }: { children: ReactNode }) => {\n  const tableId = useTableId();\n  const view = useView() as GalleryView | undefined;\n  const { personalViewCommonQuery } = usePersonalView();\n  const { shareId } = useContext(ShareViewContext) ?? {};\n  const { sort, filter } = view ?? {};\n  const permission = useTablePermission();\n  const fields = useFields();\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { coverFieldId, isCoverFit, isFieldNameHidden } = view?.options ?? {};\n  const [expandRecordId, setExpandRecordId] = useState<string>();\n  const buttonClickStatusHook = useButtonClickStatus(tableId!, shareId);\n  const router = useRouter();\n  const {\n    recordId: routerRecordId,\n    showHistory: routerShowHistory,\n    showComment: routerShowComment,\n  } = router.query;\n  const showHistory = routerShowHistory === 'true';\n  const showComment = { true: true, false: false }[routerShowComment as string];\n\n  useEffect(() => {\n    setExpandRecordId(routerRecordId as string);\n  }, [routerRecordId, setExpandRecordId]);\n\n  const recordQuery = useMemo(() => {\n    const { ignoreViewQuery } = personalViewCommonQuery ?? {};\n    const baseQuery = {\n      orderBy: sort?.sortObjs,\n      filter: filter,\n    };\n\n    if (shareId) return baseQuery;\n\n    if (ignoreViewQuery) {\n      return {\n        ...baseQuery,\n        ignoreViewQuery,\n      };\n    }\n  }, [shareId, sort, filter, personalViewCommonQuery]);\n\n  const galleryPermission = useMemo(() => {\n    return {\n      cardCreatable: Boolean(permission['record|create']),\n      cardEditable: Boolean(permission['record|update']),\n      cardDeletable: Boolean(permission['record|delete']),\n      cardDraggable: Boolean(permission['record|update'] && permission['view|update']),\n      cardCommentCreatable: Boolean(permission['record|comment']),\n    };\n  }, [permission]);\n\n  const coverField = useMemo(() => {\n    if (!coverFieldId) return;\n    return allFields.find(\n      ({ id, type }) => id === coverFieldId && type === FieldType.Attachment\n    ) as AttachmentField | undefined;\n  }, [coverFieldId, allFields]);\n\n  const { primaryField, displayFields } = useMemo(() => {\n    let primaryField: IFieldInstance | null = null;\n    const displayFields = fields.filter((f) => {\n      if (f.isPrimary) {\n        primaryField = f;\n        return false;\n      }\n      return true;\n    });\n\n    return {\n      primaryField: primaryField as unknown as IFieldInstance,\n      displayFields,\n    };\n  }, [fields]);\n\n  const value = useMemo(() => {\n    return {\n      recordQuery,\n      isCoverFit,\n      isFieldNameHidden,\n      permission: galleryPermission,\n      coverField,\n      primaryField,\n      displayFields,\n      setExpandRecordId,\n    };\n  }, [\n    recordQuery,\n    isCoverFit,\n    isFieldNameHidden,\n    galleryPermission,\n    coverField,\n    primaryField,\n    displayFields,\n    setExpandRecordId,\n  ]);\n\n  const onClose = () => {\n    setExpandRecordId(undefined);\n    const {\n      recordId: _recordId,\n      showHistory: _showHistory,\n      showComment: _showComment,\n      ...resetQuery\n    } = router.query;\n    router.push(\n      {\n        pathname: router.pathname,\n        query: resetQuery,\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  };\n\n  return (\n    <GalleryContext.Provider value={value}>\n      {primaryField && children}\n      {tableId && (\n        <ExpandRecorder\n          tableId={tableId}\n          viewId={view?.id}\n          recordId={expandRecordId}\n          recordIds={expandRecordId ? [expandRecordId] : []}\n          onClose={onClose}\n          buttonClickStatusHook={buttonClickStatusHook}\n          showHistory={showHistory}\n          showComment={showComment}\n        />\n      )}\n    </GalleryContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts",
    "content": "export * from './GalleryContext';\nexport * from './GalleryProvider';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/index.ts",
    "content": "export * from './useGallery';\nexport * from './useCacheRecords';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/useCacheRecords.ts",
    "content": "import { arrayMove } from '@dnd-kit/sortable';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { useRecords } from '@teable/sdk/hooks';\nimport type { Record as RecordModel } from '@teable/sdk/model';\nimport { useState, useEffect, useRef, useCallback, useMemo } from 'react';\n\ninterface UseVirtualRecordsReturn {\n  skip: number;\n  recordIds: string[];\n  loadedRecordMap: Record<number, RecordModel>;\n  updateSkipIndex: (startIndex: number, rowCount: number, overscan?: number) => void;\n  updateRecordOrder: (oldIndex: number, newIndex: number) => void;\n}\n\nconst DEFAULT_TAKE = 300;\nconst CACHE_COUNT = 800;\n\nexport const useCacheRecords = (\n  query: Pick<IGetRecordsRo, 'filter' | 'orderBy'> | undefined\n): UseVirtualRecordsReturn => {\n  const [skip, setSkip] = useState(0);\n  const recordQuery = useMemo(() => {\n    return {\n      ...query,\n      skip,\n      take: DEFAULT_TAKE,\n    };\n  }, [query, skip]);\n\n  const { records } = useRecords(recordQuery);\n\n  const [loadedRecordMap, setLoadedRecordMap] = useState<Record<number, RecordModel>>({});\n  const [recordIds, setRecordIds] = useState<string[]>(records.map((r) => r.id));\n  const skipIndexRef = useRef(skip);\n\n  useEffect(() => {\n    setLoadedRecordMap((prev) => {\n      const cacheStartIndex = Math.max(skipIndexRef.current - CACHE_COUNT / 2, 0);\n      const cacheEndIndex = skipIndexRef.current + CACHE_COUNT / 2;\n      const newRecordMap: Record<string, RecordModel> = {};\n\n      for (let i = cacheStartIndex; i < cacheEndIndex; i++) {\n        newRecordMap[i] = records[i - skipIndexRef.current] ?? prev[i];\n      }\n      return newRecordMap;\n    });\n  }, [records]);\n\n  useEffect(() => {\n    if (!records.length) return;\n    setRecordIds(records.map((r) => r.id));\n  }, [records]);\n\n  useEffect(() => {\n    skipIndexRef.current = skip;\n  }, [skip]);\n\n  const updateSkipIndex = useCallback(\n    (startIndex: number, rowCount: number, overscan: number = 100) => {\n      let newSkip = Math.floor(startIndex / DEFAULT_TAKE) * DEFAULT_TAKE;\n      const actualEndIndex = newSkip + DEFAULT_TAKE;\n\n      if (actualEndIndex - startIndex < overscan) {\n        newSkip = Math.max(newSkip + overscan, 0);\n      }\n\n      if (newSkip >= rowCount) return;\n\n      skipIndexRef.current = newSkip;\n      setSkip(newSkip);\n    },\n    []\n  );\n\n  const updateRecordOrder = useCallback(\n    (oldIndex: number, newIndex: number) => {\n      const newRecordIds = arrayMove(recordIds, oldIndex, newIndex);\n\n      setRecordIds(newRecordIds);\n      setLoadedRecordMap((prev) => {\n        const newRecordMap = { ...prev };\n        const minIndex = Math.min(...Object.keys(prev).map(Number));\n        const maxIndex = Math.max(...Object.keys(prev).map(Number));\n        if (oldIndex > newIndex) {\n          const record = prev[oldIndex];\n          const endIndex = Math.min(oldIndex, maxIndex);\n          for (let i = endIndex - 1; i >= newIndex; i--) {\n            newRecordMap[i + 1] = prev[i];\n          }\n          newRecordMap[newIndex] = record;\n        } else {\n          const record = prev[oldIndex];\n          const startIndex = Math.max(oldIndex, minIndex);\n          for (let i = startIndex + 1; i <= newIndex; i++) {\n            newRecordMap[i - 1] = prev[i];\n          }\n          newRecordMap[newIndex] = record;\n        }\n        return newRecordMap;\n      });\n    },\n    [recordIds]\n  );\n\n  return {\n    skip,\n    recordIds,\n    loadedRecordMap,\n    updateSkipIndex,\n    updateRecordOrder,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/useGallery.ts",
    "content": "import { useContext } from 'react';\nimport { GalleryContext } from '../context';\n\nexport const useGallery = () => {\n  return useContext(GalleryContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/type.ts",
    "content": "export interface IGalleryPermission {\n  cardCreatable: boolean;\n  cardEditable: boolean;\n  cardDeletable: boolean;\n  cardDraggable: boolean;\n  cardCommentCreatable: boolean;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/utils/card.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { IFieldInstance } from '@teable/sdk/model';\n\nexport const CARD_STYLE = {\n  titleHeight: 32,\n  cardPaddingBottom: 16,\n  contentPadding: 8,\n  flexGap: 4,\n  itemGap: 8,\n  itemInnerGap: 4,\n  itemTitleHeight: 16,\n};\n\nexport const DEFAULT_FIELD_HEIGHT = 20;\n\nexport const CARD_COVER_HEIGHT = 180;\n\nexport const LONG_TEXT_FIELD_DISPLAY_ROWS = 4;\n\nexport const FIELD_HEIGHT_MAP: { [key in FieldType]?: number } = {\n  [FieldType.Attachment]: 28,\n  [FieldType.SingleSelect]: 20,\n  [FieldType.MultipleSelect]: 20,\n  [FieldType.Link]: 20,\n  [FieldType.User]: 24,\n  [FieldType.CreatedBy]: 24,\n  [FieldType.LastModifiedBy]: 24,\n  [FieldType.Rating]: 16,\n};\n\nconst {\n  titleHeight,\n  contentPadding,\n  cardPaddingBottom,\n  flexGap,\n  itemGap,\n  itemInnerGap,\n  itemTitleHeight,\n} = CARD_STYLE;\n\nexport const getCardHeight = (\n  fields: IFieldInstance[],\n  hasCover?: boolean,\n  isFieldNameHidden?: boolean\n) => {\n  const fieldCount = fields.length;\n  const staticFieldNameSpace = isFieldNameHidden ? 0 : itemInnerGap + itemTitleHeight;\n  // flex gap-1 (4px) between title and each field item = fieldCount gaps\n  let staticHeight =\n    titleHeight +\n    contentPadding * 2 +\n    (itemGap + staticFieldNameSpace) * fieldCount +\n    flexGap * fieldCount +\n    cardPaddingBottom;\n  staticHeight = hasCover ? staticHeight + CARD_COVER_HEIGHT : staticHeight;\n  const dynamicHeight = fields.reduce((prev, { type }) => {\n    return prev + (FIELD_HEIGHT_MAP[type] || DEFAULT_FIELD_HEIGHT);\n  }, 0);\n  return staticHeight + dynamicHeight;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/utils/columns.ts",
    "content": "export const BREAKPOINTS = {\n  sm: { width: 640, columns: 2 },\n  md: { width: 768, columns: 3 },\n  lg: { width: 1024, columns: 4 },\n  xl: { width: 1280, columns: 5 },\n  '2xl': { width: 1536, columns: 6 },\n} as const;\n\nexport const calculateColumns = (width: number) => {\n  const breakpoint = Object.values(BREAKPOINTS)\n    .reverse()\n    .find((bp) => width >= bp.width);\n  return breakpoint?.columns ?? 1;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts",
    "content": "export * from './card';\nexport * from './columns';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/DomBox.tsx",
    "content": "import { GridTooltip } from '@teable/sdk/components';\nimport type { FC } from 'react';\nimport { RecordMenu, FieldMenu, FieldSetting, StatisticMenu, GroupHeaderMenu } from './components';\n\nexport const DomBox: FC<{ id?: string }> = (props) => {\n  const { id } = props;\n\n  return (\n    <>\n      <FieldMenu />\n      <RecordMenu />\n      <GroupHeaderMenu />\n      <FieldSetting />\n      <StatisticMenu />\n      <GridTooltip id={id} />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/GridView.tsx",
    "content": "import {\n  AggregationProvider,\n  RecordProvider,\n  RowCountProvider,\n  TaskStatusCollectionProvider,\n} from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { usePersonalView } from '@teable/sdk/hooks';\nimport { GridToolBar } from '../tool-bar/GridToolBar';\nimport type { IViewBaseProps } from '../types';\nimport { GridViewBase } from './GridViewBase';\n\nexport const GridView = (props: IViewBaseProps) => {\n  const { recordServerData, recordsServerData, groupPointsServerDataMap } = props;\n  const { personalViewCommonQuery, personalViewAggregationQuery } = usePersonalView();\n\n  return (\n    <SearchProvider>\n      <RecordProvider serverRecords={recordsServerData.records} serverRecord={recordServerData}>\n        <AggregationProvider query={personalViewAggregationQuery}>\n          <TaskStatusCollectionProvider>\n            <RowCountProvider query={personalViewCommonQuery}>\n              <GridToolBar />\n              <div className=\"w-full grow overflow-hidden sm:pl-2\">\n                <GridViewBase groupPointsServerDataMap={groupPointsServerDataMap} />\n              </div>\n            </RowCountProvider>\n          </TaskStatusCollectionProvider>\n        </AggregationProvider>\n      </RecordProvider>\n    </SearchProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx",
    "content": "import type { IGroupPointsVo } from '@teable/openapi';\nimport type { GridView } from '@teable/sdk';\nimport { useGridColumns } from '@teable/sdk';\nimport { useIsHydrated, useView, useViewId } from '@teable/sdk/hooks';\nimport { Skeleton } from '@teable/ui-lib';\nimport React from 'react';\nimport { GridViewBaseInner } from './GridViewBaseInner';\n\ninterface IGridViewProps {\n  groupPointsServerDataMap?: { [viewId: string]: IGroupPointsVo | null };\n  onRowExpand?: (recordId: string) => void;\n}\n\nexport const GridViewBase: React.FC<IGridViewProps> = (props: IGridViewProps) => {\n  const { groupPointsServerDataMap, onRowExpand } = props;\n  const activeViewId = useViewId();\n  const view = useView(activeViewId) as GridView | undefined;\n  const { columns } = useGridColumns();\n  const isLoading = !view || !columns.length;\n  const isHydrated = useIsHydrated();\n\n  return (\n    <>\n      {isHydrated && !isLoading ? (\n        <GridViewBaseInner\n          groupPointsServerData={groupPointsServerDataMap?.[activeViewId as string]}\n          onRowExpand={onRowExpand}\n        />\n      ) : (\n        <div className=\"relative size-full overflow-hidden\">\n          <div className=\"flex w-full items-center space-x-4\">\n            <div className=\"w-full space-y-3 px-2\">\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n              <Skeleton className=\"h-7 w-full\" />\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IAttachmentItem, IFieldVo, IGridViewOptions } from '@teable/core';\nimport {\n  FieldKeyType,\n  FieldType,\n  RowHeightLevel,\n  contractColorForTheme,\n  fieldVoSchema,\n  stringifyClipboardText,\n} from '@teable/core';\nimport type { ICreateRecordsRo, IGroupPointsVo, IUpdateOrderRo } from '@teable/openapi';\nimport { createRecords, stopFillField, autoFillCell } from '@teable/openapi';\nimport type {\n  IRectangle,\n  IPosition,\n  IGridRef,\n  ICellItem,\n  ICell,\n  IInnerCell,\n  GridView,\n  IGroupPoint,\n  IUseTablePermissionAction,\n  IRange,\n  Record,\n  IButtonCell,\n  ICellError,\n} from '@teable/sdk';\nimport {\n  Grid,\n  CellType,\n  RowControlType,\n  SelectionRegionType,\n  RegionType,\n  DraggableType,\n  CombinedSelection,\n  useGridTheme,\n  useGridColumnResize,\n  useGridColumns,\n  useGridColumnStatistics,\n  useGridColumnOrder,\n  useGridAsyncRecords,\n  useCommentCountMap,\n  useGridIcons,\n  useGridTooltipStore,\n  hexToRGBA,\n  emptySelection,\n  useGridGroupCollection,\n  useGridCollapsedGroup,\n  RowCounter,\n  generateLocalId,\n  useGridPrefillingRow,\n  SelectableType,\n  useGridRowOrder,\n  ExpandRecorder,\n  useGridViewStore,\n  useGridSelection,\n  DragRegionType,\n  useGridFileEvent,\n  extractDefaultFieldsFromFilters,\n  TaskStatusCollectionContext,\n  PendingUploadContext,\n  isNeedPersistEditing,\n} from '@teable/sdk';\nimport { GRID_DEFAULT } from '@teable/sdk/components/grid/configs';\nimport { useScrollFrameRate } from '@teable/sdk/components/grid/hooks';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  useBaseId,\n  useFields,\n  useIsTouchDevice,\n  usePersonalView,\n  useRowCount,\n  useSession,\n  useSSRRecord,\n  useSSRRecords,\n  useTableId,\n  useTablePermission,\n  useUndoRedo,\n  useView,\n  useViewId,\n  useRecordOperations,\n  useButtonClickStatus,\n  useTableListener,\n} from '@teable/sdk/hooks';\nimport {\n  finalizePendingUploadAfterCreate,\n  mergePendingAttachmentsForCreate,\n} from '@teable/sdk/store/pending-upload-create';\nimport { useCellAttachmentUploadStore } from '@teable/sdk/store/use-attachment-upload-store';\nimport { useConfirm } from '@teable/ui-lib';\nimport { toast, toast as sonnerToast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { isEqual, keyBy, uniqueId, groupBy } from 'lodash';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { usePrevious, useClickAway } from 'react-use';\nimport { computeFrozenColumnCount } from '@/features/app/blocks/view/grid/utils/computeFrozenFields';\nimport { ExpandRecordContainer } from '@/features/app/components/expand-record-container';\nimport type { IExpandRecordContainerRef } from '@/features/app/components/expand-record-container/types';\nimport { useChatPanelStore } from '@/features/app/components/sidebar/useChatPanelStore';\nimport { useShareAllowCopy, useShareContext } from '@/features/app/context/ShareContext';\nimport { useBaseUsage } from '@/features/app/hooks/useBaseUsage';\nimport { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { FieldOperator } from '../../../components/field-setting';\nimport { useFieldSettingStore } from '../field/useFieldSettingStore';\nimport { useContextMenu } from '../hooks/useContextMenu';\nimport type { IAiAutoFillDialogContainerRef } from './components';\nimport {\n  AiAutoFillDialogContainer,\n  AiGenerateButton,\n  PrefillingRowContainer,\n  PresortRowContainer,\n} from './components';\nimport type { IConfirmNewRecordsRef } from './components/ConfirmNewRecords';\nimport { ConfirmNewRecords } from './components/ConfirmNewRecords';\nimport { ResetClickCountButton } from './components/ResetClickCountButton';\nimport { GIRD_FIELD_NAME_HEIGHT_DEFINITIONS, GIRD_ROW_HEIGHT_DEFINITIONS } from './const';\nimport { DomBox } from './DomBox';\nimport { useCollaborate, useSelectionOperation } from './hooks';\nimport { useIsSelectionLoaded } from './hooks/useIsSelectionLoaded';\nimport { useGridSearchStore } from './useGridSearchStore';\nimport { getEffectRows, generateSeriesForColumn, isEmptyValue } from './utils';\nimport { getSyncCopyData } from './utils/getSyncCopyData';\n\n/**\n * Extract row ranges (0-based) from a CombinedSelection.\n * Returns null for column-only selections.\n */\nfunction getRowRangesFromSelection(selection: CombinedSelection): [number, number][] | null {\n  const { isCellSelection, isRowSelection } = selection;\n\n  if (isCellSelection) {\n    const [[, startRow], [, endRow]] = selection.serialize();\n    return [[startRow, endRow]];\n  }\n\n  if (isRowSelection) {\n    return selection\n      .serialize()\n      .map(\n        ([startRow, endRow]) =>\n          [Math.min(startRow, endRow), Math.max(startRow, endRow)] as [number, number]\n      );\n  }\n\n  return null;\n}\n\ninterface IGridViewBaseInnerProps {\n  groupPointsServerData?: IGroupPointsVo | null;\n  onRowExpand?: (recordId: string) => void;\n}\n\nconst { scrollBuffer, columnAppendBtnWidth } = GRID_DEFAULT;\n\nexport const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (\n  props: IGridViewBaseInnerProps\n) => {\n  const { groupPointsServerData, onRowExpand } = props;\n  const { t, i18n } = useTranslation(tableConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const { updateRecord, duplicateRecord } = useRecordOperations();\n  const router = useRouter();\n  const baseId = useBaseId();\n  const tableId = useTableId() as string;\n  const activeViewId = useViewId();\n  const { user } = useSession();\n  const view = useView(activeViewId) as GridView | undefined;\n  const rowCount = useRowCount();\n  const ssrRecords = useSSRRecords();\n  const ssrRecord = useSSRRecord();\n  const theme = useGridTheme();\n  const fields = useFields();\n  const usage = useBaseUsage();\n  const { aiField: aiFieldEnabled } = useDisableAIAction();\n  const allFields = useFields({ withHidden: true });\n  const taskStatusCollection = useContext(TaskStatusCollectionContext);\n  const { shareId } = useShareContext();\n  const buttonClickStatusHook = useButtonClickStatus(tableId, shareId);\n  const { columns: originalColumns, cellValue2GridDisplay } = useGridColumns();\n  const { columns, onColumnResize } = useGridColumnResize(originalColumns);\n  const { columnStatistics } = useGridColumnStatistics(columns);\n  const { onColumnOrdered } = useGridColumnOrder();\n  const {\n    selection,\n    setSelection,\n    openRecordMenu,\n    openHeaderMenu,\n    openStatisticMenu,\n    openGroupHeaderMenu,\n  } = useGridViewStore();\n  const { openSetting } = useFieldSettingStore();\n  const { openTooltip, closeTooltip } = useGridTooltipStore();\n  const preTableId = usePrevious(tableId);\n  const isTouchDevice = useIsTouchDevice();\n  const sort = view?.sort;\n  const group = view?.group;\n  const isAutoSort = sort && !sort?.manualSort;\n  const { frozenFieldId, frozenColumnCount: frozenColumnCountOption } = (view?.options ??\n    {}) as IGridViewOptions;\n  const frozenColumnCount = useMemo(() => {\n    return computeFrozenColumnCount({\n      isTouchDevice,\n      frozenFieldId,\n      frozenColumnCount: frozenColumnCountOption,\n      visibleColumns: columns,\n      allFields,\n    });\n  }, [isTouchDevice, frozenFieldId, columns, allFields, frozenColumnCountOption]);\n  const { cells: taskStatusCells, fieldMap: taskStatusFieldMap } = taskStatusCollection ?? {};\n  const rowHeight = GIRD_ROW_HEIGHT_DEFINITIONS[view?.options?.rowHeight ?? RowHeightLevel.Short];\n  const columnHeaderHeight =\n    GIRD_FIELD_NAME_HEIGHT_DEFINITIONS[view?.options?.fieldNameDisplayLines ?? 1];\n  const permission = useTablePermission();\n\n  const shareAllowCopy = useShareAllowCopy();\n  const realRowCount = rowCount ?? ssrRecords?.length ?? 0;\n  const fieldEditable = permission['field|update'];\n  const { undo, redo } = useUndoRedo();\n  const { setGridRef, searchCursor, setRecordMap, setFields } = useGridSearchStore();\n  const [expandRecord, setExpandRecord] = useState<{ tableId: string; recordId: string }>();\n  const [newRecords, setNewRecords] = useState<ICreateRecordsRo['records']>();\n  const [cellErrors, setCellErrors] = useState<ICellError[]>([]);\n\n  const { fieldAIEnable: billingFieldAIEnable = false } = usage?.limit ?? {};\n  const fieldAIEnable = billingFieldAIEnable && aiFieldEnabled;\n\n  const aiAutoFillDialogRef = useRef<IAiAutoFillDialogContainerRef>(null);\n\n  const aiGenerateButtonRef = useRef<{\n    onScrollHandler: () => void;\n  }>(null);\n  const resetClickCountButtonRef = useRef<{\n    onScrollHandler: () => void;\n  }>(null);\n\n  const gridRef = useRef<IGridRef>(null);\n  const presortGridRef = useRef<IGridRef>(null);\n  const prefillingGridRef = useRef<IGridRef>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const expandRecordRef = useRef<IExpandRecordContainerRef>(null);\n  const confirmNewRecordsRef = useRef<IConfirmNewRecordsRef>(null);\n\n  const groupCollection = useGridGroupCollection();\n\n  const { personalViewCommonQuery } = usePersonalView();\n  const { viewQuery, collapsedGroupIds, onCollapsedGroupChanged } = useGridCollapsedGroup(\n    generateLocalId(tableId, activeViewId),\n    personalViewCommonQuery\n  );\n\n  useEffect(() => {\n    if (!baseId || !activeViewId) return;\n    const queryKey = ReactQueryKeys.activeViewContext(baseId);\n\n    queryClient.setQueryData(queryKey, {\n      tableId,\n      viewId: activeViewId,\n      query: viewQuery,\n    });\n\n    return () => {\n      const current = queryClient.getQueryData<{ viewId?: string }>(queryKey);\n      if (current?.viewId === activeViewId) {\n        queryClient.removeQueries({ queryKey, exact: true });\n      }\n    };\n  }, [queryClient, baseId, tableId, activeViewId, viewQuery]);\n\n  const {\n    onVisibleRegionChanged,\n    onReset,\n    recordMap,\n    groupPoints,\n    recordsQuery,\n    searchHitIndex,\n    allGroupHeaderRefs,\n  } = useGridAsyncRecords(ssrRecords, undefined, viewQuery, groupPointsServerData ?? undefined);\n\n  const isSelectionLoaded = useIsSelectionLoaded();\n\n  const commentCountMap = useCommentCountMap(recordsQuery);\n\n  const { onRowOrdered, setDraggingRecordIds } = useGridRowOrder(recordMap);\n\n  const { copy, paste, clear, deleteRecords, syncCopy, fill } = useSelectionOperation({\n    collapsedGroupIds: viewQuery?.collapsedGroupIds\n      ? Array.from(viewQuery?.collapsedGroupIds)\n      : undefined,\n  });\n\n  const { copyRecordUrl, viewRecordHistory, addRecordComment } = useContextMenu();\n\n  const {\n    activeCell,\n    presortRecord,\n    presortRecordData,\n    onSelectionChanged,\n    onPresortCellEdited,\n    getPresortCellContent,\n    setPresortRecordData,\n  } = useGridSelection({ recordMap, columns, viewQuery, gridRef });\n\n  const {\n    localRecord,\n    prefillingRowIndex,\n    prefillingRowOrder,\n    prefillingFieldValueMap,\n    tempRecordId,\n    setPrefillingRowIndex,\n    setPrefillingRowOrder,\n    onPrefillingCellEdited,\n    getPrefillingCellContent,\n    setPrefillingFieldValueMap,\n  } = useGridPrefillingRow(columns);\n\n  const inPresorting = presortRecord != null;\n  const inPrefilling = prefillingRowIndex != null;\n\n  const pendingUploadCtx = useMemo(\n    () => ({ tempRecordId, tableId: tableId! }),\n    [tempRecordId, tableId]\n  );\n\n  const onValidation = useCallback(\n    (cell: ICellItem) => {\n      if (!permission['view|update']) return false;\n\n      const [columnIndex] = cell;\n      const field = fields[columnIndex];\n\n      if (!field) return false;\n\n      const { type, isComputed } = field;\n      return type === FieldType.Attachment && !isComputed;\n    },\n    [fields, permission]\n  );\n\n  const startUpload = useCellAttachmentUploadStore((s) => s.startUpload);\n  const onCellDrop = useCallback(\n    (cell: ICellItem, files: FileList) => {\n      const [columnIndex, rowIndex] = cell;\n      const record = recordMap[rowIndex];\n      const field = fields[columnIndex];\n      startUpload(tableId, record.id, field.id, Array.from(files), baseId);\n    },\n    [baseId, fields, recordMap, startUpload, tableId]\n  );\n\n  const startPendingUpload = useCellAttachmentUploadStore((s) => s.startPendingUpload);\n  const onPrefillingCellDrop = useCallback(\n    (cell: ICellItem, fileList: FileList) => {\n      if (!tableId || !tempRecordId) return;\n      const [columnIndex] = cell;\n      const field = fields[columnIndex];\n      if (!field) return;\n      const files = Array.from(fileList);\n      if (!files.length) return;\n\n      startPendingUpload(tableId, tempRecordId, field.id, files, baseId);\n    },\n    [tableId, tempRecordId, fields, baseId, startPendingUpload]\n  );\n\n  const completedPendingByField = useCellAttachmentUploadStore((s) =>\n    tableId ? s.getCompletedPendingAttachments(tableId, tempRecordId) : {}\n  );\n  useEffect(() => {\n    if (!prefillingFieldValueMap) return;\n\n    let changed = false;\n    const next = { ...prefillingFieldValueMap };\n\n    for (const [fieldId, pendingItems] of Object.entries(completedPendingByField)) {\n      const current = (next[fieldId] as IAttachmentItem[] | undefined) ?? [];\n      const ids = new Set(current.map((i) => i.id));\n      const additions = pendingItems.filter((i) => !ids.has(i.id));\n      if (additions.length) {\n        next[fieldId] = [...current, ...additions];\n        changed = true;\n      }\n    }\n\n    if (changed) setPrefillingFieldValueMap(next);\n  }, [completedPendingByField, prefillingFieldValueMap, setPrefillingFieldValueMap]);\n\n  useGridFileEvent({\n    gridRef: inPrefilling ? prefillingGridRef : gridRef,\n    onValidation,\n    onCellDrop: inPrefilling ? onPrefillingCellDrop : onCellDrop,\n  });\n\n  const consumePendingForCreate = useCellAttachmentUploadStore((s) => s.consumePendingForCreate);\n  const promoteToCell = useCellAttachmentUploadStore((s) => s.promoteToCell);\n  const cancelPendingUploads = useCellAttachmentUploadStore((s) => s.cancelPendingUploads);\n\n  const { mutate: mutateCreateRecord, isPending: isCreatingRecord } = useMutation({\n    mutationFn: async (records: ICreateRecordsRo['records']) => {\n      // Safety net: merge any pending-completed attachments not yet consumed by onChange\n      if (records.length === 1 && tableId && tempRecordId) {\n        const { mergedFields, consumedTaskIdsByCellKey } = mergePendingAttachmentsForCreate({\n          fields: records[0].fields,\n          tableId,\n          tempRecordId,\n          consumePendingForCreate,\n        });\n        records = [{ ...records[0], fields: mergedFields }];\n\n        const result = await createRecords(tableId!, {\n          records,\n          fieldKeyType: FieldKeyType.Id,\n          order:\n            activeViewId && prefillingRowOrder\n              ? { ...prefillingRowOrder, viewId: activeViewId }\n              : undefined,\n        });\n\n        finalizePendingUploadAfterCreate({\n          tableId,\n          tempRecordId,\n          realRecordId: result.data.records[0]?.id,\n          consumedTaskIdsByCellKey,\n          promoteToCell,\n        });\n\n        return result;\n      }\n\n      return createRecords(tableId!, {\n        records,\n        fieldKeyType: FieldKeyType.Id,\n        order:\n          activeViewId && prefillingRowOrder\n            ? { ...prefillingRowOrder, viewId: activeViewId }\n            : undefined,\n      });\n    },\n    onSuccess: () => {\n      resetNewRecords();\n    },\n  });\n\n  const resetNewRecords = () => {\n    setPrefillingRowIndex(undefined);\n    setPrefillingFieldValueMap(undefined);\n    setNewRecords(undefined);\n  };\n\n  useEffect(() => {\n    setRecordMap(recordMap);\n  }, [recordMap, setRecordMap]);\n\n  useEffect(() => {\n    setFields(fields);\n  }, [fields, setFields]);\n\n  useEffect(() => {\n    if (preTableId && preTableId !== tableId) {\n      onReset();\n    }\n  }, [onReset, tableId, preTableId]);\n\n  useEffect(() => {\n    const recordIds = Object.keys(recordMap)\n      .sort((a, b) => Number(a) - Number(b))\n      .map((key) => recordMap[key]?.id)\n      .filter(Boolean);\n    expandRecordRef.current?.updateRecordIds?.(recordIds);\n  }, [recordMap]);\n\n  // The recordId on the route changes, and the activeCell needs to change with it\n  useEffect(() => {\n    const recordId = router.query.recordId as string;\n    if (recordId) {\n      const recordIndex = Number(\n        Object.keys(recordMap).find((key) => recordMap[key]?.id === recordId)\n      );\n\n      recordIndex >= 0 &&\n        gridRef.current?.setSelection(\n          new CombinedSelection(SelectionRegionType.Cells, [\n            [0, recordIndex],\n            [0, recordIndex],\n          ])\n        );\n    }\n  }, [router.query.recordId, recordMap]);\n\n  const getCellContent = useCallback<(cell: ICellItem) => ICell>(\n    (cell) => {\n      const [colIndex, rowIndex] = cell;\n      const record = recordMap[rowIndex];\n      if (record !== undefined) {\n        const fieldId = columns[colIndex]?.id;\n        if (!fieldId) return { type: CellType.Loading };\n        return cellValue2GridDisplay(\n          record,\n          colIndex,\n          false,\n          (tableId, recordId) => setExpandRecord({ tableId, recordId }),\n          buttonClickStatusHook\n        );\n      }\n      return { type: CellType.Loading };\n    },\n    [recordMap, columns, cellValue2GridDisplay, buttonClickStatusHook]\n  );\n\n  const onCellEdited = useCallback(\n    (cell: ICellItem, newVal: IInnerCell) => {\n      const [, row] = cell;\n      const record = recordMap[row];\n      if (record === undefined) return;\n\n      const [col] = cell;\n      const fieldId = columns[col].id;\n      const { type, data } = newVal;\n      let newCellValue: unknown = null;\n\n      switch (type) {\n        case CellType.Select:\n          newCellValue = data?.length ? data : null;\n          break;\n        case CellType.Text:\n        case CellType.Number:\n        case CellType.Boolean:\n        default:\n          newCellValue = data === '' ? null : data;\n      }\n      const oldCellValue = record.getCellValue(fieldId) ?? null;\n      if (isEqual(newCellValue, oldCellValue)) return;\n      record.updateCell(fieldId, newCellValue, { t, prefix: 'sdk' });\n      return record;\n    },\n    [recordMap, columns, t]\n  );\n\n  const { confirm } = useConfirm();\n\n  // Handle auto-fill click from menu\n  const handleAutoFillClick = (fieldId: string) => {\n    aiAutoFillDialogRef.current?.open(fieldId);\n  };\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const onContextMenu = (selection: CombinedSelection, position: IPosition) => {\n    const { isCellSelection, isRowSelection, isColumnSelection, ranges } = selection;\n\n    function extract<T>(_start: number, _end: number, source: T[] | { [key: number]: T }): T[] {\n      const start = Math.min(_start, _end);\n      const end = Math.max(_start, _end);\n      return Array.from({ length: end - start + 1 })\n        .map((_, index) => {\n          return source[start + index];\n        })\n        .filter(Boolean);\n    }\n\n    if (isCellSelection || isRowSelection) {\n      const rowStart = isCellSelection ? ranges[0][1] : ranges[0][0];\n      const rowEnd = isCellSelection ? ranges[1][1] : ranges[0][1];\n      const isMultipleSelected =\n        (isRowSelection && ranges.length > 1) || Math.abs(rowEnd - rowStart) > 0;\n\n      if (isMultipleSelected) {\n        openRecordMenu({\n          position,\n          isMultipleSelected,\n          addToChat: () => {\n            const rowRanges = getRowRangesFromSelection(selection);\n            if (rowRanges && baseId) {\n              queryClient.setQueryData(ReactQueryKeys.gridSelection(baseId), {\n                rows: rowRanges,\n                timestamp: Date.now(),\n                addToChat: true,\n              });\n              useChatPanelStore.getState().open();\n            }\n          },\n          deleteRecords: async () => {\n            const deleteRows = getEffectRows(selection, realRowCount);\n\n            if (deleteRows >= 10) {\n              const confirmed = await confirm({\n                title: t('table:table.actionTips.deleteRecordConfirmTitle'),\n                description: t('table:table.actionTips.deleteRecordConfirmDescription', {\n                  recordCount: deleteRows,\n                }),\n                confirmText: t('table:table.actionTips.deleteRecord'),\n                cancelText: t('common:actions.cancel'),\n                confirmButtonVariant: 'destructive',\n              });\n              if (!confirmed) return;\n            }\n\n            deleteRecords(selection);\n            gridRef.current?.setSelection(emptySelection);\n          },\n        });\n      } else {\n        const record = recordMap[rowStart];\n        const neighborRecords: Array<Record | null> = [];\n        neighborRecords[0] = rowStart === 0 ? null : recordMap[rowStart - 1];\n        neighborRecords[1] = rowStart >= realRowCount - 1 ? null : recordMap[rowStart + 1];\n\n        openRecordMenu({\n          position,\n          record,\n          neighborRecords,\n          addToChat: () => {\n            const rowRanges = getRowRangesFromSelection(selection);\n            if (rowRanges && baseId) {\n              queryClient.setQueryData(ReactQueryKeys.gridSelection(baseId), {\n                rows: rowRanges,\n                timestamp: Date.now(),\n                addToChat: true,\n              });\n              useChatPanelStore.getState().open();\n            }\n          },\n          insertRecord: (anchorId, position, num: number) => {\n            if (!tableId || !view?.id || !record) return;\n            const targetIndex = position === 'before' ? rowStart - 1 : rowStart;\n            const fieldValueMap =\n              group?.reduce(\n                (prev, { fieldId }) => {\n                  prev[fieldId] = record.getCellValue(fieldId);\n                  return prev;\n                },\n                {} as { [key: string]: unknown }\n              ) ?? {};\n            generateRecord(fieldValueMap, Math.max(targetIndex, 0), { anchorId, position }, num);\n          },\n          duplicateRecord: async () => {\n            if (!record || !activeViewId) return;\n            await duplicateRecord({\n              tableId,\n              recordId: record.id,\n              order: {\n                viewId: activeViewId,\n                anchorId: record.id,\n                position: 'after',\n              },\n            });\n          },\n          deleteRecords: async () => {\n            deleteRecords(selection);\n            gridRef.current?.setSelection(emptySelection);\n          },\n          copyRecordUrl: async () => {\n            await copyRecordUrl(record?.id);\n          },\n          viewRecordHistory: async () => {\n            await viewRecordHistory(record?.id);\n          },\n          addRecordComment: async () => {\n            await addRecordComment(record?.id);\n          },\n          isMultipleSelected: false,\n        });\n      }\n    }\n\n    if (isColumnSelection) {\n      const [start, end] = ranges[0];\n      const selectColumns = extract(start, end, columns);\n      const indexedColumns = keyBy(selectColumns, 'id');\n      const selectFields = fields.filter((field) => indexedColumns[field.id]);\n      const onAutoFill = (fieldId: string) => handleAutoFillClick(fieldId);\n      const onSelectionClear = () => gridRef.current?.setSelection(emptySelection);\n      openHeaderMenu({\n        position,\n        fields: selectFields,\n        aiEnable: fieldAIEnable,\n        onSelectionClear,\n        onAutoFill,\n      });\n    }\n  };\n\n  const onGroupHeaderContextMenu = (groupId: string, position: IPosition) => {\n    openGroupHeaderMenu({\n      groupId,\n      position,\n      allGroupHeaderRefs,\n    });\n  };\n\n  const onColumnHeaderMenuClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      const fieldId = columns[colIndex].id;\n      const { x, height } = bounds;\n      const selectedFields = fields.filter((field) => field.id === fieldId);\n      const onAutoFill = (fieldId: string) => handleAutoFillClick(fieldId);\n      openHeaderMenu({\n        fields: selectedFields,\n        position: { x, y: height },\n        aiEnable: fieldAIEnable,\n        onAutoFill,\n      });\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [columns, fields, fieldAIEnable, openHeaderMenu]\n  );\n\n  const onColumnHeaderDblClick = useCallback(\n    (colIndex: number) => {\n      if (!columns[colIndex]) return;\n      const fieldId = columns[colIndex].id;\n      if (!fieldEditable) {\n        return;\n      }\n      gridRef.current?.setSelection(emptySelection);\n      openSetting({ fieldId, operator: FieldOperator.Edit });\n    },\n    [columns, fieldEditable, openSetting]\n  );\n\n  const onColumnHeaderClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      if (!isTouchDevice) return;\n      const fieldId = columns[colIndex].id;\n      const { x, height } = bounds;\n      const selectedFields = fields.filter((field) => field.id === fieldId);\n      openHeaderMenu({ fields: selectedFields, position: { x, y: height } });\n    },\n    [isTouchDevice, columns, fields, openHeaderMenu]\n  );\n\n  const onColumnStatisticClick = useCallback(\n    (colIndex: number, bounds: IRectangle) => {\n      const { x, y, width, height } = bounds;\n      const fieldId = columns[colIndex].id;\n      openStatisticMenu({ fieldId, position: { x, y, width, height } });\n    },\n    [columns, openStatisticMenu]\n  );\n\n  const onColumnFreeze = useCallback(\n    (count: number) => {\n      const anchorId = columns[Math.max(0, count - 1)]?.id;\n      if (!view || !anchorId) return;\n      view.updateOption({ frozenFieldId: anchorId });\n    },\n    [view, columns]\n  );\n\n  const filterCreateFieldValues = useCallback(\n    (\n      fieldMap: { [fieldId: string]: { canCreateFieldRecord?: boolean } },\n      fieldValueMap: { [fieldId: string]: unknown }\n    ) => {\n      return Object.entries(fieldValueMap).reduce(\n        (prev, [fieldId, value]) => {\n          if (fieldMap[fieldId]?.canCreateFieldRecord === false) {\n            return prev;\n          }\n          prev[fieldId] = value;\n          return prev;\n        },\n        {} as { [fieldId: string]: unknown }\n      );\n    },\n    []\n  );\n\n  const generateRecord = async (\n    fieldValueMap: { [fieldId: string]: unknown },\n    targetIndex?: number,\n    rowOrder?: IUpdateOrderRo,\n    num?: number\n  ) => {\n    const index = targetIndex ?? Math.max(realRowCount - 1, 0);\n    if (num === 0) {\n      return;\n    }\n    setPrefillingRowOrder(rowOrder);\n\n    const filter = view?.filter;\n    const fieldMap = keyBy(allFields, 'id');\n\n    if (num === 1 || num === undefined) {\n      setPrefillingFieldValueMap(filterCreateFieldValues(fieldMap, fieldValueMap));\n      setPrefillingRowIndex(index);\n      setSelection(emptySelection);\n      gridRef.current?.setSelection(emptySelection);\n      setTimeout(() => {\n        prefillingGridRef.current?.setSelection(\n          new CombinedSelection(SelectionRegionType.Cells, [\n            [0, 0],\n            [0, 0],\n          ])\n        );\n      });\n    } else {\n      const filterValueMap = await extractDefaultFieldsFromFilters({\n        filter,\n        fieldMap,\n        currentUserId: user.id,\n      });\n      const filteredCreateFieldValueMap = filterCreateFieldValues(fieldMap, {\n        ...fieldValueMap,\n        ...filterValueMap,\n      });\n      // insert empty records\n      const emptyRecords = Array.from({ length: num }).fill({\n        fields: filteredCreateFieldValueMap,\n      }) as ICreateRecordsRo['records'];\n      mutateCreateRecord(emptyRecords);\n    }\n  };\n\n  const onRowAppend = (targetIndex?: number) => {\n    if (group?.length && targetIndex != null) {\n      const record = recordMap[targetIndex];\n\n      if (record == null) return generateRecord({}, targetIndex);\n\n      const fieldValueMap = group.reduce(\n        (prev, { fieldId }) => {\n          prev[fieldId] = record.getCellValue(fieldId);\n          return prev;\n        },\n        {} as { [key: string]: unknown }\n      );\n      return generateRecord(fieldValueMap, targetIndex);\n    }\n    return generateRecord({}, targetIndex);\n  };\n\n  const onColumnAppend = () => {\n    openSetting({\n      operator: FieldOperator.Add,\n    });\n  };\n\n  const customIcons = useGridIcons();\n\n  const rowControls = useMemo(() => {\n    if (isTouchDevice) return [];\n    const drag = permission['view|update']\n      ? [\n          {\n            type: RowControlType.Drag,\n            icon: RowControlType.Drag,\n          },\n        ]\n      : [];\n    return [\n      ...drag,\n      {\n        type: RowControlType.Checkbox,\n        icon: RowControlType.Checkbox,\n      },\n      {\n        type: RowControlType.Expand,\n        icon: RowControlType.Expand,\n      },\n    ];\n  }, [isTouchDevice, permission]);\n\n  const onDelete = (selection: CombinedSelection) => {\n    clear(selection);\n  };\n\n  const onCopy = (selection: CombinedSelection, e: React.ClipboardEvent) => {\n    // In share context, use shareAllowCopy; otherwise use permission\n    const canCopy = shareId ? shareAllowCopy : permission['record|copy'];\n    if (!canCopy) {\n      sonnerToast.warning(t('table:table.actionTips.copyError.noPermission'));\n      return;\n    }\n\n    // Write selection to RQ cache for chat paste detection\n    const rowRanges = getRowRangesFromSelection(selection);\n    if (rowRanges && baseId) {\n      queryClient.setQueryData(ReactQueryKeys.gridSelection(baseId), {\n        rows: rowRanges,\n        timestamp: Date.now(),\n      });\n    }\n\n    if (isSelectionLoaded({ selection, recordMap, rowCount: realRowCount })) {\n      // sync copy\n      syncCopy(e, { selection, recordMap });\n      return;\n    }\n    copy(selection);\n  };\n\n  const onCopyForSingleRow = async (\n    e: React.ClipboardEvent,\n    selection: CombinedSelection,\n    fieldValueMap?: { [fieldId: string]: unknown }\n  ) => {\n    const { type } = selection;\n\n    if (type !== SelectionRegionType.Cells || fieldValueMap == null) return;\n\n    const getCopyData = () => {\n      const [start, end] = selection.serialize();\n      const selectedFields = fields.slice(start[0], end[0] + 1);\n      const filteredPropsFields = selectedFields\n        .map((f) => {\n          const validateField = fieldVoSchema.safeParse(f);\n          return validateField.success ? validateField.data : undefined;\n        })\n        .filter(Boolean) as IFieldVo[];\n      const content = [\n        selectedFields.map((field) => field.cellValue2String(fieldValueMap[field.id] as never)),\n      ];\n      return { content: stringifyClipboardText(content), header: filteredPropsFields };\n    };\n\n    syncCopy(e, { getCopyData });\n  };\n\n  const onPaste = async (selection: CombinedSelection, e: React.ClipboardEvent) => {\n    if (!permission['record|update']) {\n      return toast.warning('Unable to paste');\n    }\n    await paste(e, selection, recordMap);\n  };\n\n  const onPasteForPrefilling = (selection: CombinedSelection, e: React.ClipboardEvent) => {\n    if (!permission['record|update'] || localRecord == null) {\n      return toast.warning('Unable to paste');\n    }\n    paste(e, selection, { 0: localRecord }, (records) => {\n      if (records.length > 1) {\n        confirmNewRecordsRef.current?.setOpen(true, records.length);\n        setNewRecords(records);\n        return;\n      }\n      setPrefillingFieldValueMap({ ...prefillingFieldValueMap, ...records[0].fields });\n    });\n  };\n\n  const onPasteForPresort = (selection: CombinedSelection, e: React.ClipboardEvent) => {\n    if (!presortRecord) return;\n    if (!permission['record|update']) {\n      return toast.warning('Unable to paste');\n    }\n    paste(e, selection, { 0: presortRecord }, (records) => {\n      updateRecord({\n        tableId,\n        recordId: presortRecord.id,\n        recordRo: {\n          fieldKeyType: FieldKeyType.Id,\n          record: {\n            fields: { ...presortRecord.fields, ...records[0].fields },\n          },\n        },\n      });\n    });\n  };\n\n  const onDeleteForPrefilling = (selection: CombinedSelection) => {\n    if (localRecord == null || prefillingFieldValueMap == null) return;\n\n    const [start, end] = selection.serialize();\n    const startCol = Math.min(start[0], end[0]);\n    const endCol = Math.max(start[0], end[0]);\n\n    const updated: { [fieldId: string]: unknown } = { ...prefillingFieldValueMap };\n    for (let col = startCol; col <= endCol; col++) {\n      const fieldId = columns[col]?.id;\n      if (!fieldId) continue;\n      updated[fieldId] = null;\n    }\n    setPrefillingFieldValueMap(updated);\n  };\n\n  const onDeleteForPresort = (selection: CombinedSelection) => {\n    if (!presortRecord) return;\n\n    const [start, end] = selection.serialize();\n    const startCol = Math.min(start[0], end[0]);\n    const endCol = Math.max(start[0], end[0]);\n\n    const fieldsToNull: { [fieldId: string]: unknown } = {};\n    for (let col = startCol; col <= endCol; col++) {\n      const fieldId = columns[col]?.id;\n      if (!fieldId) continue;\n      fieldsToNull[fieldId] = null;\n    }\n\n    updateRecord({\n      tableId,\n      recordId: presortRecord.id,\n      recordRo: {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: { ...presortRecord.fields, ...fieldsToNull },\n        },\n      },\n    });\n  };\n\n  const collaborators = useCollaborate(selection, getCellContent);\n\n  const groupedCollaborators = useMemo(() => {\n    return groupBy(collaborators, 'activeCellId');\n  }, [collaborators]);\n\n  const onRowExpandInner = (rowIndex: number) => {\n    const recordId = recordMap[rowIndex]?.id;\n    if (!recordId) {\n      return;\n    }\n    if (onRowExpand) {\n      onRowExpand(recordId);\n      return;\n    }\n    router.push(\n      {\n        pathname: router.pathname,\n        query: { ...router.query, recordId },\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  };\n\n  const onItemClick = (type: RegionType, bounds: IRectangle, cellItem: ICellItem) => {\n    const [columnIndex] = cellItem;\n    const { id: fieldId } = columns[columnIndex] ?? {};\n\n    if (type === RegionType.ColumnDescription) {\n      openSetting({ fieldId, operator: FieldOperator.Edit });\n    }\n  };\n\n  const onFillSelection = (selectionRanges: [IRange, IRange], targetEndRealRowIndex: number) => {\n    const [start, end] = selectionRanges;\n    const startCol = Math.min(start[0], end[0]);\n    const endCol = Math.max(start[0], end[0]);\n    const topRow = Math.min(start[1], end[1]);\n    const bottomRow = Math.max(start[1], end[1]);\n    if (!tableId) return;\n    const isDownward = targetEndRealRowIndex > bottomRow;\n    const isUpward = targetEndRealRowIndex < topRow;\n    if (!isDownward && !isUpward) return;\n\n    const selectionForCopy = new CombinedSelection(SelectionRegionType.Cells, [start, end]);\n    const { headers, rawContent } = getSyncCopyData({\n      recordMap,\n      fields,\n      selection: selectionForCopy,\n    });\n\n    const allEmpty = rawContent.every((row) => row.every((v) => isEmptyValue(v)));\n\n    if (allEmpty) return;\n\n    const selectedFields = fields.slice(startCol, endCol + 1);\n    const content: unknown[][] = [];\n\n    if (isDownward) {\n      const rowsToFill = targetEndRealRowIndex - bottomRow;\n      const direction = 'down' as const;\n      const columnsCount = endCol - startCol + 1;\n      const colSeries: unknown[][] = [];\n      for (let c = 0; c < columnsCount; c++) {\n        const baseColValues = rawContent.map((r) => (r ?? [])[c]);\n        const series = generateSeriesForColumn(\n          baseColValues,\n          selectedFields[c].type,\n          rowsToFill,\n          direction\n        );\n        colSeries.push(series);\n      }\n      for (let r = 0; r < rowsToFill; r++) {\n        content.push(colSeries.map((s) => s[r]));\n      }\n      fill({\n        content,\n        header: headers,\n        ranges: [\n          [startCol, bottomRow + 1],\n          [endCol, targetEndRealRowIndex],\n        ],\n      });\n    } else if (isUpward) {\n      const rowsToFill = topRow - targetEndRealRowIndex;\n      const direction = 'up' as const;\n      const columnsCount = endCol - startCol + 1;\n      const colSeries: unknown[][] = [];\n      for (let c = 0; c < columnsCount; c++) {\n        const baseColValues = rawContent.map((r) => (r ?? [])[c]);\n        const series = generateSeriesForColumn(\n          baseColValues,\n          selectedFields[c].type,\n          rowsToFill,\n          direction\n        );\n        colSeries.push(series);\n      }\n      for (let r = 0; r < rowsToFill; r++) {\n        const idx = rowsToFill - 1 - r;\n        content.push(colSeries.map((s) => s[idx]));\n      }\n      fill({\n        content,\n        header: headers,\n        ranges: [\n          [startCol, targetEndRealRowIndex],\n          [endCol, topRow - 1],\n        ],\n      });\n    }\n  };\n\n  const componentId = useMemo(() => uniqueId('grid-view-'), []);\n\n  const onCellValueHovered = (bounds: IRectangle, cellItem: ICellItem) => {\n    const cellInfo = getCellContent(cellItem);\n    if (!cellInfo?.id) {\n      return;\n    }\n\n    if (cellInfo.type === CellType.Button) {\n      const { data } = cellInfo as IButtonCell;\n      const { fieldOptions, cellValue } = data;\n      const { label } = fieldOptions;\n      const count = cellValue?.count ?? 0;\n      const maxCount = fieldOptions?.maxCount ?? 0;\n      openTooltip({\n        id: componentId,\n        text: t('sdk:common.clickedCount', {\n          label,\n          text: maxCount > 0 ? `${count}/${maxCount}` : `${count}`,\n        }),\n        position: bounds,\n      });\n    }\n  };\n\n  const onItemHovered = (type: RegionType, bounds: IRectangle, cellItem: ICellItem) => {\n    const [columnIndex] = cellItem;\n    const { description } = columns[columnIndex] ?? {};\n\n    closeTooltip();\n\n    if (type === RegionType.ColumnDescription && description) {\n      openTooltip({\n        id: componentId,\n        text: description,\n        position: bounds,\n      });\n    }\n\n    if (type === RegionType.ColumnPrimaryIcon) {\n      openTooltip({\n        id: componentId,\n        text: t('sdk:hidden.primaryKey'),\n        position: bounds,\n      });\n    }\n\n    if (type === RegionType.RowHeaderDragHandler && isAutoSort) {\n      openTooltip({\n        id: componentId,\n        text: t('table:view.dragToolTip'),\n        position: bounds,\n      });\n    }\n\n    if ([RegionType.Cell, RegionType.ActiveCell].includes(type) && collaborators.length) {\n      const { x, y, width, height } = bounds;\n      const cellInfo = getCellContent(cellItem);\n      if (!cellInfo?.id) {\n        return;\n      }\n      const hoverCollaborators = groupedCollaborators?.[cellInfo.id]?.sort(\n        (a, b) => a.timeStamp - b.timeStamp\n      );\n      const collaboratorText = hoverCollaborators\n        ? new Intl.ListFormat(i18n.language, { style: 'narrow', type: 'conjunction' }).format(\n            hoverCollaborators.map((cur) => cur.user.name)\n          )\n        : undefined;\n\n      const hoverHeight = 24;\n\n      collaboratorText &&\n        openTooltip?.({\n          id: componentId,\n          text: collaboratorText,\n          position: {\n            x: x,\n            y: y + 9,\n            width: width,\n            height: height,\n          },\n          contentClassName:\n            'items-center py-0 px-2 absolute truncate whitespace-nowrap rounded-t-md',\n          contentStyle: {\n            right: `-${width / 2}px`,\n            top: `-${hoverHeight}px`,\n            maxWidth: width - 1,\n            height: `${hoverHeight}px`,\n            direction: 'rtl',\n            lineHeight: `${hoverHeight}px`,\n            // multiple collaborators only display the latest one\n            backgroundColor: hexToRGBA(\n              contractColorForTheme(\n                hoverCollaborators.slice(-1)[0].borderColor,\n                theme.themeKey ?? 'light'\n              )\n            ),\n          },\n        });\n    }\n\n    if (type === RegionType.CellValue) {\n      onCellValueHovered(bounds, cellItem);\n    }\n  };\n\n  const draggable = useMemo(() => {\n    if (isAutoSort) return DraggableType.Column;\n    return DraggableType.All;\n  }, [isAutoSort]);\n\n  const onDragStart = useCallback(\n    (type: DragRegionType, dragIndexs: number[]) => {\n      if (type === DragRegionType.Rows) {\n        const recordIds = dragIndexs.map((index) => recordMap[index]?.id).filter(Boolean);\n        setDraggingRecordIds(recordIds);\n      }\n    },\n    [recordMap, setDraggingRecordIds]\n  );\n\n  const getAuthorizedFunction = useCallback(\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    <T extends (...args: any[]) => any>(\n      fn: T,\n      permissionAction: IUseTablePermissionAction\n    ): T | undefined => {\n      return permission[permissionAction] ? fn : undefined;\n    },\n    [permission]\n  );\n\n  const onGridScrollChanged = useCallback((sl?: number, _st?: number) => {\n    prefillingGridRef.current?.scrollTo(sl, undefined);\n    aiGenerateButtonRef.current?.onScrollHandler();\n    resetClickCountButtonRef.current?.onScrollHandler();\n  }, []);\n\n  const onPrefillingGridScrollChanged = useCallback((sl?: number, _st?: number) => {\n    gridRef.current?.scrollTo(sl, undefined);\n  }, []);\n\n  const prefillingRowStyle = useMemo(() => {\n    const defaultTop = rowHeight;\n    const height = rowHeight + 5;\n\n    if (gridRef.current == null || prefillingRowIndex == null) {\n      return { top: 0, height };\n    }\n\n    return {\n      top: Math.max(\n        gridRef.current.getRowOffset(prefillingRowIndex) + defaultTop,\n        GIRD_ROW_HEIGHT_DEFINITIONS[RowHeightLevel.Short]\n      ),\n      height,\n    };\n  }, [rowHeight, prefillingRowIndex]);\n\n  const presortRowStyle = useMemo(() => {\n    const height = rowHeight + 5;\n    const rowIndex = presortRecordData?.rowIndex;\n\n    if (gridRef.current == null || rowIndex == null) {\n      return { top: 0, height };\n    }\n\n    return {\n      top: Math.max(\n        gridRef.current.getRowOffset(rowIndex),\n        GIRD_ROW_HEIGHT_DEFINITIONS[RowHeightLevel.Short]\n      ),\n      height,\n    };\n  }, [rowHeight, presortRecordData]);\n\n  useEffect(() => {\n    if (!inPrefilling && !inPresorting) return;\n    const scrollState = gridRef.current?.getScrollState();\n    if (scrollState == null) return;\n    presortGridRef.current?.scrollTo(scrollState.scrollLeft, undefined);\n    prefillingGridRef.current?.scrollTo(scrollState.scrollLeft, undefined);\n  }, [inPrefilling, inPresorting]);\n\n  useClickAway(containerRef, () => {\n    gridRef.current?.resetState();\n  });\n\n  useScrollFrameRate(gridRef.current?.scrollBy);\n\n  useHotkeys(\n    ['mod+f', 'mod+k'],\n    () => {\n      gridRef.current?.setSelection(emptySelection);\n    },\n    {\n      enableOnFormTags: ['input', 'select', 'textarea'],\n    }\n  );\n\n  useEffect(() => setGridRef?.(gridRef), [setGridRef]);\n\n  useEffect(() => {\n    const recordId2IndexMap: { [id: string]: number } = {};\n    Object.entries(recordMap).forEach(([index, record]) => {\n      if (record == null) return;\n      recordId2IndexMap[record.id] = index as unknown as number;\n    });\n    const fieldId2IndexMap: { [id: string]: number } = {};\n    fields.forEach(({ id }, index) => (fieldId2IndexMap[id] = index));\n    const loadingCells = taskStatusCells\n      ?.filter(\n        ({ recordId, fieldId }) =>\n          recordId2IndexMap[recordId] != null && fieldId2IndexMap[fieldId] != null\n      )\n      .map(({ recordId, fieldId }) => [fieldId2IndexMap[fieldId], recordId2IndexMap[recordId]]);\n    gridRef.current?.setCellLoading((loadingCells ?? []) as ICellItem[]);\n  }, [fields, recordMap, taskStatusCells]);\n\n  useEffect(() => {\n    const fieldId2IndexMap: { [id: string]: number } = {};\n    fields.forEach(({ id }, index) => (fieldId2IndexMap[id] = index));\n    const loadingColumnIndexs = Object.keys(taskStatusFieldMap ?? {}).map((fieldId) => {\n      const index = fieldId2IndexMap[fieldId];\n      const { completedCount = 0, totalCount } = taskStatusFieldMap?.[fieldId] ?? {};\n      return {\n        index,\n        progress: totalCount ? completedCount / totalCount : 0,\n        onCancel: () => {\n          stopFillField(tableId, fieldId);\n        },\n      };\n    });\n    gridRef.current?.setColumnLoadings(loadingColumnIndexs);\n  }, [tableId, fields, taskStatusFieldMap]);\n\n  // Helper to clear cell error by recordId and fieldId\n  const clearCellError = useCallback(\n    (recordId: string, fieldId: string) => {\n      const recordId2IndexMap: { [id: string]: number } = {};\n      Object.entries(recordMap).forEach(([index, record]) => {\n        if (record == null) return;\n        recordId2IndexMap[record.id] = index as unknown as number;\n      });\n\n      const fieldId2IndexMap: { [id: string]: number } = {};\n      fields.forEach(({ id }, index) => (fieldId2IndexMap[id] = index));\n\n      const fieldIndex = fieldId2IndexMap[fieldId];\n      const recordIndex = recordId2IndexMap[recordId];\n\n      if (fieldIndex === undefined || recordIndex === undefined) return;\n\n      setCellErrors((current) =>\n        current.filter((e) => !(e.cellItem[0] === fieldIndex && e.cellItem[1] === recordIndex))\n      );\n    },\n    [fields, recordMap]\n  );\n\n  // Handle taskProcessing events - clear any existing error for this cell\n  const handleTaskProcessing = useCallback(\n    (_actionKey: string, payload?: { recordId: string; fieldId: string }) => {\n      if (!payload) return;\n      const { recordId, fieldId } = payload;\n      clearCellError(recordId, fieldId);\n    },\n    [clearCellError]\n  );\n\n  // Handle taskFailed events from AI field generation\n  const handleTaskFailed = useCallback(\n    (_actionKey: string, payload?: { recordId: string; fieldId: string; errorMsg: string }) => {\n      if (!payload) return;\n      const { recordId, fieldId, errorMsg } = payload;\n\n      // Build index maps (same as loading state)\n      const recordId2IndexMap: { [id: string]: number } = {};\n      Object.entries(recordMap).forEach(([index, record]) => {\n        if (record == null) return;\n        recordId2IndexMap[record.id] = index as unknown as number;\n      });\n\n      const fieldId2IndexMap: { [id: string]: number } = {};\n      fields.forEach(({ id }, index) => (fieldId2IndexMap[id] = index));\n\n      const fieldIndex = fieldId2IndexMap[fieldId];\n      const recordIndex = recordId2IndexMap[recordId];\n\n      // Skip if field or record not found in current view\n      if (fieldIndex === undefined || recordIndex === undefined) {\n        return;\n      }\n\n      setCellErrors((prev) => {\n        // Check if error already exists for this cell\n        const existingIndex = prev.findIndex(\n          (e) => e.cellItem[0] === fieldIndex && e.cellItem[1] === recordIndex\n        );\n\n        const newError: ICellError = {\n          cellItem: [fieldIndex, recordIndex],\n          errorMsg,\n          onRetry: () => {\n            // Clear error and trigger retry\n            clearCellError(recordId, fieldId);\n            autoFillCell(tableId, recordId, fieldId);\n          },\n          onDismiss: () => {\n            clearCellError(recordId, fieldId);\n          },\n        };\n\n        if (existingIndex >= 0) {\n          const updated = [...prev];\n          updated[existingIndex] = newError;\n          return updated;\n        }\n\n        return [...prev, newError];\n      });\n    },\n    [fields, recordMap, tableId, clearCellError]\n  );\n\n  useTableListener(tableId, ['taskFailed'], handleTaskFailed);\n  useTableListener(tableId, ['taskProcessing'], handleTaskProcessing);\n\n  // Update cell errors in grid\n  useEffect(() => {\n    gridRef.current?.setCellErrors(cellErrors);\n  }, [cellErrors]);\n\n  const onPresortContainerInit = () => {\n    if (!activeCell) return;\n\n    const { columnIndex, fieldId } = activeCell;\n\n    if (gridRef.current?.isEditing() && isNeedPersistEditing(allFields, fieldId)) return;\n    if (columnIndex == null) return;\n\n    const range = [columnIndex, 0] as IRange;\n    setTimeout(() => {\n      gridRef.current?.setSelection(emptySelection);\n      presortGridRef.current?.setSelection(\n        new CombinedSelection(SelectionRegionType.Cells, [range, range])\n      );\n    }, 100);\n  };\n\n  const onCellDblClick = (cell: ICellItem) => {\n    const [columnIndex, rowIndex] = cell;\n    const record = recordMap[rowIndex];\n    if (record == null) return;\n    const field = columns[columnIndex];\n    if (field == null) return;\n    if (record.isHidden(field.id)) {\n      return sonnerToast.warning(t('table:permission.cell.deniedRead'));\n    }\n    if (record.isLocked(field.id)) {\n      return sonnerToast.warning(t('table:permission.cell.deniedUpdate'));\n    }\n  };\n\n  return (\n    <div ref={containerRef} className=\"relative size-full\">\n      <Grid\n        ref={gridRef}\n        theme={theme}\n        style={{ pointerEvents: inPrefilling || inPresorting ? 'none' : 'auto' }}\n        draggable={draggable}\n        isTouchDevice={isTouchDevice}\n        rowCount={realRowCount}\n        rowHeight={rowHeight}\n        columnHeaderHeight={columnHeaderHeight}\n        freezeColumnCount={frozenColumnCount}\n        columnStatistics={columnStatistics}\n        columns={columns}\n        commentCountMap={commentCountMap}\n        customIcons={customIcons}\n        rowControls={rowControls}\n        collapsedGroupIds={collapsedGroupIds}\n        groupCollection={groupCollection}\n        groupPoints={groupPoints as unknown as IGroupPoint[]}\n        collaborators={collaborators}\n        searchCursor={searchCursor}\n        searchHitIndex={searchHitIndex}\n        getCellContent={getCellContent}\n        onDelete={getAuthorizedFunction(onDelete, 'record|update')}\n        onDragStart={onDragStart}\n        onRowOrdered={onRowOrdered}\n        onRowExpand={onRowExpandInner}\n        onRowAppend={\n          isTouchDevice ? undefined : getAuthorizedFunction(onRowAppend, 'record|create')\n        }\n        onCellEdited={getAuthorizedFunction(onCellEdited, 'record|update')}\n        onFillSelection={getAuthorizedFunction(onFillSelection, 'record|update')}\n        onCellDblClick={onCellDblClick}\n        onColumnAppend={getAuthorizedFunction(onColumnAppend, 'field|create')}\n        onColumnFreeze={getAuthorizedFunction(onColumnFreeze, 'view|update')}\n        onColumnResize={getAuthorizedFunction(onColumnResize, 'view|update')}\n        onColumnOrdered={getAuthorizedFunction(onColumnOrdered, 'view|update')}\n        onContextMenu={onContextMenu}\n        onGroupHeaderContextMenu={onGroupHeaderContextMenu}\n        onColumnHeaderClick={onColumnHeaderClick}\n        onColumnStatisticClick={getAuthorizedFunction(onColumnStatisticClick, 'view|update')}\n        onVisibleRegionChanged={onVisibleRegionChanged}\n        onSelectionChanged={onSelectionChanged}\n        onColumnHeaderDblClick={onColumnHeaderDblClick}\n        onColumnHeaderMenuClick={onColumnHeaderMenuClick}\n        onCollapsedGroupChanged={onCollapsedGroupChanged}\n        onScrollChanged={onGridScrollChanged}\n        onUndo={undo}\n        onRedo={redo}\n        onCopy={onCopy}\n        onPaste={onPaste}\n        onItemClick={onItemClick}\n        onItemHovered={onItemHovered}\n      />\n      {fieldAIEnable && (\n        <AiGenerateButton\n          ref={aiGenerateButtonRef}\n          gridRef={gridRef}\n          activeCell={activeCell}\n          recordMap={recordMap}\n          onGenerate={() => {\n            if (activeCell) {\n              clearCellError(activeCell.recordId, activeCell.fieldId);\n            }\n          }}\n        />\n      )}\n      {activeCell && (\n        <ResetClickCountButton\n          ref={resetClickCountButtonRef}\n          gridRef={gridRef}\n          activeCell={activeCell}\n          recordMap={recordMap}\n        />\n      )}\n      {inPrefilling && (\n        <PendingUploadContext.Provider value={pendingUploadCtx}>\n          <PrefillingRowContainer\n            style={prefillingRowStyle}\n            isLoading={isCreatingRecord}\n            onClickOutside={async () => {\n              if (isCreatingRecord || newRecords?.length) return;\n              await mutateCreateRecord([{ fields: prefillingFieldValueMap! }]);\n            }}\n            onCancel={() => {\n              if (tableId) {\n                cancelPendingUploads(tableId, tempRecordId);\n              }\n              setPrefillingRowIndex(undefined);\n              setPrefillingFieldValueMap(undefined);\n            }}\n          >\n            <Grid\n              ref={prefillingGridRef}\n              theme={theme}\n              scrollBufferX={\n                permission['field|create'] ? scrollBuffer + columnAppendBtnWidth : scrollBuffer\n              }\n              scrollBufferY={0}\n              scrollBarVisible={false}\n              rowCount={1}\n              rowHeight={rowHeight}\n              rowIndexVisible={false}\n              rowControls={rowControls}\n              draggable={DraggableType.None}\n              selectable={SelectableType.Cell}\n              columns={columns}\n              commentCountMap={commentCountMap}\n              columnHeaderHeight={0}\n              freezeColumnCount={frozenColumnCount}\n              customIcons={customIcons}\n              getCellContent={getPrefillingCellContent}\n              onScrollChanged={onPrefillingGridScrollChanged}\n              onCellEdited={onPrefillingCellEdited}\n              onCopy={(selection, e) => onCopyForSingleRow(e, selection, prefillingFieldValueMap)}\n              onPaste={onPasteForPrefilling}\n              onDelete={getAuthorizedFunction(onDeleteForPrefilling, 'record|update')}\n            />\n          </PrefillingRowContainer>\n        </PendingUploadContext.Provider>\n      )}\n      {presortRecord && (\n        <PresortRowContainer\n          style={presortRowStyle}\n          onInit={onPresortContainerInit}\n          onClickOutside={async () => setPresortRecordData(undefined)}\n        >\n          <Grid\n            ref={presortGridRef}\n            theme={theme}\n            scrollBufferX={\n              permission['field|create'] ? scrollBuffer + columnAppendBtnWidth : scrollBuffer\n            }\n            scrollBufferY={0}\n            scrollBarVisible={false}\n            rowCount={1}\n            rowHeight={rowHeight}\n            rowIndexVisible={false}\n            rowControls={rowControls}\n            draggable={DraggableType.None}\n            selectable={SelectableType.Cell}\n            columns={columns}\n            columnHeaderHeight={0}\n            commentCountMap={commentCountMap}\n            freezeColumnCount={frozenColumnCount}\n            customIcons={customIcons}\n            getCellContent={getPresortCellContent}\n            onScrollChanged={onPrefillingGridScrollChanged}\n            onCellEdited={onPresortCellEdited}\n            onCopy={(selection, e) => onCopyForSingleRow(e, selection, presortRecord.fields)}\n            onPaste={onPasteForPresort}\n            onDelete={getAuthorizedFunction(onDeleteForPresort, 'record|update')}\n          />\n        </PresortRowContainer>\n      )}\n      <RowCounter rowCount={realRowCount} className=\"absolute bottom-3 left-0\" />\n      <DomBox id={componentId} />\n      {!onRowExpand && (\n        <ExpandRecordContainer\n          ref={expandRecordRef}\n          recordServerData={ssrRecord}\n          buttonClickStatusHook={buttonClickStatusHook}\n        />\n      )}\n      {expandRecord != null && (\n        <ExpandRecorder\n          tableId={expandRecord.tableId}\n          viewId={activeViewId}\n          recordId={expandRecord.recordId}\n          recordIds={[expandRecord.recordId]}\n          onClose={() => setExpandRecord(undefined)}\n          buttonClickStatusHook={buttonClickStatusHook}\n        />\n      )}\n      <ConfirmNewRecords\n        ref={confirmNewRecordsRef}\n        onCancel={() => {\n          setPrefillingFieldValueMap({ ...prefillingFieldValueMap, ...newRecords?.[0].fields });\n          setNewRecords(undefined);\n        }}\n        onConfirm={() => newRecords && mutateCreateRecord(newRecords)}\n      />\n      <AiAutoFillDialogContainer\n        ref={aiAutoFillDialogRef}\n        group={group}\n        personalViewCommonQuery={personalViewCommonQuery}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/AiAutoFillDialogContainer.tsx",
    "content": "import type { IGroup } from '@teable/core';\nimport { StatisticsFunc } from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { getAggregation } from '@teable/openapi';\nimport type { GridView } from '@teable/sdk';\nimport { useFieldOperations, useTableId, useView, useViewId } from '@teable/sdk/hooks';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { AiAutoFillMode } from '../../../../components/field-setting/dialog/AiAutoFillDialog';\nimport { AiAutoFillDialog } from '../../../../components/field-setting/dialog/AiAutoFillDialog';\n\ninterface IAiAutoFillDialogContainerProps {\n  group?: IGroup;\n  personalViewCommonQuery?: IGetRecordsRo;\n}\n\nexport interface IAiAutoFillDialogContainerRef {\n  open: (fieldId: string) => void;\n}\n\nexport const AiAutoFillDialogContainer = forwardRef<\n  IAiAutoFillDialogContainerRef,\n  IAiAutoFillDialogContainerProps\n>((props, ref) => {\n  const { group, personalViewCommonQuery } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { autoFillField } = useFieldOperations();\n  const tableId = useTableId() as string;\n  const activeViewId = useViewId();\n  const view = useView(activeViewId) as GridView | undefined;\n\n  const [autoFillFieldId, setAutoFillFieldId] = useState<string | undefined>();\n  const [aiFieldStats, setAiFieldStats] = useState<{\n    rowCount?: number;\n    emptyCount?: number;\n    filledCount?: number;\n    isLoading: boolean;\n  }>({ isLoading: false });\n\n  const fetchFieldStats = useCallback(\n    async (fieldId: string) => {\n      if (!tableId) return;\n\n      setAiFieldStats({ isLoading: true });\n      try {\n        // Build query based on personal view or regular view\n        const query = personalViewCommonQuery\n          ? {\n              filter: personalViewCommonQuery.filter,\n              ignoreViewQuery: true,\n            }\n          : view?.id\n            ? { viewId: view.id }\n            : {};\n\n        const result = await getAggregation(tableId, {\n          ...query,\n          field: {\n            [StatisticsFunc.Empty]: [fieldId],\n            [StatisticsFunc.Filled]: [fieldId],\n          },\n        });\n\n        const aggregations = result.data.aggregations;\n        if (aggregations && aggregations.length > 0) {\n          const parseValue = (value: string | number | null | undefined): number | undefined => {\n            if (value == null) return undefined;\n            return typeof value === 'string' ? parseInt(value, 10) : value;\n          };\n\n          const emptyAgg = aggregations.find(\n            (agg) => agg.fieldId === fieldId && agg.total?.aggFunc === StatisticsFunc.Empty\n          );\n          const filledAgg = aggregations.find(\n            (agg) => agg.fieldId === fieldId && agg.total?.aggFunc === StatisticsFunc.Filled\n          );\n\n          const emptyCount = parseValue(emptyAgg?.total?.value);\n          const filledCount = parseValue(filledAgg?.total?.value);\n          // Calculate rowCount from empty + filled\n          const rowCount =\n            emptyCount != null && filledCount != null ? emptyCount + filledCount : undefined;\n\n          setAiFieldStats({\n            rowCount,\n            emptyCount,\n            filledCount,\n            isLoading: false,\n          });\n        } else {\n          setAiFieldStats({ isLoading: false });\n        }\n      } catch (e) {\n        console.error('Failed to fetch field stats', e);\n        setAiFieldStats({ isLoading: false });\n      }\n    },\n    [tableId, view?.id, personalViewCommonQuery]\n  );\n\n  const handleOpen = useCallback(\n    (fieldId: string) => {\n      setAutoFillFieldId(fieldId);\n      fetchFieldStats(fieldId);\n    },\n    [fetchFieldStats]\n  );\n\n  useImperativeHandle(ref, () => ({\n    open: handleOpen,\n  }));\n\n  const handleClose = useCallback(() => {\n    setAutoFillFieldId(undefined);\n    setAiFieldStats({ isLoading: false });\n  }, []);\n\n  const handleConfirm = useCallback(\n    async (mode: AiAutoFillMode) => {\n      if (!tableId || !view || !autoFillFieldId || mode === 'saveOnly') {\n        handleClose();\n        return;\n      }\n\n      const baseQuery = personalViewCommonQuery\n        ? {\n            filter: personalViewCommonQuery.filter,\n            orderBy: personalViewCommonQuery.orderBy,\n            groupBy: personalViewCommonQuery.groupBy,\n            ignoreViewQuery: true,\n          }\n        : {\n            viewId: view.id,\n            groupBy: group,\n          };\n\n      try {\n        const apiMode = mode as 'emptyOnly' | 'all';\n        await autoFillField({\n          tableId,\n          fieldId: autoFillFieldId,\n          query: { ...baseQuery, mode: apiMode },\n        });\n      } catch (e) {\n        toast.error(t('table:field.aiConfig.autoFillConfirm.generateFailed'));\n        console.error('autoFillField error', e);\n      } finally {\n        handleClose();\n      }\n    },\n    [tableId, view, autoFillFieldId, personalViewCommonQuery, group, autoFillField, handleClose, t]\n  );\n\n  return (\n    <AiAutoFillDialog\n      open={Boolean(autoFillFieldId)}\n      title={t('table:field.aiConfig.autoFillFieldDialog.title')}\n      rowCount={aiFieldStats.rowCount ?? 0}\n      emptyCount={aiFieldStats.emptyCount}\n      filledCount={aiFieldStats.filledCount}\n      isLoadingStats={aiFieldStats.isLoading}\n      cancelText={t('common:actions.cancel')}\n      hideSaveOnly\n      labels={{\n        description: t('table:field.aiConfig.autoFillFieldDialog.description'),\n        emptyOnly: t('table:field.aiConfig.autoFillConfirm.emptyOnlyMode'),\n        emptyOnlyDesc: t('table:field.aiConfig.autoFillConfirm.emptyOnlyModeDesc'),\n        all: t('table:field.aiConfig.autoFillConfirm.allMode'),\n        allDesc: t('table:field.aiConfig.autoFillConfirm.allModeDesc'),\n        saveOnly: t('table:field.aiConfig.autoFillConfirm.saveOnlyMode'),\n        saveOnlyDesc: t('table:field.aiConfig.autoFillConfirm.saveOnlyModeDesc'),\n        recommended: t('table:field.aiConfig.autoFillConfirm.recommended'),\n        limitWarning: t('table:field.aiConfig.autoFillConfirm.limitWarning'),\n      }}\n      confirmLabels={{\n        emptyOnly: t('table:field.aiConfig.autoFillConfirm.fillEmptyCells'),\n        all: t('table:field.aiConfig.autoFillConfirm.generateAll'),\n        saveOnly: t('table:field.aiConfig.autoFillConfirm.saveConfigOnly'),\n      }}\n      onClose={handleClose}\n      onConfirm={handleConfirm}\n    />\n  );\n});\n\nAiAutoFillDialogContainer.displayName = 'AiAutoFillDialogContainer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/AiGenerateButton.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { RefreshCcw } from '@teable/icons';\nimport { autoFillCell } from '@teable/openapi';\nimport {\n  Record,\n  TaskStatusCollectionContext,\n  useFields,\n  useTableId,\n  useTableListener,\n  useTablePermission,\n} from '@teable/sdk';\nimport type { IActiveCell, IGridRef, IRecordIndexMap } from '@teable/sdk';\nimport { Button } from '@teable/ui-lib';\nimport React, {\n  useCallback,\n  useRef,\n  useEffect,\n  forwardRef,\n  useImperativeHandle,\n  useContext,\n  useState,\n} from 'react';\n\ninterface IAIButtonProps {\n  gridRef: React.RefObject<IGridRef>;\n  activeCell?: IActiveCell;\n  recordMap: IRecordIndexMap;\n  onGenerate?: () => void;\n}\n\nexport const AiGenerateButton = forwardRef<{ onScrollHandler: () => void }, IAIButtonProps>(\n  (props, ref) => {\n    const { gridRef, activeCell, recordMap } = props;\n    const tableId = useTableId() as string;\n    const fields = useFields();\n    const permission = useTablePermission();\n    const taskStatusCollection = useContext(TaskStatusCollectionContext);\n    const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const [style, setStyle] = React.useState<React.CSSProperties | null>(null);\n    // Track local loading state - waiting for taskProcessing message\n    const [pendingCell, setPendingCell] = useState<{ recordId: string; fieldId: string } | null>(\n      null\n    );\n\n    const { mutate: mutateGenerate } = useMutation({\n      mutationFn: ({ recordId, fieldId }: { recordId: string; fieldId: string }) =>\n        autoFillCell(tableId, recordId, fieldId),\n      onError: () => {\n        // Clear pending state if API call fails\n        setPendingCell(null);\n      },\n    });\n\n    // Clear pending state when any task event is received\n    // Task events don't include cell-specific payload, so we clear pendingCell\n    // when the task enters the queue (star animation takes over) or completes\n    const handleTaskEvent = useCallback(() => {\n      if (!pendingCell) return;\n      setPendingCell(null);\n    }, [pendingCell]);\n\n    useTableListener(\n      tableId,\n      ['taskProcessing', 'taskCompleted', 'taskCancelled', 'taskFailed'],\n      handleTaskEvent\n    );\n\n    // Check if cell is currently being processed by task queue (showing star animation)\n    const isCellInTaskQueue = (cell?: IActiveCell) => {\n      if (!cell || !taskStatusCollection?.cells) return false;\n      return taskStatusCollection.cells.some(\n        (c) => c.recordId === cell.recordId && c.fieldId === cell.fieldId\n      );\n    };\n\n    // Check if this cell is in local pending state (waiting for taskProcessing)\n    const isLocalPending =\n      pendingCell &&\n      activeCell &&\n      pendingCell.recordId === activeCell.recordId &&\n      pendingCell.fieldId === activeCell.fieldId;\n\n    useImperativeHandle(ref, () => ({\n      onScrollHandler: () => {\n        setStyle(null);\n\n        if (scrollTimeoutRef.current) {\n          clearTimeout(scrollTimeoutRef.current);\n        }\n\n        scrollTimeoutRef.current = setTimeout(() => {\n          onPositionChanged();\n        }, 200);\n      },\n    }));\n\n    const record = activeCell?.rowIndex ? recordMap[activeCell.rowIndex] : undefined;\n\n    const onPositionChanged = useCallback(() => {\n      if (!activeCell || !permission['record|update']) {\n        return setStyle(null);\n      }\n\n      const { fieldId, columnIndex, rowIndex } = activeCell;\n\n      const field = fields.find((f) => f.id === fieldId);\n\n      if (\n        Record.isLocked(record?.permissions, fieldId) ||\n        Record.isHidden(record?.permissions, fieldId)\n      ) {\n        return setStyle(null);\n      }\n\n      if (!field?.aiConfig?.type) {\n        return setStyle(null);\n      }\n\n      const bounds = gridRef.current?.getCellBounds([columnIndex, rowIndex]);\n      if (bounds) {\n        const { x, y, width, height } = bounds;\n        setStyle({\n          left: x + width + 4,\n          top: y + (height - 32) / 2,\n        });\n      }\n    }, [activeCell, fields, gridRef, permission, record]);\n\n    useEffect(() => {\n      onPositionChanged();\n    }, [activeCell, onPositionChanged]);\n\n    useEffect(() => {\n      return () => {\n        if (scrollTimeoutRef.current) {\n          clearTimeout(scrollTimeoutRef.current);\n        }\n      };\n    }, []);\n\n    const onGenerate = () => {\n      if (!activeCell || isCellInTaskQueue(activeCell) || isLocalPending) return;\n\n      props.onGenerate?.();\n\n      // Set local pending state immediately\n      setPendingCell({\n        recordId: activeCell.recordId,\n        fieldId: activeCell.fieldId,\n      });\n      // Fire the API call\n      mutateGenerate({\n        recordId: activeCell.recordId,\n        fieldId: activeCell.fieldId,\n      });\n    };\n\n    // Hide button when cell is in task queue (star animation is showing)\n    if (!style || isCellInTaskQueue(activeCell)) return null;\n\n    return (\n      <div className=\"absolute z-50 rounded-lg border bg-background\" style={style}>\n        <Button variant=\"outline\" size=\"icon-sm\" onClick={onGenerate} disabled={!!isLocalPending}>\n          <RefreshCcw\n            className={isLocalPending ? 'size-4 shrink-0 animate-spin' : 'size-4 shrink-0'}\n          />\n        </Button>\n      </div>\n    );\n  }\n);\n\nAiGenerateButton.displayName = 'AiGenerateButton';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/ConfirmNewRecords.tsx",
    "content": "import { ConfirmDialog } from '@teable/ui-lib/base';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IConfirmNewRecordsProps {\n  onCancel: () => void;\n  onConfirm: () => void;\n}\n\nexport interface IConfirmNewRecordsRef {\n  setOpen: (val: boolean, count?: number) => void;\n}\n\nexport const ConfirmNewRecords = forwardRef<IConfirmNewRecordsRef, IConfirmNewRecordsProps>(\n  (props, ref) => {\n    const { onCancel, onConfirm } = props;\n    const [open, setOpen] = useState(false);\n    const [count, setCount] = useState(0);\n    const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n    useImperativeHandle(ref, () => ({\n      setOpen: (val: boolean, count?: number) => {\n        setOpen(val);\n        setCount(count ?? 0);\n      },\n    }));\n\n    return (\n      <ConfirmDialog\n        open={open}\n        closeable={false}\n        onOpenChange={setOpen}\n        title={t('table:pasteNewRecords.title')}\n        description={t('table:pasteNewRecords.description', { count })}\n        onCancel={() => {\n          onCancel();\n          setOpen(false);\n        }}\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.confirm')}\n        onConfirm={() => {\n          onConfirm();\n          setOpen(false);\n        }}\n      />\n    );\n  }\n);\n\nConfirmNewRecords.displayName = 'ConfirmNewRecords';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport { useMutation } from '@tanstack/react-query';\nimport type { IFilter, IGroup, ISort } from '@teable/core';\nimport { FieldType, getValidFilterOperators } from '@teable/core';\nimport {\n  Trash2,\n  Edit,\n  EyeOff,\n  ArrowLeft,\n  ArrowRight,\n  FreezeColumn,\n  Filter,\n  LayoutList,\n  ArrowUpDown,\n  Copy,\n  MagicAi,\n  Download,\n} from '@teable/icons';\nimport type { IDuplicateFieldRo } from '@teable/openapi';\nimport { duplicateField } from '@teable/openapi';\nimport type { GridView } from '@teable/sdk';\nimport {\n  useFieldPermission,\n  useFields,\n  useGridViewStore,\n  useIsTouchDevice,\n  usePersonalView,\n  useTableId,\n  useTablePermission,\n  useView,\n} from '@teable/sdk';\nimport { insertSingle } from '@teable/sdk/utils';\n\nimport {\n  cn,\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Sheet,\n  SheetContent,\n  SheetHeader,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useEffect, useRef, useState } from 'react';\nimport { useClickAway } from 'react-use';\nimport { useColumnDownloadDialogStore } from '@/features/app/components/download-attachments';\nimport { FieldDeleteConfirmDialog } from '@/features/app/components/field-setting/field-delete-confirm-dialog/FieldDeleteConfirmDialog';\nimport { FieldOperator } from '@/features/app/components/field-setting/type';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useFieldSettingStore } from '../../field/useFieldSettingStore';\nimport { useToolBarStore } from '../../tool-bar/components/useToolBarStore';\nimport { useViewConfigurable } from '../../tool-bar/hook/useViewConfigurable';\nimport type { IMenuItemProps } from './RecordMenu';\n\nenum MenuItemType {\n  Edit = 'Edit',\n  AutoFill = 'AutoFill',\n  Freeze = 'Freeze',\n  Hidden = 'Hidden',\n  Delete = 'Delete',\n  InsertLeft = 'InsertLeft',\n  InsertRight = 'InsertRight',\n  Sort = 'Sort',\n  Filter = 'Filter',\n  Group = 'Group',\n  Duplicate = 'Duplicate',\n  DownloadAllAttachments = 'DownloadAllAttachments',\n}\n\nconst iconClassName = 'mr-2 h-4 w-4';\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nexport const FieldMenu = () => {\n  const isTouchDevice = useIsTouchDevice();\n  const router = useRouter();\n  const view = useView() as GridView | undefined;\n  const { filter, sort, group } = view || {};\n  const tableId = useTableId();\n  const shareId = router.query.shareId as string | undefined;\n  const { headerMenu, closeHeaderMenu } = useGridViewStore();\n  const { isViewConfigurable } = useViewConfigurable();\n  const { openSetting } = useFieldSettingStore();\n  const permission = useTablePermission();\n  const menuFieldPermission = useFieldPermission();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const fieldSettingRef = useRef<HTMLDivElement>(null);\n  const { fields, aiEnable, onSelectionClear, onAutoFill } = headerMenu ?? {};\n  const { filterRef, sortRef, groupRef } = useToolBarStore();\n  const { personalViewCommonQuery, isPersonalView } = usePersonalView();\n  const isViewLocked = Boolean(view?.isLocked && !isPersonalView);\n  const emptyFieldMenu = !view || !fields?.length || !allFields.length;\n  const [deleteFieldDialog, setDeleteFieldDialog] = useState<{\n    open: boolean;\n    tableId?: string;\n    fieldIds?: string[];\n  }>({\n    open: false,\n  });\n  const { openDialog: openDownloadDialog } = useColumnDownloadDialogStore();\n\n  const { mutateAsync: duplicateFieldFn } = useMutation({\n    mutationFn: ({\n      tableId,\n      fieldId,\n      duplicateFieldRo,\n    }: {\n      tableId: string;\n      fieldId: string;\n      duplicateFieldRo: IDuplicateFieldRo;\n    }) => duplicateField(tableId, fieldId, duplicateFieldRo),\n  });\n\n  useClickAway(fieldSettingRef, () => {\n    closeHeaderMenu();\n  });\n\n  useEffect(() => {\n    if (emptyFieldMenu) {\n      setDeleteFieldDialog({ open: false });\n    }\n  }, [emptyFieldMenu]);\n\n  if (emptyFieldMenu) {\n    return null;\n  }\n\n  const fieldIds = fields.map((f) => f.id);\n\n  const visible = Boolean(headerMenu);\n  const position = headerMenu?.position;\n  const style = position\n    ? {\n        left: position.x,\n        top: position.y,\n      }\n    : {};\n\n  const insertField = async (isInsertAfter: boolean = true) => {\n    const fieldId = fieldIds[0];\n    const index = allFields.findIndex((f) => f.id === fieldId);\n\n    if (index === -1) return;\n\n    const newOrder = insertSingle(\n      index,\n      allFields.length,\n      (index: number) => {\n        return view.columnMeta[allFields[index].id].order;\n      },\n      isInsertAfter\n    );\n\n    return openSetting({\n      order: newOrder,\n      operator: FieldOperator.Insert,\n    });\n  };\n\n  const freezeField = async () => {\n    const fieldId = fieldIds[0];\n    if (!fieldId) return;\n    await view?.updateOption({ frozenFieldId: fieldId });\n  };\n\n  const handleDownloadAllAttachments = () => {\n    if (!tableId || !fields?.length) return;\n    const field = fields[0];\n\n    // For share view: use view's filter/sort/group directly (no personal view in share view)\n    // For normal view: use personalViewCommonQuery\n    const downloadQuery = shareId\n      ? view?.filter || view?.sort || view?.group\n        ? {\n            filter: view?.filter ?? undefined,\n            orderBy: view?.sort?.sortObjs ?? undefined,\n            groupBy: view?.group ?? undefined,\n          }\n        : undefined\n      : personalViewCommonQuery ?? undefined;\n\n    openDownloadDialog({\n      tableId,\n      fieldId: field.id,\n      fieldName: field.name,\n      viewId: view?.id,\n      shareId,\n      personalViewCommonQuery: downloadQuery,\n    });\n  };\n\n  const menuGroups: IMenuItemProps<MenuItemType>[][] = [\n    [\n      {\n        type: MenuItemType.Edit,\n        name: t('table:menu.editField'),\n        icon: <Edit className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !menuFieldPermission['field|update'],\n        onClick: async () => {\n          openSetting({\n            fieldId: fieldIds[0],\n            operator: FieldOperator.Edit,\n          });\n        },\n      },\n      {\n        type: MenuItemType.Duplicate,\n        name: t('table:menu.duplicateField'),\n        icon: <Copy className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !menuFieldPermission['field|update'],\n        onClick: async () => {\n          if (!tableId) return;\n          const fieldId = fieldIds[0];\n          const field = allFields.find((f) => f.id === fieldId);\n          const newName = `${field?.name} ${t('common:noun.copy')}`;\n          const toastId = toast.loading(t('table:import.menu.duplicating'));\n          try {\n            await duplicateFieldFn({\n              tableId,\n              fieldId: fieldIds[0],\n              duplicateFieldRo: {\n                name: newName,\n                viewId: view.id,\n              },\n            });\n            toast.success(t('table:import.menu.duplicateSuccess'), { id: toastId });\n          } catch {\n            toast.error(t('table:import.menu.duplicateFailed'), { id: toastId });\n          }\n        },\n      },\n    ],\n    [\n      {\n        type: MenuItemType.AutoFill,\n        name: t('table:menu.autoFill'),\n        icon: <MagicAi className={iconClassName} />,\n        hidden:\n          !aiEnable || !fields[0].aiConfig || fieldIds.length !== 1 || !permission['record|update'],\n        onClick: async () => {\n          onAutoFill?.(fieldIds[0]);\n        },\n      },\n      {\n        type: MenuItemType.DownloadAllAttachments,\n        name: t('table:menu.downloadAllAttachments'),\n        icon: <Download className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || fields[0]?.type !== FieldType.Attachment,\n        onClick: handleDownloadAllAttachments,\n      },\n    ],\n    [\n      {\n        type: MenuItemType.InsertLeft,\n        name: t('table:menu.insertFieldLeft'),\n        icon: <ArrowLeft className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !permission['field|create'],\n        onClick: async () => await insertField(false),\n      },\n      {\n        type: MenuItemType.InsertRight,\n        name: t('table:menu.insertFieldRight'),\n        icon: <ArrowRight className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !permission['field|create'],\n        onClick: async () => await insertField(),\n      },\n    ],\n    [\n      {\n        type: MenuItemType.Filter,\n        name: t('table:menu.filterField'),\n        icon: <Filter className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !isViewConfigurable,\n        disabled: isViewLocked,\n        onClick: async () => {\n          if (!headerMenu) {\n            return;\n          }\n          const { fields } = headerMenu;\n          const field = fields.at(0);\n          if (!field) {\n            return;\n          }\n          const { id: fieldId } = field;\n          const newItem = {\n            fieldId,\n            operator: getValidFilterOperators(field)?.[0] || null,\n            value: null,\n          };\n          let newFilter = {\n            conjunction: 'and',\n            filterSet: [newItem],\n          } as IFilter;\n          if (filter) {\n            newFilter = {\n              ...filter,\n              filterSet: [...filter.filterSet, newItem],\n            };\n          }\n          await view.updateFilter(newFilter);\n          filterRef?.current?.click();\n        },\n      },\n      {\n        type: MenuItemType.Sort,\n        name: t('table:menu.sortField'),\n        icon: <ArrowUpDown className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !isViewConfigurable,\n        disabled: isViewLocked,\n        onClick: async () => {\n          if (!headerMenu) {\n            return;\n          }\n          const { fields } = headerMenu;\n          const field = fields.at(0);\n          if (!field) {\n            return;\n          }\n          const { id: fieldId } = field;\n          const newSortItem = {\n            fieldId,\n            order: 'asc',\n          };\n          let newSort = {\n            sortObjs: [newSortItem],\n          };\n          let shouldUpdate = true;\n          if (sort) {\n            const index = sort.sortObjs.findIndex((f) => f.fieldId === fieldId);\n            if (index > -1) {\n              shouldUpdate = false;\n            }\n            newSort = {\n              ...sort,\n              sortObjs: [...sort.sortObjs, newSortItem],\n            };\n          }\n          shouldUpdate && (await view?.updateSort(newSort as ISort));\n          sortRef?.current?.click();\n        },\n      },\n      {\n        type: MenuItemType.Group,\n        name: t('table:menu.groupField'),\n        icon: <LayoutList className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !isViewConfigurable,\n        disabled: isViewLocked,\n        onClick: async () => {\n          if (!headerMenu) {\n            return;\n          }\n          const { fields } = headerMenu;\n          const field = fields.at(0);\n          if (!field) {\n            return;\n          }\n          const { id: fieldId } = field;\n          const newGroupItem = {\n            fieldId,\n            order: 'asc',\n          };\n          let newGroup = [newGroupItem];\n          let shouldUpdate = true;\n          if (group) {\n            const index = group.findIndex((f) => f.fieldId === fieldId);\n            if (index > -1) {\n              shouldUpdate = false;\n            }\n            newGroup = [...group, newGroupItem];\n          }\n          shouldUpdate && (await view.updateGroup(newGroup as IGroup));\n          groupRef?.current?.click();\n        },\n      },\n    ],\n    [\n      {\n        type: MenuItemType.Freeze,\n        name: t('table:menu.freezeUpField'),\n        icon: <FreezeColumn className={iconClassName} />,\n        hidden: fieldIds.length !== 1 || !isViewConfigurable,\n        disabled: isViewLocked,\n        onClick: async () => await freezeField(),\n      },\n    ],\n    [\n      {\n        type: MenuItemType.Hidden,\n        name: t('table:menu.hideField'),\n        icon: <EyeOff className={iconClassName} />,\n        hidden: !isViewConfigurable,\n        disabled: fields.some((f) => f.isPrimary) || isViewLocked,\n        onClick: async () => {\n          const fieldIdsSet = new Set(fieldIds);\n          const filteredFields = allFields.filter((f) => fieldIdsSet.has(f.id)).filter(Boolean);\n          if (filteredFields.length === 0) return;\n          await view.updateColumnMeta(\n            filteredFields.map((field) => ({ fieldId: field.id, columnMeta: { hidden: true } }))\n          );\n        },\n      },\n      {\n        type: MenuItemType.Delete,\n        name:\n          fieldIds.length > 1\n            ? t('table:menu.deleteAllSelectedFields')\n            : t('table:menu.deleteField'),\n        icon: <Trash2 className={iconClassName} />,\n        hidden: !menuFieldPermission['field|delete'],\n        disabled: fields.some((f) => f.isPrimary),\n        className: 'text-red-500 aria-selected:text-red-500',\n        onClick: async () => {\n          if (!tableId) return;\n          const fieldIdsSet = new Set(fieldIds);\n          const filteredFields = allFields.filter((f) => fieldIdsSet.has(f.id)).filter(Boolean);\n          if (filteredFields.length === 0) return;\n\n          setDeleteFieldDialog({\n            open: true,\n            tableId,\n            fieldIds: filteredFields.map((f) => f.id),\n          });\n        },\n      },\n    ],\n  ]\n    .map((items) => items.filter(({ hidden }) => !hidden))\n    .filter((items) => items.length);\n\n  if (menuGroups.length === 0) {\n    return;\n  }\n  return (\n    <>\n      {isTouchDevice ? (\n        <Sheet open={visible} onOpenChange={(open) => !open && closeHeaderMenu()}>\n          <SheetContent className=\"h-5/6 rounded-t-lg py-0\" side=\"bottom\">\n            <SheetHeader className=\"h-16 justify-center border-b text-2xl\">\n              {allFields.find((f) => f.id === fieldIds[0])?.name ?? 'Untitled'}\n            </SheetHeader>\n            {menuGroups.flat().map(({ type, name, icon, disabled, className, onClick }) => {\n              return (\n                <div\n                  className={cn('flex w-full items-center border-b py-3', className, {\n                    'cursor-not-allowed': disabled,\n                    'opacity-50': disabled,\n                  })}\n                  key={type}\n                  onClick={async () => {\n                    if (disabled) return;\n\n                    await onClick();\n                    // Don't auto-close menu for delete action\n                    if (type !== MenuItemType.Delete) {\n                      onSelectionClear?.();\n                      closeHeaderMenu();\n                    }\n                  }}\n                >\n                  {icon}\n                  {name}\n                </div>\n              );\n            })}\n          </SheetContent>\n        </Sheet>\n      ) : (\n        <Popover open={visible}>\n          <PopoverTrigger asChild style={style} className=\"absolute\">\n            <div className=\"size-0 opacity-0\" />\n          </PopoverTrigger>\n          <PopoverContent className=\"h-auto w-60 rounded-md p-0\" align=\"start\">\n            <Command\n              ref={fieldSettingRef}\n              className=\"rounded-md border-none shadow-none\"\n              style={style}\n            >\n              <CommandList className=\"max-h-[calc(100vh-260px)]\">\n                {menuGroups.map((items, index) => {\n                  const nextItems = menuGroups[index + 1] ?? [];\n                  if (!items.length) return null;\n\n                  return (\n                    <Fragment key={index}>\n                      <CommandGroup aria-valuetext=\"name\">\n                        {items.map(({ type, name, icon, disabled, className, onClick }) => (\n                          <CommandItem\n                            className={cn('px-4 py-2', className, {\n                              'cursor-not-allowed': disabled,\n                              'opacity-50': disabled,\n                            })}\n                            key={type}\n                            value={name}\n                            onSelect={async () => {\n                              if (disabled) {\n                                return;\n                              }\n                              await onClick();\n                              // Don't auto-close menu for delete action\n                              if (type !== MenuItemType.Delete) {\n                                onSelectionClear?.();\n                                closeHeaderMenu();\n                              }\n                            }}\n                          >\n                            {icon}\n                            {name}\n                          </CommandItem>\n                        ))}\n                      </CommandGroup>\n                      {nextItems.length > 0 && <CommandSeparator />}\n                    </Fragment>\n                  );\n                })}\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      )}\n\n      <FieldDeleteConfirmDialog\n        tableId={deleteFieldDialog.tableId ?? ''}\n        fieldIds={deleteFieldDialog.fieldIds ?? []}\n        open={deleteFieldDialog.open}\n        onClose={() => {\n          setDeleteFieldDialog({ open: false });\n          onSelectionClear?.();\n          closeHeaderMenu();\n        }}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/GroupHeaderMenu.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport { Expand, ExpandAll } from '@teable/icons';\nimport type { GridView } from '@teable/sdk';\nimport {\n  generateLocalId,\n  useGridCollapsedGroup,\n  useGridViewStore,\n  useIsTouchDevice,\n  useTableId,\n  useView,\n} from '@teable/sdk';\nimport {\n  cn,\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Sheet,\n  SheetContent,\n  SheetHeader,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useRef } from 'react';\nimport { useClickAway } from 'react-use';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IMenuItemProps } from './RecordMenu';\n\nenum MenuItemType {\n  ToggleCollapse = 'ToggleCollapse',\n  ExpandAll = 'ExpandAll',\n  CollapseAll = 'CollapseAll',\n}\n\nconst iconClassName = 'mr-2 h-4 w-4';\n\nexport const GroupHeaderMenu = () => {\n  const isTouchDevice = useIsTouchDevice();\n  const tableId = useTableId();\n  const view = useView() as GridView | undefined;\n  const { groupHeaderMenu, closeGroupHeaderMenu } = useGridViewStore();\n  const { collapsedGroupIds, onCollapsedGroupChanged } = useGridCollapsedGroup(\n    generateLocalId(tableId, view?.id)\n  );\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const fieldSettingRef = useRef<HTMLDivElement>(null);\n  const { groupId, allGroupHeaderRefs } = groupHeaderMenu ?? {};\n\n  useClickAway(fieldSettingRef, () => {\n    closeGroupHeaderMenu();\n  });\n\n  if (!view || !groupId) return null;\n\n  const visible = Boolean(groupHeaderMenu);\n  const position = groupHeaderMenu?.position;\n  const style = position\n    ? {\n        left: position.x,\n        top: position.y,\n      }\n    : {};\n  const isGroupCollapsed = collapsedGroupIds?.has(groupId);\n\n  const menuGroups: IMenuItemProps<MenuItemType>[][] = [\n    [\n      {\n        type: MenuItemType.ToggleCollapse,\n        name: isGroupCollapsed ? t('table:menu.expandGroup') : t('table:menu.collapseGroup'),\n        icon: <Expand className={cn(iconClassName, !isGroupCollapsed && '-rotate-90')} />,\n        hidden: false,\n        onClick: async () => {\n          if (!allGroupHeaderRefs) return;\n\n          let groupDepth = -1;\n          const subGroupIdSet = new Set<string>();\n\n          for (const groupHeaderRef of allGroupHeaderRefs) {\n            if (groupDepth !== -1 && groupHeaderRef.depth <= groupDepth) break;\n            if (groupHeaderRef.id === groupId) {\n              groupDepth = groupHeaderRef.depth;\n            }\n            if (groupDepth !== -1 && groupHeaderRef.depth > groupDepth) {\n              subGroupIdSet.add(groupHeaderRef.id);\n            }\n          }\n\n          const newCollapsedGroupIds = new Set(collapsedGroupIds);\n          const needChangingGroupIds = [groupId, ...subGroupIdSet];\n\n          needChangingGroupIds.forEach((id) =>\n            isGroupCollapsed ? newCollapsedGroupIds.delete(id) : newCollapsedGroupIds.add(id)\n          );\n          onCollapsedGroupChanged?.(newCollapsedGroupIds);\n        },\n      },\n      {\n        type: MenuItemType.ExpandAll,\n        name: t('table:menu.expandAllGroups'),\n        icon: <ExpandAll className={iconClassName} />,\n        hidden: false,\n        onClick: async () => {\n          if (!allGroupHeaderRefs) return;\n\n          onCollapsedGroupChanged?.(new Set());\n        },\n      },\n      {\n        type: MenuItemType.CollapseAll,\n        name: t('table:menu.collapseAllGroups'),\n        icon: <ExpandAll className={cn(iconClassName, '-rotate-90')} />,\n        hidden: false,\n        onClick: async () => {\n          if (!allGroupHeaderRefs) return;\n\n          const allGroupHeaderIds = allGroupHeaderRefs.map((groupHeaderRef) => groupHeaderRef.id);\n          onCollapsedGroupChanged?.(new Set(allGroupHeaderIds));\n        },\n      },\n    ],\n  ]\n    .map((items) => items.filter(({ hidden }) => !hidden))\n    .filter((items) => items.length);\n\n  return (\n    <>\n      {isTouchDevice ? (\n        <Sheet open={visible} onOpenChange={(open) => !open && closeGroupHeaderMenu()}>\n          <SheetContent className=\"h-5/6 rounded-t-lg py-0\" side=\"bottom\">\n            <SheetHeader className=\"h-16 justify-center border-b text-2xl\">\n              {t('table:menu.groupMenuTitle')}\n            </SheetHeader>\n            {menuGroups.flat().map(({ type, name, icon, disabled, className, onClick }) => {\n              return (\n                <div\n                  className={cn('flex w-full items-center border-b py-3', className, {\n                    'cursor-not-allowed': disabled,\n                    'opacity-50': disabled,\n                  })}\n                  key={type}\n                  onClick={async () => {\n                    if (disabled) return;\n                    await onClick();\n                    closeGroupHeaderMenu();\n                  }}\n                >\n                  {icon}\n                  {name}\n                </div>\n              );\n            })}\n          </SheetContent>\n        </Sheet>\n      ) : (\n        <Popover open={visible}>\n          <PopoverTrigger asChild style={style} className=\"absolute\">\n            <div className=\"size-0 opacity-0\" />\n          </PopoverTrigger>\n          <PopoverContent className=\"h-auto w-60 rounded-md p-0\" align=\"start\">\n            <Command\n              ref={fieldSettingRef}\n              className=\"rounded-md border-none shadow-none\"\n              style={style}\n            >\n              <CommandList className=\"max-h-[410px]\">\n                {menuGroups.map((items, index) => {\n                  const nextItems = menuGroups[index + 1] ?? [];\n                  if (!items.length) return null;\n\n                  return (\n                    <Fragment key={index}>\n                      <CommandGroup aria-valuetext=\"name\">\n                        {items.map(({ type, name, icon, disabled, className, onClick }) => (\n                          <CommandItem\n                            className={cn('px-4 py-2', className, {\n                              'cursor-not-allowed': disabled,\n                              'opacity-50': disabled,\n                            })}\n                            key={type}\n                            value={name}\n                            onSelect={async () => {\n                              if (disabled) return;\n                              await onClick();\n                              closeGroupHeaderMenu();\n                            }}\n                          >\n                            {icon}\n                            {name}\n                          </CommandItem>\n                        ))}\n                      </CommandGroup>\n                      {nextItems.length > 0 && <CommandSeparator />}\n                    </Fragment>\n                  );\n                })}\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/PluginMenu.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { ChevronRight, Puzzle, Settings } from '@teable/icons';\nimport { getPluginContextMenuList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  cn,\n  CommandGroup,\n  CommandItem,\n  CommandSeparator,\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n  ScrollArea,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useRef } from 'react';\nimport {\n  PluginContextMenuManageDialog,\n  type IPluginContextMenuManageDialogRef,\n} from '@/features/app/components/plugin-context-menu/PluginContextMenuManageDialog';\nimport { useActiveMenuPluginStore } from '@/features/app/components/plugin-context-menu/useActiveMenuPlugin';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const PluginMenu = (props: { tableId?: string; closeRecordMenu: () => void }) => {\n  const { tableId, closeRecordMenu } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { setActivePluginId } = useActiveMenuPluginStore();\n  const { data: pluginContextMenu } = useQuery({\n    queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId!),\n    queryFn: ({ queryKey }) => getPluginContextMenuList(queryKey[1]).then((res) => res.data),\n    enabled: !!tableId,\n  });\n  const pluginContextMenuManageDialogRef = useRef<IPluginContextMenuManageDialogRef>(null);\n  const menuItems = pluginContextMenu?.slice(0, 3);\n  const hasMore = pluginContextMenu && pluginContextMenu.length > 3;\n\n  return (\n    <Fragment>\n      <CommandGroup\n        aria-valuetext=\"name\"\n        heading={\n          <div className=\"flex items-center justify-between gap-2\">\n            <div className=\"flex items-center gap-1\">\n              <Puzzle className=\"shrink-0\" />\n              {t('common:noun.plugin')}\n            </div>\n            <HoverCard openDelay={100}>\n              <HoverCardTrigger>\n                <Button\n                  variant=\"link\"\n                  size={'xs'}\n                  className={cn('h-auto font-normal text-muted-foreground gap-0 p-0', {\n                    hidden: !hasMore,\n                  })}\n                >\n                  {t('common:actions.more')}\n                  <ChevronRight className=\"size-4 shrink-0\" />\n                </Button>\n              </HoverCardTrigger>\n              <HoverCardContent\n                side=\"right\"\n                align=\"start\"\n                sideOffset={10}\n                className=\"p-0\"\n                onMouseDown={(e) => e.stopPropagation()}\n              >\n                <ScrollArea\n                  className={cn({\n                    'h-40': pluginContextMenu?.length && pluginContextMenu.length > 5,\n                  })}\n                >\n                  <div className=\"flex flex-col py-1\">\n                    {!pluginContextMenu?.length && (\n                      <div className=\"flex items-center justify-center py-2 text-[13px] text-muted-foreground\">\n                        {t('table:pluginContextMenu.noPlugin')}\n                      </div>\n                    )}\n                    {pluginContextMenu?.map(({ pluginInstallId, name, logo }) => {\n                      return (\n                        <Button\n                          variant=\"ghost\"\n                          className=\"mx-1 h-9 justify-start gap-2 px-4 text-sm font-normal\"\n                          key={pluginInstallId}\n                          onClick={async () => {\n                            closeRecordMenu();\n                            setActivePluginId(pluginInstallId);\n                          }}\n                        >\n                          <img\n                            className=\"size-4 shrink-0 rounded-sm object-contain\"\n                            src={logo}\n                            alt={name}\n                          />\n                          {name}\n                        </Button>\n                      );\n                    })}\n                  </div>\n                </ScrollArea>\n              </HoverCardContent>\n            </HoverCard>\n          </div>\n        }\n      >\n        {menuItems?.map(({ pluginInstallId, name, logo }) => (\n          <CommandItem\n            className=\"h-9 justify-start gap-2 px-4 text-sm font-normal\"\n            key={pluginInstallId}\n            value={pluginInstallId}\n            onSelect={async () => {\n              closeRecordMenu();\n              setActivePluginId(pluginInstallId);\n            }}\n            onMouseDown={(e) => e.stopPropagation()}\n          >\n            <img className=\"size-4 shrink-0 rounded-sm object-contain\" src={logo} alt={name} />\n            {name}\n          </CommandItem>\n        ))}\n        <CommandItem\n          className=\"h-9 justify-start gap-2 px-4 text-sm font-normal\"\n          onSelect={async () => {\n            pluginContextMenuManageDialogRef.current?.open();\n          }}\n          onMouseDown={(e) => e.stopPropagation()}\n        >\n          <Settings className=\"size-4 shrink-0\" />\n          {t('table:pluginContextMenu.mangeButton')}\n        </CommandItem>\n      </CommandGroup>\n\n      <CommandSeparator />\n      {tableId && (\n        <PluginContextMenuManageDialog tableId={tableId} ref={pluginContextMenuManageDialogRef} />\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/PrefillingRowContainer.tsx",
    "content": "import { HelpCircle, Plus } from '@teable/icons';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef } from 'react';\nimport { useClickAway } from 'react-use';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IPrefillingRowContainerProps {\n  style?: React.CSSProperties;\n  children: React.ReactNode;\n  isLoading?: boolean;\n  onCancel?: () => void;\n  onClickOutside?: () => void;\n}\n\nexport const PrefillingRowContainer = (props: IPrefillingRowContainerProps) => {\n  const { style, children, isLoading, onCancel, onClickOutside } = props;\n  const prefillingGridContainerRef = useRef<HTMLDivElement>(null);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  useClickAway(prefillingGridContainerRef, () => {\n    onClickOutside?.();\n  });\n\n  return (\n    <div\n      ref={prefillingGridContainerRef}\n      className=\"absolute left-0 w-full border-y-2 border-violet-500 dark:border-violet-700\"\n      style={style}\n    >\n      <div className=\"absolute left-0 top-[-32px] flex h-8 items-center rounded-ss-lg bg-violet-500 px-2 py-1 text-background dark:border-violet-700\">\n        {isLoading ? <Spin className=\"mr-1 size-4\" /> : <Plus className=\"mr-1\" />}\n        <span className=\"text-[13px]\">{t('table:grid.prefillingRowTitle')}</span>\n        <TooltipProvider>\n          <Tooltip delayDuration={200}>\n            <TooltipTrigger asChild>\n              <span>\n                <HelpCircle className=\"ml-1\" />\n              </span>\n            </TooltipTrigger>\n            <TooltipContent sideOffset={8}>{t('table:grid.prefillingRowTooltip')}</TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n        <Button\n          size=\"xs\"\n          variant=\"secondary\"\n          onClick={() => onCancel?.()}\n          className=\"ml-2 h-5 rounded-sm\"\n        >\n          {t('actions.cancel')}\n        </Button>\n      </div>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/PresortRowContainer.tsx",
    "content": "import { ArrowUpDown } from '@teable/icons';\nimport { useTranslation } from 'next-i18next';\nimport { useRef } from 'react';\nimport { useClickAway, useMount } from 'react-use';\nimport { tableConfig } from '@/features/i18n/table.config';\ninterface IRowStatusContainerProps {\n  children: React.ReactNode;\n  style?: React.CSSProperties;\n  onInit?: () => void;\n  onClickOutside?: () => void;\n}\n\nexport const PresortRowContainer = (props: IRowStatusContainerProps) => {\n  const { style, children, onClickOutside, onInit } = props;\n  const prefillingGridContainerRef = useRef<HTMLDivElement>(null);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  useClickAway(prefillingGridContainerRef, () => {\n    onClickOutside?.();\n  });\n\n  useMount(() => {\n    onInit?.();\n  });\n\n  return (\n    <div\n      ref={prefillingGridContainerRef}\n      className=\"absolute left-0 w-full border-y-2 border-violet-500 dark:border-violet-700\"\n      style={style}\n    >\n      <div className=\"absolute left-0 top-[-32px] flex h-8 items-center rounded-ss-lg bg-violet-500 px-2 py-1 text-background dark:border-violet-700\">\n        <ArrowUpDown className=\"mr-1\" />\n        <span className=\"text-[13px]\">{t('table:grid.presortRowTitle')}</span>\n      </div>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx",
    "content": "import {\n  History,\n  Trash2,\n  ArrowUp,\n  ArrowDown,\n  Copy,\n  Link,\n  MessageSquare,\n  MessageSquareDot,\n} from '@teable/icons';\nimport { useGridViewStore } from '@teable/sdk/components';\nimport { useBaseId, useTableId, useTablePermission, useView } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n  Input,\n  Button,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { noop } from 'lodash';\nimport { useTranslation, Trans } from 'next-i18next';\nimport { Fragment, useCallback, useRef, useState } from 'react';\nimport { useClickAway } from 'react-use';\nimport { useAI } from '@/features/app/hooks/useAI';\nimport { useBaseUsage } from '@/features/app/hooks/useBaseUsage';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport interface IMenuItemProps<T> {\n  type: T;\n  name: string;\n  icon: React.ReactNode;\n  hidden?: boolean;\n  disabled?: boolean;\n  className?: string;\n  render?: React.ReactNode;\n  onClick: () => void;\n}\n\ninterface InsertRecordRender {\n  onClick: (num: number) => void;\n  icon: React.ReactElement;\n  type: MenuItemType.InsertAbove | MenuItemType.InsertBelow;\n}\n\nenum MenuItemType {\n  Copy = 'Copy',\n  CopyLink = 'CopyLink',\n  Delete = 'Delete',\n  InsertAbove = 'InsertAbove',\n  InsertBelow = 'InsertBelow',\n  Duplicate = 'Duplicate',\n  ViewHistory = 'ViewHistory',\n  AddComment = 'AddComment',\n  AddToChat = 'AddToChat',\n}\n\nconst iconClassName = 'mr-2 h-4 w-4 shrink-0';\n\nconst InsertRecordRender = (props: InsertRecordRender) => {\n  const { onClick, icon, type } = props;\n  const [num, setNumber] = useState(1);\n  const i18nKey =\n    type === MenuItemType.InsertAbove\n      ? 'table:menu.insertRecordAbove'\n      : 'table:menu.insertRecordBelow';\n  return (\n    <Button\n      variant={'ghost'}\n      size=\"sm\"\n      className=\"size-full h-9 justify-start gap-0 px-4 py-2\"\n      onClick={() => {\n        onClick(num);\n      }}\n    >\n      {icon}\n      <div className=\"flex flex-1 items-center text-sm\">\n        <Trans\n          ns={tableConfig.i18nNamespaces}\n          i18nKey={i18nKey}\n          components={{\n            input: (\n              <Input\n                className=\"mx-1 h-6 w-14\"\n                defaultValue={1}\n                onKeyDown={(e) => {\n                  e.stopPropagation();\n                }}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  e.preventDefault();\n                }}\n                onChange={(e) => {\n                  e.stopPropagation();\n                  e.preventDefault();\n                  const originValue = Math.abs(Math.round(Number(e.target.value)));\n                  const newValue = isNaN(originValue) ? 1 : originValue;\n                  if (originValue > 1000) {\n                    e.target.value = '1000';\n                    setNumber(1000);\n                    return;\n                  }\n                  setNumber(newValue);\n                }}\n              />\n            ),\n          }}\n        />\n      </div>\n    </Button>\n  );\n};\n\nexport const RecordMenu = () => {\n  const { recordMenu, closeRecordMenu } = useGridViewStore();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const tableId = useTableId();\n  const baseId = useBaseId();\n  const view = useView();\n  const viewId = view?.id;\n  const permission = useTablePermission();\n  const recordMenuRef = useRef<HTMLDivElement>(null);\n  const { enable: aiEnable } = useAI();\n  const usage = useBaseUsage({ disabled: !baseId });\n  const chatEnabled = Boolean(aiEnable && usage?.limit?.chatAIEnable);\n\n  useClickAway(recordMenuRef, () => {\n    closeRecordMenu();\n  });\n\n  const insertRecordFn = useCallback(\n    (num: number, position: 'before' | 'after') => {\n      if (!recordMenu) {\n        return null;\n      }\n      const { record, insertRecord } = recordMenu;\n      if (!tableId || !viewId || !record) return;\n      insertRecord?.(record.id, position, num);\n    },\n    [recordMenu, tableId, viewId]\n  );\n\n  if (recordMenu == null) return null;\n\n  const { record, isMultipleSelected } = recordMenu;\n\n  if (!record && !isMultipleSelected) return null;\n\n  const visible = Boolean(recordMenu);\n  const position = recordMenu?.position;\n  const isAutoSort = Boolean(view?.sort && !view.sort?.manualSort);\n  const style = position\n    ? {\n        left: position.x,\n        top: position.y,\n      }\n    : {};\n\n  const menuItemGroups: IMenuItemProps<MenuItemType>[][] = [\n    [\n      {\n        type: MenuItemType.InsertAbove,\n        name: t('table:menu.insertRecordAbove'),\n        icon: <ArrowUp className={iconClassName} />,\n        hidden: isMultipleSelected || !permission['record|create'],\n        disabled: isAutoSort,\n        render: (\n          <InsertRecordRender\n            onClick={(num: number) => insertRecordFn(num, 'before')}\n            icon={<ArrowUp className={iconClassName} />}\n            type={MenuItemType.InsertAbove}\n          />\n        ),\n        onClick: async () => {\n          noop();\n        },\n      },\n      {\n        type: MenuItemType.InsertBelow,\n        name: t('table:menu.insertRecordBelow'),\n        icon: <ArrowDown className={iconClassName} />,\n        hidden: isMultipleSelected || !permission['record|create'],\n        disabled: isAutoSort,\n        render: (\n          <InsertRecordRender\n            onClick={(num: number) => insertRecordFn(num, 'after')}\n            icon={<ArrowDown className={iconClassName} />}\n            type={MenuItemType.InsertBelow}\n          />\n        ),\n        onClick: async () => {\n          noop();\n        },\n      },\n    ],\n    [\n      {\n        type: MenuItemType.Duplicate,\n        name: t('sdk:expandRecord.duplicateRecord'),\n        icon: <Copy className={iconClassName} />,\n        hidden: isMultipleSelected || !permission['record|create'],\n        onClick: async () => {\n          if (tableId && recordMenu?.duplicateRecord) {\n            await recordMenu.duplicateRecord();\n          }\n        },\n      },\n      {\n        type: MenuItemType.CopyLink,\n        name: t('sdk:expandRecord.copyRecordUrl'),\n        icon: <Link className={iconClassName} />,\n        hidden: isMultipleSelected,\n        onClick: async () => {\n          if (tableId && recordMenu?.copyRecordUrl) {\n            await recordMenu.copyRecordUrl();\n          }\n        },\n      },\n    ],\n    [\n      {\n        type: MenuItemType.ViewHistory,\n        name: t('sdk:expandRecord.recordHistory.showRecordHistory'),\n        icon: <History className={iconClassName} />,\n        hidden: isMultipleSelected || !permission['record|update'],\n        onClick: async () => {\n          if (tableId && recordMenu?.viewRecordHistory) {\n            await recordMenu.viewRecordHistory();\n          }\n        },\n      },\n      {\n        type: MenuItemType.AddComment,\n        name: t('sdk:expandRecord.addRecordComment'),\n        icon: <MessageSquare className={iconClassName} />,\n        hidden: isMultipleSelected || !permission['record|comment'],\n        onClick: async () => {\n          if (tableId && recordMenu?.addRecordComment) {\n            await recordMenu.addRecordComment();\n          }\n        },\n      },\n      {\n        type: MenuItemType.AddToChat,\n        name: t('table:menu.addToChat'),\n        icon: <MessageSquareDot className={iconClassName} />,\n        hidden: !chatEnabled || !recordMenu?.addToChat,\n        onClick: () => {\n          recordMenu?.addToChat?.();\n        },\n      },\n    ],\n    [],\n    [\n      {\n        type: MenuItemType.Delete,\n        name: isMultipleSelected\n          ? t('table:menu.deleteAllSelectedRecords')\n          : t('table:menu.deleteRecord'),\n        icon: <Trash2 className={iconClassName} />,\n        hidden: !permission['record|delete'] || record?.undeletable,\n        className: 'text-red-500 aria-selected:text-red-500',\n        onClick: async () => {\n          if (recordMenu && tableId && recordMenu.deleteRecords) {\n            await recordMenu.deleteRecords();\n          }\n        },\n      },\n    ],\n  ].map((items) => (items as IMenuItemProps<MenuItemType>[]).filter(({ hidden }) => !hidden));\n\n  if (menuItemGroups.every((menuItemGroup) => menuItemGroup.length === 0)) {\n    return null;\n  }\n\n  return (\n    <>\n      <Popover open={visible}>\n        <PopoverTrigger asChild style={style} className=\"absolute\">\n          <div className=\"size-0 opacity-0\" />\n        </PopoverTrigger>\n        <PopoverContent\n          className=\"size-auto min-w-40 rounded-md p-0\"\n          align=\"start\"\n          onPointerDown={(e) => e.stopPropagation()}\n        >\n          <Command ref={recordMenuRef} className=\"rounded-md border-none shadow-none\" style={style}>\n            <CommandList className=\"max-h-96\">\n              {menuItemGroups.map((items, index) => {\n                const nextItems = menuItemGroups[index + 1] ?? [];\n                const hasNextItems = nextItems.length > 0;\n                if (!items.length) return null;\n\n                return (\n                  <Fragment key={index}>\n                    <CommandGroup aria-valuetext=\"name\">\n                      {items.map(({ type, name, icon, className, disabled, onClick, render }) => {\n                        return (\n                          <CommandItem\n                            className={cn('px-4 py-2', className, {\n                              'px-0 py-0': [\n                                MenuItemType.InsertBelow,\n                                MenuItemType.InsertAbove,\n                              ].includes(type),\n                            })}\n                            key={type}\n                            value={name}\n                            onSelect={async () => {\n                              if (disabled) {\n                                return;\n                              }\n                              await onClick();\n                              closeRecordMenu();\n                            }}\n                          >\n                            {disabled ? (\n                              <TooltipProvider>\n                                <Tooltip>\n                                  <TooltipTrigger\n                                    className={cn('flex items-center gap-2', {\n                                      'opacity-50': disabled,\n                                    })}\n                                  >\n                                    <div className=\"pointer-events-none\">\n                                      {render ? (\n                                        render\n                                      ) : (\n                                        <>\n                                          {icon}\n                                          {name}\n                                        </>\n                                      )}\n                                    </div>\n                                  </TooltipTrigger>\n                                  <TooltipContent hideWhenDetached={true}>\n                                    {t('table:view.insertToolTip')}\n                                  </TooltipContent>\n                                </Tooltip>\n                              </TooltipProvider>\n                            ) : (\n                              <>\n                                {render ? (\n                                  render\n                                ) : (\n                                  <>\n                                    {icon}\n                                    {name}\n                                  </>\n                                )}\n                              </>\n                            )}\n                          </CommandItem>\n                        );\n                      })}\n                    </CommandGroup>\n                    {hasNextItems && <CommandSeparator />}\n                  </Fragment>\n                );\n              })}\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/ResetClickCountButton.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport { buttonReset } from '@teable/openapi';\nimport { Record, useFields, useTablePermission } from '@teable/sdk';\nimport type { IActiveCell, IGridRef, IRecordIndexMap } from '@teable/sdk';\nimport { Button, sonner } from '@teable/ui-lib';\nimport { RotateCcwIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport React, { useCallback, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nconst { toast } = sonner;\n\ninterface IResetClickCountButtonProps {\n  gridRef: React.RefObject<IGridRef>;\n  activeCell?: IActiveCell;\n  recordMap: IRecordIndexMap;\n}\n\nexport const ResetClickCountButton = forwardRef<\n  { onScrollHandler: () => void },\n  IResetClickCountButtonProps\n>((props, ref) => {\n  const { gridRef, activeCell, recordMap } = props;\n  const fields = useFields();\n  const permission = useTablePermission();\n  const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n  const [style, setStyle] = React.useState<React.CSSProperties | null>(null);\n  const record = activeCell?.rowIndex !== undefined ? recordMap[activeCell.rowIndex] : undefined;\n  const { fieldId } = activeCell || {};\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const field = fields.find((f) => f.id === fieldId);\n\n  const onPositionChanged = useCallback(() => {\n    if (!activeCell || !permission['record|update']) {\n      return setStyle(null);\n    }\n\n    const { fieldId, columnIndex, rowIndex } = activeCell;\n    if (!field || field.type !== FieldType.Button) {\n      return setStyle(null);\n    }\n\n    if (!field.options?.resetCount) {\n      return setStyle(null);\n    }\n\n    if (\n      Record.isLocked(record?.permissions, fieldId) ||\n      Record.isHidden(record?.permissions, fieldId)\n    ) {\n      return setStyle(null);\n    }\n\n    const bounds = gridRef.current?.getCellBounds([columnIndex, rowIndex]);\n    if (bounds) {\n      const { x, y, width, height } = bounds;\n      setStyle({\n        left: x + width + 4,\n        top: y + (height - 32) / 2,\n      });\n    }\n  }, [activeCell, gridRef, permission, record, field]);\n\n  useEffect(() => {\n    onPositionChanged();\n  }, [activeCell, onPositionChanged]);\n\n  useImperativeHandle(ref, () => ({\n    onScrollHandler: () => {\n      setStyle(null);\n\n      if (scrollTimeoutRef.current) {\n        clearTimeout(scrollTimeoutRef.current);\n      }\n\n      scrollTimeoutRef.current = setTimeout(() => {\n        onPositionChanged();\n      }, 200);\n    },\n  }));\n\n  const resetClickCount = useCallback(async () => {\n    if (!activeCell || !field || !record) return;\n    await buttonReset(field.tableId, record.id, field.id);\n    toast.success(t('sdk:common.resetSuccess'));\n  }, [activeCell, field, record, t]);\n\n  useEffect(() => {\n    return () => {\n      if (scrollTimeoutRef.current) {\n        clearTimeout(scrollTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  if (!style) return null;\n\n  return (\n    <div className=\"absolute z-50\" style={style}>\n      <Button\n        variant=\"outline\"\n        size=\"icon-sm\"\n        className=\"disabled:opacity-100\"\n        onClick={resetClickCount}\n      >\n        <RotateCcwIcon className=\"size-4 shrink-0\" />\n      </Button>\n    </div>\n  );\n});\n\nResetClickCountButton.displayName = 'ResetClickCountButton';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/StatisticMenu.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */\nimport { getValidStatisticFunc, NoneFunc } from '@teable/core';\nimport type { StatisticsFunc } from '@teable/core';\nimport { useGridViewStore, useStatisticFunc2NameMap } from '@teable/sdk/components';\nimport { useField, useIsTouchDevice, useView } from '@teable/sdk/hooks';\nimport {\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Sheet,\n  SheetContent,\n  SheetHeader,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef } from 'react';\nimport { useClickAway } from 'react-use';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const StatisticMenu = () => {\n  const view = useView();\n  const isTouchDevice = useIsTouchDevice();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { statisticMenu, closeStatisticMenu } = useGridViewStore();\n  const { fieldId, position } = statisticMenu || {};\n  const visible = Boolean(statisticMenu);\n  const style = position\n    ? {\n        left: position.x,\n        top: position.y + 4,\n        width: position.width,\n        height: position.height,\n      }\n    : {};\n\n  const field = useField(fieldId);\n  const fieldStatisticRef = useRef<HTMLDivElement>(null);\n\n  const statisticFunc2NameMap = useStatisticFunc2NameMap();\n\n  useClickAway(fieldStatisticRef, () => {\n    closeStatisticMenu();\n  });\n\n  if (fieldId == null) return null;\n\n  const menuItems = [NoneFunc.None, ...(getValidStatisticFunc(field) || [])];\n\n  const onSelect = (type: NoneFunc | StatisticsFunc) => {\n    closeStatisticMenu();\n    view &&\n      view.updateColumnMeta([\n        {\n          fieldId,\n          columnMeta: {\n            statisticFunc: type === NoneFunc.None ? null : type,\n          },\n        },\n      ]);\n  };\n\n  return (\n    <>\n      {isTouchDevice ? (\n        <Sheet open={visible} onOpenChange={(open) => !open && closeStatisticMenu()}>\n          <SheetContent className=\"h-5/6 rounded-t-lg py-0\" side=\"bottom\">\n            <SheetHeader className=\"h-16 justify-center border-b text-2xl\">\n              {t('sdk:common.summary')}\n            </SheetHeader>\n            {menuItems.map((type) => (\n              <div\n                key={type}\n                className=\"flex w-full items-center border-b py-3\"\n                onClick={() => onSelect(type)}\n              >\n                {statisticFunc2NameMap[type]}\n              </div>\n            ))}\n          </SheetContent>\n        </Sheet>\n      ) : (\n        <Popover open={visible}>\n          <PopoverTrigger asChild style={style} className=\"absolute\">\n            <div className=\"size-0 opacity-0\" />\n          </PopoverTrigger>\n          <PopoverContent className=\"h-auto w-[150px] rounded-sm px-0 py-1\" align=\"end\">\n            <Command ref={fieldStatisticRef} className=\"rounded-none border-none shadow-none\">\n              <CommandList>\n                <CommandGroup className=\"p-0\" aria-valuetext=\"name\">\n                  {menuItems.map((type) => (\n                    <CommandItem\n                      className=\"rounded-none p-2 py-1.5 text-[13px]\"\n                      key={type}\n                      value={statisticFunc2NameMap[type]}\n                      onSelect={() => onSelect(type)}\n                    >\n                      {statisticFunc2NameMap[type]}\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/components/index.ts",
    "content": "export * from './FieldMenu';\nexport * from './RecordMenu';\nexport * from './StatisticMenu';\nexport * from './GroupHeaderMenu';\nexport * from './PrefillingRowContainer';\nexport * from './PresortRowContainer';\nexport * from '../../field/FieldSetting';\nexport * from './AiGenerateButton';\nexport * from './AiAutoFillDialogContainer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/const.ts",
    "content": "import { RowHeightLevel } from '@teable/core';\n\nexport const GIRD_ROW_HEIGHT_DEFINITIONS = {\n  [RowHeightLevel.AutoFit]: 32,\n  [RowHeightLevel.Short]: 32,\n  [RowHeightLevel.Medium]: 56,\n  [RowHeightLevel.Tall]: 84,\n  [RowHeightLevel.ExtraTall]: 108,\n};\n\nexport const GIRD_FIELD_NAME_HEIGHT_DEFINITIONS = [0, 32, 56, 80];\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/hooks/index.ts",
    "content": "export * from './useSelectionOperation';\nexport * from './useCollaborate';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useCollaborate.ts",
    "content": "import { ColorUtils, getCellCollaboratorsChannel } from '@teable/core';\nimport type { ICellItem, ICell } from '@teable/sdk';\nimport { useSession } from '@teable/sdk';\nimport { SelectionRegionType } from '@teable/sdk/components/grid';\nimport type { ICollaborator, CombinedSelection } from '@teable/sdk/components/grid';\nimport { useConnection, useIsReadOnlyPreview, useTableId, useViewId } from '@teable/sdk/hooks';\nimport { useEffect, useState, useMemo } from 'react';\nimport type { Presence } from 'sharedb/lib/sharedb';\n\nexport const useCollaborate = (\n  selection: CombinedSelection | undefined,\n  getCellContent: (cell: ICellItem) => ICell\n) => {\n  const tableId = useTableId();\n  const { user } = useSession();\n  const viewId = useViewId();\n  const { connection } = useConnection();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const [presence, setPresence] = useState<Presence>();\n  const [collaborators, setCollaborators] = useState<ICollaborator>([]);\n  const activeCell = useMemo(() => {\n    if (selection?.type === SelectionRegionType.Cells) {\n      return selection?.ranges?.[0];\n    }\n    return null;\n  }, [selection]);\n\n  const localPresence = useMemo(() => {\n    if (isReadOnlyPreview || !presence || !connection?.id) {\n      return null;\n    }\n    return presence.create(`${tableId}_${user.id}_${connection.id}`);\n  }, [isReadOnlyPreview, connection?.id, presence, tableId, user.id]);\n\n  useEffect(() => {\n    if (isReadOnlyPreview || !tableId || !connection || !viewId) {\n      return;\n    }\n    // reset collaborators when table or view have been changed\n    setCollaborators([]);\n    const channel = getCellCollaboratorsChannel(tableId);\n    setPresence(connection.getPresence(channel));\n  }, [isReadOnlyPreview, connection, tableId, viewId]);\n\n  useEffect(() => {\n    if (isReadOnlyPreview) {\n      return;\n    }\n\n    const receiveHandler = () => {\n      if (presence?.remotePresences) {\n        setCollaborators(Object.values(presence.remotePresences));\n      }\n    };\n\n    if (presence) {\n      presence.subscribe();\n      presence.on('receive', receiveHandler);\n    }\n\n    return () => {\n      presence?.unsubscribe();\n      presence?.removeListener('receive', receiveHandler);\n    };\n  }, [isReadOnlyPreview, presence]);\n\n  useEffect(() => {\n    if (isReadOnlyPreview || !localPresence) {\n      return;\n    }\n    if (!activeCell) {\n      /**\n       * if want to collaborate the same user in different tab, create with connectionId\n       * reset presence data to null\n       **/\n      localPresence?.submit(null, (error) => {\n        error && console.error('submit error:', error);\n      });\n    } else {\n      const activeCellId = getCellContent(activeCell)?.id;\n      activeCellId?.length &&\n        localPresence.submit(\n          {\n            user: {\n              id: user.id,\n              name: user.name,\n              avatar: user.avatar,\n              email: user.email,\n            },\n            activeCellId: activeCellId,\n            borderColor: ColorUtils.getRandomHexFromStr(`${tableId}_${user.id}`),\n            timeStamp: Date.now(),\n          },\n          (error) => {\n            error && console.error('submit error:', error);\n          }\n        );\n    }\n    // not include getCellContent, because it will be changed frequently\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isReadOnlyPreview, activeCell, localPresence, tableId, user]);\n\n  return collaborators;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useIsSelectionLoaded.ts",
    "content": "import { SelectionRegionType, type CombinedSelection, type IRecordIndexMap } from '@teable/sdk';\nimport { useCallback } from 'react';\n\nexport const useIsSelectionLoaded = () => {\n  return useCallback(\n    ({\n      selection,\n      recordMap,\n      rowCount,\n    }: {\n      selection: CombinedSelection;\n      recordMap: IRecordIndexMap;\n      rowCount: number;\n    }) => {\n      const ranges = selection.serialize();\n      if (ranges.length === 0) {\n        return false;\n      }\n      switch (selection.type) {\n        case SelectionRegionType.Rows: {\n          const start = ranges[0][0];\n          const end = ranges[ranges.length - 1][1];\n          return recordMap[start] && recordMap[end];\n        }\n        case SelectionRegionType.Columns:\n          return recordMap[0] && recordMap[rowCount - 1];\n        case SelectionRegionType.Cells: {\n          const [[, startRowIndex], [, endRowIndex]] = ranges;\n          return recordMap[startRowIndex] && recordMap[endRowIndex];\n        }\n      }\n    },\n    []\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { UseMutateAsyncFunction } from '@tanstack/react-query';\nimport { useMutation } from '@tanstack/react-query';\nimport { FieldType, fieldVoSchema, type HttpError } from '@teable/core';\nimport type {\n  ICopyVo,\n  IPasteRo,\n  IRangesRo,\n  ITemporaryPasteRo,\n  ITemporaryPasteVo,\n} from '@teable/openapi';\nimport {\n  clear,\n  copy,\n  deleteSelection,\n  paste,\n  saveQueryParams,\n  temporaryPaste,\n} from '@teable/openapi';\nimport type { CombinedSelection, IRecordIndexMap } from '@teable/sdk';\nimport {\n  useBaseId,\n  useFields,\n  useSearch,\n  useTableId,\n  useView,\n  useViewId,\n  usePersonalView,\n  getHttpErrorMessage,\n  LARGE_QUERY_THRESHOLD,\n  useRowCount,\n} from '@teable/sdk';\nimport { useConfirm } from '@teable/ui-lib/base';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport type { AxiosResponse } from 'axios';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo } from 'react';\nimport { isHTTPS, isLocalhost } from '@/features/app/utils';\nimport { serializerCellValueHtml, serializerHtml } from '@/features/app/utils/clipboard';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { getEffectCellCount, getEffectRows, selectionCoverAttachments } from '../utils';\nimport {\n  ClipboardTypes,\n  copyHandler,\n  filePasteHandler,\n  getCellPasteInfo,\n  rangeTypes,\n  textPasteHandlerWithData,\n} from '../utils/copyAndPaste';\nimport { getSyncCopyData } from '../utils/getSyncCopyData';\nimport { buildSelectionViewQuery } from '../utils/selectionViewQuery';\nimport { useSyncSelectionStore } from './useSelectionStore';\n\nconst clearToastId = 'clearToastId';\nconst deleteToastId = 'deleteToastId';\n\nexport const useSelectionOperation = (props?: {\n  collapsedGroupIds?: string[];\n  copyReq?: UseMutateAsyncFunction<AxiosResponse<ICopyVo>, unknown, IRangesRo, unknown>;\n}) => {\n  const { collapsedGroupIds, copyReq } = props || {};\n  const baseId = useBaseId();\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const fields = useFields();\n  const view = useView();\n  const { searchQuery: search } = useSearch();\n  const { personalViewCommonQuery } = usePersonalView();\n  const rowCount = useRowCount();\n\n  // Parameters for retrieving selected records in plugins\n  useSyncSelectionStore({\n    groupBy: view?.group,\n    personalViewCommonQuery,\n    collapsedGroupIds,\n    search,\n    fields,\n  });\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const groupBy = view?.group;\n  const selectionViewQuery = useMemo(\n    () => buildSelectionViewQuery({ view, personalViewCommonQuery }),\n    [view, personalViewCommonQuery]\n  );\n\n  const { mutateAsync: defaultCopyReq } = useMutation({\n    mutationFn: async (copyRo: IRangesRo) => {\n      const { collapsedGroupIds: _originalCollapsedGroupIds, ...rest } = copyRo;\n      const params = {\n        ...rest,\n        ...selectionViewQuery,\n        viewId,\n        groupBy,\n        search,\n      };\n      if (collapsedGroupIds && collapsedGroupIds.length > LARGE_QUERY_THRESHOLD) {\n        const { data } = await saveQueryParams({ params: { collapsedGroupIds } });\n        return copy(tableId!, { ...params, queryId: data.queryId });\n      }\n      return copy(tableId!, { ...params, collapsedGroupIds });\n    },\n    meta: {\n      preventGlobalError: true,\n    },\n  });\n\n  const { mutateAsync: pasteReq } = useMutation({\n    mutationFn: (pasteRo: IPasteRo) =>\n      paste(tableId!, {\n        ...pasteRo,\n        ...selectionViewQuery,\n        viewId,\n        groupBy,\n        collapsedGroupIds,\n        search,\n      }),\n    meta: {\n      preventGlobalError: true,\n    },\n  });\n\n  const { mutateAsync: temporaryPasteReq } = useMutation({\n    mutationFn: (temporaryPasteRo: ITemporaryPasteRo) =>\n      temporaryPaste(tableId!, { ...temporaryPasteRo, ...selectionViewQuery, viewId }),\n  });\n\n  const { mutateAsync: clearReq } = useMutation({\n    mutationFn: (clearRo: IRangesRo) =>\n      clear(tableId!, {\n        ...clearRo,\n        ...selectionViewQuery,\n        viewId,\n        groupBy,\n        collapsedGroupIds,\n        search,\n      }),\n    onError: () => {\n      toast.dismiss(clearToastId);\n    },\n  });\n\n  const { mutateAsync: deleteReq } = useMutation({\n    mutationFn: async (deleteRo: IRangesRo) => {\n      const { collapsedGroupIds: _originalCollapsedGroupIds, ...rest } = deleteRo;\n      const params = {\n        ...rest,\n        ...selectionViewQuery,\n        viewId,\n        groupBy,\n        search,\n      };\n      if (collapsedGroupIds && collapsedGroupIds.length > LARGE_QUERY_THRESHOLD) {\n        const { data } = await saveQueryParams({ params: { collapsedGroupIds } });\n        return deleteSelection(tableId!, { ...params, queryId: data.queryId });\n      }\n      return deleteSelection(tableId!, { ...params, collapsedGroupIds });\n    },\n    onError: () => {\n      toast.dismiss(deleteToastId);\n    },\n  });\n\n  const copyRequest = copyReq || defaultCopyReq;\n\n  const checkCopyAndPasteEnvironment = useCallback(() => {\n    // not support http\n    if (!isLocalhost() && !isHTTPS()) {\n      toast.error(t('table:table.actionTips.copyAndPasteEnvironment'));\n      return false;\n    }\n    // browser not support clipboard\n    if (\n      !navigator.clipboard ||\n      !navigator.clipboard.write ||\n      typeof ClipboardItem === 'undefined'\n    ) {\n      toast.error(t('table:table.actionTips.copyAndPasteBrowser'));\n      return false;\n    }\n    return true;\n  }, [t]);\n\n  const doCopy = useCallback(\n    async (selection: CombinedSelection, getCopyData?: () => Promise<ICopyVo>) => {\n      if (!checkCopyAndPasteEnvironment()) return;\n      if (!viewId || !tableId) return;\n\n      const id = toast.loading(t('table:table.actionTips.copying'));\n\n      const getCopyDataDefault = async () => {\n        const ranges = selection.serialize();\n        const type = rangeTypes[selection.type];\n        const { data } = await copyRequest({\n          ranges,\n          ...(type ? { type } : {}),\n        });\n        const { content, header } = data;\n        return { content, header };\n      };\n\n      const getCopyDataInner = getCopyData ?? getCopyDataDefault;\n\n      try {\n        await copyHandler(getCopyDataInner);\n        toast.success(t('table:table.actionTips.copySuccessful'), { id });\n      } catch (e) {\n        const error = e as Error;\n        const hasFocus = document.hasFocus();\n        let errorMessage = error.message;\n        if (!hasFocus) {\n          errorMessage = t('table:table.actionTips.copyError.noFocus');\n        }\n        toast.error(t('table:table.actionTips.copyFailed'), {\n          description: errorMessage,\n          id,\n        });\n        console.error('Copy error: ', error);\n      }\n    },\n    [checkCopyAndPasteEnvironment, viewId, tableId, copyRequest, t]\n  );\n\n  const { confirm } = useConfirm();\n\n  const doPaste = useCallback(\n    async (\n      e: React.ClipboardEvent,\n      selection: CombinedSelection,\n      recordMap: IRecordIndexMap,\n      updateTemporaryData?: (records: ITemporaryPasteVo) => void\n    ) => {\n      if (!viewId || !tableId) return;\n\n      const { files, types } = e.clipboardData;\n      const hasHtml = types.includes(ClipboardTypes.html);\n      const html = hasHtml ? e.clipboardData.getData(ClipboardTypes.html) : '';\n      const text = types.includes(ClipboardTypes.text)\n        ? e.clipboardData.getData(ClipboardTypes.text)\n        : '';\n      const fileArray = Array.from(files) as unknown as FileList;\n\n      const { cellValues } = getCellPasteInfo(e);\n\n      const pasteRecordLength = cellValues?.length ?? 0;\n      const effectRows = getEffectRows(selection, rowCount);\n      const affectedRows = Math.max(pasteRecordLength, effectRows);\n\n      if (affectedRows >= 10) {\n        const confirmed = await confirm({\n          title: t('table:table.actionTips.pasteConfirmTitle'),\n          description: t('table:table.actionTips.pasteConfirmDescription', {\n            recordCount: affectedRows,\n          }),\n          confirmText: t('table:table.actionTips.paste'),\n          cancelText: t('common:actions.cancel'),\n          confirmButtonVariant: 'destructive',\n        });\n        if (!confirmed) return;\n      }\n\n      const toastId = toast.loading(t('table:table.actionTips.pasting'));\n\n      try {\n        if (fileArray.length > 0 && !types.includes(ClipboardTypes.text)) {\n          const isSelectionCoverAttachments = selectionCoverAttachments(selection, fields);\n          if (!isSelectionCoverAttachments) {\n            toast.error(t('table:table.actionTips.pasteFileFailed'), { id: toastId });\n            return;\n          }\n          await filePasteHandler({\n            files: fileArray,\n            fields,\n            selection,\n            recordMap,\n            baseId,\n            requestPaste: async (content, type, ranges) => {\n              const header = [\n                fieldVoSchema.parse(fields.find((f) => f.type === FieldType.Attachment)),\n              ];\n              if (updateTemporaryData) {\n                const res = await temporaryPasteReq({\n                  content,\n                  ranges,\n                  header,\n                });\n                updateTemporaryData(res.data);\n              } else {\n                await pasteReq({ content, type, ranges, header });\n              }\n            },\n          });\n        } else {\n          await textPasteHandlerWithData(\n            { html, text, hasHtml },\n            selection,\n            async (content, type, ranges, header) => {\n              if (!content) {\n                return;\n              }\n              if (updateTemporaryData) {\n                const res = await temporaryPasteReq({ content, ranges, header });\n                updateTemporaryData(res.data);\n              } else {\n                await pasteReq({ content, type, ranges, header });\n              }\n            }\n          );\n        }\n        toast.success(t('table:table.actionTips.pasteSuccessful'), { id: toastId });\n      } catch (e) {\n        const error = e as HttpError;\n        const description = getHttpErrorMessage(error, t, 'sdk');\n        toast.error(t('table:table.actionTips.pasteFailed'), {\n          description,\n          id: toastId,\n        });\n        console.error('Paste error: ', error);\n      }\n    },\n    [viewId, tableId, fields, rowCount, t, confirm, baseId, temporaryPasteReq, pasteReq]\n  );\n\n  const doFill = useCallback(\n    async (args: Pick<IPasteRo, 'content' | 'ranges' | 'header' | 'type'>) => {\n      const toastId = toast.loading(t('table:table.actionTips.filling'));\n      try {\n        await pasteReq(args);\n        toast.success(t('table:table.actionTips.fillSuccessful'), { id: toastId });\n      } catch (e) {\n        const error = e as HttpError;\n        const description = getHttpErrorMessage(error, t, 'sdk');\n        toast.error(t('table:table.actionTips.fillFailed'), {\n          description,\n          id: toastId,\n        });\n        console.error('Fill error: ', error);\n      }\n    },\n    [pasteReq, t]\n  );\n\n  const doClear = useCallback(\n    async (selection: CombinedSelection) => {\n      if (!viewId || !tableId) return;\n\n      const effectRows = getEffectRows(selection, rowCount);\n      const effectCells = getEffectCellCount(selection, fields, rowCount);\n\n      if (effectRows >= 10 && effectCells) {\n        const confirmed = await confirm({\n          title: t('table:table.actionTips.clearConfirmTitle'),\n          description: t('table:table.actionTips.clearConfirmDescription', {\n            cellCount: effectCells,\n            rowCount: effectRows,\n          }),\n          confirmText: t('table:table.actionTips.clear'),\n          cancelText: t('common:actions.cancel'),\n          confirmButtonVariant: 'destructive',\n        });\n        if (!confirmed) return;\n      }\n\n      const toastId = toast.loading(t('table:table.actionTips.clearing'), { id: clearToastId });\n      const ranges = selection.serialize();\n      const type = rangeTypes[selection.type];\n\n      await clearReq({\n        ranges,\n        ...(type ? { type } : {}),\n      });\n\n      toast.success(t('table:table.actionTips.clearSuccessful'), { id: toastId });\n    },\n    [viewId, tableId, fields, rowCount, t, clearReq, confirm]\n  );\n\n  const doDelete = useCallback(\n    async (selection: CombinedSelection) => {\n      if (!viewId || !tableId) return;\n\n      const toastId = toast.loading(t('table:table.actionTips.deleting'), { id: deleteToastId });\n      const ranges = selection.serialize();\n      const type = rangeTypes[selection.type];\n\n      await deleteReq({\n        ranges,\n        ...(type ? { type } : {}),\n      });\n\n      toast.success(t('table:table.actionTips.deleteSuccessful'), { id: toastId });\n    },\n    [deleteReq, tableId, viewId, t]\n  );\n\n  const doSyncCopy = useCallback(\n    (\n      e: React.ClipboardEvent,\n      params:\n        | {\n            selection: CombinedSelection;\n            recordMap: IRecordIndexMap;\n          }\n        | { getCopyData: () => ICopyVo }\n    ) => {\n      const toastId = toast.loading(t('table:table.actionTips.copying'));\n      try {\n        if ('getCopyData' in params) {\n          const data = params.getCopyData();\n          const content = data.content;\n          const header = data.header;\n          e.clipboardData.setData(ClipboardTypes.text, content);\n          e.clipboardData.setData(ClipboardTypes.html, serializerHtml(content, header));\n        } else if ('recordMap' in params && 'selection' in params) {\n          const recordMap = params.recordMap;\n          const selection = params.selection;\n          const res = getSyncCopyData({ recordMap, fields, selection });\n          e.clipboardData.setData(ClipboardTypes.text, res.content);\n          e.clipboardData.setData(\n            ClipboardTypes.html,\n            serializerCellValueHtml(res.rawContent, res.headers)\n          );\n        } else {\n          toast.error(t('table:table.actionTips.copyFailed'), {\n            description: 'Unsupported selection type',\n            id: toastId,\n          });\n          return;\n        }\n        e.preventDefault();\n        toast.success(t('table:table.actionTips.copySuccessful'), { id: toastId });\n      } catch (e) {\n        const error = e as Error;\n        toast.error(t('table:table.actionTips.copyFailed'), {\n          description: error.message,\n          id: toastId,\n        });\n        console.error('Sync copy error: ', error);\n      }\n    },\n    [fields, t]\n  );\n\n  return {\n    copy: doCopy,\n    paste: doPaste,\n    clear: doClear,\n    deleteRecords: doDelete,\n    syncCopy: doSyncCopy,\n    fill: doFill,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionStore.ts",
    "content": "import type { IGroup } from '@teable/core';\nimport type { IGetRecordsRo, IQueryBaseRo } from '@teable/openapi';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { useEffect } from 'react';\nimport { create } from 'zustand';\n\ninterface ISelectionStore {\n  fields?: IFieldInstance[];\n  setFields: (fields: IFieldInstance[] | undefined) => void;\n  search?: IQueryBaseRo['search'];\n  setSearch: (search: IQueryBaseRo['search']) => void;\n  groupBy?: IGroup;\n  setGroupBy: (groupBy: IGroup | undefined) => void;\n  personalViewCommonQuery?: IGetRecordsRo;\n  setPersonalViewCommonQuery: (personalViewCommonQuery: IGetRecordsRo | undefined) => void;\n  collapsedGroupIds?: string[];\n  setCollapsedGroupIds: (collapsedGroupIds: string[] | undefined) => void;\n}\n\nexport const useSelectionStore = create<ISelectionStore>((set) => ({\n  setGroupBy: (groupBy) => set((state) => ({ ...state, groupBy })),\n  setPersonalViewCommonQuery: (personalViewCommonQuery) =>\n    set((state) => ({ ...state, personalViewCommonQuery })),\n  setCollapsedGroupIds: (collapsedGroupIds) => set((state) => ({ ...state, collapsedGroupIds })),\n  setSearch: (search) => set((state) => ({ ...state, search })),\n  setFields: (fields) => set((state) => ({ ...state, fields })),\n}));\n\nexport const useSyncSelectionStore = ({\n  groupBy,\n  personalViewCommonQuery,\n  collapsedGroupIds,\n  search,\n  fields,\n}: {\n  groupBy?: IGroup;\n  personalViewCommonQuery?: IGetRecordsRo;\n  collapsedGroupIds?: string[];\n  search?: IQueryBaseRo['search'];\n  fields?: IFieldInstance[];\n}) => {\n  useEffect(() => {\n    useSelectionStore.setState({ groupBy });\n  }, [groupBy]);\n  useEffect(() => {\n    useSelectionStore.setState({ personalViewCommonQuery });\n  }, [personalViewCommonQuery]);\n  useEffect(() => {\n    useSelectionStore.setState({ collapsedGroupIds });\n  }, [collapsedGroupIds]);\n  useEffect(() => {\n    useSelectionStore.setState({ search });\n  }, [search]);\n  useEffect(() => {\n    useSelectionStore.setState({ fields });\n  }, [fields]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/useGridSearchStore.ts",
    "content": "import type { IGridRef, IRecordIndexMap, IFieldInstance } from '@teable/sdk';\nimport { noop } from 'lodash';\nimport { create } from 'zustand';\n\n// Event emitter for recordMap changes\ntype RecordMapListener = (recordMap: IRecordIndexMap | null) => void;\nconst recordMapListeners = new Set<RecordMapListener>();\n\nexport const subscribeToRecordMap = (listener: RecordMapListener) => {\n  recordMapListeners.add(listener);\n  return () => recordMapListeners.delete(listener);\n};\n\nconst notifyRecordMapChange = (recordMap: IRecordIndexMap | null) => {\n  recordMapListeners.forEach((listener) => listener(recordMap));\n};\n\n// Event emitter for fields changes\ntype FieldsListener = (fields: IFieldInstance[] | null) => void;\nconst fieldsListeners = new Set<FieldsListener>();\n\nexport const subscribeToFields = (listener: FieldsListener) => {\n  fieldsListeners.add(listener);\n  return () => fieldsListeners.delete(listener);\n};\n\nconst notifyFieldsChange = (fields: IFieldInstance[] | null) => {\n  fieldsListeners.forEach((listener) => listener(fields));\n};\n\ninterface IGridRefState {\n  gridRef: React.RefObject<IGridRef> | null;\n  setGridRef: (ref: React.RefObject<IGridRef>) => void;\n  searchCursor: [number, number] | null;\n  setSearchCursor: (cell: [number, number] | null) => void;\n  resetSearchHandler: () => void;\n  setResetSearchHandler: (fn: () => void) => void;\n  recordMap: IRecordIndexMap | null;\n  setRecordMap: (recordMap: IRecordIndexMap | null) => void;\n  fields: IFieldInstance[] | null;\n  setFields: (fields: IFieldInstance[] | null) => void;\n  highlightedTableId: string | null;\n  setHighlightedTableId: (tableId: string | null) => void;\n  highlightedViewId: string | null;\n  setHighlightedViewId: (viewId: string | null) => void;\n}\n\nexport const useGridSearchStore = create<IGridRefState>((set) => ({\n  gridRef: null,\n  searchCursor: null,\n  recordMap: null,\n  fields: null,\n  highlightedTableId: null,\n  highlightedViewId: null,\n  resetSearchHandler: noop,\n  setResetSearchHandler: (fn: () => void) => {\n    set((state) => {\n      return {\n        ...state,\n        resetSearchHandler: fn,\n      };\n    });\n  },\n  setGridRef: (ref: React.RefObject<IGridRef>) => {\n    set((state) => {\n      return {\n        ...state,\n        gridRef: ref,\n      };\n    });\n  },\n  setSearchCursor: (cell: [number, number] | null) => {\n    set((state) => {\n      return {\n        ...state,\n        searchCursor: cell,\n      };\n    });\n  },\n  setRecordMap: (recordMap: IRecordIndexMap | null) => {\n    set((state) => {\n      // Notify listeners when recordMap changes\n      notifyRecordMapChange(recordMap);\n      return {\n        ...state,\n        recordMap: recordMap,\n      };\n    });\n  },\n  setFields: (fields: IFieldInstance[] | null) => {\n    set((state) => {\n      // Notify listeners when fields change\n      notifyFieldsChange(fields);\n      return {\n        ...state,\n        fields: fields,\n      };\n    });\n  },\n  setHighlightedTableId: (tableId: string | null) => {\n    set((state) => {\n      return {\n        ...state,\n        highlightedTableId: tableId,\n      };\n    });\n  },\n  setHighlightedViewId: (viewId: string | null) => {\n    set((state) => {\n      return {\n        ...state,\n        highlightedViewId: viewId,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/computeFrozenFields.ts",
    "content": "import type { IFieldInstance } from '@teable/sdk/model';\n\nexport function computeFrozenColumnCount({\n  isTouchDevice,\n  frozenFieldId,\n  frozenColumnCount,\n  visibleColumns,\n  allFields,\n}: {\n  isTouchDevice: boolean;\n  frozenFieldId?: string;\n  frozenColumnCount?: number;\n  visibleColumns: { id: string }[];\n  allFields: IFieldInstance[];\n}): number {\n  if (isTouchDevice) return 0;\n  if (!frozenFieldId) return frozenColumnCount ?? 1;\n\n  const visibleIdx = visibleColumns.findIndex((c) => c?.id === frozenFieldId);\n  if (visibleIdx >= 0) return visibleIdx + 1;\n\n  const anchorOrderIndex = allFields.findIndex((f) => f?.id === frozenFieldId);\n  if (anchorOrderIndex < 0) return 0;\n\n  let lastBefore = -1;\n  const fieldIdToIndex = new Map(allFields.map((f, idx) => [f.id, idx]));\n\n  for (let i = 0; i < visibleColumns.length; i++) {\n    const colId = visibleColumns[i]?.id;\n    const pos = fieldIdToIndex.get(colId);\n    if (pos != null && pos < anchorOrderIndex) lastBefore = i;\n  }\n  return lastBefore >= 0 ? lastBefore + 1 : 0;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/copyAndPaste.ts",
    "content": "import type { IAttachmentCellValue } from '@teable/core';\nimport type { ICopyVo, IPasteRo } from '@teable/openapi';\nimport { RangeType, UploadType } from '@teable/openapi';\nimport type { CombinedSelection, IRecordIndexMap } from '@teable/sdk/components';\nimport { SelectionRegionType } from '@teable/sdk/components';\nimport type { Field } from '@teable/sdk/model';\nimport {\n  extractTableContent,\n  extractHtmlHeader,\n  serializerHtml,\n  isTeableHTML,\n  parseNormalHtml,\n} from '@/features/app/utils/clipboard';\nimport { uploadFiles } from '@/features/app/utils/uploadFile';\nimport { getSelectionCell } from './selection';\n\nexport enum ClipboardTypes {\n  'text' = 'text/plain',\n  'html' = 'text/html',\n  'Files' = 'Files',\n}\n\nexport const rangeTypes = {\n  [SelectionRegionType.Columns]: RangeType.Columns,\n  [SelectionRegionType.Rows]: RangeType.Rows,\n  [SelectionRegionType.Cells]: undefined,\n  [SelectionRegionType.None]: undefined,\n};\n\nexport const isSafari = () => /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent);\n\nexport const copyHandler = async (getCopyData: () => Promise<ICopyVo>) => {\n  // Can't await asynchronous action before navigator.clipboard.write in safari\n  if (!isSafari()) {\n    const { header, content } = await getCopyData();\n    await navigator.clipboard.write([\n      new ClipboardItem({\n        [ClipboardTypes.text]: new Blob([content], { type: ClipboardTypes.text }),\n        [ClipboardTypes.html]: new Blob([serializerHtml(content, header)], {\n          type: ClipboardTypes.html,\n        }),\n      }),\n    ]);\n    return;\n  }\n\n  const getText = async () => {\n    const { content } = await getCopyData();\n\n    return new Blob([content], { type: ClipboardTypes.text });\n  };\n\n  const getHtml = async () => {\n    const { header, content } = await getCopyData();\n    return new Blob([serializerHtml(content, header)], { type: ClipboardTypes.html });\n  };\n\n  await navigator.clipboard.write([\n    new ClipboardItem({\n      [ClipboardTypes.text]: getText(),\n      [ClipboardTypes.html]: getHtml(),\n    }),\n  ]);\n};\n\nexport const filePasteHandler = async ({\n  files,\n  fields,\n  recordMap,\n  selection,\n  baseId,\n  requestPaste,\n}: {\n  selection: CombinedSelection;\n  recordMap: IRecordIndexMap;\n  fields: Field[];\n  files: FileList;\n  baseId?: string;\n  requestPaste: (\n    content: unknown[][],\n    type: RangeType | undefined,\n    ranges: IPasteRo['ranges']\n  ) => Promise<unknown>;\n}) => {\n  const selectionCell = getSelectionCell(selection);\n  const attachments = await uploadFiles(files, UploadType.Table, baseId);\n\n  if (selectionCell) {\n    const [fieldIndex, recordIndex] = selectionCell;\n    const record = recordMap[recordIndex];\n    const field = fields[fieldIndex];\n    const oldCellValue = (record.getCellValue(field.id) as IAttachmentCellValue) || [];\n    await record.updateCell(field.id, [...oldCellValue, ...attachments]);\n  } else {\n    await requestPaste([[attachments]], rangeTypes[selection.type], selection.serialize());\n  }\n};\n\nexport const textPasteHandler = async (\n  e: React.ClipboardEvent,\n  selection: CombinedSelection,\n  requestPaste: (\n    content: string | unknown[][] | undefined,\n    type: RangeType | undefined,\n    ranges: IPasteRo['ranges'],\n    header: IPasteRo['header']\n  ) => Promise<void>\n) => {\n  const hasHtml = e.clipboardData.types.includes(ClipboardTypes.html);\n  const html = hasHtml ? e.clipboardData.getData(ClipboardTypes.html) : '';\n  const header = extractHtmlHeader(html);\n  const text = e.clipboardData.types.includes(ClipboardTypes.text)\n    ? e.clipboardData.getData(ClipboardTypes.text)\n    : '';\n\n  const cellValues = hasHtml\n    ? isTeableHTML(html)\n      ? extractTableContent(html)\n      : parseNormalHtml(html)\n    : [];\n\n  if (header.error) {\n    throw new Error(header.error);\n  }\n\n  await requestPaste(\n    hasHtml ? cellValues : text,\n    rangeTypes[selection.type],\n    selection.serialize(),\n    header.result\n  );\n};\n\nexport const textPasteHandlerWithData = async (\n  clipboardData: {\n    html: string;\n    text: string;\n    hasHtml: boolean;\n  },\n  selection: CombinedSelection,\n  requestPaste: (\n    content: string | unknown[][] | undefined,\n    type: RangeType | undefined,\n    ranges: IPasteRo['ranges'],\n    header: IPasteRo['header']\n  ) => Promise<void>\n) => {\n  const { html, text, hasHtml } = clipboardData;\n  const header = extractHtmlHeader(html);\n\n  const cellValues = hasHtml\n    ? isTeableHTML(html)\n      ? extractTableContent(html)\n      : parseNormalHtml(html)\n    : [];\n\n  if (header.error) {\n    throw new Error(header.error);\n  }\n\n  await requestPaste(\n    hasHtml ? cellValues : text,\n    rangeTypes[selection.type],\n    selection.serialize(),\n    header.result\n  );\n};\n\nexport const getCellPasteInfo = (e: React.ClipboardEvent) => {\n  const hasHtml = e.clipboardData.types.includes(ClipboardTypes.html);\n  const html = hasHtml ? e.clipboardData.getData(ClipboardTypes.html) : '';\n  const header = extractHtmlHeader(html);\n\n  return {\n    cellValues: hasHtml\n      ? isTeableHTML(html)\n        ? extractTableContent(html)\n        : parseNormalHtml(html)\n      : [],\n    header,\n  };\n};\n\nexport const getExpandInfo = (\n  selection: CombinedSelection,\n  rowCount: number | null,\n  fields: Field[],\n  cellValues?: unknown[][]\n) => {\n  if (!rowCount || !cellValues) {\n    return {\n      isExpand: false,\n      expandRowCount: 0,\n      expandColCount: 0,\n    };\n  }\n\n  const computedFieldIndexes = fields.filter((field) => field.isComputed).map((field) => field.id);\n\n  if (selection.type === SelectionRegionType.Cells) {\n    const [startRange, endRange] = selection.ranges;\n    const [startCol, startRow] = startRange;\n    const [endCol, endRow] = endRange;\n    const selectionRows = endRow - startRow + 1;\n    const selectionCols = endCol - startCol + 1;\n    const pasteRecordLength = cellValues?.length ?? 0;\n    const pasteFieldsLength = cellValues?.[0]?.length ?? 0;\n    const additionRecordsLength = rowCount ? pasteRecordLength - (rowCount - startRow) : 0;\n    const additionFieldsLength =\n      pasteFieldsLength - (fields.length - startCol - computedFieldIndexes.length);\n\n    const isExpand = additionRecordsLength > 0 || additionFieldsLength > 0;\n\n    return {\n      isExpand,\n      expandRowCount: selectionRows,\n      expandColCount: selectionCols,\n    };\n  }\n\n  return {\n    isExpand: false,\n    expandRowCount: 0,\n    expandColCount: 0,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/fill.ts",
    "content": "import { FieldType } from '@teable/core';\n\nconst isNumberValue = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\nconst isParsableDate = (v: unknown) =>\n  (typeof v === 'string' || typeof v === 'number' || v instanceof Date) &&\n  !Number.isNaN(new Date(v as never).getTime());\n\nconst toSameDateType = (base: unknown, ts: number) => {\n  if (typeof base === 'number') return ts;\n  if (base instanceof Date) return new Date(ts);\n  return new Date(ts).toISOString();\n};\n\nexport const isEmptyValue = (v: unknown) =>\n  v == null || (Array.isArray(v) && v.length === 0) || (typeof v === 'string' && v.trim() === '');\n\nconst shouldGenerateNumberSeries = (values: unknown[]) => {\n  const nums = values.filter(isNumberValue) as number[];\n  if (nums.length < 2) return false;\n  if (values.some((v) => isEmptyValue(v))) return false;\n  const first = nums[0];\n  return nums.some((n) => n !== first);\n};\n\nconst shouldGenerateDateSeries = (values: unknown[]) => {\n  if (values.length !== 2) return false;\n  const [v1, v2] = values;\n  const d1 = isParsableDate(v1) ? new Date(v1 as never).getTime() : NaN;\n  const d2 = isParsableDate(v2) ? new Date(v2 as never).getTime() : NaN;\n  return Number.isFinite(d1) && Number.isFinite(d2) && d1 !== d2;\n};\n\nexport const generateNumberSeries = (\n  baseValues: unknown[],\n  outLen: number,\n  direction: 'down' | 'up'\n): unknown[] | null => {\n  const nums = baseValues.filter(isNumberValue) as number[];\n  if (nums.length < 2) return null;\n  const diffs: number[] = [];\n  for (let i = 1; i < nums.length; i++) {\n    diffs.push(nums[i] - nums[i - 1]);\n  }\n  if (!diffs.some((d) => d !== 0)) return null;\n\n  if (direction === 'down') {\n    const result: number[] = [];\n    let current = nums[nums.length - 1];\n    for (let i = 0; i < outLen; i++) {\n      const d = diffs[i % diffs.length];\n      current += d;\n      result.push(current);\n    }\n    return result;\n  }\n  const resultUp: number[] = [];\n  let currentUp = nums[0];\n  for (let i = 0; i < outLen; i++) {\n    const d = diffs[(diffs.length - 1 - (i % diffs.length) + diffs.length) % diffs.length];\n    currentUp -= d;\n    resultUp.push(currentUp);\n  }\n  return resultUp;\n};\n\nexport const generateDateSeries = (\n  baseValues: unknown[],\n  outLen: number,\n  direction: 'down' | 'up'\n): unknown[] | null => {\n  const dates = baseValues.filter(isParsableDate);\n  if (dates.length >= 2) {\n    const tsLast = new Date(dates[dates.length - 1] as never).getTime();\n    const tsPrev = new Date(dates[dates.length - 2] as never).getTime();\n    const stepMs = tsLast - tsPrev || 24 * 60 * 60 * 1000;\n    const baseDateValue = dates[dates.length - 1];\n    if (direction === 'down') {\n      const startTs = tsLast + stepMs;\n      return Array.from({ length: outLen }, (_, i) =>\n        toSameDateType(baseDateValue, startTs + stepMs * i)\n      );\n    }\n    const firstDateValue = dates[0];\n    const firstTs = new Date(firstDateValue as never).getTime();\n    const startTs = firstTs - stepMs;\n    return Array.from({ length: outLen }, (_, i) =>\n      toSameDateType(firstDateValue, startTs - stepMs * i)\n    );\n  }\n  return null;\n};\n\nexport const generateSeriesForColumn = (\n  baseColumnValues: unknown[],\n  fieldType: FieldType,\n  outLen: number,\n  direction: 'down' | 'up'\n): unknown[] => {\n  if (fieldType === FieldType.Number && shouldGenerateNumberSeries(baseColumnValues)) {\n    const numberSeries = generateNumberSeries(baseColumnValues, outLen, direction);\n    if (numberSeries) return numberSeries;\n  } else if (fieldType === FieldType.Date && shouldGenerateDateSeries(baseColumnValues)) {\n    const dateSeries = generateDateSeries(baseColumnValues, outLen, direction);\n    if (dateSeries) return dateSeries;\n  }\n  if (direction === 'down') {\n    return Array.from({ length: outLen }, (_, i) => baseColumnValues[i % baseColumnValues.length]);\n  }\n  return Array.from({ length: outLen }, (_, i) => {\n    const len = baseColumnValues.length;\n    const idx = (len - 1 - ((outLen - 1 - i) % len) + len) % len;\n    return baseColumnValues[idx];\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/getSyncCopyData.ts",
    "content": "import type { IFieldVo } from '@teable/core';\nimport { fieldVoSchema, stringifyClipboardText } from '@teable/core';\nimport {\n  type IRecordIndexMap,\n  type CombinedSelection,\n  type Field,\n  SelectionRegionType,\n} from '@teable/sdk';\n\nexport const getSyncCopyData = ({\n  recordMap,\n  fields,\n  selection,\n}: {\n  recordMap: IRecordIndexMap;\n  fields: Field[];\n  selection: CombinedSelection;\n}) => {\n  const ranges = selection.serialize();\n  const content: string[][] = [];\n  const rawContent: unknown[][] = [];\n  let headers: IFieldVo[] = [];\n\n  switch (selection.type) {\n    case SelectionRegionType.Cells: {\n      const [[startColumnIndex, startRowIndex], [endColumnIndex, endRowIndex]] = ranges;\n      headers = fields\n        .slice(startColumnIndex, endColumnIndex + 1)\n        .map((field) => fieldVoSchema.parse(field));\n      for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) {\n        const rowContent: string[] = [];\n        const rawRowContent: unknown[] = [];\n        for (let columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++) {\n          const record = recordMap[rowIndex];\n          const field = fields[columnIndex];\n          const fieldValue = field.cellValue2String(record?.fields[field.id]);\n          rowContent.push(fieldValue);\n          rawRowContent.push(record?.fields[field.id]);\n        }\n        content.push(rowContent);\n        rawContent.push(rawRowContent);\n      }\n      break;\n    }\n    case SelectionRegionType.Rows: {\n      const len = ranges.length;\n      headers = fields.map((field) => fieldVoSchema.parse(field));\n      for (let i = 0; i < len; i++) {\n        const [startRowIndex, endRowIndex] = ranges[i];\n        for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) {\n          const rowContent: string[] = fields.map((field) => {\n            const record = recordMap[rowIndex];\n            return field.cellValue2String(record?.fields[field.id]);\n          });\n          const rawRowContent: unknown[] = fields.map((field) => {\n            const record = recordMap[rowIndex];\n            return record?.fields[field.id];\n          });\n          content.push(rowContent);\n          rawContent.push(rawRowContent);\n        }\n      }\n      break;\n    }\n    case SelectionRegionType.Columns: {\n      const len = ranges.length;\n      let selectedFields: Field[] = [];\n      for (let i = 0; i < len; i++) {\n        const [startColIndex, endColIndex] = ranges[i];\n        selectedFields = selectedFields.concat(\n          fields.slice(startColIndex, endColIndex + 1).map((field) => field)\n        );\n      }\n      Object.keys(recordMap)\n        .sort((a, b) => Number(a) - Number(b))\n        .forEach((recordIndex) => {\n          const record = recordMap[recordIndex];\n          if (!record) return;\n          const rowContent: string[] = selectedFields.map((field) =>\n            field.cellValue2String(record.fields[field.id])\n          );\n          const rawRowContent: unknown[] = selectedFields.map((field) => record.fields[field.id]);\n          content.push(rowContent);\n          rawContent.push(rawRowContent);\n        });\n\n      headers = selectedFields.map((field) => fieldVoSchema.parse(field));\n      break;\n    }\n    default:\n      throw new Error('Unsupported selection type');\n  }\n  const contentString = stringifyClipboardText(content);\n  return { content: contentString, headers, rawContent };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/index.ts",
    "content": "export * from './selection';\nexport * from './fill';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/selection.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { CombinedSelection } from '@teable/sdk/components';\nimport { SelectionRegionType } from '@teable/sdk/components';\nimport type { Field } from '@teable/sdk/model';\nimport { isEqual, range } from 'lodash';\n\nexport const selectionCoverAttachments = (selection: CombinedSelection, fields: Field[]) => {\n  const { type, ranges } = selection;\n  switch (type) {\n    case SelectionRegionType.Cells: {\n      const [start, end] = ranges;\n      return fields\n        .slice(start[0], end[0] + 1)\n        .every((field) => field.type === FieldType.Attachment && !field.isComputed);\n    }\n    case SelectionRegionType.Rows: {\n      return fields.every((field) => field.type === FieldType.Attachment && !field.isComputed);\n    }\n    case SelectionRegionType.Columns: {\n      let allFieldsAreAttachments = true;\n      for (let i = 0; i < ranges.length; i++) {\n        const start = ranges[i][0];\n        const end = ranges[i][1];\n        const fieldsInRange = fields.slice(start, end + 1);\n        const areAllAttachments = fieldsInRange.every(\n          (field) => field.type === FieldType.Attachment && !field.isComputed\n        );\n        if (!areAllAttachments) {\n          allFieldsAreAttachments = false;\n          break;\n        }\n      }\n      return allFieldsAreAttachments;\n    }\n    default:\n      return false;\n  }\n};\n\nexport const getSelectionCell = (selection: CombinedSelection) => {\n  const { type, ranges } = selection;\n  const isSelectionCell =\n    type === SelectionRegionType.Cells && ranges.length === 2 && isEqual(ranges[0], ranges[1]);\n  if (!isSelectionCell) {\n    return;\n  }\n  return ranges[0];\n};\n\nexport const getActiveCell = (selection: CombinedSelection) => {\n  const { type, ranges } = selection;\n  switch (type) {\n    case SelectionRegionType.Cells: {\n      return ranges[0];\n    }\n    case SelectionRegionType.Rows: {\n      return [0, ranges[0][0]];\n    }\n    case SelectionRegionType.Columns: {\n      return [ranges[0][0], 0];\n    }\n    default:\n      return null;\n  }\n};\n\nexport const getEffectCellCount = (\n  selection: CombinedSelection,\n  fields: Field[],\n  rowCount: number | null\n) => {\n  const calFieldsIndex = [] as number[];\n  fields.forEach((field, index) => {\n    if (field.isComputed) {\n      calFieldsIndex.push(index);\n    }\n  });\n\n  if (selection.type === SelectionRegionType.Columns && rowCount) {\n    const columnWithoutCal = [];\n    selection.ranges.forEach((currentRange) => {\n      const [startCol, endCol] = currentRange;\n      if (startCol === endCol && !calFieldsIndex.includes(startCol)) {\n        columnWithoutCal.push(startCol);\n      }\n\n      if (startCol !== endCol) {\n        const cols = range(startCol, endCol + 1);\n        const finalCols = cols.filter((col) => !calFieldsIndex.includes(col));\n        columnWithoutCal.push(...finalCols);\n      }\n    });\n    return columnWithoutCal.length * rowCount;\n  }\n\n  if (selection.type === SelectionRegionType.Cells) {\n    const [startRange, endRange] = selection.ranges;\n    const [startCol, startRow] = startRange;\n    const [endCol, endRow] = endRange;\n    const selectionRows = endRow - startRow + 1;\n\n    const colWithoutComputedFieldLength = range(startCol, endCol + 1)?.filter(\n      (index) => !calFieldsIndex.includes(index)\n    )?.length;\n\n    return colWithoutComputedFieldLength * selectionRows;\n  }\n\n  if (selection.type === SelectionRegionType.Rows) {\n    // all select\n    const [startRow, endRow] = selection.ranges as unknown as [number, number];\n    const rows = endRow - startRow + 1;\n    const fieldsWithoutCal = fields?.filter((f) => !f.isComputed);\n    return fieldsWithoutCal?.length * rows;\n  }\n\n  return 0;\n};\n\nexport const getEffectRows = (selection: CombinedSelection, rowCount?: number | null) => {\n  const { type, ranges } = selection;\n  if (type === SelectionRegionType.Rows) {\n    return ranges.reduce((acc, range) => acc + range[1] - range[0] + 1, 0);\n  }\n\n  if (type === SelectionRegionType.Cells) {\n    const [startRange, endRange] = selection.ranges;\n    const [, startRow] = startRange;\n    const [, endRow] = endRange;\n    return endRow - startRow + 1;\n  }\n\n  if (type === SelectionRegionType.Columns) {\n    return rowCount ?? 0;\n  }\n\n  return 0;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.spec.ts",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\nimport { describe, expect, it } from 'vitest';\nimport { buildSelectionViewQuery } from './selectionViewQuery';\n\ndescribe('buildSelectionViewQuery', () => {\n  it('returns undefined when there is no personal view query', () => {\n    expect(buildSelectionViewQuery({})).toBeUndefined();\n  });\n\n  it('drops ignoreViewQuery when personal query matches saved view query', () => {\n    const filter: NonNullable<IGetRecordsRo['filter']> = {\n      conjunction: 'and',\n      filterSet: [{ fieldId: 'fldValue', operator: 'is', value: 'Open' }],\n    };\n    const orderBy: NonNullable<IGetRecordsRo['orderBy']> = [{ fieldId: 'fldSort', order: 'desc' }];\n    const groupBy: NonNullable<IGetRecordsRo['groupBy']> = [{ fieldId: 'fldGroup', order: 'asc' }];\n\n    expect(\n      buildSelectionViewQuery({\n        view: {\n          filter,\n          sort: { sortObjs: orderBy },\n          group: groupBy,\n        },\n        personalViewCommonQuery: {\n          ignoreViewQuery: true,\n          filter,\n          orderBy,\n          groupBy,\n          projection: ['fldPrimary'],\n        },\n      })\n    ).toEqual({\n      projection: ['fldPrimary'],\n    });\n  });\n\n  it('keeps ignoreViewQuery when personal query intentionally clears a saved filter', () => {\n    const filter: NonNullable<IGetRecordsRo['filter']> = {\n      conjunction: 'and',\n      filterSet: [{ fieldId: 'fldValue', operator: 'is', value: 'Open' }],\n    };\n\n    expect(\n      buildSelectionViewQuery({\n        view: {\n          filter,\n        },\n        personalViewCommonQuery: {\n          ignoreViewQuery: true,\n          filter: null,\n          projection: ['fldPrimary'],\n        },\n      })\n    ).toEqual({\n      ignoreViewQuery: true,\n      filter: null,\n      projection: ['fldPrimary'],\n    });\n  });\n\n  it('keeps ignoreViewQuery when personal query changes sorting', () => {\n    const orderBy: NonNullable<IGetRecordsRo['orderBy']> = [{ fieldId: 'fldSort', order: 'asc' }];\n\n    expect(\n      buildSelectionViewQuery({\n        view: {\n          sort: { sortObjs: [{ fieldId: 'fldSort', order: 'desc' }] },\n        },\n        personalViewCommonQuery: {\n          ignoreViewQuery: true,\n          orderBy,\n          projection: ['fldPrimary'],\n        },\n      })\n    ).toEqual({\n      ignoreViewQuery: true,\n      orderBy,\n      projection: ['fldPrimary'],\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/grid/utils/selectionViewQuery.ts",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\nimport { isEqual } from 'lodash';\n\ntype IViewQueryLike = {\n  filter?: IGetRecordsRo['filter'];\n  sort?: { sortObjs?: IGetRecordsRo['orderBy'] } | null;\n  group?: IGetRecordsRo['groupBy'];\n};\n\ntype ISelectionViewQuery = Pick<\n  IGetRecordsRo,\n  'ignoreViewQuery' | 'filter' | 'orderBy' | 'groupBy' | 'projection'\n>;\n\n/**\n * Personal views always carry ignoreViewQuery=true, but selection APIs only need that\n * flag when the personal view actually changes row-targeting query state.\n */\nexport const buildSelectionViewQuery = ({\n  view,\n  personalViewCommonQuery,\n}: {\n  view?: IViewQueryLike;\n  personalViewCommonQuery?: ISelectionViewQuery;\n}): ISelectionViewQuery | undefined => {\n  if (!personalViewCommonQuery) {\n    return;\n  }\n\n  const { ignoreViewQuery, filter, orderBy, groupBy, projection } = personalViewCommonQuery;\n  if (!ignoreViewQuery) {\n    return personalViewCommonQuery;\n  }\n\n  const hasQueryDifference =\n    !isEqual(filter ?? null, view?.filter ?? null) ||\n    !isEqual(orderBy, view?.sort?.sortObjs) ||\n    !isEqual(groupBy, view?.group);\n\n  if (hasQueryDifference) {\n    return personalViewCommonQuery;\n  }\n\n  return projection ? { projection } : undefined;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/hooks/useContextMenu.ts",
    "content": "import { useBaseId, useTableId } from '@teable/sdk/hooks';\nimport { syncCopy } from '@teable/sdk/utils';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const useContextMenu = () => {\n  const baseId = useBaseId();\n  const tableId = useTableId();\n  const { publicOrigin } = useEnv();\n  const router = useRouter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const copyRecordUrl = useCallback(\n    async (recordId: string) => {\n      if (!baseId || !tableId || !recordId) return;\n      const recordUrl = `${publicOrigin}/base/${baseId}/table/${tableId}?recordId=${recordId}`;\n      await syncCopy(recordUrl);\n      toast.success(t('sdk:expandRecord.copy'));\n    },\n    [publicOrigin, baseId, tableId, t]\n  );\n\n  const viewRecordHistory = useCallback(\n    async (recordId: string) => {\n      if (!baseId || !tableId || !recordId) return;\n      await router.push(\n        {\n          pathname: router.pathname,\n          query: { ...router.query, recordId, showHistory: true },\n        },\n        undefined,\n        {\n          shallow: true,\n        }\n      );\n    },\n    [baseId, tableId, router]\n  );\n\n  const addRecordComment = useCallback(\n    async (recordId: string) => {\n      if (!baseId || !tableId || !recordId) return;\n      await router.push(\n        {\n          pathname: router.pathname,\n          query: { ...router.query, recordId, showComment: true },\n        },\n        undefined,\n        {\n          shallow: true,\n        }\n      );\n    },\n    [baseId, tableId, router]\n  );\n\n  return {\n    copyRecordUrl,\n    viewRecordHistory,\n    addRecordComment,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/hooks/useToolbarChange.ts",
    "content": "import type { IGroup, IFilter, ISort, RowHeightLevel } from '@teable/core';\nimport { generateLocalId, useGridCollapsedGroupStore } from '@teable/sdk/components';\nimport { useTableId, useView } from '@teable/sdk/hooks';\nimport { useMemo } from 'react';\n\nexport const useToolbarChange = () => {\n  const tableId = useTableId();\n  const view = useView();\n  const { setCollapsedGroupMap } = useGridCollapsedGroupStore();\n\n  return useMemo(() => {\n    const onFilterChange = async (value: IFilter) => {\n      await view?.updateFilter(value);\n    };\n    const onSortChange = async (value: ISort) => {\n      await view?.updateSort?.(value);\n    };\n    const onGroupChange = async (value: IGroup) => {\n      setCollapsedGroupMap(generateLocalId(tableId, view?.id), []);\n      await view?.updateGroup?.(value);\n    };\n    const onRowHeightChange = async (rowHeight: RowHeightLevel) => {\n      await view?.updateOption({ rowHeight });\n    };\n    const onFieldNameDisplayLinesChange = async (fieldNameDisplayLines: number) => {\n      await view?.updateOption({ fieldNameDisplayLines });\n    };\n    return {\n      onFilterChange,\n      onSortChange,\n      onGroupChange,\n      onRowHeightChange,\n      onFieldNameDisplayLinesChange,\n    };\n  }, [setCollapsedGroupMap, tableId, view]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/KanbanView.tsx",
    "content": "import { GroupPointProvider, RecordProvider } from '@teable/sdk/context';\nimport { SearchProvider } from '@teable/sdk/context/query';\nimport { useIsHydrated, usePersonalView } from '@teable/sdk/hooks';\nimport { KanbanToolBar } from '../tool-bar/KanbanToolBar';\nimport { KanbanProvider } from './context';\nimport { KanbanViewBase } from './KanbanViewBase';\n\nexport const KanbanView = () => {\n  const isHydrated = useIsHydrated();\n  const { personalViewCommonQuery } = usePersonalView();\n\n  return (\n    <SearchProvider>\n      <RecordProvider>\n        <GroupPointProvider query={personalViewCommonQuery}>\n          <KanbanToolBar />\n          <KanbanProvider>\n            <div className=\"w-full grow overflow-hidden\">{isHydrated && <KanbanViewBase />}</div>\n          </KanbanProvider>\n        </GroupPointProvider>\n      </RecordProvider>\n    </SearchProvider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/KanbanViewBase.tsx",
    "content": "import { KanbanContainer } from './components';\nimport { useKanban } from './hooks';\n\nexport const KanbanViewBase = () => {\n  const { stackCollection } = useKanban();\n\n  if (stackCollection == null) {\n    return null;\n  }\n\n  return (\n    <div className=\"relative size-full overflow-x-auto overflow-y-hidden p-2\">\n      <KanbanContainer />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanCard.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */\nimport type { DraggableProvided } from '@hello-pangea/dnd';\nimport { FieldKeyType, type IAttachmentCellValue } from '@teable/core';\nimport {\n  ArrowDown,\n  ArrowUp,\n  Copy,\n  History,\n  Link,\n  Maximize2,\n  MessageSquare,\n  Trash2,\n} from '@teable/icons';\nimport type { IRecordInsertOrderRo } from '@teable/openapi';\nimport { createRecords, deleteRecord, duplicateRecord } from '@teable/openapi';\nimport { CellValue } from '@teable/sdk/components';\nimport { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks';\nimport type { Record } from '@teable/sdk/model';\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { CardCarousel } from '../../gallery/components';\nimport { useContextMenu } from '../../hooks/useContextMenu';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\nimport type { IStackData } from '../type';\nimport { getCellValueByStack } from '../utils';\n\ninterface IKanbanCardProps {\n  stack: IStackData;\n  card: Record;\n  provided: DraggableProvided;\n  isDragging?: boolean;\n}\n\nexport const KanbanCard = (props: IKanbanCardProps) => {\n  const { stack, card, provided, isDragging } = props;\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const {\n    permission,\n    stackField,\n    primaryField,\n    displayFields,\n    coverField,\n    isCoverFit,\n    isFieldNameHidden,\n    setExpandRecordId,\n  } = useKanban() as Required<IKanbanContext>;\n  const { copyRecordUrl, viewRecordHistory, addRecordComment } = useContextMenu();\n\n  const { cardCreatable, cardDeletable, cardEditable, cardCommentCreatable } = permission;\n  const { id: fieldId } = stackField;\n  const coverFieldId = coverField?.id;\n  const coverCellValue = card.getCellValue(coverFieldId as string) as\n    | IAttachmentCellValue\n    | undefined;\n\n  const titleComponent = useMemo(() => {\n    if (primaryField == null) return t('untitled');\n    const value = card.getCellValue(primaryField.id);\n    if (value == null) return t('untitled');\n    return <CellValue field={primaryField} value={value} className=\"text-base\" ellipsis />;\n  }, [card, primaryField, t]);\n\n  const onExpand = () => {\n    setExpandRecordId(card.id);\n  };\n\n  const onDelete = () => {\n    if (tableId == null) return;\n    deleteRecord(tableId, card.id);\n  };\n\n  const onDuplicate = () => {\n    if (tableId == null || viewId == null) return;\n    duplicateRecord(tableId, card.id, { viewId, anchorId: card.id, position: 'after' });\n  };\n\n  const onInsert = async (position: IRecordInsertOrderRo['position']) => {\n    if (tableId == null || viewId == null) return;\n    const cellValue = getCellValueByStack(stack);\n    const res = await createRecords(tableId, {\n      fieldKeyType: FieldKeyType.Id,\n      records: [\n        {\n          fields: { [fieldId]: cellValue },\n        },\n      ],\n      order: {\n        viewId,\n        anchorId: card.id,\n        position,\n      },\n    });\n    const record = res.data.records[0];\n\n    if (record != null) {\n      setExpandRecordId(record.id);\n    }\n  };\n\n  const onCopyRecordUrl = async () => {\n    await copyRecordUrl(card.id);\n  };\n\n  const onViewRecordHistory = async () => {\n    setExpandRecordId(card.id);\n    await viewRecordHistory(card.id);\n  };\n\n  const onAddRecordComment = async () => {\n    setExpandRecordId(card.id);\n    await addRecordComment(card.id);\n  };\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger>\n        <div ref={provided.innerRef} {...provided.draggableProps} className=\"w-full px-3 pb-2\">\n          <div\n            {...provided.dragHandleProps}\n            className={cn(\n              'relative flex w-full grow flex-col space-y-2  gap-1 overflow-hidden rounded-md border border-border bg-card hover:border-primary/15 p-3 cursor-pointer',\n              isDragging && 'shadow-md'\n            )}\n            onClick={onExpand}\n          >\n            {coverCellValue?.length && (\n              <CardCarousel value={coverCellValue} isCoverFit={isCoverFit} />\n            )}\n            <div className=\"text-base font-semibold\">{titleComponent}</div>\n            {displayFields.map((field) => {\n              const {\n                id: fieldId,\n                name,\n                type,\n                isLookup,\n                isConditionalLookup,\n                aiConfig,\n                canReadFieldRecord,\n              } = field;\n              const { Icon } = getFieldStatic(type, {\n                isLookup,\n                isConditionalLookup,\n                hasAiConfig: Boolean(aiConfig),\n                deniedReadRecord: !canReadFieldRecord,\n              });\n              const cellValue = card.getCellValue(fieldId);\n\n              if (cellValue == null) return null;\n\n              return (\n                <div key={fieldId}>\n                  {!isFieldNameHidden && (\n                    <div className=\"mb-1 flex items-center space-x-1 text-muted-foreground\">\n                      <Icon className=\"size-4 text-sm\" />\n                      <span className=\"text-xs\">{name}</span>\n                    </div>\n                  )}\n                  <CellValue field={field} value={cellValue} />\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </ContextMenuTrigger>\n      <ContextMenuContent className=\"w-52\">\n        {cardCreatable && (\n          <>\n            <ContextMenuItem onClick={() => onInsert('before')}>\n              <ArrowUp className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.insertCardAbove')}\n            </ContextMenuItem>\n            <ContextMenuItem onClick={() => onInsert('after')}>\n              <ArrowDown className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.insertCardBelow')}\n            </ContextMenuItem>\n            <ContextMenuSeparator />\n            <ContextMenuItem onClick={onDuplicate}>\n              <Copy className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.duplicateCard')}\n            </ContextMenuItem>\n          </>\n        )}\n        <ContextMenuItem onClick={onExpand}>\n          <Maximize2 className=\"mr-2 size-4\" />\n          {t('table:kanban.cardMenu.expandCard')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem onClick={onCopyRecordUrl}>\n          <Link className=\"mr-2 size-4\" />\n          {t('sdk:expandRecord.copyRecordUrl')}\n        </ContextMenuItem>\n        {cardEditable && (\n          <ContextMenuItem onClick={onViewRecordHistory}>\n            <History className=\"mr-2 size-4\" />\n            {t('sdk:expandRecord.viewRecordHistory')}\n          </ContextMenuItem>\n        )}\n        {cardCommentCreatable && (\n          <ContextMenuItem onClick={onAddRecordComment}>\n            <MessageSquare className=\"mr-2 size-4\" />\n            {t('sdk:expandRecord.addRecordComment')}\n          </ContextMenuItem>\n        )}\n        {cardDeletable && !card.undeletable && (\n          <>\n            <ContextMenuSeparator />\n            <ContextMenuItem className=\"text-destructive focus:text-destructive\" onClick={onDelete}>\n              <Trash2 className=\"mr-2 size-4\" />\n              {t('table:kanban.cardMenu.deleteCard')}\n            </ContextMenuItem>\n          </>\n        )}\n      </ContextMenuContent>\n    </ContextMenu>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanContainer.tsx",
    "content": "import { DragDropContext, Droppable } from '@hello-pangea/dnd';\nimport type { DropResult } from '@hello-pangea/dnd';\nimport { FieldKeyType, FieldType } from '@teable/core';\nimport type { IUpdateRecordRo } from '@teable/openapi';\nimport { generateLocalId } from '@teable/sdk/components';\nimport { useTableId, useViewId, useRecordOperations } from '@teable/sdk/hooks';\nimport { keyBy } from 'lodash';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { UNCATEGORIZED_STACK_ID } from '../constant';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\nimport { useKanbanStackCollapsedStore } from '../store';\nimport { getCellValueByStack, moveTo, reorder } from '../utils';\nimport type { ICardMap } from './interface';\nimport { KanbanStackContainer } from './KanbanStackContainer';\nimport { KanbanStackCreator } from './KanbanStackCreator';\n\nconst EMPTY_LIST: never[] = [];\n\nexport const KanbanContainer = () => {\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const { updateRecord, updateRecordOrders } = useRecordOperations();\n  const { collapsedStackMap } = useKanbanStackCollapsedStore();\n  const { permission, stackField, stackCollection } = useKanban() as Required<IKanbanContext>;\n\n  const [cardMap, setCardMap] = useState<ICardMap>({});\n  const [stackIds, setStackIds] = useState(stackCollection.map(({ id }) => id));\n\n  const localId = generateLocalId(tableId, viewId);\n  const { stackCreatable } = permission;\n  const { id: fieldId, type: fieldType, isLookup } = stackField;\n  const isSingleSelectField = fieldType === FieldType.SingleSelect && !isLookup;\n\n  const collapsedStackIdSet = useMemo(() => {\n    return new Set(collapsedStackMap[localId] ?? []);\n  }, [localId, collapsedStackMap]);\n\n  useEffect(() => {\n    setStackIds(stackCollection.map(({ id }) => id));\n  }, [stackCollection]);\n\n  const stackMap = useMemo(() => keyBy(stackCollection, 'id'), [stackCollection]);\n\n  const setCardMapInner = useCallback((partialCardMap: ICardMap) => {\n    setCardMap((prev) => ({ ...prev, ...partialCardMap }));\n  }, []);\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const onDragEnd = (result: DropResult) => {\n    const { source, destination } = result;\n\n    if (!destination) return;\n\n    const { droppableId: sourceStackId, index: sourceIndex } = source;\n    const { droppableId: targetStackId, index: targetIndex } = destination;\n\n    if (sourceStackId === viewId) {\n      const newStackIds = reorder(stackIds, sourceIndex, targetIndex);\n\n      if (!isSingleSelectField || sourceIndex === targetIndex) {\n        return;\n      }\n\n      setStackIds(newStackIds);\n\n      const { choices } = stackField.options;\n      const choiceMap = keyBy(choices, 'name');\n      const newChoices = newStackIds\n        .map((choiceId) => {\n          if (choiceId === UNCATEGORIZED_STACK_ID) return;\n          const stack = stackMap[choiceId];\n          if (stack == null) return;\n          return choiceMap[stack.data as string];\n        })\n        .filter((choice): choice is NonNullable<typeof choice> => Boolean(choice));\n      stackField.convert({\n        type: fieldType,\n        options: { ...stackField.options, choices: newChoices },\n      });\n      return;\n    }\n\n    if (sourceStackId === targetStackId) {\n      const cards = cardMap[sourceStackId];\n      const cardCount = cards?.length;\n\n      if (!cardCount) return;\n\n      if (sourceIndex < cardCount && targetIndex < cardCount) {\n        if (tableId && viewId) {\n          updateRecordOrders({\n            tableId,\n            viewId,\n            order: {\n              anchorId: cards[targetIndex].id,\n              position: targetIndex > sourceIndex ? 'after' : 'before',\n              recordIds: [cards[sourceIndex].id],\n            },\n          });\n        }\n\n        const newCards = reorder(cards, sourceIndex, targetIndex);\n\n        setCardMapInner({ [sourceStackId]: newCards });\n      }\n      return;\n    }\n\n    const sourceCards = cardMap[sourceStackId];\n    const targetCards = cardMap[targetStackId];\n    const sourceCardId = sourceCards?.[sourceIndex]?.id;\n    const targetCardId = targetCards?.[targetIndex]?.id;\n\n    if (tableId && viewId && sourceCardId) {\n      const stack = stackCollection.find(({ id }) => id === targetStackId);\n\n      if (stack == null) return;\n\n      const fieldValue = getCellValueByStack(stack);\n\n      const recordRo: IUpdateRecordRo = {\n        fieldKeyType: FieldKeyType.Id,\n        record: {\n          fields: {\n            [fieldId]: fieldValue,\n          },\n        },\n      };\n\n      // Drag a card to the end of another stack\n      if (targetCardId == null) {\n        if (targetIndex !== 0) {\n          const lastTargetCardId = targetCards?.[targetIndex - 1]?.id;\n          if (lastTargetCardId != null) {\n            recordRo.order = {\n              viewId,\n              anchorId: lastTargetCardId,\n              position: 'after',\n            };\n          }\n        }\n      } else {\n        recordRo.order = {\n          viewId,\n          anchorId: targetCardId,\n          position: 'before',\n        };\n      }\n\n      updateRecord({\n        tableId,\n        recordId: sourceCardId,\n        recordRo,\n      });\n    }\n\n    const { sourceList, targetList } = moveTo({\n      source: sourceCards,\n      target: targetCards,\n      sourceIndex,\n      targetIndex,\n    });\n\n    setCardMapInner({\n      [sourceStackId]: sourceList,\n      [targetStackId]: targetList,\n    });\n  };\n\n  return (\n    <DragDropContext onDragEnd={onDragEnd}>\n      <div className=\"flex h-full\">\n        <Droppable droppableId={viewId!} direction=\"horizontal\" type=\"column\">\n          {(provided) => {\n            const { droppableProps, placeholder } = provided;\n\n            return (\n              <div ref={provided.innerRef} {...droppableProps} className=\"flex shrink-0\">\n                {stackIds.map((stackId, index) => {\n                  const stack = stackMap[stackId];\n                  if (stack == null) return null;\n                  const isCollapsed = collapsedStackIdSet.has(stackId as string);\n                  return (\n                    <KanbanStackContainer\n                      key={stackId}\n                      index={index}\n                      stack={stack}\n                      cards={cardMap[stackId] ?? EMPTY_LIST}\n                      setCardMap={setCardMapInner}\n                      disabled={!isSingleSelectField}\n                      isCollapsed={isCollapsed}\n                    />\n                  );\n                })}\n                {placeholder}\n              </div>\n            );\n          }}\n        </Droppable>\n        {stackCreatable && isSingleSelectField && (\n          <div className=\"pr-2\">\n            <KanbanStackCreator />\n          </div>\n        )}\n      </div>\n    </DragDropContext>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStack.tsx",
    "content": "/* eslint-disable @typescript-eslint/ban-ts-comment */\nimport { Draggable, Droppable } from '@hello-pangea/dnd';\nimport type { IFilter } from '@teable/core';\nimport { and, mergeFilter } from '@teable/core';\nimport { useRecords } from '@teable/sdk/hooks';\nimport type { Record } from '@teable/sdk/model';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useEffect, useMemo, useState } from 'react';\nimport { useMeasure } from 'react-use';\nimport type { ListRange, VirtuosoHandle } from 'react-virtuoso';\nimport { Virtuoso } from 'react-virtuoso';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\nimport type { IStackData } from '../type';\nimport { getFilterSet } from '../utils';\nimport type { ICardMap } from './interface';\nimport { KanbanCard } from './KanbanCard';\n\ninterface IKanbanStackProps {\n  stack: IStackData;\n  cards: Record[];\n  setCardMap?: (partialItemMap: ICardMap) => void;\n}\n\nconst LOAD_COUNT = 100;\nconst TAKE_COUNT = 200;\n\n// @ts-ignore\nexport const HeightPreservingItem = ({ children, ...props }) => {\n  const [size, setSize] = useState(0);\n  const knownSize = props['data-known-size'];\n\n  useEffect(() => {\n    setSize((prevSize) => {\n      return knownSize == 0 ? prevSize : knownSize;\n    });\n  }, [knownSize]);\n\n  return (\n    <div\n      {...props}\n      className=\"height-preserving-container\"\n      style={{\n        // @ts-ignore\n        '--child-height': `${size}px`,\n      }}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport const KanbanStack = forwardRef<VirtuosoHandle, IKanbanStackProps>((props, forwardRef) => {\n  const { stack, cards, setCardMap } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { stackField, permission, recordQuery } = useKanban() as Required<IKanbanContext>;\n  const [skipIndex, setSkipIndex] = useState(0);\n  const [ref, { height }] = useMeasure<HTMLDivElement>();\n\n  const cardCount = cards.length;\n  const { cardDraggable } = permission;\n  const { isComputed } = stackField;\n  const { id: stackId, count: stackCount } = stack;\n\n  const mergedFilter = useMemo(() => {\n    const outerFilter = recordQuery?.filter;\n    const filterSet = getFilterSet(stackField, stack);\n    return mergeFilter(outerFilter, {\n      conjunction: and.value,\n      filterSet,\n    }) as IFilter;\n  }, [recordQuery?.filter, stack, stackField]);\n\n  const query = useMemo(() => {\n    return {\n      ...recordQuery,\n      skip: skipIndex,\n      take: TAKE_COUNT,\n      filter: mergedFilter,\n    };\n  }, [recordQuery, skipIndex, mergedFilter]);\n\n  const { records } = useRecords(query);\n\n  const sortedRecords = useMemo(() => {\n    return records.filter(Boolean);\n  }, [records]);\n\n  useEffect(() => {\n    if (stackCount && !sortedRecords.length) return;\n    setCardMap?.({ [stackId]: sortedRecords });\n  }, [setCardMap, sortedRecords, stackId, stackCount]);\n\n  const onRangeChanged = (range: ListRange) => {\n    const { startIndex } = range;\n    const willSkipIndex = Math.max(0, Math.floor(startIndex / LOAD_COUNT) * LOAD_COUNT);\n    if (willSkipIndex !== skipIndex) {\n      setSkipIndex(willSkipIndex);\n    }\n  };\n\n  const itemCount = useMemo(() => {\n    if (stackCount == null) return 0;\n    if (cardCount > stackCount) return cardCount;\n    if (cardCount > TAKE_COUNT) return stackCount + 1;\n    return stackCount;\n  }, [cardCount, stackCount]);\n\n  return (\n    <div ref={ref} className=\"size-full pt-3\">\n      <Droppable\n        droppableId={stackId}\n        mode=\"virtual\"\n        renderClone={(provided, snapshot, rubric) => {\n          const card = cards[rubric.source.index];\n          const { isDragging } = snapshot;\n          return (\n            <KanbanCard provided={provided} card={card} stack={stack} isDragging={isDragging} />\n          );\n        }}\n      >\n        {(provided, _snapshot) => (\n          <Virtuoso\n            ref={forwardRef}\n            scrollerRef={provided.innerRef as never}\n            components={{\n              Item: HeightPreservingItem as never,\n              EmptyPlaceholder: () => (\n                <div className=\"flex size-full items-center justify-center text-sm text-muted-foreground\">\n                  {t('table:kanban.stack.noCards')}\n                </div>\n              ),\n            }}\n            style={{ width: '100%', height }}\n            totalCount={itemCount}\n            itemContent={(index) => {\n              const realIndex = index - skipIndex;\n              const card = cards[realIndex];\n              if (card == null) {\n                return <div className=\"h-32 w-full\" />;\n              }\n              return (\n                <Draggable\n                  draggableId={card.id}\n                  index={realIndex}\n                  key={card.id}\n                  isDragDisabled={!cardDraggable || isComputed}\n                >\n                  {(provided) => <KanbanCard provided={provided} card={card} stack={stack} />}\n                </Draggable>\n              );\n            }}\n            rangeChanged={onRangeChanged}\n          />\n        )}\n      </Droppable>\n    </div>\n  );\n});\n\nKanbanStack.displayName = 'KanbanStack';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStackContainer.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */\nimport { Draggable } from '@hello-pangea/dnd';\nimport { Plus } from '@teable/icons';\nimport { CreateRecordModal, generateLocalId } from '@teable/sdk/components';\nimport { useTableId, useViewId } from '@teable/sdk/hooks';\nimport type { Record } from '@teable/sdk/model';\nimport { Button, cn } from '@teable/ui-lib';\nimport { useRef, useState } from 'react';\nimport type { VirtuosoHandle } from 'react-virtuoso';\nimport { UNCATEGORIZED_STACK_ID } from '../constant';\nimport type { IKanbanContext } from '../context';\nimport { useInView, useKanban } from '../hooks';\nimport { useKanbanStackCollapsedStore } from '../store';\nimport type { IStackData } from '../type';\nimport type { ICardMap } from './interface';\nimport { KanbanStack } from './KanbanStack';\nimport { KanbanStackHeader } from './KanbanStackHeader';\nimport { KanbanStackTitle } from './KanbanStackTitle';\n\ninterface IKanbanStackContainerProps {\n  index: number;\n  stack: IStackData;\n  cards: Record[];\n  disabled?: boolean;\n  isCollapsed?: boolean;\n  setCardMap?: (partialItemMap: ICardMap) => void;\n}\n\nexport const KanbanStackContainer = (props: IKanbanStackContainerProps) => {\n  const { index, stack, cards, disabled, isCollapsed, setCardMap } = props;\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const { collapsedStackMap, setCollapsedStackMap } = useKanbanStackCollapsedStore();\n  const { permission } = useKanban() as Required<IKanbanContext>;\n  const [ref, isInView] = useInView();\n  const [editMode, setEditMode] = useState(false);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  const { id: stackId } = stack;\n  const { stackDraggable, cardCreatable } = permission;\n  const isUncategorized = stackId === UNCATEGORIZED_STACK_ID;\n  const draggable = stackDraggable && !disabled && !editMode && !isUncategorized;\n\n  const onAppendCallback = () => {\n    setTimeout(() => {\n      virtuosoRef.current?.scrollToIndex({\n        index: 'LAST',\n      });\n    }, 500);\n  };\n\n  const onStackExpand = () => {\n    const localId = generateLocalId(tableId, viewId);\n    const collapsedStackIdSet = new Set(collapsedStackMap[localId] ?? []);\n    collapsedStackIdSet.delete(stackId);\n    setCollapsedStackMap(localId, [...collapsedStackIdSet]);\n  };\n\n  return (\n    <Draggable draggableId={stackId} index={index} key={stackId} isDragDisabled={!draggable}>\n      {(provided, snapshot) => {\n        const { draggableProps, dragHandleProps } = provided;\n        const { isDragging } = snapshot;\n\n        return (\n          <div className=\"h-full pr-4\" ref={provided.innerRef} {...draggableProps}>\n            {isCollapsed ? (\n              <div className=\"h-full w-14\">\n                <div\n                  className={cn(\n                    'h-64 w-full cursor-grab rounded-md border bg-slate-50 hover:bg-slate-100 dark:bg-zinc-900 dark:hover:bg-zinc-800',\n                    isDragging && 'shadow-md'\n                  )}\n                  {...dragHandleProps}\n                  onClick={onStackExpand}\n                >\n                  <div\n                    style={{ transform: 'rotate(-90deg) translateX(-100%)' }}\n                    className=\"flex h-14 w-64 origin-top-left items-center px-4\"\n                  >\n                    <KanbanStackTitle stack={stack} isUncategorized={isUncategorized} />\n                  </div>\n                </div>\n              </div>\n            ) : (\n              <div\n                className={cn(\n                  'w-[264px] h-full border bg-muted rounded-md shrink-0 flex flex-col overflow-hidden',\n                  isDragging && 'shadow-md'\n                )}\n              >\n                <div ref={ref} className=\"flex size-full flex-col justify-between\">\n                  <div {...dragHandleProps} className=\"w-full\">\n                    <KanbanStackHeader\n                      stack={stack}\n                      isUncategorized={isUncategorized}\n                      setEditMode={setEditMode}\n                    />\n                  </div>\n\n                  <div className=\"w-full grow\">\n                    {isInView && (\n                      <KanbanStack\n                        ref={virtuosoRef}\n                        stack={stack}\n                        cards={cards}\n                        setCardMap={setCardMap}\n                      />\n                    )}\n                  </div>\n\n                  {cardCreatable && (\n                    <CreateRecordModal callback={onAppendCallback}>\n                      <div className=\"flex items-center justify-center rounded-b-md bg-slate-50 px-3 py-2 dark:bg-muted\">\n                        <Button variant=\"outline\" className=\"w-full shadow-none hover:bg-zinc-700\">\n                          <Plus className=\"size-5\" />\n                        </Button>\n                      </div>\n                    </CreateRecordModal>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        );\n      }}\n    </Draggable>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStackCreator.tsx",
    "content": "import { ColorUtils, type ISelectFieldChoice } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport type { SingleSelectField } from '@teable/sdk/model';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { ChoiceItem } from '@/features/app/components/field-setting/options/SelectOptions';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\n\nexport const KanbanStackCreator = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { stackField } = useKanban() as Required<IKanbanContext>;\n  const { type, options } = stackField as SingleSelectField;\n  const choices = options?.choices ?? [];\n\n  const [open, setOpen] = useState(false);\n  const [choice, setChoice] = useState<ISelectFieldChoice | null>();\n  const inputRef = useRef<HTMLInputElement | null>();\n\n  const onToggle = () => {\n    const existColors = choices.map((v) => v.color);\n    const newChoice = {\n      name: '',\n      color: ColorUtils.randomColor(existColors)[0],\n    } as ISelectFieldChoice;\n    setChoice(newChoice);\n    setTimeout(() => inputRef.current?.focus());\n  };\n\n  const onChange = (key: keyof ISelectFieldChoice, value: string) => {\n    setChoice({\n      ...(choice as ISelectFieldChoice),\n      [key]: value,\n    });\n  };\n\n  const onOptionUpdate = () => {\n    const value = inputRef.current?.value;\n    if (!value) return;\n    const newChoices = [...choices, { ...choice, name: value }];\n    stackField.convert({\n      type,\n      options: { ...options, choices: newChoices },\n    });\n  };\n\n  const onOpenChange = (open: boolean) => {\n    if (!open && choice) {\n      onOptionUpdate();\n    }\n    setOpen(open);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      onOptionUpdate();\n      setChoice(null);\n      setOpen(false);\n    }\n  };\n\n  return (\n    <Popover open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger asChild>\n        <Button variant=\"outline\" size=\"lg\" onClick={onToggle}>\n          <Plus className=\"size-4\" />\n          {t('table:kanban.stack.addStack')}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"max-h-64 w-52\">\n        {choice && (\n          <ChoiceItem\n            choice={choice}\n            onChange={onChange}\n            onKeyDown={onKeyDown}\n            onInputRef={(el) => (inputRef.current = el)}\n          />\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStackHeader.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport type { ISelectFieldOptions, ISelectFieldChoice } from '@teable/core';\nimport { ChevronDown, Minimize2, Pencil, Trash2 } from '@teable/icons';\nimport { generateLocalId } from '@teable/sdk/components';\nimport { useTableId, useViewId } from '@teable/sdk/hooks';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib';\nimport { isEqual } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { useRef, useState } from 'react';\nimport { useClickAway } from 'react-use';\nimport { ChoiceItem } from '@/features/app/components/field-setting/options/SelectOptions';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\nimport { useKanbanStackCollapsedStore } from '../store';\nimport type { IStackData } from '../type';\nimport { KanbanStackTitle } from './KanbanStackTitle';\n\ninterface IKanbanStackHeaderProps {\n  stack: IStackData;\n  isUncategorized?: boolean;\n  setEditMode: Dispatch<SetStateAction<boolean>>;\n}\n\nexport const KanbanStackHeader = (props: IKanbanStackHeaderProps) => {\n  const { stack, isUncategorized, setEditMode } = props;\n\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const { collapsedStackMap, setCollapsedStackMap } = useKanbanStackCollapsedStore();\n  const { permission, stackField } = useKanban() as Required<IKanbanContext>;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const { type, options, isLookup } = stackField;\n  const { id: stackId, data: stackData } = stack;\n  const { stackEditable, stackDeletable } = permission;\n  const isSingleSelectField = type === FieldType.SingleSelect && !isLookup;\n  const choices = (options as ISelectFieldOptions)?.choices ?? [];\n\n  const choiceRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement | null>();\n  const [renamingChoice, setRenamingChoice] = useState<ISelectFieldChoice | null>();\n\n  const onStackRename = () => {\n    if (!stackEditable) return;\n\n    const curChoice = choices.find((choice) => choice.name === stackData);\n\n    if (curChoice == null) return;\n\n    setEditMode(true);\n    setRenamingChoice({ ...curChoice });\n  };\n\n  const onChange = (key: keyof ISelectFieldChoice, value: string) => {\n    setRenamingChoice({\n      ...(renamingChoice as ISelectFieldChoice),\n      [key]: value,\n    });\n  };\n\n  const onOptionUpdate = () => {\n    const value = inputRef.current?.value;\n    if (!value || !renamingChoice || isEqual(value, stackData)) return;\n    const newChoice: ISelectFieldChoice = { ...renamingChoice, name: value };\n    const newChoices = choices.map((choice) => {\n      if (choice.name === stackData) return newChoice;\n      return choice;\n    });\n    stackField.convert({\n      type,\n      options: { ...options, choices: newChoices } as ISelectFieldOptions,\n    });\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      onOptionUpdate();\n      setRenamingChoice(null);\n    }\n  };\n\n  const onStackDelete = () => {\n    const choices = (options as ISelectFieldOptions)?.choices ?? [];\n    const newChoices = choices.filter((choice) => choice.name !== stackData);\n    stackField.convert({\n      type,\n      options: { ...options, choices: newChoices } as ISelectFieldOptions,\n    });\n  };\n\n  const onStackCollapsed = () => {\n    const localId = generateLocalId(tableId, viewId);\n    const collapsedStackIdSet = new Set(collapsedStackMap[localId] ?? []);\n    collapsedStackIdSet.add(stackId);\n    setCollapsedStackMap(localId, [...collapsedStackIdSet]);\n  };\n\n  useClickAway(choiceRef, () => {\n    if (isSingleSelectField) {\n      onOptionUpdate();\n      setEditMode(false);\n      setRenamingChoice(null);\n    }\n  });\n\n  return (\n    <div className=\"flex h-12 w-full shrink-0 items-center justify-between border-b bg-card px-4\">\n      {renamingChoice ? (\n        <div ref={choiceRef}>\n          <ChoiceItem\n            choice={renamingChoice}\n            onChange={onChange}\n            onKeyDown={onKeyDown}\n            onInputRef={(el) => (inputRef.current = el)}\n          />\n        </div>\n      ) : (\n        <KanbanStackTitle\n          stack={stack}\n          isUncategorized={isUncategorized}\n          onClick={() => {\n            if (!isSingleSelectField) return;\n            onStackRename();\n            setTimeout(() => inputRef.current?.focus());\n          }}\n        />\n      )}\n      <DropdownMenu>\n        <DropdownMenuTrigger>\n          <ChevronDown className=\"size-5\" />\n        </DropdownMenuTrigger>\n        <DropdownMenuContent\n          className=\"w-52\"\n          onCloseAutoFocus={(e) => {\n            if (isSingleSelectField) {\n              e.preventDefault();\n              inputRef.current?.focus();\n            }\n          }}\n        >\n          <DropdownMenuItem className=\"cursor-pointer\" onClick={onStackCollapsed}>\n            <Minimize2 className=\"mr-2 size-4\" />\n            {t('table:kanban.stackMenu.collapseStack')}\n          </DropdownMenuItem>\n          {isSingleSelectField && !isUncategorized && (\n            <>\n              {stackEditable && (\n                <DropdownMenuItem className=\"cursor-pointer\" onClick={onStackRename}>\n                  <Pencil className=\"mr-2 size-4\" />\n                  {t('table:kanban.stackMenu.renameStack')}\n                </DropdownMenuItem>\n              )}\n              {stackDeletable && (\n                <>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuItem\n                    className=\"cursor-pointer text-destructive focus:text-destructive\"\n                    onClick={onStackDelete}\n                  >\n                    <Trash2 className=\"mr-2 size-4\" />\n                    {t('table:kanban.stackMenu.deleteStack')}\n                  </DropdownMenuItem>\n                </>\n              )}\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStackTitle.tsx",
    "content": "import { CellValue } from '@teable/sdk/components';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IKanbanContext } from '../context';\nimport { useKanban } from '../hooks';\nimport type { IStackData } from '../type';\n\ninterface IKanbanStackTitle {\n  stack: IStackData;\n  isUncategorized?: boolean;\n  onClick?: () => void;\n}\n\nexport const KanbanStackTitle = (props: IKanbanStackTitle) => {\n  const { stack, isUncategorized, onClick } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { stackField } = useKanban() as Required<IKanbanContext>;\n\n  const { data: stackData, count: stackCount } = stack;\n\n  return (\n    <>\n      {isUncategorized ? (\n        <div className=\"flex items-center space-x-2 overflow-hidden text-muted-foreground\">\n          <span className=\"text-sm font-semibold\">{t('table:kanban.stack.uncategorized')}</span>\n          <span className=\"rounded-xl border px-2 text-xs\">{stackCount}</span>\n        </div>\n      ) : (\n        // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions\n        <div\n          className=\"flex items-center space-x-2 overflow-hidden text-muted-foreground\"\n          onClick={onClick}\n        >\n          <div className=\"min-w-0 flex-1 overflow-hidden\">\n            <CellValue\n              field={stackField}\n              value={stackData}\n              ellipsis\n              plainLongText\n              className=\"flex-nowrap overflow-hidden\"\n              itemClassName=\"overflow-hidden shrink-0\"\n            />\n          </div>\n          <span className=\"shrink-0 rounded-xl border px-2 text-xs\">{stackCount}</span>\n        </div>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/index.ts",
    "content": "export * from './KanbanContainer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/components/interface.ts",
    "content": "import type { Record as IRecord } from '@teable/sdk/model';\n\nexport type ICardMap = Record<string, IRecord[]>;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/constant.ts",
    "content": "import { FieldType } from '@teable/core';\n\nexport const UNCATEGORIZED_STACK_ID = 't_kanban_uncategorized';\nexport const UNCATEGORIZED_STACK_NAME = 'Uncategorized';\nexport const UNCATEGORIZED_STACK_EMAIL = 'unknown@teable.ai';\n\nexport const KANBAN_STACK_DISABLED_FIELD_TYPES = [FieldType.Attachment];\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/context/KanbanContext.ts",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\nimport type { AttachmentField, IFieldInstance } from '@teable/sdk/model';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { createContext } from 'react';\nimport type { IKanbanPermission, IStackData } from '../type';\n\nexport interface IKanbanContext {\n  recordQuery?: Pick<IGetRecordsRo, 'filter' | 'orderBy'>;\n  stackField?: IFieldInstance;\n  stackCollection?: IStackData[];\n  coverField?: AttachmentField;\n  isCoverFit?: boolean;\n  isFieldNameHidden?: boolean;\n  isEmptyStackHidden?: boolean;\n  permission: IKanbanPermission;\n  primaryField: IFieldInstance;\n  displayFields: IFieldInstance[];\n  setExpandRecordId: Dispatch<SetStateAction<string | undefined>>;\n}\n\nexport const KanbanContext = createContext<IKanbanContext>(null!);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/context/KanbanProvider.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IUserCellValue } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport type { IShareViewCollaboratorsRo, UserCollaboratorItem } from '@teable/openapi';\nimport {\n  getBaseCollaboratorList,\n  getShareViewCollaborators,\n  GroupPointType,\n  PrincipalType,\n} from '@teable/openapi';\nimport { ExpandRecorder } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ShareViewContext } from '@teable/sdk/context';\nimport {\n  useView,\n  useFields,\n  useTableId,\n  useGroupPoint,\n  useTablePermission,\n  useFieldPermission,\n  useBaseId,\n  usePersonalView,\n  useIsReadOnlyPreview,\n  useButtonClickStatus,\n} from '@teable/sdk/hooks';\nimport type { KanbanView, IFieldInstance, AttachmentField } from '@teable/sdk/model';\nimport { useRouter } from 'next/router';\nimport type { ReactNode } from 'react';\nimport { useContext, useEffect, useMemo, useState } from 'react';\nimport { UNCATEGORIZED_STACK_ID } from '../constant';\nimport { KanbanContext } from './KanbanContext';\n\nconst UNCATEGORIZED_STACK_DATA = {\n  id: UNCATEGORIZED_STACK_ID,\n  count: 0,\n  data: null,\n};\n\nexport const KanbanProvider = ({ children }: { children: ReactNode }) => {\n  const tableId = useTableId();\n  const view = useView() as KanbanView | undefined;\n  const { personalViewCommonQuery } = usePersonalView();\n  const baseId = useBaseId() as string;\n  const { shareId } = useContext(ShareViewContext) ?? {};\n  const { sort, filter } = view ?? {};\n  const permission = useTablePermission();\n  const fields = useFields();\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { stackFieldId, coverFieldId, isCoverFit, isFieldNameHidden, isEmptyStackHidden } =\n    view?.options ?? {};\n  const fieldPermission = useFieldPermission();\n  const [expandRecordId, setExpandRecordId] = useState<string>();\n  const buttonClickStatusHook = useButtonClickStatus(tableId!, shareId);\n  const groupPoints = useGroupPoint();\n  const router = useRouter();\n  const {\n    recordId: routerRecordId,\n    showHistory: routerShowHistory,\n    showComment: routerShowComment,\n  } = router.query;\n  const showHistory = routerShowHistory === 'true';\n  const showComment = { true: true, false: false }[routerShowComment as string];\n\n  useEffect(() => {\n    setExpandRecordId(routerRecordId as string);\n  }, [routerRecordId, setExpandRecordId]);\n\n  const recordQuery = useMemo(() => {\n    const { ignoreViewQuery } = personalViewCommonQuery ?? {};\n    const baseQuery = {\n      orderBy: sort?.sortObjs,\n      filter: filter,\n    };\n\n    if (shareId) return baseQuery;\n\n    if (ignoreViewQuery) {\n      return {\n        ...baseQuery,\n        ignoreViewQuery,\n      };\n    }\n  }, [shareId, sort, filter, personalViewCommonQuery]);\n\n  const stackField = useMemo(() => {\n    if (!stackFieldId) return;\n    return allFields.find(({ id }) => id === stackFieldId);\n  }, [stackFieldId, allFields]);\n\n  const { type, isMultipleCellValue } = stackField ?? {};\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { data: shareViewCollaborators } = useQuery({\n    queryKey: ReactQueryKeys.shareViewCollaborators(shareId, {\n      type: PrincipalType.User,\n      skip: 0,\n      take: 5000,\n    }),\n    queryFn: ({ queryKey }) =>\n      getShareViewCollaborators(queryKey[1], queryKey[2] as IShareViewCollaboratorsRo).then(\n        (data) => data.data\n      ),\n    enabled: Boolean(shareId && type === FieldType.User && !isMultipleCellValue),\n  });\n\n  const { data: baseCollaborators } = useQuery({\n    queryKey: ReactQueryKeys.baseCollaboratorList(baseId, {\n      includeSystem: true,\n      skip: 0,\n      take: 5000,\n      type: PrincipalType.User,\n    }),\n    queryFn: ({ queryKey }) =>\n      getBaseCollaboratorList(queryKey[1], queryKey[2]).then((data) => data.data),\n    enabled:\n      !shareId &&\n      Boolean(baseId && type === FieldType.User && !isMultipleCellValue && !isReadOnlyPreview),\n  });\n\n  const userList = shareId\n    ? shareViewCollaborators\n    : (baseCollaborators?.collaborators as UserCollaboratorItem[]);\n\n  const stackFieldRecordEditable = stackField?.canReadFieldRecord;\n\n  const kanbanPermission = useMemo(() => {\n    return {\n      stackCreatable: Boolean(fieldPermission['field|update']),\n      stackEditable: Boolean(fieldPermission['field|update']),\n      stackDeletable: Boolean(fieldPermission['field|update']),\n      stackDraggable: Boolean(fieldPermission['field|update']),\n      cardCreatable: Boolean(permission['record|create']),\n      cardEditable: Boolean(permission['record|update']),\n      cardDeletable: Boolean(permission['record|delete']),\n      cardDraggable: Boolean(\n        permission['record|update'] && permission['view|update'] && stackFieldRecordEditable\n      ),\n      cardCommentCreatable: Boolean(permission['record|comment']),\n    };\n  }, [permission, fieldPermission, stackFieldRecordEditable]);\n\n  const stackCollection = useMemo(() => {\n    if (groupPoints == null || stackField == null) return;\n\n    const { type, options, isMultipleCellValue } = stackField;\n    const isDisabledStackField = type === FieldType.Attachment;\n\n    if (isDisabledStackField) return;\n\n    if (!stackFieldRecordEditable) {\n      return [UNCATEGORIZED_STACK_DATA];\n    }\n\n    const stackList: { id: string; count: number; data: unknown }[] = [];\n    const stackMap: Record<string, { id: string; count: number; data: unknown }> = {};\n\n    groupPoints.forEach((cur, index) => {\n      if (cur.type !== GroupPointType.Header) return;\n\n      const { id: groupId, value } = cur;\n      const rowData = groupPoints[index + 1];\n\n      if (rowData?.type !== GroupPointType.Row) return;\n      if (value == null) return;\n\n      const { count } = rowData;\n      const obj = {\n        id: groupId,\n        count,\n        data: value,\n      };\n      stackList.push(obj);\n\n      if (type === FieldType.SingleSelect) {\n        stackMap[value as string] = obj;\n      }\n\n      if (type === FieldType.User && !isMultipleCellValue) {\n        stackMap[(value as IUserCellValue).id] = obj;\n      }\n    });\n\n    if (type === FieldType.SingleSelect) {\n      const choices = options?.choices;\n      const stackList = choices.map(\n        ({ id, name }) =>\n          stackMap[name] ?? {\n            id,\n            count: 0,\n            data: name,\n          }\n      );\n      stackList.unshift(UNCATEGORIZED_STACK_DATA);\n      if (isEmptyStackHidden) {\n        return stackList.filter(({ count }) => count > 0);\n      }\n\n      return stackList;\n    }\n\n    if (type === FieldType.User && !isMultipleCellValue && userList) {\n      const stackList = userList.map(\n        ({ userId, userName, email, avatar }) =>\n          stackMap[userId] ?? {\n            id: userId,\n            count: 0,\n            data: {\n              id: userId,\n              title: userName,\n              email,\n              avatarUrl: avatar,\n            },\n          }\n      );\n      stackList.unshift(UNCATEGORIZED_STACK_DATA);\n      if (isEmptyStackHidden) {\n        return stackList.filter(({ count }) => count > 0);\n      }\n\n      return stackList;\n    }\n\n    stackList.unshift(UNCATEGORIZED_STACK_DATA);\n    if (isEmptyStackHidden) {\n      return stackList.filter(({ count }) => count > 0);\n    }\n\n    return stackList;\n  }, [groupPoints, isEmptyStackHidden, stackField, userList, stackFieldRecordEditable]);\n\n  const coverField = useMemo(() => {\n    if (!coverFieldId) return;\n    return allFields.find(\n      ({ id, type }) => id === coverFieldId && type === FieldType.Attachment\n    ) as AttachmentField | undefined;\n  }, [coverFieldId, allFields]);\n\n  const { primaryField, displayFields } = useMemo(() => {\n    let primaryField: IFieldInstance | null = null;\n    const displayFields = fields.filter((f) => {\n      if (f.isPrimary) {\n        primaryField = f;\n        return false;\n      }\n      return true;\n    });\n\n    return {\n      primaryField: primaryField as unknown as IFieldInstance,\n      displayFields,\n    };\n  }, [fields]);\n\n  const value = useMemo(() => {\n    return {\n      recordQuery,\n      isCoverFit,\n      isFieldNameHidden,\n      isEmptyStackHidden,\n      permission: kanbanPermission,\n      stackField,\n      coverField,\n      primaryField,\n      displayFields,\n      stackCollection,\n      setExpandRecordId,\n    };\n  }, [\n    recordQuery,\n    isCoverFit,\n    isFieldNameHidden,\n    isEmptyStackHidden,\n    kanbanPermission,\n    stackField,\n    coverField,\n    primaryField,\n    displayFields,\n    stackCollection,\n    setExpandRecordId,\n  ]);\n\n  const onClose = () => {\n    setExpandRecordId(undefined);\n    const {\n      recordId: _recordId,\n      showHistory: _showHistory,\n      showComment: _showComment,\n      ...resetQuery\n    } = router.query;\n    router.push(\n      {\n        pathname: router.pathname,\n        query: resetQuery,\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  };\n\n  return (\n    <KanbanContext.Provider value={value}>\n      {children}\n      {tableId && (\n        <ExpandRecorder\n          tableId={tableId}\n          viewId={view?.id}\n          recordId={expandRecordId}\n          recordIds={expandRecordId ? [expandRecordId] : []}\n          onClose={onClose}\n          buttonClickStatusHook={buttonClickStatusHook}\n          showHistory={showHistory}\n          showComment={showComment}\n        />\n      )}\n    </KanbanContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/context/index.ts",
    "content": "export * from './KanbanContext';\nexport * from './KanbanProvider';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/hooks/index.ts",
    "content": "export * from './useInView';\nexport * from './useKanban';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/hooks/useInView.ts",
    "content": "import type { RefObject } from 'react';\nimport { useState, useEffect, useRef } from 'react';\n\nexport const useInView = (options = {}): [RefObject<HTMLDivElement>, boolean] => {\n  const [isInView, setIsInView] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        setIsInView(entry.isIntersecting);\n      },\n      { ...options }\n    );\n\n    if (ref.current) {\n      observer.observe(ref.current);\n    }\n\n    return () => {\n      if (ref.current) {\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n        observer.unobserve(ref.current);\n      }\n    };\n  }, [ref, options]);\n\n  return [ref, isInView];\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/hooks/useKanban.ts",
    "content": "import { useContext } from 'react';\nimport { KanbanContext } from '../context';\n\nexport const useKanban = () => {\n  return useContext(KanbanContext);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/store/index.ts",
    "content": "export * from './useKanbanStackCollapsed';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/store/useKanbanStackCollapsed.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface IKanbanStackCollapsedState {\n  collapsedStackMap: Record<string, string[]>;\n  setCollapsedStackMap: (key: string, stackIds: string[]) => void;\n}\n\nexport const useKanbanStackCollapsedStore = create<IKanbanStackCollapsedState>()(\n  persist(\n    (set, get) => ({\n      collapsedStackMap: {},\n      setCollapsedStackMap: (key: string, stackIds: string[]) => {\n        set({\n          collapsedStackMap: {\n            ...get().collapsedStackMap,\n            [key]: stackIds,\n          },\n        });\n      },\n    }),\n    {\n      name: LocalStorageKeys.ViewKanbanCollapsedStack,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/type.ts",
    "content": "export interface IStackData {\n  id: string;\n  data: unknown;\n  count: number;\n}\n\nexport interface IKanbanPermission {\n  stackCreatable: boolean;\n  stackEditable: boolean;\n  stackDeletable: boolean;\n  stackDraggable: boolean;\n  cardCreatable: boolean;\n  cardEditable: boolean;\n  cardDeletable: boolean;\n  cardDraggable: boolean;\n  cardCommentCreatable: boolean;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/utils/card.ts",
    "content": "import { FieldType } from '@teable/core';\nimport type { IFieldInstance, Record as IRecord } from '@teable/sdk/model';\nimport { UNCATEGORIZED_STACK_ID } from '../constant';\nimport type { IStackData } from '../type';\n\nexport const CARD_STYLE = {\n  titleHeight: 24,\n  padding: 12,\n  gap: 12,\n  itemGap: 8,\n  itemInnerGap: 4,\n  itemTitleHeight: 16,\n};\n\nexport const DEFAULT_FIELD_HEIGHT = 20;\n\nexport const CARD_COVER_HEIGHT = 160;\n\nexport const LONG_TEXT_FIELD_DISPLAY_ROWS = 4;\n\nexport const FIELD_HEIGHT_MAP: { [key in FieldType]?: number } = {\n  [FieldType.Attachment]: 28,\n  [FieldType.SingleSelect]: 24,\n  [FieldType.MultipleSelect]: 24,\n  [FieldType.Link]: 24,\n  [FieldType.User]: 24,\n  [FieldType.CreatedBy]: 24,\n  [FieldType.LastModifiedBy]: 24,\n  [FieldType.Rating]: 16,\n};\n\nconst { titleHeight, padding, gap, itemGap, itemInnerGap, itemTitleHeight } = CARD_STYLE;\n\nexport const getCardHeight = (\n  record: IRecord,\n  fields: IFieldInstance[],\n  hasCover?: boolean,\n  isFieldNameHidden?: boolean\n) => {\n  const validFields = fields.filter(({ id }) => {\n    const cellValue = record.getCellValue(id);\n    return cellValue != null;\n  });\n  const validLength = validFields.length;\n  const staticFieldNameSpace = isFieldNameHidden ? 0 : itemInnerGap + itemTitleHeight;\n  let staticHeight =\n    titleHeight + padding * 2 + (itemGap + staticFieldNameSpace) * validLength + gap;\n  staticHeight = hasCover ? staticHeight + CARD_COVER_HEIGHT + itemGap : staticHeight;\n  const dynamicHeight = validFields.reduce((prev, { type }) => {\n    if (type === FieldType.LongText) {\n      return prev + DEFAULT_FIELD_HEIGHT * LONG_TEXT_FIELD_DISPLAY_ROWS;\n    }\n    return prev + (FIELD_HEIGHT_MAP[type] || DEFAULT_FIELD_HEIGHT);\n  }, 0);\n  return staticHeight + dynamicHeight;\n};\n\nexport const getCellValueByStack = (stack: IStackData) => {\n  const { id, data } = stack;\n\n  if (id === UNCATEGORIZED_STACK_ID) {\n    return null;\n  }\n\n  return data;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/utils/drag.ts",
    "content": "export const reorder = <T>(list: T[], sourceIndex: number, targetIndex: number) => {\n  const result = Array.from(list);\n  const [removed] = result.splice(sourceIndex, 1);\n  result.splice(targetIndex, 0, removed);\n\n  return result;\n};\n\nexport const moveTo = <T>({\n  source,\n  sourceIndex,\n  target,\n  targetIndex,\n}: {\n  source: T[];\n  sourceIndex: number;\n  target: T[];\n  targetIndex: number;\n}) => {\n  const sourceList = Array.from(source);\n  const targetList = Array.from(target);\n  const [sourceCard] = sourceList.splice(sourceIndex, 1);\n\n  targetList.splice(targetIndex, 0, sourceCard);\n\n  return {\n    sourceList,\n    targetList,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/utils/filter.ts",
    "content": "import type { IDateFieldOptions, ILinkCellValue, IOperator, IUserCellValue } from '@teable/core';\nimport { exactDate, FieldType, is, isEmpty, isExactly } from '@teable/core';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { UNCATEGORIZED_STACK_ID } from '../constant';\nimport type { IStackData } from '../type';\n\nexport const getFilterValue = (stackField: IFieldInstance, stackData: unknown) => {\n  const { type, isMultipleCellValue, options } = stackField;\n\n  if (stackData == null) return stackData;\n  if (\n    [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(type)\n  ) {\n    return isMultipleCellValue\n      ? (stackData as (IUserCellValue | ILinkCellValue)[])?.map((v) => v.id)\n      : (stackData as IUserCellValue | ILinkCellValue).id;\n  }\n  if (type === FieldType.SingleSelect || type === FieldType.MultipleSelect) {\n    return isMultipleCellValue ? (stackData as string[]) : (stackData as string);\n  }\n  if ([FieldType.Date, FieldType.CreatedTime, FieldType.LastModifiedTime].includes(type)) {\n    const timeZone =\n      (options as IDateFieldOptions)?.formatting?.timeZone ??\n      Intl.DateTimeFormat().resolvedOptions().timeZone;\n    return {\n      exactDate: stackData,\n      mode: exactDate.value,\n      timeZone,\n    };\n  }\n  return stackData;\n};\n\nexport const getFilterSet = (stackField: IFieldInstance, stack: IStackData) => {\n  const { id: fieldId, type, isMultipleCellValue } = stackField;\n  const { id: stackId, data: stackData } = stack;\n  const isUncategorized = stackId === UNCATEGORIZED_STACK_ID;\n  const filterValue = getFilterValue(stackField, stackData);\n  let operator: IOperator = is.value;\n\n  if (isUncategorized && type !== FieldType.Checkbox) {\n    operator = isEmpty.value;\n  } else if (isMultipleCellValue) {\n    operator = isExactly.value;\n  }\n\n  return [\n    {\n      fieldId,\n      operator,\n      value: (isUncategorized ? null : filterValue) as string | null,\n    },\n  ];\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/kanban/utils/index.ts",
    "content": "export * from './card';\nexport * from './drag';\nexport * from './filter';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/DraggableWrapper.tsx",
    "content": "import type { IViewInstance } from '@teable/sdk';\nimport { useViews } from '@teable/sdk';\nimport { DndKitContext, Draggable, Droppable } from '@teable/ui-lib/base/dnd-kit';\nimport type { SortingStrategy, DragEndEvent, useSortable } from '@teable/ui-lib/base/dnd-kit';\nimport type { ReactElement } from 'react';\nimport { useEffect, useState } from 'react';\n\ntype IProvidedProps = ReturnType<typeof useSortable> & {\n  style: React.CSSProperties;\n  view: IViewInstance;\n};\n\nexport const DraggableWrapper = ({\n  strategy,\n  children,\n}: {\n  strategy: SortingStrategy;\n  children: (props: IProvidedProps) => ReactElement;\n}) => {\n  const views = useViews();\n\n  const [innerViews, setInnerViews] = useState([...views]);\n\n  useEffect(() => {\n    setInnerViews(views);\n  }, [views]);\n\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n    const newViews = [...innerViews];\n\n    const [moveView] = newViews.splice(from, 1);\n\n    if (!over) {\n      return;\n    }\n\n    const view = views[from];\n\n    newViews.splice(to, 0, moveView);\n\n    setInnerViews(newViews);\n    const viewIndex = newViews.findIndex((v) => v.id === view.id);\n\n    if (viewIndex == 0) {\n      await view?.updateOrder({ anchorId: newViews[1].id, position: 'before' });\n    } else {\n      await view?.updateOrder({ anchorId: newViews[viewIndex - 1].id, position: 'after' });\n    }\n  };\n\n  return (\n    <DndKitContext onDragEnd={onDragEndHandler}>\n      <Droppable items={innerViews.map(({ id }) => ({ id }))} strategy={strategy}>\n        {innerViews.map((view) => (\n          <Draggable key={view.id} id={view.id}>\n            {(props) => children({ ...props, view })}\n          </Draggable>\n        ))}\n      </Droppable>\n    </DndKitContext>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/ExpandViewList.tsx",
    "content": "import { verticalListSortingStrategy } from '@dnd-kit/sortable';\nimport { DraggableHandle } from '@teable/icons';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { useTablePermission, useViewId } from '@teable/sdk/hooks';\nimport {\n  Popover,\n  PopoverTrigger,\n  Button,\n  PopoverContent,\n  CommandInput,\n  CommandEmpty,\n  CommandList,\n  CommandItem,\n  Command,\n} from '@teable/ui-lib/shadcn';\nimport { List } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useShareUrlPrefix } from '@/features/app/context/ShareContext';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useBaseResource, type IBaseResourceTable } from '../../../hooks/useBaseResource';\nimport { getNodeUrl } from '../../base/base-node/hooks';\nimport { VIEW_ICON_MAP } from '../constant';\nimport { DraggableWrapper } from './DraggableWrapper';\n\nexport const ExpandViewList = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [open, setOpen] = useState(false);\n  const [isDraggable, setIsDraggable] = useState(true);\n  const [highlightedValue, setHighlightedValue] = useState<string | undefined>();\n  const router = useRouter();\n  const permission = useTablePermission();\n  const { baseId, tableId } = useBaseResource() as IBaseResourceTable;\n  const curViewId = useViewId();\n  const shareUrlPrefix = useShareUrlPrefix();\n\n  const handleOpenChange = (isOpen: boolean) => {\n    setOpen(isOpen);\n    if (isOpen) {\n      setHighlightedValue(curViewId);\n    }\n  };\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger asChild>\n        <Button className=\"shrink-0\" size=\"icon-xs\" variant=\"ghost\">\n          <List className=\"size-4\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"start\" className=\"w-auto max-w-[456px] p-1\">\n        <Command\n          value={highlightedValue}\n          onValueChange={setHighlightedValue}\n          filter={(value, search, keywords) => {\n            const searchLower = search.toLowerCase();\n            if (keywords?.some((keyword) => keyword.toLowerCase().includes(searchLower))) {\n              return 1;\n            }\n            return 0;\n          }}\n        >\n          <CommandInput\n            className=\"h-9\"\n            placeholder={t('table:view.searchView')}\n            onValueChange={(value) => setIsDraggable(!value)}\n          />\n          <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n          <CommandList className=\"max-h-[70vh] p-0.5\">\n            <DraggableWrapper strategy={verticalListSortingStrategy}>\n              {({\n                view: { id, name, type },\n                setNodeRef,\n                attributes,\n                listeners,\n                style,\n                isDragging,\n              }) => {\n                const Icon = VIEW_ICON_MAP[type];\n\n                return (\n                  <CommandItem\n                    key={id}\n                    value={id}\n                    keywords={[name]}\n                    ref={setNodeRef}\n                    style={{\n                      ...style,\n                      opacity: isDragging ? '0.6' : '1',\n                    }}\n                    onSelect={() => {\n                      const url = getNodeUrl({\n                        baseId,\n                        resourceType: BaseNodeResourceType.Table,\n                        resourceId: tableId,\n                        viewId: id,\n                        urlPrefix: shareUrlPrefix,\n                      });\n                      if (url) {\n                        router.push(url, undefined, { shallow: true });\n                      }\n                      setOpen(false);\n                    }}\n                  >\n                    <Icon className=\"size-4 shrink-0\" />\n                    <span className=\"ml-2 truncate text-sm\" title={name}>\n                      {name}\n                    </span>\n                    <span className=\"grow\" />\n                    {isDraggable && permission['view|update'] && (\n                      <div {...attributes} {...listeners} className=\"pr-1\">\n                        <DraggableHandle className=\"size-3 shrink-0\" />\n                      </div>\n                    )}\n                  </CommandItem>\n                );\n              }}\n            </DraggableWrapper>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/PinViewItem.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Star } from '@teable/icons';\nimport { addPin, deletePin, PinType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { usePinMap } from '../../space/usePinMap';\n\nexport const PinViewItem = ({ viewId }: { viewId: string }) => {\n  const queryClient = useQueryClient();\n  const pinMap = usePinMap();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const isPin = pinMap?.[viewId];\n\n  const { mutate: addPinMutation, isPending: addPinLoading } = useMutation({\n    mutationFn: addPin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n  });\n\n  const { mutate: deletePinMutation, isPending: deletePinLoading } = useMutation({\n    mutationFn: deletePin,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.pinList() });\n    },\n  });\n  return (\n    <Button\n      size=\"xs\"\n      variant=\"ghost\"\n      onClick={() => {\n        if (addPinLoading || deletePinLoading) return;\n        isPin\n          ? deletePinMutation({ id: viewId, type: PinType.View })\n          : addPinMutation({ id: viewId, type: PinType.View });\n      }}\n      className=\"flex justify-start\"\n    >\n      <Star className=\"size-3 shrink-0\" />\n      {isPin ? t('space:pin.remove') : t('space:pin.add')}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/ViewList.tsx",
    "content": "import { useTablePermission, useViewId, useViews, useIsHydrated } from '@teable/sdk';\nimport { horizontalListSortingStrategy } from '@teable/ui-lib/base/dnd-kit';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\nimport { DraggableWrapper } from './DraggableWrapper';\nimport { ViewListItem } from './ViewListItem';\n\nexport const ViewList = () => {\n  const views = useViews();\n  const activeViewId = useViewId();\n  const isHydrated = useIsHydrated();\n  const permission = useTablePermission();\n  const editable = permission['view|update'];\n  const [editing, setEditing] = useState(false);\n\n  return isHydrated && editable ? (\n    views.length ? (\n      <DraggableWrapper strategy={horizontalListSortingStrategy}>\n        {({ setNodeRef, attributes, listeners, style, isDragging, view }) => (\n          <div\n            ref={setNodeRef}\n            {...attributes}\n            {...(editing ? {} : listeners)}\n            style={style}\n            className={cn('relative', {\n              'opacity-50': isDragging,\n            })}\n          >\n            <ViewListItem\n              onEdit={(value) => setEditing(value)}\n              view={view}\n              removable={!!permission['view|delete'] && views.length > 1}\n              isActive={view.id === activeViewId}\n            />\n          </div>\n        )}\n      </DraggableWrapper>\n    ) : (\n      <></>\n    )\n  ) : (\n    <>\n      {views.map((view) => (\n        <ViewListItem\n          key={view.id}\n          onEdit={(value) => setEditing(value)}\n          view={view}\n          removable={!!permission['view|delete'] && views.length > 1}\n          isActive={view.id === activeViewId}\n        />\n      ))}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/ViewListItem.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { ViewType } from '@teable/core';\nimport { Pencil, Trash2, Export, Copy, Lock, Star } from '@teable/icons';\nimport { BaseNodeResourceType, duplicateView } from '@teable/openapi';\nimport {\n  useBaseId,\n  useIsReadOnlyPreview,\n  usePersonalView,\n  useTableId,\n  useTablePermission,\n  useView,\n} from '@teable/sdk/hooks';\nimport type { IViewInstance } from '@teable/sdk/model';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Separator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  cn,\n  PopoverAnchor,\n} from '@teable/ui-lib/shadcn';\nimport { Input } from '@teable/ui-lib/shadcn/ui/input';\nimport { Unlock } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useRef, Fragment, useEffect, useCallback, useMemo } from 'react';\nimport { useShareUrlPrefix } from '@/features/app/context/ShareContext';\nimport { useIsInIframe } from '@/features/app/hooks/useIsInIframe';\nimport { useDownload } from '../../../hooks/useDownLoad';\nimport { getNodeUrl } from '../../base/base-node/hooks';\nimport { usePinMap } from '../../space/usePinMap';\nimport { VIEW_ICON_MAP } from '../constant';\nimport { useGridSearchStore } from '../grid/useGridSearchStore';\nimport { PinViewItem } from './PinViewItem';\nimport { useDeleteView } from './useDeleteView';\n\ninterface IProps {\n  view: IViewInstance;\n  removable: boolean;\n  isActive: boolean;\n  onEdit: (value: boolean) => void;\n}\n\nexport const ViewListItem: React.FC<IProps> = ({ view, removable, isActive, onEdit }) => {\n  const [isEditing, setIsEditing] = useState(false);\n  const [open, _setOpen] = useState(false);\n  const tableId = useTableId() as string;\n  const baseId = useBaseId() as string;\n  const router = useRouter();\n  const deleteView = useDeleteView(view.id);\n  const permission = useTablePermission();\n  const { t } = useTranslation('table');\n  const iframeRef = useRef<HTMLIFrameElement>(null);\n  const viewItemRef = useRef<HTMLDivElement>(null);\n  const { highlightedViewId } = useGridSearchStore();\n  const isHighlighted = highlightedViewId === view.id;\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { personalViewCommonQuery } = usePersonalView();\n  const viewData = useView();\n  const shareUrlPrefix = useShareUrlPrefix();\n\n  const setOpen = useCallback(\n    (value: boolean) => {\n      if (value && isReadOnlyPreview) {\n        return;\n      }\n      _setOpen(value);\n    },\n    [isReadOnlyPreview, _setOpen]\n  );\n  const { mutateAsync: duplicateViewFn, isPending: isDuplicateViewLoading } = useMutation({\n    mutationFn: () => duplicateView(tableId, view.id),\n    onSuccess: (data) => {\n      const { id } = data?.data || {};\n      if (!id) {\n        return;\n      }\n      const url = getNodeUrl({\n        baseId,\n        resourceType: BaseNodeResourceType.Table,\n        resourceId: tableId,\n        viewId: id,\n        urlPrefix: shareUrlPrefix,\n      });\n      if (url) {\n        router.push(url, undefined, { shallow: true });\n      }\n    },\n  });\n  const downloadUrl = useMemo(() => {\n    const { ignoreViewQuery, filter, orderBy, groupBy, projection } = personalViewCommonQuery || {};\n\n    if (!ignoreViewQuery) {\n      return `/api/export/${tableId}?viewId=${viewData?.id}`;\n    }\n\n    const params = new URLSearchParams();\n    params.set('viewId', viewData?.id ?? '');\n    params.set('ignoreViewQuery', 'true');\n\n    filter && params.set('filter', JSON.stringify(filter));\n    orderBy && !viewData?.sort?.manualSort && params.set('orderBy', JSON.stringify(orderBy));\n    groupBy && params.set('groupBy', JSON.stringify(groupBy));\n    projection?.forEach((field) => params.append('projection[]', field));\n\n    if (viewData?.columnMeta) {\n      const columnMetaWithOrderOnly = Object.fromEntries(\n        Object.entries(viewData.columnMeta).map(([fieldId, meta]) => [\n          fieldId,\n          { order: meta.order },\n        ])\n      );\n      params.set('columnMeta', JSON.stringify(columnMetaWithOrderOnly));\n    }\n\n    return `/api/export/${tableId}?${params.toString()}`;\n  }, [\n    personalViewCommonQuery,\n    tableId,\n    viewData?.columnMeta,\n    viewData?.id,\n    viewData?.sort?.manualSort,\n  ]);\n\n  const { trigger } = useDownload({\n    downloadUrl,\n    key: 'view',\n  });\n\n  const { resetSearchHandler } = useGridSearchStore();\n  const isInIframe = useIsInIframe();\n\n  useEffect(() => {\n    if (isActive && !isInIframe) {\n      setTimeout(() => {\n        viewItemRef.current?.scrollIntoView({\n          behavior: 'smooth',\n        });\n      }, 0);\n    }\n  }, [isActive, isInIframe]);\n\n  const navigateHandler = () => {\n    resetSearchHandler?.();\n    const url = getNodeUrl({\n      baseId,\n      resourceType: BaseNodeResourceType.Table,\n      resourceId: tableId,\n      viewId: view.id,\n      urlPrefix: shareUrlPrefix,\n    });\n    if (url) {\n      router.push(url, undefined, { shallow: true });\n    }\n  };\n  const ViewIcon = VIEW_ICON_MAP[view.type];\n  const pinMap = usePinMap();\n  const isPin = pinMap?.[view.id];\n\n  const showViewMenu = !isEditing;\n\n  const commonPart = (\n    <div className=\"relative flex w-full items-center overflow-hidden px-0.5\">\n      {view.type === ViewType.Plugin ? (\n        <img className=\"mr-1 size-4 shrink-0\" src={view.options.pluginLogo} alt={view.name} />\n      ) : (\n        <Fragment>\n          {view.isLocked && <Lock className=\"mr-[2px] size-4 shrink-0\" />}\n          <ViewIcon className=\"mr-1 size-4 shrink-0\" />\n        </Fragment>\n      )}\n      <div className=\"flex flex-1 items-center justify-center overflow-hidden\">\n        <div className=\"truncate text-xs font-medium leading-5\">{view.name}</div>\n      </div>\n      {isPin && <Star className=\"ml-1 size-4 shrink-0 fill-yellow-400 text-yellow-400\" />}\n      {isEditing && (\n        <Input\n          type=\"text\"\n          placeholder=\"name\"\n          defaultValue={view.name}\n          className=\"absolute left-0 top-0 size-full py-0 text-xs\"\n          // eslint-disable-next-line jsx-a11y/no-autofocus\n          autoFocus\n          onBlur={(e) => {\n            if (e.target.value && e.target.value !== view.name) {\n              view.updateName(e.target.value);\n            }\n            setIsEditing(false);\n            onEdit(false);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              if (e.currentTarget.value && e.currentTarget.value !== view.name) {\n                view.updateName(e.currentTarget.value);\n              }\n              setIsEditing(false);\n              onEdit(false);\n            }\n            e.stopPropagation();\n          }}\n        />\n      )}\n    </div>\n  );\n\n  return (\n    <div\n      ref={viewItemRef}\n      role=\"button\"\n      tabIndex={0}\n      className={cn(\n        'flex h-7 max-w-52 items-center overflow-hidden rounded-md p-1 text-sm hover:bg-accent',\n        {\n          'bg-accent': isActive && !isHighlighted,\n          'bg-orange-300/40 hover:bg-orange-300/40': isHighlighted,\n        }\n      )}\n      onDoubleClick={() => {\n        if (permission['view|update']) {\n          setIsEditing(true);\n          onEdit(true);\n        }\n      }}\n      onKeyDown={(e) => {\n        if (isEditing) {\n          return;\n        }\n        if (e.key === 'Enter' || e.key === ' ') {\n          navigateHandler();\n        }\n      }}\n      onClick={() => {\n        if (isEditing) {\n          return;\n        }\n        navigateHandler();\n      }}\n      onContextMenu={() => showViewMenu && setOpen(true)}\n    >\n      <Popover open={open} onOpenChange={setOpen}>\n        <Button\n          variant=\"ghost\"\n          size=\"xs\"\n          className={cn('m-0 flex w-full rounded-sm hover:bg-transparent p-0', {\n            'bg-secondary': isActive && !isHighlighted,\n            'bg-orange-300/40': isHighlighted,\n          })}\n        >\n          {isActive && showViewMenu ? (\n            <PopoverTrigger asChild>{commonPart}</PopoverTrigger>\n          ) : (\n            <PopoverAnchor asChild>{commonPart}</PopoverAnchor>\n          )}\n        </Button>\n        {open && (\n          <PopoverContent className=\"w-auto p-1\">\n            {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}\n            <div className=\"flex flex-col\" onClick={(ev) => ev.stopPropagation()}>\n              {permission['view|update'] && (\n                <Button\n                  size=\"xs\"\n                  variant=\"ghost\"\n                  onClick={() => {\n                    setIsEditing(true);\n                    onEdit(true);\n                  }}\n                  className=\"flex justify-start\"\n                >\n                  <Pencil className=\"size-3 shrink-0\" />\n                  {t('view.action.rename')}\n                </Button>\n              )}\n              {view.type === 'grid' && permission['table|export'] && (\n                <Button\n                  size=\"xs\"\n                  variant=\"ghost\"\n                  onClick={() => {\n                    trigger?.();\n                  }}\n                  className=\"flex justify-start\"\n                >\n                  <Export className=\"size-3 shrink-0\" />\n                  {t('import.menu.downAsCsv')}\n                </Button>\n              )}\n              {permission['view|create'] && (\n                <>\n                  <Button\n                    size=\"xs\"\n                    variant=\"ghost\"\n                    onClick={async () => {\n                      await duplicateViewFn();\n                      setOpen(false);\n                    }}\n                    className=\"flex justify-start\"\n                    disabled={isDuplicateViewLoading}\n                  >\n                    <Copy className=\"size-3\" />\n                    {t('view.action.duplicate')}\n                    {isDuplicateViewLoading && <Spin className=\"size-3 shrink-0\" />}\n                  </Button>\n                </>\n              )}\n              {permission['view|update'] && (\n                <>\n                  <Separator className=\"my-0.5\" />\n                  <Button\n                    size=\"xs\"\n                    variant=\"ghost\"\n                    className=\"flex justify-start\"\n                    onClick={(e) => {\n                      e.preventDefault();\n                      view.updateLocked(!view.isLocked);\n                    }}\n                  >\n                    {view.isLocked ? (\n                      <Unlock className=\"size-3 shrink-0\" />\n                    ) : (\n                      <Lock className=\"size-3 shrink-0\" />\n                    )}\n                    {view.isLocked ? t('view.action.unlock') : t('view.action.lock')}\n                  </Button>\n                </>\n              )}\n              <PinViewItem viewId={view.id} />\n              {permission['view|delete'] && (\n                <>\n                  <Separator className=\"my-0.5\" />\n                  <Button\n                    size=\"xs\"\n                    disabled={!removable}\n                    variant=\"ghost\"\n                    className=\"flex justify-start text-red-500\"\n                    onClick={(e) => {\n                      e.preventDefault();\n                      deleteView();\n                    }}\n                  >\n                    <Trash2 className=\"size-3 shrink-0\" />\n                    {t('view.action.delete')}\n                  </Button>\n                </>\n              )}\n            </div>\n          </PopoverContent>\n        )}\n      </Popover>\n      <iframe ref={iframeRef} title=\"This for export csv download\" style={{ display: 'none' }} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/useAddView.ts",
    "content": "import { ViewType } from '@teable/core';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { useBaseId, useTable, useViews } from '@teable/sdk/hooks';\nimport { useRouter } from 'next/router';\nimport { useCallback } from 'react';\nimport { useShareUrlPrefix } from '@/features/app/context/ShareContext';\nimport { getNodeUrl } from '../../base/base-node/hooks';\n\nexport function useAddView() {\n  const table = useTable();\n  const baseId = useBaseId() as string;\n  const views = useViews();\n  const router = useRouter();\n  const shareUrlPrefix = useShareUrlPrefix();\n  const viewName = views?.[views.length - 1]?.name + ' ' + views?.length;\n\n  return useCallback(\n    async (type: ViewType = ViewType.Grid, name?: string) => {\n      if (!table) {\n        return;\n      }\n\n      const viewDoc = (\n        await table.createView({\n          name: name ?? viewName,\n          type,\n        })\n      ).data;\n      const viewId = viewDoc.id;\n\n      const url = getNodeUrl({\n        baseId,\n        resourceType: BaseNodeResourceType.Table,\n        resourceId: table.id,\n        viewId,\n        urlPrefix: shareUrlPrefix,\n      });\n      if (url) {\n        router.push(url, undefined, { shallow: true });\n      }\n    },\n    [router, table, viewName, baseId, shareUrlPrefix]\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/list/useDeleteView.ts",
    "content": "import { useBaseId, useTable, useViews, useViewId } from '@teable/sdk/hooks';\nimport { useRouter } from 'next/router';\nimport { useCallback } from 'react';\n\nexport function useDeleteView(viewId: string) {\n  const table = useTable();\n  const views = useViews();\n  const router = useRouter();\n  const baseId = useBaseId() as string;\n  const curViewId = useViewId();\n\n  return useCallback(async () => {\n    if (!table || !views) {\n      return;\n    }\n\n    const deletePromise = table.deleteView(viewId);\n\n    if (curViewId === viewId) {\n      const currentIndex = views.findIndex((v) => v.id === viewId);\n      const nextView = views[currentIndex + 1] ?? views[currentIndex - 1];\n      if (nextView) {\n        router.replace(`/base/${baseId}/table/${table.id}/${nextView.id}`, undefined, {\n          shallow: true,\n        });\n      }\n    }\n\n    await deletePromise;\n  }, [baseId, router, table, views, viewId, curViewId]);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/plugin/PluginView.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getViewInstallPlugin, PluginPosition } from '@teable/openapi';\nimport { useBaseId, useTableId, useView } from '@teable/sdk/hooks';\nimport type { PluginView as PluginViewInstance } from '@teable/sdk/model/view/plugin.view';\nimport { PluginContent } from '@/features/app/components/plugin/PluginContent';\n\nexport const PluginView = () => {\n  const view = useView();\n  const baseId = useBaseId();\n  const tableId = useTableId();\n\n  const { data: plugin } = useQuery({\n    queryKey: ['plugin-view', tableId!, view!.id] as const,\n    enabled: Boolean(view?.id && tableId),\n    queryFn: ({ queryKey }) =>\n      getViewInstallPlugin(queryKey[1], queryKey[2]).then((res) => res.data),\n  });\n\n  if (!baseId || !tableId || !view || !plugin) {\n    return;\n  }\n\n  const { options, id } = view as PluginViewInstance;\n  const { pluginId, pluginInstallId } = options;\n\n  return (\n    <PluginContent\n      baseId={baseId}\n      pluginId={pluginId}\n      pluginInstallId={pluginInstallId}\n      positionId={id}\n      positionType={PluginPosition.View}\n      pluginUrl={plugin.url}\n      tableId={tableId}\n      viewId={view.id}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/search/SearchButton.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ViewType } from '@teable/core';\nimport { AlertCircle, Search, X } from '@teable/icons';\nimport {\n  getTableActivatedIndex,\n  TableIndex,\n  RecommendedIndexRow,\n  getTableAbnormalIndex,\n  repairTableIndex,\n  DEFAULT_MAX_SEARCH_FIELD_COUNT,\n} from '@teable/openapi';\nimport { LocalStorageKeys, useView } from '@teable/sdk';\nimport { useBaseId, useFields, useRowCount, useSearch, useTableId } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  cn,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Button,\n  AlertDialog,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogCancel,\n  AlertDialogAction,\n  Checkbox,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipPortal,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { isEqual } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { useDebounce, useLocalStorage } from 'react-use';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { useGridSearchStore } from '../grid/useGridSearchStore';\nimport { ToolBarButton } from '../tool-bar/ToolBarButton';\nimport type { ISearchCommandRef } from './SearchCommand';\nimport { SearchCommand } from './SearchCommand';\nimport type { ISearchCountPaginationRef } from './SearchCountPagination';\nimport { SearchCountPagination } from './SearchCountPagination';\n\nexport interface ISearchButtonProps {\n  className?: string;\n  textClassName?: string;\n  shareView?: boolean;\n}\n\nexport const SearchButton = (props: ISearchButtonProps) => {\n  const { className, textClassName, shareView = false } = props;\n  const env = useEnv();\n  const { maxSearchFieldCount = DEFAULT_MAX_SEARCH_FIELD_COUNT } = env;\n  const [active, setActive] = useState(false);\n  const fields = useFields();\n  const tableId = useTableId();\n  const view = useView();\n  const viewId = view?.id;\n  const rowCount = useRowCount();\n  const { fieldId, value, setFieldId, setValue, hideNotMatchRow, setHideNotMatchRow } = useSearch();\n  const [alertVisible, setAlertVisible] = useState(false);\n  const [shouldAlert, setShouldAlert] = useLocalStorage(LocalStorageKeys.SearchIndexAlert, true);\n  const [shouldTips, setShouldTips] = useState(true);\n  const [noPrompt, setNoPrompt] = useState(false);\n  const baseId = useBaseId();\n  const queryClient = useQueryClient();\n\n  const [inputValue, setInputValue] = useState(value);\n  const [isFocused, setIsFocused] = useState(false);\n  const { t } = useTranslation(['common', 'table']);\n  const searchComposition = useRef(false);\n  const ref = useRef<HTMLInputElement>(null);\n  const { setSearchCursor, setResetSearchHandler } = useGridSearchStore();\n  const [enableGlobalSearch, setEnableGlobalSearch] = useLocalStorage(\n    LocalStorageKeys.EnableGlobalSearch,\n    true\n  );\n  const [lsHideNotMatch, setLsHideNotMatchRow] = useLocalStorage<boolean>(\n    LocalStorageKeys.SearchHideNotMatchRow,\n    false\n  );\n  const [searchFieldMapCache, setSearchFieldMap] = useLocalStorage<Record<string, string[]>>(\n    LocalStorageKeys.TableSearchFieldsCache,\n    {}\n  );\n\n  const searchCommandRef = useRef<ISearchCommandRef>(null);\n\n  const commandTrigger = useRef<HTMLButtonElement>(null);\n\n  const searchPaginationRef = useRef<ISearchCountPaginationRef>(null);\n\n  const { data: tableActivatedIndex } = useQuery({\n    queryKey: ['table-index', tableId],\n    queryFn: () => getTableActivatedIndex(baseId!, tableId!).then(({ data }) => data),\n    enabled: !shareView,\n  });\n\n  const enabledSearchIndex = tableActivatedIndex?.includes(TableIndex.search);\n\n  const { data: searchAbnormalIndex = [] } = useQuery({\n    queryKey: ['table-abnormal-index', baseId, tableId, TableIndex.search],\n    queryFn: () =>\n      getTableAbnormalIndex(baseId!, tableId!, TableIndex.search).then(({ data }) => data),\n    enabled: Boolean(enabledSearchIndex && !shareView),\n  });\n\n  const { mutateAsync: repairIndexFn, isPending: repairIndexLoading } = useMutation({\n    mutationFn: (type: TableIndex) => repairTableIndex(baseId!, tableId!, type),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ['table-abnormal-index', baseId, tableId, TableIndex.search],\n      });\n    },\n  });\n\n  useHotkeys(\n    `mod+f`,\n    (e) => {\n      setActive(true);\n      ref.current?.focus();\n      ref.current?.select();\n      e.preventDefault();\n    },\n    {\n      enableOnFormTags: ['input', 'select', 'textarea'],\n    }\n  );\n\n  const [, cancel] = useDebounce(\n    () => {\n      if (!searchComposition?.current && inputValue) {\n        setValue(inputValue);\n      }\n    },\n    inputValue ? 500 : 0,\n    [inputValue]\n  );\n\n  const resetSearch = useCallback(() => {\n    cancel();\n    setValue();\n    setInputValue('');\n    setSearchCursor(null);\n    setActive(false);\n  }, [cancel, setSearchCursor, setValue]);\n\n  useEffect(() => {\n    setResetSearchHandler(resetSearch);\n  }, [resetSearch, setResetSearchHandler]);\n\n  const initSearchParams = useCallback(() => {\n    if (!tableId || !viewId || fields.length === 0) {\n      return;\n    }\n\n    const localSearchKey = `${tableId}-${viewId}`;\n\n    if (view?.type === ViewType.Grid) {\n      setHideNotMatchRow(lsHideNotMatch);\n    } else {\n      // other view type only support filter search, causing the search hit highlight\n      setHideNotMatchRow(true);\n    }\n\n    if (enableGlobalSearch) {\n      setFieldId('all_fields');\n      return;\n    }\n\n    // set the first field as default search field\n    if (!searchFieldMapCache?.[localSearchKey]?.length) {\n      const newIds = [fields?.[0].id];\n      setFieldId(newIds.join(','));\n      setSearchFieldMap({ ...searchFieldMapCache, [localSearchKey]: newIds });\n      return;\n    }\n\n    const currentFieldIds = fields.map((f) => f.id);\n    const fieldIds = searchFieldMapCache[localSearchKey].filter((fieldId) =>\n      currentFieldIds.includes(fieldId)\n    );\n    setFieldId(fieldIds.join(','));\n\n    if (!isEqual(fieldIds, searchFieldMapCache[localSearchKey])) {\n      setSearchFieldMap({ ...searchFieldMapCache, [localSearchKey]: fieldIds });\n    }\n  }, [\n    enableGlobalSearch,\n    fields,\n    lsHideNotMatch,\n    searchFieldMapCache,\n    setFieldId,\n    setHideNotMatchRow,\n    setSearchFieldMap,\n    tableId,\n    view?.type,\n    viewId,\n  ]);\n\n  useEffect(() => {\n    setSearchCursor(null);\n  }, [viewId, tableId, setSearchCursor]);\n\n  useEffect(() => {\n    if (!inputValue) {\n      setValue(inputValue);\n    }\n  }, [inputValue, setValue]);\n\n  const onFieldChangeHandler = useCallback(\n    (fieldIds: string[] | null) => {\n      if (!tableId || !viewId) {\n        return;\n      }\n      const localSearchKey = `${tableId}-${viewId}`;\n      // change the search mode to field search the default from local cache or the first field\n      if (!fieldIds || fields.length === 0) {\n        if (searchFieldMapCache?.[localSearchKey]?.length) {\n          setFieldId(searchFieldMapCache[localSearchKey].join(','));\n        } else {\n          const newIds = [fields?.[0].id];\n          setFieldId(newIds.join(','));\n          setSearchFieldMap({ ...searchFieldMapCache, [tableId]: newIds });\n        }\n        setEnableGlobalSearch(false);\n        return;\n      }\n\n      // switch to global search or update search field\n      const ids = fieldIds.join(',');\n      if (ids === 'all_fields') {\n        setEnableGlobalSearch(true);\n      } else {\n        setEnableGlobalSearch(false);\n        setSearchFieldMap({ ...searchFieldMapCache, [localSearchKey]: fieldIds });\n        setFieldId(ids);\n      }\n    },\n    [\n      fields,\n      searchFieldMapCache,\n      setEnableGlobalSearch,\n      setFieldId,\n      setSearchFieldMap,\n      tableId,\n      viewId,\n    ]\n  );\n\n  useEffect(() => {\n    if (active) {\n      ref.current?.focus();\n      initSearchParams();\n    }\n  }, [active, initSearchParams]);\n\n  useHotkeys<HTMLInputElement>(\n    `esc`,\n    () => {\n      if (isFocused) {\n        resetSearch();\n        setActive(false);\n      }\n    },\n    {\n      enableOnFormTags: ['input'],\n    }\n  );\n\n  const searchHeader = useMemo(() => {\n    if (fieldId === 'all_fields') {\n      return t('noun.global');\n    }\n    const fieldIds = fieldId?.split(',') || [];\n    const fieldName = fields.find((f) => f.id === fieldIds[0])?.name;\n    if (fieldIds.length === 1) {\n      return t('table:view.search.field_one', { name: fieldName });\n    }\n    if (fieldIds.length > 1) {\n      return t('table:view.search.field_other', { name: fieldName, length: fieldIds?.length });\n    }\n  }, [fieldId, fields, t]);\n\n  const showAlert = useMemo(() => {\n    if (fieldId === 'all_fields') {\n      return fields.length > maxSearchFieldCount;\n    }\n    const fieldIds = fieldId?.split(',') || [];\n    return fieldIds.length > maxSearchFieldCount;\n  }, [fieldId, fields, maxSearchFieldCount]);\n\n  return active ? (\n    <div\n      className={cn(\n        'left-6 top-60 flex h-7 shrink-0 items-center gap-1 overflow-hidden rounded-xl bg-background p-0 pr-[7px] text-xs border outline-muted-foreground w-80',\n        {\n          outline: isFocused,\n        }\n      )}\n    >\n      <TooltipProvider>\n        <Tooltip>\n          <Popover modal>\n            <PopoverTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size={'xs'}\n                className=\"flex shrink-0 items-center justify-center overflow-hidden truncate rounded-none border-r px-2\"\n                ref={commandTrigger}\n              >\n                <TooltipTrigger>\n                  <div className=\"flex items-center gap-1\">\n                    {showAlert && <AlertCircle className=\"size-3 shrink-0\" />}\n\n                    <span className=\"truncate\" title={searchHeader}>\n                      {searchHeader}\n                    </span>\n                  </div>\n                </TooltipTrigger>\n                {showAlert && (\n                  <TooltipPortal>\n                    <TooltipContent>\n                      <p>\n                        {t('table:table.searchTips.maxFieldTips_limited', {\n                          count: maxSearchFieldCount,\n                        })}\n                      </p>\n                    </TooltipContent>\n                  </TooltipPortal>\n                )}\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent className=\"max-w-96 p-1\">\n              {fieldId && tableId && (\n                <SearchCommand\n                  value={fieldId}\n                  hideNotMatchRow={hideNotMatchRow}\n                  onChange={onFieldChangeHandler}\n                  shareView={shareView}\n                  ref={searchCommandRef}\n                  onHideSwitchChange={(checked) => {\n                    setLsHideNotMatchRow(checked);\n                    setHideNotMatchRow(checked);\n                  }}\n                />\n              )}\n            </PopoverContent>\n          </Popover>\n        </Tooltip>\n      </TooltipProvider>\n\n      <div className=\"flex flex-1 justify-between overflow-hidden\">\n        <input\n          ref={ref}\n          className=\"placeholder:text-muted-foregrounds min-w-0 grow rounded-md bg-transparent px-1 outline-none\"\n          placeholder={t('actions.search')}\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck=\"false\"\n          type=\"text\"\n          value={inputValue || ''}\n          onCompositionStart={() => {\n            searchComposition.current = true;\n          }}\n          onCompositionEnd={() => {\n            searchComposition.current = false;\n          }}\n          onChange={(e) => {\n            if (\n              shouldTips &&\n              rowCount &&\n              rowCount > RecommendedIndexRow &&\n              shouldAlert &&\n              !shareView &&\n              !tableActivatedIndex?.includes(TableIndex.search) &&\n              e.target.value\n            ) {\n              setAlertVisible(true);\n              return;\n            }\n            if (searchAbnormalIndex.length) {\n              setAlertVisible(true);\n              return;\n            }\n            setInputValue(e.target.value);\n            if (e.target.value === '') {\n              setSearchCursor(null);\n            }\n          }}\n          onBlur={() => {\n            setIsFocused(false);\n          }}\n          onFocus={() => {\n            setIsFocused(true);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              const actionFn = e.shiftKey\n                ? searchPaginationRef?.current?.prevIndex\n                : searchPaginationRef?.current?.nextIndex;\n              actionFn?.();\n            }\n          }}\n        />\n        <div className=\"flex shrink-0 items-center\">\n          {view?.type === ViewType.Grid && (\n            <SearchCountPagination shareView={shareView} ref={searchPaginationRef} />\n          )}\n\n          <X\n            className=\"hover:text-primary-foregrounds size-4 shrink-0 cursor-pointer font-light\"\n            onClick={() => {\n              resetSearch();\n              setActive(false);\n            }}\n          />\n          <Search className=\"size-4 shrink-0\" />\n        </div>\n      </div>\n\n      <AlertDialog open={alertVisible} onOpenChange={setAlertVisible}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('table:import.title.tipsTitle')}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {searchAbnormalIndex.length\n                ? t('table:table.index.repairTip')\n                : t('table:table.index.autoIndexTip', { rowCount: RecommendedIndexRow })}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <div className=\"flex items-center\">\n            <Checkbox\n              id=\"noTips\"\n              checked={noPrompt}\n              onCheckedChange={(should: boolean) => {\n                setNoPrompt(should);\n              }}\n            />\n            <label\n              htmlFor=\"noTips\"\n              className=\"pl-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n            >\n              {t('table:import.tips.noTips')}\n            </label>\n          </div>\n          <AlertDialogFooter>\n            <AlertDialogCancel\n              onClick={() => {\n                setShouldAlert(!noPrompt);\n                setShouldTips(false);\n              }}\n            >\n              {searchAbnormalIndex?.length\n                ? t('table:table.index.ignoreIndexError')\n                : t('table:table.index.keepAsIs')}\n            </AlertDialogCancel>\n            <AlertDialogAction\n              onClick={async (e) => {\n                if (searchAbnormalIndex?.length) {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  tableId && baseId && (await repairIndexFn(TableIndex.search));\n                  setAlertVisible(false);\n                  return;\n                }\n                commandTrigger?.current?.click();\n                setTimeout(() => {\n                  searchCommandRef?.current?.toggleSearchIndex();\n                  setShouldAlert(!noPrompt);\n                }, 0);\n              }}\n            >\n              {searchAbnormalIndex?.length\n                ? t('table:table.index.repair')\n                : t('table:table.index.enableIndex')}\n              {repairIndexLoading && <Spin className=\"size-3\" />}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  ) : (\n    <ToolBarButton\n      className={className}\n      textClassName={textClassName}\n      onClick={() => {\n        setActive(true);\n      }}\n    >\n      <Search className=\"size-4 shrink-0\" />\n    </ToolBarButton>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/search/SearchCommand.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { FieldType, ViewType } from '@teable/core';\nimport { HelpCircle } from '@teable/icons';\nimport {\n  toggleTableIndex,\n  getTableActivatedIndex,\n  TableIndex,\n  getTableAbnormalIndex,\n  repairTableIndex,\n  RecommendedIndexRow,\n} from '@teable/openapi';\nimport { LocalStorageKeys } from '@teable/sdk/config';\nimport {\n  useBaseId,\n  useFields,\n  useFieldStaticGetter,\n  useTableId,\n  useTablePermission,\n  useView,\n} from '@teable/sdk/hooks';\nimport {\n  Command,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandItem,\n  TooltipProvider,\n  Tooltip,\n  Label,\n  TooltipTrigger,\n  TooltipContent,\n  Switch,\n  Toggle,\n  Spin,\n  Button,\n  AlertDialog,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  Checkbox,\n  AlertDialogFooter,\n  AlertDialogCancel,\n  AlertDialogAction,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { useEnv } from '@/features/app/hooks/useEnv';\n\ninterface ISearchCommand {\n  value: string;\n  hideNotMatchRow?: boolean;\n  onHideSwitchChange: (hideNotMatchRow?: boolean) => void;\n  onChange: (fieldIds: string[] | null) => void;\n  shareView?: boolean;\n}\n\nexport interface ISearchCommandRef {\n  toggleSearchIndex: () => Promise<void>;\n}\n\nenum ActionType {\n  repair = 'repair',\n  create = 'create',\n}\n\nexport const SearchCommand = forwardRef<ISearchCommandRef, ISearchCommand>((props, ref) => {\n  const { onChange, value, hideNotMatchRow, onHideSwitchChange, shareView } = props;\n  const env = useEnv();\n  const { maxSearchFieldCount = Infinity } = env;\n  const { t } = useTranslation(['common', 'table']);\n  const defaultFields = useFields();\n  const fields = defaultFields.filter((f) => f.type !== FieldType.Button);\n  const view = useView();\n  const fieldStaticGetter = useFieldStaticGetter();\n  const baseId = useBaseId();\n  const tableId = useTableId();\n  const permission = useTablePermission();\n  const editable = permission['table|update'];\n\n  const selectedFields = useMemo(() => {\n    return value.split(',');\n  }, [value]);\n\n  const queryClient = useQueryClient();\n\n  useImperativeHandle(ref, () => ({\n    toggleSearchIndex: async () => {\n      toggleIndexFn(TableIndex.search);\n    },\n  }));\n\n  const [alertVisible, setAlertVisible] = useState(false);\n  const [shouldAlert, setShouldAlert] = useLocalStorage(LocalStorageKeys.SearchIndexAlert, true);\n  const [noPrompt, setNoPrompt] = useState(false);\n  const [actionType, setActionType] = useState(ActionType.create);\n\n  const { data: tableActivatedIndex } = useQuery({\n    queryKey: ['table-index', tableId],\n    queryFn: () => getTableActivatedIndex(baseId!, tableId!).then(({ data }) => data),\n    enabled: !shareView,\n  });\n\n  const enabledSearchIndex = tableActivatedIndex?.includes(TableIndex.search);\n\n  const { data: searchAbnormalIndex, isLoading: getAbnormalLoading } = useQuery({\n    queryKey: ['table-abnormal-index', baseId, tableId, TableIndex.search],\n    queryFn: () =>\n      getTableAbnormalIndex(baseId!, tableId!, TableIndex.search).then(({ data }) => data),\n    enabled: Boolean(enabledSearchIndex && !shareView),\n  });\n\n  const { mutateAsync: toggleIndexFn, isPending: isLoading } = useMutation({\n    mutationFn: (type: TableIndex) => toggleTableIndex(baseId!, tableId!, { type }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['table-index', tableId] });\n    },\n  });\n\n  const { mutateAsync: repairIndexFn, isPending: repairIndexLoading } = useMutation({\n    mutationFn: (type: TableIndex) => repairTableIndex(baseId!, tableId!, type),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ['table-abnormal-index', baseId, tableId, TableIndex.search],\n      });\n    },\n  });\n\n  const switchChange = (id: string, checked: boolean) => {\n    let newSelectedFields = [...selectedFields];\n    if (checked) {\n      newSelectedFields.push(id);\n    } else {\n      newSelectedFields = newSelectedFields.filter((f) => f !== id);\n    }\n    onChange(newSelectedFields);\n  };\n\n  const commandFilter = useCallback(\n    (fieldId: string, searchValue: string) => {\n      const currentField = fields.find(\n        ({ id }) => fieldId.toLocaleLowerCase() === id.toLocaleLowerCase()\n      );\n      const name = currentField?.name?.toLocaleLowerCase()?.trim() || t('untitled');\n      const containWord = name.indexOf(searchValue.toLowerCase()) > -1;\n      return Number(containWord);\n    },\n    [fields, t]\n  );\n\n  const enableGlobalSearch = value === 'all_fields';\n\n  const [filterText, setFilterText] = useState('');\n\n  return (\n    <Command filter={commandFilter}>\n      {\n        <>\n          <CommandInput\n            placeholder={t('actions.search')}\n            className=\"h-8 text-xs\"\n            disabled={enableGlobalSearch}\n            value={filterText}\n            onValueChange={(value) => {\n              setFilterText(value);\n            }}\n          />\n          <CommandList className=\"my-2 max-h-64\">\n            {<CommandEmpty>{t('listEmptyTips')}</CommandEmpty>}\n            {fields.map((field) => {\n              const {\n                id,\n                name,\n                type,\n                isLookup,\n                isConditionalLookup,\n                aiConfig,\n                canReadFieldRecord,\n              } = field;\n              const { Icon } = fieldStaticGetter(type, {\n                isLookup,\n                isConditionalLookup,\n                hasAiConfig: Boolean(aiConfig),\n                deniedReadRecord: !canReadFieldRecord,\n              });\n              return (\n                <CommandItem\n                  className=\"flex flex-1 truncate p-0\"\n                  key={id}\n                  value={id}\n                  disabled={enableGlobalSearch}\n                >\n                  <TooltipProvider>\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <div className=\"flex flex-1 items-center truncate p-0\">\n                          <Label\n                            htmlFor={id}\n                            className=\"flex flex-1 cursor-pointer items-center truncate p-2\"\n                          >\n                            <Switch\n                              id={id}\n                              className=\"scale-75\"\n                              checked={selectedFields.includes(id) || enableGlobalSearch}\n                              onCheckedChange={(checked) => {\n                                switchChange(id, checked);\n                              }}\n                              disabled={selectedFields.includes(id) && selectedFields.length === 1}\n                            />\n                            <Icon className=\"ml-2 size-4 shrink-0\" />\n                            <span\n                              className=\"h-full flex-1 cursor-pointer truncate pl-1 text-sm\"\n                              title={name}\n                            >\n                              {name}\n                            </span>\n                          </Label>\n                        </div>\n                      </TooltipTrigger>\n                      {selectedFields.includes(id) && selectedFields.length === 1 ? (\n                        <TooltipContent>\n                          {t('atLeastOne', { noun: t('noun.field') })}\n                        </TooltipContent>\n                      ) : null}\n                    </Tooltip>\n                  </TooltipProvider>\n                </CommandItem>\n              );\n            })}\n          </CommandList>\n        </>\n      }\n\n      <div className=\"flex flex-col gap-y-1\">\n        <div className=\"flex items-center justify-around gap-1\">\n          <TooltipProvider>\n            <Tooltip>\n              <Toggle\n                pressed={enableGlobalSearch}\n                onPressedChange={() => {\n                  onChange(['all_fields']);\n                  setFilterText('');\n                }}\n                size={'sm'}\n                className=\"flex flex-1 items-center truncate p-0\"\n              >\n                <TooltipTrigger asChild>\n                  <div className=\"flex size-full flex-1 items-center justify-center truncate p-0\">\n                    <span\n                      className=\"flex items-center gap-0.5 truncate text-sm\"\n                      title={t('actions.hideNotMatchRow')}\n                    >\n                      {t('actions.globalSearch')}\n                      <HelpCircle />\n                    </span>\n                  </div>\n                </TooltipTrigger>\n              </Toggle>\n\n              <TooltipContent>\n                {maxSearchFieldCount !== Infinity\n                  ? t('table:table.index.globalSearchTip_limited', {\n                      count: maxSearchFieldCount,\n                    })\n                  : t('table:table.index.globalSearchTip_infinity')}\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n\n          <Toggle\n            pressed={!enableGlobalSearch}\n            onPressedChange={() => {\n              onChange(null);\n            }}\n            size={'sm'}\n            className=\"flex flex-1 items-center truncate p-0\"\n          >\n            <span className=\"truncate text-sm\" title={t('actions.hideNotMatchRow')}>\n              {t('actions.fieldSearch')}\n            </span>\n          </Toggle>\n        </div>\n\n        {view?.type === ViewType.Grid && (\n          <div className=\"flex items-center justify-around gap-1\">\n            <Toggle\n              pressed={!hideNotMatchRow}\n              onPressedChange={() => {\n                onHideSwitchChange(false);\n              }}\n              size={'sm'}\n              className=\"flex flex-1 items-center truncate p-0\"\n            >\n              <span className=\"truncate text-sm\" title={t('actions.hideNotMatchRow')}>\n                {t('actions.showAllRow')}\n              </span>\n            </Toggle>\n\n            <Toggle\n              pressed={!!hideNotMatchRow}\n              onPressedChange={() => {\n                onHideSwitchChange(true);\n              }}\n              size={'sm'}\n              className=\"flex flex-1 items-center truncate p-0\"\n            >\n              <span className=\"truncate text-sm\" title={t('actions.hideNotMatchRow')}>\n                {t('actions.hideNotMatchRow')}\n              </span>\n            </Toggle>\n          </div>\n        )}\n      </div>\n\n      {!shareView && editable && (\n        <div className=\"flex items-center justify-between pl-1\">\n          <div className=\"flex flex-1 items-center gap-1\">\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <div className=\"flex items-center gap-0.5 text-sm\">\n                    {t('actions.tableIndex')}\n                    <HelpCircle />\n                  </div>\n                </TooltipTrigger>\n                <TooltipContent className=\"max-w-80 text-wrap break-words\" sideOffset={5}>\n                  {t('table:table.index.description', { rowCount: RecommendedIndexRow })}\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n            {enabledSearchIndex && !!searchAbnormalIndex?.length && (\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div className=\"flex items-center gap-0.5\">\n                      <Button\n                        size={'xs'}\n                        variant={'destructive'}\n                        className=\"flex h-6 items-center gap-0.5\"\n                        onClick={async () => {\n                          if (shouldAlert) {\n                            setAlertVisible(true);\n                            setActionType(ActionType.repair);\n                            return;\n                          }\n                          await repairIndexFn(TableIndex.search);\n                        }}\n                      >\n                        {t('table:table.index.repair')}\n                        {repairIndexLoading || getAbnormalLoading ? (\n                          <Spin className=\"size-3\" />\n                        ) : null}\n                      </Button>\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent className=\"text-wrap break-words\" sideOffset={5}>\n                    {t('table:table.index.repairTip')}\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            )}\n          </div>\n\n          <div>\n            <Label\n              htmlFor={'search-index'}\n              className=\"flex flex-1 cursor-pointer items-center justify-between truncate p-2\"\n            >\n              <div className=\"flex h-7 items-center gap-1\">\n                <div className=\"flex items-center gap-1\">\n                  {isLoading ? <Spin className=\"size-3\" /> : null}\n                  <Switch\n                    id={'search-index'}\n                    className=\"scale-75\"\n                    checked={enabledSearchIndex}\n                    onCheckedChange={async (val) => {\n                      if (val && shouldAlert) {\n                        setAlertVisible(true);\n                        setActionType(ActionType.create);\n                        return;\n                      }\n                      baseId && tableId && (await toggleIndexFn(TableIndex.search));\n                    }}\n                  />\n                </div>\n              </div>\n            </Label>\n          </div>\n        </div>\n      )}\n\n      <AlertDialog open={alertVisible} onOpenChange={setAlertVisible}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('table:import.title.tipsTitle')}</AlertDialogTitle>\n            <AlertDialogDescription>{t('table:table.index.enableIndexTip')}</AlertDialogDescription>\n          </AlertDialogHeader>\n          <div className=\"flex items-center\">\n            <Checkbox\n              id=\"noTips\"\n              checked={noPrompt}\n              onCheckedChange={(should: boolean) => {\n                setNoPrompt(should);\n              }}\n            />\n            <label\n              htmlFor=\"noTips\"\n              className=\"pl-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n            >\n              {t('table:import.tips.noTips')}\n            </label>\n          </div>\n          <AlertDialogFooter>\n            <AlertDialogCancel>{t('table:import.menu.cancel')}</AlertDialogCancel>\n            <AlertDialogAction\n              onClick={() => {\n                if (actionType === ActionType.create) {\n                  toggleIndexFn(TableIndex.search);\n                } else {\n                  repairIndexFn(TableIndex.search);\n                }\n                setShouldAlert(!noPrompt);\n              }}\n            >\n              {t('table:import.title.confirm')}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </Command>\n  );\n});\n\nSearchCommand.displayName = 'SearchCommand';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/search/SearchCountPagination.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { ChevronRight, ChevronLeft } from '@teable/icons';\nimport type { ISearchIndexByQueryRo, ISearchIndexVo } from '@teable/openapi';\nimport { getSearchIndex, getShareViewSearchIndex } from '@teable/openapi';\nimport { type GridView } from '@teable/sdk';\nimport {\n  useTableId,\n  useView,\n  useFields,\n  useSearch,\n  usePersonalView,\n  useTableListener,\n} from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { isEmpty, pick } from 'lodash';\nimport { useEffect, useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';\nimport { useGridSearchStore } from '../grid/useGridSearchStore';\nimport type { ISearchButtonProps } from './SearchButton';\n\nenum PageDirection {\n  Next = 1,\n  Prev = -1,\n}\n\ntype ISearchMap = Record<number, NonNullable<ISearchIndexVo>[number]>;\n\nconst PaginationBuffer = 100;\n\ntype ISearchCountPaginationProps = Pick<ISearchButtonProps, 'shareView'>;\n\nexport interface ISearchCountPaginationRef {\n  nextIndex: () => void;\n  prevIndex: () => void;\n}\n\ninterface PageData {\n  data: NonNullable<ISearchIndexVo>;\n  nextCursor: number | null;\n}\n\nexport const SearchCountPagination = forwardRef<\n  ISearchCountPaginationRef,\n  ISearchCountPaginationProps\n>((props: ISearchCountPaginationProps, ref) => {\n  const { shareView } = props;\n  const { value, searchQuery } = useSearch();\n  const tableId = useTableId();\n  const view = useView() as GridView;\n  const fields = useFields();\n  const [currentIndex, setCurrentIndex] = useState(1);\n  const { gridRef, setSearchCursor, recordMap } = useGridSearchStore();\n  const { personalViewCommonQuery } = usePersonalView();\n  const [isEnd, setIsEnd] = useState(false);\n\n  const searchViewCondition = useMemo(() => {\n    return view ? pick(view, ['sort', 'filter', 'group', 'columnMeta']) : {};\n  }, [view]);\n\n  useImperativeHandle(ref, () => ({\n    nextIndex: () => {\n      switchIndex(PageDirection.Next);\n    },\n    prevIndex: () => {\n      switchIndex(PageDirection.Prev);\n    },\n  }));\n\n  const viewOrderBy = useMemo(() => {\n    return view?.sort?.manualSort === undefined || view?.sort?.manualSort === false\n      ? view?.sort?.sortObjs\n      : undefined;\n  }, [view]);\n\n  const setIndexSelection = useCallback(\n    (row: number, cellColumnId: string) => {\n      const index = fields.findIndex((f) => f.id === cellColumnId);\n      setSearchCursor([index, row - 1]);\n      gridRef?.current?.scrollToItem([index, row - 1]);\n    },\n    [fields, gridRef, setSearchCursor]\n  );\n\n  const queryFn = async ({ pageParam = 0 }) => {\n    const skipLength = new Set(\n      Object.values(allSearchResults).map((rec) => rec.recordId) as string[]\n    ).size as number;\n\n    const baseQueryRo: ISearchIndexByQueryRo = {\n      skip: pageParam,\n      take: PaginationBuffer,\n      viewId: view?.id,\n      orderBy: viewOrderBy,\n      search: searchQuery,\n      groupBy: view.group,\n      filter: view.filter,\n      ...personalViewCommonQuery,\n    };\n\n    const searchFn = shareView\n      ? (params: ISearchIndexByQueryRo) => getShareViewSearchIndex(view.shareId!, params)\n      : (params: ISearchIndexByQueryRo) => getSearchIndex(tableId!, params);\n\n    const result = await searchFn(baseQueryRo);\n\n    if (!result?.data || pageParam === null) {\n      setIsEnd(true);\n      return {\n        data: [],\n        nextCursor: null,\n      };\n    }\n\n    const nextCursor =\n      result.data?.length ?? 0 >= PaginationBuffer ? skipLength + PaginationBuffer : null;\n\n    const dataLength = Object.values(allSearchResults).length;\n\n    if (currentIndex === dataLength && dataLength !== 0 && result?.data?.length !== 0) {\n      setCurrentIndex(currentIndex + PageDirection.Next);\n    }\n\n    return {\n      data: result.data || [],\n      nextCursor,\n    } as PageData;\n  };\n\n  const { data, isFetching, isLoading, fetchNextPage, refetch } = useInfiniteQuery({\n    queryKey: [\n      'search_index',\n      tableId,\n      value,\n      JSON.stringify(searchViewCondition),\n      JSON.stringify(searchQuery),\n      JSON.stringify(personalViewCommonQuery),\n    ],\n    queryFn,\n    refetchOnMount: 'always',\n    refetchOnWindowFocus: false,\n    enabled: !!value,\n    initialPageParam: 0,\n    getNextPageParam: (lastPage) => {\n      return lastPage.nextCursor;\n    },\n  });\n\n  const allSearchResults = useMemo(() => {\n    const finalResult: ISearchMap = {};\n    const result = data?.pages.flatMap((page) => page.data) ?? [];\n    result.forEach((result, index) => {\n      const indexNumber = index + 1;\n      finalResult[indexNumber] = result;\n    });\n    return finalResult;\n  }, [data?.pages]);\n\n  const switchIndex = (direction: PageDirection) => {\n    const newIndex = currentIndex + direction;\n    if (isFetching || isLoading) {\n      return;\n    }\n    if (newIndex < 1) {\n      setCurrentIndex(1);\n      return;\n    }\n    if (Object.values(allSearchResults)?.length === 0) {\n      return;\n    }\n    if (newIndex > Object.values(allSearchResults)?.length && !isEnd) {\n      fetchNextPage();\n      return;\n    }\n    if (newIndex > Object.values(allSearchResults)?.length && isEnd) {\n      return;\n    }\n\n    setCurrentIndex(newIndex);\n  };\n\n  useEffect(() => {\n    if (allSearchResults?.[currentIndex]) {\n      const index = allSearchResults?.[currentIndex];\n      index && setIndexSelection(index.index, index.fieldId);\n    } else {\n      setSearchCursor(null);\n    }\n  }, [currentIndex, allSearchResults, setIndexSelection, setSearchCursor]);\n\n  useEffect(() => {\n    if (value) {\n      setIsEnd(false);\n      setCurrentIndex(1);\n    }\n  }, [setSearchCursor, value]);\n\n  useTableListener(tableId, ['setRecord', 'addRecord', 'deleteRecord'], () => {\n    if (!value || isEmpty(allSearchResults) || !recordMap || isLoading || isFetching) {\n      return;\n    }\n\n    if (allSearchResults?.[currentIndex]) {\n      const index = allSearchResults?.[currentIndex];\n      const { fieldId, index: recordIndex } = index;\n      const displayValue = recordMap?.[recordIndex + 1]?.getCellValueAsString(fieldId);\n      const reg = new RegExp(value, 'gi');\n      if (!reg.test(displayValue)) {\n        setCurrentIndex(1);\n        refetch();\n      }\n    }\n  });\n\n  return (\n    value &&\n    (isFetching || isLoading ? (\n      <Spin className=\"size-3 shrink-0\" />\n    ) : (\n      <div className=\"flex flex-1 shrink-0 items-center gap-0.5 p-0\">\n        <Button\n          size={'xs'}\n          variant={'ghost'}\n          onClick={() => {\n            switchIndex(PageDirection.Prev);\n          }}\n          className=\"size-5 p-0\"\n          disabled={currentIndex === 1}\n        >\n          <ChevronLeft className=\"size-4 shrink-0\" />\n        </Button>\n\n        <Button\n          size={'xs'}\n          variant={'ghost'}\n          onClick={() => {\n            switchIndex(PageDirection.Next);\n          }}\n          className=\"size-5 p-0\"\n          disabled={\n            (currentIndex === Object.values(allSearchResults).length && isEnd) ||\n            Object.values(allSearchResults).length === 0\n          }\n        >\n          <ChevronRight className=\"size-4 shrink-0\" />\n        </Button>\n      </div>\n    ))\n  );\n});\n\nSearchCountPagination.displayName = 'SearchCountPagination';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/APIDialog.tsx",
    "content": "import { Code2 } from '@teable/icons';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Skeleton,\n} from '@teable/ui-lib/shadcn';\nimport dynamic from 'next/dynamic';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\n// Skeleton component for loading state\nconst APIDialogSkeleton = () => {\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col gap-4\">\n      {/* Tab skeleton */}\n      <div className=\"flex gap-2\">\n        <Skeleton className=\"h-9 w-28 rounded-md\" />\n        <Skeleton className=\"h-9 w-20 rounded-md\" />\n      </div>\n\n      {/* Token section skeleton */}\n      <div className=\"rounded-lg border bg-muted/30 p-4\">\n        <div className=\"flex items-center justify-between gap-4\">\n          <div className=\"flex items-center gap-3\">\n            <Skeleton className=\"size-5 rounded\" />\n            <Skeleton className=\"h-5 w-12\" />\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Skeleton className=\"h-8 w-28 rounded-md\" />\n            <Skeleton className=\"h-8 w-24 rounded-md\" />\n          </div>\n        </div>\n      </div>\n\n      {/* Content area skeleton */}\n      <div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n        <div className=\"flex items-center justify-between\">\n          <Skeleton className=\"h-5 w-32\" />\n          <Skeleton className=\"h-8 w-28 rounded-md\" />\n        </div>\n        <div className=\"flex-1 rounded-lg border bg-muted/20 p-4\">\n          <div className=\"space-y-3\">\n            <Skeleton className=\"h-6 w-48\" />\n            <Skeleton className=\"h-4 w-full\" />\n            <Skeleton className=\"h-4 w-3/4\" />\n            <div className=\"pt-4\">\n              <Skeleton className=\"h-20 w-full rounded-md\" />\n            </div>\n            <Skeleton className=\"h-4 w-full\" />\n            <Skeleton className=\"h-4 w-5/6\" />\n            <div className=\"pt-4\">\n              <Skeleton className=\"h-16 w-full rounded-md\" />\n            </div>\n            <Skeleton className=\"h-4 w-2/3\" />\n            <Skeleton className=\"h-4 w-full\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\n// Dynamically import the heavy content component\nconst APIDialogContent = dynamic(\n  () => import('./APIDialogContent').then((mod) => mod.APIDialogContent),\n  {\n    loading: () => <APIDialogSkeleton />,\n    ssr: false,\n  }\n);\n\ninterface APIDialogProps {\n  open?: boolean;\n  setOpen?: (open: boolean) => void;\n  children: React.ReactNode;\n}\n\nexport const APIDialog = ({ open, setOpen, children }: APIDialogProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const [internalOpen, setInternalOpen] = useState(false);\n\n  const isControlled = open !== undefined;\n  const isOpen = isControlled ? open : internalOpen;\n  const handleOpenChange = useCallback(\n    (open: boolean) => {\n      if (isControlled) {\n        setOpen?.(open);\n      } else {\n        setInternalOpen(open);\n      }\n    },\n    [isControlled, setOpen]\n  );\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleOpenChange}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"flex max-h-[90vh] max-w-4xl flex-col overflow-hidden\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <Code2 className=\"size-5\" />\n            {t('table:toolbar.others.api.title')}\n          </DialogTitle>\n        </DialogHeader>\n\n        {isOpen && <APIDialogContent onOpenChange={handleOpenChange} />}\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/APIDialogContent.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport { CellFormat, FieldKeyType, FieldType, type IFilterSet, type ISortItem } from '@teable/core';\nimport { ArrowUpRight, Code2, Copy, Check, Loader2, MagicAi, Key } from '@teable/icons';\nimport {\n  createAccessToken,\n  getFields,\n  getTableById,\n  type CreateAccessTokenVo,\n  type IQueryBaseRo,\n} from '@teable/openapi';\nimport { MarkdownPreview } from '@teable/sdk';\nimport { StandaloneViewProvider } from '@teable/sdk/context';\nimport {\n  Button,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n  ScrollArea,\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  Input,\n  ToggleGroup,\n  ToggleGroupItem,\n} from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useMemo, useEffect, useCallback } from 'react';\nimport { FilterBuilder } from '@/features/app/blocks/setting/query-builder/FilterBuilder';\nimport { PreviewScript } from '@/features/app/blocks/setting/query-builder/PreviewScript';\nimport { PreviewTable } from '@/features/app/blocks/setting/query-builder/PreviewTable';\nimport { SearchBuilder } from '@/features/app/blocks/setting/query-builder/SearchBuilder';\nimport { OrderByBuilder } from '@/features/app/blocks/setting/query-builder/SortBuilder';\nimport { ViewBuilder } from '@/features/app/blocks/setting/query-builder/ViewBuilder';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport type { IBaseResourceTable } from '@/features/app/hooks/useBaseResource';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IFieldInfo {\n  id: string;\n  name: string;\n  type: string;\n  description?: string;\n  options?: unknown;\n  isPrimary?: boolean;\n  isComputed?: boolean;\n}\n\nconst getFieldTypeDescription = (type: FieldType, options?: unknown): string => {\n  switch (type) {\n    case FieldType.SingleLineText:\n      return 'Single line text';\n    case FieldType.LongText:\n      return 'Long text / Rich text';\n    case FieldType.Number:\n      return 'Number';\n    case FieldType.SingleSelect: {\n      const opts = options as { choices?: { name: string }[] };\n      const choices = opts?.choices?.map((c) => c.name).join(', ') || '';\n      return choices ? `Single select (options: ${choices})` : 'Single select';\n    }\n    case FieldType.MultipleSelect: {\n      const opts = options as { choices?: { name: string }[] };\n      const choices = opts?.choices?.map((c) => c.name).join(', ') || '';\n      return choices ? `Multiple select (options: ${choices})` : 'Multiple select';\n    }\n    case FieldType.Checkbox:\n      return 'Checkbox (true/false)';\n    case FieldType.Date:\n      return 'Date/Time';\n    case FieldType.Attachment:\n      return 'File attachments';\n    case FieldType.Link:\n      return 'Link to another table';\n    case FieldType.Formula:\n      return 'Computed formula field';\n    case FieldType.Rollup:\n    case FieldType.ConditionalRollup:\n      return 'Rollup (aggregation from linked records)';\n    case FieldType.User:\n      return 'User reference';\n    case FieldType.CreatedTime:\n      return 'Created time (auto-generated)';\n    case FieldType.LastModifiedTime:\n      return 'Last modified time (auto-generated)';\n    case FieldType.CreatedBy:\n      return 'Created by (auto-generated)';\n    case FieldType.LastModifiedBy:\n      return 'Last modified by (auto-generated)';\n    case FieldType.AutoNumber:\n      return 'Auto-incrementing number';\n    case FieldType.Rating:\n      return 'Rating (1-5 stars)';\n    case FieldType.Button:\n      return 'Button (trigger actions)';\n    default:\n      return type;\n  }\n};\n\nconst TOKEN_PLACEHOLDER = '<YOUR_API_TOKEN>';\n\nconst generateAIContext = (\n  tableName: string,\n  tableDescription: string | undefined,\n  fields: IFieldInfo[],\n  baseUrl: string,\n  tableId: string,\n  token?: string\n): string => {\n  const displayToken = token || TOKEN_PLACEHOLDER;\n  const fieldDescriptions = fields\n    .map((field) => {\n      const typeDesc = getFieldTypeDescription(field.type as FieldType, field.options);\n      const primary = field.isPrimary ? ' [PRIMARY]' : '';\n      const computed = field.isComputed ? ' [READ-ONLY]' : '';\n      const desc = field.description ? ` - ${field.description}` : '';\n      return `  - \"${field.name}\" [id: ${field.id}] (${typeDesc})${primary}${computed}${desc}`;\n    })\n    .join('\\n');\n\n  const editableFields = fields\n    .filter((f) => !f.isComputed)\n    .map((f) => `\"${f.name}\"`)\n    .join(', ');\n\n  return `# Table: ${tableName}\n${tableDescription ? `\\nDescription: ${tableDescription}\\n` : ''}\n## API Operations\n\n### 1. Read Records (GET)\n\\`\\`\\`bash\ncurl -X GET \"${baseUrl}/api/table/${tableId}/record?fieldKeyType=name\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n#### Pagination\nUse \\`skip\\` and \\`take\\` parameters:\n- \\`take\\`: Number of records to return (default: 100, max: 1000)\n- \\`skip\\`: Number of records to skip\n\n\\`\\`\\`bash\n# Get 20 records, starting from the 41st record (page 3)\ncurl \"${baseUrl}/api/table/${tableId}/record?take=20&skip=40&fieldKeyType=name\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n#### Filtering\nUse the \\`filter\\` parameter with a JSON object.\n\n**⚠️ Important: The \\`fieldId\\` in filter/orderBy MUST use the actual field ID (e.g., \"fldXXXX\"), not the field name.**\n\n\\`\\`\\`bash\n# Filter records - use field ID from the Fields section above\ncurl \"${baseUrl}/api/table/${tableId}/record?fieldKeyType=name\" \\\\\n  --data-urlencode 'filter={\"conjunction\":\"and\",\"filterSet\":[{\"fieldId\":\"fldXXXXXXX\",\"operator\":\"is\",\"value\":\"Active\"}]}' \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n**Filter Operators**:\n- Text: \\`is\\`, \\`isNot\\`, \\`contains\\`, \\`doesNotContain\\`, \\`isEmpty\\`, \\`isNotEmpty\\`\n- Number: \\`is\\`, \\`isNot\\`, \\`isGreater\\`, \\`isLess\\`, \\`isGreaterEqual\\`, \\`isLessEqual\\`\n- Date: \\`is\\`, \\`isBefore\\`, \\`isAfter\\`, \\`isWithin\\`\n\n#### Sorting\nUse the \\`orderBy\\` parameter.\n\n**⚠️ Important: The \\`fieldId\\` in orderBy MUST use the actual field ID (e.g., \"fldXXXX\"), not the field name.**\n\n\\`\\`\\`bash\n# Sort by a field - use field ID from the Fields section above\ncurl \"${baseUrl}/api/table/${tableId}/record?fieldKeyType=name\" \\\\\n  --data-urlencode 'orderBy=[{\"fieldId\":\"fldXXXXXXX\",\"order\":\"desc\"}]' \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n#### Field Selection (Projection)\nUse the \\`projection\\` parameter to return only specific fields:\n\\`\\`\\`bash\n# Only return \"Name\" and \"Email\" fields\ncurl \"${baseUrl}/api/table/${tableId}/record?fieldKeyType=name&projection=Name&projection=Email\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n#### Searching\nUse the \\`search\\` parameter:\n\\`\\`\\`bash\n# Search for \"john\" in all fields\ncurl \"${baseUrl}/api/table/${tableId}/record?search=john&fieldKeyType=name\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n### 2. Create Record (POST)\n\\`\\`\\`bash\ncurl -X POST \"${baseUrl}/api/table/${tableId}/record\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\" \\\\\n  -H \"Content-Type: application/json\" \\\\\n  -d '{\n    \"fieldKeyType\": \"name\",\n    \"records\": [\n      {\n        \"fields\": {\n          // Editable fields: ${editableFields || 'None'}\n        }\n      }\n    ]\n  }'\n\\`\\`\\`\n\n### 3. Update Record (PATCH)\n\\`\\`\\`bash\ncurl -X PATCH \"${baseUrl}/api/table/${tableId}/record/{recordId}\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\" \\\\\n  -H \"Content-Type: application/json\" \\\\\n  -d '{\n    \"fieldKeyType\": \"name\",\n    \"record\": {\n      \"fields\": {\n        // Include only fields you want to update\n      }\n    }\n  }'\n\\`\\`\\`\n\n### 4. Delete Record (DELETE)\n\\`\\`\\`bash\ncurl -X DELETE \"${baseUrl}/api/table/${tableId}/record/{recordId}\" \\\\\n  -H \"Authorization: Bearer ${displayToken}\"\n\\`\\`\\`\n\n---\n\n## API Configuration\n- **Base URL**: ${baseUrl}\n- **Table ID**: ${tableId}\n- **API Token**: ${displayToken}\n- **Endpoint**: \\`${baseUrl}/api/table/${tableId}/record\\`\n\n## Authentication\nAll requests require the \\`Authorization\\` header:\n\\`\\`\\`\nAuthorization: Bearer ${displayToken}\n\\`\\`\\`\n\n---\n\n## Fields\n${fieldDescriptions}\n\n---\n\n## Notes for AI\n- Fields marked [PRIMARY] are the main identifier field\n- Fields marked [READ-ONLY] are computed and cannot be directly modified\n- Use \\`fieldKeyType=name\\` to reference fields by their display name in request/response body\n- **Important**: \\`filter\\` and \\`orderBy\\` parameters MUST use field IDs (the [id: fldXXX] shown above), not field names\n- Dates should be in ISO 8601 format (e.g., \"2024-01-15T10:30:00Z\")\n- For select fields, use the exact option names listed above\n- For link fields, provide an array of record IDs from the linked table\n- Response format: \\`{ \"records\": [{ fields: { ... } }] }\\`\n`;\n};\n\n// Token Section Component\nconst TokenSection = ({\n  generatedToken,\n  isLoading,\n  onGenerateToken,\n}: {\n  generatedToken: CreateAccessTokenVo | null;\n  isLoading: boolean;\n  onGenerateToken: () => void;\n}) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  return (\n    <div className=\"rounded-lg border bg-muted/30 p-4\">\n      <div className=\"flex items-center justify-between gap-4\">\n        <div className=\"flex items-center gap-3\">\n          <Key className=\"size-5 text-muted-foreground\" />\n          <span className=\"font-medium\">Token</span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {generatedToken ? (\n            <>\n              <Input className=\"w-64 font-mono text-xs\" readOnly value={generatedToken.token} />\n              <CopyButton\n                variant=\"outline\"\n                size=\"sm\"\n                text={generatedToken.token}\n                iconClassName=\"size-4\"\n              />\n            </>\n          ) : (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={onGenerateToken}\n              disabled={isLoading}\n              className=\"gap-2\"\n            >\n              {isLoading ? (\n                <>\n                  <Loader2 className=\"size-4 animate-spin\" />\n                  {t('table:toolbar.others.api.generatingToken')}\n                </>\n              ) : (\n                <>\n                  <Key className=\"size-4\" />\n                  {t('table:toolbar.others.api.generateToken')}\n                </>\n              )}\n            </Button>\n          )}\n          <Button variant=\"ghost\" size=\"sm\" asChild className=\"gap-1 text-muted-foreground\">\n            <Link href=\"/setting/personal-access-token\" target=\"_blank\">\n              {t('table:toolbar.others.api.manageToken')}\n              <ArrowUpRight className=\"size-3\" />\n            </Link>\n          </Button>\n        </div>\n      </div>\n      {generatedToken && (\n        <p className=\"mt-2 text-xs text-muted-foreground\">\n          {t('table:toolbar.others.api.tokenInfo', {\n            expiry: new Date(generatedToken.expiredTime).toLocaleDateString(),\n          })}\n        </p>\n      )}\n    </div>\n  );\n};\n\n// Advanced Query Builder Panel Component\nconst AdvancedQueryPanel = ({ tableId, baseId }: { tableId: string; baseId: string }) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [viewId, setViewId] = useState<string>();\n  const [filter, setFilter] = useState<IFilterSet | null>(null);\n  const [fieldKeyType, setFieldKeyType] = useState<FieldKeyType>(FieldKeyType.Name);\n  const [cellFormat, setCellFormat] = useState<CellFormat>(CellFormat.Json);\n  const [orderBy, setOrderBy] = useState<ISortItem[]>();\n  const [search, setSearch] = useState<IQueryBaseRo['search']>();\n\n  const query = useMemo(\n    () => ({\n      fieldKeyType,\n      viewId,\n      filter,\n      orderBy,\n      search,\n      cellFormat,\n    }),\n    [fieldKeyType, viewId, filter, orderBy, search, cellFormat]\n  );\n\n  return (\n    <StandaloneViewProvider baseId={baseId} tableId={tableId} viewId={viewId}>\n      <div className=\"space-y-4\">\n        {/* Introduction */}\n        <div className=\"rounded-lg border bg-muted/30 p-4\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <div>\n              <h3 className=\"font-medium\">{t('table:toolbar.others.api.queryBuilderTitle')}</h3>\n              <p className=\"mt-1 text-sm text-muted-foreground\">\n                {t('table:toolbar.others.api.queryBuilderDesc')}\n              </p>\n            </div>\n            <Button variant=\"outline\" size=\"sm\" asChild className=\"shrink-0 gap-1\">\n              <Link href={t('common:help.apiLink')} target=\"_blank\">\n                {t('table:toolbar.others.api.viewApiDocs')}\n                <ArrowUpRight className=\"size-3\" />\n              </Link>\n            </Button>\n          </div>\n        </div>\n\n        {/* View & Search */}\n        <div className=\"grid gap-4 md:grid-cols-2\">\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('common:noun.view')}</label>\n            <ViewBuilder viewId={viewId} onChange={setViewId} />\n          </div>\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('common:actions.search')}</label>\n            <SearchBuilder search={search} onChange={setSearch} />\n          </div>\n        </div>\n\n        {/* Filter */}\n        <div className=\"flex flex-col gap-2\">\n          <label className=\"text-sm font-medium\">{t('sdk:filter.label')}</label>\n          <FilterBuilder filter={filter} onChange={setFilter} />\n        </div>\n\n        {/* Sort */}\n        <div className=\"flex flex-col gap-2\">\n          <label className=\"text-sm font-medium\">{t('sdk:sort.label')}</label>\n          <OrderByBuilder orderBy={orderBy} onChange={setOrderBy} />\n        </div>\n\n        {/* Format Options */}\n        <div className=\"grid gap-4 md:grid-cols-2\">\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('developer:cellFormat')}</label>\n            <ToggleGroup\n              className=\"w-auto justify-start\"\n              variant=\"outline\"\n              type=\"single\"\n              size=\"sm\"\n              value={cellFormat}\n              onValueChange={(v) => setCellFormat((v as CellFormat) || CellFormat.Json)}\n            >\n              <ToggleGroupItem value=\"json\">JSON</ToggleGroupItem>\n              <ToggleGroupItem value=\"text\">Text</ToggleGroupItem>\n            </ToggleGroup>\n          </div>\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('developer:fieldKeyType')}</label>\n            <ToggleGroup\n              className=\"w-auto justify-start\"\n              variant=\"outline\"\n              type=\"single\"\n              size=\"sm\"\n              value={fieldKeyType}\n              onValueChange={(v) => setFieldKeyType((v as FieldKeyType) || FieldKeyType.Name)}\n            >\n              <ToggleGroupItem value=\"name\">name</ToggleGroupItem>\n              <ToggleGroupItem value=\"id\">id</ToggleGroupItem>\n              <ToggleGroupItem value=\"dbFieldName\">dbFieldName</ToggleGroupItem>\n            </ToggleGroup>\n          </div>\n        </div>\n\n        {/* Preview Script */}\n        <div className=\"border-t pt-4\">\n          <h3 className=\"mb-4 text-sm font-medium\">{t('developer:buildResult')}</h3>\n          <PreviewScript tableId={tableId} query={query} />\n        </div>\n\n        {/* Preview Return Value */}\n        <div className=\"border-t pt-4\">\n          <h3 className=\"mb-4 text-sm font-medium\">{t('developer:previewReturnValue')}</h3>\n          <PreviewTable query={query} />\n        </div>\n\n        {/* Open in new tab link */}\n        <div className=\"flex justify-end border-t pt-4\">\n          <Button variant=\"ghost\" size=\"sm\" asChild className=\"gap-1 text-muted-foreground\">\n            <Link\n              href={`/developer/tool/query-builder?baseId=${baseId}&tableId=${tableId}`}\n              target=\"_blank\"\n            >\n              {t('table:toolbar.others.api.openInNewTab')}\n              <ArrowUpRight className=\"size-3\" />\n            </Link>\n          </Button>\n        </div>\n      </div>\n    </StandaloneViewProvider>\n  );\n};\n\nexport interface APIDialogContentProps {\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const APIDialogContent = ({ onOpenChange }: APIDialogContentProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { baseId, tableId } = useBaseResource() as IBaseResourceTable;\n  const [copied, setCopied] = useState(false);\n  const [currentUrl, setCurrentUrl] = useState('');\n  const [generatedToken, setGeneratedToken] = useState<CreateAccessTokenVo | null>(null);\n  const [showTokenConfirm, setShowTokenConfirm] = useState(false);\n\n  useEffect(() => {\n    setCurrentUrl(window.location.origin);\n  }, []);\n\n  // Fetch table info\n  const { data: tableInfo } = useQuery({\n    queryKey: ['table-info-api-dialog', baseId, tableId],\n    queryFn: () => getTableById(baseId, tableId).then((res) => res.data),\n    enabled: Boolean(tableId) && Boolean(baseId),\n  });\n\n  // Fetch fields\n  const { data: fieldsData } = useQuery({\n    queryKey: ['fields-api-dialog', tableId],\n    queryFn: () => getFields(tableId).then((res) => res.data),\n    enabled: Boolean(tableId),\n  });\n\n  // Create token mutation\n  const createTokenMutation = useMutation({\n    mutationFn: async () => {\n      const expiredTime = new Date();\n      expiredTime.setFullYear(expiredTime.getFullYear() + 1);\n\n      return createAccessToken({\n        name: `API Token for ${tableInfo?.name || 'Table'} (Auto-generated)`,\n        description: `Auto-generated token for AI integration. Base: ${baseId}, Table: ${tableId}`,\n        scopes: [\n          'table|read',\n          'field|read',\n          'record|read',\n          'record|create',\n          'record|update',\n          'record|delete',\n        ],\n        baseIds: [baseId],\n        expiredTime: expiredTime.toISOString(),\n      });\n    },\n    onSuccess: (res) => {\n      setGeneratedToken(res.data);\n    },\n  });\n\n  const handleConfirmCreateToken = useCallback(() => {\n    setShowTokenConfirm(false);\n    createTokenMutation.mutate();\n  }, [createTokenMutation]);\n\n  const fields: IFieldInfo[] = useMemo(() => {\n    if (!fieldsData) return [];\n    return fieldsData.map((field) => ({\n      id: field.id,\n      name: field.name,\n      type: field.type,\n      description: field.description,\n      options: field.options,\n      isPrimary: field.isPrimary,\n      isComputed: field.isComputed,\n    }));\n  }, [fieldsData]);\n\n  const aiContext = useMemo(() => {\n    if (!tableInfo) return '';\n    return generateAIContext(\n      tableInfo.name,\n      tableInfo.description,\n      fields,\n      currentUrl,\n      tableId,\n      generatedToken?.token\n    );\n  }, [tableInfo, fields, currentUrl, tableId, generatedToken]);\n\n  const handleCopy = useCallback(async () => {\n    await navigator.clipboard.writeText(aiContext);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  }, [aiContext]);\n\n  const isLoading = createTokenMutation.isPending;\n  const isDataLoading = !tableInfo || !fieldsData;\n\n  return (\n    <Tabs defaultValue=\"ai-context\" className=\"flex min-h-0 flex-1 flex-col\">\n      <TabsList className=\"mb-4 w-fit\">\n        <TabsTrigger value=\"ai-context\" className=\"gap-2\">\n          <MagicAi className=\"size-4\" />\n          {t('table:toolbar.others.api.aiContext')}\n        </TabsTrigger>\n        <TabsTrigger value=\"advanced\" className=\"gap-2\">\n          <Code2 className=\"size-4\" />\n          {t('table:toolbar.others.api.advanced')}\n        </TabsTrigger>\n      </TabsList>\n\n      <TabsContent value=\"ai-context\" className=\"mt-0 flex min-h-0 flex-1 flex-col\">\n        {isDataLoading ? (\n          <div className=\"flex h-64 items-center justify-center\">\n            <Loader2 className=\"size-8 animate-spin text-muted-foreground\" />\n            <span className=\"ml-2 text-muted-foreground\">{t('common:actions.loading')}</span>\n          </div>\n        ) : (\n          <div className=\"flex min-h-0 flex-1 flex-col gap-4\">\n            {/* Token Section */}\n            <TokenSection\n              generatedToken={generatedToken}\n              isLoading={isLoading}\n              onGenerateToken={() => setShowTokenConfirm(true)}\n            />\n\n            {/* AI Document Preview */}\n            <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n              <div className=\"mb-2 flex shrink-0 items-center justify-between\">\n                <span className=\"text-sm font-medium\">\n                  {t('table:toolbar.others.api.aiDocPreview')}\n                </span>\n                <Button onClick={handleCopy} size=\"sm\" className=\"gap-2\">\n                  {copied ? (\n                    <>\n                      <Check className=\"size-4\" />\n                      {t('table:toolbar.others.api.copied')}\n                    </>\n                  ) : (\n                    <>\n                      <Copy className=\"size-4\" />\n                      {t('table:toolbar.others.api.copyAIDoc')}\n                    </>\n                  )}\n                </Button>\n              </div>\n              <ScrollArea className=\"h-[400px] rounded-lg border bg-muted/20 p-4\">\n                <MarkdownPreview>{aiContext}</MarkdownPreview>\n              </ScrollArea>\n            </div>\n          </div>\n        )}\n\n        {/* Token Creation Confirmation Dialog */}\n        <AlertDialog open={showTokenConfirm} onOpenChange={setShowTokenConfirm}>\n          <AlertDialogContent>\n            <AlertDialogHeader>\n              <AlertDialogTitle>{t('table:toolbar.others.api.confirmTitle')}</AlertDialogTitle>\n              <AlertDialogDescription className=\"space-y-3\">\n                <p>{t('table:toolbar.others.api.confirmDescription')}</p>\n                <ul className=\"list-inside list-disc space-y-1\">\n                  <li>{t('table:toolbar.others.api.scopeTableRead')}</li>\n                  <li>{t('table:toolbar.others.api.scopeFieldRead')}</li>\n                  <li>{t('table:toolbar.others.api.scopeRead')}</li>\n                  <li>{t('table:toolbar.others.api.scopeCreate')}</li>\n                  <li>{t('table:toolbar.others.api.scopeUpdate')}</li>\n                  <li>{t('table:toolbar.others.api.scopeDelete')}</li>\n                </ul>\n                <p>{t('table:toolbar.others.api.confirmExpiry')}</p>\n              </AlertDialogDescription>\n            </AlertDialogHeader>\n            <AlertDialogFooter>\n              <AlertDialogCancel>{t('common:actions.cancel')}</AlertDialogCancel>\n              <AlertDialogAction onClick={handleConfirmCreateToken}>\n                {t('table:toolbar.others.api.confirmButton')}\n              </AlertDialogAction>\n            </AlertDialogFooter>\n          </AlertDialogContent>\n        </AlertDialog>\n      </TabsContent>\n\n      <TabsContent value=\"advanced\" className=\"mt-0 min-h-0 flex-1 overflow-auto\">\n        <ScrollArea className=\"h-full\">\n          <AdvancedQueryPanel tableId={tableId} baseId={baseId} />\n        </ScrollArea>\n      </TabsContent>\n    </Tabs>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/CalendarToolBar.tsx",
    "content": "import { CalendarViewOperators } from './components';\nimport { useViewConfigurable } from './hook';\nimport { Others } from './Others';\n\nexport const CalendarToolBar: React.FC = () => {\n  const { isViewConfigurable } = useViewConfigurable();\n\n  return (\n    <div className=\"flex h-12 items-center gap-2 border-y px-4 py-2 @container/toolbar\">\n      <div className=\"flex flex-1 justify-between\">\n        <CalendarViewOperators disabled={!isViewConfigurable} />\n        <Others />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/FormToolBar.tsx",
    "content": "import { ArrowUpRight, Settings as Edit, Edit as Fill } from '@teable/icons';\nimport { useTableId, useTablePermission, useViewId } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { generateUniqLocalKey } from '../form/util';\nimport { SharePopover } from './SharePopover';\nimport { FormMode, useFormModeStore } from './store';\nimport { ToolBarButton } from './ToolBarButton';\n\nexport const FormToolBar: React.FC = () => {\n  const tableId = useTableId();\n  const currentViewId = useViewId();\n  const { modeMap, setModeMap } = useFormModeStore();\n  const modeKey = generateUniqLocalKey(tableId, currentViewId);\n  const currentMode = modeMap[modeKey] ?? FormMode.Edit;\n  const permission = useTablePermission();\n  const isEditable = permission['view|update'];\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const setFormMode = (mode: FormMode) => {\n    if (!tableId || !currentViewId) return;\n\n    setModeMap(modeKey, mode);\n  };\n\n  const FORM_MODE_BUTTON_LIST = useMemo(\n    () => [\n      {\n        text: t('actions.edit'),\n        Icon: Edit,\n        mode: FormMode.Edit,\n      },\n      {\n        text: t('actions.fill'),\n        Icon: Fill,\n        mode: FormMode.Fill,\n      },\n    ],\n    [t]\n  );\n\n  return (\n    <div className=\"flex flex-wrap items-center justify-end border-y py-2 pl-8 pr-4 @container/toolbar sm:justify-between\">\n      <div className=\"hidden flex-1 sm:flex\">\n        {isEditable &&\n          FORM_MODE_BUTTON_LIST.map((item) => {\n            const { text, Icon, mode } = item;\n            return (\n              <Button\n                key={mode}\n                variant={currentMode === mode ? 'default' : 'outline'}\n                size={'xs'}\n                className=\"mr-4 px-8 font-normal\"\n                onClick={() => setFormMode(mode)}\n              >\n                <Icon />\n                {text}\n              </Button>\n            );\n          })}\n      </div>\n\n      <SharePopover>\n        {(text, isActive) => (\n          <ToolBarButton\n            isActive={isActive}\n            text={text}\n            textClassName=\"inline\"\n            className=\"justify-start rounded-none\"\n            disabled={!permission['view|update']}\n          >\n            <ArrowUpRight className=\"size-4\" />\n          </ToolBarButton>\n        )}\n      </SharePopover>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/GalleryToolBar.tsx",
    "content": "import { GalleryViewOperators } from './components';\nimport { useViewConfigurable } from './hook';\nimport { Others } from './Others';\n\nexport const GalleryToolBar: React.FC = () => {\n  const { isViewConfigurable } = useViewConfigurable();\n\n  return (\n    <div className=\"flex h-12 items-center gap-2 border-y px-4 py-2 @container/toolbar\">\n      <div className=\"flex flex-1 justify-between\">\n        <GalleryViewOperators disabled={!isViewConfigurable} />\n        <Others />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/GridToolBar.tsx",
    "content": "import { Plus } from '@teable/icons';\nimport { CreateRecordModal } from '@teable/sdk/components';\nimport { useIsReadOnlyPreview, useTablePermission } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { GridViewOperators } from './components';\nimport { useViewConfigurable } from './hook';\nimport { Others } from './Others';\n\nexport const GridToolBar: React.FC = () => {\n  const permission = useTablePermission();\n  const { isViewConfigurable } = useViewConfigurable();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  return (\n    <div className=\"flex h-[48px] items-center border-t px-1 py-2 sm:gap-1 sm:px-2 md:gap-2 md:px-4\">\n      {!isReadOnlyPreview && (\n        <CreateRecordModal>\n          <Button size={'xs'} variant={'outline'} disabled={!permission['record|create']}>\n            <Plus className=\"size-4\" />\n            {t('table:view.addRecord')}\n          </Button>\n        </CreateRecordModal>\n      )}\n      <div className=\"flex flex-1 justify-between @container/toolbar\">\n        <GridViewOperators disabled={!isViewConfigurable} />\n        <Others />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/KanbanToolBar.tsx",
    "content": "import { KanbanViewOperators } from './components';\nimport { useViewConfigurable } from './hook';\nimport { Others } from './Others';\n\nexport const KanbanToolBar: React.FC = () => {\n  const { isViewConfigurable } = useViewConfigurable();\n\n  return (\n    <div className=\"flex h-12 items-center gap-2 border-y px-4 py-2 @container/toolbar\">\n      <div className=\"flex flex-1 justify-between\">\n        <KanbanViewOperators disabled={!isViewConfigurable} />\n        <Others />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/Others.tsx",
    "content": "import { ArrowUpRight, MoreHorizontal } from '@teable/icons';\nimport { useIsReadOnlyPreview, useTableId, useTablePermission, useView } from '@teable/sdk/hooks';\nimport { Button, cn, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { useBaseNodeContext } from '@/features/app/blocks/base/base-node/hooks/useBaseNodeContext';\nimport { useSharedNodeIds } from '@/features/app/blocks/base/base-side-bar/BaseNodeShareIndicator';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { SearchButton } from '../search/SearchButton';\nimport { PersonalViewSwitch } from './components';\nimport { UndoRedoButtons } from './components/UndoRedoButtons';\nimport { ToolBarButton } from './ToolBarButton';\nimport { UnifiedShareDialog } from './UnifiedShareDialog';\n\nconst ShareButton = ({\n  textClassName,\n  buttonClassName,\n  foldButton,\n}: {\n  textClassName?: string;\n  buttonClassName?: string;\n  foldButton?: boolean;\n}) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useTablePermission();\n  const view = useView();\n  const tableId = useTableId();\n  const { treeItems } = useBaseNodeContext();\n  const sharedNodeIds = useSharedNodeIds();\n  const [open, setOpen] = useState(false);\n\n  const isNodeShared = useMemo(() => {\n    if (!tableId) return false;\n    const entry = Object.entries(treeItems).find(([, item]) => item.resourceId === tableId);\n    return entry ? sharedNodeIds.has(entry[0]) : false;\n  }, [tableId, treeItems, sharedNodeIds]);\n\n  const isActive = !!view?.enableShare || isNodeShared;\n  const text = t('table:toolbar.others.share.label');\n\n  return (\n    <>\n      <ToolBarButton\n        isActive={isActive}\n        text={text}\n        textClassName={textClassName}\n        className={cn(buttonClassName, { 'w-full justify-start rounded-sm': foldButton })}\n        disabled={!permission['view|update']}\n        onClick={() => setOpen(true)}\n      >\n        <ArrowUpRight className=\"size-4 shrink-0\" />\n      </ToolBarButton>\n      <UnifiedShareDialog open={open} onOpenChange={setOpen} />\n    </>\n  );\n};\n\nconst OthersList = ({\n  classNames,\n  className,\n  foldButton,\n}: {\n  classNames?: { textClassName?: string; buttonClassName?: string };\n  className?: string;\n  foldButton?: boolean;\n}) => {\n  const { textClassName, buttonClassName } = classNames ?? {};\n\n  return (\n    <div className={cn('gap-1 flex items-center', className)}>\n      <ShareButton\n        textClassName={textClassName}\n        buttonClassName={buttonClassName}\n        foldButton={foldButton}\n      />\n      {!foldButton && <div className=\"mx-1 h-4 w-px shrink-0 bg-border\" />}\n      <PersonalViewSwitch\n        textClassName={textClassName}\n        buttonClassName={cn(buttonClassName, { 'w-full justify-start pl-2': foldButton })}\n      />\n    </div>\n  );\n};\n\nconst OthersMenu = ({ className }: { className?: string }) => {\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          variant={'ghost'}\n          size={'icon-xs'}\n          className={cn('font-normal shrink-0 truncate', className)}\n        >\n          <MoreHorizontal className=\"size-4 shrink-0\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"start\" className=\"w-40 p-1\">\n        <OthersList\n          className=\"flex w-full flex-col items-start\"\n          classNames={{ textClassName: 'inline', buttonClassName: 'justify-start rounded-none' }}\n          foldButton={true}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport const Others: React.FC = () => {\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  return (\n    <div\n      className={cn(\n        'flex shrink-0 items-center justify-end pl-6 md:gap-0',\n        'bg-[linear-gradient(90deg,rgba(255,255,255,0)_0%,hsl(var(--background))_5%)]',\n        'dark:bg-[linear-gradient(90deg,rgba(0,0,0,0)_0%,hsl(var(--background))_5%)]'\n      )}\n    >\n      <SearchButton className=\"size-7 shrink-0\" />\n      {!isReadOnlyPreview && (\n        <>\n          <div className=\"mx-1 h-4 w-px shrink-0 bg-border\"></div>\n          <UndoRedoButtons />\n          <div className=\"mx-1 h-4 w-px shrink-0 bg-border\"></div>\n          <OthersList\n            className=\"hidden @md/toolbar:flex\"\n            classNames={{ textClassName: '@2xl/toolbar:inline' }}\n          />\n          <OthersMenu className=\"@md/toolbar:hidden\" />\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/SharePopover.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { sharePasswordSchema, type IShareViewMeta, ViewType } from '@teable/core';\nimport { Edit, RefreshCcw, Qrcode } from '@teable/icons';\nimport { useTablePermission, useView } from '@teable/sdk/hooks';\nimport type { View } from '@teable/sdk/model';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  RadioGroup,\n  RadioGroupItem,\n  Separator,\n  Switch,\n  Textarea,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib';\nimport { omit } from 'lodash';\nimport { LucideEye } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { QRCodeSVG } from 'qrcode.react';\nimport { useMemo, useState } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nconst getShareUrl = ({\n  shareId,\n  theme,\n  hideToolBar,\n}: {\n  shareId: string;\n  theme?: string;\n  hideToolBar?: boolean;\n}) => {\n  const origin = typeof window !== 'undefined' ? window.location.origin : 'https://app.teable.ai';\n  const url = new URL(`/share/${shareId}/view`, origin);\n  if (theme && theme !== 'system') {\n    url.searchParams.append('theme', theme);\n  }\n  if (hideToolBar) {\n    url.searchParams.append('hideToolBar', 'true');\n  }\n  return url.toString();\n};\n\nconst embedUrl = (shareUrl: string) => {\n  const url = new URL(shareUrl);\n  url.searchParams.append('embed', 'true');\n  return url.toString();\n};\n\nexport const SharePopover: React.FC<{\n  children: (text: string, isActive?: boolean) => React.ReactNode;\n}> = (props) => {\n  const { children } = props;\n  const view = useView();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useTablePermission();\n\n  const ShareViewText = t('table:toolbar.others.share.label');\n  const [showPasswordDialog, setShowPasswordDialog] = useState<boolean>();\n  const [sharePassword, setSharePassword] = useState<string>('');\n  const [shareTheme, setShareTheme] = useState<string>('system');\n  const [hideToolBar, setHideToolBar] = useState<boolean>();\n  const [embed, setEmbed] = useState<boolean>();\n\n  const { mutate: enableShareFn, isPending: enableShareLoading } = useMutation({\n    mutationFn: async (view: View) => view.apiEnableShare(),\n  });\n\n  const { mutate: disableShareFn, isPending: disableShareLoading } = useMutation({\n    mutationFn: async (view: View) => view.disableShare(),\n  });\n\n  const shareUrl = useMemo(() => {\n    return view?.shareId\n      ? getShareUrl({ shareId: view?.shareId, theme: shareTheme, hideToolBar })\n      : undefined;\n  }, [view?.shareId, shareTheme, hideToolBar]);\n  const embedHtml = shareUrl\n    ? `<iframe src=\"${embedUrl(shareUrl)}\" width=\"100%\" height=\"533\" style=\"border: 0\"></iframe>`\n    : '';\n\n  if (!view) {\n    return children(ShareViewText, false);\n  }\n\n  const { enableShare, shareMeta } = view;\n\n  const setShareMeta = (shareMeta: IShareViewMeta) => {\n    view.setShareMeta({ ...view.shareMeta, ...shareMeta });\n  };\n\n  const setEnableShare = (enableShare: boolean) => {\n    if (!view) {\n      return;\n    }\n    if (enableShare) {\n      return enableShareFn(view);\n    }\n    disableShareFn(view);\n  };\n\n  const confirmSharePassword = async () => {\n    await setShareMeta({ password: sharePassword });\n    setShowPasswordDialog(false);\n    setSharePassword('');\n  };\n\n  const closeSharePasswordDialog = () => {\n    setSharePassword('');\n    setShowPasswordDialog(false);\n  };\n\n  const onPasswordSwitchChange = (check: boolean) => {\n    if (check) {\n      setShowPasswordDialog(true);\n      return;\n    }\n    view.setShareMeta(omit(view.shareMeta, 'password'));\n  };\n\n  const onSubmitRequireLoginChange = (check: boolean) => {\n    if (!shareMeta?.submit) {\n      return;\n    }\n    setShareMeta({ submit: { ...shareMeta?.submit, requireLogin: check } });\n  };\n\n  const needConfigCopy = [ViewType.Grid].includes(view.type);\n  const needConfigIncludeHiddenField = [ViewType.Grid].includes(view.type);\n  const needEmbedHiddenToolbar = ![ViewType.Form].includes(view.type);\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>{children(ShareViewText, enableShare)}</PopoverTrigger>\n      <PopoverContent className=\"w-96 space-y-4 p-4\">\n        <div className=\"flex items-center justify-between\">\n          <Label htmlFor=\"share-switch\">{t('table:toolbar.others.share.statusLabel')}</Label>\n          <Switch\n            className=\"ml-auto\"\n            id=\"share-switch\"\n            checked={enableShare}\n            disabled={enableShareLoading || disableShareLoading || !permission['view|share']}\n            onCheckedChange={setEnableShare}\n          />\n        </div>\n        <Separator />\n        {enableShare ? (\n          <>\n            <div className=\"flex items-center gap-1\">\n              <Input className=\"grow\" size=\"sm\" id=\"share-link\" value={shareUrl} readOnly />\n\n              <Popover>\n                <PopoverTrigger asChild>\n                  <Button size=\"xs\" variant=\"outline\">\n                    <Qrcode />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"size-48 bg-white p-2\">\n                  {shareUrl && <QRCodeSVG value={shareUrl} className=\"size-full\" />}\n                </PopoverContent>\n              </Popover>\n              <CopyButton text={shareUrl as string} size=\"xs\" variant=\"outline\" />\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <Button\n                      size={'icon-xs'}\n                      variant={'outline'}\n                      onClick={() => view.setRefreshLink()}\n                    >\n                      <RefreshCcw className=\"size-4 shrink-0\" />\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p>{t('table:toolbar.others.share.genLink')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            </div>\n            <Separator />\n            <div className=\"space-y-4\">\n              {needConfigCopy && (\n                <div className=\"flex items-center gap-2\">\n                  <Switch\n                    id=\"share-allowCopy\"\n                    checked={shareMeta?.allowCopy}\n                    onCheckedChange={(checked) => setShareMeta({ allowCopy: checked })}\n                  />\n                  <Label className=\"text-xs\" htmlFor=\"share-allowCopy\">\n                    {t('table:toolbar.others.share.allowCopy')}\n                  </Label>\n                </div>\n              )}\n              {needConfigIncludeHiddenField && (\n                <div className=\"flex items-center gap-2\">\n                  <Switch\n                    id=\"share-includeHiddenField\"\n                    checked={shareMeta?.includeHiddenField}\n                    onCheckedChange={(checked) => setShareMeta({ includeHiddenField: checked })}\n                  />\n                  <Label className=\"text-xs\" htmlFor=\"share-includeHiddenField\">\n                    {t('table:toolbar.others.share.showAllFields')}\n                  </Label>\n                </div>\n              )}\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"share-password\"\n                  checked={Boolean(shareMeta?.password)}\n                  onCheckedChange={onPasswordSwitchChange}\n                />\n                <Label className=\"text-xs\" htmlFor=\"share-password\">\n                  {t('table:toolbar.others.share.restrict')}\n                </Label>\n                {Boolean(shareMeta?.password) && (\n                  <Button\n                    className=\"h-5 py-0 hover:text-muted-foreground\"\n                    variant={'link'}\n                    size={'xs'}\n                    onClick={() => setShowPasswordDialog(true)}\n                  >\n                    <Edit className=\"size-4 shrink-0\" />\n                  </Button>\n                )}\n              </div>\n              {shareMeta?.submit && (\n                <div className=\"flex items-center gap-2\">\n                  <Switch\n                    id=\"share-required-login\"\n                    checked={Boolean(shareMeta?.submit?.requireLogin)}\n                    onCheckedChange={onSubmitRequireLoginChange}\n                  />\n                  <Label className=\"text-xs\" htmlFor=\"share-required-login\">\n                    {t('table:toolbar.others.share.requireLogin')}\n                  </Label>\n                </div>\n              )}\n            </div>\n            <hr />\n            <div>\n              <p className=\"text-sm\">{t('table:toolbar.others.share.URLSetting')}</p>\n              <p className=\"text-xs text-primary/60\">\n                {t('table:toolbar.others.share.URLSettingDescription')}\n              </p>\n            </div>\n            {needEmbedHiddenToolbar && (\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"share-hideToolBar\"\n                  checked={hideToolBar}\n                  onCheckedChange={(checked) => setHideToolBar(checked)}\n                />\n                <Label className=\"text-xs\" htmlFor=\"share-hideToolBar\">\n                  {t('table:toolbar.others.share.hideToolbar')}\n                </Label>\n              </div>\n            )}\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                id=\"share-embed\"\n                checked={embed}\n                onCheckedChange={(checked) => setEmbed(checked)}\n              />\n              <Label className=\"text-xs\" htmlFor=\"share-embed\">\n                {t('table:toolbar.others.share.embed')}\n              </Label>\n              {embed && shareUrl && (\n                <>\n                  <Dialog>\n                    <DialogTrigger asChild>\n                      <Button size=\"xs\" variant=\"outline\">\n                        <LucideEye className=\"size-3\" />\n                      </Button>\n                    </DialogTrigger>\n                    <DialogContent className=\"sm:max-w-[425px] md:max-w-[600px] lg:max-w-[800px]\">\n                      <DialogHeader>\n                        <DialogTitle>{t('table:toolbar.others.share.embedPreview')}</DialogTitle>\n                      </DialogHeader>\n                      <div className=\"h-[500px]\">\n                        <iframe\n                          src={embedUrl(shareUrl)}\n                          title=\"embed view\"\n                          width=\"100%\"\n                          height=\"100%\"\n                          style={{ border: 0 }}\n                        />\n                      </div>\n                      <DialogFooter>\n                        <DialogClose asChild>\n                          <Button size={'sm'} variant={'ghost'}>\n                            {t('common:actions.close')}\n                          </Button>\n                        </DialogClose>\n                      </DialogFooter>\n                    </DialogContent>\n                  </Dialog>\n                  <CopyButton text={embedHtml as string} size=\"xs\" variant=\"outline\" />\n                </>\n              )}\n            </div>\n            {embed && <Textarea className=\"h-20 font-mono text-xs\" value={embedHtml} readOnly />}\n            <div className=\"flex gap-4\">\n              <Label className=\"text-xs\" htmlFor=\"share-password\">\n                {t('common:settings.setting.theme')}\n              </Label>\n              <RadioGroup\n                className=\"flex gap-2\"\n                defaultValue={shareTheme}\n                onValueChange={(e) => setShareTheme(e)}\n              >\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"system\" id=\"r1\" />\n                  <Label className=\"text-xs font-normal\" htmlFor=\"r1\">\n                    {t('common:settings.setting.system')}\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"light\" id=\"r2\" />\n                  <Label className=\"text-xs font-normal\" htmlFor=\"r2\">\n                    {t('common:settings.setting.light')}\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"dark\" id=\"r3\" />\n                  <Label className=\"text-xs font-normal\" htmlFor=\"r3\">\n                    {t('common:settings.setting.dark')}\n                  </Label>\n                </div>\n              </RadioGroup>\n            </div>\n          </>\n        ) : (\n          <div className=\"text-center text-sm text-muted-foreground\">\n            {!enableShare && permission['view|share']\n              ? t('table:toolbar.others.share.tips')\n              : t('table:toolbar.others.share.noPermission')}\n          </div>\n        )}\n        <Dialog\n          open={showPasswordDialog}\n          onOpenChange={(open) => !open && closeSharePasswordDialog()}\n        >\n          <DialogTrigger asChild></DialogTrigger>\n          <DialogContent className=\"sm:max-w-[425px]\">\n            <DialogHeader>\n              <DialogTitle>{t('table:toolbar.others.share.passwordTitle')}</DialogTitle>\n              <DialogDescription>{t('table:toolbar.others.share.passwordTips')}</DialogDescription>\n            </DialogHeader>\n            <Input\n              type=\"password\"\n              value={sharePassword}\n              onChange={(e) => setSharePassword(e.target.value)}\n            />\n            <DialogFooter>\n              <Button size={'sm'} variant={'ghost'} onClick={() => closeSharePasswordDialog()}>\n                {t('table:toolbar.others.share.cancel')}\n              </Button>\n              <Button\n                size={'sm'}\n                onClick={confirmSharePassword}\n                disabled={!sharePasswordSchema.safeParse(sharePassword).success}\n              >\n                {t('table:toolbar.others.share.save')}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/ShareViewContent.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { sharePasswordSchema, type IShareViewMeta, ViewType } from '@teable/core';\nimport { Copy, Edit, RefreshCcw, Qrcode } from '@teable/icons';\nimport { useTablePermission, useView } from '@teable/sdk/hooks';\nimport type { View } from '@teable/sdk/model';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  Input,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  RadioGroup,\n  RadioGroupItem,\n  Separator,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib';\nimport { omit } from 'lodash';\nimport { ChevronRight, Eye } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { QRCodeSVG } from 'qrcode.react';\nimport { useMemo, useState } from 'react';\nimport { CopyButton } from '@/features/app/components/CopyButton';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nconst getShareUrl = ({\n  shareId,\n  theme,\n  hideToolBar,\n}: {\n  shareId: string;\n  theme?: string;\n  hideToolBar?: boolean;\n}) => {\n  const origin = typeof window !== 'undefined' ? window.location.origin : 'https://app.teable.ai';\n  const url = new URL(`/share/${shareId}/view`, origin);\n  if (theme && theme !== 'system') {\n    url.searchParams.append('theme', theme);\n  }\n  if (hideToolBar) {\n    url.searchParams.append('hideToolBar', 'true');\n  }\n  return url.toString();\n};\n\nconst getEmbedUrl = (shareUrl: string) => {\n  const url = new URL(shareUrl);\n  url.searchParams.append('embed', 'true');\n  return url.toString();\n};\n\nconst getEmbedHtml = (shareUrl: string) => {\n  const embedUrl = getEmbedUrl(shareUrl);\n  return `<iframe src=\"${embedUrl}\" width=\"100%\" height=\"533\" style=\"border: 0\"></iframe>`;\n};\n\nconst EmbedConfigPopover = ({\n  shareUrl,\n  hideToolBar,\n  setHideToolBar,\n  shareTheme,\n  setShareTheme,\n  needEmbedHiddenToolbar,\n}: {\n  shareUrl: string;\n  hideToolBar?: boolean;\n  setHideToolBar: (v: boolean) => void;\n  shareTheme: string;\n  setShareTheme: (v: string) => void;\n  needEmbedHiddenToolbar: boolean;\n}) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [previewOpen, setPreviewOpen] = useState(false);\n\n  const embedHtml = getEmbedHtml(shareUrl);\n\n  const handleCopyCode = () => {\n    navigator.clipboard.writeText(embedHtml);\n  };\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button variant=\"ghost\" className=\"flex w-full items-center justify-between px-0 py-1\">\n          <Label className=\"cursor-pointer text-sm font-normal\">\n            {t('table:baseShare.embedConfig')}\n          </Label>\n          <ChevronRight className=\"size-4 text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"right\" align=\"start\" className=\"w-80\">\n        <div className=\"mb-3 rounded-md bg-muted p-3\">\n          <code className=\"break-all text-xs\">{embedHtml}</code>\n        </div>\n\n        <div className=\"flex gap-2\">\n          <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"flex-1\"\n              onClick={() => setPreviewOpen(true)}\n            >\n              <Eye className=\"mr-1 size-4\" />\n              {t('table:toolbar.others.share.embedPreview')}\n            </Button>\n            <DialogContent className=\"sm:max-w-[425px] md:max-w-[600px] lg:max-w-[800px]\">\n              <DialogHeader>\n                <DialogTitle>{t('table:toolbar.others.share.embedPreview')}</DialogTitle>\n              </DialogHeader>\n              <div className=\"h-[500px]\">\n                <iframe\n                  src={getEmbedUrl(shareUrl)}\n                  title=\"embed view\"\n                  width=\"100%\"\n                  height=\"100%\"\n                  style={{ border: 0 }}\n                />\n              </div>\n            </DialogContent>\n          </Dialog>\n          <Button variant=\"outline\" size=\"sm\" className=\"flex-1\" onClick={handleCopyCode}>\n            <Copy className=\"mr-1 size-4\" />\n            {t('table:toolbar.others.share.copyCode')}\n          </Button>\n        </div>\n\n        <Separator className=\"my-3\" />\n\n        <div className=\"space-y-3\">\n          <p className=\"text-xs text-muted-foreground\">\n            {t('table:toolbar.others.share.URLSettingDescription')}\n          </p>\n\n          {needEmbedHiddenToolbar && (\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                id=\"embed-hideToolBar\"\n                checked={hideToolBar}\n                onCheckedChange={(checked) => setHideToolBar(checked)}\n              />\n              <Label className=\"text-xs\" htmlFor=\"embed-hideToolBar\">\n                {t('table:toolbar.others.share.hideToolbar')}\n              </Label>\n            </div>\n          )}\n\n          <div className=\"flex gap-4\">\n            <Label className=\"text-xs\">{t('common:settings.setting.theme')}</Label>\n            <RadioGroup\n              className=\"flex gap-2\"\n              defaultValue={shareTheme}\n              onValueChange={(e) => setShareTheme(e)}\n            >\n              <div className=\"flex items-center space-x-2\">\n                <RadioGroupItem value=\"system\" id=\"embed-r1\" />\n                <Label className=\"text-xs font-normal\" htmlFor=\"embed-r1\">\n                  {t('common:settings.setting.system')}\n                </Label>\n              </div>\n              <div className=\"flex items-center space-x-2\">\n                <RadioGroupItem value=\"light\" id=\"embed-r2\" />\n                <Label className=\"text-xs font-normal\" htmlFor=\"embed-r2\">\n                  {t('common:settings.setting.light')}\n                </Label>\n              </div>\n              <div className=\"flex items-center space-x-2\">\n                <RadioGroupItem value=\"dark\" id=\"embed-r3\" />\n                <Label className=\"text-xs font-normal\" htmlFor=\"embed-r3\">\n                  {t('common:settings.setting.dark')}\n                </Label>\n              </div>\n            </RadioGroup>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport const ShareViewContent: React.FC = () => {\n  const view = useView();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const permission = useTablePermission();\n\n  const [showPasswordDialog, setShowPasswordDialog] = useState<boolean>();\n  const [sharePassword, setSharePassword] = useState<string>('');\n  const [shareTheme, setShareTheme] = useState<string>('system');\n  const [hideToolBar, setHideToolBar] = useState<boolean>();\n\n  const { mutate: enableShareFn, isPending: enableShareLoading } = useMutation({\n    mutationFn: async (view: View) => view.apiEnableShare(),\n  });\n\n  const { mutate: disableShareFn, isPending: disableShareLoading } = useMutation({\n    mutationFn: async (view: View) => view.disableShare(),\n  });\n\n  const shareUrl = useMemo(() => {\n    return view?.shareId\n      ? getShareUrl({ shareId: view?.shareId, theme: shareTheme, hideToolBar })\n      : undefined;\n  }, [view?.shareId, shareTheme, hideToolBar]);\n\n  if (!view) {\n    return null;\n  }\n\n  const { enableShare, shareMeta } = view;\n\n  const setShareMeta = (shareMeta: IShareViewMeta) => {\n    view.setShareMeta({ ...view.shareMeta, ...shareMeta });\n  };\n\n  const setEnableShare = (enableShare: boolean) => {\n    if (!view) {\n      return;\n    }\n    if (enableShare) {\n      return enableShareFn(view);\n    }\n    disableShareFn(view);\n  };\n\n  const confirmSharePassword = async () => {\n    await setShareMeta({ password: sharePassword });\n    setShowPasswordDialog(false);\n    setSharePassword('');\n  };\n\n  const closeSharePasswordDialog = () => {\n    setSharePassword('');\n    setShowPasswordDialog(false);\n  };\n\n  const onPasswordSwitchChange = (check: boolean) => {\n    if (check) {\n      setShowPasswordDialog(true);\n      return;\n    }\n    view.setShareMeta(omit(view.shareMeta, 'password'));\n  };\n\n  const onSubmitRequireLoginChange = (check: boolean) => {\n    if (!shareMeta?.submit) {\n      return;\n    }\n    setShareMeta({ submit: { ...shareMeta?.submit, requireLogin: check } });\n  };\n\n  const needConfigCopy = [ViewType.Grid].includes(view.type);\n  const needConfigIncludeHiddenField = [ViewType.Grid].includes(view.type);\n  const needEmbedHiddenToolbar = ![ViewType.Form].includes(view.type);\n\n  return (\n    <div className=\"flex w-full flex-col gap-4 py-4\">\n      <div className=\"flex items-center gap-2\">\n        <Switch\n          id=\"share-view-switch\"\n          checked={enableShare}\n          disabled={enableShareLoading || disableShareLoading || !permission['view|share']}\n          onCheckedChange={setEnableShare}\n        />\n        <Label htmlFor=\"share-view-switch\" className=\"text-sm\">\n          {t('table:toolbar.others.share.statusLabel')}\n        </Label>\n      </div>\n      {enableShare ? (\n        <>\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"flex items-center gap-1.5 text-sm\">\n              <span className=\"text-muted-foreground\">{t('table:baseShare.linkHolderLabel')}</span>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Input\n                className=\"min-w-0 flex-1\"\n                size=\"lg\"\n                id=\"share-link\"\n                value={shareUrl}\n                readOnly\n              />\n              <CopyButton\n                text={shareUrl as string}\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"shrink-0\"\n              />\n              <Popover>\n                <PopoverTrigger asChild>\n                  <Button variant=\"outline\" size=\"icon\" className=\"shrink-0\">\n                    <Qrcode className=\"size-4 shrink-0\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"size-48 bg-white p-2\">\n                  {shareUrl && <QRCodeSVG value={shareUrl} className=\"size-full\" />}\n                </PopoverContent>\n              </Popover>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <Button\n                      variant=\"outline\"\n                      size=\"icon\"\n                      className=\"shrink-0\"\n                      onClick={() => view.setRefreshLink()}\n                    >\n                      <RefreshCcw className=\"size-4 shrink-0\" />\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"bottom\">\n                    <p>{t('table:toolbar.others.share.genLink')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            </div>\n          </div>\n          <Separator />\n\n          <div className=\"flex flex-col gap-3\">\n            <Label className=\"text-sm font-medium\">{t('table:baseShare.advanced')}</Label>\n\n            {needConfigCopy && (\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"share-view-allowCopy\"\n                  checked={shareMeta?.allowCopy}\n                  onCheckedChange={(checked) => setShareMeta({ allowCopy: checked })}\n                />\n                <Label className=\"text-sm font-normal\" htmlFor=\"share-view-allowCopy\">\n                  {t('table:toolbar.others.share.allowCopy')}\n                </Label>\n              </div>\n            )}\n            {needConfigIncludeHiddenField && (\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"share-view-includeHiddenField\"\n                  checked={shareMeta?.includeHiddenField}\n                  onCheckedChange={(checked) => setShareMeta({ includeHiddenField: checked })}\n                />\n                <Label className=\"text-sm font-normal\" htmlFor=\"share-view-includeHiddenField\">\n                  {t('table:toolbar.others.share.showAllFields')}\n                </Label>\n              </div>\n            )}\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                id=\"share-view-password\"\n                checked={Boolean(shareMeta?.password)}\n                onCheckedChange={onPasswordSwitchChange}\n              />\n              <Label className=\"text-sm font-normal\" htmlFor=\"share-view-password\">\n                {t('table:toolbar.others.share.restrict')}\n              </Label>\n              {Boolean(shareMeta?.password) && (\n                <Button\n                  className=\"h-5 px-1 hover:text-muted-foreground\"\n                  variant=\"link\"\n                  size=\"xs\"\n                  onClick={() => setShowPasswordDialog(true)}\n                >\n                  <Edit className=\"size-3\" />\n                </Button>\n              )}\n            </div>\n            {shareMeta?.submit && (\n              <div className=\"flex items-center gap-2\">\n                <Switch\n                  id=\"share-view-required-login\"\n                  checked={Boolean(shareMeta?.submit?.requireLogin)}\n                  onCheckedChange={onSubmitRequireLoginChange}\n                />\n                <Label className=\"text-sm font-normal\" htmlFor=\"share-view-required-login\">\n                  {t('table:toolbar.others.share.requireLogin')}\n                </Label>\n              </div>\n            )}\n\n            {shareUrl && (\n              <EmbedConfigPopover\n                shareUrl={shareUrl}\n                hideToolBar={hideToolBar}\n                setHideToolBar={setHideToolBar}\n                shareTheme={shareTheme}\n                setShareTheme={setShareTheme}\n                needEmbedHiddenToolbar={needEmbedHiddenToolbar}\n              />\n            )}\n          </div>\n        </>\n      ) : null}\n      <Dialog\n        open={showPasswordDialog}\n        onOpenChange={(open) => !open && closeSharePasswordDialog()}\n      >\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>{t('table:toolbar.others.share.passwordTitle')}</DialogTitle>\n          </DialogHeader>\n          <Input\n            type=\"password\"\n            value={sharePassword}\n            onChange={(e) => setSharePassword(e.target.value)}\n          />\n          <DialogFooter>\n            <Button size=\"sm\" variant=\"ghost\" onClick={closeSharePasswordDialog}>\n              {t('common:actions.cancel')}\n            </Button>\n            <Button\n              size=\"sm\"\n              onClick={confirmSharePassword}\n              disabled={!sharePasswordSchema.safeParse(sharePassword).success}\n            >\n              {t('common:actions.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/ToolBarButton.tsx",
    "content": "import { Button, cn } from '@teable/ui-lib';\nimport React, { forwardRef } from 'react';\n\ninterface IToolBarButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  text?: string | React.ReactNode;\n  isActive?: boolean;\n  className?: string;\n  textClassName?: string;\n  children: React.ReactElement | React.ReactElement[];\n  disabled?: boolean;\n}\n\nconst ToolBarButton = forwardRef<HTMLButtonElement, IToolBarButton>(\n  (props: IToolBarButton, ref) => {\n    const { children, text, isActive = false, className, textClassName, ...restProps } = props;\n\n    return (\n      <Button\n        variant={'ghost'}\n        size={'xs'}\n        className={cn(\n          'font-normal shrink-0 truncate px-1.5',\n          { 'bg-secondary': isActive },\n          className\n        )}\n        ref={ref}\n        {...restProps}\n      >\n        {children}\n        {text && (\n          <span suppressHydrationWarning className={cn('hidden truncate', textClassName)}>\n            {text}\n          </span>\n        )}\n      </Button>\n    );\n  }\n);\n\nToolBarButton.displayName = 'ToolBarButton';\n\nexport { ToolBarButton };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/UnifiedShareDialog.tsx",
    "content": "import { BaseNodeResourceType } from '@teable/openapi';\nimport { useBaseId, useView } from '@teable/sdk/hooks';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useBaseNodeContext } from '@/features/app/blocks/base/base-node/hooks/useBaseNodeContext';\nimport {\n  NodeShareContent,\n  NodeShareHeader,\n} from '@/features/app/blocks/base/base-side-bar/NodeShareContent';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { ShareViewContent } from './ShareViewContent';\n\ninterface IUnifiedShareDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  nodeId?: string;\n}\n\nconst useCurrentNodeFromRoute = () => {\n  const baseResource = useBaseResource();\n  const { treeItems } = useBaseNodeContext();\n\n  return useMemo(() => {\n    let resourceId: string | undefined;\n    switch (baseResource.resourceType) {\n      case BaseNodeResourceType.Table:\n        resourceId = baseResource.tableId;\n        break;\n      case BaseNodeResourceType.Dashboard:\n        resourceId = baseResource.dashboardId;\n        break;\n      case BaseNodeResourceType.Workflow:\n        resourceId = baseResource.workflowId;\n        break;\n      case BaseNodeResourceType.App:\n        resourceId = baseResource.appId;\n        break;\n      default:\n        return null;\n    }\n\n    if (!resourceId) return null;\n\n    const entry = Object.entries(treeItems).find(([, item]) => item.resourceId === resourceId);\n    if (!entry) return null;\n\n    return { nodeId: entry[0], node: entry[1], resourceType: entry[1].resourceType };\n  }, [baseResource, treeItems]);\n};\n\nexport const UnifiedShareDialog: React.FC<IUnifiedShareDialogProps> = ({\n  open,\n  onOpenChange,\n  nodeId: nodeIdProp,\n}) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const baseId = useBaseId() as string;\n  const view = useView();\n  const { treeItems } = useBaseNodeContext();\n  const routeNode = useCurrentNodeFromRoute();\n\n  const currentNode = useMemo(() => {\n    if (nodeIdProp) {\n      const node = treeItems[nodeIdProp];\n      if (!node) return null;\n      return { nodeId: nodeIdProp, node, resourceType: node.resourceType };\n    }\n    return routeNode;\n  }, [nodeIdProp, treeItems, routeNode]);\n\n  const isTable = currentNode?.resourceType === BaseNodeResourceType.Table;\n  const isCurrentRouteNode = !nodeIdProp || (routeNode && routeNode.nodeId === nodeIdProp);\n  const showViewTab = isTable && !!view && isCurrentRouteNode;\n\n  if (!currentNode) {\n    return null;\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-md gap-0 p-0\">\n        <DialogHeader className=\"sr-only\">\n          <DialogTitle>{t('table:baseShare.shareTitle')}</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"px-6 pt-6\">\n          <NodeShareHeader node={currentNode.node} />\n        </div>\n\n        {showViewTab ? (\n          <Tabs defaultValue=\"table\" className=\"min-w-0\">\n            <div className=\"px-6 pt-3\">\n              <TabsList className=\"grid w-full grid-cols-2\">\n                <TabsTrigger value=\"table\">{t('table:baseShare.shareTableTab')}</TabsTrigger>\n                <TabsTrigger value=\"view\">{t('table:baseShare.shareViewTab')}</TabsTrigger>\n              </TabsList>\n            </div>\n            <TabsContent value=\"table\" className=\"mt-0 max-h-[60vh] overflow-y-auto px-6 pb-6\">\n              <NodeShareContent\n                baseId={baseId}\n                nodeId={currentNode.nodeId}\n                node={currentNode.node}\n                hideHeader\n              />\n            </TabsContent>\n            <TabsContent value=\"view\" className=\"mt-0 max-h-[60vh] overflow-y-auto px-6 pb-6\">\n              <ShareViewContent />\n            </TabsContent>\n          </Tabs>\n        ) : (\n          <div className=\"max-h-[60vh] overflow-y-auto px-6 pb-6\">\n            <NodeShareContent\n              baseId={baseId}\n              nodeId={currentNode.nodeId}\n              node={currentNode.node}\n              hideHeader\n            />\n          </div>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/CalendarViewOperators.tsx",
    "content": "import { Filter as FilterIcon, Share2, Plus, EyeOff, Settings, AlertTriangle } from '@teable/icons';\nimport type { CalendarView } from '@teable/sdk';\nimport {\n  ViewFilter,\n  VisibleFields,\n  useTablePermission,\n  CreateRecordModal,\n  useIsReadOnlyPreview,\n} from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { CalendarConfig } from '../../calendar/components/CalendarConfig';\nimport { useToolbarChange } from '../../hooks/useToolbarChange';\nimport { ToolBarButton } from '../ToolBarButton';\n\nexport const CalendarViewOperators: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView() as CalendarView | undefined;\n  const permission = useTablePermission();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { onFilterChange } = useToolbarChange();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  if (!view) return null;\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {!isReadOnlyPreview && (\n        <>\n          <CreateRecordModal>\n            <Button size={'xs'} variant={'outline'} disabled={!permission['record|create']}>\n              <Plus className=\"size-4\" />\n              {t('table:view.addRecord')}\n            </Button>\n          </CreateRecordModal>\n          <div className=\"mx-1 h-4 w-px shrink-0 bg-border\" />\n        </>\n      )}\n      <CalendarConfig>\n        <ToolBarButton\n          disabled={disabled}\n          isActive={false}\n          text={t('table:calendar.toolbar.config')}\n          textClassName=\"@2xl/toolbar:inline\"\n        >\n          <Settings className=\"size-4 text-sm\" />\n        </ToolBarButton>\n      </CalendarConfig>\n      <VisibleFields>\n        {(_text, _isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={false}\n            text={t('sdk:hidden.label')}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <EyeOff className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </VisibleFields>\n      <ViewFilter\n        filters={view?.filter || null}\n        onChange={onFilterChange}\n        contentHeader={\n          view.enableShare && (\n            <div className=\"mb-2 flex max-w-full items-center justify-start rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground dark:bg-white/5\">\n              <Share2 className=\"mr-2 size-4 shrink-0\" />\n              <span className=\"text-muted-foreground\">{t('table:toolbar.viewFilterInShare')}</span>\n            </div>\n          )\n        }\n      >\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 shrink-0 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 shrink-0 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ViewFilter>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/CoverFieldSelect.tsx",
    "content": "import { FieldType } from '@teable/core';\nimport { useFields } from '@teable/sdk/hooks';\nimport {\n  Label,\n  Select,\n  SelectItem,\n  SelectContent,\n  SelectTrigger,\n  SelectValue,\n  Switch,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface ICoverFieldSelect {\n  fieldId?: string | null;\n  isCoverFit?: boolean;\n  className?: string;\n  onSelectChange?: (fieldId: string | null) => void;\n  onCheckedChange?: (checked: boolean) => void;\n}\n\nconst COVER_FIELD_EMPTY_ID = 'cover_field_empty_id';\n\nexport const CoverFieldSelect = (props: ICoverFieldSelect) => {\n  const { fieldId, isCoverFit, className, onCheckedChange, onSelectChange } = props;\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const filteredFields = useMemo(\n    () => allFields.filter((f) => f.type === FieldType.Attachment),\n    [allFields]\n  );\n\n  return (\n    <div className={cn('w-full flex flex-col gap-2 px-4 py-3', className)}>\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-sm\">{t('table:kanban.toolbar.imageSetting')}</span>\n        {fieldId && (\n          <div className=\"flex items-center gap-2\">\n            <Label\n              htmlFor=\"attachment-field-select\"\n              className=\"text-xs font-normal text-muted-foreground\"\n            >\n              {t('table:kanban.toolbar.fit')}\n            </Label>\n            <Switch\n              id=\"attachment-field-select\"\n              size={'sm'}\n              checked={isCoverFit}\n              onCheckedChange={(checked) => onCheckedChange?.(checked)}\n            />\n          </div>\n        )}\n      </div>\n      <Select\n        value={fieldId ?? undefined}\n        onValueChange={(value) => onSelectChange?.(value === COVER_FIELD_EMPTY_ID ? null : value)}\n      >\n        <SelectTrigger className=\"bg-background\">\n          <SelectValue placeholder={t('table:kanban.toolbar.chooseAttachmentField')} />\n        </SelectTrigger>\n        <SelectContent className=\" w-72\">\n          {filteredFields.map(({ id, name }) => (\n            <SelectItem key={id} value={id} className=\"text-sm\">\n              {name}\n            </SelectItem>\n          ))}\n          <SelectItem value={COVER_FIELD_EMPTY_ID} className=\"flex\">\n            {t('table:kanban.toolbar.noImage')}\n          </SelectItem>\n        </SelectContent>\n      </Select>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx",
    "content": "import {\n  ArrowUpDown,\n  Filter as FilterIcon,\n  Share2,\n  Settings,\n  Plus,\n  AlertTriangle,\n} from '@teable/icons';\nimport type { GalleryView } from '@teable/sdk';\nimport {\n  Sort,\n  ViewFilter,\n  VisibleFields,\n  useTablePermission,\n  CreateRecordModal,\n  useIsReadOnlyPreview,\n} from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { Button, Label, Switch, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useToolbarChange } from '../../hooks/useToolbarChange';\nimport { ToolBarButton } from '../ToolBarButton';\nimport { CoverFieldSelect } from './CoverFieldSelect';\n\nexport const GalleryViewOperators: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView() as GalleryView | undefined;\n  const permission = useTablePermission();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { onFilterChange, onSortChange } = useToolbarChange();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { coverFieldId, isCoverFit, isFieldNameHidden } = view?.options ?? {};\n\n  const onCoverFieldChange = (fieldId: string | null) => {\n    view?.updateOption({ coverFieldId: fieldId });\n  };\n\n  const onCoverFitChange = (checked: boolean) => {\n    view?.updateOption({ isCoverFit: checked });\n  };\n\n  const onFieldNameHiddenChange = (checked: boolean) => {\n    view?.updateOption({ isFieldNameHidden: checked });\n  };\n\n  if (!view) return null;\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {!isReadOnlyPreview && (\n        <>\n          <CreateRecordModal>\n            <Button size={'xs'} variant={'outline'} disabled={!permission['record|create']}>\n              <Plus className=\"size-4\" />\n              {t('table:view.addRecord')}\n            </Button>\n          </CreateRecordModal>\n          <div className=\"mx-1 h-4 w-px shrink-0 bg-border\" />\n        </>\n      )}\n      <VisibleFields\n        footer={\n          <>\n            <CoverFieldSelect\n              fieldId={coverFieldId}\n              isCoverFit={isCoverFit}\n              onSelectChange={onCoverFieldChange}\n              onCheckedChange={onCoverFitChange}\n              className=\"border-t\"\n            />\n            <div className=\"flex h-10 items-center justify-between border-t px-4\">\n              <Label htmlFor=\"is-field-name-hidden\" className=\"text-sm font-normal\">\n                {t('table:kanban.toolbar.hideFieldName')}\n              </Label>\n              <Switch\n                id=\"is-field-name-hidden\"\n                size={'default'}\n                checked={isFieldNameHidden}\n                onCheckedChange={onFieldNameHiddenChange}\n              />\n            </div>\n          </>\n        }\n      >\n        {(_text, _isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={false}\n            text={t('table:kanban.toolbar.customizeCards')}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <Settings className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </VisibleFields>\n      <ViewFilter\n        filters={view?.filter || null}\n        onChange={onFilterChange}\n        contentHeader={\n          view.enableShare && (\n            <div className=\"mb-2 flex max-w-full items-center justify-start rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground dark:bg-white/5\">\n              <Share2 className=\"mr-2 size-4 shrink-0\" />\n              <span className=\"text-muted-foreground\">{t('table:toolbar.viewFilterInShare')}</span>\n            </div>\n          )\n        }\n      >\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 shrink-0 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 shrink-0 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-orange-100 dark:bg-orange-600/30 hover:bg-orange-200 dark:hover:bg-orange-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 shrink-0 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GridViewOperators.tsx",
    "content": "import type { RowHeightLevel, IGridViewOptions } from '@teable/core';\nimport {\n  ArrowUpDown,\n  Filter as FilterIcon,\n  EyeOff,\n  LayoutList,\n  Share2,\n  AlertTriangle,\n} from '@teable/icons';\nimport { HideFields, RowHeight, Sort, Group, ViewFilter } from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useToolbarChange } from '../../hooks/useToolbarChange';\nimport { ToolBarButton } from '../ToolBarButton';\nimport { useToolBarStore } from './useToolBarStore';\n\nexport const GridViewOperators: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const view = useView();\n  const {\n    onFilterChange,\n    onRowHeightChange,\n    onFieldNameDisplayLinesChange,\n    onSortChange,\n    onGroupChange,\n  } = useToolbarChange();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { setFilterRef, setSortRef, setGroupRef } = useToolBarStore();\n  const filterRef = useRef<HTMLButtonElement>(null);\n  const sortRef = useRef<HTMLButtonElement>(null);\n  const groupRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    setFilterRef(filterRef);\n    setSortRef(sortRef);\n    setGroupRef(groupRef);\n  }, [setFilterRef, setGroupRef, setSortRef]);\n\n  if (!view) {\n    return <div></div>;\n  }\n  return (\n    <div className=\"flex min-w-0 flex-1 gap-1\">\n      <HideFields>\n        {(text, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <EyeOff className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </HideFields>\n      <ViewFilter\n        filters={view?.filter || null}\n        onChange={onFilterChange}\n        contentHeader={\n          view.enableShare && (\n            <div className=\"mb-2 flex max-w-full items-center justify-start rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground dark:bg-white/5\">\n              <Share2 className=\"mr-2 size-4 shrink-0\" />\n              <span className=\"text-muted-foreground\">{t('table:toolbar.viewFilterInShare')}</span>\n            </div>\n          )\n        }\n      >\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            ref={filterRef}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-violet-100 dark:bg-[#241A31] hover:bg-violet-200 dark:hover:bg-[#322245]',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 shrink-0 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 shrink-0 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            ref={sortRef}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-orange-100 dark:bg-[#2F2518] hover:bg-orange-200 dark:hover:bg-[#392C1B]'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 shrink-0 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n      <Group group={view?.group || null} onChange={onGroupChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            ref={groupRef}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-emerald-100 dark:bg-[#0C3026] hover:bg-emerald-200 dark:hover:bg-[#0D3A2D]'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <LayoutList className=\"size-4 shrink-0 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Group>\n      {/* <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            {\n              // disabled doesn't trigger the tooltip, so wrap div\n            }\n            <div>\n              <Color>\n                {(text: string, isActive) => (\n                  <ToolBarButton\n                    disabled={true}\n                    isActive={isActive}\n                    text={text}\n                    className={cn(\n                      GUIDE_VIEW_GROUPING,\n                      'max-w-xs',\n                      isActive &&\n                        'bg-green-100 dark:bg-green-600/30 hover:bg-green-200 dark:hover:bg-green-500/30'\n                    )}\n                    textClassName=\"@2xl/toolbar:inline\"\n                  >\n                    <PaintBucket className=\"size-4 text-sm\" />\n                  </ToolBarButton>\n                )}\n              </Color>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{t('table:toolbar.comingSoon')}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider> */}\n\n      <RowHeight\n        rowHeight={(view?.options as IGridViewOptions)?.rowHeight}\n        fieldNameDisplayLines={(view?.options as IGridViewOptions)?.fieldNameDisplayLines}\n        onChange={(type, value) => {\n          if (type === 'rowHeight') onRowHeightChange(value as RowHeightLevel);\n          if (type === 'fieldNameDisplayLines') onFieldNameDisplayLinesChange(value as number);\n        }}\n      >\n        {(_, isActive, Icon) => (\n          <ToolBarButton disabled={disabled} isActive={isActive}>\n            <Icon className=\"text-sm\" />\n          </ToolBarButton>\n        )}\n      </RowHeight>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/KanbanViewOperators.tsx",
    "content": "import type { IFieldVo } from '@teable/core';\nimport {\n  ArrowUpDown,\n  Filter as FilterIcon,\n  Share2,\n  Layers,\n  Settings,\n  Plus,\n  AlertTriangle,\n} from '@teable/icons';\nimport type { IFieldInstance, IFieldCreateOrSelectModalRef, KanbanView } from '@teable/sdk';\nimport {\n  Sort,\n  ViewFilter,\n  useFields,\n  useTableId,\n  VisibleFields,\n  generateLocalId,\n  FieldCreateOrSelectModal,\n  useTablePermission,\n  CreateRecordModal,\n  useIsReadOnlyPreview,\n} from '@teable/sdk';\nimport { useView } from '@teable/sdk/hooks/use-view';\nimport { Button, Label, Switch, cn } from '@teable/ui-lib/shadcn';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useRef } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useToolbarChange } from '../../hooks/useToolbarChange';\nimport { useKanbanStackCollapsedStore } from '../../kanban/store';\nimport { ToolBarButton } from '../ToolBarButton';\nimport { CoverFieldSelect } from './CoverFieldSelect';\n\nexport const KanbanViewOperators: React.FC<{ disabled?: boolean }> = (props) => {\n  const { disabled } = props;\n  const tableId = useTableId();\n  const view = useView() as KanbanView | undefined;\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const permission = useTablePermission();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { onFilterChange, onSortChange } = useToolbarChange();\n  const { setCollapsedStackMap } = useKanbanStackCollapsedStore();\n  const dialogRef = useRef<IFieldCreateOrSelectModalRef>(null);\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { stackFieldId, coverFieldId, isCoverFit, isEmptyStackHidden, isFieldNameHidden } =\n    view?.options ?? {};\n\n  const onFieldSelected = async (field: IFieldVo | IFieldInstance) => {\n    if (field.id === stackFieldId) return;\n    await view?.updateOption({ stackFieldId: field.id });\n    const localId = generateLocalId(tableId, view?.id);\n    setCollapsedStackMap(localId, []);\n  };\n\n  const onCoverFieldChange = (fieldId: string | null) => {\n    view?.updateOption({ coverFieldId: fieldId });\n  };\n\n  const onCoverFitChange = (checked: boolean) => {\n    view?.updateOption({ isCoverFit: checked });\n  };\n\n  const onFieldNameHiddenChange = (checked: boolean) => {\n    view?.updateOption({ isFieldNameHidden: checked });\n  };\n\n  const onEmptyStackHiddenChange = (checked: boolean) => {\n    view?.updateOption({ isEmptyStackHidden: checked });\n  };\n\n  useEffect(() => {\n    if (stackFieldId == null && !disabled) {\n      dialogRef.current?.onOpen();\n    }\n  }, [disabled, stackFieldId]);\n\n  const stackFieldName = useMemo(() => {\n    if (stackFieldId == null) return '';\n    const groupField = allFields.find(({ id }) => id === stackFieldId);\n    return groupField != null ? groupField.name : '';\n  }, [allFields, stackFieldId]);\n\n  if (!view) return null;\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {!isReadOnlyPreview && (\n        <>\n          <CreateRecordModal>\n            <Button size={'xs'} variant={'outline'} disabled={!permission['record|create']}>\n              <Plus className=\"size-4\" />\n              {t('table:view.addRecord')}\n            </Button>\n          </CreateRecordModal>\n          <div className=\"mx-1 h-4 w-px shrink-0 bg-border\"></div>\n        </>\n      )}\n      <FieldCreateOrSelectModal\n        ref={dialogRef}\n        title={t('table:kanban.toolbar.chooseStackingField')}\n        description={t('table:kanban.toolbar.chooseStackingFieldDescription')}\n        content={\n          <div className=\"flex items-center gap-2\">\n            <Switch\n              id=\"hide-empty-stack\"\n              checked={isEmptyStackHidden}\n              onCheckedChange={(checked) => onEmptyStackHiddenChange(checked)}\n            />\n            <Label htmlFor=\"hide-empty-stack\" className=\"text-sm font-normal\">\n              {t('table:kanban.toolbar.hideEmptyStack')}\n            </Label>\n          </div>\n        }\n        isCreatable={permission['field|create']}\n        selectedFieldId={stackFieldId}\n        onConfirm={onFieldSelected}\n        getCreateBtnText={(fieldName) => (\n          <Trans ns=\"table\" i18nKey={'toolbar.createFieldButtonText'}>\n            {fieldName}\n          </Trans>\n        )}\n      >\n        {(isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={\n              <Trans ns=\"table\" i18nKey={'kanban.toolbar.stackedBy'}>\n                {stackFieldName}\n              </Trans>\n            }\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <Layers className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </FieldCreateOrSelectModal>\n      <VisibleFields\n        footer={\n          <>\n            <CoverFieldSelect\n              fieldId={coverFieldId}\n              isCoverFit={isCoverFit}\n              onSelectChange={onCoverFieldChange}\n              onCheckedChange={onCoverFitChange}\n              className=\"border-t\"\n            />\n            <div className=\"flex h-10 items-center justify-between border-t px-4\">\n              <Label htmlFor=\"is-field-name-hidden\" className=\"text-sm font-normal\">\n                {t('table:kanban.toolbar.hideFieldName')}\n              </Label>\n              <Switch\n                id=\"is-field-name-hidden\"\n                size={'default'}\n                checked={isFieldNameHidden}\n                onCheckedChange={onFieldNameHiddenChange}\n              />\n            </div>\n          </>\n        }\n      >\n        {(_text, _isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={false}\n            text={t('table:kanban.toolbar.customizeCards')}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <Settings className=\"size-4 text-sm\" />\n          </ToolBarButton>\n        )}\n      </VisibleFields>\n      <ViewFilter\n        filters={view?.filter || null}\n        onChange={onFilterChange}\n        contentHeader={\n          view.enableShare && (\n            <div className=\"mb-2 flex max-w-full items-center justify-start rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground dark:bg-white/5\">\n              <Share2 className=\"mr-2 size-4 shrink-0\" />\n              <span className=\"text-muted-foreground\">{t('table:toolbar.viewFilterInShare')}</span>\n            </div>\n          )\n        }\n      >\n        {(text, isActive, hasWarning) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-violet-100 dark:bg-violet-600/30 hover:bg-violet-200 dark:hover:bg-violet-500/30',\n              hasWarning && 'border-yellow-500'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <>\n              <FilterIcon className=\"size-4 shrink-0 text-sm\" />\n              {hasWarning && <AlertTriangle className=\"size-3.5 shrink-0 text-yellow-500\" />}\n            </>\n          </ToolBarButton>\n        )}\n      </ViewFilter>\n      <Sort sorts={view?.sort || null} onChange={onSortChange}>\n        {(text: string, isActive) => (\n          <ToolBarButton\n            disabled={disabled}\n            isActive={isActive}\n            text={text}\n            className={cn(\n              'max-w-[200px]',\n              isActive &&\n                'bg-orange-100 dark:bg-orange-600/30 hover:bg-orange-200 dark:hover:bg-orange-500/30'\n            )}\n            textClassName=\"@2xl/toolbar:inline\"\n          >\n            <ArrowUpDown className=\"size-4 shrink-0 text-sm\" />\n          </ToolBarButton>\n        )}\n      </Sort>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/PersonalViewSwitch.tsx",
    "content": "import { useTablePermission, usePersonalView, useView } from '@teable/sdk/hooks';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IPersonalViewSwitchProps {\n  textClassName?: string;\n  buttonClassName?: string;\n}\n\nexport const PersonalViewSwitch = (props: IPersonalViewSwitchProps) => {\n  const { textClassName, buttonClassName } = props;\n  const view = useView();\n  const permission = useTablePermission();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { isPersonalView, openPersonalView, closePersonalView, syncViewProperties } =\n    usePersonalView();\n  const [isConfirmOpen, setIsConfirmOpen] = useState<boolean>(false);\n  const hasSyncPermission = permission['view|update'];\n  const onSwitchChange = (checked: boolean) => {\n    if (checked) {\n      openPersonalView?.();\n      return;\n    }\n\n    // turning off personal view\n    if (!hasSyncPermission || view?.isLocked) {\n      closePersonalView?.();\n    } else {\n      setIsConfirmOpen(true);\n    }\n  };\n\n  return (\n    <Fragment>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div\n              className={`${buttonClassName ?? ''} flex h-7 cursor-pointer items-center gap-2 whitespace-nowrap pl-1 text-xs`}\n            >\n              <span>{t('table:toolbar.others.personalView.personal')}</span>\n              <Switch\n                id=\"personal-view-switch\"\n                checked={Boolean(isPersonalView)}\n                onCheckedChange={onSwitchChange}\n              />\n            </div>\n          </TooltipTrigger>\n          <TooltipPortal>\n            <TooltipContent>\n              {<span>{t('table:toolbar.others.personalView.tip')}</span>}\n            </TooltipContent>\n          </TooltipPortal>\n        </Tooltip>\n      </TooltipProvider>\n      <ConfirmDialog\n        open={Boolean(isConfirmOpen)}\n        closeable={true}\n        onOpenChange={(val) => {\n          if (!val) {\n            setIsConfirmOpen(false);\n          }\n        }}\n        title={t('table:toolbar.others.personalView.dialog.title')}\n        description={t('table:toolbar.others.personalView.dialog.description')}\n        cancelText={t('table:toolbar.others.personalView.dialog.cancelText')}\n        confirmText={t('table:toolbar.others.personalView.dialog.confirmText')}\n        onConfirm={() => {\n          closePersonalView?.();\n          setIsConfirmOpen(false);\n        }}\n        onCancel={async () => {\n          await syncViewProperties?.();\n          closePersonalView?.();\n          setIsConfirmOpen(false);\n        }}\n      />\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/UndoRedoButtons.tsx",
    "content": "import { Undo2, Redo2 } from '@teable/icons';\nimport { useTablePermission, useUndoRedo } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useModKeyStr } from '@/features/app/utils/get-mod-key-str';\n\nexport const UndoRedoButtons = () => {\n  const permission = useTablePermission();\n  const { t } = useTranslation(['sdk']);\n\n  const { undo, redo } = useUndoRedo();\n\n  const modKeyStr = useModKeyStr();\n\n  return (\n    <>\n      <TooltipProvider>\n        <Tooltip delayDuration={200}>\n          <TooltipTrigger asChild>\n            <Button\n              className=\"shrink-0 p-0\"\n              size={'icon-xs'}\n              variant={'ghost'}\n              disabled={!permission['record|update']}\n              onClick={undo}\n            >\n              <Undo2 className=\"size-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              {t('sdk:undoRedo.undo')} {modKeyStr} z\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <TooltipProvider>\n        <Tooltip delayDuration={200}>\n          <TooltipTrigger asChild>\n            <Button\n              className=\"shrink-0 p-0\"\n              size={'icon-xs'}\n              variant={'ghost'}\n              disabled={!permission['record|update']}\n              onClick={redo}\n            >\n              <Redo2 className=\"size-4 shrink-0\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              {t('sdk:undoRedo.redo')} {modKeyStr} shift z\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts",
    "content": "export * from './GridViewOperators';\nexport * from './KanbanViewOperators';\nexport * from './GalleryViewOperators';\nexport * from './CalendarViewOperators';\nexport * from './PersonalViewSwitch';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/useToolBarStore.tsx",
    "content": "import type { RefObject } from 'react';\nimport { create } from 'zustand';\n\ninterface IToolBarState {\n  filterRef: RefObject<HTMLButtonElement> | null;\n  sortRef: RefObject<HTMLButtonElement> | null;\n  groupRef: RefObject<HTMLButtonElement> | null;\n  setFilterRef: (ref: RefObject<HTMLButtonElement>) => void;\n  setSortRef: (ref: RefObject<HTMLButtonElement>) => void;\n  setGroupRef: (ref: RefObject<HTMLButtonElement>) => void;\n}\n\nexport const useToolBarStore = create<IToolBarState>((set) => ({\n  filterRef: null,\n  sortRef: null,\n  groupRef: null,\n  setFilterRef: (ref: RefObject<HTMLButtonElement>) => {\n    set((state) => {\n      return {\n        ...state,\n        filterRef: ref,\n      };\n    });\n  },\n  setSortRef: (ref: RefObject<HTMLButtonElement>) => {\n    set((state) => {\n      return {\n        ...state,\n        sortRef: ref,\n      };\n    });\n  },\n  setGroupRef: (ref: RefObject<HTMLButtonElement>) => {\n    set((state) => {\n      return {\n        ...state,\n        groupRef: ref,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/index.ts",
    "content": "export * from './useViewConfigurable';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/hook/useViewConfigurable.ts",
    "content": "import { ShareViewContext } from '@teable/sdk/context';\nimport { usePersonalView, useTablePermission } from '@teable/sdk/hooks';\nimport { useContext } from 'react';\n\nexport const useViewConfigurable = () => {\n  const permission = useTablePermission();\n  const { isPersonalView } = usePersonalView();\n  const { shareId } = useContext(ShareViewContext) ?? {};\n  const isShareView = Boolean(shareId);\n\n  return {\n    isViewConfigurable: permission['view|update'] || isPersonalView || isShareView,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/store/index.ts",
    "content": "export * from './useFormModeStore';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/store/useFormModeStore.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nexport enum FormMode {\n  Edit = 'Edit',\n  Fill = 'Fill',\n}\n\ninterface IFormModeState {\n  modeMap: Record<string, FormMode>;\n  setModeMap: (key: string, mode: FormMode) => void;\n}\n\nexport const useFormModeStore = create<IFormModeState>()(\n  persist(\n    (set, get) => ({\n      modeMap: {},\n      setModeMap: (key: string, mode: FormMode) => {\n        set({\n          modeMap: {\n            ...get().modeMap,\n            [key]: mode,\n          },\n        });\n      },\n    }),\n    {\n      name: LocalStorageKeys.ViewFromMode,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/tool-bar/useViewFilterLinkContext.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IViewActionKey } from '@teable/core';\nimport { getViewFilterLinkRecords } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useViewListener } from '@teable/sdk/hooks';\nimport { useCallback, useMemo } from 'react';\n\nexport const useViewFilterLinkContext = (\n  tableId: string | undefined,\n  viewId: string | undefined,\n  config: { disabled?: boolean }\n) => {\n  const { disabled } = config;\n  const queryClient = useQueryClient();\n  const enabledQuery = Boolean(!disabled && tableId && viewId);\n\n  const { isLoading, data: queryData } = useQuery({\n    queryKey: ReactQueryKeys.getViewFilterLinkRecords(tableId!, viewId!),\n    queryFn: ({ queryKey }) =>\n      getViewFilterLinkRecords(queryKey[1], queryKey[2]).then((data) => data.data),\n    enabled: enabledQuery,\n  });\n\n  const updateContext = useCallback(() => {\n    if (enabledQuery) {\n      tableId &&\n        viewId &&\n        queryClient.invalidateQueries({\n          queryKey: ReactQueryKeys.getViewFilterLinkRecords(tableId, viewId),\n        });\n    }\n  }, [enabledQuery, queryClient, tableId, viewId]);\n\n  const viewMatches = useMemo<IViewActionKey[]>(() => ['applyViewFilter'], []);\n  useViewListener(viewId, viewMatches, updateContext);\n\n  return {\n    isLoading,\n    data: queryData?.map((v) => ({\n      tableId: v.tableId,\n      data: v.records.reduce(\n        (acc, cur) => {\n          acc[cur.id] = cur.title;\n          return acc;\n        },\n        {} as Record<string, string | undefined>\n      ),\n    })),\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/blocks/view/types.ts",
    "content": "import type { IRecord } from '@teable/core';\nimport type { IGroupPointsVo } from '@teable/openapi';\n\nexport interface IViewBaseProps {\n  recordsServerData: { records: IRecord[] };\n  recordServerData?: IRecord;\n  groupPointsServerDataMap?: { [viewId: string]: IGroupPointsVo | null };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/Chart.tsx",
    "content": "import * as echarts from 'echarts';\nimport { useCallback, useEffect, useRef } from 'react';\nimport type { Bar } from './bar';\nimport type { Line } from './line';\nimport type { Pie } from './pie';\n\nexport const Chart = (props: { chartInstance: Pie | Bar | Line }) => {\n  const { chartInstance } = props;\n  const chartContainerRef = useRef<HTMLDivElement>(null);\n\n  const renderEcharts = useCallback(\n    ({ width, height }: { width: number; height: number }) => {\n      if (!chartContainerRef.current) {\n        return;\n      }\n      // eslint-disable-next-line import/namespace\n      const myChart = echarts.init(chartContainerRef.current);\n      myChart.setOption(chartInstance.getOptions());\n      myChart.resize({ width, height });\n    },\n    [chartInstance]\n  );\n\n  useEffect(() => {\n    const resizeObserver = new ResizeObserver((entries) => {\n      entries.forEach((entry) => {\n        renderEcharts({ width: entry.contentRect.width, height: entry.contentRect.height });\n      });\n    });\n\n    if (chartContainerRef.current) {\n      resizeObserver.observe(chartContainerRef.current);\n    }\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, [chartInstance, renderEcharts]);\n\n  return (\n    <div\n      ref={chartContainerRef}\n      className={'size-full overflow-hidden p-2'}\n      style={{ minHeight: '300px', minWidth: '200px' }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/bar.ts",
    "content": "import type { BarSeriesOption, EChartsOption } from 'echarts';\nimport { Base } from './base';\nimport { ChartType } from './type';\n\nexport class Bar extends Base {\n  type = ChartType.Bar;\n\n  getOptions(): EChartsOption {\n    const _series = this.getSeries();\n    const xAxisData: string[] = [];\n    const series: BarSeriesOption[] = [];\n    let first = true;\n    _series.forEach((seriesDataMap) => {\n      const seriesData: number[] = [];\n      Object.keys(seriesDataMap).forEach((key) => {\n        first && xAxisData.push(key);\n        seriesData.push(seriesDataMap[key]);\n      });\n      series.push({\n        type: ChartType.Bar,\n        data: seriesData,\n      });\n      first = false;\n    });\n    return {\n      tooltip: {\n        trigger: 'item',\n      },\n      xAxis: {\n        type: 'category',\n        data: xAxisData,\n      },\n      yAxis: {\n        type: 'value',\n      },\n      series,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/base.ts",
    "content": "import type { EChartsOption } from 'echarts';\nimport { toNumber } from 'lodash';\nimport type { ChartType, IChartData, IChartOptions, ISeries } from './type';\nimport { Statistic } from './type';\n\nexport abstract class Base {\n  abstract type: ChartType;\n  options: IChartOptions;\n  data: IChartData;\n  constructor(options: IChartOptions, data: IChartData) {\n    this.options = options;\n    this.data = data;\n  }\n  abstract getOptions(): EChartsOption;\n\n  private getSeriesMap(series: ISeries) {\n    const { statistic, xAxis } = this.options;\n    const xAxisFieldName = xAxis.fieldName;\n    const seriesFieldName = series.fieldName;\n    switch (statistic) {\n      case Statistic.Count: {\n        const valueMap: { [key: string]: number } = {};\n        this.data.forEach((item) => {\n          const fieldValue = item[xAxisFieldName || seriesFieldName]?.toString();\n          if (!fieldValue) {\n            return;\n          }\n          const count = valueMap[fieldValue] || 0;\n          valueMap[fieldValue] = count + 1;\n        });\n        return valueMap;\n      }\n      case Statistic.Sum: {\n        const valueMap: { [key: string]: number } = {};\n        this.data.forEach((item) => {\n          const fieldValue = item[xAxisFieldName || seriesFieldName]?.toString();\n          if (!fieldValue) {\n            return;\n          }\n          const value = valueMap[fieldValue] || 0;\n          valueMap[fieldValue] = toNumber(item[seriesFieldName]) + value;\n        });\n        return valueMap;\n      }\n      default:\n        return {};\n    }\n  }\n\n  getSeries() {\n    return this.options.series.map((item) => this.getSeriesMap(item));\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/createChart.ts",
    "content": "import { Bar } from './bar';\nimport { Line } from './line';\nimport { Pie } from './pie';\nimport type { IChartData, IChartOptions } from './type';\nimport { ChartType } from './type';\n\nexport const createChart = (\n  type: ChartType,\n  context: { options: IChartOptions; data: IChartData }\n) => {\n  const { options, data } = context;\n  switch (type) {\n    case ChartType.Bar:\n      return new Bar(options, data);\n    case ChartType.Pie:\n      return new Pie(options, data);\n    case ChartType.Line:\n      return new Line(options, data);\n    default:\n      throw new Error('Unknown chart type: ' + type);\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/line.tsx",
    "content": "import type { EChartsOption, LineSeriesOption } from 'echarts';\nimport { Base } from './base';\nimport { ChartType } from './type';\n\nexport class Line extends Base {\n  type = ChartType.Line;\n\n  getOptions(): EChartsOption {\n    const seriesArr = this.getSeries();\n    const xAxisData: string[] = [];\n    const series: LineSeriesOption[] = [];\n    let first = true;\n    seriesArr.forEach((seriesDataMap) => {\n      const seriesData: number[] = [];\n      Object.keys(seriesDataMap).forEach((key) => {\n        first && xAxisData.push(key);\n        seriesData.push(seriesDataMap[key]);\n      });\n      series.push({\n        type: ChartType.Line,\n        data: seriesData,\n      });\n      first = false;\n    });\n    return {\n      tooltip: {\n        trigger: 'item',\n      },\n      xAxis: {\n        type: 'category',\n        data: xAxisData,\n      },\n      yAxis: {\n        type: 'value',\n      },\n      series,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/pie.tsx",
    "content": "import type { EChartsOption } from 'echarts';\nimport { Base } from './base';\nimport { ChartType } from './type';\n\nexport class Pie extends Base {\n  type = ChartType.Pie;\n\n  getOptions(): EChartsOption {\n    const seriesDataMap = this.getSeries()[0] || {};\n    const seriesData = Object.keys(seriesDataMap).map((key) => ({\n      name: key,\n      value: seriesDataMap[key],\n    }));\n\n    return {\n      tooltip: {\n        trigger: 'item',\n      },\n      legend: {\n        left: 'center',\n      },\n      series: {\n        type: ChartType.Pie,\n        radius: '60%',\n        data: seriesData,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Chart/type.ts",
    "content": "export enum ChartType {\n  Bar = 'bar',\n  Pie = 'pie',\n  Line = 'line',\n}\n\nexport interface ISeries {\n  fieldName: string;\n}\n\nexport enum Statistic {\n  Count = 'Count',\n  Sum = 'Sum',\n}\n\nexport interface IChartOptions {\n  xAxis: {\n    fieldName: string;\n  };\n  series: ISeries[];\n  statistic: Statistic;\n}\n\nexport type IChartData = { [fieldName: string]: unknown }[];\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/CopyButton.tsx",
    "content": "import { Check, Copy } from '@teable/icons';\nimport { syncCopy } from '@teable/sdk/utils';\nimport type { ButtonProps } from '@teable/ui-lib/shadcn';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\n\ninterface ICopyButtonProps extends ButtonProps {\n  text: string;\n  iconClassName?: string;\n  className?: string;\n  label?: string;\n  labelClassName?: string;\n}\nexport const CopyButton = (props: ICopyButtonProps) => {\n  const { text, iconClassName, className, label, labelClassName, ...rest } = props;\n  const [isCopied, setIsCopied] = useState<boolean>(false);\n\n  const onCopy = () => {\n    syncCopy(text);\n    setIsCopied(true);\n    setTimeout(() => {\n      setIsCopied(false);\n    }, 2000);\n  };\n\n  return (\n    <Button {...rest} onClick={onCopy} className={className}>\n      {isCopied ? (\n        <Check\n          className={cn(\n            'text-emerald-600 dark:text-emerald-500 animate-bounce duration-500 repeat-1',\n            iconClassName\n          )}\n        />\n      ) : (\n        <Copy className={iconClassName} />\n      )}\n      {label && <span className={cn('text-xs text-foreground', labelClassName)}>{label}</span>}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/DownloadProgressToast.tsx",
    "content": "import { X } from '@teable/icons';\nimport { Button, Progress } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IDownloadProgress } from '../utils/download-all-attachments';\nimport { formatFileSize } from '../utils/download-all-attachments';\n\ninterface IDownloadProgressToastProps {\n  progress: IDownloadProgress;\n  onCancel: () => void;\n}\n\nexport const DownloadProgressToast = ({ progress, onCancel }: IDownloadProgressToastProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { downloaded, total, currentFileName, percent } = progress;\n\n  return (\n    <div className=\"flex w-[340px] flex-col gap-3 rounded-lg bg-transparent p-4 text-popover-foreground transition-all\">\n      <div className=\"flex items-start justify-between gap-2\">\n        <div className=\"flex flex-1 flex-col gap-1 overflow-hidden\">\n          <span className=\"text-sm font-semibold leading-none tracking-tight\">\n            {t('table:download.allAttachments.downloading')}\n          </span>\n          <p className=\"truncate text-xs text-muted-foreground\" title={currentFileName}>\n            {currentFileName || '\\u00A0'}\n          </p>\n        </div>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onCancel}\n          className=\"-mr-1 -mt-1 size-6 shrink-0 text-muted-foreground hover:text-foreground\"\n        >\n          <X className=\"size-4\" />\n        </Button>\n      </div>\n\n      <div className=\"flex flex-col gap-1.5\">\n        <Progress value={percent} className=\"h-2\" />\n        <div className=\"flex items-center justify-between text-xs tabular-nums text-muted-foreground\">\n          <span>\n            {formatFileSize(downloaded)} / {formatFileSize(total)}\n          </span>\n          <span className=\"font-medium text-foreground\">{percent}%</span>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/LanguagePicker.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { updateUserLang } from '@teable/openapi';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@teable/ui-lib/shadcn/ui/select';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\n\nconst languages = [\n  { key: 'zh', title: '中文' },\n  { key: 'en', title: 'English' },\n  { key: 'it', title: 'Italiano' },\n  { key: 'fr', title: 'Français' },\n  { key: 'de', title: 'Deutsch' },\n  { key: 'ja', title: '日本語' },\n  { key: 'ru', title: 'Русский' },\n  { key: 'uk', title: 'Українська' },\n  { key: 'tr', title: 'Türkçe' },\n  { key: 'es', title: 'Español (Latinoamérica)' },\n  { key: 'default', title: 'Default' },\n];\n\nconst setCookie = (locale?: string) => {\n  if (!locale) {\n    document.cookie = `NEXT_LOCALE=; max-age=0; path=/`;\n  } else {\n    document.cookie = `NEXT_LOCALE=${locale}; max-age=31536000; path=/`;\n  }\n};\n\nexport const LanguagePicker: React.FC<{ className?: string }> = ({ className }) => {\n  const { t, i18n } = useTranslation('common');\n\n  const { mutateAsync: updateLangMutate } = useMutation({\n    mutationFn: (ro: { lang: string }) => updateUserLang(ro),\n    onSuccess: (_data, variables) => {\n      setCookie(variables.lang);\n      i18n.changeLanguage(variables.lang);\n      toast.message(t('actions.updateSucceed'));\n      window.location.reload();\n    },\n  });\n\n  const setLanguage = (value: string) => {\n    const lang = value === 'default' ? '' : value;\n    updateLangMutate({ lang });\n  };\n\n  const currentLanguage = i18n.language.split('-')[0];\n  const selectedValue = languages.some((l) => l.key === currentLanguage)\n    ? currentLanguage\n    : 'default';\n\n  return (\n    <Select value={selectedValue} onValueChange={setLanguage}>\n      <SelectTrigger size=\"lg\" className={`max-w-[320px] ${className || ''}`}>\n        <SelectValue placeholder=\"Select Language\" />\n      </SelectTrigger>\n      <SelectContent>\n        {languages.map((item) => (\n          <SelectItem key={item.key} value={item.key}>\n            {item.title}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/LicenseExpiryBanner.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getEnterpriseLicenseStatus } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef } from 'react';\nimport { useIsEE } from '@/features/app/hooks/useIsEE';\n\nexport const LicenseExpiryBanner = () => {\n  const { t } = useTranslation('common');\n  const { user } = useSession();\n  const isEE = useIsEE();\n  const toastShownRef = useRef(false);\n\n  const shouldCheck = Boolean(isEE && user?.isAdmin);\n\n  const { data: licenseStatus } = useQuery({\n    queryKey: ReactQueryKeys.getEnterpriseLicenseStatus(),\n    queryFn: () => getEnterpriseLicenseStatus().then(({ data }) => data),\n    enabled: shouldCheck,\n  });\n\n  const { expiredTime } = licenseStatus ?? {};\n\n  useEffect(() => {\n    if (!shouldCheck || !expiredTime || toastShownRef.current) return;\n\n    toast.warning(\n      <div className=\"flex w-full items-center justify-between gap-4\">\n        {t('billing.licenseExpiredGracePeriod', {\n          expiredTime: new Date(expiredTime).toLocaleDateString(),\n        })}\n        <Link href=\"/admin/license\" target=\"_blank\">\n          <Button>{t('actions.update')}</Button>\n        </Link>\n      </div>,\n      {\n        duration: Infinity,\n        closeButton: true,\n        position: 'top-center',\n        className: 'sm:w-[672px] w-full',\n      }\n    );\n    toastShownRef.current = true;\n  }, [shouldCheck, expiredTime, t]);\n\n  return null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/MenuDeleteItem.tsx",
    "content": "import { Trash2 } from '@teable/icons';\nimport { Button, DropdownMenuItem, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React, { useState } from 'react';\n\ninterface IMenuDeleteItemProps {\n  children?: React.ReactNode | React.ReactNode[];\n  onConfirm?: () => void;\n  text?: {\n    confirmButton?: string;\n    cancelButton?: string;\n  };\n  disabled?: boolean;\n}\n\nexport const MenuDeleteItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuItem>,\n  IMenuDeleteItemProps\n>((props, ref) => {\n  const [deleteAlter, setDeleteAlter] = useState(false);\n  const { onConfirm, children, text, disabled = false } = props;\n  const { t } = useTranslation('common');\n\n  const { confirmButton = t('actions.yesDelete'), cancelButton = t('actions.cancel') } = text ?? {};\n  return (\n    <div className=\"relative overflow-hidden\">\n      <DropdownMenuItem\n        ref={ref}\n        disabled={disabled}\n        className=\"text-destructive focus:bg-destructive/10 focus:text-destructive\"\n        onClick={(e) => {\n          setDeleteAlter(true);\n          e.preventDefault();\n          e.stopPropagation();\n        }}\n      >\n        {children ?? (\n          <>\n            <Trash2 className=\"mr-1.5\" />\n            {t('actions.delete')}\n          </>\n        )}\n      </DropdownMenuItem>\n      <div\n        className={cn(\n          'absolute size-full flex bottom-0 items-center gap-1 justify-between bg-popover translate-y-full transition-transform',\n          {\n            'translate-y-0': deleteAlter,\n          }\n        )}\n      >\n        <Button\n          className=\"flex-1 px-1.5\"\n          variant={'destructive'}\n          size={'xs'}\n          onClick={(e) => {\n            e.stopPropagation();\n            onConfirm?.();\n          }}\n        >\n          {confirmButton}\n        </Button>\n        <Button\n          className=\"flex-1 px-1.5\"\n          variant={'outline'}\n          size={'xs'}\n          onClick={(e) => {\n            e.stopPropagation();\n            setDeleteAlter(false);\n          }}\n        >\n          {cancelButton}\n        </Button>\n      </div>\n    </div>\n  );\n});\n\nMenuDeleteItem.displayName = 'MenuDeleteItem';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/PublicOperateButton.tsx",
    "content": "import { useIsAnonymous, useIsHydrated, useShareId, useTemplate } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useRef } from 'react';\nimport { useShareAllowSave } from '../context/ShareContext';\nimport { useIsInIframe } from '../hooks/useIsInIframe';\nimport type { IShareSelectSpaceDialogRef } from './ShareSelectSpaceDialog';\nimport { ShareSelectSpaceDialog } from './ShareSelectSpaceDialog';\nimport type { ITemplateSelectSpaceDialogRef } from './TemplateSelectSpaceDialog';\nimport { TemplateSelectSpaceDialog } from './TemplateSelectSpaceDialog';\n\nexport const PublicOperateButton = () => {\n  const isAnonymous = useIsAnonymous();\n  const template = useTemplate();\n  const shareId = useShareId();\n  const isTemplate = !!template;\n  const isShare = !!shareId;\n  const allowSave = useShareAllowSave();\n  const { t } = useTranslation(['common']);\n  const router = useRouter();\n  const isInIframe = useIsInIframe();\n  const templateRef = useRef<ITemplateSelectSpaceDialogRef>(null);\n  const shareRef = useRef<IShareSelectSpaceDialogRef>(null);\n  const isHydrated = useIsHydrated();\n\n  if (isInIframe || !isHydrated) {\n    return <></>;\n  }\n\n  // For share mode, show \"Copy to my space\" button if allowSave is enabled\n  if (isShare) {\n    // Don't show the button if allowSave is disabled\n    if (!allowSave) {\n      return null;\n    }\n\n    const handleClick = () => {\n      if (isAnonymous) {\n        // Redirect to login first, then come back with isCopyToSpace flag\n        const url = new URL(window.location.href);\n        url.searchParams.set('isCopyToSpace', '1');\n        router.push(`/auth/login?redirect=${encodeURIComponent(url.toString())}`);\n        return;\n      }\n      shareRef.current?.setOpen(true);\n    };\n\n    return (\n      <div className=\"flex w-full flex-col items-center\">\n        <Image\n          src=\"/images/savefile-light.png\"\n          alt=\"\"\n          width={120}\n          height={120}\n          className=\"block dark:hidden\"\n        />\n        <Image\n          src=\"/images/savefile-dark.png\"\n          alt=\"\"\n          width={120}\n          height={120}\n          className=\"hidden dark:block\"\n        />\n        <p className=\"mb-3 text-xs text-muted-foreground\">{t('common:actions.supportSaveCopy')}</p>\n        <Button size={'sm'} className=\"w-full text-[13px] font-normal\" onClick={handleClick}>\n          {t('common:actions.saveToMySpace')}\n        </Button>\n        <ShareSelectSpaceDialog ref={shareRef} />\n      </div>\n    );\n  }\n\n  if (!isAnonymous && !isTemplate) {\n    return null;\n  }\n\n  const handleClick = () => {\n    if (isTemplate) {\n      if (isAnonymous) {\n        const url = new URL(window.location.href);\n        url.searchParams.set('isUseTemplate', '1');\n        router.push(`/auth/login?redirect=${encodeURIComponent(url.toString())}`);\n        return;\n      }\n      templateRef.current?.setOpen(true);\n      return;\n    }\n    if (isAnonymous) {\n      router.push(`/auth/login?redirect=${encodeURIComponent(window.location.href)}`);\n    }\n  };\n\n  return (\n    <>\n      <Button size={'sm'} className=\"w-full text-[13px] font-normal\" onClick={handleClick}>\n        {isTemplate ? t('common:actions.useTemplate') : t('common:actions.login')}\n      </Button>\n      {isTemplate && !isAnonymous && (\n        <TemplateSelectSpaceDialog ref={templateRef} templateId={template.id} />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/ShareSelectSpaceDialog.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { RolePermission } from '@teable/core';\nimport {\n  copyBaseShare,\n  createSpace,\n  getBaseList,\n  getSpaceList,\n  getUserLastVisit,\n  LastVisitResourceType,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBase } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Command,\n  CommandEmpty,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n  Input,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n  ToggleGroup,\n  ToggleGroupItem,\n} from '@teable/ui-lib/shadcn';\nimport { Check, ChevronDown, Loader, Plus } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react';\nimport { useShareContext } from '../context/ShareContext';\nimport { SpaceAvatar } from './space/SpaceAvatar';\n\ntype CopyMode = 'newBase' | 'existingBase';\n\nexport interface IShareSelectSpaceDialogRef {\n  setOpen: (open: boolean) => void;\n}\n\nconst CreateSpaceSection: React.FC<{\n  newSpaceName: string;\n  setNewSpaceName: (v: string) => void;\n  isCreatingSpace: boolean;\n  onCreateSpace: () => void;\n}> = ({ newSpaceName, setNewSpaceName, isCreatingSpace, onCreateSpace }) => {\n  const { t } = useTranslation(['common']);\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <p className=\"text-[13px] text-muted-foreground\">\n        {t('common:share.copyToSpaceDialog.noSpaceDescription')}\n      </p>\n      <div className=\"flex items-center gap-2\">\n        <Input\n          size=\"lg\"\n          value={newSpaceName}\n          onChange={(e) => setNewSpaceName(e.target.value)}\n          disabled={isCreatingSpace}\n          placeholder={t('common:share.copyToSpaceDialog.newSpacePlaceholder')}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') onCreateSpace();\n          }}\n        />\n        <Button onClick={onCreateSpace} disabled={isCreatingSpace} className=\"shrink-0\">\n          {isCreatingSpace ? (\n            <Loader className=\"size-4 animate-spin\" />\n          ) : (\n            <>\n              <Plus className=\"size-4\" />\n              {t('common:share.copyToSpaceDialog.createSpace')}\n            </>\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nconst BasePickerSection: React.FC<{\n  isLoading: boolean;\n  bases: { id: string; name: string }[] | undefined;\n  selectedBaseId: string | undefined;\n  setSelectedBaseId: (id: string) => void;\n  basePickerOpen: boolean;\n  setBasePickerOpen: (open: boolean) => void;\n  disabled: boolean;\n}> = ({\n  isLoading,\n  bases,\n  selectedBaseId,\n  setSelectedBaseId,\n  basePickerOpen,\n  setBasePickerOpen,\n  disabled,\n}) => {\n  const { t } = useTranslation(['common']);\n  const selectedBaseName = bases?.find((b) => b.id === selectedBaseId)?.name;\n\n  const baseNameMap = useMemo(() => {\n    const map: Record<string, string> = {};\n    bases?.forEach((b) => {\n      map[b.id] = b.name;\n    });\n    return map;\n  }, [bases]);\n\n  const commandFilter = useCallback(\n    (id: string, search: string) => {\n      const name = baseNameMap[id?.trim()]?.toLowerCase() || '';\n      return name.includes(search?.toLowerCase()?.trim()) ? 1 : 0;\n    },\n    [baseNameMap]\n  );\n\n  if (isLoading) {\n    return (\n      <div className=\"flex h-9 items-center justify-center\">\n        <Loader className=\"size-4 animate-spin\" />\n      </div>\n    );\n  }\n\n  if (!bases || bases.length === 0) {\n    return (\n      <p className=\"text-[13px] text-muted-foreground\">\n        {t('common:share.copyToSpaceDialog.noBaseInSpace')}\n      </p>\n    );\n  }\n\n  return (\n    <Popover open={basePickerOpen} onOpenChange={setBasePickerOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          disabled={disabled}\n          className=\"h-9 w-full justify-between overflow-hidden px-3 font-normal\"\n        >\n          <span\n            className={`truncate text-[13px] ${!selectedBaseName ? 'text-muted-foreground' : ''}`}\n          >\n            {selectedBaseName ?? t('common:share.copyToSpaceDialog.selectBasePlaceholder')}\n          </span>\n          <ChevronDown className=\"size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[--radix-popover-trigger-width] p-0\" align=\"start\">\n        <Command filter={commandFilter}>\n          <CommandInput placeholder={`${t('common:actions.search')}...`} className=\"h-10\" />\n          <CommandList>\n            <CommandEmpty>{t('common:share.copyToSpaceDialog.noBaseInSpace')}</CommandEmpty>\n            {bases.map((b) => (\n              <CommandItem\n                key={b.id}\n                value={b.id}\n                onSelect={() => {\n                  setSelectedBaseId(b.id);\n                  setBasePickerOpen(false);\n                }}\n                className=\"flex items-center\"\n              >\n                <Check\n                  className={[\n                    'mr-2 size-4 shrink-0',\n                    selectedBaseId === b.id ? 'opacity-100' : 'opacity-0',\n                  ].join(' ')}\n                />\n                <span className=\"truncate\">{b.name}</span>\n              </CommandItem>\n            ))}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport const ShareSelectSpaceDialog = React.forwardRef<IShareSelectSpaceDialogRef, object>(\n  (_, ref) => {\n    const { t } = useTranslation(['common']);\n    const [selectedSpaceId, setSelectedSpaceId] = useState<string>();\n    const [selectedBaseId, setSelectedBaseId] = useState<string>();\n    const [baseName, setBaseName] = useState<string>();\n    const [newSpaceName, setNewSpaceName] = useState('');\n    const [copyMode, setCopyMode] = useState<CopyMode>('newBase');\n    const [basePickerOpen, setBasePickerOpen] = useState(false);\n    const router = useRouter();\n    const isCopyToSpace = router.query.isCopyToSpace === '1';\n    const [open, setOpen] = useState(isCopyToSpace);\n    const [copyLoading, setCopyLoading] = useState(false);\n    const base = useBase();\n    const { shareId } = useShareContext();\n    const queryClient = useQueryClient();\n\n    useImperativeHandle(ref, () => ({\n      setOpen,\n    }));\n\n    const { mutateAsync: copyBaseMutator } = useMutation({\n      mutationFn: ({\n        spaceId,\n        name,\n        baseId,\n      }: {\n        spaceId: string;\n        name?: string;\n        baseId?: string;\n      }) => {\n        if (!shareId) {\n          return Promise.reject(new Error('Share ID is required'));\n        }\n        return copyBaseShare(shareId, {\n          spaceId,\n          name,\n          withRecords: true,\n          baseId,\n        });\n      },\n      onSuccess: ({ data }) => {\n        setOpen(false);\n        const { id: newBaseId } = data;\n        window.location.href = `/base/${newBaseId}`;\n      },\n      onError: () => {\n        setCopyLoading(false);\n      },\n    });\n\n    const { mutate: createSpaceMutator, isPending: isCreatingSpace } = useMutation({\n      mutationFn: (name: string) => createSpace({ name: name || undefined }),\n      onSuccess: async (data) => {\n        await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n        setSelectedSpaceId(data.data.id);\n        setNewSpaceName('');\n      },\n    });\n\n    const { data: spaceList, isLoading: isLoadingSpaceList } = useQuery({\n      queryKey: ReactQueryKeys.spaceList(),\n      queryFn: () => getSpaceList().then((data) => data.data),\n      enabled: open,\n    });\n    const { data: userLastVisitSpace, isLoading: isLoadingUserLastVisitSpace } = useQuery({\n      queryKey: ['user-last-visit-space', LastVisitResourceType.Space] as const,\n      queryFn: () =>\n        getUserLastVisit({ resourceType: LastVisitResourceType.Space, parentResourceId: '' }).then(\n          (data) => data.data\n        ),\n      enabled: open,\n    });\n\n    const { data: baseListInSpace, isLoading: isLoadingBaseList } = useQuery({\n      queryKey: ['base-list-in-space', selectedSpaceId] as const,\n      queryFn: () =>\n        selectedSpaceId\n          ? getBaseList({ spaceId: selectedSpaceId }).then((data) => data.data)\n          : Promise.resolve([]),\n      enabled: open && !!selectedSpaceId && copyMode === 'existingBase',\n    });\n\n    const creatableSpaces = useMemo(\n      () => spaceList?.filter((s) => RolePermission[s.role]['base|create']),\n      [spaceList]\n    );\n\n    const editableBases = useMemo(\n      () => baseListInSpace?.filter((b) => RolePermission[b.role]['base|update']),\n      [baseListInSpace]\n    );\n\n    const defaultSpaceId = useMemo(() => {\n      if (isLoadingUserLastVisitSpace || isLoadingSpaceList) {\n        return;\n      }\n      if (!userLastVisitSpace) {\n        return creatableSpaces?.[0]?.id;\n      }\n      if (creatableSpaces?.some((space) => space.id === userLastVisitSpace.resourceId)) {\n        return userLastVisitSpace.resourceId;\n      }\n      return creatableSpaces?.[0]?.id;\n    }, [userLastVisitSpace, creatableSpaces, isLoadingUserLastVisitSpace, isLoadingSpaceList]);\n\n    useEffect(() => {\n      if (defaultSpaceId) {\n        setSelectedSpaceId(defaultSpaceId);\n      }\n    }, [defaultSpaceId]);\n\n    useEffect(() => {\n      setSelectedBaseId(undefined);\n      setBasePickerOpen(false);\n    }, [selectedSpaceId, copyMode]);\n\n    const hasNoSpaces = !isLoadingSpaceList && (!creatableSpaces || creatableSpaces.length === 0);\n\n    const copyHandler = () => {\n      if (!selectedSpaceId) return;\n      if (copyMode === 'existingBase' && !selectedBaseId) return;\n      setCopyLoading(true);\n      copyBaseMutator({\n        spaceId: selectedSpaceId,\n        name: copyMode === 'newBase' ? baseName?.trim() || undefined : undefined,\n        baseId: copyMode === 'existingBase' ? selectedBaseId : undefined,\n      });\n    };\n\n    const isConfirmDisabled =\n      !selectedSpaceId || copyLoading || (copyMode === 'existingBase' && !selectedBaseId);\n\n    return (\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogContent className=\"w-[480px] gap-0 p-0\">\n          <div className=\"px-6 pb-1 pt-6\">\n            <DialogHeader>\n              <DialogTitle className=\"text-lg\">\n                {t('common:share.copyToSpaceDialog.title')}\n              </DialogTitle>\n            </DialogHeader>\n            {!hasNoSpaces && (\n              <DialogDescription className=\"mt-1 text-[13px]\">\n                {t('common:share.copyToSpaceDialog.description')}\n              </DialogDescription>\n            )}\n          </div>\n\n          <div className=\"flex flex-col gap-5 px-6 py-4\">\n            <div className=\"flex flex-col gap-1.5\">\n              <Label className=\"text-[13px] text-muted-foreground\">\n                {t('common:share.copyToSpaceDialog.selectSpace')}\n              </Label>\n              {hasNoSpaces ? (\n                <CreateSpaceSection\n                  newSpaceName={newSpaceName}\n                  setNewSpaceName={setNewSpaceName}\n                  isCreatingSpace={isCreatingSpace}\n                  onCreateSpace={() => createSpaceMutator(newSpaceName.trim())}\n                />\n              ) : (\n                <Select\n                  value={selectedSpaceId}\n                  onValueChange={setSelectedSpaceId}\n                  disabled={copyLoading}\n                >\n                  <SelectTrigger size=\"lg\" className=\"overflow-hidden [&>svg:last-child]:hidden\">\n                    <SelectValue />\n                    <ChevronDown className=\"size-4 shrink-0 opacity-50\" />\n                  </SelectTrigger>\n                  <SelectContent className=\"max-h-[250px]\">\n                    {creatableSpaces?.map((space) => (\n                      <SelectItem key={space.id} value={space.id} className=\"py-1\">\n                        <span className=\"flex w-[380px] items-center gap-2 overflow-x-hidden\">\n                          <SpaceAvatar name={space.name} className=\"size-6\" />\n                          <span className=\"truncate\">{space.name}</span>\n                        </span>\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              )}\n            </div>\n\n            {!hasNoSpaces && selectedSpaceId && (\n              <div className=\"flex flex-col gap-3\">\n                <div className=\"flex flex-col gap-1.5\">\n                  <Label className=\"text-[13px] text-muted-foreground\">\n                    {t('common:share.copyToSpaceDialog.copyTarget')}\n                  </Label>\n                  <ToggleGroup\n                    type=\"single\"\n                    value={copyMode}\n                    aria-label={t('common:share.copyToSpaceDialog.copyTarget')}\n                    onValueChange={(v) => {\n                      if (!v) return;\n                      setCopyMode(v as CopyMode);\n                    }}\n                    disabled={copyLoading}\n                    size=\"sm\"\n                    className=\"h-9 w-full justify-start gap-0 rounded-lg bg-muted p-1\"\n                  >\n                    <ToggleGroupItem\n                      value=\"newBase\"\n                      className=\"flex-1 justify-center rounded-[7px] text-[13px] text-muted-foreground shadow-none transition-all data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm hover:text-foreground\"\n                    >\n                      {t('common:share.copyToSpaceDialog.createNewBase')}\n                    </ToggleGroupItem>\n                    <ToggleGroupItem\n                      value=\"existingBase\"\n                      className=\"flex-1 justify-center rounded-[7px] text-[13px] text-muted-foreground shadow-none transition-all data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm hover:text-foreground\"\n                    >\n                      {t('common:share.copyToSpaceDialog.copyToExistingBase')}\n                    </ToggleGroupItem>\n                  </ToggleGroup>\n                </div>\n\n                <div className=\"min-h-[42px]\">\n                  {copyMode === 'newBase' ? (\n                    <div className=\"flex flex-col gap-1.5\">\n                      <Label className=\"text-[13px] text-muted-foreground\">\n                        {t('common:share.copyToSpaceDialog.baseName')}\n                      </Label>\n                      <Input\n                        size=\"lg\"\n                        value={baseName ?? base?.name ?? ''}\n                        onChange={(e) => setBaseName(e.target.value)}\n                        disabled={copyLoading}\n                        placeholder={t('common:share.copyToSpaceDialog.baseNamePlaceholder')}\n                      />\n                    </div>\n                  ) : (\n                    <div className=\"flex flex-col gap-1.5\">\n                      <Label className=\"text-[13px] text-muted-foreground\">\n                        {t('common:share.copyToSpaceDialog.selectBase')}\n                      </Label>\n                      <BasePickerSection\n                        isLoading={isLoadingBaseList}\n                        bases={editableBases}\n                        selectedBaseId={selectedBaseId}\n                        setSelectedBaseId={setSelectedBaseId}\n                        basePickerOpen={basePickerOpen}\n                        setBasePickerOpen={setBasePickerOpen}\n                        disabled={copyLoading}\n                      />\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n\n          <DialogFooter className=\"border-t px-6 py-4\">\n            <Button\n              className=\"min-w-[72px]\"\n              size=\"sm\"\n              variant=\"outline\"\n              onClick={() => setOpen(false)}\n            >\n              {t('common:actions.cancel')}\n            </Button>\n            <Button\n              className=\"relative min-w-[72px]\"\n              size=\"sm\"\n              onClick={copyHandler}\n              disabled={isConfirmDisabled}\n            >\n              {copyLoading ? (\n                <Loader className=\"size-4 animate-spin\" />\n              ) : (\n                t('common:actions.duplicate')\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nShareSelectSpaceDialog.displayName = 'ShareSelectSpaceDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/SideBarFooter.tsx",
    "content": "import { useSession } from '@teable/sdk';\nimport { useIsAnonymous, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { Trans } from 'next-i18next';\nimport React from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { NotificationsManage } from '@/features/app/components/notifications/NotificationsManage';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\nimport { SettingDialog } from '@overridable/SettingDialog';\nimport { DuplicateBaseModal } from '../blocks/base/duplicate/DuplicateBaseModal';\nimport { TemplateCreateBaseModal } from '../blocks/base/duplicate/TemplateCreateBaseModal';\nimport { SpaceSubscriptionModal } from '../blocks/billing/SpaceSubscriptionModal';\nimport { useBrand } from '../hooks/useBrand';\nimport { PublicOperateButton } from './PublicOperateButton';\nimport { UserNav } from './user/UserNav';\n\nexport const SideBarFooter: React.FC = () => {\n  const { user } = useSession();\n  const isAnonymous = useIsAnonymous();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { brandName } = useBrand();\n\n  if (isAnonymous || isReadOnlyPreview) {\n    return (\n      <div className=\"mx-4 my-3 flex flex-col items-center gap-4\">\n        <PublicOperateButton />\n        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n          <Trans\n            ns=\"common\"\n            i18nKey=\"poweredBy\"\n            components={[\n              <Link\n                key={'brandFooter'}\n                href=\"/\"\n                target=\"_blank\"\n                className=\"flex items-center text-sm text-black dark:text-white\"\n              >\n                <TeableLogo className=\"text-xl\" />\n                <span className=\"ml-1 font-semibold\">{brandName}</span>\n              </Link>,\n            ]}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex w-full flex-col items-center gap-2 p-2\">\n      <div className=\"flex w-full justify-between gap-2\">\n        <UserNav>\n          <Button\n            variant=\"ghost\"\n            size={'sm'}\n            className=\"min-w-0 flex-1 justify-start overflow-hidden py-1.5 pl-2 text-sm font-normal\"\n          >\n            <UserAvatar className=\"border\" user={user} />\n            <p className=\"truncate\" title={user.name}>\n              {user.name}\n            </p>\n          </Button>\n        </UserNav>\n        <SettingDialog />\n        <DuplicateBaseModal />\n        <TemplateCreateBaseModal />\n        <SpaceSubscriptionModal />\n        <NotificationsManage />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/SpaceSettingContainer.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\n\ninterface SpaceSettingContainerProps {\n  title: string;\n  description?: string | React.ReactElement;\n  className?: string;\n  children: React.ReactNode | React.ReactNode[];\n  headerClassName?: string;\n  wrapperClassName?: string;\n  titleClassName?: string;\n}\n\nexport const SpaceSettingContainer = ({\n  title,\n  description,\n  className,\n  children,\n  headerClassName,\n  wrapperClassName,\n  titleClassName,\n}: SpaceSettingContainerProps) => {\n  return (\n    <div className={cn('h-full w-full', wrapperClassName)}>\n      <div className={cn('h-full w-full flex flex-col p-6', headerClassName)}>\n        <div className={cn('pb-6', titleClassName)}>\n          <p className=\"text-lg font-semibold\">{title}</p>\n          {description && <div className=\"mt-1 text-sm text-muted-foreground\">{description}</div>}\n        </div>\n        <div className={cn('overflow-y-auto flex flex-col flex-1 gap-6', className)}>\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/TemplateSelectSpaceDialog.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport {\n  createBaseFromTemplate,\n  createSpace,\n  getSpaceList,\n  getUserLastVisit,\n  LastVisitResourceType,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n  Input,\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n} from '@teable/ui-lib/shadcn';\nimport { ChevronDown, Loader, Plus } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useEffect, useImperativeHandle, useMemo, useState } from 'react';\nimport { SpaceAvatar } from './space/SpaceAvatar';\n\nexport interface ITemplateSelectSpaceDialogRef {\n  setOpen: (open: boolean) => void;\n}\n\ninterface ITemplateSelectSpaceDialogProps {\n  templateId: string;\n}\n\nexport const TemplateSelectSpaceDialog = React.forwardRef<\n  ITemplateSelectSpaceDialogRef,\n  ITemplateSelectSpaceDialogProps\n>(({ templateId }, ref) => {\n  const { t } = useTranslation(['common']);\n  const [selectedSpaceId, setSelectedSpaceId] = useState<string>();\n  const [newSpaceName, setNewSpaceName] = useState('');\n  const router = useRouter();\n  const isUseTemplate = router.query.isUseTemplate === '1';\n  const [open, setOpen] = useState(isUseTemplate);\n  const [applyTemplateLoading, setApplyTemplateLoading] = useState(false);\n  const queryClient = useQueryClient();\n\n  useImperativeHandle(ref, () => ({\n    setOpen,\n  }));\n\n  const { mutateAsync: applyTemplateMutator } = useMutation({\n    mutationFn: ({ spaceId, templateId }: { spaceId: string; templateId: string }) =>\n      createBaseFromTemplate({ spaceId, templateId, withRecords: true }),\n    onSuccess: ({ data }) => {\n      setOpen(false);\n      const { id: baseId, defaultUrl } = data;\n\n      // If defaultUrl is provided, navigate to it directly (e.g., to a default node)\n      if (defaultUrl) {\n        window.location.href = defaultUrl;\n        return;\n      }\n\n      // Otherwise, navigate to base home\n      window.location.href = `/base/${baseId}`;\n    },\n    onError: () => {\n      setApplyTemplateLoading(false);\n    },\n  });\n\n  const { mutate: createSpaceMutator, isPending: isCreatingSpace } = useMutation({\n    mutationFn: (name: string) => createSpace({ name: name || undefined }),\n    onSuccess: async (data) => {\n      await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      setSelectedSpaceId(data.data.id);\n      setNewSpaceName('');\n    },\n  });\n\n  const { data: spaceList, isLoading: isLoadingSpaceList } = useQuery({\n    queryKey: ReactQueryKeys.spaceList(),\n    queryFn: () => getSpaceList().then((data) => data.data),\n  });\n  const { data: userLastVisitSpace, isLoading: isLoadingUserLastVisitSpace } = useQuery({\n    queryKey: ['user-last-visit-space', LastVisitResourceType.Space] as const,\n    queryFn: () =>\n      getUserLastVisit({ resourceType: LastVisitResourceType.Space, parentResourceId: '' }).then(\n        (data) => data.data\n      ),\n  });\n  const defaultSpaceId = useMemo(() => {\n    if (isLoadingUserLastVisitSpace || isLoadingSpaceList) {\n      return;\n    }\n    if (!userLastVisitSpace) {\n      return spaceList?.[0]?.id;\n    }\n    if (spaceList?.some((space) => space.id === userLastVisitSpace.resourceId)) {\n      return userLastVisitSpace.resourceId;\n    }\n  }, [userLastVisitSpace, spaceList, isLoadingUserLastVisitSpace, isLoadingSpaceList]);\n\n  useEffect(() => {\n    if (defaultSpaceId) {\n      setSelectedSpaceId(defaultSpaceId);\n    }\n  }, [defaultSpaceId]);\n\n  const hasNoSpaces = !isLoadingSpaceList && spaceList?.length === 0;\n\n  const useTemplateHandler = () => {\n    if (!selectedSpaceId) {\n      return;\n    }\n    setApplyTemplateLoading(true);\n    applyTemplateMutator({ spaceId: selectedSpaceId, templateId });\n  };\n\n  const createSpaceHandler = () => {\n    createSpaceMutator(newSpaceName.trim());\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"w-[512px]\">\n        <DialogHeader>\n          <DialogTitle>{t('common:template.useTemplateDialog.title')}</DialogTitle>\n        </DialogHeader>\n        <DialogDescription>{t('common:template.useTemplateDialog.description')}</DialogDescription>\n        {hasNoSpaces ? (\n          <div className=\"flex flex-col gap-2\">\n            <p className=\"text-muted-foreground text-sm\">\n              {t('common:template.useTemplateDialog.noSpaceDescription')}\n            </p>\n            <div className=\"flex items-center gap-2\">\n              <Input\n                size=\"lg\"\n                value={newSpaceName}\n                onChange={(e) => setNewSpaceName(e.target.value)}\n                disabled={isCreatingSpace}\n                placeholder={t('common:template.useTemplateDialog.newSpacePlaceholder')}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') {\n                    createSpaceHandler();\n                  }\n                }}\n              />\n              <Button\n                onClick={createSpaceHandler}\n                disabled={isCreatingSpace}\n                className=\"h-9 shrink-0\"\n              >\n                {isCreatingSpace ? (\n                  <Loader className=\"size-4 animate-spin\" />\n                ) : (\n                  <>\n                    <Plus className=\"size-4\" />\n                    {t('common:template.useTemplateDialog.createSpace')}\n                  </>\n                )}\n              </Button>\n            </div>\n          </div>\n        ) : (\n          <Select\n            value={selectedSpaceId}\n            onValueChange={setSelectedSpaceId}\n            disabled={applyTemplateLoading}\n          >\n            <SelectTrigger size=\"lg\" className=\"overflow-hidden [&>svg:last-child]:hidden\">\n              <SelectValue />\n              <ChevronDown className=\"size-4 shrink-0 opacity-50\" />\n            </SelectTrigger>\n            <SelectContent className=\"max-h-[250px]\">\n              {spaceList?.map((space) => (\n                <SelectItem key={space.id} value={space.id} className=\"py-1\">\n                  <span className=\"flex w-[400px] items-center gap-2 overflow-x-hidden\">\n                    <SpaceAvatar name={space.name} className=\"size-6\" />\n                    <span className=\"truncate\">{space.name}</span>\n                  </span>\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        )}\n        <DialogFooter>\n          <Button className=\"min-w-16\" size=\"sm\" variant=\"outline\" onClick={() => setOpen(false)}>\n            {t('common:actions.cancel')}\n          </Button>\n          <Button\n            className=\"relative min-w-16\"\n            size=\"sm\"\n            onClick={useTemplateHandler}\n            disabled={!selectedSpaceId || applyTemplateLoading}\n          >\n            {applyTemplateLoading ? (\n              <Loader className=\"size-4 animate-spin \" />\n            ) : (\n              t('common:actions.confirm')\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nTemplateSelectSpaceDialog.displayName = 'TemplateSelectSpaceDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/ThemePicker.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib/shadcn/ui/dropdown-menu';\nexport const ThemePicker: React.FC<{ className?: string }> = ({ className }) => {\n  const { theme, setTheme } = useTheme();\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button className={cn('capitalize', className)} size={'xs'} variant=\"ghost\">\n          {theme || 'system'}\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-56\">\n        <DropdownMenuRadioGroup\n          value={theme}\n          onValueChange={(value) => {\n            setTheme(value);\n          }}\n        >\n          {['light', 'dark', 'system'].map((item) => {\n            return (\n              <DropdownMenuRadioItem\n                className=\"capitalize\"\n                key={item}\n                disabled={theme === item}\n                value={item}\n              >\n                {item}\n              </DropdownMenuRadioItem>\n            );\n          })}\n        </DropdownMenuRadioGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/Welcom.tsx",
    "content": "export const Welcome = () => {\n  return <div>welcome</div>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/billing/Level.tsx",
    "content": "import type { BillingProductLevel } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport type { AppSumoTier } from '../../hooks/useBillingLevelConfig';\nimport { useAppSumoTierConfig, useBillingLevelConfig } from '../../hooks/useBillingLevelConfig';\n\ninterface ILevelProps {\n  level?: BillingProductLevel;\n  appSumoTier?: AppSumoTier;\n}\n\nexport const Level = (props: ILevelProps) => {\n  const { level, appSumoTier } = props;\n  const levelConfig = useBillingLevelConfig(level);\n  const appSumoConfig = useAppSumoTierConfig(appSumoTier);\n\n  const { name, tagCls } = appSumoConfig ?? levelConfig;\n\n  return (\n    <div\n      className={cn('shrink-0 rounded px-1.5 h-5 flex items-center justify-center text-xs', tagCls)}\n    >\n      {name}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/billing/LevelWithUpgrade.tsx",
    "content": "import { SubscriptionStatus, BillingProductLevel } from '@teable/openapi';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { AppSumoTier } from '../../hooks/useBillingLevelConfig';\nimport { useAppSumoTierConfig, useBillingLevelConfig } from '../../hooks/useBillingLevelConfig';\nimport { Level } from './Level';\nimport { Status } from './Status';\n\ninterface ILevelWithUpgradeProps {\n  spaceId?: string;\n  level?: BillingProductLevel;\n  status?: SubscriptionStatus;\n  withUpgrade?: boolean;\n  organization?: {\n    id: string;\n    name: string;\n  };\n  appSumoTier?: AppSumoTier;\n  onUpgradeClick?: () => void;\n}\n\nexport const LevelWithUpgrade = (props: ILevelWithUpgradeProps) => {\n  const { level, spaceId, withUpgrade, status, organization, appSumoTier, onUpgradeClick } = props;\n  const isEnterprise = level === BillingProductLevel.Enterprise;\n  const isAppSumo = appSumoTier != null;\n  const { t } = useTranslation('common');\n  const levelConfig = useBillingLevelConfig(level);\n  const appSumoConfig = useAppSumoTierConfig(appSumoTier);\n  const router = useRouter();\n\n  // Use AppSumo description if applicable, otherwise use level description\n  const description = appSumoConfig?.description ?? levelConfig.description;\n\n  const onClick = () => {\n    if (onUpgradeClick) {\n      onUpgradeClick();\n      return;\n    }\n    if (spaceId == null) return;\n\n    router.push({\n      pathname: '/space/[spaceId]/setting/plan',\n      query: { spaceId },\n    });\n  };\n\n  return (\n    <div className=\"flex shrink-0 items-center gap-x-1 text-sm\">\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger>\n            <Level level={level} appSumoTier={appSumoTier} />\n          </TooltipTrigger>\n          <TooltipContent hideWhenDetached={true} sideOffset={8}>\n            <p>{description}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      {status === SubscriptionStatus.Active && organization?.name && (\n        <span className=\"text-xs text-muted-foreground\">{organization.name}</span>\n      )}\n      <Status status={status} />\n      {withUpgrade && !isEnterprise && !isAppSumo && (\n        <Button\n          size=\"xs\"\n          variant=\"ghost\"\n          className=\"text-violet-500 hover:text-violet-500\"\n          onClick={onClick}\n        >\n          {t('actions.upgrade')}\n        </Button>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/billing/Status.tsx",
    "content": "import { SubscriptionStatus } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\ninterface IStatusProps {\n  status?: SubscriptionStatus;\n}\n\nexport const Status = (props: IStatusProps) => {\n  const { status = SubscriptionStatus.Active } = props;\n  const { t } = useTranslation('common');\n\n  const config = useMemo(() => {\n    return {\n      [SubscriptionStatus.Active]: {\n        name: t('billing.status.active'),\n        tagCls: 'bg-green-100 dark:bg-green-700 text-green-500 dark:text-white',\n      },\n      [SubscriptionStatus.Canceled]: {\n        name: t('billing.status.canceled'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.Incomplete]: {\n        name: t('billing.status.incomplete'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.IncompleteExpired]: {\n        name: t('billing.status.incompleteExpired'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.Trialing]: {\n        name: t('billing.status.trialing'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.PastDue]: {\n        name: t('billing.status.pastDue'),\n        tagCls: 'bg-red-100 dark:bg-red-700 text-red-500 dark:text-white',\n      },\n      [SubscriptionStatus.Unpaid]: {\n        name: t('billing.status.unpaid'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.Paused]: {\n        name: t('billing.status.paused'),\n        tagCls: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-white',\n      },\n      [SubscriptionStatus.SeatLimitExceeded]: {\n        name: t('billing.status.seatLimitExceeded'),\n        tagCls: 'bg-red-100 dark:bg-red-700 text-red-500 dark:text-white',\n      },\n    };\n  }, [t]);\n\n  if (status === SubscriptionStatus.Active) {\n    return null;\n  }\n\n  return (\n    <div className={cn('shrink-0 rounded px-2 py-px text-[13px] text-', config[status].tagCls)}>\n      {config[status].name}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/billing/UpgradeWrapper.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Role } from '@teable/core';\nimport { BillingProductLevel, getSpaceById, getSubscriptionSummary } from '@teable/openapi';\nimport { UsageLimitModalType, useUsageLimitModalStore } from '@teable/sdk/components/billing/store';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBase, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport type { Base } from '@teable/sdk/model';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useCallback, type ReactElement, cloneElement } from 'react';\nimport { useBaseUsage } from '../../hooks/useBaseUsage';\nimport { useBillingLevel } from '../../hooks/useBillingLevel';\nimport { useAppSumoTierConfig, useBillingLevelConfig } from '../../hooks/useBillingLevelConfig';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useIsCommunity } from '../../hooks/useIsCommunity';\nimport { useIsEE } from '../../hooks/useIsEE';\n\ninterface IUpgradeWrapperRenderProps {\n  badge: ReactElement | null;\n  needsUpgrade: boolean;\n  isCommunity: boolean;\n  currentLevel?: BillingProductLevel;\n}\n\ninterface IUpgradeWrapperProps {\n  children?: ReactElement | ((props: IUpgradeWrapperRenderProps) => ReactElement);\n  spaceId?: string;\n  baseId?: string;\n  targetBillingLevel?: BillingProductLevel;\n  onUpgradeClick?: () => void;\n}\n\nconst getBillingLevelWeight = (level?: BillingProductLevel): number => {\n  const levelMap: Record<BillingProductLevel, number> = {\n    [BillingProductLevel.Free]: 1,\n    [BillingProductLevel.Pro]: 2,\n    [BillingProductLevel.Business]: 3,\n    [BillingProductLevel.Enterprise]: 4,\n  };\n  return level ? levelMap[level] : 0;\n};\n\nconst isLevelSufficient = (\n  currentLevel?: BillingProductLevel,\n  targetLevel?: BillingProductLevel\n): boolean => {\n  if (!targetLevel) return true;\n  return getBillingLevelWeight(currentLevel) >= getBillingLevelWeight(targetLevel);\n};\n\nexport const UpgradeWrapper: React.FC<IUpgradeWrapperProps> = ({\n  children,\n  spaceId,\n  targetBillingLevel,\n  onUpgradeClick,\n}) => {\n  const isCloud = useIsCloud();\n  const isCommunity = useIsCommunity();\n  const isEE = useIsEE();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const base = useBase() as Base | undefined;\n  const { t } = useTranslation('common');\n  const { openModal } = useUsageLimitModalStore();\n  spaceId = base?.spaceId ?? spaceId;\n  const baseId = base?.id;\n  // EE starts from business level\n  targetBillingLevel =\n    targetBillingLevel === BillingProductLevel.Pro && isEE\n      ? BillingProductLevel.Business\n      : targetBillingLevel;\n\n  const currentLevel = useBillingLevel(baseId ? { baseId } : { spaceId });\n\n  const { data: space } = useQuery({\n    queryKey: ReactQueryKeys.space(spaceId as string),\n    queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data),\n    enabled: Boolean(!baseId) && Boolean(spaceId),\n  });\n\n  // Check if user is AppSumo\n  const { data: subscriptionSummary } = useQuery({\n    queryKey: ReactQueryKeys.subscriptionSummary(spaceId as string),\n    queryFn: () => getSubscriptionSummary(spaceId as string).then((res) => res.data),\n    enabled: isCloud && Boolean(spaceId) && Boolean(!baseId),\n  });\n\n  const baseUsage = useBaseUsage({ disabled: !baseId });\n  const appSumoTier = subscriptionSummary?.appSumoTier ?? baseUsage?.appSumoTier;\n  const isAppSumo = Boolean(appSumoTier);\n\n  // Get the target tier for AppSumo users based on target billing level\n  const targetAppSumoTier = useMemo(() => {\n    if (targetBillingLevel === BillingProductLevel.Business) {\n      return 3; // Tier 3 is the minimum for Business level\n    }\n    if (targetBillingLevel === BillingProductLevel.Pro) {\n      return 1; // Tier 1 is Pro level\n    }\n    return undefined;\n  }, [targetBillingLevel]);\n\n  const targetAppSumoTierConfig = useAppSumoTierConfig(\n    targetAppSumoTier as 1 | 2 | 3 | 4 | undefined\n  );\n\n  const isLevelSufficientMemo = useMemo(() => {\n    return isLevelSufficient(currentLevel, targetBillingLevel);\n  }, [currentLevel, targetBillingLevel]);\n\n  const isSpaceOwner = useMemo(() => {\n    if (baseId) {\n      return base?.role === Role.Owner;\n    }\n    return space?.role === Role.Owner;\n  }, [baseId, base?.role, space?.role]);\n\n  // In template/share preview mode, don't show upgrade prompts\n  // Allow all features to be displayed (similar to template preview)\n  const needsUpgrade =\n    !isReadOnlyPreview &&\n    currentLevel &&\n    !isLevelSufficientMemo &&\n    !!targetBillingLevel &&\n    !isCommunity;\n\n  const handleUpgradeClick = useCallback(() => {\n    if (onUpgradeClick) {\n      onUpgradeClick();\n      return;\n    }\n\n    // For AppSumo users, redirect to AppSumo account\n    if (isAppSumo) {\n      window.open('https://appsumo.com/account/products/', '_blank');\n      return;\n    }\n\n    if (isCloud) {\n      if (!spaceId) {\n        toast.error('Base ID is required for billing upgrade');\n        return;\n      }\n\n      if (!isSpaceOwner) {\n        toast.warning(t('billing.spaceSubscriptionModal.description'));\n        return;\n      }\n\n      openModal(UsageLimitModalType.Upgrade);\n    } else {\n      window.open('https://app.teable.ai/public/pricing?host=self-hosted', '_blank');\n    }\n  }, [isCloud, isAppSumo, spaceId, isSpaceOwner, t, openModal, onUpgradeClick]);\n\n  const billingConfig = useBillingLevelConfig(targetBillingLevel);\n\n  const badge = useMemo(() => {\n    if (!needsUpgrade) {\n      return null;\n    }\n\n    const badgeName =\n      isAppSumo && targetAppSumoTierConfig ? targetAppSumoTierConfig.name : billingConfig.name;\n    const badgeCls =\n      isAppSumo && targetAppSumoTierConfig\n        ? targetAppSumoTierConfig.tagCls\n        : billingConfig.upgradeTagCls;\n\n    return (\n      <span\n        className={`cursor-pointer rounded px-1 text-[10px] leading-[16px] ${badgeCls}`}\n        onClick={handleUpgradeClick}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter' || e.key === ' ') {\n            e.preventDefault();\n            handleUpgradeClick();\n          }\n        }}\n        tabIndex={0}\n        role=\"button\"\n        aria-label={`Upgrade to ${badgeName}`}\n      >\n        {badgeName}\n      </span>\n    );\n  }, [needsUpgrade, isAppSumo, targetAppSumoTierConfig, billingConfig, handleUpgradeClick]);\n\n  if (typeof children === 'function') {\n    const element = children({\n      badge,\n      needsUpgrade: Boolean(needsUpgrade),\n      isCommunity,\n      currentLevel,\n    });\n    return cloneElement(element, {\n      onClickCapture: (e: Event) => {\n        if (!needsUpgrade) return;\n        e.preventDefault();\n        e.stopPropagation();\n        handleUpgradeClick();\n      },\n    });\n  }\n\n  if (!children) {\n    return badge;\n  }\n\n  // In template/share preview mode, always show children without upgrade prompts\n  if (isReadOnlyPreview) {\n    return children;\n  }\n\n  if (isCommunity) {\n    return null;\n  }\n\n  if (isLevelSufficientMemo) {\n    return children;\n  }\n\n  return cloneElement(children, {\n    onClickCapture: (e: Event) => {\n      if (!needsUpgrade) return;\n      e.preventDefault();\n      e.stopPropagation();\n      handleUpgradeClick();\n    },\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/billing/UsageLimitModal.tsx",
    "content": "import { UsageLimitModalType, useUsageLimitModalStore } from '@teable/sdk/components/billing/store';\nimport { useBase } from '@teable/sdk/hooks';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n  Button,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\nexport const UsageLimitModal = () => {\n  const base = useBase();\n  const router = useRouter();\n  const { t } = useTranslation('common');\n  const { modalType, modalOpen, toggleModal } = useUsageLimitModalStore();\n  const isUpgrade = modalType === UsageLimitModalType.Upgrade;\n\n  const description = useMemo(() => {\n    if (!isUpgrade) {\n      return t('billing.userLimitExceededDescription');\n    }\n    return t('billing.overLimitsDescription');\n  }, [isUpgrade, t]);\n\n  if (base == null) return null;\n\n  const { spaceId } = base;\n\n  const onClick = () => {\n    if (isUpgrade) {\n      router.push({\n        pathname: '/space/[spaceId]/setting/plan',\n        query: { spaceId },\n      });\n    } else {\n      router.push('/admin/user');\n    }\n    toggleModal(false);\n  };\n\n  return (\n    <Dialog open={modalOpen} onOpenChange={toggleModal}>\n      <DialogContent\n        className=\"sm:max-w-[425px]\"\n        closeable={isUpgrade}\n        onInteractOutside={(e) => e.preventDefault()}\n        onEscapeKeyDown={(e) => e.preventDefault()}\n      >\n        <DialogHeader>\n          <DialogTitle>{t('billing.overLimits')}</DialogTitle>\n          <DialogDescription className=\"pt-1\">{description}</DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button size=\"sm\" onClick={onClick}>\n            {isUpgrade ? t('actions.upgrade') : t('actions.confirm')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/CollaboratorsDialog.tsx",
    "content": "import type { CollaboratorItem } from '@teable/openapi';\nimport { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\n\ninterface ICollaboratorsDialogProps {\n  children: React.ReactNode;\n  list: CollaboratorItem[];\n  total: number;\n  hasNextPage?: boolean;\n  fetchNextPage: () => void;\n  isLoading: boolean;\n  title: string;\n  alert?: React.ReactNode;\n  content: React.ReactNode;\n}\n\ninterface ICollaboratorsDialogRef {\n  open: () => void;\n  close: () => void;\n}\nexport const CollaboratorsDialog = forwardRef<ICollaboratorsDialogRef, ICollaboratorsDialogProps>(\n  ({ children, title, alert, content }: ICollaboratorsDialogProps, ref) => {\n    const [open, setOpen] = useState(false);\n\n    useImperativeHandle(ref, () => ({\n      open: () => setOpen(true),\n      close: () => setOpen(false),\n    }));\n\n    return (\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent className=\"flex h-[85%] max-w-3xl flex-col gap-4 rounded-xl border p-6 shadow-lg\">\n          <h2 className=\"text-base font-semibold\">{title}</h2>\n          {alert}\n          {content}\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nCollaboratorsDialog.displayName = 'CollaboratorsDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/ShareBaseContent.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { canManageRole, hasPermission, Role, type IBaseRole, type IRole } from '@teable/core';\nimport type { CollaboratorItem, IAddCollaborator } from '@teable/openapi';\nimport {\n  addBaseCollaborator,\n  CollaboratorType,\n  createBaseInvitationLink,\n  deleteBaseCollaborator,\n  deleteBaseInvitationLink,\n  emailBaseInvitation,\n  getBaseCollaboratorList,\n  listBaseInvitationLink,\n  PrincipalType,\n  updateBaseCollaborator,\n  updateBaseInvitationLink,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Badge } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { useFilteredRoleStatic } from '../../collaborator-manage/base/useFilteredRoleStatic';\nimport { CollaboratorsDialog } from './CollaboratorsDialog';\nimport { AuthorityTips } from './common/AuthorityTips';\nimport { CollaboratorButton } from './common/CollaboratorButton';\nimport { CollaboratorTable } from './common/CollaboratorTable';\nimport { DebounceInput } from './common/DebounceInput';\nimport { EmailContent } from './common/EmailContent';\nimport { ShareHeader } from './common/Header';\nimport { InviteEmailButton } from './common/InviteEmailButton';\nimport { InviteLinkButton } from './common/InviteLinkButton';\nimport { InviteOrgButton } from './common/InviteOrgButton';\nimport { LinkContent } from './common/LinkContent';\nimport { OrgContent } from './common/OrgContent';\n\nconst MEMBERS_PER_PAGE = 50;\nexport const ShareBaseContent = ({\n  baseId,\n  baseName,\n  role: userRole,\n  enabledAuthority,\n  onClose,\n}: {\n  baseId: string;\n  baseName: string;\n  role: IRole;\n  enabledAuthority?: boolean;\n  onClose: () => void;\n}) => {\n  const router = useRouter();\n  const { user } = useSession();\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const { t } = useTranslation(['common', 'space', 'table']) as any;\n\n  const [tabType, setTabType] = useState<\n    'email' | 'link' | 'collaborators' | 'organization' | undefined\n  >();\n\n  const queryClient = useQueryClient();\n  const [search, setSearch] = useState('');\n  const {\n    data,\n    hasNextPage,\n    fetchNextPage,\n    isLoading: isListLoading,\n  } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.baseCollaboratorList(baseId, { includeSystem: true, search }),\n    staleTime: 1000,\n    refetchOnWindowFocus: false,\n    queryFn: ({ queryKey, pageParam }) =>\n      getBaseCollaboratorList(queryKey[1], {\n        ...queryKey[2],\n        skip: pageParam * MEMBERS_PER_PAGE,\n        take: MEMBERS_PER_PAGE,\n      }).then((res) => res.data),\n    initialPageParam: 0,\n    getNextPageParam: (lastPage, pages) => {\n      const allCollaborators = pages.flatMap((page) => page.collaborators);\n      return allCollaborators.length >= lastPage.total ? undefined : pages.length;\n    },\n  });\n\n  const total = data?.pages?.[0]?.total || 0;\n  const collaborators = useMemo(() => {\n    return data?.pages.flatMap((page) => page.collaborators);\n  }, [data]);\n\n  const hasInviteLinkPermission = hasPermission(userRole, 'base|invite_link');\n  const { data: linkList } = useQuery({\n    queryKey: ['invite-link-list', baseId],\n    queryFn: ({ queryKey }) => listBaseInvitationLink(queryKey[1]).then((res) => res.data),\n    enabled: hasInviteLinkPermission,\n  });\n\n  const { mutate: emailInvitation, isPending: emailInvitationLoading } = useMutation({\n    mutationFn: emailBaseInvitation,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseCollaboratorList(baseId),\n      });\n      onClose();\n      toast.success(t('invite.sendInvitationSuccess'));\n    },\n  });\n\n  const { mutate: createInviteLinkRequest, isPending: createInviteLinkLoading } = useMutation({\n    mutationFn: createBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const { mutate: updateInviteLink, isPending: updateInviteLinkLoading } = useMutation({\n    mutationFn: updateBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const { mutate: deleteInviteLink, isPending: deleteInviteLinkLoading } = useMutation({\n    mutationFn: deleteBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const { mutate: deleteCollaborator, isPending: deleteCollaboratorLoading } = useMutation({\n    mutationFn: deleteBaseCollaborator,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseCollaboratorList(baseId) });\n    },\n  });\n\n  const { mutate: updateCollaborator, isPending: updateCollaboratorLoading } = useMutation({\n    mutationFn: updateBaseCollaborator,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseCollaboratorList(baseId) });\n    },\n  });\n\n  const { mutate: addCollaborators, isPending: addCollaboratorsLoading } = useMutation({\n    mutationFn: async ({\n      role,\n      collaborators,\n    }: {\n      role: IRole;\n      collaborators: IAddCollaborator[];\n    }) => {\n      const userCollaborators = collaborators.filter((c) => c.principalType === PrincipalType.User);\n      const departmentCollaborators = collaborators.filter(\n        (c) => c.principalType === PrincipalType.Department\n      );\n      if (userCollaborators.length > 0) {\n        await addBaseCollaborator(baseId, {\n          collaborators: userCollaborators,\n          role: role as IBaseRole,\n        });\n      }\n      if (departmentCollaborators.length > 0) {\n        await addBaseCollaborator(baseId, {\n          collaborators: departmentCollaborators,\n          role: role as IBaseRole,\n        });\n      }\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseCollaboratorList(baseId) });\n      onClose();\n      toast.success(t('invite.sendInvitationSuccess'));\n    },\n  });\n\n  const toAuthorityManage = () => {\n    router.push({\n      pathname: '/base/[baseId]/authority-matrix',\n      query: { baseId },\n    });\n  };\n\n  const linkListCount = linkList?.length || 0;\n  const onBack = () => setTabType(undefined);\n  const defaultRole = userRole === Role.Owner ? Role.Creator : userRole;\n  const filteredRoleStatic = useFilteredRoleStatic(defaultRole);\n\n  if (tabType === 'link') {\n    return (\n      <LinkContent\n        list={linkList}\n        defaultRole={defaultRole}\n        isCreateLoading={createInviteLinkLoading}\n        isUpdateLoading={updateInviteLinkLoading}\n        isDeleteLoading={deleteInviteLinkLoading}\n        onCreate={(role) =>\n          createInviteLinkRequest({\n            baseId,\n            createBaseInvitationLinkRo: { role: role as IBaseRole },\n          })\n        }\n        onUpdate={(invitationId, role) =>\n          updateInviteLink({\n            invitationId,\n            updateBaseInvitationLinkRo: { role: role as IBaseRole },\n            baseId,\n          })\n        }\n        onDelete={(invitationId) => deleteInviteLink({ invitationId, baseId })}\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  if (tabType === 'email') {\n    return (\n      <EmailContent\n        defaultRole={defaultRole}\n        isCreateLoading={emailInvitationLoading}\n        onCreate={(ro) => emailInvitation({ baseId, emailBaseInvitationRo: ro })}\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  if (tabType === 'organization') {\n    return (\n      <OrgContent\n        defaultRole={defaultRole}\n        isCreateLoading={addCollaboratorsLoading}\n        onCreate={(role, members) =>\n          addCollaborators({ role: role as IRole, collaborators: members })\n        }\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  const getPermissions = (item: CollaboratorItem) => {\n    const canManage = canManageRole(userRole, item.role);\n    const isMe = item.type === PrincipalType.User && item.userId === user.id;\n    const isOwner = userRole === Role.Owner;\n    const canOperator = canManage || isMe || isOwner;\n    return {\n      canUpdateRole: item.resourceType !== CollaboratorType.Space && canOperator,\n      canDelete: canOperator,\n      showDelete: item.resourceType === CollaboratorType.Base && canOperator,\n    };\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <ShareHeader\n        title={t('invite.base.title', { baseName })}\n        description={\n          <Trans ns=\"common\" i18nKey={'invite.base.desc'} count={total} components={{ b: <b /> }} />\n        }\n      />\n      {enabledAuthority && <AuthorityTips onViewDetail={toAuthorityManage} />}\n      <div className=\"flex flex-col gap-5\">\n        <InviteEmailButton onClick={() => setTabType('email')} />\n        {user?.organization && (\n          <div className=\"space-y-2\">\n            <p className=\"text-sm font-semibold\">{t('invite.addOrgCollaborator.title')}</p>\n            <InviteOrgButton onClick={() => setTabType('organization')} />\n          </div>\n        )}\n        {hasInviteLinkPermission && (\n          <div className=\"relative space-y-2\">\n            <p className=\"text-sm font-semibold\">{t('invite.dialog.tabLink')}</p>\n            <InviteLinkButton\n              className=\"box-content -translate-x-2 px-2 py-0\"\n              linkListCount={linkListCount}\n              onClick={() => setTabType('link')}\n            />\n          </div>\n        )}\n        <div className=\"space-y-2\">\n          <p className=\"text-sm font-semibold\">{t('invite.dialog.baseTitle')}</p>\n          <CollaboratorsDialog\n            title={t('invite.base.baseTitleWithCount', { count: total })}\n            alert={\n              enabledAuthority ? <AuthorityTips onViewDetail={toAuthorityManage} /> : undefined\n            }\n            list={collaborators || []}\n            total={total}\n            hasNextPage={hasNextPage}\n            fetchNextPage={fetchNextPage}\n            isLoading={false}\n            content={\n              <div className=\"flex flex-1 flex-col gap-2 overflow-hidden\">\n                <DebounceInput\n                  value={search}\n                  onChange={(value) => setSearch(value)}\n                  placeholder={t('invite.base.collaboratorSearchPlaceholder')}\n                />\n                <CollaboratorTable\n                  className=\"flex-1 overflow-y-auto rounded-md border\"\n                  list={collaborators || []}\n                  total={total}\n                  hasNextPage={hasNextPage}\n                  fetchNextPage={fetchNextPage}\n                  isLoading={isListLoading}\n                  updateRoleLoading={updateCollaboratorLoading}\n                  deleteLoading={deleteCollaboratorLoading}\n                  filteredRoleStatic={filteredRoleStatic}\n                  onUpdateRole={\n                    enabledAuthority\n                      ? undefined\n                      : (role, item) => {\n                          updateCollaborator({\n                            baseId,\n                            updateBaseCollaborateRo: {\n                              principalId:\n                                item.type === PrincipalType.User ? item.userId : item.departmentId,\n                              principalType: item.type,\n                              role: role as IBaseRole,\n                            },\n                          });\n                        }\n                  }\n                  onDelete={(item) => {\n                    deleteCollaborator({\n                      baseId,\n                      deleteBaseCollaboratorRo: {\n                        principalId:\n                          item.type === PrincipalType.User ? item.userId : item.departmentId,\n                        principalType: item.type,\n                      },\n                    });\n                  }}\n                  getPermissions={getPermissions}\n                  renderTips={(item) => {\n                    return (\n                      item.resourceType === CollaboratorType.Space && (\n                        <Badge className=\"ml-2 text-xs font-normal\" variant={'outline'}>\n                          {t('noun.space')}\n                        </Badge>\n                      )\n                    );\n                  }}\n                />\n              </div>\n            }\n          >\n            <CollaboratorButton\n              collaborators={collaborators?.slice(0, 4) || []}\n              total={total}\n              onClick={() => setTabType('collaborators')}\n              className=\"box-content -translate-x-2 px-2 py-0\"\n            />\n          </CollaboratorsDialog>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/ShareBaseDialog.tsx",
    "content": "import { UserPlus } from '@teable/icons';\nimport { useBase } from '@teable/sdk/hooks';\nimport { Button, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef, useState } from 'react';\nimport { PublishBaseDialog } from '../../../blocks/table/table-header/publish-base/PublishBaseDialog';\nimport { ShareBaseContent } from './ShareBaseContent';\n\ninterface IShareBaseDialogProps {\n  children?: React.ReactNode;\n}\n\nexport const ShareBaseDialog = (props: IShareBaseDialogProps) => {\n  const { children } = props;\n  const base = useBase();\n  const [open, setOpen] = useState(false);\n  const [publishOpen, setPublishOpen] = useState(false);\n  const publishTriggerRef = useRef<HTMLButtonElement>(null);\n  const onClose = () => setOpen(false);\n  const { t } = useTranslation('space');\n\n  useEffect(() => {\n    if (publishOpen && publishTriggerRef.current) {\n      publishTriggerRef.current.click();\n      setPublishOpen(false);\n    }\n  }, [publishOpen]);\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>\n          {children ? (\n            children\n          ) : (\n            <Button variant=\"ghost\" size=\"xs\" className=\"w-full justify-start text-sm font-normal\">\n              <UserPlus className=\"size-4 shrink-0\" />\n              <p className=\"truncate\">{t('action.invite')}</p>\n            </Button>\n          )}\n        </DialogTrigger>\n        <DialogContent className=\"max-h-[90vh] max-w-full overflow-y-auto rounded-xl px-7 md:w-[480px]\">\n          <ShareBaseContent\n            baseId={base.id}\n            baseName={base.name}\n            role={base.role}\n            enabledAuthority={base.enabledAuthority}\n            onClose={onClose}\n          />\n        </DialogContent>\n      </Dialog>\n\n      <PublishBaseDialog onClose={onClose} closeOnSuccess>\n        <button ref={publishTriggerRef} className=\"hidden\" />\n      </PublishBaseDialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/ShareBasePopover.tsx",
    "content": "import type { IRole } from '@teable/core';\nimport { Popover, PopoverTrigger, PopoverContent } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\nimport { ShareBaseContent } from './ShareBaseContent';\n\ninterface IShareBasePopoverProps {\n  base: {\n    name: string;\n    role: IRole;\n    id: string;\n    enabledAuthority?: boolean;\n  };\n  children: React.ReactNode;\n}\n\nexport const ShareBasePopover = (props: IShareBasePopoverProps) => {\n  const { base, children } = props;\n  const [open, setOpen] = useState(false);\n  const onClose = () => setOpen(false);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen} modal>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent\n        className=\"h-auto w-[480px] max-w-[100vw] rounded-xl border p-6 shadow-lg\"\n        align=\"end\"\n      >\n        <ShareBaseContent\n          baseId={base.id}\n          baseName={base.name}\n          role={base.role}\n          enabledAuthority={base.enabledAuthority}\n          onClose={onClose}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/AuthorityTips.tsx",
    "content": "import { Button } from '@teable/ui-lib/shadcn';\nimport { ShieldHalf } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\n\ninterface IAuthorityTipsProps {\n  onViewDetail?: () => void;\n}\nexport const AuthorityTips = (props: IAuthorityTipsProps) => {\n  const { onViewDetail } = props;\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"relative flex flex-col gap-2 rounded-lg border bg-surface p-4\">\n      <div className=\"flex items-center gap-2\">\n        <ShieldHalf size={16} />\n        <p className=\"text-sm font-medium\">{t('invite.authority.title')}</p>\n      </div>\n      <p className=\"pl-6 text-xs text-muted-foreground\">{t('invite.authority.description')}</p>\n      <Button variant=\"outline\" size=\"xs\" className=\"absolute right-4 top-2\" onClick={onViewDetail}>\n        {t('invite.authority.viewDetail')}\n      </Button>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/CollaboratorButton.tsx",
    "content": "import { ChevronRight } from '@teable/icons';\nimport type { CollaboratorItem } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef } from 'react';\nimport { PreviewCollaborators } from './PreviewCollaborators';\n\nexport const CollaboratorButton = forwardRef<\n  HTMLDivElement,\n  {\n    className?: string;\n    collaborators: CollaboratorItem[];\n    onClick: () => void;\n    total: number;\n  }\n>(({ className, collaborators, total, onClick }, ref) => {\n  const { t } = useTranslation('common');\n  return (\n    // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions\n    <div\n      ref={ref}\n      onClick={onClick}\n      className={cn(\n        'inline-flex h-12 w-full cursor-pointer items-center justify-between gap-2 whitespace-nowrap rounded-md p-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',\n        className\n      )}\n    >\n      <div className=\"flex items-center gap-2\">\n        <PreviewCollaborators collaborators={collaborators || []} total={total} />\n        <p>{t('invite.dialog.haveAccess')}</p>\n      </div>\n      <ChevronRight className=\"size-4 text-muted-foreground\" />\n    </div>\n  );\n});\n\nCollaboratorButton.displayName = 'CollaboratorButton';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/CollaboratorTable.tsx",
    "content": "import type { IRole } from '@teable/core';\nimport { PrincipalType } from '@teable/openapi';\nimport type { CollaboratorItem } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  cn,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Loader, LogOut } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { Collaborator } from '../../../collaborator-manage/components/Collaborator';\nimport { RoleSelect } from '../../../collaborator-manage/components/RoleSelect';\nimport type { IRoleStatic } from '../../../collaborator-manage/types';\nimport { useRoleStatic } from '../../../collaborator-manage/useRoleStatic';\n\ninterface ICollaboratorTableProps {\n  className?: string;\n  list: CollaboratorItem[];\n  total: number;\n  hasNextPage?: boolean;\n  fetchNextPage: () => void;\n  isLoading: boolean;\n  updateRoleLoading: boolean;\n  deleteLoading: boolean;\n  filteredRoleStatic?: IRoleStatic[];\n  onUpdateRole?: (role: IRole, item: CollaboratorItem) => void;\n  onDelete: (item: CollaboratorItem) => void;\n  getPermissions: (item: CollaboratorItem) => {\n    canUpdateRole: boolean;\n    canDelete: boolean;\n    showDelete: boolean;\n  };\n  getFilteredRoleStatic?: (item: CollaboratorItem) => IRoleStatic[];\n  renderTips?: (item: CollaboratorItem) => React.ReactNode;\n}\n\nexport const CollaboratorTable = (props: ICollaboratorTableProps) => {\n  const {\n    className,\n    list,\n    total,\n    getPermissions,\n    hasNextPage,\n    fetchNextPage,\n    isLoading,\n    updateRoleLoading,\n    deleteLoading,\n    filteredRoleStatic,\n    onUpdateRole,\n    onDelete,\n    renderTips,\n    getFilteredRoleStatic,\n  } = props;\n  const { t } = useTranslation('common');\n  const roleStatic = useRoleStatic();\n\n  return (\n    <div className={cn('flex min-h-0 flex-1 flex-col gap-4', className)}>\n      <div className=\"flex-1 overflow-y-auto rounded-md border\">\n        <Table>\n          <TableHeader className=\"sticky top-0 z-10 bg-background\">\n            <TableRow>\n              <TableHead className=\"px-4 font-normal\">{t('invite.table.collaborator')}</TableHead>\n              <TableHead className=\"px-4 font-normal\">\n                {t('invite.table.accessPermission')}\n              </TableHead>\n              <TableHead className=\"w-[156px] px-4 font-normal\">\n                {t('invite.table.joinAt')}\n              </TableHead>\n              <TableHead className=\"w-[100px] px-4 font-normal\">{t('actions.title')}</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {list.map((item) => {\n              const isUser = item.type === PrincipalType.User;\n              const { canUpdateRole, canDelete, showDelete } = getPermissions(item);\n              return (\n                <TableRow className=\"h-14\" key={isUser ? item.userId : item.departmentId}>\n                  <TableCell className=\"px-4\">\n                    <Collaborator\n                      className=\"items-center\"\n                      item={\n                        isUser\n                          ? {\n                              type: PrincipalType.User as const,\n                              name: item.userName,\n                              email: item.email,\n                              avatar: item.avatar,\n                            }\n                          : {\n                              type: PrincipalType.Department as const,\n                              name: item.departmentName,\n                            }\n                      }\n                      tips={renderTips?.(item)}\n                    />\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    <RoleSelect\n                      className=\"text-[13px]\"\n                      value={item.role}\n                      options={getFilteredRoleStatic?.(item) || filteredRoleStatic || roleStatic}\n                      disabled={updateRoleLoading || !onUpdateRole || !canUpdateRole}\n                      onChange={(role) => onUpdateRole?.(role, item)}\n                    />\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    <span className=\"text-sm text-muted-foreground\">\n                      {new Date(item.createdTime).toLocaleDateString()}\n                    </span>\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    {showDelete && (\n                      <TooltipProvider>\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              className=\"size-8 p-0 text-muted-foreground\"\n                              onClick={() => onDelete(item)}\n                              disabled={deleteLoading || !canDelete}\n                            >\n                              {deleteLoading ? (\n                                <Spin className=\"size-4\" />\n                              ) : (\n                                <LogOut className=\"size-4\" />\n                              )}\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            <p>{t('invite.dialog.collaboratorRemove')}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      </TooltipProvider>\n                    )}\n                  </TableCell>\n                </TableRow>\n              );\n            })}\n          </TableBody>\n        </Table>\n      </div>\n      {isLoading && (\n        <div className=\"flex w-full justify-center py-2\">\n          <Loader className=\"size-4 animate-spin\" />\n        </div>\n      )}\n      {hasNextPage && (\n        <div className=\"flex justify-center py-2\">\n          <Button variant=\"link\" size=\"sm\" onClick={() => fetchNextPage()}>\n            {t('actions.loadMore')} ({list.length} / {total})\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/DebounceInput.tsx",
    "content": "import type { InputProps } from '@teable/ui-lib/shadcn';\nimport { Input } from '@teable/ui-lib/shadcn';\nimport { debounce } from 'lodash';\nimport { useEffect, useMemo, useState } from 'react';\n\nexport const DebounceInput = ({\n  value,\n  onChange,\n  ...restProps\n}: {\n  value: string;\n  onChange: (value: string) => void;\n} & Omit<InputProps, 'value' | 'onChange'>) => {\n  const [search, setSearch] = useState<string>('');\n  const [isComposing, setIsComposing] = useState(false);\n\n  const setApplySearchDebounced = useMemo(() => {\n    return debounce(onChange, 200);\n  }, [onChange]);\n\n  useEffect(() => {\n    if (!isComposing) {\n      setApplySearchDebounced(search);\n    }\n  }, [search, isComposing, onChange, setApplySearchDebounced]);\n\n  return (\n    <Input\n      type=\"search\"\n      {...restProps}\n      value={search}\n      onChange={(e) => {\n        const value = e.target.value;\n        setSearch(value);\n      }}\n      onCompositionStart={() => setIsComposing(true)}\n      onCompositionEnd={() => setIsComposing(false)}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/EmailContent.tsx",
    "content": "import type { IBaseRole, IRole } from '@teable/core';\nimport { ChevronLeft, UserPlus, X } from '@teable/icons';\nimport { z } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { RoleSelect } from '../../../collaborator-manage/components/RoleSelect';\nimport type { IRoleStatic } from '../../../collaborator-manage/types';\n\nexport const EmailContent = ({\n  defaultRole,\n  isCreateLoading,\n  filteredRoleStatic,\n  onCreate,\n  onBack,\n}: {\n  defaultRole: IRole;\n  isCreateLoading: boolean;\n  onCreate: (ro: { emails: string[]; role: IBaseRole }) => void;\n  onBack: () => void;\n  filteredRoleStatic: IRoleStatic[];\n}) => {\n  const { t } = useTranslation('common');\n  const [selectedRole, setSelectedRole] = useState<IRole>(defaultRole);\n  const [email, setEmail] = useState<string>('');\n  const [inviteEmails, setInviteEmails] = useState<string[]>([]);\n\n  const emailInputChange = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.code === 'Backspace' && !email?.length) {\n      setInviteEmails(inviteEmails.slice(0, inviteEmails.length - 1));\n      return;\n    }\n    if (\n      ['Space', 'Enter'].includes(e.code) &&\n      email &&\n      z.string().email().safeParse(email).success &&\n      !inviteEmails.includes(email)\n    ) {\n      setEmail('');\n      setInviteEmails(inviteEmails.concat(email));\n      e.preventDefault();\n    }\n  };\n\n  const deleteEmail = (email: string) => {\n    setInviteEmails((inviteEmails) => inviteEmails.filter((inviteEmail) => email !== inviteEmail));\n  };\n\n  const isEmailInputValid = useMemo(() => z.string().email().safeParse(email).success, [email]);\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <Button\n        variant=\"link\"\n        size=\"sm\"\n        className=\"h-auto justify-start gap-2 p-0 text-sm font-semibold hover:no-underline\"\n        onClick={onBack}\n      >\n        <ChevronLeft className=\"size-4\" />\n        {t('invite.dialog.tabEmail')}\n      </Button>\n      <div className=\"space-y-4\">\n        <div className=\"flex h-20 flex-1 flex-wrap gap-1 overflow-y-auto rounded-md border border-input bg-background p-2 text-sm shadow-sm transition-colors\">\n          {inviteEmails.map((email) => (\n            <div\n              key={email}\n              className=\"flex h-6 items-center rounded-full border bg-secondary px-2 text-[13px]\"\n            >\n              {email}\n              <X\n                className=\"ml-1 cursor-pointer hover:opacity-70\"\n                onClick={() => deleteEmail(email)}\n              />\n            </div>\n          ))}\n          <input\n            className=\"h-6 flex-auto bg-background text-[13px] outline-none\"\n            placeholder={t('invite.dialog.emailPlaceholder')}\n            type=\"email\"\n            value={email}\n            onChange={(e) => setEmail(e.target.value)}\n            onBlur={() => {\n              if (isEmailInputValid) {\n                setInviteEmails(inviteEmails.concat(email));\n                setEmail('');\n              }\n            }}\n            onKeyDown={emailInputChange}\n          />\n        </div>\n        <div className=\"flex items-center justify-between\">\n          <RoleSelect\n            value={selectedRole}\n            options={filteredRoleStatic}\n            onChange={(role) => setSelectedRole(role as IBaseRole)}\n          />\n          <Button\n            size=\"sm\"\n            className=\"text-sm font-normal\"\n            disabled={inviteEmails.length === 0 || isCreateLoading}\n            onClick={() =>\n              onCreate({\n                emails: inviteEmails,\n                role: selectedRole as IBaseRole,\n              })\n            }\n          >\n            {isCreateLoading ? <Spin className=\"size-4\" /> : <UserPlus className=\"size-4\" />}\n            {t('invite.dialog.emailSend')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/Header.tsx",
    "content": "interface IShareHeaderProps {\n  title: string;\n  description: React.ReactNode;\n}\nexport const ShareHeader = (props: IShareHeaderProps) => {\n  const { title, description } = props;\n  return (\n    <div className=\"flex flex-col items-start gap-1 self-stretch\">\n      <p className=\"text-lg font-semibold leading-normal\">{title}</p>\n      <p className=\"text-xs leading-normal text-muted-foreground\">{description}</p>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/InviteEmailButton.tsx",
    "content": "import { useTranslation } from 'next-i18next';\n\nexport const InviteEmailButton = ({ onClick }: { onClick: () => void }) => {\n  const { t } = useTranslation('common');\n  return (\n    // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions\n    <div\n      className=\"flex h-9 cursor-pointer items-center rounded-md border border-input px-3 text-sm text-muted-foreground hover:bg-accent\"\n      onClick={onClick}\n    >\n      {t('invite.dialog.tabEmail')}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/InviteLinkButton.tsx",
    "content": "import { ChevronRight } from '@teable/icons';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\nexport const InviteLinkButton = ({\n  className,\n  linkListCount,\n  onClick,\n}: {\n  className?: string;\n  linkListCount: number;\n  onClick: () => void;\n}) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <Button\n      variant=\"ghost\"\n      className={cn(\n        'flex w-full justify-between font-normal shadow-none',\n        linkListCount === 0 && 'text-muted-foreground',\n        className\n      )}\n      onClick={onClick}\n    >\n      {linkListCount > 0\n        ? `${linkListCount} ${t('invite.dialog.linkTitle')}`\n        : t('invite.dialog.noInviteLinks')}\n      <ChevronRight className=\"size-4 text-muted-foreground\" />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/InviteOrgButton.tsx",
    "content": "import { Plus } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\nexport const InviteOrgButton = ({ onClick }: { onClick: () => void }) => {\n  const { t } = useTranslation('common');\n  return (\n    <Button variant=\"outline\" size=\"sm\" onClick={onClick}>\n      <Plus className=\"size-4\" />\n      {t('invite.addOrgCollaborator.title')}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/LinkContent.tsx",
    "content": "import type { IBaseRole, IRole } from '@teable/core';\nimport { ChevronLeft, UserPlus } from '@teable/icons';\nimport type { ListSpaceInvitationLinkVo } from '@teable/openapi';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button, Separator } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { InviteLinkItem } from '../../../collaborator-manage/components/InviteLinkItem';\nimport { RoleSelect } from '../../../collaborator-manage/components/RoleSelect';\nimport type { IRoleStatic } from '../../../collaborator-manage/types';\n\ninterface ILinkContentProps {\n  list?: ListSpaceInvitationLinkVo;\n  defaultRole: IRole;\n  filteredRoleStatic: IRoleStatic[];\n  isCreateLoading?: boolean;\n  isUpdateLoading?: boolean;\n  isDeleteLoading?: boolean;\n  onCreate: (role: IRole) => void;\n  onUpdate: (invitationId: string, role: IRole) => void;\n  onDelete: (invitationId: string) => void;\n  onBack: () => void;\n}\nexport const LinkContent = ({\n  list,\n  defaultRole,\n  filteredRoleStatic,\n  isCreateLoading,\n  isUpdateLoading,\n  isDeleteLoading,\n  onCreate,\n  onUpdate,\n  onDelete,\n  onBack,\n}: ILinkContentProps) => {\n  const [selectedRole, setSelectedRole] = useState<IRole>(defaultRole);\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"flex min-w-0 flex-col gap-4\">\n      <Button\n        variant=\"link\"\n        size=\"sm\"\n        className=\"h-auto justify-start gap-2 p-0 text-sm font-semibold hover:no-underline\"\n        onClick={onBack}\n      >\n        <ChevronLeft className=\"size-4\" />\n        {t('invite.dialog.tabLink')}\n      </Button>\n      <div className=\"space-y-2\">\n        <div className=\"flex flex-col gap-2\">\n          <p className=\"text-sm\">{t('invite.dialog.linkDescription')}</p>\n        </div>\n        <div className=\"flex items-center justify-between\">\n          <RoleSelect\n            value={selectedRole}\n            options={filteredRoleStatic}\n            onChange={(role) => setSelectedRole(role as IBaseRole)}\n          />\n          <Button\n            size=\"sm\"\n            className=\"text-sm font-normal\"\n            disabled={isCreateLoading}\n            onClick={() => onCreate(selectedRole)}\n          >\n            {isCreateLoading ? <Spin className=\"size-4\" /> : <UserPlus className=\"size-4\" />}\n            {t('invite.dialog.linkSend')}\n          </Button>\n        </div>\n      </div>\n      {list && list.length > 0 && (\n        <>\n          <Separator />\n          <div>\n            <p className=\"mb-2 text-sm font-medium\">{t('invite.dialog.linkTitle')}</p>\n            <div className=\"space-y-3\">\n              {list.map((item) => (\n                <InviteLinkItem\n                  key={item.invitationId}\n                  url={item.inviteUrl}\n                  createdTime={item.createdTime}\n                  onDelete={() => onDelete(item.invitationId)}\n                  deleteDisabled={isDeleteLoading}\n                >\n                  <RoleSelect\n                    value={item.role}\n                    options={filteredRoleStatic}\n                    disabled={isUpdateLoading}\n                    onChange={(role) => onUpdate(item.invitationId, role)}\n                  />\n                </InviteLinkItem>\n              ))}\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/OrgContent.tsx",
    "content": "import type { IRole } from '@teable/core';\nimport { Building2, ChevronLeft, Plus, UserPlus, X } from '@teable/icons';\nimport { PrincipalType } from '@teable/openapi';\nimport { MemberSelectorDialog, UserAvatar } from '@teable/sdk/components';\nimport type { IMemberSelectorDialogRef, ISelectedMember } from '@teable/sdk/components';\nimport { TreeNodeType } from '@teable/sdk/components/member-selector/types';\nimport { Button, ScrollArea } from '@teable/ui-lib/shadcn';\nimport { Loader } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useRef, useState } from 'react';\nimport { RoleSelect } from '../../../collaborator-manage/components/RoleSelect';\nimport type { IRoleStatic } from '../../../collaborator-manage/types';\n\ninterface IOrgContentProps {\n  defaultRole: IRole;\n  onBack: () => void;\n  filteredRoleStatic: IRoleStatic[];\n  isCreateLoading: boolean;\n  onCreate: (role: IRole, members: { principalId: string; principalType: PrincipalType }[]) => void;\n}\nexport const OrgContent = ({\n  defaultRole,\n  onBack,\n  filteredRoleStatic,\n  isCreateLoading,\n  onCreate,\n}: IOrgContentProps) => {\n  const { t } = useTranslation('common');\n  const [selectedRole, setSelectedRole] = useState<IRole>(defaultRole);\n  const [selectedMembers, setSelectedMembers] = useState<ISelectedMember[]>([]);\n  const memberSelectorRef = useRef<IMemberSelectorDialogRef>(null);\n  const onLoadData = useCallback(() => {\n    return selectedMembers;\n  }, [selectedMembers]);\n\n  const deleteMember = (id: string) => {\n    setSelectedMembers(selectedMembers.filter((m) => m.id !== id));\n  };\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <Button\n        variant=\"link\"\n        size=\"sm\"\n        className=\"h-auto justify-start gap-2 p-0 text-sm font-semibold hover:no-underline\"\n        onClick={onBack}\n      >\n        <ChevronLeft className=\"size-4\" />\n        {t('invite.addOrgCollaborator.title')}\n      </Button>\n      <div className=\"space-y-4\">\n        <div className=\"relative\">\n          <div className=\"flex h-20 flex-1 flex-wrap gap-1 rounded-md border border-input bg-background p-2 text-sm shadow-sm transition-colors\">\n            <ScrollArea className=\"size-full\">\n              <div className=\"flex flex-1 flex-wrap gap-1 text-sm transition-colors\">\n                {selectedMembers.map((member) => (\n                  <div\n                    key={member.id}\n                    className=\"flex h-6 items-center gap-1 rounded-full border bg-secondary pr-2 text-[13px]\"\n                  >\n                    {member.data.type === TreeNodeType.USER ? (\n                      <UserAvatar\n                        avatar={member.data.avatar}\n                        name={member.data.name}\n                        className=\"size-[22px] bg-transparent\"\n                      />\n                    ) : (\n                      <div className=\"flex size-[22px] items-center justify-center rounded-full border\">\n                        <Building2 className=\"size-[16px]\" />\n                      </div>\n                    )}\n                    {member.data.name}\n                    <X\n                      className=\"cursor-pointer hover:opacity-70\"\n                      onClick={() => deleteMember(member.id)}\n                    />\n                  </div>\n                ))}\n                {selectedMembers.length === 0 && (\n                  <span className=\"text-sm text-muted-foreground\">\n                    {t('invite.addOrgCollaborator.placeholder')}\n                  </span>\n                )}\n              </div>\n            </ScrollArea>\n          </div>\n          <Button\n            size={'sm'}\n            variant={'outline'}\n            className=\"absolute bottom-1 right-1 h-auto p-1\"\n            disabled={isCreateLoading}\n            onClick={() => {\n              memberSelectorRef.current?.open();\n            }}\n          >\n            <Plus className=\"size-4 shrink-0\" />\n          </Button>\n        </div>\n        <div className=\"flex items-center justify-between\">\n          <RoleSelect\n            value={selectedRole}\n            options={filteredRoleStatic}\n            onChange={(role) => setSelectedRole(role)}\n          />\n          <Button\n            size=\"sm\"\n            className=\"text-sm font-normal\"\n            disabled={selectedMembers.length === 0 || isCreateLoading}\n            onClick={() =>\n              onCreate(\n                selectedRole,\n                selectedMembers.map((m) => ({\n                  principalId: m.data.id,\n                  principalType:\n                    m.data.type === TreeNodeType.USER\n                      ? PrincipalType.User\n                      : PrincipalType.Department,\n                }))\n              )\n            }\n          >\n            {isCreateLoading ? (\n              <Loader className=\"size-4 animate-spin\" />\n            ) : (\n              <UserPlus className=\"size-4\" />\n            )}\n            {t('actions.add')}\n          </Button>\n        </div>\n      </div>\n      <MemberSelectorDialog\n        ref={memberSelectorRef}\n        onConfirm={setSelectedMembers}\n        onLoadData={onLoadData}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/share/common/PreviewCollaborators.tsx",
    "content": "import { Building2 } from '@teable/icons';\nimport type { CollaboratorItem } from '@teable/openapi';\nimport { PrincipalType } from '@teable/openapi';\nimport { UserAvatar } from '@teable/sdk/components';\n\ninterface IPreviewCollaboratorsProps {\n  collaborators: CollaboratorItem[];\n  total: number;\n}\n\nexport const PreviewCollaborators = ({ collaborators, total }: IPreviewCollaboratorsProps) => {\n  const moreCount = total - collaborators.length;\n  return (\n    <div className=\"flex items-center -space-x-2\">\n      {collaborators.map((collaborator) => {\n        switch (collaborator.type) {\n          case PrincipalType.User:\n            return (\n              <UserAvatar\n                key={collaborator.userId}\n                name={collaborator.userName}\n                avatar={collaborator.avatar}\n                className=\"size-8 border-2 border-background dark:border-popover\"\n              />\n            );\n          case PrincipalType.Department:\n            return (\n              <div\n                key={collaborator.departmentId}\n                className=\"relative flex size-8 items-center justify-center rounded-full border-2 border-background bg-secondary\"\n              >\n                <Building2 className=\"size-5\" />\n              </div>\n            );\n        }\n      })}\n      {moreCount > 0 && (\n        <div className=\"relative flex size-8 items-center justify-center rounded-full border-2 border-background bg-blue-500 font-medium text-white\">\n          {moreCount > 99 ? '99+' : `+${moreCount}`}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/space/InviteSpaceContent.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { canManageRole, hasPermission, Role, type IBaseRole, type IRole } from '@teable/core';\nimport type {\n  CollaboratorItem,\n  IAddCollaborator,\n  UpdateBaseCollaborateRo,\n  UpdateSpaceCollaborateRo,\n} from '@teable/openapi';\nimport {\n  addSpaceCollaborator,\n  CollaboratorType,\n  createSpaceInvitationLink,\n  deleteBaseCollaborator,\n  deleteSpaceCollaborator,\n  deleteSpaceInvitationLink,\n  emailSpaceInvitation,\n  getSpaceCollaboratorList,\n  listSpaceInvitationLink,\n  PrincipalType,\n  updateBaseCollaborator,\n  updateSpaceCollaborator,\n  updateSpaceInvitationLink,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Badge } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { useFilteredRoleStatic as useFilteredBaseRoleStatic } from '../../collaborator-manage/base/useFilteredRoleStatic';\nimport { useFilteredRoleStatic } from '../../collaborator-manage/space/useFilteredRoleStatic';\nimport { CollaboratorsDialog } from '../share/CollaboratorsDialog';\nimport { CollaboratorButton } from '../share/common/CollaboratorButton';\nimport { CollaboratorTable } from '../share/common/CollaboratorTable';\nimport { DebounceInput } from '../share/common/DebounceInput';\nimport { EmailContent } from '../share/common/EmailContent';\nimport { ShareHeader } from '../share/common/Header';\nimport { InviteEmailButton } from '../share/common/InviteEmailButton';\nimport { InviteLinkButton } from '../share/common/InviteLinkButton';\nimport { InviteOrgButton } from '../share/common/InviteOrgButton';\nimport { LinkContent } from '../share/common/LinkContent';\nimport { OrgContent } from '../share/common/OrgContent';\n\ninterface IInviteSpaceContentProps {\n  spaceId: string;\n  spaceName: string;\n  role: IRole;\n  onClose: () => void;\n}\n\nconst MEMBERS_PER_PAGE = 50;\n\nconst inviteLinkQueryKey = (spaceId: string) => ['space-invite-link-list', spaceId] as const;\n\nexport const InviteSpaceContent = (props: IInviteSpaceContentProps) => {\n  const { spaceId, spaceName, role: userRole, onClose } = props;\n  const { t } = useTranslation('common');\n  const { user } = useSession();\n  const [tabType, setTabType] = useState<'email' | 'organization' | 'link' | 'collaborators'>();\n\n  const queryClient = useQueryClient();\n  const [search, setSearch] = useState('');\n  const {\n    data,\n    hasNextPage,\n    fetchNextPage,\n    isLoading: isListLoading,\n  } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId, {\n      includeSystem: true,\n      search,\n      includeBase: true,\n    }),\n    staleTime: 1000,\n    refetchOnWindowFocus: false,\n    queryFn: ({ queryKey, pageParam }) =>\n      getSpaceCollaboratorList(queryKey[1], {\n        ...queryKey[2],\n        skip: pageParam * MEMBERS_PER_PAGE,\n        take: MEMBERS_PER_PAGE,\n      }).then((res) => res.data),\n    initialPageParam: 0,\n    getNextPageParam: (lastPage, pages) => {\n      const allCollaborators = pages.flatMap((page) => page.collaborators);\n      return allCollaborators.length >= lastPage.total ? undefined : pages.length;\n    },\n  });\n\n  const total = data?.pages?.[0]?.total || 0;\n  const collaborators = useMemo(() => {\n    return data?.pages.flatMap((page) => page.collaborators);\n  }, [data]);\n\n  const hasInviteLinkPermission = hasPermission(userRole, 'space|invite_link');\n  const { data: linkList } = useQuery({\n    queryKey: inviteLinkQueryKey(spaceId),\n    queryFn: ({ queryKey }) => listSpaceInvitationLink(queryKey[1]).then((res) => res.data),\n    enabled: hasInviteLinkPermission,\n  });\n\n  const { mutate: emailInvitation, isPending: emailInvitationLoading } = useMutation({\n    mutationFn: emailSpaceInvitation,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId),\n      });\n      onClose();\n      toast.success(t('invite.sendInvitationSuccess'));\n    },\n  });\n\n  const { mutate: createInviteLinkRequest, isPending: createInviteLinkLoading } = useMutation({\n    mutationFn: createSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: inviteLinkQueryKey(spaceId) });\n    },\n  });\n\n  const { mutate: updateInviteLink, isPending: updateInviteLinkLoading } = useMutation({\n    mutationFn: updateSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: inviteLinkQueryKey(spaceId) });\n    },\n  });\n\n  const { mutate: deleteInviteLink, isPending: deleteInviteLinkLoading } = useMutation({\n    mutationFn: deleteSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: inviteLinkQueryKey(spaceId) });\n    },\n  });\n\n  const { mutate: deleteCollaborator, isPending: deleteCollaboratorLoading } = useMutation({\n    mutationFn: ({\n      resourceId,\n      principalId,\n      principalType,\n      isBase,\n    }: {\n      resourceId: string;\n      principalId: string;\n      principalType: PrincipalType;\n      isBase: boolean;\n    }) =>\n      isBase\n        ? deleteBaseCollaborator({\n            baseId: resourceId,\n            deleteBaseCollaboratorRo: { principalId, principalType },\n          })\n        : deleteSpaceCollaborator({\n            spaceId: resourceId,\n            deleteSpaceCollaboratorRo: { principalId, principalType },\n          }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId) });\n    },\n  });\n\n  const { mutate: updateCollaborator, isPending: updateCollaboratorLoading } = useMutation({\n    mutationFn: ({\n      resourceId,\n      isBase,\n      updateCollaborateRo,\n    }: {\n      resourceId: string;\n      isBase: boolean;\n      updateCollaborateRo: UpdateSpaceCollaborateRo;\n    }) =>\n      isBase\n        ? updateBaseCollaborator({\n            baseId: resourceId,\n            updateBaseCollaborateRo: updateCollaborateRo as UpdateBaseCollaborateRo,\n          })\n        : updateSpaceCollaborator({\n            spaceId: resourceId,\n            updateSpaceCollaborateRo: updateCollaborateRo,\n          }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId) });\n    },\n  });\n\n  const { mutate: addCollaborators, isPending: addCollaboratorsLoading } = useMutation({\n    mutationFn: async ({\n      role,\n      collaborators,\n    }: {\n      role: IRole;\n      collaborators: IAddCollaborator[];\n    }) => {\n      const userCollaborators = collaborators.filter((c) => c.principalType === PrincipalType.User);\n      const departmentCollaborators = collaborators.filter(\n        (c) => c.principalType === PrincipalType.Department\n      );\n      if (userCollaborators.length > 0) {\n        await addSpaceCollaborator(spaceId, {\n          collaborators: userCollaborators,\n          role: role as IBaseRole,\n        });\n      }\n      if (departmentCollaborators.length > 0) {\n        await addSpaceCollaborator(spaceId, {\n          collaborators: departmentCollaborators,\n          role: role as IBaseRole,\n        });\n      }\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId) });\n      onClose();\n      toast.success(t('invite.sendInvitationSuccess'));\n    },\n  });\n\n  const defaultRole = userRole === Role.Owner ? Role.Creator : userRole;\n  const linkListCount = linkList?.length || 0;\n  const onBack = () => setTabType(undefined);\n  const filteredRoleStatic = useFilteredRoleStatic(userRole);\n  const baseFilteredRoleStatic = useFilteredBaseRoleStatic(defaultRole);\n\n  if (tabType === 'link') {\n    return (\n      <LinkContent\n        list={linkList}\n        defaultRole={defaultRole}\n        isCreateLoading={createInviteLinkLoading}\n        isUpdateLoading={updateInviteLinkLoading}\n        isDeleteLoading={deleteInviteLinkLoading}\n        onCreate={(role) =>\n          createInviteLinkRequest({\n            spaceId,\n            createSpaceInvitationLinkRo: { role: role as IBaseRole },\n          })\n        }\n        onUpdate={(invitationId, role) =>\n          updateInviteLink({\n            invitationId,\n            updateSpaceInvitationLinkRo: { role: role as IBaseRole },\n            spaceId,\n          })\n        }\n        onDelete={(invitationId) => deleteInviteLink({ invitationId, spaceId })}\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  if (tabType === 'email') {\n    return (\n      <EmailContent\n        defaultRole={defaultRole}\n        isCreateLoading={emailInvitationLoading}\n        onCreate={(ro) => emailInvitation({ spaceId, emailSpaceInvitationRo: ro })}\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  if (tabType === 'organization') {\n    return (\n      <OrgContent\n        defaultRole={defaultRole}\n        isCreateLoading={addCollaboratorsLoading}\n        onCreate={(role, members) =>\n          addCollaborators({ role: role as IRole, collaborators: members })\n        }\n        onBack={onBack}\n        filteredRoleStatic={filteredRoleStatic}\n      />\n    );\n  }\n\n  const getPermissions = (item: CollaboratorItem) => {\n    const canManage = canManageRole(userRole, item.role);\n    const isMe = item.type === PrincipalType.User && item.userId === user.id;\n    const isOwner = userRole === Role.Owner;\n    const canOperator = canManage || isMe || isOwner;\n    return {\n      canUpdateRole: canOperator,\n      canDelete: canOperator,\n      showDelete: canOperator,\n    };\n  };\n\n  const getFilteredRoleStatic = (item: CollaboratorItem) => {\n    return item.resourceType === CollaboratorType.Base\n      ? baseFilteredRoleStatic\n      : filteredRoleStatic;\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <ShareHeader\n        title={t('invite.dialog.title', { spaceName })}\n        description={\n          <Trans\n            ns=\"common\"\n            i18nKey={'invite.dialog.desc'}\n            count={total}\n            components={{ b: <b /> }}\n          />\n        }\n      />\n      <div className=\"flex flex-col gap-5\">\n        <InviteEmailButton onClick={() => setTabType('email')} />\n        {user?.organization && (\n          <div className=\"space-y-2\">\n            <p className=\"text-sm font-semibold\">{t('invite.addOrgCollaborator.title')}</p>\n            <InviteOrgButton onClick={() => setTabType('organization')} />\n          </div>\n        )}\n        {hasInviteLinkPermission && (\n          <div className=\"space-y-2\">\n            <p className=\"text-sm font-semibold\">{t('invite.dialog.tabLink')}</p>\n            <InviteLinkButton\n              className=\"box-content -translate-x-2 bg-transparent px-2 py-0\"\n              linkListCount={linkListCount}\n              onClick={() => setTabType('link')}\n            />\n          </div>\n        )}\n        <div className=\"space-y-2\">\n          <p className=\"text-sm font-semibold\">{t('invite.dialog.spaceTitle')}</p>\n          <CollaboratorsDialog\n            title={t('invite.dialog.spaceTitleWithCount', { count: total })}\n            list={collaborators || []}\n            total={total}\n            hasNextPage={hasNextPage}\n            fetchNextPage={fetchNextPage}\n            isLoading={false}\n            content={\n              <div className=\"flex flex-1 flex-col gap-2 overflow-hidden\">\n                <DebounceInput\n                  value={search}\n                  onChange={(value) => setSearch(value)}\n                  placeholder={t('invite.base.collaboratorSearchPlaceholder')}\n                />\n                <CollaboratorTable\n                  className=\"flex-1 overflow-y-auto rounded-md border\"\n                  list={collaborators || []}\n                  total={total}\n                  hasNextPage={hasNextPage}\n                  fetchNextPage={fetchNextPage}\n                  isLoading={isListLoading}\n                  updateRoleLoading={updateCollaboratorLoading}\n                  deleteLoading={deleteCollaboratorLoading}\n                  getFilteredRoleStatic={getFilteredRoleStatic}\n                  onUpdateRole={(role, item) => {\n                    updateCollaborator({\n                      resourceId: item.base?.id || spaceId,\n                      isBase: item.resourceType === CollaboratorType.Base,\n                      updateCollaborateRo: {\n                        principalId:\n                          item.type === PrincipalType.User ? item.userId : item.departmentId,\n                        principalType: item.type,\n                        role,\n                      },\n                    });\n                  }}\n                  onDelete={(item) => {\n                    deleteCollaborator({\n                      resourceId: item.base?.id || spaceId,\n                      isBase: item.resourceType === CollaboratorType.Base,\n                      principalId:\n                        item.type === PrincipalType.User ? item.userId : item.departmentId,\n                      principalType: item.type,\n                    });\n                  }}\n                  getPermissions={getPermissions}\n                  renderTips={(item) => {\n                    return (\n                      item.resourceType === CollaboratorType.Base &&\n                      item.base?.name && (\n                        <Badge className=\"ml-2 text-xs font-normal\" variant={'outline'}>\n                          {item.base.name}\n                        </Badge>\n                      )\n                    );\n                  }}\n                />\n              </div>\n            }\n          >\n            <CollaboratorButton\n              className=\"box-content -translate-x-2 px-2 py-0\"\n              collaborators={collaborators?.slice(0, 4) || []}\n              total={total}\n              onClick={() => setTabType('collaborators')}\n            />\n          </CollaboratorsDialog>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator/space/InviteSpacePopover.tsx",
    "content": "import type { IRole } from '@teable/core';\nimport { Popover, PopoverTrigger, PopoverContent } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\nimport { InviteSpaceContent } from './InviteSpaceContent';\n\ninterface IInviteSpacePopoverProps {\n  space: {\n    id: string;\n    name: string;\n    role: IRole;\n  };\n  children: React.ReactNode;\n}\n\nexport const InviteSpacePopover = (props: IInviteSpacePopoverProps) => {\n  const { space, children } = props;\n  const [open, setOpen] = useState(false);\n  const onClose = () => setOpen(false);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen} modal>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent className=\"h-auto w-[480px] rounded-xl border p-6 shadow-lg\" align=\"end\">\n        <InviteSpaceContent\n          spaceId={space.id}\n          spaceName={space.name}\n          role={space.role}\n          onClose={onClose}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/base/BaseInvite.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IBaseRole, IRole } from '@teable/core';\nimport { hasPermission, Role } from '@teable/core';\nimport { createBaseInvitationLink, emailBaseInvitation } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useState } from 'react';\nimport { Invite } from '../components/Invite';\nimport { RoleSelect } from '../components/RoleSelect';\nimport { useFilteredRoleStatic } from './useFilteredRoleStatic';\n\nexport const BaseInvite = (props: { baseId: string; role: IRole }) => {\n  const { role, baseId } = props;\n  const [inviteRole, setInviteRole] = useState<IBaseRole>(\n    role === Role.Owner ? Role.Creator : role\n  );\n  const queryClient = useQueryClient();\n\n  const { mutate: emailInvitation, isPending: updateCollaboratorLoading } = useMutation({\n    mutationFn: emailBaseInvitation,\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.baseCollaboratorList(baseId),\n      });\n    },\n  });\n\n  const sendInviteEmail = async (emails: string[]) => {\n    emailInvitation({\n      baseId,\n      emailBaseInvitationRo: {\n        emails,\n        role: inviteRole,\n      },\n    });\n  };\n\n  const { mutate: createInviteLinkRequest, isPending: createInviteLinkLoading } = useMutation({\n    mutationFn: createBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const createInviteLink = async () => {\n    await createInviteLinkRequest({ baseId, createBaseInvitationLinkRo: { role: inviteRole } });\n  };\n\n  const filteredRoleStatic = useFilteredRoleStatic(role);\n\n  return (\n    <Invite\n      disabledLink={!hasPermission(role, 'base|invite_link')}\n      sendInviteEmail={sendInviteEmail}\n      createInviteLink={createInviteLink}\n      loading={{\n        sendInviteEmail: updateCollaboratorLoading,\n        createInviteLink: createInviteLinkLoading,\n      }}\n      roleSelect={\n        <RoleSelect\n          className=\"mx-1\"\n          value={inviteRole}\n          options={filteredRoleStatic}\n          onChange={(role) => setInviteRole(role as IBaseRole)}\n        />\n      }\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/base/BaseInviteLink.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IBaseRole, IRole } from '@teable/core';\nimport {\n  deleteBaseInvitationLink,\n  listBaseInvitationLink,\n  updateBaseInvitationLink,\n} from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport { InviteLinkItem } from '../components/InviteLinkItem';\nimport { RoleSelect } from '../components/RoleSelect';\nimport { useFilteredRoleStatic } from './useFilteredRoleStatic';\n\nexport const BaseInviteLink = (props: { baseId: string; role: IRole }) => {\n  const { baseId, role } = props;\n  const queryClient = useQueryClient();\n  const { t } = useTranslation('common');\n\n  const linkList = useQuery({\n    queryKey: ['invite-link-list', baseId],\n    queryFn: ({ queryKey }) => listBaseInvitationLink(queryKey[1]).then((res) => res.data),\n  }).data;\n\n  const { mutate: updateInviteLink, isPending: updateInviteLinkLoading } = useMutation({\n    mutationFn: updateBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const onUpdateInviteLink = async (invitationId: string, role: IBaseRole) => {\n    updateInviteLink({\n      invitationId,\n      updateBaseInvitationLinkRo: { role },\n      baseId,\n    });\n  };\n\n  const { mutate: deleteInviteLink, isPending: deleteInviteLinkLoading } = useMutation({\n    mutationFn: deleteBaseInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const onDeleteInviteLink = async (invitationId: string) => {\n    deleteInviteLink({ invitationId, baseId });\n  };\n\n  const filteredRoleStatic = useFilteredRoleStatic(role);\n\n  if (!linkList?.length) {\n    return <></>;\n  }\n\n  return (\n    <div>\n      <div className=\"mb-3 text-sm text-muted-foreground\">{t('invite.dialog.linkTitle')}</div>\n      <div className=\"space-y-3\">\n        {linkList.map(({ invitationId, inviteUrl, createdTime, role }) => (\n          <InviteLinkItem\n            key={invitationId}\n            url={inviteUrl}\n            createdTime={createdTime}\n            onDelete={() => onDeleteInviteLink(invitationId)}\n            deleteDisabled={deleteInviteLinkLoading}\n          >\n            <RoleSelect\n              value={role}\n              options={filteredRoleStatic}\n              disabled={updateInviteLinkLoading}\n              onChange={(role) => onUpdateInviteLink(invitationId, role as IBaseRole)}\n            />\n          </InviteLinkItem>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/base/useFilteredRoleStatic.ts",
    "content": "import { Role, type IRole } from '@teable/core';\nimport { useMemo } from 'react';\nimport { useRoleStatic } from '../useRoleStatic';\nimport { getRolesWithLowerPermissions } from '../utils';\n\nexport const useFilteredRoleStatic = (role: IRole) => {\n  const baseRoleStatic = useRoleStatic();\n  return useMemo(\n    () => getRolesWithLowerPermissions(role === Role.Owner ? Role.Creator : role, baseRoleStatic),\n    [role, baseRoleStatic]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/Collaborator.tsx",
    "content": "import { BillableRoles, type IRole } from '@teable/core';\nimport { Building2 } from '@teable/icons';\nimport { PrincipalType } from '@teable/openapi';\nimport {\n  Badge,\n  cn,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { UserAvatar } from '../../user/UserAvatar';\n\ninterface ICollaboratorProps {\n  item: IUserCollaborator | IDepartmentCollaborator;\n  className?: string;\n  tips?: React.ReactNode;\n}\n\nexport interface IUserCollaborator {\n  type: PrincipalType.User;\n  name: string;\n  email: string;\n  avatar?: string | null;\n  billable?: boolean | null;\n  role?: IRole;\n}\n\nexport interface IDepartmentCollaborator {\n  type: PrincipalType.Department;\n  name: string;\n}\n\nexport type ICollaborator = IUserCollaborator | IDepartmentCollaborator;\n\nconst BillableBadge = (props: { role?: IRole }) => {\n  const { role } = props;\n  const { t } = useTranslation('common');\n  const isBillableRole = role ? (BillableRoles as readonly IRole[]).includes(role) : true;\n\n  const badge = (\n    <Badge className=\"shrink-0 border-none bg-blue-100 font-normal text-blue-500 hover:bg-blue-100/80 dark:bg-blue-500/20 dark:hover:bg-blue-500/30\">\n      {t('billing.billable')}\n    </Badge>\n  );\n\n  if (isBillableRole) {\n    return badge;\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span>{badge}</span>\n        </TooltipTrigger>\n        <TooltipContent side=\"top\">\n          <p>{t('billing.billableByAuthorityMatrix')}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nexport const Collaborator = (props: ICollaboratorProps) => {\n  const { item, className, tips } = props;\n\n  return (\n    <div className={cn('flex flex-1 items-center', className)}>\n      {item.type === PrincipalType.User && (\n        <UserAvatar className=\"border\" user={{ name: item.name, avatar: item.avatar }} />\n      )}\n      {item.type === PrincipalType.Department && (\n        <div className=\" flex size-7 items-center justify-center rounded-full bg-accent\">\n          <Building2 className=\"size-4\" />\n        </div>\n      )}\n      <div className=\"ml-3 flex flex-1 flex-col space-y-1 overflow-hidden\">\n        <div className=\"text-sm font-medium\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"truncate\">{item.name}</span>\n            {item.type === PrincipalType.User && item.billable && (\n              <BillableBadge role={item.role} />\n            )}\n            {tips}\n          </div>\n        </div>\n        {item.type === PrincipalType.User && (\n          <p className=\"text-xs leading-none text-muted-foreground\">{item.email}</p>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/CollaboratorAdd.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { Role, type IBaseRole, type IRole } from '@teable/core';\nimport { Building2, Plus, X } from '@teable/icons';\nimport type { IAddCollaborator } from '@teable/openapi';\nimport {\n  addBaseCollaborator,\n  addSpaceCollaborator,\n  CollaboratorType,\n  PrincipalType,\n} from '@teable/openapi';\nimport type { IMemberSelectorDialogRef, ISelectedMember } from '@teable/sdk/components';\nimport { MemberSelectorDialog, MemberSelectorNodeType, UserAvatar } from '@teable/sdk/components';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useRef, useState } from 'react';\nimport {\n  useFilteredRoleStatic,\n  useFilteredRoleStatic as useFilteredBaseRoleStatic,\n} from '../base/useFilteredRoleStatic';\nimport { RoleSelect } from './RoleSelect';\n\ninterface ICollaboratorAddProps {\n  resourceId: string;\n  resourceType: CollaboratorType;\n  currentRole: IRole;\n  onConfirm?: () => void;\n}\n\nexport const CollaboratorAdd = (props: ICollaboratorAddProps) => {\n  const { resourceId, resourceType, currentRole, onConfirm } = props;\n  const isBase = resourceType === CollaboratorType.Base;\n  const { t } = useTranslation(['common']);\n  const [role, setRole] = useState<IRole>(() =>\n    isBase && currentRole === Role.Owner ? Role.Creator : currentRole\n  );\n\n  const memberSelectorRef = useRef<IMemberSelectorDialogRef>(null);\n  const filteredRoleStatic = useFilteredRoleStatic(currentRole);\n  const filteredBaseRoleStatic = useFilteredBaseRoleStatic(currentRole);\n  const [selectedMembers, setSelectedMembers] = useState<ISelectedMember[]>([]);\n\n  const { mutate: addCollaborators, isPending: isLoading } = useMutation({\n    mutationFn: async (collaborators: IAddCollaborator[]) => {\n      const userCollaborators = collaborators.filter((c) => c.principalType === PrincipalType.User);\n      const departmentCollaborators = collaborators.filter(\n        (c) => c.principalType === PrincipalType.Department\n      );\n      if (userCollaborators.length > 0) {\n        if (isBase) {\n          await addBaseCollaborator(resourceId, {\n            collaborators: userCollaborators,\n            role: role as IBaseRole,\n          });\n        } else {\n          await addSpaceCollaborator(resourceId, {\n            collaborators: userCollaborators,\n            role: role as IRole,\n          });\n        }\n      }\n      if (departmentCollaborators.length > 0) {\n        if (isBase) {\n          await addBaseCollaborator(resourceId, {\n            collaborators: departmentCollaborators,\n            role: role as IBaseRole,\n          });\n        } else {\n          await addSpaceCollaborator(resourceId, {\n            collaborators: departmentCollaborators,\n            role: role as IRole,\n          });\n        }\n      }\n    },\n    onSuccess: () => {\n      setSelectedMembers([]);\n      onConfirm?.();\n    },\n  });\n\n  const onLoadData = useCallback(() => {\n    return selectedMembers;\n  }, [selectedMembers]);\n\n  const deleteMember = (id: string) => {\n    setSelectedMembers(selectedMembers.filter((m) => m.id !== id));\n  };\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between\">\n        <div className=\"text-sm text-muted-foreground\">\n          {t('common:invite.addOrgCollaborator.title')}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <RoleSelect\n            value={role}\n            onChange={setRole}\n            options={isBase ? filteredBaseRoleStatic : filteredRoleStatic}\n          />\n          <Button\n            size={'sm'}\n            className=\"h-7 w-20\"\n            onClick={() => {\n              addCollaborators(\n                selectedMembers.map((m) => ({\n                  principalId: m.id,\n                  principalType:\n                    m.type === MemberSelectorNodeType.USER\n                      ? PrincipalType.User\n                      : PrincipalType.Department,\n                }))\n              );\n            }}\n            disabled={isLoading}\n          >\n            {isLoading && <Spin />}\n            {t('common:actions.add')}\n          </Button>\n        </div>\n      </div>\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <Button\n            size={'sm'}\n            variant={'link'}\n            className=\"h-7\"\n            disabled={isLoading}\n            onClick={() => {\n              memberSelectorRef.current?.open();\n            }}\n          >\n            <Plus className=\"size-4 shrink-0\" />\n            {t('common:invite.addOrgCollaborator.placeholder')}\n          </Button>\n        </div>\n      </div>\n      <div className=\"mt-2 flex flex-wrap gap-x-4 gap-y-2\">\n        {selectedMembers.map((member) => {\n          if (member.type === MemberSelectorNodeType.USER) {\n            return (\n              <div\n                key={member.id}\n                className=\"flex items-center gap-1.5 rounded-full border p-1 text-[13px]\"\n              >\n                <UserAvatar avatar={member.data.avatar} name={member.data.name} />\n                {member.data.name}\n                <Button\n                  className=\"h-6\"\n                  disabled={isLoading}\n                  size={'xs'}\n                  variant={'ghost'}\n                  onClick={() => {\n                    deleteMember(member.id);\n                  }}\n                >\n                  <X className=\"size-4 shrink-0\" />\n                </Button>\n              </div>\n            );\n          }\n          return (\n            <div\n              key={member.id}\n              className=\"flex items-center gap-1.5 rounded-full border p-1 text-[13px]\"\n            >\n              <Building2 className=\"ml-2 size-4\" />\n              {member.data.name}\n              <Button\n                className=\"h-6\"\n                disabled={isLoading}\n                size={'xs'}\n                variant={'ghost'}\n                onClick={() => {\n                  deleteMember(member.id);\n                }}\n              >\n                <X className=\"size-4 shrink-0\" />\n              </Button>\n            </div>\n          );\n        })}\n      </div>\n      <MemberSelectorDialog\n        ref={memberSelectorRef}\n        onConfirm={setSelectedMembers}\n        onLoadData={onLoadData}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/CollaboratorItem.tsx",
    "content": "import { X } from '@teable/icons';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { ICollaborator } from './Collaborator';\nimport { Collaborator } from './Collaborator';\n\nexport const CollaboratorItem = (props: {\n  item: ICollaborator;\n  createdTime: string;\n  children: React.ReactNode;\n  collaboratorTips?: React.ReactNode;\n  onDeleted: () => void;\n  deletable?: boolean;\n  showDelete?: boolean;\n}) => {\n  const { item, createdTime, children, onDeleted, deletable, showDelete, collaboratorTips } = props;\n  const { t } = useTranslation('common');\n  const dayjs = useLanDayjs();\n  return (\n    <div className=\"relative flex items-center gap-3 pr-6\">\n      <Collaborator item={item} tips={collaboratorTips} />\n      <div className=\"text-xs text-muted-foreground\">\n        {t('invite.dialog.collaboratorJoin', {\n          joinTime: dayjs(createdTime).fromNow(),\n        })}\n      </div>\n      {children}\n      {showDelete && (\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                className=\"absolute right-0 h-auto p-0 hover:bg-inherit\"\n                size=\"sm\"\n                variant=\"ghost\"\n                disabled={!deletable}\n                onClick={() => onDeleted()}\n              >\n                <X className=\"size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{t('invite.dialog.collaboratorRemove')}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/CollaboratorList.tsx",
    "content": "import { Spin } from '@teable/ui-lib/base';\nimport { Input } from '@teable/ui-lib/shadcn';\nimport { debounce } from 'lodash';\nimport { useEffect, useMemo, useState } from 'react';\n\ninterface ICollaboratorListProps {\n  children?: React.ReactNode | React.ReactNode[];\n  inputRight?: React.ReactNode;\n  searchPlaceholder?: string;\n  isSearching?: boolean;\n  onSearch: (search: string) => void;\n}\n\nexport const CollaboratorList = (props: ICollaboratorListProps) => {\n  const { searchPlaceholder, onSearch, children, inputRight, isSearching } = props;\n  const [search, setSearch] = useState<string>('');\n  const [isComposing, setIsComposing] = useState(false);\n\n  const setApplySearchDebounced = useMemo(() => {\n    return debounce(onSearch, 200);\n  }, [onSearch]);\n\n  useEffect(() => {\n    if (!isComposing) {\n      setApplySearchDebounced(search);\n    }\n  }, [search, isComposing, onSearch, setApplySearchDebounced]);\n\n  return (\n    <div className=\"flex size-full h-full flex-col\">\n      <div className=\"mb-6 flex w-full items-center gap-x-4\">\n        <Input\n          type=\"search\"\n          placeholder={searchPlaceholder}\n          value={search}\n          onChange={(e) => {\n            const value = e.target.value;\n            setSearch(value);\n          }}\n          onCompositionStart={() => setIsComposing(true)}\n          onCompositionEnd={() => setIsComposing(false)}\n        />\n        {inputRight}\n      </div>\n      <div className=\"mb-0.5 flex flex-1 grow flex-col space-y-5 overflow-y-auto\">\n        {isSearching ? (\n          <div className=\"flex justify-center\">\n            <Spin />\n          </div>\n        ) : (\n          children\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/Invite.tsx",
    "content": "import { X } from '@teable/icons';\nimport { Button, cn } from '@teable/ui-lib';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { z } from 'zod';\n\ninterface IInvite {\n  className?: string;\n  disabledLink?: boolean;\n  loading: {\n    sendInviteEmail?: boolean;\n    createInviteLink?: boolean;\n  };\n  roleSelect: React.ReactNode;\n  sendInviteEmail: (emails: string[]) => Promise<void>;\n  createInviteLink: () => Promise<void>;\n}\n\nexport const Invite = (props: IInvite) => {\n  const { className, disabledLink, loading, roleSelect, sendInviteEmail, createInviteLink } = props;\n  const { t } = useTranslation('common');\n\n  const [inviteType, setInviteType] = useState<'link' | 'email'>('email');\n  const [email, setEmail] = useState<string>('');\n  const [inviteEmails, setInviteEmails] = useState<string[]>([]);\n\n  const innerSendInviteEmail = async () => {\n    await sendInviteEmail(inviteEmails);\n    initEmail();\n  };\n\n  const innerCreateInviteLink = async () => {\n    await createInviteLink();\n  };\n\n  const changeInviteType = (inviteType: 'link' | 'email') => {\n    initEmail();\n    setInviteType(inviteType);\n  };\n\n  const initEmail = () => {\n    setInviteEmails([]);\n    setEmail('');\n  };\n\n  const emailInputChange = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.code === 'Backspace' && !email?.length) {\n      setInviteEmails(inviteEmails.slice(0, inviteEmails.length - 1));\n      return;\n    }\n    if (\n      ['Space', 'Enter'].includes(e.code) &&\n      email &&\n      z.string().email().safeParse(email).success &&\n      !inviteEmails.includes(email)\n    ) {\n      setEmail('');\n      setInviteEmails(inviteEmails.concat(email));\n      e.preventDefault();\n    }\n  };\n\n  const deleteEmail = (email: string) => {\n    setInviteEmails((inviteEmails) => inviteEmails.filter((inviteEmail) => email !== inviteEmail));\n  };\n\n  const isEmailInputValid = useMemo(() => z.string().email().safeParse(email).success, [email]);\n\n  const EmailInvite = (\n    <div>\n      <div className=\"flex gap-2\">\n        <div className=\"flex max-h-64 min-h-8 flex-1 flex-wrap gap-1 overflow-y-auto rounded-md border border-input bg-background p-1 text-sm shadow-sm transition-colors\">\n          {inviteEmails.map((email) => (\n            <div\n              key={email}\n              className=\"flex h-6 items-center rounded-full bg-muted px-2 text-xs text-muted-foreground\"\n            >\n              {email}\n              <X\n                className=\"ml-1 cursor-pointer hover:opacity-70\"\n                onClick={() => deleteEmail(email)}\n              />\n            </div>\n          ))}\n          <input\n            className=\"h-6 flex-auto bg-background text-xs outline-none\"\n            placeholder={t('invite.dialog.emailPlaceholder')}\n            type=\"email\"\n            value={email}\n            onChange={(e) => setEmail(e.target.value)}\n            onBlur={() => {\n              if (isEmailInputValid) {\n                setInviteEmails(inviteEmails.concat(email));\n                setEmail('');\n              }\n            }}\n            onKeyDown={emailInputChange}\n          />\n        </div>\n        {roleSelect}\n      </div>\n      <Button\n        className=\"mt-2\"\n        size={'sm'}\n        disabled={(!isEmailInputValid && inviteEmails.length === 0) || loading.sendInviteEmail}\n        onClick={innerSendInviteEmail}\n      >\n        {t('invite.dialog.emailSend')}\n      </Button>\n    </div>\n  );\n\n  const LinkInvite = (\n    <div>\n      <div className=\"flex items-center text-sm\">\n        <Trans ns=\"common\" i18nKey={'invite.dialog.linkPlaceholder'}>\n          {roleSelect}\n        </Trans>\n      </div>\n      <Button\n        className=\"mt-2\"\n        size={'sm'}\n        disabled={loading.createInviteLink}\n        onClick={innerCreateInviteLink}\n      >\n        {t('invite.dialog.linkSend')}\n      </Button>\n    </div>\n  );\n\n  if (disabledLink) {\n    return <div className={cn(className, 'rounded bg-muted px-4 py-2')}>{EmailInvite}</div>;\n  }\n\n  return (\n    <div className={cn(className, 'rounded bg-muted px-4 py-2')}>\n      <div className=\"pb-2\">\n        <Button\n          className=\"mr-6 p-0 data-[state=active]:underline\"\n          data-state={inviteType === 'email' ? 'active' : 'inactive'}\n          variant={'link'}\n          onClick={() => changeInviteType('email')}\n        >\n          {t('invite.dialog.tabEmail')}\n        </Button>\n        <Button\n          className=\"p-0 data-[state=active]:underline\"\n          data-state={inviteType === 'link' ? 'active' : 'inactive'}\n          variant={'link'}\n          onClick={() => changeInviteType('link')}\n        >\n          {t('invite.dialog.tabLink')}\n        </Button>\n      </div>\n      <div>{inviteType === 'email' ? EmailInvite : LinkInvite}</div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/InviteLinkItem.tsx",
    "content": "import { Copy, Trash2 } from '@teable/icons';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { syncCopy } from '@teable/sdk/utils';\nimport {\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\n\nexport const InviteLinkItem = (props: {\n  url: string;\n  createdTime: string;\n  deleteDisabled?: boolean;\n  onDelete: () => void;\n  children: React.ReactNode;\n}) => {\n  const { url, createdTime, children, deleteDisabled, onDelete } = props;\n  const { t } = useTranslation('common');\n  const dayjs = useLanDayjs();\n\n  const copyInviteUrl = async () => {\n    syncCopy(url);\n    toast.success(t('invite.dialog.linkCopySuccess'));\n  };\n\n  return (\n    <div className=\"flex items-center gap-2 overflow-hidden\">\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"truncate text-sm\">{url}</div>\n        <div className=\"text-xs text-muted-foreground\">\n          {dayjs(createdTime).format('YYYY-MM-DD')}\n        </div>\n      </div>\n      <div className=\"shrink-0\">{children}</div>\n      <div className=\"flex shrink-0 items-center gap-0\">\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button size=\"icon-sm\" variant=\"ghost\" onClick={copyInviteUrl}>\n                <Copy className=\"size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{t('actions.copyLink')}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button size=\"icon-sm\" variant=\"ghost\" disabled={deleteDisabled} onClick={onDelete}>\n                <Trash2 className=\"size-4 cursor-pointer text-muted-foreground opacity-70 hover:opacity-100\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{t('invite.dialog.linkRemove')}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/components/RoleSelect.tsx",
    "content": "import { Role, type IRole } from '@teable/core';\nimport {\n  cn,\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  Separator,\n} from '@teable/ui-lib';\nimport { find } from 'lodash';\nimport React, { useMemo } from 'react';\nimport type { IRoleStatic } from '../types';\nimport { useRoleStatic } from '../useRoleStatic';\n\ninterface IRoleSelect {\n  className?: string;\n  value?: IRole;\n  defaultValue?: IRole;\n  disabled?: boolean;\n  options: IRoleStatic[];\n  onChange?: (value: IRole) => void;\n}\n\nexport const RoleSelect = (props: IRoleSelect) => {\n  const { className, value, defaultValue, disabled, options, onChange } = props;\n  const roleStatic = useRoleStatic();\n  const showSelectedRoleValue = useMemo(\n    () => find(roleStatic, ({ role }) => role === value)?.name,\n    [value, roleStatic]\n  );\n\n  return (\n    <Select\n      value={value || defaultValue}\n      onValueChange={(value) => onChange?.(value as IRole)}\n      disabled={disabled}\n    >\n      <SelectTrigger className={cn('w-32 bg-background', className)}>\n        <SelectValue>{showSelectedRoleValue}</SelectValue>\n      </SelectTrigger>\n      <SelectContent className=\" w-72\">\n        {options.map(({ role, name, description }) => (\n          <div key={role}>\n            {role === Role.Owner && <Separator />}\n            <SelectItem value={role}>\n              <span className=\"text-sm\">{name}</span>\n              <p className=\" text-xs text-muted-foreground\">{description}</p>\n            </SelectItem>\n          </div>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborators.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IRole } from '@teable/core';\nimport { canManageRole, Role } from '@teable/core';\nimport { Settings } from '@teable/icons';\nimport type {\n  CollaboratorItem,\n  ListSpaceCollaboratorRo,\n  UpdateBaseCollaborateRo,\n} from '@teable/openapi';\nimport {\n  PrincipalType,\n  deleteBaseCollaborator,\n  deleteSpaceCollaborator,\n  getSpaceCollaboratorList,\n  updateBaseCollaborator,\n  updateSpaceCollaborator,\n} from '@teable/openapi';\nimport { ReactQueryKeys, useSession } from '@teable/sdk';\nimport { Badge, Button, Input } from '@teable/ui-lib/shadcn';\nimport { debounce } from 'lodash';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { FC, PropsWithChildren } from 'react';\nimport React, { useCallback, useEffect, useMemo, useState } from 'react';\nimport { CollaboratorTable } from '../../collaborator/share/common/CollaboratorTable';\nimport { useFilteredRoleStatic as useFilteredBaseRoleStatic } from '../base/useFilteredRoleStatic';\nimport { useFilteredRoleStatic } from './useFilteredRoleStatic';\n\ninterface ICollaborators {\n  spaceId: string;\n  role: IRole;\n  collaboratorQuery?: ListSpaceCollaboratorRo;\n}\n\nconst MEMBERS_PER_PAGE = 50;\n\nexport const Collaborators: FC<PropsWithChildren<ICollaborators>> = (props) => {\n  const { spaceId, role: currentRole, children, collaboratorQuery } = props;\n  const [search, setSearch] = React.useState('');\n  const [inputValue, setInputValue] = useState('');\n  const [isComposing, setIsComposing] = useState(false);\n  const queryClient = useQueryClient();\n  const { t } = useTranslation('common');\n  const { user } = useSession();\n  const router = useRouter();\n\n  const setSearchDebounced = useMemo(() => {\n    return debounce(setSearch, 200);\n  }, []);\n\n  useEffect(() => {\n    if (!isComposing) {\n      setSearchDebounced(inputValue);\n    }\n  }, [inputValue, isComposing, setSearchDebounced]);\n\n  const { data, hasNextPage, fetchNextPage, isLoading } = useInfiniteQuery({\n    queryKey: collaboratorQuery\n      ? ReactQueryKeys.spaceCollaboratorList(spaceId, {\n          ...collaboratorQuery,\n          search,\n          includeBase: true,\n        })\n      : ReactQueryKeys.spaceCollaboratorList(spaceId, {\n          search,\n          includeBase: true,\n        }),\n    queryFn: ({ queryKey, pageParam }) =>\n      getSpaceCollaboratorList(queryKey[1], {\n        ...queryKey[2],\n        skip: pageParam * MEMBERS_PER_PAGE,\n        take: MEMBERS_PER_PAGE,\n      }).then((res) => res.data),\n    staleTime: 1000,\n    initialPageParam: 0,\n    getNextPageParam: (lastPage, pages) => {\n      const allCollaborators = pages.flatMap((page) => page.collaborators);\n      return allCollaborators.length >= lastPage.total ? undefined : pages.length;\n    },\n  });\n\n  const collaborators = useMemo(() => {\n    return data?.pages.flatMap((page) => page.collaborators) || [];\n  }, [data]);\n\n  const total = data?.pages[0]?.total || 0;\n\n  const { mutate: updateCollaborator, isPending: updateCollaboratorLoading } = useMutation({\n    mutationFn: ({\n      resourceId,\n      updateCollaborateRo,\n      isBase,\n    }: {\n      resourceId: string;\n      updateCollaborateRo: {\n        principalId: string;\n        principalType: PrincipalType;\n        role: IRole;\n      };\n      isBase?: boolean;\n    }) =>\n      isBase\n        ? updateBaseCollaborator({\n            baseId: resourceId,\n            updateBaseCollaborateRo: updateCollaborateRo as UpdateBaseCollaborateRo,\n          })\n        : updateSpaceCollaborator({\n            spaceId: resourceId,\n            updateSpaceCollaborateRo: updateCollaborateRo,\n          }),\n    onSuccess: async (_, context) => {\n      const { isBase, resourceId } = context;\n\n      await queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId),\n      });\n      if (isBase) {\n        queryClient.invalidateQueries({\n          queryKey: ReactQueryKeys.baseCollaboratorList(resourceId),\n        });\n      } else {\n        queryClient.invalidateQueries({ queryKey: ReactQueryKeys.space(spaceId) });\n        queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n      }\n    },\n  });\n\n  const { mutate: deleteCollaborator, isPending: deleteCollaboratorLoading } = useMutation({\n    mutationFn: ({\n      principalId,\n      resourceId,\n      principalType,\n      isBase,\n    }: {\n      principalId: string;\n      principalType: PrincipalType;\n      resourceId: string;\n      isBase?: boolean;\n    }) =>\n      isBase\n        ? deleteBaseCollaborator({\n            baseId: resourceId,\n            deleteBaseCollaboratorRo: { principalId, principalType },\n          })\n        : deleteSpaceCollaborator({\n            spaceId: resourceId,\n            deleteSpaceCollaboratorRo: { principalId, principalType },\n          }),\n    onSuccess: async (_, context) => {\n      if (context.principalId === user.id) {\n        router.push('/space');\n        queryClient.invalidateQueries({ queryKey: ReactQueryKeys.spaceList() });\n        return;\n      }\n      await queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId),\n      });\n    },\n  });\n\n  const filteredRoleStatic = useFilteredRoleStatic(currentRole);\n  const filteredBaseRoleStatic = useFilteredBaseRoleStatic(currentRole);\n\n  const goBase = (baseId: string) => {\n    router.push(`/base/${baseId}`);\n  };\n\n  const getPermissions = useCallback(\n    (item: CollaboratorItem) => {\n      const collaboratorId = item.type === PrincipalType.User ? item.userId : item.departmentId;\n      const canOperator =\n        canManageRole(currentRole, item.role) ||\n        collaboratorId === user.id ||\n        currentRole === Role.Owner;\n      return {\n        canUpdateRole: canOperator,\n        canDelete: canOperator,\n        showDelete: canOperator,\n      };\n    },\n    [currentRole, user.id]\n  );\n\n  const getFilteredRoleStatic = useCallback(\n    (item: CollaboratorItem) => {\n      const isBase = Boolean(item.base);\n      return isBase ? filteredBaseRoleStatic : filteredRoleStatic;\n    },\n    [filteredBaseRoleStatic, filteredRoleStatic]\n  );\n\n  const handleUpdateRole = useCallback(\n    (role: IRole, item: CollaboratorItem) => {\n      const isBase = Boolean(item.base);\n      const collaboratorId = item.type === PrincipalType.User ? item.userId : item.departmentId;\n      updateCollaborator({\n        resourceId: item.base ? item.base.id : spaceId,\n        updateCollaborateRo: {\n          principalId: collaboratorId,\n          principalType: item.type,\n          role,\n        },\n        isBase,\n      });\n    },\n    [spaceId, updateCollaborator]\n  );\n\n  const handleDelete = useCallback(\n    (item: CollaboratorItem) => {\n      const isBase = Boolean(item.base);\n      const collaboratorId = item.type === PrincipalType.User ? item.userId : item.departmentId;\n      deleteCollaborator({\n        resourceId: item.base ? item.base.id : spaceId,\n        principalId: collaboratorId,\n        principalType: item.type,\n        isBase,\n      });\n    },\n    [spaceId, deleteCollaborator]\n  );\n\n  const renderTips = useCallback(\n    (item: CollaboratorItem) => {\n      if (!item.base) return null;\n      return (\n        <div className=\"inline-flex items-center gap-2\">\n          <Badge className=\"text-muted-foreground\" variant=\"outline\">\n            {item.base.name}\n          </Badge>\n          <Button\n            className=\"h-auto p-0.5\"\n            size=\"xs\"\n            variant=\"ghost\"\n            onClick={() => goBase(item.base!.id)}\n          >\n            <Settings className=\"size-4 shrink-0\" />\n          </Button>\n        </div>\n      );\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    []\n  );\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"mb-4 flex w-full items-center gap-x-4\">\n        <Input\n          type=\"search\"\n          placeholder={t('invite.dialog.collaboratorSearchPlaceholder')}\n          value={inputValue}\n          onChange={(e) => setInputValue(e.target.value)}\n          onCompositionStart={() => setIsComposing(true)}\n          onCompositionEnd={() => setIsComposing(false)}\n        />\n        {children}\n      </div>\n      <div className=\"flex min-h-0 flex-1 flex-col\">\n        <CollaboratorTable\n          list={collaborators}\n          total={total}\n          hasNextPage={hasNextPage}\n          fetchNextPage={fetchNextPage}\n          isLoading={isLoading}\n          updateRoleLoading={updateCollaboratorLoading}\n          deleteLoading={deleteCollaboratorLoading}\n          getPermissions={getPermissions}\n          getFilteredRoleStatic={getFilteredRoleStatic}\n          onUpdateRole={handleUpdateRole}\n          onDelete={handleDelete}\n          renderTips={renderTips}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/space/SpaceInvite.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IRole } from '@teable/core';\nimport { hasPermission, Role } from '@teable/core';\nimport { createSpaceInvitationLink, emailSpaceInvitation } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useState } from 'react';\nimport { Invite } from '../components/Invite';\nimport { RoleSelect } from '../components/RoleSelect';\nimport { useFilteredRoleStatic } from './useFilteredRoleStatic';\n\nexport const SpaceInvite = (props: { spaceId: string; role: IRole }) => {\n  const { role, spaceId } = props;\n  const [inviteRole, setInviteRole] = useState<IRole>(role === Role.Owner ? Role.Creator : role);\n  const queryClient = useQueryClient();\n\n  const { mutate: emailInvitation, isPending: updateCollaboratorLoading } = useMutation({\n    mutationFn: emailSpaceInvitation,\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId),\n      });\n    },\n  });\n\n  const sendInviteEmail = async (emails: string[]) => {\n    emailInvitation({\n      spaceId,\n      emailSpaceInvitationRo: {\n        emails,\n        role: inviteRole,\n      },\n    });\n  };\n\n  const { mutate: createInviteLinkRequest, isPending: createInviteLinkLoading } = useMutation({\n    mutationFn: createSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const createInviteLink = async () => {\n    await createInviteLinkRequest({ spaceId, createSpaceInvitationLinkRo: { role: inviteRole } });\n  };\n\n  const filteredRoleStatic = useFilteredRoleStatic(role);\n\n  return (\n    <Invite\n      disabledLink={!hasPermission(role, 'space|invite_link')}\n      sendInviteEmail={sendInviteEmail}\n      createInviteLink={createInviteLink}\n      loading={{\n        sendInviteEmail: updateCollaboratorLoading,\n        createInviteLink: createInviteLinkLoading,\n      }}\n      roleSelect={\n        <RoleSelect\n          className=\"mx-1\"\n          value={inviteRole}\n          options={filteredRoleStatic}\n          onChange={(role) => setInviteRole(role)}\n        />\n      }\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/space/SpaceInviteLink.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IRole } from '@teable/core';\nimport {\n  deleteSpaceInvitationLink,\n  listSpaceInvitationLink,\n  updateSpaceInvitationLink,\n} from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport { InviteLinkItem } from '../components/InviteLinkItem';\nimport { RoleSelect } from '../components/RoleSelect';\nimport { useFilteredRoleStatic } from './useFilteredRoleStatic';\n\nexport const SpaceInviteLink = (props: { spaceId: string; role: IRole }) => {\n  const { spaceId, role } = props;\n  const queryClient = useQueryClient();\n  const { t } = useTranslation('common');\n\n  const linkList = useQuery({\n    queryKey: ['invite-link-list', spaceId],\n    queryFn: ({ queryKey }) => listSpaceInvitationLink(queryKey[1]).then((res) => res.data),\n  }).data;\n\n  const { mutate: updateInviteLink, isPending: updateInviteLinkLoading } = useMutation({\n    mutationFn: updateSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const onUpdateInviteLink = async (invitationId: string, role: IRole) => {\n    updateInviteLink({\n      invitationId,\n      updateSpaceInvitationLinkRo: { role },\n      spaceId,\n    });\n  };\n\n  const { mutate: deleteInviteLink, isPending: deleteInviteLinkLoading } = useMutation({\n    mutationFn: deleteSpaceInvitationLink,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['invite-link-list'] });\n    },\n  });\n\n  const onDeleteInviteLink = async (invitationId: string) => {\n    deleteInviteLink({ invitationId, spaceId });\n  };\n\n  const filteredRoleStatic = useFilteredRoleStatic(role);\n\n  if (!linkList?.length) {\n    return <></>;\n  }\n\n  return (\n    <div>\n      <div className=\"mb-3 text-sm text-muted-foreground\">{t('invite.dialog.linkTitle')}</div>\n      <div className=\"space-y-3\">\n        {linkList.map(({ invitationId, inviteUrl, createdTime, role }) => (\n          <InviteLinkItem\n            key={invitationId}\n            url={inviteUrl}\n            createdTime={createdTime}\n            onDelete={() => onDeleteInviteLink(invitationId)}\n            deleteDisabled={deleteInviteLinkLoading}\n          >\n            <RoleSelect\n              value={role}\n              options={filteredRoleStatic}\n              disabled={updateInviteLinkLoading}\n              onChange={(role) => onUpdateInviteLink(invitationId, role)}\n            />\n          </InviteLinkItem>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/space/useFilteredRoleStatic.ts",
    "content": "import type { IRole } from '@teable/core';\nimport { useMemo } from 'react';\nimport { useRoleStatic } from '../useRoleStatic';\nimport { getRolesWithLowerPermissions } from '../utils';\n\nexport const useFilteredRoleStatic = (role: IRole) => {\n  const spaceRoleStatic = useRoleStatic();\n  return useMemo(\n    () => getRolesWithLowerPermissions(role, spaceRoleStatic),\n    [role, spaceRoleStatic]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/space-inner/Collaborators.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IRole } from '@teable/core';\nimport { Building2 } from '@teable/icons';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport { getSpaceCollaboratorList, PrincipalType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk';\nimport { Badge, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\nimport { InviteSpacePopover } from '../../collaborator/space/InviteSpacePopover';\n\ninterface SpaceInnerCollaboratorProps {\n  spaceId: string;\n  role?: IRole;\n  space: IGetSpaceVo;\n}\nconst MEMBERS_PER_PAGE = 50;\n\nexport const Collaborators: React.FC<SpaceInnerCollaboratorProps> = (props) => {\n  const { spaceId, space } = props;\n  const { t } = useTranslation('space');\n\n  const { data } = useQuery({\n    queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId, {\n      skip: 0,\n      take: MEMBERS_PER_PAGE,\n      orderBy: 'asc',\n      includeBase: true,\n    }),\n    queryFn: ({ queryKey }) =>\n      getSpaceCollaboratorList(queryKey[1], queryKey[2]).then((res) => res.data),\n  });\n\n  const collaborators = data?.collaborators || [];\n  const maxDisplay = 30;\n  const displayedCollaborators = collaborators.slice(0, maxDisplay);\n  const hasMore = collaborators.length > maxDisplay;\n\n  return (\n    <div>\n      <h2 className=\"mb-4 font-medium\">{t('spaceSetting.collaborators')}</h2>\n      <ul className=\"space-y-3\">\n        {displayedCollaborators.map((item) => {\n          const isBase = Boolean(item.base);\n          return (\n            <li\n              key={item.type === PrincipalType.User ? item.userId : item.departmentId}\n              className=\"flex items-center space-x-3\"\n            >\n              {item.type === PrincipalType.User ? (\n                <UserAvatar user={{ name: item.userName, avatar: item.avatar }} />\n              ) : (\n                <Building2 className=\"size-7\" />\n              )}\n              <div className=\"min-w-0 flex-1\">\n                <p\n                  className=\"truncate text-sm font-medium\"\n                  title={item.type === PrincipalType.User ? item.userName : item.departmentName}\n                >\n                  {item.type === PrincipalType.User ? item.userName : item.departmentName}\n                </p>\n                {isBase && (\n                  <Badge className=\"mt-1 text-xs text-muted-foreground\" variant=\"outline\">\n                    {item.base?.name}\n                  </Badge>\n                )}\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n      {hasMore && (\n        <div className=\"mt-4 flex\">\n          <InviteSpacePopover space={space}>\n            <Button\n              variant=\"link\"\n              size=\"sm\"\n              className=\"text-xs text-muted-foreground hover:text-foreground\"\n            >\n              +{collaborators.length - maxDisplay} {t('more')}\n            </Button>\n          </InviteSpacePopover>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/types.ts",
    "content": "import type { IRole } from '@teable/core';\n\nexport interface IRoleStatic {\n  role: IRole;\n  name: string;\n  description: string;\n  level: number;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/useRoleStatic.ts",
    "content": "import { Role } from '@teable/core';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { IRoleStatic } from './types';\n\nexport const useRoleStatic = (): IRoleStatic[] => {\n  const { t } = useTranslation('common');\n  return useMemo(() => {\n    return [\n      {\n        role: Role.Creator,\n        name: t('role.title.creator'),\n        description: t('role.description.creator'),\n        level: 1,\n      },\n      {\n        role: Role.Editor,\n        name: t('role.title.editor'),\n        description: t('role.description.editor'),\n        level: 2,\n      },\n      {\n        role: Role.Commenter,\n        name: t('role.title.commenter'),\n        description: t('role.description.commenter'),\n        level: 3,\n      },\n      {\n        role: Role.Viewer,\n        name: t('role.title.viewer'),\n        description: t('role.description.viewer'),\n        level: 4,\n      },\n      {\n        role: Role.Owner,\n        name: t('role.title.owner'),\n        description: t('role.description.owner'),\n        level: 0,\n      },\n    ];\n  }, [t]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/collaborator-manage/utils.ts",
    "content": "import type { IRole } from '@teable/core';\nimport type { IRoleStatic } from './types';\n\nexport const getRolesWithLowerPermissions = (\n  role: IRole,\n  roleStatic: IRoleStatic[],\n  includeRole: boolean = true\n) => {\n  const roleLevel = roleStatic.find((item) => item.role === role)?.level;\n  if (roleLevel == undefined) {\n    return [];\n  }\n  return roleStatic.filter(({ level }) => (includeRole ? level >= roleLevel : level > roleLevel));\n};\n\nexport const getRolesWithHigherPermissions = (\n  role: IRole,\n  roleStatic: IRoleStatic[],\n  includeRole: boolean = true\n) => {\n  const roleLevel = roleStatic.find((item) => item.role === role)?.level;\n  if (roleLevel == undefined) {\n    return [];\n  }\n  return roleStatic.filter(({ level }) => (includeRole ? level <= roleLevel : level < roleLevel));\n};\n\nexport const filterCollaborators = <T extends { userName: string; email: string }>(\n  search: string,\n  collaborators?: T[]\n) => {\n  if (!search) return collaborators;\n  return collaborators?.filter(({ userName, email }) => {\n    const searchLower = search.toLowerCase();\n    const usernameLower = userName.toLowerCase();\n    const emailLower = email.toLowerCase();\n    return !search || usernameLower.includes(searchLower) || emailLower.includes(searchLower);\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/CellDownloadHandler.tsx",
    "content": "'use client';\n\nimport { sonner } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useRef } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IDownloadProgress } from '../../utils/download-all-attachments';\nimport {\n  downloadCellAttachments,\n  downloadSingleAttachment,\n  isStreamingDownloadAvailable,\n} from '../../utils/download-all-attachments';\nimport { DownloadProgressToast } from '../DownloadProgressToast';\nimport { useDownloadAttachmentsStore } from './useDownloadAttachmentsStore';\n\nconst { toast } = sonner;\n\n/**\n * Handler component that listens to cell download signals and performs the download\n * Should be placed at a high level in the component tree (e.g., Table.tsx)\n */\nexport const CellDownloadHandler = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const cellDownload = useDownloadAttachmentsStore((state) => state.cellDownload);\n  const clearCellDownload = useDownloadAttachmentsStore((state) => state.clearCellDownload);\n  const processingRef = useRef(false);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const handleDownload = useCallback(async () => {\n    const { attachments, zipFileName } = cellDownload;\n    if (!attachments || attachments.length === 0 || processingRef.current) return;\n\n    processingRef.current = true;\n    clearCellDownload();\n\n    try {\n      // Single attachment - download directly\n      if (attachments.length === 1) {\n        downloadSingleAttachment(attachments[0]);\n        return;\n      }\n\n      // Multiple attachments - check streaming support\n      if (!isStreamingDownloadAvailable()) {\n        toast.error(t('sdk:editor.attachment.requireHttps'));\n        return;\n      }\n\n      const abortController = new AbortController();\n      abortControllerRef.current = abortController;\n\n      // Calculate total size for progress\n      const totalSize = attachments.reduce((sum, a) => sum + (a.size || 0), 0);\n\n      const toastId = toast.custom(\n        () => (\n          <DownloadProgressToast\n            progress={{ downloaded: 0, total: totalSize, currentFileName: '', percent: 0 }}\n            onCancel={() => {\n              abortController.abort();\n              toast.dismiss(toastId);\n            }}\n          />\n        ),\n        { duration: Infinity, unstyled: true, classNames: { toast: 'bg-transparent shadow-none' } }\n      );\n\n      const updateProgress = (progress: IDownloadProgress) => {\n        toast.custom(\n          () => (\n            <DownloadProgressToast\n              progress={progress}\n              onCancel={() => {\n                abortController.abort();\n                toast.dismiss(toastId);\n              }}\n            />\n          ),\n          {\n            id: toastId,\n            duration: Infinity,\n            unstyled: true,\n            classNames: { toast: 'bg-transparent shadow-none border rounded-lg' },\n          }\n        );\n      };\n\n      const result = await downloadCellAttachments({\n        attachments,\n        zipFileName: zipFileName || 'attachments.zip',\n        onProgress: updateProgress,\n        abortController,\n      });\n\n      toast.dismiss(toastId);\n\n      if (result.cancelled) {\n        toast.info(t('sdk:editor.attachment.downloadCancelled'));\n        return;\n      }\n\n      if (result.success) {\n        toast.success(t('sdk:editor.attachment.downloadSuccess'));\n      } else if (result.failedFiles.length > 0) {\n        toast.warning(\n          `${t('sdk:editor.attachment.downloadFailed')}: ${result.failedFiles.slice(0, 3).join(', ')}${result.failedFiles.length > 3 ? '...' : ''}`\n        );\n      }\n    } catch (error) {\n      if ((error as Error).name !== 'AbortError') {\n        toast.error(t('sdk:editor.attachment.downloadFailed'));\n      }\n    } finally {\n      processingRef.current = false;\n      abortControllerRef.current = null;\n    }\n  }, [cellDownload, clearCellDownload, t]);\n\n  useEffect(() => {\n    if (cellDownload.attachments && cellDownload.attachments.length > 0) {\n      handleDownload();\n    }\n  }, [cellDownload, handleDownload]);\n\n  return null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/DownloadAllAttachmentsDialog.tsx",
    "content": "import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { DynamicDownloadContent } from './DynamicDownloadContent';\nimport { useColumnDownloadDialogStore } from './useDownloadAttachmentsStore';\n\nexport const DownloadAllAttachmentsDialog = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const {\n    open,\n    tableId,\n    fieldId,\n    fieldName,\n    viewId,\n    shareId,\n    personalViewCommonQuery,\n    closeDialog,\n  } = useColumnDownloadDialogStore();\n\n  if (!open || !tableId || !fieldId || !fieldName) {\n    return null;\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={(isOpen) => !isOpen && closeDialog()}>\n      <DialogContent className=\"max-w-sm\">\n        <DialogHeader>\n          <DialogTitle>{t('table:download.allAttachments.title')}</DialogTitle>\n        </DialogHeader>\n        <DynamicDownloadContent\n          tableId={tableId}\n          fieldId={fieldId}\n          fieldName={fieldName}\n          viewId={viewId}\n          shareId={shareId}\n          personalViewCommonQuery={personalViewCommonQuery}\n          onClose={closeDialog}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/DownloadContent.tsx",
    "content": "import type { IFieldVo } from '@teable/core';\nimport { HelpCircle, ChevronDown, ChevronRight, Check } from '@teable/icons';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport { useFields, useFieldStaticGetter } from '@teable/sdk';\nimport {\n  Button,\n  Checkbox,\n  cn,\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Skeleton,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IAttachmentPreview, IDownloadProgress } from '../../utils/download-all-attachments';\nimport {\n  downloadAllAttachments,\n  formatFileSize,\n  getAttachmentPreview,\n  isFieldSuitableForNaming,\n} from '../../utils/download-all-attachments';\nimport { DownloadProgressToast } from '../DownloadProgressToast';\nimport { useColumnDownloadDialogStore } from './useDownloadAttachmentsStore';\n\ninterface IDownloadContentProps {\n  tableId: string;\n  fieldId: string;\n  fieldName: string;\n  viewId?: string;\n  shareId?: string;\n  personalViewCommonQuery?: IGetRecordsRo;\n  onClose: () => void;\n}\n\nexport const DownloadContent = ({\n  tableId,\n  fieldId,\n  fieldName,\n  viewId,\n  shareId,\n  personalViewCommonQuery,\n  onClose,\n}: IDownloadContentProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [loading, setLoading] = useState(true);\n  const [preview, setPreview] = useState<IAttachmentPreview | null>(null);\n  const [downloading, setDownloading] = useState(false);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const { namingFieldId, setNamingFieldId, groupByRow, setGroupByRow } =\n    useColumnDownloadDialogStore();\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const fieldStaticGetter = useFieldStaticGetter();\n  const [selectorOpen, setSelectorOpen] = useState(false);\n\n  // Filter fields suitable for naming (text-based fields)\n  const namingFields = useMemo(() => {\n    return allFields.filter((field) => isFieldSuitableForNaming(field as unknown as IFieldVo));\n  }, [allFields]);\n\n  // Get the selected naming field instance for download\n  // When namingFieldId is undefined, return undefined (use row number prefix)\n  const namingField = useMemo(() => {\n    if (!namingFieldId) return undefined;\n    return allFields.find((f) => f.id === namingFieldId);\n  }, [namingFieldId, allFields]);\n\n  // Handle field selection - toggle if clicking the same field (deselect)\n  const handleFieldSelect = useCallback(\n    (selectedValue: string) => {\n      setSelectorOpen(false);\n      if (selectedValue === namingFieldId) {\n        // Deselect if clicking the same field\n        setNamingFieldId(undefined);\n      } else {\n        setNamingFieldId(selectedValue);\n      }\n    },\n    [namingFieldId, setNamingFieldId]\n  );\n\n  // Load preview on mount\n  useEffect(() => {\n    const loadPreview = async () => {\n      try {\n        const previewData = await getAttachmentPreview(\n          tableId,\n          fieldId,\n          viewId,\n          shareId,\n          personalViewCommonQuery\n        );\n        setPreview(previewData);\n      } catch (error) {\n        console.error('Failed to load preview:', error);\n        onClose();\n        toast.error(t('table:download.allAttachments.error'));\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadPreview();\n  }, [tableId, fieldId, viewId, shareId, personalViewCommonQuery, onClose, t]);\n\n  const handleStartDownload = useCallback(async () => {\n    if (!preview || preview.totalAttachments === 0) return;\n\n    // Check if Service Worker is available (requires HTTPS or localhost)\n    if (typeof window !== 'undefined' && !navigator.serviceWorker) {\n      toast.error(t('table:download.allAttachments.requireHttps'));\n      return;\n    }\n\n    setDownloading(true);\n    onClose();\n\n    const abortController = new AbortController();\n    abortControllerRef.current = abortController;\n\n    const toastId = toast.custom(\n      () => (\n        <DownloadProgressToast\n          progress={{ downloaded: 0, total: 0, currentFileName: '', percent: 0 }}\n          onCancel={() => {\n            abortController.abort();\n            toast.dismiss(toastId);\n          }}\n        />\n      ),\n      { duration: Infinity, unstyled: true, classNames: { toast: 'bg-transparent shadow-none' } }\n    );\n\n    const updateProgress = (progress: IDownloadProgress) => {\n      toast.custom(\n        () => (\n          <DownloadProgressToast\n            progress={progress}\n            onCancel={() => {\n              abortController.abort();\n              toast.dismiss(toastId);\n            }}\n          />\n        ),\n        {\n          id: toastId,\n          duration: Infinity,\n          unstyled: true,\n          classNames: { toast: 'bg-transparent shadow-none border rounded-lg' },\n        }\n      );\n    };\n\n    try {\n      updateProgress({\n        downloaded: 0,\n        total: preview.totalSize,\n        currentFileName: '',\n        percent: 0,\n      });\n\n      const result = await downloadAllAttachments({\n        tableId,\n        fieldId,\n        fieldName,\n        viewId,\n        shareId,\n        personalViewCommonQuery,\n        namingField,\n        groupByRow,\n        abortController,\n        onProgress: updateProgress,\n      });\n\n      toast.dismiss(toastId);\n\n      if (result.cancelled) {\n        toast.info(t('table:download.allAttachments.cancelled'));\n      } else if (result.success) {\n        toast.success(t('table:download.allAttachments.completed'));\n      } else if (result.failedFiles.length > 0) {\n        toast.warning(\n          t('table:download.allAttachments.errorPartial', {\n            failedCount: result.failedFiles.length,\n          })\n        );\n      }\n    } catch (error) {\n      toast.dismiss(toastId);\n      console.error('Download failed:', error);\n      toast.error(t('table:download.allAttachments.error'));\n    } finally {\n      setDownloading(false);\n      abortControllerRef.current = null;\n    }\n  }, [\n    preview,\n    tableId,\n    fieldId,\n    fieldName,\n    viewId,\n    shareId,\n    namingField,\n    groupByRow,\n    personalViewCommonQuery,\n    onClose,\n    t,\n  ]);\n\n  if (loading) {\n    return (\n      <>\n        <div className=\"flex flex-col gap-3 py-4\">\n          <Skeleton className=\"h-5 w-48\" />\n          <Skeleton className=\"h-5 w-36\" />\n          <Skeleton className=\"h-5 w-40\" />\n        </div>\n        <div className=\"flex justify-end gap-2\">\n          <Button variant=\"outline\" onClick={onClose}>\n            {t('table:download.allAttachments.cancel')}\n          </Button>\n          <Button disabled>{t('table:download.allAttachments.startDownload')}</Button>\n        </div>\n      </>\n    );\n  }\n\n  if (!preview || preview.totalAttachments === 0) {\n    return (\n      <>\n        <div className=\"py-4\">\n          <p className=\"text-muted-foreground\">\n            {t('table:download.allAttachments.noAttachments')}\n          </p>\n        </div>\n        <div className=\"flex justify-end\">\n          <Button variant=\"outline\" onClick={onClose}>\n            {t('table:download.allAttachments.cancel')}\n          </Button>\n        </div>\n      </>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-3 py-4\">\n        <p className=\"text-sm\">\n          {t('table:download.allAttachments.rowsWithAttachments', {\n            count: preview.rowsWithAttachments,\n          })}\n        </p>\n        <p className=\"text-sm\">\n          {t('table:download.allAttachments.totalAttachments', {\n            count: preview.totalAttachments,\n          })}\n        </p>\n        <p className=\"text-sm font-medium\">\n          {t('table:download.allAttachments.totalSize', {\n            size: formatFileSize(preview.totalSize),\n          })}\n        </p>\n\n        {/* Advanced options */}\n        <Collapsible>\n          <CollapsibleTrigger className=\"flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground\">\n            {t('table:download.allAttachments.advancedOptions')}\n            <ChevronRight className=\"size-4 transition-transform duration-200 [[data-state=open]>&]:rotate-90\" />\n          </CollapsibleTrigger>\n          <CollapsibleContent className=\"space-y-2 pt-2\">\n            {/* Naming field selector */}\n            <div className=\"space-y-1\">\n              <Label className=\"text-sm text-muted-foreground\">\n                {t('table:download.allAttachments.namingFieldLabel')}\n              </Label>\n              <Popover open={selectorOpen} onOpenChange={setSelectorOpen} modal>\n                <PopoverTrigger asChild>\n                  <Button\n                    variant=\"outline\"\n                    role=\"combobox\"\n                    aria-expanded={selectorOpen}\n                    className=\"w-full justify-between dark:bg-[color-mix(in_oklab,white_10%,hsl(var(--background)))]\"\n                  >\n                    <div className=\"flex items-center gap-2 truncate\">\n                      {namingFieldId ? (\n                        (() => {\n                          const selectedField = namingFields.find((f) => f.id === namingFieldId);\n                          if (!selectedField) return null;\n                          const { Icon } = fieldStaticGetter(selectedField.type, {\n                            isLookup: selectedField.isLookup,\n                            isConditionalLookup: selectedField.isConditionalLookup,\n                            hasAiConfig: Boolean(selectedField.aiConfig),\n                            deniedReadRecord: !selectedField.canReadFieldRecord,\n                          });\n                          return (\n                            <>\n                              <Icon className=\"size-4 shrink-0\" />\n                              <span className=\"truncate\">{selectedField.name}</span>\n                            </>\n                          );\n                        })()\n                      ) : (\n                        <span className=\"text-muted-foreground\">\n                          {t('table:download.allAttachments.selectField')}\n                        </span>\n                      )}\n                    </div>\n                    <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"w-[200px] p-0\">\n                  <Command>\n                    <CommandInput placeholder={t('common:actions.search')} />\n                    <CommandList className=\"max-h-60\">\n                      <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n                      <CommandGroup>\n                        {namingFields.map((field) => {\n                          const { Icon } = fieldStaticGetter(field.type, {\n                            isLookup: field.isLookup,\n                            isConditionalLookup: field.isConditionalLookup,\n                            hasAiConfig: Boolean(field.aiConfig),\n                            deniedReadRecord: !field.canReadFieldRecord,\n                          });\n                          return (\n                            <CommandItem\n                              key={field.id}\n                              value={field.id}\n                              keywords={[field.name]}\n                              onSelect={() => handleFieldSelect(field.id)}\n                            >\n                              <Icon className=\"mr-2 size-4\" />\n                              <span className=\"truncate\">{field.name}</span>\n                              <Check\n                                className={cn(\n                                  'ml-auto size-4',\n                                  namingFieldId === field.id ? 'opacity-100' : 'opacity-0'\n                                )}\n                              />\n                            </CommandItem>\n                          );\n                        })}\n                      </CommandGroup>\n                    </CommandList>\n                  </Command>\n                </PopoverContent>\n              </Popover>\n            </div>\n\n            {/* Group by row option */}\n            <div className=\"flex items-center gap-2\">\n              <Checkbox\n                id=\"groupByRow\"\n                checked={groupByRow}\n                onCheckedChange={(checked) => setGroupByRow(checked === true)}\n              />\n              <Label htmlFor=\"groupByRow\" className=\"cursor-pointer text-sm\">\n                {t('table:download.allAttachments.groupByRow')}\n              </Label>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger>\n                    <HelpCircle className=\"size-4 cursor-pointer text-muted-foreground\" />\n                  </TooltipTrigger>\n                  <TooltipContent side=\"right\" sideOffset={5}>\n                    <p className=\"max-w-xs\">{t('table:download.allAttachments.groupByRowTip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            </div>\n          </CollapsibleContent>\n        </Collapsible>\n      </div>\n      <div className=\"flex justify-end gap-2\">\n        <Button variant=\"outline\" onClick={onClose} disabled={downloading}>\n          {t('table:download.allAttachments.cancel')}\n        </Button>\n        <Button onClick={handleStartDownload} disabled={downloading}>\n          {t('table:download.allAttachments.startDownload')}\n        </Button>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/DynamicDownloadContent.tsx",
    "content": "import { Skeleton } from '@teable/ui-lib/shadcn';\nimport dynamic from 'next/dynamic';\n\nexport const DynamicDownloadContent = dynamic(\n  () => import('./DownloadContent').then((mod) => mod.DownloadContent),\n  {\n    loading: () => (\n      <div className=\"flex flex-col gap-3 py-4\">\n        <Skeleton className=\"h-5 w-48\" />\n        <Skeleton className=\"h-5 w-36\" />\n        <Skeleton className=\"h-5 w-40\" />\n      </div>\n    ),\n    ssr: false,\n  }\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/index.ts",
    "content": "export { CellDownloadHandler } from './CellDownloadHandler';\nexport { DownloadAllAttachmentsDialog } from './DownloadAllAttachmentsDialog';\nexport {\n  useDownloadAttachmentsStore,\n  useColumnDownloadDialogStore,\n} from './useDownloadAttachmentsStore';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/download-attachments/useDownloadAttachmentsStore.ts",
    "content": "import type { IGetRecordsRo } from '@teable/openapi';\n// Re-export cell download store from SDK for convenience\nexport { useDownloadAttachmentsStore } from '@teable/sdk';\nimport { create } from 'zustand';\n\n// Column download dialog state - app-specific\ninterface IColumnDownloadDialogState {\n  open: boolean;\n  tableId?: string;\n  fieldId?: string;\n  fieldName?: string;\n  viewId?: string;\n  shareId?: string;\n  personalViewCommonQuery?: IGetRecordsRo;\n  namingFieldId?: string;\n  groupByRow: boolean;\n\n  openDialog: (params: {\n    tableId: string;\n    fieldId: string;\n    fieldName: string;\n    viewId?: string;\n    shareId?: string;\n    personalViewCommonQuery?: IGetRecordsRo;\n  }) => void;\n  closeDialog: () => void;\n  setNamingFieldId: (namingFieldId?: string) => void;\n  setGroupByRow: (groupByRow: boolean) => void;\n}\n\nexport const useColumnDownloadDialogStore = create<IColumnDownloadDialogState>((set) => ({\n  open: false,\n  groupByRow: false,\n\n  openDialog: (params) =>\n    set({\n      open: true,\n      namingFieldId: undefined, // Reset naming field when opening dialog\n      groupByRow: false, // Reset group by row when opening dialog\n      ...params,\n    }),\n  closeDialog: () =>\n    set({\n      open: false,\n      tableId: undefined,\n      fieldId: undefined,\n      fieldName: undefined,\n      viewId: undefined,\n      shareId: undefined,\n      personalViewCommonQuery: undefined,\n      namingFieldId: undefined,\n      groupByRow: false,\n    }),\n  setNamingFieldId: (namingFieldId) => set({ namingFieldId }),\n  setGroupByRow: (groupByRow) => set({ groupByRow }),\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/emoji/Emoji.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\n\ninterface IEmoji {\n  className?: string;\n  emoji: string;\n  size?: number | string;\n}\n\nexport const Emoji: React.FC<IEmoji> = ({ emoji, size = 24, className }) => {\n  return (\n    <div className={cn('w-full h-full flex items-center justify-center', className)}>\n      <span\n        style={{\n          fontFamily:\n            'EmojiMart, \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Segoe UI\", \"Apple Color Emoji\", \"Twemoji Mozilla\", \"Noto Color Emoji\", \"Android Emoji\"',\n          fontSize: typeof size === 'number' ? `${size}px` : size,\n        }}\n      >\n        {emoji}\n      </span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/emoji/EmojiPicker.tsx",
    "content": "import emojiData from '@emoji-mart/data';\nimport EmojiPickerCom from '@emoji-mart/react';\nimport { useTheme } from '@teable/next-themes';\nimport { cn, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport type { FC, PropsWithChildren } from 'react';\n\ninterface IEmojiPicker {\n  className?: string;\n  disabled?: boolean;\n  onChange?: (emoji: string) => void;\n}\n\nexport const EmojiPicker: FC<PropsWithChildren<IEmojiPicker>> = (props) => {\n  const { children, className, onChange, disabled } = props;\n  const { resolvedTheme } = useTheme();\n\n  if (disabled) {\n    return <div className={cn('rounded transition-colors', className)}>{children}</div>;\n  }\n\n  const onEmojiSelect = (emoji: { native: string }) => {\n    onChange?.(emoji.native);\n  };\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <div className={cn('rounded transition-colors', className)}>{children}</div>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto overflow-hidden p-0\">\n        <EmojiPickerCom theme={resolvedTheme} data={emojiData} onEmojiSelect={onEmojiSelect} />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/expand-record-container/ExpandRecordContainer.tsx",
    "content": "import type { IAttachmentCellValue, IRecord } from '@teable/core';\nimport type { IButtonClickStatusHook } from '@teable/sdk/hooks';\nimport { useTableId, useViewId } from '@teable/sdk/hooks';\nimport { useRouter } from 'next/router';\nimport { forwardRef, useCallback } from 'react';\nimport { useDownloadAttachmentsStore } from '../download-attachments';\nimport { ExpandRecordContainerBase } from './ExpandRecordContainerBase';\nimport type { IExpandRecordContainerRef } from './types';\n\nexport const ExpandRecordContainer = forwardRef<\n  IExpandRecordContainerRef,\n  { recordServerData?: IRecord; buttonClickStatusHook?: IButtonClickStatusHook }\n>((props, forwardRef) => {\n  const { recordServerData, buttonClickStatusHook } = props;\n  const router = useRouter();\n  const tableId = useTableId();\n  const viewId = useViewId();\n  const recordId = router.query.recordId as string;\n  const triggerCellDownload = useDownloadAttachmentsStore((state) => state.triggerCellDownload);\n\n  const onClose = useCallback(() => {\n    if (!recordId) {\n      return;\n    }\n    const {\n      recordId: _recordId,\n      fromNotify: _fromNotify,\n      commentId: _commentId,\n      showHistory: _showHistory,\n      showComment: _showComment,\n      ...resetQuery\n    } = router.query;\n    router.push(\n      {\n        pathname: router.pathname,\n        query: resetQuery,\n      },\n      undefined,\n      {\n        shallow: true,\n      }\n    );\n  }, [recordId, router]);\n\n  const onUpdateRecordIdCallback = useCallback(\n    (recordId: string) => {\n      router.push(\n        {\n          pathname: router.pathname,\n          query: { ...router.query, recordId },\n        },\n        undefined,\n        {\n          shallow: true,\n        }\n      );\n    },\n    [router]\n  );\n\n  const onAttachmentDownload = useCallback(\n    (attachments: IAttachmentCellValue) => {\n      triggerCellDownload(attachments);\n    },\n    [triggerCellDownload]\n  );\n\n  if (!tableId) {\n    return <></>;\n  }\n\n  return (\n    <ExpandRecordContainerBase\n      ref={forwardRef}\n      tableId={tableId}\n      viewId={viewId}\n      recordServerData={recordServerData}\n      onClose={onClose}\n      onUpdateRecordIdCallback={onUpdateRecordIdCallback}\n      buttonClickStatusHook={buttonClickStatusHook}\n      onAttachmentDownload={onAttachmentDownload}\n    />\n  );\n});\n\nExpandRecordContainer.displayName = 'ExpandRecordContainer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/expand-record-container/ExpandRecordContainerBase.tsx",
    "content": "import type { IAttachmentCellValue, IRecord } from '@teable/core';\nimport type { IButtonClickStatusHook } from '@teable/sdk';\nimport { ExpandRecorder, ExpandRecordModel } from '@teable/sdk';\nimport { useRouter } from 'next/router';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\nimport type { IExpandRecordContainerRef } from './types';\n\nexport const ExpandRecordContainerBase = forwardRef<\n  IExpandRecordContainerRef,\n  {\n    tableId: string;\n    viewId?: string;\n    recordServerData?: IRecord;\n    onClose?: () => void;\n    onUpdateRecordIdCallback?: (recordId: string) => void;\n    buttonClickStatusHook?: IButtonClickStatusHook;\n    onAttachmentDownload?: (attachments: IAttachmentCellValue) => void;\n  }\n>((props, forwardRef) => {\n  const {\n    tableId,\n    viewId,\n    recordServerData,\n    onClose,\n    onUpdateRecordIdCallback,\n    buttonClickStatusHook,\n    onAttachmentDownload,\n  } = props;\n  const router = useRouter();\n  const {\n    recordId: routerRecordId,\n    commentId: routerCommentId,\n    showHistory: routerShowHistory,\n    showComment: routerShowComment,\n  } = router.query;\n  const recordId = routerRecordId as string;\n  const commentId = routerCommentId as string;\n  const showHistory = routerShowHistory === 'true';\n  const showComment = { true: true, false: false }[routerShowComment as string];\n\n  const [recordIds, setRecordIds] = useState<string[]>();\n\n  useImperativeHandle(forwardRef, () => ({\n    updateRecordIds: setRecordIds,\n  }));\n\n  return (\n    <ExpandRecorder\n      tableId={tableId}\n      viewId={viewId}\n      recordId={recordId}\n      commentId={commentId}\n      recordIds={recordIds}\n      serverData={recordServerData}\n      model={ExpandRecordModel.Modal}\n      onClose={onClose}\n      onUpdateRecordIdCallback={onUpdateRecordIdCallback}\n      buttonClickStatusHook={buttonClickStatusHook}\n      showHistory={showHistory}\n      showComment={showComment}\n      onAttachmentDownload={onAttachmentDownload}\n    />\n  );\n});\n\nExpandRecordContainerBase.displayName = 'ExpandRecordContainerBase';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/expand-record-container/index.ts",
    "content": "export * from './ExpandRecordContainer';\nexport * from './ExpandRecordContainerBase';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/expand-record-container/types.ts",
    "content": "export interface IExpandRecordContainerRef {\n  updateRecordIds: (recordIds: string[] | undefined) => void;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/DefaultValue.tsx",
    "content": "import { Label, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const DefaultValue = (props: { children: React.ReactNode; onReset?: () => void }) => {\n  const { children, onReset } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  return (\n    <div className=\"flex w-full flex-col  gap-2\">\n      <div className=\"flex w-full justify-between\">\n        <Label className=\"flex items-center text-sm font-medium\">\n          {t('table:field.editor.defaultValue')}\n        </Label>\n        {onReset && (\n          <Button\n            size=\"xs\"\n            variant=\"link\"\n            onClick={() => {\n              onReset();\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                onReset();\n              }\n            }}\n            className=\"h-5 text-xs text-muted-foreground decoration-muted-foreground\"\n          >\n            {t('table:field.editor.reset')}\n          </Button>\n        )}\n      </div>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/DynamicFieldEditor.tsx",
    "content": "import { Skeleton } from '@teable/ui-lib/shadcn';\nimport dynamic from 'next/dynamic';\n\nexport const DynamicFieldEditor = dynamic(\n  () => import('./FieldEditor').then((mod) => mod.FieldEditor),\n  {\n    loading: () => (\n      <div className=\"h-full space-y-2 p-4\">\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n        <Skeleton className=\"h-6 w-full\" />\n      </div>\n    ),\n    ssr: false,\n  }\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/FieldEditor.spec.tsx",
    "content": "import { CellValueType, FieldType } from '@teable/core';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { render, TestAnchorProvider } from '@/test-utils';\nimport { FieldEditor } from './FieldEditor';\nimport { FieldOperator } from './type';\n\nconst lookupFields = [\n  {\n    id: 'fldSingleLineText',\n    type: FieldType.SingleLineText,\n    cellValueType: CellValueType.String,\n  },\n  {\n    id: 'fldNumber',\n    type: FieldType.Number,\n    cellValueType: CellValueType.Number,\n  },\n  {\n    id: 'fldRollup',\n    type: FieldType.Rollup,\n    options: {\n      expression: 'max({values})',\n    },\n    lookupOptions: {\n      foreignTableId: 'mockTableId',\n      linkFieldId: 'mockLinkId',\n      lookupFieldId: 'mockFieldId',\n    },\n    cellValueType: CellValueType.Number,\n  },\n] as IFieldInstance[];\n\ndescribe('field editor static tests', () => {\n  it('should render text field options', async () => {\n    const el = render(\n      <TestAnchorProvider>\n        <FieldEditor\n          field={{\n            type: FieldType.SingleLineText,\n          }}\n          onChange={() => undefined}\n          operator={FieldOperator.Add}\n        />\n      </TestAnchorProvider>\n    );\n    expect(el.getByTestId('text-show-as')).toBeInTheDocument();\n  });\n\n  it('should render lookup text field options', async () => {\n    const el = render(\n      <TestAnchorProvider fields={lookupFields}>\n        <FieldEditor\n          field={{\n            type: FieldType.SingleLineText,\n            isLookup: true,\n          }}\n          operator={FieldOperator.Add}\n          onChange={() => undefined}\n        />\n      </TestAnchorProvider>\n    );\n    expect(el.getByTestId('text-show-as')).toBeInTheDocument();\n    expect(el.getByTestId('lookup-options')).toBeInTheDocument();\n  });\n\n  it('should render rollup field lookup options', async () => {\n    const el = render(\n      <TestAnchorProvider fields={lookupFields}>\n        <FieldEditor\n          field={{\n            type: FieldType.Rollup,\n          }}\n          operator={FieldOperator.Add}\n          onChange={() => undefined}\n        />\n      </TestAnchorProvider>\n    );\n    expect(el.getByTestId('lookup-options')).toBeInTheDocument();\n  });\n\n  it('should render rollup field field options', async () => {\n    const el = render(\n      <TestAnchorProvider fields={lookupFields}>\n        <FieldEditor\n          field={{\n            type: FieldType.Rollup,\n            lookupOptions: {\n              foreignTableId: 'mockTableId',\n              linkFieldId: 'mockLinkId',\n              lookupFieldId: 'mockFieldId',\n            },\n          }}\n          operator={FieldOperator.Add}\n          onChange={() => undefined}\n        />\n      </TestAnchorProvider>\n    );\n\n    expect(el.getByTestId('lookup-options')).toBeInTheDocument();\n    expect(el.getByTestId('rollup-options')).toBeInTheDocument();\n  });\n\n  it('should render single value formatting and showAs after pick lookup field', async () => {\n    const el = render(\n      <TestAnchorProvider fields={lookupFields}>\n        <FieldEditor\n          field={{\n            type: FieldType.Rollup,\n            options: {\n              expression: 'countall({values})',\n            },\n            lookupOptions: {\n              foreignTableId: 'mockTableId',\n              linkFieldId: 'mockLinkId',\n              lookupFieldId: 'mockFieldId',\n            },\n            cellValueType: CellValueType.Number,\n          }}\n          operator={FieldOperator.Add}\n          onChange={() => undefined}\n        />\n      </TestAnchorProvider>\n    );\n    expect(el.getByTestId('single-number-show-as')).toBeInTheDocument();\n  });\n  it('should render multi value formatting and showAs after pick lookup field', async () => {\n    const el = render(\n      <TestAnchorProvider fields={lookupFields}>\n        <FieldEditor\n          field={{\n            isLookup: true,\n            type: FieldType.Rollup,\n            options: {\n              expression: 'countall({values})',\n            },\n            lookupOptions: {\n              foreignTableId: 'mockTableId',\n              linkFieldId: 'mockLinkId',\n              lookupFieldId: 'mockFieldId',\n            },\n            cellValueType: CellValueType.Number,\n            isMultipleCellValue: true,\n          }}\n          operator={FieldOperator.Add}\n          onChange={() => undefined}\n        />\n      </TestAnchorProvider>\n    );\n    expect(el.getByTestId('multi-number-show-as')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/FieldEditor.tsx",
    "content": "import type { IFieldOptionsRo, IFieldVo } from '@teable/core';\nimport {\n  FieldType,\n  checkFieldNotNullValidationEnabled,\n  checkFieldUniqueValidationEnabled,\n  isConditionalLookupOptions,\n} from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { useFieldStaticGetter } from '@teable/sdk';\nimport { Button, Textarea } from '@teable/ui-lib/shadcn';\nimport { Input } from '@teable/ui-lib/shadcn/ui/input';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { useIsEE } from '../../hooks/useIsEE';\nimport { FieldAiConfig } from './field-ai-config';\nimport { FieldValidation } from './field-validation/FieldValidation';\nimport { FieldOptions } from './FieldOptions';\nimport type { IFieldOptionsProps } from './FieldOptions';\nimport { useUpdateConditionalLookupOptions } from './hooks/useUpdateConditionalLookupOptions';\nimport { useUpdateLookupOptions } from './hooks/useUpdateLookupOptions';\nimport { LookupOptions } from './lookup-options/LookupOptions';\nimport { ConditionalLookupOptions } from './options/ConditionalLookupOptions';\nimport { SelectFieldType } from './SelectFieldType';\nimport { SystemInfo } from './SystemInfo';\nimport { FieldOperator } from './type';\nimport type { IFieldEditorRo } from './type';\nimport { useFieldTypeSubtitle } from './useFieldTypeSubtitle';\n\nexport const FieldEditor = (props: {\n  isPrimary?: boolean;\n  field: Partial<IFieldEditorRo>;\n  operator: FieldOperator;\n  onChange?: (field: IFieldEditorRo) => void;\n  onSave?: () => void;\n}) => {\n  const { isPrimary, field, operator, onChange, onSave } = props;\n  const [showDescription, setShowDescription] = useState<boolean>(Boolean(field.description));\n  const setFieldFn = useCallback(\n    (field: IFieldEditorRo) => {\n      onChange?.(field);\n    },\n    [onChange]\n  );\n  const getFieldSubtitle = useFieldTypeSubtitle();\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const isEE = useIsEE();\n  const isCloud = useIsCloud();\n\n  const updateFieldProps = (props: Partial<IFieldEditorRo>) => {\n    setFieldFn({\n      ...field,\n      ...props,\n    });\n  };\n\n  const updateFieldTypeWithLookup = (type: FieldType | 'lookup' | 'conditionalLookup') => {\n    if (type === 'lookup') {\n      return setFieldFn({\n        ...field,\n        type: FieldType.SingleLineText, // reset fieldType to default\n        options: undefined, // reset options\n        aiConfig: undefined,\n        isLookup: true,\n        isConditionalLookup: undefined,\n        unique: undefined,\n        notNull: undefined,\n      });\n    }\n\n    if (type === 'conditionalLookup') {\n      return setFieldFn({\n        ...field,\n        type: FieldType.SingleLineText,\n        options: undefined,\n        aiConfig: undefined,\n        isLookup: true,\n        isConditionalLookup: true,\n        unique: undefined,\n        notNull: undefined,\n        lookupOptions: undefined,\n      });\n    }\n\n    let options: IFieldOptionsRo | undefined = getFieldStatic(type, {\n      isLookup: false,\n      hasAiConfig: false,\n    }).defaultOptions as IFieldOptionsRo;\n\n    if (\n      [field.type, type].every((t) =>\n        [FieldType.MultipleSelect, FieldType.SingleSelect].includes(t as FieldType)\n      )\n    ) {\n      options = field.options;\n    }\n\n    setFieldFn({\n      ...field,\n      type,\n      isLookup: undefined,\n      isConditionalLookup: undefined,\n      lookupOptions: undefined,\n      aiConfig: undefined,\n      options,\n      unique: checkFieldUniqueValidationEnabled(type, field.isLookup) ? field.unique : undefined,\n      notNull:\n        operator === FieldOperator.Edit && checkFieldNotNullValidationEnabled(type, field.isLookup)\n          ? field.notNull\n          : undefined,\n    });\n  };\n\n  const updateFieldOptions: IFieldOptionsProps['onChange'] = useCallback(\n    (options) => {\n      setFieldFn({\n        ...field,\n        options: {\n          ...(field.options || {}),\n          ...options,\n        } as IFieldVo['options'],\n      });\n    },\n    [field, setFieldFn]\n  );\n\n  const updateLookupOptions = useUpdateLookupOptions(field, setFieldFn);\n  const updateConditionalLookupOptions = useUpdateConditionalLookupOptions(field, setFieldFn);\n\n  const getUnionOptions = () => {\n    if (field.isLookup) {\n      if (field.isConditionalLookup) {\n        const conditionalLookupOptions = isConditionalLookupOptions(field.lookupOptions)\n          ? field.lookupOptions\n          : undefined;\n\n        return (\n          <>\n            <ConditionalLookupOptions\n              fieldId={field.id}\n              options={conditionalLookupOptions}\n              onOptionsChange={updateConditionalLookupOptions}\n            />\n            <FieldOptions field={field} onChange={updateFieldOptions} onSave={onSave} />\n          </>\n        );\n      }\n\n      return (\n        <>\n          <LookupOptions\n            fieldId={field.id}\n            options={field.lookupOptions}\n            onChange={updateLookupOptions}\n          />\n          <FieldOptions field={field} onChange={updateFieldOptions} onSave={onSave} />\n        </>\n      );\n    }\n\n    if (field.type === FieldType.Rollup) {\n      return (\n        <>\n          <LookupOptions options={field.lookupOptions} onChange={updateLookupOptions} />\n          {field.lookupOptions && (\n            <FieldOptions field={field} onChange={updateFieldOptions} onSave={onSave} />\n          )}\n        </>\n      );\n    }\n\n    return <FieldOptions field={field} onChange={updateFieldOptions} onSave={onSave} />;\n  };\n\n  return (\n    <div className=\"flex w-full flex-1 flex-col gap-4 overflow-y-auto p-4 text-sm\">\n      <div className=\"relative flex w-full flex-col gap-2\">\n        <p className=\"text-sm font-medium\">{t('common:name')}</p>\n        <Input\n          placeholder={t('table:field.fieldNameOptional')}\n          type=\"text\"\n          size=\"lg\"\n          value={field['name'] || ''}\n          data-1p-ignore=\"true\"\n          autoComplete=\"off\"\n          onChange={(e) => updateFieldProps({ name: e.target.value || undefined })}\n        />\n        {/* should place after the name input to make sure tab index correct */}\n        <SystemInfo field={field as IFieldVo} updateFieldProps={updateFieldProps} />\n        {!showDescription && (\n          <div className=\"text-left text-xs\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"xs\"\n              className=\"\"\n              onClick={() => setShowDescription(true)}\n            >\n              <Plus className=\"size-4\" />\n              {t('table:field.editor.addDescription')}\n            </Button>\n          </div>\n        )}\n      </div>\n      {showDescription && (\n        <div className=\"flex w-full flex-col gap-2\">\n          <div>\n            <span className=\"mb-2 text-sm font-medium\">{t('common:description')}</span>\n          </div>\n          <Textarea\n            className=\"min-h-12 resize-y\"\n            value={field['description'] || undefined}\n            placeholder={t('table:field.editor.descriptionPlaceholder')}\n            onChange={(e) => updateFieldProps({ description: e.target.value || null })}\n          />\n        </div>\n      )}\n      <div className=\"flex w-full flex-col gap-2\">\n        <div>\n          <span className=\"mb-2 text-sm font-medium\">{t('table:field.editor.type')}</span>\n        </div>\n        <SelectFieldType\n          isPrimary={isPrimary}\n          value={\n            field.isLookup\n              ? field.isConditionalLookup\n                ? 'conditionalLookup'\n                : 'lookup'\n              : field.type\n          }\n          onChange={updateFieldTypeWithLookup}\n        />\n        <p className=\"text-left text-xs font-normal text-muted-foreground\">\n          {field.isLookup\n            ? field.isConditionalLookup\n              ? t('table:field.subTitle.conditionalLookup')\n              : t('table:field.subTitle.lookup')\n            : getFieldSubtitle(field.type as FieldType)}\n        </p>\n      </div>\n      <FieldValidation field={field} operator={operator} onChange={updateFieldProps} />\n      {(isCloud || isEE) && <FieldAiConfig field={field} onChange={updateFieldProps} />}\n      {getUnionOptions()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type {\n  IFieldVo,\n  IDateFieldOptions,\n  IFormulaFieldOptions,\n  ILinkFieldOptionsRo,\n  INumberFieldOptions,\n  ISelectFieldOptions,\n  IRollupFieldOptions,\n  IRatingFieldOptions,\n  ISingleLineTextFieldOptions,\n  ICreatedTimeFieldOptions,\n  ILastModifiedTimeFieldOptions,\n  ILastModifiedByFieldOptions,\n  IUserFieldOptions,\n  ICheckboxFieldOptions,\n  ILongTextFieldOptions,\n  IButtonFieldOptions,\n  IConditionalRollupFieldOptions,\n} from '@teable/core';\nimport {\n  CellValueType,\n  FieldType,\n  getRollupFunctionsByCellValueType,\n  isLinkLookupOptions,\n} from '@teable/core';\nimport { getField } from '@teable/openapi';\nimport { useFields } from '@teable/sdk/hooks';\nimport { useMemo } from 'react';\nimport { ButtonOptions } from './options/ButtonOptions';\nimport { CheckboxOptions } from './options/CheckboxOptions';\nimport { ConditionalRollupOptions } from './options/ConditionalRollupOptions';\nimport { CreatedTimeOptions } from './options/CreatedTimeOptions';\nimport { DateOptions } from './options/DateOptions';\nimport { FormulaOptions } from './options/FormulaOptions';\nimport { LastModifiedByOptions } from './options/LastModifiedByOptions';\nimport { LastModifiedTimeOptions } from './options/LastModifiedTimeOptions';\nimport { LinkOptions } from './options/LinkOptions';\nimport { LongTextOptions } from './options/LongTextOptions';\nimport { NumberOptions } from './options/NumberOptions';\nimport { RatingOptions } from './options/RatingOptions';\nimport { RollupOptions } from './options/RollupOptions';\nimport { SelectOptions } from './options/SelectOptions/SelectOptions';\nimport { SingleLineTextOptions } from './options/SingleLineTextOptions';\nimport { UserOptions } from './options/UserOptions';\nimport type { IFieldEditorRo } from './type';\n\nexport interface IFieldOptionsProps {\n  field: IFieldEditorRo;\n  onChange: (options: Partial<IFieldVo['options']>) => void;\n  onSave?: () => void;\n}\n\nexport const FieldOptions: React.FC<IFieldOptionsProps> = ({ field, onChange, onSave }) => {\n  const { id, type, isLookup, cellValueType, isMultipleCellValue, options } = field;\n  const lookupField = useRollupLookupField(field.lookupOptions);\n  const lookupCellValueType = useMemo(\n    () => normalizeCellValueType(lookupField?.cellValueType),\n    [lookupField?.cellValueType]\n  );\n  const normalizedFieldCellValueType = useMemo(\n    () => normalizeCellValueType(cellValueType),\n    [cellValueType]\n  );\n  const effectiveRollupCellValueType = lookupCellValueType ?? normalizedFieldCellValueType;\n  const rollupAvailableExpressions = useMemo(() => {\n    return effectiveRollupCellValueType\n      ? getRollupFunctionsByCellValueType(effectiveRollupCellValueType)\n      : undefined;\n  }, [effectiveRollupCellValueType]);\n  const effectiveIsMultiple = isMultipleCellValue ?? lookupField?.isMultipleCellValue ?? undefined;\n  switch (type) {\n    case FieldType.SingleLineText:\n      return (\n        <SingleLineTextOptions\n          isLookup={isLookup}\n          options={options as ISingleLineTextFieldOptions}\n          onChange={onChange}\n        />\n      );\n    case FieldType.LongText:\n      return (\n        <LongTextOptions\n          options={options as ILongTextFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.SingleSelect:\n    case FieldType.MultipleSelect:\n      return (\n        <SelectOptions\n          isMultiple={type === FieldType.MultipleSelect}\n          options={options as ISelectFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Number:\n      return (\n        <NumberOptions\n          options={options as INumberFieldOptions}\n          isLookup={isLookup}\n          isMultipleCellValue={isMultipleCellValue}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Link:\n      return (\n        <LinkOptions\n          fieldId={id}\n          options={options as ILinkFieldOptionsRo}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Formula:\n      return (\n        <FormulaOptions\n          options={options as IFormulaFieldOptions}\n          isLookup={isLookup}\n          cellValueType={cellValueType}\n          isMultipleCellValue={isMultipleCellValue}\n          onChange={onChange}\n        />\n      );\n    case FieldType.User:\n      return (\n        <UserOptions\n          options={options as IUserFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Date:\n      return (\n        <DateOptions\n          options={options as IDateFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.CreatedTime:\n      return (\n        <CreatedTimeOptions options={options as ICreatedTimeFieldOptions} onChange={onChange} />\n      );\n    case FieldType.LastModifiedTime:\n      return (\n        <LastModifiedTimeOptions\n          options={options as ILastModifiedTimeFieldOptions}\n          onChange={onChange}\n        />\n      );\n    case FieldType.LastModifiedBy:\n      return (\n        <LastModifiedByOptions\n          options={options as ILastModifiedByFieldOptions}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Rating:\n      return (\n        <RatingOptions\n          options={options as IRatingFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Checkbox:\n      return (\n        <CheckboxOptions\n          options={options as ICheckboxFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Rollup:\n      return (\n        <RollupOptions\n          options={options as IRollupFieldOptions}\n          isLookup={isLookup}\n          cellValueType={effectiveRollupCellValueType}\n          isMultipleCellValue={effectiveIsMultiple}\n          availableExpressions={rollupAvailableExpressions}\n          onChange={onChange}\n        />\n      );\n    case FieldType.ConditionalRollup:\n      return (\n        <ConditionalRollupOptions\n          fieldId={id}\n          options={options as IConditionalRollupFieldOptions}\n          onChange={onChange}\n        />\n      );\n    case FieldType.Button:\n      return (\n        <ButtonOptions\n          options={options as IButtonFieldOptions}\n          isLookup={isLookup}\n          onChange={onChange}\n          onSave={onSave}\n        />\n      );\n    default:\n      return <></>;\n  }\n};\n\nconst normalizeCellValueType = (value: unknown): CellValueType | undefined => {\n  if (\n    typeof value === 'string' &&\n    (Object.values(CellValueType) as string[]).includes(value as CellValueType)\n  ) {\n    return value as CellValueType;\n  }\n  return undefined;\n};\n\nconst useRollupLookupField = (\n  lookupOptions: IFieldEditorRo['lookupOptions']\n): Pick<IFieldVo, 'cellValueType' | 'isMultipleCellValue'> | undefined => {\n  const linkOptions = isLinkLookupOptions(lookupOptions) ? lookupOptions : undefined;\n  const lookupFieldId = linkOptions?.lookupFieldId;\n  const foreignTableId = linkOptions?.foreignTableId;\n  const fields = useFields({ withHidden: true, withDenied: true });\n\n  const localLookupField = useMemo(() => {\n    if (!lookupFieldId) return undefined;\n    return fields.find((field) => field.id === lookupFieldId);\n  }, [fields, lookupFieldId]);\n\n  const shouldFetchLookupField = Boolean(foreignTableId && lookupFieldId) && !localLookupField;\n\n  const { data: remoteLookupField } = useQuery({\n    queryKey: ['rollup-lookup-field', foreignTableId, lookupFieldId],\n    queryFn: async () => {\n      const res = await getField(foreignTableId!, lookupFieldId!);\n      return res.data;\n    },\n    enabled: shouldFetchLookupField,\n  });\n\n  return localLookupField ?? remoteLookupField;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx",
    "content": "import type {\n  IFieldRo,\n  IFieldVo,\n  ILookupConditionalOptions,\n  ILookupLinkOptions,\n  ILookupOptionsRo,\n  ILookupOptionsVo,\n} from '@teable/core';\nimport {\n  validateFieldOptions,\n  convertFieldRoSchema,\n  createFieldRoSchema,\n  FieldType,\n  getOptionsSchema,\n  isConditionalLookupOptions,\n  isLinkLookupOptions,\n  StatisticsFunc,\n} from '@teable/core';\nimport { type IPlanFieldConvertVo, getAggregation } from '@teable/openapi';\nimport { useTableId, useView, useFieldOperations, useRowCount } from '@teable/sdk/hooks';\nimport { ConfirmDialog, Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { Sheet, SheetContent } from '@teable/ui-lib/shadcn/ui/sheet';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport { fromZodError } from 'zod-validation-error';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { DynamicFieldGraph } from '../../blocks/graph/DynamicFieldGraph';\nimport { ProgressBar } from '../../blocks/graph/ProgressBar';\nimport type { AiAutoFillMode } from './dialog/AiAutoFillDialog';\nimport { AiAutoFillDialog } from './dialog/AiAutoFillDialog';\nimport { DynamicFieldEditor } from './DynamicFieldEditor';\nimport { useDefaultFieldName } from './hooks/useDefaultFieldName';\nimport type { IFieldEditorRo, IFieldSetting, IFieldSettingBase } from './type';\nimport { FieldOperator } from './type';\n\nconst asNonEmptyString = (value: unknown) =>\n  typeof value === 'string' && value ? value : undefined;\n\nconst sanitizeLinkLookupOptions = (options: ILookupLinkOptions): ILookupOptionsRo | undefined => {\n  const foreignTableId = asNonEmptyString(options.foreignTableId);\n  const lookupFieldId = asNonEmptyString(options.lookupFieldId);\n  const linkFieldId = asNonEmptyString(options.linkFieldId);\n  if (!foreignTableId || !lookupFieldId || !linkFieldId) {\n    return undefined;\n  }\n  const sanitized: ILookupOptionsRo = {\n    foreignTableId,\n    lookupFieldId,\n    linkFieldId,\n  };\n  if (options.filter != null) {\n    sanitized.filter = options.filter;\n  }\n  return sanitized;\n};\n\nconst sanitizeConditionalLookupOptions = (\n  options: ILookupConditionalOptions\n): ILookupOptionsRo | undefined => {\n  const foreignTableId = asNonEmptyString(options.foreignTableId);\n  const lookupFieldId = asNonEmptyString(options.lookupFieldId);\n  const filter = options.filter;\n  if (!foreignTableId || !lookupFieldId || !filter) {\n    return undefined;\n  }\n\n  const sanitized: ILookupOptionsRo = {\n    foreignTableId,\n    lookupFieldId,\n    filter,\n  };\n\n  const baseId = asNonEmptyString(options.baseId);\n  if (baseId) {\n    sanitized.baseId = baseId;\n  }\n  const sortFieldId = asNonEmptyString(options.sort?.fieldId);\n  if (sortFieldId && options.sort) {\n    sanitized.sort = options.sort;\n  }\n  if (typeof options.limit === 'number') {\n    sanitized.limit = options.limit;\n  }\n\n  return sanitized;\n};\n\nexport const sanitizeLookupOptions = (\n  options?: ILookupOptionsRo | ILookupOptionsVo\n): ILookupOptionsRo | undefined => {\n  if (!options) {\n    return undefined;\n  }\n\n  if (isLinkLookupOptions(options)) {\n    return sanitizeLinkLookupOptions(options);\n  }\n\n  if (isConditionalLookupOptions(options)) {\n    return sanitizeConditionalLookupOptions(options);\n  }\n\n  return undefined;\n};\n\nexport const FieldSetting = (props: IFieldSetting) => {\n  const { operator, order } = props;\n\n  const view = useView();\n  const tableId = useTableId() as string;\n  const rowCount = useRowCount();\n  const getDefaultFieldName = useDefaultFieldName();\n  const { createField, convertField, planFieldCreate, planFieldConvert, autoFillField } =\n    useFieldOperations();\n\n  const [graphVisible, setGraphVisible] = useState<boolean>(false);\n  const [processVisible, setProcessVisible] = useState<boolean>(false);\n  const [plan, setPlan] = useState<IPlanFieldConvertVo>();\n  const [fieldRo, setFieldRo] = useState<IFieldRo>();\n  const [aiConfirmVisible, setAiConfirmVisible] = useState(false);\n  const [aiFieldStats, setAiFieldStats] = useState<{\n    emptyCount?: number;\n    filledCount?: number;\n    isLoading: boolean;\n  }>({ isLoading: false });\n  const autoFillModeRef = useRef<AiAutoFillMode | null>(null);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  // Fetch field stats (empty/filled count) for AI field dialog\n  const fetchFieldStats = async (fieldId: string) => {\n    if (!tableId) return;\n\n    setAiFieldStats({ isLoading: true });\n    try {\n      const query = view?.id ? { viewId: view.id } : {};\n      const result = await getAggregation(tableId, {\n        ...query,\n        field: {\n          [StatisticsFunc.Empty]: [fieldId],\n          [StatisticsFunc.Filled]: [fieldId],\n        },\n      });\n\n      const aggregations = result.data.aggregations;\n      if (aggregations && aggregations.length > 0) {\n        const parseValue = (value: string | number | null | undefined): number | undefined => {\n          if (value == null) return undefined;\n          return typeof value === 'string' ? parseInt(value, 10) : value;\n        };\n\n        // Find empty and filled stats from aggregations\n        const emptyAgg = aggregations.find(\n          (agg) => agg.fieldId === fieldId && agg.total?.aggFunc === StatisticsFunc.Empty\n        );\n        const filledAgg = aggregations.find(\n          (agg) => agg.fieldId === fieldId && agg.total?.aggFunc === StatisticsFunc.Filled\n        );\n\n        setAiFieldStats({\n          emptyCount: parseValue(emptyAgg?.total?.value),\n          filledCount: parseValue(filledAgg?.total?.value),\n          isLoading: false,\n        });\n      } else {\n        setAiFieldStats({ isLoading: false });\n      }\n    } catch (e) {\n      console.error('Failed to fetch field stats', e);\n      setAiFieldStats({ isLoading: false });\n    }\n  };\n\n  const runAutoFillIfNeeded = async (result?: IFieldVo) => {\n    const mode = autoFillModeRef.current;\n    if (!result || !mode || mode === 'saveOnly') {\n      autoFillModeRef.current = null;\n      return;\n    }\n\n    try {\n      if (tableId && result.id) {\n        // mode is either 'emptyOnly' or 'all' at this point (saveOnly already returned above)\n        const apiMode = mode as 'emptyOnly' | 'all';\n        const query = view?.id ? { viewId: view.id, mode: apiMode } : { mode: apiMode };\n        await autoFillField({ tableId, fieldId: result.id, query });\n      }\n    } catch (e) {\n      toast.error(t('table:field.aiConfig.autoFillConfirm.generateFailed'));\n      console.error('autoFillField error', e);\n    } finally {\n      autoFillModeRef.current = null;\n    }\n  };\n\n  const onCancel = () => {\n    props.onCancel?.();\n  };\n\n  const createNewField = async (field: IFieldRo) => {\n    const fieldName = field.name ?? (await getDefaultFieldName(field));\n    return await createField({ tableId, fieldRo: { ...field, name: fieldName } });\n  };\n\n  const performAction = async (field: IFieldRo) => {\n    setGraphVisible(false);\n    if (plan && (plan.estimateTime || 0) > 1000) {\n      setProcessVisible(true);\n    }\n    let result: IFieldVo | undefined;\n    try {\n      if (operator === FieldOperator.Add) {\n        result = await createNewField(field);\n      }\n\n      if (operator === FieldOperator.Insert) {\n        result = await createNewField({\n          ...field,\n          order:\n            view && order != null\n              ? {\n                  viewId: view.id,\n                  orderIndex: order,\n                }\n              : undefined,\n        });\n      }\n\n      if (operator === FieldOperator.Edit) {\n        const fieldId = props.field?.id;\n        if (tableId && fieldId) {\n          result = await convertField({ tableId, fieldId, fieldRo: field });\n        }\n      }\n\n      toast(\n        operator === FieldOperator.Edit\n          ? t('table:field.editor.fieldUpdated')\n          : t('table:field.editor.fieldCreated')\n      );\n    } finally {\n      setProcessVisible(false);\n    }\n\n    await runAutoFillIfNeeded(result);\n\n    props.onConfirm?.(result);\n  };\n\n  const getPlan = async (fieldRo: IFieldRo) => {\n    if (operator === FieldOperator.Edit) {\n      return await planFieldConvert({ tableId, fieldId: props.field?.id as string, fieldRo });\n    }\n    return await planFieldCreate({ tableId, fieldRo });\n  };\n\n  const onConfirm = async (fieldRo?: IFieldRo) => {\n    if (!fieldRo) {\n      return onCancel();\n    }\n\n    const hasAiConfig = Boolean(fieldRo.aiConfig?.isAutoFill);\n    const originAiConfig = props.field?.aiConfig;\n    const aiConfigChanged =\n      JSON.stringify(originAiConfig ?? null) !== JSON.stringify(fieldRo.aiConfig ?? null);\n\n    if (\n      hasAiConfig &&\n      (operator === FieldOperator.Add ||\n        operator === FieldOperator.Insert ||\n        (operator === FieldOperator.Edit && aiConfigChanged))\n    ) {\n      setFieldRo(fieldRo);\n      setAiConfirmVisible(true);\n      // Fetch field stats for edit mode (existing field)\n      if (operator === FieldOperator.Edit && props.field?.id) {\n        fetchFieldStats(props.field.id);\n      } else {\n        // For new fields, all cells are empty\n        setAiFieldStats({ emptyCount: rowCount ?? 0, filledCount: 0, isLoading: false });\n      }\n      return;\n    }\n\n    const plan = await getPlan(fieldRo);\n    setFieldRo(fieldRo);\n    setPlan(plan);\n    const estimateTime = plan?.estimateTime || 0;\n    const linkFieldCount = plan?.linkFieldCount || 0;\n    if (estimateTime > 1000 || linkFieldCount > 0) {\n      setGraphVisible(true);\n      return;\n    }\n    await performAction(fieldRo);\n  };\n\n  const handleConfirmWithAutoFill = async (mode: AiAutoFillMode) => {\n    if (!fieldRo) return;\n    autoFillModeRef.current = mode;\n\n    const plan = await getPlan(fieldRo);\n    setPlan(plan);\n    const estimateTime = plan?.estimateTime || 0;\n    const linkFieldCount = plan?.linkFieldCount || 0;\n    if (estimateTime > 1000 || linkFieldCount > 0) {\n      setGraphVisible(true);\n      return;\n    }\n    await performAction(fieldRo);\n  };\n\n  return (\n    <>\n      <FieldSettingBase {...props} onCancel={onCancel} onConfirm={onConfirm} />\n      <ConfirmDialog\n        title={t('table:field.editor.confirmFieldChange')}\n        open={graphVisible}\n        onOpenChange={setGraphVisible}\n        content={\n          <>\n            <DynamicFieldGraph tableId={tableId} fieldId={props.field?.id} fieldRo={fieldRo} />\n            <p className=\"text-sm\">{t('table:field.editor.areYouSurePerformIt')}</p>\n          </>\n        }\n        cancelText={t('common:actions.cancel')}\n        confirmText={t('common:actions.confirm')}\n        onCancel={() => setGraphVisible(false)}\n        onConfirm={() => performAction(fieldRo as IFieldRo)}\n      />\n      <AiAutoFillDialog\n        open={aiConfirmVisible}\n        title={t('table:field.aiConfig.autoFillConfirm.title')}\n        rowCount={rowCount ?? 0}\n        emptyCount={aiFieldStats.emptyCount}\n        filledCount={aiFieldStats.filledCount}\n        isLoadingStats={aiFieldStats.isLoading}\n        cancelText={t('common:actions.cancel')}\n        hideEmptyOnly={operator !== FieldOperator.Edit}\n        labels={{\n          description: t('table:field.aiConfig.autoFillConfirm.description'),\n          emptyOnly: t('table:field.aiConfig.autoFillConfirm.emptyOnlyMode'),\n          emptyOnlyDesc: t('table:field.aiConfig.autoFillConfirm.emptyOnlyModeDesc'),\n          all: t('table:field.aiConfig.autoFillConfirm.allMode'),\n          allDesc: t('table:field.aiConfig.autoFillConfirm.allModeDesc'),\n          saveOnly: t('table:field.aiConfig.autoFillConfirm.saveOnlyMode'),\n          saveOnlyDesc: t('table:field.aiConfig.autoFillConfirm.saveOnlyModeDesc'),\n          recommended: t('table:field.aiConfig.autoFillConfirm.recommended'),\n          limitWarning: t('table:field.aiConfig.autoFillConfirm.limitWarning'),\n        }}\n        confirmLabels={{\n          emptyOnly: t('table:field.aiConfig.autoFillConfirm.fillEmptyCells'),\n          all: t('table:field.aiConfig.autoFillConfirm.generateAll'),\n          saveOnly: t('table:field.aiConfig.autoFillConfirm.saveConfigOnly'),\n        }}\n        onClose={() => setAiConfirmVisible(false)}\n        onConfirm={async (mode) => {\n          setAiConfirmVisible(false);\n          await handleConfirmWithAutoFill(mode);\n        }}\n      />\n      <ConfirmDialog\n        open={processVisible}\n        onOpenChange={setProcessVisible}\n        title={t('table:field.editor.calculating')}\n        content={\n          <ProgressBar duration={plan?.estimateTime || 0} cellCount={plan?.updateCellCount || 0} />\n        }\n      />\n    </>\n  );\n};\n\nconst FieldSettingBase = (props: IFieldSettingBase) => {\n  const { visible, field: originField, operator, onConfirm, onCancel } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [field, setField] = useState<IFieldEditorRo>(\n    originField\n      ? {\n          ...originField,\n          options: getOptionsSchema(originField.type).parse(originField.options),\n          lookupOptions: sanitizeLookupOptions(originField.lookupOptions),\n        }\n      : {\n          type: FieldType.SingleLineText,\n        }\n  );\n  const [alertVisible, setAlertVisible] = useState<boolean>(false);\n  const [updateCount, setUpdateCount] = useState<number>(0);\n  const [isSaving, setIsSaving] = useState<boolean>(false);\n\n  const onOpenChange = (open?: boolean) => {\n    if (open) {\n      return;\n    }\n    onCancelInner();\n  };\n\n  const onFieldEditorChange = useCallback((nextField: IFieldEditorRo) => {\n    const sanitizedLookupOptions = sanitizeLookupOptions(nextField.lookupOptions);\n    const normalizedField: IFieldEditorRo = {\n      ...nextField,\n      lookupOptions:\n        sanitizedLookupOptions ??\n        (nextField.isConditionalLookup ? nextField.lookupOptions : undefined),\n    };\n    setField(normalizedField);\n    setUpdateCount(1);\n  }, []);\n\n  const onCancelInner = () => {\n    if (updateCount > 0) {\n      setAlertVisible(true);\n      return;\n    }\n    onCancel?.();\n  };\n\n  const onSave = async () => {\n    if (operator === FieldOperator.Edit && !updateCount) {\n      onConfirm?.();\n      return;\n    }\n\n    const normalizedField: IFieldEditorRo = {\n      ...field,\n      lookupOptions: sanitizeLookupOptions(field.lookupOptions),\n    };\n\n    const validateRes = validateFieldOptions({\n      type: normalizedField.type as FieldType,\n      isLookup: normalizedField.isLookup,\n      isConditionalLookup: normalizedField.isConditionalLookup,\n      lookupOptions: normalizedField.lookupOptions,\n      options: normalizedField.options,\n      aiConfig: normalizedField.aiConfig,\n    });\n    if (validateRes.length > 0) {\n      toast.error(\n        t(validateRes[0].i18nKey, {\n          ...validateRes[0].context,\n          defaultValue: validateRes[0].message,\n        })\n      );\n      return;\n    }\n\n    const fieldRoSchema =\n      operator === FieldOperator.Edit ? convertFieldRoSchema : createFieldRoSchema;\n    const result = fieldRoSchema.safeParse(normalizedField);\n    if (result.success) {\n      setIsSaving(true);\n      try {\n        const confirmField: IFieldRo = {\n          ...(result.data as IFieldRo),\n          options: (result.data as IFieldRo).options ?? undefined,\n        };\n        await onConfirm?.(confirmField);\n      } finally {\n        setIsSaving(false);\n      }\n      return;\n    }\n\n    console.error('fieldConFirm', normalizedField);\n    console.error('fieldConFirmResult', fromZodError(result.error).message);\n    const errorMessage = fromZodError(result.error).message;\n    toast.error(`Validation Error`, {\n      description: errorMessage,\n    });\n  };\n\n  const title = useMemo(() => {\n    switch (operator) {\n      case FieldOperator.Add:\n        return t('table:field.editor.addField');\n      case FieldOperator.Edit:\n        return t('table:field.editor.editField');\n      case FieldOperator.Insert:\n        return t('table:field.editor.insertField');\n    }\n  }, [operator, t]);\n\n  return (\n    <>\n      <Sheet open={visible} onOpenChange={onOpenChange}>\n        <SheetContent\n          className=\"w-screen p-0 sm:w-[400px] sm:max-w-[400px]\"\n          side=\"right\"\n          onInteractOutside={(event) => {\n            const target = event.target as HTMLElement;\n            if (target.closest('.toaster')) {\n              event.preventDefault();\n            }\n          }}\n        >\n          <div className=\"flex h-full flex-col\">\n            {/* Header */}\n            <div className=\"text-md w-full border-b px-4 py-3 font-semibold\">{title}</div>\n            {/* Content Form */}\n            {\n              <DynamicFieldEditor\n                isPrimary={originField?.isPrimary}\n                field={field}\n                operator={operator}\n                onChange={onFieldEditorChange}\n                onSave={onSave}\n              />\n            }\n            {/* Footer */}\n            <div className=\"flex w-full shrink-0 justify-end gap-2 border-t p-4\">\n              <Button size={'sm'} variant={'ghost'} onClick={onCancel} disabled={isSaving}>\n                {t('common:actions.cancel')}\n              </Button>\n              <Button size={'sm'} onClick={onSave} disabled={isSaving}>\n                {isSaving ? <Spin className=\"size-4\" /> : t('common:actions.save')}\n              </Button>\n            </div>\n          </div>\n        </SheetContent>\n      </Sheet>\n      <ConfirmDialog\n        open={alertVisible}\n        closeable={true}\n        onOpenChange={setAlertVisible}\n        title={t('table:field.editor.doSaveChanges')}\n        onCancel={onCancel}\n        cancelText={t('common:actions.doNotSave')}\n        confirmText={t('common:actions.save')}\n        onConfirm={onSave}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/SelectFieldType.tsx",
    "content": "import { FieldType, PRIMARY_SUPPORTED_TYPES } from '@teable/core';\nimport { ConditionalLookup as ConditionalLookupIcon } from '@teable/icons';\nimport { FIELD_TYPE_ORDER, useFieldStaticGetter } from '@teable/sdk';\nimport SearchIcon from '@teable/ui-lib/icons/app/search.svg';\nimport {\n  Button,\n  cn,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Check, ChevronDown } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useBaseUsage } from '../../hooks/useBaseUsage';\n\ntype InnerFieldType = FieldType | 'lookup' | 'conditionalLookup';\n\ninterface ISelectorItem {\n  id: InnerFieldType;\n  name: string;\n  icon?: React.ReactNode;\n  description?: string;\n  tag?: string;\n  disabled?: boolean;\n  disabledReason?: string;\n}\n\nexport const FIELD_TYPE_ORDER1 = [\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Number,\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.Date,\n  FieldType.Rating,\n  FieldType.Checkbox,\n  FieldType.Attachment,\n  FieldType.Formula,\n  FieldType.Link,\n  FieldType.Rollup,\n  FieldType.ConditionalRollup,\n  FieldType.Button,\n  FieldType.CreatedTime,\n  FieldType.LastModifiedTime,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n  FieldType.AutoNumber,\n];\n\nconst BASE_FIELD_TYPE = [\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Number,\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.Date,\n  FieldType.Rating,\n  FieldType.Checkbox,\n  FieldType.Attachment,\n];\n\nconst ADVANCED_FIELD_TYPE_ORDER = [\n  FieldType.Formula,\n  FieldType.Link,\n  FieldType.Rollup,\n  FieldType.ConditionalRollup,\n  FieldType.Button,\n  FieldType.AutoNumber,\n];\n\nconst SYSTEM_FIELD_TYPE_ORDER = [\n  FieldType.CreatedTime,\n  FieldType.LastModifiedTime,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n];\n\nconst fieldTypeItem = (\n  item: ISelectorItem,\n  value: InnerFieldType,\n  setOpen: (open: boolean) => void,\n  onChange?: (type: InnerFieldType) => void\n) => {\n  const { id, name, icon, description, tag, disabled, disabledReason } = item;\n\n  const content = (\n    <div className=\"flex w-full min-w-0 items-center gap-2\">\n      <Check className={cn('h-4 w-4 flex-shrink-0', id === value ? 'opacity-100' : 'opacity-0')} />\n      {icon}\n      <span className={cn('truncate flex-1', name ? '' : 'text-primary/60')}>{name}</span>\n      {tag && <span className=\"shrink-0 text-sm\">{tag}</span>}\n    </div>\n  );\n\n  return (\n    <CommandItem\n      key={id}\n      value={id}\n      onSelect={() => {\n        if (disabled) return;\n        onChange?.(id);\n        setOpen(false);\n      }}\n      className=\"flex\"\n    >\n      {description ? (\n        <Tooltip delayDuration={50}>\n          <TooltipTrigger asChild>{content}</TooltipTrigger>\n          <TooltipContent side=\"right\" sideOffset={8} className=\"max-w-56 text-xs leading-snug\">\n            {disabled && disabledReason ? disabledReason : description}\n          </TooltipContent>\n        </Tooltip>\n      ) : (\n        content\n      )}\n    </CommandItem>\n  );\n};\n\nexport const SelectFieldType = (props: {\n  isPrimary?: boolean;\n  value?: InnerFieldType;\n  onChange?: (type: InnerFieldType) => void;\n}) => {\n  const { isPrimary, value = FieldType.SingleLineText, onChange } = props;\n  const usage = useBaseUsage();\n  const { buttonFieldEnable = false } = usage?.limit ?? {};\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [open, setOpen] = useState(false);\n  const ref = useRef<HTMLButtonElement>(null);\n\n  const searchTip = t('actions.search');\n  const emptyTip = t('noResult');\n\n  const baseGroup = useMemo((): ISelectorItem[] => {\n    const fieldTypes = isPrimary\n      ? BASE_FIELD_TYPE.filter((type) => PRIMARY_SUPPORTED_TYPES.has(type))\n      : BASE_FIELD_TYPE;\n    return fieldTypes.map((type) => {\n      const { title, description, Icon } = getFieldStatic(type, {\n        isLookup: false,\n        hasAiConfig: false,\n      });\n      return {\n        id: type,\n        name: title,\n        description,\n        icon: <Icon className=\"size-4\" />,\n        tag: type === FieldType.Attachment ? '🍌' : undefined,\n      };\n    });\n  }, [getFieldStatic, isPrimary]);\n\n  const advancedGroup = useMemo((): ISelectorItem[] => {\n    const fieldTypes = isPrimary\n      ? ADVANCED_FIELD_TYPE_ORDER.filter((type) => PRIMARY_SUPPORTED_TYPES.has(type))\n      : ADVANCED_FIELD_TYPE_ORDER;\n    const list: ISelectorItem[] = fieldTypes.map((type) => {\n      const { title, description, Icon } = getFieldStatic(type, {\n        isLookup: false,\n        hasAiConfig: false,\n      });\n      const isButton = type === FieldType.Button;\n      const disabled = isButton ? !buttonFieldEnable : false;\n      const disabledReason = isButton && disabled ? t('billing.unavailableInPlanTips') : undefined;\n      return {\n        id: type,\n        name: title,\n        description,\n        icon: <Icon className=\"size-4\" />,\n        disabled,\n        disabledReason,\n      };\n    });\n    if (!isPrimary) {\n      list.splice(2, 0, {\n        id: 'lookup',\n        name: t('sdk:field.title.lookup'),\n        description: t('sdk:field.description.lookup'),\n        icon: <SearchIcon className=\"size-4\" />,\n      });\n      list.splice(4, 0, {\n        id: 'conditionalLookup',\n        name: t('sdk:field.title.conditionalLookup'),\n        description: t('sdk:field.description.conditionalLookup'),\n        icon: <ConditionalLookupIcon className=\"size-4\" />,\n      });\n    }\n    return list;\n  }, [getFieldStatic, isPrimary, t, buttonFieldEnable]);\n\n  const systemGroup = useMemo((): ISelectorItem[] => {\n    const fieldTypes = isPrimary\n      ? SYSTEM_FIELD_TYPE_ORDER.filter((type) => PRIMARY_SUPPORTED_TYPES.has(type))\n      : SYSTEM_FIELD_TYPE_ORDER;\n    return fieldTypes.map((type) => {\n      const { title, description, Icon } = getFieldStatic(type, {\n        isLookup: false,\n        hasAiConfig: false,\n      });\n      return {\n        id: type,\n        name: title,\n        description,\n        icon: <Icon className=\"size-4\" />,\n      };\n    });\n  }, [getFieldStatic, isPrimary]);\n\n  const candidates = useMemo((): ISelectorItem[] => {\n    const fieldTypes = isPrimary\n      ? FIELD_TYPE_ORDER.filter((type) => PRIMARY_SUPPORTED_TYPES.has(type))\n      : FIELD_TYPE_ORDER;\n    const result = fieldTypes.map<ISelectorItem>((type) => {\n      const { title, description, Icon } = getFieldStatic(type, {\n        isLookup: false,\n        hasAiConfig: false,\n      });\n      return {\n        id: type,\n        name: title,\n        description,\n        icon: <Icon className=\"size-4\" />,\n        tag: type === FieldType.Attachment ? '🍌' : undefined,\n      };\n    });\n\n    return isPrimary\n      ? result\n      : result.concat(\n          {\n            id: 'lookup',\n            name: t('sdk:field.title.lookup'),\n            description: t('sdk:field.description.lookup'),\n            icon: <SearchIcon className=\"size-4\" />,\n          },\n          {\n            id: 'conditionalLookup',\n            name: t('sdk:field.title.conditionalLookup'),\n            description: t('sdk:field.description.conditionalLookup'),\n            icon: <ConditionalLookupIcon className=\"size-4\" />,\n          }\n        );\n  }, [getFieldStatic, t, isPrimary]);\n\n  const candidatesMap = useMemo(\n    () =>\n      candidates.reduce(\n        (pre, cur) => {\n          pre[cur.id] = cur;\n          return pre;\n        },\n        {} as Record<string, ISelectorItem>\n      ),\n    [candidates]\n  );\n  const selected = candidatesMap[value];\n\n  return (\n    <Popover open={open} onOpenChange={setOpen} modal={true}>\n      <PopoverTrigger asChild>\n        <Button\n          ref={ref}\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className={cn('flex gap-2 font-normal px-3')}\n        >\n          {selected.icon}\n          <span className=\"truncate\">{selected.name}</span>\n          <div className=\"grow\"></div>\n          <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" className={cn('w-[400px] p-0', 'select-field-type')}>\n        <TooltipProvider delayDuration={200}>\n          <Command\n            filter={(value, search) => {\n              if (!search) return 1;\n              const item = candidatesMap[value];\n              const text = item?.name || item?.id;\n              if (text?.toLocaleLowerCase().includes(search.toLocaleLowerCase())) return 1;\n              return 0;\n            }}\n          >\n            <CommandInput placeholder={searchTip} />\n            <CommandEmpty>{emptyTip}</CommandEmpty>\n            <CommandList>\n              <CommandGroup className=\"border-b border-border py-2\">\n                <div className=\"grid grid-cols-2 gap-1\">\n                  {baseGroup.map((item) => fieldTypeItem(item, value, setOpen, onChange))}\n                </div>\n              </CommandGroup>\n              <CommandGroup className=\"border-b border-border py-2\">\n                <div className=\"grid grid-cols-2 gap-1\">\n                  {advancedGroup.map((item) => fieldTypeItem(item, value, setOpen, onChange))}\n                </div>\n              </CommandGroup>\n              <CommandGroup className=\"py-2\">\n                <div className=\"grid grid-cols-2 gap-1\">\n                  {systemGroup.map((item) => fieldTypeItem(item, value, setOpen, onChange))}\n                </div>\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </TooltipProvider>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/SelectTable.tsx",
    "content": "import type { Table } from '@teable/sdk/model';\nimport ArrowDownIcon from '@teable/ui-lib/icons/app/arrow-down.svg';\nimport SelectIcon from '@teable/ui-lib/icons/app/select.svg';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n} from '@teable/ui-lib/shadcn/ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib/shadcn/ui/popover';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const SelectTable = (props: {\n  value?: string;\n  tables?: Table[];\n  onChange?: (id: string) => void;\n}) => {\n  const { value = '', onChange, tables = [] } = props;\n  const [open, setOpen] = useState(false);\n  const ref = useRef<HTMLButtonElement>(null);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          ref={ref}\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className=\"w-full justify-between font-normal\"\n        >\n          {value\n            ? tables.find(({ id }) => id === value)?.name\n            : t('table:field.editor.selectTable')}\n          <ArrowDownIcon className=\"ml-2 size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-full p-0\" style={{ width: ref.current?.offsetWidth }}>\n        <Command>\n          <CommandInput placeholder={t('table:field.editor.searchTable')} />\n          <CommandEmpty>{t('common:noResult')}</CommandEmpty>\n          <CommandGroup>\n            {tables.map(({ id, name }) => (\n              <CommandItem\n                key={id}\n                value={id}\n                onSelect={() => {\n                  onChange?.(id);\n                  setOpen(false);\n                }}\n              >\n                <SelectIcon\n                  className={cn('mr-2 h-4 w-4', value === id ? 'opacity-100' : 'opacity-0')}\n                />\n                {name}\n              </CommandItem>\n            ))}\n          </CommandGroup>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/SystemInfo.tsx",
    "content": "import type { IFieldVo } from '@teable/core';\nimport { LocalStorageKeys } from '@teable/sdk/config/local-storage-keys';\nimport { Input, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useLocalStorage } from 'react-use';\n\nexport const DbFieldName: React.FC<{\n  field: Partial<IFieldVo>;\n  updateFieldProps: (props: Partial<IFieldVo>) => void;\n}> = ({ field, updateFieldProps }) => {\n  const { t } = useTranslation(['table']);\n  return (\n    <>\n      <div className=\"mt-2 flex flex-col space-y-2\">\n        <p className=\"text-sm font-medium\">{t('table:field.editor.dbFieldName')}</p>\n        <Input\n          placeholder={t('table:field.editor.dbFieldName')}\n          type=\"text\"\n          size=\"lg\"\n          value={field['dbFieldName'] || ''}\n          data-1p-ignore=\"true\"\n          autoComplete=\"off\"\n          onChange={(e) => updateFieldProps({ dbFieldName: e.target.value || undefined })}\n        />\n      </div>\n    </>\n  );\n};\n\nconst FieldInfoList: React.FC<{ field: Partial<IFieldVo> }> = ({ field }) => (\n  <div className=\"mt-2 flex flex-col gap-2 rounded-md border bg-muted p-3\">\n    {[\n      { label: 'id', value: field.id },\n      { label: 'dbFieldType', value: field.dbFieldType },\n      { label: 'cellValueType', value: field.cellValueType },\n      { label: 'isMultipleCellValue', value: field.isMultipleCellValue ? 'true' : 'false' },\n      { label: 'isPrimary', value: field.isPrimary ? 'true' : 'false' },\n      { label: 'isComputed', value: field.isComputed ? 'true' : 'false' },\n      { label: 'isPending', value: field.isPending ? 'true' : 'false' },\n    ].map(({ label, value }) => (\n      <p key={label} className=\"h-4 text-xs \">\n        <span className=\"mr-1 select-none text-muted-foreground\">{label}: </span>\n        {value}\n      </p>\n    ))}\n  </div>\n);\n\nconst ToggleButton: React.FC<{\n  show?: boolean;\n  setShow: (value: boolean) => void;\n}> = ({ show, setShow }) => {\n  const { t } = useTranslation(['table']);\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' || e.key === ' ') {\n      setShow(!show);\n    }\n  };\n\n  return (\n    <Button\n      size=\"xs\"\n      variant={show ? 'outline' : 'link'}\n      onClick={() => {\n        setShow(!show);\n      }}\n      className={show ? '' : 'h-5 text-xs text-muted-foreground decoration-muted-foreground'}\n    >\n      {t(show ? 'field.hide' : 'field.advancedProps')}\n    </Button>\n  );\n};\n\nexport const SystemInfo: React.FC<{\n  field: Partial<IFieldVo>;\n  updateFieldProps: (props: Partial<IFieldVo>) => void;\n}> = ({ field, updateFieldProps }) => {\n  const [show, setShow] = useLocalStorage<boolean>(LocalStorageKeys.FieldSystem);\n\n  if (!show) {\n    return (\n      <div className=\"absolute right-0 top-[2px] cursor-pointer\">\n        <ToggleButton show={show} setShow={setShow} />\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {field.id ? (\n        <div className=\"flex flex-col space-y-2\">\n          <p>\n            <DbFieldName field={field} updateFieldProps={updateFieldProps} />\n          </p>\n          <FieldInfoList field={field} />\n        </div>\n      ) : (\n        <div className=\"flex flex-col space-y-2\">\n          <DbFieldName field={field} updateFieldProps={updateFieldProps} />\n        </div>\n      )}\n      <p className=\"mb-2 border-b pb-4 text-xs\">\n        <ToggleButton show={show} setShow={setShow} />\n      </p>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/dialog/AiAutoFillDialog.tsx",
    "content": "import { AlertTriangle } from '@teable/icons';\nimport {\n  Dialog,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogContent,\n  DialogFooter,\n  Button,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { RadioGroup, RadioGroupItem } from '@teable/ui-lib/shadcn/ui/radio-group';\nimport { Skeleton } from '@teable/ui-lib/shadcn/ui/skeleton';\nimport { useState, useEffect } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\n\nexport type AiAutoFillMode = 'emptyOnly' | 'all' | 'saveOnly';\n\ninterface IAiAutoFillDialogProps {\n  open: boolean;\n  title?: string | React.ReactNode;\n  rowCount: number;\n  emptyCount?: number;\n  filledCount?: number;\n  isLoadingStats?: boolean;\n  cancelText: string;\n  /** Hide the \"Save Only\" option (useful for regenerate scenario where there's no config to save) */\n  hideSaveOnly?: boolean;\n  /** Hide the \"Empty Only\" option (useful for new fields where all cells are empty) */\n  hideEmptyOnly?: boolean;\n  labels: {\n    /** Dynamic description with {{count}} placeholder */\n    description: string;\n    emptyOnly: string;\n    emptyOnlyDesc: string;\n    all: string;\n    allDesc: string;\n    saveOnly: string;\n    saveOnlyDesc: string;\n    recommended: string;\n    limitWarning: string;\n  };\n  confirmLabels: {\n    emptyOnly: string;\n    all: string;\n    saveOnly: string;\n  };\n  onClose: () => void;\n  onConfirm: (mode: AiAutoFillMode) => void | Promise<void>;\n}\n\nexport const AiAutoFillDialog = (props: IAiAutoFillDialogProps) => {\n  const {\n    open,\n    title,\n    rowCount,\n    emptyCount,\n    filledCount,\n    isLoadingStats,\n    cancelText,\n    hideSaveOnly = false,\n    hideEmptyOnly = false,\n    labels,\n    confirmLabels,\n    onClose,\n    onConfirm,\n  } = props;\n  const { task } = useEnv();\n  const { maxTaskRows = 0 } = task ?? {};\n  const [selectedMode, setSelectedMode] = useState<AiAutoFillMode>('emptyOnly');\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Reset and auto-select appropriate mode when dialog opens or options change\n  useEffect(() => {\n    if (open) {\n      // Auto-select 'all' mode when emptyOnly option is hidden or when there are no empty cells\n      if (hideEmptyOnly || emptyCount === 0) {\n        setSelectedMode('all');\n      } else {\n        setSelectedMode('emptyOnly');\n      }\n    }\n  }, [open, emptyCount, hideEmptyOnly]);\n\n  const handleConfirm = async () => {\n    setIsLoading(true);\n    try {\n      await onConfirm(selectedMode);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const getConfirmButtonText = () => {\n    switch (selectedMode) {\n      case 'emptyOnly':\n        return confirmLabels.emptyOnly;\n      case 'all':\n        return confirmLabels.all;\n      case 'saveOnly':\n        return confirmLabels.saveOnly;\n    }\n  };\n\n  // Disable emptyOnly if no empty cells\n  const hasEmptyCells = emptyCount == null || emptyCount > 0;\n\n  // Format count display\n  const formatCount = (count: number | undefined) => {\n    if (count == null) return null;\n    return count.toLocaleString();\n  };\n\n  // Helper to apply max limit (0 means no limit)\n  const limitByMax = (count: number) => (maxTaskRows > 0 ? Math.min(count, maxTaskRows) : count);\n  const exceedsLimit = (count: number) => maxTaskRows > 0 && count > maxTaskRows;\n\n  // Calculate actual counts that will be processed (limited by maxTaskRows)\n  const actualEmptyCount = emptyCount != null ? limitByMax(emptyCount) : undefined;\n  const actualAllCount = limitByMax(rowCount);\n\n  // Check if current selection exceeds the limit\n  const showLimitWarning =\n    (selectedMode === 'emptyOnly' && emptyCount != null && exceedsLimit(emptyCount)) ||\n    (selectedMode === 'all' && exceedsLimit(rowCount));\n\n  // Get the actual count based on selected mode\n  const getDisplayCount = () => {\n    if (selectedMode === 'saveOnly') return 0;\n    if (selectedMode === 'emptyOnly') {\n      return actualEmptyCount ?? 0;\n    }\n    return actualAllCount;\n  };\n\n  // Replace {{count}} or {{rowCount}} in description with actual count\n  const dynamicDescription = labels.description\n    .replace(/\\{\\{count\\}\\}/g, formatCount(getDisplayCount()) ?? '0')\n    .replace(/\\{\\{rowCount\\}\\}/g, formatCount(getDisplayCount()) ?? '0');\n\n  return (\n    <Dialog open={open} onOpenChange={(val) => !val && onClose()}>\n      <DialogContent\n        closeable={false}\n        onPointerDownOutside={(e) => e.preventDefault()}\n        onInteractOutside={(e) => e.preventDefault()}\n        onMouseDown={(e) => e.stopPropagation()}\n        onClick={(e) => e.stopPropagation()}\n        className=\"sm:max-w-md\"\n      >\n        <DialogHeader className=\"space-y-2\">\n          {title && <DialogTitle>{title}</DialogTitle>}\n          <DialogDescription>{dynamicDescription}</DialogDescription>\n        </DialogHeader>\n\n        <RadioGroup\n          value={selectedMode}\n          onValueChange={(value) => setSelectedMode(value as AiAutoFillMode)}\n          className=\"space-y-3\"\n        >\n          {/* Empty Only Mode - Recommended (hidden for new fields) */}\n          {!hideEmptyOnly && (\n            <div className=\"flex items-start space-x-3\">\n              <RadioGroupItem\n                value=\"emptyOnly\"\n                id=\"emptyOnly\"\n                className=\"mt-1\"\n                disabled={!hasEmptyCells}\n              />\n              <div className=\"flex flex-1 flex-col gap-0.5\">\n                <Label\n                  htmlFor=\"emptyOnly\"\n                  className={`flex cursor-pointer items-center gap-2 ${!hasEmptyCells ? 'text-muted-foreground' : ''}`}\n                >\n                  {labels.emptyOnly}\n                  {isLoadingStats ? (\n                    <Skeleton className=\"h-5 w-12\" />\n                  ) : (\n                    emptyCount != null && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        ({formatCount(actualEmptyCount)} / {formatCount(rowCount)})\n                      </span>\n                    )\n                  )}\n                  {hasEmptyCells && (\n                    <span className=\"rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary\">\n                      {labels.recommended}\n                    </span>\n                  )}\n                </Label>\n                <span className=\"text-xs text-muted-foreground\">{labels.emptyOnlyDesc}</span>\n              </div>\n            </div>\n          )}\n\n          {/* All Mode */}\n          <div className=\"flex items-start space-x-3\">\n            <RadioGroupItem value=\"all\" id=\"all\" className=\"mt-1\" />\n            <div className=\"flex flex-1 flex-col gap-0.5\">\n              <Label htmlFor=\"all\" className=\"flex cursor-pointer items-center gap-2\">\n                {labels.all}\n                {isLoadingStats ? (\n                  <Skeleton className=\"h-5 w-12\" />\n                ) : (\n                  <span className=\"text-xs text-muted-foreground\">\n                    ({formatCount(actualAllCount)})\n                  </span>\n                )}\n              </Label>\n              <span className=\"text-xs text-muted-foreground\">\n                {labels.allDesc}\n                {!isLoadingStats && filledCount != null && filledCount > 0 && (\n                  <span className=\"text-orange-500\"> ({formatCount(filledCount)})</span>\n                )}\n              </span>\n            </div>\n          </div>\n\n          {/* Save Only Mode - hidden when hideSaveOnly is true */}\n          {!hideSaveOnly && (\n            <div className=\"flex items-start space-x-3\">\n              <RadioGroupItem value=\"saveOnly\" id=\"saveOnly\" className=\"mt-1\" />\n              <div className=\"flex flex-1 flex-col gap-0.5\">\n                <Label htmlFor=\"saveOnly\" className=\"cursor-pointer\">\n                  {labels.saveOnly}\n                </Label>\n                <span className=\"text-xs text-muted-foreground\">{labels.saveOnlyDesc}</span>\n              </div>\n            </div>\n          )}\n        </RadioGroup>\n\n        {/* Limit Warning */}\n        {showLimitWarning && (\n          <div className=\"flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-200\">\n            <AlertTriangle className=\"mt-0.5 size-4 shrink-0\" />\n            <span>{labels.limitWarning}</span>\n          </div>\n        )}\n\n        <DialogFooter className=\"gap-2 sm:gap-0\">\n          <Button size=\"sm\" variant=\"ghost\" onClick={onClose} disabled={isLoading}>\n            {cancelText}\n          </Button>\n          <Button size=\"sm\" onClick={handleConfirm} disabled={isLoading}>\n            {getConfirmButtonText()}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/AttachmentFieldAiConfig.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type {\n  IAttachmentFieldAIConfig,\n  IAttachmentFieldCustomizeAIConfig,\n  IAttachmentFieldGenerateImageAIConfig,\n  IImageResolution,\n} from '@teable/core';\nimport { FieldAIActionType, FieldType, ImageQuality } from '@teable/core';\nimport { ChevronDown, ChevronRight, ImageGeneration, Pencil, Settings } from '@teable/icons';\nimport {\n  getAIConfig,\n  LLMProviderType,\n  getImageModelConfigByGatewayId,\n  type IImageModelConfig,\n} from '@teable/openapi';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { Selector } from '@teable/ui-lib/base';\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n  Slider,\n  Textarea,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { AIModelSelect } from '@/features/app/blocks/admin/setting/components/ai-config/AiModelSelect';\nimport {\n  generateModelKeyList,\n  generateGatewayModelKeyList,\n  parseModelKey,\n} from '@/features/app/blocks/admin/setting/components/ai-config/utils';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\n// Extended model capabilities for UI rendering\ninterface IModelCapabilities {\n  supportsSize: boolean;\n  supportsQuality: boolean;\n  supportsCount: boolean;\n  supportsImageInput: boolean;\n  supportsSeed: boolean;\n  supportsStyle: boolean;\n  /** Supports aspect ratio selection (for multimodal LLMs that use prompt-based control) */\n  supportsAspectRatio?: boolean;\n  /** Supports resolution selection (for multimodal LLMs that use prompt-based control) */\n  supportsResolution?: boolean;\n  supportedSizes?: string[];\n  supportedAspectRatios?: string[];\n  /** Supported resolution presets (1K, 2K, 4K) */\n  supportedResolutions?: IImageResolution[];\n  defaultSize?: string;\n  defaultAspectRatio?: string;\n  /** Default resolution preset */\n  defaultResolution?: IImageResolution;\n  maxImagesPerCall?: number;\n  sizeType?: 'size' | 'aspectRatio' | 'both' | 'flexible';\n}\n\nconst DEFAULT_CAPABILITIES: IModelCapabilities = {\n  supportsSize: true,\n  supportsQuality: true,\n  supportsCount: true,\n  supportsImageInput: false,\n  supportsSeed: false,\n  supportsStyle: false,\n  defaultSize: '1024x1024',\n};\n\nconst MULTIMODAL_LLM_CAPABILITIES: IModelCapabilities = {\n  supportsSize: false,\n  supportsQuality: false,\n  supportsCount: true,\n  supportsImageInput: true,\n  supportsSeed: false,\n  supportsStyle: false,\n  sizeType: 'flexible',\n  // Multimodal LLMs support aspect ratio via prompt instructions (no default - let model decide)\n  supportsAspectRatio: true,\n  supportedAspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '2:3'],\n  // Multimodal LLMs support resolution via prompt instructions (no default - let model decide)\n  supportsResolution: true,\n  supportedResolutions: ['1K', '2K', '4K'],\n};\n\n/**\n * Get capabilities for legacy (non-gateway) models based on model name\n */\nconst getLegacyModelCapabilities = (modelLower: string): IModelCapabilities | null => {\n  if (modelLower.includes('gemini')) return MULTIMODAL_LLM_CAPABILITIES;\n  if (modelLower.includes('gpt-image-1')) {\n    return {\n      ...DEFAULT_CAPABILITIES,\n      supportsStyle: true,\n      supportedSizes: ['1024x1024', '1536x1024', '1024x1536'],\n    };\n  }\n  if (modelLower.includes('dall-e-3')) {\n    return {\n      ...DEFAULT_CAPABILITIES,\n      supportsCount: false,\n      supportsSeed: true,\n      supportsStyle: true,\n      supportedSizes: ['1024x1024', '1792x1024', '1024x1792'],\n      maxImagesPerCall: 1,\n    };\n  }\n  if (modelLower.includes('dall-e-2')) {\n    return {\n      ...DEFAULT_CAPABILITIES,\n      supportsQuality: false,\n      supportedSizes: ['256x256', '512x512', '1024x1024'],\n    };\n  }\n  if (modelLower.includes('grok')) {\n    return { ...DEFAULT_CAPABILITIES, supportsSize: false, supportsQuality: false };\n  }\n  return null;\n};\n\n/**\n * Get model capabilities from the new unified config or fallback to legacy detection\n */\nconst getModelCapabilities = (\n  modelKey?: string,\n  gatewayModels?: Array<{ id: string; type?: string; tags?: string[] }>\n): IModelCapabilities => {\n  if (!modelKey) return DEFAULT_CAPABILITIES;\n\n  const { type, model } = parseModelKey(modelKey);\n  const modelLower = model?.toLowerCase() ?? '';\n\n  // For AI Gateway models, try to get config from unified config\n  if (type === LLMProviderType.AI_GATEWAY && model) {\n    const imageConfig = getImageModelConfigByGatewayId(model);\n    if (imageConfig) return mapImageConfigToCapabilities(imageConfig);\n\n    // Fall back to gateway model metadata\n    const gatewayModel = gatewayModels?.find((m) => m.id === model);\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const gatewayModelType = (gatewayModel as any)?.modelType || (gatewayModel as any)?.type;\n    const tags = gatewayModel?.tags ?? [];\n\n    if (gatewayModelType === 'language' && tags.includes('image-generation')) {\n      return MULTIMODAL_LLM_CAPABILITIES;\n    }\n    if (gatewayModelType === 'image') {\n      return DEFAULT_CAPABILITIES;\n    }\n  }\n\n  // Legacy detection for non-gateway models\n  if (type === LLMProviderType.GOOGLE) return MULTIMODAL_LLM_CAPABILITIES;\n  return getLegacyModelCapabilities(modelLower) ?? DEFAULT_CAPABILITIES;\n};\n\n/**\n * Map IImageModelConfig to IModelCapabilities\n */\nconst mapImageConfigToCapabilities = (config: IImageModelConfig): IModelCapabilities => {\n  // Multimodal LLMs (language models with image-generation tag) support aspect ratio via prompt\n  const isMultimodalLLM =\n    config.modelType === 'language' && (config.tags?.includes('image-generation') ?? false);\n\n  return {\n    supportsSize: config.sizeType === 'size' || config.sizeType === 'both',\n    supportsQuality: config.supportsQuality ?? false,\n    supportsCount: config.maxImagesPerCall !== 1,\n    supportsImageInput: isMultimodalLLM,\n    supportsSeed: config.supportsSeed ?? false,\n    supportsStyle: config.supportsStyle ?? false,\n    // Multimodal LLMs support aspect ratio via prompt instructions (no default - let model decide)\n    supportsAspectRatio: isMultimodalLLM || config.sizeType === 'aspectRatio',\n    // Multimodal LLMs support resolution via prompt instructions (no default - let model decide)\n    supportsResolution: isMultimodalLLM,\n    supportedResolutions: isMultimodalLLM ? ['1K', '2K', '4K'] : undefined,\n    // For multimodal LLMs, don't set defaults - only use values if explicitly specified\n    supportedSizes: config.supportedSizes,\n    supportedAspectRatios: config.supportedAspectRatios,\n    defaultSize: isMultimodalLLM ? undefined : config.defaultSize,\n    defaultAspectRatio: isMultimodalLLM ? undefined : config.defaultAspectRatio,\n    maxImagesPerCall: config.maxImagesPerCall,\n    sizeType: config.sizeType,\n  };\n};\n\n/**\n * Get default settings based on model capabilities\n * For multimodal LLMs (Gemini, etc.), don't set defaults for prompt-based controls - let the model decide\n */\nconst getModelDefaults = (\n  capabilities: IModelCapabilities\n): Partial<IAttachmentFieldGenerateImageAIConfig> => ({\n  size: capabilities.supportsSize ? capabilities.defaultSize || '1024x1024' : undefined,\n  quality: capabilities.supportsQuality ? ImageQuality.Medium : undefined,\n  n: capabilities.supportsCount ? 1 : undefined,\n  // Only set aspectRatio/resolution if there's an explicit default (not for multimodal LLMs)\n  aspectRatio: capabilities.supportsAspectRatio ? capabilities.defaultAspectRatio : undefined,\n  resolution: capabilities.supportsResolution ? capabilities.defaultResolution : undefined,\n});\n\n/**\n * Calculate settings updates for initial load (only fill missing values)\n * For multimodal LLMs, don't auto-fill prompt-based controls (aspectRatio, resolution)\n */\nconst getInitialLoadUpdates = (\n  capabilities: IModelCapabilities,\n  currentConfig?: IAttachmentFieldGenerateImageAIConfig\n): Partial<IAttachmentFieldGenerateImageAIConfig> => {\n  const updates: Partial<IAttachmentFieldGenerateImageAIConfig> = {};\n\n  if (capabilities.supportsSize && !currentConfig?.size && capabilities.defaultSize) {\n    updates.size = capabilities.defaultSize;\n  }\n  if (capabilities.supportsQuality && currentConfig?.quality === undefined) {\n    updates.quality = ImageQuality.Medium;\n  }\n  if (capabilities.supportsCount && !currentConfig?.n) {\n    updates.n = 1;\n  }\n  // Only auto-fill aspectRatio/resolution if there's an explicit default (not for multimodal LLMs)\n  if (\n    capabilities.supportsAspectRatio &&\n    !currentConfig?.aspectRatio &&\n    capabilities.defaultAspectRatio\n  ) {\n    updates.aspectRatio = capabilities.defaultAspectRatio;\n  }\n  if (\n    capabilities.supportsResolution &&\n    !currentConfig?.resolution &&\n    capabilities.defaultResolution\n  ) {\n    updates.resolution = capabilities.defaultResolution;\n  }\n\n  return updates;\n};\n\ninterface IAttachmentFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n}\n\nexport const AttachmentFieldAiConfig = (props: IAttachmentFieldAiConfigProps) => {\n  const { field, onChange } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n  const modelKey = (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.modelKey;\n  const baseId = useBaseId() as string;\n  const [advancedOpen, setAdvancedOpen] = useState(false);\n\n  // Track previous model key to detect model changes\n  const prevModelKeyRef = useRef<string | undefined>(modelKey);\n\n  // Use refs to access latest values in useEffect without adding them to dependencies\n  const aiConfigRef = useRef(aiConfig);\n  const onChangeRef = useRef(onChange);\n  aiConfigRef.current = aiConfig;\n  onChangeRef.current = onChange;\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const { data: baseAiConfig } = useQuery({\n    queryKey: ['ai-config', baseId],\n    queryFn: () => getAIConfig(baseId).then(({ data }) => data),\n  });\n\n  const { llmProviders = [], modelDefinationMap, gatewayModels } = baseAiConfig ?? {};\n  const models = [\n    ...generateGatewayModelKeyList(gatewayModels),\n    ...generateModelKeyList(llmProviders),\n  ];\n\n  // Get model capabilities based on the selected model\n  const modelCapabilities = useMemo(\n    () => getModelCapabilities(modelKey, gatewayModels),\n    [modelKey, gatewayModels]\n  );\n\n  // Check if there are any advanced options to show\n  const hasAdvancedOptions = useMemo(() => {\n    return (\n      modelCapabilities.supportsSize ||\n      modelCapabilities.supportsQuality ||\n      modelCapabilities.supportsCount ||\n      modelCapabilities.supportsAspectRatio ||\n      modelCapabilities.supportsResolution\n    );\n  }, [modelCapabilities]);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.ImageGeneration,\n        icon: <ImageGeneration className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.imageGeneration'),\n      },\n      {\n        id: FieldAIActionType.ImageCustomization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const onConfigChange = useCallback(\n    (\n      key:\n        | keyof IAttachmentFieldGenerateImageAIConfig\n        | keyof IAttachmentFieldCustomizeAIConfig\n        | 'modelKey',\n      value: unknown\n    ) => {\n      switch (key) {\n        case 'type':\n          return onChange?.({ aiConfig: { type: value } as IAttachmentFieldAIConfig });\n        case 'modelKey':\n          return onChange?.({\n            aiConfig: { ...aiConfig, modelKey: value as string } as IAttachmentFieldAIConfig,\n          });\n        case 'sourceFieldId':\n          return onChange?.({\n            aiConfig: { ...aiConfig, sourceFieldId: value as string } as IAttachmentFieldAIConfig,\n          });\n        case 'size':\n          return onChange?.({\n            aiConfig: { ...aiConfig, size: value as string } as IAttachmentFieldAIConfig,\n          });\n        case 'attachPrompt':\n          return onChange?.({\n            aiConfig: {\n              ...aiConfig,\n              attachPrompt: value as string,\n            } as IAttachmentFieldGenerateImageAIConfig,\n          });\n        case 'n':\n          return onChange?.({\n            aiConfig: { ...aiConfig, n: value as number } as IAttachmentFieldGenerateImageAIConfig,\n          });\n        case 'quality':\n          return onChange?.({\n            aiConfig: {\n              ...aiConfig,\n              quality: value as ImageQuality,\n            } as IAttachmentFieldGenerateImageAIConfig,\n          });\n        case 'aspectRatio':\n          return onChange?.({\n            aiConfig: {\n              ...aiConfig,\n              aspectRatio: value as string,\n            } as IAttachmentFieldGenerateImageAIConfig,\n          });\n        case 'resolution':\n          return onChange?.({\n            aiConfig: {\n              ...aiConfig,\n              resolution: value as IImageResolution,\n            } as IAttachmentFieldGenerateImageAIConfig,\n          });\n        case 'prompt':\n          return onChange?.({\n            aiConfig: { ...aiConfig, prompt: value as string } as IAttachmentFieldCustomizeAIConfig,\n          });\n        default:\n          throw new Error(`Unsupported key: ${key}`);\n      }\n    },\n    [aiConfig, onChange]\n  );\n\n  // Reset advanced settings to new model's defaults when model changes\n  useEffect(() => {\n    if (!modelKey || type !== FieldAIActionType.ImageGeneration) {\n      prevModelKeyRef.current = modelKey;\n      return;\n    }\n\n    const isModelChanged = prevModelKeyRef.current !== modelKey;\n    prevModelKeyRef.current = modelKey;\n\n    // Use refs to get latest values to avoid stale closure issues\n    const currentAiConfig = aiConfigRef.current;\n    const currentOnChange = onChangeRef.current;\n\n    // When model changes: reset ALL settings to new model's defaults\n    // On initial load: only fill in missing values\n    const updates = isModelChanged\n      ? getModelDefaults(modelCapabilities)\n      : getInitialLoadUpdates(\n          modelCapabilities,\n          currentAiConfig as IAttachmentFieldGenerateImageAIConfig\n        );\n\n    if (Object.keys(updates).length > 0) {\n      currentOnChange?.({\n        aiConfig: { ...currentAiConfig, ...updates } as IAttachmentFieldAIConfig,\n      });\n    }\n  }, [modelKey, type, modelCapabilities]);\n\n  const imageSizeCandidates = useMemo(() => {\n    // Use model-specific sizes if available\n    if (modelCapabilities.supportedSizes?.length) {\n      return modelCapabilities.supportedSizes.map((size) => ({ id: size, name: size }));\n    }\n\n    // Default sizes\n    return [\n      { id: '256x256', name: '256x256' },\n      { id: '512x512', name: '512x512' },\n      { id: '1024x1024', name: '1024x1024' },\n      { id: '1536x1024', name: '1536x1024' },\n      { id: '1024x1536', name: '1024x1536' },\n      { id: '1792x1024', name: '1792x1024' },\n      { id: '1024x1792', name: '1024x1792' },\n    ];\n  }, [modelCapabilities.supportedSizes]);\n\n  const qualityCandidates = useMemo(\n    () => [\n      { id: ImageQuality.Low, name: t('table:field.aiConfig.imageQuality.low') },\n      { id: ImageQuality.Medium, name: t('table:field.aiConfig.imageQuality.medium') },\n      { id: ImageQuality.High, name: t('table:field.aiConfig.imageQuality.high') },\n    ],\n    [t]\n  );\n\n  const aspectRatioCandidates = useMemo(() => {\n    const autoOption = { id: '', name: t('table:field.aiConfig.auto') };\n    // Use model-specific aspect ratios if available\n    if (modelCapabilities.supportedAspectRatios?.length) {\n      return [\n        autoOption,\n        ...modelCapabilities.supportedAspectRatios.map((ratio) => ({\n          id: ratio,\n          name: ratio,\n        })),\n      ];\n    }\n    // Default aspect ratios for multimodal LLMs\n    return [\n      autoOption,\n      { id: '1:1', name: '1:1' },\n      { id: '16:9', name: '16:9' },\n      { id: '9:16', name: '9:16' },\n      { id: '4:3', name: '4:3' },\n      { id: '3:4', name: '3:4' },\n      { id: '21:9', name: '21:9' },\n      { id: '3:2', name: '3:2' },\n      { id: '2:3', name: '2:3' },\n    ];\n  }, [modelCapabilities.supportedAspectRatios, t]);\n\n  const resolutionCandidates = useMemo(\n    () => [\n      { id: '', name: t('table:field.aiConfig.auto') },\n      { id: '1K', name: t('table:field.aiConfig.resolution.1K') },\n      { id: '2K', name: t('table:field.aiConfig.resolution.2K') },\n      { id: '4K', name: t('table:field.aiConfig.resolution.4K') },\n    ],\n    [t]\n  );\n\n  // Get current values with defaults\n  const currentSize =\n    (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.size ||\n    modelCapabilities.defaultSize ||\n    '1024x1024';\n  const currentQuality =\n    (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.quality ?? ImageQuality.Medium;\n  const currentCount = (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.n || 1;\n  const maxCount = modelCapabilities.maxImagesPerCall || 10;\n  // For multimodal LLMs, aspectRatio/resolution can be undefined (let model decide)\n  const currentAspectRatio =\n    (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.aspectRatio ||\n    modelCapabilities.defaultAspectRatio;\n  const currentResolution =\n    (aiConfig as IAttachmentFieldGenerateImageAIConfig)?.resolution ||\n    modelCapabilities.defaultResolution;\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {Boolean(type) && (\n        <Fragment>\n          {/* AI Model - placed second, right after action type */}\n          <div className=\"flex flex-col gap-y-2\">\n            <span>\n              {t('table:field.aiConfig.label.model')}\n              <RequireCom />\n            </span>\n            <AIModelSelect\n              value={modelKey || ''}\n              onValueChange={(newValue) => {\n                onConfigChange('modelKey', newValue);\n              }}\n              options={models}\n              className=\"w-full px-2\"\n              modelDefinationMap={modelDefinationMap}\n              needGroup\n              onlyImageOutput\n            />\n          </div>\n\n          {type === FieldAIActionType.ImageCustomization ? (\n            <div className=\"flex flex-col gap-y-2\">\n              <PromptEditorContainer\n                excludedFieldId={id}\n                value={(aiConfig as IAttachmentFieldCustomizeAIConfig)?.prompt || ''}\n                onChange={(value) => onConfigChange('prompt', value)}\n                label={t('table:field.aiConfig.label.prompt')}\n                placeholder={t('table:field.aiConfig.placeholder.prompt')}\n                required={true}\n                isOptionDisabled={(field) =>\n                  !modelCapabilities.supportsImageInput && field.type === FieldType.Attachment\n                }\n                getDisabledReason={(field) =>\n                  !modelCapabilities.supportsImageInput && field.type === FieldType.Attachment\n                    ? t('table:field.aiConfig.hint.attachmentNotSupported')\n                    : undefined\n                }\n              />\n            </div>\n          ) : (\n            <Fragment>\n              {/* Source Field */}\n              <div className=\"flex flex-col gap-y-2\">\n                <span>\n                  {t('table:field.aiConfig.label.sourceFieldForAttachment')}\n                  <RequireCom />\n                </span>\n                <FieldSelect\n                  excludedIds={id ? [id] : []}\n                  disabledTypes={modelCapabilities.supportsImageInput ? [] : [FieldType.Attachment]}\n                  disabledReason={t('table:field.aiConfig.hint.attachmentNotSupported')}\n                  selectedId={(aiConfig as IAttachmentFieldGenerateImageAIConfig)?.sourceFieldId}\n                  onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n                />\n                {modelCapabilities.supportsImageInput && (\n                  <p className=\"text-xs text-muted-foreground\">\n                    {t('table:field.aiConfig.hint.imageInputSupported')}\n                  </p>\n                )}\n              </div>\n\n              {/* Additional Prompt (always visible) */}\n              <div className=\"flex flex-col gap-y-2\">\n                <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n                <Textarea\n                  placeholder={t('table:field.aiConfig.placeholder.attachPromptForImageGeneration')}\n                  className=\"w-full\"\n                  value={(aiConfig as IAttachmentFieldGenerateImageAIConfig)?.attachPrompt || ''}\n                  onChange={(e) => {\n                    onConfigChange('attachPrompt', e.target.value);\n                  }}\n                />\n              </div>\n            </Fragment>\n          )}\n\n          {/* Advanced Settings - Collapsible (shared by both modes) */}\n          {hasAdvancedOptions && (\n            <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>\n              <CollapsibleTrigger className=\"flex w-full items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted/50\">\n                <Settings className=\"size-4\" />\n                <span className=\"flex-1 text-left\">\n                  {t('table:field.aiConfig.label.advancedSettings')}\n                </span>\n                {advancedOpen ? (\n                  <ChevronDown className=\"size-4\" />\n                ) : (\n                  <ChevronRight className=\"size-4\" />\n                )}\n              </CollapsibleTrigger>\n              <CollapsibleContent className=\"mt-2 space-y-4 rounded-md border p-3\">\n                {/* Image size */}\n                {modelCapabilities.supportsSize && (\n                  <div className=\"flex flex-col gap-y-2\">\n                    <span className=\"text-sm\">{t('table:field.aiConfig.label.imageSize')}</span>\n                    <Selector\n                      className=\"w-full\"\n                      placeholder={t('table:field.aiConfig.placeholder.imageSize')}\n                      selectedId={currentSize}\n                      onChange={(id) => onConfigChange('size', id)}\n                      candidates={imageSizeCandidates}\n                      searchTip={t('sdk:common.search.placeholder')}\n                      emptyTip={t('sdk:common.search.empty')}\n                    />\n                  </div>\n                )}\n\n                {/* Image quality */}\n                {modelCapabilities.supportsQuality && (\n                  <div className=\"flex flex-col gap-y-2\">\n                    <span className=\"text-sm\">{t('table:field.aiConfig.label.imageQuality')}</span>\n                    <Selector\n                      className=\"w-full\"\n                      placeholder={t('table:field.aiConfig.placeholder.imageQuality')}\n                      selectedId={currentQuality}\n                      onChange={(id) => onConfigChange('quality', id)}\n                      candidates={qualityCandidates}\n                      searchTip={t('sdk:common.search.placeholder')}\n                      emptyTip={t('sdk:common.search.empty')}\n                    />\n                  </div>\n                )}\n\n                {/* Aspect ratio (for multimodal LLMs like Gemini) */}\n                {modelCapabilities.supportsAspectRatio && (\n                  <div className=\"flex flex-col gap-y-2\">\n                    <span className=\"text-sm\">{t('table:field.aiConfig.label.aspectRatio')}</span>\n                    <Selector\n                      className=\"w-full\"\n                      placeholder={t('table:field.aiConfig.placeholder.aspectRatio')}\n                      selectedId={currentAspectRatio ?? ''}\n                      onChange={(id) => onConfigChange('aspectRatio', id || undefined)}\n                      candidates={aspectRatioCandidates}\n                      searchTip={t('sdk:common.search.placeholder')}\n                      emptyTip={t('sdk:common.search.empty')}\n                    />\n                  </div>\n                )}\n\n                {/* Resolution (for multimodal LLMs like Gemini) */}\n                {modelCapabilities.supportsResolution && (\n                  <div className=\"flex flex-col gap-y-2\">\n                    <span className=\"text-sm\">{t('table:field.aiConfig.label.resolution')}</span>\n                    <Selector\n                      className=\"w-full\"\n                      placeholder={t('table:field.aiConfig.placeholder.resolution')}\n                      selectedId={currentResolution ?? ''}\n                      onChange={(id) => onConfigChange('resolution', id || undefined)}\n                      candidates={resolutionCandidates}\n                      searchTip={t('sdk:common.search.placeholder')}\n                      emptyTip={t('sdk:common.search.empty')}\n                    />\n                  </div>\n                )}\n\n                {/* Image count */}\n                {modelCapabilities.supportsCount && (\n                  <div className=\"flex flex-col gap-y-2\">\n                    <span className=\"text-sm\">{t('table:field.aiConfig.label.imageCount')}</span>\n                    <div className=\"flex w-full cursor-pointer justify-between gap-x-4 rounded-md border px-3 py-2\">\n                      <Slider\n                        value={[currentCount]}\n                        min={1}\n                        max={maxCount}\n                        step={1}\n                        className=\"grow\"\n                        onValueChange={(value) => onConfigChange('n', Number(value[0]))}\n                      />\n                      <span className=\"min-w-[24px] text-center\">{currentCount}</span>\n                    </div>\n                    {modelCapabilities.maxImagesPerCall === 1 && (\n                      <p className=\"text-xs text-muted-foreground\">\n                        {t('table:field.aiConfig.hint.singleImageOnly')}\n                      </p>\n                    )}\n                  </div>\n                )}\n              </CollapsibleContent>\n            </Collapsible>\n          )}\n        </Fragment>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/DateFieldAiConfig.tsx",
    "content": "import type { IDateFieldExtractionAIConfig, IDateFieldCustomizeAIConfig } from '@teable/core';\nimport { FieldAIActionType } from '@teable/core';\nimport { Export, Pencil } from '@teable/icons';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Textarea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\ninterface IDateFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n  modelSelector?: React.ReactNode;\n}\n\nexport const DateFieldAiConfig = (props: IDateFieldAiConfigProps) => {\n  const { field, onChange, modelSelector } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.Extraction,\n        icon: <Export className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.extraction'),\n      },\n      {\n        id: FieldAIActionType.Customization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const onConfigChange = (\n    key: keyof IDateFieldExtractionAIConfig | keyof IDateFieldCustomizeAIConfig,\n    value: unknown\n  ) => {\n    switch (key) {\n      case 'type':\n        return onChange?.({ aiConfig: { type: value } as IDateFieldExtractionAIConfig });\n      case 'sourceFieldId':\n        return onChange?.({\n          aiConfig: { ...aiConfig, sourceFieldId: value as string } as IDateFieldExtractionAIConfig,\n        });\n      case 'attachPrompt':\n        return onChange?.({\n          aiConfig: {\n            ...aiConfig,\n            attachPrompt: value as string,\n          } as IDateFieldExtractionAIConfig,\n        });\n      case 'prompt':\n        return onChange?.({\n          aiConfig: { ...aiConfig, prompt: value as string } as IDateFieldCustomizeAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {/* Model selector - placed right after type selector */}\n      {type && modelSelector}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>\n              {t('table:field.aiConfig.label.sourceField')}\n              <RequireCom />\n            </span>\n            <FieldSelect\n              excludedIds={id ? [id] : []}\n              selectedId={(aiConfig as IDateFieldExtractionAIConfig)?.sourceFieldId}\n              onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n            />\n          </div>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n            <Textarea\n              placeholder={t('table:field.aiConfig.placeholder.extractDate')}\n              className=\"w-full\"\n              value={(aiConfig as IDateFieldExtractionAIConfig)?.attachPrompt || ''}\n              onChange={(e) => {\n                onConfigChange('attachPrompt', e.target.value);\n              }}\n            />\n          </div>\n        </Fragment>\n      )}\n      {type === FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <PromptEditorContainer\n              excludedFieldId={id}\n              value={(aiConfig as IDateFieldCustomizeAIConfig)?.prompt || ''}\n              onChange={(value) => onConfigChange('prompt', value)}\n              label={t('table:field.aiConfig.label.prompt')}\n              placeholder={t('table:field.aiConfig.placeholder.prompt')}\n              required={true}\n            />\n          </div>\n        </Fragment>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/FieldAiConfig.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\nimport { useQuery } from '@tanstack/react-query';\nimport type { IFieldAIConfig } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { ChevronDown, ChevronRight, HelpCircle, MagicAi } from '@teable/icons';\nimport { BillingProductLevel, getAIConfig } from '@teable/openapi';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Label,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment, useState } from 'react';\nimport { AIModelSelect } from '@/features/app/blocks/admin/setting/components/ai-config/AiModelSelect';\nimport {\n  generateModelKeyList,\n  generateGatewayModelKeyList,\n} from '@/features/app/blocks/admin/setting/components/ai-config/utils';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { useBaseUsage } from '@/features/app/hooks/useBaseUsage';\nimport { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { UpgradeWrapper } from '../../billing/UpgradeWrapper';\nimport type { IFieldEditorRo } from '../type';\nimport { AttachmentFieldAiConfig } from './AttachmentFieldAiConfig';\nimport { DateFieldAiConfig } from './DateFieldAiConfig';\nimport { MultipleSelectFieldAiConfig } from './MultipleSelectFieldAiConfig';\nimport { RatingFieldAiConfig } from './RatingFieldAiConfig';\nimport { SingleSelectFieldAiConfig } from './SingleSelectFieldAiConfig';\nimport { TextFieldAiConfig } from './TextFieldAiConfig';\n\ninterface FieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n}\n\nconst SUPPORTED_FIELD_TYPES = new Set([\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.Attachment,\n  FieldType.Rating,\n  FieldType.Number,\n  FieldType.Date,\n]);\n\nexport const FieldAiConfig: React.FC<FieldAiConfigProps> = ({ field, onChange }) => {\n  const { type: fieldType, isLookup, aiConfig } = field;\n  const usage = useBaseUsage();\n  const { aiField: aiFieldEnabled } = useDisableAIAction();\n  const baseId = useBaseId() as string;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const [_isExpanded, setIsExpanded] = useState(!!aiConfig);\n\n  const { data: baseAiConfig } = useQuery({\n    queryKey: ['ai-config', baseId],\n    queryFn: () => getAIConfig(baseId).then(({ data }) => data),\n  });\n\n  const { type } = aiConfig ?? {};\n  const { fieldAIEnable: billingFieldAIEnable = false } = usage?.limit ?? {};\n  const fieldAIEnable = billingFieldAIEnable && aiFieldEnabled;\n  const isExpanded = _isExpanded && fieldAIEnable;\n  const { llmProviders = [], modelDefinationMap, gatewayModels } = baseAiConfig ?? {};\n\n  // Generate model list: Space models first, then Gateway recommended models, then Instance models\n  const providerModels = generateModelKeyList(llmProviders);\n  const gatewayModelsList = generateGatewayModelKeyList(gatewayModels);\n  const spaceModels = providerModels.filter((m) => !m.isInstance);\n  const instanceModels = providerModels.filter((m) => m.isInstance);\n  const models = [...spaceModels, ...gatewayModelsList, ...instanceModels];\n\n  const onConfigChange = (key: keyof IFieldAIConfig, value: unknown) => {\n    switch (key) {\n      case 'modelKey':\n        return onChange?.({\n          aiConfig: { ...aiConfig, modelKey: value as string } as IFieldAIConfig,\n        });\n      case 'isAutoFill':\n        return onChange?.({\n          aiConfig: { ...aiConfig, isAutoFill: value as boolean } as IFieldAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  // Model selector component to be passed to field configs\n  const modelSelector =\n    fieldType !== FieldType.Attachment && models.length > 0 ? (\n      <div className=\"flex flex-col gap-y-2\">\n        <span>\n          {t('table:field.aiConfig.label.model')}\n          <RequireCom />\n        </span>\n        <AIModelSelect\n          value={aiConfig?.modelKey || ''}\n          onValueChange={(newValue) => {\n            onConfigChange('modelKey', newValue);\n          }}\n          options={models}\n          className=\"w-full px-2\"\n          modelDefinationMap={modelDefinationMap}\n          needGroup\n        />\n      </div>\n    ) : null;\n\n  const getAiConfigRenderer = () => {\n    switch (fieldType) {\n      case FieldType.SingleLineText:\n      case FieldType.LongText:\n        return (\n          <TextFieldAiConfig field={field} onChange={onChange} modelSelector={modelSelector} />\n        );\n      case FieldType.SingleSelect:\n        return (\n          <SingleSelectFieldAiConfig\n            field={field}\n            onChange={onChange}\n            modelSelector={modelSelector}\n          />\n        );\n      case FieldType.MultipleSelect:\n        return (\n          <MultipleSelectFieldAiConfig\n            field={field}\n            onChange={onChange}\n            modelSelector={modelSelector}\n          />\n        );\n      case FieldType.Attachment:\n        return <AttachmentFieldAiConfig field={field} onChange={onChange} />;\n      case FieldType.Rating:\n      case FieldType.Number:\n        return (\n          <RatingFieldAiConfig field={field} onChange={onChange} modelSelector={modelSelector} />\n        );\n      case FieldType.Date:\n        return (\n          <DateFieldAiConfig field={field} onChange={onChange} modelSelector={modelSelector} />\n        );\n      default:\n        throw new Error(`Unsupported field type: ${fieldType}`);\n    }\n  };\n\n  if (!SUPPORTED_FIELD_TYPES.has(fieldType as FieldType) || isLookup || !aiFieldEnabled) {\n    return null;\n  }\n\n  const headerComponent = fieldAIEnable ? (\n    <div\n      className={cn(\n        'group flex cursor-pointer select-none items-center justify-between px-3 py-2 rounded-sm gap-x-2',\n        `transition-all duration-500 ease-in-out \n      bg-gradient-to-r from-teal-100 via-blue-50 to-rose-50 \n    hover:from-teal-100/70 hover:via-blue-50/70 hover:to-rose-50/70\n      dark:bg-[linear-gradient(90deg,rgba(120,182,240,0.30)_0%,rgba(149,122,208,0.30)_50%,rgba(223,86,109,0.30)_100%)]\n      dark:hover:bg-[linear-gradient(90deg,rgba(120,182,240,0.35)_0%,rgba(149,122,208,0.35)_50%,rgba(223,86,109,0.35)_100%)]\n      `,\n        isExpanded && 'rounded-b-none'\n      )}\n      onClick={() => setIsExpanded(!isExpanded)}\n    >\n      <div className=\"flex shrink-0 items-center gap-x-1\">\n        <MagicAi className=\"size-4 text-amber-500\" />\n        {t('table:field.aiConfig.title')}\n      </div>\n      <div className=\"flex items-center gap-x-3 overflow-hidden\">\n        {Boolean(aiConfig?.type) && (\n          <span\n            className=\"cursor-pointer truncate border-b border-muted-foreground/80 text-xs text-muted-foreground\"\n            onClick={() => onChange?.({ aiConfig: null })}\n            tabIndex={0}\n            role=\"button\"\n          >\n            {t('actions.removeConfig')}\n          </span>\n        )}\n        {isExpanded ? (\n          <ChevronDown className=\"size-4 shrink-0\" />\n        ) : (\n          <ChevronRight className=\"size-4 shrink-0\" />\n        )}\n      </div>\n    </div>\n  ) : (\n    <UpgradeWrapper targetBillingLevel={BillingProductLevel.Pro}>\n      {({ badge }) => (\n        <div className=\"group flex cursor-pointer select-none items-center justify-between rounded-sm px-3 py-2\">\n          <div className=\"flex items-center gap-x-1\">\n            <MagicAi className=\"size-4 text-gray-500\" />\n            {t('table:field.aiConfig.title')}\n            {badge}\n          </div>\n          <ChevronRight className=\"size-4\" />\n        </div>\n      )}\n    </UpgradeWrapper>\n  );\n\n  return (\n    <Fragment>\n      <hr className=\"border-border\" />\n      <div\n        className={cn(\n          'w-full rounded-md border text-sm',\n          fieldAIEnable && 'border-border dark:border-white/20'\n        )}\n      >\n        {headerComponent}\n\n        {isExpanded && (\n          <div className=\"space-y-4 border-t p-4\">\n            {getAiConfigRenderer()}\n            {type && (\n              <div className=\"flex h-8 items-center\">\n                <Switch\n                  id=\"autoFill\"\n                  className=\"mr-2\"\n                  checked={Boolean(aiConfig?.isAutoFill)}\n                  onCheckedChange={(checked) => {\n                    onConfigChange('isAutoFill', checked);\n                  }}\n                />\n                <Label htmlFor=\"autoFill\" className=\"font-normal\">\n                  {t('table:field.aiConfig.autoFill.title')}\n                </Label>\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <div className=\"ml-2 cursor-pointer text-muted-foreground\">\n                        <HelpCircle className=\"size-4\" />\n                      </div>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <p className=\"max-w-[320px]\">{t('table:field.aiConfig.autoFill.tip')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/MultipleSelectFieldAiConfig.tsx",
    "content": "import type {\n  IMultipleSelectFieldCustomizeAIConfig,\n  IMultipleSelectFieldAIConfig,\n  ITextFieldAIConfig,\n  IMultipleSelectFieldTagAIConfig,\n} from '@teable/core';\nimport { FieldAIActionType } from '@teable/core';\nimport { Pencil } from '@teable/icons';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Textarea } from '@teable/ui-lib/shadcn';\nimport { TagIcon } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\ninterface IMultipleSelectFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n  modelSelector?: React.ReactNode;\n}\n\nexport const MultipleSelectFieldAiConfig = (props: IMultipleSelectFieldAiConfigProps) => {\n  const { field, onChange, modelSelector } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.Tag,\n        icon: <TagIcon className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.tag'),\n      },\n      {\n        id: FieldAIActionType.Customization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const onConfigChange = (\n    key: keyof IMultipleSelectFieldTagAIConfig | keyof IMultipleSelectFieldCustomizeAIConfig,\n    value: unknown\n  ) => {\n    switch (key) {\n      case 'type':\n        return onChange?.({ aiConfig: { type: value } as ITextFieldAIConfig });\n      case 'sourceFieldId':\n        return onChange?.({\n          aiConfig: { ...aiConfig, sourceFieldId: value as string } as IMultipleSelectFieldAIConfig,\n        });\n      case 'attachPrompt':\n        return onChange?.({\n          aiConfig: {\n            ...aiConfig,\n            attachPrompt: value as string,\n          } as IMultipleSelectFieldTagAIConfig,\n        });\n      case 'prompt':\n        return onChange?.({\n          aiConfig: {\n            ...aiConfig,\n            prompt: value as string,\n          } as IMultipleSelectFieldCustomizeAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {/* Model selector - placed right after type selector */}\n      {type && modelSelector}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>\n              {t('table:field.aiConfig.label.sourceFieldForTag')}\n              <RequireCom />\n            </span>\n            <FieldSelect\n              excludedIds={id ? [id] : []}\n              selectedId={(aiConfig as IMultipleSelectFieldTagAIConfig)?.sourceFieldId}\n              onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n            />\n          </div>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n            <Textarea\n              placeholder={t('table:field.aiConfig.placeholder.attachPromptForTag')}\n              className=\"w-full\"\n              value={(aiConfig as IMultipleSelectFieldTagAIConfig)?.attachPrompt || ''}\n              onChange={(e) => {\n                onConfigChange('attachPrompt', e.target.value);\n              }}\n            />\n          </div>\n        </Fragment>\n      )}\n\n      {type === FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <PromptEditorContainer\n              excludedFieldId={id}\n              value={(aiConfig as IMultipleSelectFieldCustomizeAIConfig)?.prompt || ''}\n              onChange={(value) => onConfigChange('prompt', value)}\n              label={t('table:field.aiConfig.label.prompt')}\n              placeholder={t('table:field.aiConfig.placeholder.prompt')}\n              required={true}\n            />\n          </div>\n        </Fragment>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/RatingFieldAiConfig.tsx",
    "content": "import type { IRatingFieldCustomizeAIConfig, IRatingFieldRatingAIConfig } from '@teable/core';\nimport { FieldAIActionType } from '@teable/core';\nimport { Pencil, Star } from '@teable/icons';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Textarea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\ninterface IRatingFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n  modelSelector?: React.ReactNode;\n}\n\nexport const RatingFieldAiConfig = (props: IRatingFieldAiConfigProps) => {\n  const { field, onChange, modelSelector } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.Rating,\n        icon: <Star className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.rating'),\n      },\n      {\n        id: FieldAIActionType.Customization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const onConfigChange = (\n    key: keyof IRatingFieldRatingAIConfig | keyof IRatingFieldCustomizeAIConfig,\n    value: unknown\n  ) => {\n    switch (key) {\n      case 'type':\n        return onChange?.({ aiConfig: { type: value } as IRatingFieldRatingAIConfig });\n      case 'sourceFieldId':\n        return onChange?.({\n          aiConfig: { ...aiConfig, sourceFieldId: value as string } as IRatingFieldRatingAIConfig,\n        });\n      case 'attachPrompt':\n        return onChange?.({\n          aiConfig: {\n            ...aiConfig,\n            attachPrompt: value as string,\n          } as IRatingFieldRatingAIConfig,\n        });\n      case 'prompt':\n        return onChange?.({\n          aiConfig: { ...aiConfig, prompt: value as string } as IRatingFieldCustomizeAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {/* Model selector - placed right after type selector */}\n      {type && modelSelector}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>\n              {t('table:field.aiConfig.label.sourceField')}\n              <RequireCom />\n            </span>\n            <FieldSelect\n              excludedIds={id ? [id] : []}\n              selectedId={(aiConfig as IRatingFieldRatingAIConfig)?.sourceFieldId}\n              onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n            />\n          </div>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n            <Textarea\n              placeholder={t('table:field.aiConfig.placeholder.attachPromptForRating')}\n              className=\"w-full\"\n              value={(aiConfig as IRatingFieldRatingAIConfig)?.attachPrompt || ''}\n              onChange={(e) => {\n                onConfigChange('attachPrompt', e.target.value);\n              }}\n            />\n          </div>\n        </Fragment>\n      )}\n      {type === FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <PromptEditorContainer\n              excludedFieldId={id}\n              value={(aiConfig as IRatingFieldCustomizeAIConfig)?.prompt || ''}\n              onChange={(value) => onConfigChange('prompt', value)}\n              label={t('table:field.aiConfig.label.prompt')}\n              placeholder={t('table:field.aiConfig.placeholder.prompt')}\n              required={true}\n            />\n          </div>\n        </Fragment>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/SingleSelectFieldAiConfig.tsx",
    "content": "import type {\n  ISingleSelectFieldClassifyAIConfig,\n  ISingleSelectFieldCustomizeAIConfig,\n  ISingleSelectFieldAIConfig,\n  ITextFieldAIConfig,\n} from '@teable/core';\nimport { FieldAIActionType } from '@teable/core';\nimport { ListChecks, Pencil } from '@teable/icons';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Textarea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\ninterface ISingleSelectFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n  modelSelector?: React.ReactNode;\n}\n\nexport const SingleSelectFieldAiConfig = (props: ISingleSelectFieldAiConfigProps) => {\n  const { field, onChange, modelSelector } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.Classification,\n        icon: <ListChecks className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.classification'),\n      },\n      {\n        id: FieldAIActionType.Customization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const onConfigChange = (\n    key: keyof ISingleSelectFieldClassifyAIConfig | keyof ISingleSelectFieldCustomizeAIConfig,\n    value: unknown\n  ) => {\n    switch (key) {\n      case 'type':\n        return onChange?.({ aiConfig: { type: value } as ITextFieldAIConfig });\n      case 'sourceFieldId':\n        return onChange?.({\n          aiConfig: { ...aiConfig, sourceFieldId: value as string } as ISingleSelectFieldAIConfig,\n        });\n      case 'attachPrompt':\n        return onChange?.({\n          aiConfig: {\n            ...aiConfig,\n            attachPrompt: value as string,\n          } as ISingleSelectFieldClassifyAIConfig,\n        });\n      case 'prompt':\n        return onChange?.({\n          aiConfig: { ...aiConfig, prompt: value as string } as ISingleSelectFieldCustomizeAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {/* Model selector - placed right after type selector */}\n      {type && modelSelector}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>\n              {t('table:field.aiConfig.label.sourceFieldForClassify')}\n              <RequireCom />\n            </span>\n            <FieldSelect\n              excludedIds={id ? [id] : []}\n              selectedId={(aiConfig as ISingleSelectFieldClassifyAIConfig)?.sourceFieldId}\n              onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n            />\n          </div>\n          <div className=\"flex flex-col gap-y-2\">\n            <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n            <Textarea\n              placeholder={t('table:field.aiConfig.placeholder.attachPromptForClassify')}\n              className=\"w-full\"\n              value={(aiConfig as ISingleSelectFieldClassifyAIConfig)?.attachPrompt || ''}\n              onChange={(e) => {\n                onConfigChange('attachPrompt', e.target.value);\n              }}\n            />\n          </div>\n        </Fragment>\n      )}\n\n      {type === FieldAIActionType.Customization && (\n        <Fragment>\n          <div className=\"flex flex-col gap-y-2\">\n            <PromptEditorContainer\n              excludedFieldId={id}\n              value={(aiConfig as ISingleSelectFieldCustomizeAIConfig)?.prompt || ''}\n              onChange={(value) => onConfigChange('prompt', value)}\n              label={t('table:field.aiConfig.label.prompt')}\n              placeholder={t('table:field.aiConfig.placeholder.prompt')}\n              required={true}\n            />\n          </div>\n        </Fragment>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/TextFieldAiConfig.tsx",
    "content": "import type {\n  ISingleSelectFieldClassifyAIConfig,\n  ITextFieldAIConfig,\n  ITextFieldCustomizeAIConfig,\n  ITextFieldExtractInfoAIConfig,\n  ITextFieldImproveTextAIConfig,\n  ITextFieldSummarizeAIConfig,\n  ITextFieldTranslateAIConfig,\n} from '@teable/core';\nimport { FieldAIActionType } from '@teable/core';\nimport { Edit, Export, Layers, Pencil, Translation } from '@teable/icons';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Input, Textarea } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { Fragment, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldSelect, PromptEditorContainer } from './components';\n\ninterface ITextFieldAiConfigProps {\n  field: Partial<IFieldEditorRo>;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n  modelSelector?: React.ReactNode;\n}\n\nexport const TextFieldAiConfig = (props: ITextFieldAiConfigProps) => {\n  const { field, onChange, modelSelector } = props;\n  const { id, aiConfig } = field;\n  const { type } = aiConfig ?? {};\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const candidates = useMemo(() => {\n    return [\n      {\n        id: FieldAIActionType.Summary,\n        icon: <Layers className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.summary'),\n      },\n      {\n        id: FieldAIActionType.Translation,\n        icon: <Translation className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.translation'),\n      },\n      {\n        id: FieldAIActionType.Extraction,\n        icon: <Export className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.extraction'),\n      },\n      {\n        id: FieldAIActionType.Improvement,\n        icon: <Edit className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.improvement'),\n      },\n      {\n        id: FieldAIActionType.Customization,\n        icon: <Pencil className=\"size-4\" />,\n        name: t('table:field.aiConfig.type.customization'),\n      },\n    ];\n  }, [t]);\n\n  const getPlaceholder = (type: FieldAIActionType) => {\n    switch (type) {\n      case FieldAIActionType.Translation:\n        return t('table:field.aiConfig.placeholder.translate');\n      case FieldAIActionType.Improvement:\n        return t('table:field.aiConfig.placeholder.improveText');\n      case FieldAIActionType.Extraction:\n        return t('table:field.aiConfig.placeholder.extractInfo');\n      case FieldAIActionType.Summary:\n        return t('table:field.aiConfig.placeholder.summarize');\n      case FieldAIActionType.Customization:\n        return t('table:field.aiConfig.placeholder.prompt');\n      default:\n        return '';\n    }\n  };\n\n  const onConfigChange = (\n    key:\n      | keyof ITextFieldExtractInfoAIConfig\n      | keyof ITextFieldSummarizeAIConfig\n      | keyof ITextFieldTranslateAIConfig\n      | keyof ITextFieldImproveTextAIConfig\n      | keyof ITextFieldCustomizeAIConfig,\n    value: unknown\n  ) => {\n    switch (key) {\n      case 'type':\n        return onChange?.({ aiConfig: { type: value } as ITextFieldAIConfig });\n      case 'sourceFieldId':\n        return onChange?.({\n          aiConfig: { ...aiConfig, sourceFieldId: value as string } as ITextFieldAIConfig,\n        });\n      case 'targetLanguage':\n        return onChange?.({\n          aiConfig: { ...aiConfig, targetLanguage: value as string } as ITextFieldTranslateAIConfig,\n        });\n      case 'attachPrompt':\n        return onChange?.({\n          aiConfig: { ...aiConfig, attachPrompt: value as string } as ITextFieldImproveTextAIConfig,\n        });\n      case 'prompt':\n        return onChange?.({\n          aiConfig: { ...aiConfig, prompt: value as string } as ITextFieldCustomizeAIConfig,\n        });\n      default:\n        throw new Error(`Unsupported key: ${key}`);\n    }\n  };\n\n  return (\n    <Fragment>\n      <div className=\"flex flex-col gap-y-2\">\n        <span>{t('table:field.aiConfig.label.type')}</span>\n        <Selector\n          className=\"w-full\"\n          placeholder={t('table:field.aiConfig.placeholder.type')}\n          selectedId={type}\n          onChange={(id) => {\n            onConfigChange('type', id);\n          }}\n          candidates={candidates}\n          searchTip={t('sdk:common.search.placeholder')}\n          emptyTip={t('sdk:common.search.empty')}\n        />\n      </div>\n\n      {/* Model selector - placed right after type selector */}\n      {type && modelSelector}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <div className=\"flex flex-col gap-y-2\">\n          <span>\n            {t('table:field.aiConfig.label.sourceField')}\n            <RequireCom />\n          </span>\n          <FieldSelect\n            excludedIds={id ? [id] : []}\n            selectedId={(aiConfig as ISingleSelectFieldClassifyAIConfig)?.sourceFieldId}\n            onChange={(fieldId) => onConfigChange('sourceFieldId', fieldId)}\n          />\n        </div>\n      )}\n\n      {type === FieldAIActionType.Translation && (\n        <div className=\"flex flex-col gap-y-2\">\n          <span>\n            {t('table:field.aiConfig.label.targetLanguage')}\n            <RequireCom />\n          </span>\n          <Input\n            type=\"text\"\n            placeholder={t('table:field.aiConfig.placeholder.targetLanguage')}\n            value={(aiConfig as ITextFieldTranslateAIConfig)?.targetLanguage || ''}\n            onChange={(e) => {\n              onConfigChange('targetLanguage', e.target.value);\n            }}\n          />\n        </div>\n      )}\n\n      {type && type !== FieldAIActionType.Customization && (\n        <div className=\"flex flex-col gap-y-2\">\n          <span>{t('table:field.aiConfig.label.attachPrompt')}</span>\n          <Textarea\n            placeholder={getPlaceholder(type)}\n            className=\"w-full\"\n            value={(aiConfig as ITextFieldImproveTextAIConfig)?.attachPrompt || ''}\n            onChange={(e) => {\n              onConfigChange('attachPrompt', e.target.value);\n            }}\n          />\n        </div>\n      )}\n\n      {type === FieldAIActionType.Customization && (\n        <div className=\"flex flex-col gap-y-2\">\n          <PromptEditorContainer\n            excludedFieldId={id}\n            value={(aiConfig as ITextFieldCustomizeAIConfig)?.prompt || ''}\n            onChange={(value) => onConfigChange('prompt', value)}\n            label={t('table:field.aiConfig.label.prompt')}\n            placeholder={t('table:field.aiConfig.placeholder.prompt')}\n            required={true}\n          />\n        </div>\n      )}\n    </Fragment>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/field-select/FieldSelect.tsx",
    "content": "import type { FieldType } from '@teable/core';\nimport { Check, ChevronDown } from '@teable/icons';\nimport { useFields, useFieldStaticGetter } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Command,\n  CommandEmpty,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IFieldSelectProps {\n  selectedId?: string;\n  excludedIds?: string[];\n  excludeTypes?: FieldType[];\n  disabledTypes?: FieldType[];\n  disabledReason?: string;\n  onChange: (fieldId: string) => void;\n}\n\nexport const FieldSelect: React.FC<IFieldSelectProps> = (props) => {\n  const {\n    selectedId,\n    excludeTypes = [],\n    excludedIds = [],\n    disabledTypes = [],\n    disabledReason,\n    onChange,\n  } = props;\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const getFieldStatic = useFieldStaticGetter();\n  const [open, setOpen] = useState(false);\n  const ref = useRef<HTMLButtonElement>(null);\n\n  const candidates = useMemo(() => {\n    return fields\n      .filter((f) => !excludeTypes.includes(f.type) && !excludedIds.includes(f.id))\n      .map((f) => {\n        const Icon = getFieldStatic(f.type, {\n          isLookup: f.isLookup,\n          isConditionalLookup: f.isConditionalLookup,\n          hasAiConfig: Boolean(f.aiConfig),\n          deniedReadRecord: !f.canReadFieldRecord,\n        }).Icon;\n        return {\n          id: f.id,\n          name: f.name,\n          type: f.type,\n          icon: <Icon className=\"size-4 shrink-0\" />,\n          disabled: disabledTypes.includes(f.type),\n        };\n      });\n  }, [fields, excludeTypes, excludedIds, disabledTypes, getFieldStatic]);\n\n  const candidatesMap = useMemo(\n    () =>\n      candidates.reduce(\n        (pre, cur) => {\n          pre[cur.id] = cur;\n          return pre;\n        },\n        {} as Record<string, (typeof candidates)[0]>\n      ),\n    [candidates]\n  );\n\n  const selected = candidatesMap[selectedId || ''];\n\n  const renderItem = (item: (typeof candidates)[0]) => {\n    const { id, name, icon, disabled } = item;\n    const isSelected = id === selectedId;\n\n    const itemContent = (\n      <CommandItem\n        key={id}\n        value={id}\n        disabled={disabled}\n        onSelect={() => {\n          if (disabled) return;\n          onChange(id);\n          setOpen(false);\n        }}\n        className={cn('flex', disabled && 'pointer-events-none opacity-50')}\n      >\n        <Check\n          className={cn('mr-2 h-4 w-4 flex-shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}\n        />\n        {icon}\n        <span className=\"ml-2 truncate\">{name}</span>\n      </CommandItem>\n    );\n\n    if (disabled && disabledReason) {\n      return (\n        <TooltipProvider key={id}>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div>{itemContent}</div>\n            </TooltipTrigger>\n            <TooltipContent side=\"right\">\n              <p className=\"max-w-[200px] text-xs\">{disabledReason}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      );\n    }\n\n    return itemContent;\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen} modal>\n      <PopoverTrigger asChild>\n        <Button\n          ref={ref}\n          disabled={!candidates.length}\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className={cn('flex gap-2 font-normal px-3 w-full')}\n        >\n          {selected ? (\n            <>\n              {selected.icon}\n              <span className=\"truncate\">{selected.name}</span>\n            </>\n          ) : (\n            <span className=\"shrink-0\">{t('table:field.editor.selectField')}</span>\n          )}\n          <div className=\"grow\"></div>\n          <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-full max-w-[200px] p-0\"\n        style={{ minWidth: ref.current?.offsetWidth }}\n      >\n        <Command\n          filter={(value, search) => {\n            if (!search) return 1;\n            const item = candidatesMap[value];\n            const text = item?.name || item?.id;\n            if (text?.toLocaleLowerCase().includes(search.toLocaleLowerCase())) return 1;\n            return 0;\n          }}\n        >\n          <CommandInput placeholder={t('sdk:common.search.placeholder')} />\n          <CommandEmpty>{t('sdk:common.search.empty')}</CommandEmpty>\n          <CommandList>{candidates.map(renderItem)}</CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/field-select/index.ts",
    "content": "export * from './FieldSelect';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/index.ts",
    "content": "export * from './field-select';\nexport * from './prompt-editor';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/PromptEditor.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { defaultKeymap, history, historyKeymap } from '@codemirror/commands';\nimport { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';\nimport { EditorState, StateField, StateEffect } from '@codemirror/state';\nimport type { DecorationSet } from '@codemirror/view';\nimport { EditorView, keymap, Decoration, placeholder as cmPlaceholder } from '@codemirror/view';\nimport { useTheme } from '@teable/next-themes';\nimport { useFields, useFieldStaticGetter } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { darkTheme, FieldVariable, FieldVariableNavigation, lightTheme } from './extensions';\nimport type { IEditorThemeOptions } from './extensions/theme';\n\nexport interface IPromptEditorProps {\n  value: string;\n  className?: string;\n  placeholder?: string;\n  themeOptions?: IEditorThemeOptions;\n  onChange: (value: string) => void;\n  resizable?: boolean;\n  minHeight?: number;\n  maxHeight?: number;\n  excludedFieldId?: string;\n  isOptionDisabled?: (field: IFieldInstance) => boolean;\n}\n\nconst addField = StateEffect.define<{\n  from: number;\n  to: number;\n  fieldId: string;\n  fieldName: string;\n}>();\n\nexport type EditorViewRef = { current: EditorView | null };\n\nexport const PromptEditor = ({\n  value,\n  themeOptions,\n  className,\n  placeholder,\n  editorViewRef,\n  onChange,\n  resizable = false,\n  minHeight = 80,\n  maxHeight = 400,\n  excludedFieldId,\n  isOptionDisabled,\n}: IPromptEditorProps & {\n  editorViewRef?: EditorViewRef;\n}) => {\n  const allFields = useFields({ withHidden: true, withDenied: true });\n  const { resolvedTheme } = useTheme();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const fieldStaticGetter = useFieldStaticGetter();\n\n  const editorRef = useRef<HTMLDivElement>(null);\n  const lastValueRef = useRef(value);\n  const internalEditorViewRef = useRef<EditorView | null>(null);\n  const [editorView, setEditorView] = useState<EditorView | null>(null);\n\n  // Slash command state\n  const [slashMenuOpen, setSlashMenuOpen] = useState(false);\n  const [slashMenuPosition, setSlashMenuPosition] = useState({ top: 0, left: 0 });\n  const [slashStartPos, setSlashStartPos] = useState<number | null>(null);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const slashMenuRef = useRef<HTMLDivElement>(null);\n\n  const isLightTheme = resolvedTheme === 'light';\n  const actualEditorViewRef = editorViewRef || internalEditorViewRef;\n\n  // Filter fields excluding the current field\n  const fields = useMemo(() => {\n    return allFields.filter((f) => f.id !== excludedFieldId);\n  }, [allFields, excludedFieldId]);\n\n  // Filter fields for slash menu based on search query\n  const filteredFields = useMemo(() => {\n    if (!searchQuery) return fields;\n    const query = searchQuery.toLowerCase();\n    return fields.filter((f) => f.name.toLowerCase().includes(query));\n  }, [fields, searchQuery]);\n\n  // Reset selected index when filtered fields change\n  useEffect(() => {\n    setSelectedIndex(0);\n  }, [filteredFields]);\n\n  // Close slash menu when clicking outside\n  useEffect(() => {\n    if (!slashMenuOpen) return;\n\n    const handleClickOutside = (event: MouseEvent) => {\n      // Check if click is inside the menu\n      if (slashMenuRef.current?.contains(event.target as Node)) {\n        return; // Don't close if clicking inside menu\n      }\n      // Check if click is inside the editor\n      if (editorRef.current?.contains(event.target as Node)) {\n        return; // Don't close if clicking inside editor (let doc change handler manage it)\n      }\n      setSlashMenuOpen(false);\n      setSearchQuery('');\n    };\n\n    // Use mousedown to capture before focus changes\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [slashMenuOpen]);\n\n  // Handle field selection from slash menu\n  const handleSlashFieldSelect = useCallback(\n    (fieldId: string) => {\n      const view = actualEditorViewRef.current;\n      if (!view || slashStartPosRef.current === null) return;\n\n      const formatValue = `{${fieldId}}`;\n      const slashPos = slashStartPosRef.current;\n      const cursorPos = view.state.selection.main.head;\n      const docLength = view.state.doc.length;\n\n      // Validate positions are within document bounds\n      const safeSlashPos = Math.min(Math.max(0, slashPos), docLength);\n      const safeCursorPos = Math.min(Math.max(0, cursorPos), docLength);\n      const safeFrom = Math.min(safeSlashPos, safeCursorPos);\n      const safeTo = Math.max(safeSlashPos, safeCursorPos);\n\n      // Replace \"/\" and any text typed after it with the field reference\n      view.dispatch({\n        changes: { from: safeFrom, to: safeTo, insert: formatValue },\n        selection: { anchor: safeFrom + formatValue.length },\n      });\n      view.focus();\n\n      // Update refs immediately\n      slashMenuOpenRef.current = false;\n      slashStartPosRef.current = null;\n      setSlashMenuOpen(false);\n      setSearchQuery('');\n      setSlashStartPos(null);\n    },\n    [actualEditorViewRef]\n  );\n\n  // Ref for handleSlashFieldSelect to use in keymap\n  const handleSlashFieldSelectRef = useRef(handleSlashFieldSelect);\n  useEffect(() => {\n    handleSlashFieldSelectRef.current = handleSlashFieldSelect;\n  }, [handleSlashFieldSelect]);\n\n  // Find the nearest ancestor with a CSS transform (which changes fixed positioning context)\n  const findTransformedAncestor = useCallback((element: HTMLElement | null): HTMLElement | null => {\n    let current = element?.parentElement;\n    while (current) {\n      const style = window.getComputedStyle(current);\n      const transform = style.transform || style.webkitTransform;\n      if (transform && transform !== 'none') {\n        return current;\n      }\n      current = current.parentElement;\n    }\n    return null;\n  }, []);\n\n  // Get cursor position for slash menu placement\n  const getCursorCoords = useCallback(\n    (view: EditorView) => {\n      const pos = view.state.selection.main.head;\n      const coords = view.coordsAtPos(pos);\n      if (!coords) return null;\n\n      const menuWidth = 220; // minWidth of the menu\n      const menuPadding = 8; // padding from edge\n\n      // Check if we're inside a transformed container (like a dialog)\n      // If so, we need to adjust coordinates because position:fixed becomes relative to that container\n      const transformedAncestor = findTransformedAncestor(view.dom);\n      if (transformedAncestor) {\n        const ancestorRect = transformedAncestor.getBoundingClientRect();\n        let left = coords.left - ancestorRect.left;\n        const top = coords.bottom - ancestorRect.top;\n\n        // Constrain left position to prevent overflow\n        const maxLeft = ancestorRect.width - menuWidth - menuPadding;\n        if (left > maxLeft) {\n          left = Math.max(menuPadding, maxLeft);\n        }\n\n        return { top, left };\n      }\n\n      // No transform, use viewport coordinates directly\n      let left = coords.left;\n      const maxLeft = window.innerWidth - menuWidth - menuPadding;\n      if (left > maxLeft) {\n        left = Math.max(menuPadding, maxLeft);\n      }\n\n      return {\n        top: coords.bottom,\n        left,\n      };\n    },\n    [findTransformedAncestor]\n  );\n\n  const onVariableDelete = useCallback(\n    (from: number, to: number) => {\n      if (!actualEditorViewRef.current) return;\n\n      const view = actualEditorViewRef.current;\n      const docLength = view.state.doc.length;\n\n      // Validate positions are within document bounds\n      const safeFrom = Math.min(Math.max(0, from), docLength);\n      const safeTo = Math.min(Math.max(0, to), docLength);\n\n      if (safeFrom >= safeTo) return; // Nothing to delete\n\n      view.dispatch({\n        changes: { from: safeFrom, to: safeTo, insert: '' },\n        selection: { anchor: safeFrom },\n      });\n      view.focus();\n    },\n    [actualEditorViewRef]\n  );\n\n  const decorateFields = useCallback(\n    (view: EditorView) => {\n      const effects: StateEffect<unknown>[] = [];\n      const text = view.state.doc.toString();\n      const fieldPattern = /\\{([^}]+)\\}/g;\n      let match;\n\n      while ((match = fieldPattern.exec(text)) !== null) {\n        const fieldId = match[1];\n        const field = fields.find((f) => f.id === fieldId);\n        if (field) {\n          effects.push(\n            addField.of({\n              from: match.index,\n              to: match.index + match[0].length,\n              fieldId: field.id,\n              fieldName: field.name,\n            })\n          );\n        }\n      }\n\n      if (effects.length > 0) {\n        view.dispatch({ effects });\n      }\n    },\n    [fields]\n  );\n\n  const fieldDecorationsState = useMemo(\n    () =>\n      StateField.define<DecorationSet>({\n        create() {\n          return Decoration.none;\n        },\n        update(decorations, tr) {\n          decorations = decorations.map(tr.changes);\n          for (const e of tr.effects) {\n            if (e.is(addField)) {\n              decorations = decorations.update({\n                add: [\n                  Decoration.replace({\n                    widget: new FieldVariable(\n                      e.value.fieldId,\n                      e.value.fieldName,\n                      e.value.from,\n                      e.value.to,\n                      onVariableDelete\n                    ),\n                  }).range(e.value.from, e.value.to),\n                ],\n              });\n            }\n          }\n          return decorations;\n        },\n        provide: (f) => EditorView.decorations.from(f),\n      }),\n    [onVariableDelete]\n  );\n\n  // Track slash menu state in refs for use in extensions\n  const slashMenuOpenRef = useRef(slashMenuOpen);\n  const slashStartPosRef = useRef(slashStartPos);\n  const selectedIndexRef = useRef(selectedIndex);\n  const filteredFieldsRef = useRef(filteredFields);\n\n  // Keep refs in sync with state - update synchronously for immediate access\n  slashMenuOpenRef.current = slashMenuOpen;\n  slashStartPosRef.current = slashStartPos;\n\n  useEffect(() => {\n    selectedIndexRef.current = selectedIndex;\n  }, [selectedIndex]);\n\n  useEffect(() => {\n    filteredFieldsRef.current = filteredFields;\n  }, [filteredFields]);\n\n  // Find slash position before cursor\n  const findSlashPosition = useCallback((text: string, cursorPos: number): number => {\n    for (let i = cursorPos - 1; i >= 0; i--) {\n      const char = text[i];\n      if (char === '/') return i;\n      // Stop if we hit a space, newline, or field reference\n      if (char === ' ' || char === '\\n' || char === '}') break;\n    }\n    return -1;\n  }, []);\n\n  // Handle slash menu state updates\n  const handleSlashMenuUpdate = useCallback(\n    (view: EditorView, text: string, cursorPos: number) => {\n      const slashPos = findSlashPosition(text, cursorPos);\n\n      if (slashPos >= 0) {\n        // Extract search query from text after \"/\"\n        const query = text.slice(slashPos + 1, cursorPos);\n\n        if (!slashMenuOpenRef.current) {\n          const coords = getCursorCoords(view);\n          if (coords) {\n            // Update refs immediately before state changes\n            slashMenuOpenRef.current = true;\n            slashStartPosRef.current = slashPos;\n            setSlashMenuPosition(coords);\n            setSlashMenuOpen(true);\n            setSlashStartPos(slashPos);\n          }\n        }\n        // Update search query from text typed after \"/\"\n        setSearchQuery(query);\n      } else if (slashMenuOpenRef.current) {\n        slashMenuOpenRef.current = false;\n        slashStartPosRef.current = null;\n        setSlashMenuOpen(false);\n        setSearchQuery('');\n        setSlashStartPos(null);\n      }\n    },\n    [findSlashPosition, getCursorCoords]\n  );\n\n  const extensions = useMemo(() => {\n    return [\n      history(),\n      keymap.of([\n        // Handle ArrowDown to navigate slash menu\n        {\n          key: 'ArrowDown',\n          run: () => {\n            if (slashMenuOpenRef.current) {\n              const fields = filteredFieldsRef.current;\n              if (fields.length > 0) {\n                setSelectedIndex((prev) => Math.min(prev + 1, fields.length - 1));\n              }\n              return true;\n            }\n            return false;\n          },\n        },\n        // Handle ArrowUp to navigate slash menu\n        {\n          key: 'ArrowUp',\n          run: () => {\n            if (slashMenuOpenRef.current) {\n              setSelectedIndex((prev) => Math.max(prev - 1, 0));\n              return true;\n            }\n            return false;\n          },\n        },\n        // Handle Enter to select item from slash menu\n        {\n          key: 'Enter',\n          run: () => {\n            if (slashMenuOpenRef.current) {\n              const fields = filteredFieldsRef.current;\n              const index = selectedIndexRef.current;\n              if (fields.length > 0 && index >= 0 && index < fields.length) {\n                const field = fields[index];\n                if (field && !(isOptionDisabled?.(field) ?? false)) {\n                  handleSlashFieldSelectRef.current(field.id);\n                }\n              }\n              return true;\n            }\n            return false;\n          },\n        },\n        // Handle Escape to close slash menu\n        {\n          key: 'Escape',\n          run: () => {\n            if (slashMenuOpenRef.current) {\n              setSlashMenuOpen(false);\n              setSearchQuery('');\n              setSlashStartPos(null);\n              return true;\n            }\n            return false;\n          },\n        },\n        ...defaultKeymap.filter((k) => !['Backspace', 'ArrowLeft', 'ArrowRight'].includes(k.key!)),\n        ...historyKeymap,\n        ...FieldVariableNavigation.createKeymap(),\n      ]),\n      syntaxHighlighting(defaultHighlightStyle, { fallback: true }),\n      fieldDecorationsState,\n      EditorView.updateListener.of((update) => {\n        if (update.docChanged) {\n          const newValue = update.state.doc.toString();\n          lastValueRef.current = newValue;\n          onChange(newValue);\n          decorateFields(update.view);\n\n          // Handle slash menu\n          const pos = update.state.selection.main.head;\n          handleSlashMenuUpdate(update.view, newValue, pos);\n        }\n      }),\n      isLightTheme\n        ? lightTheme(resizable ? { ...themeOptions, height: '100%' } : themeOptions)\n        : darkTheme(resizable ? { ...themeOptions, height: '100%' } : themeOptions),\n      EditorView.lineWrapping,\n      EditorState.allowMultipleSelections.of(true),\n      placeholder ? cmPlaceholder(placeholder) : [],\n      EditorState.tabSize.of(2),\n    ];\n  }, [\n    fieldDecorationsState,\n    isLightTheme,\n    themeOptions,\n    placeholder,\n    onChange,\n    decorateFields,\n    handleSlashMenuUpdate,\n    isOptionDisabled,\n    resizable,\n  ]);\n\n  const createEditorView = useCallback(\n    (parent: HTMLElement) => {\n      const view = new EditorView({\n        state: EditorState.create({ doc: value, extensions }),\n        parent,\n      });\n\n      requestAnimationFrame(() => {\n        decorateFields(view);\n      });\n\n      return view;\n    },\n    [decorateFields, extensions, value]\n  );\n\n  useEffect(() => {\n    if (!editorRef.current) return;\n\n    const view = createEditorView(editorRef.current);\n    setEditorView(view);\n    actualEditorViewRef.current = view;\n    lastValueRef.current = value;\n\n    return () => view.destroy();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    actualEditorViewRef.current?.dispatch({ effects: StateEffect.reconfigure.of(extensions) });\n  }, [actualEditorViewRef, extensions]);\n\n  useEffect(() => {\n    if (!editorView || value === lastValueRef.current) return;\n\n    const currentDoc = editorView.state.doc.toString();\n    if (currentDoc !== value) {\n      const newDocLength = value.length;\n      const currentSelection = editorView.state.selection.main;\n\n      // Clamp selection to new document bounds to prevent \"Selection points outside of document\" error\n      const safeAnchor = Math.min(Math.max(0, currentSelection.anchor), newDocLength);\n      const safeHead = Math.min(Math.max(0, currentSelection.head), newDocLength);\n\n      editorView.dispatch({\n        changes: { from: 0, to: currentDoc.length, insert: value },\n        selection: { anchor: safeAnchor, head: safeHead },\n      });\n      lastValueRef.current = value;\n\n      requestAnimationFrame(() => {\n        decorateFields(editorView);\n      });\n    }\n  }, [value, editorView, decorateFields]);\n\n  const handleContainerClick = useCallback(() => {\n    editorView?.focus();\n  }, [editorView]);\n\n  const isFullHeight = themeOptions?.height === '100%';\n\n  const resizeStyles = resizable\n    ? {\n        resize: 'vertical' as const,\n        overflow: 'hidden',\n        minHeight,\n        maxHeight,\n        height: minHeight,\n      }\n    : isFullHeight\n      ? {\n          height: '100%',\n        }\n      : {\n          height: minHeight,\n        };\n\n  return (\n    <div className={cn('flex h-full min-h-0 flex-1 flex-col', className)}>\n      <div\n        ref={editorRef}\n        className={cn(\n          'cursor-text rounded-lg border shadow-sm',\n          resizable ? 'resize-y' : 'min-h-0 flex-1'\n        )}\n        style={resizeStyles}\n        onClick={handleContainerClick}\n      />\n\n      {/* Slash command menu - use fixed positioning to escape overflow:hidden */}\n      {slashMenuOpen && (\n        <div\n          ref={slashMenuRef}\n          className=\"fixed z-[9999] overflow-hidden rounded-lg border bg-popover p-1 shadow-md\"\n          style={{\n            top: slashMenuPosition.top + 4,\n            left: slashMenuPosition.left,\n            minWidth: 220,\n            maxWidth: 320,\n          }}\n        >\n          <div className=\"max-h-[200px] overflow-y-auto\">\n            {filteredFields.length === 0 ? (\n              <div className=\"py-4 text-center text-sm text-muted-foreground\">\n                {t('sdk:common.search.empty')}\n              </div>\n            ) : (\n              filteredFields.map((field, index) => {\n                const { Icon } = fieldStaticGetter(field.type, {\n                  isLookup: field.isLookup,\n                  isConditionalLookup: field.isConditionalLookup,\n                  hasAiConfig: Boolean(field.aiConfig),\n                  deniedReadRecord: !field.canReadFieldRecord,\n                });\n                const disabled = isOptionDisabled?.(field) ?? false;\n                const isSelected = index === selectedIndex;\n\n                return (\n                  <div\n                    key={field.id}\n                    ref={isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}\n                    onClick={() => {\n                      if (!disabled) {\n                        handleSlashFieldSelect(field.id);\n                      }\n                    }}\n                    onMouseEnter={() => setSelectedIndex(index)}\n                    className={cn(\n                      'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm',\n                      isSelected && 'bg-accent',\n                      disabled && 'cursor-not-allowed opacity-50'\n                    )}\n                  >\n                    <Icon className=\"size-4 shrink-0\" />\n                    <span className=\"truncate\">{field.name}</span>\n                  </div>\n                );\n              })\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/PromptEditorContainer.tsx",
    "content": "import type { EditorView } from '@codemirror/view';\nimport { Maximize2, Plus } from '@teable/icons';\nimport { FieldSelector } from '@teable/sdk/components';\nimport { useFields } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport {\n  Button,\n  cn,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState, useRef, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { PromptEditor, type EditorViewRef, type IPromptEditorProps } from './PromptEditor';\n\ninterface IPromptEditorContainerProps extends IPromptEditorProps {\n  label?: string;\n  required?: boolean;\n  getDisabledReason?: (field: IFieldInstance) => string | undefined;\n}\n\nexport const PromptEditorContainer = (props: IPromptEditorContainerProps) => {\n  const { label, className, excludedFieldId, required, isOptionDisabled, getDisabledReason } =\n    props;\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const { t } = useTranslation('common');\n  const [isDialogVisible, setDialogVisible] = useState(false);\n  const mainEditorViewRef = useRef<EditorView | null>(null) as EditorViewRef;\n  const dialogEditorViewRef = useRef<EditorView | null>(null) as EditorViewRef;\n\n  const onFieldSelect = (fieldId: string) => {\n    const formatValue = `{${fieldId}}`;\n    const view = isDialogVisible ? dialogEditorViewRef.current : mainEditorViewRef.current;\n\n    if (view) {\n      const docLength = view.state.doc.length;\n      const selection = view.state.selection.main;\n\n      // Clamp selection positions to document bounds\n      const safeFrom = Math.min(Math.max(0, selection.from), docLength);\n      const safeTo = Math.min(Math.max(0, selection.to), docLength);\n\n      view.dispatch({\n        changes: { from: safeFrom, to: safeTo, insert: formatValue },\n        selection: { anchor: safeFrom + formatValue.length },\n      });\n      view.focus();\n    }\n  };\n\n  // Allow all field types including Attachment fields to be selected\n  // Attachment fields can now be referenced in prompts for AI processing\n  const excludedFieldIds = useMemo(() => {\n    return fields.filter((field) => field.id === excludedFieldId).map((field) => field.id);\n  }, [fields, excludedFieldId]);\n\n  const fieldSelector = (\n    <FieldSelector\n      excludedIds={excludedFieldIds}\n      onSelect={onFieldSelect}\n      isOptionDisabled={isOptionDisabled}\n      getDisabledReason={getDisabledReason}\n      maxHeight={360}\n      modal\n    >\n      <Button variant=\"outline\" size=\"xs\" className=\"gap-1\">\n        <Plus className=\"size-4\" />\n        {t('noun.field')}\n      </Button>\n    </FieldSelector>\n  );\n\n  return (\n    <>\n      <div className={cn('flex flex-col overflow-hidden gap-y-2', className)}>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"text-sm\">\n            {label}\n            {required && <RequireCom />}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            {fieldSelector}\n            <Button\n              variant=\"outline\"\n              size=\"xs\"\n              onClick={() => setDialogVisible(true)}\n              className=\"px-1.5\"\n            >\n              <Maximize2 className=\"size-3\" />\n            </Button>\n          </div>\n        </div>\n        <div className=\"flex-1\">\n          <PromptEditor {...props} editorViewRef={mainEditorViewRef} resizable />\n        </div>\n      </div>\n\n      <Dialog open={isDialogVisible} onOpenChange={setDialogVisible}>\n        <DialogContent className=\"flex max-w-3xl flex-col overflow-hidden\" closeable={false}>\n          <DialogHeader className=\"flex-none flex-row items-center justify-between\">\n            <DialogTitle>{label}</DialogTitle>\n            {fieldSelector}\n          </DialogHeader>\n          <div className=\"flex h-[50vh] max-h-[80vh] min-h-[400px] flex-col overflow-hidden\">\n            <PromptEditor\n              {...props}\n              themeOptions={{ height: '100%' }}\n              editorViewRef={dialogEditorViewRef}\n            />\n          </div>\n\n          <DialogFooter>\n            <DialogClose asChild>\n              <Button size=\"sm\">{t('actions.confirm')}</Button>\n            </DialogClose>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/extensions/index.ts",
    "content": "export * from './variable';\nexport * from './theme';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/extensions/theme.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { EditorView } from '@codemirror/view';\nimport type { CSSProperties } from 'react';\nimport colors from 'tailwindcss/colors';\n\nexport interface IEditorThemeOptions {\n  height?: string;\n  content?: CSSProperties;\n}\n\nconst createEditorThemeBase = (options?: IEditorThemeOptions) => {\n  const isFullHeight = options?.height === '100%';\n\n  return {\n    '&': {\n      height: options?.height ?? '120px',\n      maxHeight: isFullHeight ? 'unset' : '320px',\n      fontSize: '14px',\n      backgroundColor: 'transparent',\n      width: '100%',\n      overflow: 'hidden',\n    },\n    '.cm-scroller': {\n      overflow: 'auto',\n      lineHeight: '1.5',\n      // When full height, scroller should fill the editor and scroll internally\n      ...(isFullHeight ? { height: '100%' } : { maxHeight: '320px' }),\n      fontFamily:\n        'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',\n    },\n    '.cm-content': {\n      wordBreak: 'break-word',\n      whiteSpace: 'pre-wrap',\n      overflowWrap: 'break-word',\n    },\n    '&.cm-focused': {\n      outline: 'none',\n    },\n  };\n};\n\nconst EDITOR_LIGHT_THEME = (options?: IEditorThemeOptions) => {\n  const base = createEditorThemeBase(options);\n  return {\n    ...base,\n    '.cm-content': {\n      ...base['.cm-content'],\n      ...(options?.content ?? { padding: '8px 4px' }),\n      caretColor: colors.black,\n    },\n    '.cm-line': {\n      position: 'relative',\n    },\n    '.cm-placeholder': {\n      position: 'absolute',\n      paddingLeft: 'unset',\n      fontSize: 'inherit',\n    },\n  };\n};\n\nconst EDITOR_DARK_THEME = (options?: IEditorThemeOptions) => {\n  const base = createEditorThemeBase(options);\n  return {\n    ...base,\n    '.cm-content': {\n      ...base['.cm-content'],\n      ...(options?.content ?? { padding: '8px 4px' }),\n      caretColor: colors.white,\n    },\n    '.cm-line': {\n      position: 'relative',\n    },\n    '.cm-placeholder': {\n      position: 'absolute',\n      paddingLeft: 'unset',\n      fontSize: 'inherit',\n    },\n  };\n};\n\nexport const lightTheme = (options?: IEditorThemeOptions) =>\n  EditorView.theme(EDITOR_LIGHT_THEME(options));\nexport const darkTheme = (options?: IEditorThemeOptions) =>\n  EditorView.theme(EDITOR_DARK_THEME(options));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/extensions/variable.ts",
    "content": "import type { EditorState } from '@codemirror/state';\nimport type { EditorView } from '@codemirror/view';\nimport { WidgetType } from '@codemirror/view';\n\nexport class FieldVariable extends WidgetType {\n  constructor(\n    readonly fieldId: string,\n    readonly fieldName: string,\n    readonly from: number,\n    readonly to: number,\n    readonly onDelete: (from: number, to: number) => void\n  ) {\n    super();\n  }\n\n  toDOM() {\n    const container = document.createElement('span');\n    container.className =\n      'inline-flex h-5 items-center gap-1 rounded bg-violet-50 px-1.5 text-xs text-violet-500 cursor-default select-none hover:bg-violet-100 mx-1';\n    container.setAttribute('data-field-id', this.fieldId);\n    container.setAttribute('data-field-range', `${this.from},${this.to}`);\n    container.style.verticalAlign = 'middle';\n\n    const textSpan = document.createElement('span');\n    textSpan.textContent = this.fieldName;\n    textSpan.className = 'max-w-[120px] truncate';\n    container.appendChild(textSpan);\n\n    const deleteButton = document.createElement('button');\n    deleteButton.type = 'button';\n    deleteButton.className =\n      'inline-flex items-center justify-center size-3 hover:bg-violet-200 rounded-sm transition-colors';\n    deleteButton.innerHTML = `\n      <svg class=\"size-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n        <path d=\"M18 6L6 18M6 6l12 12\"/>\n      </svg>\n    `;\n    deleteButton.addEventListener('click', (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      this.onDelete(this.from, this.to);\n    });\n    container.appendChild(deleteButton);\n\n    return container;\n  }\n\n  eq(other: FieldVariable) {\n    return (\n      this.fieldId === other.fieldId &&\n      this.fieldName === other.fieldName &&\n      this.from === other.from &&\n      this.to === other.to\n    );\n  }\n}\n\nexport class FieldVariableNavigation {\n  static findField(doc: EditorState['doc'], pos: number) {\n    let start = pos;\n    while (start > 0) {\n      const ch = doc.slice(start - 1, start).toString();\n      if (ch === '{') {\n        let end = start;\n        let depth = 1;\n        while (end < doc.length) {\n          const nextCh = doc.slice(end, end + 1).toString();\n          if (nextCh === '}' && --depth === 0) {\n            return { start: start - 1, end: end + 1 };\n          }\n          end++;\n        }\n      }\n      start--;\n    }\n    return null;\n  }\n\n  static createKeymap() {\n    return [\n      {\n        key: 'Backspace',\n        run: (view: EditorView) => {\n          const { from } = view.state.selection.main;\n          if (from === 0) return false;\n\n          const text = view.state.doc.toString();\n          const beforeCursor = text.slice(0, from);\n          const lastOpenBrace = beforeCursor.lastIndexOf('{');\n          const lastCloseBrace = beforeCursor.lastIndexOf('}');\n\n          if (lastOpenBrace > lastCloseBrace) {\n            return false;\n          }\n\n          const field = this.findField(view.state.doc, from);\n          if (field && field.end === from) {\n            view.dispatch({\n              changes: { from: field.start, to: field.end, insert: '' },\n              selection: { anchor: field.start },\n            });\n            view.focus();\n            return true;\n          }\n\n          return false;\n        },\n      },\n      {\n        key: 'ArrowLeft',\n        run: (view: EditorView) => {\n          const { from } = view.state.selection.main;\n          const field = this.findField(view.state.doc, from);\n          if (field && field.end === from) {\n            view.dispatch({\n              selection: { anchor: field.start },\n            });\n            return true;\n          }\n          return false;\n        },\n      },\n      {\n        key: 'ArrowRight',\n        run: (view: EditorView) => {\n          const { from } = view.state.selection.main;\n          const text = view.state.doc.toString();\n          if (text[from] === '{') {\n            let depth = 1;\n            let pos = from + 1;\n            while (pos < text.length) {\n              if (text[pos] === '}' && --depth === 0) {\n                view.dispatch({\n                  selection: { anchor: pos + 1 },\n                });\n                return true;\n              }\n              if (text[pos] === '{') depth++;\n              pos++;\n            }\n          }\n          return false;\n        },\n      },\n    ];\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/components/prompt-editor/index.ts",
    "content": "export * from './PromptEditor';\nexport * from './PromptEditorContainer';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-ai-config/index.ts",
    "content": "export * from './FieldAiConfig';\nexport * from './TextFieldAiConfig';\nexport * from './SingleSelectFieldAiConfig';\nexport * from './DateFieldAiConfig';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/AffectedFieldsList.tsx",
    "content": "import type { FieldType, ViewType } from '@teable/core';\nimport { useBaseId, useFieldStaticGetter } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@teable/ui-lib/shadcn';\nimport { Bot, Lock, Table2 } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { VIEW_ICON_MAP } from '@/features/app/blocks/view/constant';\nimport { useStaticResolver } from '@/features/app/context/StaticTextRegistryProvider';\nimport { Emoji } from '../../emoji/Emoji';\nimport type { AffectedItem, AffectedItemType, AffectedTableSource } from './types';\n\ninterface AffectedFieldsListProps {\n  items: AffectedItem[];\n  isMultiField?: boolean;\n}\n\nexport const AffectedFieldsList = ({ items, isMultiField = false }: AffectedFieldsListProps) => {\n  const { t } = useTranslation(['common', 'table']);\n  const fieldStaticGetter = useFieldStaticGetter();\n  const getWorkflowIcon = useStaticResolver('workflow', 'getWorkflowIcon') as (\n    type: string\n  ) => React.FC<React.SVGProps<SVGSVGElement>>;\n  const getWorkflowNodeTypeName = useStaticResolver('workflow', 'getNodeTypeName') as (\n    type?: string,\n    name?: string\n  ) => string;\n  const baseId = useBaseId();\n\n  const getTypeLabel = (itemType: AffectedItemType) => {\n    switch (itemType) {\n      case 'field':\n        return String(t('common:noun.field'));\n      case 'view':\n        return t('common:noun.view');\n      case 'workflow':\n        return t('common:noun.automation');\n      case 'authorityMatrix':\n        return t('common:noun.authorityMatrix');\n      default:\n        return '-';\n    }\n  };\n\n  const getItemDisplayName = (item: AffectedItem) => {\n    if (item.itemType !== 'workflow') {\n      return item.name;\n    }\n\n    return getWorkflowNodeTypeName(item.type, item.name);\n  };\n\n  const getIcon = (item: AffectedItem) => {\n    if (item.itemType === 'field' && item.type) {\n      return fieldStaticGetter(item.type as FieldType).Icon;\n    }\n    if (item.itemType === 'workflow') {\n      return getWorkflowIcon(item.type as string);\n    }\n    if (item.itemType === 'view' && item.type) {\n      return VIEW_ICON_MAP[item.type as ViewType];\n    }\n    if (item.itemType === 'authorityMatrix') {\n      return Lock;\n    }\n    return null;\n  };\n\n  const getResourceIcon = (item: AffectedItem) => {\n    if (item.itemType === 'workflow') {\n      return <Bot className=\"size-4 shrink-0 text-muted-foreground\" />;\n    }\n    if (item.itemType === 'view' || item.itemType === 'field') {\n      const source = item.source as AffectedTableSource;\n      return source?.icon ? (\n        <Emoji emoji={source.icon} size=\"0.875rem\" className=\"size-4 shrink-0\" />\n      ) : (\n        <Table2 className=\"size-4 shrink-0 text-muted-foreground\" />\n      );\n    }\n    return null;\n  };\n\n  return (\n    <div className=\"min-h-0 overflow-y-auto rounded-md border\">\n      <Table className=\"table-fixed\">\n        <TableHeader className=\"sticky top-0\">\n          <TableRow className=\"bg-muted hover:bg-muted\">\n            <TableHead className=\"h-9 truncate px-4 text-xs\">\n              {t('table:field.editor.deleteField.affectedItems')}\n            </TableHead>\n            <TableHead\n              className={cn('h-9 w-[120px] truncate px-4 text-xs', {\n                'w-48': isMultiField,\n              })}\n            >\n              {t('table:field.editor.deleteField.type')}\n            </TableHead>\n            <TableHead\n              className={cn('h-9 w-[180px] truncate px-4 text-xs', {\n                'w-48': isMultiField,\n              })}\n            >\n              {t('table:field.editor.deleteField.source')}\n            </TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {items.map((item) => {\n            const Icon = getIcon(item);\n            const displayName = getItemDisplayName(item);\n\n            return (\n              <TableRow key={`${item.itemType}-${item.id}`} className=\"hover:bg-transparent\">\n                <TableCell className=\"truncate px-4 py-2 text-foreground\">\n                  <div className=\"flex items-center gap-2\">\n                    {Icon && <Icon className=\"size-4 shrink-0\" />}\n                    <span className=\"truncate\" title={displayName}>\n                      {displayName}\n                    </span>\n                  </div>\n                </TableCell>\n                <TableCell className=\"truncate px-4 py-2 text-foreground\">\n                  {getTypeLabel(item.itemType)}\n                </TableCell>\n                <TableCell className=\"truncate px-4 py-2 text-foreground\">\n                  {item.source ? (\n                    <div className=\"flex flex-col gap-0.5\">\n                      <div className=\"flex items-center gap-2\">\n                        {getResourceIcon(item)}\n                        <span className=\"truncate\">{item.source.name}</span>\n                      </div>\n                      {baseId !== item.source.base.id && (\n                        <span className=\"w-fit max-w-full truncate rounded-sm border bg-muted px-2 text-xs text-muted-foreground\">\n                          {item.source.base.name}\n                        </span>\n                      )}\n                    </div>\n                  ) : (\n                    '-'\n                  )}\n                </TableCell>\n              </TableRow>\n            );\n          })}\n        </TableBody>\n      </Table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/FieldDeleteConfirmDialog.tsx",
    "content": "import { deleteFields } from '@teable/openapi';\nimport { useFields } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  Separator,\n} from '@teable/ui-lib/shadcn';\nimport { ShieldCheck } from 'lucide-react';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useState } from 'react';\nimport { AffectedFieldsList } from './AffectedFieldsList';\nimport { FieldSelectionList } from './FieldSelectionList';\nimport type { AffectedItem, FieldDeleteConfirmDialogProps } from './types';\nimport {\n  useFieldCheckState,\n  useFieldSelectionState,\n  useMultiFieldReferences,\n} from './useDeleteAnalysis';\n\n// Single field delete dialog content\nconst SingleFieldContent = ({\n  tableId,\n  fieldId,\n  fieldName,\n  open,\n}: {\n  tableId: string;\n  fieldId: string;\n  fieldName: string;\n  open: boolean;\n}) => {\n  const { fieldRiskMap, isLoading } = useMultiFieldReferences(tableId, [fieldId], open);\n  const affectedItems = fieldRiskMap.get(fieldId) ?? [];\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-4\">\n        <Spin />\n      </div>\n    );\n  }\n\n  if (affectedItems.length === 0) {\n    return (\n      <AlertDialogDescription>\n        <Trans\n          ns=\"table\"\n          i18nKey=\"field.editor.deleteField.simpleConfirm\"\n          components={{ b: <b /> }}\n          values={{ fieldName }}\n        />\n      </AlertDialogDescription>\n    );\n  }\n\n  return (\n    <AlertDialogDescription asChild>\n      <div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n        <p className=\"shrink-0 text-foreground\">\n          <Trans\n            ns=\"table\"\n            i18nKey=\"field.editor.deleteField.withDependencies\"\n            components={{ b: <b /> }}\n            values={{ fieldName }}\n          />\n        </p>\n        <AffectedFieldsList items={affectedItems} />\n      </div>\n    </AlertDialogDescription>\n  );\n};\n\n// Safe to delete state component\nconst SafeToDeleteState = () => {\n  const { t } = useTranslation(['table']);\n  return (\n    <div className=\"flex flex-1 flex-col items-center justify-center gap-4 text-center\">\n      <ShieldCheck className=\"size-12 text-muted-foreground\" />\n      <div className=\"flex flex-col items-center gap-2\">\n        <p className=\"text-base font-medium text-foreground\">\n          {t('table:field.editor.deleteField.safeToDelete')}\n        </p>\n        <p className=\"text-sm text-muted-foreground\">\n          {t('table:field.editor.deleteField.safeToDeleteDesc')}\n        </p>\n      </div>\n    </div>\n  );\n};\n\n// Multi field delete dialog content\nconst MultiFieldContent = ({\n  targetFields,\n  selectedFieldId,\n  checkedFieldIds,\n  fieldRiskMap,\n  isLoading,\n  onSelect,\n  onToggleCheck,\n}: {\n  targetFields: IFieldInstance[];\n  selectedFieldId: string | null;\n  checkedFieldIds: Set<string>;\n  fieldRiskMap: Map<string, AffectedItem[]>;\n  isLoading: boolean;\n  onSelect: (fieldId: string) => void;\n  onToggleCheck: (fieldId: string) => void;\n}) => {\n  const selectedField = useMemo(\n    () => targetFields.find((f) => f.id === selectedFieldId),\n    [targetFields, selectedFieldId]\n  );\n\n  const selectedItems = selectedFieldId ? fieldRiskMap.get(selectedFieldId) ?? [] : [];\n\n  return (\n    <AlertDialogDescription asChild>\n      <div className=\"flex min-h-0 flex-1 gap-4\">\n        {/* Left panel - field list */}\n        <div className=\"w-48 shrink-0 overflow-y-auto\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-4\">\n              <Spin />\n            </div>\n          ) : (\n            <FieldSelectionList\n              fields={targetFields}\n              selectedFieldId={selectedFieldId}\n              checkedFieldIds={checkedFieldIds}\n              fieldRiskMap={fieldRiskMap}\n              onSelect={onSelect}\n              onToggleCheck={onToggleCheck}\n            />\n          )}\n        </div>\n\n        <Separator orientation=\"vertical\" className=\"h-auto\" />\n\n        {/* Right panel - detail */}\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-hidden\">\n          {isLoading ? (\n            <div className=\"flex flex-1 items-center justify-center py-4\">\n              <Spin />\n            </div>\n          ) : selectedFieldId && selectedField ? (\n            <DetailPanel fieldName={selectedField.name} affectedItems={selectedItems} />\n          ) : null}\n        </div>\n      </div>\n    </AlertDialogDescription>\n  );\n};\n\n// Detail panel for multi field mode\nconst DetailPanel = ({\n  fieldName,\n  affectedItems,\n}: {\n  fieldName: string;\n  affectedItems: AffectedItem[];\n}) => {\n  if (affectedItems.length === 0) {\n    return <SafeToDeleteState />;\n  }\n\n  return (\n    <>\n      <p className=\"shrink-0 text-sm text-foreground\">\n        <Trans\n          ns=\"table\"\n          i18nKey=\"field.editor.deleteField.withDependencies\"\n          components={{ b: <b /> }}\n          values={{ fieldName }}\n        />\n      </p>\n      <AffectedFieldsList items={affectedItems} isMultiField />\n    </>\n  );\n};\n\nexport const FieldDeleteConfirmDialog = (props: FieldDeleteConfirmDialogProps) => {\n  const { tableId, fieldIds, open, onClose } = props;\n  const { t } = useTranslation(['common', 'table']);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const allFields = useFields({ withHidden: true, withDenied: true });\n\n  const targetFields = useMemo(\n    () => allFields.filter((f) => fieldIds.includes(f.id)),\n    [allFields, fieldIds]\n  );\n\n  const isMultiField = fieldIds.length > 1;\n\n  // State for multi-field mode\n  const { selectedFieldId, selectField } = useFieldSelectionState(fieldIds);\n  const { checkedFieldIds, toggleField } = useFieldCheckState(fieldIds, open);\n  const { fieldRiskMap, isLoading, isAllLoaded } = useMultiFieldReferences(tableId, fieldIds, open);\n  const [hasInitialSelected, setHasInitialSelected] = useState(false);\n\n  // Select first field in grouped order (risk fields first) after loading - only once\n  useEffect(() => {\n    if (!isAllLoaded || !isMultiField || hasInitialSelected) return;\n\n    const riskFieldId = targetFields.find((f) => {\n      const affected = fieldRiskMap.get(f.id) ?? [];\n      return affected.length > 0;\n    })?.id;\n\n    const firstFieldId = riskFieldId ?? targetFields[0]?.id;\n    if (firstFieldId) {\n      selectField(firstFieldId);\n      setHasInitialSelected(true);\n    }\n  }, [isAllLoaded, isMultiField, targetFields, fieldRiskMap, selectField, hasInitialSelected]);\n\n  // Reset initial selection flag when dialog reopens\n  useEffect(() => {\n    if (!open) {\n      setHasInitialSelected(false);\n    }\n  }, [open]);\n\n  const deleteCount = checkedFieldIds.size;\n  const canDelete = deleteCount > 0;\n\n  const close = () => {\n    setIsDeleting(false);\n    onClose?.();\n  };\n\n  const actionDelete = async () => {\n    if (isDeleting || !canDelete) return;\n    try {\n      setIsDeleting(true);\n      const idsToDelete = isMultiField ? Array.from(checkedFieldIds) : fieldIds;\n      await deleteFields(tableId, idsToDelete);\n      close();\n    } finally {\n      setIsDeleting(false);\n    }\n  };\n\n  return (\n    <AlertDialog open={open} onOpenChange={(open) => !open && close()}>\n      <AlertDialogContent\n        className={\n          isMultiField\n            ? 'flex h-[480px] max-w-5xl flex-col'\n            : 'flex max-h-[560px] max-w-xl flex-col'\n        }\n        onMouseDown={(e) => e.stopPropagation()}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <AlertDialogHeader className=\"min-h-0 flex-1 overflow-hidden\">\n          <AlertDialogTitle>{t('table:field.editor.deleteField.title')}</AlertDialogTitle>\n          {isMultiField ? (\n            <MultiFieldContent\n              targetFields={targetFields}\n              selectedFieldId={selectedFieldId}\n              checkedFieldIds={checkedFieldIds}\n              fieldRiskMap={fieldRiskMap}\n              isLoading={isLoading}\n              onSelect={selectField}\n              onToggleCheck={toggleField}\n            />\n          ) : (\n            <SingleFieldContent\n              tableId={tableId}\n              fieldId={fieldIds[0]}\n              fieldName={targetFields[0]?.name ?? ''}\n              open={open}\n            />\n          )}\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel disabled={isDeleting}>{t('common:actions.cancel')}</AlertDialogCancel>\n          <AlertDialogAction\n            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n            onClick={(e) => {\n              e.preventDefault();\n              actionDelete();\n            }}\n            disabled={isDeleting || !canDelete}\n          >\n            {isDeleting && <Spin className=\"mr-1\" />}\n            {isMultiField\n              ? t('table:field.editor.deleteField.deleteCount', { count: deleteCount })\n              : t('common:actions.delete')}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/FieldSelectionList.tsx",
    "content": "import { useFieldStaticGetter } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { Checkbox, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { AffectedItem } from './types';\n\ninterface FieldSelectionListProps {\n  fields: IFieldInstance[];\n  selectedFieldId: string | null;\n  checkedFieldIds: Set<string>;\n  fieldRiskMap: Map<string, AffectedItem[]>;\n  onSelect: (fieldId: string) => void;\n  onToggleCheck: (fieldId: string) => void;\n}\n\nexport const FieldSelectionList = ({\n  fields,\n  selectedFieldId,\n  checkedFieldIds,\n  fieldRiskMap,\n  onSelect,\n  onToggleCheck,\n}: FieldSelectionListProps) => {\n  const { t } = useTranslation(['table']);\n  const fieldStaticGetter = useFieldStaticGetter();\n\n  const { riskFields, safeFields } = useMemo(() => {\n    const risk: IFieldInstance[] = [];\n    const safe: IFieldInstance[] = [];\n    fields.forEach((field) => {\n      const affected = fieldRiskMap.get(field.id) ?? [];\n      if (affected.length > 0) {\n        risk.push(field);\n      } else {\n        safe.push(field);\n      }\n    });\n    return { riskFields: risk, safeFields: safe };\n  }, [fields, fieldRiskMap]);\n\n  const renderFieldItem = (field: IFieldInstance) => {\n    const isSelected = field.id === selectedFieldId;\n    const isChecked = checkedFieldIds.has(field.id);\n    const FieldIcon = fieldStaticGetter(field.type).Icon;\n\n    return (\n      // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions\n      <li\n        key={field.id}\n        className={cn(\n          'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm',\n          isSelected ? 'bg-accent text-foreground font-medium' : 'hover:bg-accent'\n        )}\n        onClick={() => onSelect(field.id)}\n      >\n        <Checkbox\n          checked={isChecked}\n          onCheckedChange={() => onToggleCheck(field.id)}\n          onClick={(e) => e.stopPropagation()}\n          className=\"shrink-0\"\n        />\n        {FieldIcon && <FieldIcon className=\"size-4 shrink-0\" />}\n        <span className=\"truncate\">{field.name}</span>\n      </li>\n    );\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      {riskFields.length > 0 && (\n        <div>\n          <p className=\"mb-1.5 pl-2 text-xs font-medium text-muted-foreground\">\n            {t('table:field.editor.deleteField.riskIdentified', { count: riskFields.length })}\n          </p>\n          <ul className=\"space-y-0.5\">{riskFields.map(renderFieldItem)}</ul>\n        </div>\n      )}\n      {safeFields.length > 0 && (\n        <div>\n          <p className=\"mb-1.5 pl-2 text-xs font-medium text-muted-foreground\">\n            {t('table:field.editor.deleteField.noDependencies', { count: safeFields.length })}\n          </p>\n          <ul className=\"space-y-0.5\">{safeFields.map(renderFieldItem)}</ul>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/index.ts",
    "content": "export { FieldDeleteConfirmDialog } from './FieldDeleteConfirmDialog';\nexport type { FieldDeleteConfirmDialogProps } from './types';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/types.ts",
    "content": "export interface FieldDeleteConfirmDialogProps {\n  open: boolean;\n  tableId: string;\n  fieldIds: string[];\n  onClose?: () => void;\n}\n\nexport type AffectedItemType = 'field' | 'workflow' | 'authorityMatrix' | 'view';\n\nexport interface AffectedBaseSource {\n  id: string;\n  name: string;\n  icon?: string | null;\n}\n\nexport interface AffectedTableSource {\n  id: string;\n  name: string;\n  icon?: string | null;\n  base: AffectedBaseSource;\n}\n\nexport interface AffectedWorkflowSource {\n  id: string;\n  name: string;\n  base: AffectedBaseSource;\n}\n\nexport type AffectedItemSource = AffectedTableSource | AffectedWorkflowSource;\n\nexport interface AffectedItem {\n  id: string;\n  name: string;\n  itemType: AffectedItemType;\n  type?: string;\n  source?: AffectedItemSource;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-delete-confirm-dialog/useDeleteAnalysis.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IFieldDeleteReferencesItem } from '@teable/openapi';\nimport { getFieldDeleteReferences } from '@teable/openapi';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport type { AffectedItem } from './types';\n\nexport interface FieldViewState {\n  selectedFieldId: string | null;\n  viewedFieldIds: Set<string>;\n}\n\n/**\n * Hook to manage field selection and view state for multi-field delete dialog\n */\nexport const useFieldSelectionState = (fieldIds: string[]) => {\n  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);\n  const [viewedFieldIds, setViewedFieldIds] = useState<Set<string>>(new Set());\n\n  const fieldIdsKey = fieldIds.join(',');\n  useEffect(() => {\n    setSelectedFieldId(fieldIds[0] ?? null);\n    setViewedFieldIds(new Set());\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- fieldIdsKey is derived from fieldIds\n  }, [fieldIdsKey]);\n\n  const markAsViewed = useCallback((fieldId: string) => {\n    setViewedFieldIds((prev) => {\n      if (prev.has(fieldId)) return prev;\n      const next = new Set(prev);\n      next.add(fieldId);\n      return next;\n    });\n  }, []);\n\n  const selectField = useCallback((fieldId: string) => {\n    setSelectedFieldId(fieldId);\n  }, []);\n\n  const unviewedCount = fieldIds.length - viewedFieldIds.size;\n\n  return {\n    selectedFieldId,\n    viewedFieldIds,\n    unviewedCount,\n    selectField,\n    markAsViewed,\n  };\n};\n\n/**\n * Hook to manage field check state for multi-field delete dialog\n */\nexport const useFieldCheckState = (fieldIds: string[], open: boolean) => {\n  const [checkedFieldIds, setCheckedFieldIds] = useState<Set<string>>(new Set(fieldIds));\n\n  const fieldIdsKey = fieldIds.join(',');\n  useEffect(() => {\n    if (open) {\n      setCheckedFieldIds(new Set(fieldIds));\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- fieldIdsKey is derived from fieldIds\n  }, [fieldIdsKey, open]);\n\n  const toggleField = useCallback((fieldId: string) => {\n    setCheckedFieldIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(fieldId)) {\n        next.delete(fieldId);\n      } else {\n        next.add(fieldId);\n      }\n      return next;\n    });\n  }, []);\n\n  const toggleAll = useCallback(\n    (checked: boolean) => {\n      setCheckedFieldIds(checked ? new Set(fieldIds) : new Set());\n    },\n    [fieldIds]\n  );\n\n  return {\n    checkedFieldIds,\n    toggleField,\n    toggleAll,\n  };\n};\n\nconst mapItemReferences = (refs: IFieldDeleteReferencesItem): AffectedItem[] => {\n  const items: AffectedItem[] = [];\n\n  refs.dependentFields.forEach((f) => {\n    items.push({\n      id: f.id,\n      name: f.name,\n      itemType: 'field',\n      type: f.type,\n      source: f.source,\n    });\n  });\n\n  refs.views.forEach((v) => {\n    items.push({\n      id: v.id,\n      name: v.name,\n      itemType: 'view',\n      type: v.type,\n      source: v.source,\n    });\n  });\n\n  refs.workflowNodes.forEach((node) => {\n    items.push({\n      id: node.id,\n      name: node.name ?? node.category,\n      itemType: 'workflow',\n      type: node.type,\n      source: node.source,\n    });\n  });\n\n  refs.authorityMatrixRoles.forEach((r) => {\n    items.push({ id: r.id, name: r.name, itemType: 'authorityMatrix' });\n  });\n\n  return items;\n};\n\n/**\n * Hook to fetch delete references for one or more fields in a single request.\n * Returns a per-field risk map and overall loading state.\n */\nexport const useMultiFieldReferences = (tableId: string, fieldIds: string[], enabled: boolean) => {\n  const fieldIdsKey = fieldIds.join(',');\n\n  const { data, isLoading } = useQuery({\n    queryKey: ['get-field-delete-references', tableId, fieldIdsKey],\n    queryFn: async () => {\n      const res = await getFieldDeleteReferences(tableId, fieldIds);\n      return res.data;\n    },\n    enabled: enabled && fieldIds.length > 0,\n    refetchOnWindowFocus: false,\n  });\n\n  const fieldRiskMap = useMemo(() => {\n    const map = new Map<string, AffectedItem[]>();\n    for (const fieldId of fieldIds) {\n      const refs = data?.[fieldId];\n      map.set(fieldId, refs ? mapItemReferences(refs) : []);\n    }\n    return map;\n  }, [data, fieldIds]);\n\n  return {\n    fieldRiskMap,\n    isLoading,\n    isAllLoaded: !isLoading,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/field-validation/FieldValidation.tsx",
    "content": "import type { FieldType } from '@teable/core';\nimport {\n  checkFieldUniqueValidationEnabled,\n  checkFieldNotNullValidationEnabled,\n} from '@teable/core';\nimport { Label, Switch } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { IFieldEditorRo } from '../type';\nimport { FieldOperator } from '../type';\n\ninterface IFieldValidationProps {\n  field: Partial<IFieldEditorRo>;\n  operator: FieldOperator;\n  onChange?: (partialField: Partial<IFieldEditorRo>) => void;\n}\n\nconst VALIDATION_UNIQUE = 'field-validation-unique';\nconst VALIDATION_NOT_NULL = 'field-validation-not-null';\n\nexport const FieldValidation = (props: IFieldValidationProps) => {\n  const { field, operator, onChange } = props;\n  const { isLookup, unique, notNull } = field;\n  const fieldType = field.type as FieldType;\n  const isEditField = operator === FieldOperator.Edit;\n  const isUniqueEnabled = checkFieldUniqueValidationEnabled(fieldType, isLookup);\n  const isNotNullEnabled = isEditField && checkFieldNotNullValidationEnabled(fieldType, isLookup);\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  if (!isUniqueEnabled && !isNotNullEnabled) {\n    return null;\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-2 border-t pt-4\">\n        <span className=\"neutral-content text-sm font-medium\">\n          {t('table:field.editor.fieldValidationRules')}\n        </span>\n\n        {isUniqueEnabled && (\n          <div className=\"flex h-8 items-center space-x-2\">\n            <Switch\n              id={VALIDATION_UNIQUE}\n              checked={Boolean(unique)}\n              onCheckedChange={(checked) => {\n                onChange?.({ unique: checked });\n              }}\n            />\n            <Label htmlFor={VALIDATION_UNIQUE} className=\"font-normal leading-tight\">\n              {t('table:field.editor.enableValidateFieldUnique')}\n            </Label>\n          </div>\n        )}\n\n        {isNotNullEnabled && (\n          <div className=\"flex h-8 items-center space-x-2\">\n            <Switch\n              id={VALIDATION_NOT_NULL}\n              checked={Boolean(notNull)}\n              onCheckedChange={(checked) => {\n                onChange?.({ notNull: checked });\n              }}\n            />\n            <Label htmlFor={VALIDATION_NOT_NULL} className=\"font-normal leading-tight\">\n              {t('table:field.editor.enableValidateFieldNotNull')}\n            </Label>\n          </div>\n        )}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/formatting/DatetimeFormatting.tsx",
    "content": "import type { IDatetimeFormatting } from '@teable/core';\nimport { DateFormattingPreset, TimeFormatting } from '@teable/core';\nimport { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@teable/ui-lib';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { useTranslation } from 'next-i18next';\nimport { Selector } from '@/components/Selector';\nimport { TimeZoneFormatting } from './TimeZoneFormatting';\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nexport const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n// | Locale | Date Format | Notes |\n// |--------|-------------|-------|\n// | en-US  | M/D/YYYY    | U.S. English (United States), e.g., 12/31/2023 |\n// | en-GB  | D/M/YYYY    | British English (United Kingdom, European), e.g., 31/12/2023 |\n// | fr-FR  | DD/MM/YYYY  | French (France), e.g., 31/12/2023 |\n// | de-DE  | DD.MM.YYYY  | German (Germany), e.g., 31.12.2023 |\n// | ja-JP  | YYYY/MM/DD  | Japanese (Japan), e.g., 2023/12/31 |\n// | zh-CN  | YYYY-MM-DD  | Simplified Chinese (China), e.g., 2023-12-31 |\n// | ko-KR  | YYYY.MM.DD  | Korean (South Korea), e.g., 2023.12.31 |\nexport const localFormatStrings: { [key: string]: string } = {\n  en: 'M/D/YYYY',\n  'en-GB': 'D/M/YYYY',\n  fr: 'DD/MM/YYYY',\n  de: 'DD.MM.YYYY',\n  ja: 'YYYY/MM/DD',\n  zh: 'YYYY-MM-DD',\n  ko: 'YYYY.MM.DD',\n};\n\nexport const friendlyFormatStrings: { [key: string]: string } = {\n  en: 'MMMM D, YYYY', // English\n  'en-GB': 'D MMMM YYYY', // English GB\n  zh: 'YYYY 年 M 月 D 日', // Chinese\n  fr: 'D MMM YYYY', // French\n  de: 'D. MMM YYYY', // German\n  es: 'D de MMM de YYYY', // Spanish\n  ru: 'D MMM YYYY г.', // Russian\n  ja: 'YYYY 年 M 月 D 日', // Japanese\n  ar: 'D MMMM, YYYY', // Arabic\n  pt: 'D de MMMM de YYYY', // Portuguese\n  hi: 'D MMMM, YYYY', // Hindi\n  bn: 'D MMMM, YYYY', // Bengali\n  jv: 'D MMMM YYYY', // Javanese\n  pa: 'D MMMM YYYY', // Punjabi\n  mr: 'D MMMM, YYYY', // Marathi\n  ta: 'D MMMM, YYYY', // Tamil\n};\n\nexport function getFormatStringForLanguage(language: string, preset: { [key: string]: string }) {\n  // If the full language tag is not found, fallback to the base language\n  const baseLanguage = language.split('-')[0];\n  return preset[language] || preset[baseLanguage] || preset['en']; // Default to 'en'\n}\n\nconst useSelectInfoMap = (currentDateFormatting: string) => {\n  const { t, i18n } = useTranslation(['common', 'table']);\n  const friendlyDateFormatting = getFormatStringForLanguage(i18n.language, friendlyFormatStrings);\n  const localDateFormatting = getFormatStringForLanguage(i18n.language, localFormatStrings);\n\n  const optionsWithExample = (text: string, formatting: string) => {\n    return {\n      text: `${text} (${dayjs().format(formatting)})`,\n      value: formatting,\n    };\n  };\n\n  const dateFormattingPresetOptions = [\n    optionsWithExample(t('table:field.default.date.local'), localDateFormatting),\n    optionsWithExample(t('table:field.default.date.friendly'), friendlyDateFormatting),\n    optionsWithExample(t('table:field.default.date.us'), DateFormattingPreset.US),\n    optionsWithExample(t('table:field.default.date.european'), DateFormattingPreset.European),\n    optionsWithExample(t('table:field.default.date.asia'), DateFormattingPreset.Asian),\n  ];\n  if (localDateFormatting !== DateFormattingPreset.ISO) {\n    dateFormattingPresetOptions.push(optionsWithExample('ISO', DateFormattingPreset.ISO));\n  }\n  dateFormattingPresetOptions.push(\n    optionsWithExample(t('table:field.default.date.yearMonth'), DateFormattingPreset.YM),\n    optionsWithExample(t('table:field.default.date.monthDay'), DateFormattingPreset.MD),\n    optionsWithExample(t('table:field.default.date.year'), DateFormattingPreset.Y),\n    optionsWithExample(t('table:field.default.date.month'), DateFormattingPreset.M),\n    optionsWithExample(t('table:field.default.date.day'), DateFormattingPreset.D)\n  );\n\n  // add [Custom] option if currentDateFormatting not in the list\n  if (!dateFormattingPresetOptions.find((option) => option.value === currentDateFormatting)) {\n    dateFormattingPresetOptions.push(\n      optionsWithExample(t('table:field.default.date.custom'), currentDateFormatting)\n    );\n  }\n\n  const timeFormattingPresetOptions = [\n    {\n      text: t('table:field.default.date.24Hour'),\n      value: TimeFormatting.Hour24,\n    },\n    {\n      text: t('table:field.default.date.12Hour'),\n      value: TimeFormatting.Hour12,\n    },\n    {\n      text: t('table:field.default.date.noDisplay'),\n      value: TimeFormatting.None,\n    },\n  ];\n\n  return {\n    date: {\n      label: t('table:field.default.date.dateFormatting'),\n      list: dateFormattingPresetOptions,\n    },\n    time: {\n      label: t('table:field.default.date.timeFormatting'),\n      list: timeFormattingPresetOptions,\n    },\n  };\n};\n\ninterface IProps {\n  formatting?: IDatetimeFormatting;\n  onChange?: (formatting: IDatetimeFormatting) => void;\n  className?: string;\n}\nexport const DatetimeFormatting: React.FC<IProps> = ({ formatting, onChange, className }) => {\n  const localDateFormatting = getFormatStringForLanguage(navigator.language, localFormatStrings);\n\n  formatting = {\n    date: formatting?.date || localDateFormatting,\n    time: formatting?.time || TimeFormatting.None,\n    timeZone: formatting?.timeZone || systemTimeZone,\n  };\n\n  const { date, time } = useSelectInfoMap(formatting.date);\n\n  const onFormattingChange = (value: string, typeKey: string) => {\n    onChange?.({\n      ...formatting,\n      [typeKey]: value,\n    } as IDatetimeFormatting);\n  };\n\n  return (\n    <div className={cn('w-full space-y-4 border-t pt-4', className)}>\n      <div className=\"space-y-2\">\n        <Label className=\"text-sm font-medium\">{date.label}</Label>\n        <Selector\n          className=\"w-full\"\n          candidates={date.list.map((item) => ({ id: item.value, name: item.text }))}\n          selectedId={formatting.date}\n          onChange={(value) => onFormattingChange(value, 'date')}\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label className=\"text-sm font-medium\">{time.label}</Label>\n        <Select\n          value={formatting.time}\n          onValueChange={(value) => onFormattingChange(value, 'time')}\n        >\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {time.list.map(({ value, text }) => (\n              <SelectItem key={value} value={value}>\n                {text}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n      <TimeZoneFormatting\n        timeZone={formatting.timeZone}\n        onChange={(value) => onFormattingChange(value, 'timeZone')}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/formatting/NumberFormatting.tsx",
    "content": "import type { ICurrencyFormatting, INumberFormatting } from '@teable/core';\nimport { NumberFormattingType, defaultNumberFormatting } from '@teable/core';\nimport { Input } from '@teable/ui-lib/shadcn';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@teable/ui-lib/shadcn/ui/select';\nimport { useTranslation } from 'next-i18next';\n\ninterface IProps {\n  formatting?: INumberFormatting;\n  onChange?: (formatting: INumberFormatting) => void;\n}\n\nexport const NumberFormatting: React.FC<IProps> = (props) => {\n  const { formatting = defaultNumberFormatting, onChange } = props;\n  const { type, precision } = formatting;\n  const { t } = useTranslation(['table']);\n\n  const onFormattingTypeChange = (type: NumberFormattingType) => {\n    const { symbol: _symbol, ...rest } = formatting as ICurrencyFormatting;\n    if (type === NumberFormattingType.Currency) {\n      onChange?.({ ...rest, type, symbol: _symbol ?? t('field.default.number.defaultSymbol') });\n    } else {\n      onChange?.({ ...rest, type } as INumberFormatting);\n    }\n  };\n\n  const onPrecisionChange = (value: string) => {\n    const precision = Number(value);\n    onChange?.({\n      ...formatting,\n      precision: Number.isNaN(precision) ? defaultNumberFormatting.precision : precision,\n    });\n  };\n\n  const onSymbolChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const symbol = e.target.value;\n    onChange?.({\n      ...formatting,\n      symbol,\n    } as ICurrencyFormatting);\n  };\n\n  const NUMBER_FORMATTING_TYPE = [\n    {\n      text: t('field.default.number.decimalExample'),\n      value: NumberFormattingType.Decimal,\n    },\n    {\n      text: t('field.default.number.currencyExample'),\n      value: NumberFormattingType.Currency,\n    },\n    {\n      text: t('field.default.number.percentExample'),\n      value: NumberFormattingType.Percent,\n    },\n  ];\n\n  const NUMBER_FIELD_PRECISION = [\n    {\n      text: '1',\n      value: 0,\n    },\n    {\n      text: '1.0',\n      value: 1,\n    },\n    {\n      text: '1.00',\n      value: 2,\n    },\n    {\n      text: '1.000',\n      value: 3,\n    },\n    {\n      text: '1.0000',\n      value: 4,\n    },\n  ];\n\n  return (\n    <div className=\"border-bordr flex w-full flex-col gap-4 border-t pt-4\">\n      <div className=\"flex w-full flex-col gap-2\">\n        <Label className=\"text-sm font-medium\">{t('field.default.number.formatType')}</Label>\n        <Select value={type} onValueChange={onFormattingTypeChange}>\n          <SelectTrigger size=\"lg\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {NUMBER_FORMATTING_TYPE.map(({ text, value }) => (\n              <SelectItem key={value} value={value}>\n                {text}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n      <>\n        {type === NumberFormattingType.Currency && (\n          <div className=\"flex w-full flex-col gap-2\">\n            <Label className=\"text-sm font-medium\">\n              {t('field.default.number.currencySymbol')}\n            </Label>\n            <Input\n              placeholder={t('field.default.number.currencySymbol')}\n              size=\"lg\"\n              value={formatting.symbol}\n              onChange={onSymbolChange}\n            />\n          </div>\n        )}\n      </>\n      <div className=\"flex w-full flex-col gap-2\">\n        <Label className=\"font-medium \">{t('field.default.number.precision')}</Label>\n        <Select value={precision.toString()} onValueChange={onPrecisionChange}>\n          <SelectTrigger size=\"lg\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {NUMBER_FIELD_PRECISION.map(({ text, value }) => (\n              <SelectItem key={value} value={value.toString()}>\n                {text}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/formatting/TimeZoneFormatting.tsx",
    "content": "import { TIME_ZONE_LIST } from '@teable/core';\nimport { Selector } from '@teable/ui-lib/base';\nimport { Label } from '@teable/ui-lib/shadcn';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nfunction getUTCOffset(timeZone: string): string {\n  const offsetMinutes = dayjs().tz(timeZone).utcOffset();\n\n  const offsetHours = offsetMinutes / 60;\n\n  return offsetHours >= 0 ? `UTC+${offsetHours}` : `UTC${offsetHours}`;\n}\n\nconst systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\nexport const TimeZoneFormatting = ({\n  timeZone = systemTimeZone,\n  onChange,\n}: {\n  timeZone?: string;\n  onChange: (timeZone: string) => void;\n}) => {\n  const { t } = useTranslation(['common', 'table']);\n\n  const timeZoneList = useMemo(\n    () =>\n      TIME_ZONE_LIST.map((item) => ({\n        text: `${item} (${systemTimeZone === item ? t('common:settings.setting.system') : getUTCOffset(item)})`,\n        value: item,\n      })),\n    [t]\n  );\n\n  return (\n    <div className=\"space-y-2\">\n      <Label className=\"text-sm font-medium\">{t('table:field.default.date.timeZone')}</Label>\n      <Selector\n        className=\"w-full\"\n        contentClassName=\"w-[333px]\"\n        candidates={timeZoneList.map((item) => ({ id: item.value, name: item.text }))}\n        selectedId={timeZone}\n        onChange={(value) => onChange(value)}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/formatting/UnionFormatting.tsx",
    "content": "import type { IUnionFormatting } from '@teable/core';\nimport { CellValueType } from '@teable/core';\nimport { useMemo } from 'react';\nimport { DatetimeFormatting } from './DatetimeFormatting';\nimport { NumberFormatting } from './NumberFormatting';\n\nexport const UnionFormatting = (props: {\n  cellValueType: string;\n  formatting?: IUnionFormatting;\n  onChange?: (formatting: IUnionFormatting) => void;\n}) => {\n  const { cellValueType, formatting, onChange } = props;\n\n  const FormattingComponent = useMemo(\n    function getFormattingComponent() {\n      switch (cellValueType) {\n        case CellValueType.DateTime:\n          return DatetimeFormatting;\n        case CellValueType.Number:\n          return NumberFormatting;\n        default:\n          return null;\n      }\n    },\n    [cellValueType]\n  );\n  if (!FormattingComponent) {\n    return <></>;\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  return <FormattingComponent formatting={formatting as any} onChange={onChange} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/hooks/useDefaultFieldName.ts",
    "content": "import type {\n  IFieldRo,\n  ILinkFieldOptionsRo,\n  ILookupOptionsRo,\n  IConditionalRollupFieldOptions,\n  IConditionalLookupOptions,\n  ILookupLinkOptions,\n} from '@teable/core';\nimport { FieldType, isConditionalLookupOptions } from '@teable/core';\nimport { getField } from '@teable/openapi';\nimport { useFields, useTables } from '@teable/sdk/hooks';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\n\nexport const useDefaultFieldName = () => {\n  const { t } = useTranslation('table');\n  const tables = useTables();\n  const fields = useFields();\n\n  const getLookupName = useCallback(\n    async (fieldRo: IFieldRo) => {\n      const { foreignTableId, lookupFieldId, linkFieldId } =\n        fieldRo.lookupOptions as ILookupLinkOptions;\n\n      const lookupField = (await getField(foreignTableId, lookupFieldId)).data;\n      const linkField = fields.find((field) => field.id === linkFieldId);\n      if (!lookupField || !linkField) {\n        return;\n      }\n      return {\n        lookupFieldName: lookupField.name,\n        linkFieldName: linkField.name,\n      };\n    },\n    [fields]\n  );\n\n  const getConditionalRollupName = useCallback(\n    async (fieldRo: IFieldRo) => {\n      const { foreignTableId, lookupFieldId } = fieldRo.options as IConditionalRollupFieldOptions;\n      if (!foreignTableId || !lookupFieldId) {\n        return;\n      }\n      const lookupField = (await getField(foreignTableId, lookupFieldId)).data;\n      if (!lookupField) {\n        return;\n      }\n      const foreignTable = tables.find((table) => table.id === foreignTableId);\n      return {\n        lookupFieldName: lookupField.name,\n        tableName: foreignTable?.name ?? '',\n      };\n    },\n    [tables]\n  );\n\n  const getConditionalLookupName = useCallback(\n    async (fieldRo: IFieldRo) => {\n      const lookupOptions = fieldRo.lookupOptions as ILookupOptionsRo | undefined;\n      const conditionalOptions = isConditionalLookupOptions(lookupOptions)\n        ? (lookupOptions as IConditionalLookupOptions)\n        : undefined;\n      const foreignTableId = conditionalOptions?.foreignTableId;\n      const lookupFieldId = conditionalOptions?.lookupFieldId;\n      if (!foreignTableId || !lookupFieldId) {\n        return;\n      }\n      const lookupField = (await getField(foreignTableId, lookupFieldId)).data;\n      if (!lookupField) {\n        return;\n      }\n      const foreignTable = tables.find((table) => table.id === foreignTableId);\n      return {\n        lookupFieldName: lookupField.name,\n        tableName: foreignTable?.name ?? '',\n      };\n    },\n    [tables]\n  );\n\n  return useCallback(\n    async (fieldRo: IFieldRo) => {\n      const fieldType = fieldRo.type;\n      if (fieldRo.isLookup) {\n        if (fieldRo.isConditionalLookup) {\n          const info = await getConditionalLookupName(fieldRo);\n          if (!info) {\n            return;\n          }\n          return t('field.default.conditionalLookup.title', info);\n        }\n\n        const lookupName = await getLookupName(fieldRo);\n        if (!lookupName) {\n          return;\n        }\n        return t('field.default.lookup.title', lookupName);\n      }\n\n      switch (fieldType) {\n        case FieldType.SingleLineText:\n          return t('field.default.singleLineText.title');\n        case FieldType.LongText:\n          return t('field.default.longText.title');\n        case FieldType.Number:\n          return t('field.default.number.title');\n        case FieldType.SingleSelect:\n          return t('field.default.singleSelect.title');\n        case FieldType.MultipleSelect:\n          return t('field.default.multipleSelect.title');\n        case FieldType.Attachment:\n          return t('field.default.attachment.title');\n        case FieldType.User:\n          return t('field.default.user.title');\n        case FieldType.Date:\n          return t('field.default.date.title');\n        case FieldType.AutoNumber:\n          return t('field.default.autoNumber.title');\n        case FieldType.CreatedTime:\n          return t('field.default.createdTime.title');\n        case FieldType.LastModifiedTime:\n          return t('field.default.lastModifiedTime.title');\n        case FieldType.CreatedBy:\n          return t('field.default.createdBy.title');\n        case FieldType.LastModifiedBy:\n          return t('field.default.lastModifiedBy.title');\n        case FieldType.Rating:\n          return t('field.default.rating.title');\n        case FieldType.Checkbox:\n          return t('field.default.checkbox.title');\n        case FieldType.Button:\n          return t('field.default.button.title');\n        case FieldType.Formula:\n          return t('field.default.formula.formula');\n        case FieldType.Link: {\n          const foreignTable = tables.find(\n            (table) => table.id === (fieldRo.options as ILinkFieldOptionsRo).foreignTableId\n          );\n          if (!foreignTable) {\n            return;\n          }\n          return foreignTable.name;\n        }\n        case FieldType.Rollup: {\n          const lookupName = await getLookupName(fieldRo);\n          if (!lookupName) {\n            return;\n          }\n          return t('field.default.rollup.title', lookupName);\n        }\n        case FieldType.ConditionalRollup: {\n          const info = await getConditionalRollupName(fieldRo);\n          if (!info) {\n            return;\n          }\n          return t('field.default.conditionalRollup.title', info);\n        }\n        default:\n          return;\n      }\n    },\n    [getLookupName, getConditionalRollupName, getConditionalLookupName, t, tables]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/hooks/useUpdateConditionalLookupOptions.ts",
    "content": "import type { IConditionalLookupOptions } from '@teable/core';\nimport { isConditionalLookupOptions, safeParseOptions } from '@teable/core';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { useCallback } from 'react';\nimport type { IFieldEditorRo } from '../type';\n\nexport function useUpdateConditionalLookupOptions(\n  field: IFieldEditorRo,\n  setFieldFn: (field: IFieldEditorRo) => void\n) {\n  return useCallback(\n    (partial: Partial<IConditionalLookupOptions>, lookupField?: IFieldInstance) => {\n      const existing = isConditionalLookupOptions(field.lookupOptions)\n        ? field.lookupOptions\n        : undefined;\n\n      const nextLookupOptions: IConditionalLookupOptions = {\n        ...existing,\n        ...(partial || {}),\n      } as IConditionalLookupOptions;\n\n      const nextField: IFieldEditorRo = {\n        ...field,\n        lookupOptions: nextLookupOptions,\n        // Conditional lookups always return multiple values (filtered set of records).\n        isMultipleCellValue: true,\n      };\n\n      if (lookupField) {\n        nextField.type = lookupField.type;\n        nextField.cellValueType = lookupField.cellValueType;\n\n        const optionsResult = safeParseOptions(lookupField.type, lookupField.options);\n        if (optionsResult.success) {\n          nextField.options = optionsResult.data;\n        }\n      }\n\n      setFieldFn(nextField);\n    },\n    [field, setFieldFn]\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/hooks/useUpdateLookupOptions.spec.ts",
    "content": "import { CellValueType, FieldType, Relationship } from '@teable/core';\nimport type { IFieldInstance, LinkField } from '@teable/sdk/model';\nimport { renderHook, act } from '@testing-library/react';\nimport type { IFieldEditorRo } from '../type';\nimport { useUpdateLookupOptions } from './useUpdateLookupOptions';\n\ndescribe('useUpdateLookupOptions', () => {\n  it('should update lookup options', () => {\n    const field = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n      },\n    } as IFieldEditorRo;\n    const setField = vi.fn();\n\n    const { result } = renderHook(() => useUpdateLookupOptions(field, setField));\n\n    act(() => {\n      result.current({\n        linkFieldId: 'linkFieldId',\n      });\n    });\n\n    expect(setField).toHaveBeenCalledWith({\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n      },\n    });\n  });\n\n  it('should update lookup options with field type change', () => {\n    const field = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n      },\n    } as IFieldEditorRo;\n    const setField = vi.fn();\n\n    const { result } = renderHook(() => useUpdateLookupOptions(field, setField));\n\n    act(() => {\n      result.current(\n        {\n          lookupFieldId: 'lookupFieldId',\n        },\n        {\n          type: FieldType.Link,\n          cellValueType: CellValueType.String,\n        } as LinkField,\n        {\n          type: FieldType.Number,\n          cellValueType: CellValueType.Number,\n        } as IFieldInstance\n      );\n    });\n\n    expect(setField).toHaveBeenCalledWith({\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n        lookupFieldId: 'lookupFieldId',\n      },\n      cellValueType: CellValueType.Number,\n    });\n  });\n\n  it('should update lookup options with isMultipleCellValue lookupField', () => {\n    const field = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n      },\n    } as IFieldEditorRo;\n    const setField = vi.fn();\n\n    const { result } = renderHook(() => useUpdateLookupOptions(field, setField));\n\n    act(() => {\n      result.current(\n        {\n          lookupFieldId: 'lookupFieldId',\n        },\n        {\n          type: FieldType.Link,\n          cellValueType: CellValueType.String,\n        } as LinkField,\n        {\n          isLookup: true,\n          type: FieldType.Number,\n          cellValueType: CellValueType.Number,\n          isMultipleCellValue: true,\n        } as IFieldInstance\n      );\n    });\n\n    expect(setField).toHaveBeenCalledWith({\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n        lookupFieldId: 'lookupFieldId',\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: true,\n    });\n  });\n\n  it('should update lookup options with isMultipleCellValue linkField', () => {\n    const field = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n      },\n    } as IFieldEditorRo;\n    const setField = vi.fn();\n\n    const { result } = renderHook(() => useUpdateLookupOptions(field, setField));\n\n    act(() => {\n      result.current(\n        {\n          lookupFieldId: 'lookupFieldId',\n        },\n        {\n          type: FieldType.Link,\n          cellValueType: CellValueType.String,\n          isMultipleCellValue: true,\n        } as LinkField,\n        {\n          type: FieldType.Number,\n          cellValueType: CellValueType.Number,\n        } as IFieldInstance\n      );\n    });\n\n    expect(setField).toHaveBeenCalledWith({\n      type: FieldType.Number,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n        lookupFieldId: 'lookupFieldId',\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: true,\n    });\n  });\n\n  it('keeps rollup options when linking to a lookup field', () => {\n    const field = {\n      type: FieldType.Rollup,\n      options: {\n        expression: 'sum({values})',\n        timeZone: 'UTC',\n      },\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n      },\n    } as IFieldEditorRo;\n    const setField = vi.fn();\n\n    const { result } = renderHook(() => useUpdateLookupOptions(field, setField));\n\n    act(() => {\n      result.current(\n        {\n          lookupFieldId: 'lookupFieldId',\n        },\n        {\n          type: FieldType.Link,\n          isMultipleCellValue: true,\n        } as LinkField,\n        {\n          id: 'lookupFieldId',\n          type: FieldType.Link,\n          cellValueType: CellValueType.String,\n          isMultipleCellValue: true,\n          options: {\n            relationship: Relationship.ManyOne,\n            foreignTableId: 'foreignTableId',\n            lookupFieldId: 'displayFieldId',\n            fkHostTableName: 'host_table',\n            selfKeyName: 'selfKey',\n            foreignKeyName: 'foreignKey',\n          },\n        } as unknown as IFieldInstance\n      );\n    });\n\n    expect(setField).toHaveBeenCalledWith({\n      type: FieldType.Rollup,\n      options: field.options,\n      lookupOptions: {\n        foreignTableId: 'foreignTableId',\n        linkFieldId: 'linkFieldId',\n        lookupFieldId: 'lookupFieldId',\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: true,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/hooks/useUpdateLookupOptions.ts",
    "content": "import { FieldType, type ILookupOptionsRo } from '@teable/core';\nimport { safeParseOptions } from '@teable/core';\nimport type { LinkField, IFieldInstance } from '@teable/sdk/model';\nimport { useCallback } from 'react';\nimport type { IFieldEditorRo } from '../type';\n\nexport function useUpdateLookupOptions(\n  field: IFieldEditorRo,\n  setFieldFn: (field: IFieldEditorRo) => void\n) {\n  return useCallback(\n    (\n      lookupOptions: Partial<ILookupOptionsRo>,\n      linkField?: LinkField,\n      lookupField?: IFieldInstance\n    ) => {\n      const newLookupOptions = {\n        ...field.lookupOptions,\n        ...(lookupOptions || {}),\n      } as ILookupOptionsRo;\n      if (!field.isLookup && field.type !== FieldType.Rollup) {\n        setFieldFn({\n          ...field,\n          lookupOptions: newLookupOptions,\n        });\n        return;\n      }\n\n      const shouldPreserveRollupOptions = field.type === FieldType.Rollup && !field.isLookup;\n      const optionsResult =\n        lookupField?.type && safeParseOptions(lookupField.type, lookupField.options);\n      const options = shouldPreserveRollupOptions\n        ? field.options\n        : optionsResult?.success\n          ? optionsResult.data\n          : field.options;\n      const newField: IFieldEditorRo = lookupField\n        ? {\n            ...field,\n            options,\n            lookupOptions: newLookupOptions,\n            type: field.isLookup ? lookupField.type : field.type,\n            cellValueType: lookupField.cellValueType,\n            isMultipleCellValue: linkField?.isMultipleCellValue || lookupField.isMultipleCellValue,\n          }\n        : {\n            ...field,\n            options,\n            lookupOptions: newLookupOptions,\n          };\n\n      setFieldFn(newField);\n    },\n    [field, setFieldFn]\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/index.ts",
    "content": "export * from './FieldSetting';\nexport * from './type';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/lookup-options/LookupFilterOptions.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IFilter } from '@teable/core';\nimport { Maximize2 } from '@teable/icons';\nimport { getFields } from '@teable/openapi';\nimport { FilterWithTable, useFieldFilterLinkContext } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTableId } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { createFieldInstance } from '@teable/sdk/model';\nimport { Button, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface ILookupFilterOptionsProps {\n  fieldId?: string;\n  filter?: IFilter | null;\n  foreignTableId: string;\n  contextTableId?: string;\n  onChange?: (filter: IFilter | null) => void;\n  enableFieldReference?: boolean;\n  required?: boolean;\n}\n\nexport const LookupFilterOptions = (props: ILookupFilterOptionsProps) => {\n  const {\n    fieldId,\n    foreignTableId,\n    filter,\n    onChange,\n    contextTableId,\n    enableFieldReference,\n    required,\n  } = props;\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const currentTableId = useTableId() as string;\n  const tableIdForContext = contextTableId ?? currentTableId;\n\n  const context = useFieldFilterLinkContext(tableIdForContext, fieldId, !fieldId);\n\n  const { data: totalFields = [] } = useQuery({\n    queryKey: ReactQueryKeys.fieldList(foreignTableId),\n    queryFn: () => getFields(foreignTableId).then((res) => res.data),\n    enabled: !!foreignTableId,\n  });\n\n  const { data: selfFieldVos = [] } = useQuery({\n    queryKey: ReactQueryKeys.fieldList(tableIdForContext),\n    queryFn: () => getFields(tableIdForContext!).then((res) => res.data),\n    enabled: !!tableIdForContext,\n  });\n\n  const foreignFieldInstances = useMemo(\n    () => totalFields.map((field) => createFieldInstance(field) as IFieldInstance),\n    [totalFields]\n  );\n\n  const selfFieldInstances = useMemo(\n    () => selfFieldVos.map((field) => createFieldInstance(field) as IFieldInstance),\n    [selfFieldVos]\n  );\n\n  const referenceSource = useMemo(() => {\n    if (!enableFieldReference) {\n      return undefined;\n    }\n    return { fields: selfFieldInstances, tableId: tableIdForContext };\n  }, [enableFieldReference, selfFieldInstances, tableIdForContext]);\n\n  if (!foreignTableId || !foreignFieldInstances.length) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2 rounded-md border px-2 py-3\">\n      <div className=\"flex flex-col gap-1\">\n        <div className=\"flex items-center justify-between\">\n          <span className=\"flex items-center gap-0.5\">\n            {t('table:field.editor.filter')}\n            {required ? <RequireCom /> : null}\n          </span>\n          <Dialog>\n            <DialogTrigger asChild>\n              <Button size={'icon-xs'} variant={'ghost'}>\n                <Maximize2 className=\"size-4 shrink-0\" />\n              </Button>\n            </DialogTrigger>\n            <DialogContent className=\"min-w-96 max-w-fit\">\n              <FilterWithTable\n                fields={foreignFieldInstances}\n                value={filter ?? null}\n                context={context}\n                referenceSource={referenceSource}\n                onChange={(value) => onChange?.(value)}\n              />\n            </DialogContent>\n          </Dialog>\n        </div>\n\n        <FilterWithTable\n          fields={foreignFieldInstances}\n          value={filter ?? null}\n          context={context}\n          referenceSource={referenceSource}\n          onChange={(value) => onChange?.(value)}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/lookup-options/LookupOptions.tsx",
    "content": "import type { ILookupLinkOptionsVo, ILookupOptionsRo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { ChevronDown } from '@teable/icons';\nimport { StandaloneViewProvider } from '@teable/sdk/context';\nimport { useFields, useTable, useFieldStaticGetter, useBaseId, useTables } from '@teable/sdk/hooks';\nimport type { IFieldInstance, LinkField } from '@teable/sdk/model';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { LookupFilterOptions } from './LookupFilterOptions';\n\nexport const SelectFieldByTableId: React.FC<{\n  selectedId?: string;\n  onChange: (lookupField: IFieldInstance) => void;\n}> = ({ selectedId, onChange }) => {\n  const defaultFields = useFields({ withHidden: true, withDenied: true });\n  const fields = defaultFields.filter((f) => f.type !== FieldType.Button);\n  const getFieldStatic = useFieldStaticGetter();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  return (\n    <Selector\n      className=\"w-full\"\n      placeholder={t('table:field.editor.selectField')}\n      selectedId={selectedId}\n      onChange={(id) => {\n        onChange(fields.find((f) => f.id === id) as IFieldInstance);\n      }}\n      candidates={fields.map((f) => {\n        const Icon = getFieldStatic(f.type, {\n          isLookup: f.isLookup,\n          isConditionalLookup: f.isConditionalLookup,\n          hasAiConfig: Boolean(f.aiConfig),\n        }).Icon;\n        return {\n          id: f.id,\n          name: f.name,\n          icon: <Icon className=\"size-4 shrink-0\" />,\n        };\n      })}\n    />\n  );\n};\n\nexport const LookupOptions = (props: {\n  options: Partial<ILookupLinkOptionsVo> | undefined;\n  fieldId?: string;\n  requireFilter?: boolean;\n  onChange?: (\n    options: Partial<ILookupLinkOptionsVo>,\n    linkField?: LinkField,\n    lookupField?: IFieldInstance\n  ) => void;\n}) => {\n  const { fieldId, options = {}, onChange, requireFilter = false } = props;\n  const table = useTable();\n  const tables = useTables();\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [innerOptions, setInnerOptions] = useState<Partial<ILookupLinkOptionsVo>>({\n    foreignTableId: options.foreignTableId,\n    linkFieldId: options.linkFieldId,\n    lookupFieldId: options.lookupFieldId,\n  });\n  const baseId = useBaseId();\n\n  useEffect(() => {\n    setInnerOptions((prev) => ({\n      ...prev,\n      foreignTableId: options.foreignTableId,\n      linkFieldId: options.linkFieldId,\n      lookupFieldId: options.lookupFieldId,\n    }));\n  }, [options.foreignTableId, options.linkFieldId, options.lookupFieldId]);\n\n  const [moreVisible, setMoreVisible] = useState<boolean>(\n    requireFilter || Boolean(options?.filter)\n  );\n\n  useEffect(() => {\n    if (requireFilter) {\n      setMoreVisible(true);\n    }\n  }, [requireFilter]);\n\n  const setOptions = useCallback(\n    (options: Partial<ILookupOptionsRo>, linkField?: LinkField, lookupField?: IFieldInstance) => {\n      onChange?.({ ...innerOptions, ...options }, linkField, lookupField);\n      setInnerOptions({ ...innerOptions, ...options });\n    },\n    [innerOptions, onChange]\n  );\n\n  const linkFields = useMemo(\n    () => fields.filter((f) => f.type === FieldType.Link && !f.isLookup) as LinkField[],\n    [fields]\n  );\n  const existLinkField = linkFields.length > 0;\n  const foreignTable = innerOptions.foreignTableId\n    ? tables.find((t) => t.id === innerOptions.foreignTableId)\n    : undefined;\n\n  return (\n    <div className=\"w-full space-y-4 border-t pt-4\" data-testid=\"lookup-options\">\n      {existLinkField ? (\n        <>\n          <div className=\"space-y-2\">\n            <span className=\"neutral-content text-sm font-medium\">\n              {t('table:field.editor.linkFieldToLookup')}\n              <RequireCom />\n            </span>\n            <Selector\n              className=\"w-full\"\n              placeholder={t('table:field.editor.selectField')}\n              selectedId={innerOptions.linkFieldId}\n              onChange={(selected: string) => {\n                const selectedLinkField = linkFields.find((l) => l.id === selected);\n                setOptions({\n                  linkFieldId: selected,\n                  foreignTableId: selectedLinkField?.options.foreignTableId,\n                });\n              }}\n              candidates={linkFields}\n            />\n          </div>\n          {innerOptions.foreignTableId && (\n            <>\n              <StandaloneViewProvider baseId={baseId} tableId={innerOptions.foreignTableId}>\n                <div className=\"space-y-2\">\n                  <span className=\"neutral-content mb-2 text-sm font-medium\">\n                    <Trans\n                      ns=\"table\"\n                      i18nKey=\"field.editor.lookupToTable\"\n                      values={{\n                        tableName: foreignTable?.name,\n                      }}\n                      components={{ bold: <span className=\"font-semibold\" /> }}\n                    />\n                    <RequireCom />\n                  </span>\n                  <SelectFieldByTableId\n                    selectedId={innerOptions.lookupFieldId}\n                    onChange={(lookupField: IFieldInstance) => {\n                      const linkField = linkFields.find(\n                        (l) => l.id === innerOptions.linkFieldId\n                      ) as LinkField;\n                      setOptions?.({ lookupFieldId: lookupField.id }, linkField, lookupField);\n                    }}\n                  />\n                </div>\n              </StandaloneViewProvider>\n              <>\n                <div className=\"flex justify-start\">\n                  <Button\n                    size=\"xs\"\n                    variant=\"outline\"\n                    className=\"\"\n                    onClick={() => setMoreVisible(!moreVisible)}\n                  >\n                    {t('table:field.editor.moreOptions')}\n                    <ChevronDown className=\"size-3 \" />\n                  </Button>\n                </div>\n                {(requireFilter || moreVisible) && (\n                  <LookupFilterOptions\n                    fieldId={fieldId}\n                    foreignTableId={innerOptions.foreignTableId}\n                    filter={options.filter}\n                    enableFieldReference={requireFilter}\n                    contextTableId={table?.id}\n                    required={requireFilter}\n                    onChange={(filter) => {\n                      setOptions?.({ filter });\n                    }}\n                  />\n                )}\n              </>\n            </>\n          )}\n        </>\n      ) : (\n        <div className=\"space-y-2\">\n          <span className=\"neutral-content label-text mb-2\">\n            {t('table:field.editor.noLinkTip')}\n          </span>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/ButtonOptions.tsx",
    "content": "import { Colors, ColorUtils, FieldType } from '@teable/core';\nimport type { IButtonFieldOptions } from '@teable/core';\nimport { Plus } from '@teable/icons';\nimport { FieldSelector } from '@teable/sdk/components';\nimport { useFields } from '@teable/sdk/hooks';\nimport {\n  Button,\n  Input,\n  Label,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { PencilIcon, PlusIcon } from 'lucide-react';\nimport { useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useWorkFlowPanelStore } from '@/features/app/automation/workflow-panel/useWorkFlowPaneStore';\nimport { useBaseUsage } from '@/features/app/hooks/useBaseUsage';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { PromptEditor, type EditorViewRef } from '../field-ai-config/components/prompt-editor';\nimport { ColorPicker } from './SelectOptions';\n\nconst UnavailableInPlanTips = (props: { children: React.ReactNode }) => {\n  const { children } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>{children}</TooltipTrigger>\n        <TooltipContent>\n          <p className=\"max-w-[320px]\">{t('billing.unavailableInPlanTips')}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nconst ConfirmEditor = (props: {\n  options?: Partial<IButtonFieldOptions>;\n  onChange?: (options: Partial<IButtonFieldOptions>) => void;\n}) => {\n  const { options, onChange } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const titleEditorViewRef = useRef(null) as EditorViewRef;\n  const descEditorViewRef = useRef(null) as EditorViewRef;\n  const confirmTextEditorViewRef = useRef(null) as EditorViewRef;\n  const confirmEnabled = Boolean(options?.confirm);\n  const confirm = options?.confirm;\n\n  const excludedFieldIds = useMemo(() => {\n    return fields.filter((field) => field.type === FieldType.Attachment).map((field) => field.id);\n  }, [fields]);\n\n  const onFieldSelect = (fieldId: string, editorViewRef: EditorViewRef) => {\n    const formatValue = `{${fieldId}}`;\n    const view = editorViewRef.current;\n\n    if (view) {\n      const { from, to } = view.state.selection.main;\n      view.dispatch({\n        changes: { from, to, insert: formatValue },\n        selection: { anchor: from + formatValue.length },\n      });\n      view.focus();\n    }\n  };\n\n  const updateConfirm = (key: keyof NonNullable<typeof confirm>, value: string) => {\n    onChange?.({\n      ...options,\n      confirm: {\n        ...confirm,\n        [key]: value,\n      },\n    });\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex h-8 items-center gap-2\">\n        <Switch\n          checked={confirmEnabled}\n          onCheckedChange={(checked) => {\n            onChange?.({\n              ...options,\n              confirm: checked ? { title: '', description: '', confirmText: '' } : null,\n            });\n          }}\n        />\n        <Label className=\"text-sm font-normal\">\n          {t('table:field.default.button.clickConfirm')}\n        </Label>\n      </div>\n\n      {confirmEnabled && (\n        <div className=\"flex flex-col gap-2 rounded-md border-muted bg-muted p-3\">\n          {/* Title */}\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"flex h-6 items-center justify-between\">\n              <Label className=\"text-xs text-muted-foreground\">\n                {t('table:field.default.button.confirmTitle')}\n              </Label>\n              <FieldSelector\n                excludedIds={excludedFieldIds}\n                onSelect={(fieldId) => onFieldSelect(fieldId, titleEditorViewRef)}\n                modal\n              >\n                <Button variant=\"ghost\" size=\"icon-xs\">\n                  <Plus className=\"size-4 shrink-0\" />\n                </Button>\n              </FieldSelector>\n            </div>\n            <PromptEditor\n              themeOptions={{ height: 'auto', content: { padding: '6px 0px' } }}\n              value={confirm?.title ?? ''}\n              placeholder={t('sdk:field.button.confirm.title')}\n              editorViewRef={titleEditorViewRef}\n              onChange={(value) => updateConfirm('title', value)}\n            />\n          </div>\n\n          {/* Description */}\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"flex h-6 items-center justify-between\">\n              <Label className=\"text-xs text-muted-foreground\">\n                {t('table:field.default.button.confirmDescription')}\n              </Label>\n              <FieldSelector\n                excludedIds={excludedFieldIds}\n                onSelect={(fieldId) => onFieldSelect(fieldId, descEditorViewRef)}\n                modal\n              >\n                <Button variant=\"ghost\" size=\"icon-xs\">\n                  <Plus className=\"size-4 shrink-0\" />\n                </Button>\n              </FieldSelector>\n            </div>\n            <PromptEditor\n              themeOptions={{ content: { padding: '6px 0px' } }}\n              value={confirm?.description ?? ''}\n              placeholder={t('sdk:field.button.confirm.description')}\n              editorViewRef={descEditorViewRef}\n              onChange={(value) => updateConfirm('description', value)}\n            />\n          </div>\n\n          {/* Confirm Button Text */}\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"flex h-6 items-center justify-between\">\n              <Label className=\"text-xs text-muted-foreground\">\n                {t('table:field.default.button.confirmButtonText')}\n              </Label>\n            </div>\n            <PromptEditor\n              themeOptions={{ height: 'auto', content: { padding: '6px 0px' } }}\n              value={confirm?.confirmText ?? ''}\n              placeholder={t('common:actions.confirm')}\n              editorViewRef={confirmTextEditorViewRef}\n              onChange={(value) => updateConfirm('confirmText', value)}\n            />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\nconst WorkflowAction = (props: { options?: Partial<IButtonFieldOptions>; onSave?: () => void }) => {\n  const { options, onSave } = props;\n  const workflow = options?.workflow;\n  const { setModal } = useWorkFlowPanelStore();\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const usage = useBaseUsage();\n  const { buttonFieldEnable = false } = usage?.limit ?? {};\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <Label className=\"text-sm font-medium\">{t('table:field.default.button.automation')}</Label>\n      {buttonFieldEnable ? (\n        <Button\n          className=\"flex items-center \"\n          variant=\"outline\"\n          onClick={() => {\n            setModal({ from: 'buttonFieldOptions' });\n            onSave?.();\n          }}\n        >\n          {workflow?.id ? <PencilIcon className=\"size-4\" /> : <PlusIcon className=\"size-4\" />}\n          <span className=\"flex-1 text-left\">\n            {workflow?.name || t('table:field.default.button.customAutomation')}\n          </span>\n        </Button>\n      ) : (\n        <UnavailableInPlanTips>\n          <Button className=\"flex items-center \" variant=\"outline\">\n            <PlusIcon className=\"size-4\" />\n            <span className=\"flex-1 text-left\">\n              {workflow?.name || t('table:field.default.button.customAutomation')}\n            </span>\n          </Button>\n        </UnavailableInPlanTips>\n      )}\n    </div>\n  );\n};\n\nexport const ButtonOptions = (props: {\n  options: Partial<IButtonFieldOptions> | undefined;\n  onChange?: (options: Partial<IButtonFieldOptions>) => void;\n  isLookup?: boolean;\n  onSave?: () => void;\n}) => {\n  const { isLookup, options, onChange, onSave } = props;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const bgColor = ColorUtils.getHexForColor(options?.color ?? Colors.Teal);\n  const [limitClickCount, setLimitClickCount] = useState<boolean>((options?.maxCount ?? 0) > 0);\n\n  return (\n    <div className=\"form-control space-y-4 border-t pt-4\">\n      {!isLookup && (\n        <div className=\"flex w-full flex-col gap-4\">\n          <div className=\"flex flex-col gap-2\">\n            <Label className=\"text-sm font-medium\">{t('table:field.default.button.label')}</Label>\n\n            <div className=\"flex items-center gap-2\">\n              <Popover>\n                <PopoverTrigger>\n                  <Button\n                    variant={'ghost'}\n                    className=\"h-auto rounded-full border-2 p-[2px]\"\n                    style={{ borderColor: bgColor }}\n                  >\n                    <div style={{ backgroundColor: bgColor }} className=\"size-3 rounded-full\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"w-auto p-2\">\n                  <ColorPicker\n                    color={options?.color ?? Colors.Teal}\n                    onSelect={(color) => onChange?.({ ...options, color })}\n                  />\n                </PopoverContent>\n              </Popover>\n\n              <Input\n                size=\"lg\"\n                className=\"flex-1\"\n                value={options?.label ?? '123'}\n                onChange={(e) => onChange?.({ ...options, label: e.target.value })}\n              />\n            </div>\n          </div>\n\n          <WorkflowAction options={options} onSave={onSave} />\n\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"flex h-8 items-center gap-2\">\n              <Switch\n                checked={limitClickCount}\n                onCheckedChange={(checked) => {\n                  setLimitClickCount(checked);\n                  onChange?.({ ...options, maxCount: checked ? 1 : 0 });\n                }}\n              />\n              <Label className=\"text-sm font-normal\">\n                {t('table:field.default.button.limitCount')}\n              </Label>\n            </div>\n\n            {limitClickCount && (\n              <div className=\"flex h-8 items-center gap-2\">\n                <Switch\n                  checked={Boolean(options?.resetCount)}\n                  onCheckedChange={(checked) => onChange?.({ ...options, resetCount: checked })}\n                />\n                <Label className=\"text-sm font-normal\">\n                  {t('table:field.default.button.resetCount')}\n                </Label>\n              </div>\n            )}\n\n            {limitClickCount && (\n              <div className=\"flex flex-col gap-2\">\n                <Label className=\"font-mediun text-sm\">\n                  {t('table:field.default.button.maxCount')}\n                </Label>\n                <Input\n                  type=\"number\"\n                  value={options?.maxCount}\n                  onChange={(e) =>\n                    onChange?.({ ...options, maxCount: Math.max(0, Number(e.target.value)) })\n                  }\n                />\n              </div>\n            )}\n\n            <ConfirmEditor options={options} onChange={onChange} />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/CheckboxOptions.tsx",
    "content": "import type { ICheckboxFieldOptions } from '@teable/core';\nimport { Checkbox } from '@teable/ui-lib/shadcn';\nimport { DefaultValue } from '../DefaultValue';\n\nexport const CheckboxOptions = (props: {\n  options: Partial<ICheckboxFieldOptions> | undefined;\n  onChange?: (options: Partial<ICheckboxFieldOptions>) => void;\n  isLookup?: boolean;\n}) => {\n  const { isLookup, options, onChange } = props;\n  const onDefaultValueChange = (defaultValue: boolean | undefined) => {\n    onChange?.({\n      defaultValue: defaultValue || null,\n    });\n  };\n\n  return (\n    <div className=\"form-control space-y-4 border-t pt-4\">\n      {!isLookup && (\n        <DefaultValue>\n          <Checkbox\n            checked={options?.defaultValue || false}\n            onCheckedChange={(checked: boolean) => onDefaultValueChange(checked)}\n          />\n        </DefaultValue>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/ConditionalLookupOptions.tsx",
    "content": "import { CONDITIONAL_QUERY_DEFAULT_LIMIT, type IConditionalLookupOptions } from '@teable/core';\nimport { StandaloneViewProvider } from '@teable/sdk/context';\nimport { useBaseId, useTable, useTableId } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { Trans } from 'next-i18next';\nimport { useCallback } from 'react';\nimport { LookupFilterOptions } from '../lookup-options/LookupFilterOptions';\nimport { SelectFieldByTableId } from '../lookup-options/LookupOptions';\nimport { LinkedRecordSortLimitConfig } from './LinkedRecordSortLimitConfig';\nimport { SelectTable } from './LinkOptions/SelectTable';\n\ninterface IConditionalLookupOptionsProps {\n  fieldId?: string;\n  options?: IConditionalLookupOptions;\n  onOptionsChange: (\n    partial: Partial<IConditionalLookupOptions>,\n    lookupField?: IFieldInstance\n  ) => void;\n}\n\nexport const ConditionalLookupOptions = ({\n  fieldId,\n  options,\n  onOptionsChange,\n}: IConditionalLookupOptionsProps) => {\n  const baseId = useBaseId();\n  const sourceTableId = useTableId();\n  const effectiveOptions = options ?? ({} as IConditionalLookupOptions);\n\n  const handleTableChange = useCallback(\n    (nextBaseId?: string, tableId?: string) => {\n      onOptionsChange({\n        baseId: nextBaseId,\n        foreignTableId: tableId,\n        lookupFieldId: undefined,\n        filter: undefined,\n      });\n    },\n    [onOptionsChange]\n  );\n\n  const handleLookupField = useCallback(\n    (lookupField: IFieldInstance) => {\n      onOptionsChange(\n        {\n          lookupFieldId: lookupField.id,\n        },\n        lookupField\n      );\n    },\n    [onOptionsChange]\n  );\n  const handleSortLimitDisable = useCallback(() => {\n    onOptionsChange({ sort: undefined, limit: undefined });\n  }, [onOptionsChange]);\n\n  const foreignTableId = effectiveOptions.foreignTableId;\n  const effectiveBaseId = effectiveOptions.baseId ?? baseId;\n\n  return (\n    <div className=\"flex w-full flex-col gap-3\" data-testid=\"conditional-lookup-options\">\n      <SelectTable\n        baseId={effectiveOptions.baseId}\n        tableId={foreignTableId}\n        onChange={handleTableChange}\n      />\n\n      {foreignTableId ? (\n        <StandaloneViewProvider baseId={effectiveBaseId} tableId={foreignTableId}>\n          <ConditionalLookupForeignSection\n            fieldId={fieldId}\n            foreignTableId={foreignTableId}\n            lookupFieldId={effectiveOptions.lookupFieldId}\n            filter={effectiveOptions.filter}\n            sort={effectiveOptions.sort}\n            limit={effectiveOptions.limit}\n            onLookupFieldChange={handleLookupField}\n            onFilterChange={(filter) => onOptionsChange({ filter: filter ?? undefined })}\n            onSortChange={(sort) => onOptionsChange({ sort })}\n            onLimitChange={(limit) => onOptionsChange({ limit })}\n            onSortLimitDisable={handleSortLimitDisable}\n            sourceTableId={sourceTableId}\n          />\n        </StandaloneViewProvider>\n      ) : null}\n    </div>\n  );\n};\n\ninterface IConditionalLookupForeignSectionProps {\n  fieldId?: string;\n  foreignTableId: string;\n  lookupFieldId?: string;\n  filter?: IConditionalLookupOptions['filter'];\n  sort?: IConditionalLookupOptions['sort'];\n  limit?: number;\n  onLookupFieldChange: (field: IFieldInstance) => void;\n  onFilterChange: (filter: IConditionalLookupOptions['filter']) => void;\n  onSortChange: (sort?: IConditionalLookupOptions['sort']) => void;\n  onLimitChange: (limit?: number) => void;\n  onSortLimitDisable: () => void;\n  sourceTableId?: string;\n}\n\nconst ConditionalLookupForeignSection = ({\n  fieldId,\n  foreignTableId,\n  lookupFieldId,\n  filter,\n  sort,\n  limit,\n  onLookupFieldChange,\n  onFilterChange,\n  onSortChange,\n  onLimitChange,\n  onSortLimitDisable,\n  sourceTableId,\n}: IConditionalLookupForeignSectionProps) => {\n  const table = useTable();\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"space-y-2\">\n        {table?.name ? (\n          <span className=\"neutral-content label-text\">\n            <Trans\n              ns=\"table\"\n              i18nKey=\"field.editor.lookupToTable\"\n              values={{ tableName: table.name }}\n              components={{ bold: <span className=\"font-semibold\" /> }}\n            />\n          </span>\n        ) : null}\n        <SelectFieldByTableId selectedId={lookupFieldId} onChange={onLookupFieldChange} />\n      </div>\n\n      <LookupFilterOptions\n        fieldId={fieldId}\n        foreignTableId={foreignTableId}\n        filter={filter ?? null}\n        enableFieldReference\n        contextTableId={sourceTableId}\n        required\n        onChange={(nextFilter) => onFilterChange(nextFilter ?? null)}\n      />\n\n      <LinkedRecordSortLimitConfig\n        sort={sort}\n        limit={limit}\n        onSortChange={onSortChange}\n        onLimitChange={onLimitChange}\n        defaultLimit={CONDITIONAL_QUERY_DEFAULT_LIMIT}\n        toggleTestId=\"conditional-lookup-sort-limit-toggle\"\n        onDisable={onSortLimitDisable}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/ConditionalRollupOptions.tsx",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport type {\n  IConditionalRollupFieldOptions,\n  RollupFunction,\n  IRollupFieldOptions,\n} from '@teable/core';\nimport {\n  CellValueType,\n  getRollupFunctionsByCellValueType,\n  ROLLUP_FUNCTIONS,\n  CONDITIONAL_QUERY_DEFAULT_LIMIT,\n} from '@teable/core';\nimport { StandaloneViewProvider } from '@teable/sdk/context';\nimport { useBaseId, useFields, useTable, useTableId } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { Trans } from 'next-i18next';\nimport { useCallback, useEffect, useMemo } from 'react';\nimport { LookupFilterOptions } from '../lookup-options/LookupFilterOptions';\nimport { SelectFieldByTableId } from '../lookup-options/LookupOptions';\nimport { LinkedRecordSortLimitConfig } from './LinkedRecordSortLimitConfig';\nimport { SelectTable } from './LinkOptions/SelectTable';\nimport { RollupOptions } from './RollupOptions';\n\nconst RAW_VALUE_EXPRESSION = 'concatenate({values})' as RollupFunction;\nconst SORT_LIMIT_ENABLED_EXPRESSIONS: RollupFunction[] = [\n  'array_compact({values})',\n  'array_join({values})',\n  'array_unique({values})',\n  'concatenate({values})',\n];\n\ninterface IConditionalRollupOptionsProps {\n  fieldId?: string;\n  options?: Partial<IConditionalRollupFieldOptions>;\n  onChange?: (options: Partial<IConditionalRollupFieldOptions>) => void;\n}\n\nexport const ConditionalRollupOptions = ({\n  fieldId,\n  options = {},\n  onChange,\n}: IConditionalRollupOptionsProps) => {\n  const baseId = useBaseId();\n  const sourceTableId = useTableId();\n\n  const handlePartialChange = useCallback(\n    (partial: Partial<IConditionalRollupFieldOptions>) => {\n      onChange?.({ ...options, ...partial });\n    },\n    [onChange, options]\n  );\n\n  const handleTableChange = useCallback(\n    (nextBaseId?: string, tableId?: string) => {\n      handlePartialChange({\n        baseId: nextBaseId,\n        foreignTableId: tableId,\n        lookupFieldId: undefined,\n        filter: undefined,\n      });\n    },\n    [handlePartialChange]\n  );\n\n  const handleLookupField = useCallback(\n    (lookupField: IFieldInstance) => {\n      const cellValueType = lookupField?.cellValueType ?? CellValueType.String;\n      const allowedExpressions = getRollupFunctionsByCellValueType(cellValueType).filter(\n        (expr) => expr !== RAW_VALUE_EXPRESSION\n      );\n      const fallbackExpression =\n        allowedExpressions[0] ??\n        ROLLUP_FUNCTIONS.find((expr) => expr !== RAW_VALUE_EXPRESSION) ??\n        ROLLUP_FUNCTIONS[0];\n      const currentExpression = options.expression as RollupFunction | undefined;\n      const isCurrentAllowed =\n        currentExpression !== undefined && allowedExpressions.includes(currentExpression);\n      const expressionToUse = isCurrentAllowed ? currentExpression : fallbackExpression;\n\n      handlePartialChange({\n        lookupFieldId: lookupField.id,\n        expression: expressionToUse,\n      });\n    },\n    [handlePartialChange, options.expression]\n  );\n\n  const rollupOptions = useMemo(() => {\n    return {\n      expression: options.expression,\n      formatting: options.formatting,\n      showAs: options.showAs,\n      timeZone: options.timeZone,\n    } as Partial<IRollupFieldOptions>;\n  }, [options.expression, options.formatting, options.showAs, options.timeZone]);\n\n  const effectiveBaseId = options.baseId ?? baseId;\n  const foreignTableId = options.foreignTableId;\n\n  return (\n    <div className=\"flex w-full flex-col gap-3\" data-testid=\"conditional-rollup-options\">\n      <SelectTable baseId={options.baseId} tableId={foreignTableId} onChange={handleTableChange} />\n\n      {foreignTableId ? (\n        <StandaloneViewProvider baseId={effectiveBaseId} tableId={foreignTableId}>\n          <ConditionalRollupForeignSection\n            fieldId={fieldId}\n            options={options}\n            onOptionsChange={handlePartialChange}\n            onLookupFieldChange={handleLookupField}\n            rollupOptions={rollupOptions}\n            sourceTableId={sourceTableId}\n          />\n        </StandaloneViewProvider>\n      ) : null}\n    </div>\n  );\n};\n\ninterface IConditionalRollupForeignSectionProps {\n  fieldId?: string;\n  options: Partial<IConditionalRollupFieldOptions>;\n  onOptionsChange: (options: Partial<IConditionalRollupFieldOptions>) => void;\n  onLookupFieldChange: (field: IFieldInstance) => void;\n  rollupOptions: Partial<IRollupFieldOptions>;\n  sourceTableId?: string;\n}\n\nconst ConditionalRollupForeignSection = (props: IConditionalRollupForeignSectionProps) => {\n  const { fieldId, options, onOptionsChange, onLookupFieldChange, rollupOptions, sourceTableId } =\n    props;\n  const foreignFields = useFields({ withHidden: true, withDenied: true });\n  const table = useTable();\n\n  const lookupField = useMemo(() => {\n    if (!options.lookupFieldId) return undefined;\n    return foreignFields.find((field) => field.id === options.lookupFieldId);\n  }, [foreignFields, options.lookupFieldId]);\n\n  const cellValueType = lookupField?.cellValueType ?? CellValueType.String;\n  const isMultipleCellValue = lookupField?.isMultipleCellValue ?? false;\n  const expression = options.expression as RollupFunction | undefined;\n  const supportsSortLimit =\n    expression != null && SORT_LIMIT_ENABLED_EXPRESSIONS.includes(expression);\n\n  const availableExpressions = useMemo(() => {\n    if (!lookupField) {\n      if (options.lookupFieldId) {\n        // Preserve persisted expression until the lookup field info is ready.\n        return undefined;\n      }\n      return ROLLUP_FUNCTIONS.filter((expr) => expr !== RAW_VALUE_EXPRESSION);\n    }\n    return getRollupFunctionsByCellValueType(lookupField.cellValueType).filter(\n      (expr) => expr !== RAW_VALUE_EXPRESSION\n    );\n  }, [lookupField, options.lookupFieldId]);\n\n  useEffect(() => {\n    if (!supportsSortLimit && (options.sort || options.limit)) {\n      onOptionsChange({ sort: undefined, limit: undefined });\n    }\n  }, [supportsSortLimit, options.limit, options.sort, onOptionsChange]);\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"space-y-2\">\n        {table?.name ? (\n          <span className=\"neutral-content label-text text-sm font-medium\">\n            <Trans\n              ns=\"table\"\n              i18nKey=\"field.editor.rollupToTable\"\n              values={{ tableName: table.name }}\n              components={{ bold: <span className=\"font-semibold\" /> }}\n            />\n          </span>\n        ) : null}\n        <SelectFieldByTableId selectedId={options.lookupFieldId} onChange={onLookupFieldChange} />\n      </div>\n\n      <LookupFilterOptions\n        fieldId={fieldId}\n        foreignTableId={options.foreignTableId!}\n        filter={options.filter ?? null}\n        enableFieldReference\n        contextTableId={sourceTableId}\n        required\n        onChange={(filter) => {\n          onOptionsChange({ filter: filter ?? undefined });\n        }}\n      />\n\n      <RollupOptions\n        options={rollupOptions}\n        cellValueType={cellValueType}\n        isMultipleCellValue={isMultipleCellValue}\n        availableExpressions={availableExpressions}\n        onChange={(partial) => onOptionsChange(partial)}\n      />\n\n      {supportsSortLimit ? (\n        <LinkedRecordSortLimitConfig\n          sort={options.sort}\n          limit={options.limit}\n          onSortChange={(sortValue) => onOptionsChange({ sort: sortValue })}\n          onLimitChange={(limitValue) => onOptionsChange({ limit: limitValue })}\n          defaultLimit={CONDITIONAL_QUERY_DEFAULT_LIMIT}\n          toggleTestId=\"conditional-rollup-sort-limit-toggle\"\n          onDisable={() => onOptionsChange({ sort: undefined, limit: undefined })}\n        />\n      ) : null}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/CreatedTimeOptions.tsx",
    "content": "import type {\n  IDatetimeFormatting,\n  ICreatedTimeFieldOptionsRo,\n  ILastModifiedTimeFieldOptionsRo,\n} from '@teable/core';\nimport { DatetimeFormatting } from '../formatting/DatetimeFormatting';\n\nexport const CreatedTimeOptions = (props: {\n  options: Partial<ICreatedTimeFieldOptionsRo | ILastModifiedTimeFieldOptionsRo> | undefined;\n  onChange?: (\n    options: Partial<ICreatedTimeFieldOptionsRo | ILastModifiedTimeFieldOptionsRo>\n  ) => void;\n}) => {\n  const { options = {}, onChange } = props;\n\n  const onFormattingChange = (formatting: IDatetimeFormatting) => {\n    onChange?.({\n      formatting,\n    });\n  };\n\n  return (\n    <div className=\"form-control w-full space-y-4\">\n      <DatetimeFormatting onChange={onFormattingChange} formatting={options.formatting} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/DateOptions.tsx",
    "content": "import type { IDateFieldOptions, IDatetimeFormatting } from '@teable/core';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport { Switch } from '@teable/ui-lib/shadcn/ui/switch';\nimport { useTranslation } from 'next-i18next';\nimport { DatetimeFormatting } from '../formatting/DatetimeFormatting';\n\nexport const DateOptions = (props: {\n  options: Partial<IDateFieldOptions> | undefined;\n  isLookup?: boolean;\n  onChange?: (options: Partial<IDateFieldOptions>) => void;\n}) => {\n  const { options = {}, isLookup, onChange } = props;\n  const { defaultValue } = options;\n  const { t } = useTranslation(['table']);\n\n  const onFormattingChange = (formatting: IDatetimeFormatting) => {\n    onChange?.({\n      formatting,\n    });\n  };\n\n  const onDefaultValueChange = (checked: boolean) => {\n    onChange?.({\n      defaultValue: checked ? 'now' : null,\n    });\n  };\n\n  return (\n    <div className=\"form-control w-full space-y-4\">\n      <DatetimeFormatting onChange={onFormattingChange} formatting={options.formatting} />\n      {!isLookup && (\n        <div className=\"flex h-8 items-center space-x-2\">\n          <Switch\n            id=\"field-options-auto-fill\"\n            checked={Boolean(defaultValue)}\n            onCheckedChange={onDefaultValueChange}\n          />\n          <Label htmlFor=\"field-options-auto-fill\" className=\"font-normal\">\n            {t('field.editor.autoFillDate')}\n          </Label>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx",
    "content": "import type { IFormulaFieldOptions, IUnionFormatting, IUnionShowAs } from '@teable/core';\nimport {\n  CellValueType,\n  getShowAsSchema,\n  getFormattingSchema,\n  getDefaultFormatting,\n} from '@teable/core';\nimport { FormulaEditor } from '@teable/sdk/components';\nimport { useFields } from '@teable/sdk/hooks';\nimport type { IFieldInstance } from '@teable/sdk/model';\nimport { FormulaField } from '@teable/sdk/model';\nimport { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { isEmpty, isEqual, keyBy } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useMemo, useState } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { useAI } from '@/features/app/hooks/useAI';\nimport { TimeZoneFormatting } from '../formatting/TimeZoneFormatting';\nimport { UnionFormatting } from '../formatting/UnionFormatting';\nimport { UnionShowAs } from '../show-as/UnionShowAs';\n\nconst calculateTypedValue = (\n  fields: IFieldInstance[],\n  expression?: string\n): {\n  cellValueType: CellValueType;\n  isMultipleCellValue?: boolean;\n  hasError?: boolean;\n} => {\n  const defaultResult = {\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n  };\n\n  try {\n    return expression\n      ? FormulaField.getParsedValueType(expression, keyBy(fields, 'id'))\n      : defaultResult;\n  } catch (e) {\n    return { ...defaultResult, hasError: true };\n  }\n};\n\nexport const FormulaOptionsInner = (props: {\n  options: Partial<IFormulaFieldOptions> | undefined;\n  onChange?: (options: Partial<IFormulaFieldOptions>) => void;\n}) => {\n  const { options = {}, onChange } = props;\n  const { enable: enableAI } = useAI();\n  const { expression, formatting, showAs } = options;\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const [visible, setVisible] = useState(false);\n  const { t } = useTranslation(['table']);\n\n  const expressionByName = useMemo(() => {\n    return expression\n      ? FormulaField.convertExpressionIdToName(expression, keyBy(fields, 'id'))\n      : '';\n  }, [expression, fields]);\n\n  const {\n    cellValueType,\n    isMultipleCellValue,\n    hasError: expressionHasError,\n  } = calculateTypedValue(fields, expression);\n\n  const onExpressionChange = (expr: string) => {\n    const { cellValueType: newCellValueType } = calculateTypedValue(fields, expr);\n    const newOptions: IFormulaFieldOptions = {\n      expression: expr,\n      timeZone:\n        formatting && 'timeZone' in formatting && formatting?.timeZone\n          ? formatting.timeZone\n          : options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,\n    };\n    if (newCellValueType !== cellValueType || expressionHasError) {\n      const defaultFormatting = getDefaultFormatting(newCellValueType);\n      newOptions.formatting = defaultFormatting;\n      newOptions.showAs = undefined;\n    }\n    onChange?.(newOptions);\n    setVisible(false);\n  };\n\n  const setFormatting = useCallback(\n    (newFormatting: IUnionFormatting) => {\n      const formattingResult = getFormattingSchema(cellValueType).safeParse(newFormatting);\n      const formattingParsed = formattingResult.success ? formattingResult.data : undefined;\n\n      if (isEqual(formattingParsed, formatting)) {\n        return;\n      }\n      onChange?.({\n        formatting: isEmpty(formattingParsed) ? undefined : newFormatting,\n        timeZone: options.timeZone,\n      });\n    },\n    [cellValueType, formatting, onChange, options.timeZone]\n  );\n\n  const setTimeZone = useCallback(\n    (newTimeZone: string) => {\n      if (newTimeZone === options.timeZone) {\n        return;\n      }\n      onChange?.({ timeZone: newTimeZone });\n    },\n    [options.timeZone, onChange]\n  );\n\n  const setShowAs = useCallback(\n    (newShowAs?: IUnionShowAs) => {\n      const showAsResult = getShowAsSchema(cellValueType, isMultipleCellValue).safeParse(newShowAs);\n      const showAsParsed = showAsResult.success ? showAsResult.data : undefined;\n\n      if (isEqual(showAsParsed, showAs)) {\n        return;\n      }\n      onChange?.({ showAs: isEmpty(showAsParsed) ? undefined : newShowAs });\n    },\n    [cellValueType, isMultipleCellValue, onChange, showAs]\n  );\n\n  return (\n    <div className=\"border-bordr w-full space-y-4 border-t pt-4\">\n      <div className=\"space-y-2\">\n        <span className=\"neutral-content text-sm font-medium\">\n          {t('field.default.formula.formula')}\n          <RequireCom />\n        </span>\n        <Dialog open={visible} onOpenChange={setVisible}>\n          <DialogTrigger asChild>\n            <code className=\"block min-h-[36px] cursor-pointer items-center whitespace-pre-wrap break-words rounded-md border border-input bg-transparent px-3 py-2 ring-offset-background dark:bg-input\">\n              {expressionByName}\n            </code>\n          </DialogTrigger>\n          <DialogContent\n            tabIndex={-1}\n            closeable\n            className=\"flex size-auto max-w-full overflow-hidden rounded-sm p-0 outline-0 md:w-auto\"\n          >\n            <FormulaEditor\n              expression={expression}\n              onConfirm={onExpressionChange}\n              enableAI={enableAI}\n            />\n          </DialogContent>\n        </Dialog>\n      </div>\n      <div className=\"space-y-2\">\n        <UnionFormatting\n          cellValueType={cellValueType}\n          formatting={formatting}\n          onChange={setFormatting}\n        />\n        {cellValueType !== CellValueType.DateTime && (\n          <TimeZoneFormatting\n            timeZone={options?.timeZone}\n            onChange={(value) => setTimeZone(value)}\n          />\n        )}\n      </div>\n      {Boolean(expression) && (\n        <div className=\"space-y-2\">\n          <UnionShowAs\n            showAs={showAs}\n            cellValueType={cellValueType}\n            isMultipleCellValue={isMultipleCellValue}\n            onChange={setShowAs}\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport const FormulaOptions = (props: {\n  options: Partial<IFormulaFieldOptions> | undefined;\n  isLookup?: boolean;\n  cellValueType?: CellValueType;\n  isMultipleCellValue?: boolean;\n  onChange?: (options: Partial<IFormulaFieldOptions>) => void;\n}) => {\n  const {\n    options,\n    isLookup,\n    cellValueType = CellValueType.String,\n    isMultipleCellValue,\n    onChange,\n  } = props;\n  const { expression, formatting, showAs } = options || {};\n\n  if (isLookup) {\n    return (\n      <div className=\"w-full space-y-2\">\n        <div className=\"space-y-2\">\n          <UnionFormatting\n            cellValueType={cellValueType}\n            formatting={formatting}\n            onChange={(formatting) => onChange?.({ formatting })}\n          />\n        </div>\n        {Boolean(expression) && (\n          <div className=\"space-y-2\">\n            <UnionShowAs\n              showAs={showAs}\n              cellValueType={cellValueType}\n              isMultipleCellValue={isMultipleCellValue}\n              onChange={(showAs) => onChange?.({ showAs })}\n            />\n          </div>\n        )}\n      </div>\n    );\n  }\n  return <FormulaOptionsInner options={options} onChange={onChange} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LastModifiedByOptions.tsx",
    "content": "import type { ILastModifiedByFieldOptions } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { FieldSelector } from '@teable/sdk/components';\nimport { useFields } from '@teable/sdk/hooks';\nimport { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib/shadcn';\nimport { Badge } from '@teable/ui-lib/shadcn/ui/badge';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\ninterface IProps {\n  options?: Partial<ILastModifiedByFieldOptions>;\n  onChange?: (options: Partial<ILastModifiedByFieldOptions>) => void;\n}\n\nexport const LastModifiedByOptions = ({ options = {}, onChange }: IProps) => {\n  const { t } = useTranslation(['table']);\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const trackedFieldIds = options.trackedFieldIds ?? [];\n  const trackAll = trackedFieldIds.length === 0;\n\n  const editableFields = useMemo(() => {\n    return fields.filter((field) => {\n      if (field.type === FieldType.LastModifiedTime || field.type === FieldType.LastModifiedBy) {\n        return false;\n      }\n      if (field.type === FieldType.CreatedTime || field.type === FieldType.CreatedBy) {\n        return false;\n      }\n      return !field.isComputed;\n    });\n  }, [fields]);\n\n  const handleRadioChange = (value: string) => {\n    if (value === 'all') {\n      onChange?.({ trackedFieldIds: [] });\n      return;\n    }\n    if (!trackedFieldIds.length && editableFields.length) {\n      onChange?.({ trackedFieldIds: [editableFields[0].id] });\n    }\n  };\n\n  const selectAll = () => {\n    onChange?.({ trackedFieldIds: [] });\n  };\n\n  const addField = (fieldId: string) => {\n    if (trackedFieldIds.includes(fieldId)) return;\n    onChange?.({ trackedFieldIds: [...trackedFieldIds, fieldId] });\n  };\n\n  const removeField = (fieldId: string) => {\n    onChange?.({ trackedFieldIds: trackedFieldIds.filter((id) => id !== fieldId) });\n  };\n\n  return (\n    <div className=\"form-control w-full space-y-4\">\n      <div className=\"space-y-2\">\n        <Label className=\"text-sm font-medium\">{t('field.editor.lastModifiedScope')}</Label>\n        <RadioGroup value={trackAll ? 'all' : 'specific'} onValueChange={handleRadioChange}>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"all\" id=\"lmb-all\" />\n            <Label htmlFor=\"lmb-all\" className=\"font-normal\">\n              {t('field.editor.lastModifiedAll')}\n            </Label>\n          </div>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"specific\" id=\"lmb-specific\" />\n            <Label htmlFor=\"lmb-specific\" className=\"font-normal\">\n              {t('field.editor.lastModifiedSpecific')}\n            </Label>\n          </div>\n        </RadioGroup>\n      </div>\n\n      {!trackAll && (\n        <div className=\"space-y-3\">\n          <FieldSelector\n            modal\n            fields={editableFields}\n            excludedIds={trackedFieldIds}\n            onSelect={addField}\n            placeholder={t('field.editor.lastModifiedSelect')}\n          >\n            <Button variant=\"outline\" className=\"w-full justify-between px-3\">\n              <span className=\"truncate text-sm font-normal\">\n                {t('field.editor.lastModifiedSelect')}\n              </span>\n            </Button>\n          </FieldSelector>\n\n          {trackedFieldIds.length > 0 && (\n            <div className=\"flex flex-wrap gap-2\">\n              {trackedFieldIds.map((fieldId) => {\n                const current = fields.find((f) => f.id === fieldId);\n                if (!current) return null;\n                return (\n                  <Badge key={fieldId} variant=\"default\" className=\"gap-1\">\n                    <span className=\"truncate\">{current.name}</span>\n                    <button\n                      aria-label=\"remove\"\n                      className=\"ml-1 text-xs text-muted-foreground hover:text-foreground\"\n                      onClick={() => removeField(fieldId)}\n                    >\n                      ×\n                    </button>\n                  </Badge>\n                );\n              })}\n            </div>\n          )}\n\n          {!editableFields.length && (\n            <span className=\"text-xs text-muted-foreground\">\n              {t('field.editor.noEditableFields')}\n            </span>\n          )}\n\n          {!trackAll && trackedFieldIds.length > 0 && (\n            <button\n              className=\"text-xs text-muted-foreground hover:text-foreground\"\n              onClick={selectAll}\n            >\n              {t('field.editor.lastModifiedAll')}\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LastModifiedTimeOptions.tsx",
    "content": "import type { IDatetimeFormatting, ILastModifiedTimeFieldOptionsRo } from '@teable/core';\nimport { FieldType } from '@teable/core';\nimport { FieldSelector } from '@teable/sdk/components';\nimport { useFields } from '@teable/sdk/hooks';\nimport { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib/shadcn';\nimport { Badge } from '@teable/ui-lib/shadcn/ui/badge';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { DatetimeFormatting } from '../formatting/DatetimeFormatting';\n\ninterface IProps {\n  options?: Partial<ILastModifiedTimeFieldOptionsRo>;\n  onChange?: (options: Partial<ILastModifiedTimeFieldOptionsRo>) => void;\n}\n\nexport const LastModifiedTimeOptions = ({ options = {}, onChange }: IProps) => {\n  const { t } = useTranslation(['table']);\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const trackedFieldIds = options.trackedFieldIds ?? [];\n  const trackAll = trackedFieldIds.length === 0;\n\n  const editableFields = useMemo(() => {\n    return fields.filter((field) => {\n      if (field.type === FieldType.LastModifiedTime || field.type === FieldType.LastModifiedBy) {\n        return false;\n      }\n      if (field.type === FieldType.CreatedTime || field.type === FieldType.CreatedBy) {\n        return false;\n      }\n      return !field.isComputed;\n    });\n  }, [fields]);\n\n  const onFormattingChange = (formatting: IDatetimeFormatting) => {\n    onChange?.({\n      formatting,\n    });\n  };\n\n  const handleRadioChange = (value: string) => {\n    if (value === 'all') {\n      onChange?.({ trackedFieldIds: [] });\n      return;\n    }\n    // specific: seed with existing selection or the first editable field\n    if (!trackedFieldIds.length && editableFields.length) {\n      onChange?.({ trackedFieldIds: [editableFields[0].id] });\n    }\n  };\n\n  const selectAll = () => {\n    onChange?.({ trackedFieldIds: [] });\n  };\n\n  const addField = (fieldId: string) => {\n    if (trackedFieldIds.includes(fieldId)) return;\n    onChange?.({ trackedFieldIds: [...trackedFieldIds, fieldId] });\n  };\n\n  const removeField = (fieldId: string) => {\n    onChange?.({ trackedFieldIds: trackedFieldIds.filter((id) => id !== fieldId) });\n  };\n\n  return (\n    <div className=\"form-control w-full space-y-4\">\n      <DatetimeFormatting onChange={onFormattingChange} formatting={options.formatting} />\n\n      <div className=\"space-y-2\">\n        <Label className=\"text-sm font-medium\">{t('field.editor.lastModifiedScope')}</Label>\n        <RadioGroup value={trackAll ? 'all' : 'specific'} onValueChange={handleRadioChange}>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"all\" id=\"lmt-all\" />\n            <Label htmlFor=\"lmt-all\" className=\"font-normal\">\n              {t('field.editor.lastModifiedAll')}\n            </Label>\n          </div>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"specific\" id=\"lmt-specific\" />\n            <Label htmlFor=\"lmt-specific\" className=\"font-normal\">\n              {t('field.editor.lastModifiedSpecific')}\n            </Label>\n          </div>\n        </RadioGroup>\n      </div>\n\n      {!trackAll && (\n        <div className=\"space-y-3\">\n          <FieldSelector\n            modal\n            fields={editableFields}\n            excludedIds={trackedFieldIds}\n            onSelect={addField}\n            placeholder={t('field.editor.lastModifiedSelect')}\n          >\n            <Button variant=\"outline\" className=\"w-full justify-between px-3\">\n              <span className=\"truncate text-sm font-normal\">\n                {t('field.editor.lastModifiedSelect')}\n              </span>\n            </Button>\n          </FieldSelector>\n\n          {trackedFieldIds.length > 0 && (\n            <div className=\"flex flex-wrap gap-2\">\n              {trackedFieldIds.map((fieldId) => {\n                const current = fields.find((f) => f.id === fieldId);\n                if (!current) return null;\n                return (\n                  <Badge key={fieldId} variant=\"default\" className=\"gap-1\">\n                    <span className=\"truncate\">{current.name}</span>\n                    <button\n                      aria-label=\"remove\"\n                      className=\"ml-1 text-xs text-muted-foreground hover:text-foreground\"\n                      onClick={() => removeField(fieldId)}\n                    >\n                      ×\n                    </button>\n                  </Badge>\n                );\n              })}\n            </div>\n          )}\n\n          {!editableFields.length && (\n            <span className=\"text-xs text-muted-foreground\">\n              {t('field.editor.noEditableFields')}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/LinkOptions.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { ILinkFieldOptionsRo } from '@teable/core';\nimport { Relationship } from '@teable/core';\nimport { ArrowUpRight, ChevronDown } from '@teable/icons';\nimport { getFields, getTablePermission } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useTableId } from '@teable/sdk/hooks';\nimport { Button, Label, Switch } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { MoreLinkOptions } from './MoreLinkOptions';\nimport { SelectTable } from './SelectTable';\n\nexport const LinkOptions = (props: {\n  options: Partial<ILinkFieldOptionsRo> | undefined;\n  fieldId?: string;\n  isLookup?: boolean;\n  onChange?: (options: Partial<ILinkFieldOptionsRo>) => void;\n}) => {\n  const { fieldId, options, isLookup, onChange } = props;\n  const tableId = useTableId();\n  const selfBaseId = useBaseId() as string;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const isMoreVisible = Boolean(\n    options?.filterByViewId || options?.filter || options?.visibleFieldIds\n  );\n\n  const [moreVisible, setMoreVisible] = useState(isMoreVisible);\n\n  const relationship = options?.relationship ?? Relationship.ManyOne;\n  const foreignTableId = options?.foreignTableId;\n  const isOneWay = options?.isOneWay;\n  const baseId = options?.baseId ?? selfBaseId;\n\n  const { data: tablePermission } = useQuery({\n    refetchOnWindowFocus: false,\n    queryKey: ReactQueryKeys.getTablePermission(baseId, foreignTableId!),\n    enabled: !!foreignTableId,\n    queryFn: ({ queryKey }) =>\n      getTablePermission(queryKey[1], queryKey[2])\n        .then((res) => res.data)\n        .catch(() => undefined),\n  });\n\n  const canCreateField = tablePermission?.field?.['field|create'];\n\n  const translation = {\n    [Relationship.OneOne]: t('table:field.editor.oneToOne'),\n    [Relationship.OneMany]: t('table:field.editor.oneToMany'),\n    [Relationship.ManyOne]: t('table:field.editor.manyToOne'),\n    [Relationship.ManyMany]: t('table:field.editor.manyToMany'),\n  };\n\n  const onSelect = (key: keyof ILinkFieldOptionsRo, value: unknown) => {\n    onChange?.({ foreignTableId, relationship, isOneWay, [key]: value });\n  };\n\n  const onRelationshipChange = (leftMulti: boolean, rightMulti: boolean) => {\n    if (leftMulti && rightMulti) {\n      onSelect('relationship', Relationship.ManyMany);\n    }\n    if (leftMulti && !rightMulti) {\n      onSelect('relationship', Relationship.OneMany);\n    }\n    if (!leftMulti && rightMulti) {\n      onSelect('relationship', Relationship.ManyOne);\n    }\n    if (!leftMulti && !rightMulti) {\n      onSelect('relationship', Relationship.OneOne);\n    }\n  };\n\n  const isLeftMulti = (relationship: Relationship) => {\n    return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;\n  };\n  const isRightMulti = (relationship: Relationship) => {\n    return relationship === Relationship.ManyMany || relationship === Relationship.ManyOne;\n  };\n\n  const queryClient = useQueryClient();\n\n  const { mutate: getFieldListMutate } = useMutation({\n    mutationFn: (foreignTableId: string) => {\n      return getFields(foreignTableId).then((res) => res.data);\n    },\n    onSuccess: (data) => {\n      queryClient.setQueryData(ReactQueryKeys.fieldList(foreignTableId!), data);\n      const primaryField = data.find((field) => field.isPrimary);\n      onChange?.({\n        ...options,\n        lookupFieldId: primaryField?.id,\n      });\n    },\n  });\n\n  if (isLookup) {\n    return <></>;\n  }\n\n  return (\n    <div className=\"flex w-full flex-col gap-4 border-t pt-4\">\n      <SelectTable\n        baseId={options?.baseId}\n        tableId={options?.foreignTableId}\n        onChange={async (baseId, tableId) => {\n          onChange?.({\n            baseId,\n            foreignTableId: tableId,\n            relationship,\n            isOneWay,\n            filterByViewId: null,\n            visibleFieldIds: null,\n            filter: null,\n          });\n          if (tableId) {\n            await getFieldListMutate(tableId);\n          }\n        }}\n      />\n      {options?.foreignTableId && (\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex justify-start\">\n            <Button\n              size=\"xs\"\n              variant=\"outline\"\n              className=\"\"\n              onClick={() => setMoreVisible(!moreVisible)}\n            >\n              {t('table:field.editor.moreOptions')}\n              <ChevronDown className=\"size-3 \" />\n            </Button>\n          </div>\n          {moreVisible && (\n            <MoreLinkOptions\n              foreignTableId={options?.foreignTableId}\n              fieldId={fieldId}\n              filterByViewId={options?.filterByViewId}\n              visibleFieldIds={options?.visibleFieldIds}\n              filter={options?.filter}\n              lookupFieldId={options?.lookupFieldId}\n              onChange={(partialOptions: Partial<ILinkFieldOptionsRo>) => {\n                onChange?.({ ...options, ...partialOptions });\n              }}\n            />\n          )}\n        </div>\n      )}\n      {foreignTableId && (\n        <div className=\"flex flex-col gap-2 border-t pt-4\">\n          <div className=\"flex h-8 items-center space-x-2\">\n            <Switch\n              id=\"field-options-one-way-link\"\n              checked={!isOneWay}\n              onCheckedChange={(checked) => {\n                onSelect('isOneWay', !checked);\n              }}\n              disabled={!canCreateField}\n            />\n            <Label htmlFor=\"field-options-one-way-link\" className=\"font-normal leading-tight\">\n              {t('table:field.editor.createSymmetricLink')}\n            </Label>\n          </div>\n          <div className=\"flex h-8 items-center space-x-2\">\n            <Switch\n              id=\"field-options-self-multi\"\n              checked={isLeftMulti(relationship)}\n              onCheckedChange={(checked) => {\n                onRelationshipChange(checked, isRightMulti(relationship));\n              }}\n            />\n            <Label htmlFor=\"field-options-self-multi\" className=\"font-normal leading-tight\">\n              {t('table:field.editor.allowLinkMultipleRecords')}\n            </Label>\n          </div>\n          <div className=\"flex h-8 items-center space-x-2\">\n            <Switch\n              id=\"field-options-sym-multi\"\n              checked={isRightMulti(relationship)}\n              onCheckedChange={(checked) => {\n                onRelationshipChange(isLeftMulti(relationship), checked);\n              }}\n            />\n            <Label htmlFor=\"field-options-sym-multi\" className=\"font-normal leading-tight\">\n              {isOneWay\n                ? t('table:field.editor.allowLinkToDuplicateRecords')\n                : t('table:field.editor.allowSymmetricFieldLinkMultipleRecords')}\n            </Label>\n          </div>\n          <div className=\"border-1 flex flex-col items-end gap-2 rounded-md border bg-secondary p-3 text-sm\">\n            <div className=\"flex w-full items-center justify-between\">\n              <p className=\"text-sm font-semibold\">{t('table:field.editor.tips')}</p>\n              <Link\n                className=\"flex items-center text-xs hover:underline\"\n                href={t('table:field.editor.linkFieldKnowMoreLink')}\n                target=\"_blank\"\n              >\n                {t('table:field.editor.knowMore')}\n                <ArrowUpRight className=\"size-4\" />\n              </Link>\n            </div>\n\n            <p className=\"w-full text-[13px]\">\n              <Trans\n                ns=\"table\"\n                i18nKey=\"field.editor.linkTipMessage\"\n                components={{ b: <b />, span: <span />, br: <br /> }}\n                values={{\n                  relationship: translation[relationship],\n                  linkType:\n                    tableId === foreignTableId\n                      ? t('table:field.editor.inSelfLink')\n                      : t('table:field.editor.betweenTwoTables'),\n                }}\n              />\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/MoreLinkOptions.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { PRIMARY_SUPPORTED_TYPES, type IFilter, type ILinkFieldOptionsRo } from '@teable/core';\nimport { EyeOff, Maximize2 } from '@teable/icons';\nimport { getFields } from '@teable/openapi';\nimport {\n  FilterWithTable,\n  HideFieldsBase,\n  useFieldFilterLinkContext,\n  ViewSelect,\n  FieldSelector,\n} from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTableId } from '@teable/sdk/hooks';\nimport { createFieldInstance, type IFieldInstance } from '@teable/sdk/model';\nimport { Button, cn, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface IMoreOptionsProps {\n  foreignTableId?: string;\n  fieldId?: string;\n  filter?: IFilter | null;\n  filterByViewId?: string | null;\n  lookupFieldId?: string | null;\n  visibleFieldIds?: string[] | null;\n  onChange?: (options: Partial<ILinkFieldOptionsRo>) => void;\n}\n\nexport const MoreLinkOptions = (props: IMoreOptionsProps) => {\n  const {\n    foreignTableId = '',\n    fieldId,\n    filterByViewId,\n    visibleFieldIds: _visibleFieldIds,\n    filter,\n    lookupFieldId,\n    onChange,\n  } = props;\n\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const currentTableId = useTableId() as string;\n  const visibleFieldIds = useMemo(() => _visibleFieldIds ?? [], [_visibleFieldIds]);\n\n  const query = useMemo(() => {\n    return {\n      viewId: filterByViewId ?? undefined,\n    };\n  }, [filterByViewId]);\n\n  const { data: totalFields = [] } = useQuery({\n    queryKey: ReactQueryKeys.fieldList(foreignTableId),\n    queryFn: () => getFields(foreignTableId).then((res) => res.data),\n    enabled: !!foreignTableId,\n  });\n\n  const foreignFieldInstances = useMemo(\n    () => totalFields.map((field) => createFieldInstance(field) as IFieldInstance),\n    [totalFields]\n  );\n\n  const primaryField = useMemo(() => {\n    return foreignFieldInstances.find((field) => field.isPrimary);\n  }, [foreignFieldInstances]);\n\n  const fieldInstances = useMemo(() => {\n    return foreignFieldInstances.filter((field) => PRIMARY_SUPPORTED_TYPES.has(field.type));\n  }, [foreignFieldInstances]);\n\n  const { data: withViewFields } = useQuery({\n    queryKey: ReactQueryKeys.fieldList(foreignTableId, query),\n    queryFn: () => getFields(foreignTableId, query).then((res) => res.data),\n    enabled: !!foreignTableId && !!filterByViewId,\n  });\n\n  const context = useFieldFilterLinkContext(currentTableId, fieldId, !fieldId);\n\n  const viewFieldInstances = useMemo(\n    () =>\n      (withViewFields ?? totalFields).map((field) => createFieldInstance(field) as IFieldInstance),\n    [withViewFields, totalFields]\n  );\n\n  const hiddenFieldIds = useMemo(() => {\n    // Default all fields are visible\n    if (!visibleFieldIds.length) return [];\n\n    return totalFields\n      ?.filter((field) => !visibleFieldIds.includes(field.id) && !field.isPrimary)\n      .map((field) => field.id);\n  }, [totalFields, visibleFieldIds]);\n\n  if (!foreignTableId || !foreignFieldInstances.length) {\n    return null;\n  }\n\n  const visibleCount = visibleFieldIds.length;\n  const text = visibleCount\n    ? t('sdk:hidden.configLabel_other_visible', { count: visibleCount })\n    : t('sdk:hidden.label');\n\n  const onHiddenChange = (hiddenFieldIds: string[]) => {\n    const hiddenFieldSet = new Set(hiddenFieldIds);\n    const visibleFieldIds = totalFields\n      .filter((field) => !hiddenFieldSet.has(field.id))\n      .map((field) => field.id);\n    onChange?.({ visibleFieldIds: visibleFieldIds.length ? visibleFieldIds : null });\n  };\n\n  return (\n    <div className=\"mt-2 flex flex-col gap-4 text-sm font-medium\">\n      <div className=\"flex flex-col gap-2\">\n        <span>{t('table:field.editor.showByField')}</span>\n        <FieldSelector\n          fields={fieldInstances}\n          value={lookupFieldId ?? primaryField?.id}\n          onSelect={(fieldId) => onChange?.({ lookupFieldId: fieldId ?? undefined })}\n          className=\"h-9 w-full max-w-none\"\n          modal\n        />\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <span>{t('table:field.editor.filterByView')}</span>\n        <ViewSelect\n          tableId={foreignTableId}\n          value={filterByViewId}\n          onChange={(viewId) => onChange?.({ filterByViewId: viewId })}\n          cancelable\n          className=\"my-0 h-9 w-full max-w-none\"\n        />\n      </div>\n      <div className=\"flex flex-col gap-1 rounded-md border px-3 py-2\">\n        <div className=\"flex items-center justify-between\">\n          <span>{t('table:field.editor.filter')}</span>\n          <Dialog>\n            <DialogTrigger asChild>\n              <Button size={'icon-xs'} variant={'ghost'}>\n                <Maximize2 className=\"size-4 shrink-0\" />\n              </Button>\n            </DialogTrigger>\n            <DialogContent className=\"min-w-96 max-w-fit\">\n              <FilterWithTable\n                fields={foreignFieldInstances}\n                value={filter ?? null}\n                context={context}\n                onChange={(value) => onChange?.({ filter: value })}\n              />\n            </DialogContent>\n          </Dialog>\n        </div>\n        <FilterWithTable\n          fields={foreignFieldInstances}\n          value={filter ?? null}\n          context={context}\n          onChange={(value) => onChange?.({ filter: value })}\n        />\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <span>{t('table:field.editor.hideFields')}</span>\n        <HideFieldsBase\n          fields={viewFieldInstances}\n          hidden={hiddenFieldIds}\n          onChange={onHiddenChange}\n        >\n          <Button\n            variant={'outline'}\n            className={cn('font-normal shrink-0 truncate text-sm ', {\n              'bg-secondary hover:opacity-80 ': Boolean(visibleCount),\n            })}\n          >\n            <EyeOff className=\"size-4\" />\n            {text}\n          </Button>\n        </HideFieldsBase>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/SelectTable.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { ArrowUpRight, Database, Table2 } from '@teable/icons';\nimport { getBaseAll } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { AnchorContext, TableProvider } from '@teable/sdk/context';\nimport { useBaseId, useTableId, useTables } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { Selector } from '@/components/Selector';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { tableConfig } from '@/features/i18n/table.config';\n\ninterface ISelectTableProps {\n  baseId?: string;\n  tableId?: string;\n  onChange?: (baseId?: string, tableId?: string) => void;\n}\n\nexport const SelectTable = ({ baseId, tableId, onChange }: ISelectTableProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [enableSelectBase, setEnableSelectBase] = useState(Boolean(baseId));\n  const selfTableId = useTableId();\n  const selfBaseId = useBaseId();\n  const selectedBaseId = baseId || selfBaseId!;\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      {enableSelectBase && (\n        <>\n          <div className=\"flex w-full flex-col gap-2\">\n            <div className=\"neutral-content label-text flex h-5 items-center justify-between\">\n              {t('table:field.editor.linkBase')}\n              <Button\n                size=\"xs\"\n                variant=\"link\"\n                onClick={() => {\n                  setEnableSelectBase(false);\n                  onChange?.(undefined, undefined);\n                }}\n                className=\"h-5 text-xs text-muted-foreground decoration-muted-foreground\"\n              >\n                {t('common:actions.cancel')}\n              </Button>\n            </div>\n            <BasePicker\n              baseId={selectedBaseId}\n              onChange={(baseId) => {\n                if (baseId === selfBaseId) {\n                  onChange?.(undefined, undefined);\n                } else {\n                  onChange?.(baseId);\n                }\n              }}\n            />\n          </div>\n        </>\n      )}\n      <AnchorContext.Provider value={{ baseId: selectedBaseId }}>\n        <div className=\"flex w-full flex-col gap-2\">\n          <div className=\"neutral-content flex h-5 items-center justify-between text-sm font-medium\">\n            <span className=\"flex items-center gap-1 \">\n              {t('table:field.editor.linkTable')}\n              <RequireCom />\n              {tableId && (\n                <Link href={`/base/${selectedBaseId}/${tableId}`} target=\"_blank\">\n                  <ArrowUpRight className=\"size-4 shrink-0 text-muted-foreground\" />\n                </Link>\n              )}\n            </span>\n            {!enableSelectBase && (\n              <Button\n                size=\"xs\"\n                variant=\"link\"\n                onClick={() => setEnableSelectBase(true)}\n                className=\"h-5 text-xs text-muted-foreground decoration-muted-foreground hover:underline\"\n              >\n                {t('table:field.editor.linkFromAnotherBase')}\n              </Button>\n            )}\n          </div>\n          <TableProvider>\n            <TablePicker\n              tableId={tableId}\n              selfTableId={selfTableId}\n              onChange={(tableId) => onChange?.(baseId!, tableId)}\n            />\n          </TableProvider>\n        </div>\n      </AnchorContext.Provider>\n    </div>\n  );\n};\n\ninterface ITablePickerProps {\n  tableId: string | undefined;\n  readonly?: boolean;\n  selfTableId?: string;\n  onChange?: (tableId: string) => void;\n}\n\nconst TablePicker = ({ tableId, selfTableId, readonly, onChange }: ITablePickerProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  let tables = useTables() as { id: string; name: string; icon?: string }[];\n\n  if (tableId && !tables.find((table) => table.id === tableId)) {\n    tables = tables.concat({\n      id: tableId!,\n      name: t('table:field.editor.tableNoPermission'),\n    });\n  }\n\n  return (\n    <Selector\n      className=\"w-full\"\n      readonly={readonly}\n      selectedId={tableId}\n      onChange={(tableId) => onChange?.(tableId)}\n      candidates={tables.map((table) => ({\n        id: table.id,\n        name: table.name + (selfTableId === table.id ? ` (${t('table:field.editor.self')})` : ''),\n        icon: table.icon || <Table2 className=\"size-4 shrink-0\" />,\n      }))}\n      placeholder={t('table:field.editor.selectTable')}\n    />\n  );\n};\n\ninterface IBasePickerProps {\n  baseId: string;\n  readonly?: boolean;\n  onChange?: (baseId: string) => void;\n}\n\nconst BasePicker = ({ baseId, onChange }: IBasePickerProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  let { data: bases } = useQuery({\n    queryKey: ReactQueryKeys.baseAll(),\n    queryFn: () =>\n      getBaseAll().then((data) => data.data) as Promise<\n        { id: string; name: string; icon?: string }[]\n      >,\n  });\n\n  if (baseId && !bases?.find((base) => base.id === baseId)) {\n    bases = bases?.concat({\n      id: baseId!,\n      name: t('table:field.editor.baseNoPermission'),\n    });\n  }\n\n  return (\n    <Selector\n      className=\"w-full\"\n      selectedId={baseId}\n      onChange={(baseId) => onChange?.(baseId)}\n      candidates={bases?.map((base) => ({\n        id: base.id,\n        name: base.name,\n        icon: base.icon || <Database className=\"size-4 shrink-0\" />,\n      }))}\n      placeholder={t('table:field.editor.selectBase')}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/index.ts",
    "content": "export * from './LinkOptions';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LinkedRecordSortLimitConfig.tsx",
    "content": "import {\n  FieldType,\n  SortFunc,\n  clampConditionalLimit,\n  normalizeConditionalLimit,\n  CONDITIONAL_QUERY_DEFAULT_LIMIT,\n  CONDITIONAL_QUERY_MAX_LIMIT,\n} from '@teable/core';\nimport { FieldCommand, FieldSelector, OrderSelect } from '@teable/sdk';\nimport { useFields } from '@teable/sdk/hooks';\nimport { Input, Switch } from '@teable/ui-lib/shadcn';\nimport { AlertTriangle } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport type { ChangeEvent } from 'react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport interface ISortOrderValue {\n  fieldId: string;\n  order: SortFunc;\n}\n\ninterface ILinkedRecordSortLimitConfigProps {\n  sort?: ISortOrderValue;\n  limit?: number;\n  defaultLimit?: number;\n  onSortChange: (sort?: ISortOrderValue) => void;\n  onLimitChange: (limit?: number) => void;\n  onDisable?: () => void;\n  toggleTestId?: string;\n}\n\nconst DEFAULT_LIMIT = CONDITIONAL_QUERY_DEFAULT_LIMIT;\nconst MAX_LIMIT = CONDITIONAL_QUERY_MAX_LIMIT;\nconst TOGGLE_FALLBACK_LIMIT = 1;\n\nexport const LinkedRecordSortLimitConfig = ({\n  sort,\n  limit,\n  defaultLimit = DEFAULT_LIMIT,\n  onSortChange,\n  onLimitChange,\n  onDisable,\n  toggleTestId = 'linked-record-sort-limit-toggle',\n}: ILinkedRecordSortLimitConfigProps) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const selectFieldPlaceholder = t('table:field.editor.selectField');\n  const switchLabel = t('table:field.editor.conditionalLookup.sortLimitToggleLabel');\n  const sortLabel = t('table:field.editor.conditionalLookup.sortLabel');\n  const limitLabel = t('table:field.editor.conditionalLookup.limitLabel');\n  const limitPlaceholder = t('table:field.editor.conditionalLookup.limitPlaceholder');\n  const limitHint = t('table:field.editor.conditionalLookup.limitHint', { limit: MAX_LIMIT });\n  const sortMissingTitle = t('table:field.editor.conditionalLookup.sortMissingWarningTitle');\n  const sortMissingDescription = t(\n    'table:field.editor.conditionalLookup.sortMissingWarningDescription'\n  );\n\n  const fields = useFields({ withHidden: true, withDenied: true });\n  const sortCandidates = useMemo(() => fields.filter((f) => f.type !== FieldType.Button), [fields]);\n  const sortFieldMissing = useMemo(() => {\n    if (!sort?.fieldId) return false;\n    return !sortCandidates.some((candidate) => candidate.id === sort.fieldId);\n  }, [sort?.fieldId, sortCandidates]);\n\n  const derivedEnabled = Boolean(sort || limit);\n  const [limitDraft, setLimitDraft] = useState(limit != null ? String(limit) : '');\n  const [localOverride, setLocalOverride] = useState<boolean | null>(null);\n  const sortLimitEnabled = localOverride ?? derivedEnabled;\n\n  useEffect(() => {\n    if (limit != null) {\n      const sanitized = clampConditionalLimit(limit);\n      if (sanitized != null) {\n        setLimitDraft(String(sanitized));\n        if (sanitized !== limit) {\n          onLimitChange(sanitized);\n        }\n        return;\n      }\n      const fallback = normalizeConditionalLimit(TOGGLE_FALLBACK_LIMIT);\n      setLimitDraft(String(fallback));\n      if (limit !== fallback) {\n        onLimitChange(fallback);\n      }\n      return;\n    }\n    if (!sortLimitEnabled) {\n      setLimitDraft('');\n    }\n  }, [limit, onLimitChange, sortLimitEnabled]);\n\n  useEffect(() => {\n    if (localOverride !== null && derivedEnabled === localOverride) {\n      setLocalOverride(null);\n    }\n  }, [derivedEnabled, localOverride]);\n\n  const handleSortLimitToggle = useCallback(\n    (checked: boolean) => {\n      setLocalOverride(checked);\n\n      if (checked) {\n        if (limit == null) {\n          const baseDefault =\n            Number.isInteger(defaultLimit) && defaultLimit > 0 ? defaultLimit : DEFAULT_LIMIT;\n          const toggleDefault = baseDefault >= MAX_LIMIT ? TOGGLE_FALLBACK_LIMIT : baseDefault;\n          const normalizedDefault =\n            clampConditionalLimit(toggleDefault) ??\n            normalizeConditionalLimit(TOGGLE_FALLBACK_LIMIT);\n          setLimitDraft(String(normalizedDefault));\n          onLimitChange(normalizedDefault);\n        }\n        return;\n      }\n\n      setLimitDraft('');\n      if (onDisable) {\n        onDisable();\n        return;\n      }\n      onSortChange(undefined);\n      onLimitChange(undefined);\n    },\n    [defaultLimit, limit, onDisable, onLimitChange, onSortChange]\n  );\n\n  const handleSortFieldChange = useCallback(\n    (fieldId: string) => {\n      onSortChange({\n        fieldId,\n        order: sort?.order ?? SortFunc.Asc,\n      });\n    },\n    [onSortChange, sort?.order]\n  );\n\n  const handleSortOrderChange = useCallback(\n    (order: SortFunc) => {\n      if (!sort?.fieldId) return;\n      onSortChange({\n        fieldId: sort.fieldId,\n        order,\n      });\n    },\n    [onSortChange, sort?.fieldId]\n  );\n\n  const handleLimitChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      const value = e.target.value;\n      if (!/^\\d*$/.test(value)) {\n        return;\n      }\n\n      if (value === '') {\n        setLimitDraft('');\n        onLimitChange(undefined);\n        return;\n      }\n      const parsed = Number(value);\n      if (!Number.isInteger(parsed)) {\n        return;\n      }\n      const sanitized = clampConditionalLimit(parsed);\n      if (sanitized == null) {\n        setLimitDraft(value);\n        onLimitChange(undefined);\n        return;\n      }\n\n      setLimitDraft(String(sanitized));\n      onLimitChange(sanitized);\n    },\n    [onLimitChange]\n  );\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between rounded-md border border-border px-3 py-2\">\n        <span className=\"label-text text-sm\">{switchLabel}</span>\n        <Switch\n          checked={sortLimitEnabled}\n          onCheckedChange={handleSortLimitToggle}\n          data-testid={toggleTestId}\n        />\n      </div>\n      <span className=\"block text-xs text-muted-foreground\">{limitHint}</span>\n\n      {!sortLimitEnabled ? null : (\n        <div className=\"space-y-4\">\n          {sortFieldMissing ? (\n            <div className=\"flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2\">\n              <AlertTriangle className=\"mt-0.5 size-4 shrink-0 text-warning\" />\n              <div className=\"space-y-1 text-warning\">\n                <span className=\"block text-sm font-medium leading-none\">{sortMissingTitle}</span>\n                <span className=\"block text-xs text-warning/90\">{sortMissingDescription}</span>\n              </div>\n            </div>\n          ) : null}\n          <div className=\"space-y-2\">\n            <span className=\"neutral-content label-text\">{sortLabel}</span>\n            {sort?.fieldId ? (\n              <div className=\"flex items-center gap-2\">\n                <FieldSelector\n                  value={sort.fieldId}\n                  fields={sortCandidates}\n                  onSelect={handleSortFieldChange}\n                  className=\"h-9 !max-w-none flex-1 justify-between\"\n                />\n                <OrderSelect\n                  value={sort.order ?? SortFunc.Asc}\n                  onSelect={handleSortOrderChange}\n                  fieldId={sort.fieldId}\n                  triggerClassName=\"mx-0 h-9 w-32\"\n                />\n              </div>\n            ) : (\n              <FieldCommand\n                fields={sortCandidates}\n                onSelect={handleSortFieldChange}\n                placeholder={selectFieldPlaceholder}\n              />\n            )}\n          </div>\n\n          <div className=\"space-y-2\">\n            <span className=\"neutral-content label-text\">{limitLabel}</span>\n            <Input\n              size=\"lg\"\n              type=\"text\"\n              inputMode=\"numeric\"\n              pattern=\"\\d*\"\n              value={limitDraft}\n              placeholder={limitPlaceholder}\n              onChange={handleLimitChange}\n            />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/LongTextOptions.tsx",
    "content": "import type { ILongTextFieldOptions, ILongTextShowAs } from '@teable/core';\nimport { Textarea } from '@teable/ui-lib/shadcn';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport { Tabs, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn/ui/tabs';\nimport { useTranslation } from 'next-i18next';\nimport { DefaultValue } from '../DefaultValue';\n\nconst textFlag = 'text';\n\nexport const LongTextOptions = (props: {\n  options: Partial<ILongTextFieldOptions> | undefined;\n  onChange?: (options: Partial<ILongTextFieldOptions>) => void;\n  isLookup?: boolean;\n}) => {\n  const { isLookup, options, onChange } = props;\n  const { t } = useTranslation(['table']);\n\n  const showAs = options?.showAs;\n  const selectedType = showAs?.type ?? textFlag;\n\n  const onShowAsChange = (type: string) => {\n    const newShowAs = type === textFlag ? null : ({ type } as ILongTextShowAs);\n    onChange?.({\n      ...options,\n      showAs: newShowAs,\n    });\n  };\n\n  const onDefaultValueChange = (defaultValue: string | undefined) => {\n    onChange?.({\n      ...options,\n      defaultValue: defaultValue ?? null,\n    });\n  };\n\n  return (\n    <div className=\"form-control space-y-4 border-t pt-4\">\n      {!isLookup && (\n        <>\n          <div className=\"flex w-full flex-col gap-2\">\n            <Label className=\"text-sm font-medium\">{t('table:field.editor.showAs')}</Label>\n            <Tabs value={selectedType} onValueChange={onShowAsChange} className=\"w-full\">\n              <TabsList className=\"flex w-full gap-2\">\n                <TabsTrigger value={textFlag} className=\"flex-1 font-normal\">\n                  {t('table:field.editor.text')}\n                </TabsTrigger>\n                <TabsTrigger value=\"markdown\" className=\"flex-1 font-normal\">\n                  {t('table:field.editor.markdown')}\n                </TabsTrigger>\n              </TabsList>\n            </Tabs>\n          </div>\n          <DefaultValue onReset={() => onDefaultValueChange(undefined)}>\n            <Textarea\n              className=\"w-full\"\n              value={options?.defaultValue || ''}\n              onChange={(e) => onDefaultValueChange(e.target.value)}\n              rows={3}\n            />\n          </DefaultValue>\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/NumberOptions.tsx",
    "content": "import type { INumberShowAs, INumberFormatting, INumberFieldOptions } from '@teable/core';\nimport { Input } from '@teable/ui-lib/shadcn';\nimport { DefaultValue } from '../DefaultValue';\nimport { NumberFormatting } from '../formatting/NumberFormatting';\nimport { MultiNumberShowAs } from '../show-as/MultiNumberShowAs';\nimport { SingleNumberShowAs } from '../show-as/SingleNumberShowAs';\n\nexport const NumberOptions = (props: {\n  options: Partial<INumberFieldOptions> | undefined;\n  isLookup?: boolean;\n  isMultipleCellValue?: boolean;\n  onChange?: (options: Partial<INumberFieldOptions>) => void;\n}) => {\n  const { isLookup, options, isMultipleCellValue, onChange } = props;\n\n  const ShowAsComponent = isMultipleCellValue ? MultiNumberShowAs : SingleNumberShowAs;\n\n  const onFormattingChange = (formatting: INumberFormatting) => {\n    onChange?.({\n      formatting,\n    });\n  };\n\n  const onShowAsChange = (showAs?: INumberShowAs) => {\n    onChange?.({\n      showAs,\n    });\n  };\n\n  const onDefaultValueChange = (defaultValue: number | null | undefined) => {\n    onChange?.({\n      defaultValue: defaultValue ?? null,\n    });\n  };\n\n  return (\n    <div className=\"form-control space-y-4\">\n      <NumberFormatting formatting={options?.formatting} onChange={onFormattingChange} />\n      <hr />\n      {!isLookup && (\n        <DefaultValue onReset={() => onDefaultValueChange(null)}>\n          <Input\n            size=\"lg\"\n            type=\"number\"\n            value={options?.defaultValue ? options.defaultValue : ''}\n            onChange={(e) => {\n              const value = e.target.value;\n              onDefaultValueChange(value === '' ? null : Number(value));\n            }}\n          />\n        </DefaultValue>\n      )}\n      <ShowAsComponent showAs={options?.showAs as never} onChange={onShowAsChange} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/RatingOptions.tsx",
    "content": "import type { IRatingColors, IRatingFieldOptions } from '@teable/core';\nimport { ColorUtils, RATING_ICON_COLORS, RatingIcon } from '@teable/core';\nimport { RATING_ICON_MAP } from '@teable/sdk/components';\nimport { RatingField } from '@teable/sdk/model';\nimport {\n  Label,\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\nexport const RATING_ICON_LIST = RATING_ICON_COLORS.map((colorKey) => {\n  return [\n    {\n      id: RatingIcon.Star,\n      Icon: RATING_ICON_MAP[RatingIcon.Star],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Moon,\n      Icon: RATING_ICON_MAP[RatingIcon.Moon],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Sun,\n      Icon: RATING_ICON_MAP[RatingIcon.Sun],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Zap,\n      Icon: RATING_ICON_MAP[RatingIcon.Zap],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Flame,\n      Icon: RATING_ICON_MAP[RatingIcon.Flame],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Heart,\n      Icon: RATING_ICON_MAP[RatingIcon.Heart],\n      colorKey,\n    },\n    {\n      id: RatingIcon.Apple,\n      Icon: RATING_ICON_MAP[RatingIcon.Apple],\n      colorKey,\n    },\n    {\n      id: RatingIcon.ThumbUp,\n      Icon: RATING_ICON_MAP[RatingIcon.ThumbUp],\n      colorKey,\n    },\n  ];\n});\n\nexport const RATING_FIELD_MAXIMUM = Array.from({ length: 10 }, (_, index) => {\n  const value = 1 + index;\n  return {\n    text: value.toString(),\n    value: value,\n  };\n});\n\nexport const RatingOptions = (props: {\n  options: Partial<IRatingFieldOptions> | undefined;\n  isLookup?: boolean;\n  onChange?: (options: Partial<IRatingFieldOptions>) => void;\n}) => {\n  const { options = RatingField.defaultOptions(), isLookup, onChange } = props;\n  const { t } = useTranslation(['table']);\n\n  const { icon: selectedIcon, color: selectedColor, max } = options;\n\n  const onIconChange = (icon: RatingIcon, colorKey: IRatingColors) => {\n    onChange?.({ ...options, icon, color: colorKey });\n  };\n\n  const onMaximumChange = (max: string) => {\n    onChange?.({ ...options, max: Number(max) });\n  };\n\n  if (isLookup) return null;\n\n  return (\n    <div className=\"form-control space-y-4 border-t pt-4\">\n      <div className=\"flex w-full flex-col gap-2\">\n        <Label className=\"text-sm font-medium\">{t('field.editor.style')}</Label>\n        <div className=\"flex w-full flex-col items-center rounded-md border px-4 py-3\">\n          {RATING_ICON_LIST.map((group, index) => {\n            return (\n              <div key={index} className=\" my-1 flex w-full justify-between\">\n                {group.map((item) => {\n                  const { id, Icon, colorKey } = item;\n                  const isSelected = selectedIcon === id && selectedColor === colorKey;\n                  const color = ColorUtils.getHexForColor(colorKey);\n                  return (\n                    <Icon\n                      key={id}\n                      className={cn(\n                        'w-7 h-7 p-1 rounded cursor-pointer',\n                        isSelected && 'bg-accent'\n                      )}\n                      style={{ fill: color, color }}\n                      onClick={() => onIconChange(id, colorKey)}\n                    />\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n      </div>\n      <div className=\"flex w-full flex-col gap-2\">\n        <Label className=\"text-sm font-medium\">{t('field.editor.maximum')}</Label>\n        <Select value={max?.toString()} onValueChange={onMaximumChange}>\n          <SelectTrigger size=\"lg\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {RATING_FIELD_MAXIMUM.map(({ text, value }) => (\n              <SelectItem key={value} value={value.toString()}>\n                {text}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/RollupOptions.tsx",
    "content": "import type {\n  IRollupFieldOptions,\n  IUnionFormatting,\n  IUnionShowAs,\n  RollupFunction,\n} from '@teable/core';\nimport {\n  assertNever,\n  ROLLUP_FUNCTIONS,\n  CellValueType,\n  getDefaultFormatting,\n  getFormattingSchema,\n  getShowAsSchema,\n} from '@teable/core';\nimport { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component/base/BaseSingleSelect';\n\nimport { RollupField } from '@teable/sdk/model';\nimport { isEmpty, isEqual } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback, useEffect, useMemo } from 'react';\nimport { RequireCom } from '@/features/app/blocks/setting/components/RequireCom';\nimport { UnionFormatting } from '../formatting/UnionFormatting';\nimport { UnionShowAs } from '../show-as/UnionShowAs';\n\nconst calculateRollupTypedValue = (\n  expression?: string,\n  cellValueType: CellValueType = CellValueType.String,\n  isMultipleCellValue: boolean = false\n) => {\n  const defaultResult = {\n    cellValueType: cellValueType,\n    isMultipleCellValue: isMultipleCellValue,\n  };\n\n  try {\n    return expression\n      ? RollupField.getParsedValueType(expression, cellValueType, isMultipleCellValue)\n      : defaultResult;\n  } catch (e) {\n    return defaultResult;\n  }\n};\n\nexport const RollupOptions = (props: {\n  options: Partial<IRollupFieldOptions> | undefined;\n  cellValueType?: CellValueType;\n  isMultipleCellValue?: boolean;\n  isLookup?: boolean;\n  availableExpressions?: IRollupFieldOptions['expression'][];\n  expressionLabelOverrides?: Partial<\n    Record<RollupFunction, { label?: string; description?: string }>\n  >;\n  onChange?: (options: Partial<IRollupFieldOptions>) => void;\n}) => {\n  const {\n    options = {},\n    isLookup,\n    cellValueType = CellValueType.String,\n    isMultipleCellValue,\n    availableExpressions,\n    expressionLabelOverrides,\n    onChange,\n  } = props;\n  const { expression, formatting, showAs } = options;\n  const { t } = useTranslation(['table']);\n\n  const typedValue = useMemo(\n    () =>\n      isLookup\n        ? { cellValueType, isMultipleCellValue }\n        : calculateRollupTypedValue(expression, cellValueType, isMultipleCellValue),\n    [expression, cellValueType, isMultipleCellValue, isLookup]\n  );\n\n  const onExpressionChange = useCallback(\n    (expr: IRollupFieldOptions['expression']) => {\n      const nextTypedValue = isLookup\n        ? { cellValueType, isMultipleCellValue }\n        : calculateRollupTypedValue(expr, cellValueType, isMultipleCellValue);\n      const { cellValueType: newCellValueType, isMultipleCellValue: newIsMultipleCellValue } =\n        nextTypedValue;\n      const newOptions: Partial<IRollupFieldOptions> = {\n        expression: expr,\n        timeZone:\n          formatting && 'timeZone' in formatting && formatting?.timeZone\n            ? formatting.timeZone\n            : options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,\n      };\n\n      const formattingSchema = getFormattingSchema(newCellValueType);\n      const formattingValid = formattingSchema.safeParse(formatting).success;\n      if (!formattingValid) {\n        newOptions.formatting = getDefaultFormatting(newCellValueType);\n      }\n\n      const showAsSchema = getShowAsSchema(newCellValueType, newIsMultipleCellValue);\n      const showAsValid = showAsSchema.safeParse(showAs).success;\n      if (\n        !showAsValid ||\n        newCellValueType !== cellValueType ||\n        newIsMultipleCellValue !== isMultipleCellValue\n      ) {\n        newOptions.showAs = undefined;\n      }\n\n      onChange?.(newOptions);\n    },\n    [cellValueType, formatting, isMultipleCellValue, isLookup, onChange, options.timeZone, showAs]\n  );\n\n  useEffect(() => {\n    if (!availableExpressions || availableExpressions.length === 0) {\n      return;\n    }\n    if (expression && availableExpressions.includes(expression)) {\n      return;\n    }\n    const fallbackExpression = availableExpressions[0];\n    if (!fallbackExpression) {\n      return;\n    }\n    onExpressionChange(fallbackExpression);\n  }, [availableExpressions, expression, onExpressionChange]);\n\n  const onFormattingChange = useCallback(\n    (newFormatting?: IUnionFormatting) => {\n      const { cellValueType } = typedValue;\n      const formattingResult = getFormattingSchema(cellValueType).safeParse(newFormatting);\n      const formattingParsed = formattingResult.success ? formattingResult.data : undefined;\n\n      if (isEqual(formattingParsed, formatting)) {\n        return;\n      }\n      onChange?.({ formatting: isEmpty(formattingParsed) ? undefined : newFormatting });\n    },\n    [formatting, onChange, typedValue]\n  );\n\n  const setTimeZone = useCallback(\n    (newTimeZone: string) => {\n      if (newTimeZone === options.timeZone) {\n        return;\n      }\n      onChange?.({ timeZone: newTimeZone });\n    },\n    [options.timeZone, onChange]\n  );\n\n  const onShowAsChange = useCallback(\n    (newShowAs?: IUnionShowAs) => {\n      const { cellValueType, isMultipleCellValue } = typedValue;\n      const showAsResult = getShowAsSchema(cellValueType, isMultipleCellValue).safeParse(newShowAs);\n      const showAsParsed = showAsResult.success ? showAsResult.data : undefined;\n\n      if (isEqual(showAsParsed, showAs)) {\n        return;\n      }\n      onChange?.({ showAs: isEmpty(showAsParsed) ? undefined : newShowAs });\n    },\n    [showAs, onChange, typedValue]\n  );\n\n  const candidates = useMemo(() => {\n    const expressions = availableExpressions ?? ROLLUP_FUNCTIONS;\n    return expressions.map((f) => {\n      let name;\n      let description;\n      switch (f) {\n        case 'countall({values})':\n          name = t('field.default.rollup.func.countAll');\n          description = t('field.default.rollup.funcDesc.countAll');\n          break;\n        case 'counta({values})':\n          name = t('field.default.rollup.func.countA');\n          description = t('field.default.rollup.funcDesc.countA');\n          break;\n        case 'count({values})':\n          name = t('field.default.rollup.func.count');\n          description = t('field.default.rollup.funcDesc.count');\n          break;\n        case 'sum({values})':\n          name = t('field.default.rollup.func.sum');\n          description = t('field.default.rollup.funcDesc.sum');\n          break;\n        case 'average({values})':\n          name = t('field.default.rollup.func.average');\n          description = t('field.default.rollup.funcDesc.average');\n          break;\n        case 'max({values})':\n          name = t('field.default.rollup.func.max');\n          description = t('field.default.rollup.funcDesc.max');\n          break;\n        case 'min({values})':\n          name = t('field.default.rollup.func.min');\n          description = t('field.default.rollup.funcDesc.min');\n          break;\n        case 'and({values})':\n          name = t('field.default.rollup.func.and');\n          description = t('field.default.rollup.funcDesc.and');\n          break;\n        case 'or({values})':\n          name = t('field.default.rollup.func.or');\n          description = t('field.default.rollup.funcDesc.or');\n          break;\n        case 'xor({values})':\n          name = t('field.default.rollup.func.xor');\n          description = t('field.default.rollup.funcDesc.xor');\n          break;\n        case 'array_join({values})':\n          name = t('field.default.rollup.func.arrayJoin');\n          description = t('field.default.rollup.funcDesc.arrayJoin');\n          break;\n        case 'array_unique({values})':\n          name = t('field.default.rollup.func.arrayUnique');\n          description = t('field.default.rollup.funcDesc.arrayUnique');\n          break;\n        case 'array_compact({values})':\n          name = t('field.default.rollup.func.arrayCompact');\n          description = t('field.default.rollup.funcDesc.arrayCompact');\n          break;\n        case 'concatenate({values})':\n          name = t('field.default.rollup.func.concatenate');\n          description = t('field.default.rollup.funcDesc.concatenate');\n          break;\n        default:\n          assertNever(f);\n      }\n\n      const override = expressionLabelOverrides?.[f];\n      if (override?.label) {\n        name = override.label;\n      }\n      if (override?.description) {\n        description = override.description;\n      }\n      return {\n        value: f,\n        label: name,\n        description,\n      };\n    });\n  }, [availableExpressions, expressionLabelOverrides, t]);\n\n  const displayRender = (option: (typeof candidates)[number]) => {\n    const { label } = option;\n    return (\n      <div className=\"flex items-center justify-start\">\n        <div>\n          <div className=\"truncate pl-1 text-[13px]\">{label}</div>\n        </div>\n      </div>\n    );\n  };\n\n  const optionRender = (option: (typeof candidates)[number]) => {\n    const { label, description } = option;\n    return (\n      <div className=\"flex items-start justify-start\">\n        <div className=\"pl-1\">\n          <div className=\"truncate text-[13px]\">{label}</div>\n          <span className=\"text-wrap text-xs text-primary/60\" title={description}>\n            {description}\n          </span>\n        </div>\n      </div>\n    );\n  };\n\n  return (\n    <div className=\" w-full space-y-4 border-t pt-4\" data-testid=\"rollup-options\">\n      {!isLookup && (\n        <div className=\"space-y-2\">\n          <span className=\"neutral-content text-sm font-medium\">\n            {t('field.default.rollup.rollup')}\n            <RequireCom />\n          </span>\n          <BaseSingleSelect\n            modal\n            className=\"h-9 w-full\"\n            placeholder={t('field.default.rollup.selectAnRollupFunction')}\n            options={candidates}\n            value={expression || null}\n            onSelect={(id) => {\n              onExpressionChange(id as IRollupFieldOptions['expression']);\n            }}\n            optionRender={optionRender}\n            displayRender={displayRender}\n          />\n        </div>\n      )}\n      {(isLookup || Boolean(expression)) && (\n        <>\n          <div className=\"space-y-2\">\n            <UnionFormatting\n              cellValueType={typedValue.cellValueType}\n              formatting={formatting}\n              onChange={onFormattingChange}\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <UnionShowAs\n              showAs={options?.showAs}\n              cellValueType={typedValue.cellValueType}\n              isMultipleCellValue={typedValue.isMultipleCellValue}\n              onChange={onShowAsChange}\n            />\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/ChoiceItem.tsx",
    "content": "import type { ISelectFieldChoice } from '@teable/core';\nimport { ColorUtils } from '@teable/core';\nimport { Popover, PopoverTrigger, Button, PopoverContent, Input } from '@teable/ui-lib/shadcn';\nimport { useState, useEffect, useRef } from 'react';\nimport { ColorPicker } from './ColorPicker';\n\ninterface IOptionItemProps {\n  choice: ISelectFieldChoice;\n  readonly?: boolean;\n  onChange?: (key: keyof ISelectFieldChoice, value: string) => void;\n  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n  onInputRef?: (el: HTMLInputElement | null) => void;\n}\n\nexport const ChoiceItem = (props: IOptionItemProps) => {\n  const { choice, readonly, onChange, onKeyDown, onInputRef } = props;\n  const { color, name } = choice;\n  const bgColor = ColorUtils.getHexForColor(color);\n\n  return (\n    <li className=\"flex grow items-center\">\n      {readonly ? (\n        <div className=\"h-auto rounded-full border-2 p-[2px]\" style={{ borderColor: bgColor }}>\n          <div style={{ backgroundColor: bgColor }} className=\"size-3 rounded-full\" />\n        </div>\n      ) : (\n        <Popover>\n          <PopoverTrigger>\n            <Button\n              variant={'ghost'}\n              className=\"h-auto rounded-full border-2 p-[2px]\"\n              style={{ borderColor: bgColor }}\n            >\n              <div style={{ backgroundColor: bgColor }} className=\"size-3 rounded-full\" />\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent className=\"w-auto p-2\">\n            <ColorPicker color={color} onSelect={(color) => onChange?.('color', color)} />\n          </PopoverContent>\n        </Popover>\n      )}\n      <div className=\"flex-1 px-2\">\n        <ChoiceInput\n          reRef={(el) => onInputRef?.(el)}\n          name={name}\n          readOnly={readonly}\n          onKeyDown={(e) => onKeyDown?.(e)}\n          onChange={(value) => onChange?.('name', value)}\n        />\n      </div>\n    </li>\n  );\n};\n\nexport const ChoiceInput: React.FC<{\n  reRef: React.Ref<HTMLInputElement>;\n  readOnly?: boolean;\n  name: string;\n  onChange: (name: string) => void;\n  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n}> = ({ name, readOnly, onChange, onKeyDown, reRef }) => {\n  const [value, setValue] = useState<string>(name);\n  const valueRef = useRef<string>(value);\n  useEffect(() => {\n    valueRef.current = value;\n  }, [value]);\n\n  // Save on unmount (e.g., during virtual scrolling)\n  useEffect(() => {\n    return () => {\n      if (valueRef.current !== name) {\n        onChange(valueRef.current);\n      }\n    };\n  }, [name, onChange]);\n\n  const onChangeInner = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const curValue = e.target.value;\n    setValue(curValue);\n  };\n\n  return (\n    <Input\n      ref={reRef}\n      type=\"text\"\n      value={value}\n      readOnly={readOnly}\n      onChange={onChangeInner}\n      onKeyDown={onKeyDown}\n      onBlur={() => onChange(value)}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/ColorPicker.tsx",
    "content": "import type { Colors } from '@teable/core';\nimport { COLOR_PALETTE, ColorUtils } from '@teable/core';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\n\nexport const ColorPicker = ({\n  color,\n  onSelect,\n  className,\n}: {\n  color: Colors;\n  onSelect: (color: Colors) => void;\n  className?: string;\n}) => {\n  return (\n    <div className={cn('flex w-64 flex-wrap p-2', className)}>\n      {COLOR_PALETTE.map((group, index) => {\n        return (\n          <div key={index}>\n            {group.map((c) => {\n              const bg = ColorUtils.getHexForColor(c);\n\n              return (\n                <Button\n                  key={c}\n                  variant={'ghost'}\n                  className={cn('p-1 my-1 rounded-full h-auto', {\n                    'border-2 p-[2px]': color === c,\n                  })}\n                  style={{ borderColor: bg }}\n                  onMouseDown={(e) => {\n                    e.stopPropagation();\n                    onSelect(c);\n                  }}\n                >\n                  <div\n                    style={{\n                      backgroundColor: bg,\n                    }}\n                    className=\"size-4 rounded-full\"\n                  />\n                </Button>\n              );\n            })}\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/SelectDefaultValue.tsx",
    "content": "import type { ISelectFieldOptions } from '@teable/core';\nimport { SelectEditor, transformSelectOptions } from '@teable/sdk/components';\nimport { DefaultValue } from '../../DefaultValue';\n\ninterface ISelectDefaultValue {\n  isMultiple: boolean;\n  onChange: (value: string | string[] | undefined) => void;\n  options: Partial<ISelectFieldOptions> | undefined;\n}\n\nexport const SelectDefaultValue = ({ isMultiple, onChange, options }: ISelectDefaultValue) => {\n  return (\n    <DefaultValue onReset={() => onChange(undefined)}>\n      <SelectEditor\n        value={options?.defaultValue}\n        options={transformSelectOptions(options?.choices ?? [])}\n        onChange={onChange}\n        isMultiple={isMultiple}\n      />\n    </DefaultValue>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/SelectOptions.tsx",
    "content": "import type { DropResult } from '@hello-pangea/dnd';\nimport { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';\nimport type { ISelectFieldChoice, ISelectFieldOptions } from '@teable/core';\nimport { ColorUtils } from '@teable/core';\nimport { DraggableHandle, Plus, Trash } from '@teable/icons';\nimport { cn, Label, Switch } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useRef } from 'react';\nimport type { VirtuosoHandle } from 'react-virtuoso';\nimport { Virtuoso } from 'react-virtuoso';\nimport { HeightPreservingItem } from '@/features/app/blocks/view/kanban/components/KanbanStack';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { ChoiceItem } from './ChoiceItem';\nimport { SelectDefaultValue } from './SelectDefaultValue';\n\nconst getChoiceId = (choice: ISelectFieldChoice, index: number) => {\n  const { id, color, name } = choice;\n  return id ?? `${color}-${name}-${index}`;\n};\n\nexport const SelectOptions = (props: {\n  isMultiple: boolean;\n  options: Partial<ISelectFieldOptions> | undefined;\n  isLookup?: boolean;\n  onChange?: (options: Partial<ISelectFieldOptions>) => void;\n}) => {\n  const { isMultiple, options, isLookup, onChange } = props;\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const choices = useMemo(() => options?.choices ?? [], [options?.choices]);\n\n  const updateOptionChange = (index: number, key: keyof ISelectFieldChoice, value: string) => {\n    const newChoice = choices.map((v, i) => {\n      if (i === index) {\n        return {\n          ...v,\n          [key]: value,\n        };\n      }\n      return v;\n    });\n    onChange?.({ choices: newChoice });\n  };\n\n  const onDefaultValueChange = (defaultValue: string | string[] | undefined) => {\n    onChange?.({ defaultValue: defaultValue ?? null } as Partial<ISelectFieldOptions>);\n  };\n\n  const onPreventAutoNewOptionsChange = (checked: boolean) => {\n    onChange?.({ preventAutoNewOptions: checked });\n  };\n\n  const deleteChoice = (index: number) => {\n    onChange?.({\n      choices: choices.filter((_, i) => i !== index),\n    });\n  };\n\n  const addOption = () => {\n    const existColors = choices.map((v) => v.color);\n    const choice = {\n      name: '',\n      color: ColorUtils.randomColor(existColors)[0],\n    } as ISelectFieldChoice;\n\n    const newChoices = [...choices, choice];\n    onChange?.({ choices: newChoices });\n    setTimeout(() => {\n      virtuosoRef.current?.scrollToIndex({ index: 'LAST' });\n      setTimeout(() => inputRefs.current[choices.length]?.focus(), 150);\n    });\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter' && !isLookup) {\n      addOption();\n    }\n  };\n\n  const onDragEnd = async (result: DropResult) => {\n    const { source, destination } = result;\n\n    if (!destination) return;\n\n    const { index: from } = source;\n    const { index: to } = destination;\n    const list = [...choices];\n    const [choice] = list.splice(from, 1);\n\n    list.splice(to, 0, choice);\n\n    onChange?.({ choices: list });\n  };\n\n  return (\n    <div className=\"border-bordr flex grow flex-col space-y-2 border-t pt-4\">\n      <div\n        className=\"grow\"\n        style={{ maxHeight: choices.length * 40, minHeight: Math.min(choices.length * 40, 140) }}\n      >\n        <DragDropContext onDragEnd={onDragEnd}>\n          <Droppable\n            droppableId={'select-choice-container'}\n            mode=\"virtual\"\n            renderClone={(provided, snapshot, rubric) => {\n              const choice = choices[rubric.source.index];\n              const { draggableProps } = provided;\n              return (\n                <div\n                  ref={provided.innerRef}\n                  {...draggableProps}\n                  className={cn('py-1', isLookup && 'cursor-default')}\n                >\n                  <div className=\"flex items-center\">\n                    {!isLookup && <DraggableHandle className=\"mr-1 size-4 cursor-grabbing\" />}\n                    <ChoiceItem\n                      choice={choice}\n                      readonly={isLookup}\n                      onKeyDown={onKeyDown}\n                      onInputRef={(el) => (inputRefs.current[0] = el)}\n                    />\n                    {!isLookup && (\n                      <Button\n                        variant={'ghost'}\n                        className=\"size-6 rounded-sm p-0 focus-visible:ring-transparent focus-visible:ring-offset-0\"\n                        onClick={() => deleteChoice(0)}\n                      >\n                        <Trash className=\"size-4 text-muted-foreground\" />\n                      </Button>\n                    )}\n                  </div>\n                </div>\n              );\n            }}\n          >\n            {(provided) => (\n              <Virtuoso\n                ref={virtuosoRef}\n                scrollerRef={provided.innerRef as never}\n                className=\"size-full\"\n                totalCount={choices.length}\n                overscan={5}\n                components={{\n                  Item: HeightPreservingItem as never,\n                }}\n                itemContent={(index) => {\n                  const choice = choices[index];\n                  if (choice == null) {\n                    return null;\n                  }\n                  return (\n                    <Draggable\n                      draggableId={getChoiceId(choice, index)}\n                      index={index}\n                      key={getChoiceId(choice, index)}\n                    >\n                      {(draggableProvided) => {\n                        const { draggableProps, dragHandleProps } = draggableProvided;\n                        return (\n                          <div\n                            ref={draggableProvided.innerRef}\n                            {...draggableProps}\n                            className={cn('py-1', isLookup && 'cursor-default')}\n                          >\n                            <div className=\"flex items-center\">\n                              {!isLookup && (\n                                <div {...dragHandleProps} className=\"mr-1 size-4\">\n                                  <DraggableHandle className=\"size-4 cursor-grabbing\" />\n                                </div>\n                              )}\n                              <ChoiceItem\n                                choice={choice}\n                                readonly={isLookup}\n                                onChange={(key, value) => updateOptionChange(index, key, value)}\n                                onKeyDown={onKeyDown}\n                                onInputRef={(el) => (inputRefs.current[index] = el)}\n                              />\n                              {!isLookup && (\n                                <Button\n                                  variant={'ghost'}\n                                  className=\"size-6 rounded-sm p-0 focus-visible:ring-transparent focus-visible:ring-offset-0\"\n                                  onClick={() => deleteChoice(index)}\n                                >\n                                  <Trash className=\"size-4 text-muted-foreground\" />\n                                </Button>\n                              )}\n                            </div>\n                          </div>\n                        );\n                      }}\n                    </Draggable>\n                  );\n                }}\n              />\n            )}\n          </Droppable>\n        </DragDropContext>\n      </div>\n      {!isLookup && (\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"shrink-0\">\n            <Button\n              className=\"w-full gap-2 text-sm font-normal\"\n              size={'sm'}\n              variant={'outline'}\n              onClick={addOption}\n            >\n              <Plus className=\"size-4\" />\n              {t('table:field.editor.addOption')}\n            </Button>\n          </div>\n          <div className=\"flex h-8 items-center gap-2\">\n            <Switch\n              id=\"allow-auto-new-options\"\n              checked={!options?.preventAutoNewOptions}\n              onCheckedChange={(checked) => {\n                onPreventAutoNewOptionsChange(!checked);\n              }}\n            />\n            <Label htmlFor=\"allow-auto-new-options\" className=\"font-normal leading-tight\">\n              {t('table:field.editor.allowNewOptionsWhenEditing')}\n            </Label>\n          </div>\n\n          <div className=\"flex items-center justify-between border-t pt-4\">\n            <SelectDefaultValue\n              isMultiple={isMultiple}\n              onChange={onDefaultValueChange}\n              options={options}\n            />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/index.ts",
    "content": "export * from './ChoiceItem';\nexport * from './ColorPicker';\nexport * from './SelectOptions';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/SingleLineTextOptions.tsx",
    "content": "import type { ISingleLineTextFieldOptions, ISingleLineTextShowAs } from '@teable/core';\nimport { Input } from '@teable/ui-lib/shadcn';\nimport { DefaultValue } from '../DefaultValue';\nimport { SingleTextLineShowAs } from '../show-as/SingleLineTextShowAs';\n\nexport const SingleLineTextOptions = (props: {\n  options: Partial<ISingleLineTextFieldOptions> | undefined;\n  onChange?: (options: Partial<ISingleLineTextFieldOptions>) => void;\n  isLookup?: boolean;\n}) => {\n  const { isLookup, options, onChange } = props;\n\n  const onShowAsChange = (showAs?: ISingleLineTextShowAs) => {\n    onChange?.({\n      showAs,\n    });\n  };\n\n  const onDefaultValueChange = (defaultValue: string | undefined) => {\n    onChange?.({\n      defaultValue: defaultValue ?? null,\n    });\n  };\n\n  return (\n    <div className=\"form-control space-y-4 border-t pt-4\">\n      {!isLookup && (\n        <DefaultValue onReset={() => onDefaultValueChange(undefined)}>\n          <Input\n            size=\"lg\"\n            type=\"text\"\n            value={options?.defaultValue || ''}\n            onChange={(e) => onDefaultValueChange(e.target.value)}\n          />\n        </DefaultValue>\n      )}\n      <SingleTextLineShowAs showAs={options?.showAs as never} onChange={onShowAsChange} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/options/UserOptions.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { CellValueType, IUserCellValue, IUserFieldOptions } from '@teable/core';\nimport { getUserCollaborators } from '@teable/openapi';\nimport { UserEditor } from '@teable/sdk/components';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { Label, Switch } from '@teable/ui-lib';\nimport { keyBy } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { DefaultValue } from '../DefaultValue';\n\nexport const UserOptions = (props: {\n  options: Partial<IUserFieldOptions> | undefined;\n  isLookup?: boolean;\n  cellValueType?: CellValueType;\n  onChange?: (options: Partial<IUserFieldOptions>) => void;\n}) => {\n  const { options = {}, isLookup, onChange } = props;\n  const { isMultiple, shouldNotify } = options;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const baseId = useBaseId();\n\n  // TODO: Here is just to get the complete information of the user selected by defaultValue, only need to provide the interface to query by userId.\n  const { data: collaboratorsData, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.baseCollaboratorListUser(baseId as string, {\n      includeSystem: true,\n      skip: 0,\n      take: 1000,\n    }),\n    queryFn: ({ queryKey }) =>\n      getUserCollaborators(queryKey[1], queryKey[2]).then((res) => res.data),\n  });\n  const collaborators = collaboratorsData?.users;\n\n  const onIsMultipleChange = (checked: boolean) => {\n    onChange?.({\n      isMultiple: checked,\n    });\n  };\n\n  const onShouldNotifyChange = (checked: boolean) => {\n    onChange?.({\n      shouldNotify: checked,\n    });\n  };\n\n  const onDefaultValueChange = (defaultValue: IUserCellValue | IUserCellValue[] | undefined) => {\n    const value = Array.isArray(defaultValue) ? defaultValue.map((v) => v.id) : defaultValue?.id;\n    onChange?.({\n      defaultValue: value ?? null,\n    });\n  };\n\n  const defaultValueToUser = (\n    options: IUserFieldOptions\n  ): IUserCellValue | IUserCellValue[] | undefined => {\n    if (!options.defaultValue || !collaborators) return undefined;\n    const userMap = keyBy<{\n      id: string;\n      name: string;\n      email: string;\n      avatar?: string | null;\n    }>(collaborators, 'id');\n    userMap['me'] = {\n      name: t('sdk:filter.currentUser'),\n      id: 'me',\n      email: '',\n    };\n    const { defaultValue, isMultiple } = options;\n    const values = [defaultValue].flat();\n    if (isMultiple) {\n      return values\n        .filter((id) => userMap[id])\n        .map((id) => ({\n          title: userMap[id].name,\n          id: userMap[id].id,\n          email: userMap[id].email,\n          avatarUrl: userMap[id].avatar,\n        }));\n    }\n\n    const user = userMap[values[0]];\n    if (!user) return undefined;\n    return {\n      title: user.name,\n      id: user.id,\n      email: user.email,\n      avatarUrl: user.avatar,\n    };\n  };\n\n  return (\n    <div className=\"form-control border-bordr space-y-4 border-t pt-4\">\n      {!isLookup && (\n        <div className=\"space-y-4\">\n          <div className=\"flex w-full flex-col gap-2\">\n            <div className=\"flex h-8 items-center space-x-2\">\n              <Switch\n                id=\"field-options-is-multiple\"\n                checked={Boolean(isMultiple)}\n                onCheckedChange={onIsMultipleChange}\n              />\n              <Label htmlFor=\"field-options-is-multiple\" className=\"font-normal\">\n                {t('table:field.editor.allowMultiUsers')}\n              </Label>\n            </div>\n            <div className=\"flex h-8 items-center space-x-2\">\n              <Switch\n                id=\"field-options-should-notify\"\n                checked={Boolean(shouldNotify)}\n                onCheckedChange={onShouldNotifyChange}\n              />\n              <Label htmlFor=\"field-options-should-notify\" className=\"font-normal\">\n                {t('table:field.editor.notifyUsers')}\n              </Label>\n            </div>\n          </div>\n          {!isLoading && (\n            <div className=\"border-t pt-4\">\n              <DefaultValue onReset={() => onDefaultValueChange(undefined)}>\n                <UserEditor\n                  value={defaultValueToUser(options)}\n                  onChange={onDefaultValueChange}\n                  options={options}\n                  includeMe\n                />\n              </DefaultValue>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/show-as/MultiNumberShowAs.tsx",
    "content": "import { ColorUtils, Colors, MultiNumberDisplayType } from '@teable/core';\nimport type { IMultiNumberShowAs } from '@teable/core';\nimport {\n  Button,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  cn,\n  Tabs,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { ColorPicker } from '../options/SelectOptions';\n\nconst numberFlag = 'Number';\n\nconst defaultShowAsProps = {\n  color: Colors.TealBright,\n};\n\ninterface IMultiNumberShowAsProps {\n  showAs?: IMultiNumberShowAs;\n  onChange?: (showAs?: IMultiNumberShowAs) => void;\n}\n\nexport const MultiNumberShowAs: React.FC<IMultiNumberShowAsProps> = (props) => {\n  const { showAs, onChange } = props;\n  const { type, color } = (showAs || {}) as IMultiNumberShowAs;\n  const selectedType = showAs == null ? numberFlag : type;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const MULTI_NUMBER_DISPLAY_INFOS = [\n    {\n      type: numberFlag,\n      text: t('table:field.editor.number'),\n    },\n    {\n      type: MultiNumberDisplayType.Bar,\n      text: t('table:field.editor.chartBar'),\n    },\n    {\n      type: MultiNumberDisplayType.Line,\n      text: t('table:field.editor.chartLine'),\n    },\n  ];\n\n  const updateDisplayType = (type: string) => {\n    const newShowAs =\n      type === numberFlag\n        ? undefined\n        : {\n            ...defaultShowAsProps,\n            ...showAs,\n            type,\n          };\n    onChange?.(newShowAs as IMultiNumberShowAs);\n  };\n\n  const updateColor = (color: Colors) => {\n    if (showAs == null) return;\n    onChange?.({\n      ...showAs,\n      color,\n    });\n  };\n\n  return (\n    <div className=\"flex w-full flex-col gap-2\" data-testid=\"multi-number-show-as\">\n      <Label className=\"font-normal\">{t('table:field.editor.showAs')}</Label>\n      <Tabs value={selectedType} onValueChange={updateDisplayType} className=\"w-full\">\n        <TabsList className=\"flex w-full  gap-2\">\n          {MULTI_NUMBER_DISPLAY_INFOS.map(({ type, text }) => (\n            <TabsTrigger key={type} value={type} className=\"flex-1 font-normal\">\n              {text}\n            </TabsTrigger>\n          ))}\n        </TabsList>\n      </Tabs>\n\n      {showAs != null && (\n        <div className=\"flex h-8 items-center gap-2\">\n          <Popover>\n            <PopoverTrigger>\n              <div\n                className=\"ml-4 size-5 rounded-full p-[2px]\"\n                style={{ border: `1px solid ${ColorUtils.getHexForColor(color)}` }}\n              >\n                <div\n                  className=\"size-full rounded-full\"\n                  style={{ backgroundColor: ColorUtils.getHexForColor(color) }}\n                />\n              </div>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-auto\">\n              <ColorPicker color={color} onSelect={updateColor} />\n            </PopoverContent>\n          </Popover>\n          <Label className=\"font-normal\">{t('table:field.editor.color')}</Label>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/show-as/SingleLineTextShowAs.tsx",
    "content": "import { SingleLineTextDisplayType } from '@teable/core';\nimport type { ISingleLineTextShowAs } from '@teable/core';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport { Tabs, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn/ui/tabs';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nconst textFlag = 'text';\n\ninterface ISingleNumberShowAsProps {\n  showAs?: ISingleLineTextShowAs;\n  onChange?: (showAs?: ISingleLineTextShowAs) => void;\n}\n\nexport const SingleTextLineShowAs: React.FC<ISingleNumberShowAsProps> = (props) => {\n  const { showAs, onChange } = props;\n  const { type } = (showAs || {}) as ISingleLineTextShowAs;\n  const selectedType = showAs == null ? textFlag : type;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const updateDisplayType = (type: string) => {\n    const newShowAs =\n      type === textFlag\n        ? undefined\n        : {\n            ...showAs,\n            type,\n          };\n    onChange?.(newShowAs as ISingleLineTextShowAs);\n  };\n\n  const SINGLE_LINE_TEXT_DISPLAY_INFOS = [\n    {\n      type: textFlag,\n      text: t('table:field.editor.text'),\n    },\n    {\n      type: SingleLineTextDisplayType.Url,\n      text: t('table:field.editor.url'),\n    },\n    {\n      type: SingleLineTextDisplayType.Email,\n      text: t('table:field.editor.email'),\n    },\n    {\n      type: SingleLineTextDisplayType.Phone,\n      text: t('table:field.editor.phone'),\n    },\n  ];\n\n  return (\n    <div className=\"flex w-full flex-col gap-2\" data-testid=\"text-show-as\">\n      <Label className=\"text-sm font-medium\">{t('table:field.editor.showAs')}</Label>\n      <Tabs value={selectedType} onValueChange={updateDisplayType} className=\"w-full\">\n        <TabsList className=\"flex w-full gap-2\">\n          {SINGLE_LINE_TEXT_DISPLAY_INFOS.map(({ type, text }) => (\n            <TabsTrigger key={type} value={type} className=\"flex-1 font-normal\">\n              {text}\n            </TabsTrigger>\n          ))}\n        </TabsList>\n      </Tabs>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/show-as/SingleNumberShowAs.tsx",
    "content": "import { ColorUtils, Colors, SingleNumberDisplayType } from '@teable/core';\nimport type { ISingleNumberShowAs } from '@teable/core';\nimport {\n  Input,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n  Switch,\n  cn,\n  Tabs,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Label } from '@teable/ui-lib/shadcn/ui/label';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { ColorPicker } from '../options/SelectOptions';\n\nconst numberFlag = 'Number';\n\nconst defaultShowAsProps = {\n  color: Colors.TealBright,\n  maxValue: 100,\n  showValue: true,\n};\n\ninterface ISingleNumberShowAsProps {\n  showAs?: ISingleNumberShowAs;\n  onChange?: (showAs?: ISingleNumberShowAs) => void;\n}\n\nexport const SingleNumberShowAs: React.FC<ISingleNumberShowAsProps> = (props) => {\n  const { showAs, onChange } = props;\n  const { type, color, maxValue, showValue } = (showAs || {}) as ISingleNumberShowAs;\n  const selectedType = showAs == null ? numberFlag : type;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  const updateDisplayType = (type: string) => {\n    const newShowAs =\n      type === numberFlag\n        ? undefined\n        : {\n            ...defaultShowAsProps,\n            ...showAs,\n            type,\n          };\n    onChange?.(newShowAs as ISingleNumberShowAs);\n  };\n\n  const updateColor = (color: Colors) => {\n    if (showAs == null) return;\n    onChange?.({\n      ...showAs,\n      color,\n    });\n  };\n\n  const updateMaxValue = (e: React.ChangeEvent<HTMLInputElement>) => {\n    if (showAs == null) return;\n    const stringValue = e.target.value;\n    const maxValue = stringValue === '' ? 0 : Number(stringValue);\n\n    onChange?.({\n      ...showAs,\n      maxValue,\n    });\n  };\n\n  const updateShowValue = (checked: boolean) => {\n    if (showAs == null) return;\n    onChange?.({\n      ...showAs,\n      showValue: checked,\n    });\n  };\n\n  const SINGLE_NUMBER_DISPLAY_INFOS = [\n    {\n      type: numberFlag,\n      text: t('table:field.editor.number'),\n    },\n    {\n      type: SingleNumberDisplayType.Ring,\n      text: t('table:field.editor.ring'),\n    },\n    {\n      type: SingleNumberDisplayType.Bar,\n      text: t('table:field.editor.bar'),\n    },\n  ];\n\n  return (\n    <div className=\"flex w-full flex-col gap-4\">\n      <div className=\"flex w-full flex-col gap-2\" data-testid=\"single-number-show-as\">\n        <Label className=\"font-meidum text-sm\">{t('table:field.editor.showAs')}</Label>\n        <Tabs value={selectedType} onValueChange={updateDisplayType} className=\"w-full\">\n          <TabsList className=\"flex w-full  gap-2\">\n            {SINGLE_NUMBER_DISPLAY_INFOS.map(({ type, text }) => (\n              <TabsTrigger key={type} value={type} className=\"flex-1 font-normal\">\n                {text}\n              </TabsTrigger>\n            ))}\n          </TabsList>\n        </Tabs>\n      </div>{' '}\n      {showAs != null && (\n        <>\n          <div className=\"flex w-full flex-col gap-2\">\n            <Label className=\"font-meidum text-sm\">{t('table:field.editor.maxNumber')}</Label>\n            <Input defaultValue={maxValue} onChange={updateMaxValue} size=\"lg\" />\n          </div>\n\n          <div className=\"flex h-8 items-center gap-2\">\n            <Switch\n              className=\"h-5 w-9\"\n              classNameThumb=\"w-4 h-4 data-[state=checked]:translate-x-4\"\n              checked={Boolean(showValue)}\n              onCheckedChange={updateShowValue}\n            />\n            <Label className=\"text-sm font-normal\">{t('table:field.editor.showNumber')}</Label>\n          </div>\n\n          <div className=\"flex h-8 items-center gap-2\">\n            <Popover>\n              <PopoverTrigger>\n                <div\n                  className=\"size-5 rounded-full p-[2px]\"\n                  style={{ border: `1px solid ${ColorUtils.getHexForColor(color)}` }}\n                >\n                  <div\n                    className=\"size-full rounded-full\"\n                    style={{ backgroundColor: ColorUtils.getHexForColor(color) }}\n                  />\n                </div>\n              </PopoverTrigger>\n              <PopoverContent className=\"w-auto\">\n                <ColorPicker color={color} onSelect={updateColor} />\n              </PopoverContent>\n            </Popover>\n            <Label className=\"text-sm font-normal\">{t('table:field.editor.color')}</Label>\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/show-as/UnionShowAs.tsx",
    "content": "import type { IUnionShowAs } from '@teable/core';\nimport { CellValueType } from '@teable/core';\nimport type { FC } from 'react';\nimport { useMemo } from 'react';\nimport { MultiNumberShowAs } from './MultiNumberShowAs';\nimport { SingleTextLineShowAs } from './SingleLineTextShowAs';\nimport { SingleNumberShowAs } from './SingleNumberShowAs';\n\ninterface IUnionShowAsProps {\n  showAs?: IUnionShowAs;\n  cellValueType?: CellValueType;\n  isMultipleCellValue?: boolean;\n  onChange?: (showAs?: IUnionShowAs) => void;\n}\n\nexport const UnionShowAs: FC<IUnionShowAsProps> = (props) => {\n  const { showAs, cellValueType, isMultipleCellValue, onChange } = props;\n\n  const ShowAsComponent = useMemo(() => {\n    if (cellValueType === CellValueType.Number) {\n      return isMultipleCellValue ? MultiNumberShowAs : SingleNumberShowAs;\n    }\n    if (cellValueType === CellValueType.String) {\n      return SingleTextLineShowAs;\n    }\n    return null;\n  }, [cellValueType, isMultipleCellValue]);\n\n  if (!ShowAsComponent) {\n    return null;\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  return <ShowAsComponent showAs={showAs as any} onChange={onChange} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/type.ts",
    "content": "import type { CellValueType, IFieldRo, IFieldVo } from '@teable/core';\n\nexport enum FieldOperator {\n  Add = 'add',\n  Edit = 'edit',\n  Insert = 'insert',\n}\n\nexport interface IFieldSetting {\n  visible?: boolean;\n  order?: number;\n  field?: IFieldVo;\n  operator: FieldOperator;\n  onConfirm?: (field?: IFieldVo) => void;\n  onCancel?: () => void;\n}\n\nexport type IFieldSettingBase = Omit<IFieldSetting, 'onConfirm'> & {\n  onConfirm?: (field?: IFieldRo) => void;\n};\n\nexport type IFieldEditorRo = Partial<IFieldRo> & {\n  cellValueType?: CellValueType;\n  isMultipleCellValue?: boolean;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts",
    "content": "import { assertNever, FieldType } from '@teable/core';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\n\nexport const useFieldTypeSubtitle = () => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n\n  return useCallback(\n    (fieldType: FieldType): string => {\n      switch (fieldType) {\n        case FieldType.Link:\n          return t('table:field.subTitle.link');\n        case FieldType.SingleLineText:\n          return t('table:field.subTitle.singleLineText');\n        case FieldType.LongText:\n          return t('table:field.subTitle.longText');\n        case FieldType.Attachment:\n          return t('table:field.subTitle.attachment');\n        case FieldType.Checkbox:\n          return t('table:field.subTitle.checkbox');\n        case FieldType.MultipleSelect:\n          return t('table:field.subTitle.multipleSelect');\n        case FieldType.SingleSelect:\n          return t('table:field.subTitle.singleSelect');\n        case FieldType.User:\n          return t('table:field.subTitle.user');\n        case FieldType.Date:\n          return t('table:field.subTitle.date');\n        case FieldType.Number:\n          return t('table:field.subTitle.number');\n        case FieldType.Rating:\n          return t('table:field.subTitle.rating');\n        case FieldType.Formula:\n          return t('table:field.subTitle.formula');\n        case FieldType.Rollup:\n          return t('table:field.subTitle.rollup');\n        case FieldType.ConditionalRollup:\n          return t('table:field.subTitle.conditionalRollup');\n        case FieldType.CreatedTime:\n          return t('table:field.subTitle.createdTime');\n        case FieldType.LastModifiedTime:\n          return t('table:field.subTitle.lastModifiedTime');\n        case FieldType.CreatedBy:\n          return t('table:field.subTitle.createdBy');\n        case FieldType.LastModifiedBy:\n          return t('table:field.subTitle.lastModifiedBy');\n        case FieldType.AutoNumber:\n          return t('table:field.subTitle.autoNumber');\n        case FieldType.Button:\n          return t('table:field.subTitle.button');\n        default: {\n          assertNever(fieldType);\n        }\n      }\n    },\n    [t]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/index.ts",
    "content": "export {};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/NotificationActionBar.tsx",
    "content": "import { NotificationStatesEnum } from '@teable/core';\nimport { CheckSquare, MarkUnread } from '@teable/icons';\nimport {\n  Button,\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\n\ninterface ActionBarProps {\n  notifyStatus: NotificationStatesEnum;\n  onStatusCheck?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  children: React.ReactNode;\n  commonHandler: () => Promise<void>;\n}\n\nexport const NotificationActionBar: React.FC<ActionBarProps> = (props) => {\n  const { notifyStatus, children, onStatusCheck, commonHandler } = props;\n  const { t } = useTranslation('common');\n\n  return (\n    <HoverCard openDelay={100} closeDelay={0}>\n      <HoverCardTrigger\n        onClick={async () => {\n          await commonHandler();\n        }}\n      >\n        {children}\n      </HoverCardTrigger>\n      <HoverCardContent\n        className=\"size-auto p-0\"\n        sideOffset={-35}\n        alignOffset={16}\n        side=\"top\"\n        align=\"end\"\n      >\n        <div className=\"flex p-0.5\">\n          <div className=\"inline-flex size-6 cursor-pointer items-center justify-center rounded hover:bg-secondary\">\n            <TooltipProvider>\n              <Tooltip delayDuration={20}>\n                <TooltipTrigger asChild>\n                  <Button\n                    className=\"size-full p-0\"\n                    variant=\"ghost\"\n                    onClick={(e) => onStatusCheck?.(e)}\n                  >\n                    {notifyStatus === NotificationStatesEnum.Unread ? (\n                      <CheckSquare className=\"text-sm\" />\n                    ) : (\n                      <MarkUnread />\n                    )}\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent side=\"top\" align=\"center\" sideOffset={10}>\n                  {t('notification.markAs', {\n                    status:\n                      notifyStatus === NotificationStatesEnum.Unread\n                        ? t('notification.read')\n                        : t('notification.unread'),\n                  })}\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          </div>\n          {/* <div className=\"inline-flex size-6 cursor-pointer items-center justify-center rounded hover:bg-secondary\">\n            <TooltipProvider>\n              <Tooltip delayDuration={20}>\n                <TooltipTrigger asChild>\n                  <Button className=\"size-full p-0\" variant=\"ghost\">\n                    <MoreHorizontal />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent side=\"top\" align=\"center\" sideOffset={10}>\n                  {t('notification.changeSetting')}\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          </div> */}\n        </div>\n      </HoverCardContent>\n    </HoverCard>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/NotificationIcon.tsx",
    "content": "import type {\n  INotificationIcon,\n  INotificationSystemIcon,\n  INotificationUserIcon,\n} from '@teable/core';\nimport { NotificationTypeEnum } from '@teable/core';\nimport { Avatar, AvatarFallback, AvatarImage } from '@teable/ui-lib';\nimport React, { useCallback } from 'react';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\n\ninterface NotificationIconProps {\n  notifyIcon: INotificationIcon;\n  notifyType: NotificationTypeEnum;\n}\n\nconst NotificationIcon = (props: NotificationIconProps) => {\n  const { notifyIcon, notifyType } = props;\n\n  const dynamicComponent = useCallback(() => {\n    switch (notifyType) {\n      case NotificationTypeEnum.ExportBase:\n      case NotificationTypeEnum.System: {\n        const { iconUrl } = notifyIcon as INotificationSystemIcon;\n\n        return (\n          <Avatar className=\"size-9 overflow-visible\">\n            {iconUrl && <AvatarImage src={iconUrl} alt=\"System\" />}\n            <AvatarFallback>{'System'.slice(0, 1)}</AvatarFallback>\n          </Avatar>\n        );\n      }\n      case NotificationTypeEnum.Comment:\n      case NotificationTypeEnum.CollaboratorCellTag:\n      case NotificationTypeEnum.CollaboratorMultiRowTag: {\n        const { userAvatarUrl, userName } = notifyIcon as INotificationUserIcon;\n        return <UserAvatar className=\"size-9\" user={{ name: userName, avatar: userAvatarUrl }} />;\n      }\n    }\n  }, [notifyIcon, notifyType]);\n  return (\n    <div className=\"relative flex flex-none items-center self-start pr-2\">{dynamicComponent()}</div>\n  );\n};\n\nexport { NotificationIcon };\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx",
    "content": "import { type NotificationStatesEnum } from '@teable/core';\nimport type { INotificationVo } from '@teable/openapi';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { LinkNotification } from './notification-component';\nimport { NotificationIcon } from './NotificationIcon';\n\ninterface INotificationItemProps {\n  data: INotificationVo['notifications'][number];\n  notifyStatus: NotificationStatesEnum;\n}\n\nexport const NotificationItem = (props: INotificationItemProps) => {\n  const { data, notifyStatus } = props;\n\n  const { notifyIcon, notifyType, createdTime } = data;\n\n  const dayjs = useLanDayjs();\n\n  const fromNow = dayjs(createdTime).fromNow();\n\n  return (\n    <div className=\"m-1 flex flex-auto cursor-pointer items-center rounded-sm px-6 py-2 hover:bg-accent\">\n      <NotificationIcon notifyIcon={notifyIcon} notifyType={notifyType} />\n\n      <div className=\"mr-3 w-full items-center overflow-hidden whitespace-pre-wrap break-words text-sm font-normal\">\n        <div className=\"overflow-auto\">\n          <LinkNotification data={data} notifyStatus={notifyStatus} />\n        </div>\n\n        <div className=\"truncate text-[11px] opacity-75\" title={fromNow}>\n          {fromNow}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/NotificationList.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { NotificationStatesEnum } from '@teable/core';\nimport { Inbox } from '@teable/icons';\nimport type { INotificationVo } from '@teable/openapi';\nimport { updateNotificationStatus } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config/react-query-keys';\nimport { Button } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { NotificationActionBar } from './NotificationActionBar';\nimport { NotificationItem } from './NotificationItem';\n\ninterface NotificationListProps {\n  notifyStatus: NotificationStatesEnum;\n  data?: INotificationVo[];\n  className?: string;\n\n  hasNextPage?: boolean;\n  isFetchingNextPage?: boolean;\n  onShowMoreClick?: () => void;\n}\n\nexport const NotificationList: React.FC<NotificationListProps> = (props) => {\n  const { notifyStatus, data, className, hasNextPage, isFetchingNextPage, onShowMoreClick } = props;\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n\n  const { mutateAsync: updateStatusMutator } = useMutation({\n    mutationFn: updateNotificationStatus,\n    onSuccess: async () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.notifyUnreadCount() });\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.notifyList({ status: notifyStatus }),\n      });\n    },\n  });\n\n  const commonHandler = async (notificationId: string, isRead: boolean) => {\n    if (isRead) {\n      await updateStatusMutator({\n        notificationId,\n        updateNotifyStatusRo: { isRead },\n      });\n    }\n  };\n\n  return (\n    <div className={className}>\n      {!data || !data[0].notifications?.length ? (\n        <div className=\"p-6\">\n          <div className=\"flex items-center justify-center text-5xl font-normal\">\n            <Inbox />\n          </div>\n          <p className=\"mt-4 text-center text-sm text-muted-foreground\">\n            {t('notification.noUnread', {\n              status:\n                notifyStatus === NotificationStatesEnum.Read\n                  ? t('notification.read')\n                  : t('notification.unread'),\n            })}\n          </p>\n        </div>\n      ) : (\n        <>\n          {data?.map(({ notifications }) => {\n            return notifications.map((notification) => {\n              const { id, isRead } = notification;\n              return (\n                <NotificationActionBar\n                  key={id}\n                  notifyStatus={notifyStatus}\n                  onStatusCheck={(e) => {\n                    e.stopPropagation();\n                    updateStatusMutator({\n                      notificationId: id,\n                      updateNotifyStatusRo: { isRead: !isRead },\n                    });\n                  }}\n                  commonHandler={() => commonHandler(id, !isRead)}\n                >\n                  <NotificationItem data={notification} notifyStatus={notifyStatus} />\n                </NotificationActionBar>\n              );\n            });\n          })}\n          {hasNextPage && (\n            <Button\n              variant=\"ghost\"\n              size={'xs'}\n              className=\"flex w-full p-2 text-center text-[11px] opacity-75\"\n              onClick={onShowMoreClick}\n              disabled={!hasNextPage || isFetchingNextPage}\n            >\n              {t('notification.showMore')}\n            </Button>\n          )}\n        </>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx",
    "content": "import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { INotificationIcon } from '@teable/core';\nimport { NotificationStatesEnum, NotificationTypeEnum } from '@teable/core';\nimport { Bell, CheckCircle2 as Read, Download, RefreshCcw } from '@teable/icons';\nimport {\n  getNotificationList,\n  getNotificationUnreadCount,\n  notificationReadAll,\n} from '@teable/openapi';\nimport { useNotification } from '@teable/sdk';\nimport { ReactQueryKeys } from '@teable/sdk/config/react-query-keys';\nimport { Button, Popover, PopoverContent, PopoverTrigger } from '@teable/ui-lib';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport type { TFunction } from 'next-i18next';\nimport React, { useEffect, useState } from 'react';\nimport { LinkNotification } from './notification-component';\nimport { NotificationIcon } from './NotificationIcon';\nimport { NotificationList } from './NotificationList';\n\nconst SHOWN_NOTIFICATIONS_LIMIT = 100;\nconst shownNotificationIds = new Set<string>();\n\nconst showExportBaseToast = (\n  notification: {\n    url?: string | null;\n    messageI18n?: string | null;\n    notifyIcon: INotificationIcon;\n    notifyType: NotificationTypeEnum;\n  },\n  toastId: string,\n  t: TFunction\n) => {\n  const { url, messageI18n } = notification;\n  let fileName = '';\n  let downloadUrl = url || '';\n  let isSuccess = true;\n  try {\n    const parsed = JSON.parse(messageI18n || '{}');\n    fileName = parsed?.context?.name || parsed?.context?.baseName || '';\n    isSuccess = !parsed?.i18nKey?.includes('failed');\n    if (!downloadUrl) {\n      downloadUrl = parsed?.context?.previewUrl || '';\n    }\n  } catch {\n    // ignore\n  }\n\n  const toastFn = isSuccess ? toast : toast.error;\n  const titleKey = isSuccess\n    ? 'notification.exportBase.successText'\n    : 'notification.exportBase.failedText';\n  toastFn(\n    <div className=\"flex w-full items-center gap-2\">\n      <NotificationIcon notifyIcon={notification.notifyIcon} notifyType={notification.notifyType} />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n        <div className=\"truncate text-sm font-medium\">{t(titleKey)}</div>\n        {fileName && <div className=\"truncate text-xs text-muted-foreground\">{fileName}</div>}\n      </div>\n      {isSuccess && downloadUrl && (\n        <a href={downloadUrl} download className=\"ml-auto\">\n          <Button variant=\"default\" size=\"xs\" className=\"shrink-0 gap-1\">\n            <Download className=\"size-4\" />\n            {t('actions.download')}\n          </Button>\n        </a>\n      )}\n    </div>,\n    {\n      id: toastId,\n      position: 'top-center',\n      duration: 1000 * 3,\n      closeButton: !isSuccess,\n    }\n  );\n};\n\nexport const NotificationsManage: React.FC = () => {\n  const queryClient = useQueryClient();\n  const notification = useNotification();\n  const { t } = useTranslation('common');\n\n  const [isOpen, setOpen] = useState(false);\n  const [unreadCount, setUnreadCount] = useState<number>(0);\n\n  const [newUnreadCount, setNewUnreadCount] = useState<number | undefined>(undefined);\n\n  const [notifyStatus, setNotifyStatus] = useState(NotificationStatesEnum.Unread);\n\n  const { data: queryUnreadCount = 0 } = useQuery({\n    queryKey: ReactQueryKeys.notifyUnreadCount(),\n    queryFn: () => getNotificationUnreadCount().then(({ data }) => data.unreadCount),\n  });\n\n  useEffect(() => {\n    if (notification?.unreadCount == null) return;\n\n    setNewUnreadCount(notification.unreadCount);\n  }, [notification?.unreadCount]);\n\n  useEffect(() => {\n    setUnreadCount(newUnreadCount ?? queryUnreadCount);\n  }, [newUnreadCount, queryUnreadCount]);\n\n  useEffect(() => {\n    if (notification?.notification == null) return;\n    if (notification.notification.isRead) return;\n\n    const notificationId = notification.notification.id;\n    if (shownNotificationIds.has(notificationId)) return;\n    if (shownNotificationIds.size >= SHOWN_NOTIFICATIONS_LIMIT) {\n      shownNotificationIds.clear();\n    }\n    shownNotificationIds.add(notificationId);\n\n    const isCreditNotification =\n      notification.notification.messageI18n?.includes('creditExhausted') ||\n      notification.notification.messageI18n?.includes('insufficientCredit');\n    const toastId = isCreditNotification ? 'credit-exhausted-notification' : notificationId;\n\n    if (notification.notification.notifyType === NotificationTypeEnum.ExportBase) {\n      showExportBaseToast(notification.notification, toastId, t);\n    } else {\n      toast.info(\n        <div className=\"flex items-center\">\n          <NotificationIcon\n            notifyIcon={notification.notification.notifyIcon}\n            notifyType={notification.notification.notifyType}\n          />\n          <LinkNotification\n            data={notification.notification}\n            notifyStatus={NotificationStatesEnum.Unread}\n          />\n        </div>,\n        {\n          id: toastId,\n          position: 'top-center',\n          duration: 1000 * 3,\n          closeButton: true,\n        }\n      );\n    }\n  }, [notification?.notification, t]);\n\n  const {\n    data: notifyPage,\n    fetchNextPage,\n    hasNextPage,\n    isFetchingNextPage,\n  } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.notifyList({ status: notifyStatus }),\n    queryFn: ({ pageParam }) =>\n      getNotificationList({ notifyStates: notifyStatus, cursor: pageParam }).then(\n        ({ data }) => data\n      ),\n    initialPageParam: undefined as string | undefined,\n    getNextPageParam: (lastPage) => lastPage.nextCursor,\n    enabled: isOpen,\n    staleTime: 0,\n  });\n\n  const { mutateAsync: markAllAsReadMutator } = useMutation({\n    mutationFn: notificationReadAll,\n    onSuccess: () => {\n      refresh();\n    },\n  });\n\n  const refresh = () => {\n    setNewUnreadCount(undefined);\n    queryClient.invalidateQueries({ queryKey: ReactQueryKeys.notifyUnreadCount() });\n    queryClient.resetQueries({\n      queryKey: ReactQueryKeys.notifyList({ status: notifyStatus }),\n      exact: true,\n    });\n  };\n\n  const renderNewButton = () => {\n    if (!newUnreadCount) return;\n\n    const num = newUnreadCount - queryUnreadCount;\n\n    if (num < 1) return;\n    return (\n      <div>\n        <Button\n          variant=\"outline\"\n          size=\"xs\"\n          onClick={() => {\n            refresh();\n          }}\n        >\n          <RefreshCcw className=\"size-4 shrink-0\" />\n          <p>{t('notification.new', { count: num })}</p>\n        </Button>\n      </div>\n    );\n  };\n\n  return (\n    <Popover onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size={'xs'}\n          className=\"relative \"\n          onClick={() => {\n            setNotifyStatus(NotificationStatesEnum.Unread);\n            refresh();\n          }}\n        >\n          <Bell className=\"size-5 shrink-0\" />\n          {unreadCount > 0 ? (\n            <span className=\"absolute right-2.5 top-1 inline-flex -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-red-400 p-1 text-[8px] leading-none text-white\">\n              {unreadCount}\n            </span>\n          ) : (\n            ''\n          )}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"left\" align=\"end\" className=\"min-w-[500px] p-0\">\n        <div className=\"w-full\">\n          <div className=\"flex items-center justify-between border-b border-border-high p-4\">\n            <div className=\"text-base font-semibold\">{t('notification.title')}</div>\n            {renderNewButton()}\n            <div>\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                className={cn('ml-2', {\n                  'bg-accent': notifyStatus === NotificationStatesEnum.Unread,\n                })}\n                onClick={() => setNotifyStatus(NotificationStatesEnum.Unread)}\n              >\n                {t('notification.title')}\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                className={cn('ml-2', {\n                  'bg-accent': notifyStatus === NotificationStatesEnum.Read,\n                })}\n                onClick={() => setNotifyStatus(NotificationStatesEnum.Read)}\n              >\n                {t('notification.read')}\n              </Button>\n            </div>\n          </div>\n          <NotificationList\n            className=\"relative max-h-[78vh] overflow-auto\"\n            notifyStatus={notifyStatus}\n            data={notifyPage?.pages}\n            hasNextPage={hasNextPage}\n            isFetchingNextPage={isFetchingNextPage}\n            onShowMoreClick={() => fetchNextPage()}\n          />\n          {notifyStatus === NotificationStatesEnum.Unread ? (\n            <div className=\"my-1.5 flex justify-end\">\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                className=\"mr-2\"\n                disabled={unreadCount < 1}\n                onClick={() => {\n                  markAllAsReadMutator();\n                }}\n              >\n                <Read />\n                {t('notification.markAllAsRead')}\n              </Button>\n            </div>\n          ) : (\n            ''\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx",
    "content": "import { NotificationTypeEnum } from '@teable/core';\nimport type { ILocalization, NotificationStatesEnum } from '@teable/core';\nimport { type INotificationVo } from '@teable/openapi';\nimport { getLocalizationMessage } from '@teable/sdk/context';\nimport type { ILocaleFunction } from '@teable/sdk/context/app/i18n';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\n\ninterface LinkNotificationProps {\n  data: INotificationVo['notifications'][number];\n  notifyStatus: NotificationStatesEnum;\n}\n\nconst getShowMessage = (data: INotificationVo['notifications'][number], t: ILocaleFunction) => {\n  const { message, messageI18n } = data;\n  try {\n    if (!messageI18n) {\n      return message;\n    }\n    const parsedMessage = JSON.parse(messageI18n);\n    const { i18nKey = '', context = {} } = parsedMessage as ILocalization;\n    if (!i18nKey) {\n      return message;\n    }\n    return getLocalizationMessage({ i18nKey, context: { spaceName: '', ...context } }, t, 'common');\n  } catch (error) {\n    return message;\n  }\n};\n\nexport const LinkNotification = (props: LinkNotificationProps) => {\n  const {\n    data,\n    data: { url, notifyType },\n  } = props;\n\n  const { t } = useTranslation(['common']);\n  const message = getShowMessage(data, t as ILocaleFunction);\n\n  // When the message contains inner <a> links (e.g. error report download),\n  // we need to stop the click from bubbling up to the parent <Link> which\n  // would navigate to the table URL instead.\n  const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    const target = e.target as HTMLElement;\n    if (target.tagName === 'A' || target.closest('a')) {\n      e.stopPropagation();\n      e.preventDefault();\n      const anchor = (target.tagName === 'A' ? target : target.closest('a')) as HTMLAnchorElement;\n      if (anchor?.href) {\n        window.open(anchor.href, anchor.target || '_blank', 'noopener,noreferrer');\n      }\n    }\n  };\n\n  return notifyType !== NotificationTypeEnum.ExportBase ? (\n    <Link href={url}>\n      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}\n      <div\n        className=\"max-h-20 overflow-auto break-words\"\n        dangerouslySetInnerHTML={{ __html: message }}\n        onClick={handleContentClick}\n      />\n    </Link>\n  ) : (\n    <>\n      <div\n        className=\"max-h-20 overflow-auto break-words\"\n        dangerouslySetInnerHTML={{ __html: message }}\n      />\n      {/* do not delete this div for tailwind css */}\n      <div className=\"hidden underline hover:text-blue-500\"></div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/notifications/notification-component/index.ts",
    "content": "export * from './LinkNotification';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/oauth/OAuthLogo.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { usePreviewUrl } from '../../hooks/usePreviewUrl';\n\nexport const OAuthLogo = (props: { logo?: string; name: string; className?: string }) => {\n  const { logo, name, className } = props;\n\n  const getPreviewUrl = usePreviewUrl();\n  return (\n    <div className={cn('relative size-16 overflow-hidden rounded-sm', className)}>\n      {logo ? (\n        <img\n          src={getPreviewUrl(logo)}\n          alt={name}\n          className=\"absolute inset-0 size-full object-contain\"\n        />\n      ) : (\n        <TeableLogo className={cn('size-16', className)} />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/oauth/OAuthScope.tsx",
    "content": "import type { Action } from '@teable/core';\nimport { ActionPrefix } from '@teable/core';\nimport { Database, Hash, Sheet, Table2, User } from '@teable/icons';\nimport { usePermissionActionsStatic } from '@teable/sdk/hooks';\nimport { Badge, cn } from '@teable/ui-lib/shadcn';\nimport { AppWindowMac, Bot, List } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { useMemo } from 'react';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst IconMap: Partial<Record<ActionPrefix, React.JSXElementConstructor<any>>> = {\n  [ActionPrefix.App]: AppWindowMac,\n  [ActionPrefix.Base]: Database,\n  [ActionPrefix.Table]: Table2,\n  [ActionPrefix.Field]: Hash,\n  [ActionPrefix.Record]: List,\n  [ActionPrefix.View]: Sheet,\n  [ActionPrefix.Automation]: Bot,\n  [ActionPrefix.User]: User,\n};\n\nexport const OAuthScope = (props: {\n  scopes?: string[];\n  description?: string | ReactNode;\n  className?: string;\n}) => {\n  const { scopes, description, className } = props;\n  const { actionPrefixStaticMap, actionStaticMap } = usePermissionActionsStatic();\n\n  const scopeMap = useMemo(\n    () =>\n      (scopes || []).reduce(\n        (acc, scope) => {\n          if (!actionStaticMap) {\n            return acc;\n          }\n          const prefix = scope.split('|')[0] as ActionPrefix;\n          const scopeDesc = actionStaticMap[scope as Action].description;\n          if (acc[prefix]) {\n            acc[prefix].push(scopeDesc);\n          } else {\n            acc[prefix] = [scopeDesc];\n          }\n          return acc;\n        },\n        {} as Record<ActionPrefix, string[]>\n      ),\n    [actionStaticMap, scopes]\n  );\n  return (\n    <div className={cn('space-y-3 px-8', className)}>\n      {description && typeof description === 'string' ? (\n        <div className=\"text-center\">{description}</div>\n      ) : (\n        description\n      )}\n      {Object.entries(scopeMap).map(([prefix, scopes]) => {\n        const ScopeIcon = IconMap[prefix as ActionPrefix];\n        return (\n          <div key={prefix} className=\"space-y-2\">\n            <strong className=\"flex items-center gap-2 text-sm\">\n              {ScopeIcon && <ScopeIcon className=\"size-4 shrink-0\" />}\n              {actionPrefixStaticMap[prefix as ActionPrefix].title}\n            </strong>\n            <div className=\"flex flex-wrap gap-2\">\n              {scopes.map((scope) => (\n                <Badge key={scope} variant={'outline'} className=\"text-xs font-normal\">\n                  {scope}\n                </Badge>\n              ))}\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/ComponentPluginRender.tsx",
    "content": "import type {\n  IParentBridgeUtilsMethods,\n  IParentBridgeUIMethods,\n  IParentBridgeMethods,\n  IUIConfig,\n} from '@teable/sdk/plugin-bridge';\nimport { useMemo, useRef } from 'react';\nimport { Chart } from '../../blocks/chart/components/Chart';\nimport type { IPageParams } from '../../blocks/chart/types';\nimport type { IPluginParams } from './types';\n\ntype IBaseProps = {\n  uiConfig: IUIConfig;\n  utilsEvent: IParentBridgeUtilsMethods;\n  uiEvent: IParentBridgeUIMethods;\n};\n\ntype IComponentPluginRenderProps = IBaseProps & IPluginParams;\n\nexport const ComponentPluginRender = (props: IComponentPluginRenderProps) => {\n  const { utilsEvent, uiEvent, uiConfig, positionType, pluginId, pluginInstallId, positionId } =\n    props;\n  const baseId = 'baseId' in props ? props.baseId : '';\n  const tableId = 'tableId' in props ? props.tableId : '';\n  const pageParams: IPageParams = useMemo(\n    () => ({\n      baseId,\n      pluginId,\n      pluginInstallId,\n      positionId,\n      tableId,\n      positionType,\n    }),\n    [baseId, pluginId, pluginInstallId, positionId, tableId, positionType]\n  );\n\n  const parentBridgeMethods = useRef<IParentBridgeMethods>({\n    ...utilsEvent,\n    ...uiEvent,\n  });\n  parentBridgeMethods.current = {\n    ...utilsEvent,\n    ...uiEvent,\n  };\n\n  return (\n    <Chart\n      pageParams={pageParams}\n      parentBridgeMethods={parentBridgeMethods.current}\n      uiConfig={uiConfig}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/IframePluginRender.tsx",
    "content": "import type {\n  IChildBridgeMethods,\n  IParentBridgeMethods,\n  IParentBridgeUIMethods,\n  IParentBridgeUtilsMethods,\n} from '@teable/sdk/plugin-bridge';\nimport { cn } from '@teable/ui-lib';\nimport type { Methods } from 'penpal';\nimport { connectToChild } from 'penpal';\nimport { useEffect, useRef } from 'react';\n\ninterface IIframePluginRenderProps extends React.IframeHTMLAttributes<HTMLIFrameElement> {\n  src: string;\n  utilsEvent: IParentBridgeUtilsMethods;\n  uiEvent: IParentBridgeUIMethods;\n  bridge?: IChildBridgeMethods;\n  onBridge: (bridge?: IChildBridgeMethods) => void;\n}\nexport const IframePluginRender = (props: IIframePluginRenderProps) => {\n  const { onBridge, utilsEvent, uiEvent, className, bridge, ...rest } = props;\n\n  const iframeRef = useRef<HTMLIFrameElement | null>(null);\n  useEffect(() => {\n    if (!iframeRef.current) {\n      return;\n    }\n    const methods: IParentBridgeMethods = {\n      expandRecord: (recordIds: string[]) => {\n        return uiEvent.expandRecord(recordIds);\n      },\n      expandPlugin: () => {\n        return uiEvent.expandPlugin();\n      },\n      getAuthCode: () => {\n        return utilsEvent.getAuthCode();\n      },\n      getSelfTempToken: () => {\n        return utilsEvent.getSelfTempToken();\n      },\n      updateStorage: (storage) => {\n        return utilsEvent.updateStorage(storage);\n      },\n      getSelectionRecords: (selection, options) => {\n        return utilsEvent.getSelectionRecords(selection, options);\n      },\n    };\n    const connection = connectToChild<IChildBridgeMethods>({\n      iframe: iframeRef.current,\n      timeout: 20000,\n      methods: methods as unknown as Methods,\n    });\n\n    connection.promise.then((child) => {\n      onBridge(child);\n    });\n\n    connection.promise.catch((error) => {\n      throw error;\n    });\n\n    return () => {\n      connection.destroy();\n      onBridge(undefined);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [onBridge]);\n\n  // eslint-disable-next-line jsx-a11y/iframe-has-title\n  return (\n    // eslint-disable-next-line jsx-a11y/iframe-has-title\n    <iframe loading={'lazy'} {...rest} ref={iframeRef} className={cn('rounded-b', className)} />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/PluginCenterDialog.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { IGetPluginCenterListVo, IPluginI18n, PluginPosition } from '@teable/openapi';\nimport { getPluginCenterList } from '@teable/openapi';\nimport { Button, cn, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { get } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\nimport { PluginDetail } from './PluginDetail';\n\ninterface IPluginCenterProps {\n  children?: React.ReactNode;\n  positionType: PluginPosition;\n  onInstall?: (pluginId: string, name: string, detail: IGetPluginCenterListVo[number]) => void;\n}\n\nexport interface IPluginCenterDialogRef {\n  open: () => void;\n  close: () => void;\n}\n\nexport const PluginCenterDialog = forwardRef<IPluginCenterDialogRef, IPluginCenterProps>(\n  (props, ref) => {\n    const { children, positionType, onInstall } = props;\n    const [open, setOpen] = useState(false);\n    const { i18n, t } = useTranslation(['common']);\n    const language = i18n.language as unknown as keyof IPluginI18n;\n    const [detailPlugin, setDetailPlugin] = useState<IGetPluginCenterListVo[number]>();\n\n    const onClose = () => {\n      setOpen(false);\n      setDetailPlugin(undefined);\n    };\n\n    useImperativeHandle(\n      ref,\n      () =>\n        ({\n          open: () => setOpen(true),\n          close: onClose,\n        }) as IPluginCenterDialogRef\n    );\n\n    const { data: plugins } = useQuery({\n      queryKey: ['plugin-center', positionType] as const,\n      queryFn: ({ queryKey }) => getPluginCenterList([queryKey[1]]).then((res) => res.data),\n    });\n    const isEmpty = plugins?.length === 0;\n    return (\n      <Dialog\n        open={open}\n        onOpenChange={(open) => {\n          if (!open) {\n            onClose();\n            return;\n          }\n          setOpen(open);\n        }}\n      >\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent\n          className=\"max-w-4xl\"\n          style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }}\n        >\n          <div\n            className={cn(\n              'md:h-fit mt-4 w-full space-y-3 md:grid md:grid-cols-2 md:gap-4 md:space-y-0',\n              {\n                'md:h-auto flex md:flex': isEmpty,\n              }\n            )}\n          >\n            {plugins?.map((plugin) => {\n              const name = (get(plugin.i18n, [language, 'name']) ?? plugin.name) as string;\n              const description = (get(plugin.i18n, [language, 'description']) ??\n                plugin.description) as string | undefined;\n              const detailDesc = (get(plugin.i18n, [language, 'detailDesc']) ??\n                plugin.detailDesc) as string | undefined;\n              return (\n                <button\n                  key={plugin.id}\n                  className=\"flex h-20 w-full cursor-pointer items-center gap-3 rounded border p-2 hover:bg-accent\"\n                  onClick={() =>\n                    setDetailPlugin({\n                      ...plugin,\n                      name,\n                      description,\n                      detailDesc,\n                    })\n                  }\n                >\n                  <img src={plugin.logo} alt={name} className=\"size-14 object-contain\" />\n                  <div className=\"flex-auto text-left\">\n                    <div>{name}</div>\n                    <div\n                      className=\"line-clamp-2 break-words text-[13px] text-muted-foreground\"\n                      title={description}\n                    >\n                      {description}\n                    </div>\n                  </div>\n                  <Button\n                    size={'xs'}\n                    variant={'outline'}\n                    onClick={(e) => {\n                      onInstall?.(plugin.id, name, plugin);\n                      onClose();\n                      e.stopPropagation();\n                    }}\n                  >\n                    {t('common:pluginCenter.install')}\n                  </Button>\n                </button>\n              );\n            })}\n            {isEmpty && (\n              <div className=\"flex size-full items-center justify-center text-muted-foreground\">\n                {t('common:pluginCenter.pluginEmpty.title')}\n              </div>\n            )}\n          </div>\n          {detailPlugin && (\n            <PluginDetail\n              plugin={detailPlugin}\n              onBack={() => setDetailPlugin(undefined)}\n              onInstall={() => {\n                onInstall?.(detailPlugin.id, detailPlugin.name, detailPlugin);\n              }}\n            />\n          )}\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nPluginCenterDialog.displayName = 'PluginCenterDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/PluginContent.tsx",
    "content": "import type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';\nimport { Spin } from '@teable/ui-lib/base';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport type { IframeHTMLAttributes } from 'react';\nimport { useState } from 'react';\nimport { ComponentPluginRender } from './ComponentPluginRender';\nimport { useIframeUrl } from './hooks/iframe-url/useIframeUrl';\nimport { useIframeSize } from './hooks/useIframeSize';\nimport { useSyncBasePermissions } from './hooks/useSyncBasePermissions';\nimport { useSyncSelection } from './hooks/useSyncSelection';\nimport { useSyncUIConfig } from './hooks/useSyncUIConfig';\nimport { useSyncUrlParams } from './hooks/useSyncUrlParams';\nimport { useUIConfig } from './hooks/useUIConfig';\nimport { useUIEvent } from './hooks/useUIEvent';\nimport { useUtilsEvent } from './hooks/useUtilsEvent';\nimport { IframePluginRender } from './IframePluginRender';\nimport { RenderType } from './types';\nimport type { IPluginParams } from './types';\n\ntype IPluginContentProps = {\n  className?: string;\n  renderClassName?: string;\n  dragging?: boolean;\n  onExpand?: () => void;\n  iframeAttributes?: IframeHTMLAttributes<HTMLIFrameElement>;\n} & IPluginParams;\n\nexport const PluginContent = (props: IPluginContentProps) => {\n  const { className, renderClassName, pluginInstallId, dragging, onExpand, iframeAttributes } =\n    props;\n  const { t } = useTranslation(['common']);\n  const [bridge, setBridge] = useState<IChildBridgeMethods>();\n  const { iframeUrl, renderType } = useIframeUrl(props);\n  const [ref, { width, height }] = useIframeSize(dragging);\n  const utilsEvent = useUtilsEvent(props);\n  const uiEvent = useUIEvent({\n    onExpandPlugin: onExpand,\n  });\n\n  const uiConfig = useUIConfig(props);\n  useSyncUIConfig(bridge, props);\n  useSyncBasePermissions(bridge);\n  useSyncSelection(bridge);\n  useSyncUrlParams(bridge);\n  if (!iframeUrl) {\n    return (\n      <div\n        ref={ref}\n        className=\"flex flex-1 items-center justify-center text-sm text-muted-foreground\"\n      >\n        {t('common:pluginCenter.pluginUrlEmpty')}\n      </div>\n    );\n  }\n\n  return (\n    <div ref={ref} className={cn('relative size-full overflow-hidden', className)}>\n      {renderType === RenderType.Iframe && (\n        <>\n          {!bridge && (\n            <div className=\"flex size-full items-center justify-center\">\n              <Spin />\n            </div>\n          )}\n          <IframePluginRender\n            title={pluginInstallId}\n            width={width}\n            height={height}\n            bridge={bridge}\n            onBridge={setBridge}\n            src={iframeUrl}\n            className={renderClassName}\n            utilsEvent={utilsEvent}\n            uiEvent={uiEvent}\n            {...iframeAttributes}\n          />\n        </>\n      )}\n      {renderType === RenderType.Component && (\n        <ComponentPluginRender\n          {...props}\n          uiConfig={uiConfig}\n          utilsEvent={utilsEvent}\n          uiEvent={uiEvent}\n        />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/PluginDetail.tsx",
    "content": "import { ArrowLeft } from '@teable/icons';\nimport type { IGetPluginCenterListVo } from '@teable/openapi';\nimport { MarkdownPreview } from '@teable/sdk';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { UserAvatar } from '../user/UserAvatar';\n\nexport const PluginDetail = (props: {\n  plugin: IGetPluginCenterListVo[number];\n  onInstall?: () => void;\n  onBack?: () => void;\n}) => {\n  const { plugin, onBack, onInstall } = props;\n  const dayjs = useLanDayjs();\n  const { t } = useTranslation(['common']);\n  return (\n    <div className=\"absolute left-0 top-0 flex size-full flex-col rounded bg-background\">\n      <Button className=\"ml-2 mt-2 w-20\" variant={'ghost'} size={'sm'} onClick={onBack}>\n        <ArrowLeft className=\"size-4 shrink-0\" />\n        {t('common:actions.back')}\n      </Button>\n      <div className=\"flex-1 gap-3 overflow-auto px-4 md:flex\">\n        <div className=\"flex-1\">\n          <div className=\"mb-4 flex h-20 items-center gap-3 p-2\">\n            <img src={plugin.logo} alt={plugin.name} className=\"size-14 object-contain\" />\n            <div className=\"flex-auto\">\n              <div>{plugin.name}</div>\n              <div\n                className=\"line-clamp-2 break-words text-[13px] text-muted-foreground\"\n                title={plugin.description}\n              >\n                {plugin.description}\n              </div>\n            </div>\n          </div>\n          <Button className=\"w-full md:hidden\" size={'sm'} onClick={onInstall}>\n            {t('common:pluginCenter.install')}\n          </Button>\n          <div>\n            <MarkdownPreview>{plugin.detailDesc}</MarkdownPreview>\n          </div>\n        </div>\n        <div className=\"mb-4 w-1/4 space-y-4 text-sm\">\n          <Button className=\"hidden w-full md:inline-block\" size={'sm'} onClick={onInstall}>\n            {t('common:pluginCenter.install')}\n          </Button>\n          <div className=\"space-y-2\">\n            <p>{t('common:pluginCenter.publisher')}</p>\n            <div className=\"flex items-center gap-2\">\n              <UserAvatar\n                user={{\n                  name: plugin.createdBy.name,\n                  avatar: plugin.createdBy.avatar,\n                }}\n              />\n              {plugin.createdBy.name}\n            </div>\n          </div>\n          <div className=\"space-y-1\">\n            <p>{t('common:pluginCenter.lastUpdated')}</p>\n            <p className=\"text-xs\">{dayjs(plugin.lastModifiedTime).fromNow()}</p>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/PluginHeader.tsx",
    "content": "import { DragHandleDots2Icon } from '@radix-ui/react-icons';\nimport { Copy, Edit, Maximize2, MoreHorizontal, X } from '@teable/icons';\nimport {\n  Button,\n  cn,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { MenuDeleteItem } from '@/features/app/components/MenuDeleteItem';\n\nexport const PluginHeader = (props: {\n  name: string;\n  draggableHandleClassName: string;\n  isExpanded?: boolean;\n  canManage?: boolean;\n  dragging?: boolean;\n  onClose: () => void;\n  onDelete: () => void;\n  onExpand: () => void;\n  onCopy: () => void;\n  onNameChange: (name: string) => void;\n}) => {\n  const {\n    name,\n    dragging,\n    canManage,\n    isExpanded,\n    draggableHandleClassName,\n    onClose,\n    onDelete,\n    onExpand,\n    onCopy,\n    onNameChange,\n  } = props;\n  const [rename, setRename] = useState<string | null>(null);\n  const renameRef = useRef<HTMLInputElement>(null);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const { t } = useTranslation(['common']);\n\n  if (isExpanded) {\n    return (\n      <div className=\"flex h-10 items-center border-b pl-4 pr-2\">\n        <div className=\" flex-1 truncate\">{name}</div>\n        <Button variant={'ghost'} size={'icon-xs'} onClick={onClose}>\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-8 shrink-0 items-center gap-1 px-1\">\n      <DragHandleDots2Icon\n        className={cn(\n          'size-4 text-gray-500 cursor-pointer opacity-0 group-hover:opacity-100',\n          {\n            'pointer-events-none !opacity-0': !canManage,\n          },\n          {\n            'opacity-100': dragging,\n          },\n          draggableHandleClassName\n        )}\n      />\n      <div className=\"relative flex h-full flex-1 items-center overflow-hidden px-0.5\">\n        <span className=\"truncate text-sm\">{name}</span>\n        <Input\n          ref={renameRef}\n          style={{ width: 'calc(100% - 0.25rem)' }}\n          className={cn('absolute h-6 hidden', {\n            block: rename !== null,\n          })}\n          value={rename || ''}\n          onBlur={() => {\n            if (rename && rename !== name) {\n              onNameChange(rename);\n            }\n            setRename(null);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === 'Escape') {\n              e.currentTarget.blur();\n            }\n          }}\n          onChange={(e) => setRename(e.target.value)}\n        />\n      </div>\n      <div\n        className={cn('flex gap-1 overflow-hidden min-w-0 group-hover:w-auto', {\n          'w-0': !menuOpen,\n        })}\n      >\n        <Button\n          title={t('common:actions.expand')}\n          className=\"h-5 w-auto p-2\"\n          size={'icon-xs'}\n          variant={'ghost'}\n          onClick={onExpand}\n        >\n          <Maximize2 className=\"size-4 shrink-0\" />\n        </Button>\n        <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n          <DropdownMenuTrigger asChild>\n            <Button className=\"h-5 w-auto p-2\" variant={'ghost'} size={'icon-xs'}>\n              <MoreHorizontal className=\"size-4 shrink-0\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\" className=\"relative min-w-36 overflow-hidden\">\n            {canManage && (\n              <DropdownMenuItem\n                onSelect={() => {\n                  setRename(name);\n                  setTimeout(() => renameRef.current?.focus(), 200);\n                }}\n              >\n                <Edit className=\"mr-1.5\" />\n                {t('common:actions.rename')}\n              </DropdownMenuItem>\n            )}\n            <DropdownMenuItem onSelect={onExpand}>\n              <Maximize2 className=\"mr-1.5\" />\n              {t('common:actions.expand')}\n            </DropdownMenuItem>\n            <DropdownMenuItem onSelect={onCopy}>\n              <Copy className=\"mr-1.5\" />\n              {t('common:actions.duplicate')}\n            </DropdownMenuItem>\n            {canManage && (\n              <>\n                <DropdownMenuSeparator />\n                <MenuDeleteItem onConfirm={onDelete} />\n              </>\n            )}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/iframe-url/useIframeUrl.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { PluginPosition } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo, useRef } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { RenderType } from '../../types';\nimport type { IPluginParams } from '../../types';\nimport {\n  getContextMenuIframeUrl,\n  getDashboardIframeUrl,\n  getPanelIframeUrl,\n  getViewIframeUrl,\n} from './utils';\n\nconst componentPluginIds = ['plgchart'];\n\nexport const useIframeUrl = (params: IPluginParams) => {\n  const { pluginUrl } = params;\n  const { resolvedTheme } = useTheme();\n  const defaultTheme = useRef(resolvedTheme);\n  const {\n    i18n: { resolvedLanguage },\n  } = useTranslation(['common']);\n  const { publicOrigin } = useEnv();\n\n  const iframeUrl = useMemo(() => {\n    if (!pluginUrl) {\n      return;\n    }\n\n    const urlObj = new URL(pluginUrl, publicOrigin);\n    defaultTheme.current && urlObj.searchParams.set('theme', defaultTheme.current);\n    resolvedLanguage && urlObj.searchParams.set('lang', resolvedLanguage);\n    const urlStr = urlObj.toString();\n    switch (params.positionType) {\n      case PluginPosition.Dashboard:\n        return getDashboardIframeUrl(urlStr, params);\n      case PluginPosition.View:\n        return getViewIframeUrl(urlStr, params);\n      case PluginPosition.ContextMenu:\n        return getContextMenuIframeUrl(urlStr, params);\n      case PluginPosition.Panel:\n        return getPanelIframeUrl(urlStr, params);\n      default:\n        throw new Error(`Invalid position type`);\n    }\n  }, [pluginUrl, publicOrigin, resolvedLanguage, params]);\n\n  const renderType = useMemo(() => {\n    if (componentPluginIds.includes(params.pluginId)) {\n      return RenderType.Component;\n    }\n    return RenderType.Iframe;\n  }, [params.pluginId]);\n\n  return {\n    iframeUrl,\n    renderType,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/iframe-url/utils.ts",
    "content": "/* eslint-disable sonarjs/no-identical-functions */\nimport type {\n  IContextMenuPluginParams,\n  IDashboardPluginParams,\n  IPluginParamsBase,\n  IPanelPluginParams,\n  IViewPluginParams,\n} from '../../types';\n\nconst getBaseIframeUrl = (url: string, params: IPluginParamsBase & { positionType: string }) => {\n  const { positionId, pluginId, pluginInstallId, positionType } = params;\n  const urlObj = new URL(url);\n  urlObj.searchParams.set('positionType', positionType);\n  urlObj.searchParams.set('positionId', positionId);\n  urlObj.searchParams.set('pluginId', pluginId);\n  urlObj.searchParams.set('pluginInstallId', pluginInstallId);\n  return urlObj.toString();\n};\n\nexport const getViewIframeUrl = (url: string, params: IViewPluginParams) => {\n  const urlObj = new URL(getBaseIframeUrl(url, params));\n  if ('shareId' in params) {\n    urlObj.searchParams.set('shareId', params.shareId);\n  } else {\n    urlObj.searchParams.set('baseId', params.baseId);\n    urlObj.searchParams.set('tableId', params.tableId);\n    urlObj.searchParams.set('viewId', params.viewId);\n  }\n  return urlObj.toString();\n};\n\nexport const getDashboardIframeUrl = (url: string, params: IDashboardPluginParams) => {\n  const { baseId } = params;\n  const urlObj = new URL(getBaseIframeUrl(url, params));\n  urlObj.searchParams.set('baseId', baseId);\n  return urlObj.toString();\n};\n\nexport const getContextMenuIframeUrl = (url: string, params: IContextMenuPluginParams) => {\n  const { baseId, tableId } = params;\n  const urlObj = new URL(getBaseIframeUrl(url, params));\n  urlObj.searchParams.set('baseId', baseId);\n  urlObj.searchParams.set('tableId', tableId);\n  return urlObj.toString();\n};\n\nexport const getPanelIframeUrl = (url: string, params: IPanelPluginParams) => {\n  const { baseId, tableId } = params;\n  const urlObj = new URL(getBaseIframeUrl(url, params));\n  urlObj.searchParams.set('baseId', baseId);\n  urlObj.searchParams.set('tableId', tableId);\n  return urlObj.toString();\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useIframeSize.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\n\nexport const useIframeSize = (dragging?: boolean) => {\n  const ref = useRef<HTMLDivElement>(null);\n  const [preSize, setPreSize] = useState({ width: 0, height: 0 });\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    if (dragging) {\n      return;\n    }\n    setPreSize(size);\n  }, [dragging, size]);\n\n  useEffect(() => {\n    const currentElement = ref.current;\n\n    const observer = new ResizeObserver(() => {\n      if (currentElement) {\n        const { width, height } = currentElement.getBoundingClientRect();\n        setSize({ width, height });\n        observer.observe(currentElement);\n      }\n    });\n    if (currentElement) {\n      const { width, height } = currentElement.getBoundingClientRect();\n      setSize({ width, height });\n      observer.observe(currentElement);\n    }\n    return () => {\n      if (currentElement) {\n        observer.unobserve(currentElement);\n      }\n    };\n  }, [ref]);\n\n  return [ref, dragging ? preSize : size] as const;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useSelection.ts",
    "content": "import { useGridViewStore } from '@teable/sdk/components';\nimport type { ISelection } from '@teable/sdk/plugin-bridge';\nimport { useMemo } from 'react';\n\nexport const useSelection = () => {\n  const { selection } = useGridViewStore();\n  return useMemo(() => {\n    const res: ISelection | undefined = selection\n      ? {\n          range: selection.serialize(),\n          type: selection.type,\n        }\n      : undefined;\n    return res;\n  }, [selection]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useSyncBasePermissions.ts",
    "content": "import { useBasePermission } from '@teable/sdk/hooks';\nimport type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect } from 'react';\n\nexport const useSyncBasePermissions = (bridge?: IChildBridgeMethods) => {\n  const basePermissions = useBasePermission();\n  useEffect(() => {\n    if (!basePermissions) {\n      return;\n    }\n    bridge?.syncBasePermissions(basePermissions);\n  }, [basePermissions, bridge]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useSyncSelection.ts",
    "content": "import type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect } from 'react';\nimport { useSelection } from './useSelection';\n\nexport const useSyncSelection = (bridge: IChildBridgeMethods | undefined) => {\n  const selection = useSelection();\n  useEffect(() => {\n    bridge?.syncSelection(selection);\n  }, [selection, bridge]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useSyncUIConfig.ts",
    "content": "import type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect } from 'react';\nimport type { IPluginParams } from '../types';\nimport { useUIConfig } from './useUIConfig';\n\nexport const useSyncUIConfig = (\n  bridge: IChildBridgeMethods | undefined,\n  pluginParams: IPluginParams\n) => {\n  const uiConfig = useUIConfig(pluginParams);\n\n  useEffect(() => {\n    bridge?.syncUIConfig(uiConfig);\n  }, [bridge, uiConfig]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useSyncUrlParams.tsx",
    "content": "import type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect } from 'react';\nimport { useUrlParams } from './useUrlParams';\n\nexport const useSyncUrlParams = (bridge?: IChildBridgeMethods) => {\n  const urlParams = useUrlParams();\n\n  useEffect(() => {\n    bridge?.syncUrlParams(urlParams);\n  }, [urlParams, bridge]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useUIConfig.ts",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { useBasePermission } from '@teable/sdk/hooks';\nimport type { IUIConfig } from '@teable/sdk/plugin-bridge';\nimport { useRouter } from 'next/router';\nimport { useMemo } from 'react';\nimport type { IPluginParams } from '../types';\n\nexport const useUIConfig = (pluginParams: IPluginParams) => {\n  const basePermissions = useBasePermission();\n  const { resolvedTheme } = useTheme();\n  const router = useRouter();\n  const expandPluginId = router.query.expandPluginId as string;\n  const canSetting = basePermissions?.['base|update'];\n  const pluginInstallId =\n    'pluginInstallId' in pluginParams ? pluginParams.pluginInstallId : undefined;\n\n  return useMemo(() => {\n    const uiConfig: IUIConfig = {\n      theme: resolvedTheme,\n    };\n    if (pluginInstallId) {\n      uiConfig.isShowingSettings = expandPluginId === pluginInstallId && canSetting;\n      uiConfig.isExpand = expandPluginId === pluginInstallId;\n    }\n    return uiConfig;\n  }, [expandPluginId, pluginInstallId, resolvedTheme, canSetting]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useUIEvent.ts",
    "content": "import type { IParentBridgeUIMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect, useRef } from 'react';\n\nexport const useUIEvent = (params: { onExpandPlugin?: () => void }) => {\n  const { onExpandPlugin } = params;\n  const ref = useRef<IParentBridgeUIMethods>({\n    expandRecord: () => {\n      console.log('initializing expandRecord method');\n    },\n    expandPlugin: () => {\n      console.log('initializing expandPlugin method');\n    },\n  });\n\n  useEffect(() => {\n    ref.current.expandRecord = (recordIds) => {\n      console.log('expandRecord', recordIds);\n    };\n    if (onExpandPlugin) {\n      ref.current.expandPlugin = () => {\n        onExpandPlugin();\n      };\n    }\n  }, [onExpandPlugin]);\n\n  return ref.current;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useUrlParams.ts",
    "content": "import type { IUrlParams } from '@teable/sdk/plugin-bridge';\nimport { useRouter } from 'next/router';\nimport { useMemo } from 'react';\n\nexport const useUrlParams = () => {\n  const router = useRouter();\n  const { baseId, tableId, viewId, dashboardId, recordId, shareId } = router.query;\n  return useMemo(() => {\n    const urlParams: IUrlParams = {\n      baseId: baseId as string,\n      tableId: tableId as string,\n      viewId: viewId as string,\n      dashboardId: dashboardId as string,\n      recordId: recordId as string,\n      shareId: shareId as string,\n    };\n    return urlParams;\n  }, [baseId, tableId, viewId, dashboardId, recordId, shareId]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/useUtilsEvent.ts",
    "content": "import type { IGetTempTokenVo } from '@teable/openapi';\nimport {\n  getTempToken,\n  pluginGetAuthCode,\n  PluginPosition,\n  updateDashboardPluginStorage,\n  updatePluginPanelStorage,\n} from '@teable/openapi';\nimport { useViewId } from '@teable/sdk/hooks';\nimport type { IParentBridgeUtilsMethods } from '@teable/sdk/plugin-bridge';\nimport { useEffect, useRef } from 'react';\nimport type { IPluginParams } from '../types';\nimport { getSelectionRecords } from './utils/getSelectionRecords';\n\nexport const useUtilsEvent = (params: IPluginParams) => {\n  const tempTokenCacheRef = useRef<IGetTempTokenVo | null>(null);\n  const ref = useRef<IParentBridgeUtilsMethods>({\n    updateStorage: () => {\n      console.log('Initializing updateStorage method');\n      return Promise.resolve({});\n    },\n    getAuthCode: () => {\n      console.log('Initializing getAuthCode method');\n      return Promise.resolve('');\n    },\n    getSelfTempToken: () => {\n      const tempTokenCache = tempTokenCacheRef.current;\n      if (tempTokenCache && new Date(tempTokenCache.expiresTime).getTime() > Date.now() + 60000) {\n        return Promise.resolve(tempTokenCache);\n      }\n      return getTempToken().then((res) => {\n        tempTokenCacheRef.current = res.data;\n        return res.data;\n      });\n    },\n    getSelectionRecords: () => {\n      console.log('Initializing getSelectionRecords method');\n      return Promise.resolve({ records: [], fields: [] });\n    },\n  });\n  const { positionId, positionType, pluginId } = params;\n  const pluginInstallId = 'pluginInstallId' in params ? params.pluginInstallId : undefined;\n  const shareId = 'shareId' in params ? params.shareId : undefined;\n  const baseId = 'baseId' in params ? params.baseId : undefined;\n  const tableId = 'tableId' in params ? params.tableId : undefined;\n  const viewId = useViewId();\n\n  useEffect(() => {\n    ref.current.updateStorage = (storage) => {\n      if (shareId) {\n        console.error('Share plugin does not support updateStorage');\n        return Promise.resolve({});\n      }\n      switch (positionType) {\n        case PluginPosition.Dashboard:\n          return updateDashboardPluginStorage(baseId!, positionId, pluginInstallId!, storage).then(\n            (res) => res.data.storage ?? {}\n          );\n        case PluginPosition.Panel:\n          return updatePluginPanelStorage(tableId!, positionId, pluginInstallId!, { storage }).then(\n            (res) => res.data.storage ?? {}\n          );\n        default:\n          console.error(`Unsupported position type: ${positionType}`);\n          return Promise.resolve({});\n      }\n    };\n    ref.current.getAuthCode = () => {\n      // TODO: plugin in share page need to get auth code from share page, need plugin id and shareId to get auth code\n      if (shareId) {\n        console.error('Share plugin does not support getAuthCode');\n        return Promise.resolve('');\n      }\n      return pluginGetAuthCode(pluginId, baseId!).then((res) => res.data);\n    };\n  }, [shareId, pluginId, positionId, tableId, positionType, pluginInstallId, baseId]);\n\n  useEffect(() => {\n    ref.current.getSelectionRecords = (selection, options) => {\n      if (shareId) {\n        console.error('Share plugin does not support getSelectionRecords');\n        return Promise.resolve({ records: [], fields: [] });\n      }\n      if (!tableId || !viewId) {\n        console.error('Table ID or view ID is not available');\n        return Promise.resolve({ records: [], fields: [] });\n      }\n      return getSelectionRecords(tableId, viewId, selection, options);\n    };\n  }, [tableId, viewId, shareId]);\n\n  return ref.current;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/hooks/utils/getSelectionRecords.ts",
    "content": "import { FieldKeyType } from '@teable/core';\nimport type { IRecordsVo } from '@teable/openapi';\nimport { getRecords } from '@teable/openapi';\nimport { SelectionRegionType } from '@teable/sdk/components';\nimport type { IGetSelectionRecordsVo, ISelection } from '@teable/sdk/plugin-bridge';\nimport { useSelectionStore } from '@/features/app/blocks/view/grid/hooks/useSelectionStore';\n\nexport const getSelectionRecords = async (\n  tableId: string,\n  viewId: string,\n  selection: ISelection,\n  options?: {\n    skip?: number;\n    take?: number;\n  }\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n): Promise<IGetSelectionRecordsVo> => {\n  const { groupBy, search, fields, collapsedGroupIds, personalViewCommonQuery } =\n    useSelectionStore.getState();\n  if (!fields) {\n    return {\n      records: [],\n      fields: [],\n    };\n  }\n\n  const { type, range } = selection;\n  const { skip = 0, take = 100 } = options || {};\n\n  const getRecordsByQuery = ({\n    skip,\n    take,\n    projection,\n  }: {\n    skip: number;\n    take: number;\n    projection: string[];\n  }) => {\n    return getRecords(tableId, {\n      ...personalViewCommonQuery,\n      viewId,\n      groupBy,\n      search,\n      collapsedGroupIds,\n      projection,\n      skip,\n      take,\n      fieldKeyType: FieldKeyType.Id,\n    }).then((res) => res.data.records);\n  };\n  switch (type) {\n    case SelectionRegionType.Cells: {\n      const [[startColIndex, startRowIndex], [endColIndex, endRowIndex]] = range;\n      if (\n        startColIndex == null ||\n        startRowIndex == null ||\n        endColIndex == null ||\n        endRowIndex == null\n      ) {\n        throw new Error('Invalid selection range');\n      }\n      const projectionFields = fields.slice(startColIndex, endColIndex + 1);\n      const projection = projectionFields.map((item) => item.id);\n      const records = await getRecordsByQuery({\n        projection,\n        skip: skip + startRowIndex,\n        take: Math.min(take, endRowIndex - startRowIndex + 1),\n      });\n      return {\n        records,\n        fields: projectionFields.map((item) => item['doc'].data),\n      };\n    }\n    case SelectionRegionType.Rows: {\n      const allRecords: IRecordsVo['records'] = [];\n      let totalSkip = skip;\n      let totalTake = take;\n      const projection = fields.map((item) => item.id);\n      for (let i = 0; i < range.length; i++) {\n        const [startRowIndex, endRowIndex] = range[i];\n        const currentRowCount = endRowIndex - startRowIndex + 1;\n        if (totalSkip >= currentRowCount) {\n          totalSkip -= currentRowCount;\n          continue;\n        }\n\n        const records = await getRecordsByQuery({\n          projection,\n          skip: totalSkip + startRowIndex,\n          take: Math.min(totalTake, currentRowCount),\n        });\n        allRecords.push(...records);\n        if (totalTake > currentRowCount) {\n          totalTake -= currentRowCount;\n          totalSkip = 0;\n        } else {\n          break;\n        }\n      }\n      return {\n        records: allRecords,\n        fields: fields.map((item) => item['doc'].data),\n      };\n    }\n    case SelectionRegionType.Columns: {\n      const projections: string[] = [];\n      for (let i = 0; i < fields.length; i++) {\n        const [startColIndex, endColIndex] = range[i];\n        projections.push(...fields.slice(startColIndex, endColIndex).map((item) => item.id));\n      }\n      const records = await getRecordsByQuery({\n        projection: projections,\n        skip,\n        take,\n      });\n      return {\n        records,\n        fields: fields.map((item) => item['doc'].data),\n      };\n    }\n    default: {\n      console.error(`Unsupported selection type: ${type}`);\n      return {\n        records: [],\n        fields: [],\n      };\n    }\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin/types.ts",
    "content": "import type { PluginPosition } from '@teable/openapi';\n\nexport interface IPluginParamsBase {\n  pluginId: string;\n  pluginUrl: string | undefined;\n  positionId: string;\n  pluginInstallId: string;\n}\n\nexport interface IDashboardPluginParams extends IPluginParamsBase {\n  baseId: string;\n  positionType: PluginPosition.Dashboard;\n}\n\nexport type IViewPluginParams = IPluginParamsBase & {\n  positionType: PluginPosition.View;\n} & (\n    | {\n        shareId: string;\n      }\n    | {\n        baseId: string;\n        tableId: string;\n        viewId: string;\n      }\n  );\n\nexport type IContextMenuPluginParams = IPluginParamsBase & {\n  baseId: string;\n  tableId: string;\n  positionType: PluginPosition.ContextMenu;\n};\n\nexport type IPanelPluginParams = IPluginParamsBase & {\n  baseId: string;\n  tableId: string;\n  positionType: PluginPosition.Panel;\n};\n\nexport type IPluginParams =\n  | IDashboardPluginParams\n  | IViewPluginParams\n  | IContextMenuPluginParams\n  | IPanelPluginParams;\n\nexport enum RenderType {\n  Iframe = 'iframe',\n  Component = 'component',\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/PluginContextMenu.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getPluginContextMenu } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useEffect, useRef } from 'react';\nimport { FloatPlugin } from './components/FloatPlugin';\nimport { useActiveMenuPluginStore } from './useActiveMenuPlugin';\n\nexport const PluginContextMenu = (props: { tableId: string; baseId: string }) => {\n  const { tableId, baseId } = props;\n  const { activePluginId, setActivePluginId } = useActiveMenuPluginStore();\n  const preTableId = useRef(tableId);\n\n  const isTableChanged = tableId !== preTableId.current;\n\n  useEffect(() => {\n    if (preTableId.current && tableId && preTableId.current !== tableId) {\n      setActivePluginId(null);\n      preTableId.current = tableId;\n    }\n  }, [setActivePluginId, tableId]);\n\n  const { data: plugin } = useQuery({\n    queryKey: ReactQueryKeys.getPluginContextMenuPlugin(tableId!, activePluginId!),\n    queryFn: ({ queryKey }) =>\n      getPluginContextMenu(queryKey[1], queryKey[2]).then((res) => res.data),\n    enabled: !!tableId && !!activePluginId && !isTableChanged,\n  });\n\n  if (!baseId || !tableId || !activePluginId || !plugin || isTableChanged) return null;\n\n  return (\n    // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n    <div onMouseDown={(e) => e.stopPropagation()}>\n      <FloatPlugin\n        name={plugin?.name}\n        tableId={tableId}\n        positionId={plugin.positionId}\n        pluginId={activePluginId}\n        pluginInstallId={activePluginId}\n        pluginUrl={plugin?.url}\n        onClose={() => setActivePluginId(null)}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/PluginContextMenuManageDialog.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { DraggableHandle, Pencil, Plus, Trash2, X } from '@teable/icons';\nimport type {\n  IPluginContextMenuGetItem,\n  IPluginContextMenuInstallRo,\n  IPluginContextMenuMoveRo,\n  IPluginContextMenuRenameRo,\n} from '@teable/openapi';\nimport {\n  getPluginContextMenuList,\n  installPluginContextMenu,\n  movePluginContextMenu,\n  PluginPosition,\n  removePluginContextMenu,\n  renamePluginContextMenu,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { DragEndEvent } from '@teable/ui-lib/base';\nimport { ConfirmDialog, DndKitContext, Draggable, Droppable } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogOverlay,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { PluginCenterDialog } from '../plugin/PluginCenterDialog';\n\ninterface IPluginContextMenuManageDialogProps {\n  tableId: string;\n}\n\nexport interface IPluginContextMenuManageDialogRef {\n  open: () => void;\n  close: () => void;\n}\n\nexport const PluginContextMenuManageDialog = forwardRef<\n  IPluginContextMenuManageDialogRef,\n  IPluginContextMenuManageDialogProps\n>(({ tableId }, ref) => {\n  ref;\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const [name, setName] = useState('');\n  const [renamePluginInstallId, setRenamePluginInstallId] = useState<string | null>(null);\n  const renameInputRef = useRef<HTMLInputElement>(null);\n  const [deletePluginInstallId, setDeletePluginInstallId] = useState<string | null>(null);\n\n  const { data: pluginContextMenu } = useQuery({\n    queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId),\n    queryFn: ({ queryKey }) => getPluginContextMenuList(queryKey[1]).then((res) => res.data),\n  });\n\n  const [pluginContextMenuList, setPluginContextMenuList] = useState<IPluginContextMenuGetItem[]>(\n    []\n  );\n\n  useEffect(() => {\n    if (!pluginContextMenu) {\n      return;\n    }\n    setPluginContextMenuList(pluginContextMenu);\n  }, [pluginContextMenu]);\n\n  const { mutate: deletePluginContextMenu, isPending: isDeleting } = useMutation({\n    mutationFn: ({ pluginInstallId }: { pluginInstallId: string }) =>\n      removePluginContextMenu(tableId, pluginInstallId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId),\n      });\n      setDeletePluginInstallId(null);\n    },\n  });\n\n  const { mutate: installPlugin } = useMutation({\n    mutationFn: (ro: IPluginContextMenuInstallRo) => installPluginContextMenu(tableId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId),\n      });\n    },\n  });\n\n  const { mutate: updateOrder } = useMutation({\n    mutationFn: (ro: IPluginContextMenuMoveRo & { pluginInstallId: string }) =>\n      movePluginContextMenu(tableId, ro.pluginInstallId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId),\n      });\n    },\n  });\n\n  const { mutate: renamePlugin } = useMutation({\n    mutationFn: (ro: IPluginContextMenuRenameRo & { pluginInstallId: string }) =>\n      renamePluginContextMenu(tableId, ro.pluginInstallId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginContextMenuPlugins(tableId),\n      });\n      setRenamePluginInstallId(null);\n      setName('');\n    },\n  });\n\n  const [open, setOpen] = useState(false);\n\n  useImperativeHandle(ref, () => ({\n    open: () => setOpen(true),\n    close: () => setOpen(false),\n  }));\n\n  const onDragEndHandler = async (event: DragEndEvent) => {\n    const { over, active } = event;\n    const to = over?.data?.current?.sortable?.index;\n    const from = active?.data?.current?.sortable?.index;\n    const list = pluginContextMenu ?? [];\n\n    if (!over || !list.length || from === to) {\n      return;\n    }\n\n    const plugin = list[from];\n    const anchorPlugin = list[to];\n    const position = to > from ? 'after' : 'before';\n    updateOrder({\n      pluginInstallId: plugin.pluginInstallId,\n      anchorId: anchorPlugin.pluginInstallId,\n      position,\n    });\n    setPluginContextMenuList((prev) => {\n      const pre = [...prev];\n      pre.splice(from, 1);\n      pre.splice(to, 0, plugin);\n      return pre;\n    });\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen} modal>\n      <DialogContent\n        closeable={false}\n        overlay={\n          <DialogOverlay\n            onMouseDown={(e) => {\n              e.stopPropagation();\n            }}\n          />\n        }\n        onMouseDown={(e) => {\n          e.stopPropagation();\n        }}\n      >\n        <DialogHeader>\n          <div className=\"flex items-center justify-between gap-2\">\n            <div className=\"flex-1 truncate text-sm font-medium\">\n              {t('table:pluginContextMenu.manage')}\n            </div>\n            <div className=\"flex gap-2\">\n              <PluginCenterDialog\n                positionType={PluginPosition.ContextMenu}\n                onInstall={(id, name) => {\n                  installPlugin({ pluginId: id, name });\n                }}\n              >\n                <Button size={'sm'} variant={'outline'}>\n                  <Plus className=\"size-4 shrink-0\" />\n                  {t('table:addPlugin')}\n                </Button>\n              </PluginCenterDialog>\n              <Button size={'sm'} variant={'outline'} onClick={() => setOpen(false)}>\n                <X className=\"size-4 shrink-0\" />\n              </Button>\n            </div>\n          </div>\n        </DialogHeader>\n        <div className=\"flex flex-col gap-3\">\n          {!pluginContextMenuList?.length && (\n            <div className=\"flex items-center justify-center py-2 text-[13px] text-muted-foreground\">\n              {t('table:pluginContextMenu.noPlugin')}\n            </div>\n          )}\n          <DndKitContext onDragEnd={onDragEndHandler}>\n            <Droppable\n              items={pluginContextMenuList.map(({ pluginInstallId }) => pluginInstallId)}\n              overlayRender={(active) => {\n                const activePlugin = pluginContextMenuList.find(\n                  (plugin) => plugin.pluginInstallId === active?.id\n                );\n                if (!activePlugin) {\n                  return <div />;\n                }\n                return (\n                  <div\n                    key={activePlugin.pluginInstallId}\n                    className=\"flex items-center gap-2 rounded-sm bg-muted p-1\"\n                  >\n                    <img\n                      src={activePlugin.logo}\n                      alt={activePlugin.name}\n                      className=\"size-[30px] rounded-sm\"\n                    />\n                    <div className=\"line-clamp-1 flex-1 text-[13px]\">{activePlugin.name}</div>\n                  </div>\n                );\n              }}\n            >\n              {pluginContextMenuList?.map((plugin) => (\n                <Draggable key={plugin.pluginInstallId} id={plugin.pluginInstallId}>\n                  {({ setNodeRef, attributes, listeners, style }) => (\n                    <div ref={setNodeRef} {...attributes} style={style}>\n                      <div\n                        key={plugin.pluginInstallId}\n                        className=\"group flex h-10 items-center gap-2 rounded-sm px-1 hover:bg-muted\"\n                        {...listeners}\n                      >\n                        <img\n                          src={plugin.logo}\n                          alt={plugin.name}\n                          className=\"size-[30px] rounded-sm\"\n                        />\n                        <div className=\"relative flex h-full flex-1 items-center text-[13px]\">\n                          <p className=\"line-clamp-1\">{plugin.name}</p>\n                          {renamePluginInstallId === plugin.pluginInstallId && (\n                            <Input\n                              ref={renameInputRef}\n                              className=\"absolute z-20 flex-1 text-[13px]\"\n                              value={name}\n                              // eslint-disable-next-line jsx-a11y/no-autofocus\n                              autoFocus\n                              onChange={(e) => setName(e.target.value)}\n                              onBlur={() => {\n                                renamePlugin({ pluginInstallId: plugin.pluginInstallId, name });\n                              }}\n                              onKeyDown={(e) => {\n                                if (e.key === 'Enter') {\n                                  renamePlugin({ pluginInstallId: plugin.pluginInstallId, name });\n                                }\n                                e.stopPropagation();\n                              }}\n                              onMouseDown={(e) => {\n                                e.stopPropagation();\n                              }}\n                              onClick={(e) => {\n                                e.stopPropagation();\n                              }}\n                            />\n                          )}\n                        </div>\n                        <div className=\"flex items-center gap-4\">\n                          <Button\n                            size={'icon'}\n                            variant={'link'}\n                            className=\"h-full w-auto p-0 text-gray-500 hover:text-primary\"\n                            onClick={() => {\n                              setRenamePluginInstallId(plugin.pluginInstallId);\n                              setName(plugin.name);\n                            }}\n                          >\n                            <Pencil className=\"size-4 shrink-0\" />\n                          </Button>\n                          <Button\n                            size={'icon'}\n                            variant={'link'}\n                            className=\"h-full w-auto p-0 text-gray-500 hover:text-primary\"\n                            onClick={() => {\n                              setDeletePluginInstallId(plugin.pluginInstallId);\n                            }}\n                          >\n                            <Trash2 className=\"size-4 shrink-0\" />\n                          </Button>\n                          <DraggableHandle className=\"size-4 text-gray-500\" />\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </Draggable>\n              ))}\n            </Droppable>\n          </DndKitContext>\n        </div>\n        <ConfirmDialog\n          open={!!deletePluginInstallId}\n          title={t('table:pluginContextMenu.delete')}\n          description={t('table:pluginContextMenu.deleteDescription')}\n          confirmText={t('common:actions.confirm')}\n          cancelText={t('common:actions.cancel')}\n          confirmLoading={isDeleting}\n          onConfirm={() => {\n            deletePluginInstallId &&\n              deletePluginContextMenu({ pluginInstallId: deletePluginInstallId });\n          }}\n          onCancel={() => {\n            setDeletePluginInstallId(null);\n          }}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nPluginContextMenuManageDialog.displayName = 'PluginContextMenuManageDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/components/FloatPlugin.tsx",
    "content": "import { DragHandleDots2Icon } from '@radix-ui/react-icons';\nimport { X } from '@teable/icons';\nimport { PluginPosition } from '@teable/openapi';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Rnd } from 'react-rnd';\nimport { PluginContent } from '@/features/app/components/plugin/PluginContent';\nimport { useFloatPluginPosition } from './useFloatPluginPosition';\nimport { toPixel } from './utils/position';\n\nexport const FloatPlugin = (props: {\n  name: string;\n  tableId: string;\n  pluginId: string;\n  pluginUrl?: string;\n  pluginInstallId: string;\n  positionId: string;\n  onClose?: () => void;\n}) => {\n  const { tableId, pluginInstallId, pluginId, pluginUrl, name, onClose, positionId } = props;\n  const router = useRouter();\n  const baseId = router.query.baseId as string;\n  const { position, updatePosition, frozenResize, frozenDrag } = useFloatPluginPosition(\n    tableId,\n    pluginInstallId\n  );\n  const [isDragging, setIsDragging] = useState(false);\n  const [bodySize, setBodySize] = useState({\n    width: document.body.clientWidth,\n    height: document.body.clientHeight,\n  });\n  const preBody = useRef<{ width: number; height: number }>({\n    width: document.body.clientWidth,\n    height: document.body.clientHeight,\n  });\n\n  useEffect(() => {\n    const resizeObserver = new ResizeObserver(() => {\n      const { width, height } = document.body.getBoundingClientRect();\n      if (preBody.current.width === width && preBody.current.height === height) {\n        return;\n      }\n      setBodySize({ width, height });\n      preBody.current = { width, height };\n    });\n\n    resizeObserver.observe(document.body);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, [position, updatePosition]);\n\n  // Convert position values to pixels (handles both 0-1 percentage and >1 pixel values)\n  const positionInPixels = {\n    x: toPixel(position.x, bodySize.width),\n    y: toPixel(position.y, bodySize.height),\n    width: toPixel(position.width, bodySize.width),\n    height: toPixel(position.height, bodySize.height),\n  };\n\n  const x =\n    positionInPixels.x + positionInPixels.width > bodySize.width\n      ? Math.max(0, bodySize.width - positionInPixels.width)\n      : positionInPixels.x;\n  const y =\n    positionInPixels.y + positionInPixels.height > bodySize.height\n      ? Math.max(0, bodySize.height - positionInPixels.height)\n      : positionInPixels.y;\n  const width =\n    positionInPixels.width > bodySize.width\n      ? Math.max(120, bodySize.width)\n      : positionInPixels.width;\n  const height =\n    positionInPixels.height > bodySize.height\n      ? Math.max(90, bodySize.height)\n      : positionInPixels.height;\n\n  return createPortal(\n    // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n    <Rnd\n      className=\"!max-h-full !max-w-full overflow-hidden rounded-sm border bg-background\"\n      style={{\n        position: 'fixed',\n        zIndex: 10000,\n      }}\n      position={{\n        x,\n        y,\n      }}\n      size={{\n        width,\n        height,\n      }}\n      dragHandleClassName=\"float-plugin-drag-handle\"\n      resizeHandleClasses={{\n        bottomRight:\n          'border-b border-r border-foreground/40 after:absolute after:size-2 after:border-b-2 after:border-r-2 after:border-b after:border-r after:border-primary',\n      }}\n      minHeight={90}\n      minWidth={120}\n      enableResizing={{\n        bottomRight: !frozenResize,\n      }}\n      bounds={'parent'}\n      onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}\n      onDrag={() => {\n        setIsDragging(true);\n      }}\n      onResize={() => {\n        setIsDragging(true);\n      }}\n      onResizeStop={(_e, _direction, ref, _delta, position) => {\n        setIsDragging(false);\n        updatePosition({\n          x: position.x,\n          y: position.y,\n          width: ref.offsetWidth,\n          height: ref.offsetHeight,\n        });\n      }}\n      onDragStop={(_e, d) => {\n        updatePosition({\n          x: d.x,\n          y: d.y,\n          width: positionInPixels.width,\n          height: positionInPixels.height,\n        });\n        setIsDragging(false);\n      }}\n      disableDragging={frozenDrag}\n      disableResizing={frozenResize}\n    >\n      <div className=\"flex size-full flex-col\">\n        <div className=\"flex items-center justify-between gap-2 border-b px-1\">\n          <div className=\"flex items-center gap-2 overflow-hidden\">\n            {!frozenDrag && (\n              <DragHandleDots2Icon className=\"float-plugin-drag-handle inline-block size-4 shrink-0 cursor-move\" />\n            )}\n            <div className={cn('truncate', { 'ml-2': frozenDrag })}>{name}</div>\n          </div>\n          <Button variant=\"link\" size=\"icon\" onClick={onClose}>\n            <X className=\"size-4 shrink-0\" />\n          </Button>\n        </div>\n        <PluginContent\n          className=\"flex-1\"\n          baseId={baseId}\n          tableId={tableId}\n          pluginId={pluginId}\n          pluginInstallId={pluginInstallId}\n          positionId={positionId}\n          pluginUrl={pluginUrl}\n          positionType={PluginPosition.ContextMenu}\n          dragging={isDragging}\n          iframeAttributes={{\n            loading: 'eager',\n          }}\n        />\n      </div>\n    </Rnd>,\n    document.body\n  ) as unknown as JSX.Element;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/components/useFloatPluginPosition.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getPluginContextMenu, PluginPosition } from '@teable/openapi';\nimport { LocalStorageKeys, ReactQueryKeys } from '@teable/sdk/config';\nimport { useCallback, useMemo } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { isPercentage, pixelToPercent } from './utils/position';\n\nconst DEFAULT_FLOAT_PLUGIN_WIDTH = 320;\nconst DEFAULT_FLOAT_PLUGIN_HEIGHT = 200;\n\ninterface IFloatPluginPosition {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\ninterface IFloatPluginsPosition {\n  [key: string]: IFloatPluginPosition;\n}\n\nexport const useFloatPluginPosition = (tableId: string, pluginInstallId: string) => {\n  const [pluginsPosition, setPluginsPosition] = useLocalStorage<IFloatPluginsPosition>(\n    LocalStorageKeys.MenuPluginPosition,\n    {}\n  );\n\n  const { data: plugin } = useQuery({\n    queryKey: ReactQueryKeys.getPluginContextMenuPlugin(tableId, pluginInstallId),\n    queryFn: ({ queryKey }) =>\n      getPluginContextMenu(queryKey[1], queryKey[2]).then((res) => res.data),\n  });\n\n  const config = plugin?.config?.[PluginPosition.ContextMenu];\n\n  const defaultPosition = useMemo(() => {\n    const body = document.body;\n    const viewportWidth = body.clientWidth;\n    const viewportHeight = body.clientHeight;\n\n    // Helper to normalize config value to 0-1 percentage\n    const normalizeValue = (\n      value: number | string | undefined,\n      defaultPixel: number,\n      viewportSize: number\n    ): number => {\n      if (value === undefined) {\n        return pixelToPercent(defaultPixel, viewportSize);\n      }\n      // Handle percentage strings like \"50%\"\n      if (typeof value === 'string' && value.endsWith('%')) {\n        return parseFloat(value) / 100;\n      }\n      const numValue = value as number;\n      // If already 0-1, keep it; if >1, convert to percentage\n      return isPercentage(numValue) ? numValue : pixelToPercent(numValue, viewportSize);\n    };\n\n    return {\n      x: normalizeValue(\n        config?.x,\n        viewportWidth / 2 - DEFAULT_FLOAT_PLUGIN_WIDTH / 2,\n        viewportWidth\n      ),\n      y: normalizeValue(\n        config?.y,\n        viewportHeight / 2 - DEFAULT_FLOAT_PLUGIN_HEIGHT / 2,\n        viewportHeight\n      ),\n      width: normalizeValue(config?.width, DEFAULT_FLOAT_PLUGIN_WIDTH, viewportWidth),\n      height: normalizeValue(config?.height, DEFAULT_FLOAT_PLUGIN_HEIGHT, viewportHeight),\n    };\n  }, [config]);\n\n  const updatePosition = useCallback(\n    (position: IFloatPluginPosition) => {\n      const body = document.body;\n      const viewportWidth = body.clientWidth;\n      const viewportHeight = body.clientHeight;\n\n      // Always save as 0-1 percentages\n      const normalizedPosition: IFloatPluginPosition = {\n        x: isPercentage(position.x) ? position.x : pixelToPercent(position.x, viewportWidth),\n        y: isPercentage(position.y) ? position.y : pixelToPercent(position.y, viewportHeight),\n        width: isPercentage(position.width)\n          ? position.width\n          : pixelToPercent(position.width, viewportWidth),\n        height: isPercentage(position.height)\n          ? position.height\n          : pixelToPercent(position.height, viewportHeight),\n      };\n\n      setPluginsPosition({\n        ...pluginsPosition,\n        [pluginInstallId]: normalizedPosition,\n      });\n      return position;\n    },\n    [pluginInstallId, pluginsPosition, setPluginsPosition]\n  );\n\n  return {\n    position: pluginsPosition?.[pluginInstallId] ?? defaultPosition,\n    updatePosition,\n    frozenResize: config?.frozenResize,\n    frozenDrag: config?.frozenDrag,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/components/utils/position.ts",
    "content": "// Check if value represents a percentage (0-1) or pixels (>1)\nexport const isPercentage = (value: number): boolean => {\n  return value >= 0 && value <= 1;\n};\n\n// Convert pixel value to percentage (0-1 range)\nexport const pixelToPercent = (pixel: number, viewportSize: number): number => {\n  if (viewportSize === 0) return 0;\n  return Math.max(0, Math.min(1, pixel / viewportSize));\n};\n\n// Convert percentage (0-1) to pixel value\nexport const percentToPixel = (percent: number, viewportSize: number): number => {\n  return Math.round(percent * viewportSize);\n};\n\n// Convert any value (percentage or pixel) to pixel based on viewport\nexport const toPixel = (value: number, viewportSize: number): number => {\n  return isPercentage(value) ? percentToPixel(value, viewportSize) : value;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-context-menu/useActiveMenuPlugin.ts",
    "content": "import { create } from 'zustand';\n\ntype ActiveMenuPluginState = {\n  activePluginId: string | null;\n  setActivePluginId: (id: string | null) => void;\n};\n\nexport const useActiveMenuPluginStore = create<ActiveMenuPluginState>((set) => ({\n  activePluginId: null,\n  setActivePluginId: (id) => set({ activePluginId: id }),\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginLayout.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IPluginPanelUpdateLayoutRo } from '@teable/openapi';\nimport { getPluginPanel, updatePluginPanelLayout } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTablePermission } from '@teable/sdk/hooks';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { Responsive, WidthProvider } from 'react-grid-layout';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { useIsExpandPlugin } from '../../dashboard/hooks/useIsExpandPlugin';\nimport { CreatePluginDialog } from './components/CreatePluginDialog';\nimport { PluginItem } from './components/PluginItem';\nimport { useActivePluginPanelId } from './hooks/useActivePluginPanelId';\n\nconst ResponsiveGridLayout = WidthProvider(Responsive);\n\nexport const PluginLayout = ({ tableId }: { tableId: string }) => {\n  const activePluginPanelId = useActivePluginPanelId(tableId)!;\n  const isExpandPlugin = useIsExpandPlugin();\n  const [isDragging, setIsDragging] = useState(false);\n  const tablePermissions = useTablePermission();\n  const canMange = tablePermissions?.['table|update'];\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n\n  const { mutate: updateLayoutMutate } = useMutation({\n    mutationFn: (layout: IPluginPanelUpdateLayoutRo) =>\n      updatePluginPanelLayout(tableId, activePluginPanelId, layout),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanel(tableId, activePluginPanelId),\n      });\n    },\n  });\n\n  const onLayoutChange = (layout: ReactGridLayout.Layout[]) => {\n    updateLayoutMutate({\n      layout: layout.map(({ i, x, y, w, h }) => ({\n        pluginInstallId: i,\n        x,\n        y,\n        w,\n        h,\n      })),\n    });\n  };\n\n  const { data: pluginPanel } = useQuery({\n    queryKey: ReactQueryKeys.getPluginPanel(tableId, activePluginPanelId!),\n    queryFn: () => getPluginPanel(tableId, activePluginPanelId).then((res) => res.data),\n  });\n\n  if (!pluginPanel?.layout?.length) {\n    return (\n      <div className=\"flex flex-1 items-center justify-center\">\n        <CreatePluginDialog tableId={tableId}>\n          <Button size={'sm'}>{t('table:addPlugin')}</Button>\n        </CreatePluginDialog>\n      </div>\n    );\n  }\n\n  const { layout, pluginMap } = pluginPanel;\n\n  return (\n    <ResponsiveGridLayout\n      className=\"w-full\"\n      layouts={{\n        md: layout.map(({ pluginInstallId, x, y, w, h }) => ({\n          i: pluginInstallId,\n          x,\n          y,\n          w,\n          h,\n        })),\n      }}\n      rowHeight={80}\n      containerPadding={[8, 8]}\n      cols={{ lg: 1, md: 1, sm: 1, xs: 1, xxs: 1 }}\n      draggableHandle=\".plugin-panel-draggable-handle\"\n      onResize={() => setIsDragging(true)}\n      onResizeStop={(layout) => {\n        setIsDragging(false);\n        onLayoutChange(layout);\n      }}\n      onDrag={() => setIsDragging(true)}\n      onDragStop={(layout) => {\n        setIsDragging(false);\n        onLayoutChange(layout);\n      }}\n      isResizable={canMange}\n      isDraggable={canMange}\n    >\n      {layout.map(({ pluginInstallId, x, y, w, h }) => {\n        const plugin = pluginMap?.[pluginInstallId];\n        return (\n          <div\n            key={pluginInstallId}\n            data-grid={{ x, y, w, h }}\n            className={cn({\n              '!transform-none !transition-none': isExpandPlugin(pluginInstallId),\n            })}\n          >\n            {plugin ? (\n              <PluginItem\n                tableId={tableId}\n                pluginPanelId={activePluginPanelId}\n                pluginId={plugin.id}\n                pluginInstallId={pluginInstallId}\n                pluginName={plugin.name}\n                pluginUrl={plugin.url}\n                isDragging={isDragging}\n              />\n            ) : (\n              <div>{t('common:pluginCenter.pluginNotFound')}</div>\n            )}\n          </div>\n        );\n      })}\n    </ResponsiveGridLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginPanel.tsx",
    "content": "import { usePluginPanelStorage } from './hooks/usePluginPanelStorage';\nimport { PluginPanelContainer } from './PluginPanelContainer';\n\ninterface IPluginPanelProps {\n  tableId: string;\n}\nexport const PluginPanel = (props: IPluginPanelProps) => {\n  const { tableId } = props;\n  const { isVisible } = usePluginPanelStorage(tableId);\n  if (!isVisible) {\n    return <></>;\n  }\n\n  return <PluginPanelContainer tableId={tableId} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginPanelContainer.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { listPluginPanels } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Resizable } from 're-resizable';\nimport { usePluginPanelStorage } from './hooks/usePluginPanelStorage';\nimport { DEFAULT_PLUGIN_PANEL_WIDTH } from './hooks/usePluginPanelStore';\nimport { PluginLayout } from './PluginLayout';\nimport { PluginPanelEmpty } from './PluginPanelEmpty';\nimport { PluginPanelHeader } from './PluginPanelHeader';\n\nexport const PluginPanelContainer = ({ tableId }: { tableId: string }) => {\n  const { width, updateWidth } = usePluginPanelStorage(tableId);\n  const { data: pluginPanels } = useQuery({\n    queryKey: ReactQueryKeys.getPluginPanelList(tableId),\n    queryFn: ({ queryKey }) => listPluginPanels(queryKey[1]).then((res) => res.data),\n  });\n\n  return (\n    <Resizable\n      className=\"ml-1 bg-background px-1\"\n      size={{ width, height: '100%' }}\n      defaultSize={{ width: DEFAULT_PLUGIN_PANEL_WIDTH, height: '100%' }}\n      maxWidth={'60%'}\n      minWidth={'300px'}\n      enable={{\n        left: true,\n      }}\n      onResizeStop={(_e, _direction, ref) => {\n        updateWidth(ref.style.width);\n      }}\n      handleClasses={{\n        left: 'group',\n      }}\n      handleStyles={{\n        left: {\n          width: '4px',\n          left: '0',\n        },\n      }}\n      handleComponent={{\n        // eslint-disable-next-line tailwindcss/no-unnecessary-arbitrary-value\n        left: (\n          <div className=\"h-full w-px bg-border group-hover:px-[1.5px] group-active:px-[1.5px]\"></div>\n        ),\n      }}\n    >\n      {pluginPanels?.length ? (\n        <div className=\"flex h-full flex-col\">\n          <PluginPanelHeader tableId={tableId} />\n          <PluginLayout tableId={tableId} />\n        </div>\n      ) : (\n        <PluginPanelEmpty tableId={tableId} />\n      )}\n    </Resizable>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginPanelEmpty.tsx",
    "content": "import { X } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { CreatePluginPanelDialog } from './components/CreatePluginPanelDialog';\nimport { usePluginPanelStorage } from './hooks/usePluginPanelStorage';\n\nexport const PluginPanelEmpty = ({ tableId }: { tableId: string }) => {\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const { toggleVisible } = usePluginPanelStorage(tableId);\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"flex h-[43px] items-center justify-end border-b\">\n        <Button variant=\"outline\" size=\"icon-xs\" onClick={toggleVisible}>\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      </div>\n      <div className=\"flex flex-1 flex-col items-center justify-center gap-2 text-center\">\n        <div>{t('table:pluginPanel.empty.description')}</div>\n        <CreatePluginPanelDialog tableId={tableId}>\n          <Button className=\"w-fit\" size=\"sm\">\n            {t('table:pluginPanel.createPluginPanel.button')}\n          </Button>\n        </CreatePluginPanelDialog>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginPanelHeader.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Copy, Edit, MoreHorizontal, Plus, X } from '@teable/icons';\nimport {\n  deletePluginPanel,\n  duplicatePluginPanel,\n  listPluginPanels,\n  renamePluginPanel,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTablePermission } from '@teable/sdk/hooks';\nimport {\n  Button,\n  cn,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { MenuDeleteItem } from '../MenuDeleteItem';\nimport { CreatePluginDialog } from './components/CreatePluginDialog';\nimport { useActivePluginPanelId } from './hooks/useActivePluginPanelId';\nimport { usePluginPanelStorage } from './hooks/usePluginPanelStorage';\nimport { PluginPanelSelector } from './PluginPanelSelector';\n\nexport const PluginPanelHeader = (props: { tableId: string }) => {\n  const { tableId } = props;\n  const { toggleVisible } = usePluginPanelStorage(tableId);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const renameRef = useRef<HTMLInputElement>(null);\n  const [rename, setRename] = useState<string | null>(null);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const queryClient = useQueryClient();\n  const tablePermissions = useTablePermission();\n  const canManage = tablePermissions?.['table|update'];\n  const activePluginPanelId = useActivePluginPanelId(tableId)!;\n\n  const { data: pluginPanels } = useQuery({\n    queryKey: ReactQueryKeys.getPluginPanelList(tableId),\n    queryFn: ({ queryKey }) => listPluginPanels(queryKey[1]).then((res) => res.data),\n  });\n\n  const activePluginPanel = pluginPanels?.find(({ id }) => id === activePluginPanelId);\n\n  const { mutate: deletePluginPanelMutate } = useMutation({\n    mutationFn: () => deletePluginPanel(tableId, activePluginPanelId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getPluginPanelList(tableId) });\n    },\n  });\n\n  const { mutate: renamePluginPanelMutate } = useMutation({\n    mutationFn: (name: string) => renamePluginPanel(tableId, activePluginPanelId, { name }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getPluginPanelList(tableId) });\n      setRename(null);\n    },\n  });\n\n  const { mutate: duplicatePluginPanelMutate } = useMutation({\n    mutationFn: (name: string) =>\n      duplicatePluginPanel(tableId, activePluginPanelId, {\n        name: `${name} ${t('common:noun.copy')}`,\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getPluginPanelList(tableId) });\n      setRename(null);\n      toast.success(t('table:table.actionTips.copySuccessful'));\n    },\n  });\n\n  const onRenameSubmit = () => {\n    if (!rename || activePluginPanel?.name === rename) {\n      setRename(null);\n      return;\n    }\n    renamePluginPanelMutate(rename);\n  };\n\n  return (\n    <div className=\"relative flex h-[43px] shrink-0 items-center justify-between gap-2 border-b px-2 @container/plugin-panel-header\">\n      <PluginPanelSelector tableId={tableId} />\n      <Input\n        ref={renameRef}\n        size=\"sm\"\n        className={cn('absolute left-0 right-0', {\n          hidden: rename === null,\n        })}\n        value={rename ?? ''}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter') {\n            onRenameSubmit();\n          }\n          if (e.key === 'Escape') {\n            setRename(null);\n          }\n        }}\n        onBlur={() => {\n          onRenameSubmit();\n        }}\n        onChange={(e) => setRename(e.target.value)}\n      />\n      <div className=\"flex gap-1\">\n        <CreatePluginDialog tableId={tableId}>\n          <Button variant=\"outline\" size=\"xs\">\n            <Plus className=\"size-4 shrink-0\" />\n            <span className=\"hidden @xs/plugin-panel-header:inline\">{t('table:addPlugin')}</span>\n          </Button>\n        </CreatePluginDialog>\n        {canManage && (\n          <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"outline\" size=\"icon-xs\">\n                <MoreHorizontal className=\"size-3.5 shrink-0\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" className=\"relative min-w-36 overflow-hidden\">\n              <DropdownMenuItem\n                onSelect={() => {\n                  setRename(activePluginPanel?.name ?? null);\n                  setTimeout(() => renameRef.current?.focus(), 200);\n                }}\n              >\n                <Edit className=\"mr-1.5\" />\n                {t('common:actions.rename')}\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onSelect={() => {\n                  if (activePluginPanel?.name) {\n                    duplicatePluginPanelMutate(activePluginPanel.name);\n                  }\n                }}\n              >\n                <Copy className=\"mr-1.5\" />\n                {t('common:actions.duplicate')}\n              </DropdownMenuItem>\n              <DropdownMenuSeparator />\n              <MenuDeleteItem onConfirm={deletePluginPanelMutate} />\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n        <Button variant=\"outline\" size=\"icon-xs\" onClick={toggleVisible}>\n          <X className=\"size-4 shrink-0\" />\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/PluginPanelSelector.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Check, ChevronsUpDown, PlusCircle } from '@teable/icons';\nimport { listPluginPanels } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTablePermission } from '@teable/sdk/hooks';\nimport {\n  Button,\n  cn,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport type { ICreatePluginPanelDialogRef } from './components/CreatePluginPanelDialog';\nimport { CreatePluginPanelDialog } from './components/CreatePluginPanelDialog';\nimport { useActivePluginPanelId } from './hooks/useActivePluginPanelId';\nimport { usePluginPanelStorage } from './hooks/usePluginPanelStorage';\n\nexport const PluginPanelSelector = ({\n  tableId,\n  className,\n}: {\n  tableId: string;\n  className?: string;\n}) => {\n  const { touchActivePanel } = usePluginPanelStorage(tableId);\n  const activePluginPanelId = useActivePluginPanelId(tableId);\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const createPluginPanelDialogRef = useRef<ICreatePluginPanelDialogRef>(null);\n\n  const { data: pluginPanels } = useQuery({\n    queryKey: ReactQueryKeys.getPluginPanelList(tableId),\n    queryFn: ({ queryKey }) => listPluginPanels(queryKey[1]).then((res) => res.data),\n  });\n\n  const tablePermissions = useTablePermission();\n  const canManage = tablePermissions?.['table|update'];\n  const activePluginPanel = pluginPanels?.find(({ id }) => id === activePluginPanelId);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          size={'xs'}\n          aria-expanded={open}\n          aria-label=\"Select a team\"\n          className={cn('justify-between overflow-hidden', className)}\n        >\n          <span className=\"truncate\">{activePluginPanel?.name}</span>\n          <ChevronsUpDown className=\"ml-auto size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[200px] rounded-lg bg-background p-1 shadow-lg ring-1 ring-background/5\">\n        <Command>\n          <CommandInput placeholder={t('sdk:common.search.placeholder')} />\n          <CommandList>\n            <CommandEmpty>{t('sdk:common.search.empty')}</CommandEmpty>\n            <CommandGroup>\n              {pluginPanels?.map(({ id, name }) => (\n                <CommandItem\n                  key={id}\n                  onSelect={() => {\n                    if (id !== activePluginPanelId) {\n                      touchActivePanel(id);\n                      setOpen(false);\n                    }\n                  }}\n                  className=\"text-sm\"\n                >\n                  {name}\n                  <Check\n                    className={cn(\n                      'ml-auto h-4 w-4',\n                      activePluginPanelId === id ? 'opacity-100' : 'opacity-0'\n                    )}\n                  />\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n          {canManage && (\n            <>\n              <CommandSeparator />\n              <CommandList>\n                <CommandGroup>\n                  <CreatePluginPanelDialog\n                    ref={createPluginPanelDialogRef}\n                    tableId={tableId}\n                    onClose={() => setOpen(false)}\n                  >\n                    <CommandItem\n                      onSelect={() => {\n                        createPluginPanelDialogRef.current?.open();\n                      }}\n                    >\n                      <PlusCircle className=\"mr-2 size-5\" />\n                      {t('table:pluginPanel.createPluginPanel.button')}\n                    </CommandItem>\n                  </CreatePluginPanelDialog>\n                </CommandGroup>\n              </CommandList>\n            </>\n          )}\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/components/CreatePluginDialog.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IPluginPanelInstallRo } from '@teable/openapi';\nimport { installPluginPanel, PluginPosition } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { PluginCenterDialog } from '@/features/app/components/plugin/PluginCenterDialog';\nimport { useActivePluginPanelId } from '../hooks/useActivePluginPanelId';\n\nexport const CreatePluginDialog = ({\n  tableId,\n  children,\n}: {\n  tableId: string;\n  children: React.ReactNode;\n}) => {\n  const activePluginPanelId = useActivePluginPanelId(tableId)!;\n  const queryClient = useQueryClient();\n  const { mutate: installPlugin } = useMutation({\n    mutationFn: (ro: IPluginPanelInstallRo) => installPluginPanel(tableId, activePluginPanelId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanel(tableId, activePluginPanelId),\n      });\n    },\n  });\n\n  return (\n    <PluginCenterDialog\n      positionType={PluginPosition.Panel}\n      onInstall={(pluginId, name) => {\n        installPlugin({\n          pluginId,\n          name,\n        });\n      }}\n    >\n      {children}\n    </PluginCenterDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/components/CreatePluginPanelDialog.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { createPluginPanel, z } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Error, Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTrigger,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\nimport { tableConfig } from '@/features/i18n/table.config';\nimport { usePluginPanelStorage } from '../hooks/usePluginPanelStorage';\n\ninterface ICreatePluginPanelDialogProps {\n  tableId: string;\n  children: React.ReactNode;\n  onClose?: () => void;\n}\n\nexport interface ICreatePluginPanelDialogRef {\n  open: () => void;\n  close: () => void;\n}\n\nexport const CreatePluginPanelDialog = forwardRef<\n  ICreatePluginPanelDialogRef,\n  ICreatePluginPanelDialogProps\n>((props, ref) => {\n  const { tableId, children, onClose } = props;\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation(tableConfig.i18nNamespaces);\n  const [name, setName] = useState('');\n  const [error, setError] = useState<string>();\n  const queryClient = useQueryClient();\n  const { touchActivePanel } = usePluginPanelStorage(tableId);\n  const { mutate: createPluginPanelMutate, isPending: isLoading } = useMutation({\n    mutationFn: (name: string) => createPluginPanel(tableId, { name }),\n    onSuccess: ({ data }) => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanelList(tableId),\n      });\n      setOpen(false);\n      onClose?.();\n      touchActivePanel(data.id);\n    },\n  });\n\n  useImperativeHandle(ref, () => ({\n    open: () => {\n      setOpen(true);\n    },\n    close: () => {\n      setOpen(false);\n    },\n  }));\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent>\n        <DialogHeader>{t('table:pluginPanel.createPluginPanel.title')}</DialogHeader>\n        <div>\n          <Input\n            placeholder={t('table:pluginPanel.namePlaceholder')}\n            value={name}\n            onChange={(e) => {\n              setError(undefined);\n              setName(e.target.value);\n            }}\n          />\n          <Error error={error} />\n        </div>\n        <DialogFooter>\n          <Button\n            size={'sm'}\n            disabled={isLoading}\n            onClick={() => {\n              const valid = z\n                .string()\n                .min(1)\n                .safeParse(name || undefined);\n              if (!valid.success) {\n                setError(valid.error.issues?.[0]?.message);\n                return;\n              }\n              createPluginPanelMutate(name);\n            }}\n          >\n            {isLoading && <Spin className=\"size-4\" />}\n            {t('common:actions.confirm')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n});\n\nCreatePluginPanelDialog.displayName = 'CreatePluginPanelDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/components/PluginItem.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  duplicatePluginPanelInstalledPlugin,\n  PluginPosition,\n  removePluginPanelPlugin,\n  renamePluginPanelPlugin,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useTablePermission } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { PluginContent } from '@/features/app/components/plugin/PluginContent';\nimport { PluginHeader } from '@/features/app/components/plugin/PluginHeader';\nimport { useIsExpandPlugin } from '@/features/app/dashboard/hooks/useIsExpandPlugin';\n\nexport const PluginItem = (props: {\n  tableId: string;\n  pluginPanelId: string;\n  pluginId: string;\n  pluginName: string;\n  pluginInstallId: string;\n  pluginUrl?: string;\n  isDragging: boolean;\n}) => {\n  const { tableId, pluginPanelId, pluginId, pluginInstallId, pluginName, pluginUrl, isDragging } =\n    props;\n\n  const baseId = useBaseId()!;\n  const tablePermissions = useTablePermission();\n  const canManage = tablePermissions?.['table|update'];\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const isExpandPlugin = useIsExpandPlugin();\n  const { t } = useTranslation(['common']);\n\n  const { mutate: removePluginMutate } = useMutation({\n    mutationFn: () => removePluginPanelPlugin(tableId, pluginPanelId, pluginInstallId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanel(tableId, pluginPanelId),\n      });\n    },\n  });\n\n  const { mutate: renamePluginMutate } = useMutation({\n    mutationFn: (name: string) =>\n      renamePluginPanelPlugin(tableId, pluginPanelId, pluginInstallId, name),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanel(tableId, pluginPanelId),\n      });\n    },\n  });\n\n  const { mutate: duplicatePluginMutate } = useMutation({\n    mutationFn: (name: string) =>\n      duplicatePluginPanelInstalledPlugin(tableId, pluginPanelId, pluginInstallId, { name }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: ReactQueryKeys.getPluginPanel(tableId, pluginPanelId),\n      });\n    },\n  });\n\n  const onCopy = useCallback(async () => {\n    await duplicatePluginMutate(`${pluginName} ${t('common:noun.copy')}`);\n  }, [duplicatePluginMutate, pluginName, t]);\n\n  const onExpand = useCallback(() => {\n    const query = { ...router.query, expandPluginId: pluginInstallId };\n    router.push(\n      {\n        pathname: router.pathname,\n        query,\n      },\n      undefined,\n      { shallow: true }\n    );\n  }, [pluginInstallId, router]);\n\n  const onClose = () => {\n    const query = { ...router.query };\n    delete query.expandPluginId;\n    router.push(\n      {\n        pathname: router.pathname,\n        query,\n      },\n      undefined,\n      { shallow: true }\n    );\n  };\n\n  const isExpanded = isExpandPlugin(pluginInstallId);\n\n  return (\n    <div\n      className={cn('h-full', {\n        'fixed top-0 left-0 right-0 bottom-0 bg-black/20 flex items-center justify-center z-10':\n          isExpanded,\n      })}\n      onClick={onClose}\n    >\n      <div\n        className={cn(\n          'group flex h-full flex-col overflow-hidden rounded-xl border bg-background',\n          {\n            'md:w-[90%] h-[90%] w-full mx-4': isExpanded,\n            'pointer-events-none select-none': isDragging,\n          }\n        )}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <PluginHeader\n          name={pluginName}\n          onDelete={removePluginMutate}\n          onNameChange={renamePluginMutate}\n          onExpand={onExpand}\n          onClose={onClose}\n          isExpanded={isExpanded}\n          canManage={canManage}\n          draggableHandleClassName=\"plugin-panel-draggable-handle\"\n          dragging={isDragging}\n          onCopy={onCopy}\n        />\n        <PluginContent\n          baseId={baseId}\n          dragging={isDragging}\n          positionType={PluginPosition.Panel}\n          pluginId={pluginId}\n          pluginUrl={pluginUrl}\n          tableId={tableId}\n          pluginInstallId={pluginInstallId}\n          positionId={pluginPanelId}\n          onExpand={onExpand}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/hooks/useActivePluginPanelId.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { listPluginPanels } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { usePluginPanelStore } from './usePluginPanelStore';\n\nexport const useActivePluginPanelId = (tableId: string) => {\n  const { tables } = usePluginPanelStore();\n  const { data: pluginPanels } = useQuery({\n    queryKey: ReactQueryKeys.getPluginPanelList(tableId),\n    queryFn: ({ queryKey }) => listPluginPanels(queryKey[1]).then((res) => res.data),\n  });\n  return tables[tableId]?.activePanel ?? pluginPanels?.[0]?.id;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/hooks/usePluginPanelStorage.ts",
    "content": "import { useCallback, useMemo } from 'react';\nimport { DEFAULT_PLUGIN_PANEL_WIDTH, usePluginPanelStore } from './usePluginPanelStore';\n\nexport const usePluginPanelStorage = (tableId: string) => {\n  const {\n    tables,\n    toggleVisible: _toggleVisible,\n    updateWidth: _updateWidth,\n    touchActivePanel: _touchActivePanel,\n  } = usePluginPanelStore();\n\n  const updateWidth = useCallback(\n    (width: string) => {\n      _updateWidth(tableId, width);\n    },\n    [tableId, _updateWidth]\n  );\n\n  const toggleVisible = useCallback(() => {\n    _toggleVisible(tableId);\n  }, [tableId, _toggleVisible]);\n\n  const touchActivePanel = useCallback(\n    (panelId: string) => {\n      _touchActivePanel(tableId, panelId);\n    },\n    [tableId, _touchActivePanel]\n  );\n\n  const table = tables[tableId];\n  const isVisible = table?.isVisible ?? false;\n  const width = table?.width ?? DEFAULT_PLUGIN_PANEL_WIDTH;\n\n  return useMemo(() => {\n    return {\n      isVisible,\n      width,\n      toggleVisible,\n      updateWidth,\n      touchActivePanel,\n    };\n  }, [isVisible, width, toggleVisible, updateWidth, touchActivePanel]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/plugin-panel/hooks/usePluginPanelStore.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface ITableState {\n  isVisible: boolean;\n  width: string;\n  lastAccessed: number;\n  activePanel?: string;\n}\n\ninterface IPluginPanelState {\n  tables: Record<string, ITableState>;\n  toggleVisible: (tableId: string) => void;\n  updateWidth: (tableId: string, width: string) => void;\n  touchActivePanel: (tableId: string, panelId: string) => void;\n}\n\nexport const DEFAULT_PLUGIN_PANEL_WIDTH = '25%';\nconst MAX_TABLES = 30;\n\nconst createDefaultState = (): ITableState => ({\n  isVisible: false,\n  width: DEFAULT_PLUGIN_PANEL_WIDTH,\n  lastAccessed: Date.now(),\n});\n\nconst checkAndCleanTables = (tables: Record<string, ITableState>, tableId: string) => {\n  if (!tables[tableId]) {\n    const tableIds = Object.entries(tables);\n    if (tableIds.length >= MAX_TABLES) {\n      const oldestId = tableIds.sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed)[0][0];\n      delete tables[oldestId];\n    }\n  }\n  return tables;\n};\n\nexport const usePluginPanelStore = create<IPluginPanelState>()(\n  persist(\n    (set) => ({\n      tables: {},\n      toggleVisible: (tableId: string) =>\n        set((state) => {\n          const tables = checkAndCleanTables({ ...state.tables }, tableId);\n          return {\n            tables: {\n              ...tables,\n              [tableId]: {\n                ...(tables[tableId] || createDefaultState()),\n                isVisible: !tables[tableId]?.isVisible,\n                lastAccessed: Date.now(),\n              },\n            },\n          };\n        }),\n\n      updateWidth: (tableId: string, width: string) =>\n        set((state) => {\n          const tables = checkAndCleanTables({ ...state.tables }, tableId);\n          return {\n            tables: {\n              ...tables,\n              [tableId]: {\n                ...(tables[tableId] || createDefaultState()),\n                width,\n                lastAccessed: Date.now(),\n              },\n            },\n          };\n        }),\n\n      touchActivePanel: (tableId: string, panelId: string) =>\n        set((state) => {\n          const tables = checkAndCleanTables({ ...state.tables }, tableId);\n          return {\n            tables: {\n              ...tables,\n              [tableId]: {\n                ...(tables[tableId] || createDefaultState()),\n                lastAccessed: Date.now(),\n                activePanel: panelId,\n              },\n            },\n          };\n        }),\n    }),\n    {\n      name: LocalStorageKeys.PluginPanel,\n      partialize: (state) => ({\n        tables: state.tables,\n      }),\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/Account.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { updateUserAvatar, updateUserName } from '@teable/openapi';\nimport { useIsTouchDevice, useSession } from '@teable/sdk';\nimport {\n  Button,\n  Input,\n  Label,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { UserAvatar } from '@/features/app/components/user/UserAvatar';\nimport { AddPassword } from './account/AddPassword';\nimport { ChangeEmailDialog } from './account/ChangeEmailDialog';\nimport { ChangePasswordDialog } from './account/ChangePasswordDialog';\nimport { DeleteAccountDialog } from './account/DeleteAccountDialog';\nimport { SettingTabHeader, SettingTabShell } from './SettingTabShell';\n\nexport const Account: React.FC = () => {\n  const { user: sessionUser, refresh, refreshAvatar } = useSession();\n  const { t } = useTranslation('common');\n  const isTouchDevice = useIsTouchDevice();\n\n  const updateUserAvatarMutation = useMutation({\n    mutationFn: updateUserAvatar,\n    onSuccess: () => {\n      refreshAvatar?.();\n    },\n  });\n\n  const updateUserNameMutation = useMutation({\n    mutationFn: updateUserName,\n    onSuccess: () => {\n      refresh?.();\n    },\n  });\n\n  const toggleRenameUser = (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    const name = e.target.value;\n    if (name && name !== sessionUser.name) {\n      updateUserNameMutation.mutate({ name });\n    }\n  };\n\n  const uploadAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const avatarFille = e.target.files?.[0];\n    if (!avatarFille) {\n      return;\n    }\n    const formData = new FormData();\n    formData.append('file', avatarFille);\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    updateUserAvatarMutation.mutate(formData as any);\n  };\n\n  const avatarComponent = (\n    <div className=\"group relative flex h-fit items-center justify-center\">\n      <UserAvatar className=\"size-14 border\" user={sessionUser} />\n      <div className=\"absolute left-0 top-0 size-full rounded-full bg-transparent group-hover:bg-muted-foreground/20\">\n        <input\n          type=\"file\"\n          className=\"absolute inset-0 size-full opacity-0\"\n          accept=\"image/*\"\n          onChange={uploadAvatar}\n        />\n      </div>\n    </div>\n  );\n\n  return (\n    <SettingTabShell\n      header={<SettingTabHeader title={t('settings.account.title')} />}\n      footer={\n        <div className=\"flex w-full items-center justify-center text-xs text-muted-foreground\">\n          {`${t('settings.setting.version')}: ${process.env.NEXT_PUBLIC_BUILD_VERSION}`}\n        </div>\n      }\n    >\n      <div className=\"flex h-full flex-col justify-between gap-6\">\n        <div className=\"flex flex-1 flex-col gap-6\">\n          <div className=\"flex flex-col items-start justify-start\">\n            {isTouchDevice ? (\n              avatarComponent\n            ) : (\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>{avatarComponent}</TooltipTrigger>\n                  <TooltipContent>\n                    <p>{t('settings.account.updatePhoto')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            )}\n            <div className=\"flex-1 pt-4\">\n              <Input\n                className=\"max-w-[320px]\"\n                defaultValue={sessionUser.name}\n                onBlur={(e) => toggleRenameUser(e)}\n              />\n              <Label className=\"text-xs font-normal text-muted-foreground\" htmlFor=\"Preferred name\">\n                {t('settings.account.updateNameDesc')}\n              </Label>\n            </div>\n          </div>\n          <div>\n            <h3 className=\"mb-3 text-sm font-medium\">\n              {t('settings.account.securityTitle')}\n              {!sessionUser.hasPassword && <AddPassword />}\n            </h3>\n            <div className=\"space-y-3\">\n              <div className=\"flex items-center justify-between rounded-md border bg-card px-4 py-3\">\n                <div className=\"flex flex-col gap-1\">\n                  <p className=\"text-sm font-medium\">{t('settings.account.email')}</p>\n                  <div className=\"text-xs text-muted-foreground\">{sessionUser.email}</div>\n                </div>\n                <ChangeEmailDialog>\n                  <Button className=\"float-right\" size={'sm'} variant={'outline'}>\n                    {t('settings.account.changeEmail.title')}\n                  </Button>\n                </ChangeEmailDialog>\n              </div>\n              {sessionUser.hasPassword && (\n                <div className=\"flex items-center justify-between rounded-md border bg-card px-4 py-3\">\n                  <div className=\"flex flex-col gap-1\">\n                    <p className=\"text-sm font-medium\">{t('settings.account.password')}</p>\n                    <div className=\"text-xs text-muted-foreground\">\n                      {t('settings.account.passwordDesc')}\n                    </div>\n                  </div>\n                  <ChangePasswordDialog>\n                    <Button className=\"float-right\" size={'sm'} variant={'outline'}>\n                      {t('settings.account.changePassword.title')}\n                    </Button>\n                  </ChangePasswordDialog>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n        <div>\n          <DeleteAccountDialog />\n        </div>\n      </div>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/InteractionSelect.tsx",
    "content": "import { InteractionMode, useInteractionModeStore } from '@teable/sdk/store';\nimport {\n  Select,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n  SelectItem,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\nexport const InteractionSelect = () => {\n  const { t } = useTranslation('common');\n  const { interactionMode: interactionType, updateInteractionMode: updateInteractionType } =\n    useInteractionModeStore();\n\n  const items = useMemo(() => {\n    return [\n      {\n        value: InteractionMode.Mouse,\n        label: t('settings.setting.mouseMode'),\n      },\n      {\n        value: InteractionMode.Touch,\n        label: t('settings.setting.touchMode'),\n      },\n      {\n        value: InteractionMode.System,\n        label: t('settings.setting.systemMode'),\n      },\n    ];\n  }, [t]);\n\n  return (\n    <Select value={interactionType} onValueChange={updateInteractionType}>\n      <SelectTrigger className=\"w-auto min-w-32 text-[13px]\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent>\n        {items.map(({ value, label }) => (\n          <SelectItem key={value} value={value}>\n            {label}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/Notifications.tsx",
    "content": "import { updateUserNotifyMeta } from '@teable/openapi';\nimport { useSession } from '@teable/sdk';\nimport { Switch } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { SettingTabHeader, SettingTabShell } from './SettingTabShell';\n\nexport const Notifications: React.FC = () => {\n  const { t } = useTranslation('common');\n  const { user: sessionUser, refresh } = useSession();\n  const onNotifyMetaEmailSwitchChange = (check: boolean) => {\n    updateUserNotifyMeta({ email: check }).then(() => refresh?.());\n  };\n\n  return (\n    <SettingTabShell header={<SettingTabHeader title={t('settings.notify.title')} />}>\n      <div className=\"flex items-center justify-between gap-4 rounded-md border bg-card px-4 py-3\">\n        <div className=\"flex flex-col gap-1\">\n          <p className=\"text-sm font-medium\">{t('settings.notify.label')}</p>\n          <p className=\"text-xs text-muted-foreground\">{t('settings.notify.desc')}</p>\n        </div>\n        <Switch\n          id=\"notify-meta-email\"\n          checked={Boolean(sessionUser?.notifyMeta?.email)}\n          onCheckedChange={onNotifyMetaEmailSwitchChange}\n        />\n      </div>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/SettingDialog.tsx",
    "content": "import { Bell, Key, Link, Lock, Settings, User } from '@teable/icons';\nimport { useIsTouchDevice } from '@teable/sdk/hooks';\nimport {\n  Dialog,\n  DialogContent,\n  Sheet,\n  SheetContent,\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { System } from '@/features/app/components/setting/System';\nimport { settingConfig } from '@/features/i18n/setting.config';\nimport { Account } from './Account';\nimport { Integration } from './integration/Integration';\nimport { Notifications } from './Notifications';\nimport { OAuthAppSection } from './oauth-app';\nimport { PersonalAccessTokenSection } from './personal-access-token';\nimport { SettingTab, useSettingStore } from './useSettingStore';\n\nexport const SettingDialog = () => {\n  const { t } = useTranslation(settingConfig.i18nNamespaces);\n  const isTouchDevice = useIsTouchDevice();\n  const { open, setOpen, tab, setTab } = useSettingStore();\n\n  const tabList = useMemo(() => {\n    return [\n      {\n        key: SettingTab.Profile,\n        name: t('settings.account.tab'),\n        Icon: User,\n      },\n      {\n        key: SettingTab.System,\n        name: t('settings.setting.title'),\n        Icon: Settings,\n      },\n      {\n        key: SettingTab.Notifications,\n        name: t('settings.notify.title'),\n        Icon: Bell,\n      },\n      {\n        key: SettingTab.Integration,\n        name: t('settings.integration.title'),\n        Icon: Link,\n      },\n      {\n        key: SettingTab.PersonalAccessToken,\n        name: t('setting:personalAccessToken'),\n        Icon: Key,\n      },\n      {\n        key: SettingTab.OAuthApp,\n        name: t('setting:oauthApps'),\n        Icon: Lock,\n      },\n    ];\n  }, [t]);\n\n  const content = (\n    <Tabs\n      defaultValue={SettingTab.Profile}\n      value={tab}\n      onValueChange={(value) => setTab(value as SettingTab)}\n      className=\"flex h-full gap-0 overflow-hidden\"\n    >\n      <TabsList className=\"flex h-full w-fit max-w-72 flex-col items-start justify-start gap-1 rounded-none border-none bg-muted p-4\">\n        {tabList.map(({ key, name, Icon }) => {\n          return (\n            <TabsTrigger\n              key={key}\n              value={key}\n              className=\"h-8 w-full cursor-pointer justify-start gap-2 rounded-md font-normal data-[state=active]:bg-surface data-[state=active]:font-medium data-[state=active]:shadow-none hover:bg-surface\"\n            >\n              <Icon className=\"size-5 shrink-0 sm:size-4\" />\n              <span className=\"hidden sm:inline\">{name}</span>\n            </TabsTrigger>\n          );\n        })}\n      </TabsList>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.Profile}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <Account />\n      </TabsContent>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.System}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <System />\n      </TabsContent>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.Notifications}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <Notifications />\n      </TabsContent>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.Integration}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <Integration />\n      </TabsContent>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.PersonalAccessToken}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <PersonalAccessTokenSection />\n      </TabsContent>\n      <TabsContent\n        tabIndex={-1}\n        value={SettingTab.OAuthApp}\n        className=\"mt-0 size-full overflow-y-auto overflow-x-hidden\"\n      >\n        <OAuthAppSection />\n      </TabsContent>\n    </Tabs>\n  );\n\n  return (\n    <>\n      {isTouchDevice ? (\n        <Sheet open={open} onOpenChange={setOpen}>\n          <SheetContent\n            className=\"h-5/6 rounded-t-lg px-1 pb-0 pt-4 [&>button]:right-4 [&>button]:top-4 \"\n            side=\"bottom\"\n          >\n            {content}\n          </SheetContent>\n        </Sheet>\n      ) : (\n        <Dialog open={open} onOpenChange={setOpen}>\n          <DialogContent\n            className=\"h-4/5 max-h-[80vh] max-w-6xl overflow-hidden p-0 [&>button]:right-4 [&>button]:top-4 \"\n            onOpenAutoFocus={(e) => e.preventDefault()}\n          >\n            {content}\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/SettingTabShell.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\nimport type { ReactNode } from 'react';\n\ntype SettingTabShellProps = {\n  header?: ReactNode;\n  children: ReactNode;\n  footer?: ReactNode;\n  className?: string;\n  headerClassName?: string;\n  contentClassName?: string;\n  footerClassName?: string;\n};\n\ntype SettingTabHeaderProps = {\n  title: ReactNode;\n  description?: ReactNode;\n  actions?: ReactNode;\n  leading?: ReactNode;\n  className?: string;\n  titleClassName?: string;\n  descriptionClassName?: string;\n};\n\nexport const SettingTabHeader = ({\n  title,\n  description,\n  actions,\n  leading,\n  className,\n  titleClassName,\n  descriptionClassName,\n}: SettingTabHeaderProps) => {\n  const hasDescription = Boolean(description);\n  return (\n    <div\n      className={cn(\n        'flex w-full justify-between gap-4',\n        hasDescription ? 'items-start' : 'items-center',\n        className\n      )}\n    >\n      <div className={cn('flex flex-1 gap-2', hasDescription ? 'items-start' : 'items-center')}>\n        {leading}\n        <div className=\"flex flex-col gap-1\">\n          <div className={cn('text-base font-semibold leading-6', titleClassName)}>{title}</div>\n          {description && (\n            <div className={cn('line-clamp-2 text-sm text-muted-foreground', descriptionClassName)}>\n              {description}\n            </div>\n          )}\n        </div>\n      </div>\n      {actions && (\n        <div className=\"flex shrink-0 flex-wrap items-center justify-end gap-2\">{actions}</div>\n      )}\n    </div>\n  );\n};\n\nexport const SettingTabShell = ({\n  header,\n  children,\n  footer,\n  className,\n  headerClassName,\n  contentClassName,\n  footerClassName,\n}: SettingTabShellProps) => {\n  return (\n    <div\n      className={cn(\n        'teable-setting-tab-shell flex h-full flex-col border-l bg-background',\n        className\n      )}\n    >\n      {header && (\n        <div\n          className={cn(\n            'teable-setting-tab-shell__header flex items-start justify-between gap-3 border-b pl-6 py-3 pr-10',\n            headerClassName\n          )}\n        >\n          {header}\n        </div>\n      )}\n      <div\n        className={cn(\n          'teable-setting-tab-shell__content flex-1 overflow-y-auto px-6 py-4',\n          contentClassName\n        )}\n      >\n        {children}\n      </div>\n      {footer && <div className={cn('px-8 py-4', footerClassName)}>{footer}</div>}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/System.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { Label, RadioGroup, RadioGroupItem } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { LanguagePicker } from '../LanguagePicker';\nimport { InteractionSelect } from './InteractionSelect';\nimport { SettingTabHeader, SettingTabShell } from './SettingTabShell';\n\nexport const System: React.FC = () => {\n  const { t } = useTranslation('common');\n  const { theme, setTheme } = useTheme();\n\n  const isSupportsMultiplePointers = useMemo(() => {\n    const touchSupported: boolean =\n      'ontouchstart' in window ||\n      navigator.maxTouchPoints > 0 ||\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      (navigator as any).msMaxTouchPoints > 0;\n    const mouseSupported: boolean =\n      window.matchMedia('(pointer: fine)').matches || window.matchMedia('(hover: hover)').matches;\n    return touchSupported && mouseSupported;\n  }, []);\n\n  return (\n    <SettingTabShell header={<SettingTabHeader title={t('settings.setting.title')} />}>\n      <div className=\"flex flex-col gap-6\">\n        <div className=\"flex flex-col gap-3\">\n          <div className=\"flex flex-col gap-1\">\n            <h3 className=\"text-sm font-medium text-foreground\">{t('settings.setting.theme')}</h3>\n            <p className=\"text-xs text-muted-foreground\">{t('settings.setting.themeDesc')}</p>\n          </div>\n          <RadioGroup\n            className=\"flex w-full justify-evenly\"\n            defaultValue={theme}\n            onValueChange={(value) => {\n              setTheme(value);\n            }}\n          >\n            <div className=\"w-[206px]\">\n              <RadioGroupItem value=\"light\" id=\"light\" className=\"peer sr-only\" />\n              <Label\n                htmlFor=\"light\"\n                className=\"flex cursor-pointer flex-col rounded-lg border-2 border-transparent bg-popover p-1 peer-data-[state=checked]:border-primary peer-data-[state=checked]:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.06)] hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary\"\n              >\n                <Image\n                  className=\"overflow-hidden rounded-md border\"\n                  src={'/images/theme/theme-light.png'}\n                  alt=\"\"\n                  width={198}\n                  height={132}\n                />\n              </Label>\n              <span className=\"mt-1 block w-full text-center text-sm font-normal\">\n                {t('settings.setting.light')}\n              </span>\n            </div>\n            <div className=\"w-[206px]\">\n              <RadioGroupItem value=\"dark\" id=\"dark\" className=\"peer sr-only\" />\n              <Label\n                htmlFor=\"dark\"\n                className=\"flex cursor-pointer flex-col rounded-lg border-2 border-transparent bg-popover p-1 peer-data-[state=checked]:border-primary peer-data-[state=checked]:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.06)] hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary\"\n              >\n                <Image\n                  className=\"overflow-hidden rounded-md border\"\n                  src={'/images/theme/theme-dark.png'}\n                  alt=\"\"\n                  width={198}\n                  height={132}\n                />\n              </Label>\n              <span className=\"mt-1 block w-full text-center text-sm font-normal\">\n                {t('settings.setting.dark')}\n              </span>\n            </div>\n            <div className=\"w-[206px]\">\n              <RadioGroupItem value=\"system\" id=\"system\" className=\"peer sr-only\" />\n              <Label\n                htmlFor=\"system\"\n                className=\"flex cursor-pointer flex-col rounded-lg border-2 border-transparent bg-popover p-1 peer-data-[state=checked]:border-primary peer-data-[state=checked]:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.06)] hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary\"\n              >\n                <Image\n                  className=\"overflow-hidden rounded-md border\"\n                  src={'/images/theme/theme-system.png'}\n                  alt=\"\"\n                  width={198}\n                  height={132}\n                />\n              </Label>\n              <span className=\"mt-1 block w-full text-center text-sm font-normal\">\n                {t('settings.setting.system')}\n              </span>\n            </div>\n          </RadioGroup>\n        </div>\n        <div>\n          <h3 className=\"text-sm font-medium text-foreground\">{t('settings.setting.language')}</h3>\n          <div className=\"pt-2\">\n            <LanguagePicker />\n          </div>\n        </div>\n        {isSupportsMultiplePointers && (\n          <div>\n            <h3 className=\"text-sm font-medium text-foreground\">\n              {t('settings.setting.interactionMode')}\n            </h3>\n            <div className=\"pt-2\">\n              <InteractionSelect />\n            </div>\n          </div>\n        )}\n      </div>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/account/AddPassword.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { addPassword, passwordSchema } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Error, Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\n\nexport const AddPassword = () => {\n  const [newPassword, setNewPassword] = useState('');\n  const [confirmPassword, setConfirmPassword] = useState('');\n  const { t } = useTranslation('common');\n  const [error, setError] = useState('');\n  const [open, setOpen] = useState(false);\n  const { refresh } = useSession();\n\n  const { mutateAsync: addPasswordMutate, isPending: isLoading } = useMutation({\n    mutationFn: addPassword,\n    onSuccess: () => {\n      toast.success(t('settings.account.addPasswordSuccess.title'));\n      setOpen(false);\n      refresh();\n    },\n  });\n\n  useEffect(() => {\n    if (newPassword && confirmPassword && newPassword !== confirmPassword) {\n      setError(t('settings.account.addPasswordError.disMatch'));\n      return;\n    }\n    if (newPassword && confirmPassword && !passwordSchema.safeParse(newPassword).success) {\n      setError(t('password.setInvalid'));\n      return;\n    }\n    setError('');\n  }, [newPassword, confirmPassword, t]);\n\n  const handleSubmit = () => {\n    if (error || !newPassword || !confirmPassword || isLoading) {\n      return;\n    }\n    addPasswordMutate({ password: newPassword });\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button variant={'link'} className=\"text-xs text-blue-500 hover:text-blue-700\">\n          {t('settings.account.addPassword.title')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"md:w-80\">\n        <DialogHeader>\n          <DialogTitle className=\"text-center text-sm\">\n            {t('settings.account.addPassword.title')}\n          </DialogTitle>\n          <DialogDescription className=\"text-center text-xs\">\n            {t('settings.account.addPassword.desc')}\n          </DialogDescription>\n        </DialogHeader>\n        <div>\n          <div className=\"space-y-2\">\n            <div className=\"space-y-1\">\n              <Label className=\"text-xs text-muted-foreground\" htmlFor=\"newPassword\">\n                {t('settings.account.addPassword.password')}\n              </Label>\n              <Input\n                size=\"sm\"\n                id=\"newPassword\"\n                autoComplete=\"new-password\"\n                type=\"password\"\n                value={newPassword}\n                onChange={(e) => setNewPassword(e.target.value)}\n                aria-autocomplete=\"inline\"\n              />\n            </div>\n            <div className=\"space-y-1\">\n              <Label className=\"text-xs text-muted-foreground\" htmlFor=\"confirmPassword\">\n                {t('settings.account.addPassword.confirm')}\n              </Label>\n              <Input\n                size=\"sm\"\n                id=\"confirmPassword\"\n                autoComplete=\"new-password\"\n                type=\"password\"\n                value={confirmPassword}\n                onChange={(e) => setConfirmPassword(e.target.value)}\n                aria-autocomplete=\"inline\"\n              />\n            </div>\n          </div>\n          <Error error={error} />\n        </div>\n        <DialogFooter className=\"flex-col space-y-2 sm:flex-col sm:space-x-0\">\n          <Button size={'sm'} className=\"w-full\" type=\"submit\" onClick={handleSubmit}>\n            {isLoading && <Spin className=\"mr-1 size-4\" />}\n            {t('settings.account.addPassword.title')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/account/ChangeEmailDialog.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { HttpError } from '@teable/core';\nimport { HttpErrorCode } from '@teable/core';\nimport { Check } from '@teable/icons';\nimport type { ISendChangeEmailCodeRo } from '@teable/openapi';\nimport { changeEmail, sendChangeEmailCode } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Error as ErrorComponent, Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\nimport { useCutDown } from '@/features/app/hooks/useCutDown';\nimport { usePublicSettingQuery } from '@/features/app/hooks/useSetting';\n\nexport function ChangeEmailDialog({ children }: { children: React.ReactNode }) {\n  const { t } = useTranslation('common');\n  const [currentPassword, setCurrentPassword] = useState('');\n  const [newEmail, setNewEmail] = useState('');\n  const [code, setCode] = useState('');\n  const [error, setError] = useState('');\n  const [sendSuccess, setSendSuccess] = useState(false);\n  const [token, setToken] = useState('');\n  const { user } = useSession();\n  const router = useRouter();\n  const { countdown, setCountdown } = useCutDown();\n\n  const { data: setting } = usePublicSettingQuery();\n  const { changeEmailSendCodeMailRate } = setting ?? {};\n\n  useEffect(() => {\n    setError('');\n  }, [currentPassword, newEmail, code]);\n\n  const { mutate: sendChangeEmailCodeMutation, isPending: sendChangeEmailCodeLoading } =\n    useMutation({\n      mutationFn: (ro: ISendChangeEmailCodeRo) => {\n        if (ro.email === user.email) {\n          throw new Error(t('settings.account.changeEmail.error.invalidSameEmail'));\n        }\n        return sendChangeEmailCode(ro);\n      },\n      onSuccess: (data) => {\n        setToken(data.data.token);\n        setSendSuccess(true);\n        setTimeout(() => {\n          setSendSuccess(false);\n        }, 2000);\n        toast.success(t('settings.account.changeEmail.success.sendSuccess'));\n        if (typeof changeEmailSendCodeMailRate === 'number' && changeEmailSendCodeMailRate > 0) {\n          setCountdown(changeEmailSendCodeMailRate);\n        }\n      },\n      meta: {\n        preventGlobalError: true,\n      },\n      onError: (error: HttpError) => {\n        if (error.code === HttpErrorCode.CONFLICT) {\n          setError(t('settings.account.changeEmail.error.invalidConflict'));\n        } else if (error.code === HttpErrorCode.INVALID_CREDENTIALS) {\n          setError(t('settings.account.changeEmail.error.invalidPassword'));\n        } else if (\n          error.code === HttpErrorCode.TOO_MANY_REQUESTS &&\n          error.data &&\n          typeof error.data === 'object' &&\n          'seconds' in error.data\n        ) {\n          setError(\n            t('settings.account.changeEmail.error.sendMailRateLimit', {\n              seconds: error.data.seconds,\n            })\n          );\n          return;\n        } else {\n          setError(error.message);\n        }\n      },\n    });\n\n  const {\n    mutate: changeEmailMutation,\n    isPending: changeEmailLoading,\n    isSuccess,\n  } = useMutation({\n    mutationFn: changeEmail,\n    onSuccess: () => {\n      toast.success(t('settings.account.changeEmail.success.title'), {\n        description: t('settings.account.changeEmail.success.desc'),\n      });\n      setTimeout(() => {\n        router.reload();\n      }, 2000);\n    },\n    meta: {\n      preventGlobalError: true,\n    },\n    onError: (error: HttpError) => {\n      if (error.code === HttpErrorCode.INVALID_CAPTCHA) {\n        setError(t('settings.account.changeEmail.error.invalidCode'));\n      } else {\n        setError(error.message);\n      }\n    },\n  });\n\n  return (\n    <Dialog>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"md:w-[400px]\">\n        <DialogHeader>\n          <DialogTitle className=\"text-base\">{t('settings.account.changeEmail.title')}</DialogTitle>\n          <DialogDescription className=\"text-sm\">\n            {t('settings.account.changeEmail.desc')}\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-2\">\n          <div className=\"space-y-1\">\n            <Label className=\"font-normal text-foreground\" htmlFor=\"currentPassword\">\n              {t('settings.account.changeEmail.current')}\n            </Label>\n            <Input\n              size=\"sm\"\n              id=\"currentPassword\"\n              autoComplete=\"current-password\"\n              type=\"password\"\n              value={currentPassword}\n              onChange={(e) => setCurrentPassword(e.target.value)}\n              aria-autocomplete=\"inline\"\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"font-normal text-foreground\" htmlFor=\"newEmail\">\n              {t('settings.account.changeEmail.new')}\n            </Label>\n            <Input\n              size=\"sm\"\n              id=\"newEmail\"\n              autoComplete=\"new-email\"\n              type=\"email\"\n              value={newEmail}\n              onChange={(e) => setNewEmail(e.target.value)}\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <Label className=\"font-normal text-foreground\" htmlFor=\"code\">\n                {t('settings.account.changeEmail.code')}\n              </Label>\n              <Button\n                size={'sm'}\n                variant={'outline'}\n                onClick={() =>\n                  !sendSuccess &&\n                  sendChangeEmailCodeMutation({ email: newEmail, password: currentPassword })\n                }\n                disabled={\n                  sendChangeEmailCodeLoading || !newEmail || !currentPassword || countdown > 0\n                }\n              >\n                {sendChangeEmailCodeLoading && <Spin className=\"size-4\" />}\n                {sendSuccess && <Check className=\"size-4 text-green-500 dark:text-green-400\" />}\n                {countdown > 0 ? `${countdown}s` : t('settings.account.changeEmail.getCode')}\n              </Button>\n            </div>\n            <Input\n              size=\"sm\"\n              id=\"code\"\n              type=\"text\"\n              value={code}\n              onChange={(e) => setCode(e.target.value)}\n            />\n          </div>\n          <ErrorComponent className=\"!mt-4 text-xs\" error={error} />\n        </div>\n\n        <Button\n          className=\"w-full\"\n          size={'sm'}\n          onClick={() => changeEmailMutation({ email: newEmail, token, code })}\n          disabled={changeEmailLoading || isSuccess}\n        >\n          {changeEmailLoading && <Spin className=\"size-4\" />}\n          {t('actions.confirm')}\n        </Button>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/account/ChangePasswordDialog.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { HttpError } from '@teable/core';\nimport { changePassword, changePasswordRoSchema } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\ninterface IChangePasswordDialogProps {\n  children?: React.ReactNode;\n}\nexport const ChangePasswordDialog = (props: IChangePasswordDialogProps) => {\n  const { children } = props;\n  const { t } = useTranslation('common');\n  const router = useRouter();\n  const { user } = useSession();\n  const [newPassword, setNewPassword] = useState('');\n  const [confirmPassword, setConfirmPassword] = useState('');\n  const [currentPassword, setCurrentPassword] = useState('');\n  const [error, setError] = useState('');\n\n  const {\n    mutate: changePasswordMutate,\n    isPending: isLoading,\n    isSuccess,\n  } = useMutation({\n    mutationFn: changePassword,\n    onSuccess: () => {\n      toast.success(t('settings.account.changePasswordSuccess.title'), {\n        description: t('settings.account.changePasswordSuccess.desc'),\n      });\n      setTimeout(() => {\n        router.reload();\n      }, 2000);\n    },\n    onError: (err: HttpError) => {\n      console.error(err.message);\n      setError(t('settings.account.changePasswordError.invalid'));\n    },\n  });\n\n  const checkConfirmEqual = () => {\n    if (newPassword && confirmPassword && newPassword !== confirmPassword) {\n      setError(t('settings.account.changePasswordError.disMatch'));\n      return;\n    }\n    if (newPassword && confirmPassword && currentPassword === newPassword) {\n      setError(t('settings.account.changePasswordError.equal'));\n      return;\n    }\n    setError('');\n  };\n\n  const reset = () => {\n    setNewPassword('');\n    setConfirmPassword('');\n    setCurrentPassword('');\n    setError('');\n  };\n\n  const disableSubmitBtn =\n    !currentPassword || !newPassword || !confirmPassword || newPassword !== confirmPassword;\n\n  const handleSubmit = async () => {\n    const valid = changePasswordRoSchema.safeParse({ password: currentPassword, newPassword });\n    if (!valid.success) {\n      setError(t('password.setInvalid'));\n      return;\n    }\n    changePasswordMutate({ password: currentPassword, newPassword });\n  };\n\n  return (\n    <Dialog onOpenChange={reset}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"md:w-[400px]\">\n        <DialogHeader>\n          <DialogTitle className=\"text-base\">\n            {t('settings.account.changePassword.title')}\n          </DialogTitle>\n          <DialogDescription className=\"text-sm\">\n            {t('settings.account.changePassword.desc')}\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"space-y-2\">\n          <div className=\"space-y-1\">\n            <Input\n              className=\"visible m-0 h-0 border-0 p-0 text-[0]\"\n              type=\"text\"\n              name=\"email\"\n              autoComplete=\"email\"\n              readOnly\n              value={user.email}\n            />\n            <Label className=\"font-normal text-foreground\" htmlFor=\"currentPassword\">\n              {t('settings.account.changePassword.current')}\n            </Label>\n            <Input\n              size=\"sm\"\n              id=\"currentPassword\"\n              autoComplete=\"current-password\"\n              type=\"password\"\n              value={currentPassword}\n              onChange={(e) => setCurrentPassword(e.target.value)}\n              aria-autocomplete=\"inline\"\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"font-normal text-foreground\" htmlFor=\"newPassword\">\n              {t('settings.account.changePassword.new')}\n            </Label>\n            <Input\n              size=\"sm\"\n              id=\"newPassword\"\n              autoComplete=\"new-password\"\n              type=\"password\"\n              value={newPassword}\n              onChange={(e) => setNewPassword(e.target.value)}\n              onBlur={checkConfirmEqual}\n              aria-autocomplete=\"inline\"\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"font-normal text-foreground\" htmlFor=\"confirmPassword\">\n              {t('settings.account.changePassword.confirm')}\n            </Label>\n            <Input\n              size=\"sm\"\n              id=\"confirmPassword\"\n              autoComplete=\"new-password\"\n              type=\"password\"\n              value={confirmPassword}\n              onChange={(e) => setConfirmPassword(e.target.value)}\n              onBlur={checkConfirmEqual}\n              aria-autocomplete=\"inline\"\n            />\n          </div>\n          {error && <div className=\"!mt-4 text-xs text-destructive\">{error}</div>}\n        </div>\n        <DialogFooter className=\"flex flex-col gap-2 sm:flex-row\">\n          <DialogClose asChild>\n            <Button size={'sm'} className=\"w-full\" variant={'outline'}>\n              {t('actions.cancel')}\n            </Button>\n          </DialogClose>\n          <Button\n            size={'sm'}\n            className=\"m-0 w-full\"\n            type=\"submit\"\n            disabled={disableSubmitBtn || isSuccess || isLoading}\n            onClick={handleSubmit}\n          >\n            {isLoading && <Spin className=\"mr-1 size-4\" />}\n            {t('settings.account.changePassword.title')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/account/DeleteAccountDialog.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport type { HttpError } from '@teable/core';\nimport { HttpErrorCode } from '@teable/core';\nimport type { IDeleteUserErrorData } from '@teable/openapi';\nimport { deleteUser, deleteUserErrorDataSchema } from '@teable/openapi';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n  Label,\n} from '@teable/ui-lib/shadcn';\nimport { Alert, AlertDescription } from '@teable/ui-lib/shadcn/ui/alert';\nimport { AlertTriangle, X, Loader2 } from 'lucide-react';\nimport Link from 'next/link';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\n\nexport const DeleteAccountDialog = () => {\n  const { t } = useTranslation(['common']);\n  const [open, setOpen] = useState(false);\n  const [confirmText, setConfirmText] = useState('');\n  const [deleteError, setDeleteError] = useState<IDeleteUserErrorData | string | null>(null);\n\n  const { mutate: deleteAccountMutation, isPending: isLoading } = useMutation({\n    mutationFn: (confirm: string) => deleteUser(confirm),\n    meta: {\n      preventGlobalError: true,\n    },\n    onError: (error: HttpError) => {\n      if (\n        error.code === HttpErrorCode.VALIDATION_ERROR &&\n        error.data &&\n        deleteUserErrorDataSchema.safeParse(error.data).success\n      ) {\n        setDeleteError(error.data as IDeleteUserErrorData);\n      } else {\n        setDeleteError(error.message);\n      }\n    },\n    onSuccess: () => {\n      window.location.reload();\n    },\n  });\n\n  const handleDelete = () => {\n    deleteAccountMutation(confirmText);\n  };\n\n  useEffect(() => {\n    setConfirmText('');\n    setDeleteError(null);\n  }, [open, setConfirmText]);\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button\n          variant=\"outline\"\n          className=\"w-fit text-destructive hover:text-destructive/80\"\n          size={'sm'}\n        >\n          {t('settings.account.deleteAccount.title')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2 text-base font-semibold\">\n            <AlertTriangle className=\"size-5 text-destructive\" />\n            {t('settings.account.deleteAccount.title')}\n          </DialogTitle>\n          <DialogDescription className=\"text-[13px]\">\n            {t('settings.account.deleteAccount.desc')}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          {typeof deleteError === 'string' && (\n            <Alert variant=\"destructive\">\n              <X className=\"size-4\" />\n              <AlertDescription>{deleteError}</AlertDescription>\n            </Alert>\n          )}\n          {deleteError &&\n            typeof deleteError === 'object' &&\n            Object.keys(deleteError).map((key) => {\n              const errorKey = key as keyof IDeleteUserErrorData;\n              const error = deleteError[errorKey];\n              if (!Array.isArray(error)) {\n                return <></>;\n              }\n              return (\n                <Alert variant=\"destructive\" key={key}>\n                  <X className=\"size-4\" />\n                  <AlertDescription className=\"text-[13px]\">\n                    <strong>{t('settings.account.deleteAccount.error.title')}</strong>\n                    <p className=\"mt-1\">{t('settings.account.deleteAccount.error.desc')}</p>\n                    <ul className=\"ml-4 mt-2 flex list-disc flex-col gap-2\">\n                      {error.map((item, index) => (\n                        <li key={index}>\n                          <Button variant=\"secondary\" asChild size=\"xs\">\n                            <Link\n                              href={item.deletedTime ? `/space/trash` : `/space/${item.id}`}\n                              target=\"_blank\"\n                            >\n                              {item.name}\n                            </Link>\n                          </Button>\n                        </li>\n                      ))}\n                    </ul>\n                    <p className=\"mt-2\">\n                      {t(`settings.account.deleteAccount.error.${errorKey}Error`)}\n                    </p>\n                  </AlertDescription>\n                </Alert>\n              );\n            })}\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"confirm\" className=\"text-[13px]\">\n              <Trans\n                ns=\"common\"\n                i18nKey=\"settings.account.deleteAccount.confirm.title\"\n                components={{ code: <code className=\"text-destructive\" /> }}\n              />\n            </Label>\n            <Input\n              id=\"confirm\"\n              className=\"text-[13px]\"\n              value={confirmText}\n              onChange={(e) => setConfirmText(e.target.value)}\n              placeholder={t('settings.account.deleteAccount.confirm.placeholder')}\n              disabled={isLoading}\n            />\n          </div>\n        </div>\n\n        <DialogFooter className=\"gap-2\">\n          <Button variant=\"outline\" size=\"sm\" onClick={() => setOpen(false)} disabled={isLoading}>\n            {t('common:actions.cancel')}\n          </Button>\n          <Button\n            variant=\"destructive\"\n            size=\"sm\"\n            onClick={handleDelete}\n            disabled={confirmText !== 'DELETE'}\n          >\n            {isLoading && <Loader2 className=\"mr-2 size-4 animate-spin\" />}\n            {isLoading ? t('settings.account.deleteAccount.loading') : t('common:actions.delete')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/Integration.tsx",
    "content": "/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { Plus } from '@teable/icons';\nimport { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { SettingTabHeader, SettingTabShell } from '../SettingTabShell';\nimport { ThirdPartyIntegrationsContent } from './third-party-integrations/Content';\nimport { UserIntegrationContent } from './user-integration/Content';\nimport { NewIntegration } from './user-integration/NewIntegration';\n\nexport const Integration = () => {\n  const { t } = useTranslation('common');\n  const [tab, setTab] = useState<'user' | 'third-party'>('user');\n\n  return (\n    <SettingTabShell\n      className=\"relative\"\n      header={\n        <SettingTabHeader\n          title={t('settings.integration.title')}\n          actions={\n            <NewIntegration>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"justify-start gap-2 text-sm font-normal text-foreground\"\n              >\n                <Plus className=\"size-4\" />\n                {t('settings.integration.userIntegration.create')}\n              </Button>\n            </NewIntegration>\n          }\n        />\n      }\n      contentClassName=\"px-0 py-0\"\n    >\n      <Tabs\n        className=\"flex h-full flex-1 flex-col gap-4 overflow-hidden px-8 py-4\"\n        value={tab}\n        onValueChange={(value) => setTab(value as 'user' | 'third-party')}\n      >\n        <TabsList className=\"w-fit\">\n          <TabsTrigger value=\"user\">{t('settings.integration.userIntegration.title')}</TabsTrigger>\n          <TabsTrigger value=\"third-party\">\n            {t('settings.integration.thirdPartyIntegrations.title')}\n          </TabsTrigger>\n        </TabsList>\n\n        <TabsContent className=\"mt-0 flex-1 overflow-hidden\" value=\"user\">\n          <UserIntegrationContent />\n        </TabsContent>\n        <TabsContent className=\"mt-0 flex-1 overflow-hidden\" value=\"third-party\">\n          <ThirdPartyIntegrationsContent />\n        </TabsContent>\n      </Tabs>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/common/Container.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { cn, Skeleton } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nexport const IntegrationContainer = (props: {\n  children: React.ReactNode;\n  count?: number;\n  isLoading?: boolean;\n  description?: string | React.ReactNode;\n}) => {\n  const { children, count, isLoading, description } = props;\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n  const isEmpty = typeof count === 'number' && count === 0;\n  return (\n    <div className=\"h-full overflow-auto py-4\">\n      <div\n        className={cn(\n          'flex px-3 size-full flex-col items-center justify-center gap-4 text-center text-sm text-muted-foreground',\n          {\n            'h-auto items-start': !isEmpty,\n          }\n        )}\n      >\n        {isEmpty && (\n          <Image\n            src={\n              isDark\n                ? '/images/layout/empty-integration-dark.png'\n                : '/images/layout/empty-integration-light.png'\n            }\n            width={160}\n            height={160}\n            alt=\"No integrations available\"\n          />\n        )}\n        {description}\n      </div>\n      <div className=\"flex-1 overflow-auto px-3\">\n        {isLoading ? (\n          <div className=\"flex h-full flex-col items-center justify-center gap-4\">\n            <Skeleton className=\"h-10 w-full rounded-md\" />\n            <Skeleton className=\"h-10 w-full rounded-md\" />\n          </div>\n        ) : (\n          children\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/common/Header.tsx",
    "content": "/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\n\nexport const IntegrationHeader = (props: {\n  detailName?: string;\n  onBack?: () => void;\n  className?: string;\n}) => {\n  const { detailName, onBack, className } = props;\n  const { t } = useTranslation('common');\n  return (\n    <div className={cn('flex items-center text-lg font-medium', className)}>\n      <h3\n        className={cn('text-lg font-medium', {\n          'hover:underline hover:text-foreground cursor-pointer text-muted-foreground': detailName,\n        })}\n        onClick={onBack}\n      >\n        {t('settings.integration.title')}\n      </h3>\n      {detailName && <div className=\"px-2\">/</div>}\n      {detailName && <div>{detailName}</div>}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/third-party-integrations/Content.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport type { AuthorizedVo } from '@teable/openapi';\nimport { getAuthorizedList } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { IntegrationContainer } from '../common/Container';\nimport { IntegrationHeader } from '../common/Header';\nimport { Detail } from './Detail';\nimport { List } from './List';\n\nexport const ThirdPartyIntegrationsContent = () => {\n  const { t } = useTranslation('common');\n  const { data: authorizedList, isLoading } = useQuery({\n    queryKey: ['integration'],\n    queryFn: () => getAuthorizedList().then((res) => res.data),\n  });\n  const [detail, setDetail] = useState<AuthorizedVo>();\n\n  return (\n    <>\n      <IntegrationContainer\n        count={authorizedList?.length}\n        isLoading={isLoading}\n        description={\n          <p className=\"pb-2 text-sm text-muted-foreground\">\n            {t('settings.integration.thirdPartyIntegrations.description', {\n              count: authorizedList?.length,\n            })}\n          </p>\n        }\n      >\n        <List list={authorizedList} onDetail={setDetail} />\n      </IntegrationContainer>\n      <div\n        className={cn(\n          'absolute left-0 top-0 w-full h-full flex-1 bg-background translate-x-full transition-transform duration-300',\n          {\n            'translate-x-0': detail,\n          }\n        )}\n      >\n        <IntegrationHeader\n          className=\"border-b px-8 py-4\"\n          detailName={detail?.name}\n          onBack={() => setDetail(undefined)}\n        />\n        <Detail detail={detail} onBack={() => setDetail(undefined)} />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/third-party-integrations/Detail.tsx",
    "content": "import { Clock4, Home, User } from '@teable/icons';\nimport type { AuthorizedVo } from '@teable/openapi';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Button, Separator } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useTranslation } from 'next-i18next';\nimport { OAuthLogo } from '../../../oauth/OAuthLogo';\nimport { OAuthScope } from '../../../oauth/OAuthScope';\nimport { RevokeButton } from './RevokeButton';\n\nexport const Detail = (props: { detail?: AuthorizedVo; onBack: () => void }) => {\n  const { detail, onBack } = props;\n  const { logo, name, lastUsedTime, createdUser, homepage, description, scopes, clientId } =\n    detail || {};\n  const dayjs = useLanDayjs();\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"px-5\">\n      <div className=\"flex items-center gap-4\">\n        <OAuthLogo logo={logo || ''} name={name || ''} />\n        <div className=\"space-y-1\">\n          <p>{name}</p>\n          <div className=\"flex gap-4 text-xs text-muted-foreground\">\n            <p className=\"flex items-center gap-2\">\n              <Clock4 />\n              {t('settings.integration.thirdPartyIntegrations.lastUsed', {\n                date: dayjs(lastUsedTime).fromNow(),\n              })}\n            </p>\n            <p className=\"flex items-center gap-2\">\n              <User />\n              {t('settings.integration.thirdPartyIntegrations.owner', {\n                user: createdUser?.name || '',\n              })}\n            </p>\n            <p className=\"flex items-center gap-2\">\n              <Home />\n              <Button className=\"h-5 p-0\" size=\"xs\" variant={'link'}>\n                <Link target=\"_blank\" href={homepage || ''}>\n                  {homepage}\n                </Link>\n              </Button>\n            </p>\n          </div>\n        </div>\n      </div>\n      <Separator className=\"my-4\" />\n      <div className=\"text-sm\">{description}</div>\n      <div className=\"mt-8 flex items-center justify-between\">\n        <div>{t('settings.integration.thirdPartyIntegrations.scopeTitle')}</div>\n        <RevokeButton clientId={clientId || ''} name={name || ''} onSuccess={onBack} />\n      </div>\n      <Separator className=\"my-4\" />\n      <OAuthScope\n        className=\"p-0\"\n        scopes={scopes}\n        description={\n          <div className=\"text-sm\">\n            {t('settings.integration.thirdPartyIntegrations.scopeDesc')}\n          </div>\n        }\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/third-party-integrations/List.tsx",
    "content": "import type { AuthorizedVo } from '@teable/openapi';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { OAuthLogo } from '../../../oauth/OAuthLogo';\nimport { RevokeButton } from './RevokeButton';\n\nexport const List = (props: {\n  list?: AuthorizedVo[];\n  onDetail: (detail: AuthorizedVo) => void;\n}) => {\n  const { list, onDetail } = props;\n  const { t } = useTranslation('common');\n  const dayjs = useLanDayjs();\n\n  return (\n    <div className=\"flex-1 overflow-auto\">\n      {list?.map((authorized) => (\n        <div key={authorized.clientId} className=\"flex items-center gap-4 border-t py-4\">\n          <OAuthLogo logo={authorized.logo} name={authorized.name} className=\"size-14\" />\n          <div className=\"flex-1\">\n            <Button\n              className=\"h-5 p-0 text-sm\"\n              variant={'link'}\n              onClick={() => onDetail(authorized)}\n            >\n              {authorized.name}\n            </Button>\n            <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n              <p>\n                {t('settings.integration.thirdPartyIntegrations.lastUsed', {\n                  date: dayjs(authorized.lastUsedTime).fromNow(),\n                })}\n              </p>\n              <p>\n                {t('settings.integration.thirdPartyIntegrations.owner', {\n                  user: authorized.createdUser.name,\n                })}\n              </p>\n            </div>\n          </div>\n          <RevokeButton clientId={authorized.clientId} name={authorized.name} />\n        </div>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/third-party-integrations/RevokeButton.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { revokeToken } from '@teable/openapi';\nimport { ConfirmDialog, Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\nexport const RevokeButton = (props: { name: string; clientId: string; onSuccess?: () => void }) => {\n  const { name, clientId, onSuccess } = props;\n  const [revokeConfirm, setRevokeConfirm] = useState(false);\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { mutate: revokeTokenMutate, isPending: revokeTokenLoading } = useMutation({\n    mutationFn: revokeToken,\n    onSuccess: () => {\n      setRevokeConfirm(false);\n      queryClient.invalidateQueries({ queryKey: ['integration'] });\n      onSuccess?.();\n    },\n  });\n\n  return (\n    <ConfirmDialog\n      open={revokeConfirm}\n      onOpenChange={setRevokeConfirm}\n      title={t('settings.integration.thirdPartyIntegrations.revokeTitle')}\n      description={t('settings.integration.thirdPartyIntegrations.revokeDesc', { name })}\n      cancelText={t('actions.cancel')}\n      confirmText={t('actions.confirm')}\n      onCancel={() => setRevokeConfirm(false)}\n      onConfirm={() => revokeTokenMutate(clientId)}\n    >\n      <Button size={'xs'} variant={'destructive'} disabled={revokeTokenLoading}>\n        {revokeTokenLoading && <Spin />}\n        {t('settings.integration.thirdPartyIntegrations.revoke')}\n      </Button>\n    </ConfirmDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/ActionMenu.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Pencil } from '@teable/icons';\nimport { deleteUserIntegration } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ConfirmDialog } from '@teable/ui-lib/base';\nimport {\n  Button,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { Link2, MoreHorizontal, Trash2 } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\n\nexport const ActionMenu = (props: {\n  name: string;\n  integrationId: string;\n  onRename?: () => void;\n  onReconnect?: () => void;\n}) => {\n  const { name, integrationId, onRename, onReconnect } = props;\n  const [deleteConfirm, setDeleteConfirm] = useState(false);\n  const { t } = useTranslation('common');\n  const queryClient = useQueryClient();\n  const { mutate: deleteIntegrationMutate, isPending: deleteLoading } = useMutation({\n    mutationFn: deleteUserIntegration,\n    onSuccess: () => {\n      setDeleteConfirm(false);\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getUserIntegrations() });\n    },\n  });\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button variant=\"ghost\" size=\"icon-sm\">\n            <MoreHorizontal className=\"size-4\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\">\n          <DropdownMenuItem className=\"gap-2\" onClick={() => onRename?.()}>\n            <Pencil className=\"size-4\" />\n            {t('actions.rename')}\n          </DropdownMenuItem>\n          <DropdownMenuItem className=\"gap-2\" onClick={() => onReconnect?.()}>\n            <Link2 className=\"size-4\" />\n            {t('settings.integration.userIntegration.actions.reconnect')}\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem\n            className=\"gap-2 text-destructive focus:text-destructive\"\n            onClick={() => setDeleteConfirm(true)}\n          >\n            <Trash2 className=\"size-4\" />\n            {t('actions.delete')}\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <ConfirmDialog\n        open={deleteConfirm}\n        onOpenChange={setDeleteConfirm}\n        title={t('settings.integration.userIntegration.deleteTitle')}\n        description={t('settings.integration.userIntegration.deleteDesc', { name })}\n        cancelText={t('actions.cancel')}\n        confirmText={t('actions.confirm')}\n        onCancel={() => setDeleteConfirm(false)}\n        onConfirm={() => deleteIntegrationMutate(integrationId)}\n        confirmLoading={deleteLoading}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/Content.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getUserIntegrationList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useTranslation } from 'next-i18next';\nimport { IntegrationContainer } from '../common/Container';\nimport { List } from './List';\n\nexport const UserIntegrationContent = () => {\n  const { t } = useTranslation('common');\n  const { data: integrationData, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.getUserIntegrations(),\n    queryFn: () => getUserIntegrationList().then((res) => res.data),\n  });\n\n  const integrationList = integrationData?.integrations;\n  const integrationCount = integrationList?.length;\n  return (\n    <>\n      <IntegrationContainer\n        count={integrationCount}\n        isLoading={isLoading}\n        description={\n          <p className=\"pb-2 text-sm text-muted-foreground\">\n            {t('settings.integration.userIntegration.description')}\n          </p>\n        }\n      >\n        <List list={integrationList} />\n      </IntegrationContainer>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/List.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { updateUserIntegrationName, UserIntegrationProvider } from '@teable/openapi';\nimport type { IUserIntegrationListVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useLanDayjs } from '@teable/sdk/hooks';\nimport { useState } from 'react';\nimport { UserIntegrationProviderLogo } from '@/features/app/components/user-integration/ProviderLogo';\nimport { openConnectIntegration } from '../../../user-integration/utils';\nimport { ActionMenu } from './ActionMenu';\nimport { EmailItem } from './provider/EmailItem';\nimport { SlackItem } from './provider/SlackItem';\nimport { Rename } from './Rename';\n\nexport const List = (props: { list?: IUserIntegrationListVo['integrations'] }) => {\n  const { list } = props;\n  const queryClient = useQueryClient();\n  const [editingId, setEditingId] = useState<string>();\n  const dayjs = useLanDayjs();\n\n  const { mutate: updateUserIntegrationNameMutate } = useMutation({\n    mutationFn: ({ id, name }: { id: string; name: string }) => updateUserIntegrationName(id, name),\n    onSuccess: () => {\n      setEditingId(undefined);\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getUserIntegrations() });\n    },\n  });\n\n  const handleReconnectIntegration = (provider: UserIntegrationProvider, integrationId: string) => {\n    openConnectIntegration(provider, {\n      integrationId,\n    });\n  };\n\n  return (\n    <div className=\"flex-1 overflow-auto\">\n      {list?.map((integration) => (\n        <div key={integration.id} className=\"flex items-center justify-between gap-4 border-t py-3\">\n          <div className=\"flex items-center gap-3\">\n            <UserIntegrationProviderLogo provider={integration.provider} className=\"size-10\" />\n            {integration.provider === UserIntegrationProvider.Slack ? (\n              <SlackItem item={integration}>\n                <Rename\n                  name={integration.name}\n                  setIsEditing={(editing) => setEditingId(editing ? integration.id : undefined)}\n                  isEditing={integration.id === editingId}\n                  onNameChange={(name) =>\n                    updateUserIntegrationNameMutate({ id: integration.id, name })\n                  }\n                />\n              </SlackItem>\n            ) : integration.provider === UserIntegrationProvider.Gmail ||\n              integration.provider === UserIntegrationProvider.Outlook ? (\n              <EmailItem item={integration}>\n                <Rename\n                  name={integration.name}\n                  setIsEditing={(editing) => setEditingId(editing ? integration.id : undefined)}\n                  isEditing={integration.id === editingId}\n                  onNameChange={(name) =>\n                    updateUserIntegrationNameMutate({ id: integration.id, name })\n                  }\n                />\n              </EmailItem>\n            ) : null}\n          </div>\n          <div className=\"flex shrink-0 items-center gap-4\">\n            <div className=\"text-xs text-muted-foreground\">\n              {dayjs(integration.connectedTime).fromNow()}\n            </div>\n            <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n              <ActionMenu\n                integrationId={integration.id}\n                name={integration.name}\n                onRename={() => {\n                  setEditingId(integration.id);\n                }}\n                onReconnect={() => handleReconnectIntegration(integration.provider, integration.id)}\n              />\n            </div>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/NewIntegration.tsx",
    "content": "import { UserIntegrationProvider } from '@teable/openapi';\nimport { Popover, PopoverContent, PopoverTrigger, Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { UserIntegrationProviderLogo } from '../../../user-integration/ProviderLogo';\nimport { getUserIntegrationName, openConnectIntegration } from '../../../user-integration/utils';\n\nexport const NewIntegration = (props: { children: React.ReactNode }) => {\n  const { children } = props;\n  const [open, setOpen] = useState(false);\n  const { t } = useTranslation('common');\n  const { availableIntegrationProviders } = useEnv();\n\n  const providers = Object.values(UserIntegrationProvider).filter((provider) =>\n    availableIntegrationProviders?.includes(provider)\n  );\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger>{children}</PopoverTrigger>\n      <PopoverContent className=\"h-auto w-64 p-2 text-[0px]\">\n        {providers.map((provider) => (\n          <Button\n            key={provider as string}\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"w-full justify-start gap-2 px-2 text-sm font-normal\"\n            onClick={() => {\n              openConnectIntegration(provider, {\n                name: t('settings.integration.userIntegration.defaultName', {\n                  name: getUserIntegrationName(provider),\n                }),\n              });\n              setOpen(false);\n            }}\n          >\n            <UserIntegrationProviderLogo provider={provider} className=\"size-5\" />\n            <span>{getUserIntegrationName(provider)}</span>\n          </Button>\n        ))}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/Rename.tsx",
    "content": "import { cn, Input } from '@teable/ui-lib/shadcn';\nimport { useEffect, useRef, useState } from 'react';\n\ninterface IRenameProps {\n  name: string;\n  onNameChange: (name: string) => void;\n  isEditing?: boolean;\n  setIsEditing: (isEditing: boolean) => void;\n}\n\nexport interface IRenameRef {\n  setEditingName: (name?: string) => void;\n}\n\nexport const Rename = ({ name, onNameChange, isEditing, setIsEditing }: IRenameProps) => {\n  const [editingName, setEditingName] = useState<string>();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const onChange = () => {\n    if (editingName && editingName !== name) {\n      onNameChange(editingName);\n      return;\n    }\n    setIsEditing(false);\n  };\n\n  useEffect(() => {\n    if (isEditing) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 200);\n    }\n  }, [isEditing]);\n\n  return (\n    <div className=\"flex flex-col items-start text-sm\">\n      {isEditing ? (\n        <Input\n          ref={inputRef}\n          className={cn({\n            hidden: !isEditing,\n          })}\n          size=\"sm\"\n          value={editingName ?? name}\n          onChange={(e) => setEditingName(e.target.value)}\n          onBlur={() => onChange()}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              onChange();\n            }\n            if (e.key === 'Escape') {\n              setIsEditing(false);\n            }\n            e.stopPropagation();\n          }}\n        />\n      ) : (\n        name\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/provider/EmailItem.tsx",
    "content": "import type { IUserIntegrationEmailMetadata, IUserIntegrationItemVo } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\n\nexport const EmailItem = ({\n  item,\n  children,\n}: {\n  item: IUserIntegrationItemVo;\n  children: React.ReactNode;\n}) => {\n  const metadata = item.metadata as IUserIntegrationEmailMetadata;\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"flex-1 space-y-1\">\n      {children}\n      <div className=\"text-xs text-muted-foreground\">\n        {t('settings.integration.userIntegration.email.user')}: {metadata.userInfo.name}\n      </div>\n      <div className=\"text-xs text-muted-foreground\">\n        {t('settings.integration.userIntegration.email.email')}: {metadata.userInfo.email}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/integration/user-integration/provider/SlackItem.tsx",
    "content": "import type { IUserIntegrationSlackMetadata, IUserIntegrationItemVo } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\n\nexport const SlackItem = ({\n  item,\n  children,\n}: {\n  item: IUserIntegrationItemVo;\n  children: React.ReactNode;\n}) => {\n  const metadata = item.metadata as IUserIntegrationSlackMetadata;\n  const { t } = useTranslation('common');\n  return (\n    <div className=\"flex-1 space-y-1\">\n      {children}\n      <div className=\"text-xs text-muted-foreground\">\n        {t('settings.integration.userIntegration.slack.user')}: {metadata.userInfo.name}\n      </div>\n      <div className=\"text-xs text-muted-foreground\">\n        {t('settings.integration.userIntegration.slack.workspace')}: {metadata.teamInfo.name}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/oauth-app/OAuthAppSection.tsx",
    "content": "import { ChevronLeft, Plus } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo, useState } from 'react';\nimport { OAuthAppList } from '@/features/app/blocks/setting/oauth-app/manage/List';\nimport { OAuthAppEdit } from '@/features/app/blocks/setting/oauth-app/manage/OAuthAppEdit';\nimport { OAuthAppNew } from '@/features/app/blocks/setting/oauth-app/manage/OAuthAppNew';\nimport { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport { SettingTabHeader, SettingTabShell } from '../SettingTabShell';\n\ntype IFormType = 'new' | 'edit';\n\ninterface IViewState {\n  formType?: IFormType;\n  clientId?: string;\n}\n\nexport const OAuthAppSection = () => {\n  const { t } = useTranslation(oauthAppConfig.i18nNamespaces);\n  const [viewState, setViewState] = useState<IViewState>({});\n\n  useInitializationZodI18n();\n\n  const { formType, clientId } = viewState;\n\n  const onBack = () => {\n    setViewState({});\n  };\n\n  const title = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return t('oauth:title.add');\n      case 'edit':\n        return t('oauth:title.edit');\n      default:\n        return t('setting:oauthApps');\n    }\n  }, [formType, t]);\n\n  return (\n    <SettingTabShell\n      header={\n        <SettingTabHeader\n          leading={\n            formType ? (\n              <Button variant=\"ghost\" size=\"icon-xs\" onClick={onBack}>\n                <ChevronLeft className=\"size-4 shrink-0\" />\n              </Button>\n            ) : undefined\n          }\n          title={title}\n          description={\n            !formType ? (\n              <Trans\n                ns=\"oauth\"\n                i18nKey=\"title.description\"\n                components={{\n                  a: (\n                    <Link\n                      href={t('oauth:help.link')}\n                      className=\"text-violet-500 underline underline-offset-4\"\n                      target=\"_blank\"\n                    />\n                  ),\n                }}\n              />\n            ) : undefined\n          }\n          actions={\n            !formType ? (\n              <Button size=\"xs\" onClick={() => setViewState({ formType: 'new' })}>\n                <Plus className=\"size-4 shrink-0\" />\n                {t('oauth:add')}\n              </Button>\n            ) : undefined\n          }\n        />\n      }\n    >\n      <div className=\"flex-1\">\n        {formType === 'new' && <OAuthAppNew onBack={onBack} />}\n        {formType === 'edit' && clientId && <OAuthAppEdit clientId={clientId} onBack={onBack} />}\n        {!formType && (\n          <OAuthAppList onEdit={(id) => setViewState({ formType: 'edit', clientId: id })} />\n        )}\n      </div>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/oauth-app/index.ts",
    "content": "export { OAuthAppSection } from './OAuthAppSection';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/personal-access-token/PersonalAccessTokenSection.tsx",
    "content": "import { ArrowUpRight, ChevronLeft, Plus } from '@teable/icons';\nimport type { CreateAccessTokenVo, UpdateAccessTokenVo } from '@teable/openapi';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useMemo, useRef, useState } from 'react';\nimport { AccessTokenList } from '@/features/app/blocks/setting/access-token/AccessTokenList';\nimport type { IFormType } from '@/features/app/blocks/setting/access-token/form/AccessTokenForm';\nimport { PersonAccessTokenForm } from '@/features/app/blocks/setting/access-token/PersonAccessTokenForm';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\nimport { SettingTabHeader, SettingTabShell } from '../SettingTabShell';\n\ninterface IViewState {\n  formType?: IFormType;\n  accessTokenId?: string;\n}\n\nexport const PersonalAccessTokenSection = () => {\n  const { t } = useTranslation(personalAccessTokenConfig.i18nNamespaces);\n  const [viewState, setViewState] = useState<IViewState>({});\n  const newTokenRef = useRef<string>();\n\n  const { formType, accessTokenId = '' } = viewState;\n\n  const onBack = () => {\n    newTokenRef.current = undefined;\n    setViewState({});\n  };\n\n  const onSubmit = (data: CreateAccessTokenVo | UpdateAccessTokenVo) => {\n    if (formType === 'new' && 'token' in data) {\n      newTokenRef.current = data.token;\n    }\n    setViewState({});\n  };\n\n  const onRefresh = (token: string) => {\n    newTokenRef.current = token;\n    setViewState({});\n  };\n\n  const title = useMemo(() => {\n    switch (formType) {\n      case 'new':\n        return t('token:new.headerTitle');\n      case 'edit':\n        return t('token:edit.title');\n      default:\n        return t('setting:personalAccessToken');\n    }\n  }, [formType, t]);\n\n  return (\n    <SettingTabShell\n      header={\n        <SettingTabHeader\n          leading={\n            formType ? (\n              <Button variant=\"ghost\" size=\"icon-xs\" onClick={onBack}>\n                <ChevronLeft className=\"size-4 shrink-0\" />\n              </Button>\n            ) : undefined\n          }\n          title={title}\n          description={\n            !formType ? (\n              <Trans\n                ns=\"token\"\n                i18nKey=\"list.description\"\n                components={{\n                  a: (\n                    <Link\n                      href={t('token:help.link')}\n                      className=\"text-violet-500 underline underline-offset-4\"\n                      target=\"_blank\"\n                    />\n                  ),\n                }}\n              />\n            ) : undefined\n          }\n          actions={\n            !formType ? (\n              <>\n                <Button size=\"xs\" variant=\"ghost\" asChild>\n                  <Link href=\"/developer/tool/query-builder\" target=\"_blank\" className=\"gap-1\">\n                    <ArrowUpRight className=\"size-4\" />\n                    {t('developer:apiQueryBuilder')}\n                  </Link>\n                </Button>\n                <Button size=\"xs\" onClick={() => setViewState({ formType: 'new' })}>\n                  <Plus className=\"size-4 shrink-0\" />\n                  {t('token:new.button')}\n                </Button>\n              </>\n            ) : undefined\n          }\n        />\n      }\n      contentClassName=\"py-0\"\n    >\n      <div className=\"flex-1 py-4\">\n        {formType ? (\n          <PersonAccessTokenForm\n            formType={formType}\n            accessTokenId={accessTokenId}\n            onSubmit={onSubmit}\n            onRefresh={onRefresh}\n            onCancel={onBack}\n          />\n        ) : (\n          <AccessTokenList\n            newToken={newTokenRef.current}\n            onEdit={(id) => setViewState({ formType: 'edit', accessTokenId: id })}\n          />\n        )}\n      </div>\n    </SettingTabShell>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/personal-access-token/index.ts",
    "content": "export { PersonalAccessTokenSection } from './PersonalAccessTokenSection';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/setting/useSettingStore.ts",
    "content": "import { create } from 'zustand';\n\nexport enum SettingTab {\n  Profile = 'profile',\n  System = 'system',\n  Notifications = 'notifications',\n  Integration = 'integration',\n  PersonalAccessToken = 'personal-access-token',\n  OAuthApp = 'oauth-app',\n  License = 'license',\n  LicensePlan = 'license-plan',\n}\n\ninterface ISettingState {\n  tab?: SettingTab;\n  setTab: (tab: SettingTab) => void;\n  open: boolean;\n  setOpen: (open: boolean, tab?: SettingTab) => void;\n}\n\nexport const useSettingStore = create<ISettingState>((set) => ({\n  open: false,\n  setOpen: (open: boolean, tab?: SettingTab) => {\n    set((state) => {\n      return {\n        ...state,\n        open,\n        tab,\n      };\n    });\n  },\n  setTab: (tab: SettingTab) => {\n    set((state) => {\n      return {\n        ...state,\n        tab,\n      };\n    });\n  },\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/SideBarScript.tsx",
    "content": "/* eslint-disable @next/next/no-before-interactive-script-outside-document */\nimport Script from 'next/script';\n\nexport const SideBarScript = () => {\n  return (\n    <Script id=\"init-sidebar-width\" strategy=\"beforeInteractive\">\n      {`\n         let sidebarWidth = 288;\n          try {\n            sidebarWidth = JSON.parse(localStorage.getItem('ls_sidebar') || '{}')?.state?.width || 288;\n          } catch (error) {\n            console.error('Error parsing sidebar width', error);\n          }\n          document.documentElement.style.setProperty('--sidebar-width', sidebarWidth.toString() + 'px');\n      `}\n    </Script>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/Sidebar.tsx",
    "content": "import { ChevronsLeft } from '@teable/icons';\nimport { useIsHydrated, useIsMobile, useIsReadOnlyPreview } from '@teable/sdk';\nimport { Button, cn } from '@teable/ui-lib';\nimport { Resizable } from 're-resizable';\nimport type { FC, PropsWithChildren, ReactNode } from 'react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport {\n  MAX_SIDE_BAR_WIDTH,\n  MIN_SIDE_BAR_WIDTH,\n  SIDE_BAR_WIDTH,\n} from '../toggle-side-bar/constant';\nimport { HoverWrapper } from '../toggle-side-bar/HoverWrapper';\nimport { SheetWrapper } from '../toggle-side-bar/SheetWrapper';\nimport { SidebarHeader } from './SidebarHeader';\nimport { useSidebarStore } from './useSidebarStore';\n\ninterface ISidebarProps {\n  headerLeft: ReactNode;\n  headerRight?: ReactNode;\n  className?: string;\n}\n\nconst useSidebar = () => {\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const [isVisible, setVisible] = useState(true);\n  const [width, setWidth] = useState(SIDE_BAR_WIDTH);\n  const storedSidebarStore = useSidebarStore();\n  return useMemo(() => {\n    if (isReadOnlyPreview) {\n      return {\n        isVisible,\n        setVisible,\n        setWidth,\n        width,\n      };\n    }\n    return storedSidebarStore;\n  }, [isVisible, setVisible, setWidth, width, isReadOnlyPreview, storedSidebarStore]);\n};\n\nexport const Sidebar: FC<PropsWithChildren<ISidebarProps>> = (props) => {\n  const { headerLeft, headerRight, children, className } = props;\n  const isMobile = useIsMobile();\n  const { isVisible, setVisible, setWidth, width } = useSidebar();\n  const isHydrated = useIsHydrated();\n  const toggleSidebar = useCallback(() => {\n    setVisible(!isVisible);\n  }, [isVisible, setVisible]);\n  useHotkeys(`mod+b`, toggleSidebar);\n\n  const sidebarClassName = cn(\n    'group/sidebar flex size-full flex-col overflow-hidden bg-background',\n    className\n  );\n\n  const sidebarContent = useMemo(\n    () => (\n      <>\n        <SidebarHeader headerLeft={headerLeft} headerRight={headerRight} onExpand={toggleSidebar} />\n        {children}\n      </>\n    ),\n    [headerLeft, headerRight, children, toggleSidebar]\n  );\n\n  // During SSR/hydration, render consistent layout to avoid mismatch\n  if (!isHydrated) {\n    return (\n      <div\n        className=\"h-full shrink-0 border-r\"\n        style={{ width: `var(--sidebar-width` }}\n        onContextMenu={(e) => e.preventDefault()}\n      >\n        <div className={sidebarClassName}>{sidebarContent}</div>\n      </div>\n    );\n  }\n\n  // After hydration, safe to check client-only values\n  if (isMobile) {\n    return (\n      <SheetWrapper>\n        <div className={sidebarClassName}>\n          <SidebarHeader headerLeft={headerLeft} headerRight={headerRight} />\n          {children}\n        </div>\n      </SheetWrapper>\n    );\n  }\n\n  // Collapsed state: show trigger button with hover panel\n  if (!isVisible) {\n    return (\n      <HoverWrapper size={width}>\n        <HoverWrapper.Trigger>\n          <Button\n            className=\"fixed left-0 top-7 z-40 rounded-none rounded-r-full p-1\"\n            variant=\"outline\"\n            size=\"xs\"\n            onClick={toggleSidebar}\n          >\n            <ChevronsLeft className=\"size-5 rotate-180\" />\n          </Button>\n        </HoverWrapper.Trigger>\n        <HoverWrapper.content>\n          <div className={sidebarClassName} onContextMenu={(e) => e.preventDefault()}>\n            <SidebarHeader headerLeft={headerLeft} headerRight={headerRight} />\n            {children}\n          </div>\n        </HoverWrapper.content>\n      </HoverWrapper>\n    );\n  }\n\n  return (\n    <Resizable\n      className=\"h-full shrink-0 border-r\"\n      size={{ width, height: '100%' }}\n      defaultSize={{ width, height: '100%' }}\n      minWidth={MIN_SIDE_BAR_WIDTH}\n      maxWidth={MAX_SIDE_BAR_WIDTH}\n      enable={{ right: true }}\n      onResizeStop={(_e, _direction, ref) => {\n        const newWidth = parseInt(ref.style.width, 10);\n        if (!isNaN(newWidth)) {\n          if (newWidth <= MIN_SIDE_BAR_WIDTH) {\n            setVisible(false);\n          } else {\n            setWidth(newWidth);\n          }\n        }\n      }}\n      handleClasses={{ right: 'group' }}\n      handleStyles={{\n        right: {\n          width: '6px',\n          right: '-6px',\n        },\n      }}\n      handleComponent={{\n        right: (\n          <div className=\"h-full w-px bg-transparent transition-colors group-hover:bg-primary/50 group-active:bg-primary\" />\n        ),\n      }}\n    >\n      <div className={sidebarClassName} onContextMenu={(e) => e.preventDefault()}>\n        {sidebarContent}\n      </div>\n    </Resizable>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/SidebarContent.tsx",
    "content": "import type { BillingProductLevel } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { Button } from '@teable/ui-lib/shadcn/ui/button';\nimport type { LucideIcon } from 'lucide-react';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { UpgradeWrapper } from '../billing/UpgradeWrapper';\n\nexport interface ISidebarContentRoute {\n  Icon: React.FC<{ className?: string }> | LucideIcon;\n  label: string | React.ReactNode;\n  route: string;\n  pathTo: string;\n  billingLevel?: BillingProductLevel;\n}\n\ninterface ISidebarContentProps {\n  className?: string;\n  title?: string;\n  routes: ISidebarContentRoute[];\n}\n\nexport const SidebarContent = (props: ISidebarContentProps) => {\n  const { title, routes, className } = props;\n  const router = useRouter();\n\n  return (\n    <div className={cn('flex flex-col gap-2 border-t px-4 py-2', className)}>\n      {title && <span className=\"text-sm text-muted-foreground\">{title}</span>}\n      <ul>\n        {routes.map(({ Icon, label, route, pathTo, billingLevel }) => {\n          return (\n            <UpgradeWrapper\n              key={route}\n              spaceId={router.query.spaceId as string}\n              targetBillingLevel={billingLevel}\n            >\n              {({ badge }) => (\n                <li>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"xs\"\n                    asChild\n                    className={cn(\n                      'w-full justify-start text-sm my-[2px]',\n                      route === router.pathname && 'bg-accent'\n                    )}\n                  >\n                    <Link href={pathTo} className=\"font-normal\">\n                      <Icon className=\"size-4 shrink-0\" />\n                      <p className=\"truncate\">{label}</p>\n                      <div className=\"grow basis-0\"></div>\n                      {badge}\n                    </Link>\n                  </Button>\n                </li>\n              )}\n            </UpgradeWrapper>\n          );\n        })}\n      </ul>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/SidebarHeader.tsx",
    "content": "import { Sidebar } from '@teable/icons';\nimport { Button, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport type { ReactNode } from 'react';\nimport { useModKeyStr } from '@/features/app/utils/get-mod-key-str';\nexport interface ISidebarHeaderProps {\n  headerLeft: ReactNode;\n  headerRight?: ReactNode;\n  onExpand?: () => void;\n}\n\nexport const SidebarHeader = (props: ISidebarHeaderProps) => {\n  const { headerLeft, headerRight, onExpand } = props;\n  const modKeyStr = useModKeyStr();\n  const { t } = useTranslation(['common']);\n  return (\n    <div className=\"flex w-full items-center gap-2 py-2 pl-4 pr-2\">\n      <div className=\"flex min-w-0 flex-1 items-center gap-2\">{headerLeft}</div>\n      <div className=\"flex shrink-0 items-center gap-1\">\n        {headerRight}\n        {onExpand && (\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  className=\"size-7 shrink-0 p-0\"\n                  variant=\"ghost\"\n                  size=\"xs\"\n                  onClick={onExpand}\n                >\n                  <Sidebar className=\"size-4\" />\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent hideWhenDetached={true}>\n                {t('common:actions.collapseSidebar')}\n                <span>{modKeyStr}+B</span>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/SidebarHeaderLeft.tsx",
    "content": "import { ChevronsLeft } from '@teable/icons';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '../../hooks/useBrand';\n\ninterface ISidebarBackButtonProps {\n  title?: string;\n  icon?: React.ReactNode;\n  onBack?: () => void;\n}\n\nexport const SidebarHeaderLeft = (props: ISidebarBackButtonProps) => {\n  const { title, icon, onBack } = props;\n  const displayIcon = icon ?? <TeableLogo className=\"size-5 shrink-0\" />;\n  const { brandName } = useBrand();\n\n  return (\n    <>\n      {onBack ? (\n        <div\n          className=\"group relative size-5 shrink-0 cursor-pointer\"\n          onClick={() => onBack?.()}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              onBack?.();\n            }\n          }}\n          role=\"button\"\n          tabIndex={0}\n        >\n          <div className=\"absolute top-0 size-5 group-hover:opacity-0\">{displayIcon}</div>\n          <ChevronsLeft className=\"absolute top-0 size-5 opacity-0 group-hover:opacity-100\" />\n        </div>\n      ) : (\n        displayIcon\n      )}\n\n      <p className=\"ml-[2px] truncate text-sm\">{title ?? brandName}</p>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/useChatPanelStore.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk/config';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\n/**\n * Chat panel visibility states:\n * - 'open'     — panel visible at normal width (side panel)\n * - 'close'    — panel hidden, only cuppy icon shown\n * - 'expanded' — panel takes up most of the screen\n *\n * State is persisted to localStorage so the user's preference\n * survives page navigations and browser refreshes.\n *\n * Default is 'open' — first-time visitors see the panel.\n * Once a user explicitly closes the panel, 'close' is persisted\n * and respected on subsequent visits.\n *\n * NOTE: Some pages force-open the panel for specific UX flows:\n * - AppPage calls open() because app builder requires the chat panel\n * - ChatContainer calls expand() for the empty-base welcome screen\n * These are intentional overrides, not default-state logic.\n */\ninterface IChatPanelState {\n  status: 'open' | 'close' | 'expanded';\n  close: () => void;\n  open: () => void;\n  expand: () => void;\n  toggleVisible: () => void;\n  toggleExpanded: () => void;\n}\n\nexport const useChatPanelStore = create<IChatPanelState>()(\n  persist(\n    (set) => ({\n      status: 'open',\n      close: () => set({ status: 'close' }),\n      open: () => set({ status: 'open' }),\n      expand: () => set({ status: 'expanded' }),\n      toggleVisible: () =>\n        set((state) => ({ status: state.status !== 'close' ? 'close' : 'open' })),\n      toggleExpanded: () =>\n        set((state) => ({ status: state.status === 'expanded' ? 'open' : 'expanded' })),\n    }),\n    {\n      name: LocalStorageKeys.ChatPanel,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/sidebar/useSidebarStore.ts",
    "content": "import { LocalStorageKeys } from '@teable/sdk';\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\nimport { SIDE_BAR_WIDTH } from '../toggle-side-bar/constant';\n\ninterface ISidebarState {\n  isVisible: boolean;\n  setVisible: (isVisible: boolean) => void;\n  width: number;\n  setWidth: (width: number) => void;\n}\n\nexport const useSidebarStore = create<ISidebarState>()(\n  persist(\n    (set) => ({\n      isVisible: true,\n      width: SIDE_BAR_WIDTH,\n      setVisible: (isVisible: boolean) => set((state) => ({ ...state, isVisible })),\n      setWidth: (width: number) => set((state) => ({ ...state, width })),\n    }),\n    {\n      name: LocalStorageKeys.Sidebar,\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/CollaboratorAvatars.tsx",
    "content": "import { Building2 } from '@teable/icons';\nimport type { CollaboratorItem } from '@teable/openapi';\nimport { PrincipalType } from '@teable/openapi';\nimport { cn, Button } from '@teable/ui-lib';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { UserAvatar } from '../user/UserAvatar';\n\ninterface CollaboratorAvatarsProps {\n  collaborators: CollaboratorItem[];\n  maxDisplay?: number;\n  onShowMore?: () => void;\n  className?: string;\n}\n\nexport const CollaboratorAvatars: React.FC<CollaboratorAvatarsProps> = ({\n  collaborators,\n  maxDisplay = 15,\n  onShowMore,\n  className,\n}) => {\n  const { t } = useTranslation('space');\n\n  const { displayedCollaborators, remainingCount } = useMemo(() => {\n    const displayed = collaborators.slice(0, maxDisplay);\n    const remaining = Math.max(0, collaborators.length - maxDisplay);\n    return {\n      displayedCollaborators: displayed,\n      remainingCount: remaining,\n    };\n  }, [collaborators, maxDisplay]);\n\n  if (collaborators.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={cn('flex items-center', className)}>\n      <div className=\"flex items-center space-x-1\">\n        <span className=\"text-sm text-muted-foreground\">{t('collaborators')}:</span>\n        <div className=\"flex -space-x-1\">\n          {displayedCollaborators.map((collaborator, index) => {\n            const getUserId = (collab: typeof collaborator) => {\n              return collab.type === PrincipalType.User ? collab.userId : collab.departmentId;\n            };\n\n            const getUserName = (collab: typeof collaborator) => {\n              return collab.type === PrincipalType.User ? collab.userName : collab.departmentName;\n            };\n\n            const getUserAvatar = (collab: typeof collaborator) => {\n              return collab.type === PrincipalType.User ? collab.avatar : null;\n            };\n\n            return (\n              <div\n                key={getUserId(collaborator)}\n                className=\"relative\"\n                style={{ zIndex: displayedCollaborators.length - index }}\n              >\n                {collaborator.type === PrincipalType.User ? (\n                  <UserAvatar\n                    user={{ name: getUserName(collaborator), avatar: getUserAvatar(collaborator) }}\n                    className=\"size-6 border-2 border-background\"\n                  />\n                ) : (\n                  <div className=\"flex size-6 items-center justify-center rounded-full border-2 border-background bg-accent\">\n                    <Building2 className=\"size-3\" />\n                  </div>\n                )}\n              </div>\n            );\n          })}\n        </div>\n        {remainingCount > 0 && onShowMore && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-6 px-2 text-xs text-muted-foreground hover:text-foreground\"\n            onClick={onShowMore}\n          >\n            +{remainingCount} {t('more')}\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/CreateBaseModal.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { getUniqName } from '@teable/core';\nimport { Database, LayoutTemplate } from '@teable/icons';\nimport { createBase } from '@teable/openapi';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { ReactNode } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useBaseList } from '../../blocks/space/useBaseList';\nimport { TemplateModal } from './template';\nimport { TemplateContext } from './template/context';\n\nexport const CreateBaseModalTrigger = ({\n  spaceId,\n  children,\n}: {\n  spaceId: string;\n  children: ReactNode;\n}) => {\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const router = useRouter();\n  const allBases = useBaseList();\n  const bases = allBases?.filter((base) => base.spaceId === spaceId);\n  const { mutate: createBaseMutator, isPending: createBaseLoading } = useMutation({\n    mutationFn: createBase,\n    onSuccess: ({ data }) => {\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId: data.id },\n      });\n    },\n  });\n\n  return (\n    <div>\n      <Dialog>\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>{t('space:baseModal.howToCreate')}</DialogTitle>\n          </DialogHeader>\n          <div className=\"flex justify-around pt-4\">\n            <Button\n              className=\"flex h-auto grow flex-col items-center gap-4\"\n              variant=\"ghost\"\n              onClick={() => {\n                const name = getUniqName(\n                  t('common:noun.base'),\n                  bases?.map((base) => base.name) || []\n                );\n                createBaseMutator({ spaceId, name });\n              }}\n              disabled={createBaseLoading}\n            >\n              <Database className=\"size-8\" />\n              {t('space:baseModal.fromScratch')}\n            </Button>\n            <TemplateContext.Provider value={{ spaceId }}>\n              <TemplateModal spaceId={spaceId}>\n                <Button className=\"flex h-auto grow flex-col items-center gap-4\" variant=\"ghost\">\n                  <LayoutTemplate className=\"size-8\" />\n                  {t('space:baseModal.fromTemplate')}\n                </Button>\n              </TemplateModal>\n            </TemplateContext.Provider>\n          </div>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/DeleteSpaceConfirm.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { BillingProductLevel, getSpaceUsage } from '@teable/openapi';\nimport {\n  Button,\n  Dialog,\n  DialogFooter,\n  DialogHeader,\n  DialogContent,\n  DialogTitle,\n} from '@teable/ui-lib/shadcn';\nimport { Trans, useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useIsCloud } from '../../hooks/useIsCloud';\n\nexport interface IDeleteSpaceConfirmProps {\n  open: boolean;\n  spaceId: string;\n  spaceName?: string;\n  onOpenChange: (open: boolean) => void;\n  onConfirm?: () => void;\n  onPermanentConfirm?: () => void;\n}\n\nexport const DeleteSpaceConfirm: React.FC<IDeleteSpaceConfirmProps> = (props) => {\n  const { open, spaceId, spaceName, onOpenChange, onConfirm } = props;\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const isCloud = useIsCloud();\n\n  const { data } = useQuery({\n    queryKey: ['usage-before-delete', spaceId],\n    queryFn: async () => (await getSpaceUsage(spaceId)).data,\n    enabled: isCloud && !!spaceId && open,\n  });\n\n  const isBlocked =\n    data &&\n    data.level !== BillingProductLevel.Free &&\n    data.level !== BillingProductLevel.Enterprise;\n\n  const handleAddToTrash = () => {\n    onConfirm?.();\n    onOpenChange(false);\n  };\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent\n          onPointerDownOutside={(e) => e.preventDefault()}\n          onInteractOutside={(e) => e.preventDefault()}\n          onMouseDown={(e) => e.stopPropagation()}\n          onClick={(e) => e.stopPropagation()}\n        >\n          <DialogHeader>\n            <DialogTitle>\n              {isBlocked ? (\n                t('space:deleteSpaceModal.blockedTitle')\n              ) : (\n                <Trans ns=\"space\" i18nKey={'tip.delete'}>\n                  {spaceName}\n                </Trans>\n              )}\n            </DialogTitle>\n          </DialogHeader>\n          {isBlocked ? (\n            <div className=\"text-sm\">{t('space:deleteSpaceModal.blockedDesc')}</div>\n          ) : (\n            <div className=\"py-1\" />\n          )}\n          <DialogFooter>\n            {isBlocked ? (\n              <Button size={'sm'} onClick={() => onOpenChange(false)}>\n                {t('actions.confirm')}\n              </Button>\n            ) : (\n              <>\n                <Button size={'sm'} variant={'ghost'} onClick={() => onOpenChange(false)}>\n                  {t('actions.cancel')}\n                </Button>\n                <Button size={'sm'} onClick={handleAddToTrash}>\n                  {t('common:trash.addToTrash')}\n                </Button>\n              </>\n            )}\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/SpaceActionBar.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { getUniqName, hasPermission } from '@teable/core';\nimport { MoreHorizontal, Plus, UserPlus } from '@teable/icons';\nimport { createBase, type IGetSpaceVo } from '@teable/openapi';\nimport { useIsMobile } from '@teable/sdk/hooks';\nimport type { ButtonProps } from '@teable/ui-lib';\nimport { Button, cn } from '@teable/ui-lib';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@teable/ui-lib/shadcn/ui/tooltip';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useMemo } from 'react';\nimport { GUIDE_CREATE_BASE } from '@/components/Guide';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { SpaceActionTrigger } from '../../blocks/space/component/SpaceActionTrigger';\nimport { UploadPanelDialog } from '../../blocks/space/component/upload-panel';\nimport { useBaseList } from '../../blocks/space/useBaseList';\nimport { InviteSpacePopover } from '../collaborator/space/InviteSpacePopover';\n\ninterface ActionBarProps {\n  space: IGetSpaceVo;\n  invQueryFilters: string[];\n  className?: string;\n  buttonSize?: ButtonProps['size'];\n  disallowSpaceInvitation?: boolean | null;\n  onRename?: () => void;\n  onDelete?: () => void;\n  onPermanentDelete?: () => void;\n}\n\nexport const SpaceActionBar: React.FC<ActionBarProps> = (props) => {\n  const {\n    space,\n    className,\n    buttonSize = 'default',\n    disallowSpaceInvitation,\n    onRename,\n    onDelete,\n    onPermanentDelete,\n  } = props;\n  const [importBaseOpen, setImportBaseOpen] = React.useState(false);\n\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const isMobile = useIsMobile();\n  const router = useRouter();\n  const bases = useBaseList();\n\n  const basesInSpace = useMemo(() => {\n    return bases?.filter((base) => base.spaceId === space.id);\n  }, [bases, space.id]);\n\n  const { mutate: createBaseMutator, isPending: createBaseLoading } = useMutation({\n    mutationFn: createBase,\n    onSuccess: ({ data }) => {\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId: data.id },\n      });\n    },\n  });\n\n  const handleCreateBase = () => {\n    const name = getUniqName(t('common:noun.base'), basesInSpace?.map((base) => base.name) || []);\n    createBaseMutator({ spaceId: space.id, name });\n  };\n\n  const canCreateBase = hasPermission(space.role, 'base|create');\n\n  return (\n    <div className={cn('flex shrink-0 items-center gap-2', className)}>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            {isMobile ? (\n              <Button\n                variant={'outline'}\n                size=\"icon\"\n                className=\"size-7\"\n                onClick={handleCreateBase}\n                disabled={!canCreateBase || createBaseLoading}\n              >\n                <Plus className=\"size-4\" />\n              </Button>\n            ) : (\n              <Button\n                className={GUIDE_CREATE_BASE}\n                size={buttonSize}\n                onClick={handleCreateBase}\n                disabled={!canCreateBase || createBaseLoading}\n              >\n                <Plus className=\"size-4\" />\n                {t('space:action.createBase')}\n              </Button>\n            )}\n          </TooltipTrigger>\n          {!canCreateBase && (\n            <TooltipContent>{t('space:tooltip.noPermissionToCreateBase')}</TooltipContent>\n          )}\n        </Tooltip>\n      </TooltipProvider>\n      {!disallowSpaceInvitation && (\n        <InviteSpacePopover space={space}>\n          {isMobile ? (\n            <Button variant=\"outline\" size=\"icon-xs\">\n              <UserPlus className=\"size-4\" />\n            </Button>\n          ) : (\n            <Button variant={'outline'} size={buttonSize}>\n              <UserPlus className=\"size-4\" /> {t('space:action.invite')}\n            </Button>\n          )}\n        </InviteSpacePopover>\n      )}\n\n      <SpaceActionTrigger\n        space={space}\n        showRename={false}\n        showSettings={hasPermission(space.role, 'space|update')}\n        showDelete={hasPermission(space.role, 'space|delete')}\n        showImportBase={hasPermission(space.role, 'space|update')}\n        onDelete={onDelete}\n        onPermanentDelete={onPermanentDelete}\n        onRename={onRename}\n        onImportBase={() => setImportBaseOpen(true)}\n      >\n        <Button variant={'outline'} size={buttonSize} className=\"p-[5px]\">\n          <MoreHorizontal className=\"size-4\" />\n        </Button>\n      </SpaceActionTrigger>\n\n      <UploadPanelDialog\n        spaceId={space.id}\n        open={importBaseOpen}\n        onOpenChange={setImportBaseOpen}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/SpaceAvatar.tsx",
    "content": "import { Avatar, AvatarFallback, cn } from '@teable/ui-lib/shadcn';\n\ninterface ISpaceAvatarProps {\n  name: string;\n  className?: string;\n}\n\nexport const SpaceAvatar = ({ name, className }: ISpaceAvatarProps) => {\n  const initial = name?.charAt(0).toUpperCase() || '?';\n\n  return (\n    <Avatar className={cn('shrink-0 rounded border', className)}>\n      <AvatarFallback className={cn('rounded bg-background text-foreground font-medium ')}>\n        {initial}\n      </AvatarFallback>\n    </Avatar>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/SpaceRenaming.tsx",
    "content": "import { cn, Input } from '@teable/ui-lib';\nimport React, { useEffect, useRef } from 'react';\n\ninterface SpaceRenamingProps {\n  className?: string;\n  spaceName: string;\n  isRenaming: boolean;\n  children: React.ReactNode;\n  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;\n  onBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void;\n}\n\nexport const SpaceRenaming: React.FC<SpaceRenamingProps> = (props) => {\n  const { spaceName, isRenaming, children, onChange, onBlur, className } = props;\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    if (isRenaming) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 200);\n    }\n  }, [isRenaming]);\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter') {\n      inputRef.current?.blur();\n    }\n  };\n\n  return (\n    <>\n      {isRenaming ? (\n        <Input\n          ref={inputRef}\n          className={cn('m-0.5 h-6 flex-1', className)}\n          value={spaceName}\n          onKeyDown={handleKeyDown}\n          onChange={onChange}\n          onBlur={onBlur}\n        />\n      ) : (\n        children\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/CategoryMenu.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getTemplateCategoryList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useIsMobile } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { CategoryMenuItem } from './CategoryMenuItem';\n\ninterface ICategoryMenuProps {\n  currentCategoryId: string | null;\n  onCategoryChange: (category: string | null) => void;\n  className?: string;\n  categoryHeaderRender?: () => React.ReactNode;\n  isFeatured: boolean | undefined;\n  onFeaturedChange: (isFeatured: boolean | undefined) => void;\n  disabledFeaturedToggle: boolean;\n}\n\nexport const CategoryMenu = (props: ICategoryMenuProps) => {\n  const { currentCategoryId, onCategoryChange, className } = props;\n  const { t } = useTranslation('common');\n  const { data: categoryListFromServer } = useQuery({\n    queryKey: ReactQueryKeys.publishedTemplateCategoryList(),\n    queryFn: () => getTemplateCategoryList().then((data) => data.data),\n  });\n\n  const isMobile = useIsMobile();\n\n  const categoryList = useMemo(() => {\n    return [\n      {\n        id: null,\n        name: t('settings.templateAdmin.category.menu.recommended'),\n        order: -Infinity,\n      },\n      // Widen type so concat is valid (recommended + categories)\n      ...(categoryListFromServer ?? []),\n    ];\n  }, [categoryListFromServer, t]);\n\n  return (\n    <div\n      className={cn('flex flex-col gap-6 overflow-hidden px-2 pt-4 shrink-0 w-64', className, {\n        'flex-row w-full': isMobile,\n      })}\n    >\n      {categoryList && categoryList.length > 0 && (\n        <div\n          className={cn('flex flex-1 flex-col overflow-hidden', {\n            'flex-row overflow-x-auto': isMobile,\n          })}\n        >\n          <div\n            className={cn('flex flex-1 flex-col overflow-auto gap-0.5', {\n              'flex-row gap-x-0.5': isMobile,\n            })}\n          >\n            {categoryList?.map(({ name, id }) => (\n              <CategoryMenuItem\n                key={id}\n                category={name}\n                id={id}\n                currentCategoryId={currentCategoryId}\n                onClickHandler={() => {\n                  onCategoryChange(id);\n                }}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/CategoryMenuItem.tsx",
    "content": "import { Button, cn } from '@teable/ui-lib/shadcn';\n\ninterface CategoryMenuItemProps {\n  category: string;\n  currentCategoryId: string | null;\n  id: string | null;\n  onClickHandler: (id: string | null) => void;\n}\n\nexport const CategoryMenuItem = (props: CategoryMenuItemProps) => {\n  const { category, currentCategoryId, id, onClickHandler } = props;\n  return (\n    <Button\n      className={cn('px-2 h-8 cursor-pointer w-full justify-start', {\n        'bg-accent': currentCategoryId === id,\n      })}\n      variant=\"ghost\"\n      onClick={() => onClickHandler(id)}\n    >\n      <span className=\"truncate text-nowrap text-sm\" title={category}>\n        {category}\n      </span>\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/RecommendTemplate.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getPublishedTemplateList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config/react-query-keys';\nimport { useIsMobile } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { TemplateCard } from './TemplateCard';\nimport type { ITemplateBaseProps } from './TemplateMain';\n\ninterface IRecommendTemplateProps extends Pick<ITemplateBaseProps, 'onClickTemplateCardHandler'> {\n  filterTemplateIds?: string[];\n  onClickTemplateCardHandler?: (templateId: string) => void;\n  className?: string;\n}\n\nexport const RecommendTemplate = (props: IRecommendTemplateProps) => {\n  const { onClickTemplateCardHandler, className, filterTemplateIds } = props;\n  const { t } = useTranslation('common');\n  const isMobile = useIsMobile();\n\n  const { data: templates, isLoading } = useQuery({\n    queryKey: [...ReactQueryKeys.publishedTemplateList(null, '', true), 'recommend'],\n    queryFn: () => getPublishedTemplateList({ featured: true, take: 4 }).then((res) => res.data),\n  });\n\n  const filteredTemplates = useMemo(() => {\n    return templates?.filter((template) => !filterTemplateIds?.includes(template.id))?.slice(0, 3);\n  }, [templates, filterTemplateIds]);\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-8\">\n        <Spin className=\"size-6\" />\n      </div>\n    );\n  }\n\n  if (!templates || templates.length === 0) {\n    return null;\n  }\n\n  return filteredTemplates && filteredTemplates?.length > 0 ? (\n    <div className={cn('flex flex-col items-start justify-start gap-3 self-stretch', className)}>\n      <p className=\"text-base font-semibold text-foreground\">\n        {t('settings.templateAdmin.relatedTemplates')}\n      </p>\n      <div\n        className={cn('grid w-full grid-cols-3 gap-5', {\n          'grid-cols-1': isMobile,\n        })}\n      >\n        {filteredTemplates?.map((template) => (\n          <TemplateCard\n            key={template.id}\n            template={template}\n            size=\"lg\"\n            onClickTemplateCardHandler={onClickTemplateCardHandler}\n          />\n        ))}\n      </div>\n    </div>\n  ) : null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateCard.tsx",
    "content": "import { Eye } from '@teable/icons';\nimport type { ITemplateVo } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'react-i18next';\nimport type { ITemplateBaseProps } from './TemplateMain';\n\ninterface ITemplateCardProps extends Pick<ITemplateBaseProps, 'onClickTemplateCardHandler'> {\n  template: ITemplateVo;\n  size: 'xs' | 'sm' | 'md' | 'lg';\n  className?: string;\n}\n\nconst AspectRatioMap = {\n  xs: 'aspect-[16/10]',\n  sm: 'aspect-[16/10]',\n  md: 'aspect-[16/9]',\n  lg: 'aspect-[16/9]',\n};\n\nexport const TemplateCard = ({\n  template,\n  onClickTemplateCardHandler,\n  size = 'sm',\n  className,\n}: ITemplateCardProps) => {\n  const { name, description, cover, visitCount, id: templateId } = template;\n  const { presignedUrl } = cover ?? {};\n  const { t, i18n } = useTranslation(['common']);\n\n  const formatCount = (count: number) =>\n    Intl.NumberFormat([i18n.language, 'en'], { notation: 'compact' }).format(count);\n\n  return (\n    <div\n      className={cn('relative flex w-full shrink-0 cursor-pointer flex-col', className)}\n      role=\"button\"\n      tabIndex={0}\n      onClick={(e) => {\n        e.stopPropagation();\n        onClickTemplateCardHandler?.(templateId);\n      }}\n      onKeyDown={(e) => {\n        e.stopPropagation();\n        if (e.key === 'Enter') {\n          onClickTemplateCardHandler?.(templateId);\n        }\n      }}\n    >\n      <div\n        className={cn(\n          'group w-full shrink-0 overflow-hidden rounded-lg border bg-secondary p-0 transition-shadow hover:shadow-[0_4px_12px_-4px_rgba(0,0,0,0.08),0_3px_6px_-2px_rgba(0,0,0,0.08)]',\n          AspectRatioMap[size]\n        )}\n      >\n        {presignedUrl ? (\n          <img\n            src={presignedUrl}\n            className=\"size-full object-cover transition-all duration-300 group-hover:scale-105\"\n            alt=\"preview\"\n          />\n        ) : (\n          <div className=\"flex size-full items-center justify-center\">\n            <span className=\"text-sm text-muted-foreground\">\n              {t('settings.templateAdmin.noImage')}\n            </span>\n          </div>\n        )}\n      </div>\n\n      <div\n        className={cn('flex flex-1 flex-col gap-1 px-1 pt-2 text-base', {\n          'text-sm pt-1 gap-0.5': size === 'xs',\n        })}\n      >\n        <h2\n          className={cn('flex items-center justify-between gap-3', {\n            'gap-2': size === 'xs',\n          })}\n        >\n          <span className=\"truncate font-medium\" title={name}>\n            {name}\n          </span>\n\n          <div\n            className={cn('flex shrink-0 items-center gap-2 text-muted-foreground text-sm', {\n              'text-xs gap-1': size === 'xs',\n            })}\n          >\n            <Eye className=\"size-4\" />\n            <span>{formatCount(visitCount)}</span>\n          </div>\n        </h2>\n        <p\n          className={cn('m-0 flex-1 overflow-hidden truncate text-muted-foreground text-sm', {\n            'text-xs': size === 'xs',\n          })}\n          title={description}\n        >\n          {description}\n        </p>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateDetail.tsx",
    "content": "import { useMutation, useQuery } from '@tanstack/react-query';\nimport {\n  createBaseFromTemplate,\n  getTemplateCategoryList,\n  getTemplateDetail,\n} from '@teable/openapi';\nimport { MarkdownPreview } from '@teable/sdk';\nimport { ReactQueryKeys } from '@teable/sdk/config/react-query-keys';\nimport { useIsMobile } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Badge, Button, cn, useToast } from '@teable/ui-lib/shadcn';\nimport { ArrowUpRight, ChevronLeft, Share2 } from 'lucide-react';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useMemo, useRef } from 'react';\nimport { useSpaceId } from './hooks/use-space-id';\nimport { RecommendTemplate } from './RecommendTemplate';\nimport { TemplatePreview } from './TemplatePreview';\nimport { TemplatePreviewSheet } from './TemplatePreviewSheet';\n\ninterface ITemplateDetailProps {\n  templateId: string;\n  onBackToTemplateList?: () => void;\n  onTemplateClick?: (templateId: string) => void;\n}\nexport const TemplateDetail = (props: ITemplateDetailProps) => {\n  const { templateId, onBackToTemplateList, onTemplateClick } = props;\n  const { t } = useTranslation(['common']);\n  const detailRef = useRef<HTMLDivElement>(null);\n  const isMobile = useIsMobile();\n  const { toast } = useToast();\n  const { data: _templateDetail } = useQuery({\n    queryKey: ReactQueryKeys.templateDetail(templateId),\n    queryFn: () => getTemplateDetail(templateId).then((res) => res.data),\n  });\n\n  const templateDetail = _templateDetail?.id === templateId ? _templateDetail : undefined;\n\n  const { name, description, categoryId, markdownDescription, cover } = templateDetail || {};\n\n  const { data: categoryList } = useQuery({\n    queryKey: ReactQueryKeys.publishedTemplateCategoryList(),\n    queryFn: () => getTemplateCategoryList().then((data) => data.data),\n  });\n\n  const categoryNames = useMemo(() => {\n    if (!categoryId || categoryId.length === 0) return [];\n    return categoryList?.filter((c) => categoryId.includes(c.id)).map((c) => c.name) || [];\n  }, [categoryList, categoryId]);\n\n  const router = useRouter();\n  const spaceId = useSpaceId();\n  const routerBaseId = router.query.baseId as string | undefined;\n\n  const { mutateAsync: createTemplateToBase, isPending: isLoading } = useMutation({\n    mutationFn: () =>\n      createBaseFromTemplate({\n        spaceId: spaceId as string,\n        templateId,\n        withRecords: true,\n        baseId: routerBaseId,\n      }),\n    onSuccess: (res) => {\n      const { id: baseId, defaultUrl } = res.data;\n\n      // If defaultUrl is provided, navigate to it directly\n      if (defaultUrl) {\n        router.push(defaultUrl);\n        return;\n      }\n\n      // Otherwise, navigate to base home\n      router.push({\n        pathname: '/base/[baseId]',\n        query: { baseId },\n      });\n    },\n  });\n\n  const filterTemplateIds = useMemo(() => {\n    return [templateId];\n  }, [templateId]);\n\n  const handleCopyPermalink = () => {\n    const permalink = `${window.location.origin}/t/${templateId}`;\n    navigator.clipboard.writeText(permalink);\n    toast({\n      title: t('common:template.non.copy'),\n    });\n  };\n\n  useEffect(() => {\n    if (detailRef.current) {\n      detailRef.current.scrollTo({\n        top: 0,\n        behavior: 'smooth',\n      });\n    }\n  }, [templateId]);\n\n  if (isMobile) {\n    return (\n      <div className=\"absolute inset-0 flex size-full flex-col rounded bg-background\">\n        <div className=\"flex items-center gap-2 px-6 py-3 pr-9\">\n          {onBackToTemplateList && (\n            <Button\n              className=\"h-auto p-0 font-normal\"\n              variant=\"link\"\n              onClick={onBackToTemplateList}\n            >\n              <ChevronLeft className=\"size-6\" />\n            </Button>\n          )}\n          <h1 className=\"truncate bg-background text-lg font-bold\">{name}</h1>\n        </div>\n        <div ref={detailRef} className=\"flex flex-col gap-3 overflow-y-auto px-6 pb-3\">\n          {categoryNames.length > 0 && (\n            <div className=\"flex flex-wrap gap-2\">\n              {categoryNames.map((categoryName) => (\n                <Badge\n                  key={categoryName}\n                  variant=\"secondary\"\n                  className=\"text-xs font-normal text-muted-foreground\"\n                >\n                  {categoryName}\n                </Badge>\n              ))}\n            </div>\n          )}\n          <p className=\"text-base font-normal text-muted-foreground\">{description}</p>\n          <div className=\"flex gap-2\">\n            <TemplatePreviewSheet detail={templateDetail}>\n              <Button className=\"flex-1\" variant=\"outline\" size=\"xs\">\n                <ArrowUpRight className=\"size-3\" />\n                {t('common:settings.templateAdmin.actions.preview')}\n              </Button>\n            </TemplatePreviewSheet>\n            <Button className=\"flex-1\" variant=\"outline\" size=\"xs\" onClick={handleCopyPermalink}>\n              <Share2 className=\"size-3\" />\n              {t('common:template.non.share')}\n            </Button>\n            <Button\n              className=\"flex-1\"\n              size=\"xs\"\n              onClick={() => createTemplateToBase()}\n              disabled={isLoading}\n            >\n              {t('common:settings.templateAdmin.useTemplate')}\n              {isLoading && <Spin className=\"size-3\" />}\n            </Button>\n          </div>\n          {cover?.presignedUrl && (\n            <div className=\"rounded-md border \">\n              <img\n                src={cover?.presignedUrl}\n                alt={name}\n                className=\"w-full rounded-md  object-contain\"\n              />\n            </div>\n          )}\n          <div className=\"flex flex-col gap-1 pb-2\">\n            {markdownDescription && (\n              <MarkdownPreview className=\"p-0\">{markdownDescription}</MarkdownPreview>\n            )}\n          </div>\n          <RecommendTemplate\n            filterTemplateIds={filterTemplateIds}\n            onClickTemplateCardHandler={onTemplateClick}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"absolute inset-0 flex size-full flex-col rounded bg-background\">\n      <div className=\"flex gap-3 border-b px-6 py-4 pr-12\">\n        <div className=\"flex flex-1 flex-col gap-1 overflow-hidden\">\n          <div className=\"flex items-center gap-3\">\n            {onBackToTemplateList && (\n              <Button\n                className=\"h-auto p-0 font-normal\"\n                variant=\"link\"\n                onClick={onBackToTemplateList}\n              >\n                <ChevronLeft className=\"size-6\" />\n              </Button>\n            )}\n            <h1 className=\"truncate text-lg font-semibold\">{name}</h1>\n            <div className=\"flex gap-2\">\n              {categoryNames.length > 0 &&\n                categoryNames.map((name) => (\n                  <Badge\n                    variant=\"secondary\"\n                    className=\"px-2 text-xs font-normal text-muted-foreground\"\n                    key={name}\n                  >\n                    {name}\n                  </Badge>\n                ))}\n            </div>\n          </div>\n          <p\n            className={cn(\n              'overflow-hidden text-wrap break-words pl-9 text-sm font-normal text-muted-foreground',\n              {\n                'pl-0': !onBackToTemplateList,\n              }\n            )}\n          >\n            {description}\n          </p>\n        </div>\n        <div className=\"flex gap-2\">\n          <Button size=\"sm\" variant=\"outline\" onClick={handleCopyPermalink}>\n            <Share2 className=\"size-4\" />\n            {t('common:template.non.share')}\n          </Button>\n          <Button size=\"sm\" onClick={() => createTemplateToBase()} disabled={isLoading}>\n            {t('common:settings.templateAdmin.useTemplate')}\n            {isLoading && <Spin className=\"size-3\" />}\n          </Button>\n        </div>\n      </div>\n      <div\n        ref={detailRef}\n        className=\"flex flex-1 flex-col gap-8 overflow-y-auto bg-muted px-10 py-6\"\n      >\n        <TemplatePreview detail={templateDetail} />\n        {markdownDescription && (\n          <div className=\"flex flex-col gap-1 pb-2\">\n            <MarkdownPreview className=\"p-0\">{markdownDescription}</MarkdownPreview>\n          </div>\n        )}\n        <RecommendTemplate\n          filterTemplateIds={filterTemplateIds}\n          onClickTemplateCardHandler={onTemplateClick}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateList.tsx",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query';\nimport { useTheme } from '@teable/next-themes';\nimport { getPublishedTemplateList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button, cn, Skeleton } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { TemplateCard } from './TemplateCard';\nimport type { ITemplateBaseProps } from './TemplateMain';\n\nconst TemplateCardSkeleton = () => (\n  <div className=\"flex w-full shrink-0 flex-col\">\n    <Skeleton className=\"aspect-[16/9] w-full rounded-lg\" />\n    <div className=\"flex flex-col gap-1 px-1 pt-2\">\n      <div className=\"flex items-center justify-between gap-3\">\n        <Skeleton className=\"h-5 w-2/3\" />\n        <Skeleton className=\"h-4 w-12\" />\n      </div>\n      <Skeleton className=\"h-4 w-full\" />\n    </div>\n  </div>\n);\n\ninterface ITemplateListProps extends ITemplateBaseProps {\n  currentCategoryId: string | null;\n  search: string;\n  className?: string;\n  isFeatured: boolean | undefined;\n}\n\nconst PAGE_SIZE = 2 * 3 * 2;\n\nexport const TemplateList = (props: ITemplateListProps) => {\n  const { currentCategoryId, search, onClickTemplateCardHandler, className, isFeatured } = props;\n  const { t } = useTranslation(['common', 'space']);\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({\n    queryKey: ReactQueryKeys.publishedTemplateList(currentCategoryId, search, isFeatured),\n    queryFn: ({ pageParam }) =>\n      getPublishedTemplateList({\n        categoryId: currentCategoryId,\n        search,\n        skip: pageParam,\n        take: PAGE_SIZE,\n        featured: isFeatured,\n      }).then((res) => res.data),\n    initialPageParam: 0,\n    getNextPageParam: (lastPage, allPages) => {\n      if (lastPage.length < PAGE_SIZE) {\n        return undefined;\n      }\n      return allPages.length * PAGE_SIZE;\n    },\n  });\n\n  const currentTemplateList = useMemo(() => {\n    return data?.pages?.flatMap((page) => page) ?? [];\n  }, [data]);\n\n  if (isLoading) {\n    return (\n      <div className=\"flex flex-1 flex-col overflow-y-auto\">\n        <div\n          className={cn(\n            'grid grid-cols-1 gap-5 text-left sm:grid-cols-2 lg:grid-cols-3',\n            className\n          )}\n        >\n          {Array.from({ length: 9 }).map((_, index) => (\n            <TemplateCardSkeleton key={index} />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  if (currentTemplateList?.length === 0) {\n    return (\n      <div className=\"flex size-full flex-1 flex-col items-center justify-center gap-4\">\n        <Image\n          src={\n            isDark ? '/images/layout/empty-list-dark.png' : '/images/layout/empty-list-light.png'\n          }\n          alt=\"No templates available\"\n          width={240}\n          height={240}\n        />\n        <div className=\"flex flex-col items-center justify-center gap-2\">\n          <p className=\"text-base font-semibold text-foreground\">\n            {t('space:template.noTemplatesAvailable')}\n          </p>\n          <p className=\"text-sm text-muted-foreground\">\n            {t('space:template.noTemplatesDescription')}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-1 flex-col overflow-y-auto\">\n      <div\n        className={cn('grid grid-cols-1 gap-5 text-left sm:grid-cols-2 lg:grid-cols-3', className)}\n      >\n        {currentTemplateList?.map((template) => (\n          <TemplateCard\n            size=\"md\"\n            key={template.id}\n            template={template}\n            onClickTemplateCardHandler={onClickTemplateCardHandler}\n          />\n        ))}\n      </div>\n\n      {hasNextPage && (\n        <div className=\"flex justify-center\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"my-4 flex gap-2 px-4\"\n            onClick={() => fetchNextPage()}\n            disabled={isFetchingNextPage}\n          >\n            {t('common:actions.loadMore')}\n            {isFetchingNextPage && <Spin className=\"size-4\" />}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateMain.tsx",
    "content": "import { useIsMobile } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\nimport { CategoryMenu } from './CategoryMenu';\nimport { TemplateList } from './TemplateList';\n\nexport interface ITemplateBaseProps {\n  onClickUseTemplateHandler?: (templateId: string) => void;\n  onClickTemplateCardHandler?: (template: string) => void;\n}\n\ninterface ITemplateMainProps extends ITemplateBaseProps {\n  currentCategoryId: string | null;\n  search: string;\n  onCategoryChange: (value: string | null) => void;\n  categoryMenuClassName?: string;\n  categoryHeaderRender?: () => React.ReactNode;\n  className?: string;\n  templateListClassName?: string;\n  disabledFeaturedToggle?: boolean;\n}\n\nexport const TemplateMain = (props: ITemplateMainProps) => {\n  const isMobile = useIsMobile();\n  const {\n    currentCategoryId,\n    search,\n    onCategoryChange,\n    onClickUseTemplateHandler,\n    onClickTemplateCardHandler,\n    categoryMenuClassName,\n    categoryHeaderRender,\n    className,\n    templateListClassName,\n    disabledFeaturedToggle = true,\n  } = props;\n  const [isFeatured, setIsFeatured] = useState<boolean | undefined>(true);\n  return (\n    <div\n      className={cn('flex flex-1 overflow-hidden', className, {\n        'flex-col': isMobile,\n      })}\n    >\n      <CategoryMenu\n        currentCategoryId={currentCategoryId}\n        onCategoryChange={onCategoryChange}\n        className={categoryMenuClassName}\n        categoryHeaderRender={categoryHeaderRender}\n        isFeatured={isFeatured}\n        onFeaturedChange={setIsFeatured}\n        disabledFeaturedToggle={disabledFeaturedToggle}\n      />\n      <TemplateList\n        currentCategoryId={currentCategoryId}\n        search={search}\n        onClickUseTemplateHandler={onClickUseTemplateHandler}\n        onClickTemplateCardHandler={onClickTemplateCardHandler}\n        className={cn(templateListClassName, 'p-4 shrink-0 content-start')}\n        isFeatured={isFeatured}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateModal.tsx",
    "content": "import { useIsMobile } from '@teable/sdk/hooks';\nimport {\n  cn,\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useDebounce } from 'react-use';\nimport { TemplateDetail } from './TemplateDetail';\nimport { TemplateMain } from './TemplateMain';\nimport { TemplateSheet } from './TemplateSheet';\ninterface TemplateModalProps {\n  children: React.ReactNode;\n  spaceId: string;\n}\n\nexport const TemplateModal = (props: TemplateModalProps) => {\n  const { children, spaceId } = props;\n  const { t } = useTranslation(['space', 'common']);\n\n  const [currentCategoryId, setCurrentCategoryId] = useState<string | null>(null);\n\n  const [search, setSearch] = useState<string>('');\n  const [inputValue, setInputValue] = useState<string>('');\n\n  const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);\n\n  const isMobile = useIsMobile();\n\n  // Debounce search input to avoid excessive updates\n  useDebounce(\n    () => {\n      setSearch(inputValue);\n    },\n    500,\n    [inputValue]\n  );\n\n  return isMobile ? (\n    <TemplateSheet spaceId={spaceId}>{children}</TemplateSheet>\n  ) : (\n    <Dialog>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"flex h-[88%] max-h-[88%] max-w-7xl flex-col gap-0 p-0 transition-[max-width] duration-300\">\n        <DialogHeader className=\"flex w-full border-b p-4\">\n          <div className=\"relative flex w-full items-center justify-center gap-2\">\n            <div className=\"absolute left-0 flex shrink-0 flex-col gap-0.5\">\n              <DialogTitle>{t('common:template.title')}</DialogTitle>\n              <DialogDescription>{t('common:template.description')}</DialogDescription>\n            </div>\n            <Input\n              placeholder={t('common:settings.templateAdmin.baseSelectPanel.search')}\n              value={inputValue}\n              className={cn('w-72', {\n                'opacity-0': currentTemplateId,\n              })}\n              onChange={(e) => setInputValue(e.target.value)}\n            />\n          </div>\n        </DialogHeader>\n\n        {currentTemplateId ? (\n          <TemplateDetail\n            templateId={currentTemplateId}\n            onBackToTemplateList={() => setCurrentTemplateId(null)}\n            onTemplateClick={(templateId) => setCurrentTemplateId(templateId)}\n          />\n        ) : (\n          <TemplateMain\n            currentCategoryId={currentCategoryId}\n            search={search}\n            onCategoryChange={(value) => setCurrentCategoryId(value)}\n            templateListClassName=\"overflow-y-auto p-2\"\n            className=\"w-full\"\n            onClickTemplateCardHandler={(templateId) => setCurrentTemplateId(templateId)}\n          />\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplatePreview.tsx",
    "content": "import type { ITemplateVo } from '@teable/openapi';\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button, cn } from '@teable/ui-lib/shadcn';\nimport { ArrowUpRight } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\nimport { useMeasure } from 'react-use';\n\nexport const TemplatePreview = (props: {\n  detail?: ITemplateVo;\n  hidePreviewButton?: boolean;\n  className?: string;\n  isFull?: boolean;\n}) => {\n  const { detail, hidePreviewButton, className, isFull } = props;\n  const { snapshot, name, id } = detail || {};\n  const [isLoading, setIsLoading] = useState(true);\n  const { t } = useTranslation(['common']);\n  const [ref, { width }] = useMeasure<HTMLDivElement>();\n  const isHydrated = useIsHydrated();\n  // Use permalink for template preview\n  const url = id\n    ? `${window.location.origin}/t/${id}`\n    : snapshot?.baseId\n      ? `${window.location.origin}/base/${snapshot.baseId}`\n      : '';\n  useEffect(() => {\n    if (url) {\n      setIsLoading(true);\n    }\n  }, [url]);\n\n  if (!isHydrated) {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground\">\n        <Spin className=\"size-4\" />\n      </div>\n    );\n  }\n\n  const height = width * (640 / 1240);\n\n  return (\n    <div className={cn('relative', className)} ref={isFull ? null : ref}>\n      <div style={{ height: isFull ? '100%' : `${height}px` }}></div>\n      {url && (\n        <iframe\n          className=\"absolute inset-0 overflow-hidden rounded-lg border\"\n          src={url}\n          title={name}\n          width={isFull ? '100%' : width}\n          height={isFull ? '100%' : height}\n          onLoad={() => requestAnimationFrame(() => setIsLoading(false))}\n        />\n      )}\n      {(isLoading || !url) && (\n        <div\n          className=\"absolute inset-0 flex items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground\"\n          style={{ height: isFull ? '100%' : `${height}px` }}\n        >\n          {t('common:actions.loading')}\n        </div>\n      )}\n      {!hidePreviewButton && (\n        <div className=\"absolute bottom-3 right-3\">\n          <Button variant=\"outline\" size=\"xs\" onClick={() => window.open(url, '_blank')}>\n            <ArrowUpRight className=\"size-4\" />\n            {t('common:settings.templateAdmin.actions.preview')}\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplatePreviewSheet.tsx",
    "content": "import type { ITemplateVo } from '@teable/openapi';\nimport { Sheet, SheetContent, SheetTrigger } from '@teable/ui-lib/shadcn';\nimport { TemplatePreview } from './TemplatePreview';\n\ninterface ITemplatePreviewSheetProps {\n  detail?: ITemplateVo;\n  children: React.ReactNode;\n}\nexport const TemplatePreviewSheet = (props: ITemplatePreviewSheetProps) => {\n  const { detail, children } = props;\n  return (\n    <Sheet>\n      <SheetTrigger asChild>{children}</SheetTrigger>\n      <SheetContent side=\"bottom\" className=\"h-[65%]\">\n        <div className=\"h-full pt-4\">\n          <TemplatePreview className=\"h-full\" detail={detail} hidePreviewButton isFull />\n        </div>\n      </SheetContent>\n    </Sheet>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/TemplateSheet.tsx",
    "content": "import {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n  Input,\n  cn,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useDebounce } from 'react-use';\nimport { TemplateDetail } from './TemplateDetail';\nimport { TemplateMain } from './TemplateMain';\n\ninterface ITemplateSheetProps {\n  children: React.ReactNode;\n  spaceId: string;\n}\n\nexport const TemplateSheet = (props: ITemplateSheetProps) => {\n  const { children } = props;\n  const { t } = useTranslation(['space', 'common']);\n\n  const [currentCategoryId, setCurrentCategoryId] = useState<string | null>(null);\n\n  const [search, setSearch] = useState<string>('');\n  const [inputValue, setInputValue] = useState<string>('');\n\n  const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);\n\n  // Debounce search input to avoid excessive updates\n  useDebounce(\n    () => {\n      setSearch(inputValue);\n    },\n    500,\n    [inputValue]\n  );\n\n  return (\n    <Sheet>\n      <SheetTrigger asChild>{children}</SheetTrigger>\n      <SheetContent side=\"bottom\" className=\"h-[95%]\">\n        <SheetHeader className=\"flex w-full border-b p-1\">\n          <div className=\"flex w-full items-center justify-start gap-2\">\n            <div className=\"left-0 flex flex-1 flex-col gap-1 p-0.5 pr-2\">\n              <div className=\"flex gap-2\">\n                <SheetTitle>{t('common:template.title')}</SheetTitle>\n                <Input\n                  placeholder={t('common:settings.templateAdmin.baseSelectPanel.search')}\n                  value={inputValue}\n                  className={cn('flex-1', {\n                    'opacity-0': currentTemplateId,\n                  })}\n                  onChange={(e) => setInputValue(e.target.value)}\n                />\n              </div>\n              <SheetDescription className=\"text-start\">\n                {t('common:template.description')}\n              </SheetDescription>\n            </div>\n          </div>\n        </SheetHeader>\n\n        {currentTemplateId ? (\n          <TemplateDetail\n            templateId={currentTemplateId}\n            onBackToTemplateList={() => setCurrentTemplateId(null)}\n            onTemplateClick={(templateId) => setCurrentTemplateId(templateId)}\n          />\n        ) : (\n          <TemplateMain\n            currentCategoryId={currentCategoryId}\n            search={search}\n            onCategoryChange={(value) => setCurrentCategoryId(value)}\n            templateListClassName=\"flex flex-col overflow-y-auto p-2 max-h-[calc(100vh-12rem)]\"\n            className=\"w-full\"\n            onClickTemplateCardHandler={(templateId) => setCurrentTemplateId(templateId)}\n          />\n        )}\n      </SheetContent>\n    </Sheet>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/context.ts",
    "content": "import React from 'react';\n\nexport interface ITemplateContext {\n  spaceId?: string;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const TemplateContext = React.createContext<ITemplateContext>({});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/hooks/use-space-id.ts",
    "content": "import { useContext } from 'react';\nimport { TemplateContext } from '../context';\n\nexport const useSpaceId = () => {\n  const { spaceId } = useContext(TemplateContext);\n  return spaceId;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/space/template/index.ts",
    "content": "export * from './TemplateModal';\nexport * from './TemplateMain';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/toggle-side-bar/HoverWrapper.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\nimport React, { useState } from 'react';\nimport { useSidebarStore } from '../sidebar/useSidebarStore';\n\ninterface IHoverWrapperProps {\n  children: React.ReactElement[];\n  size: number;\n}\n\ninterface IHoverWrapperTag {\n  children: React.ReactElement;\n}\n\nexport const HoverWrapper = (props: IHoverWrapperProps) => {\n  const { children, size = 240 } = props;\n  const [trigger, content] = children;\n  const [hover, setHover] = useState(false);\n\n  const mouseEnterHandler = () => {\n    setHover(true);\n  };\n\n  const mouseOutHandler = () => {\n    setHover(false);\n  };\n\n  const { isVisible } = useSidebarStore();\n\n  return (\n    <div>\n      <div onMouseEnter={() => mouseEnterHandler()} className=\"z-10\">\n        {trigger}\n      </div>\n      {\n        <div\n          className={cn(\n            'fixed flex h-full top-0 transition-[z-index] will-change-auto',\n            hover ? 'z-30 w-full' : 'w-auto z-0'\n          )}\n        >\n          <div\n            className={cn(\n              'transition-[width] overflow-hidden drop-shadow-2xl border-r will-change-auto',\n              {\n                'border-r-0': !isVisible,\n              }\n            )}\n            style={{\n              width: hover ? `${size}px` : '0',\n            }}\n          >\n            {content}\n          </div>\n          <div\n            onMouseEnter={() => mouseOutHandler()}\n            className={cn('flex-1', { hidden: !hover })}\n          ></div>\n        </div>\n      }\n    </div>\n  );\n};\n\nexport const HoverWrapperTrigger = ({ children }: IHoverWrapperTag) => {\n  return <>{children}</>;\n};\n\nHoverWrapperTrigger.displayName = 'HoverWrapper.trigger';\n\nHoverWrapper.Trigger = HoverWrapperTrigger;\n\nexport const HoverWrapperContent = ({ children }: IHoverWrapperTag) => {\n  return <>{children}</>;\n};\n\nHoverWrapperContent.displayName = 'HoverWrapper.content';\n\nHoverWrapper.content = HoverWrapperContent;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/toggle-side-bar/SheetWrapper.tsx",
    "content": "import { ChevronsRight } from '@teable/icons';\nimport { Sheet, SheetContent, Button, SheetTrigger } from '@teable/ui-lib';\nimport { cn } from '@teable/ui-lib/shadcn';\n\ninterface SheetWrapperProps {\n  children: React.ReactNode;\n}\n\nexport const SheetWrapper = (props: SheetWrapperProps) => {\n  const { children } = props;\n\n  return (\n    <Sheet modal={true}>\n      <SheetTrigger asChild>\n        <Button\n          className={cn('fixed left-0 z-50 p-1 top-7 transition-all rounded-r-full rounded-l-none')}\n          size=\"icon-xs\"\n          variant={'outline'}\n        >\n          <ChevronsRight className=\"size-5 shrink-0\" />\n        </Button>\n      </SheetTrigger>\n      <SheetContent side=\"left\" className=\"p-0\" closeable={false}>\n        {children}\n      </SheetContent>\n    </Sheet>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/toggle-side-bar/constant.ts",
    "content": "export const SIDE_BAR_WIDTH = 288;\nexport const MIN_SIDE_BAR_WIDTH = 176;\nexport const MAX_SIDE_BAR_WIDTH = 480;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/upload-progress-panel/TaskItem.tsx",
    "content": "import { UsageLimitModalType, useUsageLimitModalStore } from '@teable/sdk/components/billing/store';\nimport { EllipsisFileName } from '@teable/sdk/components/upload/EllipsisFileName';\nimport { FileCover } from '@teable/sdk/components/upload/FileCover';\nimport type { IGlobalUploadTask } from '@teable/sdk/store/use-attachment-upload-store';\nimport { cn, isImage } from '@teable/ui-lib';\nimport { RotateCcw, X } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef, useState } from 'react';\n\ninterface ITaskItemProps {\n  task: IGlobalUploadTask;\n  onCancel: () => void;\n  onRemove: () => void;\n  onRetry: () => void;\n}\n\nexport const TaskItem = ({ task, onCancel, onRemove, onRetry }: ITaskItemProps) => {\n  const { t } = useTranslation('table');\n  const mimetype = task.file.type || 'application/octet-stream';\n  const isError = task.status === 'error';\n  const isCompleted = task.status === 'completed';\n  const isUploading = task.status === 'uploading' || task.status === 'pending';\n  const shouldShowRemove = isError || isCompleted;\n  const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);\n  // Use ref to avoid resetting timers when parent re-renders with new callback references\n  const onRemoveRef = useRef(onRemove);\n  onRemoveRef.current = onRemove;\n\n  useEffect(() => {\n    if (!isImage(mimetype)) {\n      setImageUrl(undefined);\n      return;\n    }\n    const url = URL.createObjectURL(task.file);\n    setImageUrl(url);\n    return () => {\n      URL.revokeObjectURL(url);\n    };\n  }, [mimetype, task.file]);\n\n  useEffect(() => {\n    if (task.code === 402) {\n      useUsageLimitModalStore.setState({\n        modalType: UsageLimitModalType.Upgrade,\n        modalOpen: true,\n      });\n    }\n  }, [task.code, task.error, t]);\n\n  return (\n    <div\n      className={cn(\n        'group flex items-center gap-3 px-3 py-2.5 hover:bg-muted/30 w-full overflow-hidden'\n      )}\n    >\n      <div className=\"flex size-9 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40\">\n        <FileCover\n          className=\"size-full object-cover\"\n          mimetype={mimetype}\n          url={imageUrl}\n          name={task.fileName}\n        />\n      </div>\n\n      <div className=\"flex-1 overflow-hidden\">\n        <EllipsisFileName className=\"justify-start\" name={task.fileName} />\n\n        <div className=\"mt-0.5 flex items-center gap-1.5\">\n          {isUploading ? (\n            <span className=\"text-[11px] text-primary\">{task.progress}%</span>\n          ) : isError ? (\n            <span className=\"text-[11px] text-destructive\">\n              {task.error || t('upload.statusFailed')}\n            </span>\n          ) : (\n            <span className=\"text-[11px] text-emerald-600 dark:text-emerald-500\">\n              {t('upload.statusCompleted')}\n            </span>\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100\">\n        {isError ? (\n          <button\n            type=\"button\"\n            className=\"flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/50\"\n            onClick={onRetry}\n            title={t('upload.statusRetry')}\n          >\n            <RotateCcw className=\"size-3.5 text-muted-foreground\" />\n          </button>\n        ) : null}\n        {isUploading ? (\n          <button\n            type=\"button\"\n            className=\"flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/50\"\n            onClick={onCancel}\n            title={t('upload.statusCancel')}\n          >\n            <X className=\"size-3.5 text-muted-foreground\" />\n          </button>\n        ) : null}\n        {shouldShowRemove ? (\n          <button\n            type=\"button\"\n            className=\"flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/50\"\n            onClick={onRemove}\n          >\n            <X className=\"size-3.5 text-muted-foreground\" />\n          </button>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/upload-progress-panel/UploadProgressBubble.tsx",
    "content": "'use client';\n\nimport { useCellAttachmentUploadStore } from '@teable/sdk/store/use-attachment-upload-store';\nimport { cn } from '@teable/ui-lib';\nimport { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, X } from 'lucide-react';\nimport { useTranslation } from 'next-i18next';\n\ninterface IUploadProgressBubbleProps {\n  isExpanded: boolean;\n  onToggle: () => void;\n  onClose: () => void;\n}\n\nconst CIRCLE_RADIUS = 8;\nconst CIRCLE_LENGTH = 2 * Math.PI * CIRCLE_RADIUS;\n\nexport const UploadProgressBubble = ({\n  isExpanded,\n  onToggle,\n  onClose,\n}: IUploadProgressBubbleProps) => {\n  const { t } = useTranslation('table');\n  const globalProgress = useCellAttachmentUploadStore((state) => state.getGlobalProgress());\n\n  const { total, uploading, failed, progress } = globalProgress;\n  const hasActiveUploads = uploading > 0;\n  const hasErrors = failed > 0 && !hasActiveUploads;\n  const hasTasks = total > 0;\n  const statusText = hasActiveUploads\n    ? t('upload.panelUploading', { count: uploading })\n    : hasErrors\n      ? t('upload.panelFailed', { count: failed })\n      : t('upload.panelCompleted', { count: globalProgress.completed });\n\n  return (\n    <div\n      role=\"button\"\n      tabIndex={0}\n      onClick={onToggle}\n      onKeyDown={(event) => {\n        if (event.key === 'Enter' || event.key === ' ') {\n          event.preventDefault();\n          onToggle();\n        }\n      }}\n      className={cn(\n        'flex cursor-pointer select-none items-center justify-between px-3 py-2.5',\n        'transition-colors hover:bg-muted/30',\n        hasActiveUploads && 'border-primary/20',\n        hasErrors && 'border-destructive/20',\n        !hasActiveUploads && !hasErrors && 'border-border/50'\n      )}\n    >\n      <div className=\"flex items-center gap-2.5\">\n        <div className=\"relative flex size-5 items-center justify-center\">\n          {hasActiveUploads ? (\n            <>\n              <div className=\"-rotate-90\">\n                <svg className=\"size-5\" viewBox=\"0 0 20 20\">\n                  <circle\n                    cx=\"10\"\n                    cy=\"10\"\n                    r={CIRCLE_RADIUS}\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    className=\"text-muted-foreground/30\"\n                  />\n                  <circle\n                    cx=\"10\"\n                    cy=\"10\"\n                    r={CIRCLE_RADIUS}\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeDasharray={`${(progress / 100) * CIRCLE_LENGTH} ${CIRCLE_LENGTH}`}\n                    className=\"text-primary transition-all duration-300\"\n                  />\n                </svg>\n              </div>\n              <span className=\"absolute text-[8px] font-medium text-muted-foreground\">\n                {progress}\n              </span>\n            </>\n          ) : hasErrors ? (\n            <AlertCircle className=\"size-5 text-destructive\" />\n          ) : (\n            <CheckCircle2 className=\"size-5 text-emerald-600 dark:text-emerald-500\" />\n          )}\n        </div>\n        <span className=\"text-[13px] font-medium text-foreground\">\n          {hasTasks ? statusText : t('upload.panelCompleted', { count: 0 })}\n        </span>\n      </div>\n      <div className=\"flex items-center gap-0.5\">\n        <button\n          type=\"button\"\n          className=\"flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/50\"\n          onClick={(event) => {\n            event.stopPropagation();\n            onToggle();\n          }}\n        >\n          {isExpanded ? (\n            <ChevronDown className=\"size-4 text-muted-foreground\" />\n          ) : (\n            <ChevronUp className=\"size-4 text-muted-foreground\" />\n          )}\n        </button>\n        <button\n          type=\"button\"\n          className=\"flex size-6 items-center justify-center rounded transition-colors hover:bg-muted/50\"\n          onClick={(event) => {\n            event.stopPropagation();\n            onClose();\n          }}\n        >\n          <X className=\"size-4 text-muted-foreground\" />\n        </button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/upload-progress-panel/UploadProgressPanel.tsx",
    "content": "'use client';\n\nimport { useIsHydrated } from '@teable/sdk/hooks';\nimport { useCellAttachmentUploadStore } from '@teable/sdk/store/use-attachment-upload-store';\nimport { cn } from '@teable/ui-lib';\nimport { useEffect, useRef, useState } from 'react';\nimport { UploadProgressBubble } from './UploadProgressBubble';\nimport { UploadTaskList } from './UploadTaskList';\n\nexport const UploadProgressPanel = () => {\n  const isHydrated = useIsHydrated();\n  const [isExpanded, setIsExpanded] = useState(true);\n  const [isVisible, setIsVisible] = useState(true);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const previousTaskCountRef = useRef(0);\n\n  const allTasks = useCellAttachmentUploadStore((state) => state.getAllActiveTasks());\n  const hasActiveUploads = useCellAttachmentUploadStore((state) => state.hasActiveUploads());\n  const hasAnyTasks = allTasks.length > 0;\n  const clearCompletedTasks = useCellAttachmentUploadStore((state) => state.clearCompletedTasks);\n  const clearErrorTasks = useCellAttachmentUploadStore((state) => state.clearErrorTasks);\n\n  // Close panel when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {\n        setIsExpanded(false);\n      }\n    };\n\n    if (isExpanded) {\n      document.addEventListener('mousedown', handleClickOutside);\n      return () => document.removeEventListener('mousedown', handleClickOutside);\n    }\n  }, [isExpanded]);\n\n  useEffect(() => {\n    if (allTasks.length > 0 && allTasks.length > previousTaskCountRef.current) {\n      setIsVisible(true);\n    }\n    previousTaskCountRef.current = allTasks.length;\n  }, [allTasks.length]);\n\n  // Warn user when trying to leave page with active uploads\n  useEffect(() => {\n    const handleBeforeUnload = (event: BeforeUnloadEvent) => {\n      if (hasActiveUploads) {\n        event.preventDefault();\n        // Legacy support: some older browsers require returnValue to be set\n        event.returnValue = '';\n      }\n    };\n\n    window.addEventListener('beforeunload', handleBeforeUnload);\n    return () => window.removeEventListener('beforeunload', handleBeforeUnload);\n  }, [hasActiveUploads]);\n\n  if (!isHydrated || !hasAnyTasks || !isVisible) {\n    return null;\n  }\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn('absolute bottom-5 right-5 z-50 w-[340px]', 'transition-opacity duration-200')}\n    >\n      <div className=\"overflow-hidden rounded-lg border bg-background shadow-md\">\n        <UploadProgressBubble\n          isExpanded={isExpanded}\n          onToggle={() => setIsExpanded((prev) => !prev)}\n          onClose={() => {\n            setIsVisible(false);\n            clearCompletedTasks();\n            clearErrorTasks();\n          }}\n        />\n        <UploadTaskList isExpanded={isExpanded} />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/upload-progress-panel/UploadTaskList.tsx",
    "content": "'use client';\n\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { useCellAttachmentUploadStore } from '@teable/sdk/store/use-attachment-upload-store';\nimport { cn, ScrollArea } from '@teable/ui-lib';\nimport { memo, useRef } from 'react';\nimport { TaskItem } from './TaskItem';\n\ninterface IUploadTaskListProps {\n  isExpanded: boolean;\n}\n\nconst TaskListContent = memo(() => {\n  const allTasks = useCellAttachmentUploadStore((state) => state.getAllActiveTasks());\n  const cancelTask = useCellAttachmentUploadStore((state) => state.cancelTask);\n  const removeTask = useCellAttachmentUploadStore((state) => state.removeTask);\n  const retryTask = useCellAttachmentUploadStore((state) => state.retryTask);\n\n  const parentRef = useRef<HTMLDivElement>(null);\n\n  const rowVirtualizer = useVirtualizer({\n    count: allTasks.length,\n    getScrollElement: () => parentRef.current,\n    estimateSize: () => 60,\n    overscan: 3,\n    getItemKey: (index) => allTasks[index].id,\n  });\n\n  const virtualItems = rowVirtualizer.getVirtualItems();\n\n  return (\n    <ScrollArea\n      viewportRef={parentRef}\n      className=\"max-h-80 w-full [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0\"\n    >\n      <div\n        style={{\n          height: `${rowVirtualizer.getTotalSize()}px`,\n          width: '100%',\n          position: 'relative',\n        }}\n      >\n        {virtualItems.map((virtualItem) => (\n          <div\n            key={virtualItem.key}\n            data-virtual-index={virtualItem.index}\n            style={{\n              position: 'absolute',\n              top: 0,\n              left: 0,\n              width: '100%',\n              height: `${virtualItem.size}px`,\n              transform: `translateY(${virtualItem.start}px)`,\n            }}\n          >\n            <TaskItem\n              key={virtualItem.index}\n              task={allTasks[virtualItem.index]}\n              onCancel={() =>\n                cancelTask(allTasks[virtualItem.index].cellKey, allTasks[virtualItem.index].id)\n              }\n              onRemove={() =>\n                removeTask(allTasks[virtualItem.index].cellKey, allTasks[virtualItem.index].id)\n              }\n              onRetry={() =>\n                retryTask(allTasks[virtualItem.index].cellKey, allTasks[virtualItem.index].id)\n              }\n            />\n          </div>\n        ))}\n      </div>\n    </ScrollArea>\n  );\n});\n\nTaskListContent.displayName = 'TaskListContent';\n\nexport const UploadTaskList = ({ isExpanded }: IUploadTaskListProps) => {\n  return (\n    <div\n      className={cn(\n        'transition-[max-height] duration-200 ease-out flex h-full overflow-hidden max-h-0',\n        {\n          'border-t max-h-80': isExpanded,\n        }\n      )}\n    >\n      <TaskListContent />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/upload-progress-panel/index.ts",
    "content": "export { UploadProgressPanel } from './UploadProgressPanel';\nexport { UploadProgressBubble } from './UploadProgressBubble';\nexport { UploadTaskList } from './UploadTaskList';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/user/UserAvatar.tsx",
    "content": "import { Avatar, AvatarFallback, AvatarImage, cn } from '@teable/ui-lib/shadcn';\nimport React from 'react';\n\ninterface UserAvatarProps {\n  user: { name: string; avatar?: string | null };\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nexport const UserAvatar: React.FC<UserAvatarProps> = (props) => {\n  const { user, className, style } = props;\n  const { name, avatar } = user;\n\n  return (\n    <Avatar className={cn('size-7 bg-background', className)} style={style}>\n      <AvatarImage src={avatar || undefined} alt={name} />\n      <AvatarFallback>{name?.slice(0, 1)}</AvatarFallback>\n    </Avatar>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/user/UserNav.tsx",
    "content": "import { ExitIcon } from '@radix-ui/react-icons';\nimport { useMutation } from '@tanstack/react-query';\nimport { Key, HelpCircle, License, MessageSquare, Settings } from '@teable/icons';\nimport { signout } from '@teable/openapi';\nimport { useSession } from '@teable/sdk/hooks';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { useIsCloud } from '../../hooks/useIsCloud';\nimport { SettingTab, useSettingStore } from '../setting/useSettingStore';\n\nexport const UserNav: React.FC<React.PropsWithChildren> = (props) => {\n  const { children } = props;\n  const router = useRouter();\n  const { t } = useTranslation(['common', 'setting']);\n  const { user } = useSession();\n  const setting = useSettingStore();\n  const { mutateAsync: loginOut, isPending: isLoading } = useMutation({\n    mutationFn: signout,\n  });\n  const isCloud = useIsCloud();\n\n  const loginOutClick = async () => {\n    await loginOut();\n    router.push('/auth/login');\n  };\n\n  return (\n    <DropdownMenu modal={false}>\n      <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-56\" align=\"end\" forceMount>\n        <DropdownMenuLabel className=\"font-normal\">\n          <div className=\"flex flex-col space-y-1\">\n            <p className=\"truncate text-sm font-medium\" title={user.name}>\n              {user.name}\n            </p>\n            <p className=\"truncate text-xs text-muted-foreground\" title={user.email}>\n              {user.email}\n            </p>\n          </div>\n        </DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        <DropdownMenuItem className=\"flex gap-2\" onClick={() => setting.setOpen(true)}>\n          <Settings className=\"size-4 shrink-0\" />\n          {t('settings.nav.settings')}\n        </DropdownMenuItem>\n        <DropdownMenuItem className=\"flex gap-2\" asChild>\n          <a href={t('help.mainLink')} target=\"_blank\" rel=\"noreferrer\">\n            <HelpCircle className=\"size-4 shrink-0\" />\n            {t('help.title')}\n          </a>\n        </DropdownMenuItem>\n        <DropdownMenuItem className=\"flex gap-2\" asChild>\n          <a\n            href=\"https://app.teable.ai/share/shrX1qxpciRUj1Jww2b/view\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            <MessageSquare className=\"size-4 shrink-0\" />\n            {t('settings.nav.contactSupport')}\n          </a>\n        </DropdownMenuItem>\n        {isCloud && (\n          <DropdownMenuItem\n            className=\"flex gap-2\"\n            onClick={() => {\n              setting.setOpen(true, SettingTab.License);\n            }}\n          >\n            <License className=\"size-4 shrink-0\" />\n            {t('noun.license')}\n          </DropdownMenuItem>\n        )}\n        <DropdownMenuItem\n          className=\"flex gap-2\"\n          onClick={() => {\n            setting.setOpen(true, SettingTab.PersonalAccessToken);\n          }}\n        >\n          <Key className=\"size-4 shrink-0\" />\n          {t('setting:personalAccessToken')}\n        </DropdownMenuItem>\n        <DropdownMenuItem className=\"flex gap-2\" onClick={loginOutClick} disabled={isLoading}>\n          <ExitIcon className=\"size-4 shrink-0\" />\n          {t('settings.nav.logout')}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/user-integration/ProviderLogo.tsx",
    "content": "import { GoogleLogo, Mail, Slack } from '@teable/icons';\nimport { UserIntegrationProvider } from '@teable/openapi';\nimport { cn } from '@teable/ui-lib/shadcn';\n\nconst PROVIDER_ICONS: Record<UserIntegrationProvider, React.ReactNode> = {\n  [UserIntegrationProvider.Slack]: <Slack className=\"size-8\" />,\n  [UserIntegrationProvider.Gmail]: <GoogleLogo className=\"size-8\" />,\n  [UserIntegrationProvider.Outlook]: <Mail className=\"size-8\" />,\n};\n\nexport const UserIntegrationProviderLogo = (props: {\n  provider: UserIntegrationProvider;\n  className?: string;\n}) => {\n  const { provider, className } = props;\n\n  return (\n    <div className={cn('flex items-center justify-center', className)}>\n      {PROVIDER_ICONS[provider]}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/components/user-integration/utils.ts",
    "content": "import { UserIntegrationProvider } from '@teable/openapi';\n\nexport const openConnectIntegration = (\n  provider: UserIntegrationProvider,\n  queryParams?: Record<string, string>\n) => {\n  const queryString = new URLSearchParams({\n    ...queryParams,\n    callBackType: 'page',\n  }).toString();\n  switch (provider) {\n    case UserIntegrationProvider.Slack:\n    case UserIntegrationProvider.Gmail:\n    case UserIntegrationProvider.Outlook:\n      return window.open(`/api/user-integrations/authorize/${provider}?${queryString}`, '_blank');\n    default:\n      throw new Error(`Unsupported provider: ${provider}`);\n  }\n};\n\nexport const getUserIntegrationName = (provider: UserIntegrationProvider) => {\n  switch (provider) {\n    case UserIntegrationProvider.Slack:\n      return 'Slack';\n    case UserIntegrationProvider.Gmail:\n      return 'Gmail';\n    case UserIntegrationProvider.Outlook:\n      return 'Outlook';\n    default:\n      throw new Error(`Unsupported provider: ${provider}`);\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/context/ShareContext.tsx",
    "content": "import { createContext, useContext } from 'react';\n\ninterface IShareContext {\n  shareId?: string;\n  // URL prefix like `/share/{shareId}` to prepend to base URLs\n  urlPrefix?: string;\n  // Allowed node ID for base share filtering (the shared node and its descendants)\n  nodeId?: string;\n  // Whether users can copy/save the shared base to their space\n  allowSave?: boolean;\n  // Whether users can copy data from the shared base\n  allowCopy?: boolean;\n}\n\nexport const ShareContext = createContext<IShareContext>({});\n\nexport const useShareContext = () => useContext(ShareContext);\n\nexport const useShareUrlPrefix = () => {\n  const { urlPrefix } = useShareContext();\n  return urlPrefix || '';\n};\n\nexport const useShareNodeId = () => {\n  const { nodeId } = useShareContext();\n  return nodeId;\n};\n\nexport const useShareAllowSave = () => {\n  const { allowSave } = useShareContext();\n  return allowSave ?? false;\n};\n\nexport const useShareAllowCopy = () => {\n  const { allowCopy } = useShareContext();\n  return allowCopy ?? false;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/context/StaticTextRegistryProvider.tsx",
    "content": "import type { PropsWithChildren } from 'react';\nimport { createContext, useContext, useMemo } from 'react';\n\nexport type StaticTextRegistry = Record<string, Record<string, string>>;\n\nexport type StaticTextResolver = (domain: string, key: string) => unknown | undefined;\n\ntype StaticTextContextValue = {\n  registry: StaticTextRegistry;\n  resolver?: StaticTextResolver;\n};\n\nconst EMPTY_REGISTRY: StaticTextRegistry = Object.freeze({});\nconst EMPTY_CONTEXT = Object.freeze({\n  registry: EMPTY_REGISTRY,\n  resolver: undefined as StaticTextResolver | undefined,\n});\n\nconst StaticTextRegistryContext = createContext<StaticTextContextValue>(EMPTY_CONTEXT);\n\nexport const buildStaticTextKey = (...parts: Array<string | undefined | null>) => {\n  return parts.filter((part): part is string => Boolean(part)).join(':');\n};\n\nexport const getStaticTextByMap = (registry: StaticTextRegistry, domain?: string, key?: string) => {\n  if (!domain || !key) {\n    return;\n  }\n  return registry[domain]?.[key];\n};\n\nexport const StaticTextRegistryProvider = ({\n  children,\n  registry,\n  resolver,\n}: PropsWithChildren<{ registry?: StaticTextRegistry; resolver?: StaticTextResolver }>) => {\n  const contextValue = useMemo(\n    () => ({\n      registry: registry ?? EMPTY_REGISTRY,\n      resolver,\n    }),\n    [registry, resolver]\n  );\n\n  return (\n    <StaticTextRegistryContext.Provider value={contextValue}>\n      {children}\n    </StaticTextRegistryContext.Provider>\n  );\n};\n\nexport const useStaticText = (domain?: string, key?: string) => {\n  const { registry } = useContext(StaticTextRegistryContext);\n  return getStaticTextByMap(registry, domain, key);\n};\n\nexport const useStaticResolver = (domain: string, key: string) => {\n  const { resolver } = useContext(StaticTextRegistryContext);\n  return resolver?.(domain, key);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/DashboardGrid.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport type { IDashboardLayout } from '@teable/openapi';\nimport { getDashboard, updateLayoutDashboard } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { Responsive, WidthProvider } from 'react-grid-layout';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport { PluginItem } from './components/PluginItem';\nimport { useIsExpandPlugin } from './hooks/useIsExpandPlugin';\n\nconst ResponsiveGridLayout = WidthProvider(Responsive);\n\nexport const DashboardGrid = (props: { dashboardId: string }) => {\n  const { dashboardId } = props;\n  const baseId = useBaseId()!;\n  const queryClient = useQueryClient();\n  const isExpandPlugin = useIsExpandPlugin();\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n  const [isDragging, setIsDragging] = useState(false);\n  const [isSmallScreen, setIsSmallScreen] = useState(false);\n  const basePermissions = useBasePermission();\n  const canMange = basePermissions?.['base|update'];\n  const { data: dashboardData } = useQuery({\n    queryKey: ReactQueryKeys.getDashboard(dashboardId),\n    queryFn: () => getDashboard(baseId, dashboardId).then((res) => res.data),\n  });\n\n  const { mutate: updateLayoutDashboardMutate } = useMutation({\n    mutationFn: (layout: IDashboardLayout) => updateLayoutDashboard(baseId, dashboardId, layout),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  const layout = dashboardData?.layout ?? [];\n  const pluginMap = dashboardData?.pluginMap ?? {};\n\n  const onLayoutChange = (layout: ReactGridLayout.Layout[]) => {\n    updateLayoutDashboardMutate(\n      layout.map(({ i, x, y, w, h }) => ({ pluginInstallId: i, x, y, w, h }))\n    );\n  };\n\n  return (\n    <ResponsiveGridLayout\n      className=\"w-full\"\n      layouts={{\n        md: layout.map(({ pluginInstallId, x, y, w, h }) => ({\n          i: pluginInstallId,\n          x,\n          y,\n          w,\n          h,\n        })),\n      }}\n      breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}\n      rowHeight={80}\n      margin={[16, 16]}\n      containerPadding={[16, 16]}\n      cols={{ lg: 12, md: 12, sm: 12, xs: 1, xxs: 1 }}\n      draggableHandle=\".dashboard-draggable-handle\"\n      onResize={() => setIsDragging(true)}\n      onResizeStop={(layout) => {\n        setIsDragging(false);\n        onLayoutChange(layout);\n      }}\n      onDrag={() => setIsDragging(true)}\n      onDragStop={(layout) => {\n        setIsDragging(false);\n        onLayoutChange(layout);\n      }}\n      isResizable={canMange && !isSmallScreen}\n      isDraggable={canMange && !isSmallScreen}\n      onWidthChange={(containerWidth) => {\n        if (containerWidth < 768) {\n          setIsSmallScreen(true);\n        } else {\n          setIsSmallScreen(false);\n        }\n      }}\n    >\n      {layout.map(({ pluginInstallId, x, y, w, h }) => (\n        <div\n          key={pluginInstallId}\n          data-grid={{ x, y, w, h }}\n          className={cn({\n            '!transform-none !transition-none': isExpandPlugin(pluginInstallId),\n          })}\n        >\n          {pluginMap[pluginInstallId] ? (\n            <PluginItem\n              dragging={isDragging}\n              dashboardId={dashboardId}\n              name={pluginMap[pluginInstallId].name}\n              pluginId={pluginMap[pluginInstallId].id}\n              pluginUrl={pluginMap[pluginInstallId].url}\n              pluginInstallId={pluginMap[pluginInstallId].pluginInstallId}\n            />\n          ) : (\n            <div>{t('common:pluginCenter.pluginNotFound')}</div>\n          )}\n        </div>\n      ))}\n    </ResponsiveGridLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { MoreHorizontal, Plus } from '@teable/icons';\nimport { BaseNodeResourceType, getDashboard, renameDashboard } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { Button, Input } from '@teable/ui-lib/shadcn';\nimport Head from 'next/head';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef, useState } from 'react';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport { BaseNodeMore } from '../blocks/base/base-side-bar/BaseNodeMore';\nimport { useBrand } from '../hooks/useBrand';\nimport { AddPluginDialog } from './components/AddPluginDialog';\n\nexport const DashboardHeader = (props: { dashboardId: string }) => {\n  const { dashboardId } = props;\n  const baseId = useBaseId()!;\n  const queryClient = useQueryClient();\n  const [isRenaming, setIsRenaming] = useState(false);\n  const [editName, setEditName] = useState<string>('');\n  const renameRef = useRef<HTMLInputElement>(null);\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n  const basePermissions = useBasePermission();\n  const canManage = basePermissions?.['base|update'];\n  const { brandName } = useBrand();\n\n  const { data: dashboard } = useQuery({\n    queryKey: ReactQueryKeys.getDashboard(dashboardId),\n    queryFn: () => getDashboard(baseId, dashboardId).then((res) => res.data),\n  });\n\n  const { mutate: renameDashboardMutate } = useMutation({\n    mutationFn: ({ name }: { name: string }) => renameDashboard(baseId, dashboardId, name),\n    onSuccess: () => {\n      setIsRenaming(false);\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  const dashboardName = dashboard?.name ?? t('common:noun.dashboard');\n\n  const startRename = () => {\n    setIsRenaming(true);\n    setEditName(dashboardName);\n  };\n\n  const cancelRename = () => {\n    setIsRenaming(false);\n    setEditName(dashboardName);\n  };\n\n  const submitRename = () => {\n    const newName = editName.trim();\n    if (dashboardName === newName) {\n      setIsRenaming(false);\n      return;\n    }\n    setIsRenaming(false);\n    renameDashboardMutate({ name: newName });\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      submitRename();\n    } else if (e.key === 'Escape') {\n      cancelRename();\n    }\n  };\n\n  useEffect(() => {\n    let timer: NodeJS.Timeout;\n    if (isRenaming) {\n      timer = setTimeout(() => {\n        renameRef.current?.focus();\n        renameRef.current?.select();\n      }, 200);\n    }\n    return () => clearTimeout(timer);\n  }, [isRenaming]);\n\n  return (\n    <div className=\"flex h-12 shrink-0 items-center justify-between border-b px-4\">\n      <Head>\n        <title>{dashboardName ? `${dashboardName} - ${brandName}` : brandName}</title>\n      </Head>\n      {isRenaming ? (\n        <Input\n          ref={renameRef}\n          className=\"max-w-60\"\n          value={editName ?? ''}\n          onBlur={submitRename}\n          onKeyDown={handleKeyDown}\n          onChange={(e) => setEditName(e.target.value)}\n        />\n      ) : (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"justify-start text-sm\"\n          disabled={!canManage}\n          onClick={startRename}\n        >\n          <span className=\"truncate\"> {dashboardName}</span>\n        </Button>\n      )}\n\n      <div className=\"flex items-center gap-2\">\n        {canManage && (\n          <AddPluginDialog dashboardId={dashboardId}>\n            <Button variant={'outline'} size={'xs'}>\n              <Plus className=\"size-4 shrink-0\" />\n              {t('dashboard:addPlugin')}\n            </Button>\n          </AddPluginDialog>\n        )}\n        {canManage && (\n          <BaseNodeMore\n            resourceType={BaseNodeResourceType.Dashboard}\n            resourceId={dashboardId}\n            onRename={startRename}\n          >\n            <Button size=\"icon-xs\" variant=\"outline\">\n              <MoreHorizontal className=\"size-4\" />\n            </Button>\n          </BaseNodeMore>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Plus } from '@teable/icons';\nimport { getDashboard } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { isEmpty } from 'lodash';\nimport { useTranslation } from 'next-i18next';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport { AddPluginDialog } from './components/AddPluginDialog';\nimport { DashboardGrid } from './DashboardGrid';\n\nexport const DashboardMain = (props: { dashboardId: string }) => {\n  const { dashboardId } = props;\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n  const baseId = useBaseId()!;\n  const basePermissions = useBasePermission();\n  const canManage = basePermissions?.['base|update'];\n  const { data: dashboardData, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.getDashboard(dashboardId),\n    queryFn: () => getDashboard(baseId, dashboardId).then((res) => res.data),\n  });\n  if (isLoading) {\n    return (\n      <div className=\"flex flex-1 items-center justify-center\">\n        <Spin />\n      </div>\n    );\n  }\n  if (isEmpty(dashboardData?.pluginMap) && !isLoading) {\n    return (\n      <div className=\"flex flex-1 flex-col items-center justify-center gap-3\">\n        <p>{t('common:pluginCenter.pluginEmpty.title')}</p>\n        {canManage && (\n          <AddPluginDialog dashboardId={dashboardId}>\n            <Button size={'xs'}>\n              <Plus className=\"size-4 shrink-0\" />\n              {t('dashboard:addPlugin')}\n            </Button>\n          </AddPluginDialog>\n        )}\n      </div>\n    );\n  }\n  return (\n    <div className=\"flex-1 overflow-y-auto p-4\">\n      <DashboardGrid dashboardId={dashboardId} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx",
    "content": "import { Plus } from '@teable/icons';\nimport { useTheme } from '@teable/next-themes';\nimport { useBasePermission } from '@teable/sdk/hooks';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Image from 'next/image';\nimport { useTranslation } from 'next-i18next';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport { CreateDashboardDialog } from './components/CreateDashboardDialog';\n\nexport const EmptyDashboard = () => {\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n\n  const basePermissions = useBasePermission();\n  const canManage = basePermissions?.['base|update'];\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === 'dark';\n\n  return (\n    <div className=\"flex h-full flex-col items-center justify-center gap-5 px-20\">\n      <Image\n        src={\n          isDark\n            ? '/images/layout/empty-dashboard-dark.png'\n            : '/images/layout/empty-dashboard-light.png'\n        }\n        alt=\"Empty dashboard\"\n        width={240}\n        height={240}\n        className=\"mb-6\"\n      />\n      <div className=\"text-center\">\n        <h3 className=\"mb-3 text-xl font-semibold text-foreground\">{t('dashboard:empty.title')}</h3>\n        <p className=\"mb-6 max-w-md text-sm text-muted-foreground\">\n          {t('dashboard:empty.description')}\n        </p>\n        {canManage && (\n          <CreateDashboardDialog>\n            <Button size=\"lg\" className=\"px-8\">\n              <Plus className=\"size-4 shrink-0\" /> {t('dashboard:empty.create')}\n            </Button>\n          </CreateDashboardDialog>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/Pages.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { AlertCircle, X } from '@teable/icons';\nimport { getDashboardList, LastVisitResourceType, updateUserLastVisit } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useState } from 'react';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport { useBaseResource } from '../hooks/useBaseResource';\nimport type { IBaseResourceDashboard } from '../hooks/useBaseResource';\nimport { useInitializationZodI18n } from '../hooks/useInitializationZodI18n';\nimport { useSetting } from '../hooks/useSetting';\nimport { DashboardHeader } from './DashboardHeader';\nimport { DashboardMain } from './DashboardMain';\nimport { EmptyDashboard } from './EmptyDashboard';\n\nexport function DashboardPage() {\n  const baseId = useBaseId() as string;\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n  const [showDeprecationBanner, setShowDeprecationBanner] = useState(true);\n  useInitializationZodI18n();\n  const { dashboardId: dashboardQueryId } = useBaseResource() as IBaseResourceDashboard;\n  const { data: dashboardList, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.getDashboardList(baseId),\n    queryFn: ({ queryKey }) => getDashboardList(queryKey[1]).then((res) => res.data),\n    enabled: !!baseId,\n  });\n  const { disallowDashboard } = useSetting();\n  useEffect(() => {\n    // Skip last visit tracking in template or share mode\n    if (isReadOnlyPreview) return;\n    if (dashboardQueryId) {\n      updateUserLastVisit({\n        resourceId: dashboardQueryId,\n        parentResourceId: baseId,\n        resourceType: LastVisitResourceType.Dashboard,\n      });\n    }\n  }, [dashboardQueryId, baseId, isReadOnlyPreview]);\n\n  if (isLoading) {\n    return (\n      <div className=\"ml-4 mt-4\">\n        <Spin />\n      </div>\n    );\n  }\n  if (!isLoading && !dashboardList?.length) {\n    return <EmptyDashboard />;\n  }\n  const dashboardId = dashboardQueryId ?? dashboardList?.[0]?.id;\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <DashboardHeader dashboardId={dashboardId} />\n      {disallowDashboard && showDeprecationBanner && (\n        <div className=\"shrink-0 px-4 pt-4\">\n          <div className=\"flex flex-col items-start gap-1 rounded-lg border border-black/[0.08] bg-zinc-100 p-4 dark:border-white/[0.08] dark:bg-zinc-800\">\n            <div className=\"flex h-5 w-full items-center gap-3\">\n              <AlertCircle className=\"size-4 shrink-0 text-zinc-900 dark:text-zinc-100\" />\n              <p className=\"flex-1 text-sm font-medium text-zinc-900 dark:text-zinc-100\">\n                {t('dashboard:deprecation.title')}\n              </p>\n              <Button\n                onClick={() => setShowDeprecationBanner(false)}\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\" p-0\"\n              >\n                <X className=\"size-4\" />\n              </Button>\n            </div>\n            <div className=\"pl-7\">\n              <p className=\"text-xs text-zinc-900 dark:text-zinc-100\">\n                {t('dashboard:deprecation.description')}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n      <DashboardMain dashboardId={dashboardId} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/TestBaseQuery.tsx",
    "content": "import type { IBaseQuery } from '@teable/openapi';\nimport { BaseQueryBuilder } from '@teable/sdk/components';\nimport { Button, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';\nimport { useState } from 'react';\n\nexport const TestBaseQuery = () => {\n  const [query, setQuery] = useState<IBaseQuery>();\n  return (\n    <Dialog>\n      <DialogTrigger>\n        <Button size={'xs'} variant={'ghost'}>\n          Open Dialog\n        </Button>\n      </DialogTrigger>\n      <DialogContent>\n        <Button className=\"w-7\" size={'xs'} variant={'ghost'} onClick={() => setQuery(undefined)}>\n          Clear Query\n        </Button>\n        <BaseQueryBuilder query={query} onChange={setQuery} />\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/components/AddPluginDialog.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport type { IDashboardInstallPluginRo } from '@teable/openapi';\nimport { installPlugin, PluginPosition } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { PluginCenterDialog } from '../../components/plugin/PluginCenterDialog';\n\nexport const AddPluginDialog = (props: { children?: React.ReactNode; dashboardId: string }) => {\n  const { children, dashboardId } = props;\n  const baseId = useBaseId()!;\n  const queryClient = useQueryClient();\n\n  const { mutate: installPluginMutate } = useMutation({\n    mutationFn: (ro: IDashboardInstallPluginRo) => installPlugin(baseId, dashboardId, ro),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  return (\n    <PluginCenterDialog\n      positionType={PluginPosition.Dashboard}\n      onInstall={(id, name) =>\n        installPluginMutate({\n          pluginId: id,\n          name,\n        })\n      }\n    >\n      {children}\n    </PluginCenterDialog>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { createDashboard, z } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { Error } from '@teable/ui-lib/base';\nimport {\n  Button,\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTrigger,\n  Input,\n} from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { forwardRef, useImperativeHandle, useState } from 'react';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\n\ninterface ICreateDashboardDialogProps {\n  children?: React.ReactNode;\n  onSuccessCallback?: (dashboardId: string) => void;\n}\n\nexport interface ICreateDashboardDialogRef {\n  open: () => void;\n  close: () => void;\n}\n\nexport const CreateDashboardDialog = forwardRef<\n  ICreateDashboardDialogRef,\n  ICreateDashboardDialogProps\n>(\n  (\n    props: { children?: React.ReactNode; onSuccessCallback?: (dashboardId: string) => void },\n    ref\n  ) => {\n    const { onSuccessCallback } = props;\n    const baseId = useBaseId()!;\n    const router = useRouter();\n    const [error, setError] = useState<string>();\n    const [name, setName] = useState('');\n    const [open, setOpen] = useState(false);\n    const queryClient = useQueryClient();\n    const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n\n    useImperativeHandle(ref, () => ({\n      open: () => setOpen(true),\n      close: () => setOpen(false),\n    }));\n\n    const { mutate: createDashboardMutate } = useMutation({\n      mutationFn: (name: string) => createDashboard(baseId, { name }),\n      onSuccess: (res) => {\n        setOpen(false);\n        setName('');\n        queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboardList(baseId) });\n        router.push(`/base/${baseId}/dashboard/${res.data.id}`);\n        if (onSuccessCallback) {\n          onSuccessCallback?.(res.data.id);\n        }\n      },\n    });\n    return (\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>{props.children}</DialogTrigger>\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>{t('dashboard:createDashboard.title')}</DialogHeader>\n          <div>\n            <Input\n              placeholder={t('dashboard:createDashboard.placeholder')}\n              value={name}\n              onChange={(e) => {\n                setError(undefined);\n                setName(e.target.value);\n              }}\n            />\n            <Error error={error} />\n          </div>\n          <DialogFooter>\n            <Button\n              size={'sm'}\n              onClick={() => {\n                const valid = z\n                  .string()\n                  .min(1)\n                  .safeParse(name || undefined);\n                if (!valid.success) {\n                  setError(valid.error.issues?.[0]?.message);\n                  return;\n                }\n                createDashboardMutate(name);\n              }}\n            >\n              {t('common:actions.confirm')}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n);\n\nCreateDashboardDialog.displayName = 'CreateDashboardDialog';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Check, ChevronsUpDown, PlusCircle } from '@teable/icons';\nimport { getDashboardList } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport {\n  Button,\n  cn,\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useRef, useState } from 'react';\nimport { dashboardConfig } from '@/features/i18n/dashboard.config';\nimport type { ICreateDashboardDialogRef } from './CreateDashboardDialog';\nimport { CreateDashboardDialog } from './CreateDashboardDialog';\n\nexport const DashboardSwitcher = (props: {\n  className?: string;\n  dashboardId: string;\n  onChange?: (dashboardId: string) => void;\n}) => {\n  const { className, dashboardId } = props;\n  const [open, setOpen] = useState(false);\n  const baseId = useBaseId()!;\n  const { t } = useTranslation(dashboardConfig.i18nNamespaces);\n  const createDashboardDialogRef = useRef<ICreateDashboardDialogRef>(null);\n  const { data: dashboardList } = useQuery({\n    queryKey: ReactQueryKeys.getDashboardList(baseId),\n    queryFn: ({ queryKey }) => getDashboardList(queryKey[1]).then((res) => res.data),\n  });\n  const basePermissions = useBasePermission();\n  const canManage = basePermissions?.['base|update'];\n\n  const selectedDashboard = dashboardList?.find(({ id }) => id === dashboardId);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          size={'sm'}\n          aria-expanded={open}\n          aria-label=\"Select a team\"\n          className={cn('w-[200px] justify-between', className)}\n        >\n          <span className=\"truncate\">{selectedDashboard?.name}</span>\n          <ChevronsUpDown className=\"ml-auto size-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[200px] p-0\">\n        <Command>\n          <CommandInput placeholder={t('dashboard:findDashboard')} />\n          <CommandList>\n            <CommandEmpty>{t('common.search.empty')}</CommandEmpty>\n            <CommandGroup>\n              {dashboardList?.map(({ id, name }) => (\n                <CommandItem\n                  key={id}\n                  onSelect={() => {\n                    if (id !== dashboardId) {\n                      props.onChange?.(id);\n                      setOpen(false);\n                    }\n                  }}\n                  className=\"text-sm\"\n                >\n                  {name}\n                  <Check\n                    className={cn(\n                      'ml-auto h-4 w-4',\n                      dashboardId === id ? 'opacity-100' : 'opacity-0'\n                    )}\n                  />\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n          {canManage && (\n            <>\n              <CommandSeparator />\n              <CommandList>\n                <CommandGroup>\n                  <CreateDashboardDialog\n                    ref={createDashboardDialogRef}\n                    onSuccessCallback={() => setOpen(false)}\n                  >\n                    <CommandItem\n                      onSelect={() => {\n                        createDashboardDialogRef.current?.open();\n                      }}\n                    >\n                      <PlusCircle className=\"mr-2 size-5\" />\n                      {t('dashboard:createDashboard.button')}\n                    </CommandItem>\n                  </CreateDashboardDialog>\n                </CommandGroup>\n              </CommandList>\n            </>\n          )}\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/components/PluginItem.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events */\n/* eslint-disable jsx-a11y/no-static-element-interactions */\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  duplicateDashboardInstalledPlugin,\n  PluginPosition,\n  removePlugin,\n  renamePlugin,\n} from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseId, useBasePermission } from '@teable/sdk/hooks';\nimport { cn } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useCallback } from 'react';\nimport { PluginContent } from '../../components/plugin/PluginContent';\nimport { PluginHeader } from '../../components/plugin/PluginHeader';\nimport { useIsExpandPlugin } from '../hooks/useIsExpandPlugin';\n\nexport const PluginItem = (props: {\n  name: string;\n  pluginId: string;\n  dragging?: boolean;\n  pluginUrl?: string;\n  dashboardId: string;\n  pluginInstallId: string;\n}) => {\n  const baseId = useBaseId()!;\n  const { t } = useTranslation(['common']);\n  const { pluginInstallId, dashboardId, dragging, pluginId, name, pluginUrl } = props;\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const isExpandPlugin = useIsExpandPlugin();\n  const basePermissions = useBasePermission();\n  const canManage = basePermissions?.['base|update'];\n\n  const { mutate: removePluginMutate } = useMutation({\n    mutationFn: () => removePlugin(baseId, dashboardId, pluginInstallId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  const { mutate: renamePluginMutate } = useMutation({\n    mutationFn: (name: string) => renamePlugin(baseId, dashboardId, pluginInstallId, name),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  const { mutate: duplicateDashboardInstalledPluginFn } = useMutation({\n    mutationFn: () =>\n      duplicateDashboardInstalledPlugin(baseId, dashboardId, pluginInstallId, {\n        name: `${name} ${t('common:noun.copy')}`,\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getDashboard(dashboardId) });\n    },\n  });\n\n  const onExpand = useCallback(() => {\n    const query = { ...router.query, expandPluginId: pluginInstallId };\n    router.push(\n      {\n        pathname: router.pathname,\n        query,\n      },\n      undefined,\n      { shallow: true }\n    );\n  }, [pluginInstallId, router]);\n\n  const onCopy = useCallback(() => {\n    duplicateDashboardInstalledPluginFn();\n  }, [duplicateDashboardInstalledPluginFn]);\n\n  const onClose = () => {\n    const query = { ...router.query };\n    delete query.expandPluginId;\n    router.push(\n      {\n        pathname: router.pathname,\n        query,\n      },\n      undefined,\n      { shallow: true }\n    );\n  };\n\n  const isExpanded = isExpandPlugin(pluginInstallId);\n\n  return (\n    <div\n      className={cn('h-full', {\n        'fixed top-0 left-0 right-0 bottom-0 bg-black/20 flex items-center justify-center z-50':\n          isExpanded,\n      })}\n      onClick={onClose}\n    >\n      <div\n        className={cn(\n          'group flex h-full flex-col overflow-hidden rounded-xl border bg-background',\n          {\n            'md:w-[90%] h-[90%] w-full mx-4': isExpanded,\n            'pointer-events-none select-none': dragging,\n          }\n        )}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <PluginHeader\n          dragging={dragging}\n          draggableHandleClassName=\"dashboard-draggable-handle\"\n          name={name}\n          onDelete={removePluginMutate}\n          onNameChange={renamePluginMutate}\n          onExpand={onExpand}\n          onClose={onClose}\n          isExpanded={isExpanded}\n          canManage={canManage}\n          onCopy={onCopy}\n        />\n        <PluginContent\n          baseId={baseId}\n          dragging={dragging}\n          pluginId={pluginId}\n          pluginInstallId={pluginInstallId}\n          pluginUrl={pluginUrl}\n          positionId={dashboardId}\n          onExpand={onExpand}\n          renderClassName=\"p-1\"\n          positionType={PluginPosition.Dashboard}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/dashboard/hooks/useIsExpandPlugin.ts",
    "content": "import { useRouter } from 'next/router';\nimport { useCallback } from 'react';\n\nexport const useIsExpandPlugin = () => {\n  const router = useRouter();\n  const expandPluginId = router.query.expandPluginId as string | undefined;\n  return useCallback(\n    (pluginInstallId: string) => expandPluginId === pluginInstallId,\n    [expandPluginId]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useAI.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getAIConfig } from '@teable/openapi';\nimport { useBaseId } from '@teable/sdk/hooks';\n\nexport function useAI() {\n  const baseId = useBaseId() as string;\n  const { data } = useQuery({\n    queryKey: ['ai-config', baseId],\n    queryFn: () => getAIConfig(baseId).then(({ data }) => data),\n    enabled: Boolean(baseId),\n  });\n\n  return {\n    enable: Boolean(data),\n  };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useAutoFavicon.tsx",
    "content": "/**\n * IMPORTANT LEGAL NOTICE:\n *\n * This file is part of Teable, licensed under the GNU Affero General Public License (AGPL).\n *\n * While Teable is open source software, the brand assets (including but not limited to\n * the Teable name, logo, and brand identity) are protected intellectual property.\n * Modification, replacement, or removal of these brand assets is strictly prohibited\n * and constitutes a violation of our trademark rights and the terms of the AGPL license.\n *\n * Under Section 7(e) of AGPLv3, we explicitly reserve all rights to the\n * Teable brand assets. Any unauthorized modification, redistribution, or use\n * of these assets, including creating derivative works that remove or replace\n * the brand assets, may result in legal action.\n */\n\nimport { useEffect } from 'react';\nimport { useEnv } from './useEnv';\n\nexport const useAutoFavicon = () => {\n  const env = useEnv();\n  useEffect(() => {\n    if (!env.brandLogo) {\n      return;\n    }\n\n    const links = document.querySelectorAll(\"link[rel*='icon']\");\n    links.forEach((link) => link.remove());\n\n    const newLink = document.createElement('link');\n    newLink.type = 'image/x-icon';\n    newLink.rel = 'shortcut icon';\n    newLink.href = env.brandLogo;\n    document.head.appendChild(newLink);\n  }, [env.brandLogo]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useBaseResource.ts",
    "content": "import { BaseNodeResourceType } from '@teable/openapi';\nimport { useRouter } from 'next/router';\nimport { useMemo } from 'react';\n\ninterface IBaseResourceBase {\n  baseId: string;\n}\n\ninterface IBaseResourceEmpty extends IBaseResourceBase {\n  resourceType?: undefined;\n}\n\nexport interface IBaseResourceTable extends IBaseResourceBase {\n  resourceType: typeof BaseNodeResourceType.Table;\n  tableId: string;\n  viewId: string;\n}\n\nexport interface IBaseResourceDashboard extends IBaseResourceBase {\n  resourceType: typeof BaseNodeResourceType.Dashboard;\n  dashboardId: string;\n}\n\nexport interface IBaseResourceWorkflow extends IBaseResourceBase {\n  resourceType: typeof BaseNodeResourceType.Workflow;\n  workflowId: string;\n}\n\nexport interface IBaseResourceApp extends IBaseResourceBase {\n  resourceType: typeof BaseNodeResourceType.App;\n  appId: string;\n}\n\nexport type IBaseResource =\n  | IBaseResourceEmpty\n  | IBaseResourceTable\n  | IBaseResourceDashboard\n  | IBaseResourceWorkflow\n  | IBaseResourceApp;\n\nexport type IBaseResourceParsed =\n  | Omit<IBaseResourceEmpty, 'baseId'>\n  | Omit<IBaseResourceTable, 'baseId'>\n  | Omit<IBaseResourceDashboard, 'baseId'>\n  | Omit<IBaseResourceWorkflow, 'baseId'>\n  | Omit<IBaseResourceApp, 'baseId'>;\n\n/**\n * URL:\n * - /base/xxx                           → { resourceType: undefined }\n * - /base/xxx/table/tbl1/viw1           → { resourceType: Table, tableId: 'tbl1', viewId: 'viw1' }\n * - /base/xxx/table/tbl1                → { resourceType: Table, tableId: 'tbl1' }\n * - /base/xxx/dashboard/dsh1            → { resourceType: Dashboard, dashboardId: 'dsh1' }\n * - /base/xxx/automation                → { resourceType: Workflow }\n * - /base/xxx/automation/aut1           → { resourceType: Workflow, workflowId: 'aut1' }\n * - /base/xxx/app/app1                  → { resourceType: App, appId: 'app1' }\n *\n * Note: Legacy URLs like /base/xxx/tbl1/viw1 are redirected to /base/xxx/table/tbl1/viw1 in getServerSideProps\n */\nexport function parseBaseSlug(slug?: string[]): IBaseResourceParsed {\n  if (!slug || slug.length === 0) {\n    return { resourceType: undefined };\n  }\n\n  const [type, id, extra] = slug;\n\n  switch (type) {\n    case 'table':\n      return {\n        resourceType: BaseNodeResourceType.Table,\n        tableId: id,\n        viewId: extra,\n      };\n    case 'dashboard':\n      return {\n        resourceType: BaseNodeResourceType.Dashboard,\n        dashboardId: id,\n      };\n    case 'automation':\n      return {\n        resourceType: BaseNodeResourceType.Workflow,\n        workflowId: id,\n      };\n    case 'app':\n      return {\n        resourceType: BaseNodeResourceType.App,\n        appId: id,\n      };\n    default:\n      return { resourceType: undefined };\n  }\n}\n\nexport function useBaseResource(): IBaseResource {\n  const router = useRouter();\n  const { baseId, slug } = router.query;\n\n  return useMemo(() => {\n    const parsed = parseBaseSlug(slug as string[] | undefined);\n    return {\n      baseId: baseId as string,\n      ...parsed,\n    };\n  }, [baseId, slug]);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useBaseUsage.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getBaseUsage } from '@teable/openapi';\nimport { useBaseId } from '@teable/sdk/hooks';\nimport { useIsReadOnlyPreview } from '@teable/sdk/hooks/use-is-readonly-preview';\nimport { useIsCloud } from './useIsCloud';\nimport { useIsEE } from './useIsEE';\n\nexport const useBaseUsage = (props?: { disabled?: boolean }) => {\n  const isEE = useIsEE();\n  const isCloud = useIsCloud();\n  const baseId = useBaseId() as string;\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  const { data: baseUsage } = useQuery({\n    queryKey: ['base-usage', baseId],\n    queryFn: ({ queryKey }) => getBaseUsage(queryKey[1]).then(({ data }) => data),\n    enabled: !props?.disabled && (isCloud || isEE) && !isReadOnlyPreview,\n  });\n\n  return baseUsage;\n};\n\nexport const useBaseUsageWithLoading = (props?: { disabled?: boolean }) => {\n  const isEE = useIsEE();\n  const isCloud = useIsCloud();\n  const baseId = useBaseId() as string;\n\n  const {\n    data: baseUsage,\n    isLoading,\n    isFetched,\n  } = useQuery({\n    queryKey: ['base-usage', baseId],\n    queryFn: ({ queryKey }) => getBaseUsage(queryKey[1]).then(({ data }) => data),\n    enabled: !props?.disabled && (isCloud || isEE),\n  });\n\n  return { baseUsage, loading: isLoading, isFetched };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useBillingLevel.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getInstanceUsage, getSubscriptionSummary } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useBaseUsage } from './useBaseUsage';\nimport { useIsCloud } from './useIsCloud';\nimport { useIsEE } from './useIsEE';\n\nexport const useBillingLevel = ({ spaceId, baseId }: { spaceId?: string; baseId?: string }) => {\n  const isCloud = useIsCloud();\n  const isEE = useIsEE();\n\n  const baseUsage = useBaseUsage({ disabled: !baseId });\n\n  const { data: instanceUsage } = useQuery({\n    queryKey: ReactQueryKeys.instanceUsage(),\n    queryFn: () => getInstanceUsage().then((res) => res.data),\n    enabled: isEE && Boolean(spaceId),\n  });\n\n  const { data: subscriptionSummary } = useQuery({\n    queryKey: ReactQueryKeys.subscriptionSummary(spaceId as string),\n    queryFn: () => getSubscriptionSummary(spaceId as string).then((res) => res.data),\n    enabled: isCloud && Boolean(spaceId),\n  });\n\n  return subscriptionSummary?.level ?? baseUsage?.level ?? instanceUsage?.level;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useBillingLevelConfig.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { BillingProductLevel } from '@teable/openapi';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\n\nexport type AppSumoTier = 1 | 2 | 3 | 4;\n\nexport const useBillingLevelConfig = (productLevel?: BillingProductLevel) => {\n  const { t } = useTranslation('common');\n\n  const config = useMemo(() => {\n    return {\n      [BillingProductLevel.Free]: {\n        name: t('level.free'),\n        description: t('billing.levelTips', { level: t('level.free') }),\n        tagCls: 'bg-gray-900/10 dark:bg-white/10 text-gray-600 dark:text-gray-300',\n        upgradeTagCls:\n          'border border-gray-900/10 dark:border-white/10 text-gray-600 dark:text-white',\n      },\n      [BillingProductLevel.Pro]: {\n        name: t('level.pro'),\n        description: t('billing.levelTips', { level: t('level.pro') }),\n        tagCls: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',\n        upgradeTagCls: 'border border-emerald-200 dark:border-emerald-400 text-emerald-400',\n      },\n      [BillingProductLevel.Business]: {\n        name: t('level.business'),\n        description: t('billing.levelTips', { level: t('level.business') }),\n        tagCls: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400',\n        upgradeTagCls: 'border border-blue-200 dark:border-blue-400 text-blue-400',\n      },\n      [BillingProductLevel.Enterprise]: {\n        name: t('level.enterprise'),\n        description: t('billing.levelTips', { level: t('level.enterprise') }),\n        tagCls: 'bg-primary text-primary-foreground',\n        upgradeTagCls: 'border border-primary',\n      },\n    };\n  }, [t]);\n\n  return config[productLevel as BillingProductLevel] ?? config[BillingProductLevel.Free];\n};\n\nexport const useAppSumoTierConfig = (tier?: AppSumoTier) => {\n  const { t } = useTranslation('common');\n\n  const config = useMemo(() => {\n    return {\n      1: {\n        name: 'Tier 1',\n        description: t('billing.levelTips', { level: 'Tier 1' }),\n        tagCls: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',\n      },\n      2: {\n        name: 'Tier 2',\n        description: t('billing.levelTips', { level: 'Tier 2' }),\n        tagCls: 'bg-cyan-100 dark:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400',\n      },\n      3: {\n        name: 'Tier 3',\n        description: t('billing.levelTips', { level: 'Tier 3' }),\n        tagCls: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400',\n      },\n      4: {\n        name: 'Tier 4',\n        description: t('billing.levelTips', { level: 'Tier 4' }),\n        tagCls: 'bg-violet-100 dark:bg-violet-500/20 text-violet-600 dark:text-violet-400',\n      },\n    };\n  }, [t]);\n\n  return tier ? config[tier] : null;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useBrand.tsx",
    "content": "/**\n * IMPORTANT LEGAL NOTICE:\n *\n * This file is part of Teable, licensed under the GNU Affero General Public License (AGPL).\n *\n * While Teable is open source software, the brand assets (including but not limited to\n * the Teable name, logo, and brand identity) are protected intellectual property.\n * Modification, replacement, or removal of these brand assets is strictly prohibited\n * and constitutes a violation of our trademark rights and the terms of the AGPL license.\n *\n * Under Section 7(e) of AGPLv3, we explicitly reserve all rights to the\n * Teable brand assets. Any unauthorized modification, redistribution, or use\n * of these assets, including creating derivative works that remove or replace\n * the brand assets, may result in legal action.\n */\n\nimport { useEnv } from './useEnv';\n\nexport const useBrand = (): { brandName: string; brandLogo?: string } => {\n  const env = useEnv();\n\n  return {\n    brandName: env.brandName || 'Teable',\n    brandLogo: env.brandLogo,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useCutDown.ts",
    "content": "import { useEffect, useState } from 'react';\n\nexport const useCutDown = (initialCountdown: number = 0) => {\n  const [countdown, setCountdown] = useState<number>(initialCountdown);\n\n  useEffect(() => {\n    if (countdown <= 0) return;\n    const timer = setTimeout(() => {\n      setCountdown((prev) => prev - 1);\n    }, 1000);\n    return () => clearTimeout(timer);\n  }, [countdown]);\n\n  return {\n    countdown,\n    setCountdown,\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useDisableAIAction.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getAIDisableActions } from '@teable/openapi';\nimport { useBaseId, useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { useMemo } from 'react';\nimport { AIActions } from '../blocks/admin/setting/components/ai-config/AIControlCard';\n\nexport const useDisableAIAction = () => {\n  const baseId = useBaseId();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n  const { data: { disableActions } = {} } = useQuery({\n    queryKey: ['disable-ai-actions', baseId],\n    queryFn: () => getAIDisableActions(baseId!).then((res) => res.data),\n    enabled: !!baseId && !isReadOnlyPreview,\n  });\n\n  return useMemo(() => {\n    if (Array.isArray(disableActions) && disableActions.length > 0) {\n      return {\n        aiField: !disableActions.includes(AIActions.AIField),\n        aiChat: !disableActions.includes(AIActions.AIChat),\n      };\n    }\n    return {\n      aiField: true,\n      aiChat: true,\n    };\n  }, [disableActions]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useDownLoad.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react';\n\ninterface IDownloadProps {\n  downloadUrl: string;\n  key?: string;\n}\n\nconst DEFAULT_DOWNLOAD_IFRAME_ID = 'teable_download_iframe_id';\n\nexport const useDownload = ({ downloadUrl, key }: IDownloadProps) => {\n  const iframeRef = useRef<HTMLIFrameElement | null>(null);\n\n  useEffect(() => {\n    const target = document.getElementById(`${DEFAULT_DOWNLOAD_IFRAME_ID}_${key}`);\n    if (target && target instanceof HTMLIFrameElement) {\n      iframeRef.current = target;\n      return;\n    }\n    const iframe = document.createElement('iframe');\n    iframe.id = `${DEFAULT_DOWNLOAD_IFRAME_ID}_${key}`;\n    iframe.style.display = 'none';\n    iframe.title = 'This is for download';\n    document.body.appendChild(iframe);\n    iframeRef.current = iframe;\n\n    return () => {\n      iframeRef.current && document.body.removeChild(iframeRef.current);\n    };\n  }, [key]);\n\n  const trigger = useCallback(() => {\n    if (iframeRef.current) {\n      const iframe = iframeRef.current;\n      iframe.src = downloadUrl;\n    }\n  }, [downloadUrl]);\n\n  return { trigger };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useEnv.ts",
    "content": "import { useContext } from 'react';\nimport { EnvContext } from '@/lib/server-env';\n\nexport function useEnv() {\n  return useContext(EnvContext);\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useInitializationZodI18n.ts",
    "content": "import { useTranslation } from 'next-i18next';\nimport { useEffect } from 'react';\nimport { z } from 'zod';\nimport { zhCN, en } from 'zod/v4/locales';\n\n// Zod 4.x native i18n support\nconst localeErrorMaps = {\n  'zh-CN': zhCN().localeError,\n  en: en().localeError,\n  'en-US': en().localeError,\n};\n\nexport const useInitializationZodI18n = () => {\n  const { i18n } = useTranslation();\n\n  useEffect(() => {\n    const language = i18n.language || 'en';\n    // Map language codes to Zod locale error maps\n    const errorMap =\n      localeErrorMaps[language as keyof typeof localeErrorMaps] || localeErrorMaps['en'];\n    z.config({ localeError: errorMap });\n  }, [i18n.language]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useIsCloud.ts",
    "content": "import { useEnv } from './useEnv';\n\nexport const useIsCloud = () => {\n  const { edition } = useEnv();\n\n  return edition?.toUpperCase() === 'CLOUD';\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useIsCommunity.ts",
    "content": "import { useIsReadOnlyPreview } from '@teable/sdk/hooks';\nimport { useEnv } from './useEnv';\n\nexport const useIsCommunity = () => {\n  const { edition } = useEnv();\n  const isReadOnlyPreview = useIsReadOnlyPreview();\n\n  // In template/share preview mode, allow all features to be displayed\n  // (similar to how template preview works)\n  if (isReadOnlyPreview) {\n    return false;\n  }\n\n  return edition?.toUpperCase() != 'EE' && edition?.toUpperCase() != 'CLOUD';\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useIsEE.ts",
    "content": "import { useEnv } from './useEnv';\n\nexport const useIsEE = () => {\n  const { edition } = useEnv();\n\n  return edition?.toUpperCase() === 'EE';\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useIsInIframe.ts",
    "content": "import { useEffect, useState } from 'react';\n\nconst getIsIframe = () => {\n  try {\n    return window.self !== window.top;\n  } catch (e) {\n    return true;\n  }\n};\n\nexport const useIsInIframe = () => {\n  const [isInIframe, setIsInIframe] = useState(getIsIframe);\n\n  useEffect(() => {\n    setIsInIframe(getIsIframe());\n  }, []);\n  return isInIframe;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/usePreviewUrl.ts",
    "content": "import { getPublicFullStorageUrl } from '@teable/openapi';\nimport { useCallback } from 'react';\nimport { useEnv } from './useEnv';\n\nexport const usePreviewUrl = () => {\n  const { storage = {} } = useEnv();\n\n  return useCallback(\n    (path: string) => {\n      const { publicUrl, prefix = '', provider, publicBucket } = storage;\n\n      if (path.startsWith(prefix)) {\n        return path;\n      }\n\n      return getPublicFullStorageUrl({ publicUrl, prefix, provider, publicBucket }, path);\n    },\n    [storage]\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useSdkLocale.ts",
    "content": "import { useTranslation } from 'next-i18next';\n\nexport const useSdkLocale = () => {\n  const { i18n } = useTranslation();\n  return i18n.getDataByLanguage(i18n.language)?.sdk;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/hooks/useSetting.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getPublicSetting } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { useSession } from '@teable/sdk/hooks';\n\nexport const useSetting = () => {\n  const { user } = useSession();\n  const { data: setting, isLoading } = useQuery({\n    queryKey: ReactQueryKeys.getPublicSetting(),\n    queryFn: () => getPublicSetting().then(({ data }) => data),\n  });\n\n  const {\n    disallowSignUp = false,\n    disallowSpaceCreation = false,\n    disallowSpaceInvitation = false,\n    disallowDashboard = false,\n    appGenerationEnabled = false,\n    createdTime,\n    enableCreditReward = false,\n  } = setting ?? {};\n\n  return {\n    disallowSignUp,\n    disallowSpaceCreation: !user.isAdmin && (isLoading || disallowSpaceCreation),\n    disallowSpaceInvitation: !user.isAdmin && (isLoading || disallowSpaceInvitation),\n    disallowDashboard,\n    appGenerationEnabled,\n    createdTime,\n    enableCreditReward,\n  };\n};\n\nexport const usePublicSettingQuery = () => {\n  return useQuery({\n    queryKey: ReactQueryKeys.getPublicSetting(),\n    queryFn: () => getPublicSetting().then(({ data }) => data),\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport { Settings, LayoutTemplate as TemplateIcon, ShieldUser } from '@teable/icons';\nimport type { IUser } from '@teable/sdk';\nimport { SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport Head from 'next/head';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { Sidebar } from '@/features/app/components/sidebar/Sidebar';\nimport { SidebarHeaderLeft } from '@/features/app/components/sidebar/SidebarHeaderLeft';\nimport { useSdkLocale } from '@/features/app/hooks/useSdkLocale';\nimport { AppLayout } from '@/features/app/layouts';\nimport { SidebarContent } from '../components/sidebar/SidebarContent';\n\nexport const AdminLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { t } = useTranslation('common');\n  const router = useRouter();\n\n  const onBack = () => {\n    router.push({ pathname: '/space' });\n  };\n\n  const routes = [\n    {\n      Icon: Settings,\n      label: t('settings.title'),\n      route: '/admin/setting',\n      pathTo: '/admin/setting',\n    },\n    {\n      Icon: TemplateIcon,\n      label: t('settings.templateAdmin.title'),\n      route: '/admin/template',\n      pathTo: '/admin/template',\n    },\n  ];\n\n  return (\n    <AppLayout>\n      <Head>\n        <title>{t('noun.adminPanel')}</title>\n      </Head>\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n            <Sidebar\n              headerLeft={\n                <SidebarHeaderLeft\n                  title={t('noun.adminPanel')}\n                  icon={<ShieldUser className=\"size-5 shrink-0\" />}\n                  onBack={onBack}\n                />\n              }\n            >\n              <SidebarContent routes={routes} />\n            </Sidebar>\n            {children}\n          </div>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/AppLayout.tsx",
    "content": "import { generateWindowId } from '@teable/core';\nimport { axios } from '@teable/openapi';\nimport { useIsTouchDevice } from '@teable/sdk/hooks';\nimport { useEffect, type FC, type PropsWithChildren } from 'react';\nimport { useMount } from 'react-use';\nimport { MainLayout } from '@/components/layout';\nimport { useAutoFavicon } from '../hooks/useAutoFavicon';\n\nexport const AppLayout: FC<PropsWithChildren> = (props) => {\n  const { children } = props;\n\n  // Determine whether it is a touch device\n  const isTouchDevice = useIsTouchDevice();\n  useAutoFavicon();\n\n  useMount(() => {\n    const windowId = generateWindowId();\n    axios.interceptors.request.use((config) => {\n      config.headers['X-Window-Id'] = windowId;\n      return config;\n    });\n  });\n\n  // Solve the problem that the page will be pushed up after the input is focused on touch devices\n  useEffect(() => {\n    if (!isTouchDevice) return;\n\n    const onFocusout = () => {\n      setTimeout(() => window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }));\n    };\n    document.body.addEventListener('focusout', onFocusout);\n    return () => document.body.removeEventListener('focusout', onFocusout);\n  }, [isTouchDevice]);\n\n  return <MainLayout>{children}</MainLayout>;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IGetBaseVo, ITableVo } from '@teable/openapi';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, SessionProvider } from '@teable/sdk';\nimport { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment } from 'react';\nimport { AppLayout } from '@/features/app/layouts';\nimport { WorkFlowPanelModal } from '../automation/workflow-panel/WorkFlowPanelModal';\nimport { BaseNodeProvider } from '../blocks/base/base-node/BaseNodeProvider';\nimport { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar';\nimport { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft';\nimport { QuickAction } from '../blocks/base/base-side-bar/QuickAction';\nimport { BasePermissionListener } from '../blocks/base/BasePermissionListener';\nimport { UsageLimitModal } from '../components/billing/UsageLimitModal';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport { UploadProgressPanel } from '../components/upload-progress-panel/UploadProgressPanel';\nimport type { IBaseResourceTable } from '../hooks/useBaseResource';\nimport { useBaseResource } from '../hooks/useBaseResource';\nimport { useEnv } from '../hooks/useEnv';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { TemplateBaseLayout } from './TemplateBaseLayout';\n\nexport const BaseLayout: React.FC<{\n  children: React.ReactNode;\n  tableServerData?: ITableVo[];\n  dehydratedState?: DehydratedState;\n  user?: IUser;\n  base?: IGetBaseVo;\n}> = ({ children, ...props }) => {\n  const { tableServerData, user, dehydratedState } = props;\n  const { baseId, tableId, viewId } = useBaseResource() as IBaseResourceTable;\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { maxSearchFieldCount } = useEnv();\n\n  return (\n    <TemplateBaseLayout {...props} childrenContent={children}>\n      <AppLayout>\n        <AppProvider\n          lang={i18n.language}\n          locale={sdkLocale}\n          dehydratedState={dehydratedState}\n          maxSearchFieldCount={maxSearchFieldCount}\n        >\n          <SessionProvider user={user}>\n            <NotificationProvider>\n              <AnchorContext.Provider\n                value={{\n                  baseId: baseId as string,\n                  tableId: tableId as string,\n                  viewId: viewId as string,\n                }}\n              >\n                <BaseProvider>\n                  <BaseNodeProvider>\n                    <BasePermissionListener />\n                    <TableProvider serverData={tableServerData}>\n                      <div\n                        id=\"portal\"\n                        className=\"relative flex h-screen w-full items-start\"\n                        onContextMenu={(e) => e.preventDefault()}\n                      >\n                        <div className=\"flex h-screen w-full\">\n                          <Sidebar\n                            headerLeft={<BaseSidebarHeaderLeft />}\n                            headerRight={<QuickAction />}\n                          >\n                            <Fragment>\n                              <div className=\"flex h-full flex-col gap-2 divide-y divide-solid overflow-auto py-2\">\n                                <BaseSideBar />\n                              </div>\n                              <div className=\"grow basis-0\" />\n                              <SideBarFooter />\n                            </Fragment>\n                          </Sidebar>\n                          <div className=\"min-w-80 flex-1\">{children}</div>\n                        </div>\n                        <UploadProgressPanel />\n                      </div>\n                      <UsageLimitModal />\n                      <WorkFlowPanelModal />\n                    </TableProvider>\n                  </BaseNodeProvider>\n                </BaseProvider>\n              </AnchorContext.Provider>\n            </NotificationProvider>\n          </SessionProvider>\n        </AppProvider>\n      </AppLayout>\n    </TemplateBaseLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SettingLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { DriverClient } from '@teable/core';\nimport type { IUser } from '@teable/sdk';\nimport { AppProvider, SessionProvider } from '@teable/sdk';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { AppLayout } from '@/features/app/layouts';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SidebarContent } from '../components/sidebar/SidebarContent';\nimport { SidebarHeaderLeft } from '../components/sidebar/SidebarHeaderLeft';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { useSettingRoute } from './useSettingRoute';\n\nexport const SettingLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  driver: DriverClient;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const router = useRouter();\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { t } = useTranslation(['setting', 'common']);\n\n  const routes = useSettingRoute();\n\n  const onBack = () => {\n    router.push('/');\n  };\n\n  return (\n    <AppLayout>\n      <AppProvider lang={i18n.language} locale={sdkLocale} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n            <Sidebar\n              headerLeft={<SidebarHeaderLeft title={t('common:settings.title')} onBack={onBack} />}\n            >\n              <SidebarContent routes={routes} />\n            </Sidebar>\n            {children}\n          </div>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/ShareBaseLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IGetBaseVo, ITableVo } from '@teable/openapi';\nimport { SessionProvider, addQueryParamsToWebSocketUrl } from '@teable/sdk';\nimport type { IUser } from '@teable/sdk/context';\nimport { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context';\nimport { getWsPath } from '@teable/sdk/context/app/useConnection';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment, useMemo } from 'react';\nimport { AppLayout } from '@/features/app/layouts';\nimport { BaseNodeProvider } from '../blocks/base/base-node/BaseNodeProvider';\nimport { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar';\nimport { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft';\nimport { BasePermissionListener } from '../blocks/base/BasePermissionListener';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport { ShareContext } from '../context/ShareContext';\nimport type { IBaseResourceTable } from '../hooks/useBaseResource';\nimport { useBaseResource } from '../hooks/useBaseResource';\nimport { useEnv } from '../hooks/useEnv';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { initAxios } from '../utils/init-axios';\n\ninterface IShareBaseLayoutProps {\n  children: React.ReactNode;\n  tableServerData?: ITableVo[];\n  dehydratedState?: DehydratedState;\n  user?: IUser;\n  base?: IGetBaseVo;\n  shareId?: string;\n  shareNodeId?: string;\n  allowSave?: boolean;\n  allowCopy?: boolean;\n}\n\nexport const ShareBaseLayout: React.FC<IShareBaseLayoutProps> = ({\n  children,\n  tableServerData,\n  dehydratedState,\n  user,\n  shareId,\n  shareNodeId,\n  allowSave,\n  allowCopy,\n}) => {\n  const { baseId, tableId, viewId } = useBaseResource() as IBaseResourceTable;\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { maxSearchFieldCount } = useEnv();\n\n  const isShare = !!shareId;\n\n  // Initialize axios with share header (synchronous, like template)\n  if (isShare) {\n    initAxios({ shareId });\n  }\n\n  const wsPath = useMemo(() => {\n    if (typeof window === 'object' && shareId) {\n      return addQueryParamsToWebSocketUrl(getWsPath(), { baseShareId: shareId });\n    }\n    return undefined;\n  }, [shareId]);\n\n  // Share context value with URL prefix and nodeId for filtering\n  const shareContextValue = useMemo(\n    () => ({\n      shareId,\n      urlPrefix: shareId ? `/share/${shareId}` : undefined,\n      nodeId: shareNodeId,\n      allowSave,\n      allowCopy,\n    }),\n    [shareId, shareNodeId, allowSave, allowCopy]\n  );\n\n  // If not a share context, just render children (fallback)\n  if (!isShare) {\n    return <>{children}</>;\n  }\n\n  return (\n    <ShareContext.Provider value={shareContextValue}>\n      <AppLayout>\n        <AppProvider\n          lang={i18n.language}\n          locale={sdkLocale}\n          dehydratedState={dehydratedState}\n          wsPath={wsPath}\n          shareId={shareId}\n          maxSearchFieldCount={maxSearchFieldCount}\n        >\n          <SessionProvider user={user} disabledApi>\n            <AnchorContext.Provider\n              value={{\n                baseId: baseId as string,\n                tableId: tableId as string,\n                viewId: viewId as string,\n              }}\n            >\n              <BaseProvider>\n                <BaseNodeProvider>\n                  <BasePermissionListener />\n                  <TableProvider serverData={tableServerData}>\n                    <div\n                      id=\"portal\"\n                      className=\"relative flex h-screen w-full items-start\"\n                      onContextMenu={(e) => e.preventDefault()}\n                    >\n                      <div className=\"flex h-screen w-full\">\n                        <Sidebar headerLeft={<BaseSidebarHeaderLeft />}>\n                          <Fragment>\n                            <div className=\"flex h-full flex-col gap-2 divide-y divide-solid overflow-auto py-2\">\n                              <BaseSideBar />\n                            </div>\n                            <div className=\"grow basis-0\" />\n                            <SideBarFooter />\n                          </Fragment>\n                        </Sidebar>\n                        <div className=\"min-w-80 flex-1\">{children}</div>\n                      </div>\n                    </div>\n                  </TableProvider>\n                </BaseNodeProvider>\n              </BaseProvider>\n            </AnchorContext.Provider>\n          </SessionProvider>\n        </AppProvider>\n      </AppLayout>\n    </ShareContext.Provider>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport Head from 'next/head';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { AppLayout } from './AppLayout';\n\nexport const SharedBaseLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  return (\n    <AppLayout>\n      <Head>\n        <title>{t('space:sharedBase.title')}</title>\n      </Head>\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <NotificationProvider>\n            <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n              {children}\n            </div>\n          </NotificationProvider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport { LastVisitResourceType, updateUserLastVisit } from '@teable/openapi';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport { isString } from 'lodash';\nimport { useParams } from 'next/navigation';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment, useEffect } from 'react';\nimport { LicenseExpiryBanner } from '@/features/app/components/LicenseExpiryBanner';\nimport { AppLayout } from '@/features/app/layouts';\nimport { SpaceInnerSettingModal } from '@overridable/SpaceInnerSettingModal';\nimport { SpaceInnerSideBar } from '../blocks/space/space-side-bar/SpaceInnerSideBar';\nimport { SpaceQuickSearch } from '../blocks/space/space-side-bar/SpaceQuickSearch';\nimport { SpaceSwitcher } from '../blocks/space/space-side-bar/SpaceSwitcher';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { SpacePageTitle } from './SpacePageTitle';\n\nexport const SpaceInnerLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { spaceId } = useParams<{ spaceId: string }>();\n\n  useEffect(() => {\n    if (!spaceId || !isString(spaceId)) {\n      return;\n    }\n    updateUserLastVisit({\n      resourceType: LastVisitResourceType.Space,\n      resourceId: spaceId,\n      parentResourceId: '',\n    });\n  }, [spaceId]);\n\n  const renderSettingModal = (children: React.ReactNode) => {\n    return <SpaceInnerSettingModal>{children}</SpaceInnerSettingModal>;\n  };\n\n  return (\n    <AppLayout>\n      <SpacePageTitle dehydratedState={dehydratedState} />\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <NotificationProvider>\n            <LicenseExpiryBanner />\n            <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n              <Sidebar\n                headerLeft={<SpaceSwitcher />}\n                headerRight={<SpaceQuickSearch spaceId={spaceId} />}\n              >\n                <Fragment>\n                  <div className=\"flex flex-1 flex-col gap-1 divide-y divide-solid overflow-hidden\">\n                    <SpaceInnerSideBar renderSettingModal={renderSettingModal} />\n                  </div>\n                  <SideBarFooter />\n                </Fragment>\n              </Sidebar>\n              {children}\n            </div>\n          </NotificationProvider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SpaceLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment } from 'react';\nimport { LicenseExpiryBanner } from '@/features/app/components/LicenseExpiryBanner';\nimport { AppLayout } from '@/features/app/layouts';\nimport { SpaceSideBar } from '../blocks/space/space-side-bar/SpaceSideBar';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SidebarHeaderLeft } from '../components/sidebar/SidebarHeaderLeft';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { SpacePageTitle } from './SpacePageTitle';\n\nexport const SpaceLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n\n  return (\n    <AppLayout>\n      <SpacePageTitle dehydratedState={dehydratedState} />\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <NotificationProvider>\n            <LicenseExpiryBanner />\n            <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n              <Sidebar headerLeft={<SidebarHeaderLeft />}>\n                <Fragment>\n                  <div className=\"flex flex-1 flex-col gap-2 divide-y divide-solid overflow-hidden\">\n                    <SpaceSideBar isAdmin={user?.isAdmin} />\n                  </div>\n                  <SideBarFooter />\n                </Fragment>\n              </Sidebar>\n              {children}\n            </div>\n          </NotificationProvider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SpacePageTitle.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { find } from 'lodash';\nimport Head from 'next/head';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\n\nexport const SpacePageTitle = (props: { dehydratedState?: DehydratedState }) => {\n  const { dehydratedState } = props;\n  const { t } = useTranslation('space');\n  const router = useRouter();\n  const spaceId = router.query.spaceId as string;\n\n  const findSpaceName = () => {\n    const spaceData = find(dehydratedState?.queries || [], {\n      queryHash: JSON.stringify(ReactQueryKeys.space(spaceId)),\n    })?.state.data as IGetSpaceVo;\n    return spaceData?.name;\n  };\n  return (\n    <Head>\n      <title>{spaceId && dehydratedState ? findSpaceName() : t('allSpaces')}</title>\n    </Head>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SpaceSettingLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport { Component, Home, Users } from '@teable/icons';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, ReactQueryKeys, SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport { find } from 'lodash';\nimport Head from 'next/head';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment, useMemo } from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SidebarContent } from '../components/sidebar/SidebarContent';\nimport { SidebarHeaderLeft } from '../components/sidebar/SidebarHeaderLeft';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { AppLayout } from './AppLayout';\n\nexport const SpaceSettingLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n  const router = useRouter();\n  const spaceId = router.query.spaceId as string;\n  const space = find(dehydratedState?.queries || [], {\n    queryHash: JSON.stringify(ReactQueryKeys.space(spaceId)),\n  })?.state.data as IGetSpaceVo;\n\n  const onBack = () => {\n    if (!spaceId) return router.push({ pathname: '/space' });\n\n    router.push({\n      pathname: '/space/[spaceId]',\n      query: { spaceId },\n    });\n  };\n\n  const routes = useMemo(() => {\n    return [\n      {\n        Icon: Home,\n        label: t('space:spaceSetting.general'),\n        route: `/space/[spaceId]/setting/general`,\n        pathTo: `/space/${spaceId}/setting/general`,\n      },\n      {\n        Icon: Users,\n        label: t('space:spaceSetting.collaborators'),\n        route: `/space/[spaceId]/setting/collaborator`,\n        pathTo: `/space/${spaceId}/setting/collaborator`,\n      },\n    ];\n  }, [spaceId, t]);\n\n  return (\n    <AppLayout>\n      <Head>\n        <title>{spaceId && dehydratedState ? space.name : t('allSpaces')}</title>\n      </Head>\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <NotificationProvider>\n            <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n              <Sidebar\n                headerLeft={\n                  <SidebarHeaderLeft\n                    title={space.name}\n                    icon={<Component className=\"size-5 shrink-0\" />}\n                    onBack={onBack}\n                  />\n                }\n              >\n                <Fragment>\n                  <div className=\"flex flex-1 flex-col gap-2 divide-y divide-solid overflow-hidden\">\n                    <SidebarContent title={t('space:spaceSetting.title')} routes={routes} />\n                  </div>\n                  <SideBarFooter />\n                </Fragment>\n              </Sidebar>\n              {children}\n            </div>\n          </NotificationProvider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/SpaceTrashLayout.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { IUser } from '@teable/sdk';\nimport { NotificationProvider, SessionProvider } from '@teable/sdk';\nimport { AppProvider } from '@teable/sdk/context';\nimport Head from 'next/head';\nimport { useTranslation } from 'next-i18next';\nimport React from 'react';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { AppLayout } from './AppLayout';\n\nexport const SpaceTrashLayout: React.FC<{\n  children: React.ReactNode;\n  user?: IUser;\n  dehydratedState?: DehydratedState;\n}> = ({ children, user, dehydratedState }) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { t } = useTranslation(spaceConfig.i18nNamespaces);\n\n  return (\n    <AppLayout>\n      <Head>\n        <title>{t('common:trash.spaceTrash')}</title>\n      </Head>\n      <AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>\n        <SessionProvider user={user}>\n          <NotificationProvider>\n            <div id=\"portal\" className=\"relative flex h-screen w-full items-start\">\n              {children}\n            </div>\n          </NotificationProvider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/TemplateBaseLayout.tsx",
    "content": "import { type DehydratedState } from '@tanstack/react-query';\nimport { incrementTemplateVisit, type IGetBaseVo, type ITableVo } from '@teable/openapi';\nimport { SessionProvider, addQueryParamsToWebSocketUrl } from '@teable/sdk';\nimport type { IUser } from '@teable/sdk/context';\nimport { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context';\nimport { getWsPath } from '@teable/sdk/context/app/useConnection';\nimport { useTranslation } from 'next-i18next';\nimport React, { Fragment, useEffect, useMemo } from 'react';\nimport { AppLayout } from '@/features/app/layouts';\nimport { BaseNodeProvider } from '../blocks/base/base-node/BaseNodeProvider';\nimport { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar';\nimport { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft';\nimport { BasePermissionListener } from '../blocks/base/BasePermissionListener';\nimport { Sidebar } from '../components/sidebar/Sidebar';\nimport { SideBarFooter } from '../components/SideBarFooter';\nimport type { IBaseResourceTable } from '../hooks/useBaseResource';\nimport { useBaseResource } from '../hooks/useBaseResource';\nimport { useEnv } from '../hooks/useEnv';\nimport { useSdkLocale } from '../hooks/useSdkLocale';\nimport { initAxios } from '../utils/init-axios';\n\nexport const TemplateBaseLayout = ({\n  children,\n  base,\n  dehydratedState,\n  user,\n  tableServerData,\n  childrenContent,\n}: {\n  children: React.ReactNode;\n  tableServerData?: ITableVo[];\n  dehydratedState?: DehydratedState;\n  user?: IUser;\n  base?: IGetBaseVo;\n  childrenContent: React.ReactNode;\n}) => {\n  const { baseId, tableId, viewId } = useBaseResource() as IBaseResourceTable;\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n  const { maxSearchFieldCount } = useEnv();\n  const isTemplate = !!base?.template;\n  const templateHeader = base?.template?.headers;\n  initAxios({ base });\n\n  const wsPath = useMemo(() => {\n    if (typeof window === 'object' && templateHeader) {\n      return addQueryParamsToWebSocketUrl(getWsPath(), { templateHeader });\n    }\n    return undefined;\n  }, [templateHeader]);\n\n  useEffect(() => {\n    if (isTemplate && base?.template?.id) {\n      incrementTemplateVisit(base?.template?.id);\n    }\n  }, [isTemplate, base?.template?.id]);\n\n  if (!isTemplate) {\n    return children;\n  }\n\n  return (\n    <AppLayout>\n      <AppProvider\n        lang={i18n.language}\n        locale={sdkLocale}\n        dehydratedState={dehydratedState}\n        template={base?.template}\n        wsPath={wsPath}\n        maxSearchFieldCount={maxSearchFieldCount}\n      >\n        <SessionProvider user={user}>\n          <AnchorContext.Provider\n            value={{\n              baseId: baseId as string,\n              tableId: tableId as string,\n              viewId: viewId as string,\n            }}\n          >\n            <BaseProvider>\n              <BaseNodeProvider>\n                <BasePermissionListener />\n                <TableProvider serverData={tableServerData}>\n                  <div\n                    id=\"portal\"\n                    className=\"relative flex h-screen w-full items-start\"\n                    onContextMenu={(e) => e.preventDefault()}\n                  >\n                    <div className=\"flex h-screen w-full\">\n                      <Sidebar headerLeft={<BaseSidebarHeaderLeft />}>\n                        <Fragment>\n                          <div className=\"flex h-full flex-col gap-2 divide-y divide-solid overflow-auto py-2\">\n                            <BaseSideBar />\n                          </div>\n                          <div className=\"grow basis-0\" />\n                          <SideBarFooter />\n                        </Fragment>\n                      </Sidebar>\n                      <div className=\"min-w-80 flex-1\">{childrenContent}</div>\n                    </div>\n                  </div>\n                </TableProvider>\n              </BaseNodeProvider>\n            </BaseProvider>\n          </AnchorContext.Provider>\n        </SessionProvider>\n      </AppProvider>\n    </AppLayout>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/index.ts",
    "content": "export { AppLayout } from './AppLayout';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/layouts/useSettingRoute.tsx",
    "content": "import { Code2, Key, Link } from '@teable/icons';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport type { ISidebarContentRoute } from '../components/sidebar/SidebarContent';\n\nexport const useSettingRoute = (): ISidebarContentRoute[] => {\n  const { t } = useTranslation(['setting', 'common', 'developer']);\n  const router = useRouter();\n  const pathname = router.pathname;\n  const isDeveloperToolQueryBuilder = pathname.includes('developer/tool/query-builder');\n  return useMemo(() => {\n    if (isDeveloperToolQueryBuilder) {\n      return [\n        {\n          Icon: Code2,\n          label: t('developer:apiQueryBuilder'),\n          route: '/developer/tool/query-builder',\n          pathTo: '/developer/tool/query-builder',\n        },\n      ];\n    }\n\n    return [\n      {\n        Icon: Key,\n        label: t('setting:personalAccessToken'),\n        route: '/setting/personal-access-token',\n        pathTo: '/setting/personal-access-token',\n      },\n      {\n        Icon: Link,\n        label: (\n          <>\n            {t('setting:oauthApps')}\n            <span className=\"ml-1 h-5 rounded-sm border border-warning p-0.5 text-[11px] font-normal text-warning\">\n              {t('common:noun.beta')}\n            </span>\n          </>\n        ),\n        route: '/setting/oauth-app',\n        pathTo: '/setting/oauth-app',\n      },\n      // {\n      //   Icon: Code,\n      //   label: t('setting:plugins'),\n      //   route: '/setting/plugin',\n      //   pathTo: '/setting/plugin',\n      // },\n    ];\n  }, [t]);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/clipboard.spec.ts",
    "content": "import { vi } from 'vitest';\nimport {\n  CellValueType,\n  DbFieldType,\n  FieldType,\n} from '../../../../../../packages/core/src/models/field/constant';\nimport {\n  extractHtmlHeader,\n  isTeableHTML,\n  serializerHtml,\n  serializerCellValueHtml,\n  extractTableContent,\n  escapeHTML,\n} from './clipboard';\n\nconst stringData = 'John\\t20\\tlight\\nTom\\t30\\tmedium\\nBob\\t40\\theavy';\nconst parseData = [\n  ['John', '20', 'light'],\n  ['Tom', '30', 'medium'],\n  ['Bob', '40', 'heavy'],\n];\n\nvi.mock('@teable/core', () => {\n  return {\n    __esModule: true,\n    IFieldVo: {},\n    FieldType: FieldType,\n    parseClipboardText: () => parseData,\n    fieldVoSchema: {\n      safeParse: () => ({ success: true }),\n    },\n  };\n});\n\nvi.mock('zod', () => {\n  return {\n    z: {\n      array: () => ({\n        safeParse: (data: string) => ({ success: true, data }),\n      }),\n    },\n  };\n});\n\nvi.mock('@teable/sdk/model', () => {\n  return {\n    createFieldInstance: (header: { type: number }) => ({\n      type: header.type,\n      cellValue2String: (cell: unknown) => (cell == null ? '' : String(cell)),\n    }),\n  };\n});\n\ndescribe('clipboard', () => {\n  const html = `<meta charset=\"utf-8\"><table data-teable-html-marker=\"1\" data-teable-html-header=\"%5B%7B%22id%22%3A%22fldziUf9QuQjkbfMuG5%22%2C%22name%22%3A%22Name%22%2C%22isPrimary%22%3Atrue%2C%22columnMeta%22%3A%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A0.5%7D%7D%2C%22dbFieldName%22%3A%22Name_fldziUf9QuQjkbfMuG5%22%2C%22dbFieldType%22%3A%22TEXT%22%2C%22type%22%3A%22singleLineText%22%2C%22options%22%3A%7B%7D%2C%22cellValueType%22%3A%22string%22%7D%2C%7B%22id%22%3A%22fldpsQvHI4ugP2luizP%22%2C%22name%22%3A%22Count%22%2C%22columnMeta%22%3A%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A1%7D%7D%2C%22dbFieldName%22%3A%22Count_fldpsQvHI4ugP2luizP%22%2C%22dbFieldType%22%3A%22REAL%22%2C%22type%22%3A%22number%22%2C%22options%22%3A%7B%22formatting%22%3A%7B%22type%22%3A%22decimal%22%2C%22precision%22%3A0%7D%7D%2C%22cellValueType%22%3A%22number%22%7D%2C%7B%22id%22%3A%22fldGTKfZvXNXeMJ6nqu%22%2C%22name%22%3A%22Status%22%2C%22columnMeta%22%3A%7B%22viwE0sl0GqGdWaBqwFi%22%3A%7B%22order%22%3A2%7D%7D%2C%22dbFieldName%22%3A%22Status_fldGTKfZvXNXeMJ6nqu%22%2C%22dbFieldType%22%3A%22TEXT%22%2C%22options%22%3A%7B%22choices%22%3A%5B%7B%22name%22%3A%22light%22%2C%22id%22%3A%22cho2caYhPrI%22%2C%22color%22%3A%22grayBright%22%7D%2C%7B%22name%22%3A%22medium%22%2C%22id%22%3A%22chor2ob8aU7%22%2C%22color%22%3A%22yellowBright%22%7D%2C%7B%22name%22%3A%22heavy%22%2C%22id%22%3A%22choArPr57sO%22%2C%22color%22%3A%22tealBright%22%7D%5D%7D%2C%22type%22%3A%22singleSelect%22%2C%22cellValueType%22%3A%22string%22%7D%5D\"><tbody><tr><td>John</td><td>20</td><td>light</td></tr><tr><td>Tom</td><td>30</td><td>medium</td></tr><tr><td>Bob</td><td>40</td><td>heavy</td></tr></tbody></table>`;\n\n  const expectedHeader: any[] = [\n    {\n      id: 'fldziUf9QuQjkbfMuG5',\n      name: 'Name',\n      isPrimary: true,\n      columnMeta: {\n        viwE0sl0GqGdWaBqwFi: {\n          order: 0.5,\n        },\n      },\n      dbFieldName: 'Name_fldziUf9QuQjkbfMuG5',\n      dbFieldType: DbFieldType.Text,\n      type: FieldType.SingleLineText,\n      options: {},\n      cellValueType: CellValueType.String,\n    },\n    {\n      id: 'fldpsQvHI4ugP2luizP',\n      name: 'Count',\n      columnMeta: {\n        viwE0sl0GqGdWaBqwFi: {\n          order: 1,\n        },\n      },\n      dbFieldName: 'Count_fldpsQvHI4ugP2luizP',\n      dbFieldType: DbFieldType.Real,\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          type: 'decimal',\n          precision: 0,\n        },\n      },\n      cellValueType: CellValueType.Number,\n    },\n    {\n      id: 'fldGTKfZvXNXeMJ6nqu',\n      name: 'Status',\n      columnMeta: {\n        viwE0sl0GqGdWaBqwFi: {\n          order: 2,\n        },\n      },\n      dbFieldName: 'Status_fldGTKfZvXNXeMJ6nqu',\n      dbFieldType: DbFieldType.Text,\n      options: {\n        choices: [\n          {\n            name: 'light',\n            id: 'cho2caYhPrI',\n            color: 'grayBright',\n          },\n          {\n            name: 'medium',\n            id: 'chor2ob8aU7',\n            color: 'yellowBright',\n          },\n          {\n            name: 'heavy',\n            id: 'choArPr57sO',\n            color: 'tealBright',\n          },\n        ],\n      },\n      type: FieldType.SingleSelect,\n      cellValueType: CellValueType.String,\n    },\n  ];\n  it('extractTableHeader should extract table header from HTML', () => {\n    const { result } = extractHtmlHeader(html);\n    expect(result).toEqual(expectedHeader);\n  });\n\n  it('extractTableHeader should return undefined from non-teable HTML', () => {\n    const { result } = extractHtmlHeader('<table></table>');\n    expect(result).toEqual(undefined);\n  });\n\n  it('serializerHtml should serializer table from data and header of table', () => {\n    const result = serializerHtml(stringData, expectedHeader);\n    expect(result).toEqual(html);\n  });\n\n  describe('isTeableHtml', () => {\n    it('returns true for HTML with table tagged as teable', () => {\n      const html = `\n        <meta charset=\"utf-8\"><table data-teable-html-marker=\"true\">\n          <tr><td>Hello</td></tr>  \n        </table>\n      `;\n      expect(isTeableHTML(html)).toBe(true);\n    });\n\n    it('returns false for HTML without table', () => {\n      const html = `\n        <div>No Table</div>\n      `;\n      expect(isTeableHTML(html)).toBe(false);\n    });\n\n    it('returns false if table lacks marker attribute', () => {\n      const html = `\n        <meta charset=\"utf-8\"><table>\n          <tr><td>Hello</td></tr>\n        </table>\n      `;\n      expect(isTeableHTML(html)).toBe(false);\n    });\n\n    it('handles invalid HTML gracefully', () => {\n      const html = `<div>`;\n      expect(isTeableHTML(html)).toBe(false);\n    });\n  });\n\n  it('escapeHTML should escape special HTML characters', () => {\n    const input = '<div>&</div>\"\\'';\n    const output = escapeHTML(input);\n    expect(output).toBe('&lt;div&gt;&amp;&lt;/div&gt;\"\\'');\n  });\n\n  it('serializerCellValueHtml should serialize with safe HTML and data attributes, and be round-trippable', () => {\n    const headers = [\n      { id: 'h1', name: 'Long', type: FieldType.LongText },\n      { id: 'h2', name: 'Num', type: FieldType.Number },\n      { id: 'h3', name: 'Txt', type: FieldType.SingleLineText },\n    ];\n\n    const data: unknown[][] = [\n      ['line1\\nline2 & <tag>', 2, '<b>x</b>'],\n      [null, 3, 'normal'],\n    ];\n\n    const html = serializerCellValueHtml(data, headers as any);\n\n    // Contains teable marker and header attribute\n    expect(html).toContain('data-teable-html-marker=\"1\"');\n    expect(html).toContain('data-teable-html-header=\"');\n\n    // LongText should replace newlines with <br data-teable-line-tag>\n    expect(html).toMatch(/line1<br[^>]*data-teable-line-tag=\"1\"[^>]*>line2/);\n\n    // XSS should be escaped in cell innerHTML\n    expect(html).toContain('&lt;tag&gt;');\n    expect(html).toContain('&lt;b&gt;x&lt;/b&gt;');\n\n    // Round-trip extract back to original cell values\n    const parsed = extractTableContent(html);\n    expect(parsed).toEqual(data);\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/clipboard.ts",
    "content": "import { FieldType, fieldVoSchema, parseClipboardText, type IFieldVo } from '@teable/core';\nimport { createFieldInstance } from '@teable/sdk/model';\nimport { z } from 'zod';\nimport { fromZodError } from 'zod-validation-error';\n\nconst teableHtmlMarker = 'data-teable-html-marker';\nconst teableHeader = 'data-teable-html-header';\n\nconst lineTag = '<br data-teable-line-tag=\"1\" style=\"mso-data-placement:same-cell;\">';\n\nexport const escapeHTML = (str: string) => {\n  const p = document.createElement('p');\n  p.appendChild(document.createTextNode(str));\n  return p.innerHTML;\n};\n\nexport const serializerHtml = (data: string, headers: IFieldVo[]) => {\n  const tableData = parseClipboardText(data);\n  const bodyContent = tableData\n    .map((row) => {\n      return `<tr>${row\n        .map((cell, index) => {\n          const header = headers[index];\n          if (header.type === FieldType.LongText) {\n            return `<td>${cell.replaceAll('\\n', lineTag)}</td>`;\n          }\n          return `<td>${cell}</td>`;\n        })\n        .join('')}</tr>`;\n    })\n    .join('');\n\n  return `<meta charset=\"utf-8\"><table ${teableHtmlMarker}=\"1\" ${teableHeader}=\"${encodeURIComponent(JSON.stringify(headers))}\"><tbody>${bodyContent}</tbody></table>`;\n};\n\nexport const serializerCellValueHtml = (data: unknown[][], headers: IFieldVo[]) => {\n  const fields = headers.map((header) => createFieldInstance(header));\n  const bodyContent = data\n    .map((row) => {\n      return `<tr>${row\n        .map((cell, index) => {\n          const field = fields[index];\n          const safeHtml = escapeHTML(field.cellValue2String(cell));\n          if (field.type === FieldType.LongText) {\n            return `<td data-teable-cell-value=\"${encodeURIComponent(JSON.stringify(cell == null ? null : cell))}\">${safeHtml.replaceAll('\\n', lineTag)}</td>`;\n          }\n          return `<td data-teable-cell-value=\"${encodeURIComponent(JSON.stringify(cell == null ? null : cell))}\">${safeHtml}</td>`;\n        })\n        .join('')}</tr>`;\n    })\n    .join('');\n\n  return `<meta charset=\"utf-8\"><table ${teableHtmlMarker}=\"1\" ${teableHeader}=\"${encodeURIComponent(JSON.stringify(headers))}\"><tbody>${bodyContent}</tbody></table>`;\n};\n\nexport const extractHtmlHeader = (html?: string) => {\n  if (!html || !isTeableHTML(html)) {\n    return { result: undefined };\n  }\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(html, 'text/html');\n  const table = doc.querySelector('table');\n  const headerStr = table?.getAttribute(teableHeader);\n  const headers = headerStr ? JSON.parse(decodeURIComponent(headerStr)) : undefined;\n  if (!headers) {\n    return { result: undefined };\n  }\n  const validate = z.array(fieldVoSchema).safeParse(headers);\n  if (!validate.success) {\n    return { result: undefined, error: fromZodError(validate.error).message };\n  }\n  return { result: validate.data };\n};\n\nexport const isTeableHTML = (html: string) => {\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(html, 'text/html');\n  const table = doc.querySelector('table');\n  return Boolean(table?.getAttribute(teableHtmlMarker));\n};\n\nexport const extractTableContent = (html: string) => {\n  if (!html || !isTeableHTML(html)) {\n    return undefined;\n  }\n\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(html, 'text/html');\n  const table = doc.querySelector('table');\n\n  if (!table) {\n    return undefined;\n  }\n\n  const rows = table.querySelectorAll('tr');\n  const content: unknown[][] = [];\n\n  rows.forEach((row) => {\n    const rowData: unknown[] = [];\n    const cells = row.querySelectorAll('td');\n    cells.forEach((cell) => {\n      const cellText = cell.textContent || '';\n      const cellValue = cell.getAttribute('data-teable-cell-value');\n      if (!cellValue) {\n        rowData.push(cellText);\n        return;\n      }\n\n      const cellValueObj = JSON.parse(decodeURIComponent(cellValue));\n      rowData.push(cellValueObj);\n    });\n\n    if (rowData.length > 0) {\n      content.push(rowData);\n    }\n  });\n  return content;\n};\n\nexport const parseNormalHtml = (html: string) => {\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(html, 'text/html');\n  const table = doc.querySelector('table');\n  if (!table) {\n    return [[doc.body.textContent || '']];\n  }\n  const rows = Array.from(table.rows);\n  return rows.map((row) => {\n    const cells = Array.from(row.cells);\n    return cells.map((cell) => cell.textContent || '');\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/download-all-attachments.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\nimport type { IAttachmentCellValue, IFieldVo, IFilter } from '@teable/core';\nimport { FieldKeyType, FieldType, mergeFilter } from '@teable/core';\nimport type { IGetRecordsRo } from '@teable/openapi';\nimport {\n  getRecords,\n  getRowCount,\n  getShareViewRecords,\n  getShareViewRowCount,\n} from '@teable/openapi';\nimport type { IFieldInstance } from '@teable/sdk';\n\n/**\n * Check if should disable cache for this mimetype to avoid CORS issues.\n * Media types (image/video/audio) may be cached by browser via native tags without CORS headers.\n * Returns true (disable cache) if mimetype is media type or invalid.\n */\nconst shouldDisableCache = (mimetype: string | undefined, fileName: string): boolean => {\n  if (!mimetype || typeof mimetype !== 'string') {\n    console.error(`Invalid mimetype for: ${fileName}, using no-store fallback`);\n    return true;\n  }\n  return (\n    mimetype.startsWith('image/') || mimetype.startsWith('video/') || mimetype.startsWith('audio/')\n  );\n};\n\nexport interface IDownloadProgress {\n  downloaded: number;\n  total: number;\n  currentFileName: string;\n  percent: number;\n}\n\nexport interface IDownloadAllAttachmentsOptions {\n  tableId: string;\n  fieldId: string;\n  fieldName: string;\n  viewId?: string;\n  shareId?: string;\n  personalViewCommonQuery?: IGetRecordsRo;\n  namingField?: IFieldInstance;\n  groupByRow?: boolean;\n  onProgress?: (progress: IDownloadProgress) => void;\n  abortController?: AbortController;\n}\n\n/**\n * Field types suitable for naming files\n * These are fields that can produce meaningful text values for file names\n */\nconst NAMING_SUITABLE_FIELD_TYPES: FieldType[] = [\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Number,\n  FieldType.AutoNumber,\n  FieldType.SingleSelect,\n  FieldType.Date,\n  FieldType.Formula,\n  FieldType.Rollup,\n];\n\n/**\n * Check if a field is suitable for naming files\n */\nexport function isFieldSuitableForNaming(field: IFieldVo): boolean {\n  return NAMING_SUITABLE_FIELD_TYPES.includes(field.type as FieldType);\n}\n\nexport interface IDownloadCellAttachmentsOptions {\n  attachments: IAttachmentCellValue;\n  zipFileName?: string;\n  onProgress?: (progress: IDownloadProgress) => void;\n  abortController?: AbortController;\n}\n\nexport interface IDownloadResult {\n  success: boolean;\n  totalFiles: number;\n  failedFiles: string[];\n  cancelled?: boolean;\n}\n\nexport interface IAttachmentPreview {\n  rowsWithAttachments: number;\n  totalAttachments: number;\n  totalSize: number;\n}\n\ninterface IAttachmentWithRowIndex {\n  rowIndex: number;\n  attachmentIndex: number;\n  attachment: IAttachmentCellValue[number];\n  namingValue?: string;\n  rowAttachmentCount: number; // Total attachments in this row (for groupByRow feature)\n}\n\nconst PAGE_SIZE = 100;\nconst DOWNLOAD_CANCELLED_MESSAGE = 'Download cancelled';\n\n/**\n * Format bytes to human readable string\n */\nexport function formatFileSize(bytes: number): string {\n  if (bytes === 0) return '0 B';\n  const k = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n}\n\n/**\n * Create a filter that combines personal view filter with non-empty attachment filter\n */\nfunction createAttachmentFilter(fieldId: string, existingFilter?: IFilter): IFilter {\n  // Non-empty filter for attachment field\n  const nonEmptyFilter: IFilter = {\n    conjunction: 'and',\n    filterSet: [\n      {\n        fieldId,\n        operator: 'isNotEmpty',\n        value: null,\n      },\n    ],\n  };\n\n  // Merge with existing filter if provided\n  return mergeFilter(existingFilter, nonEmptyFilter, 'and') ?? nonEmptyFilter;\n}\n\n/**\n * Sanitize string for use as filename\n * Remove or replace characters that are not allowed in file names\n */\nfunction sanitizeForFilename(str: string): string {\n  // Replace characters not allowed in filenames with underscore\n  return str\n    .replace(/[<>:\"/\\\\|?*]/g, '_') // Replace illegal characters\n    .replace(/\\s+/g, '_') // Replace whitespace with underscore\n    .replace(/_+/g, '_') // Collapse multiple underscores\n    .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores\n    .slice(0, 100); // Limit length to 100 characters\n}\n\n/**\n * Load all attachments from records with pagination\n */\nasync function loadAllAttachments(\n  tableId: string,\n  fieldId: string,\n  viewId?: string,\n  shareId?: string,\n  personalViewCommonQuery?: IGetRecordsRo,\n  abortSignal?: AbortSignal,\n  namingField?: IFieldInstance\n): Promise<{\n  attachments: IAttachmentWithRowIndex[];\n  rowsWithAttachments: number;\n  totalAttachments: number;\n  totalSize: number;\n}> {\n  const { ignoreViewQuery, filter, orderBy, groupBy } = personalViewCommonQuery ?? {};\n\n  // 1. Create filter with non-empty attachment condition\n  const attachmentFilter = createAttachmentFilter(fieldId, filter as IFilter | undefined);\n\n  // 2. Get total row count with the filter (use share view API if shareId is provided)\n  const rowCountData = shareId\n    ? (await getShareViewRowCount(shareId, { filter: attachmentFilter })).data\n    : (\n        await getRowCount(tableId, {\n          viewId,\n          ...(ignoreViewQuery ? { ignoreViewQuery } : {}),\n          filter: attachmentFilter,\n        })\n      ).data;\n\n  const totalRows = rowCountData.rowCount;\n\n  if (totalRows === 0) {\n    return {\n      attachments: [],\n      rowsWithAttachments: 0,\n      totalAttachments: 0,\n      totalSize: 0,\n    };\n  }\n\n  // 3. Build projection - include naming field if specified\n  const projection = namingField ? [fieldId, namingField.id] : [fieldId];\n\n  // 4. Load all records with pagination\n  const attachments: IAttachmentWithRowIndex[] = [];\n  let rowsWithAttachments = 0;\n  let totalAttachments = 0;\n  let totalSize = 0;\n  let rowIndex = 1;\n\n  for (let skip = 0; skip < totalRows; skip += PAGE_SIZE) {\n    if (abortSignal?.aborted) {\n      throw new DOMException(DOWNLOAD_CANCELLED_MESSAGE, 'AbortError');\n    }\n\n    const query: IGetRecordsRo = {\n      viewId,\n      take: PAGE_SIZE,\n      skip,\n      fieldKeyType: FieldKeyType.Id,\n      projection,\n      filter: attachmentFilter,\n      ...(ignoreViewQuery ? { ignoreViewQuery } : {}),\n      ...(orderBy ? { orderBy } : {}),\n      ...(groupBy ? { groupBy } : {}),\n    };\n\n    // Use share view API if shareId is provided\n    const { data } = shareId\n      ? await getShareViewRecords(shareId, query)\n      : await getRecords(tableId, query);\n    const records = data.records;\n\n    if (!records?.length) break;\n\n    for (const record of records) {\n      const cellValue = record.fields[fieldId] as IAttachmentCellValue | undefined;\n      if (cellValue && Array.isArray(cellValue) && cellValue.length > 0) {\n        // Filter attachments with valid presignedUrl (non-empty string)\n        const downloadableAttachments = cellValue.filter(\n          (a) => a.presignedUrl && typeof a.presignedUrl === 'string' && a.presignedUrl.trim()\n        );\n        if (downloadableAttachments.length > 0) {\n          rowsWithAttachments++;\n          totalAttachments += downloadableAttachments.length;\n          totalSize += downloadableAttachments.reduce((sum, a) => sum + (a.size || 0), 0);\n\n          // Get naming value using field's cellValue2String method\n          let namingValue: string | undefined;\n          if (namingField) {\n            const namingCellValue = record.fields[namingField.id];\n            const rawValue = namingField.cellValue2String(namingCellValue);\n            namingValue = rawValue ? sanitizeForFilename(rawValue) : undefined;\n          }\n\n          const rowAttachmentCount = downloadableAttachments.length;\n          downloadableAttachments.forEach((attachment, attachmentIndex) => {\n            attachments.push({\n              rowIndex,\n              attachmentIndex,\n              attachment,\n              namingValue,\n              rowAttachmentCount,\n            });\n          });\n        }\n      }\n      rowIndex++;\n    }\n  }\n\n  return {\n    attachments,\n    rowsWithAttachments,\n    totalAttachments,\n    totalSize,\n  };\n}\n\n/**\n * Get preview info for download dialog (row count, attachment count, total size)\n */\nexport async function getAttachmentPreview(\n  tableId: string,\n  fieldId: string,\n  viewId?: string,\n  shareId?: string,\n  personalViewCommonQuery?: IGetRecordsRo\n): Promise<IAttachmentPreview> {\n  const { rowsWithAttachments, totalAttachments, totalSize } = await loadAllAttachments(\n    tableId,\n    fieldId,\n    viewId,\n    shareId,\n    personalViewCommonQuery\n  );\n\n  return { rowsWithAttachments, totalAttachments, totalSize };\n}\n\n/**\n * Get padded row number based on total records\n */\nfunction getPaddedRowNumber(rowIndex: number, totalRows: number): string {\n  const digits = Math.max(3, String(totalRows).length);\n  return String(rowIndex).padStart(digits, '0');\n}\n\n/**\n * Generate folder name for groupByRow feature\n */\nfunction generateFolderName(\n  rowIndex: number,\n  totalRows: number,\n  namingValue?: string,\n  isNamingValueDuplicated?: boolean\n): string {\n  if (namingValue) {\n    if (isNamingValueDuplicated) {\n      return `${namingValue}_${getPaddedRowNumber(rowIndex, totalRows)}`;\n    }\n    return namingValue;\n  }\n  return getPaddedRowNumber(rowIndex, totalRows);\n}\n\n/**\n * Generate unique filename for attachment within zip\n * When namingValue is provided and is unique, use simple format: namingValue_fileName\n * When namingValue is duplicated, add row number: namingValue_rowNumber_fileName\n * When no namingValue, use row number as prefix\n *\n * When groupByRow is enabled and row has multiple attachments:\n * - Put files in a folder named after the row\n * - Use original filename (with index suffix if duplicated)\n */\nfunction generateZipFileName(\n  rowIndex: number,\n  attachmentIndex: number,\n  fileName: string,\n  totalRows: number,\n  rowAttachmentCount: number,\n  namingValue?: string,\n  isNamingValueDuplicated?: boolean,\n  groupByRow?: boolean\n): string {\n  const hasMultipleInRow = rowAttachmentCount > 1;\n\n  // When groupByRow is enabled and row has multiple attachments, use folder structure\n  if (groupByRow && hasMultipleInRow) {\n    const folderName = generateFolderName(\n      rowIndex,\n      totalRows,\n      namingValue,\n      isNamingValueDuplicated\n    );\n    // Use original filename, add index suffix to avoid duplicates within same row\n    return `${folderName}/${attachmentIndex + 1}_${fileName}`;\n  }\n\n  // Original flat structure\n  let prefix: string;\n\n  if (namingValue) {\n    // Has naming value: add row number only if duplicated\n    if (isNamingValueDuplicated) {\n      prefix = `${namingValue}_${getPaddedRowNumber(rowIndex, totalRows)}`;\n    } else {\n      prefix = namingValue;\n    }\n  } else {\n    // No naming value: use row number\n    prefix = getPaddedRowNumber(rowIndex, totalRows);\n  }\n\n  if (hasMultipleInRow) {\n    return `${prefix}_${attachmentIndex + 1}_${fileName}`;\n  }\n  return `${prefix}_${fileName}`;\n}\n\n/**\n * Download all attachments and compress into a zip file\n * Uses streaming to avoid memory issues with large files\n */\nexport async function downloadAllAttachments(\n  options: IDownloadAllAttachmentsOptions\n): Promise<IDownloadResult> {\n  const {\n    tableId,\n    fieldId,\n    fieldName,\n    viewId,\n    shareId,\n    personalViewCommonQuery,\n    namingField,\n    groupByRow,\n    onProgress,\n    abortController,\n  } = options;\n\n  const abortSignal = abortController?.signal;\n  const failedFiles: string[] = [];\n\n  try {\n    // 1. Load all attachments\n    const { attachments: attachmentList, totalSize } = await loadAllAttachments(\n      tableId,\n      fieldId,\n      viewId,\n      shareId,\n      personalViewCommonQuery,\n      abortSignal,\n      namingField\n    );\n\n    if (attachmentList.length === 0) {\n      return { success: true, totalFiles: 0, failedFiles: [] };\n    }\n\n    // 2. Get max row index for padding\n    const maxRowIndex = Math.max(...attachmentList.map((a) => a.rowIndex));\n\n    // 3. Count naming values to detect duplicates (only count unique rows per naming value)\n    const namingValueRowCount = new Map<string, Set<number>>();\n    attachmentList.forEach(({ rowIndex, namingValue }) => {\n      if (namingValue) {\n        if (!namingValueRowCount.has(namingValue)) {\n          namingValueRowCount.set(namingValue, new Set());\n        }\n        namingValueRowCount.get(namingValue)!.add(rowIndex);\n      }\n    });\n    // A naming value is duplicated if it appears in more than one row\n    const duplicatedNamingValues = new Set<string>();\n    namingValueRowCount.forEach((rows, namingValue) => {\n      if (rows.size > 1) {\n        duplicatedNamingValues.add(namingValue);\n      }\n    });\n\n    // 4. Dynamic import streaming libraries (not loaded until needed)\n    const [{ Zip, ZipPassThrough }, streamSaverModule] = await Promise.all([\n      import('fflate'),\n      import('streamsaver'),\n    ]);\n    // streamsaver uses CommonJS, access default export\n    const streamSaver = streamSaverModule.default ?? streamSaverModule;\n\n    // Configure StreamSaver to use local service worker\n    if (typeof window !== 'undefined') {\n      streamSaver.mitm = `${window.location.origin}/streamsaver/mitm.html?version=2.0.0`;\n    }\n\n    // 5. Create file write stream\n    const zipFileName = `${fieldName}_attachments.zip`;\n    const fileStream = streamSaver.createWriteStream(zipFileName);\n    const writer = fileStream.getWriter();\n\n    let downloadedBytes = 0;\n    let processedFiles = 0;\n\n    // 6. Create zip stream\n    const zip = new Zip((err, chunk, final) => {\n      if (err) {\n        writer.abort();\n        throw err;\n      }\n      writer.write(chunk);\n      if (final) {\n        writer.close();\n      }\n    });\n\n    // 7. Process each attachment\n    for (const {\n      rowIndex,\n      attachmentIndex,\n      attachment,\n      namingValue,\n      rowAttachmentCount: attachmentCountInRow,\n    } of attachmentList) {\n      if (abortSignal?.aborted) {\n        zip.end();\n        throw new DOMException(DOWNLOAD_CANCELLED_MESSAGE, 'AbortError');\n      }\n\n      const isNamingValueDuplicated = namingValue ? duplicatedNamingValues.has(namingValue) : false;\n      const fileName = generateZipFileName(\n        rowIndex,\n        attachmentIndex,\n        attachment.name,\n        maxRowIndex,\n        attachmentCountInRow,\n        namingValue,\n        isNamingValueDuplicated,\n        groupByRow\n      );\n\n      // Skip attachments without valid presignedUrl\n      if (!attachment.presignedUrl) {\n        failedFiles.push(attachment.name);\n        continue;\n      }\n\n      // Update progress with current file name\n      onProgress?.({\n        downloaded: downloadedBytes,\n        total: totalSize,\n        currentFileName: attachment.name,\n        percent: totalSize > 0 ? Math.round((downloadedBytes / totalSize) * 100) : 0,\n      });\n\n      // Create a passthrough for this file (no compression for speed)\n      const file = new ZipPassThrough(fileName);\n      zip.add(file);\n\n      try {\n        const disableCache = shouldDisableCache(attachment.mimetype, attachment.name);\n        const response = await fetch(attachment.presignedUrl, {\n          signal: abortSignal,\n          ...(disableCache && { cache: 'no-store' }),\n        });\n\n        if (!response.ok) {\n          failedFiles.push(attachment.name);\n          file.push(new Uint8Array(0), true);\n          continue;\n        }\n\n        const reader = response.body?.getReader();\n        if (!reader) {\n          failedFiles.push(attachment.name);\n          file.push(new Uint8Array(0), true);\n          continue;\n        }\n\n        // Stream the file content\n        // eslint-disable-next-line no-constant-condition\n        while (true) {\n          const { done, value } = await reader.read();\n          if (done) {\n            file.push(new Uint8Array(0), true);\n            break;\n          }\n          file.push(value);\n          downloadedBytes += value.length;\n\n          // Update progress\n          onProgress?.({\n            downloaded: downloadedBytes,\n            total: totalSize,\n            currentFileName: attachment.name,\n            percent: totalSize > 0 ? Math.round((downloadedBytes / totalSize) * 100) : 0,\n          });\n        }\n\n        processedFiles++;\n      } catch (error) {\n        // Always close the file entry in case of error\n        file.push(new Uint8Array(0), true);\n\n        if ((error as Error).name === 'AbortError') {\n          throw error;\n        }\n        console.error(`Fetch error for: ${attachment.name}`, error);\n        failedFiles.push(attachment.name);\n      }\n    }\n\n    // Finalize zip\n    zip.end();\n\n    return {\n      success: failedFiles.length === 0,\n      totalFiles: processedFiles,\n      failedFiles,\n    };\n  } catch (error) {\n    if ((error as Error).name === 'AbortError') {\n      return {\n        success: false,\n        totalFiles: 0,\n        failedFiles,\n        cancelled: true,\n      };\n    }\n    throw error;\n  }\n}\n\n/**\n * Check if streaming download is available (requires HTTPS or localhost)\n */\nexport function isStreamingDownloadAvailable(): boolean {\n  if (typeof window === 'undefined') return false;\n  return !!navigator.serviceWorker;\n}\n\n/**\n * Download a single attachment directly\n */\nexport function downloadSingleAttachment(\n  attachment: IAttachmentCellValue[number],\n  isMobile: boolean = false\n): void {\n  if (!attachment.presignedUrl) return;\n\n  const downloadLink = document.createElement('a');\n  downloadLink.href = attachment.presignedUrl;\n  downloadLink.target = isMobile ? '_self' : '_blank';\n  downloadLink.download = attachment.name;\n  downloadLink.click();\n}\n\n/**\n * Generate unique filename handling duplicates\n */\nfunction generateUniqueFileName(fileName: string, filenameCount: Map<string, number>): string {\n  const count = filenameCount.get(fileName) || 0;\n  filenameCount.set(fileName, count + 1);\n\n  if (count === 0) {\n    return fileName;\n  }\n\n  const lastDotIndex = fileName.lastIndexOf('.');\n  if (lastDotIndex > 0) {\n    return `${fileName.slice(0, lastDotIndex)}_${count}${fileName.slice(lastDotIndex)}`;\n  }\n  return `${fileName}_${count}`;\n}\n\n/**\n * Stream attachments into a zip file\n * Shared logic for both column and cell downloads\n */\nasync function streamAttachmentsToZip(\n  attachments: Array<{\n    fileName: string;\n    url: string;\n    originalName: string;\n    size: number;\n    mimetype: string;\n  }>,\n  zipFileName: string,\n  totalSize: number,\n  onProgress?: (progress: IDownloadProgress) => void,\n  abortSignal?: AbortSignal\n): Promise<IDownloadResult> {\n  const failedFiles: string[] = [];\n\n  // Dynamic import streaming libraries\n  const [{ Zip, ZipPassThrough }, streamSaverModule] = await Promise.all([\n    import('fflate'),\n    import('streamsaver'),\n  ]);\n  const streamSaver = streamSaverModule.default ?? streamSaverModule;\n\n  // Configure StreamSaver to use local service worker\n  if (typeof window !== 'undefined') {\n    streamSaver.mitm = `${window.location.origin}/streamsaver/mitm.html?version=2.0.0`;\n  }\n\n  // Create file write stream\n  const fileStream = streamSaver.createWriteStream(zipFileName);\n  const writer = fileStream.getWriter();\n\n  let downloadedBytes = 0;\n  let processedFiles = 0;\n\n  // Create zip stream\n  const zip = new Zip((err: Error | null, chunk: Uint8Array, final: boolean) => {\n    if (err) {\n      writer.abort();\n      throw err;\n    }\n    writer.write(chunk);\n    if (final) {\n      writer.close();\n    }\n  });\n\n  // Process each attachment\n  for (const { fileName, url, originalName, mimetype } of attachments) {\n    if (abortSignal?.aborted) {\n      zip.end();\n      throw new DOMException(DOWNLOAD_CANCELLED_MESSAGE, 'AbortError');\n    }\n\n    // Update progress\n    onProgress?.({\n      downloaded: downloadedBytes,\n      total: totalSize,\n      currentFileName: originalName,\n      percent: totalSize > 0 ? Math.round((downloadedBytes / totalSize) * 100) : 0,\n    });\n\n    const file = new ZipPassThrough(fileName);\n    zip.add(file);\n\n    try {\n      const disableCache = shouldDisableCache(mimetype, originalName);\n      const response = await fetch(url, {\n        signal: abortSignal,\n        ...(disableCache && { cache: 'no-store' }),\n      });\n\n      if (!response.ok) {\n        failedFiles.push(originalName);\n        file.push(new Uint8Array(0), true);\n        continue;\n      }\n\n      const reader = response.body?.getReader();\n      if (!reader) {\n        failedFiles.push(originalName);\n        file.push(new Uint8Array(0), true);\n        continue;\n      }\n\n      // eslint-disable-next-line no-constant-condition\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) {\n          file.push(new Uint8Array(0), true);\n          break;\n        }\n        file.push(value);\n        downloadedBytes += value.length;\n\n        onProgress?.({\n          downloaded: downloadedBytes,\n          total: totalSize,\n          currentFileName: originalName,\n          percent: totalSize > 0 ? Math.round((downloadedBytes / totalSize) * 100) : 0,\n        });\n      }\n\n      processedFiles++;\n    } catch (error) {\n      file.push(new Uint8Array(0), true);\n\n      if ((error as Error).name === 'AbortError') {\n        throw error;\n      }\n      console.error(`Fetch error for: ${originalName}`, error);\n      failedFiles.push(originalName);\n    }\n  }\n\n  zip.end();\n\n  return {\n    success: failedFiles.length === 0,\n    totalFiles: processedFiles,\n    failedFiles,\n  };\n}\n\n/**\n * Download cell attachments as a zip file\n * For single cell download in expand record view\n */\nexport async function downloadCellAttachments(\n  options: IDownloadCellAttachmentsOptions\n): Promise<IDownloadResult> {\n  const { attachments, zipFileName = 'attachments.zip', onProgress, abortController } = options;\n\n  const abortSignal = abortController?.signal;\n\n  // Filter valid attachments\n  const validAttachments = attachments.filter(\n    (a) => a.presignedUrl && typeof a.presignedUrl === 'string' && a.presignedUrl.trim()\n  );\n\n  if (validAttachments.length === 0) {\n    return { success: true, totalFiles: 0, failedFiles: [] };\n  }\n\n  const totalSize = validAttachments.reduce((sum, a) => sum + (a.size || 0), 0);\n  const filenameCount = new Map<string, number>();\n\n  // Prepare attachment list with unique filenames\n  const attachmentList = validAttachments.map((a) => ({\n    fileName: generateUniqueFileName(a.name, filenameCount),\n    url: a.presignedUrl!,\n    originalName: a.name,\n    size: a.size || 0,\n    mimetype: a.mimetype,\n  }));\n\n  try {\n    return await streamAttachmentsToZip(\n      attachmentList,\n      zipFileName,\n      totalSize,\n      onProgress,\n      abortSignal\n    );\n  } catch (error) {\n    if ((error as Error).name === 'AbortError') {\n      return {\n        success: false,\n        totalFiles: 0,\n        failedFiles: [],\n        cancelled: true,\n      };\n    }\n    throw error;\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/file.ts",
    "content": "import UnknownFileIcon from '@teable/ui-lib/icons/app/unknown-file.svg';\nimport { renderToString } from 'react-dom/server';\n\nexport const getFileCover = (mimetype: string, url: string) => {\n  if (mimetype.startsWith('image/')) {\n    return url;\n  }\n  return 'data:image/svg+xml,' + encodeURIComponent(renderToString(UnknownFileIcon({})));\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/get-mod-key-str.ts",
    "content": "import { useEffect, useState } from 'react';\n\nexport function useModKeyStr() {\n  const [modKeyStr, setModKeyStr] = useState('');\n  useEffect(() => {\n    if (typeof navigator !== 'undefined') {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const platform = navigator.platform || (navigator as any).userAgentData?.platform || '';\n      const key = /^Mac/i.test(platform) ? '⌘' : 'Ctrl';\n      setModKeyStr(key);\n    }\n  }, []);\n  return modKeyStr;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/index.ts",
    "content": "export * from './file';\nexport * from './is-https';\nexport * from './is-local';\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/init-axios.ts",
    "content": "import type { IGetBaseVo } from '@teable/openapi';\nimport { axios, IS_TEMPLATE_HEADER, X_CANARY_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi';\n\ninterface IInitAxiosOptions {\n  base?: IGetBaseVo;\n  shareId?: string;\n}\n\n/**\n * Mutable config object that the single interceptor reads from.\n * Replaced entirely on each `initAxios` call to avoid stale headers\n * leaking across page navigations.\n */\nlet currentOptions: IInitAxiosOptions = {};\nlet interceptorRegistered = false;\n\n/**\n * Initialize axios request interceptors for page-specific headers.\n *\n * - Registers a single interceptor on first call.\n * - On subsequent calls, only updates the config (no extra interceptors).\n * - The interceptor reads `currentOptions` dynamically, so updating\n *   the config is immediately effective for all future requests.\n */\nexport const initAxios = (options: IInitAxiosOptions = {}) => {\n  if (typeof window === 'undefined') return;\n\n  // Replace config entirely — prevents stale headers from previous pages\n  currentOptions = options;\n\n  if (interceptorRegistered) return;\n\n  axios.interceptors.request.use((config) => {\n    const { base, shareId } = currentOptions;\n\n    // Template preview\n    if (base?.template?.headers) {\n      config.headers[IS_TEMPLATE_HEADER] = base.template.headers;\n    }\n\n    // Canary version\n    if (base?.isCanary) {\n      config.headers[X_CANARY_HEADER] = 'true';\n    }\n\n    // Base share page\n    if (shareId) {\n      config.headers[BASE_SHARE_ID_HEADER] = shareId;\n    }\n\n    return config;\n  });\n\n  interceptorRegistered = true;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/is-https.ts",
    "content": "export function isHTTPS() {\n  const protocol = window.location.protocol;\n\n  return protocol === 'https:';\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/is-local.ts",
    "content": "export function isLocalhost() {\n  const hostname = window.location.hostname;\n\n  return hostname === 'localhost' || hostname === '127.0.0.1';\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/utils/uploadFile.ts",
    "content": "import { generateAttachmentId } from '@teable/core';\nimport { UploadType, type INotifyVo } from '@teable/openapi';\nimport type { IFile } from '@teable/sdk';\nimport { AttachmentManager } from '@teable/sdk';\n\nexport const uploadFiles = async (\n  files: FileList | File[],\n  type = UploadType.Table,\n  baseId?: string\n): Promise<(INotifyVo & { id: string; name: string })[]> => {\n  return new Promise((resolve, reject) => {\n    const attachmentManager = new AttachmentManager(2);\n    const attachments: (INotifyVo & { id: string; name: string })[] = [];\n    const fileArray = Array.isArray(files) ? files : Array.from(files);\n    attachmentManager.upload(\n      fileArray.map((file) => ({ instance: file, id: generateAttachmentId() })),\n      type,\n      {\n        successCallback: (file: IFile, attachment: INotifyVo) => {\n          const { instance, id } = file;\n          const newAttachment = {\n            id,\n            name: instance.name,\n            ...attachment,\n          };\n          attachments.push(newAttachment);\n          if (attachments.length === files.length) {\n            resolve(attachments);\n          }\n        },\n        errorCallback: (_file, error?: string) => {\n          reject(error);\n        },\n      },\n      baseId\n    );\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/app/waitlist/WaitlistPage.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useMutation, useQuery } from '@tanstack/react-query';\nimport type { IJoinWaitlistRo } from '@teable/openapi';\nimport { getPublicSetting, joinWaitlist, joinWaitlistSchemaRo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport {\n  Button,\n  Input,\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormMessage,\n} from '@teable/ui-lib';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport React, { useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { NotFoundPage } from '@/features/system/pages/NotFoundPage';\nimport { useIsCloud } from '../hooks/useIsCloud';\n\nconst WaitlistPageInner = () => {\n  const { t } = useTranslation('common');\n  const [isSubmitted, setIsSubmitted] = useState(false);\n  const router = useRouter();\n\n  const { mutate: joinWaitlistMutation, isPending: isLoading } = useMutation({\n    mutationFn: (ro: IJoinWaitlistRo) => joinWaitlist(ro),\n    onSuccess: () => {\n      setIsSubmitted(true);\n    },\n  });\n\n  const form = useForm<IJoinWaitlistRo>({\n    resolver: zodResolver(joinWaitlistSchemaRo),\n    defaultValues: {\n      email: router.query.email as string,\n    },\n  });\n\n  const onSubmit = () => {\n    const data = form.getValues();\n    if (!joinWaitlistSchemaRo.safeParse(data).success) {\n      toast.error('Please enter a valid email address');\n      return;\n    }\n    joinWaitlistMutation({ email: data.email });\n  };\n\n  if (isSubmitted) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4\">\n        <Card className=\"w-full max-w-md\">\n          <CardHeader className=\"text-center\">\n            <div className=\"mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100\">\n              <svg\n                className=\"size-8 text-green-600\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n              >\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M5 13l4 4L19 7\"\n                />\n              </svg>\n            </div>\n            <CardTitle className=\"text-2xl font-bold text-gray-900\">\n              {t('waitlist.youAreOnTheList')}\n            </CardTitle>\n            <CardDescription className=\"text-lg\">{t('waitlist.thanksForJoining')}</CardDescription>\n          </CardHeader>\n          <CardContent className=\"text-center\">\n            <Button onClick={() => setIsSubmitted(false)} variant=\"outline\" className=\"w-full\">\n              {t('waitlist.back')}\n            </Button>\n          </CardContent>\n        </Card>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4\">\n      <div className=\"mb-16 text-center\">\n        <h1 className=\"mb-4 text-4xl font-bold text-gray-900\">{t('waitlist.joinTitle')}</h1>\n        <h2 className=\"text-xl  text-gray-900\">{t('waitlist.joinDesc')}</h2>\n      </div>\n\n      <Card className=\"w-full max-w-md\">\n        <CardContent>\n          <Form {...form}>\n            <form className=\"mt-6 space-y-6\">\n              <FormField\n                control={form.control}\n                name=\"email\"\n                render={({ field }) => (\n                  <FormItem>\n                    <FormControl>\n                      <Input\n                        type=\"email\"\n                        placeholder={t('waitlist.emailPlaceholder')}\n                        {...field}\n                        className=\"h-12\"\n                      />\n                    </FormControl>\n                    <FormMessage />\n                  </FormItem>\n                )}\n              />\n\n              <Button\n                type=\"button\"\n                onClick={onSubmit}\n                className=\"h-12 w-full text-lg font-semibold\"\n                disabled={isLoading}\n              >\n                {isLoading ? (\n                  <div className=\"flex items-center space-x-2\">\n                    <div className=\"size-4 animate-spin rounded-full border-2 border-white border-t-transparent\"></div>\n                    <span>{t('waitlist.joining')}</span>\n                  </div>\n                ) : (\n                  t('waitlist.join')\n                )}\n              </Button>\n            </form>\n          </Form>\n        </CardContent>\n      </Card>\n    </div>\n  );\n};\n\nexport const WaitlistPage = () => {\n  const { brandName } = useBrand();\n  const isCloud = useIsCloud();\n  const { data: setting } = useQuery({\n    queryKey: ReactQueryKeys.getPublicSetting(),\n    queryFn: () => getPublicSetting().then(({ data }) => data),\n  });\n  const { enableWaitlist = false } = setting ?? {};\n\n  if (!isCloud || !enableWaitlist) {\n    return <NotFoundPage />;\n  }\n\n  return (\n    <div className=\" h-screen w-screen\">\n      <div className=\"fixed left-5 top-5 flex flex-none items-center gap-2\">\n        <TeableLogo className=\"size-8\" />\n        {brandName}\n      </div>\n      <WaitlistPageInner />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/DescContent.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport { Rectangles } from './Rectangles';\n\nexport const DescContent = () => {\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  return (\n    <div className=\"shrink-1 relative hidden flex-1 basis-1/4 items-center justify-center border-r p-10 shadow-lg lg:flex\">\n      <div className=\"absolute inset-10 -z-10 flex flex-none flex-wrap justify-between gap-2 overflow-hidden\">\n        <Rectangles className=\"size-10 rounded-md\" amount={36 * 2} />\n        <Rectangles className=\"hidden size-10 rounded-md md:block\" amount={36 * 2} />\n        <Rectangles className=\"hidden size-10 rounded-md lg:block\" amount={36 * 2} />\n        <Rectangles className=\"hidden size-10 rounded-md xl:block\" amount={36 * 2} />\n      </div>\n      <div className=\"overflow-hidden\">\n        <h2 className=\"absolute -translate-y-full text-wrap pr-10 text-6xl font-bold\">\n          {t('auth:content.title')}\n        </h2>\n        <p className=\"py-10\">{t('auth:content.description')}</p>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/LayoutMain.tsx",
    "content": "import { Card } from '@teable/ui-lib/shadcn';\nimport { TeableFooter } from './TeableFooter';\n\ninterface ILayoutMainProps {\n  children?: React.ReactNode | React.ReactNode[];\n}\n\nexport const LayoutMain = (props: ILayoutMainProps) => {\n  const { children } = props;\n  return (\n    <div className=\"fixed flex h-screen w-full flex-col  justify-center gap-8 overflow-y-auto px-8 py-6 sm:px-6 lg:px-8\">\n      <Card className=\"mx-auto flex w-full max-w-md flex-col p-9\">{children}</Card>\n      <TeableFooter enableClick />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/Rectangles.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\nimport type { HTMLAttributes } from 'react';\n\nexport const Rectangles = ({\n  amount,\n  className,\n  style,\n  ...props\n}: HTMLAttributes<HTMLDivElement> & {\n  amount: number;\n}) => {\n  const LIGHT_COLORS = [\n    'bg-zinc-100 dark:bg-zinc-900',\n    'bg-zinc-100 bg-opacity-25 dark:bg-zinc-900 dark:bg-opacity-25',\n    'bg-zinc-50 dark:bg-zinc-900',\n    'bg-zinc-50 bg-opacity-25 dark:bg-zinc-900 dark:bg-opacity-25',\n  ];\n\n  return Array.from({ length: amount }).map((_, index) => {\n    const randomColor = LIGHT_COLORS[Math.floor(Math.random() * LIGHT_COLORS.length)];\n\n    const randomDuration = Math.random() * 3 + 2;\n\n    return (\n      <div\n        key={index}\n        className={cn(randomColor, className)}\n        style={{\n          ...style,\n          animation: `pulse ${randomDuration}s cubic-bezier(0.4, 0, 0.6, 1) infinite`,\n        }}\n        {...props}\n      />\n    );\n  });\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/SendVerificationButton.tsx",
    "content": "import { Check } from '@teable/icons';\nimport { Spin } from '@teable/ui-lib/base';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useTranslation } from 'next-i18next';\nimport { useEffect, useRef, useState } from 'react';\nimport { authConfig } from '@/features/i18n/auth.config';\n\ninterface SendVerificationButtonProps {\n  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;\n  disabled: boolean;\n  loading?: boolean;\n  countdown?: number;\n}\n\nexport const SendVerificationButton = ({\n  disabled,\n  onClick,\n  loading,\n  countdown = 0,\n}: SendVerificationButtonProps) => {\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  const [isSuccess, setIsSuccess] = useState(false);\n  const prevLoading = useRef(loading);\n\n  useEffect(() => {\n    let timer: NodeJS.Timeout | null = null;\n    if (!loading && prevLoading.current) {\n      setIsSuccess(true);\n      timer = setTimeout(() => {\n        setIsSuccess(false);\n      }, 3000);\n    } else {\n      setIsSuccess(false);\n    }\n    prevLoading.current = loading;\n    return () => {\n      timer && clearTimeout(timer);\n      timer = null;\n    };\n  }, [loading]);\n\n  const getButtonText = () => {\n    if (countdown > 0) {\n      return `${t('auth:button.resend')} (${countdown}s)`;\n    }\n    return t('auth:button.resend');\n  };\n\n  return (\n    <Button\n      variant={'outline'}\n      className=\"mt-4 w-full\"\n      disabled={disabled}\n      onClick={(e) => {\n        if (isSuccess || countdown > 0) {\n          return;\n        }\n        onClick(e);\n      }}\n    >\n      {loading && <Spin />}\n      {!loading && isSuccess && countdown === 0 && (\n        <Check className=\"size-4 animate-bounce text-green-500 dark:text-green-400\" />\n      )}\n      {getButtonText()}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/SignForm.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { HttpErrorCode, type HttpError } from '@teable/core';\nimport type { ISignin, ISignup } from '@teable/openapi';\nimport {\n  signup,\n  signin,\n  signinSchema,\n  signupSchema,\n  sendSignupVerificationCode,\n  sendSignupVerificationCodeRoSchema,\n} from '@teable/openapi';\nimport { Spin, Error as ErrorCom } from '@teable/ui-lib/base';\nimport { Button, Input, Label, cn } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ZodIssue } from 'zod';\nimport { fromZodError } from 'zod-validation-error';\nimport { trackSignUpConversion } from '@/components/google-ads';\nimport { useCutDown } from '@/features/app/hooks/useCutDown';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { usePublicSettingQuery } from '@/features/app/hooks/useSetting';\nimport { authConfig } from '../../i18n/auth.config';\nimport { SendVerificationButton } from './SendVerificationButton';\nimport TurnstileWidget from './TurnstileWidget';\n\nexport interface ISignForm {\n  className?: string;\n  type?: 'signin' | 'signup';\n  onSuccess?: () => void;\n}\nexport const SignForm: FC<ISignForm> = (props) => {\n  const { className, type = 'signin', onSuccess } = props;\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  const [signupVerificationToken, setSignupVerificationToken] = useState<string>();\n  const [signupVerificationCode, setSignupVerificationCode] = useState<string>();\n  const router = useRouter();\n  const [inviteCode, setInviteCode] = useState<string>(router.query.inviteCode as string);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [error, setError] = useState<string>();\n  const [turnstileToken, setTurnstileToken] = useState<string>();\n  const { countdown, setCountdown } = useCutDown();\n  const [turnstileKey, setTurnstileKey] = useState<number>(0);\n  const env = useEnv();\n  const emailRef = useRef<HTMLInputElement>(null);\n\n  const { data: setting } = usePublicSettingQuery();\n  const {\n    enableWaitlist = false,\n    disallowSignUp = false,\n    turnstileSiteKey,\n    signupVerificationSendCodeMailRate = 0,\n  } = setting ?? {};\n\n  const hasInvitationRedirect = useMemo(() => {\n    try {\n      const redirect = decodeURIComponent((router.query.redirect as string) || '');\n      const url = new URL(redirect, window.location.origin);\n      return url.searchParams.has('invitationId') && url.searchParams.has('invitationCode');\n    } catch {\n      return false;\n    }\n  }, [router.query.redirect]);\n\n  const joinWaitlist = useCallback(() => {\n    if (enableWaitlist) {\n      const email = emailRef.current?.value;\n      const url = email ? `/waitlist?email=${email}` : '/waitlist';\n      router.push(url);\n    }\n  }, [enableWaitlist, router]);\n\n  useEffect(() => {\n    setSignupVerificationCode(undefined);\n    setSignupVerificationToken(undefined);\n    setError(undefined);\n    setTurnstileToken(undefined);\n    setCountdown(0);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [type]);\n\n  // Countdown timer for send verification code button\n\n  const { mutate: submitMutation } = useMutation({\n    mutationFn: ({ type, form }: { type: 'signin' | 'signup'; form: ISignin }) => {\n      if (type === 'signin') {\n        return signin(form);\n      }\n      if (type === 'signup') {\n        return signup({\n          ...form,\n          refMeta: {\n            query: window.location.search || undefined,\n            referer: document.referrer || undefined,\n          },\n          defaultSpaceName: t('space:initialSpaceName', { name: form.email.split('@')[0] }),\n        });\n      }\n      throw new Error('Invalid type');\n    },\n    onError: (error: HttpError) => {\n      // need verify email\n      switch (error.code) {\n        case HttpErrorCode.UNPROCESSABLE_ENTITY:\n          if (error.data && typeof error.data === 'object' && 'token' in error.data) {\n            setSignupVerificationToken(error.data.token as string);\n            setError(undefined);\n            // Start countdown based on configured rate limit (only if configured)\n            if (\n              typeof signupVerificationSendCodeMailRate === 'number' &&\n              signupVerificationSendCodeMailRate > 0\n            ) {\n              setCountdown(signupVerificationSendCodeMailRate);\n            }\n          } else {\n            setError(error.message);\n          }\n          break;\n        case HttpErrorCode.CONFLICT:\n          setError(t('auth:signError.exist'));\n          break;\n        case HttpErrorCode.INVALID_CREDENTIALS:\n          setError(t('auth:signError.incorrect'));\n          break;\n        case HttpErrorCode.INVALID_CAPTCHA:\n          setError(t('auth:signupError.verificationCodeInvalid'));\n          break;\n        case HttpErrorCode.TOO_MANY_REQUESTS:\n          if (error.data && typeof error.data === 'object' && 'minutes' in error.data) {\n            setError(t('auth:signError.tooManyRequests', { minutes: error.data.minutes }));\n          } else {\n            setError(error.message);\n          }\n          break;\n        default:\n          setError(error.message);\n      }\n      // Reset turnstile token on any error to force re-verification\n      setTurnstileToken(undefined);\n      setTurnstileKey((prev) => prev + 1);\n      setIsLoading(false);\n      return true;\n    },\n    meta: {\n      preventGlobalError: true,\n    },\n    onSuccess: (data, variables) => {\n      // Reset turnstile token after successful submission\n      setTurnstileToken(undefined);\n      setTurnstileKey((prev) => prev + 1);\n\n      // Track Google Ads conversion for successful sign-up with user info\n      if (variables.type === 'signup' && data.data) {\n        trackSignUpConversion(env.googleAdsConversionId, {\n          id: data.data.id,\n          email: data.data.email,\n          name: data.data.name,\n        });\n      }\n\n      onSuccess?.();\n    },\n  });\n\n  const {\n    mutate: sendSignupVerificationCodeMutation,\n    isPending: sendSignupVerificationCodeLoading,\n  } = useMutation({\n    mutationFn: ({ email, turnstileToken }: { email: string; turnstileToken?: string }) =>\n      sendSignupVerificationCode(email, turnstileToken),\n    onSuccess: (data) => {\n      setSignupVerificationToken(data.data.token);\n      // Start countdown based on configured rate limit (only if configured)\n      if (\n        typeof signupVerificationSendCodeMailRate === 'number' &&\n        signupVerificationSendCodeMailRate > 0\n      ) {\n        setCountdown(signupVerificationSendCodeMailRate);\n      }\n      // Reset turnstile token and force widget refresh\n      setTurnstileToken(undefined);\n      setTurnstileKey((prev) => prev + 1);\n    },\n    onError: (error: HttpError) => {\n      // Reset turnstile on error\n      setTurnstileToken(undefined);\n      setTurnstileKey((prev) => prev + 1);\n      if (\n        error.code === HttpErrorCode.TOO_MANY_REQUESTS &&\n        error.data &&\n        typeof error.data === 'object' &&\n        'seconds' in error.data\n      ) {\n        setError(t('auth:signupError.sendMailRateLimit', { seconds: error.data.seconds }));\n        return;\n      }\n      setError(error.message);\n    },\n    meta: {\n      preventGlobalError: true,\n    },\n  });\n\n  const validation = useCallback(\n    (form: ISignin | ISignup) => {\n      const transformError = (issues: ZodIssue[]) => {\n        if (issues?.[0].path?.[0] === 'password') {\n          const code = issues[0].code;\n          if (code === 'too_small') {\n            return { error: t('auth:signupError.passwordLength') };\n          }\n          // In Zod 4.x, string validation errors may use different codes\n          // Check for common validation failure codes\n          if (code === 'invalid_format' || code === 'custom') {\n            return { error: t('auth:signupError.passwordInvalid') };\n          }\n        }\n        return { error: issues[0]?.message ?? t('common:noun.unknownError') };\n      };\n      if (type === 'signin') {\n        const res = signinSchema.safeParse(form);\n        if (!res.success) {\n          return transformError(res.error.issues);\n        }\n        return {\n          error: undefined,\n        };\n      }\n      const res = signupSchema.safeParse(form);\n      if (!res.success) {\n        return transformError(res.error.issues);\n      }\n      return {\n        error: undefined,\n      };\n    },\n    [t, type]\n  );\n\n  const showVerificationCode = type === 'signup' && signupVerificationToken;\n\n  // Turnstile callbacks\n  const handleTurnstileVerify = useCallback((token: string) => setTurnstileToken(token), []);\n  const handleTurnstileError = useCallback(() => {\n    setTurnstileToken(undefined);\n    setError(t('auth:signError.turnstileError'));\n  }, [t]);\n  const handleTurnstileExpire = useCallback(() => {\n    setTurnstileToken(undefined);\n    setError(t('auth:signError.turnstileExpired'));\n  }, [t]);\n  const handleTurnstileTimeout = useCallback(() => {\n    setTurnstileToken(undefined);\n    setError(t('auth:signError.turnstileTimeout'));\n  }, [t]);\n\n  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n\n    const email = (event.currentTarget.elements.namedItem('email') as HTMLInputElement).value;\n    const password = (event.currentTarget.elements.namedItem('password') as HTMLInputElement).value;\n    const code = (event.currentTarget.elements.namedItem('verification-code') as HTMLInputElement)\n      ?.value;\n    const inviteCode = (event.currentTarget.elements.namedItem('invite-code') as HTMLInputElement)\n      ?.value;\n\n    const form = {\n      email,\n      password,\n      verification: code ? { code, token: signupVerificationToken } : undefined,\n      inviteCode: enableWaitlist ? inviteCode : undefined,\n      turnstileToken: turnstileToken,\n    };\n\n    const { error } = validation(form);\n    if (error) {\n      setError(error);\n      return;\n    }\n\n    if (showVerificationCode && !signupVerificationCode) {\n      setError(t('auth:signupError.verificationCodeRequired'));\n      return;\n    }\n\n    // Check Turnstile verification if enabled\n    if (turnstileSiteKey && !turnstileToken) {\n      setError(t('auth:signError.turnstileRequired'));\n      return;\n    }\n\n    // Using custom isLoading instead of submitMutation.isLoading because isLoading only reflects the mutation state,\n    // and we need the loader to persist during the delay between the request completion and the redirect.\n    setIsLoading(true);\n    submitMutation({ type, form });\n  }\n\n  const buttonText = useMemo(\n    () => (type === 'signin' ? t('auth:button.signin') : t('auth:button.signup')),\n    [t, type]\n  );\n\n  return (\n    <div\n      className={cn(\n        'flex flex-col gap-3',\n        {\n          'pointer-events-none': isLoading,\n        },\n        className\n      )}\n    >\n      <div className=\"relative mb-4 text-muted-foreground\">\n        <h2 className=\"text-center text-xl\">\n          {type === 'signin' ? t('auth:title.signin') : t('auth:title.signup')}\n        </h2>\n      </div>\n      <form className=\"relative\" onSubmit={onSubmit} onChange={() => setError(undefined)}>\n        <div className=\"grid gap-3\">\n          <div className=\"grid gap-3\">\n            <Label htmlFor=\"email\">{t('auth:label.email')}</Label>\n            <Input\n              id=\"email\"\n              placeholder={t('auth:placeholder.email')}\n              type=\"text\"\n              autoComplete=\"username\"\n              ref={emailRef}\n              onChange={() => {\n                setSignupVerificationCode(undefined);\n                setSignupVerificationToken(undefined);\n              }}\n              disabled={isLoading}\n            />\n          </div>\n          <div className=\"grid gap-3\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor=\"password\">{t('auth:label.password')}</Label>\n            </div>\n            <Input\n              id=\"password\"\n              placeholder={t('auth:placeholder.password')}\n              type=\"password\"\n              autoComplete={type === 'signup' ? 'new-password' : 'current-password'}\n              disabled={isLoading}\n            />\n            {type === 'signin' && (\n              <Link\n                className=\"absolute right-0 text-xs text-muted-foreground underline-offset-4 hover:underline\"\n                href=\"/auth/forget-password\"\n              >\n                {t('auth:forgetPassword.trigger')}\n              </Link>\n            )}\n          </div>\n\n          {enableWaitlist && type === 'signup' && (\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"invite-code\">{t('common:waitlist.code')}</Label>\n              <div className=\"flex items-center\">\n                <Input\n                  id=\"invite-code\"\n                  type=\"text\"\n                  placeholder={t('common:waitlist.inviteCodePlaceholder')}\n                  autoComplete=\"off\"\n                  disabled={isLoading}\n                  value={inviteCode}\n                  onChange={(e) => setInviteCode(e.target.value)}\n                />\n                <Button variant=\"link\" className=\"p-2 text-xs\" type=\"button\" onClick={joinWaitlist}>\n                  {t('common:waitlist.join')}\n                </Button>\n              </div>\n            </div>\n          )}\n\n          <div\n            data-state={showVerificationCode ? 'show' : 'hide'}\n            className={cn('transition-all data-[state=show]:mt-4', {\n              'h-0 overflow-hidden': !showVerificationCode,\n            })}\n          >\n            {showVerificationCode && (\n              <div className=\"grid gap-3\">\n                <Label htmlFor=\"verification-code\">{t('auth:label.verificationCode')}</Label>\n                <Input\n                  id=\"verification-code\"\n                  type=\"text\"\n                  placeholder={t('auth:placeholder.verificationCode')}\n                  value={signupVerificationCode}\n                  onChange={(e) => setSignupVerificationCode(e.target.value)}\n                />\n                <SendVerificationButton\n                  disabled={sendSignupVerificationCodeLoading || countdown > 0}\n                  onClick={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    const emailInput = e.currentTarget.form?.querySelector(\n                      '#email'\n                    ) as HTMLInputElement;\n                    const email = emailInput?.value;\n                    if (!email) {\n                      return;\n                    }\n\n                    // Check Turnstile verification if enabled\n                    if (turnstileSiteKey && !turnstileToken) {\n                      setError(t('auth:signError.turnstileRequired'));\n                      return;\n                    }\n\n                    const res = sendSignupVerificationCodeRoSchema.safeParse({\n                      email,\n                      turnstileToken,\n                    });\n                    if (!res.success) {\n                      setError(fromZodError(res.error).message);\n                      return;\n                    }\n                    sendSignupVerificationCodeMutation({ email, turnstileToken });\n                  }}\n                  loading={sendSignupVerificationCodeLoading}\n                  countdown={countdown}\n                />\n              </div>\n            )}\n          </div>\n\n          {/* Turnstile Widget */}\n          {turnstileSiteKey && (\n            <div className=\"flex justify-center\">\n              <TurnstileWidget\n                key={turnstileKey}\n                siteKey={turnstileSiteKey}\n                onVerify={handleTurnstileVerify}\n                onError={handleTurnstileError}\n                onExpire={handleTurnstileExpire}\n                onTimeout={handleTurnstileTimeout}\n                action={type}\n                theme=\"auto\"\n                size=\"normal\"\n              />\n            </div>\n          )}\n\n          <div>\n            <Button className=\"w-full\" disabled={isLoading}>\n              {isLoading && <Spin />}\n              {buttonText}\n            </Button>\n            {(!disallowSignUp || hasInvitationRedirect) && (\n              <div className=\"flex justify-end py-2\">\n                <Link\n                  href={{\n                    pathname: type === 'signin' ? '/auth/signup' : '/auth/login',\n                    query: { ...router.query },\n                  }}\n                  shallow\n                  className=\"text-xs text-muted-foreground underline-offset-4 hover:underline\"\n                >\n                  {type === 'signin' ? t('auth:button.signup') : t('auth:button.signin')}\n                </Link>\n              </div>\n            )}\n            <ErrorCom error={error} />\n          </div>\n        </div>\n      </form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/SocialAuth.tsx",
    "content": "import { GithubLogo, GoogleLogo } from '@teable/icons';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useMemo } from 'react';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { authConfig } from '@/features/i18n/auth.config';\n\nexport const providersAll = [\n  {\n    id: 'github',\n    text: 'Github',\n    Icon: GithubLogo,\n    authUrl: '/api/auth/github',\n  },\n  {\n    id: 'google',\n    text: 'Google',\n    Icon: GoogleLogo,\n    authUrl: '/api/auth/google',\n  },\n  {\n    id: 'oidc',\n    text: 'OIDC',\n    authUrl: '/api/auth/oidc',\n  },\n];\n\nexport const SocialAuth = () => {\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  const { socialAuthProviders, passwordLoginDisabled } = useEnv();\n  const router = useRouter();\n  const redirect = router.query.redirect as string;\n\n  const providers = useMemo(\n    () => providersAll.filter((provider) => socialAuthProviders?.includes(provider.id)),\n    [socialAuthProviders]\n  );\n\n  const onClick = (authUrl: string) => {\n    window.location.href = redirect\n      ? `${authUrl}?redirect_uri=${encodeURIComponent(redirect)}`\n      : authUrl;\n  };\n\n  if (!providers.length) {\n    return;\n  }\n\n  return (\n    <>\n      {!passwordLoginDisabled && (\n        <div className=\"relative my-5\">\n          <div className=\"absolute inset-0 flex items-center\">\n            <span className=\"w-full border-t\" />\n          </div>\n          <div className=\"relative flex justify-center text-xs uppercase\">\n            <span className=\"bg-background px-2 text-muted-foreground\">\n              {t('auth:socialAuth.title')}\n            </span>\n          </div>\n        </div>\n      )}\n      <div className=\"space-y-2\">\n        {providers.map(({ id, text, Icon, authUrl }) => (\n          <Button key={id} className=\"w-full\" variant=\"outline\" onClick={() => onClick(authUrl)}>\n            {Icon && <Icon className=\"size-4\" />}\n            {text}\n          </Button>\n        ))}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/TeableFooter.tsx",
    "content": "import { cn } from '@teable/ui-lib/shadcn';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useBrand } from '@/features/app/hooks/useBrand';\n\ninterface ITeableHeaderProps {\n  className?: string;\n  enableClick?: boolean;\n}\n\nexport const TeableFooter = (props: ITeableHeaderProps) => {\n  const { className, enableClick } = props;\n  const { brandName } = useBrand();\n\n  return (\n    <div\n      data-state={enableClick ? 'click' : undefined}\n      className={cn(\n        'max-w-6xl mx-auto w-full flex items-center justify-center gap-2 data-[state=click]:cursor-pointer font-bold',\n        className\n      )}\n    >\n      <TeableLogo className=\"size-8 rounded-full\" />\n      {brandName}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/Terms.tsx",
    "content": "import Link from 'next/link';\nimport { Trans, useTranslation } from 'next-i18next';\nimport { useIsCloud } from '@/features/app/hooks/useIsCloud';\nimport { authConfig } from '@/features/i18n/auth.config';\n\nexport const Terms = () => {\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  const isCloud = useIsCloud();\n\n  if (!isCloud) {\n    return null;\n  }\n\n  return (\n    <p className=\"mt-4 text-xs text-muted-foreground\">\n      <Trans\n        ns=\"auth\"\n        i18nKey=\"legal.tip\"\n        components={{\n          Terms: <Link className=\"underline\" href={t('auth:legal.termsUrl')} target=\"_blank\" />,\n          Privacy: <Link className=\"underline\" href={t('auth:legal.privacyUrl')} target=\"_blank\" />,\n        }}\n      />\n    </p>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/components/TurnstileWidget.tsx",
    "content": "import { useCallback, useEffect, useRef } from 'react';\n\ndeclare global {\n  interface Window {\n    turnstile?: {\n      render: (element: string | HTMLElement, options: TurnstileOptions) => string;\n      reset: (widgetId?: string) => void;\n      remove: (widgetId?: string) => void;\n      getResponse: (widgetId?: string) => string;\n    };\n  }\n}\n\ninterface TurnstileOptions {\n  sitekey: string;\n  callback?: (token: string) => void;\n  'error-callback'?: () => void;\n  'expired-callback'?: () => void;\n  'timeout-callback'?: () => void;\n  'after-interactive-callback'?: () => void;\n  'before-interactive-callback'?: () => void;\n  'unsupported-callback'?: () => void;\n  theme?: 'light' | 'dark' | 'auto';\n  language?: string;\n  tabindex?: number;\n  'response-field'?: boolean;\n  'response-field-name'?: string;\n  size?: 'normal' | 'compact';\n  retry?: 'auto' | 'never';\n  'retry-interval'?: number;\n  'refresh-expired'?: 'auto' | 'manual' | 'never';\n  appearance?: 'always' | 'execute' | 'interaction-only';\n  execution?: 'render' | 'execute';\n  action?: string;\n  cdata?: string;\n}\n\ninterface TurnstileWidgetProps {\n  siteKey: string;\n  onVerify?: (token: string) => void;\n  onError?: () => void;\n  onExpire?: () => void;\n  onTimeout?: () => void;\n  theme?: 'light' | 'dark' | 'auto';\n  size?: 'normal' | 'compact';\n  action?: string;\n  cdata?: string;\n  className?: string;\n}\n\nexport const TurnstileWidget: React.FC<TurnstileWidgetProps> = ({\n  siteKey,\n  onVerify,\n  onError,\n  onExpire,\n  onTimeout,\n  theme = 'auto',\n  size = 'normal',\n  action,\n  cdata,\n  className,\n}) => {\n  const widgetRef = useRef<HTMLDivElement>(null);\n  const widgetIdRef = useRef<string>();\n  const scriptLoadedRef = useRef<boolean>(false);\n\n  const loadTurnstileScript = (): Promise<void> => {\n    return new Promise((resolve, reject) => {\n      if (scriptLoadedRef.current || window.turnstile) {\n        resolve();\n        return;\n      }\n\n      const script = document.createElement('script');\n      script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';\n      script.async = true;\n      script.defer = true;\n\n      script.onload = () => {\n        scriptLoadedRef.current = true;\n        resolve();\n      };\n\n      script.onerror = () => {\n        reject(new Error('Failed to load Turnstile script'));\n      };\n\n      document.head.appendChild(script);\n    });\n  };\n\n  const renderWidget = useCallback(() => {\n    if (!widgetRef.current || !window.turnstile || !siteKey) {\n      return;\n    }\n\n    // Remove existing widget if any\n    if (widgetIdRef.current) {\n      window.turnstile.remove(widgetIdRef.current);\n    }\n\n    const options: TurnstileOptions = {\n      sitekey: siteKey,\n      callback: onVerify,\n      'error-callback': onError,\n      'expired-callback': onExpire,\n      'timeout-callback': onTimeout,\n      theme,\n      size,\n      action,\n      cdata,\n    };\n\n    try {\n      widgetIdRef.current = window.turnstile.render(widgetRef.current, options);\n    } catch (error) {\n      console.error('Failed to render Turnstile widget:', error);\n      onError?.();\n    }\n  }, [siteKey, onVerify, onError, onExpire, onTimeout, theme, size, action, cdata]);\n\n  useEffect(() => {\n    if (!siteKey) return;\n\n    loadTurnstileScript()\n      .then(() => {\n        renderWidget();\n      })\n      .catch((error) => {\n        console.error('Failed to load Turnstile:', error);\n        if (onError) {\n          onError();\n        }\n      });\n\n    return () => {\n      if (widgetIdRef.current && window.turnstile) {\n        window.turnstile.remove(widgetIdRef.current);\n      }\n    };\n  }, [siteKey, renderWidget, onError]);\n\n  // Reset widget when callbacks change\n  useEffect(() => {\n    if (widgetIdRef.current && window.turnstile) {\n      window.turnstile.reset(widgetIdRef.current);\n    }\n  }, [onVerify, onError, onExpire, onTimeout]);\n\n  if (!siteKey) {\n    return null;\n  }\n\n  return <div ref={widgetRef} className={className} />;\n};\n\nexport default TurnstileWidget;\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/pages/ForgetPasswordPage.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { HttpErrorCode, type HttpError } from '@teable/core';\nimport { sendResetPasswordEmail } from '@teable/openapi';\nimport { Spin, Error } from '@teable/ui-lib/base';\nimport { Button, Input, Label, Separator } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { z } from 'zod';\nimport { useAutoFavicon } from '@/features/app/hooks/useAutoFavicon';\nimport { useCutDown } from '@/features/app/hooks/useCutDown';\nimport { usePublicSettingQuery } from '@/features/app/hooks/useSetting';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport { LayoutMain } from '../components/LayoutMain';\n\nexport const ForgetPasswordPage = () => {\n  const [error, setError] = useState<string>();\n  const [email, setEmail] = useState<string>();\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  useAutoFavicon();\n  const { countdown, setCountdown } = useCutDown();\n  const { data: setting } = usePublicSettingQuery();\n  const { resetPasswordSendMailRate } = setting ?? {};\n\n  const { mutate: sendResetPasswordEmailMutate, isPending: isLoading } = useMutation({\n    mutationFn: sendResetPasswordEmail,\n    onSuccess: () => {\n      toast.success(t('auth:forgetPassword.success.title'), {\n        description: t('auth:forgetPassword.success.description'),\n      });\n      if (typeof resetPasswordSendMailRate === 'number' && resetPasswordSendMailRate > 0) {\n        setCountdown(resetPasswordSendMailRate);\n      }\n    },\n    onError: (err: HttpError) => {\n      if (\n        err.code === HttpErrorCode.TOO_MANY_REQUESTS &&\n        err.data &&\n        typeof err.data === 'object' &&\n        'seconds' in err.data\n      ) {\n        setError(t('auth:forgetPassword.sendMailRateLimit', { seconds: err.data.seconds }));\n        return;\n      }\n      setError(err.message);\n    },\n  });\n\n  const emailOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const value = event.target.value;\n    setEmail(value);\n    setError('');\n  };\n\n  const validEmail = (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    const value = e.target.value;\n    if (!value) {\n      return setError(t('auth:forgetPassword.errorRequiredEmail'));\n    }\n    if (!z.string().email().safeParse(value).success) {\n      return setError(t('auth:forgetPassword.errorInvalidEmail'));\n    }\n  };\n\n  return (\n    <LayoutMain>\n      <h1 className=\"mb-3 text-2xl lg:text-3xl\">{t('auth:forgetPassword.header')}</h1>\n      <p className=\"mb-16 text-sm text-muted-foreground\">{t('auth:forgetPassword.description')}</p>\n      <div className=\"flex flex-col gap-2\">\n        <Label>{t('auth:label.email')}</Label>\n        <div>\n          <Input\n            id=\"email\"\n            placeholder={t('auth:placeholder.email')}\n            type=\"text\"\n            autoComplete=\"email\"\n            disabled={isLoading}\n            onChange={emailOnChange}\n            onBlur={validEmail}\n          />\n          <Error error={error} />\n        </div>\n        <Separator className=\"my-2\" />\n        <Button\n          onClick={() => {\n            if (error || isLoading || !email) return;\n            sendResetPasswordEmailMutate({ email });\n          }}\n          disabled={isLoading || countdown > 0}\n        >\n          {isLoading && <Spin />}\n          {countdown > 0 ? `${countdown}s` : t('auth:forgetPassword.buttonText')}\n        </Button>\n      </div>\n    </LayoutMain>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/pages/LoginPage.tsx",
    "content": "import { ScrollArea, Tabs, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn';\nimport Link from 'next/link';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { NextSeo } from 'next-seo';\nimport { useCallback, useMemo } from 'react';\nimport { TeableLogo } from '@/components/TeableLogo';\nimport { useAutoFavicon } from '@/features/app/hooks/useAutoFavicon';\nimport { useBrand } from '@/features/app/hooks/useBrand';\nimport { useEnv } from '@/features/app/hooks/useEnv';\nimport { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport { DescContent } from '../components/DescContent';\nimport { SignForm } from '../components/SignForm';\nimport { SocialAuth } from '../components/SocialAuth';\nimport { Terms } from '../components/Terms';\nimport { useDisallowSignUp } from '../useDisallowSignUp';\n\nexport const LoginPage = (props: { children?: React.ReactNode | React.ReactNode[] }) => {\n  const { children } = props;\n  useInitializationZodI18n();\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  const { brandName } = useBrand();\n  const router = useRouter();\n  useAutoFavicon();\n  const redirect = decodeURIComponent((router.query.redirect as string) || '');\n  const signType = router.pathname.endsWith('/signup') ? 'signup' : 'signin';\n  const { passwordLoginDisabled } = useEnv();\n  const disallowSignUp = useDisallowSignUp();\n  const hasInvitationRedirect = useMemo(() => {\n    try {\n      const url = new URL(redirect, window.location.origin);\n      return url.searchParams.has('invitationId') && url.searchParams.has('invitationCode');\n    } catch {\n      return false;\n    }\n  }, [redirect]);\n  const onSuccess = useCallback(() => {\n    if (redirect) {\n      router.push(redirect);\n    } else {\n      router.push({\n        pathname: '/space',\n        query: router.query,\n      });\n    }\n  }, [redirect, router]);\n\n  return (\n    <ScrollArea className=\"h-screen\">\n      <div className=\"flex min-h-screen\">\n        <NextSeo title={signType === 'signin' ? t('auth:page.signin') : t('auth:page.signup')} />\n        <div className=\"fixed left-5 top-5 flex flex-none items-center gap-2\">\n          <TeableLogo className=\"size-8\" />\n          {brandName}\n        </div>\n        <DescContent />\n        <div className=\"relative flex flex-1 shrink-0 flex-col items-center justify-center\">\n          <div className=\"absolute right-0 top-0 flex h-[4em] items-center justify-end bg-background px-5 lg:h-20\">\n            <Tabs value={signType}>\n              <TabsList>\n                <Link href={{ pathname: '/auth/login', query: { ...router.query } }} shallow>\n                  <TabsTrigger value=\"signin\">{t('auth:button.signin')}</TabsTrigger>\n                </Link>\n                {(!disallowSignUp || hasInvitationRedirect) && (\n                  <Link href={{ pathname: '/auth/signup', query: { ...router.query } }} shallow>\n                    <TabsTrigger value=\"signup\">{t('auth:button.signup')}</TabsTrigger>\n                  </Link>\n                )}\n              </TabsList>\n            </Tabs>\n          </div>\n          <div className=\"relative w-80 py-[5em] lg:py-24\">\n            {!passwordLoginDisabled && <SignForm type={signType} onSuccess={onSuccess} />}\n            <SocialAuth />\n            {children}\n            <Terms />\n          </div>\n        </div>\n      </div>\n    </ScrollArea>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/pages/ResetPasswordPage.tsx",
    "content": "import { useMutation } from '@tanstack/react-query';\nimport { type HttpError } from '@teable/core';\nimport { resetPassword, passwordSchema } from '@teable/openapi';\nimport { Spin, Error } from '@teable/ui-lib/base';\nimport { Button, Input, Label, Separator } from '@teable/ui-lib/shadcn';\nimport { toast } from '@teable/ui-lib/shadcn/ui/sonner';\nimport { useRouter } from 'next/router';\nimport { useTranslation } from 'next-i18next';\nimport { useState } from 'react';\nimport { useAutoFavicon } from '@/features/app/hooks/useAutoFavicon';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport { LayoutMain } from '../components/LayoutMain';\n\nexport const ResetPasswordPage = () => {\n  const [error, setError] = useState<string>();\n  const [password, setPassword] = useState<string>();\n  const router = useRouter();\n  const code = router.query.code as string;\n  const { t } = useTranslation(authConfig.i18nNamespaces);\n  useAutoFavicon();\n\n  const {\n    mutate: resetPasswordMutate,\n    isPending: isLoading,\n    isSuccess,\n  } = useMutation({\n    mutationFn: resetPassword,\n    onSuccess: () => {\n      toast.success(t('auth:resetPassword.success.title'), {\n        description: t('auth:resetPassword.success.description'),\n      });\n      setTimeout(() => {\n        router.push('/auth/login');\n      }, 2000);\n    },\n    onError: (err: HttpError) => {\n      setError(err.message);\n    },\n  });\n\n  const passwordOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const value = event.target.value;\n    setPassword(value);\n    setError(undefined);\n  };\n\n  const validPassword = (e: React.FocusEvent<HTMLInputElement, Element>) => {\n    const value = e.target.value;\n    if (!value) {\n      return setError(t('auth:resetPassword.error.requiredPassword'));\n    }\n    const res = passwordSchema.safeParse(value);\n    if (!res.success) {\n      return setError(t('common:password.setInvalid'));\n    }\n  };\n\n  return (\n    <LayoutMain>\n      <h1 className=\"mb-3 text-2xl lg:text-3xl\">{t('auth:resetPassword.header')}</h1>\n      <p className=\"mb-10 text-sm text-muted-foreground\">{t('auth:resetPassword.description')}</p>\n      <div className=\"flex flex-col gap-2\">\n        <Label>{t('auth:resetPassword.label')}</Label>\n        <div>\n          <Input\n            id=\"new-password\"\n            placeholder={t('auth:placeholder.password')}\n            type=\"password\"\n            autoComplete=\"password\"\n            disabled={isLoading}\n            onChange={passwordOnChange}\n            onBlur={validPassword}\n          />\n          <Error error={code ? error : t('auth:resetPassword.error.invalidLink')} />\n        </div>\n        <Separator className=\"my-2\" />\n        <Button\n          onClick={() => {\n            if (error || isLoading || !password || isSuccess) return;\n            resetPasswordMutate({ code, password });\n          }}\n        >\n          {isLoading && <Spin />}\n          {t('auth:resetPassword.buttonText')}\n        </Button>\n      </div>\n    </LayoutMain>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/auth/useDisallowSignUp.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { getPublicSetting } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\n\nexport const useDisallowSignUp = () => {\n  const { data: setting } = useQuery({\n    queryKey: ReactQueryKeys.getPublicSetting(),\n    queryFn: () => getPublicSetting().then(({ data }) => data),\n  });\n  const { disallowSignUp } = setting ?? {};\n  return disallowSignUp;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/auth.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IAuthConfig {\n  // Define namespaces in use in both the type and the config.\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'auth' | 'space' | 'zod'>;\n}\n\nexport const authConfig: IAuthConfig = {\n  i18nNamespaces: ['common', 'auth', 'space', 'zod'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/automation.tsx",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IAutomationConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'space' | 'sdk'>;\n}\n\nexport const automationConfig: IAutomationConfig = {\n  i18nNamespaces: ['common', 'space', 'sdk'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/base-all.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IBaseAllConfig {\n  i18nNamespaces: I18nActiveNamespaces<\n    | 'common'\n    | 'space'\n    | 'sdk'\n    | 'table'\n    | 'chart'\n    | 'dashboard'\n    | 'zod'\n    | 'developer'\n    | 'token'\n    | 'setting'\n    | 'oauth'\n  >;\n}\n\nexport const baseAllConfig: IBaseAllConfig = {\n  i18nNamespaces: [\n    'common',\n    'space',\n    'sdk',\n    'table',\n    'chart',\n    'dashboard',\n    'zod',\n    'developer',\n    'token',\n    'setting',\n    'oauth',\n  ],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/base.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IBaseConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'space' | 'sdk' | 'table'>;\n}\n\nexport const baseConfig: IBaseConfig = {\n  i18nNamespaces: ['common', 'space', 'sdk', 'table'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/dashboard.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IDashboardConfig {\n  i18nNamespaces: I18nActiveNamespaces<\n    'common' | 'space' | 'sdk' | 'table' | 'dashboard' | 'zod' | 'chart'\n  >;\n}\n\nexport const dashboardConfig: IDashboardConfig = {\n  i18nNamespaces: ['common', 'space', 'sdk', 'table', 'dashboard', 'zod', 'chart'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/developer.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IDeveloperConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'setting' | 'sdk' | 'developer'>;\n}\n\nexport const developerConfig: IDeveloperConfig = {\n  i18nNamespaces: ['common', 'setting', 'sdk', 'developer'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/oauth-app.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IOAuthAppConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'setting' | 'sdk' | 'oauth' | 'zod' | 'token'>;\n}\n\nexport const oauthAppConfig: IOAuthAppConfig = {\n  i18nNamespaces: ['common', 'sdk', 'setting', 'oauth', 'zod', 'token'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/personal-access-token.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IPersonalAccessTokenConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'sdk' | 'setting' | 'developer' | 'token'>;\n}\n\nexport const personalAccessTokenConfig: IPersonalAccessTokenConfig = {\n  i18nNamespaces: ['common', 'sdk', 'setting', 'developer', 'token'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/setting-plugin.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface ISettingPluginConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'sdk' | 'setting' | 'plugin' | 'zod'>;\n}\n\nexport const settingPluginConfig: ISettingPluginConfig = {\n  i18nNamespaces: ['common', 'sdk', 'setting', 'plugin', 'zod'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/setting.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface ISettingConfig {\n  i18nNamespaces: I18nActiveNamespaces<\n    'common' | 'sdk' | 'setting' | 'developer' | 'token' | 'oauth' | 'zod'\n  >;\n}\n\nexport const settingConfig: ISettingConfig = {\n  i18nNamespaces: ['common', 'sdk', 'setting', 'developer', 'token', 'oauth', 'zod'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/share.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface IShareConfig {\n  // Define namespaces in use in both the type and the config.\n  i18nNamespaces: I18nActiveNamespaces<'share' | 'common' | 'table' | 'sdk' | 'share'>;\n}\n\nexport const shareConfig: IShareConfig = {\n  i18nNamespaces: ['share', 'common', 'table', 'sdk', 'share'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/space.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface ISpaceConfig {\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'space' | 'sdk'>;\n}\n\nexport const spaceConfig: ISpaceConfig = {\n  i18nNamespaces: ['common', 'space', 'sdk'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/system.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface ISystemConfig {\n  // Define namespaces in use in both the type and the config.\n  i18nNamespaces: I18nActiveNamespaces<'common' | 'sdk'>;\n}\n\nexport const systemConfig: ISystemConfig = {\n  i18nNamespaces: ['common', 'sdk'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/i18n/table.config.ts",
    "content": "import type { I18nActiveNamespaces } from '@/lib/i18n';\n\nexport interface ITableConfig {\n  // Define namespaces in use in both the type and the config.\n  i18nNamespaces: I18nActiveNamespaces<\n    'common' | 'space' | 'sdk' | 'table' | 'chart' | 'developer' | 'token'\n  >;\n}\n\nexport const tableConfig: ITableConfig = {\n  i18nNamespaces: ['common', 'space', 'sdk', 'table', 'chart', 'developer', 'token'],\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/ErrorPage.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { IllustrationPage } from './IllustrationPage';\n\ntype Props = {\n  statusCode?: number | null;\n  error?: Error;\n  message?: string;\n  errorId?: string;\n  children?: never;\n};\n\nexport const ErrorPage: FC<Props> = (props) => {\n  const { error, errorId, message, statusCode } = props;\n  const { t } = useTranslation('common');\n\n  return (\n    <div className=\"relative\">\n      <IllustrationPage\n        imageLightSrc=\"/images/layout/error-light.png\"\n        imageDarkSrc=\"/images/layout/error-dark.png\"\n        imageAlt=\"Error\"\n        title={t('system.error.title')}\n        description={t('system.error.description')}\n        button={{ label: t('system.links.backToHome'), href: '/' }}\n      />\n      <div className=\"absolute bottom-0 right-0 m-5 flex flex-col gap-1 rounded-lg border bg-background p-4 text-left text-sm\">\n        <div className=\"flex gap-2\" data-testid=\"error-status-code\">\n          <span className=\"text-muted-foreground\">Code: </span>\n          <span className=\"text-foreground\">{statusCode}</span>\n        </div>\n        <div className=\"flex gap-2\">\n          <span className=\"text-muted-foreground\">Message: </span>\n          <span className=\"text-foreground\">{message}</span>\n        </div>\n        <div className=\"flex gap-2\">\n          <span className=\"text-muted-foreground\">Error id: </span>\n          <span className=\"text-foreground\">{errorId}</span>\n        </div>\n        <div className=\"flex gap-2\">\n          <span className=\"text-muted-foreground\">ErrorMessage: </span>\n          <span className=\"text-foreground\">{error?.message}</span>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/ForbiddenPage.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport type { IButtonConfig } from './IllustrationPage';\nimport { IllustrationPage } from './IllustrationPage';\n\ntype ForbiddenPageProps = {\n  title?: string;\n  description?: string;\n  button?: IButtonConfig;\n};\n\nexport const ForbiddenPage: FC<ForbiddenPageProps> = ({ title, description, button }) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <IllustrationPage\n      imageLightSrc=\"/images/layout/permission-light.png\"\n      imageDarkSrc=\"/images/layout/permission-dark.png\"\n      imageAlt=\"Permission Denied\"\n      title={title ?? t('system.forbidden.title')}\n      description={description ?? t('system.forbidden.description')}\n      button={button ?? { label: t('system.links.backToHome'), href: '/' }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/HttpErrorPage.tsx",
    "content": "import type { ICustomHttpExceptionData, IHttpError } from '@teable/core';\nimport { getLocalizationMessage } from '@teable/sdk';\nimport type { ILocaleFunction } from '@teable/sdk/context/app/i18n';\nimport { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport { ForbiddenPage } from './ForbiddenPage';\nimport { PaymentRequiredPage } from './PaymentRequired';\n\ntype HttpErrorPageProps = {\n  httpError: IHttpError;\n};\n\nconst errorComponentMap: Record<\n  IHttpError['status'],\n  React.ComponentType<{ description?: string }>\n> = {\n  402: PaymentRequiredPage,\n  403: ForbiddenPage,\n};\n\nexport const HttpErrorPage: FC<HttpErrorPageProps> = ({ httpError }) => {\n  const { t } = useTranslation('common');\n\n  const ErrorComponent = errorComponentMap[httpError.status];\n\n  const { data } = httpError;\n  const { localization } = (data as ICustomHttpExceptionData) || {};\n  const description = localization\n    ? getLocalizationMessage(localization, t as unknown as ILocaleFunction, 'sdk')\n    : undefined;\n\n  if (!ErrorComponent) {\n    return null;\n  }\n\n  return <ErrorComponent description={description} />;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/IllustrationPage.tsx",
    "content": "import { useTheme } from '@teable/next-themes';\nimport { Button } from '@teable/ui-lib/shadcn';\nimport Head from 'next/head';\nimport Image from 'next/image';\nimport type { FC } from 'react';\n\nexport interface IButtonConfig {\n  label: string;\n  href: string;\n  variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';\n}\n\nexport interface IIllustrationPageProps {\n  /** Light theme image path */\n  imageLightSrc: string;\n  /** Dark theme image path */\n  imageDarkSrc: string;\n  /** Image alt text */\n  imageAlt?: string;\n  /** Page title (also used for document title) */\n  title: string;\n  /** Page description */\n  description?: string;\n  /** Button config */\n  button: IButtonConfig;\n}\n\nexport const IllustrationPage: FC<IIllustrationPageProps> = ({\n  imageLightSrc,\n  imageDarkSrc,\n  imageAlt = 'Illustration',\n  title,\n  description,\n  button,\n}) => {\n  const { resolvedTheme } = useTheme();\n\n  const imageSrc = resolvedTheme === 'dark' ? imageDarkSrc : imageLightSrc;\n\n  return (\n    <>\n      <Head>\n        <title>{title}</title>\n      </Head>\n      <div className=\"flex h-screen flex-col items-center justify-center px-4 text-center\">\n        <Image src={imageSrc} alt={imageAlt} width={240} height={240} priority />\n        <div className=\"mb-6 mt-4 flex flex-col items-center justify-center gap-2\">\n          <h1 data-testid=\"not-found-title\" className=\"text-3xl font-semibold md:text-2xl\">\n            {title}\n          </h1>\n          {description && (\n            <p className=\"max-w-md whitespace-pre-line text-base text-muted-foreground\">\n              {description}\n            </p>\n          )}\n        </div>\n        <Button asChild variant={button.variant ?? 'default'}>\n          <a href={button.href}>{button.label}</a>\n        </Button>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/NotFoundPage.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport type { IButtonConfig } from './IllustrationPage';\nimport { IllustrationPage } from './IllustrationPage';\n\ntype NotFoundPageProps = {\n  title?: string;\n  description?: string;\n  button?: IButtonConfig;\n};\n\nexport const NotFoundPage: FC<NotFoundPageProps> = ({ title, description, button }) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <IllustrationPage\n      imageLightSrc=\"/images/layout/not-found-light.png\"\n      imageDarkSrc=\"/images/layout/not-found-dark.png\"\n      imageAlt=\"Not Found\"\n      title={title ?? t('system.notFound.title')}\n      description={description ?? t('system.notFound.description')}\n      button={button ?? { label: t('system.links.backToHome'), href: '/' }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/PaymentRequired.tsx",
    "content": "import { useTranslation } from 'next-i18next';\nimport type { FC } from 'react';\nimport type { IButtonConfig } from './IllustrationPage';\nimport { IllustrationPage } from './IllustrationPage';\n\ntype PaymentRequiredPageProps = {\n  title?: string;\n  description?: string;\n  button?: IButtonConfig;\n};\n\nexport const PaymentRequiredPage: FC<PaymentRequiredPageProps> = ({\n  title,\n  description,\n  button,\n}) => {\n  const { t } = useTranslation('common');\n\n  return (\n    <IllustrationPage\n      imageLightSrc=\"/images/layout/upgrade-light.png\"\n      imageDarkSrc=\"/images/layout/upgrade-dark.png\"\n      imageAlt=\"Payment Required\"\n      title={title ?? t('system.paymentRequired.title')}\n      description={description ?? t('system.paymentRequired.description')}\n      button={button ?? { label: t('system.links.backToHome'), href: '/' }}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/__tests__/ErrorPage.test.tsx",
    "content": "import { ErrorPage } from '@/features/system/pages';\nimport { render, screen } from '@/test-utils';\n\ndescribe('errorPage test', () => {\n  it('should contain error passed status code', async () => {\n    render(<ErrorPage statusCode={500} />);\n    expect(screen.getByTestId('error-status-code')).toHaveTextContent('500');\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/__tests__/NotFoundPage.test.tsx",
    "content": "import { NotFoundPage } from '@/features/system/pages';\nimport { render, screen } from '@/test-utils';\n\ndescribe('notFoundPage test', () => {\n  it('should contain passed title', async () => {\n    render(<NotFoundPage title={'404 - Not found'} />);\n    expect(screen.getByTestId('not-found-title')).toHaveTextContent('404 - Not found');\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/features/system/pages/index.ts",
    "content": "export { IllustrationPage } from './IllustrationPage';\nexport type { IButtonConfig, IIllustrationPageProps } from './IllustrationPage';\nexport { NotFoundPage } from './NotFoundPage';\nexport { ErrorPage } from './ErrorPage';\nexport { ForbiddenPage } from './ForbiddenPage';\nexport { PaymentRequiredPage } from './PaymentRequired';\nexport { HttpErrorPage } from './HttpErrorPage';\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/emoji-color.ts",
    "content": "function getEmojiAverageColor(\n  emoji: string,\n  size: number = 64\n): { r: number; g: number; b: number } {\n  const canvas = document.createElement('canvas');\n  const context = canvas.getContext('2d');\n\n  if (!context) {\n    throw new Error('No canvas context');\n  }\n\n  canvas.width = size;\n  canvas.height = size;\n\n  context.clearRect(0, 0, size, size);\n\n  context.font = `${Math.floor(size * 0.8)}px Arial`;\n  context.textAlign = 'center';\n  context.textBaseline = 'middle';\n\n  context.fillText(emoji, size / 2, size / 2);\n\n  const imageData = context.getImageData(0, 0, size, size);\n  const pixels = imageData.data;\n\n  let totalR = 0;\n  let totalG = 0;\n  let totalB = 0;\n  let count = 0;\n\n  for (let i = 0; i < pixels.length; i += 4) {\n    const r = pixels[i];\n    const g = pixels[i + 1];\n    const b = pixels[i + 2];\n    const a = pixels[i + 3];\n\n    if (a > 0) {\n      totalR += r;\n      totalG += g;\n      totalB += b;\n      count++;\n    }\n  }\n\n  if (count === 0) {\n    return { r: 0, g: 0, b: 0 };\n  }\n\n  const avgR = Math.round(totalR / count);\n  const avgG = Math.round(totalG / count);\n  const avgB = Math.round(totalB / count);\n\n  return { r: avgR, g: avgG, b: avgB };\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n  return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;\n}\n\nexport function getEmojiColor(emoji: string, size: number = 64): string {\n  try {\n    const { r, g, b } = getEmojiAverageColor(emoji, size);\n    return rgbToHex(r, g, b);\n  } catch (error) {\n    return '#000000';\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/ensureLogin.ts",
    "content": "import type { ParsedUrlQuery } from 'querystring';\nimport { HttpError, isAnonymous } from '@teable/core';\nimport type {\n  GetServerSidePropsContext,\n  GetServerSidePropsResult,\n  PreviewData,\n  GetServerSideProps as NextGetServerSideProps,\n} from 'next';\nimport { getUserMe } from '@/backend/api/rest/get-user';\nimport { providersAll } from '@/features/auth/components/SocialAuth';\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype GetServerSideProps<\n  P extends { [key: string]: any } = { [key: string]: any },\n  Q extends ParsedUrlQuery = ParsedUrlQuery,\n  D extends PreviewData = PreviewData,\n> = (context: GetServerSidePropsContext<Q, D>) => Promise<GetServerSidePropsResult<P>>;\n\nexport default function ensureLogin<P extends { [key: string]: any }>(\n  handler: GetServerSideProps<P, ParsedUrlQuery, PreviewData>,\n  isLoginPage?: boolean\n): NextGetServerSideProps<P> {\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  return async (context: GetServerSidePropsContext) => {\n    const req = context.req;\n    let props: { [key: string]: any } = {};\n    try {\n      const user = await getUserMe(req?.headers.cookie);\n      props['user'] = user;\n      // User is logged in, redirect to home page if on login page\n      if (!isAnonymous(user?.id) && isLoginPage) {\n        const redirect = context.query.redirect;\n        let destination = typeof redirect === 'string' ? redirect : '/space';\n\n        const via = context.query.via;\n        if (typeof via === 'string' && via) {\n          const separator = destination.includes('?') ? '&' : '?';\n          destination = `${destination}${separator}via=${encodeURIComponent(via)}`;\n        }\n\n        return {\n          redirect: {\n            destination,\n            permanent: false,\n          },\n        };\n      }\n      // User is not logged in, redirect to social auth if on login page\n      if (isLoginPage) {\n        const result = redirectSocialAuth(req);\n        if (result) {\n          return result;\n        }\n      }\n    } catch (error) {\n      if (error instanceof HttpError) {\n        if (isLoginPage) {\n          // User is not logged in, handle login page\n          return redirectSocialAuth(req) || handler(context);\n        }\n        if (error.status < 500 && error.status >= 400) {\n          // User is not logged in, redirect to login page\n          const redirect = encodeURIComponent(req?.url || '');\n          const query = redirect ? `redirect=${redirect}` : '';\n          return {\n            redirect: {\n              destination: `/auth/login?${query}`,\n              permanent: false,\n            },\n          };\n        }\n      }\n\n      console.error('ensureLogin: ', error);\n      // Workaround for https://github.com/zeit/next.js/issues/8592\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      props['err'] = (error as any)?.message;\n    }\n\n    const res = await handler(context);\n    if ('props' in res) {\n      props = {\n        ...(await res.props),\n        ...props,\n      };\n    }\n\n    return {\n      ...res,\n      props: props as P,\n    };\n  };\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// Redirect to social auth if password login is disabled and only one provider is available\nfunction redirectSocialAuth(req: GetServerSidePropsContext['req']) {\n  const redirect = new URLSearchParams(req?.url?.split('?')[1] ?? '').get('redirect');\n  const envProviders = process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [];\n  const envPasswordLoginDisabled = process.env.PASSWORD_LOGIN_DISABLED === 'true';\n  if (envPasswordLoginDisabled && envProviders.length === 1) {\n    const provider = providersAll.find((provider) => provider.id === envProviders[0]);\n\n    if (provider?.authUrl)\n      return {\n        redirect: {\n          destination: redirect\n            ? `${provider.authUrl}?redirect_uri=${encodeURIComponent(redirect)}`\n            : provider.authUrl,\n          permanent: false,\n        },\n      };\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/get-brand.ts",
    "content": "import { BillingProductLevel } from '@teable/openapi';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\n\nexport async function getBrand(ssrApi: SsrApi) {\n  if (process.env.NEXT_BUILD_ENV_EDITION?.toLowerCase() === 'ee') {\n    const [usage, publicSetting] = await Promise.all([\n      ssrApi.getInstanceUsage(),\n      ssrApi.getPublicSetting(),\n    ]);\n\n    if (usage.level === BillingProductLevel.Enterprise) {\n      return {\n        brandName: publicSetting.brandName,\n        logoUrl: publicSetting.brandLogo,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/handleBase.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { QueryClient } from '@tanstack/react-query';\nimport type { IGetBaseVo } from '@teable/openapi';\nimport { IS_TEMPLATE_HEADER } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\n\nexport default async function handleBase<T extends SsrApi = SsrApi>(\n  baseId: string,\n  ssrApi: T,\n  queryClient: QueryClient\n): Promise<IGetBaseVo> {\n  const base = await queryClient.fetchQuery({\n    queryKey: ReactQueryKeys.base(baseId),\n    queryFn: ({ queryKey }) => ssrApi.getBaseById(queryKey[1]),\n  });\n  const templateHeader = base?.template?.headers;\n  if (templateHeader) {\n    ssrApi.disableLastVisit = true;\n    ssrApi.axios.interceptors.request.use((config) => {\n      config.headers[IS_TEMPLATE_HEADER] = templateHeader;\n      return config;\n    });\n  }\n  return base;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/I18nNamespace.types.ts",
    "content": "import type { CustomTypeOptions } from 'i18next';\n\nexport type I18nNamespace = keyof CustomTypeOptions['resources'];\n\n/**\n * Helper to get fully typed namespaced keys\n */\nexport type I18nActiveNamespaces<NamespacesUnion extends I18nNamespace> = Extract<\n  I18nNamespace,\n  NamespacesUnion\n>[];\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/acceptHeader.ts",
    "content": "/**\n * From NextJS source code\n * https://github.com/vercel/next.js/blob/ef81fc3f8d/packages/next/server/accept-header.ts\n */\n\ninterface ISelection {\n  pos: number;\n  pref?: number;\n  q: number;\n  token: string;\n}\n\ninterface IOptions {\n  prefixMatch?: boolean;\n  type: 'accept-language';\n}\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nfunction parse(raw: string, preferences: string[] | undefined, options: IOptions) {\n  const lowers = new Map<string, { orig: string; pos: number }>();\n  const header = raw.replace(/[ \\t]/g, '');\n\n  if (preferences) {\n    let pos = 0;\n    for (const preference of preferences) {\n      const lower = preference.toLowerCase();\n      lowers.set(lower, { orig: preference, pos: pos++ });\n      if (options.prefixMatch) {\n        const parts = lower.split('-');\n        while ((parts.pop(), parts.length > 0)) {\n          const joined = parts.join('-');\n          if (!lowers.has(joined)) {\n            lowers.set(joined, { orig: preference, pos: pos++ });\n          }\n        }\n      }\n    }\n  }\n\n  const parts = header.split(',');\n  const selections: ISelection[] = [];\n  const map = new Set<string>();\n  const fallbackLocales: string[] = [];\n  for (let i = 0; i < parts.length; ++i) {\n    const part = parts[i];\n    if (!part) {\n      continue;\n    }\n    const prefix = part.split('-')[0];\n    if (prefix && lowers.has(prefix)) {\n      fallbackLocales.push(prefix);\n    }\n\n    const params = part.split(';');\n    if (params.length > 2) {\n      throw new Error(`Invalid ${options.type} header`);\n    }\n\n    const token = params[0].toLowerCase();\n    if (!token) {\n      throw new Error(`Invalid ${options.type} header`);\n    }\n\n    const selection: ISelection = { token, pos: i, q: 1 };\n    if (preferences && lowers.has(token)) {\n      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n      selection.pref = lowers.get(token)!.pos;\n    }\n\n    map.add(selection.token);\n\n    if (params.length === 2) {\n      const q = params[1];\n      const [key, value] = q.split('=');\n\n      if (!value || (key !== 'q' && key !== 'Q')) {\n        throw new Error(`Invalid ${options.type} header`);\n      }\n\n      const score = parseFloat(value);\n      if (score === 0) {\n        continue;\n      }\n\n      if (Number.isFinite(score) && score <= 1 && score >= 0.001) {\n        selection.q = score;\n      }\n    }\n\n    selections.push(selection);\n  }\n\n  selections.sort((a, b) => {\n    if (b.q !== a.q) {\n      return b.q - a.q;\n    }\n\n    if (b.pref !== a.pref) {\n      if (a.pref === undefined) {\n        return 1;\n      }\n\n      if (b.pref === undefined) {\n        return -1;\n      }\n\n      return a.pref - b.pref;\n    }\n\n    return a.pos - b.pos;\n  });\n\n  const values = selections.map((selection) => selection.token);\n  if (!preferences || !preferences.length) {\n    return values;\n  }\n\n  const preferred: string[] = [];\n  for (const selection of values) {\n    if (selection === '*') {\n      for (const [preference, value] of lowers) {\n        if (!map.has(preference)) {\n          preferred.push(value.orig);\n        }\n      }\n    } else {\n      const lower = selection.toLowerCase();\n      if (lowers.has(lower)) {\n        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n        preferred.push(lowers.get(lower)!.orig);\n      }\n    }\n  }\n\n  return preferred.length ? preferred : fallbackLocales;\n}\n\nexport function acceptLanguage(header = '', preferences?: string[]) {\n  return (\n    parse(header, preferences, {\n      type: 'accept-language',\n      prefixMatch: true,\n    })[0] || ''\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/getLocale.ts",
    "content": "import type { NextRequest } from 'next/server';\nimport { acceptLanguage } from './acceptHeader';\n\nexport interface I18NConfig {\n  defaultLocale: string;\n  localeDetection?: false;\n  locales: string[];\n}\n\ninterface IOptions {\n  req: NextRequest;\n  i18n: I18NConfig;\n}\n\nfunction getAcceptPreferredLocale(\n  i18n: I18NConfig,\n  headers?: { [key: string]: string | string[] | undefined }\n) {\n  const acceptLangStr = headers?.['accept-language'];\n  if (acceptLangStr && !Array.isArray(acceptLangStr)) {\n    try {\n      return acceptLanguage(acceptLangStr, i18n.locales);\n    } catch (err) {\n      return;\n    }\n  }\n}\n\nfunction getLocaleFromCookie(req: NextRequest, locales: string[]) {\n  const nextLocale = req.cookies.get('NEXT_LOCALE')?.value;\n  return nextLocale\n    ? locales.find((locale: string) => nextLocale.toLowerCase() === locale.toLowerCase())\n    : undefined;\n}\n\nfunction detectLocale({\n  i18n,\n  req,\n  preferredLocale,\n}: {\n  i18n: I18NConfig;\n  req: NextRequest;\n  preferredLocale?: string;\n}) {\n  return getLocaleFromCookie(req, i18n.locales) || preferredLocale || i18n.defaultLocale;\n}\n\nexport function getLocaleDetection({ req, i18n }: IOptions) {\n  if (i18n && i18n.localeDetection !== false) {\n    const headers = Object.fromEntries(req.headers);\n    const preferredLocale = getAcceptPreferredLocale(i18n, headers);\n    const detectedLocale = detectLocale({\n      i18n: i18n,\n      req,\n      preferredLocale,\n    });\n\n    return detectedLocale.toLowerCase();\n  }\n  return i18n.defaultLocale;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/getServerSideTranslations.ts",
    "content": "/**\n * Retrieve translations on server-side, wraps next-i18next.serverSideTranslations\n * to allow further customizations.\n */\nimport type { SSRConfig, UserConfig } from 'next-i18next';\nimport { serverSideTranslations } from 'next-i18next/serverSideTranslations';\nimport type { I18nNamespace } from '@/lib/i18n/I18nNamespace.types';\n\nexport const getServerSideTranslations = async (\n  locale: string,\n  namespacesRequired?: I18nNamespace[] | I18nNamespace | undefined,\n  configOverride?: UserConfig | null,\n  extraLocales?: string[] | false\n): Promise<SSRConfig> => {\n  // Let serverSideTranslations auto-detect the nearest next-i18next.config.js\n  // This allows EE apps to use their own config without explicitly passing it\n  return serverSideTranslations(locale, namespacesRequired, configOverride, extraLocales);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/getTranslationsProps.ts",
    "content": "import type { GetServerSidePropsContext } from 'next';\nimport type { UserConfig } from 'next-i18next';\nimport { getServerSideTranslations } from './getServerSideTranslations';\nimport type { I18nNamespace } from './I18nNamespace.types';\n\nexport const CookieLocaleKey = 'X-Server-Locale';\n\nexport const getTranslationsProps = (\n  context: GetServerSidePropsContext,\n  i18nNamespaces: I18nNamespace[] | I18nNamespace | undefined,\n  configOverride?: UserConfig | null\n) => {\n  const locale = context.res.getHeader(CookieLocaleKey) as string | undefined;\n  return getServerSideTranslations(locale || 'en', i18nNamespaces, configOverride);\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/helper.ts",
    "content": "import { acceptLanguage } from './acceptHeader';\n\nexport const getLocaleFromCookie = (cookie: string): string | null => {\n  if (!cookie) return null;\n  const match = cookie.match(/NEXT_LOCALE=([^;]+)/);\n  return match?.[1] || null;\n};\n\nexport const getLocaleFromBrowser = (): string => {\n  if (typeof navigator === 'undefined') return 'en';\n  const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage;\n  if (!browserLang) return 'en';\n  // Extract primary language code (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en')\n  return browserLang.split('-')[0];\n};\n\nexport const getLocaleFromAcceptLanguage = (\n  acceptLanguageHeader: string | undefined,\n  supportedLocales: string[]\n): string | null => {\n  if (!acceptLanguageHeader) return null;\n  try {\n    const locale = acceptLanguage(acceptLanguageHeader, supportedLocales);\n    return locale || null;\n  } catch {\n    return null;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/index.ts",
    "content": "export { getServerSideTranslations } from './getServerSideTranslations';\nexport type { I18nActiveNamespaces, I18nNamespace } from './I18nNamespace.types';\nexport * from './getTranslationsProps';\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/i18n/staticPageLocale.ts",
    "content": "import { getLocaleFromBrowser, getLocaleFromCookie } from './helper';\nexport * from './helper';\n\ntype LocaleLoader = () => Promise<{ default: Record<string, unknown> }>;\n\nexport const detectStaticLocale = (cookie: string): string => {\n  return getLocaleFromCookie(cookie) ?? getLocaleFromBrowser();\n};\n\nexport const commonLocaleLoaders: Record<string, LocaleLoader> = {\n  en: () => import('@teable/common-i18n/src/locales/en/common.json'),\n  it: () => import('@teable/common-i18n/src/locales/it/common.json'),\n  zh: () => import('@teable/common-i18n/src/locales/zh/common.json'),\n  fr: () => import('@teable/common-i18n/src/locales/fr/common.json'),\n  ja: () => import('@teable/common-i18n/src/locales/ja/common.json'),\n  ru: () => import('@teable/common-i18n/src/locales/ru/common.json'),\n  de: () => import('@teable/common-i18n/src/locales/de/common.json'),\n  uk: () => import('@teable/common-i18n/src/locales/uk/common.json'),\n  tr: () => import('@teable/common-i18n/src/locales/tr/common.json'),\n  es: () => import('@teable/common-i18n/src/locales/es/common.json'),\n};\n\nexport const loadCommonTranslations = async (locale: string) => {\n  try {\n    const loader = commonLocaleLoaders[locale] ?? commonLocaleLoaders.en;\n    return (await loader()).default;\n  } catch {\n    return (await commonLocaleLoaders.en()).default;\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/server-env.ts",
    "content": "import React from 'react';\n\nexport interface IServerEnv {\n  driver?: string;\n  brandName?: string;\n  brandLogo?: string;\n  templateSiteLink?: string;\n  microsoftClarityId?: string;\n  umamiWebSiteId?: string;\n  gaId?: string;\n  googleAdsConversionId?: string;\n  umamiUrl?: string;\n  sentryDsn?: string;\n  socialAuthProviders?: string[];\n  storage?: {\n    provider?: 'local' | 'minio' | 's3';\n    prefix?: string;\n    publicBucket?: string;\n    publicUrl?: string;\n  };\n  edition?: string;\n  passwordLoginDisabled?: boolean;\n  // global settings\n  globalSettings?: {\n    disallowSignUp?: boolean;\n    disallowSpaceCreation?: boolean;\n    disallowSpaceInvitation?: boolean;\n    aiConfig?: {\n      enable: boolean;\n    };\n  };\n  enableDomainEmail?: boolean;\n  maxSearchFieldCount?: number;\n  chatContextAttachmentSize?: number;\n  publicOrigin?: string;\n  publicDatabaseProxy?: string;\n  changeEmailSendMailCodeRate?: number;\n  resetPasswordSendMailCodeRate?: number;\n  signupVerificationSendMailCodeRate?: number;\n  availableIntegrationProviders?: string[];\n  enableCanaryFeature?: boolean;\n  task?: {\n    maxTaskRows?: number;\n  };\n  trash?: {\n    retentionDays?: number;\n  };\n}\n\nexport const EnvContext = React.createContext<IServerEnv>({});\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/space-role-checker.ts",
    "content": "import type { QueryClient } from '@tanstack/react-query';\nimport type { IRole } from '@teable/core';\nimport type { IGetSpaceVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { ForbiddenError } from './withAuthSSR';\n\nexport const spaceRoleChecker = ({\n  queryClient,\n  spaceId,\n  roles,\n}: {\n  queryClient: QueryClient;\n  spaceId: string;\n  roles: IRole[];\n}) => {\n  const role = (queryClient.getQueryState(ReactQueryKeys.space(spaceId))?.data as IGetSpaceVo)\n    ?.role;\n\n  if (!roles.includes(role)) {\n    throw new ForbiddenError();\n  }\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/type.ts",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport type { NextPage } from 'next';\nimport type { SSRConfig } from 'next-i18next';\nimport type { ReactElement, ReactNode } from 'react';\n\nexport type IBasePageProps = SSRConfig & {\n  dehydratedState?: DehydratedState;\n  [p: string]: unknown;\n};\n\nexport type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  getLayout?: (page: ReactElement, appProps: any) => ReactNode;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/view-pages-data.ts",
    "content": "import type { IFieldVo, IRecord, IViewVo } from '@teable/core';\nimport type { IGroupPointsVo, ITableVo } from '@teable/openapi';\nimport type { SsrApi } from '@/backend/api/rest/ssr-api';\n\nexport interface IViewPageProps {\n  tableServerData?: ITableVo[];\n  fieldServerData: IFieldVo[];\n  viewServerData: IViewVo[];\n  recordsServerData: { records: IRecord[] };\n  recordServerData?: IRecord;\n  groupPointsServerDataMap?: { [viewId: string]: IGroupPointsVo | null };\n}\n\nexport const getViewPageServerData = async (\n  ssrApi: SsrApi,\n  baseId: string,\n  tableId: string,\n  viewId: string\n): Promise<IViewPageProps | undefined> => {\n  const api = ssrApi;\n  const tableResult = await api.getTable(baseId, tableId, viewId);\n  if (tableResult) {\n    const { fields, views, records, extra } = tableResult;\n\n    return {\n      fieldServerData: fields,\n      viewServerData: views,\n      recordsServerData: { records },\n      groupPointsServerDataMap: {\n        [viewId]: extra?.groupPoints ?? null,\n      },\n    };\n  }\n\n  return undefined;\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/withAuthSSR.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { ParsedUrlQuery } from 'querystring';\nimport { HttpError, type IHttpError } from '@teable/core';\nimport { isUndefined, omitBy } from 'lodash';\nimport type {\n  GetServerSidePropsContext,\n  GetServerSidePropsResult,\n  PreviewData,\n  GetServerSideProps as NextGetServerSideProps,\n} from 'next';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport { systemConfig } from '@/features/i18n/system.config';\nimport { getTranslationsProps } from '@/lib/i18n/getTranslationsProps';\n\nexport type SSRHttpError = { httpError: IHttpError };\n\nexport class ForbiddenError extends HttpError {\n  constructor(message = 'Forbidden') {\n    super(message, 403);\n  }\n}\n\nexport type GetServerSideProps<\n  P extends { [key: string]: any } = { [key: string]: any },\n  Q extends ParsedUrlQuery = ParsedUrlQuery,\n  D extends PreviewData = PreviewData,\n  T extends SsrApi = SsrApi,\n> = (\n  context: GetServerSidePropsContext<Q, D>,\n  ssrApi: T\n) => Promise<GetServerSidePropsResult<P | SSRHttpError>>;\n\nexport default function withAuthSSR<\n  P extends { [key: string]: any } = { [key: string]: any },\n  T extends SsrApi = SsrApi,\n>(\n  handler: GetServerSideProps<P, ParsedUrlQuery, PreviewData, T>,\n  ssrClass: new () => T = SsrApi as new () => T\n): NextGetServerSideProps<P | SSRHttpError> {\n  return async (context: GetServerSidePropsContext) => {\n    const req = context.req;\n    try {\n      const ssrApi = new ssrClass();\n      ssrApi.axios.defaults.headers['cookie'] = req.headers.cookie || '';\n      return await handler(context, ssrApi);\n    } catch (e) {\n      const error = e as IHttpError;\n      if (error.status === 401) {\n        return {\n          redirect: {\n            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n            destination: `/auth/login?redirect=${encodeURIComponent(req.url!)}`,\n            permanent: false,\n          },\n        };\n      }\n      if (error.status === 402 || error.status === 403) {\n        context.res.statusCode = error.status;\n        return {\n          props: {\n            ...(await getTranslationsProps(context, systemConfig.i18nNamespaces)),\n            httpError: omitBy(\n              {\n                message: error.message,\n                status: error.status,\n                code: error.code,\n                data: error.data,\n              },\n              isUndefined\n            ) as IHttpError,\n          },\n        };\n      }\n      if (error.status == 404) {\n        return {\n          notFound: true,\n        };\n      }\n      console.error(error);\n      throw error;\n    }\n  };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/lib/withEnv.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { ParsedUrlQuery } from 'querystring';\nimport { parseDsn } from '@teable/core';\nimport { isUndefined, omitBy, toNumber } from 'lodash';\nimport ms from 'ms';\nimport type {\n  GetServerSidePropsContext,\n  GetServerSidePropsResult,\n  PreviewData,\n  GetServerSideProps as NextGetServerSideProps,\n} from 'next';\n\ntype GetServerSideProps<\n  P extends { [key: string]: any } = { [key: string]: any },\n  Q extends ParsedUrlQuery = ParsedUrlQuery,\n  D extends PreviewData = PreviewData,\n> = (context: GetServerSidePropsContext<Q, D>) => Promise<GetServerSidePropsResult<P>>;\n\nexport default function withEnv<P extends { [key: string]: any }>(\n  handler: GetServerSideProps<P, ParsedUrlQuery, PreviewData>\n): NextGetServerSideProps<P> {\n  return async (context: GetServerSidePropsContext) => {\n    const { driver } = parseDsn(process.env.PRISMA_DATABASE_URL as string);\n    const envMaxSearchFieldCount = toNumber(process.env.MAX_SEARCH_FIELD_COUNT);\n    const task = {\n      maxTaskRows: toNumber(process.env.MAX_TASK_ROWS),\n    };\n    const storage = {\n      provider: process.env.BACKEND_STORAGE_PROVIDER ?? 'local',\n      prefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN,\n      publicBucket: process.env.BACKEND_STORAGE_PUBLIC_BUCKET ?? 'public',\n      publicUrl: process.env.BACKEND_STORAGE_PUBLIC_URL,\n    };\n    const trashRetention = process.env.TRASH_RETENTION ?? '30d';\n    const trash = {\n      retentionDays: ms(trashRetention) / ms('1d'),\n    };\n    const env = omitBy(\n      {\n        driver,\n        templateSiteLink: process.env.TEMPLATE_SITE_LINK,\n        microsoftClarityId: process.env.MICROSOFT_CLARITY_ID,\n        umamiUrl: process.env.UMAMI_URL,\n        umamiWebSiteId: process.env.UMAMI_WEBSITE_ID,\n        gaId: process.env.GA_ID,\n        googleAdsConversionId: process.env.GOOGLE_ADS_CONVERSION_ID,\n        sentryDsn: process.env.SENTRY_DSN,\n        socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(','),\n        storage: omitBy(storage, isUndefined),\n        passwordLoginDisabled: process.env.PASSWORD_LOGIN_DISABLED === 'true' ? true : undefined,\n        publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY,\n        // default to Infinity, return undefined causing the value will be transformed to null when json-stringify\n        maxSearchFieldCount:\n          isNaN(envMaxSearchFieldCount) || envMaxSearchFieldCount === Infinity\n            ? undefined\n            : envMaxSearchFieldCount,\n        publicOrigin: process.env.PUBLIC_ORIGIN,\n        enableCanaryFeature: process.env.ENABLE_CANARY_FEATURE === 'true' ? true : undefined,\n        task,\n        trash,\n      },\n      isUndefined\n    );\n    const res = await handler(context);\n    if ('props' in res) {\n      return {\n        ...res,\n        props: {\n          ...(await res.props),\n          env,\n        },\n      };\n    }\n    return {\n      ...res,\n      props: {\n        env,\n      },\n    };\n  };\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/402.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport { systemConfig } from '@/features/i18n/system.config';\nimport { PaymentRequiredPage } from '@/features/system/pages';\nimport { getTranslationsProps } from '@/lib/i18n';\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  return {\n    props: {\n      ...(await getTranslationsProps(context, systemConfig.i18nNamespaces)),\n    },\n  };\n};\n\nexport default function Custom402() {\n  return <PaymentRequiredPage />;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/403.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport { systemConfig } from '@/features/i18n/system.config';\nimport { ForbiddenPage } from '@/features/system/pages';\nimport { getTranslationsProps } from '@/lib/i18n';\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  return {\n    props: {\n      ...(await getTranslationsProps(context, systemConfig.i18nNamespaces)),\n    },\n  };\n};\n\nexport default function Custom403() {\n  return <ForbiddenPage />;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/404.tsx",
    "content": "import type { GetStaticProps } from 'next';\nimport { useTranslation } from 'next-i18next';\nimport { serverSideTranslations } from 'next-i18next/serverSideTranslations';\nimport { useEffect, useState } from 'react';\nimport { systemConfig } from '@/features/i18n/system.config';\nimport { NotFoundPage } from '@/features/system/pages';\nimport {\n  commonLocaleLoaders,\n  detectStaticLocale,\n  loadCommonTranslations,\n} from '@/lib/i18n/staticPageLocale';\n\nexport const getStaticProps: GetStaticProps = async () => {\n  return {\n    props: {\n      ...(await serverSideTranslations('en', systemConfig.i18nNamespaces)),\n    },\n  };\n};\n\nexport default function Custom404() {\n  const { i18n } = useTranslation();\n  const [isReady, setIsReady] = useState(false);\n\n  useEffect(() => {\n    const detectedLocale = detectStaticLocale(document.cookie);\n    const validLocale = commonLocaleLoaders[detectedLocale] ? detectedLocale : 'en';\n\n    // If locale matches current i18n locale, ready immediately\n    if (validLocale === i18n.language) {\n      setIsReady(true);\n      return;\n    }\n\n    // Load translations for detected locale and update i18n\n    loadCommonTranslations(validLocale)\n      .then((translations) => {\n        i18n.addResourceBundle(validLocale, 'common', translations, true, true);\n        return i18n.changeLanguage(validLocale);\n      })\n      .catch((error) => {\n        // Ensure UI remains usable even if translation loading or language change fails\n        console.error('Failed to load translations or change language for 404 page:', error);\n      })\n      .finally(() => {\n        setIsReady(true);\n      });\n  }, [i18n]);\n\n  return (\n    <div\n      style={{\n        opacity: isReady ? 1 : 0,\n        transition: 'opacity 0.15s ease-in-out',\n      }}\n    >\n      <NotFoundPage />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_app.tsx",
    "content": "import * as Sentry from '@sentry/nextjs';\nimport type { IHttpError } from '@teable/core';\nimport type { IUser } from '@teable/sdk';\nimport dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport type { AppContext, AppProps as NextAppProps } from 'next/app';\nimport App from 'next/app';\nimport Head from 'next/head';\nimport { appWithTranslation } from 'next-i18next';\nimport { useEffect } from 'react';\nimport { Guide } from '@/components/Guide';\nimport { GoogleAnalytics, MicrosoftClarity, Umami } from '@/components/Metrics';\nimport RouterProgressBar from '@/components/RouterProgress';\nimport { SideBarScript } from '@/features/app/components/sidebar/SideBarScript';\nimport { HttpErrorPage } from '@/features/system/pages';\nimport type { IServerEnv } from '@/lib/server-env';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport { colors } from '@/themes/colors';\nimport { getColorsCssVariablesText } from '@/themes/utils';\nimport nextI18nextConfig from '../../next-i18next.config.js';\nimport { AppProviders } from '../AppProviders';\nimport '@glideapps/glide-data-grid/dist/index.css';\nimport 'react-grid-layout/css/styles.css';\nimport 'react-resizable/css/styles.css';\nimport 'reactflow/dist/style.css';\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\n/**\n * Import global styles, global css or polyfills here\n * i.e.: import '@/assets/theme/style.scss'\n */\nimport '../styles/global.css';\n\nimport '@fontsource-variable/inter';\n\n// Workaround for https://github.com/zeit/next.js/issues/8592\nexport type AppProps<T> = NextAppProps<T> & {\n  /** Will be defined only is there was an error */\n  err?: Error;\n};\n\ntype AppPropsWithLayout = AppProps<{\n  user?: IUser;\n  env?: IServerEnv;\n  err?: Error;\n  httpError?: IHttpError;\n}> & {\n  Component: NextPageWithLayout;\n};\n\n/**\n * @link https://nextjs.org/docs/advanced-features/custom-app\n */\nconst MyApp = (appProps: AppPropsWithLayout) => {\n  const { Component, err: nextJsError, pageProps } = appProps;\n  const { user, env = {}, err: pageError, httpError } = pageProps;\n  // Use the layout defined at the page level, if available\n  const getLayout = Component.getLayout ?? ((page) => page);\n  useEffect(() => {\n    Sentry.setUser(user ? { id: user.id, email: user.email } : null);\n  }, [user]);\n\n  return (\n    <>\n      <AppProviders env={env}>\n        <Head>\n          <meta\n            name=\"viewport\"\n            content=\"width=device-width,viewport-fit=cover, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no\"\n          />\n          <style>{getColorsCssVariablesText(colors)}</style>\n        </Head>\n        <MicrosoftClarity clarityId={env.microsoftClarityId} user={user} />\n        <Umami umamiWebSiteId={env.umamiWebSiteId} umamiUrl={env.umamiUrl} user={user} />\n        <GoogleAnalytics gaId={env.gaId} user={user} />\n        <SideBarScript />\n        <script\n          dangerouslySetInnerHTML={{\n            __html: `\n              window.version=\"${process.env.NEXT_PUBLIC_BUILD_VERSION ?? 'develop'}\";\n              window.__TE__=${JSON.stringify(env)};\n            `,\n          }}\n        />\n        {/* Workaround for https://github.com/vercel/next.js/issues/8592 */}\n        {httpError && [402, 403].includes(httpError.status) ? (\n          <HttpErrorPage httpError={httpError} />\n        ) : (\n          getLayout(<Component {...pageProps} err={nextJsError || pageError} />, {\n            ...pageProps,\n          })\n        )}\n      </AppProviders>\n      {user && <Guide user={user} />}\n      <RouterProgressBar />\n    </>\n  );\n};\n\n/**\n * Generally don't enable getInitialProp if you don't need to,\n * all your pages will be served server-side (no static optimizations).\n */\n\nMyApp.getInitialProps = async (appContext: AppContext) => {\n  // calls page's `getInitialProps` and fills `appProps.pageProps`\n  const appProps = await App.getInitialProps(appContext);\n  return { ...appProps };\n};\n\nexport default appWithTranslation(MyApp, {\n  ...nextI18nextConfig,\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_document.tsx",
    "content": "import type { DocumentProps } from 'next/document';\nimport Document, { Html, Main, Head, NextScript } from 'next/document';\n\ntype Props = DocumentProps & {\n  emotionStyleTags?: string[];\n};\n\nclass MyDocument extends Document<Props> {\n  render() {\n    const locale = this.props.locale;\n\n    return (\n      <Html lang={locale}>\n        <Head>\n          <meta charSet=\"utf-8\" />\n          <link\n            rel=\"apple-touch-icon\"\n            sizes=\"180x180\"\n            href=\"/images/favicon/apple-touch-icon.png\"\n          />\n          <link\n            rel=\"icon\"\n            type=\"image/png\"\n            sizes=\"32x32\"\n            href=\"/images/favicon/favicon-32x32.png\"\n          />\n          <link\n            rel=\"icon\"\n            type=\"image/png\"\n            sizes=\"16x16\"\n            href=\"/images/favicon/favicon-16x16.png\"\n          />\n          <link rel=\"manifest\" href=\"/images/favicon/site.webmanifest\" />\n          <link rel=\"mask-icon\" href=\"/images/favicon/safari-pinned-tab.svg\" color=\"#5bbad5\" />\n          <link rel=\"shortcut icon\" href=\"/images/favicon/favicon.svg\" type=\"image/svg+xml\" />\n          <meta name=\"msapplication-TileColor\" content=\"#da532c\" />\n          <meta name=\"msapplication-config\" content=\"/images/favicon/browserconfig.xml\" />\n        </Head>\n        <body>\n          <Main />\n          <NextScript />\n        </body>\n      </Html>\n    );\n  }\n}\n\n// Example to process graceful shutdowns (ie: closing db or other resources)\n// https://nextjs.org/docs/deployment#manual-graceful-shutdowns\nif (process.env.NEXT_MANUAL_SIG_HANDLE) {\n  // this should be added in your custom _document\n  process.on('SIGTERM', () => {\n    console.log('Received SIGTERM: ', 'cleaning up');\n    process.exit(0);\n  });\n\n  process.on('SIGINT', () => {\n    console.log('Received SIGINT: ', 'cleaning up');\n    process.exit(0);\n  });\n}\n\nexport default MyDocument;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_error.tsx",
    "content": "/**\n * Typescript class based component for custom-error\n * @link https://nextjs.org/docs/advanced-features/custom-error-page\n */\n\nimport { captureException as sentryCaptureException, flush as sentryFlush } from '@sentry/nextjs';\nimport type { NextPage, NextPageContext } from 'next';\nimport NextErrorComponent from 'next/error';\nimport type { ErrorProps } from 'next/error';\nimport { ErrorPage } from '@/features/system/pages';\nimport {\n  commonLocaleLoaders,\n  loadCommonTranslations,\n  getLocaleFromCookie,\n  getLocaleFromAcceptLanguage,\n} from '@/lib/i18n/staticPageLocale';\n\nconst sentryIgnoredStatusCodes: number[] = [404, 410];\n\n// Adds HttpException to the list of possible error types.\ntype AugmentedError = NonNullable<NextPageContext['err']> | null;\ntype CustomErrorProps = {\n  err?: AugmentedError;\n  message?: string;\n  sentryErrorId?: string;\n  hasGetInitialPropsRun?: boolean;\n} & Omit<ErrorProps, 'err'>;\n\ntype AugmentedNextPageContext = Omit<NextPageContext, 'err'> & {\n  err: AugmentedError;\n};\n\n/**\n * The request to sentry might be blocked on the browser due to ad blockers, csrf...\n * Alternatively a good practice is to proxy the sentry in a nextjs api route, istio...\n * @see https://github.com/getsentry/sentry-javascript/issues/2916\n */\nconst sentryCaptureExceptionFailsafe = (err: Error | string): string | undefined => {\n  let browserSentryErrorId: string | undefined;\n  try {\n    browserSentryErrorId = sentryCaptureException(err);\n  } catch (e) {\n    const msg = `Couldn't send error to sentry, reason ${\n      e instanceof Error ? e.message : 'unknown'\n    }`;\n    console.error(msg);\n  }\n  return browserSentryErrorId;\n};\n\n/**\n * Flushing the request on the browser is not required and might fail with err:BLOCKED_BY_CLIENT\n * Possible causes vary, but the most common is that the request is blocked by ad-blockers or csrf rules.\n */\nconst sentryFlushServerSide = async (flushAfter: number) => {\n  if (typeof window === 'undefined') {\n    try {\n      await sentryFlush(flushAfter);\n    } catch (e) {\n      const msg = `Couldn't flush sentry, reason ${e instanceof Error ? e.message : 'unknown'}`;\n      console.error(msg);\n    }\n  }\n};\n\nconst CustomError: NextPage<CustomErrorProps> = (props) => {\n  const { statusCode, err, hasGetInitialPropsRun, sentryErrorId, message } = props;\n\n  let browserSentryErrorId: string | undefined;\n\n  if (!hasGetInitialPropsRun && err) {\n    // getInitialProps is not called in case of https://github.com/vercel/next.js/issues/8592.\n    // As a workaround, we pass err via _app.js so it can be captured\n    browserSentryErrorId = sentryCaptureExceptionFailsafe(err);\n    // Flushing is not required in this case as it only happens on the client\n  }\n  return (\n    <ErrorPage\n      error={err ?? undefined}\n      message={message}\n      errorId={sentryErrorId ?? browserSentryErrorId}\n      statusCode={statusCode}\n    />\n  );\n};\n\nCustomError.getInitialProps = async (context: AugmentedNextPageContext) => {\n  const { res, err, asPath, req } = context;\n\n  const supportedLocales = Object.keys(commonLocaleLoaders);\n  // Detect locale: prefer context.locale, fallback to cookie, then Accept-Language header, default to 'en'\n  const cookieLocale = getLocaleFromCookie(req?.headers?.cookie ?? '');\n  const acceptLangLocale = getLocaleFromAcceptLanguage(\n    req?.headers?.['accept-language'],\n    supportedLocales\n  );\n  const detectedLocale = context.locale || cookieLocale || acceptLangLocale || 'en';\n  const locale = commonLocaleLoaders[detectedLocale] ? detectedLocale : 'en';\n\n  const errorInitialProps = (await NextErrorComponent.getInitialProps({\n    res,\n    err,\n  } as NextPageContext)) as CustomErrorProps;\n\n  const resources = await loadCommonTranslations(locale);\n  Object.assign(errorInitialProps, {\n    _nextI18Next: {\n      initialI18nStore: {\n        [locale]: {\n          common: resources,\n        },\n      },\n      initialLocale: locale,\n      ns: ['common'],\n      userConfig: null,\n    },\n  });\n\n  // Workaround for https://github.com/vercel/next.js/issues/8592, mark when\n  // getInitialProps has run\n  errorInitialProps.hasGetInitialPropsRun = true;\n\n  // Returning early because we don't want to log ignored errors to Sentry.\n  if (typeof res?.statusCode === 'number' && sentryIgnoredStatusCodes.includes(res.statusCode)) {\n    return errorInitialProps;\n  }\n\n  // Running on the server, the response object (`res`) is available.\n  //\n  // Next.js will pass an error on the server if a page's data fetching methods\n  // threw or returned a Promise that rejected\n  //\n  // Running on the client (browser), Next.js will provide an error if:\n  //\n  //  - a page's `getInitialProps` threw or returned a Promise that rejected\n  //  - an exception was thrown somewhere in the React lifecycle (render,\n  //    componentDidMount, etc) that was caught by Next.js's React Error\n  //    Boundary. Read more about what types of exceptions are caught by Error\n  //    Boundaries: https://reactjs.org/docs/error-boundaries.html\n\n  if (err) {\n    errorInitialProps.sentryErrorId = sentryCaptureExceptionFailsafe(err);\n    // Flushing before returning is necessary if deploying to Vercel, see\n    // https://vercel.com/docs/platform/limits#streaming-responses\n    await sentryFlushServerSide(1_500);\n    return errorInitialProps;\n  }\n\n  // If this point is reached, getInitialProps was called without any\n  // information about what the error might be. This is unexpected and may\n  // indicate a bug introduced in Next.js, so record it in Sentry\n  errorInitialProps.sentryErrorId = sentryCaptureException(\n    new Error(`_error.js getInitialProps missing data at path: ${asPath}`)\n  );\n  await sentryFlushServerSide(1_500);\n  return errorInitialProps;\n};\n\nexport default CustomError;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_monitor/preview/error-page.tsx",
    "content": "import { ErrorPage } from '@/features/system/pages';\n\nconst exampleError = new Error('ErrorPage example error');\n\nexport default function ErrorPageRoute() {\n  return (\n    <ErrorPage\n      statusCode={500}\n      message={'ErrorPage preview'}\n      errorId={'xxxxx-xxxxx-xxxxx-xxxxx'}\n      error={exampleError}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_monitor/sentry/csr-page.tsx",
    "content": "import type { FC } from 'react';\nimport { useEffect, useState } from 'react';\n\nconst getAsyncError = async (): Promise<void> => {\n  throw new Error(\n    'Error purposely crafted for monitoring sentry (/pages/_monitor/sentry/csr-page.tsx)'\n  );\n};\n\nconst MonitorSentryCsrRoute: FC = () => {\n  const [error, setError] = useState<Error | null>(null);\n\n  useEffect(() => {\n    getAsyncError().catch((err) => setError(err));\n  }, []);\n\n  if (error) {\n    throw error;\n  }\n  return (\n    <div>\n      <h1>Unexpected error</h1>\n      <p>\n        If you see this message, it means that an error thrown in a static NextJs page wasn't caught\n        by the global error handler (pages/_error.tsx). This is a bug in the application and may\n        affect the ability to display error pages and log errors on Sentry. See the monitoring page\n        in /pages/_monitor/sentry/csr-page.tsx.\n      </p>\n    </div>\n  );\n};\n\nexport default MonitorSentryCsrRoute;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/_monitor/sentry/ssr-page.tsx",
    "content": "import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';\n\ntype Props = {\n  hasRunOnServer: boolean;\n};\n\nexport default function MonitorSentrySsrRoute(\n  _props: InferGetServerSidePropsType<typeof getServerSideProps>\n) {\n  return (\n    <div>\n      <h1>Unexpected error</h1>\n      <p>\n        If you see this message, it means that the an error thrown in the `getServerSideProps()`\n        function wasn't caught by the global error handler (pages/_error.tsx). This is a bug in the\n        application and may affect the ability to display error pages and log errors on Sentry. See\n        the monitoring page in /pages/_monitor/sentry/ssr-page.tsx.\n      </p>\n    </div>\n  );\n}\n\n/**\n * Always throws an error on purpose for monitoring\n */\nexport const getServerSideProps: GetServerSideProps<Props> = async (_context) => {\n  throw new Error(\n    'Error purposely crafted for monitoring sentry (/pages/_monitor/sentry/ssr-page.tsx)'\n  );\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/admin/setting.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport type { ISettingPageProps } from '@/features/app/blocks/admin';\nimport { SettingPage } from '@/features/app/blocks/admin';\nimport { AdminLayout } from '@/features/app/layouts/AdminLayout';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR, { ForbiddenError } from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Setting: NextPageWithLayout<ISettingPageProps> = ({ settingServerData }) => (\n  <SettingPage settingServerData={settingServerData} />\n);\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR<ISettingPageProps>(async (context, ssrApi) => {\n      const userMe = await ssrApi.getUserMe();\n\n      if (!userMe?.isAdmin) {\n        throw new ForbiddenError();\n      }\n\n      const setting = await ssrApi.getSetting();\n      return {\n        props: {\n          settingServerData: setting,\n          ...(await getTranslationsProps(context, 'common')),\n        },\n      };\n    })\n  )\n);\n\nSetting.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <AdminLayout {...pageProps}>{page}</AdminLayout>;\n};\n\nexport default Setting;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/admin/template.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport type { ISettingPageProps } from '@/features/app/blocks/admin';\nimport { TemplatePage } from '@/features/app/blocks/admin';\nimport { AdminLayout } from '@/features/app/layouts/AdminLayout';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR, { ForbiddenError } from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst TemplateAdmin: NextPageWithLayout<ISettingPageProps> = () => <TemplatePage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR<ISettingPageProps>(async (context, ssrApi) => {\n      const userMe = await ssrApi.getUserMe();\n\n      if (!userMe?.isAdmin) {\n        throw new ForbiddenError();\n      }\n\n      const setting = await ssrApi.getSetting();\n      return {\n        props: {\n          settingServerData: setting,\n          ...(await getTranslationsProps(context, 'common')),\n        },\n      };\n    })\n  )\n);\n\nTemplateAdmin.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <AdminLayout {...pageProps}>{page}</AdminLayout>;\n};\n\nexport default TemplateAdmin;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/api/_monitor/healthcheck.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from 'next';\n\nexport type IHealthCheckApiPayload = {\n  status: 'ok' | 'error';\n  message: string;\n  appName: string;\n  appVersion: string;\n  timestamp: string;\n};\n\nexport default async function healthCheckApiRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method !== 'GET') {\n    res.status(400).end();\n    return;\n  }\n\n  res.setHeader('Content-Type', 'application/json');\n\n  const payload: IHealthCheckApiPayload = {\n    status: 'ok',\n    message: 'Health check successful for API route',\n    appName: process.env.APP_NAME ?? 'unknown',\n    appVersion: process.env.APP_VERSION ?? 'unknown',\n    timestamp: new Date().toISOString(),\n  };\n\n  res.status(200).send(JSON.stringify(payload, undefined, 2));\n  res.end();\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/api/_monitor/sentry.ts",
    "content": "import { wrapApiHandlerWithSentry } from '@sentry/nextjs';\nimport type { NextApiRequest, NextApiResponse } from 'next';\n\n// eslint-disable-next-line @typescript-eslint/require-await\nasync function sentryMonitorApiRoute(_req: NextApiRequest, _res: NextApiResponse): Promise<never> {\n  throw new Error('Error purposely crafted for monitoring sentry (/pages/api/_monitor/sentry.tsx)');\n}\nexport default wrapApiHandlerWithSentry(sentryMonitorApiRoute, '/api/_monitor/sentry');\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/auth/forget-password.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { GetServerSideProps } from 'next';\nimport { ForgetPasswordPage } from '@/features/auth/pages/ForgetPasswordPage';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\nexport default function ForgetPasswordRoute() {\n  const queryClient = createQueryClient();\n  return (\n    <QueryClientProvider client={queryClient}>\n      <ForgetPasswordPage />\n    </QueryClientProvider>\n  );\n}\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    const { i18nNamespaces } = authConfig;\n    return {\n      props: {\n        ...(await getTranslationsProps(context, i18nNamespaces)),\n      },\n    };\n  }, true)\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/auth/login.tsx",
    "content": "import type { DehydratedState } from '@tanstack/react-query';\nimport {\n  dehydrate,\n  HydrationBoundary,\n  QueryClient,\n  QueryClientProvider,\n} from '@tanstack/react-query';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { GetServerSideProps, InferGetServerSidePropsType } from 'next';\nimport { useState } from 'react';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport { LoginPage } from '@/features/auth/pages/LoginPage';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\ntype Props = {\n  /** Add props here */\n};\n\nexport default function LoginRoute(\n  props: InferGetServerSidePropsType<typeof getServerSideProps> & {\n    dehydratedState: DehydratedState;\n  }\n) {\n  const [queryClient] = useState(() => createQueryClient());\n\n  return (\n    <QueryClientProvider client={queryClient}>\n      <HydrationBoundary state={props.dehydratedState}>\n        <LoginPage />\n      </HydrationBoundary>\n    </QueryClientProvider>\n  );\n}\n\nexport const getServerSideProps: GetServerSideProps<Props> = withEnv(\n  ensureLogin(async (context) => {\n    const { i18nNamespaces } = authConfig;\n    const queryClient = new QueryClient();\n    const ssrApi = new SsrApi();\n    await Promise.all([\n      queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.getPublicSetting(),\n        queryFn: () => ssrApi.getPublicSetting(),\n      }),\n    ]);\n    return {\n      props: {\n        ...(await getTranslationsProps(context, i18nNamespaces)),\n        dehydratedState: dehydrate(queryClient),\n      },\n    };\n  }, true)\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/auth/reset-password.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { GetServerSideProps } from 'next';\nimport { ResetPasswordPage } from '@/features/auth/pages/ResetPasswordPage';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\nexport default function ForgetPasswordRoute() {\n  const queryClient = createQueryClient();\n  return (\n    <QueryClientProvider client={queryClient}>\n      <ResetPasswordPage />\n    </QueryClientProvider>\n  );\n}\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    const { i18nNamespaces } = authConfig;\n    return {\n      props: {\n        ...(await getTranslationsProps(context, i18nNamespaces)),\n      },\n    };\n  }, true)\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/auth/signup.tsx",
    "content": "import LoginRoute from './login';\n\nexport { getServerSideProps } from './login';\n\nexport default LoginRoute;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/base/[baseId]/[[...slug]].tsx",
    "content": "import { QueryClient } from '@tanstack/react-query';\nimport { IdPrefix } from '@teable/core';\nimport { BaseNodeResourceType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { CommunityPage } from '@/features/app/base/CommunityPage';\nimport type { ISSRContext } from '@/features/app/base-node';\nimport {\n  TablePage,\n  getTableServerSideProps,\n  DashBoardPage,\n  getDashboardServerSideProps,\n  getWorkflowServerSideProps,\n  WorkflowPage,\n  getBaseServerSideProps,\n  redirect,\n} from '@/features/app/base-node';\nimport type { IBaseNodePageProps } from '@/features/app/base-node/types';\nimport { parseBaseSlug, useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { BaseLayout } from '@/features/app/layouts/BaseLayout';\nimport { baseAllConfig } from '@/features/i18n/base-all.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport handleBase from '@/lib/handleBase';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst UnifiedBasePage: NextPageWithLayout<IBaseNodePageProps> = (props: IBaseNodePageProps) => {\n  const { resourceType } = useBaseResource();\n\n  switch (resourceType) {\n    case BaseNodeResourceType.Table:\n      return <TablePage {...props} />;\n    case BaseNodeResourceType.Dashboard:\n      return <DashBoardPage />;\n    case BaseNodeResourceType.Workflow:\n      return <WorkflowPage />;\n    case BaseNodeResourceType.App:\n      return <div>App Page</div>;\n    default:\n      return <CommunityPage />;\n  }\n};\n\nexport const getServerSideProps: GetServerSideProps<IBaseNodePageProps> = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { baseId, slug, ...queryParams } = context.query;\n      context.res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n      const queryClient = new QueryClient();\n      const base = await handleBase(baseId as string, ssrApi, queryClient);\n      // Redirect legacy table URLs: /base/xxx/tbl1/viw1 → /base/xxx/table/tbl1/viw1\n      if (Array.isArray(slug) && slug.length > 0 && slug[0].startsWith(IdPrefix.Table)) {\n        const queryString = new URLSearchParams(queryParams as Record<string, string>).toString();\n        const tablePath = slug[1] ? `${slug[0]}/${slug[1]}` : slug[0];\n        const query = queryString ? `?${queryString}` : '';\n        return redirect(`/base/${baseId}/table/${tablePath}${query}`);\n      }\n\n      const parsed = parseBaseSlug(slug as string[]);\n      const baseIdStr = baseId as string;\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.base(baseIdStr),\n          queryFn: () => base,\n        }),\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getBasePermission(baseIdStr),\n          queryFn: () => ssrApi.getBasePermission(baseIdStr),\n        }),\n      ]);\n\n      ssrApi.configureBaseHeaders(base);\n\n      const i18nNamespaces = baseAllConfig.i18nNamespaces;\n      const ctx: ISSRContext = {\n        context,\n        queryClient,\n        baseId: baseIdStr,\n        ssrApi,\n        getTranslationsProps: () => getTranslationsProps(context, i18nNamespaces),\n        base,\n      };\n\n      if (!parsed.resourceType) {\n        return getBaseServerSideProps(ctx);\n      }\n\n      switch (parsed.resourceType) {\n        case BaseNodeResourceType.Table:\n          return getTableServerSideProps(ctx, parsed, queryParams);\n        case BaseNodeResourceType.Dashboard:\n          return getDashboardServerSideProps(ctx, parsed);\n        case BaseNodeResourceType.Workflow:\n          return getWorkflowServerSideProps(ctx, parsed);\n        case BaseNodeResourceType.App:\n        default:\n          return { notFound: true };\n      }\n    })\n  )\n);\n\nUnifiedBasePage.getLayout = function getLayout(page: ReactElement, pageProps: IBaseNodePageProps) {\n  return <BaseLayout {...pageProps}>{page}</BaseLayout>;\n};\n\nexport default UnifiedBasePage;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/base/[baseId]/authority-matrix.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { IS_TEMPLATE_HEADER, type ITableVo } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { AuthorityMatrixPage } from '@/features/app/blocks/AuthorityMatrix';\nimport { BaseLayout } from '@/features/app/layouts/BaseLayout';\nimport { baseAllConfig } from '@/features/i18n/base-all.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport handleBase from '@/lib/handleBase';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Node: NextPageWithLayout = () => <AuthorityMatrixPage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { baseId } = context.query;\n      const queryClient = new QueryClient();\n      const base = await handleBase(baseId as string, ssrApi, queryClient);\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.base(baseId as string),\n          queryFn: () => base,\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getBasePermission(baseId as string),\n          queryFn: ({ queryKey }) => ssrApi.getBasePermission(queryKey[1]),\n        }),\n      ]);\n\n      const templateHeader = base?.template?.headers;\n      if (templateHeader) {\n        ssrApi.disableLastVisit = true;\n        ssrApi.axios.interceptors.request.use((config) => {\n          config.headers[IS_TEMPLATE_HEADER] = templateHeader;\n          return config;\n        });\n      }\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, baseAllConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nNode.getLayout = function getLayout(\n  page: ReactElement,\n  pageProps: { tableServerData: ITableVo[] }\n) {\n  return <BaseLayout {...pageProps}>{page}</BaseLayout>;\n};\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/base/[baseId]/design.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { ReactElement } from 'react';\nimport { Design } from '@/features/app/blocks/design/Design';\nimport { BaseLayout } from '@/features/app/layouts/BaseLayout';\nimport { baseAllConfig } from '@/features/i18n/base-all.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { IBasePageProps, NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Node: NextPageWithLayout = () => {\n  return <Design />;\n};\n\nexport const getServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { baseId } = context.query;\n      const queryClient = new QueryClient();\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.base(baseId as string),\n          queryFn: ({ queryKey }) =>\n            queryKey[1] ? ssrApi.getBaseById(baseId as string) : undefined,\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getBasePermission(baseId as string),\n          queryFn: ({ queryKey }) => ssrApi.getBasePermission(queryKey[1]),\n        }),\n      ]);\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, baseAllConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nNode.getLayout = function getLayout(page: ReactElement, pageProps: IBasePageProps) {\n  return <BaseLayout {...pageProps}>{page}</BaseLayout>;\n};\n\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/base/[baseId]/trash.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { BaseTrashPage } from '@/features/app/blocks/trash/BaseTrashPage';\nimport { BaseLayout } from '@/features/app/layouts/BaseLayout';\nimport { baseAllConfig } from '@/features/i18n/base-all.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { IBasePageProps, NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Node: NextPageWithLayout = () => <BaseTrashPage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { baseId } = context.query;\n      const queryClient = new QueryClient();\n\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.base(baseId as string),\n          queryFn: ({ queryKey }) =>\n            queryKey[1] ? ssrApi.getBaseById(baseId as string) : undefined,\n        }),\n      ]);\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, baseAllConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nNode.getLayout = function getLayout(page: ReactElement, pageProps: IBasePageProps) {\n  return <BaseLayout {...pageProps}>{page}</BaseLayout>;\n};\n\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/developer/tool/query-builder.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport React from 'react';\nimport { QueryBuilder } from '@/features/app/blocks/setting/query-builder/QueryBuilder';\nimport { SettingLayout } from '@/features/app/layouts/SettingLayout';\nimport { developerConfig } from '@/features/i18n/developer.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst QueryBuilderPage: NextPageWithLayout = () => {\n  return <QueryBuilder />;\n};\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context) => {\n      return {\n        props: {\n          ...(await getTranslationsProps(context, developerConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nQueryBuilderPage.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SettingLayout {...pageProps}>{page}</SettingLayout>;\n};\n\nexport default QueryBuilderPage;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/index.tsx",
    "content": "import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';\ntype Props = {\n  /** Add HomeRoute props here */\n};\n\nexport default function DemoRoute(_props: InferGetServerSidePropsType<typeof getServerSideProps>) {\n  return <></>;\n}\n\nexport const getServerSideProps: GetServerSideProps<Props> = async (context) => {\n  // Preserve query parameters when redirecting to /space\n  const queryString = context.req.url?.split('?')[1];\n  const destination = queryString ? `/space?${queryString}` : '/space';\n  return {\n    redirect: {\n      destination,\n      permanent: false,\n    },\n  };\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/invite/index.tsx",
    "content": "import type { IHttpError } from '@teable/core';\nimport type { GetServerSideProps } from 'next';\nimport { Error } from '@/features/app/blocks/Error';\nimport ensureLogin from '@/lib/ensureLogin';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst InvitePage: NextPageWithLayout<{ error?: IHttpError }> = () => {\n  return <Error message=\"Sorry, we were unable to accept the invite.\" />;\n};\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { invitationId, invitationCode } = context.query;\n      try {\n        const { spaceId, baseId } = await ssrApi.acceptInvitationLink({\n          invitationId: invitationId as string,\n          invitationCode: invitationCode as string,\n        });\n        if (spaceId) {\n          return {\n            redirect: {\n              destination: `/space/${spaceId}`,\n              permanent: false,\n            },\n          };\n        }\n        if (baseId) {\n          return {\n            redirect: {\n              destination: `/base/${baseId}`,\n              permanent: false,\n            },\n          };\n        }\n\n        return { props: {} };\n      } catch (e) {\n        const error = e as IHttpError;\n        console.log('error === ', error);\n        if (error.status !== 401) {\n          return {\n            props: {},\n          };\n        }\n        throw error;\n      }\n    })\n  )\n);\n\nexport default InvitePage;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/oauth/decision.tsx",
    "content": "import type { IUser } from '@teable/sdk/context';\nimport { AppProvider, SessionProvider } from '@teable/sdk/context';\nimport type { GetServerSideProps } from 'next';\nimport { useTranslation } from 'next-i18next';\nimport type { ReactElement } from 'react';\nimport { OAuthAppDecisionPage } from '@/features/app/blocks/setting/oauth-app/OAuthAppDecisionPage';\nimport { useSdkLocale } from '@/features/app/hooks/useSdkLocale';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withEnv from '@/lib/withEnv';\n\nconst OAuthAppDecision: NextPageWithLayout = () => {\n  return <OAuthAppDecisionPage />;\n};\n\nconst OAuthAppDecisionLayout = ({\n  children,\n  user,\n}: {\n  children: React.ReactNode;\n  user?: IUser;\n}) => {\n  const sdkLocale = useSdkLocale();\n  const { i18n } = useTranslation();\n\n  return (\n    <AppProvider lang={i18n.language} locale={sdkLocale}>\n      <SessionProvider user={user}>{children}</SessionProvider>\n    </AppProvider>\n  );\n};\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    return {\n      props: {\n        ...(await getTranslationsProps(context, oauthAppConfig.i18nNamespaces)),\n      },\n    };\n  })\n);\n\nOAuthAppDecision.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <OAuthAppDecisionLayout {...pageProps}>{page}</OAuthAppDecisionLayout>;\n};\n\nexport default OAuthAppDecision;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/setting/index.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { NextPageWithLayout } from '@/lib/type';\n\nconst Node: NextPageWithLayout = () => {\n  return <p>redirecting</p>;\n};\n\nexport const getServerSideProps: GetServerSideProps = async () => {\n  return {\n    redirect: {\n      destination: `/setting/personal-access-token`,\n      permanent: false,\n    },\n  };\n};\n\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/setting/oauth-app.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { OAuthAppPage } from '@/features/app/blocks/setting/oauth-app/OAuthAppPage';\nimport { SettingLayout } from '@/features/app/layouts/SettingLayout';\nimport { oauthAppConfig } from '@/features/i18n/oauth-app.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withEnv from '@/lib/withEnv';\n\nconst OAuthApp: NextPageWithLayout = () => {\n  return <OAuthAppPage />;\n};\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    return {\n      props: {\n        ...(await getTranslationsProps(context, oauthAppConfig.i18nNamespaces)),\n      },\n    };\n  })\n);\n\nOAuthApp.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SettingLayout {...pageProps}>{page}</SettingLayout>;\n};\n\nexport default OAuthApp;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/setting/personal-access-token.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { PersonAccessTokenPage } from '@/features/app/blocks/setting/access-token/PersonAccessTokenPage';\nimport { SettingLayout } from '@/features/app/layouts/SettingLayout';\nimport { personalAccessTokenConfig } from '@/features/i18n/personal-access-token.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withEnv from '@/lib/withEnv';\n\nconst PersonalAccessToken: NextPageWithLayout = () => {\n  return <PersonAccessTokenPage />;\n};\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    return {\n      props: {\n        ...(await getTranslationsProps(context, personalAccessTokenConfig.i18nNamespaces)),\n      },\n    };\n  })\n);\n\nPersonalAccessToken.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SettingLayout {...pageProps}>{page}</SettingLayout>;\n};\n\nexport default PersonalAccessToken;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/setting/plugin.tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { PluginPage } from '@/features/app/blocks/setting/plugin/PluginPage';\nimport { SettingLayout } from '@/features/app/layouts/SettingLayout';\nimport { settingPluginConfig } from '@/features/i18n/setting-plugin.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withEnv from '@/lib/withEnv';\n\nconst Plugin: NextPageWithLayout = () => {\n  return <PluginPage />;\n};\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(async (context) => {\n    return {\n      props: {\n        ...(await getTranslationsProps(context, settingPluginConfig.i18nNamespaces)),\n      },\n    };\n  })\n);\n\nPlugin.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SettingLayout {...pageProps}>{page}</SettingLayout>;\n};\n\nexport default Plugin;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/share/[shareId]/base/[baseId]/[[...slug]].tsx",
    "content": "import { BaseNodeResourceType } from '@teable/openapi';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport type { ISSRContext } from '@/features/app/base-node';\nimport {\n  DashBoardPage,\n  getBaseServerSideProps,\n  getDashboardServerSideProps,\n  getTableServerSideProps,\n  getWorkflowServerSideProps,\n  TablePage,\n  WorkflowPage,\n} from '@/features/app/base-node';\nimport type { IShareBasePagePropsBase } from '@/features/app/blocks/share/base/share-base-ssr';\nimport { createShareBaseSSR } from '@/features/app/blocks/share/base/share-base-ssr';\nimport type { IBaseResourceParsed } from '@/features/app/hooks/useBaseResource';\nimport { useBaseResource } from '@/features/app/hooks/useBaseResource';\nimport { ShareBaseLayout } from '@/features/app/layouts/ShareBaseLayout';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withEnv from '@/lib/withEnv';\n\nexport type IShareBasePageProps = IShareBasePagePropsBase;\n\nconst ShareBasePage: NextPageWithLayout<IShareBasePageProps> = (props: IShareBasePageProps) => {\n  const { resourceType } = useBaseResource();\n\n  switch (resourceType) {\n    case BaseNodeResourceType.Table:\n      return <TablePage {...props} />;\n    case BaseNodeResourceType.Dashboard:\n      return <DashBoardPage />;\n    case BaseNodeResourceType.Workflow:\n      return <WorkflowPage />;\n    default:\n      return null;\n  }\n};\n\nconst getResourcePageProps = async (\n  ctx: ISSRContext,\n  parsed: IBaseResourceParsed,\n  queryParams: Record<string, string | string[] | undefined>\n) => {\n  if (!parsed.resourceType) {\n    return getBaseServerSideProps(ctx);\n  }\n  switch (parsed.resourceType) {\n    case BaseNodeResourceType.Table:\n      return getTableServerSideProps(ctx, parsed, queryParams);\n    case BaseNodeResourceType.Dashboard:\n      return getDashboardServerSideProps(ctx, parsed);\n    case BaseNodeResourceType.Workflow:\n      return getWorkflowServerSideProps(ctx, parsed);\n    default:\n      return null;\n  }\n};\n\nexport const getServerSideProps: GetServerSideProps<IShareBasePageProps> =\n  withEnv<IShareBasePageProps>(async (context) => {\n    const ssrApi = new SsrApi();\n    return createShareBaseSSR<IShareBasePageProps>({\n      ssrApi,\n      context,\n      getResourcePageProps,\n    });\n  });\n\nShareBasePage.getLayout = function getLayout(page: ReactElement, pageProps: IShareBasePageProps) {\n  return <ShareBaseLayout {...pageProps}>{page}</ShareBaseLayout>;\n};\n\nexport default ShareBasePage;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/share/[shareId]/base/auth.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { GetServerSideProps } from 'next';\nimport { BaseShareAuthPage } from '@/features/app/blocks/share/base/BaseShareAuthPage';\nimport { shareConfig } from '@/features/i18n/share.config';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\nconst queryClient = createQueryClient();\n\nexport const getServerSideProps: GetServerSideProps = withEnv(async (context) => {\n  const { i18nNamespaces } = shareConfig;\n  context.res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n  return {\n    props: {\n      ...(await getTranslationsProps(context, i18nNamespaces)),\n    },\n  };\n});\n\nexport default function ShareBaseAuth() {\n  return (\n    <QueryClientProvider client={queryClient}>\n      <BaseShareAuthPage />\n    </QueryClientProvider>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/share/[shareId]/base/index.tsx",
    "content": "import type { IHttpError } from '@teable/core';\nimport type { GetServerSideProps } from 'next';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport withEnv from '@/lib/withEnv';\n\n// This page redirects to the proper share base URL with baseId\nexport const getServerSideProps: GetServerSideProps = withEnv(async (context) => {\n  const { res, req, query } = context;\n  const { shareId } = query;\n  res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n\n  try {\n    const ssrApi = new SsrApi();\n    ssrApi.axios.defaults.headers['cookie'] = req.headers.cookie || '';\n    const shareData = await ssrApi.getBaseShare(shareId as string);\n\n    const { baseId, defaultUrl } = shareData;\n\n    // Build destination URL\n    let destination = `/share/${shareId}/base/${baseId}`;\n    if (defaultUrl) {\n      // Replace /base/xxx with /share/{shareId}/base/xxx\n      destination = defaultUrl.replace(`/base/${baseId}`, `/share/${shareId}/base/${baseId}`);\n    }\n\n    return {\n      redirect: {\n        destination,\n        permanent: false,\n      },\n    };\n  } catch (e) {\n    const error = e as IHttpError;\n    if (error.status === 401) {\n      return {\n        redirect: {\n          destination: `/share/${shareId}/base/auth`,\n          permanent: false,\n        },\n      };\n    }\n    return {\n      notFound: true,\n    };\n  }\n});\n\n// This page only redirects, no content\nexport default function ShareBase() {\n  return null;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/share/[shareId]/view/auth.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { GetServerSideProps } from 'next';\nimport { AuthPage } from '@/features/app/blocks/share/view/AuthPage';\nimport { shareConfig } from '@/features/i18n/share.config';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\nconst queryClient = createQueryClient();\n\nexport const getServerSideProps: GetServerSideProps = withEnv(async (context) => {\n  const { i18nNamespaces } = shareConfig;\n  context.res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n  return {\n    props: {\n      ...(await getTranslationsProps(context, i18nNamespaces)),\n    },\n  };\n});\n\nexport default function ShareAuth() {\n  return (\n    <QueryClientProvider client={queryClient}>\n      <AuthPage />\n    </QueryClientProvider>\n  );\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/share/[shareId]/view/index.tsx",
    "content": "import { parseDsn, type DriverClient, type IHttpError } from '@teable/core';\nimport type { ShareViewGetVo } from '@teable/openapi';\nimport type { GetServerSideProps } from 'next';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport type { IShareViewPageProps } from '@/features/app/blocks/share/view/ShareViewPage';\nimport { ShareViewPage } from '@/features/app/blocks/share/view/ShareViewPage';\nimport { shareConfig } from '@/features/i18n/share.config';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\nexport const getServerSideProps: GetServerSideProps<IShareViewPageProps> =\n  withEnv<IShareViewPageProps>(async (context) => {\n    const { res, req, query } = context;\n    const { shareId } = query;\n    const { i18nNamespaces } = shareConfig;\n    res.setHeader('Content-Security-Policy', 'frame-ancestors *;');\n\n    try {\n      const ssrApi = new SsrApi();\n      ssrApi.axios.defaults.headers['cookie'] = req.headers.cookie || '';\n      const shareViewData = await ssrApi.getShareView(shareId as string);\n      const driver = parseDsn(process.env.PRISMA_DATABASE_URL as string).driver as DriverClient;\n      if (shareViewData.shareMeta?.submit?.requireLogin) {\n        const user = await ssrApi.getUserMe().catch(() => null);\n        if (!user) {\n          return {\n            redirect: {\n              destination: `/auth/login?redirect=${encodeURIComponent(req?.url || '')}`,\n              permanent: false,\n            },\n          };\n        }\n      }\n      return {\n        props: {\n          shareViewData,\n          driver,\n          ...(await getTranslationsProps(context, i18nNamespaces)),\n        },\n      };\n    } catch (e) {\n      const error = e as IHttpError;\n      if (error.status === 401) {\n        return {\n          redirect: {\n            destination: `/share/${shareId}/view/auth`,\n            permanent: false,\n          },\n        };\n      }\n      return {\n        notFound: true,\n      };\n    }\n  });\n\nexport default function ShareView({\n  shareViewData,\n  driver,\n}: {\n  shareViewData: ShareViewGetVo;\n  driver: DriverClient;\n}) {\n  return <ShareViewPage shareViewData={shareViewData} driver={driver} />;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/[spaceId]/setting/collaborator.tsx",
    "content": "import { QueryClient, dehydrate } from '@tanstack/react-query';\nimport { Role } from '@teable/core';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { CollaboratorPage } from '@/features/app/blocks/space-setting';\nimport { SpaceSettingLayout } from '@/features/app/layouts/SpaceSettingLayout';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport { spaceRoleChecker } from '@/lib/space-role-checker';\nimport type { NextPageWithLayout, IBasePageProps } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Collaborator: NextPageWithLayout = () => <CollaboratorPage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR<IBasePageProps>(async (context, ssrApi) => {\n      const { spaceId } = context.query;\n      const queryClient = new QueryClient();\n\n      await queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.space(spaceId as string),\n        queryFn: ({ queryKey }) => ssrApi.getSpaceById(queryKey[1]),\n      });\n\n      spaceRoleChecker({\n        queryClient,\n        spaceId: spaceId as string,\n        roles: [Role.Owner],\n      });\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nCollaborator.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SpaceSettingLayout {...pageProps}>{page}</SpaceSettingLayout>;\n};\n\nexport default Collaborator;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/[spaceId]/setting/general.tsx",
    "content": "import { QueryClient, dehydrate } from '@tanstack/react-query';\nimport { Role } from '@teable/core';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { GeneralPage } from '@/features/app/blocks/space-setting';\nimport { SpaceSettingLayout } from '@/features/app/layouts/SpaceSettingLayout';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport { spaceRoleChecker } from '@/lib/space-role-checker';\nimport type { NextPageWithLayout, IBasePageProps } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst General: NextPageWithLayout = () => <GeneralPage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR<IBasePageProps>(async (context, ssrApi) => {\n      const { spaceId } = context.query;\n      const queryClient = new QueryClient();\n\n      await queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.space(spaceId as string),\n        queryFn: ({ queryKey }) => ssrApi.getSpaceById(queryKey[1]),\n      });\n\n      spaceRoleChecker({\n        queryClient,\n        spaceId: spaceId as string,\n        roles: [Role.Owner],\n      });\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nGeneral.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SpaceSettingLayout {...pageProps}>{page}</SpaceSettingLayout>;\n};\n\nexport default General;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/[spaceId].tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { Role } from '@teable/core';\nimport { ReactQueryKeys } from '@teable/sdk';\nimport { uniq } from 'lodash';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { SpaceInnerPage } from '@/features/app/blocks/space';\nimport { SpaceInnerLayout } from '@/features/app/layouts/SpaceInnerLayout';\nimport { settingConfig } from '@/features/i18n/setting.config';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Node: NextPageWithLayout = () => <SpaceInnerPage />;\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const { spaceId } = context.query;\n      const queryClient = new QueryClient();\n\n      // Fetch space info and base list first to check if auto-creation is needed\n      const [space, baseList] = await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.space(spaceId as string),\n          queryFn: ({ queryKey }) => ssrApi.getSpaceById(queryKey[1]),\n        }),\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.baseAll(),\n          queryFn: () => ssrApi.getBaseList(),\n        }),\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.recentlyBase(),\n          queryFn: () => ssrApi.getRecentlyBase(),\n        }),\n      ]);\n\n      // Check if user is owner and space has no bases\n      const basesInSpace = baseList.filter((base) => base.spaceId === spaceId);\n      const isOwner = space.role === Role.Owner;\n\n      // Check if this is a template apply request - skip auto-create if so\n      const { action, tid } = context.query;\n      const isTemplateApply = action === 'createFromTemplate' && tid;\n\n      // If owner enters an empty space, auto-create a base and redirect\n      // Skip auto-create if template apply is requested\n      if (isOwner && basesInSpace.length === 0 && !isTemplateApply) {\n        const newBase = await ssrApi.createBase({\n          spaceId: spaceId as string,\n        });\n        const queryString = context.req.url?.split('?')[1];\n        const baseDest = queryString ? `/base/${newBase.id}?${queryString}` : `/base/${newBase.id}`;\n        return {\n          redirect: {\n            destination: baseDest,\n            permanent: false,\n          },\n        };\n      }\n\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.spaceCollaboratorList(spaceId as string, {\n            skip: 0,\n            take: 50,\n            orderBy: 'asc',\n            includeBase: true,\n          }),\n          queryFn: ({ queryKey }) => ssrApi.getSpaceCollaboratorList(queryKey[1], queryKey[2]),\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.spaceList(),\n          queryFn: () => ssrApi.getSpaceList(),\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getPublicSetting(),\n          queryFn: () => ssrApi.getPublicSetting(),\n        }),\n      ]);\n\n      if (process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD') {\n        await queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.subscriptionSummary(spaceId as string),\n          queryFn: ({ queryKey }) => ssrApi.getSubscriptionSummary(queryKey[1]),\n        });\n      }\n\n      return {\n        props: {\n          ...(await getTranslationsProps(\n            context,\n            uniq([...spaceConfig.i18nNamespaces, ...settingConfig.i18nNamespaces])\n          )),\n          dehydratedState: dehydrate(queryClient),\n        },\n      };\n    })\n  )\n);\n\nNode.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SpaceInnerLayout {...pageProps}>{page}</SpaceInnerLayout>;\n};\n\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/index.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { LastVisitResourceType } from '@teable/openapi';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { SpacePage } from '@/features/app/blocks/space';\nimport { SpaceLayout } from '@/features/app/layouts/SpaceLayout';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Space: NextPageWithLayout = () => {\n  return <SpacePage />;\n};\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const queryClient = new QueryClient();\n      const [userLastVisitSpace, spaceList] = await Promise.all([\n        ssrApi.getUserLastVisit(LastVisitResourceType.Space, ''),\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.spaceList(),\n          queryFn: () => ssrApi.getSpaceList(),\n        }),\n      ]);\n\n      const spaceIds = spaceList.map((space) => space.id);\n      const spaceId =\n        userLastVisitSpace?.resourceId && spaceIds.includes(userLastVisitSpace?.resourceId)\n          ? userLastVisitSpace?.resourceId\n          : spaceIds[0];\n      if (spaceId) {\n        // Preserve query parameters when redirecting (e.g., action=createFromTemplate&tid=xxx)\n        const queryString = context.req.url?.split('?')[1];\n        const destination = queryString ? `/space/${spaceId}?${queryString}` : `/space/${spaceId}`;\n        return {\n          redirect: {\n            destination,\n            permanent: false,\n          },\n        };\n      }\n\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.baseAll(),\n          queryFn: () => ssrApi.getBaseList(),\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.pinList(),\n          queryFn: () => ssrApi.getPinList(),\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getPublicSetting(),\n          queryFn: () => ssrApi.getPublicSetting(),\n        }),\n\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.recentlyBase(),\n          queryFn: () => ssrApi.getRecentlyBase(),\n        }),\n      ]);\n\n      if (process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD') {\n        await queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.subscriptionSummaryList(),\n          queryFn: () => ssrApi.getSubscriptionSummaryList(),\n        });\n      }\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nSpace.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SpaceLayout {...pageProps}>{page}</SpaceLayout>;\n};\n\nexport default Space;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/shared-base.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { SharedBasePage } from '@/features/app/blocks/space/SharedBasePage';\nimport { SharedBaseLayout } from '@/features/app/layouts/SharedBaseLayout';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst Node: NextPageWithLayout = () => <SharedBasePage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const queryClient = new QueryClient();\n\n      await Promise.all([\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.spaceList(),\n          queryFn: () => ssrApi.getSpaceList(),\n        }),\n        queryClient.fetchQuery({\n          queryKey: ReactQueryKeys.getSharedBase(),\n          queryFn: () => ssrApi.getSharedBase(),\n        }),\n      ]);\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nNode.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SharedBaseLayout {...pageProps}>{page}</SharedBaseLayout>;\n};\nexport default Node;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/space/trash.tsx",
    "content": "import { dehydrate, QueryClient } from '@tanstack/react-query';\nimport { ReactQueryKeys } from '@teable/sdk/config';\nimport type { GetServerSideProps } from 'next';\nimport type { ReactElement } from 'react';\nimport { SpaceTrashPage } from '@/features/app/blocks/trash/SpaceTrashPage';\nimport { SpaceTrashLayout } from '@/features/app/layouts/SpaceTrashLayout';\nimport { spaceConfig } from '@/features/i18n/space.config';\nimport ensureLogin from '@/lib/ensureLogin';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport type { NextPageWithLayout } from '@/lib/type';\nimport withAuthSSR from '@/lib/withAuthSSR';\nimport withEnv from '@/lib/withEnv';\n\nconst SpaceTrash: NextPageWithLayout = () => <SpaceTrashPage />;\n\nexport const getServerSideProps: GetServerSideProps = withEnv(\n  ensureLogin(\n    withAuthSSR(async (context, ssrApi) => {\n      const queryClient = new QueryClient();\n\n      await queryClient.fetchQuery({\n        queryKey: ReactQueryKeys.spaceList(),\n        queryFn: () => ssrApi.getSpaceList(),\n      });\n\n      return {\n        props: {\n          dehydratedState: dehydrate(queryClient),\n          ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)),\n        },\n      };\n    })\n  )\n);\n\nSpaceTrash.getLayout = function getLayout(page: ReactElement, pageProps) {\n  return <SpaceTrashLayout {...pageProps}>{page}</SpaceTrashLayout>;\n};\n\nexport default SpaceTrash;\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/t/[identifier].tsx",
    "content": "import type { GetServerSideProps } from 'next';\nimport { SsrApi } from '@/backend/api/rest/ssr-api';\nimport withEnv from '@/lib/withEnv';\n\nexport const getServerSideProps: GetServerSideProps = withEnv(async (context) => {\n  const { identifier } = context.query;\n\n  if (!identifier || typeof identifier !== 'string') {\n    return {\n      notFound: true,\n    };\n  }\n\n  try {\n    // Create SSR API instance\n    const ssrApi = new SsrApi();\n\n    // Call backend API to resolve permalink\n    const data = await ssrApi.getTemplatePermalink(identifier as string);\n\n    // Server-side redirect (302 - temporary redirect)\n    // Use 302 because the template URL may change when republished\n    return {\n      redirect: {\n        destination: data.redirectUrl,\n        permanent: false,\n      },\n    };\n  } catch (error) {\n    // Template not found or not published\n    console.error('Template permalink error:', error);\n    return {\n      notFound: true,\n    };\n  }\n});\n\n// This page will never be rendered because we always redirect\nexport default function TemplatePage() {\n  return null;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/pages/waitlist/index.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query';\nimport { createQueryClient } from '@teable/sdk/context';\nimport type { InferGetServerSidePropsType, GetServerSideProps } from 'next';\nimport { useState } from 'react';\nimport { WaitlistPage } from '@/features/app/waitlist/WaitlistPage';\nimport { authConfig } from '@/features/i18n/auth.config';\nimport { getTranslationsProps } from '@/lib/i18n';\nimport withEnv from '@/lib/withEnv';\n\ntype Props = {\n  /** Add props here */\n};\n\nexport default function WaitlistRoute(\n  _props: InferGetServerSidePropsType<typeof getServerSideProps>\n) {\n  const [queryClient] = useState(() => createQueryClient());\n\n  return (\n    <QueryClientProvider client={queryClient}>\n      <WaitlistPage />\n    </QueryClientProvider>\n  );\n}\n\nexport const getServerSideProps: GetServerSideProps<Props> = withEnv(async (context) => {\n  return {\n    props: {\n      ...(await getTranslationsProps(context, authConfig.i18nNamespaces)),\n    },\n  };\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/proxy.ts",
    "content": "import type { NextRequest } from 'next/server';\nimport { NextResponse } from 'next/server';\nimport { getLocaleDetection } from './lib/i18n/getLocale';\n\nexport function proxy(request: NextRequest) {\n  const locale = getLocaleDetection({\n    req: request,\n    i18n: {\n      defaultLocale: 'en',\n      locales: ['en', 'it', 'de', 'zh', 'fr', 'ja', 'ru', 'uk', 'tr', 'es'],\n    },\n  });\n\n  const response = NextResponse.next();\n  response.headers.set('X-Server-Locale', locale);\n  return response;\n}\n\nexport const config = {\n  /*\n   * Match all request paths except for the ones starting with:\n   * - api (API routes)\n   * - _next/static (static files)\n   * - _next/image (image optimization files)\n   * - favicon.ico (favicon file)\n   * - socket (Ws)\n   */\n  matcher: '/((?!api|_next/static|_next/image|favicon.ico|socket).*)',\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/store/message.ts",
    "content": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nexport enum CreatorRole {\n  System = 'system',\n  User = 'user',\n  Assistant = 'assistant',\n}\n\nexport enum MessageStatus {\n  Loading = 'loading',\n  Done = 'done',\n  Failed = 'failed',\n}\n\nexport interface IMessage {\n  id: string;\n  chatId: string;\n  creatorId: string;\n  creatorRole: CreatorRole;\n  createdAt: number;\n  content: string;\n  status: MessageStatus;\n  type?: 'table' | 'chart';\n}\n\ninterface IMessageState {\n  messageList: IMessage[];\n  getState: () => IMessageState;\n  addMessage: (message: IMessage) => void;\n  updateMessage: (messageId: string, message: Partial<IMessage>) => void;\n  clearMessage: (filter: (message: IMessage) => boolean) => void;\n}\n\nexport const useMessageStore = create<IMessageState>()(\n  persist(\n    (set, get) => ({\n      messageList: [],\n      getState: () => get(),\n      addMessage: (message: IMessage) =>\n        set((state) => ({ messageList: [...state.messageList, message] })),\n      updateMessage: (messageId: string, message: Partial<IMessage>) => {\n        set((state) => ({\n          ...state,\n          messageList: state.messageList.map((item) =>\n            item.id === messageId ? { ...item, ...message } : item\n          ),\n        }));\n      },\n      clearMessage: (filter: (message: IMessage) => boolean) =>\n        set((state) => ({ messageList: state.messageList.filter(filter) })),\n    }),\n    {\n      name: 'message-storage',\n    }\n  )\n);\n"
  },
  {
    "path": "apps/nextjs-app/src/store/user.ts",
    "content": "import { create } from 'zustand';\n\nexport interface IUser {\n  id: string;\n  name: string;\n  description: string;\n  avatar: string;\n}\n\nconst defaultUser: IUser = {\n  id: 'default-user',\n  name: 'Default user',\n  description: '',\n  avatar: '',\n};\n\ninterface IUserState {\n  currentUser: IUser;\n}\n\nexport const useUserStore = create<IUserState>()(() => ({\n  currentUser: defaultUser,\n}));\n"
  },
  {
    "path": "apps/nextjs-app/src/styles/github-markdown.css",
    "content": ".markdown-body {\n  --base-size-4: 0.25rem;\n  --base-size-8: 0.5rem;\n  --base-size-12: 0.75rem;\n  --base-size-16: 1rem;\n  --base-text-weight-normal: 400;\n  --base-text-weight-medium: 500;\n  --base-text-weight-semibold: 600;\n  --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono,\n    monospace;\n}\n\n.dark .markdown-body {\n  /*dark*/\n  color-scheme: dark;\n  --focus-outlineColor: #1f6feb;\n  --fgColor-default: hsl(var(--foreground));\n  --fgColor-muted: #8d96a0;\n  --fgColor-accent: #4493f8;\n  --fgColor-success: #3fb950;\n  --fgColor-attention: #d29922;\n  --fgColor-danger: #f85149;\n  --fgColor-done: #ab7df8;\n  --bgColor-default: hsl(var(--background));\n  --bgColor-muted: #161b22;\n  --bgColor-neutral-muted: #6e768166;\n  --bgColor-attention-muted: #bb800926;\n  --borderColor-default: #30363d;\n  --borderColor-muted: #30363db3;\n  --borderColor-neutral-muted: #6e768166;\n  --borderColor-accent-emphasis: #1f6feb;\n  --borderColor-success-emphasis: #238636;\n  --borderColor-attention-emphasis: #9e6a03;\n  --borderColor-danger-emphasis: #da3633;\n  --borderColor-done-emphasis: #8957e5;\n  --color-prettylights-syntax-comment: #8b949e;\n  --color-prettylights-syntax-constant: #79c0ff;\n  --color-prettylights-syntax-constant-other-reference-link: #a5d6ff;\n  --color-prettylights-syntax-entity: #d2a8ff;\n  --color-prettylights-syntax-storage-modifier-import: #c9d1d9;\n  --color-prettylights-syntax-entity-tag: #7ee787;\n  --color-prettylights-syntax-keyword: #ff7b72;\n  --color-prettylights-syntax-string: #a5d6ff;\n  --color-prettylights-syntax-variable: #ffa657;\n  --color-prettylights-syntax-brackethighlighter-unmatched: #f85149;\n  --color-prettylights-syntax-brackethighlighter-angle: #8b949e;\n  --color-prettylights-syntax-invalid-illegal-text: #f0f6fc;\n  --color-prettylights-syntax-invalid-illegal-bg: #8e1519;\n  --color-prettylights-syntax-carriage-return-text: #f0f6fc;\n  --color-prettylights-syntax-carriage-return-bg: #b62324;\n  --color-prettylights-syntax-string-regexp: #7ee787;\n  --color-prettylights-syntax-markup-list: #f2cc60;\n  --color-prettylights-syntax-markup-heading: #1f6feb;\n  --color-prettylights-syntax-markup-italic: #c9d1d9;\n  --color-prettylights-syntax-markup-bold: #c9d1d9;\n  --color-prettylights-syntax-markup-deleted-text: #ffdcd7;\n  --color-prettylights-syntax-markup-deleted-bg: #67060c;\n  --color-prettylights-syntax-markup-inserted-text: #aff5b4;\n  --color-prettylights-syntax-markup-inserted-bg: #033a16;\n  --color-prettylights-syntax-markup-changed-text: #ffdfb6;\n  --color-prettylights-syntax-markup-changed-bg: #5a1e02;\n  --color-prettylights-syntax-markup-ignored-text: #c9d1d9;\n  --color-prettylights-syntax-markup-ignored-bg: #1158c7;\n  --color-prettylights-syntax-meta-diff-range: #d2a8ff;\n  --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;\n}\n\n.markdown-body {\n  /*light*/\n  color-scheme: light;\n  --focus-outlineColor: #0969da;\n  --fgColor-default: hsl(var(--foreground));\n  --fgColor-muted: #636c76;\n  --fgColor-accent: #0969da;\n  --fgColor-success: #1a7f37;\n  --fgColor-attention: #9a6700;\n  --fgColor-danger: #d1242f;\n  --fgColor-done: #8250df;\n  --bgColor-default: hsl(var(--background));\n  --bgColor-muted: #f6f8fa;\n  --bgColor-neutral-muted: #afb8c133;\n  --bgColor-attention-muted: #fff8c5;\n  --borderColor-default: #d0d7de;\n  --borderColor-muted: #d0d7deb3;\n  --borderColor-neutral-muted: #afb8c133;\n  --borderColor-accent-emphasis: #0969da;\n  --borderColor-success-emphasis: #1a7f37;\n  --borderColor-attention-emphasis: #bf8700;\n  --borderColor-danger-emphasis: #cf222e;\n  --borderColor-done-emphasis: #8250df;\n  --color-prettylights-syntax-comment: #57606a;\n  --color-prettylights-syntax-constant: #0550ae;\n  --color-prettylights-syntax-constant-other-reference-link: #0a3069;\n  --color-prettylights-syntax-entity: #6639ba;\n  --color-prettylights-syntax-storage-modifier-import: #24292f;\n  --color-prettylights-syntax-entity-tag: #0550ae;\n  --color-prettylights-syntax-keyword: #cf222e;\n  --color-prettylights-syntax-string: #0a3069;\n  --color-prettylights-syntax-variable: #953800;\n  --color-prettylights-syntax-brackethighlighter-unmatched: #82071e;\n  --color-prettylights-syntax-brackethighlighter-angle: #57606a;\n  --color-prettylights-syntax-invalid-illegal-text: #f6f8fa;\n  --color-prettylights-syntax-invalid-illegal-bg: #82071e;\n  --color-prettylights-syntax-carriage-return-text: #f6f8fa;\n  --color-prettylights-syntax-carriage-return-bg: #cf222e;\n  --color-prettylights-syntax-string-regexp: #116329;\n  --color-prettylights-syntax-markup-list: #3b2300;\n  --color-prettylights-syntax-markup-heading: #0550ae;\n  --color-prettylights-syntax-markup-italic: #24292f;\n  --color-prettylights-syntax-markup-bold: #24292f;\n  --color-prettylights-syntax-markup-deleted-text: #82071e;\n  --color-prettylights-syntax-markup-deleted-bg: #ffebe9;\n  --color-prettylights-syntax-markup-inserted-text: #116329;\n  --color-prettylights-syntax-markup-inserted-bg: #dafbe1;\n  --color-prettylights-syntax-markup-changed-text: #953800;\n  --color-prettylights-syntax-markup-changed-bg: #ffd8b5;\n  --color-prettylights-syntax-markup-ignored-text: #eaeef2;\n  --color-prettylights-syntax-markup-ignored-bg: #0550ae;\n  --color-prettylights-syntax-meta-diff-range: #8250df;\n  --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;\n}\n\n.markdown-body {\n  -ms-text-size-adjust: 100%;\n  -webkit-text-size-adjust: 100%;\n  margin: 0;\n  color: var(--fgColor-default);\n  background-color: var(--bgColor-default);\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial,\n    sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';\n  font-size: 14px;\n  line-height: 1.5;\n  word-wrap: break-word;\n  scroll-behavior: auto;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  fill: currentColor;\n  vertical-align: text-bottom;\n}\n\n.markdown-body h1:hover .anchor .octicon-link:before,\n.markdown-body h2:hover .anchor .octicon-link:before,\n.markdown-body h3:hover .anchor .octicon-link:before,\n.markdown-body h4:hover .anchor .octicon-link:before,\n.markdown-body h5:hover .anchor .octicon-link:before,\n.markdown-body h6:hover .anchor .octicon-link:before {\n  width: 16px;\n  height: 16px;\n  content: ' ';\n  display: inline-block;\n  background-color: currentColor;\n  -webkit-mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n  mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n}\n\n.markdown-body details,\n.markdown-body figcaption,\n.markdown-body figure {\n  display: block;\n}\n\n.markdown-body summary {\n  display: list-item;\n}\n\n.markdown-body [hidden] {\n  display: none !important;\n}\n\n.markdown-body a {\n  background-color: transparent;\n  color: var(--fgColor-accent);\n  text-decoration: none;\n}\n\n.markdown-body abbr[title] {\n  border-bottom: none;\n  -webkit-text-decoration: underline dotted;\n  text-decoration: underline dotted;\n}\n\n.markdown-body b,\n.markdown-body strong {\n  font-weight: var(--base-text-weight-semibold, 600);\n}\n\n.markdown-body dfn {\n  font-style: italic;\n}\n\n.markdown-body h1 {\n  margin: 0.67em 0;\n  font-weight: var(--base-text-weight-semibold, 600);\n  padding-bottom: 0.3em;\n  font-size: 2em;\n  border-bottom: 1px solid var(--borderColor-muted);\n}\n\n.markdown-body mark {\n  background-color: var(--bgColor-attention-muted);\n  color: var(--fgColor-default);\n}\n\n.markdown-body small {\n  font-size: 90%;\n}\n\n.markdown-body sub,\n.markdown-body sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.markdown-body sub {\n  bottom: -0.25em;\n}\n\n.markdown-body sup {\n  top: -0.5em;\n}\n\n.markdown-body img {\n  border-style: none;\n  max-width: 100%;\n  box-sizing: content-box;\n  background-color: var(--bgColor-default);\n}\n\n.markdown-body code,\n.markdown-body kbd,\n.markdown-body pre,\n.markdown-body samp {\n  font-family: monospace;\n  font-size: 1em;\n}\n\n.markdown-body figure {\n  margin: 1em 40px;\n}\n\n.markdown-body hr {\n  box-sizing: content-box;\n  overflow: hidden;\n  background: transparent;\n  border-bottom: 1px solid var(--borderColor-muted);\n  height: 0.25em;\n  padding: 0;\n  margin: 24px 0;\n  background-color: var(--borderColor-default);\n  border: 0;\n}\n\n.markdown-body input {\n  font: inherit;\n  margin: 0;\n  overflow: visible;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.markdown-body [type='button'],\n.markdown-body [type='reset'],\n.markdown-body [type='submit'] {\n  -webkit-appearance: button;\n  appearance: button;\n}\n\n.markdown-body [type='checkbox'],\n.markdown-body [type='radio'] {\n  box-sizing: border-box;\n  padding: 0;\n}\n\n.markdown-body [type='number']::-webkit-inner-spin-button,\n.markdown-body [type='number']::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.markdown-body [type='search']::-webkit-search-cancel-button,\n.markdown-body [type='search']::-webkit-search-decoration {\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-body ::-webkit-input-placeholder {\n  color: inherit;\n  opacity: 0.54;\n}\n\n.markdown-body ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  appearance: button;\n  font: inherit;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body ::placeholder {\n  color: var(--fgColor-muted);\n  opacity: 1;\n}\n\n.markdown-body hr::before {\n  display: table;\n  content: '';\n}\n\n.markdown-body hr::after {\n  display: table;\n  clear: both;\n  content: '';\n}\n\n.markdown-body table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n}\n\n.markdown-body td,\n.markdown-body th {\n  padding: 0;\n}\n\n.markdown-body details summary {\n  cursor: pointer;\n}\n\n.markdown-body details:not([open]) > *:not(summary) {\n  display: none;\n}\n\n.markdown-body a:focus,\n.markdown-body [role='button']:focus,\n.markdown-body input[type='radio']:focus,\n.markdown-body input[type='checkbox']:focus {\n  outline: 2px solid var(--focus-outlineColor);\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:focus:not(:focus-visible),\n.markdown-body [role='button']:focus:not(:focus-visible),\n.markdown-body input[type='radio']:focus:not(:focus-visible),\n.markdown-body input[type='checkbox']:focus:not(:focus-visible) {\n  outline: solid 1px transparent;\n}\n\n.markdown-body a:focus-visible,\n.markdown-body [role='button']:focus-visible,\n.markdown-body input[type='radio']:focus-visible,\n.markdown-body input[type='checkbox']:focus-visible {\n  outline: 2px solid var(--focus-outlineColor);\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:not([class]):focus,\n.markdown-body a:not([class]):focus-visible,\n.markdown-body input[type='radio']:focus,\n.markdown-body input[type='radio']:focus-visible,\n.markdown-body input[type='checkbox']:focus,\n.markdown-body input[type='checkbox']:focus-visible {\n  outline-offset: 0;\n}\n\n.markdown-body kbd {\n  display: inline-block;\n  padding: 3px 5px;\n  font: 11px\n    var(\n      --fontStack-monospace,\n      ui-monospace,\n      SFMono-Regular,\n      SF Mono,\n      Menlo,\n      Consolas,\n      Liberation Mono,\n      monospace\n    );\n  line-height: 10px;\n  color: var(--fgColor-default);\n  vertical-align: middle;\n  background-color: var(--bgColor-muted);\n  border: solid 1px var(--borderColor-neutral-muted);\n  border-bottom-color: var(--borderColor-neutral-muted);\n  border-radius: 6px;\n  box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted);\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  margin-top: 24px;\n  margin-bottom: 16px;\n  font-weight: var(--base-text-weight-semibold, 600);\n  line-height: 1.25;\n}\n\n.markdown-body h2 {\n  font-weight: var(--base-text-weight-semibold, 600);\n  padding-bottom: 0.3em;\n  font-size: 1.5em;\n  border-bottom: 1px solid var(--borderColor-muted);\n}\n\n.markdown-body h3 {\n  font-weight: var(--base-text-weight-semibold, 600);\n  font-size: 1.25em;\n}\n\n.markdown-body h4 {\n  font-weight: var(--base-text-weight-semibold, 600);\n  font-size: 1em;\n}\n\n.markdown-body h5 {\n  font-weight: var(--base-text-weight-semibold, 600);\n  font-size: 0.875em;\n}\n\n.markdown-body h6 {\n  font-weight: var(--base-text-weight-semibold, 600);\n  font-size: 0.85em;\n  color: var(--fgColor-muted);\n}\n\n.markdown-body p {\n  margin-top: 0;\n  margin-bottom: 10px;\n}\n\n.markdown-body blockquote {\n  margin: 0;\n  padding: 0 1em;\n  color: var(--fgColor-muted);\n  border-left: 0.25em solid var(--borderColor-default);\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  margin-top: 0;\n  margin-bottom: 0;\n  padding-left: 2em;\n  list-style: revert;\n}\n\n.markdown-body ol ol,\n.markdown-body ul ol {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ul ul ol,\n.markdown-body ul ol ol,\n.markdown-body ol ul ol,\n.markdown-body ol ol ol {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body dd {\n  margin-left: 0;\n}\n\n.markdown-body tt,\n.markdown-body code,\n.markdown-body samp {\n  font-family: var(\n    --fontStack-monospace,\n    ui-monospace,\n    SFMono-Regular,\n    SF Mono,\n    Menlo,\n    Consolas,\n    Liberation Mono,\n    monospace\n  );\n  font-size: 12px;\n}\n\n.markdown-body pre {\n  margin-top: 0;\n  margin-bottom: 0;\n  font-family: var(\n    --fontStack-monospace,\n    ui-monospace,\n    SFMono-Regular,\n    SF Mono,\n    Menlo,\n    Consolas,\n    Liberation Mono,\n    monospace\n  );\n  font-size: 12px;\n  word-wrap: normal;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  overflow: visible !important;\n  vertical-align: text-bottom;\n  fill: currentColor;\n}\n\n.markdown-body input::-webkit-outer-spin-button,\n.markdown-body input::-webkit-inner-spin-button {\n  margin: 0;\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-body .mr-2 {\n  margin-right: var(--base-size-8, 8px) !important;\n}\n\n.markdown-body::before {\n  display: table;\n  content: '';\n}\n\n.markdown-body::after {\n  display: table;\n  clear: both;\n  content: '';\n}\n\n.markdown-body > *:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body > *:last-child {\n  margin-bottom: 0 !important;\n}\n\n.markdown-body a:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\n.markdown-body .absent {\n  color: var(--fgColor-danger);\n}\n\n.markdown-body .anchor {\n  float: left;\n  padding-right: 4px;\n  margin-left: -20px;\n  line-height: 1;\n}\n\n.markdown-body .anchor:focus {\n  outline: none;\n}\n\n.markdown-body p,\n.markdown-body blockquote,\n.markdown-body ul,\n.markdown-body ol,\n.markdown-body dl,\n.markdown-body table,\n.markdown-body pre,\n.markdown-body details {\n  margin-top: 0;\n  margin-bottom: 16px;\n}\n\n.markdown-body blockquote > :first-child {\n  margin-top: 0;\n}\n\n.markdown-body blockquote > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body h1 .octicon-link,\n.markdown-body h2 .octicon-link,\n.markdown-body h3 .octicon-link,\n.markdown-body h4 .octicon-link,\n.markdown-body h5 .octicon-link,\n.markdown-body h6 .octicon-link {\n  color: var(--fgColor-default);\n  vertical-align: middle;\n  visibility: hidden;\n}\n\n.markdown-body h1:hover .anchor,\n.markdown-body h2:hover .anchor,\n.markdown-body h3:hover .anchor,\n.markdown-body h4:hover .anchor,\n.markdown-body h5:hover .anchor,\n.markdown-body h6:hover .anchor {\n  text-decoration: none;\n}\n\n.markdown-body h1:hover .anchor .octicon-link,\n.markdown-body h2:hover .anchor .octicon-link,\n.markdown-body h3:hover .anchor .octicon-link,\n.markdown-body h4:hover .anchor .octicon-link,\n.markdown-body h5:hover .anchor .octicon-link,\n.markdown-body h6:hover .anchor .octicon-link {\n  visibility: visible;\n}\n\n.markdown-body h1 tt,\n.markdown-body h1 code,\n.markdown-body h2 tt,\n.markdown-body h2 code,\n.markdown-body h3 tt,\n.markdown-body h3 code,\n.markdown-body h4 tt,\n.markdown-body h4 code,\n.markdown-body h5 tt,\n.markdown-body h5 code,\n.markdown-body h6 tt,\n.markdown-body h6 code {\n  padding: 0 0.2em;\n  font-size: inherit;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2,\n.markdown-body summary h3,\n.markdown-body summary h4,\n.markdown-body summary h5,\n.markdown-body summary h6 {\n  display: inline-block;\n}\n\n.markdown-body summary h1 .anchor,\n.markdown-body summary h2 .anchor,\n.markdown-body summary h3 .anchor,\n.markdown-body summary h4 .anchor,\n.markdown-body summary h5 .anchor,\n.markdown-body summary h6 .anchor {\n  margin-left: -40px;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2 {\n  padding-bottom: 0;\n  border-bottom: 0;\n}\n\n.markdown-body ul.no-list,\n.markdown-body ol.no-list {\n  padding: 0;\n  list-style-type: none;\n}\n\n.markdown-body ol[type='a s'] {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body ol[type='A s'] {\n  list-style-type: upper-alpha;\n}\n\n.markdown-body ol[type='i s'] {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ol[type='I s'] {\n  list-style-type: upper-roman;\n}\n\n.markdown-body ol[type='1'] {\n  list-style-type: decimal;\n}\n\n.markdown-body div > ol:not([type]) {\n  list-style-type: decimal;\n}\n\n.markdown-body ul ul,\n.markdown-body ul ol,\n.markdown-body ol ol,\n.markdown-body ol ul {\n  margin-top: 0;\n  margin-bottom: 0;\n}\n\n.markdown-body li > p {\n  margin-top: 16px;\n}\n\n.markdown-body li + li {\n  margin-top: 0.25em;\n}\n\n.markdown-body dl {\n  padding: 0;\n}\n\n.markdown-body dl dt {\n  padding: 0;\n  margin-top: 16px;\n  font-size: 1em;\n  font-style: italic;\n  font-weight: var(--base-text-weight-semibold, 600);\n}\n\n.markdown-body dl dd {\n  padding: 0 16px;\n  margin-bottom: 16px;\n}\n\n.markdown-body table th {\n  font-weight: var(--base-text-weight-semibold, 600);\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  padding: 6px 13px;\n  border: 1px solid var(--borderColor-default);\n}\n\n.markdown-body table td > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body table tr {\n  background-color: var(--bgColor-default);\n  border-top: 1px solid var(--borderColor-muted);\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: var(--bgColor-muted);\n}\n\n.markdown-body table img {\n  background-color: transparent;\n}\n\n.markdown-body img[align='right'] {\n  padding-left: 20px;\n}\n\n.markdown-body img[align='left'] {\n  padding-right: 20px;\n}\n\n.markdown-body .emoji {\n  max-width: none;\n  vertical-align: text-top;\n  background-color: transparent;\n}\n\n.markdown-body span.frame {\n  display: block;\n  overflow: hidden;\n}\n\n.markdown-body span.frame > span {\n  display: block;\n  float: left;\n  width: auto;\n  padding: 7px;\n  margin: 13px 0 0;\n  overflow: hidden;\n  border: 1px solid var(--borderColor-default);\n}\n\n.markdown-body span.frame span img {\n  display: block;\n  float: left;\n}\n\n.markdown-body span.frame span span {\n  display: block;\n  padding: 5px 0 0;\n  clear: both;\n  color: var(--fgColor-default);\n}\n\n.markdown-body span.align-center {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-center > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: center;\n}\n\n.markdown-body span.align-center span img {\n  margin: 0 auto;\n  text-align: center;\n}\n\n.markdown-body span.align-right {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-right > span {\n  display: block;\n  margin: 13px 0 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body span.align-right span img {\n  margin: 0;\n  text-align: right;\n}\n\n.markdown-body span.float-left {\n  display: block;\n  float: left;\n  margin-right: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-left span {\n  margin: 13px 0 0;\n}\n\n.markdown-body span.float-right {\n  display: block;\n  float: right;\n  margin-left: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-right > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body code,\n.markdown-body tt {\n  padding: 0.2em 0.4em;\n  margin: 0;\n  font-size: 85%;\n  white-space: break-spaces;\n  background-color: var(--bgColor-neutral-muted);\n  border-radius: 6px;\n}\n\n.markdown-body code br,\n.markdown-body tt br {\n  display: none;\n}\n\n.markdown-body del code {\n  text-decoration: inherit;\n}\n\n.markdown-body samp {\n  font-size: 85%;\n}\n\n.markdown-body pre code {\n  font-size: 100%;\n}\n\n.markdown-body pre > code {\n  padding: 0;\n  margin: 0;\n  word-break: normal;\n  white-space: pre;\n  background: transparent;\n  border: 0;\n}\n\n.markdown-body .highlight {\n  margin-bottom: 16px;\n}\n\n.markdown-body .highlight pre {\n  margin-bottom: 0;\n  word-break: normal;\n}\n\n.markdown-body .highlight pre,\n.markdown-body pre {\n  padding: 16px;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  color: var(--fgColor-default);\n  background-color: var(--bgColor-muted);\n  border-radius: 6px;\n}\n\n.markdown-body pre code,\n.markdown-body pre tt {\n  display: inline;\n  max-width: auto;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-body .csv-data td,\n.markdown-body .csv-data th {\n  padding: 5px;\n  overflow: hidden;\n  font-size: 12px;\n  line-height: 1;\n  text-align: left;\n  white-space: nowrap;\n}\n\n.markdown-body .csv-data .blob-num {\n  padding: 10px 8px 9px;\n  text-align: right;\n  background: var(--bgColor-default);\n  border: 0;\n}\n\n.markdown-body .csv-data tr {\n  border-top: 0;\n}\n\n.markdown-body .csv-data th {\n  font-weight: var(--base-text-weight-semibold, 600);\n  background: var(--bgColor-muted);\n  border-top: 0;\n}\n\n.markdown-body [data-footnote-ref]::before {\n  content: '[';\n}\n\n.markdown-body [data-footnote-ref]::after {\n  content: ']';\n}\n\n.markdown-body .footnotes {\n  font-size: 12px;\n  color: var(--fgColor-muted);\n  border-top: 1px solid var(--borderColor-default);\n}\n\n.markdown-body .footnotes ol {\n  padding-left: 16px;\n}\n\n.markdown-body .footnotes ol ul {\n  display: inline-block;\n  padding-left: 16px;\n  margin-top: 16px;\n}\n\n.markdown-body .footnotes li {\n  position: relative;\n}\n\n.markdown-body .footnotes li:target::before {\n  position: absolute;\n  top: -8px;\n  right: -8px;\n  bottom: -8px;\n  left: -24px;\n  pointer-events: none;\n  content: '';\n  border: 2px solid var(--borderColor-accent-emphasis);\n  border-radius: 6px;\n}\n\n.markdown-body .footnotes li:target {\n  color: var(--fgColor-default);\n}\n\n.markdown-body .footnotes .data-footnote-backref g-emoji {\n  font-family: monospace;\n}\n\n.markdown-body .pl-c {\n  color: var(--color-prettylights-syntax-comment);\n}\n\n.markdown-body .pl-c1,\n.markdown-body .pl-s .pl-v {\n  color: var(--color-prettylights-syntax-constant);\n}\n\n.markdown-body .pl-e,\n.markdown-body .pl-en {\n  color: var(--color-prettylights-syntax-entity);\n}\n\n.markdown-body .pl-smi,\n.markdown-body .pl-s .pl-s1 {\n  color: var(--color-prettylights-syntax-storage-modifier-import);\n}\n\n.markdown-body .pl-ent {\n  color: var(--color-prettylights-syntax-entity-tag);\n}\n\n.markdown-body .pl-k {\n  color: var(--color-prettylights-syntax-keyword);\n}\n\n.markdown-body .pl-s,\n.markdown-body .pl-pds,\n.markdown-body .pl-s .pl-pse .pl-s1,\n.markdown-body .pl-sr,\n.markdown-body .pl-sr .pl-cce,\n.markdown-body .pl-sr .pl-sre,\n.markdown-body .pl-sr .pl-sra {\n  color: var(--color-prettylights-syntax-string);\n}\n\n.markdown-body .pl-v,\n.markdown-body .pl-smw {\n  color: var(--color-prettylights-syntax-variable);\n}\n\n.markdown-body .pl-bu {\n  color: var(--color-prettylights-syntax-brackethighlighter-unmatched);\n}\n\n.markdown-body .pl-ii {\n  color: var(--color-prettylights-syntax-invalid-illegal-text);\n  background-color: var(--color-prettylights-syntax-invalid-illegal-bg);\n}\n\n.markdown-body .pl-c2 {\n  color: var(--color-prettylights-syntax-carriage-return-text);\n  background-color: var(--color-prettylights-syntax-carriage-return-bg);\n}\n\n.markdown-body .pl-sr .pl-cce {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-string-regexp);\n}\n\n.markdown-body .pl-ml {\n  color: var(--color-prettylights-syntax-markup-list);\n}\n\n.markdown-body .pl-mh,\n.markdown-body .pl-mh .pl-en,\n.markdown-body .pl-ms {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-markup-heading);\n}\n\n.markdown-body .pl-mi {\n  font-style: italic;\n  color: var(--color-prettylights-syntax-markup-italic);\n}\n\n.markdown-body .pl-mb {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-markup-bold);\n}\n\n.markdown-body .pl-md {\n  color: var(--color-prettylights-syntax-markup-deleted-text);\n  background-color: var(--color-prettylights-syntax-markup-deleted-bg);\n}\n\n.markdown-body .pl-mi1 {\n  color: var(--color-prettylights-syntax-markup-inserted-text);\n  background-color: var(--color-prettylights-syntax-markup-inserted-bg);\n}\n\n.markdown-body .pl-mc {\n  color: var(--color-prettylights-syntax-markup-changed-text);\n  background-color: var(--color-prettylights-syntax-markup-changed-bg);\n}\n\n.markdown-body .pl-mi2 {\n  color: var(--color-prettylights-syntax-markup-ignored-text);\n  background-color: var(--color-prettylights-syntax-markup-ignored-bg);\n}\n\n.markdown-body .pl-mdr {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-meta-diff-range);\n}\n\n.markdown-body .pl-ba {\n  color: var(--color-prettylights-syntax-brackethighlighter-angle);\n}\n\n.markdown-body .pl-sg {\n  color: var(--color-prettylights-syntax-sublimelinter-gutter-mark);\n}\n\n.markdown-body .pl-corl {\n  text-decoration: underline;\n  color: var(--color-prettylights-syntax-constant-other-reference-link);\n}\n\n.markdown-body [role='button']:focus:not(:focus-visible),\n.markdown-body [role='tabpanel'][tabindex='0']:focus:not(:focus-visible),\n.markdown-body button:focus:not(:focus-visible),\n.markdown-body summary:focus:not(:focus-visible),\n.markdown-body a:focus:not(:focus-visible) {\n  outline: none;\n  box-shadow: none;\n}\n\n.markdown-body [tabindex='0']:focus:not(:focus-visible),\n.markdown-body details-dialog:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.markdown-body g-emoji {\n  display: inline-block;\n  min-width: 1ch;\n  font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';\n  font-size: 1em;\n  font-style: normal !important;\n  font-weight: var(--base-text-weight-normal, 400);\n  line-height: 1;\n  vertical-align: -0.075em;\n}\n\n.markdown-body g-emoji img {\n  width: 1em;\n  height: 1em;\n}\n\n.markdown-body .task-list-item {\n  list-style-type: none;\n}\n\n.markdown-body .task-list-item label {\n  font-weight: var(--base-text-weight-normal, 400);\n}\n\n.markdown-body .task-list-item.enabled label {\n  cursor: pointer;\n}\n\n.markdown-body .task-list-item + .task-list-item {\n  margin-top: var(--base-size-4);\n}\n\n.markdown-body .task-list-item .handle {\n  display: none;\n}\n\n.markdown-body .task-list-item-checkbox {\n  margin: 0 0.2em 0.25em -1.4em;\n  vertical-align: middle;\n}\n\n.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em 0.25em 0.2em;\n}\n\n.markdown-body .contains-task-list {\n  position: relative;\n}\n\n.markdown-body .contains-task-list:hover .task-list-item-convert-container,\n.markdown-body .contains-task-list:focus-within .task-list-item-convert-container {\n  display: block;\n  width: auto;\n  height: 24px;\n  overflow: visible;\n  clip: auto;\n}\n\n.markdown-body ::-webkit-calendar-picker-indicator {\n  filter: invert(50%);\n}\n\n.markdown-body .markdown-alert {\n  padding: var(--base-size-8) var(--base-size-16);\n  margin-bottom: var(--base-size-16);\n  color: inherit;\n  border-left: 0.25em solid var(--borderColor-default);\n}\n\n.markdown-body .markdown-alert > :first-child {\n  margin-top: 0;\n}\n\n.markdown-body .markdown-alert > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body .markdown-alert .markdown-alert-title {\n  display: flex;\n  font-weight: var(--base-text-weight-medium, 500);\n  align-items: center;\n  line-height: 1;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note {\n  border-left-color: var(--borderColor-accent-emphasis);\n}\n\n.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {\n  color: var(--fgColor-accent);\n}\n\n.markdown-body .markdown-alert.markdown-alert-important {\n  border-left-color: var(--borderColor-done-emphasis);\n}\n\n.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {\n  color: var(--fgColor-done);\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning {\n  border-left-color: var(--borderColor-attention-emphasis);\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {\n  color: var(--fgColor-attention);\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip {\n  border-left-color: var(--borderColor-success-emphasis);\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {\n  color: var(--fgColor-success);\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution {\n  border-left-color: var(--borderColor-danger-emphasis);\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {\n  color: var(--fgColor-danger);\n}\n\n.markdown-body > *:first-child > .heading-element:first-child {\n  margin-top: 0 !important;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/styles/global.css",
    "content": "@import url('@teable/ui-lib/shadcn/global.shadcn.css');\n@import url('nprogress/nprogress.css');\n@import url('@/styles/github-markdown.css');\n@import url('@/features/app/blocks/chart/globals.css');\n@import url('@teable/sdk/components/editor/long-text/milkdown.css');\n\n:root {\n  --btn-text-case: none;\n  --focus-border: hsl(var(--primary)) !important;\n  --separator-border: hsl(var(--border)) !important;\n  --fc-border-color: hsl(var(--border));\n}\n\nsvg.icon {\n  fill: currentColor;\n}\n\n[type='search']:focus {\n  box-shadow: inherit;\n}\n\nhtml,\nbody {\n  overflow: hidden;\n}\n\n.select-field-type [cmdk-list] {\n  max-height: min(600px, calc(100vh - 450px));\n}\n\n.height-preserving-container:empty {\n  min-height: calc(var(--child-height));\n  box-sizing: border-box;\n}\n\n.react-grid-item.react-grid-placeholder {\n  z-index: 0 !important;\n  border-radius: 0.75rem;\n  border-color: hsl(var(--primary)) !important;\n  background-color: hsl(var(--muted-foreground)) !important;\n}\n\n.fc-scrollgrid-section > td {\n  border-radius: 0px 0px 8px 8px !important;\n}\n\n.fc-scrollgrid-section > th {\n  border-radius: 0px 8px 0px 0px !important;\n}\n\n.fc-scrollgrid,\n.fc-col-header {\n  border-radius: 8px !important;\n}\n\n.fc table {\n  font-size: 14px;\n}\n\n.fc th {\n  font-weight: normal;\n}\n\n.fc .fc-highlight {\n  @apply bg-purple-200/30 dark:bg-purple-800/30 !important;\n}\n\n.fc-daygrid-day-number {\n  @apply text-[13px] py-[2px] px-0 !important;\n}\n\n.fc .fc-daygrid-day.fc-day-today {\n  @apply bg-transparent !important;\n}\n\n.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number {\n  @apply bg-foreground text-background rounded-md px-1 !important;\n}\n\n.fc-daygrid-event-harness {\n  margin-bottom: 1px !important;\n}\n\n.fc-daygrid-more-link {\n  @apply w-full flex justify-center !important;\n}\n\n.fc-daygrid-more-link:hover {\n  @apply bg-transparent !important;\n}\n\n.fc-popover {\n  @apply border-none rounded-md z-10 invisible !important;\n}\n\n.fc-popover-header {\n  @apply text-sm !important;\n}\n\n.fc-popover-body {\n  @apply max-h-[320px] overflow-y-auto text-sm !important;\n}\n\n.fc-daygrid-day-top {\n  @apply pointer-events-none pt-[2px] px-[2px] !important;\n}\n\n.fc-col-header-cell-cushion {\n  @apply w-full !important;\n}\n\n.fc-event-resizer-start,\n.fc-event-resizer-end {\n  @apply cursor-ew-resize !important;\n}\n\n.fc-event-resizer-start {\n  @apply bg-zinc-500/30 w-2 rounded-l-sm left-0 !important;\n}\n\n.fc-event-resizer-end {\n  @apply bg-zinc-500/30 w-2 rounded-r-sm right-0 !important;\n}\n\n.fc-event-title {\n  @apply truncate !important;\n}\n\n.fc-event:focus {\n  @apply shadow-none !important;\n}\n\n.fc-event:focus:after {\n  @apply bg-transparent !important;\n}\n\n.cm-placeholder {\n  color: hsl(var(--muted));\n  display: inline-block;\n  pointer-events: none;\n  padding-left: 0.3rem;\n  font-size: small;\n}\n\n/* React Flow Controls dark mode support */\n.react-flow__controls {\n  border-radius: 0.375rem;\n  border: 1px solid hsl(var(--border));\n  overflow: hidden;\n  box-shadow: none !important;\n}\n\n.react-flow__controls-button {\n  background-color: hsl(var(--popover)) !important;\n  border-color: hsl(var(--border)) !important;\n  fill: hsl(var(--foreground)) !important;\n}\n\n.react-flow__controls-button:hover {\n  background-color: hsl(var(--accent)) !important;\n}\n\n.react-flow__controls-button:last-child {\n  border-bottom: none !important;\n}\n\n.react-flow__controls-button svg {\n  fill: currentColor !important;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/colors/index.ts",
    "content": "export const colors = {\n  light: {\n    info: '#3bbff7',\n    infoContent: '#002b3e',\n    success: '#37d39a',\n    successContent: '#013321',\n    warning: '#fabd23',\n    warningContent: '#fabd23',\n    error: '#f97272',\n    errorContent: '#480000',\n  },\n  dark: {\n    info: '#3bbff7',\n    infoContent: '#002b3e',\n    success: '#37d39a',\n    successContent: '#013321',\n    warning: '#fabd23',\n    warningContent: '#fabd23',\n    error: '#f97272',\n    errorContent: '#480000',\n  },\n};\n\nexport type IColor = typeof colors.light | typeof colors.dark;\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/shared/__tests__/colors.test.ts",
    "content": "import { tailwindV3Colors } from '../colors';\ndescribe('colors', () => {\n  describe('tailwindV3Colors', () => {\n    it('should be defined', () => {\n      expect(tailwindV3Colors).toBeDefined();\n    });\n    it('should contain current', () => {\n      expect(tailwindV3Colors?.current).toBeDefined();\n    });\n    it(\"shouldn't contain deprecated colors\", () => {\n      expect(tailwindV3Colors?.warmGray).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/shared/browser-fonts.js",
    "content": "// @ts-check\n// Keep this file as '.js' as it's included in tailwind.config.js\n\n/**\n * @type {{mono: string[], sans: string[], serif: string[]}}\n */\nconst browserFonts = {\n  sans: [\n    'ui-sans-serif',\n    'system-ui',\n    '-apple-system',\n    'BlinkMacSystemFont',\n    'Segoe UI',\n    'Roboto',\n    'Helvetica Neue',\n    'Arial',\n    'Noto Sans',\n    'sans-serif',\n    'Apple Color Emoji',\n    'Segoe UI Emoji',\n    'Segoe UI Symbol',\n    'Noto Color Emoji',\n  ],\n  serif: ['ui-serif', 'Georgia', 'Cambria', 'Times New Roman', 'Times', 'serif'],\n  mono: [\n    'ui-monospace',\n    'SFMono-Regular',\n    'Menlo',\n    'Monaco',\n    'Consolas',\n    'Liberation Mono',\n    'Courier New',\n    'monospace',\n  ],\n};\n\nmodule.exports = {\n  browserFonts: browserFonts,\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/shared/colors.js",
    "content": "const tailwindColors = require('tailwindcss/colors');\n\n/**\n * Return tailwind v3 non-deprecated colors\n * PS: code is dirty cause tailwind colors have getters on them\n *     that will log a warning when accessing the object key\n * @type {Record<string, string | Record<string, string>>}\n */\nconst tailwindV3Colors = Object.entries(Object.getOwnPropertyDescriptors(tailwindColors))\n  .filter(\n    ([, desc]) =>\n      Object.prototype.hasOwnProperty.call(desc, 'value') && typeof desc.value !== 'function'\n  )\n  .reduce((acc, [key]) => {\n    if (!['coolGray', 'lightBlue', 'warmGray', 'trueGray', 'blueGray'].includes(key)) {\n      acc[key] = tailwindColors[key];\n    }\n    return acc;\n  }, {});\n\nmodule.exports = { tailwindV3Colors };\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/tailwind/tailwind.theme.js",
    "content": "// @ts-check\n// Keep this file as '.js' as it's included in tailwind.config.js\n\nconst { browserFonts } = require('../shared/browser-fonts');\n\nmodule.exports = {\n  fontFamily: {\n    sans: ['Inter Variable', ...browserFonts.sans],\n  },\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/type.ts",
    "content": "import type { IColor } from './colors';\n\nexport enum ThemeName {\n  Light = 'light',\n  Dark = 'dark',\n}\n\nexport interface ITheme {\n  color: IColor;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/themes/utils.ts",
    "content": "import { kebabCase } from 'lodash';\nimport type { IColor } from './colors';\nimport type { ThemeName } from './type';\n\nexport const getColorsCssVariablesText = (themeData: { [key in ThemeName]: IColor }) => {\n  return Object.entries(themeData)\n    .map(([themeName, properties]) => {\n      const cssVariables = Object.entries(properties)\n        .map(([key, value]) => `  --${kebabCase(key)}: ${value};`)\n        .join('\\n');\n      return `:root[data-theme=\"${themeName}\"] {\\n${cssVariables} \\n}\\n`;\n    })\n    .join('\\n');\n};\n"
  },
  {
    "path": "apps/nextjs-app/src/types.d/i18next.d.ts",
    "content": "/**\n * Types augmentation for translation keys to allow to typecheck\n * and suggesting keys to the t function. In case it's too slow\n * you can opt out by commenting the following code.\n * @link https://react.i18next.com/latest/typescript\n */\nimport 'i18next';\nimport type { I18nNamespaces } from '@teable/common-i18n';\n\ndeclare module 'i18next' {\n  interface CustomTypeOptions {\n    defaultNS: 'common';\n    resources: I18nNamespaces;\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/types.d/next-i18next.d.ts",
    "content": "/**\n * Types augmentation for translation keys to allow to typecheck\n * and suggesting keys to the t function. In case it's too slow\n * you can opt out by commenting the following code.\n * @link https://react.i18next.com/latest/typescript\n */\nimport 'next-i18next';\nimport type { I18nNamespaces } from '@teable/common-i18n';\n\ndeclare module 'next-i18next' {\n  interface CustomTypeOptions {\n    defaultNS: 'common';\n    resources: I18nNamespaces;\n  }\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/types.d/react-svgr.d.ts",
    "content": "/**\n * An example declaration for svg if you're relying on https://react-svgr.com/\n * and @svgr/webpack equivalent strategy.\n *\n * This definition will improve type completion experience.\n *\n * @link {https://github.com/gregberge/svgr/issues/546|For issue followup}\n * @link {https://github.com/gregberge/svgr/pull/573|To follow upcoming improvements}\n *\n * If you're NOT using @svgr/webpack, be sure the svg definition is equivalent to\n *\n * ```\n * declare module \"*.svg\" {\n *   const svg: string;\n *   export default svg;\n * }\n * ```\n */\n\ndeclare module '*.svg' {\n  import type React from 'react';\n  const svg: React.VFC<React.SVGProps<SVGSVGElement>>;\n  export default svg;\n}\n"
  },
  {
    "path": "apps/nextjs-app/src/types.d/umami.d.ts",
    "content": "declare interface Window {\n  umami?: {\n    identify: (props: { email?: string; id?: string; name?: string }) => void;\n  };\n}\n"
  },
  {
    "path": "apps/nextjs-app/tailwind.config.js",
    "content": "const { join } = require('path');\nconst uiConfig = require('@teable/ui-lib/ui.config.cjs');\nconst filePath = join(__dirname, './src/**/*.{js,ts,jsx,tsx}');\nconst sdkPath = join(__dirname, '../../packages/sdk/src/**/*.{js,ts,jsx,tsx}');\nconst uiLibPath = join(__dirname, '../../packages/ui-lib/src/**/*.{js,ts,jsx,tsx}');\nconst scrollbarPlugin = require('tailwind-scrollbar');\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = uiConfig({\n  content: [filePath, sdkPath, uiLibPath],\n  darkMode: 'class',\n  theme: {},\n  plugins: [\n    scrollbarPlugin({ nocompatible: true }),\n    function ({ addUtilities }) {\n      const newUtilities = {\n        '.scrollbar-min-thumb': {\n          '&::-webkit-scrollbar-thumb': {\n            minHeight: '32px',\n          },\n          '&::-webkit-scrollbar-thumb:vertical': {\n            minHeight: '32px',\n          },\n        },\n      };\n\n      addUtilities(newUtilities);\n    },\n    require('@tailwindcss/container-queries'),\n  ],\n});\n"
  },
  {
    "path": "apps/nextjs-app/tsconfig.eslint.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"module\": \"esnext\",\n    \"jsx\": \"preserve\",\n    \"experimentalDecorators\": true\n  },\n  \"exclude\": [\"node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\",\n    \".next/types/**/*.ts\"\n  ]\n}\n"
  },
  {
    "path": "apps/nextjs-app/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \"./src\",\n    \"target\": \"esnext\",\n    \"experimentalDecorators\": true,\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"module\": \"esnext\",\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"paths\": {\n      \"@/test-utils\": [\"../config/tests/test-utils\"],\n      \"@/config/*\": [\"./config/*\"],\n      \"@/lib/*\": [\"./lib/*\"],\n      \"@/components/*\": [\"./components/*\"],\n      \"@/backend/*\": [\"./backend/*\"],\n      \"@/features/*\": [\"./features/*\"],\n      \"@/pages/*\": [\"./pages/*\"],\n      \"@/public/*\": [\"../public/*\"],\n      \"@/themes/*\": [\"./themes/*\"],\n      \"@/styles/*\": [\"./styles/*\"],\n      \"@/api/*\": [\"./api/*\"],\n      \"@overridable/SpaceInnerSettingModal\": [\n        \"./features/app/blocks/space-setting/SpaceInnerSettingModal\"\n      ],\n      \"@overridable/WorkFlowPanel\": [\"./features/app/automation/workflow-panel/WorkFlowPanel\"],\n      \"@overridable/SettingDialog\": [\"./features/app/components/setting/SettingDialog\"],\n      \"@teable/common-i18n\": [\"../../../packages/common-i18n/src/index\"],\n      \"@teable/common-i18n/locales/*\": [\"../../../packages/common-i18n/src/locales/*\"],\n      \"@teable/ui-lib/*\": [\"../../../packages/ui-lib/src/*\"],\n      \"@teable/ui-lib\": [\"../../../packages/ui-lib/src/index\"],\n      \"@teable/sdk/*\": [\"../../../packages/sdk/src/*\"],\n      \"@teable/sdk\": [\"../../../packages/sdk/src/index\"],\n      \"@teable/db-main-prisma\": [\"../../../packages/db-main-prisma/src/index\"],\n      \"@teable/core\": [\"../../../packages/core/src/index\"],\n      \"@teable/openapi\": [\"../../../packages/openapi/src/index\"],\n      \"@teable/icons\": [\"../../../packages/icons/src/index\"],\n      \"@teable/formula\": [\"../../../packages/formula/src/index\"]\n    },\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"types\": [\"vitest/globals\", \"node\"]\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/\"],\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\",\n    \".next/types/**/*.ts\"\n  ]\n}\n"
  },
  {
    "path": "apps/nextjs-app/tsconfig.scripts.json",
    "content": "{\n  \"compilerOptions\": {\n    \"strictNullChecks\": true,\n    \"module\": \"NodeNext\",\n    \"esModuleInterop\": true\n  },\n  \"include\": [\"**/*.ts\", \"**/*.tsx\"]\n}\n"
  },
  {
    "path": "apps/nextjs-app/turbopack-empty-stub.js",
    "content": "// Empty stub for Node.js fs module (browser build only)\n// Equivalent to webpack's: config.resolve.fallback = { fs: false }\nmodule.exports = {};\n"
  },
  {
    "path": "apps/nextjs-app/vitest.config.ts",
    "content": "import react from '@vitejs/plugin-react-swc';\nimport svgr from 'vite-plugin-svgr';\nimport tsconfigPaths from 'vite-tsconfig-paths';\nimport { configDefaults, defineConfig } from 'vitest/config';\n\nconst testFiles = ['./src/**/*.{test,spec}.{js,jsx,ts,tsx}'];\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  plugins: [\n    react({\n      devTarget: 'es2022',\n    }),\n    tsconfigPaths(),\n    svgr({\n      // svgr options: https://react-svgr.com/docs/options/\n      svgrOptions: {},\n    }),\n  ],\n  cacheDir: '../../.cache/vitest/nextjs-app',\n  test: {\n    globals: true,\n    environment: 'happy-dom',\n    passWithNoTests: false,\n    setupFiles: './config/tests/setupVitest.ts',\n    coverage: {\n      provider: 'v8',\n      include: ['src/**/*.{js,jsx,ts,tsx}', 'config/**/*.{js,jsx,ts,tsx}'],\n    },\n    include: testFiles,\n    exclude: [...configDefaults.exclude, '**/.next/**'],\n  },\n});\n"
  },
  {
    "path": "apps/playground/.cta.json",
    "content": "{\n  \"projectName\": \"playground\",\n  \"mode\": \"file-router\",\n  \"typescript\": true,\n  \"tailwind\": true,\n  \"packageManager\": \"pnpm\",\n  \"git\": false,\n  \"addOnOptions\": {},\n  \"version\": 1,\n  \"framework\": \"react-cra\",\n  \"chosenAddOns\": [\"start\", \"shadcn\", \"oRPC\", \"nitro\", \"tanstack-query\"]\n}\n"
  },
  {
    "path": "apps/playground/.cursorrules",
    "content": "# shadcn instructions\n\nUse the latest version of Shadcn to install new components, like this command to add a button component:\n\n```bash\npnpm dlx shadcn@latest add button\n```\n"
  },
  {
    "path": "apps/playground/.gitignore",
    "content": "node_modules\n.DS_Store\ndist\ndist-ssr\n*.local\ncount.txt\n.env\n.nitro\n.tanstack\n.wrangler\n.output\n.vinxi\ntodos.json\n"
  },
  {
    "path": "apps/playground/.vscode/settings.json",
    "content": "{\n  \"files.watcherExclude\": {\n    \"**/routeTree.gen.ts\": true\n  },\n  \"search.exclude\": {\n    \"**/routeTree.gen.ts\": true\n  },\n  \"files.readonlyInclude\": {\n    \"**/routeTree.gen.ts\": true\n  }\n}\n"
  },
  {
    "path": "apps/playground/README.md",
    "content": "Welcome to your new TanStack app!\n\n## Playground Hook Notes (AI)\n\n- Prefer hooks from `usehooks-ts` before writing custom hooks.\n- Clipboard: use `useCopyToClipboard`.\n- Debounce: use `useDebounceValue` or `useDebounceCallback`.\n\n# Getting Started\n\nTo run this application:\n\n```bash\npnpm install\npnpm start\n```\n\n# Building For Production\n\nTo build this application for production:\n\n```bash\npnpm build\n```\n\n## Testing\n\nThis project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:\n\n```bash\npnpm test\n```\n\n## Styling\n\nThis project uses [Tailwind CSS](https://tailwindcss.com/) for styling.\n\n## Shadcn\n\nAdd components using the latest version of [Shadcn](https://ui.shadcn.com/).\n\n```bash\npnpm dlx shadcn@latest add button\n```\n\n## Routing\n\nThis project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.\n\n### Adding A Route\n\nTo add a new route to your application just add another a new file in the `./src/routes` directory.\n\nTanStack will automatically generate the content of the route file for you.\n\nNow that you have two routes you can use a `Link` component to navigate between them.\n\n### Adding Links\n\nTo use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.\n\n```tsx\nimport { Link } from \"@tanstack/react-router\";\n```\n\nThen anywhere in your JSX you can use it like so:\n\n```tsx\n<Link to=\"/about\">About</Link>\n```\n\nThis will create a link that will navigate to the `/about` route.\n\nMore information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).\n\n### Using A Layout\n\nIn the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.\n\nHere is an example layout that includes a header:\n\n```tsx\nimport { Outlet, createRootRoute } from \"@tanstack/react-router\";\nimport { TanStackRouterDevtools } from \"@tanstack/react-router-devtools\";\n\nimport { Link } from \"@tanstack/react-router\";\n\nexport const Route = createRootRoute({\n  component: () => (\n    <>\n      <header>\n        <nav>\n          <Link to=\"/\">Home</Link>\n          <Link to=\"/about\">About</Link>\n        </nav>\n      </header>\n      <Outlet />\n      <TanStackRouterDevtools />\n    </>\n  ),\n});\n```\n\nThe `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.\n\nMore information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).\n\n## Data Fetching\n\nThere are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.\n\nFor example:\n\n```tsx\nconst peopleRoute = createRoute({\n  getParentRoute: () => rootRoute,\n  path: \"/people\",\n  loader: async () => {\n    const response = await fetch(\"https://swapi.dev/api/people\");\n    return response.json() as Promise<{\n      results: {\n        name: string;\n      }[];\n    }>;\n  },\n  component: () => {\n    const data = peopleRoute.useLoaderData();\n    return (\n      <ul>\n        {data.results.map((person) => (\n          <li key={person.name}>{person.name}</li>\n        ))}\n      </ul>\n    );\n  },\n});\n```\n\nLoaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).\n\n### React-Query\n\nReact-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.\n\nFirst add your dependencies:\n\n```bash\npnpm add @tanstack/react-query @tanstack/react-query-devtools\n```\n\nNext we'll need to create a query client and provider. We recommend putting those in `main.tsx`.\n\n```tsx\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\n\n// ...\n\nconst queryClient = new QueryClient();\n\n// ...\n\nif (!rootElement.innerHTML) {\n  const root = ReactDOM.createRoot(rootElement);\n\n  root.render(\n    <QueryClientProvider client={queryClient}>\n      <RouterProvider router={router} />\n    </QueryClientProvider>\n  );\n}\n```\n\nYou can also add TanStack Query Devtools to the root route (optional).\n\n```tsx\nimport { ReactQueryDevtools } from \"@tanstack/react-query-devtools\";\n\nconst rootRoute = createRootRoute({\n  component: () => (\n    <>\n      <Outlet />\n      <ReactQueryDevtools buttonPosition=\"top-right\" />\n      <TanStackRouterDevtools />\n    </>\n  ),\n});\n```\n\nNow you can use `useQuery` to fetch your data.\n\n```tsx\nimport { useQuery } from \"@tanstack/react-query\";\n\nimport \"./App.css\";\n\nfunction App() {\n  const { data } = useQuery({\n    queryKey: [\"people\"],\n    queryFn: () =>\n      fetch(\"https://swapi.dev/api/people\")\n        .then((res) => res.json())\n        .then((data) => data.results as { name: string }[]),\n    initialData: [],\n  });\n\n  return (\n    <div>\n      <ul>\n        {data.map((person) => (\n          <li key={person.name}>{person.name}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nexport default App;\n```\n\nYou can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).\n\n## State Management\n\nAnother common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.\n\nFirst you need to add TanStack Store as a dependency:\n\n```bash\npnpm add @tanstack/store\n```\n\nNow let's create a simple counter in the `src/App.tsx` file as a demonstration.\n\n```tsx\nimport { useStore } from \"@tanstack/react-store\";\nimport { Store } from \"@tanstack/store\";\nimport \"./App.css\";\n\nconst countStore = new Store(0);\n\nfunction App() {\n  const count = useStore(countStore);\n  return (\n    <div>\n      <button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>\n    </div>\n  );\n}\n\nexport default App;\n```\n\nOne of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.\n\nLet's check this out by doubling the count using derived state.\n\n```tsx\nimport { useStore } from \"@tanstack/react-store\";\nimport { Store, Derived } from \"@tanstack/store\";\nimport \"./App.css\";\n\nconst countStore = new Store(0);\n\nconst doubledStore = new Derived({\n  fn: () => countStore.state * 2,\n  deps: [countStore],\n});\ndoubledStore.mount();\n\nfunction App() {\n  const count = useStore(countStore);\n  const doubledCount = useStore(doubledStore);\n\n  return (\n    <div>\n      <button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>\n      <div>Doubled - {doubledCount}</div>\n    </div>\n  );\n}\n\nexport default App;\n```\n\nWe use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.\n\nOnce we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.\n\nYou can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).\n\n# Demo files\n\nFiles prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.\n\n# Learn More\n\nYou can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).\n"
  },
  {
    "path": "apps/playground/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/styles.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "apps/playground/package.json",
    "content": "{\n  \"name\": \"playground\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest run --passWithNoTests\"\n  },\n  \"dependencies\": {\n    \"@electric-sql/pglite\": \"0.3.14\",\n    \"@hookform/resolvers\": \"3.3.4\",\n    \"@opentelemetry/api\": \"1.9.0\",\n    \"@opentelemetry/exporter-trace-otlp-http\": \"0.201.1\",\n    \"@opentelemetry/instrumentation\": \"0.201.1\",\n    \"@opentelemetry/instrumentation-fetch\": \"0.201.1\",\n    \"@opentelemetry/instrumentation-pg\": \"0.49.0\",\n    \"@opentelemetry/resources\": \"2.0.1\",\n    \"@opentelemetry/sdk-node\": \"0.201.1\",\n    \"@opentelemetry/sdk-trace-base\": \"2.0.1\",\n    \"@opentelemetry/sdk-trace-web\": \"2.0.1\",\n    \"@opentelemetry/semantic-conventions\": \"1.34.0\",\n    \"@orpc/client\": \"^1.13.0\",\n    \"@orpc/contract\": \"^1.13.0\",\n    \"@orpc/experimental-pino\": \"^1.13.0\",\n    \"@orpc/otel\": \"^1.13.0\",\n    \"@orpc/server\": \"^1.13.0\",\n    \"@orpc/tanstack-query\": \"^1.13.0\",\n    \"@radix-ui/react-alert-dialog\": \"1.1.15\",\n    \"@radix-ui/react-checkbox\": \"1.3.3\",\n    \"@radix-ui/react-context-menu\": \"2.2.16\",\n    \"@radix-ui/react-dialog\": \"1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"2.1.16\",\n    \"@radix-ui/react-icons\": \"1.3.2\",\n    \"@radix-ui/react-label\": \"2.1.8\",\n    \"@radix-ui/react-popover\": \"1.1.15\",\n    \"@radix-ui/react-radio-group\": \"1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"1.2.10\",\n    \"@radix-ui/react-select\": \"2.2.6\",\n    \"@radix-ui/react-separator\": \"1.1.8\",\n    \"@radix-ui/react-slider\": \"1.3.6\",\n    \"@radix-ui/react-slot\": \"1.2.4\",\n    \"@radix-ui/react-switch\": \"1.2.6\",\n    \"@radix-ui/react-tabs\": \"1.1.13\",\n    \"@radix-ui/react-tooltip\": \"1.2.8\",\n    \"@tailwindcss/vite\": \"^4.0.6\",\n    \"@tanstack/react-devtools\": \"^0.7.0\",\n    \"@tanstack/react-form\": \"^0.41.2\",\n    \"@tanstack/react-query\": \"^5.66.5\",\n    \"@tanstack/react-query-devtools\": \"^5.84.2\",\n    \"@tanstack/react-router\": \"^1.150.0\",\n    \"@tanstack/react-router-devtools\": \"^1.150.0\",\n    \"@tanstack/react-router-ssr-query\": \"^1.150.0\",\n    \"@tanstack/react-start\": \"^1.150.0\",\n    \"@tanstack/react-table\": \"8.11.7\",\n    \"@tanstack/router-plugin\": \"^1.150.0\",\n    \"@tanstack/zod-form-adapter\": \"^0.41.2\",\n    \"@teable/v2-adapter-db-postgres-pg\": \"workspace:^\",\n    \"@teable/v2-adapter-db-postgres-pglite\": \"workspace:^\",\n    \"@teable/v2-adapter-logger-pino\": \"workspace:^\",\n    \"@teable/v2-adapter-realtime-broadcastchannel\": \"workspace:^\",\n    \"@teable/v2-adapter-realtime-sharedb\": \"workspace:^\",\n    \"@teable/v2-container-browser\": \"workspace:^\",\n    \"@teable/v2-container-node\": \"workspace:^\",\n    \"@teable/v2-contract-http\": \"workspace:^\",\n    \"@teable/v2-contract-http-implementation\": \"workspace:^\",\n    \"@teable/v2-core\": \"workspace:^\",\n    \"@teable/v2-di\": \"workspace:^\",\n    \"@teable/v2-postgres-schema\": \"workspace:^\",\n    \"@teable/v2-table-templates\": \"workspace:^\",\n    \"@toon-format/toon\": \"2.1.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.0.0\",\n    \"date-fns\": \"4.1.0\",\n    \"kysely\": \"0.28.9\",\n    \"lucide-react\": \"^0.544.0\",\n    \"nitro\": \"latest\",\n    \"nprogress\": \"0.2.0\",\n    \"nuqs\": \"^2.5.0\",\n    \"papaparse\": \"5.5.3\",\n    \"pino\": \"10.1.0\",\n    \"react\": \"18.3.1\",\n    \"react-day-picker\": \"9.5.1\",\n    \"react-dom\": \"18.3.1\",\n    \"react-hook-form\": \"7.71.1\",\n    \"react-json-view-lite\": \"^2.5.0\",\n    \"sharedb\": \"5.2.2\",\n    \"sonner\": \"1.7.3\",\n    \"sql-formatter\": \"^15.4.10\",\n    \"tailwind-merge\": \"^3.0.2\",\n    \"tailwindcss\": \"^4.0.6\",\n    \"ts-pattern\": \"5.1.1\",\n    \"tw-animate-css\": \"^1.3.6\",\n    \"usehooks-ts\": \"3.1.1\",\n    \"vite-tsconfig-paths\": \"^5.1.4\",\n    \"ws\": \"8.18.3\",\n    \"zod\": \"^4.1.8\"\n  },\n  \"devDependencies\": {\n    \"@tanstack/devtools-vite\": \"^0.3.11\",\n    \"@testing-library/dom\": \"^10.4.0\",\n    \"@testing-library/react\": \"^16.2.0\",\n    \"@types/node\": \"^22.10.2\",\n    \"@types/nprogress\": \"0.2.3\",\n    \"@types/papaparse\": \"5.3.15\",\n    \"@types/react\": \"18.3.18\",\n    \"@types/react-dom\": \"18.3.5\",\n    \"@types/sharedb\": \"5.1.0\",\n    \"@types/ws\": \"8.5.12\",\n    \"@vitejs/plugin-react\": \"^5.0.4\",\n    \"jsdom\": \"^27.0.0\",\n    \"pino-pretty\": \"11.0.0\",\n    \"typescript\": \"^5.7.2\",\n    \"vite\": \"^7.1.7\",\n    \"vitest\": \"^4.0.17\",\n    \"web-vitals\": \"^5.1.0\"\n  }\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/ComputedTasksPanel.tsx",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  AlertTriangle,\n  CheckCircle2,\n  Clock,\n  Loader2,\n  Play,\n  RefreshCw,\n  RotateCcw,\n  Trash2,\n  XCircle,\n} from 'lucide-react';\nimport { useState } from 'react';\nimport { toast } from 'sonner';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\n\ntype OutboxTask = {\n  id: string;\n  baseId: string;\n  seedTableId: string;\n  status: string;\n  changeType: string;\n  attempts: number;\n  maxAttempts: number;\n  lastError: string | null;\n  planHash: string;\n  runId: string;\n  createdAt: string;\n  updatedAt: string;\n  nextRunAt: string;\n  seedCount: number;\n};\n\ntype DeadLetter = {\n  id: string;\n  baseId: string;\n  seedTableId: string;\n  status: string;\n  changeType: string;\n  attempts: number;\n  maxAttempts: number;\n  lastError: string | null;\n  planHash: string;\n  runId: string;\n  failedAt: string;\n  createdAt: string;\n  traceData: unknown | null;\n  seedCount: number;\n};\n\ntype TraceStep = {\n  stepIndex: number;\n  level: number;\n  tableId: string;\n  fieldId: string;\n  sql?: string;\n  paramCount?: number;\n  dirtyRecordCount?: number;\n  durationMs?: number;\n  error?: string;\n};\n\ntype TraceData = {\n  requestId?: string;\n  taskId?: string;\n  steps?: TraceStep[];\n  attempts?: number;\n  totalDurationMs?: number;\n  finalError?: string;\n};\n\nconst formatDate = (dateStr: string) => {\n  return new Date(dateStr).toLocaleString();\n};\n\nconst StatusBadge = ({ status }: { status: string }) => {\n  switch (status) {\n    case 'pending':\n      return (\n        <Badge variant=\"secondary\" className=\"gap-1\">\n          <Clock className=\"h-3 w-3\" />\n          Pending\n        </Badge>\n      );\n    case 'processing':\n      return (\n        <Badge variant=\"default\" className=\"gap-1 bg-blue-500\">\n          <Loader2 className=\"h-3 w-3 animate-spin\" />\n          Processing\n        </Badge>\n      );\n    case 'completed':\n      return (\n        <Badge variant=\"default\" className=\"gap-1 bg-green-500\">\n          <CheckCircle2 className=\"h-3 w-3\" />\n          Completed\n        </Badge>\n      );\n    case 'failed':\n      return (\n        <Badge variant=\"destructive\" className=\"gap-1\">\n          <XCircle className=\"h-3 w-3\" />\n          Failed\n        </Badge>\n      );\n    default:\n      return <Badge variant=\"outline\">{status}</Badge>;\n  }\n};\n\nexport function ComputedTasksPanel() {\n  const queryClient = useQueryClient();\n  const [selectedDeadLetter, setSelectedDeadLetter] = useState<DeadLetter | null>(null);\n\n  const outboxQuery = useQuery({\n    queryKey: ['computed-tasks', 'outbox'],\n    queryFn: async () => {\n      const response = await fetch('/api/computed-tasks/outbox');\n      if (!response.ok) {\n        throw new Error('Failed to fetch outbox tasks');\n      }\n      return response.json() as Promise<{ items: OutboxTask[]; total: number }>;\n    },\n    refetchInterval: 5000,\n  });\n\n  const deadLettersQuery = useQuery({\n    queryKey: ['computed-tasks', 'dead-letters'],\n    queryFn: async () => {\n      const response = await fetch('/api/computed-tasks/dead-letters');\n      if (!response.ok) {\n        throw new Error('Failed to fetch dead letters');\n      }\n      return response.json() as Promise<{ items: DeadLetter[]; total: number }>;\n    },\n    refetchInterval: 10000,\n  });\n\n  const retryMutation = useMutation({\n    mutationFn: async (taskId: string) => {\n      const response = await fetch(`/api/computed-tasks/${taskId}/retry-now`, {\n        method: 'POST',\n      });\n      if (!response.ok) {\n        throw new Error('Failed to retry task');\n      }\n      return response.json();\n    },\n    onSuccess: () => {\n      toast.success('Task queued for immediate retry');\n      void queryClient.invalidateQueries({ queryKey: ['computed-tasks'] });\n    },\n    onError: (error) => {\n      toast.error(`Failed to retry: ${error.message}`);\n    },\n  });\n\n  const replayMutation = useMutation({\n    mutationFn: async (taskId: string) => {\n      const response = await fetch(`/api/computed-tasks/dead-letters/${taskId}/replay`, {\n        method: 'POST',\n      });\n      if (!response.ok) {\n        throw new Error('Failed to replay dead letter');\n      }\n      return response.json();\n    },\n    onSuccess: () => {\n      toast.success('Dead letter replayed successfully');\n      void queryClient.invalidateQueries({ queryKey: ['computed-tasks'] });\n    },\n    onError: (error) => {\n      toast.error(`Failed to replay: ${error.message}`);\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: async (taskId: string) => {\n      const response = await fetch(`/api/computed-tasks/dead-letters/${taskId}`, {\n        method: 'DELETE',\n      });\n      if (!response.ok) {\n        throw new Error('Failed to delete dead letter');\n      }\n      return response.json();\n    },\n    onSuccess: () => {\n      toast.success('Dead letter deleted');\n      void queryClient.invalidateQueries({ queryKey: ['computed-tasks'] });\n    },\n    onError: (error) => {\n      toast.error(`Failed to delete: ${error.message}`);\n    },\n  });\n\n  const handleRefresh = () => {\n    void queryClient.invalidateQueries({ queryKey: ['computed-tasks'] });\n  };\n\n  const outboxTasks = outboxQuery.data?.items ?? [];\n  const deadLetters = deadLettersQuery.data?.items ?? [];\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h2 className=\"text-lg font-semibold\">Computed Update Tasks</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            Monitor and manage computed field update tasks\n          </p>\n        </div>\n        <Button variant=\"outline\" size=\"sm\" onClick={handleRefresh}>\n          <RefreshCw className=\"mr-2 h-4 w-4\" />\n          Refresh\n        </Button>\n      </div>\n\n      <div className=\"grid gap-4 md:grid-cols-3\">\n        <Card>\n          <CardHeader className=\"pb-2\">\n            <CardTitle className=\"text-sm font-medium\">Pending</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div className=\"text-2xl font-bold\">\n              {outboxTasks.filter((t) => t.status === 'pending').length}\n            </div>\n          </CardContent>\n        </Card>\n        <Card>\n          <CardHeader className=\"pb-2\">\n            <CardTitle className=\"text-sm font-medium\">Processing</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div className=\"text-2xl font-bold\">\n              {outboxTasks.filter((t) => t.status === 'processing').length}\n            </div>\n          </CardContent>\n        </Card>\n        <Card>\n          <CardHeader className=\"pb-2\">\n            <CardTitle className=\"text-sm font-medium text-destructive\">Dead Letters</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div className=\"text-2xl font-bold text-destructive\">{deadLetters.length}</div>\n          </CardContent>\n        </Card>\n      </div>\n\n      <Tabs defaultValue=\"outbox\">\n        <TabsList>\n          <TabsTrigger value=\"outbox\">\n            Outbox\n            {outboxTasks.length > 0 && (\n              <Badge variant=\"secondary\" className=\"ml-2\">\n                {outboxTasks.length}\n              </Badge>\n            )}\n          </TabsTrigger>\n          <TabsTrigger value=\"dead-letters\">\n            Dead Letters\n            {deadLetters.length > 0 && (\n              <Badge variant=\"destructive\" className=\"ml-2\">\n                {deadLetters.length}\n              </Badge>\n            )}\n          </TabsTrigger>\n        </TabsList>\n\n        <TabsContent value=\"outbox\" className=\"mt-4\">\n          <Card>\n            <CardHeader>\n              <CardTitle>Outbox Tasks</CardTitle>\n              <CardDescription>\n                Tasks waiting to be processed or currently processing\n              </CardDescription>\n            </CardHeader>\n            <CardContent>\n              {outboxQuery.isLoading ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <Loader2 className=\"h-6 w-6 animate-spin\" />\n                </div>\n              ) : outboxTasks.length === 0 ? (\n                <div className=\"py-8 text-center text-muted-foreground\">No pending tasks</div>\n              ) : (\n                <ScrollArea className=\"h-[400px]\">\n                  <Table>\n                    <TableHeader>\n                      <TableRow>\n                        <TableHead>Run ID</TableHead>\n                        <TableHead>Status</TableHead>\n                        <TableHead>Change Type</TableHead>\n                        <TableHead>Seeds</TableHead>\n                        <TableHead>Attempts</TableHead>\n                        <TableHead>Next Run</TableHead>\n                        <TableHead>Error</TableHead>\n                        <TableHead className=\"text-right\">Actions</TableHead>\n                      </TableRow>\n                    </TableHeader>\n                    <TableBody>\n                      {outboxTasks.map((task) => (\n                        <TableRow key={task.id}>\n                          <TableCell className=\"font-mono text-xs\">{task.runId}</TableCell>\n                          <TableCell>\n                            <StatusBadge status={task.status} />\n                          </TableCell>\n                          <TableCell>\n                            <Badge variant=\"outline\">{task.changeType}</Badge>\n                          </TableCell>\n                          <TableCell>{task.seedCount}</TableCell>\n                          <TableCell>\n                            {task.attempts}/{task.maxAttempts}\n                          </TableCell>\n                          <TableCell className=\"text-xs\">{formatDate(task.nextRunAt)}</TableCell>\n                          <TableCell className=\"max-w-[200px] truncate text-xs text-destructive\">\n                            {task.lastError}\n                          </TableCell>\n                          <TableCell className=\"text-right\">\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              onClick={() => retryMutation.mutate(task.id)}\n                              disabled={retryMutation.isPending}\n                            >\n                              <Play className=\"h-4 w-4\" />\n                            </Button>\n                          </TableCell>\n                        </TableRow>\n                      ))}\n                    </TableBody>\n                  </Table>\n                </ScrollArea>\n              )}\n            </CardContent>\n          </Card>\n        </TabsContent>\n\n        <TabsContent value=\"dead-letters\" className=\"mt-4\">\n          <Card>\n            <CardHeader>\n              <CardTitle>Dead Letters</CardTitle>\n              <CardDescription>Failed tasks that exceeded maximum retry attempts</CardDescription>\n            </CardHeader>\n            <CardContent>\n              {deadLettersQuery.isLoading ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <Loader2 className=\"h-6 w-6 animate-spin\" />\n                </div>\n              ) : deadLetters.length === 0 ? (\n                <div className=\"py-8 text-center text-muted-foreground\">No dead letters</div>\n              ) : (\n                <ScrollArea className=\"h-[400px]\">\n                  <Table>\n                    <TableHeader>\n                      <TableRow>\n                        <TableHead>Run ID</TableHead>\n                        <TableHead>Change Type</TableHead>\n                        <TableHead>Seeds</TableHead>\n                        <TableHead>Attempts</TableHead>\n                        <TableHead>Failed At</TableHead>\n                        <TableHead>Error</TableHead>\n                        <TableHead className=\"text-right\">Actions</TableHead>\n                      </TableRow>\n                    </TableHeader>\n                    <TableBody>\n                      {deadLetters.map((dl) => (\n                        <TableRow key={dl.id}>\n                          <TableCell className=\"font-mono text-xs\">{dl.runId}</TableCell>\n                          <TableCell>\n                            <Badge variant=\"outline\">{dl.changeType}</Badge>\n                          </TableCell>\n                          <TableCell>{dl.seedCount}</TableCell>\n                          <TableCell>{dl.attempts}</TableCell>\n                          <TableCell className=\"text-xs\">{formatDate(dl.failedAt)}</TableCell>\n                          <TableCell className=\"max-w-[200px] truncate text-xs text-destructive\">\n                            {dl.lastError}\n                          </TableCell>\n                          <TableCell className=\"text-right\">\n                            <div className=\"flex items-center justify-end gap-1\">\n                              {dl.traceData && (\n                                <Button\n                                  variant=\"ghost\"\n                                  size=\"sm\"\n                                  onClick={() => setSelectedDeadLetter(dl)}\n                                >\n                                  <AlertTriangle className=\"h-4 w-4\" />\n                                </Button>\n                              )}\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => replayMutation.mutate(dl.id)}\n                                disabled={replayMutation.isPending}\n                              >\n                                <RotateCcw className=\"h-4 w-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => deleteMutation.mutate(dl.id)}\n                                disabled={deleteMutation.isPending}\n                              >\n                                <Trash2 className=\"h-4 w-4 text-destructive\" />\n                              </Button>\n                            </div>\n                          </TableCell>\n                        </TableRow>\n                      ))}\n                    </TableBody>\n                  </Table>\n                </ScrollArea>\n              )}\n            </CardContent>\n          </Card>\n        </TabsContent>\n      </Tabs>\n\n      <Dialog open={!!selectedDeadLetter} onOpenChange={() => setSelectedDeadLetter(null)}>\n        <DialogContent className=\"max-w-3xl max-h-[80vh] overflow-hidden flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>Task Trace</DialogTitle>\n            <DialogDescription>Run ID: {selectedDeadLetter?.runId}</DialogDescription>\n          </DialogHeader>\n          <ScrollArea className=\"flex-1\">\n            <TraceView traceData={selectedDeadLetter?.traceData as TraceData | null} />\n          </ScrollArea>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n\nfunction TraceView({ traceData }: { traceData: TraceData | null }) {\n  if (!traceData) {\n    return <div className=\"text-muted-foreground\">No trace data available</div>;\n  }\n\n  return (\n    <div className=\"space-y-4 p-4\">\n      <div className=\"grid gap-2 text-sm\">\n        <div className=\"flex justify-between\">\n          <span className=\"text-muted-foreground\">Request ID:</span>\n          <span className=\"font-mono\">{traceData.requestId ?? 'N/A'}</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-muted-foreground\">Total Duration:</span>\n          <span>{traceData.totalDurationMs ? `${traceData.totalDurationMs}ms` : 'N/A'}</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-muted-foreground\">Attempts:</span>\n          <span>{traceData.attempts ?? 'N/A'}</span>\n        </div>\n      </div>\n\n      {traceData.finalError && (\n        <div className=\"rounded-lg border border-destructive/50 bg-destructive/10 p-3\">\n          <p className=\"text-sm font-medium text-destructive\">Final Error</p>\n          <p className=\"mt-1 text-sm\">{traceData.finalError}</p>\n        </div>\n      )}\n\n      {traceData.steps && traceData.steps.length > 0 && (\n        <div className=\"space-y-2\">\n          <p className=\"text-sm font-medium\">Execution Steps</p>\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>#</TableHead>\n                <TableHead>Level</TableHead>\n                <TableHead>Table ID</TableHead>\n                <TableHead>Field ID</TableHead>\n                <TableHead>Duration</TableHead>\n                <TableHead>Status</TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {traceData.steps.map((step, index) => (\n                <TableRow key={index}>\n                  <TableCell>{step.stepIndex}</TableCell>\n                  <TableCell>{step.level}</TableCell>\n                  <TableCell className=\"font-mono text-xs\">{step.tableId}</TableCell>\n                  <TableCell className=\"font-mono text-xs\">{step.fieldId}</TableCell>\n                  <TableCell>{step.durationMs ? `${step.durationMs}ms` : '-'}</TableCell>\n                  <TableCell>\n                    {step.error ? (\n                      <Badge variant=\"destructive\">Error</Badge>\n                    ) : (\n                      <Badge variant=\"default\" className=\"bg-green-500\">\n                        OK\n                      </Badge>\n                    )}\n                  </TableCell>\n                </TableRow>\n              ))}\n            </TableBody>\n          </Table>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/CreateTableDropdown.tsx",
    "content": "import type { TableTemplateDefinition } from '@teable/v2-table-templates';\nimport type { VariantProps } from 'class-variance-authority';\nimport { FileUp, Loader2, Plus } from 'lucide-react';\nimport { useEffect, useMemo, useState } from 'react';\n\nimport { cn } from '@/lib/utils';\nimport { Badge } from '@/components/ui/badge';\nimport { Button, buttonVariants } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Switch } from '@/components/ui/switch';\nimport { ImportCsvDialog } from './ImportCsvDialog';\n\ntype CreateTableDropdownProps = {\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  isCreating: boolean;\n  onSelect: (template: TableTemplateDefinition, options: { includeRecords: boolean }) => void;\n  onImportCsv?: (data: { tableName: string; csvData?: string; csvUrl?: string }) => Promise<void>;\n  label?: string;\n  variant?: VariantProps<typeof buttonVariants>['variant'];\n  size?: VariantProps<typeof buttonVariants>['size'];\n  className?: string;\n};\n\nexport function CreateTableDropdown({\n  templates,\n  isCreating,\n  onSelect,\n  onImportCsv,\n  label = 'Create table',\n  variant = 'default',\n  size = 'sm',\n  className,\n}: CreateTableDropdownProps) {\n  const [open, setOpen] = useState(false);\n  const [selectedKey, setSelectedKey] = useState(templates[0]?.key ?? '');\n  const selectedTemplate = useMemo(\n    () => templates.find((template) => template.key === selectedKey) ?? templates[0] ?? null,\n    [selectedKey, templates]\n  );\n  const [includeRecords, setIncludeRecords] = useState((templates[0]?.defaultRecordCount ?? 0) > 0);\n  const [seedSelectionLocked, setSeedSelectionLocked] = useState(false);\n  const [pendingClose, setPendingClose] = useState(false);\n  const [createStarted, setCreateStarted] = useState(false);\n  const isBusy = isCreating || pendingClose;\n\n  useEffect(() => {\n    if (!templates.length) {\n      setSelectedKey('');\n      return;\n    }\n    if (!selectedTemplate) {\n      setSelectedKey(templates[0]!.key);\n    }\n  }, [selectedTemplate, templates]);\n\n  useEffect(() => {\n    if (!selectedTemplate) return;\n    if (!seedSelectionLocked) {\n      setIncludeRecords((selectedTemplate.defaultRecordCount ?? 0) > 0);\n    }\n  }, [seedSelectionLocked, selectedTemplate?.key]);\n\n  useEffect(() => {\n    if (!pendingClose) {\n      if (createStarted) {\n        setCreateStarted(false);\n      }\n      return;\n    }\n    if (isCreating) {\n      if (!createStarted) {\n        setCreateStarted(true);\n      }\n      return;\n    }\n    if (createStarted) {\n      setOpen(false);\n      setPendingClose(false);\n      setCreateStarted(false);\n    }\n  }, [createStarted, isCreating, pendingClose]);\n\n  const supportsRecords = (selectedTemplate?.defaultRecordCount ?? 0) > 0;\n  const selectedTables = selectedTemplate?.tables ?? [];\n\n  const handleCreate = () => {\n    if (!selectedTemplate) return;\n    onSelect(selectedTemplate, {\n      includeRecords: includeRecords && supportsRecords,\n    });\n    setPendingClose(true);\n  };\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Dialog\n        open={open}\n        onOpenChange={(nextOpen) => {\n          if (isBusy) return;\n          setOpen(nextOpen);\n          if (!nextOpen) {\n            setPendingClose(false);\n            setCreateStarted(false);\n          }\n        }}\n      >\n        <DialogTrigger asChild>\n          <Button\n            variant={variant}\n            size={size}\n            disabled={isCreating || templates.length === 0}\n            className={cn('text-xs font-normal', className)}\n          >\n            <Plus className=\"mr-1.5 h-3.5 w-3.5\" />\n            {isCreating ? 'Creating...' : label}\n          </Button>\n        </DialogTrigger>\n        <DialogContent className=\"flex h-[calc(100vh-2rem)] max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none sm:max-w-none flex-col\">\n          <DialogHeader>\n            <DialogTitle>Create table</DialogTitle>\n            <DialogDescription>\n              Pick a template and optionally seed it with example records.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"grid min-h-0 flex-1 gap-4 md:grid-cols-[360px_1fr] lg:grid-cols-[420px_1fr]\">\n            <div className=\"flex min-h-0 flex-col rounded-lg border border-border/70 bg-muted/10\">\n              <div className=\"border-b border-border/70 px-3 py-2 text-xs font-medium text-muted-foreground\">\n                Templates\n              </div>\n              <ScrollArea className=\"min-h-0 flex-1\">\n                <div className=\"p-2 space-y-2\">\n                  {templates.map((template) => {\n                    const selected = template.key === selectedTemplate?.key;\n                    const seedCount = template.defaultRecordCount ?? 0;\n                    const tableCount = template.tables.length;\n                    return (\n                      <button\n                        key={template.key}\n                        type=\"button\"\n                        onClick={() => setSelectedKey(template.key)}\n                        className={cn(\n                          'flex w-full flex-col gap-1.5 rounded-md border px-2.5 py-2 text-left text-sm transition',\n                          selected\n                            ? 'border-primary/60 bg-primary/10'\n                            : 'border-border/70 hover:border-foreground/40',\n                          isBusy && 'pointer-events-none opacity-60'\n                        )}\n                        disabled={isBusy}\n                      >\n                        <div className=\"flex items-center justify-between gap-2\">\n                          <span className=\"text-xs font-semibold text-foreground\">\n                            {template.name}\n                          </span>\n                          <div className=\"flex items-center gap-1.5\">\n                            <Badge variant=\"outline\" className=\"text-[10px] font-normal uppercase\">\n                              {tableCount} {tableCount === 1 ? 'table' : 'tables'}\n                            </Badge>\n                            {seedCount > 0 ? (\n                              <Badge\n                                variant=\"outline\"\n                                className=\"text-[10px] font-normal uppercase\"\n                              >\n                                {seedCount} records\n                              </Badge>\n                            ) : null}\n                          </div>\n                        </div>\n                        <span className=\"text-[11px] text-muted-foreground leading-snug line-clamp-2\">\n                          {template.description}\n                        </span>\n                      </button>\n                    );\n                  })}\n                </div>\n              </ScrollArea>\n            </div>\n\n            <div className=\"flex min-h-0 flex-col gap-4\">\n              <ScrollArea className=\"min-h-0 flex-1\">\n                <div className=\"flex flex-col gap-4 pr-1\">\n                  <div>\n                    <div className=\"text-sm font-medium text-foreground\">\n                      {selectedTemplate?.name ?? 'Select a template'}\n                    </div>\n                    {selectedTemplate ? (\n                      <div className=\"mt-1 text-xs text-muted-foreground\">\n                        {selectedTemplate.description}\n                      </div>\n                    ) : null}\n                  </div>\n\n                  <div className=\"rounded-lg border border-border/70 bg-muted/20 p-4\">\n                    <div className=\"text-xs font-medium text-muted-foreground\">Tables</div>\n                    <div className=\"mt-2 grid gap-2 sm:grid-cols-2\">\n                      {selectedTables.map((table) => (\n                        <div\n                          key={table.key}\n                          className=\"rounded-md border border-border/70 bg-background/60 px-3 py-2\"\n                        >\n                          <div className=\"flex items-center justify-between gap-2\">\n                            <div className=\"text-xs font-semibold text-foreground\">\n                              {table.name}\n                            </div>\n                            <div className=\"flex items-center gap-1.5\">\n                              <Badge\n                                variant=\"secondary\"\n                                className=\"text-[10px] font-normal uppercase\"\n                              >\n                                {table.fieldCount} fields\n                              </Badge>\n                              {table.defaultRecordCount > 0 ? (\n                                <Badge\n                                  variant=\"secondary\"\n                                  className=\"text-[10px] font-normal uppercase\"\n                                >\n                                  {table.defaultRecordCount} records\n                                </Badge>\n                              ) : null}\n                            </div>\n                          </div>\n                          {table.description ? (\n                            <div className=\"mt-1 text-[11px] text-muted-foreground leading-snug line-clamp-2\">\n                              {table.description}\n                            </div>\n                          ) : null}\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n\n                  <div className=\"rounded-lg border border-border/70 bg-muted/20 p-4\">\n                    <div className=\"flex items-center justify-between gap-4\">\n                      <div>\n                        <div className=\"text-sm font-medium text-foreground\">Seed records</div>\n                        <div className=\"text-xs text-muted-foreground\">\n                          {supportsRecords\n                            ? 'Add sample records from the selected template.'\n                            : 'This template ships without sample records.'}\n                        </div>\n                      </div>\n                      <Switch\n                        checked={includeRecords && supportsRecords}\n                        onCheckedChange={(checked) => {\n                          setSeedSelectionLocked(true);\n                          setIncludeRecords(checked);\n                        }}\n                        disabled={!supportsRecords || isBusy}\n                      />\n                    </div>\n                  </div>\n                </div>\n              </ScrollArea>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button variant=\"secondary\" size=\"sm\" onClick={() => setOpen(false)} disabled={isBusy}>\n              Cancel\n            </Button>\n            <Button size=\"sm\" onClick={handleCreate} disabled={isBusy || !selectedTemplate}>\n              {isBusy ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : null}\n              {isBusy\n                ? 'Creating...'\n                : selectedTemplate && selectedTemplate.tables.length > 1\n                  ? 'Create tables'\n                  : 'Create table'}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n      {onImportCsv && (\n        <ImportCsvDialog\n          onImport={onImportCsv}\n          trigger={\n            <Button\n              variant=\"outline\"\n              size={size}\n              disabled={isCreating}\n              className={cn('text-xs font-normal', className)}\n            >\n              <FileUp className=\"mr-1.5 h-3.5 w-3.5\" />\n              Import CSV\n            </Button>\n          }\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/ExplainResultPanel.tsx",
    "content": "import { useMemo, useState, useCallback } from 'react';\nimport type { IExplainResultDto } from '@teable/v2-contract-http';\nimport { format } from 'sql-formatter';\nimport { encode } from '@toon-format/toon';\nimport {\n  ChevronDown,\n  ChevronRight,\n  Clock,\n  Database,\n  GitBranch,\n  AlertTriangle,\n  Zap,\n  Layers,\n  Lock,\n  Table2,\n  Copy,\n  Code,\n  LayoutDashboard,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { getFieldTypeIcon } from '@/lib/fieldTypeIcons';\nimport { maskPlaygroundDbUrl, resolvePlaygroundDbUrl } from '@/lib/playground/databaseUrl';\nimport { usePlaygroundEnvironment } from '@/lib/playground/environment';\n\ninterface ExplainResultPanelProps {\n  result: IExplainResultDto;\n  className?: string;\n}\n\ntype ComputedUpdateReason = NonNullable<IExplainResultDto['sqlExplains'][number]['computedReason']>;\n\nfunction ComplexityScoreCard({ level, score }: { level: string; score: number }) {\n  const config: Record<string, { bg: string; border: string; text: string; label: string }> = {\n    trivial: {\n      bg: 'bg-gradient-to-br from-green-50 to-green-100',\n      border: 'border-green-200',\n      text: 'text-green-700',\n      label: 'Trivial',\n    },\n    low: {\n      bg: 'bg-gradient-to-br from-blue-50 to-blue-100',\n      border: 'border-blue-200',\n      text: 'text-blue-700',\n      label: 'Low',\n    },\n    medium: {\n      bg: 'bg-gradient-to-br from-yellow-50 to-yellow-100',\n      border: 'border-yellow-200',\n      text: 'text-yellow-700',\n      label: 'Medium',\n    },\n    high: {\n      bg: 'bg-gradient-to-br from-orange-50 to-orange-100',\n      border: 'border-orange-200',\n      text: 'text-orange-700',\n      label: 'High',\n    },\n    very_high: {\n      bg: 'bg-gradient-to-br from-red-50 to-red-100',\n      border: 'border-red-200',\n      text: 'text-red-700',\n      label: 'Very High',\n    },\n  };\n\n  const c = config[level] ?? config.medium;\n\n  return (\n    <div className={cn('rounded-xl border-2 p-4 text-center', c.bg, c.border)}>\n      <div className=\"flex items-center justify-center gap-2 mb-1\">\n        <Zap className={cn('h-5 w-5', c.text)} />\n        <span className={cn('text-sm font-medium uppercase tracking-wide', c.text)}>\n          Complexity\n        </span>\n      </div>\n      <div className={cn('text-4xl font-bold', c.text)}>{score}</div>\n      <div className={cn('text-sm font-medium mt-1', c.text)}>{c.label}</div>\n    </div>\n  );\n}\n\nfunction SqlBlock({ sql, parameters }: { sql: string; parameters: readonly unknown[] }) {\n  const [copied, setCopied] = useState(false);\n\n  const formattedSql = useMemo(() => {\n    try {\n      // Skip formatting for comments\n      if (sql.startsWith('--')) {\n        return sql;\n      }\n      return format(sql, {\n        language: 'postgresql',\n        tabWidth: 2,\n        keywordCase: 'upper',\n        linesBetweenQueries: 1,\n      });\n    } catch {\n      return sql;\n    }\n  }, [sql]);\n\n  const handleCopy = useCallback(async () => {\n    const textToCopy =\n      parameters.length > 0\n        ? `${formattedSql}\\n\\n-- Parameters: ${JSON.stringify(parameters)}`\n        : formattedSql;\n    await navigator.clipboard.writeText(textToCopy);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  }, [formattedSql, parameters]);\n\n  return (\n    <div className=\"rounded-md bg-muted/50 p-3 font-mono text-xs relative group\">\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity\"\n        onClick={handleCopy}\n      >\n        <Copy className=\"h-3 w-3\" />\n      </Button>\n      {copied && (\n        <span className=\"absolute top-2 right-10 text-[10px] text-muted-foreground\">Copied!</span>\n      )}\n      <div className=\"h-64 w-full overflow-auto overscroll-contain\">\n        <pre className=\"whitespace-pre-wrap pr-4\">{formattedSql}</pre>\n      </div>\n      {parameters.length > 0 && (\n        <div className=\"mt-2 pt-2 border-t border-border/50\">\n          <div className=\"text-muted-foreground\">Parameters:</div>\n          <div className=\"mt-1 overflow-x-auto\">\n            <pre className=\"whitespace-pre text-xs\">{JSON.stringify(parameters)}</pre>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ExplainOutputBlock({\n  output,\n  isAnalyze,\n}: {\n  output: {\n    plan: unknown;\n    planningTimeMs?: number;\n    executionTimeMs?: number;\n    actualRows?: number;\n    estimatedRows?: number;\n    estimatedCost?: number;\n  };\n  isAnalyze: boolean;\n}) {\n  const formattedPlan = useMemo(() => {\n    return JSON.stringify(output.plan, null, 2);\n  }, [output.plan]);\n\n  return (\n    <div className=\"rounded-md bg-slate-900 text-slate-100 p-3 font-mono text-xs overflow-x-auto\">\n      <pre className=\"whitespace-pre-wrap max-h-64 overflow-y-auto\">{formattedPlan}</pre>\n      <div className=\"mt-2 pt-2 border-t border-slate-700 flex flex-wrap gap-3 text-slate-300\">\n        {output.planningTimeMs !== undefined && (\n          <span>Planning: {output.planningTimeMs.toFixed(2)}ms</span>\n        )}\n        {output.executionTimeMs !== undefined && (\n          <span>Execution: {output.executionTimeMs.toFixed(2)}ms</span>\n        )}\n        {output.estimatedRows !== undefined && <span>Est. rows: {output.estimatedRows}</span>}\n        {output.actualRows !== undefined && isAnalyze && (\n          <span>Actual rows: {output.actualRows}</span>\n        )}\n        {output.estimatedCost !== undefined && (\n          <span>Est. cost: {output.estimatedCost.toFixed(2)}</span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction ComputedReasonBlock({ reason }: { reason: ComputedUpdateReason }) {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <div className=\"mt-4 rounded-md border bg-muted/40 text-xs\">\n      <button\n        type=\"button\"\n        className=\"w-full px-3 py-2 flex items-center gap-2 text-muted-foreground hover:text-foreground\"\n        onClick={() => setOpen((value) => !value)}\n      >\n        {open ? <ChevronDown className=\"h-4 w-4\" /> : <ChevronRight className=\"h-4 w-4\" />}\n        <GitBranch className=\"h-4 w-4\" />\n        <span className=\"font-medium\">Computed Update Reason</span>\n        <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1 uppercase\">\n          {reason.changeType}\n        </Badge>\n      </button>\n      {open && (\n        <div className=\"h-80 w-full overflow-auto overscroll-contain\">\n          <div className=\"space-y-3 px-3 pb-3 pr-4\">\n            {reason.notes.length > 0 && (\n              <div className=\"text-muted-foreground\">{reason.notes.join(' ')}</div>\n            )}\n            <div>\n              <div className=\"text-[11px] font-medium text-muted-foreground mb-1\">Triggered By</div>\n              <div className=\"flex flex-wrap gap-1.5\">\n                {reason.seedFields.length > 0 ? (\n                  reason.seedFields.map((seed) => {\n                    const Icon = getFieldTypeIcon(seed.fieldType);\n                    return (\n                      <div\n                        key={seed.fieldId}\n                        className=\"inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border text-[11px]\"\n                        title={`${seed.tableName} · ${seed.fieldType}`}\n                      >\n                        <Icon className=\"h-3 w-3 text-muted-foreground shrink-0\" />\n                        <span className=\"font-medium\">{seed.fieldName}</span>\n                        <span className=\"text-muted-foreground\">({seed.fieldType})</span>\n                        <Badge variant=\"secondary\" className=\"text-[10px] h-4 px-1\">\n                          {seed.impact === 'link_relation' ? 'link' : 'value'}\n                        </Badge>\n                      </div>\n                    );\n                  })\n                ) : (\n                  <span className=\"text-muted-foreground\">No seed fields</span>\n                )}\n              </div>\n            </div>\n            <div>\n              <div className=\"text-[11px] font-medium text-muted-foreground mb-1\">Updates</div>\n              <div className=\"space-y-2\">\n                {reason.targetFields.length > 0 ? (\n                  reason.targetFields.map((target) => {\n                    const Icon = getFieldTypeIcon(target.fieldType);\n                    return (\n                      <div\n                        key={target.fieldId}\n                        className=\"rounded-md border bg-background px-2 py-2\"\n                      >\n                        <div className=\"flex items-center gap-2\">\n                          <Icon className=\"h-3 w-3 text-muted-foreground\" />\n                          <span className=\"font-medium\">{target.fieldName}</span>\n                          <span className=\"text-muted-foreground\">({target.fieldType})</span>\n                        </div>\n                        <div className=\"mt-1 space-y-1 text-[11px]\">\n                          {target.dependencies.length > 0 ? (\n                            target.dependencies.map((dep, index) => (\n                              <div\n                                key={`${dep.fromFieldId}-${index}`}\n                                className=\"flex flex-wrap items-center gap-1.5 text-muted-foreground\"\n                              >\n                                <span className=\"font-medium text-foreground\">\n                                  {dep.fromTableName}.{dep.fromFieldName}\n                                </span>\n                                <span>({dep.fromFieldType})</span>\n                                <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1\">\n                                  {dep.kind}\n                                </Badge>\n                                {dep.semantic && (\n                                  <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1\">\n                                    {dep.semantic}\n                                  </Badge>\n                                )}\n                                {dep.isSeed && (\n                                  <Badge variant=\"secondary\" className=\"text-[10px] h-4 px-1\">\n                                    seed\n                                  </Badge>\n                                )}\n                              </div>\n                            ))\n                          ) : (\n                            <span className=\"text-muted-foreground\">No direct dependencies</span>\n                          )}\n                        </div>\n                      </div>\n                    );\n                  })\n                ) : (\n                  <span className=\"text-muted-foreground\">No computed targets</span>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction StatCard({\n  icon: Icon,\n  label,\n  value,\n  subValue,\n}: {\n  icon: React.ElementType;\n  label: string;\n  value: string | number;\n  subValue?: string;\n}) {\n  return (\n    <div className=\"rounded-lg border bg-card p-3\">\n      <div className=\"flex items-center gap-2 text-muted-foreground mb-1\">\n        <Icon className=\"h-4 w-4\" />\n        <span className=\"text-xs font-medium\">{label}</span>\n      </div>\n      <div className=\"text-lg font-semibold\">{value}</div>\n      {subValue && <div className=\"text-xs text-muted-foreground\">{subValue}</div>}\n    </div>\n  );\n}\n\nfunction SqlStepCard({\n  sqlInfo,\n  index,\n  computedLocks,\n}: {\n  sqlInfo: IExplainResultDto['sqlExplains'][number];\n  index: number;\n  computedLocks: IExplainResultDto['computedLocks'];\n}) {\n  const [copied, setCopied] = useState(false);\n\n  const handleCopyStep = useCallback(async () => {\n    const toonData = encode(sqlInfo);\n    await navigator.clipboard.writeText(toonData);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  }, [sqlInfo]);\n\n  return (\n    <div className=\"rounded-lg border bg-card overflow-hidden\">\n      <div className=\"px-4 py-2 bg-muted/50 border-b\">\n        <div className=\"flex items-start gap-2 flex-wrap\">\n          <Badge variant=\"outline\" className=\"text-xs shrink-0\">\n            Step {index + 1}\n          </Badge>\n          <span\n            className=\"text-sm font-medium break-words whitespace-normal min-w-0 flex-1\"\n            title={sqlInfo.stepDescription}\n          >\n            {sqlInfo.stepDescription}\n          </span>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-6 w-6 shrink-0\"\n                onClick={handleCopyStep}\n              >\n                <Copy className=\"h-3 w-3\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"top\">{copied ? 'Copied!' : 'Copy step (Toon)'}</TooltipContent>\n          </Tooltip>\n        </div>\n        {computedLocks && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div className=\"mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-help\">\n                <Lock className=\"h-3 w-3\" />\n                <span>\n                  Stage locks: {computedLocks.mode} · {computedLocks.recordLockCount} records ·{' '}\n                  {computedLocks.tableLockCount} tables\n                </span>\n              </div>\n            </TooltipTrigger>\n            <TooltipContent side=\"top\" sideOffset={6} className=\"max-w-[280px]\">\n              <div className=\"space-y-1\">\n                <div className=\"font-medium\">Computed update locks</div>\n                <div className=\"text-[11px] text-background/80\">{computedLocks.reason}</div>\n                <div className=\"text-[11px] text-background/80\">\n                  Applied once per update; the same lock set covers all steps.\n                </div>\n              </div>\n            </TooltipContent>\n          </Tooltip>\n        )}\n      </div>\n      <div className=\"p-4\">\n        <div className=\"grid grid-cols-1 xl:grid-cols-2 gap-4\">\n          <div>\n            <div className=\"text-xs font-medium text-muted-foreground mb-2\">SQL</div>\n            <SqlBlock sql={sqlInfo.sql} parameters={sqlInfo.parameters} />\n          </div>\n          {(sqlInfo.explainError ||\n            sqlInfo.explainAnalyze ||\n            (sqlInfo.explainOnly && !sqlInfo.explainAnalyze)) && (\n            <div>\n              {sqlInfo.explainError ? (\n                <div className=\"rounded-md border border-rose-100 bg-rose-50/60 px-3 py-2 text-xs text-rose-700\">\n                  <div className=\"flex items-start gap-2\">\n                    <AlertTriangle className=\"h-3.5 w-3.5 mt-0.5\" />\n                    <div>\n                      <div className=\"font-medium\">Explain Error</div>\n                      <div className=\"break-all\">{sqlInfo.explainError}</div>\n                    </div>\n                  </div>\n                </div>\n              ) : sqlInfo.explainAnalyze ? (\n                <>\n                  <div className=\"text-xs font-medium text-muted-foreground mb-2\">\n                    EXPLAIN ANALYZE\n                  </div>\n                  <ExplainOutputBlock output={sqlInfo.explainAnalyze} isAnalyze={true} />\n                </>\n              ) : (\n                <>\n                  <div className=\"text-xs font-medium text-muted-foreground mb-2\">EXPLAIN</div>\n                  <ExplainOutputBlock output={sqlInfo.explainOnly!} isAnalyze={false} />\n                </>\n              )}\n            </div>\n          )}\n        </div>\n        {sqlInfo.computedReason && <ComputedReasonBlock reason={sqlInfo.computedReason} />}\n      </div>\n    </div>\n  );\n}\n\nfunction generateOptimizationPromptText(result: IExplainResultDto, dbUrl: string | null): string {\n  const lines: string[] = [];\n\n  lines.push('# SQL Command EXPLAIN Analysis');\n  lines.push(`DB URL: ${dbUrl ?? '(not set)'}`);\n  lines.push('');\n  lines.push(\n    'You are a database performance engineer. Analyze this execution plan and identify performance issues, bottlenecks, and concrete optimization steps.'\n  );\n  lines.push(\n    'Focus on expensive steps, sequential scans, row-estimate mismatch, missing indexes, lock contention, and computed field fan-out.'\n  );\n  lines.push(\n    'Return a prioritized list of issues, quick wins, deeper schema/index changes, and any trade-offs or risks.'\n  );\n  lines.push('');\n\n  // Command Info\n  lines.push('## Command');\n  lines.push(`- Type: ${result.command.type}`);\n  lines.push(`- Table: ${result.command.tableName}`);\n  lines.push(`- Change Type: ${result.command.changeType}`);\n  if (result.command.changedFieldNames && result.command.changedFieldNames.length > 0) {\n    lines.push(`- Changed Fields: ${result.command.changedFieldNames.join(', ')}`);\n  }\n  lines.push('');\n\n  // Complexity\n  lines.push('## Complexity Assessment');\n  lines.push(`- Score: ${result.complexity.score}/100`);\n  lines.push(`- Level: ${result.complexity.level}`);\n  if (result.complexity.factors.length > 0) {\n    lines.push('- Factors:');\n    for (const f of result.complexity.factors) {\n      lines.push(`  - ${f.name}: ${f.value} (contribution: +${f.contribution})`);\n    }\n  }\n  if (result.complexity.recommendations.length > 0) {\n    lines.push('- Recommendations:');\n    for (const rec of result.complexity.recommendations) {\n      lines.push(`  - ${rec}`);\n    }\n  }\n  lines.push('');\n\n  // Computed Impact\n  if (result.computedImpact && result.computedImpact.updateSteps.length > 0) {\n    lines.push('## Computed Field Impact');\n    lines.push(`- Seed Record Count: ${result.computedImpact.seedRecordCount}`);\n    lines.push(\n      `- Dependency Graph: ${result.computedImpact.dependencyGraph.fieldCount} fields, ${result.computedImpact.dependencyGraph.edgeCount} edges`\n    );\n    lines.push('- Update Steps:');\n    for (const step of result.computedImpact.updateSteps) {\n      lines.push(\n        `  - Level ${step.level}: ${step.tableName} - fields: ${step.fieldNames.join(', ')}`\n      );\n      if (step.warning) {\n        lines.push(`    - Warning: ${step.warning}`);\n      }\n    }\n    if (result.computedImpact.warnings && result.computedImpact.warnings.length > 0) {\n      lines.push('- Warnings:');\n      for (const warning of result.computedImpact.warnings) {\n        lines.push(`  - ${warning}`);\n      }\n    }\n    lines.push('');\n  }\n\n  // SQL Statements\n  if (result.sqlExplains.length > 0) {\n    lines.push('## SQL Statements');\n    for (let i = 0; i < result.sqlExplains.length; i++) {\n      const sqlInfo = result.sqlExplains[i];\n      lines.push('');\n      lines.push(`### Step ${i + 1}: ${sqlInfo.stepDescription}`);\n      if (sqlInfo.explainError) {\n        lines.push(`Explain Error: ${sqlInfo.explainError}`);\n      }\n      lines.push('```sql');\n      lines.push(sqlInfo.sql);\n      lines.push('```');\n      if (sqlInfo.parameters.length > 0) {\n        lines.push(`Parameters: ${JSON.stringify(sqlInfo.parameters)}`);\n      }\n      if (sqlInfo.explainAnalyze) {\n        lines.push('');\n        lines.push('EXPLAIN ANALYZE:');\n        lines.push('```json');\n        lines.push(JSON.stringify(sqlInfo.explainAnalyze.plan, null, 2));\n        lines.push('```');\n        if (sqlInfo.explainAnalyze.planningTimeMs !== undefined) {\n          lines.push(`- Planning Time: ${sqlInfo.explainAnalyze.planningTimeMs}ms`);\n        }\n        if (sqlInfo.explainAnalyze.executionTimeMs !== undefined) {\n          lines.push(`- Execution Time: ${sqlInfo.explainAnalyze.executionTimeMs}ms`);\n        }\n      } else if (sqlInfo.explainOnly) {\n        lines.push('');\n        lines.push('EXPLAIN:');\n        lines.push('```json');\n        lines.push(JSON.stringify(sqlInfo.explainOnly.plan, null, 2));\n        lines.push('```');\n      }\n    }\n    lines.push('');\n  }\n\n  if (result.computedLocks) {\n    lines.push('## Computed Locks');\n    lines.push(`- Mode: ${result.computedLocks.mode}`);\n    lines.push(`- Reason: ${result.computedLocks.reason}`);\n    lines.push(`- Record Locks: ${result.computedLocks.recordLockCount}`);\n    lines.push(`- Table Locks: ${result.computedLocks.tableLockCount}`);\n    lines.push('');\n  }\n\n  // Timing\n  lines.push('## Timing');\n  lines.push(`- Total: ${result.timing.totalMs}ms`);\n  if (result.timing.dependencyGraphMs > 0) {\n    lines.push(`- Dependency Graph: ${result.timing.dependencyGraphMs}ms`);\n  }\n  if (result.timing.planningMs > 0) {\n    lines.push(`- Planning: ${result.timing.planningMs}ms`);\n  }\n  if (result.timing.sqlExplainMs > 0) {\n    lines.push(`- SQL Explain: ${result.timing.sqlExplainMs}ms`);\n  }\n\n  return lines.join('\\n');\n}\n\nfunction JsonViewPanel({ result }: { result: IExplainResultDto }) {\n  const [copied, setCopied] = useState(false);\n\n  const formattedJson = useMemo(() => {\n    return JSON.stringify(result, null, 2);\n  }, [result]);\n\n  const handleCopy = useCallback(async () => {\n    await navigator.clipboard.writeText(formattedJson);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  }, [formattedJson]);\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      <div className=\"flex items-center justify-between mb-2\">\n        <div className=\"flex items-center gap-2\">\n          <Code className=\"h-4 w-4 text-muted-foreground\" />\n          <h3 className=\"font-semibold\">JSON Analysis Result</h3>\n        </div>\n        <Button variant=\"outline\" size=\"sm\" className=\"gap-1\" onClick={handleCopy}>\n          <Copy className=\"h-3.5 w-3.5\" />\n          {copied ? 'Copied!' : 'Copy JSON'}\n        </Button>\n      </div>\n      <div className=\"flex-1 rounded-md border bg-slate-900 overflow-auto\">\n        <pre className=\"p-4 text-xs font-mono text-slate-100 whitespace-pre-wrap break-all\">\n          {formattedJson}\n        </pre>\n      </div>\n    </div>\n  );\n}\n\nexport function ExplainResultPanel({ result, className }: ExplainResultPanelProps) {\n  const env = usePlaygroundEnvironment();\n  const [viewMode, setViewMode] = useState<'visual' | 'json'>('visual');\n  const [impactOpen, setImpactOpen] = useState(true);\n  const [locksOpen, setLocksOpen] = useState(false);\n  const [linkLocksOpen, setLinkLocksOpen] = useState(false);\n  const [copiedKey, setCopiedKey] = useState<'raw' | 'optimized' | null>(null);\n\n  const totalSteps = result.computedImpact?.updateSteps.length ?? 0;\n  const totalRecords =\n    result.computedImpact?.affectedRecordEstimates.reduce((sum, e) => sum + e.estimatedCount, 0) ??\n    0;\n\n  const resolveDbUrl = useCallback(() => {\n    if (env.kind === 'sandbox') return env.pgliteConnectionString;\n    return resolvePlaygroundDbUrl();\n  }, [env]);\n\n  const displayDbUrl = useMemo(() => {\n    const dbUrl = resolveDbUrl();\n    if (!dbUrl) return null;\n    return maskPlaygroundDbUrl(dbUrl);\n  }, [resolveDbUrl]);\n\n  // Calculate total execution time from SQL explains\n  const totalExecutionTime = useMemo(() => {\n    let total = 0;\n    let hasData = false;\n    for (const sql of result.sqlExplains) {\n      if (sql.explainAnalyze?.executionTimeMs !== undefined) {\n        total += sql.explainAnalyze.executionTimeMs;\n        hasData = true;\n      }\n    }\n    return hasData ? total : null;\n  }, [result.sqlExplains]);\n\n  const totalPlanningTime = useMemo(() => {\n    let total = 0;\n    let hasData = false;\n    for (const sql of result.sqlExplains) {\n      if (sql.explainAnalyze?.planningTimeMs !== undefined) {\n        total += sql.explainAnalyze.planningTimeMs;\n        hasData = true;\n      }\n    }\n    return hasData ? total : null;\n  }, [result.sqlExplains]);\n\n  const explainErrorCount = useMemo(\n    () => result.sqlExplains.filter((sql) => sql.explainError).length,\n    [result.sqlExplains]\n  );\n\n  const copyWithFeedback = useCallback(async (text: string, key: 'raw' | 'optimized') => {\n    await navigator.clipboard.writeText(text);\n    setCopiedKey(key);\n    setTimeout(() => setCopiedKey(null), 2000);\n  }, []);\n\n  const handleCopyRaw = useCallback(async () => {\n    const dbUrl = resolveDbUrl();\n    const rawPayload = encode(result);\n    await copyWithFeedback(`DB URL: ${dbUrl ?? '(not set)'}\\n${rawPayload}`, 'raw');\n  }, [copyWithFeedback, resolveDbUrl, result]);\n\n  const handleCopyOptimized = useCallback(async () => {\n    const dbUrl = resolveDbUrl();\n    await copyWithFeedback(generateOptimizationPromptText(result, dbUrl), 'optimized');\n  }, [copyWithFeedback, resolveDbUrl, result]);\n\n  return (\n    <div className={cn('flex flex-col h-[calc(85vh-120px)]', className)}>\n      {/* View Mode Tabs */}\n      <div className=\"flex items-center justify-between mb-4 shrink-0\">\n        <Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'visual' | 'json')}>\n          <TabsList>\n            <TabsTrigger value=\"visual\" className=\"gap-1.5\">\n              <LayoutDashboard className=\"h-3.5 w-3.5\" />\n              Visual\n            </TabsTrigger>\n            <TabsTrigger value=\"json\" className=\"gap-1.5\">\n              <Code className=\"h-3.5 w-3.5\" />\n              JSON\n            </TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </div>\n\n      {viewMode === 'json' ? (\n        <JsonViewPanel result={result} />\n      ) : (\n        <div className=\"flex gap-6 flex-1 min-h-0\">\n          {/* Left Panel - Overview */}\n          <div className=\"w-[340px] shrink-0 overflow-auto\">\n            <div className=\"space-y-4 pr-4\">\n              {/* Complexity Score */}\n              <ComplexityScoreCard\n                level={result.complexity.level}\n                score={result.complexity.score}\n              />\n\n              {/* Command Info */}\n              <div className=\"rounded-lg border bg-card p-4 space-y-3\">\n                <div className=\"flex items-center gap-2\">\n                  <Database className=\"h-4 w-4 text-muted-foreground shrink-0\" />\n                  <span className=\"font-medium\">{result.command.type}</span>\n                </div>\n                <div className=\"flex items-start gap-2\">\n                  <Table2 className=\"h-4 w-4 text-muted-foreground shrink-0 mt-0.5\" />\n                  <code className=\"text-sm bg-muted px-2 py-0.5 rounded break-all\">\n                    {result.command.tableName}\n                  </code>\n                </div>\n                {result.command.changedFieldNames &&\n                  result.command.changedFieldNames.length > 0 && (\n                    <div>\n                      <div className=\"text-xs text-muted-foreground mb-1.5\">Changed Fields</div>\n                      <div className=\"flex flex-wrap gap-1.5\">\n                        {result.command.changedFieldNames.map((name, i) => {\n                          const fieldType =\n                            result.command.changedFieldTypes?.[i] || 'singleLineText';\n                          const Icon = getFieldTypeIcon(fieldType);\n                          return (\n                            <div\n                              key={i}\n                              className=\"inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-xs\"\n                              title={fieldType}\n                            >\n                              <Icon className=\"h-3 w-3 text-muted-foreground shrink-0\" />\n                              <span>{name}</span>\n                              <span className=\"text-muted-foreground\">({fieldType})</span>\n                            </div>\n                          );\n                        })}\n                      </div>\n                    </div>\n                  )}\n              </div>\n\n              {/* Stats Grid */}\n              <div className=\"grid grid-cols-2 gap-2\">\n                <StatCard\n                  icon={Clock}\n                  label=\"Analyze Time\"\n                  value={`${result.timing.totalMs}ms`}\n                  subValue={\n                    result.timing.sqlExplainMs > 0\n                      ? `SQL: ${result.timing.sqlExplainMs}ms`\n                      : undefined\n                  }\n                />\n                <StatCard\n                  icon={Layers}\n                  label=\"Steps\"\n                  value={totalSteps}\n                  subValue={totalRecords > 0 ? `~${totalRecords} records` : undefined}\n                />\n                {explainErrorCount > 0 && (\n                  <StatCard\n                    icon={AlertTriangle}\n                    label=\"Explain Errors\"\n                    value={explainErrorCount}\n                    subValue=\"Failed explain steps\"\n                  />\n                )}\n              </div>\n\n              {/* Execution Time - only show when ANALYZE data is available */}\n              {totalExecutionTime !== null && (\n                <div className=\"rounded-lg border bg-card p-4\">\n                  <div className=\"flex items-center gap-2 text-muted-foreground mb-2\">\n                    <Zap className=\"h-4 w-4\" />\n                    <span className=\"text-xs font-medium\">SQL Execution Time</span>\n                  </div>\n                  <div className=\"text-2xl font-bold text-primary\">\n                    {totalExecutionTime.toFixed(2)}ms\n                  </div>\n                  {totalPlanningTime !== null && (\n                    <div className=\"text-xs text-muted-foreground mt-1\">\n                      Planning: {totalPlanningTime.toFixed(2)}ms\n                    </div>\n                  )}\n                  <div className=\"mt-2 pt-2 border-t space-y-1\">\n                    {result.sqlExplains.map(\n                      (sql, i) =>\n                        sql.explainAnalyze?.executionTimeMs !== undefined && (\n                          <div key={i} className=\"flex justify-between text-xs\">\n                            <span\n                              className=\"text-muted-foreground truncate max-w-[180px]\"\n                              title={sql.stepDescription}\n                            >\n                              Step {i + 1}\n                            </span>\n                            <span className=\"font-mono\">\n                              {sql.explainAnalyze.executionTimeMs.toFixed(2)}ms\n                            </span>\n                          </div>\n                        )\n                    )}\n                  </div>\n                </div>\n              )}\n\n              {/* Recommendations */}\n              {result.complexity.recommendations.length > 0 && (\n                <div className=\"rounded-lg border border-yellow-200 bg-yellow-50 p-3\">\n                  <div className=\"flex items-center gap-2 text-yellow-800 font-medium text-sm mb-2\">\n                    <AlertTriangle className=\"h-4 w-4\" />\n                    Recommendations\n                  </div>\n                  <ul className=\"list-disc list-inside text-xs text-yellow-700 space-y-1\">\n                    {result.complexity.recommendations.map((rec, i) => (\n                      <li key={i}>{rec}</li>\n                    ))}\n                  </ul>\n                </div>\n              )}\n\n              {/* Complexity Factors */}\n              {result.complexity.factors.length > 0 && (\n                <div className=\"rounded-lg border bg-card p-3\">\n                  <div className=\"text-xs font-medium text-muted-foreground mb-2\">\n                    Complexity Factors\n                  </div>\n                  <div className=\"space-y-1.5\">\n                    {result.complexity.factors.map((f, i) => (\n                      <div key={i} className=\"flex items-center justify-between text-xs\">\n                        <span className=\"text-muted-foreground\">{f.name}</span>\n                        <div className=\"flex items-center gap-2\">\n                          <span className=\"font-mono\">{f.value}</span>\n                          <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1\">\n                            +{f.contribution}\n                          </Badge>\n                        </div>\n                      </div>\n                    ))}\n                  </div>\n                </div>\n              )}\n\n              {/* Computed Impact */}\n              {result.computedImpact && result.computedImpact.updateSteps.length > 0 && (\n                <div className=\"rounded-lg border bg-card overflow-hidden\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"w-full justify-start gap-2 rounded-none border-b h-10\"\n                    onClick={() => setImpactOpen(!impactOpen)}\n                  >\n                    {impactOpen ? (\n                      <ChevronDown className=\"h-4 w-4\" />\n                    ) : (\n                      <ChevronRight className=\"h-4 w-4\" />\n                    )}\n                    <GitBranch className=\"h-4 w-4\" />\n                    <span className=\"font-medium text-xs\">\n                      Computed Updates ({result.computedImpact.updateSteps.length})\n                    </span>\n                  </Button>\n                  {impactOpen && (\n                    <div className=\"p-3 space-y-2\">\n                      {result.computedImpact.updateSteps.map((step, i) => (\n                        <div key={i} className=\"flex items-start gap-2 text-xs\">\n                          <Badge variant=\"outline\" className=\"shrink-0 text-[10px] h-5\">\n                            L{step.level}\n                          </Badge>\n                          <div className=\"min-w-0\">\n                            <div className=\"flex items-center gap-2\">\n                              <div className=\"font-medium break-all\">{step.tableName}</div>\n                              {step.status === 'blocked' && (\n                                <Badge variant=\"destructive\" className=\"text-[10px] h-4 px-1\">\n                                  Blocked\n                                </Badge>\n                              )}\n                            </div>\n                            <div className=\"text-muted-foreground break-all\">\n                              {step.fieldNames.join(', ')}\n                            </div>\n                            {step.warning && (\n                              <div className=\"text-[10px] text-destructive break-words mt-1\">\n                                {step.warning}\n                              </div>\n                            )}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              )}\n\n              {/* Computed Locks */}\n              {result.computedLocks && (\n                <div className=\"rounded-lg border bg-card overflow-hidden\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"w-full justify-start gap-2 rounded-none border-b h-9 text-muted-foreground\"\n                    onClick={() => setLocksOpen(!locksOpen)}\n                  >\n                    {locksOpen ? (\n                      <ChevronDown className=\"h-4 w-4\" />\n                    ) : (\n                      <ChevronRight className=\"h-4 w-4\" />\n                    )}\n                    <Lock className=\"h-4 w-4\" />\n                    <span className=\"font-medium text-[11px]\">Computed Locks</span>\n                    <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1 uppercase\">\n                      {result.computedLocks.mode}\n                    </Badge>\n                    <Badge variant=\"secondary\" className=\"text-[10px] h-4 px-1\">\n                      {result.computedLocks.recordLockCount} records\n                    </Badge>\n                    <Badge variant=\"secondary\" className=\"text-[10px] h-4 px-1\">\n                      {result.computedLocks.tableLockCount} tables\n                    </Badge>\n                  </Button>\n                  {locksOpen && (\n                    <div className=\"p-3 space-y-3 text-xs\">\n                      <div className=\"text-muted-foreground\">{result.computedLocks.reason}</div>\n                      {result.computedLocks.tableLocks.length > 0 && (\n                        <div className=\"space-y-1.5\">\n                          <div className=\"text-[11px] font-medium text-muted-foreground\">\n                            Table Locks\n                          </div>\n                          {result.computedLocks.tableLocks.map((lock) => (\n                            <div key={lock.key} className=\"flex flex-col gap-0.5\">\n                              <span className=\"font-medium\">{lock.tableName}</span>\n                              <span className=\"text-muted-foreground break-all\">{lock.key}</span>\n                            </div>\n                          ))}\n                        </div>\n                      )}\n                      {result.computedLocks.recordLocks.length > 0 && (\n                        <div className=\"space-y-1.5\">\n                          <div className=\"text-[11px] font-medium text-muted-foreground\">\n                            Record Locks\n                          </div>\n                          {result.computedLocks.recordLocks.map((lock) => (\n                            <div key={lock.key} className=\"flex flex-col gap-0.5\">\n                              <span className=\"font-medium\">{lock.tableName}</span>\n                              <span className=\"text-muted-foreground break-all\">\n                                {lock.recordId}\n                              </span>\n                            </div>\n                          ))}\n                        </div>\n                      )}\n                      {result.computedLocks.statements.length > 0 && (\n                        <div className=\"space-y-2\">\n                          <div className=\"text-[11px] font-medium text-muted-foreground\">\n                            Lock SQL\n                          </div>\n                          {result.computedLocks.statements.map((statement, index) => (\n                            <div key={`${statement.key}-${index}`} className=\"space-y-1\">\n                              <div className=\"text-muted-foreground break-all\">\n                                {statement.tableName}\n                                {statement.recordId ? ` · ${statement.recordId}` : ''}\n                              </div>\n                              <SqlBlock sql={statement.sql} parameters={statement.parameters} />\n                            </div>\n                          ))}\n                        </div>\n                      )}\n                    </div>\n                  )}\n                </div>\n              )}\n\n              {/* Link Record Locks */}\n              {result.linkLocks && result.linkLocks.mode === 'active' && (\n                <div className=\"rounded-lg border bg-card overflow-hidden\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"w-full justify-start gap-2 rounded-none border-b h-9 text-muted-foreground\"\n                    onClick={() => setLinkLocksOpen(!linkLocksOpen)}\n                  >\n                    {linkLocksOpen ? (\n                      <ChevronDown className=\"h-4 w-4\" />\n                    ) : (\n                      <ChevronRight className=\"h-4 w-4\" />\n                    )}\n                    <Lock className=\"h-4 w-4\" />\n                    <span className=\"font-medium text-[11px]\">Link Record Locks</span>\n                    <Badge variant=\"outline\" className=\"text-[10px] h-4 px-1 uppercase\">\n                      {result.linkLocks.mode}\n                    </Badge>\n                    <Badge variant=\"secondary\" className=\"text-[10px] h-4 px-1\">\n                      {result.linkLocks.lockCount} records\n                    </Badge>\n                  </Button>\n                  {linkLocksOpen && (\n                    <div className=\"p-3 space-y-3 text-xs\">\n                      <div className=\"text-muted-foreground\">{result.linkLocks.reason}</div>\n                      {result.linkLocks.locks.length > 0 && (\n                        <div className=\"space-y-1.5\">\n                          <div className=\"text-[11px] font-medium text-muted-foreground\">\n                            Foreign Records\n                          </div>\n                          {result.linkLocks.locks.map((lock) => (\n                            <div key={lock.key} className=\"flex flex-col gap-0.5\">\n                              <span className=\"font-medium\">\n                                {lock.foreignTableName ?? lock.foreignTableId}\n                              </span>\n                              <span className=\"text-muted-foreground break-all\">\n                                {lock.foreignRecordId}\n                              </span>\n                              <span className=\"text-muted-foreground/60 break-all text-[10px]\">\n                                {lock.key}\n                              </span>\n                            </div>\n                          ))}\n                        </div>\n                      )}\n                      {result.linkLocks.sql && (\n                        <div className=\"space-y-2\">\n                          <div className=\"text-[11px] font-medium text-muted-foreground\">\n                            Lock SQL\n                          </div>\n                          <SqlBlock\n                            sql={result.linkLocks.sql}\n                            parameters={result.linkLocks.parameters ?? []}\n                          />\n                        </div>\n                      )}\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Right Panel - SQL */}\n          <div className=\"flex-1 min-w-0 overflow-auto\">\n            <div className=\"space-y-4 pr-4\">\n              <div className=\"flex items-center justify-between mb-2\">\n                <div className=\"flex flex-col gap-1\">\n                  <div className=\"flex items-center gap-2\">\n                    <Database className=\"h-4 w-4 text-muted-foreground\" />\n                    <h3 className=\"font-semibold\">SQL Statements</h3>\n                    <Badge variant=\"secondary\" className=\"text-xs\">\n                      {result.sqlExplains.length}\n                    </Badge>\n                  </div>\n                  <div className=\"text-[11px] text-muted-foreground\">\n                    DB URL:{' '}\n                    <span className=\"font-mono break-all\">{displayDbUrl ?? '(not set)'}</span>\n                  </div>\n                </div>\n                <DropdownMenu modal={false}>\n                  <DropdownMenuTrigger asChild>\n                    <Button variant=\"outline\" size=\"sm\" className=\"gap-1\">\n                      <Zap className=\"h-3.5 w-3.5\" />\n                      AI Analysis\n                      <ChevronDown className=\"h-3 w-3\" />\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent align=\"end\" className=\"z-[60]\">\n                    <DropdownMenuItem onClick={handleCopyRaw} className=\"gap-2 cursor-pointer\">\n                      <Copy className=\"h-4 w-4\" />\n                      {copiedKey === 'raw' ? 'Copied raw!' : 'Copy raw (Toon)'}\n                    </DropdownMenuItem>\n                    <DropdownMenuItem\n                      onClick={handleCopyOptimized}\n                      className=\"gap-2 cursor-pointer\"\n                    >\n                      <Copy className=\"h-4 w-4\" />\n                      {copiedKey === 'optimized' ? 'Copied optimized!' : 'Copy optimized prompt'}\n                    </DropdownMenuItem>\n                  </DropdownMenuContent>\n                </DropdownMenu>\n              </div>\n\n              {result.sqlExplains.length === 0 ? (\n                <div className=\"rounded-lg border border-dashed p-8 text-center text-muted-foreground\">\n                  No SQL statements to display\n                </div>\n              ) : (\n                <div className=\"space-y-4\">\n                  {result.sqlExplains.map((sqlInfo, i) => (\n                    <SqlStepCard\n                      key={i}\n                      sqlInfo={sqlInfo}\n                      index={i}\n                      computedLocks={result.computedLocks}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/FieldCreateDialog.tsx",
    "content": "import { useQueryState, parseAsBoolean } from 'nuqs';\nimport { Plus } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { FieldForm } from './FieldForm';\n\ninterface FieldCreateDialogProps {\n  baseId: string;\n  tableId: string;\n  onSuccess?: () => void;\n}\n\nexport function FieldCreateDialog({ baseId, tableId, onSuccess }: FieldCreateDialogProps) {\n  const [isOpen, setIsOpen] = useQueryState(\n    'createField',\n    parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true })\n  );\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" className=\"text-xs font-normal\">\n          <Plus className=\"mr-1.5 size-4\" />\n          Create field\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"max-w-2xl max-h-[90vh] overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle>Create Field</DialogTitle>\n          <DialogDescription>Add a new field to your table.</DialogDescription>\n        </DialogHeader>\n        <FieldForm\n          baseId={baseId}\n          tableId={tableId}\n          onCancel={() => setIsOpen(false)}\n          onSuccess={() => {\n            setIsOpen(false);\n            onSuccess?.();\n          }}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/FieldForm.tsx",
    "content": "import {\n  useForm,\n  type ReactFormApi,\n  standardSchemaValidator,\n  type Validator,\n  type StandardSchemaV1,\n} from '@tanstack/react-form';\nimport { toast } from 'sonner';\nimport { useMutation, useQuery } from '@tanstack/react-query';\nimport { useRef } from 'react';\nimport { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport {\n  ROLLUP_FUNCTIONS,\n  TIME_ZONE_LIST,\n  checkFieldNotNullValidationEnabled,\n  checkFieldUniqueValidationEnabled,\n  isComputedFieldType,\n  type ITableFieldInput,\n  tableFieldInputSchema,\n} from '@teable/v2-core';\nimport type { IListTablesOkResponseDto, ITableDto } from '@teable/v2-contract-http';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport { FieldFormOptions } from './FieldFormOptions';\n\ninterface FieldFormProps {\n  baseId: string;\n  tableId: string;\n  onCancel: () => void;\n  onSuccess: () => void;\n}\n\ntype FieldOptionsValue = Extract<ITableFieldInput, { options?: unknown }>['options'];\ntype FieldFormValues = Omit<ITableFieldInput, 'options'> & { options?: FieldOptionsValue };\ntype FieldFormValidator = Validator<FieldFormValues, StandardSchemaV1<FieldFormValues>>;\ntype LinkFieldOptions = Extract<ITableFieldInput, { type: 'link' }>['options'];\ntype RollupFieldConfig = Extract<ITableFieldInput, { type: 'rollup' }>['config'];\ntype RollupFieldOptions = Extract<ITableFieldInput, { type: 'rollup' }>['options'];\ntype LookupFieldOptions = Extract<ITableFieldInput, { type: 'lookup' }>['options'];\ntype FieldType = ITableFieldInput['type'];\n\nexport type FieldFormApi = ReactFormApi<FieldFormValues, FieldFormValidator>;\n\nexport function FieldForm({ baseId, tableId, onCancel, onSuccess }: FieldFormProps) {\n  const orpc = createTanstackQueryUtils(useOrpcClient());\n  const validatorAdapter = standardSchemaValidator() as FieldFormValidator;\n\n  const tablesQuery = useQuery<IListTablesOkResponseDto, Error, ReadonlyArray<ITableDto>>(\n    orpc.tables.list.queryOptions({\n      input: { baseId },\n      select: (response) => response.data.tables,\n    })\n  );\n\n  const createFieldMutation = useMutation(\n    orpc.tables.createField.mutationOptions({\n      onSuccess: () => {\n        onSuccess();\n      },\n      onError: (error: any) => {\n        toast.error(error.message || 'Failed to create field');\n      },\n    })\n  );\n\n  const typeDrafts = useRef<\n    Partial<\n      Record<\n        FieldType,\n        {\n          options?: FieldOptionsValue;\n          config?: RollupFieldConfig;\n          notNull?: boolean;\n          unique?: boolean;\n        }\n      >\n    >\n  >({});\n\n  const defaultLinkOptions = (): LinkFieldOptions => {\n    const tables = tablesQuery.data ?? [];\n    const candidates = tables.filter((table) => table.id !== tableId);\n    const target = candidates[0];\n    if (!target) {\n      return { relationship: 'manyMany' } as any;\n    }\n    const lookupField = target.fields.find((field) => field.isPrimary) ?? target.fields[0];\n    if (!lookupField) {\n      return { relationship: 'manyMany', foreignTableId: target.id } as any;\n    }\n    return {\n      relationship: 'manyMany',\n      foreignTableId: target.id,\n      lookupFieldId: lookupField.id,\n    } as any;\n  };\n\n  const defaultRollupConfig = (): RollupFieldConfig => {\n    const tables = tablesQuery.data ?? [];\n    const currentTable = tables.find((table) => table.id === tableId);\n    const linkField = currentTable?.fields.find((field) => field.type === 'link') as\n      | Extract<ITableDto['fields'][number], { type: 'link' }>\n      | undefined;\n    if (!linkField || linkField.type !== 'link') {\n      return {} as any;\n    }\n    const foreignTableId = linkField.options?.foreignTableId;\n    const foreignTable = tables.find((table) => table.id === foreignTableId);\n    const lookupField =\n      foreignTable?.fields.find((field) => field.isPrimary) ?? foreignTable?.fields[0];\n    return {\n      linkFieldId: linkField.id,\n      foreignTableId: foreignTableId ?? '',\n      lookupFieldId: lookupField?.id ?? linkField.options?.lookupFieldId ?? '',\n    } as any;\n  };\n\n  const defaultRollupOptions = (): RollupFieldOptions => {\n    return {\n      expression: ROLLUP_FUNCTIONS[0],\n      timeZone: TIME_ZONE_LIST[0],\n    } as any;\n  };\n\n  const defaultLookupOptions = (): LookupFieldOptions => {\n    const tables = tablesQuery.data ?? [];\n    const currentTable = tables.find((table) => table.id === tableId);\n    const linkField = currentTable?.fields.find((field) => field.type === 'link') as\n      | Extract<ITableDto['fields'][number], { type: 'link' }>\n      | undefined;\n    if (!linkField || linkField.type !== 'link') {\n      return {} as any;\n    }\n    const foreignTableId = linkField.options?.foreignTableId;\n    const foreignTable = tables.find((table) => table.id === foreignTableId);\n    const lookupField =\n      foreignTable?.fields.find((field) => field.isPrimary) ?? foreignTable?.fields[0];\n    return {\n      linkFieldId: linkField.id,\n      foreignTableId: foreignTableId ?? '',\n      lookupFieldId: lookupField?.id ?? linkField.options?.lookupFieldId ?? '',\n    } as any;\n  };\n\n  const defaultFormulaOptions = () => {\n    return {\n      expression: '1',\n    } as any;\n  };\n\n  const defaultConditionalRollupConfig = () => {\n    const tables = tablesQuery.data ?? [];\n    const candidates = tables.filter((table) => table.id !== tableId);\n    const foreignTable = candidates[0];\n    const lookupField =\n      foreignTable?.fields.find((field) => field.isPrimary) ?? foreignTable?.fields[0];\n    return {\n      foreignTableId: foreignTable?.id ?? '',\n      lookupFieldId: lookupField?.id ?? '',\n      condition: { filter: null },\n    } as any;\n  };\n\n  const defaultConditionalRollupOptions = () => {\n    return {\n      expression: ROLLUP_FUNCTIONS[0],\n      timeZone: TIME_ZONE_LIST[0],\n    } as any;\n  };\n\n  const defaultConditionalLookupOptions = () => {\n    const tables = tablesQuery.data ?? [];\n    const candidates = tables.filter((table) => table.id !== tableId);\n    const foreignTable = candidates[0];\n    const lookupField =\n      foreignTable?.fields.find((field) => field.isPrimary) ?? foreignTable?.fields[0];\n    return {\n      foreignTableId: foreignTable?.id ?? '',\n      lookupFieldId: lookupField?.id ?? '',\n      condition: { filter: null },\n    } as any;\n  };\n\n  const getDefaultValuesForType = (\n    type: FieldType\n  ): { options?: FieldOptionsValue; config?: RollupFieldConfig } => {\n    switch (type) {\n      case 'link':\n        return { options: defaultLinkOptions() };\n      case 'formula':\n        return { options: defaultFormulaOptions() };\n      case 'rollup':\n        return { options: defaultRollupOptions(), config: defaultRollupConfig() };\n      case 'lookup':\n        return { options: defaultLookupOptions() };\n      case 'conditionalRollup':\n        return {\n          options: defaultConditionalRollupOptions(),\n          config: defaultConditionalRollupConfig(),\n        };\n      case 'conditionalLookup':\n        return { options: defaultConditionalLookupOptions() };\n      default:\n        return { options: {} };\n    }\n  };\n\n  const form = useForm<FieldFormValues, FieldFormValidator>({\n    defaultValues: {\n      type: 'singleLineText',\n      name: '',\n      options: {},\n    } as FieldFormValues,\n    validatorAdapter,\n    validators: {\n      onChange: tableFieldInputSchema,\n      onBlur: tableFieldInputSchema,\n    },\n    onSubmit: async ({ value }: { value: FieldFormValues }) => {\n      createFieldMutation.mutate({\n        baseId,\n        tableId,\n        field: value,\n      } as any);\n    },\n  });\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        form.handleSubmit();\n      }}\n      className=\"space-y-6\"\n    >\n      <form.Field\n        name=\"name\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Field Name</Label>\n            <Input\n              id={field.name}\n              value={field.state.value}\n              onBlur={field.handleBlur}\n              onChange={(e) => field.handleChange(e.target.value)}\n              placeholder=\"Enter field name\"\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"type\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Field Type</Label>\n            <Select\n              value={field.state.value}\n              onValueChange={(value) => {\n                const nextType = value as FieldType;\n                const currentType = form.getFieldValue('type') as FieldType;\n                const currentOptions = form.getFieldValue('options') as\n                  | FieldOptionsValue\n                  | undefined;\n                const currentConfig = form.getFieldValue('config' as any) as\n                  | RollupFieldConfig\n                  | undefined;\n                const currentNotNull = form.getFieldValue('notNull' as any) as boolean | undefined;\n                const currentUnique = form.getFieldValue('unique' as any) as boolean | undefined;\n                typeDrafts.current[currentType] = {\n                  options: currentOptions,\n                  config: currentConfig,\n                  notNull: currentNotNull,\n                  unique: currentUnique,\n                };\n\n                const draft = typeDrafts.current[nextType];\n                const defaults = getDefaultValuesForType(nextType);\n                const nextOptions = draft?.options ?? defaults.options;\n                const nextConfig =\n                  nextType === 'rollup' || nextType === 'conditionalRollup'\n                    ? draft?.config ?? defaults.config\n                    : undefined;\n                const isComputed = isComputedFieldType(nextType);\n                const notNullEnabled = checkFieldNotNullValidationEnabled(nextType, {\n                  isComputed,\n                });\n                const uniqueEnabled = checkFieldUniqueValidationEnabled(nextType, {\n                  isComputed,\n                });\n                const nextNotNull = notNullEnabled ? draft?.notNull : undefined;\n                const nextUnique = uniqueEnabled ? draft?.unique : undefined;\n\n                const nextValues: FieldFormValues = {\n                  ...form.state.values,\n                  type: nextType,\n                  options: nextOptions,\n                  notNull: nextNotNull,\n                  unique: nextUnique,\n                };\n\n                if (nextType === 'rollup' || nextType === 'conditionalRollup') {\n                  (nextValues as FieldFormValues & { config?: RollupFieldConfig }).config =\n                    nextConfig;\n                }\n\n                form.reset(nextValues);\n                field.handleChange(nextType as any);\n              }}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue placeholder=\"Select a field type\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"singleLineText\">Text</SelectItem>\n                <SelectItem value=\"longText\">Long Text</SelectItem>\n                <SelectItem value=\"number\">Number</SelectItem>\n                <SelectItem value=\"autoNumber\">Auto Number</SelectItem>\n                <SelectItem value=\"rating\">Rating</SelectItem>\n                <SelectItem value=\"singleSelect\">Single Select</SelectItem>\n                <SelectItem value=\"multipleSelect\">Multiple Select</SelectItem>\n                <SelectItem value=\"checkbox\">Checkbox</SelectItem>\n                <SelectItem value=\"attachment\">Attachment</SelectItem>\n                <SelectItem value=\"date\">Date</SelectItem>\n                <SelectItem value=\"createdTime\">Created Time</SelectItem>\n                <SelectItem value=\"lastModifiedTime\">Last Modified Time</SelectItem>\n                <SelectItem value=\"user\">User</SelectItem>\n                <SelectItem value=\"createdBy\">Created By</SelectItem>\n                <SelectItem value=\"lastModifiedBy\">Last Modified By</SelectItem>\n                <SelectItem value=\"button\">Button</SelectItem>\n                <SelectItem value=\"formula\">Formula</SelectItem>\n                <SelectItem value=\"link\">Link</SelectItem>\n                <SelectItem value=\"rollup\">Rollup</SelectItem>\n                <SelectItem value=\"lookup\">Lookup</SelectItem>\n                <SelectItem value=\"conditionalRollup\">Conditional Rollup</SelectItem>\n                <SelectItem value=\"conditionalLookup\">Conditional Lookup</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n        )}\n      />\n\n      <form.Subscribe\n        selector={(state) => state.values.type}\n        children={(type) => (\n          <FieldFormOptions\n            key={type}\n            type={type}\n            form={form as FieldFormApi}\n            tableId={tableId}\n            tables={tablesQuery.data ?? []}\n            isTablesLoading={tablesQuery.isLoading}\n          />\n        )}\n      />\n\n      <div className=\"flex justify-end gap-3 pt-4\">\n        <Button type=\"button\" variant=\"outline\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <form.Subscribe\n          selector={(state) => [state.canSubmit, state.isSubmitting] as const}\n          children={([canSubmit, isSubmitting]) => (\n            <Button\n              type=\"submit\"\n              disabled={!canSubmit || isSubmitting || createFieldMutation.isPending}\n            >\n              {createFieldMutation.isPending ? 'Creating...' : 'Create Field'}\n            </Button>\n          )}\n        />\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/FieldFormOptions.tsx",
    "content": "import { match } from 'ts-pattern';\nimport { SingleLineTextOptions } from './field-options/SingleLineTextOptions';\nimport { NumberOptions } from './field-options/NumberOptions';\nimport { RatingOptions } from './field-options/RatingOptions';\nimport { SelectOptions } from './field-options/SelectOptions';\nimport { CheckboxOptions } from './field-options/CheckboxOptions';\nimport { DateOptions } from './field-options/DateOptions';\nimport { UserOptions } from './field-options/UserOptions';\nimport { ButtonOptions } from './field-options/ButtonOptions';\nimport { FormulaOptions } from './field-options/FormulaOptions';\nimport { LinkOptions } from './field-options/LinkOptions';\nimport { RollupOptions } from './field-options/RollupOptions';\nimport { LookupOptions } from './field-options/LookupOptions';\nimport { ConditionalRollupOptions } from './field-options/ConditionalRollupOptions';\nimport { ConditionalLookupOptions } from './field-options/ConditionalLookupOptions';\nimport type { FieldFormApi } from './FieldForm';\nimport {\n  checkFieldNotNullValidationEnabled,\n  checkFieldUniqueValidationEnabled,\n  isComputedFieldType,\n  type ITableFieldInput,\n} from '@teable/v2-core';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport { Label } from '@/components/ui/label';\nimport { Switch } from '@/components/ui/switch';\n\ninterface FieldFormOptionsProps {\n  type: ITableFieldInput['type'];\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n}\n\nexport function FieldFormOptions({\n  type,\n  form,\n  tableId,\n  tables,\n  isTablesLoading,\n}: FieldFormOptionsProps) {\n  const isComputed = isComputedFieldType(type);\n  const notNullEnabled = checkFieldNotNullValidationEnabled(type, { isComputed });\n  const uniqueEnabled = checkFieldUniqueValidationEnabled(type, { isComputed });\n  const validationHint = isComputed\n    ? 'Computed fields do not support not-null or unique validation.'\n    : 'No validation options for this field type.';\n\n  return (\n    <div className=\"space-y-4 border-t pt-4\">\n      <h3 className=\"text-sm font-medium\">Field Options</h3>\n      {match(type)\n        .with('singleLineText', () => <SingleLineTextOptions form={form} />)\n        .with('longText', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for long text.</p>\n        ))\n        .with('number', () => <NumberOptions form={form} />)\n        .with('rating', () => <RatingOptions form={form} />)\n        .with('singleSelect', () => <SelectOptions form={form} />)\n        .with('multipleSelect', () => <SelectOptions form={form} />)\n        .with('checkbox', () => <CheckboxOptions form={form} />)\n        .with('attachment', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for attachment.</p>\n        ))\n        .with('date', () => <DateOptions form={form} />)\n        .with('createdTime', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for created time.</p>\n        ))\n        .with('lastModifiedTime', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for last modified time.</p>\n        ))\n        .with('user', () => <UserOptions form={form} />)\n        .with('createdBy', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for created by.</p>\n        ))\n        .with('lastModifiedBy', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for last modified by.</p>\n        ))\n        .with('autoNumber', () => (\n          <p className=\"text-xs text-muted-foreground\">No options for auto number.</p>\n        ))\n        .with('button', () => <ButtonOptions form={form} />)\n        .with('formula', () => <FormulaOptions form={form} />)\n        .with('link', () => (\n          <LinkOptions\n            form={form}\n            tableId={tableId}\n            tables={tables}\n            isTablesLoading={isTablesLoading}\n          />\n        ))\n        .with('rollup', () => (\n          <RollupOptions\n            form={form}\n            tableId={tableId}\n            tables={tables}\n            isTablesLoading={isTablesLoading}\n          />\n        ))\n        .with('lookup', () => (\n          <LookupOptions\n            form={form}\n            tableId={tableId}\n            tables={tables}\n            isTablesLoading={isTablesLoading}\n          />\n        ))\n        .with('conditionalRollup', () => (\n          <ConditionalRollupOptions\n            form={form}\n            tableId={tableId}\n            tables={tables}\n            isTablesLoading={isTablesLoading}\n          />\n        ))\n        .with('conditionalLookup', () => (\n          <ConditionalLookupOptions\n            form={form}\n            tableId={tableId}\n            tables={tables}\n            isTablesLoading={isTablesLoading}\n          />\n        ))\n        .exhaustive()}\n      <div className=\"space-y-2 pt-2\">\n        <h4 className=\"text-xs font-medium uppercase text-muted-foreground\">Validation</h4>\n        {notNullEnabled || uniqueEnabled ? (\n          <div className=\"space-y-2\">\n            {notNullEnabled ? (\n              <form.Field\n                name=\"notNull\"\n                children={(field) => (\n                  <div className=\"flex items-center justify-between gap-3 rounded-md border border-border/60 p-3\">\n                    <div className=\"space-y-0.5\">\n                      <Label htmlFor={field.name}>Not Null</Label>\n                      <p className=\"text-xs text-muted-foreground\">\n                        Require a value in every record.\n                      </p>\n                    </div>\n                    <Switch\n                      id={field.name}\n                      checked={field.state.value === true}\n                      onCheckedChange={(checked) =>\n                        field.handleChange(checked ? true : (undefined as any))\n                      }\n                    />\n                  </div>\n                )}\n              />\n            ) : null}\n            {uniqueEnabled ? (\n              <form.Field\n                name=\"unique\"\n                children={(field) => (\n                  <div className=\"flex items-center justify-between gap-3 rounded-md border border-border/60 p-3\">\n                    <div className=\"space-y-0.5\">\n                      <Label htmlFor={field.name}>Unique</Label>\n                      <p className=\"text-xs text-muted-foreground\">\n                        Prevent duplicate values across records.\n                      </p>\n                    </div>\n                    <Switch\n                      id={field.name}\n                      checked={field.state.value === true}\n                      onCheckedChange={(checked) =>\n                        field.handleChange(checked ? true : (undefined as any))\n                      }\n                    />\n                  </div>\n                )}\n              />\n            ) : null}\n          </div>\n        ) : (\n          <p className=\"text-xs text-muted-foreground\">{validationHint}</p>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/ImportCsvDialog.tsx",
    "content": "import { FileUp, Globe, Loader2, Upload } from 'lucide-react';\nimport Papa from 'papaparse';\nimport { useCallback, useRef, useState } from 'react';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\n\ntype ImportCsvDialogProps = {\n  onImport: (data: { tableName: string; csvData?: string; csvUrl?: string }) => Promise<void>;\n  trigger?: React.ReactNode;\n};\n\nexport function ImportCsvDialog({ onImport, trigger }: ImportCsvDialogProps) {\n  const [open, setOpen] = useState(false);\n  const [importMode, setImportMode] = useState<'file' | 'url'>('file');\n  const [file, setFile] = useState<File | null>(null);\n  const [csvUrl, setCsvUrl] = useState('');\n  const [tableName, setTableName] = useState('');\n  const [preview, setPreview] = useState<{\n    headers: string[];\n    rows: Record<string, string>[];\n    totalRows: number;\n  } | null>(null);\n  const [isImporting, setIsImporting] = useState(false);\n  const [isLoadingPreview, setIsLoadingPreview] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const reset = useCallback(() => {\n    setFile(null);\n    setCsvUrl('');\n    setTableName('');\n    setPreview(null);\n    setError(null);\n    if (fileInputRef.current) {\n      fileInputRef.current.value = '';\n    }\n  }, []);\n\n  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {\n    const selectedFile = event.target.files?.[0];\n    if (!selectedFile) return;\n\n    setFile(selectedFile);\n    setError(null);\n\n    // 自动从文件名生成表名\n    const nameWithoutExt = selectedFile.name.replace(/\\.csv$/i, '');\n    setTableName(nameWithoutExt);\n\n    // 解析 CSV 预览\n    Papa.parse<Record<string, string>>(selectedFile, {\n      header: true,\n      skipEmptyLines: 'greedy',\n      preview: 5, // 只预览前 5 行\n      complete: (results) => {\n        if (results.errors.length > 0) {\n          setError(`CSV parse error: ${results.errors[0].message}`);\n          return;\n        }\n        setPreview({\n          headers: results.meta.fields ?? [],\n          rows: results.data,\n          totalRows: -1, // 未知总行数\n        });\n      },\n      error: (err) => {\n        setError(`Failed to parse CSV: ${err.message}`);\n      },\n    });\n  }, []);\n\n  const handleUrlPreview = useCallback(async () => {\n    if (!csvUrl.trim()) return;\n\n    setIsLoadingPreview(true);\n    setError(null);\n    setPreview(null);\n\n    try {\n      // Validate URL\n      new URL(csvUrl);\n\n      // 尝试从 URL 获取预览（只获取前 5 行）\n      // 使用 PapaParse 的 preview 模式\n      Papa.parse<Record<string, string>>(csvUrl, {\n        download: true,\n        header: true,\n        skipEmptyLines: 'greedy',\n        preview: 5,\n        complete: (results) => {\n          if (results.errors.length > 0) {\n            setError(`CSV parse error: ${results.errors[0].message}`);\n            setIsLoadingPreview(false);\n            return;\n          }\n          setPreview({\n            headers: results.meta.fields ?? [],\n            rows: results.data,\n            totalRows: -1,\n          });\n\n          // 自动从 URL 生成表名\n          if (!tableName) {\n            try {\n              const url = new URL(csvUrl);\n              const pathParts = url.pathname.split('/');\n              const filename = pathParts[pathParts.length - 1] || 'imported';\n              setTableName(filename.replace(/\\.csv$/i, ''));\n            } catch {\n              setTableName('imported');\n            }\n          }\n          setIsLoadingPreview(false);\n        },\n        error: (err) => {\n          setError(`Failed to load CSV from URL: ${err.message}`);\n          setIsLoadingPreview(false);\n        },\n      });\n    } catch {\n      setError('Invalid URL format');\n      setIsLoadingPreview(false);\n    }\n  }, [csvUrl, tableName]);\n\n  const handleImport = useCallback(async () => {\n    if (!tableName.trim()) return;\n\n    if (importMode === 'file' && !file) return;\n    if (importMode === 'url' && !csvUrl.trim()) return;\n\n    setIsImporting(true);\n    setError(null);\n\n    try {\n      if (importMode === 'file' && file) {\n        // Read file as text\n        const csvData = await file.text();\n        if (!csvData.trim()) {\n          throw new Error('CSV file is empty');\n        }\n\n        await onImport({\n          tableName: tableName.trim(),\n          csvData,\n        });\n      } else if (importMode === 'url') {\n        // 传递 URL，让后端流式处理\n        await onImport({\n          tableName: tableName.trim(),\n          csvUrl: csvUrl.trim(),\n        });\n      }\n\n      setOpen(false);\n      reset();\n    } catch (err) {\n      setError(err instanceof Error ? err.message : 'Import failed');\n    } finally {\n      setIsImporting(false);\n    }\n  }, [file, csvUrl, tableName, importMode, onImport, reset]);\n\n  const handleOpenChange = useCallback(\n    (nextOpen: boolean) => {\n      setOpen(nextOpen);\n      if (!nextOpen) {\n        reset();\n      }\n    },\n    [reset]\n  );\n\n  return (\n    <Dialog open={open} onOpenChange={handleOpenChange}>\n      <DialogTrigger asChild>\n        {trigger ?? (\n          <Button variant=\"outline\" size=\"sm\" className=\"text-xs\">\n            <FileUp className=\"mr-1.5 h-3.5 w-3.5\" />\n            Import CSV\n          </Button>\n        )}\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-lg\">\n        <DialogHeader>\n          <DialogTitle>Import from CSV</DialogTitle>\n          <DialogDescription>\n            Upload a CSV file to create a new table. All columns will be created as text fields.\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"grid gap-4 py-4\">\n          <Tabs\n            value={importMode}\n            onValueChange={(v) => {\n              setImportMode(v as 'file' | 'url');\n              reset();\n            }}\n          >\n            <TabsList className=\"grid w-full grid-cols-2\">\n              <TabsTrigger value=\"file\" className=\"gap-1.5\">\n                <Upload className=\"h-3.5 w-3.5\" />\n                File Upload\n              </TabsTrigger>\n              <TabsTrigger value=\"url\" className=\"gap-1.5\">\n                <Globe className=\"h-3.5 w-3.5\" />\n                From URL\n              </TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"file\" className=\"mt-4 space-y-4\">\n              {/* File Input */}\n              <div className=\"grid gap-2\">\n                <Label htmlFor=\"csv-file\">CSV File</Label>\n                <div\n                  className=\"relative flex min-h-[120px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 bg-muted/50 transition-colors hover:border-muted-foreground/50 hover:bg-muted\"\n                  onClick={() => fileInputRef.current?.click()}\n                >\n                  <input\n                    ref={fileInputRef}\n                    id=\"csv-file\"\n                    type=\"file\"\n                    accept=\".csv,text/csv\"\n                    onChange={handleFileChange}\n                    className=\"sr-only\"\n                  />\n                  <div className=\"flex flex-col items-center gap-2 text-center\">\n                    <Upload className=\"h-8 w-8 text-muted-foreground/50\" />\n                    {file ? (\n                      <div>\n                        <p className=\"text-sm font-medium\">{file.name}</p>\n                        <p className=\"text-xs text-muted-foreground\">\n                          {(file.size / 1024).toFixed(1)} KB\n                        </p>\n                      </div>\n                    ) : (\n                      <div>\n                        <p className=\"text-sm text-muted-foreground\">Click to upload CSV file</p>\n                        <p className=\"text-xs text-muted-foreground/70\">or drag and drop</p>\n                      </div>\n                    )}\n                  </div>\n                </div>\n              </div>\n\n              {/* Table Name Input for File */}\n              {file && (\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"table-name-file\">Table Name</Label>\n                  <Input\n                    id=\"table-name-file\"\n                    value={tableName}\n                    onChange={(e) => setTableName(e.target.value)}\n                    placeholder=\"Enter table name\"\n                  />\n                </div>\n              )}\n            </TabsContent>\n\n            <TabsContent value=\"url\" className=\"mt-4 space-y-4\">\n              {/* URL Input */}\n              <div className=\"grid gap-2\">\n                <Label htmlFor=\"csv-url\">CSV URL</Label>\n                <div className=\"flex gap-2\">\n                  <Input\n                    id=\"csv-url\"\n                    type=\"url\"\n                    value={csvUrl}\n                    onChange={(e) => setCsvUrl(e.target.value)}\n                    placeholder=\"https://example.com/data.csv\"\n                    className=\"flex-1\"\n                  />\n                  <Button\n                    variant=\"outline\"\n                    onClick={handleUrlPreview}\n                    disabled={!csvUrl.trim() || isLoadingPreview}\n                  >\n                    {isLoadingPreview ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : 'Preview'}\n                  </Button>\n                </div>\n                <p className=\"text-xs text-muted-foreground\">\n                  Enter a public URL to a CSV file. Large files will be streamed.\n                </p>\n              </div>\n\n              {/* Table Name Input for URL */}\n              {preview && (\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"table-name-url\">Table Name</Label>\n                  <Input\n                    id=\"table-name-url\"\n                    value={tableName}\n                    onChange={(e) => setTableName(e.target.value)}\n                    placeholder=\"Enter table name\"\n                  />\n                </div>\n              )}\n            </TabsContent>\n          </Tabs>\n\n          {/* Preview */}\n          {preview && (\n            <div className=\"grid gap-2\">\n              <Label>Preview ({preview.headers.length} columns)</Label>\n              <div className=\"max-h-[200px] overflow-auto rounded-md border bg-muted/30\">\n                <table className=\"w-full text-xs\">\n                  <thead className=\"sticky top-0 bg-muted\">\n                    <tr>\n                      {preview.headers.map((header, i) => (\n                        <th\n                          key={i}\n                          className=\"whitespace-nowrap border-b px-2 py-1.5 text-left font-medium\"\n                        >\n                          {header || `Column ${i + 1}`}\n                        </th>\n                      ))}\n                    </tr>\n                  </thead>\n                  <tbody>\n                    {preview.rows.map((row, rowIndex) => (\n                      <tr key={rowIndex} className=\"border-b last:border-0\">\n                        {preview.headers.map((header, colIndex) => (\n                          <td\n                            key={colIndex}\n                            className=\"whitespace-nowrap px-2 py-1 text-muted-foreground\"\n                          >\n                            {row[header] || '-'}\n                          </td>\n                        ))}\n                      </tr>\n                    ))}\n                  </tbody>\n                </table>\n              </div>\n              <p className=\"text-xs text-muted-foreground\">Showing first 5 rows</p>\n            </div>\n          )}\n\n          {/* Error */}\n          {error && (\n            <div className=\"rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive\">\n              {error}\n            </div>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => setOpen(false)} disabled={isImporting}>\n            Cancel\n          </Button>\n          <Button\n            onClick={handleImport}\n            disabled={\n              isImporting ||\n              !tableName.trim() ||\n              (importMode === 'file' && !file) ||\n              (importMode === 'url' && !csvUrl.trim())\n            }\n          >\n            {isImporting ? (\n              <>\n                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                Importing...\n              </>\n            ) : (\n              <>\n                <FileUp className=\"mr-2 h-4 w-4\" />\n                Import\n              </>\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/LinkFieldLabel.tsx",
    "content": "import type { Field, LinkField, LinkRelationshipValue } from '@teable/v2-core';\nimport { Badge } from '@/components/ui/badge';\nimport { getFieldTypeIcon } from '@/lib/fieldTypeIcons';\nimport { cn } from '@/lib/utils';\n\ntype LinkFieldLabelProps = {\n  name: string;\n  fieldId: string;\n  relationship: string;\n  isOneWay?: boolean;\n  className?: string;\n  badgeClassName?: string;\n};\n\nconst relationshipLabels: Record<LinkRelationshipValue, string> = {\n  manyMany: 'many-many',\n  oneMany: 'one-many',\n  manyOne: 'many-one',\n  oneOne: 'one-one',\n};\n\nconst formatRelationshipLabel = (relationship: string): string =>\n  relationshipLabels[relationship as LinkRelationshipValue] ?? relationship;\n\nexport function LinkFieldLabel({\n  name,\n  fieldId,\n  relationship,\n  isOneWay = false,\n  className,\n  badgeClassName,\n}: LinkFieldLabelProps) {\n  const relationshipLabel = formatRelationshipLabel(relationship);\n  const directionLabel = isOneWay ? 'one-way' : 'two-way';\n  const badgeClasses = cn('h-4 px-1 text-[9px] font-normal uppercase', badgeClassName);\n\n  return (\n    <span\n      className={cn('inline-flex items-center gap-1.5', className)}\n      data-field-id={fieldId}\n      title={fieldId}\n    >\n      <span>{name}</span>\n      <Badge variant=\"outline\" className={badgeClasses}>\n        {relationshipLabel}\n      </Badge>\n      <Badge variant={isOneWay ? 'secondary' : 'outline'} className={badgeClasses}>\n        {directionLabel}\n      </Badge>\n    </span>\n  );\n}\n\ntype FieldLabelProps = {\n  field: Field;\n  className?: string;\n};\n\nexport function FieldLabel({ field, className }: FieldLabelProps) {\n  const fieldId = field.id().toString();\n  const fieldType = field.type().toString();\n  const fieldName = field.name().toString();\n  const FieldIcon = getFieldTypeIcon(fieldType);\n\n  if (fieldType === 'link') {\n    const linkField = field as LinkField;\n    return (\n      <span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={fieldId}>\n        <FieldIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n        <LinkFieldLabel\n          name={fieldName}\n          fieldId={fieldId}\n          relationship={linkField.relationship().toString()}\n          isOneWay={linkField.isOneWay()}\n          className=\"min-w-0\"\n        />\n      </span>\n    );\n  }\n\n  return (\n    <span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={fieldId}>\n      <FieldIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n      <span className=\"truncate\">{fieldName}</span>\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/LogPanel.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport {\n  AlertCircle,\n  AlertTriangle,\n  Bug,\n  ChevronDown,\n  ChevronUp,\n  Circle,\n  Info,\n  Maximize2,\n  Minimize2,\n  Pause,\n  Play,\n  Search,\n  Terminal,\n  Trash2,\n  Wifi,\n  WifiOff,\n  X,\n} from 'lucide-react';\nimport { parseAsBoolean, useQueryState } from 'nuqs';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\nimport { useLogStream, type LogEntry, type LogLevel } from '@/hooks/useLogStream';\n\ntype LogLevelConfig = {\n  icon: typeof Info;\n  className: string;\n  badgeClassName: string;\n  label: string;\n};\n\nconst LOG_LEVEL_CONFIG: Record<LogLevel, LogLevelConfig> = {\n  debug: {\n    icon: Bug,\n    className: 'text-slate-500',\n    badgeClassName: 'bg-slate-500/10 text-slate-600 border-slate-500/30',\n    label: 'DEBUG',\n  },\n  info: {\n    icon: Info,\n    className: 'text-blue-500',\n    badgeClassName: 'bg-blue-500/10 text-blue-600 border-blue-500/30',\n    label: 'INFO',\n  },\n  warn: {\n    icon: AlertTriangle,\n    className: 'text-amber-500',\n    badgeClassName: 'bg-amber-500/10 text-amber-600 border-amber-500/30',\n    label: 'WARN',\n  },\n  error: {\n    icon: AlertCircle,\n    className: 'text-red-500',\n    badgeClassName: 'bg-red-500/10 text-red-600 border-red-500/30',\n    label: 'ERROR',\n  },\n};\n\nconst ALL_LEVELS: ReadonlyArray<LogLevel> = ['debug', 'info', 'warn', 'error'];\n\ntype PanelSize = 'normal' | 'large';\n\nconst PANEL_SIZE_CONFIG: Record<PanelSize, { width: string; height: string }> = {\n  normal: { width: 'w-[600px]', height: 'h-[320px]' },\n  large: { width: 'w-[900px]', height: 'h-[500px]' },\n};\n\nconst LOG_PANEL_SIZE_KEY = 'teable-log-panel-size';\n\nconst readStoredSize = (): PanelSize => {\n  if (typeof window === 'undefined') return 'normal';\n  const stored = localStorage.getItem(LOG_PANEL_SIZE_KEY);\n  if (stored === 'large' || stored === 'normal') return stored;\n  return 'normal';\n};\n\nconst storeSize = (size: PanelSize): void => {\n  if (typeof window === 'undefined') return;\n  localStorage.setItem(LOG_PANEL_SIZE_KEY, size);\n};\n\ntype LogPanelProps = {\n  className?: string;\n  defaultLevels?: ReadonlyArray<LogLevel>;\n};\n\nexport function LogPanel({\n  className,\n  defaultLevels = ['debug', 'info', 'warn', 'error'],\n}: LogPanelProps) {\n  // Use URL query param for expanded state\n  const [expanded, setExpanded] = useQueryState('logs', parseAsBoolean.withDefault(false));\n\n  // Use localStorage for panel size\n  const [panelSize, setPanelSize] = useState<PanelSize>(() => readStoredSize());\n  const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(new Set(defaultLevels));\n  const [searchQuery, setSearchQuery] = useState('');\n  const [autoScroll, setAutoScroll] = useState(true);\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  const sizeConfig = PANEL_SIZE_CONFIG[panelSize];\n  const isLarge = panelSize === 'large';\n\n  const toggleSize = useCallback(() => {\n    setPanelSize((prev) => {\n      const next = prev === 'normal' ? 'large' : 'normal';\n      storeSize(next);\n      return next;\n    });\n  }, []);\n\n  const { logs, status, paused, pause, resume, clear } = useLogStream({\n    enabled: expanded,\n  });\n\n  // Filter logs by level and search query\n  const filteredLogs = useMemo(() => {\n    return logs.filter((log) => {\n      if (!enabledLevels.has(log.level)) return false;\n      if (searchQuery.trim()) {\n        const query = searchQuery.toLowerCase();\n        const matchesMessage = log.message.toLowerCase().includes(query);\n        const matchesContext = log.context\n          ? JSON.stringify(log.context).toLowerCase().includes(query)\n          : false;\n        if (!matchesMessage && !matchesContext) return false;\n      }\n      return true;\n    });\n  }, [logs, enabledLevels, searchQuery]);\n\n  // Count logs by level\n  const levelCounts = useMemo(() => {\n    const counts: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 };\n    for (const log of logs) {\n      counts[log.level]++;\n    }\n    return counts;\n  }, [logs]);\n\n  // Auto-scroll to bottom\n  useEffect(() => {\n    if (autoScroll && scrollRef.current) {\n      const viewport = scrollRef.current.querySelector('[data-slot=\"scroll-area-viewport\"]');\n      if (viewport) {\n        viewport.scrollTop = viewport.scrollHeight;\n      }\n    }\n  }, [filteredLogs, autoScroll]);\n\n  const toggleLevel = useCallback((level: LogLevel) => {\n    setEnabledLevels((prev) => {\n      const next = new Set(prev);\n      if (next.has(level)) {\n        next.delete(level);\n      } else {\n        next.add(level);\n      }\n      return next;\n    });\n  }, []);\n\n  const isConnected = status === 'connected';\n\n  if (!expanded) {\n    return (\n      <div className={cn('fixed bottom-4 right-4 z-50', className)}>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              className=\"relative h-10 w-10 rounded-full bg-background/95 backdrop-blur shadow-lg hover:shadow-xl transition-shadow\"\n              onClick={() => void setExpanded(true)}\n            >\n              <Terminal className=\"h-5 w-5\" />\n              {levelCounts.error > 0 && (\n                <span className=\"absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white\">\n                  {levelCounts.error > 99 ? '99+' : levelCounts.error}\n                </span>\n              )}\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent side=\"left\">Open log panel</TooltipContent>\n        </Tooltip>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        'fixed bottom-4 right-4 z-50 flex max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-background/95 backdrop-blur shadow-2xl transition-all duration-200',\n        sizeConfig.width,\n        isLarge && 'max-h-[calc(100vh-2rem)]',\n        className\n      )}\n    >\n      {/* Header */}\n      <div className=\"flex items-center gap-2 border-b px-3 py-2\">\n        <div className=\"flex items-center gap-2\">\n          <Terminal className=\"h-4 w-4 text-muted-foreground\" />\n          <span className=\"text-sm font-medium\">Logs</span>\n          <Badge variant=\"outline\" className=\"text-[10px] px-1.5 py-0\">\n            {filteredLogs.length}\n          </Badge>\n        </div>\n\n        <div className=\"flex-1\" />\n\n        {/* Connection Status */}\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div\n              className={cn(\n                'flex items-center gap-1.5 text-xs',\n                isConnected ? 'text-emerald-600' : 'text-muted-foreground'\n              )}\n            >\n              {isConnected ? (\n                <>\n                  <Wifi className=\"h-3.5 w-3.5\" />\n                  <Circle className=\"h-1.5 w-1.5 fill-current animate-pulse\" />\n                </>\n              ) : (\n                <WifiOff className=\"h-3.5 w-3.5\" />\n              )}\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            {isConnected ? 'Connected to log stream' : `Status: ${status}`}\n          </TooltipContent>\n        </Tooltip>\n\n        {/* Actions */}\n        <div className=\"flex items-center gap-1\">\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon-sm\" onClick={paused ? resume : pause}>\n                {paused ? <Play className=\"h-3.5 w-3.5\" /> : <Pause className=\"h-3.5 w-3.5\" />}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{paused ? 'Resume' : 'Pause'}</TooltipContent>\n          </Tooltip>\n\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon-sm\" onClick={clear}>\n                <Trash2 className=\"h-3.5 w-3.5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>Clear logs</TooltipContent>\n          </Tooltip>\n\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                onClick={() => setAutoScroll(!autoScroll)}\n                className={autoScroll ? 'text-primary' : ''}\n              >\n                <ChevronDown className=\"h-3.5 w-3.5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{autoScroll ? 'Auto-scroll on' : 'Auto-scroll off'}</TooltipContent>\n          </Tooltip>\n\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon-sm\" onClick={toggleSize}>\n                {isLarge ? (\n                  <Minimize2 className=\"h-3.5 w-3.5\" />\n                ) : (\n                  <Maximize2 className=\"h-3.5 w-3.5\" />\n                )}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{isLarge ? 'Shrink panel' : 'Expand panel'}</TooltipContent>\n          </Tooltip>\n\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            onClick={() => void setExpanded(null)}\n            className=\"ml-1\"\n          >\n            <X className=\"h-3.5 w-3.5\" />\n          </Button>\n        </div>\n      </div>\n\n      {/* Filters */}\n      <div className=\"flex items-center gap-2 border-b px-3 py-2\">\n        {/* Search */}\n        <div className=\"relative flex-1\">\n          <Search className=\"absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground\" />\n          <Input\n            type=\"search\"\n            placeholder=\"Search logs...\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            className=\"pl-7\"\n            size=\"sm\"\n          />\n        </div>\n\n        {/* Level Filters */}\n        <div className=\"flex items-center gap-1\">\n          {ALL_LEVELS.map((level) => {\n            const config = LOG_LEVEL_CONFIG[level];\n            const Icon = config.icon;\n            const isActive = enabledLevels.has(level);\n            const count = levelCounts[level];\n\n            return (\n              <Tooltip key={level}>\n                <TooltipTrigger asChild>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => toggleLevel(level)}\n                    className={cn(\n                      'h-7 gap-1 px-2 text-xs',\n                      isActive ? config.className : 'text-muted-foreground opacity-50'\n                    )}\n                  >\n                    <Icon className=\"h-3.5 w-3.5\" />\n                    <span className=\"tabular-nums\">{count}</span>\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>{isActive ? `Hide ${level}` : `Show ${level}`}</TooltipContent>\n              </Tooltip>\n            );\n          })}\n        </div>\n      </div>\n\n      {/* Log List */}\n      <ScrollArea className={cn('transition-all duration-200', sizeConfig.height)} ref={scrollRef}>\n        <div className=\"p-2 space-y-0.5\">\n          {filteredLogs.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center py-12 text-muted-foreground\">\n              <Terminal className=\"h-8 w-8 mb-2 opacity-40\" />\n              <span className=\"text-sm\">No logs to display</span>\n              {!isConnected && <span className=\"text-xs mt-1\">Waiting for connection...</span>}\n            </div>\n          ) : (\n            filteredLogs.map((log) => <LogRow key={log.id} log={log} />)\n          )}\n        </div>\n      </ScrollArea>\n\n      {/* Footer */}\n      {paused && (\n        <div className=\"flex items-center justify-center border-t px-3 py-1.5 text-xs text-muted-foreground bg-amber-500/5\">\n          <Pause className=\"h-3 w-3 mr-1.5\" />\n          Log reception paused\n        </div>\n      )}\n    </div>\n  );\n}\n\ntype LogRowProps = {\n  log: LogEntry;\n};\n\nfunction LogRow({ log }: LogRowProps) {\n  const [expanded, setExpanded] = useState(false);\n  const config = LOG_LEVEL_CONFIG[log.level];\n  const Icon = config.icon;\n  const hasContext = log.context && Object.keys(log.context).length > 0;\n\n  const timestamp = useMemo(() => {\n    const date = new Date(log.timestamp);\n    return date.toLocaleTimeString('en-US', {\n      hour12: false,\n      hour: '2-digit',\n      minute: '2-digit',\n      second: '2-digit',\n      fractionalSecondDigits: 3,\n    });\n  }, [log.timestamp]);\n\n  return (\n    <div\n      className={cn(\n        'group rounded-md px-2 py-1 text-xs font-mono transition-colors hover:bg-muted/50',\n        log.level === 'error' && 'bg-red-500/5 hover:bg-red-500/10',\n        log.level === 'warn' && 'bg-amber-500/5 hover:bg-amber-500/10'\n      )}\n    >\n      <div className=\"flex items-start gap-2\">\n        <Icon className={cn('h-3.5 w-3.5 mt-0.5 shrink-0', config.className)} />\n        <span className=\"text-muted-foreground shrink-0 select-none\">{timestamp}</span>\n        <Badge\n          variant=\"outline\"\n          className={cn('px-1 py-0 text-[9px] shrink-0', config.badgeClassName)}\n        >\n          {config.label}\n        </Badge>\n        <span className=\"flex-1 break-all\">{log.message}</span>\n        {hasContext && (\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            className=\"h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0\"\n            onClick={() => setExpanded(!expanded)}\n          >\n            {expanded ? <ChevronUp className=\"h-3 w-3\" /> : <ChevronDown className=\"h-3 w-3\" />}\n          </Button>\n        )}\n      </div>\n      {expanded && hasContext && (\n        <div className=\"mt-1.5 ml-6 rounded bg-muted/50 p-2 text-[10px] overflow-x-auto\">\n          <pre className=\"text-muted-foreground whitespace-pre-wrap\">\n            {JSON.stringify(log.context, null, 2)}\n          </pre>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/MetaCheckPanel.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport {\n  PLAYGROUND_DB_URL_QUERY_PARAM,\n  resolvePlaygroundDbUrl,\n} from '@/lib/playground/databaseUrl';\nimport {\n  CheckCircle2,\n  XCircle,\n  AlertTriangle,\n  Loader2,\n  Play,\n  RefreshCcw,\n  FileSearch,\n  Link2,\n  Table2,\n} from 'lucide-react';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport { getFieldTypeIcon } from '@/lib/fieldTypeIcons';\nimport { cn } from '@/lib/utils';\n\nexport type MetaValidationSeverity = 'error' | 'warning' | 'info';\nexport type MetaValidationCategory = 'schema' | 'reference';\n\nexport interface MetaValidationIssue {\n  fieldId: string;\n  fieldName: string;\n  fieldType: string;\n  category: MetaValidationCategory;\n  severity: MetaValidationSeverity;\n  message: string;\n  details?: {\n    path?: string;\n    expected?: string;\n    received?: string;\n    relatedTableId?: string;\n    relatedFieldId?: string;\n  };\n}\n\ninterface MetaCheckSSEResult {\n  id: string;\n  type: 'connect' | 'issue' | 'complete' | 'error';\n  issue?: MetaValidationIssue;\n  message?: string;\n  timestamp: number;\n}\n\ntype FieldMeta = {\n  id: string;\n  name: string;\n  type: string;\n  relationship?: string;\n  isOneWay?: boolean;\n};\n\ntype MetaCheckPanelProps = {\n  tableId: string;\n  tableName: string;\n  fields?: ReadonlyArray<FieldMeta>;\n};\n\nconst SeverityIcon = ({ severity }: { severity: MetaValidationSeverity }) => {\n  switch (severity) {\n    case 'error':\n      return <XCircle className=\"h-4 w-4 text-destructive\" />;\n    case 'warning':\n      return <AlertTriangle className=\"h-4 w-4 text-yellow-500\" />;\n    case 'info':\n    default:\n      return <CheckCircle2 className=\"h-4 w-4 text-blue-500\" />;\n  }\n};\n\nconst SeverityBadge = ({ severity }: { severity: MetaValidationSeverity }) => {\n  const variants: Record<\n    MetaValidationSeverity,\n    'default' | 'secondary' | 'destructive' | 'outline'\n  > = {\n    error: 'destructive',\n    warning: 'outline',\n    info: 'secondary',\n  };\n\n  const labels: Record<MetaValidationSeverity, string> = {\n    error: 'Error',\n    warning: 'Warning',\n    info: 'Info',\n  };\n\n  return (\n    <Badge variant={variants[severity]} className=\"h-5 px-1.5 text-[10px] font-normal uppercase\">\n      {labels[severity]}\n    </Badge>\n  );\n};\n\nconst CategoryIcon = ({ category }: { category: MetaValidationCategory }) => {\n  switch (category) {\n    case 'reference':\n      return <Link2 className=\"h-3 w-3 text-muted-foreground\" />;\n    case 'schema':\n    default:\n      return <Table2 className=\"h-3 w-3 text-muted-foreground\" />;\n  }\n};\n\n/**\n * Renders a single issue result.\n */\nconst IssueResultItem = ({ issue }: { issue: MetaValidationIssue }) => {\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-2 text-xs rounded-md p-2',\n        issue.severity === 'error'\n          ? 'bg-destructive/10'\n          : issue.severity === 'warning'\n            ? 'bg-yellow-500/10'\n            : 'bg-blue-500/10'\n      )}\n    >\n      <SeverityIcon severity={issue.severity} />\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          <CategoryIcon category={issue.category} />\n          <span className=\"font-medium capitalize\">{issue.category}</span>\n          <SeverityBadge severity={issue.severity} />\n        </div>\n        <div\n          className={cn(\n            'mt-1',\n            issue.severity === 'error'\n              ? 'text-destructive'\n              : issue.severity === 'warning'\n                ? 'text-yellow-600 dark:text-yellow-400'\n                : 'text-muted-foreground'\n          )}\n        >\n          {issue.message}\n        </div>\n        {issue.details && (\n          <div className=\"mt-1 text-[11px] text-muted-foreground space-y-0.5\">\n            {issue.details.path && (\n              <div>\n                Path: <code className=\"bg-muted px-1 rounded\">{issue.details.path}</code>\n              </div>\n            )}\n            {issue.details.relatedTableId && (\n              <div>\n                Related Table:{' '}\n                <code className=\"bg-muted px-1 rounded\">{issue.details.relatedTableId}</code>\n              </div>\n            )}\n            {issue.details.relatedFieldId && (\n              <div>\n                Related Field:{' '}\n                <code className=\"bg-muted px-1 rounded\">{issue.details.relatedFieldId}</code>\n              </div>\n            )}\n            {issue.details.expected && (\n              <div>\n                Expected: <code className=\"bg-muted px-1 rounded\">{issue.details.expected}</code>\n              </div>\n            )}\n            {issue.details.received && (\n              <div>\n                Received: <code className=\"bg-muted px-1 rounded\">{issue.details.received}</code>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport function MetaCheckPanel({ tableId, tableName, fields }: MetaCheckPanelProps) {\n  const [issues, setIssues] = useState<MetaValidationIssue[]>([]);\n  const [isRunning, setIsRunning] = useState(false);\n  const [hasRun, setHasRun] = useState(false);\n  const eventSourceRef = useRef<EventSource | null>(null);\n\n  const stopCheck = useCallback(() => {\n    if (eventSourceRef.current) {\n      eventSourceRef.current.close();\n      eventSourceRef.current = null;\n    }\n    setIsRunning(false);\n  }, []);\n\n  const startCheck = useCallback(() => {\n    stopCheck();\n    setIssues([]);\n    setIsRunning(true);\n    setHasRun(true);\n\n    const dbUrl = resolvePlaygroundDbUrl();\n    const baseUrl = `/api/meta/${tableId}/check/stream`;\n    const eventSourceUrl = dbUrl\n      ? `${baseUrl}?${new URLSearchParams({\n          [PLAYGROUND_DB_URL_QUERY_PARAM]: dbUrl,\n        }).toString()}`\n      : baseUrl;\n    const eventSource = new EventSource(eventSourceUrl);\n    eventSourceRef.current = eventSource;\n\n    eventSource.onmessage = (event) => {\n      try {\n        const result = JSON.parse(event.data) as MetaCheckSSEResult;\n\n        // Skip connection message\n        if (result.type === 'connect') {\n          return;\n        }\n\n        if (result.type === 'complete') {\n          setIsRunning(false);\n          eventSource.close();\n          return;\n        }\n\n        if (result.type === 'error') {\n          // Add error as an issue\n          setIssues((prev) => [\n            ...prev,\n            {\n              fieldId: '',\n              fieldName: '',\n              fieldType: '',\n              category: 'schema',\n              severity: 'error',\n              message: result.message || 'Unknown error',\n            },\n          ]);\n          setIsRunning(false);\n          eventSource.close();\n          return;\n        }\n\n        if (result.type === 'issue' && result.issue) {\n          setIssues((prev) => [...prev, result.issue!]);\n        }\n      } catch (e) {\n        console.error('Failed to parse SSE message:', e);\n      }\n    };\n\n    eventSource.onerror = () => {\n      setIsRunning(false);\n      eventSource.close();\n    };\n  }, [tableId, stopCheck]);\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      if (eventSourceRef.current) {\n        eventSourceRef.current.close();\n      }\n    };\n  }, []);\n\n  // Reset when tableId changes\n  const hasStartedRef = useRef(false);\n  useEffect(() => {\n    hasStartedRef.current = false;\n    setIssues([]);\n    setHasRun(false);\n    stopCheck();\n  }, [tableId, stopCheck]);\n\n  // Auto-start when entering the tab\n  useEffect(() => {\n    if (!hasStartedRef.current && !isRunning && !hasRun) {\n      hasStartedRef.current = true;\n      const timer = setTimeout(() => {\n        startCheck();\n      }, 100);\n      return () => clearTimeout(timer);\n    }\n  }, [tableId, isRunning, hasRun, startCheck]);\n\n  // Group issues by field\n  const groupedIssues = issues.reduce<Record<string, MetaValidationIssue[]>>((acc, issue) => {\n    const key = issue.fieldId || 'system';\n    if (!acc[key]) {\n      acc[key] = [];\n    }\n    acc[key].push(issue);\n    return acc;\n  }, {});\n\n  // Summary counts\n  const summary = {\n    total: issues.length,\n    error: issues.filter((i) => i.severity === 'error').length,\n    warning: issues.filter((i) => i.severity === 'warning').length,\n    info: issues.filter((i) => i.severity === 'info').length,\n  };\n\n  // Get field meta by ID\n  const getFieldMeta = (fieldId: string): FieldMeta | undefined => {\n    return fields?.find((f) => f.id === fieldId);\n  };\n\n  return (\n    <section className=\"space-y-4 min-w-0\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex flex-wrap items-center gap-2 text-sm font-semibold\">\n          <FileSearch className=\"h-4 w-4 text-muted-foreground\" />\n          Meta Check\n          {hasRun && (\n            <>\n              {summary.total === 0 ? (\n                <Badge\n                  variant=\"secondary\"\n                  className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider text-green-600\"\n                >\n                  ✓ All valid\n                </Badge>\n              ) : (\n                <>\n                  <Badge\n                    variant=\"secondary\"\n                    className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n                  >\n                    {summary.total} issues\n                  </Badge>\n                  {summary.error > 0 && (\n                    <Badge\n                      variant=\"destructive\"\n                      className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n                    >\n                      ✗ {summary.error}\n                    </Badge>\n                  )}\n                  {summary.warning > 0 && (\n                    <Badge\n                      variant=\"outline\"\n                      className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider text-yellow-600\"\n                    >\n                      ⚠ {summary.warning}\n                    </Badge>\n                  )}\n                </>\n              )}\n            </>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {isRunning ? (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"h-8 text-xs font-normal\"\n              onClick={stopCheck}\n            >\n              <XCircle className=\"mr-1.5 h-3.5 w-3.5\" />\n              Stop\n            </Button>\n          ) : (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"h-8 text-xs font-normal\"\n              onClick={startCheck}\n            >\n              {hasRun ? (\n                <RefreshCcw className=\"mr-1.5 h-3.5 w-3.5\" />\n              ) : (\n                <Play className=\"mr-1.5 h-3.5 w-3.5\" />\n              )}\n              {hasRun ? 'Re-check' : 'Start Check'}\n            </Button>\n          )}\n        </div>\n      </div>\n\n      {!hasRun ? (\n        <div className=\"rounded-lg border border-border/60 bg-muted/20 p-6 text-center\">\n          <div className=\"text-sm text-muted-foreground\">\n            Click \"Start Check\" to validate the meta data for table{' '}\n            <span className=\"font-medium text-foreground\">{tableName}</span>.\n          </div>\n          <div className=\"mt-2 text-xs text-muted-foreground\">\n            This will check field configurations including link references, lookup dependencies, and\n            more.\n          </div>\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {/* Show success message if no issues */}\n          {!isRunning && issues.length === 0 && hasRun && (\n            <div className=\"rounded-lg border border-green-500/40 bg-green-500/5 p-6 text-center\">\n              <CheckCircle2 className=\"h-8 w-8 text-green-500 mx-auto mb-2\" />\n              <div className=\"text-sm font-medium text-green-600 dark:text-green-400\">\n                All meta data is valid\n              </div>\n              <div className=\"mt-1 text-xs text-muted-foreground\">\n                No issues found in field configurations.\n              </div>\n            </div>\n          )}\n\n          {/* Group issues by field */}\n          {Object.entries(groupedIssues).map(([fieldId, fieldIssues]) => {\n            const isSystemField = fieldId === 'system';\n            const fieldMeta = getFieldMeta(fieldId);\n            const fieldName =\n              fieldMeta?.name || fieldIssues[0]?.fieldName || (isSystemField ? 'System' : fieldId);\n            const fieldType = fieldMeta?.type || fieldIssues[0]?.fieldType;\n            const fieldRelationship = fieldMeta?.relationship;\n            const FieldIcon = fieldType ? getFieldTypeIcon(fieldType) : null;\n            const hasError = fieldIssues.some((i) => i.severity === 'error');\n            const hasWarning = fieldIssues.some((i) => i.severity === 'warning');\n\n            return (\n              <div\n                key={fieldId}\n                className={cn(\n                  'rounded-lg border p-3 space-y-2',\n                  hasError\n                    ? 'border-destructive/40 bg-destructive/5'\n                    : hasWarning\n                      ? 'border-yellow-500/40 bg-yellow-500/5'\n                      : 'border-blue-500/40 bg-blue-500/5'\n                )}\n              >\n                <div className=\"flex items-center gap-2 text-sm font-medium\">\n                  {hasError ? (\n                    <XCircle className=\"h-4 w-4 text-destructive\" />\n                  ) : hasWarning ? (\n                    <AlertTriangle className=\"h-4 w-4 text-yellow-500\" />\n                  ) : (\n                    <CheckCircle2 className=\"h-4 w-4 text-blue-500\" />\n                  )}\n                  {FieldIcon ? <FieldIcon className=\"h-4 w-4 text-muted-foreground\" /> : null}\n                  {fieldType === 'link' && fieldRelationship && fieldId !== 'system' ? (\n                    <LinkFieldLabel\n                      name={fieldName}\n                      fieldId={fieldId}\n                      relationship={fieldRelationship}\n                      isOneWay={fieldMeta?.isOneWay ?? false}\n                    />\n                  ) : (\n                    <span>{fieldName}</span>\n                  )}\n                  {fieldType ? (\n                    <Badge\n                      variant=\"outline\"\n                      className=\"h-5 px-1.5 text-[10px] font-normal uppercase\"\n                    >\n                      {fieldType}\n                    </Badge>\n                  ) : null}\n                  <span className=\"text-xs text-muted-foreground font-mono\">\n                    {fieldId !== 'system' && fieldId ? `(${fieldId})` : ''}\n                  </span>\n                </div>\n\n                <div className=\"space-y-1.5 pl-6\">\n                  {fieldIssues.map((issue, index) => (\n                    <IssueResultItem key={`${fieldId}-${index}`} issue={issue} />\n                  ))}\n                </div>\n              </div>\n            );\n          })}\n\n          {isRunning && issues.length === 0 && (\n            <div className=\"flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              Checking meta data...\n            </div>\n          )}\n        </div>\n      )}\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/PlaygroundRecordRoute.tsx",
    "content": "import { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useNavigate } from '@tanstack/react-router';\nimport { mapTableDtoToDomain, type ITableRecordDto } from '@teable/v2-contract-http';\nimport type {\n  Field,\n  ITableRecordRealtimeDTO,\n  LinkField,\n  Table as TableAggregate,\n} from '@teable/v2-core';\n\nimport {\n  formatRecordValue,\n  stringifyRecordValue,\n} from '@/components/playground/recordValueVisitor';\nimport { FieldLabel } from '@/components/playground/LinkFieldLabel';\nimport { ArrowLeft, Pencil, TriangleAlert, Radio, Trash2 } from 'lucide-react';\nimport { useEffect, useMemo, useState, type ReactNode } from 'react';\nimport { toast } from 'sonner';\n\nimport { RecordDeleteDialog } from '@/components/playground/RecordDeleteDialog';\nimport { RecordUpdateDialog } from '@/components/playground/RecordUpdateDialog';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport {\n  Table as UITable,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport { useBroadcastChannelDoc } from '@/lib/broadcastChannel';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport { usePlaygroundEnvironment } from '@/lib/playground/environment';\nimport { useShareDbDoc, type ShareDbDocStatus } from '@/lib/shareDb';\n\nconst getErrorMessage = (error: unknown, fallback: string): string => {\n  if (error instanceof Error) return error.message;\n  if (typeof error === 'string') return error;\n  return fallback;\n};\n\nconst isEmptyRecordValue = (value: unknown): boolean =>\n  value === undefined ||\n  value === null ||\n  value === '' ||\n  (Array.isArray(value) && value.length === 0);\n\ntype LinkValueItem = {\n  id: string;\n  label: string;\n};\n\nconst resolveLinkLabel = (value: unknown): string | null => {\n  if (value === undefined || value === null) return null;\n  if (typeof value === 'string') return value;\n  if (typeof value === 'object') {\n    const candidate = value as { title?: unknown; name?: unknown; id?: unknown };\n    if (typeof candidate.title === 'string') return candidate.title;\n    if (typeof candidate.name === 'string') return candidate.name;\n    if (typeof candidate.id === 'string') return candidate.id;\n  }\n  return null;\n};\n\nconst resolveLinkId = (value: unknown): string | null => {\n  if (typeof value === 'string') return value;\n  if (typeof value === 'object' && value !== null) {\n    const candidate = value as { id?: unknown };\n    if (typeof candidate.id === 'string') return candidate.id;\n  }\n  return null;\n};\n\nconst extractLinkValues = (value: unknown): LinkValueItem[] => {\n  if (isEmptyRecordValue(value)) return [];\n  const values = Array.isArray(value) ? value : [value];\n  return values\n    .map((entry) => {\n      const id = resolveLinkId(entry);\n      if (!id) return null;\n      const label = resolveLinkLabel(entry) ?? id;\n      return { id, label };\n    })\n    .filter((entry): entry is LinkValueItem => Boolean(entry));\n};\n\ntype PlaygroundRecordRouteProps = {\n  baseId: string;\n  tableId: string;\n  recordId: string;\n};\n\nexport function PlaygroundRecordRoute({ baseId, tableId, recordId }: PlaygroundRecordRouteProps) {\n  const env = usePlaygroundEnvironment();\n  const navigate = useNavigate();\n  const orpcClient = useOrpcClient();\n  const orpc = createTanstackQueryUtils(orpcClient);\n  const queryClient = useQueryClient();\n\n  const tableQuery = useQuery(\n    orpc.tables.getById.queryOptions({\n      input: { baseId, tableId },\n      placeholderData: keepPreviousData,\n      select: (response) => response.data.table,\n    })\n  );\n\n  const recordQuery = useQuery(\n    orpc.tables.getRecord.queryOptions({\n      input: { tableId, recordId },\n      enabled: Boolean(recordId),\n      placeholderData: keepPreviousData,\n      select: (response) => response.data.record,\n    })\n  );\n\n  // Realtime subscription for single record\n  const isSandbox = env.kind === 'sandbox';\n  const realtimeRecordCollection = useMemo(() => `rec_${tableId}`, [tableId]);\n\n  const shareDbRecord = useShareDbDoc<ITableRecordRealtimeDTO>({\n    collection: realtimeRecordCollection,\n    docId: recordId,\n    enabled: !isSandbox && !!tableId && !!recordId,\n  });\n\n  const broadcastRecord = useBroadcastChannelDoc<ITableRecordRealtimeDTO>({\n    collection: realtimeRecordCollection,\n    docId: recordId,\n    enabled: isSandbox && !!tableId && !!recordId,\n  });\n\n  const realtimeRecord = isSandbox ? broadcastRecord : shareDbRecord;\n\n  // Sync realtime data to TanStack Query cache\n  useEffect(() => {\n    if (!realtimeRecord.data) return;\n\n    const queryKey = orpc.tables.getRecord.queryOptions({\n      input: { tableId, recordId },\n    }).queryKey;\n\n    type RecordQueryData = { ok: true; data: { record: ITableRecordDto } };\n\n    queryClient.setQueryData<RecordQueryData | undefined>(queryKey, (oldData) => {\n      if (!oldData?.data?.record) return oldData;\n\n      // Merge realtime fields into cached record\n      return {\n        ...oldData,\n        data: {\n          ...oldData.data,\n          record: {\n            ...oldData.data.record,\n            fields: {\n              ...oldData.data.record.fields,\n              ...realtimeRecord.data!.fields,\n            },\n          },\n        },\n      };\n    });\n  }, [realtimeRecord.data, queryClient, orpc, tableId, recordId]);\n\n  const tableResult = useMemo(\n    () => (tableQuery.data ? mapTableDtoToDomain(tableQuery.data) : null),\n    [tableQuery.data]\n  );\n  const table = tableResult?.isOk() ? tableResult.value : null;\n  const mappingError = tableResult?.isErr() ? tableResult.error.message : null;\n  const record = recordQuery.data ?? null;\n  const [isUpdateOpen, setIsUpdateOpen] = useState(false);\n  const [isDeleteOpen, setIsDeleteOpen] = useState(false);\n\n  const errorMessage = (() => {\n    if (mappingError) return mappingError;\n    if (tableQuery.error) return getErrorMessage(tableQuery.error, 'Failed to load table');\n    if (recordQuery.error) return getErrorMessage(recordQuery.error, 'Failed to load record');\n    return null;\n  })();\n\n  const isLoading = tableQuery.isLoading || recordQuery.isLoading;\n\n  const sortedFields = useMemo(() => {\n    if (!table) return [] as Field[];\n    const primaryFieldId = table.primaryFieldId().toString();\n    return [...table.getFields()].sort((a, b) => {\n      const aIsPrimary = a.id().toString() === primaryFieldId;\n      const bIsPrimary = b.id().toString() === primaryFieldId;\n      if (aIsPrimary) return -1;\n      if (bIsPrimary) return 1;\n      return 0;\n    });\n  }, [table]);\n\n  const recordLabel = useMemo(() => {\n    if (!table || !record) return recordId;\n    const primaryFieldId = table.primaryFieldId().toString();\n    const label = stringifyRecordValue(record.fields[primaryFieldId]);\n    return label.trim() || record.id;\n  }, [record, recordId, table]);\n\n  const deleteRecordsMutation = useMutation(\n    orpc.tables.deleteRecords.mutationOptions({\n      onSuccess: (response) => {\n        const deletedCount = response.data.deletedRecordIds.length;\n        toast.success(`Deleted ${deletedCount} record${deletedCount === 1 ? '' : 's'}`);\n        queryClient.removeQueries({\n          queryKey: orpc.tables.getRecord.queryKey({\n            input: { tableId, recordId },\n          }),\n        });\n        void navigate({\n          to: env.routes.table,\n          params: { baseId, tableId },\n          search: (prev) => prev,\n        });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to delete record'));\n      },\n    })\n  );\n\n  const handleDeleteConfirm = () => {\n    if (!record) return;\n    deleteRecordsMutation.reset();\n    deleteRecordsMutation.mutate({ tableId, recordIds: [recordId] });\n    setIsDeleteOpen(false);\n  };\n\n  const handleBack = () => {\n    void navigate({\n      to: env.routes.table,\n      params: { baseId, tableId },\n      search: (prev) => prev,\n    });\n  };\n\n  const resolveRecordHref = (targetBaseId: string, targetTableId: string, linkedRecordId: string) =>\n    env.routes.record\n      .replace('$baseId', targetBaseId)\n      .replace('$tableId', targetTableId)\n      .replace('$recordId', linkedRecordId);\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <header className=\"flex items-center justify-between gap-4 border-b border-border/60 bg-background/80 px-5 py-4\">\n        <div className=\"flex items-center gap-3\">\n          <Button variant=\"ghost\" size=\"icon-sm\" onClick={handleBack}>\n            <ArrowLeft className=\"h-4 w-4\" />\n          </Button>\n          <div className=\"space-y-1\">\n            <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n              <span>Record detail</span>\n              <RealtimeStatusBadge status={realtimeRecord.status} />\n            </div>\n            <div className=\"text-base font-semibold\">{recordId}</div>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {table && record ? (\n            <Button variant=\"outline\" size=\"sm\" onClick={() => setIsUpdateOpen(true)}>\n              <Pencil className=\"mr-2 h-3.5 w-3.5\" />\n              Update record\n            </Button>\n          ) : null}\n          {table && record ? (\n            <Button\n              variant=\"destructive\"\n              size=\"sm\"\n              onClick={() => setIsDeleteOpen(true)}\n              disabled={deleteRecordsMutation.isPending}\n            >\n              <Trash2 className=\"mr-2 h-3.5 w-3.5\" />\n              {deleteRecordsMutation.isPending ? 'Deleting...' : 'Delete'}\n            </Button>\n          ) : null}\n          <Button variant=\"outline\" size=\"sm\" onClick={handleBack}>\n            Back to table\n          </Button>\n        </div>\n      </header>\n      <ScrollArea className=\"flex-1\">\n        <div className=\"px-6 py-6\">\n          {errorMessage ? (\n            <Card className=\"border-destructive/40 bg-destructive/10\">\n              <CardHeader className=\"flex flex-row items-center gap-3\">\n                <TriangleAlert className=\"h-4 w-4 text-destructive\" />\n                <CardTitle className=\"text-base text-destructive\">{errorMessage}</CardTitle>\n              </CardHeader>\n            </Card>\n          ) : isLoading ? (\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"text-base\">Loading record...</CardTitle>\n              </CardHeader>\n              <CardContent className=\"text-sm text-muted-foreground\">\n                Fetching the latest data for this record.\n              </CardContent>\n            </Card>\n          ) : !table || !record ? (\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"text-base\">Record not found</CardTitle>\n              </CardHeader>\n              <CardContent className=\"text-sm text-muted-foreground\">\n                We couldn&apos;t locate this record in the selected table.\n              </CardContent>\n            </Card>\n          ) : (\n            <>\n              <RecordDetailCard\n                table={table}\n                record={record}\n                fields={sortedFields}\n                baseId={baseId}\n                resolveRecordHref={resolveRecordHref}\n              />\n              <RealtimeRecordCard\n                realtimeRecord={realtimeRecord.data}\n                status={realtimeRecord.status}\n                error={realtimeRecord.error}\n              />\n            </>\n          )}\n          {table && record ? (\n            <RecordUpdateDialog\n              table={table}\n              record={record}\n              baseId={baseId}\n              open={isUpdateOpen}\n              onOpenChange={setIsUpdateOpen}\n              onSuccess={() => void recordQuery.refetch()}\n            />\n          ) : null}\n          <RecordDeleteDialog\n            open={isDeleteOpen}\n            onOpenChange={setIsDeleteOpen}\n            tableId={tableId}\n            recordIds={[recordId]}\n            recordLabel={recordLabel}\n            isDeleting={deleteRecordsMutation.isPending}\n            onConfirm={handleDeleteConfirm}\n          />\n        </div>\n      </ScrollArea>\n    </div>\n  );\n}\n\ntype RecordDetailCardProps = {\n  table: TableAggregate;\n  record: ITableRecordDto;\n  fields: Field[];\n  baseId: string;\n  resolveRecordHref: (targetBaseId: string, targetTableId: string, recordId: string) => string;\n};\n\nfunction RecordDetailCard({\n  table,\n  record,\n  fields,\n  baseId,\n  resolveRecordHref,\n}: RecordDetailCardProps) {\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"text-base\">{table.name().toString()}</CardTitle>\n      </CardHeader>\n      <CardContent className=\"overflow-auto\">\n        <UITable>\n          <TableHeader>\n            <TableRow>\n              <TableHead className=\"w-64\">Field</TableHead>\n              <TableHead>Value</TableHead>\n              <TableHead className=\"w-32\">Type</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {fields.map((field) => {\n              const fieldId = field.id().toString();\n              const value = record.fields[fieldId];\n              const fieldType = field.type().toString();\n\n              let valueNode: ReactNode = null;\n\n              if (fieldType === 'link') {\n                const linkItems = extractLinkValues(value);\n                const linkField = field as LinkField;\n                const targetBaseId = linkField.baseId()?.toString() ?? baseId;\n                const targetTableId = linkField.foreignTableId().toString();\n\n                valueNode = linkItems.length ? (\n                  <div className=\"flex flex-wrap gap-2\">\n                    {linkItems.map((item) => (\n                      <a\n                        key={item.id}\n                        className=\"max-w-[240px] truncate text-sm text-primary underline underline-offset-2 hover:text-primary/80\"\n                        href={resolveRecordHref(targetBaseId, targetTableId, item.id)}\n                        target=\"_blank\"\n                        rel=\"noreferrer\"\n                        title={item.label}\n                      >\n                        {item.label}\n                      </a>\n                    ))}\n                  </div>\n                ) : (\n                  <span className=\"text-xs text-muted-foreground\">-</span>\n                );\n              } else {\n                const formattedValue = formatRecordValue(field, value);\n                valueNode = formattedValue.node;\n              }\n\n              return (\n                <TableRow key={fieldId}>\n                  <TableCell className=\"font-medium\">\n                    <FieldLabel field={field} className=\"min-w-0\" />\n                  </TableCell>\n                  <TableCell>\n                    <div className=\"text-sm text-foreground\">{valueNode}</div>\n                  </TableCell>\n                  <TableCell className=\"text-sm text-muted-foreground\">\n                    {field.type().toString()}\n                  </TableCell>\n                </TableRow>\n              );\n            })}\n          </TableBody>\n        </UITable>\n      </CardContent>\n    </Card>\n  );\n}\n\ntype RealtimeStatusBadgeProps = {\n  status: ShareDbDocStatus;\n};\n\nfunction RealtimeStatusBadge({ status }: RealtimeStatusBadgeProps) {\n  const statusLabel =\n    status === 'ready'\n      ? 'Live'\n      : status === 'connecting'\n        ? 'Connecting'\n        : status === 'error'\n          ? 'Error'\n          : 'Idle';\n  const variant = status === 'ready' ? 'secondary' : status === 'error' ? 'destructive' : 'outline';\n\n  return (\n    <Badge\n      variant={variant}\n      className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider gap-1\"\n    >\n      {status === 'ready' ? <Radio className=\"h-2.5 w-2.5 animate-pulse\" /> : null}\n      {statusLabel}\n    </Badge>\n  );\n}\n\ntype RealtimeRecordCardProps = {\n  realtimeRecord: ITableRecordRealtimeDTO | null;\n  status: ShareDbDocStatus;\n  error: string | null;\n};\n\nfunction RealtimeRecordCard({ realtimeRecord, status, error }: RealtimeRecordCardProps) {\n  console.log('[RealtimeRecordCard] render', { realtimeRecord, status, error });\n  return (\n    <Card className=\"mt-6\">\n      <CardHeader>\n        <div className=\"flex items-center gap-2\">\n          <CardTitle className=\"text-base\">Realtime Snapshot</CardTitle>\n          <RealtimeStatusBadge status={status} />\n        </div>\n      </CardHeader>\n      <CardContent>\n        {error ? (\n          <div className=\"text-sm text-destructive\">Realtime error: {error}</div>\n        ) : !realtimeRecord ? (\n          <div className=\"text-sm text-muted-foreground\">\n            {status === 'connecting' ? 'Connecting to ShareDB...' : 'Waiting for realtime data.'}\n          </div>\n        ) : (\n          <div className=\"space-y-3\">\n            <div className=\"flex items-center justify-between text-sm\">\n              <span className=\"text-muted-foreground\">Record ID</span>\n              <code className=\"font-mono text-xs\">{realtimeRecord.id}</code>\n            </div>\n            <div className=\"flex items-center justify-between text-sm\">\n              <span className=\"text-muted-foreground\">Table ID</span>\n              <code className=\"font-mono text-xs\">{realtimeRecord.tableId}</code>\n            </div>\n            <div className=\"border-t pt-3\">\n              <div className=\"text-sm font-medium mb-2\">\n                Fields ({Object.keys(realtimeRecord.fields).length})\n              </div>\n              <div className=\"space-y-2 text-sm\">\n                {Object.entries(realtimeRecord.fields).map(([fieldId, value]) => (\n                  <div key={fieldId} className=\"flex items-start justify-between gap-4\">\n                    <code className=\"font-mono text-xs text-muted-foreground shrink-0\">\n                      {fieldId}\n                    </code>\n                    <div className=\"text-right break-all\">\n                      {value === null || value === undefined ? (\n                        <span className=\"text-muted-foreground\">-</span>\n                      ) : typeof value === 'object' ? (\n                        <code className=\"font-mono text-xs\">{JSON.stringify(value)}</code>\n                      ) : (\n                        <span>{String(value)}</span>\n                      )}\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/PlaygroundShell.tsx",
    "content": "import type { IBaseDto, ITableDto } from '@teable/v2-contract-http';\nimport { Link, useNavigate } from '@tanstack/react-router';\nimport {\n  ArrowRight,\n  Check,\n  ChevronDown,\n  ChevronsUpDown,\n  Cog,\n  Copy,\n  Database,\n  FlaskConical,\n  GalleryVerticalEnd,\n  Globe,\n  Pin,\n  Plus,\n  Search,\n  Table as TableIcon,\n  Trash2,\n  TriangleAlert,\n} from 'lucide-react';\nimport { useCallback, useEffect, useState, useRef, type FormEvent, type ReactNode } from 'react';\nimport { useCopyToClipboard, useLocalStorage } from 'usehooks-ts';\nimport { toast } from 'sonner';\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  useSidebar,\n} from '@/components/ui/sidebar';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Switch } from '@/components/ui/switch';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport {\n  usePlaygroundEnvironment,\n  resolvePlaygroundEnvironment,\n} from '@/lib/playground/environment';\nimport {\n  PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n  PLAYGROUND_DB_URL_STORAGE_KEY,\n  createPlaygroundDbConnectionId,\n  findPlaygroundDbConnectionByUrl,\n  formatPlaygroundDbUrlLabel,\n  isValidPlaygroundDbUrl,\n  maskPlaygroundDbUrl,\n  normalizePlaygroundDbUrl,\n  resolvePlaygroundDbUrl,\n  resolvePlaygroundDbStorageKey,\n  sortPlaygroundDbConnections,\n  type PlaygroundDbConnection,\n} from '@/lib/playground/databaseUrl';\nimport { cn } from '@/lib/utils';\n\ntype PlaygroundShellProps = {\n  baseId: string;\n  bases: ReadonlyArray<IBaseDto>;\n  isLoadingBases: boolean;\n  onCreateBase: (name: string) => void;\n  isCreatingBase: boolean;\n  activeTableId: string | null;\n  tables: ReadonlyArray<ITableDto>;\n  isInitialLoading: boolean;\n  errorMessage: string | null;\n  searchValue: string;\n  onSearchChange: (value: string) => void;\n  onDeleteTable: (table: ITableDto) => void;\n  isDeletingTable: boolean;\n  children: ReactNode;\n};\n\ntype DbConnectionDraft = {\n  id: string | null;\n  name: string;\n  description: string;\n  url: string;\n  pinned: boolean;\n};\n\ntype NavigationTarget =\n  | { to: string; params: { baseId: string } }\n  | { to: string; params: { baseId: string; tableId: string } }\n  | { to: string; params?: undefined };\n\nexport function PlaygroundShell({\n  baseId,\n  bases,\n  isLoadingBases,\n  onCreateBase,\n  isCreatingBase,\n  activeTableId,\n  tables,\n  isInitialLoading,\n  errorMessage,\n  searchValue,\n  onSearchChange,\n  onDeleteTable,\n  isDeletingTable,\n  children,\n}: PlaygroundShellProps) {\n  const env = usePlaygroundEnvironment();\n  const isSandbox = env.kind === 'sandbox';\n\n  return (\n    <div\n      className={cn(\n        'relative min-h-svh bg-background',\n        isSandbox &&\n          'rounded-2xl ring-2 ring-emerald-400/70 ring-offset-4 ring-offset-emerald-50/50'\n      )}\n    >\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.25]\" />\n      <div className=\"pointer-events-none absolute inset-x-0 -top-24 h-56 bg-gradient-radial opacity-80\" />\n      {isSandbox ? (\n        <div className=\"pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2\">\n          <div className=\"rounded-b-xl border border-t-0 border-emerald-500/50 bg-gradient-to-r from-emerald-600 via-emerald-500 to-emerald-600 px-5 py-1.5 text-[10px] font-bold tracking-[0.3em] text-white shadow-lg shadow-emerald-500/20\">\n            SANDBOX\n          </div>\n        </div>\n      ) : null}\n      <div\n        className={cn(\n          'relative z-10 min-h-svh bg-background/70 backdrop-blur-sm',\n          isSandbox && 'rounded-2xl overflow-hidden'\n        )}\n      >\n        <SidebarProvider>\n          <PlaygroundSidebar\n            baseId={baseId}\n            bases={bases}\n            isLoadingBases={isLoadingBases}\n            onCreateBase={onCreateBase}\n            isCreatingBase={isCreatingBase}\n            activeTableId={activeTableId}\n            tables={tables}\n            isInitialLoading={isInitialLoading}\n            errorMessage={errorMessage}\n            searchValue={searchValue}\n            onSearchChange={onSearchChange}\n            onDeleteTable={onDeleteTable}\n            isDeletingTable={isDeletingTable}\n          />\n          <SidebarInset className=\"h-svh overflow-hidden bg-background/80 backdrop-blur-sm\">\n            {children}\n          </SidebarInset>\n        </SidebarProvider>\n      </div>\n    </div>\n  );\n}\n\ntype PlaygroundSidebarProps = {\n  baseId: string;\n  bases: ReadonlyArray<IBaseDto>;\n  isLoadingBases: boolean;\n  onCreateBase: (name: string) => void;\n  isCreatingBase: boolean;\n  activeTableId: string | null;\n  tables: ReadonlyArray<ITableDto>;\n  isInitialLoading: boolean;\n  errorMessage: string | null;\n  searchValue: string;\n  onSearchChange: (value: string) => void;\n  onDeleteTable: (table: ITableDto) => void;\n  isDeletingTable: boolean;\n};\n\nfunction PlaygroundSidebar({\n  baseId,\n  bases,\n  isLoadingBases,\n  onCreateBase,\n  isCreatingBase,\n  activeTableId,\n  tables,\n  isInitialLoading,\n  errorMessage,\n  searchValue,\n  onSearchChange,\n  onDeleteTable,\n  isDeletingTable,\n}: PlaygroundSidebarProps) {\n  const navigate = useNavigate();\n  const env = usePlaygroundEnvironment();\n  const isSandbox = env.kind === 'sandbox';\n  const sandboxEnv = resolvePlaygroundEnvironment('/sandbox');\n  const remoteEnv = resolvePlaygroundEnvironment('/');\n  const activeEnv = isSandbox ? sandboxEnv : remoteEnv;\n  const { isMobile, state } = useSidebar();\n  const [nextBaseId, setNextBaseId] = useState(baseId);\n  const [baseDropdownOpen, setBaseDropdownOpen] = useState(false);\n  const [newBaseName, setNewBaseName] = useState('');\n  const [deleteTarget, setDeleteTarget] = useState<ITableDto | null>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const [dbManagerOpen, setDbManagerOpen] = useState(false);\n  const [connectionDraft, setConnectionDraft] = useState<DbConnectionDraft>({\n    id: null,\n    name: '',\n    description: '',\n    url: '',\n    pinned: false,\n  });\n  const [connectionError, setConnectionError] = useState<string | null>(null);\n  const [connectionTestStatus, setConnectionTestStatus] = useState<\n    'idle' | 'loading' | 'success' | 'error'\n  >('idle');\n  const [connectionTestMessage, setConnectionTestMessage] = useState<string | null>(null);\n  const [dbUrl, setDbUrl, removeDbUrl] = useLocalStorage<string | null>(\n    PLAYGROUND_DB_URL_STORAGE_KEY,\n    null,\n    { initializeWithValue: false }\n  );\n  const [dbConnections, setDbConnections] = useLocalStorage<PlaygroundDbConnection[]>(\n    PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n    [],\n    { initializeWithValue: false }\n  );\n  const envDbUrl = resolvePlaygroundDbUrl(null);\n  const activeDbUrl = resolvePlaygroundDbUrl(dbUrl);\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  const resetConnectionDraft = useCallback((connection?: PlaygroundDbConnection) => {\n    if (connection) {\n      setConnectionDraft({\n        id: connection.id,\n        name: connection.name,\n        description: connection.description ?? '',\n        url: connection.url,\n        pinned: Boolean(connection.pinned),\n      });\n    } else {\n      setConnectionDraft({\n        id: null,\n        name: '',\n        description: '',\n        url: '',\n        pinned: false,\n      });\n    }\n    setConnectionError(null);\n    setConnectionTestStatus('idle');\n    setConnectionTestMessage(null);\n  }, []);\n\n  const updateConnectionDraft = (updates: Partial<DbConnectionDraft>) => {\n    setConnectionDraft((prev) => ({ ...prev, ...updates }));\n    setConnectionError(null);\n    setConnectionTestStatus('idle');\n    setConnectionTestMessage(null);\n  };\n\n  useEffect(() => {\n    setNextBaseId(baseId);\n  }, [baseId]);\n\n  useEffect(() => {\n    if (activeTableId && menuRef.current) {\n      const activeElement = menuRef.current.querySelector('[data-active=\"true\"]');\n      activeElement?.scrollIntoView({\n        behavior: 'smooth',\n        block: 'nearest',\n      });\n    }\n  }, [activeTableId]);\n\n  useEffect(() => {\n    if (!dbManagerOpen) return;\n    resetConnectionDraft();\n  }, [dbManagerOpen, resetConnectionDraft]);\n\n  const trimmedBaseId = nextBaseId.trim();\n  const canSwitchBase = trimmedBaseId.length > 0 && trimmedBaseId !== baseId;\n  const tableSkeletonKeys = ['table-skeleton-0', 'table-skeleton-1', 'table-skeleton-2'];\n\n  const handleBaseSubmit = (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (!canSwitchBase) return;\n    void navigate({\n      to: env.routes.base,\n      params: { baseId: trimmedBaseId },\n      search: {},\n    });\n  };\n\n  const handleDeleteConfirm = () => {\n    if (!deleteTarget) return;\n    onDeleteTable(deleteTarget);\n    setDeleteTarget(null);\n  };\n\n  const reloadPlayground = () => {\n    if (typeof window !== 'undefined') {\n      window.location.reload();\n    }\n  };\n\n  const handleConnectionSave = (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    const trimmedName = connectionDraft.name.trim();\n    const trimmedUrl = normalizePlaygroundDbUrl(connectionDraft.url);\n    const trimmedDescription = connectionDraft.description.trim();\n\n    if (!trimmedName) {\n      setConnectionError('Connection name is required.');\n      return;\n    }\n    if (!trimmedUrl) {\n      setConnectionError('Enter a database URL first.');\n      return;\n    }\n    if (!isValidPlaygroundDbUrl(trimmedUrl)) {\n      setConnectionError('Use a postgres:// or postgresql:// URL.');\n      return;\n    }\n\n    const now = Date.now();\n    const existing = connectionDraft.id\n      ? dbConnections.find((item) => item.id === connectionDraft.id)\n      : undefined;\n    const nextConnection: PlaygroundDbConnection = {\n      id: connectionDraft.id ?? createPlaygroundDbConnectionId(),\n      name: trimmedName,\n      description: trimmedDescription ? trimmedDescription : undefined,\n      url: trimmedUrl,\n      pinned: connectionDraft.pinned,\n      createdAt: existing?.createdAt ?? now,\n      lastUsedAt: existing?.lastUsedAt,\n    };\n\n    const nextConnections = connectionDraft.id\n      ? dbConnections.map((item) => (item.id === connectionDraft.id ? nextConnection : item))\n      : [...dbConnections, nextConnection];\n\n    setDbConnections(nextConnections);\n\n    if (\n      existing &&\n      dbUrl &&\n      normalizePlaygroundDbUrl(existing.url) === normalizePlaygroundDbUrl(dbUrl)\n    ) {\n      if (normalizePlaygroundDbUrl(existing.url) !== normalizePlaygroundDbUrl(nextConnection.url)) {\n        setDbUrl(nextConnection.url);\n        setDbManagerOpen(false);\n        reloadPlayground();\n        return;\n      }\n    }\n\n    resetConnectionDraft();\n  };\n\n  const handleConnectionTest = async () => {\n    const trimmedUrl = normalizePlaygroundDbUrl(connectionDraft.url);\n    if (!trimmedUrl) {\n      setConnectionTestStatus('error');\n      setConnectionTestMessage('Enter a database URL first.');\n      return;\n    }\n    if (!isValidPlaygroundDbUrl(trimmedUrl)) {\n      setConnectionTestStatus('error');\n      setConnectionTestMessage('Use a postgres:// or postgresql:// URL.');\n      return;\n    }\n    setConnectionTestStatus('loading');\n    setConnectionTestMessage('Testing connection...');\n    try {\n      const response = await fetch('/api/db/check', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({ connectionString: trimmedUrl }),\n      });\n      const payload = (await response.json().catch(() => null)) as {\n        ok?: boolean;\n        error?: string;\n      } | null;\n      if (!response.ok || payload?.ok === false) {\n        const message = payload?.error ?? 'Connection failed.';\n        setConnectionTestStatus('error');\n        setConnectionTestMessage(message);\n        return;\n      }\n      setConnectionTestStatus('success');\n      setConnectionTestMessage('Connection OK.');\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Connection failed.';\n      setConnectionTestStatus('error');\n      setConnectionTestMessage(message);\n    }\n  };\n\n  const handleConnectionCopy = useCallback(\n    async (connection: PlaygroundDbConnection) => {\n      const didCopy = await copyToClipboard(connection.url);\n      if (didCopy) {\n        toast.success('Database URL copied to clipboard');\n      } else {\n        toast.error('Failed to copy database URL');\n      }\n    },\n    [copyToClipboard]\n  );\n\n  const handleConnectionEdit = (connection: PlaygroundDbConnection) => {\n    setDbManagerOpen(true);\n    resetConnectionDraft(connection);\n  };\n\n  const handleConnectionDelete = (connection: PlaygroundDbConnection) => {\n    setDbConnections(dbConnections.filter((item) => item.id !== connection.id));\n    if (dbUrl && normalizePlaygroundDbUrl(connection.url) === normalizePlaygroundDbUrl(dbUrl)) {\n      void handleUseDefault();\n    }\n  };\n\n  const handleConnectionSwitch = async (connection: PlaygroundDbConnection) => {\n    const now = Date.now();\n    setDbConnections(\n      dbConnections.map((item) => (item.id === connection.id ? { ...item, lastUsedAt: now } : item))\n    );\n    setDbUrl(connection.url);\n    setDbManagerOpen(false);\n    const next = resolveTargetPath(activeEnv, {\n      connectionId: connection.id,\n      dbUrl: connection.url,\n    });\n    await navigateToTarget(next);\n    reloadPlayground();\n  };\n\n  const handleUseDefault = async () => {\n    removeDbUrl();\n    setDbManagerOpen(false);\n    const next = resolveTargetPath(activeEnv, { connectionId: null, dbUrl: null });\n    await navigateToTarget(next);\n    reloadPlayground();\n  };\n\n  const sortedConnections = sortPlaygroundDbConnections(dbConnections);\n  const activeConnection = findPlaygroundDbConnectionByUrl(dbConnections, activeDbUrl);\n  const dbLabel = dbUrl\n    ? activeConnection?.name ?? formatPlaygroundDbUrlLabel(dbUrl)\n    : envDbUrl\n      ? `Default (.env) - ${activeConnection?.name ?? formatPlaygroundDbUrlLabel(envDbUrl)}`\n      : 'Default (.env)';\n\n  const readStoredValue = (key: string): string | null => {\n    if (typeof window === 'undefined') return null;\n    const raw = window.localStorage.getItem(key);\n    if (!raw) return null;\n    try {\n      const parsed = JSON.parse(raw);\n      if (typeof parsed === 'string') return parsed.trim() || null;\n      if (parsed === null || parsed === undefined) return null;\n    } catch {\n      return raw.trim() || null;\n    }\n    return null;\n  };\n\n  const resolveStorageKeys = (\n    target: typeof activeEnv,\n    options: { connectionId?: string | null; dbUrl?: string | null }\n  ) => {\n    if (target.kind === 'sandbox') {\n      return target.storageKeys;\n    }\n    return {\n      baseId: resolvePlaygroundDbStorageKey(target.storageKeys.baseId, options),\n      tableId: resolvePlaygroundDbStorageKey(target.storageKeys.tableId, options),\n    };\n  };\n\n  const resolveTargetPath = (\n    target: typeof activeEnv,\n    options: { connectionId?: string | null; dbUrl?: string | null }\n  ): NavigationTarget => {\n    if (typeof window === 'undefined') {\n      return { to: target.routes.base, params: { baseId: target.defaults.baseId } };\n    }\n    const storageKeys = resolveStorageKeys(target, options);\n    const storedBaseId = readStoredValue(storageKeys.baseId);\n    const storedTableId = readStoredValue(storageKeys.tableId);\n    const baseId = storedBaseId || (target.kind === 'sandbox' ? target.defaults.baseId : null);\n    if (!baseId) {\n      return { to: target.routes.index };\n    }\n    if (storedTableId) {\n      return { to: target.routes.table, params: { baseId, tableId: storedTableId } };\n    }\n    return { to: target.routes.base, params: { baseId } };\n  };\n\n  const navigateToTarget = async (target: NavigationTarget) => {\n    if (target.params) {\n      await navigate({ to: target.to, params: target.params, search: {} });\n      return;\n    }\n    await navigate({ to: target.to, search: {} });\n  };\n\n  const handleEnvSwitch = (target: typeof activeEnv) => {\n    const next = resolveTargetPath(target, {\n      connectionId: activeConnection?.id ?? null,\n      dbUrl: activeDbUrl,\n    });\n    void navigateToTarget(next);\n  };\n\n  return (\n    <>\n      <Sidebar\n        collapsible=\"icon\"\n        className=\"border-r border-sidebar-border/70 bg-sidebar/80 backdrop-blur-xl shadow-sm\"\n      >\n        <SidebarHeader className=\"gap-0 border-b border-sidebar-border/70 bg-sidebar/90 backdrop-blur\">\n          <SidebarMenu>\n            <SidebarMenuItem>\n              <SidebarMenuButton size=\"lg\" asChild>\n                <div className=\"flex items-center gap-3\">\n                  <div className=\"flex aspect-square size-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/70 text-primary-foreground shadow-md\">\n                    <GalleryVerticalEnd className=\"size-5\" />\n                  </div>\n                  <div className=\"flex flex-col gap-0.5 leading-none\">\n                    <span className=\"text-sm font-semibold tracking-tight text-foreground\">\n                      Teable v2\n                    </span>\n                    <span className=\"text-[10px] font-medium uppercase tracking-[0.24em] text-muted-foreground\">\n                      Playground\n                    </span>\n                  </div>\n                </div>\n              </SidebarMenuButton>\n            </SidebarMenuItem>\n          </SidebarMenu>\n\n          <SidebarGroup className=\"shrink-0 py-2\">\n            <SidebarGroupLabel className=\"h-6 text-[11px] uppercase tracking-[0.2em] text-muted-foreground\">\n              Base\n            </SidebarGroupLabel>\n            <SidebarGroupContent>\n              <div className=\"px-2 group-data-[collapsible=icon]:hidden space-y-2\">\n                <DropdownMenu open={baseDropdownOpen} onOpenChange={setBaseDropdownOpen}>\n                  <DropdownMenuTrigger asChild>\n                    <Button\n                      variant=\"outline\"\n                      className=\"w-full justify-between h-8 text-xs bg-background/70 border-border/60\"\n                      disabled={isLoadingBases}\n                    >\n                      <span className=\"truncate\">\n                        {bases.find((b) => b.id === baseId)?.name ?? baseId}\n                      </span>\n                      <ChevronsUpDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent className=\"w-[220px]\" align=\"start\">\n                    <DropdownMenuLabel>Switch Base</DropdownMenuLabel>\n                    <DropdownMenuSeparator />\n                    <div className=\"max-h-[200px] overflow-y-auto\">\n                      <DropdownMenuGroup>\n                        {isLoadingBases ? (\n                          <DropdownMenuItem disabled>Loading bases...</DropdownMenuItem>\n                        ) : bases.length ? (\n                          bases.map((base) => (\n                            <DropdownMenuItem\n                              key={base.id}\n                              onSelect={() => {\n                                setBaseDropdownOpen(false);\n                                void navigate({\n                                  to: env.routes.base,\n                                  params: { baseId: base.id },\n                                  search: {},\n                                });\n                              }}\n                            >\n                              <Check\n                                className={cn(\n                                  'mr-2 h-4 w-4 shrink-0',\n                                  baseId === base.id ? 'opacity-100' : 'opacity-0'\n                                )}\n                              />\n                              <span className=\"truncate\">{base.name}</span>\n                            </DropdownMenuItem>\n                          ))\n                        ) : (\n                          <DropdownMenuItem disabled>No bases found</DropdownMenuItem>\n                        )}\n                      </DropdownMenuGroup>\n                    </div>\n                    <DropdownMenuSeparator />\n                    <DropdownMenuGroup>\n                      <div className=\"p-2\">\n                        <form\n                          onSubmit={(e) => {\n                            e.preventDefault();\n                            const name = newBaseName.trim();\n                            if (name) {\n                              onCreateBase(name);\n                              setNewBaseName('');\n                              setBaseDropdownOpen(false);\n                            }\n                          }}\n                          className=\"flex items-center gap-1\"\n                        >\n                          <Input\n                            type=\"text\"\n                            placeholder=\"New base name\"\n                            value={newBaseName}\n                            onChange={(e) => setNewBaseName(e.target.value)}\n                            size=\"sm\"\n                            disabled={isCreatingBase}\n                          />\n                          <Button\n                            type=\"submit\"\n                            size=\"icon\"\n                            variant=\"ghost\"\n                            className=\"h-7 w-7 shrink-0\"\n                            disabled={!newBaseName.trim() || isCreatingBase}\n                          >\n                            <Plus className=\"h-4 w-4\" />\n                          </Button>\n                        </form>\n                      </div>\n                    </DropdownMenuGroup>\n                  </DropdownMenuContent>\n                </DropdownMenu>\n                <form className=\"flex items-center gap-1.5\" onSubmit={handleBaseSubmit}>\n                  <SidebarInput\n                    type=\"text\"\n                    placeholder=\"Base ID\"\n                    value={nextBaseId}\n                    onChange={(event) => setNextBaseId(event.target.value)}\n                    aria-label=\"Base ID\"\n                    spellCheck={false}\n                    className=\"h-8 text-xs bg-background/70 border-border/60 focus:border-primary/40\"\n                  />\n                  <Button\n                    type=\"submit\"\n                    variant=\"outline\"\n                    size=\"icon-sm\"\n                    className=\"h-8 w-8 shrink-0\"\n                    disabled={!canSwitchBase}\n                    aria-label=\"Open base\"\n                  >\n                    <ArrowRight className=\"h-4 w-4\" />\n                  </Button>\n                </form>\n              </div>\n            </SidebarGroupContent>\n          </SidebarGroup>\n\n          <SidebarGroup className=\"shrink-0 pb-4\">\n            <SidebarGroupLabel className=\"h-6 text-[11px] uppercase tracking-[0.2em] text-muted-foreground\">\n              Tables\n            </SidebarGroupLabel>\n            <SidebarGroupContent>\n              <div className=\"px-2 group-data-[collapsible=icon]:hidden\">\n                <div className=\"relative\">\n                  <Search className=\"absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground\" />\n                  <SidebarInput\n                    type=\"search\"\n                    placeholder=\"Search tables...\"\n                    value={searchValue}\n                    onChange={(event) => onSearchChange(event.target.value)}\n                    maxLength={255}\n                    aria-label=\"Search tables\"\n                    className=\"pl-8 bg-background/70 border-border/60 focus:border-primary/40\"\n                  />\n                </div>\n              </div>\n            </SidebarGroupContent>\n          </SidebarGroup>\n          <SidebarSeparator />\n        </SidebarHeader>\n\n        <SidebarContent className=\"overflow-hidden\">\n          <ScrollArea className=\"h-full\" scrollHideDelay={0}>\n            <SidebarGroup className=\"py-2\">\n              <SidebarGroupContent>\n                <div className=\"mt-2\" ref={menuRef}>\n                  {isInitialLoading ? (\n                    <SidebarMenu>\n                      {tableSkeletonKeys.map((key) => (\n                        <SidebarMenuItem key={key}>\n                          <SidebarMenuSkeleton showIcon />\n                        </SidebarMenuItem>\n                      ))}\n                    </SidebarMenu>\n                  ) : errorMessage ? (\n                    <div className=\"mx-2 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-xs text-destructive\">\n                      <TriangleAlert className=\"mt-0.5 h-4 w-4\" />\n                      <span>{errorMessage}</span>\n                    </div>\n                  ) : tables.length ? (\n                    <SidebarMenu className=\"space-y-1\">\n                      {tables.map((table) => {\n                        const isActive = table.id === activeTableId;\n                        return (\n                          <SidebarMenuItem key={table.id}>\n                            <SidebarMenuButton\n                              asChild\n                              isActive={isActive}\n                              size=\"sm\"\n                              className={cn(\n                                'transition-all duration-200',\n                                isActive && 'bg-sidebar-accent/70 border border-sidebar-border/80'\n                              )}\n                            >\n                              <Link\n                                to={env.routes.table}\n                                params={{ baseId, tableId: table.id }}\n                                search={(prev) => ({\n                                  ...prev,\n                                  ...(searchValue ? { q: searchValue } : {}),\n                                })}\n                              >\n                                <TableIcon\n                                  className={cn(\n                                    'h-4 w-4 transition-colors',\n                                    isActive && 'text-primary'\n                                  )}\n                                />\n                                <span className=\"truncate\">{table.name}</span>\n                              </Link>\n                            </SidebarMenuButton>\n                            <SidebarMenuAction\n                              showOnHover\n                              onClick={() => setDeleteTarget(table)}\n                              aria-label={`Delete ${table.name}`}\n                              disabled={isDeletingTable}\n                            >\n                              <Trash2 className=\"h-4 w-4\" />\n                            </SidebarMenuAction>\n                            <SidebarMenuBadge className=\"right-7 text-[10px] font-medium\">\n                              {table.fields.length}\n                            </SidebarMenuBadge>\n                          </SidebarMenuItem>\n                        );\n                      })}\n                    </SidebarMenu>\n                  ) : (\n                    <div className=\"mx-2 rounded-xl border border-dashed border-sidebar-border/70 bg-gradient-to-br from-muted/40 to-muted/10 p-6 text-center\">\n                      <div className=\"mb-2 text-3xl opacity-40\">\n                        <TableIcon className=\"mx-auto h-8 w-8\" />\n                      </div>\n                      <p className=\"text-sm font-medium text-muted-foreground\">No tables found</p>\n                      <p className=\"mt-1 text-xs text-muted-foreground/70\">\n                        Create a table to get started\n                      </p>\n                    </div>\n                  )}\n                </div>\n              </SidebarGroupContent>\n            </SidebarGroup>\n          </ScrollArea>\n        </SidebarContent>\n        <SidebarFooter>\n          <SidebarMenu>\n            <SidebarMenuItem>\n              <SidebarMenuButton asChild size=\"lg\" tooltip=\"Computed Tasks\">\n                <Link to=\"/computed-tasks\">\n                  <div className=\"flex size-9 items-center justify-center rounded-xl border-2 border-orange-400/60 bg-gradient-to-br from-orange-500/20 to-orange-600/10 text-orange-600 shadow-sm\">\n                    <Cog className=\"size-5\" />\n                  </div>\n                  <div className=\"flex flex-1 items-center justify-between gap-3 group-data-[collapsible=icon]:hidden\">\n                    <div className=\"flex flex-col text-left leading-tight\">\n                      <span className=\"text-[11px] font-medium text-muted-foreground\">System</span>\n                      <span className=\"text-sm font-semibold\">Computed Tasks</span>\n                    </div>\n                  </div>\n                </Link>\n              </SidebarMenuButton>\n            </SidebarMenuItem>\n            <SidebarMenuItem>\n              <DropdownMenu>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <DropdownMenuTrigger asChild>\n                      <SidebarMenuButton\n                        size=\"lg\"\n                        className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n                      >\n                        <div\n                          className={cn(\n                            'flex size-9 items-center justify-center rounded-xl border-2 shadow-sm transition-all duration-200',\n                            isSandbox\n                              ? 'border-emerald-400/60 bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 text-emerald-600'\n                              : 'border-sky-400/60 bg-gradient-to-br from-sky-500/20 to-sky-600/10 text-sky-600'\n                          )}\n                        >\n                          {isSandbox ? (\n                            <FlaskConical className=\"size-5\" />\n                          ) : (\n                            <Globe className=\"size-5\" />\n                          )}\n                        </div>\n                        <div className=\"flex flex-1 items-center justify-between gap-3 group-data-[collapsible=icon]:hidden\">\n                          <div className=\"flex flex-col text-left leading-tight\">\n                            <span className=\"text-[11px] font-medium text-muted-foreground\">\n                              Environment\n                            </span>\n                            <span className=\"text-sm font-semibold\">\n                              {isSandbox ? 'Sandbox' : 'Remote'}\n                            </span>\n                          </div>\n                          <ChevronDown className=\"size-4 opacity-60\" />\n                        </div>\n                      </SidebarMenuButton>\n                    </DropdownMenuTrigger>\n                  </TooltipTrigger>\n                  <TooltipContent\n                    side=\"right\"\n                    align=\"center\"\n                    hidden={state !== 'collapsed' || isMobile}\n                  >\n                    Environment\n                  </TooltipContent>\n                </Tooltip>\n                <DropdownMenuContent\n                  className=\"w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg\"\n                  side=\"top\"\n                  align=\"start\"\n                  sideOffset={6}\n                >\n                  <DropdownMenuLabel className=\"text-xs\">Switch environment</DropdownMenuLabel>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuGroup>\n                    <DropdownMenuItem\n                      className=\"gap-2 py-2 text-sm\"\n                      onSelect={() => handleEnvSwitch(remoteEnv)}\n                      disabled={activeEnv.kind === 'remote'}\n                    >\n                      <Globe className=\"mr-2 size-4 text-sky-600\" />\n                      Remote\n                    </DropdownMenuItem>\n                    <DropdownMenuItem\n                      className=\"gap-2 py-2 text-sm\"\n                      onSelect={() => handleEnvSwitch(sandboxEnv)}\n                      disabled={activeEnv.kind === 'sandbox'}\n                    >\n                      <FlaskConical className=\"mr-2 size-4 text-emerald-600\" />\n                      Sandbox\n                    </DropdownMenuItem>\n                  </DropdownMenuGroup>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuLabel className=\"text-xs\">Database</DropdownMenuLabel>\n                  <DropdownMenuGroup>\n                    <DropdownMenuItem\n                      className=\"gap-2 py-2 text-sm\"\n                      onSelect={() => setDbManagerOpen(true)}\n                    >\n                      <Database className=\"mr-2 size-4 text-slate-600\" />\n                      Manage connections\n                    </DropdownMenuItem>\n                    {dbUrl ? (\n                      <DropdownMenuItem\n                        className=\"gap-2 py-2 text-sm text-destructive focus:text-destructive\"\n                        onSelect={handleUseDefault}\n                      >\n                        <Trash2 className=\"mr-2 size-4\" />\n                        Use default (.env)\n                      </DropdownMenuItem>\n                    ) : null}\n                  </DropdownMenuGroup>\n                  <DropdownMenuSeparator />\n                  <DropdownMenuLabel className=\"text-xs\">Saved connections</DropdownMenuLabel>\n                  <DropdownMenuGroup>\n                    {sortedConnections.length ? (\n                      sortedConnections.map((connection) => {\n                        const isActive = activeConnection?.id === connection.id;\n                        return (\n                          <DropdownMenuItem\n                            key={connection.id}\n                            className=\"gap-2 py-2 text-sm\"\n                            onSelect={() => handleConnectionSwitch(connection)}\n                            disabled={isActive}\n                          >\n                            <Check\n                              className={cn(\n                                'size-4 transition-opacity',\n                                isActive ? 'opacity-100' : 'opacity-0'\n                              )}\n                            />\n                            <span className=\"flex-1 truncate\">{connection.name}</span>\n                            {connection.pinned ? (\n                              <Pin className=\"size-3 text-muted-foreground\" />\n                            ) : null}\n                          </DropdownMenuItem>\n                        );\n                      })\n                    ) : (\n                      <DropdownMenuItem className=\"text-xs text-muted-foreground/80\" disabled>\n                        No saved connections yet\n                      </DropdownMenuItem>\n                    )}\n                    <DropdownMenuItem\n                      className=\"text-xs text-muted-foreground/80\"\n                      disabled\n                    >{`Active: ${dbLabel}`}</DropdownMenuItem>\n                  </DropdownMenuGroup>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </SidebarMenuItem>\n          </SidebarMenu>\n        </SidebarFooter>\n        <SidebarRail />\n      </Sidebar>\n      <Dialog open={dbManagerOpen} onOpenChange={setDbManagerOpen}>\n        <DialogContent className=\"max-w-3xl\">\n          <DialogHeader>\n            <DialogTitle>Database connections</DialogTitle>\n            <DialogDescription>\n              Store multiple database URLs locally, switch quickly, and reload the playground when\n              needed.\n              <span className=\"mt-1 block text-[11px] text-muted-foreground\">\n                Default (.env):{' '}\n                <span className=\"font-mono\">\n                  {envDbUrl ? maskPlaygroundDbUrl(envDbUrl) : 'not set'}\n                </span>\n              </span>\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"space-y-4\">\n            <form\n              className=\"space-y-3 rounded-lg border border-border/60 p-4\"\n              onSubmit={handleConnectionSave}\n            >\n              <div className=\"flex items-center justify-between gap-4\">\n                <div>\n                  <p className=\"text-sm font-semibold\">\n                    {connectionDraft.id ? 'Edit connection' : 'New connection'}\n                  </p>\n                  <p className=\"text-xs text-muted-foreground\">\n                    {connectionDraft.id\n                      ? 'Update name, description, or URL.'\n                      : 'Add a connection saved in this browser.'}\n                  </p>\n                </div>\n                {connectionDraft.id ? (\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => resetConnectionDraft()}\n                  >\n                    New\n                  </Button>\n                ) : null}\n              </div>\n              <div className=\"grid gap-3\">\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"playground-db-name\">Name</Label>\n                  <Input\n                    id=\"playground-db-name\"\n                    type=\"text\"\n                    placeholder=\"Staging database\"\n                    value={connectionDraft.name}\n                    onChange={(event) => updateConnectionDraft({ name: event.target.value })}\n                    spellCheck={false}\n                  />\n                </div>\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"playground-db-description\">Description</Label>\n                  <Textarea\n                    id=\"playground-db-description\"\n                    placeholder=\"Optional notes about this connection\"\n                    value={connectionDraft.description}\n                    onChange={(event) => updateConnectionDraft({ description: event.target.value })}\n                    rows={2}\n                  />\n                </div>\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"playground-db-url\">Database URL</Label>\n                  <Input\n                    id=\"playground-db-url\"\n                    type=\"text\"\n                    placeholder=\"postgres://user:pass@localhost:5432/teable\"\n                    value={connectionDraft.url}\n                    onChange={(event) => updateConnectionDraft({ url: event.target.value })}\n                    spellCheck={false}\n                    autoComplete=\"off\"\n                  />\n                </div>\n                <div className=\"flex items-center gap-3\">\n                  <Switch\n                    id=\"playground-db-pin\"\n                    checked={connectionDraft.pinned}\n                    onCheckedChange={(checked) => updateConnectionDraft({ pinned: checked })}\n                  />\n                  <Label htmlFor=\"playground-db-pin\">Pin to top</Label>\n                </div>\n              </div>\n              {connectionError ? (\n                <p className=\"text-xs text-destructive\">{connectionError}</p>\n              ) : null}\n              {connectionTestMessage ? (\n                <p\n                  className={cn(\n                    'text-xs',\n                    connectionTestStatus === 'success' && 'text-emerald-600',\n                    connectionTestStatus === 'error' && 'text-destructive',\n                    connectionTestStatus === 'loading' && 'text-muted-foreground'\n                  )}\n                >\n                  {connectionTestMessage}\n                </p>\n              ) : null}\n              <DialogFooter>\n                <Button type=\"button\" variant=\"ghost\" onClick={() => setDbManagerOpen(false)}>\n                  Close\n                </Button>\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={handleConnectionTest}\n                  disabled={connectionTestStatus === 'loading'}\n                >\n                  {connectionTestStatus === 'loading' ? 'Testing...' : 'Test connection'}\n                </Button>\n                <Button type=\"submit\">\n                  {connectionDraft.id ? 'Save changes' : 'Add connection'}\n                </Button>\n              </DialogFooter>\n            </form>\n            <div className=\"space-y-3\">\n              <div className=\"flex items-center justify-between gap-3\">\n                <div>\n                  <p className=\"text-sm font-semibold\">Saved connections</p>\n                  <p className=\"text-xs text-muted-foreground\">Switching reloads the playground.</p>\n                </div>\n                <Badge variant=\"secondary\">{sortedConnections.length}</Badge>\n              </div>\n              {sortedConnections.length ? (\n                <div className=\"space-y-2\">\n                  {sortedConnections.map((connection) => {\n                    const isActive = activeConnection?.id === connection.id;\n                    return (\n                      <div\n                        key={connection.id}\n                        className=\"flex flex-wrap items-start justify-between gap-3 rounded-lg border border-border/60 p-3\"\n                      >\n                        <div className=\"space-y-1\">\n                          <div className=\"flex flex-wrap items-center gap-2\">\n                            <span className=\"text-sm font-medium\">{connection.name}</span>\n                            {connection.pinned ? (\n                              <Pin className=\"h-3 w-3 text-muted-foreground\" />\n                            ) : null}\n                            {isActive ? <Badge variant=\"outline\">Active</Badge> : null}\n                          </div>\n                          <p className=\"text-xs text-muted-foreground\">\n                            {connection.description || formatPlaygroundDbUrlLabel(connection.url)}\n                          </p>\n                          <p className=\"text-[11px] text-muted-foreground/70\">\n                            {maskPlaygroundDbUrl(connection.url)}\n                          </p>\n                        </div>\n                        <div className=\"flex shrink-0 items-center gap-2\">\n                          <Button\n                            size=\"sm\"\n                            onClick={() => handleConnectionSwitch(connection)}\n                            disabled={isActive}\n                          >\n                            Use\n                          </Button>\n                          <Button\n                            size=\"sm\"\n                            variant=\"ghost\"\n                            onClick={() => handleConnectionCopy(connection)}\n                          >\n                            <Copy className=\"mr-1 h-3.5 w-3.5\" />\n                            Copy URL\n                          </Button>\n                          <Button\n                            size=\"sm\"\n                            variant=\"ghost\"\n                            onClick={() => handleConnectionEdit(connection)}\n                          >\n                            Edit\n                          </Button>\n                          <Button\n                            size=\"sm\"\n                            variant=\"ghost\"\n                            className=\"text-destructive\"\n                            onClick={() => handleConnectionDelete(connection)}\n                          >\n                            Delete\n                          </Button>\n                        </div>\n                      </div>\n                    );\n                  })}\n                </div>\n              ) : (\n                <div className=\"rounded-lg border border-dashed border-border/60 p-4 text-center text-sm text-muted-foreground\">\n                  No saved connections yet.\n                </div>\n              )}\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n      <AlertDialog\n        open={!!deleteTarget}\n        onOpenChange={(open) => {\n          if (!open) setDeleteTarget(null);\n        }}\n      >\n        <AlertDialogContent className=\"max-w-sm\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete table</AlertDialogTitle>\n            <AlertDialogDescription>\n              {deleteTarget\n                ? `Delete \"${deleteTarget.name}\"? This will remove its schema and metadata.`\n                : 'Delete this table?'}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isDeletingTable}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              className=\"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n              onClick={handleDeleteConfirm}\n              disabled={isDeletingTable}\n            >\n              {isDeletingTable ? 'Deleting...' : 'Delete'}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/PlaygroundTableRoute.tsx",
    "content": "import { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Navigate, useNavigate } from '@tanstack/react-router';\nimport { mapTableDtoToDomain, type IListTablesOkResponseDto } from '@teable/v2-contract-http';\nimport type {\n  ITableFieldPersistenceDTO,\n  ITablePersistenceDTO,\n  ITableRecordRealtimeDTO,\n} from '@teable/v2-core';\nimport { tableTemplates, type TableTemplateDefinition } from '@teable/v2-table-templates';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { toast } from 'sonner';\nimport { useLocalStorage } from 'usehooks-ts';\n\n/** Default page size for records */\nconst DEFAULT_PAGE_SIZE = 20;\n\nimport { TableMetaPage } from '@/components/playground/TableMetaPage';\nimport { useBroadcastChannelDoc, useBroadcastChannelQuery } from '@/lib/broadcastChannel';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport {\n  PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n  PLAYGROUND_DB_URL_STORAGE_KEY,\n  findPlaygroundDbConnectionByUrl,\n  resolvePlaygroundDbUrl,\n  resolvePlaygroundDbStorageKey,\n  type PlaygroundDbConnection,\n} from '@/lib/playground/databaseUrl';\nimport { usePlaygroundEnvironment } from '@/lib/playground/environment';\nimport { useShareDbDoc, useShareDbQuery } from '@/lib/shareDb';\n\nconst getErrorMessage = (error: unknown, fallback: string): string => {\n  if (error instanceof Error) return error.message;\n  if (typeof error === 'string') return error;\n  return fallback;\n};\n\ntype PlaygroundTableDetailProps = {\n  baseId: string;\n  tableId: string;\n};\n\nexport function PlaygroundTableRoute({ baseId, tableId }: PlaygroundTableDetailProps) {\n  const env = usePlaygroundEnvironment();\n\n  if (tableId === 'new') {\n    return <Navigate to={env.routes.base} params={{ baseId }} replace />;\n  }\n\n  return <PlaygroundTableDetail baseId={baseId} tableId={tableId} />;\n}\n\nfunction PlaygroundTableDetail({ baseId, tableId }: PlaygroundTableDetailProps) {\n  const env = usePlaygroundEnvironment();\n  const [eventCount, setEventCount] = useState<number | null>(null);\n  const navigate = useNavigate();\n  const [dbUrl] = useLocalStorage<string | null>(PLAYGROUND_DB_URL_STORAGE_KEY, null, {\n    initializeWithValue: false,\n  });\n  const [dbConnections] = useLocalStorage<PlaygroundDbConnection[]>(\n    PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n    [],\n    { initializeWithValue: false }\n  );\n  const activeDbUrl = resolvePlaygroundDbUrl(dbUrl);\n  const activeConnection = findPlaygroundDbConnectionByUrl(dbConnections, activeDbUrl);\n  const baseStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.baseId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.baseId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const tableStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.tableId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.tableId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const [storedBaseId, setStoredBaseId] = useLocalStorage<string | null>(baseStorageKey, null, {\n    initializeWithValue: false,\n  });\n  const [storedTableId, setStoredTableId, removeStoredTableId] = useLocalStorage<string | null>(\n    tableStorageKey,\n    null,\n    { initializeWithValue: false }\n  );\n\n  // Pagination state for records\n  const [pageIndex, setPageIndex] = useState(0);\n  const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);\n\n  // Reset pagination when table changes\n  useEffect(() => {\n    if (!tableId) return;\n    setPageIndex(0);\n  }, [tableId]);\n\n  const orpcClient = useOrpcClient();\n  const orpc = createTanstackQueryUtils(orpcClient);\n  const queryClient = useQueryClient();\n\n  const createTableMutation = useMutation(\n    orpc.tables.createTables.mutationOptions({\n      onSuccess: (response) => {\n        const created = response.data.tables[0];\n        if (!created) return;\n        setEventCount(response.data.events.length);\n        setStoredBaseId(baseId);\n        setStoredTableId(created.id);\n        queryClient.setQueryData(\n          orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId: created.id,\n            },\n          }),\n          { ok: true, data: { table: created } }\n        );\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n        void navigate({\n          to: env.routes.table,\n          params: { baseId, tableId: created.id },\n          search: (prev) => prev,\n        });\n      },\n    })\n  );\n\n  const deleteTableMutation = useMutation(\n    orpc.tables.delete.mutationOptions({\n      onSuccess: () => {\n        queryClient.removeQueries({\n          queryKey: orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId,\n            },\n          }),\n        });\n\n        const removeFromList = (list: IListTablesOkResponseDto | undefined) =>\n          list\n            ? {\n                ...list,\n                data: {\n                  ...list.data,\n                  tables: list.data.tables.filter((table) => table.id !== tableId),\n                },\n              }\n            : list;\n\n        queryClient.setQueryData(orpc.tables.list.queryKey({ input: { baseId } }), removeFromList);\n\n        if (storedBaseId === baseId && storedTableId === tableId) {\n          removeStoredTableId();\n        }\n\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n\n        void navigate({ to: env.routes.base, params: { baseId } });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to delete table'));\n      },\n    })\n  );\n\n  const renameTableMutation = useMutation(\n    orpc.tables.rename.mutationOptions({\n      onSuccess: (response) => {\n        const updated = response.data.table;\n        setEventCount(response.data.events.length);\n\n        queryClient.setQueryData(\n          orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId,\n            },\n          }),\n          { ok: true, data: { table: updated } }\n        );\n\n        const updateList = (list: IListTablesOkResponseDto | undefined) =>\n          list\n            ? {\n                ...list,\n                data: {\n                  ...list.data,\n                  tables: list.data.tables.map((table) =>\n                    table.id === updated.id ? updated : table\n                  ),\n                },\n              }\n            : list;\n\n        queryClient.setQueryData(orpc.tables.list.queryKey({ input: { baseId } }), updateList);\n\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to rename table'));\n      },\n    })\n  );\n\n  const deleteFieldMutation = useMutation(\n    orpc.tables.deleteField.mutationOptions({\n      onSuccess: (response) => {\n        const updated = response.data.table;\n        setEventCount(response.data.events.length);\n\n        queryClient.setQueryData(\n          orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId,\n            },\n          }),\n          { ok: true, data: { table: updated } }\n        );\n\n        const updateList = (list: IListTablesOkResponseDto | undefined) =>\n          list\n            ? {\n                ...list,\n                data: {\n                  ...list.data,\n                  tables: list.data.tables.map((table) =>\n                    table.id === updated.id ? updated : table\n                  ),\n                },\n              }\n            : list;\n\n        queryClient.setQueryData(orpc.tables.list.queryKey({ input: { baseId } }), updateList);\n\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to delete field'));\n      },\n    })\n  );\n\n  const deleteRecordsMutation = useMutation(\n    orpc.tables.deleteRecords.mutationOptions({\n      onSuccess: (response) => {\n        setEventCount(response.data.events.length);\n        const deletedCount = response.data.deletedRecordIds.length;\n        toast.success(`Deleted ${deletedCount} record${deletedCount === 1 ? '' : 's'}`);\n        void recordsQuery.refetch();\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to delete record'));\n      },\n    })\n  );\n\n  useEffect(() => {\n    setStoredBaseId(baseId);\n    setStoredTableId(tableId);\n  }, [baseId, setStoredBaseId, setStoredTableId, tableId]);\n\n  const isSandbox = env.kind === 'sandbox';\n  const realtimeCollection = useMemo(() => `tbl_${baseId}`, [baseId]);\n  const shareDbDoc = useShareDbDoc<ITablePersistenceDTO>({\n    collection: realtimeCollection,\n    docId: tableId,\n    enabled: !isSandbox,\n  });\n  const realtimeFieldCollection = useMemo(() => `fld_${tableId}`, [tableId]);\n  const shareDbFields = useShareDbQuery<ITableFieldPersistenceDTO>({\n    collection: realtimeFieldCollection,\n    query: {},\n    enabled: !isSandbox,\n    filter: (doc) => {\n      const data = doc.data as { id?: unknown } | null | undefined;\n      return Boolean(doc.type) && typeof data?.id === 'string';\n    },\n  });\n  const broadcastDoc = useBroadcastChannelDoc<ITablePersistenceDTO>({\n    collection: realtimeCollection,\n    docId: tableId,\n    enabled: isSandbox,\n  });\n  const broadcastFields = useBroadcastChannelQuery<ITableFieldPersistenceDTO>({\n    collection: realtimeFieldCollection,\n    enabled: isSandbox,\n    getId: (snapshot) => snapshot.id,\n  });\n  const realtimeDoc = isSandbox ? broadcastDoc : shareDbDoc;\n  const realtimeFields = isSandbox ? broadcastFields : shareDbFields;\n\n  // Record realtime subscription\n  const realtimeRecordCollection = useMemo(() => `rec_${tableId}`, [tableId]);\n  const shareDbRecords = useShareDbQuery<ITableRecordRealtimeDTO>({\n    collection: realtimeRecordCollection,\n    query: {},\n    enabled: !isSandbox,\n    filter: (doc) => {\n      const data = doc.data as { id?: unknown } | null | undefined;\n      return Boolean(doc.type) && typeof data?.id === 'string';\n    },\n  });\n  const broadcastRecords = useBroadcastChannelQuery<ITableRecordRealtimeDTO>({\n    collection: realtimeRecordCollection,\n    enabled: isSandbox,\n    getId: (snapshot) => snapshot.id,\n  });\n  const realtimeRecords = isSandbox ? broadcastRecords : shareDbRecords;\n\n  const tableQuery = useQuery(\n    orpc.tables.getById.queryOptions({\n      input: {\n        baseId,\n        tableId,\n      },\n      placeholderData: keepPreviousData,\n      select: (response) => response.data.table,\n    })\n  );\n\n  const recordsQuery = useQuery(\n    orpc.tables.listRecords.queryOptions({\n      input: {\n        tableId,\n        limit: pageSize,\n        offset: pageIndex * pageSize,\n      },\n      enabled: Boolean(tableId),\n      placeholderData: keepPreviousData,\n      refetchOnMount: false,\n      refetchOnReconnect: false,\n      refetchOnWindowFocus: false,\n      select: (response) => ({\n        records: response.data.records,\n        pagination: response.data.pagination,\n      }),\n    })\n  );\n\n  const tableDto = tableQuery.data ?? null;\n  const tableResult = useMemo(() => (tableDto ? mapTableDtoToDomain(tableDto) : null), [tableDto]);\n  const table = tableResult?.isOk() ? tableResult.value : null;\n  const mappingError = tableResult?.isErr() ? tableResult.error.message : null;\n  const records = recordsQuery.data?.records ?? null;\n  const recordsPagination = recordsQuery.data?.pagination ?? null;\n  const recordsError = recordsQuery.error\n    ? getErrorMessage(recordsQuery.error, 'Failed to load records')\n    : null;\n\n  // Sync realtime records data to TanStack Query cache\n  useEffect(() => {\n    if (!realtimeRecords.data || realtimeRecords.data.length === 0) return;\n\n    const queryKey = orpc.tables.listRecords.queryOptions({\n      input: {\n        tableId,\n        limit: pageSize,\n        offset: pageIndex * pageSize,\n      },\n    }).queryKey;\n\n    type RecordsQueryData = {\n      ok: true;\n      data: {\n        records: Array<{ id: string; fields: Record<string, unknown> }>;\n        pagination: unknown;\n      };\n    };\n\n    queryClient.setQueryData<RecordsQueryData | undefined>(queryKey, (oldData) => {\n      if (!oldData?.data?.records) return oldData;\n\n      // Create a map of realtime records by id for quick lookup\n      const realtimeMap = new Map(realtimeRecords.data.map((r) => [r.id, r]));\n\n      // Update records with realtime data\n      const updatedRecords = oldData.data.records.map((record) => {\n        const realtimeRecord = realtimeMap.get(record.id);\n        if (!realtimeRecord) return record;\n\n        return {\n          ...record,\n          fields: {\n            ...record.fields,\n            ...realtimeRecord.fields,\n          },\n        };\n      });\n\n      return {\n        ...oldData,\n        data: {\n          ...oldData.data,\n          records: updatedRecords,\n        },\n      };\n    });\n  }, [realtimeRecords.data, queryClient, orpc, tableId, pageSize, pageIndex]);\n\n  // Pagination change handler\n  const handlePaginationChange = useCallback(\n    (pagination: { pageIndex: number; pageSize: number }) => {\n      setPageIndex(pagination.pageIndex);\n      setPageSize(pagination.pageSize);\n    },\n    []\n  );\n\n  const isInitialLoading = !table && tableQuery.isLoading;\n  const isLoading = tableQuery.isFetching;\n  const isCreating = createTableMutation.isPending;\n  const errorMessage = (() => {\n    if (mappingError) return mappingError;\n    if (tableQuery.error) {\n      return getErrorMessage(tableQuery.error, 'Failed to load table');\n    }\n    if (createTableMutation.error) {\n      return getErrorMessage(createTableMutation.error, 'Failed to create table');\n    }\n    return null;\n  })();\n\n  const handleCreateTemplate = (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => {\n    createTableMutation.reset();\n    createTableMutation.mutate(template.createInput(baseId, options));\n  };\n\n  const handleDelete = () => {\n    deleteTableMutation.reset();\n    deleteTableMutation.mutate({ baseId, tableId });\n  };\n\n  const handleRename = (name: string) => {\n    renameTableMutation.reset();\n    renameTableMutation.mutate({ baseId, tableId, name });\n  };\n\n  const handleRefresh = () => {\n    void tableQuery.refetch();\n    void recordsQuery.refetch();\n  };\n\n  const handleFieldCreated = () => {\n    void tableQuery.refetch();\n  };\n\n  const handleDeleteField = (fieldId: string) => {\n    deleteFieldMutation.reset();\n    deleteFieldMutation.mutate({ baseId, tableId, fieldId });\n  };\n\n  const handleDeleteRecords = (recordIds: string[]) => {\n    if (!recordIds.length) return;\n    deleteRecordsMutation.reset();\n    deleteRecordsMutation.mutate({ tableId, recordIds });\n  };\n\n  const handleRecordCreated = () => {\n    void recordsQuery.refetch();\n  };\n\n  const handleImportCsv = async (data: {\n    tableName: string;\n    csvData?: string;\n    csvUrl?: string;\n  }): Promise<void> => {\n    try {\n      const result = await orpcClient.tables.importCsv({\n        baseId,\n        ...(data.csvUrl ? { csvUrl: data.csvUrl } : { csvData: data.csvData! }),\n        tableName: data.tableName,\n        batchSize: 5000,\n      });\n\n      toast.success(`Imported ${result.data.totalImported} records into \"${data.tableName}\"`);\n\n      // Navigate to new table and refresh\n      setStoredTableId(result.data.table.id);\n      void navigate({\n        to: env.routes.table,\n        params: { baseId, tableId: result.data.table.id },\n        search: (prev) => prev,\n      });\n    } catch (error) {\n      const errorMsg = getErrorMessage(error, 'Failed to import CSV');\n      toast.error(errorMsg);\n      throw error;\n    }\n  };\n\n  return (\n    <TableMetaPage\n      baseId={baseId}\n      tableId={tableId}\n      table={table}\n      eventCount={eventCount}\n      realtimeSnapshot={realtimeDoc.data}\n      realtimeStatus={realtimeDoc.status}\n      realtimeError={realtimeDoc.error}\n      realtimeFieldSnapshots={realtimeFields.data}\n      realtimeFieldStatus={realtimeFields.status}\n      realtimeFieldError={realtimeFields.error}\n      realtimeRecordSnapshots={realtimeRecords.data}\n      realtimeRecordStatus={realtimeRecords.status}\n      realtimeRecordError={realtimeRecords.error}\n      isInitialLoading={isInitialLoading}\n      isLoading={isLoading}\n      isCreating={isCreating}\n      isDeleting={deleteTableMutation.isPending}\n      isDeletingField={deleteFieldMutation.isPending}\n      isRenaming={renameTableMutation.isPending}\n      records={records}\n      recordsPagination={recordsPagination}\n      recordsError={recordsError}\n      isRecordsLoading={recordsQuery.isLoading}\n      isRecordsFetching={recordsQuery.isFetching}\n      isDeletingRecord={deleteRecordsMutation.isPending}\n      errorMessage={errorMessage}\n      onRefresh={handleRefresh}\n      onFieldCreated={handleFieldCreated}\n      onRecordCreated={handleRecordCreated}\n      onPaginationChange={handlePaginationChange}\n      templates={tableTemplates}\n      onCreateTemplate={handleCreateTemplate}\n      onImportCsv={handleImportCsv}\n      onDelete={handleDelete}\n      onDeleteField={handleDeleteField}\n      onDeleteRecords={handleDeleteRecords}\n      onRename={handleRename}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/RecordCreateDialog.tsx",
    "content": "import {\n  useForm,\n  standardSchemaValidator,\n  type Validator,\n  type StandardSchemaV1,\n} from '@tanstack/react-form';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport { type Table as TableAggregate, type LinkField } from '@teable/v2-core';\nimport type { IExplainResultDto } from '@teable/v2-contract-http';\nimport { Plus, Search } from 'lucide-react';\nimport { useMemo, useState, useCallback } from 'react';\nimport { toast } from 'sonner';\nimport { z, type ZodTypeAny } from 'zod';\n\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { Label } from '@/components/ui/label';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport { FieldInput } from './field-inputs';\nimport { ExplainResultPanel } from './ExplainResultPanel';\n\ninterface RecordCreateDialogProps {\n  table: TableAggregate;\n  onSuccess?: () => void;\n  baseId?: string;\n}\n\ntype RecordFieldValues = Record<string, unknown>;\ntype RecordFormValidator = Validator<RecordFieldValues, StandardSchemaV1<RecordFieldValues>>;\n\nfunction useRecordInputSchema(\n  table: TableAggregate\n): z.ZodObject<Record<string, ZodTypeAny>> | null {\n  return useMemo(() => {\n    const schemaResult = table.createRecordInputSchema();\n    if (schemaResult.isErr()) {\n      console.error('Failed to create record input schema:', schemaResult.error);\n      return null;\n    }\n    return schemaResult.value;\n  }, [table]);\n}\n\nexport function RecordCreateDialog({ table, onSuccess, baseId }: RecordCreateDialogProps) {\n  const [open, setOpen] = useState(false);\n  const [explainResult, setExplainResult] = useState<IExplainResultDto | null>(null);\n  const [explainDialogOpen, setExplainDialogOpen] = useState(false);\n  const [analyzeMode, setAnalyzeMode] = useState(true);\n  const orpcClient = useOrpcClient();\n  const orpc = createTanstackQueryUtils(orpcClient);\n  const queryClient = useQueryClient();\n\n  const tableId = table.id().toString();\n  const editableFields = useMemo(() => table.getEditableFields(), [table]);\n  const recordSchema = useRecordInputSchema(table);\n  const validatorAdapter = standardSchemaValidator() as RecordFormValidator;\n\n  const defaultValues = useMemo(() => {\n    const values: RecordFieldValues = {};\n    for (const field of editableFields) {\n      const fieldType = field.type().toString();\n      if (fieldType === 'checkbox') {\n        values[field.id().toString()] = false;\n      } else {\n        values[field.id().toString()] = null;\n      }\n    }\n    return values;\n  }, [editableFields]);\n\n  const createRecordMutation = useMutation(\n    orpc.tables.createRecord.mutationOptions({\n      onSuccess: () => {\n        toast.success('Record created');\n        form.reset();\n        setOpen(false);\n        setExplainResult(null);\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.listRecords.queryKey({ input: { tableId } }),\n        });\n        onSuccess?.();\n      },\n      onError: (error: Error) => {\n        toast.error(error.message || 'Failed to create record');\n      },\n    })\n  );\n\n  const explainMutation = useMutation(\n    orpc.tables.explainCreateRecord.mutationOptions({\n      onSuccess: (response) => {\n        setExplainResult(response.data);\n        setExplainDialogOpen(true);\n      },\n      onError: (error: Error) => {\n        toast.error(error.message || 'Failed to explain command');\n      },\n    })\n  );\n\n  const form = useForm<RecordFieldValues, RecordFormValidator>({\n    defaultValues,\n    validatorAdapter,\n    validators: recordSchema ? { onSubmit: recordSchema } : {},\n    onSubmit: async ({ value }) => {\n      const filteredFields: Record<string, unknown> = {};\n      for (const [key, val] of Object.entries(value)) {\n        if (val !== null && val !== undefined && val !== '') {\n          filteredFields[key] = val;\n        }\n      }\n      createRecordMutation.mutate({ tableId, fields: filteredFields });\n    },\n  });\n\n  const handleExplain = useCallback(() => {\n    const value = form.state.values;\n    const filteredFields: Record<string, unknown> = {};\n    for (const [key, val] of Object.entries(value)) {\n      if (val !== null && val !== undefined && val !== '') {\n        filteredFields[key] = val;\n      }\n    }\n    explainMutation.mutate({\n      tableId,\n      fields: filteredFields,\n      analyze: analyzeMode,\n      includeSql: true,\n      includeGraph: false,\n    });\n  }, [explainMutation, form.state.values, tableId, analyzeMode]);\n\n  const handleOpenChange = useCallback(\n    (nextOpen: boolean) => {\n      setOpen(nextOpen);\n      if (!nextOpen) {\n        form.reset();\n        createRecordMutation.reset();\n        explainMutation.reset();\n        setExplainResult(null);\n      }\n    },\n    [form, createRecordMutation, explainMutation]\n  );\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={handleOpenChange}>\n        <DialogTrigger asChild>\n          <Button size=\"sm\">\n            <Plus className=\"mr-1.5 h-3.5 w-3.5\" />\n            Create Record\n          </Button>\n        </DialogTrigger>\n        <DialogContent className=\"max-w-lg p-0 overflow-hidden flex flex-col max-h-[85vh]\">\n          <DialogHeader className=\"p-6 pb-4\">\n            <DialogTitle>Create Record</DialogTitle>\n            <DialogDescription>\n              Fill in the fields below to create a new record. Fields marked with * are required.\n            </DialogDescription>\n          </DialogHeader>\n          <form\n            className=\"flex flex-col flex-1 min-h-0\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              form.handleSubmit();\n            }}\n          >\n            <div className=\"flex-1 overflow-y-auto px-6\">\n              <div className=\"space-y-4 pb-4\">\n                {editableFields.map((field) => (\n                  <form.Field\n                    key={field.id().toString()}\n                    name={field.id().toString()}\n                    children={(formField) => {\n                      const isRequired = field.notNull().toBoolean();\n                      const fieldType = field.type().toString();\n                      const fieldName = field.name().toString();\n                      const isLinkField = fieldType === 'link';\n                      const linkField = isLinkField ? (field as LinkField) : null;\n                      return (\n                        <div className=\"space-y-2\">\n                          <Label htmlFor={field.id().toString()}>\n                            {isLinkField && linkField ? (\n                              <LinkFieldLabel\n                                name={fieldName}\n                                fieldId={linkField.id().toString()}\n                                relationship={linkField.relationship().toString()}\n                                isOneWay={linkField.isOneWay()}\n                              />\n                            ) : (\n                              <span>{fieldName}</span>\n                            )}\n                            {isRequired && <span className=\"text-destructive ml-1\">*</span>}\n                            <span className=\"ml-2 text-xs text-muted-foreground font-normal\">\n                              ({fieldType})\n                            </span>\n                          </Label>\n\n                          <FieldInput\n                            field={field}\n                            value={formField.state.value}\n                            onChange={formField.handleChange}\n                            onBlur={formField.handleBlur}\n                            orpcClient={orpcClient}\n                            baseId={baseId}\n                          />\n                          {formField.state.meta.errors.length > 0 && (\n                            <p className=\"text-xs text-destructive\">\n                              {formField.state.meta.errors.join(', ')}\n                            </p>\n                          )}\n                        </div>\n                      );\n                    }}\n                  />\n                ))}\n              </div>\n            </div>\n\n            <div className=\"flex justify-between gap-3 p-6 pt-4 border-t bg-background\">\n              <div className=\"flex items-center gap-3\">\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleExplain}\n                  disabled={explainMutation.isPending}\n                  className=\"text-muted-foreground\"\n                >\n                  <Search className=\"mr-1.5 h-3.5 w-3.5\" />\n                  {explainMutation.isPending ? 'Analyzing...' : 'Explain'}\n                </Button>\n                <div className=\"flex items-center gap-1.5\">\n                  <Checkbox\n                    id=\"analyze-mode-create\"\n                    checked={analyzeMode}\n                    onCheckedChange={(checked) => setAnalyzeMode(!!checked)}\n                  />\n                  <Label\n                    htmlFor=\"analyze-mode-create\"\n                    className=\"text-xs text-muted-foreground cursor-pointer\"\n                  >\n                    ANALYZE\n                  </Label>\n                </div>\n              </div>\n              <div className=\"flex gap-3\">\n                <Button type=\"button\" variant=\"outline\" onClick={() => handleOpenChange(false)}>\n                  Cancel\n                </Button>\n                <form.Subscribe\n                  selector={(state) => [state.canSubmit, state.isSubmitting] as const}\n                  children={([canSubmit, isSubmitting]) => (\n                    <Button\n                      type=\"submit\"\n                      disabled={!canSubmit || isSubmitting || createRecordMutation.isPending}\n                    >\n                      {createRecordMutation.isPending ? 'Creating...' : 'Create'}\n                    </Button>\n                  )}\n                />\n              </div>\n            </div>\n          </form>\n        </DialogContent>\n      </Dialog>\n\n      <Dialog open={explainDialogOpen} onOpenChange={setExplainDialogOpen}>\n        <DialogContent className=\"sm:max-w-[90vw] w-[90vw] max-h-[85vh] overflow-hidden flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>Explain: Create Record</DialogTitle>\n            <DialogDescription>Analysis of the create record operation</DialogDescription>\n          </DialogHeader>\n          <div className=\"flex-1 overflow-hidden\">\n            {explainResult && <ExplainResultPanel result={explainResult} />}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/RecordDeleteDialog.tsx",
    "content": "import { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport type { IExplainResultDto } from '@teable/v2-contract-http';\nimport { useMutation } from '@tanstack/react-query';\nimport { Search } from 'lucide-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { toast } from 'sonner';\n\nimport { ExplainResultPanel } from '@/components/playground/ExplainResultPanel';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Label } from '@/components/ui/label';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\n\ntype RecordDeleteDialogProps = {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  tableId: string;\n  recordIds: string[];\n  recordLabel?: string | null;\n  isDeleting: boolean;\n  onConfirm: () => void;\n};\n\nexport function RecordDeleteDialog({\n  open,\n  onOpenChange,\n  tableId,\n  recordIds,\n  recordLabel,\n  isDeleting,\n  onConfirm,\n}: RecordDeleteDialogProps) {\n  const [explainResult, setExplainResult] = useState<IExplainResultDto | null>(null);\n  const [explainDialogOpen, setExplainDialogOpen] = useState(false);\n  const [analyzeMode, setAnalyzeMode] = useState(true);\n  const [explainTargetCount, setExplainTargetCount] = useState(0);\n  const orpcClient = useOrpcClient();\n  const orpc = createTanstackQueryUtils(orpcClient);\n  const normalizedLabel = recordLabel?.trim() ?? '';\n  const analyzeId = useMemo(() => `record-delete-analyze-${tableId}`, [tableId]);\n\n  const explainMutation = useMutation(\n    orpc.tables.explainDeleteRecords.mutationOptions({\n      onSuccess: (response) => {\n        setExplainResult(response.data);\n        setExplainDialogOpen(true);\n      },\n      onError: (error: Error) => {\n        toast.error(error.message || 'Failed to explain command');\n      },\n    })\n  );\n\n  const handleExplainDelete = useCallback(() => {\n    if (!recordIds.length) {\n      toast.info('No records to explain');\n      return;\n    }\n    setExplainTargetCount(recordIds.length);\n    explainMutation.mutate({\n      tableId,\n      recordIds,\n      analyze: analyzeMode,\n      includeSql: true,\n      includeGraph: false,\n    });\n  }, [analyzeMode, explainMutation, recordIds, tableId]);\n\n  const handleOpenChange = useCallback(\n    (nextOpen: boolean) => {\n      onOpenChange(nextOpen);\n      if (!nextOpen) {\n        setExplainResult(null);\n        setExplainDialogOpen(false);\n        setExplainTargetCount(0);\n        explainMutation.reset();\n      }\n    },\n    [explainMutation, onOpenChange]\n  );\n\n  const explainDialogLabel = recordIds.length > 1 ? 'Delete Records' : 'Delete Record';\n  const canDelete = recordIds.length > 0 && !isDeleting;\n  const canExplain = recordIds.length > 0 && !explainMutation.isPending;\n\n  return (\n    <>\n      <AlertDialog open={open} onOpenChange={handleOpenChange}>\n        <AlertDialogContent className=\"max-w-md\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete record</AlertDialogTitle>\n            <AlertDialogDescription>\n              {recordIds.length > 1\n                ? `Delete ${recordIds.length} records?`\n                : normalizedLabel\n                  ? `Delete \"${normalizedLabel}\"?`\n                  : 'Delete this record?'}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter className=\"sm:justify-between sm:items-center flex-wrap gap-3\">\n            <div className=\"flex items-center gap-3\">\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={handleExplainDelete}\n                disabled={!canExplain}\n                className=\"text-muted-foreground\"\n              >\n                <Search className=\"mr-1.5 h-3.5 w-3.5\" />\n                {explainMutation.isPending ? 'Analyzing...' : 'Explain'}\n              </Button>\n              <div className=\"flex items-center gap-1.5\">\n                <Checkbox\n                  id={analyzeId}\n                  checked={analyzeMode}\n                  onCheckedChange={(checked) => setAnalyzeMode(!!checked)}\n                />\n                <Label htmlFor={analyzeId} className=\"text-xs text-muted-foreground cursor-pointer\">\n                  ANALYZE\n                </Label>\n              </div>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>\n              <AlertDialogAction\n                className=\"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n                onClick={onConfirm}\n                disabled={!canDelete}\n              >\n                {isDeleting ? 'Deleting...' : 'Delete'}\n              </AlertDialogAction>\n            </div>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n      <Dialog open={explainDialogOpen} onOpenChange={setExplainDialogOpen}>\n        <DialogContent className=\"sm:max-w-[90vw] w-[90vw] max-h-[85vh] overflow-visible flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>Explain: {explainDialogLabel}</DialogTitle>\n            <DialogDescription>\n              {explainTargetCount > 1\n                ? `Analysis of deleting ${explainTargetCount} records`\n                : 'Analysis of the delete record operation'}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"flex-1 overflow-hidden\">\n            {explainResult && <ExplainResultPanel result={explainResult} />}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/RecordUpdateDialog.tsx",
    "content": "import {\n  useForm,\n  standardSchemaValidator,\n  type StandardSchemaV1,\n  type Validator,\n} from '@tanstack/react-form';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport type { ITableRecordDto, IExplainResultDto } from '@teable/v2-contract-http';\nimport {\n  type Table as TableAggregate,\n  type SingleSelectField,\n  type MultipleSelectField,\n  type LinkField,\n} from '@teable/v2-core';\nimport { Search } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { toast } from 'sonner';\nimport { z, type ZodTypeAny } from 'zod';\n\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Label } from '@/components/ui/label';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport { FieldInput } from './field-inputs';\nimport { ExplainResultPanel } from './ExplainResultPanel';\n\ninterface RecordUpdateDialogProps {\n  table: TableAggregate;\n  record: ITableRecordDto;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onSuccess?: () => void;\n  baseId?: string;\n}\n\ntype RecordFieldValues = Record<string, unknown>;\ntype RecordFormValidator = Validator<RecordFieldValues, StandardSchemaV1<RecordFieldValues>>;\n\nconst useRecordInputSchema = (\n  table: TableAggregate\n): z.ZodObject<Record<string, ZodTypeAny>> | null => {\n  return useMemo(() => {\n    const schemaResult = table.createRecordInputSchema();\n    if (schemaResult.isErr()) {\n      console.error('Failed to create record input schema:', schemaResult.error);\n      return null;\n    }\n    return schemaResult.value;\n  }, [table]);\n};\n\nexport function RecordUpdateDialog({\n  table,\n  record,\n  open,\n  onOpenChange,\n  onSuccess,\n  baseId,\n}: RecordUpdateDialogProps) {\n  const [explainResult, setExplainResult] = useState<IExplainResultDto | null>(null);\n  const [explainDialogOpen, setExplainDialogOpen] = useState(false);\n  const [analyzeMode, setAnalyzeMode] = useState(true);\n  const orpcClient = useOrpcClient();\n  const orpc = createTanstackQueryUtils(orpcClient);\n  const queryClient = useQueryClient();\n  const tableId = table.id().toString();\n\n  const editableFields = useMemo(() => table.getEditableFields(), [table]);\n  const recordSchema = useRecordInputSchema(table);\n  const validatorAdapter = standardSchemaValidator() as RecordFormValidator;\n\n  const normalizeSelectValue = useCallback(\n    (field: SingleSelectField | MultipleSelectField, value: unknown): string | null => {\n      if (value === null || value === undefined) return null;\n      const options = field.selectOptions();\n      const findById = (id: string) => options.find((option) => option.id().toString() === id);\n      const findByName = (name: string) =>\n        options.find((option) => option.name().toString() === name);\n      if (typeof value === 'string') {\n        const trimmed = value.trim();\n        if (!trimmed) return null;\n        return findById(trimmed)?.id().toString() ?? findByName(trimmed)?.id().toString() ?? null;\n      }\n      if (typeof value === 'object') {\n        const candidate = value as { id?: unknown; name?: unknown };\n        if (typeof candidate.id === 'string') {\n          return findById(candidate.id)?.id().toString() ?? candidate.id;\n        }\n        if (typeof candidate.name === 'string') {\n          return findByName(candidate.name)?.id().toString() ?? candidate.name;\n        }\n      }\n      return null;\n    },\n    []\n  );\n\n  const getDirtyFieldValues = useCallback(\n    (values: RecordFieldValues, fieldMeta: Record<string, { isDirty?: boolean }>) => {\n      const dirtyValues: RecordFieldValues = {};\n      for (const [key, val] of Object.entries(values)) {\n        if (fieldMeta[key]?.isDirty) {\n          dirtyValues[key] = val;\n        }\n      }\n      return dirtyValues;\n    },\n    []\n  );\n\n  const validateDirtyFields = useCallback(\n    ({\n      value,\n      formApi,\n    }: {\n      value: RecordFieldValues;\n      formApi: { state: { fieldMeta: Record<string, { isDirty?: boolean }> } };\n    }) => {\n      if (!recordSchema) return;\n      const dirtyValues = getDirtyFieldValues(value, formApi.state.fieldMeta);\n      if (Object.keys(dirtyValues).length === 0) return;\n      const dirtyShape: Record<string, ZodTypeAny> = {};\n      for (const key of Object.keys(dirtyValues)) {\n        const fieldSchema = recordSchema.shape[key];\n        if (fieldSchema) {\n          dirtyShape[key] = fieldSchema;\n        }\n      }\n      if (Object.keys(dirtyShape).length === 0) return;\n      const result = z.object(dirtyShape).safeParse(dirtyValues);\n      if (result.success) return;\n      const fieldErrors = result.error.flatten().fieldErrors;\n      const fields: Record<string, string> = {};\n      for (const [fieldId, errors] of Object.entries(fieldErrors)) {\n        const message = errors?.[0];\n        if (message) {\n          fields[fieldId] = message;\n        }\n      }\n      if (Object.keys(fields).length > 0) {\n        return { fields };\n      }\n      return 'Invalid form values';\n    },\n    [getDirtyFieldValues, recordSchema]\n  );\n\n  const defaultValues = useMemo(() => {\n    const values: RecordFieldValues = {};\n    for (const field of editableFields) {\n      const fieldId = field.id().toString();\n      const fieldType = field.type().toString();\n      const recordValue = record.fields[fieldId];\n      if (recordValue !== undefined) {\n        if (fieldType === 'date' && recordValue instanceof Date) {\n          values[fieldId] = recordValue.toISOString();\n          continue;\n        }\n        if (fieldType === 'singleSelect') {\n          values[fieldId] = normalizeSelectValue(field as SingleSelectField, recordValue);\n          continue;\n        }\n        if (fieldType === 'multipleSelect') {\n          const entries = Array.isArray(recordValue) ? recordValue : [recordValue];\n          const normalized = entries\n            .map((entry) => normalizeSelectValue(field as MultipleSelectField, entry))\n            .filter((entry): entry is string => Boolean(entry));\n          values[fieldId] = normalized.length > 0 ? normalized : null;\n          continue;\n        }\n        values[fieldId] = recordValue;\n        continue;\n      }\n      values[fieldId] = fieldType === 'checkbox' ? false : null;\n    }\n    return values;\n  }, [editableFields, normalizeSelectValue, record]);\n\n  const updateRecordMutation = useMutation(\n    orpc.tables.updateRecord.mutationOptions({\n      onSuccess: () => {\n        toast.success('Record updated');\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.listRecords.queryKey({ input: { tableId } }),\n        });\n        onSuccess?.();\n        setExplainResult(null);\n        onOpenChange(false);\n      },\n      onError: (error: Error) => {\n        toast.error(error.message || 'Failed to update record');\n      },\n    })\n  );\n\n  const explainMutation = useMutation(\n    orpc.tables.explainUpdateRecord.mutationOptions({\n      onSuccess: (response) => {\n        setExplainResult(response.data);\n        setExplainDialogOpen(true);\n      },\n      onError: (error: Error) => {\n        toast.error(error.message || 'Failed to explain command');\n      },\n    })\n  );\n\n  const form = useForm<RecordFieldValues, RecordFormValidator>({\n    defaultValues,\n    validatorAdapter,\n    validators: recordSchema ? { onSubmit: validateDirtyFields } : {},\n    onSubmit: async ({ value, formApi }) => {\n      const fields = getDirtyFieldValues(value, formApi.state.fieldMeta);\n      if (Object.keys(fields).length === 0) {\n        toast.info('No changes to update');\n        return;\n      }\n      updateRecordMutation.mutate({ tableId, recordId: record.id, fields });\n    },\n  });\n\n  const handleExplain = useCallback(() => {\n    const fields = getDirtyFieldValues(form.state.values, form.state.fieldMeta);\n    if (Object.keys(fields).length === 0) {\n      toast.info('No changes to explain');\n      return;\n    }\n    explainMutation.mutate({\n      tableId,\n      recordId: record.id,\n      fields,\n      analyze: analyzeMode,\n      includeSql: true,\n      includeGraph: false,\n    });\n  }, [\n    analyzeMode,\n    explainMutation,\n    form.state.fieldMeta,\n    form.state.values,\n    getDirtyFieldValues,\n    record.id,\n    tableId,\n  ]);\n\n  const lastOpenRef = useRef(false);\n  const lastRecordIdRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    const wasOpen = lastOpenRef.current;\n    lastOpenRef.current = open;\n    if (!open) return;\n    const recordChanged = lastRecordIdRef.current !== record.id;\n    if (!wasOpen || recordChanged) {\n      lastRecordIdRef.current = record.id;\n      form.reset(defaultValues);\n      updateRecordMutation.reset();\n      explainMutation.reset();\n      setExplainResult(null);\n    }\n  }, [open, record.id, defaultValues, form, updateRecordMutation, explainMutation]);\n\n  const handleOpenChange = useCallback(\n    (nextOpen: boolean) => {\n      onOpenChange(nextOpen);\n      if (!nextOpen) {\n        form.reset(defaultValues);\n        updateRecordMutation.reset();\n        explainMutation.reset();\n        setExplainResult(null);\n      }\n    },\n    [defaultValues, form, onOpenChange, updateRecordMutation, explainMutation]\n  );\n\n  return (\n    <>\n      <Dialog open={open} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"max-w-lg p-0 overflow-hidden flex flex-col max-h-[85vh]\">\n          <DialogHeader className=\"p-6 pb-4\">\n            <DialogTitle>Update Record</DialogTitle>\n            <DialogDescription>\n              Update the values for this record. Fields marked with * are required.\n            </DialogDescription>\n          </DialogHeader>\n          <form\n            className=\"flex flex-col flex-1 min-h-0\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              form.handleSubmit();\n            }}\n          >\n            <div className=\"flex-1 overflow-y-auto px-6\">\n              <div className=\"space-y-4 pb-4\">\n                {editableFields.map((field) => (\n                  <form.Field key={field.id().toString()} name={field.id().toString()}>\n                    {(formField) => {\n                      const isRequired = field.notNull().toBoolean();\n                      const fieldType = field.type().toString();\n                      const fieldName = field.name().toString();\n                      const isLinkField = fieldType === 'link';\n                      const linkField = isLinkField ? (field as LinkField) : null;\n                      return (\n                        <div className=\"space-y-2\">\n                          <Label\n                            htmlFor={field.id().toString()}\n                            className=\"flex items-center gap-2\"\n                          >\n                            {isLinkField && linkField ? (\n                              <LinkFieldLabel\n                                name={fieldName}\n                                fieldId={linkField.id().toString()}\n                                relationship={linkField.relationship().toString()}\n                                isOneWay={linkField.isOneWay()}\n                              />\n                            ) : (\n                              <span>{fieldName}</span>\n                            )}\n                            {isRequired && <span className=\"text-destructive\">*</span>}\n                            <span className=\"text-xs text-muted-foreground font-normal\">\n                              ({fieldType})\n                            </span>\n                            {formField.state.meta.isDirty && (\n                              <span className=\"text-xs font-medium text-amber-600\">已修改</span>\n                            )}\n                          </Label>\n                          <div className=\"flex items-center gap-2\">\n                            <div className=\"flex-1\">\n                              <FieldInput\n                                field={field}\n                                value={formField.state.value}\n                                onChange={formField.handleChange}\n                                onBlur={formField.handleBlur}\n                                orpcClient={orpcClient}\n                                baseId={baseId}\n                              />\n                            </div>\n                          </div>\n                          {formField.state.meta.errors.length > 0 && (\n                            <p className=\"text-xs text-destructive\">\n                              {formField.state.meta.errors.join(', ')}\n                            </p>\n                          )}\n                        </div>\n                      );\n                    }}\n                  </form.Field>\n                ))}\n              </div>\n            </div>\n\n            <div className=\"flex justify-between gap-3 p-6 pt-4 border-t bg-background\">\n              <div className=\"flex items-center gap-3\">\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleExplain}\n                  disabled={explainMutation.isPending}\n                  className=\"text-muted-foreground\"\n                >\n                  <Search className=\"mr-1.5 h-3.5 w-3.5\" />\n                  {explainMutation.isPending ? 'Analyzing...' : 'Explain'}\n                </Button>\n                <div className=\"flex items-center gap-1.5\">\n                  <Checkbox\n                    id=\"analyze-mode\"\n                    checked={analyzeMode}\n                    onCheckedChange={(checked) => setAnalyzeMode(!!checked)}\n                  />\n                  <Label\n                    htmlFor=\"analyze-mode\"\n                    className=\"text-xs text-muted-foreground cursor-pointer\"\n                  >\n                    ANALYZE\n                  </Label>\n                </div>\n              </div>\n              <div className=\"flex gap-3\">\n                <Button type=\"button\" variant=\"outline\" onClick={() => handleOpenChange(false)}>\n                  Cancel\n                </Button>\n                <form.Subscribe\n                  selector={(state) => [state.canSubmit, state.isSubmitting] as const}\n                >\n                  {([canSubmit, isSubmitting]) => (\n                    <Button\n                      type=\"submit\"\n                      disabled={!canSubmit || isSubmitting || updateRecordMutation.isPending}\n                    >\n                      {updateRecordMutation.isPending ? 'Updating...' : 'Update'}\n                    </Button>\n                  )}\n                </form.Subscribe>\n              </div>\n            </div>\n          </form>\n        </DialogContent>\n      </Dialog>\n\n      <Dialog open={explainDialogOpen} onOpenChange={setExplainDialogOpen}>\n        <DialogContent className=\"sm:max-w-[90vw] w-[90vw] max-h-[85vh] overflow-hidden flex flex-col\">\n          <DialogHeader>\n            <DialogTitle>Explain: Update Record</DialogTitle>\n            <DialogDescription>Analysis of the update record operation</DialogDescription>\n          </DialogHeader>\n          <div className=\"flex-1 overflow-hidden\">\n            {explainResult && <ExplainResultPanel result={explainResult} />}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/SchemaCheckPanel.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport {\n  PLAYGROUND_DB_URL_QUERY_PARAM,\n  resolvePlaygroundDbUrl,\n} from '@/lib/playground/databaseUrl';\nimport {\n  CheckCircle2,\n  XCircle,\n  AlertTriangle,\n  Loader2,\n  Play,\n  RefreshCcw,\n  Clock,\n  ChevronRight,\n} from 'lucide-react';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport { getFieldTypeIcon } from '@/lib/fieldTypeIcons';\nimport { cn } from '@/lib/utils';\n\nexport type SchemaCheckStatus = 'success' | 'error' | 'warn' | 'pending' | 'running';\n\nexport interface SchemaCheckResult {\n  id: string;\n  fieldId: string;\n  fieldName: string;\n  ruleId: string;\n  ruleDescription: string;\n  status: SchemaCheckStatus;\n  message?: string;\n  details?: {\n    missing?: ReadonlyArray<string>;\n    extra?: ReadonlyArray<string>;\n  };\n  required: boolean;\n  timestamp: number;\n  /** IDs of rules this rule depends on */\n  dependencies?: ReadonlyArray<string>;\n  /** Nesting depth (0 = root, 1+ = nested) */\n  depth?: number;\n}\n\ntype FieldMeta = {\n  id: string;\n  name: string;\n  type: string;\n  relationship?: string;\n  isOneWay?: boolean;\n};\n\ntype SchemaCheckPanelProps = {\n  tableId: string;\n  tableName: string;\n  fields?: ReadonlyArray<FieldMeta>;\n};\n\nconst StatusIcon = ({ status }: { status: SchemaCheckStatus }) => {\n  switch (status) {\n    case 'success':\n      return <CheckCircle2 className=\"h-4 w-4 text-green-500\" />;\n    case 'error':\n      return <XCircle className=\"h-4 w-4 text-destructive\" />;\n    case 'warn':\n      return <AlertTriangle className=\"h-4 w-4 text-yellow-500\" />;\n    case 'running':\n      return <Loader2 className=\"h-4 w-4 text-blue-500 animate-spin\" />;\n    case 'pending':\n    default:\n      return <Clock className=\"h-4 w-4 text-muted-foreground\" />;\n  }\n};\n\nconst StatusBadge = ({ status }: { status: SchemaCheckStatus }) => {\n  const variants: Record<SchemaCheckStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {\n    success: 'secondary',\n    error: 'destructive',\n    warn: 'outline',\n    running: 'default',\n    pending: 'outline',\n  };\n\n  const labels: Record<SchemaCheckStatus, string> = {\n    success: 'Pass',\n    error: 'Fatal',\n    warn: 'Warning',\n    running: 'Running',\n    pending: 'Pending',\n  };\n\n  return (\n    <Badge variant={variants[status]} className=\"h-5 px-1.5 text-[10px] font-normal uppercase\">\n      {labels[status]}\n    </Badge>\n  );\n};\n\n/**\n * Renders a single rule result with appropriate indentation based on depth.\n */\nconst RuleResultItem = ({ result }: { result: SchemaCheckResult }) => {\n  const depth = result.depth ?? 0;\n  const hasParent = depth > 0;\n\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-2 text-xs rounded-md p-2',\n        result.status === 'error'\n          ? 'bg-destructive/10'\n          : result.status === 'warn'\n            ? 'bg-yellow-500/10'\n            : result.status === 'success'\n              ? 'bg-green-500/10'\n              : 'bg-muted/40'\n      )}\n      style={{ marginLeft: `${depth * 16}px` }}\n    >\n      {hasParent && <ChevronRight className=\"h-3 w-3 text-muted-foreground mt-0.5 shrink-0\" />}\n      <StatusIcon status={result.status} />\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          <span className={cn('font-medium', hasParent && 'text-muted-foreground')}>\n            {result.ruleDescription}\n          </span>\n          <StatusBadge status={result.status} />\n          {!result.required && (\n            <Badge variant=\"outline\" className=\"h-4 px-1 text-[9px] font-normal uppercase\">\n              Optional\n            </Badge>\n          )}\n        </div>\n        {result.message && result.message !== 'Schema is valid' && (\n          <div\n            className={cn(\n              'mt-1',\n              result.status === 'error'\n                ? 'text-destructive'\n                : result.status === 'warn'\n                  ? 'text-yellow-600 dark:text-yellow-400'\n                  : 'text-muted-foreground'\n            )}\n          >\n            {result.message}\n          </div>\n        )}\n        {result.details?.missing && result.details.missing.length > 0 && (\n          <div className=\"mt-1 text-destructive text-[11px]\">\n            Missing: {result.details.missing.join(', ')}\n          </div>\n        )}\n        {result.details?.extra && result.details.extra.length > 0 && (\n          <div className=\"mt-1 text-yellow-600 dark:text-yellow-400 text-[11px]\">\n            Extra: {result.details.extra.join(', ')}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport function SchemaCheckPanel({ tableId, tableName, fields }: SchemaCheckPanelProps) {\n  const [results, setResults] = useState<SchemaCheckResult[]>([]);\n  const [isRunning, setIsRunning] = useState(false);\n  const [hasRun, setHasRun] = useState(false);\n  const eventSourceRef = useRef<EventSource | null>(null);\n  const fieldMetaById = useMemo(() => {\n    return (fields ?? []).reduce<Record<string, FieldMeta>>((acc, field) => {\n      acc[field.id] = field;\n      return acc;\n    }, {});\n  }, [fields]);\n\n  const stopCheck = useCallback(() => {\n    if (eventSourceRef.current) {\n      eventSourceRef.current.close();\n      eventSourceRef.current = null;\n    }\n    setIsRunning(false);\n  }, []);\n\n  const startCheck = useCallback(() => {\n    stopCheck();\n    setResults([]);\n    setIsRunning(true);\n    setHasRun(true);\n\n    const dbUrl = resolvePlaygroundDbUrl();\n    const baseUrl = `/api/schema/${tableId}/check/stream`;\n    const eventSourceUrl = dbUrl\n      ? `${baseUrl}?${new URLSearchParams({\n          [PLAYGROUND_DB_URL_QUERY_PARAM]: dbUrl,\n        }).toString()}`\n      : baseUrl;\n    const eventSource = new EventSource(eventSourceUrl);\n    eventSourceRef.current = eventSource;\n\n    eventSource.onmessage = (event) => {\n      try {\n        const result = JSON.parse(event.data) as SchemaCheckResult;\n\n        // Skip connection and completion messages for display\n        if (result.id === 'connect') {\n          return;\n        }\n\n        if (result.id === 'complete') {\n          setIsRunning(false);\n          eventSource.close();\n          return;\n        }\n\n        setResults((prev) => {\n          // If running status, update existing or add new\n          if (result.status === 'running') {\n            const existingIndex = prev.findIndex((r) => r.id === result.id);\n            if (existingIndex >= 0) {\n              const updated = [...prev];\n              updated[existingIndex] = result;\n              return updated;\n            }\n            return [...prev, result];\n          }\n\n          // For final status, update the existing running entry\n          const existingIndex = prev.findIndex((r) => r.id === result.id);\n          if (existingIndex >= 0) {\n            const updated = [...prev];\n            updated[existingIndex] = result;\n            return updated;\n          }\n\n          return [...prev, result];\n        });\n      } catch (e) {\n        console.error('Failed to parse SSE message:', e);\n      }\n    };\n\n    eventSource.onerror = () => {\n      setIsRunning(false);\n      eventSource.close();\n    };\n  }, [tableId, stopCheck]);\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      if (eventSourceRef.current) {\n        eventSourceRef.current.close();\n      }\n    };\n  }, []);\n\n  // Auto-start when tableId changes or on mount\n  const hasStartedRef = useRef(false);\n  useEffect(() => {\n    hasStartedRef.current = false;\n    setResults([]);\n    setHasRun(false);\n    stopCheck();\n  }, [tableId, stopCheck]);\n\n  useEffect(() => {\n    // Auto-start the check when entering the tab (only once per tableId)\n    if (!hasStartedRef.current && !isRunning && !hasRun) {\n      hasStartedRef.current = true;\n      const timer = setTimeout(() => {\n        startCheck();\n      }, 100);\n      return () => clearTimeout(timer);\n    }\n  }, [tableId, isRunning, hasRun, startCheck]);\n\n  // Group results by field\n  const groupedResults = results.reduce<Record<string, SchemaCheckResult[]>>((acc, result) => {\n    const key = result.fieldId || 'system';\n    if (!acc[key]) {\n      acc[key] = [];\n    }\n    acc[key].push(result);\n    return acc;\n  }, {});\n\n  // Summary counts\n  const summary = {\n    total: results.filter((r) => r.status !== 'running').length,\n    success: results.filter((r) => r.status === 'success').length,\n    error: results.filter((r) => r.status === 'error').length,\n    warn: results.filter((r) => r.status === 'warn').length,\n    running: results.filter((r) => r.status === 'running').length,\n  };\n\n  return (\n    <section className=\"space-y-4 min-w-0\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex flex-wrap items-center gap-2 text-sm font-semibold\">\n          <CheckCircle2 className=\"h-4 w-4 text-muted-foreground\" />\n          Schema Check\n          {hasRun && (\n            <>\n              <Badge\n                variant=\"secondary\"\n                className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n              >\n                {summary.total} checks\n              </Badge>\n              {summary.success > 0 && (\n                <Badge\n                  variant=\"outline\"\n                  className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider text-green-600\"\n                >\n                  ✓ {summary.success}\n                </Badge>\n              )}\n              {summary.error > 0 && (\n                <Badge\n                  variant=\"destructive\"\n                  className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n                >\n                  ✗ {summary.error}\n                </Badge>\n              )}\n              {summary.warn > 0 && (\n                <Badge\n                  variant=\"outline\"\n                  className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider text-yellow-600\"\n                >\n                  ⚠ {summary.warn}\n                </Badge>\n              )}\n            </>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {isRunning ? (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"h-8 text-xs font-normal\"\n              onClick={stopCheck}\n            >\n              <XCircle className=\"mr-1.5 h-3.5 w-3.5\" />\n              Stop\n            </Button>\n          ) : (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"h-8 text-xs font-normal\"\n              onClick={startCheck}\n            >\n              {hasRun ? (\n                <RefreshCcw className=\"mr-1.5 h-3.5 w-3.5\" />\n              ) : (\n                <Play className=\"mr-1.5 h-3.5 w-3.5\" />\n              )}\n              {hasRun ? 'Re-check' : 'Start Check'}\n            </Button>\n          )}\n        </div>\n      </div>\n\n      {!hasRun ? (\n        <div className=\"rounded-lg border border-border/60 bg-muted/20 p-6 text-center\">\n          <div className=\"text-sm text-muted-foreground\">\n            Click \"Start Check\" to validate the schema for table{' '}\n            <span className=\"font-medium text-foreground\">{tableName}</span>.\n          </div>\n          <div className=\"mt-2 text-xs text-muted-foreground\">\n            This will check all field rules including columns, indexes, foreign keys, and more.\n          </div>\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {Object.entries(groupedResults).map(([fieldId, fieldResults]) => {\n            const isSystemField = fieldId === 'system';\n            const fieldMeta = fieldMetaById[fieldId];\n            const fieldName =\n              fieldMeta?.name || fieldResults[0]?.fieldName || (isSystemField ? 'System' : fieldId);\n            const fieldType = fieldMeta?.type;\n            const fieldRelationship = fieldMeta?.relationship;\n            const FieldIcon = fieldType ? getFieldTypeIcon(fieldType) : null;\n            const hasError = fieldResults.some((r) => r.status === 'error');\n            const hasWarn = fieldResults.some((r) => r.status === 'warn');\n            const allSuccess = fieldResults.every(\n              (r) => r.status === 'success' || r.status === 'running'\n            );\n\n            return (\n              <div\n                key={fieldId}\n                className={cn(\n                  'rounded-lg border p-3 space-y-2',\n                  hasError\n                    ? 'border-destructive/40 bg-destructive/5'\n                    : hasWarn\n                      ? 'border-yellow-500/40 bg-yellow-500/5'\n                      : allSuccess\n                        ? 'border-green-500/40 bg-green-500/5'\n                        : 'border-border/60 bg-muted/20'\n                )}\n              >\n                <div className=\"flex items-center gap-2 text-sm font-medium\">\n                  {hasError ? (\n                    <XCircle className=\"h-4 w-4 text-destructive\" />\n                  ) : hasWarn ? (\n                    <AlertTriangle className=\"h-4 w-4 text-yellow-500\" />\n                  ) : allSuccess ? (\n                    <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n                  ) : (\n                    <Loader2 className=\"h-4 w-4 text-muted-foreground animate-spin\" />\n                  )}\n                  {FieldIcon ? <FieldIcon className=\"h-4 w-4 text-muted-foreground\" /> : null}\n                  {fieldType === 'link' && fieldRelationship && fieldId !== 'system' ? (\n                    <LinkFieldLabel\n                      name={fieldName || 'System'}\n                      fieldId={fieldId}\n                      relationship={fieldRelationship}\n                      isOneWay={fieldMeta?.isOneWay ?? false}\n                    />\n                  ) : (\n                    <span>{fieldName || 'System'}</span>\n                  )}\n                  {fieldType ? (\n                    <Badge\n                      variant=\"outline\"\n                      className=\"h-5 px-1.5 text-[10px] font-normal uppercase\"\n                    >\n                      {fieldType}\n                    </Badge>\n                  ) : null}\n                  <span className=\"text-xs text-muted-foreground font-mono\">\n                    {fieldId !== 'system' && fieldId ? `(${fieldId})` : ''}\n                  </span>\n                </div>\n\n                <div className=\"space-y-1.5 pl-6\">\n                  {fieldResults.map((result) => (\n                    <RuleResultItem key={result.id} result={result} />\n                  ))}\n                </div>\n              </div>\n            );\n          })}\n\n          {isRunning && results.length === 0 && (\n            <div className=\"flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              Starting schema check...\n            </div>\n          )}\n\n          {!isRunning && results.length === 0 && hasRun && (\n            <div className=\"text-center py-8 text-sm text-muted-foreground\">\n              No results yet. The table may have no fields to check.\n            </div>\n          )}\n        </div>\n      )}\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/TableMetaPage.tsx",
    "content": "import {\n  mapTableToDto,\n  type IListTableRecordsPaginationDto,\n  type ITableDto,\n  type ITableRecordDto,\n} from '@teable/v2-contract-http';\nimport type {\n  Field,\n  ITableFieldPersistenceDTO,\n  ITablePersistenceDTO,\n  ITableRecordRealtimeDTO,\n  LinkField,\n  Table as TableAggregate,\n  View,\n  ViewColumnMetaValue,\n} from '@teable/v2-core';\nimport type { TableTemplateDefinition } from '@teable/v2-table-templates';\nimport {\n  Copy,\n  ExternalLink,\n  FileJson,\n  MoreVertical,\n  Pencil,\n  RefreshCcw,\n  Table as TableIcon,\n  Trash2,\n  TriangleAlert,\n} from 'lucide-react';\nimport type { ColumnDef, Row, RowSelectionState } from '@tanstack/react-table';\nimport { Link } from '@tanstack/react-router';\nimport { parseAsStringEnum, useQueryState } from 'nuqs';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { JsonView } from 'react-json-view-lite';\nimport { toast } from 'sonner';\nimport { useCopyToClipboard } from 'usehooks-ts';\n\nimport { CreateTableDropdown } from '@/components/playground/CreateTableDropdown';\nimport { FieldCreateDialog } from '@/components/playground/FieldCreateDialog';\nimport { RecordCreateDialog } from '@/components/playground/RecordCreateDialog';\nimport { RecordDeleteDialog } from '@/components/playground/RecordDeleteDialog';\nimport { RecordUpdateDialog } from '@/components/playground/RecordUpdateDialog';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { DataTable } from '@/components/ui/data-table';\nimport {\n  Table as UITable,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport { cn } from '@/lib/utils';\nimport type { ShareDbDocStatus } from '@/lib/shareDb';\nimport { usePlaygroundEnvironment } from '@/lib/playground/environment';\nimport { renderFieldOptions } from './fieldOptionsVisitor';\nimport { formatRecordValue, stringifyRecordValue } from './recordValueVisitor';\nimport { SchemaCheckPanel } from './SchemaCheckPanel';\nimport { MetaCheckPanel } from './MetaCheckPanel';\nimport { UnderlyingDataPanel } from './UnderlyingDataPanel';\nimport { FieldLabel } from '@/components/playground/LinkFieldLabel';\n\nconst getViewColumnMeta = (\n  view: View\n): { value: ViewColumnMetaValue | null; error: string | null } => {\n  const result = view.columnMeta();\n  if (result.isOk()) {\n    return { value: result.value.toDto(), error: null };\n  }\n  return { value: null, error: result.error.message };\n};\n\nconst sortColumnMeta = (\n  columnMeta: ViewColumnMetaValue\n): Array<[string, ViewColumnMetaValue[string]]> =>\n  Object.entries(columnMeta).sort(([, left], [, right]) => (left.order ?? 0) - (right.order ?? 0));\n\nconst formatOptionalBoolean = (value: boolean | undefined): string => {\n  if (value === undefined) return '-';\n  return value ? 'true' : 'false';\n};\n\nconst formatOptionalNumber = (value: number | null | undefined): string => {\n  if (value === undefined || value === null) return '-';\n  return value.toString();\n};\n\nconst formatOptionalString = (value: string | null | undefined): string => {\n  if (value === undefined || value === null) return '-';\n  return value;\n};\n\nconst formatColumnMetaExtras = (entry: ViewColumnMetaValue[string]): string => {\n  const knownKeys = new Set(['order', 'visible', 'hidden', 'required', 'width', 'statisticFunc']);\n  const extra = Object.keys(entry).reduce<Record<string, unknown>>((acc, key) => {\n    if (!knownKeys.has(key)) {\n      acc[key] = entry[key];\n    }\n    return acc;\n  }, {});\n\n  if (!Object.keys(extra).length) return '-';\n  return JSON.stringify(extra);\n};\n\nconst getDbFieldName = (field: Field): string | null => {\n  const nameResult = field.dbFieldName().andThen((name) => name.value());\n  return nameResult.isOk() ? nameResult.value : null;\n};\n\nconst getDbTableName = (table: TableAggregate): string | null => {\n  const nameResult = table.dbTableName().andThen((name) => name.value());\n  return nameResult.isOk() ? nameResult.value : null;\n};\n\nconst tableTabValues = [\n  'table',\n  'records',\n  'json',\n  'realtime',\n  'schema',\n  'meta',\n  'underlying',\n] as const;\ntype TableMetaTab = (typeof tableTabValues)[number];\n\nconst isTableMetaTab = (value: string): value is TableMetaTab =>\n  tableTabValues.includes(value as TableMetaTab);\n\nconst shouldExpandJsonNode = (level: number) => level < 2;\n\nconst copyTableJson = async (\n  table: TableAggregate,\n  copyToClipboard: (value: string) => Promise<boolean>\n) => {\n  const tableDtoResult = mapTableToDto(table);\n  if (tableDtoResult.isErr()) {\n    toast.error('Unable to prepare table JSON', { description: tableDtoResult.error.message });\n    return;\n  }\n\n  const didCopy = await copyToClipboard(JSON.stringify(tableDtoResult.value, null, 2));\n  if (didCopy) {\n    toast.success('Copied table JSON');\n  } else {\n    toast.error('Copy failed');\n  }\n};\n\ntype TableMetaPageProps = {\n  baseId: string;\n  tableId: string;\n  table: TableAggregate | null;\n  eventCount: number | null;\n  realtimeSnapshot: ITablePersistenceDTO | null;\n  realtimeStatus: ShareDbDocStatus;\n  realtimeError: string | null;\n  realtimeFieldSnapshots: ReadonlyArray<ITableFieldPersistenceDTO>;\n  realtimeFieldStatus: ShareDbDocStatus;\n  realtimeFieldError: string | null;\n  realtimeRecordSnapshots: ReadonlyArray<ITableRecordRealtimeDTO>;\n  realtimeRecordStatus: ShareDbDocStatus;\n  realtimeRecordError: string | null;\n  isInitialLoading: boolean;\n  isLoading: boolean;\n  records: ReadonlyArray<ITableRecordDto> | null;\n  recordsPagination: IListTableRecordsPaginationDto | null;\n  recordsError: string | null;\n  isRecordsLoading: boolean;\n  isRecordsFetching: boolean;\n  isDeletingRecord: boolean;\n  isCreating: boolean;\n  isDeleting: boolean;\n  isDeletingField: boolean;\n  isRenaming: boolean;\n  errorMessage: string | null;\n  onRefresh: () => void;\n  onFieldCreated: () => void;\n  onRecordCreated?: () => void;\n  onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n  onImportCsv?: (data: { tableName: string; csvData?: string; csvUrl?: string }) => Promise<void>;\n  onDelete: () => void;\n  onDeleteField: (fieldId: string) => void;\n  onDeleteRecords: (recordIds: string[]) => void;\n  onRename: (name: string) => void;\n};\n\nexport function TableMetaPage({\n  baseId,\n  tableId,\n  table,\n  realtimeSnapshot,\n  realtimeStatus,\n  realtimeError,\n  realtimeFieldSnapshots,\n  realtimeFieldStatus,\n  realtimeFieldError,\n  realtimeRecordSnapshots,\n  realtimeRecordStatus,\n  realtimeRecordError,\n  isInitialLoading,\n  isLoading,\n  records,\n  recordsPagination,\n  recordsError,\n  isRecordsLoading,\n  isRecordsFetching,\n  isDeletingRecord,\n  isCreating,\n  isDeleting,\n  isDeletingField,\n  isRenaming,\n  errorMessage,\n  onRefresh,\n  onFieldCreated,\n  onRecordCreated,\n  onPaginationChange,\n  templates,\n  onCreateTemplate,\n  onImportCsv,\n  onDelete,\n  onDeleteField,\n  onDeleteRecords,\n  onRename,\n}: TableMetaPageProps) {\n  const [activeTab, setActiveTab] = useQueryState(\n    'tab',\n    parseAsStringEnum<TableMetaTab>([...tableTabValues]).withDefault('table')\n  );\n  const tableDtoResult = useMemo(() => (table ? mapTableToDto(table) : null), [table]);\n  const tableJson = tableDtoResult?.isOk() ? tableDtoResult.value : null;\n  const tableJsonError = tableDtoResult?.isErr() ? tableDtoResult.error.message : null;\n\n  const handleTabChange = (value: string) => {\n    if (!isTableMetaTab(value)) return;\n    void setActiveTab(value);\n  };\n\n  return (\n    <div className=\"flex flex-1 flex-col overflow-hidden h-full\">\n      <PlaygroundHeader\n        baseId={baseId}\n        table={table}\n        isLoading={isLoading}\n        isCreating={isCreating}\n        isDeleting={isDeleting}\n        isRenaming={isRenaming}\n        onRefresh={onRefresh}\n        onFieldCreated={onFieldCreated}\n        templates={templates}\n        onCreateTemplate={onCreateTemplate}\n        onImportCsv={onImportCsv}\n        onDelete={onDelete}\n        onRename={onRename}\n      />\n      <div className=\"flex-1 min-h-0 flex flex-col\">\n        {errorMessage ? (\n          <div className=\"px-6 pt-6\">\n            <PlaygroundErrorState message={errorMessage} />\n          </div>\n        ) : null}\n\n        {isInitialLoading ? (\n          <ScrollArea className=\"flex-1\">\n            <div className=\"px-6 py-6\">\n              <PlaygroundLoadingState />\n            </div>\n          </ScrollArea>\n        ) : !table ? (\n          <ScrollArea className=\"flex-1\">\n            <div className=\"px-6 py-6\">\n              <PlaygroundEmptyState\n                isCreating={isCreating}\n                templates={templates}\n                onCreateTemplate={onCreateTemplate}\n                onImportCsv={onImportCsv}\n              />\n            </div>\n          </ScrollArea>\n        ) : (\n          <Tabs\n            value={activeTab}\n            onValueChange={handleTabChange}\n            className=\"flex-1 flex flex-col min-h-0 animate-fade-in\"\n          >\n            <div className=\"px-6 pt-6\">\n              <TabsList className=\"h-9 w-fit rounded-full border border-border/60 bg-background/70 p-1 shadow-sm\">\n                <TabsTrigger\n                  value=\"table\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Table\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"records\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Records\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"json\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  JSON\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"realtime\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Realtime\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"schema\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Schema Check\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"meta\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Meta Check\n                </TabsTrigger>\n                <TabsTrigger\n                  value=\"underlying\"\n                  className=\"h-7 rounded-full px-4 text-xs font-medium text-muted-foreground data-[state=active]:bg-background data-[state=active]:shadow-sm data-[state=active]:text-foreground transition-all duration-200\"\n                >\n                  Underlying\n                </TabsTrigger>\n              </TabsList>\n            </div>\n            <TabsContent value=\"table\" className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\">\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <PlaygroundMetaLayout\n                    table={table}\n                    baseId={baseId}\n                    tableId={tableId}\n                    isLoading={isLoading}\n                    isDeletingField={isDeletingField}\n                    onDeleteField={onDeleteField}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n            <TabsContent\n              value=\"records\"\n              className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden flex flex-col\"\n            >\n              <div className=\"flex-1 min-h-0 px-6 py-6\">\n                <PlaygroundRecordsLayout\n                  baseId={baseId}\n                  table={table}\n                  records={records}\n                  recordsPagination={recordsPagination}\n                  recordsError={recordsError}\n                  isRecordsLoading={isRecordsLoading}\n                  isRecordsFetching={isRecordsFetching}\n                  isDeletingRecord={isDeletingRecord}\n                  onRecordCreated={onRecordCreated}\n                  onPaginationChange={onPaginationChange}\n                  onDeleteRecords={onDeleteRecords}\n                />\n              </div>\n            </TabsContent>\n            <TabsContent value=\"json\" className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\">\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <PlaygroundJsonLayout\n                    table={table}\n                    tableJson={tableJson}\n                    tableJsonError={tableJsonError}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n            <TabsContent\n              value=\"realtime\"\n              className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\"\n            >\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <PlaygroundRealtimeLayout\n                    realtimeSnapshot={realtimeSnapshot}\n                    realtimeStatus={realtimeStatus}\n                    realtimeError={realtimeError}\n                    realtimeFieldSnapshots={realtimeFieldSnapshots}\n                    realtimeFieldStatus={realtimeFieldStatus}\n                    realtimeFieldError={realtimeFieldError}\n                    realtimeRecordSnapshots={realtimeRecordSnapshots}\n                    realtimeRecordStatus={realtimeRecordStatus}\n                    realtimeRecordError={realtimeRecordError}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n            <TabsContent\n              value=\"schema\"\n              className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\"\n            >\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <SchemaCheckPanel\n                    tableId={table.id().toString()}\n                    tableName={table.name().toString()}\n                    fields={table.getFields().map((field) => {\n                      const fieldType = field.type().toString();\n                      const baseMeta = {\n                        id: field.id().toString(),\n                        name: field.name().toString(),\n                        type: fieldType,\n                      };\n                      if (fieldType === 'link') {\n                        const linkField = field as LinkField;\n                        return {\n                          ...baseMeta,\n                          relationship: linkField.relationship().toString(),\n                          isOneWay: linkField.isOneWay(),\n                        };\n                      }\n                      return baseMeta;\n                    })}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n            <TabsContent value=\"meta\" className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\">\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <MetaCheckPanel\n                    tableId={table.id().toString()}\n                    tableName={table.name().toString()}\n                    fields={table.getFields().map((field) => {\n                      const fieldType = field.type().toString();\n                      const baseMeta = {\n                        id: field.id().toString(),\n                        name: field.name().toString(),\n                        type: fieldType,\n                      };\n                      if (fieldType === 'link') {\n                        const linkField = field as LinkField;\n                        return {\n                          ...baseMeta,\n                          relationship: linkField.relationship().toString(),\n                          isOneWay: linkField.isOneWay(),\n                        };\n                      }\n                      return baseMeta;\n                    })}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n            <TabsContent\n              value=\"underlying\"\n              className=\"flex-1 min-h-0 mt-0 outline-none overflow-hidden\"\n            >\n              <ScrollArea className=\"h-full w-full\">\n                <div className=\"px-6 py-6\">\n                  <UnderlyingDataPanel\n                    tableId={table.id().toString()}\n                    tableName={table.name().toString()}\n                  />\n                </div>\n              </ScrollArea>\n            </TabsContent>\n          </Tabs>\n        )}\n      </div>\n    </div>\n  );\n}\n\ntype PlaygroundHeaderProps = {\n  baseId: string;\n  table: TableAggregate | null;\n  isLoading: boolean;\n  isCreating: boolean;\n  isDeleting: boolean;\n  isRenaming: boolean;\n  onRefresh: () => void;\n  onFieldCreated: () => void;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n  onImportCsv?: (data: { tableName: string; csvData?: string; csvUrl?: string }) => Promise<void>;\n  onDelete: () => void;\n  onRename: (name: string) => void;\n};\n\nfunction PlaygroundHeader({\n  baseId,\n  table,\n  isLoading,\n  isCreating,\n  isDeleting,\n  isRenaming,\n  onRefresh,\n  onFieldCreated,\n  templates,\n  onCreateTemplate,\n  onImportCsv,\n  onDelete,\n  onRename,\n}: PlaygroundHeaderProps) {\n  const [deleteOpen, setDeleteOpen] = useState(false);\n  const [renameOpen, setRenameOpen] = useState(false);\n  const [renameValue, setRenameValue] = useState('');\n  const canDelete = !!table && !isDeleting;\n  const currentName = table ? table.name().toString() : '';\n  const tableName = table ? table.name().toString() : 'Table';\n  const fieldCount = table ? table.getFields().length : null;\n  const trimmedRename = renameValue.trim();\n  const canRename =\n    !!table && trimmedRename.length > 0 && trimmedRename !== currentName && !isRenaming;\n  const appBaseUrl = import.meta.env.VITE_APP_URL?.trim();\n  const appTableUrl =\n    table && appBaseUrl\n      ? (() => {\n          const resolvedTableId = table.id().toString();\n          try {\n            return new URL(`/base/${baseId}/table/${resolvedTableId}`, appBaseUrl).toString();\n          } catch {\n            return null;\n          }\n        })()\n      : null;\n\n  const handleDeleteConfirm = () => {\n    if (!table) return;\n    onDelete();\n    setDeleteOpen(false);\n  };\n\n  const handleRenameConfirm = () => {\n    if (!table) return;\n    if (!canRename) return;\n    onRename(trimmedRename);\n    setRenameOpen(false);\n  };\n\n  useEffect(() => {\n    if (!renameOpen) return;\n    if (!table) return;\n    setRenameValue(table.name().toString());\n  }, [renameOpen, table]);\n\n  return (\n    <header className=\"relative flex flex-wrap items-center justify-between gap-4 border-b border-border/60 bg-background/80 px-5 py-4 backdrop-blur\">\n      <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-muted/35 to-transparent\" />\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.2]\" />\n      <div className=\"relative flex w-full flex-wrap items-center justify-between gap-4\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <SidebarTrigger className=\"-ml-1\" />\n          <div className=\"h-6 w-px bg-gradient-to-b from-transparent via-border to-transparent\" />\n          <div className=\"flex flex-wrap items-center gap-2.5\">\n            <div className=\"flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/20\">\n              <TableIcon className=\"h-4 w-4 text-primary\" />\n            </div>\n            <span className=\"text-base font-semibold tracking-tight\">{tableName}</span>\n            {appTableUrl ? (\n              <Button variant=\"ghost\" size=\"icon-sm\" className=\"h-6 w-6\" asChild>\n                <a href={appTableUrl} target=\"_blank\" rel=\"noreferrer\" title=\"Open in App\">\n                  <ExternalLink className=\"h-3.5 w-3.5\" />\n                </a>\n              </Button>\n            ) : null}\n          </div>\n          <div className=\"ml-2 flex flex-wrap items-center gap-1.5\">\n            {fieldCount !== null ? (\n              <Badge\n                variant=\"secondary\"\n                className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n              >\n                {fieldCount} fields\n              </Badge>\n            ) : null}\n          </div>\n        </div>\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-9 text-xs font-normal\"\n            disabled={!table || isLoading}\n            onClick={onRefresh}\n          >\n            <RefreshCcw className=\"mr-1.5 h-3.5 w-3.5\" />\n            Refresh\n          </Button>\n          {table && (\n            <FieldCreateDialog\n              baseId={baseId}\n              tableId={table.id().toString()}\n              onSuccess={onFieldCreated}\n            />\n          )}\n          <CreateTableDropdown\n            templates={templates}\n            isCreating={isCreating}\n            onSelect={onCreateTemplate}\n            onImportCsv={onImportCsv}\n            label=\"Create table\"\n            className=\"h-9\"\n          />\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                className=\"h-9 w-9\"\n                aria-label=\"Table actions\"\n                disabled={!table}\n              >\n                <MoreVertical className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" className=\"w-40\">\n              <DropdownMenuItem\n                disabled={!table || isRenaming}\n                className=\"text-xs py-1.5\"\n                onSelect={(event) => {\n                  event.preventDefault();\n                  setRenameOpen(true);\n                }}\n              >\n                <Pencil className=\"mr-2 h-3.5 w-3.5\" />\n                Rename table\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"text-xs py-1.5 text-destructive focus:text-destructive\"\n                disabled={!canDelete}\n                onSelect={(event) => {\n                  event.preventDefault();\n                  setDeleteOpen(true);\n                }}\n              >\n                <Trash2 className=\"mr-2 h-3.5 w-3.5\" />\n                Delete table\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </div>\n      <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n        <AlertDialogContent className=\"max-w-sm\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete table</AlertDialogTitle>\n            <AlertDialogDescription>\n              {table\n                ? `Delete \"${table.name().toString()}\"? This will remove its schema and metadata.`\n                : 'Delete this table?'}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              className=\"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n              onClick={handleDeleteConfirm}\n              disabled={isDeleting}\n            >\n              {isDeleting ? 'Deleting...' : 'Delete'}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n      <AlertDialog open={renameOpen} onOpenChange={setRenameOpen}>\n        <AlertDialogContent className=\"max-w-sm\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>Rename table</AlertDialogTitle>\n            <AlertDialogDescription>\n              Choose a new name for this table. Names must be between 1 and 255 characters.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <div className=\"space-y-2\">\n            <Input\n              value={renameValue}\n              onChange={(event) => setRenameValue(event.target.value)}\n              maxLength={255}\n              placeholder=\"Table name\"\n              autoFocus\n            />\n          </div>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isRenaming}>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={handleRenameConfirm} disabled={!canRename}>\n              {isRenaming ? 'Renaming...' : 'Rename'}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </header>\n  );\n}\n\ntype PlaygroundErrorStateProps = {\n  message: string;\n};\n\nfunction PlaygroundErrorState({ message }: PlaygroundErrorStateProps) {\n  return (\n    <Card className=\"border-destructive/40 bg-destructive/10\">\n      <CardHeader className=\"flex flex-row items-center gap-3\">\n        <TriangleAlert className=\"h-4 w-4 text-destructive\" />\n        <CardTitle className=\"text-base text-destructive\">{message}</CardTitle>\n      </CardHeader>\n    </Card>\n  );\n}\n\nfunction PlaygroundLoadingState() {\n  return (\n    <div className=\"space-y-3 min-w-0\">\n      <div className=\"flex items-center gap-3\">\n        <Skeleton className=\"h-5 w-5 rounded-full\" />\n        <Skeleton className=\"h-5 w-44\" />\n        <Skeleton className=\"h-5 w-16 rounded-full\" />\n      </div>\n      <div className=\"space-y-3\">\n        <div className=\"grid grid-cols-6 gap-3\">\n          {Array.from({ length: 6 }).map((_, index) => (\n            <Skeleton key={`header-skeleton-${index}`} className=\"h-4 w-full\" />\n          ))}\n        </div>\n        <div className=\"space-y-3\">\n          {Array.from({ length: 6 }).map((_, rowIndex) => (\n            <div key={`row-skeleton-${rowIndex}`} className=\"grid grid-cols-6 gap-3\">\n              {Array.from({ length: 6 }).map((_, colIndex) => (\n                <Skeleton key={`cell-skeleton-${rowIndex}-${colIndex}`} className=\"h-4 w-full\" />\n              ))}\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\ntype PlaygroundEmptyStateProps = {\n  isCreating: boolean;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n  onImportCsv?: (data: { tableName: string; csvData?: string; csvUrl?: string }) => Promise<void>;\n};\n\nfunction PlaygroundEmptyState({\n  isCreating,\n  templates,\n  onCreateTemplate,\n  onImportCsv,\n}: PlaygroundEmptyStateProps) {\n  return (\n    <Card className=\"relative overflow-hidden border border-dashed border-border/70 bg-background/80\">\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.25]\" />\n      <CardHeader className=\"relative pb-2 pt-8 text-center\">\n        <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 ring-2 ring-primary/20 animate-float\">\n          <TableIcon className=\"h-8 w-8 text-primary\" />\n        </div>\n        <CardTitle className=\"text-xl font-bold tracking-tight text-foreground\">\n          Build a table in seconds\n        </CardTitle>\n      </CardHeader>\n      <CardContent className=\"relative space-y-6 pb-8 text-center\">\n        <p className=\"mx-auto max-w-md text-sm leading-relaxed text-muted-foreground\">\n          This playground uses Teable v2 core with a fixed actor. Pick a template to create a table,\n          view its schema, or switch the base ID from the sidebar.\n        </p>\n        <div className=\"flex justify-center\">\n          <CreateTableDropdown\n            templates={templates}\n            isCreating={isCreating}\n            onSelect={onCreateTemplate}\n            onImportCsv={onImportCsv}\n            label=\"Create table\"\n          />\n        </div>\n        <div className=\"flex flex-wrap items-center justify-center gap-6 pt-4 text-[11px] text-muted-foreground/70\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-2 w-2 rounded-full bg-emerald-500/60\" />\n            <span>Templates available</span>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-2 w-2 rounded-full bg-sky-500/60\" />\n            <span>CSV import supported</span>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\ntype PlaygroundMetaLayoutProps = {\n  table: TableAggregate;\n  baseId: string;\n  tableId: string;\n  isLoading: boolean;\n  isDeletingField: boolean;\n  onDeleteField: (fieldId: string) => void;\n};\n\nfunction PlaygroundMetaLayout({\n  table,\n  baseId,\n  tableId,\n  isLoading,\n  isDeletingField,\n  onDeleteField,\n}: PlaygroundMetaLayoutProps) {\n  return (\n    <div className=\"space-y-6 min-w-0\">\n      <TableSchemaCard\n        table={table}\n        baseId={baseId}\n        tableId={tableId}\n        isDeletingField={isDeletingField}\n        onDeleteField={onDeleteField}\n      />\n      <TableViewsCard views={table.views()} fields={table.getFields()} />\n      <TableConnectionCard baseId={baseId} tableId={tableId} table={table} isLoading={isLoading} />\n    </div>\n  );\n}\n\ntype PlaygroundRecordsLayoutProps = {\n  baseId: string;\n  table: TableAggregate;\n  records: ReadonlyArray<ITableRecordDto> | null;\n  recordsPagination: IListTableRecordsPaginationDto | null;\n  recordsError: string | null;\n  isRecordsLoading: boolean;\n  isRecordsFetching: boolean;\n  isDeletingRecord: boolean;\n  onRecordCreated?: () => void;\n  onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void;\n  onDeleteRecords: (recordIds: string[]) => void;\n};\n\nfunction PlaygroundRecordsLayout({\n  baseId,\n  table,\n  records,\n  recordsPagination,\n  recordsError,\n  isRecordsLoading,\n  isRecordsFetching,\n  isDeletingRecord,\n  onRecordCreated,\n  onPaginationChange,\n  onDeleteRecords,\n}: PlaygroundRecordsLayoutProps) {\n  return (\n    <div className=\"flex flex-col h-full min-h-0 space-y-6\">\n      <TableRecordsCard\n        baseId={baseId}\n        table={table}\n        records={records}\n        recordsPagination={recordsPagination}\n        recordsError={recordsError}\n        isRecordsLoading={isRecordsLoading}\n        isRecordsFetching={isRecordsFetching}\n        isDeletingRecord={isDeletingRecord}\n        onRecordCreated={onRecordCreated}\n        onPaginationChange={onPaginationChange}\n        onDeleteRecords={onDeleteRecords}\n      />\n    </div>\n  );\n}\n\ntype PlaygroundJsonLayoutProps = {\n  table: TableAggregate;\n  tableJson: ITableDto | null;\n  tableJsonError: string | null;\n};\n\nfunction PlaygroundJsonLayout({ table, tableJson, tableJsonError }: PlaygroundJsonLayoutProps) {\n  return (\n    <div className=\"space-y-6 min-w-0\">\n      <TableJsonCard table={table} tableJson={tableJson} tableJsonError={tableJsonError} />\n    </div>\n  );\n}\n\ntype PlaygroundRealtimeLayoutProps = {\n  realtimeSnapshot: ITablePersistenceDTO | null;\n  realtimeStatus: ShareDbDocStatus;\n  realtimeError: string | null;\n  realtimeFieldSnapshots: ReadonlyArray<ITableFieldPersistenceDTO>;\n  realtimeFieldStatus: ShareDbDocStatus;\n  realtimeFieldError: string | null;\n  realtimeRecordSnapshots: ReadonlyArray<ITableRecordRealtimeDTO>;\n  realtimeRecordStatus: ShareDbDocStatus;\n  realtimeRecordError: string | null;\n};\n\nfunction PlaygroundRealtimeLayout({\n  realtimeSnapshot,\n  realtimeStatus,\n  realtimeError,\n  realtimeFieldSnapshots,\n  realtimeFieldStatus,\n  realtimeFieldError,\n  realtimeRecordSnapshots,\n  realtimeRecordStatus,\n  realtimeRecordError,\n}: PlaygroundRealtimeLayoutProps) {\n  return (\n    <div className=\"space-y-6 min-w-0\">\n      <RealtimeSnapshotCard\n        snapshot={realtimeSnapshot}\n        status={realtimeStatus}\n        error={realtimeError}\n        title=\"ShareDB Table Snapshot\"\n      />\n      <RealtimeFieldsCard\n        snapshots={realtimeFieldSnapshots}\n        status={realtimeFieldStatus}\n        error={realtimeFieldError}\n      />\n      <RealtimeRecordsCard\n        snapshots={realtimeRecordSnapshots}\n        status={realtimeRecordStatus}\n        error={realtimeRecordError}\n      />\n    </div>\n  );\n}\n\ntype TableSchemaCardProps = {\n  table: TableAggregate;\n  baseId: string;\n  tableId: string;\n  isDeletingField: boolean;\n  onDeleteField: (fieldId: string) => void;\n};\n\nfunction TableSchemaCard({\n  table,\n  baseId,\n  tableId,\n  isDeletingField,\n  onDeleteField,\n}: TableSchemaCardProps) {\n  const fields = table.getFields();\n  const primaryFieldId = table.primaryFieldId();\n  const [, copyToClipboard] = useCopyToClipboard();\n  const [deleteOpen, setDeleteOpen] = useState(false);\n  const [deleteTarget, setDeleteTarget] = useState<Field | null>(null);\n  const canDeleteField = !!deleteTarget && !isDeletingField;\n  const handleCopyTableJson = () => {\n    void copyTableJson(table, copyToClipboard);\n  };\n  const handleDeleteConfirm = () => {\n    if (!deleteTarget) return;\n    onDeleteField(deleteTarget.id().toString());\n    setDeleteOpen(false);\n  };\n  const handleCopyFieldPath = (fieldId: string) => {\n    const path = `${baseId}/${tableId}/${fieldId}`;\n    copyToClipboard(path)\n      .then((success) => {\n        if (success) {\n          toast.success('Field path copied');\n        } else {\n          toast.error('Failed to copy field path');\n        }\n      })\n      .catch(() => {\n        toast.error('Failed to copy field path');\n      });\n  };\n  const deleteFieldLabel = deleteTarget ? deleteTarget.name().toString() : 'this field';\n\n  return (\n    <section className=\"space-y-4 min-w-0 animate-fade-in\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex flex-wrap items-center gap-3 text-sm font-semibold\">\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 ring-1 ring-primary/20\">\n            <TableIcon className=\"h-4 w-4 text-primary\" />\n          </div>\n          <span className=\"font-semibold\">{table.name().toString()}</span>\n          <Badge\n            variant=\"secondary\"\n            className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n          >\n            {fields.length} fields\n          </Badge>\n        </div>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"h-7 text-xs font-normal hover:bg-primary/5 hover:border-primary/30 transition-colors\"\n          onClick={handleCopyTableJson}\n        >\n          <Copy className=\"h-3.5 w-3.5\" />\n          Copy JSON\n        </Button>\n      </div>\n      <div className=\"overflow-auto rounded-xl border border-border/50 bg-gradient-to-b from-muted/20 to-transparent shadow-sm\">\n        <UITable>\n          <TableHeader>\n            <TableRow>\n              <TableHead>Name</TableHead>\n              <TableHead>Field ID</TableHead>\n              <TableHead>Type</TableHead>\n              <TableHead>DB Field</TableHead>\n              <TableHead>Info</TableHead>\n              <TableHead>Options</TableHead>\n              <TableHead className=\"w-12 text-right\">Actions</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {fields.map((field) => {\n              const dbFieldName = getDbFieldName(field);\n              const isPrimary = field.id().equals(primaryFieldId);\n              const disableDelete = isPrimary || isDeletingField;\n              return (\n                <TableRow key={field.id().toString()} className=\"group\">\n                  <TableCell className=\"font-medium\">\n                    <div className=\"flex items-center gap-2\">\n                      <FieldLabel field={field} className=\"min-w-0\" />\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon-sm\"\n                        className=\"h-6 w-6 opacity-60 transition-opacity group-hover:opacity-100\"\n                        aria-label=\"Copy field path\"\n                        onClick={() => handleCopyFieldPath(field.id().toString())}\n                      >\n                        <Copy className=\"h-3 w-3 text-muted-foreground hover:text-foreground\" />\n                      </Button>\n                    </div>\n                  </TableCell>\n\n                  <TableCell className=\"break-all font-mono text-xs text-muted-foreground\">\n                    {field.id().toString()}\n                  </TableCell>\n                  <TableCell>{field.type().toString()}</TableCell>\n                  <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                    {dbFieldName ?? '-'}\n                  </TableCell>\n                  <TableCell>\n                    <div className=\"flex flex-wrap gap-1\">\n                      {isPrimary ? <Badge variant=\"outline\">Primary</Badge> : null}\n                      {field.type().toString() === 'lookup' ? (\n                        <Badge variant=\"secondary\">Lookup</Badge>\n                      ) : null}\n                      {!isPrimary && field.type().toString() !== 'lookup' ? (\n                        <span className=\"text-xs text-muted-foreground\">-</span>\n                      ) : null}\n                    </div>\n                  </TableCell>\n                  <TableCell>{renderFieldOptions(field)}</TableCell>\n                  <TableCell className=\"text-right\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon-sm\"\n                      className=\"h-7 w-7\"\n                      aria-label={`Delete ${field.name().toString()}`}\n                      disabled={disableDelete}\n                      onClick={() => {\n                        setDeleteTarget(field);\n                        setDeleteOpen(true);\n                      }}\n                    >\n                      <Trash2 className=\"h-3.5 w-3.5 text-destructive\" />\n                    </Button>\n                  </TableCell>\n                </TableRow>\n              );\n            })}\n          </TableBody>\n        </UITable>\n      </div>\n      <AlertDialog\n        open={deleteOpen}\n        onOpenChange={(open) => {\n          setDeleteOpen(open);\n          if (!open) setDeleteTarget(null);\n        }}\n      >\n        <AlertDialogContent className=\"max-w-sm\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete field</AlertDialogTitle>\n            <AlertDialogDescription>\n              Delete &quot;{deleteFieldLabel}&quot;? This will remove its schema and metadata.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isDeletingField}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              className=\"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\"\n              onClick={handleDeleteConfirm}\n              disabled={!canDeleteField}\n            >\n              {isDeletingField ? 'Deleting...' : 'Delete'}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  );\n}\n\ntype TableRecordsCardProps = {\n  baseId: string;\n  table: TableAggregate;\n  records: ReadonlyArray<ITableRecordDto> | null;\n  recordsPagination: IListTableRecordsPaginationDto | null;\n  recordsError: string | null;\n  isRecordsLoading: boolean;\n  isRecordsFetching: boolean;\n  isDeletingRecord: boolean;\n  onRecordCreated?: () => void;\n  onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void;\n  onDeleteRecords: (recordIds: string[]) => void;\n};\n\nfunction TableRecordsCard({\n  baseId,\n  table,\n  records,\n  recordsPagination,\n  recordsError,\n  isRecordsLoading,\n  isRecordsFetching,\n  isDeletingRecord,\n  onRecordCreated,\n  onPaginationChange,\n  onDeleteRecords,\n}: TableRecordsCardProps) {\n  const [, copyToClipboard] = useCopyToClipboard();\n  const fields = table.getFields();\n  const primaryFieldId = table.primaryFieldId().toString();\n  const tableId = table.id().toString();\n  const totalRecords = recordsPagination?.total ?? records?.length ?? 0;\n  const isInitialLoading = isRecordsLoading && !records;\n  const [updateTarget, setUpdateTarget] = useState<ITableRecordDto | null>(null);\n  const [isUpdateOpen, setIsUpdateOpen] = useState(false);\n  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n  const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([]);\n  const [pendingDeleteLabel, setPendingDeleteLabel] = useState<string | null>(null);\n  const env = usePlaygroundEnvironment();\n\n  const handleUpdateOpen = useCallback((record: ITableRecordDto) => {\n    setUpdateTarget(record);\n    setIsUpdateOpen(true);\n  }, []);\n\n  const handleUpdateOpenChange = useCallback((nextOpen: boolean) => {\n    setIsUpdateOpen(nextOpen);\n    if (!nextOpen) {\n      setUpdateTarget(null);\n    }\n  }, []);\n\n  useEffect(() => {\n    setRowSelection({});\n  }, [recordsPagination?.offset, recordsPagination?.limit, tableId]);\n\n  const resolveRecordLabel = useCallback(\n    (record: ITableRecordDto) => {\n      const value = record.fields[primaryFieldId];\n      const label = stringifyRecordValue(value);\n      return label.trim() || record.id;\n    },\n    [primaryFieldId]\n  );\n\n  const selectedRecordIds = useMemo(\n    () =>\n      Object.entries(rowSelection)\n        .filter(([, selected]) => selected)\n        .map(([key]) => key),\n    [rowSelection]\n  );\n\n  const canDeleteSelected = selectedRecordIds.length > 0 && !isDeletingRecord;\n\n  const openDeleteDialog = useCallback((recordIds: string[], label?: string) => {\n    setPendingDeleteIds(recordIds);\n    setPendingDeleteLabel(label ?? null);\n    setDeleteDialogOpen(true);\n  }, []);\n\n  const handleCopyRecordId = useCallback(\n    (recordId: string) => {\n      copyToClipboard(recordId)\n        .then((success) => {\n          if (success) {\n            toast.success('Record ID copied to clipboard');\n          } else {\n            toast.error('Failed to copy Record ID');\n          }\n        })\n        .catch(() => {\n          toast.error('Failed to copy Record ID');\n        });\n    },\n    [copyToClipboard]\n  );\n\n  const handleCopyRecordJson = useCallback(\n    (record: ITableRecordDto) => {\n      const payload = JSON.stringify(record, null, 2);\n      copyToClipboard(payload)\n        .then((success) => {\n          if (success) {\n            toast.success('Row JSON copied to clipboard');\n          } else {\n            toast.error('Failed to copy row JSON');\n          }\n        })\n        .catch(() => {\n          toast.error('Failed to copy row JSON');\n        });\n    },\n    [copyToClipboard]\n  );\n\n  const handleDeleteConfirm = useCallback(() => {\n    if (!pendingDeleteIds.length) return;\n    onDeleteRecords(pendingDeleteIds);\n    setRowSelection({});\n  }, [onDeleteRecords, pendingDeleteIds]);\n\n  const columns = useMemo<ColumnDef<ITableRecordDto>[]>(() => {\n    // Sort fields: primary field first, then others\n    const sortedFields = [...fields].sort((a, b) => {\n      const aIsPrimary = a.id().toString() === primaryFieldId;\n      const bIsPrimary = b.id().toString() === primaryFieldId;\n      if (aIsPrimary) return -1;\n      if (bIsPrimary) return 1;\n      return 0;\n    });\n\n    const selectionColumn: ColumnDef<ITableRecordDto> = {\n      id: 'select',\n      header: ({ table }) => (\n        <div className=\"flex items-center justify-center\">\n          <Checkbox\n            checked={\n              table.getIsAllPageRowsSelected()\n                ? true\n                : table.getIsSomePageRowsSelected()\n                  ? 'indeterminate'\n                  : false\n            }\n            onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}\n            aria-label=\"Select all rows\"\n          />\n        </div>\n      ),\n      cell: ({ row }) => (\n        <div className=\"flex items-center justify-center\">\n          <Checkbox\n            checked={row.getIsSelected()}\n            onCheckedChange={(value) => row.toggleSelected(Boolean(value))}\n            aria-label={`Select record ${row.original.id}`}\n          />\n        </div>\n      ),\n      enableSorting: false,\n      enableHiding: false,\n      size: 36,\n    };\n\n    const fieldColumns: ColumnDef<ITableRecordDto>[] = sortedFields.map((field) => {\n      const isPrimary = field.id().toString() === primaryFieldId;\n      return {\n        id: field.id().toString(),\n        header: () => <FieldLabel field={field} className=\"min-w-0\" />,\n        cell: ({ row }) => {\n          const value = row.original.fields[field.id().toString()];\n          const formattedValue = formatRecordValue(field, value);\n\n          if (isPrimary) {\n            const label =\n              formattedValue.text && formattedValue.text !== '-'\n                ? formattedValue.text\n                : resolveRecordLabel(row.original);\n            return (\n              <div className=\"relative w-full min-w-0 group\">\n                <Link\n                  to={env.routes.record}\n                  params={{ baseId, tableId, recordId: row.original.id }}\n                  search={(prev) => prev}\n                  className=\"absolute inset-0\"\n                  aria-label={label}\n                >\n                  <span className=\"sr-only\">{label}</span>\n                </Link>\n                <span\n                  className={cn(\n                    'block truncate text-left text-primary underline underline-offset-2 group-hover:text-primary/80',\n                    formattedValue.cellClassName\n                  )}\n                  title={label}\n                >\n                  {label}\n                </span>\n              </div>\n            );\n          }\n\n          return (\n            <div\n              className={cn('max-w-[220px] min-w-0 truncate', formattedValue.cellClassName)}\n              title={formattedValue.text}\n            >\n              {formattedValue.node}\n            </div>\n          );\n        },\n        size: isPrimary ? 150 : 150,\n      };\n    });\n\n    const actionsColumn: ColumnDef<ITableRecordDto> = {\n      id: 'actions',\n      header: '',\n      cell: ({ row }) => (\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button variant=\"ghost\" size=\"icon\" className=\"h-8 w-8\">\n              <MoreVertical className=\"h-4 w-4\" />\n              <span className=\"sr-only\">Open menu</span>\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\">\n            <DropdownMenuItem onClick={() => handleUpdateOpen(row.original)}>\n              <Pencil className=\"mr-2 h-4 w-4\" />\n              Update record\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={() => handleCopyRecordId(row.original.id)}>\n              <Copy className=\"mr-2 h-4 w-4\" />\n              Copy Record ID\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={() => handleCopyRecordJson(row.original)}>\n              <FileJson className=\"mr-2 h-4 w-4\" />\n              Copy Row JSON\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              className=\"text-destructive focus:text-destructive\"\n              disabled={isDeletingRecord}\n              onClick={() => openDeleteDialog([row.original.id], resolveRecordLabel(row.original))}\n            >\n              <Trash2 className=\"mr-2 h-4 w-4\" />\n              Delete record\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      ),\n    };\n\n    return [selectionColumn, ...fieldColumns, actionsColumn];\n  }, [\n    fields,\n    primaryFieldId,\n    handleUpdateOpen,\n    handleCopyRecordId,\n    handleCopyRecordJson,\n    onDeleteRecords,\n    openDeleteDialog,\n    resolveRecordLabel,\n    baseId,\n    tableId,\n    env.routes.record,\n    isDeletingRecord,\n  ]);\n\n  const data = useMemo(() => (records ?? []) as ITableRecordDto[], [records]);\n\n  const pinnedColumns = useMemo(\n    () => ({\n      left: ['select', primaryFieldId],\n    }),\n    [primaryFieldId]\n  );\n\n  // Calculate pagination state for DataTable\n  const paginationForTable = useMemo(() => {\n    if (!recordsPagination || !onPaginationChange) return undefined;\n    const pageIndex = Math.floor(recordsPagination.offset / recordsPagination.limit);\n    return {\n      pageIndex,\n      pageSize: recordsPagination.limit,\n      total: recordsPagination.total,\n    };\n  }, [recordsPagination, onPaginationChange]);\n\n  const rowContextMenuContent = useCallback(\n    (row: Row<ITableRecordDto>) => (\n      <>\n        <ContextMenuItem onClick={() => handleUpdateOpen(row.original)}>\n          <Pencil className=\"mr-2 h-4 w-4\" />\n          Update record\n        </ContextMenuItem>\n        <ContextMenuItem onClick={() => handleCopyRecordId(row.original.id)}>\n          <Copy className=\"mr-2 h-4 w-4\" />\n          Copy Record ID\n        </ContextMenuItem>\n        <ContextMenuItem onClick={() => handleCopyRecordJson(row.original)}>\n          <FileJson className=\"mr-2 h-4 w-4\" />\n          Copy Row JSON\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem\n          className=\"text-destructive focus:text-destructive\"\n          disabled={isDeletingRecord}\n          onClick={() => openDeleteDialog([row.original.id], resolveRecordLabel(row.original))}\n        >\n          <Trash2 className=\"mr-2 h-4 w-4\" />\n          Delete record\n        </ContextMenuItem>\n      </>\n    ),\n    [\n      handleUpdateOpen,\n      handleCopyRecordId,\n      handleCopyRecordJson,\n      isDeletingRecord,\n      openDeleteDialog,\n      resolveRecordLabel,\n    ]\n  );\n\n  return (\n    <section className=\"flex flex-col h-full min-h-0 space-y-4 animate-fade-in\">\n      <div className=\"flex flex-wrap items-center justify-between gap-2 shrink-0\">\n        <div className=\"flex flex-wrap items-center gap-3 text-sm font-semibold\">\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500/15 to-emerald-500/5 ring-1 ring-emerald-500/20\">\n            <TableIcon className=\"h-4 w-4 text-emerald-600\" />\n          </div>\n          <span>Records</span>\n          <Badge\n            variant=\"secondary\"\n            className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n          >\n            {totalRecords} total\n          </Badge>\n          {isRecordsFetching ? (\n            <Badge\n              variant=\"outline\"\n              className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n            >\n              Loading\n            </Badge>\n          ) : null}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {selectedRecordIds.length > 0 ? (\n            <Button\n              variant=\"destructive\"\n              size=\"sm\"\n              className=\"h-8 text-xs\"\n              disabled={!canDeleteSelected}\n              onClick={() => openDeleteDialog(selectedRecordIds)}\n            >\n              Delete selected ({selectedRecordIds.length})\n            </Button>\n          ) : null}\n          {table && (\n            <RecordCreateDialog table={table} onSuccess={onRecordCreated} baseId={baseId} />\n          )}\n        </div>\n      </div>\n      {recordsError ? (\n        <div className=\"flex items-center gap-2 text-sm text-destructive\">\n          <TriangleAlert className=\"h-4 w-4\" />\n          <span>{recordsError}</span>\n        </div>\n      ) : isInitialLoading ? (\n        <div className=\"space-y-3\">\n          <div className=\"grid grid-cols-4 gap-3\">\n            {Array.from({ length: 4 }).map((_, index) => (\n              <Skeleton key={`record-header-skeleton-${index}`} className=\"h-4 w-full\" />\n            ))}\n          </div>\n          {Array.from({ length: 4 }).map((_, rowIndex) => (\n            <div key={`record-row-skeleton-${rowIndex}`} className=\"grid grid-cols-4 gap-3\">\n              {Array.from({ length: 4 }).map((_, colIndex) => (\n                <Skeleton\n                  key={`record-cell-skeleton-${rowIndex}-${colIndex}`}\n                  className=\"h-4 w-full\"\n                />\n              ))}\n            </div>\n          ))}\n        </div>\n      ) : (\n        <DataTable\n          columns={columns}\n          data={data}\n          className=\"flex-1 min-h-0\"\n          emptyMessage=\"No records yet.\"\n          pinnedColumns={pinnedColumns}\n          pagination={paginationForTable}\n          onPaginationChange={onPaginationChange}\n          enableRowSelection\n          rowSelection={rowSelection}\n          onRowSelectionChange={setRowSelection}\n          getRowId={(row) => row.id}\n          rowContextMenuContent={rowContextMenuContent}\n        />\n      )}\n      {updateTarget ? (\n        <RecordUpdateDialog\n          table={table}\n          record={updateTarget}\n          baseId={baseId}\n          open={isUpdateOpen}\n          onOpenChange={handleUpdateOpenChange}\n          onSuccess={onRecordCreated}\n        />\n      ) : null}\n      <RecordDeleteDialog\n        open={deleteDialogOpen}\n        onOpenChange={(open) => {\n          setDeleteDialogOpen(open);\n          if (!open) {\n            setPendingDeleteIds([]);\n            setPendingDeleteLabel(null);\n          }\n        }}\n        tableId={tableId}\n        recordIds={pendingDeleteIds}\n        recordLabel={pendingDeleteLabel}\n        isDeleting={isDeletingRecord}\n        onConfirm={handleDeleteConfirm}\n      />\n    </section>\n  );\n}\n\ntype TableJsonCardProps = {\n  table: TableAggregate;\n  tableJson: ITableDto | null;\n  tableJsonError: string | null;\n};\n\nfunction TableJsonCard({ table, tableJson, tableJsonError }: TableJsonCardProps) {\n  const [, copyToClipboard] = useCopyToClipboard();\n  const handleCopyTableJson = () => {\n    void copyTableJson(table, copyToClipboard);\n  };\n\n  return (\n    <section className=\"space-y-4 min-w-0 animate-fade-in\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex flex-wrap items-center gap-3 text-sm font-semibold\">\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500/15 to-amber-500/5 ring-1 ring-amber-500/20\">\n            <FileJson className=\"h-4 w-4 text-amber-600\" />\n          </div>\n          <span>Table JSON</span>\n          <Badge\n            variant=\"secondary\"\n            className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n          >\n            {table.getFields().length} fields\n          </Badge>\n          <Badge\n            variant=\"outline\"\n            className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n          >\n            {table.views().length} views\n          </Badge>\n        </div>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"h-7 text-xs font-normal hover:bg-primary/5 hover:border-primary/30 transition-colors\"\n          onClick={handleCopyTableJson}\n        >\n          <Copy className=\"h-3.5 w-3.5\" />\n          Copy JSON\n        </Button>\n      </div>\n      <div className=\"overflow-hidden rounded-xl border border-border/50 bg-gradient-to-b from-muted/20 to-transparent shadow-sm\">\n        {tableJsonError ? (\n          <div className=\"px-6 py-4 text-sm text-destructive\">\n            Unable to render JSON: {tableJsonError}\n          </div>\n        ) : !tableJson ? (\n          <div className=\"px-6 py-4 text-sm text-muted-foreground\">JSON snapshot unavailable.</div>\n        ) : (\n          <ScrollArea className=\"h-[60vh] min-h-[320px]\">\n            <div className=\"px-6 pb-6 pt-4 text-xs font-mono text-foreground\">\n              <JsonView\n                data={tableJson}\n                shouldExpandNode={shouldExpandJsonNode}\n                clickToExpandNode\n              />\n            </div>\n          </ScrollArea>\n        )}\n      </div>\n    </section>\n  );\n}\n\ntype RealtimeSnapshotCardProps = {\n  snapshot: unknown;\n  status: ShareDbDocStatus;\n  error: string | null;\n  title: string;\n};\n\nconst formatRealtimeStatusLabel = (status: ShareDbDocStatus): string => {\n  if (status === 'ready') return 'Live';\n  if (status === 'connecting') return 'Connecting';\n  if (status === 'error') return 'Error';\n  return 'Idle';\n};\n\nconst resolveRealtimeStatusVariant = (\n  status: ShareDbDocStatus\n): 'secondary' | 'outline' | 'destructive' => {\n  if (status === 'ready') return 'secondary';\n  if (status === 'error') return 'destructive';\n  return 'outline';\n};\n\nfunction RealtimeSnapshotCard({ snapshot, status, error, title }: RealtimeSnapshotCardProps) {\n  const statusLabel = formatRealtimeStatusLabel(status);\n  const statusVariant = resolveRealtimeStatusVariant(status);\n\n  return (\n    <section className=\"space-y-3 min-w-0\">\n      <div className=\"flex flex-wrap items-center gap-2 text-sm font-semibold\">\n        <FileJson className=\"h-4 w-4 text-muted-foreground\" />\n        {title}\n        <Badge\n          variant={statusVariant}\n          className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n        >\n          {statusLabel}\n        </Badge>\n      </div>\n      <div className=\"overflow-hidden rounded-md border border-border/60\">\n        {error ? (\n          <div className=\"px-6 py-4 text-sm text-destructive\">Realtime error: {error}</div>\n        ) : !snapshot ? (\n          <div className=\"px-6 py-4 text-sm text-muted-foreground\">\n            Waiting for ShareDB snapshot.\n          </div>\n        ) : (\n          <ScrollArea className=\"h-[60vh] min-h-[320px]\">\n            <div className=\"px-6 pb-6 pt-4 text-xs font-mono text-foreground\">\n              <JsonView data={snapshot} shouldExpandNode={shouldExpandJsonNode} clickToExpandNode />\n            </div>\n          </ScrollArea>\n        )}\n      </div>\n    </section>\n  );\n}\n\ntype RealtimeFieldsCardProps = {\n  snapshots: ReadonlyArray<ITableFieldPersistenceDTO>;\n  status: ShareDbDocStatus;\n  error: string | null;\n};\n\nfunction RealtimeFieldsCard({ snapshots, status, error }: RealtimeFieldsCardProps) {\n  return (\n    <RealtimeSnapshotCard\n      snapshot={snapshots}\n      status={status}\n      error={error}\n      title=\"ShareDB Field Query\"\n    />\n  );\n}\n\ntype RealtimeRecordsCardProps = {\n  snapshots: ReadonlyArray<ITableRecordRealtimeDTO>;\n  status: ShareDbDocStatus;\n  error: string | null;\n};\n\nfunction RealtimeRecordsCard({ snapshots, status, error }: RealtimeRecordsCardProps) {\n  return (\n    <RealtimeSnapshotCard\n      snapshot={snapshots}\n      status={status}\n      error={error}\n      title=\"ShareDB Record Query\"\n    />\n  );\n}\n\ntype TableViewsCardProps = {\n  views: ReadonlyArray<View>;\n  fields: ReadonlyArray<Field>;\n};\n\nfunction TableViewsCard({ views, fields }: TableViewsCardProps) {\n  const fieldById = useMemo(() => {\n    const map = new Map<string, Field>();\n    fields.forEach((field) => {\n      map.set(field.id().toString(), field);\n    });\n    return map;\n  }, [fields]);\n\n  const viewDetails = useMemo(\n    () =>\n      views.map((view) => {\n        const columnMetaResult = getViewColumnMeta(view);\n        const columnMetaEntries = columnMetaResult.value\n          ? sortColumnMeta(columnMetaResult.value)\n          : [];\n        const hasVisibility = columnMetaEntries.some(\n          ([, entry]) => entry.visible !== undefined || entry.hidden !== undefined\n        );\n        return {\n          view,\n          columnMetaEntries,\n          columnMetaError: columnMetaResult.error,\n          columnMetaCount: columnMetaEntries.length,\n          hasVisibility,\n        };\n      }),\n    [views]\n  );\n\n  const [activeViewId, setActiveViewId] = useState<string>(() => {\n    const first = viewDetails[0]?.view.id().toString();\n    return first ?? '';\n  });\n\n  useEffect(() => {\n    if (!viewDetails.length) return;\n    const activeExists = viewDetails.some((entry) => entry.view.id().toString() === activeViewId);\n    if (!activeViewId || !activeExists) {\n      setActiveViewId(viewDetails[0].view.id().toString());\n    }\n  }, [activeViewId, viewDetails]);\n\n  const renderViewFieldLabel = (fieldId: string) => {\n    const field = fieldById.get(fieldId);\n    if (!field) {\n      return (\n        <span className=\"break-all font-mono text-xs text-muted-foreground\" title={fieldId}>\n          {fieldId}\n        </span>\n      );\n    }\n\n    return <FieldLabel field={field} className=\"min-w-0\" />;\n  };\n\n  return (\n    <section className=\"space-y-3 min-w-0\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"text-sm font-semibold\">Views</div>\n        <Badge\n          variant=\"outline\"\n          className=\"h-5 px-2 text-[10px] font-medium uppercase tracking-wider\"\n        >\n          {views.length} views\n        </Badge>\n      </div>\n\n      {viewDetails.length ? (\n        <Tabs value={activeViewId} onValueChange={setActiveViewId} className=\"w-full\">\n          <TabsList className=\"h-auto flex flex-wrap justify-start gap-1 bg-muted/20\">\n            {viewDetails.map(({ view }) => (\n              <TabsTrigger\n                key={view.id().toString()}\n                value={view.id().toString()}\n                className=\"max-w-full\"\n              >\n                <span className=\"max-w-[180px] truncate\">{view.name().toString()}</span>\n              </TabsTrigger>\n            ))}\n          </TabsList>\n\n          {viewDetails.map(\n            ({ view, columnMetaEntries, columnMetaError, columnMetaCount, hasVisibility }) => (\n              <TabsContent key={view.id().toString()} value={view.id().toString()} className=\"mt-3\">\n                <div className=\"space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3\">\n                  <div className=\"flex flex-wrap items-center gap-2\">\n                    <div className=\"text-sm font-semibold text-foreground\">\n                      {view.name().toString()}\n                    </div>\n                    <Badge variant=\"secondary\">{view.type().toString()}</Badge>\n                    <Badge variant=\"outline\">{columnMetaCount} columns</Badge>\n                    {hasVisibility ? <Badge variant=\"outline\">visibility</Badge> : null}\n                    {columnMetaError ? (\n                      <Badge variant=\"destructive\">column meta error</Badge>\n                    ) : null}\n                  </div>\n\n                  {columnMetaError ? (\n                    <div className=\"text-xs text-destructive\">{columnMetaError}</div>\n                  ) : null}\n\n                  {columnMetaEntries.length ? (\n                    <div className=\"overflow-auto rounded-md border border-border/40 bg-background/50\">\n                      <UITable>\n                        <TableHeader>\n                          <TableRow>\n                            <TableHead>Field</TableHead>\n                            <TableHead>Order</TableHead>\n                            <TableHead>Visible</TableHead>\n                            <TableHead>Hidden</TableHead>\n                            <TableHead>Required</TableHead>\n                            <TableHead>Width</TableHead>\n                            <TableHead>Statistic</TableHead>\n                            <TableHead>Extras</TableHead>\n                          </TableRow>\n                        </TableHeader>\n                        <TableBody>\n                          {columnMetaEntries.map(([fieldId, entry]) => (\n                            <TableRow key={`${view.id().toString()}-${fieldId}`}>\n                              <TableCell className=\"min-w-[220px]\">\n                                {renderViewFieldLabel(fieldId)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalNumber(entry.order)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalBoolean(entry.visible)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalBoolean(entry.hidden)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalBoolean(entry.required)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalNumber(entry.width)}\n                              </TableCell>\n                              <TableCell className=\"font-mono text-xs text-muted-foreground\">\n                                {formatOptionalString(entry.statisticFunc)}\n                              </TableCell>\n                              <TableCell className=\"break-all font-mono text-xs text-muted-foreground\">\n                                {formatColumnMetaExtras(entry)}\n                              </TableCell>\n                            </TableRow>\n                          ))}\n                        </TableBody>\n                      </UITable>\n                    </div>\n                  ) : (\n                    <div className=\"text-xs text-muted-foreground\">No column meta entries.</div>\n                  )}\n                </div>\n              </TabsContent>\n            )\n          )}\n        </Tabs>\n      ) : (\n        <div className=\"text-sm text-muted-foreground\">No views defined.</div>\n      )}\n    </section>\n  );\n}\n\ntype TableConnectionCardProps = {\n  baseId: string;\n  tableId: string;\n  table: TableAggregate;\n  isLoading: boolean;\n};\n\nfunction TableConnectionCard({ baseId, tableId, table, isLoading }: TableConnectionCardProps) {\n  const dbTableName = getDbTableName(table);\n  const tableIdValue = table.id().toString();\n  const baseIdValue = table.baseId().toString();\n  const resolvedTableId = tableIdValue || tableId;\n\n  return (\n    <section className=\"space-y-3 min-w-0\">\n      <div className=\"text-sm font-semibold\">Connection</div>\n      <div className=\"space-y-2 text-xs text-muted-foreground\">\n        <div className=\"flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between\">\n          <span>Base ID</span>\n          <code className=\"break-all text-[11px] text-foreground font-mono sm:text-right\">\n            {baseIdValue || baseId}\n          </code>\n        </div>\n        <div className=\"flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between\">\n          <span>Table ID</span>\n          <code className=\"break-all text-[11px] text-foreground font-mono sm:text-right\">\n            {resolvedTableId}\n          </code>\n        </div>\n        <div className=\"flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between\">\n          <span>DB Table</span>\n          <code className=\"break-all text-[11px] text-foreground font-mono sm:text-right\">\n            {dbTableName ?? '-'}\n          </code>\n        </div>\n        <div className=\"flex items-center justify-between\">\n          <span>Status</span>\n          <Badge\n            variant=\"outline\"\n            className=\"h-5 px-1.5 text-[10px] font-normal uppercase tracking-wider\"\n          >\n            {isLoading ? 'loading' : 'ready'}\n          </Badge>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/UnderlyingDataPanel.tsx",
    "content": "import { useState, useCallback, useMemo, useEffect } from 'react';\nimport { useQuery, keepPreviousData } from '@tanstack/react-query';\nimport { JsonView } from 'react-json-view-lite';\nimport type { ColumnDef } from '@tanstack/react-table';\nimport {\n  Database,\n  RefreshCcw,\n  Table as TableIcon,\n  FileJson,\n  Loader2,\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n  Columns,\n} from 'lucide-react';\n\nimport type {\n  DebugTableMeta,\n  DebugFieldMeta,\n  DebugRawRecordQueryResult,\n} from '@teable/v2-debug-data';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardHeader, CardTitle } from '@/components/ui/card';\nimport { DataTable } from '@/components/ui/data-table';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/components/ui/table';\nimport { cn } from '@/lib/utils';\nimport {\n  PLAYGROUND_DB_URL_QUERY_PARAM,\n  resolvePlaygroundDbUrl,\n} from '@/lib/playground/databaseUrl';\n\ntype UnderlyingDataResponse = {\n  tableMeta: DebugTableMeta | null;\n  fields: DebugFieldMeta[] | null;\n  rawRecords: DebugRawRecordQueryResult | null;\n  error: string | null;\n};\n\ntype UnderlyingDataPanelProps = {\n  tableId: string;\n  tableName: string;\n};\n\nconst PAGE_SIZE_OPTIONS = [10, 20, 50, 100];\n\nconst shouldExpandJsonNode = (level: number) => level < 2;\n\nconst formatMetaValue = (value: unknown): string => {\n  if (value === null || value === undefined) return '-';\n  if (typeof value === 'string') return value || '-';\n  if (typeof value === 'number') return value.toString();\n  if (typeof value === 'boolean') return value ? 'true' : 'false';\n  return JSON.stringify(value);\n};\n\nexport function UnderlyingDataPanel({ tableId }: UnderlyingDataPanelProps) {\n  const [activeTab, setActiveTab] = useState<'records' | 'fields' | 'meta'>('records');\n  const [pageIndex, setPageIndex] = useState(0);\n  const [pageSize, setPageSize] = useState(20);\n\n  // Reset pagination when tableId changes\n  useEffect(() => {\n    setPageIndex(0);\n  }, [tableId]);\n\n  const fetchUnderlyingData = useCallback(async (): Promise<UnderlyingDataResponse> => {\n    const dbUrl = resolvePlaygroundDbUrl();\n    const baseUrl = `/api/underlying/${tableId}`;\n    const params = new URLSearchParams();\n\n    if (dbUrl) {\n      params.set(PLAYGROUND_DB_URL_QUERY_PARAM, dbUrl);\n    }\n    params.set('limit', pageSize.toString());\n    params.set('offset', (pageIndex * pageSize).toString());\n\n    const url = `${baseUrl}?${params.toString()}`;\n    const response = await fetch(url);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch: ${response.statusText}`);\n    }\n    return response.json();\n  }, [tableId, pageIndex, pageSize]);\n\n  const query = useQuery({\n    queryKey: ['underlying-data', tableId, pageIndex, pageSize],\n    queryFn: fetchUnderlyingData,\n    placeholderData: keepPreviousData,\n  });\n\n  const { data, isLoading, isFetching, refetch } = query;\n\n  const tableMeta = data?.tableMeta ?? null;\n  const fields = data?.fields ?? null;\n  const rawRecords = data?.rawRecords ?? null;\n  const error = data?.error ?? null;\n\n  const totalRecords = rawRecords?.total ?? 0;\n  const totalPages = Math.ceil(totalRecords / pageSize);\n  const hasNext = pageIndex < totalPages - 1;\n  const hasPrev = pageIndex > 0;\n\n  const handlePageChange = useCallback((newPage: number) => {\n    setPageIndex(newPage);\n  }, []);\n\n  const handlePageSizeChange = useCallback((newSize: string) => {\n    setPageSize(parseInt(newSize, 10));\n    setPageIndex(0);\n  }, []);\n\n  // Build dynamic columns for records from first record\n  const recordColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {\n    const records = rawRecords?.records ?? [];\n    if (records.length === 0) return [];\n\n    // Get all unique keys from records\n    const keys = new Set<string>();\n    for (const record of records) {\n      Object.keys(record).forEach((key) => keys.add(key));\n    }\n\n    // Sort keys: regular columns first, then system columns (__id, __created_time, etc.)\n    const sortedKeys = [...keys].sort((a, b) => {\n      const aIsSystem = a.startsWith('__');\n      const bIsSystem = b.startsWith('__');\n      if (!aIsSystem && bIsSystem) return -1;\n      if (aIsSystem && !bIsSystem) return 1;\n      // Within system columns, put __id first\n      if (aIsSystem && bIsSystem) {\n        if (a === '__id') return -1;\n        if (b === '__id') return 1;\n      }\n      return a.localeCompare(b);\n    });\n\n    return sortedKeys.map((key) => ({\n      id: key,\n      accessorKey: key,\n      header: () => (\n        <span className={cn('font-mono text-xs', key.startsWith('__') && 'text-muted-foreground')}>\n          {key}\n        </span>\n      ),\n      cell: ({ getValue }) => {\n        const value = getValue();\n        const isSystem = key.startsWith('__');\n\n        if (value === null || value === undefined) {\n          return <span className=\"text-muted-foreground/50 italic\">&lt;NULL&gt;</span>;\n        }\n\n        if (typeof value === 'object') {\n          return (\n            <span className=\"font-mono text-xs text-muted-foreground truncate max-w-[200px] block\">\n              {JSON.stringify(value)}\n            </span>\n          );\n        }\n\n        return (\n          <span\n            className={cn(\n              'font-mono text-xs truncate max-w-[200px] block',\n              isSystem && 'text-muted-foreground'\n            )}\n            title={String(value)}\n          >\n            {String(value)}\n          </span>\n        );\n      },\n      size: key === '__id' ? 180 : 150,\n    }));\n  }, [rawRecords]);\n\n  const tableData = useMemo(\n    () => (rawRecords?.records ?? []) as Record<string, unknown>[],\n    [rawRecords]\n  );\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Header */}\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"flex flex-wrap items-center gap-3 text-sm font-semibold\">\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/15 to-violet-500/5 ring-1 ring-violet-500/20\">\n            <Database className=\"h-4 w-4 text-violet-600\" />\n          </div>\n          <span>Underlying Data</span>\n          {isFetching && <Loader2 className=\"h-4 w-4 text-muted-foreground animate-spin\" />}\n        </div>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"h-7 text-xs font-normal\"\n          onClick={() => refetch()}\n          disabled={isFetching}\n        >\n          <RefreshCcw className={cn('h-3.5 w-3.5 mr-1.5', isFetching && 'animate-spin')} />\n          Refresh\n        </Button>\n      </div>\n\n      {error && (\n        <Card className=\"border-destructive/40 bg-destructive/5\">\n          <CardHeader className=\"py-3\">\n            <CardTitle className=\"text-sm text-destructive\">Error: {error}</CardTitle>\n          </CardHeader>\n        </Card>\n      )}\n\n      {/* Internal Tabs for Records, Fields and Table Meta */}\n      <Tabs\n        value={activeTab}\n        onValueChange={(v) => setActiveTab(v as 'records' | 'fields' | 'meta')}\n      >\n        <TabsList className=\"h-9 w-fit rounded-lg border border-border/60 bg-background/70 p-1\">\n          <TabsTrigger value=\"records\" className=\"h-7 rounded-md px-3 text-xs font-medium\">\n            <FileJson className=\"h-3.5 w-3.5 mr-1.5\" />\n            Records\n            {rawRecords && (\n              <Badge variant=\"secondary\" className=\"ml-1.5 h-4 px-1 text-[9px]\">\n                {totalRecords}\n              </Badge>\n            )}\n          </TabsTrigger>\n          <TabsTrigger value=\"fields\" className=\"h-7 rounded-md px-3 text-xs font-medium\">\n            <Columns className=\"h-3.5 w-3.5 mr-1.5\" />\n            Fields\n            {fields && (\n              <Badge variant=\"secondary\" className=\"ml-1.5 h-4 px-1 text-[9px]\">\n                {fields.length}\n              </Badge>\n            )}\n          </TabsTrigger>\n          <TabsTrigger value=\"meta\" className=\"h-7 rounded-md px-3 text-xs font-medium\">\n            <TableIcon className=\"h-3.5 w-3.5 mr-1.5\" />\n            Table Meta\n          </TabsTrigger>\n        </TabsList>\n\n        {/* Records Tab */}\n        <TabsContent value=\"records\" className=\"mt-4\">\n          <section className=\"space-y-3\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <div className=\"flex items-center gap-2 text-sm font-medium\">\n                <span>Raw Records</span>\n                {rawRecords && (\n                  <span className=\"text-xs text-muted-foreground\">\n                    (from{' '}\n                    <code className=\"px-1 py-0.5 bg-muted rounded\">{rawRecords.dbTableName}</code>)\n                  </span>\n                )}\n              </div>\n            </div>\n\n            {isLoading ? (\n              <div className=\"space-y-2\">\n                <div className=\"grid grid-cols-4 gap-3\">\n                  {Array.from({ length: 4 }).map((_, i) => (\n                    <Skeleton key={`header-${i}`} className=\"h-4 w-full\" />\n                  ))}\n                </div>\n                {Array.from({ length: 5 }).map((_, rowIndex) => (\n                  <div key={`row-${rowIndex}`} className=\"grid grid-cols-4 gap-3\">\n                    {Array.from({ length: 4 }).map((_, colIndex) => (\n                      <Skeleton key={`cell-${rowIndex}-${colIndex}`} className=\"h-4 w-full\" />\n                    ))}\n                  </div>\n                ))}\n              </div>\n            ) : tableData.length > 0 ? (\n              <div className=\"space-y-3\">\n                <div className=\"overflow-auto rounded-lg border border-border/50\">\n                  <DataTable\n                    columns={recordColumns}\n                    data={tableData}\n                    className=\"max-h-[400px]\"\n                    emptyMessage=\"No records found.\"\n                  />\n                </div>\n\n                {/* Pagination Controls */}\n                <div className=\"flex items-center justify-between gap-4 flex-wrap\">\n                  <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                    <span>Rows per page:</span>\n                    <Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>\n                      <SelectTrigger className=\"w-[70px]\">\n                        <SelectValue placeholder={pageSize.toString()} />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {PAGE_SIZE_OPTIONS.map((size) => (\n                          <SelectItem key={size} value={size.toString()}>\n                            {size}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </div>\n\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-sm text-muted-foreground\">\n                      Page {pageIndex + 1} of {totalPages || 1}\n                    </span>\n                    <div className=\"flex items-center gap-1\">\n                      <Button\n                        variant=\"outline\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        disabled={!hasPrev}\n                        onClick={() => handlePageChange(0)}\n                        title=\"First page\"\n                      >\n                        <ChevronsLeft className=\"h-4 w-4\" />\n                      </Button>\n                      <Button\n                        variant=\"outline\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        disabled={!hasPrev}\n                        onClick={() => handlePageChange(pageIndex - 1)}\n                        title=\"Previous page\"\n                      >\n                        <ChevronLeft className=\"h-4 w-4\" />\n                      </Button>\n                      <Button\n                        variant=\"outline\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        disabled={!hasNext}\n                        onClick={() => handlePageChange(pageIndex + 1)}\n                        title=\"Next page\"\n                      >\n                        <ChevronRight className=\"h-4 w-4\" />\n                      </Button>\n                      <Button\n                        variant=\"outline\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        disabled={!hasNext}\n                        onClick={() => handlePageChange(totalPages - 1)}\n                        title=\"Last page\"\n                      >\n                        <ChevronsRight className=\"h-4 w-4\" />\n                      </Button>\n                    </div>\n                  </div>\n\n                  <span className=\"text-sm text-muted-foreground\">\n                    Showing {pageIndex * pageSize + 1}-\n                    {Math.min((pageIndex + 1) * pageSize, totalRecords)} of {totalRecords}\n                  </span>\n                </div>\n              </div>\n            ) : (\n              <div className=\"text-sm text-muted-foreground py-4 text-center\">\n                No records found in the underlying table.\n              </div>\n            )}\n          </section>\n        </TabsContent>\n\n        {/* Fields Tab */}\n        <TabsContent value=\"fields\" className=\"mt-4\">\n          <section className=\"space-y-3\">\n            <div className=\"flex items-center gap-2 text-sm font-medium\">\n              <span>Field Metadata</span>\n            </div>\n\n            {isLoading ? (\n              <div className=\"space-y-2\">\n                {Array.from({ length: 5 }).map((_, i) => (\n                  <Skeleton key={i} className=\"h-20 w-full\" />\n                ))}\n              </div>\n            ) : fields && fields.length > 0 ? (\n              <div className=\"space-y-4\">\n                {fields.map((field) => (\n                  <div\n                    key={field.id}\n                    className=\"overflow-hidden rounded-lg border border-border/50 bg-muted/10\"\n                  >\n                    <div className=\"flex items-center gap-2 px-4 py-2 bg-muted/20 border-b border-border/50\">\n                      <span className=\"font-semibold text-sm\">{field.name}</span>\n                      <Badge variant=\"outline\" className=\"text-[10px]\">\n                        {field.type}\n                      </Badge>\n                      {field.isPrimary && (\n                        <Badge variant=\"secondary\" className=\"text-[10px]\">\n                          Primary\n                        </Badge>\n                      )}\n                      {field.isComputed && (\n                        <Badge variant=\"secondary\" className=\"text-[10px]\">\n                          Computed\n                        </Badge>\n                      )}\n                      {field.isLookup && (\n                        <Badge variant=\"secondary\" className=\"text-[10px]\">\n                          Lookup\n                        </Badge>\n                      )}\n                    </div>\n                    <div className=\"p-4\">\n                      <div className=\"text-xs font-mono\">\n                        <JsonView\n                          data={field}\n                          shouldExpandNode={shouldExpandJsonNode}\n                          clickToExpandNode\n                        />\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            ) : (\n              <div className=\"text-sm text-muted-foreground py-4 text-center\">\n                No field metadata available.\n              </div>\n            )}\n          </section>\n        </TabsContent>\n\n        {/* Table Meta Tab */}\n        <TabsContent value=\"meta\" className=\"mt-4\">\n          <section className=\"space-y-3\">\n            <div className=\"flex items-center gap-2 text-sm font-medium\">\n              <span>Table Metadata</span>\n            </div>\n\n            {isLoading ? (\n              <div className=\"space-y-2\">\n                {Array.from({ length: 4 }).map((_, i) => (\n                  <Skeleton key={i} className=\"h-4 w-full\" />\n                ))}\n              </div>\n            ) : tableMeta ? (\n              <div className=\"overflow-hidden rounded-lg border border-border/50 bg-muted/10\">\n                <Table>\n                  <TableHeader>\n                    <TableRow>\n                      <TableHead className=\"w-[180px]\">Property</TableHead>\n                      <TableHead>Value</TableHead>\n                    </TableRow>\n                  </TableHeader>\n                  <TableBody>\n                    {[\n                      ['ID', tableMeta.id],\n                      ['Name', tableMeta.name],\n                      ['Base ID', tableMeta.baseId],\n                      ['DB Table Name', tableMeta.dbTableName],\n                      ['DB View Name', tableMeta.dbViewName],\n                      ['Description', tableMeta.description],\n                      ['Icon', tableMeta.icon],\n                      ['Version', tableMeta.version],\n                      ['Order', tableMeta.order],\n                      ['Created Time', tableMeta.createdTime],\n                      ['Created By', tableMeta.createdBy],\n                      ['Last Modified Time', tableMeta.lastModifiedTime],\n                      ['Last Modified By', tableMeta.lastModifiedBy],\n                      ['Deleted Time', tableMeta.deletedTime],\n                    ].map(([label, value]) => (\n                      <TableRow key={label as string}>\n                        <TableCell className=\"font-medium text-muted-foreground\">{label}</TableCell>\n                        <TableCell className=\"font-mono text-xs\">\n                          {formatMetaValue(value)}\n                        </TableCell>\n                      </TableRow>\n                    ))}\n                  </TableBody>\n                </Table>\n              </div>\n            ) : (\n              <div className=\"text-sm text-muted-foreground py-4 text-center\">\n                No table metadata available.\n              </div>\n            )}\n          </section>\n        </TabsContent>\n      </Tabs>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/CheckboxFieldInput.tsx",
    "content": "import { Checkbox } from '@/components/ui/checkbox';\nimport { Label } from '@/components/ui/label';\nimport type { FieldInputProps } from './types';\n\nexport function CheckboxFieldInput({ field, value, onChange, onBlur, disabled }: FieldInputProps) {\n  const checked = value === true;\n\n  return (\n    <div className=\"flex items-center space-x-2\">\n      <Checkbox\n        id={field.id().toString()}\n        checked={checked}\n        onCheckedChange={(checked) => onChange(checked === true)}\n        onBlur={onBlur}\n        disabled={disabled}\n      />\n      <Label htmlFor={field.id().toString()} className=\"text-sm font-normal cursor-pointer\">\n        {checked ? 'Yes' : 'No'}\n      </Label>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/DateFieldInput.tsx",
    "content": "import { DatePicker } from '@/components/ui/date-picker';\nimport type { FieldInputProps } from './types';\n\nexport function DateFieldInput({ field, value, onChange, disabled }: FieldInputProps) {\n  const fieldName = field.name().toString();\n  const isRequired = field.notNull().toBoolean();\n\n  // Parse the ISO string value to Date\n  const dateValue = typeof value === 'string' ? new Date(value) : null;\n\n  const handleChange = (date: Date | null) => {\n    // Convert to ISO string for storage\n    onChange(date ? date.toISOString() : null);\n  };\n\n  return (\n    <DatePicker\n      value={dateValue}\n      onChange={handleChange}\n      disabled={disabled}\n      placeholder={`Pick ${fieldName.toLowerCase()}${isRequired ? '' : ' (optional)'}`}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/DisabledFieldInput.tsx",
    "content": "import { AlertCircle, FileIcon, User, Zap } from 'lucide-react';\nimport type { FieldInputProps } from './types';\n\nexport function DisabledFieldInput({ field }: FieldInputProps) {\n  const fieldType = field.type().toString();\n\n  let icon: React.ReactNode;\n  let message: string;\n\n  switch (fieldType) {\n    case 'attachment':\n      icon = <FileIcon className=\"h-4 w-4\" />;\n      message = 'Attachment upload is not yet supported';\n      break;\n    case 'user':\n    case 'createdBy':\n    case 'lastModifiedBy':\n      icon = <User className=\"h-4 w-4\" />;\n      message = 'User selection is not yet supported';\n      break;\n    case 'button':\n      icon = <Zap className=\"h-4 w-4\" />;\n      message = 'Button fields cannot be edited';\n      break;\n    default:\n      icon = <AlertCircle className=\"h-4 w-4\" />;\n      message = 'This field type is not yet supported';\n  }\n\n  return (\n    <div className=\"flex items-center gap-2 p-3 rounded-md border border-dashed bg-muted/50 text-muted-foreground\">\n      {icon}\n      <span className=\"text-sm\">{message}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/LinkFieldInput.tsx",
    "content": "import { useState, useCallback, useMemo } from 'react';\nimport {\n  Link2,\n  X,\n  Plus,\n  Search,\n  ExternalLink,\n  AlertCircle,\n  SquareArrowOutUpRight,\n} from 'lucide-react';\nimport type { LinkField } from '@teable/v2-core';\nimport type { ITableRecordDto } from '@teable/v2-contract-http';\nimport { Button } from '@/components/ui/button';\nimport { Badge } from '@/components/ui/badge';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport type { FieldInputProps } from './types';\n\ninterface LinkedRecord {\n  id: string;\n  title: string;\n}\n\nexport function LinkFieldInput({\n  field,\n  value,\n  onChange,\n  disabled,\n  orpcClient,\n  baseId,\n}: FieldInputProps) {\n  const [open, setOpen] = useState(false);\n  const [searchTerm, setSearchTerm] = useState('');\n  const [records, setRecords] = useState<LinkedRecord[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const linkField = field as LinkField;\n  const fieldName = field.name().toString();\n  const isRequired = field.notNull().toBoolean();\n\n  // Parse value - could be single record or array of records\n  const selectedRecords: LinkedRecord[] = Array.isArray(value)\n    ? value.map((v) =>\n        typeof v === 'object' && v !== null\n          ? (v as LinkedRecord)\n          : { id: String(v), title: String(v) }\n      )\n    : value && typeof value === 'object'\n      ? [value as LinkedRecord]\n      : [];\n\n  const foreignTableId = linkField.foreignTableId().toString();\n  const lookupFieldId = linkField.lookupFieldId().toString();\n  const isMultipleValue = linkField.isMultipleValue();\n  // For cross-base links, linkField.baseId() returns the foreign table's base ID\n  // For same-base links, it returns undefined, so we use the current baseId\n  const foreignBaseId = linkField.baseId()?.toString() ?? baseId;\n\n  // Build the URL to navigate to the foreign table\n  const foreignTableUrl = useMemo(() => {\n    if (!foreignBaseId) return null;\n    // Detect if we're in sandbox mode based on current pathname\n    const isSandbox =\n      typeof window !== 'undefined' && window.location.pathname.startsWith('/sandbox');\n    const prefix = isSandbox ? '/sandbox' : '';\n    return `${prefix}/${foreignBaseId}/${foreignTableId}`;\n  }, [foreignBaseId, foreignTableId]);\n\n  const handleOpenForeignTable = () => {\n    if (foreignTableUrl) {\n      window.open(foreignTableUrl, '_blank', 'noopener,noreferrer');\n    }\n  };\n\n  // Fetch records from the foreign table\n  const fetchRecords = useCallback(\n    async (_search: string) => {\n      if (!orpcClient) {\n        setError('ORPC client not available');\n        return;\n      }\n\n      setLoading(true);\n      setError(null);\n      try {\n        const response = await orpcClient.tables.listRecords({\n          tableId: foreignTableId,\n        });\n\n        if (!response.ok) {\n          setError(response.error?.message || 'Failed to fetch records');\n          setRecords([]);\n          return;\n        }\n\n        // Map records to LinkedRecord format\n        // Use the lookupFieldId to get the primary field value as title\n        const linkedRecords: LinkedRecord[] = response.data.records.map(\n          (record: ITableRecordDto) => {\n            const primaryValue = record.fields[lookupFieldId];\n            const title =\n              primaryValue !== null && primaryValue !== undefined\n                ? String(primaryValue)\n                : record.id;\n            return {\n              id: record.id,\n              title,\n            };\n          }\n        );\n\n        // Filter by search term if provided\n        const filteredRecords = _search\n          ? linkedRecords.filter(\n              (r) =>\n                r.title.toLowerCase().includes(_search.toLowerCase()) ||\n                r.id.toLowerCase().includes(_search.toLowerCase())\n            )\n          : linkedRecords;\n\n        setRecords(filteredRecords);\n      } catch (err) {\n        console.error('Failed to fetch records:', err);\n        setError(err instanceof Error ? err.message : 'Unknown error');\n        setRecords([]);\n      } finally {\n        setLoading(false);\n      }\n    },\n    [orpcClient, foreignTableId, lookupFieldId]\n  );\n\n  const handleOpenChange = (isOpen: boolean) => {\n    setOpen(isOpen);\n    if (isOpen) {\n      fetchRecords('');\n    } else {\n      setSearchTerm('');\n      setRecords([]);\n      setError(null);\n    }\n  };\n\n  const handleSearch = (term: string) => {\n    setSearchTerm(term);\n    fetchRecords(term);\n  };\n\n  const handleSelectRecord = (record: LinkedRecord) => {\n    if (isMultipleValue) {\n      const alreadySelected = selectedRecords.some((r) => r.id === record.id);\n      if (alreadySelected) {\n        const newRecords = selectedRecords.filter((r) => r.id !== record.id);\n        onChange(newRecords.length > 0 ? newRecords : null);\n      } else {\n        onChange([...selectedRecords, record]);\n      }\n    } else {\n      onChange(record);\n      setOpen(false);\n    }\n  };\n\n  const handleRemoveRecord = (recordId: string) => {\n    const newRecords = selectedRecords.filter((r) => r.id !== recordId);\n    onChange(newRecords.length > 0 ? newRecords : null);\n  };\n\n  const isSelected = (recordId: string) => selectedRecords.some((r) => r.id === recordId);\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center gap-1\">\n        <Dialog open={open} onOpenChange={handleOpenChange}>\n          <DialogTrigger asChild>\n            <Button\n              variant=\"outline\"\n              className=\"flex-1 justify-start font-normal\"\n              disabled={disabled}\n            >\n              <Link2 className=\"mr-2 h-4 w-4\" />\n              {selectedRecords.length > 0 ? (\n                `${selectedRecords.length} record${selectedRecords.length > 1 ? 's' : ''} linked`\n              ) : (\n                <span className=\"text-muted-foreground\">\n                  Link {fieldName.toLowerCase()}\n                  {isRequired ? '' : ' (optional)'}\n                </span>\n              )}\n            </Button>\n          </DialogTrigger>\n          <DialogContent className=\"sm:max-w-[500px]\">\n            <DialogHeader>\n              <DialogTitle className=\"flex items-center gap-2\">\n                <Link2 className=\"h-5 w-5\" />\n                Link Records\n              </DialogTitle>\n              <DialogDescription asChild>\n                <div>\n                  <span>Search and select records from the linked table.</span>\n                  <span className=\"mt-1 text-xs flex items-center gap-2\">\n                    <span>\n                      Foreign Table ID:{' '}\n                      <code className=\"bg-muted px-1 rounded\">{foreignTableId}</code>\n                    </span>\n                    {foreignTableUrl && (\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <Button\n                            type=\"button\"\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"h-5 w-5\"\n                            onClick={handleOpenForeignTable}\n                          >\n                            <SquareArrowOutUpRight className=\"h-3.5 w-3.5\" />\n                          </Button>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          <p>在新窗口中打开关联表</p>\n                        </TooltipContent>\n                      </Tooltip>\n                    )}\n                  </span>\n                </div>\n              </DialogDescription>\n            </DialogHeader>\n\n            <div className=\"space-y-4\">\n              <div className=\"relative\">\n                <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n                <Input\n                  placeholder=\"Search records...\"\n                  value={searchTerm}\n                  onChange={(e) => handleSearch(e.target.value)}\n                  className=\"pl-9\"\n                />\n              </div>\n\n              <ScrollArea className=\"h-[300px] rounded-md border\">\n                <div className=\"p-2\">\n                  {loading ? (\n                    <div className=\"flex items-center justify-center py-8 text-muted-foreground\">\n                      <div className=\"animate-spin rounded-full h-6 w-6 border-b-2 border-primary\" />\n                      <span className=\"ml-2\">Loading...</span>\n                    </div>\n                  ) : error ? (\n                    <div className=\"flex flex-col items-center justify-center py-8 text-destructive\">\n                      <AlertCircle className=\"h-8 w-8 mb-2\" />\n                      <span className=\"text-center\">{error}</span>\n                    </div>\n                  ) : records.length === 0 ? (\n                    <div className=\"flex flex-col items-center justify-center py-8 text-muted-foreground\">\n                      <ExternalLink className=\"h-8 w-8 mb-2\" />\n                      <span>No records found</span>\n                      {searchTerm && (\n                        <span className=\"text-xs mt-1\">Try a different search term</span>\n                      )}\n                    </div>\n                  ) : (\n                    <div className=\"space-y-1\">\n                      {records.map((record) => (\n                        <button\n                          key={record.id}\n                          type=\"button\"\n                          onClick={() => handleSelectRecord(record)}\n                          className={`w-full flex items-center justify-between p-2 rounded-md text-left transition-colors ${\n                            isSelected(record.id) ? 'bg-primary/10 text-primary' : 'hover:bg-muted'\n                          }`}\n                        >\n                          <div className=\"flex flex-col truncate\">\n                            <span className=\"truncate font-medium\">{record.title}</span>\n                            <span className=\"text-xs text-muted-foreground truncate\">\n                              {record.id}\n                            </span>\n                          </div>\n                          {isSelected(record.id) ? (\n                            <Badge variant=\"secondary\" className=\"ml-2 shrink-0\">\n                              Selected\n                            </Badge>\n                          ) : (\n                            <Plus className=\"h-4 w-4 text-muted-foreground shrink-0\" />\n                          )}\n                        </button>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              </ScrollArea>\n            </div>\n          </DialogContent>\n        </Dialog>\n        {foreignTableUrl && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"h-9 w-9 shrink-0\"\n                onClick={handleOpenForeignTable}\n              >\n                <SquareArrowOutUpRight className=\"h-4 w-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>在新窗口中打开关联表</p>\n            </TooltipContent>\n          </Tooltip>\n        )}\n      </div>\n\n      {selectedRecords.length > 0 && (\n        <div className=\"flex flex-wrap gap-1\">\n          {selectedRecords.map((record) => (\n            <Badge key={record.id} variant=\"secondary\" className=\"gap-1 pr-1\">\n              <Link2 className=\"h-3 w-3\" />\n              {record.title}\n              <button\n                type=\"button\"\n                onClick={() => handleRemoveRecord(record.id)}\n                className=\"ml-1 hover:bg-muted rounded-full p-0.5\"\n                disabled={disabled}\n              >\n                <X className=\"h-3 w-3\" />\n              </button>\n            </Badge>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/NumberFieldInput.tsx",
    "content": "import { Input } from '@/components/ui/input';\nimport type { FieldInputProps } from './types';\n\nexport function NumberFieldInput({ field, value, onChange, onBlur, disabled }: FieldInputProps) {\n  const fieldName = field.name().toString();\n  const isRequired = field.notNull().toBoolean();\n\n  return (\n    <Input\n      id={field.id().toString()}\n      type=\"number\"\n      value={typeof value === 'number' ? value : ''}\n      onChange={(e) => {\n        const val = e.target.value;\n        onChange(val === '' ? null : Number(val));\n      }}\n      onBlur={onBlur}\n      disabled={disabled}\n      placeholder={`Enter ${fieldName.toLowerCase()}${isRequired ? '' : ' (optional)'}`}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/RatingFieldInput.tsx",
    "content": "import { Star } from 'lucide-react';\nimport { type RatingField } from '@teable/v2-core';\nimport { cn } from '@/lib/utils';\nimport type { FieldInputProps } from './types';\n\nexport function RatingFieldInput({ field, value, onChange, disabled }: FieldInputProps) {\n  // Cast to RatingField to access rating-specific methods\n  const ratingField = field as RatingField;\n  const max = ratingField.ratingMax().toNumber();\n  const currentValue = typeof value === 'number' ? value : 0;\n\n  const handleClick = (rating: number) => {\n    if (disabled) return;\n    // If clicking the same rating, clear it\n    onChange(currentValue === rating ? null : rating);\n  };\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {Array.from({ length: max }, (_, i) => i + 1).map((rating) => (\n        <button\n          key={rating}\n          type=\"button\"\n          onClick={() => handleClick(rating)}\n          disabled={disabled}\n          className={cn(\n            'p-0.5 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 rounded',\n            disabled && 'cursor-not-allowed opacity-50'\n          )}\n        >\n          <Star\n            className={cn(\n              'h-5 w-5 transition-colors',\n              rating <= currentValue\n                ? 'fill-yellow-400 text-yellow-400'\n                : 'fill-transparent text-muted-foreground hover:text-yellow-300'\n            )}\n          />\n        </button>\n      ))}\n      <span className=\"ml-2 text-sm text-muted-foreground\">\n        {currentValue > 0 ? `${currentValue}/${max}` : `0/${max}`}\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/SelectFieldInput.tsx",
    "content": "import { Check, X } from 'lucide-react';\nimport { type SingleSelectField, type MultipleSelectField } from '@teable/v2-core';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport { useState } from 'react';\nimport type { FieldInputProps } from './types';\n\ninterface SelectOption {\n  id: string;\n  name: string;\n  color: string;\n}\n\nfunction getSelectOptions(field: SingleSelectField | MultipleSelectField): SelectOption[] {\n  return field.selectOptions().map((opt) => ({\n    id: opt.id().toString(),\n    name: opt.name().toString(),\n    color: opt.color().toString(),\n  }));\n}\n\nfunction SingleSelectInput({\n  field,\n  value,\n  onChange,\n  disabled,\n}: FieldInputProps & { field: SingleSelectField }) {\n  const options = getSelectOptions(field);\n  const fieldName = field.name().toString();\n  const isRequired = field.notNull().toBoolean();\n\n  if (options.length === 0) {\n    return (\n      <div className=\"text-sm text-muted-foreground p-2 border rounded-md\">\n        No options configured for this field\n      </div>\n    );\n  }\n\n  return (\n    <Select\n      value={typeof value === 'string' ? value : ''}\n      onValueChange={(val) => onChange(val || null)}\n      disabled={disabled}\n    >\n      <SelectTrigger size=\"lg\">\n        <SelectValue\n          placeholder={`Select ${fieldName.toLowerCase()}${isRequired ? '' : ' (optional)'}`}\n        />\n      </SelectTrigger>\n      <SelectContent>\n        {options.map((option) => (\n          <SelectItem key={option.id} value={option.id}>\n            <div className=\"flex items-center gap-2\">\n              <div\n                className=\"w-3 h-3 rounded-full\"\n                style={{ backgroundColor: getColorHex(option.color) }}\n              />\n              {option.name}\n            </div>\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n}\n\nfunction MultipleSelectInput({\n  field,\n  value,\n  onChange,\n  disabled,\n}: FieldInputProps & { field: MultipleSelectField }) {\n  const [open, setOpen] = useState(false);\n  const options = getSelectOptions(field);\n  const fieldName = field.name().toString();\n\n  const selectedValues: string[] = Array.isArray(value) ? value : [];\n\n  if (options.length === 0) {\n    return (\n      <div className=\"text-sm text-muted-foreground p-2 border rounded-md\">\n        No options configured for this field\n      </div>\n    );\n  }\n\n  const handleSelect = (optionId: string) => {\n    const newValues = selectedValues.includes(optionId)\n      ? selectedValues.filter((v) => v !== optionId)\n      : [...selectedValues, optionId];\n    onChange(newValues.length > 0 ? newValues : null);\n  };\n\n  const handleRemove = (optionId: string) => {\n    const newValues = selectedValues.filter((v) => v !== optionId);\n    onChange(newValues.length > 0 ? newValues : null);\n  };\n\n  const getOptionById = (id: string) => options.find((opt) => opt.id === id);\n\n  return (\n    <div className=\"space-y-2\">\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger asChild>\n          <Button\n            variant=\"outline\"\n            role=\"combobox\"\n            aria-expanded={open}\n            disabled={disabled}\n            className=\"w-full justify-between font-normal\"\n          >\n            {selectedValues.length > 0\n              ? `${selectedValues.length} selected`\n              : `Select ${fieldName.toLowerCase()}`}\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent className=\"w-full p-0\" align=\"start\">\n          <Command>\n            <CommandInput placeholder=\"Search options...\" />\n            <CommandList>\n              <CommandEmpty>No options found.</CommandEmpty>\n              <CommandGroup>\n                {options.map((option) => (\n                  <CommandItem\n                    key={option.id}\n                    onSelect={() => handleSelect(option.id)}\n                    className=\"cursor-pointer\"\n                  >\n                    <div className=\"flex items-center gap-2 flex-1\">\n                      <div\n                        className=\"w-3 h-3 rounded-full shrink-0\"\n                        style={{ backgroundColor: getColorHex(option.color) }}\n                      />\n                      {option.name}\n                    </div>\n                    <Check\n                      className={cn(\n                        'h-4 w-4',\n                        selectedValues.includes(option.id) ? 'opacity-100' : 'opacity-0'\n                      )}\n                    />\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n\n      {selectedValues.length > 0 && (\n        <div className=\"flex flex-wrap gap-1\">\n          {selectedValues.map((id) => {\n            const option = getOptionById(id);\n            if (!option) return null;\n            return (\n              <Badge\n                key={id}\n                variant=\"secondary\"\n                className=\"gap-1 pr-1\"\n                style={{\n                  backgroundColor: getColorHex(option.color) + '20',\n                  borderColor: getColorHex(option.color),\n                }}\n              >\n                {option.name}\n                <button\n                  type=\"button\"\n                  onClick={() => handleRemove(id)}\n                  className=\"ml-1 hover:bg-muted rounded-full p-0.5\"\n                  disabled={disabled}\n                >\n                  <X className=\"h-3 w-3\" />\n                </button>\n              </Badge>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Map field color names to hex values\nfunction getColorHex(color: string): string {\n  const colorMap: Record<string, string> = {\n    blue: '#3b82f6',\n    red: '#ef4444',\n    green: '#22c55e',\n    yellow: '#eab308',\n    orange: '#f97316',\n    purple: '#a855f7',\n    pink: '#ec4899',\n    teal: '#14b8a6',\n    cyan: '#06b6d4',\n    gray: '#6b7280',\n  };\n  return colorMap[color] || '#6b7280';\n}\n\nexport function SelectFieldInput(props: FieldInputProps) {\n  const fieldType = props.field.type().toString();\n\n  if (fieldType === 'singleSelect') {\n    return <SingleSelectInput {...props} field={props.field as SingleSelectField} />;\n  }\n\n  if (fieldType === 'multipleSelect') {\n    return <MultipleSelectInput {...props} field={props.field as MultipleSelectField} />;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/TextFieldInput.tsx",
    "content": "import { Input } from '@/components/ui/input';\nimport { Textarea } from '@/components/ui/textarea';\nimport type { FieldInputProps } from './types';\n\nexport function TextFieldInput({ field, value, onChange, onBlur, disabled }: FieldInputProps) {\n  const fieldType = field.type().toString();\n  const fieldName = field.name().toString();\n  const isRequired = field.notNull().toBoolean();\n  const isLongText = fieldType === 'longText';\n\n  if (isLongText) {\n    return (\n      <Textarea\n        id={field.id().toString()}\n        value={typeof value === 'string' ? value : ''}\n        onChange={(e) => onChange(e.target.value || null)}\n        onBlur={onBlur}\n        disabled={disabled}\n        placeholder={`Enter ${fieldName.toLowerCase()}${isRequired ? '' : ' (optional)'}`}\n        rows={3}\n      />\n    );\n  }\n\n  return (\n    <Input\n      id={field.id().toString()}\n      type=\"text\"\n      value={typeof value === 'string' ? value : ''}\n      onChange={(e) => onChange(e.target.value || null)}\n      onBlur={onBlur}\n      disabled={disabled}\n      placeholder={`Enter ${fieldName.toLowerCase()}${isRequired ? '' : ' (optional)'}`}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/index.tsx",
    "content": "import type { Field } from '@teable/v2-core';\nimport { TextFieldInput } from './TextFieldInput';\nimport { NumberFieldInput } from './NumberFieldInput';\nimport { RatingFieldInput } from './RatingFieldInput';\nimport { CheckboxFieldInput } from './CheckboxFieldInput';\nimport { SelectFieldInput } from './SelectFieldInput';\nimport { DateFieldInput } from './DateFieldInput';\nimport { LinkFieldInput } from './LinkFieldInput';\nimport { DisabledFieldInput } from './DisabledFieldInput';\nimport type { FieldInputProps } from './types';\n\nexport { TextFieldInput } from './TextFieldInput';\nexport { NumberFieldInput } from './NumberFieldInput';\nexport { RatingFieldInput } from './RatingFieldInput';\nexport { CheckboxFieldInput } from './CheckboxFieldInput';\nexport { SelectFieldInput } from './SelectFieldInput';\nexport { DateFieldInput } from './DateFieldInput';\nexport { LinkFieldInput } from './LinkFieldInput';\nexport { DisabledFieldInput } from './DisabledFieldInput';\nexport type { FieldInputProps } from './types';\n\n/**\n * Get the appropriate field input component based on field type.\n */\nexport function getFieldInputComponent(\n  fieldType: string\n): React.ComponentType<FieldInputProps> | null {\n  switch (fieldType) {\n    case 'singleLineText':\n    case 'longText':\n      return TextFieldInput;\n    case 'number':\n      return NumberFieldInput;\n    case 'rating':\n      return RatingFieldInput;\n    case 'checkbox':\n      return CheckboxFieldInput;\n    case 'singleSelect':\n    case 'multipleSelect':\n      return SelectFieldInput;\n    case 'date':\n      return DateFieldInput;\n    case 'link':\n      return LinkFieldInput;\n    case 'attachment':\n    case 'user':\n    case 'button':\n      return DisabledFieldInput;\n    // Computed fields - should not appear in create form\n    case 'formula':\n    case 'rollup':\n    case 'lookup':\n    case 'createdTime':\n    case 'lastModifiedTime':\n    case 'createdBy':\n    case 'lastModifiedBy':\n    case 'autoNumber':\n      return null;\n    default:\n      return null;\n  }\n}\n\n/**\n * Render a field input based on field type.\n */\nexport function FieldInput(props: FieldInputProps) {\n  const fieldType = props.field.type().toString();\n  const Component = getFieldInputComponent(fieldType);\n\n  if (!Component) {\n    return (\n      <div className=\"text-sm text-muted-foreground p-2 border rounded-md\">\n        Unsupported field type: {fieldType}\n      </div>\n    );\n  }\n\n  return <Component {...props} />;\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-inputs/types.ts",
    "content": "import type { Field } from '@teable/v2-core';\nimport type { V2HttpClient } from '@/lib/orpc/OrpcClientContext';\n\nexport interface FieldInputProps {\n  field: Field;\n  value: unknown;\n  onChange: (value: unknown) => void;\n  onBlur: () => void;\n  disabled?: boolean;\n  /** ORPC client for fetching related data (e.g., link field records) */\n  orpcClient?: V2HttpClient;\n  /** Base ID for constructing navigation links (e.g., link field foreign table) */\n  baseId?: string;\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/ButtonOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Switch } from '@/components/ui/switch';\nimport { fieldColorValues } from '@teable/v2-core';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function ButtonOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.label\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Button Label</Label>\n            <Input\n              value={(field.state.value as string) || ''}\n              onChange={(e) => field.handleChange(e.target.value as any)}\n              placeholder=\"e.g. Click Me\"\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.color\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Button Color</Label>\n            <Select\n              value={(field.state.value as string) || 'blueBright'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {fieldColorValues.map((color) => (\n                  <SelectItem key={color} value={color}>\n                    {color}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.maxCount\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Max Click Count</Label>\n            <Input\n              type=\"number\"\n              value={(field.state.value as number) ?? ''}\n              onChange={(e) =>\n                field.handleChange(\n                  e.target.value ? (parseInt(e.target.value) as any) : (undefined as any)\n                )\n              }\n              placeholder=\"Unlimited if empty\"\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.resetCount\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Reset Count Daily</Label>\n              <Switch\n                id={field.name}\n                checked={(field.state.value as boolean) || false}\n                onCheckedChange={(checked) => field.handleChange(checked as any)}\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/CheckboxOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Switch } from '@/components/ui/switch';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function CheckboxOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.defaultValue\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Default Value</Label>\n              <Switch\n                id={field.name}\n                checked={(field.state.value as boolean) || false}\n                onCheckedChange={(checked) => field.handleChange(checked as any)}\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/ConditionBuilder.tsx",
    "content": "import { useState, useCallback } from 'react';\nimport { Plus, Trash2, GripVertical } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Label } from '@/components/ui/label';\nimport { cn } from '@/lib/utils';\nimport type { ITableDto } from '@teable/v2-contract-http';\n\n// Types for the condition structure (compatible with v1 IFilter format)\nexport interface IFilterItem {\n  fieldId: string;\n  operator: string;\n  value?: unknown;\n  isSymbol?: boolean;\n}\n\nexport interface IFilterGroup {\n  conjunction: 'and' | 'or';\n  filterSet: (IFilterItem | IFilterGroup)[];\n}\n\nexport interface ConditionValue {\n  filter?: IFilterGroup | null;\n  sort?: { fieldId: string; order: 'asc' | 'desc' };\n  limit?: number;\n}\n\n// Available operators based on field type\nconst TEXT_OPERATORS = [\n  { value: 'is', label: 'is' },\n  { value: 'isNot', label: 'is not' },\n  { value: 'contains', label: 'contains' },\n  { value: 'doesNotContain', label: 'does not contain' },\n  { value: 'isEmpty', label: 'is empty' },\n  { value: 'isNotEmpty', label: 'is not empty' },\n];\n\nconst NUMBER_OPERATORS = [\n  { value: 'is', label: '=' },\n  { value: 'isNot', label: '≠' },\n  { value: 'isGreater', label: '>' },\n  { value: 'isGreaterEqual', label: '≥' },\n  { value: 'isLess', label: '<' },\n  { value: 'isLessEqual', label: '≤' },\n  { value: 'isEmpty', label: 'is empty' },\n  { value: 'isNotEmpty', label: 'is not empty' },\n];\n\nconst SELECT_OPERATORS = [\n  { value: 'is', label: 'is' },\n  { value: 'isNot', label: 'is not' },\n  { value: 'isAnyOf', label: 'is any of' },\n  { value: 'isNoneOf', label: 'is none of' },\n  { value: 'isEmpty', label: 'is empty' },\n  { value: 'isNotEmpty', label: 'is not empty' },\n];\n\nconst MULTI_SELECT_OPERATORS = [\n  ...SELECT_OPERATORS,\n  { value: 'hasAnyOf', label: 'has any of' },\n  { value: 'hasAllOf', label: 'has all of' },\n  { value: 'hasNoneOf', label: 'has none of' },\n  { value: 'isExactly', label: 'is exactly' },\n];\n\nconst getOperatorsForFieldType = (fieldType: string) => {\n  switch (fieldType) {\n    case 'number':\n    case 'rating':\n    case 'autoNumber':\n      return NUMBER_OPERATORS;\n    case 'singleSelect':\n      return SELECT_OPERATORS;\n    case 'multipleSelect':\n      return MULTI_SELECT_OPERATORS;\n    default:\n      return TEXT_OPERATORS;\n  }\n};\n\nconst isNullaryOperator = (operator: string) => {\n  return operator === 'isEmpty' || operator === 'isNotEmpty';\n};\n\ninterface FilterItemRowProps {\n  item: IFilterItem;\n  fields: ITableDto['fields'];\n  onChange: (item: IFilterItem) => void;\n  onRemove: () => void;\n  showRemove: boolean;\n}\n\nfunction FilterItemRow({ item, fields, onChange, onRemove, showRemove }: FilterItemRowProps) {\n  const selectedField = fields.find((f) => f.id === item.fieldId);\n  const operators = getOperatorsForFieldType(selectedField?.type ?? 'singleLineText');\n\n  return (\n    <div className=\"flex items-center gap-2 py-1\">\n      <GripVertical className=\"h-4 w-4 text-muted-foreground cursor-move opacity-50\" />\n\n      <Select\n        value={item.fieldId}\n        onValueChange={(value) => onChange({ ...item, fieldId: value, value: undefined })}\n      >\n        <SelectTrigger size=\"lg\" className=\"w-[140px]\">\n          <SelectValue placeholder=\"Field\" />\n        </SelectTrigger>\n        <SelectContent>\n          {fields.map((field) => (\n            <SelectItem key={field.id} value={field.id}>\n              {field.name}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n\n      <Select\n        value={item.operator}\n        onValueChange={(value) => onChange({ ...item, operator: value })}\n      >\n        <SelectTrigger size=\"lg\" className=\"w-[120px]\">\n          <SelectValue placeholder=\"Operator\" />\n        </SelectTrigger>\n        <SelectContent>\n          {operators.map((op) => (\n            <SelectItem key={op.value} value={op.value}>\n              {op.label}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n\n      {!isNullaryOperator(item.operator) && (\n        <Input\n          value={String(item.value ?? '')}\n          onChange={(e) => onChange({ ...item, value: e.target.value })}\n          placeholder=\"Value\"\n          className=\"w-[120px]\"\n        />\n      )}\n\n      {showRemove && (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onRemove}\n          className=\"h-8 w-8 text-muted-foreground hover:text-destructive\"\n        >\n          <Trash2 className=\"h-4 w-4\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n\ninterface FilterGroupProps {\n  group: IFilterGroup;\n  fields: ITableDto['fields'];\n  onChange: (group: IFilterGroup) => void;\n  onRemove?: () => void;\n  depth?: number;\n}\n\nfunction FilterGroup({ group, fields, onChange, onRemove, depth = 0 }: FilterGroupProps) {\n  const addFilterItem = () => {\n    const firstField = fields[0];\n    const newItem: IFilterItem = {\n      fieldId: firstField?.id ?? '',\n      operator: 'is',\n      value: '',\n    };\n    onChange({\n      ...group,\n      filterSet: [...group.filterSet, newItem],\n    });\n  };\n\n  const addFilterGroup = () => {\n    const firstField = fields[0];\n    const newGroup: IFilterGroup = {\n      conjunction: group.conjunction === 'and' ? 'or' : 'and',\n      filterSet: [\n        {\n          fieldId: firstField?.id ?? '',\n          operator: 'is',\n          value: '',\n        },\n      ],\n    };\n    onChange({\n      ...group,\n      filterSet: [...group.filterSet, newGroup],\n    });\n  };\n\n  const updateItem = (index: number, item: IFilterItem | IFilterGroup) => {\n    const newFilterSet = [...group.filterSet];\n    newFilterSet[index] = item;\n    onChange({ ...group, filterSet: newFilterSet });\n  };\n\n  const removeItem = (index: number) => {\n    const newFilterSet = group.filterSet.filter((_, i) => i !== index);\n    onChange({ ...group, filterSet: newFilterSet });\n  };\n\n  const isFilterItem = (item: IFilterItem | IFilterGroup): item is IFilterItem => {\n    return 'fieldId' in item;\n  };\n\n  return (\n    <div\n      className={cn(\n        'rounded-lg border p-3',\n        depth === 0 ? 'bg-muted/30' : 'bg-background',\n        depth > 0 && 'ml-4'\n      )}\n    >\n      <div className=\"flex items-center gap-2 mb-2\">\n        <span className=\"text-xs text-muted-foreground\">Where</span>\n        <Select\n          value={group.conjunction}\n          onValueChange={(value) => onChange({ ...group, conjunction: value as 'and' | 'or' })}\n        >\n          <SelectTrigger size=\"sm\" className=\"w-[80px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"and\">And</SelectItem>\n            <SelectItem value=\"or\">Or</SelectItem>\n          </SelectContent>\n        </Select>\n        <span className=\"text-xs text-muted-foreground\">of the following are true...</span>\n        {onRemove && (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={onRemove}\n            className=\"h-6 w-6 ml-auto text-muted-foreground hover:text-destructive\"\n          >\n            <Trash2 className=\"h-3 w-3\" />\n          </Button>\n        )}\n      </div>\n\n      <div className=\"space-y-1\">\n        {group.filterSet.map((item, index) => (\n          <div key={index}>\n            {isFilterItem(item) ? (\n              <FilterItemRow\n                item={item}\n                fields={fields}\n                onChange={(newItem) => updateItem(index, newItem)}\n                onRemove={() => removeItem(index)}\n                showRemove={group.filterSet.length > 1}\n              />\n            ) : (\n              <FilterGroup\n                group={item}\n                fields={fields}\n                onChange={(newGroup) => updateItem(index, newGroup)}\n                onRemove={() => removeItem(index)}\n                depth={depth + 1}\n              />\n            )}\n          </div>\n        ))}\n      </div>\n\n      <div className=\"flex gap-2 mt-2\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={addFilterItem}\n          className=\"text-xs text-muted-foreground hover:text-foreground\"\n        >\n          <Plus className=\"h-3 w-3 mr-1\" />\n          Add condition\n        </Button>\n        {depth < 2 && (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={addFilterGroup}\n            className=\"text-xs text-muted-foreground hover:text-foreground\"\n          >\n            <Plus className=\"h-3 w-3 mr-1\" />\n            Add condition group\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n\ninterface ConditionBuilderProps {\n  value: ConditionValue;\n  onChange: (value: ConditionValue) => void;\n  fields: ITableDto['fields'];\n  showSort?: boolean;\n  showLimit?: boolean;\n}\n\nexport function ConditionBuilder({\n  value,\n  onChange,\n  fields,\n  showSort = true,\n  showLimit = true,\n}: ConditionBuilderProps) {\n  const [showFilter, setShowFilter] = useState(!!value.filter);\n\n  const initFilter = useCallback(() => {\n    const firstField = fields[0];\n    return {\n      conjunction: 'and' as const,\n      filterSet: [\n        {\n          fieldId: firstField?.id ?? '',\n          operator: 'is',\n          value: '',\n        },\n      ],\n    };\n  }, [fields]);\n\n  const handleFilterChange = (filter: IFilterGroup) => {\n    onChange({ ...value, filter });\n  };\n\n  const handleAddFilter = () => {\n    setShowFilter(true);\n    if (!value.filter) {\n      onChange({ ...value, filter: initFilter() });\n    }\n  };\n\n  const handleRemoveFilter = () => {\n    setShowFilter(false);\n    onChange({ ...value, filter: null });\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Filter Section */}\n      <div>\n        <div className=\"flex items-center justify-between mb-2\">\n          <Label className=\"text-sm font-medium\">Filter Conditions</Label>\n          {!showFilter ? (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleAddFilter}\n              disabled={fields.length === 0}\n            >\n              <Plus className=\"h-3 w-3 mr-1\" />\n              Add Filter\n            </Button>\n          ) : (\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={handleRemoveFilter}\n              className=\"text-muted-foreground hover:text-destructive\"\n            >\n              <Trash2 className=\"h-3 w-3 mr-1\" />\n              Clear\n            </Button>\n          )}\n        </div>\n\n        {showFilter && value.filter && (\n          <FilterGroup group={value.filter} fields={fields} onChange={handleFilterChange} />\n        )}\n\n        {!showFilter && (\n          <p className=\"text-xs text-muted-foreground\">\n            No filter conditions. All records will be included.\n          </p>\n        )}\n      </div>\n\n      {/* Sort Section */}\n      {showSort && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm font-medium\">Sort</Label>\n          <div className=\"flex gap-2\">\n            <Select\n              value={value.sort?.fieldId ?? '__none__'}\n              onValueChange={(fieldId) =>\n                onChange({\n                  ...value,\n                  sort:\n                    fieldId && fieldId !== '__none__'\n                      ? { fieldId, order: value.sort?.order ?? 'asc' }\n                      : undefined,\n                })\n              }\n            >\n              <SelectTrigger size=\"lg\" className=\"w-[160px]\">\n                <SelectValue placeholder=\"Sort by field\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"__none__\">No sort</SelectItem>\n                {fields.map((field) => (\n                  <SelectItem key={field.id} value={field.id}>\n                    {field.name}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n\n            {value.sort?.fieldId && (\n              <Select\n                value={value.sort.order}\n                onValueChange={(order) =>\n                  onChange({\n                    ...value,\n                    sort: { ...value.sort!, order: order as 'asc' | 'desc' },\n                  })\n                }\n              >\n                <SelectTrigger size=\"lg\" className=\"w-[120px]\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"asc\">Ascending</SelectItem>\n                  <SelectItem value=\"desc\">Descending</SelectItem>\n                </SelectContent>\n              </Select>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Limit Section */}\n      {showLimit && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm font-medium\">Limit</Label>\n          <Input\n            type=\"number\"\n            min={1}\n            value={value.limit ?? ''}\n            onChange={(e) => {\n              const num = parseInt(e.target.value, 10);\n              onChange({\n                ...value,\n                limit: isNaN(num) || num < 1 ? undefined : num,\n              });\n            }}\n            placeholder=\"No limit\"\n            className=\"w-[120px]\"\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            Maximum number of records to include (optional)\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/ConditionalLookupOptions.tsx",
    "content": "import { useMemo } from 'react';\nimport { Label } from '@/components/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport type { FieldFormApi } from '../FieldForm';\nimport { ConditionBuilder, type ConditionValue } from './ConditionBuilder';\n\ntype ConditionalLookupOptionsProps = {\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n};\n\nexport function ConditionalLookupOptions({\n  form,\n  tableId,\n  tables,\n  isTablesLoading,\n}: ConditionalLookupOptionsProps) {\n  // Cast form to any to work around strict typing for nested paths\n  const formAny = form as any;\n  // Get all tables except current for foreign table selection\n  const foreignTables = useMemo(\n    () => tables.filter((table) => table.id !== tableId),\n    [tables, tableId]\n  );\n\n  return (\n    <div className=\"space-y-4\">\n      {foreignTables.length === 0 ? (\n        <p className=\"text-xs text-muted-foreground\">\n          No other tables available. Create another table first.\n        </p>\n      ) : null}\n\n      {/* Foreign Table Selection */}\n      <form.Field\n        name=\"options.foreignTableId\"\n        children={(field) => {\n          const selectedTableId = field.state.value as string | undefined;\n          const foreignTable = tables.find((t) => t.id === selectedTableId);\n          const foreignFields = foreignTable?.fields ?? [];\n\n          return (\n            <div className=\"space-y-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor={field.name}>Foreign Table</Label>\n                <Select\n                  value={selectedTableId ?? ''}\n                  onValueChange={(value) => {\n                    const nextTable = tables.find((t) => t.id === value);\n                    const nextLookupField =\n                      nextTable?.fields.find((f) => f.isPrimary) ?? nextTable?.fields[0];\n\n                    field.handleChange(value as any);\n                    formAny.setFieldValue(\n                      'options.lookupFieldId',\n                      (nextLookupField?.id ?? '') as any\n                    );\n                    // Reset condition when table changes\n                    formAny.setFieldValue('options.condition', { filter: null } as any);\n                  }}\n                  disabled={foreignTables.length === 0 || isTablesLoading}\n                >\n                  <SelectTrigger size=\"lg\" id={field.name}>\n                    <SelectValue\n                      placeholder={isTablesLoading ? 'Loading...' : 'Select foreign table'}\n                    />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {foreignTables.map((table) => (\n                      <SelectItem key={table.id} value={table.id}>\n                        {table.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                {field.state.meta.errors ? (\n                  <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n                ) : null}\n              </div>\n\n              {/* Lookup Field Selection */}\n              <form.Field\n                name=\"options.lookupFieldId\"\n                children={(lookupField) => (\n                  <div className=\"space-y-2\">\n                    <Label htmlFor={lookupField.name}>Lookup Field</Label>\n                    <Select\n                      value={(lookupField.state.value as string | undefined) ?? ''}\n                      onValueChange={(value) => lookupField.handleChange(value as any)}\n                      disabled={!foreignFields.length}\n                    >\n                      <SelectTrigger size=\"lg\" id={lookupField.name}>\n                        <SelectValue placeholder=\"Select lookup field\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {foreignFields.map((f) => (\n                          <SelectItem key={f.id} value={f.id}>\n                            {f.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {lookupField.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {lookupField.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n\n              {/* Condition Builder */}\n              {selectedTableId && foreignFields.length > 0 && (\n                <form.Field\n                  name=\"options.condition\"\n                  children={(conditionField) => (\n                    <div className=\"space-y-2\">\n                      <Label>Condition (filter records from foreign table)</Label>\n                      <ConditionBuilder\n                        value={(conditionField.state.value as ConditionValue) ?? { filter: null }}\n                        onChange={(value) => conditionField.handleChange(value as any)}\n                        fields={foreignFields}\n                        showSort={true}\n                        showLimit={true}\n                      />\n                    </div>\n                  )}\n                />\n              )}\n            </div>\n          );\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/ConditionalRollupOptions.tsx",
    "content": "import { useMemo } from 'react';\nimport { Label } from '@/components/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { ROLLUP_FUNCTIONS, TIME_ZONE_LIST } from '@teable/v2-core';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport type { FieldFormApi } from '../FieldForm';\nimport { ConditionBuilder, type ConditionValue } from './ConditionBuilder';\n\ntype ConditionalRollupOptionsProps = {\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n};\n\nexport function ConditionalRollupOptions({\n  form,\n  tableId,\n  tables,\n  isTablesLoading,\n}: ConditionalRollupOptionsProps) {\n  // Cast form to any to work around strict typing for config paths\n  const formAny = form as any;\n  // Get all tables except current for foreign table selection\n  const foreignTables = useMemo(\n    () => tables.filter((table) => table.id !== tableId),\n    [tables, tableId]\n  );\n\n  return (\n    <div className=\"space-y-4\">\n      {foreignTables.length === 0 ? (\n        <p className=\"text-xs text-muted-foreground\">\n          No other tables available. Create another table first.\n        </p>\n      ) : null}\n\n      {/* Foreign Table Selection */}\n      <formAny.Field\n        name=\"config.foreignTableId\"\n        children={(field: any) => {\n          const selectedTableId = field.state.value as string | undefined;\n          const foreignTable = tables.find((t) => t.id === selectedTableId);\n          const foreignFields = foreignTable?.fields ?? [];\n\n          return (\n            <div className=\"space-y-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor={field.name}>Foreign Table</Label>\n                <Select\n                  value={selectedTableId ?? ''}\n                  onValueChange={(value) => {\n                    const nextTable = tables.find((t) => t.id === value);\n                    const nextLookupField =\n                      nextTable?.fields.find((f) => f.isPrimary) ?? nextTable?.fields[0];\n\n                    field.handleChange(value);\n                    formAny.setFieldValue('config.lookupFieldId', nextLookupField?.id ?? '');\n                    // Reset condition when table changes (condition is in config for conditionalRollup)\n                    formAny.setFieldValue('config.condition', { filter: null });\n                  }}\n                  disabled={foreignTables.length === 0 || isTablesLoading}\n                >\n                  <SelectTrigger size=\"lg\" id={field.name}>\n                    <SelectValue\n                      placeholder={isTablesLoading ? 'Loading...' : 'Select foreign table'}\n                    />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {foreignTables.map((table) => (\n                      <SelectItem key={table.id} value={table.id}>\n                        {table.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                {field.state.meta.errors ? (\n                  <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n                ) : null}\n              </div>\n\n              {/* Lookup Field Selection */}\n              <formAny.Field\n                name=\"config.lookupFieldId\"\n                children={(lookupField: any) => (\n                  <div className=\"space-y-2\">\n                    <Label htmlFor={lookupField.name}>Lookup Field (for aggregation)</Label>\n                    <Select\n                      value={(lookupField.state.value as string | undefined) ?? ''}\n                      onValueChange={(value) => lookupField.handleChange(value)}\n                      disabled={!foreignFields.length}\n                    >\n                      <SelectTrigger size=\"lg\" id={lookupField.name}>\n                        <SelectValue placeholder=\"Select lookup field\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {foreignFields.map((f) => (\n                          <SelectItem key={f.id} value={f.id}>\n                            {f.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {lookupField.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {lookupField.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n\n              {/* Condition Builder */}\n              {selectedTableId && foreignFields.length > 0 && (\n                <formAny.Field\n                  name=\"config.condition\"\n                  children={(conditionField: any) => (\n                    <div className=\"space-y-2\">\n                      <Label>Condition (filter records from foreign table)</Label>\n                      <ConditionBuilder\n                        value={(conditionField.state.value as ConditionValue) ?? { filter: null }}\n                        onChange={(value) => conditionField.handleChange(value)}\n                        fields={foreignFields}\n                        showSort={true}\n                        showLimit={true}\n                      />\n                    </div>\n                  )}\n                />\n              )}\n            </div>\n          );\n        }}\n      />\n\n      {/* Rollup Function */}\n      <form.Field\n        name=\"options.expression\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Rollup Function</Label>\n            <Select\n              value={(field.state.value as string | undefined) ?? ''}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\" id={field.name}>\n                <SelectValue placeholder=\"Select rollup function\" />\n              </SelectTrigger>\n              <SelectContent>\n                {ROLLUP_FUNCTIONS.map((expr) => (\n                  <SelectItem key={expr} value={expr}>\n                    {expr}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      {/* Time Zone */}\n      <form.Field\n        name=\"options.timeZone\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Time Zone</Label>\n            <Select\n              value={(field.state.value as string | undefined) ?? TIME_ZONE_LIST[0]}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\" id={field.name}>\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent className=\"max-h-[300px]\">\n                {TIME_ZONE_LIST.map((tz) => (\n                  <SelectItem key={tz} value={tz}>\n                    {tz}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/DateOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Switch } from '@/components/ui/switch';\nimport { TIME_ZONE_LIST } from '@teable/v2-core';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function DateOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.formatting.date\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Date Format</Label>\n            <Select\n              value={(field.state.value as string) || 'YYYY-MM-DD'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"YYYY-MM-DD\">2023-12-24</SelectItem>\n                <SelectItem value=\"MM/DD/YYYY\">12/24/2023</SelectItem>\n                <SelectItem value=\"DD/MM/YYYY\">24/12/2023</SelectItem>\n                <SelectItem value=\"YYYY/MM/DD\">2023/12/24</SelectItem>\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.formatting.time\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Time Format</Label>\n            <Select\n              value={(field.state.value as string) || 'None'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"None\">None</SelectItem>\n                <SelectItem value=\"Hour24\">24 Hour (13:00)</SelectItem>\n                <SelectItem value=\"Hour12\">12 Hour (1:00 PM)</SelectItem>\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.formatting.timeZone\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Time Zone</Label>\n            <Select\n              value={(field.state.value as string) || 'utc'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent className=\"max-h-[300px]\">\n                {TIME_ZONE_LIST.map((tz) => (\n                  <SelectItem key={tz} value={tz}>\n                    {tz}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.defaultValue\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Default to \"Now\"</Label>\n              <Switch\n                id={field.name}\n                checked={field.state.value === 'now'}\n                onCheckedChange={(checked) =>\n                  field.handleChange(checked ? ('now' as any) : (undefined as any))\n                }\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/FormulaOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Textarea } from '@/components/ui/textarea';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { TIME_ZONE_LIST } from '@teable/v2-core';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function FormulaOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.expression\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Formula Expression</Label>\n            <Textarea\n              value={(field.state.value as string) || ''}\n              onChange={(e) => field.handleChange(e.target.value as any)}\n              placeholder=\"e.g. {Field1} + {Field2}\"\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.timeZone\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Time Zone</Label>\n            <Select\n              value={(field.state.value as string) || 'utc'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent className=\"max-h-[300px]\">\n                {TIME_ZONE_LIST.map((tz) => (\n                  <SelectItem key={tz} value={tz}>\n                    {tz}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <p className=\"text-xs text-muted-foreground\">\n        Formatting and Show As options are automatically inferred based on the formula result type\n        in this version.\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/LinkOptions.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';\nimport { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport { Button } from '@/components/ui/button';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { cn } from '@/lib/utils';\nimport type { FieldFormApi } from '../FieldForm';\n\nconst relationshipOptions = [\n  { value: 'manyMany', label: 'Many to many' },\n  { value: 'oneMany', label: 'One to many' },\n  { value: 'manyOne', label: 'Many to one' },\n  { value: 'oneOne', label: 'One to one' },\n] as const;\n\ntype LinkOptionsProps = {\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n};\n\nexport function LinkOptions({ form, tableId, tables, isTablesLoading }: LinkOptionsProps) {\n  const availableTables = useMemo(\n    () => tables.filter((table) => table.id !== tableId),\n    [tables, tableId]\n  );\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.relationship\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Relationship</Label>\n            <Select\n              value={(field.state.value as string | undefined) ?? 'manyMany'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\" id={field.name}>\n                <SelectValue placeholder=\"Select relationship\" />\n              </SelectTrigger>\n              <SelectContent>\n                {relationshipOptions.map((option) => (\n                  <SelectItem key={option.value} value={option.value}>\n                    {option.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.foreignTableId\"\n        children={(field) => {\n          const selectedTableId = field.state.value as string | undefined;\n          const selectedTable =\n            availableTables.find((table) => table.id === selectedTableId) ?? null;\n          const lookupField =\n            selectedTable?.fields.find((value) => value.isPrimary) ??\n            selectedTable?.fields[0] ??\n            null;\n\n          return (\n            <div className=\"space-y-2\">\n              <Label htmlFor={field.name}>Linked Table</Label>\n              <Popover open={isOpen} onOpenChange={setIsOpen}>\n                <PopoverTrigger asChild>\n                  <Button\n                    id={field.name}\n                    variant=\"outline\"\n                    role=\"combobox\"\n                    aria-expanded={isOpen}\n                    className=\"w-full justify-between\"\n                    disabled={isTablesLoading}\n                  >\n                    {selectedTable?.name ??\n                      (isTablesLoading ? 'Loading tables...' : 'Select a table to link')}\n                    <ChevronsUpDownIcon className=\"ml-2 size-4 shrink-0 opacity-60\" />\n                  </Button>\n                </PopoverTrigger>\n                <PopoverContent className=\"w-[--radix-popover-trigger-width] p-0\" align=\"start\">\n                  <Command>\n                    <CommandInput placeholder=\"Search tables...\" />\n                    <CommandList>\n                      <CommandEmpty>\n                        {availableTables.length ? 'No matching tables.' : 'No other tables.'}\n                      </CommandEmpty>\n                      <CommandGroup>\n                        {availableTables.map((table) => (\n                          <CommandItem\n                            key={table.id}\n                            value={table.id}\n                            keywords={[table.name]}\n                            onSelect={() => {\n                              const nextTable =\n                                availableTables.find((entry) => entry.id === table.id) ?? null;\n                              const nextLookup =\n                                nextTable?.fields.find((entry) => entry.isPrimary) ??\n                                nextTable?.fields[0] ??\n                                null;\n                              field.handleChange(table.id as any);\n                              form.setFieldValue(\n                                'options.lookupFieldId',\n                                (nextLookup?.id ?? undefined) as any\n                              );\n                              setIsOpen(false);\n                            }}\n                          >\n                            <CheckIcon\n                              className={cn(\n                                'mr-2 size-4',\n                                table.id === selectedTableId ? 'opacity-100' : 'opacity-0'\n                              )}\n                            />\n                            {table.name}\n                          </CommandItem>\n                        ))}\n                      </CommandGroup>\n                    </CommandList>\n                  </Command>\n                </PopoverContent>\n              </Popover>\n              {field.state.meta.errors ? (\n                <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n              ) : null}\n\n              <form.Field\n                name=\"options.lookupFieldId\"\n                children={(lookupFieldState) => (\n                  <div className=\"space-y-2 pt-2\">\n                    <Label htmlFor={lookupFieldState.name}>Lookup Field</Label>\n                    <Input id={lookupFieldState.name} value={lookupField?.name ?? '-'} readOnly />\n                    {lookupFieldState.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {lookupFieldState.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n            </div>\n          );\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/LookupOptions.tsx",
    "content": "import { useMemo } from 'react';\nimport { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport type { FieldFormApi } from '../FieldForm';\n\ntype LinkFieldDto = Extract<ITableDto['fields'][number], { type: 'link' }>;\n\ntype LookupOptionsProps = {\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n};\n\nexport function LookupOptions({ form, tableId, tables, isTablesLoading }: LookupOptionsProps) {\n  const currentTable = useMemo(\n    () => tables.find((table) => table.id === tableId),\n    [tables, tableId]\n  );\n  const linkFields = useMemo(\n    () => (currentTable?.fields.filter((field) => field.type === 'link') ?? []) as LinkFieldDto[],\n    [currentTable]\n  );\n  const hasLinkFields = linkFields.length > 0;\n\n  return (\n    <div className=\"space-y-4\">\n      {!hasLinkFields ? (\n        <p className=\"text-xs text-muted-foreground\">\n          No link fields available. Create a link field first to use lookup.\n        </p>\n      ) : null}\n\n      <form.Field\n        name=\"options.linkFieldId\"\n        children={(field) => {\n          const selectedLinkId = field.state.value as string | undefined;\n          const selectedLinkField = linkFields.find((entry) => entry.id === selectedLinkId) ?? null;\n          const foreignTableId = selectedLinkField?.options?.foreignTableId;\n          const foreignTable = tables.find((table) => table.id === foreignTableId) ?? null;\n          const foreignFields = foreignTable?.fields ?? [];\n\n          return (\n            <div className=\"space-y-2\">\n              <Label htmlFor={field.name}>Link Field</Label>\n              <Select\n                value={selectedLinkId ?? ''}\n                onValueChange={(value) => {\n                  const nextLink = linkFields.find((entry) => entry.id === value) ?? null;\n                  const nextForeignTableId = nextLink?.options?.foreignTableId;\n                  const nextForeignTable =\n                    tables.find((table) => table.id === nextForeignTableId) ?? null;\n                  const nextLookupField =\n                    nextForeignTable?.fields.find((entry) => entry.isPrimary) ??\n                    nextForeignTable?.fields[0] ??\n                    null;\n\n                  field.handleChange((value || undefined) as any);\n                  form.setFieldValue('options.foreignTableId', (nextForeignTableId ?? '') as any);\n                  form.setFieldValue('options.lookupFieldId', (nextLookupField?.id ?? '') as any);\n                }}\n                disabled={!hasLinkFields || isTablesLoading}\n              >\n                <SelectTrigger size=\"lg\" id={field.name}>\n                  <SelectValue placeholder={isTablesLoading ? 'Loading...' : 'Select link field'} />\n                </SelectTrigger>\n                <SelectContent>\n                  {linkFields.map((entry) => (\n                    <SelectItem key={entry.id} value={entry.id}>\n                      <LinkFieldLabel\n                        name={entry.name}\n                        fieldId={entry.id}\n                        relationship={entry.options?.relationship ?? 'manyMany'}\n                        isOneWay={entry.options?.isOneWay ?? false}\n                      />\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {field.state.meta.errors ? (\n                <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n              ) : null}\n\n              <form.Field\n                name=\"options.foreignTableId\"\n                children={(foreignField) => (\n                  <div className=\"space-y-2 pt-2\">\n                    <Label htmlFor={foreignField.name}>Foreign Table</Label>\n                    <Input id={foreignField.name} value={foreignTable?.name ?? '-'} readOnly />\n                    {foreignField.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {foreignField.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n\n              <form.Field\n                name=\"options.lookupFieldId\"\n                children={(lookupFieldState) => (\n                  <div className=\"space-y-2 pt-2\">\n                    <Label htmlFor={lookupFieldState.name}>Lookup Field</Label>\n                    <Select\n                      value={(lookupFieldState.state.value as string | undefined) ?? ''}\n                      onValueChange={(value) => lookupFieldState.handleChange(value as any)}\n                      disabled={!foreignFields.length}\n                    >\n                      <SelectTrigger size=\"lg\" id={lookupFieldState.name}>\n                        <SelectValue placeholder=\"Select lookup field\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {foreignFields.map((entry) => (\n                          <SelectItem key={entry.id} value={entry.id}>\n                            {entry.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {lookupFieldState.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {lookupFieldState.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n            </div>\n          );\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/NumberOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function NumberOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.formatting.type\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Formatting Type</Label>\n            <Select\n              value={(field.state.value as string) || 'decimal'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"decimal\">Decimal</SelectItem>\n                <SelectItem value=\"percent\">Percent</SelectItem>\n                <SelectItem value=\"currency\">Currency</SelectItem>\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.formatting.precision\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Precision (0-5)</Label>\n            <Input\n              type=\"number\"\n              min={0}\n              max={5}\n              value={(field.state.value as number) ?? 2}\n              onChange={(e) => field.handleChange(parseInt(e.target.value) as any)}\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Subscribe\n        selector={(state) => (state.values.options as any)?.formatting?.type}\n        children={(type) =>\n          type === 'currency' ? (\n            <form.Field\n              name=\"options.formatting.symbol\"\n              children={(field) => (\n                <div className=\"space-y-2\">\n                  <Label>Currency Symbol</Label>\n                  <Input\n                    value={(field.state.value as string) || '$'}\n                    onChange={(e) => field.handleChange(e.target.value as any)}\n                  />\n                  {field.state.meta.errors ? (\n                    <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n                  ) : null}\n                </div>\n              )}\n            />\n          ) : null\n        }\n      />\n\n      <div className=\"space-y-2\">\n        <Label>Default Value</Label>\n        <form.Field\n          name=\"options.defaultValue\"\n          children={(field) => (\n            <>\n              <Input\n                type=\"number\"\n                value={(field.state.value as number) ?? ''}\n                onChange={(e) =>\n                  field.handleChange(\n                    e.target.value ? (parseFloat(e.target.value) as any) : (undefined as any)\n                  )\n                }\n              />\n              {field.state.meta.errors ? (\n                <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n              ) : null}\n            </>\n          )}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/RatingOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { fieldColorValues } from '@teable/v2-core';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function RatingOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.max\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Max Rating</Label>\n            <Input\n              type=\"number\"\n              min={1}\n              max={10}\n              value={(field.state.value as number) ?? 5}\n              onChange={(e) => field.handleChange(parseInt(e.target.value) as any)}\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.icon\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Icon</Label>\n            <Select\n              value={(field.state.value as string) || 'star'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"star\">Star</SelectItem>\n                <SelectItem value=\"heart\">Heart</SelectItem>\n                <SelectItem value=\"smile\">Smile</SelectItem>\n                <SelectItem value=\"flag\">Flag</SelectItem>\n                <SelectItem value=\"fire\">Fire</SelectItem>\n                <SelectItem value=\"thumbUp\">Thumb Up</SelectItem>\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.color\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label>Color</Label>\n            <Select\n              value={(field.state.value as string) || 'yellowBright'}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {fieldColorValues.map((color) => (\n                  <SelectItem key={color} value={color}>\n                    {color}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/RollupOptions.tsx",
    "content": "import { useMemo } from 'react';\nimport { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport { LinkFieldLabel } from '@/components/playground/LinkFieldLabel';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { ROLLUP_FUNCTIONS, TIME_ZONE_LIST } from '@teable/v2-core';\nimport type { ITableDto } from '@teable/v2-contract-http';\nimport type { FieldFormApi } from '../FieldForm';\n\ntype LinkFieldDto = Extract<ITableDto['fields'][number], { type: 'link' }>;\n\ntype RollupOptionsProps = {\n  form: FieldFormApi;\n  tableId: string;\n  tables: ReadonlyArray<ITableDto>;\n  isTablesLoading: boolean;\n};\n\nexport function RollupOptions({ form, tableId, tables, isTablesLoading }: RollupOptionsProps) {\n  const currentTable = useMemo(\n    () => tables.find((table) => table.id === tableId),\n    [tables, tableId]\n  );\n  const linkFields = useMemo(\n    () => (currentTable?.fields.filter((field) => field.type === 'link') ?? []) as LinkFieldDto[],\n    [currentTable]\n  );\n  const hasLinkFields = linkFields.length > 0;\n\n  return (\n    <div className=\"space-y-4\">\n      {!hasLinkFields ? (\n        <p className=\"text-xs text-muted-foreground\">\n          No link fields available. Create a link field first.\n        </p>\n      ) : null}\n\n      <form.Field\n        name=\"config.linkFieldId\"\n        children={(field) => {\n          const selectedLinkId = field.state.value as string | undefined;\n          const selectedLinkField = linkFields.find((entry) => entry.id === selectedLinkId) ?? null;\n          const foreignTableId = selectedLinkField?.options?.foreignTableId;\n          const foreignTable = tables.find((table) => table.id === foreignTableId) ?? null;\n          const foreignFields = foreignTable?.fields ?? [];\n\n          return (\n            <div className=\"space-y-2\">\n              <Label htmlFor={field.name}>Link Field</Label>\n              <Select\n                value={selectedLinkId ?? ''}\n                onValueChange={(value) => {\n                  const nextLink = linkFields.find((entry) => entry.id === value) ?? null;\n                  const nextForeignTableId = nextLink?.options?.foreignTableId;\n                  const nextForeignTable =\n                    tables.find((table) => table.id === nextForeignTableId) ?? null;\n                  const nextLookupField =\n                    nextForeignTable?.fields.find((entry) => entry.isPrimary) ??\n                    nextForeignTable?.fields[0] ??\n                    null;\n\n                  field.handleChange((value || undefined) as any);\n                  form.setFieldValue('config.foreignTableId', (nextForeignTableId ?? '') as any);\n                  form.setFieldValue('config.lookupFieldId', (nextLookupField?.id ?? '') as any);\n                }}\n                disabled={!hasLinkFields || isTablesLoading}\n              >\n                <SelectTrigger size=\"lg\" id={field.name}>\n                  <SelectValue placeholder={isTablesLoading ? 'Loading...' : 'Select link field'} />\n                </SelectTrigger>\n                <SelectContent>\n                  {linkFields.map((entry) => (\n                    <SelectItem key={entry.id} value={entry.id}>\n                      <LinkFieldLabel\n                        name={entry.name}\n                        fieldId={entry.id}\n                        relationship={entry.options?.relationship ?? 'manyMany'}\n                        isOneWay={entry.options?.isOneWay ?? false}\n                      />\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {field.state.meta.errors ? (\n                <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n              ) : null}\n\n              <form.Field\n                name=\"config.foreignTableId\"\n                children={(foreignField) => (\n                  <div className=\"space-y-2 pt-2\">\n                    <Label htmlFor={foreignField.name}>Foreign Table</Label>\n                    <Input id={foreignField.name} value={foreignTable?.name ?? '-'} readOnly />\n                    {foreignField.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {foreignField.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n\n              <form.Field\n                name=\"config.lookupFieldId\"\n                children={(lookupFieldState) => (\n                  <div className=\"space-y-2 pt-2\">\n                    <Label htmlFor={lookupFieldState.name}>Lookup Field</Label>\n                    <Select\n                      value={(lookupFieldState.state.value as string | undefined) ?? ''}\n                      onValueChange={(value) => lookupFieldState.handleChange(value as any)}\n                      disabled={!foreignFields.length}\n                    >\n                      <SelectTrigger size=\"lg\" id={lookupFieldState.name}>\n                        <SelectValue placeholder=\"Select lookup field\" />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {foreignFields.map((entry) => (\n                          <SelectItem key={entry.id} value={entry.id}>\n                            {entry.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {lookupFieldState.state.meta.errors ? (\n                      <p className=\"text-xs text-destructive\">\n                        {lookupFieldState.state.meta.errors.join(', ')}\n                      </p>\n                    ) : null}\n                  </div>\n                )}\n              />\n            </div>\n          );\n        }}\n      />\n\n      <form.Field\n        name=\"options.expression\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Rollup Function</Label>\n            <Select\n              value={(field.state.value as string | undefined) ?? ''}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\" id={field.name}>\n                <SelectValue placeholder=\"Select rollup function\" />\n              </SelectTrigger>\n              <SelectContent>\n                {ROLLUP_FUNCTIONS.map((expr) => (\n                  <SelectItem key={expr} value={expr}>\n                    {expr}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.timeZone\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Time Zone</Label>\n            <Select\n              value={(field.state.value as string | undefined) ?? TIME_ZONE_LIST[0]}\n              onValueChange={(value) => field.handleChange(value as any)}\n            >\n              <SelectTrigger size=\"lg\" id={field.name}>\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent className=\"max-h-[300px]\">\n                {TIME_ZONE_LIST.map((tz) => (\n                  <SelectItem key={tz} value={tz}>\n                    {tz}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/SelectOptions.tsx",
    "content": "import { Plus, Trash2 } from 'lucide-react';\nimport { Label } from '@/components/ui/label';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Switch } from '@/components/ui/switch';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { fieldColorValues } from '@teable/v2-core';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function SelectOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Choices</Label>\n        <form.Field\n          name=\"options.choices\"\n          children={(field) => {\n            const choices = (field.state.value as any[]) || [];\n            return (\n              <div className=\"space-y-2\">\n                {choices.map((choice: any, index: number) => (\n                  <div key={index} className=\"flex gap-2 items-center\">\n                    <Input\n                      placeholder=\"Choice name\"\n                      value={choice.name}\n                      onChange={(e) => {\n                        const newChoices = [...choices];\n                        newChoices[index] = { ...choice, name: e.target.value };\n                        field.handleChange(newChoices as any);\n                      }}\n                    />\n                    <Select\n                      value={choice.color}\n                      onValueChange={(value) => {\n                        const newChoices = [...choices];\n                        newChoices[index] = { ...choice, color: value };\n                        field.handleChange(newChoices as any);\n                      }}\n                    >\n                      <SelectTrigger size=\"lg\" className=\"w-[140px]\">\n                        <SelectValue />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {fieldColorValues.map((color) => (\n                          <SelectItem key={color} value={color}>\n                            <div className=\"flex items-center gap-2\">\n                              <div\n                                className=\"h-3 w-3 rounded-full\"\n                                style={{ backgroundColor: color }}\n                              />\n                              <span className=\"text-xs\">{color}</span>\n                            </div>\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      onClick={() => {\n                        const newChoices = choices.filter((_: any, i: number) => i !== index);\n                        field.handleChange(newChoices as any);\n                      }}\n                    >\n                      <Trash2 className=\"h-4 w-4\" />\n                    </Button>\n                  </div>\n                ))}\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className=\"w-full\"\n                  onClick={() => {\n                    field.handleChange([\n                      ...choices,\n                      {\n                        name: '',\n                        color: fieldColorValues[choices.length % fieldColorValues.length],\n                      },\n                    ] as any);\n                  }}\n                >\n                  <Plus className=\"mr-2 h-4 w-4\" />\n                  Add Choice\n                </Button>\n                {field.state.meta.errors ? (\n                  <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n                ) : null}\n              </div>\n            );\n          }}\n        />\n      </div>\n\n      <form.Field\n        name=\"options.preventAutoNewOptions\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Prevent Auto-creation of Options</Label>\n              <Switch\n                id={field.name}\n                checked={(field.state.value as boolean) || false}\n                onCheckedChange={(checked) => field.handleChange(checked as any)}\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/SingleLineTextOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Input } from '@/components/ui/input';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function SingleLineTextOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.showAs.type\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Show As</Label>\n            <Select\n              value={(field.state.value as string) || 'none'}\n              onValueChange={(value) =>\n                field.handleChange(value === 'none' ? (undefined as any) : value)\n              }\n            >\n              <SelectTrigger size=\"lg\">\n                <SelectValue placeholder=\"Select display type\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"url\">URL</SelectItem>\n                <SelectItem value=\"email\">Email</SelectItem>\n                <SelectItem value=\"phone\">Phone</SelectItem>\n                <SelectItem value=\"none\">None</SelectItem>\n              </SelectContent>\n            </Select>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.defaultValue\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <Label htmlFor={field.name}>Default Value</Label>\n            <Input\n              id={field.name}\n              value={(field.state.value as string) || ''}\n              onChange={(e) => field.handleChange(e.target.value as any)}\n              placeholder=\"Enter default value\"\n            />\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/field-options/UserOptions.tsx",
    "content": "import { Label } from '@/components/ui/label';\nimport { Switch } from '@/components/ui/switch';\nimport type { FieldFormApi } from '../FieldForm';\n\nexport function UserOptions({ form }: { form: FieldFormApi }) {\n  return (\n    <div className=\"space-y-4\">\n      <form.Field\n        name=\"options.isMultiple\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Multiple Users</Label>\n              <Switch\n                id={field.name}\n                checked={(field.state.value as boolean) || false}\n                onCheckedChange={(checked) => field.handleChange(checked as any)}\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n\n      <form.Field\n        name=\"options.shouldNotify\"\n        children={(field) => (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor={field.name}>Notify Users</Label>\n              <Switch\n                id={field.name}\n                checked={(field.state.value as boolean) || false}\n                onCheckedChange={(checked) => field.handleChange(checked as any)}\n              />\n            </div>\n            {field.state.meta.errors ? (\n              <p className=\"text-xs text-destructive\">{field.state.meta.errors.join(', ')}</p>\n            ) : null}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/playground/fieldOptionsVisitor.tsx",
    "content": "import type { ReactNode } from 'react';\nimport {\n  ok,\n  type AttachmentField,\n  type AutoNumberField,\n  type ButtonField,\n  type CheckboxField,\n  type ConditionalLookupField,\n  type ConditionalRollupField,\n  type CreatedByField,\n  type CreatedTimeField,\n  type DateField,\n  type Field,\n  type IFieldVisitor,\n  type LastModifiedByField,\n  type LastModifiedTimeField,\n  type LinkField,\n  type LookupField,\n  type LongTextField,\n  type MultipleSelectField,\n  type NumberField,\n  type NumberFormatting,\n  type NumberShowAs,\n  type FormulaField,\n  type RatingField,\n  type RollupField,\n  type Result,\n  type SingleLineTextField,\n  type SingleSelectField,\n  type UserField,\n} from '@teable/v2-core';\n\nconst emptyNode = <span className=\"text-xs text-muted-foreground\">-</span>;\n\nconst formatTokens = (tokens: string[]): ReactNode => {\n  if (tokens.length === 0) return emptyNode;\n  return (\n    <span className=\"flex flex-wrap gap-1 text-xs text-muted-foreground\">\n      {tokens.map((token, index) => (\n        <span key={`${token}-${index}`} className=\"rounded border border-border/60 px-1.5 py-0.5\">\n          {token}\n        </span>\n      ))}\n    </span>\n  );\n};\n\nconst tokenValue = (value: string | number | boolean): string => {\n  if (typeof value === 'boolean') return value ? 'on' : 'off';\n  return String(value);\n};\n\nconst pushToken = (tokens: string[], label: string, value: string | number | boolean) => {\n  tokens.push(`${label}:${tokenValue(value)}`);\n};\n\nconst pushValidationTokens = (tokens: string[], field: Field) => {\n  if (field.notNull().toBoolean()) pushToken(tokens, 'notNull', true);\n  if (field.unique().toBoolean()) pushToken(tokens, 'unique', true);\n};\n\nconst formatFieldTokens = (field: Field, tokens: string[]): ReactNode => {\n  pushValidationTokens(tokens, field);\n  return formatTokens(tokens);\n};\n\nconst formatNumberFormattingTokens = (formatting: NumberFormatting): string[] => {\n  const tokens: string[] = [];\n  pushToken(tokens, 'format', formatting.type());\n  pushToken(tokens, 'precision', formatting.precision().toNumber());\n  const symbol = formatting.symbol();\n  if (symbol) pushToken(tokens, 'symbol', symbol);\n  return tokens;\n};\n\nconst formatNumberShowAsTokens = (showAs: NumberShowAs): string[] => {\n  const tokens: string[] = [];\n  const dto = showAs.toDto();\n  pushToken(tokens, 'showAs', dto.type);\n  pushToken(tokens, 'color', dto.color);\n  if ('maxValue' in dto) {\n    pushToken(tokens, 'max', dto.maxValue);\n    pushToken(tokens, 'value', dto.showValue);\n  }\n  return tokens;\n};\n\nconst formatDefaultValue = (value: string | string[]): string =>\n  Array.isArray(value) ? value.join(', ') : value;\n\nexport class FieldOptionsVisitor implements IFieldVisitor<ReactNode> {\n  visitSingleLineTextField(field: SingleLineTextField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const showAs = field.showAs();\n    if (showAs) pushToken(tokens, 'showAs', showAs.type());\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', defaultValue.toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitLongTextField(field: LongTextField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', defaultValue.toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitNumberField(field: NumberField): Result<ReactNode, string> {\n    const tokens = [...formatNumberFormattingTokens(field.formatting())];\n    const showAs = field.showAs();\n    if (showAs) {\n      tokens.push(...formatNumberShowAsTokens(showAs));\n    }\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', defaultValue.toNumber());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitRatingField(field: RatingField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'max', field.ratingMax().toNumber());\n    pushToken(tokens, 'icon', field.ratingIcon().toString());\n    pushToken(tokens, 'color', field.ratingColor().toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitFormulaField(field: FormulaField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'expr', field.expression().toString());\n    const timeZone = field.timeZone();\n    if (timeZone) pushToken(tokens, 'tz', timeZone.toString());\n    const formatting = field.formatting();\n    if (formatting) {\n      const dto = formatting.toDto();\n      if ('precision' in dto) {\n        tokens.push(...formatNumberFormattingTokens(formatting as NumberFormatting));\n      } else {\n        pushToken(tokens, 'date', dto.date);\n        pushToken(tokens, 'time', dto.time);\n        pushToken(tokens, 'tz', dto.timeZone);\n      }\n    }\n    const showAs = field.showAs();\n    if (showAs) {\n      const dto = showAs.toDto();\n      if ('color' in dto) {\n        tokens.push(...formatNumberShowAsTokens(showAs as NumberShowAs));\n      } else {\n        pushToken(tokens, 'showAs', dto.type);\n      }\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitRollupField(field: RollupField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'expr', field.expression().toString());\n    pushToken(tokens, 'link', field.linkFieldId().toString());\n    pushToken(tokens, 'foreign', field.foreignTableId().toString());\n    pushToken(tokens, 'lookup', field.lookupFieldId().toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitSingleSelectField(field: SingleSelectField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'choices', field.selectOptions().length);\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', formatDefaultValue(defaultValue.toDto()));\n    if (field.preventAutoNewOptions().toBoolean()) pushToken(tokens, 'autoNew', 'off');\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitMultipleSelectField(field: MultipleSelectField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'choices', field.selectOptions().length);\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', formatDefaultValue(defaultValue.toDto()));\n    if (field.preventAutoNewOptions().toBoolean()) pushToken(tokens, 'autoNew', 'off');\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitCheckboxField(field: CheckboxField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const defaultValue = field.defaultValue();\n    if (!defaultValue) {\n      pushToken(tokens, 'default', 'unchecked');\n    } else {\n      pushToken(tokens, 'default', defaultValue.toBoolean() ? 'checked' : 'unchecked');\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitAttachmentField(field: AttachmentField): Result<ReactNode, string> {\n    return ok(formatFieldTokens(field, []));\n  }\n\n  visitDateField(field: DateField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const formatting = field.formatting();\n    pushToken(tokens, 'date', formatting.date());\n    pushToken(tokens, 'time', formatting.time());\n    pushToken(tokens, 'tz', formatting.timeZone().toString());\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', defaultValue.toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitCreatedTimeField(field: CreatedTimeField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const formatting = field.formatting();\n    pushToken(tokens, 'date', formatting.date());\n    pushToken(tokens, 'time', formatting.time());\n    pushToken(tokens, 'tz', formatting.timeZone().toString());\n    pushToken(tokens, 'expr', field.expression().toString());\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    const formatting = field.formatting();\n    pushToken(tokens, 'date', formatting.date());\n    pushToken(tokens, 'time', formatting.time());\n    pushToken(tokens, 'tz', formatting.timeZone().toString());\n    pushToken(tokens, 'expr', field.expression().toString());\n    const trackedFieldIds = field.trackedFieldIds();\n    if (trackedFieldIds.length > 0) {\n      pushToken(tokens, 'tracked', trackedFieldIds.length);\n    } else {\n      pushToken(tokens, 'tracked', 'all');\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitUserField(field: UserField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'mode', field.multiplicity().toBoolean() ? 'multiple' : 'single');\n    pushToken(tokens, 'notify', field.notification().toBoolean());\n    const defaultValue = field.defaultValue();\n    if (defaultValue) pushToken(tokens, 'default', formatDefaultValue(defaultValue.toDto()));\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitCreatedByField(field: CreatedByField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'system', 'created');\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitLastModifiedByField(field: LastModifiedByField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'system', 'modified');\n    const trackedFieldIds = field.trackedFieldIds();\n    if (trackedFieldIds.length > 0) {\n      pushToken(tokens, 'tracked', trackedFieldIds.length);\n    } else {\n      pushToken(tokens, 'tracked', 'all');\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitAutoNumberField(field: AutoNumberField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'system', 'auto');\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitButtonField(field: ButtonField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'label', field.label().toString());\n    pushToken(tokens, 'color', field.color().toString());\n    const maxCount = field.maxCount();\n    if (maxCount) pushToken(tokens, 'max', maxCount.toNumber());\n    const resetCount = field.resetCount();\n    if (resetCount) pushToken(tokens, 'reset', resetCount.toBoolean());\n    const workflow = field.workflow();\n    if (workflow) {\n      const workflowDto = workflow.toDto();\n      const workflowLabel = workflowDto.name ?? workflowDto.id ?? 'workflow';\n      pushToken(tokens, 'workflow', workflowLabel);\n      if (workflowDto.isActive === false) pushToken(tokens, 'active', 'off');\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitLinkField(field: LinkField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'rel', field.relationship().toString());\n    pushToken(tokens, 'foreign', field.foreignTableId().toString());\n    pushToken(tokens, 'lookup', field.lookupFieldId().toString());\n    if (field.isOneWay()) pushToken(tokens, 'oneWay', 'on');\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitLookupField(field: LookupField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'link', field.linkFieldId().toString());\n    pushToken(tokens, 'foreign', field.foreignTableId().toString());\n    pushToken(tokens, 'lookup', field.lookupFieldId().toString());\n    const innerTypeResult = field.innerFieldType();\n    if (innerTypeResult.isOk()) {\n      pushToken(tokens, 'inner', innerTypeResult.value.toString());\n    } else {\n      pushToken(tokens, 'inner', 'pending');\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'expr', field.expression().toString());\n    pushToken(tokens, 'foreign', field.foreignTableId().toString());\n    pushToken(tokens, 'lookup', field.lookupFieldId().toString());\n    const condition = field.config().condition();\n    if (condition.hasFilter()) {\n      pushToken(tokens, 'filtered', 'yes');\n    }\n    if (condition.hasSort()) {\n      pushToken(tokens, 'sorted', 'yes');\n    }\n    if (condition.hasLimit()) {\n      pushToken(tokens, 'limit', condition.limit() ?? 0);\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n\n  visitConditionalLookupField(field: ConditionalLookupField): Result<ReactNode, string> {\n    const tokens: string[] = [];\n    pushToken(tokens, 'foreign', field.foreignTableId().toString());\n    pushToken(tokens, 'lookup', field.lookupFieldId().toString());\n    const innerTypeResult = field.innerFieldType();\n    if (innerTypeResult.isOk()) {\n      pushToken(tokens, 'inner', innerTypeResult.value.toString());\n    } else {\n      pushToken(tokens, 'inner', 'pending');\n    }\n    const condition = field.conditionalLookupOptions().condition();\n    if (condition.hasFilter()) {\n      pushToken(tokens, 'filtered', 'yes');\n    }\n    if (condition.hasSort()) {\n      pushToken(tokens, 'sorted', 'yes');\n    }\n    if (condition.hasLimit()) {\n      pushToken(tokens, 'limit', condition.limit() ?? 0);\n    }\n    return ok(formatFieldTokens(field, tokens));\n  }\n}\n\nexport const renderFieldOptions = (field: Field): ReactNode => {\n  const result = field.accept(new FieldOptionsVisitor());\n  if (result.isErr()) {\n    return <span className=\"text-xs text-destructive\">{result.error}</span>;\n  }\n  return result.value;\n};\n"
  },
  {
    "path": "apps/playground/src/components/playground/recordValueVisitor.tsx",
    "content": "import type { ReactNode } from 'react';\nimport {\n  AbstractFieldVisitor,\n  ok,\n  type AttachmentField,\n  type AutoNumberField,\n  type ButtonField,\n  type CheckboxField,\n  type ConditionalLookupField,\n  type ConditionalRollupField,\n  type CreatedByField,\n  type CreatedTimeField,\n  type DateField,\n  type DateTimeFormatting,\n  type DomainError,\n  type Field,\n  type FormulaField,\n  type LastModifiedByField,\n  type LastModifiedTimeField,\n  type LinkField,\n  type LongTextField,\n  type LookupField,\n  type MultipleSelectField,\n  type NumberField,\n  type NumberFormatting,\n  type RatingField,\n  type Result,\n  type RollupField,\n  type SelectOption,\n  type SingleLineTextField,\n  type SingleSelectField,\n  type UserField,\n} from '@teable/v2-core';\n\nimport { Badge } from '@/components/ui/badge';\nimport { cn } from '@/lib/utils';\n\ntype FormattedRecordValue = {\n  text: string;\n  node: ReactNode;\n  cellClassName?: string;\n};\n\nconst emptyRecordValue: FormattedRecordValue = {\n  text: '-',\n  node: <span className=\"text-xs text-muted-foreground\">-</span>,\n  cellClassName: 'text-muted-foreground',\n};\n\nconst isEmptyRecordValue = (value: unknown): boolean =>\n  value === undefined ||\n  value === null ||\n  value === '' ||\n  (Array.isArray(value) && value.length === 0);\n\nexport const stringifyRecordValue = (value: unknown): string => {\n  if (value === undefined || value === null) return '';\n  if (value instanceof Date) return value.toISOString();\n  if (typeof value === 'string') return value;\n  if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {\n    return value.toString();\n  }\n  try {\n    const json = JSON.stringify(value);\n    return json ?? String(value);\n  } catch {\n    return String(value);\n  }\n};\n\nconst formatTextCellValue = (text: string, cellClassName = 'truncate'): FormattedRecordValue => {\n  if (!text) return emptyRecordValue;\n  return {\n    text,\n    node: text,\n    cellClassName,\n  };\n};\n\nconst formatBadgeListValue = (\n  labels: string[],\n  options?: {\n    variant?: 'secondary' | 'outline' | 'default' | 'destructive';\n    maxBadges?: number;\n    badgeClassName?: string;\n  }\n): FormattedRecordValue => {\n  if (!labels.length) return emptyRecordValue;\n  const maxBadges = options?.maxBadges ?? 3;\n  const visible = labels.slice(0, maxBadges);\n  const remaining = labels.length - visible.length;\n  const renderLabels = remaining > 0 ? [...visible, `+${remaining}`] : visible;\n  return {\n    text: labels.join(', '),\n    node: (\n      <div className=\"flex flex-wrap gap-1\">\n        {renderLabels.map((label) => (\n          <Badge\n            key={label}\n            variant={options?.variant ?? 'secondary'}\n            className={cn('min-w-0', options?.badgeClassName)}\n          >\n            {label}\n          </Badge>\n        ))}\n      </div>\n    ),\n    cellClassName: 'whitespace-normal',\n  };\n};\n\nconst resolveNumberValue = (value: unknown): number | null => {\n  if (typeof value === 'number' && Number.isFinite(value)) return value;\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number(value);\n    if (Number.isFinite(parsed)) return parsed;\n  }\n  return null;\n};\n\nconst resolveBooleanValue = (value: unknown): boolean | null => {\n  if (typeof value === 'boolean') return value;\n  if (typeof value === 'string') {\n    if (value.toLowerCase() === 'true') return true;\n    if (value.toLowerCase() === 'false') return false;\n  }\n  return null;\n};\n\nconst formatNumberText = (value: number, formatting: NumberFormatting): string => {\n  const dto = formatting.toDto();\n  const precision = dto.precision ?? 0;\n\n  if (dto.type === 'percent') {\n    const formatter = new Intl.NumberFormat(undefined, {\n      style: 'percent',\n      minimumFractionDigits: precision,\n      maximumFractionDigits: precision,\n    });\n    return formatter.format(value);\n  }\n\n  const formatter = new Intl.NumberFormat(undefined, {\n    minimumFractionDigits: precision,\n    maximumFractionDigits: precision,\n  });\n\n  if (dto.type === 'currency') {\n    const symbol = dto.symbol ?? '$';\n    const sign = value < 0 ? '-' : '';\n    return `${sign}${symbol}${formatter.format(Math.abs(value))}`;\n  }\n\n  return formatter.format(value);\n};\n\nconst formatDateTimeText = (value: unknown, formatting: DateTimeFormatting): string | null => {\n  if (value === undefined || value === null) return null;\n  const date =\n    value instanceof Date\n      ? value\n      : typeof value === 'string' || typeof value === 'number'\n        ? new Date(value)\n        : null;\n  if (!date || Number.isNaN(date.getTime())) return null;\n\n  const dto = formatting.toDto();\n  const timeZone = dto.timeZone === 'utc' ? 'UTC' : dto.timeZone;\n\n  try {\n    const parts = new Intl.DateTimeFormat(undefined, {\n      timeZone,\n      year: 'numeric',\n      month: '2-digit',\n      day: '2-digit',\n      hour: '2-digit',\n      minute: '2-digit',\n      hour12: false,\n    }).formatToParts(date);\n\n    const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? '';\n    const year = Number(part('year'));\n    const month = Number(part('month'));\n    const day = Number(part('day'));\n    const hour24 = Number(part('hour'));\n    const minute = Number(part('minute'));\n    const hour12 = hour24 % 12 || 12;\n    const dayPeriod = hour24 >= 12 ? 'PM' : 'AM';\n\n    const pad2 = (val: number) => String(val).padStart(2, '0');\n    const tokens: Record<string, string> = {\n      YYYY: String(year),\n      MM: pad2(month),\n      M: String(month),\n      DD: pad2(day),\n      D: String(day),\n      HH: pad2(hour24),\n      hh: pad2(hour12),\n      mm: pad2(minute),\n      A: dayPeriod,\n    };\n\n    const formatWithTokens = (pattern: string) =>\n      pattern.replace(/YYYY|MM|DD|HH|hh|mm|M|D|A/g, (match) => tokens[match] ?? match);\n\n    const dateText = formatWithTokens(dto.date);\n    if (dto.time === 'None') return dateText;\n    const timeText = formatWithTokens(dto.time);\n    return `${dateText} ${timeText}`.trim();\n  } catch {\n    return date.toISOString();\n  }\n};\n\nconst formatBooleanValue = (value: unknown): FormattedRecordValue => {\n  const bool = resolveBooleanValue(value);\n  if (bool !== null) {\n    return formatBadgeListValue([bool ? 'Yes' : 'No'], { variant: 'outline' });\n  }\n  if (Array.isArray(value)) {\n    const labels = value\n      .map((entry) => resolveBooleanValue(entry))\n      .filter((entry): entry is boolean => entry !== null)\n      .map((entry) => (entry ? 'Yes' : 'No'));\n    return formatBadgeListValue(labels, { variant: 'outline' });\n  }\n  return formatTextCellValue(stringifyRecordValue(value));\n};\n\ntype SelectOptionLookup = {\n  byId: Map<string, SelectOption>;\n  byName: Map<string, SelectOption>;\n};\n\nconst buildSelectLookup = (options: ReadonlyArray<SelectOption>): SelectOptionLookup => ({\n  byId: new Map(options.map((option) => [option.id().toString(), option])),\n  byName: new Map(options.map((option) => [option.name().toString(), option])),\n});\n\nconst resolveSelectLabel = (lookup: SelectOptionLookup, value: unknown): string | null => {\n  if (value === undefined || value === null) return null;\n\n  if (typeof value === 'string') {\n    return (\n      lookup.byId.get(value)?.name().toString() ??\n      lookup.byName.get(value)?.name().toString() ??\n      value\n    );\n  }\n\n  if (typeof value === 'object') {\n    const candidate = value as { id?: unknown; name?: unknown };\n    if (typeof candidate.name === 'string') return candidate.name;\n    if (typeof candidate.id === 'string') {\n      return lookup.byId.get(candidate.id)?.name().toString() ?? candidate.id;\n    }\n  }\n\n  return stringifyRecordValue(value);\n};\n\nconst resolveUserLabel = (value: unknown): string | null => {\n  if (value === undefined || value === null) return null;\n  if (typeof value === 'string') return value;\n  if (typeof value === 'object') {\n    const candidate = value as { title?: unknown; name?: unknown; email?: unknown; id?: unknown };\n    if (typeof candidate.title === 'string') return candidate.title;\n    if (typeof candidate.name === 'string') return candidate.name;\n    if (typeof candidate.email === 'string') return candidate.email;\n    if (typeof candidate.id === 'string') return candidate.id;\n  }\n  return stringifyRecordValue(value);\n};\n\nconst resolveAttachmentLabel = (value: unknown): string | null => {\n  if (value === undefined || value === null) return null;\n  if (typeof value === 'string') return value;\n  if (typeof value === 'object') {\n    const candidate = value as { name?: unknown; id?: unknown };\n    if (typeof candidate.name === 'string') return candidate.name;\n    if (typeof candidate.id === 'string') return candidate.id;\n  }\n  return stringifyRecordValue(value);\n};\n\nconst resolveLinkLabel = (value: unknown): string | null => {\n  if (value === undefined || value === null) return null;\n  if (typeof value === 'string') return value;\n  if (typeof value === 'object') {\n    const candidate = value as { title?: unknown; name?: unknown; id?: unknown };\n    if (typeof candidate.title === 'string') return candidate.title;\n    if (typeof candidate.name === 'string') return candidate.name;\n    if (typeof candidate.id === 'string') return candidate.id;\n  }\n  return stringifyRecordValue(value);\n};\n\nconst isUserLikeValue = (value: unknown): boolean =>\n  typeof value === 'object' &&\n  value !== null &&\n  ('title' in value || 'name' in value || 'email' in value || 'id' in value);\n\nconst buildUserLookupEntries = (values: unknown[]): FormattedRecordValue[] | null => {\n  if (!values.some((value) => isUserLikeValue(value))) return null;\n  const labels = values\n    .map((entry) => resolveUserLabel(entry))\n    .filter((entry): entry is string => Boolean(entry));\n  if (!labels.length) return null;\n  return labels.map((label) => formatBadgeListValue([label], { variant: 'outline' }));\n};\n\ntype ComputedField = FormulaField | RollupField | ConditionalRollupField;\n\nconst formatComputedFieldValue = (field: ComputedField, value: unknown): FormattedRecordValue => {\n  const valueTypeResult = field.cellValueType();\n  if (valueTypeResult.isOk()) {\n    const valueType = valueTypeResult.value.toString();\n    const formatting = field.formatting();\n\n    if (valueType === 'number') {\n      const number = resolveNumberValue(value);\n      if (number === null) return formatTextCellValue(stringifyRecordValue(value));\n      if (formatting) {\n        const dto = formatting.toDto();\n        if ('precision' in dto) {\n          return formatTextCellValue(\n            formatNumberText(number, formatting as NumberFormatting),\n            'text-right tabular-nums whitespace-nowrap'\n          );\n        }\n      }\n      return formatTextCellValue(number.toString(), 'text-right tabular-nums whitespace-nowrap');\n    }\n\n    if (valueType === 'dateTime' && formatting) {\n      const dto = formatting.toDto();\n      if ('date' in dto) {\n        const formatted = formatDateTimeText(value, formatting as DateTimeFormatting);\n        if (formatted) return formatTextCellValue(formatted, 'font-mono text-xs whitespace-nowrap');\n      }\n    }\n\n    if (valueType === 'boolean') {\n      return formatBooleanValue(value);\n    }\n  }\n\n  return formatTextCellValue(stringifyRecordValue(value));\n};\n\nconst formatLookupEntryList = (entries: FormattedRecordValue[]): FormattedRecordValue => {\n  if (!entries.length) return emptyRecordValue;\n  return {\n    text: '',\n    node: (\n      <div className=\"flex flex-wrap gap-1 max-w-full min-w-0 [data-slot=badge]:max-w-full [data-slot=badge]:whitespace-normal [data-slot=badge]:break-words [data-slot=badge]:shrink [data-slot=badge]:min-w-0\">\n        {entries.map((entry) => (\n          <span\n            key={`${entry.text}-${entry.cellClassName ?? ''}`}\n            className={cn('max-w-full min-w-0', entry.cellClassName)}\n          >\n            {entry.node}\n          </span>\n        ))}\n      </div>\n    ),\n    cellClassName: 'whitespace-normal',\n  };\n};\n\nclass RecordValueVisitor extends AbstractFieldVisitor<FormattedRecordValue> {\n  constructor(private readonly value: unknown) {\n    super();\n  }\n\n  visitSingleLineTextField(field: SingleLineTextField) {\n    const text = stringifyRecordValue(this.value);\n    const showAs = field.showAs()?.type();\n    if (!text) return ok(emptyRecordValue);\n\n    if (showAs === 'url') {\n      const href = /^https?:\\/\\//i.test(text) ? text : `https://${text}`;\n      return ok({\n        text,\n        node: (\n          <a href={href} target=\"_blank\" rel=\"noreferrer\" className=\"text-primary underline\">\n            {text}\n          </a>\n        ),\n        cellClassName: 'truncate',\n      });\n    }\n\n    if (showAs === 'email') {\n      return ok({\n        text,\n        node: (\n          <a href={`mailto:${text}`} className=\"text-primary underline\">\n            {text}\n          </a>\n        ),\n        cellClassName: 'truncate',\n      });\n    }\n\n    if (showAs === 'phone') {\n      return ok({\n        text,\n        node: (\n          <a href={`tel:${text}`} className=\"text-primary underline\">\n            {text}\n          </a>\n        ),\n        cellClassName: 'truncate',\n      });\n    }\n\n    return ok(formatTextCellValue(text));\n  }\n\n  visitLongTextField(_field: LongTextField) {\n    const text = stringifyRecordValue(this.value);\n    return ok(formatTextCellValue(text));\n  }\n\n  visitNumberField(field: NumberField) {\n    const number = resolveNumberValue(this.value);\n    if (number === null) return ok(formatTextCellValue(stringifyRecordValue(this.value)));\n    return ok(\n      formatTextCellValue(\n        formatNumberText(number, field.formatting()),\n        'text-right tabular-nums whitespace-nowrap'\n      )\n    );\n  }\n\n  visitAutoNumberField(_field: AutoNumberField) {\n    const number = resolveNumberValue(this.value);\n    if (number === null) return ok(formatTextCellValue(stringifyRecordValue(this.value)));\n    return ok(formatTextCellValue(number.toString(), 'text-right tabular-nums whitespace-nowrap'));\n  }\n\n  visitRatingField(field: RatingField) {\n    const rating = resolveNumberValue(this.value);\n    if (rating === null) return ok(formatTextCellValue(stringifyRecordValue(this.value)));\n    const max = field.ratingMax().toNumber();\n    return ok(\n      formatTextCellValue(`${rating} / ${max}`, 'text-right tabular-nums whitespace-nowrap')\n    );\n  }\n\n  visitCheckboxField(_field: CheckboxField) {\n    return ok(formatBooleanValue(this.value));\n  }\n\n  visitDateField(field: DateField) {\n    const formatted = formatDateTimeText(this.value, field.formatting());\n    return ok(\n      formatted\n        ? formatTextCellValue(formatted, 'font-mono text-xs whitespace-nowrap')\n        : emptyRecordValue\n    );\n  }\n\n  visitCreatedTimeField(field: CreatedTimeField) {\n    const formatted = formatDateTimeText(this.value, field.formatting());\n    return ok(\n      formatted\n        ? formatTextCellValue(formatted, 'font-mono text-xs whitespace-nowrap')\n        : emptyRecordValue\n    );\n  }\n\n  visitLastModifiedTimeField(field: LastModifiedTimeField) {\n    const formatted = formatDateTimeText(this.value, field.formatting());\n    return ok(\n      formatted\n        ? formatTextCellValue(formatted, 'font-mono text-xs whitespace-nowrap')\n        : emptyRecordValue\n    );\n  }\n\n  visitSingleSelectField(field: SingleSelectField) {\n    const lookup = buildSelectLookup(field.selectOptions());\n    const label = resolveSelectLabel(lookup, this.value);\n    return ok(label ? formatBadgeListValue([label], { variant: 'secondary' }) : emptyRecordValue);\n  }\n\n  visitMultipleSelectField(field: MultipleSelectField) {\n    const lookup = buildSelectLookup(field.selectOptions());\n    const values = Array.isArray(this.value) ? this.value : [this.value];\n    const labels = values\n      .map((entry) => resolveSelectLabel(lookup, entry))\n      .filter((entry): entry is string => Boolean(entry));\n    return ok(formatBadgeListValue(labels, { variant: 'secondary' }));\n  }\n\n  visitUserField(field: UserField) {\n    const values = field.multiplicity().toBoolean()\n      ? Array.isArray(this.value)\n        ? this.value\n        : [this.value]\n      : Array.isArray(this.value)\n        ? this.value.slice(0, 1)\n        : [this.value];\n    const labels = values\n      .map((entry) => resolveUserLabel(entry))\n      .filter((entry): entry is string => Boolean(entry));\n    return ok(formatBadgeListValue(labels, { variant: 'outline' }));\n  }\n\n  visitCreatedByField(_field: CreatedByField) {\n    const values = Array.isArray(this.value) ? this.value : [this.value];\n    const labels = values\n      .map((entry) => resolveUserLabel(entry))\n      .filter((entry): entry is string => Boolean(entry));\n    return ok(formatBadgeListValue(labels, { variant: 'outline' }));\n  }\n\n  visitLastModifiedByField(_field: LastModifiedByField) {\n    const values = Array.isArray(this.value) ? this.value : [this.value];\n    const labels = values\n      .map((entry) => resolveUserLabel(entry))\n      .filter((entry): entry is string => Boolean(entry));\n    return ok(formatBadgeListValue(labels, { variant: 'outline' }));\n  }\n\n  visitAttachmentField(_field: AttachmentField) {\n    const attachments = Array.isArray(this.value) ? this.value : [this.value];\n    const labels = attachments\n      .map((entry) => resolveAttachmentLabel(entry))\n      .filter((entry): entry is string => Boolean(entry));\n    if (!labels.length) return ok(emptyRecordValue);\n    if (labels.length === 1) return ok(formatBadgeListValue([labels[0]], { variant: 'outline' }));\n    return ok({\n      text: labels.join(', '),\n      node: <Badge variant=\"outline\">{`${labels.length} files`}</Badge>,\n      cellClassName: 'whitespace-nowrap',\n    });\n  }\n\n  visitButtonField(field: ButtonField) {\n    const label = field.label().toString();\n    const count =\n      typeof this.value === 'object' && this.value !== null && 'count' in this.value\n        ? Number((this.value as { count?: unknown }).count)\n        : resolveNumberValue(this.value);\n    const text =\n      typeof count === 'number' && Number.isFinite(count) ? `${label} (${count})` : label;\n    return ok({\n      text,\n      node: <Badge variant=\"outline\">{text}</Badge>,\n      cellClassName: 'whitespace-nowrap',\n    });\n  }\n\n  visitLinkField(field: LinkField) {\n    const values = field.isMultipleValue()\n      ? Array.isArray(this.value)\n        ? this.value\n        : [this.value]\n      : Array.isArray(this.value)\n        ? this.value.slice(0, 1)\n        : [this.value];\n    const labels = values\n      .map((entry) => resolveLinkLabel(entry))\n      .filter((entry): entry is string => Boolean(entry));\n    return ok(\n      formatBadgeListValue(labels, {\n        variant: 'outline',\n        badgeClassName: 'max-w-[140px] min-w-0 w-auto justify-start text-left truncate',\n      })\n    );\n  }\n\n  visitLookupField(field: LookupField): Result<FormattedRecordValue, DomainError> {\n    const values = Array.isArray(this.value) ? this.value : [this.value];\n    const innerFieldResult = field.innerField();\n    if (innerFieldResult.isErr()) {\n      const userEntries = buildUserLookupEntries(values);\n      if (userEntries) {\n        return ok(formatLookupEntryList(userEntries));\n      }\n      const labels = values\n        .map((entry) => stringifyRecordValue(entry))\n        .filter((entry): entry is string => Boolean(entry));\n      return ok(formatBadgeListValue(labels, { variant: 'secondary' }));\n    }\n\n    const innerField = innerFieldResult.value;\n    const entries = values.reduce<FormattedRecordValue[]>((acc, entry) => {\n      if (isEmptyRecordValue(entry)) return acc;\n      const formattedResult = innerField.accept(new RecordValueVisitor(entry));\n      acc.push(\n        formattedResult.isErr()\n          ? formatTextCellValue(stringifyRecordValue(entry))\n          : formattedResult.value\n      );\n      return acc;\n    }, []);\n    return ok(formatLookupEntryList(entries));\n  }\n\n  visitFormulaField(field: FormulaField) {\n    return ok(formatComputedFieldValue(field, this.value));\n  }\n\n  visitRollupField(field: RollupField) {\n    return ok(formatComputedFieldValue(field, this.value));\n  }\n\n  visitConditionalRollupField(field: ConditionalRollupField) {\n    return ok(formatComputedFieldValue(field, this.value));\n  }\n\n  visitConditionalLookupField(\n    field: ConditionalLookupField\n  ): Result<FormattedRecordValue, DomainError> {\n    const values = Array.isArray(this.value) ? this.value : [this.value];\n    const innerFieldResult = field.innerField();\n    if (innerFieldResult.isErr()) {\n      const userEntries = buildUserLookupEntries(values);\n      if (userEntries) {\n        return ok(formatLookupEntryList(userEntries));\n      }\n      const labels = values\n        .map((entry) => stringifyRecordValue(entry))\n        .filter((entry): entry is string => Boolean(entry));\n      return ok(formatBadgeListValue(labels, { variant: 'secondary' }));\n    }\n\n    const innerField = innerFieldResult.value;\n    const entries = values.reduce<FormattedRecordValue[]>((acc, entry) => {\n      if (isEmptyRecordValue(entry)) return acc;\n      const formattedResult = innerField.accept(new RecordValueVisitor(entry));\n      acc.push(\n        formattedResult.isErr()\n          ? formatTextCellValue(stringifyRecordValue(entry))\n          : formattedResult.value\n      );\n      return acc;\n    }, []);\n    return ok(formatLookupEntryList(entries));\n  }\n}\n\nexport const formatRecordValue = (field: Field, value: unknown): FormattedRecordValue => {\n  if (isEmptyRecordValue(value)) return emptyRecordValue;\n\n  const result = field.accept(new RecordValueVisitor(value));\n  if (result.isOk()) return result.value;\n\n  return formatTextCellValue(stringifyRecordValue(value));\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/alert-dialog.tsx",
    "content": "import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\nimport { buttonVariants } from '@/components/ui/button';\n\nconst AlertDialog = AlertDialogPrimitive.Root;\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger;\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal;\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      'fixed inset-0 z-50 bg-black/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\n\nconst AlertDialogContent = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPortal>\n    <AlertDialogOverlay />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n        className\n      )}\n      {...props}\n    />\n  </AlertDialogPortal>\n));\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\n\nconst AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />\n);\nAlertDialogHeader.displayName = 'AlertDialogHeader';\n\nconst AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}\n    {...props}\n  />\n);\nAlertDialogFooter.displayName = 'AlertDialogFooter';\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn('text-lg font-semibold', className)}\n    {...props}\n  />\n));\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nAlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;\n\nconst AlertDialogAction = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />\n));\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}\n    {...props}\n  />\n));\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/badge.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst badgeVariants = cva(\n  'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',\n  {\n    variants: {\n      variant: {\n        default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',\n        destructive:\n          'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',\n        outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n);\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'span';\n\n  return (\n    <Comp data-slot=\"badge\" className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "apps/playground/src/components/ui/button.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/lib/utils';\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive:\n          'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',\n        outline:\n          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',\n        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',\n        icon: 'size-9',\n        'icon-sm': 'size-8',\n        'icon-lg': 'size-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n);\n\nfunction Button({\n  className,\n  variant = 'default',\n  size = 'default',\n  asChild = false,\n  ...props\n}: React.ComponentProps<'button'> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n  }) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "apps/playground/src/components/ui/calendar.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';\nimport { DayPicker } from 'react-day-picker';\n\nimport { cn } from '@/lib/utils';\nimport { buttonVariants } from '@/components/ui/button';\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  ...props\n}: React.ComponentProps<typeof DayPicker>) {\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn('p-3', className)}\n      classNames={{\n        months: 'flex flex-col sm:flex-row gap-2',\n        month: 'flex flex-col gap-4',\n        month_caption: 'flex justify-center pt-1 relative items-center w-full',\n        caption_label: 'text-sm font-medium',\n        nav: 'flex items-center gap-1',\n        button_previous: cn(\n          buttonVariants({ variant: 'outline' }),\n          'size-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'\n        ),\n        button_next: cn(\n          buttonVariants({ variant: 'outline' }),\n          'size-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'\n        ),\n        month_grid: 'w-full border-collapse space-x-1',\n        weekdays: 'flex',\n        weekday: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',\n        week: 'flex w-full mt-2',\n        day: cn(\n          'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50',\n          props.mode === 'range'\n            ? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'\n            : '[&:has([aria-selected])]:rounded-md'\n        ),\n        day_button: cn(\n          buttonVariants({ variant: 'ghost' }),\n          'size-8 p-0 font-normal aria-selected:opacity-100'\n        ),\n        range_start: 'day-range-start rounded-l-md',\n        range_end: 'day-range-end rounded-r-md',\n        selected:\n          'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',\n        today: 'bg-accent text-accent-foreground',\n        outside:\n          'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',\n        disabled: 'text-muted-foreground opacity-50',\n        range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',\n        hidden: 'invisible',\n        ...classNames,\n      }}\n      components={{\n        Chevron: ({ orientation }) => {\n          const Icon = orientation === 'left' ? ChevronLeftIcon : ChevronRightIcon;\n          return <Icon className=\"size-4\" />;\n        },\n      }}\n      {...props}\n    />\n  );\n}\n\nexport { Calendar };\n"
  },
  {
    "path": "apps/playground/src/components/ui/card.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Card({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn('leading-none font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return <div data-slot=\"card-content\" className={cn('px-6', className)} {...props} />;\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn('flex items-center px-6 [.border-t]:pt-6', className)}\n      {...props}\n    />\n  );\n}\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };\n"
  },
  {
    "path": "apps/playground/src/components/ui/checkbox.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox';\nimport { CheckIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"grid place-content-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  );\n}\n\nexport { Checkbox };\n"
  },
  {
    "path": "apps/playground/src/components/ui/command.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { SearchIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\n\nfunction Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandDialog({\n  title = 'Command Palette',\n  description = 'Search for a command to run...',\n  children,\n  className,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string;\n  description?: string;\n  className?: string;\n  showCloseButton?: boolean;\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent\n        className={cn('overflow-hidden p-0', className)}\n        showCloseButton={showCloseButton}\n      >\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div data-slot=\"command-input-wrapper\" className=\"flex h-9 items-center gap-2 border-b px-3\">\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',\n          className\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  );\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn('bg-border -mx-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/context-menu.tsx",
    "content": "'use client';\n\nimport * as ContextMenuPrimitive from '@radix-ui/react-context-menu';\nimport { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nconst ContextMenu = ContextMenuPrimitive.Root;\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger;\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group;\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal;\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub;\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;\n\nconst ContextMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <ContextMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRightIcon className=\"ml-auto h-4 w-4\" />\n  </ContextMenuPrimitive.SubTrigger>\n));\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;\n\nconst ContextMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-high bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className\n    )}\n    {...props}\n  />\n));\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;\n\nconst ContextMenuContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Portal>\n    <ContextMenuPrimitive.Content\n      ref={ref}\n      className={cn(\n        'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-high bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className\n      )}\n      {...props}\n    />\n  </ContextMenuPrimitive.Portal>\n));\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;\n\nconst ContextMenuItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;\n\nconst ContextMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <ContextMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <CheckIcon className=\"h-4 w-4\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.CheckboxItem>\n));\nContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;\n\nconst ContextMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <ContextMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <DotFilledIcon className=\"h-4 w-4 fill-current\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.RadioItem>\n));\nContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;\n\nconst ContextMenuLabel = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Label\n    ref={ref}\n    className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}\n    {...props}\n  />\n));\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;\n\nconst ContextMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-border-high', className)}\n    {...props}\n  />\n));\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;\n\nconst ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}\n      {...props}\n    />\n  );\n};\nContextMenuShortcut.displayName = 'ContextMenuShortcut';\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/data-table.tsx",
    "content": "'use client';\n\nimport {\n  type ColumnDef,\n  type ColumnPinningState,\n  type OnChangeFn,\n  type PaginationState,\n  type Row,\n  type RowSelectionState,\n  flexRender,\n  getCoreRowModel,\n  useReactTable,\n} from '@tanstack/react-table';\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\nimport { useMemo } from 'react';\n\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\n\n/** Pagination info for server-side pagination */\nexport interface DataTablePagination {\n  /** Current page index (0-based) */\n  pageIndex: number;\n  /** Number of rows per page */\n  pageSize: number;\n  /** Total number of rows (for server-side pagination) */\n  total: number;\n}\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n  emptyMessage?: string;\n  caption?: React.ReactNode;\n  className?: string;\n  pinnedColumns?: {\n    left?: string[];\n    right?: string[];\n  };\n  /** Server-side pagination state */\n  pagination?: DataTablePagination;\n  /** Callback when pagination changes */\n  onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void;\n  /** Available page sizes */\n  pageSizeOptions?: number[];\n  /** Enable row selection */\n  enableRowSelection?: boolean;\n  /** Controlled row selection */\n  rowSelection?: RowSelectionState;\n  /** Callback when row selection changes */\n  onRowSelectionChange?: OnChangeFn<RowSelectionState>;\n  /** Get row id for stable selection */\n  getRowId?: (originalRow: TData, index: number) => string;\n  /** Context menu content for a row */\n  rowContextMenuContent?: (row: Row<TData>) => React.ReactNode;\n}\n\nexport function DataTable<TData, TValue>({\n  columns,\n  data,\n  emptyMessage = 'No results.',\n  caption,\n  className,\n  pinnedColumns,\n  pagination,\n  onPaginationChange,\n  pageSizeOptions = [10, 20, 50, 100],\n  enableRowSelection,\n  rowSelection,\n  onRowSelectionChange,\n  getRowId,\n  rowContextMenuContent,\n}: DataTableProps<TData, TValue>) {\n  const columnPinning = useMemo<ColumnPinningState>(\n    () => ({\n      left: pinnedColumns?.left ?? [],\n      right: pinnedColumns?.right ?? [],\n    }),\n    [pinnedColumns]\n  );\n\n  // Server-side pagination state\n  const paginationState = useMemo<PaginationState | undefined>(\n    () =>\n      pagination\n        ? {\n            pageIndex: pagination.pageIndex,\n            pageSize: pagination.pageSize,\n          }\n        : undefined,\n    [pagination]\n  );\n\n  const pageCount = pagination ? Math.ceil(pagination.total / pagination.pageSize) : -1;\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    // Server-side pagination\n    manualPagination: Boolean(pagination),\n    pageCount,\n    enableRowSelection: Boolean(enableRowSelection),\n    state: {\n      columnPinning,\n      ...(paginationState && { pagination: paginationState }),\n      ...(rowSelection && { rowSelection }),\n    },\n    onPaginationChange: onPaginationChange\n      ? (updater) => {\n          const newState =\n            typeof updater === 'function'\n              ? updater(paginationState ?? { pageIndex: 0, pageSize: 10 })\n              : updater;\n          onPaginationChange(newState);\n        }\n      : undefined,\n    onRowSelectionChange: onRowSelectionChange,\n    getRowId,\n  });\n\n  // Calculate cumulative offsets for pinned columns\n  const getPinnedStyle = (columnId: string, isPinned: string | false) => {\n    if (!isPinned || isPinned !== 'left' || !pinnedColumns?.left) return {};\n\n    const pinnedIndex = pinnedColumns.left.indexOf(columnId);\n    if (pinnedIndex <= 0) return { left: 0 };\n\n    // Calculate cumulative width of all previous pinned columns\n    let offset = 0;\n    for (let i = 0; i < pinnedIndex; i++) {\n      const colId = pinnedColumns.left[i];\n      const col = table.getColumn(colId);\n      // Use column size if defined, otherwise use a default\n      offset += col?.getSize() ?? (colId === 'select' ? 50 : 150);\n    }\n    return { left: offset };\n  };\n\n  return (\n    <div className={cn('w-full flex flex-col min-h-0', className)}>\n      <div className=\"rounded-lg border overflow-hidden flex-1 min-h-0\">\n        <ScrollArea className=\"h-full w-full\" orientation=\"both\">\n          <table\n            className=\"w-full caption-bottom text-sm border-collapse\"\n            style={{ minWidth: 'max-content' }}\n          >\n            {caption && <caption className=\"text-muted-foreground mt-4 text-xs\">{caption}</caption>}\n            <TableHeader className=\"sticky top-0 z-20 bg-muted/95 backdrop-blur-sm\">\n              {table.getHeaderGroups().map((headerGroup) => (\n                <TableRow key={headerGroup.id} className=\"hover:bg-transparent border-b\">\n                  {headerGroup.headers.map((header) => {\n                    const isPinned = header.column.getIsPinned();\n                    const pinnedStyle = getPinnedStyle(header.column.id, isPinned);\n                    const colSize = header.column.getSize();\n                    return (\n                      <TableHead\n                        key={header.id}\n                        className={cn(\n                          'whitespace-nowrap',\n                          isPinned && 'sticky z-30 bg-muted/95 backdrop-blur-sm',\n                          isPinned === 'left' &&\n                            'border-r border-border/50 shadow-[2px_0_8px_-4px_rgba(0,0,0,0.15)]',\n                          isPinned === 'right' && 'right-0'\n                        )}\n                        style={{\n                          ...pinnedStyle,\n                          width: colSize,\n                          minWidth: colSize,\n                        }}\n                      >\n                        {header.isPlaceholder\n                          ? null\n                          : flexRender(header.column.columnDef.header, header.getContext())}\n                      </TableHead>\n                    );\n                  })}\n                </TableRow>\n              ))}\n            </TableHeader>\n            <TableBody>\n              {table.getRowModel().rows?.length ? (\n                table.getRowModel().rows.map((row) => {\n                  const rowCells = row.getVisibleCells().map((cell) => {\n                    const isPinned = cell.column.getIsPinned();\n                    const pinnedStyle = getPinnedStyle(cell.column.id, isPinned);\n                    const colSize = cell.column.getSize();\n                    return (\n                      <TableCell\n                        key={cell.id}\n                        className={cn(\n                          isPinned && 'sticky z-10 bg-background',\n                          isPinned === 'left' &&\n                            'border-r border-border/50 shadow-[2px_0_8px_-4px_rgba(0,0,0,0.15)]',\n                          isPinned === 'right' && 'right-0'\n                        )}\n                        style={{\n                          ...pinnedStyle,\n                          width: colSize,\n                          minWidth: colSize,\n                        }}\n                      >\n                        {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                      </TableCell>\n                    );\n                  });\n\n                  if (!rowContextMenuContent) {\n                    return (\n                      <TableRow\n                        key={row.id}\n                        data-state={row.getIsSelected() && 'selected'}\n                        className=\"hover:bg-muted/30\"\n                      >\n                        {rowCells}\n                      </TableRow>\n                    );\n                  }\n\n                  return (\n                    <ContextMenu key={row.id}>\n                      <ContextMenuTrigger asChild>\n                        <TableRow\n                          data-state={row.getIsSelected() && 'selected'}\n                          className=\"hover:bg-muted/30\"\n                        >\n                          {rowCells}\n                        </TableRow>\n                      </ContextMenuTrigger>\n                      <ContextMenuContent className=\"w-56\">\n                        {rowContextMenuContent(row)}\n                      </ContextMenuContent>\n                    </ContextMenu>\n                  );\n                })\n              ) : (\n                <TableRow>\n                  <TableCell\n                    colSpan={columns.length}\n                    className=\"h-24 text-center text-sm text-muted-foreground\"\n                  >\n                    {emptyMessage}\n                  </TableCell>\n                </TableRow>\n              )}\n            </TableBody>\n          </table>\n        </ScrollArea>\n      </div>\n\n      {/* Pagination controls */}\n      {pagination && onPaginationChange && (\n        <div className=\"flex items-center justify-between px-2\">\n          <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n            <span>\n              {pagination.total > 0\n                ? `${pagination.pageIndex * pagination.pageSize + 1}-${Math.min(\n                    (pagination.pageIndex + 1) * pagination.pageSize,\n                    pagination.total\n                  )} of ${pagination.total}`\n                : '0 records'}\n            </span>\n          </div>\n\n          <div className=\"flex items-center gap-6\">\n            {/* Page size selector */}\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-sm text-muted-foreground\">Rows per page</span>\n              <Select\n                value={String(pagination.pageSize)}\n                onValueChange={(value) =>\n                  onPaginationChange({ pageIndex: 0, pageSize: Number(value) })\n                }\n              >\n                <SelectTrigger className=\"w-[70px]\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {pageSizeOptions.map((size) => (\n                    <SelectItem key={size} value={String(size)}>\n                      {size}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n\n            {/* Page navigation */}\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-sm text-muted-foreground\">\n                Page {pagination.pageIndex + 1} of {Math.max(pageCount, 1)}\n              </span>\n              <div className=\"flex items-center gap-1\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"h-8 w-8\"\n                  onClick={() => table.setPageIndex(0)}\n                  disabled={!table.getCanPreviousPage()}\n                >\n                  <ChevronsLeft className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">First page</span>\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"h-8 w-8\"\n                  onClick={() => table.previousPage()}\n                  disabled={!table.getCanPreviousPage()}\n                >\n                  <ChevronLeft className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">Previous page</span>\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"h-8 w-8\"\n                  onClick={() => table.nextPage()}\n                  disabled={!table.getCanNextPage()}\n                >\n                  <ChevronRight className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">Next page</span>\n                </Button>\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"h-8 w-8\"\n                  onClick={() => table.setPageIndex(table.getPageCount() - 1)}\n                  disabled={!table.getCanNextPage()}\n                >\n                  <ChevronsRight className=\"h-4 w-4\" />\n                  <span className=\"sr-only\">Last page</span>\n                </Button>\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/ui/date-picker.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport { format } from 'date-fns';\nimport { CalendarIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Calendar } from '@/components/ui/calendar';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\n\ninterface DatePickerProps {\n  value?: Date | null;\n  onChange?: (date: Date | null) => void;\n  placeholder?: string;\n  disabled?: boolean;\n}\n\nexport function DatePicker({ value, onChange, placeholder, disabled }: DatePickerProps) {\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          disabled={disabled}\n          className={cn(\n            'w-full justify-start text-left font-normal',\n            !value && 'text-muted-foreground'\n          )}\n        >\n          <CalendarIcon className=\"mr-2 h-4 w-4\" />\n          {value ? format(value, 'PPP') : <span>{placeholder ?? 'Pick a date'}</span>}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <Calendar\n          mode=\"single\"\n          selected={value ?? undefined}\n          onSelect={(date) => {\n            onChange?.(date ?? null);\n            setOpen(false);\n          }}\n          initialFocus\n        />\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/components/ui/dialog.tsx",
    "content": "import * as React from 'react';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { XIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />;\n}\n\nfunction DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />;\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean;\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg',\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn('flex flex-col gap-2 text-center sm:text-left', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn('text-lg leading-none font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/dropdown-menu.tsx",
    "content": "import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';\nimport { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';\nimport * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRightIcon className=\"ml-auto h-4 w-4\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-high bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {\n    container?: HTMLElement | null;\n    portalled?: boolean;\n  }\n>(({ className, sideOffset = 4, container, portalled = true, ...props }, ref) => {\n  const content = (\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-high bg-popover p-1 text-popover-foreground shadow-md',\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className\n      )}\n      {...props}\n    />\n  );\n\n  if (!portalled) {\n    return content;\n  }\n\n  return (\n    <DropdownMenuPrimitive.Portal container={container}>{content}</DropdownMenuPrimitive.Portal>\n  );\n});\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <CheckIcon className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <DotFilledIcon className=\"h-4 w-4 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-border-high', className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />\n  );\n};\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n  type DropdownMenuProps,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/form.tsx",
    "content": "import * as React from 'react';\nimport type * as LabelPrimitive from '@radix-ui/react-label';\nimport { Slot } from '@radix-ui/react-slot';\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  useFormState,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from 'react-hook-form';\n\nimport { cn } from '@/lib/utils';\nimport { Label } from '@/components/ui/label';\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  );\n};\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext);\n  const itemContext = React.useContext(FormItemContext);\n  const { getFieldState } = useFormContext();\n  const formState = useFormState({ name: fieldContext.name });\n  const fieldState = getFieldState(fieldContext.name, formState);\n\n  if (!fieldContext) {\n    throw new Error('useFormField should be used within <FormField>');\n  }\n\n  const { id } = itemContext;\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  };\n};\n\ntype FormItemContextValue = {\n  id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);\n\nfunction FormItem({ className, ...props }: React.ComponentProps<'div'>) {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div data-slot=\"form-item\" className={cn('grid gap-2', className)} {...props} />\n    </FormItemContext.Provider>\n  );\n}\n\nfunction FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <Label\n      data-slot=\"form-label\"\n      data-error={!!error}\n      className={cn('data-[error=true]:text-destructive', className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n}\n\nfunction FormControl({ ...props }: React.ComponentProps<typeof Slot>) {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField();\n\n  return (\n    <Slot\n      data-slot=\"form-control\"\n      id={formItemId}\n      aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}\n      aria-invalid={!!error}\n      {...props}\n    />\n  );\n}\n\nfunction FormDescription({ className, ...props }: React.ComponentProps<'p'>) {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      data-slot=\"form-description\"\n      id={formDescriptionId}\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction FormMessage({ className, ...props }: React.ComponentProps<'p'>) {\n  const { error, formMessageId } = useFormField();\n  const body = error ? String(error?.message ?? '') : props.children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      data-slot=\"form-message\"\n      id={formMessageId}\n      className={cn('text-destructive text-sm', className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n}\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/input.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Input({ className, type, ...props }: React.ComponentProps<'input'>) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "apps/playground/src/components/ui/label.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as LabelPrimitive from '@radix-ui/react-label';\n\nimport { cn } from '@/lib/utils';\n\nfunction Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Label };\n"
  },
  {
    "path": "apps/playground/src/components/ui/popover.tsx",
    "content": "import * as React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\n\nimport { cn } from '@/lib/utils';\n\nfunction Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />;\n}\n\nfunction PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\nfunction PopoverContent({\n  className,\n  align = 'center',\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',\n          className\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  );\n}\n\nfunction PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />;\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n"
  },
  {
    "path": "apps/playground/src/components/ui/radio-group.tsx",
    "content": "import * as React from 'react';\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group';\nimport { CircleIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction RadioGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {\n  return (\n    <RadioGroupPrimitive.Root\n      data-slot=\"radio-group\"\n      className={cn('grid gap-3', className)}\n      {...props}\n    />\n  );\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {\n  return (\n    <RadioGroupPrimitive.Item\n      data-slot=\"radio-group-item\"\n      className={cn(\n        'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator\n        data-slot=\"radio-group-indicator\"\n        className=\"relative flex items-center justify-center\"\n      >\n        <CircleIcon className=\"fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"
  },
  {
    "path": "apps/playground/src/components/ui/scroll-area.tsx",
    "content": "import * as React from 'react';\nimport * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';\n\nimport { cn } from '@/lib/utils';\n\nfunction ScrollArea({\n  className,\n  children,\n  orientation = 'vertical',\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {\n  orientation?: 'vertical' | 'horizontal' | 'both';\n}) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn('relative overflow-hidden', className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"size-full rounded-[inherit] [&>div]:!block [&>div]:!min-w-0\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      {(orientation === 'vertical' || orientation === 'both') && (\n        <ScrollBar orientation=\"vertical\" />\n      )}\n      {(orientation === 'horizontal' || orientation === 'both') && (\n        <ScrollBar orientation=\"horizontal\" />\n      )}\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  );\n}\n\nfunction ScrollBar({\n  className,\n  orientation = 'vertical',\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        'flex touch-none p-px transition-colors select-none',\n        orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',\n        orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  );\n}\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "apps/playground/src/components/ui/select.tsx",
    "content": "import * as React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />;\n}\n\nfunction SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = 'default',\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: 'sm' | 'default';\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = 'item-aligned',\n  align = 'center',\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',\n          position === 'popper' &&\n            'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n          className\n        )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            'p-1',\n            position === 'popper' &&\n              'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span\n        data-slot=\"select-item-indicator\"\n        className=\"absolute right-2 flex size-3.5 items-center justify-center\"\n      >\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn('flex cursor-default items-center justify-center py-1', className)}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn('flex cursor-default items-center justify-center py-1', className)}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/separator.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SeparatorPrimitive from '@radix-ui/react-separator';\n\nimport { cn } from '@/lib/utils';\n\nfunction Separator({\n  className,\n  orientation = 'horizontal',\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Separator };\n"
  },
  {
    "path": "apps/playground/src/components/ui/sheet.tsx",
    "content": "import * as React from 'react';\nimport * as SheetPrimitive from '@radix-ui/react-dialog';\nimport { XIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />;\n}\n\nfunction SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />;\n}\n\nfunction SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />;\n}\n\nfunction SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />;\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = 'right',\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: 'top' | 'right' | 'bottom' | 'left';\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot=\"sheet-content\"\n        className={cn(\n          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',\n          side === 'right' &&\n            'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',\n          side === 'left' &&\n            'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',\n          side === 'top' &&\n            'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',\n          side === 'bottom' &&\n            'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn('flex flex-col gap-1.5 p-4', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sheet-footer\"\n      className={cn('mt-auto flex flex-col gap-2 p-4', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn('text-foreground font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn('text-muted-foreground text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/sidebar.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { PanelLeftIcon } from 'lucide-react';\n\nimport { useIsMobile } from '@/hooks/use-mobile';\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from '@/components/ui/sheet';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';\n\nconst SIDEBAR_COOKIE_NAME = 'sidebar_state';\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH_STORAGE_KEY = 'sidebar_width';\nconst SIDEBAR_WIDTH = '22rem';\nconst SIDEBAR_WIDTH_MOBILE = '18rem';\nconst SIDEBAR_WIDTH_ICON = '3rem';\nconst SIDEBAR_KEYBOARD_SHORTCUT = 'b';\nconst SIDEBAR_MIN_WIDTH = 200;\nconst SIDEBAR_MAX_WIDTH = 420;\n\ntype SidebarContextProps = {\n  state: 'expanded' | 'collapsed';\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n  sidebarWidth: string;\n  setSidebarWidth: (value: string) => void;\n};\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) {\n    throw new Error('useSidebar must be used within a SidebarProvider.');\n  }\n\n  return context;\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst toPixels = (value: string) => {\n  if (value.endsWith('px')) return Number.parseFloat(value);\n  if (value.endsWith('rem')) {\n    const rootSize =\n      typeof window === 'undefined'\n        ? 16\n        : Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n    return Number.parseFloat(value) * rootSize;\n  }\n  const parsed = Number.parseFloat(value);\n  return Number.isNaN(parsed) ? 256 : parsed;\n};\n\nconst getStoredSidebarWidth = () => {\n  if (typeof window === 'undefined') return SIDEBAR_WIDTH;\n  return localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) ?? SIDEBAR_WIDTH;\n};\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}) {\n  const isMobile = useIsMobile();\n  const [openMobile, setOpenMobile] = React.useState(false);\n  const [sidebarWidth, setSidebarWidth] = React.useState(getStoredSidebarWidth);\n\n  React.useEffect(() => {\n    if (typeof window === 'undefined') return;\n    localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, sidebarWidth);\n  }, [sidebarWidth]);\n\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen);\n  const open = openProp ?? _open;\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === 'function' ? value(open) : value;\n      if (setOpenProp) {\n        setOpenProp(openState);\n      } else {\n        _setOpen(openState);\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n    },\n    [setOpenProp, open]\n  );\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(() => {\n    return isMobile ? setOpenMobile((nextOpen) => !nextOpen) : setOpen((nextOpen) => !nextOpen);\n  }, [isMobile, setOpen, setOpenMobile]);\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault();\n        toggleSidebar();\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [toggleSidebar]);\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? 'expanded' : 'collapsed';\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n      sidebarWidth,\n      setSidebarWidth,\n    }),\n    [\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n      sidebarWidth,\n      setSidebarWidth,\n    ]\n  );\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <TooltipProvider delayDuration={0}>\n        <div\n          data-slot=\"sidebar-wrapper\"\n          style={\n            {\n              '--sidebar-width': sidebarWidth,\n              '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as React.CSSProperties\n          }\n          className={cn(\n            'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',\n            className\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  );\n}\n\nfunction Sidebar({\n  side = 'left',\n  variant = 'sidebar',\n  collapsible = 'offcanvas',\n  className,\n  children,\n  ...props\n}: React.ComponentProps<'div'> & {\n  side?: 'left' | 'right';\n  variant?: 'sidebar' | 'floating' | 'inset';\n  collapsible?: 'offcanvas' | 'icon' | 'none';\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n  if (collapsible === 'none') {\n    return (\n      <div\n        data-slot=\"sidebar\"\n        className={cn(\n          'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    );\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n        <SheetContent\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar\"\n          data-mobile=\"true\"\n          className=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden\"\n          style={\n            {\n              '--sidebar-width': SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n          side={side}\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n\n  return (\n    <div\n      className=\"group peer text-sidebar-foreground hidden md:block\"\n      data-state={state}\n      data-collapsible={state === 'collapsed' ? collapsible : ''}\n      data-variant={variant}\n      data-side={side}\n      data-slot=\"sidebar\"\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot=\"sidebar-gap\"\n        className={cn(\n          'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',\n          'group-data-[collapsible=offcanvas]:w-0',\n          'group-data-[side=right]:rotate-180',\n          variant === 'floating' || variant === 'inset'\n            ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'\n            : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'\n        )}\n      />\n      <div\n        data-slot=\"sidebar-container\"\n        className={cn(\n          'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',\n          side === 'left'\n            ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'\n            : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',\n          // Adjust the padding for floating and inset variants.\n          variant === 'floating' || variant === 'inset'\n            ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'\n            : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',\n          className\n        )}\n        {...props}\n      >\n        <div\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n          className=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <Button\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn('size-7', className)}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      {...props}\n    >\n      <PanelLeftIcon />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  );\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {\n  const { isMobile, sidebarWidth, setSidebarWidth } = useSidebar();\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {\n    if (isMobile) return;\n    event.preventDefault();\n    const startX = event.clientX;\n    const startWidth = toPixels(sidebarWidth);\n\n    const handlePointerMove = (moveEvent: PointerEvent) => {\n      const delta = moveEvent.clientX - startX;\n      const nextWidth = clamp(startWidth + delta, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);\n      setSidebarWidth(`${Math.round(nextWidth)}px`);\n    };\n\n    const handlePointerUp = () => {\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n      window.removeEventListener('pointermove', handlePointerMove);\n      window.removeEventListener('pointerup', handlePointerUp);\n    };\n\n    document.body.style.cursor = 'col-resize';\n    document.body.style.userSelect = 'none';\n    window.addEventListener('pointermove', handlePointerMove);\n    window.addEventListener('pointerup', handlePointerUp);\n  };\n\n  return (\n    <button\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      aria-label=\"Resize sidebar\"\n      tabIndex={-1}\n      onPointerDown={handlePointerDown}\n      title=\"Resize sidebar\"\n      className={cn(\n        'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',\n        'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',\n        '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',\n        'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',\n        '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',\n        '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        'bg-background relative flex w-full flex-1 flex-col',\n        'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {\n  return <Input data-slot=\"sidebar-input\" data-sidebar=\"input\" className={className} {...props} />;\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      data-sidebar=\"header\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      data-sidebar=\"footer\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"sidebar-separator\"\n      data-sidebar=\"separator\"\n      className={cn('bg-sidebar-border mx-2 w-[calc(100%-1rem)]', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\n      data-sidebar=\"content\"\n      className={cn(\n        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      data-sidebar=\"group\"\n      className={cn('relative flex w-full min-w-0 flex-col p-2', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'div'> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'div';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      data-sidebar=\"group-label\"\n      className={cn(\n        'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<'button'> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-action\"\n      data-sidebar=\"group-action\"\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        // Increases the hit area of the button on mobile.\n        'after:absolute after:-inset-2 md:after:hidden',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-group-content\"\n      data-sidebar=\"group-content\"\n      className={cn('w-full text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu\"\n      data-sidebar=\"menu\"\n      className={cn('flex w-full min-w-0 flex-col gap-1', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-item\"\n      data-sidebar=\"menu-item\"\n      className={cn('group/menu-item relative', className)}\n      {...props}\n    />\n  );\n}\n\nconst sidebarMenuButtonVariants = cva(\n  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',\n        outline:\n          'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',\n      },\n      size: {\n        default: 'h-8 text-sm',\n        sm: 'h-7 text-xs',\n        lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n);\n\nconst SidebarMenuButton = React.forwardRef<\n  React.ElementRef<'button'>,\n  React.ComponentPropsWithoutRef<'button'> & {\n    asChild?: boolean;\n    isActive?: boolean;\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n  } & VariantProps<typeof sidebarMenuButtonVariants>\n>(\n  (\n    {\n      asChild = false,\n      isActive = false,\n      variant = 'default',\n      size = 'default',\n      tooltip,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const Comp = asChild ? Slot : 'button';\n    const { isMobile, state } = useSidebar();\n\n    const button = (\n      <Comp\n        ref={ref}\n        data-slot=\"sidebar-menu-button\"\n        data-sidebar=\"menu-button\"\n        data-size={size}\n        data-active={isActive}\n        className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n        {...props}\n      />\n    );\n\n    if (!tooltip) {\n      return button;\n    }\n\n    if (typeof tooltip === 'string') {\n      tooltip = {\n        children: tooltip,\n      };\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>{button}</TooltipTrigger>\n        <TooltipContent\n          side=\"right\"\n          align=\"center\"\n          hidden={state !== 'collapsed' || isMobile}\n          {...tooltip}\n        />\n      </Tooltip>\n    );\n  }\n);\nSidebarMenuButton.displayName = 'SidebarMenuButton';\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<'button'> & {\n  asChild?: boolean;\n  showOnHover?: boolean;\n}) {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-action\"\n      data-sidebar=\"menu-action\"\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        // Increases the hit area of the button on mobile.\n        'after:absolute after:-inset-2 md:after:hidden',\n        'peer-data-[size=sm]/menu-button:top-1',\n        'peer-data-[size=default]/menu-button:top-1.5',\n        'peer-data-[size=lg]/menu-button:top-2.5',\n        'group-data-[collapsible=icon]:hidden',\n        showOnHover &&\n          'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      data-sidebar=\"menu-badge\"\n      className={cn(\n        'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',\n        'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',\n        'peer-data-[size=sm]/menu-button:top-1',\n        'peer-data-[size=default]/menu-button:top-1.5',\n        'peer-data-[size=lg]/menu-button:top-2.5',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<'div'> & {\n  showIcon?: boolean;\n}) {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  }, []);\n\n  return (\n    <div\n      data-slot=\"sidebar-menu-skeleton\"\n      data-sidebar=\"menu-skeleton\"\n      className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}\n      {...props}\n    >\n      {showIcon ? (\n        <Skeleton className=\"size-4 rounded-md\" data-sidebar=\"menu-skeleton-icon\" />\n      ) : null}\n      <Skeleton\n        className=\"h-4 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            '--skeleton-width': width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      data-sidebar=\"menu-sub\"\n      className={cn(\n        'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      data-sidebar=\"menu-sub-item\"\n      className={cn('group/menu-sub-item relative', className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = 'md',\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<'a'> & {\n  asChild?: boolean;\n  size?: 'sm' | 'md';\n  isActive?: boolean;\n}) {\n  const Comp = asChild ? Slot : 'a';\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',\n        'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',\n        size === 'sm' && 'text-xs',\n        size === 'md' && 'text-sm',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n};\n"
  },
  {
    "path": "apps/playground/src/components/ui/skeleton.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn('animate-pulse rounded-md bg-primary/10', className)} {...props} />;\n}\n\nexport { Skeleton };\n"
  },
  {
    "path": "apps/playground/src/components/ui/slider.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SliderPrimitive from '@radix-ui/react-slider';\n\nimport { cn } from '@/lib/utils';\n\nfunction Slider({\n  className,\n  defaultValue,\n  value,\n  min = 0,\n  max = 100,\n  ...props\n}: React.ComponentProps<typeof SliderPrimitive.Root>) {\n  const _values = React.useMemo(\n    () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),\n    [value, defaultValue, min, max]\n  );\n\n  return (\n    <SliderPrimitive.Root\n      data-slot=\"slider\"\n      defaultValue={defaultValue}\n      value={value}\n      min={min}\n      max={max}\n      className={cn(\n        'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',\n        className\n      )}\n      {...props}\n    >\n      <SliderPrimitive.Track\n        data-slot=\"slider-track\"\n        className={cn(\n          'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'\n        )}\n      >\n        <SliderPrimitive.Range\n          data-slot=\"slider-range\"\n          className={cn(\n            'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'\n          )}\n        />\n      </SliderPrimitive.Track>\n      {Array.from({ length: _values.length }, (_, index) => (\n        <SliderPrimitive.Thumb\n          data-slot=\"slider-thumb\"\n          key={index}\n          className=\"border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50\"\n        />\n      ))}\n    </SliderPrimitive.Root>\n  );\n}\n\nexport { Slider };\n"
  },
  {
    "path": "apps/playground/src/components/ui/switch.tsx",
    "content": "'use client';\n\nimport * as React from 'react';\nimport * as SwitchPrimitive from '@radix-ui/react-switch';\n\nimport { cn } from '@/lib/utils';\n\nfunction Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'\n        )}\n      />\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch };\n"
  },
  {
    "path": "apps/playground/src/components/ui/table.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Table({ className, ...props }: React.ComponentProps<'table'>) {\n  return (\n    <div data-slot=\"table-container\" className=\"relative w-full overflow-x-auto\">\n      <table\n        data-slot=\"table\"\n        className={cn('w-full caption-bottom text-sm', className)}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {\n  return <thead data-slot=\"table-header\" className={cn('[&_tr]:border-b', className)} {...props} />;\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn('[&_tr:last-child]:border-0', className)}\n      {...props}\n    />\n  );\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}\n      {...props}\n    />\n  );\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<'tr'>) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<'th'>) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<'td'>) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn('text-muted-foreground mt-4 text-sm', className)}\n      {...props}\n    />\n  );\n}\n\nexport { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };\n"
  },
  {
    "path": "apps/playground/src/components/ui/tabs.tsx",
    "content": "import * as React from 'react';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\n\nimport { cn } from '@/lib/utils';\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      'inline-flex h-10 items-center justify-center rounded-md border border-border bg-muted p-1 text-muted-foreground',\n      className\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n      className\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      'mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n      className\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "apps/playground/src/components/ui/textarea.tsx",
    "content": "import * as React from 'react';\n\nimport { cn } from '@/lib/utils';\n\nfunction Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
  },
  {
    "path": "apps/playground/src/components/ui/tooltip.tsx",
    "content": "import * as React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\n\nimport { cn } from '@/lib/utils';\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  );\n}\n\nfunction Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  );\n}\n\nfunction TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />;\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "apps/playground/src/hooks/use-mobile.ts",
    "content": "import * as React from 'react';\n\nconst MOBILE_BREAKPOINT = 768;\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);\n\n  React.useEffect(() => {\n    const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    };\n    mediaQuery.addEventListener('change', onChange);\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    return () => mediaQuery.removeEventListener('change', onChange);\n  }, []);\n\n  return Boolean(isMobile);\n}\n"
  },
  {
    "path": "apps/playground/src/hooks/useLogStream.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\n\nexport type LogLevel = 'debug' | 'info' | 'warn' | 'error';\n\nexport interface LogEntry {\n  id: string;\n  timestamp: string;\n  level: LogLevel;\n  message: string;\n  context?: Record<string, unknown>;\n}\n\nexport type LogStreamStatus = 'disconnected' | 'connecting' | 'connected' | 'error';\n\nexport interface UseLogStreamOptions {\n  /**\n   * Whether the stream should be enabled. Defaults to true.\n   */\n  enabled?: boolean;\n\n  /**\n   * Maximum number of log entries to keep in memory. Defaults to 1000.\n   */\n  maxEntries?: number;\n\n  /**\n   * Custom URL for the log stream endpoint. Defaults to '/api/logs/stream'.\n   */\n  url?: string;\n\n  /**\n   * Callback when a new log entry is received.\n   */\n  onEntry?: (entry: LogEntry) => void;\n}\n\nexport interface UseLogStreamResult {\n  /**\n   * Array of log entries received from the stream.\n   */\n  logs: ReadonlyArray<LogEntry>;\n\n  /**\n   * Current connection status.\n   */\n  status: LogStreamStatus;\n\n  /**\n   * Error message if status is 'error'.\n   */\n  error: string | null;\n\n  /**\n   * Whether log reception is paused.\n   */\n  paused: boolean;\n\n  /**\n   * Pause receiving new logs (keeps connection open but stops updating state).\n   */\n  pause: () => void;\n\n  /**\n   * Resume receiving new logs.\n   */\n  resume: () => void;\n\n  /**\n   * Clear all stored log entries.\n   */\n  clear: () => void;\n\n  /**\n   * Manually reconnect to the stream.\n   */\n  reconnect: () => void;\n}\n\nconst DEFAULT_URL = '/api/logs/stream';\nconst DEFAULT_MAX_ENTRIES = 1000;\n\n/**\n * Hook to subscribe to the real-time log stream via SSE.\n *\n * @example\n * ```tsx\n * const { logs, status, pause, resume, clear } = useLogStream();\n *\n * return (\n *   <div>\n *     <span>Status: {status}</span>\n *     <button onClick={pause}>Pause</button>\n *     <button onClick={resume}>Resume</button>\n *     <button onClick={clear}>Clear</button>\n *     <ul>\n *       {logs.map(log => (\n *         <li key={log.id}>[{log.level}] {log.message}</li>\n *       ))}\n *     </ul>\n *   </div>\n * );\n * ```\n */\nexport const useLogStream = (options?: UseLogStreamOptions): UseLogStreamResult => {\n  const {\n    enabled = true,\n    maxEntries = DEFAULT_MAX_ENTRIES,\n    url = DEFAULT_URL,\n    onEntry,\n  } = options ?? {};\n\n  const [logs, setLogs] = useState<LogEntry[]>([]);\n  const [status, setStatus] = useState<LogStreamStatus>('disconnected');\n  const [error, setError] = useState<string | null>(null);\n  const [paused, setPaused] = useState(false);\n\n  const eventSourceRef = useRef<EventSource | null>(null);\n  const pausedRef = useRef(paused);\n  const onEntryRef = useRef(onEntry);\n\n  // Keep refs in sync\n  pausedRef.current = paused;\n  onEntryRef.current = onEntry;\n\n  const clear = useCallback(() => {\n    setLogs([]);\n  }, []);\n\n  const pause = useCallback(() => {\n    setPaused(true);\n  }, []);\n\n  const resume = useCallback(() => {\n    setPaused(false);\n  }, []);\n\n  const [reconnectKey, setReconnectKey] = useState(0);\n\n  const reconnect = useCallback(() => {\n    setReconnectKey((k) => k + 1);\n    setStatus('disconnected');\n    setError(null);\n  }, []);\n\n  useEffect(() => {\n    if (!enabled) {\n      setStatus('disconnected');\n      return;\n    }\n\n    let isActive = true;\n    setStatus('connecting');\n    setError(null);\n\n    const eventSource = new EventSource(url);\n    eventSourceRef.current = eventSource;\n\n    eventSource.onopen = () => {\n      if (!isActive) return;\n      setStatus('connected');\n      setError(null);\n    };\n\n    eventSource.onmessage = (event) => {\n      if (!isActive) return;\n\n      try {\n        const entry = JSON.parse(event.data) as LogEntry;\n\n        // Call the onEntry callback regardless of pause state\n        onEntryRef.current?.(entry);\n\n        // Only update state if not paused\n        if (!pausedRef.current) {\n          setLogs((prev) => {\n            const next = [...prev, entry];\n            // Trim to maxEntries\n            if (next.length > maxEntries) {\n              return next.slice(-maxEntries);\n            }\n            return next;\n          });\n        }\n      } catch {\n        // Ignore parse errors\n      }\n    };\n\n    eventSource.onerror = () => {\n      if (!isActive) return;\n      setStatus('error');\n      setError('Connection lost. Attempting to reconnect...');\n    };\n\n    return () => {\n      isActive = false;\n      eventSource.close();\n      eventSourceRef.current = null;\n    };\n  }, [enabled, url, maxEntries, reconnectKey]);\n\n  return {\n    logs,\n    status,\n    error,\n    paused,\n    pause,\n    resume,\n    clear,\n    reconnect,\n  };\n};\n"
  },
  {
    "path": "apps/playground/src/hooks/useRecord.ts",
    "content": "import { useMemo } from 'react';\nimport type { ITableRecordRealtimeDTO } from '@teable/v2-core';\n\nimport { useShareDbDoc } from '../lib/shareDb';\n\nexport type UseRecordParams = {\n  tableId?: string;\n  recordId?: string;\n  enabled?: boolean;\n};\n\nexport type UseRecordState = {\n  record: ITableRecordRealtimeDTO | null;\n  status: 'idle' | 'connecting' | 'ready' | 'error';\n  error: string | null;\n};\n\n/**\n * Subscribe to a single record via ShareDB.\n *\n * Example:\n * ```tsx\n * const { record, status, error } = useRecord({\n *   tableId: 'tbl123',\n *   recordId: 'rec456',\n * });\n * ```\n */\nexport const useRecord = (params: UseRecordParams): UseRecordState => {\n  const { tableId, recordId, enabled = true } = params;\n\n  const collection = useMemo(() => (tableId ? `rec_${tableId}` : undefined), [tableId]);\n\n  const { status, data, error } = useShareDbDoc<ITableRecordRealtimeDTO>({\n    collection,\n    docId: recordId,\n    enabled: enabled && !!tableId && !!recordId,\n  });\n\n  return { record: data, status, error };\n};\n"
  },
  {
    "path": "apps/playground/src/hooks/useRecords.ts",
    "content": "import { useMemo } from 'react';\nimport type { ITableRecordRealtimeDTO } from '@teable/v2-core';\n\nimport { useShareDbQuery } from '../lib/shareDb';\n\nexport type UseRecordsParams = {\n  tableId?: string;\n  enabled?: boolean;\n};\n\nexport type UseRecordsState = {\n  records: ReadonlyArray<ITableRecordRealtimeDTO>;\n  recordIds: ReadonlyArray<string>;\n  removedRecordIds: ReadonlyArray<string>;\n  status: 'idle' | 'connecting' | 'ready' | 'error';\n  error: string | null;\n};\n\n/**\n * Subscribe to all records in a table via ShareDB query.\n *\n * Example:\n * ```tsx\n * const { records, status, error } = useRecords({\n *   tableId: 'tbl123',\n * });\n *\n * // Also available: recordIds, removedRecordIds for tracking additions/deletions\n * ```\n */\nexport const useRecords = (params: UseRecordsParams): UseRecordsState => {\n  const { tableId, enabled = true } = params;\n\n  const collection = useMemo(() => (tableId ? `rec_${tableId}` : undefined), [tableId]);\n\n  // Empty query = all documents in collection\n  const query = useMemo(() => ({}), []);\n\n  const { status, data, ids, removedIds, error } = useShareDbQuery<ITableRecordRealtimeDTO>({\n    collection,\n    query,\n    enabled: enabled && !!tableId,\n  });\n\n  return {\n    records: data,\n    recordIds: ids,\n    removedRecordIds: removedIds,\n    status,\n    error,\n  };\n};\n"
  },
  {
    "path": "apps/playground/src/integrations/otel/client.ts",
    "content": "import { ORPCInstrumentation } from '@orpc/otel';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport { registerInstrumentations } from '@opentelemetry/instrumentation';\nimport { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';\nimport { resourceFromAttributes } from '@opentelemetry/resources';\nimport {\n  BatchSpanProcessor,\n  ConsoleSpanExporter,\n  SimpleSpanProcessor,\n  type SpanProcessor,\n} from '@opentelemetry/sdk-trace-base';\nimport { WebTracerProvider } from '@opentelemetry/sdk-trace-web';\nimport { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';\n\nlet started = false;\n\nconst parseOtelHeaders = (headerStr?: string) => {\n  if (!headerStr) return {};\n  return headerStr.split(',').reduce(\n    (acc, curr) => {\n      const [key, value] = curr.split('=');\n      if (key && value) {\n        acc[key.trim()] = value.trim();\n      }\n      return acc;\n    },\n    {} as Record<string, string>\n  );\n};\n\nexport const initClientOtel = () => {\n  if (started) return;\n  started = true;\n\n  const serviceName = import.meta.env.VITE_OTEL_SERVICE_NAME ?? 'teable-playground-web';\n  const serviceVersion = import.meta.env.VITE_BUILD_VERSION;\n  const otlpEndpoint = import.meta.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT;\n  const otlpHeaders = parseOtelHeaders(import.meta.env.VITE_OTEL_EXPORTER_OTLP_HEADERS);\n\n  const spanProcessors: SpanProcessor[] = [];\n\n  if (otlpEndpoint) {\n    spanProcessors.push(\n      new BatchSpanProcessor(\n        new OTLPTraceExporter({\n          url: otlpEndpoint,\n          headers: {\n            'Content-Type': 'application/x-protobuf',\n            ...otlpHeaders,\n          },\n        })\n      )\n    );\n  } else if (import.meta.env.DEV) {\n    spanProcessors.push(new SimpleSpanProcessor(new ConsoleSpanExporter()));\n  }\n\n  const provider = new WebTracerProvider({\n    resource: resourceFromAttributes({\n      [ATTR_SERVICE_NAME]: serviceName,\n      ...(serviceVersion ? { [ATTR_SERVICE_VERSION]: serviceVersion } : {}),\n    }),\n    spanProcessors,\n  });\n\n  provider.register();\n\n  const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n  const corsUrls = [new RegExp(`^${escapeRegExp(window.location.origin)}`)];\n\n  registerInstrumentations({\n    instrumentations: [\n      new ORPCInstrumentation(),\n      new FetchInstrumentation({\n        propagateTraceHeaderCorsUrls: corsUrls,\n      }),\n    ],\n  });\n};\n"
  },
  {
    "path": "apps/playground/src/integrations/tanstack-query/devtools.tsx",
    "content": "import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';\n\nexport default {\n  name: 'Tanstack Query',\n  render: <ReactQueryDevtoolsPanel />,\n};\n"
  },
  {
    "path": "apps/playground/src/integrations/tanstack-query/root-provider.tsx",
    "content": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\n\nexport function getContext() {\n  const queryClient = new QueryClient();\n  return {\n    queryClient,\n  };\n}\n\nexport function Provider({\n  children,\n  queryClient,\n}: {\n  children: React.ReactNode;\n  queryClient: QueryClient;\n}) {\n  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;\n}\n"
  },
  {
    "path": "apps/playground/src/lib/broadcastChannel.ts",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react';\n\nimport {\n  broadcastChannelDefaults,\n  getBroadcastChannelRealtimeHub,\n} from '@teable/v2-adapter-realtime-broadcastchannel';\n\nexport type BroadcastChannelStatus = 'idle' | 'ready' | 'error';\n\nexport type BroadcastChannelDocState<T> = {\n  status: BroadcastChannelStatus;\n  data: T | null;\n  error: string | null;\n};\n\nexport type BroadcastChannelQueryState<T> = {\n  status: BroadcastChannelStatus;\n  collection: string | null;\n  data: ReadonlyArray<T>;\n  ids: ReadonlyArray<string>;\n  removedIds: ReadonlyArray<string>;\n  error: string | null;\n};\n\nconst resolveChannelName = (): string => {\n  return import.meta.env.VITE_BROADCAST_CHANNEL ?? broadcastChannelDefaults.channelName;\n};\n\nexport const useBroadcastChannelDoc = <T>(params: {\n  collection?: string;\n  docId?: string;\n  enabled?: boolean;\n}): BroadcastChannelDocState<T> => {\n  const { collection, docId, enabled = true } = params;\n  const docKey = useMemo(() => {\n    if (!collection || !docId) return null;\n    return `${collection}/${docId}`;\n  }, [collection, docId]);\n  const [state, setState] = useState<BroadcastChannelDocState<T>>({\n    status: 'idle',\n    data: null,\n    error: null,\n  });\n\n  useEffect(() => {\n    if (!enabled || !docKey) {\n      setState({ status: 'idle', data: null, error: null });\n      return;\n    }\n\n    const hubResult = getBroadcastChannelRealtimeHub(resolveChannelName());\n    if (hubResult.isErr()) {\n      setState({ status: 'error', data: null, error: hubResult.error.message });\n      return;\n    }\n\n    const hub = hubResult.value;\n    const unsubscribe = hub.subscribeDoc(docKey, (snapshot) => {\n      setState({ status: 'ready', data: (snapshot as T) ?? null, error: null });\n    });\n\n    return () => {\n      unsubscribe();\n    };\n  }, [docKey, enabled]);\n\n  return state;\n};\n\nexport const useBroadcastChannelQuery = <T>(params: {\n  collection?: string;\n  enabled?: boolean;\n  getId?: (snapshot: T) => string | null;\n}): BroadcastChannelQueryState<T> => {\n  const { collection, enabled = true, getId } = params;\n  const [state, setState] = useState<BroadcastChannelQueryState<T>>({\n    status: 'idle',\n    collection: null,\n    data: [],\n    ids: [],\n    removedIds: [],\n    error: null,\n  });\n  const previousIdsRef = useRef<Set<string>>(new Set());\n\n  useEffect(() => {\n    if (!enabled || !collection) {\n      previousIdsRef.current = new Set();\n      setState({\n        status: 'idle',\n        collection: null,\n        data: [],\n        ids: [],\n        removedIds: [],\n        error: null,\n      });\n      return;\n    }\n\n    const hubResult = getBroadcastChannelRealtimeHub(resolveChannelName());\n    if (hubResult.isErr()) {\n      previousIdsRef.current = new Set();\n      setState({\n        status: 'error',\n        collection,\n        data: [],\n        ids: [],\n        removedIds: [],\n        error: hubResult.error.message ?? 'Unknown error',\n      });\n      return;\n    }\n\n    const hub = hubResult.value;\n    previousIdsRef.current = new Set();\n    const subscribedCollection = collection;\n    const unsubscribe = hub.subscribeCollection(collection, (snapshots, removedDocIds) => {\n      const data = snapshots as ReadonlyArray<T>;\n      const ids =\n        getId != null\n          ? data.flatMap((snapshot) => {\n              const id = getId(snapshot);\n              return id ? [id] : [];\n            })\n          : data.flatMap((snapshot) => {\n              if (\n                snapshot &&\n                typeof snapshot === 'object' &&\n                'id' in snapshot &&\n                typeof (snapshot as { id?: unknown }).id === 'string'\n              ) {\n                return [(snapshot as { id: string }).id];\n              }\n              return [];\n            });\n      const nextIds = new Set(ids);\n      const diffRemovedIds = [...previousIdsRef.current].filter((id) => !nextIds.has(id));\n      const normalizedRemovedIds = removedDocIds.filter((id) => !nextIds.has(id));\n      const removedIds = [...new Set([...diffRemovedIds, ...normalizedRemovedIds])];\n      previousIdsRef.current = nextIds;\n      setState({\n        status: 'ready',\n        collection: subscribedCollection,\n        data,\n        ids,\n        removedIds,\n        error: null,\n      });\n    });\n\n    return () => {\n      unsubscribe();\n      previousIdsRef.current = new Set();\n    };\n  }, [collection, enabled]);\n\n  return state;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/fieldTypeIcons.ts",
    "content": "import {\n  Type,\n  Hash,\n  Calendar,\n  CheckSquare,\n  Link2,\n  User,\n  List,\n  FileText,\n  Calculator,\n  Search,\n  Mail,\n  Phone,\n  Star,\n  Percent,\n  DollarSign,\n  Paperclip,\n  ListOrdered,\n  MousePointerClick,\n  Clock,\n  History,\n  UserPlus,\n  UserPen,\n  type LucideIcon,\n} from 'lucide-react';\n\n// Field type to icon mapping\nexport const fieldTypeIcons: Record<string, LucideIcon> = {\n  singleLineText: Type,\n  longText: FileText,\n  number: Hash,\n  date: Calendar,\n  createdTime: Clock,\n  lastModifiedTime: History,\n  checkbox: CheckSquare,\n  link: Link2,\n  user: User,\n  createdBy: UserPlus,\n  lastModifiedBy: UserPen,\n  singleSelect: List,\n  multipleSelect: List,\n  formula: Calculator,\n  rollup: Calculator,\n  conditionalRollup: Calculator,\n  lookup: Search,\n  conditionalLookup: Search,\n  email: Mail,\n  phone: Phone,\n  rating: Star,\n  percent: Percent,\n  currency: DollarSign,\n  attachment: Paperclip,\n  autoNumber: ListOrdered,\n  button: MousePointerClick,\n};\n\nexport function getFieldTypeIcon(fieldType: string): LucideIcon {\n  return fieldTypeIcons[fieldType] || Type;\n}\n"
  },
  {
    "path": "apps/playground/src/lib/nuqs/tanstackRouterAdapter.tsx",
    "content": "import { useLocation, useNavigate } from '@tanstack/react-router';\nimport { startTransition, useCallback, useMemo } from 'react';\nimport {\n  renderQueryString,\n  unstable_createAdapterProvider,\n  type unstable_AdapterInterface,\n  type unstable_UpdateUrlFunction,\n} from 'nuqs/adapters/custom';\n\nconst getSafePathname = (pathname: string) => pathname.split(/[?#]/)[0] ?? '';\n\nfunction useNuqsTanstackRouterAdapter(watchKeys: string[]): unstable_AdapterInterface {\n  const pathname = useLocation({ select: (state) => state.pathname });\n  const locationSearch = useLocation({ select: (state) => state.search });\n  const safePathname = useMemo(() => getSafePathname(pathname), [pathname]);\n  const navigate = useNavigate();\n\n  const watchKeySet = useMemo(() => new Set(watchKeys), [watchKeys]);\n  const searchSnapshotKey = useMemo(() => JSON.stringify(locationSearch ?? {}), [locationSearch]);\n\n  const searchParams = useMemo(() => {\n    void searchSnapshotKey;\n    if (typeof window === 'undefined') {\n      return new URLSearchParams();\n    }\n    const params = new URLSearchParams(window.location.search);\n    for (const key of Array.from(params.keys())) {\n      if (!watchKeySet.has(key)) {\n        params.delete(key);\n      }\n    }\n    return params;\n  }, [searchSnapshotKey, watchKeySet]);\n\n  const updateUrl: unstable_UpdateUrlFunction = useCallback(\n    (nextSearch, options) => {\n      startTransition(() => {\n        navigate({\n          from: '/',\n          to: safePathname + renderQueryString(nextSearch),\n          replace: options.history === 'replace',\n          resetScroll: options.scroll,\n          hash: (prevHash) => prevHash ?? '',\n          state: (state) => state,\n        });\n      });\n    },\n    [navigate, safePathname]\n  );\n\n  return {\n    searchParams,\n    getSearchParamsSnapshot: () =>\n      typeof window === 'undefined'\n        ? new URLSearchParams()\n        : new URLSearchParams(window.location.search),\n    updateUrl,\n    rateLimitFactor: 1,\n  };\n}\n\nexport const NuqsAdapter = unstable_createAdapterProvider(useNuqsTanstackRouterAdapter);\n"
  },
  {
    "path": "apps/playground/src/lib/orpc/OrpcClientContext.tsx",
    "content": "import type { ContractRouterClient } from '@orpc/contract';\nimport type { ReactNode } from 'react';\nimport { createContext, useContext } from 'react';\n\ntype V2ContractRouter = (typeof import('@teable/v2-contract-http'))['v2Contract'];\ntype V2OrpcClient = ContractRouterClient<V2ContractRouter>;\n\nconst OrpcClientContext = createContext<V2OrpcClient | null>(null);\n\nexport const OrpcClientProvider = ({\n  client,\n  children,\n}: {\n  client: V2OrpcClient;\n  children: ReactNode;\n}) => {\n  return <OrpcClientContext.Provider value={client}>{children}</OrpcClientContext.Provider>;\n};\n\nexport const useOrpcClient = (): V2OrpcClient => {\n  const client = useContext(OrpcClientContext);\n  if (!client) {\n    throw new Error('OrpcClientProvider is missing.');\n  }\n  return client;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/orpc/RemoteOrpcProvider.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useMemo } from 'react';\n\nimport { OrpcClientProvider } from './OrpcClientContext';\nimport { getRemoteOrpcClient } from '@/lib/orpcClient';\n\nexport const RemoteOrpcProvider = ({ children }: { children: ReactNode }) => {\n  const client = useMemo(() => getRemoteOrpcClient(), []);\n  return <OrpcClientProvider client={client}>{children}</OrpcClientProvider>;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/orpc/SandboxOrpcProvider.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useMemo } from 'react';\n\nimport { OrpcClientProvider } from './OrpcClientContext';\nimport { getSandboxOrpcClient } from '@/lib/sandboxOrpcClient';\n\nexport const SandboxOrpcProvider = ({ children }: { children: ReactNode }) => {\n  const client = useMemo(() => getSandboxOrpcClient(), []);\n  return <OrpcClientProvider client={client}>{children}</OrpcClientProvider>;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/orpcClient.ts",
    "content": "import { createORPCClient } from '@orpc/client';\nimport { RPCLink } from '@orpc/client/fetch';\nimport type { ContractRouterClient } from '@orpc/contract';\n\nimport { PLAYGROUND_DB_URL_HEADER, resolvePlaygroundDbUrl } from '@/lib/playground/databaseUrl';\ntype V2ContractRouter = (typeof import('@teable/v2-contract-http'))['v2Contract'];\ntype V2OrpcClient = ContractRouterClient<V2ContractRouter>;\n\nlet serverClient: V2OrpcClient | null = null;\n\nif (import.meta.env.SSR) {\n  const [{ createRouterClient }, { v2OrpcRouter }, { getRequestHeaders }] = await Promise.all([\n    import('@orpc/server'),\n    import('@/server/v2OrpcRouter'),\n    import('@tanstack/react-start/server'),\n  ]);\n\n  serverClient = createRouterClient(v2OrpcRouter, {\n    context: () => ({\n      headers: getRequestHeaders(),\n    }),\n  }) as V2OrpcClient;\n}\n\nexport const getRemoteOrpcClient = (): V2OrpcClient => {\n  if (import.meta.env.SSR) {\n    if (!serverClient) {\n      throw new Error('Server ORPC client is not initialized.');\n    }\n    return serverClient;\n  }\n\n  const link = new RPCLink({\n    url: `${window.location.origin}/api/rpc`,\n    adapterInterceptors: [\n      async (options) => {\n        const { request, next, ...rest } = options;\n        const dbUrl = resolvePlaygroundDbUrl();\n        if (!dbUrl) {\n          return next({ ...rest, request });\n        }\n        const headers = new Headers(request.headers);\n        headers.set(PLAYGROUND_DB_URL_HEADER, dbUrl);\n        const nextRequest = new Request(request, { headers });\n        return next({ ...rest, request: nextRequest });\n      },\n    ],\n  });\n  return createORPCClient(link) as V2OrpcClient;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/playground/constants.ts",
    "content": "export const PLAYGROUND_BASE_ID = 'bseplayground000001';\nexport const PLAYGROUND_BASE_NAME = '__teable_playground';\nexport const PLAYGROUND_SPACE_ID = 'spc_playground';\nexport const PLAYGROUND_ACTOR_ID = 'playground';\nexport const PLAYGROUND_BASE_ID_STORAGE_KEY = 'teable.playground.baseId';\nexport const PLAYGROUND_TABLE_ID_STORAGE_KEY = 'teable.playground.tableId';\n\nexport const SANDBOX_BASE_ID = 'bseSandbox000000001';\nexport const SANDBOX_BASE_NAME = '__teable_sandbox';\nexport const SANDBOX_SPACE_ID = 'spcSandbox000000001';\nexport const SANDBOX_ACTOR_ID = 'sandbox';\nexport const SANDBOX_BASE_ID_STORAGE_KEY = 'teable.sandbox.baseId';\nexport const SANDBOX_TABLE_ID_STORAGE_KEY = 'teable.sandbox.tableId';\nexport const SANDBOX_PGLITE_CONNECTION_STRING = 'idb://teable';\n"
  },
  {
    "path": "apps/playground/src/lib/playground/databaseUrl.ts",
    "content": "const PLAYGROUND_DB_URL_STORAGE_KEY = 'teable.playground.dbUrl';\nconst PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY = 'teable.playground.dbConnections';\nconst PLAYGROUND_DB_URL_HEADER = 'x-playground-db-url';\nconst PLAYGROUND_DB_URL_QUERY_PARAM = 'dbUrl';\n\nexport interface PlaygroundDbConnection {\n  id: string;\n  name: string;\n  description?: string;\n  url: string;\n  pinned?: boolean;\n  createdAt?: number;\n  lastUsedAt?: number;\n}\n\nconst parseStoredValue = (raw: string): string | null => {\n  try {\n    const parsed = JSON.parse(raw);\n    if (typeof parsed === 'string') return parsed.trim() || null;\n    if (parsed === null || parsed === undefined) return null;\n  } catch {\n    return raw.trim() || null;\n  }\n  return null;\n};\n\nexport const readPlaygroundDbUrl = (): string | null => {\n  if (typeof window === 'undefined') return null;\n  const raw = window.localStorage.getItem(PLAYGROUND_DB_URL_STORAGE_KEY);\n  if (!raw) return null;\n  return parseStoredValue(raw);\n};\n\nexport const resolvePlaygroundDbUrl = (storedDbUrl?: string | null): string | null => {\n  if (storedDbUrl && storedDbUrl.trim()) return storedDbUrl;\n  if (typeof storedDbUrl !== 'undefined') {\n    return readPlaygroundDbUrlFromEnv();\n  }\n  return readPlaygroundDbUrl() ?? readPlaygroundDbUrlFromEnv();\n};\n\nexport const readPlaygroundDbUrlFromEnv = (): string | null => {\n  if (typeof import.meta === 'undefined') return null;\n  const envUrl = import.meta.env?.VITE_PLAYGROUND_DB_URL ?? import.meta.env?.VITE_DATABASE_URL;\n  if (!envUrl) return null;\n  const trimmed = envUrl.trim();\n  return trimmed.length > 0 ? trimmed : null;\n};\n\nexport const maskPlaygroundDbUrl = (value: string): string => {\n  try {\n    const url = new URL(value);\n    if (url.password) {\n      url.password = '';\n    }\n    return url.toString().replace(/:@/, '@');\n  } catch {\n    return value.replace(/\\/\\/([^:/?#]+):([^@/]+)@/, '//$1@');\n  }\n};\n\nexport const writePlaygroundDbUrl = (value: string | null): void => {\n  if (typeof window === 'undefined') return;\n  if (!value) {\n    window.localStorage.removeItem(PLAYGROUND_DB_URL_STORAGE_KEY);\n    return;\n  }\n  window.localStorage.setItem(PLAYGROUND_DB_URL_STORAGE_KEY, JSON.stringify(value));\n};\n\nexport const isValidPlaygroundDbUrl = (value: string): boolean => {\n  try {\n    const url = new URL(value);\n    return url.protocol === 'postgres:' || url.protocol === 'postgresql:';\n  } catch {\n    return false;\n  }\n};\n\nexport const formatPlaygroundDbUrlLabel = (value: string): string => {\n  try {\n    const url = new URL(value);\n    const host = url.hostname + (url.port ? `:${url.port}` : '');\n    const dbName = url.pathname.replace(/^\\//, '');\n    if (!dbName) return host;\n    return `${host}/${dbName}`;\n  } catch {\n    return 'Custom database';\n  }\n};\n\nexport const normalizePlaygroundDbUrl = (value: string): string => value.trim();\n\nexport const findPlaygroundDbConnectionByUrl = (\n  connections: PlaygroundDbConnection[],\n  dbUrl: string | null\n): PlaygroundDbConnection | null => {\n  if (!dbUrl) return null;\n  const normalized = normalizePlaygroundDbUrl(dbUrl);\n  if (!normalized) return null;\n  return (\n    connections.find((connection) => normalizePlaygroundDbUrl(connection.url) === normalized) ??\n    null\n  );\n};\n\nexport const resolvePlaygroundDbStorageKey = (\n  baseKey: string,\n  options: { connectionId?: string | null; dbUrl?: string | null }\n): string => {\n  if (options.connectionId) {\n    return `${baseKey}:${options.connectionId}`;\n  }\n  if (options.dbUrl) {\n    const normalized = normalizePlaygroundDbUrl(options.dbUrl);\n    if (normalized) {\n      return `${baseKey}:url:${encodeURIComponent(normalized)}`;\n    }\n  }\n  return baseKey;\n};\n\nconst parseStoredConnections = (raw: string): PlaygroundDbConnection[] => {\n  try {\n    const parsed = JSON.parse(raw);\n    if (!Array.isArray(parsed)) return [];\n    const connections: PlaygroundDbConnection[] = [];\n    for (const item of parsed) {\n      if (!item || typeof item !== 'object') continue;\n      const record = item as Partial<PlaygroundDbConnection>;\n      if (!record.id || !record.name || !record.url) continue;\n      connections.push({\n        id: String(record.id),\n        name: String(record.name),\n        description: record.description ? String(record.description) : undefined,\n        url: String(record.url),\n        pinned: Boolean(record.pinned),\n        createdAt: typeof record.createdAt === 'number' ? record.createdAt : undefined,\n        lastUsedAt: typeof record.lastUsedAt === 'number' ? record.lastUsedAt : undefined,\n      });\n    }\n    return connections;\n  } catch {\n    return [];\n  }\n};\n\nexport const readPlaygroundDbConnections = (): PlaygroundDbConnection[] => {\n  if (typeof window === 'undefined') return [];\n  const raw = window.localStorage.getItem(PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY);\n  if (!raw) return [];\n  return parseStoredConnections(raw);\n};\n\nexport const writePlaygroundDbConnections = (connections: PlaygroundDbConnection[]): void => {\n  if (typeof window === 'undefined') return;\n  window.localStorage.setItem(PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY, JSON.stringify(connections));\n};\n\nexport const createPlaygroundDbConnectionId = (): string => {\n  if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {\n    return crypto.randomUUID();\n  }\n  return `db-${Date.now()}-${Math.random().toString(16).slice(2)}`;\n};\n\nexport const sortPlaygroundDbConnections = (\n  connections: PlaygroundDbConnection[]\n): PlaygroundDbConnection[] =>\n  [...connections].sort((left, right) => {\n    const pinnedDiff = Number(!!right.pinned) - Number(!!left.pinned);\n    if (pinnedDiff !== 0) return pinnedDiff;\n    const lastUsedDiff = (right.lastUsedAt ?? 0) - (left.lastUsedAt ?? 0);\n    if (lastUsedDiff !== 0) return lastUsedDiff;\n    return left.name.localeCompare(right.name);\n  });\n\nexport {\n  PLAYGROUND_DB_URL_HEADER,\n  PLAYGROUND_DB_URL_QUERY_PARAM,\n  PLAYGROUND_DB_URL_STORAGE_KEY,\n  PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n};\n"
  },
  {
    "path": "apps/playground/src/lib/playground/environment.ts",
    "content": "import { useRouterState } from '@tanstack/react-router';\n\nimport {\n  PLAYGROUND_ACTOR_ID,\n  PLAYGROUND_BASE_ID,\n  PLAYGROUND_BASE_ID_STORAGE_KEY,\n  PLAYGROUND_BASE_NAME,\n  PLAYGROUND_SPACE_ID,\n  PLAYGROUND_TABLE_ID_STORAGE_KEY,\n  SANDBOX_ACTOR_ID,\n  SANDBOX_BASE_ID,\n  SANDBOX_BASE_ID_STORAGE_KEY,\n  SANDBOX_BASE_NAME,\n  SANDBOX_PGLITE_CONNECTION_STRING,\n  SANDBOX_SPACE_ID,\n  SANDBOX_TABLE_ID_STORAGE_KEY,\n} from './constants';\n\nconst remoteEnvironment = {\n  kind: 'remote',\n  routes: {\n    index: '/',\n    base: '/$baseId',\n    table: '/$baseId/$tableId',\n    record: '/$baseId/$tableId/$recordId',\n  },\n  storageKeys: {\n    baseId: PLAYGROUND_BASE_ID_STORAGE_KEY,\n    tableId: PLAYGROUND_TABLE_ID_STORAGE_KEY,\n  },\n  defaults: {\n    baseId: PLAYGROUND_BASE_ID,\n    baseName: PLAYGROUND_BASE_NAME,\n    spaceId: PLAYGROUND_SPACE_ID,\n    actorId: PLAYGROUND_ACTOR_ID,\n  },\n} as const;\n\nconst sandboxEnvironment = {\n  kind: 'sandbox',\n  routes: {\n    index: '/sandbox',\n    base: '/sandbox/$baseId',\n    table: '/sandbox/$baseId/$tableId',\n    record: '/sandbox/$baseId/$tableId/$recordId',\n  },\n  storageKeys: {\n    baseId: SANDBOX_BASE_ID_STORAGE_KEY,\n    tableId: SANDBOX_TABLE_ID_STORAGE_KEY,\n  },\n  defaults: {\n    baseId: SANDBOX_BASE_ID,\n    baseName: SANDBOX_BASE_NAME,\n    spaceId: SANDBOX_SPACE_ID,\n    actorId: SANDBOX_ACTOR_ID,\n  },\n  pgliteConnectionString: SANDBOX_PGLITE_CONNECTION_STRING,\n} as const;\n\nexport type PlaygroundEnvironment = typeof remoteEnvironment | typeof sandboxEnvironment;\n\nexport const resolvePlaygroundEnvironment = (pathname?: string | null): PlaygroundEnvironment => {\n  if (pathname && pathname.startsWith('/sandbox')) {\n    return sandboxEnvironment;\n  }\n  return remoteEnvironment;\n};\n\nexport const usePlaygroundEnvironment = (): PlaygroundEnvironment => {\n  const pathname = useRouterState({ select: (state) => state.location.pathname });\n  return resolvePlaygroundEnvironment(pathname);\n};\n\nexport const resolveBaseName = (env: PlaygroundEnvironment, baseId: string): string => {\n  return baseId === env.defaults.baseId ? env.defaults.baseName : baseId;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/sandboxContainer.ts",
    "content": "import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pglite';\nimport { PinoLoggerAdapter, createV2PinoLogger } from '@teable/v2-adapter-logger-pino';\nimport { registerV2BroadcastChannelRealtime } from '@teable/v2-adapter-realtime-broadcastchannel';\nimport { createV2BrowserContainer } from '@teable/v2-container-browser';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport {\n  SANDBOX_ACTOR_ID,\n  SANDBOX_BASE_ID,\n  SANDBOX_BASE_NAME,\n  SANDBOX_PGLITE_CONNECTION_STRING,\n  SANDBOX_SPACE_ID,\n} from '@/lib/playground/constants';\n\nlet containerPromise: Promise<DependencyContainer> | undefined;\nlet spaceSeedPromise: Promise<void> | undefined;\nconst baseSeedPromises = new Map<string, Promise<void>>();\nconst sandboxLogLevel = import.meta.env.VITE_LOG_LEVEL ?? (import.meta.env.DEV ? 'debug' : 'info');\nconst sandboxLogger = new PinoLoggerAdapter(\n  createV2PinoLogger({\n    name: 'teable-sandbox',\n    level: sandboxLogLevel,\n    browser: { asObject: true },\n  })\n);\n\nconst ensureSandboxSpace = async (db: Kysely<V1TeableDatabase>): Promise<void> => {\n  const existingSpace = await db\n    .selectFrom('space')\n    .select('id')\n    .where('id', '=', SANDBOX_SPACE_ID)\n    .executeTakeFirst();\n\n  if (!existingSpace) {\n    await db\n      .insertInto('space')\n      .values({\n        id: SANDBOX_SPACE_ID,\n        name: 'Sandbox Space',\n        created_by: SANDBOX_ACTOR_ID,\n      })\n      .execute();\n  }\n};\n\nexport const ensureSandboxBase = async (\n  container: DependencyContainer,\n  baseId: string,\n  baseName: string\n): Promise<void> => {\n  const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n  if (!spaceSeedPromise) {\n    spaceSeedPromise = ensureSandboxSpace(db);\n  }\n  await spaceSeedPromise;\n\n  if (!baseSeedPromises.has(baseId)) {\n    const seedPromise = (async () => {\n      const existingBase = await db\n        .selectFrom('base')\n        .select('id')\n        .where('id', '=', baseId)\n        .executeTakeFirst();\n\n      if (!existingBase) {\n        await db\n          .insertInto('base')\n          .values({\n            id: baseId,\n            space_id: SANDBOX_SPACE_ID,\n            name: baseName,\n            order: 1,\n            created_by: SANDBOX_ACTOR_ID,\n          })\n          .execute();\n      }\n    })();\n\n    baseSeedPromises.set(baseId, seedPromise);\n  }\n\n  await baseSeedPromises.get(baseId);\n};\n\nexport const createSandboxContainer = async (): Promise<DependencyContainer> => {\n  if (!containerPromise) {\n    containerPromise = (async () => {\n      const container = await createV2BrowserContainer({\n        ensureSchema: true,\n        connectionString: SANDBOX_PGLITE_CONNECTION_STRING,\n        logger: sandboxLogger,\n      });\n      registerV2BroadcastChannelRealtime(container);\n      await ensureSandboxBase(container, SANDBOX_BASE_ID, SANDBOX_BASE_NAME);\n      return container;\n    })();\n  }\n\n  return containerPromise;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/sandboxOrpcClient.ts",
    "content": "import { ORPCError, createORPCClient } from '@orpc/client';\nimport type { ClientLink } from '@orpc/client';\nimport type { ContractRouterClient } from '@orpc/contract';\nimport {\n  executeCreateFieldEndpoint,\n  executeCreateRecordEndpoint,\n  executeCreateTableEndpoint,\n  executeDeleteFieldEndpoint,\n  executeDeleteRecordsEndpoint,\n  executeDeleteTableEndpoint,\n  executeGetTableByIdEndpoint,\n  executeImportCsvEndpoint,\n  executeListTableRecordsEndpoint,\n  executeListTablesEndpoint,\n  executeRenameTableEndpoint,\n  executeUpdateRecordEndpoint,\n} from '@teable/v2-contract-http-implementation/handlers';\nimport {\n  ActorId,\n  type ICommandBus,\n  type IExecutionContext,\n  type IQueryBus,\n  v2CoreTokens,\n} from '@teable/v2-core';\n\nimport { SANDBOX_ACTOR_ID } from '@/lib/playground/constants';\nimport { createSandboxContainer } from '@/lib/sandboxContainer';\n\ntype V2ContractRouter = (typeof import('@teable/v2-contract-http'))['v2Contract'];\ntype V2OrpcClient = ContractRouterClient<V2ContractRouter>;\n\ntype SandboxHandler = (input: unknown, executionContext: IExecutionContext) => Promise<unknown>;\n\nconst actorIdResult = ActorId.create(SANDBOX_ACTOR_ID);\nif (actorIdResult.isErr()) {\n  throw new Error(actorIdResult.error);\n}\nconst sandboxActorId = actorIdResult.value;\n\nconst createExecutionContext = (): IExecutionContext => ({\n  actorId: sandboxActorId,\n});\n\nconst getErrorMessage = (body: unknown, fallback: string): string => {\n  if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {\n    return body.error;\n  }\n\n  return fallback;\n};\n\nconst toSandboxError = (status: number, body: unknown): never => {\n  if (status === 400) {\n    throw new ORPCError('BAD_REQUEST', { message: getErrorMessage(body, 'Bad request') });\n  }\n\n  if (status === 404) {\n    throw new ORPCError('NOT_FOUND', { message: getErrorMessage(body, 'Not found') });\n  }\n\n  throw new ORPCError('INTERNAL_SERVER_ERROR', {\n    message: getErrorMessage(body, 'Internal server error'),\n  });\n};\n\nconst unwrapEndpointResult = <T extends { status: number; body: unknown }>(result: T) => {\n  if (result.status >= 200 && result.status < 300) {\n    return result.body;\n  }\n\n  return toSandboxError(result.status, result.body);\n};\n\nconst createSandboxHandlers = (): Record<string, SandboxHandler> => ({\n  'tables.create': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeCreateTableEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.createField': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeCreateFieldEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.createRecord': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeCreateRecordEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.updateRecord': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeUpdateRecordEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.deleteRecords': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeDeleteRecordsEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.deleteField': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeDeleteFieldEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.getById': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const queryBus = container.resolve<IQueryBus>(v2CoreTokens.queryBus);\n    const result = await executeGetTableByIdEndpoint(executionContext, input, queryBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.delete': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeDeleteTableEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.list': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const queryBus = container.resolve<IQueryBus>(v2CoreTokens.queryBus);\n    const result = await executeListTablesEndpoint(executionContext, input, queryBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.listRecords': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const queryBus = container.resolve<IQueryBus>(v2CoreTokens.queryBus);\n    const result = await executeListTableRecordsEndpoint(executionContext, input, queryBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.rename': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeRenameTableEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n  'tables.importCsv': async (input, executionContext) => {\n    const container = await createSandboxContainer();\n    const commandBus = container.resolve<ICommandBus>(v2CoreTokens.commandBus);\n    const result = await executeImportCsvEndpoint(executionContext, input, commandBus);\n    return unwrapEndpointResult(result);\n  },\n});\n\nconst sandboxHandlers = createSandboxHandlers();\n\nlet sandboxClient: V2OrpcClient | undefined;\n\nexport const getSandboxOrpcClient = (): V2OrpcClient => {\n  if (!sandboxClient) {\n    const link: ClientLink<Record<string, never>> = {\n      call: async (path, input, _options) => {\n        const key = path.join('.');\n        const handler = sandboxHandlers[key];\n\n        if (!handler) {\n          throw new ORPCError('NOT_FOUND', { message: `Unknown sandbox procedure: ${key}` });\n        }\n\n        return handler(input, createExecutionContext());\n      },\n    };\n\n    sandboxClient = createORPCClient(link) as V2OrpcClient;\n  }\n\n  return sandboxClient;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/shareDb.ts",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react';\nimport { Connection } from 'sharedb/lib/client';\nimport type { Doc, Query } from 'sharedb/lib/client';\nimport type { Socket } from 'sharedb/lib/sharedb';\n\nexport type ShareDbDocStatus = 'idle' | 'connecting' | 'ready' | 'error';\n\nexport type ShareDbDocState<T> = {\n  status: ShareDbDocStatus;\n  data: T | null;\n  error: string | null;\n};\n\nexport type ShareDbQueryState<T> = {\n  status: ShareDbDocStatus;\n  collection: string | null;\n  data: ReadonlyArray<T>;\n  ids: ReadonlyArray<string>;\n  removedIds: ReadonlyArray<string>;\n  error: string | null;\n};\n\ntype SharedConnection = {\n  url: string;\n  socket: WebSocket;\n  connection: Connection;\n  refs: number;\n};\n\nconst connections = new Map<string, SharedConnection>();\n\nconst resolveShareDbUrl = (url?: string): string | null => {\n  if (url) return url;\n  const envUrl = typeof import.meta !== 'undefined' ? import.meta.env?.VITE_SHAREDB_URL : undefined;\n  if (envUrl) return envUrl;\n  if (typeof window === 'undefined') return null;\n  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n  const host = window.location.hostname;\n  const basePort = Number(window.location.port || '0');\n  const port = Number.isNaN(basePort) || basePort === 0 ? 3101 : basePort + 1;\n  return `${protocol}//${host}:${port}/socket`;\n};\n\nconst toErrorMessage = (error: unknown): string => {\n  if (error instanceof Error) return error.message;\n  if (typeof error === 'string') return error;\n  return 'ShareDB error';\n};\n\nconst acquireConnection = (url: string): SharedConnection => {\n  const existing = connections.get(url);\n  if (existing) {\n    existing.refs += 1;\n    console.log('[ShareDB] reusing existing connection', { url, refs: existing.refs });\n    return existing;\n  }\n  console.log('[ShareDB] creating new connection', { url });\n  const socket = new WebSocket(url);\n  socket.onopen = () => console.log('[ShareDB] WebSocket opened', { url });\n  socket.onclose = (e) =>\n    console.log('[ShareDB] WebSocket closed', { url, code: e.code, reason: e.reason });\n  socket.onerror = (e) => console.error('[ShareDB] WebSocket error', { url, error: e });\n  socket.onmessage = (e) => console.log('[ShareDB] WebSocket message', { url, data: e.data });\n  const connection = new Connection(socket as Socket);\n  const shared = { url, socket, connection, refs: 1 };\n  connections.set(url, shared);\n  return shared;\n};\n\nconst releaseConnection = (url: string): void => {\n  const shared = connections.get(url);\n  if (!shared) return;\n  shared.refs -= 1;\n  if (shared.refs > 0) return;\n  shared.connection.close();\n  shared.socket.close();\n  connections.delete(url);\n};\n\nexport const useShareDbDoc = <T>(params: {\n  collection?: string;\n  docId?: string;\n  url?: string;\n  enabled?: boolean;\n}): ShareDbDocState<T> => {\n  const { collection, docId, url, enabled = true } = params;\n  const [state, setState] = useState<ShareDbDocState<T>>({\n    status: 'idle',\n    data: null,\n    error: null,\n  });\n\n  useEffect(() => {\n    if (!enabled || !collection || !docId) {\n      setState({ status: 'idle', data: null, error: null });\n      return;\n    }\n\n    const wsUrl = resolveShareDbUrl(url);\n    if (!wsUrl) return;\n\n    let isActive = true;\n    const shared = acquireConnection(wsUrl);\n    const connection = shared.connection;\n    const doc = connection.get(collection, docId) as Doc<T>;\n\n    setState((prev) => ({ ...prev, status: 'connecting', error: null }));\n\n    let updatePending = false;\n    let lastUpdateTime = 0;\n    const UPDATE_THROTTLE_MS = 16; // ~60fps\n\n    const updateSnapshot = (source?: string) => {\n      if (!isActive) return;\n\n      console.log(`[ShareDB] updateSnapshot called from ${source}`, {\n        collection,\n        docId,\n        docType: doc.type,\n        docData: doc.data,\n        docVersion: doc.version,\n      });\n\n      const now = Date.now();\n      const timeSinceLastUpdate = now - lastUpdateTime;\n\n      // Throttle updates to prevent excessive re-renders\n      if (timeSinceLastUpdate < UPDATE_THROTTLE_MS) {\n        if (!updatePending) {\n          updatePending = true;\n          setTimeout(() => {\n            updatePending = false;\n            if (isActive) {\n              doUpdateSnapshot();\n            }\n          }, UPDATE_THROTTLE_MS - timeSinceLastUpdate);\n        }\n        return;\n      }\n\n      doUpdateSnapshot();\n    };\n\n    const doUpdateSnapshot = () => {\n      lastUpdateTime = Date.now();\n      console.log('[ShareDB] doUpdateSnapshot', {\n        collection,\n        docId,\n        data: doc.data,\n      });\n      setState({ status: 'ready', data: doc.data ?? null, error: null });\n    };\n\n    const handleError = (error: unknown) => {\n      if (!isActive) return;\n      console.error('[ShareDB] error', { collection, docId, error });\n      setState({ status: 'error', data: null, error: toErrorMessage(error) });\n    };\n\n    doc.subscribe((error) => {\n      console.log('[ShareDB] subscribe callback', {\n        collection,\n        docId,\n        error,\n        docType: doc.type,\n        docData: doc.data,\n        docVersion: doc.version,\n      });\n      if (error) {\n        handleError(error);\n        return;\n      }\n      updateSnapshot('subscribe');\n    });\n\n    const onConnectionError = (error: unknown) => {\n      handleError(error);\n    };\n\n    connection.on('error', onConnectionError);\n    doc.on('create', () => updateSnapshot('create'));\n    doc.on('op', (op) => {\n      console.log('[ShareDB] received op', { collection, docId, op, docData: doc.data });\n      updateSnapshot('op');\n    });\n    doc.on('del', () => updateSnapshot('del'));\n\n    return () => {\n      isActive = false;\n      connection.removeListener('error', onConnectionError);\n      doc.removeListener('create', () => updateSnapshot('create'));\n      doc.removeListener('op', () => updateSnapshot('op'));\n      doc.removeListener('del', () => updateSnapshot('del'));\n      doc.unsubscribe();\n      doc.destroy();\n      releaseConnection(wsUrl);\n    };\n  }, [collection, docId, enabled, url]);\n\n  return state;\n};\n\nexport const useShareDbQuery = <T>(params: {\n  collection?: string;\n  query?: unknown;\n  url?: string;\n  enabled?: boolean;\n  filter?: (doc: Doc<T>) => boolean;\n}): ShareDbQueryState<T> => {\n  const { collection, query, url, enabled = true, filter } = params;\n  const queryKey = useMemo(() => {\n    if (query == null) return 'null';\n    if (typeof query !== 'object') return String(query);\n    try {\n      return JSON.stringify(query);\n    } catch {\n      return 'unserializable';\n    }\n  }, [query]);\n  const queryValue = useMemo(() => (query == null ? {} : query), [queryKey]);\n  const [state, setState] = useState<ShareDbQueryState<T>>({\n    status: 'idle',\n    collection: null,\n    data: [],\n    ids: [],\n    removedIds: [],\n    error: null,\n  });\n  const previousIdsRef = useRef<Set<string>>(new Set());\n  // Use ref to always access the latest filter function without adding it to effect dependencies\n  const filterRef = useRef(filter);\n  filterRef.current = filter;\n\n  useEffect(() => {\n    if (!enabled || !collection) {\n      previousIdsRef.current = new Set();\n      setState({\n        status: 'idle',\n        collection: null,\n        data: [],\n        ids: [],\n        removedIds: [],\n        error: null,\n      });\n      return;\n    }\n\n    const wsUrl = resolveShareDbUrl(url);\n    if (!wsUrl) return;\n\n    let isActive = true;\n    previousIdsRef.current = new Set();\n    const shared = acquireConnection(wsUrl);\n    const connection = shared.connection;\n    const subscribeQuery = connection.createSubscribeQuery<T>(collection, queryValue) as Query<T>;\n\n    const subscribedCollection = collection;\n    setState((prev) => ({\n      ...prev,\n      status: 'connecting',\n      collection: subscribedCollection,\n      error: null,\n    }));\n\n    const docListeners = new Map<Doc<T>, () => void>();\n\n    const attachDocListener = (doc: Doc<T>) => {\n      if (docListeners.has(doc)) return;\n      const handler = () => {\n        console.log('[ShareDB Query] doc event', {\n          collection,\n          docId: doc.id,\n          docType: doc.type,\n          docData: doc.data,\n        });\n        updateResults();\n      };\n      docListeners.set(doc, handler);\n      doc.on('create', handler);\n      doc.on('op', handler);\n      doc.on('del', handler);\n    };\n\n    const detachDocListener = (doc: Doc<T>) => {\n      const handler = docListeners.get(doc);\n      if (!handler) return;\n      doc.removeListener('create', handler);\n      doc.removeListener('op', handler);\n      doc.removeListener('del', handler);\n      docListeners.delete(doc);\n    };\n\n    let updatePending = false;\n    let lastUpdateTime = 0;\n    const UPDATE_THROTTLE_MS = 16; // ~60fps\n\n    const updateResults = () => {\n      if (!isActive) return;\n\n      const now = Date.now();\n      const timeSinceLastUpdate = now - lastUpdateTime;\n\n      // Throttle updates to prevent excessive re-renders\n      if (timeSinceLastUpdate < UPDATE_THROTTLE_MS) {\n        if (!updatePending) {\n          updatePending = true;\n          setTimeout(() => {\n            updatePending = false;\n            if (isActive) {\n              doUpdateResults();\n            }\n          }, UPDATE_THROTTLE_MS - timeSinceLastUpdate);\n        }\n        return;\n      }\n\n      doUpdateResults();\n    };\n\n    const doUpdateResults = () => {\n      lastUpdateTime = Date.now();\n      const docFilter =\n        filterRef.current ?? ((doc: Doc<T>) => Boolean(doc.type) && doc.data != null);\n      const docs = subscribeQuery.results ?? [];\n      const nextDocs = new Set(docs);\n      for (const doc of docs) attachDocListener(doc);\n      for (const doc of docListeners.keys()) {\n        if (!nextDocs.has(doc)) detachDocListener(doc);\n      }\n      const results = docs.filter(docFilter).map((doc: Doc<T>) => doc.data as T);\n      const ids = docs.map((doc) => doc.id);\n      const nextIds = new Set(ids);\n      const removedIds = [...previousIdsRef.current].filter((id) => !nextIds.has(id));\n      previousIdsRef.current = nextIds;\n      console.log('[ShareDB Query] doUpdateResults', {\n        collection,\n        docsCount: docs.length,\n        resultsCount: results.length,\n        ids,\n      });\n      setState({\n        status: 'ready',\n        collection: subscribedCollection,\n        data: results,\n        ids,\n        removedIds,\n        error: null,\n      });\n    };\n\n    const handleError = (error: unknown) => {\n      if (!isActive) return;\n      previousIdsRef.current = new Set();\n      setState({\n        status: 'error',\n        collection: subscribedCollection,\n        data: [],\n        ids: [],\n        removedIds: [],\n        error: toErrorMessage(error),\n      });\n    };\n\n    subscribeQuery.on('ready', () => {\n      console.log('[ShareDB Query] ready', {\n        collection,\n        resultsCount: subscribeQuery.results?.length,\n      });\n      updateResults();\n    });\n    subscribeQuery.on('changed', () => {\n      console.log('[ShareDB Query] changed', {\n        collection,\n        resultsCount: subscribeQuery.results?.length,\n      });\n      updateResults();\n    });\n    subscribeQuery.on('error', handleError);\n\n    return () => {\n      isActive = false;\n      subscribeQuery.removeAllListeners('ready');\n      subscribeQuery.removeAllListeners('changed');\n      subscribeQuery.removeAllListeners('error');\n      for (const doc of docListeners.keys()) {\n        detachDocListener(doc);\n      }\n      subscribeQuery.destroy();\n      releaseConnection(wsUrl);\n      previousIdsRef.current = new Set();\n    };\n  }, [collection, enabled, queryKey, queryValue, url]);\n\n  return state;\n};\n"
  },
  {
    "path": "apps/playground/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "apps/playground/src/polyfill.ts",
    "content": "import { File } from 'node:buffer';\n\n/**\n * This file aims to polyfill missing APIs in Node.js 18 that oRPC depends on.\n *\n * Since Stackblitz runs on Node.js 18, these polyfills ensure oRPC works in that environment.\n * If you're running oRPC locally, please use Node.js 20 or later for full compatibility.\n */\n\n/**\n * Note: Stackblitz provides an emulated Node.js environment with inherent limitations.\n * If you encounter issues, please test on a local setup with Node.js 20 or later before reporting them.\n */\n\n/**\n * The `oz.file()` schema depends on the `File` API.\n * If you're not using `oz.file()`, you can safely remove this polyfill.\n */\nif (typeof globalThis.File === 'undefined') {\n  globalThis.File = File as any;\n}\n"
  },
  {
    "path": "apps/playground/src/router.tsx",
    "content": "import { AnyRouter, createRouter, Link } from '@tanstack/react-router';\nimport { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';\nimport NProgress from 'nprogress';\nimport * as TanstackQuery from './integrations/tanstack-query/root-provider';\n\n// Import the generated route tree\nimport { routeTree } from './routeTree.gen';\n\nconst DefaultNotFoundComponent = () => (\n  <div className=\"flex min-h-screen items-center justify-center bg-background px-6 py-12 text-foreground\">\n    <div className=\"max-w-xl text-center\">\n      <p className=\"text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground\">\n        Not Found\n      </p>\n      <h1 className=\"mt-4 text-3xl font-semibold tracking-tight\">We couldn't find that page.</h1>\n      <p className=\"mt-3 text-sm text-muted-foreground\">\n        The link might be broken or the base no longer exists.\n      </p>\n      <div className=\"mt-6 flex justify-center\">\n        <Link\n          to=\"/\"\n          className=\"inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition hover:bg-primary/90\"\n        >\n          Back to playground\n        </Link>\n      </div>\n    </div>\n  </div>\n);\n\nlet isNProgressSetup = false;\n\nconst setupNProgress = (router: AnyRouter) => {\n  if (import.meta.env.SSR || isNProgressSetup) return;\n  isNProgressSetup = true;\n  NProgress.configure({ showSpinner: false });\n\n  let pendingNavigations = 0;\n  const start = () => {\n    if (pendingNavigations === 0) {\n      NProgress.start();\n    }\n    pendingNavigations += 1;\n  };\n  const done = () => {\n    if (pendingNavigations === 0) return;\n    pendingNavigations -= 1;\n    if (pendingNavigations === 0) {\n      NProgress.done();\n    }\n  };\n\n  router.subscribe('onBeforeNavigate', (event) => {\n    if (!event.pathChanged && !event.hrefChanged) return;\n    start();\n  });\n  router.subscribe('onResolved', done);\n  router.subscribe('onRendered', done);\n};\n\n// Create a new router instance\nexport const getRouter = () => {\n  const rqContext = TanstackQuery.getContext();\n\n  const router = createRouter({\n    routeTree,\n    context: { ...rqContext },\n    defaultPreload: 'intent',\n    defaultNotFoundComponent: DefaultNotFoundComponent,\n  });\n\n  setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient });\n  setupNProgress(router);\n\n  return router;\n};\n"
  },
  {
    "path": "apps/playground/src/routes/$baseId.$tableId.$recordId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\n\nimport { PlaygroundRecordRoute } from '@/components/playground/PlaygroundRecordRoute';\nimport { RemoteOrpcProvider } from '@/lib/orpc/RemoteOrpcProvider';\n\nexport const Route = createFileRoute('/$baseId/$tableId/$recordId')({\n  component: PlaygroundRecordRouteWrapper,\n});\n\nfunction PlaygroundRecordRouteWrapper() {\n  const { baseId, tableId, recordId } = Route.useParams();\n  return (\n    <RemoteOrpcProvider>\n      <PlaygroundRecordRoute baseId={baseId} tableId={tableId} recordId={recordId} />\n    </RemoteOrpcProvider>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/$baseId.$tableId.tsx",
    "content": "import { Outlet, createFileRoute, useRouterState } from '@tanstack/react-router';\n\nimport { PlaygroundTableRoute } from '@/components/playground/PlaygroundTableRoute';\nimport { RemoteOrpcProvider } from '@/lib/orpc/RemoteOrpcProvider';\n\nexport const Route = createFileRoute('/$baseId/$tableId')({\n  component: TableLayoutRoute,\n});\n\nfunction TableLayoutRoute() {\n  const { baseId, tableId } = Route.useParams();\n  const pathname = useRouterState({ select: (state) => state.location.pathname });\n  const recordPrefix = `/${baseId}/${tableId}/`;\n  const isRecordRoute = pathname.startsWith(recordPrefix);\n\n  return (\n    <RemoteOrpcProvider>\n      {isRecordRoute ? <Outlet /> : <PlaygroundTableRoute baseId={baseId} tableId={tableId} />}\n    </RemoteOrpcProvider>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/$baseId.tsx",
    "content": "import { createTanstackQueryUtils } from '@orpc/tanstack-query';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Link, createFileRoute, Outlet, useMatch, useNavigate } from '@tanstack/react-router';\nimport { TableByNameLikeSpec, TableName } from '@teable/v2-core';\nimport {\n  mapTableDtoToDomain,\n  type IBaseDto,\n  type IListBasesOkResponseDto,\n  type IListTablesOkResponseDto,\n  type ITableDto,\n} from '@teable/v2-contract-http';\nimport { tableTemplates, type TableTemplateDefinition } from '@teable/v2-table-templates';\nimport { Database, RefreshCcw, Table as TableIcon, TriangleAlert } from 'lucide-react';\nimport { debounce, useQueryState } from 'nuqs';\nimport { useEffect, useMemo, useState } from 'react';\nimport { toast } from 'sonner';\nimport { useDebounceValue, useLocalStorage } from 'usehooks-ts';\n\nimport { CreateTableDropdown } from '@/components/playground/CreateTableDropdown';\nimport { PlaygroundShell } from '@/components/playground/PlaygroundShell';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { RemoteOrpcProvider } from '@/lib/orpc/RemoteOrpcProvider';\nimport { useOrpcClient } from '@/lib/orpc/OrpcClientContext';\nimport {\n  PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n  PLAYGROUND_DB_URL_STORAGE_KEY,\n  findPlaygroundDbConnectionByUrl,\n  resolvePlaygroundDbUrl,\n  resolvePlaygroundDbStorageKey,\n  type PlaygroundDbConnection,\n} from '@/lib/playground/databaseUrl';\nimport { resolveBaseName, usePlaygroundEnvironment } from '@/lib/playground/environment';\n\nexport const Route = createFileRoute('/$baseId')({ component: PlaygroundBaseRoute });\n\nconst getErrorMessage = (error: unknown, fallback: string): string => {\n  if (error instanceof Error) return error.message;\n  if (typeof error === 'string') return error;\n  return fallback;\n};\n\nconst filterTablesByNameLike = (\n  tables: ReadonlyArray<ITableDto>,\n  query: string\n): ReadonlyArray<ITableDto> => {\n  const nameResult = TableName.create(query);\n  if (nameResult.isErr()) return [];\n  const spec = TableByNameLikeSpec.create(nameResult.value);\n\n  return tables.filter((table) => {\n    const tableResult = mapTableDtoToDomain(table);\n    if (tableResult.isErr()) return false;\n    return spec.isSatisfiedBy(tableResult.value);\n  });\n};\n\nfunction PlaygroundBaseRoute() {\n  const { baseId } = Route.useParams();\n  return (\n    <RemoteOrpcProvider>\n      <PlaygroundBaseLayout baseId={baseId} />\n    </RemoteOrpcProvider>\n  );\n}\n\ntype PlaygroundBaseLayoutProps = {\n  baseId: string;\n};\n\nexport function PlaygroundBaseLayout({ baseId }: PlaygroundBaseLayoutProps) {\n  const env = usePlaygroundEnvironment();\n  const tableMatch = useMatch({ from: env.routes.table, shouldThrow: false });\n  const activeTableId = tableMatch?.params.tableId ?? null;\n  const baseName = resolveBaseName(env, baseId);\n  const [dbUrl] = useLocalStorage<string | null>(PLAYGROUND_DB_URL_STORAGE_KEY, null, {\n    initializeWithValue: false,\n  });\n  const [dbConnections] = useLocalStorage<PlaygroundDbConnection[]>(\n    PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n    [],\n    { initializeWithValue: false }\n  );\n  const activeDbUrl = resolvePlaygroundDbUrl(dbUrl);\n  const activeConnection = findPlaygroundDbConnectionByUrl(dbConnections, activeDbUrl);\n  const baseStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.baseId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.baseId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const tableStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.tableId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.tableId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const [storedBaseId, setStoredBaseId] = useLocalStorage<string | null>(baseStorageKey, null, {\n    initializeWithValue: false,\n  });\n  const [storedTableId, setStoredTableId, removeStoredTableId] = useLocalStorage<string | null>(\n    tableStorageKey,\n    null,\n    { initializeWithValue: false }\n  );\n\n  const [search, setSearch] = useQueryState('q', {\n    limitUrlUpdates: debounce(300),\n  });\n  const [debouncedSearch] = useDebounceValue(search, 300);\n  const searchValue = search ?? '';\n  const trimmedSearch = searchValue.trim();\n  const hasSearch = trimmedSearch.length > 0;\n  const searchQuery = debouncedSearch?.trim() ?? '';\n  const isSearchSynced = trimmedSearch === searchQuery;\n\n  const orpc = createTanstackQueryUtils(useOrpcClient());\n  const queryClient = useQueryClient();\n  const navigate = useNavigate();\n\n  // Bases query - only fetch first 20 for dropdown\n  const basesQuery = useQuery<IListBasesOkResponseDto, unknown, ReadonlyArray<IBaseDto>>(\n    orpc.bases.list.queryOptions({\n      input: { limit: 20, offset: 0 },\n      select: (response) => response.data.bases,\n    })\n  );\n  const bases = basesQuery.data ?? [];\n\n  // Create base mutation\n  const createBaseMutation = useMutation(\n    orpc.bases.create.mutationOptions({\n      onSuccess: (response) => {\n        const created = response.data.base;\n        toast.success(`Created base \"${created.name}\"`);\n        void queryClient.invalidateQueries({\n          queryKey: orpc.bases.list.queryKey({ input: {} }),\n          exact: false,\n        });\n        void navigate({\n          to: env.routes.base,\n          params: { baseId: created.id },\n          search: {},\n        });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to create base'));\n      },\n    })\n  );\n\n  const handleCreateBase = (name: string) => {\n    createBaseMutation.mutate({ name });\n  };\n\n  const tablesQuery = useQuery<IListTablesOkResponseDto, unknown, ReadonlyArray<ITableDto>>(\n    orpc.tables.list.queryOptions({\n      input: {\n        baseId,\n        ...(searchQuery ? { q: searchQuery } : {}),\n      },\n      select: (response) => response.data.tables,\n    })\n  );\n\n  const [baseTables, setBaseTables] = useState<ReadonlyArray<ITableDto>>([]);\n\n  useEffect(() => {\n    if (!searchQuery && tablesQuery.data) {\n      setBaseTables(tablesQuery.data);\n    }\n  }, [searchQuery, tablesQuery.data]);\n\n  const optimisticTables = useMemo(() => {\n    if (!hasSearch) return baseTables;\n    return filterTablesByNameLike(baseTables, trimmedSearch);\n  }, [baseTables, hasSearch, trimmedSearch]);\n\n  useEffect(() => {\n    if (storedBaseId !== baseId) {\n      setStoredBaseId(baseId);\n      if (!activeTableId) {\n        removeStoredTableId();\n      }\n    }\n  }, [activeTableId, baseId, removeStoredTableId, setStoredBaseId, storedBaseId]);\n\n  const tables = (() => {\n    if (!hasSearch) return tablesQuery.data ?? [];\n    if (!isSearchSynced) return optimisticTables;\n    return tablesQuery.data ?? optimisticTables;\n  })();\n\n  const listErrorMessage = tablesQuery.error\n    ? getErrorMessage(tablesQuery.error, 'Failed to load tables')\n    : null;\n  const isInitialLoading = tablesQuery.isLoading && !hasSearch;\n\n  const createTableMutation = useMutation(\n    orpc.tables.createTables.mutationOptions({\n      onSuccess: (response) => {\n        const created = response.data.tables[0];\n        if (!created) return;\n        setStoredBaseId(baseId);\n        setStoredTableId(created.id);\n        queryClient.setQueryData(\n          orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId: created.id,\n            },\n          }),\n          { ok: true, data: { table: created } }\n        );\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n        void navigate({\n          to: env.routes.table,\n          params: { baseId, tableId: created.id },\n          search: (prev) => prev,\n        });\n      },\n    })\n  );\n\n  const deleteTableMutation = useMutation(\n    orpc.tables.delete.mutationOptions({\n      onSuccess: (_response, variables) => {\n        const deletedId = variables.tableId;\n\n        queryClient.removeQueries({\n          queryKey: orpc.tables.getById.queryKey({\n            input: {\n              baseId,\n              tableId: deletedId,\n            },\n          }),\n        });\n\n        const removeFromList = (list: IListTablesOkResponseDto | undefined) =>\n          list\n            ? {\n                ...list,\n                data: {\n                  ...list.data,\n                  tables: list.data.tables.filter((table) => table.id !== deletedId),\n                },\n              }\n            : list;\n\n        queryClient.setQueryData(orpc.tables.list.queryKey({ input: { baseId } }), removeFromList);\n\n        if (searchQuery) {\n          queryClient.setQueryData(\n            orpc.tables.list.queryKey({ input: { baseId, q: searchQuery } }),\n            removeFromList\n          );\n        }\n\n        if (storedBaseId === baseId && storedTableId === deletedId) {\n          removeStoredTableId();\n        }\n\n        if (activeTableId === deletedId) {\n          void navigate({ to: env.routes.base, params: { baseId } });\n        }\n\n        void queryClient.invalidateQueries({\n          queryKey: orpc.tables.list.queryKey({ input: { baseId } }),\n          exact: false,\n        });\n      },\n      onError: (error) => {\n        toast.error(getErrorMessage(error, 'Failed to delete table'));\n      },\n    })\n  );\n\n  const handleSearchChange = (value: string) => {\n    const nextValue = value.trim();\n    void setSearch(nextValue ? nextValue : null);\n  };\n\n  const handleRefresh = () => {\n    void tablesQuery.refetch();\n  };\n\n  const handleCreateTemplate = (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => {\n    createTableMutation.reset();\n    createTableMutation.mutate(template.createInput(baseId, options));\n  };\n\n  const handleDeleteTable = (table: ITableDto) => {\n    deleteTableMutation.reset();\n    deleteTableMutation.mutate({ baseId, tableId: table.id });\n  };\n\n  const pageErrorMessage = (() => {\n    if (listErrorMessage) return listErrorMessage;\n    if (createTableMutation.error) {\n      return getErrorMessage(createTableMutation.error, 'Failed to create table');\n    }\n    return null;\n  })();\n\n  return (\n    <PlaygroundShell\n      baseId={baseId}\n      bases={bases}\n      isLoadingBases={basesQuery.isLoading}\n      onCreateBase={handleCreateBase}\n      isCreatingBase={createBaseMutation.isPending}\n      activeTableId={activeTableId}\n      tables={tables}\n      isInitialLoading={isInitialLoading}\n      errorMessage={listErrorMessage}\n      searchValue={searchValue}\n      onSearchChange={handleSearchChange}\n      onDeleteTable={handleDeleteTable}\n      isDeletingTable={deleteTableMutation.isPending}\n    >\n      {activeTableId ? (\n        <Outlet />\n      ) : (\n        <PlaygroundBasePage\n          baseId={baseId}\n          baseName={baseName}\n          tables={tables}\n          isInitialLoading={isInitialLoading}\n          isLoading={tablesQuery.isFetching}\n          isCreating={createTableMutation.isPending}\n          errorMessage={pageErrorMessage}\n          searchValue={searchValue}\n          onRefresh={handleRefresh}\n          templates={tableTemplates}\n          onCreateTemplate={handleCreateTemplate}\n        />\n      )}\n    </PlaygroundShell>\n  );\n}\n\ntype PlaygroundBasePageProps = {\n  baseId: string;\n  baseName: string;\n  tables: ReadonlyArray<ITableDto>;\n  isInitialLoading: boolean;\n  isLoading: boolean;\n  isCreating: boolean;\n  errorMessage: string | null;\n  searchValue: string;\n  onRefresh: () => void;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n};\n\nfunction PlaygroundBasePage({\n  baseId,\n  baseName,\n  tables,\n  isInitialLoading,\n  isLoading,\n  isCreating,\n  errorMessage,\n  searchValue,\n  onRefresh,\n  templates,\n  onCreateTemplate,\n}: PlaygroundBasePageProps) {\n  const trimmedSearch = searchValue.trim();\n  const hasSearch = trimmedSearch.length > 0;\n\n  return (\n    <>\n      <PlaygroundBaseHeader\n        baseName={baseName}\n        tableCount={tables.length}\n        isLoading={isLoading}\n        isCreating={isCreating}\n        onRefresh={onRefresh}\n        templates={templates}\n        onCreateTemplate={onCreateTemplate}\n      />\n      <section className=\"relative flex-1 px-6 py-8\">\n        <div className=\"pointer-events-none absolute inset-0 bg-grid-pattern opacity-[0.18]\" />\n        <div className=\"relative mx-auto flex w-full max-w-6xl flex-col gap-6\">\n          {errorMessage ? <PlaygroundErrorState message={errorMessage} /> : null}\n\n          {isInitialLoading ? (\n            <PlaygroundBaseLoadingState />\n          ) : tables.length ? (\n            <PlaygroundTablesCard baseId={baseId} tables={tables} searchValue={searchValue} />\n          ) : (\n            <PlaygroundBaseEmptyState\n              hasSearch={hasSearch}\n              searchValue={trimmedSearch}\n              isCreating={isCreating}\n              templates={templates}\n              onCreateTemplate={onCreateTemplate}\n            />\n          )}\n        </div>\n      </section>\n    </>\n  );\n}\n\ntype PlaygroundBaseHeaderProps = {\n  baseName: string;\n  tableCount: number;\n  isLoading: boolean;\n  isCreating: boolean;\n  onRefresh: () => void;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n};\n\nfunction PlaygroundBaseHeader({\n  baseName,\n  tableCount,\n  isLoading,\n  isCreating,\n  onRefresh,\n  templates,\n  onCreateTemplate,\n}: PlaygroundBaseHeaderProps) {\n  return (\n    <header className=\"relative overflow-hidden border-b border-border/70 bg-background/80 px-6 py-6\">\n      <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-muted/35 to-transparent\" />\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.3]\" />\n      <div className=\"relative flex flex-wrap items-center justify-between gap-4\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <SidebarTrigger className=\"shrink-0\" />\n          <div className=\"h-9 w-px bg-gradient-to-b from-transparent via-border to-transparent\" />\n          <div>\n            <div className=\"text-[11px] font-semibold uppercase tracking-[0.3em] text-muted-foreground\">\n              Base\n            </div>\n            <div className=\"text-2xl font-semibold tracking-tight text-foreground\">{baseName}</div>\n            <div className=\"mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n              <Database className=\"h-4 w-4 text-muted-foreground\" />\n              <span>Postgres playground</span>\n              <Badge variant=\"outline\" className=\"text-[10px] uppercase tracking-wider\">\n                {tableCount} tables\n              </Badge>\n            </div>\n          </div>\n        </div>\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-9 border-border/70 bg-background/80\"\n            disabled={isLoading}\n            onClick={onRefresh}\n          >\n            <RefreshCcw className=\"mr-2 h-4 w-4\" />\n            Refresh\n          </Button>\n          <CreateTableDropdown\n            templates={templates}\n            isCreating={isCreating}\n            onSelect={onCreateTemplate}\n            label=\"Create table\"\n            className=\"h-9\"\n          />\n        </div>\n      </div>\n    </header>\n  );\n}\n\ntype PlaygroundErrorStateProps = {\n  message: string;\n};\n\nfunction PlaygroundErrorState({ message }: PlaygroundErrorStateProps) {\n  return (\n    <Card className=\"border-destructive/40 bg-destructive/10\">\n      <CardHeader className=\"flex flex-row items-center gap-3\">\n        <TriangleAlert className=\"h-4 w-4 text-destructive\" />\n        <CardTitle className=\"text-base text-destructive\">{message}</CardTitle>\n      </CardHeader>\n    </Card>\n  );\n}\n\nfunction PlaygroundBaseLoadingState() {\n  const skeletonKeys = ['table-row-0', 'table-row-1', 'table-row-2', 'table-row-3'];\n\n  return (\n    <Card className=\"border-border/60 bg-background/80 shadow-sm\">\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-3 text-base\">\n          <Skeleton className=\"h-5 w-5 rounded-full\" />\n          <Skeleton className=\"h-4 w-40\" />\n        </CardTitle>\n      </CardHeader>\n      <CardContent className=\"space-y-3\">\n        {skeletonKeys.map((key) => (\n          <div key={key} className=\"flex items-center gap-3\">\n            <Skeleton className=\"h-4 w-4 rounded-full\" />\n            <Skeleton className=\"h-4 w-40\" />\n            <Skeleton className=\"ml-auto h-4 w-20\" />\n          </div>\n        ))}\n      </CardContent>\n    </Card>\n  );\n}\n\ntype PlaygroundTablesCardProps = {\n  baseId: string;\n  tables: ReadonlyArray<ITableDto>;\n  searchValue: string;\n};\n\nfunction PlaygroundTablesCard({ baseId, tables, searchValue }: PlaygroundTablesCardProps) {\n  const env = usePlaygroundEnvironment();\n  const search = searchValue ? { q: searchValue } : {};\n\n  return (\n    <Card className=\"overflow-hidden border-border/60 bg-background/80 shadow-sm\">\n      <CardHeader className=\"border-b border-border/60 bg-muted/30\">\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <CardTitle className=\"flex items-center gap-2 text-base\">\n            <TableIcon className=\"h-4 w-4 text-muted-foreground\" />\n            Tables\n            <Badge variant=\"secondary\" className=\"text-[10px] uppercase tracking-wider\">\n              {tables.length} total\n            </Badge>\n          </CardTitle>\n          <span className=\"text-xs text-muted-foreground\">Select a table to open</span>\n        </div>\n      </CardHeader>\n      <CardContent className=\"p-0\">\n        <div className=\"divide-y divide-border/60\">\n          {tables.map((table) => (\n            <div\n              key={table.id}\n              className=\"group flex flex-col gap-3 px-5 py-4 transition hover:bg-muted/30 sm:flex-row sm:items-center sm:justify-between\"\n            >\n              <div className=\"flex items-start gap-3\">\n                <div className=\"flex h-9 w-9 items-center justify-center rounded-lg bg-muted/40 text-muted-foreground ring-1 ring-border/50\">\n                  <TableIcon className=\"h-4 w-4\" />\n                </div>\n                <div>\n                  <Link\n                    to={env.routes.table}\n                    params={{ baseId, tableId: table.id }}\n                    search={search}\n                    className=\"text-sm font-semibold text-foreground transition-colors group-hover:text-primary\"\n                  >\n                    {table.name}\n                  </Link>\n                  <div className=\"mt-1 text-xs font-mono text-muted-foreground\">{table.id}</div>\n                </div>\n              </div>\n              <div className=\"flex flex-wrap items-center gap-3\">\n                <Badge variant=\"secondary\" className=\"text-[10px] uppercase tracking-wider\">\n                  {table.fields.length} fields\n                </Badge>\n                <Button variant=\"outline\" size=\"sm\" asChild>\n                  <Link\n                    to={env.routes.table}\n                    params={{ baseId, tableId: table.id }}\n                    search={search}\n                  >\n                    Open\n                  </Link>\n                </Button>\n              </div>\n            </div>\n          ))}\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\ntype PlaygroundBaseEmptyStateProps = {\n  hasSearch: boolean;\n  searchValue: string;\n  isCreating: boolean;\n  templates: ReadonlyArray<TableTemplateDefinition>;\n  onCreateTemplate: (\n    template: TableTemplateDefinition,\n    options: { includeRecords: boolean }\n  ) => void;\n};\n\nfunction PlaygroundBaseEmptyState({\n  hasSearch,\n  searchValue,\n  isCreating,\n  templates,\n  onCreateTemplate,\n}: PlaygroundBaseEmptyStateProps) {\n  const title = hasSearch ? 'No matching tables' : 'Create your first table';\n  const description = hasSearch\n    ? `No tables match \"${searchValue}\".`\n    : 'Pick a template to explore the v2 playground with different field mixes.';\n\n  return (\n    <Card className=\"relative overflow-hidden border border-dashed border-border/70 bg-background/80\">\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.25]\" />\n      <CardHeader className=\"relative\">\n        <CardTitle className=\"text-lg\">{title}</CardTitle>\n      </CardHeader>\n      <CardContent className=\"relative space-y-4 text-sm text-muted-foreground\">\n        <p>{description}</p>\n        <CreateTableDropdown\n          templates={templates}\n          isCreating={isCreating}\n          onSelect={onCreateTemplate}\n          label=\"Create table\"\n        />\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/__root.tsx",
    "content": "import { HeadContent, Outlet, Scripts, createRootRouteWithContext } from '@tanstack/react-router';\nimport { NuqsAdapter } from '@/lib/nuqs/tanstackRouterAdapter';\nimport { Toaster } from 'sonner';\n\nimport appCss from '../styles.css?url';\nimport { LogPanel } from '@/components/playground/LogPanel';\n\nimport type { QueryClient } from '@tanstack/react-query';\n\ninterface MyRouterContext {\n  queryClient: QueryClient;\n}\n\nif (!import.meta.env.SSR) {\n  void import('../integrations/otel/client').then((mod) => mod.initClientOtel());\n}\n\nexport const Route = createRootRouteWithContext<MyRouterContext>()({\n  head: () => ({\n    meta: [\n      {\n        charSet: 'utf-8',\n      },\n      {\n        name: 'viewport',\n        content: 'width=device-width, initial-scale=1',\n      },\n      {\n        title: 'Teable Playground',\n      },\n    ],\n    links: [\n      {\n        rel: 'stylesheet',\n        href: appCss,\n      },\n      {\n        rel: 'icon',\n        type: 'image/svg+xml',\n        href: '/favicon.svg',\n      },\n      {\n        rel: 'icon',\n        type: 'image/x-icon',\n        href: '/favicon.ico',\n      },\n    ],\n  }),\n\n  shellComponent: RootDocument,\n  component: () => (\n    <NuqsAdapter>\n      <Outlet />\n      {/* Log panel for monitoring computed field updates and other backend logs */}\n      {!import.meta.env.SSR && <LogPanel />}\n    </NuqsAdapter>\n  ),\n});\n\nfunction RootDocument({ children }: { children: React.ReactNode }) {\n  return (\n    <html lang=\"en\">\n      <head>\n        <HeadContent />\n      </head>\n      <body>\n        {children}\n        <Toaster\n          position=\"top-center\"\n          richColors\n          closeButton\n          toastOptions={{\n            classNames: {\n              toast: 'bg-popover text-foreground border border-border shadow-sm',\n              title: 'text-sm font-medium',\n              description: 'text-xs text-muted-foreground',\n            },\n          }}\n        />\n        <Scripts />\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/api.computed-tasks.$taskId.retry-now.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport { v2RecordRepositoryPostgresTokens } from '@teable/v2-adapter-table-repository-postgres';\nimport type { ComputedUpdateWorker } from '@teable/v2-adapter-table-repository-postgres';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handlePost({ params }: { params: { taskId: string } }) {\n  try {\n    const { taskId } = params;\n    const container = await createPlaygroundContainer();\n    const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n    // Update the task to run immediately\n    const result = await db\n      .updateTable('computed_update_outbox')\n      .set({\n        next_run_at: new Date(),\n        locked_at: null,\n        locked_by: null,\n      })\n      .where('id', '=', taskId)\n      .where('status', 'in', ['pending', 'processing'])\n      .executeTakeFirst();\n\n    if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {\n      return jsonResponse({ error: 'Task not found or not in retryable state' }, 404);\n    }\n\n    // Dispatch worker to process the task (non-blocking)\n    const worker = container.resolve<ComputedUpdateWorker>(\n      v2RecordRepositoryPostgresTokens.computedUpdateWorker\n    );\n    setImmediate(() => {\n      void worker.runOnce({\n        workerId: 'playground-retry',\n        limit: 10,\n      });\n    });\n\n    return jsonResponse({ success: true });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to retry task';\n    return jsonResponse({ error: message }, 500);\n  }\n}\n\nexport const Route = createFileRoute('/api/computed-tasks/$taskId/retry-now')({\n  server: {\n    handlers: {\n      POST: handlePost,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.computed-tasks.dead-letters.$taskId.replay.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\nimport { generateUuid } from '@teable/v2-core';\n\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handlePost({ params }: { params: { taskId: string } }) {\n  try {\n    const { taskId } = params;\n    const container = await createPlaygroundContainer();\n    const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n    // Get the dead letter\n    const deadLetter = await db\n      .selectFrom('computed_update_dead_letter')\n      .selectAll()\n      .where('id', '=', taskId)\n      .executeTakeFirst();\n\n    if (!deadLetter) {\n      return jsonResponse({ error: 'Dead letter not found' }, 404);\n    }\n\n    const newTaskId = generateUuid();\n    const now = new Date();\n\n    // Create a new outbox task from the dead letter\n    await db\n      .insertInto('computed_update_outbox')\n      .values({\n        id: newTaskId,\n        base_id: deadLetter.base_id,\n        seed_table_id: deadLetter.seed_table_id,\n        seed_record_ids: deadLetter.seed_record_ids,\n        change_type: deadLetter.change_type,\n        steps: deadLetter.steps,\n        edges: deadLetter.edges,\n        status: 'pending',\n        attempts: 0,\n        max_attempts: deadLetter.max_attempts,\n        next_run_at: now,\n        estimated_complexity: deadLetter.estimated_complexity,\n        plan_hash: deadLetter.plan_hash,\n        dirty_stats: deadLetter.dirty_stats,\n        run_id: generateUuid(),\n        origin_run_ids: [deadLetter.run_id, ...(deadLetter.origin_run_ids ?? [])],\n        run_total_steps: deadLetter.run_total_steps,\n        run_completed_steps_before: deadLetter.run_completed_steps_before,\n        affected_table_ids: deadLetter.affected_table_ids,\n        affected_field_ids: deadLetter.affected_field_ids,\n        sync_max_level: deadLetter.sync_max_level,\n        created_at: now,\n        updated_at: now,\n      })\n      .execute();\n\n    // Delete the dead letter\n    await db.deleteFrom('computed_update_dead_letter').where('id', '=', taskId).execute();\n\n    return jsonResponse({ success: true, newTaskId });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to replay dead letter';\n    return jsonResponse({ error: message }, 500);\n  }\n}\n\nexport const Route = createFileRoute('/api/computed-tasks/dead-letters/$taskId/replay')({\n  server: {\n    handlers: {\n      POST: handlePost,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.computed-tasks.dead-letters.$taskId.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handleDelete({ params }: { params: { taskId: string } }) {\n  try {\n    const { taskId } = params;\n    const container = await createPlaygroundContainer();\n    const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n    const result = await db\n      .deleteFrom('computed_update_dead_letter')\n      .where('id', '=', taskId)\n      .executeTakeFirst();\n\n    if (!result.numDeletedRows || result.numDeletedRows === 0n) {\n      return jsonResponse({ error: 'Dead letter not found' }, 404);\n    }\n\n    return jsonResponse({ success: true });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to delete dead letter';\n    return jsonResponse({ error: message }, 500);\n  }\n}\n\nexport const Route = createFileRoute('/api/computed-tasks/dead-letters/$taskId')({\n  server: {\n    handlers: {\n      DELETE: handleDelete,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.computed-tasks.dead-letters.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handleGet() {\n  try {\n    const container = await createPlaygroundContainer();\n    const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n    const deadLetters = await db\n      .selectFrom('computed_update_dead_letter')\n      .select([\n        'id',\n        'base_id as baseId',\n        'seed_table_id as seedTableId',\n        'status',\n        'change_type as changeType',\n        'attempts',\n        'max_attempts as maxAttempts',\n        'last_error as lastError',\n        'plan_hash as planHash',\n        'run_id as runId',\n        'failed_at as failedAt',\n        'created_at as createdAt',\n        'trace_data as traceData',\n      ])\n      .orderBy('failed_at', 'desc')\n      .limit(100)\n      .execute();\n\n    // Get seed counts for each dead letter\n    const deadLetterIds = deadLetters.map((dl) => dl.id);\n    const seedCounts: Map<string, number> = new Map();\n\n    if (deadLetterIds.length > 0) {\n      // Dead letters don't have a seed table, use seed_record_ids JSON field\n      for (const dl of deadLetters) {\n        const raw = (dl as unknown as { seedRecordIds?: unknown }).seedRecordIds;\n        if (Array.isArray(raw)) {\n          seedCounts.set(dl.id, raw.length);\n        } else {\n          seedCounts.set(dl.id, 0);\n        }\n      }\n    }\n\n    const items = deadLetters.map((dl) => ({\n      ...dl,\n      seedCount: seedCounts.get(dl.id) ?? 0,\n    }));\n\n    return jsonResponse({\n      items,\n      total: items.length,\n    });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to fetch dead letters';\n    return jsonResponse({ error: message }, 500);\n  }\n}\n\nexport const Route = createFileRoute('/api/computed-tasks/dead-letters')({\n  server: {\n    handlers: {\n      GET: handleGet,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.computed-tasks.outbox.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handleGet() {\n  try {\n    const container = await createPlaygroundContainer();\n    const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n    const tasks = await db\n      .selectFrom('computed_update_outbox as o')\n      .leftJoin('computed_update_outbox_seed as s', 's.task_id', 'o.id')\n      .select([\n        'o.id',\n        'o.base_id as baseId',\n        'o.seed_table_id as seedTableId',\n        'o.status',\n        'o.change_type as changeType',\n        'o.attempts',\n        'o.max_attempts as maxAttempts',\n        'o.last_error as lastError',\n        'o.plan_hash as planHash',\n        'o.run_id as runId',\n        'o.created_at as createdAt',\n        'o.updated_at as updatedAt',\n        'o.next_run_at as nextRunAt',\n        (eb) => eb.fn.count<number>('s.id').as('seedCount'),\n      ])\n      .groupBy('o.id')\n      .orderBy('o.created_at', 'desc')\n      .limit(100)\n      .execute();\n\n    return jsonResponse({\n      items: tasks,\n      total: tasks.length,\n    });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Failed to fetch outbox tasks';\n    return jsonResponse({ error: message }, 500);\n  }\n}\n\nexport const Route = createFileRoute('/api/computed-tasks/outbox')({\n  server: {\n    handlers: {\n      GET: handleGet,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.db.check.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { createV2PostgresDb } from '@teable/v2-adapter-db-postgres-pg';\nimport type { Kysely } from 'kysely';\nimport { sql } from 'kysely';\n\ntype CheckRequestBody = {\n  connectionString?: unknown;\n};\n\nconst toConnectionString = async (request: Request): Promise<string | null> => {\n  try {\n    const body = (await request.json()) as CheckRequestBody;\n    return typeof body.connectionString === 'string' ? body.connectionString.trim() : null;\n  } catch {\n    return null;\n  }\n};\n\nconst isValidConnectionString = (value: string): boolean => {\n  try {\n    const url = new URL(value);\n    return url.protocol === 'postgres:' || url.protocol === 'postgresql:';\n  } catch {\n    return false;\n  }\n};\n\nconst jsonResponse = (body: unknown, status = 200): Response =>\n  new Response(JSON.stringify(body), {\n    status,\n    headers: { 'Content-Type': 'application/json' },\n  });\n\nasync function handleCheck({ request }: { request: Request }) {\n  const connectionString = await toConnectionString(request);\n  if (!connectionString) {\n    return jsonResponse({ ok: false, error: 'Missing connection string.' }, 400);\n  }\n  if (!isValidConnectionString(connectionString)) {\n    return jsonResponse({ ok: false, error: 'Use a postgres:// or postgresql:// URL.' }, 400);\n  }\n\n  let db: Kysely<unknown> | null = null;\n  try {\n    db = await createV2PostgresDb({ pg: { connectionString } });\n    await sql`select 1`.execute(db);\n    return jsonResponse({ ok: true });\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Connection failed.';\n    return jsonResponse({ ok: false, error: message }, 400);\n  } finally {\n    if (db) {\n      await db.destroy().catch(() => undefined);\n    }\n  }\n}\n\nexport const Route = createFileRoute('/api/db/check')({\n  server: {\n    handlers: {\n      POST: handleCheck,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.logs.stream.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { playgroundBroadcastLogger } from '@/server/playgroundLogger';\nimport type { ILogEntry } from '@teable/v2-adapter-logger-pino';\n\nconst formatSSEMessage = (entry: ILogEntry): string => {\n  return `data: ${JSON.stringify(entry)}\\n\\n`;\n};\n\nasync function handleSSE({ request }: { request: Request }) {\n  const encoder = new TextEncoder();\n\n  // Create a readable stream for SSE\n  const stream = new ReadableStream({\n    start(controller) {\n      // Send a connection confirmation message\n      const connectEntry: ILogEntry = {\n        id: `log-connect-${Date.now()}`,\n        timestamp: new Date().toISOString(),\n        level: 'info',\n        message: '🔗 Log stream connected',\n        context: { source: 'log-panel' },\n      };\n      controller.enqueue(encoder.encode(formatSSEMessage(connectEntry)));\n\n      // Send initial history\n      const history = playgroundBroadcastLogger.getHistory();\n      for (const entry of history) {\n        controller.enqueue(encoder.encode(formatSSEMessage(entry)));\n      }\n\n      // Subscribe to new logs\n      const unsubscribe = playgroundBroadcastLogger.subscribe((entry) => {\n        try {\n          controller.enqueue(encoder.encode(formatSSEMessage(entry)));\n        } catch {\n          // Stream may be closed\n          unsubscribe();\n        }\n      });\n\n      // Handle client disconnect\n      request.signal.addEventListener('abort', () => {\n        unsubscribe();\n        try {\n          controller.close();\n        } catch {\n          // Already closed\n        }\n      });\n    },\n  });\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      Connection: 'keep-alive',\n      'X-Accel-Buffering': 'no', // Disable nginx buffering\n    },\n  });\n}\n\nexport const Route = createFileRoute('/api/logs/stream')({\n  server: {\n    handlers: {\n      GET: handleSSE,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.meta.$tableId.check.stream.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport {\n  MetaChecker,\n  type MetaValidationIssue,\n} from '@teable/v2-adapter-table-repository-postgres';\nimport { ActorId, TableByIdSpec, TableId, Table, v2CoreTokens } from '@teable/v2-core';\nimport type { IExecutionContext, ITableRepository } from '@teable/v2-core';\n\nimport { PLAYGROUND_ACTOR_ID } from '@/lib/playground/constants';\nimport { PLAYGROUND_DB_URL_QUERY_PARAM } from '@/lib/playground/databaseUrl';\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\nimport { v2Tracer } from '@/server/otel';\n\n// Meta check result format for SSE\ninterface MetaCheckSSEResult {\n  id: string;\n  type: 'connect' | 'issue' | 'complete' | 'error';\n  issue?: MetaValidationIssue;\n  message?: string;\n  timestamp: number;\n}\n\nconst formatSSEMessage = (result: MetaCheckSSEResult): string => {\n  return `data: ${JSON.stringify(result)}\\n\\n`;\n};\n\nconst yieldToEventLoop = async (): Promise<void> => {\n  await new Promise((resolve) => setTimeout(resolve, 0));\n};\n\nasync function handleSSE({\n  request,\n  params,\n}: {\n  request: Request;\n  params: Record<string, string>;\n}) {\n  const { tableId: tableIdStr } = params;\n  const encoder = new TextEncoder();\n\n  // Create a readable stream for SSE\n  const stream = new ReadableStream({\n    async start(controller) {\n      try {\n        // Get container\n        const url = new URL(request.url);\n        const connectionString =\n          url.searchParams.get(PLAYGROUND_DB_URL_QUERY_PARAM)?.trim() || undefined;\n        const container = await createPlaygroundContainer({ connectionString });\n\n        // Validate tableId\n        const tableIdResult = TableId.create(tableIdStr);\n        if (tableIdResult.isErr()) {\n          const errorResult: MetaCheckSSEResult = {\n            id: 'error:invalid_table_id',\n            type: 'error',\n            message: `Invalid table ID: ${tableIdResult.error}`,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const tableId = tableIdResult.value;\n\n        // Get table from repository\n        const tableRepository = container.resolve<ITableRepository>(v2CoreTokens.tableRepository);\n        const spec = TableByIdSpec.create(tableId);\n\n        // Create execution context with real tracer\n        const actorIdResult = ActorId.create(PLAYGROUND_ACTOR_ID);\n        if (actorIdResult.isErr()) {\n          const errorResult: MetaCheckSSEResult = {\n            id: 'error:actor_id',\n            type: 'error',\n            message: `Invalid actor ID: ${actorIdResult.error}`,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const context: IExecutionContext = {\n          actorId: actorIdResult.value,\n          tracer: v2Tracer,\n        };\n\n        const tableResult = await tableRepository.findOne(context, spec);\n        if (tableResult.isErr()) {\n          const errorResult: MetaCheckSSEResult = {\n            id: 'error:table_not_found',\n            type: 'error',\n            message: `Table not found: ${tableResult.error.message}`,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const table = tableResult.value;\n        const baseId = table.baseId();\n\n        // Send connection confirmation\n        const connectResult: MetaCheckSSEResult = {\n          id: 'connect',\n          type: 'connect',\n          message: `Meta check stream connected for table: ${table.name().toString()}`,\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(connectResult)));\n        await yieldToEventLoop();\n\n        // Load all tables in the base for reference validation\n        const allTablesSpecResult = Table.specs(baseId).build();\n        if (allTablesSpecResult.isErr()) {\n          const errorResult: MetaCheckSSEResult = {\n            id: 'error:spec',\n            type: 'error',\n            message: `Failed to build spec: ${allTablesSpecResult.error.message}`,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const allTablesResult = await tableRepository.find(context, allTablesSpecResult.value);\n        if (allTablesResult.isErr()) {\n          const errorResult: MetaCheckSSEResult = {\n            id: 'error:load_tables',\n            type: 'error',\n            message: `Failed to load tables: ${allTablesResult.error.message}`,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        // Create meta checker\n        const checker = new MetaChecker({\n          tableRepository,\n          executionContext: context,\n        });\n\n        // Stream check results\n        let issueIndex = 0;\n        for await (const issue of checker.checkTable(table, baseId)) {\n          if (request.signal.aborted) {\n            break;\n          }\n          const issueResult: MetaCheckSSEResult = {\n            id: `issue:${issueIndex++}`,\n            type: 'issue',\n            issue,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(issueResult)));\n          await yieldToEventLoop();\n        }\n\n        // Send completion message\n        const completeResult: MetaCheckSSEResult = {\n          id: 'complete',\n          type: 'complete',\n          message: 'Meta check completed',\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(completeResult)));\n        controller.close();\n      } catch (e) {\n        const errorResult: MetaCheckSSEResult = {\n          id: 'error:unexpected',\n          type: 'error',\n          message: e instanceof Error ? e.message : 'Unknown error',\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n        controller.close();\n      }\n    },\n  });\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      Connection: 'keep-alive',\n      'X-Accel-Buffering': 'no', // Disable nginx buffering\n    },\n  });\n}\n\nexport const Route = createFileRoute('/api/meta/$tableId/check/stream')({\n  server: {\n    handlers: {\n      GET: handleSSE,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.rpc.$.ts",
    "content": "import '@/polyfill';\n\nimport { LoggingHandlerPlugin } from '@orpc/experimental-pino';\nimport { RPCHandler } from '@orpc/server/fetch';\nimport { onError } from '@orpc/server';\nimport { SpanStatusCode, trace } from '@opentelemetry/api';\nimport { createFileRoute } from '@tanstack/react-router';\nimport { playgroundPinoLogger } from '@/server/playgroundLogger';\nimport { v2OrpcRouter } from '@/server/v2OrpcRouter';\nimport { extractRequestContext } from '@/server/traceContext';\nimport { applyTraceHeaders } from '@/server/traceResponseHeaders';\nimport { withPlaygroundDbContext } from '@/server/playgroundDbContext';\nimport { PLAYGROUND_DB_URL_HEADER } from '@/lib/playground/databaseUrl';\n\nconst generateRequestId = (): string => {\n  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n    return crypto.randomUUID();\n  }\n  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n};\n\nconst handler = new RPCHandler(v2OrpcRouter, {\n  plugins: [\n    new LoggingHandlerPlugin({\n      logger: playgroundPinoLogger,\n      generateId: generateRequestId,\n      logRequestResponse: true,\n      logRequestAbort: true,\n    }),\n  ],\n  interceptors: [\n    ({ request, next }) => {\n      const span = trace.getActiveSpan();\n      span?.setAttribute('rpc.system', 'orpc');\n      request.signal?.addEventListener('abort', () => {\n        span?.addEvent('aborted', { reason: String(request.signal?.reason ?? 'unknown') });\n      });\n      return next();\n    },\n    onError((error) => {\n      const span = trace.getActiveSpan();\n      if (span) {\n        const message = error instanceof Error ? error.message : String(error);\n        span.recordException(error instanceof Error ? error : message);\n        span.setStatus({ code: SpanStatusCode.ERROR, message });\n      }\n      const errorObject = error instanceof Error ? error : new Error(String(error));\n      playgroundPinoLogger.error(errorObject, 'oRPC handler error');\n    }),\n  ],\n});\n\nasync function handle({ request }: { request: Request }) {\n  const tracer = trace.getTracer('orpc');\n  const parentContext = extractRequestContext(request);\n  return tracer.startActiveSpan(\n    'orpc.request',\n    { attributes: { 'rpc.system': 'orpc' } },\n    parentContext,\n    async (span) => {\n      try {\n        const connectionString = request.headers.get(PLAYGROUND_DB_URL_HEADER)?.trim() || undefined;\n        const { response } = await withPlaygroundDbContext(connectionString, () =>\n          handler.handle(request, {\n            prefix: '/api/rpc',\n            context: {},\n          })\n        );\n\n        const finalResponse = response ?? new Response('Not Found', { status: 404 });\n        span.setAttribute('http.status_code', finalResponse.status);\n        if (finalResponse.status >= 500) {\n          span.setStatus({ code: SpanStatusCode.ERROR });\n        }\n        return applyTraceHeaders(finalResponse, span.spanContext());\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        span.recordException(error instanceof Error ? error : message);\n        span.setStatus({ code: SpanStatusCode.ERROR, message });\n        throw error;\n      } finally {\n        span.end();\n      }\n    }\n  );\n}\n\nexport const Route = createFileRoute('/api/rpc/$')({\n  server: {\n    handlers: {\n      HEAD: handle,\n      GET: handle,\n      POST: handle,\n      PUT: handle,\n      PATCH: handle,\n      DELETE: handle,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.schema.$tableId.check.stream.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport {\n  createSchemaChecker,\n  PostgresSchemaIntrospector,\n  type SchemaCheckResult,\n} from '@teable/v2-adapter-table-repository-postgres';\nimport { ActorId, TableByIdSpec, TableId, v2CoreTokens } from '@teable/v2-core';\nimport type { IExecutionContext, ITableRepository } from '@teable/v2-core';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\n\nimport { PLAYGROUND_ACTOR_ID } from '@/lib/playground/constants';\nimport { PLAYGROUND_DB_URL_QUERY_PARAM } from '@/lib/playground/databaseUrl';\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\nimport { v2Tracer } from '@/server/otel';\n\nconst formatSSEMessage = (result: SchemaCheckResult): string => {\n  return `data: ${JSON.stringify(result)}\\n\\n`;\n};\n\nasync function handleSSE({\n  request,\n  params,\n}: {\n  request: Request;\n  params: Record<string, string>;\n}) {\n  const { tableId: tableIdStr } = params;\n  const encoder = new TextEncoder();\n\n  // Create a readable stream for SSE\n  const stream = new ReadableStream({\n    async start(controller) {\n      try {\n        // Get container\n        const url = new URL(request.url);\n        const connectionString =\n          url.searchParams.get(PLAYGROUND_DB_URL_QUERY_PARAM)?.trim() || undefined;\n        const container = await createPlaygroundContainer({ connectionString });\n\n        // Validate tableId\n        const tableIdResult = TableId.create(tableIdStr);\n        if (tableIdResult.isErr()) {\n          const errorResult: SchemaCheckResult = {\n            id: 'error:invalid_table_id',\n            fieldId: '',\n            fieldName: '',\n            ruleId: 'table_lookup',\n            ruleDescription: 'Table lookup',\n            status: 'error',\n            message: `Invalid table ID: ${tableIdResult.error}`,\n            required: true,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const tableId = tableIdResult.value;\n\n        // Get table from repository\n        const tableRepository = container.resolve<ITableRepository>(v2CoreTokens.tableRepository);\n        const spec = TableByIdSpec.create(tableId);\n\n        // Create execution context with real tracer\n        const actorIdResult = ActorId.create(PLAYGROUND_ACTOR_ID);\n        if (actorIdResult.isErr()) {\n          const errorResult: SchemaCheckResult = {\n            id: 'error:actor_id',\n            fieldId: '',\n            fieldName: '',\n            ruleId: 'actor_id',\n            ruleDescription: 'Actor ID',\n            status: 'error',\n            message: `Invalid actor ID: ${actorIdResult.error}`,\n            required: true,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const context: IExecutionContext = {\n          actorId: actorIdResult.value,\n          tracer: v2Tracer,\n        };\n\n        const tableResult = await tableRepository.findOne(context, spec);\n        if (tableResult.isErr()) {\n          const errorResult: SchemaCheckResult = {\n            id: 'error:table_not_found',\n            fieldId: '',\n            fieldName: '',\n            ruleId: 'table_lookup',\n            ruleDescription: 'Table lookup',\n            status: 'error',\n            message: `Table not found: ${tableResult.error.message}`,\n            required: true,\n            timestamp: Date.now(),\n          };\n          controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n          controller.close();\n          return;\n        }\n\n        const table = tableResult.value;\n\n        // Send connection confirmation\n        const connectResult: SchemaCheckResult = {\n          id: 'connect',\n          fieldId: '',\n          fieldName: '',\n          ruleId: 'connection',\n          ruleDescription: 'Connection',\n          status: 'success',\n          message: `🔗 Schema check stream connected for table: ${table.name().toString()}`,\n          required: true,\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(connectResult)));\n\n        // Create schema checker with base ID as schema\n        const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n        const introspector = new PostgresSchemaIntrospector(db);\n        const baseId = table.baseId().toString();\n\n        const checker = createSchemaChecker({\n          db,\n          introspector,\n          schema: baseId, // Use base ID as schema\n        });\n\n        // Stream check results\n        for await (const result of checker.checkTable(table)) {\n          if (request.signal.aborted) {\n            break;\n          }\n          controller.enqueue(encoder.encode(formatSSEMessage(result)));\n        }\n\n        // Send completion message\n        const completeResult: SchemaCheckResult = {\n          id: 'complete',\n          fieldId: '',\n          fieldName: '',\n          ruleId: 'completion',\n          ruleDescription: 'Completion',\n          status: 'success',\n          message: '✅ Schema check completed',\n          required: true,\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(completeResult)));\n        controller.close();\n      } catch (e) {\n        const errorResult: SchemaCheckResult = {\n          id: 'error:unexpected',\n          fieldId: '',\n          fieldName: '',\n          ruleId: 'unexpected',\n          ruleDescription: 'Unexpected error',\n          status: 'error',\n          message: e instanceof Error ? e.message : 'Unknown error',\n          required: true,\n          timestamp: Date.now(),\n        };\n        controller.enqueue(encoder.encode(formatSSEMessage(errorResult)));\n        controller.close();\n      }\n    },\n  });\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      Connection: 'keep-alive',\n      'X-Accel-Buffering': 'no', // Disable nginx buffering\n    },\n  });\n}\n\nexport const Route = createFileRoute('/api/schema/$tableId/check/stream')({\n  server: {\n    handlers: {\n      GET: handleSSE,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/api.underlying.$tableId.ts",
    "content": "import { createFileRoute } from '@tanstack/react-router';\nimport {\n  registerV2DebugData,\n  v2DebugDataTokens,\n  type DebugDataService,\n  type DebugTableMeta,\n  type DebugFieldMeta,\n  type DebugRawRecordQueryResult,\n} from '@teable/v2-debug-data';\nimport { createPlaygroundContainer } from '@/server/playgroundContainer';\nimport { PLAYGROUND_DB_URL_QUERY_PARAM } from '@/lib/playground/databaseUrl';\n\ntype UnderlyingDataResponse = {\n  tableMeta: DebugTableMeta | null;\n  fields: DebugFieldMeta[] | null;\n  rawRecords: DebugRawRecordQueryResult | null;\n  error: string | null;\n};\n\nasync function handleGet({\n  request,\n  params,\n}: {\n  request: Request;\n  params: Record<string, string>;\n}): Promise<Response> {\n  const { tableId } = params;\n  const url = new URL(request.url);\n\n  // Parse query params\n  const connectionString = url.searchParams.get(PLAYGROUND_DB_URL_QUERY_PARAM)?.trim() || undefined;\n  const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);\n  const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);\n\n  try {\n    const container = await createPlaygroundContainer({ connectionString });\n\n    // Register debug-data service\n    registerV2DebugData(container);\n    const service = container.resolve(v2DebugDataTokens.debugDataService) as DebugDataService;\n\n    // Get table meta\n    const tableMetaResult = await service.getTableMeta(tableId);\n    const tableMeta = tableMetaResult.isOk() ? tableMetaResult.value : null;\n\n    // Get fields\n    const fieldsResult = await service.getFieldsByTableId(tableId);\n    const fields = fieldsResult.isOk() ? fieldsResult.value : null;\n\n    // Get raw records\n    const rawRecordsResult = await service.getRawRecords(tableId, { limit, offset });\n    const rawRecords = rawRecordsResult.isOk() ? rawRecordsResult.value : null;\n\n    const response: UnderlyingDataResponse = {\n      tableMeta,\n      fields,\n      rawRecords,\n      error: null,\n    };\n\n    return new Response(JSON.stringify(response), {\n      headers: { 'Content-Type': 'application/json' },\n    });\n  } catch (error) {\n    const response: UnderlyingDataResponse = {\n      tableMeta: null,\n      fields: null,\n      rawRecords: null,\n      error: error instanceof Error ? error.message : 'Unknown error',\n    };\n\n    return new Response(JSON.stringify(response), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' },\n    });\n  }\n}\n\nexport const Route = createFileRoute('/api/underlying/$tableId')({\n  server: {\n    handlers: {\n      GET: handleGet,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/playground/src/routes/computed-tasks.tsx",
    "content": "import { createFileRoute, Link } from '@tanstack/react-router';\nimport { ArrowLeft, Cog } from 'lucide-react';\n\nimport { Button } from '@/components/ui/button';\nimport { ComputedTasksPanel } from '@/components/playground/ComputedTasksPanel';\n\nexport const Route = createFileRoute('/computed-tasks')({\n  component: ComputedTasksPage,\n});\n\nfunction ComputedTasksPage() {\n  return (\n    <div className=\"flex flex-1 flex-col overflow-hidden h-full min-h-svh bg-background\">\n      <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.25]\" />\n      <header className=\"relative flex flex-wrap items-center justify-between gap-4 border-b border-border/60 bg-background/80 px-5 py-4 backdrop-blur\">\n        <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-muted/35 to-transparent\" />\n        <div className=\"pointer-events-none absolute inset-0 bg-dot-pattern opacity-[0.2]\" />\n        <div className=\"relative flex w-full flex-wrap items-center justify-between gap-4\">\n          <div className=\"flex flex-wrap items-center gap-3\">\n            <Button variant=\"ghost\" size=\"sm\" asChild>\n              <Link to=\"/\">\n                <ArrowLeft className=\"h-4 w-4 mr-2\" />\n                Back to Playground\n              </Link>\n            </Button>\n            <div className=\"h-6 w-px bg-gradient-to-b from-transparent via-border to-transparent\" />\n            <div className=\"flex flex-wrap items-center gap-2.5\">\n              <div className=\"flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/20 to-orange-500/5 ring-1 ring-orange-500/20\">\n                <Cog className=\"h-4 w-4 text-orange-600\" />\n              </div>\n              <span className=\"text-base font-semibold tracking-tight\">Computed Tasks</span>\n            </div>\n          </div>\n        </div>\n      </header>\n      <div className=\"relative flex-1 min-h-0 p-6 overflow-auto\">\n        <ComputedTasksPanel />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/index.tsx",
    "content": "import { Link, createFileRoute, Navigate } from '@tanstack/react-router';\nimport { useEffect, useState } from 'react';\nimport { useLocalStorage } from 'usehooks-ts';\n\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport {\n  PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n  PLAYGROUND_DB_URL_STORAGE_KEY,\n  findPlaygroundDbConnectionByUrl,\n  resolvePlaygroundDbUrl,\n  resolvePlaygroundDbStorageKey,\n  type PlaygroundDbConnection,\n} from '@/lib/playground/databaseUrl';\nimport { usePlaygroundEnvironment } from '@/lib/playground/environment';\n\nexport const Route = createFileRoute('/')({ component: PlaygroundIndex });\n\ntype RedirectTarget =\n  | { to: '/$baseId'; params: { baseId: string } }\n  | { to: '/$baseId/$tableId'; params: { baseId: string; tableId: string } }\n  | { to: '/sandbox/$baseId'; params: { baseId: string } }\n  | { to: '/sandbox/$baseId/$tableId'; params: { baseId: string; tableId: string } }\n  | null;\n\nexport function PlaygroundIndex() {\n  const env = usePlaygroundEnvironment();\n  const [target, setTarget] = useState<RedirectTarget>(null);\n  const [hasHydrated, setHasHydrated] = useState(false);\n  const [dbUrl] = useLocalStorage<string | null>(PLAYGROUND_DB_URL_STORAGE_KEY, null, {\n    initializeWithValue: false,\n  });\n  const [dbConnections] = useLocalStorage<PlaygroundDbConnection[]>(\n    PLAYGROUND_DB_CONNECTIONS_STORAGE_KEY,\n    [],\n    { initializeWithValue: false }\n  );\n  const activeDbUrl = resolvePlaygroundDbUrl(dbUrl);\n  const activeConnection = findPlaygroundDbConnectionByUrl(dbConnections, activeDbUrl);\n  const baseStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.baseId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.baseId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const tableStorageKey =\n    env.kind === 'sandbox'\n      ? env.storageKeys.tableId\n      : resolvePlaygroundDbStorageKey(env.storageKeys.tableId, {\n          connectionId: activeConnection?.id ?? null,\n          dbUrl: activeDbUrl,\n        });\n  const [storedBaseId] = useLocalStorage<string | null>(baseStorageKey, null, {\n    initializeWithValue: false,\n  });\n  const [storedTableId, , removeStoredTableId] = useLocalStorage<string | null>(\n    tableStorageKey,\n    null,\n    { initializeWithValue: false }\n  );\n\n  useEffect(() => {\n    setHasHydrated(true);\n  }, []);\n\n  useEffect(() => {\n    if (!hasHydrated) return;\n    const baseId = storedBaseId && storedBaseId.trim() ? storedBaseId : null;\n    const tableId = storedTableId && storedTableId.trim() ? storedTableId : null;\n\n    if (!baseId) {\n      if (tableId) {\n        removeStoredTableId();\n      }\n      setTarget(null);\n      return;\n    }\n\n    if (tableId) {\n      setTarget({\n        to: env.routes.table,\n        params: { baseId, tableId },\n      });\n      return;\n    }\n\n    setTarget({ to: env.routes.base, params: { baseId } });\n  }, [\n    env.routes.base,\n    env.routes.table,\n    hasHydrated,\n    removeStoredTableId,\n    storedBaseId,\n    storedTableId,\n  ]);\n\n  if (target) {\n    return <Navigate to={target.to} params={target.params} replace />;\n  }\n\n  if (!hasHydrated) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center bg-background px-6 py-10\">\n        <Card className=\"w-full max-w-md\">\n          <CardHeader>\n            <CardTitle className=\"text-lg\">Loading playground...</CardTitle>\n          </CardHeader>\n          <CardContent className=\"text-sm text-muted-foreground\">\n            Preparing your workspace.\n          </CardContent>\n        </Card>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-background px-6 py-10\">\n      <Card className=\"w-full max-w-md\">\n        <CardHeader>\n          <CardTitle className=\"text-lg\">Welcome to the playground</CardTitle>\n        </CardHeader>\n        <CardContent className=\"space-y-4 text-sm text-muted-foreground\">\n          <p>No recent base found for this connection.</p>\n          <Button asChild className=\"w-full\">\n            <Link to={env.routes.base} params={{ baseId: env.defaults.baseId }}>\n              Open default base\n            </Link>\n          </Button>\n          <Button asChild variant=\"outline\" className=\"w-full\">\n            <Link to=\"/computed-tasks\">View Computed Tasks</Link>\n          </Button>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/sandbox/$baseId.$tableId.$recordId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\n\nimport { PlaygroundRecordRoute } from '@/components/playground/PlaygroundRecordRoute';\nimport { SandboxOrpcProvider } from '@/lib/orpc/SandboxOrpcProvider';\n\nexport const Route = createFileRoute('/sandbox/$baseId/$tableId/$recordId')({\n  component: SandboxRecordRoute,\n  ssr: false,\n});\n\nfunction SandboxRecordRoute() {\n  const { baseId, tableId, recordId } = Route.useParams();\n  return (\n    <SandboxOrpcProvider>\n      <PlaygroundRecordRoute baseId={baseId} tableId={tableId} recordId={recordId} />\n    </SandboxOrpcProvider>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/sandbox/$baseId.$tableId.tsx",
    "content": "import { Outlet, createFileRoute, useRouterState } from '@tanstack/react-router';\n\nimport { PlaygroundTableRoute } from '@/components/playground/PlaygroundTableRoute';\nimport { SandboxOrpcProvider } from '@/lib/orpc/SandboxOrpcProvider';\n\nexport const Route = createFileRoute('/sandbox/$baseId/$tableId')({\n  component: SandboxTableLayout,\n  ssr: false,\n});\n\nfunction SandboxTableLayout() {\n  const { baseId, tableId } = Route.useParams();\n  const pathname = useRouterState({ select: (state) => state.location.pathname });\n  const recordPrefix = `/sandbox/${baseId}/${tableId}/`;\n  const isRecordRoute = pathname.startsWith(recordPrefix);\n\n  return (\n    <SandboxOrpcProvider>\n      {isRecordRoute ? <Outlet /> : <PlaygroundTableRoute baseId={baseId} tableId={tableId} />}\n    </SandboxOrpcProvider>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/sandbox/$baseId.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\n\nimport { SandboxOrpcProvider } from '@/lib/orpc/SandboxOrpcProvider';\nimport { PlaygroundBaseLayout } from '@/routes/$baseId';\n\nexport const Route = createFileRoute('/sandbox/$baseId')({\n  component: SandboxBaseRoute,\n  ssr: false,\n});\n\nfunction SandboxBaseRoute() {\n  const { baseId } = Route.useParams();\n  return (\n    <SandboxOrpcProvider>\n      <PlaygroundBaseLayout baseId={baseId} />\n    </SandboxOrpcProvider>\n  );\n}\n"
  },
  {
    "path": "apps/playground/src/routes/sandbox/index.tsx",
    "content": "import { createFileRoute } from '@tanstack/react-router';\n\nimport { PlaygroundIndex } from '@/routes/index';\n\nexport const Route = createFileRoute('/sandbox/')({\n  component: PlaygroundIndex,\n  ssr: false,\n});\n"
  },
  {
    "path": "apps/playground/src/server/otel.ts",
    "content": "import {\n  context as otelContext,\n  SpanStatusCode,\n  trace,\n  type Span as ApiSpan,\n} from '@opentelemetry/api';\nimport { ORPCInstrumentation } from '@orpc/otel';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport { PgInstrumentation } from '@opentelemetry/instrumentation-pg';\nimport { resourceFromAttributes } from '@opentelemetry/resources';\nimport { NodeSDK } from '@opentelemetry/sdk-node';\nimport { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';\nimport type { ISpan, ITracer, SpanAttributeValue, SpanAttributes } from '@teable/v2-core';\nimport { createRequire } from 'module';\n\nconst require = createRequire(import.meta.url);\n\nconst parseOtelHeaders = (headerStr?: string) => {\n  if (!headerStr) return {};\n  return headerStr.split(',').reduce(\n    (acc, curr) => {\n      const [key, value] = curr.split('=');\n      if (key && value) {\n        acc[key.trim()] = value.trim();\n      }\n      return acc;\n    },\n    {} as Record<string, string>\n  );\n};\n\nconst serviceName = process.env.OTEL_SERVICE_NAME ?? 'teable-playground';\nconst serviceVersion = process.env.BUILD_VERSION;\nconst traceEndpoint =\n  process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;\nconst otelHeaders = parseOtelHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS);\n\nconst traceExporter = traceEndpoint\n  ? new OTLPTraceExporter({\n      url: traceEndpoint,\n      headers: {\n        'Content-Type': 'application/x-protobuf',\n        ...otelHeaders,\n      },\n    })\n  : undefined;\n\nconst resourceAttributes = {\n  [ATTR_SERVICE_NAME]: serviceName,\n  ...(serviceVersion ? { [ATTR_SERVICE_VERSION]: serviceVersion } : {}),\n};\n\nconst globalAny = globalThis as typeof globalThis & {\n  __teablePlaygroundOtelSdk?: NodeSDK;\n  __teablePlaygroundOtelStart?: Promise<NodeSDK>;\n};\n\nexport const ensureServerOtel = async (): Promise<NodeSDK> => {\n  if (globalAny.__teablePlaygroundOtelStart) return globalAny.__teablePlaygroundOtelStart;\n  if (globalAny.__teablePlaygroundOtelSdk) {\n    return Promise.resolve(globalAny.__teablePlaygroundOtelSdk);\n  }\n\n  const sdkOptions = {\n    resource: resourceFromAttributes(resourceAttributes),\n    instrumentations: [\n      new ORPCInstrumentation(),\n      new PgInstrumentation({\n        enhancedDatabaseReporting: true,\n        requireParentSpan: false,\n      }),\n    ],\n  };\n\n  const sdk = new NodeSDK(traceExporter ? { ...sdkOptions, traceExporter } : sdkOptions);\n\n  globalAny.__teablePlaygroundOtelSdk = sdk;\n  try {\n    sdk.start();\n  } catch (err) {\n    console.error('Playground OTEL start error', err);\n  }\n  const startPromise = Promise.resolve(sdk);\n  globalAny.__teablePlaygroundOtelStart = startPromise;\n\n  const shutdown = () =>\n    sdk.shutdown().then(\n      () => console.log('Playground OTEL shut down'),\n      (err) => console.log('Playground OTEL shutdown error', err)\n    );\n\n  process.on('SIGTERM', shutdown);\n  process.on('SIGINT', shutdown);\n\n  try {\n    // Force load pg after SDK start to ensure it is instrumented.\n    // In Nitro/Vite dev environments, this helps ensure the patch is applied before the app uses it.\n    const adapterRequire = createRequire(require.resolve('@teable/v2-adapter-db-postgres-pg'));\n    adapterRequire('pg');\n  } catch {\n    try {\n      require('pg');\n    } catch {\n      // Ignore if pg is not available in the current environment\n    }\n  }\n\n  return globalAny.__teablePlaygroundOtelStart;\n};\n\nclass OpenTelemetrySpan implements ISpan {\n  constructor(public readonly span: ApiSpan) {}\n\n  setAttribute(key: string, value: SpanAttributeValue): void {\n    this.span.setAttribute(key, value);\n  }\n\n  setAttributes(attributes: SpanAttributes): void {\n    this.span.setAttributes(attributes);\n  }\n\n  recordError(message: string): void {\n    this.span.recordException(message);\n    this.span.setStatus({ code: SpanStatusCode.ERROR, message });\n  }\n\n  end(): void {\n    this.span.end();\n  }\n}\n\nexport class OpenTelemetryTracer implements ITracer {\n  constructor(private readonly name = 'v2-core') {}\n\n  startSpan(name: string, attributes?: SpanAttributes): ISpan {\n    const tracer = trace.getTracer(this.name);\n    const span = tracer.startSpan(name, { attributes }, otelContext.active());\n    return new OpenTelemetrySpan(span);\n  }\n\n  async withSpan<T>(span: ISpan, callback: () => Promise<T>): Promise<T> {\n    if (span instanceof OpenTelemetrySpan) {\n      return otelContext.with(trace.setSpan(otelContext.active(), span.span), callback);\n    }\n    return callback();\n  }\n\n  getActiveSpan(): ISpan | undefined {\n    const span = trace.getActiveSpan();\n    return span ? new OpenTelemetrySpan(span) : undefined;\n  }\n}\n\nexport const v2Tracer = new OpenTelemetryTracer('v2-core');\n\nvoid ensureServerOtel();\n"
  },
  {
    "path": "apps/playground/src/server/playgroundContainer.ts",
    "content": "import { ensureServerOtel, v2Tracer } from './otel';\nimport { createV2NodePgContainer } from '@teable/v2-container-node';\nimport { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport type { V1TeableDatabase } from '@teable/v2-postgres-schema';\nimport type { Kysely } from 'kysely';\nimport { playgroundLogger } from './playgroundLogger';\nimport { registerPlaygroundShareDbRealtime } from './shareDbServer';\nimport { getPlaygroundDbConnectionString } from './playgroundDbContext';\n\nimport {\n  PLAYGROUND_ACTOR_ID,\n  PLAYGROUND_BASE_ID,\n  PLAYGROUND_BASE_NAME,\n  PLAYGROUND_SPACE_ID,\n} from '@/lib/playground/constants';\n\nconst containerPromises = new Map<string, Promise<DependencyContainer>>();\nconst seedPromises = new Map<string, Promise<void>>();\n\nconst resolveConnectionString = (override?: string): string | undefined =>\n  override ??\n  getPlaygroundDbConnectionString() ??\n  process.env.VITE_PLAYGROUND_DB_URL ??\n  process.env.VITE_DATABASE_URL ??\n  process.env.DATABASE_URL ??\n  process.env.PRISMA_DATABASE_URL;\n\nconst ensurePlaygroundSeed = async (container: DependencyContainer, key: string): Promise<void> => {\n  if (!seedPromises.has(key)) {\n    const seedPromise = (async () => {\n      const db = container.resolve<Kysely<V1TeableDatabase>>(v2PostgresDbTokens.db);\n\n      const existingSpace = await db\n        .selectFrom('space')\n        .select('id')\n        .where('id', '=', PLAYGROUND_SPACE_ID)\n        .executeTakeFirst();\n\n      if (!existingSpace) {\n        await db\n          .insertInto('space')\n          .values({\n            id: PLAYGROUND_SPACE_ID,\n            name: 'Playground Space',\n            created_by: PLAYGROUND_ACTOR_ID,\n          })\n          .execute();\n      }\n\n      const existingBase = await db\n        .selectFrom('base')\n        .select('id')\n        .where('id', '=', PLAYGROUND_BASE_ID)\n        .executeTakeFirst();\n\n      if (!existingBase) {\n        await db\n          .insertInto('base')\n          .values({\n            id: PLAYGROUND_BASE_ID,\n            space_id: PLAYGROUND_SPACE_ID,\n            name: PLAYGROUND_BASE_NAME,\n            order: 1,\n            created_by: PLAYGROUND_ACTOR_ID,\n          })\n          .execute();\n      }\n    })();\n    seedPromises.set(key, seedPromise);\n  }\n\n  await seedPromises.get(key)!;\n};\n\nexport const createPlaygroundContainer = async (\n  options: {\n    connectionString?: string;\n  } = {}\n): Promise<DependencyContainer> => {\n  const connectionString = resolveConnectionString(options.connectionString);\n  if (!connectionString) {\n    throw new Error('Missing DATABASE_URL for playground container (.env or .env.development)');\n  }\n  const key = connectionString;\n\n  if (!containerPromises.has(key)) {\n    const promise = (async () => {\n      await ensureServerOtel();\n      const container = await createV2NodePgContainer({\n        ensureSchema: true,\n        connectionString,\n        logger: playgroundLogger,\n        tracer: v2Tracer,\n      });\n      await registerPlaygroundShareDbRealtime(container);\n      await ensurePlaygroundSeed(container, key);\n      return container;\n    })();\n    containerPromises.set(key, promise);\n  }\n\n  return containerPromises.get(key)!;\n};\n\nexport const warmPlaygroundContainer = (): void => {\n  void createPlaygroundContainer().catch((error) => {\n    console.error('Playground container warmup failed', error);\n  });\n};\n"
  },
  {
    "path": "apps/playground/src/server/playgroundDbContext.ts",
    "content": "import { AsyncLocalStorage } from 'node:async_hooks';\n\ntype PlaygroundDbContext = {\n  connectionString?: string;\n};\n\nconst storage = new AsyncLocalStorage<PlaygroundDbContext>();\n\nexport const withPlaygroundDbContext = async <T>(\n  connectionString: string | undefined,\n  callback: () => Promise<T>\n): Promise<T> => {\n  if (!connectionString) {\n    return callback();\n  }\n  return storage.run({ connectionString }, callback);\n};\n\nexport const getPlaygroundDbConnectionString = (): string | undefined => {\n  return storage.getStore()?.connectionString;\n};\n"
  },
  {
    "path": "apps/playground/src/server/playgroundLogger.ts",
    "content": "import {\n  BroadcastLogger,\n  PinoLoggerAdapter,\n  createV2PinoLogger,\n} from '@teable/v2-adapter-logger-pino';\nimport type { LoggerOptions } from 'pino';\n\nconst isDev = process.env.NODE_ENV === 'development';\n\nconst prettyOptions: LoggerOptions = isDev\n  ? {\n      transport: {\n        target: 'pino-pretty',\n        options: {\n          colorize: true,\n          translateTime: 'SYS:standard',\n          ignore: 'pid,hostname',\n        },\n      },\n    }\n  : {};\n\n// Use globalThis to ensure singleton across HMR in development\ntype GlobalWithLogger = typeof globalThis & {\n  __playgroundPinoLogger?: ReturnType<typeof createV2PinoLogger>;\n  __playgroundBroadcastLogger?: BroadcastLogger;\n};\n\nconst globalScope = globalThis as GlobalWithLogger;\n\nconst ensurePinoLogger = () => {\n  if (!globalScope.__playgroundPinoLogger) {\n    globalScope.__playgroundPinoLogger = createV2PinoLogger(prettyOptions);\n  }\n  return globalScope.__playgroundPinoLogger;\n};\n\nconst ensureBroadcastLogger = () => {\n  if (!globalScope.__playgroundBroadcastLogger) {\n    const pino = ensurePinoLogger();\n    const pinoAdapter = new PinoLoggerAdapter(pino);\n    globalScope.__playgroundBroadcastLogger = new BroadcastLogger(pinoAdapter, {\n      bufferSize: 1000,\n    });\n  }\n  return globalScope.__playgroundBroadcastLogger;\n};\n\nexport const playgroundPinoLogger = ensurePinoLogger();\n\n/**\n * Broadcast logger that wraps the pino adapter.\n * All logs are forwarded to pino while also being broadcast to subscribers.\n * Use `playgroundBroadcastLogger.subscribe()` to receive real-time log entries.\n */\nexport const playgroundBroadcastLogger = ensureBroadcastLogger();\n\n/**\n * The main logger used throughout the playground.\n * This is the broadcast logger which delegates to pino.\n */\nexport const playgroundLogger = playgroundBroadcastLogger;\n"
  },
  {
    "path": "apps/playground/src/server/shareDbServer.ts",
    "content": "import type { ILogger } from '@teable/v2-core';\nimport { v2CoreTokens } from '@teable/v2-core';\nimport type { DependencyContainer } from '@teable/v2-di';\nimport ShareDb from 'sharedb';\nimport type PubSubClass from 'sharedb/lib/pubsub';\nimport memoryPubSubModule from 'sharedb/lib/pubsub/memory';\nimport { WebSocketServer } from 'ws';\n\nimport {\n  ShareDbBackendPublisher,\n  ShareDbWebSocketServer,\n  registerV2ShareDbRealtime,\n} from '@teable/v2-adapter-realtime-sharedb';\n\nconst PubSub = ((memoryPubSubModule as { default?: unknown }).default ??\n  memoryPubSubModule) as new () => PubSubClass;\n\nconst DEFAULT_SHAREDB_PORT = 3101;\n\ntype ShareDbRuntime = {\n  backend: ShareDb;\n  pubsub: PubSubClass;\n  wsServer: WebSocketServer;\n  port: number;\n};\n\nlet runtimePromise: Promise<ShareDbRuntime> | undefined;\nlet globalRuntimePromise: Promise<ShareDbRuntime> | undefined;\n\nconst resolveGlobalRuntime = (): Promise<ShareDbRuntime> | undefined => {\n  const globalScope = globalThis as { __playgroundShareDbRuntimePromise?: Promise<ShareDbRuntime> };\n  return globalScope.__playgroundShareDbRuntimePromise;\n};\n\nconst registerGlobalRuntime = (promise: Promise<ShareDbRuntime>): void => {\n  const globalScope = globalThis as { __playgroundShareDbRuntimePromise?: Promise<ShareDbRuntime> };\n  globalScope.__playgroundShareDbRuntimePromise = promise;\n  globalRuntimePromise = promise;\n};\n\nconst resolveShareDbPort = (): number => {\n  const fromEnv = process.env.PLAYGROUND_SHAREDB_PORT ?? process.env.SHAREDB_PORT;\n  const parsed = fromEnv ? Number(fromEnv) : NaN;\n  if (!Number.isNaN(parsed) && parsed > 0) {\n    return parsed;\n  }\n  return DEFAULT_SHAREDB_PORT;\n};\n\nconst startShareDbRuntime = async (logger?: ILogger): Promise<ShareDbRuntime> => {\n  const pubsub = new PubSub();\n  const backend = new ShareDb({ pubsub });\n  const port = resolveShareDbPort();\n  const wsServer = new WebSocketServer({ port, path: '/socket' });\n  const shareDbWebSocket = new ShareDbWebSocketServer(backend, logger);\n  shareDbWebSocket.attach(wsServer);\n  return { backend, pubsub, wsServer, port };\n};\n\nexport const ensureShareDbRuntime = async (logger?: ILogger): Promise<ShareDbRuntime> => {\n  if (!runtimePromise) {\n    const existing = globalRuntimePromise ?? resolveGlobalRuntime();\n    runtimePromise = existing ?? startShareDbRuntime(logger);\n    if (!existing) {\n      registerGlobalRuntime(runtimePromise);\n    }\n  }\n  return runtimePromise;\n};\n\nexport const registerPlaygroundShareDbRealtime = async (\n  container: DependencyContainer\n): Promise<void> => {\n  const logger = container.resolve<ILogger>(v2CoreTokens.logger);\n  const runtime = await ensureShareDbRuntime(logger);\n  registerV2ShareDbRealtime(container, {\n    publisher: new ShareDbBackendPublisher(runtime.backend, logger),\n  });\n};\n"
  },
  {
    "path": "apps/playground/src/server/traceContext.ts",
    "content": "import { context as otelContext, propagation, type TextMapGetter } from '@opentelemetry/api';\n\nconst headerGetter: TextMapGetter<Headers> = {\n  get: (carrier, key) => carrier.get(key) ?? undefined,\n  keys: (carrier) => Array.from(carrier.keys()),\n};\n\nexport const extractRequestContext = (request: Request) =>\n  propagation.extract(otelContext.active(), request.headers, headerGetter);\n"
  },
  {
    "path": "apps/playground/src/server/traceResponseHeaders.ts",
    "content": "import { TraceFlags, trace, type SpanContext } from '@opentelemetry/api';\n\nconst normalizeBaseUrl = (value?: string) => value?.replace(/\\/+$/, '');\n\nconst buildTraceLink = (traceId: string) => {\n  const baseUrl = normalizeBaseUrl(process.env.PLAYGROUND_TRACE_LINK_BASE_URL);\n  if (!baseUrl) return null;\n  return `${baseUrl}/trace/${traceId}?uiEmbed=v0`;\n};\n\nconst buildTraceparent = (traceId: string, spanId: string, traceFlags: TraceFlags) => {\n  const sampled = (traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED;\n  return `00-${traceId}-${spanId}-${sampled ? '01' : '00'}`;\n};\n\nconst resolveSpanContext = (provided?: SpanContext) => {\n  if (provided?.traceId && provided?.spanId) return provided;\n  const span = trace.getActiveSpan();\n  return span?.spanContext();\n};\n\nexport const applyTraceHeaders = (response: Response, spanContext?: SpanContext): Response => {\n  const resolvedContext = resolveSpanContext(spanContext);\n  if (!resolvedContext?.traceId || !resolvedContext?.spanId) return response;\n\n  const headers = new Headers(response.headers);\n  headers.set(\n    'traceparent',\n    buildTraceparent(resolvedContext.traceId, resolvedContext.spanId, resolvedContext.traceFlags)\n  );\n\n  const traceLink = buildTraceLink(resolvedContext.traceId);\n  if (traceLink) {\n    headers.append('Link', `<${traceLink}>; rel=\"trace\"`);\n  }\n\n  return new Response(response.body, {\n    status: response.status,\n    statusText: response.statusText,\n    headers,\n  });\n};\n"
  },
  {
    "path": "apps/playground/src/server/v2OrpcRouter.ts",
    "content": "import { trace } from '@opentelemetry/api';\nimport { v2Tracer } from './otel';\nimport { createV2OrpcRouter } from '@teable/v2-contract-http-implementation';\nimport { ActorId, generateUuid, type IExecutionContext } from '@teable/v2-core';\n\nimport { PLAYGROUND_ACTOR_ID } from '@/lib/playground/constants';\nimport { createPlaygroundContainer, warmPlaygroundContainer } from './playgroundContainer';\n\nconst actorIdResult = ActorId.create(PLAYGROUND_ACTOR_ID);\nif (actorIdResult.isErr()) {\n  throw new Error(actorIdResult.error);\n}\nconst playgroundActorId = actorIdResult.value;\n\nconst formatAsUuid = (hex: string): string => {\n  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;\n};\n\nconst getRequestId = (): string => {\n  const span = trace.getActiveSpan();\n  const traceId = span?.spanContext().traceId;\n  if (traceId && traceId.length === 32) {\n    return formatAsUuid(traceId);\n  }\n  return generateUuid();\n};\n\nconst createExecutionContext = (): IExecutionContext => ({\n  actorId: playgroundActorId,\n  tracer: v2Tracer,\n  requestId: getRequestId(),\n});\n\nwarmPlaygroundContainer();\n\nexport const v2OrpcRouter = createV2OrpcRouter({\n  createContainer: createPlaygroundContainer,\n  createExecutionContext,\n});\n"
  },
  {
    "path": "apps/playground/src/server.ts",
    "content": "import { SpanStatusCode, trace } from '@opentelemetry/api';\nimport {\n  createStartHandler,\n  defaultStreamHandler,\n  defineHandlerCallback,\n} from '@tanstack/react-start/server';\nimport { createServerEntry } from '@tanstack/react-start/server-entry';\nimport { ensureServerOtel } from './server/otel';\nimport { extractRequestContext } from './server/traceContext';\nimport { applyTraceHeaders } from './server/traceResponseHeaders';\n\nvoid ensureServerOtel();\n\nconst handler = defineHandlerCallback(async (ctx) => {\n  const tracer = trace.getTracer('tanstack-start');\n  const url = new URL(ctx.request.url);\n  const method = ctx.request.method ?? 'GET';\n  const matches = ctx.router?.state?.matches ?? [];\n  const leaf = matches[matches.length - 1];\n  const routeId = leaf?.routeId ?? url.pathname;\n  const spanName = `tanstack.start ${routeId}`;\n  const parentContext = extractRequestContext(ctx.request);\n\n  return tracer.startActiveSpan(\n    spanName,\n    {\n      attributes: {\n        'http.method': method,\n        'http.route': routeId,\n        'http.url': url.pathname,\n      },\n    },\n    parentContext,\n    async (span) => {\n      try {\n        const response = await defaultStreamHandler(ctx);\n        span.setAttribute('http.status_code', response.status);\n        if (response.status >= 500) {\n          span.setStatus({ code: SpanStatusCode.ERROR });\n        }\n        return applyTraceHeaders(response, span.spanContext());\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        span.recordException(error instanceof Error ? error : message);\n        span.setStatus({ code: SpanStatusCode.ERROR, message });\n        throw error;\n      } finally {\n        span.end();\n      }\n    }\n  );\n});\n\nconst fetch = createStartHandler(handler);\n\nexport default createServerEntry({ fetch });\n"
  },
  {
    "path": "apps/playground/src/styles.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital,opsz@0,8..60;1,8..60&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');\n\n@import 'tailwindcss';\n\n@import 'tw-animate-css';\n\n@import 'react-json-view-lite/dist/index.css';\n@import 'nprogress/nprogress.css';\n\n@custom-variant dark (&:is(.dark *));\n\nbody {\n  @apply m-0;\n  font-family: var(--font-sans);\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n  font-family: var(--font-mono);\n}\n\n:root {\n  /* Notion/Stripe inspired neutral theme */\n  --background: oklch(0.992 0.003 250);\n  --foreground: oklch(0.22 0.01 250);\n  --card: oklch(0.998 0.002 250);\n  --card-foreground: oklch(0.22 0.01 250);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.22 0.01 250);\n  --primary: oklch(0.52 0.16 245);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.972 0.004 250);\n  --secondary-foreground: oklch(0.32 0.02 250);\n  --muted: oklch(0.975 0.004 250);\n  --muted-foreground: oklch(0.55 0.02 250);\n  --accent: oklch(0.955 0.02 245);\n  --accent-foreground: oklch(0.3 0.08 245);\n  --destructive: oklch(0.55 0.22 25);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.91 0.006 250);\n  --input: oklch(0.91 0.006 250);\n  --ring: oklch(0.52 0.16 245);\n  --chart-1: oklch(0.55 0.16 245);\n  --chart-2: oklch(0.6 0.12 210);\n  --chart-3: oklch(0.56 0.12 165);\n  --chart-4: oklch(0.62 0.14 70);\n  --chart-5: oklch(0.53 0.14 330);\n  --sidebar: oklch(0.988 0.003 250);\n  --sidebar-foreground: oklch(0.22 0.01 250);\n  --sidebar-primary: oklch(0.52 0.16 245);\n  --sidebar-primary-foreground: oklch(1 0 0);\n  --sidebar-accent: oklch(0.955 0.02 245);\n  --sidebar-accent-foreground: oklch(0.3 0.08 245);\n  --sidebar-border: oklch(0.91 0.006 250);\n  --sidebar-ring: oklch(0.52 0.16 245);\n  --font-sans: 'Space Grotesk', 'SF Pro Text', 'Segoe UI', sans-serif;\n  --font-serif: 'Instrument Serif', 'Times New Roman', serif;\n  --font-mono: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;\n  --radius: 0.625rem;\n  --shadow-x: 0;\n  --shadow-y: 1px;\n  --shadow-blur: 3px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0.1;\n  --shadow-color: oklch(0.3 0 0);\n  --shadow-2xs: 0 1px 2px 0px oklch(0.3 0 0 / 0.03);\n  --shadow-xs: 0 1px 3px 0px oklch(0.3 0 0 / 0.04);\n  --shadow-sm: 0 2px 4px 0px oklch(0.3 0 0 / 0.05);\n  --shadow: 0 3px 6px 0px oklch(0.3 0 0 / 0.06);\n  --shadow-md: 0 8px 18px -6px oklch(0.3 0 0 / 0.1);\n  --shadow-lg: 0 12px 28px -8px oklch(0.3 0 0 / 0.12);\n  --shadow-xl: 0 20px 40px -12px oklch(0.3 0 0 / 0.14);\n  --shadow-2xl: 0 25px 50px -12px oklch(0.3 0 0 / 0.2);\n  --tracking-normal: 0em;\n  --spacing: 0.25rem;\n}\n\n.dark {\n  /* Dark mode - Notion/Stripe inspired */\n  --background: oklch(0.14 0.006 250);\n  --foreground: oklch(0.92 0.01 250);\n  --card: oklch(0.18 0.01 250);\n  --card-foreground: oklch(0.92 0.01 250);\n  --popover: oklch(0.18 0.01 250);\n  --popover-foreground: oklch(0.92 0.01 250);\n  --primary: oklch(0.66 0.15 245);\n  --primary-foreground: oklch(0.12 0.01 250);\n  --secondary: oklch(0.22 0.01 250);\n  --secondary-foreground: oklch(0.88 0.01 250);\n  --muted: oklch(0.2 0.01 250);\n  --muted-foreground: oklch(0.62 0.02 250);\n  --accent: oklch(0.28 0.04 245);\n  --accent-foreground: oklch(0.88 0.04 245);\n  --destructive: oklch(0.5 0.2 25);\n  --destructive-foreground: oklch(1 0 0);\n  --border: oklch(0.28 0.01 250);\n  --input: oklch(0.28 0.01 250);\n  --ring: oklch(0.66 0.15 245);\n  --chart-1: oklch(0.66 0.15 245);\n  --chart-2: oklch(0.65 0.12 210);\n  --chart-3: oklch(0.6 0.1 165);\n  --chart-4: oklch(0.65 0.12 70);\n  --chart-5: oklch(0.6 0.14 330);\n  --sidebar: oklch(0.12 0.006 250);\n  --sidebar-foreground: oklch(0.92 0.01 250);\n  --sidebar-primary: oklch(0.66 0.15 245);\n  --sidebar-primary-foreground: oklch(0.12 0.01 250);\n  --sidebar-accent: oklch(0.28 0.04 245);\n  --sidebar-accent-foreground: oklch(0.88 0.04 245);\n  --sidebar-border: oklch(0.28 0.01 250);\n  --sidebar-ring: oklch(0.66 0.15 245);\n  --font-sans: 'Space Grotesk', 'SF Pro Text', 'Segoe UI', sans-serif;\n  --font-serif: 'Instrument Serif', 'Times New Roman', serif;\n  --font-mono: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;\n  --radius: 0.625rem;\n  --shadow-x: 0;\n  --shadow-y: 1px;\n  --shadow-blur: 3px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0.1;\n  --shadow-color: oklch(0 0 0);\n  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.1);\n  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.12);\n  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.15), 0 1px 2px -1px hsl(0 0% 0% / 0.15);\n  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.15), 0 1px 2px -1px hsl(0 0% 0% / 0.15);\n  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.18), 0 2px 4px -1px hsl(0 0% 0% / 0.18);\n  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.2), 0 4px 6px -1px hsl(0 0% 0% / 0.2);\n  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.22), 0 8px 10px -1px hsl(0 0% 0% / 0.22);\n  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.3);\n}\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n\n  --font-sans: var(--font-sans);\n  --font-mono: var(--font-mono);\n  --font-serif: var(--font-serif);\n\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n\n  --shadow-2xs: var(--shadow-2xs);\n  --shadow-xs: var(--shadow-xs);\n  --shadow-sm: var(--shadow-sm);\n  --shadow: var(--shadow);\n  --shadow-md: var(--shadow-md);\n  --shadow-lg: var(--shadow-lg);\n  --shadow-xl: var(--shadow-xl);\n  --shadow-2xl: var(--shadow-2xl);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n/* Decorative background patterns */\n.bg-grid-pattern {\n  background-image: linear-gradient(to right, var(--border) 1px, transparent 1px),\n    linear-gradient(to bottom, var(--border) 1px, transparent 1px);\n  background-size: 24px 24px;\n}\n\n.bg-dot-pattern {\n  background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);\n  background-size: 16px 16px;\n}\n\n.bg-gradient-radial {\n  background: radial-gradient(ellipse at top, oklch(0.96 0.02 260) 0%, transparent 50%);\n}\n\n.dark .bg-gradient-radial {\n  background: radial-gradient(ellipse at top, oklch(0.22 0.03 260) 0%, transparent 50%);\n}\n\n/* Glassmorphism effects */\n.glass {\n  background: oklch(1 0 0 / 0.7);\n  backdrop-filter: blur(12px);\n  -webkit-backdrop-filter: blur(12px);\n}\n\n.dark .glass {\n  background: oklch(0.2 0 0 / 0.7);\n}\n\n.glass-subtle {\n  background: oklch(1 0 0 / 0.5);\n  backdrop-filter: blur(8px);\n  -webkit-backdrop-filter: blur(8px);\n}\n\n.dark .glass-subtle {\n  background: oklch(0.2 0 0 / 0.5);\n}\n\n/* Animated gradient border */\n@property --gradient-angle {\n  syntax: '<angle>';\n  initial-value: 0deg;\n  inherits: false;\n}\n\n.gradient-border {\n  --gradient-angle: 0deg;\n  border: 1px solid transparent;\n  background:\n    linear-gradient(var(--background), var(--background)) padding-box,\n    linear-gradient(var(--gradient-angle), var(--primary), oklch(0.55 0.14 200), var(--primary))\n      border-box;\n  animation: gradient-rotate 4s linear infinite;\n}\n\n@keyframes gradient-rotate {\n  to {\n    --gradient-angle: 360deg;\n  }\n}\n\n/* Glow effects */\n.glow-primary {\n  box-shadow: 0 0 20px -5px var(--primary);\n}\n\n.glow-primary-sm {\n  box-shadow: 0 0 10px -3px var(--primary);\n}\n\n/* Smooth card hover */\n.card-hover {\n  transition:\n    transform 0.2s ease,\n    box-shadow 0.2s ease;\n}\n\n.card-hover:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 8px 30px -10px oklch(0 0 0 / 0.15);\n}\n\n.dark .card-hover:hover {\n  box-shadow: 0 8px 30px -10px oklch(0 0 0 / 0.4);\n}\n\n/* Animated underline for links */\n.animated-underline {\n  position: relative;\n}\n\n.animated-underline::after {\n  content: '';\n  position: absolute;\n  bottom: -2px;\n  left: 0;\n  width: 0;\n  height: 2px;\n  background: var(--primary);\n  transition: width 0.3s ease;\n}\n\n.animated-underline:hover::after {\n  width: 100%;\n}\n\n/* Skeleton shimmer animation */\n@keyframes shimmer {\n  0% {\n    background-position: -200% 0;\n  }\n  100% {\n    background-position: 200% 0;\n  }\n}\n\n.skeleton-shimmer {\n  background: linear-gradient(90deg, var(--muted) 25%, oklch(0.95 0 0) 50%, var(--muted) 75%);\n  background-size: 200% 100%;\n  animation: shimmer 1.5s infinite;\n}\n\n.dark .skeleton-shimmer {\n  background: linear-gradient(90deg, var(--muted) 25%, oklch(0.3 0 0) 50%, var(--muted) 75%);\n  background-size: 200% 100%;\n}\n\n/* Floating animation for decorative elements */\n@keyframes float {\n  0%,\n  100% {\n    transform: translateY(0);\n  }\n  50% {\n    transform: translateY(-10px);\n  }\n}\n\n.animate-float {\n  animation: float 3s ease-in-out infinite;\n}\n\n/* Fade in animation */\n@keyframes fade-in {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.animate-fade-in {\n  animation: fade-in 0.4s ease-out;\n}\n\n/* Scale in animation */\n@keyframes scale-in {\n  from {\n    opacity: 0;\n    transform: scale(0.95);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.animate-scale-in {\n  animation: scale-in 0.3s ease-out;\n}\n\n/* Pulse glow animation */\n@keyframes pulse-glow {\n  0%,\n  100% {\n    box-shadow: 0 0 5px var(--primary);\n  }\n  50% {\n    box-shadow: 0 0 20px var(--primary);\n  }\n}\n\n.animate-pulse-glow {\n  animation: pulse-glow 2s ease-in-out infinite;\n}\n"
  },
  {
    "path": "apps/playground/src/types/sharedb-pubsub.d.ts",
    "content": "declare module 'sharedb/lib/pubsub' {\n  import type ShareDB = require('sharedb');\n\n  export default class PubSub extends ShareDB.PubSub {}\n}\n\ndeclare module 'sharedb/lib/pubsub/memory' {\n  import type ShareDB = require('sharedb');\n\n  export default class MemoryPubSub extends ShareDB.PubSub {}\n}\n"
  },
  {
    "path": "apps/playground/tsconfig.json",
    "content": "{\n  \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"types\": [\"vite/client\"],\n    \"experimentalDecorators\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": false,\n    \"noEmit\": true,\n\n    /* Linting */\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@teable/v2-*\": [\"../../packages/v2/*/src/index\"],\n      \"@teable/v2-contract-http-implementation/handlers\": [\n        \"../../packages/v2/contract-http-implementation/src/handlers/index.ts\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/playground/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite';\nimport { fileURLToPath } from 'node:url';\nimport { createRequire } from 'node:module';\nimport path from 'node:path';\nimport { devtools } from '@tanstack/devtools-vite';\nimport { tanstackStart } from '@tanstack/react-start/plugin/vite';\nimport viteReact from '@vitejs/plugin-react';\nimport viteTsConfigPaths from 'vite-tsconfig-paths';\nimport tailwindcss from '@tailwindcss/vite';\nimport type { PluginOption } from 'vite';\n\nconst v2ServerPackages = [\n  // Core\n  '@teable/v2-core',\n  '@teable/v2-di',\n  '@teable/v2-postgres-schema',\n  '@teable/v2-table-templates',\n  '@teable/v2-formula-sql-pg',\n  // Contract\n  '@teable/v2-contract-http',\n  '@teable/v2-contract-http-client',\n  '@teable/v2-contract-http-express',\n  '@teable/v2-contract-http-fastify',\n  '@teable/v2-contract-http-hono',\n  '@teable/v2-contract-http-implementation',\n  '@teable/v2-contract-http-openapi',\n  // Containers\n  '@teable/v2-container-browser',\n  '@teable/v2-container-node',\n  '@teable/v2-container-node-test',\n  // DB Adapters\n  '@teable/v2-adapter-db-postgres-pg',\n  '@teable/v2-adapter-db-postgres-pglite',\n  '@teable/v2-adapter-db-postgres-postgresjs',\n  '@teable/v2-adapter-db-postgres-shared',\n  // Repository Adapters\n  '@teable/v2-adapter-repository-postgres',\n  '@teable/v2-adapter-record-repository-postgres',\n  '@teable/v2-adapter-table-repository-postgres',\n  // Other Adapters\n  '@teable/v2-adapter-csv-parser-papaparse',\n  '@teable/v2-adapter-logger-console',\n  '@teable/v2-adapter-logger-pino',\n  '@teable/v2-adapter-realtime-broadcastchannel',\n  '@teable/v2-adapter-realtime-sharedb',\n];\nconst sourceOnlyPackages = ['@teable/formula'];\n\nconst nodeExternalDeps = ['pg', 'pg-pool', 'kysely', '@electric-sql/pglite'];\n\nconst PLAYGROUND_PORT = 3100;\nconst v2PackagePrefix = '@teable/v2-';\n\nconst config = defineConfig(({ mode }) => {\n  const envDir = path.dirname(fileURLToPath(import.meta.url));\n  const env = loadEnv(mode, envDir, '');\n  const require = createRequire(import.meta.url);\n\n  const tanstackStartEntryResolver = (): PluginOption => {\n    const reactStartPkgDir = path.dirname(require.resolve('@tanstack/react-start/package.json'));\n    const defaultClientEntry = path.resolve(\n      reactStartPkgDir,\n      'dist/plugin/default-entry/client.tsx'\n    );\n    const serverEntry = path.resolve(envDir, 'src/server.ts');\n\n    const resolveVirtual = (id: string) => {\n      if (\n        id === 'virtual:tanstack-start-client-entry' ||\n        id === '\\0virtual:tanstack-start-client-entry'\n      ) {\n        return defaultClientEntry;\n      }\n      if (\n        id === 'virtual:tanstack-start-server-entry' ||\n        id === '\\0virtual:tanstack-start-server-entry'\n      ) {\n        return serverEntry;\n      }\n      return null;\n    };\n\n    return {\n      name: 'teable-playground:tanstack-start-entry-resolver',\n      enforce: 'pre',\n      resolveId(id) {\n        return resolveVirtual(id);\n      },\n    };\n  };\n\n  const v2Aliases = v2ServerPackages.map(\n    (pkg) =>\n      ({\n        find: pkg,\n        replacement: path.resolve(\n          envDir,\n          `../../packages/v2/${pkg.slice(v2PackagePrefix.length)}/src/index.ts`\n        ),\n      }) as const\n  );\n\n  const teablePlaygroundAliases = (): PluginOption => ({\n    name: 'teable-playground:aliases',\n    enforce: 'post',\n    config(viteConfig) {\n      const existingAliases = Array.isArray(viteConfig.resolve?.alias)\n        ? viteConfig.resolve.alias\n        : viteConfig.resolve?.alias\n          ? Object.entries(viteConfig.resolve.alias).map(([find, replacement]) => ({\n              find,\n              replacement,\n            }))\n          : [];\n\n      return {\n        resolve: {\n          alias: [\n            ...existingAliases,\n            {\n              find: '@teable/v2-contract-http-implementation/handlers',\n              replacement: path.resolve(\n                envDir,\n                '../../packages/v2/contract-http-implementation/src/handlers/index.ts'\n              ),\n            },\n            ...v2Aliases,\n            {\n              find: '@teable/formula',\n              replacement: path.resolve(envDir, '../../packages/formula/src/index.ts'),\n            },\n          ],\n        },\n      };\n    },\n  });\n  if (!process.env.DATABASE_URL && env.DATABASE_URL) {\n    process.env.DATABASE_URL = env.DATABASE_URL;\n  }\n  if (!process.env.PRISMA_DATABASE_URL && env.PRISMA_DATABASE_URL) {\n    process.env.PRISMA_DATABASE_URL = env.PRISMA_DATABASE_URL;\n  }\n  if (!process.env.VITE_PLAYGROUND_DB_URL) {\n    const defaultDbUrl = env.VITE_PLAYGROUND_DB_URL ?? env.DATABASE_URL;\n    if (defaultDbUrl) {\n      process.env.VITE_PLAYGROUND_DB_URL = defaultDbUrl;\n    }\n  }\n  if (!process.env.PLAYGROUND_TRACE_LINK_BASE_URL && env.PLAYGROUND_TRACE_LINK_BASE_URL) {\n    process.env.PLAYGROUND_TRACE_LINK_BASE_URL = env.PLAYGROUND_TRACE_LINK_BASE_URL;\n  }\n\n  return {\n    envDir,\n    plugins: [\n      devtools(),\n      tanstackStartEntryResolver(),\n      // this is the plugin that enables path aliases\n      viteTsConfigPaths({\n        projects: ['./tsconfig.json'],\n      }),\n      tailwindcss(),\n      tanstackStart(),\n      viteReact(),\n      teablePlaygroundAliases(),\n    ],\n    ssr: {\n      // Bundle v2 source in dev so we can run without dist outputs.\n      noExternal: [...v2ServerPackages, ...sourceOnlyPackages],\n      external: nodeExternalDeps,\n    },\n    optimizeDeps: {\n      exclude: [...v2ServerPackages, ...sourceOnlyPackages, ...nodeExternalDeps],\n    },\n    server: {\n      port: PLAYGROUND_PORT,\n      host: true,\n      hmr: {\n        protocol: 'ws',\n        host: 'localhost',\n        port: PLAYGROUND_PORT,\n      },\n      fs: {\n        // Allow TanStack Start's default entry modules under repo root `node_modules`.\n        allow: [path.resolve(envDir, '../../..'), path.resolve(envDir, '../../../..')],\n      },\n    },\n  };\n});\n\nexport default config;\n"
  },
  {
    "path": "commitlint.config.js",
    "content": "module.exports = {\n  extends: ['@commitlint/config-conventional'],\n  rules: {\n    'body-leading-blank': [1, 'always'],\n    'body-max-line-length': [0, 'always', 100],\n    'footer-leading-blank': [1, 'always'],\n    'footer-max-line-length': [0, 'always', 100],\n    'header-max-length': [2, 'always', 100],\n    'scope-case': [2, 'always', 'lower-case'],\n    'subject-case': [\n      2,\n      'never',\n      ['sentence-case', 'start-case', 'pascal-case', 'upper-case'],\n    ],\n    'subject-empty': [2, 'never'],\n    'subject-full-stop': [2, 'never', '.'],\n    'type-case': [2, 'always', 'lower-case'],\n    'type-empty': [2, 'never'],\n    'type-enum': [\n      2,\n      'always',\n      [\n        'build',\n        'chore',\n        'ci',\n        'docs',\n        'feat',\n        'fix',\n        'perf',\n        'refactor',\n        'revert',\n        'style',\n        'test',\n        'translation',\n        'security',\n      ],\n    ],\n  },\n};\n"
  },
  {
    "path": "crowdin.yml",
    "content": "files:\n  - source: /packages/common-i18n/src/locales/en/*.json\n    translation: /packages/common-i18n/src/locales/%two_letters_code%/%original_file_name%\n"
  },
  {
    "path": "docker-bake.hcl",
    "content": "group \"default\" {\n  targets = [\"teable\"]\n}\n\nvariable \"IMAGE_REGISTRY\" {\n  default = \"docker.io\"\n}\n\nvariable \"IMAGE_TAG\" {\n  default = \"latest\"\n}\n\ntarget \"teable\" {\n  context = \".\"\n  dockerfile = \"dockers/teable/Dockerfile\"\n  platforms = [\"linux/amd64\", \"linux/arm64\"]\n  tags = [\"${IMAGE_REGISTRY}/teableio/teable:latest\", \"${IMAGE_REGISTRY}/teableio/teable:${IMAGE_TAG}\"]\n}\n\ntarget \"teable-db-migrate\" {\n  context = \".\"\n  dockerfile = \"dockers/teable/Dockerfile.db-migrate\"\n  platforms = [\"linux/amd64\", \"linux/arm64\"]\n  tags = [\"${IMAGE_REGISTRY}/teableio/teable-db-migrate:latest\", \"${IMAGE_REGISTRY}/teableio/teable-db-migrate:${IMAGE_TAG}\"]\n}"
  },
  {
    "path": "dockers/cache-redis.yml",
    "content": "version: '3.9'\n\nservices:\n  teable-cache:\n    image: redis:7.2.4\n    container_name: teable-cache\n    hostname: teable-cache\n    restart: always\n    ports:\n      - '6379:6379'\n    networks:\n      - teable-net\n    volumes:\n      - cache_data:/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/cache/data:/data:rw\n    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}\n    healthcheck:\n      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\nvolumes:\n  cache_data:\n"
  },
  {
    "path": "dockers/database-postgres.yml",
    "content": "\nservices:\n  teable-postgres:\n    image: postgres:15.4\n    container_name: teable-postgres\n    hostname: teable-postgres\n    ports:\n      - '5432:5432'\n    networks:\n      - teable-net\n    environment:\n      - POSTGRES_DB=${POSTGRES_DB}\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n    volumes:\n      - db_data:/var/lib/postgresql/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/db/data:/var/lib/postgresql/data:rw\n    healthcheck:\n      test: ['CMD-SHELL', \"sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'\"]\n      interval: 10s\n      timeout: 3s\n      retries: 3\n    restart: always\n\nvolumes:\n  db_data:\n"
  },
  {
    "path": "dockers/examples/cluster/README.md",
    "content": "# Setup Instructions for Teable Cluster\n\nBefore executing `docker compose up -d`, ensure to update the variables in the `.env` file according to your\nenvironment's requirements.\n\n## Teable Configuration\n\n- **Access URL:** Access the Teable interface via [http://127.0.0.1:80](http://127.0.0.1:80).\n- **Database Storage:** Utilizes PostgreSQL database for data storage.\n- **Telemetry:** Telemetry collection is disabled by default.\n\n## MinIO Endpoint Configuration\n\nWhen configuring the Teable cluster to use MinIO for storage, it's necessary to replace the\nplaceholder `<MINIO_ENDPOINT>` in the `.env` file with the appropriate endpoint:\n\n- **For Intranet Use:** Replace `<MINIO_ENDPOINT>` with the IP address of the host where MinIO is deployed. This setup\n  is recommended if MinIO and Teable are on the same network.\n\n- **For Extranet Use:** Replace `<MINIO_ENDPOINT>` with the domain name or extranet IP address of your MinIO instance.\n  Use this configuration if you need to access MinIO from outside your local network.\n\n## Public Database Proxy Configuration\n\nTo ensure smooth native database connections, you need to set the `PUBLIC_DATABASE_PROXY` variable in the `.env` file to\nits default value, `127.0.0.1:42345`. This port should match the port specified for the `teable-db` container in\nthe `docker-compose.yaml` file's `ports` attribute and can be adjusted to suit your needs.\n\n**Important Note:** When using ports `80` or `443`, it's essential to explicitly specify the port number in the URL.\nFailing to do so is not allowed. This practice guarantees accurate address resolution and dependable connectivity,\nproviding a solid foundation for your database connections.\n\nEnsure to review and adjust these configurations to match your deployment environment before starting the Teable\ncluster.\n"
  },
  {
    "path": "dockers/examples/cluster/docker-compose.yaml",
    "content": "\nservices:\n  teable:\n    image: ghcr.io/teableio/teable:latest\n    deploy:\n      replicas: 2\n    restart: always\n    expose:\n      - '3000'\n    env_file:\n      - .env\n    environment:\n      - TZ=${TIMEZONE}\n    networks:\n      - teable-cluster\n    depends_on:\n      teable-cache:\n        condition: service_healthy\n    healthcheck:\n      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']\n      start_period: 5s\n      interval: 5s\n      timeout: 3s\n      retries: 3\n\n  teable-db:\n    image: postgres:15.4\n    restart: always\n    ports:\n      - '42345:5432'\n    volumes:\n      - teable-db:/var/lib/postgresql/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/db/data:/var/lib/postgresql/data:rw\n    environment:\n      - TZ=${TIMEZONE}\n      - POSTGRES_DB=${POSTGRES_DB}\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n    networks:\n      - teable-cluster\n    healthcheck:\n      test: ['CMD-SHELL', \"sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'\"]\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\n  teable-cache:\n    image: redis:7.2.4\n    restart: always\n    expose:\n      - '6379'\n    volumes:\n      - teable-cache:/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/cache/data:/data:rw\n    networks:\n      - teable-cluster\n    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}\n    healthcheck:\n      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\n  teable-storage:\n    image: minio/minio:RELEASE.2024-02-17T01-15-57Z\n    expose:\n      - '9000'\n      - '9001'\n    environment:\n      - MINIO_SERVER_URL=${MINIO_SERVER_URL}\n      - MINIO_BROWSER_REDIRECT_URL=${MINIO_BROWSER_REDIRECT_URL}\n      - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}\n      - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}\n    volumes:\n      - teable-storage:/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/storage/data:/data:rw\n    networks:\n      - teable-cluster\n    command: server /data --console-address \":9001\"\n\n  createbuckets:\n    image: minio/mc\n    networks:\n      - teable-cluster\n    entrypoint: >\n      /bin/sh -c \"\n      /usr/bin/mc alias set teable-storage http://teable-storage:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};\n      /usr/bin/mc mb teable-storage/public;\n      /usr/bin/mc anonymous set public teable-storage/public;\n      /usr/bin/mc mb teable-storage/private;\n      exit 0;\n      \"\n    depends_on:\n      teable-storage:\n        condition: service_started\n\n  teable-gateway:\n    image: openresty/openresty:1.25.3.1-2-bookworm-fat\n    restart: unless-stopped\n    ports:\n      - '80:80'\n      - '443:443'\n      - '9000:9000'\n    volumes:\n      - './gateway/conf.d:/etc/nginx/conf.d'\n    networks:\n      - teable-cluster\n    healthcheck:\n      test: ['CMD', 'curl', '-f', 'http://localhost/healthcheck']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n    depends_on:\n      teable:\n        condition: service_started\n\nnetworks:\n  teable-cluster:\n    name: teable-cluster-network\n    driver: bridge\n\nvolumes:\n  teable-db: {}\n  teable-cache: {}\n  teable-storage: {}\n"
  },
  {
    "path": "dockers/examples/cluster/gateway/conf.d/default.conf",
    "content": "log_format json_log escape=json '{'\n    '\"timestamp\":\"$time_iso8601\",'\n    '\"remote_addr\":\"$remote_addr\",'\n    '\"remote_user\":\"$remote_user\",'\n    '\"request_method\":\"$request_method\",'\n    '\"request_uri\":\"$request_uri\",'\n    '\"protocol\":\"$server_protocol\",'\n    '\"status\":$status,'\n    '\"body_bytes_sent\":$body_bytes_sent,'\n    '\"request_time\":$request_time,'\n    '\"http_referrer\":\"$http_referer\",'\n    '\"http_user_agent\":\"$http_user_agent\",'\n    '\"http_x_forwarded_for\":\"$http_x_forwarded_for\",'\n    '\"upstream_addr\":\"$upstream_addr\",'\n    '\"upstream_status\":\"$upstream_status\",'\n    '\"upstream_response_time\":\"$upstream_response_time\",'\n    '\"server_name\":\"$server_name\",'\n    '\"http_host\":\"$host\"'\n'}';\n\naccess_log  /dev/stdout json_log;\nserver_tokens off;\n\nmap $http_upgrade $connection_upgrade {\n    default upgrade;\n    ''      close;\n}\n\nupstream teable {\n    server  teable:3000;\n}\n\nserver {\n\tserver_name localhost;\n\tlisten 80;\n\tlisten [::]:80;\n\n    location / {\n        proxy_pass http://teable;\n\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection $connection_upgrade;\n        proxy_set_header Host $host;\n    }\n\n    location /healthcheck {\n        default_type application/json;\n        access_log off;\n        return 200 '{\"status\":\"ok\"}';\n    }\n}\n"
  },
  {
    "path": "dockers/examples/cluster/gateway/conf.d/minio.conf",
    "content": "upstream storage_s3 {\n\tserver teable-storage:9000;\n}\n\nupstream storage_console {\n\tserver teable-storage:9001;\n}\n\nserver {\n\tserver_name localhost;\n\tlisten 9000;\n\tlisten [::]:9000;\n\n\t# Allow special characters in headers\n\tignore_invalid_headers off;\n\t# Allow any size file to be uploaded.\n\t# Set to a value such as 1000m; to restrict file size to a specific value\n\tclient_max_body_size 0;\n\t# Disable buffering\n\tproxy_buffering off;\n\tproxy_request_buffering off;\n\n\tlocation / {\n\t\tproxy_set_header Host $http_host;\n\t\tproxy_set_header X-Real-IP $remote_addr;\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t\tproxy_set_header X-Forwarded-Proto $scheme;\n\n\t\tproxy_connect_timeout 300;\n\t\t# Default is HTTP/1, keepalive is only enabled in HTTP/1.1\n\t\tproxy_http_version 1.1;\n\t\tproxy_set_header Connection \"\";\n\t\tchunked_transfer_encoding off;\n\n\t\tproxy_pass http://storage_s3; # This uses the upstream directive definition to load balance\n\t}\n\n\tlocation /minio/ui/ {\n\t\trewrite ^/minio/ui/(.*) /$1 break;\n\t\tproxy_set_header Host $http_host;\n\t\tproxy_set_header X-Real-IP $remote_addr;\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t\tproxy_set_header X-Forwarded-Proto $scheme;\n\t\tproxy_set_header X-NginX-Proxy true;\n\n\t\t# This is necessary to pass the correct IP to be hashed\n\t\treal_ip_header X-Real-IP;\n\n\t\tproxy_connect_timeout 300;\n\n\t\t# To support websockets in MinIO versions released after January 2023\n\t\tproxy_http_version 1.1;\n\t\tproxy_set_header Upgrade $http_upgrade;\n\t\tproxy_set_header Connection \"upgrade\";\n\t\t# Some environments may encounter CORS errors (Kubernetes + Nginx Ingress)\n\t\t# Uncomment the following line to set the Origin request to an empty string\n\t\t# proxy_set_header Origin '';\n\n\t\tchunked_transfer_encoding off;\n\n\t\tproxy_pass http://storage_console; # This uses the upstream directive definition to load balance\n\t}\n}"
  },
  {
    "path": "dockers/examples/docker-swarm/README.md",
    "content": "# Teable Deployment with Docker Swarm\n\nThis guide provides step-by-step instructions on how to deploy Teable using Docker Swarm, including initializing the\nswarm, deploying services, and managing updates and service removal.\n\n## Initialization\n\nFirst, initialize your Docker Swarm environment:\n\n```shell\ndocker swarm init\n# Or specify the advertise address manually if needed:\n# docker swarm init --advertise-addr 192.168.99.100\n```\n\n## Deploying Services\n\nUse the provided `deploy.sh` script to deploy your services. You can specify the service type and stack name as\narguments:\n\n```shell\n# Syntax: ./deploy.sh [service_type] [stack_name]\n./deploy.sh - example\n\n# To view the current services:\ndocker service ls\n\n# To deploy and configure updates for your application:\n./deploy.sh app example\n```\n\n## Managing Services\n\n### Removing a Service\n\nTo remove a deployed service, use the following command:\n\n```shell\ndocker stack rm example\n```\n\n### Rolling Updates\n\nFor rolling updates of the application, forcing a re-deployment of the current configuration:\n\n```shell\ndocker service update --force example_teable\n```\n\n## Additional Notes\n\n### Cleaning Up Shutdown Containers\n\nTo clean up containers that are in a shutdown state:\n\n```shell\ndocker rm $(docker stack ps --no-trunc -f \"desired-state=shutdown\" --format \"{{.Name}}.{{.ID}}\" example)\n```\n\nThis guide outlines the basic steps for deploying and managing your Teable application with Docker Swarm, including\nservice deployment, updates, and cleanup procedures.\n"
  },
  {
    "path": "dockers/examples/docker-swarm/deploy.sh",
    "content": "#!/bin/sh\n\ncreate_network() {\n  docker network create -d overlay teable-swarm || true\n}\n\nexport_env_vars() {\n  if [ -f .env ]; then\n    # see https://github.com/moby/moby/issues/29133\n    export $(grep -v '^#' .env | xargs)\n  else\n    echo \".env file not found, skipping export.\"\n  fi\n}\n\ndeploy_stack() {\n  compose_files=\"$1\"               # Compose files to use for deployment\n  stack_name=\"${2:-default_stack}\" # Stack name with a default value if not provided\n\n  echo \"Deploying services with stack name '$stack_name' using compose files: $compose_files\"\n  docker stack deploy -c docker-compose.default.yml $compose_files $stack_name\n}\n\nshow_help() {\n  echo \"Usage: $0 [service_type] [stack_name]\"\n  echo \"service_type: The type of service to deploy (kit, app, gateway). Leave empty to deploy all.\"\n  echo \"stack_name: The name of the stack. Optional.\"\n  echo \"Examples:\"\n  echo \"  $0 kit - Deploys the 'kit' service stack.\"\n  echo \"  $0 app default_stack - Deploys the 'app' service stack with a specific stack name.\"\n  echo \"  $0 - Deploys all services with default stack name.\"\n}\n\ndeploy_service() {\n  service_type=$1\n  stack_name=$2\n\n  if [ -z \"$1\" ] || [ \"$1\" = \"help\" ]; then\n    show_help\n    exit 0\n  fi\n\n  create_network\n  export_env_vars\n\n  case $service_type in\n  \"kit\")\n    deploy_stack \"-c docker-compose.kit.yml\" $stack_name\n    ;;\n  \"app\")\n    deploy_stack \"-c docker-compose.app.yml\" $stack_name\n    ;;\n  \"gateway\")\n    deploy_stack \"-c docker-compose.gateway.yml\" $stack_name\n    ;;\n  *)\n    # Deploy all services if no specific service type is provided\n    deploy_stack \"-c docker-compose.kit.yml -c docker-compose.app.yml -c docker-compose.gateway.yml\" $stack_name\n    ;;\n  esac\n}\n\n# $1 is the service type (kit, app, gateway) or empty for all services\n# $2 is the stack name, optional\ndeploy_service $1 $2\n"
  },
  {
    "path": "dockers/examples/docker-swarm/docker-compose.app.yml",
    "content": "\nservices:\n  teable:\n    image: ghcr.io/teableio/teable:latest\n    deploy:\n      replicas: 2\n      restart_policy:\n        condition: on-failure\n      update_config:\n        parallelism: 1\n        delay: 10s\n        order: start-first\n    expose:\n      - '3000'\n    environment:\n      - TZ=${TIMEZONE}\n      - PUBLIC_ORIGIN=${PUBLIC_ORIGIN}\n      - PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}\n      - PUBLIC_DATABASE_PROXY=${PUBLIC_DATABASE_PROXY}\n      - BACKEND_CACHE_PROVIDER=redis\n      - BACKEND_CACHE_REDIS_URI=redis://default:${POSTGRES_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}\n      - BACKEND_STORAGE_PROVIDER=${BACKEND_STORAGE_PROVIDER}\n      - BACKEND_STORAGE_PUBLIC_BUCKET=${BACKEND_STORAGE_PUBLIC_BUCKET}\n      - BACKEND_STORAGE_PRIVATE_BUCKET=${BACKEND_STORAGE_PRIVATE_BUCKET}\n      - BACKEND_STORAGE_MINIO_ENDPOINT=${BACKEND_STORAGE_MINIO_ENDPOINT}\n      - BACKEND_STORAGE_MINIO_PORT=${BACKEND_STORAGE_MINIO_PORT}\n      - BACKEND_STORAGE_MINIO_USE_SSL=${BACKEND_STORAGE_MINIO_USE_SSL}\n      - BACKEND_STORAGE_MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}\n      - BACKEND_STORAGE_MINIO_SECRET_KEY=${MINIO_SECRET_KEY}\n      - STORAGE_PREFIX=${STORAGE_PREFIX}\n      # - BACKEND_MAIL_HOST=${BACKEND_MAIL_HOST}\n      # - BACKEND_MAIL_PORT=${BACKEND_MAIL_PORT}\n      # - BACKEND_MAIL_SECURE=${BACKEND_MAIL_SECURE}\n      # - BACKEND_MAIL_SENDER=${BACKEND_MAIL_SENDER}\n      # - BACKEND_MAIL_SENDER_NAME=${BACKEND_MAIL_SENDER_NAME}\n      # - BACKEND_MAIL_AUTH_USER=${BACKEND_MAIL_AUTH_USER}\n      # - BACKEND_MAIL_AUTH_PASS=${BACKEND_MAIL_AUTH_PASS}\n    networks:\n      - teable-swarm\n    healthcheck:\n      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']\n      start_period: 5s\n      interval: 5s\n      timeout: 3s\n      retries: 3\n"
  },
  {
    "path": "dockers/examples/docker-swarm/docker-compose.default.yml",
    "content": "\nnetworks:\n  teable-swarm:\n    external: true\n\nvolumes:\n  teable-db:\n  teable-cache:\n  teable-storage:\n"
  },
  {
    "path": "dockers/examples/docker-swarm/docker-compose.gateway.yml",
    "content": "\nservices:\n  teable-gateway:\n    image: openresty/openresty:1.25.3.1-2-bookworm-fat\n    deploy:\n      placement:\n        constraints:\n          - node.role == manager\n    ports:\n      - '80:80'\n      - '443:443'\n      - '9000:9000'\n      - '9001:9001'\n    volumes:\n      - ./gateway/conf.d:/etc/nginx/conf.d\n    networks:\n      - teable-swarm\n    healthcheck:\n      test: ['CMD', 'curl', '-f', 'http://127.0.0.1/healthcheck']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n"
  },
  {
    "path": "dockers/examples/docker-swarm/docker-compose.kit.yml",
    "content": "\nservices:\n  teable-db:\n    image: postgres:15.4\n    ports:\n      - '42345:5432'\n    volumes:\n      - teable-db:/var/lib/postgresql/data:rw\n    environment:\n      - TZ=${TIMEZONE}\n      - POSTGRES_DB=${POSTGRES_DB}\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n    networks:\n      - teable-swarm\n    healthcheck:\n      test: ['CMD-SHELL', \"sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'\"]\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\n  teable-cache:\n    image: redis:7.2.4\n    expose:\n      - '6379'\n    volumes:\n      - teable-cache:/data:rw\n    networks:\n      - teable-swarm\n    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}\n    healthcheck:\n      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\n  teable-storage:\n    image: minio/minio:RELEASE.2024-02-17T01-15-57Z\n    expose:\n      - '9000'\n      - '9001'\n    environment:\n      - MINIO_SERVER_URL=${MINIO_SERVER_URL}\n      - MINIO_BROWSER_REDIRECT_URL=${MINIO_BROWSER_REDIRECT_URL}\n      - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}\n      - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}\n    volumes:\n      - teable-storage:/data:rw\n    networks:\n      - teable-swarm\n    command: server /data --console-address \":9001\"\n\n  createbuckets:\n    image: minio/mc\n    deploy:\n      restart_policy:\n        condition: on-failure\n    networks:\n      - teable-swarm\n    entrypoint: >\n      /bin/sh -c \"\n      /usr/bin/mc alias set teable-storage http://teable-storage:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};\n      /usr/bin/mc mb teable-storage/public;\n      /usr/bin/mc anonymous set public teable-storage/public;\n      /usr/bin/mc mb teable-storage/private;\n      exit 0;\n      \"\n"
  },
  {
    "path": "dockers/examples/docker-swarm/gateway/conf.d/default.conf",
    "content": "log_format json_log escape=json '{'\n    '\"timestamp\":\"$time_iso8601\",'\n    '\"remote_addr\":\"$remote_addr\",'\n    '\"remote_user\":\"$remote_user\",'\n    '\"request_method\":\"$request_method\",'\n    '\"request_uri\":\"$request_uri\",'\n    '\"protocol\":\"$server_protocol\",'\n    '\"status\":$status,'\n    '\"body_bytes_sent\":$body_bytes_sent,'\n    '\"request_time\":$request_time,'\n    '\"http_referrer\":\"$http_referer\",'\n    '\"http_user_agent\":\"$http_user_agent\",'\n    '\"http_x_forwarded_for\":\"$http_x_forwarded_for\",'\n    '\"upstream_addr\":\"$upstream_addr\",'\n    '\"upstream_status\":\"$upstream_status\",'\n    '\"upstream_response_time\":\"$upstream_response_time\",'\n    '\"server_name\":\"$server_name\",'\n    '\"http_host\":\"$host\"'\n'}';\n\naccess_log  /dev/stdout json_log;\nserver_tokens off;\n\nmap $http_upgrade $connection_upgrade {\n    default upgrade;\n    ''      close;\n}\n\nupstream teable {\n    server  teable:3000;\n}\n\nserver {\n\tserver_name localhost;\n\tlisten 80;\n\tlisten [::]:80;\n\n    location / {\n        proxy_pass http://teable;\n\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection $connection_upgrade;\n    }\n\n    location /healthcheck {\n        default_type application/json;\n        access_log off;\n        return 200 '{\"status\":\"ok\"}';\n    }\n}"
  },
  {
    "path": "dockers/examples/docker-swarm/gateway/conf.d/minio.conf",
    "content": "upstream storage_s3 {\n\tserver teable-storage:9000;\n}\n\nupstream storage_console {\n\tserver teable-storage:9001;\n}\n\nserver {\n\tserver_name localhost;\n\tlisten 9000;\n\tlisten [::]:9000;\n\n\t# Allow special characters in headers\n\tignore_invalid_headers off;\n\t# Allow any size file to be uploaded.\n\t# Set to a value such as 1000m; to restrict file size to a specific value\n\tclient_max_body_size 0;\n\t# Disable buffering\n\tproxy_buffering off;\n\tproxy_request_buffering off;\n\n\tlocation / {\n\t\tproxy_set_header Host $http_host;\n\t\tproxy_set_header X-Real-IP $remote_addr;\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t\tproxy_set_header X-Forwarded-Proto $scheme;\n\n\t\tproxy_connect_timeout 300;\n\t\t# Default is HTTP/1, keepalive is only enabled in HTTP/1.1\n\t\tproxy_http_version 1.1;\n\t\tproxy_set_header Connection \"\";\n\t\tchunked_transfer_encoding off;\n\n\t\tproxy_pass http://storage_s3; # This uses the upstream directive definition to load balance\n\t}\n\n\tlocation /minio/ui/ {\n\t\trewrite ^/minio/ui/(.*) /$1 break;\n\t\tproxy_set_header Host $http_host;\n\t\tproxy_set_header X-Real-IP $remote_addr;\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t\tproxy_set_header X-Forwarded-Proto $scheme;\n\t\tproxy_set_header X-NginX-Proxy true;\n\n\t\t# This is necessary to pass the correct IP to be hashed\n\t\treal_ip_header X-Real-IP;\n\n\t\tproxy_connect_timeout 300;\n\n\t\t# To support websockets in MinIO versions released after January 2023\n\t\tproxy_http_version 1.1;\n\t\tproxy_set_header Upgrade $http_upgrade;\n\t\tproxy_set_header Connection \"upgrade\";\n\t\t# Some environments may encounter CORS errors (Kubernetes + Nginx Ingress)\n\t\t# Uncomment the following line to set the Origin request to an empty string\n\t\t# proxy_set_header Origin '';\n\n\t\tchunked_transfer_encoding off;\n\n\t\tproxy_pass http://storage_console; # This uses the upstream directive definition to load balance\n\t}\n}"
  },
  {
    "path": "dockers/examples/standalone/README.md",
    "content": "# Example with teable standalone\n\nLook into the `.env` file and update the vaiables before executing `docker compose up -d`.\n\n## Teable\n\n- Accessible via `http://127.0.0.1:3000`\n- Uses postgres db for storage\n- Telemetry is disabled\n"
  },
  {
    "path": "dockers/examples/standalone/docker-compose.yaml",
    "content": "\nservices:\n  teable:\n    image: ghcr.io/teableio/teable:latest\n    restart: always\n    ports:\n      - '3000:3000'\n    volumes:\n      - teable-data:/app/.assets:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/teable/data:/app/.assets:rw\n    env_file:\n      - .env\n    environment:\n      - TZ=${TIMEZONE}\n    networks:\n      - teable-standalone\n    depends_on:\n      teable-db:\n        condition: service_healthy\n      teable-cache:\n        condition: service_healthy\n\n  teable-db:\n    image: postgres:15.4\n    restart: always\n    ports:\n      - '42345:5432'\n    volumes:\n      - teable-db:/var/lib/postgresql/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/db/data:/var/lib/postgresql/data:rw\n    environment:\n      - TZ=${TIMEZONE}\n      - POSTGRES_DB=${POSTGRES_DB}\n      - POSTGRES_USER=${POSTGRES_USER}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}\n    networks:\n      - teable-standalone\n    healthcheck:\n      test: ['CMD-SHELL', \"sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'\"]\n      interval: 10s\n      timeout: 3s\n      retries: 3\n  teable-cache:\n    image: redis:7.2.4\n    restart: always\n    expose:\n      - '6379'\n    volumes:\n      - teable-cache:/data:rw\n    networks:\n      - teable-standalone\n    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}\n    healthcheck:\n      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']\n      interval: 10s\n      timeout: 3s\n      retries: 3\n\n\nnetworks:\n  teable-standalone:\n    name: teable-standalone-network\n    driver: bridge\n\nvolumes:\n  teable-data: {}\n  teable-db: {} \n  teable-cache: {}\n"
  },
  {
    "path": "dockers/integration-test.yml",
    "content": "\nservices:\n  integration-test:\n    container_name: integration-test\n    build:\n      context: ../\n      dockerfile: ./dockers/teable/Dockerfile\n      target: builder\n      args:\n        INTEGRATION_TEST: 1\n    hostname: integration-test\n    networks:\n      - teable-net\n    environment:\n      - PRISMA_DATABASE_URL=file:../../db/main.db\n"
  },
  {
    "path": "dockers/networks.yml",
    "content": "\nnetworks:\n  teable-net:\n    name: ${NETWORK_MODE:-teablenet}\n    external: true\n"
  },
  {
    "path": "dockers/storage-minio.yml",
    "content": "\nservices:\n  teable-storage:\n    image: minio/minio:RELEASE.2024-02-17T01-15-57Z\n    container_name: teable-storage\n    hostname: teable-storage\n    restart: always\n    ports:\n      - '9000:9000'\n      - '9001:9001'\n    networks:\n      - teable-net\n    environment:\n      - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}\n      - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}\n    volumes:\n      - storage_data:/data:rw\n      # you may use a bind-mounted host directory instead,\n      # so that it is harder to accidentally remove the volume and lose all your data!\n      # - ./docker/storage/data:/data:rw\n    command: server /data --console-address \":9001\"\n\n  createbuckets:\n    image: minio/mc:RELEASE.2024-02-16T11-05-48Z\n    networks:\n      - teable-net\n    depends_on:\n      - teable-storage\n    entrypoint: >\n      /bin/sh -c \"\n      /usr/bin/mc alias set teable-storage http://teable-storage:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};\n      /usr/bin/mc mb teable-storage/public;\n      /usr/bin/mc anonymous set public teable-storage/public;\n      /usr/bin/mc mb teable-storage/private;\n      exit 0;\n      \"\n\nvolumes:\n  storage_data:\n"
  },
  {
    "path": "dockers/teable/Dockerfile",
    "content": "ARG NODE_VERSION=22.18.0\nARG BUILD_VERSION=\"1.0.0-alpha\"\n\n###################################################################\n# Stage 1: Install all workspaces (dev)dependencies               #\n#          and generates node_modules folder(s)                   #\n###################################################################\n\nFROM node:${NODE_VERSION}-bookworm AS deps\nENV PNPM_HOME=\"/pnpm\"\nENV PATH=\"$PNPM_HOME:$PATH\"\nRUN corepack enable && pnpm add npm-run-all2 zx -g\n\n# Disabling some well-known postinstall scripts\nENV HUSKY=0\n\nWORKDIR /workspace-install\n\nCOPY --link package.json pnpm-workspace.yaml pnpm-lock.yaml ./\n\nRUN pnpm fetch\n\nCOPY --link . .\n\nRUN pnpm install --prefer-offline --frozen-lockfile\nRUN pnpm -F @teable/db-main-prisma prisma-generate --schema ./prisma/postgres/schema.prisma\n\n###################################################################\n# Stage 2: Build the app                                          #\n###################################################################\n\nFROM deps AS builder\n\nARG INTEGRATION_TEST\nARG BUILD_VERSION\nARG ENABLE_CSP=true\nARG SENTRY_ENABLED=true\nARG SENTRY_TRACING=true\n\nENV NODE_ENV=production \\\n    NEXT_BUILD_ENV_CSP=${ENABLE_CSP} \\\n    NEXT_BUILD_ENV_TYPECHECK=false \\\n    NEXT_BUILD_ENV_SENTRY_ENABLED=${SENTRY_ENABLED} \\\n    NEXT_BUILD_ENV_SENTRY_TRACING=${SENTRY_TRACING}\n\nWORKDIR /app\n\nCOPY --from=deps --link /workspace-install ./\n\nRUN set -ex; \\\n        echo \"\\nNEXT_PUBLIC_BUILD_VERSION=\\\"${BUILD_VERSION}\\\"\" >> apps/nextjs-app/.env; \\\n# Distinguish whether it is an integration test operation\n        if [ -n \"$INTEGRATION_TEST\" ]; then \\\n              pnpm -F \"./packages/**\" run build; \\\n        else \\\n              NODE_OPTIONS=--max-old-space-size=8192 pnpm -r --filter '!playground' run build; \\\n        fi\n\n\n##################################################################\n# Stage 3: Post Builder                                          #\n##################################################################\n\nFROM builder AS post-builder\n\nENV NODE_ENV=production\n\nWORKDIR /app\n\nARG UPLOAD_ASSETS_LIST=\"\"\n\nRUN set -ex; \\\n        rm -fr node_modules; \\\n        pnpm nuke:node_modules; \\\n        chmod +x ./scripts/post-build-cleanup.mjs; \\\n        zx ./scripts/post-build-cleanup.mjs; \\\n        pnpm -F '!@teable/eslint-config-bases' --config.dedupe-peer-dependents=false install --prod --prefer-offline --frozen-lockfile; \\\n        pnpm -F @teable/db-main-prisma prisma-generate --schema ./prisma/postgres/schema.prisma\n\nRUN set -ex; \\\n        apt-get update; \\\n        apt-get install -y --no-install-recommends rsync; \\\n        rm -rf /var/lib/apt/lists/*; \\\n        chmod +x ./scripts/upload-assets.mjs; \\\n        zx ./scripts/upload-assets.mjs --list=\"$UPLOAD_ASSETS_LIST\";\n\n\n##################################################################\n# Stage 4: Extract a minimal image from the build                #\n##################################################################\n\nFROM tianon/gosu:1.19 AS gosu\n\nFROM node:${NODE_VERSION}-bookworm-slim AS runner\n\nENV TZ=UTC \\\n    NODE_ENV=production \\\n    PORT=${NEXTJS_APP_PORT:-3000}\\\n    NEXTJS_DIR=apps/nextjs-app \\\n    PNPM_HOME=\"/pnpm\" \\\n    PATH=\"$PNPM_HOME:$PATH\"\n\nRUN set -ex; \\\n        npm install zx -g; \\\n        corepack enable; \\\n        apt-get update; \\\n        apt-get install -y --no-install-recommends \\\n                curl \\\n                ca-certificates \\\n                openssl \\\n                netcat-traditional \\\n                wget \\\n    \t; \\\n    \trm -rf /var/lib/apt/lists/*; \\\n        ln -s /usr/local/lib/node_modules /node_modules\n\n# gosu for better su+exec command (multi-stage copy from official image)\nCOPY --from=gosu /gosu /usr/local/bin/gosu\n\nWORKDIR /app\n\nRUN set -ex; \\\n        addgroup --system --gid 1001 nodejs; \\\n        adduser --system --uid 1001 nodejs; \\\n# Set the correct permission for local cache\n        mkdir .assets; \\\n        mkdir .temporary; \\\n        chown -R nodejs:nodejs /app\n\nCOPY --from=post-builder --chown=nodejs:nodejs /app/apps/nextjs-app/next.config.js \\\n        /app/apps/nextjs-app/next-i18next.config.js \\\n        /app/apps/nextjs-app/package.json \\\n        /app/apps/nextjs-app/.env \\\n        ./apps/nextjs-app/\n\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nextjs-app/.next ./apps/nextjs-app/.next\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nextjs-app/node_modules ./apps/nextjs-app/node_modules\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nextjs-app/public ./apps/nextjs-app/public\n\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nestjs-backend/dist ./apps/nestjs-backend/dist\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nestjs-backend/node_modules ./apps/nestjs-backend/node_modules\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nestjs-backend/package.json ./apps/nestjs-backend/\n\n# mv it is necessary\nCOPY --from=builder --link --chown=nodejs:nodejs /app/packages/common-i18n/ ./packages/common-i18n/\n\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/packages ./packages\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/node_modules ./node_modules\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/package.json ./package.json\n\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/plugins/.next/standalone/plugins ./plugins\nCOPY --from=post-builder --link --chown=nodejs:nodejs /app/apps/nestjs-backend/static ./static\n\nCOPY --chown=nodejs:nodejs scripts/start.sh ./scripts/start.sh\nCOPY --chown=nodejs:nodejs scripts/db-migrate.mjs ./scripts/db-migrate.mjs\nCOPY --chown=nodejs:nodejs scripts/wait-for ./scripts/wait-for\n\nENV BUILD_VERSION=$BUILD_VERSION\n\nEXPOSE ${PORT}\n\nENTRYPOINT [\"scripts/start.sh\"]\n"
  },
  {
    "path": "dockers/teable/Dockerfile.db-migrate",
    "content": "FROM alpine:3.19\nCMD echo \"[DEPRECATED] db-migrate image is deprecated and no longer needed. This image will be removed in v2.0.0.\" && exit 0\n"
  },
  {
    "path": "dottea/.gitignore",
    "content": "*.tea\n"
  },
  {
    "path": "lint-staged.common.js",
    "content": "// @ts-check\n\nconst path = require('path');\nconst escape = require('shell-quote').quote;\n\nconst isWin = process.platform === 'win32';\n\nconst eslintGlobalRulesForFix = [\n  // react-hooks/eslint and react in general is very strict about exhaustively\n  // declaring the dependencies when using the useEffect, useCallback... hooks.\n  //\n  // In some specific scenarios declaring the deps seems 'less' wanted or 'less' applicable\n  // by the developer, leading to some exceptions in the code. That said it should be avoided.\n  //\n  // While the 'react-hooks/exhaustive-deps' rule is a good rule of thumb, it's not recommended to\n  // automatically fix it from lint-staged as it can potentially break a legit intent.\n  //\n  // Reminder that a good practice is to always declare the dependencies when using the hooks,\n  // and if not applicable, add an eslint disable comment to the useEffect, useCallback... such as:\n  //\n  //    // eslint-disable-next-line react-hooks/exhaustive-deps\n  //\n  // Another approach can be to use hooks such as https://github.com/kentcdodds/use-deep-compare-effect to quickly bypass\n  // shallow rendering limitations.\n  //\n  // @see https://reactjs.org/docs/hooks-rules.html\n  // @see https://eslint.org/docs/2.13.1/user-guide/configuring#disabling-rules-with-inline-comments\n  'react-hooks/exhaustive-deps: off',\n];\n\n/**\n * Lint-staged command for running eslint in packages or apps.\n * @param {{cwd: string, files: string[], fix: boolean, fixType?: ('problem'|'suggestion'|'layout'|'directive')[], cache: boolean, rules?: string[], maxWarnings?: number}} params\n */\nconst getEslintFixCmd = ({\n  cwd,\n  files,\n  rules,\n  fix,\n  fixType,\n  cache,\n  maxWarnings,\n}) => {\n  const cliRules = [...(rules ?? []), ...eslintGlobalRulesForFix]\n    .filter((rule) => rule.trim().length > 0)\n    .map((r) => `\"${r.trim()}\"`);\n\n  // For lint-staged it's safer to not apply the fix command if it changes the AST\n  // @see https://eslint.org/docs/user-guide/command-line-interface#--fix-type\n  const cliFixType = [...(fixType ?? ['layout'])].filter(\n    (type) => type.trim().length > 0\n  );\n\n  const args = [\n    cache ? '--cache' : '',\n    fix ? '--fix' : '',\n    cliFixType.length > 0 ? `--fix-type ${cliFixType.join(',')}` : '',\n    maxWarnings !== undefined ? `--max-warnings=${maxWarnings}` : '',\n    cliRules.length > 0 ? `--rule ${cliRules.join('--rule ')}` : '',\n    files\n      // makes output cleaner by removing absolute paths from filenames\n      .map((f) => `\"./${path.relative(cwd, f)}\"`)\n      .join(' '),\n  ].join(' ');\n  return `eslint ${args}`;\n};\n\n/**\n * Concatenate and escape a list of filenames that can be passed as args to prettier cli\n *\n * Prettier has an issue with special characters in filenames,\n * such as the ones uses for nextjs dynamic routes (ie: [id].tsx...)\n *\n * @link https://github.com/okonet/lint-staged/issues/676\n *\n * @param {string[]} filenames\n * @returns {string} Return concatenated and escaped filenames\n */\nconst concatFilesForPrettier = (filenames) =>\n  filenames\n    .map((filename) => `\"${isWin ? filename : escape([filename])}\"`)\n    .join(' ');\n\nconst concatFilesForStylelint = concatFilesForPrettier;\n\nmodule.exports = {\n  concatFilesForPrettier,\n  concatFilesForStylelint,\n  getEslintFixCmd,\n};\n"
  },
  {
    "path": "lint-staged.config.js",
    "content": "// @ts-check\n\n/**\n * This is the base lint-staged rules config and just includes prettier by default.\n * A good practice is to override this base configuration in each package and/or application\n * where we are able to add customization depending on the nature of the project (eslint...).\n *\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/teable/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier } = require('./lint-staged.common.js');\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '{apps,packages}/**/*.{json,md,mdx,css,html,yml,yaml,scss,ts,js,tsx,jsx,mjs}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nmodule.exports = rules;\n"
  },
  {
    "path": "monorepo.code-workspace",
    "content": "{\n  \"folders\": [\n    {\n      \"name\": \"nextjs-app\",\n      \"path\": \"apps/nextjs-app\",\n    },\n    {\n      \"name\": \"plugins\",\n      \"path\": \"plugins\",\n    },\n    {\n      \"name\": \"nestjs-backend\",\n      \"path\": \"apps/nestjs-backend\",\n    },\n    {\n      \"name\": \"common-i18n\",\n      \"path\": \"packages/common-i18n\",\n    },\n    {\n      \"name\": \"sdk\",\n      \"path\": \"packages/sdk\",\n    },\n    {\n      \"name\": \"core\",\n      \"path\": \"packages/core\",\n    },\n    {\n      \"name\": \"db-main-prisma\",\n      \"path\": \"packages/db-main-prisma\",\n    },\n    {\n      \"name\": \"eslint-config-bases\",\n      \"path\": \"packages/eslint-config-bases\",\n    },\n    {\n      \"name\": \"ui-lib\",\n      \"path\": \"packages/ui-lib\",\n    },\n    {\n      \"name\": \"icons\",\n      \"path\": \"packages/icons\",\n    },\n    {\n      \"name\": \"openapi\",\n      \"path\": \"packages/openapi\",\n    },\n    {\n      \"name\": \"root\",\n      \"path\": \".\",\n    },\n  ],\n  \"extensions\": {\n    \"recommendations\": [\"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"],\n  },\n  \"settings\": {\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n    \"editor.codeActionsOnSave\": {\n      \"source.fixAll.eslint\": \"explicit\",\n    },\n    // Disable vscode formatting for js,jsx,ts,tsx files\n    // to allow dbaeumer.vscode-eslint to format them\n    \"[javascript]\": {\n      \"editor.formatOnSave\": false,\n    },\n    \"eslint.alwaysShowStatus\": true,\n    // https://github.com/Microsoft/vscode-eslint#mono-repository-setup\n    \"eslint.workingDirectories\": [\n      \"./apps/nextjs-app\",\n      \"./apps/nestjs-backend\",\n      \"./packages/common-i18n\",\n      \"./packages/sdk\",\n      \"./packages/core\",\n      \"./packages/icons\",\n      \"./packages/db-main-prisma\",\n      \"./packages/eslint-config-bases\",\n      \"./packages/ui-lib\",\n      \"./plugins\",\n    ],\n    \"cSpell.words\": [\n      \"combobox\",\n      \"DATEPICKEROPTIONS\",\n      \"glideapps\",\n      \"Gridlines\",\n      \"INPUTOPTIONS\",\n      \"jschardet\",\n      \"overscan\",\n      \"Qrcode\",\n      \"sharedb\",\n      \"Sqls\",\n      \"tada\",\n      \"Teable\",\n      \"thumbsdown\",\n      \"thumbsup\",\n      \"Trgm\",\n      \"tsvector\",\n      \"udecode\",\n      \"univer\",\n      \"Univer\",\n      \"univerjs\",\n      \"zustand\",\n    ],\n  },\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@teable/teable\",\n  \"version\": \"1.10.0\",\n  \"license\": \"AGPL-3.0\",\n  \"private\": true,\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"keywords\": [\n    \"teable\",\n    \"database\"\n  ],\n  \"workspaces\": [\n    \"apps/*\",\n    \"packages/*\",\n    \"packages/v2/*\",\n    \"plugins\",\n    \"!apps/electron\"\n  ],\n  \"scripts\": {\n    \"clean:global-cache\": \"rimraf ./.cache\",\n    \"deps:check\": \"pnpm --package=npm-check-updates@latest dlx npm-check-updates --configFileName .ncurc.yml --workspaces --root --mergeConfig\",\n    \"deps:update\": \"pnpm --package=npm-check-updates@latest dlx npm-check-updates --configFileName .ncurc.yml -u --workspaces --root --mergeConfig\",\n    \"dev:playground\": \"pnpm -C apps/playground dev\",\n    \"dev:v2\": \"pnpm -r --parallel --stream -F @teable/formula -F './packages/v2/*' dev\",\n    \"clean:v2\": \"pnpm -r --parallel --stream -F @teable/formula -F './packages/v2/*' clean\",\n    \"build:v2\": \"pnpm -r --parallel --stream -F @teable/formula -F './packages/v2/*' build\",\n    \"build:packages\": \"pnpm -r -F './packages/**' build\",\n    \"g:build\": \"pnpm -r run build\",\n    \"g:build-changed\": \"pnpm -r -F '...[origin/main]' build\",\n    \"g:check-dist\": \"pnpm -r --parallel check-dist\",\n    \"g:clean\": \"pnpm clean:global-cache && pnpm -r run clean\",\n    \"g:fix-all-files\": \"pnpm -r fix-all-files\",\n    \"g:lint\": \"pnpm -r --parallel lint --color\",\n    \"g:lint-staged-files\": \"lint-staged --allow-empty\",\n    \"g:lint-styles\": \"pnpm -r lint-styles --color\",\n    \"g:test\": \"pnpm g:test-e2e && pnpm g:test-unit\",\n    \"g:test-e2e\": \"pnpm -r test-e2e\",\n    \"g:test-e2e-cover\": \"pnpm -r test-e2e-cover\",\n    \"g:test-unit\": \"pnpm -r --parallel test-unit\",\n    \"g:test-unit-cover\": \"pnpm -r --parallel test-unit-cover\",\n    \"g:typecheck\": \"pnpm -r --parallel typecheck\",\n    \"generate-openapi-types\": \"node scripts/generate-openapi-types.mjs\",\n    \"install:playwright\": \"playwright install\",\n    \"install:husky\": \"node .husky/install.mjs\",\n    \"nuke:node_modules\": \"pnpm -r exec -- rm -fr node_modules\",\n    \"publish:beta:prerelease\": \"node ./scripts/publish.mjs prerelease beta\",\n    \"publish:beta:patch\": \"node ./scripts/publish.mjs prepatch beta\",\n    \"publish:beta:minor\": \"node ./scripts/publish.mjs preminor beta\",\n    \"publish:beta:major\": \"node ./scripts/publish.mjs premajor beta\",\n    \"publish:next:patch\": \"node ./scripts/publish.mjs patch next\",\n    \"publish:next:minor\": \"node ./scripts/publish.mjs minor next\",\n    \"publish:next:major\": \"node ./scripts/publish.mjs major next\",\n    \"publish:latest:patch\": \"node ./scripts/publish.mjs patch latest\",\n    \"publish:latest:minor\": \"node ./scripts/publish.mjs minor latest\",\n    \"publish:latest:major\": \"node ./scripts/publish.mjs major latest\",\n    \"prepare\": \"run-s install:husky\",\n    \"run:plugin\": \"pnpm -F '@teable/plugin' dev\"\n  },\n  \"dependencies\": {\n    \"cross-env\": \"7.0.3\"\n  },\n  \"devDependencies\": {\n    \"@commitlint/cli\": \"19.2.1\",\n    \"@commitlint/config-conventional\": \"19.1.0\",\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/shell-quote\": \"1.7.5\",\n    \"eslint\": \"8.57.0\",\n    \"husky\": \"9.0.11\",\n    \"lint-staged\": \"15.2.2\",\n    \"npm-run-all2\": \"6.1.2\",\n    \"openapi-typescript\": \"6.7.5\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"shell-quote\": \"1.8.1\",\n    \"typescript\": \"5.4.3\",\n    \"zx\": \"8.8.5\"\n  },\n  \"engines\": {\n    \"node\": \">=22.0.0\",\n    \"pnpm\": \">=9.13.0\",\n    \"npm\": \"please-use-pnpm\"\n  },\n  \"packageManager\": \"pnpm@9.13.0\"\n}\n"
  },
  {
    "path": "packages/common-i18n/.eslintrc.cjs",
    "content": "/**\n * Specific eslint rules for this app/package, extends the base rules\n * @see https://github.com/teableio/teable/blob/main/docs/about-linters.md\n */\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns()],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    // optional overrides per project file match\n  ],\n};\n"
  },
  {
    "path": "packages/common-i18n/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# build\n/dist\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n"
  },
  {
    "path": "packages/common-i18n/.idea/common-i18n.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "packages/common-i18n/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/common-i18n.iml\" filepath=\"$PROJECT_DIR$/.idea/common-i18n.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "packages/common-i18n/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2025 Teable, Inc.\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": "packages/common-i18n/lint-staged.config.js",
    "content": "// @ts-check\n\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport lintStagedCommon from '../../lint-staged.common.js';\n\n/**\n * This files overrides the base lint-staged.config.js present in the root directory.\n * It allows to run eslint based the package specific requirements.\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/teable/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier, getEslintFixCmd } = lintStagedCommon;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '**/*.{js,jsx,ts,tsx,mjs,cjs}': (filenames) => {\n    return getEslintFixCmd({\n      cwd: __dirname,\n      fix: true,\n      cache: true,\n      // when autofixing staged-files a good tip is to disable react-hooks/exhaustive-deps, cause\n      // a change here can potentially break things without proper visibility.\n      rules: ['react-hooks/exhaustive-deps: off'],\n      maxWarnings: 25,\n      files: filenames,\n    });\n  },\n  '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nexport default rules;\n"
  },
  {
    "path": "packages/common-i18n/package.json",
    "content": "{\n  \"name\": \"@teable/common-i18n\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"private\": false,\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/common-i18n\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"sideEffects\": false,\n  \"type\": \"module\",\n  \"types\": \"./src/index.ts\",\n  \"files\": [\n    \"src\"\n  ],\n  \"exports\": {\n    \"./src/locales/*\": {\n      \"require\": \"./src/locales/*\",\n      \"import\": \"./src/locales/*\"\n    }\n  },\n  \"scripts\": {\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --cache --cache-location ../../.cache/eslint/common-i18n.eslintcache\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/node\": \"22.18.0\",\n    \"cross-env\": \"7.0.3\",\n    \"eslint\": \"8.57.0\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"typescript\": \"5.4.3\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/I18nNamespaces.ts",
    "content": "import type auth from './locales/en/auth.json';\nimport type chart from './locales/en/chart.json';\nimport type common from './locales/en/common.json';\nimport type dashboard from './locales/en/dashboard.json';\nimport type developer from './locales/en/developer.json';\nimport type oauth from './locales/en/oauth.json';\nimport type plugin from './locales/en/plugin.json';\nimport type sdk from './locales/en/sdk.json';\nimport type setting from './locales/en/setting.json';\nimport type share from './locales/en/share.json';\nimport type space from './locales/en/space.json';\nimport type table from './locales/en/table.json';\nimport type token from './locales/en/token.json';\nimport type zod from './locales/en/zod.json';\n\nexport interface I18nNamespaces {\n  auth: typeof auth;\n  space: typeof space;\n  common: typeof common;\n  sdk: typeof sdk;\n  share: typeof share;\n  table: typeof table;\n  token: typeof token;\n  setting: typeof setting;\n  oauth: typeof oauth;\n  zod: typeof zod;\n  developer: typeof developer;\n  plugin: typeof plugin;\n  dashboard: typeof dashboard;\n  chart: typeof chart;\n}\n"
  },
  {
    "path": "packages/common-i18n/src/index.ts",
    "content": "export type { I18nNamespaces } from './I18nNamespaces';\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"Login\",\n    \"signup\": \"Registrieren\"\n  },\n  \"title\": {\n    \"signin\": \"Bei Ihrem Konto anmelden\",\n    \"signup\": \"Erstellen Sie ein Konto zum Anmelden\"\n  },\n  \"content\": {\n    \"title\": \"Wo Daten fließen, wachsen Teams\",\n    \"description\": \"Eine Datenbank, die für jedes Team geeignet ist, von einfachen Tabellen bis hin zu Unternehmenslösungen\"\n  },\n  \"button\": {\n    \"signin\": \"Einloggen\",\n    \"signup\": \"Registrieren\",\n    \"resend\": \"Erneut zusenden\"\n  },\n  \"label\": {\n    \"email\": \"E-Mail\",\n    \"password\": \"Passwort\",\n    \"verificationCode\": \"Verifizierungscode\"\n  },\n  \"placeholder\": {\n    \"password\": \"Geben Sie Ihr Passwort ein...\",\n    \"email\": \"Geben Sie Ihre E-Mail ein...\",\n    \"verificationCode\": \"Geben Sie Ihren Verifizierungscode ein...\"\n  },\n  \"signError\": {\n    \"exist\": \"E-Mail wurde registriert\",\n    \"incorrect\": \"E-Mail oder Passwort ist falsch\",\n    \"tooManyRequests\": \"Ihr Konto wurde gesperrt, bitte versuchen Sie es nach {{Minuten}} Minuten erneut\",\n    \"turnstileRequired\": \"Bitte vervollständigen Sie die Verifizierungs-Challenge\",\n    \"turnstileError\": \"Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut\",\n    \"turnstileExpired\": \"Verifizierung abgelaufen. Bitte versuchen Sie es erneut\",\n    \"turnstileTimeout\": \"Verifizierung ist abgelaufen. Bitte versuchen Sie es erneut\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"Verifizierungscode ist erforderlich\",\n    \"verificationCodeInvalid\": \"Der Verifizierungscode ist ungültig\",\n    \"passwordLength\": \"Minimal 8 Zeichen\",\n    \"passwordInvalid\": \"Das Passwort muss mindestens einen Buchstaben und eine Ziffer enthalten\"\n  },\n  \"socialAuth\": {\n    \"title\": \"Oder fahren Sie fort mit\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Ihr Passwort festlegen\",\n    \"description\": \"Geben Sie ein neues Passwort ein\",\n    \"label\": \"Neues Passwort\",\n    \"error\": {\n      \"requiredPassword\": \"Passwort eingeben\",\n      \"invalidLink\": \"Ungültiger Link zum Zurücksetzen des Passworts\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Zurücksetzen des Passworts erfolgreich\",\n      \"description\": \"Ihr Passwort wurde erfolgreich zurückgesetzt. Sie werden zur Anmeldeseite weitergeleitet.\"\n    },\n    \"buttonText\": \"Passwort zurücksetzen\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Passwort vergessen?\",\n    \"header\": \"Ihr Passwort zurücksetzen\",\n    \"description\": \"Bitte geben Sie unten Ihre E-Mail-Adresse ein, damit wir Ihnen einen Link zum Zurücksetzen Ihres Passworts zusenden können.\",\n    \"errorRequiredEmail\": \"E-Mail ist erforderlich\",\n    \"errorInvalidEmail\": \"Ungültige email\",\n    \"buttonText\": \"Reset-E-Mail senden\",\n    \"success\": {\n      \"title\": \"🎉 E-Mail zum Zurücksetzen des Passworts gesendet\",\n      \"description\": \"Wir haben Ihnen eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts geschickt. Bitte prüfen Sie Ihren Posteingang.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/chart.json",
    "content": "{\n  \"notBaseId\": \"BaseId fehlt\",\n  \"notPositionId\": \"PositionId fehlt\",\n  \"notPluginInstallId\": \"PluginInstallId fehlt\",\n  \"initBridge\": \"Bridge wird initialisiert...\",\n  \"actions\": {\n    \"cancel\": \"Abbrechen\",\n    \"save\": \"Speichern\"\n  },\n  \"queryTitle\": \"Datenabfrage-Konfiguration\",\n  \"notSupport\": \"Nicht unterstützt\",\n  \"chart\": {\n    \"bar\": \"Balkendiagramm\",\n    \"line\": \"Liniendiagramm\",\n    \"pie\": \"Kreisdiagramm\",\n    \"area\": \"Flächendiagramm\",\n    \"table\": \"Tabelle\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Diagrammtyp auswählen\",\n      \"label\": \"Diagrammtyp\"\n    },\n    \"pie\": {\n      \"dimension\": \"Dimension\",\n      \"measure\": \"Messung\",\n      \"showTotal\": \"Gesamtsumme anzeigen\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"X-Achse\",\n        \"placeholder\": \"X-Achse auswählen\"\n      },\n      \"yAxis\": {\n        \"label\": \"Y-Achse\",\n        \"placeholder\": \"Y-Achse auswählen\",\n        \"position\": \"Y-Achse Position\"\n      },\n      \"xDisplay\": {\n        \"label\": \"X-Anzeige\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Y-Anzeige\"\n      },\n      \"addXAxis\": \"Serienaufschlüsselung hinzufügen\",\n      \"addYAxis\": \"Weitere Serie hinzufügen\",\n      \"stack\": \"Stapeln\",\n      \"position\": {\n        \"label\": \"Position\",\n        \"auto\": \"Automatisch\",\n        \"left\": \"Links\",\n        \"right\": \"Rechts\"\n      },\n      \"goalLine\": {\n        \"label\": \"Ziellinie\"\n      },\n      \"range\": {\n        \"label\": \"Bereich\",\n        \"min\": \"Minimum\",\n        \"max\": \"Maximum\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Linienstil\",\n        \"normal\": \"Normal\",\n        \"linear\": \"Linear\",\n        \"step\": \"Stufe\"\n      },\n      \"displayType\": \"Anzeigetyp\"\n    },\n    \"typeError\": \"Formular: Nicht unterstützter Diagrammtyp\",\n    \"updateQuery\": \"Abfrage aktualisieren\",\n    \"queryError\": \"Abfragefehler\",\n    \"querySuccess\": \"Abfrage wurde konfiguriert\",\n    \"decimal\": \"Dezimal\",\n    \"prefix\": \"Präfix\",\n    \"suffix\": \"Suffix\",\n    \"showLabel\": \"Wertebeschriftungen im Diagramm anzeigen\",\n    \"showLegend\": \"Legende anzeigen\",\n    \"value\": \"Wert\",\n    \"label\": \"Beschriftung\",\n    \"padding\": {\n      \"label\": \"Innenabstand\",\n      \"top\": \"Oben\",\n      \"right\": \"Rechts\",\n      \"bottom\": \"Unten\",\n      \"left\": \"Links\"\n    },\n    \"tableConfig\": \"Tabellenkonfiguration\",\n    \"width\": \"Breite\"\n  },\n  \"reloadQuery\": \"Abfrage neu laden\",\n  \"noStorage\": \"Bitte konfigurieren Sie zuerst das Diagramm-Plugin\",\n  \"noPermission\": \"Keine Zugriffsberechtigung\",\n  \"goConfig\": \"Zur Konfiguration gehen\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Aktionen\",\n    \"add\": \"Hinzufügen\",\n    \"save\": \"Speichern\",\n    \"doNotSave\": \"Nicht speichern\",\n    \"submit\": \"Abschicken\",\n    \"confirm\": \"Bestätigen\",\n    \"continue\": \"Fortfahren\",\n    \"close\": \"Schließen\",\n    \"edit\": \"Bearbeiten\",\n    \"fill\": \"Ausfüllen\",\n    \"update\": \"Aktualisieren\",\n    \"create\": \"Erstellen\",\n    \"delete\": \"Löschen\",\n    \"cancel\": \"Abbrechen\",\n    \"zoomIn\": \"Vergrößern\",\n    \"zoomOut\": \"Verkleinern\",\n    \"back\": \"Zurück\",\n    \"remove\": \"Entfernen\",\n    \"removeConfig\": \"Konfiguration entfernen\",\n    \"saveSucceed\": \"Speichern erfolgreich!\",\n    \"submitSucceed\": \"Abschicken erfolgreich!\",\n    \"editSucceed\": \"Bearbeiten erfolgreich!\",\n    \"updateSucceed\": \"Aktualisieren erfolgreich!\",\n    \"deleteSucceed\": \"Löschen erfolgreich!\",\n    \"resetSucceed\": \"Papierkorb erfolgreich gelöscht!\",\n    \"restoreSucceed\": \"Wiederherstellen erfolgreich!\",\n    \"loading\": \"Lade...\",\n    \"refreshPage\": \"Seite aktualisieren\",\n    \"yesDelete\": \"Ja, lösche\",\n    \"rename\": \"Umbenennen\",\n    \"duplicate\": \"Duplizieren\",\n    \"export\": \"Exportieren\",\n    \"import\": \"Importieren\",\n    \"change\": \"Ändern\",\n    \"upgrade\": \"Upgrade\",\n    \"upgradeToLevel\": \"Upgrade auf {{level}}\",\n    \"search\": \"Suche\",\n    \"loadMore\": \"Mehr laden\",\n    \"collapseSidebar\": \"Seitenleiste einklappen\",\n    \"restore\": \"Wiederherstellen\",\n    \"permanentDelete\": \"Permanent löschen\",\n    \"globalSearch\": \"Globale Suche\",\n    \"fieldSearch\": \"Suche nach Feld\",\n    \"tableIndex\": \"Index\",\n    \"showAllRow\": \"Zeige alle Reihen\",\n    \"hideNotMatchRow\": \"Nicht übereinstimmende Zeile ausblenden\",\n    \"more\": \"Mehr\",\n    \"expand\": \"Erweitern\",\n    \"view\": \"Ansehen\",\n    \"preview\": \"Vorschau\",\n    \"viewAndEdit\": \"Ansehen und bearbeiten\",\n    \"deleteTip\": \"Möchten Sie \\\"{{name}}\\\" wirklich löschen?\",\n    \"move\": \"Verschieben nach\",\n    \"turnOn\": \"Einschalten\",\n    \"exit\": \"Ausloggen\",\n    \"next\": \"Nächste\",\n    \"previous\": \"Vorherige\",\n    \"select\": \"Auswählen\",\n    \"refresh\": \"Aktualisieren\",\n    \"login\": \"Anmelden\",\n    \"useTemplate\": \"Vorlage verwenden\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Zurück zum Space\",\n    \"switchBase\": \"Base wechseln\",\n    \"getMore\": \"Mehr erhalten\",\n    \"copySuccess\": \"Kopieren erfolgreich\"\n  },\n  \"quickAction\": {\n    \"title\": \"Schnelle Aktionen\",\n    \"placeHolder\": \"Geben Sie einen Befehl ein oder suchen Sie...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"Das Passwort ist ungültig, besteht aus mindestens 8 Zeichen und muss mindestens einen Buchstaben und eine Zahl enthalten.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Teilen\",\n      \"copy\": \"Kopiert\"\n    },\n    \"aiTitle\": \"Lass uns zusammen bauen\",\n    \"aiGreeting\": \"Wie kann ich Ihnen helfen, {{name}}\",\n    \"aiSubTitle\": \"Die erste KI-Plattform, auf der Teams gemeinsam an Daten arbeiten und Produktions-Apps erstellen\",\n    \"guideTitle\": \"Beginnen Sie mit Ihrem Szenario\",\n    \"watchVideo\": \"Video ansehen\",\n    \"title\": \"Vorlage\",\n    \"description\": \"Erstellen Sie eine neue Datenbank aus einer Vorlage\",\n    \"browseAll\": \"Alle durchsuchen\",\n    \"templateTitle\": \"Mit Vorlage beginnen\",\n    \"loadMore\": \"Mehr laden\",\n    \"allTemplatesLoaded\": \"Alle Vorlagen geladen\",\n    \"createTemplate\": \"Vorlage erstellen\",\n    \"promptBox\": {\n      \"placeholder\": \"Erstellen Sie Ihre Business-App mit Teable\",\n      \"start\": \"Starten\",\n      \"carouselGuides\": {\n        \"guide1\": \"Quittungen einfügen → Teable bitten, Schlüsseldaten automatisch zu extrahieren\",\n        \"guide2\": \"Erstellen Sie ein KI-CRM mit Lead- und Follow-up-Tabellen\",\n        \"guide3\": \"Kundenfeedback einfügen → Teable bitten, Erkenntnisse zu finden und Berichte zu erstellen\",\n        \"guide4\": \"Erstellen Sie einen Projekt-Tracker mit Aufgaben und Fristen\",\n        \"guide5\": \"Leads-Tabelle einfügen → Teable bitten, ein intelligentes CRM zu erstellen\",\n        \"guide6\": \"Erstellen Sie einen Content-Planer mit Beiträgen und Veröffentlichungsdaten\",\n        \"guide7\": \"Lebensläufe einfügen → Teable bitten, Kandidaten zu organisieren und vorzusortieren\"\n      }\n    },\n    \"useTemplateDialog\": {\n      \"title\": \"Space auswählen\",\n      \"description\": \"Bitte wählen Sie einen Space für die Vorlage aus\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"In Bereich kopieren\",\n      \"description\": \"Bitte wählen Sie einen Bereich aus, in den diese Basis kopiert werden soll\",\n      \"baseName\": \"Basisname\",\n      \"baseNamePlaceholder\": \"Basisname eingeben\",\n      \"selectSpace\": \"Bereich auswählen\",\n      \"noSpaceDescription\": \"Keine verwaltbaren Bereiche. Bitte erstellen Sie einen neuen Bereich.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"In diesem Bereich gibt es keine verwaltbaren Basen. Bitte wählen Sie \\\"Neue Basis\\\".\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Instanzeinstellungen\",\n    \"personal\": {\n      \"title\": \"Persönliche Einstellungen\"\n    },\n    \"back\": \"Zurück zum Start\",\n    \"account\": {\n      \"title\": \"Mein Profil\",\n      \"tab\": \"Mein Konto\",\n      \"updatePhoto\": \"Photo aktualisieren\",\n      \"updateNameDesc\": \"Spitzname oder Vorname, je nachdem, wie du in Teable genannt werden möchtest\",\n      \"securityTitle\": \"Konto Sicherheit\",\n      \"email\": \"E-Mail\",\n      \"password\": \"Passwort\",\n      \"passwordDesc\": \"Legen Sie ein dauerhaftes Passwort für die Anmeldung bei Ihrem Konto fest.\",\n      \"changePassword\": {\n        \"title\": \"Passwort ändern\",\n        \"desc\": \"Bitte geben Sie Ihr aktuelles Passwort ein und legen Sie ein neues fest\",\n        \"current\": \"Aktuelles Passwort\",\n        \"new\": \"Neues Passwort\",\n        \"confirm\": \"Neues Passwort bestätigen\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Ihr neues Passwort stimmt nicht überein.\",\n        \"equal\": \"Ihr neues Passwort muss sich von Ihrem aktuellen Passwort unterscheiden.\",\n        \"invalid\": \"Ihr aktuelles Passwort ist ungültig.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Passwort erfolgreich geändert.\",\n        \"desc\": \"Sie werden in 2 Sekunden auf die Anmeldeseite weitergeleitet.\"\n      },\n      \"manageToken\": \"Zugriffs-Token\",\n      \"addPassword\": {\n        \"title\": \"Passwort hinzufügen\",\n        \"desc\": \"Legen Sie ein dauerhaftes Passwort für die Anmeldung bei Ihrem Konto fest.\",\n        \"password\": \"Geben Sie Ihr Passwort ein\",\n        \"confirm\": \"Bestätigen Sie Ihr Passwort\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Ihr Passwort stimmt nicht überein.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Passwort erfolgreich hinzugefügt.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Konto löschen\",\n        \"desc\": \"Diese Aktion ist unwiderruflich. Es wird Ihr Konto und alle zugehörigen Daten dauerhaft löschen.\",\n        \"error\": {\n          \"title\": \"Konto kann nicht gelöscht werden\",\n          \"desc\": \"Sie müssen zuerst die folgenden Abhängigkeiten behandeln:\",\n          \"spacesError\": \"Bevor Sie Ihr Konto löschen, müssen Sie zuerst Ihre Spaces verlassen (oder löschen und dann in den Papierkorb verschieben).\"\n        },\n        \"confirm\": {\n          \"title\": \"Bitte geben Sie <code>DELETE</code> zur Bestätigung ein\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Lösche...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"E-Mail-Adresse ändern\",\n        \"desc\": \"Bitte verifizieren Sie Ihr Passwort und bestätigen Sie Ihre neue E-Mail-Adresse\",\n        \"current\": \"Aktuelles Passwort\",\n        \"new\": \"Neue E-Mail\",\n        \"code\": \"Bestätigungscode\",\n        \"getCode\": \"Code senden\",\n        \"error\": {\n          \"invalidCode\": \"Der Bestätigungscode ist ungültig.\",\n          \"invalidPassword\": \"Das Passwort ist ungültig.\",\n          \"invalidConflict\": \"Die neue E-Mail-Adresse ist bereits registriert.\",\n          \"invalidSameEmail\": \"Die neue E-Mail-Adresse ist dieselbe wie die aktuelle.\",\n          \"sendMailRateLimit\": \"Bitte warten Sie {{seconds}} Sekunden, bevor Sie eine neue E-Mail senden\"\n        },\n        \"success\": {\n          \"title\": \"🎉 E-Mail erfolgreich geändert.\",\n          \"desc\": \"Sie werden in 2 Sekunden zur Anmeldeseite weitergeleitet.\",\n          \"sendSuccess\": \"Bestätigungscode erfolgreich gesendet.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Meine Benachrichtigungen\",\n      \"label\": \"Aktivität in Ihrem Space\",\n      \"desc\": \"Sie erhalten E-Mails, wenn Sie Kommentare, Erwähnungen, Seiteneinladungen, Erinnerungen, Zugriffsanfragen und Eigentumsänderungen erhalten.\"\n    },\n    \"setting\": {\n      \"title\": \"Einstellungen\",\n      \"theme\": \"Benutzeroberflächen-Theme\",\n      \"themeDesc\": \"Wählen Sie Ihr Farbschema für die Benutzeroberfläche\",\n      \"dark\": \"Dunkel\",\n      \"light\": \"Hell\",\n      \"system\": \"System\",\n      \"version\": \"App Version\",\n      \"language\": \"Sprache\",\n      \"interactionMode\": \"Interaktionsmodus\",\n      \"mouseMode\": \"Cursor-Modus\",\n      \"touchMode\": \"Touch-Modus\",\n      \"systemMode\": \"Systemeinstellung folgen\"\n    },\n    \"nav\": {\n      \"settings\": \"Einstellungen\",\n      \"logout\": \"Ausloggen\",\n      \"contactSupport\": \"Support kontaktieren\"\n    },\n    \"integration\": {\n      \"title\": \"Integrationen\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"Drittanbieter-Integrationen\",\n        \"description\": \"Sie haben {{count}} Anwendungen Zugriff auf Ihr Konto gewährt.\",\n        \"lastUsed\": \"Zuletzt verwendet am {{date}}\",\n        \"revoke\": \"Widerrufen\",\n        \"owner\": \"Im Besitz von {{user}}\",\n        \"revokeTitle\": \"Sind Sie sicher, dass Sie die Genehmigung widerrufen wollen?\",\n        \"revokeDesc\": \"{{name}} wird nicht mehr auf die Teable-API zugreifen können. Sie können diese Aktion nicht rückgängig machen.\",\n        \"scopeTitle\": \"Berechtigungen\",\n        \"scopeDesc\": \"Diese Anwendung wird die folgenden Bereiche abrufen können:\"\n      },\n      \"userIntegration\": {\n        \"title\": \"Verbundene Konten\",\n        \"description\": \"Verbinden Sie externe Konten, um Teable den Zugriff auf Ihre Ressourcen zu ermöglichen.\",\n        \"emptyDescription\": \"Keine verbundenen Konten\",\n        \"actions\": {\n          \"reconnect\": \"Erneut verbinden\"\n        },\n        \"slack\": {\n          \"user\": \"Slack-Benutzer\",\n          \"workspace\": \"Slack-Arbeitsbereich\"\n        },\n        \"email\": {\n          \"user\": \"Benutzer\",\n          \"email\": \"E-Mail\"\n        },\n        \"deleteTitle\": \"Verbundenes Konto entfernen\",\n        \"deleteDesc\": \"Möchten Sie {{name}} wirklich entfernen?\",\n        \"create\": \"Neues Konto verbinden\",\n        \"manage\": \"Verbundene Konten verwalten\",\n        \"searchPlaceholder\": \"Verbundene Konten suchen\",\n        \"defaultName\": \"{{name}} Integration\",\n        \"callback\": {\n          \"error\": \"Autorisierung fehlgeschlagen\",\n          \"title\": \"Autorisierung erfolgreich\",\n          \"desc\": \"Sie können dieses Fenster jetzt schließen.\"\n        }\n      }\n    },\n    \"templateAdmin\": {\n      \"title\": \"Vorlagenverwaltung\",\n      \"noData\": \"Keine Daten\",\n      \"importing\": \"Importiere...\",\n      \"usageCount\": \"Nutzungsanzahl: {{count}}\",\n      \"useTemplate\": \"Diese Vorlage verwenden\",\n      \"createdBy\": \"von {{user}}\",\n      \"backToTemplateList\": \"Zurück zur Vorlagenliste\",\n      \"tips\": {\n        \"errorCategoryName\": \"Kategorie existiert nicht oder wurde gelöscht\",\n        \"needSnapshot\": \"Bitte erstellen Sie einen Snapshot vor der Veröffentlichung, und der Vorlagenname und die Beschreibung dürfen nicht leer sein\",\n        \"needPublish\": \"Bitte veröffentlichen Sie die Vorlage, bevor Sie sie hervorheben\",\n        \"needBaseSource\": \"Bitte wählen Sie eine Base-Quelle, bevor Sie einen Snapshot erstellen\",\n        \"forbiddenUpdateSystemTemplate\": \"Systemvorlagen können nicht geändert werden\",\n        \"addCategoryTips\": \"Bitte geben Sie zuerst einen Kategorienamen in das Suchfeld ein.\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Erste Schritte\",\n          \"recommended\": \"Empfohlen\",\n          \"all\": \"Alle\",\n          \"browseByCategory\": \"Nach Kategorie durchsuchen\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Umschlag\",\n        \"name\": \"Name\",\n        \"description\": \"Beschreibung\",\n        \"markdownDescription\": \"Markdown-Beschreibung\",\n        \"category\": \"Kategorie\",\n        \"isSystem\": \"System\",\n        \"source\": \"Quelle\",\n        \"status\": \"Veröffentlicht\",\n        \"publishSnapshot\": \"Snapshot veröffentlichen\",\n        \"snapshotTime\": \"Snapshot-Zeit\",\n        \"actions\": \"Aktionen\",\n        \"featured\": \"Hervorgehoben\",\n        \"createdBy\": \"Erstellt von\",\n        \"userNonExistent\": \"Benutzer existiert nicht\",\n        \"preview\": \"Vorschau\",\n        \"usage\": \"Nutzung\",\n        \"visit\": \"Besuche\"\n      },\n      \"actions\": {\n        \"title\": \"Aktionen\",\n        \"publish\": \"Veröffentlichen\",\n        \"delete\": \"Löschen\",\n        \"duplicate\": \"Duplizieren\",\n        \"preview\": \"Vorschau\",\n        \"use\": \"Verwenden\",\n        \"pinTop\": \"Oben anheften\",\n        \"addCategory\": \"Kategorie hinzufügen\",\n        \"selectCategory\": \"Kategorie auswählen\",\n        \"viewTemplate\": \"Vorlage anzeigen\",\n        \"manageCategory\": \"Kategorien verwalten\"\n      },\n      \"relatedTemplates\": \"Verwandte Vorlagen\",\n      \"noImage\": \"Kein Bild\",\n      \"baseSelectPanel\": {\n        \"title\": \"Vorlagenquelle auswählen\",\n        \"description\": \"Wählen Sie eine Base als Vorlage\",\n        \"confirm\": \"Bestätigen\",\n        \"search\": \"Suchen...\",\n        \"cancel\": \"Abbrechen\",\n        \"selectBase\": \"Base auswählen\",\n        \"createTemplate\": \"Vorlage erstellen\",\n        \"abnormalBase\": \"Base existiert nicht oder wurde gelöscht\"\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tabelle\",\n    \"view\": \"Ansicht\",\n    \"space\": \"Space\",\n    \"base\": \"Base\",\n    \"field\": \"Feld\",\n    \"record\": \"Datensatz\",\n    \"dashboard\": \"Dashboard\",\n    \"automation\": \"Automatisierung\",\n    \"authorityMatrix\": \"Authority Matrix\",\n    \"design\": \"Design\",\n    \"adminPanel\": \"Systemverwaltung\",\n    \"license\": \"Selbst-gehostete Lizenz\",\n    \"instanceId\": \"Instanz ID\",\n    \"beta\": \"Beta\",\n    \"trash\": \"Papierkorb\",\n    \"global\": \"Global\",\n    \"organizationPanel\": \"Organisationseinstellungen\",\n    \"unknownError\": \"Unbekannter Fehler\",\n    \"pluginPanel\": \"Panel\",\n    \"pluginContextMenu\": \"Kontextmenü\",\n    \"plugin\": \"Plugins\",\n    \"copy\": \"Kopie\",\n    \"credits\": \"Credits\",\n    \"aiChat\": \"KI-Chat\",\n    \"app\": \"App\",\n    \"webSearch\": \"Websuche\",\n    \"folder\": \"Ordner\",\n    \"newAutomation\": \"Neue Automatisierung\",\n    \"newApp\": \"Neue App\",\n    \"newFolder\": \"Neuer Ordner\",\n    \"template\": \"Vorlage\"\n  },\n  \"level\": {\n    \"free\": \"Free\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"Business\",\n    \"enterprise\": \"Enterprise\"\n  },\n  \"noResult\": \"Kein Ergebnis.\",\n  \"allNodes\": \"Alle Knoten\",\n  \"noDescription\": \"Keine Beschreibung\",\n  \"untitled\": \"Ohne Titel\",\n  \"name\": \"Name\",\n  \"description\": \"Beschreibung\",\n  \"required\": \"Notwendig\",\n  \"characters\": \"Zeichen\",\n  \"atLeastOne\": \"Reservieren Sie zumindest ein {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Vorherige\",\n    \"next\": \"Nächste\",\n    \"done\": \"Erledigt\",\n    \"skip\": \"Überspringen\",\n    \"createSpaceTooltipTitle\": \"Einen Space erstellen\",\n    \"createSpaceTooltipContent\": \"Teable ist in Spaces gegliedert, die zur Zusammenarbeit einladen.<br></br>Die Spaces in Teable dienen als primäres Navigationselement in der Menüleiste und bieten eine grundlegende Plattform für Benutzer, die Datenbanken nach Bedarf hinzufügen und verwalten möchten.\",\n    \"createBaseTooltipTitle\": \"Eine Base erstellen\",\n    \"createBaseTooltipContent\": \"Eine Base (kurz für \\\"database\\\") ist ein Ort, an dem wichtige Daten und die davon abhängigen Arbeitsabläufe gespeichert werden.\",\n    \"createTableTooltipTitle\": \"Eine Tabelle erstellen\",\n    \"createTableTooltipContent\": \"Tabellen sind für den effizienten Umgang mit verschiedenen Datensätzen konzipiert und bieten eine vielseitige Anzeige von Informationen durch verschiedene Datentypen.\",\n    \"createViewTooltipTitle\": \"Eine Ansicht erstellen\",\n    \"createViewTooltipContent\": \"Gegenwärtig können Benutzer Raster-, Galerie-, Kanban- und Formularansichten erstellen, wobei Kalenderansichten für zukünftige Versionen geplant sind. <br></br>Durch diese Vielfalt verfügen die Benutzer über ein umfassendes Toolkit für verschiedene Datenverwaltungsaufgaben.\",\n    \"viewFilteringTooltipTitle\": \"Datensätze filtern\",\n    \"viewFilteringTooltipContent\": \"Eine der Hauptfunktionen von Ansichten ist die Möglichkeit, Datensätze aus einer Ansicht entsprechend den von Ihnen festgelegten Bedingungen herauszufiltern. <br></br>Wenn ein Datensatz auf der Grundlage einer Bedingung herausgefiltert wird, wird er nicht gelöscht, sondern nur aus der Ansicht ausgeblendet, die Sie für die Anzeige Ihrer Tabelle verwenden.\",\n    \"viewSortingTooltipTitle\": \"Datensätze sortieren\",\n    \"viewSortingTooltipContent\": \"In einer Ansicht können Sie Ihre Datensätze so sortieren, dass sie in einer bestimmten Reihenfolge nach den Werten in bestimmten Feldern erscheinen. <br></br>Das Sortieren Ihrer Datensätze in einer Ansicht wirkt sich nicht auf die Reihenfolge der Datensätze in anderen Ansichten aus - es gilt nur für die Ansicht, in der Sie gerade Ihre Tabelle betrachten.\",\n    \"viewGroupingTooltipTitle\": \"Datensätze gruppieren\",\n    \"viewGroupingTooltipContent\": \"Die Gruppierung von Datensätzen ermöglicht es den Erstellern, eine oder mehrere Bedingungen festzulegen, die zur Kategorisierung des Datensatzes beitragen, der in einer bestimmten Ansicht dargestellt wird.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable bietet eine leistungsstarke API, die fast alle Produktfunktionen unterstützt und es Entwicklern ermöglicht, Aufrufe mit einem <a>Token</a> zu tätigen.\"\n  },\n  \"token\": \"Token\",\n  \"poweredBy\": \"Powered by <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"{{spaceName}} Space teilen\",\n      \"desc_one\": \"Dieser Space hat <b>{{count}} Kollaborateure</b>. Das Hinzufügen eines Space Kollaborateurs gibt ihm Zugang zu allen Bases in diesem Space.\",\n      \"desc_other\": \"Dieser Space hat <b>{{count}} Kollaborateure</b>. Das Hinzufügen eines Space Kollaborateurs gibt ihm Zugang zu allen Bases in diesem Space.\",\n      \"tabEmail\": \"Per E-Mail einladen\",\n      \"emailPlaceholder\": \"Weitere Kollaborateure per E-Mail einladen\",\n      \"tabLink\": \"Per Link einladen\",\n      \"linkPlaceholder\": \"Erstellen Sie einen Einladungslink, der jedem, der ihn öffnet, <0/> Zugang gewährt.\",\n      \"emailSend\": \"Einladung senden\",\n      \"linkSend\": \"Link erstellen\",\n      \"spaceTitle\": \"Space Kollaborateure\",\n      \"collaboratorSearchPlaceholder\": \"Finde einen Space Kollaborateur via Name oder E-Mail\",\n      \"collaboratorJoin\": \"beigetreten am {{joinTime}}\",\n      \"collaboratorRemove\": \"Kollaborateur entfernen\",\n      \"linkTitle\": \"Einladungslinks\",\n      \"linkCreatedTime\": \"erstellt am {{createdTime}}\",\n      \"linkCopySuccess\": \"Link kopiert\",\n      \"linkRemove\": \"Link entfernen\"\n    },\n    \"base\": {\n      \"title\": \"{{baseName}} teilen\",\n      \"desc_one\": \"Diese Base wird mit {{count}} Kollaborateur geteilt.\",\n      \"desc_other\": \"Diese Base wird mit {{count}} Kollaborateuren geteilt.\",\n      \"baseTitle\": \"Base Kollaborateure\",\n      \"collaboratorSearchPlaceholder\": \"Finde einen Base Kollaborateur via Name oder E-Mail\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Organisationskollaborateur hinzufügen\",\n      \"placeholder\": \"Organisationskollaborateur oder Abteilung auswählen\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Hilfe\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Die Berechtigungen für die Seite wurden aktualisiert. Bitte aktualisieren Sie die Seite, um den neuesten Inhalt zu sehen.\",\n  \"listEmptyTips\": \"Die Liste ist leer\",\n  \"billing\": {\n    \"overLimits\": \"Limits überschritten\",\n    \"overLimitsDescription\": \"Ihr aktuelles Abonnement hat sein Nutzungslimit überschritten. Bitte aktualisieren Sie Ihr Abonnement, um diese Funktion weiterhin ohne Unterbrechung nutzen zu können.\",\n    \"userLimitExceededDescription\": \"Die aktuelle Instanz hat die maximale Anzahl von Benutzern erreicht, die Ihre Lizenz erlaubt. Bitte deaktivieren Sie einige Benutzer oder aktualisieren Sie die Lizenz.\",\n    \"unavailableInPlanTips\": \"Der aktuelle Abonnementplan unterstützt diese Funktion nicht\",\n    \"unavailableConnectionTips\": \"Die Datenbankverbindungsfunktion wird in Zukunft entfernt und ist nur in der Enterprise-Edition von öffentlicher Cloud und selbstgehosteten Versionen verfügbar.\",\n    \"levelTips\": \"Dieser Space befindet sich derzeit auf dem {{level}} Plan\",\n    \"enterpriseFeature\": \"Enterprise-Funktion\",\n    \"automationRequiresUpgrade\": \"Upgrade auf Enterprise Edition (EE) um Automatisierung zu aktivieren\",\n    \"authorityMatrixRequiresUpgrade\": \"Upgrade auf Enterprise Edition (EE) um Berechtigungsmatrix zu aktivieren\",\n    \"viewPricing\": \"Preise anzeigen\",\n    \"billable\": \"Abrechenbar\",\n    \"billableByAuthorityMatrix\": \"Abrechnung durch Berechtigungsmatrix generiert\",\n    \"licenseExpiredGracePeriod\": \"Ihre Self-Hosted-Lizenz ist abgelaufen und wird am {{expiredTime}} auf den kostenlosen Plan herabgestuft. Bitte aktualisieren Sie Ihre Lizenz umgehend, um Zugriff auf Premium-Funktionen zu behalten.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Upgrade des Space-Abonnementplans\",\n      \"description\": \"Sie können nur Arbeitsbereiche aktualisieren, deren Eigentümer Sie sind.\"\n    },\n    \"status\": {\n      \"active\": \"Aktiv\",\n      \"canceled\": \"Storniert\",\n      \"incomplete\": \"Unvollständig\",\n      \"incompleteExpired\": \"Unvollständig Abgelaufen\",\n      \"trialing\": \"Testphase\",\n      \"pastDue\": \"Überfällig\",\n      \"unpaid\": \"Unbezahlt\",\n      \"paused\": \"Pausiert\",\n      \"seatLimitExceeded\": \"Anzahl der Sitzplätze überschritten\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Instanzeinstellungen\",\n      \"description\": \"Ändern Sie die Einstellungen für Ihre aktuelle Instanz\",\n      \"allowSignUp\": \"Erstellen neuer Konten zulassen\",\n      \"allowSignUpDescription\": \"Wenn Sie diese Option deaktivieren, werden neue Benutzerregistrierungen verhindert, und die Schaltfläche „Registrieren“ wird nicht mehr auf der Anmeldeseite angezeigt.\",\n      \"allowSpaceInvitation\": \"Erlauben Sie das Versenden von Space-Einladungen\",\n      \"allowSpaceInvitationDescription\": \"Die Deaktivierung dieser Option verhindert, dass andere Benutzer als Administratoren andere Benutzer zu Spaces einladen können. Wenn aktiviert, können neue Benutzer, die per E-Mail eingeladen wurden, die Registrierung durch Klicken auf den Einladungslink in der E-Mail abschließen, aber freigegebene Einladungslinks funktionieren nicht.\",\n      \"allowSpaceCreation\": \"Jedem die Möglichkeit geben, neue Space anzulegen\",\n      \"allowSpaceCreationDescription\": \"Wenn Sie diese Option deaktivieren, können andere Benutzer als Administratoren keine neuen Spaces erstellen.\",\n      \"enableEmailVerification\": \"Aktivieren der E-Mail-Überprüfung\",\n      \"enableEmailVerificationDescription\": \"Die Aktivierung dieser Option erfordert, dass Benutzer ihre E-Mail-Adresse bei der Erstellung eines neuen Kontos verifizieren.\",\n      \"enableWaitlist\": \"Aktivieren Sie die Warteliste\",\n      \"enableWaitlistDescription\": \"Wenn Sie diese Option aktivieren, können Benutzer sich nur mit einem Einladungscode registrieren.\",\n      \"generalSettings\": \"Allgemeine Einstellungen\",\n      \"aiSettings\": \"AI Konfiguration\",\n      \"brandingSettings\": {\n        \"title\": \"Branding-Einstellungen\",\n        \"description\": \"Nur in der Enterprise Edition verfügbar\",\n        \"brandName\": \"Markenname\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"Das Logo ist Ihre Markenidentität in Teable.\",\n        \"logoUpload\": \"Logo hochladen\",\n        \"logoUploadDescription\": \"Logo-Bild hochladen, unterstützt PNG, JPEG-Format, empfohlene Größe ist 100x100px. Maximale Upload-Größe ist 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Name\",\n        \"nameDescription\": \"Der Name des LLM-Anbieters\",\n        \"enable\": \"AI aktivieren\",\n        \"enableDescription\": \"Aktivieren Sie AI für die aktuelle Instanz, dann können alle Benutzer die AI-Funktionen nutzen.\",\n        \"updateLLMProvider\": \"LLM-Anbieter aktualisieren\",\n        \"addProvider\": \"LLM-Anbieter hinzufügen\",\n        \"addProviderDescription\": \"Einen neuen LLM-Anbieter zur Liste hinzufügen\",\n        \"providerType\": \"Anbietertyp\",\n        \"baseUrl\": \"Basis-URL\",\n        \"apiKey\": \"API Schlüssel\",\n        \"baseUrlDescription\": \"Die Basis-URL des LLM-Anbieters\",\n        \"apiKeyDescription\": \"Der API-Schlüssel des LLM-Anbieters\",\n        \"models\": \"Modelle\",\n        \"modelsDescription\": \"Die vom LLM-Anbieter unterstützten Modelle\",\n        \"baseUrlRequired\": \"Basis-URL ist erforderlich\",\n        \"fetchModelListError\": \"Modellliste konnte nicht abgerufen werden\",\n        \"provider\": \"LLM-Anbieter\",\n        \"providerDescription\": \"Der zu verwendende LLM-Anbieter\",\n        \"modelPreferences\": \"Modellpräferenzen\",\n        \"modelPreferencesDescription\": \"Die Modellpräferenzen für den LLM-Anbieter\",\n        \"embeddingModel\": \"Einbettungsmodell\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Übersetzungsmodell\",\n        \"translationModelDescription\": \"Das zu verwendende Übersetzungsmodell\",\n        \"chatModel\": \"Chat-Modell\",\n        \"chatModelDescription\": \"Das zu verwendende Chat-Modell, Tipp: Das mittlere Kodierungsmodell wird standardmäßig für AI-Formelgenerierung und verwandte Funktionen verwendet\",\n        \"chatModels\": {\n          \"lg\": \"Erweiteres Chat-Modell\",\n          \"lgDescription\": \"Für Planung, Programmierung und andere komplexe Aufgabenszenarien. Empfohlen: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"KI-Fähigkeiten\",\n          \"aiField\": {\n            \"title\": \"KI-Feld\",\n            \"description\": \"KI-Feldfunktionen einschließlich automatischer Ausfüllung und KI-Konfiguration in Feldeinstellungen\"\n          },\n          \"aiChat\": {\n            \"title\": \"KI-Chat\",\n            \"description\": \"KI-Chat-Seitenleiste und alle Agent-Funktionen\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Modell testen\",\n          \"description\": \"Testen Sie die Fähigkeit des Modells, Bilder und PDFs zu verarbeiten\",\n          \"notConfigLgModel\": \"Bitte konfigurieren Sie zuerst das große Modell\",\n          \"confirmTitle\": \"Modellfähigkeiten testen\",\n          \"confirmDescription\": \"Möchten Sie die Fähigkeiten des großen Chat-Modells testen?\",\n          \"confirm\": \"Modell testen\",\n          \"cancel\": \"Abbrechen\",\n          \"missingCapabilitiesWarning\": \"Dieses Modell unterstützt keine Bild- oder PDF-Verarbeitung, was dazu führen kann, dass einige Funktionen nicht verfügbar sind.\",\n          \"enableAITitle\": \"AI aktivieren\",\n          \"enableAIDescription\": \"Modelltest abgeschlossen. AI ist derzeit nicht aktiviert. Möchten Sie AI aktivieren, um diese Modellfähigkeiten zu nutzen?\",\n          \"enableAI\": \"AI aktivieren\",\n          \"skipTest\": \"Überspringen\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Bild\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Websuche\",\n          \"disabledWebSearch\": \"Websuche deaktiviert\",\n          \"lgModelAbility\": \"Große Modellfähigkeit\"\n        },\n        \"configUpdated\": \"AI-Konfiguration aktualisiert\",\n        \"noModelFound\": \"Kein Modell gefunden.\",\n        \"searchModel\": \"Suche Modell...\",\n        \"selectModel\": \"Wähle Modell...\",\n        \"input\": \"Eingabe {{ratio}}\",\n        \"output\": \"Ausgabe {{ratio}}\",\n        \"inputOrOutputTip\": \"Das Verhältnis stellt den Wechselkurs zwischen Credits und Tokens dar. Beispielsweise bedeutet „1:1000“, dass 1 Credit ungefähr 1000 Tokens entspricht.<br></br>Hinweis: Bei jeder Nutzung wird mindestens 1 Credit abgezogen. Auch wenn der tatsächliche Verbrauch weniger als 1 Credit beträgt, wird er dennoch als 1 Credit abgerechnet.\",\n        \"imageOutput\": \"Pro Bild {{credits}}\",\n        \"imageOutputTip\": \"Bildgenerierung erfordert Credits, jedes Bild verbraucht {{credits}} Credits\",\n        \"supportImageOutputTip\": \"Dieses Modell unterstützt Bildgenerierung\",\n        \"supportVisionTip\": \"Dieses Modell unterstützt Bildeingabe\",\n        \"supportAudioTip\": \"Dieses Modell unterstützt Audioeingabe\",\n        \"supportVideoTip\": \"Dieses Modell unterstützt Videoeingabe\",\n        \"supportDeepThinkTip\": \"Dieses Modell unterstützt tiefes Denken\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Testen...\",\n        \"testSuccess\": \"Test erfolgreich\",\n        \"testFailed\": \"Test fehlgeschlagen\",\n        \"fillRequiredFields\": \"Bitte füllen Sie alle erforderlichen Felder aus\",\n        \"modelsRequired\": \"Bitte füllen Sie mindestens ein Modell aus\",\n        \"noValidModel\": \"Kein gültiges Modell gefunden\",\n        \"addCustomModel\": \"Benutzerdefiniertes Modell hinzufügen\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Konfigurieren Sie den Firecrawl API-Schlüssel, um die Websuchfunktion zu aktivieren. Gehen Sie zu <a>Firecrawl-Einstellungen</a>, um den API-Schlüssel zu erhalten\"\n      },\n      \"app\": {\n        \"domain\": \"Domain\",\n        \"v0ApiKey\": \"v0 API-Schlüssel\",\n        \"customDomain\": \"Benutzerdefinierte Domain (Optional)\",\n        \"customDomainDescription\": \"Legen Sie Ihre benutzerdefinierte Domain für die App-Bereitstellung fest, besuchen Sie <a>Vercel Domain</a> um eine benutzerdefinierte Domain zu erhalten\",\n        \"vercelToken\": \"Vercel API Token\",\n        \"vercelTokenDescription\": \"Besuchen Sie <a>Vercel Einstellungen</a> um das API Token zu erhalten\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"API-Schlüssel eingeben\",\n      \"goToConfiguration\": \"Zur Konfiguration gehen\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Vielen Dank für die Nutzung von teable\",\n      \"pleaseGoToConfiguration\": \"Bitte gehen Sie zur Einstellungsseite, um einige anfängliche Konfigurationen abzuschließen, um die volle Funktionalität und bessere Benutzererfahrung von teable zu genießen\",\n      \"pleaseContactAdmin\": \"Bitte wenden Sie sich an den Administrator\"\n    },\n    \"configuration\": {\n      \"title\": \"Ausstehende Konfiguration\",\n      \"description\": \"Schließen Sie diese Konfigurationen ab, um vollständige Funktionen zu erhalten\",\n      \"copyInstance\": \"ID kopieren\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"PUBLIC_ORIGIN Umgebungsvariable\",\n          \"description\": \"Ihre <strong>PUBLIC_ORIGIN</strong> Umgebungsvariablenkonfiguration stimmt nicht mit der aktuellen Zugriffsadresse <underline>https://example.com</underline> überein. Import von xlsx, csv und Anhangfeldfunktionen funktionieren möglicherweise nicht ordnungsgemäß. Es wird empfohlen, sie auf <underline>https://example.com</underline> zu setzen\"\n        },\n        \"https\": {\n          \"title\": \"HTTPS aktivieren\",\n          \"description\": \"Sie haben HTTPS nicht aktiviert. Die großmaßstäbliche Kopierfunktion (300 Zeilen oder mehr) ist nicht verfügbar. Es wird empfohlen, sie zu aktivieren.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"PUBLIC_DATABASE_PROXY Umgebungsvariable\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> ist nicht konfiguriert. Die externe Datenbankverbindungsfunktion ist nicht verfügbar. Bitte beachten Sie das <a>Hilfedokument</a>\",\n          \"href\": \"https://help.teable.ai/de/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Sie haben die AI LLM API noch nicht konfiguriert. AI Chat/AI Automatisierung kann nicht verwendet werden, <anchor>zu den Einstellungen gehen</anchor>\",\n          \"errorTips\": \"Sie haben die AI LLM API noch nicht konfiguriert. AI Chat/AI Automatisierung kann nicht verwendet werden\"\n        },\n        \"app\": {\n          \"title\": \"App-Builder\",\n          \"description\": \"Sie haben die v0 API noch nicht konfiguriert. Die App-Builder-Funktion ist nicht verfügbar, <anchor>zu den Einstellungen gehen</anchor>\",\n          \"errorTips\": \"Sie haben die v0 API noch nicht konfiguriert. Die App-Builder-Funktion ist nicht verfügbar\"\n        },\n        \"webSearch\": {\n          \"title\": \"Websuche\",\n          \"description\": \"Sie haben die Websuche-API noch nicht konfiguriert. Die Websuchfunktion ist nicht verfügbar, <anchor>zu den Einstellungen gehen</anchor>\",\n          \"errorTips\": \"Sie haben die Websuche-API noch nicht konfiguriert. Die Websuchfunktion ist nicht verfügbar\"\n        },\n        \"email\": {\n          \"title\": \"E-Mail\",\n          \"description\": \"E-Mail nicht konfiguriert. Selbstbedienungs-Passwort-Wiederherstellung und E-Mail-Benachrichtigungsfunktion sind nicht verfügbar, <anchor>zu den Einstellungen gehen</anchor>\",\n          \"errorTips\": \"E-Mail nicht konfiguriert. Selbstbedienungs-Passwort-Wiederherstellung, E-Mail-Verifizierung/Benachrichtigungsfunktion sind nicht verfügbar\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Benachrichtigungen\",\n    \"unread\": \"Ungelesen\",\n    \"read\": \"Gelesen\",\n    \"markAs\": \"Markieren Sie diese Meldung als {{status}}\",\n    \"markAllAsRead\": \"Alles als gelesen markieren\",\n    \"noUnread\": \"Keine Benachrichtigungen mit Status: {{status}}\",\n    \"changeSetting\": \"Einstellungen für Seitenbenachrichtigungen ändern\",\n    \"new\": \"{{count}} neue\",\n    \"showMore\": \"Zeige mehr\",\n    \"exportBase\": {\n      \"successText\": \"Exportdaten sind bereit\",\n      \"failedText\": \"Export fehlgeschlagen, bitte erneut versuchen\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Eigentümer\",\n      \"creator\": \"Ersteller\",\n      \"editor\": \"Editor\",\n      \"commenter\": \"Kommentator\",\n      \"viewer\": \"Betrachter\"\n    },\n    \"description\": {\n      \"owner\": \"Vollständige Konfiguration und Bearbeitung von Bases, Automatisierungen, Authority Matrizen und Verwaltung von Space Einstellungen und Abrechnung\",\n      \"creator\": \"Vollständige Konfiguration und Bearbeitung von Bases, Automatisierungen und Authority Matrizen\",\n      \"editor\": \"Kann Datensätze und Ansichten bearbeiten, aber keine Tabellen oder Felder konfigurieren\",\n      \"commenter\": \"Kann Datensätze kommentieren\",\n      \"viewer\": \"Kann nicht bearbeiten oder kommentieren\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Space Papierkorb\",\n    \"type\": \"Typ\",\n    \"resetTrash\": \"Papierkorb leeren\",\n    \"deletedBy\": \"Gelöscht von\",\n    \"deletedTime\": \"Gelöscht Zeit\",\n    \"fromSpace\": \"Von \\\"{{name}}\\\" Space\",\n    \"permanentDeleteTips\": \"Sind Sie sicher, dass Sie \\\"{{name}}\\\" {{resource}}? dauerhaft löschen möchten\",\n    \"resetTrashConfirm\": \"Sind Sie sicher, dass Sie den Papierkorb leeren wollen?\",\n    \"addToTrash\": \"Zum Papierkorb verschieben\",\n    \"description\": \"Daten im Papierkorb belegen immer noch Speicherplatz für Datensätze und Anhänge.\",\n    \"spaceDescription\": \"Stellen Sie Spaces wieder her, die in den letzten {{retentionDays}} Tagen gelöscht wurden\",\n    \"spaceInnerDescription\": \"Stellen Sie Bases wieder her, die in den letzten {{retentionDays}} Tagen aus diesem Space gelöscht wurden\",\n    \"baseDescription\": \"Stellen Sie Ressourcen wieder her, die in den letzten {{retentionDays}} Tagen aus dieser Base gelöscht wurden\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Plugin hat keine Url\",\n    \"install\": \"Installieren\",\n    \"publisher\": \"Herausgeber\",\n    \"lastUpdated\": \"Zuletzt aktualisiert\",\n    \"pluginNotFound\": \"Plugin nicht gefunden\",\n    \"pluginEmpty\": {\n      \"title\": \"Noch keine Plugins\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Möchten Sie die aktuelle Automatisierung einschalten?\"\n  },\n  \"email\": {\n    \"send\": \"Senden\",\n    \"config\": \"E-Mail-Konfiguration\",\n    \"customConfig\": \"Benutzerdefinierter E-Mail-Server\",\n    \"notify\": \"Benachrichtigungs-E-Mail\",\n    \"automation\": \"Automatisierungs-E-Mail\",\n    \"customNotifyConfig\": \"Benutzerdefinierte Benachrichtigungs-E-Mail-Konfiguration\",\n    \"customAutomationConfig\": \"Benutzerdefinierte Automatisierungs-E-Mail-Konfiguration\",\n    \"addConfig\": \"Konfiguration hinzufügen\",\n    \"editConfig\": \"Konfiguration bearbeiten\",\n    \"resetConfig\": \"Zurücksetzen\",\n    \"testEmail\": \"Test-E-Mail\",\n    \"testEmailPlaceholder\": \"Geben Sie die E-Mail-Adressen ein und trennen Sie sie mit der Eingabetaste\",\n    \"testEmailError\": \"Bitte korrekte Test-E-Mail-Adresse eingeben\",\n    \"testEmailSend\": \"Test-E-Mail erfolgreich gesendet, bitte überprüfen Sie Ihr E-Mail-Postfach\",\n    \"configError\": \"Bitte korrekte E-Mail-Konfiguration eingeben\",\n    \"host\": \"Server-Adresse\",\n    \"hostDescription\": \"Bitte SMTP-E-Mail-Server-Adresse eingeben, zum Beispiel: smtp.example.com\",\n    \"port\": \"Port\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Authentifizierung\",\n    \"username\": \"Benutzername\",\n    \"password\": \"Passwort\",\n    \"sender\": \"Absender-Adresse\",\n    \"senderName\": \"Absender-Name\",\n    \"subscribe\": \"Abonnieren\",\n    \"unsubscribe\": \"Abbestellen\",\n    \"unsubscribeList\": \"Abbestellliste\",\n    \"unsubscribeTime\": \"Abbestellzeit\",\n    \"source\": \"Quelle\",\n    \"sourceAutomationDeleted\": \"Automatisierung oder Knoten gelöscht\",\n    \"processing\": \"Verarbeitung...\",\n    \"unsubscribeH1\": \"Abmeldung bestätigen?\",\n    \"unsubscribeH2\": \"Sie sind dabei, sich von zukünftigen Teable-Werbeaktionen und Produktaktualisierungen abzumelden. Möchten Sie sich wirklich abmelden?\",\n    \"subscribeH1\": \"Abonnement bestätigen?\",\n    \"subscribeH2\": \"Sie sind dabei, zukünftige Teable-Werbeaktionen und Produktaktualisierungen zu abonnieren. Möchten Sie wirklich abonnieren?\",\n    \"unsubscribeListTip\": \"Die folgenden Benutzer in der aktuellen Basis haben sich abgemeldet, Sie können ihnen keine E-Mails mehr senden. Beim Importieren von E-Mails platzieren Sie bitte die E-Mail-Adressen in der ersten Spalte und benennen Sie die Kopfzeile \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Passwort zurücksetzen - {{brandName}}\",\n        \"title\": \"Setzen Sie Ihr Passwort zurück\",\n        \"message\": \"Wenn Sie diese Änderung nicht angefordert haben, ignorieren Sie diese E-Mail bitte. Klicken Sie andernfalls auf die Schaltfläche unten, um Ihr Passwort zurückzusetzen.\",\n        \"buttonText\": \"Passwort zurücksetzen\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Registrierungsbestätigung - {{brandName}}\",\n          \"title\": \"Registrierungsbestätigung\",\n          \"message\": \"Ihr Bestätigungscode lautet {{code}}. Bitte verwenden Sie ihn innerhalb von {{expiresIn}} Minuten.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Domain-Verifizierung - {{brandName}}\",\n          \"title\": \"Domain-Verifizierung\",\n          \"message\": \"Ihr Einmalcode lautet: {{code}}. Bitte verwenden Sie ihn innerhalb von {{expiresIn}} Minuten.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"E-Mail-Änderungsbestätigung - {{brandName}}\",\n          \"title\": \"E-Mail-Änderungsbestätigung\",\n          \"message\": \"Ihr Bestätigungscode lautet {{code}}. Bitte verwenden Sie ihn innerhalb von {{expiresIn}} Minuten.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} hat Sie zum Feld {{fieldName}} eines Datensatzes in {{tableName}} hinzugefügt\",\n        \"title\": \"<strong>{{fromUserName}}</strong> hat Sie zum Feld <strong>{{fieldName}}</strong> eines Datensatzes in <strong>{{tableName}}</strong> hinzugefügt\",\n        \"buttonText\": \"Datensatz anzeigen\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} hat Sie zu {{refLength}} Datensätzen in {{tableName}} hinzugefügt\",\n        \"title\": \"<strong>{{fromUserName}}</strong> hat Sie zu <strong>{{refLength}}</strong> Datensätzen in <strong>{{tableName}}</strong> hinzugefügt\",\n        \"buttonText\": \"Datensätze anzeigen\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) hat Sie zu {{resourceAlias}} {{resourceName}} eingeladen - {{brandName}}\",\n        \"title\": \"Einladung zur Zusammenarbeit\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) hat Sie zu {{resourceAlias}} <strong>{{resourceName}}</strong> eingeladen.\",\n        \"buttonText\": \"Einladung annehmen\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Willkommen - {{brandName}}\",\n        \"title\": \"Willkommen\",\n        \"message\": \"Sie haben sich erfolgreich auf der Warteliste von {{brandName}} eingetragen. Bitte verwenden Sie den folgenden Einladungscode zur Registrierung: {{code}}. Er kann {{times}} Mal verwendet werden.\",\n        \"buttonText\": \"Registrieren\"\n      },\n      \"test\": {\n        \"subject\": \"Test-E-Mail - {{brandName}}\",\n        \"title\": \"Test-E-Mail\",\n        \"message\": \"Dies ist eine Test-E-Mail, bitte ignorieren.\"\n      },\n      \"notify\": {\n        \"subject\": \"Benachrichtigung - {{brandName}}\",\n        \"title\": \"Benachrichtigung\",\n        \"buttonText\": \"Anzeigen\",\n        \"import\": {\n          \"title\": \"Importergebnis-Benachrichtigung\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ {{tableName}} Import abgebrochen: {{errorMessage}} fehlgeschlagener Zeilenbereich: [{{range}}]. Bitte überprüfen Sie die Daten für diesen Bereich und versuchen Sie es erneut.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ {{tableName}} Import fehlgeschlagen: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ {{tableName}} Import fehlgeschlagen: Zeilenlimit erreicht, bitte upgraden Sie Ihren Plan, um mehr Datensätze zu importieren\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ {{tableName}} Import fehlgeschlagen: Keine Datensätze wurden verarbeitet\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} erfolgreich importiert.\",\n              \"inplace\": \"🎉 {{tableName}} erfolgreich inkrementell importiert.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Datensatzkommentar-Benachrichtigung\",\n          \"message\": \"{{fromUserName}} hat einen Kommentar zu {{recordName}} in {{tableName}} in {{baseName}} abgegeben\"\n        },\n        \"automation\": {\n          \"title\": \"Automatisierungs-Benachrichtigung\",\n          \"failed\": {\n            \"title\": \"Automatisierung {{name}} fehlgeschlagen\",\n            \"message\": \"Ihre Automatisierung {{name}} konnte nicht ausgeführt werden. Klicken Sie auf die Schaltfläche unten, um spezifische Fehler aus der Ausführungshistorie anzuzeigen.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"Automatisierung {{name}} aufgrund unzureichenden Guthabens fehlgeschlagen\",\n            \"message\": \"Ihre Automatisierung {{name}} konnte aufgrund unzureichenden Guthabens nicht ausgeführt werden. Bitte aktualisieren Sie Ihr Abonnement oder kontaktieren Sie den Support.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"Automatisierung {{name}} hat die maximale monatliche Ausführungsanzahl erreicht\",\n            \"message\": \"Die monatlichen Ausführungen für Automatisierung {{name}} sind aufgebraucht. Die Ausführung ist vorübergehend nicht möglich. Bitte upgraden Sie Ihr Abonnement oder kaufen Sie zusätzliche Ausführungen.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Abrechnungsbenachrichtigung\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Space {{spaceName}} KI-Guthaben 80 % verwendet\",\n              \"message\": \"Ihr Space hat 80 % des KI-Guthabens verwendet. Erwägen Sie ein Upgrade oder den Kauf weiterer Guthaben.\"\n            },\n            \"warning90\": {\n              \"title\": \"Space {{spaceName}} KI-Guthaben 90 % verwendet\",\n              \"message\": \"Ihr Space hat 90 % des KI-Guthabens verwendet. Upgraden Sie bald, um Unterbrechungen zu vermeiden.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Space {{spaceName}} Automatisierungsläufe 80 % des Kontingents verwendet\",\n              \"message\": \"Ihr Space hat {{usedRuns}} von {{totalLimit}} Automatisierungsläufen (80 %) verwendet. Erwägen Sie ein Upgrade Ihres Plans.\"\n            },\n            \"warning90\": {\n              \"title\": \"Space {{spaceName}} Automatisierungsläufe 90 % des Kontingents verwendet\",\n              \"message\": \"Ihr Space hat {{usedRuns}} von {{totalLimit}} Automatisierungsläufen (90 %) verwendet. Upgraden Sie bald, um Unterbrechungen zu vermeiden.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Space {{spaceName}} Automatisierungsläufe überschritten – Karenzzeit aktiv\",\n              \"message\": \"Ihr Kontingent an Automatisierungsläufen wurde überschritten. Automatisierungen werden in {{remainingHours}} Stunden gestoppt und deaktiviert. Bitte upgraden Sie Ihren Plan.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Export-Base-Ergebnis-Benachrichtigung\",\n          \"success\": {\n            \"message\": \"{{baseName}} erfolgreich exportiert: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ {{baseName}} Export fehlgeschlagen: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"KI-Aufgabe in Tabelle {{tableName}} fehlgeschlagen\",\n              \"message\": \"Die KI-Aufgabe für Feld {{fieldName}} in Tabelle {{tableName}} (Datensatz-ID: {{recordId}}) ist fehlgeschlagen.\\n\\n{{errorMsg}}\\n\\nKlicken Sie auf die Schaltfläche unten, um Details anzuzeigen.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Warteliste\",\n    \"email\": \"E-Mail\",\n    \"joinTitle\": \"Wir skalieren schnell, um die Nachfrage zu befriedigen\",\n    \"joinDesc\": \"Warten Sie auf die Liste — wir werden Sie benachrichtigen, sobald wir bereit sind\",\n    \"emailPlaceholder\": \"Geben Sie Ihre E-Mail-Adresse ein...\",\n    \"youAreOnTheList\": \"Sie sind auf der Liste!\",\n    \"thanksForJoining\": \"Vielen Dank für Ihre Registrierung auf der Warteliste. Wir werden Sie benachrichtigen, sobald wir bereit sind.\",\n    \"back\": \"Zurück\",\n    \"inviteCodePlaceholder\": \"Geben Sie Ihre Einladungscode ein\",\n    \"join\": \"Warten Sie auf die Liste\",\n    \"joining\": \"Beitritt...\",\n    \"invite\": \"Einladen\",\n    \"inviteTime\": \"Einladungszeit\",\n    \"createdTime\": \"Beitrittszeit\",\n    \"yes\": \"Ja\",\n    \"no\": \"Nein\",\n    \"generateCode\": \"Einladungscode generieren\",\n    \"count\": \"Anzahl\",\n    \"times\": \"Verfügbare Anzahl\",\n    \"generate\": \"Generieren\",\n    \"code\": \"Einladungscode\",\n    \"inviteSuccess\": \"Einladung erfolgreich\",\n    \"app\": {\n      \"previewAppError\": \"Anwendungsfehler\",\n      \"sendErrorToAI\": \"Fehler an AI senden\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"Sind Sie sicher, dass Sie \\\"{{name}}\\\" Base löschen möchten?\",\n    \"createResource\": \"Ressource erstellen\",\n    \"noPermissionToCreateResource\": \"Sie haben keine Berechtigung, eine Ressource zu erstellen\"\n  },\n  \"credit\": {\n    \"title\": \"Guthaben\",\n    \"leftAmount\": \"Verbleibendes Guthaben\",\n    \"winFreeCredits\": \"Erfahrung teilen\",\n    \"getCredits\": \"1000 kostenlose Credits erhalten\",\n    \"winCredit\": {\n      \"title\": \"Teilen Sie eine positive Bewertung und verdienen Sie\",\n      \"freeCredits\": \"1000 kostenloses Guthaben\",\n      \"guidelinesTitle\": \"Checkliste zum Teilen\",\n      \"tagTeableio\": \"Markieren Sie <bold>@teableio</bold>\",\n      \"minCharacters\": \"Schreiben Sie eine Bewertung mit <bold>80+</bold> Zeichen\",\n      \"minFollowers\": \"Haben Sie <bold>10+</bold> Follower\",\n      \"limitPerWeek\": \"500 Guthaben pro Beitrag, bis zu 2 Beiträge wöchentlich (X + LinkedIn)\",\n      \"postOnX\": \"Auf X posten\",\n      \"postOnLinkedIn\": \"Auf LinkedIn posten\",\n      \"preFilledDraft\": \"🌟 Vorausgefüllter Entwurf für Sie bereit!\",\n      \"claimTitle\": \"Fügen Sie die Beitrags-URL ein, um Ihr Guthaben zu beanspruchen\",\n      \"userEmail\": \"Benutzer-E-Mail\",\n      \"postUrlLabel\": \"Beitrags-URL (X oder LinkedIn)\",\n      \"postUrlPlaceholder\": \"Fügen Sie hier den Link zu Ihrem Beitrag ein\",\n      \"invalidUrl\": \"Bitte geben Sie eine gültige X- oder LinkedIn-Beitrags-URL ein\",\n      \"claiming\": \"Wird beansprucht...\",\n      \"claimCredits\": \"Guthaben beanspruchen\",\n      \"congratulations\": \"Herzlichen Glückwunsch!\",\n      \"claimSuccess\": \"Sie haben 500 Guthaben erhalten!\",\n      \"verifying\": \"Ihr Beitrag wird überprüft...\",\n      \"verifyingDescription\": \"Wir prüfen Ihren Beitragsinhalt, dies dauert normalerweise einige Sekunden\",\n      \"verifyFailed\": \"Überprüfung fehlgeschlagen\",\n      \"tryAgain\": \"Erneut versuchen\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Überprüfung fehlgeschlagen\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Belohnung\",\n    \"rewardCredits\": \"Belohnungsguthaben\",\n    \"minCharCount\": \"Beitrag muss mindestens {{count}} Zeichen haben\",\n    \"minFollowerCount\": \"Konto muss mindestens {{count}} Follower haben\",\n    \"mustMention\": \"Beitrag muss {{mention}} erwähnen\",\n    \"fetchSnapshotFailed\": \"Fehler beim Abrufen des Beitrags-Snapshots\",\n    \"alreadyClaimedThisWeek\": \"Sie haben diese Woche bereits eine Belohnung für dieses Konto beansprucht\",\n    \"manage\": {\n      \"title\": \"Belohnungsverwaltung\",\n      \"description\": \"Belohnungseinträge aller Spaces anzeigen und verwalten\",\n      \"overview\": \"Übersicht\",\n      \"records\": \"Einträge\",\n      \"searchSpace\": \"Space suchen...\",\n      \"searchRecords\": \"PostUrl/UserId suchen...\",\n      \"dateRange\": \"Datumsbereich auswählen\",\n      \"from\": \"Von\",\n      \"to\": \"Bis\",\n      \"totalSpaces\": \"Gesamt: {{count}} Spaces\",\n      \"totalRecords\": \"Gesamt: {{count}} Einträge\",\n      \"space\": \"Space\",\n      \"allSpaces\": \"Alle Spaces\",\n      \"user\": \"Benutzer\",\n      \"creator\": \"Ersteller\",\n      \"sourceType\": \"Quelltyp\",\n      \"platform\": \"Plattform\",\n      \"allStatuses\": \"Alle Status\",\n      \"allSourceTypes\": \"Alle Quelltypen\",\n      \"allPlatforms\": \"Alle Plattformen\",\n      \"pendingCount\": \"Ausstehende Anzahl\",\n      \"approvedCount\": \"Genehmigte Anzahl\",\n      \"rejectedCount\": \"Abgelehnte Anzahl\",\n      \"approvedAmount\": \"Genehmigter Betrag\",\n      \"consumedAmount\": \"Verbrauchter Betrag\",\n      \"availableAmount\": \"Verfügbarer Betrag\",\n      \"expiringSoonAmount\": \"Bald ablaufender Betrag (7 Tage)\",\n      \"amount\": \"Betrag\",\n      \"remainingAmount\": \"Verbleibender Betrag\",\n      \"createdTime\": \"Erstellungszeit\",\n      \"rewardTime\": \"Belohnungszeit\",\n      \"expiredTime\": \"Ablaufzeit\",\n      \"lastModified\": \"Zuletzt geändert\",\n      \"viewDetails\": \"Details anzeigen\",\n      \"details\": \"Belohnungsdetails\",\n      \"basicInfo\": \"Grundinformationen\",\n      \"amountInfo\": \"Betragsinformationen\",\n      \"timeInfo\": \"Zeitinformationen\",\n      \"socialInfo\": \"Soziale Informationen\",\n      \"verifyResult\": \"Überprüfungsergebnis\",\n      \"uniqueKey\": \"Eindeutiger Schlüssel\",\n      \"verify\": \"Überprüfen\",\n      \"valid\": \"Gültig\",\n      \"invalid\": \"Ungültig\",\n      \"errors\": \"Fehler\",\n      \"copied\": \"{{label}} kopiert\",\n      \"openPost\": \"Beitrag öffnen\",\n      \"noData\": \"Keine Daten\",\n      \"page\": \"Seite {{current}} von {{total}}\",\n      \"status\": {\n        \"label\": \"Status\",\n        \"pending\": \"Ausstehend\",\n        \"approved\": \"Genehmigt\",\n        \"rejected\": \"Abgelehnt\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Seite nicht gefunden\",\n      \"description\": \"Der Link, dem Sie gefolgt sind, ist möglicherweise fehlerhaft oder die Seite wurde verschoben.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Zurück zur Startseite\"\n    },\n    \"forbidden\": {\n      \"title\": \"Zugriff eingeschränkt\",\n      \"description\": \"Sie benötigen eine Berechtigung, um auf diese Ressource zuzugreifen.\\nBitte wenden Sie sich an Ihren Administrator.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Premium-Funktion freischalten\",\n      \"description\": \"Diese Funktion ist in erweiterten Plänen verfügbar.\\nUpgraden Sie, um Ihre Möglichkeiten zu erweitern.\"\n    },\n    \"error\": {\n      \"title\": \"Etwas ist schief gelaufen\",\n      \"description\": \"Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Datumsanalyse fehlgeschlagen, Wert \\\"{{value}}\\\" liegt außerhalb des gültigen Bereichs\",\n      \"planRowLimit\": \"Zeilenlimit erreicht: Bitte upgraden Sie Ihren Plan, um mehr Datensätze zu importieren\",\n      \"notNullValidation\": \"{{fieldHint}}Pflichtfeld(er) dürfen nicht leer sein\",\n      \"uniqueValidation\": \"{{fieldHint}}Doppelte Werte in eindeutigem Feld/Feldern\",\n      \"requestTimeout\": \"Zeitüberschreitung der Anfrage\",\n      \"chunkProcessingFailed\": \"Stapelverarbeitung fehlgeschlagen: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"NEUE AKTUALISIERUNG\",\n    \"title\": \"Massen-Download von Anhängen ist verfügbar\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Noch keine Dashboards\",\n    \"description\": \"Es sieht so aus, als hätten Sie noch keine Dashboards erstellt. Dashboards helfen Ihnen, Ihre Daten besser zu visualisieren und zu verwalten.\",\n    \"create\": \"Erstellen Sie Ihr erstes Dashboard\"\n  },\n  \"addPlugin\": \"Ein Plugin hinzufügen\",\n  \"createDashboard\": {\n    \"button\": \"Dashboard erstellen\",\n    \"title\": \"Neues Dashboard erstellen\",\n    \"placeholder\": \"Name des Dashboards eingeben\"\n  },\n  \"findDashboard\": \"Ein Dashboards finden...\",\n  \"expand\": \"Erweitern\",\n  \"deprecation\": {\n    \"title\": \"Die Dashboard-Knoten-Funktion wird eingestellt\",\n    \"description\": \"Um Ihnen ein intelligenteres und effizienteres Erlebnis zu bieten, werden wir die Unterstützung für die Dashboard-Knoten-Funktion einstellen. Sie können neue Dashboards einfach über KI in der KI-generierten Anwendung erstellen.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"API Query Builder\",\n  \"subTitle\": \"Sie können Ihre Abfragen schnell über eine interaktive Benutzeroberfläche erstellen und den Code kopieren, der direkt ausgeführt werden kann\",\n  \"apiList\": \"Vollständige API-Liste\",\n  \"cellFormat\": \"Zellenergebnisformat\",\n  \"fieldKeyType\": \"Feld Schlüsseltyp\",\n  \"fieldKeyTypeDesc\": \"Der Typ des Feldschlüssels im Ergebnis\",\n  \"chooseSource\": \"Datenquelle auswählen...\",\n  \"action\": {\n    \"selectBase\": \"Base auswählen...\",\n    \"selectTable\": \"Tabelle auswählen...\"\n  },\n  \"pickParams\": \"Wählen und konfigurieren Sie die Parameter\",\n  \"buildResult\": \"Build Ergebnis\",\n  \"buildResultEmpty\": \"Noch kein Ergebnis\",\n  \"previewReturnValue\": \"Vorschau des Rückgabewerts\",\n  \"replaceToken\": \"Token ersetzen\",\n  \"createNewToken\": \"Neues Token erstellen\",\n  \"showPagination\": \"Paginierungsparameter werden im JSON-Modus angezeigt\",\n  \"addSort\": \"Eine Sortierung hinzufügen\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/oauth.json",
    "content": "{\n  \"add\": \"Neue OAuth Apps\",\n  \"title\": {\n    \"add\": \"Neue OAuth Apps\",\n    \"edit\": \"Bearbeite OAuth Apps\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"OAuth App Name\",\n      \"description\": \"Der Name Ihrer OAuth App.\"\n    },\n    \"description\": {\n      \"label\": \"Beschreibung\",\n      \"description\": \"Eine kurze Beschreibung Ihrer OAuth App.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"Homepage URL\",\n      \"description\": \"Die vollständige URL zur Webseite Ihrer OAuth App.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Ein quadratisches Bild wird empfohlen.\",\n      \"placeholder\": \"Ziehen Sie Ihr Logo hierher oder klicken Sie zum Hochladen\",\n      \"button\": \"Ein Logo hochladen\",\n      \"clear\": \"Leeren\",\n      \"lengthError\": \"Es ist nur eine Datei zulässig.\",\n      \"typeError\": \"Nur Bilddateien sind erlaubt.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"Callback URL\",\n      \"description\": \"Die vollständige URL, zu der umgeleitet werden soll, nachdem ein Benutzer Ihre Integration autorisiert hat.\",\n      \"add\": \"Callback URL hinzufügen\"\n    },\n    \"scopes\": {\n      \"label\": \"Geltungsbereiche\",\n      \"description\": \"Die Berechtigungen, die Ihre OAuth-App benötigt.\"\n    },\n    \"secret\": {\n      \"label\": \"Client Secrets\",\n      \"add\": \"Neues Client Secret erzeugen\",\n      \"newDescription\": \"Stellen Sie sicher, dass Sie Ihr neues Client Secret jetzt kopieren. Sie werden es nicht mehr sehen können.\",\n      \"empty\": \"Sie benötigen ein Client Secret, um sich als Anwendung bei der API zu authentifizieren.\",\n      \"lastUsed\": \"Zuletzt verdendet am {{date}}\",\n      \"tag\": \"Client Secret\",\n      \"neverUsed\": \"Noch nie verwendet\"\n    },\n    \"clientId\": {\n      \"label\": \"Client ID: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Grundlegende Informationen\",\n    \"scopes\": \"Geltungsbereiche\",\n    \"identify\": \"Identifizierung und Autorisierung von Benutzern\",\n    \"clientInfo\": \"Client Informationen\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} fordert den Zugang zu Ihrem Konto an\",\n    \"scopes\": \"Diese Anwendung wird die folgenden Bereiche abrufen können:\",\n    \"redirectDescription\": \"Die Autorisierung wird weitergeleitet zu\",\n    \"authorize\": \"Autorisiere\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/plugin.json",
    "content": "{\n  \"add\": \"Neue Plugins\",\n  \"title\": {\n    \"add\": \"Neue Plugins\",\n    \"edit\": \"Plugins bearbeiten\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Plugin Benutzer\",\n    \"description\": \"Plugin Benutzer als ein vom System automatisch generiertes Plugin\"\n  },\n  \"secret\": \"Geheimnis\",\n  \"regenerateSecret\": \"Geheimnis regenerieren\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Name\",\n      \"description\": \"Name des Plugins\"\n    },\n    \"description\": {\n      \"label\": \"Beschreibung\",\n      \"description\": \"Beschreibung des Plugins\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Detailbeschreibung\",\n      \"description\": \"ausführliche Beschreibung des Plugins\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Logo des Plugins, können Sie ein Bild hochladen oder eine URL verwenden\",\n      \"upload\": \"Hochladen\",\n      \"clear\": \"Leeren\",\n      \"placeholder\": \"Ziehen Sie Ihr Logo hierher oder klicken Sie zum Hochladen\",\n      \"lengthError\": \"Es ist nur eine Datei zulässig.\",\n      \"typeError\": \"Nur Bilddateien sind erlaubt.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"Hilfe Url\",\n      \"description\": \"URL des Hilfedokuments für das Plugin\"\n    },\n    \"positions\": {\n      \"label\": \"Positionen\",\n      \"description\": \"Positionen des Plugins\"\n    },\n    \"i18n\": {\n      \"label\": \"I18n\",\n      \"description\": \"i18n des Plugins, enthält(Name, Beschreibung, Detailbeschreibung)\"\n    },\n    \"url\": {\n      \"label\": \"Url\",\n      \"description\": \"URL des Plugins\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Schreiben\",\n    \"preview\": \"Vorschau\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Demnächst verfügbar\",\n    \"empty\": \"Leer\",\n    \"noRecords\": \"Keine Datensätze verfügbar\",\n    \"unnamedRecord\": \"Unbenannter Datensatz\",\n    \"untitled\": \"Unbenannt\",\n    \"cancel\": \"Abbrechen\",\n    \"confirm\": \"Bestätigen\",\n    \"back\": \"Zurück\",\n    \"done\": \"Erledigt\",\n    \"create\": \"Erstellen\",\n    \"search\": {\n      \"placeholder\": \"Suchen...\",\n      \"empty\": \"Keine Resultate gefunden\"\n    },\n    \"readOnlyTip\": \"Diese Ansicht ist gesperrt. Sie können den <button>persönlichen Modus</button> aktivieren, um Ansichtsoptionen zu bearbeiten, und die Änderungen treten nur für Sie in Kraft.\",\n    \"selectPlaceHolder\": \"Auswählen...\",\n    \"loading\": \"Lade...\",\n    \"loadMore\": \"Mehr laden\",\n    \"uploadFailed\": \"Hochladen fehlgeschlagen\",\n    \"rowCount\": \"{{count}} Datensätze\",\n    \"summary\": \"Zusammnfassung\",\n    \"summaryTip\": \"Hovern, um Zusammenfassung auszuwählen\",\n    \"actions\": \"Aktionen\",\n    \"remove\": \"Entfernen\",\n    \"runStatus\": {\n      \"success\": \"{{name}} erfolgreich\",\n      \"failed\": \"{{name}} fehlgeschlagen\",\n      \"running\": \"{{name}} läuft\"\n    },\n    \"resetSuccess\": \"Erfolgreich zurückgesetzt\",\n    \"click\": \"Klicken\",\n    \"clickedCount\": \"{{label}}: Klickt {{text}} mal\",\n    \"atLeastOne\": \"Reservieren Sie zumindest ein {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Benachrichtigung\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Größenbeschränkung der Vorschaudatei: {{size}}MB, bitte laden Sie die Datei herunter, um sie anzusehen.\",\n    \"loadFileError\": \"Datei konnte nicht geladen werden\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Rückgängig machen\",\n    \"redo\": \"Wiederholen\",\n    \"undoFailed\": \"Rückgängig machen fehlgeschlagen\",\n    \"redoFailed\": \"Wiederholen fehlgeschlagen\",\n    \"nothingToUndo\": \"Nichts zum rückgängig machen\",\n    \"nothingToRedo\": \"Nichts zum Wiederholen\",\n    \"undoSucceed\": \"Rückgängig machen erfolgreich\",\n    \"redoSucceed\": \"Wiederholen erfolgreich\",\n    \"undoing\": \"Mache rückgängig...\",\n    \"redoing\": \"Wiederhole...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Freigabe zum Hochladen der Datei\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Einfügen oder Ziehen und Ablegen zum Hochladen\",\n      \"upload\": \"Hochladen\"\n    },\n    \"date\": {\n      \"placeholder\": \"Datum auswählen\",\n      \"today\": \"Heute\"\n    },\n    \"formula\": {\n      \"title\": \"Formeleditor\",\n      \"guideSyntax\": \"Syntax\",\n      \"guideExample\": \"Beispiel\",\n      \"helperExample\": \"Beispiel: \",\n      \"fieldValue\": \"Gibt den Wert in den Zellen des Feldes {{fieldName}} zurück.\",\n      \"placeholder\": \"Einen Ausdruck eingeben\",\n      \"placeholderForAIPrompt\": \"Beschreiben Sie die Formel, die Sie generieren möchten\",\n      \"editExpression\": \"Formel bearbeiten\",\n      \"generateExpressionByAI\": \"Formel mit AI generieren\",\n      \"inputPrompt\": \"Eingabeanweisung\",\n      \"generateExpression\": \"Generierte Formel\",\n      \"generatingByAI\": \"Formel mit AI generieren...\",\n      \"generatedExpressionTips\": \"Nach Generieren, klicken Sie auf Anwenden, um die Formel schnell einzufügen\",\n      \"action\": {\n        \"generating\": \"Generieren...\",\n        \"generate\": \"Generieren\",\n        \"apply\": \"Anwenden\"\n      },\n      \"expressionRequired\": \"Ausdruck ist erforderlich\"\n    },\n    \"link\": {\n      \"placeholder\": \"Zu verknüpfende Datensätze auswählen\",\n      \"searchPlaceholder\": \"Datensätze durchsuchen\",\n      \"allFields\": \"Alle Felder\",\n      \"globalSearch\": \"Globale Suche\",\n      \"fieldSearch\": \"Feldsuche\",\n      \"maxFieldTips\": \"Die maximale Anzahl von {{count}} Feldern wurde überschritten, zusätzliche Felder werden ignoriert\",\n      \"create\": \"Datensatz hinzufügen\",\n      \"selectRecord\": \"Datensatz auswählen\",\n      \"all\": \"Alle\",\n      \"selected\": \"Ausgewählt\",\n      \"expandRecordError\": \"Keine Erlaubnis, diesen Datensatz einzusehen.\",\n      \"alreadyOpen\": \"Dieser Datensatz ist bereits geöffnet.\",\n      \"linkedTo\": \"Verknüpft mit\",\n      \"goToForeignTable\": \"Gehe zu fremder Tabelle\",\n      \"foreignTableIdRequired\": \"Fremdtabelle ist erforderlich\",\n      \"linkFieldIdRequired\": \"Verknüpfungsfeld ist erforderlich\",\n      \"selectTooManyRecords\": \"Die ausgewählten Datensätze sollten {{maxCount}} nicht überschreiten\",\n      \"relationshipRequired\": \"Beziehung ist erforderlich\",\n      \"rangeSelectFailed\": \"Auswahl von Datensätzen im Bereich fehlgeschlagen\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Benutzer nach Namen suchen\",\n      \"notify\": \"Benutzer benachrichtigen, sobald sie ausgewählt wurden\"\n    },\n    \"select\": {\n      \"addOption\": \"Option '{{option}}' hinzufügen\",\n      \"choicesNameRequired\": \"Auswahlname darf nicht leer sein\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Lookup-Feld ist erforderlich\",\n      \"lookupOptionsNotAllowed\": \"Lookup-Optionen sind nicht erlaubt, wenn das isLookup-Attribut wahr ist oder der Feldtyp Rollup ist.\",\n      \"lookupOptionsRequired\": \"Lookup-Optionen sind erforderlich\",\n      \"refineOptionsError\": \"Fehler beim Parsen der Lookup-Optionen {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"Ausdruck ist erforderlich\",\n      \"unsupportedTip\": \"Rollup unterstützt nur Verknüpfungs- und Rollup-Felder\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Der Filter muss mindestens eine Bedingung enthalten\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"Conditional Lookup requires at least one filter condition\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Modell ist erforderlich\",\n      \"typeNotSupported\": \"Nicht unterstützter AI-Typ\",\n      \"sourceFieldIdRequired\": \"Quellfeld ist erforderlich\",\n      \"targetLanguageRequired\": \"Zielsprache ist erforderlich\",\n      \"promptRequired\": \"Eingabeaufforderung ist erforderlich\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Fehler beim Parsen der Feldoptionen {{message}}\",\n      \"optionsRequired\": \"Feldoptionen sind erforderlich\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filter\",\n    \"displayLabel\": \"Filtern nach \",\n    \"displayLabel_other\": \"Filtern nach {{fieldName}} und {{count}} anderen Feldern\",\n    \"addCondition\": \"Bedingung hinzufügen\",\n    \"addConditionGroup\": \"Bedingungsgruppe hinzufügen\",\n    \"nestedLimitTip\": \"Filterbedingungen können nur {{depth}} Ebenen tief verschachtelt werden\",\n    \"linkInputPlaceholder\": \"Wert eingeben\",\n    \"groupDescription\": \"Jede der folgenden Aussagen ist wahr...\",\n    \"currentUser\": \"Ich (aktueller Benutzer)\",\n    \"tips\": {\n      \"scope\": \"In dieser Ansicht, Datensätze anzeigen\"\n    },\n    \"invalidateSelected\": \"Unzulässiger Wert\",\n    \"invalidateSelectedTips\": \"Der ausgewählte Wert wurde gelöscht, bitte wählen Sie erneut\",\n    \"default\": {\n      \"empty\": \"Es werden keine Filterbedingungen angewendet\",\n      \"placeholder\": \"Einen Wert eingeben\"\n    },\n    \"conjunction\": {\n      \"and\": \"und\",\n      \"or\": \"oder\",\n      \"where\": \"wo\",\n      \"meetingAll\": \"Alle Bedingungen erfüllen\",\n      \"meetingAny\": \"Eine Bedingung erfüllen\"\n    },\n    \"operator\": {\n      \"is\": \"ist\",\n      \"isNot\": \"ist nicht\",\n      \"contains\": \"enthält\",\n      \"doesNotContain\": \"enthält nicht\",\n      \"isEmpty\": \"ist leer\",\n      \"isNotEmpty\": \"ist nicht leer\",\n      \"isGreater\": \"ist größer als\",\n      \"isGreaterEqual\": \"ist größergleich\",\n      \"isLess\": \"ist kleiner als\",\n      \"isLessEqual\": \"ist kleinergleich\",\n      \"isAnyOf\": \"ist eine von\",\n      \"isNoneOf\": \"ist nicht eine von\",\n      \"hasAnyOf\": \"hat eine von\",\n      \"hasAllOf\": \"hat all von\",\n      \"hasNoneOf\": \"hat keine von\",\n      \"isExactly\": \"ist genau\",\n      \"isWithIn\": \"ist enthalten in\",\n      \"isBefore\": \"ist vor\",\n      \"isAfter\": \"ist nach\",\n      \"isOnOrBefore\": \"ist am oder vor\",\n      \"isOnOrAfter\": \"ist am oder nach\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"heute\",\n        \"tomorrow\": \"morgen\",\n        \"yesterday\": \"gestern\",\n        \"oneWeekAgo\": \"vor einer Woche\",\n        \"oneWeekFromNow\": \"in einer Woche ab jetzt\",\n        \"oneMonthAgo\": \"vor einem Monat\",\n        \"oneMonthFromNow\": \"in einem Monat ab jetzt\",\n        \"daysAgo\": \"Tage her\",\n        \"daysFromNow\": \"Tage ab jetzt\",\n        \"exactDate\": \"exaktes Datum\",\n        \"exactFormatDate\": \"exaktes Datum (formatiert)\",\n        \"currentWeek\": \"Aktuelle Woche\",\n        \"currentMonth\": \"Aktueller Monat\",\n        \"currentYear\": \"Aktuelles Jahr\",\n        \"lastWeek\": \"Letzte Woche\",\n        \"lastMonth\": \"Letzter Monat\",\n        \"lastYear\": \"Letztes Jahr\",\n        \"nextWeekPeriod\": \"Nächste Woche\",\n        \"nextMonthPeriod\": \"Nächster Monat\",\n        \"nextYearPeriod\": \"Nächstes Jahr\",\n        \"pastWeek\": \"letze Woche\",\n        \"pastMonth\": \"letzer Monat\",\n        \"pastYear\": \"letzes Jahr\",\n        \"nextWeek\": \"nächste Woche\",\n        \"nextMonth\": \"nächster Monat\",\n        \"nextYear\": \"nächstes Jahr\",\n        \"pastNumberOfDays\": \"letzte Anzahl an Tagen\",\n        \"nextNumberOfDays\": \"nächste Anzahl an Tagen\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"Farbe\"\n  },\n  \"rowHeight\": {\n    \"short\": \"kurz\",\n    \"medium\": \"mittel\",\n    \"tall\": \"groß\",\n    \"extraTall\": \"extra groß\",\n    \"title\": \"Zeilenhöhe\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"Feldname\",\n    \"displayLines\": \"{{count}} Zeilen\"\n  },\n  \"share\": {\n    \"title\": \"teilen\"\n  },\n  \"extensions\": {\n    \"title\": \"Erweiterungen\"\n  },\n  \"hidden\": {\n    \"label\": \"Versteckte Felder\",\n    \"configLabel_one\": \"{{count}} verstecktes Felde\",\n    \"configLabel_other\": \"{{count}} versteckte Felder\",\n    \"configLabel_other_visible\": \"{{count}} sichtbare Felder\",\n    \"showAll\": \"Alle zeigen\",\n    \"hideAll\": \"Alle verstecken\",\n    \"primaryKey\": \"Primäres Feld: Identifiziert Datensätze\\nKann nicht ausgeblendet oder gelöscht werden, sichtbar in verknüpften Datensätzen.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"In die Zwischenablage kopieren\",\n    \"duplicateRecord\": \"Datensatz duplizieren\",\n    \"copyRecordUrl\": \"Datensatz URL kopieren\",\n    \"deleteRecord\": \"Datensatz löschen\",\n    \"addRecordComment\": \"Kommentar hinzufügen\",\n    \"viewRecordHistory\": \"Datensatzverlauf anzeigen\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Datensatz Historie verstecken\",\n      \"showRecordHistory\": \"Datensatz Historie anzeigen\",\n      \"createdTime\": \"Erstellzeit\",\n      \"createdBy\": \"Erstellt von\",\n      \"before\": \"Vor\",\n      \"after\": \"Nach\",\n      \"viewRecord\": \"Datensatz anzeigen\"\n    },\n    \"showHiddenFields\": \"Zeige {{count}} versteckte Felder\",\n    \"hideHiddenFields\": \"Verstecke {{count}} versteckte Felder\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Sortieren\",\n    \"displayLabel_one\": \"Sortiere nach {{count}} Feld\",\n    \"displayLabel_other\": \"Sortiere nach {{count}} Feldern\",\n    \"setTips\": \"Sortieren nach\",\n    \"addButton\": \"Weitere Sortierung hinzufügen\",\n    \"autoSort\": \"Datensätze automatisch sortieren\",\n    \"selectASCLabel\": \"erste → letzte\",\n    \"selectDESCLabel\": \"letzte → erste\"\n  },\n  \"group\": {\n    \"label\": \"Gruppieren\",\n    \"displayLabel_one\": \"Gruppiere nach {{count}} Feld\",\n    \"displayLabel_other\": \"Gruppiere nach {{count}} Feldern\",\n    \"setTips\": \"Gruppieren nach\",\n    \"addButton\": \"Untergruppe hinzufügen\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Einzeiliger Text\",\n      \"longText\": \"Langer Text\",\n      \"singleSelect\": \"Einfache Auswahl\",\n      \"number\": \"Nummer\",\n      \"multipleSelect\": \"Mehrfachauswahl\",\n      \"link\": \"Link zu einem anderen Datensatz\",\n      \"formula\": \"Formel\",\n      \"date\": \"Datum\",\n      \"createdTime\": \"Erstellzeit\",\n      \"lastModifiedTime\": \"Zeitstempel der letzten Änderung\",\n      \"attachment\": \"Anhang\",\n      \"checkbox\": \"Checkbox\",\n      \"rollup\": \"Rollup\",\n      \"conditionalRollup\": \"Bedingtes Rollup\",\n      \"user\": \"Benutzer\",\n      \"rating\": \"Bewertung\",\n      \"autoNumber\": \"Automatische Nummer\",\n      \"lookup\": \"Nachschlag\",\n      \"conditionalLookup\": \"Conditional Lookup\",\n      \"button\": \"Button\",\n      \"createdBy\": \"Erstellt von\",\n      \"lastModifiedBy\": \"Letzte Änderung von\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Kurze Texte wie Namen oder Titel speichern.\",\n      \"longText\": \"Längere Notizen und Beschreibungen erfassen.\",\n      \"singleSelect\": \"Eine Option aus einer Liste wählen.\",\n      \"number\": \"Zahlenwerte mit Formatierung verfolgen.\",\n      \"multipleSelect\": \"Datensätze mit mehreren Optionen taggen.\",\n      \"link\": \"Diesen Datensatz mit einer anderen Tabelle verknüpfen.\",\n      \"formula\": \"Werte aus anderen Feldern berechnen.\",\n      \"date\": \"Daten oder Zeiten festhalten.\",\n      \"createdTime\": \"Anzeigen, wann ein Datensatz erstellt wurde.\",\n      \"lastModifiedTime\": \"Die zuletzt aktualisierte Zeit anzeigen.\",\n      \"attachment\": \"Dateien hochladen oder KI-generierte Bilder erstellen, unterstützt Modelle wie 🍌 Nano banana pro\",\n      \"checkbox\": \"Ein einfaches Ja oder Nein umschalten.\",\n      \"rollup\": \"Verknüpfte Datensätze mit Formeln zusammenfassen.\",\n      \"conditionalRollup\": \"Daten anhand von Bedingungen zusammenfassen.\",\n      \"user\": \"Datensätze Workspace-Mitgliedern zuweisen.\",\n      \"rating\": \"Elemente mit konfigurierbaren Symbolen bewerten.\",\n      \"autoNumber\": \"Jedem Datensatz eine eindeutige Sequenz geben.\",\n      \"lookup\": \"Werte aus verknüpften Datensätzen anzeigen.\",\n      \"conditionalLookup\": \"Zeigt verknüpfte Werte an, die Ihren Filtern entsprechen.\",\n      \"button\": \"Aktionen über einen klickbaren Button ausführen.\",\n      \"createdBy\": \"Anzeigen, wer den Datensatz erstellt hat.\",\n      \"lastModifiedBy\": \"Anzeigen, wer den Datensatz zuletzt bearbeitet hat.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Einfach\",\n      \"twoWay\": \"Doppelt\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Aktion bestätigen\",\n        \"description\": \"Sind Sie sicher, dass Sie diese Aktion ausführen möchten?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Space erstellen\",\n      \"spaceDelete\": \"Space löschen\",\n      \"spaceRead\": \"Space lesen\",\n      \"spaceUpdate\": \"Space aktualisieren\",\n      \"spaceInviteEmail\": \"per E-Mail in den Space einladen\",\n      \"spaceInviteLink\": \"per Link in den Space einladen\",\n      \"spaceGrantRole\": \"Rolle im Space gewähren\",\n      \"baseCreate\": \"Base erstellen\",\n      \"baseDelete\": \"Base löschen\",\n      \"baseRead\": \"Base lesen\",\n      \"baseReadAll\": \"Alle Bases lesen\",\n      \"baseUpdate\": \"Base aktualisieren\",\n      \"baseInviteEmail\": \"per E-Mail in die Base einladen\",\n      \"baseInviteLink\": \"per Link in die Base einladen\",\n      \"baseTableImport\": \"Daten in Base importieren\",\n      \"baseAuthorityMatrixConfig\": \"Authority Matrix konfigurieren\",\n      \"baseDbConnect\": \"Zu Datenbank verbinden\",\n      \"tableCreate\": \"Tabelle erstellen\",\n      \"tableRead\": \"Tabelle lesen\",\n      \"tableDelete\": \"Tabelle löschen\",\n      \"tableUpdate\": \"Tabelle aktualisieren\",\n      \"tableImport\": \"Daten in Tabelle importieren\",\n      \"tableExport\": \"Tabelle aus Tabelle exportieren\",\n      \"tableTrashRead\": \"Tabelle Papierkorb lesen\",\n      \"tableTrashUpdate\": \"Tabelle Papierkorb akualisieren\",\n      \"tableTrashReset\": \"Tabelle Papierkorb zurücksetzen\",\n      \"viewCreate\": \"Ansicht erstellen\",\n      \"viewDelete\": \"Ansicht löschen\",\n      \"viewRead\": \"Ansicht lesen\",\n      \"viewUpdate\": \"Ansicht aktualisieren\",\n      \"viewShare\": \"Ansicht teilen\",\n      \"fieldCreate\": \"Feld erstellen\",\n      \"fieldDelete\": \"Feld löschen\",\n      \"fieldRead\": \"Feld lesen\",\n      \"fieldUpdate\": \"Feld aktualisieren\",\n      \"recordCreate\": \"Datensatz erstellen\",\n      \"recordComment\": \"Datensatz kommentieren\",\n      \"recordDelete\": \"Datensatz löschen\",\n      \"recordRead\": \"Datensatz lesen\",\n      \"recordUpdate\": \"Datensatz aktualisieren\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Automatisierung erstellen\",\n      \"automationDelete\": \"Automatisierung löschen\",\n      \"automationRead\": \"Automatisierung lesen\",\n      \"automationUpdate\": \"Automatisierung aktualisieren\",\n      \"appCreate\": \"App erstellen\",\n      \"appDelete\": \"App löschen\",\n      \"appRead\": \"App lesen\",\n      \"appUpdate\": \"App aktualisieren\",\n      \"userProfileRead\": \"Aktuelles Benutzerprofil lesen\",\n      \"userEmailRead\": \"Aktuelles Benutzer E-Mail lesen\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Datensatzhistorie lesen\",\n      \"baseQuery\": \"Base abfragen\",\n      \"instanceRead\": \"Instanz lesen\",\n      \"instanceUpdate\": \"Instanz aktualisieren\",\n      \"enterpriseRead\": \"Unternehmenskonfiguration lesen\",\n      \"enterpriseUpdate\": \"Unternehmenskonfiguration aktualisieren\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tabelle\",\n    \"view\": \"Ansicht\",\n    \"space\": \"Raum\",\n    \"base\": \"Basis\",\n    \"field\": \"Feld\",\n    \"record\": \"Datensatz\",\n    \"automation\": \"Automatisierung\",\n    \"app\": \"App\",\n    \"user\": \"Benutzer\",\n    \"recordHistory\": \"Datensatzhistorie\",\n    \"you\": \"Sie\",\n    \"instance\": \"Instanz\",\n    \"enterprise\": \"Unternehmen\",\n    \"history\": \"Historie\",\n    \"global\": \"Global\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Addieren Sie die Zahlen. Äquivalent zu nummer1 + nummer2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Gibt den Durchschnitt der Zahlen zurück.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Gibt die größte der angegebenen Zahlen zurück.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Gibt die kleinste der angegebenen Zahlen zurück.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Rundet den Wert auf die in \\\"precision\\\" angegebene Anzahl von Dezimalstellen (ROUND rundet auf die nächste Ganzzahl mit der angegebenen Genauigkeit, wobei Gleichstände durch Aufrunden auf die Hälfte in Richtung positive Unendlichkeit aufgelöst werden).\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Rundet den Wert auf die durch \\\"precision\\\" angegebene Anzahl von Dezimalstellen, wobei immer aufgerundet wird, d. h. weg von Null. (Sie müssen einen Wert für die Genauigkeit angeben, sonst funktioniert die Funktion nicht).\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Rundet den Wert auf die durch \\\"precision\\\" angegebene Anzahl von Dezimalstellen, wobei immer nach unten, d. h. gegen Null, gerundet wird. (Sie müssen einen Wert für die Genauigkeit angeben, sonst funktioniert die Funktion nicht).\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Gibt das nächstgelegene ganzzahlige Vielfache von Bedeutung zurück, das größer oder gleich dem Wert ist. Wird keine Wertigkeit angegeben, wird eine Wertigkeit von 1 angenommen.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Gibt das nächstgelegene ganzzahlige Vielfache von Bedeutung zurück, das kleiner oder gleich dem Wert ist. Wird keine Wertigkeit angegeben, wird eine Wertigkeit von 1 angenommen.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Gibt die kleinste gerade Ganzzahl zurück, die größer oder gleich dem angegebenen Wert ist.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Rundet positive Werte auf die nächste ungerade Zahl auf und negative Werte auf die nächste ungerade Zahl ab.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Gibt die ganze Zahl eines Wertes zurück und rundet dabei gegen die negative Unendlichkeit.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Gibt den absoluten Wert zurück.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Gibt die Quadratwurzel einer nichtnegativen Zahl zurück.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Berechnet die angegebene Basis mit der angegebenen Potenz.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Berechnet die Eulersche Zahl (e) mit der angegebenen Potenz.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Berechnet den Logarithmus des Wertes in der angegebenen Basis. Die Basis ist standardmäßig 10, wenn sie nicht angegeben wird.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Gibt den Rest zurück, nachdem das erste Argument durch das zweite dividiert wurde.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Wandelt die Textzeichenfolge in eine Zahl um.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Verbindet die Argumente verschiedener Werttypen zu einem einzigen Textwert.\",\n      \"example\": \"CONCATENATE(\\\"Hallo \\\", \\\"Teable\\\") => Hallo Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Findet ein Vorkommen von stringToFind in der whereToSearch-Zeichenkette ab einer optionalen startFromPosition.(startFromPosition ist standardmäßig 0.) Wenn kein Vorkommen von stringToFind gefunden wird, ist das Ergebnis 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hallo Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hallo Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hallo Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Sucht nach einem Vorkommen von stringToFind in der Zeichenkette whereToSearch ab einer optionalen startFromPosition. (startFromPosition ist standardmäßig 0.) Wenn kein Vorkommen von stringToFind gefunden wird, ist das Ergebnis leer.\\nÄhnlich wie FIND(), allerdings gibt FIND() 0 statt leer zurück, wenn kein Vorkommen von stringToFind gefunden wird.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hallo Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hallo Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hallo Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Extrahieren Sie eine Teilzeichenkette von count-Zeichen, die bei whereToStart beginnt.\",\n      \"example\": \"MID(\\\"Hallo Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Extrahiert howMany Zeichen vom Anfang der Zeichenkette.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Extrahiert howMany Zeichen aus dem Ende der Zeichenkette.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Ersetzt die Anzahl der Zeichen, die mit dem Startzeichen beginnen, durch den Ersatztext.\\n(Wenn Sie nach einer Möglichkeit suchen, alle Vorkommen von alter_text zu finden und durch new_text zu ersetzen, siehe SUBSTITUTE().)\",\n      \"example\": \"REPLACE(\\\"Hallo Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hallo Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Ersetzt alle Teilzeichenfolgen, die dem regulären Ausdruck entsprechen, durch Ersetzung.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hallo Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Ersetzt Vorkommen von alter_text durch new_text.\\nSie können optional eine Indexnummer (beginnend mit 1) angeben, um nur ein bestimmtes Vorkommen von alter_text zu ersetzen. Wenn keine Indexnummer angegeben wird, werden alle Vorkommen von alter_text ersetzt.\",\n      \"example\": \"SUBSTITUTE(\\\"Hallo Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hallo Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Wandelt eine Zeichenkette in Kleinbuchstaben um.\",\n      \"example\": \"LOWER(\\\"Hallo Teable\\\") => \\\"Hallo teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Wandelt eine Zeichenkette in Großbuchstaben um.\",\n      \"example\": \"UPPER(\\\"Hallo Teable\\\") => \\\"Hallo TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Wiederholt Zeichenkette um die angegebene Anzahl von Malen.\",\n      \"example\": \"REPT(\\\"Hallo!\\\") => \\\"Hallo!Hallo!Hallo!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Entfernt Leerzeichen am Anfang und Ende der Zeichenkette.\",\n      \"example\": \"TRIM(\\\" Hallo \\\") => \\\"Hallo\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Extrahiert howMany Zeichen vom Anfang der Zeichenkette.\",\n      \"example\": \"LEN(\\\"Hallo\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Gibt das Argument zurück, wenn es Text ist, und sonst leer.\",\n      \"example\": \"T(\\\"Hallo\\\") => \\\"Hallo\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Ersetzt bestimmte Zeichen durch kodierte Äquivalente zur Verwendung beim Aufbau von URLs oder URIs. Kodiert nicht die folgenden Zeichen: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hallo Teable\\\") => \\\"Hallo%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Gibt wert1 zurück, wenn das logische Argument wahr ist, andernfalls wird wert2 zurückgegeben. Kann auch verwendet werden, um verschachtelte IF-Anweisungen zu erstellen.\\nKann auch verwendet werden, um zu prüfen, ob eine Zelle leer ist.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Nimmt einen Ausdruck, eine Liste möglicher Werte für diesen Ausdruck und für jeden einzelnen einen Wert, den der Ausdruck in diesem Fall annehmen soll. Sie kann auch einen Standardwert annehmen, wenn der eingegebene Ausdruck keinem der definierten Muster entspricht. In vielen Fällen kann SWITCH() anstelle einer verschachtelten IF()-Formel verwendet werden.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Gibt true zurück, wenn alle Argumente wahr sind, andernfalls falsch.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Gibt wahr zurück, wenn eines der Argumente wahr ist.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Gibt wahr zurück, wenn eine ungerade Anzahl von Argumenten wahr ist.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Kehrt den logischen Wert seines Arguments um.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Gibt einen leeren Wert zurück.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Gibt den Fehlerwert zurück.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Gibt wahr zurück, wenn der Ausdruck einen Fehler verursacht.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Gibt das aktuelle Datum zurück.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Gibt das aktuelle Datum und die Uhrzeit zurück.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Gibt das vierstellige Jahr einer Datumsangabe zurück.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Gibt den Monat eines Datums als Zahl zwischen 1 (Januar) und 12 (Dezember) zurück.\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Gibt die Wochennummer in einem Jahr zurück.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Liefert den Wochentag als Ganzzahl zwischen 0 und 6 einschließlich. Sie können optional ein zweites Argument angeben (entweder \\\"Sunday\\\" oder \\\"Monday\\\"), um Wochen an diesem Tag zu beginnen. Wird es weggelassen, beginnen die Wochen standardmäßig am Sonntag. Beispiel:\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Gibt den Tag des Monats eines Datums in Form einer Zahl zwischen 1-31 zurück.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Gibt die Stunde eines Datums als Zahl zwischen 0 (12:00 Uhr) und 23 (23:00 Uhr) zurück.\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Gibt die Minute einer Datumszeit als Ganzzahl zwischen 0 und 59 zurück.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Gibt die Sekunde einer Datumszeit als Ganzzahl zwischen 0 und 59 zurück.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Berechnet die Anzahl der Tage zwischen dem aktuellen Datum und einem anderen Datum.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Berechnet die Anzahl der Tage zwischen dem aktuellen Datum und einem anderen Datum.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Gibt die Differenz zwischen den Zeitpunkten in den angegebenen Einheiten zurück. Standardeinheit ist \\\"day\\\".\\nUnterstützte Einheiten: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nDie Differenz zwischen Datumszeiten wird durch Subtraktion von [datum2] von [datum1] ermittelt. Das bedeutet, dass der resultierende Wert negativ ist, wenn [datum2] später als [datum1] liegt.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Gibt den Arbeitstag bis zum Startdatum zurück, ohne die angegebenen Feiertage\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Gibt die Anzahl der Arbeitstage zwischen datum1 und datum2 zurück. Arbeitstage schließen Wochenenden und eine optionale Liste von Feiertagen aus, die als kommagetrennte Zeichenfolge von ISO-formatierten Daten formatiert sind.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Vergleicht zwei Daten bis zu einer Einheit und stellt fest, ob sie identisch sind. Gibt wahr zurück, wenn ja, falsch, wenn nein.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Ermittelt, ob datum1 später als datum2 ist. Gibt wahr zurück, wenn ja, falsch, wenn nein.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Ermittelt, ob datum1 früher als datum2 liegt. Gibt wahr zurück, wenn ja, falsch, wenn nein.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Fügt einer Datetime die angegebenen \\\"count\\\"-Einheiten hinzu.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Formatiert eine Datumsangabe in eine Zeichenkette (JJJJ-MM-TT).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Formatiert eine Datumsangabe in eine reine Zeitzeichenfolge (HH:mm:ss).\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Formatiert eine Datumsangabe in eine angegebene Zeichenkette. Für eine Erklärung, wie diese Funktion mit Datumsfeldern verwendet werden kann, klicken Sie hier. Für eine Liste der unterstützten Formatspezifikationen klicken Sie bitte hier.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Interpretiert eine Textzeichenkette als strukturiertes Datum, mit optionalen Eingabeformat- und Gebietsschema-Parametern. Das Ausgabeformat ist immer \\\"M/D/YYYY h:mm a\\\".\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Gibt die Erstellungszeit des aktuellen Datensatzes zurück.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Gibt das Datum und die Uhrzeit der letzten Änderung zurück, die von einem Benutzer in einem nicht berechneten Feld der Tabelle vorgenommen wurde.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Gibt die Anzahl aller Elemente einschließlich Text und Leerzeichen zurück.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Gibt die Anzahl der nicht leeren Werte zurück. Diese Funktion zählt sowohl numerische als auch Textwerte.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Gibt die Anzahl der numerischen Elemente zurück.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Fügen Sie das Array der Rollup-Elemente zu einer Zeichenkette mit einem Trennzeichen zusammen.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Gibt nur eindeutige Elemente im Array zurück.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Flacht das Array ab, indem es jegliche Arrayverschachtelung entfernt. Alle Elemente werden zu Elementen eines einzigen Arrays.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Entfernt leere Zeichenketten und Nullwerte aus dem Array. Behält \\\"false\\\" und Zeichenketten, die ein oder mehrere Leerzeichen enthalten.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Gibt alle Textwerte zurück\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Gibt die ID des aktuellen Datensatzes zurück.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Gibt die eindeutigen und inkrementierten Nummern für jeden Datensatz zurück.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Setzen Sie Variablen, Operationszeichen und Funktionen ein, um Formeln für Berechnungen zu bilden.\",\n      \"example\": \"Quoting the Column: {Field name}\\n\\nUsing operator: 100 * 2 + 300\\n\\nUsing function: SUM({Number Field 1}, 100)\\n\\nUsing IF statement: \\nIF(logical condition, \\\"value 1\\\", \\\"value 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Felder\",\n    \"numeric\": \"Numerisch\",\n    \"text\": \"Text\",\n    \"logical\": \"Logisch\",\n    \"date\": \"Datum\",\n    \"array\": \"Array\",\n    \"system\": \"System\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Keine\",\n    \"count\": \"Anzahl\",\n    \"empty\": \"Leer\",\n    \"filled\": \"Ausgefüllt\",\n    \"unique\": \"Einzigartig\",\n    \"max\": \"Max\",\n    \"min\": \"Min\",\n    \"sum\": \"Summe\",\n    \"average\": \"Durchschnitt\",\n    \"checked\": \"Geprüft\",\n    \"unChecked\": \"Ungeprüft\",\n    \"percentEmpty\": \"Prozent Leer\",\n    \"percentFilled\": \"Prozent Ausgefüllt\",\n    \"percentUnique\": \"Prozent Einzigartig\",\n    \"percentChecked\": \"Prozent Geprüft\",\n    \"percentUnChecked\": \"Prozent Ungeprüft\",\n    \"earliestDate\": \"Frühestes Datum\",\n    \"latestDate\": \"Spätestes Datum\",\n    \"dateRangeOfDays\": \"Datum Spanne (Tage)\",\n    \"dateRangeOfMonths\": \"Datum Spanne (Monate)\",\n    \"totalAttachmentSize\": \"Gesamtgröße Anhänge\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Hinzufügen\",\n    \"error\": {\n      \"invalidCol\": \"Ungültige Spalte, bitte wählen Sie erneut\",\n      \"invalidCols\": \"Ungültige Spalten: {{colNames}}\",\n      \"invalidTable\": \"Ungültige Tabelle, bitte wählen Sie erneut\",\n      \"requiredSelect\": \"Sie müssen eine auswählen\"\n    },\n    \"from\": {\n      \"title\": \"Von\",\n      \"fromTable\": \"Tabelle auswählen\",\n      \"fromQuery\": \"Aus Abfrage\"\n    },\n    \"select\": {\n      \"title\": \"Auswählen\"\n    },\n    \"where\": {\n      \"title\": \"Wo\"\n    },\n    \"groupBy\": {\n      \"title\": \"Gruppieren nach\"\n    },\n    \"orderBy\": {\n      \"title\": \"Sortieren nach\",\n      \"asc\": \"Aufsteigend\",\n      \"desc\": \"Absteigend\"\n    },\n    \"limit\": {\n      \"title\": \"Limit\"\n    },\n    \"offset\": {\n      \"title\": \"Versatz\"\n    },\n    \"join\": {\n      \"title\": \"Schnittmenge\",\n      \"joinType\": \"Schnittmenge Typ\",\n      \"leftJoin\": \"linke Vereinigung\",\n      \"rightJoin\": \"rechte Schnittmenge\",\n      \"innerJoin\": \"Innere Schnittmenge\",\n      \"fullJoin\": \"Volle Schnittmenge\",\n      \"data\": \"Von\"\n    },\n    \"aggregation\": {\n      \"title\": \"Aggregierung\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Kommentar\",\n    \"placeholder\": \"Einen Kommentar hinterlassen...\",\n    \"emptyComment\": \"Starte eine Konversation\",\n    \"deletedComment\": \"Kommentar gelöscht\",\n    \"imageSizeLimit\": \"Die Bildgröße darf nicht größer sein als {{size}}\",\n    \"tip\": {\n      \"editing\": \"Bearbeiten...\",\n      \"edited\": \"(Bearbeitet)\",\n      \"notifyAll\": \"Alle Kommentare benachrichtigen\",\n      \"notifyRelatedToMe\": \"Kommentare, die sich auf mich beziehen, benachrichtigen\",\n      \"all\": \"Alle\",\n      \"relatedToMe\": \"Mit mir verbunden\",\n      \"reactionUserSuffix\": \"hat mit {{emoji}} emoji reagiert\",\n      \"me\": \"Du\",\n      \"connection\": \"und\"\n    },\n    \"toolbar\": {\n      \"link\": \"Link\",\n      \"image\": \"Bild\",\n      \"mention\": \"Erwähnung\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Link bearbeiten\",\n      \"caption\": \"Überschrift\",\n      \"delete\": \"Löschen\",\n      \"linkText\": \"Link Text\",\n      \"enterUrl\": \"URL eingeben\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Mitglieder auswählen\",\n    \"memberSelectorSearchPlaceholder\": \"Mitglieder durchsuchen...\",\n    \"departmentSelectorSearchPlaceholder\": \"Abteilungen durchsuchen...\",\n    \"selected\": \"Ausgewählt\",\n    \"noSelected\": \"Unausgewählt\",\n    \"empty\": \"Keine Mitglieder\",\n    \"emptyDepartment\": \"Keine Abteilungen\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Datenvalidierungsfehler\",\n    \"invalidCaptcha\": \"Ungültige Captcha\",\n    \"invalidCredentials\": \"Ungültige Anmeldeinformationen\",\n    \"unauthorized\": \"Nicht autorisiert\",\n    \"unauthorizedShare\": \"Nicht autorisierte Freigabe\",\n    \"paymentRequired\": \"Zahlung erforderlich\",\n    \"creditLimitExceeded\": \"Kreditlimit überschritten\",\n    \"restrictedResource\": \"Eingeschränkte Ressource\",\n    \"notFound\": \"Nicht gefunden\",\n    \"conflict\": \"Konflikt\",\n    \"unprocessableEntity\": \"Nicht verarbeitbare Entität\",\n    \"userLimitExceeded\": \"Benutzerlimit überschritten\",\n    \"tooManyRequests\": \"Zu viele Anfragen\",\n    \"internalServerError\": \"Interner Serverfehler\",\n    \"databaseConnectionUnavailable\": \"Datenbankverbindung nicht verfügbar\",\n    \"gatewayTimeout\": \"Gateway Timeout\",\n    \"unknownErrorCode\": \"Unbekannter Fehlercode\",\n    \"networkError\": \"Netzwerkverbindungsproblem\",\n    \"requestTimeout\": \"Operationen außerhalb des Bereichs\",\n    \"failedDependency\": \"Abhängigkeitsfehler\",\n    \"automationNodeParseError\": \"Automatisierungsknoten-Analysefehler\",\n    \"automationNodeNeedTest\": \"Automatisierungsknoten benötigt Test\",\n    \"automationNodeTestOutdated\": \"Automatisierungsknoten-Test veraltet\",\n    \"invalidToken\": \"Ungültiges Token\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" Feld \\\"{{fieldName}}\\\" darf keine leeren Werte enthalten, bitte vollständig ausfüllen bevor Sie absenden.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" Feld \\\"{{fieldName}}\\\" darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" Feld darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.\",\n      \"requestTimeout\": \"Der aktuelle Vorgangsbereich ist zu groß, bitte versuchen Sie es mit einem kleineren Bereich erneut.\",\n      \"searchTimeOut\": \"Suche ist abgelaufen, bitte die Suchbegriffe reduzieren und erneut versuchen.\",\n      \"dependencyNodeRequire\": \"Abhängiger Knoten nicht getestet, bitte überprüfen Sie, ob alle vorherigen Knoten getestet wurden\",\n      \"invalidOperation\": \"Ungültige Operation erkannt, bitte überprüfen Sie die Operationsparameter\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"Die Anzahl der angeforderten Kommentare übersteigt das maximale Limit von 1000\",\n      \"invalidContentType\": \"Ungültiger Kommentarinhaltstyp\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"Die Token-Ablaufzeit muss weniger als 7 Tage betragen\",\n      \"s3RegionRequired\": \"S3-Region ist erforderlich\",\n      \"s3EndpointRequired\": \"S3-Endpunkt ist erforderlich\",\n      \"s3AccessKeyRequired\": \"S3-Zugriffsschlüssel ist erforderlich\",\n      \"s3SecretKeyRequired\": \"S3-Geheimschlüssel ist erforderlich\",\n      \"s3UploadMethodMustBePut\": \"Die S3-Upload-Methode muss PUT sein\",\n      \"presignedError\": \"Fehler beim Generieren der vorsignierten URL\",\n      \"invalidObjectMeta\": \"Ungültige Objektmetadaten\",\n      \"invalidImageStream\": \"Ungültiger Bildstream\",\n      \"calculateImageSizeFailed\": \"Fehler beim Berechnen der Bildgröße\",\n      \"uploadFailed\": \"Upload fehlgeschlagen\",\n      \"invalidImage\": \"Ungültiges Bild\",\n      \"cantGetImageStream\": \"Bildstream kann nicht abgerufen werden\",\n      \"invalidProvider\": \"Ungültiger Speicheranbieter\",\n      \"failedToDeleteDirectory\": \"Fehler beim Löschen des Verzeichnisses\",\n      \"invalidToken\": \"Ungültiges Token\",\n      \"tokenExpired\": \"Token ist abgelaufen\",\n      \"sizeMismatch\": \"Dateigröße stimmt nicht überein\",\n      \"notAllowUploadFileType\": \"Dateityp {{mimetype}} für Upload nicht erlaubt\",\n      \"notFound\": \"Anhang nicht gefunden\",\n      \"invalidPath\": \"Ungültiger Anhangpfad\",\n      \"fileSizeExceedsMaximumLimit\": \"Dateigröße überschreitet das maximale Limit von {{maxSize}}\",\n      \"invalidUploadType\": \"Ungültiger Upload-Typ\",\n      \"urlReject\": \"URL abgelehnt oder nicht erreichbar\"\n    },\n    \"email\": {\n      \"testEmailError\": \"E-Mail-Konfigurationsfehler {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Dieses Button-Feld ist bereits mit {{name}}[{{id}}] Automatisierungsprozess verbunden, bitte wählen Sie ein anderes Button-Feld\",\n      \"triggerNotFound\": \"Automatisierung muss einen Trigger-Knoten haben\",\n      \"nodeNotFound\": \"{{nodeId}} Knoten nicht gefunden\",\n      \"triggerTestFailed\": \"Trigger-Test fehlgeschlagen, bitte überprüfen Sie die Trigger-Konfiguration\",\n      \"testFailed\": \"Automatisierungstest fehlgeschlagen, bitte überprüfen Sie die Automatisierungskonfiguration\",\n      \"runFailed\": \"Automatisierungsausführung fehlgeschlagen\",\n      \"nodeParseError\": \"{{name}} Knotenkonfiguration ist unvollständig oder fehlerhaft, bitte überprüfen Sie die Knotenkonfiguration\",\n      \"nodeNeedTest\": \"{{name}} Knoten muss getestet werden\",\n      \"nodeTestOutdated\": \"{{name}} Knotentest ist veraltet\",\n      \"notFound\": \"Automatisierung nicht gefunden\",\n      \"currentSnapshotEmpty\": \"Der aktuelle Automatisierungs-Snapshot ist leer\",\n      \"runNotFound\": \"Automatisierungslauf nicht gefunden\",\n      \"anchorNotFound\": \"Anker-Automatisierung nicht gefunden\",\n      \"validationError\": \"Fehler bei der Validierung der Automatisierungskonfiguration\",\n      \"tableNotInBase\": \"Sie können nur eine Tabelle innerhalb Ihrer Datenbank abonnieren\",\n      \"alreadyActiveAndNotDraft\": \"Automatisierung ist bereits aktiv und kein Entwurf\",\n      \"noActiveSnapshot\": \"Automatisierung hat keinen aktiven Snapshot\",\n      \"triggerNodeAlreadyExists\": \"Diese Automatisierung hat bereits einen Trigger-Knoten\",\n      \"generateLogicError\": \"Fehler beim Generieren des Logikknotens\",\n      \"logicNotFound\": \"Automatisierungs-Logikknoten nicht gefunden\",\n      \"actionNotFound\": \"Automatisierungs-Aktionsknoten nicht gefunden\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Duplizierung dieses Automatisierungs-Knotentyps nicht unterstützt\",\n      \"unSupportLogicType\": \"Nicht unterstützter Logiktyp\",\n      \"groupEndNotFound\": \"GroupEnd für Logik nicht gefunden\",\n      \"insertNodeError\": \"Fehler beim Einfügen des Knotens\",\n      \"controlNodeNotBeTested\": \"Kontrollknoten sollte nicht getestet werden\",\n      \"invalidNodeType\": \"Ungültiger Knotentyp\",\n      \"unsupportedCategory\": \"Nicht unterstützte Kategorie\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Postfächerliste konnte nicht abgerufen werden: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Unbekannter Datensatz: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Scrape-Dienst ist nicht konfiguriert\",\n      \"triggerFailed\": \"Scrape-Auslösung fehlgeschlagen: {{detail}}\",\n      \"snapshotError\": \"Scrape-Snapshot-Fehler: {{detail}}\",\n      \"timeout\": \"Scrape-Zeitüberschreitung nach {{seconds}}s\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Space nicht gefunden\",\n      \"noPermission\": \"Sie haben keine Berechtigung, auf diesen Space zuzugreifen\",\n      \"disallowSpaceCreation\": \"Die Space-Erstellung wurde vom Administrator deaktiviert\",\n      \"cannotChangeOnlyOwnerRole\": \"Die Rolle des einzigen Besitzers des Space kann nicht geändert werden\",\n      \"cannotDeleteOnlyOwner\": \"Der einzige Besitzer des Space kann nicht gelöscht werden\",\n      \"deleted\": \"Space wurde gelöscht\",\n      \"cannotOperate\": \"Kann nicht auf einem Space operieren, bitte überprüfen Sie, dass es Organisationsmitglieder im Space gibt und sie Eigentümer sind\",\n      \"notBelongToOrg\": \"Dieser Space gehört nicht zur Organisation\",\n      \"invalidSpaceIds\": \"Space-IDs sind ungültig: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base nicht gefunden\",\n      \"cannotAccess\": \"Sie haben keine Berechtigung, auf Base {{baseId}} zuzugreifen\",\n      \"anchorNotFound\": \"Anker-Base {{anchorId}} nicht gefunden\",\n      \"baseAndSpaceMismatch\": \"Base {{baseId}} und Space {{spaceId}} stimmen nicht überein\",\n      \"templateNotFound\": \"Vorlage {{templateId}} nicht gefunden\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"Base-ID ist erforderlich\",\n      \"nodeIdIsRequired\": \"Knoten-ID ist erforderlich\",\n      \"invalidResourceType\": \"Ungültiger Ressourcentyp\",\n      \"notFound\": \"Basisknoten nicht gefunden\",\n      \"parentMustBeFolder\": \"Übergeordnetes Element muss ein Ordner sein\",\n      \"cannotDuplicateFolder\": \"Ordner kann nicht dupliziert werden\",\n      \"cannotDeleteEmptyFolder\": \"Ordner kann nicht gelöscht werden, da er nicht leer ist\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Es darf nur parentId oder anchorId angegeben werden\",\n      \"cannotMoveToItself\": \"Knoten kann nicht zu sich selbst verschoben werden\",\n      \"cannotMoveToCircularReference\": \"Knoten kann nicht zu seinem eigenen Unterknoten verschoben werden (Zirkelverweis)\",\n      \"anchorIdOrParentIdRequired\": \"Mindestens parentId oder anchorId muss angegeben werden\",\n      \"parentNotFound\": \"Übergeordneter Knoten nicht gefunden\",\n      \"parentIsNotFolder\": \"Übergeordnetes Element ist kein Ordner\",\n      \"circularReference\": \"Zirkelverweis erkannt\",\n      \"folderDepthLimitExceeded\": \"Ordnertiefenlimit überschritten\",\n      \"folderNotFound\": \"Ordner nicht gefunden\",\n      \"anchorNotFound\": \"Ankerknoten nicht gefunden\",\n      \"nameAlreadyExists\": \"Name existiert bereits\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Dashboard nicht gefunden\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Plugin nicht gefunden\",\n      \"notSupportInstallInView\": \"Plugin unterstützt keine Installation in der Ansicht\",\n      \"userNotFound\": \"Plugin-Benutzer nicht gefunden\",\n      \"invalidSecret\": \"Ungültiges Geheimnis\",\n      \"invalidRefreshToken\": \"Ungültiges Aktualisierungstoken\",\n      \"anomalousToken\": \"Anomales Token\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Plugin-Panel nicht gefunden\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Plugin nicht installiert\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Falsches Passwort\",\n      \"notAllowedToSubmit\": \"Formularübermittlung nicht erlaubt\",\n      \"viewRequired\": \"Ansicht ist für diesen Vorgang erforderlich\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"Formularübermittlung nicht erlaubt, wenn versteckte Felder enthalten sind\",\n      \"submitRecordsError\": \"Datensatzübermittlung fehlgeschlagen\",\n      \"notAllowedToCopy\": \"Kopieren nicht erlaubt\",\n      \"fieldHiddenNotAllowed\": \"Feld ist versteckt und kann nicht aufgerufen werden\",\n      \"fieldTypeNotLinkField\": \"Feld ist kein Verknüpfungsfeld\",\n      \"fieldIdRequired\": \"Feld-ID ist erforderlich\",\n      \"fieldNotUserRelatedField\": \"Feld ist kein benutzerbezogenes Feld\",\n      \"viewTypeNotAllowed\": \"Dieser Ansichtstyp ist für diesen Vorgang nicht erlaubt\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"Passwortbeschränkung ist für diese geteilte Ansicht nicht aktiviert\",\n      \"shareViewNotFound\": \"Geteilte Ansicht nicht gefunden oder Freigabe ist deaktiviert\",\n      \"linkFieldNotFound\": \"Verknüpfungsfeld nicht gefunden\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Base-Freigabe nicht gefunden oder Freigabe ist deaktiviert\",\n      \"alreadyExists\": \"Eine Freigabe existiert bereits für diesen Knoten\",\n      \"copyNotAllowed\": \"Diese Freigabe erlaubt kein Kopieren\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"Sie haben keine Berechtigung, auf diese Ansicht zuzugreifen\",\n      \"fieldPermissionNotAllowed\": \"Sie haben keine Berechtigung, auf diese Felder zuzugreifen\",\n      \"recordPermissionNotAllowed\": \"Sie haben keine Berechtigung, auf diese Datensätze zuzugreifen\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Plugin-Kontextmenü nicht gefunden\",\n      \"anchorNotFound\": \"Plugin-Kontextmenü-Anker nicht gefunden\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Plugin-Diagramm-Abfrage nicht gefunden\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Nicht unterstützter Datenbanktreiber: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Nur der Base-Besitzer kann die Datenbankverbindung für Base {{baseId}} entfernen\",\n      \"onlyOwnerCanCreate\": \"Nur der Base-Besitzer kann eine Datenbankverbindung für Base {{baseId}} erstellen\",\n      \"roleNotExist\": \"Datenbankrolle {{role}} existiert nicht\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Abfrage fehlgeschlagen: {{message}}\",\n      \"invalidJoinType\": \"Ungültiger Join-Typ: {{joinType}}\",\n      \"tableNotFound\": \"Tabelle {{tableId}} nicht gefunden in Base {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Ausführung von SQL mit Schlüsselwort {{keyword}} nicht erlaubt\",\n      \"whiteListCheckError\": \"Fehler beim Überprüfen des Tabellenzugriffs: {{message}}\",\n      \"databaseConnectionFailed\": \"Datenbankverbindung fehlgeschlagen: {{message}}\",\n      \"executeQuerySqlFailed\": \"Ausführung der Abfrage-SQL fehlgeschlagen: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"Sie haben keine Berechtigung, Datensätze mit Feldern({{fields}}) zu erstellen\",\n      \"deleteRecords\": \"Sie haben keine Berechtigung, Datensätze({{recordIds}}) zu löschen\",\n      \"readRecordWithDeniedFields\": \"Sie haben keine Berechtigung, Felder({{fields}}) im Datensatz({{recordId}}) zu lesen\",\n      \"updateRecordWithDeniedFields\": \"Sie haben keine Berechtigung, Felder({{fields}}) im Datensatz({{recordId}}) zu aktualisieren\",\n      \"checkIdNotExist\": \"Berechtigungsprüfungs-ID existiert nicht\",\n      \"userNotAdmin\": \"Benutzer ist kein Administrator\",\n      \"accessTokenNoPermission\": \"Zugriffstoken hat nicht die erforderliche Berechtigung\",\n      \"invalidResource\": \"Ressource ist nicht gültig\",\n      \"notAllowedSpace\": \"Sie haben keine Berechtigung, auf diesen Space zuzugreifen\",\n      \"notAllowedBase\": \"Sie haben keine Berechtigung, auf diese Base zuzugreifen\",\n      \"notAllowedTables\": \"Sie haben keine Berechtigung, auf Tabellen({{tableIds}}) zuzugreifen\",\n      \"notAllowedOperationTable\": \"Sie haben keine Berechtigung, diese Tabelle zu bearbeiten\",\n      \"notAllowedOperationRecord\": \"Sie haben keine Berechtigung, diesen Datensatz zu bearbeiten\",\n      \"notAllowedRecordUpdate\": \"Sie haben keine Berechtigung, diesen Datensatz zu aktualisieren\",\n      \"notAllowedOperationView\": \"Sie haben keine Berechtigung, diese Ansicht zu bearbeiten\",\n      \"deniedByEnabledAuthorityMatrix\": \"Berechtigung durch aktivierte Berechtigungsmatrix verweigert\",\n      \"invalidRequestPath\": \"Anfragepfad ist nicht gültig\",\n      \"notAllowedOperation\": \"Sie haben keine Berechtigung, diese Operation auszuführen\",\n      \"notAllowedDepartment\": \"Sie haben keine Berechtigung, auf diese Abteilung zuzugreifen\",\n      \"templateHeaderInvalid\": \"Vorlagen-Header ist ungültig\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Standardrolle nicht gefunden\",\n      \"alreadyDisabled\": \"Berechtigungsmatrix ist bereits deaktiviert\",\n      \"alreadyEnabled\": \"Berechtigungsmatrix ist bereits aktiviert\",\n      \"notFound\": \"Berechtigungsmatrix nicht gefunden\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Primärfeld kann nicht für Lesezugriff deaktiviert werden\",\n      \"fieldDuplicated\": \"Feld ist in der Berechtigungskonfiguration dupliziert\",\n      \"cannotSetRecordPermissionGroup\": \"Diese Kombination von Datensatzberechtigungen({{actions}}) kann nicht festgelegt werden\",\n      \"notFoundBaseAndTable\": \"Base-ID und Tabellen-ID nicht gefunden\",\n      \"roleTablesShouldNotBeEmpty\": \"Berechtigungsmatrix-Rollentabellen dürfen nicht leer sein\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Ungültiger Rückgabetyp\",\n      \"exceedMaxReadRows\": \"Maximale Anzahl der zu lesenden Zeilen überschritten\",\n      \"invalidCellValueType\": \"Ungültiger Zellentyp\",\n      \"exceedMaxCopyCells\": \"Maximale Anzahl der zu kopierenden Zellen überschritten\",\n      \"exceedMaxPasteCells\": \"Maximale Anzahl der einzufügenden Zellen überschritten\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Nicht unterstützter Feldtyp {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Nicht unterstützter Feldtyp {{type}} als Primärfeld\",\n      \"primaryFieldNotSupported\": \"Feldtyp wird nicht als Primärfeld unterstützt\",\n      \"calculateRecordNotFound\": \"Datensatz nicht gefunden für: {{value}}, fieldId: {{fieldId}}, bei Berechnung {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds oder fromRecordIds ist für normales berechnetes Feld erforderlich\",\n      \"recordFieldsRequired\": \"Datensatzfelder sind undefiniert\",\n      \"uniqueUnsupportedType\": \"Feld {{name}}[{{fieldId}}] unterstützt keine eindeutige Feldwertvalidierung\",\n      \"notNullValidationWhenCreateField\": \"Feld {{name}}[{{fieldId}}] unterstützt keine Nicht-Null-Validierung beim Erstellen eines neuen Feldes\",\n      \"dbFieldNameAlreadyExists\": \"Datenbankfeldname {{dbFieldName}} existiert bereits\",\n      \"fieldValidationError\": \"Feld {{name}}[{{fieldId}}] Feldvalidierungsfehler\",\n      \"fieldNameAlreadyExists\": \"Feldname {{name}} existiert bereits\",\n      \"notFound\": \"Feld nicht gefunden\",\n      \"fieldKeyTypeNotFound\": \"Feld \\\"{{fieldKeyType}}: {{missedFields}}\\\" nicht gefunden\",\n      \"notFoundInTable\": \"Feld {{fieldId}} nicht in Tabelle {{tableId}} gefunden\",\n      \"deleteFieldsNotFound\": \"Zu löschende Felder {{fieldIds}} nicht in Tabelle {{tableId}} gefunden\",\n      \"lookupValuesShouldBeArray\": \"lookupValues sollte ein Array sein, wenn das Verknüpfungsfeld mehrere Zellenwerte hat\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues sollte ein Array sein, wenn das Verknüpfungsfeld mehrere Zellenwerte hat\",\n      \"lookupAndLinkLengthMatch\": \"lookupValues-Länge sollte gleich linkCellValues-Länge sein\",\n      \"cycleDetected\": \"Zyklus erkannt\",\n      \"cycleDetectedCreateField\": \"Zyklus erkannt, Feld {{name}}[{{id}}] kann nicht erstellt werden\",\n      \"recordMapNotFound\": \"Datensatz nicht gefunden in Tabelle {{tableName}} für Feld {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Löschen des Primärfeldes verboten\",\n      \"foreignTableIdInvalid\": \"Fremdtabelle {{foreignTableId}} ungültig\",\n      \"relationshipInvalid\": \"Beziehung {{relationship}} ungültig\",\n      \"linkFieldIdInvalid\": \"Verknüpfungsfeld {{linkFieldId}} ungültig\",\n      \"lookupFieldIdInvalid\": \"Nachschlagefeld {{lookupFieldId}} ungültig\",\n      \"formulaExpressionParseError\": \"Formelausdrucks-Parsefehler\",\n      \"formulaReferenceNotFound\": \"Formelreferenzfeld {{fieldIds}} nicht gefunden\",\n      \"formulaReferenceNotFieldId\": \"Formelreferenzen {{fieldIds}} nicht gefunden. Formeln müssen Feld-IDs (fldXXXXXXXXXXXXXXXX Format) verwenden, nicht Feldnamen.\",\n      \"rollupExpressionParseError\": \"Rollup-Ausdrucks-Parsefehler\",\n      \"choiceNameAlreadyExists\": \"Auswahlname {{name}} existiert bereits\",\n      \"symmetricFieldIdRequired\": \"Symmetrische Feld-ID ist erforderlich\",\n      \"foreignKeyNameCannotUseId\": \"Fremdschlüsselname kann nicht __id verwenden\",\n      \"createForeignKeyError\": \"Fehler beim Erstellen des Fremdschlüssels\",\n      \"lookupFieldTypeNotEqual\": \"Aktueller Feldtyp {{fieldType}} ist nicht gleich Nachschlagefeldtyp {{lookupFieldType}}\",\n      \"recordNotFound\": \"Datensatz {{recordId}} nicht gefunden in {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Doppelte recordId kann nicht gesetzt werden: {{recordId}} in derselben Zelle\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Eins-zu-Eins-Verknüpfungsfeldwerte können kein Array sein\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Viele-zu-Eins-Verknüpfungsfeldwerte können kein Array sein\",\n      \"foreignKeyDuplicate\": \"Fremdschlüssel dupliziert\",\n      \"linkConsistencyError\": \"Konsistenzfehler, recordId {{recordId}} existiert nicht\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Eins-zu-Viele-Verknüpfungsfeldwerte sollten ein Array sein\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Viele-zu-Viele-Verknüpfungsfeldwerte sollten ein Array sein\",\n      \"onlyLinkFieldCanBeFiltered\": \"Nur Verknüpfungsfelder können zum Filtern verwendet werden\",\n      \"notLinkedToCurrentTable\": \"Feld ist nicht mit der aktuellen Tabelle verknüpft\",\n      \"notAttachment\": \"Feld ist kein Anhangfeld\",\n      \"isComputed\": \"Feld ist berechnet und kann nicht geändert werden\",\n      \"notFoundAICofig\": \"Feld hat keine AI-Konfiguration\",\n      \"foreignTableIdRequired\": \"Fremdtabelle ist erforderlich\",\n      \"lookupFieldIdRequired\": \"Suchfeld ist erforderlich\",\n      \"lookupFieldNotExist\": \"Suchfeld {{lookupFieldId}} existiert nicht\",\n      \"lookupFieldNotBelongToTable\": \"Suchfeld {{lookupFieldId}} gehört nicht zur Tabelle {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Aktueller Feldtyp {{fieldType}} stimmt nicht mit Suchfeldtyp {{lookupFieldType}} überein\",\n      \"conditionalRollupOptionsRequired\": \"Optionen für bedingtes Rollup-Feld sind erforderlich\",\n      \"conditionalRollupParseError\": \"Fehler beim Parsen des bedingten Rollups: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Optionen für bedingtes Lookup-Feld sind erforderlich\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Anzahl der Schaltflächenklicks hat das Maximum erreicht\",\n        \"notSupportReset\": \"Schaltflächenfeld unterstützt kein Zurücksetzen\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Ansicht nicht gefunden\",\n      \"cannotDeleteLastView\": \"Die letzte Ansicht in einer Tabelle kann nicht gelöscht werden. Eine Tabelle muss mindestens eine Ansicht haben.\",\n      \"defaultViewNotFound\": \"Standardansicht nicht gefunden\",\n      \"propertyParseError\": \"Fehler beim Parsen der Ansichtseigenschaft\",\n      \"primaryFieldCannotBeHidden\": \"Primärfeld kann nicht ausgeblendet werden\",\n      \"filterUnsupportedFieldType\": \"Filter unterstützt Feldtyp nicht\",\n      \"filterInvalidOperator\": \"Filter hat ungültigen Operator für diesen Feldtyp\",\n      \"filterInvalidOperatorMode\": \"Filter hat ungültige Operator- und Modus-Kombination\",\n      \"sortUnsupportedFieldType\": \"Sortierung unterstützt Feldtyp nicht\",\n      \"groupUnsupportedFieldType\": \"Gruppierung unterstützt Feldtyp nicht\",\n      \"anchorNotFound\": \"Anker-Ansicht nicht gefunden\",\n      \"notEnoughGapToShuffleRow\": \"Nicht genügend Platz zum Mischen der Zeile\",\n      \"shareNotEnabled\": \"Ansichtsfreigabe ist nicht aktiviert\",\n      \"shareAlreadyEnabled\": \"Ansichtsfreigabe ist bereits aktiviert\",\n      \"shareAlreadyDisabled\": \"Ansichtsfreigabe ist bereits deaktiviert\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Unzureichendes Guthaben\",\n      \"exceedMaxRowLimit\": \"Maximale Zeilenanzahl {{maxRowCount}} überschritten\",\n      \"exceedMaxAutomationRunLimit\": \"Maximale monatliche Anzahl von Automatisierungsausführungen erreicht\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Suchanfrage ist erforderlich\",\n      \"maxSearchIndexResult\": \"Das maximale Suchindexergebnis beträgt 1000\",\n      \"queryCollectionMustBeTableId\": \"Die Abfragesammlung muss eine Tabellen-ID sein\",\n      \"searchTimeOut\": \"Suche ist abgelaufen, bitte die Suchbegriffe reduzieren und erneut versuchen.\",\n      \"indexNotFound\": \"Index nicht gefunden\",\n      \"invalidStartDateFieldId\": \"Ungültige Startdatum-Feld-ID\",\n      \"invalidEndDateFieldId\": \"Ungültige Enddatum-Feld-ID\",\n      \"fieldMapRequired\": \"Feldkarte ist erforderlich, wenn Suche festgelegt ist\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected und filterLinkCellCandidate können nicht gleichzeitig festgelegt werden\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"KI-Chat-Modell lg ist nicht festgelegt\",\n      \"chatModelLgProviderNotSet\": \"KI-Chat-Modell lg Anbieter ist nicht festgelegt\",\n      \"chatModelSmNotSet\": \"KI-Chat-Modell sm ist nicht festgelegt\",\n      \"chatModelMdNotSet\": \"KI-Chat-Modell md ist nicht festgelegt\",\n      \"configurationNotSet\": \"KI-Konfiguration ist nicht festgelegt\",\n      \"unsupportedProvider\": \"Nicht unterstützter KI-Anbieter {{type}}\",\n      \"providerConfigurationNotSet\": \"KI-Anbieter-Konfiguration ist nicht festgelegt\",\n      \"testLLMFailed\": \"LLM-Verbindungstest fehlgeschlagen\",\n      \"audioNotSupported\": \"Audio-Eingabe wird von diesem Modell {{model}} nicht unterstützt\",\n      \"imageNotSupported\": \"Bild-Eingabe wird von diesem Modell {{model}} nicht unterstützt\",\n      \"modelNotSet\": \"KI-Modell ist nicht festgelegt\",\n      \"unsupportedFileType\": \"Nicht unterstützter Dateityp {{mimetype}}\",\n      \"unsupportedModelType\": \"Nicht unterstützter Modelltyp\",\n      \"embeddingModelNotSet\": \"Einbettungsmodell ist nicht festgelegt\",\n      \"validateActionFailed\": \"Validierung der Feld-AI-Aktion fehlgeschlagen\",\n      \"generateFailed\": \"AI-Generierung fehlgeschlagen\",\n      \"unsupportedActionType\": \"Nicht unterstützter AI-Aktionstyp\"\n    },\n    \"role\": {\n      \"notFound\": \"Rolle nicht gefunden\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Mitarbeiter existiert bereits\",\n      \"notFound\": \"Mitarbeiter nicht gefunden\",\n      \"userNotFoundInCollaborator\": \"Benutzer wurde in Mitarbeitern nicht gefunden\",\n      \"noPermissionToDelete\": \"Sie haben keine Berechtigung, diesen Mitarbeiter zu löschen\",\n      \"noPermissionToUpdate\": \"Sie haben keine Berechtigung, diesen Mitarbeiter zu aktualisieren\",\n      \"noPermissionToOperateRole\": \"Sie haben keine Berechtigung, diese Rolle zu verwalten\",\n      \"alreadyExistedInBase\": \"Mitarbeiter existiert bereits in der Datenbank\",\n      \"userNotFound\": \"Benutzer nicht gefunden: {{userIds}}\",\n      \"baseNotFound\": \"Datenbank nicht gefunden\",\n      \"noPermissionToAddRole\": \"Sie haben keine Berechtigung, einen Mitarbeiter mit dieser Rolle hinzuzufügen\",\n      \"departmentNotFound\": \"Abteilung nicht gefunden\"\n    },\n    \"table\": {\n      \"notFound\": \"Tabelle nicht gefunden\",\n      \"dbTableNameAlreadyExists\": \"Datenbanktabellenname existiert bereits\",\n      \"anchorNotFound\": \"Anker-Tabelle nicht gefunden\",\n      \"notInTrash\": \"Tabelle befindet sich nicht im Papierkorb\",\n      \"notSupportTableIndex\": \"Tabellenindex-Typ wird nicht unterstützt\",\n      \"createTableIndexError\": \"Fehler beim Erstellen des Tabellenindex\",\n      \"dropTableIndexError\": \"Fehler beim Löschen des Tabellenindex\",\n      \"notFoundPrimaryField\": \"Primärfeld in Tabelle nicht gefunden\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"{{viewType}} Ansichtstyp wird nicht für den Export unterstützt\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Dateiformat wird nicht unterstützt, nur {{supportType}} werden unterstützt, Ihr Dateityp ist {{fileFormat}}\",\n      \"notSupportedFileType\": \"Import-Dateityp nicht unterstützt\",\n      \"exceedMaxFieldsLength\": \"Die Anzahl der Felder in der Tabelle darf {{maxFieldsLength}} nicht überschreiten, aktuell ist {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"Die aktuelle Instanz verbietet Raumeinladungen durch den Administrator\",\n      \"invalidCode\": \"Ungültiger Einladungscode\",\n      \"linkNotFound\": \"Einladungslink nicht gefunden\",\n      \"linkExpired\": \"Einladungslink ist abgelaufen\",\n      \"limitExceeded\": \"Sie haben die maximale Anzahl an Einladungen pro Stunde erreicht\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Favorit bereits vorhanden\",\n      \"notFound\": \"Favorit nicht gefunden\",\n      \"anchorNotFound\": \"Anker-Favorit nicht gefunden\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Ungültiger Ressourcentyp\",\n      \"notFound\": \"Papierkorb-Element nicht gefunden\",\n      \"parentSpaceTrashed\": \"Diese Basis kann nicht wiederhergestellt werden, da der übergeordnete Bereich ebenfalls im Papierkorb ist\",\n      \"parentBaseOrSpaceTrashed\": \"Diese Tabelle kann nicht wiederhergestellt werden, da die übergeordnete Basis oder der Bereich ebenfalls im Papierkorb ist\",\n      \"parentBaseTrashed\": \"Dieses Element kann nicht wiederhergestellt werden, da seine übergeordnete Basis ebenfalls im Papierkorb ist\",\n      \"parentNotFound\": \"Übergeordnete Ressource nicht gefunden\",\n      \"tableNotFound\": \"Tabellen-Papierkorb-Element nicht gefunden\"\n    },\n    \"license\": {\n      \"invalid\": \"Die Lizenz ist ungültig\",\n      \"instanceIdMismatch\": \"Die eingehende Instanz-ID stimmt nicht mit der Instanz-ID der aktuellen Instanz überein\",\n      \"expired\": \"Die Lizenz ist abgelaufen\",\n      \"userLimitExceeded\": \"Die Anzahl der Benutzer in der aktuellen Instanz überschreitet das Platzlimit der Lizenz. Bitte deaktivieren Sie einige Benutzer oder aktualisieren Sie die Lizenz\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Kein Domain-Verifizierungscode gefunden\",\n      \"invalidCode\": \"Ungültiger Verifizierungscode\",\n      \"resendCooldown\": \"Bitte warten Sie 1 Minute bevor Sie einen neuen Code anfordern\",\n      \"alreadyVerified\": \"Domain bereits verifiziert\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organisation nicht gefunden\",\n      \"authenticationNotFound\": \"Authentifizierung nicht gefunden\",\n      \"spaceShouldExist\": \"Organisations-Space sollte existieren\",\n      \"emailsNotInOrgDomain\": \"Diese E-Mails {{emails}} sind nicht in der Organisations-Domain\",\n      \"emailNotSpaceUser\": \"E-Mail ist kein Space-Benutzer\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"E-Mail senden fehlgeschlagen\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"Die aktuelle Instanz erlaubt keine Anmeldungen durch den Administrator\",\n      \"waitlistInviteCodeRequired\": \"Warteliste ist aktiviert, Einladungscode ist erforderlich\",\n      \"waitlistInviteCodeInvalid\": \"Warteliste ist aktiviert, Einladungscode ist ungültig\",\n      \"systemUser\": \"Benutzer ist ein Systembenutzer\",\n      \"collaboratorsInSpaces\": \"Benutzer hat Mitarbeiter in Bereichen (oder gelöschte Bereiche im Papierkorb)\",\n      \"notFound\": \"Benutzer nicht gefunden\",\n      \"cannotDeleteAdmin\": \"Administrator-Benutzer kann nicht gelöscht werden\",\n      \"cannotDeactivateAdmin\": \"Administrator-Benutzer kann nicht deaktiviert werden\",\n      \"cannotRemoveLastAdmin\": \"Administratorberechtigung kann nicht vom letzten aktiven Administrator-Benutzer entfernt werden\",\n      \"permanentDeleted\": \"Benutzer ist dauerhaft gelöscht\",\n      \"cannotDeleteSelf\": \"Sie können sich nicht selbst löschen\",\n      \"alreadyInDepartment\": \"Benutzer {{userId}} ist bereits in der Zielabteilung\",\n      \"emailsNotFound\": \"E-Mails {{emails}} wurden nicht gefunden\",\n      \"deleted\": \"Benutzer {{userId}} wurden gelöscht\",\n      \"alreadyInOrg\": \"Benutzer {{userId}} sind bereits in der Organisation\",\n      \"notInOrg\": \"Benutzer {{userId}} ist nicht in der Organisation\"\n    },\n    \"record\": {\n      \"notFound\": \"Datensatz nicht gefunden\",\n      \"deletedIdsNotFound\": \"Einige zu löschende Datensätze wurden nicht gefunden\",\n      \"updateFailed\": \"Fehler beim Aktualisieren des Datensatzes\",\n      \"noFileOrUrlProvided\": \"Keine Datei oder URL bereitgestellt\",\n      \"createRecordsEmpty\": \"Datensätze erstellen darf nicht leer sein\",\n      \"duplicateFailed\": \"Fehler beim Duplizieren des Datensatzes\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Zellenwertvalidierung fehlgeschlagen\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Workflow ist nicht aktiv\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Ungültiger Ressourcentyp\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Vorlagenkategorie nicht gefunden\",\n      \"snapshotRequired\": \"Diese Vorlage konnte nicht veröffentlicht werden, da ein Snapshot fehlt\",\n      \"sourceTemplateNotFound\": \"Quellvorlage nicht gefunden\",\n      \"noMinOrderFound\": \"Keine Minimalreihenfolge gefunden\",\n      \"takeCountTooLarge\": \"Die Anzahl der angeforderten Vorlagen überschreitet das Maximum\",\n      \"categoryLimitReached\": \"Vorlagenkategorie-Limit erreicht (maximal {{maxCount}})\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Übergeordnete Abteilung nicht gefunden\",\n      \"notFound\": \"Abteilung nicht gefunden\",\n      \"cannotMoveToItself\": \"Abteilung kann nicht zu sich selbst verschoben werden\",\n      \"cannotMoveToSub\": \"Abteilung kann nicht zu ihrer Unterabteilung verschoben werden\"\n    },\n    \"app\": {\n      \"notFound\": \"App nicht gefunden\",\n      \"noFilesToUpdate\": \"Keine Dateien zum Aktualisieren\",\n      \"noChatIdFound\": \"Keine Chat-ID für diese App gefunden\",\n      \"noChatFound\": \"Kein Chat für diese App gefunden\",\n      \"versionNotFound\": \"Version nicht gefunden\",\n      \"cannotRollbackToLatestVersion\": \"Rollback auf die neueste Version nicht möglich\",\n      \"noChatOrProjectTokenFound\": \"Kein Chat oder Projekt-Token gefunden\",\n      \"apiKeyNotSet\": \"App Builder API-Schlüssel ist nicht festgelegt\",\n      \"cannotDeployAppBeforeInitialization\": \"App kann vor der Initialisierung nicht bereitgestellt werden\",\n      \"noProjectOrVersionFound\": \"Kein Projekt oder Version gefunden\",\n      \"noDeploymentUrlAvailable\": \"Keine Bereitstellungs-URL verfügbar\"\n    },\n    \"reward\": {\n      \"notFound\": \"Belohnung nicht gefunden\",\n      \"unsupportedSourceType\": \"Nicht unterstützter Belohnungsquellentyp\",\n      \"maxClaimsReached\": \"Sie haben die maximale Anzahl an Belohnungsansprüchen (2) für diese Woche erreicht\",\n      \"verificationFailed\": \"Verifizierung fehlgeschlagen: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Sie haben bereits eine Belohnung für dieses Konto in dieser Woche beansprucht\",\n      \"invalidPostUrl\": \"Ungültiges Beitrags-URL-Format\",\n      \"postAlreadyUsed\": \"Dieser Beitrag wurde bereits verwendet, um eine Belohnung zu beanspruchen\",\n      \"unsupportedPlatformUrl\": \"Nicht unterstützte Social-Media-Plattform-URL\",\n      \"unsupportedPlatform\": \"Nicht unterstützte Plattform: {{platform}}\",\n      \"minCharCount\": \"Beitrag muss mindestens {{count}} Zeichen haben\",\n      \"minFollowerCount\": \"Konto muss mindestens {{count}} Follower haben\",\n      \"mustMention\": \"Beitrag muss {{mention}} erwähnen\",\n      \"fetchTweetFailed\": \"Abrufen des X-Tweets fehlgeschlagen: {{error}}\",\n      \"tweetNotFound\": \"X-Tweet nicht gefunden: {{postId}}\",\n      \"fetchUserFailed\": \"Abrufen des X-Benutzers fehlgeschlagen: {{error}}\",\n      \"xUserNotFound\": \"X-Benutzer nicht gefunden: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Abrufen des LinkedIn-Beitrags fehlgeschlagen: {{error}}\",\n      \"linkedInPostNotFound\": \"LinkedIn-Beitrag nicht gefunden: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"LinkedIn-Autor nicht gefunden: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Abrufen des LinkedIn-Benutzers fehlgeschlagen: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Persönliche Zugangstoken\",\n  \"oauthApps\": \"OAuth Apps\",\n  \"plugins\": \"Plugins\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Geben Sie Ihr Passwort ein, um diese Seite zu sehen\",\n    \"submit\": \"Abschicken\",\n    \"password\": \"Passwort\",\n    \"passwordTooShort\": \"Das Passwort muss mindestens 3 Zeichen lang sein\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Auswählen...\"\n  },\n  \"openOnNewPage\": \"Auf neuer Seite öffnen\",\n  \"errorTips\": \"Die Freigabequelle hat eine Authority Matrix aktiviert, die Anzeige ist nicht erlaubt\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}' Space\",\n  \"action\": {\n    \"createBase\": \"Base erstellen\",\n    \"createSpace\": \"Eine Base erstellen\",\n    \"invite\": \"Einladen\"\n  },\n  \"allSpaces\": \"Alle Spaces\",\n  \"emptySpaceTitle\": \"Keine Bases in diesem Space\",\n  \"spaceIsEmpty\": \"Erstellen Sie Ihre erste Base, um zu starten\",\n  \"baseModal\": {\n    \"copy\": \"Kopieren\",\n    \"duplicate\": \"Dupliziere \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Eine Basis aus einer Vorlage erstellen\",\n    \"duplicateRecords\": \"Dupliziere Einträge\",\n    \"duplicateRecordsTip\": \"Ihre Änderungshistorie wird nicht dupliziert.\",\n    \"toSpace\": \"Zu Space\",\n    \"copyToSpace\": \"Kopiere zu Space\",\n    \"duplicateBase\": \"Dupliziere Base\",\n    \"missTargetTip\": \"Bitte wählen Sie ein Feld, um die Base zu duplizieren.\",\n    \"copying\": \"Duplizieren der Base, das kann eine Weile dauern...\",\n    \"copyingTemplate\": \"Base aus Vorlage erstellen, das kann eine Weile dauern...\",\n    \"howToCreate\": \"Wie wollen Sie beginnen?\",\n    \"fromScratch\": \"Von Grund auf neu\",\n    \"fromTemplate\": \"Aus Vorlage\",\n    \"moveBaseToAnotherSpace\": \"Verschiebe {{baseName}} zu einem anderen Space\",\n    \"chooseSpace\": \"Space auswählen\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Space Einstellungen\",\n    \"general\": \"Allgemein\",\n    \"collaborators\": \"Kollaborateure\",\n    \"generalDescription\": \"Ändern Sie hier die Einstellungen für Ihren aktuellen Space\",\n    \"collaboratorDescription\": \"Verwalten Sie die Mitarbeiter Ihres Space und legen Sie deren Zugriffsrechte fest.\",\n    \"spaceName\": \"Space Name\",\n    \"spaceId\": \"Space ID\"\n  },\n  \"pin\": {\n    \"add\": \"Anpinnen\",\n    \"remove\": \"Pin entfernen\",\n    \"pin\": \"Pin\",\n    \"empty\": \"Ihre gepinnten Bases und Spaces werden hier erscheinen\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"Sie haben keine Berechtigung, eine Base zu erstellen\"\n  },\n  \"tip\": {\n    \"delete\": \"Sind Sie sicher, dass Sie <0/> löschen wollen?\",\n    \"title\": \"Tipps\",\n    \"exportTips1\": \"Exportieren Sie die aktuelle Base als .tea-Datei, was einige Zeit dauern kann. Sie können die Exportergebnisse im Benachrichtigungszentrum überprüfen.\",\n    \"exportTips2\": \"Sie können die .tea-Datei in Space -> mehr -> Import importieren\",\n    \"exportTips3\": \"Base-übergreifende Beziehungsfelder werden in einzeiligen Text umgewandelt.\",\n    \"exportIncludeDataLabel\": \"Datensätze einschließen\",\n    \"exportIncludeDataDescription\": \"Deaktivieren, um nur Struktur und Konfiguration zu exportieren.\",\n    \"moveBaseSuccessTitle\": \"Verschiebung erfolgreich\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} wurde erfolgreich nach {{spaceName}} verschoben\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Space löschen\",\n    \"blockedTitle\": \"Dieser Space kann nicht gelöscht werden\",\n    \"blockedDesc\": \"Dieser Space hat ein aktives Abonnement. Bitte kündigen Sie das Abonnement, bevor Sie den Space löschen.\",\n    \"permanentDeleteWarning\": \"Diese Aktion wird dauerhaft alle Ressourcen und Daten im aktuellen Space löschen. Bitte gehen Sie mit Vorsicht vor!\",\n    \"confirmInputLabel\": \"Bitte geben Sie DELETE ein, um das Löschen zu bestätigen\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Geteilte Bases\",\n    \"description\": \"Alle Bases, zu denen ich eingeladen wurde\",\n    \"empty\": \"Noch keine geteilten Bases\"\n  },\n  \"integration\": {\n    \"title\": \"Integrationen\",\n    \"description\": \"Verwalten Sie die Integrationen Ihres Spaces\",\n    \"addIntegration\": \"Integration hinzufügen\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"KI-Einstellungen\",\n    \"description\": \"Verwalten Sie die KI-Einstellungen Ihres Space\",\n    \"enableTips\": \"Aktivieren Sie die KI, um KI-Funktionen in Ihrem Space zu nutzen, anstatt die System-KI zu verwenden\",\n    \"enable\": \"KI-Einstellungen initialisieren\",\n    \"enableSwitchTips\": \"Bitte konfigurieren Sie das große Programmiermodell vor der Aktivierung\"\n  },\n  \"import\": {\n    \"importing\": \"Importieren\",\n    \"importWayTip\": \"Klicken oder ziehen Sie die Datei in diesen Bereich zum Hochladen\",\n    \"baseImportTips\": \"Klicken oder ziehen Sie die .tea-Datei in diesen Bereich zum Hochladen\",\n    \"confirm\": \"Bestätigen und fortfahren\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Klicken zum Anzeigen\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Vorlage\",\n    \"description\": \"Erstellen Sie schnell eine neue Base aus einer Vorlage\",\n    \"noTemplatesAvailable\": \"Keine Vorlagen verfügbar\",\n    \"noTemplatesDescription\": \"Hier gibt es derzeit nichts\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Zuletzt besucht\"\n  },\n  \"noBases\": {\n    \"title\": \"Hallo {{userName}}!\",\n    \"description\": \"Lassen Sie uns mit der Verwaltung Ihrer Arbeit in Ihrer ersten Base beginnen.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Hallo {{userName}}!\",\n    \"description\": \"Erstellen Sie Ihren ersten Space, um Ihre Reise der Datenzusammenarbeit zu beginnen.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Alle Bases\",\n    \"owner\": \"Eigentümer\",\n    \"createdTime\": \"Erstellt\",\n    \"lastOpened\": \"Zuletzt geöffnet\",\n    \"enter\": \"Eintreten\",\n    \"noTables\": \"Keine Tabellen\",\n    \"empty\": \"Noch keine Bases\"\n  },\n  \"publishBase\": {\n    \"title\": \"Base in der Community veröffentlichen\",\n    \"description\": \"Veröffentlichen Sie Ihre Base mit einem Klick, Inspiration ist nicht länger einsam! Lassen Sie mehr Menschen Ihre Kreativität sehen, nutzen und remixen, und bauen Sie gemeinsam ein leistungsstärkeres Geschäft auf\",\n    \"infoTitle\": \"Basisinformationen\",\n    \"form\": {\n      \"title\": \"Titel\",\n      \"description\": \"Beschreibung\",\n      \"security\": \"Sicherheit\",\n      \"includeNodes\": \"Knoten einschließen\",\n      \"advanced\": \"Erweitert\",\n      \"publishNode\": \"Knoten veröffentlichen\",\n      \"includeData\": \"Daten einschließen\",\n      \"defaultActiveNode\": \"Standardmäßig aktiver Knoten\",\n      \"select\": \"bitte auswählen\",\n      \"descriptionPlaceholder\": \"Beschreibung\",\n      \"titlePlaceholder\": \"Titel\",\n      \"toBeFilledTitle\": \"Benennen Sie Ihre Arbeit\",\n      \"toBeFilledDescription\": \"Beschreiben Sie kurz Ihre Idee\"\n    },\n    \"publishToCommunity\": \"Im Vorlagenzentrum veröffentlichen\",\n    \"publish\": \"Veröffentlichen\",\n    \"publishSuccess\": \"Veröffentlichung erfolgreich!\",\n    \"previewTips\": \"Zeigen Sie der Welt Ihre Arbeit\",\n    \"update\": \"Aktualisieren\",\n    \"unPublish\": \"Veröffentlichung aufheben\",\n    \"unPublishSuccess\": \"Veröffentlichung der Base erfolgreich aufgehoben!\",\n    \"unPublishConfirmTitle\": \"Veröffentlichung aufheben bestätigen\",\n    \"unPublishConfirmDescription\": \"Sind Sie sicher, dass Sie die Veröffentlichung dieser Base aufheben möchten? Sie wird nicht mehr im Vorlagen-Center der Community sichtbar sein.\",\n    \"usageCount\": \"Nutzungsanzahl: \",\n    \"uploadCover\": \"Klicken, um Titelbild hochzuladen\",\n    \"changeCover\": \"Klicken, um Titelbild zu ändern\",\n    \"uploading\": \"Bild wird hochgeladen...\",\n    \"uploadSuccess\": \"Bild erfolgreich hochgeladen\",\n    \"uploadFailed\": \"Hochladen fehlgeschlagen\",\n    \"invalidImageType\": \"Bitte wählen Sie eine Bilddatei\",\n    \"tips\": {\n      \"publishValidation\": \"Titel und Beschreibung sind erforderlich\",\n      \"atLeastOneNode\": \"Bitte wählen Sie mindestens einen Knoten zur Veröffentlichung\"\n    },\n    \"urlCopied\": \"URL in die Zwischenablage kopiert!\",\n    \"urlCopiedForDiscord\": \"URL in die Zwischenablage kopiert! Sie können sie in Discord einfügen.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Offiziell als Featured ausgewählt. Ihre Vorlage wird mehr Aufmerksamkeit erhalten.\",\n    \"unfeaturedTip\": \"Noch nicht als Featured ausgewählt. Verbessern Sie weiter, um eine Chance auf Empfehlung zu haben. Lassen Sie mehr Menschen Ihre Arbeit sehen.\",\n    \"publishSuccessDescription\": \"Teilen Sie Ihre Arbeit mit der Welt\",\n    \"shareWith\": \"Teilen mit\",\n    \"unpublishedApps\": {\n      \"title\": \"Unveröffentlichte Apps erkannt\",\n      \"description\": \"Unveröffentlichte Apps können Fehler bei der Vorlagenvorschau verursachen. Veröffentlichen Sie sie jetzt oder fahren Sie trotzdem fort.\",\n      \"publishAll\": \"Alle veröffentlichen\",\n      \"publish\": \"Veröffentlichen\",\n      \"published\": \"Veröffentlicht\",\n      \"publishing\": \"Veröffentlichen...\",\n      \"publishFailed\": \"Veröffentlichung fehlgeschlagen\",\n      \"publishFailedTip1\": \"Überprüfen Sie, ob die Quell-App erfolgreich veröffentlicht werden kann\",\n      \"publishFailedTip2\": \"Versuchen Sie, diese Vorlage erneut zu veröffentlichen\",\n      \"notPublished\": \"Nicht veröffentlicht\",\n      \"ignoreAndContinue\": \"Ignorieren und fortfahren\",\n      \"goToFix\": \"Zur Behebung\",\n      \"redeploy\": \"Erneut bereitstellen\",\n      \"unnamedApp\": \"Unbenannte App\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Demnächst verfügbar\",\n    \"viewFilterInShare\": \"Diese Ansicht wird in einem Ansichtsfreigabelink verwendet. Änderungen an der Konfiguration der Ansicht ändern auch den Link zur Ansichtsfreigabe.\",\n    \"createFieldButtonText\": \"Ein neues <0/>-Feld erstellen\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Teilen\",\n        \"statusLabel\": \"Ansicht im Web teilen\",\n        \"noPermission\": \"Keine Erlaubnis zum Teilen der Ansicht\",\n        \"shareLink\": \"Freigabelink\",\n        \"copied\": \"Kopiert\",\n        \"genLink\": \"Erzeuge neuen Link\",\n        \"allowCopy\": \"Betrachtern das Kopieren von Daten aus dieser Ansicht ermöglichen\",\n        \"showAllFields\": \"Alle Felder in erweiterten Datensätzen anzeigen\",\n        \"restrict\": \"Durch Passwort beschränken\",\n        \"tips\": \"Diejenigen, die den Link haben, können die Ansicht sehen.\",\n        \"passwordTitle\": \"Geben Sie ein Passwort ein\",\n        \"passwordTips\": \"Passwortbeschränkungen für den Zugriff auf gemeinsame Ansichten\",\n        \"embed\": \"Einbetten\",\n        \"embedPreview\": \"Einbetten Vorschau\",\n        \"hideToolbar\": \"Werkzeugleiste verstecken\",\n        \"URLSetting\": \"URL Parameter Konfiguration\",\n        \"URLSettingDescription\": \"Die Anpassung der folgenden Einstellungen hat keine Auswirkungen auf die geteilten Links. Sie müssen den Link mit den neuen Parametern kopieren, damit die Änderungen wirksam werden\",\n        \"cancel\": \"Abbrechen\",\n        \"save\": \"Speichern\",\n        \"requireLogin\": \"Anmeldung erforderlich machen\"\n      },\n      \"extensions\": {\n        \"label\": \"Erweiterungen\",\n        \"graph\": \"Graph\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"Datebankverbindung\"\n      },\n      \"personalView\": {\n        \"personal\": \"Persönlich\",\n        \"tip\": \"Nach der Aktivierung gilt die Ansichtseinstellung nur für dich persönlich\",\n        \"collaborative\": \"Kollaboration\",\n        \"dialog\": {\n          \"title\": \"Persönlichen Modus beenden\",\n          \"description\": \"Die persönliche Ansichtskonfiguration wird auf den Echtzeit-Kollaborationsstatus zurückgesetzt. Sie können die persönlichen Ansichtseinstellungen auch speichern und für alle synchronisieren.\",\n          \"cancelText\": \"Beenden und synchronisieren\",\n          \"confirmText\": \"Beenden bestätigen\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Willkommen\",\n    \"emptyTitle\": \"Beginnen Sie mit dem Aufbau Ihrer Datenbank\",\n    \"description\": \"Klicken Sie auf den „+“-Knopf in der Seitenleiste, um Ressourcen hinzuzufügen\",\n    \"help\": \"Weitere Informationen finden Sie im <HelpCenter />.\",\n    \"helpCenter\": \"Hilfe-Center\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Verknüpfung nicht möglich: Im selben Batch wurde der Datensatz bereits von einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.\",\n      \"one_many_duplicate\": \"Verknüpfung nicht möglich: Der Datensatz ist bereits mit einem anderen Datensatz verknüpft. In Eins-zu-viele-Beziehungen kann jeder untergeordnete Datensatz nur zu einem übergeordneten Datensatz gehören.\",\n      \"one_one_duplicate\": \"Verknüpfung nicht möglich: Der Zieldatensatz ist in einer Eins-zu-eins-Beziehung bereits mit einem anderen Datensatz verknüpft.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"Die Tabelle \\\"{{tableName}}\\\" darf höchstens {{maxFieldCount}} Felder enthalten.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Feld-Management\",\n    \"fieldManagementDesc\": \"Detaillierte Eigenschaften für alle Felder der aktuellen Tabelle\",\n    \"advancedProps\": \"Erweiterte Eigenschaften\",\n    \"hide\": \"verstecken\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Label\"\n      },\n      \"longText\": {\n        \"title\": \"Notizen\"\n      },\n      \"number\": {\n        \"title\": \"Nummer\",\n        \"formatType\": \"Format Typ\",\n        \"currencySymbol\": \"Währungssymbol\",\n        \"defaultSymbol\": \"€\",\n        \"precision\": \"Genaugikeit\",\n        \"decimalExample\": \"Zahl (123)\",\n        \"currencyExample\": \"Währung (€100)\",\n        \"percentExample\": \"Prozent (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Status\",\n        \"options\": {\n          \"todo\": \"Zu erledigen\",\n          \"inProgress\": \"In Arbeit\",\n          \"done\": \"Erledigt\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Tags\"\n      },\n      \"attachment\": {\n        \"title\": \"Anhänge\"\n      },\n      \"user\": {\n        \"title\": \"Kollaborateure\"\n      },\n      \"date\": {\n        \"title\": \"Datum\",\n        \"dateFormatting\": \"Datum Formatierung\",\n        \"timeFormatting\": \"Zeit Formatierung\",\n        \"timeZone\": \"Zeitzone\",\n        \"yearMonth\": \"Jahr/Monat\",\n        \"monthDay\": \"Monat/Tag\",\n        \"year\": \"Jahr\",\n        \"month\": \"Monat\",\n        \"day\": \"Tag\",\n        \"local\": \"Lokal\",\n        \"friendly\": \"Freundlich\",\n        \"us\": \"US\",\n        \"european\": \"Europa\",\n        \"asia\": \"Asien\",\n        \"custom\": \"Angepasst\",\n        \"12Hour\": \"12 Stunden\",\n        \"24Hour\": \"24 Stunden\",\n        \"noDisplay\": \"Nicht anzeogen\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Erstellzeitstempel\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Zeitstempel der letzten Änderung\"\n      },\n      \"createdBy\": {\n        \"title\": \"Erstellt von\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Letzte Änderung von\"\n      },\n      \"rating\": {\n        \"title\": \"Bewertung\"\n      },\n      \"checkbox\": {\n        \"title\": \"Erledigt\"\n      },\n      \"button\": {\n        \"title\": \"Schaltfläche\",\n        \"label\": \"Schaltflächentext\",\n        \"color\": \"Schaltflächencolor\",\n        \"limitCount\": \"Klickzähler begrenzen\",\n        \"resetCount\": \"Zurücksetzen des Klickzählers erlauben\",\n        \"maxCount\": \"Maximale Klickzahl\",\n        \"automation\": \"Automatisierung\",\n        \"customAutomation\": \"Benutzerdefinierte Automatisierung\",\n        \"clickConfirm\": \"Klicken Sie vor dem Klicken bestätigen\",\n        \"confirmTitle\": \"Titel\",\n        \"confirmDescription\": \"Inhalt\",\n        \"confirmButtonText\": \"Bestätigungstext der Schaltfläche\"\n      },\n      \"formula\": {\n        \"title\": \"Berechnung\",\n        \"formula\": \"Formel\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (von {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (gefiltert aus {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Rollup (von {{linkFieldName}})\",\n        \"rollup\": \"Rollup\",\n        \"selectAnRollupFunction\": \"Wählen Sie eine Rollup-Funktion aus\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Gibt wahr zurück, wenn alle Werte wahr sind\",\n          \"arrayCompact\": \"Entfernt leere Zeichenketten und Nullwerte aus dem Array. Behält 'false' und Zeichenketten, die ein oder mehrere Leerzeichen enthalten.\",\n          \"arrayJoin\": \"Fügen Sie alle Werte zu einer einzigen, durch Komma getrennten Zeichenkette zusammen.\",\n          \"arrayUnique\": \"Nur einzigartige Werte zurückgeben.\",\n          \"average\": \"Durchschnitt der Werte.\",\n          \"concatenate\": \"Fügt die Textwerte zu einem einzigen Textwert zusammen.\",\n          \"count\": \"Zählt nur nicht leere numerische Werte. Wenn Sie alle Datensätze zählen wollen, verwenden Sie COUNTALL.\",\n          \"countA\": \"Zählt die Anzahl der nicht leeren Werte. Diese Funktion zählt sowohl numerische als auch Textwerte.\",\n          \"countAll\": \"zählt alle Werte einschließlich leerer Datensätze.\",\n          \"max\": \"Gibt die größte der angegebenen Zahlen zurück.\",\n          \"min\": \"Gibt die kleinste der angegebenen Zahlen zurück.\",\n          \"or\": \"Gibt wahr zurück, wenn einer der Werte wahr ist.\",\n          \"sum\": \"Addieren der Werte.\",\n          \"xor\": \"Gibt wahr zurück, wenn und nur wenn eine ungerade Anzahl von Werten wahr ist.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} bedingtes Rollup\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Feld hinzufügen\",\n      \"editField\": \"Feld bearbeiten\",\n      \"insertField\": \"Feld einfügen\",\n      \"graph\": \"Graph\",\n      \"defaultValue\": \"Standardwert\",\n      \"reset\": \"Zurücksetzen\",\n      \"fieldUpdated\": \"Feld wurde aktualisiert\",\n      \"fieldCreated\": \"Feld wurde erstellt\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Sind Sie sicher, dass Sie das durchführen wollen?\",\n      \"addDescription\": \"Beschreibung hinzufügen\",\n      \"dbFieldName\": \"DB Feld Name\",\n      \"description\": \"Beschreibung\",\n      \"descriptionPlaceholder\": \"Beschreiben Sie das Feld (optional)\",\n      \"type\": \"Typ\",\n      \"showAs\": \"Anzeigen als\",\n      \"color\": \"Farbe\",\n      \"number\": \"Nummer\",\n      \"chartBar\": \"Balkendiagramm\",\n      \"chartLine\": \"Liniendiagramm\",\n      \"ring\": \"Ring\",\n      \"bar\": \"Balken\",\n      \"text\": \"Text\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"E-Mail\",\n      \"phone\": \"Telfonnummer\",\n      \"maxNumber\": \"Maximale Nummer\",\n      \"showNumber\": \"Zeige Nummer\",\n      \"autoFillDate\": \"Automatisches Ausfüllen mit aktuellem Datum\",\n      \"createSymmetricLink\": \"Erstellen Sie ein Rückverweisfeld in der verlinkten Tabelle\",\n      \"allowLinkMultipleRecords\": \"Mehrfachauswahl zulassen\",\n      \"allowLinkToDuplicateRecords\": \"Erlaubt die mehrfache Auswahl von Datensätzen\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Erlaubt die mehrfache Auswahl von Datensätzen\",\n      \"oneToOne\": \"one-to-one\",\n      \"oneToMany\": \"one-to-many\",\n      \"manyToOne\": \"many-to-one\",\n      \"manyToMany\": \"many-to-many\",\n      \"self\": \"Selbst\",\n      \"selectTable\": \"Tabelle auswählen...\",\n      \"selectBase\": \"Base auswählen...\",\n      \"linkFromAnotherBase\": \"Link von externer Base\",\n      \"inSelfLink\": \"in Selbstverknüpfung\",\n      \"betweenTwoTables\": \"zwischen zwei Tabellen\",\n      \"tips\": \"Tipps\",\n      \"linkTipMessage\": \"Diese Konfiguration stellt eine<br></br> <b>{{relationship}}</b> Beziehung her <span>{{linkType}}</span>\",\n      \"style\": \"Style\",\n      \"maximum\": \"Maximum\",\n      \"addOption\": \"Option hinzufügen\",\n      \"allowMultiUsers\": \"Hinzufügen mehrerer Benutzer zulassen\",\n      \"notifyUsers\": \"Benutzer benachrichtigen, sobald sie ausgewählt wurden\",\n      \"searchTable\": \"Tabelle durchsuchen...\",\n      \"calculating\": \"Berechne...\",\n      \"doSaveChanges\": \"Möchten Sie die vorgenommenen Änderungen speichern?\",\n      \"linkFieldToLookup\": \"Verknüpftes Datensatzfeld, das für die Suche verwendet werden soll\",\n      \"lookupToTable\": \"Feld aus <bold>{{tableName}}</bold> nachschlagen\",\n      \"rollupToTable\": \"Feld aus <bold>{{tableName}}</bold> nachschlagen\",\n      \"selectField\": \"Feld auswählen...\",\n      \"linkTable\": \"Tabelle verlinken\",\n      \"linkBase\": \"Base verlinken\",\n      \"tableNoPermission\": \"Keine Berechtigung für Tabelle\",\n      \"baseNoPermission\": \"Keine Berechtigung für Base\",\n      \"noLinkTip\": \"Keine verknüpften Datensätze zum Nachschlagen. Fügen Sie einen Link zu einem anderen Datensatzfeld hinzu und versuchen Sie dann erneut, die Suche zu konfigurieren.\",\n      \"fieldValidationRules\": \"Regeln für die Validierung von Feldwerten\",\n      \"enableValidateFieldUnique\": \"Doppelte Werte verbieten\",\n      \"enableValidateFieldNotNull\": \"Erforderlich\",\n      \"knowMore\": \"mehr erfahren\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Datensätze nach Feld filtern\",\n      \"filterByView\": \"Datensätze nach Ansicht filtern\",\n      \"filter\": \"Datensätze filtern\",\n      \"hideFields\": \"Felder verstecken\",\n      \"moreOptions\": \"Mehr Optionen\",\n      \"allowNewOptionsWhenEditing\": \"Neue Optionen bei der Bearbeitung zulassen\",\n      \"deleteField\": {\n        \"title\": \"Feld löschen\",\n        \"simpleConfirm\": \"Sind Sie sicher, dass Sie das Feld <b>{{fieldName}}</b> löschen möchten?\",\n        \"withDependencies\": \"Das Löschen des Felds <b>{{fieldName}}</b> wirkt sich auf die folgenden Felder aus:\",\n        \"affectedFields\": \"Betroffene Felder:\",\n        \"fieldsToDelete\": \"Zu löschende Felder ({{count}})\",\n        \"unviewedHint\": \"{{count}} Feld(er) nicht überprüft\",\n        \"deleteCount\": \"{{count}} Felder löschen\",\n        \"noAffectedFields\": \"Dieses Feld wird von keinen anderen Feldern referenziert.\",\n        \"riskIdentified\": \"Risiko identifiziert({{count}})\",\n        \"noDependencies\": \"Keine Abhängigkeiten({{count}})\",\n        \"safeToDelete\": \"Sicher zu löschen\",\n        \"safeToDeleteDesc\": \"Dieses Feld wird von keinen anderen Feldern referenziert und kann sicher gelöscht werden\",\n        \"affectedItems\": \"Betroffene Elemente\",\n        \"type\": \"Typ\",\n        \"source\": \"Quelle\",\n        \"sourceTable\": \"Quelltabelle\",\n        \"typeField\": \"Feld\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Verknüpfung mit Datensätzen in der von Ihnen gewählten Tabelle\",\n      \"singleLineText\": \"Text eingeben oder jede neue Zelle mit einem Standardwert ausfüllen.\",\n      \"longText\": \"Mehrere Textzeilen eingeben.\",\n      \"attachment\": \"Bilder hinzufügen oder mit KI generieren, oder Dokumente und andere Dateien hochladen, die angezeigt oder heruntergeladen werden können.\",\n      \"checkbox\": \"Aktivieren oder deaktivieren Sie die Checkbox, um den Status anzugeben.\",\n      \"multipleSelect\": \"Eine oder mehrere vordefinierte Optionen in einer Liste auswählen.\",\n      \"singleSelect\": \"Eine vordefinierte Option aus einer Liste auswählen oder jede neue Zelle mit einer Standardoption ausfüllen.\",\n      \"user\": \"Einen Benutzer zu einem Datensatz hinzufügen.\",\n      \"date\": \"Geben Sie ein Datum ein (z.B. 12/11/2023) oder wählen Sie eines aus einem Kalender aus.\",\n      \"number\": \"Zahl eingeben oder jede neue Zelle mit einem Standardwert ausfüllen.\",\n      \"duration\": \"Zeitdauer in Stunden, Minuten oder Sekunden ein (z. B. 1:23) eingeben.\",\n      \"rating\": \"Bewertung auf einer vordefinierten Skala hinzufügen.\",\n      \"formula\": \"Werte auf der Basis von Feldern berechnen.\",\n      \"rollup\": \"Daten aus verknüpften Datensätzen zusammenfassen.\",\n      \"conditionalLookup\": \"Zeigt verknüpfte Werte, die Ihren Filtern entsprechen.\",\n      \"count\": \"Anzahl der verknüpften Datensätze zählen.\",\n      \"createdTime\": \"Sehen Sie das Datum und die Uhrzeit, zu der jeder Datensatz erstellt wurde.\",\n      \"lastModifiedTime\": \"Zeigt das Datum und die Uhrzeit der letzten Bearbeitung einiger oder aller Felder eines Datensatzes an.\",\n      \"createdBy\": \"Sehen, welcher Benutzer den Datensatz erstellt hat.\",\n      \"lastModifiedBy\": \"Sehen, welcher Benutzer die letzte Bearbeitung an einigen oder allen Feldern eines Datensatzes vorgenommen hat.\",\n      \"autoNumber\": \"Automatisch eindeutige inkrementelle Nummern für jeden Datensatz erzeugen.\",\n      \"button\": \"Auslösen einer benutzerdefinierten Aktion.\",\n      \"lookup\": \"Werte aus einem Feld in einem verknüpften Datensatz sehen.\"\n    },\n    \"fieldName\": \"Feld Name\",\n    \"fieldNameOptional\": \"Feld Name (Optional)\",\n    \"fieldType\": \"Feld Typ\",\n    \"aiConfig\": {\n      \"title\": \"AI Konfiguration\",\n      \"type\": {\n        \"summary\": \"Zusammenfassung\",\n        \"translation\": \"Übersetzen\",\n        \"extraction\": \"Information extrahieren\",\n        \"improvement\": \"Verbessern\",\n        \"tag\": \"Smart-Tag\",\n        \"classification\": \"Smart-Klassifizierung\",\n        \"customization\": \"Anpassen\",\n        \"imageGeneration\": \"Bildgenerierung\",\n        \"rating\": \"Bewertung\"\n      },\n      \"label\": {\n        \"type\": \"AI Aktion Typ\",\n        \"model\": \"AI Modell\",\n        \"targetLanguage\": \"Zielsprache\",\n        \"sourceField\": \"Quellfeld\",\n        \"sourceFieldForTag\": \"Wählen Sie ein Feld, passen Sie es mit den erstellten Tags an\",\n        \"sourceFieldForClassify\": \"Wählen Sie ein Feld, passen Sie es mit den erstellten Klassifizierungen an\",\n        \"attachPrompt\": \"Anhängen von Anforderungen\",\n        \"prompt\": \"Anpassen von Prompt\",\n        \"sourceFieldForAttachment\": \"Quellfeld für Anhang\",\n        \"imageSize\": \"Bildgröße\",\n        \"imageQuality\": \"Bildqualität\",\n        \"imageCount\": \"Bildanzahl\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Zusammenfassung des Inhalts\",\n        \"translate\": \"Übersetzung kurz und verständlich, lebendig\",\n        \"extractInfo\": \"Email, Telefon, Name, Adresse extrahieren...\",\n        \"extractDate\": \"Startzeit extrahieren\",\n        \"improveText\": \"Formell, freundlich, humorvoll...\",\n        \"attachPromptForTag\": \"Nicht erlaubt, mehr als drei Tags zu überschreiten\",\n        \"attachPromptForClassify\": \"Klassifizieren Sie “In Progress” als “Kein Risiko”\",\n        \"attachPrompt\": \"Bitte geben Sie zusätzliche Anforderungen ein\",\n        \"prompt\": \"Bitte geben Sie einen benutzerdefinierten Prompt ein\",\n        \"type\": \"AI Aktion auswählen\",\n        \"targetLanguage\": \"Englisch, Chinesisch, Französisch...\",\n        \"imageSize\": \"Bitte geben Sie die Bildgröße ein\",\n        \"imageQuality\": \"Bitte geben Sie die Bildqualität ein\",\n        \"attachPromptForImageGeneration\": \"Das Bild sollte lebendig und natürlich sein\",\n        \"attachPromptForRating\": \"Bewerte die Bildqualität\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Niedrig\",\n        \"medium\": \"Mittel\",\n        \"high\": \"Hoch\"\n      },\n      \"autoFill\": {\n        \"title\": \"Automatisch aktualisieren\",\n        \"tip\": \"Nachdem Sie diese Option aktiviert haben, wird das aktuelle Feld synchron mit den Inhaltsänderungen der AI-Konfiguration aktualisiert\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Alle Datensätze aktualisieren\",\n        \"description\": \"Alle Datensätze in der aktuellen Ansicht werden aktualisiert, einschließlich aller mit dem Feld generierten verknüpften Datensätze\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Gesamte Spalte generieren?\",\n        \"description\": \"Dabei werden {{rowCount}} Zeilen aktualisiert. Dieser Vorgang kann viele KI-Ressourcen verbrauchen.\",\n        \"saveConfigOnly\": \"Nur Konfiguration speichern\",\n        \"generate\": \"Generieren\",\n        \"generateFailed\": \"Generierung fehlgeschlagen\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Anhang hinzufügen\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Neue Tabelle\",\n    \"rename\": \"Umbenennen\",\n    \"design\": \"Design\",\n    \"tableRecordHistory\": \"Tabelle Datensatzhistorie\",\n    \"deleteConfirm\": \"Sind Sie sicher, dass Sie \\\"{{tableName}}\\\" löschen möchten?\",\n    \"dbTableName\": \"Tabelle Name in der physischen Datenbank\",\n    \"schemaName\": \"Schema Name in der physischen Datenbank\",\n    \"baseInfo\": \"Base Info\",\n    \"typeOfDatabase\": \"Typ der Databank\",\n    \"descriptionForTable\": \"Beschreibung für diese Tabelle\",\n    \"nameForTable\": \"Name für diese Tabelle\",\n    \"deleteTip1\": \"Verknüpfungsfelder, die mit dieser Tabelle in anderen Tabellen verknüpft sind, werden gelöscht.\",\n    \"deleteTip2\": \"Diese Tabelle kann nach dem Löschen aus dem Papierkorb wiederhergestellt werden.\",\n    \"operator\": {\n      \"createBlank\": \"Neue Tabelle\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Kopieren und Einfügen funktioniert nur bei HTTPS oder localhost\",\n      \"copyAndPasteBrowser\": \"Kopieren und Einfügen wird in diesem Browser nicht unterstützt\",\n      \"copying\": \"Kopiere...\",\n      \"copySuccessful\": \"Kopieren erfolgreich\",\n      \"copyFailed\": \"Kopieren fehlgeschlagen\",\n      \"pasting\": \"Einfügen...\",\n      \"pasteSuccessful\": \"Einfügen erfolgreich\",\n      \"pasteFailed\": \"Einfügen fehlgeschlagen\",\n      \"filling\": \"Füllen...\",\n      \"fillSuccessful\": \"Füllen erfolgreich\",\n      \"fillFailed\": \"Füllen fehlgeschlagen\",\n      \"clearing\": \"Leeren...\",\n      \"clearSuccessful\": \"Leeren erfolgreich\",\n      \"deleteFieldConfirmTitle\": \"Sie werden die folgenden Felder löschen\",\n      \"deleting\": \"Lösche...\",\n      \"deleteSuccessful\": \"Löschen erfolgreich\",\n      \"pasteFileFailed\": \"Dateien können nur in ein Anhangsfeld eingefügt werden\",\n      \"copyError\": {\n        \"noFocus\": \"Bitte lassen Sie die Seite aktiv und wechseln Sie nicht das Fenster\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Tabelle Label\",\n      \"effectCells\": \"Kann sich auf Zellen auswirken\",\n      \"estimatedTime\": \"Geschätzte Zeit\",\n      \"linkFieldCount\": \"Affects the number of linked fields\"\n    },\n    \"integrity\": {\n      \"check\": \"Prüfen\",\n      \"title\": \"Integritätsprüfung\",\n      \"loading\": \"Prüfe Integrität...\",\n      \"allGood\": \"Alles super!\",\n      \"fixIssues\": \"Probleme beheben\"\n    },\n    \"index\": {\n      \"description\": \"Indizes können die Suchleistung erheblich verbessern, insbesondere bei großen Datenmengen (über {{rowCount}} Zeilen). Der Nachteil sind etwas langsamere Schreibvorgänge. Wenn Sie häufig Suchen durchführen oder große Datenmengen haben, sollten Sie die Indizierung aktivieren.\",\n      \"repair\": \"Reparieren\",\n      \"repairTip\": \"Es wurden Indexanomalien entdeckt, die zu einer Verschlechterung der Suchleistung führen können. Es wird empfohlen, auf die Reparieren-Schaltfläche zu klicken, um den Index zu reparieren.\",\n      \"enableIndexTip\": \"Sie erstellen einen Index. Die benötigte Zeit hängt von der Größe der Tabelle ab. Während dieses Vorgangs kann die Lese- und Schreibleistung der Tabelle beeinträchtigt werden. Bitte haben Sie Geduld.\",\n      \"globalSearchTip_limited\": \"Alle Felder durchsuchen außer Datum Feld, Checkbox Feld und Button Feld, maximal {{count}} Felder\",\n      \"globalSearchTip_infinity\": \"Alle Felder durchsuchen außer Datum Feld, Checkbox Feld und Button Feld\",\n      \"autoIndexTip\": \"Ihre Tabelle hat {{rowCount}} Zeilen überschritten. Wir empfehlen, die Indizierung zu aktivieren, um die Suchleistung zu verbessern. Bitte beachten Sie, dass die Indizierung die Schreibgeschwindigkeit leicht verringern kann. Während der Indexerstellung kann die Lese-/Schreibleistung der Tabelle vorübergehend beeinträchtigt sein. Bitte haben Sie etwas Geduld.\",\n      \"enableIndex\": \"aktivieren\",\n      \"keepAsIs\": \"beibehalten\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Hochladen\",\n      \"import\": \"Importieren\",\n      \"localFile\": \"Lokale Dateien\",\n      \"linkUrl\": \"Link (URL)\",\n      \"linkUrlInputTitle\": \"Datei von URL hinzufügen\",\n      \"importTitle\": \"Eine neue Tabelle erstellen\",\n      \"incrementImportTitle\": \"Importieren in — \",\n      \"optionsTitle\": \"Import Optionen\",\n      \"primitiveFields\": \"Primitive Felder\",\n      \"importFields\": \"Import Felder\",\n      \"primaryField\": \"Primäres Feld\",\n      \"tipsTitle\": \"Tipps\",\n      \"confirm\": \"Bestätigen und fortfahren\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Aus anderen Quellen hinzufügen\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"CSV Datei\",\n      \"importCsvData\": \"CSV-Daten importieren\",\n      \"importExcelData\": \"Microsoft Excel-Daten importieren\",\n      \"cancel\": \"Abbrechen\",\n      \"leave\": \"Verlassen\",\n      \"downAsCsv\": \"csv herunterladen\",\n      \"importData\": \"Daten importieren\",\n      \"duplicate\": \"Duplizieren\",\n      \"duplicating\": \"Duplizieren...\",\n      \"duplicateSuccess\": \"Erfolgreich dupliziert\",\n      \"duplicateFailed\": \"Duplizieren fehlgeschlagen\",\n      \"importing\": \"Importiere\",\n      \"includeRecords\": \"Datensätze einschließen\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Klicken oder ziehen Sie die Datei zum Hochladen in diesen Bereich\",\n      \"leaveTip\": \"Ihre Daten werden trotzdem importiert.\",\n      \"fileExceedSizeTip\": \"Die Dateigröße dieses Typs überschreitet die Grenze von\",\n      \"analyzing\": \"Analysiere\",\n      \"importing\": \"Importiere\",\n      \"notSupportFieldType\": \"Feldtyp wird nicht unterstützt\",\n      \"resultEmpty\": \"Keine Resultate gefunden.\",\n      \"searchPlaceholder\": \"Suchen...\",\n      \"importAlert\": \"Sobald der Import beginnt, kann er nicht mehr gestoppt werden, bis er entweder erfolgreich abgeschlossen oder aufgrund eines Fehlers abgebrochen wurde. Der Importstatus wird in der oberen rechten Ecke der Tabelle angezeigt. Die Ergebnisse des Importvorgangs werden Ihnen nach Abschluss mitgeteilt. Sie sollten das Feld während des Imports nicht aktualisieren, da dies zu Fehlern führen kann.\",\n      \"noTips\": \"Das weiß ich, zeige es nicht noch einmal\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Feldtypen automatisch auswählen\",\n      \"useFirstRowAsHeaderOptionName\": \"Erste Zeile als Kopfzeile verwenden\",\n      \"importDataOptionName\": \"Daten importieren\",\n      \"sheetKey\": \"Blattname: \",\n      \"excludeFirstRow\": \"Erste Zeile beim Import ausschließen\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Feld\",\n      \"error\": {\n        \"urlEmptyTip\": \"Die URL sollte nicht leer sein!\",\n        \"errorFileFormat\": \"Das Dateiformat ist falsch!\",\n        \"uniqueFieldName\": \"Der Feldname sollte eindeutig sein!\",\n        \"fieldNameEmpty\": \"Der Feldname darf nicht leer sein!\",\n        \"atLeastAImportField\": \"Bitte definieren Sie mindestens ein Importfeld\",\n        \"urlValidateTip\": \"Die URL konnte nicht analysiert werden. Versuchen Sie eine andere!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Nicht importieren\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"CSV herunterladen\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Neuen Datensatz hinzufügen\",\n    \"prefillingRowTooltip\": \"Bitte geben Sie die Daten des neuen Datensatzes unten ein. Der Datensatz wird automatisch gespeichert, sobald Sie außerhalb dieser Zeile klicken.\",\n    \"presortRowTitle\": \"Dieser Datensatz wurde aufgrund von Sortierregeln gefiltert oder verschoben\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Felder\",\n    \"addAll\": \"Alle hinzufügen\",\n    \"removeAll\": \"Alle entfernen\",\n    \"hideFieldTip\": \"Das Feld bis hierhin ausblenden\",\n    \"unableAddFieldTip\": \"Feld dieses Typs kann nicht hinzugefügt werden\",\n    \"removeFromFormTip\": \"Aus dem Formular entfernen\",\n    \"descriptionPlaceholder\": \"Aus Beschreibung eingeben\",\n    \"dragToFormTip\": \"Ziehen Sie das Feld hierher, um es dem Formular hinzuzufügen\",\n    \"protectedFieldTip\": \"Dieses Feld wurde als \\\"Pflichtfeld\\\" festgelegt und kann in der Formularansicht nicht entfernt werden. Bitte ändern Sie es in den Feldeinstellungen.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Feldname ausblenden\",\n      \"customizeCards\": \"Karten anpassen\",\n      \"stackedBy\": \"Gestapelt durch <0/>\",\n      \"chooseStackingField\": \"Wählen Sie ein Stapelfeld\",\n      \"chooseStackingFieldDescription\": \"Welches Feld möchten Sie für diese Kanban-Ansicht verwenden? Ihre Datensätze werden auf der Grundlage dieses Feldes gestapelt.\",\n      \"hideEmptyStack\": \"Leeren Stapel ausblenden\",\n      \"imageSetting\": \"Bildeinstellung\",\n      \"fit\": \"Einpassen\",\n      \"noImage\": \"Kein Bild\",\n      \"chooseAttachmentField\": \"Feld für Anhänge wählen\"\n    },\n    \"stack\": {\n      \"addStack\": \"Stapel hinzufügen\",\n      \"noCards\": \"Keine Karten\",\n      \"uncategorized\": \"Unkategorisiert\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Stapel einklappen\",\n      \"renameStack\": \"Stapel umbenennen\",\n      \"deleteStack\": \"Stapel löschen\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Karte oberhalb einfügen\",\n      \"insertCardBelow\": \"Karte unterhalb einfügen\",\n      \"expandCard\": \"Karte aufklappen\",\n      \"deleteCard\": \"Karte löschen\",\n      \"duplicateCard\": \"Karte duplizieren\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Kalendar Konfiguration\",\n      \"startDateField\": \"Anfangsdatum Feld\",\n      \"endDateField\": \"Enddatum Feld\",\n      \"titleField\": \"Titel Feld\",\n      \"colorField\": \"Farbe Feld\",\n      \"colorType\": \"Farbanzeige\",\n      \"customColor\": \"Farbe anpassen\",\n      \"alignWithRecords\": \"An Datensätzen ausrichten\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Ein Farbfeld auswählen\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Startdatum\",\n      \"endDate\": \"Enddatum\",\n      \"notAdd\": \"Nicht hinzufügen\",\n      \"addDateField\": \"Datumsfelder hinzufügen\",\n      \"content\": \"Erstellen Sie einen Zeitplan in der Kalenderansicht. Die Tabelle muss zwei Datumsfelder enthalten: Startdatum und Enddatum\"\n    },\n    \"moreLinkText\": \"Zeige alle {{count}} Datensätze\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Datensatz <input /> oberhalb einfügen\",\n    \"insertRecordBelow\": \"Datensatz <input /> unterhalb einfügen\",\n    \"copyCells\": \"Zellen kopieren\",\n    \"deleteRecord\": \"Datensätze löschen\",\n    \"deleteAllSelectedRecords\": \"Alle ausgewählten Datensätze löschen\",\n    \"editField\": \"Feld bearbeiten\",\n    \"insertFieldLeft\": \"Links einfügen\",\n    \"insertFieldRight\": \"Rechts einfügen\",\n    \"freezeUpField\": \"Bis zu diesem Feld einfrieren\",\n    \"hideField\": \"Feld verstecken\",\n    \"deleteField\": \"Feld löschen\",\n    \"deleteAllSelectedFields\": \"Alle ausgewählten Felder löschen\",\n    \"filterField\": \"Filtern Sie nach diesem Feld\",\n    \"sortField\": \"Sortieren nach diesem Feld\",\n    \"groupField\": \"Gruppieren nach diesem Feld\",\n    \"autoFill\": \"Alle Datensätze aktualisieren\",\n    \"groupMenuTitle\": \"Gruppenmenü\",\n    \"expandGroup\": \"Gruppe und Untergruppen erweitern\",\n    \"collapseGroup\": \"Gruppe und Untergruppen einklappen\",\n    \"expandAllGroups\": \"Alle Gruppen erweitern\",\n    \"collapseAllGroups\": \"Alle Gruppen einklappen\",\n    \"addToChat\": \"Zum Chat hinzufügen\"\n  },\n  \"connection\": {\n    \"title\": \"Datenbankverbindung\",\n    \"description\": \"Sie können über die Datenbankverbindung direkt auf die Datenbank zugreifen, einschließlich aller Tabellen unter der aktuellen Base\",\n    \"noPermission\": \"Sie haben keine Berechtigung zum Zugriff auf die Datenbank\",\n    \"connectionCountTip\": \"Die maximale Anzahl von Datenbankverbindungen beträgt <b>{{max}}</b> und die aktuellen Verbindungen sind <b>{{current}}</b>\",\n    \"createFailed\": \"Bitte stellen Sie sicher, dass die Umgebungsvariable PUBLIC_DATABASE_PROXY korrekt gesetzt ist\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Datensatz hinzufügen\",\n    \"searchView\": \"Ansicht durchsuchen...\",\n    \"dragToolTip\": \"Die automatische Sortierung ist eingeschaltet, manuelles Ziehen ist nicht möglich.\",\n    \"insertToolTip\": \"Automatische Sortierung ist eingeschaltet, Einfügen mit Auftrag ist nicht verfügbar\",\n    \"action\": {\n      \"rename\": \"Ansicht umbenennen\",\n      \"duplicate\": \"Ansicht duplizieren\",\n      \"delete\": \"Ansicht löschen\",\n      \"lock\": \"Ansicht sperren\",\n      \"unlock\": \"Ansicht entsprren\",\n      \"enable\": \"Aktivieren\"\n    },\n    \"category\": {\n      \"table\": \"Raster Ansicht\",\n      \"form\": \"Formular Ansicht\",\n      \"kanban\": \"Kanban Ansicht\",\n      \"gallery\": \"Galerie Ansicht\",\n      \"calendar\": \"Kalendar Ansicht\"\n    },\n    \"crash\": {\n      \"title\": \"Crash!\",\n      \"description\": \"Diese Ansicht ist defekt. Wenn die Aktualisierung weiterhin fehlschlägt, kontaktieren Sie uns bitte. support@teable.ai\"\n    },\n    \"addPluginView\": \"Ansicht Plugin hinzufügen\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"Felder ({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"Diese Ansicht ist gesperrt. Sie können den persönlichen Modus aktivieren, um die Ansichtsoptionen zu bearbeiten, und die Änderungen wirken sich nur auf Sie aus.\"\n    },\n    \"noView\": \"Keine Ansichten\"\n  },\n  \"lastModifiedTime\": \"Letzter Änderungszeitpunkt\",\n  \"lastModify\": \"Letzte Änderung: \",\n  \"pasteNewRecords\": {\n    \"title\": \"Möchten Sie mehrere Datensätze hinzufügen?\",\n    \"description\": \"Die {{count}} Datensätze werden der Tabelle hinzugefügt.\"\n  },\n  \"tableTrash\": {\n    \"title\": \"Tabelle Papierkorb\",\n    \"resourceType\": \"Typ der Ressource\",\n    \"deletedResource\": \"Gelöschte Ressource\"\n  },\n  \"baseShare\": {\n    \"title\": \"Base teilen\",\n    \"shareTitle\": \"Teilen\",\n    \"shareToWeb\": \"Im Web teilen\",\n    \"description\": \"\\\"{{baseName}}\\\" mit anderen teilen\",\n    \"nodeShareDescription\": \"\\\"{{nodeName}}\\\" und dessen Inhalte teilen\",\n    \"shareLinks\": \"Freigabelinks\",\n    \"newLink\": \"Neuer Link\",\n    \"noShareLinks\": \"Noch keine Freigabelinks\",\n    \"createFirstLink\": \"Freigabelink erstellen\",\n    \"editSettings\": \"Einstellungen bearbeiten\",\n    \"refreshLink\": \"Link erneuern\",\n    \"deleteLink\": \"Link löschen\",\n    \"deleteConfirmTitle\": \"Freigabelink löschen\",\n    \"deleteConfirmDescription\": \"Diese Aktion kann nicht rückgängig gemacht werden. Personen mit diesem Link können nicht mehr auf die geteilte Base zugreifen.\",\n    \"createSuccess\": \"Freigabelink erstellt\",\n    \"createFailed\": \"Freigabelink konnte nicht erstellt werden\",\n    \"updateSuccess\": \"Freigabeeinstellungen aktualisiert\",\n    \"updateFailed\": \"Freigabeeinstellungen konnten nicht aktualisiert werden\",\n    \"deleteSuccess\": \"Freigabelink gelöscht\",\n    \"deleteFailed\": \"Freigabelink konnte nicht gelöscht werden\",\n    \"refreshSuccess\": \"Freigabelink erneuert\",\n    \"refreshFailed\": \"Freigabelink konnte nicht erneuert werden\",\n    \"copied\": \"Link in Zwischenablage kopiert\",\n    \"shareLink\": \"Freigabelink\",\n    \"linkHolderLabel\": \"Die Person, die den Link erhalten hat\",\n    \"linkHolderCanView\": \"Kann anzeigen\",\n    \"linkHolderCanEdit\": \"Kann bearbeiten\",\n    \"linkHolderCanCopyAndSave\": \"Kann als Kopie speichern\",\n    \"passwordProtection\": \"Passwortschutz\",\n    \"enterPassword\": \"Passwort eingeben\",\n    \"selectNodes\": \"Tabellen auswählen\",\n    \"shareEntireBase\": \"Gesamte Base teilen\",\n    \"shareSelectedNodes\": \"Ausgewählte Knoten teilen\",\n    \"shareEntireBaseDescription\": \"Neue Tabellen und Ordner, die dieser Base hinzugefügt werden, werden automatisch geteilt\",\n    \"noNodesSelectedWarning\": \"Bitte wählen Sie mindestens einen Knoten zum Teilen aus\",\n    \"allowSave\": \"Im Space speichern erlauben\",\n    \"allowSaveDescription\": \"Betrachtern erlauben, eine Kopie dieser Base in ihrem eigenen Space zu speichern\",\n    \"allowCopy\": \"Daten kopieren erlauben\",\n    \"allowCopyData\": \"Betrachtern erlauben, Daten zu kopieren\",\n    \"allowDuplicate\": \"Betrachtern erlauben, zu duplizieren\",\n    \"allowCopyDescription\": \"Betrachtern erlauben, Tabellendaten aus dieser geteilten Base zu kopieren\",\n    \"selectedNodes\": \"{{count}} Tabellen ausgewählt\",\n    \"allNodes\": \"Alle Tabellen\",\n    \"sharedNode\": \"Geteilter Knoten\",\n    \"sharedNodeDescription\": \"Dieser Freigabelink ist für einen bestimmten Knoten und dessen Unterknoten\",\n    \"publicShareTitle\": \"Öffentlicher Link zum Teilen\",\n    \"publicShareCount\": \"{{count}} öffentliche Freigabelink(s)\",\n    \"noPublicShare\": \"Keine öffentlichen Freigabelinks\",\n    \"security\": \"Sicherheit\",\n    \"restrictByPassword\": \"Mit Passwort einschränken\",\n    \"advanced\": \"Erweitert\",\n    \"embedConfig\": \"Einbettungskonfiguration\",\n    \"appPublicLink\": \"Öffentlicher App-Link\",\n    \"appNotPublished\": \"Diese App ist noch nicht veröffentlicht. Bitte veröffentlichen Sie die App zuerst, bevor Sie sie teilen.\",\n    \"goToPublish\": \"Zur Veröffentlichung\",\n    \"publishSuccess\": \"App erfolgreich veröffentlicht\",\n    \"publishFailed\": \"App konnte nicht veröffentlicht werden\",\n    \"openLink\": \"Link öffnen\",\n    \"appPublished\": \"App veröffentlicht\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Tabelle Felder abrufen\",\n      \"getTablesMeta\": \"Tabelle Metadaten abrufen\",\n      \"sqlQuery\": \"SQL Abfrage ausführen\",\n      \"generateScriptAction\": \"Skript Aktion generieren\",\n      \"getScriptInput\": \"Skript Eingabe abrufen\",\n      \"getTeableApi\": \"API abrufen\",\n      \"dataVisualization\": \"Datenvisualisierung\",\n      \"updateBase\": \"Datenbankinfo aktualisieren\",\n      \"args\": \"Argumente\",\n      \"result\": \"Ergebnis\",\n      \"thinking\": \"Wird überlegt...\",\n      \"toBeConfirmed\": \"Zu bestätigen\",\n      \"errorMessage\": \"Fehlermeldung\",\n      \"confirm\": \"Bestätigen\",\n      \"createRecordsSuccess\": \"{{count}} Datensatz/Datensätze erfolgreich erstellt\",\n      \"createRecordsFailed\": \"Datensätze konnten nicht erstellt werden\",\n      \"updateRecordsSuccess\": \"{{count}} Datensatz/Datensätze erfolgreich aktualisiert\",\n      \"updateRecordsFailed\": \"Datensätze konnten nicht aktualisiert werden\",\n      \"generatingRecords\": \"{{count}} Datensatz/Datensätze werden generiert...\",\n      \"creatingRecords\": \"{{count}} Datensatz/Datensätze werden erstellt...\",\n      \"updatingRecords\": \"{{count}} Datensatz/Datensätze werden aktualisiert...\",\n      \"recordsPreview\": \"Datensatzvorschau\",\n      \"andMoreRecords\": \"Und {{count}} weitere...\",\n      \"unknownError\": \"Unbekannter Fehler\",\n      \"recordIds\": \"Datensatz-IDs\",\n      \"records\": \"Datensatz/Datensätze\",\n      \"viewAll\": \"Alle anzeigen\",\n      \"showLess\": \"Weniger anzeigen\",\n      \"generatingData\": \"Daten werden generiert...\",\n      \"generatingUpdates\": \"Aktualisierungen werden generiert...\",\n      \"recordsGenerated\": \"{{count}} Datensatz/Datensätze generiert\",\n      \"recordsCount\": \"Datensätze ({{count}})\",\n      \"fieldsCount\": \"Felder ({{count}})\",\n      \"fieldsGenerated\": \"{{count}} Feld/Felder generiert\",\n      \"updatedProperties\": \"Aktualisiert ({{count}})\",\n      \"configured\": \"konfiguriert\",\n      \"recordsToUpdate\": \"{{count}} Datensatz/Datensätze zu aktualisieren\",\n      \"showingLast\": \"letzte {{count}}\",\n      \"recordLabel\": \"Datensatz\",\n      \"statusGenerating\": \"Wird generiert...\",\n      \"statusCreating\": \"Wird erstellt...\",\n      \"statusUpdating\": \"Wird aktualisiert...\",\n      \"statusCreated\": \"Erstellt\",\n      \"statusUpdated\": \"Aktualisiert\",\n      \"getApps\": {\n        \"title\": \"App-Liste\",\n        \"loading\": \"Apps werden geladen...\",\n        \"foundApps\": \"{{count}} App(s) gefunden\",\n        \"noApps\": \"Keine Apps in dieser Basis gefunden\",\n        \"openApp\": \"App öffnen\"\n      },\n      \"generateApp\": {\n        \"title\": \"App-Builder\",\n        \"creatingApp\": \"App wird erstellt\",\n        \"updatingApp\": \"App wird aktualisiert\",\n        \"generatingApp\": \"App wird generiert\",\n        \"generating\": \"Wird generiert...\",\n        \"openApp\": \"App öffnen\",\n        \"viewProgress\": \"Fortschritt anzeigen\",\n        \"newApp\": \"Neue App\",\n        \"building\": \"Wird erstellt...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Automatisierungs-Builder\",\n        \"creatingAutomation\": \"Automatisierung wird erstellt\",\n        \"updatingAutomation\": \"Automatisierung wird aktualisiert\",\n        \"generatingAutomation\": \"Automatisierung wird erstellt\",\n        \"building\": \"Wird erstellt...\",\n        \"openAutomation\": \"Automatisierung öffnen\",\n        \"viewProgress\": \"Fortschritt anzeigen\",\n        \"testResults\": \"Testergebnisse\",\n        \"triggerTest\": \"Trigger\",\n        \"actionTest\": \"Aktion {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Vorschau\",\n        \"code\": \"Code\",\n        \"download\": \"Herunterladen\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Bild (PNG)\",\n        \"copy\": \"Kopieren\",\n        \"copied\": \"Kopiert!\",\n        \"fullscreen\": \"Vollbild\",\n        \"exitFullscreen\": \"Vollbild beenden\",\n        \"downloadSuccess\": \"Bild erfolgreich heruntergeladen\",\n        \"downloadFailed\": \"Bild konnte nicht aufgenommen werden\",\n        \"iframeFailed\": \"Aufnahme fehlgeschlagen: iframe nicht zugänglich\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Anhang laden\",\n        \"loading\": \"Anhang wird geladen\",\n        \"failed\": \"Anhang konnte nicht geladen werden\",\n        \"empty\": \"Keine Anhänge geladen\",\n        \"modeNative\": \"KI-Vision\",\n        \"modeNativeDesc\": \"In KI-Kontext geladen\",\n        \"modeExtracted\": \"Text extrahiert\",\n        \"modeExtractedDesc\": \"Dateiinhalt geparst und als Text zur Analyse extrahiert\",\n        \"visionLoaded\": \"Zur visuellen Analyse geladen\",\n        \"pdfLoaded\": \"Zur PDF-Analyse geladen\",\n        \"textExtracted\": \"{{chars}} Zeichen extrahiert\",\n        \"contextLoaded\": \"In KI-Kontext geladen\",\n        \"truncated\": \"Gekürzt\",\n        \"preview\": \"Vorschau\"\n      },\n      \"textExtract\": {\n        \"title\": \"Text extrahieren\",\n        \"loading\": \"Text wird extrahiert\",\n        \"failed\": \"Text konnte nicht extrahiert werden\",\n        \"empty\": \"Kein Text extrahiert\",\n        \"preview\": \"Vorschau\",\n        \"truncated\": \"Gekürzt\",\n        \"previews\": \"Vorschauen\",\n        \"chars\": \"{{chars}} Zeichen\",\n        \"totalCharacters\": \"Gesamt: {{chars}} Zeichen\",\n        \"filesTruncated\": \"({{count}} Datei(en) gekürzt)\"\n      },\n      \"importExcel\": {\n        \"title\": \"Excel importieren\",\n        \"loading\": \"Datei wird verarbeitet...\",\n        \"failed\": \"Import fehlgeschlagen\",\n        \"suggestions\": \"Vorschläge\",\n        \"analyzeComplete\": \"Analyse abgeschlossen\",\n        \"worksheets\": \"Arbeitsblätter\",\n        \"columns\": \"Spalten\",\n        \"importComplete\": \"Import abgeschlossen\",\n        \"stageAnalyze\": \"Wird analysiert\",\n        \"stageImport\": \"Wird importiert\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Teable-API abrufen\",\n      \"readFiles\": \"Dateien lesen\",\n      \"writeFile\": \"Datei schreiben\",\n      \"deleteFiles\": \"Dateien löschen\",\n      \"listFiles\": \"Dateien auflisten\",\n      \"addDependencies\": \"Abhängigkeiten hinzufügen\",\n      \"checkBuildErrors\": \"Build-Fehler prüfen\",\n      \"lint\": \"Code linten\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Vorschau konnte nicht geladen werden\",\n      \"retry\": \"{{count}}-ter Versuch\",\n      \"chatAborted\": \"abgebrochen\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Gelöschte Tabelle\",\n      \"deletedView\": \"Gelöschte Ansicht\",\n      \"deletedField\": \"Gelöschtes Feld\",\n      \"deletedRecords\": \"Gelöschte Datensätze\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Tabellen-Agent\",\n      \"viewOperatorAgent\": \"Ansichten-Agent\",\n      \"fieldOperatorAgent\": \"Feld-Agent\",\n      \"recordOperatorAgent\": \"Datensatz-Agent\",\n      \"buildBaseAgent\": \"Basis-Aufbau-Agent\",\n      \"buildAutomationAgent\": \"Automatisierungs-Aufbau-Agent\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"Zu bestätigen\",\n      \"deleteWarning\": \"Sind Sie sicher, dass Sie löschen möchten?\"\n    },\n    \"action\": {\n      \"createTable\": \"Tabelle erstellen\",\n      \"updateTable\": \"Tabelle aktualisieren\",\n      \"updateTableName\": \"Tabellennamen aktualisieren\",\n      \"deleteTable\": \"Tabelle löschen\",\n      \"createView\": \"Ansicht erstellen\",\n      \"updateView\": \"Ansicht aktualisieren\",\n      \"updateViewName\": \"Ansichtsnamen aktualisieren\",\n      \"deleteView\": \"Ansicht löschen\",\n      \"createField\": \"Feld erstellen\",\n      \"createAiField\": \"KI-Feld erstellen\",\n      \"createLinkField\": \"Verknüpfungsfeld erstellen\",\n      \"createLookupField\": \"Nachschlagefeld erstellen\",\n      \"createRollupField\": \"Rollup-Feld erstellen\",\n      \"createFormulaField\": \"Formelfeld erstellen\",\n      \"deleteField\": \"Feld löschen\",\n      \"updateField\": \"Feld aktualisieren\",\n      \"createRecord\": \"Datensatz erstellen\",\n      \"createRecords\": \"Datensätze erstellen\",\n      \"deleteRecord\": \"Datensatz löschen\",\n      \"updateRecord\": \"Datensatz aktualisieren\",\n      \"updateRecords\": \"Datensätze aktualisieren\",\n      \"updateBase\": \"Datenbank-Info aktualisieren\",\n      \"planTask\": \"Aufgabe planen...\",\n      \"generateTables\": \"Tabellen generieren\",\n      \"generatePrimaryFields\": \"Primärfelder generieren\",\n      \"generateFields\": \"Felder generieren\",\n      \"generateViews\": \"Ansichten generieren\",\n      \"generateRecords\": \"Datensätze generieren\",\n      \"generateAIFields\": \"KI-Felder generieren\",\n      \"generateLinkFields\": \"Verknüpfungsfelder generieren\",\n      \"generateLookupFields\": \"Nachschlagefelder generieren\",\n      \"generateRollupFields\": \"Rollup-Felder generieren\",\n      \"generateFormulaFields\": \"Formelfelder generieren\",\n      \"generateWorkflow\": \"Workflow generieren\",\n      \"generateTrigger\": \"Trigger generieren\",\n      \"generateScriptAction\": \"Skript-Aktionsknoten generieren\",\n      \"generateSendMailAction\": \"E-Mail-Versandknoten generieren\",\n      \"generateAction\": \"Aktionsknoten generieren\",\n      \"setupAutomationTrigger\": \"Automatisierungstrigger einrichten\",\n      \"testAutomationNode\": \"Automatisierungsknoten testen\",\n      \"activateAutomation\": \"Automatisierung aktivieren\",\n      \"executeScript\": \"Skript ausführen\",\n      \"wait\": \"Warten\",\n      \"generateScriptFlowChart\": \"Skript-Flussdiagramm generieren\",\n      \"triggerAiFill\": \"KI-Ausfüllung auslösen\",\n      \"initialize\": \"Umgebung initialisieren\",\n      \"rename\": \"App-Name generieren\",\n      \"buildTest\": \"Test erstellen\",\n      \"developTask\": \"Aufgabe entwickeln\",\n      \"generateSummary\": \"Zusammenfassung generieren\",\n      \"previewEnvironment\": \"Vorschauumgebung vorbereiten\",\n      \"getRelativeData\": \"Verwandte Daten abrufen\",\n      \"getPreviousNodeOutputVariables\": \"Vorherige Knotenausgabevariablen abrufen\",\n      \"getApiJson\": \"API-Info abrufen\",\n      \"generateScriptAndDependencies\": \"Skript und Abhängigkeiten generieren\",\n      \"analyzingAttachment\": \"Anhang wird analysiert...\",\n      \"locateResource\": \"Suchen\",\n      \"goTo\": \"Gehe zu\",\n      \"operationSuccess\": \"Vorgang erfolgreich abgeschlossen\",\n      \"operationFailed\": \"Vorgang fehlgeschlagen\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} Datensatz/Datensätze für KI-Generierung in Warteschlange\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Datensätze abfragen\",\n      \"getRecordsWithTable\": \"Datensätze abfragen · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Felder abfragen\",\n      \"getFieldsWithTable\": \"Felder abfragen · {{tableName}}\",\n      \"getTables\": \"Tabellen abfragen\",\n      \"getViews\": \"Ansichten abfragen\",\n      \"getViewsWithTable\": \"Ansichten abfragen · {{tableName}}\",\n      \"sqlQuery\": \"SQL-Abfrage\",\n      \"querying\": \"Wird abgefragt...\",\n      \"queryFailed\": \"Abfrage fehlgeschlagen\",\n      \"aborted\": \"Abgebrochen\",\n      \"noData\": \"Keine Daten zurückgegeben\",\n      \"dataFormatError\": \"Datenformatfehler\",\n      \"unsupportedQueryType\": \"Nicht unterstützter Abfragetyp: {{toolName}}\",\n      \"returnedRecords\": \"{{count}} Datensatz/Datensätze zurückgegeben\",\n      \"record\": \"Datensatz {{index}}\",\n      \"moreRecords\": \"... +{{count}} weitere Datensätze\",\n      \"foundFields\": \"{{count}} Feld/Felder gefunden\",\n      \"moreFields\": \"... +{{count}} weitere Felder\",\n      \"foundTables\": \"{{count}} Tabelle(n) gefunden\",\n      \"moreTables\": \"... +{{count}} weitere Tabellen\",\n      \"foundViews\": \"{{count}} Ansicht(en) gefunden\",\n      \"moreViews\": \"... +{{count}} weitere Ansichten\",\n      \"queryReturned\": \"Abfrage lieferte {{rowCount}} Zeile(n) × {{columnCount}} Spalte(n)\",\n      \"row\": \"Zeile {{index}}\",\n      \"moreRows\": \"... +{{count}} weitere Zeilen\",\n      \"getDoc\": \"Dokument abrufen\",\n      \"getDocWithTopic\": \"Dokument abrufen · {{topic}}\",\n      \"getAutomations\": \"Automatisierungen abfragen\",\n      \"getAutomation\": \"Automatisierung abfragen\",\n      \"getAutomationRuns\": \"Automatisierungsausführungen abfragen\",\n      \"foundAutomations\": \"{{count}} Automatisierung(en) gefunden\",\n      \"moreAutomations\": \"... +{{count}} weitere Automatisierungen\",\n      \"foundRuns\": \"{{count}} Ausführung(en) gefunden\",\n      \"moreRuns\": \"... +{{count}} weitere Ausführungen\",\n      \"active\": \"Aktiv\",\n      \"trigger\": \"Trigger\",\n      \"actions\": \"{{count}} Aktion(en)\",\n      \"moreActions\": \"... +{{count}} weitere Aktionen\",\n      \"getUserIntegrations\": \"Integrationen prüfen\",\n      \"connectedIntegrations\": \"Verbunden\",\n      \"availableToConnect\": \"Zum Verbinden verfügbar\",\n      \"connect\": \"Verbinden\",\n      \"noIntegrationsAvailable\": \"Keine Integrationen verfügbar\",\n      \"activateTool\": \"Tools aktivieren\",\n      \"webSearch\": \"Websuche\",\n      \"webSearchResults\": \"{{count}} Ergebnis(se) gefunden\",\n      \"webSearchCompleted\": \"Suche abgeschlossen\",\n      \"searchApi\": \"API suchen\",\n      \"searchApiWithQuery\": \"API suchen · {{query}}\",\n      \"noApiFound\": \"Keine APIs gefunden\",\n      \"foundApis\": \"{{count}} API(s) gefunden\",\n      \"totalApis\": \"Gesamt {{count}} APIs verfügbar\",\n      \"callApi\": \"API aufrufen\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Antwort\",\n      \"success\": \"Erfolgreich\",\n      \"failed\": \"Fehlgeschlagen\",\n      \"inputData\": \"Eingabedaten\",\n      \"availableNodes\": \"Verfügbare Knoten\",\n      \"hasPreviousCode\": \"Vorhandener Code\",\n      \"noInputData\": \"Keine Eingabedaten verfügbar\"\n    },\n    \"showUI\": {\n      \"connect\": \"Verbinden\",\n      \"connecting\": \"Wird verbunden...\",\n      \"connected\": \"Verbunden\",\n      \"connectToUse\": \"{{provider}} verbinden, um in Automatisierungen zu nutzen\",\n      \"checkingConnection\": \"Verbindungsstatus wird geprüft...\",\n      \"confirm\": \"Bestätigen\",\n      \"confirmed\": \"Bestätigt\",\n      \"cancel\": \"Abbrechen\",\n      \"cancelled\": \"Abgebrochen\",\n      \"connectionCancelled\": \"Verbindung abgebrochen\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} Zeilen ausgeblendet\",\n      \"collapseCode\": \"Code einklappen\",\n      \"code\": \"Code\",\n      \"preview\": \"Vorschau\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Build-Fortschritt\",\n      \"completed\": \"App-Build abgeschlossen\",\n      \"completedDesc\": \"Alle Schritte erfolgreich abgeschlossen, App ist zur Vorschau bereit\",\n      \"stepStatus\": {\n        \"initializing\": \"App wird erstellt und Umgebung initialisiert...\",\n        \"naming\": \"App-Name wird generiert...\",\n        \"planning\": \"Anforderungen werden analysiert und Entwicklung geplant...\",\n        \"developing\": \"Code wird geschrieben und Funktionen implementiert...\",\n        \"summarizing\": \"Entwicklungsergebnisse werden zusammengefasst...\",\n        \"deploying\": \"Wird in Vorschauumgebung bereitgestellt...\",\n        \"testing\": \"Test wird erstellt...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"Läuft\",\n        \"completed\": \"Abgeschlossen\",\n        \"error\": \"Fehlgeschlagen\",\n        \"pending\": \"Ausstehend\"\n      },\n      \"toolStatus\": {\n        \"running\": \"Läuft\",\n        \"completed\": \"Abgeschlossen\",\n        \"error\": \"Fehlgeschlagen\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Skript erfolgreich generiert\"\n    },\n    \"buildBase\": {\n      \"title\": \"Basis erstellen\",\n      \"generateSuccess\": \"Datenbank erfolgreich generiert\",\n      \"generateError\": \"Datenbank konnte nicht generiert werden\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Automatisierung erstellen\",\n      \"generateSuccess\": \"Automatisierung erfolgreich generiert\"\n    },\n    \"automation\": {\n      \"created\": \"Erstellt\",\n      \"updated\": \"Aktualisiert\",\n      \"workflow\": \"Workflow\",\n      \"trigger\": \"Trigger\",\n      \"scriptAction\": \"Skript-Aktion\",\n      \"workflowLabel\": \"Workflow\",\n      \"triggerLabel\": \"Trigger\",\n      \"scriptActionLabel\": \"Skript-Aktion\",\n      \"workflowId\": \"Workflow\",\n      \"triggerId\": \"Trigger\",\n      \"scriptActionId\": \"Skript-Aktion\",\n      \"viewAutomation\": \"Anzeigen\",\n      \"navigateToAutomation\": \"Zur Automatisierung navigieren\",\n      \"triggerType\": {\n        \"recordCreated\": \"Datensatz erstellt\",\n        \"recordUpdated\": \"Datensatz aktualisiert\",\n        \"recordCreatedOrUpdated\": \"Datensatz erstellt oder aktualisiert\",\n        \"formSubmitted\": \"Formular eingereicht\",\n        \"scheduledTime\": \"Geplante Zeit\",\n        \"buttonClick\": \"Schaltflächenklick\"\n      },\n      \"activated\": \"Aktiviert\",\n      \"deactivated\": \"Deaktiviert\",\n      \"discarded\": \"Änderungen verworfen\",\n      \"activateFailed\": \"Aktivierung fehlgeschlagen\",\n      \"deactivateFailed\": \"Deaktivierung fehlgeschlagen\",\n      \"discardFailed\": \"Verwerfen fehlgeschlagen\",\n      \"scriptUpdated\": \"Skript aktualisiert\",\n      \"scriptUpdateFailed\": \"Aktualisierung fehlgeschlagen\",\n      \"scriptExecuted\": \"Skript ausgeführt\",\n      \"scriptExecutionFailed\": \"Ausführung fehlgeschlagen\",\n      \"scriptReady\": \"Skript bereit\",\n      \"executingScript\": \"Skript wird ausgeführt...\",\n      \"waitedSeconds\": \"{{seconds}}s gewartet\",\n      \"waitFailed\": \"Warten fehlgeschlagen\",\n      \"flowchartGenerated\": \"Flussdiagramm generiert\",\n      \"flowchartGenerationFailed\": \"Generierung fehlgeschlagen\"\n    },\n    \"newChat\": \"Neue Chat\",\n    \"clearChat\": \"Chat löschen\",\n    \"expand\": \"Erweitern\",\n    \"history\": \"Verlauf\",\n    \"close\": \"Einklappen\",\n    \"clearChatConfirmTitle\": \"Chat löschen bestätigen\",\n    \"clearChatConfirmDesc\": \"Der aktuelle Chat-Inhalt wird nicht gespeichert. Sind Sie sicher, dass Sie ihn löschen möchten?\",\n    \"dontShowAgain\": \"Nicht mehr anzeigen\",\n    \"noModel\": \"Keine verfügbaren Modelle\",\n    \"addAttachment\": \"Anhang hinzufügen\",\n    \"noHistory\": \"Keine Chat-Verlauf\",\n    \"noFoundHistory\": \"Keine passenden Chat-Verlauf gefunden, starten Sie einen neuen Chat\",\n    \"timeGroup\": {\n      \"today\": \"Heute\",\n      \"oneWeek\": \"Eine Woche\",\n      \"twoWeek\": \"Zwei Wochen\",\n      \"oneMonth\": \"Ein Monat\",\n      \"other\": \"Andere\"\n    },\n    \"context\": {\n      \"button\": \"Kontext hinzufügen\",\n      \"search\": \"Tabellen hinzufügen\",\n      \"searchEmpty\": \"Kein passender Kontext gefunden\",\n      \"emptyContext\": \"Kein Kontext zum Hinzufügen\",\n      \"selectionRows\": \"Zeilen {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Nachricht senden...\",\n    \"thought\": \"Denken\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Generierungszeit: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} Credits verbraucht\",\n      \"tokenDescription\": \"Tokens verwendet: {{tokens}}\",\n      \"input\": \"Eingabe\",\n      \"output\": \"Ausgabe\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"Gesamtzeitaufwand\",\n      \"totalCreditCost\": \"Gesamt-Creditverbrauch\",\n      \"customModel\": \"Benutzerdefiniertes Modell\",\n      \"tokenDetails\": \"Token-Details\",\n      \"cachedInput\": \"Gecachte Eingabe (90 % Rabatt)\",\n      \"cacheWrite\": \"Cache-Schreiben\",\n      \"reasoning\": \"Begründung (Denken)\",\n      \"taskCompleted\": \"Aufgabe abgeschlossen\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Datenvisualisierung fehlgeschlagen\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Nur Administratoren können konfigurieren\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"Bild wird nicht unterstützt\",\n      \"attachmentSizeExceeded\": \"Anhang überschreitet die Größenbeschränkung von {{size}}MB\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Empfohlen\",\n      \"ask\": \"Fragen\",\n      \"analyze\": \"Analysieren\",\n      \"build\": \"Erstellen\",\n      \"title\": \"Wie kann ich helfen?\",\n      \"whatCanIDo\": \"Was kann ich tun?\",\n      \"createOrModifyDatabase\": \"Datenbank erstellen oder ändern\",\n      \"buildAutomations\": \"Automatisierungen erstellen\",\n      \"buildApps\": \"Apps erstellen\",\n      \"buildMeCRM\": \"Erstellen Sie mir ein CRM\",\n      \"addAIField\": \"KI-Feld hinzufügen, um jeden Kunden zu analysieren\",\n      \"createDataAnalysis\": \"Erstellen Sie mir einen Datenanalysebericht\",\n      \"emailWhenRecordCreated\": \"E-Mail senden, wenn Datensatz erstellt wurde\",\n      \"syncStatusToSlack\": \"Statusaktualisierungen mit Slack synchronisieren\",\n      \"buildDashboard\": \"Dashboard aus dieser Tabelle erstellen\",\n      \"buildLeadCapture\": \"Erstellen Sie mir eine Lead-Erfassungs-Landingpage\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"{{duration}}s nachgedacht\"\n      },\n      \"task\": {\n        \"searching\": \"\\\"{{query}}\\\" suchen\",\n        \"readingFiles\": \"Dateien lesen:\",\n        \"foundResults\": \"{{count}} Ergebnisse gefunden\",\n        \"noIssuesFound\": \"Keine Probleme gefunden\",\n        \"defaultTitle\": \"Aufgabe\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Code-Projekt\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"Zur Vorschauerstellung ist ein KI-Modell erforderlich.\",\n      \"writeCodeHint\": \"Schreiben Sie Code, um die Flussdiagramm-Vorschau anzuzeigen.\",\n      \"noPreview\": \"Keine Flussdiagramm-Vorschau verfügbar.\",\n      \"generatePreview\": \"Vorschau generieren\",\n      \"analyzing\": \"Skript wird analysiert...\",\n      \"codeChanged\": \"Der Code wurde seit der letzten Vorschau geändert.\",\n      \"regenerate\": \"Neu generieren\",\n      \"refresh\": \"Aktualisieren\",\n      \"regenerating\": \"Wird neu generiert...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Alle Anhänge herunterladen\",\n      \"loading\": \"Vorschau wird geladen...\",\n      \"rowsWithAttachments\": \"{{count}} Zeilen mit Anhängen\",\n      \"totalAttachments\": \"{{count}} Anhänge\",\n      \"totalSize\": \"Gesamtgröße: {{size}}\",\n      \"startDownload\": \"Download starten\",\n      \"confirmTitle\": \"{{count}} Dateien herunterladen\",\n      \"confirmDescription\": \"Gesamtgröße: {{size}}. Die Dateien werden in eine ZIP-Datei komprimiert.\",\n      \"confirm\": \"Herunterladen\",\n      \"cancel\": \"Abbrechen\",\n      \"downloading\": \"Wird heruntergeladen...\",\n      \"downloadingFile\": \"Wird heruntergeladen: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Download abgeschlossen\",\n      \"cancelled\": \"Download abgebrochen\",\n      \"noAttachments\": \"Keine Anhänge zum Herunterladen\",\n      \"error\": \"Download fehlgeschlagen\",\n      \"errorPartial\": \"{{failedCount}} Dateien konnten nicht heruntergeladen werden\",\n      \"requireHttps\": \"Batch-Download erfordert HTTPS. Bitte über HTTPS oder localhost zugreifen.\",\n      \"advancedOptions\": \"Erweiterte Optionen\",\n      \"namingFieldLabel\": \"Präfix für Anhangsname\",\n      \"selectField\": \"Standard: Anhangsindex\",\n      \"groupByRow\": \"In Ordner archivieren\",\n      \"groupByRowTip\": \"Wenn eine Zeile mehrere Anhänge hat, werden sie im selben Ordner abgelegt; Zeilen mit nur einem Anhang erstellen keinen Ordner.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/token.json",
    "content": "{\n  \"access\": \"Zugriff\",\n  \"name\": \"Name\",\n  \"description\": \"Beschreibung\",\n  \"scopes\": \"Geltungsbereiche\",\n  \"expiration\": \"Verfallsdatum\",\n  \"createdTime\": \"Erstellt\",\n  \"lastUse\": \"Zuletzt genutzt\",\n  \"allSpace\": \"Der Space, alle derzeitigen und zukünftigen Bases in diesem Space\",\n  \"formLabelTips\": {\n    \"name\": \"Geben Sie einen Token-Namen an\",\n    \"description\": \"Wozu dient dieses Token?\",\n    \"scopes\": \"Mit diesem Token dürfen Sie:\",\n    \"access\": \"Dieses Token kann auf die folgenden Bases und Spaces zugreifen. Sie können nur Zugriff auf Bases und Spaces gewähren, auf die Sie Zugriff haben.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Erstelle neues Token\",\n    \"title\": \"Für die Nutzung der Teable-API sind persönliche Zugangstoken erforderlich.\",\n    \"description\": \"Der Token gewährt Zugriff auf die Daten in den ausgewählten Spaces und Bases und auf andere API-Endpunkte, die kein Space/Base sind. Bitte verwenden Sie dieses Token nur für Ihre eigene Entwicklung. Seien Sie bitte vorsichtig, wenn Sie es mit Diensten und Anwendungen von Dritten teilen.\",\n    \"button\": \"Create new token\",\n    \"success\": {\n      \"title\": \"Token erfolgreich generiert\",\n      \"description\": \"Achten Sie darauf, Ihren Token zu kopieren. Er wird nie wieder angezeigt werden.\"\n    },\n    \"expirationList\": {\n      \"days\": \"Tage\",\n      \"permanent\": \"Dauerhaft\",\n      \"custom\": \"Angepasst\",\n      \"pick\": \"Datum auswählen\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Bearbeite Token\",\n    \"name\": \"Name\",\n    \"scopes\": \"Geltungsbereiche\",\n    \"selectAll\": \"Alle auswählen\",\n    \"cancelSelectAll\": \"Alle auswählen abbrechen\"\n  },\n  \"refresh\": {\n    \"title\": \"Persönliches Zugangstoken neu generieren\",\n    \"description\": \"Wenn Sie dieses Formular abschicken, wird ein neues Token generiert. Beachten Sie, dass alle Skripte oder Anwendungen, die dieses Token verwenden, aktualisiert werden müssen\",\n    \"button\": \"Token regenerieren\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Base oder Space hinzufügen\",\n    \"empty\": \"Kein Zugriff gefunden.\",\n    \"spaceSelectItem\": \"Alle Bases in Space\",\n    \"inputPlaceholder\": \"Finde Space oder Base...\"\n  },\n  \"moreScopes\": \"und {{len}} weitere\",\n  \"list\": {\n    \"description\": \"Persönliche Zugangstoken sind erforderlich, um die Teable-API zu nutzen. Bitte lesen Sie die <a>Hilfe-Dokumentation</a> für weitere Informationen.\"\n  },\n  \"empty\": {\n    \"list\": \"Keine persönlichen Zugangstoken gefunden.\",\n    \"access\": \"Kein Zugriff\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Sind Sie sicher, dass Sie dieses Token löschen wollen?\",\n    \"description\": \"Alle Anwendungen oder Skripte, die dieses Token verwenden, können nicht mehr auf die Teable-API zugreifen. Sie können diese Aktion nicht rückgängig machen.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/de/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"{{expected}} erwartet, {{received}} erhalten\",\n    \"invalid_type_received_undefined\": \"Required\",\n    \"invalid_type_received_null\": \"Required\",\n    \"invalid_literal\": \"Ungültiger literaler Wert, {{expected}} erwartet\",\n    \"unrecognized_keys\": \"Nicht erkannte(r) Schlüssel im Objekt: {{- keys}}\",\n    \"invalid_union\": \"Ungültige Eingabe\",\n    \"invalid_union_discriminator\": \"Ungültiger Diskriminatorwert. {{- options}} ewartet\",\n    \"invalid_enum_value\": \"Ungültiger Enum-Wert. {{- options}} erwartet, '{{received}} erhalten\",\n    \"invalid_arguments\": \"Unzulässige Funktionsargumente\",\n    \"invalid_return_type\": \"Ungültiger Rückgabetyp der Funktion\",\n    \"invalid_date\": \"Ungültiges Datum\",\n    \"custom\": \"Ungültige Eingabe\",\n    \"invalid_intersection_types\": \"Schnittmengenergebnisse konnten nicht zusammengeführt werden\",\n    \"not_multiple_of\": \"Die Zahl muss ein Vielfaches von {{multipleOf}} sein\",\n    \"not_finite\": \"Die Zahl muss endlich sein\",\n    \"invalid_string\": {\n      \"email\": \"{{validation}} ungültig\",\n      \"url\": \"{{validation}} ungültig\",\n      \"uuid\": \"{{validation}} ungültig\",\n      \"cuid\": \"{{validation}} ungültig\",\n      \"regex\": \"ungültig\",\n      \"datetime\": \"{{validation}} ungültig\",\n      \"startsWith\": \"Ungültige Eingabe: muss mit \\\"{{startsWith}}\\\" beginnen\",\n      \"endsWith\": \"Ungültige Eingabe: muss mit \\\"{{endsWith}}\\\" enden\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Array muss genau {{minimum}} Elemente enthalten\",\n        \"inclusive\": \"Array muss mindestens {{minimum}} Elemente enthalten\",\n        \"not_inclusive\": \"Array darf nicht mehr als {{minimum}} Elemente enthalten\"\n      },\n      \"string\": {\n        \"exact\": \"String muss genau {{minimum}} Zeichen enthalten\",\n        \"inclusive\": \"String muss mindestens {{minimum}} Zeichen enthalten\",\n        \"not_inclusive\": \"String darf nicht mehr als {{minimum}} Zeichen enthalten\"\n      },\n      \"number\": {\n        \"exact\": \"Nummer muss genau {{minimum}} sein\",\n        \"inclusive\": \"Die Nummer muss größer oder gleich {{minimum}} sein\",\n        \"not_inclusive\": \"Nummer darf nicht größer als {{minimum}} sein\"\n      },\n      \"set\": {\n        \"exact\": \"Ungültige Eingabe\",\n        \"inclusive\": \"Ungültige Eingabe\",\n        \"not_inclusive\": \"Ungültige Eingabe\"\n      },\n      \"date\": {\n        \"exact\": \"Date must be exactly {{- minimum, datetime}}\",\n        \"inclusive\": \"Date must be greater than or equal to {{- minimum, datetime}}\",\n        \"not_inclusive\": \"Date must be greater than {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Array muss genau {{maximum}} Elemente enthalten\",\n        \"inclusive\": \"Array muss mindestens {{maximum}} Elemente enthalten\",\n        \"not_inclusive\": \"Array darf nicht mehr als {{maximum}} Elemente enthalten\"\n      },\n      \"string\": {\n        \"exact\": \"String muss genau {{maximum}} Zeichen enthalten\",\n        \"inclusive\": \"String muss mindestens {{maximum}} Zeichen enthalten\",\n        \"not_inclusive\": \"String darf nicht mehr als {{maximum}} Zeichen enthalten\"\n      },\n      \"number\": {\n        \"exact\": \"Nummer muss genau {{maximum}} sein\",\n        \"inclusive\": \"Die Nummer muss größer oder gleich {{maximum}} sein\",\n        \"not_inclusive\": \"Nummer darf nicht größer als {{maximum}} sein\"\n      },\n      \"set\": {\n        \"exact\": \"Ungültige Eingabe\",\n        \"inclusive\": \"Ungültige Eingabe\",\n        \"not_inclusive\": \"Ungültige Eingabe\"\n      },\n      \"date\": {\n        \"exact\": \"Das Datum muss genau {{- maximum, datetime}} sein\",\n        \"inclusive\": \"Das Datum muss kleiner oder gleich sein als {{- maximum, datetime}}\",\n        \"not_inclusive\": \"Das Datum muss kleiner als {{- maximum, datetime}} sein\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"E-Mail\",\n    \"url\": \"URL\",\n    \"uuid\": \"UUID\",\n    \"cuid\": \"CUID\",\n    \"regex\": \"RegEx\",\n    \"datetime\": \"DateTime\"\n  },\n  \"types\": {\n    \"function\": \"Funktion\",\n    \"number\": \"Nummer\",\n    \"string\": \"String\",\n    \"nan\": \"NaN\",\n    \"integer\": \"Integer\",\n    \"float\": \"Float\",\n    \"boolean\": \"Boolean\",\n    \"date\": \"Datum\",\n    \"bigint\": \"BigInt\",\n    \"undefined\": \"Undefiniert\",\n    \"symbol\": \"Symbol\",\n    \"null\": \"Null\",\n    \"array\": \"Array\",\n    \"object\": \"Objekt\",\n    \"unknown\": \"Unbekannt\",\n    \"promise\": \"Promise\",\n    \"void\": \"Void\",\n    \"never\": \"Never\",\n    \"map\": \"Map\",\n    \"set\": \"Set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"Login\",\n    \"signup\": \"Sign up\"\n  },\n  \"title\": {\n    \"signin\": \"Sign in to your account\",\n    \"signup\": \"Create a new account\"\n  },\n  \"content\": {\n    \"title\": \"Where data flows, teams grow\",\n    \"description\": \"A database designed for every team, from simple tables to enterprise solutions\"\n  },\n  \"legal\": {\n    \"tip\": \"By continuing, you agree to Teable's <Terms>Terms of service</Terms> and <Privacy>Privacy policy</Privacy>, and to receive periodic emails with updates.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"button\": {\n    \"signin\": \"Sign in\",\n    \"signup\": \"Sign up\",\n    \"resend\": \"Resend\"\n  },\n  \"label\": {\n    \"email\": \"Email\",\n    \"password\": \"Password\",\n    \"verificationCode\": \"Verification code\"\n  },\n  \"placeholder\": {\n    \"password\": \"Enter your password...\",\n    \"email\": \"Enter your email...\",\n    \"verificationCode\": \"Enter your verification code...\"\n  },\n  \"signError\": {\n    \"exist\": \"Email has been registered\",\n    \"incorrect\": \"Email or password is incorrect\",\n    \"tooManyRequests\": \"Your account has been locked out, please try again after {{minutes}} minutes\",\n    \"turnstileRequired\": \"Please complete the verification challenge\",\n    \"turnstileError\": \"Verification failed. Please try again\",\n    \"turnstileExpired\": \"Verification expired. Please try again\",\n    \"turnstileTimeout\": \"Verification timed out. Please try again\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"Verification code is required\",\n    \"verificationCodeInvalid\": \"Verification code is invalid\",\n    \"passwordLength\": \"Minimum 8 chars\",\n    \"passwordInvalid\": \"Password must contain at least one letter and one number\",\n    \"sendMailRateLimit\": \"Please wait {{seconds}} seconds before requesting a new code\"\n  },\n  \"socialAuth\": {\n    \"title\": \"Or continue with\",\n    \"sso\": {\n      \"title\": \"Sign in with Single sign on\",\n      \"description\": \"Enter your email to sign in with Single sign on\",\n      \"error\": \"SSO is not set up for your email domain.\"\n    }\n  },\n  \"resetPassword\": {\n    \"header\": \"Set your password\",\n    \"description\": \"Enter a new password\",\n    \"label\": \"New password\",\n    \"error\": {\n      \"requiredPassword\": \"Enter password\",\n      \"invalidLink\": \"Invalid reset password link\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Reset password success\",\n      \"description\": \"Your password has been reset successfully. Will redirect you to the login page.\"\n    },\n    \"buttonText\": \"Confirm reset password\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Forgot password?\",\n    \"header\": \"Reset your password\",\n    \"description\": \"Please enter your email address below and we will send you a link to reset your password.\",\n    \"errorRequiredEmail\": \"Email is required\",\n    \"errorInvalidEmail\": \"Invalid email\",\n    \"sendMailRateLimit\": \"Please wait {{seconds}} seconds before sending a new email\",\n    \"buttonText\": \"Send reset email\",\n    \"success\": {\n      \"title\": \"🎉 Reset password email sent\",\n      \"description\": \"We've sent you an email with a link to reset your password. Please check your inbox.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/chart.json",
    "content": "{\n  \"notBaseId\": \"Missing baseId\",\n  \"notPositionId\": \"Missing positionId\",\n  \"notPluginInstallId\": \"Missing pluginInstallId\",\n  \"initBridge\": \"Bridge initializing...\",\n  \"actions\": {\n    \"cancel\": \"Cancel\",\n    \"save\": \"Save\"\n  },\n  \"queryTitle\": \"Data query configuration\",\n  \"notSupport\": \"Not supported\",\n  \"chart\": {\n    \"bar\": \"Bar\",\n    \"line\": \"Line\",\n    \"pie\": \"Pie\",\n    \"area\": \"Area\",\n    \"table\": \"Table\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Select chart type\",\n      \"label\": \"Chart type\"\n    },\n    \"pie\": {\n      \"dimension\": \"Dimension\",\n      \"measure\": \"Measure\",\n      \"showTotal\": \"Show total\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"X axis\",\n        \"placeholder\": \"Select X axis\"\n      },\n      \"yAxis\": {\n        \"label\": \"Y axis\",\n        \"placeholder\": \"Select Y axis\",\n        \"position\": \"Y-axis position\"\n      },\n      \"xDisplay\": {\n        \"label\": \"X display\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Y display\"\n      },\n      \"addXAxis\": \"Add series breakout\",\n      \"addYAxis\": \"Add another series\",\n      \"stack\": \"Stack\",\n      \"position\": {\n        \"label\": \"Position\",\n        \"auto\": \"Auto\",\n        \"left\": \"Left\",\n        \"right\": \"Right\"\n      },\n      \"goalLine\": {\n        \"label\": \"Goal line\"\n      },\n      \"range\": {\n        \"label\": \"Range\",\n        \"min\": \"Min\",\n        \"max\": \"Max\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Line style\",\n        \"normal\": \"Normal\",\n        \"linear\": \"Linear\",\n        \"step\": \"Step\"\n      },\n      \"displayType\": \"Display type\"\n    },\n    \"typeError\": \"Form: Not supported type of chart\",\n    \"updateQuery\": \"Update\",\n    \"queryError\": \"Query error\",\n    \"querySuccess\": \"Query has configured\",\n    \"decimal\": \"Decimal\",\n    \"prefix\": \"Prefix\",\n    \"suffix\": \"Suffix\",\n    \"showLabel\": \"Show values label on chart\",\n    \"showLegend\": \"Show legend\",\n    \"value\": \"Value\",\n    \"label\": \"Label\",\n    \"padding\": {\n      \"label\": \"Padding\",\n      \"top\": \"Top\",\n      \"right\": \"Right\",\n      \"bottom\": \"Bottom\",\n      \"left\": \"Left\"\n    },\n    \"tableConfig\": \"Table configuration\",\n    \"width\": \"Width\"\n  },\n  \"reloadQuery\": \"Reload query\",\n  \"noStorage\": \"Please configure the chart plugin first\",\n  \"noPermission\": \"No permission to access\",\n  \"goConfig\": \"Go to configuration\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Actions\",\n    \"add\": \"Add\",\n    \"save\": \"Save\",\n    \"doNotSave\": \"Don't save\",\n    \"submit\": \"Submit\",\n    \"confirm\": \"Confirm\",\n    \"continue\": \"Continue\",\n    \"close\": \"Close\",\n    \"edit\": \"Edit\",\n    \"fill\": \"Fill\",\n    \"update\": \"Update\",\n    \"create\": \"Create\",\n    \"delete\": \"Delete\",\n    \"cancel\": \"Cancel\",\n    \"zoomIn\": \"Zoom in\",\n    \"zoomOut\": \"Zoom out\",\n    \"back\": \"Back\",\n    \"download\": \"Download\",\n    \"remove\": \"Remove\",\n    \"removeConfig\": \"Remove config\",\n    \"retry\": \"Retry\",\n    \"saveSucceed\": \"Save succeed!\",\n    \"submitSucceed\": \"Submit succeed!\",\n    \"editSucceed\": \"Edit succeed!\",\n    \"updateSucceed\": \"Update succeed!\",\n    \"deleteSucceed\": \"Delete succeed!\",\n    \"resetSucceed\": \"Empty trash succeed!\",\n    \"restoreSucceed\": \"Restored succeed!\",\n    \"loading\": \"Loading...\",\n    \"refreshPage\": \"Refresh page\",\n    \"yesDelete\": \"Yes, delete\",\n    \"rename\": \"Rename\",\n    \"duplicate\": \"Duplicate\",\n    \"export\": \"Export\",\n    \"import\": \"Import\",\n    \"change\": \"Change\",\n    \"upgrade\": \"Upgrade\",\n    \"upgradeToLevel\": \"Upgrade to {{level}}\",\n    \"search\": \"Search\",\n    \"loadMore\": \"Load more\",\n    \"collapseSidebar\": \"Collapse sidebar\",\n    \"restore\": \"Restore\",\n    \"permanentDelete\": \"Permanent delete\",\n    \"globalSearch\": \"Global search\",\n    \"fieldSearch\": \"Search field\",\n    \"tableIndex\": \"Index\",\n    \"showAllRow\": \"Display all rows\",\n    \"hideNotMatchRow\": \"Hide not match row\",\n    \"more\": \"More\",\n    \"expand\": \"Expand\",\n    \"view\": \"View\",\n    \"preview\": \"Preview\",\n    \"viewAndEdit\": \"View and edit\",\n    \"deleteTip\": \"Are you sure you want to delete \\\"{{name}}\\\"?\",\n    \"move\": \"Move to\",\n    \"turnOn\": \"Turn on\",\n    \"exit\": \"Exit\",\n    \"next\": \"Next\",\n    \"previous\": \"Previous\",\n    \"select\": \"Select\",\n    \"refresh\": \"Refresh\",\n    \"login\": \"Login\",\n    \"useTemplate\": \"Use template\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"copyLink\": \"Copy link\",\n    \"backToSpace\": \"Back to space\",\n    \"switchBase\": \"Switch base\",\n    \"collapse\": \"Collapse\",\n    \"viewDetails\": \"View details\",\n    \"getMore\": \"Get more\",\n    \"copySuccess\": \"Copy successful\"\n  },\n  \"quickAction\": {\n    \"title\": \"Quick actions\",\n    \"placeHolder\": \"Type a command or search...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"The password is invalid, at least 8 characters, and must contain at least one letter and one number.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Share\",\n      \"copy\": \"Copied\"\n    },\n    \"aiTitle\": \"Let's build together\",\n    \"aiGreeting\": \"How can I help you, {{name}}\",\n    \"aiSubTitle\": \"The first AI platform where teams collaborate on data and generate production apps\",\n    \"guideTitle\": \"Start from your scenario\",\n    \"watchVideo\": \"Watch video\",\n    \"title\": \"Template\",\n    \"description\": \"Create a new base from a template\",\n    \"browseAll\": \"Browse all\",\n    \"templateTitle\": \"Start with template\",\n    \"loadMore\": \"Load more\",\n    \"allTemplatesLoaded\": \"All templates loaded\",\n    \"createTemplate\": \"Create template\",\n    \"promptBox\": {\n      \"placeholder\": \"Build your business app with Teable\",\n      \"start\": \"Start it\",\n      \"carouselGuides\": {\n        \"guide1\": \"Paste receipts → Ask Teable to auto-extract key data\",\n        \"guide2\": \"Build an AI CRM with lead & follow-up tables\",\n        \"guide3\": \"Paste customer feedback → Ask Teable to find insights & generate reports\",\n        \"guide4\": \"Create a project tracker with tasks & deadlines\",\n        \"guide5\": \"Paste leads spreadsheet → Ask Teable to build a smart CRM\",\n        \"guide6\": \"Build a content planner with posts & publish dates\",\n        \"guide7\": \"Paste resumes → Ask Teable to organize and shortlist candidates\"\n      }\n    },\n    \"useTemplateDialog\": {\n      \"title\": \"Select space\",\n      \"description\": \"Please select a space to apply template\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Copy to space\",\n      \"description\": \"Please select a space to copy this base to\",\n      \"baseName\": \"Base name\",\n      \"baseNamePlaceholder\": \"Enter base name\",\n      \"selectSpace\": \"Select space\",\n      \"noSpaceDescription\": \"No manageable spaces. Please create a new space to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"No manageable bases in this space. Please select \\\"New base\\\".\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Instance settings\",\n    \"personal\": {\n      \"title\": \"Personal settings\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"Template admin\",\n      \"noData\": \"No data\",\n      \"importing\": \"Importing...\",\n      \"usageCount\": \"Usage count: {{count}}\",\n      \"useTemplate\": \"Use this template\",\n      \"createdBy\": \"by {{user}}\",\n      \"backToTemplateList\": \"Back to template list\",\n      \"tips\": {\n        \"errorCategoryName\": \"Category does not exist or has been deleted\",\n        \"needSnapshot\": \"Please create a snapshot before publishing, and the template name and description cannot be empty\",\n        \"needPublish\": \"Please publish the template before set featured\",\n        \"needBaseSource\": \"Please select a base source before creating a snapshot\",\n        \"forbiddenUpdateSystemTemplate\": \"System template cannot be modified\",\n        \"addCategoryTips\": \"Please enter a category name in search input first.\",\n        \"categoryNamePlaceholder\": \"Enter category name\",\n        \"duplicateCategoryName\": \"Category name already exists\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Get Started\",\n          \"recommended\": \"Recommended\",\n          \"all\": \"All\",\n          \"browseByCategory\": \"Browse by category\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Cover\",\n        \"name\": \"Name\",\n        \"description\": \"Description\",\n        \"markdownDescription\": \"Markdown description\",\n        \"category\": \"Category\",\n        \"isSystem\": \"System\",\n        \"source\": \"Source\",\n        \"status\": \"Published\",\n        \"publishSnapshot\": \"Publish Snapshot\",\n        \"snapshotTime\": \"Snapshot Time\",\n        \"actions\": \"Actions\",\n        \"featured\": \"Featured\",\n        \"createdBy\": \"Created by\",\n        \"userNonExistent\": \"User non-existent\",\n        \"preview\": \"Preview\",\n        \"usage\": \"Usage\",\n        \"visit\": \"Visit\"\n      },\n      \"actions\": {\n        \"title\": \"Actions\",\n        \"publish\": \"Publish\",\n        \"delete\": \"Delete\",\n        \"duplicate\": \"Duplicate\",\n        \"preview\": \"Preview\",\n        \"use\": \"Use\",\n        \"pinTop\": \"Pin top\",\n        \"addCategory\": \"Add category\",\n        \"selectCategory\": \"Select category\",\n        \"viewTemplate\": \"View template\",\n        \"manageCategory\": \"Manage categories\"\n      },\n      \"relatedTemplates\": \"Related Templates\",\n      \"noImage\": \"No image\",\n      \"baseSelectPanel\": {\n        \"title\": \"Select template source\",\n        \"description\": \"Select a base to be the template\",\n        \"confirm\": \"Confirm\",\n        \"search\": \"Search...\",\n        \"cancel\": \"Cancel\",\n        \"selectBase\": \"Select base\",\n        \"createTemplate\": \"Create template\",\n        \"abnormalBase\": \"Base does not exist or has been deleted\"\n      }\n    },\n    \"back\": \"Back to home\",\n    \"account\": {\n      \"title\": \"Profile\",\n      \"tab\": \"Profile\",\n      \"updatePhoto\": \"Update photo\",\n      \"updateNameDesc\": \"Nickname or first name, however you want to be called in Teable\",\n      \"securityTitle\": \"Account security\",\n      \"email\": \"Email\",\n      \"password\": \"Password\",\n      \"passwordDesc\": \"Set a permanent password to login to your account.\",\n      \"changePassword\": {\n        \"title\": \"Change password\",\n        \"desc\": \"Please enter your current password and set a new one\",\n        \"current\": \"Current password\",\n        \"new\": \"New password\",\n        \"confirm\": \"Confirm new password\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Your new password does not match.\",\n        \"equal\": \"Your new password must be different from your current password.\",\n        \"invalid\": \"Your current password is invalid.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Change password successfully.\",\n        \"desc\": \"You will be redirected to the login page in 2 seconds.\"\n      },\n      \"manageToken\": \"Access token\",\n      \"addPassword\": {\n        \"title\": \"Add password\",\n        \"desc\": \"Set a permanent password to login to your account.\",\n        \"password\": \"Enter your password\",\n        \"confirm\": \"Confirm your password\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Your password does not match.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Add password successfully.\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Change email address\",\n        \"desc\": \"Please verify your password and confirm your new email address\",\n        \"current\": \"Current password\",\n        \"new\": \"New email\",\n        \"code\": \"Verification code\",\n        \"getCode\": \"Send code\",\n        \"error\": {\n          \"invalidCode\": \"The verification code is invalid.\",\n          \"invalidPassword\": \"The password is invalid.\",\n          \"invalidConflict\": \"The new email is already registered.\",\n          \"invalidSameEmail\": \"The new email is the same as the current email.\",\n          \"sendMailRateLimit\": \"Please wait {{seconds}} seconds before sending a new email\"\n        },\n        \"success\": {\n          \"title\": \"🎉 Change email successfully.\",\n          \"desc\": \"You will be redirected to the login page in 2 seconds.\",\n          \"sendSuccess\": \"Verification code sent successfully.\"\n        }\n      },\n      \"deleteAccount\": {\n        \"title\": \"Delete account\",\n        \"desc\": \"This action is irreversible. It will permanently delete your account and all associated data.\",\n        \"error\": {\n          \"title\": \"Unable to delete account\",\n          \"desc\": \"You need to handle the following dependencies first:\",\n          \"spacesError\": \"Before deleting your account, you need to leave your spaces(or delete and then go to trash) first.\"\n        },\n        \"confirm\": {\n          \"title\": \"Please enter <code>DELETE</code> to confirm\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Deleting...\"\n      }\n    },\n    \"notify\": {\n      \"title\": \"Notifications\",\n      \"label\": \"Activity in your space\",\n      \"desc\": \"Receive emails when you get comments, mentions, page invites, reminders, access requests, and property changes.\"\n    },\n    \"setting\": {\n      \"title\": \"Preferences\",\n      \"theme\": \"Intertace theme\",\n      \"themeDesc\": \"Select your interface color scheme\",\n      \"dark\": \"Dark\",\n      \"light\": \"Light\",\n      \"system\": \"System\",\n      \"version\": \"App version\",\n      \"language\": \"Language\",\n      \"interactionMode\": \"Interaction mode\",\n      \"mouseMode\": \"Cursor mode\",\n      \"touchMode\": \"Touch mode\",\n      \"systemMode\": \"Follow system\",\n      \"buySelfHostedLicense\": \"Buy self-hosted license\"\n    },\n    \"nav\": {\n      \"settings\": \"Settings\",\n      \"logout\": \"Log out\",\n      \"contactSupport\": \"Contact support\"\n    },\n    \"integration\": {\n      \"title\": \"Integrations\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"Third-party integrations\",\n        \"description\": \"You have granted {{count}} applications access to your account.\",\n        \"lastUsed\": \"Last used at {{date}}\",\n        \"revoke\": \"Revoke\",\n        \"owner\": \"Owner by {{user}}\",\n        \"revokeTitle\": \"Are you sure you want to revoke authorization?\",\n        \"revokeDesc\": \"{{name}} will no longer be able to access the Teable API. You cannot undo this action.\",\n        \"scopeTitle\": \"Permissions\",\n        \"scopeDesc\": \"This application will be able to get the following scopes:\"\n      },\n      \"userIntegration\": {\n        \"title\": \"Connected accounts\",\n        \"description\": \"Connect external accounts to authorize Teable to access and sync your resources.\",\n        \"emptyDescription\": \"No connected accounts\",\n        \"actions\": {\n          \"reconnect\": \"Reconnect\"\n        },\n        \"slack\": {\n          \"user\": \"Slack user\",\n          \"workspace\": \"Slack workspace\"\n        },\n        \"email\": {\n          \"user\": \"User\",\n          \"email\": \"Email\"\n        },\n        \"deleteTitle\": \"Remove connected account\",\n        \"deleteDesc\": \"Are you sure you want to remove {{name}}?\",\n        \"create\": \"Connect new account\",\n        \"manage\": \"Manage connected accounts\",\n        \"searchPlaceholder\": \"Search connected accounts\",\n        \"defaultName\": \"{{name}} Integration\",\n        \"callback\": {\n          \"error\": \"Authorization failed\",\n          \"title\": \"Authorization successful\",\n          \"desc\": \"You may close this window now.\"\n        }\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"Table\",\n    \"view\": \"View\",\n    \"space\": \"Space\",\n    \"base\": \"Base\",\n    \"field\": \"Field\",\n    \"record\": \"Record\",\n    \"dashboard\": \"Dashboard\",\n    \"automation\": \"Automation\",\n    \"authorityMatrix\": \"Authority matrix\",\n    \"design\": \"Design\",\n    \"adminPanel\": \"System Admin\",\n    \"license\": \"Self-hosted license\",\n    \"instanceId\": \"Instance ID\",\n    \"beta\": \"Beta\",\n    \"trash\": \"Trash\",\n    \"global\": \"Global\",\n    \"organizationPanel\": \"Organization settings\",\n    \"unknownError\": \"Unknown error\",\n    \"pluginPanel\": \"Panel\",\n    \"pluginContextMenu\": \"Context menu\",\n    \"plugin\": \"Plugins\",\n    \"copy\": \"Copy\",\n    \"credits\": \"Credits\",\n    \"aiChat\": \"AI Chat\",\n    \"app\": \"App\",\n    \"webSearch\": \"Web search\",\n    \"folder\": \"Folder\",\n    \"newAutomation\": \"New automation\",\n    \"newApp\": \"New app\",\n    \"newFolder\": \"New folder\",\n    \"template\": \"Template\"\n  },\n  \"level\": {\n    \"free\": \"Free\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"Business\",\n    \"enterprise\": \"Enterprise\"\n  },\n  \"noResult\": \"No Result.\",\n  \"allNodes\": \"All Nodes\",\n  \"noDescription\": \"No description\",\n  \"untitled\": \"Untitled\",\n  \"name\": \"Name\",\n  \"description\": \"Description\",\n  \"required\": \"Required\",\n  \"characters\": \"characters\",\n  \"atLeastOne\": \"At least reserve a {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Prev\",\n    \"next\": \"Next\",\n    \"done\": \"Done\",\n    \"skip\": \"Skip\",\n    \"createSpaceTooltipTitle\": \"Create a space\",\n    \"createSpaceTooltipContent\": \"Create a space to collaborate with your team.\",\n    \"createBaseTooltipTitle\": \"Create a base\",\n    \"createBaseTooltipContent\": \"A base stores your data and provides access to automation and apps dashboard.\",\n    \"createTableTooltipTitle\": \"Create a table\",\n    \"createTableTooltipContent\": \"Create a new table or import an existing one here.\",\n    \"createViewTooltipTitle\": \"Create a view\",\n    \"createViewTooltipContent\": \"Create Grid, Gallery, Kanban, and Form views. Calendar views coming soon. <br></br>Choose the best view for your data management needs.\",\n    \"viewFilteringTooltipTitle\": \"Filtering records\",\n    \"viewFilteringTooltipContent\": \"Filter records by setting conditions. Filtered records are hidden, not deleted. <br></br>This only affects the current view, not your actual data.\",\n    \"viewSortingTooltipTitle\": \"Sorting records\",\n    \"viewSortingTooltipContent\": \"Sort records by field values to organize them in your preferred order. <br></br>Sorting only affects the current view, not other views of the same table.\",\n    \"viewGroupingTooltipTitle\": \"Grouping records\",\n    \"viewGroupingTooltipContent\": \"Group records by conditions to categorize and organize your dataset within a view.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Access Teable's powerful API with nearly all product features using an <a>Token</a>.\"\n  },\n  \"token\": \"Token\",\n  \"poweredBy\": \"Powered by <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"{{spaceName}} space sharing\",\n      \"desc_one\": \"This space has <b>{{count}} collaborator</b>. Adding a space collaborator will give them access to all bases within this space.\",\n      \"desc_other\": \"This space has <b>{{count}} collaborators</b>. Adding a space collaborator will give them access to all bases within this space.\",\n      \"desc_billable_one\": \"This space has <b>{{count}} collaborator</b> and <b>{{billableCount}} billable user</b>. Adding a space collaborator will give them access to all bases within this space.\",\n      \"desc_billable_other\": \"This space has <b>{{count}} collaborators</b> and <b>{{billableCount}} billable users</b>. Adding a space collaborator will give them access to all bases within this space.\",\n      \"tabEmail\": \"Invite by email\",\n      \"emailPlaceholder\": \"Enter email addresses, separated by 'Enter' key\",\n      \"tabLink\": \"Invite by link\",\n      \"linkPlaceholder\": \"Create an invite link that grants <0/> access to anyone who opens it.\",\n      \"emailSend\": \"Send invite\",\n      \"linkSend\": \"Create link\",\n      \"spaceTitle\": \"Space collaborators\",\n      \"spaceTitleWithCount\": \"Space collaborators ({{count}})\",\n      \"baseTitle\": \"Base collaborators\",\n      \"allCollaboratorsTitle\": \"All collaborators (Space & Base)\",\n      \"baseOnly\": \"Base only\",\n      \"collaboratorSearchPlaceholder\": \"Find a space collaborator by name or email\",\n      \"collaboratorJoin\": \"joined {{joinTime}}\",\n      \"collaboratorRemove\": \"Remove collaborator\",\n      \"linkTitle\": \"Invite links\",\n      \"linkCreatedTime\": \"created {{createdTime}}\",\n      \"linkCopySuccess\": \"Link copied\",\n      \"linkRemove\": \"Remove link\",\n      \"noInviteLinks\": \"No invite links\",\n      \"linkDescription\": \"Create an invitation link and assign permissions\",\n      \"haveAccess\": \"Have access\"\n    },\n    \"base\": {\n      \"title\": \"Share {{baseName}}\",\n      \"desc_one\": \"This base is shared with {{count}} collaborator.\",\n      \"desc_other\": \"This base is shared with {{count}} collaborators.\",\n      \"baseTitle\": \"Base collaborators\",\n      \"collaboratorSearchPlaceholder\": \"Find a base collaborator by name or email\",\n      \"baseTitleWithCount\": \"Base collaborators ({{count}})\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Add organization collaborator\",\n      \"placeholder\": \"Select organization member or department\"\n    },\n    \"sendInvitationSuccess\": \"Invitation has been sent\",\n    \"table\": {\n      \"collaborator\": \"Collaborator\",\n      \"accessPermission\": \"Access permission\",\n      \"joinAt\": \"Joined at\"\n    },\n    \"authority\": {\n      \"title\": \"Authority matrix is enabled\",\n      \"description\": \"Collaborators’ s effective access permission is affected by the roles assigned to them, Unassigned user using the default role\",\n      \"viewDetail\": \"View detail\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Help\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai/en\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Page permissions have been updated. Refresh required.\",\n  \"listEmptyTips\": \"The list is empty\",\n  \"billing\": {\n    \"overLimits\": \"Over limits\",\n    \"overLimitsDescription\": \"Your current subscription has exceeded its usage limit. Please upgrade your plan to continue using this feature without interruption.\",\n    \"userLimitExceededDescription\": \"The current instance have reached the maximum number of users allowed by your license. Please deactivate some users or upgrade the license.\",\n    \"unavailableInPlanTips\": \"The current subscription plan does not support this feature\",\n    \"unavailableConnectionTips\": \"Database connection feature will be removed and only available in Enterprise Plan and self-hosted versions.\",\n    \"levelTips\": \"This space is currently on the {{level}} plan\",\n    \"enterpriseFeature\": \"Enterprise feature\",\n    \"automationRequiresUpgrade\": \"Upgrade to Enterprise Edition (EE) to enable automation\",\n    \"authorityMatrixRequiresUpgrade\": \"Upgrade to Enterprise Edition (EE) to enable authority matrix\",\n    \"viewPricing\": \"View pricing\",\n    \"billable\": \"Billable\",\n    \"billableByAuthorityMatrix\": \"Billing generated by authority matrix\",\n    \"licenseExpiredGracePeriod\": \"Your self-hosted license has expired and will downgrade to the free plan on {{expiredTime}}. Please update your license promptly to retain access to premium features.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Upgrade space's subscription plan\",\n      \"description\": \"You can only upgrade spaces where you are an owner\"\n    },\n    \"contactAdminToUpgrade\": \"Please contact the space owner to upgrade the subscription plan.\",\n    \"status\": {\n      \"active\": \"Active\",\n      \"canceled\": \"Canceled\",\n      \"incomplete\": \"Incomplete\",\n      \"incompleteExpired\": \"Incomplete expired\",\n      \"trialing\": \"Trialing\",\n      \"pastDue\": \"Past due\",\n      \"unpaid\": \"Unpaid\",\n      \"paused\": \"Paused\",\n      \"seatLimitExceeded\": \"Seat limit exceeded\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Instance settings\",\n      \"description\": \"Change the settings for your current instance\",\n      \"allowSignUp\": \"Allow creating new accounts\",\n      \"allowSignUpDescription\": \"Disabling this option will prohibit new user registrations, and the register button will no longer appear on the login page.\",\n      \"allowSpaceInvitation\": \"Allow sending space invitations\",\n      \"allowSpaceInvitationDescription\": \"Disabling this option will prevent users other than administrators from inviting others to join spaces. When enabled, new users invited via email can complete registration by clicking the invitation link in the email, but shared invitation links will not work.\",\n      \"allowSpaceCreation\": \"Allow everyone to create new spaces\",\n      \"allowSpaceCreationDescription\": \"Disabling this option will prevent users other than administrators from creating new spaces.\",\n      \"enableEmailVerification\": \"Enable email verification\",\n      \"enableEmailVerificationDescription\": \"Enabling this option will require users to verify their email address when creating a new account.\",\n      \"enableWaitlist\": \"Enable waitlist\",\n      \"enableWaitlistDescription\": \"Enabling this option will allow users to register only with an invitation code.\",\n      \"generalSettings\": \"General settings\",\n      \"aiSettings\": \"AI settings\",\n      \"brandingSettings\": {\n        \"title\": \"Branding settings\",\n        \"description\": \"Only available in Enterprise Edition\",\n        \"brandName\": \"Brand name\",\n        \"logo\": \"Logo\",\n        \"logoUpload\": \"Upload logo\",\n        \"logoUploadDescription\": \"Upload Logo image, supports PNG, JPEG format, recommended size is 100x100px. Maximum upload size is 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Name\",\n        \"nameDescription\": \"The name of the LLM provider\",\n        \"customModel\": \"Custom model\",\n        \"customModelDescription\": \"Custom models can be used in AI fields, AI automation, and AI chat interface. Image generation models need to be checked for testing\",\n        \"updateLLMProvider\": \"Update LLM provider\",\n        \"addProvider\": \"Add LLM provider\",\n        \"addProviderDescription\": \"Add a new LLM provider to the list\",\n        \"providerType\": \"Provider type\",\n        \"baseUrl\": \"Base URL\",\n        \"apiKey\": \"API key\",\n        \"baseUrlDescription\": \"The base URL of the LLM provider\",\n        \"apiKeyDescription\": \"The API key of the LLM provider\",\n        \"models\": \"Models\",\n        \"modelsDescription\": \"The models supported by the LLM provider\",\n        \"baseUrlRequired\": \"Base URL is required\",\n        \"fetchModelListError\": \"Failed to fetch model list\",\n        \"provider\": \"LLM provider\",\n        \"aiAbilitySettings\": \"AI ability settings\",\n        \"aiAbilitySettingsDescription\": \"Configure the AI abilities\",\n        \"modelPreferences\": \"Model preferences\",\n        \"modelPreferencesDescription\": \"The model preferences for the LLM provider\",\n        \"embeddingModel\": \"Embedding model\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Translation model\",\n        \"translationModelDescription\": \"The translation model to use\",\n        \"chatModel\": \"Chat model\",\n        \"chatModelDescription\": \"The chat model to use, Tips: tool calling is required for teable ai features\",\n        \"chatModels\": {\n          \"lg\": \"Advanced chat model\",\n          \"lgDescription\": \"For planning, coding, and other complex task scenarios. Recommended: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"AI Capabilities\",\n          \"aiField\": {\n            \"title\": \"AI Field\",\n            \"description\": \"AI field capabilities including auto-fill and AI configuration in field settings\"\n          },\n          \"aiChat\": {\n            \"title\": \"AI Chat\",\n            \"description\": \"AI Chat sidebar and all agent capabilities\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Test model\",\n          \"description\": \"Test the ability of the model to handle image, pdf.\",\n          \"notConfigLgModel\": \"Please configure the advanced chat model first\",\n          \"confirmTitle\": \"Test model ability\",\n          \"confirmDescription\": \"Do you want to test the advanced chat model's capabilities?\",\n          \"confirm\": \"Test model\",\n          \"cancel\": \"Cancel\",\n          \"missingCapabilitiesWarning\": \"This model does not support image or PDF processing, which may cause some features to be unavailable.\",\n          \"modelNotSuitable\": \"This model may not be suitable for advanced AI features:\",\n          \"enableAITitle\": \"Enable Custom Model\",\n          \"enableAIDescription\": \"Model test completed. Custom model is not currently enabled. Do you want to enable custom model to use these model capabilities?\",\n          \"enableAI\": \"Enable Custom Model\",\n          \"skipTest\": \"Skip\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Image\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Web search\",\n          \"toolCall\": \"Tool Call\",\n          \"reasoning\": \"Reasoning\",\n          \"imageGeneration\": \"Image Generation\",\n          \"disabledWebSearch\": \"Disabled web search\",\n          \"lgModelAbility\": \"Model Capabilities\",\n          \"missingVision\": \"Does not support image or PDF processing\",\n          \"missingToolCall\": \"Does not support tool/function calling\",\n          \"notTested\": \"Model capabilities not tested yet. Please test the model in provider settings first\",\n          \"supportedFormats\": \"Supported formats\"\n        },\n        \"imageModelAbility\": {\n          \"generation\": \"Text to Image\",\n          \"imageToImage\": \"Image to Image\"\n        },\n        \"configUpdated\": \"AI config updated\",\n        \"noModelFound\": \"No model found.\",\n        \"searchModel\": \"Search model...\",\n        \"selectModel\": \"Select model...\",\n        \"moreModels\": \"More Models\",\n        \"noModelsAvailable\": \"No models available\",\n        \"input\": \"Input {{ratio}}\",\n        \"output\": \"Output {{ratio}}\",\n        \"inputOrOutputTip\": \"The ratio represents the exchange rate between credits and tokens, e.g. \\\"1:1000\\\" means 1 credit ≈ 1000 tokens.<br></br>Note: Each use will deduct at least 1 credit. Even if the actual consumption is less than 1 credit, it will still be charged as 1 credit.\",\n        \"imageOutput\": \"Per image {{credits}}\",\n        \"imageOutputTip\": \"Image generation requires credits, each image consumes {{credits}} credits\",\n        \"supportImageOutputTip\": \"This model supports image generation\",\n        \"supportVisionTip\": \"This model supports image input\",\n        \"supportAudioTip\": \"This model supports audio input\",\n        \"supportVideoTip\": \"This model supports video input\",\n        \"supportDeepThinkTip\": \"This model supports deep thinking\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Testing...\",\n        \"testSuccess\": \"Test successful\",\n        \"testCompleteWithCount\": \"Test complete: {{success}}/{{total}} models passed\",\n        \"allTestsFailed\": \"All model tests failed\",\n        \"testFailed\": \"Test failed\",\n        \"batchTest\": \"Test Model Capabilities\",\n        \"test\": \"Test\",\n        \"testProvider\": \"Test\",\n        \"testProviderTooltip\": \"Test all {{count}} models under this provider\",\n        \"batchTesting\": \"Testing all models...\",\n        \"batchTestComplete\": \"Batch test completed\",\n        \"batchTestResults\": \"Batch Test Results\",\n        \"batchTestResultsSummary\": \"Tested {{tested}} of {{total}} models: {{success}} passed, {{failed}} failed\",\n        \"batchTestNoModels\": \"No models configured to test\",\n        \"modelStatus\": \"Model Status\",\n        \"imageSupport\": \"Image Support\",\n        \"basicGeneration\": \"Basic Generation\",\n        \"supported\": \"Supported\",\n        \"notSupported\": \"Not Supported\",\n        \"partialSupport\": \"Partial Support\",\n        \"urlSupport\": \"URL\",\n        \"base64Support\": \"Base64\",\n        \"closeResults\": \"Close\",\n        \"retryFailed\": \"Retry Failed\",\n        \"stopTest\": \"Stop\",\n        \"pending\": \"Pending\",\n        \"configuredModels\": \"Configured Models\",\n        \"fillRequiredFields\": \"Please fill in all required fields\",\n        \"modelsRequired\": \"Please fill in at least one model\",\n        \"noValidModel\": \"No valid model found\",\n        \"addCustomModel\": \"Add custom model\",\n        \"isOpenRouter\": \"OpenRouter\",\n        \"modelRates\": \"Model Rates\",\n        \"model\": \"Model\",\n        \"inputRate\": \"Input\",\n        \"outputRate\": \"Output\",\n        \"inputRateTip\": \"Credits per 1M input tokens\",\n        \"outputRateTip\": \"Credits per 1M output tokens\",\n        \"rateExplanationTitle\": \"How rates work (1 USD = 100 credits)\",\n        \"rateExplanationFormula\": \"Credits = Tokens × Rate ÷ 1,000,000\",\n        \"rateExplanationExample\": \"Example: 10,000 tokens with rate 300 = 10000 × 300 ÷ 1M = 3 credits\",\n        \"ratesDescription\": \"Rates per 1M tokens (click 'Fetch Pricing' to auto-fill from OpenRouter)\",\n        \"advancedRates\": \"Advanced rates (cache, reasoning, image)\",\n        \"advancedRatesDescription\": \"Leave blank for auto-calculation. Cache read ≈ 10% of input, cache write ≈ 125% of input, reasoning = output rate.\",\n        \"cacheRead\": \"Cache↓\",\n        \"cacheWrite\": \"Cache↑\",\n        \"reasoning\": \"Reason\",\n        \"perImage\": \"Image\",\n        \"cacheReadRateTip\": \"Rate for cached input tokens (usually 10-50% of input rate, or 0 for free)\",\n        \"cacheWriteRateTip\": \"Rate for cache write tokens (usually same as input or 25% more)\",\n        \"reasoningRateTip\": \"Rate for reasoning tokens like o1 (usually same as output rate)\",\n        \"imageRateTip\": \"Credits per generated image\",\n        \"imageModel\": \"Image Model\",\n        \"imageGeneration\": \"Image Generation\",\n        \"imageToImage\": \"Image-to-Image\",\n        \"clickToToggleImageModel\": \"💡 Click to mark as image generation model\",\n        \"markAsImageModel\": \"Mark as image generation model\",\n        \"imageGenerationModel\": \"Image Generation Model\",\n        \"markedAsImageModel\": \"Marked as image generation model. Will test text-to-image and image-to-image capabilities.\",\n        \"markedAsTextModel\": \"Marked as text model. Will test vision (image input) capability.\",\n        \"fetchPricing\": \"Fetch Pricing\",\n        \"fetchPricingTip\": \"Fetch model pricing from OpenRouter API and auto-fill rates\",\n        \"fetchPricingError\": \"Failed to fetch pricing\",\n        \"pricingPreview\": \"Pricing Preview\",\n        \"pricingPreviewDesc\": \"Found pricing for {{matched}} of {{total}} models\",\n        \"openRouterId\": \"OpenRouter ID\",\n        \"notFound\": \"Not found\",\n        \"applyPricing\": \"Apply ({{count}})\",\n        \"pricingApplied\": \"Pricing applied\",\n        \"pricingAppliedCount\": \"Updated pricing for {{count}} models\",\n        \"hint\": {\n          \"title\": \"Suggestions\",\n          \"missingV1Suffix\": \"The Base URL might be missing the \\\"/v1\\\" suffix. Most OpenAI-compatible APIs require URLs ending with \\\"/v1\\\" (e.g., https://api.openai.com/v1)\",\n          \"removeTrailingSlash\": \"Try removing the trailing slash from the Base URL\",\n          \"checkApiKey\": \"Please verify your API key is correct and has not expired\",\n          \"azureDeployment\": \"For Azure, ensure you've configured the correct resource name and deployment in the Base URL\",\n          \"checkQuotaOrPermission\": \"Your API key may have insufficient permissions or the quota has been exhausted. Please check your account status\",\n          \"checkModelName\": \"The model name might be incorrect. Please check the model name matches what your provider supports\",\n          \"checkConnection\": \"Unable to connect to the server. Please check if the Base URL is correct and the server is accessible\",\n          \"ollamaRunning\": \"Ensure Ollama is running locally. You can start it with the 'ollama serve' command\",\n          \"sslCertificate\": \"There's an SSL certificate issue. If using a self-signed certificate, you may need additional configuration\",\n          \"checkConfiguration\": \"Please check your configuration. Ensure the Base URL, API key, and model name are all correct\"\n        },\n        \"recommended\": \"Recommended\",\n        \"gatewayModels\": \"AI Gateway Models\",\n        \"gatewayModelsDescription\": \"Configure recommended models via Vercel AI Gateway for optimal AI SDK support\",\n        \"gatewayDescription\": \"Use AI Gateway for best compatibility and reliability. Configure API key in the App section below.\",\n        \"providerDescription\": \"Advanced: Configure custom AI providers for specific use cases or self-hosted models.\",\n        \"noGatewayModels\": \"No gateway models configured. Add models to get started.\",\n        \"addModel\": \"Add Model\",\n        \"addGatewayModel\": \"Add Gateway Model\",\n        \"popularModels\": \"Popular Models\",\n        \"modelId\": \"Model ID\",\n        \"modelIdHint\": \"The model identifier used by AI Gateway (e.g., anthropic/claude-sonnet-4)\",\n        \"searchModelPlaceholder\": \"Type to search models...\",\n        \"noMatchingModels\": \"No matching models found\",\n        \"useCustomId\": \"Use custom ID: {{id}}\",\n        \"typeToSearch\": \"Type keywords to search available models\",\n        \"modelNotFound\": \"This model ID was not found in available list, please verify spelling\",\n        \"testModel\": \"Test Model\",\n        \"testModelSuccess\": \"Model test successful! API responded correctly\",\n        \"testModelImageSuccess\": \"Image model confirmed to exist in API\",\n        \"testModelNotFound\": \"Model not found in API list\",\n        \"displayLabel\": \"Display Label\",\n        \"isImageModel\": \"This is an image generation model\",\n        \"capabilities\": \"Capabilities\",\n        \"setAsDefault\": \"Set as default chat model\",\n        \"quickAdd\": \"Quick add popular models:\",\n        \"guide\": {\n          \"configStatus\": \"Configuration Status\",\n          \"ready\": \"Ready\",\n          \"needsAttention\": \"Needs Attention\",\n          \"incomplete\": \"Incomplete\",\n          \"aiEnabled\": \"AI Enabled\",\n          \"aiEnabledDesc\": \"Custom models are enabled for this instance\",\n          \"aiDisabledDesc\": \"Enable to use custom AI models\",\n          \"gatewayKey\": \"Gateway API Key\",\n          \"gatewayKeyConfigured\": \"AI Gateway key is configured\",\n          \"gatewayKeyMissing\": \"Configure AI Gateway API key for recommended setup\",\n          \"gatewayKeyRequired\": \"Please configure AI Gateway API key first in the App section below\",\n          \"gatewayModels\": \"Gateway Models\",\n          \"gatewayModelsConfigured\": \"{{count}} models enabled\",\n          \"gatewayModelsEmpty\": \"Add gateway models for user selection\",\n          \"providers\": \"Custom Providers\",\n          \"providersConfigured\": \"{{count}} providers configured\",\n          \"providersEmpty\": \"No custom providers configured\",\n          \"chatModel\": \"Default Chat Model\",\n          \"chatModelGateway\": \"Using gateway model\",\n          \"chatModelProvider\": \"Using custom provider model\",\n          \"chatModelMissing\": \"Select a default chat model\"\n        },\n        \"enableCard\": {\n          \"title\": \"AI Services\",\n          \"ready\": \"Configured and ready to use\",\n          \"needsConfig\": \"Enabled, but needs configuration below\",\n          \"disabled\": \"AI features are currently disabled\",\n          \"missingConfig\": \"Missing configuration:\",\n          \"allConfigured\": \"All configurations complete\"\n        },\n        \"wizard\": {\n          \"setupProgress\": \"Setup Progress\",\n          \"checklist\": \"Checklist\",\n          \"allComplete\": \"All required settings complete! AI is ready to use.\",\n          \"nextStep\": \"Next: {{step}}\",\n          \"configureAI\": \"Complete the following steps to enable AI\",\n          \"optional\": \"Optional\",\n          \"gatewayHelp\": \"AI Gateway: One API key for hundreds of models; first-time use requires adding a credit card in Vercel;\",\n          \"gatewayByok\": \"supported (bring your own provider keys)\",\n          \"getApiKey\": \"Get API Key\",\n          \"keyInvalid\": \"Invalid API key, please check and try again\",\n          \"gatewayErrorUnauthorized\": \"Invalid API key. Please check if it was copied correctly\",\n          \"gatewayErrorNeedCreditCard\": \"Credit card required to use AI Gateway. Please add one in the Vercel dashboard\",\n          \"gatewayErrorInsufficientQuota\": \"Insufficient account balance. Please top up and try again\",\n          \"gatewayErrorForbidden\": \"Access forbidden. Please check your account status in the Vercel dashboard\",\n          \"gatewayErrorNetwork\": \"Network error. Please check your connection or proxy settings\",\n          \"pleaseTest\": \"Please click Test to verify the API key\",\n          \"test\": \"Test\",\n          \"testing\": \"Testing...\",\n          \"attachmentTest\": {\n            \"title\": \"Attachment Transfer Mode\",\n            \"urlMode\": \"URL Mode\",\n            \"base64Mode\": \"Base64 Mode\",\n            \"accessible\": \"Available\",\n            \"inaccessible\": \"Unavailable\",\n            \"urlNotAccessibleWarning\": \"AI service cannot access attachment URLs. This is common for intranet deployments. Base64 mode has been auto-enabled.\",\n            \"useBase64Mode\": \"Use Base64 Mode\",\n            \"base64ModeDescription\": \"Convert attachments to Base64 before sending (Teable cannot be accessed from the public network)\",\n            \"originChanged\": \"PUBLIC_ORIGIN has changed\",\n            \"originChangedDesc\": \"The server address has changed since the last test. Please re-test to verify attachment accessibility.\"\n          },\n          \"saveAndContinue\": \"Save & Continue\",\n          \"completeStep1First\": \"Please complete Step 1: Configure LLM API first\",\n          \"completeStep2First\": \"Please complete Step 2: Add at least one model first\",\n          \"addCustom\": \"Custom...\",\n          \"enabledModels\": \"Enabled Models\",\n          \"chatDefault\": \"Chat Default\",\n          \"noModelsAvailable\": \"No models available, please add models in Step 2 first\",\n          \"quickSetup\": \"Quick Setup\",\n          \"useRecommended\": \"Use Recommended\",\n          \"useRecommendedDesc\": \"Set {{model}} as default for all chat scenarios with one click\",\n          \"chatModels\": \"Chat Model\",\n          \"chatModelTip\": \"This model is used for sidebar AI chat, specified by admin and cannot be changed by users\",\n          \"selectChatModel\": \"Select chat model...\",\n          \"lgDesc\": \"Complex tasks, deep analysis\",\n          \"mdDesc\": \"Daily conversations, general tasks\",\n          \"smDesc\": \"Simple queries, quick responses\",\n          \"readyToUse\": \"AI is configured and ready to use!\",\n          \"customProviderHelp\": \"Add your own AI providers (OpenAI, Anthropic, Azure, etc.), configure API keys and models.\",\n          \"testModelCapabilities\": \"Test Model Capabilities\",\n          \"customModelsAutoImported\": \"Your provider models have been automatically imported to the model pool.\",\n          \"modelsCount\": \"{{count}} models available\",\n          \"customModelsHint\": \"To adjust model configurations, go back to Step 1 to modify provider settings.\",\n          \"gatewayOption\": {\n            \"title\": \"AI Gateway (Recommended)\",\n            \"desc\": \"Access hundreds of models via Vercel AI Gateway with best compatibility and reliability\"\n          },\n          \"customOption\": {\n            \"title\": \"Custom Provider\",\n            \"desc\": \"Connect to self-hosted/private models, supports multiple providers\"\n          },\n          \"step\": {\n            \"llmApi\": \"Configure LLM API\",\n            \"llmApiDesc\": \"Choose AI Gateway or configure custom providers\",\n            \"modelPool\": \"Configure Recommended Models\",\n            \"modelPoolDesc\": \"Recommended models for users to select in AI fields & automation\",\n            \"chatModel\": \"Set Chat Model\",\n            \"chatModelDesc\": \"Model for sidebar AI chat (select from recommended models)\",\n            \"providers\": \"Configure Providers\",\n            \"providersDesc\": \"Add and manage AI providers\"\n          }\n        }\n      },\n      \"app\": {\n        \"domain\": \"Domain\",\n        \"v0ApiKey\": \"v0 API key\",\n        \"customDomain\": \"Custom Domain (Optional)\",\n        \"customDomainDescription\": \"Set your custom domain for app deployment, access <a>Vercel domain</a> to get custom domain\",\n        \"vercelToken\": \"Vercel API Token\",\n        \"vercelTokenDescription\": \"Access <a>Vercel settings</a> to get API token\",\n        \"apiProxy\": \"API Proxy (Optional)\",\n        \"apiProxyDescription\": \"Configure reverse proxy URLs for v0 and Vercel APIs (e.g., Cloudflare Workers). Leave empty to use default endpoints.\",\n        \"v0BaseUrl\": \"v0 API Base URL\",\n        \"vercelBaseUrl\": \"Vercel API Base URL\",\n        \"aiGateway\": \"Vercel AI Gateway\",\n        \"aiGatewayDescription\": \"Configure Vercel AI Gateway to access hundreds of models through a single endpoint. Access <a>AI Gateway settings</a> to get API key\",\n        \"aiGatewayApiKey\": \"AI Gateway API Key\",\n        \"aiGatewayKeyConfigured\": \"API key configured\",\n        \"aiGatewayBaseUrl\": \"Custom Gateway URL\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Enter API key\",\n      \"goToConfiguration\": \"Go to configuration\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Thank you for using teable\",\n      \"pleaseGoToConfiguration\": \"Please go to the settings page to complete some initial configurations to enjoy the full functionality and a better user experience of teable\",\n      \"pleaseContactAdmin\": \"Please contact the administrator\"\n    },\n    \"configuration\": {\n      \"title\": \"Pending configuration\",\n      \"description\": \"Complete those configuration to get full features\",\n      \"progressTitle\": \"Setup progress\",\n      \"allComplete\": \"All configurations completed\",\n      \"incomplete\": \"Some configurations are incomplete\",\n      \"optional\": \"Optional\",\n      \"completed\": \"Completed\",\n      \"group\": {\n        \"system\": \"Basics\",\n        \"ai\": \"AI Services\",\n        \"appBuilder\": \"App Builder\"\n      },\n      \"copyInstance\": \"Copy ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"PUBLIC_ORIGIN environment variable\",\n          \"description\": \"Your <strong>{{envPublicOrigin}}</strong> environment variable configuration does not match the current access address <underline>{{currentPublicOrigin}}</underline>, import xlsx, csv, and attachment field functions may not work normally, it is recommended to set it to <underline>{{envPublicOrigin}}</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Enable https\",\n          \"description\": \"You have not enabled HTTPS, the large-scale copy (300 lines or more) feature will not be available, and it is recommended to enable it.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"PUBLIC_DATABASE_PROXY environment variable\",\n          \"description\": \"The <strong>PUBLIC_DATABASE_PROXY</strong> is not configured, the external database connection feature will not be available, please refer to the <a>help document</a>\",\n          \"href\": \"https://help.teable.ai/en/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"You have not configured the AI LLM API, AI Chat/ AI automation will not be usable, <anchor>go to setting</anchor>\",\n          \"errorTips\": \"You have not configured the AI LLM API, AI Chat/ AI automation will not be usable\"\n        },\n        \"aiEnable\": {\n          \"title\": \"AI Services (Chat / AI Field)\",\n          \"description\": \"Enable to use AI Chat, AI Fields, and automations, <anchor>go to setting</anchor>\"\n        },\n        \"aiLlmApi\": {\n          \"title\": \"AI: Configure LLM API\",\n          \"description\": \"Choose AI Gateway or configure custom providers, <anchor>go to setting</anchor>\"\n        },\n        \"aiModelPool\": {\n          \"title\": \"AI: Configure available models\",\n          \"description\": \"Enable at least one model for AI Fields and automations, <anchor>go to setting</anchor>\"\n        },\n        \"aiChatModel\": {\n          \"title\": \"AI: Set chat model\",\n          \"description\": \"Select a default model for the sidebar AI Chat, <anchor>go to setting</anchor>\"\n        },\n        \"app\": {\n          \"title\": \"App builder\",\n          \"description\": \"You have not configured the v0 API key, the App builder feature will not be available, <anchor>go to setting</anchor>\",\n          \"errorTips\": \"You have not configured the v0 API key, the App builder feature will not be available\"\n        },\n        \"appBuilderV0\": {\n          \"title\": \"App Builder: Enable v0\",\n          \"description\": \"Configure the v0 API key to enable App Builder, <anchor>go to setting</anchor>\"\n        },\n        \"appBuilderDomain\": {\n          \"title\": \"App Builder: Custom domain\",\n          \"description\": \"If you want to publish apps under your custom domain, configure domain + Vercel token, <anchor>go to setting</anchor>\"\n        },\n        \"appBuilderApiProxy\": {\n          \"title\": \"App Builder: API proxy\",\n          \"description\": \"If you need a proxy for v0/Vercel APIs, configure the proxy URLs, <anchor>go to setting</anchor>\"\n        },\n        \"email\": {\n          \"title\": \"Email\",\n          \"description\": \"No email configured, self-service password recovery, and email notification function will not be available, <anchor>go to setting</anchor>\",\n          \"errorTips\": \"No email configured, self-service password recovery, and email validation / notification function will not be available\"\n        }\n      }\n    },\n    \"canary\": {\n      \"title\": \"Canary Release\",\n      \"enable\": \"Enable canary\",\n      \"enableDescription\": \"When enabled, selected spaces will have canary features activated\",\n      \"spaces\": \"Canary spaces\",\n      \"spacesDescription\": \"{{count}} spaces configured\",\n      \"configure\": \"Configure\",\n      \"spaceIds\": \"Space IDs\",\n      \"spaceIdsDescription\": \"Enter space IDs, one per line or separated by commas/spaces\",\n      \"spaceIdsPlaceholder\": \"spcXXXXXXXXXXX\\nspcYYYYYYYYYYY\",\n      \"preview\": \"Preview ({{count}} IDs)\",\n      \"noSpaceIds\": \"No space IDs entered\"\n    }\n  },\n  \"notification\": {\n    \"title\": \"Notifications\",\n    \"unread\": \"Unread\",\n    \"read\": \"Read\",\n    \"markAs\": \"Mark this notification as {{status}}\",\n    \"markAllAsRead\": \"Mark all as read\",\n    \"noUnread\": \"No {{status}} notifications\",\n    \"changeSetting\": \"Change page notification settings\",\n    \"new\": \"new {{count}}\",\n    \"showMore\": \"Show more\",\n    \"exportBase\": {\n      \"successText\": \"Export data is ready\",\n      \"failedText\": \"Export failed, please retry\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Owner\",\n      \"creator\": \"Creator\",\n      \"editor\": \"Editor\",\n      \"commenter\": \"Commenter\",\n      \"viewer\": \"Viewer\"\n    },\n    \"description\": {\n      \"owner\": \"Can fully configure and edit bases, automation, authority matrices and manage space settings and billing\",\n      \"creator\": \"Can fully configure and edit bases, automation and enable the authority matrix\",\n      \"editor\": \"Can edit records and views, but cannot configure tables or fields\",\n      \"commenter\": \"Can comment on records\",\n      \"viewer\": \"Cannot edit or comment\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Space trash\",\n    \"type\": \"Type\",\n    \"resetTrash\": \"Empty trash\",\n    \"deletedBy\": \"Deleted by\",\n    \"deletedTime\": \"Deleted time\",\n    \"fromSpace\": \"From \\\"{{name}}\\\" space\",\n    \"permanentDeleteTips\": \"Are you sure you want to permanently delete \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"Are you sure you want to empty the trash?\",\n    \"addToTrash\": \"Move to Trash\",\n    \"description\": \"Data in the trash still occupies record usage and attachment usage.\",\n    \"spaceDescription\": \"Restore spaces deleted within the past {{retentionDays}} days\",\n    \"spaceInnerDescription\": \"Restore bases deleted from this space within the past {{retentionDays}} days\",\n    \"baseDescription\": \"Restore resources deleted from this base within the past {{retentionDays}} days\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Plugin Not Setting url\",\n    \"install\": \"Install\",\n    \"publisher\": \"Publisher\",\n    \"lastUpdated\": \"Last Updated\",\n    \"pluginNotFound\": \"Plugin Not Found\",\n    \"pluginEmpty\": {\n      \"title\": \"No Plugins Yet\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Do you want to turn on the current automation?\"\n  },\n  \"email\": {\n    \"title\": \"Email\",\n    \"send\": \"Send\",\n    \"config\": \"Email config\",\n    \"customConfig\": \"Custom email server\",\n    \"notify\": \"Notify email\",\n    \"automation\": \"Automation email\",\n    \"customNotifyConfig\": \"Custom notify email config\",\n    \"customAutomationConfig\": \"Custom automation email config\",\n    \"addConfig\": \"Add config\",\n    \"editConfig\": \"Edit config\",\n    \"resetConfig\": \"Reset\",\n    \"testEmail\": \"Test email\",\n    \"testEmailPlaceholder\": \"Please enter the test email\",\n    \"testEmailError\": \"Please enter a valid test email address\",\n    \"testEmailSend\": \"Test email sent successfully, please check your email\",\n    \"configError\": \"Please enter a valid email config\",\n    \"host\": \"Server address\",\n    \"hostDescription\": \"Please enter the SMTP mail server address, for example: smtp.example.com\",\n    \"port\": \"Port\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Authentication\",\n    \"username\": \"Username\",\n    \"password\": \"Password\",\n    \"sender\": \"Sender\",\n    \"senderName\": \"Sender name\",\n    \"subscribe\": \"Subscribe\",\n    \"unsubscribe\": \"Unsubscribe\",\n    \"unsubscribeList\": \"Unsubscribe list\",\n    \"unsubscribeTime\": \"Unsubscribe time\",\n    \"source\": \"Source\",\n    \"sourceAutomationDeleted\": \"Automation or node deleted\",\n    \"processing\": \"Processing...\",\n    \"unsubscribeH1\": \"Confirm unsubscribe?\",\n    \"unsubscribeH2\": \"You're about to unsubscribe from future Teable promotions and product updates. Are you sure you want to unsubscribe?\",\n    \"subscribeH1\": \"Confirm subscribe?\",\n    \"subscribeH2\": \"You're about to subscribe from future Teable promotions and product updates. Are you sure you want to subscribe?\",\n    \"unsubscribeListTip\": \"The following users in the current base have unsubscribed, you will no longer be able to send them emails. When importing emails, please place the email addresses in the first column, and name the header \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Reset Password - {{brandName}}\",\n        \"title\": \"Reset your password\",\n        \"message\": \"If you did not request this change, please ignore this email. Otherwise, click the button below to reset your password.\",\n        \"buttonText\": \"Reset password\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Signup Verification - {{brandName}}\",\n          \"title\": \"Signup verification\",\n          \"message\": \"Your verification code is {{code}}, please use it within {{expiresIn}} minutes.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Domain Verification - {{brandName}}\",\n          \"title\": \"Domain verification\",\n          \"message\": \"Your verification code is {{code}}, please use it within {{expiresIn}} minutes.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Change Email Verification - {{brandName}}\",\n          \"title\": \"Change email verification\",\n          \"message\": \"Your verification code is {{code}}, please use it within {{expiresIn}} minutes.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} added you to the {{fieldName}} field of a record in {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> added you to the <strong>{{fieldName}}</strong> field of a record in <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"View record\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} added you to {{refLength}} records in {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> added you to <strong>{{refLength}}</strong> records in <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"View records\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) invited you to their {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Invitation to collaborate\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) invited you to their {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Accept invitation\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Welcome - {{brandName}}\",\n        \"title\": \"Welcome\",\n        \"message\": \"You've successfully joined the waitlist of {{brandName}}, please use the following invite code to register: {{code}}, it can be used {{times}} times.\",\n        \"buttonText\": \"Register\"\n      },\n      \"test\": {\n        \"subject\": \"Test Email - {{brandName}}\",\n        \"title\": \"Test Email\",\n        \"message\": \"This is a test email, please ignore.\"\n      },\n      \"notify\": {\n        \"subject\": \"Notify - {{brandName}}\",\n        \"title\": \"Notify\",\n        \"buttonText\": \"View\",\n        \"import\": {\n          \"title\": \"Import result notification\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ {{tableName}} import aborted: {{errorMessage}} failed row range: [{{range}}]. Please check the data for this range and retry.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ {{tableName}} import failed: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ {{tableName}} import failed: Row limit reached, please upgrade your plan to import more records\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ {{tableName}} import failed: No records were processed\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} imported successfully.\",\n              \"inplace\": \"🎉 {{tableName}} inplace imported successfully.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download Error Report</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download Error Report</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Record comment notification\",\n          \"message\": \"{{fromUserName}} made a comment on {{recordName}} in {{tableName}} in {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Automation notification\",\n          \"failed\": {\n            \"title\": \"Automation {{name}} run failed\",\n            \"message\": \"Your automation {{name}} encountered an execution error. Open run history to view details.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"Automation {{name}} run failed due to insufficient AI credits\",\n            \"message\": \"Your automation {{name}} failed because AI credits are insufficient. Please add credits or upgrade your plan.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"Automation {{name}} run failed due to run quota limit\",\n            \"message\": \"Your automation {{name}} reached the monthly automation run quota. Please upgrade your plan or purchase additional automation runs.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Billing notification\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Space {{spaceName}} AI credits 80% used\",\n              \"message\": \"Your space has used 80% of AI credits. Consider upgrading or purchasing more credits.\"\n            },\n            \"warning90\": {\n              \"title\": \"Space {{spaceName}} AI credits 90% used\",\n              \"message\": \"Your space has used 90% of AI credits. Upgrade soon to avoid interruption.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Space {{spaceName}} automation runs 80% quota used\",\n              \"message\": \"Your space has used {{usedRuns}} of {{totalLimit}} automation runs (80%). Consider upgrading your plan.\"\n            },\n            \"warning90\": {\n              \"title\": \"Space {{spaceName}} automation runs 90% quota used\",\n              \"message\": \"Your space has used {{usedRuns}} of {{totalLimit}} automation runs (90%). Upgrade soon to avoid interruption.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Space {{spaceName}} automation runs exceeded - grace period active\",\n              \"message\": \"Your automation run quota has been exceeded. Automations will stop running and shut down after {{remainingHours}} more hours. Please upgrade your plan.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Export base result notification\",\n          \"success\": {\n            \"message\": \"{{baseName}} Export successfully: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ {{baseName}} exported failed: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"AI task failed in table {{tableName}}\",\n              \"message\": \"AI task failed for field \\\"{{fieldName}}\\\" in table \\\"{{tableName}}\\\": {{errorMsg}}\"\n            },\n            \"cancelled\": {\n              \"title\": \"AI task cancelled\",\n              \"rateLimit\": \"AI task for table \\\"{{tableName}}\\\" was cancelled due to rate limiting (429). Please try again later.\",\n              \"creditExhausted\": \"AI task for table \\\"{{tableName}}\\\" was cancelled because credits have been exhausted. Please upgrade your subscription or wait for credits to refresh.\",\n              \"authFailed\": \"AI task for table \\\"{{tableName}}\\\" was cancelled due to authentication failure. Please check your API key configuration.\",\n              \"serviceUnavailable\": \"AI task for table \\\"{{tableName}}\\\" was cancelled because the AI service is temporarily unavailable. Please try again later.\",\n              \"unknown\": \"AI task for table \\\"{{tableName}}\\\" was cancelled. Error: {{errorMessage}}\"\n            }\n          }\n        },\n        \"rewardRejected\": {\n          \"title\": \"Reward Claim Rejected\",\n          \"message\": \"Your <a href=\\\"{{spaceUrl}}\\\">{{spaceName}}</a> reward claim has been rejected for the following reason(s): {{errorMessages}}, Please review and submit again.\",\n          \"buttonText\": \"View Space\"\n        },\n        \"rewardApproved\": {\n          \"title\": \"Reward Claim Approved\",\n          \"message\": \"Congratulations! Your <a href=\\\"{{spaceUrl}}\\\">{{spaceName}}</a> reward claim has been approved. {{amount}} credits have been added, valid for {{expiredDays}} days.\",\n          \"buttonText\": \"View Space\"\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Waitlist\",\n    \"email\": \"Email\",\n    \"joinTitle\": \"We're scaling fast to meet demand.\",\n    \"joinDesc\": \"Join wait list — we'll ping you the moment we're ready.\",\n    \"emailPlaceholder\": \"Enter your email...\",\n    \"youAreOnTheList\": \"You're on the list!\",\n    \"thanksForJoining\": \"Thanks for joining our waitlist. We'll notify you as soon as we have updates.\",\n    \"back\": \"Back\",\n    \"inviteCodePlaceholder\": \"Enter your invite code\",\n    \"join\": \"Join wait list\",\n    \"joining\": \"Joining...\",\n    \"invite\": \"Invite\",\n    \"inviteTime\": \"Invite time\",\n    \"createdTime\": \"Join time\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\",\n    \"generateCode\": \"Generate invite code\",\n    \"count\": \"Count\",\n    \"times\": \"Times\",\n    \"generate\": \"Generate\",\n    \"code\": \"Invite code\",\n    \"inviteSuccess\": \"Invite successfully\"\n  },\n  \"noPermissionToCreateBase\": \"No permission to create base in any space\",\n  \"base\": {\n    \"deleteTip\": \"Are you sure you want to delete \\\"{{name}}\\\" base?\",\n    \"createResource\": \"Create\",\n    \"noPermissionToCreateResource\": \"You don't have permission to create, please contact owner\"\n  },\n  \"app\": {\n    \"title\": \"App builder\",\n    \"description\": \"Configure the v0 API Key to enable App builder capabilities. Access <a>v0 setting</a> to obtain your API Key\",\n    \"previewAppError\": \"App running error\",\n    \"sendErrorToAI\": \"Send error to AI\"\n  },\n  \"credit\": {\n    \"title\": \"Credit\",\n    \"leftAmount\": \"Credits left\",\n    \"winFreeCredits\": \"Share experience\",\n    \"getCredits\": \"Get 1000 free credits\",\n    \"winCredit\": {\n      \"title\": \"Share a positive review to earn\",\n      \"freeCredits\": \"1000 free credits\",\n      \"guidelinesTitle\": \"Sharing checklist\",\n      \"tagTeableio\": \"Tag <bold>@teableio</bold>\",\n      \"minCharacters\": \"Write  <bold>80+</bold> characters review\",\n      \"minFollowers\": \"Have <bold>10+</bold> followers\",\n      \"limitPerWeek\": \"500 credits per post, up to 2 posts weekly (X + LinkedIn)\",\n      \"postOnX\": \"Post on X\",\n      \"postOnLinkedIn\": \"Post on LinkedIn\",\n      \"preFilledDraft\": \"🌟 Pre-filled draft ready for you!\",\n      \"claimTitle\": \"Paste post URL to claim your credits\",\n      \"userEmail\": \"User email\",\n      \"postUrlLabel\": \"Post URL (X or LinkedIn)\",\n      \"postUrlPlaceholder\": \"Paste the link to your post here\",\n      \"invalidUrl\": \"Please enter a valid X or LinkedIn post URL\",\n      \"claiming\": \"Claiming...\",\n      \"claimCredits\": \"Claim credits\",\n      \"congratulations\": \"Congratulations!\",\n      \"claimSuccess\": \"You have claimed 500 credits!\",\n      \"verifying\": \"Verifying your post...\",\n      \"verifyingDescription\": \"We're checking your post content, this usually takes a few seconds\",\n      \"verifyFailed\": \"Verification failed\",\n      \"tryAgain\": \"Try Again\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Verification failed\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Reward\",\n    \"rewardCredits\": \"Reward credits\",\n    \"minCharCount\": \"Post must have at least {{count}} characters\",\n    \"minFollowerCount\": \"Account must have at least {{count}} followers\",\n    \"mustMention\": \"Post must mention {{mention}}\",\n    \"fetchSnapshotFailed\": \"Failed to fetch post snapshot\",\n    \"alreadyClaimedThisWeek\": \"You have already claimed a reward for this account this week\",\n    \"manage\": {\n      \"title\": \"Reward Management\",\n      \"description\": \"View and manage reward records across all spaces\",\n      \"overview\": \"Overview\",\n      \"records\": \"Records\",\n      \"searchSpace\": \"Search space...\",\n      \"searchRecords\": \"Search postUrl/userId...\",\n      \"dateRange\": \"Select date range\",\n      \"from\": \"From\",\n      \"to\": \"To\",\n      \"totalSpaces\": \"Total: {{count}} spaces\",\n      \"totalRecords\": \"Total: {{count}} records\",\n      \"space\": \"Space\",\n      \"allSpaces\": \"All Spaces\",\n      \"user\": \"User\",\n      \"creator\": \"Creator\",\n      \"sourceType\": \"Source Type\",\n      \"platform\": \"Platform\",\n      \"allStatuses\": \"All Statuses\",\n      \"allSourceTypes\": \"All Source Types\",\n      \"allPlatforms\": \"All Platforms\",\n      \"pendingCount\": \"Pending Count\",\n      \"approvedCount\": \"Approved Count\",\n      \"rejectedCount\": \"Rejected Count\",\n      \"approvedAmount\": \"Approved Amount\",\n      \"consumedAmount\": \"Consumed Amount\",\n      \"availableAmount\": \"Available Amount\",\n      \"expiringSoonAmount\": \"Expiring Soon Amount (7d)\",\n      \"amount\": \"Amount\",\n      \"remainingAmount\": \"Remaining Amount\",\n      \"createdTime\": \"Created Time\",\n      \"rewardTime\": \"Rewarded Time\",\n      \"expiredTime\": \"Expired Time\",\n      \"lastModified\": \"Last Modified\",\n      \"viewDetails\": \"View Details\",\n      \"details\": \"Reward Details\",\n      \"basicInfo\": \"Basic Info\",\n      \"amountInfo\": \"Amount Info\",\n      \"timeInfo\": \"Time Info\",\n      \"socialInfo\": \"Social Info\",\n      \"verifyResult\": \"Verification Result\",\n      \"uniqueKey\": \"Unique Key\",\n      \"verify\": \"Verify\",\n      \"valid\": \"Valid\",\n      \"invalid\": \"Invalid\",\n      \"errors\": \"Errors\",\n      \"copied\": \"{{label}} copied\",\n      \"openPost\": \"Open post\",\n      \"noData\": \"No data\",\n      \"page\": \"Page {{current}} of {{total}}\",\n      \"status\": {\n        \"label\": \"Status\",\n        \"pending\": \"Pending\",\n        \"approved\": \"Approved\",\n        \"rejected\": \"Rejected\"\n      }\n    }\n  },\n  \"chat\": {\n    \"serverError\": \"Server error occurred\",\n    \"serverErrorHint\": \"Please start a new conversation and try again.\"\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Page not found\",\n      \"description\": \"The link you followed may be broken, or the page has been moved.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Back to home\"\n    },\n    \"forbidden\": {\n      \"title\": \"Access restricted\",\n      \"description\": \"You need permission to access this resource.\\nPlease contact your admin.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Unlock premium feature\",\n      \"description\": \"This feature is available on advanced plans.\\nUpgrade to expand your capabilities.\"\n    },\n    \"error\": {\n      \"title\": \"Something went wrong\",\n      \"description\": \"An unexpected issue occurred. Please try again later.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Date parsing failed, value \\\"{{value}}\\\" is out of valid range\",\n      \"planRowLimit\": \"Row limit reached, please upgrade your plan to import more records\",\n      \"notNullValidation\": \"{{fieldHint}}Required field(s) cannot be empty\",\n      \"uniqueValidation\": \"{{fieldHint}}Duplicate value(s) in unique field(s)\",\n      \"requestTimeout\": \"Request timed out\",\n      \"chunkProcessingFailed\": \"Batch processing failed: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"NEW UPDATE\",\n    \"title\": \"Bulk Download Attachments Are Live\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"No dashboards yet\",\n    \"description\": \"Dashboards help you visualize and manage your data more effectively.\",\n    \"create\": \"Create dashboard\"\n  },\n  \"addPlugin\": \"Add a plugin\",\n  \"createDashboard\": {\n    \"button\": \"Create dashboard\",\n    \"title\": \"Create new dashboard\",\n    \"placeholder\": \"Enter dashboard name\"\n  },\n  \"findDashboard\": \"Find a dashboard...\",\n  \"deprecation\": {\n    \"title\": \"Dashboard node feature will be discontinued\",\n    \"description\": \"To bring you a smarter and more efficient experience, we will discontinue support for the dashboard node feature. You can easily create new dashboards through AI in the AI-generated application for a smoother experience.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"API query builder\",\n  \"subTitle\": \"You can quickly build your query requests through a interactive UI and copy the code that can be run directly\",\n  \"apiList\": \"Complete API list\",\n  \"cellFormat\": \"Cell result format\",\n  \"fieldKeyType\": \"Field key type\",\n  \"chooseSource\": \"Choose a data source\",\n  \"action\": {\n    \"selectBase\": \"Select a database...\",\n    \"selectTable\": \"Select a table...\"\n  },\n  \"pickParams\": \"Select and configure the parameters\",\n  \"buildResult\": \"Build result\",\n  \"buildResultEmpty\": \"Please select a table first\",\n  \"previewReturnValue\": \"Return value preview\",\n  \"replaceToken\": \"Replace token\",\n  \"createNewToken\": \"Create new token\",\n  \"showPagination\": \"Pagination parameters are displayed in JSON mode\",\n  \"addSort\": \"Add a sort\",\n  \"tabs\": {\n    \"apiBuilder\": \"API Builder\",\n    \"aiContext\": \"AI Context\"\n  },\n  \"aiContext\": {\n    \"title\": \"AI-Friendly Table Context\",\n    \"description\": \"Copy this context to your AI assistant (ChatGPT, Claude, etc.) to help it understand how to interact with your table data.\",\n    \"selectTableFirst\": \"Please select a table to generate AI context\",\n    \"fullContext\": \"Full Context\",\n    \"compactContext\": \"Compact\",\n    \"copyToClipboard\": \"Copy to Clipboard\",\n    \"copied\": \"Copied!\",\n    \"compactDescription\": \"A shorter version suitable for quick context sharing with token limits in mind.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/oauth.json",
    "content": "{\n  \"add\": \"New OAuth Apps\",\n  \"title\": {\n    \"add\": \"New OAuth Apps\",\n    \"edit\": \"Edit OAuth Apps\",\n    \"description\": \"Teable Apps can act on their own behalf, directly performing operations through the API, without impersonating a user. See our <a>Help documentation</a> for more information.\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"OAuth App name\",\n      \"description\": \"The name of your OAuth App.\"\n    },\n    \"description\": {\n      \"label\": \"Description\",\n      \"description\": \"A short description of your OAuth App.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"Homepage URL\",\n      \"description\": \"The full URL to your OAuth App’s website.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"A square image is recommended.\",\n      \"placeholder\": \"Drag and drop your logo here or click to upload\",\n      \"button\": \"Upload a logo\",\n      \"clear\": \"Clear\",\n      \"lengthError\": \"Only one file is allowed.\",\n      \"typeError\": \"Only image file is allowed.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"Callback URL\",\n      \"description\": \"The full URL to redirect to after a user authorizing your integration.\",\n      \"add\": \"Add Callback URL\"\n    },\n    \"scopes\": {\n      \"label\": \"Scopes\",\n      \"description\": \"The permissions your OAuth App needs.\"\n    },\n    \"secret\": {\n      \"label\": \"Client secrets\",\n      \"add\": \"Generate a new client secret\",\n      \"newDescription\": \"Make sure to copy your new client secret now. You won’t be able to see it again.\",\n      \"empty\": \"You need a client secret to authenticate as the application to the API.\",\n      \"lastUsed\": \"Last used at {{date}}\",\n      \"tag\": \"Client secret\",\n      \"neverUsed\": \"Never used\"\n    },\n    \"clientId\": {\n      \"label\": \"Client ID: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Basic information\",\n    \"scopes\": \"Scopes\",\n    \"identify\": \"Identifying and authorizing users\",\n    \"clientInfo\": \"Client information\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} is requesting access to your account\",\n    \"scopes\": \"This application will be able to get the following scopes:\",\n    \"redirectDescription\": \"Authorizing will redirect to\",\n    \"authorize\": \"Authorize\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/oauth\",\n    \"title\": \"Learn more\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Delete OAuth App\",\n    \"description\": \"Are you sure you want to delete the {{name}} OAuth App? This action cannot be undone.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/plugin.json",
    "content": "{\n  \"add\": \"New plugins\",\n  \"title\": {\n    \"add\": \"New plugins\",\n    \"edit\": \"Edit plugins\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Plugin user\",\n    \"description\": \"Plugin user as a plugin auto generated by the system\"\n  },\n  \"secret\": \"Secret\",\n  \"regenerateSecret\": \"Regenerate secret\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Name\",\n      \"description\": \"Name of the plugin\"\n    },\n    \"description\": {\n      \"label\": \"Description\",\n      \"description\": \"Description of the plugin\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Detail description\",\n      \"description\": \"Detail description of the plugin\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Logo of the plugin, you can upload a picture or use a URL\",\n      \"upload\": \"Upload\",\n      \"clear\": \"Clear\",\n      \"placeholder\": \"Drag and drop your logo here or click to upload\",\n      \"lengthError\": \"Only one file is allowed.\",\n      \"typeError\": \"Only image file is allowed.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"Help URL\",\n      \"description\": \"URL of the help document of the plugin\"\n    },\n    \"positions\": {\n      \"label\": \"Positions\",\n      \"description\": \"Positions of the plugin\"\n    },\n    \"i18n\": {\n      \"label\": \"I18n\",\n      \"description\": \"I18n of the plugin, contains(name, description, detail description)\"\n    },\n    \"url\": {\n      \"label\": \"URL\",\n      \"description\": \"URL of the plugin\"\n    },\n    \"autoCreateMember\": {\n      \"label\": \"Auto create member\",\n      \"description\": \"Auto create member for the plugin\"\n    },\n    \"config\": {\n      \"label\": \"Config\",\n      \"description\": \"Config of the plugin\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Write\",\n    \"preview\": \"Preview\"\n  },\n  \"status\": {\n    \"reviewing\": \"Reviewing\",\n    \"published\": \"Published\",\n    \"developing\": \"Developing\"\n  },\n  \"button\": {\n    \"submitApproved\": \"Submit approved\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Coming soon\",\n    \"empty\": \"Empty\",\n    \"noRecords\": \"No records available\",\n    \"unnamedRecord\": \"Unnamed record\",\n    \"untitled\": \"Untitled\",\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"back\": \"Back\",\n    \"done\": \"Done\",\n    \"create\": \"Create\",\n    \"search\": {\n      \"placeholder\": \"Search...\",\n      \"empty\": \"No results found\"\n    },\n    \"readOnlyTip\": \"This view is locked. You can enable<button>personal mode</button>to edit view options, and the changes will only take effect for yourself.\",\n    \"selectPlaceHolder\": \"Select...\",\n    \"loading\": \"Loading...\",\n    \"loadMore\": \"Load more\",\n    \"uploadFailed\": \"Upload failed\",\n    \"rowCount\": \"{{count}} records\",\n    \"summary\": \"Summary\",\n    \"summaryTip\": \"Hover to select summary\",\n    \"actions\": \"Actions\",\n    \"remove\": \"Remove\",\n    \"runStatus\": {\n      \"success\": \"{{name}} success\",\n      \"failed\": \"{{name}} failed\",\n      \"running\": \"{{name}} running\"\n    },\n    \"resetSuccess\": \"Reset successfully\",\n    \"click\": \"Click\",\n    \"clickedCount\": \"{{label}}: Clicked {{text}} times\",\n    \"atLeastOne\": \"At least reserve a {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Notification\"\n  },\n  \"aiError\": {\n    \"title\": \"AI Generation Failed\",\n    \"retry\": \"Retry\",\n    \"dismiss\": \"Dismiss\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Preview file size limit: {{size}}MB, please download to view instead.\",\n    \"loadFileError\": \"Failed to load file\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Undo\",\n    \"redo\": \"Redo\",\n    \"undoFailed\": \"Undo failed\",\n    \"redoFailed\": \"Redo failed\",\n    \"nothingToUndo\": \"Nothing to undo\",\n    \"nothingToRedo\": \"Nothing to redo\",\n    \"undoSucceed\": \"Undo succeed\",\n    \"redoSucceed\": \"Redo succeed\",\n    \"undoing\": \"Undoing...\",\n    \"redoing\": \"Redoing...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Drop file here to upload\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \" or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Paste or drag and drop to upload here\",\n      \"upload\": \"Upload file\",\n      \"downloadAll\": \"Download all\",\n      \"downloading\": \"Downloading...\",\n      \"downloadSuccess\": \"Download completed\",\n      \"downloadFailed\": \"Download failed\",\n      \"downloadCancelled\": \"Download cancelled\",\n      \"requireHttps\": \"Bulk download requires HTTPS. Please access via HTTPS or localhost\"\n    },\n    \"date\": {\n      \"placeholder\": \"Pick a date\",\n      \"today\": \"Today\",\n      \"rangePlaceholder\": \"Pick a date range\",\n      \"rangeSelected\": \"Selected\",\n      \"invalidTimeRange\": \"End time must be after start time\",\n      \"from\": \"From\",\n      \"to\": \"To\"\n    },\n    \"formula\": {\n      \"title\": \"Formula editor\",\n      \"guideSyntax\": \"Syntax\",\n      \"guideExample\": \"Example\",\n      \"helperExample\": \"Example: \",\n      \"fieldValue\": \"Returns the value to the cells of the {{fieldName}} field.\",\n      \"placeholder\": \"Enter an expression\",\n      \"placeholderForAIPrompt\": \"Describe the formula you want to generate\",\n      \"editExpression\": \"Edit formula\",\n      \"generateExpressionByAI\": \"Generate formula by AI\",\n      \"inputPrompt\": \"Input instruction\",\n      \"generateExpression\": \"Generated formula\",\n      \"generatingByAI\": \"Generating formula by AI...\",\n      \"generatedExpressionTips\": \"After generating, click apply to quickly insert the formula\",\n      \"action\": {\n        \"generating\": \"Generating...\",\n        \"generate\": \"Generate\",\n        \"apply\": \"Apply\"\n      },\n      \"expressionRequired\": \"Expression is required\"\n    },\n    \"link\": {\n      \"placeholder\": \"Select records to link\",\n      \"searchPlaceholder\": \"Search records\",\n      \"allFields\": \"All Fields\",\n      \"globalSearch\": \"Global search\",\n      \"fieldSearch\": \"Search field\",\n      \"maxFieldTips\": \"Exceeded the maximum {{count}} number of fields, extra fields will be ignored\",\n      \"create\": \"Add record\",\n      \"selectRecord\": \"Select record\",\n      \"all\": \"All\",\n      \"selected\": \"Selected\",\n      \"expandRecordError\": \"No permission to view this record.\",\n      \"alreadyOpen\": \"This record is already open.\",\n      \"linkedTo\": \"Linked to\",\n      \"goToForeignTable\": \"Go to foreign table\",\n      \"foreignTableIdRequired\": \"Foreign table is required\",\n      \"linkFieldIdRequired\": \"Link field is required\",\n      \"selectTooManyRecords\": \"The selected records should not exceed {{maxCount}}\",\n      \"relationshipRequired\": \"Relationship is required\",\n      \"rangeSelectFailed\": \"Failed to select records in range\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Find users by name\",\n      \"notify\": \"Notify users once they're selected\"\n    },\n    \"select\": {\n      \"addOption\": \"Add an option '{{option}}'\",\n      \"choicesNameRequired\": \"Choice name is not empty\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Lookup field is required\",\n      \"lookupOptionsNotAllowed\": \"Lookup options are not allowed when isLookup attribute is true or field type is rollup.\",\n      \"lookupOptionsRequired\": \"Lookup options are required\",\n      \"refineOptionsError\": \"Parse lookup options error {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"Expression is required\",\n      \"unsupportedTip\": \"Rollup only support link and rollup field\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Filter must contain at least one condition\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"Conditional lookup requires at least one filter condition\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Model is required\",\n      \"typeNotSupported\": \"Unsupported AI type\",\n      \"sourceFieldIdRequired\": \"Source field is required\",\n      \"targetLanguageRequired\": \"Target language is required\",\n      \"promptRequired\": \"Prompt is required\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Parse field options error {{message}}\",\n      \"optionsRequired\": \"Field options is required\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filter\",\n    \"displayLabel\": \"Filter by \",\n    \"displayLabel_other\": \"Filter by {{fieldName}} and {{count}} other fields\",\n    \"addCondition\": \"Add condition\",\n    \"addConditionGroup\": \"Add condition group\",\n    \"nestedLimitTip\": \"Filter conditions can only be nested {{depth}} levels deep\",\n    \"linkInputPlaceholder\": \"Enter a value\",\n    \"groupDescription\": \"Any of the following are true…\",\n    \"currentUser\": \"Me (current user)\",\n    \"tips\": {\n      \"scope\": \"In this view, show records\"\n    },\n    \"invalidateSelected\": \"Invalid value\",\n    \"invalidateSelectedTips\": \"The selected value has been deleted, please select again\",\n    \"default\": {\n      \"empty\": \"No filter conditions are applied\",\n      \"placeholder\": \"Enter a value\"\n    },\n    \"conjunction\": {\n      \"and\": \"and\",\n      \"or\": \"or\",\n      \"where\": \"where\",\n      \"meetingAll\": \"Meeting all conditions\",\n      \"meetingAny\": \"Meeting any conditions\"\n    },\n    \"operator\": {\n      \"is\": \"is\",\n      \"isNot\": \"is not\",\n      \"contains\": \"contains\",\n      \"doesNotContain\": \"does not contain\",\n      \"isEmpty\": \"is empty\",\n      \"isNotEmpty\": \"is not empty\",\n      \"isGreater\": \"is greater than\",\n      \"isGreaterEqual\": \"is greater equal\",\n      \"isLess\": \"is less than\",\n      \"isLessEqual\": \"is less equal\",\n      \"isAnyOf\": \"is any of\",\n      \"isNoneOf\": \"is not any of\",\n      \"hasAnyOf\": \"has any of\",\n      \"hasAllOf\": \"has all of\",\n      \"hasNoneOf\": \"has none of\",\n      \"isExactly\": \"is exactly\",\n      \"isWithIn\": \"is with in\",\n      \"isBefore\": \"is before\",\n      \"isAfter\": \"is after\",\n      \"isOnOrBefore\": \"is on or before\",\n      \"isOnOrAfter\": \"is on or after\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"today\",\n        \"tomorrow\": \"tomorrow\",\n        \"yesterday\": \"yesterday\",\n        \"oneWeekAgo\": \"one week ago\",\n        \"oneWeekFromNow\": \"one week from now\",\n        \"oneMonthAgo\": \"one month ago\",\n        \"oneMonthFromNow\": \"one month from now\",\n        \"daysAgo\": \"days ago\",\n        \"daysFromNow\": \"days from now\",\n        \"exactDate\": \"exact date\",\n        \"exactFormatDate\": \"exact date (formatted)\",\n        \"dateRange\": \"custom range\",\n        \"currentWeek\": \"current week\",\n        \"currentMonth\": \"current month\",\n        \"currentYear\": \"current year\",\n        \"lastWeek\": \"last week\",\n        \"lastMonth\": \"last month\",\n        \"lastYear\": \"last year\",\n        \"nextWeekPeriod\": \"next week\",\n        \"nextMonthPeriod\": \"next month\",\n        \"nextYearPeriod\": \"next year\",\n        \"pastWeek\": \"past week\",\n        \"pastMonth\": \"past month\",\n        \"pastYear\": \"past year\",\n        \"nextWeek\": \"next week\",\n        \"nextMonth\": \"next month\",\n        \"nextYear\": \"next year\",\n        \"pastNumberOfDays\": \"past number of days\",\n        \"nextNumberOfDays\": \"next number of days\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"Color\"\n  },\n  \"rowHeight\": {\n    \"short\": \"Short\",\n    \"medium\": \"Medium\",\n    \"tall\": \"Tall\",\n    \"extraTall\": \"Extra tall\",\n    \"title\": \"Row height\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"Field name\",\n    \"displayLines\": \"{{count}} lines\"\n  },\n  \"share\": {\n    \"title\": \"Share\"\n  },\n  \"extensions\": {\n    \"title\": \"Extensions\"\n  },\n  \"hidden\": {\n    \"label\": \"Hidden fields\",\n    \"configLabel_one\": \"{{count}} hidden field\",\n    \"configLabel_other\": \"{{count}} hidden fields\",\n    \"configLabel_other_visible\": \"{{count}} visible fields\",\n    \"showAll\": \"Show all\",\n    \"hideAll\": \"Hide all\",\n    \"primaryKey\": \"Primary field: Identifies records\\nCannot be hidden or deleted, visible in linked records.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Copy to clipboard\",\n    \"duplicateRecord\": \"Duplicate record\",\n    \"copyRecordUrl\": \"Copy record URL\",\n    \"deleteRecord\": \"Delete record\",\n    \"addRecordComment\": \"Add comment\",\n    \"viewRecordHistory\": \"View record history\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Hide record history\",\n      \"showRecordHistory\": \"Show record history\",\n      \"createdTime\": \"Created time\",\n      \"createdBy\": \"Created by\",\n      \"before\": \"Before\",\n      \"after\": \"After\",\n      \"viewRecord\": \"View record\"\n    },\n    \"showHiddenFields\": \"Show {{count}} hidden fields\",\n    \"hideHiddenFields\": \"Hide {{count}} hidden fields\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Sort\",\n    \"displayLabel_one\": \"Sort by {{count}} field\",\n    \"displayLabel_other\": \"Sort by {{count}} fields\",\n    \"setTips\": \"Sort by\",\n    \"addButton\": \"Add another sort\",\n    \"autoSort\": \"Automatically sort records\",\n    \"selectASCLabel\": \"first → last\",\n    \"selectDESCLabel\": \"last → first\"\n  },\n  \"group\": {\n    \"label\": \"Group\",\n    \"displayLabel_one\": \"Group by {{count}} fields\",\n    \"displayLabel_other\": \"Group by {{count}} fields\",\n    \"setTips\": \"Group by\",\n    \"addButton\": \"Add subgroup\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Single line text\",\n      \"longText\": \"Long text\",\n      \"singleSelect\": \"Single select\",\n      \"number\": \"Number\",\n      \"multipleSelect\": \"Multiple select\",\n      \"link\": \"Link to record\",\n      \"formula\": \"Formula\",\n      \"date\": \"Date\",\n      \"createdTime\": \"Created time\",\n      \"lastModifiedTime\": \"Last modified time\",\n      \"attachment\": \"Attachment\",\n      \"checkbox\": \"Checkbox\",\n      \"rollup\": \"Rollup\",\n      \"conditionalRollup\": \"Conditional rollup\",\n      \"user\": \"User\",\n      \"rating\": \"Rating\",\n      \"autoNumber\": \"Auto number\",\n      \"lookup\": \"Lookup\",\n      \"conditionalLookup\": \"Conditional lookup\",\n      \"button\": \"Button\",\n      \"createdBy\": \"Created by\",\n      \"lastModifiedBy\": \"Last modified by\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Store short text like names or titles.\",\n      \"longText\": \"Capture longer notes and descriptions.\",\n      \"singleSelect\": \"Choose one option from a list.\",\n      \"number\": \"Track numeric values with formatting.\",\n      \"multipleSelect\": \"Tag records with multiple choices.\",\n      \"link\": \"Link this record to another table.\",\n      \"formula\": \"Calculate values from other fields.\",\n      \"date\": \"Record dates or times.\",\n      \"createdTime\": \"Show when a record was created.\",\n      \"lastModifiedTime\": \"Show the most recent update time.\",\n      \"attachment\": \"Upload files or AI generate images, supports models such as 🍌 Nano banana pro\",\n      \"checkbox\": \"Toggle a simple yes or no.\",\n      \"rollup\": \"Summarize linked records with formulas.\",\n      \"conditionalRollup\": \"Summarize data from conditions.\",\n      \"user\": \"Assign records to workspace members.\",\n      \"rating\": \"Score items with configurable icons.\",\n      \"autoNumber\": \"Give each record a unique sequence.\",\n      \"lookup\": \"Display values from linked records.\",\n      \"conditionalLookup\": \"Show linked values that match filters you define.\",\n      \"button\": \"Run actions with a clickable button.\",\n      \"createdBy\": \"Show who created the record.\",\n      \"lastModifiedBy\": \"Show who last modified the record.\"\n    },\n    \"link\": {\n      \"oneWay\": \"One way\",\n      \"twoWay\": \"Two way\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Operation confirmation\",\n        \"description\": \"Are you sure you want to perform this button operation?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Create space\",\n      \"spaceDelete\": \"Delete space\",\n      \"spaceRead\": \"Read space\",\n      \"spaceUpdate\": \"Update space\",\n      \"spaceInviteEmail\": \"Invite via email in space\",\n      \"spaceInviteLink\": \"Invite via link in space\",\n      \"spaceGrantRole\": \"Grant role in space\",\n      \"baseCreate\": \"Create base\",\n      \"baseDelete\": \"Delete base\",\n      \"baseRead\": \"Read base\",\n      \"baseReadAll\": \"Read all bases\",\n      \"baseUpdate\": \"Update base\",\n      \"baseInviteEmail\": \"Invite via email in base\",\n      \"baseInviteLink\": \"Invite via link in base\",\n      \"baseTableImport\": \"Import data into base\",\n      \"baseAuthorityMatrixConfig\": \"Configure authority matrix\",\n      \"baseDbConnect\": \"Connect to database\",\n      \"tableCreate\": \"Create table\",\n      \"tableRead\": \"Read table\",\n      \"tableDelete\": \"Delete table\",\n      \"tableUpdate\": \"Update table\",\n      \"tableImport\": \"Import data into table\",\n      \"tableExport\": \"Export table data\",\n      \"tableTrashRead\": \"Read table trash\",\n      \"tableTrashUpdate\": \"Update table trash\",\n      \"tableTrashReset\": \"Reset table trash\",\n      \"viewCreate\": \"Create view\",\n      \"viewDelete\": \"Delete view\",\n      \"viewRead\": \"Read view\",\n      \"viewUpdate\": \"Update view\",\n      \"viewShare\": \"Share view\",\n      \"fieldCreate\": \"Create field\",\n      \"fieldDelete\": \"Delete field\",\n      \"fieldRead\": \"Read field\",\n      \"fieldUpdate\": \"Update field\",\n      \"recordCreate\": \"Create record\",\n      \"recordComment\": \"Comment on record\",\n      \"recordDelete\": \"Delete record\",\n      \"recordRead\": \"Read record\",\n      \"recordUpdate\": \"Update record\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Create automation\",\n      \"automationDelete\": \"Delete automation\",\n      \"automationRead\": \"Read automation\",\n      \"automationUpdate\": \"Update automation\",\n      \"appCreate\": \"Create app\",\n      \"appDelete\": \"Delete app\",\n      \"appRead\": \"Read app\",\n      \"appUpdate\": \"Update app\",\n      \"userProfileRead\": \"Read current user profile\",\n      \"userEmailRead\": \"Read current user email\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Read record history\",\n      \"baseQuery\": \"Query base\",\n      \"instanceRead\": \"Read instance\",\n      \"instanceUpdate\": \"Update instance\",\n      \"enterpriseRead\": \"Read enterprise configuration\",\n      \"enterpriseUpdate\": \"Update enterprise configuration\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Table\",\n    \"view\": \"View\",\n    \"space\": \"Space\",\n    \"base\": \"Base\",\n    \"field\": \"Field\",\n    \"record\": \"Record\",\n    \"automation\": \"Automation\",\n    \"app\": \"App\",\n    \"user\": \"User\",\n    \"recordHistory\": \"Record history\",\n    \"you\": \"You\",\n    \"instance\": \"Instance\",\n    \"enterprise\": \"Enterprise\",\n    \"history\": \"History\",\n    \"global\": \"Global\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Sum together the numbers. Equivalent to number1 + number2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Returns the average of the numbers.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Returns the largest of the given numbers.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Returns the smallest of the given numbers.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Rounds the value to the number of decimal places given by \\\"precision\\\" (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by rounding half up toward positive infinity.)\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Rounds the value to the number of decimal places given by \\\"precision\\\" always rounding up, i.e., away from zero. (You must give a value for the precision or the function will not work.)\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Rounds the value to the number of decimal places given by \\\"precision\\\" always rounding down, i.e., toward zero. (You must give a value for the precision or the function will not work.)\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Returns the smallest even integer that is greater than or equal to the specified value.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Rounds positive value up the nearest odd number and negative value down to the nearest odd number.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Returns the integer part of a number, rounding down toward negative infinity.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Returns the absolute value.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Returns the square root of a nonnegative number.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Computes the specified base to the specified power.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Computes Euler number (e) to the specified power.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Computes the logarithm of the value in provided base. The base defaults to 10 if not specified.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Returns the remainder after dividing the first argument by the second.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Converts the text string to a number.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Joins together various value types arguments into a single text value.\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty.\\nSimilar to FIND(), though FIND() returns 0 rather than empty if no occurrence of stringToFind is found.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Extract a substring of count characters starting at whereToStart.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Extract howMany characters from the beginning of the string.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Extract howMany characters from the ending of the string.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Replaces the number of characters beginning with the start character with the replacement text.\\n(If you are looking for a way to find and replace all occurrences of old_text with new_text, see SUBSTITUTE().)\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Replaces all substrings matching regular expression with replacement.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Replaces occurrences of old_text with new_text.\\nYou can optionally specify an index number (starting from 1) to replace just a specific occurrence of old_text. If no index number is specified, then all occurrences of old_text will be replaced.\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Makes a string lowercase.\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Makes a string uppercase.\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Repeats string by the specified number of times.\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Removes whitespace at the beginning and end of string.\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Extract howMany characters from the beginning of the string.\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Returns the argument if it is text and blank otherwise.\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make nested IF statements.\\nCan also be used to check if a cell is blank/is empty.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input does not match any of the defined patterns. In many cases, SWITCH() can be used instead of a nested IF() formula.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Returns true if all the arguments are true, returns false otherwise.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Returns true if any one of the arguments is true.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Returns true if an odd number of arguments are true.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Reverses the logical value of its argument.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Returns a blank value.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Returns the error value.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Returns true if the expression causes an error.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Returns the current date.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Returns the current date and time.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Returns the four-digit year of a datetime.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Returns the month of a datetime as a number between 1 (January) and 12 (December).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Returns the week number in a year.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Returns the day of the week as an integer between 0 and 6, inclusive. You may optionally provide a second argument (either \\\"Sunday\\\" or \\\"Monday\\\") to start weeks on that day. If omitted, weeks start on Sunday by default. Example:\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Returns the day of the month of a datetime in the form of a number between 1-31.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm).\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Returns the minute of a datetime as an integer between 0 and 59.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Returns the second of a datetime as an integer between 0 and 59.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Calculates the number of days between the current date and another date.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Calculates the number of days between the current date and another date.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Returns the difference between datetimes in specified units. Default unit is \\\"day\\\".\\nSupported units: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nThe difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Returns the workday to the start date, excluding the specified holidays\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Returns the number of working days between date1 and date2. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Compares two dates up to a unit and determines whether they are identical. Returns true if yes, false if no.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Determines if date1 is later than date2. Returns true if yes, false if no.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Determines if date1 is earlier than date2. Returns true if yes, false if no.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Adds specified \\\"count\\\" units to a datetime.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Formats a datetime into a string (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Formats a datetime into a time-only string (HH:mm:ss).\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Formats a datetime into a specified string. Supported specifiers: YY, YYYY, M, MM, MMM, MMMM, D, DD, d, dd, ddd, dddd, H, HH, h, hh, m, mm, s, ss, SSS, Z, ZZ, A, a, LT, LTS, L, LL, LLL, LLLL, l, ll, lll, llll.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted \\\"M/D/YYYY h:mm a\\\".\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Returns the creation time of the current record.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Returns the date and time of the most recent modification made by a user in a non-computed field in the table. Optionally pass a field to track updates for that column only.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Returns the number of all elements including text and blanks.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Returns the number of non-empty values. This function counts both numeric and text values.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Returns the number of numeric items.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Join the array of rollup items into a string with a separator.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Returns only unique items in the array.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Flattens the array by removing any array nesting. All items become elements of a single array.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Removes empty strings and null values from the array. Keeps \\\"false\\\" and strings that contain one or more blank characters.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Returns all text values\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Returns the ID of the current record.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Returns the unique and incremented numbers for each record.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Fill in variables, operational characters, and functions to form formulas for calculations.\",\n      \"example\": \"Quoting the Column: {Field name}\\n\\nUsing operator: 100 * 2 + 300\\n\\nUsing function: SUM({Number Field 1}, 100)\\n\\nUsing IF statement: \\nIF(logical condition, \\\"value 1\\\", \\\"value 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Fields\",\n    \"numeric\": \"Numeric\",\n    \"text\": \"Text\",\n    \"logical\": \"Logical\",\n    \"date\": \"Date\",\n    \"array\": \"Array\",\n    \"system\": \"System\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"None\",\n    \"count\": \"Count\",\n    \"empty\": \"Empty\",\n    \"filled\": \"Filled\",\n    \"unique\": \"Unique\",\n    \"max\": \"Max\",\n    \"min\": \"Min\",\n    \"sum\": \"Sum\",\n    \"average\": \"Average\",\n    \"checked\": \"Checked\",\n    \"unChecked\": \"Unchecked\",\n    \"percentEmpty\": \"Percent empty\",\n    \"percentFilled\": \"Percent filled\",\n    \"percentUnique\": \"Percent unique\",\n    \"percentChecked\": \"Percent checked\",\n    \"percentUnChecked\": \"Percent unchecked\",\n    \"earliestDate\": \"Earliest date\",\n    \"latestDate\": \"Latest date\",\n    \"dateRangeOfDays\": \"Date range (days)\",\n    \"dateRangeOfMonths\": \"Date range (months)\",\n    \"totalAttachmentSize\": \"Total attachment size\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Add\",\n    \"error\": {\n      \"invalidCol\": \"Invalid column, please reselect\",\n      \"invalidCols\": \"Invalid columns: {{colNames}}\",\n      \"invalidTable\": \"Invalid table, please reselect\",\n      \"requiredSelect\": \"You must select one\"\n    },\n    \"from\": {\n      \"title\": \"From\",\n      \"fromTable\": \"Select table\",\n      \"fromQuery\": \"From query\"\n    },\n    \"select\": {\n      \"title\": \"Select\"\n    },\n    \"where\": {\n      \"title\": \"Where\"\n    },\n    \"groupBy\": {\n      \"title\": \"Group by\"\n    },\n    \"orderBy\": {\n      \"title\": \"Order by\",\n      \"asc\": \"Ascending\",\n      \"desc\": \"Descending\"\n    },\n    \"limit\": {\n      \"title\": \"Limit\"\n    },\n    \"offset\": {\n      \"title\": \"Offset\"\n    },\n    \"join\": {\n      \"title\": \"Join\",\n      \"joinType\": \"Join type\",\n      \"leftJoin\": \"Left join\",\n      \"rightJoin\": \"Right join\",\n      \"innerJoin\": \"Inner join\",\n      \"fullJoin\": \"Full join\",\n      \"data\": \"From\"\n    },\n    \"aggregation\": {\n      \"title\": \"Aggregation\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Comment\",\n    \"placeholder\": \"Leave a comment...\",\n    \"emptyComment\": \"Start a conversation\",\n    \"deletedComment\": \"Deleted comment\",\n    \"imageSizeLimit\": \"Image size could not be greater than {{size}}\",\n    \"tip\": {\n      \"editing\": \"Editing...\",\n      \"edited\": \"(Edited)\",\n      \"notifyAll\": \"Notify all comments\",\n      \"notifyRelatedToMe\": \"Notify comments related to me\",\n      \"all\": \"All\",\n      \"relatedToMe\": \"Related to me\",\n      \"reactionUserSuffix\": \"reacted with {{emoji}} emoji\",\n      \"me\": \"You\",\n      \"connection\": \"and\"\n    },\n    \"toolbar\": {\n      \"link\": \"Link\",\n      \"image\": \"Image\",\n      \"mention\": \"Mention\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Edit link\",\n      \"caption\": \"Caption\",\n      \"delete\": \"Delete\",\n      \"linkText\": \"Link text\",\n      \"enterUrl\": \"Enter URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Validation error\",\n    \"invalidCaptcha\": \"Invalid captcha\",\n    \"invalidCredentials\": \"Invalid credentials\",\n    \"unauthorized\": \"Unauthorized\",\n    \"unauthorizedShare\": \"Unauthorized share\",\n    \"paymentRequired\": \"Payment required\",\n    \"creditLimitExceeded\": \"Credits have exceeded the limit\",\n    \"restrictedResource\": \"Restricted resource\",\n    \"notFound\": \"Not found\",\n    \"conflict\": \"Conflict\",\n    \"unprocessableEntity\": \"Unprocessable entity\",\n    \"userLimitExceeded\": \"User limit exceeded\",\n    \"tooManyRequests\": \"Too many requests\",\n    \"internalServerError\": \"Internal server error\",\n    \"databaseConnectionUnavailable\": \"Database connection unavailable\",\n    \"gatewayTimeout\": \"Gateway timeout\",\n    \"unknownErrorCode\": \"Unknown error code\",\n    \"networkError\": \"Network connection issue\",\n    \"requestTimeout\": \"Request timeout\",\n    \"failedDependency\": \"Failed dependency\",\n    \"automationNodeParseError\": \"Automation node parse error\",\n    \"automationNodeNeedTest\": \"Automation node need test\",\n    \"automationNodeTestOutdated\": \"Automation node test outdated\",\n    \"invalidToken\": \"Invalid token\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" field \\\"{{fieldName}}\\\" does not allow empty values, please fill in completely before submitting.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" field \\\"{{fieldName}}\\\" does not allow duplicate values, please fill in a unique value before submitting.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" field does not allow duplicate associations with the same record\",\n      \"requestTimeout\": \"This action is too large, please try with a smaller scope.\",\n      \"searchTimeOut\": \"Search timeout, please decrease the search scope and try again\",\n      \"dependencyNodeRequire\": \"Dependency node not tested, please check if all previous nodes have been tested\",\n      \"invalidOperation\": \"Invalid operation detected, please check operation parameters\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"The number of comments requested exceeds the maximum limit of 1000\",\n      \"invalidContentType\": \"Invalid comment content type\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"Token expiration must be less than 7 days\",\n      \"s3RegionRequired\": \"S3 region is required\",\n      \"s3EndpointRequired\": \"S3 endpoint is required\",\n      \"s3AccessKeyRequired\": \"S3 access key is required\",\n      \"s3SecretKeyRequired\": \"S3 secret key is required\",\n      \"s3UploadMethodMustBePut\": \"S3 upload method must be PUT\",\n      \"presignedError\": \"Failed to generate presigned URL\",\n      \"invalidObjectMeta\": \"Invalid object metadata\",\n      \"invalidImageStream\": \"Invalid image stream\",\n      \"calculateImageSizeFailed\": \"Failed to calculate image size\",\n      \"uploadFailed\": \"Upload failed\",\n      \"invalidImage\": \"Invalid image\",\n      \"cantGetImageStream\": \"Cannot get image stream\",\n      \"invalidProvider\": \"Invalid storage provider\",\n      \"failedToDeleteDirectory\": \"Failed to delete directory\",\n      \"invalidToken\": \"Invalid token\",\n      \"tokenExpired\": \"Token has expired\",\n      \"sizeMismatch\": \"File size mismatch\",\n      \"notAllowUploadFileType\": \"File type {{mimetype}} not allowed for upload\",\n      \"notFound\": \"Attachment not found\",\n      \"invalidPath\": \"Invalid attachment path\",\n      \"fileSizeExceedsMaximumLimit\": \"File size exceeds the maximum limit of {{maxSize}}\",\n      \"invalidUploadType\": \"Invalid upload type\",\n      \"urlReject\": \"URL rejected or inaccessible\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Mail config error {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"This button field is already bound to {{name}}[{{id}}] this automation process, please select another button field\",\n      \"triggerNotFound\": \"Automation must have a trigger node\",\n      \"nodeNotFound\": \"{{nodeId}} node not found\",\n      \"triggerTestFailed\": \"Trigger test failed, please check the trigger configuration\",\n      \"testFailed\": \"Automation test failed, please check the automation configuration\",\n      \"runFailed\": \"Automation run failed\",\n      \"nodeParseError\": \"{{name}} node configuration is incomplete or has errors, please check the node configuration\",\n      \"nodeNeedTest\": \"{{name}} node needs to be tested\",\n      \"nodeTestOutdated\": \"{{name}} node test outdated\",\n      \"notFound\": \"Automation not found\",\n      \"currentSnapshotEmpty\": \"Automation current snapshot is empty\",\n      \"runNotFound\": \"Automation run not found\",\n      \"anchorNotFound\": \"Anchor automation not found\",\n      \"validationError\": \"Automation configuration validation error\",\n      \"tableNotInBase\": \"You can only subscribe to a table within your base\",\n      \"alreadyActiveAndNotDraft\": \"Automation already active and not draft\",\n      \"noActiveSnapshot\": \"Automation has no active snapshot\",\n      \"triggerNodeAlreadyExists\": \"This automation already has a trigger node\",\n      \"unSupportLogicType\": \"Unsupported logic type\",\n      \"generateLogicError\": \"Generate logic node error\",\n      \"logicNotFound\": \"Automation logic node not found\",\n      \"groupEndNotFound\": \"GroupEnd not found for logic\",\n      \"insertNodeError\": \"Insert node error\",\n      \"actionNotFound\": \"Automation action node not found\",\n      \"unSupportDuplicateWorkflowNodeType\": \"UnSupport duplicate automation node type\",\n      \"controlNodeNotBeTested\": \"Control node should not be tested\",\n      \"invalidNodeType\": \"Invalid node type\",\n      \"unsupportedCategory\": \"Unsupported category\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Failed to list mailboxes: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Unknown dataset: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Scrape service is not configured\",\n      \"triggerFailed\": \"Scrape trigger failed: {{detail}}\",\n      \"snapshotError\": \"Scrape snapshot error: {{detail}}\",\n      \"timeout\": \"Scrape timed out after {{seconds}}s\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Space not found\",\n      \"noPermission\": \"You have no permission to access this space\",\n      \"disallowSpaceCreation\": \"Space creation has been disabled by the administrator\",\n      \"cannotChangeOnlyOwnerRole\": \"Cannot change the role of the only owner of the space\",\n      \"cannotDeleteOnlyOwner\": \"Cannot delete the only owner of the space\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Can't operate on a space, please verify that there are organization members in the space and they are owners\",\n      \"notBelongToOrg\": \"This space not belong to the organization\",\n      \"invalidSpaceIds\": \"Space ids is invalid: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base not found\",\n      \"cannotAccess\": \"You don't have permission to access base {{baseId}}\",\n      \"anchorNotFound\": \"Anchor base {{anchorId}} not found\",\n      \"baseAndSpaceMismatch\": \"Base {{baseId}} and space {{spaceId}} mismatch\",\n      \"templateNotFound\": \"Template {{templateId}} not found\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"Base ID is required\",\n      \"nodeIdIsRequired\": \"Node ID is required\",\n      \"invalidResourceType\": \"Invalid resource type\",\n      \"notFound\": \"Base node not found\",\n      \"parentMustBeFolder\": \"Parent must be a folder\",\n      \"cannotDuplicateFolder\": \"Cannot duplicate folder\",\n      \"cannotDeleteEmptyFolder\": \"Cannot delete folder because it is not empty\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Only one of parentId or anchorId must be provided\",\n      \"cannotMoveToItself\": \"Cannot move node to itself\",\n      \"cannotMoveToCircularReference\": \"Cannot move node to its own child (circular reference)\",\n      \"anchorIdOrParentIdRequired\": \"At least one of parentId or anchorId must be provided\",\n      \"parentNotFound\": \"Parent node not found\",\n      \"parentIsNotFolder\": \"Parent is not a folder\",\n      \"circularReference\": \"Circular reference detected\",\n      \"folderDepthLimitExceeded\": \"Folder depth limit exceeded\",\n      \"folderNotFound\": \"Folder not found\",\n      \"anchorNotFound\": \"Anchor node not found\",\n      \"nameAlreadyExists\": \"Name already exists\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Dashboard not found\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Plugin not found\",\n      \"notSupportInstallInView\": \"Plugin does not support installation in view\",\n      \"userNotFound\": \"Plugin user not found\",\n      \"invalidSecret\": \"Invalid secret\",\n      \"invalidRefreshToken\": \"Invalid refresh token\",\n      \"anomalousToken\": \"Anomalous token\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Plugin panel not found\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Plugin not installed\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Incorrect password\",\n      \"notAllowedToSubmit\": \"Form submission is not allowed\",\n      \"viewRequired\": \"View is required for this operation\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"Form submission is not allowed when hidden fields are included\",\n      \"submitRecordsError\": \"Record submission failed\",\n      \"notAllowedToCopy\": \"Copy operation is not allowed\",\n      \"fieldHiddenNotAllowed\": \"Field is hidden and cannot be accessed\",\n      \"fieldTypeNotLinkField\": \"Field is not a link field\",\n      \"fieldIdRequired\": \"Field ID is required\",\n      \"fieldNotUserRelatedField\": \"Field is not a user-related field\",\n      \"viewTypeNotAllowed\": \"This view type is not allowed for this operation\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"Password restriction is not enabled for this share\",\n      \"shareViewNotFound\": \"Shared view not found or sharing is disabled\",\n      \"linkFieldNotFound\": \"Link field not found\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Base share not found or sharing is disabled\",\n      \"alreadyExists\": \"A share already exists for this node\",\n      \"copyNotAllowed\": \"This share does not allow copying\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"You do not have permission to access this view\",\n      \"fieldPermissionNotAllowed\": \"You do not have permission to access these fields\",\n      \"recordPermissionNotAllowed\": \"You do not have permission to access these records\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Plugin context menu not found\",\n      \"anchorNotFound\": \"Plugin context menu anchor not found\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Plugin chart query not found\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Unsupported database driver: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Only the base owner can remove database connection for base {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Only the base owner can create database connection for base {{baseId}}\",\n      \"roleNotExist\": \"Database role {{role}} does not exist\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Query failed: {{message}}\",\n      \"invalidJoinType\": \"Invalid join type: {{joinType}}\",\n      \"tableNotFound\": \"Table {{tableId}} not found in base {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Not allowed to execute sql with keyword {{keyword}}\",\n      \"whiteListCheckError\": \"An error occurred while checking table access: {{message}}\",\n      \"databaseConnectionFailed\": \"Database connection failed: {{message}}\",\n      \"executeQuerySqlFailed\": \"Execute query sql failed: {{message}}\",\n      \"readOnlyCheckFailed\": \"Read only check failed: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"You don't have permission to create record with fields({{fields}})\",\n      \"deleteRecords\": \"You don't have permission to delete records({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"You don't have permission to read fields({{fields}}) in record({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"You don't have permission to update fields({{fields}}) in record({{recordId}})\",\n      \"checkIdNotExist\": \"Permission check ID does not exist\",\n      \"userNotAdmin\": \"User is not an admin\",\n      \"accessTokenNoPermission\": \"Access token does not have the required permission\",\n      \"invalidResource\": \"Resource is not valid\",\n      \"notAllowedSpace\": \"You don't have permission to access this space\",\n      \"notAllowedBase\": \"You don't have permission to access this base\",\n      \"notAllowedTables\": \"You don't have permission to access tables({{tableIds}})\",\n      \"notAllowedOperationTable\": \"You don't have permission to operate on this table\",\n      \"notAllowedOperationRecord\": \"You don't have permission to operate on this record\",\n      \"notAllowedRecordUpdate\": \"You don't have permission to update this record\",\n      \"notAllowedOperationView\": \"You don't have permission to operate on this view\",\n      \"deniedByEnabledAuthorityMatrix\": \"Permission denied by enabled authority matrix\",\n      \"invalidRequestPath\": \"Request path is not valid\",\n      \"notAllowedOperation\": \"You don't have permission to perform this operation\",\n      \"notAllowedDepartment\": \"You are not allowed to access this department\",\n      \"templateHeaderInvalid\": \"Template header is invalid\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Default role not found\",\n      \"alreadyDisabled\": \"Authority matrix is already disabled\",\n      \"alreadyEnabled\": \"Authority matrix is already enabled\",\n      \"notFound\": \"Authority matrix not found\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Primary field cannot be disabled for read access\",\n      \"fieldDuplicated\": \"Field is duplicated in permission configuration\",\n      \"cannotSetRecordPermissionGroup\": \"Cannot set this combination of record permissions({{actions}})\",\n      \"notFoundBaseAndTable\": \"Base ID and Table ID not found\",\n      \"roleTablesShouldNotBeEmpty\": \"Authority matrix role tables should not be empty\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Invalid return type\",\n      \"exceedMaxReadRows\": \"Exceed max read rows limit\",\n      \"invalidCellValueType\": \"Invalid cell value type\",\n      \"exceedMaxCopyCells\": \"Exceed max copy cells limit\",\n      \"exceedMaxPasteCells\": \"Exceed max paste cells limit\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Unsupported field type {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Unsupported field type {{type}} as primary field\",\n      \"primaryFieldNotSupported\": \"Field type is not supported as primary field\",\n      \"calculateRecordNotFound\": \"Record not found for: {{value}}, fieldId: {{fieldId}}, when calculate {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds or fromRecordIds is required for normal computed field\",\n      \"recordFieldsRequired\": \"Record fields is undefined\",\n      \"uniqueUnsupportedType\": \"Field {{name}}[{{fieldId}}] does not support field value unique validation\",\n      \"notNullValidationWhenCreateField\": \"Field {{name}}[{{fieldId}}] does not support not null validation when creating a new field\",\n      \"dbFieldNameAlreadyExists\": \"Database field name {{dbFieldName}} already exists\",\n      \"fieldValidationError\": \"Field {{name}}[{{fieldId}}] field validation error\",\n      \"fieldNameAlreadyExists\": \"Field name {{name}} already exists\",\n      \"notFound\": \"field not found\",\n      \"fieldKeyTypeNotFound\": \"Field \\\"{{fieldKeyType}}: {{missedFields}}\\\" not found\",\n      \"notFoundInTable\": \"field {{fieldId}} not found in table {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Delete fields {{fieldIds}} not found in table {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues should be array when link field is multiple cell value\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues should be array when link field is multiple cell value\",\n      \"lookupAndLinkLengthMatch\": \"lookupValues length should be same as linkCellValues length\",\n      \"cycleDetected\": \"Cycle detected\",\n      \"cycleDetectedCreateField\": \"Cycle detected, cannot create field {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Record not found in table {{tableName}} for field {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Forbid delete primary field\",\n      \"foreignTableIdInvalid\": \"Foreign table {{foreignTableId}} invalid\",\n      \"relationshipInvalid\": \"Relationship {{relationship}} invalid\",\n      \"linkFieldIdInvalid\": \"Link field {{linkFieldId}} invalid\",\n      \"lookupFieldIdInvalid\": \"Lookup field {{lookupFieldId}} invalid\",\n      \"formulaExpressionParseError\": \"Formula expression parse error\",\n      \"formulaReferenceNotFound\": \"Formula reference field {{fieldIds}} not found\",\n      \"formulaReferenceNotFieldId\": \"Formula references {{fieldIds}} not found. Formulas must use field IDs (fldXXXXXXXXXXXXXXXX format), not field names.\",\n      \"rollupExpressionParseError\": \"Rollup expression parse error\",\n      \"choiceNameAlreadyExists\": \"Choice name {{name}} already exists\",\n      \"symmetricFieldIdRequired\": \"Symmetric field ID is required\",\n      \"foreignKeyNameCannotUseId\": \"Foreign key name cannot use __id\",\n      \"createForeignKeyError\": \"Create foreignKey error\",\n      \"lookupFieldTypeNotEqual\": \"Current field type {{fieldType}} is not equal to lookup field {{lookupFieldType}}\",\n      \"recordNotFound\": \"Record {{recordId}} not found in {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Cannot set duplicate recordId: {{recordId}} in the same cell\",\n      \"linkConsistencyError\": \"Consistency error, record ID {{recordId}} does not exist\",\n      \"oneOneLinkCellValueCannotBeArray\": \"One-to-one link field values cannot be an array\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Many-to-one link field values cannot be an array\",\n      \"oneManyLinkCellValueShouldBeArray\": \"One-to-many link field values should be an array\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Many-to-many link field values should be an array\",\n      \"foreignKeyDuplicate\": \"Foreign key duplicate\",\n      \"onlyLinkFieldCanBeFiltered\": \"Only link fields can be used for filtering\",\n      \"notLinkedToCurrentTable\": \"Field is not linked to current table\",\n      \"notAttachment\": \"Field is not an attachment field\",\n      \"isComputed\": \"Field is computed and cannot be modified\",\n      \"notFoundAICofig\": \"Field does not have AI configuration\",\n      \"foreignTableIdRequired\": \"Foreign table is required\",\n      \"lookupFieldIdRequired\": \"Lookup field is required\",\n      \"lookupFieldNotExist\": \"Lookup field {{lookupFieldId}} does not exist\",\n      \"lookupFieldNotBelongToTable\": \"Lookup field {{lookupFieldId}} does not belong to table {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Current field type {{fieldType}} does not match lookup field type {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Conditional rollup field options are required\",\n      \"conditionalRollupParseError\": \"Conditional rollup parse error: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Conditional lookup field options are required\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Button click count has reached the maximum limit\",\n        \"notSupportReset\": \"Button field does not support reset\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"View not found\",\n      \"cannotDeleteLastView\": \"Cannot delete the last view in a table. A table must have at least one view.\",\n      \"defaultViewNotFound\": \"Default view not found\",\n      \"propertyParseError\": \"Failed to parse view property\",\n      \"primaryFieldCannotBeHidden\": \"Primary field cannot be hidden\",\n      \"filterUnsupportedFieldType\": \"Filter unsupported field type\",\n      \"filterInvalidOperator\": \"Filter has invalid operator for this field type\",\n      \"filterInvalidOperatorMode\": \"Filter has invalid operator and mode combination\",\n      \"sortUnsupportedFieldType\": \"Sort unsupported field type\",\n      \"groupUnsupportedFieldType\": \"Group unsupported field type\",\n      \"anchorNotFound\": \"Anchor view not found\",\n      \"notEnoughGapToShuffleRow\": \"Not enough gap to shuffle the row\",\n      \"shareNotEnabled\": \"View sharing is not enabled\",\n      \"shareAlreadyEnabled\": \"View sharing is already enabled\",\n      \"shareAlreadyDisabled\": \"View sharing is already disabled\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Insufficient credit\",\n      \"exceedMaxRowLimit\": \"Maximum row limit {{maxRowCount}} exceeded\",\n      \"exceedMaxAutomationRunLimit\": \"Maximum automation run limit exceeded\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Search query is required\",\n      \"maxSearchIndexResult\": \"The maximum search index result is 1000\",\n      \"queryCollectionMustBeTableId\": \"Query collection must be table id\",\n      \"searchTimeOut\": \"Search timeout, please decrease the search scope and try again\",\n      \"indexNotFound\": \"Index not found\",\n      \"invalidStartDateFieldId\": \"Invalid start date field id\",\n      \"invalidEndDateFieldId\": \"Invalid end date field id\",\n      \"fieldMapRequired\": \"Field map is required when search is set\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected and filterLinkCellCandidate cannot be set at the same time\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"AI chat model lg is not set\",\n      \"chatModelLgProviderNotSet\": \"AI chat model lg provider is not set\",\n      \"chatModelSmNotSet\": \"AI chat model sm is not set\",\n      \"chatModelMdNotSet\": \"AI chat model md is not set\",\n      \"configurationNotSet\": \"AI configuration is not set\",\n      \"unsupportedProvider\": \"Unsupported AI provider {{type}}\",\n      \"providerConfigurationNotSet\": \"AI provider configuration is not set\",\n      \"gatewayApiKeyNotSet\": \"AI Gateway API key is not configured\",\n      \"testLLMFailed\": \"LLM connection test failed\",\n      \"audioNotSupported\": \"Audio input not supported by this model {{model}}\",\n      \"imageNotSupported\": \"Image input not supported by this model {{model}}\",\n      \"modelNotSet\": \"AI model is not set\",\n      \"unsupportedFileType\": \"Unsupported file type {{mimetype}}\",\n      \"unsupportedModelType\": \"Unsupported model type\",\n      \"embeddingModelNotSet\": \"Embedding model is not set\",\n      \"validateActionFailed\": \"Validate field AI action failed\",\n      \"generateFailed\": \"AI generation failed\",\n      \"unsupportedActionType\": \"Unsupported AI action type\",\n      \"geminiImageNotSupportedViaGateway\": \"Gemini image generation is not supported through AI Gateway. Please configure a direct Google provider instead.\"\n    },\n    \"role\": {\n      \"notFound\": \"Role not found\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Collaborator has already existed\",\n      \"notFound\": \"Collaborator not found\",\n      \"userNotFoundInCollaborator\": \"User not found in collaborator\",\n      \"noPermissionToDelete\": \"You do not have permission to delete this collaborator\",\n      \"noPermissionToUpdate\": \"You do not have permission to update this collaborator\",\n      \"noPermissionToOperateRole\": \"You do not have permission to operate this role\",\n      \"alreadyExistedInBase\": \"Collaborator has already existed in base\",\n      \"userNotFound\": \"User not found: {{userIds}}\",\n      \"baseNotFound\": \"Base not found\",\n      \"noPermissionToAddRole\": \"You do not have permission to add this role collaborator\",\n      \"departmentNotFound\": \"Department not found\"\n    },\n    \"table\": {\n      \"notFound\": \"Table not found\",\n      \"dbTableNameAlreadyExists\": \"Database table name already exists\",\n      \"anchorNotFound\": \"Anchor table not found\",\n      \"notInTrash\": \"Table is not in trash\",\n      \"notSupportTableIndex\": \"Table index type is not supported\",\n      \"createTableIndexError\": \"Failed to create table index\",\n      \"dropTableIndexError\": \"Failed to drop table index\",\n      \"notFoundPrimaryField\": \"Primary field not found in table\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"{{viewType}} view type is not supported for export\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"File format is not supported, only {{supportType}} are supported, your file's content type is {{fileFormat}}\",\n      \"notSupportedFileType\": \"Import file type not supported\",\n      \"exceedMaxFieldsLength\": \"The number of fields in the table cannot exceed {{maxFieldsLength}}, current is {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"The current instance disallow space invitation by the administrator\",\n      \"invalidCode\": \"Invalid invitation code\",\n      \"linkNotFound\": \"Invitation link not found\",\n      \"linkExpired\": \"Invitation link has expired\",\n      \"limitExceeded\": \"You have reached the maximum number of invitations per hour\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Pin already exists\",\n      \"notFound\": \"Pin not found\",\n      \"anchorNotFound\": \"Pin anchor not found\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Invalid resource type\",\n      \"notFound\": \"Trash item not found\",\n      \"parentSpaceTrashed\": \"Unable to restore this base because its parent space is also in trash\",\n      \"parentBaseOrSpaceTrashed\": \"Unable to restore this table because its parent base or space is also in trash\",\n      \"parentBaseTrashed\": \"Unable to restore this item because its parent base is also in trash\",\n      \"parentNotFound\": \"Parent resource not found\",\n      \"tableNotFound\": \"Table trash item not found\"\n    },\n    \"license\": {\n      \"invalid\": \"The license is invalid\",\n      \"instanceIdMismatch\": \"The incoming instanceId does not match the current instance's instanceId\",\n      \"expired\": \"The license is expired\",\n      \"userLimitExceeded\": \"The number of users in the current instance exceeds the seat limit of the license. Please deactivate some users or upgrade the license\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"No domain verification code found\",\n      \"invalidCode\": \"Invalid verification code\",\n      \"resendCooldown\": \"Please wait for 1 minute before requesting a new code\",\n      \"alreadyVerified\": \"Domain already verified\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organization not found\",\n      \"authenticationNotFound\": \"Authentication not found\",\n      \"spaceShouldExist\": \"Organization space should exist\",\n      \"emailsNotInOrgDomain\": \"These emails {{emails}} are not in the organization domain\",\n      \"emailNotSpaceUser\": \"Email is not a space user\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Failed to send email\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"The current instance disallows sign up by the administrator\",\n      \"waitlistInviteCodeRequired\": \"Waitlist is enabled, invite code is required\",\n      \"waitlistInviteCodeInvalid\": \"Waitlist is enabled, invite code is invalid\",\n      \"systemUser\": \"User is a system user\",\n      \"collaboratorsInSpaces\": \"User has collaborators in spaces (or deleted spaces in trash)\",\n      \"notFound\": \"User not found\",\n      \"cannotDeleteAdmin\": \"Cannot delete admin user\",\n      \"cannotDeactivateAdmin\": \"Cannot deactivate admin user\",\n      \"cannotRemoveLastAdmin\": \"Cannot remove admin privilege from the last active admin user\",\n      \"permanentDeleted\": \"User is permanently deleted\",\n      \"cannotDeleteSelf\": \"Cannot delete yourself\",\n      \"alreadyInDepartment\": \"User {{userId}} already in target department\",\n      \"emailsNotFound\": \"Emails {{emails}} are not found\",\n      \"deleted\": \"User{{userId}} is deleted\",\n      \"alreadyInOrg\": \"User {{userId}} is already in the organization\",\n      \"notInOrg\": \"User {{userId}} is not in the organization\"\n    },\n    \"record\": {\n      \"notFound\": \"Record not found\",\n      \"deletedIdsNotFound\": \"Some records to be deleted cannot be found\",\n      \"updateFailed\": \"Failed to update record\",\n      \"noFileOrUrlProvided\": \"No file or URL provided\",\n      \"createRecordsEmpty\": \"Create records cannot be empty\",\n      \"duplicateFailed\": \"Failed to duplicate record\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Cell value validation failed\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Workflow is not active\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Invalid resource type\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Template category not found\",\n      \"snapshotRequired\": \"This template could not be published due to missing snapshot\",\n      \"sourceTemplateNotFound\": \"Source template not found\",\n      \"noMinOrderFound\": \"No minimum order found\",\n      \"takeCountTooLarge\": \"The number of templates requested exceeds the maximum limit\",\n      \"categoryLimitReached\": \"Template category limit reached (max {{maxCount}})\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Parent department not found\",\n      \"notFound\": \"Department not found\",\n      \"cannotMoveToItself\": \"Cannot move department to itself\",\n      \"cannotMoveToSub\": \"Cannot move department to its sub-department\"\n    },\n    \"app\": {\n      \"notFound\": \"App not found\",\n      \"noFilesToUpdate\": \"No files to update\",\n      \"noChatIdFound\": \"No chat ID found for this app\",\n      \"noChatFound\": \"No chat found for this app\",\n      \"versionNotFound\": \"Version not found\",\n      \"cannotRollbackToLatestVersion\": \"Cannot rollback to the latest version\",\n      \"noChatOrProjectTokenFound\": \"No chat or project token found\",\n      \"apiKeyNotSet\": \"App builder API key is not set\",\n      \"cannotDeployAppBeforeInitialization\": \"Cannot deploy app before initialization\",\n      \"noProjectOrVersionFound\": \"No project or version found\",\n      \"noDeploymentUrlAvailable\": \"No deployment URL available\",\n      \"noFilesInZip\": \"No files found in ZIP\",\n      \"zipFileTooLarge\": \"ZIP file size exceeds 5MB limit\",\n      \"invalidZip\": \"Invalid ZIP file\"\n    },\n    \"reward\": {\n      \"notFound\": \"Reward not found\",\n      \"unsupportedSourceType\": \"Unsupported reward source type\",\n      \"maxClaimsReached\": \"You have reached the maximum reward claims (2) for this week\",\n      \"verificationFailed\": \"Verification failed: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"You have already claimed a reward for this account this week\",\n      \"invalidPostUrl\": \"Invalid post URL format\",\n      \"postAlreadyUsed\": \"This post has already been used to claim a reward\",\n      \"unsupportedPlatformUrl\": \"Unsupported social platform URL\",\n      \"unsupportedPlatform\": \"Unsupported platform: {{platform}}\",\n      \"minCharCount\": \"Post must have at least {{count}} characters\",\n      \"minFollowerCount\": \"Account must have at least {{count}} followers\",\n      \"mustMention\": \"Post must mention {{mention}}\",\n      \"fetchTweetFailed\": \"Failed to fetch X tweet: {{error}}\",\n      \"tweetNotFound\": \"X tweet not found: {{postId}}\",\n      \"fetchUserFailed\": \"Failed to fetch X user: {{error}}\",\n      \"xUserNotFound\": \"X user not found: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Failed to fetch LinkedIn post: {{error}}\",\n      \"linkedInPostNotFound\": \"LinkedIn post not found: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"LinkedIn author not found: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Failed to fetch LinkedIn user: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Personal access tokens\",\n  \"oauthApps\": \"OAuth Apps\",\n  \"plugins\": \"Plugins\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Enter your password to view this page\",\n    \"submit\": \"Submit\",\n    \"password\": \"Password\",\n    \"passwordTooShort\": \"Password must be at least 3 characters\"\n  },\n  \"form\": {\n    \"requireLoginTip\": \"This form requires you to log in before submitting\",\n    \"login\": \"Log in\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Select...\"\n  },\n  \"openOnNewPage\": \"Open on new page\",\n  \"errorTips\": \"Sharing source has enabled a Authority matrix, viewing is not allowed\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}'s space\",\n  \"action\": {\n    \"createBase\": \"Create a base\",\n    \"createSpace\": \"Create a space\",\n    \"invite\": \"Invite\"\n  },\n  \"allSpaces\": \"All spaces\",\n  \"emptySpaceTitle\": \"No bases in this space\",\n  \"spaceIsEmpty\": \"Create your first base to start\",\n  \"baseModal\": {\n    \"copy\": \"Copy\",\n    \"duplicate\": \"Duplicate \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Create base from template\",\n    \"duplicateRecords\": \"Duplicate records\",\n    \"duplicateRecordsTip\": \"Your revision history and collaborators will not be duplicated.\",\n    \"toSpace\": \"To space\",\n    \"copyToSpace\": \"Copy to space\",\n    \"duplicateBase\": \"Duplicate base\",\n    \"missTargetTip\": \"Please select a space to duplicate the base.\",\n    \"copying\": \"Duplicating base, it may take a while...\",\n    \"copyingTemplate\": \"Creating base from template, it may take a while...\",\n    \"howToCreate\": \"How do you want to start?\",\n    \"fromScratch\": \"From scratch\",\n    \"fromTemplate\": \"From template\",\n    \"moveBaseToAnotherSpace\": \"Move {{baseName}} to another space\",\n    \"chooseSpace\": \"Choose space\",\n    \"duplicateBaseSucceedAndJump\": \"Success, click to jump\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Settings\",\n    \"general\": \"General\",\n    \"collaborators\": \"Collaborators\",\n    \"generalDescription\": \"Change the settings for your current space here\",\n    \"collaboratorDescription\": \"Manage collaborators of your space and set their access permission\",\n    \"spaceName\": \"Space name\",\n    \"spaceId\": \"Space ID\",\n    \"importBase\": \"Import\"\n  },\n  \"collaborators\": \"Collaborators\",\n  \"more\": \"More\",\n  \"pin\": {\n    \"add\": \"Add to pin\",\n    \"remove\": \"Remove from pin\",\n    \"pin\": \"Pin\",\n    \"empty\": \"Your pined bases and space will appear here\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"You don't have permission to create base\"\n  },\n  \"tip\": {\n    \"delete\": \"Are you sure you want to delete <0/>?\",\n    \"title\": \"Tips\",\n    \"exportTips1\": \"Export the current base as a .tea file, which may take some time. You can check the export results in the notification center.\",\n    \"exportTips2\": \"You can import the .tea file in space -> more -> import\",\n    \"exportTips3\": \"Cross-base relation fields will be converted to single line text.\",\n    \"exportIncludeDataLabel\": \"Include records\",\n    \"exportIncludeDataDescription\": \"Turn off to export only structure and configuration.\",\n    \"moveBaseSuccessTitle\": \"Move base success\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} has been moved to {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Delete space\",\n    \"blockedTitle\": \"Cannot delete this space\",\n    \"blockedDesc\": \"This space has an active subscription. Please cancel the subscription before deleting the space.\",\n    \"permanentDeleteWarning\": \"This action will permanently delete all resources and data under the current space. Please proceed with caution!\",\n    \"confirmInputLabel\": \"Please type DELETE to confirm the deletion\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Shared with me\",\n    \"description\": \"All the bases that invited me to join\",\n    \"empty\": \"No bases shared with me yet\"\n  },\n\n  \"integration\": {\n    \"title\": \"Integrations\",\n    \"description\": \"Manage integrations of your space\",\n    \"addIntegration\": \"Add integration\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"AI settings\",\n    \"description\": \"Manage AI settings of your space\",\n    \"enableTips\": \"Enable AI to use AI features in your space instead of using the system AI\",\n    \"enable\": \"Initialize AI settings\",\n    \"enableSwitchTips\": \"Please configure the large coding model before enabling\"\n  },\n  \"import\": {\n    \"importing\": \"Importing\",\n    \"importWayTip\": \"Click or drag and drop the file to upload in this area\",\n    \"baseImportTips\": \"Click or drag and drop .tea file to upload in this area\",\n    \"confirm\": \"Confirm and continue\",\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Click to view\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Template\",\n    \"description\": \"Create a new base from a template quickly\",\n    \"noTemplatesAvailable\": \"No templates available\",\n    \"noTemplatesDescription\": \"There is nothing here right now\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Recently visited\"\n  },\n  \"noBases\": {\n    \"title\": \"Hi {{userName}}!\",\n    \"description\": \"Let's start managing your work with your first base.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Hi {{userName}}!\",\n    \"description\": \"Create your first space to start your data collaboration journey.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"All bases\",\n    \"owner\": \"Owner\",\n    \"createdTime\": \"Created\",\n    \"lastOpened\": \"Last opened\",\n    \"enter\": \"Enter\",\n    \"noTables\": \"No tables\",\n    \"empty\": \"No bases yet\",\n    \"recent\": \"Recent\",\n    \"manual\": \"Manual order\",\n    \"noBasesFound\": \"No bases found\"\n  },\n  \"publishBase\": {\n    \"title\": \"Publish Base to community\",\n    \"description\": \"One-click publish base, inspiration is no longer lonely! Let more people see, use, and Remix your creativity, and build a more powerful business together\",\n    \"infoTitle\": \"Basic info\",\n    \"form\": {\n      \"title\": \"Work title\",\n      \"description\": \"Description\",\n      \"security\": \"Security\",\n      \"includeNodes\": \"Include nodes\",\n      \"advanced\": \"Advanced\",\n      \"publishNode\": \"Publish nodes\",\n      \"includeData\": \"Include data\",\n      \"defaultActiveNode\": \"Default active node\",\n      \"select\": \"please select\",\n      \"descriptionPlaceholder\": \"Briefly describe your idea...\",\n      \"titlePlaceholder\": \"Name your work...\"\n    },\n    \"publishToCommunity\": \"Publish to template center\",\n    \"publish\": \"Publish\",\n    \"publishSuccess\": \"Publish success!\",\n    \"previewTips\": \"Let the world see your work\",\n    \"update\": \"Update\",\n    \"unPublish\": \"Unpublish\",\n    \"unPublishSuccess\": \"Base unpublished successfully!\",\n    \"unPublishConfirmTitle\": \"Confirm unpublish\",\n    \"unPublishConfirmDescription\": \"Are you sure you want to unpublish this base? It will no longer be visible in the community template center.\",\n    \"usageCount\": \"Usage count: \",\n    \"uploadCover\": \"Click to upload cover image\",\n    \"changeCover\": \"Click to change cover\",\n    \"uploading\": \"Uploading image...\",\n    \"uploadSuccess\": \"Image uploaded successfully\",\n    \"uploadFailed\": \"Upload failed\",\n    \"invalidImageType\": \"Please select an image file\",\n    \"tips\": {\n      \"publishValidation\": \"Title and description are required\",\n      \"atLeastOneNode\": \"At least select one node to publish\"\n    },\n    \"urlCopied\": \"URL copied to clipboard!\",\n    \"urlCopiedForDiscord\": \"URL copied to clipboard! You can paste it in Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Officially featured. Your template will receive more exposure.\",\n    \"unfeaturedTip\": \"Not featured yet. Keep improving for a chance to be highlighted. Let more people see your work.\",\n    \"publishSuccessDescription\": \"Share your work to the world\",\n    \"shareWith\": \"Share with\",\n    \"unpublishedApps\": {\n      \"title\": \"Unpublished Apps Detected\",\n      \"description\": \"Unpublished apps may cause template preview failures. Publish them now or continue anyway.\",\n      \"publishAll\": \"Publish All\",\n      \"publish\": \"Publish\",\n      \"published\": \"Published\",\n      \"publishing\": \"Publishing\",\n      \"notPublished\": \"Not Published\",\n      \"publishFailed\": \"Publish failed\",\n      \"publishFailedTip1\": \"Check if the source app can be published successfully\",\n      \"publishFailedTip2\": \"Try republishing this template\",\n      \"ignoreAndContinue\": \"Ignore & Continue\",\n      \"goToFix\": \"Go to Fix\",\n      \"redeploy\": \"Redeploy\",\n      \"unnamedApp\": \"Unnamed App\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Coming soon\",\n    \"viewFilterInShare\": \"This view is being used in a view share link. Modifications to the view configuration will also change the view share link.\",\n    \"createFieldButtonText\": \"Create a new <0/> field\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Share\",\n        \"statusLabel\": \"Share view to web\",\n        \"noPermission\": \"No permission to share view\",\n        \"shareLink\": \"Share link\",\n        \"copied\": \"Copied\",\n        \"genLink\": \"Generate new link\",\n        \"allowCopy\": \"Allow viewers to copy data out of this view\",\n        \"showAllFields\": \"Show all fields in expanded records\",\n        \"restrict\": \"Restrict by password\",\n        \"tips\": \"People who have the link can see the view.\",\n        \"passwordTitle\": \"Enter a password\",\n        \"passwordTips\": \"Password restrictions for accessing shared views\",\n        \"embed\": \"Embed\",\n        \"embedPreview\": \"Preview\",\n        \"copyCode\": \"Copy code\",\n        \"hideToolbar\": \"Hide toolbar\",\n        \"URLSetting\": \"URL parameters config\",\n        \"URLSettingDescription\": \"Adjusting the following settings will not affect the embed code. You need to copy the code with new parameters to make it take effect\",\n        \"theme\": \"Theme\",\n        \"themeSystem\": \"System\",\n        \"themeLight\": \"Light\",\n        \"themeDark\": \"Dark\",\n        \"cancel\": \"Cancel\",\n        \"save\": \"Save\",\n        \"requireLogin\": \"Require login submit\"\n      },\n      \"extensions\": {\n        \"label\": \"Extensions\",\n        \"graph\": \"Graph\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"Database connection\",\n        \"title\": \"API Access\",\n        \"aiContext\": \"Copy to AI\",\n        \"advanced\": \"Advanced\",\n        \"generatingToken\": \"Generating...\",\n        \"aiContextTitle\": \"AI-Friendly API Documentation\",\n        \"aiContextDescriptionNoToken\": \"Copy this documentation to your AI assistant (ChatGPT, Claude, etc.). Click 'Generate Token' to create an API token that will be automatically filled in.\",\n        \"aiContextDescriptionWithToken\": \"Copy this documentation to your AI assistant (ChatGPT, Claude, etc.). A permission-restricted API token has been generated and filled in.\",\n        \"generateToken\": \"Generate Token\",\n        \"confirmTitle\": \"Create API Token\",\n        \"confirmDescription\": \"This will create a new API token with the following permissions for this base:\",\n        \"scopeTableRead\": \"Read table info\",\n        \"scopeFieldRead\": \"Read fields\",\n        \"scopeRead\": \"Read records\",\n        \"scopeCreate\": \"Create records\",\n        \"scopeUpdate\": \"Update records\",\n        \"scopeDelete\": \"Delete records\",\n        \"confirmExpiry\": \"The token will be valid for 1 year.\",\n        \"confirmButton\": \"Confirm & Create\",\n        \"tokenInfo\": \"Token expires: {{expiry}}\",\n        \"tokenCreatedSuccess\": \"Token created successfully! Click copy to use it with your AI assistant.\",\n        \"copied\": \"Copied!\",\n        \"copy\": \"Copy\",\n        \"copyAIDoc\": \"Copy AI Doc\",\n        \"aiDocPreview\": \"AI Doc Preview\",\n        \"manageToken\": \"Manage Token\",\n        \"openInNewTab\": \"Open in New Tab\",\n        \"advancedDesc\": \"Open the advanced API query builder for more customization options.\",\n        \"openAdvanced\": \"Open Query Builder\",\n        \"queryBuilderTitle\": \"API Query Builder\",\n        \"queryBuilderDesc\": \"Visually configure filters, sorting, and other parameters to generate ready-to-run API request code.\",\n        \"viewApiDocs\": \"View Full API Docs\"\n      },\n      \"personalView\": {\n        \"personal\": \"Personal\",\n        \"collaborative\": \"Collaborative\",\n        \"tip\": \"After enabling, the view settings will only take effect for you personally\",\n        \"dialog\": {\n          \"title\": \"Exit personal mode\",\n          \"description\": \"The personal view configuration will be restored to the real-time collaboration state, you can also save the personal view settings and synchronize them to everyone\",\n          \"cancelText\": \"Exit and sync\",\n          \"confirmText\": \"Confirm exit\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Welcome\",\n    \"emptyTitle\": \"Start building your base\",\n    \"description\": \"Click the \\\"+\\\" button in the sidebar to add resources\",\n    \"help\": \"Visit the <HelpCenter /> for more information\",\n    \"helpCenter\": \"Help center\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Cannot link record(s): already linked by another record in the same batch. In one-to-many relationships, each record can only belong to one parent.\",\n      \"one_many_duplicate\": \"Cannot link record(s): already linked to another record. In one-to-many relationships, each record can only belong to one parent.\",\n      \"one_one_duplicate\": \"Cannot link record(s): the target record is already linked by another record in a one-to-one relationship.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"Table \\\"{{tableName}}\\\" can have at most {{maxFieldCount}} fields.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Field management\",\n    \"fieldManagementDesc\": \"Detailed properties for all fields of the current table\",\n    \"advancedProps\": \"Advanced properties\",\n    \"hide\": \"hide\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Label\"\n      },\n      \"longText\": {\n        \"title\": \"Notes\"\n      },\n      \"number\": {\n        \"title\": \"Number\",\n        \"formatType\": \"Format type\",\n        \"currencySymbol\": \"Currency symbol\",\n        \"defaultSymbol\": \"$\",\n        \"precision\": \"Precision\",\n        \"decimalExample\": \"Number (123)\",\n        \"currencyExample\": \"Currency ($100)\",\n        \"percentExample\": \"Percent (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Status\",\n        \"options\": {\n          \"todo\": \"To do\",\n          \"inProgress\": \"In progress\",\n          \"done\": \"Done\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Tags\"\n      },\n      \"attachment\": {\n        \"title\": \"Attachments\"\n      },\n      \"user\": {\n        \"title\": \"Collaborator\"\n      },\n      \"date\": {\n        \"title\": \"Date\",\n        \"dateFormatting\": \"Date formatting\",\n        \"timeFormatting\": \"Time formatting\",\n        \"timeZone\": \"Time zone\",\n        \"yearMonth\": \"Year/month\",\n        \"monthDay\": \"Month/day\",\n        \"year\": \"Year\",\n        \"month\": \"Month\",\n        \"day\": \"Day\",\n        \"local\": \"Local\",\n        \"friendly\": \"Friendly\",\n        \"us\": \"US\",\n        \"european\": \"European\",\n        \"asia\": \"Asia\",\n        \"custom\": \"Custom\",\n        \"12Hour\": \"12 hour\",\n        \"24Hour\": \"24 hour\",\n        \"noDisplay\": \"No display\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Created time\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Last modified time\"\n      },\n      \"createdBy\": {\n        \"title\": \"Created by\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Last modified by\"\n      },\n      \"rating\": {\n        \"title\": \"Rating\"\n      },\n      \"checkbox\": {\n        \"title\": \"Done\"\n      },\n      \"button\": {\n        \"title\": \"Button\",\n        \"label\": \"Button label\",\n        \"color\": \"Button color\",\n        \"limitCount\": \"Limit clicks\",\n        \"resetCount\": \"Allow reset\",\n        \"maxCount\": \"Max clicks\",\n        \"automation\": \"Automation\",\n        \"customAutomation\": \"Custom automation\",\n        \"clickConfirm\": \"Confirm before clicking\",\n        \"confirmTitle\": \"Title\",\n        \"confirmDescription\": \"Content\",\n        \"confirmButtonText\": \"Confirm button text\"\n      },\n      \"formula\": {\n        \"title\": \"Calculation\",\n        \"formula\": \"Formula\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (from {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (filtered from {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Rollup (from {{linkFieldName}})\",\n        \"rollup\": \"Rollup\",\n        \"selectAnRollupFunction\": \"Select an rollup function\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Returns true if all the values are true\",\n          \"arrayCompact\": \"Removes empty strings and null values from the array. Keeps 'false' and strings that contain one or more blank characters.\",\n          \"arrayJoin\": \"Join all the values into a single comma-separated string.\",\n          \"arrayUnique\": \"Return only unique items.\",\n          \"average\": \"Mean average of the values.\",\n          \"concatenate\": \"Joins together the text values into a single text value.\",\n          \"count\": \"Count only non-empty numeric values. If you want to count all records, use COUNTALL.\",\n          \"countA\": \"Count the number of non-empty values. This function counts both numeric and text values.\",\n          \"countAll\": \"counts all values including blank records.\",\n          \"max\": \"Returns the largest of the given numbers.\",\n          \"min\": \"Returns the smallest of the given numbers.\",\n          \"or\": \"Returns true if any one of the values is true.\",\n          \"sum\": \"Sum together the values.\",\n          \"xor\": \"Returns true if and only if odd number of values are true.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} conditional rollup\",\n        \"description\": \"Aggregate values from another table using filters.\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Add field\",\n      \"editField\": \"Edit field\",\n      \"insertField\": \"Insert field\",\n      \"graph\": \"Graph\",\n      \"defaultValue\": \"Default value\",\n      \"reset\": \"Reset\",\n      \"fieldUpdated\": \"Field has been updated\",\n      \"fieldCreated\": \"Field has been created\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Are you sure you want to perform it?\",\n      \"addDescription\": \"Add description\",\n      \"dbFieldName\": \"DB field name\",\n      \"description\": \"Description\",\n      \"descriptionPlaceholder\": \"Describe this field (optional)\",\n      \"type\": \"Type\",\n      \"showAs\": \"Show as\",\n      \"color\": \"Color\",\n      \"number\": \"Number\",\n      \"chartBar\": \"Chart bar\",\n      \"chartLine\": \"Chart line\",\n      \"ring\": \"Ring\",\n      \"bar\": \"Bar\",\n      \"text\": \"Text\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"Email\",\n      \"phone\": \"Phone\",\n      \"maxNumber\": \"Max number\",\n      \"showNumber\": \"Show number\",\n      \"autoFillDate\": \"Auto fill with current date\",\n      \"createSymmetricLink\": \"Create a back link field in the link table\",\n      \"allowLinkMultipleRecords\": \"Allow multiple select\",\n      \"allowLinkToDuplicateRecords\": \"Allows records to be selected more than once\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Allows records to be selected more than once\",\n      \"oneToOne\": \"one-to-one\",\n      \"oneToMany\": \"one-to-many\",\n      \"manyToOne\": \"many-to-one\",\n      \"manyToMany\": \"many-to-many\",\n      \"self\": \"Self\",\n      \"selectTable\": \"Select table...\",\n      \"selectBase\": \"Select a base...\",\n      \"linkFromAnotherBase\": \"Link from another base\",\n      \"inSelfLink\": \"in self-link\",\n      \"betweenTwoTables\": \"between two tables\",\n      \"tips\": \"Tips\",\n      \"linkTipMessage\": \"This configuration represents a<br></br> <b>{{relationship}}</b> relationship <span>{{linkType}}</span>\",\n      \"style\": \"Style\",\n      \"maximum\": \"Maximum\",\n      \"addOption\": \"Add option\",\n      \"allowMultiUsers\": \"Allow adding multiple users\",\n      \"notifyUsers\": \"Notify users once they're selected\",\n      \"searchTable\": \"Search table...\",\n      \"calculating\": \"Calculating...\",\n      \"doSaveChanges\": \"Do you want to save the changes you made?\",\n      \"linkFieldToLookup\": \"Linked record field to use for lookup\",\n      \"lookupToTable\": \"Field from <bold>{{tableName}}</bold> you want to look up\",\n      \"rollupToTable\": \"Field from <bold>{{tableName}}</bold> you want to roll up\",\n      \"selectField\": \"Select a field...\",\n      \"lastModifiedScope\": \"Fields\",\n      \"lastModifiedAll\": \"All editable fields\",\n      \"lastModifiedSpecific\": \"Specific fields\",\n      \"lastModifiedSelect\": \"Tracked fields\",\n      \"lastModifiedSelectAll\": \"Track all fields\",\n      \"noEditableFields\": \"No editable fields available\",\n      \"conditionalRollup\": {\n        \"fieldMapping\": \"Add field mapping\",\n        \"selectBaseField\": \"Select base field\",\n        \"noMappings\": \"No field mappings configured yet.\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      },\n      \"linkTable\": \"Link table\",\n      \"linkBase\": \"Link base\",\n      \"tableNoPermission\": \"No permission table\",\n      \"baseNoPermission\": \"No permission base\",\n      \"noLinkTip\": \"No linked records to look up. Add a Link to another record field, then try to configure your lookup again.\",\n      \"fieldValidationRules\": \"Field value validation rules\",\n      \"enableValidateFieldUnique\": \"Unique values only\",\n      \"enableValidateFieldNotNull\": \"Required\",\n      \"knowMore\": \"know more\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Show records by field\",\n      \"filterByView\": \"Filter records by view\",\n      \"filter\": \"Filter records\",\n      \"hideFields\": \"Hide fields\",\n      \"moreOptions\": \"More options\",\n      \"allowNewOptionsWhenEditing\": \"Allow new options when editing\",\n      \"deleteField\": {\n        \"title\": \"Delete field\",\n        \"simpleConfirm\": \"Are you sure you want to delete the field <b>{{fieldName}}</b>?\",\n        \"withDependencies\": \"Deleting the field <b>{{fieldName}}</b> will affect the following fields:\",\n        \"affectedFields\": \"Affected fields:\",\n        \"fieldsToDelete\": \"Fields to delete ({{count}})\",\n        \"unviewedHint\": \"{{count}} field(s) not reviewed\",\n        \"deleteCount\": \"Delete {{count}} fields\",\n        \"noAffectedFields\": \"This field is not referenced by other fields.\",\n        \"riskIdentified\": \"Risk identified({{count}})\",\n        \"noDependencies\": \"Not referenced({{count}})\",\n        \"safeToDelete\": \"Safe to delete\",\n        \"safeToDeleteDesc\": \"This field is not referenced by other fields, can be deleted safely\",\n        \"affectedItems\": \"Affected Items\",\n        \"type\": \"Type\",\n        \"source\": \"Source\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Link to records in the table you choose\",\n      \"singleLineText\": \"Enter text, or prefill each new cell with a default value.\",\n      \"longText\": \"Enter multiple lines of text.\",\n      \"attachment\": \"Add or AI generate images, or upload documents or other files to be viewed or downloaded.\",\n      \"checkbox\": \"Check or uncheck to indicate status.\",\n      \"multipleSelect\": \"Select one or more predefined options in a list.\",\n      \"singleSelect\": \"Select one predefined option from a list, or prefill each new cell with a default option.\",\n      \"user\": \"Add an user to a record.\",\n      \"date\": \"Enter a date (e.g. 11/12/2023) or choose one from a calendar.\",\n      \"number\": \"Enter a number, or prefill each new cell with a default value.\",\n      \"duration\": \"Enter a duration of time in hours, minutes or seconds (e.g. 1:23).\",\n      \"rating\": \"Add a rating on a predefined scale.\",\n      \"formula\": \"Compute values based on fields.\",\n      \"rollup\": \"Summarize data from linked records.\",\n      \"conditionalRollup\": \"Summarize values from a linked table with conditional filters.\",\n      \"conditionalLookup\": \"Show linked values that meet your filter conditions.\",\n      \"count\": \"Count the number of linked records.\",\n      \"createdTime\": \"See the date and time each record was created.\",\n      \"lastModifiedTime\": \"See the date and time of the most recent edit to some or all fields in a record.\",\n      \"createdBy\": \"See which user created the record.\",\n      \"lastModifiedBy\": \"See which user made the most recent edit to some or all fields in a record.\",\n      \"autoNumber\": \"Automatically generate unique incremental numbers for each record.\",\n      \"button\": \"Trigger a customized action.\",\n      \"lookup\": \"See values from a field in a linked record.\"\n    },\n    \"fieldName\": \"Field name\",\n    \"fieldNameOptional\": \"Field name (Optional)\",\n    \"fieldType\": \"Field type\",\n    \"aiConfig\": {\n      \"title\": \"AI config\",\n      \"type\": {\n        \"summary\": \"Summarize\",\n        \"translation\": \"Translate\",\n        \"extraction\": \"Extract information\",\n        \"improvement\": \"Improve\",\n        \"tag\": \"Smart-tag\",\n        \"classification\": \"Smart-classify\",\n        \"customization\": \"Customize\",\n        \"imageGeneration\": \"Image generation\",\n        \"rating\": \"Rating\"\n      },\n      \"label\": {\n        \"type\": \"AI action type\",\n        \"model\": \"AI model\",\n        \"targetLanguage\": \"Target language\",\n        \"sourceField\": \"Source field\",\n        \"sourceFieldForTag\": \"Select a field, match it with the created tags\",\n        \"sourceFieldForClassify\": \"Select a field, match it with the created classifications\",\n        \"attachPrompt\": \"Attach requirements\",\n        \"prompt\": \"Customize prompt\",\n        \"sourceFieldForAttachment\": \"Source field for attachment\",\n        \"imageSize\": \"Image size\",\n        \"imageQuality\": \"Image quality\",\n        \"imageCount\": \"Image count\",\n        \"aspectRatio\": \"Aspect ratio\",\n        \"resolution\": \"Resolution\",\n        \"advancedSettings\": \"Advanced settings\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Summary of the content\",\n        \"translate\": \"Translation concise and easy to understand, light-hearted\",\n        \"extractInfo\": \"Extract email, phone, name, address...\",\n        \"extractDate\": \"Extract start time\",\n        \"improveText\": \"Formal, friendly, humorous...\",\n        \"attachPromptForTag\": \"Not allowed to exceed three tags\",\n        \"attachPromptForClassify\": \"Classify \\\"In Progress\\\" as \\\"No Risk\\\"\",\n        \"attachPrompt\": \"Please enter additional requirements\",\n        \"prompt\": \"Enter / select the referenced field\",\n        \"type\": \"Select AI action\",\n        \"targetLanguage\": \"English, Chinese, French...\",\n        \"imageSize\": \"Please enter the image size\",\n        \"imageQuality\": \"Please enter the image quality\",\n        \"aspectRatio\": \"Select aspect ratio\",\n        \"resolution\": \"Select resolution\",\n        \"attachPromptForImageGeneration\": \"The image should be vivid and natural\",\n        \"attachPromptForRating\": \"Rate the image quality\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Low\",\n        \"medium\": \"Medium\",\n        \"high\": \"High\"\n      },\n      \"hint\": {\n        \"imageInputSupported\": \"💡 This model supports image input. You can select an attachment field to use images as reference for generation.\",\n        \"attachmentNotSupported\": \"💡 This model does not support attachment input. Only Gemini models support image-to-image generation.\",\n        \"singleImageOnly\": \"This model can only generate one image at a time\"\n      },\n      \"auto\": \"Auto\",\n      \"resolution\": {\n        \"1K\": \"1K (Standard)\",\n        \"2K\": \"2K (HD)\",\n        \"4K\": \"4K (Ultra HD)\"\n      },\n      \"autoFill\": {\n        \"title\": \"Auto update\",\n        \"tip\": \"After enabling, the current field will be updated synchronously with the content changes of the AI configuration\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Regenerate AI Field\",\n        \"description\": \"Will generate for {{count}} rows in the current view. Task will run in the background.\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Apply AI Field Configuration\",\n        \"description\": \"Will generate for {{count}} rows in the current view. Task will run in the background.\",\n        \"generateMode\": \"Generation mode\",\n        \"emptyOnlyMode\": \"Fill empty cells only\",\n        \"emptyOnlyModeDesc\": \"Only generate for empty cells, existing content will not be overwritten\",\n        \"allMode\": \"Generate entire column\",\n        \"allModeDesc\": \"Generate for all rows, will overwrite existing content\",\n        \"saveOnlyMode\": \"Save configuration only\",\n        \"saveOnlyModeDesc\": \"Only save field configuration, no cells will be written\",\n        \"saveConfigOnly\": \"Save configuration only\",\n        \"generate\": \"Generate\",\n        \"fillEmptyCells\": \"Fill empty cells\",\n        \"generateAll\": \"Generate all\",\n        \"recommended\": \"Recommended\",\n        \"generateFailed\": \"Generate failed\",\n        \"taskLimited\": \"Due to row limit, only the first {{processedCount}} rows were processed ({{rowCount}} rows matched)\",\n        \"limitWarning\": \"Due to the 1,000 row limit per task, only the first 1,000 rows will be processed. You can run the task again to process remaining rows.\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Add attachment\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"New table\",\n    \"rename\": \"Rename\",\n    \"design\": \"Design\",\n    \"tableRecordHistory\": \"Table record history\",\n    \"deleteConfirm\": \"Are you sure you want to delete \\\"{{tableName}}\\\" ?\",\n    \"dbTableName\": \"Table name in physical database\",\n    \"schemaName\": \"Schema name in physical database\",\n    \"baseInfo\": \"Base info\",\n    \"typeOfDatabase\": \"Type of database\",\n    \"descriptionForTable\": \"Description for this table\",\n    \"nameForTable\": \"Name for this table\",\n    \"deleteTip1\": \"Link fields linked with this table in other tables will be deleted.\",\n    \"deleteTip2\": \"This table can be restored from the trash after deletion.\",\n    \"operator\": {\n      \"createBlank\": \"New table\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Copy and paste only works in HTTPS or localhost\",\n      \"copyAndPasteBrowser\": \"Copy and paste not supported in this browser\",\n      \"copying\": \"Copying...\",\n      \"copySuccessful\": \"Copy successful\",\n      \"copyFailed\": \"Copy failed\",\n      \"pasting\": \"Pasting...\",\n      \"pasteSuccessful\": \"Paste successful\",\n      \"pasteFailed\": \"Paste failed\",\n      \"filling\": \"Filling...\",\n      \"fillSuccessful\": \"Fill successful\",\n      \"fillFailed\": \"Fill failed\",\n      \"clearing\": \"Clearing...\",\n      \"clearSuccessful\": \"Clear successful\",\n      \"clearConfirmTitle\": \"Clear data\",\n      \"clearConfirmDescription\": \"This action will clear {{cellCount}} cells in {{rowCount}} records. Are you sure you want to continue?\",\n      \"deleteRecordConfirmTitle\": \"Delete records\",\n      \"deleteRecordConfirmDescription\": \"This action will delete {{recordCount}} records. Are you sure you want to continue?\",\n      \"pasteConfirmTitle\": \"Perform paste\",\n      \"pasteConfirmDescription\": \"You are about to perform a paste that overwrites {{recordCount}} records. Are you sure you want to continue?\",\n      \"expandCommonDescription\": \"To fit your pasted data into the table, we need to add\",\n      \"expandColDescription\": \"{{count}} fields\",\n      \"expandRowDescription\": \"{{count}} records\",\n      \"paste\": \"Paste\",\n      \"deleteRecord\": \"Delete\",\n      \"clear\": \"Clear\",\n      \"conjunction\": \"and\",\n      \"deleteFieldConfirmTitle\": \"You will delete the following fields\",\n      \"deleting\": \"Deleting...\",\n      \"deleteSuccessful\": \"Delete successful\",\n      \"pasteFileFailed\": \"Files can only be pasted into an attachment field\",\n      \"copyError\": {\n        \"noFocus\": \"Please keep the page active and do not switch windows\",\n        \"noPermission\": \"You don't have permission to copy records\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Table label\",\n      \"effectCells\": \"May effect cells\",\n      \"estimatedTime\": \"Estimated time\",\n      \"linkFieldCount\": \"Affects the number of linked fields\"\n    },\n    \"integrity\": {\n      \"check\": \"Check\",\n      \"title\": \"Integrity check\",\n      \"loading\": \"Checking integrity...\",\n      \"allGood\": \"All good!\",\n      \"fixIssues\": \"Fix issues\",\n      \"type\": \"Type\",\n      \"message\": \"Error message\",\n      \"errorType\": {\n        \"ForeignTableNotFound\": \"ForeignTableNotFound\",\n        \"ForeignKeyNotFound\": \"ForeignKeyNotFound\",\n        \"SelfKeyNotFound\": \"SelfKeyNotFound\",\n        \"SymmetricFieldNotFound\": \"SymmetricFieldNotFound\",\n        \"MissingRecordReference\": \"MissingRecordReference\",\n        \"InvalidLinkReference\": \"InvalidLinkReference\",\n        \"ForeignKeyHostTableNotFound\": \"ForeignKeyHostTableNotFound\",\n        \"ReferenceFieldNotFound\": \"ReferenceFieldNotFound\",\n        \"UniqueIndexNotFound\": \"UniqueIndexNotFound\",\n        \"EmptyString\": \"EmptyString\"\n      }\n    },\n    \"index\": {\n      \"description\": \"Indexes can significantly improve search performance, especially when dealing with large amounts of data (over {{rowCount}} rows). The downside is slightly slower write operations. If you frequently perform searches or have large datasets, it's recommended to enable indexing.\",\n      \"repair\": \"repair\",\n      \"repairTip\": \"Index anomalies detected, which may cause search performance degradation. It is recommended to click the repair button to fix the index\",\n      \"enableIndexTip\": \"You are creating an index. The time required depends on the table size. During this process, the table's read and write performance may be affected. Please be patient.\",\n      \"globalSearchTip_limited\": \"Search all fields except date, checkbox and button fields, maximum {{count}} fields\",\n      \"globalSearchTip_infinity\": \"Search all fields except date, checkbox and button fields\",\n      \"autoIndexTip\": \"Your table has exceeded {{rowCount}} rows. We recommend enabling indexing to improve search performance. Please note that indexing may slightly reduce write speed. During index creation, table read/write performance might be temporarily affected. Please be patient.\",\n      \"enableIndex\": \"enable\",\n      \"keepAsIs\": \"keep as it\",\n      \"ignoreIndexError\": \"ignore\"\n    },\n    \"searchTips\": {\n      \"maxFieldTips_limited\": \"Exceeded the maximum {{count}} number of fields, extra fields will be ignored\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Upload\",\n      \"import\": \"Import\",\n      \"localFile\": \"Local files\",\n      \"linkUrl\": \"Link(URL)\",\n      \"linkUrlInputTitle\": \"Add file from URL\",\n      \"importTitle\": \"Create a new table\",\n      \"incrementImportTitle\": \"Import into — \",\n      \"optionsTitle\": \"Import option\",\n      \"primitiveFields\": \"Primitive fields\",\n      \"importFields\": \"Import field\",\n      \"primaryField\": \"Primary field\",\n      \"tipsTitle\": \"Tips\",\n      \"confirm\": \"Confirm and continue\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Add from other sources\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"CSV file\",\n      \"importCsvData\": \"Import CSV data\",\n      \"importExcelData\": \"Import Microsoft Excel data\",\n      \"cancel\": \"Cancel\",\n      \"leave\": \"Leave\",\n      \"downAsCsv\": \"Download CSV\",\n      \"importData\": \"Import data\",\n      \"duplicate\": \"Duplicate\",\n      \"duplicating\": \"Duplicating...\",\n      \"duplicateSuccess\": \"Duplicated successfully\",\n      \"duplicateFailed\": \"Failed to duplicate\",\n      \"includeRecords\": \"Include records\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Click or drag file to this area to upload\",\n      \"leaveTip\": \"Your data will still be imported.\",\n      \"fileExceedSizeTip\": \"This type file size exceeds the limit of\",\n      \"analyzing\": \"analyzing\",\n      \"importing\": \"Importing\",\n      \"notSupportFieldType\": \"Field type is not supported\",\n      \"resultEmpty\": \"No results found.\",\n      \"searchPlaceholder\": \"Search...\",\n      \"importAlert\": \"Once the import begins, it cannot be stopped until it is either successfully completed or terminated due to failure. The import status is displayed in the upper right corner of the table. The results of import process will be notified to you once completed. You should not update the field during the import as it may cause errors.\",\n      \"noTips\": \"I know that, don't show again\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Auto-select field types\",\n      \"useFirstRowAsHeaderOptionName\": \"Use first row as header\",\n      \"importDataOptionName\": \"Import data\",\n      \"sheetKey\": \"SheetName: \",\n      \"excludeFirstRow\": \"Exclude first row in import\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Field\",\n      \"error\": {\n        \"urlEmptyTip\": \"URL should not be empty!\",\n        \"errorFileFormat\": \"File format is incorrect!\",\n        \"uniqueFieldName\": \"Field name should be unique!\",\n        \"fieldNameEmpty\": \"Field name should not be empty!\",\n        \"atLeastAImportField\": \"Please set a import field at least\",\n        \"urlValidateTip\": \"Couldn't parse URL. Try a different URL!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Do not import\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Download Csv\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Download All Attachments\",\n      \"loading\": \"Loading preview...\",\n      \"rowsWithAttachments\": \"{{count}} rows with attachments\",\n      \"totalAttachments\": \"{{count}} attachments\",\n      \"totalSize\": \"Total size: {{size}}\",\n      \"startDownload\": \"Start Download\",\n      \"confirmTitle\": \"Download {{count}} files\",\n      \"confirmDescription\": \"Total size: {{size}}. The files will be compressed into a zip file.\",\n      \"confirm\": \"Download\",\n      \"cancel\": \"Cancel\",\n      \"downloading\": \"Downloading...\",\n      \"downloadingFile\": \"Downloading: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Download completed\",\n      \"cancelled\": \"Download cancelled\",\n      \"noAttachments\": \"No attachments to download\",\n      \"error\": \"Download failed\",\n      \"errorPartial\": \"{{failedCount}} files failed to download\",\n      \"requireHttps\": \"Batch download requires HTTPS. Please access via HTTPS or localhost.\",\n      \"advancedOptions\": \"Advanced options\",\n      \"namingFieldLabel\": \"Attachment name prefix\",\n      \"selectField\": \"Default: attachment index\",\n      \"groupByRow\": \"Archive into folders\",\n      \"groupByRowTip\": \"When a row has multiple attachments, they will be placed in the same folder; rows with only one attachment will not create a folder.\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Add new record\",\n    \"prefillingRowTooltip\": \"Please enter the new record data below. The record will be saved automatically once you click outside this row.\",\n    \"presortRowTitle\": \"This record has been filtered or moved due to sorting rules\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Fields\",\n    \"addAll\": \"Add all\",\n    \"removeAll\": \"Remove all\",\n    \"hideFieldTip\": \"Hide the field to here\",\n    \"unableAddFieldTip\": \"Unable to add this type field\",\n    \"removeFromFormTip\": \"Remove from the form\",\n    \"descriptionPlaceholder\": \"Enter from description\",\n    \"dragToFormTip\": \"Drag the field here to add it to the form\",\n    \"protectedFieldTip\": \"This field has been set as a \\\"required\\\" field, and cannot be removed in the form view. Please modify it in the field settings.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Hide field name\",\n      \"customizeCards\": \"Customize cards\",\n      \"stackedBy\": \"Stacked by <0/>\",\n      \"chooseStackingField\": \"Choose a stacking field\",\n      \"chooseStackingFieldDescription\": \"Which field would you like to use for this kanban view? Your records will be stacked based on this field.\",\n      \"hideEmptyStack\": \"Hide empty stack\",\n      \"imageSetting\": \"Image setting\",\n      \"fit\": \"Fit\",\n      \"noImage\": \"No image\",\n      \"chooseAttachmentField\": \"Choose attachment field\"\n    },\n    \"stack\": {\n      \"addStack\": \"Add stack\",\n      \"noCards\": \"No cards\",\n      \"uncategorized\": \"Uncategorized\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Collapse stack\",\n      \"renameStack\": \"Rename stack\",\n      \"deleteStack\": \"Delete stack\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Insert card above\",\n      \"insertCardBelow\": \"Insert card below\",\n      \"expandCard\": \"Expand card\",\n      \"deleteCard\": \"Delete card\",\n      \"duplicateCard\": \"Duplicate card\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Calendar config\",\n      \"startDateField\": \"Start date field\",\n      \"endDateField\": \"End date field\",\n      \"titleField\": \"Title field\",\n      \"colorField\": \"Color field\",\n      \"colorType\": \"Color display\",\n      \"customColor\": \"Customize color\",\n      \"alignWithRecords\": \"Align with records\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Select a color field\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Start date\",\n      \"endDate\": \"End date\",\n      \"notAdd\": \"Not add\",\n      \"addDateField\": \"Add date fields\",\n      \"content\": \"Create a calendar view schedule, and the table needs to have two date fields: start date and end date\"\n    },\n    \"moreLinkText\": \"Show all {{count}} records\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Insert <input /> record above\",\n    \"insertRecordBelow\": \"Insert <input /> record below\",\n    \"copyCells\": \"Copy cells\",\n    \"deleteRecord\": \"Delete record\",\n    \"deleteAllSelectedRecords\": \"Delete all selected records\",\n    \"editField\": \"Edit field\",\n    \"duplicateField\": \"Duplicate field\",\n    \"insertFieldLeft\": \"Insert left\",\n    \"insertFieldRight\": \"Insert right\",\n    \"freezeUpField\": \"Freeze up to this field\",\n    \"hideField\": \"Hide field\",\n    \"deleteField\": \"Delete field\",\n    \"deleteAllSelectedFields\": \"Delete all selected fields\",\n    \"filterField\": \"Filter by this field\",\n    \"sortField\": \"Sort by this field\",\n    \"groupField\": \"Group by this field\",\n    \"downloadAllAttachments\": \"Download all files\",\n    \"autoFill\": \"Generate\",\n    \"groupMenuTitle\": \"Group menu\",\n    \"expandGroup\": \"Expand this group and its sub-groups\",\n    \"collapseGroup\": \"Collapse this group and its sub-groups\",\n    \"expandAllGroups\": \"Expand all groups\",\n    \"collapseAllGroups\": \"Collapse all groups\",\n    \"addToChat\": \"Add to Chat\"\n  },\n  \"connection\": {\n    \"title\": \"Database connection\",\n    \"description\": \"You can access the database directly through the database connection, including all the tables under the current base\",\n    \"noPermission\": \"You don't have permission to access the database\",\n    \"connectionCountTip\": \"The maximum number of database connections is <b>{{max}}</b> and the current connections is <b>{{current}}</b>\",\n    \"createFailed\": \"Please ensure that the PUBLIC_DATABASE_PROXY environment variable is set correctly\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Add record\",\n    \"searchView\": \"Search view...\",\n    \"dragToolTip\": \"Automatic sorting is turned on, manual drag is not available\",\n    \"insertToolTip\": \"Automatic sorting is turned on, insert with order is not available\",\n    \"action\": {\n      \"rename\": \"Rename view\",\n      \"duplicate\": \"Duplicate view\",\n      \"delete\": \"Delete view\",\n      \"lock\": \"Lock view\",\n      \"unlock\": \"Unlock view\",\n      \"enable\": \"Enable\"\n    },\n    \"category\": {\n      \"table\": \"Grid view\",\n      \"form\": \"Form view\",\n      \"kanban\": \"Kanban view\",\n      \"gallery\": \"Gallery view\",\n      \"calendar\": \"Calendar view\"\n    },\n    \"crash\": {\n      \"title\": \"Crash!\",\n      \"description\": \"This view is broken. If the refresh still fails, please contact us. support@teable.ai\"\n    },\n    \"addPluginView\": \"Add plugin view\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"fields({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"This view is locked. You can enable personal mode to edit view options, and the changes will only take effect for yourself.\"\n    },\n    \"noView\": \"No view\"\n  },\n  \"lastModifiedTime\": \"Last modified time\",\n  \"lastModify\": \"Last modify: \",\n  \"pasteNewRecords\": {\n    \"title\": \"Do you want to add multiple records?\",\n    \"description\": \"The {{count}} records will be added to the table.\"\n  },\n  \"tableTrash\": {\n    \"title\": \"Table trash\",\n    \"resourceType\": \"Resource type\",\n    \"deletedResource\": \"Deleted resource\"\n  },\n  \"plugin\": {\n    \"recent\": \"Recents\",\n    \"more\": \"Explore more...\"\n  },\n  \"pluginPanel\": {\n    \"empty\": {\n      \"description\": \"No plugin panels have been added yet.\"\n    },\n    \"createPluginPanel\": {\n      \"button\": \"Create a plugin panel\",\n      \"title\": \"Create a plugin panel\"\n    },\n    \"namePlaceholder\": \"Enter a plugin panel name\"\n  },\n  \"addPlugin\": \"Add plugin\",\n  \"pluginContextMenu\": {\n    \"mangeButton\": \"Manage plugins\",\n    \"manage\": \"Manage your Context menu plugins\",\n    \"noPlugin\": \"No Context menu plugins\",\n    \"delete\": \"Delete\",\n    \"deleteDescription\": \"Are you sure you want to delete this plugin?\"\n  },\n  \"permission\": {\n    \"cell\": {\n      \"deniedRead\": \"You don't have permission to view this cell\",\n      \"deniedUpdate\": \"You don't have permission to update this cell\"\n    }\n  },\n  \"baseShare\": {\n    \"title\": \"Share Base\",\n    \"shareTitle\": \"Share\",\n    \"shareToWeb\": \"Share to web\",\n    \"description\": \"Share \\\"{{baseName}}\\\" with others\",\n    \"nodeShareDescription\": \"Share \\\"{{nodeName}}\\\" and its contents\",\n    \"shareLinks\": \"Share Links\",\n    \"newLink\": \"New Link\",\n    \"noShareLinks\": \"No share links yet\",\n    \"createFirstLink\": \"Create share link\",\n    \"editSettings\": \"Edit Settings\",\n    \"refreshLink\": \"Regenerate Link\",\n    \"deleteLink\": \"Delete Link\",\n    \"deleteConfirmTitle\": \"Delete Share Link\",\n    \"deleteConfirmDescription\": \"This action cannot be undone. People with this link will no longer be able to access the shared base.\",\n    \"createSuccess\": \"Share link created\",\n    \"createFailed\": \"Failed to create share link\",\n    \"updateSuccess\": \"Share settings updated\",\n    \"updateFailed\": \"Failed to update share settings\",\n    \"deleteSuccess\": \"Share link deleted\",\n    \"deleteFailed\": \"Failed to delete share link\",\n    \"refreshSuccess\": \"Share link regenerated\",\n    \"refreshFailed\": \"Failed to regenerate share link\",\n    \"copied\": \"Link copied to clipboard\",\n    \"shareLink\": \"Share link\",\n    \"linkHolderLabel\": \"The person who obtained the link\",\n    \"linkHolderCanView\": \"Can view\",\n    \"linkHolderCanEdit\": \"Can edit\",\n    \"linkHolderCanCopyAndSave\": \"Can save as copy\",\n    \"passwordProtection\": \"Password Protection\",\n    \"enterPassword\": \"Enter password\",\n    \"selectNodes\": \"Select Tables\",\n    \"shareEntireBase\": \"Share entire base\",\n    \"shareSelectedNodes\": \"Share selected nodes\",\n    \"shareEntireBaseDescription\": \"New tables and folders added to this base will be automatically shared\",\n    \"noNodesSelectedWarning\": \"Please select at least one node to share\",\n    \"allowSave\": \"Allow Save to Space\",\n    \"allowSaveDescription\": \"Allow viewers to save a copy of this base to their own space\",\n    \"allowCopy\": \"Allow Copy Data\",\n    \"allowCopyData\": \"Allow viewers to copy data\",\n    \"allowDuplicate\": \"Allow viewers to duplicate\",\n    \"allowCopyDescription\": \"Allow viewers to copy table data from this shared base\",\n    \"selectedNodes\": \"{{count}} tables selected\",\n    \"allNodes\": \"All tables\",\n    \"sharedNode\": \"Shared node\",\n    \"sharedNodeDescription\": \"This share link is for a specific node and its descendants\",\n    \"publicShareTitle\": \"Public share by link\",\n    \"publicShareCount\": \"{{count}} public share link(s)\",\n    \"noPublicShare\": \"No public share links\",\n    \"security\": \"Security\",\n    \"restrictByPassword\": \"Restrict by password\",\n    \"advanced\": \"Advanced\",\n    \"embedConfig\": \"Embed config\",\n    \"appPublicLink\": \"App public link\",\n    \"appNotPublished\": \"This app is not published yet. Please publish the app first before sharing.\",\n    \"goToPublish\": \"Go to publish\",\n    \"publishSuccess\": \"App published successfully\",\n    \"publishFailed\": \"Failed to publish app\",\n    \"openLink\": \"Open link\",\n    \"appPublished\": \"App published\",\n    \"shareTableTab\": \"Share table\",\n    \"shareViewTab\": \"Share view\",\n    \"shareNodeTab\": \"Share node\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Get table fields\",\n      \"getTablesMeta\": \"Get tables meta\",\n      \"sqlQuery\": \"Execute SQL query\",\n      \"generateScriptAction\": \"Generate script action\",\n      \"getScriptInput\": \"Get script input\",\n      \"getTeableApi\": \"Get API\",\n      \"dataVisualization\": \"Data visualization\",\n      \"updateBase\": \"Update database info\",\n      \"args\": \"Arguments\",\n      \"result\": \"Result\",\n      \"thinking\": \"Thinking...\",\n      \"toBeConfirmed\": \"To be confirmed\",\n      \"errorMessage\": \"Error message\",\n      \"confirm\": \"Confirm\",\n      \"createRecordsSuccess\": \"Successfully created {{count}} record(s)\",\n      \"createRecordsFailed\": \"Failed to create records\",\n      \"updateRecordsSuccess\": \"Successfully updated {{count}} record(s)\",\n      \"updateRecordsFailed\": \"Failed to update records\",\n      \"generatingRecords\": \"Generating {{count}} record(s)...\",\n      \"creatingRecords\": \"Creating {{count}} record(s)...\",\n      \"updatingRecords\": \"Updating {{count}} record(s)...\",\n      \"recordsPreview\": \"Records preview\",\n      \"andMoreRecords\": \"And {{count}} more...\",\n      \"unknownError\": \"Unknown error\",\n      \"recordIds\": \"Record IDs\",\n      \"records\": \"record(s)\",\n      \"viewAll\": \"View All\",\n      \"showLess\": \"Show Less\",\n      \"generatingData\": \"Generating data...\",\n      \"generatingUpdates\": \"Generating updates...\",\n      \"recordsGenerated\": \"{{count}} record(s) generated\",\n      \"recordsCount\": \"Records({{count}})\",\n      \"fieldsCount\": \"Fields({{count}})\",\n      \"fieldsGenerated\": \"{{count}} field(s) generated\",\n      \"updatedProperties\": \"Updated ({{count}})\",\n      \"configured\": \"configured\",\n      \"recordsToUpdate\": \"{{count}} record(s) to update\",\n      \"showingLast\": \"last {{count}}\",\n      \"recordLabel\": \"Record\",\n      \"statusGenerating\": \"Generating...\",\n      \"statusCreating\": \"Creating...\",\n      \"statusUpdating\": \"Updating...\",\n      \"statusCreated\": \"Created\",\n      \"statusUpdated\": \"Updated\",\n      \"getApps\": {\n        \"title\": \"App List\",\n        \"loading\": \"Loading apps...\",\n        \"foundApps\": \"Found {{count}} app(s)\",\n        \"noApps\": \"No apps found in this base\",\n        \"openApp\": \"Open App\"\n      },\n      \"generateApp\": {\n        \"title\": \"App Builder\",\n        \"creatingApp\": \"Creating app\",\n        \"updatingApp\": \"Updating app\",\n        \"generatingApp\": \"Generating app\",\n        \"generating\": \"Generating...\",\n        \"openApp\": \"Open App\",\n        \"viewProgress\": \"View Progress\",\n        \"newApp\": \"New App\",\n        \"building\": \"Building...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Automation Builder\",\n        \"creatingAutomation\": \"Creating automation\",\n        \"updatingAutomation\": \"Updating automation\",\n        \"generatingAutomation\": \"Building automation\",\n        \"building\": \"Building...\",\n        \"openAutomation\": \"Open Automation\",\n        \"viewProgress\": \"View Progress\",\n        \"testResults\": \"Test Results\",\n        \"triggerTest\": \"Trigger\",\n        \"actionTest\": \"Action {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Preview\",\n        \"code\": \"Code\",\n        \"download\": \"Download\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Image (PNG)\",\n        \"copy\": \"Copy\",\n        \"copied\": \"Copied!\",\n        \"fullscreen\": \"Fullscreen\",\n        \"exitFullscreen\": \"Exit Fullscreen\",\n        \"downloadSuccess\": \"Image downloaded successfully\",\n        \"downloadFailed\": \"Failed to capture image\",\n        \"iframeFailed\": \"Failed to capture: iframe not accessible\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Load Attachment\",\n        \"loading\": \"Loading attachment\",\n        \"failed\": \"Failed to load attachment\",\n        \"empty\": \"No attachments loaded\",\n        \"modeNative\": \"AI Vision\",\n        \"modeNativeDesc\": \"Loaded into AI native context\",\n        \"modeExtracted\": \"Text Extracted\",\n        \"modeExtractedDesc\": \"File content parsed and extracted as text for analysis\",\n        \"visionLoaded\": \"Loaded for visual analysis\",\n        \"pdfLoaded\": \"Loaded for PDF analysis\",\n        \"textExtracted\": \"{{chars}} characters extracted\",\n        \"contextLoaded\": \"Loaded to AI context\",\n        \"truncated\": \"Truncated\",\n        \"preview\": \"Preview\"\n      },\n      \"textExtract\": {\n        \"title\": \"Text Extract\",\n        \"loading\": \"Extracting text\",\n        \"failed\": \"Failed to extract text\",\n        \"empty\": \"No text extracted\",\n        \"preview\": \"Preview\",\n        \"truncated\": \"Truncated\",\n        \"previews\": \"previews\",\n        \"chars\": \"{{chars}} chars\",\n        \"totalCharacters\": \"Total: {{chars}} characters\",\n        \"filesTruncated\": \"({{count}} file(s) truncated)\"\n      },\n      \"importExcel\": {\n        \"title\": \"Import Excel\",\n        \"loading\": \"Processing file...\",\n        \"failed\": \"Import failed\",\n        \"suggestions\": \"Suggestions\",\n        \"analyzeComplete\": \"Analysis Complete\",\n        \"worksheets\": \"Worksheets\",\n        \"columns\": \"columns\",\n        \"importComplete\": \"Import Complete\",\n        \"stageAnalyze\": \"Analyzing\",\n        \"stageImport\": \"Importing\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Get Teable API\",\n      \"readFiles\": \"Read Files\",\n      \"writeFile\": \"Write File\",\n      \"deleteFiles\": \"Delete Files\",\n      \"listFiles\": \"List Files\",\n      \"addDependencies\": \"Add Dependencies\",\n      \"checkBuildErrors\": \"Check Build Errors\",\n      \"lint\": \"Lint Code\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Preview load failed\",\n      \"retry\": \"Retry {{count}} time\",\n      \"chatAborted\": \"aborted\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Deleted table\",\n      \"deletedView\": \"Deleted view\",\n      \"deletedField\": \"Deleted field\",\n      \"deletedRecords\": \"Deleted records\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Table agent\",\n      \"viewOperatorAgent\": \"View agent\",\n      \"fieldOperatorAgent\": \"Field agent\",\n      \"recordOperatorAgent\": \"Record agent\",\n      \"buildBaseAgent\": \"Build base agent\",\n      \"buildAutomationAgent\": \"Build automation agent\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"To be confirmed\",\n      \"deleteWarning\": \"Are you sure you want to delete?\"\n    },\n    \"action\": {\n      \"createTable\": \"Create table\",\n      \"updateTable\": \"Update table\",\n      \"updateTableName\": \"Update table name\",\n      \"deleteTable\": \"Delete table\",\n      \"createView\": \"Create view\",\n      \"updateView\": \"Update view\",\n      \"updateViewName\": \"Update view name\",\n      \"deleteView\": \"Delete view\",\n      \"createField\": \"Create field\",\n      \"createAiField\": \"Create AI field\",\n      \"createLinkField\": \"Create link field\",\n      \"createLookupField\": \"Create lookup field\",\n      \"createRollupField\": \"Create rollup field\",\n      \"createFormulaField\": \"Create formula field\",\n      \"deleteField\": \"Delete field\",\n      \"updateField\": \"Update field\",\n      \"createRecord\": \"Create record\",\n      \"createRecords\": \"Create records\",\n      \"deleteRecord\": \"Delete record\",\n      \"updateRecord\": \"Update record\",\n      \"updateRecords\": \"Update records\",\n      \"updateBase\": \"Update database info\",\n      \"planTask\": \"Plan task...\",\n      \"generateTables\": \"Generate tables\",\n      \"generatePrimaryFields\": \"Generate primary fields\",\n      \"generateFields\": \"Generate fields\",\n      \"generateViews\": \"Generate views\",\n      \"generateRecords\": \"Generate records\",\n      \"generateAIFields\": \"Generate AI fields\",\n      \"generateLinkFields\": \"Generate link fields\",\n      \"generateLookupFields\": \"Generate lookup fields\",\n      \"generateRollupFields\": \"Generate rollup fields\",\n      \"generateFormulaFields\": \"Generate formula fields\",\n      \"generateWorkflow\": \"Generate workflow\",\n      \"generateTrigger\": \"Generate trigger\",\n      \"generateScriptAction\": \"Generate script action node\",\n      \"generateSendMailAction\": \"Generate send mail action node\",\n      \"generateAction\": \"Generate action node\",\n      \"setupAutomationTrigger\": \"Setup automation trigger\",\n      \"testAutomationNode\": \"Test automation node\",\n      \"activateAutomation\": \"Activate automation\",\n      \"deleteAutomationNode\": \"Delete {{nodeType}} node\",\n      \"executeScript\": \"Execute script\",\n      \"wait\": \"Wait\",\n      \"generateScriptFlowChart\": \"Generate script flowchart\",\n      \"triggerAiFill\": \"Trigger AI fill\",\n      \"initialize\": \"Initialize environment\",\n      \"rename\": \"Generate app name\",\n      \"buildTest\": \"Build test\",\n      \"developTask\": \"Develop task\",\n      \"generateSummary\": \"Generate summary\",\n      \"previewEnvironment\": \"Prepare preview environment\",\n      \"getRelativeData\": \"Get relative data\",\n      \"getPreviousNodeOutputVariables\": \"Get previous node output variables\",\n      \"getApiJson\": \"Get API info\",\n      \"generateScriptAndDependencies\": \"Generate script and dependencies\",\n      \"analyzingAttachment\": \"Analyzing attachment...\",\n      \"locateResource\": \"Locate\",\n      \"goTo\": \"Go to\",\n      \"operationSuccess\": \"Operation completed successfully\",\n      \"operationFailed\": \"Operation failed\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} record(s) queued for AI generation\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Query records\",\n      \"getRecordsWithTable\": \"Query records · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Query fields\",\n      \"getFieldsWithTable\": \"Query fields · {{tableName}}\",\n      \"getTables\": \"Query tables\",\n      \"getViews\": \"Query views\",\n      \"getViewsWithTable\": \"Query views · {{tableName}}\",\n      \"sqlQuery\": \"SQL query\",\n      \"querying\": \"Querying...\",\n      \"queryFailed\": \"Query failed\",\n      \"aborted\": \"Aborted\",\n      \"noData\": \"No data returned\",\n      \"dataFormatError\": \"Data format error\",\n      \"unsupportedQueryType\": \"Unsupported query type: {{toolName}}\",\n      \"returnedRecords\": \"Returned {{count}} record(s)\",\n      \"record\": \"Record {{index}}\",\n      \"moreRecords\": \"... +{{count}} more record(s)\",\n      \"foundFields\": \"Found {{count}} field(s)\",\n      \"moreFields\": \"... +{{count}} more field(s)\",\n      \"foundTables\": \"Found {{count}} table(s)\",\n      \"moreTables\": \"... +{{count}} more table(s)\",\n      \"foundViews\": \"Found {{count}} view(s)\",\n      \"moreViews\": \"... +{{count}} more view(s)\",\n      \"queryReturned\": \"Query returned {{rowCount}} row(s) × {{columnCount}} column(s)\",\n      \"row\": \"Row {{index}}\",\n      \"moreRows\": \"... +{{count}} more row(s)\",\n      \"getDoc\": \"Get Doc\",\n      \"getDocWithTopic\": \"Get Doc · {{topic}}\",\n      \"getAutomations\": \"Query automations\",\n      \"getAutomation\": \"Query automation\",\n      \"getAutomationRuns\": \"Query automation runs\",\n      \"foundAutomations\": \"Found {{count}} automation(s)\",\n      \"moreAutomations\": \"... +{{count}} more automation(s)\",\n      \"foundRuns\": \"Found {{count}} run(s)\",\n      \"moreRuns\": \"... +{{count}} more run(s)\",\n      \"active\": \"Active\",\n      \"trigger\": \"Trigger\",\n      \"actions\": \"{{count}} action(s)\",\n      \"moreActions\": \"... +{{count}} more action(s)\",\n      \"getUserIntegrations\": \"Check integrations\",\n      \"connectedIntegrations\": \"Connected\",\n      \"availableToConnect\": \"Available to connect\",\n      \"connect\": \"Connect\",\n      \"noIntegrationsAvailable\": \"No integrations available\",\n      \"activateTool\": \"Activate tools\",\n      \"webSearch\": \"Web search\",\n      \"webSearchResults\": \"Found {{count}} result(s)\",\n      \"webSearchCompleted\": \"Search completed\",\n      \"searchApi\": \"Search API\",\n      \"searchApiWithQuery\": \"Search API · {{query}}\",\n      \"noApiFound\": \"No APIs found\",\n      \"foundApis\": \"Found {{count}} API(s)\",\n      \"totalApis\": \"Total {{count}} APIs available\",\n      \"callApi\": \"Call API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Response\",\n      \"success\": \"Success\",\n      \"failed\": \"Failed\",\n      \"inputData\": \"Input data\",\n      \"availableNodes\": \"Available nodes\",\n      \"hasPreviousCode\": \"Has existing code\",\n      \"noInputData\": \"No input data available\"\n    },\n    \"showUI\": {\n      \"connect\": \"Connect\",\n      \"connecting\": \"Connecting...\",\n      \"connected\": \"Connected\",\n      \"connectToUse\": \"Connect {{provider}} to use in automations\",\n      \"checkingConnection\": \"Checking connection status...\",\n      \"confirm\": \"Confirm\",\n      \"confirmed\": \"Confirmed\",\n      \"cancel\": \"Cancel\",\n      \"cancelled\": \"Cancelled\",\n      \"connectionCancelled\": \"Connection cancelled\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} lines hidden\",\n      \"collapseCode\": \"Collapse code\",\n      \"code\": \"Code\",\n      \"preview\": \"Preview\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Build progress\",\n      \"completed\": \"App build completed\",\n      \"completedDesc\": \"All steps completed successfully, app is ready for preview\",\n      \"stepStatus\": {\n        \"initializing\": \"Creating app and initializing environment...\",\n        \"naming\": \"Generating app name...\",\n        \"planning\": \"Analyzing requirements and planning development...\",\n        \"developing\": \"Writing code and implementing features...\",\n        \"summarizing\": \"Organizing development results...\",\n        \"deploying\": \"Deploying to preview environment...\",\n        \"testing\": \"Building test...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"Running\",\n        \"completed\": \"Completed\",\n        \"error\": \"Failed\",\n        \"pending\": \"Pending\"\n      },\n      \"toolStatus\": {\n        \"running\": \"Running\",\n        \"completed\": \"Completed\",\n        \"error\": \"Failed\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Generate script successfully\"\n    },\n    \"buildBase\": {\n      \"title\": \"Build base\",\n      \"generateSuccess\": \"Generate database successfully\",\n      \"generateError\": \"Generate database failed\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Build automation\",\n      \"generateSuccess\": \"Generate automation successfully\"\n    },\n    \"automation\": {\n      \"created\": \"Created\",\n      \"updated\": \"Updated\",\n      \"workflow\": \"Workflow\",\n      \"trigger\": \"Trigger\",\n      \"scriptAction\": \"Script Action\",\n      \"workflowLabel\": \"Workflow\",\n      \"triggerLabel\": \"Trigger\",\n      \"scriptActionLabel\": \"Script Action\",\n      \"workflowId\": \"Workflow\",\n      \"triggerId\": \"Trigger\",\n      \"scriptActionId\": \"Script Action\",\n      \"viewAutomation\": \"View\",\n      \"navigateToAutomation\": \"Navigate to automation\",\n      \"triggerType\": {\n        \"recordCreated\": \"Record Created\",\n        \"recordUpdated\": \"Record Updated\",\n        \"recordCreatedOrUpdated\": \"Record Created or Updated\",\n        \"formSubmitted\": \"Form Submitted\",\n        \"scheduledTime\": \"Scheduled Time\",\n        \"buttonClick\": \"Button Click\"\n      },\n      \"activated\": \"Activated\",\n      \"deactivated\": \"Deactivated\",\n      \"discarded\": \"Changes Discarded\",\n      \"activateFailed\": \"Activate Failed\",\n      \"deactivateFailed\": \"Deactivate Failed\",\n      \"discardFailed\": \"Discard Failed\",\n      \"scriptUpdated\": \"Script Updated\",\n      \"scriptUpdateFailed\": \"Update Failed\",\n      \"scriptExecuted\": \"Script Executed\",\n      \"scriptExecutionFailed\": \"Execution Failed\",\n      \"scriptReady\": \"Script ready\",\n      \"executingScript\": \"Executing script...\",\n      \"waitedSeconds\": \"Waited {{seconds}}s\",\n      \"waitFailed\": \"Wait failed\",\n      \"flowchartGenerated\": \"Flowchart Generated\",\n      \"flowchartGenerationFailed\": \"Generation Failed\"\n    },\n    \"newChat\": \"New Chat\",\n    \"clearChat\": \"Clear Chat\",\n    \"expand\": \"Expand\",\n    \"history\": \"History\",\n    \"close\": \"Collapse\",\n    \"clearChatConfirmTitle\": \"Confirm Clear Chat\",\n    \"clearChatConfirmDesc\": \"Current chat content will not be saved. Are you sure you want to clear it?\",\n    \"dontShowAgain\": \"Don't show again\",\n    \"noModel\": \"No available models\",\n    \"addAttachment\": \"Add Attachment\",\n    \"noHistory\": \"No chat history\",\n    \"noFoundHistory\": \"No chat history found, please start a new conversation\",\n    \"timeGroup\": {\n      \"today\": \"Today\",\n      \"oneWeek\": \"One week\",\n      \"twoWeek\": \"Two week\",\n      \"oneMonth\": \"One month\",\n      \"other\": \"Other\"\n    },\n    \"context\": {\n      \"button\": \"Add context\",\n      \"search\": \"Add tables\",\n      \"searchEmpty\": \"No matching context found\",\n      \"emptyContext\": \"No context to add\",\n      \"selectionRows\": \"Rows {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Describe what you want to do\",\n    \"thought\": \"Thinking\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Generation time: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} credits consumed\",\n      \"tokenDescription\": \"Tokens used: {{tokens}}\",\n      \"input\": \"Input\",\n      \"output\": \"Output\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"Total time cost\",\n      \"totalCreditCost\": \"Total credit cost\",\n      \"customModel\": \"Custom model\",\n      \"tokenDetails\": \"Token Details\",\n      \"cachedInput\": \"Cached Input (90% off)\",\n      \"cacheWrite\": \"Cache Write\",\n      \"reasoning\": \"Reasoning (Thinking)\",\n      \"taskCompleted\": \"Task completed\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Data visualization failed\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Only administrators can configure\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"Image is not supported\",\n      \"attachmentSizeExceeded\": \"Attachment size exceeds the limit {{size}}MB\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Recommended\",\n      \"ask\": \"Ask\",\n      \"analyze\": \"Analyze\",\n      \"build\": \"Build\",\n      \"title\": \"How can I help?\",\n      \"whatCanIDo\": \"What can I do?\",\n      \"createOrModifyDatabase\": \"Create or modify database\",\n      \"buildAutomations\": \"Build automations\",\n      \"buildApps\": \"Build apps\",\n      \"buildMeCRM\": \"Build me a CRM\",\n      \"addAIField\": \"Add an AI field to analyze each customer\",\n      \"createDataAnalysis\": \"Create me a data analysis report\",\n      \"emailWhenRecordCreated\": \"Email me when record created\",\n      \"syncStatusToSlack\": \"Sync status updates to Slack\",\n      \"buildDashboard\": \"Build a dashboard from this table\",\n      \"buildLeadCapture\": \"Build me a lead capture landing page\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Thought for {{duration}}s\"\n      },\n      \"task\": {\n        \"searching\": \"Searching \\\"{{query}}\\\"\",\n        \"readingFiles\": \"Reading files:\",\n        \"foundResults\": \"Found {{count}} results\",\n        \"noIssuesFound\": \"No issues found\",\n        \"defaultTitle\": \"Task\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Code Project\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"AI model is required to generate preview.\",\n      \"writeCodeHint\": \"Write some code to see the flowchart preview.\",\n      \"noPreview\": \"No flowchart preview available.\",\n      \"generatePreview\": \"Generate Preview\",\n      \"analyzing\": \"Analyzing script...\",\n      \"codeChanged\": \"Code changed since this preview was generated.\",\n      \"regenerate\": \"Regenerate\",\n      \"refresh\": \"Refresh\",\n      \"regenerating\": \"Regenerating...\"\n    }\n  },\n  \"upload\": {\n    \"panelUploading\": \"Uploading {{count}} files\",\n    \"panelFailed\": \"{{count}} files failed\",\n    \"panelCompleted\": \"{{count}} files completed\",\n    \"statusFailed\": \"Failed\",\n    \"statusCompleted\": \"Completed\",\n    \"statusCancel\": \"Cancel upload\",\n    \"statusRetry\": \"Retry upload\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/token.json",
    "content": "{\n  \"access\": \"Access\",\n  \"name\": \"Name\",\n  \"description\": \"Description\",\n  \"scopes\": \"Scopes\",\n  \"expiration\": \"Expiration\",\n  \"createdTime\": \"Created\",\n  \"lastUse\": \"Last use\",\n  \"allSpace\": \"The space, all current and future bases in this space\",\n  \"formLabelTips\": {\n    \"name\": \"Provide a token name\",\n    \"description\": \"What is this token for?\",\n    \"scopes\": \"With this token, you will be able to:\",\n    \"access\": \"This token can access the following bases and spaces. You can only grant access to bases and spaces you have access to.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Create new token\",\n    \"title\": \"Personal access tokens are required to use the Teable API.\",\n    \"description\": \"This token will grant access to the data in the selected spaces and bases. And other, non-space/base API endpoints. Please only use this token for your own development. Please be cautious when sharing it with third-party services and applications.\",\n    \"button\": \"Create new token\",\n    \"success\": {\n      \"title\": \"Token successfully generated\",\n      \"description\": \"Make sure to copy your token. It will never be displayed again.\"\n    },\n    \"expirationList\": {\n      \"days\": \"days\",\n      \"permanent\": \"Permanent\",\n      \"custom\": \"Custom\",\n      \"pick\": \"Pick a date\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Edit token\",\n    \"name\": \"Name\",\n    \"scopes\": \"Scopes\",\n    \"selectAll\": \"Select all\",\n    \"cancelSelectAll\": \"Cancel select all\"\n  },\n  \"refresh\": {\n    \"title\": \"Regenerate personal access token\",\n    \"description\": \"Submitting this form will generate a new token. Be aware that any scripts or applications using this token will need to be updated\",\n    \"button\": \"Regenerate token\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Add base or space\",\n    \"empty\": \"No access found.\",\n    \"spaceSelectItem\": \"All base in space\",\n    \"inputPlaceholder\": \"Find space or base...\",\n    \"fullAccess\": {\n      \"button\": \"Add all resources\",\n      \"description\": \"All current and future spaces and bases\",\n      \"title\": \"All resources\"\n    },\n    \"sharedBase\": \"Shared with me\"\n  },\n  \"moreScopes\": \"and {{len}} more\",\n  \"list\": {\n    \"description\": \"Personal access tokens are required to use the Teable API. Please refer to the <a>help document</a> for more information.\"\n  },\n  \"empty\": {\n    \"list\": \"No personal access tokens found.\",\n    \"access\": \"No access\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Are you sure you want to delete this token?\",\n    \"description\": \"Any applications or scripts using this token will no longer be able to access the Teable API. You cannot undo this action.\"\n  },\n  \"noAccessConfirm\": {\n    \"title\": \"Are you sure to continue?\",\n    \"description\": \"This token will not be able to access any data in any base.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/en/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Expected {{expected}}, received {{received}}\",\n    \"invalid_type_received_undefined\": \"Required\",\n    \"invalid_type_received_null\": \"Required\",\n    \"invalid_literal\": \"Invalid literal value, expected {{expected}}\",\n    \"unrecognized_keys\": \"Unrecognized key(s) in object: {{- keys}}\",\n    \"invalid_union\": \"Invalid input\",\n    \"invalid_union_discriminator\": \"Invalid discriminator value. Expected {{- options}}\",\n    \"invalid_enum_value\": \"Invalid enum value. Expected {{- options}}, received '{{received}}'\",\n    \"invalid_arguments\": \"Invalid function arguments\",\n    \"invalid_return_type\": \"Invalid function return type\",\n    \"invalid_date\": \"Invalid date\",\n    \"custom\": \"Invalid input\",\n    \"invalid_intersection_types\": \"Intersection results could not be merged\",\n    \"not_multiple_of\": \"Number must be a multiple of {{multipleOf}}\",\n    \"not_finite\": \"Number must be finite\",\n    \"invalid_string\": {\n      \"email\": \"Invalid {{validation}}\",\n      \"url\": \"Invalid {{validation}}\",\n      \"uuid\": \"Invalid {{validation}}\",\n      \"cuid\": \"Invalid {{validation}}\",\n      \"regex\": \"Invalid\",\n      \"datetime\": \"Invalid {{validation}}\",\n      \"startsWith\": \"Invalid input: must start with \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Invalid input: must end with \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Array must contain exactly {{minimum}} element(s)\",\n        \"inclusive\": \"Array must contain at least {{minimum}} element(s)\",\n        \"not_inclusive\": \"Array must contain more than {{minimum}} element(s)\"\n      },\n      \"string\": {\n        \"exact\": \"String must contain exactly {{minimum}} character(s)\",\n        \"inclusive\": \"String must contain at least {{minimum}} character(s)\",\n        \"not_inclusive\": \"String must contain over {{minimum}} character(s)\"\n      },\n      \"number\": {\n        \"exact\": \"Number must be exactly {{minimum}}\",\n        \"inclusive\": \"Number must be greater than or equal to {{minimum}}\",\n        \"not_inclusive\": \"Number must be greater than {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Invalid input\",\n        \"inclusive\": \"Invalid input\",\n        \"not_inclusive\": \"Invalid input\"\n      },\n      \"date\": {\n        \"exact\": \"Date must be exactly {{- minimum, datetime}}\",\n        \"inclusive\": \"Date must be greater than or equal to {{- minimum, datetime}}\",\n        \"not_inclusive\": \"Date must be greater than {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Array must contain exactly {{maximum}} element(s)\",\n        \"inclusive\": \"Array must contain at most {{maximum}} element(s)\",\n        \"not_inclusive\": \"Array must contain less than {{maximum}} element(s)\"\n      },\n      \"string\": {\n        \"exact\": \"String must contain exactly {{maximum}} character(s)\",\n        \"inclusive\": \"String must contain at most {{maximum}} character(s)\",\n        \"not_inclusive\": \"String must contain under {{maximum}} character(s)\"\n      },\n      \"number\": {\n        \"exact\": \"Number must be exactly {{maximum}}\",\n        \"inclusive\": \"Number must be less than or equal to {{maximum}}\",\n        \"not_inclusive\": \"Number must be less than {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Invalid input\",\n        \"inclusive\": \"Invalid input\",\n        \"not_inclusive\": \"Invalid input\"\n      },\n      \"date\": {\n        \"exact\": \"Date must be exactly {{- maximum, datetime}}\",\n        \"inclusive\": \"Date must be smaller than or equal to {{- maximum, datetime}}\",\n        \"not_inclusive\": \"Date must be smaller than {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"function\",\n    \"number\": \"number\",\n    \"string\": \"string\",\n    \"nan\": \"nan\",\n    \"integer\": \"integer\",\n    \"float\": \"float\",\n    \"boolean\": \"boolean\",\n    \"date\": \"date\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"undefined\",\n    \"symbol\": \"symbol\",\n    \"null\": \"null\",\n    \"array\": \"array\",\n    \"object\": \"object\",\n    \"unknown\": \"unknown\",\n    \"promise\": \"promise\",\n    \"void\": \"void\",\n    \"never\": \"never\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"Iniciar sesión\",\n    \"signup\": \"Registrarse\"\n  },\n  \"title\": {\n    \"signin\": \"Inicia sesión en tu cuenta\",\n    \"signup\": \"Crea una cuenta para iniciar sesión\"\n  },\n  \"content\": {\n    \"title\": \"Donde los Datos Fluyen, los Equipos Crecen\",\n    \"description\": \"Una base de datos diseñada para cada equipo, desde tablas simples hasta soluciones empresariales\"\n  },\n  \"legal\": {\n    \"tip\": \"Al continuar, acepta los <Terms>Términos de servicio</Terms> y la <Privacy>Política de privacidad</Privacy> de Teable, y recibirás correos periódicos con actualizaciones.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"button\": {\n    \"signin\": \"Iniciar sesión\",\n    \"signup\": \"Registrarse\",\n    \"resend\": \"Reenviar\"\n  },\n  \"label\": {\n    \"email\": \"Correo electrónico\",\n    \"password\": \"Contraseña\",\n    \"verificationCode\": \"Código de verificación\"\n  },\n  \"placeholder\": {\n    \"password\": \"Ingresa tu contraseña...\",\n    \"email\": \"Ingresa tu correo electrónico...\",\n    \"verificationCode\": \"Ingresa tu código de verificación...\"\n  },\n  \"signError\": {\n    \"exist\": \"El correo electrónico ya está registrado\",\n    \"incorrect\": \"El correo electrónico o la contraseña son incorrectos\",\n    \"tooManyRequests\": \"Tu cuenta ha sido bloqueada, por favor intenta nuevamente después de {{minutes}} minutos\",\n    \"turnstileRequired\": \"Por favor completa el desafío de verificación\",\n    \"turnstileError\": \"Verificación fallida. Por favor intenta nuevamente\",\n    \"turnstileExpired\": \"Verificación expirada. Por favor intenta nuevamente\",\n    \"turnstileTimeout\": \"Verificación agotada. Por favor intenta nuevamente\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"El código de verificación es requerido\",\n    \"verificationCodeInvalid\": \"El código de verificación es inválido\",\n    \"passwordLength\": \"Mínimo 8 caracteres\",\n    \"passwordInvalid\": \"La contraseña debe contener al menos una letra y un número\"\n  },\n  \"socialAuth\": {\n    \"title\": \"O continuar con\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Establece tu Contraseña\",\n    \"description\": \"Ingresa una nueva contraseña\",\n    \"label\": \"Nueva contraseña\",\n    \"error\": {\n      \"requiredPassword\": \"Ingresa una contraseña\",\n      \"invalidLink\": \"El enlace para restablecer la contraseña es inválido\"\n    },\n    \"success\": {\n      \"title\": \"¡Contraseña actualizada!\",\n      \"description\": \"Tu contraseña ha sido actualizada exitosamente\"\n    },\n    \"buttonText\": \"Confirmar contraseña\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"¿Has olvidado tu contraseña?\",\n    \"header\": \"Restablecer su contraseña\",\n    \"description\": \"Ingrese su dirección de correo electrónico a continuación y le enviaremos un enlace para restablecer su contraseña.\",\n    \"errorRequiredEmail\": \"Se requiere correo electrónico\",\n    \"errorInvalidEmail\": \"Correo electrónico no válido\",\n    \"buttonText\": \"Enviar correo electrónico de reinicio\",\n    \"success\": {\n      \"title\": \"🎉 Restablecer el correo electrónico de contraseña enviado\",\n      \"description\": \"Le hemos enviado un correo electrónico con un enlace para restablecer su contraseña. \"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/chart.json",
    "content": "{\n  \"notBaseId\": \"Falta baseId\",\n  \"notPositionId\": \"Falta positionId\",\n  \"notPluginInstallId\": \"Falta pluginInstallId\",\n  \"initBridge\": \"Inicializando puente...\",\n  \"actions\": {\n    \"cancel\": \"Cancelar\",\n    \"save\": \"Guardar\"\n  },\n  \"queryTitle\": \"Configuración de consulta de datos\",\n  \"notSupport\": \"No compatible\",\n  \"chart\": {\n    \"bar\": \"Barras\",\n    \"line\": \"Líneas\",\n    \"pie\": \"Circular\",\n    \"area\": \"Área\",\n    \"table\": \"Tabla\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Seleccionar tipo de gráfico\",\n      \"label\": \"Tipo de gráfico\"\n    },\n    \"pie\": {\n      \"dimension\": \"Dimensión\",\n      \"measure\": \"Medida\",\n      \"showTotal\": \"Mostrar total\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"Eje X\",\n        \"placeholder\": \"Seleccionar eje X\"\n      },\n      \"yAxis\": {\n        \"label\": \"Eje Y\",\n        \"placeholder\": \"Seleccionar eje Y\",\n        \"position\": \"Posición del eje Y\"\n      },\n      \"xDisplay\": {\n        \"label\": \"Mostrar X\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Mostrar Y\"\n      },\n      \"addXAxis\": \"Añadir desglose de series\",\n      \"addYAxis\": \"Añadir otra serie\",\n      \"stack\": \"Apilar\",\n      \"position\": {\n        \"label\": \"Posición\",\n        \"auto\": \"Automático\",\n        \"left\": \"Izquierda\",\n        \"right\": \"Derecha\"\n      },\n      \"goalLine\": {\n        \"label\": \"Línea objetivo\"\n      },\n      \"range\": {\n        \"label\": \"Rango\",\n        \"min\": \"Mínimo\",\n        \"max\": \"Máximo\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Estilo de línea\",\n        \"normal\": \"Normal\",\n        \"linear\": \"Lineal\",\n        \"step\": \"Escalón\"\n      },\n      \"displayType\": \"Tipo de visualización\"\n    },\n    \"typeError\": \"Formulario: Tipo de gráfico no compatible\",\n    \"updateQuery\": \"Actualizar consulta\",\n    \"queryError\": \"Error de consulta\",\n    \"querySuccess\": \"Consulta configurada\",\n    \"decimal\": \"Decimal\",\n    \"prefix\": \"Prefijo\",\n    \"suffix\": \"Sufijo\",\n    \"showLabel\": \"Mostrar etiquetas de valores en el gráfico\",\n    \"showLegend\": \"Mostrar leyenda\",\n    \"value\": \"Valor\",\n    \"label\": \"Etiqueta\",\n    \"padding\": {\n      \"label\": \"Relleno\",\n      \"top\": \"Arriba\",\n      \"right\": \"Derecha\",\n      \"bottom\": \"Abajo\",\n      \"left\": \"Izquierda\"\n    },\n    \"tableConfig\": \"Configuración de tabla\",\n    \"width\": \"Ancho\"\n  },\n  \"reloadQuery\": \"Recargar consulta\",\n  \"noStorage\": \"Por favor configure primero el plugin de gráficos\",\n  \"noPermission\": \"Sin permisos de acceso\",\n  \"goConfig\": \"Ir a configuración\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Acciones\",\n    \"add\": \"Agregar\",\n    \"save\": \"Guardar\",\n    \"doNotSave\": \"No guardar\",\n    \"submit\": \"Enviar\",\n    \"confirm\": \"Confirmar\",\n    \"close\": \"Cerrar\",\n    \"edit\": \"Editar\",\n    \"fill\": \"Completar\",\n    \"update\": \"Actualizar\",\n    \"create\": \"Crear\",\n    \"delete\": \"Eliminar\",\n    \"cancel\": \"Cancelar\",\n    \"zoomIn\": \"Acercar\",\n    \"zoomOut\": \"Alejar\",\n    \"back\": \"Volver\",\n    \"remove\": \"Eliminar\",\n    \"removeConfig\": \"Eliminar configuración\",\n    \"saveSucceed\": \"¡Guardado con éxito!\",\n    \"submitSucceed\": \"¡Enviado con éxito!\",\n    \"editSucceed\": \"¡Editado con éxito!\",\n    \"updateSucceed\": \"¡Actualizado con éxito!\",\n    \"deleteSucceed\": \"¡Eliminado con éxito!\",\n    \"resetSucceed\": \"¡Papelera vaciada con éxito!\",\n    \"restoreSucceed\": \"¡Restaurado con éxito!\",\n    \"loading\": \"Cargando...\",\n    \"refreshPage\": \"Refrescar página\",\n    \"yesDelete\": \"Sí, eliminar\",\n    \"rename\": \"Renombrar\",\n    \"duplicate\": \"Duplicar\",\n    \"change\": \"Cambiar\",\n    \"upgrade\": \"Actualizar\",\n    \"upgradeToLevel\": \"Actualizar a {{level}}\",\n    \"search\": \"Buscar\",\n    \"loadMore\": \"Cargar más\",\n    \"collapseSidebar\": \"Colapsar barra lateral\",\n    \"restore\": \"Restaurar\",\n    \"permanentDelete\": \"Eliminar permanentemente\",\n    \"globalSearch\": \"Búsqueda global\",\n    \"fieldSearch\": \"Buscar campo\",\n    \"tableIndex\": \"Índice\",\n    \"showAllRow\": \"Mostrar todas las filas\",\n    \"hideNotMatchRow\": \"Ocultar filas sin coincidencia\",\n    \"more\": \"Más\",\n    \"move\": \"Mover a\",\n    \"turnOn\": \"Activar\",\n    \"exit\": \"Cerrar sesión\",\n    \"next\": \"Siguiente\",\n    \"previous\": \"Anterior\",\n    \"select\": \"Seleccionar\",\n    \"view\": \"Ver\",\n    \"preview\": \"Vista previa\",\n    \"viewAndEdit\": \"Ver y editar\",\n    \"deleteTip\": \"¿Está seguro de que desea eliminar \\\"{{name}}\\\"?\",\n    \"refresh\": \"Actualizar\",\n    \"login\": \"Iniciar sesión\",\n    \"useTemplate\": \"Usar plantilla\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Volver al Espacio\",\n    \"switchBase\": \"Cambiar Base\",\n    \"continue\": \"Continuar\",\n    \"export\": \"Exportar\",\n    \"import\": \"Importar\",\n    \"expand\": \"Expandir\",\n    \"getMore\": \"Obtener más\",\n    \"copySuccess\": \"Copia exitosa\"\n  },\n  \"quickAction\": {\n    \"title\": \"Acciones Rápidas\",\n    \"placeHolder\": \"Escribe un comando o busca...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"La contraseña no es válida, debe tener al menos 8 caracteres y contener al menos una letra y un número.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Compartir\",\n      \"copy\": \"Copiado\"\n    },\n    \"aiTitle\": \"Construyamos juntos\",\n    \"aiGreeting\": \"¿En qué puedo ayudarte, {{name}}?\",\n    \"aiSubTitle\": \"La primera plataforma de IA donde los equipos colaboran en datos y generan aplicaciones de producción\",\n    \"guideTitle\": \"Comienza desde tu escenario\",\n    \"watchVideo\": \"Ver video\",\n    \"title\": \"Plantilla\",\n    \"description\": \"Crear una nueva base desde una plantilla\",\n    \"browseAll\": \"Ver Todas\",\n    \"templateTitle\": \"Comenzar con plantilla\",\n    \"loadMore\": \"Cargar Más\",\n    \"allTemplatesLoaded\": \"Todas las plantillas cargadas\",\n    \"createTemplate\": \"Crear Plantilla\",\n    \"useTemplateDialog\": {\n      \"title\": \"Seleccionar espacio\",\n      \"description\": \"Por favor seleccione un espacio para la plantilla\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Construye tu aplicación empresarial con Teable\",\n      \"start\": \"Iniciar\",\n      \"carouselGuides\": {\n        \"guide1\": \"Pega recibos → Pide a Teable que extraiga automáticamente los datos clave\",\n        \"guide2\": \"Construye un CRM de IA con tablas de leads y seguimiento\",\n        \"guide3\": \"Pega comentarios de clientes → Pide a Teable que encuentre insights y genere informes\",\n        \"guide4\": \"Crea un rastreador de proyectos con tareas y plazos\",\n        \"guide5\": \"Pega hoja de leads → Pide a Teable que construya un CRM inteligente\",\n        \"guide6\": \"Construye un planificador de contenido con publicaciones y fechas de publicación\",\n        \"guide7\": \"Pega currículums → Pide a Teable que organice y preseleccione candidatos\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Copiar al espacio\",\n      \"description\": \"Por favor selecciona un espacio para copiar esta base\",\n      \"baseName\": \"Nombre de la base\",\n      \"baseNamePlaceholder\": \"Ingresa el nombre de la base\",\n      \"selectSpace\": \"Seleccionar espacio\",\n      \"noSpaceDescription\": \"No hay espacios gestionables. Crea un nuevo espacio para continuar.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"No hay bases gestionables en este espacio. Selecciona \\\"Nueva base\\\".\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Configuración de instancia\",\n    \"personal\": {\n      \"title\": \"Configuración personal\"\n    },\n    \"back\": \"Volver al inicio\",\n    \"account\": {\n      \"title\": \"Mi perfil\",\n      \"tab\": \"Mi cuenta\",\n      \"updatePhoto\": \"Actualizar foto\",\n      \"updateNameDesc\": \"Apodo o primer nombre, como quieras que te llamen en Teable\",\n      \"securityTitle\": \"Seguridad de la cuenta\",\n      \"email\": \"Correo electrónico\",\n      \"password\": \"Contraseña\",\n      \"passwordDesc\": \"Establece una contraseña permanente para iniciar sesión en tu cuenta.\",\n      \"changePassword\": {\n        \"title\": \"Cambiar contraseña\",\n        \"desc\": \"Introduce tu contraseña actual y establece una nueva\",\n        \"current\": \"Contraseña actual\",\n        \"new\": \"Nueva contraseña\",\n        \"confirm\": \"Confirmar nueva contraseña\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Su nueva contraseña no coincide.\",\n        \"equal\": \"Su nueva contraseña debe ser diferente de su contraseña actual.\",\n        \"invalid\": \"Su contraseña actual no es válida.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Cambie la contraseña con éxito.\",\n        \"desc\": \"Será redirigido a la página de inicio de sesión en 2 segundos.\"\n      },\n      \"manageToken\": \"Token de Acceso\",\n      \"addPassword\": {\n        \"title\": \"Agregar contraseña\",\n        \"desc\": \"Establezca una contraseña permanente para iniciar sesión en su cuenta.\",\n        \"password\": \"Ingrese su contraseña\",\n        \"confirm\": \"Confirma tu contraseña\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Su contraseña no coincide.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Agregue la contraseña con éxito.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Eliminar cuenta\",\n        \"desc\": \"Esta acción es irreversible. Eliminará permanentemente su cuenta y todos los datos asociados.\",\n        \"error\": {\n          \"title\": \"No se puede eliminar la cuenta\",\n          \"desc\": \"Debe manejar las siguientes dependencias primero:\",\n          \"spacesError\": \"Antes de eliminar su cuenta, primero debe salir (o eliminar, luego mover a la papelera) de sus Espacios.\"\n        },\n        \"confirm\": {\n          \"title\": \"Por favor escriba <code>DELETE</code> para confirmar\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Eliminando...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Cambiar dirección de correo\",\n        \"desc\": \"Verifica tu contraseña y confirma tu nueva dirección de correo\",\n        \"current\": \"Contraseña actual\",\n        \"new\": \"Nuevo correo\",\n        \"code\": \"Código de verificación\",\n        \"getCode\": \"Enviar código\",\n        \"error\": {\n          \"invalidCode\": \"El código de verificación no es válido.\",\n          \"invalidPassword\": \"La contraseña no es válida.\",\n          \"invalidConflict\": \"El nuevo correo ya está registrado.\",\n          \"invalidSameEmail\": \"El nuevo correo es el mismo que el actual.\",\n          \"sendMailRateLimit\": \"Espera {{seconds}} segundos antes de enviar un nuevo correo\"\n        },\n        \"success\": {\n          \"title\": \"🎉 Correo cambiado con éxito.\",\n          \"desc\": \"Serás redirigido a la página de inicio de sesión en 2 segundos.\",\n          \"sendSuccess\": \"Código de verificación enviado con éxito.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Mis Notificaciones\",\n      \"label\": \"Actividad en tu espacio\",\n      \"desc\": \"Recibe correos electrónicos cuando recibas comentarios, menciones, invitaciones a páginas, recordatorios, solicitudes de acceso y cambios de propiedades.\"\n    },\n    \"setting\": {\n      \"title\": \"Mis Ajustes\",\n      \"theme\": \"Tema de interfaz\",\n      \"themeDesc\": \"Selecciona tu esquema de colores de interfaz\",\n      \"dark\": \"Oscuro\",\n      \"light\": \"Claro\",\n      \"system\": \"Sistema\",\n      \"version\": \"Versión de la aplicación\",\n      \"language\": \"Idioma\",\n      \"interactionMode\": \"Modo de interacción\",\n      \"mouseMode\": \"Modo de cursor\",\n      \"touchMode\": \"Modo táctil\",\n      \"systemMode\": \"Seguir configuración del sistema\"\n    },\n    \"nav\": {\n      \"settings\": \"Configuración\",\n      \"logout\": \"Cerrar sesión\",\n      \"contactSupport\": \"Contactar con soporte\"\n    },\n    \"integration\": {\n      \"title\": \"Integraciones\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"Integraciones de terceros\",\n        \"description\": \"Ha otorgado acceso a {{count}} aplicaciones a su cuenta.\",\n        \"lastUsed\": \"Último uso {{date}}\",\n        \"revoke\": \"Revocar\",\n        \"owner\": \"Propiedad de {{user}}\",\n        \"revokeTitle\": \"¿Está seguro de que desea revocar la autorización?\",\n        \"revokeDesc\": \"{{name}} ya no podrá acceder a la API de Teable. No puede deshacer esta acción.\",\n        \"scopeTitle\": \"Permisos\",\n        \"scopeDesc\": \"Esta aplicación podrá obtener los siguientes ámbitos:\"\n      },\n      \"userIntegration\": {\n        \"title\": \"Cuentas conectadas\",\n        \"description\": \"Conecte cuentas externas para permitir que Teable acceda a sus recursos.\",\n        \"emptyDescription\": \"Sin cuentas conectadas\",\n        \"actions\": {\n          \"reconnect\": \"Reconectar\"\n        },\n        \"slack\": {\n          \"user\": \"Usuario de Slack\",\n          \"workspace\": \"Espacio de trabajo de Slack\"\n        },\n        \"email\": {\n          \"user\": \"Usuario\",\n          \"email\": \"Correo electrónico\"\n        },\n        \"deleteTitle\": \"Eliminar cuenta conectada\",\n        \"deleteDesc\": \"¿Está seguro de que desea eliminar {{name}}?\",\n        \"create\": \"Conectar nueva cuenta\",\n        \"manage\": \"Administrar cuentas conectadas\",\n        \"searchPlaceholder\": \"Buscar cuentas conectadas\",\n        \"defaultName\": \"Integración de {{name}}\",\n        \"callback\": {\n          \"error\": \"Autorización fallida\",\n          \"title\": \"Autorización exitosa\",\n          \"desc\": \"Puede cerrar esta ventana ahora.\"\n        }\n      }\n    },\n    \"templateAdmin\": {\n      \"title\": \"Administración de plantillas\",\n      \"noData\": \"Sin datos\",\n      \"importing\": \"Importando...\",\n      \"usageCount\": \"Recuento de uso: {{count}}\",\n      \"useTemplate\": \"Usar esta plantilla\",\n      \"createdBy\": \"por {{user}}\",\n      \"backToTemplateList\": \"Volver a la lista de plantillas\",\n      \"tips\": {\n        \"errorCategoryName\": \"La categoría no existe o fue eliminada\",\n        \"needSnapshot\": \"Por favor cree una instantánea antes de publicar, y el nombre y descripción de la plantilla no pueden estar vacíos\",\n        \"needPublish\": \"Por favor publique la plantilla antes de destacarla\",\n        \"needBaseSource\": \"Por favor seleccione una fuente Base antes de crear la instantánea\",\n        \"forbiddenUpdateSystemTemplate\": \"Las plantillas del sistema no se pueden modificar\",\n        \"addCategoryTips\": \"Por favor ingrese primero un nombre de categoría en el cuadro de búsqueda.\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Comenzar\",\n          \"recommended\": \"Recomendado\",\n          \"all\": \"Todos\",\n          \"browseByCategory\": \"Explorar por categoría\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Portada\",\n        \"name\": \"Nombre\",\n        \"description\": \"Descripción\",\n        \"markdownDescription\": \"Descripción Markdown\",\n        \"category\": \"Categoría\",\n        \"isSystem\": \"Sistema\",\n        \"source\": \"Fuente\",\n        \"status\": \"Publicado\",\n        \"publishSnapshot\": \"Publicar instantánea\",\n        \"snapshotTime\": \"Hora de instantánea\",\n        \"actions\": \"Acciones\",\n        \"featured\": \"Destacado\",\n        \"createdBy\": \"Creado por\",\n        \"userNonExistent\": \"Usuario no existe\",\n        \"preview\": \"Vista previa\",\n        \"usage\": \"Uso\",\n        \"visit\": \"Visitas\"\n      },\n      \"actions\": {\n        \"title\": \"Acciones\",\n        \"publish\": \"Publicar\",\n        \"delete\": \"Eliminar\",\n        \"duplicate\": \"Duplicar\",\n        \"preview\": \"Vista previa\",\n        \"use\": \"Usar\",\n        \"pinTop\": \"Fijar arriba\",\n        \"addCategory\": \"Agregar categoría\",\n        \"selectCategory\": \"Seleccionar categoría\",\n        \"viewTemplate\": \"Ver plantilla\",\n        \"manageCategory\": \"Gestionar categorías\"\n      },\n      \"relatedTemplates\": \"Plantillas relacionadas\",\n      \"noImage\": \"Sin imagen\",\n      \"baseSelectPanel\": {\n        \"title\": \"Seleccionar fuente de plantilla\",\n        \"description\": \"Seleccione una Base como plantilla\",\n        \"confirm\": \"Confirmar\",\n        \"search\": \"Buscar...\",\n        \"cancel\": \"Cancelar\",\n        \"selectBase\": \"Seleccionar Base\",\n        \"createTemplate\": \"Crear plantilla\",\n        \"abnormalBase\": \"La base no existe o fue eliminada\"\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tabla\",\n    \"view\": \"Vista\",\n    \"space\": \"Espacio\",\n    \"base\": \"Base\",\n    \"field\": \"Campo\",\n    \"record\": \"Registro\",\n    \"dashboard\": \"Panel\",\n    \"automation\": \"Automatización\",\n    \"authorityMatrix\": \"Matriz de Autoridad\",\n    \"design\": \"Diseño\",\n    \"adminPanel\": \"Administración del sistema\",\n    \"license\": \"Licencia autoalojada\",\n    \"instanceId\": \"ID de Instancia\",\n    \"beta\": \"Beta\",\n    \"trash\": \"Papelera\",\n    \"global\": \"Global\",\n    \"organizationPanel\": \"Configuración de organización\",\n    \"unknownError\": \"Error desconocido\",\n    \"pluginPanel\": \"Panel\",\n    \"pluginContextMenu\": \"Menú contextual\",\n    \"plugin\": \"Plugins\",\n    \"copy\": \"Copia\",\n    \"credits\": \"Créditos\",\n    \"aiChat\": \"Chat de IA\",\n    \"app\": \"Aplicación\",\n    \"webSearch\": \"Búsqueda web\",\n    \"folder\": \"Carpeta\",\n    \"newAutomation\": \"Nueva automatización\",\n    \"newApp\": \"Nueva aplicación\",\n    \"newFolder\": \"Nueva carpeta\",\n    \"template\": \"Plantilla\"\n  },\n  \"level\": {\n    \"free\": \"Gratis\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"Business\",\n    \"enterprise\": \"Enterprise\"\n  },\n  \"noResult\": \"Sin resultado.\",\n  \"allNodes\": \"Todos los nodos\",\n  \"noDescription\": \"Sin descripción\",\n  \"untitled\": \"Sin título\",\n  \"name\": \"Nombre\",\n  \"description\": \"Descripción\",\n  \"required\": \"Requerido\",\n  \"characters\": \"caracteres\",\n  \"atLeastOne\": \"Reserve al menos un {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Anterior\",\n    \"next\": \"Siguiente\",\n    \"done\": \"Listo\",\n    \"skip\": \"Omitir\",\n    \"createSpaceTooltipTitle\": \"Crear un espacio\",\n    \"createSpaceTooltipContent\": \"La tiza se organiza en espacios, donde cada espacio invita a los usuarios a colaborar. \",\n    \"createBaseTooltipTitle\": \"Crear una base\",\n    \"createBaseTooltipContent\": \"Una base (abreviatura de \\\"base de datos\\\") es un lugar para almacenar datos importantes y los flujos de trabajo que dependen de ello.\",\n    \"createTableTooltipTitle\": \"Crear una mesa\",\n    \"createTableTooltipContent\": \"Las tablas están diseñadas para manejar eficientemente diversos conjuntos de datos, ofreciendo una visualización versátil de información a través de varios tipos de datos.\",\n    \"createViewTooltipTitle\": \"Crear una vista\",\n    \"createViewTooltipContent\": \"Actualmente, los usuarios pueden crear vistas de cuadrícula, galería, kanban y formulario, con vistas de calendario planificadas para futuros lanzamientos. \",\n    \"viewFilteringTooltipTitle\": \"Registros filtrantes\",\n    \"viewFilteringTooltipContent\": \"Una de las características centrales de las vistas es la capacidad de filtrar registros desde una vista de acuerdo con las condiciones que establece. \",\n    \"viewSortingTooltipTitle\": \"Clasificación de registros\",\n    \"viewSortingTooltipContent\": \"Mientras esté en una vista, puede ordenar sus registros para que aparezcan en un orden particular de acuerdo con los valores en campos específicos. \",\n    \"viewGroupingTooltipTitle\": \"Grupos de registros\",\n    \"viewGroupingTooltipContent\": \"Los registros de agrupación permiten a los creadores construir un conjunto de una o más condiciones que ayuden a clasificar el conjunto de datos que se presenta dentro de una vista particular.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"TABLE ofrece una potente API que admite casi todas las características del producto, lo que permite a los desarrolladores hacer llamadas usando un <a> token </a>.\"\n  },\n  \"token\": \"Simbólico\",\n  \"poweredBy\": \"Alimentado por <0> </0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"Compartir espacio {{spaceName}}\",\n      \"desc_one\": \"Este espacio tiene <b>{{count}} colaborador</b>. Agregar un colaborador de espacio le da acceso a todas las Bases en este espacio.\",\n      \"desc_other\": \"Este espacio tiene <b>{{count}} colaboradores</b>. Agregar un colaborador de espacio les da acceso a todas las Bases en este espacio.\",\n      \"tabEmail\": \"Invitar por correo electrónico\",\n      \"emailPlaceholder\": \"Introduce las direcciones de correo electrónico, separadas por Enter\",\n      \"tabLink\": \"Invitar por enlace\",\n      \"linkPlaceholder\": \"Cree un enlace de invitación que otorgue el acceso <0/> a cualquier persona que lo abra.\",\n      \"emailSend\": \"Enviar invitación\",\n      \"linkSend\": \"Crear enlace\",\n      \"spaceTitle\": \"Colaboradores espaciales\",\n      \"collaboratorSearchPlaceholder\": \"Encuentra un colaborador espacial por nombre o correo electrónico\",\n      \"collaboratorJoin\": \"se unió {{joinTime}}\",\n      \"collaboratorRemove\": \"Eliminar el colaborador\",\n      \"linkTitle\": \"Enlaces de invitación\",\n      \"linkCreatedTime\": \"creado {{createdTime}}\",\n      \"linkCopySuccess\": \"Enlace copiado\",\n      \"linkRemove\": \"Eliminar enlace\"\n    },\n    \"base\": {\n      \"title\": \"Compartir {{baseName}}\",\n      \"desc_one\": \"Esta Base se comparte con {{count}} colaborador.\",\n      \"desc_other\": \"Esta Base se comparte con {{count}} colaboradores.\",\n      \"baseTitle\": \"Colaboradores de Base\",\n      \"collaboratorSearchPlaceholder\": \"Encuentre un colaborador de Base por nombre o correo electrónico\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Agregar colaborador de organización\",\n      \"placeholder\": \"Seleccionar miembro o departamento de la organización\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Ayuda\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Los permisos de la página se han actualizado. \",\n  \"listEmptyTips\": \"La lista está vacía\",\n  \"billing\": {\n    \"overLimits\": \"Límites excedidos\",\n    \"overLimitsDescription\": \"Tu suscripción actual ha excedido su límite de uso. Por favor, actualiza tu plan para continuar usando esta función sin interrupciones.\",\n    \"userLimitExceededDescription\": \"La instancia actual ha alcanzado el número máximo de usuarios permitidos por tu licencia. Por favor, desactiva algunos usuarios o actualiza la licencia.\",\n    \"unavailableInPlanTips\": \"El plan de suscripción actual no admite esta función\",\n    \"unavailableConnectionTips\": \"La función de conexión de base de datos se eliminará en el futuro y solo estará disponible en la edición Enterprise y en versiones autohospedadas.\",\n    \"levelTips\": \"Este espacio se encuentra actualmente en el plan {{level}}\",\n    \"enterpriseFeature\": \"Función Enterprise\",\n    \"automationRequiresUpgrade\": \"Actualice a Enterprise Edition (EE) para habilitar la automatización\",\n    \"authorityMatrixRequiresUpgrade\": \"Actualice a Enterprise Edition (EE) para habilitar la matriz de autoridad\",\n    \"viewPricing\": \"Ver precios\",\n    \"billable\": \"Facturable\",\n    \"billableByAuthorityMatrix\": \"Facturación generada por la matriz de permisos\",\n    \"licenseExpiredGracePeriod\": \"Su licencia autohospedada ha caducado y se degradará al plan gratuito el {{expiredTime}}. Actualice su licencia de inmediato para mantener el acceso a las funciones premium.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Plan de suscripción de actualización de Space\",\n      \"description\": \"Solo puede actualizar los espacios de trabajo donde es un propietario\"\n    },\n    \"status\": {\n      \"active\": \"Activo\",\n      \"canceled\": \"Cancelado\",\n      \"incomplete\": \"Incompleto\",\n      \"incompleteExpired\": \"Caducado incompleto\",\n      \"trialing\": \"Juicio\",\n      \"pastDue\": \"Debido a\",\n      \"unpaid\": \"No pagado\",\n      \"paused\": \"Detenido\",\n      \"seatLimitExceeded\": \"Límite de asiento excedido\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Configuración de instancia\",\n      \"description\": \"Cambie la configuración de su instancia actual\",\n      \"allowSignUp\": \"Permitir crear nuevas cuentas\",\n      \"allowSignUpDescription\": \"Deshabilitar esta opción prohibirá los registros de nuevos usuarios, y el botón de registro ya no aparecerá en la página de inicio de sesión.\",\n      \"allowSpaceInvitation\": \"Permitir envío de invitaciones de espacio\",\n      \"allowSpaceInvitationDescription\": \"Deshabilitar esta opción evitará que los usuarios que no sean administradores inviten a otros a unirse a espacios. Cuando está habilitado, los nuevos usuarios invitados por correo electrónico pueden completar el registro haciendo clic en el enlace de invitación en el correo, pero los enlaces de invitación compartidos no funcionarán.\",\n      \"allowSpaceCreation\": \"Permitir que todos creen nuevos espacios\",\n      \"allowSpaceCreationDescription\": \"Deshabilitar esta opción evitará que los usuarios que no sean administradores creen nuevos espacios.\",\n      \"enableEmailVerification\": \"Habilitar la verificación de correo electrónico\",\n      \"enableEmailVerificationDescription\": \"Habilitar esta opción requerirá que los usuarios verifiquen su dirección de correo electrónico al crear una nueva cuenta.\",\n      \"enableWaitlist\": \"Habilitar lista de espera\",\n      \"enableWaitlistDescription\": \"Habilitar esta opción permitirá que los usuarios se registren solo con un código de invitación.\",\n      \"generalSettings\": \"Configuración general\",\n      \"aiSettings\": {\n        \"title\": \"Configuración de IA\",\n        \"description\": \"Configurar los ajustes de IA para esta instancia\"\n      },\n      \"brandingSettings\": {\n        \"title\": \"Configuración de marca\",\n        \"description\": \"Solo disponible en la Edición Enterprise\",\n        \"brandName\": \"Nombre de marca\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"El logo es su identidad de marca en Teable.\",\n        \"logoUpload\": \"Subir logo\",\n        \"logoUploadDescription\": \"Suba una imagen de logo, admite formato PNG, JPEG, tamaño recomendado 100x100px. Tamaño máximo de carga 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Nombre\",\n        \"nameDescription\": \"El nombre del proveedor de LLM\",\n        \"enable\": \"Habilitar AI\",\n        \"enableDescription\": \"Habilitar AI para la instancia actual, todos los usuarios podrán usar funciones de AI\",\n        \"updateLLMProvider\": \"Actualizar el proveedor de LLM\",\n        \"addProvider\": \"Agregar proveedor de LLM\",\n        \"addProviderDescription\": \"Agregue un nuevo proveedor de LLM a la lista\",\n        \"providerType\": \"Tipo de proveedor\",\n        \"baseUrl\": \"URL base\",\n        \"apiKey\": \"Llave de API\",\n        \"baseUrlDescription\": \"La URL base del proveedor de LLM\",\n        \"apiKeyDescription\": \"La clave API del proveedor de LLM\",\n        \"models\": \"Modelos\",\n        \"modelsDescription\": \"Los modelos compatibles con el proveedor de LLM\",\n        \"baseUrlRequired\": \"Se requiere URL base\",\n        \"fetchModelListError\": \"No se pudo obtener la lista de modelos\",\n        \"provider\": \"Proveedor de LLM\",\n        \"providerDescription\": \"El proveedor de LLM para usar\",\n        \"modelPreferences\": \"Preferencias de modelo\",\n        \"modelPreferencesDescription\": \"Las preferencias del modelo para el proveedor de LLM\",\n        \"embeddingModel\": \"Modelo de incrustación\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Modelo de traducción\",\n        \"translationModelDescription\": \"El modelo de traducción para usar\",\n        \"chatModel\": \"Modelo de chat\",\n        \"chatModelDescription\": \"El modelo de chat a usar, Consejo: el modelo de codificación mediano se usa por defecto para generación de fórmulas AI y funciones relacionadas\",\n        \"chatModels\": {\n          \"lg\": \"Modelo de chat avanzado\",\n          \"lgDescription\": \"Para planificación, programación y otros escenarios de tareas complejas. Recomendado: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"Capacidades de IA\",\n          \"aiField\": {\n            \"title\": \"Campo IA\",\n            \"description\": \"Funciones de campo IA incluyendo autocompletado y configuración IA en ajustes de campo\"\n          },\n          \"aiChat\": {\n            \"title\": \"Chat IA\",\n            \"description\": \"Barra lateral de Chat IA y todas las funciones de agente\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Probar modelo\",\n          \"description\": \"Probar la capacidad del modelo para manejar imágenes, PDF\",\n          \"notConfigLgModel\": \"Por favor configure primero el modelo grande\",\n          \"confirmTitle\": \"Probar capacidades del modelo\",\n          \"confirmDescription\": \"¿Desea probar las capacidades del modelo de chat grande?\",\n          \"confirm\": \"Probar modelo\",\n          \"cancel\": \"Cancelar\",\n          \"missingCapabilitiesWarning\": \"Este modelo no admite procesamiento de imágenes o PDF, lo que puede causar que algunas funciones no estén disponibles.\",\n          \"enableAITitle\": \"Habilitar AI\",\n          \"enableAIDescription\": \"Prueba de modelo completada. AI no está habilitado actualmente. ¿Desea habilitar AI para usar estas capacidades del modelo?\",\n          \"enableAI\": \"Habilitar AI\",\n          \"skipTest\": \"Omitir\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Imagen\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Búsqueda web\",\n          \"disabledWebSearch\": \"Búsqueda web deshabilitada\",\n          \"lgModelAbility\": \"Capacidad de modelo grande\"\n        },\n        \"configUpdated\": \"Configuración de AI actualizada\",\n        \"noModelFound\": \"No se encuentra ningún modelo.\",\n        \"searchModel\": \"Modelo de búsqueda ...\",\n        \"selectModel\": \"Seleccionar modelo ...\",\n        \"input\": \"Aporte {{ratio}}\",\n        \"output\": \"Producción {{ratio}}\",\n        \"inputOrOutputTip\": \"La proporción representa la tasa de cambio entre créditos y tokens, por ejemplo, \\\"1:1000\\\" significa que 1 crédito ≈ 1000 tokens.<br></br>Nota: Cada uso deducirá al menos 1 crédito. Incluso si el consumo real es menos de 1 crédito, se le cobrará como 1 crédito.\",\n        \"imageOutput\": \"Por imagen {{credits}}\",\n        \"imageOutputTip\": \"La generación de imágenes requiere créditos, cada imagen consume {{credits}} créditos\",\n        \"supportImageOutputTip\": \"Este modelo admite generación de imágenes\",\n        \"supportVisionTip\": \"Este modelo admite entrada de imagen\",\n        \"supportAudioTip\": \"Este modelo admite entrada de audio\",\n        \"supportVideoTip\": \"Este modelo admite entrada de video\",\n        \"supportDeepThinkTip\": \"Este modelo admite pensamiento profundo\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Probando...\",\n        \"testSuccess\": \"Test exitoso\",\n        \"testFailed\": \"Test fallido\",\n        \"fillRequiredFields\": \"Por favor, complete todos los campos requeridos\",\n        \"modelsRequired\": \"Por favor, complete al menos un modelo\",\n        \"noValidModel\": \"No se encontró ningún modelo válido\",\n        \"addCustomModel\": \"Agregar modelo personalizado\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Configure la clave API de Firecrawl para habilitar la función de búsqueda web, acceda a <a>configuración de Firecrawl</a> para obtener la clave API\"\n      },\n      \"app\": {\n        \"domain\": \"Dominio\",\n        \"v0ApiKey\": \"Clave de API v0\",\n        \"customDomain\": \"Dominio Personalizado (Opcional)\",\n        \"customDomainDescription\": \"Configura tu dominio personalizado para el despliegue de la aplicación, accede a <a>dominio de Vercel</a> para obtener el dominio personalizado\",\n        \"vercelToken\": \"Token API de Vercel\",\n        \"vercelTokenDescription\": \"Accede a <a>configuración de Vercel</a> para obtener el token API\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Ingrese la clave API\",\n      \"goToConfiguration\": \"Ir a configuración\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Gracias por usar teable\",\n      \"pleaseGoToConfiguration\": \"Por favor vaya a la página de configuración para completar algunas configuraciones iniciales para disfrutar de la funcionalidad completa y una mejor experiencia de usuario de teable\",\n      \"pleaseContactAdmin\": \"Por favor contacte al administrador\"\n    },\n    \"configuration\": {\n      \"title\": \"Configuración pendiente\",\n      \"description\": \"Complete estas configuraciones para obtener funciones completas\",\n      \"copyInstance\": \"Copiar ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"Variable de entorno PUBLIC_ORIGIN\",\n          \"description\": \"Su configuración de variable de entorno <strong>PUBLIC_ORIGIN</strong> no coincide con la dirección de acceso actual <underline>https://example.com</underline>, las funciones de importación xlsx, csv y campo de adjunto pueden no funcionar normalmente, se recomienda configurar a <underline>https://example.com</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Habilitar HTTPS\",\n          \"description\": \"No ha habilitado HTTPS, la función de copia a gran escala (300 líneas o más) no estará disponible, se recomienda habilitar.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"Variable de entorno PUBLIC_DATABASE_PROXY\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> no está configurado, la función de conexión de base de datos externa no estará disponible, consulte el <a>documento de ayuda</a>\",\n          \"href\": \"https://help.teable.ai/es/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Aún no ha configurado la API LLM de AI, AI Chat/automatización de AI no se podrá usar, <anchor>ir a configuración</anchor>\",\n          \"errorTips\": \"Aún no ha configurado la API LLM de AI, AI Chat/automatización de AI no se podrá usar\"\n        },\n        \"app\": {\n          \"title\": \"Constructor de aplicaciones\",\n          \"description\": \"Aún no ha configurado la API v0, la función Constructor de aplicaciones no estará disponible, <anchor>ir a configuración</anchor>\",\n          \"errorTips\": \"Aún no ha configurado la API v0, la función Constructor de aplicaciones no estará disponible\"\n        },\n        \"webSearch\": {\n          \"title\": \"Búsqueda web\",\n          \"description\": \"Aún no ha configurado la API de búsqueda web, la función de búsqueda web no estará disponible, <anchor>ir a configuración</anchor>\",\n          \"errorTips\": \"Aún no ha configurado la API de búsqueda web, la función de búsqueda web no estará disponible\"\n        },\n        \"email\": {\n          \"title\": \"Correo electrónico\",\n          \"description\": \"Correo electrónico no configurado, la recuperación de contraseña de autoservicio y la función de notificación por correo electrónico no estarán disponibles, <anchor>ir a configuración</anchor>\",\n          \"errorTips\": \"Correo electrónico no configurado, la recuperación de contraseña de autoservicio, verificación de correo electrónico/función de notificación no estarán disponibles\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Notificaciones\",\n    \"unread\": \"No leído\",\n    \"read\": \"Leído\",\n    \"markAs\": \"Marcar esta notificación como {{status}}\",\n    \"markAllAsRead\": \"Marcar todo como leído\",\n    \"noUnread\": \"No hay notificaciones con estado: {{status}}\",\n    \"changeSetting\": \"Cambiar la configuración de notificación de página\",\n    \"new\": \"{{count}} nueva(s)\",\n    \"showMore\": \"Mostrar más\",\n    \"exportBase\": {\n      \"successText\": \"Los datos de exportación están listos\",\n      \"failedText\": \"La exportación falló, por favor reinténtelo\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Propietario\",\n      \"creator\": \"Creador\",\n      \"editor\": \"Editor\",\n      \"commenter\": \"Comentador\",\n      \"viewer\": \"Espectador\"\n    },\n    \"description\": {\n      \"owner\": \"Puede configurar y editar completamente las bases, automatización, matrices de autoridad y administrar la configuración y la facturación del espacio\",\n      \"creator\": \"Puede configurar y editar completamente las bases, automatizar y habilitar la matriz de autoridad\",\n      \"editor\": \"Puede editar registros y vistas, pero no puede configurar tablas o campos\",\n      \"commenter\": \"Puede comentar los registros\",\n      \"viewer\": \"No se puede editar o comentar\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Papelera de espacio\",\n    \"type\": \"Tipo\",\n    \"resetTrash\": \"Vaciar papelera\",\n    \"deletedBy\": \"Eliminado por\",\n    \"deletedTime\": \"Fecha de eliminación\",\n    \"fromSpace\": \"Del espacio \\\"{{name}}\\\"\",\n    \"permanentDeleteTips\": \"¿Estás seguro de que deseas eliminar permanentemente \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"¿Estás seguro de que deseas vaciar la papelera?\",\n    \"addToTrash\": \"Mover a la papelera\",\n    \"description\": \"Los datos en la papelera aún ocupan espacio de uso de registros y uso de archivos adjuntos.\",\n    \"spaceDescription\": \"Restaurar espacios eliminados en los últimos {{retentionDays}} días\",\n    \"spaceInnerDescription\": \"Restaurar bases eliminadas de este espacio en los últimos {{retentionDays}} días\",\n    \"baseDescription\": \"Restaurar recursos eliminados de esta base en los últimos {{retentionDays}} días\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Complemento no configurando URL\",\n    \"install\": \"Instalar\",\n    \"publisher\": \"Editor\",\n    \"lastUpdated\": \"Última actualización\",\n    \"pluginNotFound\": \"Complemento no encontrado\",\n    \"pluginEmpty\": {\n      \"title\": \"Todavía no hay complementos\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"¿Desea activar la automatización actual?\"\n  },\n  \"email\": {\n    \"send\": \"Enviar\",\n    \"config\": \"Configuración de Correo\",\n    \"customConfig\": \"Servidor de Correo Personalizado\",\n    \"notify\": \"Correo de Notificación\",\n    \"automation\": \"Correo de Automatización\",\n    \"customNotifyConfig\": \"Configuración de Correo de Notificación Personalizada\",\n    \"customAutomationConfig\": \"Configuración de Correo de Automatización Personalizada\",\n    \"addConfig\": \"Agregar Configuración\",\n    \"editConfig\": \"Editar Configuración\",\n    \"resetConfig\": \"Restablecer\",\n    \"testEmail\": \"Correo de Prueba\",\n    \"testEmailPlaceholder\": \"Por favor ingrese el correo de prueba\",\n    \"testEmailError\": \"Por favor ingrese una dirección de correo de prueba válida\",\n    \"testEmailSend\": \"Correo de prueba enviado exitosamente, por favor verifique su bandeja de entrada\",\n    \"configError\": \"Por favor ingrese una configuración de correo válida\",\n    \"host\": \"Dirección del Servidor\",\n    \"hostDescription\": \"Por favor ingrese la dirección del servidor de correo SMTP, por ejemplo: smtp.example.com\",\n    \"port\": \"Puerto\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Autenticación\",\n    \"username\": \"Nombre de Usuario\",\n    \"password\": \"Contraseña\",\n    \"sender\": \"Dirección del Remitente\",\n    \"senderName\": \"Nombre del Remitente\",\n    \"subscribe\": \"Suscribirse\",\n    \"unsubscribe\": \"Cancelar suscripción\",\n    \"unsubscribeList\": \"Lista de cancelar suscripción\",\n    \"unsubscribeTime\": \"Fecha de cancelación de suscripción\",\n    \"source\": \"Fuente\",\n    \"sourceAutomationDeleted\": \"Automatización o nodo eliminado\",\n    \"processing\": \"Procesando...\",\n    \"unsubscribeH1\": \"¿Confirmar cancelación de suscripción?\",\n    \"unsubscribeH2\": \"Estás a punto de cancelar la suscripción a futuras promociones y actualizaciones de productos de Teable. ¿Estás seguro de que deseas cancelar la suscripción?\",\n    \"subscribeH1\": \"¿Confirmar suscripción?\",\n    \"subscribeH2\": \"Estás a punto de suscribirte a futuras promociones y actualizaciones de productos de Teable. ¿Estás seguro de que deseas suscribirte?\",\n    \"unsubscribeListTip\": \"Los siguientes usuarios de la base actual se han dado de baja, ya no podrás enviarles correos electrónicos. Al importar correos electrónicos, coloque las direcciones de correo electrónico en la primera columna y nombre el encabezado \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Restablecer contraseña - {{brandName}}\",\n        \"title\": \"Restablece tu contraseña\",\n        \"message\": \"Si no solicitaste este cambio, ignora este correo electrónico. De lo contrario, haz clic en el botón a continuación para restablecer tu contraseña.\",\n        \"buttonText\": \"Restablecer contraseña\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Verificación de registro - {{brandName}}\",\n          \"title\": \"Verificación de registro\",\n          \"message\": \"Tu código de verificación es {{code}}, úsalo dentro de {{expiresIn}} minutos.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Verificación de dominio - {{brandName}}\",\n          \"title\": \"Verificación de dominio\",\n          \"message\": \"Tu código de un solo uso es: {{code}}, úsalo dentro de {{expiresIn}} minutos.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Verificación de cambio de correo electrónico - {{brandName}}\",\n          \"title\": \"Verificación de cambio de correo electrónico\",\n          \"message\": \"Tu código de verificación es {{code}}, úsalo dentro de {{expiresIn}} minutos.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} te agregó al campo {{fieldName}} de un registro en {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> te agregó al campo <strong>{{fieldName}}</strong> de un registro en <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Ver registro\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} te agregó a {{refLength}} registros en {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> te agregó a <strong>{{refLength}}</strong> registros en <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Ver registros\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) te invitó a su {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Invitación a colaborar\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) te invitó a su {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Aceptar invitación\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Bienvenido - {{brandName}}\",\n        \"title\": \"Bienvenido\",\n        \"message\": \"Te has unido exitosamente a la lista de espera de {{brandName}}. Usa el siguiente código de invitación para registrarte: {{code}}, se puede usar {{times}} veces.\",\n        \"buttonText\": \"Registrarse\"\n      },\n      \"test\": {\n        \"subject\": \"Correo de prueba - {{brandName}}\",\n        \"title\": \"Correo de prueba\",\n        \"message\": \"Este es un correo de prueba, por favor ignóralo.\"\n      },\n      \"notify\": {\n        \"subject\": \"Notificación - {{brandName}}\",\n        \"title\": \"Notificación\",\n        \"buttonText\": \"Ver\",\n        \"import\": {\n          \"title\": \"Notificación de resultado de importación\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ Importación de {{tableName}} abortada: {{errorMessage}} rango de filas fallidas: [{{range}}]. Por favor verifica los datos para este rango e intenta nuevamente.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ Importación de {{tableName}} fallida: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ Importación de {{tableName}} fallida: Límite de filas alcanzado, actualice su plan para importar más registros\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ Importación de {{tableName}} fallida: No se procesaron registros\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} importado exitosamente.\",\n              \"inplace\": \"🎉 {{tableName}} importado incrementalmente exitosamente.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Notificación de comentario de registro\",\n          \"message\": \"{{fromUserName}} hizo un comentario en {{recordName}} en {{tableName}} en {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Notificación de automatización\",\n          \"failed\": {\n            \"title\": \"La automatización {{name}} falló\",\n            \"message\": \"Tu automatización {{name}} no se pudo ejecutar. Haz clic en el botón a continuación para ver los errores específicos del historial de ejecución.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"La automatización {{name}} falló por crédito insuficiente\",\n            \"message\": \"Tu automatización {{name}} no se pudo ejecutar por crédito insuficiente. Por favor actualiza la suscripción o contacta con soporte.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"La automatización {{name}} alcanzó el máximo mensual de ejecuciones\",\n            \"message\": \"Las ejecuciones mensuales de la automatización {{name}} se han agotado y no puede ejecutarse temporalmente. Actualiza tu suscripción o compra ejecuciones adicionales.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Notificación de facturación\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Espacio {{spaceName}} créditos de IA 80 % usados\",\n              \"message\": \"Tu espacio ha usado el 80 % de los créditos de IA. Considera actualizar o comprar más créditos.\"\n            },\n            \"warning90\": {\n              \"title\": \"Espacio {{spaceName}} créditos de IA 90 % usados\",\n              \"message\": \"Tu espacio ha usado el 90 % de los créditos de IA. Actualiza pronto para evitar interrupciones.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Espacio {{spaceName}} ejecuciones de automatización 80 % de la cuota usada\",\n              \"message\": \"Tu espacio ha usado {{usedRuns}} de {{totalLimit}} ejecuciones de automatización (80 %). Considera actualizar tu plan.\"\n            },\n            \"warning90\": {\n              \"title\": \"Espacio {{spaceName}} ejecuciones de automatización 90 % de la cuota usada\",\n              \"message\": \"Tu espacio ha usado {{usedRuns}} de {{totalLimit}} ejecuciones de automatización (90 %). Actualiza pronto para evitar interrupciones.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Espacio {{spaceName}} ejecuciones de automatización excedidas - período de gracia activo\",\n              \"message\": \"Se ha excedido la cuota de ejecuciones de automatización. Las automatizaciones dejarán de ejecutarse y se cerrarán en {{remainingHours}} horas. Por favor, actualiza tu plan.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Notificación de resultado de exportación de base\",\n          \"success\": {\n            \"message\": \"{{baseName}} exportado exitosamente: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ Exportación de {{baseName}} fallida: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"Tarea de IA falló en la tabla {{tableName}}\",\n              \"message\": \"La tarea de IA para el campo {{fieldName}} en la tabla {{tableName}} (ID de registro: {{recordId}}) ha fallado.\\n\\n{{errorMsg}}\\n\\nHaz clic en el botón a continuación para ver los detalles.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Lista de espera\",\n    \"email\": \"Correo electrónico\",\n    \"joinTitle\": \"Estamos escalando rápidamente para satisfacer la demanda\",\n    \"joinDesc\": \"Únete a la lista de espera — te notificaremos tan pronto como estemos listos\",\n    \"emailPlaceholder\": \"Introduce tu correo electrónico...\",\n    \"youAreOnTheList\": \"¡Estás en la lista!\",\n    \"thanksForJoining\": \"Gracias por unirte a nuestra lista de espera. Te notificaremos tan pronto como estemos listos.\",\n    \"back\": \"Volver\",\n    \"inviteCodePlaceholder\": \"Introduce el código de invitación\",\n    \"join\": \"Únete a la lista de espera\",\n    \"joining\": \"Uniendo...\",\n    \"invite\": \"Invitar\",\n    \"inviteTime\": \"Tiempo de invitación\",\n    \"createdTime\": \"Fecha de incorporación\",\n    \"yes\": \"Sí\",\n    \"no\": \"No\",\n    \"generateCode\": \"Generar código de invitación\",\n    \"count\": \"Cantidad\",\n    \"times\": \"Usos\",\n    \"generate\": \"Generar\",\n    \"code\": \"Código de invitación\",\n    \"inviteSuccess\": \"Invitación exitosa\",\n    \"app\": {\n      \"previewAppError\": \"Error de aplicación\",\n      \"sendErrorToAI\": \"Enviar error a AI\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"¿Estás seguro de que quieres eliminar \\\"{{name}}\\\" base?\",\n    \"createResource\": \"Crear recurso\",\n    \"noPermissionToCreateResource\": \"No tienes permiso para crear un recurso\"\n  },\n  \"credit\": {\n    \"title\": \"Crédito\",\n    \"leftAmount\": \"Créditos restantes\",\n    \"winFreeCredits\": \"Compartir experiencia\",\n    \"getCredits\": \"Obtener 1000 créditos gratis\",\n    \"winCredit\": {\n      \"title\": \"Comparte una reseña positiva para ganar\",\n      \"freeCredits\": \"1000 créditos gratis\",\n      \"guidelinesTitle\": \"Lista de verificación para compartir\",\n      \"tagTeableio\": \"Etiqueta <bold>@teableio</bold>\",\n      \"minCharacters\": \"Escribe una reseña de <bold>80+</bold> caracteres\",\n      \"minFollowers\": \"Ten <bold>10+</bold> seguidores\",\n      \"limitPerWeek\": \"500 créditos por publicación, hasta 2 publicaciones semanales (X + LinkedIn)\",\n      \"postOnX\": \"Publicar en X\",\n      \"postOnLinkedIn\": \"Publicar en LinkedIn\",\n      \"preFilledDraft\": \"🌟 ¡Borrador prellenado listo para ti!\",\n      \"claimTitle\": \"Pega la URL de la publicación para reclamar tus créditos\",\n      \"userEmail\": \"Correo del usuario\",\n      \"postUrlLabel\": \"URL de la publicación (X o LinkedIn)\",\n      \"postUrlPlaceholder\": \"Pega el enlace de tu publicación aquí\",\n      \"invalidUrl\": \"Por favor ingresa una URL válida de X o LinkedIn\",\n      \"claiming\": \"Reclamando...\",\n      \"claimCredits\": \"Reclamar créditos\",\n      \"congratulations\": \"¡Felicitaciones!\",\n      \"claimSuccess\": \"¡Has reclamado 500 créditos!\",\n      \"verifying\": \"Verificando tu publicación...\",\n      \"verifyingDescription\": \"Estamos revisando el contenido de tu publicación, esto normalmente toma unos segundos\",\n      \"verifyFailed\": \"Verificación fallida\",\n      \"tryAgain\": \"Intentar de nuevo\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Verificación fallida\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Recompensa\",\n    \"rewardCredits\": \"Créditos de recompensa\",\n    \"minCharCount\": \"La publicación debe tener al menos {{count}} caracteres\",\n    \"minFollowerCount\": \"La cuenta debe tener al menos {{count}} seguidores\",\n    \"mustMention\": \"La publicación debe mencionar {{mention}}\",\n    \"fetchSnapshotFailed\": \"Error al obtener la captura de la publicación\",\n    \"alreadyClaimedThisWeek\": \"Ya has reclamado una recompensa para esta cuenta esta semana\",\n    \"manage\": {\n      \"title\": \"Gestión de recompensas\",\n      \"description\": \"Ver y gestionar registros de recompensas en todos los espacios\",\n      \"overview\": \"Resumen\",\n      \"records\": \"Registros\",\n      \"searchSpace\": \"Buscar espacio...\",\n      \"searchRecords\": \"Buscar postUrl/userId...\",\n      \"dateRange\": \"Seleccionar rango de fechas\",\n      \"from\": \"Desde\",\n      \"to\": \"Hasta\",\n      \"totalSpaces\": \"Total: {{count}} espacios\",\n      \"totalRecords\": \"Total: {{count}} registros\",\n      \"space\": \"Espacio\",\n      \"allSpaces\": \"Todos los espacios\",\n      \"user\": \"Usuario\",\n      \"creator\": \"Creador\",\n      \"sourceType\": \"Tipo de fuente\",\n      \"platform\": \"Plataforma\",\n      \"allStatuses\": \"Todos los estados\",\n      \"allSourceTypes\": \"Todos los tipos de fuente\",\n      \"allPlatforms\": \"Todas las plataformas\",\n      \"pendingCount\": \"Cantidad pendiente\",\n      \"approvedCount\": \"Cantidad aprobada\",\n      \"rejectedCount\": \"Cantidad rechazada\",\n      \"approvedAmount\": \"Monto aprobado\",\n      \"consumedAmount\": \"Monto consumido\",\n      \"availableAmount\": \"Monto disponible\",\n      \"expiringSoonAmount\": \"Monto por vencer (7 días)\",\n      \"amount\": \"Monto\",\n      \"remainingAmount\": \"Monto restante\",\n      \"createdTime\": \"Fecha de creación\",\n      \"rewardTime\": \"Fecha de recompensa\",\n      \"expiredTime\": \"Fecha de vencimiento\",\n      \"lastModified\": \"Última modificación\",\n      \"viewDetails\": \"Ver detalles\",\n      \"details\": \"Detalles de recompensa\",\n      \"basicInfo\": \"Información básica\",\n      \"amountInfo\": \"Información de monto\",\n      \"timeInfo\": \"Información de tiempo\",\n      \"socialInfo\": \"Información social\",\n      \"verifyResult\": \"Resultado de verificación\",\n      \"uniqueKey\": \"Clave única\",\n      \"verify\": \"Verificar\",\n      \"valid\": \"Válido\",\n      \"invalid\": \"Inválido\",\n      \"errors\": \"Errores\",\n      \"copied\": \"{{label}} copiado\",\n      \"openPost\": \"Abrir publicación\",\n      \"noData\": \"Sin datos\",\n      \"page\": \"Página {{current}} de {{total}}\",\n      \"status\": {\n        \"label\": \"Estado\",\n        \"pending\": \"Pendiente\",\n        \"approved\": \"Aprobado\",\n        \"rejected\": \"Rechazado\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Página no encontrada\",\n      \"description\": \"El enlace que seguiste puede estar roto o la página ha sido movida.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Volver al inicio\"\n    },\n    \"forbidden\": {\n      \"title\": \"Acceso restringido\",\n      \"description\": \"Necesitas permiso para acceder a este recurso.\\nPor favor, contacta con tu administrador.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Desbloquear función Premium\",\n      \"description\": \"Esta función está disponible en planes avanzados.\\nActualiza para ampliar tus capacidades.\"\n    },\n    \"error\": {\n      \"title\": \"Algo salió mal\",\n      \"description\": \"Ocurrió un error inesperado. Por favor, inténtalo de nuevo más tarde.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Error al analizar la fecha, el valor \\\"{{value}}\\\" está fuera del rango válido\",\n      \"planRowLimit\": \"Límite de filas alcanzado: actualice su plan para importar más registros\",\n      \"notNullValidation\": \"{{fieldHint}}Los campos obligatorios no pueden estar vacíos\",\n      \"uniqueValidation\": \"{{fieldHint}}Valores duplicados en campos únicos\",\n      \"requestTimeout\": \"Tiempo de espera agotado\",\n      \"chunkProcessingFailed\": \"Error en el procesamiento por lotes: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"NUEVA ACTUALIZACIÓN\",\n    \"title\": \"Descarga masiva de archivos adjuntos disponible\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Aún No Hay Tableros\",\n    \"description\": \"Parece que aún no has creado ningún tablero. Los tableros te ayudan a visualizar y gestionar tus datos de manera más efectiva.\",\n    \"create\": \"Crear Tu Primer Tablero\"\n  },\n  \"addPlugin\": \"Agregar un Complemento\",\n  \"createDashboard\": {\n    \"button\": \"Crear Tablero\",\n    \"title\": \"Crear Nuevo Tablero\",\n    \"placeholder\": \"Ingresa el nombre del tablero\"\n  },\n  \"findDashboard\": \"Buscar un tablero...\",\n  \"expand\": \"Expandir\",\n  \"deprecation\": {\n    \"title\": \"La función de nodo del tablero será descontinuada\",\n    \"description\": \"Para brindarte una experiencia más inteligente y eficiente, descontinuaremos el soporte para la función de nodo del tablero. Podrás crear nuevos tableros fácilmente a través de IA en la aplicación generada por IA.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"Constructor de queries API\",\n  \"subTitle\": \"Puedes construir rápidamente tus queries a través de una interfaz interactiva y copiar el código que se puede ejecutar directamente\",\n  \"apiList\": \"Lista completa de APIs\",\n  \"cellFormat\": \"Formato del resultado de celda\",\n  \"fieldKeyType\": \"Tipo de key del campo\",\n  \"chooseSource\": \"Elige una fuente de datos\",\n  \"action\": {\n    \"selectBase\": \"Seleccionar una base de datos...\",\n    \"selectTable\": \"Seleccionar una tabla...\"\n  },\n  \"pickParams\": \"Selecciona y configura los parámetros\",\n  \"buildResult\": \"Construir resultado\",\n  \"buildResultEmpty\": \"Aún no hay resultados\",\n  \"previewReturnValue\": \"Vista previa del valor retornado\",\n  \"replaceToken\": \"Reemplazar token\",\n  \"createNewToken\": \"Crear nuevo token\",\n  \"showPagination\": \"Los parámetros de paginación se muestran en modo JSON\",\n  \"addSort\": \"Agregar un ordenamiento\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/oauth.json",
    "content": "{\n  \"add\": \"Nueva App OAuth\",\n  \"title\": {\n    \"add\": \"Nueva App OAuth\",\n    \"edit\": \"Editar App OAuth\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"Nombre de la App OAuth\",\n      \"description\": \"El nombre de tu App OAuth.\"\n    },\n    \"description\": {\n      \"label\": \"Descripción\",\n      \"description\": \"Una breve descripción de tu App OAuth.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"URL de la página principal\",\n      \"description\": \"La URL completa del sitio web de tu App OAuth.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Se recomienda una imagen cuadrada.\",\n      \"placeholder\": \"Arrastra y suelta tu logo aquí o haz clic para subir\",\n      \"button\": \"Subir un logo\",\n      \"clear\": \"Limpiar\",\n      \"lengthError\": \"Solo se permite un archivo.\",\n      \"typeError\": \"Solo se permiten archivos de imagen.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"URL de callback\",\n      \"description\": \"La URL completa a la que redirigir después de que un usuario autorice tu integración.\",\n      \"add\": \"Agregar URL de callback\"\n    },\n    \"scopes\": {\n      \"label\": \"Scopes\",\n      \"description\": \"Los permisos que necesita tu App OAuth.\"\n    },\n    \"secret\": {\n      \"label\": \"Client secrets\",\n      \"add\": \"Generar un nuevo client secret\",\n      \"newDescription\": \"Asegúrate de copiar tu nuevo client secret ahora. No podrás verlo nuevamente.\",\n      \"empty\": \"Necesitas un client secret para autenticarte como la aplicación en la API.\",\n      \"lastUsed\": \"Último uso el {{date}}\",\n      \"tag\": \"Client secret\",\n      \"neverUsed\": \"Nunca usado\"\n    },\n    \"clientId\": {\n      \"label\": \"Client ID: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Información básica\",\n    \"scopes\": \"Escopas\",\n    \"identify\": \"Identificar y autorizar a los usuarios\",\n    \"clientInfo\": \"Información del cliente\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} está solicitando acceso a su cuenta\",\n    \"scopes\": \"Esta aplicación podrá obtener los siguientes ámbitos:\",\n    \"redirectDescription\": \"La autorización redirigirá a\",\n    \"authorize\": \"Autorizar\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/plugin.json",
    "content": "{\n  \"add\": \"Nuevos Plugins\",\n  \"title\": {\n    \"add\": \"Nuevos Plugins\",\n    \"edit\": \"Editar Plugins\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Usuario del Plugin\",\n    \"description\": \"Usuario del Plugin generado automáticamente por el sistema\"\n  },\n  \"secret\": \"Secret\",\n  \"regenerateSecret\": \"Regenerar Secret\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Nombre\",\n      \"description\": \"nombre del plugin\"\n    },\n    \"description\": {\n      \"label\": \"Descripción\",\n      \"description\": \"descripción del plugin\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Descripción Detallada\",\n      \"description\": \"descripción detallada del plugin\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"logo del plugin, puedes subir una imagen o usar una URL\",\n      \"upload\": \"Subir\",\n      \"clear\": \"Limpiar\",\n      \"placeholder\": \"Arrastra y suelta tu logo aquí o haz clic para subir\",\n      \"lengthError\": \"Solo se permite un archivo.\",\n      \"typeError\": \"Solo se permiten archivos de imagen.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"URL de Ayuda\",\n      \"description\": \"URL del documento de ayuda del plugin\"\n    },\n    \"positions\": {\n      \"label\": \"Posiciones\",\n      \"description\": \"posiciones del plugin\"\n    },\n    \"i18n\": {\n      \"label\": \"i18n\",\n      \"description\": \"i18n del plugin, contiene (nombre, descripción, descripción detallada)\"\n    },\n    \"url\": {\n      \"label\": \"URL\",\n      \"description\": \"URL del plugin\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Escribir\",\n    \"preview\": \"Avance\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Próximamente\",\n    \"empty\": \"Vacío\",\n    \"noRecords\": \"No hay registros disponibles\",\n    \"unnamedRecord\": \"Registro sin nombre\",\n    \"untitled\": \"Sin título\",\n    \"cancel\": \"Cancelar\",\n    \"confirm\": \"Confirmar\",\n    \"back\": \"Volver\",\n    \"done\": \"Listo\",\n    \"create\": \"Crear\",\n    \"search\": {\n      \"placeholder\": \"Buscar...\",\n      \"empty\": \"No se encontraron resultados\"\n    },\n    \"readOnlyTip\": \"Esta vista está bloqueada. Puede activar el <button>modo personal</button> para editar las opciones de la vista, y los cambios solo tendrán efecto para usted.\",\n    \"selectPlaceHolder\": \"Seleccionar...\",\n    \"loading\": \"Cargando...\",\n    \"loadMore\": \"Cargar más\",\n    \"uploadFailed\": \"Error al subir\",\n    \"rowCount\": \"{{count}} registros\",\n    \"summary\": \"Resumen\",\n    \"summaryTip\": \"Pasa el cursor para seleccionar resumen\",\n    \"actions\": \"Acciones\",\n    \"remove\": \"Eliminar\",\n    \"runStatus\": {\n      \"success\": \"{{name}} exitoso\",\n      \"failed\": \"{{name}} fallido\",\n      \"running\": \"{{name}} en ejecución\"\n    },\n    \"resetSuccess\": \"Restablecido exitosamente\",\n    \"click\": \"Clic\",\n    \"clickedCount\": \"{{label}}: Clicado {{text}} veces\",\n    \"atLeastOne\": \"Reserve al menos un {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Notificación\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Límite de tamaño para vista previa: {{size}}MB, por favor descarga para ver.\",\n    \"loadFileError\": \"Error al cargar el archivo\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Deshacer\",\n    \"redo\": \"Rehacer\",\n    \"undoFailed\": \"Error al deshacer\",\n    \"redoFailed\": \"Error al rehacer\",\n    \"nothingToUndo\": \"Nada para deshacer\",\n    \"nothingToRedo\": \"Nada para rehacer\",\n    \"undoSucceed\": \"Deshacer exitoso\",\n    \"redoSucceed\": \"Rehacer exitoso\",\n    \"undoing\": \"deshaciendo...\",\n    \"redoing\": \"rehaciendo...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Suelta para subir archivo\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Pega o arrastra y suelta para subir aquí\",\n      \"upload\": \"subir\"\n    },\n    \"date\": {\n      \"placeholder\": \"Selecciona una fecha\",\n      \"today\": \"Hoy\"\n    },\n    \"formula\": {\n      \"title\": \"Editor de fórmulas\",\n      \"guideSyntax\": \"Sintaxis\",\n      \"guideExample\": \"Ejemplo\",\n      \"helperExample\": \"Ejemplo:\",\n      \"fieldValue\": \"Devuelve el valor a las celdas del campo {{fieldName}}.\",\n      \"placeholder\": \"Ingrese una expresión\",\n      \"placeholderForAIPrompt\": \"Describe la fórmula que quieres generar\",\n      \"editExpression\": \"Editar fórmula\",\n      \"generateExpressionByAI\": \"Generar fórmula con AI\",\n      \"inputPrompt\": \"Instrucción de entrada\",\n      \"generateExpression\": \"Fórmula generada\",\n      \"generatingByAI\": \"Generando fórmula con AI...\",\n      \"generatedExpressionTips\": \"Después de generar, haga clic en Aplicar para insertar la fórmula rápidamente\",\n      \"action\": {\n        \"generating\": \"Generando...\",\n        \"generate\": \"Generar\",\n        \"apply\": \"Aplicar\"\n      },\n      \"expressionRequired\": \"La expresión es obligatoria\"\n    },\n    \"link\": {\n      \"placeholder\": \"Seleccionar registros para vincular\",\n      \"searchPlaceholder\": \"Registros de búsqueda\",\n      \"allFields\": \"Todos los campos\",\n      \"globalSearch\": \"Búsqueda global\",\n      \"fieldSearch\": \"Buscar campo\",\n      \"maxFieldTips\": \"Se ha excedido el número máximo de {{count}} campos, los campos adicionales serán ignorados\",\n      \"create\": \"Agregar registro\",\n      \"selectRecord\": \"Seleccionar registro\",\n      \"all\": \"Todos\",\n      \"selected\": \"Seleccionado\",\n      \"expandRecordError\": \"No hay permiso para ver este registro.\",\n      \"alreadyOpen\": \"Este registro ya está abierto.\",\n      \"linkedTo\": \"Vinculado a\",\n      \"goToForeignTable\": \"Ir a la mesa extranjera\",\n      \"foreignTableIdRequired\": \"La tabla externa es obligatoria\",\n      \"linkFieldIdRequired\": \"El campo de enlace es obligatorio\",\n      \"selectTooManyRecords\": \"Los registros seleccionados no deben exceder {{maxCount}}\",\n      \"relationshipRequired\": \"La relación es obligatoria\",\n      \"rangeSelectFailed\": \"Error al seleccionar registros en el rango\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Encuentra usuarios por nombre\",\n      \"notify\": \"Notifique a los usuarios una vez que sean seleccionados\"\n    },\n    \"select\": {\n      \"addOption\": \"Agregar una opción '{{option}}'\",\n      \"choicesNameRequired\": \"El nombre de la opción no puede estar vacío\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"El campo de búsqueda es obligatorio\",\n      \"lookupOptionsNotAllowed\": \"Las opciones de búsqueda no están permitidas cuando el atributo isLookup es verdadero o el tipo de campo es rollup.\",\n      \"lookupOptionsRequired\": \"Las opciones de búsqueda son obligatorias\",\n      \"refineOptionsError\": \"Error al analizar las opciones de búsqueda {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"La expresión es obligatoria\",\n      \"unsupportedTip\": \"El rollup solo admite campos de enlace y rollup\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"El filtro debe contener al menos una condición\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"La búsqueda condicional requiere al menos una condición de filtro\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"El modelo es obligatorio\",\n      \"typeNotSupported\": \"Tipo de AI no compatible\",\n      \"sourceFieldIdRequired\": \"El campo de origen es obligatorio\",\n      \"targetLanguageRequired\": \"El idioma de destino es obligatorio\",\n      \"promptRequired\": \"El prompt es obligatorio\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Error al analizar las opciones del campo {{message}}\",\n      \"optionsRequired\": \"Las opciones del campo son obligatorias\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filtro\",\n    \"displayLabel\": \"Filtrar por \",\n    \"displayLabel_other\": \"Filtrar por {{fieldName}} y {{count}} otros campos\",\n    \"addCondition\": \"Agregar condición\",\n    \"addConditionGroup\": \"Agregar grupo de condición\",\n    \"nestedLimitTip\": \"Filter conditions can only be nested {{depth}} levels deep\",\n    \"linkInputPlaceholder\": \"Ingrese un valor\",\n    \"groupDescription\": \"Cualquiera de los siguientes es cierto ...\",\n    \"currentUser\": \"Yo (usuario actual)\",\n    \"tips\": {\n      \"scope\": \"Desde este punto de vista, Show Records\"\n    },\n    \"invalidateSelected\": \"Valor no válido\",\n    \"invalidateSelectedTips\": \"Se ha eliminado el valor seleccionado, seleccione nuevamente\",\n    \"default\": {\n      \"empty\": \"No se aplican condiciones de filtro\",\n      \"placeholder\": \"Ingrese un valor\"\n    },\n    \"conjunction\": {\n      \"and\": \"y\",\n      \"or\": \"o\",\n      \"where\": \"dónde\",\n      \"meetingAll\": \"Cumplir todas las condiciones\",\n      \"meetingAny\": \"Cumplir cualquier condición\"\n    },\n    \"operator\": {\n      \"is\": \"es\",\n      \"isNot\": \"no es\",\n      \"contains\": \"contiene\",\n      \"doesNotContain\": \"no contiene\",\n      \"isEmpty\": \"esta vacío\",\n      \"isNotEmpty\": \"no esta vacío\",\n      \"isGreater\": \"es mayor que\",\n      \"isGreaterEqual\": \"es mayor igual\",\n      \"isLess\": \"es menos que\",\n      \"isLessEqual\": \"es menos igual\",\n      \"isAnyOf\": \"es alguno de\",\n      \"isNoneOf\": \"no es ninguno de\",\n      \"hasAnyOf\": \"tiene alguno de\",\n      \"hasAllOf\": \"tiene todo\",\n      \"hasNoneOf\": \"no tiene nada de\",\n      \"isExactly\": \"es exactamente\",\n      \"isWithIn\": \"es con\",\n      \"isBefore\": \"es antes\",\n      \"isAfter\": \"es después\",\n      \"isOnOrBefore\": \"está en o antes\",\n      \"isOnOrAfter\": \"está en o después\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"hoy\",\n        \"tomorrow\": \"mañana\",\n        \"yesterday\": \"ayer\",\n        \"oneWeekAgo\": \"Hace una semana\",\n        \"oneWeekFromNow\": \"Una semana a partir de ahora\",\n        \"oneMonthAgo\": \"Hace un mes\",\n        \"oneMonthFromNow\": \"Un mes a partir de ahora\",\n        \"daysAgo\": \"hace días\",\n        \"daysFromNow\": \"días a partir de ahora\",\n        \"exactDate\": \"Fecha exacta\",\n        \"exactFormatDate\": \"Fecha exacta (formato)\",\n        \"currentWeek\": \"semana actual\",\n        \"currentMonth\": \"mes actual\",\n        \"currentYear\": \"año corriente\",\n        \"lastWeek\": \"la semana pasada\",\n        \"lastMonth\": \"mes pasado\",\n        \"lastYear\": \"el año pasado\",\n        \"nextWeekPeriod\": \"la próxima semana\",\n        \"nextMonthPeriod\": \"mes próximo\",\n        \"nextYearPeriod\": \"al año que viene\",\n        \"pastWeek\": \"semana pasada\",\n        \"pastMonth\": \"mes pasado\",\n        \"pastYear\": \"año pasado\",\n        \"nextWeek\": \"la próxima semana\",\n        \"nextMonth\": \"mes próximo\",\n        \"nextYear\": \"al año que viene\",\n        \"pastNumberOfDays\": \"número pasado de días\",\n        \"nextNumberOfDays\": \"Siguiente número de días\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"color\"\n  },\n  \"rowHeight\": {\n    \"short\": \"corto\",\n    \"medium\": \"medio\",\n    \"tall\": \"alto\",\n    \"extraTall\": \"extratall\",\n    \"title\": \"de altura de fila\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"de nombre de campo\",\n    \"displayLines\": \"{{count}} líneas\"\n  },\n  \"share\": {\n    \"title\": \"compartir\"\n  },\n  \"extensions\": {\n    \"title\": \"extensiones\"\n  },\n  \"hidden\": {\n    \"label\": \"Campos ocultos\",\n    \"configLabel_one\": \"{{count}} hidden field\",\n    \"configLabel_other\": \"{{count}} hidden fields\",\n    \"configLabel_other_visible\": \"{{count}} visible fields\",\n    \"showAll\": \"Mostrar todo\",\n    \"hideAll\": \"Esconderse\",\n    \"primaryKey\": \"Campo primario: identifica registros\\n\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Copiar al portapapeles\",\n    \"duplicateRecord\": \"Récord duplicado\",\n    \"copyRecordUrl\": \"Copiar URL de registro\",\n    \"deleteRecord\": \"Eliminar registro\",\n    \"addRecordComment\": \"Agregar comentario\",\n    \"viewRecordHistory\": \"Ver historial del registro\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Ocultar historia de registro\",\n      \"showRecordHistory\": \"Mostrar historia discográfica\",\n      \"createdTime\": \"Tiempo creado\",\n      \"createdBy\": \"Creado por\",\n      \"before\": \"Antes\",\n      \"after\": \"Después\",\n      \"viewRecord\": \"Ver registro\"\n    },\n    \"showHiddenFields\": \"Show {{count}} hidden fields\",\n    \"hideHiddenFields\": \"Hide {{count}} hidden fields\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Clasificar\",\n    \"displayLabel_one\": \"Sort by {{count}} field\",\n    \"displayLabel_other\": \"Sort by {{count}} fields\",\n    \"setTips\": \"Ordenar por\",\n    \"addButton\": \"Agregar otro tipo\",\n    \"autoSort\": \"Ordenar los registros automáticamente\",\n    \"selectASCLabel\": \"Primero → último\",\n    \"selectDESCLabel\": \"Último → Primero\"\n  },\n  \"group\": {\n    \"label\": \"Grupo\",\n    \"displayLabel_one\": \"Group by {{count}} fields\",\n    \"displayLabel_other\": \"Group by {{count}} fields\",\n    \"setTips\": \"Agrupar\",\n    \"addButton\": \"Agregar subgrupo\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Texto de una línea\",\n      \"longText\": \"Texto largo\",\n      \"singleSelect\": \"Selección única\",\n      \"number\": \"Número\",\n      \"multipleSelect\": \"Seleccionar múltiples\",\n      \"link\": \"Enlace a otro registro\",\n      \"formula\": \"Fórmula\",\n      \"date\": \"Fecha\",\n      \"createdTime\": \"Tiempo creado\",\n      \"lastModifiedTime\": \"Último tiempo modificado\",\n      \"attachment\": \"Adjunto\",\n      \"checkbox\": \"Caja\",\n      \"rollup\": \"Acurrucado\",\n      \"conditionalRollup\": \"Resumen condicional\",\n      \"user\": \"Usuario\",\n      \"rating\": \"Clasificación\",\n      \"autoNumber\": \"Número automático\",\n      \"lookup\": \"Buscar\",\n      \"conditionalLookup\": \"Búsqueda condicional\",\n      \"button\": \"Botón\",\n      \"createdBy\": \"Creado por\",\n      \"lastModifiedBy\": \"Última modificación por\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Guarda texto corto como nombres o títulos.\",\n      \"longText\": \"Captura notas y descripciones más largas.\",\n      \"singleSelect\": \"Elige una opción de una lista.\",\n      \"number\": \"Sigue valores numéricos con formato.\",\n      \"multipleSelect\": \"Etiqueta registros con varias opciones.\",\n      \"link\": \"Vincula este registro a otra tabla.\",\n      \"formula\": \"Calcula valores a partir de otros campos.\",\n      \"date\": \"Registra fechas u horas.\",\n      \"createdTime\": \"Muestra cuándo se creó un registro.\",\n      \"lastModifiedTime\": \"Muestra la última hora de actualización.\",\n      \"attachment\": \"Puede subir archivos o generar imágenes con IA, compatible con modelos como 🍌 Nano banana pro\",\n      \"checkbox\": \"Activa o desactiva un sí o no sencillo.\",\n      \"rollup\": \"Resume registros vinculados con fórmulas.\",\n      \"conditionalRollup\": \"Resume datos según condiciones.\",\n      \"user\": \"Asigna registros a miembros del espacio de trabajo.\",\n      \"rating\": \"Califica elementos con iconos configurables.\",\n      \"autoNumber\": \"Da a cada registro una secuencia única.\",\n      \"lookup\": \"Muestra valores de registros vinculados.\",\n      \"conditionalLookup\": \"Muestra valores vinculados que cumplen filtros definidos.\",\n      \"button\": \"Ejecuta acciones con un botón clicable.\",\n      \"createdBy\": \"Muestra quién creó el registro.\",\n      \"lastModifiedBy\": \"Muestra quién modificó el registro por última vez.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Unidireccional\",\n      \"twoWay\": \"Bidireccional\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Confirmación de acción\",\n        \"description\": \"¿Estás seguro de querer ejecutar esta acción?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Crear espacio\",\n      \"spaceDelete\": \"Eliminar espacio\",\n      \"spaceRead\": \"Leer espacio\",\n      \"spaceUpdate\": \"Actualizar espacio\",\n      \"spaceInviteEmail\": \"Invitar por correo electrónico en el espacio\",\n      \"spaceInviteLink\": \"Invitar a través de un enlace en el espacio\",\n      \"spaceGrantRole\": \"Faid de papel en el espacio\",\n      \"baseCreate\": \"Crear base\",\n      \"baseDelete\": \"Eliminar la base\",\n      \"baseRead\": \"Base de lectura\",\n      \"baseReadAll\": \"Lea todas las bases\",\n      \"baseUpdate\": \"Base de actualización\",\n      \"baseInviteEmail\": \"Invitar por correo electrónico en la base\",\n      \"baseInviteLink\": \"Invitar a través de un enlace en la base\",\n      \"baseTableImport\": \"Importar datos a la base\",\n      \"baseAuthorityMatrixConfig\": \"Configurar la matriz de autoridad\",\n      \"baseDbConnect\": \"Conectarse a la base de datos\",\n      \"tableCreate\": \"Crear mesa\",\n      \"tableRead\": \"Tabla de lectura\",\n      \"tableDelete\": \"Eliminar mesa\",\n      \"tableUpdate\": \"Tabla de actualización\",\n      \"tableImport\": \"Importar datos a la tabla\",\n      \"tableExport\": \"Datos de la tabla de exportación\",\n      \"tableTrashRead\": \"Lea la basura de la mesa\",\n      \"tableTrashUpdate\": \"Actualizar la basura de la tabla\",\n      \"tableTrashReset\": \"Restablecer la basura de la mesa\",\n      \"viewCreate\": \"Crear vista\",\n      \"viewDelete\": \"Eliminar vista\",\n      \"viewRead\": \"Leer vista\",\n      \"viewUpdate\": \"Vista de actualización\",\n      \"viewShare\": \"Vista de compartir\",\n      \"fieldCreate\": \"Crear campo\",\n      \"fieldDelete\": \"Eliminar campo\",\n      \"fieldRead\": \"Campo de lectura\",\n      \"fieldUpdate\": \"Campo de actualización\",\n      \"recordCreate\": \"Crear registro\",\n      \"recordComment\": \"Comentario sobre el registro\",\n      \"recordDelete\": \"Eliminar registro\",\n      \"recordRead\": \"Récord de lectura\",\n      \"recordUpdate\": \"Registro de actualización\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Crear automatización\",\n      \"automationDelete\": \"Eliminar la automatización\",\n      \"automationRead\": \"Leer automatización\",\n      \"automationUpdate\": \"Actualización de automatización\",\n      \"appCreate\": \"Crear aplicación\",\n      \"appDelete\": \"Eliminar aplicación\",\n      \"appRead\": \"Leer aplicación\",\n      \"appUpdate\": \"Actualizar aplicación\",\n      \"userProfileRead\": \"Lea el perfil de usuario actual\",\n      \"userEmailRead\": \"Lea el correo electrónico actual del usuario\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Leer Historia de registro\",\n      \"baseQuery\": \"Base de consulta\",\n      \"instanceRead\": \"Instancia de lectura\",\n      \"instanceUpdate\": \"Instancia de actualización\",\n      \"enterpriseRead\": \"Leer la configuración empresarial\",\n      \"enterpriseUpdate\": \"Actualizar la configuración empresarial\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Mesa\",\n    \"view\": \"Vista\",\n    \"space\": \"Espacio\",\n    \"base\": \"Base\",\n    \"field\": \"Campo\",\n    \"record\": \"Registro\",\n    \"automation\": \"Automatización\",\n    \"app\": \"Aplicación\",\n    \"user\": \"Usuario\",\n    \"recordHistory\": \"Historia récord\",\n    \"you\": \"Tú\",\n    \"instance\": \"Instancia\",\n    \"enterprise\": \"Empresa\",\n    \"history\": \"Historial\",\n    \"global\": \"Global\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Suma los números. Equivalente a número1 + número2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"MID\": {\n      \"summary\": \"Extrae una subcadena de count caracteres comenzando en whereToStart.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Devuelve el mes de una fecha como un número entre 1 (Enero) y 12 (Diciembre).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Devuelve la fecha y hora de la modificación más reciente realizada por un usuario en un campo no calculado en la tabla.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Devuelve el promedio de los números.\",\n      \"example\": \"Promedio (100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Devuelve el más grande de los números dados.\",\n      \"example\": \"Máx (100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Devuelve el más pequeño de los números dados.\",\n      \"example\": \"Min (100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Reduce el valor al número de decimales dados por \\\"Precision\\\" (específicamente, la ronda redondeará al entero más cercano en la precisión especificada, con lazos rotos al redondear la mitad hacia el infinito positivo).\",\n      \"example\": \"Ronda (1.99, 0) => 2\\n\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Redonde el valor al número de decimales dados por \\\"precisión\\\" siempre redondeando, es decir, lejos de cero. \",\n      \"example\": \"Roundup (1.1, 0) => 2\\n\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Redonde el valor al número de lugares decimales dados por \\\"precisión\\\" siempre redondeando, es decir, hacia cero. \",\n      \"example\": \"Rounddown (1.9, 0) => 1\\n\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Devuelve el múltiplo entero más cercano de importancia que es mayor o igual al valor. \",\n      \"example\": \"Techo (2.49) => 3\\n\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Devuelve el múltiplo entero más cercano de importancia que es menor o igual al valor. \",\n      \"example\": \"Piso (2.49) => 2\\n\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Devuelve el número entero incluso más pequeño que es mayor o igual al valor especificado.\",\n      \"example\": \"Incluso (0.1) => 2\\n\"\n    },\n    \"ODD\": {\n      \"summary\": \"Redondea el valor positivo en el número impar más cercano y el valor negativo hasta el número impar más cercano.\",\n      \"example\": \"Impar (0.1) => 1\\n\"\n    },\n    \"INT\": {\n      \"summary\": \"Redondea un número hacia abajo al entero más cercano.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Devuelve el valor absoluto.\",\n      \"example\": \"ABS (-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Devuelve la raíz cuadrada de un número no negativo.\",\n      \"example\": \"Sqrt (4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Calcula la base especificada a la potencia especificada.\",\n      \"example\": \"Potencia (2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Calcula el número de Euler (E) a la potencia especificada.\",\n      \"example\": \"Exp (0) => 1\\n\"\n    },\n    \"LOG\": {\n      \"summary\": \"Calcula el logaritmo del valor en la base proporcionada. \",\n      \"example\": \"Log (100) => 2\\n\"\n    },\n    \"MOD\": {\n      \"summary\": \"Devuelve el resto después de dividir el primer argumento por el segundo.\",\n      \"example\": \"Mod (9, 2) => 1\\n\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Convierte la cadena de texto a un número.\",\n      \"example\": \"Valor (\\\"$ 1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Se une a varios argumentos de tipos de valor en un solo valor de texto.\",\n      \"example\": \"Concatenate (\\\"Hola\\\", \\\"TABLE\\\") => Hello Table\"\n    },\n    \"FIND\": {\n      \"summary\": \"Encuentra una ocurrencia de StringTOfind en WheretoseSearch String que comienza desde una Opcional StartFromposition. (StartFromposition es 0 por defecto). Si no se encuentra ninguna aparición de StringToFind, el resultado será 0.\",\n      \"example\": \"Find (\\\"table\\\", \\\"hola table\\\") => 7\\n\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Búsqueda de una aparición de StringToFind en Wheretosearch String que comienza desde una Opcional StartFromposition. \",\n      \"example\": \"Buscar (\\\"table\\\", \\\"hello table\\\") => 7\\n\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Extraiga a Howmany personajes desde el comienzo de la cadena.\",\n      \"example\": \"Izquierda (\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Extraiga a Howmany personajes del final de la cadena.\",\n      \"example\": \"Derecha (\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Reemplaza el número de caracteres que comienzan con el carácter de inicio con el texto de reemplazo.\\n\",\n      \"example\": \"Reemplazar (\\\"Hello Table\\\", 7, 5, \\\"Table\\\") => \\\"Hola Table\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Reemplaza todas las subcadenas que coinciden con la expresión regular con reemplazo.\",\n      \"example\": \"Regexp_replace (\\\"Hello Table\\\", \\\"H.*\\\", \\\"\\\") => \\\"Table\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Reemplaza las ocurrencias de Old_Text con New_Text.\\n\",\n      \"example\": \"Sustituye (\\\"Hello Table\\\", \\\"Tabla\\\", \\\"Table\\\") => \\\"Hello Table\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Hace una cuerda en minúsculas.\",\n      \"example\": \"Lower (\\\"Hello Table\\\") => \\\"Hola Table\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Hace una cadena en mayúscula.\",\n      \"example\": \"Upper (\\\"Hello Table\\\") => \\\"Hola Table\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Repita la cadena por el número especificado de veces.\",\n      \"example\": \"REPT (\\\"¡Hola!\\\") => \\\"¡Hola! ¡Hola! ¡Hola!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Elimina espacios en blanco al principio y al final de la cadena.\",\n      \"example\": \"TRIM (\\\"Hello\\\") => \\\"Hola\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Extraiga a Howmany personajes desde el comienzo de la cadena.\",\n      \"example\": \"Len (\\\"Hola\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Devuelve el argumento si es texto y en blanco de otra manera.\",\n      \"example\": \"T (\\\"hola\\\") => \\\"hola\\\"\\n\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Reemplaza ciertos caracteres con equivalentes codificados para su uso en la construcción de URL o URI. \",\n      \"example\": \"Encode_url_component (\\\"Hello Table\\\") => \\\"Hello%20 Mable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Devuelve Value1 si el argumento lógico es verdadero, de lo contrario devuelve Value2. \",\n      \"example\": \"If (2> 1, \\\"a\\\", \\\"b\\\") => \\\"A\\\"\\n\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Toma una expresión, una lista de valores posibles para esa expresión, y para cada uno, un valor que la expresión debe tomar en ese caso. \",\n      \"example\": \"Switch (\\\"B\\\", \\\"A\\\", \\\"Valor A\\\", \\\"B\\\", \\\"Valor B\\\", \\\"Valor predeterminado\\\") => \\\"Valor B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Devuelve verdadero si todos los argumentos son verdaderos, devuelve falso de lo contrario.\",\n      \"example\": \"Y (1 <2, 5> 3) => verdadero\\n\"\n    },\n    \"OR\": {\n      \"summary\": \"Devuelve verdadero si alguno de los argumentos es verdadero.\",\n      \"example\": \"O (1 <2, 5 <3) => verdadero\\n\"\n    },\n    \"XOR\": {\n      \"summary\": \"Devuelve verdadero si un número impar de argumentos son verdaderos.\",\n      \"example\": \"Xor (1 <2, 5 <3, 8 <10) => Falso\\n\"\n    },\n    \"NOT\": {\n      \"summary\": \"Invierte el valor lógico de su argumento.\",\n      \"example\": \"No (1 <2) => falso\\n\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Devuelve un valor en blanco.\",\n      \"example\": \"Blank () => nulo\\n\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Devuelve el valor de error.\",\n      \"example\": \"If (2> 3, \\\"sí\\\", error (\\\"cálculo\\\")) => \\\"#Error: cálculo\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Devuelve verdadero si la expresión causa un error.\",\n      \"example\": \"Is_error (error ()) => verdadero\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Devuelve la fecha actual.\",\n      \"example\": \"Today () => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Devuelve la fecha y hora actuales.\",\n      \"example\": \"Ahora () => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Devuelve el año de cuatro dígitos de una hora de fecha.\",\n      \"example\": \"Año (\\\"2023-09-08\\\") => 2023\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Devuelve el número de semana en un año.\",\n      \"example\": \"Weeknum (\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Devuelve el día de la semana como entero entre 0 y 6, inclusive. \",\n      \"example\": \"Weeknum (\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Devuelve el día del mes de una fecha y hora en forma de un número entre 1-31.\",\n      \"example\": \"Día (\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Devuelve la hora de una fecha y hora como un número entre 0 (12:00 a.m.) y 23 (11:00 p.m.).\",\n      \"example\": \"Hora (\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Devuelve el minuto de una fecha y hora como un entero entre 0 y 59.\",\n      \"example\": \"Minuto (\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Devuelve el segundo de una fecha y hora como un entero entre 0 y 59.\",\n      \"example\": \"Segundo (\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Calcula el número de días entre la fecha actual y otra fecha.\",\n      \"example\": \"Fromnow ({date}, \\\"día\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Calcula el número de días entre la fecha actual y otra fecha.\",\n      \"example\": \"Tonow ({date}, \\\"día\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Devuelve la diferencia entre fechas en unidades especificadas. La unidad predeterminada es \\\"day\\\".\\nUnidades soportadas: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nLa diferencia entre fechas se determina restando [date2] de [date1]. Esto significa que si [date2] es posterior a [date1], el valor resultante será negativo.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Devuelve la jornada laboral a la fecha de inicio, excluyendo las vacaciones especificadas\",\n      \"example\": \"Día de trabajo (\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\n\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Devuelve el número de días hábiles entre la fecha1 y la fecha2. \",\n      \"example\": \"Workday_diff (\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\n\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Compara dos fechas hasta una unidad y determina si son idénticas. \",\n      \"example\": \"Is_same (\\\"2023-09-08\\\", \\\"2023-09-10\\\") => Falso\\n\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Determina si la fecha1 es posterior a la fecha2. \",\n      \"example\": \"IS_AFTER (\\\"2023-09-10\\\", \\\"2023-09-08\\\") => Verdadero\\n\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Determina si la fecha1 es anterior a la fecha2. \",\n      \"example\": \"Is_before (\\\"2023-09-08\\\", \\\"2023-09-10\\\") => verdadero\\n\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Agrega unidades especificadas de \\\"recuento\\\" a una hora de fecha.\",\n      \"example\": \"Date_add (\\\"2023-09-08 18:00:00\\\", 10, \\\"día\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Formatea una fecha y hora en una cadena (aaa yyyy-mm-dd).\",\n      \"example\": \"Datestr (\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Formatea una vez en una cadena de solo tiempo (HH: MM: SS).\",\n      \"example\": \"Datestr (\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Formatea una fecha y hora en una cadena especificada. \",\n      \"example\": \"Datetime_format (\\\"2023-09-08\\\", \\\"dd-mm-yyyy\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Interpreta una cadena de texto como una fecha estructurada, con el formato de entrada opcional y los parámetros de locales. \",\n      \"example\": \"Datetime_parse (\\\"8 de septiembre de 2023 18:00\\\", \\\"d mmm yyyy hh: mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Devuelve el tiempo de creación del registro actual.\",\n      \"example\": \"Creat_time () => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Devuelve el número de todos los elementos, incluidos el texto y los espacios en blanco.\",\n      \"example\": \"Countall (100, 200, \\\"\\\", \\\"Table\\\", True ()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Devuelve el número de valores no vacíos. \",\n      \"example\": \"Counta (100, 200, 300, \\\"\\\", \\\"Table\\\", True) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Devuelve el número de elementos numéricos.\",\n      \"example\": \"Count (100, 200, 300, \\\"\\\", \\\"Table\\\", True) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Únase a la matriz de elementos enrollables en una cadena con un separador.\",\n      \"example\": \"Array_join ([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\";\\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Devuelve solo elementos únicos en la matriz.\",\n      \"example\": \"Array_unique ([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Aplana la matriz quitando cualquier anidación de matriz. \",\n      \"example\": \"Array_flatten ([1, 2, \\\"\\\", 3, verdadero], [\\\"ABC\\\"]) => [1, 2, 3, \\\"\\\", verdadero, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Elimina cadenas vacías y valores nulos de la matriz. \",\n      \"example\": \"Array_compact ([1, 2, 3, \\\"\\\", nulo, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Devuelve todos los valores de texto\",\n      \"example\": \"Text_all (\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Devuelve la ID del registro actual.\",\n      \"example\": \"Registro_id () => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Devuelve los números únicos e incrementados para cada registro.\",\n      \"example\": \"Auto_number () => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Complete variables, caracteres operativos y funciones para formar fórmulas para los cálculos.\",\n      \"example\": \"Citando la columna: {nombre de campo}\\n\\n\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Campos\",\n    \"numeric\": \"Numérico\",\n    \"text\": \"Texto\",\n    \"logical\": \"Lógico\",\n    \"date\": \"Fecha\",\n    \"array\": \"Formación\",\n    \"system\": \"Sistema\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Ninguno\",\n    \"count\": \"Contar\",\n    \"empty\": \"Vacío\",\n    \"filled\": \"Completado\",\n    \"unique\": \"Único\",\n    \"max\": \"Máximo\",\n    \"min\": \"Mínimo\",\n    \"sum\": \"Suma\",\n    \"average\": \"Promedio\",\n    \"checked\": \"Comprobado\",\n    \"unChecked\": \"Desenfrenado\",\n    \"percentEmpty\": \"Por ciento vacío\",\n    \"percentFilled\": \"Por ciento lleno\",\n    \"percentUnique\": \"Porcentaje único\",\n    \"percentChecked\": \"Porcentaje verificado\",\n    \"percentUnChecked\": \"Porcentaje sin control\",\n    \"earliestDate\": \"Fecha más temprana\",\n    \"latestDate\": \"Última fecha\",\n    \"dateRangeOfDays\": \"Rango de fechas (días)\",\n    \"dateRangeOfMonths\": \"Rango de fechas (meses)\",\n    \"totalAttachmentSize\": \"Tamaño total del accesorio\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Agregar\",\n    \"error\": {\n      \"invalidCol\": \"Columna no válida, vuelva a seleccionar\",\n      \"invalidCols\": \"Invalid columns: {{colNames}}\",\n      \"invalidTable\": \"Mesa no válida, vuelva a seleccionar\",\n      \"requiredSelect\": \"Debes seleccionar uno\"\n    },\n    \"from\": {\n      \"title\": \"De\",\n      \"fromTable\": \"Seleccionar tabla\",\n      \"fromQuery\": \"De la consulta\"\n    },\n    \"select\": {\n      \"title\": \"Seleccionar\"\n    },\n    \"where\": {\n      \"title\": \"Dónde\"\n    },\n    \"groupBy\": {\n      \"title\": \"Agrupar\"\n    },\n    \"orderBy\": {\n      \"title\": \"Ordenar\",\n      \"asc\": \"Ascendente\",\n      \"desc\": \"Descendente\"\n    },\n    \"limit\": {\n      \"title\": \"Límite\"\n    },\n    \"offset\": {\n      \"title\": \"Compensar\"\n    },\n    \"join\": {\n      \"title\": \"Unirse\",\n      \"joinType\": \"Tipo de unión\",\n      \"leftJoin\": \"Se unió a la izquierda\",\n      \"rightJoin\": \"Juego correcto\",\n      \"innerJoin\": \"Unión interior\",\n      \"fullJoin\": \"Completa\",\n      \"data\": \"De\"\n    },\n    \"aggregation\": {\n      \"title\": \"Agregación\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Comentario\",\n    \"placeholder\": \"Deja un comentario ...\",\n    \"emptyComment\": \"Comience una conversación\",\n    \"deletedComment\": \"Comentario eliminado\",\n    \"imageSizeLimit\": \"Image size could not be greater than {{size}}\",\n    \"tip\": {\n      \"editing\": \"Editando...\",\n      \"edited\": \"(Editado)\",\n      \"notifyAll\": \"Notificar todos los comentarios\",\n      \"notifyRelatedToMe\": \"Notificar comentarios relacionados conmigo\",\n      \"all\": \"Todos\",\n      \"relatedToMe\": \"Relacionados conmigo\",\n      \"reactionUserSuffix\": \"reaccionó con el emoji {{emoji}}\",\n      \"me\": \"Tú\",\n      \"connection\": \"y\"\n    },\n    \"toolbar\": {\n      \"link\": \"Enlace\",\n      \"image\": \"Imagen\",\n      \"mention\": \"Mención\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Enlace de edición\",\n      \"caption\": \"Subtítulo\",\n      \"delete\": \"Borrar\",\n      \"linkText\": \"Texto de enlace\",\n      \"enterUrl\": \"Ingrese URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Miembros seleccionados\",\n    \"memberSelectorSearchPlaceholder\": \"Miembros de búsqueda ...\",\n    \"departmentSelectorSearchPlaceholder\": \"Departamentos de búsqueda ...\",\n    \"selected\": \"Seleccionado\",\n    \"noSelected\": \"No seleccionado\",\n    \"empty\": \"No hay miembros\",\n    \"emptyDepartment\": \"No hay departamentos\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Error de validación\",\n    \"invalidCaptcha\": \"Captcha inválido\",\n    \"invalidCredentials\": \"Credenciales inválidas\",\n    \"unauthorized\": \"No autorizado\",\n    \"unauthorizedShare\": \"Compartir no autorizado\",\n    \"paymentRequired\": \"Pago requerido\",\n    \"creditLimitExceeded\": \"Límite de créditos excedido\",\n    \"restrictedResource\": \"Recurso restringido\",\n    \"notFound\": \"No encontrado\",\n    \"conflict\": \"Conflicto\",\n    \"unprocessableEntity\": \"Entidad no procesable\",\n    \"userLimitExceeded\": \"Límite de usuario excedido\",\n    \"tooManyRequests\": \"Demasiadas solicitudes\",\n    \"internalServerError\": \"Error del servidor interno\",\n    \"databaseConnectionUnavailable\": \"Conexión de base de datos no disponible\",\n    \"gatewayTimeout\": \"Tiempo de espera de puerta de enlace\",\n    \"unknownErrorCode\": \"Código de error desconocido\",\n    \"networkError\": \"Problema de conexión de red\",\n    \"requestTimeout\": \"El tiempo de espera de la solicitud ha excedido\",\n    \"failedDependency\": \"Dependencia fallida\",\n    \"automationNodeParseError\": \"Error de análisis del nodo de automatización\",\n    \"automationNodeNeedTest\": \"El nodo de automatización necesita prueba\",\n    \"automationNodeTestOutdated\": \"Prueba del nodo de automatización desactualizada\",\n    \"invalidToken\": \"Token no válido\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" campo \\\"{{fieldName}}\\\" no permite valores vacíos, por favor complete antes de enviar.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" campo \\\"{{fieldName}}\\\" no permite valores duplicados, por favor complete un valor único antes de enviar.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" campo no permite asociaciones duplicadas con el mismo registro\",\n      \"requestTimeout\": \"El ámbito de la operación actual es demasiado grande, por favor intente con un ámbito más pequeño.\",\n      \"searchTimeOut\": \"La búsqueda ha expirado, por favor reduce el ámbito de búsqueda y vuelve a intentarlo.\",\n      \"dependencyNodeRequire\": \"Nodo de dependencia no probado, por favor verifica si todos los nodos anteriores han sido probados\",\n      \"invalidOperation\": \"Operación no válida detectada, por favor verifique los parámetros de la operación\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"El número de comentarios solicitados excede el límite máximo de 1000\",\n      \"invalidContentType\": \"Tipo de contenido de comentario no válido\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"La expiración del token debe ser menor a 7 días\",\n      \"s3RegionRequired\": \"La región S3 es obligatoria\",\n      \"s3EndpointRequired\": \"El endpoint S3 es obligatorio\",\n      \"s3AccessKeyRequired\": \"La clave de acceso S3 es obligatoria\",\n      \"s3SecretKeyRequired\": \"La clave secreta S3 es obligatoria\",\n      \"s3UploadMethodMustBePut\": \"El método de carga S3 debe ser PUT\",\n      \"presignedError\": \"Error al generar la URL prefirmada\",\n      \"invalidObjectMeta\": \"Metadatos de objeto no válidos\",\n      \"invalidImageStream\": \"Flujo de imagen no válido\",\n      \"calculateImageSizeFailed\": \"Error al calcular el tamaño de la imagen\",\n      \"uploadFailed\": \"Error en la carga\",\n      \"invalidImage\": \"Imagen no válida\",\n      \"cantGetImageStream\": \"No se puede obtener el flujo de imagen\",\n      \"invalidProvider\": \"Proveedor de almacenamiento no válido\",\n      \"failedToDeleteDirectory\": \"Error al eliminar el directorio\",\n      \"invalidToken\": \"Token no válido\",\n      \"tokenExpired\": \"El token ha expirado\",\n      \"sizeMismatch\": \"El tamaño del archivo no coincide\",\n      \"notAllowUploadFileType\": \"Tipo de archivo {{mimetype}} no permitido para carga\",\n      \"notFound\": \"Archivo adjunto no encontrado\",\n      \"invalidPath\": \"Ruta de archivo adjunto no válida\",\n      \"fileSizeExceedsMaximumLimit\": \"El tamaño del archivo excede el límite máximo de {{maxSize}}\",\n      \"invalidUploadType\": \"Tipo de carga no válido\",\n      \"urlReject\": \"URL rechazada o inaccesible\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Error de configuración de correo {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Este campo de botón ya está vinculado a {{name}}[{{id}}] este proceso de automatización, por favor seleccione otro campo de botón\",\n      \"triggerNotFound\": \"La automatización debe tener un nodo disparador\",\n      \"nodeNotFound\": \"{{nodeId}} nodo no encontrado\",\n      \"triggerTestFailed\": \"Prueba de trigger falló, por favor verifica la configuración del trigger\",\n      \"testFailed\": \"Prueba de automatización fallida, por favor revise la configuración de automatización\",\n      \"runFailed\": \"La ejecución de la automatización falló\",\n      \"nodeParseError\": \"{{name}} la configuración del nodo está incompleta o tiene errores, por favor verifique la configuración del nodo\",\n      \"nodeNeedTest\": \"{{name}} el nodo necesita ser probado\",\n      \"nodeTestOutdated\": \"{{name}} la prueba del nodo está desactualizada\",\n      \"notFound\": \"Automatización no encontrada\",\n      \"currentSnapshotEmpty\": \"La instantánea actual de automatización está vacía\",\n      \"runNotFound\": \"Ejecución de automatización no encontrada\",\n      \"anchorNotFound\": \"Automatización de anclaje no encontrada\",\n      \"validationError\": \"Error de validación de configuración de automatización\",\n      \"tableNotInBase\": \"Solo puedes suscribirte a una tabla dentro de tu base de datos\",\n      \"alreadyActiveAndNotDraft\": \"La automatización ya está activa y no es un borrador\",\n      \"noActiveSnapshot\": \"La automatización no tiene instantánea activa\",\n      \"triggerNodeAlreadyExists\": \"Esta automatización ya tiene un nodo disparador\",\n      \"generateLogicError\": \"Error al generar el nodo de lógica\",\n      \"logicNotFound\": \"Nodo de lógica de automatización no encontrado\",\n      \"actionNotFound\": \"Nodo de acción de automatización no encontrado\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Duplicación de este tipo de nodo de automatización no soportada\",\n      \"unSupportLogicType\": \"Tipo de lógica no soportado\",\n      \"groupEndNotFound\": \"GroupEnd no encontrado para la lógica\",\n      \"insertNodeError\": \"Error al insertar nodo\",\n      \"controlNodeNotBeTested\": \"El nodo de control no debe ser probado\",\n      \"invalidNodeType\": \"Tipo de nodo inválido\",\n      \"unsupportedCategory\": \"Categoría no soportada\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Error al listar buzones: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Conjunto de datos desconocido: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"El servicio de scraping no está configurado\",\n      \"triggerFailed\": \"Error al activar el scraping: {{detail}}\",\n      \"snapshotError\": \"Error de snapshot de scraping: {{detail}}\",\n      \"timeout\": \"Tiempo de espera de scraping agotado después de {{seconds}}s\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Espacio no encontrado\",\n      \"noPermission\": \"No tienes permiso para acceder a este espacio\",\n      \"disallowSpaceCreation\": \"La creación de espacios ha sido deshabilitada por el administrador\",\n      \"cannotChangeOnlyOwnerRole\": \"No se puede cambiar el rol del único propietario del espacio\",\n      \"cannotDeleteOnlyOwner\": \"No se puede eliminar al único propietario del espacio\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"No se puede operar en un espacio, verifique que hay miembros de la organización en el espacio y que son propietarios\",\n      \"notBelongToOrg\": \"Este espacio no pertenece a la organización\",\n      \"invalidSpaceIds\": \"Los IDs de espacio son inválidos: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base no encontrada\",\n      \"cannotAccess\": \"No tienes permiso para acceder a la base {{baseId}}\",\n      \"anchorNotFound\": \"Base de anclaje {{anchorId}} no encontrada\",\n      \"baseAndSpaceMismatch\": \"Base {{baseId}} y espacio {{spaceId}} no coinciden\",\n      \"templateNotFound\": \"Plantilla {{templateId}} no encontrada\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"ID de base requerido\",\n      \"nodeIdIsRequired\": \"ID de nodo requerido\",\n      \"invalidResourceType\": \"Tipo de recurso no válido\",\n      \"notFound\": \"Nodo base no encontrado\",\n      \"parentMustBeFolder\": \"El padre debe ser una carpeta\",\n      \"cannotDuplicateFolder\": \"No se puede duplicar la carpeta\",\n      \"cannotDeleteEmptyFolder\": \"No se puede eliminar la carpeta porque no está vacía\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Solo se debe proporcionar parentId o anchorId\",\n      \"cannotMoveToItself\": \"No se puede mover el nodo a sí mismo\",\n      \"cannotMoveToCircularReference\": \"No se puede mover el nodo a su propio hijo (referencia circular)\",\n      \"anchorIdOrParentIdRequired\": \"Se debe proporcionar al menos parentId o anchorId\",\n      \"parentNotFound\": \"Nodo padre no encontrado\",\n      \"parentIsNotFolder\": \"El padre no es una carpeta\",\n      \"circularReference\": \"Referencia circular detectada\",\n      \"folderDepthLimitExceeded\": \"Límite de profundidad de carpeta excedido\",\n      \"folderNotFound\": \"Carpeta no encontrada\",\n      \"anchorNotFound\": \"Nodo ancla no encontrado\",\n      \"nameAlreadyExists\": \"El nombre ya existe\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Panel de control no encontrado\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Plugin no encontrado\",\n      \"notSupportInstallInView\": \"El plugin no admite instalación en la vista\",\n      \"userNotFound\": \"Usuario del plugin no encontrado\",\n      \"invalidSecret\": \"Secreto inválido\",\n      \"invalidRefreshToken\": \"Token de actualización inválido\",\n      \"anomalousToken\": \"Token anómalo\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Panel de plugin no encontrado\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Plugin no instalado\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Contraseña incorrecta\",\n      \"notAllowedToSubmit\": \"No se permite el envío de formularios\",\n      \"viewRequired\": \"Se requiere vista para esta operación\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"No se permite el envío de formularios cuando se incluyen campos ocultos\",\n      \"submitRecordsError\": \"Error al enviar el registro\",\n      \"notAllowedToCopy\": \"No se permite la operación de copia\",\n      \"fieldHiddenNotAllowed\": \"El campo está oculto y no se puede acceder\",\n      \"fieldTypeNotLinkField\": \"El campo no es un campo de enlace\",\n      \"fieldIdRequired\": \"Se requiere ID de campo\",\n      \"fieldNotUserRelatedField\": \"El campo no es un campo relacionado con el usuario\",\n      \"viewTypeNotAllowed\": \"Este tipo de vista no está permitido para esta operación\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"La restricción de contraseña no está habilitada para esta vista compartida\",\n      \"shareViewNotFound\": \"Vista compartida no encontrada o el uso compartido está deshabilitado\",\n      \"linkFieldNotFound\": \"Campo de enlace no encontrado\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Compartición de base no encontrada o el uso compartido está deshabilitado\",\n      \"alreadyExists\": \"Ya existe una compartición para este nodo\",\n      \"copyNotAllowed\": \"Esta compartición no permite copiar\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"No tiene permiso para acceder a esta vista\",\n      \"fieldPermissionNotAllowed\": \"No tiene permiso para acceder a estos campos\",\n      \"recordPermissionNotAllowed\": \"No tiene permiso para acceder a estos registros\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Menú contextual de plugin no encontrado\",\n      \"anchorNotFound\": \"Ancla de menú contextual de plugin no encontrada\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Consulta de gráfico de plugin no encontrada\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Driver de base de datos no soportado: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Solo el propietario de la base puede eliminar la conexión de base de datos para la base {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Solo el propietario de la base puede crear una conexión de base de datos para la base {{baseId}}\",\n      \"roleNotExist\": \"El rol de base de datos {{role}} no existe\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Consulta fallida: {{message}}\",\n      \"invalidJoinType\": \"Tipo de unión no válido: {{joinType}}\",\n      \"tableNotFound\": \"Tabla {{tableId}} no encontrada en la base {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"No se permite ejecutar SQL con la palabra clave {{keyword}}\",\n      \"whiteListCheckError\": \"Ocurrió un error al verificar el acceso a la tabla: {{message}}\",\n      \"databaseConnectionFailed\": \"Conexión a la base de datos fallida: {{message}}\",\n      \"executeQuerySqlFailed\": \"Error al ejecutar la consulta SQL: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"No tienes permiso para crear registros con campos({{fields}})\",\n      \"deleteRecords\": \"No tienes permiso para eliminar registros({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"No tienes permiso para leer campos({{fields}}) en el registro({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"No tienes permiso para actualizar campos({{fields}}) en el registro({{recordId}})\",\n      \"checkIdNotExist\": \"El ID de verificación de permisos no existe\",\n      \"userNotAdmin\": \"El usuario no es administrador\",\n      \"accessTokenNoPermission\": \"El token de acceso no tiene el permiso requerido\",\n      \"invalidResource\": \"El recurso no es válido\",\n      \"notAllowedSpace\": \"No tienes permiso para acceder a este espacio\",\n      \"notAllowedBase\": \"No tienes permiso para acceder a esta base\",\n      \"notAllowedTables\": \"No tienes permiso para acceder a tablas({{tableIds}})\",\n      \"notAllowedOperationTable\": \"No tienes permiso para operar en esta tabla\",\n      \"notAllowedOperationRecord\": \"No tienes permiso para operar en este registro\",\n      \"notAllowedRecordUpdate\": \"No tienes permiso para actualizar este registro\",\n      \"notAllowedOperationView\": \"No tienes permiso para operar en esta vista\",\n      \"deniedByEnabledAuthorityMatrix\": \"Permiso denegado por matriz de autoridad habilitada\",\n      \"invalidRequestPath\": \"La ruta de la solicitud no es válida\",\n      \"notAllowedOperation\": \"No tienes permiso para realizar esta operación\",\n      \"notAllowedDepartment\": \"No tienes permitido acceder a este departamento\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Rol predeterminado no encontrado\",\n      \"alreadyDisabled\": \"La matriz de autoridad ya está deshabilitada\",\n      \"alreadyEnabled\": \"La matriz de autoridad ya está habilitada\",\n      \"notFound\": \"Matriz de autoridad no encontrada\",\n      \"primaryFieldCannotBeDisabledForRead\": \"El campo primario no puede deshabilitarse para acceso de lectura\",\n      \"fieldDuplicated\": \"El campo está duplicado en la configuración de permisos\",\n      \"cannotSetRecordPermissionGroup\": \"No se puede establecer esta combinación de permisos de registro({{actions}})\",\n      \"notFoundBaseAndTable\": \"ID de base y ID de tabla no encontrados\",\n      \"roleTablesShouldNotBeEmpty\": \"Las tablas de roles de la matriz de autoridad no deben estar vacías\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Tipo de retorno no válido\",\n      \"exceedMaxReadRows\": \"Se excedió el límite máximo de filas de lectura\",\n      \"invalidCellValueType\": \"Tipo de valor de celda no válido\",\n      \"exceedMaxCopyCells\": \"Se excedió el límite máximo de celdas para copiar\",\n      \"exceedMaxPasteCells\": \"Se excedió el límite máximo de celdas para pegar\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Tipo de campo no compatible {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Tipo de campo no compatible {{type}} como campo primario\",\n      \"primaryFieldNotSupported\": \"El tipo de campo no es compatible como campo primario\",\n      \"calculateRecordNotFound\": \"Registro no encontrado para: {{value}}, fieldId: {{fieldId}}, al calcular {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds o fromRecordIds es requerido para campo computado normal\",\n      \"recordFieldsRequired\": \"Los campos de registro no están definidos\",\n      \"uniqueUnsupportedType\": \"Campo {{name}}[{{fieldId}}] no admite validación de valor de campo único\",\n      \"notNullValidationWhenCreateField\": \"Campo {{name}}[{{fieldId}}] no admite validación no nula al crear un nuevo campo\",\n      \"dbFieldNameAlreadyExists\": \"El nombre del campo de base de datos {{dbFieldName}} ya existe\",\n      \"fieldValidationError\": \"Campo {{name}}[{{fieldId}}] error de validación de campo\",\n      \"fieldNameAlreadyExists\": \"El nombre del campo {{name}} ya existe\",\n      \"notFound\": \"Campo no encontrado\",\n      \"fieldKeyTypeNotFound\": \"Campo \\\"{{fieldKeyType}}: {{missedFields}}\\\" no encontrado\",\n      \"notFoundInTable\": \"Campo {{fieldId}} no encontrado en la tabla {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Campos a eliminar {{fieldIds}} no encontrados en la tabla {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues debe ser un array cuando el campo de enlace tiene múltiples valores de celda\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues debe ser un array cuando el campo de enlace tiene múltiples valores de celda\",\n      \"lookupAndLinkLengthMatch\": \"La longitud de lookupValues debe ser igual a la longitud de linkCellValues\",\n      \"cycleDetected\": \"Ciclo detectado\",\n      \"cycleDetectedCreateField\": \"Ciclo detectado, no se puede crear el campo {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Registro no encontrado en la tabla {{tableName}} para el campo {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Prohibido eliminar campo primario\",\n      \"foreignTableIdInvalid\": \"Tabla externa {{foreignTableId}} inválida\",\n      \"relationshipInvalid\": \"Relación {{relationship}} inválida\",\n      \"linkFieldIdInvalid\": \"Campo de enlace {{linkFieldId}} inválido\",\n      \"lookupFieldIdInvalid\": \"Campo de búsqueda {{lookupFieldId}} inválido\",\n      \"formulaExpressionParseError\": \"Error al analizar la expresión de la fórmula\",\n      \"formulaReferenceNotFound\": \"Campo de referencia de fórmula {{fieldIds}} no encontrado\",\n      \"rollupExpressionParseError\": \"Error al analizar la expresión de resumen\",\n      \"choiceNameAlreadyExists\": \"El nombre de opción {{name}} ya existe\",\n      \"symmetricFieldIdRequired\": \"Se requiere ID de campo simétrico\",\n      \"foreignKeyNameCannotUseId\": \"El nombre de clave externa no puede usar __id\",\n      \"createForeignKeyError\": \"Error al crear clave externa\",\n      \"lookupFieldTypeNotEqual\": \"El tipo de campo actual {{fieldType}} no es igual al tipo de campo de búsqueda {{lookupFieldType}}\",\n      \"recordNotFound\": \"Registro {{recordId}} no encontrado en {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"No se puede establecer recordId duplicado: {{recordId}} en la misma celda\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Los valores de campo de enlace uno a uno no pueden ser un array\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Los valores de campo de enlace muchos a uno no pueden ser un array\",\n      \"foreignKeyDuplicate\": \"Clave externa duplicada\",\n      \"linkConsistencyError\": \"Error de consistencia, recordId {{recordId}} no existe\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Los valores de campo de enlace uno a muchos deben ser un array\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Los valores de campo de enlace muchos a muchos deben ser un array\",\n      \"onlyLinkFieldCanBeFiltered\": \"Solo los campos de enlace se pueden usar para filtrar\",\n      \"notLinkedToCurrentTable\": \"El campo no está vinculado a la tabla actual\",\n      \"notAttachment\": \"El campo no es un campo de adjunto\",\n      \"isComputed\": \"El campo es calculado y no se puede modificar\",\n      \"notFoundAICofig\": \"El campo no tiene configuración de AI\",\n      \"foreignTableIdRequired\": \"La tabla externa es obligatoria\",\n      \"lookupFieldIdRequired\": \"El campo de búsqueda es obligatorio\",\n      \"lookupFieldNotExist\": \"El campo de búsqueda {{lookupFieldId}} no existe\",\n      \"lookupFieldNotBelongToTable\": \"El campo de búsqueda {{lookupFieldId}} no pertenece a la tabla {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"El tipo de campo actual {{fieldType}} no coincide con el tipo de campo de búsqueda {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Las opciones del campo de resumen condicional son obligatorias\",\n      \"conditionalRollupParseError\": \"Error al analizar el resumen condicional: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Las opciones del campo de búsqueda condicional son obligatorias\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"El conteo de clics del botón ha alcanzado el límite máximo\",\n        \"notSupportReset\": \"El campo de botón no admite restablecimiento\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Vista no encontrada\",\n      \"defaultViewNotFound\": \"Vista predeterminada no encontrada\",\n      \"propertyParseError\": \"Error al analizar la propiedad de la vista\",\n      \"primaryFieldCannotBeHidden\": \"El campo primario no se puede ocultar\",\n      \"filterUnsupportedFieldType\": \"Tipo de campo no compatible con el filtro\",\n      \"sortUnsupportedFieldType\": \"Tipo de campo no compatible con la ordenación\",\n      \"groupUnsupportedFieldType\": \"Tipo de campo no compatible con la agrupación\",\n      \"anchorNotFound\": \"Vista de anclaje no encontrada\",\n      \"notEnoughGapToShuffleRow\": \"No hay suficiente espacio para mezclar la fila\",\n      \"shareNotEnabled\": \"El uso compartido de vistas no está habilitado\",\n      \"shareAlreadyEnabled\": \"El uso compartido de vistas ya está habilitado\",\n      \"shareAlreadyDisabled\": \"El uso compartido de vistas ya está deshabilitado\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Crédito insuficiente\",\n      \"exceedMaxRowLimit\": \"Se excedió el límite máximo de filas {{maxRowCount}}\",\n      \"exceedMaxAutomationRunLimit\": \"Se alcanzó el máximo mensual de ejecuciones de automatización\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Se requiere consulta de búsqueda\",\n      \"maxSearchIndexResult\": \"El resultado máximo del índice de búsqueda es 1000\",\n      \"queryCollectionMustBeTableId\": \"La colección de consulta debe ser un identificador de tabla\",\n      \"searchTimeOut\": \"La búsqueda ha expirado, por favor reduce el ámbito de búsqueda y vuelve a intentarlo.\",\n      \"indexNotFound\": \"Índice no encontrado\",\n      \"invalidStartDateFieldId\": \"Identificador de campo de fecha de inicio inválido\",\n      \"invalidEndDateFieldId\": \"Identificador de campo de fecha de fin inválido\",\n      \"fieldMapRequired\": \"Se requiere mapa de campos cuando se establece la búsqueda\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected y filterLinkCellCandidate no se pueden establecer al mismo tiempo\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"El modelo de chat IA lg no está configurado\",\n      \"chatModelLgProviderNotSet\": \"El proveedor del modelo de chat IA lg no está configurado\",\n      \"chatModelSmNotSet\": \"El modelo de chat IA sm no está configurado\",\n      \"chatModelMdNotSet\": \"El modelo de chat IA md no está configurado\",\n      \"configurationNotSet\": \"La configuración de IA no está configurada\",\n      \"unsupportedProvider\": \"Proveedor de IA no compatible {{type}}\",\n      \"providerConfigurationNotSet\": \"La configuración del proveedor de IA no está configurada\",\n      \"testLLMFailed\": \"La prueba de conexión de LLM falló\",\n      \"audioNotSupported\": \"La entrada de audio no es compatible con este modelo {{model}}\",\n      \"imageNotSupported\": \"La entrada de imagen no es compatible con este modelo {{model}}\",\n      \"modelNotSet\": \"El modelo de IA no está configurado\",\n      \"unsupportedFileType\": \"Tipo de archivo no compatible {{mimetype}}\",\n      \"unsupportedModelType\": \"Tipo de modelo no compatible\",\n      \"embeddingModelNotSet\": \"El modelo de incrustación no está configurado\",\n      \"validateActionFailed\": \"Falló la validación de la acción AI del campo\",\n      \"generateFailed\": \"Falló la generación de AI\",\n      \"unsupportedActionType\": \"Tipo de acción AI no soportado\"\n    },\n    \"role\": {\n      \"notFound\": \"Rol no encontrado\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"El colaborador ya existe\",\n      \"notFound\": \"Colaborador no encontrado\",\n      \"userNotFoundInCollaborator\": \"Usuario no encontrado en colaboradores\",\n      \"noPermissionToDelete\": \"No tienes permiso para eliminar este colaborador\",\n      \"noPermissionToUpdate\": \"No tienes permiso para actualizar este colaborador\",\n      \"noPermissionToOperateRole\": \"No tienes permiso para operar este rol\",\n      \"alreadyExistedInBase\": \"El colaborador ya existe en la base\",\n      \"userNotFound\": \"Usuario no encontrado: {{userIds}}\",\n      \"baseNotFound\": \"Base no encontrada\",\n      \"noPermissionToAddRole\": \"No tienes permiso para agregar un colaborador con este rol\",\n      \"departmentNotFound\": \"Departamento no encontrado\"\n    },\n    \"table\": {\n      \"notFound\": \"Tabla no encontrada\",\n      \"dbTableNameAlreadyExists\": \"El nombre de la tabla de base de datos ya existe\",\n      \"anchorNotFound\": \"Tabla de anclaje no encontrada\",\n      \"notInTrash\": \"La tabla no está en la papelera\",\n      \"notSupportTableIndex\": \"El tipo de índice de tabla no es compatible\",\n      \"createTableIndexError\": \"Error al crear el índice de tabla\",\n      \"dropTableIndexError\": \"Error al eliminar el índice de tabla\",\n      \"notFoundPrimaryField\": \"Campo primario no encontrado en la tabla\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"El tipo de vista {{viewType}} no es compatible con la exportación\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Formato de archivo no compatible, solo se admiten {{supportType}}, el tipo de contenido de su archivo es {{fileFormat}}\",\n      \"notSupportedFileType\": \"Tipo de archivo de importación no compatible\",\n      \"exceedMaxFieldsLength\": \"El número de campos en la tabla no puede exceder {{maxFieldsLength}}, el actual es {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"La instancia actual no permite invitaciones de espacio por parte del administrador\",\n      \"invalidCode\": \"Código de invitación no válido\",\n      \"linkNotFound\": \"Enlace de invitación no encontrado\",\n      \"linkExpired\": \"El enlace de invitación ha expirado\",\n      \"limitExceeded\": \"Ha alcanzado el número máximo de invitaciones por hora\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"El favorito ya existe\",\n      \"notFound\": \"Favorito no encontrado\",\n      \"anchorNotFound\": \"Anclaje de favorito no encontrado\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Tipo de recurso no válido\",\n      \"notFound\": \"Elemento de la papelera no encontrado\",\n      \"parentSpaceTrashed\": \"No se puede restaurar esta base porque su espacio principal también está en la papelera\",\n      \"parentBaseOrSpaceTrashed\": \"No se puede restaurar esta tabla porque su base o espacio principal también está en la papelera\",\n      \"parentBaseTrashed\": \"No se puede restaurar este elemento porque su base principal también está en la papelera\",\n      \"parentNotFound\": \"Recurso principal no encontrado\",\n      \"tableNotFound\": \"Elemento de papelera de tabla no encontrado\"\n    },\n    \"license\": {\n      \"invalid\": \"La licencia no es válida\",\n      \"instanceIdMismatch\": \"El ID de instancia entrante no coincide con el ID de instancia de la instancia actual\",\n      \"expired\": \"La licencia ha expirado\",\n      \"userLimitExceeded\": \"El número de usuarios en la instancia actual excede el límite de asientos de la licencia. Por favor desactive algunos usuarios o actualice la licencia\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organización no encontrada\",\n      \"emailNotSpaceUser\": \"El correo electrónico no es usuario del espacio\",\n      \"authenticationNotFound\": \"Autenticación no encontrada\",\n      \"spaceShouldExist\": \"El espacio de la organización debería existir\",\n      \"emailsNotInOrgDomain\": \"Estos correos electrónicos {{emails}} no están en el dominio de la organización\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"La instancia actual no permite el registro por parte del administrador\",\n      \"waitlistInviteCodeRequired\": \"La lista de espera está habilitada, se requiere código de invitación\",\n      \"waitlistInviteCodeInvalid\": \"La lista de espera está habilitada, el código de invitación no es válido\",\n      \"systemUser\": \"El usuario es un usuario del sistema\",\n      \"collaboratorsInSpaces\": \"El usuario tiene colaboradores en espacios (o espacios eliminados en la papelera)\",\n      \"notFound\": \"Usuario no encontrado\",\n      \"cannotDeleteAdmin\": \"No se puede eliminar el usuario administrador\",\n      \"cannotDeactivateAdmin\": \"No se puede desactivar el usuario administrador\",\n      \"cannotRemoveLastAdmin\": \"No se pueden quitar los privilegios de administrador del último usuario administrador activo\",\n      \"permanentDeleted\": \"El usuario ha sido eliminado permanentemente\",\n      \"cannotDeleteSelf\": \"No puedes eliminarte a ti mismo\",\n      \"alreadyInDepartment\": \"El usuario {{userId}} ya está en el departamento objetivo\",\n      \"emailsNotFound\": \"Correos electrónicos {{emails}} no encontrados\",\n      \"deleted\": \"El usuario {{userId}} ha sido eliminado\",\n      \"alreadyInOrg\": \"El usuario {{userId}} ya está en la organización\",\n      \"notInOrg\": \"El usuario {{userId}} no está en la organización\"\n    },\n    \"record\": {\n      \"notFound\": \"Registro no encontrado\",\n      \"deletedIdsNotFound\": \"Algunos registros para eliminar no se encuentran\",\n      \"updateFailed\": \"Error al actualizar el registro\",\n      \"noFileOrUrlProvided\": \"No se proporcionó archivo o URL\",\n      \"createRecordsEmpty\": \"Crear registros no puede estar vacío\",\n      \"duplicateFailed\": \"Error al duplicar el registro\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Error en la validación del valor de celda\"\n    },\n    \"workflow\": {\n      \"notActive\": \"El flujo de trabajo no está activo\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Tipo de recurso no válido\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Categoría de plantilla no encontrada\",\n      \"snapshotRequired\": \"Esta plantilla no se pudo publicar debido a la falta de instantánea\",\n      \"sourceTemplateNotFound\": \"Plantilla de origen no encontrada\",\n      \"noMinOrderFound\": \"No se encontró el orden mínimo\",\n      \"takeCountTooLarge\": \"La cantidad de plantillas solicitadas excede el límite máximo\",\n      \"categoryLimitReached\": \"Límite de categorías de plantilla alcanzado (máximo {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"No se encontró código de verificación de dominio\",\n      \"invalidCode\": \"Código de verificación inválido\",\n      \"resendCooldown\": \"Por favor espera 1 minuto antes de solicitar un nuevo código\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Error al enviar el correo electrónico\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Departamento padre no encontrado\",\n      \"notFound\": \"Departamento no encontrado\",\n      \"cannotMoveToItself\": \"No se puede mover el departamento a sí mismo\",\n      \"cannotMoveToSub\": \"No se puede mover el departamento a su sub-departamento\"\n    },\n    \"app\": {\n      \"notFound\": \"Aplicación no encontrada\",\n      \"noFilesToUpdate\": \"No hay archivos para actualizar\",\n      \"noChatIdFound\": \"No se encontró ID de chat para esta aplicación\",\n      \"noChatFound\": \"No se encontró chat para esta aplicación\",\n      \"versionNotFound\": \"Versión no encontrada\",\n      \"cannotRollbackToLatestVersion\": \"No se puede revertir a la última versión\",\n      \"noChatOrProjectTokenFound\": \"No se encontró chat o token de proyecto\",\n      \"apiKeyNotSet\": \"La clave API del constructor de aplicaciones no está configurada\",\n      \"cannotDeployAppBeforeInitialization\": \"No se puede implementar la aplicación antes de la inicialización\",\n      \"noProjectOrVersionFound\": \"No se encontró proyecto o versión\",\n      \"noDeploymentUrlAvailable\": \"No hay URL de implementación disponible\"\n    },\n    \"reward\": {\n      \"notFound\": \"Recompensa no encontrada\",\n      \"unsupportedSourceType\": \"Tipo de fuente de recompensa no soportado\",\n      \"maxClaimsReached\": \"Ha alcanzado el máximo de reclamaciones de recompensa (2) para esta semana\",\n      \"verificationFailed\": \"Verificación fallida: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Ya ha reclamado una recompensa para esta cuenta esta semana\",\n      \"invalidPostUrl\": \"Formato de URL de publicación inválido\",\n      \"postAlreadyUsed\": \"Esta publicación ya ha sido utilizada para reclamar una recompensa\",\n      \"unsupportedPlatformUrl\": \"URL de plataforma social no soportada\",\n      \"unsupportedPlatform\": \"Plataforma no soportada: {{platform}}\",\n      \"minCharCount\": \"La publicación debe tener al menos {{count}} caracteres\",\n      \"minFollowerCount\": \"La cuenta debe tener al menos {{count}} seguidores\",\n      \"mustMention\": \"La publicación debe mencionar {{mention}}\",\n      \"fetchTweetFailed\": \"Error al obtener el tweet de X: {{error}}\",\n      \"tweetNotFound\": \"Tweet de X no encontrado: {{postId}}\",\n      \"fetchUserFailed\": \"Error al obtener el usuario de X: {{error}}\",\n      \"xUserNotFound\": \"Usuario de X no encontrado: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Error al obtener la publicación de LinkedIn: {{error}}\",\n      \"linkedInPostNotFound\": \"Publicación de LinkedIn no encontrada: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"Autor de LinkedIn no encontrado: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Error al obtener el usuario de LinkedIn: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Tokens de acceso personal\",\n  \"oauthApps\": \"Aplicaciones OAuth\",\n  \"plugins\": \"Complementos\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Ingresa tu contraseña para ver esta página\",\n    \"submit\": \"Enviar\",\n    \"password\": \"Contraseña\",\n    \"passwordTooShort\": \"La contraseña debe tener al menos 3 caracteres\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Seleccionar...\"\n  },\n  \"openOnNewPage\": \"Abrir en nueva página\",\n  \"errorTips\": \"La fuente compartida tiene habilitada una Matriz de Autoridad, no se permite la visualización\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/space.json",
    "content": "{\n  \"initialSpaceName\": \"Espacio de {{name}}\",\n  \"action\": {\n    \"createBase\": \"Crear una base\",\n    \"createSpace\": \"Crear un espacio\",\n    \"invite\": \"Invitar\"\n  },\n  \"allSpaces\": \"Todos los espacios\",\n  \"emptySpaceTitle\": \"No hay bases en este espacio\",\n  \"spaceIsEmpty\": \"Crea tu primera base para comenzar\",\n  \"baseModal\": {\n    \"copy\": \"Copiar\",\n    \"duplicate\": \"Duplicar \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Crear base desde plantilla\",\n    \"duplicateRecords\": \"Duplicar registros\",\n    \"duplicateRecordsTip\": \"Tu historial de revisiones y colaboradores no será duplicado.\",\n    \"toSpace\": \"Al espacio\",\n    \"copyToSpace\": \"Copiar al espacio\",\n    \"duplicateBase\": \"Duplicar base\",\n    \"missTargetTip\": \"Por favor, selecciona un espacio para duplicar la base.\",\n    \"copying\": \"Duplicando base, esto puede tomar un momento...\",\n    \"copyingTemplate\": \"Creando base desde plantilla, esto puede tomar un momento...\",\n    \"howToCreate\": \"¿Cómo quieres empezar?\",\n    \"fromScratch\": \"Desde cero\",\n    \"fromTemplate\": \"Desde plantilla\",\n    \"moveBaseToAnotherSpace\": \"Mover {{baseName}} a otro espacio\",\n    \"chooseSpace\": \"Seleccionar espacio\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Configuración del espacio\",\n    \"general\": \"General\",\n    \"collaborators\": \"Colaboradores\",\n    \"generalDescription\": \"Cambia la configuración de tu espacio actual aquí\",\n    \"collaboratorDescription\": \"Gestiona los colaboradores de tu espacio y establece sus permisos de acceso\",\n    \"spaceName\": \"Nombre del Espacio\",\n    \"spaceId\": \"ID del Espacio\"\n  },\n  \"pin\": {\n    \"add\": \"Agregar a fijados\",\n    \"remove\": \"Quitar de fijados\",\n    \"pin\": \"Fijar\",\n    \"empty\": \"Tus bases y espacios fijados aparecerán aquí\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"No tienes permiso para crear una base\"\n  },\n  \"tip\": {\n    \"delete\": \"¿Estás seguro de que quieres eliminar <0/>?\",\n    \"title\": \"Consejos\",\n    \"exportTips1\": \"Exportar la base actual como archivo .tea, lo que puede tomar cierto tiempo. Puedes verificar los resultados de la exportación en el centro de notificaciones.\",\n    \"exportTips2\": \"Puedes importar el archivo .tea en espacio -> más -> importar\",\n    \"exportTips3\": \"Los campos de relación entre bases se convertirán en texto de una sola línea.\",\n    \"exportIncludeDataLabel\": \"Incluir registros\",\n    \"exportIncludeDataDescription\": \"Desactiva para exportar solo la estructura y la configuración.\",\n    \"moveBaseSuccessTitle\": \"Movimiento exitoso\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} ha sido movido exitosamente a {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Eliminar espacio\",\n    \"blockedTitle\": \"No se puede eliminar este espacio\",\n    \"blockedDesc\": \"Este espacio tiene una suscripción activa. Por favor, cancela la suscripción antes de eliminar el espacio.\",\n    \"permanentDeleteWarning\": \"Esta acción eliminará permanentemente todos los recursos y datos del espacio actual. ¡Por favor, procede con precaución!\",\n    \"confirmInputLabel\": \"Por favor, escribe DELETE para confirmar la eliminación\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Bases compartidas\",\n    \"description\": \"Todas las bases a las que me invitaron a unirme\",\n    \"empty\": \"Aún no hay bases compartidas\"\n  },\n  \"integration\": {\n    \"title\": \"Integración\",\n    \"description\": \"Administrar integraciones de su espacio\",\n    \"addIntegration\": \"Agregar integración\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"Configuración de IA\",\n    \"description\": \"Administra la configuración de IA de tu espacio\",\n    \"enableTips\": \"Habilita la IA para usar funciones de IA en tu espacio en lugar de usar la IA del sistema\",\n    \"enable\": \"Inicializar configuración de IA\",\n    \"enableSwitchTips\": \"Por favor, configura el modelo de codificación grande antes de habilitar\"\n  },\n  \"import\": {\n    \"importing\": \"Importando\",\n    \"importWayTip\": \"Haz clic o arrastra y suelta el archivo en esta área para subirlo\",\n    \"baseImportTips\": \"Haz clic o arrastra y suelta el archivo .tea en esta área para subirlo\",\n    \"confirm\": \"Confirmar y continuar\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Haga clic para ver\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Plantilla\",\n    \"description\": \"Crea una nueva base rápidamente desde una plantilla\",\n    \"noTemplatesAvailable\": \"No hay plantillas disponibles\",\n    \"noTemplatesDescription\": \"No hay nada aquí en este momento\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Visitado recientemente\"\n  },\n  \"noBases\": {\n    \"title\": \"¡Hola {{userName}}!\",\n    \"description\": \"Comencemos a gestionar tu trabajo con tu primera base.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"¡Hola {{userName}}!\",\n    \"description\": \"Crea tu primer espacio para comenzar tu viaje de colaboración de datos.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Todas las bases\",\n    \"owner\": \"Propietario\",\n    \"createdTime\": \"Creado\",\n    \"lastOpened\": \"Abierto por última vez\",\n    \"enter\": \"Entrar\",\n    \"noTables\": \"Sin tablas\",\n    \"empty\": \"No hay bases aún\"\n  },\n  \"publishBase\": {\n    \"title\": \"Publicar Base en la comunidad\",\n    \"description\": \"Publica la base con un clic, ¡la inspiración ya no es solitaria! Deja que más personas vean, usen y hagan Remix de tu creatividad, y construyan juntos un negocio más poderoso\",\n    \"infoTitle\": \"Información básica\",\n    \"form\": {\n      \"title\": \"Título\",\n      \"description\": \"Descripción\",\n      \"security\": \"Seguridad\",\n      \"includeNodes\": \"Incluir nodos\",\n      \"advanced\": \"Avanzado\",\n      \"publishNode\": \"Publicar nodos\",\n      \"includeData\": \"Incluir datos\",\n      \"defaultActiveNode\": \"Nodo activo predeterminado\",\n      \"select\": \"por favor selecciona\",\n      \"descriptionPlaceholder\": \"Describe brevemente tu idea...\",\n      \"titlePlaceholder\": \"Nombra tu trabajo...\"\n    },\n    \"publishToCommunity\": \"Publicar en el centro de plantillas\",\n    \"publish\": \"Publicar\",\n    \"publishSuccess\": \"¡Publicación exitosa!\",\n    \"previewTips\": \"Deja que el mundo vea tu trabajo\",\n    \"update\": \"Actualizar\",\n    \"unPublish\": \"Despublicar\",\n    \"unPublishSuccess\": \"¡Base despublicada con éxito!\",\n    \"unPublishConfirmTitle\": \"Confirmar despublicación\",\n    \"unPublishConfirmDescription\": \"¿Estás seguro de que deseas despublicar esta base? Ya no será visible en el centro de plantillas de la comunidad.\",\n    \"usageCount\": \"Recuento de uso: \",\n    \"uploadCover\": \"Clic para subir imagen de portada\",\n    \"changeCover\": \"Clic para cambiar portada\",\n    \"uploading\": \"Subiendo imagen...\",\n    \"uploadSuccess\": \"Imagen subida con éxito\",\n    \"uploadFailed\": \"Error al subir\",\n    \"invalidImageType\": \"Por favor selecciona un archivo de imagen\",\n    \"tips\": {\n      \"publishValidation\": \"título y descripción son obligatorios\",\n      \"atLeastOneNode\": \"Selecciona al menos un nodo para publicar\"\n    },\n    \"urlCopied\": \"¡URL copiada al portapapeles!\",\n    \"urlCopiedForDiscord\": \"¡URL copiada al portapapeles! Puedes pegarla en Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Seleccionado oficialmente como destacado. Tu plantilla recibirá más exposición.\",\n    \"unfeaturedTip\": \"Aún no destacado. Sigue mejorando para tener la oportunidad de ser recomendado. Deja que más personas vean tu trabajo.\",\n    \"publishSuccessDescription\": \"Comparte tu trabajo con el mundo\",\n    \"shareWith\": \"Compartir con\",\n    \"unpublishedApps\": {\n      \"title\": \"Apps no publicadas detectadas\",\n      \"description\": \"Las apps no publicadas pueden causar errores en la vista previa de la plantilla. Publícalas ahora o continúa de todos modos.\",\n      \"publishAll\": \"Publicar todas\",\n      \"publish\": \"Publicar\",\n      \"published\": \"Publicada\",\n      \"publishing\": \"Publicando...\",\n      \"publishFailed\": \"Error al publicar\",\n      \"publishFailedTip1\": \"Verifique si la app de origen se puede publicar correctamente\",\n      \"publishFailedTip2\": \"Intente volver a publicar esta plantilla\",\n      \"notPublished\": \"No publicada\",\n      \"ignoreAndContinue\": \"Ignorar y continuar\",\n      \"goToFix\": \"Ir a corregir\",\n      \"redeploy\": \"Redesplegar\",\n      \"unnamedApp\": \"Aplicación sin nombre\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Próximamente\",\n    \"viewFilterInShare\": \"Esta vista se está utilizando en un enlace compartido. Las modificaciones a la configuración de la vista también cambiarán el enlace compartido.\",\n    \"createFieldButtonText\": \"Crear un nuevo campo <0/>\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Compartir\",\n        \"statusLabel\": \"Compartir vista en la web\",\n        \"noPermission\": \"Sin permiso para compartir vista\",\n        \"shareLink\": \"Enlace para compartir\",\n        \"copied\": \"Copiado\",\n        \"genLink\": \"Generar nuevo enlace\",\n        \"allowCopy\": \"Permitir a los visualizadores copiar datos de esta vista\",\n        \"showAllFields\": \"Mostrar todos los campos en registros expandidos\",\n        \"restrict\": \"Restringir por contraseña\",\n        \"tips\": \"Las personas con el enlace pueden ver la vista.\",\n        \"passwordTitle\": \"Ingresa una contraseña\",\n        \"passwordTips\": \"Restricciones de contraseña para acceder a las vistas compartidas\",\n        \"embed\": \"Incrustar\",\n        \"embedPreview\": \"Vista previa incrustada\",\n        \"hideToolbar\": \"Ocultar barra de herramientas\",\n        \"URLSetting\": \"Configuración de parámetros URL\",\n        \"URLSettingDescription\": \"Ajustar las siguientes configuraciones no afectará los enlaces compartidos. Necesitas copiar el enlace con los nuevos parámetros para que surta efecto\",\n        \"cancel\": \"Cancelar\",\n        \"save\": \"Guardar\",\n        \"requireLogin\": \"Requerir inicio de sesión\"\n      },\n      \"extensions\": {\n        \"label\": \"Extensiones\",\n        \"graph\": \"Gráfico\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"API RESTful\",\n        \"databaseConnection\": \"Conexión a Base de Datos\"\n      },\n      \"personalView\": {\n        \"personal\": \"Personal\",\n        \"tip\": \"Una vez habilitado, la configuración de la vista solo se aplicará a ti\",\n        \"collaborative\": \"Colaborativo\",\n        \"dialog\": {\n          \"title\": \"Salir del modo personal\",\n          \"description\": \"La configuración de la vista personal se restaurará al estado de colaboración en tiempo real. También puedes guardar los ajustes de la vista personal y sincronizarlos con todos.\",\n          \"cancelText\": \"Salir y sincronizar\",\n          \"confirmText\": \"Confirmar salida\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Bienvenido\",\n    \"emptyTitle\": \"Comienza a construir tu base de datos\",\n    \"description\": \"Haz clic en el botón \\\"+\\\" de la barra lateral para agregar recursos\",\n    \"help\": \"Visite el <HelpCenter /> para obtener más información.\",\n    \"helpCenter\": \"Centro de ayuda\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"No se puede vincular el registro: en el mismo lote ya está vinculado por otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.\",\n      \"one_many_duplicate\": \"No se puede vincular el registro: ya está vinculado a otro registro. En las relaciones de uno a muchos, cada registro secundario solo puede pertenecer a un registro principal.\",\n      \"one_one_duplicate\": \"No se puede vincular el registro: el registro de destino ya está vinculado por otro registro en una relación de uno a uno.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"La tabla \\\"{{tableName}}\\\" puede tener como máximo {{maxFieldCount}} campos.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Gestión de Campos\",\n    \"fieldManagementDesc\": \"Propiedades detalladas para todos los campos de la tabla actual\",\n    \"advancedProps\": \"Propiedades avanzadas\",\n    \"hide\": \"ocultar\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Etiqueta\"\n      },\n      \"longText\": {\n        \"title\": \"Notas\"\n      },\n      \"number\": {\n        \"title\": \"Número\",\n        \"formatType\": \"Tipo de formato\",\n        \"currencySymbol\": \"Símbolo de moneda\",\n        \"defaultSymbol\": \"$\",\n        \"precision\": \"Precisión\",\n        \"decimalExample\": \"Número (123)\",\n        \"currencyExample\": \"Moneda ($ 100)\",\n        \"percentExample\": \"Porcentaje (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Estado\",\n        \"options\": {\n          \"todo\": \"Hacer\",\n          \"inProgress\": \"En curso\",\n          \"done\": \"Hecho\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Etiquetas\"\n      },\n      \"attachment\": {\n        \"title\": \"Accesorios\"\n      },\n      \"user\": {\n        \"title\": \"Colaborador\"\n      },\n      \"date\": {\n        \"title\": \"Fecha\",\n        \"dateFormatting\": \"Formato de fecha\",\n        \"timeFormatting\": \"Formato de tiempo\",\n        \"timeZone\": \"Huso horario\",\n        \"yearMonth\": \"Año/mes\",\n        \"monthDay\": \"Mes/día\",\n        \"year\": \"Año\",\n        \"month\": \"Mes\",\n        \"day\": \"Día\",\n        \"local\": \"Local\",\n        \"friendly\": \"Amigable\",\n        \"us\": \"A NOSOTROS\",\n        \"european\": \"europeo\",\n        \"asia\": \"Asia\",\n        \"custom\": \"Costumbre\",\n        \"12Hour\": \"12 horas\",\n        \"24Hour\": \"24 horas\",\n        \"noDisplay\": \"Sin pantalla\"\n      },\n      \"autoNumber\": {\n        \"title\": \"IDENTIFICACIÓN\"\n      },\n      \"createdTime\": {\n        \"title\": \"Tiempo creado\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Último tiempo modificado\"\n      },\n      \"createdBy\": {\n        \"title\": \"Creado por\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Última modificación por\"\n      },\n      \"rating\": {\n        \"title\": \"Clasificación\"\n      },\n      \"checkbox\": {\n        \"title\": \"Hecho\"\n      },\n      \"button\": {\n        \"title\": \"Botón\",\n        \"label\": \"Texto del botón\",\n        \"color\": \"Color del botón\",\n        \"limitCount\": \"Limitar contador de clics\",\n        \"resetCount\": \"Permitir restablecer contador de clics\",\n        \"maxCount\": \"Máximo de clics\",\n        \"automation\": \"Automatización\",\n        \"customAutomation\": \"Automatización personalizada\",\n        \"clickConfirm\": \"Confirmar antes de hacer clic\",\n        \"confirmTitle\": \"Título\",\n        \"confirmDescription\": \"Contenido\",\n        \"confirmButtonText\": \"Texto del botón de confirmación\"\n      },\n      \"formula\": {\n        \"title\": \"Cálculo\",\n        \"formula\": \"Fórmula\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (de {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (filtrado de {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"Resumen de {{lookupFieldName}} (de {{linkFieldName}})\",\n        \"rollup\": \"Acurrucado\",\n        \"selectAnRollupFunction\": \"Seleccione una función enrollable\",\n        \"func\": {\n          \"and\": \"Y\",\n          \"arrayCompact\": \"Arraycompact\",\n          \"arrayJoin\": \"Arrayjoin\",\n          \"arrayUnique\": \"Arrayunique\",\n          \"average\": \"PROMEDIO\",\n          \"concatenate\": \"CONCATENAR\",\n          \"count\": \"CONTAR\",\n          \"countA\": \"Cuenta\",\n          \"countAll\": \"Cuenta\",\n          \"max\": \"Máximo\",\n          \"min\": \"Mínimo\",\n          \"or\": \"O\",\n          \"sum\": \"SUMA\",\n          \"xor\": \"Xor\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Devuelve verdadero si todos los valores son verdaderos\",\n          \"arrayCompact\": \"Elimina cadenas vacías y valores nulos de la matriz. \",\n          \"arrayJoin\": \"Une todos los valores en una sola cadena separada por comas.\",\n          \"arrayUnique\": \"Devuelve solo elementos únicos.\",\n          \"average\": \"Promedio medio de los valores.\",\n          \"concatenate\": \"Une los valores de texto en un solo valor de texto.\",\n          \"count\": \"Cuente solo valores numéricos no vacíos. \",\n          \"countA\": \"Cuente el número de valores no vacíos. \",\n          \"countAll\": \"Cuenta todos los valores, incluidos los registros en blanco.\",\n          \"max\": \"Devuelve el más grande de los números dados.\",\n          \"min\": \"Devuelve el más pequeño de los números dados.\",\n          \"or\": \"Devuelve verdadero si alguno de los valores es verdadero.\",\n          \"sum\": \"Sumar los valores.\",\n          \"xor\": \"Devuelve verdadero si y solo si el número impar de valores es verdadero.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"Resumen condicional de {{lookupFieldName}}\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Agregar Campo\",\n      \"editField\": \"Editar Campo\",\n      \"insertField\": \"Insertar Campo\",\n      \"graph\": \"Gráfico\",\n      \"defaultValue\": \"Valor predeterminado\",\n      \"reset\": \"Reiniciar\",\n      \"fieldUpdated\": \"El campo se ha actualizado\",\n      \"fieldCreated\": \"Se ha creado el campo\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"¿Estás seguro de que quieres realizarlo?\",\n      \"addDescription\": \"Agregar descripción\",\n      \"dbFieldName\": \"Nombre de campo de DB\",\n      \"description\": \"Descripción\",\n      \"descriptionPlaceholder\": \"Describe este campo (opcional)\",\n      \"type\": \"Tipo\",\n      \"showAs\": \"Exhibir\",\n      \"color\": \"Color\",\n      \"number\": \"Número\",\n      \"chartBar\": \"Barra de gráfico\",\n      \"chartLine\": \"Línea de gráfico\",\n      \"ring\": \"Anillo\",\n      \"bar\": \"Bar\",\n      \"text\": \"Texto\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"Correo electrónico\",\n      \"phone\": \"Teléfono\",\n      \"maxNumber\": \"Número máximo\",\n      \"showNumber\": \"Numero\",\n      \"autoFillDate\": \"Llena automática con la fecha actual\",\n      \"createSymmetricLink\": \"Crear un campo de enlace posterior en la tabla de enlaces\",\n      \"allowLinkMultipleRecords\": \"Permitir múltiples select\",\n      \"allowLinkToDuplicateRecords\": \"Permite que los registros se seleccionen más de una vez\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Permite que los registros se seleccionen más de una vez\",\n      \"oneToOne\": \"cara a cara\",\n      \"oneToMany\": \"uno a muchos\",\n      \"manyToOne\": \"muchos a uno\",\n      \"manyToMany\": \"muchos a muchos\",\n      \"self\": \"Ser\",\n      \"selectTable\": \"Seleccionar tabla ...\",\n      \"selectBase\": \"Seleccione una base ...\",\n      \"linkFromAnotherBase\": \"Enlace desde la base externa\",\n      \"inSelfLink\": \"en retroceso\",\n      \"betweenTwoTables\": \"Entre dos mesas\",\n      \"style\": \"Estilo\",\n      \"maximum\": \"Máximo\",\n      \"addOption\": \"Añadir opción\",\n      \"allowMultiUsers\": \"Permitir agregar varios usuarios\",\n      \"notifyUsers\": \"Notifique a los usuarios una vez que sean seleccionados\",\n      \"searchTable\": \"Tabla de búsqueda ...\",\n      \"calculating\": \"Calculador...\",\n      \"doSaveChanges\": \"¿Quieres guardar los cambios que hiciste?\",\n      \"linkFieldToLookup\": \"Campo de registro vinculado para usar para la búsqueda\",\n      \"lookupToTable\": \"Campo de <bold>{{tableName}}</bold> que desea buscar\",\n      \"rollupToTable\": \"Campo de <bold>{{tableName}}</bold> que desea buscar\",\n      \"selectField\": \"Seleccione un campo ...\",\n      \"linkTable\": \"Tabla de enlace\",\n      \"linkBase\": \"Base de enlace\",\n      \"tableNoPermission\": \"No hay tabla de permiso\",\n      \"baseNoPermission\": \"Sin base de permiso\",\n      \"noLinkTip\": \"No hay registros vinculados para buscar. \",\n      \"fieldValidationRules\": \"Reglas de validación de valor de campo\",\n      \"enableValidateFieldUnique\": \"Prohibir los valores duplicados\",\n      \"enableValidateFieldNotNull\": \"Obligatorio\",\n      \"knowMore\": \"conocer más\",\n      \"linkFieldKnowMoreLink\": \"https://help.table.io/en/basic/field/advanced/link\",\n      \"showByField\": \"Mostrar registros por campo\",\n      \"filterByView\": \"Registros de filtro por vista\",\n      \"filter\": \"Registros de filtro\",\n      \"hideFields\": \"Ocultar campos\",\n      \"moreOptions\": \"Más opciones\",\n      \"allowNewOptionsWhenEditing\": \"Permitir nuevas opciones al editar\",\n      \"deleteField\": {\n        \"title\": \"Eliminar campo\",\n        \"simpleConfirm\": \"¿Está seguro de que desea eliminar el campo <b>{{fieldName}}</b>?\",\n        \"withDependencies\": \"Eliminar el campo <b>{{fieldName}}</b> afectará a los siguientes campos:\",\n        \"affectedFields\": \"Campos afectados:\",\n        \"fieldsToDelete\": \"Campos a eliminar ({{count}})\",\n        \"unviewedHint\": \"{{count}} campo(s) no revisado(s)\",\n        \"deleteCount\": \"Eliminar {{count}} campos\",\n        \"noAffectedFields\": \"Este campo no está referenciado por otros campos.\",\n        \"riskIdentified\": \"Riesgo identificado({{count}})\",\n        \"noDependencies\": \"Sin dependencias({{count}})\",\n        \"safeToDelete\": \"Seguro para eliminar\",\n        \"safeToDeleteDesc\": \"Este campo no está referenciado por otros campos, se puede eliminar de forma segura\",\n        \"affectedItems\": \"Elementos afectados\",\n        \"type\": \"Tipo\",\n        \"source\": \"Fuente\",\n        \"sourceTable\": \"Tabla de origen\",\n        \"typeField\": \"Campo\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Enlace a los registros en la tabla que elija\",\n      \"singleLineText\": \"Ingrese el texto o prefiera cada nueva celda con un valor predeterminado.\",\n      \"longText\": \"Ingrese múltiples líneas de texto.\",\n      \"attachment\": \"Agregue o genere con IA imágenes, o cargue documentos u otros archivos para verse o descargar.\",\n      \"checkbox\": \"Verifique o desmarque para indicar el estado.\",\n      \"multipleSelect\": \"Seleccione una o más opciones predefinidas en una lista.\",\n      \"singleSelect\": \"Seleccione una opción predefinida de una lista, o prefire cada celda nueva con una opción predeterminada.\",\n      \"user\": \"Agregue un usuario a un registro.\",\n      \"date\": \"Ingrese una fecha (por ejemplo, 11/12/2023) o elija una de un calendario.\",\n      \"number\": \"Ingrese un número, o prefiera cada nueva celda con un valor predeterminado.\",\n      \"duration\": \"Ingrese una duración del tiempo en horas, minutos o segundos (por ejemplo, 1:23).\",\n      \"rating\": \"Agregue una calificación en una escala predefinida.\",\n      \"formula\": \"Calcule los valores basados ​​en los campos.\",\n      \"rollup\": \"Resumir los datos de los registros vinculados.\",\n      \"conditionalLookup\": \"Muestra valores vinculados que cumplen sus filtros.\",\n      \"count\": \"Cuente el número de registros vinculados.\",\n      \"createdTime\": \"Vea la fecha y hora en que se creó cada registro.\",\n      \"lastModifiedTime\": \"Vea la fecha y hora de la edición más reciente a algunos o todos los campos en un registro.\",\n      \"createdBy\": \"Vea qué usuario creó el registro.\",\n      \"lastModifiedBy\": \"Vea qué usuario hizo la edición más reciente a algunos o todos los campos en un registro.\",\n      \"autoNumber\": \"Genere automáticamente números incrementales únicos para cada registro.\",\n      \"button\": \"Activar una acción personalizada.\",\n      \"lookup\": \"Vea los valores desde un campo en un registro vinculado.\"\n    },\n    \"fieldName\": \"Nombre de campo\",\n    \"fieldNameOptional\": \"Nombre de campo (opcional)\",\n    \"fieldType\": \"Tipo de Campo\",\n    \"aiConfig\": {\n      \"title\": \"Configuración de IA\",\n      \"type\": {\n        \"summary\": \"Resumir\",\n        \"translation\": \"Traducir\",\n        \"extraction\": \"Extraer información\",\n        \"improvement\": \"Mejorar\",\n        \"tag\": \"Etiqueta inteligente\",\n        \"classification\": \"Clasificar inteligente\",\n        \"customization\": \"Personalizar\",\n        \"imageGeneration\": \"Generación de imágenes\",\n        \"rating\": \"Clasificación de imágenes\"\n      },\n      \"label\": {\n        \"type\": \"Tipo de acción de IA\",\n        \"model\": \"Modelo de IA\",\n        \"targetLanguage\": \"Idioma objetivo\",\n        \"sourceField\": \"Campo de origen\",\n        \"sourceFieldForTag\": \"Seleccione un campo, coincida con las etiquetas creadas\",\n        \"sourceFieldForClassify\": \"Seleccione un campo, coincida con las clasificaciones creadas\",\n        \"attachPrompt\": \"Adjuntar requisitos\",\n        \"prompt\": \"Personalizar prompt\",\n        \"sourceFieldForAttachment\": \"Campo de origen para el adjunto\",\n        \"imageSize\": \"Tamaño de la imagen\",\n        \"imageQuality\": \"Calidad de la imagen\",\n        \"imageCount\": \"Número de imágenes\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Resumen del contenido\",\n        \"translate\": \"Traducción concisa y fácil de entender, light-hearted\",\n        \"extractInfo\": \"Extraer email, teléfono, nombre, dirección...\",\n        \"extractDate\": \"Extraer hora de inicio\",\n        \"improveText\": \"Formal, amigable, humorístico...\",\n        \"attachPromptForTag\": \"No permitido superar tres etiquetas\",\n        \"attachPromptForClassify\": \"Clasificar “En curso” como “Sin riesgo”\",\n        \"attachPrompt\": \"Por favor, ingrese requisitos adicionales\",\n        \"prompt\": \"Por favor, ingrese un prompt personalizado\",\n        \"type\": \"Seleccione una acción de IA\",\n        \"targetLanguage\": \"Inglés, chino, francés...\",\n        \"imageSize\": \"Por favor, ingrese el tamaño de la imagen\",\n        \"imageQuality\": \"Por favor, ingrese la calidad de la imagen\",\n        \"attachPromptForImageGeneration\": \"La imagen debe ser viva y natural\",\n        \"attachPromptForRating\": \"Clasifique la calidad de la imagen\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Bajo\",\n        \"medium\": \"Medio\",\n        \"high\": \"Alto\"\n      },\n      \"autoFill\": {\n        \"title\": \"Actualizar automáticamente\",\n        \"tip\": \"Después de habilitarlo, el campo actual se actualizará sincronizadamente con los cambios del contenido de la configuración de IA\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Actualizar todos los registros\",\n        \"description\": \"Todos los registros en la vista actual se actualizarán, incluidos todos los datos relacionados generados por el campo\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"¿Generar toda la columna?\",\n        \"description\": \"Generar toda la columna actualizará {{rowCount}} filas. Esta operación puede consumir muchos recursos de IA.\",\n        \"saveConfigOnly\": \"Solo guardar configuración\",\n        \"generate\": \"Generar\",\n        \"generateFailed\": \"La generación ha fallado\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Agregar adjunto\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Nueva tabla\",\n    \"rename\": \"Renombrar\",\n    \"design\": \"Diseñar\",\n    \"tableRecordHistory\": \"Historial de registros de tabla\",\n    \"dbTableName\": \"Nombre de la tabla en la base de datos física\",\n    \"schemaName\": \"Nombre del esquema en la base de datos física\",\n    \"baseInfo\": \"Información base\",\n    \"typeOfDatabase\": \"Tipo de base de datos\",\n    \"descriptionForTable\": \"Descripción para esta tabla\",\n    \"nameForTable\": \"Nombre para esta tabla\",\n    \"deleteTip1\": \"Se eliminarán los campos de enlace vinculados con esta tabla en otras tablas.\",\n    \"deleteTip2\": \"Esta tabla se puede restaurar desde la basura después de la eliminación.\",\n    \"operator\": {\n      \"createBlank\": \"Nueva tabla\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Copiar y pegar solo funciona en https o localhost\",\n      \"copyAndPasteBrowser\": \"Copiar y pegar no admitido en este navegador\",\n      \"copying\": \"Proceso de copiar...\",\n      \"copySuccessful\": \"Copiar exitoso\",\n      \"copyFailed\": \"Copia fallida\",\n      \"pasting\": \"Paliza...\",\n      \"pasteSuccessful\": \"Pegar exitoso\",\n      \"pasteFailed\": \"Pasta falló\",\n      \"filling\": \"Llenando...\",\n      \"fillSuccessful\": \"Llenado exitoso\",\n      \"fillFailed\": \"Llenado falló\",\n      \"clearing\": \"Claro...\",\n      \"clearSuccessful\": \"Claro exitoso\",\n      \"deleteFieldConfirmTitle\": \"Eliminar los siguientes campos\",\n      \"deleting\": \"Eliminar ...\",\n      \"deleteSuccessful\": \"Eliminar exitoso\",\n      \"pasteFileFailed\": \"Los archivos solo se pueden pegar en un campo de archivo adjunto\",\n      \"copyError\": {\n        \"noFocus\": \"Mantenga la página activa y no cambie Windows\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Etiqueta de mesa\",\n      \"effectCells\": \"Puede afectar las células\",\n      \"estimatedTime\": \"Tiempo estimado\",\n      \"linkFieldCount\": \"Afecta el número de campos vinculados\"\n    },\n    \"integrity\": {\n      \"check\": \"Controlar\",\n      \"title\": \"Comprobación de integridad\",\n      \"loading\": \"Verificación de integridad ...\",\n      \"allGood\": \"¡Todo está bien!\",\n      \"fixIssues\": \"Solucionar problemas\"\n    },\n    \"index\": {\n      \"repair\": \"reparar\",\n      \"repairTip\": \"Anomalías de índice detectados, lo que puede causar la degradación del rendimiento de la búsqueda. \",\n      \"enableIndexTip\": \"Estás creando un índice. \",\n      \"globalSearchTip_infinity\": \"Busque todos los campos, excepto la fecha, la casilla de verificación y los campos de botón\",\n      \"globalSearchTip_limited\": \"Busque todos los campos, excepto la fecha, la casilla de verificación y los campos de botón, máximo {{count}} campos\",\n      \"enableIndex\": \"permitir\",\n      \"keepAsIs\": \"guarda\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Subir\",\n      \"import\": \"Importar\",\n      \"localFile\": \"Archivos Locales\",\n      \"linkUrl\": \"Enlace (URL)\",\n      \"linkUrlInputTitle\": \"Agregar archivo de URL\",\n      \"importTitle\": \"Crea una nueva mesa\",\n      \"incrementImportTitle\": \"Importación en -\",\n      \"optionsTitle\": \"Opción de importación\",\n      \"primitiveFields\": \"Campos primitivos\",\n      \"importFields\": \"Campo de importación\",\n      \"primaryField\": \"Campo primario\",\n      \"tipsTitle\": \"Consejos\",\n      \"confirm\": \"Confirmar y continuar\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Agregar de otras fuentes\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"Archivo CSV\",\n      \"importCsvData\": \"Importar datos CSV\",\n      \"importExcelData\": \"Importar datos de Microsoft Excel\",\n      \"cancel\": \"Cancelar\",\n      \"leave\": \"Dejar\",\n      \"downAsCsv\": \"Descargar csv\",\n      \"importData\": \"Importar datos\",\n      \"duplicate\": \"Duplicar\",\n      \"duplicating\": \"Duplicando...\",\n      \"duplicateSuccess\": \"Duplicado con éxito\",\n      \"duplicateFailed\": \"Error al duplicar\",\n      \"importing\": \"Importando\",\n      \"includeRecords\": \"Incluir registros\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Haga clic o arrastre el archivo a esta área para cargar\",\n      \"leaveTip\": \"Sus datos aún se importarán.\",\n      \"fileExceedSizeTip\": \"Este tipo de archivo de tipo excede el límite de\",\n      \"analyzing\": \"analización\",\n      \"importing\": \"Importador\",\n      \"notSupportFieldType\": \"El tipo de campo no es compatible\",\n      \"resultEmpty\": \"No se encontraron resultados.\",\n      \"searchPlaceholder\": \"Buscar...\",\n      \"importAlert\": \"Una vez que comienza la importación, no se puede detener hasta que se complete o termine con éxito debido a la falla. \",\n      \"noTips\": \"Sé eso, no te muestres de nuevo\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Tipos de campo automáticos\",\n      \"useFirstRowAsHeaderOptionName\": \"Use la primera fila como encabezado\",\n      \"importDataOptionName\": \"Importar datos\",\n      \"sheetKey\": \"Nombre de la hoja:\",\n      \"excludeFirstRow\": \"Excluir la primera fila en importación\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Campo\",\n      \"error\": {\n        \"urlEmptyTip\": \"¡URL no debe estar vacía!\",\n        \"errorFileFormat\": \"¡El formato de archivo es incorrecto!\",\n        \"uniqueFieldName\": \"¡El nombre del campo debería ser único!\",\n        \"fieldNameEmpty\": \"¡El nombre del campo no debe estar vacío!\",\n        \"atLeastAImportField\": \"Establezca un campo de importación al menos\",\n        \"urlValidateTip\": \"No se pudo analizar la URL. \"\n      },\n      \"option\": {\n        \"doNotImport\": \"No importe\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Descargar CSV\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Agregar nuevo registro\",\n    \"prefillingRowTooltip\": \"Por favor, ingresa los datos del nuevo registro a continuación. El registro se guardará automáticamente cuando hagas clic fuera de esta fila.\",\n    \"presortRowTitle\": \"Este registro ha sido filtrado o movido debido a reglas de ordenamiento\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Campos\",\n    \"addAll\": \"Agregar todos\",\n    \"removeAll\": \"Eliminar todos\",\n    \"hideFieldTip\": \"Ocultar el campo aquí\",\n    \"unableAddFieldTip\": \"No se puede agregar este tipo de campo\",\n    \"removeFromFormTip\": \"Eliminar del formulario\",\n    \"descriptionPlaceholder\": \"Ingresa la descripción del formulario\",\n    \"dragToFormTip\": \"Arrastra el campo aquí para agregarlo al formulario\",\n    \"protectedFieldTip\": \"Este campo se ha establecido como campo \\\"requerido\\\" y no se puede eliminar en la vista de formulario. Por favor, modifíquelo en la configuración del campo.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Ocultar nombre del campo\",\n      \"customizeCards\": \"Personalizar tarjetas\",\n      \"stackedBy\": \"Agrupado por <0/>\",\n      \"chooseStackingField\": \"Elegir un campo de agrupación\",\n      \"chooseStackingFieldDescription\": \"¿Qué campo te gustaría usar para esta vista kanban? Tus registros se agruparán según este campo.\",\n      \"hideEmptyStack\": \"Ocultar grupo vacío\",\n      \"imageSetting\": \"Configuración de imagen\",\n      \"fit\": \"Ajustar\",\n      \"noImage\": \"Sin imagen\",\n      \"chooseAttachmentField\": \"Elegir campo de adjunto\"\n    },\n    \"stack\": {\n      \"addStack\": \"Agregar grupo\",\n      \"noCards\": \"Sin tarjetas\",\n      \"uncategorized\": \"Sin categorizar\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Colapsar grupo\",\n      \"renameStack\": \"Renombrar grupo\",\n      \"deleteStack\": \"Eliminar grupo\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Insertar tarjeta arriba\",\n      \"insertCardBelow\": \"Insertar tarjeta abajo\",\n      \"expandCard\": \"Expandir tarjeta\",\n      \"deleteCard\": \"Eliminar tarjeta\",\n      \"duplicateCard\": \"Duplicar tarjeta\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Configuración de Calendario\",\n      \"startDateField\": \"Campo de fecha de inicio\",\n      \"endDateField\": \"Campo de fecha de fin\",\n      \"titleField\": \"Campo de título\",\n      \"colorField\": \"Campo de color\",\n      \"colorType\": \"Visualización de color\",\n      \"customColor\": \"Personalizar el color\",\n      \"alignWithRecords\": \"Alinearse con los registros\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Seleccione un campo de color\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Fecha de inicio\",\n      \"endDate\": \"Fecha final\",\n      \"notAdd\": \"No agregar\",\n      \"addDateField\": \"Agregar campos de fecha\",\n      \"content\": \"Cree un horario de vista de calendario, y la tabla debe tener dos campos de fecha: fecha de inicio y fecha de finalización\"\n    }\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Insertar <input /> registro arriba\",\n    \"insertRecordBelow\": \"Insertar <input /> registro a continuación\",\n    \"copyCells\": \"Copiar celdas\",\n    \"deleteRecord\": \"Eliminar registro\",\n    \"deleteAllSelectedRecords\": \"Eliminar todos los registros seleccionados\",\n    \"editField\": \"Campo de edición\",\n    \"insertFieldLeft\": \"Insertar a la izquierda\",\n    \"insertFieldRight\": \"Insertar a la derecha\",\n    \"freezeUpField\": \"Congelarse a este campo\",\n    \"hideField\": \"Esconder el campo\",\n    \"deleteField\": \"Eliminar campo\",\n    \"deleteAllSelectedFields\": \"Eliminar todos los campos seleccionados\",\n    \"filterField\": \"Filtrar por este campo\",\n    \"sortField\": \"Ordenar por este campo\",\n    \"groupField\": \"Grupo por este campo\",\n    \"autoFill\": \"Actualizar todos los registros\",\n    \"groupMenuTitle\": \"Menú de grupo\",\n    \"expandGroup\": \"Expandir este grupo y sus subgrupos\",\n    \"collapseGroup\": \"Colapsar este grupo y sus subgrupos\",\n    \"expandAllGroups\": \"Expandir todos los grupos\",\n    \"collapseAllGroups\": \"Colapsar todos los grupos\",\n    \"addToChat\": \"Añadir al chat\"\n  },\n  \"connection\": {\n    \"title\": \"Conexiones\",\n    \"description\": \"Puede acceder a la base de datos directamente a través de la conexión de la base de datos, incluidas todas las tablas bajo la base actual\",\n    \"noPermission\": \"No tiene permiso para acceder a la base de datos\",\n    \"createFailed\": \"Asegúrese de que la variable de entorno public_database_proxy esté configurada correctamente\",\n    \"helpLink\": \"https://help.table.io/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Agregar registro\",\n    \"searchView\": \"Vista de búsqueda ...\",\n    \"dragToolTip\": \"La clasificación automática está activada, el arrastre manual no está disponible\",\n    \"insertToolTip\": \"La clasificación automática se enciende, el inserto con el pedido no está disponible\",\n    \"action\": {\n      \"rename\": \"Cambiar el nombre de la vista\",\n      \"duplicate\": \"Vista duplicada\",\n      \"delete\": \"Eliminar vista\",\n      \"lock\": \"Vista de bloqueo\",\n      \"unlock\": \"Vista de desbloqueo\",\n      \"enable\": \"Permitir\"\n    },\n    \"category\": {\n      \"table\": \"Vista de la cuadrícula\",\n      \"form\": \"Vista de formulario\",\n      \"kanban\": \"Vista kanban\",\n      \"gallery\": \"Vista de la galería\",\n      \"calendar\": \"Vista de calendario\"\n    },\n    \"crash\": {\n      \"title\": \"¡Chocar!\",\n      \"description\": \"Esta vista está rota. \"\n    },\n    \"addPluginView\": \"Agregar vista de complemento\",\n    \"search\": {},\n    \"locked\": {\n      \"tip\": \"Esta vista está bloqueada. \"\n    },\n    \"noView\": \"No hay vistas\"\n  },\n  \"lastModifiedTime\": \"Último tiempo modificado\",\n  \"lastModify\": \"Última modificación:\",\n  \"pasteNewRecords\": {\n    \"title\": \"¿Quieres agregar múltiples registros?\"\n  },\n  \"tableTrash\": {\n    \"title\": \"Papelera de tablas\",\n    \"resourceType\": \"Tipo de recurso\",\n    \"deletedResource\": \"Recurso eliminado\"\n  },\n  \"baseShare\": {\n    \"title\": \"Compartir Base\",\n    \"shareTitle\": \"Compartir\",\n    \"shareToWeb\": \"Compartir en la web\",\n    \"description\": \"Compartir \\\"{{baseName}}\\\" con otros\",\n    \"nodeShareDescription\": \"Compartir \\\"{{nodeName}}\\\" y su contenido\",\n    \"shareLinks\": \"Enlaces de compartición\",\n    \"newLink\": \"Nuevo enlace\",\n    \"noShareLinks\": \"Aún no hay enlaces de compartición\",\n    \"createFirstLink\": \"Crear enlace de compartición\",\n    \"editSettings\": \"Editar configuración\",\n    \"refreshLink\": \"Regenerar enlace\",\n    \"deleteLink\": \"Eliminar enlace\",\n    \"deleteConfirmTitle\": \"Eliminar enlace de compartición\",\n    \"deleteConfirmDescription\": \"Esta acción no se puede deshacer. Las personas con este enlace ya no podrán acceder a la base compartida.\",\n    \"createSuccess\": \"Enlace de compartición creado\",\n    \"createFailed\": \"Error al crear enlace de compartición\",\n    \"updateSuccess\": \"Configuración de compartición actualizada\",\n    \"updateFailed\": \"Error al actualizar configuración de compartición\",\n    \"deleteSuccess\": \"Enlace de compartición eliminado\",\n    \"deleteFailed\": \"Error al eliminar enlace de compartición\",\n    \"refreshSuccess\": \"Enlace de compartición regenerado\",\n    \"refreshFailed\": \"Error al regenerar enlace de compartición\",\n    \"copied\": \"Enlace copiado al portapapeles\",\n    \"shareLink\": \"Enlace de compartición\",\n    \"linkHolderLabel\": \"La persona que obtuvo el enlace\",\n    \"linkHolderCanView\": \"Puede ver\",\n    \"linkHolderCanEdit\": \"Puede editar\",\n    \"linkHolderCanCopyAndSave\": \"Puede guardar como copia\",\n    \"passwordProtection\": \"Protección con contraseña\",\n    \"enterPassword\": \"Introducir contraseña\",\n    \"selectNodes\": \"Seleccionar tablas\",\n    \"shareEntireBase\": \"Compartir toda la base\",\n    \"shareSelectedNodes\": \"Compartir nodos seleccionados\",\n    \"shareEntireBaseDescription\": \"Las nuevas tablas y carpetas añadidas a esta base se compartirán automáticamente\",\n    \"noNodesSelectedWarning\": \"Por favor seleccione al menos un nodo para compartir\",\n    \"allowSave\": \"Permitir guardar en espacio\",\n    \"allowSaveDescription\": \"Permitir a los espectadores guardar una copia de esta base en su propio espacio\",\n    \"allowCopy\": \"Permitir copiar datos\",\n    \"allowCopyData\": \"Permitir a los espectadores copiar datos\",\n    \"allowDuplicate\": \"Permitir a los espectadores duplicar\",\n    \"allowCopyDescription\": \"Permitir a los espectadores copiar datos de tabla de esta base compartida\",\n    \"selectedNodes\": \"{{count}} tablas seleccionadas\",\n    \"allNodes\": \"Todas las tablas\",\n    \"sharedNode\": \"Nodo compartido\",\n    \"sharedNodeDescription\": \"Este enlace de compartición es para un nodo específico y sus descendientes\",\n    \"publicShareTitle\": \"Compartición pública por enlace\",\n    \"publicShareCount\": \"{{count}} enlace(s) de compartición pública\",\n    \"noPublicShare\": \"Sin enlaces de compartición pública\",\n    \"security\": \"Seguridad\",\n    \"restrictByPassword\": \"Restringir con contraseña\",\n    \"advanced\": \"Avanzado\",\n    \"embedConfig\": \"Configuración de incrustación\",\n    \"appPublicLink\": \"Enlace público de la aplicación\",\n    \"appNotPublished\": \"Esta aplicación aún no está publicada. Por favor publique la aplicación primero antes de compartir.\",\n    \"goToPublish\": \"Ir a publicar\",\n    \"publishSuccess\": \"Aplicación publicada exitosamente\",\n    \"publishFailed\": \"Error al publicar la aplicación\",\n    \"openLink\": \"Abrir enlace\",\n    \"appPublished\": \"Aplicación publicada\"\n  },\n  \"plugin\": {\n    \"recent\": \"Frases\",\n    \"more\": \"Explorar más ...\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Obtener campos de la tabla\",\n      \"getTablesMeta\": \"Obtener metadatos de la tabla\",\n      \"sqlQuery\": \"Ejecutar consulta SQL\",\n      \"generateScriptAction\": \"Generar acción de script\",\n      \"getScriptInput\": \"Obtener entrada de script\",\n      \"getTeableApi\": \"Obtener API\",\n      \"dataVisualization\": \"Visualización de datos\",\n      \"updateBase\": \"Actualizar información de la base\",\n      \"args\": \"Argumentos\",\n      \"result\": \"Resultado\",\n      \"thinking\": \"Pensando...\",\n      \"toBeConfirmed\": \"Por confirmar\",\n      \"errorMessage\": \"Mensaje de error\",\n      \"confirm\": \"Confirmar\",\n      \"createRecordsSuccess\": \"Se crearon correctamente {{count}} registro(s)\",\n      \"createRecordsFailed\": \"Error al crear registros\",\n      \"updateRecordsSuccess\": \"Se actualizaron correctamente {{count}} registro(s)\",\n      \"updateRecordsFailed\": \"Error al actualizar registros\",\n      \"generatingRecords\": \"Generando {{count}} registro(s)...\",\n      \"creatingRecords\": \"Creando {{count}} registro(s)...\",\n      \"updatingRecords\": \"Actualizando {{count}} registro(s)...\",\n      \"recordsPreview\": \"Vista previa de registros\",\n      \"andMoreRecords\": \"Y {{count}} más...\",\n      \"unknownError\": \"Error desconocido\",\n      \"recordIds\": \"IDs de registros\",\n      \"records\": \"registro(s)\",\n      \"viewAll\": \"Ver todo\",\n      \"showLess\": \"Ver menos\",\n      \"generatingData\": \"Generando datos...\",\n      \"generatingUpdates\": \"Generando actualizaciones...\",\n      \"recordsGenerated\": \"{{count}} registro(s) generado(s)\",\n      \"recordsCount\": \"Registros ({{count}})\",\n      \"fieldsCount\": \"Campos ({{count}})\",\n      \"fieldsGenerated\": \"{{count}} campo(s) generado(s)\",\n      \"updatedProperties\": \"Actualizado ({{count}})\",\n      \"configured\": \"configurado\",\n      \"recordsToUpdate\": \"{{count}} registro(s) a actualizar\",\n      \"showingLast\": \"últimos {{count}}\",\n      \"recordLabel\": \"Registro\",\n      \"statusGenerating\": \"Generando...\",\n      \"statusCreating\": \"Creando...\",\n      \"statusUpdating\": \"Actualizando...\",\n      \"statusCreated\": \"Creado\",\n      \"statusUpdated\": \"Actualizado\",\n      \"getApps\": {\n        \"title\": \"Lista de aplicaciones\",\n        \"loading\": \"Cargando aplicaciones...\",\n        \"foundApps\": \"Se encontraron {{count}} aplicación(es)\",\n        \"noApps\": \"No se encontraron aplicaciones en esta base\",\n        \"openApp\": \"Abrir aplicación\"\n      },\n      \"generateApp\": {\n        \"title\": \"Constructor de aplicaciones\",\n        \"creatingApp\": \"Creando aplicación\",\n        \"updatingApp\": \"Actualizando aplicación\",\n        \"generatingApp\": \"Generando aplicación\",\n        \"generating\": \"Generando...\",\n        \"openApp\": \"Abrir aplicación\",\n        \"viewProgress\": \"Ver progreso\",\n        \"newApp\": \"Nueva aplicación\",\n        \"building\": \"Construyendo...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Constructor de automatizaciones\",\n        \"creatingAutomation\": \"Creando automatización\",\n        \"updatingAutomation\": \"Actualizando automatización\",\n        \"generatingAutomation\": \"Construyendo automatización\",\n        \"building\": \"Construyendo...\",\n        \"openAutomation\": \"Abrir automatización\",\n        \"viewProgress\": \"Ver progreso\",\n        \"testResults\": \"Resultados de prueba\",\n        \"triggerTest\": \"Disparador\",\n        \"actionTest\": \"Acción {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Vista previa\",\n        \"code\": \"Código\",\n        \"download\": \"Descargar\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Imagen (PNG)\",\n        \"copy\": \"Copiar\",\n        \"copied\": \"¡Copiado!\",\n        \"fullscreen\": \"Pantalla completa\",\n        \"exitFullscreen\": \"Salir de pantalla completa\",\n        \"downloadSuccess\": \"Imagen descargada correctamente\",\n        \"downloadFailed\": \"Error al capturar la imagen\",\n        \"iframeFailed\": \"Error al capturar: iframe no accesible\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Cargar adjunto\",\n        \"loading\": \"Cargando adjunto\",\n        \"failed\": \"Error al cargar adjunto\",\n        \"empty\": \"No se cargaron adjuntos\",\n        \"modeNative\": \"Visión IA\",\n        \"modeNativeDesc\": \"Cargado en el contexto nativo de la IA\",\n        \"modeExtracted\": \"Texto extraído\",\n        \"modeExtractedDesc\": \"Contenido del archivo analizado y extraído como texto para análisis\",\n        \"visionLoaded\": \"Cargado para análisis visual\",\n        \"pdfLoaded\": \"Cargado para análisis de PDF\",\n        \"textExtracted\": \"{{chars}} caracteres extraídos\",\n        \"contextLoaded\": \"Cargado en el contexto de la IA\",\n        \"truncated\": \"Truncado\",\n        \"preview\": \"Vista previa\"\n      },\n      \"textExtract\": {\n        \"title\": \"Extracción de texto\",\n        \"loading\": \"Extrayendo texto\",\n        \"failed\": \"Error al extraer texto\",\n        \"empty\": \"No se extrajo texto\",\n        \"preview\": \"Vista previa\",\n        \"truncated\": \"Truncado\",\n        \"previews\": \"vistas previas\",\n        \"chars\": \"{{chars}} caracteres\",\n        \"totalCharacters\": \"Total: {{chars}} caracteres\",\n        \"filesTruncated\": \"({{count}} archivo(s) truncado(s))\"\n      },\n      \"importExcel\": {\n        \"title\": \"Importar Excel\",\n        \"loading\": \"Procesando archivo...\",\n        \"failed\": \"Error de importación\",\n        \"suggestions\": \"Sugerencias\",\n        \"analyzeComplete\": \"Análisis completado\",\n        \"worksheets\": \"Hojas de cálculo\",\n        \"columns\": \"columnas\",\n        \"importComplete\": \"Importación completada\",\n        \"stageAnalyze\": \"Analizando\",\n        \"stageImport\": \"Importando\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Obtener API de Teable\",\n      \"readFiles\": \"Leer archivos\",\n      \"writeFile\": \"Escribir archivo\",\n      \"deleteFiles\": \"Eliminar archivos\",\n      \"listFiles\": \"Listar archivos\",\n      \"addDependencies\": \"Agregar dependencias\",\n      \"checkBuildErrors\": \"Verificar errores de compilación\",\n      \"lint\": \"Revisar código\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Error al cargar la vista previa\",\n      \"retry\": \"Reintentar {{count}} vez\",\n      \"chatAborted\": \"cancelado\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Tabla eliminada\",\n      \"deletedView\": \"Vista eliminada\",\n      \"deletedField\": \"Campo eliminado\",\n      \"deletedRecords\": \"Registros eliminados\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Agente de tablas\",\n      \"viewOperatorAgent\": \"Agente de vistas\",\n      \"fieldOperatorAgent\": \"Agente de campos\",\n      \"recordOperatorAgent\": \"Agente de registros\",\n      \"buildBaseAgent\": \"Agente de creación de bases\",\n      \"buildAutomationAgent\": \"Agente de automatizaciones\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"Por confirmar\",\n      \"deleteWarning\": \"¿Está seguro de que desea eliminar?\"\n    },\n    \"action\": {\n      \"createTable\": \"Crear tabla\",\n      \"updateTable\": \"Actualizar tabla\",\n      \"updateTableName\": \"Actualizar nombre de tabla\",\n      \"deleteTable\": \"Eliminar tabla\",\n      \"createView\": \"Crear vista\",\n      \"updateView\": \"Actualizar vista\",\n      \"updateViewName\": \"Actualizar nombre de vista\",\n      \"deleteView\": \"Eliminar vista\",\n      \"createField\": \"Crear campo\",\n      \"createAiField\": \"Crear campo IA\",\n      \"createLinkField\": \"Crear campo de enlace\",\n      \"createLookupField\": \"Crear campo de búsqueda\",\n      \"createRollupField\": \"Crear campo de resumen\",\n      \"createFormulaField\": \"Crear campo de fórmula\",\n      \"deleteField\": \"Eliminar campo\",\n      \"updateField\": \"Actualizar campo\",\n      \"createRecord\": \"Crear registro\",\n      \"createRecords\": \"Crear registros\",\n      \"deleteRecord\": \"Eliminar registro\",\n      \"updateRecord\": \"Actualizar registro\",\n      \"updateRecords\": \"Actualizar registros\",\n      \"updateBase\": \"Actualizar info de base de datos\",\n      \"planTask\": \"Planificar tarea...\",\n      \"generateTables\": \"Generar tablas\",\n      \"generatePrimaryFields\": \"Generar campos primarios\",\n      \"generateFields\": \"Generar campos\",\n      \"generateViews\": \"Generar vistas\",\n      \"generateRecords\": \"Generar registros\",\n      \"generateAIFields\": \"Generar campos IA\",\n      \"generateLinkFields\": \"Generar campos de enlace\",\n      \"generateLookupFields\": \"Generar campos de búsqueda\",\n      \"generateRollupFields\": \"Generar campos de resumen\",\n      \"generateFormulaFields\": \"Generar campos de fórmula\",\n      \"generateWorkflow\": \"Generar flujo de trabajo\",\n      \"generateTrigger\": \"Generar disparador\",\n      \"generateScriptAction\": \"Generar nodo de acción de script\",\n      \"generateSendMailAction\": \"Generar nodo de envío de correo\",\n      \"generateAction\": \"Generar nodo de acción\",\n      \"setupAutomationTrigger\": \"Configurar disparador de automatización\",\n      \"testAutomationNode\": \"Probar nodo de automatización\",\n      \"activateAutomation\": \"Activar automatización\",\n      \"executeScript\": \"Ejecutar script\",\n      \"wait\": \"Esperar\",\n      \"generateScriptFlowChart\": \"Generar diagrama de flujo del script\",\n      \"triggerAiFill\": \"Activar relleno IA\",\n      \"initialize\": \"Inicializar entorno\",\n      \"rename\": \"Generar nombre de app\",\n      \"buildTest\": \"Crear prueba\",\n      \"developTask\": \"Desarrollar tarea\",\n      \"generateSummary\": \"Generar resumen\",\n      \"previewEnvironment\": \"Preparar entorno de vista previa\",\n      \"getRelativeData\": \"Obtener datos relacionados\",\n      \"getPreviousNodeOutputVariables\": \"Obtener variables de salida del nodo anterior\",\n      \"getApiJson\": \"Obtener info de API\",\n      \"generateScriptAndDependencies\": \"Generar script y dependencias\",\n      \"analyzingAttachment\": \"Analizando adjunto...\",\n      \"locateResource\": \"Localizar\",\n      \"goTo\": \"Ir a\",\n      \"operationSuccess\": \"Operación completada con éxito\",\n      \"operationFailed\": \"Operación fallida\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} registro(s) en cola para generación con IA\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Consultar registros\",\n      \"getRecordsWithTable\": \"Consultar registros · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Consultar campos\",\n      \"getFieldsWithTable\": \"Consultar campos · {{tableName}}\",\n      \"getTables\": \"Consultar tablas\",\n      \"getViews\": \"Consultar vistas\",\n      \"getViewsWithTable\": \"Consultar vistas · {{tableName}}\",\n      \"sqlQuery\": \"Consulta SQL\",\n      \"querying\": \"Consultando...\",\n      \"queryFailed\": \"Error en la consulta\",\n      \"aborted\": \"Abortado\",\n      \"noData\": \"No se devolvieron datos\",\n      \"dataFormatError\": \"Error de formato de datos\",\n      \"unsupportedQueryType\": \"Tipo de consulta no soportado: {{toolName}}\",\n      \"returnedRecords\": \"Se devolvieron {{count}} registro(s)\",\n      \"record\": \"Registro {{index}}\",\n      \"moreRecords\": \"... +{{count}} registro(s) más\",\n      \"foundFields\": \"Se encontraron {{count}} campo(s)\",\n      \"moreFields\": \"... +{{count}} campo(s) más\",\n      \"foundTables\": \"Se encontraron {{count}} tabla(s)\",\n      \"moreTables\": \"... +{{count}} tabla(s) más\",\n      \"foundViews\": \"Se encontraron {{count}} vista(s)\",\n      \"moreViews\": \"... +{{count}} vista(s) más\",\n      \"queryReturned\": \"La consulta devolvió {{rowCount}} fila(s) × {{columnCount}} columna(s)\",\n      \"row\": \"Fila {{index}}\",\n      \"moreRows\": \"... +{{count}} fila(s) más\",\n      \"getDoc\": \"Obtener documento\",\n      \"getDocWithTopic\": \"Obtener documento · {{topic}}\",\n      \"getAutomations\": \"Consultar automatizaciones\",\n      \"getAutomation\": \"Consultar automatización\",\n      \"getAutomationRuns\": \"Consultar ejecuciones de automatización\",\n      \"foundAutomations\": \"Se encontraron {{count}} automatización(es)\",\n      \"moreAutomations\": \"... +{{count}} automatización(es) más\",\n      \"foundRuns\": \"Se encontraron {{count}} ejecución(es)\",\n      \"moreRuns\": \"... +{{count}} ejecución(es) más\",\n      \"active\": \"Activo\",\n      \"trigger\": \"Disparador\",\n      \"actions\": \"{{count}} acción(es)\",\n      \"moreActions\": \"... +{{count}} acción(es) más\",\n      \"getUserIntegrations\": \"Verificar integraciones\",\n      \"connectedIntegrations\": \"Conectado\",\n      \"availableToConnect\": \"Disponible para conectar\",\n      \"connect\": \"Conectar\",\n      \"noIntegrationsAvailable\": \"No hay integraciones disponibles\",\n      \"activateTool\": \"Activar herramientas\",\n      \"webSearch\": \"Búsqueda web\",\n      \"webSearchResults\": \"Se encontraron {{count}} resultado(s)\",\n      \"webSearchCompleted\": \"Búsqueda completada\",\n      \"searchApi\": \"Buscar API\",\n      \"searchApiWithQuery\": \"Buscar API · {{query}}\",\n      \"noApiFound\": \"No se encontraron APIs\",\n      \"foundApis\": \"Se encontraron {{count}} API(s)\",\n      \"totalApis\": \"Total {{count}} APIs disponibles\",\n      \"callApi\": \"Llamar API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Respuesta\",\n      \"success\": \"Éxito\",\n      \"failed\": \"Fallido\",\n      \"inputData\": \"Datos de entrada\",\n      \"availableNodes\": \"Nodos disponibles\",\n      \"hasPreviousCode\": \"Tiene código existente\",\n      \"noInputData\": \"No hay datos de entrada disponibles\"\n    },\n    \"showUI\": {\n      \"connect\": \"Conectar\",\n      \"connecting\": \"Conectando...\",\n      \"connected\": \"Conectado\",\n      \"connectToUse\": \"Conecte {{provider}} para usar en automatizaciones\",\n      \"checkingConnection\": \"Verificando estado de conexión...\",\n      \"confirm\": \"Confirmar\",\n      \"confirmed\": \"Confirmado\",\n      \"cancel\": \"Cancelar\",\n      \"cancelled\": \"Cancelado\",\n      \"connectionCancelled\": \"Conexión cancelada\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} líneas ocultas\",\n      \"collapseCode\": \"Ocultar código\",\n      \"code\": \"Código\",\n      \"preview\": \"Vista previa\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Progreso de construcción\",\n      \"completed\": \"Construcción de la aplicación completada\",\n      \"completedDesc\": \"Todos los pasos completados correctamente, la aplicación está lista para vista previa\",\n      \"stepStatus\": {\n        \"initializing\": \"Creando aplicación e inicializando entorno...\",\n        \"naming\": \"Generando nombre de aplicación...\",\n        \"planning\": \"Analizando requisitos y planificando desarrollo...\",\n        \"developing\": \"Escribiendo código e implementando características...\",\n        \"summarizing\": \"Organizando resultados del desarrollo...\",\n        \"deploying\": \"Desplegando en entorno de vista previa...\",\n        \"testing\": \"Creando prueba...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"En ejecución\",\n        \"completed\": \"Completado\",\n        \"error\": \"Fallido\",\n        \"pending\": \"Pendiente\"\n      },\n      \"toolStatus\": {\n        \"running\": \"En ejecución\",\n        \"completed\": \"Completado\",\n        \"error\": \"Fallido\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Script generado correctamente\"\n    },\n    \"buildBase\": {\n      \"title\": \"Construir base\",\n      \"generateSuccess\": \"Base de datos generada correctamente\",\n      \"generateError\": \"Error al generar la base de datos\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Construir automatización\",\n      \"generateSuccess\": \"Automatización generada correctamente\"\n    },\n    \"automation\": {\n      \"created\": \"Creado\",\n      \"updated\": \"Actualizado\",\n      \"workflow\": \"Flujo de trabajo\",\n      \"trigger\": \"Disparador\",\n      \"scriptAction\": \"Acción de script\",\n      \"workflowLabel\": \"Flujo de trabajo\",\n      \"triggerLabel\": \"Disparador\",\n      \"scriptActionLabel\": \"Acción de script\",\n      \"workflowId\": \"Flujo de trabajo\",\n      \"triggerId\": \"Disparador\",\n      \"scriptActionId\": \"Acción de script\",\n      \"viewAutomation\": \"Ver\",\n      \"navigateToAutomation\": \"Navegar a la automatización\",\n      \"triggerType\": {\n        \"recordCreated\": \"Registro creado\",\n        \"recordUpdated\": \"Registro actualizado\",\n        \"recordCreatedOrUpdated\": \"Registro creado o actualizado\",\n        \"formSubmitted\": \"Formulario enviado\",\n        \"scheduledTime\": \"Hora programada\",\n        \"buttonClick\": \"Clic de botón\"\n      },\n      \"activated\": \"Activado\",\n      \"deactivated\": \"Desactivado\",\n      \"discarded\": \"Cambios descartados\",\n      \"activateFailed\": \"Error al activar\",\n      \"deactivateFailed\": \"Error al desactivar\",\n      \"discardFailed\": \"Error al descartar\",\n      \"scriptUpdated\": \"Script actualizado\",\n      \"scriptUpdateFailed\": \"Error al actualizar\",\n      \"scriptExecuted\": \"Script ejecutado\",\n      \"scriptExecutionFailed\": \"Error de ejecución\",\n      \"scriptReady\": \"Script listo\",\n      \"executingScript\": \"Ejecutando script...\",\n      \"waitedSeconds\": \"Esperado {{seconds}}s\",\n      \"waitFailed\": \"Error de espera\",\n      \"flowchartGenerated\": \"Diagrama de flujo generado\",\n      \"flowchartGenerationFailed\": \"Error de generación\"\n    },\n    \"newChat\": \"Nuevo chat\",\n    \"clearChat\": \"Borrar chat\",\n    \"expand\": \"Expandir\",\n    \"history\": \"Historial\",\n    \"close\": \"Contraer\",\n    \"clearChatConfirmTitle\": \"Confirmar borrar chat\",\n    \"clearChatConfirmDesc\": \"El contenido actual del chat no se guardará. ¿Está seguro de que desea borrarlo?\",\n    \"dontShowAgain\": \"No mostrar de nuevo\",\n    \"noModel\": \"No hay modelos disponibles\",\n    \"addAttachment\": \"Añadir archivo adjunto\",\n    \"noHistory\": \"No hay historial de chat\",\n    \"noFoundHistory\": \"No se encontró historial de chat coincidente, comience una nueva conversación\",\n    \"timeGroup\": {\n      \"today\": \"Hoy\",\n      \"oneWeek\": \"Una semana\",\n      \"twoWeek\": \"Dos semanas\",\n      \"oneMonth\": \"Un mes\",\n      \"other\": \"Otro\"\n    },\n    \"context\": {\n      \"button\": \"Agregar contexto\",\n      \"search\": \"Agregar tablas\",\n      \"searchEmpty\": \"No se encontró contexto coincidente\",\n      \"emptyContext\": \"No hay contexto para agregar\",\n      \"selectionRows\": \"Filas {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Enviar mensaje...\",\n    \"thought\": \"Pensando\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Tiempo de generación: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} créditos utilizados\",\n      \"tokenDescription\": \"Tokens utilizados: {{tokens}}\",\n      \"input\": \"Entrada\",\n      \"output\": \"Salida\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"Tiempo total\",\n      \"totalCreditCost\": \"Costo total en créditos\",\n      \"customModel\": \"Modelo personalizado\",\n      \"tokenDetails\": \"Detalles de tokens\",\n      \"cachedInput\": \"Entrada en caché (90% descuento)\",\n      \"cacheWrite\": \"Escritura en caché\",\n      \"reasoning\": \"Razonamiento (Pensamiento)\",\n      \"taskCompleted\": \"Tarea completada\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Error en la visualización de datos\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Solo los administradores pueden configurar\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"La imagen no es compatible\",\n      \"attachmentSizeExceeded\": \"El tamaño del adjunto supera el límite de {{size}}MB\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Recomendado\",\n      \"ask\": \"Preguntar\",\n      \"analyze\": \"Analizar\",\n      \"build\": \"Construir\",\n      \"title\": \"¿En qué puedo ayudarle?\",\n      \"whatCanIDo\": \"¿Qué puedo hacer?\",\n      \"createOrModifyDatabase\": \"Crear o modificar base de datos\",\n      \"buildAutomations\": \"Construir automatizaciones\",\n      \"buildApps\": \"Construir aplicaciones\",\n      \"buildMeCRM\": \"Créeme un CRM\",\n      \"addAIField\": \"Agregar un campo IA para analizar cada cliente\",\n      \"createDataAnalysis\": \"Créame un reporte de análisis de datos\",\n      \"emailWhenRecordCreated\": \"Envíame un correo cuando se cree un registro\",\n      \"syncStatusToSlack\": \"Sincronizar actualizaciones de estado con Slack\",\n      \"buildDashboard\": \"Construir un panel desde esta tabla\",\n      \"buildLeadCapture\": \"Créame una página de captura de leads\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Pensó durante {{duration}}s\"\n      },\n      \"task\": {\n        \"searching\": \"Buscando \\\"{{query}}\\\"\",\n        \"readingFiles\": \"Leyendo archivos:\",\n        \"foundResults\": \"Se encontraron {{count}} resultados\",\n        \"noIssuesFound\": \"No se encontraron problemas\",\n        \"defaultTitle\": \"Tarea\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Proyecto de código\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"Se requiere un modelo de IA para generar la vista previa.\",\n      \"writeCodeHint\": \"Escribe código para ver la vista previa del diagrama de flujo.\",\n      \"noPreview\": \"No hay vista previa del diagrama de flujo disponible.\",\n      \"generatePreview\": \"Generar vista previa\",\n      \"analyzing\": \"Analizando script...\",\n      \"codeChanged\": \"El código ha cambiado desde que se generó esta vista previa.\",\n      \"regenerate\": \"Regenerar\",\n      \"refresh\": \"Actualizar\",\n      \"regenerating\": \"Regenerando...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Descargar todos los archivos adjuntos\",\n      \"loading\": \"Cargando vista previa...\",\n      \"rowsWithAttachments\": \"{{count}} filas con archivos adjuntos\",\n      \"totalAttachments\": \"{{count}} archivos adjuntos\",\n      \"totalSize\": \"Tamaño total: {{size}}\",\n      \"startDownload\": \"Iniciar descarga\",\n      \"confirmTitle\": \"Descargar {{count}} archivos\",\n      \"confirmDescription\": \"Tamaño total: {{size}}. Los archivos se comprimirán en un archivo ZIP.\",\n      \"confirm\": \"Descargar\",\n      \"cancel\": \"Cancelar\",\n      \"downloading\": \"Descargando...\",\n      \"downloadingFile\": \"Descargando: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Descarga completada\",\n      \"cancelled\": \"Descarga cancelada\",\n      \"noAttachments\": \"No hay archivos adjuntos para descargar\",\n      \"error\": \"Descarga fallida\",\n      \"errorPartial\": \"{{failedCount}} archivos no se pudieron descargar\",\n      \"requireHttps\": \"La descarga por lotes requiere HTTPS. Por favor acceda a través de HTTPS o localhost.\",\n      \"advancedOptions\": \"Opciones avanzadas\",\n      \"namingFieldLabel\": \"Prefijo del nombre del adjunto\",\n      \"selectField\": \"Por defecto: índice de adjunto\",\n      \"groupByRow\": \"Archivar en carpetas\",\n      \"groupByRowTip\": \"Cuando una fila tiene múltiples adjuntos, se colocarán en la misma carpeta; las filas con solo un adjunto no crearán una carpeta.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/token.json",
    "content": "{\n  \"access\": \"Acceso\",\n  \"name\": \"Nombre\",\n  \"description\": \"Descripción\",\n  \"scopes\": \"Scopes\",\n  \"expiration\": \"Vencimiento\",\n  \"createdTime\": \"Creado\",\n  \"lastUse\": \"Último uso\",\n  \"allSpace\": \"El espacio, todas las bases actuales y futuras en este espacio\",\n  \"formLabelTips\": {\n    \"name\": \"Proporciona un nombre para el token\",\n    \"description\": \"¿Para qué es este token?\",\n    \"scopes\": \"Con este token, podrás:\",\n    \"access\": \"Este token puede acceder a las siguientes bases y espacios. Solo puedes otorgar acceso a bases y espacios a los que tienes acceso.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Crear nuevo token\",\n    \"title\": \"Se requieren tokens de acceso personal para usar la API de Teable.\",\n    \"description\": \"Este token otorgará acceso a los datos en los espacios y bases seleccionados, y a otros endpoints de la API que no son de espacio/base. Por favor, usa este token solo para tu propio desarrollo. Ten precaución al compartirlo con servicios y aplicaciones de terceros.\",\n    \"button\": \"Crear nuevo token\",\n    \"success\": {\n      \"title\": \"Token generado exitosamente\",\n      \"description\": \"Asegúrate de copiar tu token. No se mostrará nuevamente.\"\n    },\n    \"expirationList\": {\n      \"days\": \"días\",\n      \"permanent\": \"Permanente\",\n      \"custom\": \"Personalizado\",\n      \"pick\": \"Seleccionar una fecha\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Editar token\",\n    \"name\": \"Nombre\",\n    \"scopes\": \"Scopes\",\n    \"selectAll\": \"Seleccionar todo\",\n    \"cancelSelectAll\": \"Cancelar selección\"\n  },\n  \"refresh\": {\n    \"title\": \"Regenerar token de acceso personal\",\n    \"description\": \"Enviar este formulario generará un nuevo token. Ten en cuenta que cualquier script o aplicación que use este token necesitará ser actualizado\",\n    \"button\": \"Regenerar token\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Agregar base o espacio\",\n    \"empty\": \"No se encontró acceso.\",\n    \"spaceSelectItem\": \"Todas las bases en el espacio\",\n    \"inputPlaceholder\": \"Buscar espacio o base...\"\n  },\n  \"moreScopes\": \"y {{len}} más\",\n  \"list\": {\n    \"description\": \"Se requieren tokens de acceso personal para usar la API de Teable. Por favor, consulte la <a>documentación de ayuda</a> para obtener más información.\"\n  },\n  \"empty\": {\n    \"list\": \"No se encontraron tokens de acceso personal.\",\n    \"access\": \"No hay acceso\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"¿Estás seguro de que quieres eliminar este token?\",\n    \"description\": \"Cualquier aplicación o scripts que use este token ya no podrá acceder a la API de la Tabe. \"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/es/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Se esperaba {{expected}}, se recibió {{received}}\",\n    \"invalid_type_received_undefined\": \"Requerido\",\n    \"invalid_type_received_null\": \"Requerido\",\n    \"invalid_literal\": \"Valor literal inválido, se esperaba {{expected}}\",\n    \"unrecognized_keys\": \"Clave(s) no reconocida(s) en el objeto: {{- keys}}\",\n    \"invalid_union\": \"Entrada inválida\",\n    \"invalid_union_discriminator\": \"Valor discriminador inválido. Se esperaba {{- options}}\",\n    \"invalid_enum_value\": \"Valor de enumeración inválido. Se esperaba {{- options}}, se recibió '{{received}}'\",\n    \"invalid_arguments\": \"Argumentos de función inválidos\",\n    \"invalid_return_type\": \"Tipo de retorno de función inválido\",\n    \"invalid_date\": \"Fecha inválida\",\n    \"custom\": \"Entrada inválida\",\n    \"invalid_intersection_types\": \"Los resultados de la intersección no se pudieron combinar\",\n    \"not_multiple_of\": \"El número debe ser múltiplo de {{multipleOf}}\",\n    \"not_finite\": \"El número debe ser finito\",\n    \"invalid_string\": {\n      \"email\": \"{{validation}} inválido\",\n      \"url\": \"{{validation}} inválida\",\n      \"uuid\": \"{{validation}} inválido\",\n      \"cuid\": \"{{validation}} inválido\",\n      \"regex\": \"Inválido\",\n      \"datetime\": \"{{validation}} inválida\",\n      \"startsWith\": \"Entrada inválida: debe comenzar con \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Entrada inválida: debe terminar con \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"El array debe contener exactamente {{minimum}} elemento(s)\",\n        \"inclusive\": \"El array debe contener al menos {{minimum}} elemento(s)\",\n        \"not_inclusive\": \"El array debe contener más de {{minimum}} elemento(s)\"\n      },\n      \"string\": {\n        \"exact\": \"La cadena debe contener exactamente {{minimum}} caracter(es)\",\n        \"inclusive\": \"La cadena debe contener al menos {{minimum}} caracter(es)\",\n        \"not_inclusive\": \"La cadena debe contener más de {{minimum}} caracter(es)\"\n      },\n      \"number\": {\n        \"exact\": \"El número debe ser exactamente {{minimum}}\",\n        \"inclusive\": \"El número debe ser mayor o igual a {{minimum}}\",\n        \"not_inclusive\": \"El número debe ser mayor que {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Entrada inválida\",\n        \"inclusive\": \"Entrada inválida\",\n        \"not_inclusive\": \"Entrada inválida\"\n      },\n      \"date\": {\n        \"exact\": \"La fecha debe ser exactamente {{- minimum, datetime}}\",\n        \"inclusive\": \"La fecha debe ser mayor o igual a {{- minimum, datetime}}\",\n        \"not_inclusive\": \"La fecha debe ser mayor que {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"El array debe contener exactamente {{maximum}} elemento(s)\",\n        \"inclusive\": \"El array debe contener como máximo {{maximum}} elemento(s)\",\n        \"not_inclusive\": \"El array debe contener menos de {{maximum}} elemento(s)\"\n      },\n      \"string\": {\n        \"exact\": \"La cadena debe contener exactamente {{maximum}} caracter(es)\",\n        \"inclusive\": \"La cadena debe contener como máximo {{maximum}} caracter(es)\",\n        \"not_inclusive\": \"La cadena debe contener menos de {{maximum}} caracter(es)\"\n      },\n      \"number\": {\n        \"exact\": \"El número debe ser exactamente {{maximum}}\",\n        \"inclusive\": \"El número debe ser menor o igual a {{maximum}}\",\n        \"not_inclusive\": \"El número debe ser menor que {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Entrada inválida\",\n        \"inclusive\": \"Entrada inválida\",\n        \"not_inclusive\": \"Entrada inválida\"\n      },\n      \"date\": {\n        \"exact\": \"La fecha debe ser exactamente {{- maximum, datetime}}\",\n        \"inclusive\": \"La fecha debe ser menor o igual a {{- maximum, datetime}}\",\n        \"not_inclusive\": \"La fecha debe ser menor que {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"correo electrónico\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuidero\",\n    \"regex\": \"regular\",\n    \"datetime\": \"de fecha y hora\"\n  },\n  \"types\": {\n    \"function\": \"función\",\n    \"number\": \"número\",\n    \"string\": \"cadena\",\n    \"nan\": \"yaya\",\n    \"integer\": \"entero\",\n    \"float\": \"flotar\",\n    \"boolean\": \"booleano\",\n    \"date\": \"fecha\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"indefinido\",\n    \"symbol\": \"símbolo\",\n    \"null\": \"nulo\",\n    \"array\": \"formación\",\n    \"object\": \"objeto\",\n    \"unknown\": \"desconocido\",\n    \"promise\": \"promesa\",\n    \"void\": \"vacío\",\n    \"never\": \"nunca\",\n    \"map\": \"mapa\",\n    \"set\": \"colocar\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/auth.json",
    "content": "{\n  \"page\": {\n    \"title\": \"Connexion\"\n  },\n  \"button\": {\n    \"signin\": \"Se connecter\",\n    \"signup\": \"S'inscrire\"\n  },\n  \"legal\": {\n    \"tip\": \"En continuant, vous acceptez les <Terms>Conditions d'utilisation</Terms> et la <Privacy>Politique de confidentialité</Privacy> de Teable, et recevrez des e-mails périodiques avec des mises à jour.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"label\": {\n    \"email\": \"E-mail\",\n    \"password\": \"Mot de passe\"\n  },\n  \"placeholder\": {\n    \"password\": \"Entrez votre mot de passe...\",\n    \"email\": \"Entrez votre e-mail...\"\n  },\n  \"signError\": {\n    \"exist\": \"L'e-mail a déjà été enregistré\",\n    \"incorrect\": \"L'e-mail ou le mot de passe est incorrect\",\n    \"tooManyRequests\": \"Votre compte a été verrouillé, veuillez réessayer après {{minutes}} minutes\",\n    \"turnstileRequired\": \"Veuillez compléter le défi de vérification\",\n    \"turnstileError\": \"Vérification échouée. Veuillez réessayer\",\n    \"turnstileExpired\": \"Vérification expirée. Veuillez réessayer\",\n    \"turnstileTimeout\": \"Vérification expirée. Veuillez réessayer\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Définir votre mot de passe\",\n    \"description\": \"Entrez un nouveau mot de passe\",\n    \"label\": \"Nouveau mot de passe\",\n    \"error\": {\n      \"requiredPassword\": \"Entrez le mot de passe\",\n      \"invalidLink\": \"Lien de réinitialisation du mot de passe invalide\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Réinitialisation du mot de passe réussie\",\n      \"description\": \"Votre mot de passe a été réinitialisé avec succès. Vous serez redirigé vers la page de connexion.\"\n    },\n    \"buttonText\": \"Confirmer le mot de passe\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Mot de passe oublié ?\",\n    \"header\": \"Réinitialiser votre mot de passe\",\n    \"description\": \"Veuillez entrer votre adresse e-mail ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.\",\n    \"errorRequiredEmail\": \"L'e-mail est requis\",\n    \"errorInvalidEmail\": \"E-mail invalide\",\n    \"buttonText\": \"Envoyer l'e-mail de réinitialisation\",\n    \"success\": {\n      \"title\": \"🎉 E-mail de réinitialisation envoyé\",\n      \"description\": \"Nous vous avons envoyé un e-mail avec un lien pour réinitialiser votre mot de passe. Veuillez vérifier votre boîte de réception.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/chart.json",
    "content": "{\n  \"notBaseId\": \"BaseId manquant\",\n  \"notPositionId\": \"PositionId manquant\",\n  \"notPluginInstallId\": \"PluginInstallId manquant\",\n  \"initBridge\": \"Initialisation du pont...\",\n  \"actions\": {\n    \"cancel\": \"Annuler\",\n    \"save\": \"Enregistrer\"\n  },\n  \"queryTitle\": \"Configuration de requête de données\",\n  \"notSupport\": \"Non pris en charge\",\n  \"chart\": {\n    \"bar\": \"Barres\",\n    \"line\": \"Ligne\",\n    \"pie\": \"Secteurs\",\n    \"area\": \"Zone\",\n    \"table\": \"Tableau\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Sélectionner le type de graphique\",\n      \"label\": \"Type de graphique\"\n    },\n    \"pie\": {\n      \"dimension\": \"Dimension\",\n      \"measure\": \"Mesure\",\n      \"showTotal\": \"Afficher le total\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"Axe X\",\n        \"placeholder\": \"Sélectionner l'axe X\"\n      },\n      \"yAxis\": {\n        \"label\": \"Axe Y\",\n        \"placeholder\": \"Sélectionner l'axe Y\",\n        \"position\": \"Position de l'axe Y\"\n      },\n      \"xDisplay\": {\n        \"label\": \"Affichage X\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Affichage Y\"\n      },\n      \"addXAxis\": \"Ajouter une répartition de série\",\n      \"addYAxis\": \"Ajouter une autre série\",\n      \"stack\": \"Empiler\",\n      \"position\": {\n        \"label\": \"Position\",\n        \"auto\": \"Automatique\",\n        \"left\": \"Gauche\",\n        \"right\": \"Droite\"\n      },\n      \"goalLine\": {\n        \"label\": \"Ligne d'objectif\"\n      },\n      \"range\": {\n        \"label\": \"Plage\",\n        \"min\": \"Minimum\",\n        \"max\": \"Maximum\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Style de ligne\",\n        \"normal\": \"Normal\",\n        \"linear\": \"Linéaire\",\n        \"step\": \"Palier\"\n      },\n      \"displayType\": \"Type d'affichage\"\n    },\n    \"typeError\": \"Formulaire : Type de graphique non pris en charge\",\n    \"updateQuery\": \"Mettre à jour la requête\",\n    \"queryError\": \"Erreur de requête\",\n    \"querySuccess\": \"Requête configurée\",\n    \"decimal\": \"Décimal\",\n    \"prefix\": \"Préfixe\",\n    \"suffix\": \"Suffixe\",\n    \"showLabel\": \"Afficher les étiquettes de valeurs sur le graphique\",\n    \"showLegend\": \"Afficher la légende\",\n    \"value\": \"Valeur\",\n    \"label\": \"Étiquette\",\n    \"padding\": {\n      \"label\": \"Espacement\",\n      \"top\": \"Haut\",\n      \"right\": \"Droite\",\n      \"bottom\": \"Bas\",\n      \"left\": \"Gauche\"\n    },\n    \"tableConfig\": \"Configuration du tableau\",\n    \"width\": \"Largeur\"\n  },\n  \"reloadQuery\": \"Recharger la requête\",\n  \"noStorage\": \"Veuillez d'abord configurer le plugin de graphiques\",\n  \"noPermission\": \"Aucune autorisation d'accès\",\n  \"goConfig\": \"Aller à la configuration\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Actions\",\n    \"add\": \"Ajouter\",\n    \"save\": \"Sauvegarder\",\n    \"doNotSave\": \"Ne pas sauvegarder\",\n    \"submit\": \"Soumettre\",\n    \"confirm\": \"Confirmer\",\n    \"close\": \"Fermer\",\n    \"edit\": \"Éditer\",\n    \"fill\": \"Remplir\",\n    \"update\": \"Mettre à jour\",\n    \"create\": \"Créer\",\n    \"delete\": \"Supprimer\",\n    \"cancel\": \"Annuler\",\n    \"zoomIn\": \"Zoomer\",\n    \"zoomOut\": \"Dézoomer\",\n    \"back\": \"Retour\",\n    \"remove\": \"Supprimer\",\n    \"removeConfig\": \"Supprimer la configuration\",\n    \"saveSucceed\": \"Sauvegarde réussie !\",\n    \"submitSucceed\": \"Soumission réussie !\",\n    \"editSucceed\": \"Édition réussie !\",\n    \"updateSucceed\": \"Mise à jour réussie !\",\n    \"deleteSucceed\": \"Suppression réussie !\",\n    \"resetSucceed\": \"Vidage de la corbeille réussi !\",\n    \"restoreSucceed\": \"Restauration réussie !\",\n    \"loading\": \"Chargement...\",\n    \"refreshPage\": \"Rafraîchir la page\",\n    \"yesDelete\": \"Oui, supprimer\",\n    \"rename\": \"Renommer\",\n    \"duplicate\": \"Dupliquer\",\n    \"change\": \"Changer\",\n    \"upgrade\": \"Mettre à niveau\",\n    \"upgradeToLevel\": \"Passer à {{level}}\",\n    \"search\": \"Rechercher\",\n    \"loadMore\": \"Charger plus\",\n    \"collapseSidebar\": \"Réduire la barre latérale\",\n    \"restore\": \"Récupérer\",\n    \"permanentDelete\": \"Suppression permanente\",\n    \"globalSearch\": \"Recherche globale\",\n    \"fieldSearch\": \"Recherche par champ\",\n    \"tableIndex\": \"Index\",\n    \"showAllRow\": \"Afficher toutes les lignes\",\n    \"hideNotMatchRow\": \"Masquer les lignes non correspondantes\",\n    \"more\": \"Plus\",\n    \"expand\": \"Développer\",\n    \"move\": \"Déplacer vers\",\n    \"turnOn\": \"Activer\",\n    \"exit\": \"Déconnexion\",\n    \"next\": \"Suivant\",\n    \"previous\": \"Précédent\",\n    \"select\": \"Sélectionner\",\n    \"view\": \"Voir\",\n    \"preview\": \"Aperçu\",\n    \"viewAndEdit\": \"Voir et modifier\",\n    \"deleteTip\": \"Êtes-vous sûr de vouloir supprimer \\\"{{name}}\\\" ?\",\n    \"refresh\": \"Actualiser\",\n    \"login\": \"Se connecter\",\n    \"useTemplate\": \"Utiliser le modèle\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Retour à l'espace\",\n    \"switchBase\": \"Changer de base\",\n    \"continue\": \"Continuer\",\n    \"export\": \"Exporter\",\n    \"import\": \"Importer\",\n    \"getMore\": \"Obtenir plus\",\n    \"copySuccess\": \"Copie réussie\"\n  },\n  \"quickAction\": {\n    \"title\": \"Actions rapides\",\n    \"placeHolder\": \"Tapez une commande ou recherchez...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"Le mot de passe n'est pas valide, il doit contenir au moins 8 caractères et au moins une lettre et un chiffre.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Partager\",\n      \"copy\": \"Copié\"\n    },\n    \"aiTitle\": \"Construisons ensemble\",\n    \"aiGreeting\": \"Comment puis-je vous aider, {{name}} ?\",\n    \"aiSubTitle\": \"La première plateforme IA où les équipes collaborent sur les données et génèrent des applications de production\",\n    \"guideTitle\": \"Commencez à partir de votre scénario\",\n    \"watchVideo\": \"Regarder la vidéo\",\n    \"title\": \"Modèle\",\n    \"description\": \"Créer une nouvelle base à partir d'un modèle\",\n    \"browseAll\": \"Parcourir Tout\",\n    \"templateTitle\": \"Commencer avec un modèle\",\n    \"loadMore\": \"Charger Plus\",\n    \"allTemplatesLoaded\": \"Tous les modèles chargés\",\n    \"createTemplate\": \"Créer un Modèle\",\n    \"useTemplateDialog\": {\n      \"title\": \"Sélectionner un espace\",\n      \"description\": \"Veuillez sélectionner un espace pour le modèle\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Créez votre application métier avec Teable\",\n      \"start\": \"Démarrer\",\n      \"carouselGuides\": {\n        \"guide1\": \"Collez des reçus → Demandez à Teable d'extraire automatiquement les données clés\",\n        \"guide2\": \"Créez un CRM IA avec des tables de prospects et de suivi\",\n        \"guide3\": \"Collez les commentaires clients → Demandez à Teable de trouver des insights et générer des rapports\",\n        \"guide4\": \"Créez un suivi de projet avec tâches et échéances\",\n        \"guide5\": \"Collez une feuille de prospects → Demandez à Teable de créer un CRM intelligent\",\n        \"guide6\": \"Créez un planificateur de contenu avec publications et dates de publication\",\n        \"guide7\": \"Collez des CV → Demandez à Teable d'organiser et présélectionner les candidats\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Copier vers l'espace\",\n      \"description\": \"Veuillez sélectionner un espace pour copier cette base\",\n      \"baseName\": \"Nom de la base\",\n      \"baseNamePlaceholder\": \"Entrez le nom de la base\",\n      \"selectSpace\": \"Sélectionner l'espace\",\n      \"noSpaceDescription\": \"Aucun espace gérable. Veuillez créer un nouvel espace pour continuer.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"Aucune base gérable dans cet espace. Veuillez sélectionner \\\"Nouvelle base\\\".\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Paramètres d'instance\",\n    \"personal\": {\n      \"title\": \"Paramètres personnels\"\n    },\n    \"back\": \"Retour à l'accueil\",\n    \"account\": {\n      \"title\": \"Mon profil\",\n      \"tab\": \"Mon compte\",\n      \"updatePhoto\": \"Mettre à jour la photo\",\n      \"updateNameDesc\": \"Surnom ou prénom, comme vous souhaitez être appelé dans Teable\",\n      \"securityTitle\": \"Sécurité du compte\",\n      \"email\": \"Email\",\n      \"password\": \"Mot de passe\",\n      \"passwordDesc\": \"Définissez un mot de passe permanent pour vous connecter à votre compte.\",\n      \"changePassword\": {\n        \"title\": \"Changer le mot de passe\",\n        \"desc\": \"Veuillez saisir votre mot de passe actuel et en définir un nouveau\",\n        \"current\": \"Mot de passe actuel\",\n        \"new\": \"Nouveau mot de passe\",\n        \"confirm\": \"Confirmer le nouveau mot de passe\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Votre nouveau mot de passe ne correspond pas.\",\n        \"equal\": \"Votre nouveau mot de passe doit être différent de votre mot de passe actuel.\",\n        \"invalid\": \"Votre mot de passe actuel est invalide.\",\n        \"invalidNew\": \"Votre nouveau mot de passe est invalide, minimum 8 caractères.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Mot de passe changé avec succès.\",\n        \"desc\": \"Vous serez redirigé vers la page de connexion dans 2 secondes.\"\n      },\n      \"manageToken\": \"Jeton d'accès\",\n      \"addPassword\": {\n        \"title\": \"Ajouter un mot de passe\",\n        \"desc\": \"Définissez un mot de passe permanent pour vous connecter à votre compte.\",\n        \"password\": \"Entrez votre mot de passe\",\n        \"confirm\": \"Confirmez votre mot de passe\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Votre mot de passe ne correspond pas.\",\n        \"invalid\": \"Votre mot de passe est invalide, minimum 8 caractères.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Mot de passe ajouté avec succès.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Supprimer le compte\",\n        \"desc\": \"Cette action est irréversible. Elle supprimera définitivement votre compte et toutes les données associées.\",\n        \"error\": {\n          \"title\": \"Impossible de supprimer le compte\",\n          \"desc\": \"Vous devez d'abord gérer les dépendances suivantes :\",\n          \"spacesError\": \"Avant de supprimer votre compte, vous devez d'abord quitter (ou supprimer, puis déplacer vers la corbeille) vos Espaces.\"\n        },\n        \"confirm\": {\n          \"title\": \"Veuillez taper <code>DELETE</code> pour confirmer\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Suppression en cours...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Changer l'adresse e-mail\",\n        \"desc\": \"Veuillez vérifier votre mot de passe et confirmer votre nouvelle adresse e-mail\",\n        \"current\": \"Mot de passe actuel\",\n        \"new\": \"Nouvel e-mail\",\n        \"code\": \"Code de vérification\",\n        \"getCode\": \"Envoyer le code\",\n        \"error\": {\n          \"invalidCode\": \"Le code de vérification n'est pas valide.\",\n          \"invalidPassword\": \"Le mot de passe n'est pas valide.\",\n          \"invalidConflict\": \"La nouvelle adresse e-mail est déjà enregistrée.\",\n          \"invalidSameEmail\": \"La nouvelle adresse e-mail est identique à l'actuelle.\",\n          \"sendMailRateLimit\": \"Veuillez patienter {{seconds}} secondes avant d'envoyer un nouvel e-mail\"\n        },\n        \"success\": {\n          \"title\": \"🎉 E-mail modifié avec succès.\",\n          \"desc\": \"Vous serez redirigé vers la page de connexion dans 2 secondes.\",\n          \"sendSuccess\": \"Code de vérification envoyé avec succès.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Mes notifications\",\n      \"label\": \"Activité dans votre espace\",\n      \"desc\": \"Recevez des emails lorsque vous recevez des commentaires, des mentions, des invitations à des pages, des rappels, des demandes d'accès et des modifications de propriété.\"\n    },\n    \"setting\": {\n      \"title\": \"Mes paramètres\",\n      \"theme\": \"Thème\",\n      \"themeDesc\": \"Sélectionnez le thème pour l'application.\",\n      \"dark\": \"Sombre\",\n      \"light\": \"Clair\",\n      \"system\": \"Système\",\n      \"version\": \"Version de l'application\",\n      \"language\": \"Langue\",\n      \"interactionMode\": \"Mode d'interaction\",\n      \"mouseMode\": \"Mode souris\",\n      \"touchMode\": \"Mode tactile\",\n      \"systemMode\": \"Suivre le système\"\n    },\n    \"nav\": {\n      \"settings\": \"Paramètres\",\n      \"logout\": \"Déconnexion\",\n      \"contactSupport\": \"Contacter l'assistance\"\n    },\n    \"integration\": {\n      \"title\": \"Intégrations\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"Intégrations tierces\",\n        \"description\": \"Vous avez accordé l'accès à {{count}} applications à votre compte.\",\n        \"lastUsed\": \"Dernière utilisation {{date}}\",\n        \"revoke\": \"Révoquer\",\n        \"owner\": \"Propriété de {{user}}\",\n        \"revokeTitle\": \"Êtes-vous sûr de vouloir révoquer l'autorisation ?\",\n        \"revokeDesc\": \"{{name}} ne pourra plus accéder à l'API Teable. Vous ne pouvez pas annuler cette action.\",\n        \"scopeTitle\": \"Autorisations\",\n        \"scopeDesc\": \"Cette application pourra obtenir les autorisations suivantes :\"\n      },\n      \"userIntegration\": {\n        \"title\": \"Comptes connectés\",\n        \"description\": \"Connectez des comptes externes pour permettre à Teable d'accéder à vos ressources.\",\n        \"emptyDescription\": \"Aucun compte connecté\",\n        \"actions\": {\n          \"reconnect\": \"Reconnecter\"\n        },\n        \"slack\": {\n          \"user\": \"Utilisateur Slack\",\n          \"workspace\": \"Espace de travail Slack\"\n        },\n        \"email\": {\n          \"user\": \"Utilisateur\",\n          \"email\": \"E-mail\"\n        },\n        \"deleteTitle\": \"Supprimer le compte connecté\",\n        \"deleteDesc\": \"Êtes-vous sûr de vouloir supprimer {{name}} ?\",\n        \"create\": \"Connecter un nouveau compte\",\n        \"manage\": \"Gérer les comptes connectés\",\n        \"searchPlaceholder\": \"Rechercher les comptes connectés\",\n        \"defaultName\": \"Intégration {{name}}\",\n        \"callback\": {\n          \"error\": \"Échec de l'autorisation\",\n          \"title\": \"Autorisation réussie\",\n          \"desc\": \"Vous pouvez maintenant fermer cette fenêtre.\"\n        }\n      }\n    },\n    \"templateAdmin\": {\n      \"title\": \"Administration des modèles\",\n      \"noData\": \"Aucune donnée\",\n      \"importing\": \"Importation...\",\n      \"usageCount\": \"Nombre d'utilisations: {{count}}\",\n      \"useTemplate\": \"Utiliser ce modèle\",\n      \"createdBy\": \"par {{user}}\",\n      \"backToTemplateList\": \"Retour à la liste des modèles\",\n      \"tips\": {\n        \"errorCategoryName\": \"La catégorie n'existe pas ou a été supprimée\",\n        \"needSnapshot\": \"Veuillez créer un instantané avant de publier, et le nom et la description du modèle ne peuvent pas être vides\",\n        \"needPublish\": \"Veuillez publier le modèle avant de le mettre en vedette\",\n        \"needBaseSource\": \"Veuillez sélectionner une source Base avant de créer l'instantané\",\n        \"forbiddenUpdateSystemTemplate\": \"Les modèles système ne peuvent pas être modifiés\",\n        \"addCategoryTips\": \"Veuillez d'abord saisir un nom de catégorie dans la zone de recherche.\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Commencer\",\n          \"recommended\": \"Recommandé\",\n          \"all\": \"Tous\",\n          \"browseByCategory\": \"Parcourir par catégorie\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Couverture\",\n        \"name\": \"Nom\",\n        \"description\": \"Description\",\n        \"markdownDescription\": \"Description Markdown\",\n        \"category\": \"Catégorie\",\n        \"isSystem\": \"Système\",\n        \"source\": \"Source\",\n        \"status\": \"Publié\",\n        \"publishSnapshot\": \"Publier l'instantané\",\n        \"snapshotTime\": \"Heure de l'instantané\",\n        \"actions\": \"Actions\",\n        \"featured\": \"En vedette\",\n        \"createdBy\": \"Créé par\",\n        \"userNonExistent\": \"Utilisateur inexistant\",\n        \"preview\": \"Aperçu\",\n        \"usage\": \"Utilisation\",\n        \"visit\": \"Visites\"\n      },\n      \"actions\": {\n        \"title\": \"Actions\",\n        \"publish\": \"Publier\",\n        \"delete\": \"Supprimer\",\n        \"duplicate\": \"Dupliquer\",\n        \"preview\": \"Aperçu\",\n        \"use\": \"Utiliser\",\n        \"pinTop\": \"Épingler en haut\",\n        \"addCategory\": \"Ajouter une catégorie\",\n        \"selectCategory\": \"Sélectionner une catégorie\",\n        \"viewTemplate\": \"Voir le modèle\",\n        \"manageCategory\": \"Gérer les catégories\"\n      },\n      \"relatedTemplates\": \"Modèles associés\",\n      \"noImage\": \"Aucune image\",\n      \"baseSelectPanel\": {\n        \"title\": \"Sélectionner la source du modèle\",\n        \"description\": \"Sélectionnez une Base comme modèle\",\n        \"confirm\": \"Confirmer\",\n        \"search\": \"Rechercher...\",\n        \"cancel\": \"Annuler\",\n        \"selectBase\": \"Sélectionner une Base\",\n        \"createTemplate\": \"Créer un modèle\",\n        \"abnormalBase\": \"La base n'existe pas ou a été supprimée\"\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"Table\",\n    \"view\": \"Vue\",\n    \"space\": \"Espace\",\n    \"base\": \"Base\",\n    \"field\": \"Champ\",\n    \"record\": \"Enregistrement\",\n    \"dashboard\": \"Tableau de bord\",\n    \"automation\": \"Automatisation\",\n    \"authorityMatrix\": \"Matrice d'autorité\",\n    \"design\": \"Conception\",\n    \"adminPanel\": \"Administration système\",\n    \"license\": \"Licence auto-hébergée\",\n    \"instanceId\": \"ID d'instance\",\n    \"beta\": \"Bêta\",\n    \"trash\": \"Corbeille\",\n    \"global\": \"Global\",\n    \"organizationPanel\": \"Paramètres d'organisation\",\n    \"unknownError\": \"Erreur inconnue\",\n    \"pluginPanel\": \"Panneau\",\n    \"pluginContextMenu\": \"Menu contextuel\",\n    \"plugin\": \"Plugins\",\n    \"copy\": \"Copie\",\n    \"credits\": \"Crédits\",\n    \"aiChat\": \"Chat IA\",\n    \"app\": \"Application\",\n    \"webSearch\": \"Recherche web\",\n    \"folder\": \"Dossier\",\n    \"newAutomation\": \"Nouvelle automatisation\",\n    \"newApp\": \"Nouvelle application\",\n    \"newFolder\": \"Nouveau dossier\",\n    \"template\": \"Modèle\"\n  },\n  \"level\": {\n    \"free\": \"Gratuit\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"Business\",\n    \"enterprise\": \"Enterprise\"\n  },\n  \"noResult\": \"Aucun résultat.\",\n  \"allNodes\": \"Tous les nœuds\",\n  \"noDescription\": \"Pas de description\",\n  \"untitled\": \"Sans titre\",\n  \"name\": \"Nom\",\n  \"description\": \"Description\",\n  \"required\": \"Requis\",\n  \"characters\": \"caractères\",\n  \"atLeastOne\": \"Conservez au moins un {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Précédent\",\n    \"next\": \"Suivant\",\n    \"done\": \"Terminé\",\n    \"skip\": \"Passer\",\n    \"createSpaceTooltipTitle\": \"Créer un espace\",\n    \"createSpaceTooltipContent\": \"Teable est organisé en espaces, où chaque espace invite les utilisateurs à collaborer. <br></br>Les espaces dans Teable servent d'élément de navigation principal dans la barre de menu, offrant une plateforme fondamentale pour que les utilisateurs ajoutent et gèrent des bases de données selon leurs besoins.\",\n    \"createBaseTooltipTitle\": \"Créer une base\",\n    \"createBaseTooltipContent\": \"Une base (abrégé de \\\"base de données\\\") est un endroit pour stocker des données importantes et les flux de travail qui en dépendent.\",\n    \"createTableTooltipTitle\": \"Créer une table\",\n    \"createTableTooltipContent\": \"Les tables sont conçues pour gérer efficacement des ensembles de données divers, offrant un affichage polyvalent des informations à travers différents types de données.\",\n    \"createViewTooltipTitle\": \"Créer une vue\",\n    \"createViewTooltipContent\": \"Actuellement, les utilisateurs peuvent créer des vues en Grille, Galerie, Kanban et Formulaire, avec des vues Calendrier prévues pour les futures versions. <br></br>Cette variété fournit aux utilisateurs un ensemble complet d'outils pour diverses tâches de gestion des données.\",\n    \"viewFilteringTooltipTitle\": \"Filtrage des enregistrements\",\n    \"viewFilteringTooltipContent\": \"L'une des fonctionnalités principales des vues est la capacité à filtrer les enregistrements d'une vue selon les conditions que vous définissez. <br></br>Lorsqu'un enregistrement est filtré en fonction d'une condition, il n'est pas supprimé—il est juste masqué de la vue particulière que vous utilisez pour consulter votre table.\",\n    \"viewSortingTooltipTitle\": \"Triage des enregistrements\",\n    \"viewSortingTooltipContent\": \"Lorsque vous êtes dans une vue, vous pouvez trier vos enregistrements afin qu'ils apparaissent dans un ordre particulier en fonction des valeurs dans des champs spécifiques. <br></br>Trier vos enregistrements dans une vue n'affecte pas l'ordre des enregistrements dans d'autres vues—cela s'applique uniquement à la vue que vous utilisez actuellement pour consulter votre table.\",\n    \"viewGroupingTooltipTitle\": \"Groupement des enregistrements\",\n    \"viewGroupingTooltipContent\": \"Le groupement des enregistrements permet aux créateurs de construire un ensemble d'une ou plusieurs conditions qui aideront à catégoriser l'ensemble de données présenté dans une vue particulière.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable propose une API puissante qui prend en charge presque toutes les fonctionnalités du produit, permettant aux développeurs de faire des appels en utilisant un <a>Jeton</a>.\"\n  },\n  \"token\": \"Jeton\",\n  \"poweredBy\": \"Propulsé par <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"Partager l'espace {{spaceName}}\",\n      \"desc_one\": \"Cet espace a <b>{{count}} collaborateur</b>. Ajouter un collaborateur à l'espace lui donnera accès à toutes les bases au sein de cet espace.\",\n      \"desc_other\": \"Cet espace a <b>{{count}} collaborateurs</b>. Ajouter un collaborateur à l'espace lui donnera accès à toutes les bases au sein de cet espace.\",\n      \"tabEmail\": \"Inviter par email\",\n      \"emailPlaceholder\": \"Saisissez les adresses e-mail, séparées par la touche Entrée\",\n      \"tabLink\": \"Inviter par lien\",\n      \"linkPlaceholder\": \"Créer un lien d'invitation qui accorde <0/> l'accès à quiconque l'ouvre.\",\n      \"emailSend\": \"Envoyer l'invitation\",\n      \"linkSend\": \"Créer le lien\",\n      \"spaceTitle\": \"Collaborateurs de l'espace\",\n      \"collaboratorSearchPlaceholder\": \"Trouver un collaborateur par nom ou email\",\n      \"collaboratorJoin\": \"a rejoint {{joinTime}}\",\n      \"collaboratorRemove\": \"Supprimer le collaborateur\",\n      \"linkTitle\": \"Liens d'invitation\",\n      \"linkCreatedTime\": \"créé {{createdTime}}\",\n      \"linkCopySuccess\": \"Lien copié\",\n      \"linkRemove\": \"Supprimer le lien\"\n    },\n    \"base\": {\n      \"title\": \"Partager {{baseName}}\",\n      \"desc_one\": \"Cette Base est partagée avec {{count}} collaborateur.\",\n      \"desc_other\": \"Cette Base est partagée avec {{count}} collaborateurs.\",\n      \"baseTitle\": \"Collaborateurs de la Base\",\n      \"collaboratorSearchPlaceholder\": \"Trouver un collaborateur de Base par nom ou email\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Ajouter un collaborateur d'organisation\",\n      \"placeholder\": \"Sélectionner un membre ou département de l'organisation\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Aide\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Les autorisations de la page ont été mises à jour. Veuillez rafraîchir la page pour voir le contenu le plus récent.\",\n  \"listEmptyTips\": \"La liste est vide\",\n  \"billing\": {\n    \"overLimits\": \"Dépassement des limites\",\n    \"overLimitsDescription\": \"Votre abonnement actuel a dépassé sa limite d'utilisation. Veuillez mettre à niveau votre plan pour continuer à utiliser cette fonctionnalité sans interruption.\",\n    \"userLimitExceededDescription\": \"L'instance actuelle a atteint le nombre maximum d'utilisateurs autorisés par votre licence. Veuillez désactiver certains utilisateurs ou mettre à niveau la licence.\",\n    \"unavailableInPlanTips\": \"Le plan d'abonnement actuel ne prend pas en charge cette fonctionnalité\",\n    \"unavailableConnectionTips\": \"La fonctionnalité de connexion de base de données sera supprimée et ne sera disponible que dans la version Entreprise et les versions auto-hébergées.\",\n    \"levelTips\": \"Cet espace est actuellement sur le plan {{level}}\",\n    \"enterpriseFeature\": \"Fonctionnalité Enterprise\",\n    \"automationRequiresUpgrade\": \"Passez à Enterprise Edition (EE) pour activer l'automatisation\",\n    \"authorityMatrixRequiresUpgrade\": \"Passez à Enterprise Edition (EE) pour activer la matrice d'autorité\",\n    \"viewPricing\": \"Voir les tarifs\",\n    \"billable\": \"Facturable\",\n    \"billableByAuthorityMatrix\": \"Facturation générée par la matrice d'autorités\",\n    \"licenseExpiredGracePeriod\": \"Votre licence auto-hébergée a expiré et sera rétrogradée au forfait gratuit le {{expiredTime}}. Veuillez mettre à jour votre licence rapidement pour conserver l'accès aux fonctionnalités premium.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Mettre à niveau le plan d'abonnement de l'espace\",\n      \"description\": \"Vous ne pouvez mettre à niveau que les espaces où vous êtes propriétaire\"\n    },\n    \"status\": {\n      \"active\": \"Actif\",\n      \"canceled\": \"Annulé\",\n      \"incomplete\": \"Incomplet\",\n      \"incompleteExpired\": \"Incomplet Expiré\",\n      \"trialing\": \"En essai\",\n      \"pastDue\": \"En retard\",\n      \"unpaid\": \"Non payé\",\n      \"paused\": \"En pause\",\n      \"seatLimitExceeded\": \"Limite de sièges dépassée\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Paramètres d'instance\",\n      \"description\": \"Modifier les paramètres de votre instance actuelle\",\n      \"allowSignUp\": \"Autoriser la création de nouveaux comptes\",\n      \"allowSignUpDescription\": \"Désactiver cette option interdira les nouvelles inscriptions d'utilisateur, et le bouton d'inscription n'apparaîtra plus sur la page de connexion.\",\n      \"allowSpaceInvitation\": \"Autoriser l'envoi d'invitations à des espaces\",\n      \"allowSpaceInvitationDescription\": \"Désactiver cette option empêchera les utilisateurs autres que les administrateurs d'inviter d'autres personnes à rejoindre des espaces. Lorsqu'elle est activée, les nouveaux utilisateurs invités par e-mail peuvent compléter leur inscription en cliquant sur le lien d'invitation dans l'e-mail, mais les liens d'invitation partagés ne fonctionneront pas.\",\n      \"allowSpaceCreation\": \"Autoriser tout le monde à créer de nouveaux espaces\",\n      \"allowSpaceCreationDescription\": \"Désactiver cette option empêchera les utilisateurs autres que les administrateurs de créer de nouveaux espaces.\",\n      \"enableEmailVerification\": \"Activer la vérification par email\",\n      \"enableEmailVerificationDescription\": \"Activer cette option permettra aux utilisateurs de vérifier leur adresse email lors de la création d'un nouveau compte.\",\n      \"enableWaitlist\": \"Activer la liste d'attente\",\n      \"enableWaitlistDescription\": \"Activer cette option permettra aux utilisateurs de s'inscrire uniquement avec un code d'invitation.\",\n      \"generalSettings\": \"Paramètres généraux\",\n      \"aiSettings\": {\n        \"title\": \"Paramètres IA\",\n        \"description\": \"Configurer les paramètres IA pour cette instance\"\n      },\n      \"brandingSettings\": {\n        \"title\": \"Paramètres de marque\",\n        \"description\": \"Disponible uniquement dans l'Édition Enterprise\",\n        \"brandName\": \"Nom de marque\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"Le logo est votre identité de marque dans Teable.\",\n        \"logoUpload\": \"Télécharger le logo\",\n        \"logoUploadDescription\": \"Téléchargez une image de logo, prend en charge les formats PNG, JPEG, taille recommandée 100x100px. Taille maximale de téléchargement 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Nom\",\n        \"nameDescription\": \"Le nom du fournisseur LLM\",\n        \"enable\": \"Activer AI\",\n        \"enableDescription\": \"Activer AI pour l'instance actuelle, tous les utilisateurs pourront utiliser les fonctionnalités AI\",\n        \"updateLLMProvider\": \"Mettre à jour le fournisseur LLM\",\n        \"addProvider\": \"Ajouter un fournisseur LLM\",\n        \"addProviderDescription\": \"Ajouter un nouveau fournisseur LLM à la liste\",\n        \"providerType\": \"Type de fournisseur\",\n        \"baseUrl\": \"URL de base\",\n        \"apiKey\": \"Clé API\",\n        \"baseUrlDescription\": \"L'URL de base du fournisseur LLM\",\n        \"apiKeyDescription\": \"La clé API du fournisseur LLM\",\n        \"models\": \"Modèles\",\n        \"modelsDescription\": \"Les modèles pris en charge par le fournisseur LLM\",\n        \"baseUrlRequired\": \"L'URL de base est requise\",\n        \"fetchModelListError\": \"Impossible de récupérer la liste des modèles\",\n        \"provider\": \"Fournisseur LLM\",\n        \"providerDescription\": \"Le fournisseur LLM à utiliser\",\n        \"modelPreferences\": \"Préférences des modèles\",\n        \"modelPreferencesDescription\": \"Les préférences des modèles pour le fournisseur LLM\",\n        \"embeddingModel\": \"Modèle d'intégration\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Modèle de traduction\",\n        \"translationModelDescription\": \"Le modèle de traduction à utiliser\",\n        \"chatModel\": \"Modèle de chat\",\n        \"chatModelDescription\": \"Le modèle de chat à utiliser, Astuce : le modèle de codage moyen est utilisé par défaut pour la génération de formules AI et les fonctionnalités associées\",\n        \"chatModels\": {\n          \"lg\": \"Modèle de chat avancé\",\n          \"lgDescription\": \"Pour la planification, le codage et d'autres scénarios de tâches complexes. Recommandé: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"Capacités IA\",\n          \"aiField\": {\n            \"title\": \"Champ IA\",\n            \"description\": \"Fonctionnalités de champ IA incluant le remplissage automatique et la configuration IA dans les paramètres de champ\"\n          },\n          \"aiChat\": {\n            \"title\": \"Chat IA\",\n            \"description\": \"Barre latérale Chat IA et toutes les fonctionnalités d'agent\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Tester le modèle\",\n          \"description\": \"Tester la capacité du modèle à traiter les images, PDF\",\n          \"notConfigLgModel\": \"Veuillez d'abord configurer le grand modèle\",\n          \"confirmTitle\": \"Tester les capacités du modèle\",\n          \"confirmDescription\": \"Voulez-vous tester les capacités du grand modèle de chat ?\",\n          \"confirm\": \"Tester le modèle\",\n          \"cancel\": \"Annuler\",\n          \"missingCapabilitiesWarning\": \"Ce modèle ne prend pas en charge le traitement d'images ou de PDF, ce qui peut rendre certaines fonctionnalités indisponibles.\",\n          \"enableAITitle\": \"Activer l'AI\",\n          \"enableAIDescription\": \"Test du modèle terminé. L'AI n'est pas activée actuellement. Voulez-vous activer l'AI pour utiliser ces capacités de modèle ?\",\n          \"enableAI\": \"Activer l'AI\",\n          \"skipTest\": \"Ignorer\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Image\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Recherche web\",\n          \"disabledWebSearch\": \"Recherche web désactivée\",\n          \"lgModelAbility\": \"Capacité du grand modèle\"\n        },\n        \"configUpdated\": \"Configuration AI mise à jour\",\n        \"noModelFound\": \"Aucun modèle trouvé\",\n        \"searchModel\": \"Rechercher un modèle...\",\n        \"selectModel\": \"Sélectionner un modèle...\",\n        \"input\": \"Entrée {{ratio}}\",\n        \"output\": \"Sortie {{ratio}}\",\n        \"inputOrOutputTip\": \"Le ratio représente le taux de change entre les crédits et les jetons, par exemple, \\\"1:1000\\\" signifie que 1 crédit ≈ 1000 jetons.<br></br>Remarque : chaque utilisation déduira au moins 1 crédit. Même si la consommation réelle est inférieure à 1 crédit, elle sera tout de même facturée comme 1 crédit.\",\n        \"imageOutput\": \"Par image {{credits}}\",\n        \"imageOutputTip\": \"La génération d'images nécessite des crédits, chaque image consomme {{credits}} crédits\",\n        \"supportImageOutputTip\": \"Ce modèle prend en charge la génération d'images\",\n        \"supportVisionTip\": \"Ce modèle prend en charge l'entrée d'images\",\n        \"supportAudioTip\": \"Ce modèle prend en charge l'entrée audio\",\n        \"supportVideoTip\": \"Ce modèle prend en charge l'entrée vidéo\",\n        \"supportDeepThinkTip\": \"Ce modèle prend en charge la réflexion approfondie\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Test en cours...\",\n        \"testSuccess\": \"Test réussi\",\n        \"testFailed\": \"Test échoué\",\n        \"fillRequiredFields\": \"Veuillez remplir tous les champs requis\",\n        \"modelsRequired\": \"Veuillez remplir au moins un modèle\",\n        \"noValidModel\": \"Aucun modèle valide trouvé\",\n        \"addCustomModel\": \"Ajouter un modèle personnalisé\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Configurez la clé API Firecrawl pour activer la fonction de recherche web, accédez aux <a>paramètres Firecrawl</a> pour obtenir la clé API\"\n      },\n      \"app\": {\n        \"domain\": \"Domaine\",\n        \"v0ApiKey\": \"Clé API v0\",\n        \"customDomain\": \"Domaine Personnalisé (Optionnel)\",\n        \"customDomainDescription\": \"Définissez votre domaine personnalisé pour le déploiement de l'application, accédez au <a>domaine Vercel</a> pour obtenir un domaine personnalisé\",\n        \"vercelToken\": \"Token API Vercel\",\n        \"vercelTokenDescription\": \"Accédez aux <a>paramètres Vercel</a> pour obtenir le token API\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Entrez la clé API\",\n      \"goToConfiguration\": \"Aller à la configuration\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Merci d'utiliser teable\",\n      \"pleaseGoToConfiguration\": \"Veuillez vous rendre sur la page des paramètres pour terminer certaines configurations initiales afin de profiter de toutes les fonctionnalités et d'une meilleure expérience utilisateur de teable\",\n      \"pleaseContactAdmin\": \"Veuillez contacter l'administrateur\"\n    },\n    \"configuration\": {\n      \"title\": \"Configuration en attente\",\n      \"description\": \"Complétez ces configurations pour obtenir toutes les fonctionnalités\",\n      \"copyInstance\": \"Copier l'ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"Variable d'environnement PUBLIC_ORIGIN\",\n          \"description\": \"Votre configuration de variable d'environnement <strong>PUBLIC_ORIGIN</strong> ne correspond pas à l'adresse d'accès actuelle <underline>https://example.com</underline>, les fonctions d'importation xlsx, csv et de champ de pièce jointe peuvent ne pas fonctionner normalement, il est recommandé de la définir sur <underline>https://example.com</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Activer HTTPS\",\n          \"description\": \"Vous n'avez pas activé HTTPS, la fonction de copie à grande échelle (300 lignes ou plus) ne sera pas disponible, il est recommandé de l'activer.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"Variable d'environnement PUBLIC_DATABASE_PROXY\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> n'est pas configuré, la fonction de connexion de base de données externe ne sera pas disponible, veuillez consulter le <a>document d'aide</a>\",\n          \"href\": \"https://help.teable.ai/fr/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Vous n'avez pas encore configuré l'API LLM AI, AI Chat/automatisation AI ne pourra pas être utilisé, <anchor>aller aux paramètres</anchor>\",\n          \"errorTips\": \"Vous n'avez pas encore configuré l'API LLM AI, AI Chat/automatisation AI ne pourra pas être utilisé\"\n        },\n        \"app\": {\n          \"title\": \"Générateur d'application\",\n          \"description\": \"Vous n'avez pas encore configuré l'API v0, la fonction Générateur d'application ne sera pas disponible, <anchor>aller aux paramètres</anchor>\",\n          \"errorTips\": \"Vous n'avez pas encore configuré l'API v0, la fonction Générateur d'application ne sera pas disponible\"\n        },\n        \"webSearch\": {\n          \"title\": \"Recherche web\",\n          \"description\": \"Vous n'avez pas encore configuré l'API de recherche web, la fonction de recherche web ne sera pas disponible, <anchor>aller aux paramètres</anchor>\",\n          \"errorTips\": \"Vous n'avez pas encore configuré l'API de recherche web, la fonction de recherche web ne sera pas disponible\"\n        },\n        \"email\": {\n          \"title\": \"Email\",\n          \"description\": \"Email non configuré, la récupération de mot de passe en libre-service et la fonction de notification par email ne seront pas disponibles, <anchor>aller aux paramètres</anchor>\",\n          \"errorTips\": \"Email non configuré, la récupération de mot de passe en libre-service, la vérification email/fonction de notification ne seront pas disponibles\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Notifications\",\n    \"unread\": \"Non lu\",\n    \"read\": \"Lu\",\n    \"markAs\": \"Marquer cette notification comme {{status}}\",\n    \"markAllAsRead\": \"Marquer tout comme lu\",\n    \"noUnread\": \"Pas de notifications {{status}}\",\n    \"changeSetting\": \"Modifier les paramètres des notifications de la page\",\n    \"new\": \"nouveau {{count}}\",\n    \"showMore\": \"Voir plus\",\n    \"exportBase\": {\n      \"successText\": \"Les données d'exportation sont prêtes\",\n      \"failedText\": \"L'exportation a échoué, veuillez réessayer\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Propriétaire\",\n      \"creator\": \"Créateur\",\n      \"editor\": \"Éditeur\",\n      \"commenter\": \"Commentateur\",\n      \"viewer\": \"Lecteur\"\n    },\n    \"description\": {\n      \"owner\": \"Peut entièrement configurer et modifier les bases, automatisations, matrices d'autorité et gérer les paramètres et la facturation de l'espace\",\n      \"creator\": \"Peut entièrement configurer et modifier les bases, automatisations et matrices d'autorité\",\n      \"editor\": \"Peut modifier les enregistrements et les vues, mais ne peut pas configurer les tables ou les champs\",\n      \"commenter\": \"Peut commenter les enregistrements\",\n      \"viewer\": \"Ne peut pas modifier ou commenter\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Corbeille de l'espace\",\n    \"type\": \"Type\",\n    \"resetTrash\": \"Vider\",\n    \"deletedBy\": \"Supprimé par\",\n    \"deletedTime\": \"Date de suppression\",\n    \"fromSpace\": \"De l'espace \\\"{{name}}\\\"\",\n    \"permanentDeleteTips\": \"Êtes-vous sûr de vouloir supprimer définitivement \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"Êtes-vous sûr de vouloir vider la corbeille?\",\n    \"addToTrash\": \"Déplacer dans la corbeille\",\n    \"description\": \"Les données dans la corbeille occupent toujours un espace d'utilisation de records et d'utilisation de pièces jointes.\",\n    \"spaceDescription\": \"Restaurer les espaces supprimés au cours des {{retentionDays}} derniers jours\",\n    \"spaceInnerDescription\": \"Restaurer les bases supprimées de cet espace au cours des {{retentionDays}} derniers jours\",\n    \"baseDescription\": \"Restaurer les ressources supprimées de cette base au cours des {{retentionDays}} derniers jours\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Le plugin n'a pas d'URL\",\n    \"install\": \"Installer\",\n    \"publisher\": \"Éditeur\",\n    \"lastUpdated\": \"Dernière mise à jour\",\n    \"pluginNotFound\": \"Plugin introuvable\",\n    \"pluginEmpty\": {\n      \"title\": \"Pas encore de plugins\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Voulez-vous activer l'automatisation actuelle ?\"\n  },\n  \"email\": {\n    \"send\": \"Envoyer\",\n    \"config\": \"Configuration Email\",\n    \"customConfig\": \"Serveur Email Personnalisé\",\n    \"notify\": \"Email de Notification\",\n    \"automation\": \"Email d'Automatisation\",\n    \"customNotifyConfig\": \"Configuration Email de Notification Personnalisée\",\n    \"customAutomationConfig\": \"Configuration Email d'Automatisation Personnalisée\",\n    \"addConfig\": \"Ajouter Configuration\",\n    \"editConfig\": \"Modifier Configuration\",\n    \"resetConfig\": \"Réinitialiser\",\n    \"testEmail\": \"Email de Test\",\n    \"testEmailPlaceholder\": \"Veuillez saisir l'email de test\",\n    \"testEmailError\": \"Veuillez saisir une adresse email de test valide\",\n    \"testEmailSend\": \"Email de test envoyé avec succès, veuillez vérifier votre boîte de réception\",\n    \"configError\": \"Veuillez saisir une configuration email valide\",\n    \"host\": \"Adresse du Serveur\",\n    \"hostDescription\": \"Veuillez saisir l'adresse du serveur email SMTP, par exemple : smtp.example.com\",\n    \"port\": \"Port\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Authentification\",\n    \"username\": \"Nom d'utilisateur\",\n    \"password\": \"Mot de passe\",\n    \"sender\": \"Adresse de l'Expéditeur\",\n    \"senderName\": \"Nom de l'Expéditeur\",\n    \"subscribe\": \"S'abonner\",\n    \"unsubscribe\": \"Se désabonner\",\n    \"unsubscribeList\": \"Liste de désabonnement\",\n    \"unsubscribeTime\": \"Heure de désabonnement\",\n    \"source\": \"Source\",\n    \"sourceAutomationDeleted\": \"Automatisation ou nœud supprimé\",\n    \"processing\": \"Traitement en cours...\",\n    \"unsubscribeH1\": \"Confirmer le désabonnement ?\",\n    \"unsubscribeH2\": \"Vous êtes sur le point de vous désabonner des futures promotions et mises à jour de produits Teable. Êtes-vous sûr de vouloir vous désabonner ?\",\n    \"subscribeH1\": \"Confirmer l'abonnement ?\",\n    \"subscribeH2\": \"Vous êtes sur le point de vous abonner aux futures promotions et mises à jour de produits Teable. Êtes-vous sûr de vouloir vous abonner ?\",\n    \"unsubscribeListTip\": \"Les utilisateurs suivants de la base actuelle se sont désabonnés, vous ne pourrez plus leur envoyer d'e-mails. Lors de l'importation d'e-mails, veuillez placer les adresses e-mail dans la première colonne et nommer l'en-tête \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Réinitialiser le mot de passe - {{brandName}}\",\n        \"title\": \"Réinitialisez votre mot de passe\",\n        \"message\": \"Si vous n'avez pas demandé ce changement, veuillez ignorer cet e-mail. Sinon, cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe.\",\n        \"buttonText\": \"Réinitialiser le mot de passe\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Vérification d'inscription - {{brandName}}\",\n          \"title\": \"Vérification d'inscription\",\n          \"message\": \"Votre code de vérification est {{code}}, veuillez l'utiliser dans {{expiresIn}} minutes.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Vérification de domaine - {{brandName}}\",\n          \"title\": \"Vérification de domaine\",\n          \"message\": \"Votre code à usage unique est : {{code}}, veuillez l'utiliser dans {{expiresIn}} minutes.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Vérification de changement d'e-mail - {{brandName}}\",\n          \"title\": \"Vérification de changement d'e-mail\",\n          \"message\": \"Votre code de vérification est {{code}}, veuillez l'utiliser dans {{expiresIn}} minutes.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} vous a ajouté au champ {{fieldName}} d'un enregistrement dans {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> vous a ajouté au champ <strong>{{fieldName}}</strong> d'un enregistrement dans <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Voir l'enregistrement\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} vous a ajouté à {{refLength}} enregistrements dans {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> vous a ajouté à <strong>{{refLength}}</strong> enregistrements dans <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Voir les enregistrements\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) vous a invité à leur {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Invitation à collaborer\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) vous a invité à leur {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Accepter l'invitation\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Bienvenue - {{brandName}}\",\n        \"title\": \"Bienvenue\",\n        \"message\": \"Vous avez rejoint avec succès la liste d'attente de {{brandName}}. Veuillez utiliser le code d'invitation suivant pour vous inscrire: {{code}}, il peut être utilisé {{times}} fois.\",\n        \"buttonText\": \"S'inscrire\"\n      },\n      \"test\": {\n        \"subject\": \"E-mail de test - {{brandName}}\",\n        \"title\": \"E-mail de test\",\n        \"message\": \"Ceci est un e-mail de test, veuillez l'ignorer.\"\n      },\n      \"notify\": {\n        \"subject\": \"Notification - {{brandName}}\",\n        \"title\": \"Notification\",\n        \"buttonText\": \"Voir\",\n        \"import\": {\n          \"title\": \"Notification de résultat d'importation\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ Importation de {{tableName}} interrompue: {{errorMessage}} plage de lignes échouées: [{{range}}]. Veuillez vérifier les données de cette plage et réessayer.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ Importation de {{tableName}} échouée: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ Importation de {{tableName}} échouée: Limite de lignes atteinte, veuillez mettre à niveau votre plan pour importer plus d'enregistrements\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ Importation de {{tableName}} échouée: Aucun enregistrement n'a été traité\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} importé avec succès.\",\n              \"inplace\": \"🎉 {{tableName}} importé en place avec succès.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Notification de commentaire d'enregistrement\",\n          \"message\": \"{{fromUserName}} a fait un commentaire sur {{recordName}} dans {{tableName}} dans {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Notification d'automatisation\",\n          \"failed\": {\n            \"title\": \"L'automatisation {{name}} a échoué\",\n            \"message\": \"Votre automatisation {{name}} n'a pas pu s'exécuter. Cliquez sur le bouton ci-dessous pour voir les erreurs spécifiques de l'historique d'exécution.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"L'automatisation {{name}} a échoué en raison d'un crédit insuffisant\",\n            \"message\": \"Votre automatisation {{name}} n'a pas pu s'exécuter en raison d'un crédit insuffisant. Veuillez mettre à niveau votre abonnement ou contacter le support.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"L'automatisation {{name}} a atteint le nombre maximal d'exécutions mensuelles\",\n            \"message\": \"Le quota mensuel d'exécutions de l'automatisation {{name}} est épuisé. L'exécution est temporairement indisponible. Veuillez mettre à niveau votre abonnement ou acheter des exécutions supplémentaires.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Notification de facturation\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Espace {{spaceName}} crédits IA 80 % utilisés\",\n              \"message\": \"Votre espace a utilisé 80 % des crédits IA. Envisagez de mettre à niveau ou d'acheter des crédits supplémentaires.\"\n            },\n            \"warning90\": {\n              \"title\": \"Espace {{spaceName}} crédits IA 90 % utilisés\",\n              \"message\": \"Votre espace a utilisé 90 % des crédits IA. Mettez à niveau bientôt pour éviter les interruptions.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Espace {{spaceName}} exécutions d'automatisation 80 % du quota utilisé\",\n              \"message\": \"Votre espace a utilisé {{usedRuns}} sur {{totalLimit}} exécutions d'automatisation (80 %). Envisagez de mettre à niveau votre plan.\"\n            },\n            \"warning90\": {\n              \"title\": \"Espace {{spaceName}} exécutions d'automatisation 90 % du quota utilisé\",\n              \"message\": \"Votre espace a utilisé {{usedRuns}} sur {{totalLimit}} exécutions d'automatisation (90 %). Mettez à niveau bientôt pour éviter les interruptions.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Espace {{spaceName}} exécutions d'automatisation dépassées - période de grâce active\",\n              \"message\": \"Votre quota d'exécutions d'automatisation a été dépassé. Les automatisations cesseront de fonctionner et seront désactivées dans {{remainingHours}} heures. Veuillez mettre à niveau votre plan.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Notification de résultat d'exportation de base\",\n          \"success\": {\n            \"message\": \"{{baseName}} exporté avec succès: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ Exportation de {{baseName}} échouée: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"Tâche IA échouée dans la table {{tableName}}\",\n              \"message\": \"La tâche IA pour le champ {{fieldName}} dans la table {{tableName}} (ID d'enregistrement: {{recordId}}) a échoué.\\n\\n{{errorMsg}}\\n\\nCliquez sur le bouton ci-dessous pour voir les détails.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Liste d'attente\",\n    \"email\": \"Email\",\n    \"joinTitle\": \"Nous évoluons rapidement pour répondre à la demande\",\n    \"joinDesc\": \"Rejoignez la liste d'attente — nous vous enverrons un message dès que nous serons prêts\",\n    \"emailPlaceholder\": \"Entrez votre adresse email...\",\n    \"youAreOnTheList\": \"Vous êtes sur la liste !\",\n    \"thanksForJoining\": \"Merci d'avoir rejoint notre liste d'attente. Nous vous enverrons un message dès que nous serons prêts.\",\n    \"back\": \"Retour\",\n    \"inviteCodePlaceholder\": \"Entrez le code d'invitation\",\n    \"join\": \"Rejoindre la liste d'attente\",\n    \"joining\": \"Rejoindre...\",\n    \"invite\": \"Inviter\",\n    \"inviteTime\": \"Temps d'invitation\",\n    \"createdTime\": \"Date d'inscription\",\n    \"yes\": \"Oui\",\n    \"no\": \"Non\",\n    \"generateCode\": \"Générer un code d'invitation\",\n    \"count\": \"Quantité\",\n    \"times\": \"Usages\",\n    \"generate\": \"Générer\",\n    \"code\": \"Code d'invitation\",\n    \"inviteSuccess\": \"Invitation réussie\",\n    \"app\": {\n      \"previewAppError\": \"Erreur d'application\",\n      \"sendErrorToAI\": \"Envoyer l'erreur à AI\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"Êtes-vous sûr de vouloir supprimer \\\"{{name}}\\\" base?\",\n    \"createResource\": \"Créer une ressource\",\n    \"noPermissionToCreateResource\": \"Vous n'avez pas la permission de créer une ressource\"\n  },\n  \"credit\": {\n    \"title\": \"Crédit\",\n    \"leftAmount\": \"Crédits restants\",\n    \"winFreeCredits\": \"Partager son expérience\",\n    \"getCredits\": \"Obtenir 1000 crédits gratuits\",\n    \"winCredit\": {\n      \"title\": \"Partagez un avis positif pour gagner\",\n      \"freeCredits\": \"1000 crédits gratuits\",\n      \"guidelinesTitle\": \"Liste de vérification du partage\",\n      \"tagTeableio\": \"Mentionnez <bold>@teableio</bold>\",\n      \"minCharacters\": \"Écrivez un avis de <bold>80+</bold> caractères\",\n      \"minFollowers\": \"Ayez <bold>10+</bold> abonnés\",\n      \"limitPerWeek\": \"500 crédits par publication, jusqu'à 2 publications par semaine (X + LinkedIn)\",\n      \"postOnX\": \"Publier sur X\",\n      \"postOnLinkedIn\": \"Publier sur LinkedIn\",\n      \"preFilledDraft\": \"🌟 Brouillon pré-rempli prêt pour vous !\",\n      \"claimTitle\": \"Collez l'URL de la publication pour réclamer vos crédits\",\n      \"userEmail\": \"E-mail de l'utilisateur\",\n      \"postUrlLabel\": \"URL de la publication (X ou LinkedIn)\",\n      \"postUrlPlaceholder\": \"Collez le lien de votre publication ici\",\n      \"invalidUrl\": \"Veuillez entrer une URL de publication X ou LinkedIn valide\",\n      \"claiming\": \"Réclamation en cours...\",\n      \"claimCredits\": \"Réclamer des crédits\",\n      \"congratulations\": \"Félicitations !\",\n      \"claimSuccess\": \"Vous avez réclamé 500 crédits !\",\n      \"verifying\": \"Vérification de votre publication...\",\n      \"verifyingDescription\": \"Nous vérifions le contenu de votre publication, cela prend généralement quelques secondes\",\n      \"verifyFailed\": \"Échec de la vérification\",\n      \"tryAgain\": \"Réessayer\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Échec de la vérification\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Récompense\",\n    \"rewardCredits\": \"Crédits de récompense\",\n    \"minCharCount\": \"La publication doit contenir au moins {{count}} caractères\",\n    \"minFollowerCount\": \"Le compte doit avoir au moins {{count}} abonnés\",\n    \"mustMention\": \"La publication doit mentionner {{mention}}\",\n    \"fetchSnapshotFailed\": \"Échec de la récupération de la capture de la publication\",\n    \"alreadyClaimedThisWeek\": \"Vous avez déjà réclamé une récompense pour ce compte cette semaine\",\n    \"manage\": {\n      \"title\": \"Gestion des récompenses\",\n      \"description\": \"Voir et gérer les enregistrements de récompenses dans tous les espaces\",\n      \"overview\": \"Aperçu\",\n      \"records\": \"Enregistrements\",\n      \"searchSpace\": \"Rechercher un espace...\",\n      \"searchRecords\": \"Rechercher postUrl/userId...\",\n      \"dateRange\": \"Sélectionner une plage de dates\",\n      \"from\": \"De\",\n      \"to\": \"À\",\n      \"totalSpaces\": \"Total : {{count}} espaces\",\n      \"totalRecords\": \"Total : {{count}} enregistrements\",\n      \"space\": \"Espace\",\n      \"allSpaces\": \"Tous les espaces\",\n      \"user\": \"Utilisateur\",\n      \"creator\": \"Créateur\",\n      \"sourceType\": \"Type de source\",\n      \"platform\": \"Plateforme\",\n      \"allStatuses\": \"Tous les statuts\",\n      \"allSourceTypes\": \"Tous les types de source\",\n      \"allPlatforms\": \"Toutes les plateformes\",\n      \"pendingCount\": \"Nombre en attente\",\n      \"approvedCount\": \"Nombre approuvé\",\n      \"rejectedCount\": \"Nombre rejeté\",\n      \"approvedAmount\": \"Montant approuvé\",\n      \"consumedAmount\": \"Montant consommé\",\n      \"availableAmount\": \"Montant disponible\",\n      \"expiringSoonAmount\": \"Montant expirant bientôt (7j)\",\n      \"amount\": \"Montant\",\n      \"remainingAmount\": \"Montant restant\",\n      \"createdTime\": \"Date de création\",\n      \"rewardTime\": \"Date de récompense\",\n      \"expiredTime\": \"Date d'expiration\",\n      \"lastModified\": \"Dernière modification\",\n      \"viewDetails\": \"Voir les détails\",\n      \"details\": \"Détails de la récompense\",\n      \"basicInfo\": \"Informations de base\",\n      \"amountInfo\": \"Informations sur le montant\",\n      \"timeInfo\": \"Informations temporelles\",\n      \"socialInfo\": \"Informations sociales\",\n      \"verifyResult\": \"Résultat de la vérification\",\n      \"uniqueKey\": \"Clé unique\",\n      \"verify\": \"Vérifier\",\n      \"valid\": \"Valide\",\n      \"invalid\": \"Invalide\",\n      \"errors\": \"Erreurs\",\n      \"copied\": \"{{label}} copié\",\n      \"openPost\": \"Ouvrir la publication\",\n      \"noData\": \"Aucune donnée\",\n      \"page\": \"Page {{current}} sur {{total}}\",\n      \"status\": {\n        \"label\": \"Statut\",\n        \"pending\": \"En attente\",\n        \"approved\": \"Approuvé\",\n        \"rejected\": \"Rejeté\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Page non trouvée\",\n      \"description\": \"Le lien que vous avez suivi est peut-être rompu ou la page a été déplacée.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Retour à l'accueil\"\n    },\n    \"forbidden\": {\n      \"title\": \"Accès restreint\",\n      \"description\": \"Vous avez besoin d'une autorisation pour accéder à cette ressource.\\nVeuillez contacter votre administrateur.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Débloquer la fonctionnalité Premium\",\n      \"description\": \"Cette fonctionnalité est disponible dans les forfaits avancés.\\nPassez à un forfait supérieur pour étendre vos capacités.\"\n    },\n    \"error\": {\n      \"title\": \"Une erreur s'est produite\",\n      \"description\": \"Une erreur inattendue s'est produite. Veuillez réessayer plus tard.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Échec de l'analyse de la date, la valeur \\\"{{value}}\\\" est hors de la plage valide\",\n      \"planRowLimit\": \"Limite de lignes atteinte : veuillez mettre à niveau votre plan pour importer plus d'enregistrements\",\n      \"notNullValidation\": \"{{fieldHint}}Les champs obligatoires ne peuvent pas être vides\",\n      \"uniqueValidation\": \"{{fieldHint}}Valeurs en double dans les champs uniques\",\n      \"requestTimeout\": \"Délai d'attente dépassé\",\n      \"chunkProcessingFailed\": \"Échec du traitement par lots : {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"NOUVELLE MISE À JOUR\",\n    \"title\": \"Téléchargement groupé des pièces jointes disponible\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"Générateur de requêtes API\",\n  \"subTitle\": \"Vous pouvez rapidement créer vos requêtes via une interface interactive et copier le code qui peut être exécuté directement\",\n  \"apiList\": \"Liste complète des API\",\n  \"cellFormat\": \"Format de résultat de la cellule\",\n  \"fieldKeyType\": \"Type de clé du champ\",\n  \"chooseSource\": \"Choisissez une source de données\",\n  \"action\": {\n    \"selectBase\": \"Sélectionnez une base de données...\",\n    \"selectTable\": \"Sélectionnez une table...\"\n  },\n  \"pickParams\": \"Sélectionnez et configurez les paramètres\",\n  \"buildResult\": \"Construire le résultat\",\n  \"buildResultEmpty\": \"Aucun résultat pour l'instant\",\n  \"previewReturnValue\": \"Aperçu de la valeur de retour\",\n  \"replaceToken\": \"Remplacer le jeton\",\n  \"createNewToken\": \"Créer un nouveau jeton\",\n  \"only10Records\": \"Seuls les 10 premiers enregistrements sont affichés\",\n  \"addSort\": \"Ajouter un tri\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/oauth.json",
    "content": "{\n  \"add\": \"Nouvelle application OAuth\",\n  \"title\": {\n    \"add\": \"Nouvelle application OAuth\",\n    \"edit\": \"Modifier l'application OAuth\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"Nom de l'application OAuth\",\n      \"description\": \"Le nom de votre application OAuth.\"\n    },\n    \"description\": {\n      \"label\": \"Description\",\n      \"description\": \"Une brève description de votre application OAuth.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"URL de la page d'accueil\",\n      \"description\": \"L'URL complète du site web de votre application OAuth.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Une image carrée est recommandée.\",\n      \"placeholder\": \"Glissez-déposez votre logo ici ou cliquez pour télécharger\",\n      \"button\": \"Télécharger un logo\",\n      \"clear\": \"Effacer\",\n      \"lengthError\": \"Un seul fichier est autorisé.\",\n      \"typeError\": \"Seul un fichier image est autorisé.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"URL de rappel\",\n      \"description\": \"L'URL complète à laquelle rediriger après qu'un utilisateur a autorisé votre intégration.\",\n      \"add\": \"Ajouter une URL de rappel\"\n    },\n    \"scopes\": {\n      \"label\": \"Périmètres\",\n      \"description\": \"Les autorisations dont votre application OAuth a besoin.\"\n    },\n    \"secret\": {\n      \"label\": \"Secrets du client\",\n      \"add\": \"Générer un nouveau secret client\",\n      \"newDescription\": \"Assurez-vous de copier votre nouveau secret client maintenant. Vous ne pourrez plus le voir par la suite.\",\n      \"empty\": \"Vous avez besoin d'un secret client pour vous authentifier en tant qu'application auprès de l'API.\",\n      \"lastUsed\": \"Dernière utilisation le {{date}}\",\n      \"tag\": \"Secret client\",\n      \"neverUsed\": \"Jamais utilisé\"\n    },\n    \"clientId\": {\n      \"label\": \"ID Client : \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Informations de base\",\n    \"scopes\": \"Périmètres\",\n    \"identify\": \"Identification et autorisation des utilisateurs\",\n    \"clientInfo\": \"Informations sur le client\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} demande l'accès à votre compte\",\n    \"scopes\": \"Cette application pourra obtenir les périmètres suivants :\",\n    \"redirectDescription\": \"L'autorisation redirigera vers\",\n    \"authorize\": \"Autoriser\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Bientôt disponible\",\n    \"empty\": \"Vide\",\n    \"noRecords\": \"Aucun enregistrement disponible\",\n    \"unnamedRecord\": \"Enregistrement sans nom\",\n    \"untitled\": \"Sans titre\",\n    \"cancel\": \"Annuler\",\n    \"confirm\": \"Confirmer\",\n    \"back\": \"Retour\",\n    \"done\": \"Terminé\",\n    \"create\": \"Créer\",\n    \"search\": {\n      \"placeholder\": \"Rechercher...\",\n      \"empty\": \"Aucun résultat trouvé\"\n    },\n    \"readOnlyTip\": \"Cette vue est verrouillée. Vous pouvez activer le <button>mode personnel</button> pour modifier les options de la vue, et les modifications ne prendront effet que pour vous.\",\n    \"selectPlaceHolder\": \"Sélectionner...\",\n    \"loading\": \"Chargement...\",\n    \"loadMore\": \"Charger plus\",\n    \"uploadFailed\": \"Échec du téléchargement\",\n    \"rowCount\": \"{{count}} enregistrements\",\n    \"summary\": \"Résumé\",\n    \"summaryTip\": \"Survolez pour sélectionner le résumé\",\n    \"actions\": \"Actions\",\n    \"remove\": \"Supprimer\",\n    \"runStatus\": {\n      \"success\": \"{{name}} réussi\",\n      \"failed\": \"{{name}} échoué\",\n      \"running\": \"{{name}} en cours\"\n    },\n    \"resetSuccess\": \"Réinitialisation réussie\",\n    \"click\": \"Cliquer\",\n    \"clickedCount\": \"{{label}}: Cliquer {{text}} fois\",\n    \"atLeastOne\": \"Conservez au moins un {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Notification\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Limite de taille du fichier en aperçu : {{size}} Mo, veuillez télécharger pour le visualiser.\",\n    \"loadFileError\": \"Échec du chargement du fichier\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Undo\",\n    \"redo\": \"Redo\",\n    \"undoFailed\": \"Undo failed\",\n    \"redoFailed\": \"Redo failed\",\n    \"nothingToUndo\": \"Nothing to undo\",\n    \"nothingToRedo\": \"Nothing to redo\",\n    \"undoSucceed\": \"Undo succeed\",\n    \"redoSucceed\": \"Redo succeed\",\n    \"undoing\": \"undoing...\",\n    \"redoing\": \"redoing...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Relâchez pour télécharger le fichier\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Collez ou faites glisser pour télécharger ici\",\n      \"upload\": \"télécharger\"\n    },\n    \"date\": {\n      \"placeholder\": \"Choisir une date\",\n      \"today\": \"Aujourd'hui\"\n    },\n    \"formula\": {\n      \"title\": \"Éditeur de formule\",\n      \"guideSyntax\": \"Syntaxe\",\n      \"guideExample\": \"Exemple\",\n      \"helperExample\": \"Exemple : \",\n      \"fieldValue\": \"Renvoie la valeur aux cellules du champ {{fieldName}}.\",\n      \"placeholder\": \"Entrez une expression\",\n      \"placeholderForAIPrompt\": \"Décrivez la formule que vous souhaitez générer\",\n      \"editExpression\": \"Modifier la formule\",\n      \"generateExpressionByAI\": \"Générer la formule avec AI\",\n      \"inputPrompt\": \"Entrez une instruction\",\n      \"generateExpression\": \"Formule générée\",\n      \"generatingByAI\": \"Génération de la formule avec AI...\",\n      \"generatedExpressionTips\": \"Après génération, cliquez sur Appliquer pour insérer rapidement la formule\",\n      \"action\": {\n        \"generating\": \"Génération...\",\n        \"generate\": \"Générer\",\n        \"apply\": \"Appliquer\"\n      },\n      \"expressionRequired\": \"L'expression est requise\"\n    },\n    \"link\": {\n      \"placeholder\": \"Sélectionner des enregistrements à lier\",\n      \"searchPlaceholder\": \"Rechercher des enregistrements\",\n      \"allFields\": \"Tous les champs\",\n      \"globalSearch\": \"Recherche globale\",\n      \"fieldSearch\": \"Recherche par champ\",\n      \"maxFieldTips\": \"Le nombre maximum de {{count}} champs a été dépassé, les champs supplémentaires seront ignorés\",\n      \"create\": \"Ajouter un enregistrement\",\n      \"selectRecord\": \"Sélectionner un enregistrement\",\n      \"all\": \"Tous\",\n      \"selected\": \"Sélectionné\",\n      \"expandRecordError\": \"Aucun droit pour voir cet enregistrement.\",\n      \"alreadyOpen\": \"Cet enregistrement est déjà ouvert.\",\n      \"linkedTo\": \"Lié à\",\n      \"goToForeignTable\": \"Accéder à la table liée\",\n      \"foreignTableIdRequired\": \"La table externe est requise\",\n      \"linkFieldIdRequired\": \"Le champ de lien est requis\",\n      \"selectTooManyRecords\": \"Les enregistrements sélectionnés ne doivent pas dépasser {{maxCount}}\",\n      \"relationshipRequired\": \"La relation est requise\",\n      \"rangeSelectFailed\": \"Échec de la sélection des enregistrements dans la plage\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Trouver des utilisateurs par nom\",\n      \"notify\": \"Notifier les utilisateurs une fois qu'ils sont sélectionnés\"\n    },\n    \"select\": {\n      \"addOption\": \"Ajouter une option '{{option}}'\",\n      \"choicesNameRequired\": \"Le nom du choix ne peut pas être vide\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Le champ de recherche est requis\",\n      \"lookupOptionsNotAllowed\": \"Les options de recherche ne sont pas autorisées lorsque l'attribut isLookup est vrai ou que le type de champ est rollup.\",\n      \"lookupOptionsRequired\": \"Les options de recherche sont requises\",\n      \"refineOptionsError\": \"Erreur lors de l'analyse des options de recherche {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"L'expression est requise\",\n      \"unsupportedTip\": \"Le rollup ne prend en charge que les champs de lien et de rollup\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Le filtre doit contenir au moins une condition\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"La recherche conditionnelle nécessite au moins un filtre\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Le modèle est requis\",\n      \"typeNotSupported\": \"Type d'AI non pris en charge\",\n      \"sourceFieldIdRequired\": \"Le champ source est requis\",\n      \"targetLanguageRequired\": \"La langue cible est requise\",\n      \"promptRequired\": \"L'invite est requise\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Erreur lors de l'analyse des options de champ {{message}}\",\n      \"optionsRequired\": \"Les options de champ sont requises\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filtrer\",\n    \"displayLabel\": \"Filtrer par \",\n    \"displayLabel_other\": \"Filtrer par {{fieldName}} et {{count}} autres champs\",\n    \"addCondition\": \"Ajouter une condition\",\n    \"addConditionGroup\": \"Ajouter un groupe de conditions\",\n    \"nestedLimitTip\": \"Les conditions de filtrage ne peuvent être imbriquées que jusqu'à 3 niveaux\",\n    \"linkInputPlaceholder\": \"Entrez une valeur\",\n    \"groupDescription\": \"L'un des éléments suivants est vrai…\",\n    \"currentUser\": \"Moi (utilisateur actuel)\",\n    \"tips\": {\n      \"scope\": \"Dans cette vue, afficher les enregistrements\"\n    },\n    \"invalidateSelected\": \"Invalid value\",\n    \"invalidateSelectedTips\": \"The selected value has been deleted, please select again\",\n    \"default\": {\n      \"empty\": \"Aucune condition de filtrage appliquée\",\n      \"placeholder\": \"Entrez une valeur\"\n    },\n    \"conjunction\": {\n      \"and\": \"et\",\n      \"or\": \"ou\",\n      \"where\": \"où\",\n      \"meetingAll\": \"Répondre à toutes les conditions\",\n      \"meetingAny\": \"Répondre à une condition\"\n    },\n    \"operator\": {\n      \"is\": \"est\",\n      \"isNot\": \"n'est pas\",\n      \"contains\": \"contient\",\n      \"doesNotContain\": \"ne contient pas\",\n      \"isEmpty\": \"est vide\",\n      \"isNotEmpty\": \"n'est pas vide\",\n      \"isGreater\": \"est supérieur à\",\n      \"isGreaterEqual\": \"est supérieur ou égal à\",\n      \"isLess\": \"est inférieur à\",\n      \"isLessEqual\": \"est inférieur ou égal à\",\n      \"isAnyOf\": \"est l'un des\",\n      \"isNoneOf\": \"n'est aucun des\",\n      \"hasAnyOf\": \"a l'un des\",\n      \"hasAllOf\": \"a tous les\",\n      \"hasNoneOf\": \"n'a aucun des\",\n      \"isExactly\": \"est exactement\",\n      \"isWithIn\": \"est dans\",\n      \"isBefore\": \"est avant\",\n      \"isAfter\": \"est après\",\n      \"isOnOrBefore\": \"est le ou avant\",\n      \"isOnOrAfter\": \"est le ou après\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"aujourd'hui\",\n        \"tomorrow\": \"demain\",\n        \"yesterday\": \"hier\",\n        \"oneWeekAgo\": \"il y a une semaine\",\n        \"oneWeekFromNow\": \"dans une semaine\",\n        \"oneMonthAgo\": \"il y a un mois\",\n        \"oneMonthFromNow\": \"dans un mois\",\n        \"daysAgo\": \"jours écoulés\",\n        \"daysFromNow\": \"jours restants\",\n        \"exactDate\": \"date exacte\",\n        \"exactFormatDate\": \"date exacte (formatée)\",\n        \"currentWeek\": \"current week\",\n        \"currentMonth\": \"current month\",\n        \"currentYear\": \"current year\",\n        \"lastWeek\": \"last week\",\n        \"lastMonth\": \"last month\",\n        \"lastYear\": \"last year\",\n        \"nextWeekPeriod\": \"next week\",\n        \"nextMonthPeriod\": \"next month\",\n        \"nextYearPeriod\": \"next year\",\n        \"pastWeek\": \"semaine passée\",\n        \"pastMonth\": \"mois passé\",\n        \"pastYear\": \"année passée\",\n        \"nextWeek\": \"semaine prochaine\",\n        \"nextMonth\": \"mois prochain\",\n        \"nextYear\": \"année prochaine\",\n        \"pastNumberOfDays\": \"nombre de jours passés\",\n        \"nextNumberOfDays\": \"nombre de jours à venir\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"couleur\"\n  },\n  \"rowHeight\": {\n    \"short\": \"court\",\n    \"medium\": \"moyen\",\n    \"tall\": \"grand\",\n    \"extraTall\": \"très grand\",\n    \"title\": \"de la hauteur de ligne\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"du nom de champ\",\n    \"displayLines\": \"{{count}} lignes\"\n  },\n  \"share\": {\n    \"title\": \"partager\"\n  },\n  \"extensions\": {\n    \"title\": \"extensions\"\n  },\n  \"hidden\": {\n    \"label\": \"Champs cachés\",\n    \"configLabel_one\": \"{{count}} champ caché\",\n    \"configLabel_other\": \"{{count}} champs cachés\",\n    \"configLabel_other_visible\": \"{{count}} champs visibles\",\n    \"showAll\": \"Tout afficher\",\n    \"hideAll\": \"Tout masquer\",\n    \"primaryKey\": \"Champ principal : utilisé pour identifier les enregistrements, ne peut pas être caché ou supprimé\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Copier dans le presse-papiers\",\n    \"duplicateRecord\": \"Duplicate record\",\n    \"copyRecordUrl\": \"Copier l'URL de l'enregistrement\",\n    \"deleteRecord\": \"Supprimer l'enregistrement\",\n    \"addRecordComment\": \"Ajouter un commentaire\",\n    \"viewRecordHistory\": \"Voir l'historique de l'enregistrement\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Masquer l'historique des enregistrements\",\n      \"showRecordHistory\": \"Afficher l'historique des enregistrements\",\n      \"createdTime\": \"Heure de création\",\n      \"createdBy\": \"Créé par\",\n      \"before\": \"Avant\",\n      \"after\": \"Après\",\n      \"viewRecord\": \"Voir l'enregistrement\"\n    },\n    \"showHiddenFields\": \"Afficher {{count}} champs cachés\",\n    \"hideHiddenFields\": \"Masquer {{count}} champs cachés\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Trier\",\n    \"displayLabel_one\": \"Trier par {{count}} champ\",\n    \"displayLabel_other\": \"Trier par {{count}} champs\",\n    \"setTips\": \"Trier par\",\n    \"addButton\": \"Ajouter un autre tri\",\n    \"autoSort\": \"Trier automatiquement les enregistrements\",\n    \"selectASCLabel\": \"Ordre d'options\",\n    \"selectDESCLabel\": \"Options dans l'ordre inverse\"\n  },\n  \"group\": {\n    \"label\": \"Grouper\",\n    \"displayLabel_one\": \"Grouper par {{count}} champ\",\n    \"displayLabel_other\": \"Grouper par {{count}} champs\",\n    \"setTips\": \"Grouper par\",\n    \"addButton\": \"Ajouter un sous-groupe\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Texte sur une seule ligne\",\n      \"longText\": \"Texte long\",\n      \"singleSelect\": \"Sélection unique\",\n      \"number\": \"Nombre\",\n      \"multipleSelect\": \"Sélection multiple\",\n      \"link\": \"Lien vers un autre enregistrement\",\n      \"formula\": \"Formule\",\n      \"date\": \"Date\",\n      \"createdTime\": \"Heure de création\",\n      \"lastModifiedTime\": \"Dernière modification\",\n      \"attachment\": \"Pièce jointe\",\n      \"checkbox\": \"Case à cocher\",\n      \"rollup\": \"Résumé\",\n      \"conditionalRollup\": \"Résumé conditionnel\",\n      \"user\": \"Utilisateur\",\n      \"rating\": \"Évaluation\",\n      \"autoNumber\": \"Numéro automatique\",\n      \"lookup\": \"Recherche\",\n      \"conditionalLookup\": \"Recherche conditionnelle\",\n      \"button\": \"Bouton\",\n      \"createdBy\": \"Créé par\",\n      \"lastModifiedBy\": \"Dernière modification par\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Enregistrez de courts textes comme des noms ou titres.\",\n      \"longText\": \"Saisissez des notes et descriptions plus longues.\",\n      \"singleSelect\": \"Choisissez une option dans une liste.\",\n      \"number\": \"Suivez des valeurs numériques avec formatage.\",\n      \"multipleSelect\": \"Étiquetez les enregistrements avec plusieurs choix.\",\n      \"link\": \"Reliez cet enregistrement à une autre table.\",\n      \"formula\": \"Calculez des valeurs à partir d'autres champs.\",\n      \"date\": \"Enregistrez des dates ou des heures.\",\n      \"createdTime\": \"Affiche quand l'enregistrement a été créé.\",\n      \"lastModifiedTime\": \"Affiche la dernière mise à jour.\",\n      \"attachment\": \"Téléverser des fichiers ou générer des images par IA, prend en charge des modèles comme 🍌 Nano banana pro\",\n      \"checkbox\": \"Basculez un simple oui ou non.\",\n      \"rollup\": \"Résumez les enregistrements liés avec des formules.\",\n      \"conditionalRollup\": \"Résumez les données selon des conditions.\",\n      \"user\": \"Assignez les enregistrements aux membres de l'espace de travail.\",\n      \"rating\": \"Notez les éléments avec des icônes configurables.\",\n      \"autoNumber\": \"Attribuez une séquence unique à chaque enregistrement.\",\n      \"lookup\": \"Affichez les valeurs depuis des enregistrements liés.\",\n      \"conditionalLookup\": \"Affiche les valeurs liées qui répondent aux filtres définis.\",\n      \"button\": \"Lancez des actions via un bouton cliquable.\",\n      \"createdBy\": \"Indique qui a créé l'enregistrement.\",\n      \"lastModifiedBy\": \"Indique qui l'a modifié en dernier.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Unidirectionnel\",\n      \"twoWay\": \"Bidirectionnel\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Confirmation d'action\",\n        \"description\": \"Êtes-vous sûr de vouloir exécuter cette action?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Créer un espace\",\n      \"spaceDelete\": \"Supprimer un espace\",\n      \"spaceRead\": \"Lire un espace\",\n      \"spaceUpdate\": \"Mettre à jour un espace\",\n      \"spaceInviteEmail\": \"Inviter par email dans l'espace\",\n      \"spaceInviteLink\": \"Inviter par lien dans l'espace\",\n      \"spaceGrantRole\": \"Accorder un rôle dans l'espace\",\n      \"baseCreate\": \"Créer une base\",\n      \"baseDelete\": \"Supprimer une base\",\n      \"baseRead\": \"Lire une base\",\n      \"baseReadAll\": \"Lire toutes les bases\",\n      \"baseUpdate\": \"Mettre à jour une base\",\n      \"baseInviteEmail\": \"Inviter par email dans la base\",\n      \"baseInviteLink\": \"Inviter par lien dans la base\",\n      \"baseTableImport\": \"Importer des données dans la base\",\n      \"baseAuthorityMatrixConfig\": \"Configurer la matrice d'autorité\",\n      \"baseDbConnect\": \"Se connecter à la base de données\",\n      \"tableCreate\": \"Créer une table\",\n      \"tableRead\": \"Lire une table\",\n      \"tableDelete\": \"Supprimer une table\",\n      \"tableUpdate\": \"Mettre à jour une table\",\n      \"tableImport\": \"Importer des données dans la table\",\n      \"tableExport\": \"Exporter les données de la table\",\n      \"tableTrashRead\": \"Lire la corbeille de la table\",\n      \"tableTrashUpdate\": \"Mettre à jour la corbeille de la table\",\n      \"tableTrashReset\": \"Réinitialiser la corbeille de la table\",\n      \"viewCreate\": \"Créer une vue\",\n      \"viewDelete\": \"Supprimer une vue\",\n      \"viewRead\": \"Lire une vue\",\n      \"viewUpdate\": \"Mettre à jour une vue\",\n      \"viewShare\": \"Share view\",\n      \"fieldCreate\": \"Créer un champ\",\n      \"fieldDelete\": \"Supprimer un champ\",\n      \"fieldRead\": \"Lire un champ\",\n      \"fieldUpdate\": \"Mettre à jour un champ\",\n      \"recordCreate\": \"Créer un enregistrement\",\n      \"recordComment\": \"Commenter un enregistrement\",\n      \"recordDelete\": \"Supprimer un enregistrement\",\n      \"recordRead\": \"Lire un enregistrement\",\n      \"recordUpdate\": \"Mettre à jour un enregistrement\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Créer une automatisation\",\n      \"automationDelete\": \"Supprimer une automatisation\",\n      \"automationRead\": \"Lire une automatisation\",\n      \"automationUpdate\": \"Mettre à jour une automatisation\",\n      \"appCreate\": \"Créer une application\",\n      \"appDelete\": \"Supprimer une application\",\n      \"appRead\": \"Lire une application\",\n      \"appUpdate\": \"Mettre à jour une application\",\n      \"userProfileRead\": \"Lire le profil de l'utilisateur\",\n      \"userEmailRead\": \"Lire l'email de l'utilisateur\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Lire l'historique des enregistrements\",\n      \"baseQuery\": \"Interroger la base\",\n      \"instanceRead\": \"Lire l'instance\",\n      \"instanceUpdate\": \"Mettre à jour l'instance\",\n      \"enterpriseRead\": \"Read enterprise configuration\",\n      \"enterpriseUpdate\": \"Update enterprise configuration\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Table\",\n    \"view\": \"Vue\",\n    \"space\": \"Espace\",\n    \"base\": \"Base\",\n    \"field\": \"Champ\",\n    \"record\": \"Enregistrement\",\n    \"automation\": \"Automatisation\",\n    \"app\": \"Application\",\n    \"user\": \"Utilisateur\",\n    \"recordHistory\": \"Historique des enregistrements\",\n    \"you\": \"Vous\",\n    \"instance\": \"Instance\",\n    \"enterprise\": \"Entreprise\",\n    \"history\": \"Historique\",\n    \"global\": \"Global\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Additionne les nombres. Équivalent à number1 + number2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Renvoie la moyenne des nombres.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Renvoie le plus grand des nombres donnés.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Renvoie le plus petit des nombres donnés.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Arrondit la valeur au nombre de décimales donné par \\\"precision\\\" (En particulier, ROUND arrondit à l'entier le plus proche à la précision spécifiée, les égalités étant arrondies vers le haut vers l'infini positif.)\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Arrondit la valeur au nombre de décimales donné par \\\"precision\\\" toujours en arrondissant vers le haut, c'est-à-dire, loin de zéro. (Vous devez fournir une valeur pour la précision ou la fonction ne fonctionnera pas.)\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Arrondit la valeur au nombre de décimales donné par \\\"precision\\\" toujours en arrondissant vers le bas, c'est-à-dire, vers zéro. (Vous devez fournir une valeur pour la précision ou la fonction ne fonctionnera pas.)\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Renvoie le multiple entier le plus proche de la signification qui est supérieur ou égal à la valeur. Si aucune signification n'est fournie, une signification de 1 est supposée.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Renvoie le multiple entier le plus proche de la signification qui est inférieur ou égal à la valeur. Si aucune signification n'est fournie, une signification de 1 est supposée.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Renvoie le plus petit entier pair qui est supérieur ou égal à la valeur spécifiée.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Arrondit la valeur positive au nombre impair le plus proche et la valeur négative au nombre impair le plus proche vers le bas.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Arrondit un nombre vers le bas à l'entier le plus proche.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Renvoie la valeur absolue.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Renvoie la racine carrée d'un nombre non négatif.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Calcule la base spécifiée à la puissance spécifiée.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Calcule le nombre d'Euler (e) à la puissance spécifiée.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Calcule le logarithme de la valeur dans la base fournie. La base par défaut est 10 si non spécifiée.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Renvoie le reste après avoir divisé le premier argument par le second.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Convertit la chaîne de texte en nombre.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Joins ensemble divers types de valeurs en une seule valeur texte.\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Trouve une occurrence de stringToFind dans la chaîne whereToSearch en commençant à partir de la position startFromPosition (startFromPosition est 0 par défaut). Si aucune occurrence de stringToFind n'est trouvée, le résultat sera 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Recherche une occurrence de stringToFind dans la chaîne whereToSearch en commençant à partir de la position startFromPosition (startFromPosition est 0 par défaut). Si aucune occurrence de stringToFind n'est trouvée, le résultat sera vide.\\nSemblable à FIND(), bien que FIND() renvoie 0 au lieu de vide si aucune occurrence de stringToFind n'est trouvée.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Extrait une sous-chaîne de count caractères en commençant à whereToStart.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Extrait howMany caractères du début de la chaîne.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Extrait howMany caractères de la fin de la chaîne.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Remplace le nombre de caractères commençant à partir du caractère start par le texte de remplacement.\\n(Si vous recherchez un moyen de trouver et de remplacer toutes les occurrences de old_text par new_text, voir SUBSTITUTE().)\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Remplace toutes les sous-chaînes correspondant à l'expression régulière par le remplacement.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Remplace les occurrences de old_text par new_text.\\nVous pouvez optionnellement spécifier un numéro d'index (commençant à 1) pour remplacer juste une occurrence spécifique de old_text. Si aucun numéro d'index n'est spécifié, toutes les occurrences de old_text seront remplacées.\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Met une chaîne en minuscules.\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Met une chaîne en majuscules.\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Répète une chaîne un nombre spécifié de fois.\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Supprime les espaces au début et à la fin de la chaîne.\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Renvoie le nombre de caractères dans la chaîne.\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Renvoie l'argument s'il s'agit de texte et vide sinon.\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Remplace certains caractères par des équivalents encodés pour l'utilisation dans la construction d'URL ou d'URI. N'encode pas les caractères suivants : - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Renvoie value1 si l'argument logique est vrai, sinon renvoie value2. Peut également être utilisé pour créer des instructions IF imbriquées.\\nPeut également être utilisé pour vérifier si une cellule est vide.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Prend une expression, une liste de valeurs possibles pour cette expression, et pour chacune, une valeur que l'expression devrait prendre dans ce cas. Elle peut également prendre une valeur par défaut si l'entrée d'expression ne correspond à aucun des modèles définis. Dans de nombreux cas, SWITCH() peut être utilisé à la place d'une formule IF() imbriquée.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Valeur A\\\", \\\"B\\\", \\\"Valeur B\\\", \\\"Valeur par défaut\\\") => \\\"Valeur B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Renvoie vrai si tous les arguments sont vrais, renvoie faux sinon.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Renvoie vrai si l'un des arguments est vrai.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Renvoie vrai si un nombre impair d'arguments est vrai.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Inverse la valeur logique de son argument.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Renvoie une valeur vide.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Renvoie la valeur d'erreur.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calcul\\\")) => \\\"#ERROR: Calcul\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Renvoie vrai si l'expression provoque une erreur.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Renvoie la date actuelle.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Renvoie la date et l'heure actuelles.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Renvoie l'année à quatre chiffres d'un datetime.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Renvoie le mois d'un datetime sous forme de nombre entre 1 (janvier) et 12 (décembre).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Renvoie le numéro de la semaine dans une année.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Renvoie le jour de la semaine sous forme d'entier entre 0 et 6, inclus. Vous pouvez éventuellement fournir un deuxième argument (soit \\\"Dimanche\\\" soit \\\"Lundi\\\") pour commencer les semaines ce jour-là. Si omis, les semaines commencent par défaut le dimanche. Exemple :\\nWEEKDAY(TODAY(), \\\"Lundi\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Renvoie le jour du mois d'un datetime sous forme de nombre entre 1-31.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Renvoie l'heure d'un datetime sous forme de nombre entre 0 (00:00) et 23 (23:00).\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Renvoie la minute d'un datetime sous forme d'entier entre 0 et 59.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Renvoie la seconde d'un datetime sous forme d'entier entre 0 et 59.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Calcule le nombre de jours entre la date actuelle et une autre date.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Calcule le nombre de jours entre la date actuelle et une autre date.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Renvoie la différence entre les datetimes dans les unités spécifiées. L'unité par défaut est \\\"day\\\".\\nUnités supportées : \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nLa différence entre les datetimes est déterminée en soustrayant [date2] de [date1]. Cela signifie que si [date2] est plus tard que [date1], la valeur résultante sera négative.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Renvoie le jour ouvrable à partir de la date de début, en excluant les jours fériés spécifiés.\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Renvoie le nombre de jours ouvrables entre date1 et date2. Les jours ouvrables excluent les week-ends et une liste optionnelle de jours fériés, formatée comme une chaîne de dates au format ISO séparées par des virgules.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Compare deux dates jusqu'à une unité et détermine si elles sont identiques. Renvoie vrai si oui, faux sinon.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"mois\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Détermine si date1 est plus tard que date2. Renvoie vrai si oui, faux sinon.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"mois\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Détermine si date1 est antérieure à date2. Renvoie vrai si oui, faux sinon.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"mois\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Ajoute des unités spécifiées \\\"count\\\" à un datetime.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Formate un datetime en une chaîne (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Formate un datetime en une chaîne de temps uniquement (HH:mm:ss).\",\n      \"example\": \"TIMESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Formate un datetime en une chaîne spécifiée. Pour une explication sur l'utilisation de cette fonction avec des champs de date, cliquez ici. Pour une liste des spécificateurs de format pris en charge, cliquez ici.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Interprète une chaîne de texte comme une date structurée, avec des paramètres de format et de langue optionnels. Le format de sortie sera toujours \\\"M/D/YYYY h:mm a\\\".\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Renvoie l'heure de création de l'enregistrement actuel.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Renvoie la date et l'heure de la dernière modification effectuée par un utilisateur dans un champ non calculé du tableau.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Renvoie le nombre total d'éléments, y compris les textes et les cases vides.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Renvoie le nombre de valeurs non vides. Cette fonction compte à la fois les valeurs numériques et textuelles.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Renvoie le nombre d'éléments numériques.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Joint le tableau des éléments rollup en une chaîne avec un séparateur.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Renvoie uniquement les éléments uniques du tableau.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Aplati le tableau en supprimant tout niveau d'imbrication. Tous les éléments deviennent des éléments d'un seul tableau.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Supprime les chaînes vides et les valeurs nulles du tableau. Conserve \\\"false\\\" et les chaînes contenant un ou plusieurs caractères blancs.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Renvoie toutes les valeurs textuelles.\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Renvoie l'ID de l'enregistrement actuel.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Renvoie les numéros uniques et incrémentés pour chaque enregistrement.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Remplissez les variables, les caractères opérationnels et les fonctions pour former des formules de calcul.\",\n      \"example\": \"Citation de la colonne : {Nom du champ}\\n\\nUtilisation de l'opérateur : 100 * 2 + 300\\n\\nUtilisation de la fonction : SUM({Champ Numérique 1}, 100)\\n\\nUtilisation de la déclaration IF : \\nIF(condition logique, \\\"valeur 1\\\", \\\"valeur 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Champs\",\n    \"numeric\": \"Numérique\",\n    \"text\": \"Texte\",\n    \"logical\": \"Logique\",\n    \"date\": \"Date\",\n    \"array\": \"Tableau\",\n    \"system\": \"Système\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Aucun\",\n    \"count\": \"Compter\",\n    \"empty\": \"Vide\",\n    \"filled\": \"Rempli\",\n    \"unique\": \"Unique\",\n    \"max\": \"Max\",\n    \"min\": \"Min\",\n    \"sum\": \"Somme\",\n    \"average\": \"Moyenne\",\n    \"checked\": \"Coché\",\n    \"unChecked\": \"Non coché\",\n    \"percentEmpty\": \"Pourcentage vide\",\n    \"percentFilled\": \"Pourcentage rempli\",\n    \"percentUnique\": \"Pourcentage unique\",\n    \"percentChecked\": \"Pourcentage coché\",\n    \"percentUnChecked\": \"Pourcentage non coché\",\n    \"earliestDate\": \"Date la plus ancienne\",\n    \"latestDate\": \"Date la plus récente\",\n    \"dateRangeOfDays\": \"Plage de dates (jours)\",\n    \"dateRangeOfMonths\": \"Plage de dates (mois)\",\n    \"totalAttachmentSize\": \"Taille totale des pièces jointes\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Ajouter\",\n    \"error\": {\n      \"invalidCol\": \"Colonne invalide, veuillez sélectionner à nouveau\",\n      \"invalidCols\": \"Colonnes invalides : {{colNames}}\",\n      \"invalidTable\": \"Table invalide, veuillez sélectionner à nouveau\",\n      \"requiredSelect\": \"Vous devez sélectionner un élément\"\n    },\n    \"from\": {\n      \"title\": \"De\",\n      \"fromTable\": \"Sélectionner la table\",\n      \"fromQuery\": \"De la requête\"\n    },\n    \"select\": {\n      \"title\": \"Sélectionner\"\n    },\n    \"where\": {\n      \"title\": \"Où\"\n    },\n    \"groupBy\": {\n      \"title\": \"Grouper par\"\n    },\n    \"orderBy\": {\n      \"title\": \"Trier par\",\n      \"asc\": \"Croissant\",\n      \"desc\": \"Décroissant\"\n    },\n    \"limit\": {\n      \"title\": \"Limite\"\n    },\n    \"offset\": {\n      \"title\": \"Décalage\"\n    },\n    \"join\": {\n      \"title\": \"Joindre\",\n      \"joinType\": \"Type de jointure\",\n      \"leftJoin\": \"Jointure à gauche\",\n      \"rightJoin\": \"Jointure à droite\",\n      \"innerJoin\": \"Jointure interne\",\n      \"fullJoin\": \"Jointure complète\",\n      \"data\": \"De\"\n    },\n    \"aggregation\": {\n      \"title\": \"Agrégation\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Commentaire\",\n    \"placeholder\": \"Laissez un commentaire...\",\n    \"emptyComment\": \"Démarrer une conversation\",\n    \"deletedComment\": \"Commentaire supprimé\",\n    \"imageSizeLimit\": \"La taille de l'image ne peut pas dépasser {{size}}\",\n    \"tip\": {\n      \"editing\": \"Modification en cours...\",\n      \"edited\": \"(Modifié)\",\n      \"notifyAll\": \"Notifier tous les commentaires\",\n      \"notifyRelatedToMe\": \"Notifier les commentaires relatifs à moi\",\n      \"all\": \"Tous\",\n      \"relatedToMe\": \"Relatif à moi\",\n      \"reactionUserSuffix\": \"a réagi avec l'emoji {{emoji}}\",\n      \"me\": \"Vous\",\n      \"connection\": \"et\"\n    },\n    \"toolbar\": {\n      \"link\": \"Lien\",\n      \"image\": \"Image\",\n      \"mention\": \"Mention\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Modifier le lien\",\n      \"caption\": \"Légende\",\n      \"delete\": \"Supprimer\",\n      \"linkText\": \"Texte du lien\",\n      \"enterUrl\": \"Entrez l'URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select Members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Erreur de validation\",\n    \"invalidCaptcha\": \"Captcha invalide\",\n    \"invalidCredentials\": \"Identifiants invalides\",\n    \"unauthorized\": \"Non autorisé\",\n    \"unauthorizedShare\": \"Partage non autorisé\",\n    \"paymentRequired\": \"Paiement requis\",\n    \"creditLimitExceeded\": \"Limite de crédits dépassée\",\n    \"restrictedResource\": \"Ressource restreinte\",\n    \"notFound\": \"Non trouvé\",\n    \"conflict\": \"Conflit\",\n    \"unprocessableEntity\": \"Entité non traitable\",\n    \"userLimitExceeded\": \"Limite de l'utilisateur dépassée\",\n    \"tooManyRequests\": \"Trop de requêtes\",\n    \"internalServerError\": \"Erreur interne du serveur\",\n    \"databaseConnectionUnavailable\": \"Connexion à la base de données non disponible\",\n    \"gatewayTimeout\": \"Temps d'attente de la passerelle\",\n    \"unknownErrorCode\": \"Code d'erreur inconnu\",\n    \"networkError\": \"Problème de connexion réseau\",\n    \"requestTimeout\": \"Temps d'attente de la requête dépassé\",\n    \"failedDependency\": \"Échec de dépendance\",\n    \"automationNodeParseError\": \"Erreur d'analyse du nœud d'automatisation\",\n    \"automationNodeNeedTest\": \"Le nœud d'automatisation nécessite un test\",\n    \"automationNodeTestOutdated\": \"Test du nœud d'automatisation obsolète\",\n    \"invalidToken\": \"Jeton non valide\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" champ \\\"{{fieldName}}\\\" ne permet pas les valeurs vides, veuillez les remplir complètement avant de soumettre.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" champ \\\"{{fieldName}}\\\" ne permet pas les valeurs dupliquées, veuillez remplir une valeur unique avant de soumettre.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" champ ne permet pas les associations dupliquées avec le même enregistrement\",\n      \"requestTimeout\": \"L'action actuelle est trop grande, veuillez réessayer avec un étendue plus petite.\",\n      \"searchTimeOut\": \"La recherche a expiré, veuillez réduire l'ambito de la recherche et réessayer.\",\n      \"dependencyNodeRequire\": \"Nœud de dépendance non testé, veuillez vérifier si tous les nœuds précédents ont été testés\",\n      \"invalidOperation\": \"Opération non valide détectée, veuillez vérifier les paramètres de l'opération\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"Le nombre de commentaires demandés dépasse la limite maximale de 1000\",\n      \"invalidContentType\": \"Type de contenu de commentaire non valide\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"L'expiration du jeton doit être inférieure à 7 jours\",\n      \"s3RegionRequired\": \"La région S3 est requise\",\n      \"s3EndpointRequired\": \"Le point de terminaison S3 est requis\",\n      \"s3AccessKeyRequired\": \"La clé d'accès S3 est requise\",\n      \"s3SecretKeyRequired\": \"La clé secrète S3 est requise\",\n      \"s3UploadMethodMustBePut\": \"La méthode de téléchargement S3 doit être PUT\",\n      \"presignedError\": \"Échec de la génération de l'URL pré-signée\",\n      \"invalidObjectMeta\": \"Métadonnées d'objet non valides\",\n      \"invalidImageStream\": \"Flux d'image non valide\",\n      \"calculateImageSizeFailed\": \"Échec du calcul de la taille de l'image\",\n      \"uploadFailed\": \"Échec du téléchargement\",\n      \"invalidImage\": \"Image non valide\",\n      \"cantGetImageStream\": \"Impossible d'obtenir le flux d'image\",\n      \"invalidProvider\": \"Fournisseur de stockage non valide\",\n      \"failedToDeleteDirectory\": \"Échec de la suppression du répertoire\",\n      \"invalidToken\": \"Jeton non valide\",\n      \"tokenExpired\": \"Le jeton a expiré\",\n      \"sizeMismatch\": \"La taille du fichier ne correspond pas\",\n      \"notAllowUploadFileType\": \"Type de fichier {{mimetype}} non autorisé pour le téléchargement\",\n      \"notFound\": \"Pièce jointe introuvable\",\n      \"invalidPath\": \"Chemin de pièce jointe non valide\",\n      \"fileSizeExceedsMaximumLimit\": \"La taille du fichier dépasse la limite maximale de {{maxSize}}\",\n      \"invalidUploadType\": \"Type de téléchargement non valide\",\n      \"urlReject\": \"URL rejetée ou inaccessible\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Erreur de configuration du courrier {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Ce champ de bouton est déjà lié à {{name}}[{{id}}] ce processus d'automatisation, veuillez sélectionner un autre champ de bouton\",\n      \"triggerNotFound\": \"L'automatisation doit avoir un nœud déclencheur\",\n      \"nodeNotFound\": \"{{nodeId}} nœud non trouvé\",\n      \"triggerTestFailed\": \"Test du déclencheur échoué, veuillez vérifier la configuration du déclencheur\",\n      \"testFailed\": \"Test d'automatisation échoué, veuillez vérifier la configuration d'automatisation\",\n      \"runFailed\": \"L'exécution de l'automatisation a échoué\",\n      \"nodeParseError\": \"{{name}} la configuration du nœud est incomplète ou contient des erreurs, veuillez vérifier la configuration du nœud\",\n      \"nodeNeedTest\": \"{{name}} le nœud doit être testé\",\n      \"nodeTestOutdated\": \"{{name}} le test du nœud est obsolète\",\n      \"notFound\": \"Automatisation non trouvée\",\n      \"currentSnapshotEmpty\": \"L'instantané actuel d'automatisation est vide\",\n      \"runNotFound\": \"Exécution d'automatisation non trouvée\",\n      \"anchorNotFound\": \"Automatisation d'ancrage non trouvée\",\n      \"validationError\": \"Erreur de validation de la configuration d'automatisation\",\n      \"tableNotInBase\": \"Vous ne pouvez vous abonner qu'à une table de votre base de données\",\n      \"alreadyActiveAndNotDraft\": \"L'automatisation est déjà active et n'est pas un brouillon\",\n      \"noActiveSnapshot\": \"L'automatisation n'a pas d'instantané actif\",\n      \"triggerNodeAlreadyExists\": \"Cette automatisation a déjà un nœud déclencheur\",\n      \"generateLogicError\": \"Erreur lors de la génération du nœud de logique\",\n      \"logicNotFound\": \"Nœud de logique d'automatisation non trouvé\",\n      \"actionNotFound\": \"Nœud d'action d'automatisation non trouvé\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Duplication de ce type de nœud d'automatisation non supportée\",\n      \"unSupportLogicType\": \"Type de logique non supporté\",\n      \"groupEndNotFound\": \"GroupEnd non trouvé pour la logique\",\n      \"insertNodeError\": \"Erreur d'insertion de nœud\",\n      \"controlNodeNotBeTested\": \"Le nœud de contrôle ne doit pas être testé\",\n      \"invalidNodeType\": \"Type de nœud invalide\",\n      \"unsupportedCategory\": \"Catégorie non supportée\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Échec de la récupération des boîtes mail : {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Jeu de données inconnu : {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Le service de scraping n'est pas configuré\",\n      \"triggerFailed\": \"Échec du déclenchement du scraping : {{detail}}\",\n      \"snapshotError\": \"Erreur de snapshot de scraping : {{detail}}\",\n      \"timeout\": \"Délai de scraping dépassé après {{seconds}}s\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Espace non trouvé\",\n      \"noPermission\": \"Vous n'avez pas la permission d'accéder à cet espace\",\n      \"disallowSpaceCreation\": \"La création d'espaces a été désactivée par l'administrateur\",\n      \"cannotChangeOnlyOwnerRole\": \"Impossible de modifier le rôle du seul propriétaire de l'espace\",\n      \"cannotDeleteOnlyOwner\": \"Impossible de supprimer le seul propriétaire de l'espace\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Impossible d'opérer sur un espace, veuillez vérifier qu'il y a des membres de l'organisation dans l'espace et qu'ils sont propriétaires\",\n      \"notBelongToOrg\": \"Cet espace n'appartient pas à l'organisation\",\n      \"invalidSpaceIds\": \"Les IDs d'espace sont invalides: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base non trouvée\",\n      \"cannotAccess\": \"Vous n'avez pas la permission d'accéder à la base {{baseId}}\",\n      \"anchorNotFound\": \"Base d'ancrage {{anchorId}} non trouvée\",\n      \"baseAndSpaceMismatch\": \"La base {{baseId}} et l'espace {{spaceId}} ne correspondent pas\",\n      \"templateNotFound\": \"Modèle {{templateId}} non trouvé\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"ID de base requis\",\n      \"nodeIdIsRequired\": \"ID de nœud requis\",\n      \"invalidResourceType\": \"Type de ressource invalide\",\n      \"notFound\": \"Nœud de base non trouvé\",\n      \"parentMustBeFolder\": \"Le parent doit être un dossier\",\n      \"cannotDuplicateFolder\": \"Impossible de dupliquer le dossier\",\n      \"cannotDeleteEmptyFolder\": \"Impossible de supprimer le dossier car il n'est pas vide\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Seul parentId ou anchorId doit être fourni\",\n      \"cannotMoveToItself\": \"Impossible de déplacer le nœud vers lui-même\",\n      \"cannotMoveToCircularReference\": \"Impossible de déplacer le nœud vers son propre enfant (référence circulaire)\",\n      \"anchorIdOrParentIdRequired\": \"Au moins parentId ou anchorId doit être fourni\",\n      \"parentNotFound\": \"Nœud parent non trouvé\",\n      \"parentIsNotFolder\": \"Le parent n'est pas un dossier\",\n      \"circularReference\": \"Référence circulaire détectée\",\n      \"folderDepthLimitExceeded\": \"Limite de profondeur de dossier dépassée\",\n      \"folderNotFound\": \"Dossier non trouvé\",\n      \"anchorNotFound\": \"Nœud d'ancrage non trouvé\",\n      \"nameAlreadyExists\": \"Le nom existe déjà\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Tableau de bord non trouvé\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Plugin non trouvé\",\n      \"notSupportInstallInView\": \"Le plugin ne prend pas en charge l'installation dans la vue\",\n      \"userNotFound\": \"Utilisateur du plugin introuvable\",\n      \"invalidSecret\": \"Secret invalide\",\n      \"invalidRefreshToken\": \"Jeton de rafraîchissement invalide\",\n      \"anomalousToken\": \"Jeton anormal\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Panneau de plugin non trouvé\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Plugin non installé\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Mot de passe incorrect\",\n      \"notAllowedToSubmit\": \"La soumission du formulaire n'est pas autorisée\",\n      \"viewRequired\": \"La vue est requise pour cette opération\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"La soumission du formulaire n'est pas autorisée lorsque des champs masqués sont inclus\",\n      \"submitRecordsError\": \"Échec de la soumission de l'enregistrement\",\n      \"notAllowedToCopy\": \"L'opération de copie n'est pas autorisée\",\n      \"fieldHiddenNotAllowed\": \"Le champ est masqué et ne peut pas être accédé\",\n      \"fieldTypeNotLinkField\": \"Le champ n'est pas un champ de liaison\",\n      \"fieldIdRequired\": \"L'ID du champ est requis\",\n      \"fieldNotUserRelatedField\": \"Le champ n'est pas un champ lié à l'utilisateur\",\n      \"viewTypeNotAllowed\": \"Ce type de vue n'est pas autorisé pour cette opération\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"La restriction par mot de passe n'est pas activée pour cette vue partagée\",\n      \"shareViewNotFound\": \"Vue partagée introuvable ou le partage est désactivé\",\n      \"linkFieldNotFound\": \"Champ de liaison introuvable\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Partage de base introuvable ou le partage est désactivé\",\n      \"alreadyExists\": \"Un partage existe déjà pour ce nœud\",\n      \"copyNotAllowed\": \"Ce partage ne permet pas la copie\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"Vous n'avez pas la permission d'accéder à cette vue\",\n      \"fieldPermissionNotAllowed\": \"Vous n'avez pas la permission d'accéder à ces champs\",\n      \"recordPermissionNotAllowed\": \"Vous n'avez pas la permission d'accéder à ces enregistrements\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Menu contextuel du plugin non trouvé\",\n      \"anchorNotFound\": \"Ancre du menu contextuel du plugin non trouvée\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Requête du graphique du plugin non trouvée\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Pilote de base de données non pris en charge: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Seul le propriétaire de la base peut supprimer la connexion à la base de données pour la base {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Seul le propriétaire de la base peut créer une connexion à la base de données pour la base {{baseId}}\",\n      \"roleNotExist\": \"Le rôle de base de données {{role}} n'existe pas\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Échec de la requête: {{message}}\",\n      \"invalidJoinType\": \"Type de jointure non valide: {{joinType}}\",\n      \"tableNotFound\": \"Table {{tableId}} non trouvée dans la base {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Non autorisé à exécuter SQL avec le mot-clé {{keyword}}\",\n      \"whiteListCheckError\": \"Une erreur s'est produite lors de la vérification de l'accès à la table : {{message}}\",\n      \"databaseConnectionFailed\": \"La connexion à la base de données a échoué : {{message}}\",\n      \"executeQuerySqlFailed\": \"L'exécution de la requête SQL a échoué : {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"Vous n'avez pas la permission de créer des enregistrements avec les champs({{fields}})\",\n      \"deleteRecords\": \"Vous n'avez pas la permission de supprimer les enregistrements({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"Vous n'avez pas la permission de lire les champs({{fields}}) dans l'enregistrement({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"Vous n'avez pas la permission de mettre à jour les champs({{fields}}) dans l'enregistrement({{recordId}})\",\n      \"checkIdNotExist\": \"L'ID de vérification des permissions n'existe pas\",\n      \"userNotAdmin\": \"L'utilisateur n'est pas administrateur\",\n      \"accessTokenNoPermission\": \"Le jeton d'accès n'a pas la permission requise\",\n      \"invalidResource\": \"La ressource n'est pas valide\",\n      \"notAllowedSpace\": \"Vous n'avez pas la permission d'accéder à cet espace\",\n      \"notAllowedBase\": \"Vous n'avez pas la permission d'accéder à cette base\",\n      \"notAllowedTables\": \"Vous n'avez pas la permission d'accéder à tables({{tableIds}})\",\n      \"notAllowedOperationTable\": \"Vous n'avez pas la permission d'opérer sur cette table\",\n      \"notAllowedOperationRecord\": \"Vous n'avez pas la permission d'opérer sur cet enregistrement\",\n      \"notAllowedRecordUpdate\": \"Vous n'avez pas la permission de mettre à jour cet enregistrement\",\n      \"notAllowedOperationView\": \"Vous n'avez pas la permission d'opérer sur cette vue\",\n      \"deniedByEnabledAuthorityMatrix\": \"Permission refusée par la matrice d'autorité activée\",\n      \"invalidRequestPath\": \"Le chemin de la requête n'est pas valide\",\n      \"notAllowedOperation\": \"Vous n'avez pas la permission d'effectuer cette opération\",\n      \"notAllowedDepartment\": \"Vous n'êtes pas autorisé à accéder à ce département\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Rôle par défaut introuvable\",\n      \"alreadyDisabled\": \"La matrice d'autorité est déjà désactivée\",\n      \"alreadyEnabled\": \"La matrice d'autorité est déjà activée\",\n      \"notFound\": \"Matrice d'autorité introuvable\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Le champ principal ne peut pas être désactivé pour l'accès en lecture\",\n      \"fieldDuplicated\": \"Le champ est dupliqué dans la configuration des permissions\",\n      \"cannotSetRecordPermissionGroup\": \"Impossible de définir cette combinaison de permissions d'enregistrement({{actions}})\",\n      \"notFoundBaseAndTable\": \"ID de base et ID de table introuvables\",\n      \"roleTablesShouldNotBeEmpty\": \"Les tables de rôles de la matrice d'autorité ne doivent pas être vides\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Type de retour invalide\",\n      \"exceedMaxReadRows\": \"Limite maximale de lignes à lire dépassée\",\n      \"invalidCellValueType\": \"Type de valeur de cellule invalide\",\n      \"exceedMaxCopyCells\": \"Limite maximale de cellules à copier dépassée\",\n      \"exceedMaxPasteCells\": \"Limite maximale de cellules à coller dépassée\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Type de champ non pris en charge {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Type de champ non pris en charge {{type}} comme champ primaire\",\n      \"primaryFieldNotSupported\": \"Le type de champ n'est pas pris en charge comme champ primaire\",\n      \"calculateRecordNotFound\": \"Enregistrement introuvable pour: {{value}}, fieldId: {{fieldId}}, lors du calcul {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds ou fromRecordIds est requis pour le champ calculé normal\",\n      \"recordFieldsRequired\": \"Les champs d'enregistrement ne sont pas définis\",\n      \"uniqueUnsupportedType\": \"Le champ {{name}}[{{fieldId}}] ne prend pas en charge la validation de valeur de champ unique\",\n      \"notNullValidationWhenCreateField\": \"Le champ {{name}}[{{fieldId}}] ne prend pas en charge la validation non nulle lors de la création d'un nouveau champ\",\n      \"dbFieldNameAlreadyExists\": \"Le nom du champ de base de données {{dbFieldName}} existe déjà\",\n      \"fieldValidationError\": \"Champ {{name}}[{{fieldId}}] erreur de validation de champ\",\n      \"fieldNameAlreadyExists\": \"Le nom du champ {{name}} existe déjà\",\n      \"notFound\": \"Champ introuvable\",\n      \"fieldKeyTypeNotFound\": \"Champ \\\"{{fieldKeyType}}: {{missedFields}}\\\" introuvable\",\n      \"notFoundInTable\": \"Champ {{fieldId}} introuvable dans la table {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Champs à supprimer {{fieldIds}} introuvables dans la table {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues doit être un tableau lorsque le champ de lien a plusieurs valeurs de cellule\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues doit être un tableau lorsque le champ de lien a plusieurs valeurs de cellule\",\n      \"lookupAndLinkLengthMatch\": \"La longueur de lookupValues doit être identique à la longueur de linkCellValues\",\n      \"cycleDetected\": \"Cycle détecté\",\n      \"cycleDetectedCreateField\": \"Cycle détecté, impossible de créer le champ {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Enregistrement introuvable dans la table {{tableName}} pour le champ {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Interdiction de supprimer le champ primaire\",\n      \"foreignTableIdInvalid\": \"Table étrangère {{foreignTableId}} invalide\",\n      \"relationshipInvalid\": \"Relation {{relationship}} invalide\",\n      \"linkFieldIdInvalid\": \"Champ de lien {{linkFieldId}} invalide\",\n      \"lookupFieldIdInvalid\": \"Champ de recherche {{lookupFieldId}} invalide\",\n      \"formulaExpressionParseError\": \"Erreur d'analyse de l'expression de formule\",\n      \"formulaReferenceNotFound\": \"Champ de référence de formule {{fieldIds}} introuvable\",\n      \"rollupExpressionParseError\": \"Erreur d'analyse de l'expression de cumul\",\n      \"choiceNameAlreadyExists\": \"Le nom de choix {{name}} existe déjà\",\n      \"symmetricFieldIdRequired\": \"L'ID de champ symétrique est requis\",\n      \"foreignKeyNameCannotUseId\": \"Le nom de clé étrangère ne peut pas utiliser __id\",\n      \"createForeignKeyError\": \"Erreur de création de clé étrangère\",\n      \"lookupFieldTypeNotEqual\": \"Le type de champ actuel {{fieldType}} n'est pas égal au type de champ de recherche {{lookupFieldType}}\",\n      \"recordNotFound\": \"Enregistrement {{recordId}} introuvable dans {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Impossible de définir un recordId en double: {{recordId}} dans la même cellule\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Les valeurs de champ de lien un-à-un ne peuvent pas être un tableau\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Les valeurs de champ de lien plusieurs-à-un ne peuvent pas être un tableau\",\n      \"foreignKeyDuplicate\": \"Clé étrangère en double\",\n      \"linkConsistencyError\": \"Erreur de cohérence, recordId {{recordId}} n'existe pas\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Les valeurs de champ de lien un-à-plusieurs doivent être un tableau\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Les valeurs de champ de lien plusieurs-à-plusieurs doivent être un tableau\",\n      \"onlyLinkFieldCanBeFiltered\": \"Seuls les champs de lien peuvent être utilisés pour le filtrage\",\n      \"notLinkedToCurrentTable\": \"Le champ n'est pas lié à la table actuelle\",\n      \"notAttachment\": \"Le champ n'est pas un champ de pièce jointe\",\n      \"isComputed\": \"Le champ est calculé et ne peut pas être modifié\",\n      \"notFoundAICofig\": \"Le champ n'a pas de configuration IA\",\n      \"foreignTableIdRequired\": \"La table externe est requise\",\n      \"lookupFieldIdRequired\": \"Le champ de recherche est requis\",\n      \"lookupFieldNotExist\": \"Le champ de recherche {{lookupFieldId}} n'existe pas\",\n      \"lookupFieldNotBelongToTable\": \"Le champ de recherche {{lookupFieldId}} n'appartient pas à la table {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Le type de champ actuel {{fieldType}} ne correspond pas au type de champ de recherche {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Les options du champ de résumé conditionnel sont requises\",\n      \"conditionalRollupParseError\": \"Erreur d'analyse du résumé conditionnel : {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Les options du champ de recherche conditionnelle sont requises\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Le nombre de clics sur le bouton a atteint la limite maximale\",\n        \"notSupportReset\": \"Le champ de bouton ne prend pas en charge la réinitialisation\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Vue introuvable\",\n      \"defaultViewNotFound\": \"Vue par défaut introuvable\",\n      \"propertyParseError\": \"Échec de l'analyse de la propriété de la vue\",\n      \"primaryFieldCannotBeHidden\": \"Le champ primaire ne peut pas être masqué\",\n      \"filterUnsupportedFieldType\": \"Type de champ non pris en charge par le filtre\",\n      \"sortUnsupportedFieldType\": \"Type de champ non pris en charge par le tri\",\n      \"groupUnsupportedFieldType\": \"Type de champ non pris en charge par le regroupement\",\n      \"anchorNotFound\": \"Vue d'ancrage introuvable\",\n      \"notEnoughGapToShuffleRow\": \"Pas assez d'espace pour mélanger la ligne\",\n      \"shareNotEnabled\": \"Le partage de vue n'est pas activé\",\n      \"shareAlreadyEnabled\": \"Le partage de vue est déjà activé\",\n      \"shareAlreadyDisabled\": \"Le partage de vue est déjà désactivé\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Crédit insuffisant\",\n      \"exceedMaxRowLimit\": \"Limite maximale de lignes {{maxRowCount}} dépassée\",\n      \"exceedMaxAutomationRunLimit\": \"Le nombre maximal mensuel d'exécutions d'automatisation est atteint\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"La requête de recherche est requise\",\n      \"maxSearchIndexResult\": \"Le résultat de l'index de recherche maximal est de 1000\",\n      \"queryCollectionMustBeTableId\": \"La collection de requête doit être un identifiant de table\",\n      \"searchTimeOut\": \"La recherche a expiré, veuillez réduire l'ambito de la recherche et réessayer.\",\n      \"indexNotFound\": \"Index non trouvé\",\n      \"invalidStartDateFieldId\": \"Identifiant de champ de date de début invalide\",\n      \"invalidEndDateFieldId\": \"Identifiant de champ de date de fin invalide\",\n      \"fieldMapRequired\": \"La carte des champs est requise lorsque la recherche est définie\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected et filterLinkCellCandidate ne peuvent pas être définis en même temps\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"Le modèle de chat IA lg n'est pas défini\",\n      \"chatModelLgProviderNotSet\": \"Le fournisseur du modèle de chat IA lg n'est pas défini\",\n      \"chatModelSmNotSet\": \"Le modèle de chat IA sm n'est pas défini\",\n      \"chatModelMdNotSet\": \"Le modèle de chat IA md n'est pas défini\",\n      \"configurationNotSet\": \"La configuration IA n'est pas définie\",\n      \"unsupportedProvider\": \"Fournisseur IA non pris en charge {{type}}\",\n      \"providerConfigurationNotSet\": \"La configuration du fournisseur IA n'est pas définie\",\n      \"testLLMFailed\": \"Le test de connexion LLM a échoué\",\n      \"audioNotSupported\": \"L'entrée audio n'est pas prise en charge par ce modèle {{model}}\",\n      \"imageNotSupported\": \"L'entrée d'image n'est pas prise en charge par ce modèle {{model}}\",\n      \"modelNotSet\": \"Le modèle IA n'est pas défini\",\n      \"unsupportedFileType\": \"Type de fichier non pris en charge {{mimetype}}\",\n      \"unsupportedModelType\": \"Type de modèle non pris en charge\",\n      \"embeddingModelNotSet\": \"Le modèle d'incorporation n'est pas défini\",\n      \"validateActionFailed\": \"Échec de la validation de l'action IA du champ\",\n      \"generateFailed\": \"Échec de la génération IA\",\n      \"unsupportedActionType\": \"Type d'action IA non pris en charge\"\n    },\n    \"role\": {\n      \"notFound\": \"Rôle introuvable\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Le collaborateur existe déjà\",\n      \"notFound\": \"Collaborateur introuvable\",\n      \"userNotFoundInCollaborator\": \"Utilisateur non trouvé dans les collaborateurs\",\n      \"noPermissionToDelete\": \"Vous n'avez pas la permission de supprimer ce collaborateur\",\n      \"noPermissionToUpdate\": \"Vous n'avez pas la permission de mettre à jour ce collaborateur\",\n      \"noPermissionToOperateRole\": \"Vous n'avez pas la permission d'opérer ce rôle\",\n      \"alreadyExistedInBase\": \"Le collaborateur existe déjà dans la base\",\n      \"userNotFound\": \"Utilisateur non trouvé : {{userIds}}\",\n      \"baseNotFound\": \"Base non trouvée\",\n      \"noPermissionToAddRole\": \"Vous n'avez pas la permission d'ajouter un collaborateur avec ce rôle\",\n      \"departmentNotFound\": \"Département non trouvé\"\n    },\n    \"table\": {\n      \"notFound\": \"Table non trouvée\",\n      \"dbTableNameAlreadyExists\": \"Le nom de la table de base de données existe déjà\",\n      \"anchorNotFound\": \"Table d'ancrage non trouvée\",\n      \"notInTrash\": \"La table n'est pas dans la corbeille\",\n      \"notSupportTableIndex\": \"Le type d'index de table n'est pas pris en charge\",\n      \"createTableIndexError\": \"Échec de la création de l'index de table\",\n      \"dropTableIndexError\": \"Échec de la suppression de l'index de table\",\n      \"notFoundPrimaryField\": \"Champ primaire non trouvé dans la table\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"Le type de vue {{viewType}} n'est pas pris en charge pour l'exportation\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Format de fichier non pris en charge, seuls {{supportType}} sont pris en charge, le type de contenu de votre fichier est {{fileFormat}}\",\n      \"notSupportedFileType\": \"Type de fichier d'importation non pris en charge\",\n      \"exceedMaxFieldsLength\": \"Le nombre de champs dans la table ne peut pas dépasser {{maxFieldsLength}}, actuellement {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"L'instance actuelle interdit les invitations d'espace par l'administrateur\",\n      \"invalidCode\": \"Code d'invitation invalide\",\n      \"linkNotFound\": \"Lien d'invitation non trouvé\",\n      \"linkExpired\": \"Le lien d'invitation a expiré\",\n      \"limitExceeded\": \"Vous avez atteint le nombre maximum d'invitations par heure\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Le favori existe déjà\",\n      \"notFound\": \"Favori non trouvé\",\n      \"anchorNotFound\": \"Ancre de favori non trouvée\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Type de ressource non valide\",\n      \"notFound\": \"Élément de la corbeille introuvable\",\n      \"parentSpaceTrashed\": \"Impossible de restaurer cette base car son espace parent est également dans la corbeille\",\n      \"parentBaseOrSpaceTrashed\": \"Impossible de restaurer cette table car sa base ou son espace parent est également dans la corbeille\",\n      \"parentBaseTrashed\": \"Impossible de restaurer cet élément car sa base parente est également dans la corbeille\",\n      \"parentNotFound\": \"Ressource parente non trouvée\",\n      \"tableNotFound\": \"Élément de corbeille de table introuvable\"\n    },\n    \"license\": {\n      \"invalid\": \"La licence n'est pas valide\",\n      \"instanceIdMismatch\": \"L'ID d'instance entrant ne correspond pas à l'ID d'instance de l'instance actuelle\",\n      \"expired\": \"La licence a expiré\",\n      \"userLimitExceeded\": \"Le nombre d'utilisateurs dans l'instance actuelle dépasse la limite de sièges de la licence. Veuillez désactiver certains utilisateurs ou mettre à niveau la licence\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organisation non trouvée\",\n      \"emailNotSpaceUser\": \"L'email n'est pas un utilisateur de l'espace\",\n      \"authenticationNotFound\": \"Authentification non trouvée\",\n      \"spaceShouldExist\": \"L'espace de l'organisation devrait exister\",\n      \"emailsNotInOrgDomain\": \"Ces e-mails {{emails}} ne sont pas dans le domaine de l'organisation\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"L'instance actuelle n'autorise pas l'inscription par l'administrateur\",\n      \"waitlistInviteCodeRequired\": \"La liste d'attente est activée, un code d'invitation est requis\",\n      \"waitlistInviteCodeInvalid\": \"La liste d'attente est activée, le code d'invitation n'est pas valide\",\n      \"systemUser\": \"L'utilisateur est un utilisateur système\",\n      \"collaboratorsInSpaces\": \"L'utilisateur a des collaborateurs dans les espaces (ou des espaces supprimés dans la corbeille)\",\n      \"notFound\": \"Utilisateur non trouvé\",\n      \"cannotDeleteAdmin\": \"Impossible de supprimer l'utilisateur administrateur\",\n      \"cannotDeactivateAdmin\": \"Impossible de désactiver l'utilisateur administrateur\",\n      \"cannotRemoveLastAdmin\": \"Impossible de retirer les privilèges d'administrateur du dernier utilisateur administrateur actif\",\n      \"permanentDeleted\": \"L'utilisateur a été définitivement supprimé\",\n      \"cannotDeleteSelf\": \"Vous ne pouvez pas vous supprimer\",\n      \"alreadyInDepartment\": \"L'utilisateur {{userId}} est déjà dans le département cible\",\n      \"emailsNotFound\": \"E-mails {{emails}} non trouvés\",\n      \"deleted\": \"L'utilisateur {{userId}} a été supprimé\",\n      \"alreadyInOrg\": \"L'utilisateur {{userId}} est déjà dans l'organisation\",\n      \"notInOrg\": \"L'utilisateur {{userId}} n'est pas dans l'organisation\"\n    },\n    \"record\": {\n      \"notFound\": \"Enregistrement non trouvé\",\n      \"deletedIdsNotFound\": \"Certains enregistrements à supprimer sont introuvables\",\n      \"updateFailed\": \"Échec de la mise à jour de l'enregistrement\",\n      \"noFileOrUrlProvided\": \"Aucun fichier ou URL fourni\",\n      \"createRecordsEmpty\": \"La création d'enregistrements ne peut pas être vide\",\n      \"duplicateFailed\": \"Échec de la duplication de l'enregistrement\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Échec de la validation de la valeur de cellule\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Le flux de travail n'est pas actif\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Type de ressource non valide\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Catégorie de modèle non trouvée\",\n      \"snapshotRequired\": \"Ce modèle n'a pas pu être publié en raison d'un instantané manquant\",\n      \"sourceTemplateNotFound\": \"Modèle source introuvable\",\n      \"noMinOrderFound\": \"Aucun ordre minimum trouvé\",\n      \"takeCountTooLarge\": \"Le nombre de modèles demandés dépasse la limite maximale\",\n      \"categoryLimitReached\": \"Limite de catégories de modèles atteinte (maximum {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Aucun code de vérification de domaine trouvé\",\n      \"invalidCode\": \"Code de vérification invalide\",\n      \"resendCooldown\": \"Veuillez attendre 1 minute avant de demander un nouveau code\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Échec de l'envoi de l'e-mail\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Département parent non trouvé\",\n      \"notFound\": \"Département non trouvé\",\n      \"cannotMoveToItself\": \"Impossible de déplacer le département vers lui-même\",\n      \"cannotMoveToSub\": \"Impossible de déplacer le département vers son sous-département\"\n    },\n    \"app\": {\n      \"notFound\": \"Application non trouvée\",\n      \"noFilesToUpdate\": \"Aucun fichier à mettre à jour\",\n      \"noChatIdFound\": \"Aucun ID de chat trouvé pour cette application\",\n      \"noChatFound\": \"Aucun chat trouvé pour cette application\",\n      \"versionNotFound\": \"Version non trouvée\",\n      \"cannotRollbackToLatestVersion\": \"Impossible de revenir à la dernière version\",\n      \"noChatOrProjectTokenFound\": \"Aucun chat ou jeton de projet trouvé\",\n      \"apiKeyNotSet\": \"La clé API du constructeur d'applications n'est pas définie\",\n      \"cannotDeployAppBeforeInitialization\": \"Impossible de déployer l'application avant l'initialisation\",\n      \"noProjectOrVersionFound\": \"Aucun projet ou version trouvé\",\n      \"noDeploymentUrlAvailable\": \"Aucune URL de déploiement disponible\"\n    },\n    \"reward\": {\n      \"notFound\": \"Récompense non trouvée\",\n      \"unsupportedSourceType\": \"Type de source de récompense non pris en charge\",\n      \"maxClaimsReached\": \"Vous avez atteint le maximum de réclamations de récompenses (2) pour cette semaine\",\n      \"verificationFailed\": \"Vérification échouée : {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Vous avez déjà réclamé une récompense pour ce compte cette semaine\",\n      \"invalidPostUrl\": \"Format d'URL de publication invalide\",\n      \"postAlreadyUsed\": \"Cette publication a déjà été utilisée pour réclamer une récompense\",\n      \"unsupportedPlatformUrl\": \"URL de plateforme sociale non prise en charge\",\n      \"unsupportedPlatform\": \"Plateforme non prise en charge : {{platform}}\",\n      \"minCharCount\": \"La publication doit contenir au moins {{count}} caractères\",\n      \"minFollowerCount\": \"Le compte doit avoir au moins {{count}} abonnés\",\n      \"mustMention\": \"La publication doit mentionner {{mention}}\",\n      \"fetchTweetFailed\": \"Échec de la récupération du tweet X : {{error}}\",\n      \"tweetNotFound\": \"Tweet X non trouvé : {{postId}}\",\n      \"fetchUserFailed\": \"Échec de la récupération de l'utilisateur X : {{error}}\",\n      \"xUserNotFound\": \"Utilisateur X non trouvé : {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Échec de la récupération de la publication LinkedIn : {{error}}\",\n      \"linkedInPostNotFound\": \"Publication LinkedIn non trouvée : {{postId}}\",\n      \"linkedInAuthorNotFound\": \"Auteur LinkedIn non trouvé : {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Échec de la récupération de l'utilisateur LinkedIn : {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Jetons d'accès personnel\",\n  \"oauthApps\": \"Applications OAuth\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Entrez votre mot de passe pour voir cette page\",\n    \"submit\": \"Soumettre\",\n    \"password\": \"Mot de passe\",\n    \"passwordTooShort\": \"Le mot de passe doit contenir au moins 3 caractères\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Sélectionner...\"\n  },\n  \"openOnNewPage\": \"Ouvrir sur une nouvelle page\",\n  \"errorTips\": \"La source de partage a activé une matrice d'autorité, la visualisation n'est pas autorisée\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/space.json",
    "content": "{\n  \"initialSpaceName\": \"Espace de {{name}}\",\n  \"action\": {\n    \"createBase\": \"Créer une base\",\n    \"createSpace\": \"Créer un espace\",\n    \"invite\": \"Inviter\"\n  },\n  \"allSpaces\": \"Tous les espaces\",\n  \"emptySpaceTitle\": \"Aucune base dans cet espace\",\n  \"spaceIsEmpty\": \"Créez votre première base pour commencer\",\n  \"baseModal\": {\n    \"copy\": \"Copier\",\n    \"duplicate\": \"Dupliquer \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Créer une base à partir d'un modèle\",\n    \"duplicateRecords\": \"Dupliquer les enregistrements\",\n    \"duplicateRecordsTip\": \"Votre historique des révisions et vos collaborateurs ne seront pas dupliqués.\",\n    \"toSpace\": \"Vers l'espace\",\n    \"copyToSpace\": \"Copier vers l'espace\",\n    \"duplicateBase\": \"Dupliquer la base\",\n    \"missTargetTip\": \"Veuillez sélectionner un espace pour dupliquer la base.\",\n    \"copying\": \"Duplication de la base, cela peut prendre un certain temps...\",\n    \"copyingTemplate\": \"Création de la base à partir d'un modèle, cela peut prendre un certain temps...\",\n    \"howToCreate\": \"Comment souhaitez-vous commencer ?\",\n    \"fromScratch\": \"À partir de zéro\",\n    \"fromTemplate\": \"À partir d'un modèle\",\n    \"moveBaseToAnotherSpace\": \"Déplacer {{baseName}} vers un autre espace\",\n    \"chooseSpace\": \"Choisir un espace\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Paramètres de l'espace\",\n    \"general\": \"Général\",\n    \"collaborators\": \"Collaborateurs\",\n    \"generalDescription\": \"Modifiez ici les paramètres de votre espace actuel\",\n    \"collaboratorDescription\": \"Gérez les collaborateurs de votre espace et définissez leurs permissions d'accès\",\n    \"spaceName\": \"Nom de l'espace\",\n    \"spaceId\": \"ID de l'espace\"\n  },\n  \"pin\": {\n    \"add\": \"Ajouter aux favoris\",\n    \"remove\": \"Retirer des favoris\",\n    \"pin\": \"Épingler\",\n    \"empty\": \"Vos bases et espaces favoris apparaîtront ici\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"Vous n'avez pas la permission de créer une base\"\n  },\n  \"tip\": {\n    \"delete\": \"Êtes-vous sûr de vouloir supprimer <0/> ?\",\n    \"title\": \"Conseils\",\n    \"exportTips1\": \"Exporter la base actuelle en fichier .tea, ce qui peut prendre un certain temps. Vous pouvez vérifier les résultats d'exportation dans le centre de notifications.\",\n    \"exportTips2\": \"Vous pouvez importer le fichier .tea dans espace -> plus -> importer\",\n    \"exportTips3\": \"Les champs de relation inter-base seront convertis en texte sur une seule ligne.\",\n    \"exportIncludeDataLabel\": \"Inclure les enregistrements\",\n    \"exportIncludeDataDescription\": \"Désactivez pour exporter uniquement la structure et la configuration.\",\n    \"moveBaseSuccessTitle\": \"Déplacement réussi\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} a été déplacé avec succès vers {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Supprimer l'espace\",\n    \"blockedTitle\": \"Impossible de supprimer cet espace\",\n    \"blockedDesc\": \"Cet espace a un abonnement actif. Veuillez annuler l'abonnement avant de supprimer l'espace.\",\n    \"permanentDeleteWarning\": \"Cette action supprimera définitivement toutes les ressources et données de l'espace actuel. Veuillez procéder avec prudence !\",\n    \"confirmInputLabel\": \"Veuillez taper DELETE pour confirmer la suppression\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Partagé avec moi\",\n    \"description\": \"Toutes les bases auxquelles j'ai été invité à rejoindre\",\n    \"empty\": \"Aucune base partagée avec moi pour le moment\"\n  },\n  \"integration\": {\n    \"title\": \"Intégrations\",\n    \"description\": \"Gérer les intégrations de votre espace\",\n    \"addIntegration\": \"Ajouter une intégration\",\n    \"ai\": \"IA\"\n  },\n  \"aiSetting\": {\n    \"title\": \"Paramètres IA\",\n    \"description\": \"Gérer les paramètres IA de votre espace\",\n    \"enableTips\": \"Activez l'IA pour utiliser les fonctionnalités IA dans votre espace au lieu d'utiliser l'IA système\",\n    \"enable\": \"Initialiser les paramètres IA\",\n    \"enableSwitchTips\": \"Veuillez configurer le modèle de code avant d'activer\"\n  },\n  \"import\": {\n    \"importing\": \"Importation en cours\",\n    \"importWayTip\": \"Cliquez ou glissez-déposez le fichier dans cette zone pour télécharger\",\n    \"baseImportTips\": \"Cliquez ou glissez-déposez le fichier .tea dans cette zone pour télécharger\",\n    \"confirm\": \"Confirmer et continuer\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Cliquer pour voir\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Modèle\",\n    \"description\": \"Créer rapidement une nouvelle base à partir d'un modèle\",\n    \"noTemplatesAvailable\": \"Aucun modèle disponible\",\n    \"noTemplatesDescription\": \"Il n'y a rien ici pour le moment\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Consulté récemment\"\n  },\n  \"noBases\": {\n    \"title\": \"Bonjour {{userName}} !\",\n    \"description\": \"Commençons à gérer votre travail avec votre première base.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Bonjour {{userName}} !\",\n    \"description\": \"Créez votre premier espace pour commencer votre voyage de collaboration de données.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Toutes les bases\",\n    \"owner\": \"Propriétaire\",\n    \"createdTime\": \"Créé\",\n    \"lastOpened\": \"Dernière ouverture\",\n    \"enter\": \"Entrer\",\n    \"noTables\": \"Aucun tableau\",\n    \"empty\": \"Aucune base pour le moment\"\n  },\n  \"publishBase\": {\n    \"title\": \"Publier la Base dans la communauté\",\n    \"description\": \"Publiez votre base en un clic, l'inspiration n'est plus solitaire ! Laissez plus de gens voir, utiliser et remixer votre créativité, et construisez ensemble une entreprise plus puissante\",\n    \"infoTitle\": \"Infos de base\",\n    \"form\": {\n      \"title\": \"Titre\",\n      \"description\": \"Description\",\n      \"security\": \"Sécurité\",\n      \"includeNodes\": \"Inclure les nœuds\",\n      \"advanced\": \"Avancé\",\n      \"publishNode\": \"Publier les nœuds\",\n      \"includeData\": \"Inclure les données\",\n      \"defaultActiveNode\": \"Nœud actif par défaut\",\n      \"select\": \"veuillez sélectionner\",\n      \"descriptionPlaceholder\": \"Décrivez brièvement votre idée...\",\n      \"titlePlaceholder\": \"Nommez votre travail...\"\n    },\n    \"publishToCommunity\": \"Publier dans le centre de modèles\",\n    \"publish\": \"Publier\",\n    \"publishSuccess\": \"Publication réussie !\",\n    \"previewTips\": \"Montrez votre travail au monde\",\n    \"update\": \"Mettre à jour\",\n    \"unPublish\": \"Dépublier\",\n    \"unPublishSuccess\": \"Base dépubliée avec succès !\",\n    \"unPublishConfirmTitle\": \"Confirmer la dépublication\",\n    \"unPublishConfirmDescription\": \"Êtes-vous sûr de vouloir dépublier cette base ? Elle ne sera plus visible dans le centre de modèles de la communauté.\",\n    \"usageCount\": \"Nombre d'utilisations : \",\n    \"uploadCover\": \"Cliquez pour télécharger l'image de couverture\",\n    \"changeCover\": \"Cliquez pour changer la couverture\",\n    \"uploading\": \"Téléchargement de l'image...\",\n    \"uploadSuccess\": \"Image téléchargée avec succès\",\n    \"uploadFailed\": \"Échec du téléchargement\",\n    \"invalidImageType\": \"Veuillez sélectionner un fichier image\",\n    \"tips\": {\n      \"publishValidation\": \"le titre et la description sont requis\",\n      \"atLeastOneNode\": \"Sélectionnez au moins un nœud à publier\"\n    },\n    \"urlCopied\": \"URL copiée dans le presse-papiers !\",\n    \"urlCopiedForDiscord\": \"URL copiée dans le presse-papiers ! Vous pouvez la coller dans Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Officiellement sélectionné comme vedette. Votre modèle recevra plus de visibilité.\",\n    \"unfeaturedTip\": \"Pas encore mis en vedette. Continuez à améliorer pour avoir une chance d'être recommandé. Laissez plus de gens voir votre travail.\",\n    \"publishSuccessDescription\": \"Partagez votre travail avec le monde\",\n    \"shareWith\": \"Partager avec\",\n    \"unpublishedApps\": {\n      \"title\": \"Applications non publiées détectées\",\n      \"description\": \"Les applications non publiées peuvent provoquer des erreurs d'aperçu du modèle. Publiez-les maintenant ou continuez.\",\n      \"publishAll\": \"Tout publier\",\n      \"publish\": \"Publier\",\n      \"published\": \"Publiée\",\n      \"publishing\": \"Publication...\",\n      \"publishFailed\": \"Échec de la publication\",\n      \"publishFailedTip1\": \"Vérifiez si l'application source peut être publiée avec succès\",\n      \"publishFailedTip2\": \"Essayez de republier ce modèle\",\n      \"notPublished\": \"Non publiée\",\n      \"ignoreAndContinue\": \"Ignorer et continuer\",\n      \"goToFix\": \"Aller corriger\",\n      \"redeploy\": \"Redéployer\",\n      \"unnamedApp\": \"Application sans nom\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Bientôt disponible\",\n    \"viewFilterInShare\": \"Cette vue est utilisée dans un lien de partage de vue. Les modifications apportées à la configuration de la vue modifieront également le lien de partage de la vue.\",\n    \"createFieldButtonText\": \"Créer un nouveau <0/> champ\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Partager\",\n        \"statusLabel\": \"Partager la vue sur le web\",\n        \"shareLink\": \"Lien de partage\",\n        \"copied\": \"Copié\",\n        \"genLink\": \"Générer un nouveau lien\",\n        \"allowCopy\": \"Autoriser les spectateurs à copier des données depuis cette vue\",\n        \"showAllFields\": \"Afficher tous les champs dans les enregistrements étendus\",\n        \"restrict\": \"Restreindre par mot de passe\",\n        \"tips\": \"Les personnes ayant le lien peuvent voir la vue.\",\n        \"passwordTitle\": \"Entrer un mot de passe\",\n        \"passwordTips\": \"Restrictions de mot de passe pour accéder aux vues partagées\",\n        \"embed\": \"Intégrer\",\n        \"embedPreview\": \"Aperçu de l'intégration\",\n        \"hideToolbar\": \"Masquer la barre d'outils\",\n        \"URLSetting\": \"Configuration des paramètres URL\",\n        \"URLSettingDescription\": \"Ajuster les paramètres suivants n'affectera pas les liens partagés. Vous devez copier le lien avec de nouveaux paramètres pour qu'il prenne effet.\",\n        \"cancel\": \"Annuler\",\n        \"save\": \"Enregistrer\"\n      },\n      \"extensions\": {\n        \"label\": \"Extensions\",\n        \"graph\": \"Graphique\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"API Restful\",\n        \"databaseConnection\": \"Connexion à la base de données\"\n      },\n      \"personalView\": {\n        \"personal\": \"Personnel\",\n        \"tip\": \"Une fois activée, la configuration de la vue ne s’appliquera qu’à vous-même\",\n        \"collaborative\": \"Collaboratif\",\n        \"dialog\": {\n          \"title\": \"Quitter le mode personnel\",\n          \"description\": \"La configuration de la vue personnelle sera restaurée à l'état de collaboration en temps réel. Vous pouvez également enregistrer les paramètres de la vue personnelle et les synchroniser pour tout le monde.\",\n          \"cancelText\": \"Quitter et synchroniser\",\n          \"confirmText\": \"Confirmer la sortie\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Bienvenue\",\n    \"emptyTitle\": \"Commencez à créer votre base de données\",\n    \"description\": \"Cliquez sur le bouton \\\"+\\\" dans la barre latérale pour ajouter des ressources\",\n    \"help\": \"Visitez le <HelpCenter /> pour plus d'informations\",\n    \"helpCenter\": \"Centre d'aide\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Impossible de lier l'enregistrement : dans ce même lot, il est déjà lié par un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.\",\n      \"one_many_duplicate\": \"Impossible de lier l'enregistrement : il est déjà lié à un autre enregistrement. Dans une relation un-à-plusieurs, chaque enregistrement enfant ne peut appartenir qu'à un seul enregistrement parent.\",\n      \"one_one_duplicate\": \"Impossible de lier l'enregistrement : l'enregistrement cible est déjà lié par un autre enregistrement dans une relation un-à-un.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"La table \\\"{{tableName}}\\\" peut contenir au maximum {{maxFieldCount}} champs.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Gestion des champs\",\n    \"fieldManagementDesc\": \"Propriétés détaillées pour tous les champs de la table actuelle\",\n    \"advancedProps\": \"Propriétés avancées\",\n    \"hide\": \"cacher\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Étiquette\"\n      },\n      \"longText\": {\n        \"title\": \"Notes\"\n      },\n      \"number\": {\n        \"title\": \"Nombre\",\n        \"formatType\": \"Type de format\",\n        \"currencySymbol\": \"Symbole monétaire\",\n        \"defaultSymbol\": \"$\",\n        \"precision\": \"Précision\",\n        \"decimalExample\": \"Nombre (123)\",\n        \"currencyExample\": \"Monétaire ($100)\",\n        \"percentExample\": \"Pourcentage (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Statut\",\n        \"options\": {\n          \"todo\": \"À faire\",\n          \"inProgress\": \"En cours\",\n          \"done\": \"Fait\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Tags\"\n      },\n      \"attachment\": {\n        \"title\": \"Pièces jointes\"\n      },\n      \"user\": {\n        \"title\": \"Collaborateur\"\n      },\n      \"date\": {\n        \"title\": \"Date\",\n        \"dateFormatting\": \"Format de la date\",\n        \"timeFormatting\": \"Format de l'heure\",\n        \"timeZone\": \"Fuseau horaire\",\n        \"yearMonth\": \"Année/Mois\",\n        \"monthDay\": \"Mois/Jour\",\n        \"year\": \"Année\",\n        \"month\": \"Mois\",\n        \"day\": \"Jour\",\n        \"local\": \"Local\",\n        \"friendly\": \"Amical\",\n        \"us\": \"US\",\n        \"european\": \"Européen\",\n        \"asia\": \"Asie\",\n        \"custom\": \"Personnalisé\",\n        \"12Hour\": \"12 heures\",\n        \"24Hour\": \"24 heures\",\n        \"noDisplay\": \"Aucun affichage\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Heure de création\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Dernière modification\"\n      },\n      \"createdBy\": {\n        \"title\": \"Créé par\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Dernière modification par\"\n      },\n      \"rating\": {\n        \"title\": \"Évaluation\"\n      },\n      \"checkbox\": {\n        \"title\": \"Fait\"\n      },\n      \"button\": {\n        \"title\": \"Bouton\",\n        \"label\": \"Texte du bouton\",\n        \"color\": \"Couleur du bouton\",\n        \"limitCount\": \"Limiter le compteur de clics\",\n        \"resetCount\": \"Autoriser la réinitialisation du compteur de clics\",\n        \"maxCount\": \"Maximum de clics\",\n        \"automation\": \"Automatisation\",\n        \"customAutomation\": \"Automatisation personnalisée\",\n        \"clickConfirm\": \"Confirmer avant de cliquer\",\n        \"confirmTitle\": \"Titre\",\n        \"confirmDescription\": \"Contenu\",\n        \"confirmButtonText\": \"Texte du bouton de confirmation\"\n      },\n      \"formula\": {\n        \"title\": \"Calcul\",\n        \"formula\": \"Formule\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (depuis {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (filtré depuis {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Rollup (depuis {{linkFieldName}})\",\n        \"rollup\": \"Rollup\",\n        \"selectAnRollupFunction\": \"Sélectionner une fonction de rollup\",\n        \"func\": {\n          \"and\": \"ET\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"MOYENNE\",\n          \"concatenate\": \"CONCATÉNER\",\n          \"count\": \"COMPTER\",\n          \"countA\": \"COMPTERA\",\n          \"countAll\": \"COMPTERTOUS\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OU\",\n          \"sum\": \"SOMME\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Renvoie vrai si toutes les valeurs sont vraies.\",\n          \"arrayCompact\": \"Supprime les chaînes vides et les valeurs nulles du tableau. Garde 'faux' et les chaînes contenant un ou plusieurs caractères vides.\",\n          \"arrayJoin\": \"Joins toutes les valeurs en une seule chaîne séparée par des virgules.\",\n          \"arrayUnique\": \"Renvoie uniquement les éléments uniques.\",\n          \"average\": \"Moyenne des valeurs.\",\n          \"concatenate\": \"Joint ensemble les valeurs textuelles en une seule valeur textuelle.\",\n          \"count\": \"Compte uniquement les valeurs numériques non vides. Si vous souhaitez compter tous les enregistrements, utilisez COUNTALL.\",\n          \"countA\": \"Compte le nombre de valeurs non vides. Cette fonction compte les valeurs numériques et textuelles.\",\n          \"countAll\": \"Compte toutes les valeurs, y compris les enregistrements vides.\",\n          \"max\": \"Renvoie le plus grand des nombres donnés.\",\n          \"min\": \"Renvoie le plus petit des nombres donnés.\",\n          \"or\": \"Renvoie vrai si l'une des valeurs est vraie.\",\n          \"sum\": \"Somme des valeurs.\",\n          \"xor\": \"Renvoie vrai si et seulement si un nombre impair de valeurs sont vraies.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} rollup conditionnel\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Ajouter un champ\",\n      \"editField\": \"Modifier le champ\",\n      \"insertField\": \"Insérer un champ\",\n      \"graph\": \"Graphique\",\n      \"fieldUpdated\": \"Champ mis à jour\",\n      \"fieldCreated\": \"Champ créé\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Êtes-vous sûr de vouloir effectuer cette action ?\",\n      \"addDescription\": \"Ajouter une description\",\n      \"dbFieldName\": \"Nom du champ physique\",\n      \"description\": \"Description\",\n      \"descriptionPlaceholder\": \"Décrivez ce champ (optionnel)\",\n      \"type\": \"Type\",\n      \"showAs\": \"Afficher comme\",\n      \"color\": \"Couleur\",\n      \"number\": \"Nombre\",\n      \"chartBar\": \"Graphique en barres\",\n      \"chartLine\": \"Graphique linéaire\",\n      \"ring\": \"Anneau\",\n      \"bar\": \"Barre\",\n      \"text\": \"Texte\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"Email\",\n      \"phone\": \"Téléphone\",\n      \"maxNumber\": \"Nombre maximum\",\n      \"showNumber\": \"Afficher le nombre\",\n      \"autoFillDate\": \"Remplir automatiquement avec la date actuelle\",\n      \"createSymmetricLink\": \"Créer un champ de lien réciproque dans la table de lien\",\n      \"allowLinkMultipleRecords\": \"Autoriser la sélection multiple\",\n      \"allowLinkToDuplicateRecords\": \"Permet la sélection des enregistrements plus d'une fois\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Permet la sélection des enregistrements plus d'une fois\",\n      \"oneToOne\": \"un-à-un\",\n      \"oneToMany\": \"un-à-plusieurs\",\n      \"manyToOne\": \"plusieurs-à-un\",\n      \"manyToMany\": \"plusieurs-à-plusieurs\",\n      \"self\": \"Auto\",\n      \"selectTable\": \"Sélectionner une table...\",\n      \"inSelfLink\": \"dans un lien auto-référencé\",\n      \"betweenTwoTables\": \"entre deux tables\",\n      \"tips\": \"Conseils\",\n      \"linkTipMessage\": \"Cette configuration représente une<br></br> <b>{{relationship}}</b> relation <span>{{linkType}}</span>\",\n      \"style\": \"Style\",\n      \"maximum\": \"Maximum\",\n      \"addOption\": \"Ajouter une option\",\n      \"allowMultiUsers\": \"Autoriser l'ajout de plusieurs utilisateurs\",\n      \"notifyUsers\": \"Notifier les utilisateurs une fois sélectionnés\",\n      \"searchTable\": \"Rechercher une table...\",\n      \"calculating\": \"Calcul en cours...\",\n      \"doSaveChanges\": \"Voulez-vous enregistrer les modifications apportées ?\",\n      \"linkFieldToLookup\": \"Champ de lien à utiliser pour la recherche\",\n      \"lookupToTable\": \"Champ de <bold>{{tableName}}</bold> que vous souhaitez rechercher\",\n      \"rollupToTable\": \"Champ de <bold>{{tableName}}</bold> que vous souhaitez rechercher\",\n      \"selectField\": \"Sélectionner un champ...\",\n      \"linkTable\": \"Table de lien\",\n      \"noLinkTip\": \"Aucun enregistrement lié à rechercher. Ajoutez un champ de lien vers un autre enregistrement, puis essayez de configurer votre recherche à nouveau.\",\n      \"fieldValidationRules\": \"Règles de validation des valeurs de champ\",\n      \"enableValidateFieldUnique\": \"Valeurs uniques uniquement\",\n      \"enableValidateFieldNotNull\": \"Obligatoire\",\n      \"knowMore\": \"en savoir plus\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Afficher les enregistrements par champ\",\n      \"filterByView\": \"Filtrer les enregistrements par vue\",\n      \"filter\": \"Filtrer les enregistrements\",\n      \"hideFields\": \"Masquer les champs\",\n      \"moreOptions\": \"Plus d'options\",\n      \"allowNewOptionsWhenEditing\": \"Autoriser de nouvelles options lors de l'édition\",\n      \"deleteField\": {\n        \"title\": \"Supprimer le champ\",\n        \"simpleConfirm\": \"Êtes-vous sûr de vouloir supprimer le champ <b>{{fieldName}}</b> ?\",\n        \"withDependencies\": \"La suppression du champ <b>{{fieldName}}</b> affectera les champs suivants :\",\n        \"affectedFields\": \"Champs affectés :\",\n        \"fieldsToDelete\": \"Champs à supprimer ({{count}})\",\n        \"unviewedHint\": \"{{count}} champ(s) non examiné(s)\",\n        \"deleteCount\": \"Supprimer {{count}} champs\",\n        \"noAffectedFields\": \"Ce champ n'est référencé par aucun autre champ.\",\n        \"riskIdentified\": \"Risque identifié({{count}})\",\n        \"noDependencies\": \"Pas de dépendances({{count}})\",\n        \"safeToDelete\": \"Suppression sûre\",\n        \"safeToDeleteDesc\": \"Ce champ n'est pas référencé par d'autres champs, il peut être supprimé en toute sécurité\",\n        \"affectedItems\": \"Éléments affectés\",\n        \"type\": \"Type\",\n        \"source\": \"Source\",\n        \"sourceTable\": \"Table source\",\n        \"typeField\": \"Champ\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Lien vers les enregistrements dans la table que vous choisissez\",\n      \"singleLineText\": \"Entrez du texte ou pré-remplissez chaque nouvelle cellule avec une valeur par défaut.\",\n      \"longText\": \"Entrez plusieurs lignes de texte.\",\n      \"attachment\": \"Ajoutez ou générez des images par IA, ou téléversez des documents ou d'autres fichiers à visualiser ou télécharger.\",\n      \"checkbox\": \"Cochez ou décochez pour indiquer le statut.\",\n      \"multipleSelect\": \"Sélectionnez une ou plusieurs options prédéfinies dans une liste.\",\n      \"singleSelect\": \"Sélectionnez une option prédéfinie dans une liste, ou pré-remplissez chaque nouvelle cellule avec une option par défaut.\",\n      \"user\": \"Ajoutez un utilisateur à un enregistrement.\",\n      \"date\": \"Entrez une date (par exemple, 11/12/2023) ou choisissez-en une dans un calendrier.\",\n      \"number\": \"Entrez un nombre, ou pré-remplissez chaque nouvelle cellule avec une valeur par défaut.\",\n      \"duration\": \"Entrez une durée en heures, minutes ou secondes (par exemple, 1:23).\",\n      \"rating\": \"Ajoutez une évaluation sur une échelle prédéfinie.\",\n      \"formula\": \"Calculez des valeurs basées sur des champs.\",\n      \"rollup\": \"Résumez les données des enregistrements liés.\",\n      \"conditionalLookup\": \"Affiche les valeurs liées correspondant aux filtres définis.\",\n      \"count\": \"Comptez le nombre d'enregistrements liés.\",\n      \"createdTime\": \"Voir la date et l'heure de création de chaque enregistrement.\",\n      \"lastModifiedTime\": \"Voir la date et l'heure de la modification la plus récente de certains ou de tous les champs d'un enregistrement.\",\n      \"createdBy\": \"Voir quel utilisateur a créé l'enregistrement.\",\n      \"lastModifiedBy\": \"Voir quel utilisateur a effectué la dernière modification de certains ou de tous les champs d'un enregistrement.\",\n      \"autoNumber\": \"Générez automatiquement des numéros incrémentiels uniques pour chaque enregistrement.\",\n      \"button\": \"Déclencher une action personnalisée.\",\n      \"lookup\": \"Voir les valeurs d'un champ dans un enregistrement lié.\"\n    },\n    \"fieldName\": \"Nom du champ\",\n    \"fieldNameOptional\": \"Nom du champ (Optionnel)\",\n    \"fieldType\": \"Type de champ\",\n    \"aiConfig\": {\n      \"title\": \"Configuration AI\",\n      \"type\": {\n        \"summary\": \"Résumer\",\n        \"translation\": \"Traduire\",\n        \"extraction\": \"Extraire des informations\",\n        \"improvement\": \"Améliorer\",\n        \"tag\": \"Étiquette intelligente\",\n        \"classification\": \"Classification intelligente\",\n        \"customization\": \"Personnaliser\",\n        \"imageGeneration\": \"Génération d'images\",\n        \"rating\": \"Évaluation d'images\"\n      },\n      \"label\": {\n        \"type\": \"Type d'action AI\",\n        \"model\": \"Modèle AI\",\n        \"targetLanguage\": \"Langue cible\",\n        \"sourceField\": \"Champ source\",\n        \"sourceFieldForTag\": \"Sélectionnez un champ, correspondant aux tags créés\",\n        \"sourceFieldForClassify\": \"Sélectionnez un champ, correspondant aux classifications créées\",\n        \"attachPrompt\": \"Attacher les exigences\",\n        \"prompt\": \"Personnaliser le prompt\",\n        \"sourceFieldForAttachment\": \"Champ source pour l'attachement\",\n        \"imageSize\": \"Taille de l'image\",\n        \"imageQuality\": \"Qualité de l'image\",\n        \"imageCount\": \"Nombre d'images\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Résumé du contenu\",\n        \"translate\": \"Traduction concise et facile à comprendre, légèrement humoristique\",\n        \"extractInfo\": \"Extraction des emails, téléphones, noms, adresses...\",\n        \"extractDate\": \"Extraction de l'heure de début\",\n        \"improveText\": \"Formel, amical, humoristique...\",\n        \"attachPromptForTag\": \"Ne pas dépasser trois tags\",\n        \"attachPromptForClassify\": \"Classer “En cours” comme “Aucun risque”\",\n        \"attachPrompt\": \"Entrez des exigences supplémentaires\",\n        \"prompt\": \"Entrez un prompt personnalisé\",\n        \"type\": \"Sélectionner une action AI\",\n        \"targetLanguage\": \"Anglais, Chinois, Français...\",\n        \"imageSize\": \"Veuillez entrer la taille de l'image\",\n        \"imageQuality\": \"Veuillez entrer la qualité de l'image\",\n        \"attachPromptForImageGeneration\": \"L'image doit être vivante et naturelle\",\n        \"attachPromptForRating\": \"Évaluez la qualité de l'image\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Faible\",\n        \"medium\": \"Moyenne\",\n        \"high\": \"Élevée\"\n      },\n      \"autoFill\": {\n        \"title\": \"Mise à jour automatique\",\n        \"tip\": \"Après activation, le champ actuel sera mis à jour de manière synchrone avec les modifications du contenu de la configuration AI\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Mettre à jour tous les enregistrements\",\n        \"description\": \"Tous les enregistrements dans la vue actuelle seront mis à jour, y compris tous les données générées par le champ\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Générer toute la colonne ?\",\n        \"description\": \"La génération de la colonne mettra à jour {{rowCount}} lignes. Cette opération peut consommer beaucoup de ressources d'IA.\",\n        \"saveConfigOnly\": \"Enregistrer la configuration uniquement\",\n        \"generate\": \"Générer\",\n        \"generateFailed\": \"Échec de la génération\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Ajouter un fichier\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Nouvelle table\",\n    \"rename\": \"Renommer\",\n    \"design\": \"Concevoir\",\n    \"tableRecordHistory\": \"Historique des enregistrements de la table\",\n    \"deleteConfirm\": \"Êtes-vous sûr de vouloir supprimer \\\"{{tableName}}\\\" ?\",\n    \"dbTableName\": \"Nom de la table dans la base de données physique\",\n    \"schemaName\": \"Nom du schéma dans la base de données physique\",\n    \"baseInfo\": \"Informations sur la base\",\n    \"typeOfDatabase\": \"Type de base de données\",\n    \"descriptionForTable\": \"Description pour cette table\",\n    \"nameForTable\": \"Nom pour cette table\",\n    \"deleteTip1\": \"Les champs de lien associés à cette table dans d'autres tables seront supprimés.\",\n    \"deleteTip2\": \"Cette table peut être restaurée depuis la corbeille après sa suppression.\",\n    \"operator\": {\n      \"createBlank\": \"Nouvelle table\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Le copier-coller fonctionne uniquement dans HTTPS ou localhost\",\n      \"copyAndPasteBrowser\": \"Le copier-coller n'est pas pris en charge dans ce navigateur\",\n      \"copying\": \"Copie en cours...\",\n      \"copySuccessful\": \"Copie réussie\",\n      \"copyFailed\": \"Échec de la copie\",\n      \"pasting\": \"Collage en cours...\",\n      \"pasteSuccessful\": \"Collage réussi\",\n      \"pasteFailed\": \"Échec du collage\",\n      \"filling\": \"Remplissage en cours...\",\n      \"fillSuccessful\": \"Remplissage réussi\",\n      \"fillFailed\": \"Échec du remplissage\",\n      \"clearing\": \"Effacement en cours...\",\n      \"clearSuccessful\": \"Effacement réussi\",\n      \"deleteFieldConfirmTitle\": \"Vous allez supprimer les champs suivants\",\n      \"deleting\": \"Suppression en cours...\",\n      \"deleteSuccessful\": \"Suppression réussie\",\n      \"pasteFileFailed\": \"Les fichiers ne peuvent être collés que dans un champ de pièces jointes\",\n      \"copyError\": {\n        \"noFocus\": \"Veuillez garder la page active et ne pas changer de fenêtre\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Étiquette de la table\",\n      \"effectCells\": \"Peut affecter les cellules\",\n      \"estimatedTime\": \"Temps estimé\",\n      \"linkFieldCount\": \"Affecte le nombre de champs liés\"\n    },\n    \"integrity\": {\n      \"check\": \"Vérifier\",\n      \"title\": \"Vérification de l'intégrité\",\n      \"loading\": \"Vérification de l'intégrité...\",\n      \"allGood\": \"Tout va bien !\",\n      \"fixIssues\": \"Corriger les problèmes\"\n    },\n    \"index\": {\n      \"description\": \"Les index peuvent améliorer considérablement les performances de recherche, en particulier lors du traitement de grandes quantités de données (plus de {{rowCount}} lignes). L'inconvénient est que les opérations d'écriture sont légèrement plus lentes. Si vous effectuez fréquemment des recherches ou avez de grands ensembles de données, il est recommandé d'activer l'indexation.\",\n      \"repair\": \"réparer\",\n      \"repairTip\": \"Des anomalies d'index ont été détectées, ce qui peut entraîner une dégradation des performances de recherche. Il est recommandé de cliquer sur le bouton réparer pour corriger l'index\",\n      \"enableIndexTip\": \"Vous créez un index. Le temps nécessaire dépend de la taille de la table. Pendant ce processus, les performances de lecture et d'écriture de la table peuvent être affectées. Veuillez patienter.\",\n      \"globalSearchTip_infinity\": \"Rechercher dans tous les champs sauf les champs de date, de case à cocher et de bouton\",\n      \"globalSearchTip_limited\": \"Rechercher dans tous les champs sauf les champs de date, de case à cocher et de bouton, maximum {{count}} champs\",\n      \"autoIndexTip\": \"Votre table a dépassé {{rowCount}} lignes. Nous vous recommandons d'activer l'indexation pour améliorer les performances de recherche. Veuillez noter que l'indexation peut légèrement réduire la vitesse d'écriture. Pendant la création de l'index, les performances de lecture/écriture de la table peuvent être temporairement affectées. Veuillez patienter.\",\n      \"enableIndex\": \"activer\",\n      \"keepAsIs\": \"laisser tel quel\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Télécharger\",\n      \"import\": \"Importer\",\n      \"localFile\": \"Fichiers locaux\",\n      \"linkUrl\": \"Lien(URL)\",\n      \"linkUrlInputTitle\": \"Ajouter un fichier depuis l'URL\",\n      \"importTitle\": \"Créer une nouvelle table\",\n      \"incrementImportTitle\": \"Importer dans — \",\n      \"optionsTitle\": \"Option d'importation\",\n      \"primitiveFields\": \"Champs primitifs\",\n      \"importFields\": \"Importer le champ\",\n      \"primaryField\": \"Champ principal\",\n      \"tipsTitle\": \"Conseils\",\n      \"confirm\": \"Confirmer et continuer\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Ajouter depuis d'autres sources\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"Fichier CSV\",\n      \"importCsvData\": \"Importer des données CSV\",\n      \"importExcelData\": \"Importer des données Microsoft Excel\",\n      \"cancel\": \"Annuler\",\n      \"leave\": \"Quitter\",\n      \"downAsCsv\": \"Télécharger csv\",\n      \"importData\": \"Importer les données\",\n      \"duplicate\": \"Dupliquer\",\n      \"duplicating\": \"Duplication en cours...\",\n      \"duplicateSuccess\": \"Dupliqué avec succès\",\n      \"duplicateFailed\": \"Échec de la duplication\",\n      \"importing\": \"Importation\",\n      \"includeRecords\": \"Inclure les enregistrements\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Cliquez ou faites glisser le fichier dans cette zone pour le télécharger\",\n      \"leaveTip\": \"Vos données seront toujours importées.\",\n      \"fileExceedSizeTip\": \"Ce type de fichier dépasse la limite de taille de\",\n      \"analyzing\": \"analyse\",\n      \"importing\": \"Importation\",\n      \"notSupportFieldType\": \"Type de champ non pris en charge\",\n      \"resultEmpty\": \"Aucun résultat trouvé.\",\n      \"searchPlaceholder\": \"Rechercher...\",\n      \"importAlert\": \"Une fois l'importation commencée, elle ne peut être arrêtée que lorsqu'elle est soit terminée avec succès, soit arrêtée en raison d'un échec. Le statut de l'importation est affiché dans le coin supérieur droit du tableau. Les résultats du processus d'importation vous seront notifiés une fois terminés. Vous ne devez pas modifier les champs pendant l'importation car cela pourrait provoquer des erreurs.\",\n      \"noTips\": \"Je sais, ne pas afficher à nouveau\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Sélection automatique des types de champs\",\n      \"useFirstRowAsHeaderOptionName\": \"Utiliser la première ligne comme en-tête\",\n      \"importDataOptionName\": \"Importer les données\",\n      \"sheetKey\": \"Nom de la feuille : \",\n      \"excludeFirstRow\": \"Exclure la première ligne dans l'importation\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Champ\",\n      \"error\": {\n        \"urlEmptyTip\": \"L'URL ne doit pas être vide !\",\n        \"errorFileFormat\": \"Format de fichier incorrect !\",\n        \"uniqueFieldName\": \"Le nom du champ doit être unique !\",\n        \"fieldNameEmpty\": \"Le nom du champ ne doit pas être vide !\",\n        \"atLeastAImportField\": \"Veuillez définir au moins un champ d'importation\",\n        \"urlValidateTip\": \"Impossible d'analyser l'URL. Essayez une autre URL !\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Ne pas importer\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Télécharger CSV\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Ajouter un nouvel enregistrement\",\n    \"prefillingRowTooltip\": \"Veuillez entrer les données du nouvel enregistrement ci-dessous. L'enregistrement sera sauvegardé automatiquement une fois que vous cliquerez en dehors de cette ligne.\",\n    \"presortRowTitle\": \"Cet enregistrement a été filtré ou déplacé en raison des règles de tri\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Champs\",\n    \"addAll\": \"Ajouter tout\",\n    \"removeAll\": \"Supprimer tout\",\n    \"hideFieldTip\": \"Masquer le champ ici\",\n    \"unableAddFieldTip\": \"Impossible d'ajouter ce type de champ\",\n    \"removeFromFormTip\": \"Supprimer du formulaire\",\n    \"descriptionPlaceholder\": \"Entrez une description\",\n    \"dragToFormTip\": \"Faites glisser le champ ici pour l'ajouter au formulaire\",\n    \"protectedFieldTip\": \"Ce champ a été défini comme un champ \\\"requis\\\" et ne peut pas être supprimé dans la vue formulaire. Veuillez le modifier dans les paramètres du champ.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Masquer le nom du champ\",\n      \"customizeCards\": \"Personnaliser les cartes\",\n      \"stackedBy\": \"Empilé par <0/>\",\n      \"chooseStackingField\": \"Choisir un champ d'empilement\",\n      \"chooseStackingFieldDescription\": \"Voulez-vous utiliser un champ pour cette vue kanban ? Vos enregistrements seront empilés en fonction de ce champ.\",\n      \"hideEmptyStack\": \"Masquer l'empilement vide\",\n      \"imageSetting\": \"Paramètre de l'image\",\n      \"fit\": \"Ajuster\",\n      \"noImage\": \"Pas d'image\",\n      \"chooseAttachmentField\": \"Choisir un champ de pièce jointe\"\n    },\n    \"stack\": {\n      \"addStack\": \"Ajouter un empilement\",\n      \"noCards\": \"Pas de cartes\",\n      \"uncategorized\": \"Non catégorisé\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Réduire l'empilement\",\n      \"renameStack\": \"Renommer l'empilement\",\n      \"deleteStack\": \"Supprimer l'empilement\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Insérer une carte au-dessus\",\n      \"insertCardBelow\": \"Insérer une carte en dessous\",\n      \"expandCard\": \"Développer la carte\",\n      \"deleteCard\": \"Supprimer la carte\",\n      \"duplicateCard\": \"Dupliquer la carte\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Configuration du calendrier\",\n      \"startDateField\": \"Champ de date de début\",\n      \"endDateField\": \"Champ de date de fin\",\n      \"titleField\": \"Champ de titre\",\n      \"colorField\": \"Champ de couleur\",\n      \"colorType\": \"Affichage de la couleur\",\n      \"customColor\": \"Personnaliser la couleur\",\n      \"alignWithRecords\": \"Aligner avec les enregistrements\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Sélectionner un champ de couleur\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Date de début\",\n      \"endDate\": \"Date de fin\",\n      \"notAdd\": \"Non ajouter\",\n      \"addDateField\": \"Ajouter des champs de date\",\n      \"content\": \"Créer un calendrier de vue, et la table doit avoir deux champs de date : date de début et date de fin\"\n    },\n    \"moreLinkText\": \"Afficher toutes les {{count}} enregistrements\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Insérer un enregistrement au-dessus\",\n    \"insertRecordBelow\": \"Insérer un enregistrement en dessous\",\n    \"copyCells\": \"Copier les cellules\",\n    \"deleteRecord\": \"Supprimer l'enregistrement\",\n    \"deleteAllSelectedRecords\": \"Supprimer tous les enregistrements sélectionnés\",\n    \"editField\": \"Modifier le champ\",\n    \"insertFieldLeft\": \"Insérer à gauche\",\n    \"insertFieldRight\": \"Insérer à droite\",\n    \"freezeUpField\": \"Geler jusqu'à ce champ\",\n    \"hideField\": \"Masquer le champ\",\n    \"deleteField\": \"Supprimer le champ\",\n    \"deleteAllSelectedFields\": \"Supprimer tous les champs sélectionnés\",\n    \"autoFill\": \"Mettre à jour tous les enregistrements\",\n    \"groupMenuTitle\": \"Menu de groupe\",\n    \"expandGroup\": \"Développer ce groupe et ses sous-groupes\",\n    \"collapseGroup\": \"Réduire ce groupe et ses sous-groupes\",\n    \"expandAllGroups\": \"Développer tous les groupes\",\n    \"collapseAllGroups\": \"Réduire tous les groupes\",\n    \"addToChat\": \"Ajouter au chat\"\n  },\n  \"connection\": {\n    \"title\": \"Connexion à la base de données\",\n    \"description\": \"Vous pouvez accéder directement à la base de données via la connexion à la base de données, y compris toutes les tables sous la base actuelle\",\n    \"noPermission\": \"Vous n'avez pas la permission d'accéder à la base de données\",\n    \"connectionCountTip\": \"Le nombre maximum de connexions à la base de données est <b>{{max}}</b> et les connexions actuelles sont <b>{{current}}</b>\",\n    \"createFailed\": \"Veuillez vérifier que la variable d'environnement PUBLIC_DATABASE_PROXY est correctement configurée\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Ajouter un enregistrement\",\n    \"searchView\": \"Rechercher une vue...\",\n    \"dragToolTip\": \"Le tri automatique est activé, le glissement manuel n'est pas disponible\",\n    \"insertToolTip\": \"Le tri automatique est activé, l'insertion avec ordre n'est pas disponible\",\n    \"action\": {\n      \"rename\": \"Renommer la vue\",\n      \"duplicate\": \"Dupliquer la vue\",\n      \"delete\": \"Supprimer la vue\",\n      \"lock\": \"Verrouiller la vue\",\n      \"unlock\": \"Déverrouiller la vue\",\n      \"enable\": \"Activer\"\n    },\n    \"category\": {\n      \"table\": \"Vue en grille\",\n      \"form\": \"Vue en formulaire\",\n      \"kanban\": \"Vue Kanban\",\n      \"gallery\": \"Vue en galerie\",\n      \"calendar\": \"Vue en calendrier\"\n    },\n    \"crash\": {\n      \"title\": \"Plantage !\",\n      \"description\": \"Cette vue est endommagée. Si le rafraîchissement échoue toujours, veuillez nous contacter à support@teable.ai\"\n    },\n    \"locked\": {\n      \"tip\": \"Cette vue est verrouillée. Vous pouvez activer le mode personnel pour modifier les options de la vue, et les modifications ne prendront effet que pour vous.\"\n    },\n    \"noView\": \"Aucune vue\"\n  },\n  \"lastModifiedTime\": \"Dernière modification\",\n  \"lastModify\": \"Dernière modification : \",\n  \"tableTrash\": {\n    \"title\": \"Corbeille de Table\",\n    \"resourceType\": \"Type de Ressource\",\n    \"deletedResource\": \"Ressource Supprimée\"\n  },\n  \"baseShare\": {\n    \"title\": \"Partager la Base\",\n    \"shareTitle\": \"Partager\",\n    \"shareToWeb\": \"Partager sur le web\",\n    \"description\": \"Partager \\\"{{baseName}}\\\" avec d'autres\",\n    \"nodeShareDescription\": \"Partager \\\"{{nodeName}}\\\" et son contenu\",\n    \"shareLinks\": \"Liens de partage\",\n    \"newLink\": \"Nouveau lien\",\n    \"noShareLinks\": \"Aucun lien de partage pour l'instant\",\n    \"createFirstLink\": \"Créer un lien de partage\",\n    \"editSettings\": \"Modifier les paramètres\",\n    \"refreshLink\": \"Régénérer le lien\",\n    \"deleteLink\": \"Supprimer le lien\",\n    \"deleteConfirmTitle\": \"Supprimer le lien de partage\",\n    \"deleteConfirmDescription\": \"Cette action est irréversible. Les personnes disposant de ce lien ne pourront plus accéder à la base partagée.\",\n    \"createSuccess\": \"Lien de partage créé\",\n    \"createFailed\": \"Échec de la création du lien de partage\",\n    \"updateSuccess\": \"Paramètres de partage mis à jour\",\n    \"updateFailed\": \"Échec de la mise à jour des paramètres de partage\",\n    \"deleteSuccess\": \"Lien de partage supprimé\",\n    \"deleteFailed\": \"Échec de la suppression du lien de partage\",\n    \"refreshSuccess\": \"Lien de partage régénéré\",\n    \"refreshFailed\": \"Échec de la régénération du lien de partage\",\n    \"copied\": \"Lien copié dans le presse-papiers\",\n    \"shareLink\": \"Lien de partage\",\n    \"linkHolderLabel\": \"La personne qui a obtenu le lien\",\n    \"linkHolderCanView\": \"Peut consulter\",\n    \"linkHolderCanEdit\": \"Peut modifier\",\n    \"linkHolderCanCopyAndSave\": \"Peut enregistrer comme copie\",\n    \"passwordProtection\": \"Protection par mot de passe\",\n    \"enterPassword\": \"Entrer le mot de passe\",\n    \"selectNodes\": \"Sélectionner les tables\",\n    \"shareEntireBase\": \"Partager toute la base\",\n    \"shareSelectedNodes\": \"Partager les nœuds sélectionnés\",\n    \"shareEntireBaseDescription\": \"Les nouvelles tables et dossiers ajoutés à cette base seront automatiquement partagés\",\n    \"noNodesSelectedWarning\": \"Veuillez sélectionner au moins un nœud à partager\",\n    \"allowSave\": \"Autoriser l'enregistrement dans l'espace\",\n    \"allowSaveDescription\": \"Permettre aux spectateurs d'enregistrer une copie de cette base dans leur propre espace\",\n    \"allowCopy\": \"Autoriser la copie des données\",\n    \"allowCopyData\": \"Permettre aux spectateurs de copier les données\",\n    \"allowDuplicate\": \"Permettre aux spectateurs de dupliquer\",\n    \"allowCopyDescription\": \"Permettre aux spectateurs de copier les données de table de cette base partagée\",\n    \"selectedNodes\": \"{{count}} tables sélectionnées\",\n    \"allNodes\": \"Toutes les tables\",\n    \"sharedNode\": \"Nœud partagé\",\n    \"sharedNodeDescription\": \"Ce lien de partage est pour un nœud spécifique et ses descendants\",\n    \"publicShareTitle\": \"Partage public par lien\",\n    \"publicShareCount\": \"{{count}} lien(s) de partage public\",\n    \"noPublicShare\": \"Aucun lien de partage public\",\n    \"security\": \"Sécurité\",\n    \"restrictByPassword\": \"Restreindre par mot de passe\",\n    \"advanced\": \"Avancé\",\n    \"embedConfig\": \"Configuration d'intégration\",\n    \"appPublicLink\": \"Lien public de l'application\",\n    \"appNotPublished\": \"Cette application n'est pas encore publiée. Veuillez d'abord publier l'application avant de la partager.\",\n    \"goToPublish\": \"Aller à la publication\",\n    \"publishSuccess\": \"Application publiée avec succès\",\n    \"publishFailed\": \"Échec de la publication de l'application\",\n    \"openLink\": \"Ouvrir le lien\",\n    \"appPublished\": \"Application publiée\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Obtenir les champs de la table\",\n      \"getTablesMeta\": \"Obtenir les métadonnées de la table\",\n      \"sqlQuery\": \"Exécuter une requête SQL\",\n      \"generateScriptAction\": \"Générer une action de script\",\n      \"getScriptInput\": \"Obtenir l'entrée du script\",\n      \"getTeableApi\": \"Obtenir l'API\",\n      \"dataVisualization\": \"Visualisation des données\",\n      \"updateBase\": \"Mettre à jour les infos de la base\",\n      \"args\": \"Arguments\",\n      \"result\": \"Résultat\",\n      \"thinking\": \"Réflexion en cours...\",\n      \"toBeConfirmed\": \"À confirmer\",\n      \"errorMessage\": \"Message d'erreur\",\n      \"confirm\": \"Confirmer\",\n      \"createRecordsSuccess\": \"{{count}} enregistrement(s) créé(s) avec succès\",\n      \"createRecordsFailed\": \"Échec de la création des enregistrements\",\n      \"updateRecordsSuccess\": \"{{count}} enregistrement(s) mis à jour avec succès\",\n      \"updateRecordsFailed\": \"Échec de la mise à jour des enregistrements\",\n      \"generatingRecords\": \"Génération de {{count}} enregistrement(s)...\",\n      \"creatingRecords\": \"Création de {{count}} enregistrement(s)...\",\n      \"updatingRecords\": \"Mise à jour de {{count}} enregistrement(s)...\",\n      \"recordsPreview\": \"Aperçu des enregistrements\",\n      \"andMoreRecords\": \"Et {{count}} de plus...\",\n      \"unknownError\": \"Erreur inconnue\",\n      \"recordIds\": \"ID des enregistrements\",\n      \"records\": \"enregistrement(s)\",\n      \"viewAll\": \"Tout afficher\",\n      \"showLess\": \"Afficher moins\",\n      \"generatingData\": \"Génération des données...\",\n      \"generatingUpdates\": \"Génération des mises à jour...\",\n      \"recordsGenerated\": \"{{count}} enregistrement(s) généré(s)\",\n      \"recordsCount\": \"Enregistrements ({{count}})\",\n      \"fieldsCount\": \"Champs ({{count}})\",\n      \"fieldsGenerated\": \"{{count}} champ(s) généré(s)\",\n      \"updatedProperties\": \"Mis à jour ({{count}})\",\n      \"configured\": \"configuré\",\n      \"recordsToUpdate\": \"{{count}} enregistrement(s) à mettre à jour\",\n      \"showingLast\": \"{{count}} derniers\",\n      \"recordLabel\": \"Enregistrement\",\n      \"statusGenerating\": \"Génération...\",\n      \"statusCreating\": \"Création...\",\n      \"statusUpdating\": \"Mise à jour...\",\n      \"statusCreated\": \"Créé\",\n      \"statusUpdated\": \"Mis à jour\",\n      \"getApps\": {\n        \"title\": \"Liste des applications\",\n        \"loading\": \"Chargement des applications...\",\n        \"foundApps\": \"{{count}} application(s) trouvée(s)\",\n        \"noApps\": \"Aucune application trouvée dans cette base\",\n        \"openApp\": \"Ouvrir l'application\"\n      },\n      \"generateApp\": {\n        \"title\": \"Générateur d'applications\",\n        \"creatingApp\": \"Création de l'application\",\n        \"updatingApp\": \"Mise à jour de l'application\",\n        \"generatingApp\": \"Génération de l'application\",\n        \"generating\": \"Génération...\",\n        \"openApp\": \"Ouvrir l'application\",\n        \"viewProgress\": \"Voir la progression\",\n        \"newApp\": \"Nouvelle application\",\n        \"building\": \"Construction en cours...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Générateur d'automatisation\",\n        \"creatingAutomation\": \"Création de l'automatisation\",\n        \"updatingAutomation\": \"Mise à jour de l'automatisation\",\n        \"generatingAutomation\": \"Construction de l'automatisation\",\n        \"building\": \"Construction en cours...\",\n        \"openAutomation\": \"Ouvrir l'automatisation\",\n        \"viewProgress\": \"Voir la progression\",\n        \"testResults\": \"Résultats des tests\",\n        \"triggerTest\": \"Déclencheur\",\n        \"actionTest\": \"Action {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Aperçu\",\n        \"code\": \"Code\",\n        \"download\": \"Télécharger\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Image (PNG)\",\n        \"copy\": \"Copier\",\n        \"copied\": \"Copié !\",\n        \"fullscreen\": \"Plein écran\",\n        \"exitFullscreen\": \"Quitter le plein écran\",\n        \"downloadSuccess\": \"Image téléchargée avec succès\",\n        \"downloadFailed\": \"Échec de la capture d'image\",\n        \"iframeFailed\": \"Échec de la capture : iframe non accessible\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Charger la pièce jointe\",\n        \"loading\": \"Chargement de la pièce jointe\",\n        \"failed\": \"Échec du chargement de la pièce jointe\",\n        \"empty\": \"Aucune pièce jointe chargée\",\n        \"modeNative\": \"Vision IA\",\n        \"modeNativeDesc\": \"Chargé dans le contexte natif de l'IA\",\n        \"modeExtracted\": \"Texte extrait\",\n        \"modeExtractedDesc\": \"Contenu du fichier analysé et extrait en texte pour l'analyse\",\n        \"visionLoaded\": \"Chargé pour l'analyse visuelle\",\n        \"pdfLoaded\": \"Chargé pour l'analyse PDF\",\n        \"textExtracted\": \"{{chars}} caractères extraits\",\n        \"contextLoaded\": \"Chargé dans le contexte IA\",\n        \"truncated\": \"Tronqué\",\n        \"preview\": \"Aperçu\"\n      },\n      \"textExtract\": {\n        \"title\": \"Extraction de texte\",\n        \"loading\": \"Extraction du texte\",\n        \"failed\": \"Échec de l'extraction du texte\",\n        \"empty\": \"Aucun texte extrait\",\n        \"preview\": \"Aperçu\",\n        \"truncated\": \"Tronqué\",\n        \"previews\": \"aperçus\",\n        \"chars\": \"{{chars}} caractères\",\n        \"totalCharacters\": \"Total : {{chars}} caractères\",\n        \"filesTruncated\": \"({{count}} fichier(s) tronqué(s))\"\n      },\n      \"importExcel\": {\n        \"title\": \"Importer Excel\",\n        \"loading\": \"Traitement du fichier...\",\n        \"failed\": \"Échec de l'import\",\n        \"suggestions\": \"Suggestions\",\n        \"analyzeComplete\": \"Analyse terminée\",\n        \"worksheets\": \"Feuilles de calcul\",\n        \"columns\": \"colonnes\",\n        \"importComplete\": \"Import terminé\",\n        \"stageAnalyze\": \"Analyse en cours\",\n        \"stageImport\": \"Import en cours\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Obtenir l'API Teable\",\n      \"readFiles\": \"Lire les fichiers\",\n      \"writeFile\": \"Écrire un fichier\",\n      \"deleteFiles\": \"Supprimer les fichiers\",\n      \"listFiles\": \"Lister les fichiers\",\n      \"addDependencies\": \"Ajouter les dépendances\",\n      \"checkBuildErrors\": \"Vérifier les erreurs de build\",\n      \"lint\": \"Linter le code\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Échec du chargement de l'aperçu\",\n      \"retry\": \"Réessayer {{count}} fois\",\n      \"chatAborted\": \"interrompu\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Table supprimée\",\n      \"deletedView\": \"Vue supprimée\",\n      \"deletedField\": \"Champ supprimé\",\n      \"deletedRecords\": \"Enregistrements supprimés\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Agent de table\",\n      \"viewOperatorAgent\": \"Agent de vue\",\n      \"fieldOperatorAgent\": \"Agent de champ\",\n      \"recordOperatorAgent\": \"Agent d'enregistrement\",\n      \"buildBaseAgent\": \"Agent de construction de base\",\n      \"buildAutomationAgent\": \"Agent d'automatisation\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"À confirmer\",\n      \"deleteWarning\": \"Êtes-vous sûr de vouloir supprimer ?\"\n    },\n    \"action\": {\n      \"createTable\": \"Créer une table\",\n      \"updateTable\": \"Mettre à jour la table\",\n      \"updateTableName\": \"Mettre à jour le nom de la table\",\n      \"deleteTable\": \"Supprimer la table\",\n      \"createView\": \"Créer une vue\",\n      \"updateView\": \"Mettre à jour la vue\",\n      \"updateViewName\": \"Mettre à jour le nom de la vue\",\n      \"deleteView\": \"Supprimer la vue\",\n      \"createField\": \"Créer un champ\",\n      \"createAiField\": \"Créer un champ IA\",\n      \"createLinkField\": \"Créer un champ de lien\",\n      \"createLookupField\": \"Créer un champ de recherche\",\n      \"createRollupField\": \"Créer un champ de cumul\",\n      \"createFormulaField\": \"Créer un champ de formule\",\n      \"deleteField\": \"Supprimer le champ\",\n      \"updateField\": \"Mettre à jour le champ\",\n      \"createRecord\": \"Créer un enregistrement\",\n      \"createRecords\": \"Créer des enregistrements\",\n      \"deleteRecord\": \"Supprimer l'enregistrement\",\n      \"updateRecord\": \"Mettre à jour l'enregistrement\",\n      \"updateRecords\": \"Mettre à jour les enregistrements\",\n      \"updateBase\": \"Mettre à jour les infos de la base\",\n      \"planTask\": \"Planifier la tâche...\",\n      \"generateTables\": \"Générer les tables\",\n      \"generatePrimaryFields\": \"Générer les champs primaires\",\n      \"generateFields\": \"Générer les champs\",\n      \"generateViews\": \"Générer les vues\",\n      \"generateRecords\": \"Générer les enregistrements\",\n      \"generateAIFields\": \"Générer les champs IA\",\n      \"generateLinkFields\": \"Générer les champs de lien\",\n      \"generateLookupFields\": \"Générer les champs de recherche\",\n      \"generateRollupFields\": \"Générer les champs de cumul\",\n      \"generateFormulaFields\": \"Générer les champs de formule\",\n      \"generateWorkflow\": \"Générer le flux de travail\",\n      \"generateTrigger\": \"Générer le déclencheur\",\n      \"generateScriptAction\": \"Générer le nœud d'action de script\",\n      \"generateSendMailAction\": \"Générer le nœud d'envoi d'e-mail\",\n      \"generateAction\": \"Générer le nœud d'action\",\n      \"setupAutomationTrigger\": \"Configurer le déclencheur d'automatisation\",\n      \"testAutomationNode\": \"Tester le nœud d'automatisation\",\n      \"activateAutomation\": \"Activer l'automatisation\",\n      \"executeScript\": \"Exécuter le script\",\n      \"wait\": \"Attendre\",\n      \"generateScriptFlowChart\": \"Générer le diagramme de flux du script\",\n      \"triggerAiFill\": \"Déclencher le remplissage IA\",\n      \"initialize\": \"Initialiser l'environnement\",\n      \"rename\": \"Générer le nom de l'app\",\n      \"buildTest\": \"Créer le test\",\n      \"developTask\": \"Développer la tâche\",\n      \"generateSummary\": \"Générer le résumé\",\n      \"previewEnvironment\": \"Préparer l'environnement de prévisualisation\",\n      \"getRelativeData\": \"Obtenir les données associées\",\n      \"getPreviousNodeOutputVariables\": \"Obtenir les variables de sortie du nœud précédent\",\n      \"getApiJson\": \"Obtenir les infos API\",\n      \"generateScriptAndDependencies\": \"Générer le script et les dépendances\",\n      \"analyzingAttachment\": \"Analyse de la pièce jointe...\",\n      \"locateResource\": \"Localiser\",\n      \"goTo\": \"Aller à\",\n      \"operationSuccess\": \"Opération terminée avec succès\",\n      \"operationFailed\": \"Échec de l'opération\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} enregistrement(s) en file pour la génération IA\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Rechercher des enregistrements\",\n      \"getRecordsWithTable\": \"Rechercher des enregistrements · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Rechercher des champs\",\n      \"getFieldsWithTable\": \"Rechercher des champs · {{tableName}}\",\n      \"getTables\": \"Rechercher des tables\",\n      \"getViews\": \"Rechercher des vues\",\n      \"getViewsWithTable\": \"Rechercher des vues · {{tableName}}\",\n      \"sqlQuery\": \"Requête SQL\",\n      \"querying\": \"Requête en cours...\",\n      \"queryFailed\": \"Échec de la requête\",\n      \"aborted\": \"Interrompu\",\n      \"noData\": \"Aucune donnée retournée\",\n      \"dataFormatError\": \"Erreur de format des données\",\n      \"unsupportedQueryType\": \"Type de requête non pris en charge : {{toolName}}\",\n      \"returnedRecords\": \"{{count}} enregistrement(s) retourné(s)\",\n      \"record\": \"Enregistrement {{index}}\",\n      \"moreRecords\": \"... +{{count}} enregistrement(s) de plus\",\n      \"foundFields\": \"{{count}} champ(s) trouvé(s)\",\n      \"moreFields\": \"... +{{count}} champ(s) de plus\",\n      \"foundTables\": \"{{count}} table(s) trouvée(s)\",\n      \"moreTables\": \"... +{{count}} table(s) de plus\",\n      \"foundViews\": \"{{count}} vue(s) trouvée(s)\",\n      \"moreViews\": \"... +{{count}} vue(s) de plus\",\n      \"queryReturned\": \"La requête a retourné {{rowCount}} ligne(s) × {{columnCount}} colonne(s)\",\n      \"row\": \"Ligne {{index}}\",\n      \"moreRows\": \"... +{{count}} ligne(s) de plus\",\n      \"getDoc\": \"Obtenir le document\",\n      \"getDocWithTopic\": \"Obtenir le document · {{topic}}\",\n      \"getAutomations\": \"Rechercher des automatisations\",\n      \"getAutomation\": \"Rechercher une automatisation\",\n      \"getAutomationRuns\": \"Rechercher les exécutions d'automatisation\",\n      \"foundAutomations\": \"{{count}} automatisation(s) trouvée(s)\",\n      \"moreAutomations\": \"... +{{count}} automatisation(s) de plus\",\n      \"foundRuns\": \"{{count}} exécution(s) trouvée(s)\",\n      \"moreRuns\": \"... +{{count}} exécution(s) de plus\",\n      \"active\": \"Actif\",\n      \"trigger\": \"Déclencheur\",\n      \"actions\": \"{{count}} action(s)\",\n      \"moreActions\": \"... +{{count}} action(s) de plus\",\n      \"getUserIntegrations\": \"Vérifier les intégrations\",\n      \"connectedIntegrations\": \"Connecté\",\n      \"availableToConnect\": \"Disponible à connecter\",\n      \"connect\": \"Connecter\",\n      \"noIntegrationsAvailable\": \"Aucune intégration disponible\",\n      \"activateTool\": \"Activer les outils\",\n      \"webSearch\": \"Recherche web\",\n      \"webSearchResults\": \"{{count}} résultat(s) trouvé(s)\",\n      \"webSearchCompleted\": \"Recherche terminée\",\n      \"searchApi\": \"Rechercher l'API\",\n      \"searchApiWithQuery\": \"Rechercher l'API · {{query}}\",\n      \"noApiFound\": \"Aucune API trouvée\",\n      \"foundApis\": \"{{count}} API(s) trouvée(s)\",\n      \"totalApis\": \"{{count}} API(s) au total disponibles\",\n      \"callApi\": \"Appeler l'API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Réponse\",\n      \"success\": \"Succès\",\n      \"failed\": \"Échec\",\n      \"inputData\": \"Données d'entrée\",\n      \"availableNodes\": \"Nœuds disponibles\",\n      \"hasPreviousCode\": \"Code existant\",\n      \"noInputData\": \"Aucune donnée d'entrée disponible\"\n    },\n    \"showUI\": {\n      \"connect\": \"Connecter\",\n      \"connecting\": \"Connexion en cours...\",\n      \"connected\": \"Connecté\",\n      \"connectToUse\": \"Connecter {{provider}} pour utiliser dans les automatisations\",\n      \"checkingConnection\": \"Vérification du statut de connexion...\",\n      \"confirm\": \"Confirmer\",\n      \"confirmed\": \"Confirmé\",\n      \"cancel\": \"Annuler\",\n      \"cancelled\": \"Annulé\",\n      \"connectionCancelled\": \"Connexion annulée\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} lignes masquées\",\n      \"collapseCode\": \"Réduire le code\",\n      \"code\": \"Code\",\n      \"preview\": \"Aperçu\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Progression du build\",\n      \"completed\": \"Construction de l'application terminée\",\n      \"completedDesc\": \"Toutes les étapes terminées avec succès, l'application est prête pour l'aperçu\",\n      \"stepStatus\": {\n        \"initializing\": \"Création de l'application et initialisation de l'environnement...\",\n        \"naming\": \"Génération du nom de l'application...\",\n        \"planning\": \"Analyse des besoins et planification du développement...\",\n        \"developing\": \"Écriture du code et mise en œuvre des fonctionnalités...\",\n        \"summarizing\": \"Organisation des résultats du développement...\",\n        \"deploying\": \"Déploiement vers l'environnement de prévisualisation...\",\n        \"testing\": \"Construction des tests...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"En cours\",\n        \"completed\": \"Terminé\",\n        \"error\": \"Échec\",\n        \"pending\": \"En attente\"\n      },\n      \"toolStatus\": {\n        \"running\": \"En cours\",\n        \"completed\": \"Terminé\",\n        \"error\": \"Échec\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Script généré avec succès\"\n    },\n    \"buildBase\": {\n      \"title\": \"Construire la base\",\n      \"generateSuccess\": \"Base de données générée avec succès\",\n      \"generateError\": \"Échec de la génération de la base de données\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Construire l'automatisation\",\n      \"generateSuccess\": \"Automatisation générée avec succès\"\n    },\n    \"automation\": {\n      \"created\": \"Créé\",\n      \"updated\": \"Mis à jour\",\n      \"workflow\": \"Flux de travail\",\n      \"trigger\": \"Déclencheur\",\n      \"scriptAction\": \"Action de script\",\n      \"workflowLabel\": \"Flux de travail\",\n      \"triggerLabel\": \"Déclencheur\",\n      \"scriptActionLabel\": \"Action de script\",\n      \"workflowId\": \"Flux de travail\",\n      \"triggerId\": \"Déclencheur\",\n      \"scriptActionId\": \"Action de script\",\n      \"viewAutomation\": \"Voir\",\n      \"navigateToAutomation\": \"Naviguer vers l'automatisation\",\n      \"triggerType\": {\n        \"recordCreated\": \"Enregistrement créé\",\n        \"recordUpdated\": \"Enregistrement mis à jour\",\n        \"recordCreatedOrUpdated\": \"Enregistrement créé ou mis à jour\",\n        \"formSubmitted\": \"Formulaire soumis\",\n        \"scheduledTime\": \"Heure planifiée\",\n        \"buttonClick\": \"Clic sur bouton\"\n      },\n      \"activated\": \"Activé\",\n      \"deactivated\": \"Désactivé\",\n      \"discarded\": \"Modifications annulées\",\n      \"activateFailed\": \"Échec de l'activation\",\n      \"deactivateFailed\": \"Échec de la désactivation\",\n      \"discardFailed\": \"Échec de l'annulation\",\n      \"scriptUpdated\": \"Script mis à jour\",\n      \"scriptUpdateFailed\": \"Échec de la mise à jour\",\n      \"scriptExecuted\": \"Script exécuté\",\n      \"scriptExecutionFailed\": \"Échec de l'exécution\",\n      \"scriptReady\": \"Script prêt\",\n      \"executingScript\": \"Exécution du script...\",\n      \"waitedSeconds\": \"Attendu {{seconds}}s\",\n      \"waitFailed\": \"Échec de l'attente\",\n      \"flowchartGenerated\": \"Diagramme de flux généré\",\n      \"flowchartGenerationFailed\": \"Échec de la génération\"\n    },\n    \"newChat\": \"Nouveau Chat\",\n    \"clearChat\": \"Effacer le chat\",\n    \"expand\": \"Développer\",\n    \"history\": \"Historique\",\n    \"close\": \"Réduire\",\n    \"clearChatConfirmTitle\": \"Confirmer l'effacement du chat\",\n    \"clearChatConfirmDesc\": \"Le contenu actuel du chat ne sera pas sauvegardé. Êtes-vous sûr de vouloir l'effacer ?\",\n    \"dontShowAgain\": \"Ne plus afficher\",\n    \"noModel\": \"Aucun modèle disponible\",\n    \"addAttachment\": \"Ajouter une pièce jointe\",\n    \"noHistory\": \"Aucun historique de chat\",\n    \"noFoundHistory\": \"Aucun historique de chat trouvé, veuillez commencer une nouvelle conversation\",\n    \"timeGroup\": {\n      \"today\": \"Aujourd'hui\",\n      \"oneWeek\": \"Une semaine\",\n      \"twoWeek\": \"Deux semaines\",\n      \"oneMonth\": \"Un mois\",\n      \"other\": \"Autre\"\n    },\n    \"context\": {\n      \"button\": \"Ajouter un contexte\",\n      \"search\": \"Ajouter des tables\",\n      \"searchEmpty\": \"Aucun contexte trouvé\",\n      \"emptyContext\": \"Aucun contexte à ajouter\",\n      \"selectionRows\": \"Lignes {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Envoyer un message...\",\n    \"thought\": \"En train de penser\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Temps de génération : {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} crédits consommés\",\n      \"tokenDescription\": \"Tokens utilisés : {{tokens}}\",\n      \"input\": \"Entrée\",\n      \"output\": \"Sortie\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"Temps total\",\n      \"totalCreditCost\": \"Coût total en crédits\",\n      \"customModel\": \"Modèle personnalisé\",\n      \"tokenDetails\": \"Détails des tokens\",\n      \"cachedInput\": \"Entrée en cache (90% de réduction)\",\n      \"cacheWrite\": \"Écriture en cache\",\n      \"reasoning\": \"Raisonnement (Réflexion)\",\n      \"taskCompleted\": \"Tâche terminée\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Échec de la visualisation des données\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Seuls les administrateurs peuvent configurer\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"L'image n'est pas prise en charge\",\n      \"attachmentSizeExceeded\": \"La taille de la pièce jointe dépasse la limite de {{size}} Mo\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Recommandé\",\n      \"ask\": \"Demander\",\n      \"analyze\": \"Analyser\",\n      \"build\": \"Construire\",\n      \"title\": \"Comment puis-je vous aider ?\",\n      \"whatCanIDo\": \"Que puis-je faire ?\",\n      \"createOrModifyDatabase\": \"Créer ou modifier la base de données\",\n      \"buildAutomations\": \"Construire des automatisations\",\n      \"buildApps\": \"Construire des applications\",\n      \"buildMeCRM\": \"Créer un CRM pour moi\",\n      \"addAIField\": \"Ajouter un champ IA pour analyser chaque client\",\n      \"createDataAnalysis\": \"Créer un rapport d'analyse de données\",\n      \"emailWhenRecordCreated\": \"M'envoyer un e-mail lors de la création d'un enregistrement\",\n      \"syncStatusToSlack\": \"Synchroniser les mises à jour de statut avec Slack\",\n      \"buildDashboard\": \"Créer un tableau de bord à partir de cette table\",\n      \"buildLeadCapture\": \"Créer une page d'accueil de capture de leads\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Réflexion pendant {{duration}}s\"\n      },\n      \"task\": {\n        \"searching\": \"Recherche de \\\"{{query}}\\\"\",\n        \"readingFiles\": \"Lecture des fichiers :\",\n        \"foundResults\": \"{{count}} résultat(s) trouvé(s)\",\n        \"noIssuesFound\": \"Aucun problème trouvé\",\n        \"defaultTitle\": \"Tâche\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Projet de code\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"Un modèle IA est requis pour générer l'aperçu.\",\n      \"writeCodeHint\": \"Écrivez du code pour voir l'aperçu du diagramme de flux.\",\n      \"noPreview\": \"Aucun aperçu de diagramme de flux disponible.\",\n      \"generatePreview\": \"Générer l'aperçu\",\n      \"analyzing\": \"Analyse du script...\",\n      \"codeChanged\": \"Le code a changé depuis la génération de cet aperçu.\",\n      \"regenerate\": \"Régénérer\",\n      \"refresh\": \"Actualiser\",\n      \"regenerating\": \"Régénération en cours...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Télécharger toutes les pièces jointes\",\n      \"loading\": \"Chargement de l'aperçu...\",\n      \"rowsWithAttachments\": \"{{count}} lignes avec pièces jointes\",\n      \"totalAttachments\": \"{{count}} pièces jointes\",\n      \"totalSize\": \"Taille totale : {{size}}\",\n      \"startDownload\": \"Démarrer le téléchargement\",\n      \"confirmTitle\": \"Télécharger {{count}} fichiers\",\n      \"confirmDescription\": \"Taille totale : {{size}}. Les fichiers seront compressés dans un fichier ZIP.\",\n      \"confirm\": \"Télécharger\",\n      \"cancel\": \"Annuler\",\n      \"downloading\": \"Téléchargement en cours...\",\n      \"downloadingFile\": \"Téléchargement : {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Téléchargement terminé\",\n      \"cancelled\": \"Téléchargement annulé\",\n      \"noAttachments\": \"Aucune pièce jointe à télécharger\",\n      \"error\": \"Échec du téléchargement\",\n      \"errorPartial\": \"{{failedCount}} fichiers n'ont pas pu être téléchargés\",\n      \"requireHttps\": \"Le téléchargement groupé nécessite HTTPS. Veuillez accéder via HTTPS ou localhost.\",\n      \"advancedOptions\": \"Options avancées\",\n      \"namingFieldLabel\": \"Préfixe du nom de pièce jointe\",\n      \"selectField\": \"Par défaut: index de pièce jointe\",\n      \"groupByRow\": \"Archiver dans des dossiers\",\n      \"groupByRowTip\": \"Lorsqu'une ligne contient plusieurs pièces jointes, elles seront placées dans le même dossier ; les lignes avec une seule pièce jointe ne créeront pas de dossier.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/token.json",
    "content": "{\n  \"access\": \"Accès\",\n  \"name\": \"Nom\",\n  \"description\": \"Description\",\n  \"scopes\": \"Périmètres\",\n  \"expiration\": \"Expiration\",\n  \"createdTime\": \"Créé le\",\n  \"lastUse\": \"Dernière utilisation\",\n  \"allSpace\": \"L'espace, toutes les bases actuelles et futures dans cet espace\",\n  \"formLabelTips\": {\n    \"name\": \"Fournissez un nom pour le jeton\",\n    \"description\": \"À quoi sert ce jeton ?\",\n    \"scopes\": \"Avec ce jeton, vous pourrez :\",\n    \"access\": \"Ce jeton peut accéder aux bases et espaces suivants. Vous ne pouvez accorder l'accès qu'aux bases et espaces auxquels vous avez accès.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Créer un nouveau jeton\",\n    \"title\": \"Les jetons d'accès personnel sont nécessaires pour utiliser l'API Teable.\",\n    \"description\": \"Ce jeton permettra d'accéder aux données dans les espaces et bases sélectionnés. Et d'autres, non liés aux espaces/bases, points de terminaison d'API. Utilisez ce jeton uniquement pour votre propre développement. Soyez prudent lors de son partage avec des services et applications tiers.\",\n    \"button\": \"Créer un nouveau jeton\",\n    \"success\": {\n      \"title\": \"Jeton généré avec succès\",\n      \"description\": \"Assurez-vous de copier votre jeton. Il ne sera plus jamais affiché.\"\n    },\n    \"expirationList\": {\n      \"days\": \"jours\",\n      \"permanent\": \"Permanent\",\n      \"custom\": \"Personnalisé\",\n      \"pick\": \"Choisissez une date\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Modifier le jeton\",\n    \"name\": \"Nom\",\n    \"scopes\": \"Périmètres\",\n    \"selectAll\": \"Sélectionner tout\",\n    \"cancelSelectAll\": \"Annuler la sélection de tout\"\n  },\n  \"refresh\": {\n    \"title\": \"Régénérer le jeton d'accès personnel\",\n    \"description\": \"Soumettre ce formulaire générera un nouveau jeton. Sachez que tous les scripts ou applications utilisant ce jeton devront être mis à jour.\",\n    \"button\": \"Régénérer le jeton\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Ajouter une base ou un espace\",\n    \"empty\": \"Aucun accès trouvé.\",\n    \"spaceSelectItem\": \"Toutes les bases dans l'espace\",\n    \"inputPlaceholder\": \"Trouver un espace ou une base...\"\n  },\n  \"moreScopes\": \"et {{len}} autres\",\n  \"list\": {\n    \"description\": \"Les jetons d'accès personnels sont nécessaires pour utiliser l'API Teable. Veuillez consulter la <a>documentation d'aide</a> pour plus d'informations.\"\n  },\n  \"empty\": {\n    \"list\": \"Aucun jeton d'accès personnel trouvé.\",\n    \"access\": \"Aucun accès\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Êtes-vous sûr de vouloir supprimer ce jeton ?\",\n    \"description\": \"Toutes les applications ou scripts utilisant ce jeton ne pourront plus accéder à l'API Teable. Cette action est irréversible.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/fr/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Type attendu : {{expected}}, reçu : {{received}}\",\n    \"invalid_type_received_undefined\": \"Requis\",\n    \"invalid_type_received_null\": \"Requis\",\n    \"invalid_literal\": \"Valeur littérale invalide, attendu : {{expected}}\",\n    \"unrecognized_keys\": \"Clé(s) non reconnue(s) dans l'objet : {{- keys}}\",\n    \"invalid_union\": \"Entrée invalide\",\n    \"invalid_union_discriminator\": \"Valeur de discriminant invalide. Attendu : {{- options}}\",\n    \"invalid_enum_value\": \"Valeur d'énumération invalide. Attendu : {{- options}}, reçu : '{{received}}'\",\n    \"invalid_arguments\": \"Arguments de fonction invalides\",\n    \"invalid_return_type\": \"Type de retour de fonction invalide\",\n    \"invalid_date\": \"Date invalide\",\n    \"custom\": \"Entrée invalide\",\n    \"invalid_intersection_types\": \"Les résultats d'intersection n'ont pas pu être fusionnés\",\n    \"not_multiple_of\": \"Le nombre doit être un multiple de {{multipleOf}}\",\n    \"not_finite\": \"Le nombre doit être fini\",\n    \"invalid_string\": {\n      \"email\": \"{{validation}} invalide\",\n      \"url\": \"{{validation}} invalide\",\n      \"uuid\": \"{{validation}} invalide\",\n      \"cuid\": \"{{validation}} invalide\",\n      \"regex\": \"Invalide\",\n      \"datetime\": \"{{validation}} invalide\",\n      \"startsWith\": \"Entrée invalide : doit commencer par \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Entrée invalide : doit se terminer par \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Le tableau doit contenir exactement {{minimum}} élément(s)\",\n        \"inclusive\": \"Le tableau doit contenir au moins {{minimum}} élément(s)\",\n        \"not_inclusive\": \"Le tableau doit contenir plus de {{minimum}} élément(s)\"\n      },\n      \"string\": {\n        \"exact\": \"La chaîne doit contenir exactement {{minimum}} caractère(s)\",\n        \"inclusive\": \"La chaîne doit contenir au moins {{minimum}} caractère(s)\",\n        \"not_inclusive\": \"La chaîne doit contenir plus de {{minimum}} caractère(s)\"\n      },\n      \"number\": {\n        \"exact\": \"Le nombre doit être exactement {{minimum}}\",\n        \"inclusive\": \"Le nombre doit être supérieur ou égal à {{minimum}}\",\n        \"not_inclusive\": \"Le nombre doit être supérieur à {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Entrée invalide\",\n        \"inclusive\": \"Entrée invalide\",\n        \"not_inclusive\": \"Entrée invalide\"\n      },\n      \"date\": {\n        \"exact\": \"La date doit être exactement {{- minimum, datetime}}\",\n        \"inclusive\": \"La date doit être postérieure ou égale à {{- minimum, datetime}}\",\n        \"not_inclusive\": \"La date doit être postérieure à {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Le tableau doit contenir exactement {{maximum}} élément(s)\",\n        \"inclusive\": \"Le tableau doit contenir au maximum {{maximum}} élément(s)\",\n        \"not_inclusive\": \"Le tableau doit contenir moins de {{maximum}} élément(s)\"\n      },\n      \"string\": {\n        \"exact\": \"La chaîne doit contenir exactement {{maximum}} caractère(s)\",\n        \"inclusive\": \"La chaîne doit contenir au maximum {{maximum}} caractère(s)\",\n        \"not_inclusive\": \"La chaîne doit contenir moins de {{maximum}} caractère(s)\"\n      },\n      \"number\": {\n        \"exact\": \"Le nombre doit être exactement {{maximum}}\",\n        \"inclusive\": \"Le nombre doit être inférieur ou égal à {{maximum}}\",\n        \"not_inclusive\": \"Le nombre doit être inférieur à {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Entrée invalide\",\n        \"inclusive\": \"Entrée invalide\",\n        \"not_inclusive\": \"Entrée invalide\"\n      },\n      \"date\": {\n        \"exact\": \"La date doit être exactement {{- maximum, datetime}}\",\n        \"inclusive\": \"La date doit être antérieure ou égale à {{- maximum, datetime}}\",\n        \"not_inclusive\": \"La date doit être antérieure à {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"fonction\",\n    \"number\": \"nombre\",\n    \"string\": \"chaîne\",\n    \"nan\": \"nan\",\n    \"integer\": \"entier\",\n    \"float\": \"flottant\",\n    \"boolean\": \"booléen\",\n    \"date\": \"date\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"indéfini\",\n    \"symbol\": \"symbole\",\n    \"null\": \"nul\",\n    \"array\": \"tableau\",\n    \"object\": \"objet\",\n    \"unknown\": \"inconnu\",\n    \"promise\": \"promesse\",\n    \"void\": \"vide\",\n    \"never\": \"jamais\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"Accedi\",\n    \"signup\": \"Registrati\"\n  },\n  \"title\": {\n    \"signin\": \"Accedi al tuo account\",\n    \"signup\": \"Crea un account per accedere\"\n  },\n  \"content\": {\n    \"title\": \"Dove i Dati Fluiscono, i Team Crescono\",\n    \"description\": \"Un database progettato per ogni team, dalle semplici tabelle alle soluzioni aziendali\"\n  },\n  \"legal\": {\n    \"tip\": \"Continuando, accetti i <Terms>Termini di servizio</Terms> e la <Privacy>Privacy Policy</Privacy> di Teable, e riceverai email periodiche con aggiornamenti.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"button\": {\n    \"signin\": \"Accedi\",\n    \"signup\": \"Registrati\",\n    \"resend\": \"Reinvia\"\n  },\n  \"label\": {\n    \"email\": \"Email\",\n    \"password\": \"Password\",\n    \"verificationCode\": \"Codice di verifica\"\n  },\n  \"placeholder\": {\n    \"password\": \"Inserisci la tua password...\",\n    \"email\": \"Inserisci la tua email...\",\n    \"verificationCode\": \"Inserisci il tuo codice di verifica...\"\n  },\n  \"signError\": {\n    \"exist\": \"L'email è già stata registrata\",\n    \"incorrect\": \"Email o password non corretti\",\n    \"tooManyRequests\": \"Il tuo account è stato bloccato, riprova tra {{minutes}} minuti\",\n    \"turnstileRequired\": \"Si prega di completare la sfida di verifica\",\n    \"turnstileError\": \"Verifica fallita. Si prega di riprovare\",\n    \"turnstileExpired\": \"Verifica scaduta. Si prega di riprovare\",\n    \"turnstileTimeout\": \"Verifica scaduta. Si prega di riprovare\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"Il codice di verifica è obbligatorio\",\n    \"verificationCodeInvalid\": \"Il codice di verifica non è valido\",\n    \"passwordLength\": \"Minimo 8 caratteri\",\n    \"passwordInvalid\": \"La password deve contenere almeno una lettera e un numero\"\n  },\n  \"socialAuth\": {\n    \"title\": \"Oppure continua con\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Imposta la tua password\",\n    \"description\": \"Inserisci una nuova password\",\n    \"label\": \"Nuova password\",\n    \"error\": {\n      \"requiredPassword\": \"Inserisci la password\",\n      \"invalidLink\": \"Link per il reset della password non valido\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Reset della password riuscito\",\n      \"description\": \"La tua password è stata reimpostata con successo. Verrai reindirizzato alla pagina di accesso.\"\n    },\n    \"buttonText\": \"Conferma password\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Password dimenticata?\",\n    \"header\": \"Reimposta la tua password\",\n    \"description\": \"Inserisci il tuo indirizzo email qui sotto e ti invieremo un link per reimpostare la tua password.\",\n    \"errorRequiredEmail\": \"L'email è obbligatoria\",\n    \"errorInvalidEmail\": \"Email non valida\",\n    \"buttonText\": \"Invia Email di Reset\",\n    \"success\": {\n      \"title\": \"🎉 Email di reset della password inviata\",\n      \"description\": \"Ti abbiamo inviato un'email con un link per reimpostare la tua password. Controlla la tua casella di posta.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/chart.json",
    "content": "{\n  \"notBaseId\": \"BaseId mancante\",\n  \"notPositionId\": \"PositionId mancante\",\n  \"notPluginInstallId\": \"PluginInstallId mancante\",\n  \"initBridge\": \"Inizializzazione bridge...\",\n  \"actions\": {\n    \"cancel\": \"Annulla\",\n    \"save\": \"Salva\"\n  },\n  \"queryTitle\": \"Configurazione query dati\",\n  \"notSupport\": \"Non supportato\",\n  \"chart\": {\n    \"bar\": \"Barre\",\n    \"line\": \"Linee\",\n    \"pie\": \"Torta\",\n    \"area\": \"Area\",\n    \"table\": \"Tabella\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Seleziona tipo di grafico\",\n      \"label\": \"Tipo di grafico\"\n    },\n    \"pie\": {\n      \"dimension\": \"Dimensione\",\n      \"measure\": \"Misura\",\n      \"showTotal\": \"Mostra totale\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"Asse X\",\n        \"placeholder\": \"Seleziona asse X\"\n      },\n      \"yAxis\": {\n        \"label\": \"Asse Y\",\n        \"placeholder\": \"Seleziona asse Y\",\n        \"position\": \"Posizione asse Y\"\n      },\n      \"xDisplay\": {\n        \"label\": \"Visualizzazione X\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Visualizzazione Y\"\n      },\n      \"addXAxis\": \"Aggiungi suddivisione serie\",\n      \"addYAxis\": \"Aggiungi un'altra serie\",\n      \"stack\": \"Sovrapponi\",\n      \"position\": {\n        \"label\": \"Posizione\",\n        \"auto\": \"Automatico\",\n        \"left\": \"Sinistra\",\n        \"right\": \"Destra\"\n      },\n      \"goalLine\": {\n        \"label\": \"Linea obiettivo\"\n      },\n      \"range\": {\n        \"label\": \"Intervallo\",\n        \"min\": \"Minimo\",\n        \"max\": \"Massimo\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Stile linea\",\n        \"normal\": \"Normale\",\n        \"linear\": \"Lineare\",\n        \"step\": \"A gradini\"\n      },\n      \"displayType\": \"Tipo di visualizzazione\"\n    },\n    \"typeError\": \"Modulo: Tipo di grafico non supportato\",\n    \"updateQuery\": \"Aggiorna query\",\n    \"queryError\": \"Errore query\",\n    \"querySuccess\": \"Query configurata\",\n    \"decimal\": \"Decimale\",\n    \"prefix\": \"Prefisso\",\n    \"suffix\": \"Suffisso\",\n    \"showLabel\": \"Mostra etichette valori sul grafico\",\n    \"showLegend\": \"Mostra leggenda\",\n    \"value\": \"Valore\",\n    \"label\": \"Etichetta\",\n    \"padding\": {\n      \"label\": \"Spaziatura\",\n      \"top\": \"Alto\",\n      \"right\": \"Destra\",\n      \"bottom\": \"Basso\",\n      \"left\": \"Sinistra\"\n    },\n    \"tableConfig\": \"Configurazione tabella\",\n    \"width\": \"Larghezza\"\n  },\n  \"reloadQuery\": \"Ricarica query\",\n  \"noStorage\": \"Si prega di configurare prima il plugin grafico\",\n  \"noPermission\": \"Nessuna autorizzazione di accesso\",\n  \"goConfig\": \"Vai alla configurazione\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Azioni\",\n    \"add\": \"Aggiungi\",\n    \"save\": \"Salva\",\n    \"doNotSave\": \"Non salvare\",\n    \"submit\": \"Invia\",\n    \"confirm\": \"Conferma\",\n    \"close\": \"Chiudi\",\n    \"edit\": \"Modifica\",\n    \"fill\": \"Compila\",\n    \"update\": \"Aggiorna\",\n    \"create\": \"Crea\",\n    \"delete\": \"Elimina\",\n    \"cancel\": \"Annulla\",\n    \"zoomIn\": \"Ingrandisci\",\n    \"zoomOut\": \"Riduci\",\n    \"back\": \"Indietro\",\n    \"remove\": \"Rimuovi\",\n    \"removeConfig\": \"Rimuovi configurazione\",\n    \"saveSucceed\": \"Salvataggio riuscito!\",\n    \"submitSucceed\": \"Invio riuscito!\",\n    \"editSucceed\": \"Modifica riuscita!\",\n    \"updateSucceed\": \"Aggiornamento riuscito!\",\n    \"deleteSucceed\": \"Eliminazione riuscita!\",\n    \"resetSucceed\": \"Cestino svuotato con successo!\",\n    \"restoreSucceed\": \"Ripristino riuscito!\",\n    \"loading\": \"Caricamento...\",\n    \"refreshPage\": \"Ricarica pagina\",\n    \"yesDelete\": \"Sì, elimina\",\n    \"rename\": \"Rinomina\",\n    \"duplicate\": \"Duplica\",\n    \"change\": \"Cambia\",\n    \"upgrade\": \"Aggiorna\",\n    \"upgradeToLevel\": \"Aggiorna a {{level}}\",\n    \"search\": \"Cerca\",\n    \"loadMore\": \"Carica altro\",\n    \"collapseSidebar\": \"Comprimi barra laterale\",\n    \"restore\": \"Ripristina\",\n    \"permanentDelete\": \"Eliminazione permanente\",\n    \"globalSearch\": \"Ricerca globale\",\n    \"fieldSearch\": \"Cerca campo\",\n    \"tableIndex\": \"Indice\",\n    \"showAllRow\": \"Mostra tutte le righe\",\n    \"hideNotMatchRow\": \"Nascondi righe non corrispondenti\",\n    \"more\": \"Altro\",\n    \"move\": \"Sposta in\",\n    \"turnOn\": \"Attiva\",\n    \"exit\": \"Esci\",\n    \"next\": \"Successivo\",\n    \"previous\": \"Precedente\",\n    \"select\": \"Seleziona\",\n    \"view\": \"Visualizza\",\n    \"preview\": \"Anteprima\",\n    \"viewAndEdit\": \"Visualizza e modifica\",\n    \"deleteTip\": \"Sei sicuro di voler eliminare \\\"{{name}}\\\"?\",\n    \"refresh\": \"Aggiorna\",\n    \"login\": \"Accedi\",\n    \"useTemplate\": \"Usa modello\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Torna allo Spazio\",\n    \"switchBase\": \"Cambia Base\",\n    \"continue\": \"Continua\",\n    \"export\": \"Esporta\",\n    \"import\": \"Importa\",\n    \"expand\": \"Espandi\",\n    \"getMore\": \"Ottieni di più\",\n    \"copySuccess\": \"Copia riuscita\"\n  },\n  \"quickAction\": {\n    \"title\": \"Azioni rapide\",\n    \"placeHolder\": \"Digita un comando o cerca...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"La password non è valida, deve contenere almeno 8 caratteri e almeno una lettera e un numero.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Condividi\",\n      \"copy\": \"Copiato\"\n    },\n    \"aiTitle\": \"Costruiamo insieme\",\n    \"aiGreeting\": \"Come posso aiutarti, {{name}}?\",\n    \"aiSubTitle\": \"La prima piattaforma AI dove i team collaborano sui dati e generano app di produzione\",\n    \"guideTitle\": \"Inizia dal tuo scenario\",\n    \"watchVideo\": \"Guarda il video\",\n    \"title\": \"Modello\",\n    \"description\": \"Crea un nuovo database da un modello\",\n    \"browseAll\": \"Sfoglia Tutti\",\n    \"templateTitle\": \"Inizia con modello\",\n    \"loadMore\": \"Carica Altro\",\n    \"allTemplatesLoaded\": \"Tutti i modelli caricati\",\n    \"createTemplate\": \"Crea Modello\",\n    \"useTemplateDialog\": {\n      \"title\": \"Seleziona spazio\",\n      \"description\": \"Seleziona uno spazio per il modello\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Costruisci la tua app aziendale con Teable\",\n      \"start\": \"Inizia\",\n      \"carouselGuides\": {\n        \"guide1\": \"Incolla ricevute → Chiedi a Teable di estrarre automaticamente i dati chiave\",\n        \"guide2\": \"Costruisci un CRM AI con tabelle di lead e follow-up\",\n        \"guide3\": \"Incolla feedback dei clienti → Chiedi a Teable di trovare insights e generare report\",\n        \"guide4\": \"Crea un tracker di progetti con attività e scadenze\",\n        \"guide5\": \"Incolla foglio di lead → Chiedi a Teable di costruire un CRM intelligente\",\n        \"guide6\": \"Costruisci un pianificatore di contenuti con post e date di pubblicazione\",\n        \"guide7\": \"Incolla curriculum → Chiedi a Teable di organizzare e selezionare candidati\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Copia nello spazio\",\n      \"description\": \"Seleziona uno spazio in cui copiare questo database\",\n      \"baseName\": \"Nome database\",\n      \"baseNamePlaceholder\": \"Inserisci nome database\",\n      \"selectSpace\": \"Seleziona spazio\",\n      \"noSpaceDescription\": \"Nessuno spazio gestibile. Crea un nuovo spazio per continuare.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"Nessuna base gestibile in questo spazio. Seleziona \\\"Nuova base\\\".\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Impostazioni dell'istanza\",\n    \"personal\": {\n      \"title\": \"Impostazioni personali\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"Gestione modelli\",\n      \"noData\": \"Nessun dato\",\n      \"importing\": \"Importazione...\",\n      \"usageCount\": \"Conteggio utilizzo: {{count}}\",\n      \"useTemplate\": \"Usa questo modello\",\n      \"createdBy\": \"da {{user}}\",\n      \"backToTemplateList\": \"Torna all'elenco modelli\",\n      \"tips\": {\n        \"errorCategoryName\": \"La categoria non esiste o è stata eliminata\",\n        \"needSnapshot\": \"Prima di pubblicare, crea uno snapshot, il nome e la descrizione del modello non possono essere vuoti\",\n        \"needPublish\": \"Pubblica il modello prima di impostarlo come in evidenza\",\n        \"needBaseSource\": \"Seleziona una base sorgente prima di creare uno snapshot\",\n        \"forbiddenUpdateSystemTemplate\": \"Il modello di sistema non può essere modificato\",\n        \"addCategoryTips\": \"Inserisci prima un nome categoria nell'input di ricerca.\",\n        \"categoryNamePlaceholder\": \"Inserisci nome categoria\",\n        \"duplicateCategoryName\": \"Il nome della categoria esiste già\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Inizia\",\n          \"recommended\": \"Consigliati\",\n          \"all\": \"Tutti\",\n          \"browseByCategory\": \"Sfoglia per categoria\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Copertina\",\n        \"name\": \"Nome\",\n        \"description\": \"Descrizione\",\n        \"markdownDescription\": \"Descrizione markdown\",\n        \"category\": \"Categoria\",\n        \"isSystem\": \"Sistema\",\n        \"source\": \"Fonte\",\n        \"status\": \"Pubblicato\",\n        \"publishSnapshot\": \"Pubblica snapshot\",\n        \"snapshotTime\": \"Ora snapshot\",\n        \"actions\": \"Azioni\",\n        \"featured\": \"In evidenza\",\n        \"createdBy\": \"Creato da\",\n        \"userNonExistent\": \"Utente non esistente\",\n        \"preview\": \"Anteprima\",\n        \"usage\": \"Utilizzo\",\n        \"visit\": \"Visite\"\n      },\n      \"actions\": {\n        \"title\": \"Azioni\",\n        \"publish\": \"Pubblica\",\n        \"delete\": \"Elimina\",\n        \"duplicate\": \"Duplica\",\n        \"preview\": \"Anteprima\",\n        \"use\": \"Usa\",\n        \"pinTop\": \"Fissa in alto\",\n        \"addCategory\": \"Aggiungi categoria\",\n        \"selectCategory\": \"Seleziona categoria\",\n        \"viewTemplate\": \"Visualizza modello\",\n        \"manageCategory\": \"Gestisci categorie\"\n      },\n      \"relatedTemplates\": \"Modelli correlati\",\n      \"noImage\": \"Nessuna immagine\",\n      \"baseSelectPanel\": {\n        \"title\": \"Seleziona fonte modello\",\n        \"description\": \"Seleziona una base come modello\",\n        \"confirm\": \"Conferma\",\n        \"search\": \"Cerca...\",\n        \"cancel\": \"Annulla\",\n        \"selectBase\": \"Seleziona base\",\n        \"createTemplate\": \"Crea modello\",\n        \"abnormalBase\": \"La base non esiste o è stata eliminata\"\n      }\n    },\n    \"back\": \"Torna alla home\",\n    \"account\": {\n      \"title\": \"Il mio profilo\",\n      \"tab\": \"Il mio account\",\n      \"updatePhoto\": \"Aggiorna foto\",\n      \"updateNameDesc\": \"Soprannome o nome, come preferisci essere chiamato su Teable\",\n      \"securityTitle\": \"Sicurezza dell'account\",\n      \"email\": \"Email\",\n      \"password\": \"Password\",\n      \"passwordDesc\": \"Imposta una password permanente per accedere al tuo account.\",\n      \"changePassword\": {\n        \"title\": \"Modifica password\",\n        \"desc\": \"Inserisci la password attuale e impostane una nuova\",\n        \"current\": \"Password attuale\",\n        \"new\": \"Nuova password\",\n        \"confirm\": \"Conferma nuova password\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"La tua nuova password non corrisponde.\",\n        \"equal\": \"La tua nuova password deve essere diversa dalla tua password attuale.\",\n        \"invalid\": \"La tua password attuale non è valida.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Cambio password riuscito.\",\n        \"desc\": \"Verrai reindirizzato alla pagina di accesso in 2 secondi.\"\n      },\n      \"manageToken\": \"Token di accesso\",\n      \"addPassword\": {\n        \"title\": \"Aggiungi password\",\n        \"desc\": \"Imposta una password permanente per accedere al tuo account.\",\n        \"password\": \"Inserisci la tua password\",\n        \"confirm\": \"Conferma la tua password\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"La tua password non corrisponde.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Aggiunta password riuscita.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Elimina account\",\n        \"desc\": \"Questa azione è irreversibile. Eliminerà permanentemente il tuo account e tutti i dati associati.\",\n        \"error\": {\n          \"title\": \"Impossibile eliminare l'account\",\n          \"desc\": \"Devi prima gestire le seguenti dipendenze:\",\n          \"spacesError\": \"Prima di eliminare il tuo account, devi prima uscire (o eliminare, poi spostare nel cestino) dai tuoi Spazi.\"\n        },\n        \"confirm\": {\n          \"title\": \"Digita <code>DELETE</code> per confermare\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Eliminazione in corso...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Modifica indirizzo email\",\n        \"desc\": \"Verifica la tua password e conferma il tuo nuovo indirizzo email\",\n        \"current\": \"Password attuale\",\n        \"new\": \"Nuova email\",\n        \"code\": \"Codice di verifica\",\n        \"getCode\": \"Invia codice\",\n        \"error\": {\n          \"invalidCode\": \"Il codice di verifica non è valido.\",\n          \"invalidPassword\": \"La password non è valida.\",\n          \"invalidConflict\": \"La nuova email è già registrata.\",\n          \"invalidSameEmail\": \"La nuova email è uguale a quella attuale.\",\n          \"sendMailRateLimit\": \"Attendi {{seconds}} secondi prima di inviare una nuova email\"\n        },\n        \"success\": {\n          \"title\": \"🎉 Email modificata con successo.\",\n          \"desc\": \"Sarai reindirizzato alla pagina di login tra 2 secondi.\",\n          \"sendSuccess\": \"Codice di verifica inviato con successo.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Le mie notifiche\",\n      \"label\": \"Attività nel tuo spazio\",\n      \"desc\": \"Ricevi email quando ricevi commenti, menzioni, inviti a pagine, promemoria, richieste di accesso e modifiche alle proprietà.\"\n    },\n    \"setting\": {\n      \"title\": \"Le mie impostazioni\",\n      \"theme\": \"Tema interfaccia\",\n      \"themeDesc\": \"Seleziona lo schema di colori dell'interfaccia\",\n      \"dark\": \"Scuro\",\n      \"light\": \"Chiaro\",\n      \"system\": \"Sistema\",\n      \"version\": \"Versione dell'app\",\n      \"language\": \"Lingua\",\n      \"interactionMode\": \"Modalità di interazione\",\n      \"mouseMode\": \"Modalità cursore\",\n      \"touchMode\": \"Modalità touch\",\n      \"systemMode\": \"Segui le impostazioni di sistema\"\n    },\n    \"nav\": {\n      \"settings\": \"Impostazioni\",\n      \"logout\": \"Esci\",\n      \"contactSupport\": \"Contatta l'assistenza\"\n    },\n    \"integration\": {\n      \"title\": \"Integrazioni\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"Integrazioni di terze parti\",\n        \"description\": \"Hai concesso l'accesso al tuo account a {{count}} applicazioni.\",\n        \"lastUsed\": \"Ultimo utilizzo {{date}}\",\n        \"revoke\": \"Revoca\",\n        \"owner\": \"Di proprietà di {{user}}\",\n        \"revokeTitle\": \"Sei sicuro di voler revocare l'autorizzazione?\",\n        \"revokeDesc\": \"{{name}} non sarà più in grado di accedere all'API di Teable. Non puoi annullare questa azione.\",\n        \"scopeTitle\": \"Permessi\",\n        \"scopeDesc\": \"Questa applicazione sarà in grado di ottenere i seguenti ambiti:\"\n      },\n      \"userIntegration\": {\n        \"title\": \"Account connessi\",\n        \"description\": \"Connetti account esterni per consentire a Teable di accedere alle tue risorse.\",\n        \"emptyDescription\": \"Nessun account connesso\",\n        \"actions\": {\n          \"reconnect\": \"Riconnetti\"\n        },\n        \"slack\": {\n          \"user\": \"Utente Slack\",\n          \"workspace\": \"Area di lavoro Slack\"\n        },\n        \"email\": {\n          \"user\": \"Utente\",\n          \"email\": \"Email\"\n        },\n        \"deleteTitle\": \"Rimuovi account connesso\",\n        \"deleteDesc\": \"Sei sicuro di voler rimuovere {{name}}?\",\n        \"create\": \"Connetti nuovo account\",\n        \"manage\": \"Gestisci account connessi\",\n        \"searchPlaceholder\": \"Cerca account connessi\",\n        \"defaultName\": \"Integrazione {{name}}\",\n        \"callback\": {\n          \"error\": \"Autorizzazione fallita\",\n          \"title\": \"Autorizzazione riuscita\",\n          \"desc\": \"Puoi chiudere questa finestra ora.\"\n        }\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tabella\",\n    \"view\": \"Vista\",\n    \"space\": \"Spazio\",\n    \"base\": \"Base\",\n    \"field\": \"Campo\",\n    \"record\": \"Record\",\n    \"dashboard\": \"Dashboard\",\n    \"automation\": \"Automazione\",\n    \"authorityMatrix\": \"Matrice di autorità\",\n    \"design\": \"Design\",\n    \"adminPanel\": \"Amministrazione di sistema\",\n    \"license\": \"Licenza self-hosted\",\n    \"instanceId\": \"ID istanza\",\n    \"beta\": \"Beta\",\n    \"trash\": \"Cestino\",\n    \"global\": \"Globale\",\n    \"organizationPanel\": \"Impostazioni organizzazione\",\n    \"unknownError\": \"Errore sconosciuto\",\n    \"pluginPanel\": \"Pannello\",\n    \"pluginContextMenu\": \"Menu contestuale\",\n    \"plugin\": \"Plugin\",\n    \"copy\": \"Copia\",\n    \"credits\": \"Crediti\",\n    \"aiChat\": \"Chat AI\",\n    \"app\": \"App\",\n    \"webSearch\": \"Ricerca web\",\n    \"folder\": \"Cartella\",\n    \"newAutomation\": \"Nuova automazione\",\n    \"newApp\": \"Nuova applicazione\",\n    \"newFolder\": \"Nuova cartella\",\n    \"template\": \"Modello\"\n  },\n  \"level\": {\n    \"free\": \"Gratuito\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"Business\",\n    \"enterprise\": \"Enterprise\"\n  },\n  \"noResult\": \"Nessun risultato.\",\n  \"allNodes\": \"Tutti i nodi\",\n  \"noDescription\": \"Nessuna descrizione\",\n  \"untitled\": \"Senza titolo\",\n  \"name\": \"Nome\",\n  \"description\": \"Descrizione\",\n  \"required\": \"Obbligatorio\",\n  \"characters\": \"caratteri\",\n  \"atLeastOne\": \"Riserva almeno un {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Precedente\",\n    \"next\": \"Successivo\",\n    \"done\": \"Fatto\",\n    \"skip\": \"Salta\",\n    \"createSpaceTooltipTitle\": \"Crea uno spazio\",\n    \"createSpaceTooltipContent\": \"Teable è organizzato in spazi, dove ogni spazio invita gli utenti a collaborare. <br></br>Gli spazi in Teable servono come elemento di navigazione principale all'interno della barra dei menu, offrendo una piattaforma fondamentale per gli utenti per aggiungere e gestire database secondo necessità.\",\n    \"createBaseTooltipTitle\": \"Crea una base\",\n    \"createBaseTooltipContent\": \"Una base (abbreviazione di \\\"database\\\") è un luogo per memorizzare dati importanti e i flussi di lavoro che ne dipendono.\",\n    \"createTableTooltipTitle\": \"Crea una tabella\",\n    \"createTableTooltipContent\": \"Le tabelle sono progettate per gestire in modo efficiente set di dati diversi, offrendo una visualizzazione versatile delle informazioni attraverso vari tipi di dati.\",\n    \"createViewTooltipTitle\": \"Crea una vista\",\n    \"createViewTooltipContent\": \"Attualmente, gli utenti possono creare viste Griglia, Galleria, Kanban e Modulo, con viste Calendario previste per i futuri rilasci. <br></br>Questa varietà fornisce agli utenti un toolkit completo per vari compiti di gestione dei dati.\",\n    \"viewFilteringTooltipTitle\": \"Filtraggio dei record\",\n    \"viewFilteringTooltipContent\": \"Una delle caratteristiche principali delle viste è la possibilità di filtrare i record da una vista in base alle condizioni impostate. <br></br>Quando un record viene filtrato in base a una condizione, non viene eliminato—è solo nascosto dalla vista particolare che stai utilizzando per guardare la tua tabella.\",\n    \"viewSortingTooltipTitle\": \"Ordinamento dei record\",\n    \"viewSortingTooltipContent\": \"Mentre sei in una vista, puoi ordinare i tuoi record in modo che appaiano in un ordine particolare in base ai valori in campi specifici. <br></br>Ordinare i tuoi record in una vista non influisce sull'ordine dei record in altre viste—si applica solo alla vista che stai utilizzando per guardare la tua tabella.\",\n    \"viewGroupingTooltipTitle\": \"Raggruppamento dei record\",\n    \"viewGroupingTooltipContent\": \"Raggruppare i record consente ai creatori di costruire un insieme di una o più condizioni che aiuteranno a categorizzare il set di dati presentato all'interno di una vista particolare.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable offre una potente API che supporta quasi tutte le funzionalità del prodotto, consentendo agli sviluppatori di effettuare chiamate utilizzando un <a>Token</a>.\"\n  },\n  \"token\": \"Token\",\n  \"poweredBy\": \"Offerto da <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"Condivisione dello spazio {{spaceName}}\",\n      \"desc_one\": \"Questo spazio ha <b>{{count}} collaboratore</b>. Aggiungere un collaboratore dello spazio darà loro accesso a tutte le basi all'interno di questo spazio.\",\n      \"desc_other\": \"Questo spazio ha <b>{{count}} collaboratori</b>. Aggiungere un collaboratore dello spazio darà loro accesso a tutte le basi all'interno di questo spazio.\",\n      \"tabEmail\": \"Invita via email\",\n      \"emailPlaceholder\": \"Inserisci gli indirizzi email, separandoli con Invio\",\n      \"tabLink\": \"Invita tramite link\",\n      \"linkPlaceholder\": \"Crea un link di invito che concede <0/> accesso a chiunque lo apra.\",\n      \"emailSend\": \"Invia invito\",\n      \"linkSend\": \"Crea link\",\n      \"spaceTitle\": \"Collaboratori dello spazio\",\n      \"collaboratorSearchPlaceholder\": \"Trova un collaboratore dello spazio per nome o email\",\n      \"collaboratorJoin\": \"si è unito {{joinTime}}\",\n      \"collaboratorRemove\": \"Rimuovi collaboratore\",\n      \"linkTitle\": \"Link di invito\",\n      \"linkCreatedTime\": \"creato {{createdTime}}\",\n      \"linkCopySuccess\": \"Link copiato\",\n      \"linkRemove\": \"Rimuovi link\"\n    },\n    \"base\": {\n      \"title\": \"Condividi {{baseName}}\",\n      \"desc_one\": \"Questa base è condivisa con {{count}} collaboratore.\",\n      \"desc_other\": \"Questa base è condivisa con {{count}} collaboratori.\",\n      \"baseTitle\": \"Collaboratori della base\",\n      \"collaboratorSearchPlaceholder\": \"Trova un collaboratore della base per nome o email\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Aggiungi collaboratore dell'organizzazione\",\n      \"placeholder\": \"Seleziona membro dell'organizzazione o dipartimento\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Aiuto\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"I permessi della pagina sono stati aggiornati. Si prega di aggiornare la pagina per vedere il contenuto più recente.\",\n  \"listEmptyTips\": \"La lista è vuota\",\n  \"billing\": {\n    \"overLimits\": \"Oltre i limiti\",\n    \"overLimitsDescription\": \"Il tuo abbonamento attuale ha superato il limite di utilizzo. Si prega di aggiornare il piano per continuare a utilizzare questa funzione senza interruzioni.\",\n    \"userLimitExceededDescription\": \"L'istanza corrente ha raggiunto il numero massimo di utenti consentiti dalla tua licenza. Si prega di disattivare alcuni utenti o aggiornare la licenza.\",\n    \"unavailableInPlanTips\": \"Il piano di abbonamento attuale non supporta questa funzione\",\n    \"unavailableConnectionTips\": \"La funzione di connessione al database verrà rimossa in futuro e sarà disponibile solo nella versione Enterprise e nelle versioni self-hosted.\",\n    \"levelTips\": \"Questo spazio è attualmente sul piano {{level}}\",\n    \"enterpriseFeature\": \"Funzionalità Enterprise\",\n    \"automationRequiresUpgrade\": \"Aggiorna a Enterprise Edition (EE) per abilitare l'automazione\",\n    \"authorityMatrixRequiresUpgrade\": \"Aggiorna a Enterprise Edition (EE) per abilitare la matrice di autorità\",\n    \"viewPricing\": \"Visualizza prezzi\",\n    \"billable\": \"Fatturabile\",\n    \"billableByAuthorityMatrix\": \"Fatturazione generata dalla matrice dei permessi\",\n    \"licenseExpiredGracePeriod\": \"La tua licenza self-hosted è scaduta e verrà declassata al piano gratuito il {{expiredTime}}. Aggiorna la tua licenza tempestivamente per mantenere l'accesso alle funzionalità premium.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Aggiorna il piano di abbonamento dello spazio\",\n      \"description\": \"Puoi aggiornare solo gli spazi di lavoro di cui sei proprietario\"\n    },\n    \"status\": {\n      \"active\": \"Attivo\",\n      \"canceled\": \"Annullato\",\n      \"incomplete\": \"Incompleto\",\n      \"incompleteExpired\": \"Incompleto Scaduto\",\n      \"trialing\": \"In prova\",\n      \"pastDue\": \"Scaduto\",\n      \"unpaid\": \"Non pagato\",\n      \"paused\": \"In pausa\",\n      \"seatLimitExceeded\": \"Limite di posti superato\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Impostazioni dell'istanza\",\n      \"description\": \"Modifica le impostazioni per la tua istanza corrente\",\n      \"allowSignUp\": \"Consenti la creazione di nuovi account\",\n      \"allowSignUpDescription\": \"Disabilitando questa opzione si proibiranno le nuove registrazioni degli utenti e il pulsante di registrazione non apparirà più nella pagina di accesso.\",\n      \"allowSpaceInvitation\": \"Consenti l'invio di inviti per lo spazio\",\n      \"allowSpaceInvitationDescription\": \"Disabilitando questa opzione si impedirà agli utenti diversi dagli amministratori di invitare altri a unirsi agli spazi. Quando abilitata, i nuovi utenti invitati via email possono completare la registrazione cliccando sul link di invito nell'email, ma i link di invito condivisi non funzioneranno.\",\n      \"allowSpaceCreation\": \"Consenti a tutti di creare nuovi spazi\",\n      \"allowSpaceCreationDescription\": \"Disabilitando questa opzione si impedirà agli utenti diversi dagli amministratori di creare nuovi spazi.\",\n      \"enableEmailVerification\": \"Abilita la verifica email\",\n      \"enableEmailVerificationDescription\": \"Abilitando questa opzione si richiederà agli utenti di verificare il loro indirizzo email quando creano un nuovo account.\",\n      \"enableWaitlist\": \"Abilita la lista d'attesa\",\n      \"enableWaitlistDescription\": \"Abilitando questa opzione si consentirà agli utenti di registrarsi solo con un codice di invito.\",\n      \"generalSettings\": \"Impostazioni generali\",\n      \"aiSettings\": {\n        \"title\": \"Impostazioni AI\",\n        \"description\": \"Configura le impostazioni AI per questa istanza\"\n      },\n      \"brandingSettings\": {\n        \"title\": \"Impostazioni di branding\",\n        \"description\": \"Disponibile solo nell'Edizione Enterprise\",\n        \"brandName\": \"Nome del marchio\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"Il logo è la tua identità di marca in Teable.\",\n        \"logoUpload\": \"Carica logo\",\n        \"logoUploadDescription\": \"Carica immagine del logo, supporta formato PNG, JPEG, dimensioni consigliate 100x100px. Dimensione massima di caricamento 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Nome\",\n        \"nameDescription\": \"Il nome del fornitore LLM\",\n        \"enable\": \"Abilita AI\",\n        \"enableDescription\": \"Abilita AI per l'istanza corrente, tutti gli utenti potranno utilizzare le funzionalità AI\",\n        \"updateLLMProvider\": \"Aggiorna fornitore LLM\",\n        \"addProvider\": \"Aggiungi fornitore LLM\",\n        \"addProviderDescription\": \"Aggiungi un nuovo fornitore LLM all'elenco\",\n        \"providerType\": \"Tipo di fornitore\",\n        \"baseUrl\": \"URL di base\",\n        \"apiKey\": \"Chiave API\",\n        \"baseUrlDescription\": \"L'URL di base del fornitore LLM\",\n        \"apiKeyDescription\": \"La chiave API del fornitore LLM\",\n        \"models\": \"Modelli\",\n        \"modelsDescription\": \"I modelli supportati dal fornitore LLM\",\n        \"baseUrlRequired\": \"URL di base richiesto\",\n        \"fetchModelListError\": \"Impossibile recuperare l'elenco dei modelli\",\n        \"provider\": \"Fornitore LLM\",\n        \"providerDescription\": \"Il fornitore LLM da utilizzare\",\n        \"modelPreferences\": \"Preferenze del modello\",\n        \"modelPreferencesDescription\": \"Le preferenze del modello per il fornitore LLM\",\n        \"embeddingModel\": \"Modello di embedding\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Modello di traduzione\",\n        \"translationModelDescription\": \"Il modello di traduzione da utilizzare\",\n        \"chatModel\": \"Modello di chat\",\n        \"chatModelDescription\": \"Il modello di chat da utilizzare, Suggerimento: il modello di codifica medio viene utilizzato per impostazione predefinita per la generazione di formule AI e funzioni correlate\",\n        \"chatModels\": {\n          \"lg\": \"Modello di chat avanzato\",\n          \"lgDescription\": \"Per pianificazione, programmazione e altri scenari di attività complesse. Consigliato: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"Capacità IA\",\n          \"aiField\": {\n            \"title\": \"Campo IA\",\n            \"description\": \"Funzionalità campo IA incluso compilazione automatica e configurazione IA nelle impostazioni campo\"\n          },\n          \"aiChat\": {\n            \"title\": \"Chat IA\",\n            \"description\": \"Barra laterale Chat IA e tutte le funzionalità agente\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Testa modello\",\n          \"description\": \"Testa la capacità del modello di gestire immagini, PDF\",\n          \"notConfigLgModel\": \"Per favore configura prima il modello grande\",\n          \"confirmTitle\": \"Testa capacità del modello\",\n          \"confirmDescription\": \"Vuoi testare le capacità del modello di chat grande?\",\n          \"confirm\": \"Testa modello\",\n          \"cancel\": \"Annulla\",\n          \"missingCapabilitiesWarning\": \"Questo modello non supporta l'elaborazione di immagini o PDF, il che potrebbe rendere alcune funzionalità non disponibili.\",\n          \"enableAITitle\": \"Abilita AI\",\n          \"enableAIDescription\": \"Test del modello completato. AI non è attualmente abilitato. Vuoi abilitare AI per utilizzare queste capacità del modello?\",\n          \"enableAI\": \"Abilita AI\",\n          \"skipTest\": \"Salta\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Immagine\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Ricerca web\",\n          \"disabledWebSearch\": \"Ricerca web disabilitata\",\n          \"lgModelAbility\": \"Capacità del modello grande\"\n        },\n        \"configUpdated\": \"Configurazione AI aggiornata\",\n        \"noModelFound\": \"Nessun modello trovato.\",\n        \"searchModel\": \"Cerca modello...\",\n        \"selectModel\": \"Seleziona modello...\",\n        \"input\": \"Input {{ratio}}\",\n        \"output\": \"Output {{ratio}}\",\n        \"inputOrOutputTip\": \"Il rapporto rappresenta il tasso di cambio tra crediti e token, ad esempio \\\"1:1000\\\" significa che 1 credito ≈ 1000 token.<br></br>Nota: ogni utilizzo dedurrà almeno 1 credito. Anche se il consumo effettivo è inferiore a 1 credito, verrà comunque addebitato come 1 credito.\",\n        \"imageOutput\": \"Per immagine {{credits}}\",\n        \"imageOutputTip\": \"La generazione di immagini richiede crediti, ogni immagine consuma {{credits}} crediti\",\n        \"supportImageOutputTip\": \"Questo modello supporta la generazione di immagini\",\n        \"supportVisionTip\": \"Questo modello supporta l'input di immagini\",\n        \"supportAudioTip\": \"Questo modello supporta l'input audio\",\n        \"supportVideoTip\": \"Questo modello supporta l'input video\",\n        \"supportDeepThinkTip\": \"Questo modello supporta il pensiero profondo\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Test in corso...\",\n        \"testSuccess\": \"Test riuscito\",\n        \"testFailed\": \"Test fallito\",\n        \"fillRequiredFields\": \"Per favore compila tutti i campi obbligatori\",\n        \"modelsRequired\": \"Per favore compila almeno un modello\",\n        \"noValidModel\": \"Nessun modello valido trovato\",\n        \"addCustomModel\": \"Aggiungi modello personalizzato\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Configura la chiave API Firecrawl per abilitare la funzione di ricerca web, accedi alle <a>impostazioni Firecrawl</a> per ottenere la chiave API\"\n      },\n      \"app\": {\n        \"domain\": \"Dominio\",\n        \"v0ApiKey\": \"Chiave API v0\",\n        \"customDomain\": \"Dominio Personalizzato (Opzionale)\",\n        \"customDomainDescription\": \"Imposta il tuo dominio personalizzato per la distribuzione dell'app, accedi al <a>dominio Vercel</a> per ottenere il dominio personalizzato\",\n        \"vercelToken\": \"Token API Vercel\",\n        \"vercelTokenDescription\": \"Accedi alle <a>impostazioni Vercel</a> per ottenere il token API\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Inserisci chiave API\",\n      \"goToConfiguration\": \"Vai alla configurazione\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Grazie per aver usato teable\",\n      \"pleaseGoToConfiguration\": \"Per favore vai alla pagina delle impostazioni per completare alcune configurazioni iniziali per godere della piena funzionalità e di una migliore esperienza utente di teable\",\n      \"pleaseContactAdmin\": \"Per favore contatta l'amministratore\"\n    },\n    \"configuration\": {\n      \"title\": \"Configurazione in attesa\",\n      \"description\": \"Completa queste configurazioni per ottenere funzionalità complete\",\n      \"copyInstance\": \"Copia ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"Variabile d'ambiente PUBLIC_ORIGIN\",\n          \"description\": \"La configurazione della variabile d'ambiente <strong>PUBLIC_ORIGIN</strong> non corrisponde all'indirizzo di accesso corrente <underline>https://example.com</underline>, le funzioni di importazione xlsx, csv e campo allegato potrebbero non funzionare normalmente, si consiglia di impostarla su <underline>https://example.com</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Abilita HTTPS\",\n          \"description\": \"Non hai abilitato HTTPS, la funzione di copia su larga scala (300 righe o più) non sarà disponibile, si consiglia di abilitarla.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"Variabile d'ambiente PUBLIC_DATABASE_PROXY\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> non è configurato, la funzione di connessione database esterno non sarà disponibile, per favore fai riferimento al <a>documento di aiuto</a>\",\n          \"href\": \"https://help.teable.ai/it/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Non hai ancora configurato l'API LLM AI, AI Chat/automazione AI non sarà utilizzabile, <anchor>vai alle impostazioni</anchor>\",\n          \"errorTips\": \"Non hai ancora configurato l'API LLM AI, AI Chat/automazione AI non sarà utilizzabile\"\n        },\n        \"app\": {\n          \"title\": \"Costruttore di app\",\n          \"description\": \"Non hai ancora configurato l'API v0, la funzione Costruttore di app non sarà disponibile, <anchor>vai alle impostazioni</anchor>\",\n          \"errorTips\": \"Non hai ancora configurato l'API v0, la funzione Costruttore di app non sarà disponibile\"\n        },\n        \"webSearch\": {\n          \"title\": \"Ricerca web\",\n          \"description\": \"Non hai ancora configurato l'API di ricerca web, la funzione di ricerca web non sarà disponibile, <anchor>vai alle impostazioni</anchor>\",\n          \"errorTips\": \"Non hai ancora configurato l'API di ricerca web, la funzione di ricerca web non sarà disponibile\"\n        },\n        \"email\": {\n          \"title\": \"Email\",\n          \"description\": \"Email non configurata, il ripristino password self-service e la funzione di notifica email non saranno disponibili, <anchor>vai alle impostazioni</anchor>\",\n          \"errorTips\": \"Email non configurata, il ripristino password self-service, verifica email/funzione di notifica non saranno disponibili\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Notifiche\",\n    \"unread\": \"Non lette\",\n    \"read\": \"Lette\",\n    \"markAs\": \"Segna questa notifica come {{status}}\",\n    \"markAllAsRead\": \"Segna tutto come letto\",\n    \"noUnread\": \"Nessuna notifica {{status}}\",\n    \"changeSetting\": \"Cambia le impostazioni delle notifiche della pagina\",\n    \"new\": \"nuovo {{count}}\",\n    \"showMore\": \"Mostra di più\",\n    \"exportBase\": {\n      \"successText\": \"I dati di esportazione sono pronti\",\n      \"failedText\": \"Esportazione fallita, riprova\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Proprietario\",\n      \"creator\": \"Creatore\",\n      \"editor\": \"Editor\",\n      \"commenter\": \"Commentatore\",\n      \"viewer\": \"Visualizzatore\"\n    },\n    \"description\": {\n      \"owner\": \"Può configurare e modificare completamente basi, automazione, matrici di autorità e gestire le impostazioni e la fatturazione dello spazio\",\n      \"creator\": \"Può configurare e modificare completamente basi, automazione e abilitare la matrice di autorità\",\n      \"editor\": \"Può modificare record e viste, ma non può configurare tabelle o campi\",\n      \"commenter\": \"Può commentare i record\",\n      \"viewer\": \"Non può modificare o commentare\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Cestino dello spazio\",\n    \"type\": \"Tipo\",\n    \"resetTrash\": \"Svuota cestino\",\n    \"deletedBy\": \"Eliminato da\",\n    \"deletedTime\": \"Ora di eliminazione\",\n    \"fromSpace\": \"Dallo spazio \\\"{{name}}\\\"\",\n    \"permanentDeleteTips\": \"Sei sicuro di voler eliminare definitivamente \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"Sei sicuro di voler svuotare il cestino?\",\n    \"addToTrash\": \"Muovi nel cestino\",\n    \"description\": \"I dati nel cestino occupano ancora spazio di utilizzo di record e di utilizzo di allegati.\",\n    \"spaceDescription\": \"Ripristina gli spazi eliminati negli ultimi {{retentionDays}} giorni\",\n    \"spaceInnerDescription\": \"Ripristina le basi eliminate da questo spazio negli ultimi {{retentionDays}} giorni\",\n    \"baseDescription\": \"Ripristina le risorse eliminate da questa base negli ultimi {{retentionDays}} giorni\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Plugin non impostato URL\",\n    \"install\": \"Installa\",\n    \"publisher\": \"Editore\",\n    \"lastUpdated\": \"Ultimo aggiornamento\",\n    \"pluginNotFound\": \"Plugin non trovato\",\n    \"pluginEmpty\": {\n      \"title\": \"Nessun plugin ancora\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Vuoi attivare l'automazione corrente?\"\n  },\n  \"email\": {\n    \"send\": \"Invia\",\n    \"config\": \"Configurazione Email\",\n    \"customConfig\": \"Server Email Personalizzato\",\n    \"notify\": \"Email di Notifica\",\n    \"automation\": \"Email di Automazione\",\n    \"customNotifyConfig\": \"Configurazione Email di Notifica Personalizzata\",\n    \"customAutomationConfig\": \"Configurazione Email di Automazione Personalizzata\",\n    \"addConfig\": \"Aggiungi Configurazione\",\n    \"editConfig\": \"Modifica Configurazione\",\n    \"resetConfig\": \"Resetta\",\n    \"testEmail\": \"Email di Test\",\n    \"testEmailPlaceholder\": \"Inserisci l'email di test\",\n    \"testEmailError\": \"Inserisci un indirizzo email di test valido\",\n    \"testEmailSend\": \"Email di test inviata con successo, controlla la tua casella di posta\",\n    \"configError\": \"Inserisci una configurazione email valida\",\n    \"host\": \"Indirizzo del Server\",\n    \"hostDescription\": \"Inserisci l'indirizzo del server email SMTP, ad esempio: smtp.example.com\",\n    \"port\": \"Porta\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Autenticazione\",\n    \"username\": \"Nome Utente\",\n    \"password\": \"Password\",\n    \"sender\": \"Indirizzo del Mittente\",\n    \"senderName\": \"Nome del Mittente\",\n    \"subscribe\": \"Iscriviti\",\n    \"unsubscribe\": \"Annulla iscrizione\",\n    \"unsubscribeList\": \"Lista di annullamento iscrizione\",\n    \"unsubscribeTime\": \"Ora di annullamento iscrizione\",\n    \"source\": \"Fonte\",\n    \"sourceAutomationDeleted\": \"Automazione o nodo eliminato\",\n    \"processing\": \"Elaborazione in corso...\",\n    \"unsubscribeH1\": \"Confermare la cancellazione dell'iscrizione?\",\n    \"unsubscribeH2\": \"Stai per annullare l'iscrizione alle future promozioni e aggiornamenti dei prodotti Teable. Sei sicuro di voler annullare l'iscrizione?\",\n    \"subscribeH1\": \"Confermare l'iscrizione?\",\n    \"subscribeH2\": \"Stai per iscriverti alle future promozioni e aggiornamenti dei prodotti Teable. Sei sicuro di volerti iscrivere?\",\n    \"unsubscribeListTip\": \"I seguenti utenti nella base corrente si sono disiscritti, non potrai più inviare loro e-mail. Durante l'importazione delle e-mail, posizionare gli indirizzi e-mail nella prima colonna e nominare l'intestazione \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Reimposta password - {{brandName}}\",\n        \"title\": \"Reimposta la tua password\",\n        \"message\": \"Se non hai richiesto questa modifica, ignora questa email. Altrimenti, fai clic sul pulsante qui sotto per reimpostare la tua password.\",\n        \"buttonText\": \"Reimposta password\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Verifica registrazione - {{brandName}}\",\n          \"title\": \"Verifica registrazione\",\n          \"message\": \"Il tuo codice di verifica è {{code}}, usalo entro {{expiresIn}} minuti.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Verifica dominio - {{brandName}}\",\n          \"title\": \"Verifica dominio\",\n          \"message\": \"Il tuo codice monouso è: {{code}}, usalo entro {{expiresIn}} minuti.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Verifica cambio email - {{brandName}}\",\n          \"title\": \"Verifica cambio email\",\n          \"message\": \"Il tuo codice di verifica è {{code}}, usalo entro {{expiresIn}} minuti.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} ti ha aggiunto al campo {{fieldName}} di un record in {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> ti ha aggiunto al campo <strong>{{fieldName}}</strong> di un record in <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Visualizza record\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} ti ha aggiunto a {{refLength}} record in {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> ti ha aggiunto a <strong>{{refLength}}</strong> record in <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Visualizza record\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) ti ha invitato al loro {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Invito a collaborare\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) ti ha invitato al loro {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Accetta invito\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Benvenuto - {{brandName}}\",\n        \"title\": \"Benvenuto\",\n        \"message\": \"Ti sei unito con successo alla lista d'attesa di {{brandName}}. Usa il seguente codice di invito per registrarti: {{code}}, può essere utilizzato {{times}} volte.\",\n        \"buttonText\": \"Registrati\"\n      },\n      \"test\": {\n        \"subject\": \"Email di test - {{brandName}}\",\n        \"title\": \"Email di test\",\n        \"message\": \"Questa è un'email di test, ignorala.\"\n      },\n      \"notify\": {\n        \"subject\": \"Notifica - {{brandName}}\",\n        \"title\": \"Notifica\",\n        \"buttonText\": \"Visualizza\",\n        \"import\": {\n          \"title\": \"Notifica risultato importazione\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ Importazione di {{tableName}} interrotta: {{errorMessage}} intervallo di righe fallite: [{{range}}]. Verifica i dati di questo intervallo e riprova.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ Importazione di {{tableName}} fallita: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ Importazione di {{tableName}} fallita: Limite di righe raggiunto, aggiorna il tuo piano per importare più record\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ Importazione di {{tableName}} fallita: Nessun record è stato elaborato\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} importato con successo.\",\n              \"inplace\": \"🎉 {{tableName}} importato in loco con successo.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Notifica commento record\",\n          \"message\": \"{{fromUserName}} ha fatto un commento su {{recordName}} in {{tableName}} in {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Notifica automazione\",\n          \"failed\": {\n            \"title\": \"L'automazione {{name}} è fallita\",\n            \"message\": \"La tua automazione {{name}} non è riuscita ad essere eseguita. Fai clic sul pulsante qui sotto per visualizzare errori specifici dalla cronologia di esecuzione.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"L'automazione {{name}} è fallita a causa di credito insufficiente\",\n            \"message\": \"La tua automazione {{name}} non è riuscita ad essere eseguita a causa di credito insufficiente. Aggiorna l'abbonamento o contatta il supporto.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"L'automazione {{name}} ha raggiunto il numero massimo mensile di esecuzioni\",\n            \"message\": \"Le esecuzioni mensili dell'automazione {{name}} sono esaurite e l'esecuzione è temporaneamente non disponibile. Aggiorna l'abbonamento o acquista esecuzioni aggiuntive.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Notifica fatturazione\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Spazio {{spaceName}} crediti IA 80 % utilizzati\",\n              \"message\": \"Il tuo spazio ha utilizzato l'80 % dei crediti IA. Considera l'upgrade o l'acquisto di crediti aggiuntivi.\"\n            },\n            \"warning90\": {\n              \"title\": \"Spazio {{spaceName}} crediti IA 90 % utilizzati\",\n              \"message\": \"Il tuo spazio ha utilizzato il 90 % dei crediti IA. Aggiorna presto per evitare interruzioni.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Spazio {{spaceName}} esecuzioni automazione 80 % della quota utilizzata\",\n              \"message\": \"Il tuo spazio ha utilizzato {{usedRuns}} di {{totalLimit}} esecuzioni di automazione (80 %). Considera l'upgrade del tuo piano.\"\n            },\n            \"warning90\": {\n              \"title\": \"Spazio {{spaceName}} esecuzioni automazione 90 % della quota utilizzata\",\n              \"message\": \"Il tuo spazio ha utilizzato {{usedRuns}} di {{totalLimit}} esecuzioni di automazione (90 %). Aggiorna presto per evitare interruzioni.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Spazio {{spaceName}} esecuzioni automazione superate - periodo di grazia attivo\",\n              \"message\": \"La quota di esecuzioni di automazione è stata superata. Le automazioni smetteranno di funzionare e verranno disattivate tra {{remainingHours}} ore. Aggiorna il tuo piano.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Notifica risultato esportazione base\",\n          \"success\": {\n            \"message\": \"{{baseName}} esportato con successo: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ Esportazione di {{baseName}} fallita: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"Attività AI fallita nella tabella {{tableName}}\",\n              \"message\": \"L'attività AI per il campo {{fieldName}} nella tabella {{tableName}} (ID record: {{recordId}}) è fallita.\\n\\n{{errorMsg}}\\n\\nFai clic sul pulsante qui sotto per visualizzare i dettagli.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Lista d'attesa\",\n    \"email\": \"Email\",\n    \"joinTitle\": \"Stiamo scalando rapidamente per soddisfare la domanda\",\n    \"joinDesc\": \"Unisciti alla lista d'attesa — ti avviseremo non appena saremo pronti\",\n    \"emailPlaceholder\": \"Inserisci la tua email...\",\n    \"youAreOnTheList\": \"Sei sulla lista!\",\n    \"thanksForJoining\": \"Grazie per esserti unito alla nostra lista d'attesa. Ti avviseremo non appena saremo pronti.\",\n    \"back\": \"Torna indietro\",\n    \"inviteCodePlaceholder\": \"Inserisci il codice di invito\",\n    \"join\": \"Unisciti alla lista d'attesa\",\n    \"joining\": \"Unendo...\",\n    \"invite\": \"Invita\",\n    \"inviteTime\": \"Tempo di invito\",\n    \"createdTime\": \"Ora di iscrizione\",\n    \"yes\": \"Sì\",\n    \"no\": \"No\",\n    \"generateCode\": \"Genera codice di invito\",\n    \"count\": \"Quantità\",\n    \"times\": \"Utilizzi\",\n    \"generate\": \"Genera\",\n    \"code\": \"Codice di invito\",\n    \"inviteSuccess\": \"Invito riuscito\",\n    \"app\": {\n      \"previewAppError\": \"Errore dell'applicazione\",\n      \"sendErrorToAI\": \"Invia errore a AI\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"Sei sicuro di voler eliminare \\\"{{name}}\\\" base?\",\n    \"createResource\": \"Crea risorsa\",\n    \"noPermissionToCreateResource\": \"Non hai il permesso di creare una risorsa\"\n  },\n  \"credit\": {\n    \"title\": \"Credito\",\n    \"leftAmount\": \"Crediti rimanenti\",\n    \"winFreeCredits\": \"Condividi esperienza\",\n    \"getCredits\": \"Ottieni 1000 crediti gratuiti\",\n    \"winCredit\": {\n      \"title\": \"Condividi una recensione positiva per guadagnare\",\n      \"freeCredits\": \"1000 crediti gratuiti\",\n      \"guidelinesTitle\": \"Checklist di condivisione\",\n      \"tagTeableio\": \"Tagga <bold>@teableio</bold>\",\n      \"minCharacters\": \"Scrivi una recensione di <bold>80+</bold> caratteri\",\n      \"minFollowers\": \"Avere <bold>10+</bold> follower\",\n      \"limitPerWeek\": \"500 crediti per post, fino a 2 post settimanali (X + LinkedIn)\",\n      \"postOnX\": \"Pubblica su X\",\n      \"postOnLinkedIn\": \"Pubblica su LinkedIn\",\n      \"preFilledDraft\": \"🌟 Bozza precompilata pronta per te!\",\n      \"claimTitle\": \"Incolla l'URL del post per richiedere i tuoi crediti\",\n      \"userEmail\": \"Email utente\",\n      \"postUrlLabel\": \"URL del post (X o LinkedIn)\",\n      \"postUrlPlaceholder\": \"Incolla qui il link del tuo post\",\n      \"invalidUrl\": \"Inserisci un URL valido di X o LinkedIn\",\n      \"claiming\": \"Richiesta in corso...\",\n      \"claimCredits\": \"Richiedi crediti\",\n      \"congratulations\": \"Congratulazioni!\",\n      \"claimSuccess\": \"Hai richiesto 500 crediti!\",\n      \"verifying\": \"Verifica del tuo post...\",\n      \"verifyingDescription\": \"Stiamo controllando il contenuto del tuo post, di solito ci vogliono pochi secondi\",\n      \"verifyFailed\": \"Verifica fallita\",\n      \"tryAgain\": \"Riprova\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Verifica fallita\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Ricompensa\",\n    \"rewardCredits\": \"Crediti ricompensa\",\n    \"minCharCount\": \"Il post deve avere almeno {{count}} caratteri\",\n    \"minFollowerCount\": \"L'account deve avere almeno {{count}} follower\",\n    \"mustMention\": \"Il post deve menzionare {{mention}}\",\n    \"fetchSnapshotFailed\": \"Impossibile recuperare lo snapshot del post\",\n    \"alreadyClaimedThisWeek\": \"Hai già richiesto una ricompensa per questo account questa settimana\",\n    \"manage\": {\n      \"title\": \"Gestione ricompense\",\n      \"description\": \"Visualizza e gestisci i record delle ricompense in tutti gli spazi\",\n      \"overview\": \"Panoramica\",\n      \"records\": \"Record\",\n      \"searchSpace\": \"Cerca spazio...\",\n      \"searchRecords\": \"Cerca postUrl/userId...\",\n      \"dateRange\": \"Seleziona intervallo date\",\n      \"from\": \"Da\",\n      \"to\": \"A\",\n      \"totalSpaces\": \"Totale: {{count}} spazi\",\n      \"totalRecords\": \"Totale: {{count}} record\",\n      \"space\": \"Spazio\",\n      \"allSpaces\": \"Tutti gli spazi\",\n      \"user\": \"Utente\",\n      \"creator\": \"Creatore\",\n      \"sourceType\": \"Tipo di fonte\",\n      \"platform\": \"Piattaforma\",\n      \"allStatuses\": \"Tutti gli stati\",\n      \"allSourceTypes\": \"Tutti i tipi di fonte\",\n      \"allPlatforms\": \"Tutte le piattaforme\",\n      \"pendingCount\": \"Conteggio in sospeso\",\n      \"approvedCount\": \"Conteggio approvato\",\n      \"rejectedCount\": \"Conteggio rifiutato\",\n      \"approvedAmount\": \"Importo approvato\",\n      \"consumedAmount\": \"Importo consumato\",\n      \"availableAmount\": \"Importo disponibile\",\n      \"expiringSoonAmount\": \"Importo in scadenza (7g)\",\n      \"amount\": \"Importo\",\n      \"remainingAmount\": \"Importo rimanente\",\n      \"createdTime\": \"Data creazione\",\n      \"rewardTime\": \"Data ricompensa\",\n      \"expiredTime\": \"Data scadenza\",\n      \"lastModified\": \"Ultima modifica\",\n      \"viewDetails\": \"Visualizza dettagli\",\n      \"details\": \"Dettagli ricompensa\",\n      \"basicInfo\": \"Informazioni base\",\n      \"amountInfo\": \"Informazioni importo\",\n      \"timeInfo\": \"Informazioni temporali\",\n      \"socialInfo\": \"Informazioni social\",\n      \"verifyResult\": \"Risultato verifica\",\n      \"uniqueKey\": \"Chiave univoca\",\n      \"verify\": \"Verifica\",\n      \"valid\": \"Valido\",\n      \"invalid\": \"Non valido\",\n      \"errors\": \"Errori\",\n      \"copied\": \"{{label}} copiato\",\n      \"openPost\": \"Apri post\",\n      \"noData\": \"Nessun dato\",\n      \"page\": \"Pagina {{current}} di {{total}}\",\n      \"status\": {\n        \"label\": \"Stato\",\n        \"pending\": \"In sospeso\",\n        \"approved\": \"Approvato\",\n        \"rejected\": \"Rifiutato\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Pagina non trovata\",\n      \"description\": \"Il link che hai seguito potrebbe essere non valido o la pagina è stata spostata.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Torna alla home\"\n    },\n    \"forbidden\": {\n      \"title\": \"Accesso limitato\",\n      \"description\": \"Hai bisogno dell'autorizzazione per accedere a questa risorsa.\\nContatta il tuo amministratore.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Sblocca funzionalità Premium\",\n      \"description\": \"Questa funzionalità è disponibile nei piani avanzati.\\nEffettua l'upgrade per espandere le tue capacità.\"\n    },\n    \"error\": {\n      \"title\": \"Qualcosa è andato storto\",\n      \"description\": \"Si è verificato un errore imprevisto. Riprova più tardi.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Analisi della data fallita, il valore \\\"{{value}}\\\" è fuori dall'intervallo valido\",\n      \"planRowLimit\": \"Limite di righe raggiunto: aggiorna il tuo piano per importare più record\",\n      \"notNullValidation\": \"{{fieldHint}}I campi obbligatori non possono essere vuoti\",\n      \"uniqueValidation\": \"{{fieldHint}}Valori duplicati nei campi univoci\",\n      \"requestTimeout\": \"Timeout della richiesta\",\n      \"chunkProcessingFailed\": \"Elaborazione batch fallita: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"NUOVO AGGIORNAMENTO\",\n    \"title\": \"Download massivo degli allegati disponibile\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Nessuna Dashboard Ancora\",\n    \"description\": \"Sembra che tu non abbia ancora creato nessuna dashboard. Le dashboard ti aiutano a visualizzare e gestire i tuoi dati in modo più efficace.\",\n    \"create\": \"Crea la Tua Prima Dashboard\"\n  },\n  \"addPlugin\": \"Aggiungi un Plugin\",\n  \"createDashboard\": {\n    \"button\": \"Crea Dashboard\",\n    \"title\": \"Crea Nuova Dashboard\",\n    \"placeholder\": \"Inserisci il nome della dashboard\"\n  },\n  \"findDashboard\": \"Trova una dashboard...\",\n  \"expand\": \"Espandi\",\n  \"deprecation\": {\n    \"title\": \"La funzione del nodo dashboard sarà interrotta\",\n    \"description\": \"Per offrirti un'esperienza più intelligente ed efficiente, interromperemo il supporto per la funzione del nodo dashboard. Potrai creare facilmente nuove dashboard tramite AI nell'applicazione generata da AI.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"Costruttore di query API\",\n  \"subTitle\": \"Puoi costruire rapidamente le tue richieste di query tramite un'interfaccia utente interattiva e copiare il codice che può essere eseguito direttamente\",\n  \"apiList\": \"Elenco completo delle API\",\n  \"cellFormat\": \"Formato del risultato della cella\",\n  \"fieldKeyType\": \"Tipo di chiave del campo\",\n  \"chooseSource\": \"Scegli una fonte di dati\",\n  \"action\": {\n    \"selectBase\": \"Seleziona un database...\",\n    \"selectTable\": \"Seleziona una tabella...\"\n  },\n  \"pickParams\": \"Seleziona e configura i parametri\",\n  \"buildResult\": \"Costruisci risultato\",\n  \"buildResultEmpty\": \"Nessun risultato ancora\",\n  \"previewReturnValue\": \"Anteprima del valore di ritorno\",\n  \"replaceToken\": \"Sostituisci token\",\n  \"createNewToken\": \"Crea nuovo token\",\n  \"showPagination\": \"I parametri di paginazione sono visualizzati in modalità JSON\",\n  \"addSort\": \"Aggiungi un ordinamento\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/oauth.json",
    "content": "{\n  \"add\": \"Nuove App OAuth\",\n  \"title\": {\n    \"add\": \"Nuove App OAuth\",\n    \"edit\": \"Modifica App OAuth\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"Nome App OAuth\",\n      \"description\": \"Il nome della tua App OAuth.\"\n    },\n    \"description\": {\n      \"label\": \"Descrizione\",\n      \"description\": \"Una breve descrizione della tua App OAuth.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"URL della Homepage\",\n      \"description\": \"L'URL completo del sito web della tua App OAuth.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Si consiglia un'immagine quadrata.\",\n      \"placeholder\": \"Trascina e rilascia il tuo logo qui o clicca per caricare\",\n      \"button\": \"Carica un logo\",\n      \"clear\": \"Cancella\",\n      \"lengthError\": \"È consentito solo un file.\",\n      \"typeError\": \"È consentito solo il file immagine.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"URL di Callback\",\n      \"description\": \"L'URL completo a cui reindirizzare dopo che un utente ha autorizzato la tua integrazione.\",\n      \"add\": \"Aggiungi URL di Callback\"\n    },\n    \"scopes\": {\n      \"label\": \"Ambiti\",\n      \"description\": \"Le autorizzazioni di cui ha bisogno la tua App OAuth.\"\n    },\n    \"secret\": {\n      \"label\": \"Client secrets\",\n      \"add\": \"Genera un nuovo client secret\",\n      \"newDescription\": \"Assicurati di copiare il tuo nuovo client secret ora. Non potrai più vederlo.\",\n      \"empty\": \"Hai bisogno di un client secret per autenticarti come applicazione all'API.\",\n      \"lastUsed\": \"Ultimo utilizzo il {{date}}\",\n      \"tag\": \"Client secret\",\n      \"neverUsed\": \"Mai usato\"\n    },\n    \"clientId\": {\n      \"label\": \"Client ID: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Informazioni di base\",\n    \"scopes\": \"Ambiti\",\n    \"identify\": \"Identificazione e autorizzazione degli utenti\",\n    \"clientInfo\": \"Informazioni sul client\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} sta richiedendo l'accesso al tuo account\",\n    \"scopes\": \"Questa applicazione sarà in grado di ottenere i seguenti ambiti:\",\n    \"redirectDescription\": \"L'autorizzazione reindirizzerà a\",\n    \"authorize\": \"Autorizza\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/plugin.json",
    "content": "{\n  \"add\": \"Nuovi Plugin\",\n  \"title\": {\n    \"add\": \"Nuovi Plugin\",\n    \"edit\": \"Modifica Plugin\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Utente Plugin\",\n    \"description\": \"Utente Plugin generato automaticamente dal sistema\"\n  },\n  \"secret\": \"Secret\",\n  \"regenerateSecret\": \"Rigenera Secret\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Nome\",\n      \"description\": \"nome del plugin\"\n    },\n    \"description\": {\n      \"label\": \"Descrizione\",\n      \"description\": \"descrizione del plugin\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Descrizione Dettagliata\",\n      \"description\": \"descrizione dettagliata del plugin\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"logo del plugin, puoi caricare un'immagine o usare un URL\",\n      \"upload\": \"Carica\",\n      \"clear\": \"Cancella\",\n      \"placeholder\": \"Trascina e rilascia il tuo logo qui o clicca per caricare\",\n      \"lengthError\": \"È consentito solo un file.\",\n      \"typeError\": \"È consentito solo il file immagine.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"URL di Aiuto\",\n      \"description\": \"URL del documento di aiuto del plugin\"\n    },\n    \"positions\": {\n      \"label\": \"Posizioni\",\n      \"description\": \"posizioni del plugin\"\n    },\n    \"i18n\": {\n      \"label\": \"I18n\",\n      \"description\": \"i18n del plugin, contiene(nome, descrizione, descrizione dettagliata)\"\n    },\n    \"url\": {\n      \"label\": \"Url\",\n      \"description\": \"URL del plugin\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Scrivi\",\n    \"preview\": \"Anteprima\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Prossimamente\",\n    \"empty\": \"Vuoto\",\n    \"noRecords\": \"Nessun record disponibile\",\n    \"unnamedRecord\": \"Record senza nome\",\n    \"untitled\": \"Senza titolo\",\n    \"cancel\": \"Annulla\",\n    \"confirm\": \"Conferma\",\n    \"back\": \"Indietro\",\n    \"done\": \"Fatto\",\n    \"create\": \"Crea\",\n    \"search\": {\n      \"placeholder\": \"Cerca...\",\n      \"empty\": \"Nessun risultato trovato\"\n    },\n    \"readOnlyTip\": \"Questa vista è bloccata. Puoi abilitare <button>modo personale</button> per modificare le opzioni della vista, e le modifiche avranno effetto solo per te.\",\n    \"selectPlaceHolder\": \"Seleziona...\",\n    \"loading\": \"Caricamento...\",\n    \"loadMore\": \"Carica altro\",\n    \"uploadFailed\": \"Caricamento fallito\",\n    \"rowCount\": \"{{count}} record\",\n    \"summary\": \"Riepilogo\",\n    \"summaryTip\": \"Passa il mouse per selezionare il riepilogo\",\n    \"actions\": \"Azioni\",\n    \"remove\": \"Rimuovi\",\n    \"runStatus\": {\n      \"success\": \"{{name}} riuscito\",\n      \"failed\": \"{{name}} fallito\",\n      \"running\": \"{{name}} in corso\"\n    },\n    \"resetSuccess\": \"Rimesso riuscito\",\n    \"click\": \"Clicca\",\n    \"clickedCount\": \"{{label}}: Cliccato {{text}} volte\",\n    \"atLeastOne\": \"Riserva almeno un {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Notifica\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Limite dimensione file di anteprima: {{size}}MB, per favore scarica per visualizzare.\",\n    \"loadFileError\": \"Caricamento file fallito\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Annulla\",\n    \"redo\": \"Ripeti\",\n    \"undoFailed\": \"Annullamento fallito\",\n    \"redoFailed\": \"Ripetizione fallita\",\n    \"nothingToUndo\": \"Niente da annullare\",\n    \"nothingToRedo\": \"Niente da ripetere\",\n    \"undoSucceed\": \"Annullamento riuscito\",\n    \"redoSucceed\": \"Ripetizione riuscita\",\n    \"undoing\": \"annullamento in corso...\",\n    \"redoing\": \"ripetizione in corso...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Rilascia per caricare il file\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Incolla o trascina e rilascia per caricare qui\",\n      \"upload\": \"carica\"\n    },\n    \"date\": {\n      \"placeholder\": \"Seleziona una data\",\n      \"today\": \"Oggi\"\n    },\n    \"formula\": {\n      \"title\": \"Editor di formule\",\n      \"guideSyntax\": \"Sintassi\",\n      \"guideExample\": \"Esempio\",\n      \"helperExample\": \"Esempio: \",\n      \"fieldValue\": \"Restituisce il valore alle celle del campo {{fieldName}}.\",\n      \"placeholder\": \"Inserisci un'espressione\",\n      \"placeholderForAIPrompt\": \"Descrivi la formula che vuoi generare\",\n      \"editExpression\": \"Modifica formula\",\n      \"generateExpressionByAI\": \"Genera formula con AI\",\n      \"inputPrompt\": \"Inserisci istruzione\",\n      \"generateExpression\": \"Formula generata\",\n      \"generatingByAI\": \"Generazione formula con AI...\",\n      \"generatedExpressionTips\": \"Dopo la generazione, clicca su Applica per inserire rapidamente la formula\",\n      \"action\": {\n        \"generating\": \"Generazione...\",\n        \"generate\": \"Genera\",\n        \"apply\": \"Applica\"\n      },\n      \"expressionRequired\": \"L'espressione è obbligatoria\"\n    },\n    \"link\": {\n      \"placeholder\": \"Seleziona record da collegare\",\n      \"searchPlaceholder\": \"Cerca record\",\n      \"allFields\": \"Tutti i campi\",\n      \"globalSearch\": \"Ricerca globale\",\n      \"fieldSearch\": \"Ricerca per campo\",\n      \"maxFieldTips\": \"Superato il numero massimo di {{count}} campi, i campi extra verranno ignorati\",\n      \"create\": \"Aggiungi Record\",\n      \"selectRecord\": \"Seleziona Record\",\n      \"all\": \"Tutti\",\n      \"selected\": \"Selezionato\",\n      \"expandRecordError\": \"Nessun permesso per visualizzare questo record.\",\n      \"alreadyOpen\": \"Questo record è già aperto.\",\n      \"linkedTo\": \"Collegato a\",\n      \"goToForeignTable\": \"Vai alla tabella esterna\",\n      \"foreignTableIdRequired\": \"La tabella esterna è obbligatoria\",\n      \"linkFieldIdRequired\": \"Il campo di collegamento è obbligatorio\",\n      \"selectTooManyRecords\": \"I record selezionati non devono superare {{maxCount}}\",\n      \"relationshipRequired\": \"La relazione è obbligatoria\",\n      \"rangeSelectFailed\": \"Impossibile selezionare i record nell'intervallo\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Trova utenti per nome\",\n      \"notify\": \"Notifica agli utenti una volta selezionati\"\n    },\n    \"select\": {\n      \"addOption\": \"Aggiungi un'opzione '{{option}}'\",\n      \"choicesNameRequired\": \"Il nome della scelta non può essere vuoto\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Il campo di ricerca è obbligatorio\",\n      \"lookupOptionsNotAllowed\": \"Le opzioni di ricerca non sono consentite quando l'attributo isLookup è vero o il tipo di campo è rollup.\",\n      \"lookupOptionsRequired\": \"Le opzioni di ricerca sono richieste\",\n      \"refineOptionsError\": \"Errore nell'analisi delle opzioni di ricerca {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"L'espressione è obbligatoria\",\n      \"unsupportedTip\": \"Il rollup supporta solo i campi di collegamento e rollup\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Il filtro deve contenere almeno una condizione\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"La ricerca condizionale richiede almeno un filtro\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Il modello è obbligatorio\",\n      \"typeNotSupported\": \"Tipo di AI non supportato\",\n      \"sourceFieldIdRequired\": \"Il campo sorgente è obbligatorio\",\n      \"targetLanguageRequired\": \"La lingua di destinazione è obbligatoria\",\n      \"promptRequired\": \"Il prompt è obbligatorio\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Errore nell'analisi delle opzioni del campo {{message}}\",\n      \"optionsRequired\": \"Le opzioni del campo sono obbligatorie\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filtro\",\n    \"displayLabel\": \"Filtra per \",\n    \"displayLabel_other\": \"Filtra per {{fieldName}} e altri {{count}} campi\",\n    \"addCondition\": \"Aggiungi condizione\",\n    \"addConditionGroup\": \"Aggiungi gruppo di condizioni\",\n    \"nestedLimitTip\": \"Le condizioni del filtro possono essere annidate solo fino a {{depth}} livelli di profondità\",\n    \"linkInputPlaceholder\": \"Inserisci un valore\",\n    \"groupDescription\": \"Qualsiasi delle seguenti è vera…\",\n    \"currentUser\": \"Io (utente corrente)\",\n    \"tips\": {\n      \"scope\": \"In questa vista, mostra i record\"\n    },\n    \"invalidateSelected\": \"Valore non valido\",\n    \"invalidateSelectedTips\": \"Il valore selezionato è stato eliminato, per favore seleziona di nuovo\",\n    \"default\": {\n      \"empty\": \"Nessuna condizione di filtro applicata\",\n      \"placeholder\": \"Inserisci un valore\"\n    },\n    \"conjunction\": {\n      \"and\": \"e\",\n      \"or\": \"o\",\n      \"where\": \"dove\",\n      \"meetingAll\": \"Soddisfare tutte le condizioni\",\n      \"meetingAny\": \"Soddisfare una condizione\"\n    },\n    \"operator\": {\n      \"is\": \"è\",\n      \"isNot\": \"non è\",\n      \"contains\": \"contiene\",\n      \"doesNotContain\": \"non contiene\",\n      \"isEmpty\": \"è vuoto\",\n      \"isNotEmpty\": \"non è vuoto\",\n      \"isGreater\": \"è maggiore di\",\n      \"isGreaterEqual\": \"è maggiore o uguale a\",\n      \"isLess\": \"è minore di\",\n      \"isLessEqual\": \"è minore o uguale a\",\n      \"isAnyOf\": \"è uno di\",\n      \"isNoneOf\": \"non è nessuno di\",\n      \"hasAnyOf\": \"ha uno di\",\n      \"hasAllOf\": \"ha tutti di\",\n      \"hasNoneOf\": \"non ha nessuno di\",\n      \"isExactly\": \"è esattamente\",\n      \"isWithIn\": \"è entro\",\n      \"isBefore\": \"è prima di\",\n      \"isAfter\": \"è dopo\",\n      \"isOnOrBefore\": \"è il o prima di\",\n      \"isOnOrAfter\": \"è il o dopo\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"oggi\",\n        \"tomorrow\": \"domani\",\n        \"yesterday\": \"ieri\",\n        \"oneWeekAgo\": \"una settimana fa\",\n        \"oneWeekFromNow\": \"una settimana da oggi\",\n        \"oneMonthAgo\": \"un mese fa\",\n        \"oneMonthFromNow\": \"un mese da oggi\",\n        \"daysAgo\": \"giorni fa\",\n        \"daysFromNow\": \"giorni da oggi\",\n        \"exactDate\": \"data esatta\",\n        \"exactFormatDate\": \"data esatta (formato)\",\n        \"currentWeek\": \"settimana corrente\",\n        \"currentMonth\": \"mese corrente\",\n        \"currentYear\": \"anno corrente\",\n        \"lastWeek\": \"settimana scorsa\",\n        \"lastMonth\": \"mese scorso\",\n        \"lastYear\": \"anno scorso\",\n        \"nextWeekPeriod\": \"prossima settimana\",\n        \"nextMonthPeriod\": \"prossimo mese\",\n        \"nextYearPeriod\": \"prossimo anno\",\n        \"pastWeek\": \"settimana passata\",\n        \"pastMonth\": \"mese passato\",\n        \"pastYear\": \"anno passato\",\n        \"nextWeek\": \"prossima settimana\",\n        \"nextMonth\": \"prossimo mese\",\n        \"nextYear\": \"prossimo anno\",\n        \"pastNumberOfDays\": \"numero di giorni passati\",\n        \"nextNumberOfDays\": \"numero di giorni futuri\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"colore\"\n  },\n  \"rowHeight\": {\n    \"short\": \"corto\",\n    \"medium\": \"medio\",\n    \"tall\": \"alto\",\n    \"extraTall\": \"extra alto\",\n    \"title\": \"dell'altezza delle righe\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"del nome del campo\",\n    \"displayLines\": \"{{count}} linee\"\n  },\n  \"share\": {\n    \"title\": \"condividi\"\n  },\n  \"extensions\": {\n    \"title\": \"estensioni\"\n  },\n  \"hidden\": {\n    \"label\": \"Campi nascosti\",\n    \"configLabel_one\": \"{{count}} campo nascosto\",\n    \"configLabel_other\": \"{{count}} campi nascosti\",\n    \"configLabel_other_visible\": \"{{count}} campi visibili\",\n    \"showAll\": \"Mostra tutto\",\n    \"hideAll\": \"Nascondi tutto\",\n    \"primaryKey\": \"Campo principale: Identifica i record\\nnon può essere nascosto o eliminato, visibile nei record collegati.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Copia negli appunti\",\n    \"duplicateRecord\": \"Duplica record\",\n    \"copyRecordUrl\": \"Copia URL del record\",\n    \"deleteRecord\": \"Elimina record\",\n    \"addRecordComment\": \"Aggiungi commento\",\n    \"viewRecordHistory\": \"Visualizza cronologia del record\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Nascondi cronologia del record\",\n      \"showRecordHistory\": \"Mostra cronologia del record\",\n      \"createdTime\": \"Ora di creazione\",\n      \"createdBy\": \"Creato da\",\n      \"before\": \"Prima\",\n      \"after\": \"Dopo\",\n      \"viewRecord\": \"Visualizza record\"\n    },\n    \"showHiddenFields\": \"Mostra {{count}} campi nascosti\",\n    \"hideHiddenFields\": \"Nascondi {{count}} campi nascosti\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Ordina\",\n    \"displayLabel_one\": \"Ordina per {{count}} campo\",\n    \"displayLabel_other\": \"Ordina per {{count}} campi\",\n    \"setTips\": \"Ordina per\",\n    \"addButton\": \"Aggiungi un altro ordinamento\",\n    \"autoSort\": \"Ordina automaticamente i record\",\n    \"selectASCLabel\": \"primo → ultimo\",\n    \"selectDESCLabel\": \"ultimo → primo\"\n  },\n  \"group\": {\n    \"label\": \"Gruppo\",\n    \"displayLabel_one\": \"Raggruppa per {{count}} campo\",\n    \"displayLabel_other\": \"Raggruppa per {{count}} campi\",\n    \"setTips\": \"Raggruppa per\",\n    \"addButton\": \"Aggiungi sottogruppo\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Testo a riga singola\",\n      \"longText\": \"Testo lungo\",\n      \"singleSelect\": \"Selezione singola\",\n      \"number\": \"Numero\",\n      \"multipleSelect\": \"Selezione multipla\",\n      \"link\": \"Collega a un altro record\",\n      \"formula\": \"Formula\",\n      \"date\": \"Data\",\n      \"createdTime\": \"Ora di creazione\",\n      \"lastModifiedTime\": \"Ultima modifica\",\n      \"attachment\": \"Allegato\",\n      \"checkbox\": \"Casella di controllo\",\n      \"rollup\": \"Rollup\",\n      \"conditionalRollup\": \"Rollup condizionale\",\n      \"user\": \"Utente\",\n      \"rating\": \"Valutazione\",\n      \"autoNumber\": \"Numero automatico\",\n      \"lookup\": \"Ricerca\",\n      \"conditionalLookup\": \"Ricerca condizionale\",\n      \"button\": \"Pulsante\",\n      \"createdBy\": \"Creato da\",\n      \"lastModifiedBy\": \"Ultima modifica di\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Memorizza testi brevi come nomi o titoli.\",\n      \"longText\": \"Raccogli note e descrizioni più lunghe.\",\n      \"singleSelect\": \"Scegli un'opzione da un elenco.\",\n      \"number\": \"Tieni traccia di valori numerici con formattazione.\",\n      \"multipleSelect\": \"Etichetta i record con più scelte.\",\n      \"link\": \"Collega questo record a un'altra tabella.\",\n      \"formula\": \"Calcola valori da altri campi.\",\n      \"date\": \"Memorizza date o orari.\",\n      \"createdTime\": \"Mostra quando è stato creato il record.\",\n      \"lastModifiedTime\": \"Mostra l'ultimo aggiornamento.\",\n      \"attachment\": \"Può caricare file o generare immagini con IA, supporta modelli come 🍌 Nano banana pro\",\n      \"checkbox\": \"Attiva o disattiva un semplice sì/no.\",\n      \"rollup\": \"Riassumi i record collegati con formule.\",\n      \"conditionalRollup\": \"Riassumi dati in base a condizioni.\",\n      \"user\": \"Assegna i record ai membri dell'area di lavoro.\",\n      \"rating\": \"Valuta gli elementi con icone configurabili.\",\n      \"autoNumber\": \"Assegna a ogni record una sequenza unica.\",\n      \"lookup\": \"Mostra valori provenienti da record collegati.\",\n      \"conditionalLookup\": \"Mostra valori collegati che soddisfano i filtri definiti.\",\n      \"button\": \"Esegui azioni con un pulsante cliccabile.\",\n      \"createdBy\": \"Mostra chi ha creato il record.\",\n      \"lastModifiedBy\": \"Mostra chi lo ha modificato per ultimo.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Unidirezionale\",\n      \"twoWay\": \"Bidirezionale\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Conferma azione\",\n        \"description\": \"Sei sicuro di voler eseguire questa azione?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Crea spazio\",\n      \"spaceDelete\": \"Elimina spazio\",\n      \"spaceRead\": \"Leggi spazio\",\n      \"spaceUpdate\": \"Aggiorna spazio\",\n      \"spaceInviteEmail\": \"Invita via email nello spazio\",\n      \"spaceInviteLink\": \"Invita tramite link nello spazio\",\n      \"spaceGrantRole\": \"Concedi ruolo nello spazio\",\n      \"baseCreate\": \"Crea base\",\n      \"baseDelete\": \"Elimina base\",\n      \"baseRead\": \"Leggi base\",\n      \"baseReadAll\": \"Leggi tutte le basi\",\n      \"baseUpdate\": \"Aggiorna base\",\n      \"baseInviteEmail\": \"Invita via email nella base\",\n      \"baseInviteLink\": \"Invita tramite link nella base\",\n      \"baseTableImport\": \"Importa dati nella base\",\n      \"baseAuthorityMatrixConfig\": \"Configura matrice di autorità\",\n      \"baseDbConnect\": \"Connetti al database\",\n      \"tableCreate\": \"Crea tabella\",\n      \"tableRead\": \"Leggi tabella\",\n      \"tableDelete\": \"Elimina tabella\",\n      \"tableUpdate\": \"Aggiorna tabella\",\n      \"tableImport\": \"Importa dati nella tabella\",\n      \"tableExport\": \"Esporta dati dalla tabella\",\n      \"tableTrashRead\": \"Leggi cestino della tabella\",\n      \"tableTrashUpdate\": \"Aggiorna cestino della tabella\",\n      \"tableTrashReset\": \"Reimposta cestino della tabella\",\n      \"viewCreate\": \"Crea vista\",\n      \"viewDelete\": \"Elimina vista\",\n      \"viewRead\": \"Leggi vista\",\n      \"viewUpdate\": \"Aggiorna vista\",\n      \"viewShare\": \"Condividi vista\",\n      \"fieldCreate\": \"Crea campo\",\n      \"fieldDelete\": \"Elimina campo\",\n      \"fieldRead\": \"Leggi campo\",\n      \"fieldUpdate\": \"Aggiorna campo\",\n      \"recordCreate\": \"Crea record\",\n      \"recordComment\": \"Commenta record\",\n      \"recordDelete\": \"Elimina record\",\n      \"recordRead\": \"Leggi record\",\n      \"recordUpdate\": \"Aggiorna record\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Crea automazione\",\n      \"automationDelete\": \"Elimina automazione\",\n      \"automationRead\": \"Leggi automazione\",\n      \"automationUpdate\": \"Aggiorna automazione\",\n      \"appCreate\": \"Crea app\",\n      \"appDelete\": \"Elimina app\",\n      \"appRead\": \"Leggi app\",\n      \"appUpdate\": \"Aggiorna app\",\n      \"userProfileRead\": \"Leggi profilo utente corrente\",\n      \"userEmailRead\": \"Leggi email utente corrente\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Leggi cronologia record\",\n      \"baseQuery\": \"Interroga base\",\n      \"instanceRead\": \"Leggi istanza\",\n      \"instanceUpdate\": \"Aggiorna istanza\",\n      \"enterpriseRead\": \"Leggi configurazione enterprise\",\n      \"enterpriseUpdate\": \"Aggiorna configurazione enterprise\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tabella\",\n    \"view\": \"Vista\",\n    \"space\": \"Spazio\",\n    \"base\": \"Base\",\n    \"field\": \"Campo\",\n    \"record\": \"Record\",\n    \"automation\": \"Automazione\",\n    \"app\": \"App\",\n    \"user\": \"Utente\",\n    \"recordHistory\": \"Cronologia Record\",\n    \"you\": \"Tu\",\n    \"instance\": \"Istanza\",\n    \"enterprise\": \"Impresa\",\n    \"history\": \"Storico\",\n    \"global\": \"Globale\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Somma i numeri. Equivalente a numero1 + numero2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Restituisce la media dei numeri.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Restituisce il numero più grande tra quelli forniti.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Restituisce il numero più piccolo tra quelli forniti.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Arrotonda il valore al numero di decimali specificato da \\\"precision\\\" (Specifica, ROUND arrotonderà al numero intero più vicino alla precisione specificata, con i legami rotti arrotondando a metà verso l'infinito positivo.)\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Arrotonda il valore al numero di decimali specificato da \\\"precision\\\" arrotondando sempre verso l'alto, cioè, lontano da zero. (Devi fornire un valore per la precisione o la funzione non funzionerà.)\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Arrotonda il valore al numero di decimali specificato da \\\"precision\\\" arrotondando sempre verso il basso, cioè, verso zero. (Devi fornire un valore per la precisione o la funzione non funzionerà.)\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Restituisce il multiplo intero più vicino di significato che è maggiore o uguale al valore. Se non viene fornito alcun significato, si assume un significato di 1.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Restituisce il multiplo intero più vicino di significato che è minore o uguale al valore. Se non viene fornito alcun significato, si assume un significato di 1.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Restituisce il più piccolo numero intero pari che è maggiore o uguale al valore specificato.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Arrotonda il valore positivo al numero dispari più vicino e il valore negativo al numero dispari più vicino.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Arrotonda un numero verso il basso al numero intero più vicino.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Restituisce il valore assoluto.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Restituisce la radice quadrata di un numero non negativo.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Calcola la base specificata alla potenza specificata.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Calcola il numero di Eulero (e) alla potenza specificata.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Calcola il logaritmo del valore nella base fornita. La base predefinita è 10 se non specificata.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Restituisce il resto dopo aver diviso il primo argomento per il secondo.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Converte la stringa di testo in un numero.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Unisce vari argomenti di tipo valore in un singolo valore di testo.\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Trova un'occorrenza di stringToFind nella stringa whereToSearch a partire da una posizione startFromPosition opzionale. (startFromPosition è 0 per impostazione predefinita.) Se non viene trovata alcuna occorrenza di stringToFind, il risultato sarà 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Cerca un'occorrenza di stringToFind nella stringa whereToSearch a partire da una posizione startFromPosition opzionale. (startFromPosition è 0 per impostazione predefinita.) Se non viene trovata alcuna occorrenza di stringToFind, il risultato sarà vuoto.\\nSimile a FIND(), sebbene FIND() restituisca 0 anziché vuoto se non viene trovata alcuna occorrenza di stringToFind.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Estrae una sottostringa di count caratteri a partire da whereToStart.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Estrae howMany caratteri dall'inizio della stringa.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Estrae howMany caratteri dalla fine della stringa.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Sostituisce il numero di caratteri a partire dal carattere start con il testo di sostituzione.\\n(Se stai cercando un modo per trovare e sostituire tutte le occorrenze di old_text con new_text, vedi SUBSTITUTE().)\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Sostituisce tutte le sottostringhe che corrispondono all'espressione regolare con la sostituzione.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Sostituisce le occorrenze di old_text con new_text.\\nPuoi opzionalmente specificare un numero di indice (a partire da 1) per sostituire solo una specifica occorrenza di old_text. Se non viene specificato alcun numero di indice, tutte le occorrenze di old_text verranno sostituite.\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Rende una stringa minuscola.\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Rende una stringa maiuscola.\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Ripete la stringa per il numero di volte specificato.\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Rimuove gli spazi bianchi all'inizio e alla fine della stringa.\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Estrae howMany caratteri dall'inizio della stringa.\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Restituisce l'argomento se è testo e vuoto altrimenti.\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Sostituisce alcuni caratteri con equivalenti codificati per l'uso nella costruzione di URL o URI. Non codifica i seguenti caratteri: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Restituisce value1 se l'argomento logico è vero, altrimenti restituisce value2. Può anche essere utilizzato per creare dichiarazioni IF annidate.\\nPuò anche essere utilizzato per verificare se una cella è vuota/è vuota.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Prende un'espressione, un elenco di valori possibili per quell'espressione e per ciascuno, un valore che l'espressione dovrebbe assumere in quel caso. Può anche prendere un valore predefinito se l'input dell'espressione non corrisponde a nessuno dei modelli definiti. In molti casi, SWITCH() può essere utilizzato al posto di una formula IF() annidata.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Restituisce vero se tutti gli argomenti sono veri, restituisce falso altrimenti.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Restituisce vero se uno qualsiasi degli argomenti è vero.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Restituisce vero se un numero dispari di argomenti è vero.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Inverte il valore logico del suo argomento.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Restituisce un valore vuoto.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Restituisce il valore di errore.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Restituisce vero se l'espressione causa un errore.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Restituisce la data corrente.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Restituisce la data e l'ora correnti.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Restituisce l'anno a quattro cifre di una data.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Restituisce il mese di una data come numero compreso tra 1 (gennaio) e 12 (dicembre).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Restituisce il numero della settimana in un anno.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Restituisce il giorno della settimana come un intero compreso tra 0 e 6, inclusi. Puoi opzionalmente fornire un secondo argomento (\\\"Sunday\\\" o \\\"Monday\\\") per iniziare le settimane in quel giorno. Se omesso, le settimane iniziano di domenica per impostazione predefinita. Esempio:\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Restituisce il giorno del mese di una data sotto forma di numero compreso tra 1-31.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Restituisce l'ora di una data come numero compreso tra 0 (12:00am) e 23 (11:00pm).\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Restituisce il minuto di una data come un intero compreso tra 0 e 59.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Restituisce il secondo di una data come un intero compreso tra 0 e 59.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Calcola il numero di giorni tra la data corrente e un'altra data.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Calcola il numero di giorni tra la data corrente e un'altra data.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Restituisce la differenza tra le date in unità specificate. L'unità predefinita è \\\"day\\\".\\nUnità supportate: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nLa differenza tra le date è determinata sottraendo [date2] da [date1]. Ciò significa che se [date2] è successiva a [date1], il valore risultante sarà negativo.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Restituisce il giorno lavorativo alla data di inizio, escludendo le festività specificate\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Restituisce il numero di giorni lavorativi tra date1 e date2. I giorni lavorativi escludono i fine settimana e un elenco opzionale di festività, formattato come una stringa separata da virgole di date in formato ISO.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Confronta due date fino a un'unità e determina se sono identiche. Restituisce vero se sì, falso se no.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Determina se date1 è successiva a date2. Restituisce vero se sì, falso se no.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Determina se date1 è precedente a date2. Restituisce vero se sì, falso se no.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Aggiunge \\\"count\\\" unità specificate a una data.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Formatta una data in una stringa (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Formatta una data in una stringa solo ora (HH:mm:ss).\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Formatta una data in una stringa specificata. Per una spiegazione su come utilizzare questa funzione con i campi data, fare clic qui. Per un elenco dei specificatori di formato supportati, fare clic qui.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Interpreta una stringa di testo come una data strutturata, con parametri di formato di input e locale opzionali. Il formato di output sarà sempre formattato \\\"M/D/YYYY h:mm a\\\".\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Restituisce l'ora di creazione del record corrente.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Restituisce la data e l'ora dell'ultima modifica effettuata da un utente in un campo non calcolato nella tabella.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Restituisce il numero di tutti gli elementi inclusi testo e vuoti.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Restituisce il numero di valori non vuoti. Questa funzione conta sia i valori numerici che quelli di testo.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Restituisce il numero di elementi numerici.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Unisce l'array di elementi di rollup in una stringa con un separatore.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Restituisce solo gli elementi unici nell'array.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Appiattisce l'array rimuovendo qualsiasi nidificazione dell'array. Tutti gli elementi diventano elementi di un singolo array.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Rimuove le stringhe vuote e i valori nulli dall'array. Mantiene \\\"false\\\" e stringhe che contengono uno o più caratteri vuoti.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Restituisce tutti i valori di testo\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Restituisce l'ID del record corrente.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Restituisce i numeri unici e incrementati per ogni record.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Compila variabili, caratteri operativi e funzioni per formare formule per i calcoli.\",\n      \"example\": \"Citazione della colonna: {Nome del campo}\\n\\nUtilizzo dell'operatore: 100 * 2 + 300\\n\\nUtilizzo della funzione: SUM({Campo numerico 1}, 100)\\n\\nUtilizzo dell'istruzione IF: \\nIF(condizione logica, \\\"valore 1\\\", \\\"valore 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Fields\",\n    \"numeric\": \"Numeric\",\n    \"text\": \"Text\",\n    \"logical\": \"Logical\",\n    \"date\": \"Date\",\n    \"array\": \"Array\",\n    \"system\": \"System\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"None\",\n    \"count\": \"Count\",\n    \"empty\": \"Empty\",\n    \"filled\": \"Filled\",\n    \"unique\": \"Unique\",\n    \"max\": \"Max\",\n    \"min\": \"Min\",\n    \"sum\": \"Sum\",\n    \"average\": \"Average\",\n    \"checked\": \"Checked\",\n    \"unChecked\": \"Unchecked\",\n    \"percentEmpty\": \"Percent Empty\",\n    \"percentFilled\": \"Percent Filled\",\n    \"percentUnique\": \"Percent Unique\",\n    \"percentChecked\": \"Percent Checked\",\n    \"percentUnChecked\": \"Percent Unchecked\",\n    \"earliestDate\": \"Earliest Date\",\n    \"latestDate\": \"Latest Date\",\n    \"dateRangeOfDays\": \"Date Range (days)\",\n    \"dateRangeOfMonths\": \"Date Range (months)\",\n    \"totalAttachmentSize\": \"Total Attachment Size\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Add\",\n    \"error\": {\n      \"invalidCol\": \"Invalid column, please reselect\",\n      \"invalidCols\": \"Invalid columns: {{colNames}}\",\n      \"invalidTable\": \"Invalid table, please reselect\",\n      \"requiredSelect\": \"You must select one\"\n    },\n    \"from\": {\n      \"title\": \"From\",\n      \"fromTable\": \"Select Table\",\n      \"fromQuery\": \"From Query\"\n    },\n    \"select\": {\n      \"title\": \"Select\"\n    },\n    \"where\": {\n      \"title\": \"Where\"\n    },\n    \"groupBy\": {\n      \"title\": \"Group By\"\n    },\n    \"orderBy\": {\n      \"title\": \"Order By\",\n      \"asc\": \"Ascending\",\n      \"desc\": \"Descending\"\n    },\n    \"limit\": {\n      \"title\": \"Limit\"\n    },\n    \"offset\": {\n      \"title\": \"Offset\"\n    },\n    \"join\": {\n      \"title\": \"Join\",\n      \"joinType\": \"Join Type\",\n      \"leftJoin\": \"Left Join\",\n      \"rightJoin\": \"Right Join\",\n      \"innerJoin\": \"Inner Join\",\n      \"fullJoin\": \"Full Join\",\n      \"data\": \"From\"\n    },\n    \"aggregation\": {\n      \"title\": \"Aggregation\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Comment\",\n    \"placeholder\": \"Leave a comment...\",\n    \"emptyComment\": \"Start a conversation\",\n    \"deletedComment\": \"Deleted Comment\",\n    \"imageSizeLimit\": \"Image size could not be greater than {{size}}\",\n    \"tip\": {\n      \"editing\": \"Modifica...\",\n      \"edited\": \"(Editato)\",\n      \"notifyAll\": \"Notifica tutti i commenti\",\n      \"notifyRelatedToMe\": \"Notifica i commenti relativi a me\",\n      \"all\": \"Tutti\",\n      \"relatedToMe\": \"Relativo a me\",\n      \"reactionUserSuffix\": \"ha reagito con {{emoji}} emoji\",\n      \"me\": \"Tu\",\n      \"connection\": \"e\"\n    },\n    \"toolbar\": {\n      \"link\": \"Link\",\n      \"image\": \"Image\",\n      \"mention\": \"Menzione\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Edit Link\",\n      \"caption\": \"Caption\",\n      \"delete\": \"Delete\",\n      \"linkText\": \"Link Text\",\n      \"enterUrl\": \"Enter URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select Members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Errore di validazione\",\n    \"invalidCaptcha\": \"Captcha non valido\",\n    \"invalidCredentials\": \"Credenziali non valide\",\n    \"unauthorized\": \"Non autorizzato\",\n    \"unauthorizedShare\": \"Non autorizzato condivisione\",\n    \"paymentRequired\": \"Pagamento richiesto\",\n    \"creditLimitExceeded\": \"Limite di crediti superato\",\n    \"restrictedResource\": \"Risorsa limitata\",\n    \"notFound\": \"Non trovato\",\n    \"conflict\": \"Conflitto\",\n    \"unprocessableEntity\": \"Entità non processabile\",\n    \"userLimitExceeded\": \"Limite utente superato\",\n    \"tooManyRequests\": \"Troppi richieste\",\n    \"internalServerError\": \"Errore interno del server\",\n    \"databaseConnectionUnavailable\": \"Connessione al database non disponibile\",\n    \"gatewayTimeout\": \"Timeout del gateway\",\n    \"unknownErrorCode\": \"Codice errore sconosciuto\",\n    \"networkError\": \"Problema di connessione di rete\",\n    \"requestTimeout\": \"Timeout della richiesta\",\n    \"failedDependency\": \"Errore di dipendenza\",\n    \"automationNodeParseError\": \"Errore di analisi del nodo di automazione\",\n    \"automationNodeNeedTest\": \"Il nodo di automazione ha bisogno di test\",\n    \"automationNodeTestOutdated\": \"Test del nodo di automazione obsoleto\",\n    \"invalidToken\": \"Token non valido\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" campo \\\"{{fieldName}}\\\" non consente valori vuoti, per favore riempilo completamente prima di inviare.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" campo \\\"{{fieldName}}\\\" non consente valori duplicati, per favore riempilo con un valore unico prima di inviare.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" campo non consente associazioni duplicate con lo stesso record\",\n      \"requestTimeout\": \"L'azione corrente è troppo grande, per favore riprova con un ambito più piccolo.\",\n      \"searchTimeOut\": \"La ricerca ha scaduto, per favore ridurre l'ambito di ricerca e riprova.\",\n      \"dependencyNodeRequire\": \"Il nodo dipendente non è stato testato, per favore controlla se tutti i nodi precedenti sono stati testati\",\n      \"invalidOperation\": \"Operazione non valida rilevata, per favore controlla i parametri dell'operazione\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"Il numero di commenti richiesti supera il limite massimo di 1000\",\n      \"invalidContentType\": \"Tipo di contenuto del commento non valido\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"La scadenza del token deve essere inferiore a 7 giorni\",\n      \"s3RegionRequired\": \"La regione S3 è obbligatoria\",\n      \"s3EndpointRequired\": \"L'endpoint S3 è obbligatorio\",\n      \"s3AccessKeyRequired\": \"La chiave di accesso S3 è obbligatoria\",\n      \"s3SecretKeyRequired\": \"La chiave segreta S3 è obbligatoria\",\n      \"s3UploadMethodMustBePut\": \"Il metodo di caricamento S3 deve essere PUT\",\n      \"presignedError\": \"Errore nella generazione dell'URL pre-firmato\",\n      \"invalidObjectMeta\": \"Metadati oggetto non validi\",\n      \"invalidImageStream\": \"Flusso immagine non valido\",\n      \"calculateImageSizeFailed\": \"Errore nel calcolo delle dimensioni dell'immagine\",\n      \"uploadFailed\": \"Caricamento fallito\",\n      \"invalidImage\": \"Immagine non valida\",\n      \"cantGetImageStream\": \"Impossibile ottenere il flusso immagine\",\n      \"invalidProvider\": \"Provider di archiviazione non valido\",\n      \"failedToDeleteDirectory\": \"Errore nell'eliminazione della directory\",\n      \"invalidToken\": \"Token non valido\",\n      \"tokenExpired\": \"Il token è scaduto\",\n      \"sizeMismatch\": \"La dimensione del file non corrisponde\",\n      \"notAllowUploadFileType\": \"Tipo di file {{mimetype}} non consentito per il caricamento\",\n      \"notFound\": \"Allegato non trovato\",\n      \"invalidPath\": \"Percorso allegato non valido\",\n      \"fileSizeExceedsMaximumLimit\": \"La dimensione del file supera il limite massimo di {{maxSize}}\",\n      \"invalidUploadType\": \"Tipo di caricamento non valido\",\n      \"urlReject\": \"URL rifiutato o inaccessibile\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Errore di configurazione della posta {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Questo campo di pulsante è già collegato a {{name}}[{{id}}] questo processo di automazione, per favore seleziona un altro campo di pulsante\",\n      \"triggerNotFound\": \"L'automazione deve avere un nodo trigger\",\n      \"nodeNotFound\": \"{{nodeId}} nodo non trovato\",\n      \"triggerTestFailed\": \"Test del trigger fallito, per favore controlla la configurazione del trigger\",\n      \"testFailed\": \"Test di automazione fallito, controlla la configurazione dell'automazione\",\n      \"runFailed\": \"L'esecuzione dell'automazione non è riuscita\",\n      \"nodeParseError\": \"{{name}} la configurazione del nodo è incompleta o contiene errori, si prega di verificare la configurazione del nodo\",\n      \"nodeNeedTest\": \"{{name}} il nodo deve essere testato\",\n      \"nodeTestOutdated\": \"{{name}} il test del nodo è obsoleto\",\n      \"notFound\": \"Automazione non trovata\",\n      \"currentSnapshotEmpty\": \"L'istantanea corrente dell'automazione è vuota\",\n      \"runNotFound\": \"Esecuzione dell'automazione non trovata\",\n      \"anchorNotFound\": \"Automazione di ancoraggio non trovata\",\n      \"validationError\": \"Errore di validazione della configurazione dell'automazione\",\n      \"tableNotInBase\": \"Puoi abbonarti solo a una tabella all'interno del tuo database\",\n      \"alreadyActiveAndNotDraft\": \"L'automazione è già attiva e non è una bozza\",\n      \"noActiveSnapshot\": \"L'automazione non ha un'istantanea attiva\",\n      \"triggerNodeAlreadyExists\": \"Questa automazione ha già un nodo trigger\",\n      \"generateLogicError\": \"Errore nella generazione del nodo di logica\",\n      \"logicNotFound\": \"Nodo di logica dell'automazione non trovato\",\n      \"actionNotFound\": \"Nodo di azione dell'automazione non trovato\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Duplicazione di questo tipo di nodo automazione non supportata\",\n      \"unSupportLogicType\": \"Tipo di logica non supportato\",\n      \"groupEndNotFound\": \"GroupEnd non trovato per la logica\",\n      \"insertNodeError\": \"Errore nell'inserimento del nodo\",\n      \"controlNodeNotBeTested\": \"Il nodo di controllo non deve essere testato\",\n      \"invalidNodeType\": \"Tipo di nodo non valido\",\n      \"unsupportedCategory\": \"Categoria non supportata\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Impossibile elencare le caselle di posta: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Dataset sconosciuto: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Il servizio di scraping non è configurato\",\n      \"triggerFailed\": \"Attivazione scraping fallita: {{detail}}\",\n      \"snapshotError\": \"Errore snapshot scraping: {{detail}}\",\n      \"timeout\": \"Timeout scraping dopo {{seconds}}s\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Spazio non trovato\",\n      \"noPermission\": \"Non hai il permesso di accedere a questo spazio\",\n      \"disallowSpaceCreation\": \"La creazione di spazi è stata disabilitata dall'amministratore\",\n      \"cannotChangeOnlyOwnerRole\": \"Impossibile modificare il ruolo dell'unico proprietario dello spazio\",\n      \"cannotDeleteOnlyOwner\": \"Impossibile eliminare l'unico proprietario dello spazio\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Impossibile operare su uno spazio, verificare che ci siano membri dell'organizzazione nello spazio e che siano proprietari\",\n      \"notBelongToOrg\": \"Questo spazio non appartiene all'organizzazione\",\n      \"invalidSpaceIds\": \"Gli ID spazio non sono validi: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base non trovata\",\n      \"cannotAccess\": \"Non hai il permesso di accedere alla base {{baseId}}\",\n      \"anchorNotFound\": \"Base di ancoraggio {{anchorId}} non trovata\",\n      \"baseAndSpaceMismatch\": \"Base {{baseId}} e spazio {{spaceId}} non corrispondono\",\n      \"templateNotFound\": \"Modello {{templateId}} non trovato\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"ID base richiesto\",\n      \"nodeIdIsRequired\": \"ID nodo richiesto\",\n      \"invalidResourceType\": \"Tipo di risorsa non valido\",\n      \"notFound\": \"Nodo base non trovato\",\n      \"parentMustBeFolder\": \"Il genitore deve essere una cartella\",\n      \"cannotDuplicateFolder\": \"Impossibile duplicare la cartella\",\n      \"cannotDeleteEmptyFolder\": \"Impossibile eliminare la cartella perché non è vuota\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"È necessario fornire solo parentId o anchorId\",\n      \"cannotMoveToItself\": \"Impossibile spostare il nodo su se stesso\",\n      \"cannotMoveToCircularReference\": \"Impossibile spostare il nodo sul proprio figlio (riferimento circolare)\",\n      \"anchorIdOrParentIdRequired\": \"È necessario fornire almeno parentId o anchorId\",\n      \"parentNotFound\": \"Nodo genitore non trovato\",\n      \"parentIsNotFolder\": \"Il genitore non è una cartella\",\n      \"circularReference\": \"Riferimento circolare rilevato\",\n      \"folderDepthLimitExceeded\": \"Limite di profondità della cartella superato\",\n      \"folderNotFound\": \"Cartella non trovata\",\n      \"anchorNotFound\": \"Nodo di ancoraggio non trovato\",\n      \"nameAlreadyExists\": \"Il nome esiste già\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Dashboard non trovato\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Plugin non trovato\",\n      \"notSupportInstallInView\": \"Il plugin non supporta l'installazione nella vista\",\n      \"userNotFound\": \"Utente del plugin non trovato\",\n      \"invalidSecret\": \"Segreto non valido\",\n      \"invalidRefreshToken\": \"Token di aggiornamento non valido\",\n      \"anomalousToken\": \"Token anomalo\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Pannello plugin non trovato\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Plugin non installato\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Password errata\",\n      \"notAllowedToSubmit\": \"Invio del modulo non consentito\",\n      \"viewRequired\": \"La vista è richiesta per questa operazione\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"L'invio del modulo non è consentito quando sono inclusi campi nascosti\",\n      \"submitRecordsError\": \"Invio del record non riuscito\",\n      \"notAllowedToCopy\": \"Operazione di copia non consentita\",\n      \"fieldHiddenNotAllowed\": \"Il campo è nascosto e non può essere accessibile\",\n      \"fieldTypeNotLinkField\": \"Il campo non è un campo di collegamento\",\n      \"fieldIdRequired\": \"L'ID del campo è richiesto\",\n      \"fieldNotUserRelatedField\": \"Il campo non è un campo correlato all'utente\",\n      \"viewTypeNotAllowed\": \"Questo tipo di vista non è consentito per questa operazione\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"La restrizione della password non è abilitata per questa vista condivisa\",\n      \"shareViewNotFound\": \"Vista condivisa non trovata o la condivisione è disabilitata\",\n      \"linkFieldNotFound\": \"Campo di collegamento non trovato\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Condivisione base non trovata o la condivisione è disabilitata\",\n      \"alreadyExists\": \"Esiste già una condivisione per questo nodo\",\n      \"copyNotAllowed\": \"Questa condivisione non consente la copia\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"Non hai il permesso di accedere a questa vista\",\n      \"fieldPermissionNotAllowed\": \"Non hai il permesso di accedere a questi campi\",\n      \"recordPermissionNotAllowed\": \"Non hai il permesso di accedere a questi record\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Menu contestuale del plugin non trovato\",\n      \"anchorNotFound\": \"Ancoraggio del menu contestuale del plugin non trovato\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Query del grafico del plugin non trovata\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Driver di database non supportato: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Solo il proprietario della base può rimuovere la connessione al database per la base {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Solo il proprietario della base può creare una connessione al database per la base {{baseId}}\",\n      \"roleNotExist\": \"Il ruolo del database {{role}} non esiste\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Query fallita: {{message}}\",\n      \"invalidJoinType\": \"Tipo di join non valido: {{joinType}}\",\n      \"tableNotFound\": \"Tabella {{tableId}} non trovata nella base {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Non è consentito eseguire SQL con la parola chiave {{keyword}}\",\n      \"whiteListCheckError\": \"Si è verificato un errore durante il controllo dell'accesso alla tabella: {{message}}\",\n      \"databaseConnectionFailed\": \"Connessione al database fallita: {{message}}\",\n      \"executeQuerySqlFailed\": \"Esecuzione della query SQL fallita: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"Non hai il permesso di creare record con campi({{fields}})\",\n      \"deleteRecords\": \"Non hai il permesso di eliminare i record({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"Non hai il permesso di leggere i campi({{fields}}) nel record({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"Non hai il permesso di aggiornare i campi({{fields}}) nel record({{recordId}})\",\n      \"checkIdNotExist\": \"L'ID di verifica dei permessi non esiste\",\n      \"userNotAdmin\": \"L'utente non è un amministratore\",\n      \"accessTokenNoPermission\": \"Il token di accesso non ha il permesso richiesto\",\n      \"invalidResource\": \"La risorsa non è valida\",\n      \"notAllowedSpace\": \"Non hai il permesso di accedere a questo spazio\",\n      \"notAllowedBase\": \"Non hai il permesso di accedere a questa base\",\n      \"notAllowedTables\": \"Non hai il permesso di accedere a tabelle({{tableIds}})\",\n      \"notAllowedOperationTable\": \"Non hai il permesso di operare su questa tabella\",\n      \"notAllowedOperationRecord\": \"Non hai il permesso di operare su questo record\",\n      \"notAllowedRecordUpdate\": \"Non hai il permesso di aggiornare questo record\",\n      \"notAllowedOperationView\": \"Non hai il permesso di operare su questa vista\",\n      \"deniedByEnabledAuthorityMatrix\": \"Permesso negato dalla matrice di autorità abilitata\",\n      \"invalidRequestPath\": \"Il percorso della richiesta non è valido\",\n      \"notAllowedOperation\": \"Non hai il permesso di eseguire questa operazione\",\n      \"notAllowedDepartment\": \"Non sei autorizzato ad accedere a questo dipartimento\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Ruolo predefinito non trovato\",\n      \"alreadyDisabled\": \"La matrice di autorità è già disabilitata\",\n      \"alreadyEnabled\": \"La matrice di autorità è già abilitata\",\n      \"notFound\": \"Matrice di autorità non trovata\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Il campo primario non può essere disabilitato per l'accesso in lettura\",\n      \"fieldDuplicated\": \"Il campo è duplicato nella configurazione dei permessi\",\n      \"cannotSetRecordPermissionGroup\": \"Impossibile impostare questa combinazione di permessi di record({{actions}})\",\n      \"notFoundBaseAndTable\": \"ID base e ID tabella non trovati\",\n      \"roleTablesShouldNotBeEmpty\": \"Le tabelle dei ruoli della matrice di autorità non devono essere vuote\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Tipo di ritorno non valido\",\n      \"exceedMaxReadRows\": \"Superato il limite massimo di righe da leggere\",\n      \"invalidCellValueType\": \"Tipo di valore di cella non valido\",\n      \"exceedMaxCopyCells\": \"Superato il limite massimo di celle da copiare\",\n      \"exceedMaxPasteCells\": \"Superato il limite massimo di celle da incollare\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Tipo di campo non supportato {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Tipo di campo non supportato {{type}} come campo primario\",\n      \"primaryFieldNotSupported\": \"Il tipo di campo non è supportato come campo primario\",\n      \"calculateRecordNotFound\": \"Record non trovato per: {{value}}, fieldId: {{fieldId}}, durante il calcolo {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds o fromRecordIds è richiesto per il campo calcolato normale\",\n      \"recordFieldsRequired\": \"I campi del record non sono definiti\",\n      \"uniqueUnsupportedType\": \"Il campo {{name}}[{{fieldId}}] non supporta la validazione del valore del campo unico\",\n      \"notNullValidationWhenCreateField\": \"Il campo {{name}}[{{fieldId}}] non supporta la validazione non nulla durante la creazione di un nuovo campo\",\n      \"dbFieldNameAlreadyExists\": \"Il nome del campo del database {{dbFieldName}} esiste già\",\n      \"fieldValidationError\": \"Campo {{name}}[{{fieldId}}] errore di validazione del campo\",\n      \"fieldNameAlreadyExists\": \"Il nome del campo {{name}} esiste già\",\n      \"notFound\": \"Campo non trovato\",\n      \"fieldKeyTypeNotFound\": \"Campo \\\"{{fieldKeyType}}: {{missedFields}}\\\" non trovato\",\n      \"notFoundInTable\": \"Campo {{fieldId}} non trovato nella tabella {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Campi da eliminare {{fieldIds}} non trovati nella tabella {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues dovrebbe essere un array quando il campo di collegamento ha più valori di cella\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues dovrebbe essere un array quando il campo di collegamento ha più valori di cella\",\n      \"lookupAndLinkLengthMatch\": \"La lunghezza di lookupValues dovrebbe essere uguale alla lunghezza di linkCellValues\",\n      \"cycleDetected\": \"Ciclo rilevato\",\n      \"cycleDetectedCreateField\": \"Ciclo rilevato, impossibile creare il campo {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Record non trovato nella tabella {{tableName}} per il campo {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Vietato eliminare il campo primario\",\n      \"foreignTableIdInvalid\": \"Tabella esterna {{foreignTableId}} non valida\",\n      \"relationshipInvalid\": \"Relazione {{relationship}} non valida\",\n      \"linkFieldIdInvalid\": \"Campo di collegamento {{linkFieldId}} non valido\",\n      \"lookupFieldIdInvalid\": \"Campo di ricerca {{lookupFieldId}} non valido\",\n      \"formulaExpressionParseError\": \"Errore di analisi dell'espressione della formula\",\n      \"formulaReferenceNotFound\": \"Campo di riferimento della formula {{fieldIds}} non trovato\",\n      \"rollupExpressionParseError\": \"Errore di analisi dell'espressione di rollup\",\n      \"choiceNameAlreadyExists\": \"Il nome della scelta {{name}} esiste già\",\n      \"symmetricFieldIdRequired\": \"L'ID del campo simmetrico è richiesto\",\n      \"foreignKeyNameCannotUseId\": \"Il nome della chiave esterna non può usare __id\",\n      \"createForeignKeyError\": \"Errore nella creazione della chiave esterna\",\n      \"lookupFieldTypeNotEqual\": \"Il tipo di campo corrente {{fieldType}} non è uguale al tipo di campo di ricerca {{lookupFieldType}}\",\n      \"recordNotFound\": \"Record {{recordId}} non trovato in {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Impossibile impostare recordId duplicato: {{recordId}} nella stessa cella\",\n      \"oneOneLinkCellValueCannotBeArray\": \"I valori del campo di collegamento uno-a-uno non possono essere un array\",\n      \"manyOneLinkCellValueCannotBeArray\": \"I valori del campo di collegamento molti-a-uno non possono essere un array\",\n      \"foreignKeyDuplicate\": \"Chiave esterna duplicata\",\n      \"linkConsistencyError\": \"Errore di coerenza, recordId {{recordId}} non esiste\",\n      \"oneManyLinkCellValueShouldBeArray\": \"I valori del campo di collegamento uno-a-molti dovrebbero essere un array\",\n      \"manyManyLinkCellValueShouldBeArray\": \"I valori del campo di collegamento molti-a-molti dovrebbero essere un array\",\n      \"onlyLinkFieldCanBeFiltered\": \"Solo i campi di collegamento possono essere utilizzati per il filtro\",\n      \"notLinkedToCurrentTable\": \"Il campo non è collegato alla tabella corrente\",\n      \"notAttachment\": \"Il campo non è un campo allegato\",\n      \"isComputed\": \"Il campo è calcolato e non può essere modificato\",\n      \"notFoundAICofig\": \"Il campo non ha configurazione AI\",\n      \"foreignTableIdRequired\": \"La tabella esterna è obbligatoria\",\n      \"lookupFieldIdRequired\": \"Il campo di ricerca è obbligatorio\",\n      \"lookupFieldNotExist\": \"Il campo di ricerca {{lookupFieldId}} non esiste\",\n      \"lookupFieldNotBelongToTable\": \"Il campo di ricerca {{lookupFieldId}} non appartiene alla tabella {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Il tipo di campo corrente {{fieldType}} non corrisponde al tipo di campo di ricerca {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Le opzioni del campo riepilogo condizionale sono richieste\",\n      \"conditionalRollupParseError\": \"Errore di analisi del riepilogo condizionale: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Le opzioni del campo di ricerca condizionale sono richieste\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Il conteggio dei clic sul pulsante ha raggiunto il limite massimo\",\n        \"notSupportReset\": \"Il campo pulsante non supporta il ripristino\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Vista non trovata\",\n      \"defaultViewNotFound\": \"Vista predefinita non trovata\",\n      \"propertyParseError\": \"Impossibile analizzare la proprietà della vista\",\n      \"primaryFieldCannotBeHidden\": \"Il campo primario non può essere nascosto\",\n      \"filterUnsupportedFieldType\": \"Tipo di campo non supportato dal filtro\",\n      \"sortUnsupportedFieldType\": \"Tipo di campo non supportato dall'ordinamento\",\n      \"groupUnsupportedFieldType\": \"Tipo di campo non supportato dal raggruppamento\",\n      \"anchorNotFound\": \"Vista di ancoraggio non trovata\",\n      \"notEnoughGapToShuffleRow\": \"Spazio insufficiente per mescolare la riga\",\n      \"shareNotEnabled\": \"La condivisione della vista non è abilitata\",\n      \"shareAlreadyEnabled\": \"La condivisione della vista è già abilitata\",\n      \"shareAlreadyDisabled\": \"La condivisione della vista è già disabilitata\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Credito insufficiente\",\n      \"exceedMaxRowLimit\": \"Limite massimo di righe {{maxRowCount}} superato\",\n      \"exceedMaxAutomationRunLimit\": \"Raggiunto il numero massimo mensile di esecuzioni di automazione\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"È richiesta una query di ricerca\",\n      \"maxSearchIndexResult\": \"Il risultato massimo dell'indice di ricerca è 1000\",\n      \"queryCollectionMustBeTableId\": \"La raccolta di query deve essere un ID tabella\",\n      \"searchTimeOut\": \"La ricerca ha scaduto, per favore ridurre l'ambito di ricerca e riprova.\",\n      \"indexNotFound\": \"Indice non trovato\",\n      \"invalidStartDateFieldId\": \"ID campo data di inizio non valido\",\n      \"invalidEndDateFieldId\": \"ID campo data di fine non valido\",\n      \"fieldMapRequired\": \"Mappa campo richiesta quando è impostata la ricerca\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected e filterLinkCellCandidate non possono essere impostati contemporaneamente\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"Il modello di chat IA lg non è impostato\",\n      \"chatModelLgProviderNotSet\": \"Il fornitore del modello di chat IA lg non è impostato\",\n      \"chatModelSmNotSet\": \"Il modello di chat IA sm non è impostato\",\n      \"chatModelMdNotSet\": \"Il modello di chat IA md non è impostato\",\n      \"configurationNotSet\": \"La configurazione IA non è impostata\",\n      \"unsupportedProvider\": \"Fornitore IA non supportato {{type}}\",\n      \"providerConfigurationNotSet\": \"La configurazione del fornitore IA non è impostata\",\n      \"testLLMFailed\": \"Test di connessione LLM fallito\",\n      \"audioNotSupported\": \"L'input audio non è supportato da questo modello {{model}}\",\n      \"imageNotSupported\": \"L'input immagine non è supportato da questo modello {{model}}\",\n      \"modelNotSet\": \"Il modello IA non è impostato\",\n      \"unsupportedFileType\": \"Tipo di file non supportato {{mimetype}}\",\n      \"unsupportedModelType\": \"Tipo di modello non supportato\",\n      \"embeddingModelNotSet\": \"Il modello di embedding non è impostato\",\n      \"validateActionFailed\": \"Validazione dell'azione AI del campo fallita\",\n      \"generateFailed\": \"Generazione AI fallita\",\n      \"unsupportedActionType\": \"Tipo di azione AI non supportato\"\n    },\n    \"role\": {\n      \"notFound\": \"Ruolo non trovato\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Il collaboratore esiste già\",\n      \"notFound\": \"Collaboratore non trovato\",\n      \"userNotFoundInCollaborator\": \"Utente non trovato nei collaboratori\",\n      \"noPermissionToDelete\": \"Non hai il permesso di eliminare questo collaboratore\",\n      \"noPermissionToUpdate\": \"Non hai il permesso di aggiornare questo collaboratore\",\n      \"noPermissionToOperateRole\": \"Non hai il permesso di operare questo ruolo\",\n      \"alreadyExistedInBase\": \"Il collaboratore esiste già nella base\",\n      \"userNotFound\": \"Utente non trovato: {{userIds}}\",\n      \"baseNotFound\": \"Base non trovata\",\n      \"noPermissionToAddRole\": \"Non hai il permesso di aggiungere un collaboratore con questo ruolo\",\n      \"departmentNotFound\": \"Dipartimento non trovato\"\n    },\n    \"table\": {\n      \"notFound\": \"Tabella non trovata\",\n      \"dbTableNameAlreadyExists\": \"Il nome della tabella del database esiste già\",\n      \"anchorNotFound\": \"Tabella di ancoraggio non trovata\",\n      \"notInTrash\": \"La tabella non è nel cestino\",\n      \"notSupportTableIndex\": \"Il tipo di indice della tabella non è supportato\",\n      \"createTableIndexError\": \"Impossibile creare l'indice della tabella\",\n      \"dropTableIndexError\": \"Impossibile eliminare l'indice della tabella\",\n      \"notFoundPrimaryField\": \"Campo primario non trovato nella tabella\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"Il tipo di vista {{viewType}} non è supportato per l'esportazione\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Formato file non supportato, sono supportati solo {{supportType}}, il tipo di contenuto del file è {{fileFormat}}\",\n      \"notSupportedFileType\": \"Tipo di file di importazione non supportato\",\n      \"exceedMaxFieldsLength\": \"Il numero di campi nella tabella non può superare {{maxFieldsLength}}, attualmente è {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"L'istanza corrente non consente inviti allo spazio da parte dell'amministratore\",\n      \"invalidCode\": \"Codice invito non valido\",\n      \"linkNotFound\": \"Link di invito non trovato\",\n      \"linkExpired\": \"Il link di invito è scaduto\",\n      \"limitExceeded\": \"Hai raggiunto il numero massimo di inviti all'ora\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Il preferito esiste già\",\n      \"notFound\": \"Preferito non trovato\",\n      \"anchorNotFound\": \"Ancoraggio del preferito non trovato\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Tipo di risorsa non valido\",\n      \"notFound\": \"Elemento del cestino non trovato\",\n      \"parentSpaceTrashed\": \"Impossibile ripristinare questa base perché il suo spazio padre è anche nel cestino\",\n      \"parentBaseOrSpaceTrashed\": \"Impossibile ripristinare questa tabella perché la sua base o spazio padre è anche nel cestino\",\n      \"parentBaseTrashed\": \"Impossibile ripristinare questo elemento perché la sua base padre è anche nel cestino\",\n      \"parentNotFound\": \"Risorsa padre non trovata\",\n      \"tableNotFound\": \"Elemento del cestino della tabella non trovato\"\n    },\n    \"license\": {\n      \"invalid\": \"La licenza non è valida\",\n      \"instanceIdMismatch\": \"L'ID istanza in entrata non corrisponde all'ID istanza dell'istanza attuale\",\n      \"expired\": \"La licenza è scaduta\",\n      \"userLimitExceeded\": \"Il numero di utenti nell'istanza attuale supera il limite di posti della licenza. Si prega di disattivare alcuni utenti o aggiornare la licenza\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organizzazione non trovata\",\n      \"emailNotSpaceUser\": \"L'email non è un utente dello spazio\",\n      \"authenticationNotFound\": \"Autenticazione non trovata\",\n      \"spaceShouldExist\": \"Lo spazio dell'organizzazione dovrebbe esistere\",\n      \"emailsNotInOrgDomain\": \"Queste email {{emails}} non sono nel dominio dell'organizzazione\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"L'istanza attuale non consente la registrazione da parte dell'amministratore\",\n      \"waitlistInviteCodeRequired\": \"La lista d'attesa è abilitata, è richiesto un codice di invito\",\n      \"waitlistInviteCodeInvalid\": \"La lista d'attesa è abilitata, il codice di invito non è valido\",\n      \"systemUser\": \"L'utente è un utente di sistema\",\n      \"collaboratorsInSpaces\": \"L'utente ha collaboratori negli spazi (o spazi eliminati nel cestino)\",\n      \"notFound\": \"Utente non trovato\",\n      \"cannotDeleteAdmin\": \"Impossibile eliminare l'utente amministratore\",\n      \"cannotDeactivateAdmin\": \"Impossibile disattivare l'utente amministratore\",\n      \"cannotRemoveLastAdmin\": \"Impossibile rimuovere i privilegi di amministratore dall'ultimo utente amministratore attivo\",\n      \"permanentDeleted\": \"L'utente è stato eliminato permanentemente\",\n      \"cannotDeleteSelf\": \"Non puoi cancellare te stesso\",\n      \"alreadyInDepartment\": \"L'utente {{userId}} è già nel dipartimento di destinazione\",\n      \"emailsNotFound\": \"Email {{emails}} non trovate\",\n      \"deleted\": \"L'utente {{userId}} è stato cancellato\",\n      \"alreadyInOrg\": \"L'utente {{userId}} è già nell'organizzazione\",\n      \"notInOrg\": \"L'utente {{userId}} non è nell'organizzazione\"\n    },\n    \"record\": {\n      \"notFound\": \"Record non trovato\",\n      \"deletedIdsNotFound\": \"Alcuni record da eliminare non sono stati trovati\",\n      \"updateFailed\": \"Aggiornamento record fallito\",\n      \"noFileOrUrlProvided\": \"Nessun file o URL fornito\",\n      \"createRecordsEmpty\": \"La creazione di record non può essere vuota\",\n      \"duplicateFailed\": \"Duplicazione record fallita\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Validazione del valore della cella fallita\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Il flusso di lavoro non è attivo\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Tipo di risorsa non valido\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Categoria del modello non trovata\",\n      \"snapshotRequired\": \"Questo modello non può essere pubblicato perché manca l'istantanea\",\n      \"sourceTemplateNotFound\": \"Modello di origine non trovato\",\n      \"noMinOrderFound\": \"Nessun ordine minimo trovato\",\n      \"takeCountTooLarge\": \"Il numero di modelli richiesti supera il limite massimo\",\n      \"categoryLimitReached\": \"Limite categorie modello raggiunto (massimo {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Nessun codice di verifica del dominio trovato\",\n      \"invalidCode\": \"Codice di verifica non valido\",\n      \"resendCooldown\": \"Attendere 1 minuto prima di richiedere un nuovo codice\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Invio email fallito\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Dipartimento padre non trovato\",\n      \"notFound\": \"Dipartimento non trovato\",\n      \"cannotMoveToItself\": \"Impossibile spostare il dipartimento su se stesso\",\n      \"cannotMoveToSub\": \"Impossibile spostare il dipartimento nel suo sotto-dipartimento\"\n    },\n    \"app\": {\n      \"notFound\": \"App non trovata\",\n      \"noFilesToUpdate\": \"Nessun file da aggiornare\",\n      \"noChatIdFound\": \"Nessun ID chat trovato per questa app\",\n      \"noChatFound\": \"Nessuna chat trovata per questa app\",\n      \"versionNotFound\": \"Versione non trovata\",\n      \"cannotRollbackToLatestVersion\": \"Impossibile tornare all'ultima versione\",\n      \"noChatOrProjectTokenFound\": \"Nessuna chat o token di progetto trovato\",\n      \"apiKeyNotSet\": \"La chiave API del costruttore di app non è impostata\",\n      \"cannotDeployAppBeforeInitialization\": \"Impossibile distribuire l'app prima dell'inizializzazione\",\n      \"noProjectOrVersionFound\": \"Nessun progetto o versione trovato\",\n      \"noDeploymentUrlAvailable\": \"Nessun URL di distribuzione disponibile\"\n    },\n    \"reward\": {\n      \"notFound\": \"Premio non trovato\",\n      \"unsupportedSourceType\": \"Tipo di fonte premio non supportato\",\n      \"maxClaimsReached\": \"Hai raggiunto il massimo di richieste premio (2) per questa settimana\",\n      \"verificationFailed\": \"Verifica fallita: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Hai già richiesto un premio per questo account questa settimana\",\n      \"invalidPostUrl\": \"Formato URL del post non valido\",\n      \"postAlreadyUsed\": \"Questo post è già stato utilizzato per richiedere un premio\",\n      \"unsupportedPlatformUrl\": \"URL della piattaforma social non supportato\",\n      \"unsupportedPlatform\": \"Piattaforma non supportata: {{platform}}\",\n      \"minCharCount\": \"Il post deve avere almeno {{count}} caratteri\",\n      \"minFollowerCount\": \"L'account deve avere almeno {{count}} follower\",\n      \"mustMention\": \"Il post deve menzionare {{mention}}\",\n      \"fetchTweetFailed\": \"Impossibile recuperare il tweet X: {{error}}\",\n      \"tweetNotFound\": \"Tweet X non trovato: {{postId}}\",\n      \"fetchUserFailed\": \"Impossibile recuperare l'utente X: {{error}}\",\n      \"xUserNotFound\": \"Utente X non trovato: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Impossibile recuperare il post LinkedIn: {{error}}\",\n      \"linkedInPostNotFound\": \"Post LinkedIn non trovato: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"Autore LinkedIn non trovato: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Impossibile recuperare l'utente LinkedIn: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Token di accesso personale\",\n  \"oauthApps\": \"App OAuth\",\n  \"plugins\": \"Plugin\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Inserisci la tua password per visualizzare questa pagina\",\n    \"submit\": \"Invia\",\n    \"password\": \"Password\",\n    \"passwordTooShort\": \"La password deve contenere almeno 3 caratteri\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Seleziona...\"\n  },\n  \"openOnNewPage\": \"Apri in una nuova pagina\",\n  \"errorTips\": \"La fonte di condivisione ha abilitato una Matrice di Autorità, la visualizzazione non è consentita\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/space.json",
    "content": "{\n  \"initialSpaceName\": \"Spazio di {{name}}\",\n  \"action\": {\n    \"createBase\": \"Crea una base\",\n    \"createSpace\": \"Crea uno spazio\",\n    \"invite\": \"Invita\"\n  },\n  \"allSpaces\": \"Tutti gli spazi\",\n  \"emptySpaceTitle\": \"Non ci sono basi in questo spazio\",\n  \"spaceIsEmpty\": \"Crea la tua prima base per iniziare\",\n  \"baseModal\": {\n    \"copy\": \"Copia\",\n    \"duplicate\": \"Duplica \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Crea base da modello\",\n    \"duplicateRecords\": \"Duplica record\",\n    \"duplicateRecordsTip\": \"La cronologia delle revisioni e i collaboratori non saranno duplicati.\",\n    \"toSpace\": \"Allo spazio\",\n    \"copyToSpace\": \"Copia nello spazio\",\n    \"duplicateBase\": \"Duplica base\",\n    \"missTargetTip\": \"Seleziona uno spazio per duplicare la base.\",\n    \"copying\": \"Duplicazione della base in corso, potrebbe richiedere del tempo...\",\n    \"copyingTemplate\": \"Creazione della base da modello in corso, potrebbe richiedere del tempo...\",\n    \"howToCreate\": \"Come vuoi iniziare?\",\n    \"fromScratch\": \"Da zero\",\n    \"fromTemplate\": \"Da modello\",\n    \"moveBaseToAnotherSpace\": \"Sposta {{baseName}} in un altro spazio\",\n    \"chooseSpace\": \"Scegli uno spazio\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Impostazioni dello spazio\",\n    \"general\": \"Generale\",\n    \"collaborators\": \"Collaboratori\",\n    \"generalDescription\": \"Modifica qui le impostazioni per il tuo spazio corrente\",\n    \"collaboratorDescription\": \"Gestisci i collaboratori del tuo spazio e imposta i loro permessi di accesso\",\n    \"spaceName\": \"Nome dello spazio\",\n    \"spaceId\": \"ID dello spazio\"\n  },\n  \"pin\": {\n    \"add\": \"Aggiungi ai preferiti\",\n    \"remove\": \"Rimuovi dai preferiti\",\n    \"pin\": \"Preferiti\",\n    \"empty\": \"Le tue basi e spazi preferiti appariranno qui\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"Non hai il permesso di creare una base\"\n  },\n  \"tip\": {\n    \"delete\": \"Sei sicuro di voler eliminare <0/>?\",\n    \"title\": \"Suggerimenti\",\n    \"exportTips1\": \"Esporta la base corrente come file .tea, il che potrebbe richiedere del tempo. Puoi controllare i risultati dell'esportazione nel centro notifiche.\",\n    \"exportTips2\": \"Puoi importare il file .tea in spazio -> altro -> importa\",\n    \"exportTips3\": \"I campi di relazione tra basi saranno convertiti in testo a riga singola.\",\n    \"exportIncludeDataLabel\": \"Includi record\",\n    \"exportIncludeDataDescription\": \"Disattiva per esportare solo struttura e configurazione.\",\n    \"moveBaseSuccessTitle\": \"Spostamento riuscito\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} è stato spostato con successo in {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Elimina spazio\",\n    \"blockedTitle\": \"Impossibile eliminare questo spazio\",\n    \"blockedDesc\": \"Questo spazio ha un abbonamento attivo. Si prega di annullare l'abbonamento prima di eliminare lo spazio.\",\n    \"permanentDeleteWarning\": \"Questa azione eliminerà definitivamente tutte le risorse e i dati nello spazio corrente. Si prega di procedere con cautela!\",\n    \"confirmInputLabel\": \"Si prega di digitare DELETE per confermare l'eliminazione\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Basi condivise\",\n    \"description\": \"Tutte le basi a cui sono stato invitato a partecipare\",\n    \"empty\": \"Nessuna base condivisa ancora\"\n  },\n  \"integration\": {\n    \"title\": \"Integrazioni\",\n    \"description\": \"Gestisci le integrazioni del tuo spazio\",\n    \"addIntegration\": \"Aggiungi integrazione\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"Impostazioni AI\",\n    \"description\": \"Gestisci le impostazioni AI del tuo spazio\",\n    \"enableTips\": \"Abilita l'AI per utilizzare le funzionalità AI nel tuo spazio invece di usare l'AI di sistema\",\n    \"enable\": \"Inizializza impostazioni AI\",\n    \"enableSwitchTips\": \"Si prega di configurare il modello di coding prima di abilitare\"\n  },\n  \"import\": {\n    \"importing\": \"Importazione in corso\",\n    \"importWayTip\": \"Clicca o trascina il file in quest'area per caricarlo\",\n    \"baseImportTips\": \"Clicca o trascina il file .tea in quest'area per caricarlo\",\n    \"confirm\": \"Conferma e continua\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Clicca per visualizzare\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Modello\",\n    \"description\": \"Crea rapidamente una nuova base da un modello\",\n    \"noTemplatesAvailable\": \"Nessun modello disponibile\",\n    \"noTemplatesDescription\": \"Non c'è nulla qui al momento\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Visitati di recente\"\n  },\n  \"noBases\": {\n    \"title\": \"Ciao {{userName}}!\",\n    \"description\": \"Inizia a gestire il tuo lavoro con la tua prima base.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Ciao {{userName}}!\",\n    \"description\": \"Crea il tuo primo spazio per iniziare il tuo viaggio di collaborazione dati.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Tutte le basi\",\n    \"owner\": \"Proprietario\",\n    \"createdTime\": \"Creato\",\n    \"lastOpened\": \"Ultima apertura\",\n    \"enter\": \"Entra\",\n    \"noTables\": \"Nessuna tabella\",\n    \"empty\": \"Nessuna base presente\"\n  },\n  \"publishBase\": {\n    \"title\": \"Pubblica Base nella community\",\n    \"description\": \"Pubblica la base con un clic, l'ispirazione non è più sola! Lascia che più persone vedano, usino e facciano Remix della tua creatività, costruendo insieme un business più potente\",\n    \"infoTitle\": \"Informazioni di base\",\n    \"form\": {\n      \"title\": \"Titolo\",\n      \"description\": \"Descrizione\",\n      \"security\": \"Sicurezza\",\n      \"includeNodes\": \"Includi nodi\",\n      \"advanced\": \"Avanzate\",\n      \"publishNode\": \"Pubblica nodi\",\n      \"includeData\": \"Includi dati\",\n      \"defaultActiveNode\": \"Nodo attivo predefinito\",\n      \"select\": \"seleziona\",\n      \"descriptionPlaceholder\": \"Descrivi brevemente la tua idea...\",\n      \"titlePlaceholder\": \"Dai un nome al tuo lavoro...\"\n    },\n    \"publishToCommunity\": \"Pubblica nel centro modelli\",\n    \"publish\": \"Pubblica\",\n    \"publishSuccess\": \"Pubblicazione riuscita!\",\n    \"previewTips\": \"Fai vedere al mondo il tuo lavoro\",\n    \"update\": \"Aggiorna\",\n    \"unPublish\": \"Annulla pubblicazione\",\n    \"unPublishSuccess\": \"Pubblicazione base annullata con successo!\",\n    \"unPublishConfirmTitle\": \"Conferma annullamento pubblicazione\",\n    \"unPublishConfirmDescription\": \"Sei sicuro di voler annullare la pubblicazione di questa base? Non sarà più visibile nel centro modelli della community.\",\n    \"usageCount\": \"Conteggio utilizzo: \",\n    \"uploadCover\": \"Clicca per caricare l'immagine di copertina\",\n    \"changeCover\": \"Clicca per cambiare copertina\",\n    \"uploading\": \"Caricamento immagine...\",\n    \"uploadSuccess\": \"Immagine caricata con successo\",\n    \"uploadFailed\": \"Caricamento fallito\",\n    \"invalidImageType\": \"Seleziona un file immagine\",\n    \"tips\": {\n      \"publishValidation\": \"titolo e descrizione sono obbligatori\",\n      \"atLeastOneNode\": \"Seleziona almeno un nodo da pubblicare\"\n    },\n    \"urlCopied\": \"URL copiato negli appunti!\",\n    \"urlCopiedForDiscord\": \"URL copiato negli appunti! Puoi incollarlo su Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Ufficialmente selezionato come in evidenza. Il tuo modello riceverà più visibilità.\",\n    \"unfeaturedTip\": \"Non ancora in evidenza. Continua a migliorare per avere la possibilità di essere raccomandato. Lascia che più persone vedano il tuo lavoro.\",\n    \"publishSuccessDescription\": \"Condividi il tuo lavoro con il mondo\",\n    \"shareWith\": \"Condividi con\",\n    \"unpublishedApps\": {\n      \"title\": \"App non pubblicate rilevate\",\n      \"description\": \"Le app non pubblicate potrebbero causare errori nell'anteprima del modello. Pubblicale ora o continua comunque.\",\n      \"publishAll\": \"Pubblica tutte\",\n      \"publish\": \"Pubblica\",\n      \"published\": \"Pubblicata\",\n      \"publishing\": \"Pubblicazione...\",\n      \"publishFailed\": \"Pubblicazione fallita\",\n      \"publishFailedTip1\": \"Verifica se l'app di origine può essere pubblicata correttamente\",\n      \"publishFailedTip2\": \"Prova a ripubblicare questo modello\",\n      \"notPublished\": \"Non pubblicata\",\n      \"ignoreAndContinue\": \"Ignora e continua\",\n      \"goToFix\": \"Vai a correggere\",\n      \"redeploy\": \"Ridistribuisci\",\n      \"unnamedApp\": \"App senza nome\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Prossimamente\",\n    \"viewFilterInShare\": \"Questa vista è utilizzata in un link di condivisione della vista. Le modifiche alla configurazione della vista cambieranno anche il link di condivisione della vista.\",\n    \"createFieldButtonText\": \"Crea un nuovo campo <0/>\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Condividi\",\n        \"statusLabel\": \"Condividi vista sul web\",\n        \"noPermission\": \"Nessun permesso per condividere la vista\",\n        \"shareLink\": \"Condividi Link\",\n        \"copied\": \"Copiato\",\n        \"genLink\": \"Genera nuovo link\",\n        \"allowCopy\": \"Consenti ai visualizzatori di copiare i dati da questa vista\",\n        \"showAllFields\": \"Mostra tutti i campi nei record espansi\",\n        \"restrict\": \"Restringi con password\",\n        \"tips\": \"Le persone che hanno il link possono vedere la vista.\",\n        \"passwordTitle\": \"Inserisci una password\",\n        \"passwordTips\": \"Restrizioni di password per accedere alle viste condivise\",\n        \"embed\": \"Incorpora\",\n        \"embedPreview\": \"Anteprima incorporamento\",\n        \"hideToolbar\": \"Nascondi barra degli strumenti\",\n        \"URLSetting\": \"Configurazione parametri URL\",\n        \"URLSettingDescription\": \"Regolare le seguenti impostazioni non influirà sui link condivisi. È necessario copiare il link con i nuovi parametri per renderlo effettivo\",\n        \"cancel\": \"Annulla\",\n        \"save\": \"Salva\",\n        \"requireLogin\": \"Richiedi accesso per inviare\"\n      },\n      \"extensions\": {\n        \"label\": \"Estensioni\",\n        \"graph\": \"Grafico\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"API Restful\",\n        \"databaseConnection\": \"Connessione al Database\"\n      },\n      \"personalView\": {\n        \"personal\": \"Personale\",\n        \"tip\": \"Dopo l’attivazione, la configurazione della visualizzazione si applicherà solo a te\",\n        \"collaborative\": \"Collaborativo\",\n        \"dialog\": {\n          \"title\": \"Esci dalla modalità personale\",\n          \"description\": \"La configurazione della vista personale verrà ripristinata allo stato di collaborazione in tempo reale; puoi anche salvare le impostazioni della vista personale e sincronizzarle con tutti.\",\n          \"cancelText\": \"Esci e sincronizza\",\n          \"confirmText\": \"Conferma uscita\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Benvenuto\",\n    \"emptyTitle\": \"Inizia a costruire il tuo database\",\n    \"description\": \"Fai clic sul pulsante \\\"+\\\" nella barra laterale per aggiungere risorse\",\n    \"help\": \"Visita il <HelpCenter /> per maggiori informazioni.\",\n    \"helpCenter\": \"Centro Assistenza\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Impossibile collegare il record: nello stesso batch è già collegato da un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.\",\n      \"one_many_duplicate\": \"Impossibile collegare il record: è già collegato a un altro record. Nelle relazioni uno-a-molti, ogni record figlio può appartenere a un solo record padre.\",\n      \"one_one_duplicate\": \"Impossibile collegare il record: il record di destinazione è già collegato da un altro record in una relazione uno-a-uno.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"La tabella \\\"{{tableName}}\\\" può avere al massimo {{maxFieldCount}} campi.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Gestione dei Campi\",\n    \"fieldManagementDesc\": \"Proprietà dettagliate per tutti i campi della tabella corrente\",\n    \"advancedProps\": \"Proprietà avanzate\",\n    \"hide\": \"nascondi\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Etichetta\"\n      },\n      \"longText\": {\n        \"title\": \"Note\"\n      },\n      \"number\": {\n        \"title\": \"Numero\",\n        \"formatType\": \"Tipo di Formato\",\n        \"currencySymbol\": \"Simbolo della Valuta\",\n        \"defaultSymbol\": \"$\",\n        \"precision\": \"Precisione\",\n        \"decimalExample\": \"Numero (123)\",\n        \"currencyExample\": \"Valuta ($100)\",\n        \"percentExample\": \"Percentuale (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Stato\",\n        \"options\": {\n          \"todo\": \"Da fare\",\n          \"inProgress\": \"In corso\",\n          \"done\": \"Fatto\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Tag\"\n      },\n      \"attachment\": {\n        \"title\": \"Allegati\"\n      },\n      \"user\": {\n        \"title\": \"Collaboratore\"\n      },\n      \"date\": {\n        \"title\": \"Data\",\n        \"dateFormatting\": \"Formattazione della Data\",\n        \"timeFormatting\": \"Formattazione dell'Ora\",\n        \"timeZone\": \"Fuso Orario\",\n        \"yearMonth\": \"Anno/Mese\",\n        \"monthDay\": \"Mese/Giorno\",\n        \"year\": \"Anno\",\n        \"month\": \"Mese\",\n        \"day\": \"Giorno\",\n        \"local\": \"Locale\",\n        \"friendly\": \"Amichevole\",\n        \"us\": \"USA\",\n        \"european\": \"Europeo\",\n        \"asia\": \"Asia\",\n        \"custom\": \"Personalizzato\",\n        \"12Hour\": \"12 ore\",\n        \"24Hour\": \"24 ore\",\n        \"noDisplay\": \"Nessuna visualizzazione\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Ora di creazione\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Ultima modifica\"\n      },\n      \"createdBy\": {\n        \"title\": \"Creato da\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Ultima modifica di\"\n      },\n      \"rating\": {\n        \"title\": \"Valutazione\"\n      },\n      \"checkbox\": {\n        \"title\": \"Fatto\"\n      },\n      \"button\": {\n        \"title\": \"Pulsante\",\n        \"label\": \"Testo del pulsante\",\n        \"color\": \"Colore del pulsante\",\n        \"limitCount\": \"Limita conteggio clic\",\n        \"resetCount\": \"Consenti reimpostazione conteggio clic\",\n        \"maxCount\": \"Massimo di clic\",\n        \"automation\": \"Automazione\",\n        \"customAutomation\": \"Automazione personalizzata\",\n        \"clickConfirm\": \"Conferma prima di cliccare\",\n        \"confirmTitle\": \"Titolo\",\n        \"confirmDescription\": \"Contenuto\",\n        \"confirmButtonText\": \"Testo del pulsante di conferma\"\n      },\n      \"formula\": {\n        \"title\": \"Calcolo\",\n        \"formula\": \"Formula\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (da {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (filtrato da {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"Rollup {{lookupFieldName}} (da {{linkFieldName}})\",\n        \"rollup\": \"Rollup\",\n        \"selectAnRollupFunction\": \"Seleziona una funzione di rollup\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Restituisce vero se tutti i valori sono veri\",\n          \"arrayCompact\": \"Rimuove stringhe vuote e valori nulli dall'array. Mantiene 'false' e stringhe che contengono uno o più caratteri vuoti.\",\n          \"arrayJoin\": \"Unisce tutti i valori in una singola stringa separata da virgole.\",\n          \"arrayUnique\": \"Restituisce solo elementi unici.\",\n          \"average\": \"Media aritmetica dei valori.\",\n          \"concatenate\": \"Unisce insieme i valori di testo in un singolo valore di testo.\",\n          \"count\": \"Conta solo i valori numerici non vuoti. Se vuoi contare tutti i record, usa COUNTALL.\",\n          \"countA\": \"Conta il numero di valori non vuoti. Questa funzione conta sia valori numerici che di testo.\",\n          \"countAll\": \"Conta tutti i valori inclusi i record vuoti.\",\n          \"max\": \"Restituisce il numero più grande tra quelli forniti.\",\n          \"min\": \"Restituisce il numero più piccolo tra quelli forniti.\",\n          \"or\": \"Restituisce vero se uno qualsiasi dei valori è vero.\",\n          \"sum\": \"Somma insieme i valori.\",\n          \"xor\": \"Restituisce vero se e solo se un numero dispari di valori è vero.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} rollup condizionale\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Aggiungi Campo\",\n      \"editField\": \"Modifica Campo\",\n      \"insertField\": \"Inserisci Campo\",\n      \"graph\": \"Grafico\",\n      \"defaultValue\": \"Valore predefinito\",\n      \"reset\": \"Reimposta\",\n      \"fieldUpdated\": \"Il campo è stato aggiornato\",\n      \"fieldCreated\": \"Il campo è stato creato\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Sei sicuro di voler eseguire questa operazione?\",\n      \"addDescription\": \"Aggiungi Descrizione\",\n      \"dbFieldName\": \"Nome campo DB\",\n      \"description\": \"Descrizione\",\n      \"descriptionPlaceholder\": \"Descrivi questo campo (opzionale)\",\n      \"type\": \"Tipo\",\n      \"showAs\": \"Mostra come\",\n      \"color\": \"Colore\",\n      \"number\": \"Numero\",\n      \"chartBar\": \"Grafico a Barre\",\n      \"chartLine\": \"Grafico a Linee\",\n      \"ring\": \"Anello\",\n      \"bar\": \"Barra\",\n      \"text\": \"Testo\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"Email\",\n      \"phone\": \"Telefono\",\n      \"maxNumber\": \"Numero massimo\",\n      \"showNumber\": \"Mostra Numero\",\n      \"autoFillDate\": \"Compila automaticamente con la data corrente\",\n      \"createSymmetricLink\": \"Crea un campo di collegamento inverso nella tabella di collegamento\",\n      \"allowLinkMultipleRecords\": \"Consenti selezione multipla\",\n      \"allowLinkToDuplicateRecords\": \"Consenti che i record siano selezionati più di una volta\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Consenti che i record siano selezionati più di una volta\",\n      \"oneToOne\": \"uno-a-uno\",\n      \"oneToMany\": \"uno-a-molti\",\n      \"manyToOne\": \"molti-a-uno\",\n      \"manyToMany\": \"molti-a-molti\",\n      \"self\": \"Se stesso\",\n      \"selectTable\": \"Seleziona tabella...\",\n      \"selectBase\": \"Seleziona una base...\",\n      \"linkFromAnotherBase\": \"Collega da base esterna\",\n      \"inSelfLink\": \"in auto-collegamento\",\n      \"betweenTwoTables\": \"tra due tabelle\",\n      \"tips\": \"Suggerimenti\",\n      \"linkTipMessage\": \"Qquesta configurazione rappresenta una<br></br> <b>{{relationship}}</b> relazione <span>{{linkType}}</span>\",\n      \"style\": \"Stile\",\n      \"maximum\": \"Massimo\",\n      \"addOption\": \"Aggiungi opzione\",\n      \"allowMultiUsers\": \"Consenti l'aggiunta di più utenti\",\n      \"notifyUsers\": \"Notifica agli utenti una volta selezionati\",\n      \"searchTable\": \"Cerca tabella...\",\n      \"calculating\": \"Calcolo in corso...\",\n      \"doSaveChanges\": \"Vuoi salvare le modifiche apportate?\",\n      \"linkFieldToLookup\": \"Campo record collegato da utilizzare per la ricerca\",\n      \"lookupToTable\": \"Campo di <bold>{{tableName}}</bold> che vuoi cercare\",\n      \"rollupToTable\": \"Campo di <bold>{{tableName}}</bold> che vuoi cercare\",\n      \"selectField\": \"Seleziona un campo...\",\n      \"linkTable\": \"Collega tabella\",\n      \"linkBase\": \"Collega base\",\n      \"tableNoPermission\": \"Nessun permesso tabella\",\n      \"baseNoPermission\": \"Nessun permesso base\",\n      \"noLinkTip\": \"Nessun record collegato da cercare. Aggiungi un campo di collegamento a un altro record, quindi prova a configurare di nuovo la tua ricerca.\",\n      \"fieldValidationRules\": \"Regole di validazione del valore del campo\",\n      \"enableValidateFieldUnique\": \"Vieta valori duplicati\",\n      \"enableValidateFieldNotNull\": \"Obbligatorio\",\n      \"knowMore\": \"saperne di più\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Mostra record per campo\",\n      \"filterByView\": \"Filtra record per vista\",\n      \"filter\": \"Filtra record\",\n      \"hideFields\": \"Nascondi campi\",\n      \"moreOptions\": \"Altre opzioni\",\n      \"allowNewOptionsWhenEditing\": \"Consenti nuove opzioni durante la modifica\",\n      \"deleteField\": {\n        \"title\": \"Elimina campo\",\n        \"simpleConfirm\": \"Sei sicuro di voler eliminare il campo <b>{{fieldName}}</b>?\",\n        \"withDependencies\": \"L'eliminazione del campo <b>{{fieldName}}</b> influenzerà i seguenti campi:\",\n        \"affectedFields\": \"Campi interessati:\",\n        \"fieldsToDelete\": \"Campi da eliminare ({{count}})\",\n        \"unviewedHint\": \"{{count}} campo/i non esaminato/i\",\n        \"deleteCount\": \"Elimina {{count}} campi\",\n        \"noAffectedFields\": \"Questo campo non è referenziato da altri campi.\",\n        \"riskIdentified\": \"Rischio identificato({{count}})\",\n        \"noDependencies\": \"Nessuna dipendenza({{count}})\",\n        \"safeToDelete\": \"Sicuro da eliminare\",\n        \"safeToDeleteDesc\": \"Questo campo non è referenziato da altri campi, può essere eliminato in sicurezza\",\n        \"affectedItems\": \"Elementi interessati\",\n        \"type\": \"Tipo\",\n        \"source\": \"Fonte\",\n        \"sourceTable\": \"Tabella di origine\",\n        \"typeField\": \"Campo\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Collega ai record nella tabella che scegli\",\n      \"singleLineText\": \"Inserisci testo, o precompila ogni nuova cella con un valore predefinito.\",\n      \"longText\": \"Inserisci più righe di testo.\",\n      \"attachment\": \"Aggiungi o genera con IA immagini, oppure carica documenti o altri file da visualizzare o scaricare.\",\n      \"checkbox\": \"Seleziona o deseleziona per indicare lo stato.\",\n      \"multipleSelect\": \"Seleziona una o più opzioni predefinite in un elenco.\",\n      \"singleSelect\": \"Seleziona un'opzione predefinita da un elenco, o precompila ogni nuova cella con un'opzione predefinita.\",\n      \"user\": \"Aggiungi un utente a un record.\",\n      \"date\": \"Inserisci una data (es. 11/12/2023) o scegline una da un calendario.\",\n      \"number\": \"Inserisci un numero, o precompila ogni nuova cella con un valore predefinito.\",\n      \"duration\": \"Inserisci una durata di tempo in ore, minuti o secondi (es. 1:23).\",\n      \"rating\": \"Aggiungi una valutazione su una scala predefinita.\",\n      \"formula\": \"Calcola valori basati sui campi.\",\n      \"rollup\": \"Riepiloga dati da record collegati.\",\n      \"conditionalLookup\": \"Mostra valori collegati che rispettano i filtri impostati.\",\n      \"count\": \"Conta il numero di record collegati.\",\n      \"createdTime\": \"Visualizza la data e l'ora in cui ogni record è stato creato.\",\n      \"lastModifiedTime\": \"Visualizza la data e l'ora dell'ultima modifica a uno o tutti i campi in un record.\",\n      \"createdBy\": \"Visualizza quale utente ha creato il record.\",\n      \"lastModifiedBy\": \"Visualizza quale utente ha effettuato l'ultima modifica a uno o tutti i campi in un record.\",\n      \"autoNumber\": \"Genera automaticamente numeri incrementali unici per ogni record.\",\n      \"button\": \"Attiva un'azione personalizzata.\",\n      \"lookup\": \"Visualizza valori da un campo in un record collegato.\"\n    },\n    \"fieldName\": \"Nome del Campo\",\n    \"fieldNameOptional\": \"Nome del Campo (Opzionale)\",\n    \"fieldType\": \"Tipo di Campo\",\n    \"aiConfig\": {\n      \"title\": \"Configurazione AI\",\n      \"type\": {\n        \"summary\": \"Riepilogo\",\n        \"translation\": \"Traduzione\",\n        \"extraction\": \"Estrazione Informazioni\",\n        \"improvement\": \"Miglioramento\",\n        \"tag\": \"Smart-Tag\",\n        \"classification\": \"Smart-Classify\",\n        \"customization\": \"Personalizzazione\",\n        \"imageGeneration\": \"Generazione di immagini\",\n        \"rating\": \"Valutazione di immagini\"\n      },\n      \"label\": {\n        \"type\": \"Tipo di Azione AI\",\n        \"model\": \"Modello AI\",\n        \"targetLanguage\": \"Lingua Target\",\n        \"sourceField\": \"Campo di Origine\",\n        \"sourceFieldForTag\": \"Seleziona un campo, corrispondilo ai tag creati\",\n        \"sourceFieldForClassify\": \"Seleziona un campo, corrispondilo alle classificazioni create\",\n        \"attachPrompt\": \"Attacca Requisiti\",\n        \"prompt\": \"Personalizza Prompt\",\n        \"sourceFieldForAttachment\": \"Campo di origine per l'allegato\",\n        \"imageSize\": \"Dimensione dell'immagine\",\n        \"imageQuality\": \"Qualità dell'immagine\",\n        \"imageCount\": \"Numero di immagini\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Riepilogo del contenuto\",\n        \"translate\": \"Traduzione concisa e facile da capire, leggera\",\n        \"extractInfo\": \"Estrai email, telefono, nome, indirizzo...\",\n        \"extractDate\": \"Estrai ora di inizio\",\n        \"improveText\": \"Formale, amichevole, umoristica...\",\n        \"attachPromptForTag\": \"Non consentito superare tre tag\",\n        \"attachPromptForClassify\": \"Classifica “In Progress” come “No Risk”\",\n        \"attachPrompt\": \"Inserisci ulteriori requisiti\",\n        \"prompt\": \"Inserisci un prompt personalizzato\",\n        \"type\": \"Seleziona Azione AI\",\n        \"targetLanguage\": \"Inglese, Cinese, Francese...\",\n        \"imageSize\": \"Inserisci la dimensione dell'immagine\",\n        \"imageQuality\": \"Inserisci la qualità dell'immagine\",\n        \"attachPromptForImageGeneration\": \"L'immagine deve essere vivida e naturale\",\n        \"attachPromptForRating\": \"Valuta la qualità dell'immagine\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Basso\",\n        \"medium\": \"Medio\",\n        \"high\": \"Alto\"\n      },\n      \"autoFill\": {\n        \"title\": \"Aggiorna Automaticamente\",\n        \"tip\": \"Dopo averlo abilitato, il campo corrente verrà aggiornato sincronizzando il contenuto con le modifiche della configurazione AI\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Aggiorna tutti i record\",\n        \"description\": \"Tutti i record nella vista corrente verranno aggiornati, incluso tutti i dati generati dal campo\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Generare l'intera colonna?\",\n        \"description\": \"La generazione dell'intera colonna aggiornerà {{rowCount}} righe. Questa operazione può consumare molte risorse di IA.\",\n        \"saveConfigOnly\": \"Salva solo la configurazione\",\n        \"generate\": \"Genera\",\n        \"generateFailed\": \"Generazione non riuscita\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Aggiungi allegato\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Nuova tabella\",\n    \"rename\": \"Rinomina\",\n    \"design\": \"Progetta\",\n    \"tableRecordHistory\": \"Cronologia dei record della tabella\",\n    \"deleteConfirm\": \"Sei sicuro di voler eliminare \\\"{{tableName}}\\\"?\",\n    \"dbTableName\": \"Nome della tabella nel database fisico\",\n    \"schemaName\": \"Nome dello schema nel database fisico\",\n    \"baseInfo\": \"Informazioni di Base\",\n    \"typeOfDatabase\": \"Tipo di database\",\n    \"descriptionForTable\": \"Descrizione per questa tabella\",\n    \"nameForTable\": \"Nome per questa tabella\",\n    \"deleteTip1\": \"I campi di collegamento collegati a questa tabella in altre tabelle saranno eliminati.\",\n    \"deleteTip2\": \"Questa tabella può essere ripristinata dal cestino dopo l'eliminazione.\",\n    \"operator\": {\n      \"createBlank\": \"Nuova tabella\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Copia e incolla funziona solo in HTTPS o localhost\",\n      \"copyAndPasteBrowser\": \"Copia e incolla non supportato in questo browser\",\n      \"copying\": \"Copia in corso...\",\n      \"copySuccessful\": \"Copia riuscita\",\n      \"copyFailed\": \"Copia fallita\",\n      \"pasting\": \"Incolla in corso...\",\n      \"pasteSuccessful\": \"Incolla riuscita\",\n      \"pasteFailed\": \"Incolla fallita\",\n      \"filling\": \"Riempimento in corso...\",\n      \"fillSuccessful\": \"Riempimento riuscito\",\n      \"fillFailed\": \"Riempimento fallito\",\n      \"clearing\": \"Cancellazione in corso...\",\n      \"clearSuccessful\": \"Cancellazione riuscita\",\n      \"deleteFieldConfirmTitle\": \"Elimina i seguenti campi\",\n      \"deleting\": \"Eliminazione in corso...\",\n      \"deleteSuccessful\": \"Eliminazione riuscita\",\n      \"pasteFileFailed\": \"I file possono essere incollati solo in un campo di allegato\",\n      \"copyError\": {\n        \"noFocus\": \"Si prega di mantenere la pagina attiva e non cambiare finestra\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Etichetta della tabella\",\n      \"effectCells\": \"Può influire sulle celle\",\n      \"estimatedTime\": \"Tempo stimato\",\n      \"linkFieldCount\": \"Influisce sul numero di campi collegati\"\n    },\n    \"integrity\": {\n      \"check\": \"Controlla\",\n      \"title\": \"Controllo dell'integrità\",\n      \"loading\": \"Controllo dell'integrità in corso...\",\n      \"allGood\": \"Tutto bene!\",\n      \"fixIssues\": \"Risolvi Problemi\"\n    },\n    \"index\": {\n      \"description\": \"Gli indici possono migliorare significativamente le prestazioni di ricerca, specialmente quando si gestiscono grandi quantità di dati (oltre {{rowCount}} righe). Lo svantaggio è una leggera riduzione delle operazioni di scrittura. Se esegui frequentemente ricerche o hai grandi set di dati, si consiglia di abilitare l'indicizzazione.\",\n      \"repair\": \"ripara\",\n      \"repairTip\": \"Rilevate anomalie nell'indice, che possono causare un degrado delle prestazioni di ricerca. Si consiglia di cliccare sul pulsante di riparazione per correggere l'indice\",\n      \"enableIndexTip\": \"Stai creando un indice. Il tempo richiesto dipende dalla dimensione della tabella. Durante questo processo, le prestazioni di lettura e scrittura della tabella potrebbero essere influenzate. Si prega di essere pazienti.\",\n      \"globalSearchTip_infinity\": \"Cerca in tutti i campi tranne i campi data, casella di controllo e pulsante\",\n      \"globalSearchTip_limited\": \"Cerca in tutti i campi tranne i campi data, casella di controllo e pulsante, massimo {{count}} campi\",\n      \"autoIndexTip\": \"La tua tabella ha superato {{rowCount}} righe. Si consiglia di abilitare l'indicizzazione per migliorare le prestazioni di ricerca. Si prega di notare che l'indicizzazione può ridurre leggermente la velocità di scrittura. Durante la creazione dell'indice, le prestazioni di lettura/scrittura della tabella potrebbero essere temporaneamente influenzate. Si prega di essere pazienti.\",\n      \"enableIndex\": \"abilita\",\n      \"keepAsIs\": \"mantieni così com'è\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Carica\",\n      \"import\": \"Importa\",\n      \"localFile\": \"File Locali\",\n      \"linkUrl\": \"Link(URL)\",\n      \"linkUrlInputTitle\": \"Aggiungi file da URL\",\n      \"importTitle\": \"Crea una nuova tabella\",\n      \"incrementImportTitle\": \"Importa In — \",\n      \"optionsTitle\": \"Opzioni di importazione\",\n      \"primitiveFields\": \"Campi Primitivi\",\n      \"importFields\": \"Campo di Importazione\",\n      \"primaryField\": \"Campo Primario\",\n      \"tipsTitle\": \"Suggerimenti\",\n      \"confirm\": \"Conferma e continua\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Aggiungi da altre fonti\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"File Csv\",\n      \"importCsvData\": \"Importa dati CSV\",\n      \"importExcelData\": \"Importa dati Microsoft Excel\",\n      \"cancel\": \"Annulla\",\n      \"leave\": \"Lascia\",\n      \"downAsCsv\": \"Scarica csv\",\n      \"importData\": \"Importa Dati\",\n      \"duplicate\": \"Duplica\",\n      \"duplicating\": \"Duplicazione in corso...\",\n      \"duplicateSuccess\": \"Duplicato con successo\",\n      \"duplicateFailed\": \"Duplicazione fallita\",\n      \"importing\": \"Importazione\",\n      \"includeRecords\": \"Includi record\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Clicca o trascina il file in quest'area per caricare\",\n      \"leaveTip\": \"I tuoi dati saranno comunque importati.\",\n      \"fileExceedSizeTip\": \"La dimensione del file di questo tipo supera il limite di\",\n      \"analyzing\": \"analisi in corso\",\n      \"importing\": \"Importazione in corso\",\n      \"notSupportFieldType\": \"Tipo di campo non supportato\",\n      \"resultEmpty\": \"Nessun risultato trovato.\",\n      \"searchPlaceholder\": \"Cerca...\",\n      \"importAlert\": \"Una volta iniziata l'importazione, non può essere fermata fino a quando non è completata con successo o terminata a causa di un errore. Lo stato dell'importazione è visualizzato nell'angolo in alto a destra della tabella. I risultati del processo di importazione ti saranno notificati una volta completati. Non dovresti aggiornare il campo durante l'importazione poiché potrebbe causare errori.\",\n      \"noTips\": \"Lo so, non mostrare di nuovo\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Seleziona automaticamente i tipi di campo\",\n      \"useFirstRowAsHeaderOptionName\": \"Usa la prima riga come intestazione\",\n      \"importDataOptionName\": \"Importa Dati\",\n      \"sheetKey\": \"NomeFoglio: \",\n      \"excludeFirstRow\": \"Escludi la prima riga nell'importazione\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Campo\",\n      \"error\": {\n        \"urlEmptyTip\": \"L'URL non deve essere vuoto!\",\n        \"errorFileFormat\": \"Formato del file non corretto!\",\n        \"uniqueFieldName\": \"Il nome del campo deve essere univoco!\",\n        \"fieldNameEmpty\": \"Il nome del campo non deve essere vuoto!\",\n        \"atLeastAImportField\": \"Si prega di impostare almeno un campo di importazione\",\n        \"urlValidateTip\": \"Impossibile analizzare l'URL. Prova un URL diverso!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Non importare\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Scarica Csv\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Aggiungi nuovo record\",\n    \"prefillingRowTooltip\": \"Inserisci i dati del nuovo record qui sotto. Il record verrà salvato automaticamente una volta che clicchi fuori da questa riga.\",\n    \"presortRowTitle\": \"Questo record è stato filtrato o spostato a causa delle regole di ordinamento\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Campi\",\n    \"addAll\": \"Aggiungi Tutto\",\n    \"removeAll\": \"Rimuovi Tutto\",\n    \"hideFieldTip\": \"Nascondi il campo qui\",\n    \"unableAddFieldTip\": \"Impossibile aggiungere questo tipo di campo\",\n    \"removeFromFormTip\": \"Rimuovi dal modulo\",\n    \"descriptionPlaceholder\": \"Inserisci la descrizione del modulo\",\n    \"dragToFormTip\": \"Trascina il campo qui per aggiungerlo al modulo\",\n    \"protectedFieldTip\": \"Questo campo è stato impostato come campo \\\"obbligatorio\\\" e non può essere rimosso nella vista modulo. Si prega di modificarlo nelle impostazioni del campo.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Nascondi nome campo\",\n      \"customizeCards\": \"Personalizza carte\",\n      \"stackedBy\": \"Impilato per <0/>\",\n      \"chooseStackingField\": \"Scegli un campo di impilamento\",\n      \"chooseStackingFieldDescription\": \"Quale campo vorresti usare per questa vista kanban? I tuoi record saranno impilati in base a questo campo.\",\n      \"hideEmptyStack\": \"Nascondi pila vuota\",\n      \"imageSetting\": \"Impostazione immagine\",\n      \"fit\": \"Adatta\",\n      \"noImage\": \"Nessuna immagine\",\n      \"chooseAttachmentField\": \"Scegli campo allegato\"\n    },\n    \"stack\": {\n      \"addStack\": \"Aggiungi Pila\",\n      \"noCards\": \"Nessuna carta\",\n      \"uncategorized\": \"Non categorizzato\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Comprimi pila\",\n      \"renameStack\": \"Rinomina pila\",\n      \"deleteStack\": \"Elimina pila\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Inserisci carta sopra\",\n      \"insertCardBelow\": \"Inserisci carta sotto\",\n      \"expandCard\": \"Espandi carta\",\n      \"deleteCard\": \"Elimina carta\",\n      \"duplicateCard\": \"Duplica carta\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Configurazione Calendario\",\n      \"startDateField\": \"Campo data di inizio\",\n      \"endDateField\": \"Campo data di fine\",\n      \"titleField\": \"Campo titolo\",\n      \"colorField\": \"Campo colore\",\n      \"colorType\": \"Visualizzazione colore\",\n      \"customColor\": \"Personalizza colore\",\n      \"alignWithRecords\": \"Allinea con i record\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Seleziona un campo colore\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Data di inizio\",\n      \"endDate\": \"Data di fine\",\n      \"notAdd\": \"Non aggiungere\",\n      \"addDateField\": \"Aggiungi campi data\",\n      \"content\": \"Crea una pianificazione della vista calendario, e la tabella deve avere due campi data: data di inizio e data di fine\"\n    },\n    \"moreLinkText\": \"Mostra tutti i {{count}} record\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Inserisci <input /> record sopra\",\n    \"insertRecordBelow\": \"Inserisci <input /> record sotto\",\n    \"copyCells\": \"Copia celle\",\n    \"deleteRecord\": \"Elimina record\",\n    \"deleteAllSelectedRecords\": \"Elimina tutti i record selezionati\",\n    \"editField\": \"Modifica campo\",\n    \"insertFieldLeft\": \"Inserisci a sinistra\",\n    \"insertFieldRight\": \"Inserisci a destra\",\n    \"freezeUpField\": \"Blocca fino a questo campo\",\n    \"hideField\": \"Nascondi campo\",\n    \"deleteField\": \"Elimina campo\",\n    \"deleteAllSelectedFields\": \"Elimina tutti i campi selezionati\",\n    \"filterField\": \"Filtra per questo campo\",\n    \"sortField\": \"Ordina per questo campo\",\n    \"groupField\": \"Raggruppa per questo campo\",\n    \"autoFill\": \"Aggiorna tutti i record\",\n    \"groupMenuTitle\": \"Menu di raggruppamento\",\n    \"expandGroup\": \"Espandi questo gruppo e i suoi sottogruppi\",\n    \"collapseGroup\": \"Comprimi questo gruppo e i suoi sottogruppi\",\n    \"expandAllGroups\": \"Espandi tutti i gruppi\",\n    \"collapseAllGroups\": \"Comprimi tutti i gruppi\",\n    \"addToChat\": \"Aggiungi alla chat\"\n  },\n  \"connection\": {\n    \"title\": \"Connessione al Database\",\n    \"description\": \"Puoi accedere direttamente al database tramite la connessione al database, inclusi tutti i tavoli sotto la base corrente\",\n    \"noPermission\": \"Non hai il permesso di accedere al database\",\n    \"connectionCountTip\": \"Il numero massimo di connessioni al database è <b>{{max}}</b> e le connessioni attuali sono <b>{{current}}</b>\",\n    \"createFailed\": \"Assicurati che la variabile d'ambiente PUBLIC_DATABASE_PROXY sia impostata correttamente\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Aggiungi record\",\n    \"searchView\": \"Cerca vista...\",\n    \"dragToolTip\": \"L'ordinamento automatico è attivato, il trascinamento manuale non è disponibile\",\n    \"insertToolTip\": \"L'ordinamento automatico è attivato, l'inserimento con ordine non è disponibile\",\n    \"action\": {\n      \"rename\": \"Rinomina vista\",\n      \"duplicate\": \"Duplica vista\",\n      \"delete\": \"Elimina vista\",\n      \"lock\": \"Blocca vista\",\n      \"unlock\": \"Sblocca vista\",\n      \"enable\": \"Abilita\"\n    },\n    \"category\": {\n      \"table\": \"Vista Griglia\",\n      \"form\": \"Vista Modulo\",\n      \"kanban\": \"Vista Kanban\",\n      \"gallery\": \"Vista Galleria\",\n      \"calendar\": \"Vista Calendario\"\n    },\n    \"crash\": {\n      \"title\": \"Errore!\",\n      \"description\": \"Questa vista è danneggiata. Se il refresh fallisce ancora, contattaci. support@teable.ai\"\n    },\n    \"addPluginView\": \"Aggiungi Vista Plugin\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"campi({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"Questa vista è bloccata. Puoi abilitare la modalità personale per modificare le opzioni della vista, e le modifiche avranno effetto solo per te.\"\n    },\n    \"noView\": \"Nessuna vista\"\n  },\n  \"lastModifiedTime\": \"Ultima modifica\",\n  \"lastModify\": \"Ultima modifica: \",\n  \"pasteNewRecords\": {\n    \"title\": \"Vuoi aggiungere più record?\",\n    \"description\": \"I {{count}} record saranno aggiunti alla tabella.\"\n  },\n  \"tableTrash\": {\n    \"title\": \"Cestino della tabella\",\n    \"resourceType\": \"Tipo di risorsa\",\n    \"deletedResource\": \"Risorsa eliminata\"\n  },\n  \"baseShare\": {\n    \"title\": \"Condividi Base\",\n    \"shareTitle\": \"Condividi\",\n    \"shareToWeb\": \"Condividi sul web\",\n    \"description\": \"Condividi \\\"{{baseName}}\\\" con altri\",\n    \"nodeShareDescription\": \"Condividi \\\"{{nodeName}}\\\" e i suoi contenuti\",\n    \"shareLinks\": \"Link di condivisione\",\n    \"newLink\": \"Nuovo link\",\n    \"noShareLinks\": \"Nessun link di condivisione ancora\",\n    \"createFirstLink\": \"Crea link di condivisione\",\n    \"editSettings\": \"Modifica impostazioni\",\n    \"refreshLink\": \"Rigenera link\",\n    \"deleteLink\": \"Elimina link\",\n    \"deleteConfirmTitle\": \"Elimina link di condivisione\",\n    \"deleteConfirmDescription\": \"Questa azione non può essere annullata. Le persone con questo link non potranno più accedere alla base condivisa.\",\n    \"createSuccess\": \"Link di condivisione creato\",\n    \"createFailed\": \"Impossibile creare il link di condivisione\",\n    \"updateSuccess\": \"Impostazioni di condivisione aggiornate\",\n    \"updateFailed\": \"Impossibile aggiornare le impostazioni di condivisione\",\n    \"deleteSuccess\": \"Link di condivisione eliminato\",\n    \"deleteFailed\": \"Impossibile eliminare il link di condivisione\",\n    \"refreshSuccess\": \"Link di condivisione rigenerato\",\n    \"refreshFailed\": \"Impossibile rigenerare il link di condivisione\",\n    \"copied\": \"Link copiato negli appunti\",\n    \"shareLink\": \"Link di condivisione\",\n    \"linkHolderLabel\": \"La persona che ha ottenuto il link\",\n    \"linkHolderCanView\": \"Può visualizzare\",\n    \"linkHolderCanEdit\": \"Può modificare\",\n    \"linkHolderCanCopyAndSave\": \"Può salvare come copia\",\n    \"passwordProtection\": \"Protezione con password\",\n    \"enterPassword\": \"Inserisci password\",\n    \"selectNodes\": \"Seleziona tabelle\",\n    \"shareEntireBase\": \"Condividi l'intera base\",\n    \"shareSelectedNodes\": \"Condividi i nodi selezionati\",\n    \"shareEntireBaseDescription\": \"Le nuove tabelle e cartelle aggiunte a questa base saranno condivise automaticamente\",\n    \"noNodesSelectedWarning\": \"Seleziona almeno un nodo da condividere\",\n    \"allowSave\": \"Consenti salvataggio nello spazio\",\n    \"allowSaveDescription\": \"Consenti agli spettatori di salvare una copia di questa base nel loro spazio\",\n    \"allowCopy\": \"Consenti copia dati\",\n    \"allowCopyData\": \"Consenti agli spettatori di copiare i dati\",\n    \"allowDuplicate\": \"Consenti agli spettatori di duplicare\",\n    \"allowCopyDescription\": \"Consenti agli spettatori di copiare i dati della tabella da questa base condivisa\",\n    \"selectedNodes\": \"{{count}} tabelle selezionate\",\n    \"allNodes\": \"Tutte le tabelle\",\n    \"sharedNode\": \"Nodo condiviso\",\n    \"sharedNodeDescription\": \"Questo link di condivisione è per un nodo specifico e i suoi discendenti\",\n    \"publicShareTitle\": \"Condivisione pubblica tramite link\",\n    \"publicShareCount\": \"{{count}} link di condivisione pubblici\",\n    \"noPublicShare\": \"Nessun link di condivisione pubblico\",\n    \"security\": \"Sicurezza\",\n    \"restrictByPassword\": \"Limita con password\",\n    \"advanced\": \"Avanzato\",\n    \"embedConfig\": \"Configurazione incorporamento\",\n    \"appPublicLink\": \"Link pubblico dell'app\",\n    \"appNotPublished\": \"Questa app non è ancora pubblicata. Pubblica prima l'app prima di condividerla.\",\n    \"goToPublish\": \"Vai alla pubblicazione\",\n    \"publishSuccess\": \"App pubblicata con successo\",\n    \"publishFailed\": \"Impossibile pubblicare l'app\",\n    \"openLink\": \"Apri link\",\n    \"appPublished\": \"App pubblicata\"\n  },\n  \"plugin\": {\n    \"recent\": \"Recenti\",\n    \"more\": \"Esplora di più...\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Ottieni campi della tabella\",\n      \"getTablesMeta\": \"Ottieni metadati della tabella\",\n      \"sqlQuery\": \"Esegui query SQL\",\n      \"generateScriptAction\": \"Genera azione script\",\n      \"getScriptInput\": \"Ottieni input script\",\n      \"getTeableApi\": \"Ottieni API\",\n      \"dataVisualization\": \"Visualizzazione dati\",\n      \"updateBase\": \"Aggiorna info database\",\n      \"args\": \"Argomenti\",\n      \"result\": \"Risultato\",\n      \"thinking\": \"Pensando...\",\n      \"toBeConfirmed\": \"Da confermare\",\n      \"errorMessage\": \"Messaggio di errore\",\n      \"confirm\": \"Conferma\",\n      \"createRecordsSuccess\": \"{{count}} record creati con successo\",\n      \"createRecordsFailed\": \"Creazione record fallita\",\n      \"updateRecordsSuccess\": \"{{count}} record aggiornati con successo\",\n      \"updateRecordsFailed\": \"Aggiornamento record fallito\",\n      \"generatingRecords\": \"Generazione di {{count}} record...\",\n      \"creatingRecords\": \"Creazione di {{count}} record...\",\n      \"updatingRecords\": \"Aggiornamento di {{count}} record...\",\n      \"recordsPreview\": \"Anteprima record\",\n      \"andMoreRecords\": \"E altri {{count}}...\",\n      \"unknownError\": \"Errore sconosciuto\",\n      \"recordIds\": \"ID record\",\n      \"records\": \"record\",\n      \"viewAll\": \"Visualizza tutto\",\n      \"showLess\": \"Mostra meno\",\n      \"generatingData\": \"Generazione dati...\",\n      \"generatingUpdates\": \"Generazione aggiornamenti...\",\n      \"recordsGenerated\": \"{{count}} record generati\",\n      \"recordsCount\": \"Record ({{count}})\",\n      \"fieldsCount\": \"Campi ({{count}})\",\n      \"fieldsGenerated\": \"{{count}} campi generati\",\n      \"updatedProperties\": \"Aggiornati ({{count}})\",\n      \"configured\": \"configurati\",\n      \"recordsToUpdate\": \"{{count}} record da aggiornare\",\n      \"showingLast\": \"ultimi {{count}}\",\n      \"recordLabel\": \"Record\",\n      \"statusGenerating\": \"Generazione...\",\n      \"statusCreating\": \"Creazione...\",\n      \"statusUpdating\": \"Aggiornamento...\",\n      \"statusCreated\": \"Creati\",\n      \"statusUpdated\": \"Aggiornati\",\n      \"getApps\": {\n        \"title\": \"Elenco app\",\n        \"loading\": \"Caricamento app...\",\n        \"foundApps\": \"Trovate {{count}} app\",\n        \"noApps\": \"Nessuna app trovata in questo base\",\n        \"openApp\": \"Apri app\"\n      },\n      \"generateApp\": {\n        \"title\": \"Costruttore app\",\n        \"creatingApp\": \"Creazione app\",\n        \"updatingApp\": \"Aggiornamento app\",\n        \"generatingApp\": \"Generazione app\",\n        \"generating\": \"Generazione...\",\n        \"openApp\": \"Apri app\",\n        \"viewProgress\": \"Visualizza avanzamento\",\n        \"newApp\": \"Nuova app\",\n        \"building\": \"Costruzione...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Costruttore automazioni\",\n        \"creatingAutomation\": \"Creazione automazione\",\n        \"updatingAutomation\": \"Aggiornamento automazione\",\n        \"generatingAutomation\": \"Costruzione automazione\",\n        \"building\": \"Costruzione...\",\n        \"openAutomation\": \"Apri automazione\",\n        \"viewProgress\": \"Visualizza avanzamento\",\n        \"testResults\": \"Risultati test\",\n        \"triggerTest\": \"Trigger\",\n        \"actionTest\": \"Azione {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Anteprima\",\n        \"code\": \"Codice\",\n        \"download\": \"Scarica\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Immagine (PNG)\",\n        \"copy\": \"Copia\",\n        \"copied\": \"Copiato!\",\n        \"fullscreen\": \"Schermo intero\",\n        \"exitFullscreen\": \"Esci da schermo intero\",\n        \"downloadSuccess\": \"Immagine scaricata con successo\",\n        \"downloadFailed\": \"Cattura immagine fallita\",\n        \"iframeFailed\": \"Cattura fallita: iframe non accessibile\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Carica allegato\",\n        \"loading\": \"Caricamento allegato\",\n        \"failed\": \"Caricamento allegato fallito\",\n        \"empty\": \"Nessun allegato caricato\",\n        \"modeNative\": \"Visione AI\",\n        \"modeNativeDesc\": \"Caricato nel contesto nativo AI\",\n        \"modeExtracted\": \"Testo estratto\",\n        \"modeExtractedDesc\": \"Contenuto file analizzato ed estratto come testo\",\n        \"visionLoaded\": \"Caricato per analisi visiva\",\n        \"pdfLoaded\": \"Caricato per analisi PDF\",\n        \"textExtracted\": \"{{chars}} caratteri estratti\",\n        \"contextLoaded\": \"Caricato nel contesto AI\",\n        \"truncated\": \"Troncato\",\n        \"preview\": \"Anteprima\"\n      },\n      \"textExtract\": {\n        \"title\": \"Estrazione testo\",\n        \"loading\": \"Estrazione testo in corso\",\n        \"failed\": \"Estrazione testo fallita\",\n        \"empty\": \"Nessun testo estratto\",\n        \"preview\": \"Anteprima\",\n        \"truncated\": \"Troncato\",\n        \"previews\": \"anteprime\",\n        \"chars\": \"{{chars}} caratteri\",\n        \"totalCharacters\": \"Totale: {{chars}} caratteri\",\n        \"filesTruncated\": \"({{count}} file troncati)\"\n      },\n      \"importExcel\": {\n        \"title\": \"Importa Excel\",\n        \"loading\": \"Elaborazione file...\",\n        \"failed\": \"Importazione fallita\",\n        \"suggestions\": \"Suggerimenti\",\n        \"analyzeComplete\": \"Analisi completata\",\n        \"worksheets\": \"Fogli di lavoro\",\n        \"columns\": \"colonne\",\n        \"importComplete\": \"Importazione completata\",\n        \"stageAnalyze\": \"Analisi\",\n        \"stageImport\": \"Importazione\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Ottieni API Teable\",\n      \"readFiles\": \"Leggi file\",\n      \"writeFile\": \"Scrivi file\",\n      \"deleteFiles\": \"Elimina file\",\n      \"listFiles\": \"Elenco file\",\n      \"addDependencies\": \"Aggiungi dipendenze\",\n      \"checkBuildErrors\": \"Controlla errori di build\",\n      \"lint\": \"Controlla codice\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Caricamento anteprima fallito\",\n      \"retry\": \"Riprova {{count}} volta\",\n      \"chatAborted\": \"interrotto\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Tabella eliminata\",\n      \"deletedView\": \"Vista eliminata\",\n      \"deletedField\": \"Campo eliminato\",\n      \"deletedRecords\": \"Record eliminati\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Agente tabella\",\n      \"viewOperatorAgent\": \"Agente vista\",\n      \"fieldOperatorAgent\": \"Agente campo\",\n      \"recordOperatorAgent\": \"Agente record\",\n      \"buildBaseAgent\": \"Agente creazione base\",\n      \"buildAutomationAgent\": \"Agente creazione automazione\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"Da confermare\",\n      \"deleteWarning\": \"Sei sicuro di voler eliminare?\"\n    },\n    \"action\": {\n      \"createTable\": \"Crea tabella\",\n      \"updateTable\": \"Aggiorna tabella\",\n      \"updateTableName\": \"Aggiorna nome tabella\",\n      \"deleteTable\": \"Elimina tabella\",\n      \"createView\": \"Crea vista\",\n      \"updateView\": \"Aggiorna vista\",\n      \"updateViewName\": \"Aggiorna nome vista\",\n      \"deleteView\": \"Elimina vista\",\n      \"createField\": \"Crea campo\",\n      \"createAiField\": \"Crea campo AI\",\n      \"createLinkField\": \"Crea campo collegamento\",\n      \"createLookupField\": \"Crea campo di ricerca\",\n      \"createRollupField\": \"Crea campo di riepilogo\",\n      \"createFormulaField\": \"Crea campo formula\",\n      \"deleteField\": \"Elimina campo\",\n      \"updateField\": \"Aggiorna campo\",\n      \"createRecord\": \"Crea record\",\n      \"createRecords\": \"Crea record\",\n      \"deleteRecord\": \"Elimina record\",\n      \"updateRecord\": \"Aggiorna record\",\n      \"updateRecords\": \"Aggiorna record\",\n      \"updateBase\": \"Aggiorna info database\",\n      \"planTask\": \"Pianifica attività...\",\n      \"generateTables\": \"Genera tabelle\",\n      \"generatePrimaryFields\": \"Genera campi primari\",\n      \"generateFields\": \"Genera campi\",\n      \"generateViews\": \"Genera viste\",\n      \"generateRecords\": \"Genera record\",\n      \"generateAIFields\": \"Genera campi AI\",\n      \"generateLinkFields\": \"Genera campi collegamento\",\n      \"generateLookupFields\": \"Genera campi di ricerca\",\n      \"generateRollupFields\": \"Genera campi di riepilogo\",\n      \"generateFormulaFields\": \"Genera campi formula\",\n      \"generateWorkflow\": \"Genera flusso di lavoro\",\n      \"generateTrigger\": \"Genera trigger\",\n      \"generateScriptAction\": \"Genera nodo azione script\",\n      \"generateSendMailAction\": \"Genera nodo invio email\",\n      \"generateAction\": \"Genera nodo azione\",\n      \"setupAutomationTrigger\": \"Configura trigger automazione\",\n      \"testAutomationNode\": \"Testa nodo automazione\",\n      \"activateAutomation\": \"Attiva automazione\",\n      \"executeScript\": \"Esegui script\",\n      \"wait\": \"Attendi\",\n      \"generateScriptFlowChart\": \"Genera diagramma di flusso dello script\",\n      \"triggerAiFill\": \"Attiva compilazione AI\",\n      \"initialize\": \"Inizializza ambiente\",\n      \"rename\": \"Genera nome app\",\n      \"buildTest\": \"Crea test\",\n      \"developTask\": \"Sviluppa attività\",\n      \"generateSummary\": \"Genera riepilogo\",\n      \"previewEnvironment\": \"Prepara ambiente di anteprima\",\n      \"getRelativeData\": \"Ottieni dati correlati\",\n      \"getPreviousNodeOutputVariables\": \"Ottieni variabili di output del nodo precedente\",\n      \"getApiJson\": \"Ottieni info API\",\n      \"generateScriptAndDependencies\": \"Genera script e dipendenze\",\n      \"analyzingAttachment\": \"Analisi dell'allegato...\",\n      \"locateResource\": \"Localizza\",\n      \"goTo\": \"Vai a\",\n      \"operationSuccess\": \"Operazione completata con successo\",\n      \"operationFailed\": \"Operazione fallita\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} record in coda per generazione AI\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Query record\",\n      \"getRecordsWithTable\": \"Query record · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Query campi\",\n      \"getFieldsWithTable\": \"Query campi · {{tableName}}\",\n      \"getTables\": \"Query tabelle\",\n      \"getViews\": \"Query viste\",\n      \"getViewsWithTable\": \"Query viste · {{tableName}}\",\n      \"sqlQuery\": \"Query SQL\",\n      \"querying\": \"Query in corso...\",\n      \"queryFailed\": \"Query fallita\",\n      \"aborted\": \"Interrotto\",\n      \"noData\": \"Nessun dato restituito\",\n      \"dataFormatError\": \"Errore formato dati\",\n      \"unsupportedQueryType\": \"Tipo di query non supportato: {{toolName}}\",\n      \"returnedRecords\": \"Restituiti {{count}} record\",\n      \"record\": \"Record {{index}}\",\n      \"moreRecords\": \"... +{{count}} record in più\",\n      \"foundFields\": \"Trovati {{count}} campi\",\n      \"moreFields\": \"... +{{count}} campi in più\",\n      \"foundTables\": \"Trovate {{count}} tabelle\",\n      \"moreTables\": \"... +{{count}} tabelle in più\",\n      \"foundViews\": \"Trovate {{count}} viste\",\n      \"moreViews\": \"... +{{count}} viste in più\",\n      \"queryReturned\": \"Query ha restituito {{rowCount}} righe × {{columnCount}} colonne\",\n      \"row\": \"Riga {{index}}\",\n      \"moreRows\": \"... +{{count}} righe in più\",\n      \"getDoc\": \"Ottieni documento\",\n      \"getDocWithTopic\": \"Ottieni documento · {{topic}}\",\n      \"getAutomations\": \"Query automazioni\",\n      \"getAutomation\": \"Query automazione\",\n      \"getAutomationRuns\": \"Query esecuzioni automazione\",\n      \"foundAutomations\": \"Trovate {{count}} automazioni\",\n      \"moreAutomations\": \"... +{{count}} automazioni in più\",\n      \"foundRuns\": \"Trovate {{count}} esecuzioni\",\n      \"moreRuns\": \"... +{{count}} esecuzioni in più\",\n      \"active\": \"Attivo\",\n      \"trigger\": \"Trigger\",\n      \"actions\": \"{{count}} azioni\",\n      \"moreActions\": \"... +{{count}} azioni in più\",\n      \"getUserIntegrations\": \"Verifica integrazioni\",\n      \"connectedIntegrations\": \"Connesso\",\n      \"availableToConnect\": \"Disponibile per connessione\",\n      \"connect\": \"Connetti\",\n      \"noIntegrationsAvailable\": \"Nessuna integrazione disponibile\",\n      \"activateTool\": \"Attiva strumenti\",\n      \"webSearch\": \"Ricerca web\",\n      \"webSearchResults\": \"Trovati {{count}} risultati\",\n      \"webSearchCompleted\": \"Ricerca completata\",\n      \"searchApi\": \"Cerca API\",\n      \"searchApiWithQuery\": \"Cerca API · {{query}}\",\n      \"noApiFound\": \"Nessuna API trovata\",\n      \"foundApis\": \"Trovate {{count}} API\",\n      \"totalApis\": \"Totale {{count}} API disponibili\",\n      \"callApi\": \"Chiama API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Risposta\",\n      \"success\": \"Successo\",\n      \"failed\": \"Fallito\",\n      \"inputData\": \"Dati di input\",\n      \"availableNodes\": \"Nodi disponibili\",\n      \"hasPreviousCode\": \"Codice esistente presente\",\n      \"noInputData\": \"Nessun dato di input disponibile\"\n    },\n    \"showUI\": {\n      \"connect\": \"Connetti\",\n      \"connecting\": \"Connessione...\",\n      \"connected\": \"Connesso\",\n      \"connectToUse\": \"Connetti {{provider}} per usare nelle automazioni\",\n      \"checkingConnection\": \"Verifica stato connessione...\",\n      \"confirm\": \"Conferma\",\n      \"confirmed\": \"Confermato\",\n      \"cancel\": \"Annulla\",\n      \"cancelled\": \"Annullato\",\n      \"connectionCancelled\": \"Connessione annullata\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} linee nascoste\",\n      \"collapseCode\": \"Comprimi codice\",\n      \"code\": \"Codice\",\n      \"preview\": \"Anteprima\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Avanzamento build\",\n      \"completed\": \"Build app completato\",\n      \"completedDesc\": \"Tutti i passaggi completati con successo, l'app è pronta per l'anteprima\",\n      \"stepStatus\": {\n        \"initializing\": \"Creazione app e inizializzazione ambiente...\",\n        \"naming\": \"Generazione nome app...\",\n        \"planning\": \"Analisi requisiti e pianificazione sviluppo...\",\n        \"developing\": \"Scrittura codice e implementazione funzionalità...\",\n        \"summarizing\": \"Organizzazione risultati sviluppo...\",\n        \"deploying\": \"Distribuzione in ambiente anteprima...\",\n        \"testing\": \"Creazione test...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"In esecuzione\",\n        \"completed\": \"Completato\",\n        \"error\": \"Fallito\",\n        \"pending\": \"In attesa\"\n      },\n      \"toolStatus\": {\n        \"running\": \"In esecuzione\",\n        \"completed\": \"Completato\",\n        \"error\": \"Fallito\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Script generato con successo\"\n    },\n    \"buildBase\": {\n      \"title\": \"Crea base\",\n      \"generateSuccess\": \"Database generato con successo\",\n      \"generateError\": \"Generazione database fallita\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Crea automazione\",\n      \"generateSuccess\": \"Automazione generata con successo\"\n    },\n    \"automation\": {\n      \"created\": \"Creato\",\n      \"updated\": \"Aggiornato\",\n      \"workflow\": \"Flusso di lavoro\",\n      \"trigger\": \"Trigger\",\n      \"scriptAction\": \"Azione script\",\n      \"workflowLabel\": \"Flusso di lavoro\",\n      \"triggerLabel\": \"Trigger\",\n      \"scriptActionLabel\": \"Azione script\",\n      \"workflowId\": \"Flusso di lavoro\",\n      \"triggerId\": \"Trigger\",\n      \"scriptActionId\": \"Azione script\",\n      \"viewAutomation\": \"Visualizza\",\n      \"navigateToAutomation\": \"Naviga all'automazione\",\n      \"triggerType\": {\n        \"recordCreated\": \"Record creato\",\n        \"recordUpdated\": \"Record aggiornato\",\n        \"recordCreatedOrUpdated\": \"Record creato o aggiornato\",\n        \"formSubmitted\": \"Modulo inviato\",\n        \"scheduledTime\": \"Ora programmata\",\n        \"buttonClick\": \"Clic pulsante\"\n      },\n      \"activated\": \"Attivato\",\n      \"deactivated\": \"Disattivato\",\n      \"discarded\": \"Modifiche annullate\",\n      \"activateFailed\": \"Attivazione fallita\",\n      \"deactivateFailed\": \"Disattivazione fallita\",\n      \"discardFailed\": \"Annullamento fallito\",\n      \"scriptUpdated\": \"Script aggiornato\",\n      \"scriptUpdateFailed\": \"Aggiornamento fallito\",\n      \"scriptExecuted\": \"Script eseguito\",\n      \"scriptExecutionFailed\": \"Esecuzione fallita\",\n      \"scriptReady\": \"Script pronto\",\n      \"executingScript\": \"Esecuzione script...\",\n      \"waitedSeconds\": \"Atteso {{seconds}}s\",\n      \"waitFailed\": \"Attesa fallita\",\n      \"flowchartGenerated\": \"Diagramma di flusso generato\",\n      \"flowchartGenerationFailed\": \"Generazione fallita\"\n    },\n    \"newChat\": \"Nuovo Chat\",\n    \"clearChat\": \"Cancella chat\",\n    \"expand\": \"Espandi\",\n    \"history\": \"Cronologia\",\n    \"close\": \"Comprimi\",\n    \"clearChatConfirmTitle\": \"Conferma cancellazione chat\",\n    \"clearChatConfirmDesc\": \"Il contenuto attuale della chat non verrà salvato. Sei sicuro di volerlo cancellare?\",\n    \"dontShowAgain\": \"Non mostrare più\",\n    \"noModel\": \"Nessun modello disponibile\",\n    \"addAttachment\": \"Aggiungi allegato\",\n    \"noHistory\": \"Nessun chat history\",\n    \"noFoundHistory\": \"Nessun chat history trovato, per favore inizia una nuova conversazione\",\n    \"timeGroup\": {\n      \"today\": \"Oggi\",\n      \"oneWeek\": \"Una settimana\",\n      \"twoWeek\": \"Due settimane\",\n      \"oneMonth\": \"Un mese\",\n      \"other\": \"Altro\"\n    },\n    \"context\": {\n      \"button\": \"Aggiungi contesto\",\n      \"search\": \"Aggiungi tabelle\",\n      \"searchEmpty\": \"Nessun contesto trovato\",\n      \"emptyContext\": \"Nessun contesto da aggiungere\",\n      \"selectionRows\": \"Righe {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Invia messaggio...\",\n    \"thought\": \"Pensando\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Tempo di generazione: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} crediti consumati\",\n      \"tokenDescription\": \"Tokens usati: {{tokens}}\",\n      \"input\": \"Input\",\n      \"output\": \"Output\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"Tempo totale\",\n      \"totalCreditCost\": \"Costo crediti totale\",\n      \"customModel\": \"Modello personalizzato\",\n      \"tokenDetails\": \"Dettagli token\",\n      \"cachedInput\": \"Input memorizzato (sconto 90%)\",\n      \"cacheWrite\": \"Scrittura cache\",\n      \"reasoning\": \"Ragionamento (Pensiero)\",\n      \"taskCompleted\": \"Attività completata\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Visualizzazione dati non riuscita\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Solo gli amministratori possono configurare\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"L'immagine non è supportata\",\n      \"attachmentSizeExceeded\": \"La dimensione dell'allegato supera il limite di {{size}}MB\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Consigliati\",\n      \"ask\": \"Chiedi\",\n      \"analyze\": \"Analizza\",\n      \"build\": \"Costruisci\",\n      \"title\": \"Come posso aiutarti?\",\n      \"whatCanIDo\": \"Cosa posso fare?\",\n      \"createOrModifyDatabase\": \"Crea o modifica database\",\n      \"buildAutomations\": \"Costruisci automazioni\",\n      \"buildApps\": \"Costruisci app\",\n      \"buildMeCRM\": \"Costruiscimi un CRM\",\n      \"addAIField\": \"Aggiungi un campo AI per analizzare ogni cliente\",\n      \"createDataAnalysis\": \"Creami un report di analisi dati\",\n      \"emailWhenRecordCreated\": \"Inviami un'email quando viene creato un record\",\n      \"syncStatusToSlack\": \"Sincronizza gli aggiornamenti di stato su Slack\",\n      \"buildDashboard\": \"Costruisci una dashboard da questa tabella\",\n      \"buildLeadCapture\": \"Costruiscimi una landing page per acquisizione lead\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Pensato per {{duration}}s\"\n      },\n      \"task\": {\n        \"searching\": \"Ricerca \\\"{{query}}\\\"\",\n        \"readingFiles\": \"Lettura file:\",\n        \"foundResults\": \"Trovati {{count}} risultati\",\n        \"noIssuesFound\": \"Nessun problema trovato\",\n        \"defaultTitle\": \"Attività\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Progetto codice\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"È necessario un modello AI per generare l'anteprima.\",\n      \"writeCodeHint\": \"Scrivi del codice per vedere l'anteprima del diagramma di flusso.\",\n      \"noPreview\": \"Nessuna anteprima del diagramma di flusso disponibile.\",\n      \"generatePreview\": \"Genera anteprima\",\n      \"analyzing\": \"Analisi dello script...\",\n      \"codeChanged\": \"Il codice è cambiato da quando è stata generata questa anteprima.\",\n      \"regenerate\": \"Rigenera\",\n      \"refresh\": \"Aggiorna\",\n      \"regenerating\": \"Rigenerazione in corso...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Scarica tutti gli allegati\",\n      \"loading\": \"Caricamento anteprima...\",\n      \"rowsWithAttachments\": \"{{count}} righe con allegati\",\n      \"totalAttachments\": \"{{count}} allegati\",\n      \"totalSize\": \"Dimensione totale: {{size}}\",\n      \"startDownload\": \"Avvia download\",\n      \"confirmTitle\": \"Scarica {{count}} file\",\n      \"confirmDescription\": \"Dimensione totale: {{size}}. I file verranno compressi in un file ZIP.\",\n      \"confirm\": \"Scarica\",\n      \"cancel\": \"Annulla\",\n      \"downloading\": \"Download in corso...\",\n      \"downloadingFile\": \"Download: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Download completato\",\n      \"cancelled\": \"Download annullato\",\n      \"noAttachments\": \"Nessun allegato da scaricare\",\n      \"error\": \"Download fallito\",\n      \"errorPartial\": \"{{failedCount}} file non sono stati scaricati\",\n      \"requireHttps\": \"Il download batch richiede HTTPS. Accedi tramite HTTPS o localhost.\",\n      \"advancedOptions\": \"Opzioni avanzate\",\n      \"namingFieldLabel\": \"Prefisso nome allegato\",\n      \"selectField\": \"Predefinito: indice allegato\",\n      \"groupByRow\": \"Archivia in cartelle\",\n      \"groupByRowTip\": \"Quando una riga ha più allegati, verranno inseriti nella stessa cartella; le righe con un solo allegato non creeranno una cartella.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/token.json",
    "content": "{\n  \"access\": \"Accesso\",\n  \"name\": \"Nome\",\n  \"description\": \"Descrizione\",\n  \"scopes\": \"Ambiti\",\n  \"expiration\": \"Scadenza\",\n  \"createdTime\": \"Creato\",\n  \"lastUse\": \"Ultimo utilizzo\",\n  \"allSpace\": \"Lo spazio, tutte le basi attuali e future in questo spazio\",\n  \"formLabelTips\": {\n    \"name\": \"Fornisci un nome per il token\",\n    \"description\": \"A cosa serve questo token?\",\n    \"scopes\": \"Con questo token, sarai in grado di:\",\n    \"access\": \"Questo token può accedere alle seguenti basi e spazi. Puoi concedere l'accesso solo alle basi e agli spazi a cui hai accesso.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Crea nuovo token\",\n    \"title\": \"I token di accesso personale sono necessari per utilizzare l'API di Teable.\",\n    \"description\": \"Questo token concederà l'accesso ai dati negli spazi e nelle basi selezionati. E ad altri endpoint API non relativi a spazi/basi. Utilizza questo token solo per il tuo sviluppo. Fai attenzione quando lo condividi con servizi e applicazioni di terze parti.\",\n    \"button\": \"Crea nuovo token\",\n    \"success\": {\n      \"title\": \"Token generato con successo\",\n      \"description\": \"Assicurati di copiare il tuo token. Non sarà mai più visualizzato.\"\n    },\n    \"expirationList\": {\n      \"days\": \"giorni\",\n      \"permanent\": \"Permanente\",\n      \"custom\": \"Personalizzato\",\n      \"pick\": \"Scegli una data\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Modifica token\",\n    \"name\": \"Nome\",\n    \"scopes\": \"Ambiti\",\n    \"selectAll\": \"Seleziona tutto\",\n    \"cancelSelectAll\": \"Annulla selezione tutto\"\n  },\n  \"refresh\": {\n    \"title\": \"Rigenera token di accesso personale\",\n    \"description\": \"L'invio di questo modulo genererà un nuovo token. Tieni presente che qualsiasi script o applicazione che utilizza questo token dovrà essere aggiornato\",\n    \"button\": \"Rigenera token\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Aggiungi base o spazio\",\n    \"empty\": \"Nessun accesso trovato.\",\n    \"spaceSelectItem\": \"Tutte le basi nello spazio\",\n    \"inputPlaceholder\": \"Trova spazio o base...\"\n  },\n  \"moreScopes\": \"e {{len}} altri\",\n  \"list\": {\n    \"description\": \"I token di accesso personale sono necessari per utilizzare l'API di Teable. Per ulteriori informazioni, si prega di consultare la <a>documentazione di aiuto</a>.\"\n  },\n  \"empty\": {\n    \"list\": \"Nessun token di accesso personale trovato.\",\n    \"access\": \"Nessun accesso\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Sei sicuro di voler eliminare questo token?\",\n    \"description\": \"Qualsiasi applicazione o script che utilizza questo token non sarà più in grado di accedere all'API di Teable. Non puoi annullare questa azione.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/it/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Previsto {{expected}}, ricevuto {{received}}\",\n    \"invalid_type_received_undefined\": \"Richiesto\",\n    \"invalid_type_received_null\": \"Richiesto\",\n    \"invalid_literal\": \"Valore letterale non valido, previsto {{expected}}\",\n    \"unrecognized_keys\": \"Chiave(i) non riconosciuta(e) nell'oggetto: {{- keys}}\",\n    \"invalid_union\": \"Input non valido\",\n    \"invalid_union_discriminator\": \"Valore discriminatore non valido. Previsto {{- options}}\",\n    \"invalid_enum_value\": \"Valore enum non valido. Previsto {{- options}}, ricevuto '{{received}}'\",\n    \"invalid_arguments\": \"Argomenti della funzione non validi\",\n    \"invalid_return_type\": \"Tipo di ritorno della funzione non valido\",\n    \"invalid_date\": \"Data non valida\",\n    \"custom\": \"Input non valido\",\n    \"invalid_intersection_types\": \"I risultati dell'intersezione non possono essere uniti\",\n    \"not_multiple_of\": \"Il numero deve essere un multiplo di {{multipleOf}}\",\n    \"not_finite\": \"Il numero deve essere finito\",\n    \"invalid_string\": {\n      \"email\": \"{{validation}} non valido\",\n      \"url\": \"{{validation}} non valido\",\n      \"uuid\": \"{{validation}} non valido\",\n      \"cuid\": \"{{validation}} non valido\",\n      \"regex\": \"Non valido\",\n      \"datetime\": \"{{validation}} non valido\",\n      \"startsWith\": \"Input non valido: deve iniziare con \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Input non valido: deve terminare con \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"L'array deve contenere esattamente {{minimum}} elemento(i)\",\n        \"inclusive\": \"L'array deve contenere almeno {{minimum}} elemento(i)\",\n        \"not_inclusive\": \"L'array deve contenere più di {{minimum}} elemento(i)\"\n      },\n      \"string\": {\n        \"exact\": \"La stringa deve contenere esattamente {{minimum}} carattere(i)\",\n        \"inclusive\": \"La stringa deve contenere almeno {{minimum}} carattere(i)\",\n        \"not_inclusive\": \"La stringa deve contenere più di {{minimum}} carattere(i)\"\n      },\n      \"number\": {\n        \"exact\": \"Il numero deve essere esattamente {{minimum}}\",\n        \"inclusive\": \"Il numero deve essere maggiore o uguale a {{minimum}}\",\n        \"not_inclusive\": \"Il numero deve essere maggiore di {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Input non valido\",\n        \"inclusive\": \"Input non valido\",\n        \"not_inclusive\": \"Input non valido\"\n      },\n      \"date\": {\n        \"exact\": \"La data deve essere esattamente {{- minimum, datetime}}\",\n        \"inclusive\": \"La data deve essere maggiore o uguale a {{- minimum, datetime}}\",\n        \"not_inclusive\": \"La data deve essere maggiore di {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"L'array deve contenere esattamente {{maximum}} elemento(i)\",\n        \"inclusive\": \"L'array deve contenere al massimo {{maximum}} elemento(i)\",\n        \"not_inclusive\": \"L'array deve contenere meno di {{maximum}} elemento(i)\"\n      },\n      \"string\": {\n        \"exact\": \"La stringa deve contenere esattamente {{maximum}} carattere(i)\",\n        \"inclusive\": \"La stringa deve contenere al massimo {{maximum}} carattere(i)\",\n        \"not_inclusive\": \"La stringa deve contenere meno di {{maximum}} carattere(i)\"\n      },\n      \"number\": {\n        \"exact\": \"Il numero deve essere esattamente {{maximum}}\",\n        \"inclusive\": \"Il numero deve essere minore o uguale a {{maximum}}\",\n        \"not_inclusive\": \"Il numero deve essere minore di {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Input non valido\",\n        \"inclusive\": \"Input non valido\",\n        \"not_inclusive\": \"Input non valido\"\n      },\n      \"date\": {\n        \"exact\": \"La data deve essere esattamente {{- maximum, datetime}}\",\n        \"inclusive\": \"La data deve essere minore o uguale a {{- maximum, datetime}}\",\n        \"not_inclusive\": \"La data deve essere minore di {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"funzione\",\n    \"number\": \"numero\",\n    \"string\": \"stringa\",\n    \"nan\": \"nan\",\n    \"integer\": \"intero\",\n    \"float\": \"float\",\n    \"boolean\": \"booleano\",\n    \"date\": \"data\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"undefined\",\n    \"symbol\": \"simbolo\",\n    \"null\": \"null\",\n    \"array\": \"array\",\n    \"object\": \"oggetto\",\n    \"unknown\": \"sconosciuto\",\n    \"promise\": \"promessa\",\n    \"void\": \"void\",\n    \"never\": \"mai\",\n    \"map\": \"mappa\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/auth.json",
    "content": "{\n  \"page\": {\n    \"title\": \"ログイン\"\n  },\n  \"button\": {\n    \"signin\": \"サインイン\",\n    \"signup\": \"登録\"\n  },\n  \"label\": {\n    \"email\": \"メール\",\n    \"password\": \"パスワード\"\n  },\n  \"legal\": {\n    \"tip\": \"続けることで、Teableの<Terms>利用規約</Terms>と<Privacy>プライバシーポリシー</Privacy>に同意し、更新情報を定期的に受け取ることに同意します。\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"placeholder\": {\n    \"password\": \"パスワードを入力...\",\n    \"email\": \"メールアドレスを入力...\"\n  },\n  \"signError\": {\n    \"exist\": \"メールアドレスは既に登録されています\",\n    \"incorrect\": \"メールアドレスまたはパスワードが正しくありません\",\n    \"tooManyRequests\": \"アカウントがロックされました。{{minutes}}分後に再試行してください\",\n    \"turnstileRequired\": \"認証チャレンジを完了してください\",\n    \"turnstileError\": \"認証に失敗しました。もう一度お試しください\",\n    \"turnstileExpired\": \"認証の有効期限が切れました。もう一度お試しください\",\n    \"turnstileTimeout\": \"認証がタイムアウトしました。もう一度お試しください\"\n  },\n  \"resetPassword\": {\n    \"header\": \"パスワードの設定\",\n    \"description\": \"新しいパスワードを入力\",\n    \"label\": \"新しいパスワード\",\n    \"error\": {\n      \"requiredPassword\": \"パスワードを入力\",\n      \"invalidLink\": \"無効なパスワード再設定用リンク\"\n    },\n    \"success\": {\n      \"title\": \"🎉 パスワード再設定に成功\",\n      \"description\": \"パスワードのリセットに成功しました。ログインページにリダイレクトされます。\"\n    },\n    \"buttonText\": \"パスワードの再設定\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"パスワードを忘れましたか？\",\n    \"header\": \"パスワードの再設定\",\n    \"description\": \"以下にメールアドレスを入力すると、パスワードをリセットするためのリンクが送信されます。\",\n    \"errorRequiredEmail\": \"メールアドレスは必須です\",\n    \"errorInvalidEmail\": \"無効なメールアドレス\",\n    \"buttonText\": \"再設定用メール送信\",\n    \"success\": {\n      \"title\": \"🎉 パスワード再設定用メールを送信しました\",\n      \"description\": \"パスワードを再設定するためのリンクを記載したメールを送信しました。受信トレイを確認してください。\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/chart.json",
    "content": "{\n  \"notBaseId\": \"baseIdがありません\",\n  \"notPositionId\": \"positionIdがありません\",\n  \"notPluginInstallId\": \"pluginInstallIdがありません\",\n  \"initBridge\": \"ブリッジを初期化中...\",\n  \"actions\": {\n    \"cancel\": \"キャンセル\",\n    \"save\": \"保存\"\n  },\n  \"queryTitle\": \"データクエリ設定\",\n  \"notSupport\": \"サポートされていません\",\n  \"chart\": {\n    \"bar\": \"棒グラフ\",\n    \"line\": \"折れ線グラフ\",\n    \"pie\": \"円グラフ\",\n    \"area\": \"エリアグラフ\",\n    \"table\": \"テーブル\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"チャートタイプを選択\",\n      \"label\": \"チャートタイプ\"\n    },\n    \"pie\": {\n      \"dimension\": \"ディメンション\",\n      \"measure\": \"メジャー\",\n      \"showTotal\": \"合計を表示\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"X軸\",\n        \"placeholder\": \"X軸を選択\"\n      },\n      \"yAxis\": {\n        \"label\": \"Y軸\",\n        \"placeholder\": \"Y軸を選択\",\n        \"position\": \"Y軸の位置\"\n      },\n      \"xDisplay\": {\n        \"label\": \"X表示\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Y表示\"\n      },\n      \"addXAxis\": \"系列分解を追加\",\n      \"addYAxis\": \"別の系列を追加\",\n      \"stack\": \"スタック\",\n      \"position\": {\n        \"label\": \"位置\",\n        \"auto\": \"自動\",\n        \"left\": \"左\",\n        \"right\": \"右\"\n      },\n      \"goalLine\": {\n        \"label\": \"目標線\"\n      },\n      \"range\": {\n        \"label\": \"範囲\",\n        \"min\": \"最小値\",\n        \"max\": \"最大値\"\n      },\n      \"lineStyle\": {\n        \"label\": \"線のスタイル\",\n        \"normal\": \"通常\",\n        \"linear\": \"線形\",\n        \"step\": \"ステップ\"\n      },\n      \"displayType\": \"表示タイプ\"\n    },\n    \"typeError\": \"フォーム：サポートされていないチャートタイプ\",\n    \"updateQuery\": \"クエリを更新\",\n    \"queryError\": \"クエリエラー\",\n    \"querySuccess\": \"クエリが設定されました\",\n    \"decimal\": \"小数点\",\n    \"prefix\": \"接頭辞\",\n    \"suffix\": \"接尾辞\",\n    \"showLabel\": \"チャートに値ラベルを表示\",\n    \"showLegend\": \"凡例を表示\",\n    \"value\": \"値\",\n    \"label\": \"ラベル\",\n    \"padding\": {\n      \"label\": \"パディング\",\n      \"top\": \"上\",\n      \"right\": \"右\",\n      \"bottom\": \"下\",\n      \"left\": \"左\"\n    },\n    \"tableConfig\": \"テーブル設定\",\n    \"width\": \"幅\"\n  },\n  \"reloadQuery\": \"クエリを再読み込み\",\n  \"noStorage\": \"まずチャートプラグインを設定してください\",\n  \"noPermission\": \"アクセス権限がありません\",\n  \"goConfig\": \"設定に移動\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"アクション\",\n    \"add\": \"追加\",\n    \"save\": \"保存\",\n    \"doNotSave\": \"保存しない\",\n    \"submit\": \"送信\",\n    \"confirm\": \"確認\",\n    \"close\": \"閉じる\",\n    \"edit\": \"編集\",\n    \"fill\": \"埋める\",\n    \"update\": \"アップデート\",\n    \"create\": \"作成\",\n    \"delete\": \"削除\",\n    \"cancel\": \"キャンセル\",\n    \"zoomIn\": \"ズームイン\",\n    \"zoomOut\": \"ズームアウト\",\n    \"back\": \"戻る\",\n    \"remove\": \"削除\",\n    \"removeConfig\": \"設定を削除\",\n    \"saveSucceed\": \"保存成功！\",\n    \"submitSucceed\": \"送信成功！\",\n    \"editSucceed\": \"編集成功！\",\n    \"updateSucceed\": \"アップデート成功！\",\n    \"deleteSucceed\": \"削除成功！\",\n    \"resetSucceed\": \"リセット成功！\",\n    \"restoreSucceed\": \"復元成功！\",\n    \"loading\": \"読込中...\",\n    \"refreshPage\": \"ページの更新\",\n    \"yesDelete\": \"はい、削除\",\n    \"rename\": \"名前変更\",\n    \"duplicate\": \"複製\",\n    \"change\": \"変更\",\n    \"upgrade\": \"アップグレード\",\n    \"upgradeToLevel\": \"{{level}}にアップグレード\",\n    \"search\": \"検索\",\n    \"loadMore\": \"されに読み込む\",\n    \"collapseSidebar\": \"サイドバーを折りたたむ\",\n    \"restore\": \"復元\",\n    \"permanentDelete\": \"永久削除\",\n    \"globalSearch\": \"グローバル検索\",\n    \"fieldSearch\": \"フィールドで検索\",\n    \"tableIndex\": \"インデックス\",\n    \"showAllRow\": \"すべての行を表示\",\n    \"hideNotMatchRow\": \"一致しない行を非表示\",\n    \"more\": \"もっと\",\n    \"expand\": \"展開\",\n    \"move\": \"移動先\",\n    \"turnOn\": \"有効にする\",\n    \"exit\": \"ログアウト\",\n    \"next\": \"次へ\",\n    \"previous\": \"前へ\",\n    \"select\": \"選択\",\n    \"view\": \"表示\",\n    \"preview\": \"プレビュー\",\n    \"viewAndEdit\": \"表示と編集\",\n    \"deleteTip\": \"\\\"{{name}}\\\"を削除してもよろしいですか？\",\n    \"refresh\": \"更新\",\n    \"login\": \"ログイン\",\n    \"useTemplate\": \"テンプレートを使用\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"スペースに戻る\",\n    \"switchBase\": \"ベースを切り替え\",\n    \"continue\": \"続行\",\n    \"export\": \"エクスポート\",\n    \"import\": \"インポート\",\n    \"getMore\": \"もっと取得\",\n    \"copySuccess\": \"コピー成功\"\n  },\n  \"quickAction\": {\n    \"title\": \"クイックアクション\",\n    \"placeHolder\": \"コマンドまたは検索の入力...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"パスワードが無効です。少なくとも8文字で、少なくとも1つの文字と1つの数字を含める必要があります。\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"共有\",\n      \"copy\": \"コピーされました\"\n    },\n    \"aiTitle\": \"一緒に構築しましょう\",\n    \"aiGreeting\": \"{{name}}さん、何かお手伝いできますか？\",\n    \"aiSubTitle\": \"チームがデータで協力し、本番アプリを生成する最初のAIプラットフォーム\",\n    \"guideTitle\": \"シナリオから始める\",\n    \"watchVideo\": \"動画を見る\",\n    \"title\": \"テンプレート\",\n    \"description\": \"テンプレートから新しいベースを作成\",\n    \"browseAll\": \"すべて閲覧\",\n    \"templateTitle\": \"テンプレートで始める\",\n    \"loadMore\": \"さらに読み込む\",\n    \"allTemplatesLoaded\": \"すべてのテンプレートが読み込まれました\",\n    \"createTemplate\": \"テンプレートを作成\",\n    \"useTemplateDialog\": {\n      \"title\": \"スペースを選択\",\n      \"description\": \"テンプレート用のスペースを選択してください\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Teableでビジネスアプリを構築\",\n      \"start\": \"開始\",\n      \"carouselGuides\": {\n        \"guide1\": \"レシートを貼り付け → Teableに主要データを自動抽出させる\",\n        \"guide2\": \"リードとフォローアップテーブルでAI CRMを構築\",\n        \"guide3\": \"顧客フィードバックを貼り付け → Teableにインサイトを見つけてレポートを生成させる\",\n        \"guide4\": \"タスクと期限付きのプロジェクトトラッカーを作成\",\n        \"guide5\": \"リードスプレッドシートを貼り付け → TeableにスマートなCRMを構築させる\",\n        \"guide6\": \"投稿と公開日付を持つコンテンツプランナーを構築\",\n        \"guide7\": \"履歴書を貼り付け → Teableに候補者を整理して選別させる\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"スペースにコピー\",\n      \"description\": \"このベースをコピーするスペースを選択してください\",\n      \"baseName\": \"ベース名\",\n      \"baseNamePlaceholder\": \"ベース名を入力\",\n      \"selectSpace\": \"スペースを選択\",\n      \"noSpaceDescription\": \"管理可能なスペースがありません。続行するには新規スペースを作成してください。\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"このスペースに管理可能なベースがありません。「新規ベース」を選択してください。\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"インスタンス設定\",\n    \"personal\": {\n      \"title\": \"個人設定\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"テンプレート管理\",\n      \"noData\": \"データなし\",\n      \"importing\": \"インポート中...\",\n      \"usageCount\": \"使用回数: {{count}}\",\n      \"useTemplate\": \"このテンプレートを使用\",\n      \"createdBy\": \"作成者: {{user}}\",\n      \"backToTemplateList\": \"テンプレート一覧に戻る\",\n      \"tips\": {\n        \"errorCategoryName\": \"カテゴリが存在しないか削除されています\",\n        \"needSnapshot\": \"公開前にスナップショットを作成してください。テンプレート名と説明は空にできません\",\n        \"needPublish\": \"おすすめに設定する前にテンプレートを公開してください\",\n        \"needBaseSource\": \"スナップショットを作成する前にベースソースを選択してください\",\n        \"forbiddenUpdateSystemTemplate\": \"システムテンプレートは変更できません\",\n        \"addCategoryTips\": \"検索入力欄にカテゴリ名を先に入力してください\",\n        \"categoryNamePlaceholder\": \"カテゴリ名を入力\",\n        \"duplicateCategoryName\": \"カテゴリ名は既に存在します\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"はじめに\",\n          \"recommended\": \"おすすめ\",\n          \"all\": \"すべて\",\n          \"browseByCategory\": \"カテゴリで閲覧\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"カバー\",\n        \"name\": \"名前\",\n        \"description\": \"説明\",\n        \"markdownDescription\": \"Markdown説明\",\n        \"category\": \"カテゴリ\",\n        \"isSystem\": \"システム\",\n        \"source\": \"ソース\",\n        \"status\": \"公開済み\",\n        \"publishSnapshot\": \"スナップショットを公開\",\n        \"snapshotTime\": \"スナップショット時間\",\n        \"actions\": \"アクション\",\n        \"featured\": \"おすすめ\",\n        \"createdBy\": \"作成者\",\n        \"userNonExistent\": \"ユーザーが存在しません\",\n        \"preview\": \"プレビュー\",\n        \"usage\": \"使用回数\",\n        \"visit\": \"訪問回数\"\n      },\n      \"actions\": {\n        \"title\": \"アクション\",\n        \"publish\": \"公開\",\n        \"delete\": \"削除\",\n        \"duplicate\": \"複製\",\n        \"preview\": \"プレビュー\",\n        \"use\": \"使用\",\n        \"pinTop\": \"上部に固定\",\n        \"addCategory\": \"カテゴリを追加\",\n        \"selectCategory\": \"カテゴリを選択\",\n        \"viewTemplate\": \"テンプレートを表示\",\n        \"manageCategory\": \"カテゴリ管理\"\n      },\n      \"relatedTemplates\": \"関連テンプレート\",\n      \"noImage\": \"画像なし\",\n      \"baseSelectPanel\": {\n        \"title\": \"テンプレートソースを選択\",\n        \"description\": \"テンプレートとしてベースを選択\",\n        \"confirm\": \"確認\",\n        \"search\": \"検索...\",\n        \"cancel\": \"キャンセル\",\n        \"selectBase\": \"ベースを選択\",\n        \"createTemplate\": \"テンプレートを作成\",\n        \"abnormalBase\": \"ベースが存在しないか削除されています\"\n      }\n    },\n    \"back\": \"ホームに戻る\",\n    \"account\": {\n      \"title\": \"プロフィール\",\n      \"tab\": \"アカウント\",\n      \"updatePhoto\": \"写真の更新\",\n      \"updateNameDesc\": \"ニックネームまたは名前、Teable で表示したい名前を入力してください\",\n      \"securityTitle\": \"アカウントセキュリティ\",\n      \"email\": \"Eメール\",\n      \"password\": \"パスワード\",\n      \"passwordDesc\": \"アカウントにログインするための永続的なパスワードを設定してください。\",\n      \"changePassword\": {\n        \"title\": \"パスワードの変更\",\n        \"desc\": \"現在のパスワードを入力し、新しいパスワードを設定してください\",\n        \"current\": \"現在のパスワード\",\n        \"new\": \"新しいパスワード\",\n        \"confirm\": \"新しいパスワード（確認）\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"新しいパスワードが一致しません。\",\n        \"equal\": \"新しいパスワードは現在のパスワードとは異なる必要があります。\",\n        \"invalid\": \"現在のパスワードは無効です。\",\n        \"invalidNew\": \"新しいパスワードは無効です。最低8文字以上。\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 パスワードの変更に成功しました。\",\n        \"desc\": \"2秒後にログイン ページにリダイレクトされます。\"\n      },\n      \"manageToken\": \"アクセストークン\",\n      \"addPassword\": {\n        \"title\": \"パスワードの追加\",\n        \"desc\": \"アカウントにログインするための永続的なパスワードを設定してください。\",\n        \"password\": \"パスワードを入力\",\n        \"confirm\": \"パスワードの再入力\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"パスワードが一致しません。\",\n        \"invalid\": \"パスワードは無効です。最低8文字以上。\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 パスワードの追加に成功しました。\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"アカウントを削除\",\n        \"desc\": \"このアクションは元に戻せません。アカウントとすべての関連データが完全に削除されます。\",\n        \"error\": {\n          \"title\": \"アカウントを削除できません\",\n          \"desc\": \"まず以下の依存関係を処理する必要があります：\",\n          \"spacesError\": \"アカウントを削除する前に、まずスペースを退出（または削除してゴミ箱に移動）する必要があります。\"\n        },\n        \"confirm\": {\n          \"title\": \"確認するには<code>DELETE</code>と入力してください\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"削除中...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"メールアドレスの変更\",\n        \"desc\": \"パスワードを認証し、新しいメールアドレスを確認してください\",\n        \"current\": \"現在のパスワード\",\n        \"new\": \"新しいメールアドレス\",\n        \"code\": \"認証コード\",\n        \"getCode\": \"コードを送信\",\n        \"error\": {\n          \"invalidCode\": \"認証コードが無効です。\",\n          \"invalidPassword\": \"パスワードが無効です。\",\n          \"invalidConflict\": \"新しいメールアドレスは既に登録されています。\",\n          \"invalidSameEmail\": \"新しいメールアドレスは現在のものと同じです。\",\n          \"sendMailRateLimit\": \"新しいメールを送信するまで {{seconds}} 秒お待ちください\"\n        },\n        \"success\": {\n          \"title\": \"🎉 メールアドレスを変更しました。\",\n          \"desc\": \"2秒後にログインページへリダイレクトされます。\",\n          \"sendSuccess\": \"認証コードが送信されました。\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"通知\",\n      \"label\": \"スペースの活動\",\n      \"desc\": \"コメント、メンション、ページ招待、リマインダー、アクセス要求、プロパティの変更があったときにメールを受信します。\"\n    },\n    \"setting\": {\n      \"title\": \"自分の設定\",\n      \"theme\": \"テーマ\",\n      \"themeDesc\": \"アプリのテーマを選択します。\",\n      \"dark\": \"ダーク\",\n      \"light\": \"ライト\",\n      \"system\": \"システム\",\n      \"version\": \"アプリバージョン\",\n      \"language\": \"言語\",\n      \"interactionMode\": \"インタラクションモード\",\n      \"mouseMode\": \"カーソルモード\",\n      \"touchMode\": \"タッチモード\",\n      \"systemMode\": \"システム設定に従う\"\n    },\n    \"nav\": {\n      \"settings\": \"設定\",\n      \"logout\": \"ログアウト\",\n      \"contactSupport\": \"サポートに連絡\"\n    },\n    \"integration\": {\n      \"title\": \"統合\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"サードパーティ統合\",\n        \"description\": \"{{count}}個のアプリケーションにアカウントへのアクセスを許可しました。\",\n        \"lastUsed\": \"最終使用日：{{date}}\",\n        \"revoke\": \"取り消す\",\n        \"owner\": \"所有者：{{user}}\",\n        \"revokeTitle\": \"本当に承認を取り消しますか？\",\n        \"revokeDesc\": \"{{name}}はTeable APIにアクセスできなくなります。この操作を元に戻すことはできません。\",\n        \"scopeTitle\": \"権限\",\n        \"scopeDesc\": \"このアプリケーションは、次のスコープを取得できます：\"\n      },\n      \"userIntegration\": {\n        \"title\": \"接続されたアカウント\",\n        \"description\": \"外部アカウントを接続して、Teableがあなたのリソースにアクセスできるようにします。\",\n        \"emptyDescription\": \"接続されたアカウントがありません\",\n        \"actions\": {\n          \"reconnect\": \"再接続\"\n        },\n        \"slack\": {\n          \"user\": \"Slackユーザー\",\n          \"workspace\": \"Slackワークスペース\"\n        },\n        \"email\": {\n          \"user\": \"ユーザー\",\n          \"email\": \"メール\"\n        },\n        \"deleteTitle\": \"接続されたアカウントを削除\",\n        \"deleteDesc\": \"{{name}}を削除してもよろしいですか？\",\n        \"create\": \"新しいアカウントを接続\",\n        \"manage\": \"接続されたアカウントを管理\",\n        \"searchPlaceholder\": \"接続されたアカウントを検索\",\n        \"defaultName\": \"{{name}}統合\",\n        \"callback\": {\n          \"error\": \"認証に失敗しました\",\n          \"title\": \"認証に成功しました\",\n          \"desc\": \"このウィンドウを閉じることができます。\"\n        }\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"テーブル\",\n    \"view\": \"ビュー\",\n    \"space\": \"スペース\",\n    \"base\": \"ベース\",\n    \"field\": \"フィールド\",\n    \"record\": \"レコード\",\n    \"dashboard\": \"ダッシュボード\",\n    \"automation\": \"オートメーション\",\n    \"authorityMatrix\": \"権限マトリクス\",\n    \"design\": \"デザイン\",\n    \"adminPanel\": \"システム管理\",\n    \"license\": \"セルフホストライセンス\",\n    \"instanceId\": \"インスタンスID\",\n    \"beta\": \"ベータ\",\n    \"trash\": \"ゴミ箱\",\n    \"global\": \"グローバル\",\n    \"organizationPanel\": \"組織設定\",\n    \"unknownError\": \"不明なエラー\",\n    \"pluginPanel\": \"パネル\",\n    \"pluginContextMenu\": \"コンテキストメニュー\",\n    \"plugin\": \"プラグイン\",\n    \"copy\": \"コピー\",\n    \"credits\": \"クレジット\",\n    \"aiChat\": \"AIチャット\",\n    \"app\": \"アプリ\",\n    \"webSearch\": \"ウェブ検索\",\n    \"folder\": \"フォルダ\",\n    \"newAutomation\": \"新しいオートメーション\",\n    \"newApp\": \"新しいアプリ\",\n    \"newFolder\": \"新しいフォルダ\",\n    \"template\": \"テンプレート\"\n  },\n  \"level\": {\n    \"free\": \"無料\",\n    \"plus\": \"プラス\",\n    \"pro\": \"プロ\",\n    \"business\": \"ビジネス\",\n    \"enterprise\": \"エンタープライズ\"\n  },\n  \"noResult\": \"結果なし。\",\n  \"allNodes\": \"すべてのノード\",\n  \"noDescription\": \"説明なし\",\n  \"untitled\": \"無題\",\n  \"name\": \"名称\",\n  \"description\": \"説明\",\n  \"required\": \"必須\",\n  \"characters\": \"文字\",\n  \"atLeastOne\": \"少なくとも1つの{{noun}}を保持してください\",\n  \"guide\": {\n    \"prev\": \"前へ\",\n    \"next\": \"次へ\",\n    \"done\": \"決定\",\n    \"skip\": \"スキップ\",\n    \"createSpaceTooltipTitle\": \"スペースの作成\",\n    \"createSpaceTooltipContent\": \"Teableはスペースで構成されており、各スペースではユーザーが共同作業を行うことができます。<br></br>Teableのスペースはメニューバー内の主要なナビゲーション要素として機能し、ユーザーが必要に応じてデータベースを追加および管理するための基本的なプラットフォームを提供します。\",\n    \"createBaseTooltipTitle\": \"ベースの作成\",\n    \"createBaseTooltipContent\": \"ベース (「データベース」の略) は、重要なデータとそれに依存するワークフローを保存する場所です。\",\n    \"createTableTooltipTitle\": \"テーブルの作成\",\n    \"createTableTooltipContent\": \"テーブルは、多様なデータセットを効率的に処理できるように設計されており、さまざまなデータ型による情報を多様に表示できます。\",\n    \"createViewTooltipTitle\": \"ビューの作成\",\n    \"createViewTooltipContent\": \"現在、ユーザーはグリッド、ギャラリー、カンバン、およびフォームビューを作成できます。カレンダービューは将来のリリースで追加される予定です。<br></br>この多様性により、ユーザーはさまざまなデータ管理タスクに対応する包括的なツールキットを利用できるようになります。\",\n    \"viewFilteringTooltipTitle\": \"レコードのフィルタリング\",\n    \"viewFilteringTooltipContent\": \"ビューのコア機能の一つは、設定した条件に従ってビューからレコードをフィルター処理できることです。<br></br>条件に基づいてレコードがフィルター処理されても、そのレコードは削除されず、テーブルを表示するために使用している特定のビューから非表示になるだけです。\",\n    \"viewSortingTooltipTitle\": \"レコードのソート\",\n    \"viewSortingTooltipContent\": \"ビューでは、特定のフィールドの値に従ってレコードを特定の順序で表示されるように並べ替えることができます。<br></br>あるビューでレコードを並べ替えても、他のビューのレコードの順序には影響しません。これは、テーブルを表示するために現在使用しているビューにのみ適用されます。\",\n    \"viewGroupingTooltipTitle\": \"レコードのグループ化\",\n    \"viewGroupingTooltipContent\": \"レコードをグループ化すると、作成者は特定のビュー内に表示されるデータセットを分類するのに役立つ一つ以上の条件のセットを構築できます。\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teableは、ほぼすべての製品機能をサポートする強力なAPIを提供しており、開発者は<a>トークン</a>を使用して呼び出しを行うことができます。\"\n  },\n  \"token\": \"トークン\",\n  \"poweredBy\": \"Powered by <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"{{spaceName}}スペース共有\",\n      \"desc_one\": \"このスペースには <b>{{count}}人の共同作業者</b>がいます。スペースの共同作業者を追加すると、その共同作業者はこのスペース内のすべての基地にアクセスできるようになります。\",\n      \"desc_other\": \"このスペースには <b>{{count}}人の共同作業者</b>がいます。スペースの共同作業者を追加すると、その共同作業者はこのスペース内のすべての基地にアクセスできるようになります。\",\n      \"tabEmail\": \"メールで招待\",\n      \"emailPlaceholder\": \"メールアドレスを入力し、Enterキーで区切ってください\",\n      \"tabLink\": \"リンクで招待\",\n      \"linkPlaceholder\": \"招待リンクを作成すると、リンクを開いたすべてのユーザーに<0/>アクセスが許可されます。\",\n      \"emailSend\": \"正体の送信\",\n      \"linkSend\": \"リンクの作成\",\n      \"spaceTitle\": \"スペースの共同作業者\",\n      \"collaboratorSearchPlaceholder\": \"名前またはメールアドレスでスペースの共同作業者を探す\",\n      \"collaboratorJoin\": \"{{joinTime}}に参加\",\n      \"collaboratorRemove\": \"共同作業者の削除\",\n      \"linkTitle\": \"招待リンク\",\n      \"linkCreatedTime\": \"{{createdTime}}に作成\",\n      \"linkCopySuccess\": \"リンクをコピーしました\",\n      \"linkRemove\": \"リンクの削除\"\n    },\n    \"base\": {\n      \"title\": \"{{baseName}}共有\",\n      \"desc_one\": \"このベースは{{count}}人の共同作業者と共有されています。\",\n      \"desc_other\": \"このベースは{{count}}人の共同作業者と共有されています。\",\n      \"baseTitle\": \"ベースの共同作業者\",\n      \"collaboratorSearchPlaceholder\": \"名前またはメールアドレスでベースの共同作業者を探す\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"組織の共同作業者を追加\",\n      \"placeholder\": \"組織のメンバーまたは部門を選択\"\n    }\n  },\n  \"help\": {\n    \"title\": \"ヘルプ\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"ページの権限が更新されました。最新のコンテンツを表示するにはページを更新してください。\",\n  \"listEmptyTips\": \"このリストは空です\",\n  \"billing\": {\n    \"overLimits\": \"制限超過\",\n    \"overLimitsDescription\": \"現在契約中のプランは使用制限を超えました。この機能を引き続き使用するには、プランをアップグレードしてください。\",\n    \"userLimitExceededDescription\": \"現在のインスタンスは、ライセンスで許可されている最大ユーザー数に達しました。一部のユーザーを非アクティブ化するか、ライセンスをアップグレードしてください。\",\n    \"unavailableInPlanTips\": \"現在契約中のプランではこの機能はサポートされていません\",\n    \"unavailableConnectionTips\": \"データベース接続機能は将来削除され、Enterpriseプランと自己ホストバージョンでのみ利用可能になります。\",\n    \"levelTips\": \"このスペースは現在{{level}}プランです\",\n    \"enterpriseFeature\": \"エンタープライズ機能\",\n    \"automationRequiresUpgrade\": \"オートメーションを有効にするにはEnterprise Edition（EE）にアップグレードしてください\",\n    \"authorityMatrixRequiresUpgrade\": \"権限マトリックスを有効にするにはEnterprise Edition（EE）にアップグレードしてください\",\n    \"viewPricing\": \"料金を見る\",\n    \"billable\": \"課金対象\",\n    \"billableByAuthorityMatrix\": \"権限マトリックスによって生成された課金\",\n    \"licenseExpiredGracePeriod\": \"セルフホスト版ライセンスの有効期限が切れました。{{expiredTime}}に無料プランへダウングレードされ、プレミアム機能が利用できなくなります。完全な機能を維持するため、速やかにライセンスを更新してください。\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"スペースの契約プランをアップグレード\",\n      \"description\": \"自分が所有者であるワークスペースのみアップグレードできます\"\n    },\n    \"status\": {\n      \"active\": \"アクティブ\",\n      \"canceled\": \"中止\",\n      \"incomplete\": \"未完成\",\n      \"incompleteExpired\": \"未完成 期限切れ\",\n      \"trialing\": \"試用\",\n      \"pastDue\": \"期限切れ\",\n      \"unpaid\": \"未払い\",\n      \"paused\": \"休止\",\n      \"seatLimitExceeded\": \"席数制限超過\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"インスタンス設定\",\n      \"description\": \"現在のインスタンスの設定を変更する\",\n      \"allowSignUp\": \"新しいアカウントの作成を許可する\",\n      \"allowSignUpDescription\": \"このオプションを無効にすると、新規ユーザーの登録が禁止され、ログインページに登録ボタンが表示されなくなります。\",\n      \"allowSpaceInvitation\": \"スペースへの招待の送信を許可する\",\n      \"allowSpaceInvitationDescription\": \"このオプションを無効にすると、管理者以外のユーザーは他のユーザーをスペースに招待できなくなります。有効にすると、メールで招待された新規ユーザーはメール内の招待リンクをクリックして登録を完了できますが、共有された招待リンクは機能しません。\",\n      \"allowSpaceCreation\": \"誰でも新しいスペースを作成できるようにする\",\n      \"allowSpaceCreationDescription\": \"このオプションを無効にすると、管理者以外のユーザーは新しいスペースを作成できなくなります。\",\n      \"enableEmailVerification\": \"メールアドレスの確認を有効にする\",\n      \"enableEmailVerificationDescription\": \"このオプションを有効にすると、新規アカウントの作成時にメールアドレスの確認が必要になります。\",\n      \"enableWaitlist\": \"待機リストを有効にする\",\n      \"enableWaitlistDescription\": \"このオプションを有効にすると、ユーザーは招待コードを使用して新規アカウントの作成が可能になります。\",\n      \"generalSettings\": \"一般設定\",\n      \"aiSettings\": {\n        \"title\": \"AI設定\",\n        \"description\": \"このインスタンスのAI設定を構成する\"\n      },\n      \"brandingSettings\": {\n        \"title\": \"ブランディング設定\",\n        \"description\": \"エンタープライズエディションでのみ利用可能\",\n        \"brandName\": \"ブランド名\",\n        \"logo\": \"ロゴ\",\n        \"logoDescription\": \"ロゴはTeableでのあなたのブランドアイデンティティです。\",\n        \"logoUpload\": \"ロゴをアップロード\",\n        \"logoUploadDescription\": \"ロゴ画像をアップロードし、PNG、JPEG形式をサポートし、推奨サイズは100x100pxです。最大アップロードサイズは500KBです。\"\n      },\n      \"ai\": {\n        \"name\": \"名前\",\n        \"nameDescription\": \"LLMプロバイダーの名前\",\n        \"enable\": \"AIを有効にする\",\n        \"enableDescription\": \"現在のインスタンスでAIを有効にし、すべてのユーザーがAI機能を使用できるようにする\",\n        \"updateLLMProvider\": \"LLMプロバイダーを更新する\",\n        \"addProvider\": \"LLMプロバイダーを追加する\",\n        \"addProviderDescription\": \"LLMプロバイダーのリストに新しいプロバイダーを追加する\",\n        \"providerType\": \"プロバイダータイプ\",\n        \"baseUrl\": \"ベースURL\",\n        \"apiKey\": \"APIキー\",\n        \"baseUrlDescription\": \"LLMプロバイダーのベースURL\",\n        \"apiKeyDescription\": \"LLMプロバイダーのAPIキー\",\n        \"models\": \"モデル\",\n        \"modelsDescription\": \"LLMプロバイダーでサポートされているモデル\",\n        \"baseUrlRequired\": \"ベースURLは必須です\",\n        \"fetchModelListError\": \"モデルリストの取得に失敗しました\",\n        \"provider\": \"LLMプロバイダー\",\n        \"providerDescription\": \"使用するLLMプロバイダー\",\n        \"modelPreferences\": \"モデルの設定\",\n        \"modelPreferencesDescription\": \"LLMプロバイダーのモデルの設定\",\n        \"embeddingModel\": \"埋め込みモデル\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"翻訳モデル\",\n        \"translationModelDescription\": \"使用する翻訳モデル\",\n        \"chatModel\": \"チャットモデル\",\n        \"chatModelDescription\": \"使用するチャットモデル、ヒント: 中型コーディングモデルはAI数式生成と関連機能にデフォルトで使用されます\",\n        \"chatModels\": {\n          \"lg\": \"高度なチャットモデル\",\n          \"lgDescription\": \"計画、コーディング、その他の複雑なタスクシナリオに使用。推奨: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"AI機能\",\n          \"aiField\": {\n            \"title\": \"AIフィールド\",\n            \"description\": \"自動入力やフィールド設定のAI構成を含むAIフィールド機能\"\n          },\n          \"aiChat\": {\n            \"title\": \"AIチャット\",\n            \"description\": \"AIチャットサイドバーとすべてのエージェント機能\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"モデルをテスト\",\n          \"description\": \"モデルの画像、PDF処理能力をテストします\",\n          \"notConfigLgModel\": \"まず大型モデルを設定してください\",\n          \"confirmTitle\": \"モデル能力をテスト\",\n          \"confirmDescription\": \"大型チャットモデルの能力をテストしますか？\",\n          \"confirm\": \"モデルをテスト\",\n          \"cancel\": \"キャンセル\",\n          \"missingCapabilitiesWarning\": \"このモデルは画像まPDF処理をサポートしておらず、一部の機能が利用できなくなる可能性があります。\",\n          \"enableAITitle\": \"AIを有効にする\",\n          \"enableAIDescription\": \"モデルテストが完了しました。AIは現在有効になっていません。これらのモデル能力を使用するためにAIを有効にしますか？\",\n          \"enableAI\": \"AIを有効にする\",\n          \"skipTest\": \"スキップ\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"画像\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"ウェブ検索\",\n          \"disabledWebSearch\": \"ウェブ検索無効\",\n          \"lgModelAbility\": \"大型モデル能力\"\n        },\n        \"configUpdated\": \"AI設定が更新されました\",\n        \"noModelFound\": \"モデルが見つかりません\",\n        \"searchModel\": \"モデルを検索...\",\n        \"selectModel\": \"モデルを選択...\",\n        \"input\": \"入力 {{ratio}}\",\n        \"output\": \"出力 {{ratio}}\",\n        \"inputOrOutputTip\": \"入力対出力比率は、クレジットとトークンの交換レートを表します。たとえば、「1:1000」は、1 クレジットが約 1000 トークンに等しいことを意味します。<br></br>注: 使用ごとに少なくとも 1 クレジットが差し引かれます。実際の消費量が 1 クレジット未満の場合でも、1 クレジットとして請求されます。\",\n        \"imageOutput\": \"画像ごとに{{credits}}\",\n        \"imageOutputTip\": \"画像生成にはクレジットが必要で、各画像は{{credits}}クレジットを消費します\",\n        \"supportImageOutputTip\": \"このモデルは画像生成をサポートしています\",\n        \"supportVisionTip\": \"このモデルは画像入力をサポートしています\",\n        \"supportAudioTip\": \"このモデルは音声入力をサポートしています\",\n        \"supportVideoTip\": \"このモデルは動画入力をサポートしています\",\n        \"supportDeepThinkTip\": \"このモデルは深い思考をサポートしています\",\n        \"testConnection\": \"テスト\",\n        \"testing\": \"テスト中...\",\n        \"testSuccess\": \"テスト成功\",\n        \"testFailed\": \"テスト失敗\",\n        \"fillRequiredFields\": \"すべての必須フィールドを入力してください\",\n        \"modelsRequired\": \"少なくとも1つのモデルを入力してください\",\n        \"noValidModel\": \"有効なモデルが見つかりません\",\n        \"addCustomModel\": \"カスタムモデルを追加する\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"ウェブ検索機能を有効にするためにFirecrawl APIキーを設定し、APIキーを取得するために<a>Firecrawl設定</a>にアクセスしてください\"\n      },\n      \"app\": {\n        \"domain\": \"ドメイン\",\n        \"v0ApiKey\": \"v0 APIキー\",\n        \"customDomain\": \"カスタムドメイン（オプション）\",\n        \"customDomainDescription\": \"アプリのデプロイ用のカスタムドメインを設定し、<a>Vercelドメイン</a>にアクセスしてカスタムドメインを取得してください\",\n        \"vercelToken\": \"Vercel APIトークン\",\n        \"vercelTokenDescription\": \"APIトークンを取得するために<a>Vercel設定</a>にアクセスしてください\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"APIキーを入力\",\n      \"goToConfiguration\": \"設定に移動\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"teableをご利用いただきありがとうございます\",\n      \"pleaseGoToConfiguration\": \"teableの完全な機能とより良いユーザーエクスペリエンスを楽しむために、設定ページに移動していくつかの初期設定を完了してください\",\n      \"pleaseContactAdmin\": \"管理者にお問い合わせください\"\n    },\n    \"configuration\": {\n      \"title\": \"設定待ち\",\n      \"description\": \"完全な機能を取得するためにこれらの設定を完了してください\",\n      \"copyInstance\": \"IDをコピー\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"PUBLIC_ORIGIN環境変数\",\n          \"description\": \"<strong>PUBLIC_ORIGIN</strong>環境変数設定が現在のアクセスアドレス<underline>https://example.com</underline>と一致しません。xlsx、csvインポートおよび添付ファイルフィールド機能が正常に動作しない可能性があります。<underline>https://example.com</underline>に設定することをお勧めします\"\n        },\n        \"https\": {\n          \"title\": \"HTTPSを有効にする\",\n          \"description\": \"HTTPSを有効にしていません。大規模コピー（300行以上）機能が利用できなくなります。有効にすることをお勧めします。\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"PUBLIC_DATABASE_PROXY環境変数\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong>が設定されていません。外部データベース接続機能が利用できません。<a>ヘルプドキュメント</a>を参照してください\",\n          \"href\": \"https://help.teable.ai/ja/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"AI LLM APIをまだ設定していません。AIチャット/AI自動化が使用できません。<anchor>設定に移動</anchor>\",\n          \"errorTips\": \"AI LLM APIをまだ設定していません。AIチャット/AI自動化が使用できません\"\n        },\n        \"app\": {\n          \"title\": \"アプリビルダー\",\n          \"description\": \"v0 APIをまだ設定していません。アプリビルダー機能が利用できません。<anchor>設定に移動</anchor>\",\n          \"errorTips\": \"v0 APIをまだ設定していません。アプリビルダー機能が利用できません\"\n        },\n        \"webSearch\": {\n          \"title\": \"ウェブ検索\",\n          \"description\": \"ウェブ検索APIをまだ設定していません。ウェブ検索機能が利用できません。<anchor>設定に移動</anchor>\",\n          \"errorTips\": \"ウェブ検索APIをまだ設定していません。ウェブ検索機能が利用できません\"\n        },\n        \"email\": {\n          \"title\": \"メール\",\n          \"description\": \"メールが設定されていません。セルフサービスパスワード復旧とメール通知機能が利用できません。<anchor>設定に移動</anchor>\",\n          \"errorTips\": \"メールが設定されていません。セルフサービスパスワード復旧、メール検証/通知機能が利用できません\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"通知\",\n    \"unread\": \"未読\",\n    \"read\": \"既読\",\n    \"markAs\": \"この通知を{{status}}としてマークする\",\n    \"markAllAsRead\": \"全て既読にする\",\n    \"noUnread\": \"{{status}}の通知はありません\",\n    \"changeSetting\": \"ページ通知設定を変更する\",\n    \"new\": \"{{count}}件の新たな通知\",\n    \"showMore\": \"もっと見る\",\n    \"exportBase\": {\n      \"successText\": \"エクスポートデータの準備ができました\",\n      \"failedText\": \"エクスポートに失敗しました。再試行してください\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"所有者\",\n      \"creator\": \"作成者\",\n      \"editor\": \"編集者\",\n      \"commenter\": \"解説者\",\n      \"viewer\": \"閲覧者\"\n    },\n    \"description\": {\n      \"owner\": \"ベース、オートメーション、権限マトリックスのすべての設定と編集、スペース設定と課金を管理できます。\",\n      \"creator\": \"ベース、オートメーション、権限マトリックスのすべての設定と編集ができます\",\n      \"editor\": \"レコードとビューを編集できますが、テーブルやフィールドを設定することはできません\",\n      \"commenter\": \"レコードにコメントできます\",\n      \"viewer\": \"編集もコメントもできません\"\n    }\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"プラグインにURLがありません\",\n    \"install\": \"インストール\",\n    \"publisher\": \"発行者\",\n    \"lastUpdated\": \"最終更新\",\n    \"pluginNotFound\": \"プラグインが見つかりません\",\n    \"pluginEmpty\": {\n      \"title\": \"プラグインがまだありません\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"スペースのゴミ箱\",\n    \"type\": \"タイプ\",\n    \"resetTrash\": \"ゴミ箱を空にする\",\n    \"deletedBy\": \"削除者\",\n    \"deletedTime\": \"削除日時\",\n    \"fromSpace\": \"「{{name}}」スペースから\",\n    \"permanentDeleteTips\": \"「{{name}}」{{resource}}を完全に削除してもよろしいですか？\",\n    \"resetTrashConfirm\": \"ゴミ箱を空にしてもよろしいですか？\",\n    \"addToTrash\": \"ゴミ箱に移動\",\n    \"description\": \"ゴミ箱のデータは依然としてレコード使用量と添付ファイル使用量を占めています。\",\n    \"spaceDescription\": \"過去{{retentionDays}}日以内に削除されたスペースを復元\",\n    \"spaceInnerDescription\": \"過去{{retentionDays}}日以内にこのスペースから削除されたベースを復元\",\n    \"baseDescription\": \"過去{{retentionDays}}日以内にこのベースから削除されたリソースを復元\"\n  },\n  \"automation\": {\n    \"turnOnTip\": \"現在のオートメーションを有効にしますか？\"\n  },\n  \"email\": {\n    \"send\": \"送信\",\n    \"config\": \"メール設定\",\n    \"customConfig\": \"カスタムメールサーバー\",\n    \"notify\": \"通知メール\",\n    \"automation\": \"自動化メール\",\n    \"customNotifyConfig\": \"カスタム通知メール設定\",\n    \"customAutomationConfig\": \"カスタム自動化メール設定\",\n    \"addConfig\": \"設定追加\",\n    \"editConfig\": \"設定編集\",\n    \"resetConfig\": \"リセット\",\n    \"testEmail\": \"テストメール\",\n    \"testEmailPlaceholder\": \"テストメールを入力してください\",\n    \"testEmailError\": \"正しいテストメールアドレスを入力してください\",\n    \"testEmailSend\": \"テストメールの送信に成功しました。メールボックスを確認してください\",\n    \"configError\": \"正しいメール設定を入力してください\",\n    \"host\": \"サーバーアドレス\",\n    \"hostDescription\": \"SMTPメールサーバーアドレスを入力してください。例：smtp.example.com\",\n    \"port\": \"ポート\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"認証\",\n    \"username\": \"ユーザー名\",\n    \"password\": \"パスワード\",\n    \"sender\": \"送信者アドレス\",\n    \"senderName\": \"送信者名\",\n    \"subscribe\": \"購読\",\n    \"unsubscribe\": \"購読解除\",\n    \"unsubscribeList\": \"購読解除リスト\",\n    \"unsubscribeTime\": \"購読解除時刻\",\n    \"source\": \"ソース\",\n    \"sourceAutomationDeleted\": \"自動化またはノードが削除されました\",\n    \"processing\": \"処理中...\",\n    \"unsubscribeH1\": \"登録解除を確認しますか？\",\n    \"unsubscribeH2\": \"今後のTeable のプロモーションや製品アップデート情報の配信を解除しようとしています。登録解除してもよろしいですか？\",\n    \"subscribeH1\": \"登録を確認しますか？\",\n    \"subscribeH2\": \"今後のTeable のプロモーションや製品アップデート情報の配信を登録しようとしています。登録してもよろしいですか？\",\n    \"unsubscribeListTip\": \"現在のベースの以下のユーザーが購読を解除しました。これらのユーザーにはメールを送信できなくなります。メールをインポートする際は、メールアドレスを最初の列に配置し、ヘッダーに「email」という名前を付けてください。\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"パスワードをリセット - {{brandName}}\",\n        \"title\": \"パスワードをリセット\",\n        \"message\": \"この変更をリクエストしていない場合は、このメールを無視してください。それ以外の場合は、下のボタンをクリックしてパスワードをリセットしてください。\",\n        \"buttonText\": \"パスワードをリセット\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"登録確認 - {{brandName}}\",\n          \"title\": \"登録確認\",\n          \"message\": \"確認コードは{{code}}です。{{expiresIn}}分以内に使用してください。\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"ドメイン確認 - {{brandName}}\",\n          \"title\": \"ドメイン確認\",\n          \"message\": \"あなたのワンタイムコードは：{{code}}です。{{expiresIn}}分以内に使用してください。\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"メール変更確認 - {{brandName}}\",\n          \"title\": \"メール変更確認\",\n          \"message\": \"確認コードは{{code}}です。{{expiresIn}}分以内に使用してください。\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}}があなたを{{tableName}}のレコードの{{fieldName}}フィールドに追加しました\",\n        \"title\": \"<strong>{{fromUserName}}</strong>があなたを<strong>{{tableName}}</strong>のレコードの<strong>{{fieldName}}</strong>フィールドに追加しました\",\n        \"buttonText\": \"レコードを表示\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}}があなたを{{tableName}}の{{refLength}}件のレコードに追加しました\",\n        \"title\": \"<strong>{{fromUserName}}</strong>があなたを<strong>{{tableName}}</strong>の<strong>{{refLength}}</strong>件のレコードに追加しました\",\n        \"buttonText\": \"レコードを表示\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}})があなたを{{resourceAlias}} {{resourceName}}に招待しました - {{brandName}}\",\n        \"title\": \"コラボレーションへの招待\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}})があなたを{{resourceAlias}} <strong>{{resourceName}}</strong>に招待しました。\",\n        \"buttonText\": \"招待を承諾\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"ようこそ - {{brandName}}\",\n        \"title\": \"ようこそ\",\n        \"message\": \"{{brandName}}のウェイトリストに登録されました。次の招待コードを使用して登録してください：{{code}}、{{times}}回使用できます。\",\n        \"buttonText\": \"登録\"\n      },\n      \"test\": {\n        \"subject\": \"テストメール - {{brandName}}\",\n        \"title\": \"テストメール\",\n        \"message\": \"これはテストメールです。無視してください。\"\n      },\n      \"notify\": {\n        \"subject\": \"通知 - {{brandName}}\",\n        \"title\": \"通知\",\n        \"buttonText\": \"表示\",\n        \"import\": {\n          \"title\": \"インポート結果通知\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ {{tableName}}のインポートが中止されました：{{errorMessage}} 失敗した行の範囲：[{{range}}]。この範囲のデータを確認して再試行してください。\"\n            },\n            \"failed\": {\n              \"message\": \"❌ {{tableName}}のインポートが失敗しました：{{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ {{tableName}}のインポートが失敗しました：行数上限に達しました。より多くのレコードをインポートするにはプランをアップグレードしてください\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ {{tableName}}のインポートが失敗しました：レコードが処理されませんでした\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}}が正常にインポートされました。\",\n              \"inplace\": \"🎉 {{tableName}}が正常にインプレースインポートされました。\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"レコードコメント通知\",\n          \"message\": \"{{fromUserName}}が{{baseName}}の{{tableName}}の{{recordName}}にコメントしました\"\n        },\n        \"automation\": {\n          \"title\": \"自動化通知\",\n          \"failed\": {\n            \"title\": \"自動化{{name}}が失敗しました\",\n            \"message\": \"自動化{{name}}の実行に失敗しました。下のボタンをクリックして、実行履歴から具体的なエラーを表示してください。\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"自動化{{name}}がクレジット不足により失敗しました\",\n            \"message\": \"自動化{{name}}がクレジット不足により実行に失敗しました。サブスクリプションをアップグレードするか、サポートに連絡してください。\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"自動化{{name}}は月間実行回数の上限に達しました\",\n            \"message\": \"自動化{{name}}の今月の実行回数を使い切ったため、現在は実行できません。プランをアップグレードするか、追加実行回数を購入してください。\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"請求通知\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"スペース {{spaceName}} AIクレジット 80% 使用済み\",\n              \"message\": \"スペースのAIクレジットの80%を使用しました。アップグレードまたは追加クレジットの購入をご検討ください。\"\n            },\n            \"warning90\": {\n              \"title\": \"スペース {{spaceName}} AIクレジット 90% 使用済み\",\n              \"message\": \"スペースのAIクレジットの90%を使用しました。中断を避けるため、早めにアップグレードしてください。\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"スペース {{spaceName}} 自動化実行回数 80% 使用済み\",\n              \"message\": \"スペースの自動化実行回数が {{usedRuns}}/{{totalLimit}} 回（80%）に達しました。プランのアップグレードをご検討ください。\"\n            },\n            \"warning90\": {\n              \"title\": \"スペース {{spaceName}} 自動化実行回数 90% 使用済み\",\n              \"message\": \"スペースの自動化実行回数が {{usedRuns}}/{{totalLimit}} 回（90%）に達しました。中断を避けるため、早めにアップグレードしてください。\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"スペース {{spaceName}} 自動化実行回数超過 - 猶予期間中\",\n              \"message\": \"自動化実行回数の上限を超えました。自動化は {{remainingHours}} 時間後に停止します。プランをアップグレードしてください。\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"ベースエクスポート結果通知\",\n          \"success\": {\n            \"message\": \"{{baseName}}が正常にエクスポートされました：<a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ {{baseName}}のエクスポートが失敗しました：{{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"テーブル{{tableName}}でAIタスクが失敗しました\",\n              \"message\": \"テーブル{{tableName}}のフィールド{{fieldName}}のAIタスク（レコードID：{{recordId}}）が失敗しました。\\n\\n{{errorMsg}}\\n\\n下のボタンをクリックして詳細を表示してください。\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"待機リスト\",\n    \"email\": \"メール\",\n    \"joinTitle\": \"必要に応じて迅速にスケールアップしています\",\n    \"joinDesc\": \"待機リストに参加 — 準備ができたらすぐにお知らせします\",\n    \"emailPlaceholder\": \"メールアドレスを入力してください...\",\n    \"youAreOnTheList\": \"待機リストに参加しました！\",\n    \"thanksForJoining\": \"待機リストに参加していただきありがとうございます。更新がある場合はすぐにお知らせします。\",\n    \"back\": \"戻る\",\n    \"inviteCodePlaceholder\": \"招待コードを入力してください\",\n    \"join\": \"待機リストに参加\",\n    \"joining\": \"参加中...\",\n    \"invite\": \"招待\",\n    \"inviteTime\": \"招待時間\",\n    \"createdTime\": \"参加時間\",\n    \"yes\": \"はい\",\n    \"no\": \"いいえ\",\n    \"generateCode\": \"招待コードを生成\",\n    \"count\": \"数\",\n    \"times\": \"回\",\n    \"generate\": \"生成\",\n    \"code\": \"招待コード\",\n    \"inviteSuccess\": \"招待成功\",\n    \"app\": {\n      \"previewAppError\": \"アプリケーションエラー\",\n      \"sendErrorToAI\": \"エラーを AI に送信\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"「{{name}}」ベースを削除してもよろしいですか？\",\n    \"createResource\": \"リソースを作成\",\n    \"noPermissionToCreateResource\": \"リソースを作成する権限がありません\"\n  },\n  \"credit\": {\n    \"title\": \"クレジット\",\n    \"leftAmount\": \"残りクレジット\",\n    \"winFreeCredits\": \"体験を共有\",\n    \"getCredits\": \"1000無料クレジットを取得\",\n    \"winCredit\": {\n      \"title\": \"ポジティブなレビューを共有して獲得\",\n      \"freeCredits\": \"1000無料クレジット\",\n      \"guidelinesTitle\": \"共有チェックリスト\",\n      \"tagTeableio\": \"<bold>@teableio</bold>をタグ付け\",\n      \"minCharacters\": \"<bold>80+</bold>文字のレビューを書く\",\n      \"minFollowers\": \"<bold>10+</bold>人のフォロワーを持つ\",\n      \"limitPerWeek\": \"投稿ごとに500クレジット、週に最大2投稿（X + LinkedIn）\",\n      \"postOnX\": \"Xに投稿\",\n      \"postOnLinkedIn\": \"LinkedInに投稿\",\n      \"preFilledDraft\": \"🌟 事前入力された下書きが準備されています！\",\n      \"claimTitle\": \"投稿URLを貼り付けてクレジットを請求\",\n      \"userEmail\": \"ユーザーメール\",\n      \"postUrlLabel\": \"投稿URL（XまたはLinkedIn）\",\n      \"postUrlPlaceholder\": \"投稿のリンクをここに貼り付け\",\n      \"invalidUrl\": \"有効なXまたはLinkedInの投稿URLを入力してください\",\n      \"claiming\": \"請求中...\",\n      \"claimCredits\": \"クレジットを請求\",\n      \"congratulations\": \"おめでとうございます！\",\n      \"claimSuccess\": \"500クレジットを獲得しました！\",\n      \"verifying\": \"投稿を確認中...\",\n      \"verifyingDescription\": \"投稿内容を確認しています。通常数秒かかります\",\n      \"verifyFailed\": \"確認に失敗しました\",\n      \"tryAgain\": \"再試行\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"確認に失敗しました\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"報酬\",\n    \"rewardCredits\": \"報酬クレジット\",\n    \"minCharCount\": \"投稿は少なくとも{{count}}文字必要です\",\n    \"minFollowerCount\": \"アカウントには少なくとも{{count}}人のフォロワーが必要です\",\n    \"mustMention\": \"投稿は{{mention}}をメンションする必要があります\",\n    \"fetchSnapshotFailed\": \"投稿スナップショットの取得に失敗しました\",\n    \"alreadyClaimedThisWeek\": \"今週すでにこのアカウントの報酬を請求しています\",\n    \"manage\": {\n      \"title\": \"報酬管理\",\n      \"description\": \"すべてのスペースの報酬記録を表示・管理\",\n      \"overview\": \"概要\",\n      \"records\": \"記録\",\n      \"searchSpace\": \"スペースを検索...\",\n      \"searchRecords\": \"postUrl/userIdを検索...\",\n      \"dateRange\": \"日付範囲を選択\",\n      \"from\": \"開始\",\n      \"to\": \"終了\",\n      \"totalSpaces\": \"合計: {{count}}スペース\",\n      \"totalRecords\": \"合計: {{count}}件の記録\",\n      \"space\": \"スペース\",\n      \"allSpaces\": \"すべてのスペース\",\n      \"user\": \"ユーザー\",\n      \"creator\": \"作成者\",\n      \"sourceType\": \"ソースタイプ\",\n      \"platform\": \"プラットフォーム\",\n      \"allStatuses\": \"すべてのステータス\",\n      \"allSourceTypes\": \"すべてのソースタイプ\",\n      \"allPlatforms\": \"すべてのプラットフォーム\",\n      \"pendingCount\": \"保留中の数\",\n      \"approvedCount\": \"承認済みの数\",\n      \"rejectedCount\": \"却下された数\",\n      \"approvedAmount\": \"承認済み金額\",\n      \"consumedAmount\": \"消費済み金額\",\n      \"availableAmount\": \"利用可能金額\",\n      \"expiringSoonAmount\": \"まもなく期限切れ（7日）\",\n      \"amount\": \"金額\",\n      \"remainingAmount\": \"残額\",\n      \"createdTime\": \"作成日時\",\n      \"rewardTime\": \"報酬日時\",\n      \"expiredTime\": \"有効期限\",\n      \"lastModified\": \"最終更新\",\n      \"viewDetails\": \"詳細を表示\",\n      \"details\": \"報酬の詳細\",\n      \"basicInfo\": \"基本情報\",\n      \"amountInfo\": \"金額情報\",\n      \"timeInfo\": \"時間情報\",\n      \"socialInfo\": \"ソーシャル情報\",\n      \"verifyResult\": \"確認結果\",\n      \"uniqueKey\": \"一意のキー\",\n      \"verify\": \"確認\",\n      \"valid\": \"有効\",\n      \"invalid\": \"無効\",\n      \"errors\": \"エラー\",\n      \"copied\": \"{{label}}をコピーしました\",\n      \"openPost\": \"投稿を開く\",\n      \"noData\": \"データなし\",\n      \"page\": \"{{total}}ページ中{{current}}ページ\",\n      \"status\": {\n        \"label\": \"ステータス\",\n        \"pending\": \"保留中\",\n        \"approved\": \"承認済み\",\n        \"rejected\": \"却下\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"ページが見つかりません\",\n      \"description\": \"お探しのリンクは無効であるか、ページが移動された可能性があります。\"\n    },\n    \"links\": {\n      \"backToHome\": \"ホームへ戻る\"\n    },\n    \"forbidden\": {\n      \"title\": \"アクセス制限\",\n      \"description\": \"このリソースにアクセスするには権限が必要です。\\n管理者にお問い合わせください。\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"プレミアム機能のロック解除\",\n      \"description\": \"この機能は上位プランでご利用いただけます。\\nアップグレードして機能を拡張しましょう。\"\n    },\n    \"error\": {\n      \"title\": \"エラーが発生しました\",\n      \"description\": \"予期しないエラーが発生しました。後でもう一度お試しください。\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}日付解析エラー、値「{{value}}」が有効範囲外です\",\n      \"planRowLimit\": \"行数上限に達しました：より多くのレコードをインポートするにはプランをアップグレードしてください\",\n      \"notNullValidation\": \"{{fieldHint}}必須フィールドが空です\",\n      \"uniqueValidation\": \"{{fieldHint}}一意フィールドに重複値があります\",\n      \"requestTimeout\": \"リクエストがタイムアウトしました\",\n      \"chunkProcessingFailed\": \"バッチ処理に失敗しました：{{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"新着アップデート\",\n    \"title\": \"添付ファイルの一括ダウンロードが利用可能に\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"APIクエリビルダー\",\n  \"subTitle\": \"インタラクティブなUIを使用してクエリリクエストをすばやく構築し、直接実行できるコードをコピーできます。\",\n  \"apiList\": \"完全なAPIリスト\",\n  \"cellFormat\": \"セル結果の形式\",\n  \"fieldKeyType\": \"フィールドキータイプ\",\n  \"chooseSource\": \"データソースを選択\",\n  \"action\": {\n    \"selectBase\": \"データベースを選択...\",\n    \"selectTable\": \"テーブルを選択...\"\n  },\n  \"pickParams\": \"パラメータを選択して設定する\",\n  \"buildResult\": \"構築結果\",\n  \"buildResultEmpty\": \"まだ結果はありません\",\n  \"previewReturnValue\": \"戻り値のプレビュー\",\n  \"replaceToken\": \"トークンを置き換える\",\n  \"createNewToken\": \"新しいトークンを作成する\",\n  \"only10Records\": \"最初の10件のレコードのみが表示されます\",\n  \"addSort\": \"並べ替えを追加\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/oauth.json",
    "content": "{\n  \"add\": \"新しいOAuthアプリ\",\n  \"title\": {\n    \"add\": \"新しいOAuthアプリ\",\n    \"edit\": \"OAuthアプリの編集\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"OAuthアプリ名\",\n      \"description\": \"OAuthアプリの名前。\"\n    },\n    \"description\": {\n      \"label\": \"説明\",\n      \"description\": \"OAuthアプリの簡単な説明。\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"ホームページURL\",\n      \"description\": \"OAuthアプリのWebサイトへの完全なURL。\"\n    },\n    \"logo\": {\n      \"label\": \"ロゴ\",\n      \"description\": \"正方形の画像が推奨されます。\",\n      \"placeholder\": \"ロゴをここにドラッグアンドドロップするか、クリックしてアップロードしてください\",\n      \"button\": \"ロゴのアップロード\",\n      \"clear\": \"クリア\",\n      \"lengthError\": \"許可されるファイルは1つだけです。\",\n      \"typeError\": \"画像ファイルのみ許可されます。\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"コールバックURL\",\n      \"description\": \"ユーザーが統合を承認した後にリダイレクトする完全なURL。\",\n      \"add\": \"コールバックURLを追加\"\n    },\n    \"scopes\": {\n      \"label\": \"Scopes\",\n      \"description\": \"OAuthアプリに必要な権限。\"\n    },\n    \"secret\": {\n      \"label\": \"クライアントシークレット\",\n      \"add\": \"新しいクライアントシークレットを生成する\",\n      \"newDescription\": \"新しいクライアントシークレットを必ずコピーしてください。再度表示することはできません。\",\n      \"empty\": \"アプリケーションとしてAPIを認証するには、クライアントシークレットが必要です。\",\n      \"lastUsed\": \"最終使用日：{{date}}\",\n      \"tag\": \"クライアントシークレット\",\n      \"neverUsed\": \"未使用\"\n    },\n    \"clientId\": {\n      \"label\": \"クライアントID: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"基本情報\",\n    \"scopes\": \"Scopes\",\n    \"identify\": \"ユーザーの識別と承認\",\n    \"clientInfo\": \"クライアント情報\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}}があなたのアカウントへのアクセスを要求しています\",\n    \"scopes\": \"このアプリケーションは、次のscopeを取得できます:\",\n    \"redirectDescription\": \"承認するとリダイレクトされます\",\n    \"authorize\": \"承認\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"近日公開\",\n    \"empty\": \"空白\",\n    \"noRecords\": \"記録がありません\",\n    \"unnamedRecord\": \"無名のレコード\",\n    \"untitled\": \"無題\",\n    \"cancel\": \"キャンセル\",\n    \"confirm\": \"確認\",\n    \"back\": \"戻る\",\n    \"done\": \"決定\",\n    \"create\": \"作成\",\n    \"search\": {\n      \"placeholder\": \"検索...\",\n      \"empty\": \"結果が見つかりません\"\n    },\n    \"readOnlyTip\": \"このビューはロックされています。<button>個人モード</button>を有効にすることで、ビューのオプションを編集できます。変更はあなたにのみ適用されます。\",\n    \"selectPlaceHolder\": \"選択...\",\n    \"loading\": \"読込中...\",\n    \"loadMore\": \"さらに読み込む\",\n    \"uploadFailed\": \"アップロードに失敗しました\",\n    \"rowCount\": \"{{count}}レコード\",\n    \"summary\": \"概要ー\",\n    \"summaryTip\": \"カーソルを合わせて概要を選択\",\n    \"actions\": \"アクション\",\n    \"remove\": \"削除\",\n    \"runStatus\": {\n      \"success\": \"{{name}} 成功\",\n      \"failed\": \"{{name}} 失敗\",\n      \"running\": \"{{name}} 実行中\"\n    },\n    \"resetSuccess\": \"リセット成功\",\n    \"click\": \"クリック\",\n    \"clickedCount\": \"{{label}}: クリック {{text}} 回\",\n    \"atLeastOne\": \"少なくとも1つの{{noun}}を保持してください\"\n  },\n  \"notification\": {\n    \"title\": \"通知\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"プレビュー可能なファイルサイズの上限: {{size}}MB、ダウンロードしてご覧ください。\",\n    \"loadFileError\": \"ファイルの読み込みに失敗しました\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Undo\",\n    \"redo\": \"Redo\",\n    \"undoFailed\": \"Undo failed\",\n    \"redoFailed\": \"Redo failed\",\n    \"nothingToUndo\": \"Nothing to undo\",\n    \"nothingToRedo\": \"Nothing to redo\",\n    \"undoSucceed\": \"Undo succeed\",\n    \"redoSucceed\": \"Redo succeed\",\n    \"undoing\": \"undoing...\",\n    \"redoing\": \"redoing...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"ファイルをアップロードするにはリリースしてください\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"ここに貼り付けるかドラッグアンドドロップしてアップロードしてください\",\n      \"upload\": \"アップロード\"\n    },\n    \"date\": {\n      \"placeholder\": \"日付を選択\",\n      \"today\": \"今日\"\n    },\n    \"formula\": {\n      \"title\": \"数式エディタ\",\n      \"guideSyntax\": \"構文\",\n      \"guideExample\": \"例\",\n      \"helperExample\": \"例: \",\n      \"fieldValue\": \"{{fieldName}}フィールドのセルに値を返します。\",\n      \"placeholder\": \"数式を入力\",\n      \"placeholderForAIPrompt\": \"生成したい数式を記述してください\",\n      \"editExpression\": \"数式を編集\",\n      \"generateExpressionByAI\": \"AIで数式を生成\",\n      \"inputPrompt\": \"入力指示\",\n      \"generateExpression\": \"生成数式\",\n      \"generatingByAI\": \"AIで数式を生成中...\",\n      \"generatedExpressionTips\": \"生成後、適用ボタンをクリックして数式を挿入します\",\n      \"action\": {\n        \"generating\": \"生成中...\",\n        \"generate\": \"生成\",\n        \"apply\": \"適用\"\n      },\n      \"expressionRequired\": \"式は必須です\"\n    },\n    \"link\": {\n      \"placeholder\": \"リンクするレコードを選択\",\n      \"searchPlaceholder\": \"レコードの検索\",\n      \"allFields\": \"すべてのフィールド\",\n      \"globalSearch\": \"グローバル検索\",\n      \"fieldSearch\": \"フィールド検索\",\n      \"maxFieldTips\": \"最大{{count}}フィールドを超えました。余分なフィールドは無視されます\",\n      \"create\": \"レコードの追加\",\n      \"selectRecord\": \"レコードを選択\",\n      \"all\": \"すべて\",\n      \"selected\": \"選択済\",\n      \"expandRecordError\": \"このレコードを表示する権限がありません。\",\n      \"alreadyOpen\": \"このレコードはすでに開いています。\",\n      \"linkedTo\": \"リンク先\",\n      \"goToForeignTable\": \"関連テーブルに移動\",\n      \"foreignTableIdRequired\": \"関連テーブルは必須です\",\n      \"linkFieldIdRequired\": \"リンクフィールドは必須です\",\n      \"selectTooManyRecords\": \"選択されたレコードは{{maxCount}}を超えてはいけません\",\n      \"relationshipRequired\": \"リレーションシップは必須です\",\n      \"rangeSelectFailed\": \"範囲内のレコードの選択に失敗しました\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"名前でユーザーを検索する\",\n      \"notify\": \"選択されたらユーザーに通知する\"\n    },\n    \"select\": {\n      \"addOption\": \"オプション '{{option}}' を追加\",\n      \"choicesNameRequired\": \"選択肢名は空にできません\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"ルックアップフィールドは必須です\",\n      \"lookupOptionsNotAllowed\": \"isLookup属性がtrueまたはフィールドタイプがrollupの場合、ルックアップオプションは許可されません。\",\n      \"lookupOptionsRequired\": \"ルックアップオプションが必要です\",\n      \"refineOptionsError\": \"ルックアップオプションの解析エラー {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"式は必須です\",\n      \"unsupportedTip\": \"ロールアップはリンクフィールドとロールアップフィールドのみサポートします\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"フィルターには少なくとも1つの条件が必要です\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"条件付き検索には少なくとも1つのフィルター条件が必要です\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"モデルは必須です\",\n      \"typeNotSupported\": \"サポートされていないAIタイプ\",\n      \"sourceFieldIdRequired\": \"ソースフィールドは必須です\",\n      \"targetLanguageRequired\": \"対象言語は必須です\",\n      \"promptRequired\": \"プロンプトは必須です\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"フィールドオプションの解析エラー {{message}}\",\n      \"optionsRequired\": \"フィールドオプションは必須です\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"フィルター\",\n    \"displayLabel\": \"フィルター \",\n    \"displayLabel_other\": \"{{fieldName}}と{{count}}の他のフィールドでフィルタリング\",\n    \"addCondition\": \"条件を追加\",\n    \"addConditionGroup\": \"条件グループを追加\",\n    \"nestedLimitTip\": \"フィルタ条件は{{depth}}レベルまでネストできます\",\n    \"linkInputPlaceholder\": \"値を入力してください\",\n    \"groupDescription\": \"以下のいずれかに当てはまります…\",\n    \"currentUser\": \"自分 (現在のユーザー)\",\n    \"tips\": {\n      \"scope\": \"このビューでは、レコードを表示します\"\n    },\n    \"invalidateSelected\": \"無効な値\",\n    \"invalidateSelectedTips\": \"選択した値は削除されました。もう一度選択してください\",\n    \"default\": {\n      \"empty\": \"フィルター条件は適用されません\",\n      \"placeholder\": \"値を入力してください\"\n    },\n    \"conjunction\": {\n      \"and\": \"and\",\n      \"or\": \"or\",\n      \"where\": \"where\",\n      \"meetingAll\": \"すべての条件を満たす\",\n      \"meetingAny\": \"いずれかの条件を満たす\"\n    },\n    \"operator\": {\n      \"is\": \"等しい\",\n      \"isNot\": \"等しくない\",\n      \"contains\": \"含む\",\n      \"doesNotContain\": \"含まない\",\n      \"isEmpty\": \"空\",\n      \"isNotEmpty\": \"空ではない\",\n      \"isGreater\": \"より大きい\",\n      \"isGreaterEqual\": \"以上\",\n      \"isLess\": \"より小さい\",\n      \"isLessEqual\": \"以下\",\n      \"isAnyOf\": \"いずれか\",\n      \"isNoneOf\": \"いずれでもない\",\n      \"hasAnyOf\": \"いずれも含む\",\n      \"hasAllOf\": \"すべて含む\",\n      \"hasNoneOf\": \"含まない\",\n      \"isExactly\": \"正確に\",\n      \"isWithIn\": \"中にある\",\n      \"isBefore\": \"より前\",\n      \"isAfter\": \"より後\",\n      \"isOnOrBefore\": \"以前\",\n      \"isOnOrAfter\": \"以後\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"今日\",\n        \"tomorrow\": \"明日\",\n        \"yesterday\": \"昨日\",\n        \"oneWeekAgo\": \"一週間前\",\n        \"oneWeekFromNow\": \"一週間後\",\n        \"oneMonthAgo\": \"一ヶ月前\",\n        \"oneMonthFromNow\": \"一ヶ月後\",\n        \"daysAgo\": \"日前\",\n        \"daysFromNow\": \"日後\",\n        \"exactDate\": \"正確な日付\",\n        \"exactFormatDate\": \"正確な日付（フォーマット）\",\n        \"currentWeek\": \"current week\",\n        \"currentMonth\": \"current month\",\n        \"currentYear\": \"current year\",\n        \"lastWeek\": \"last week\",\n        \"lastMonth\": \"last month\",\n        \"lastYear\": \"last year\",\n        \"nextWeekPeriod\": \"next week\",\n        \"nextMonthPeriod\": \"next month\",\n        \"nextYearPeriod\": \"next year\",\n        \"pastWeek\": \"先週\",\n        \"pastMonth\": \"先月\",\n        \"pastYear\": \"去年\",\n        \"nextWeek\": \"来週\",\n        \"nextMonth\": \"来月\",\n        \"nextYear\": \"来年\",\n        \"pastNumberOfDays\": \"過去の日数\",\n        \"nextNumberOfDays\": \"次の日数\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"色\"\n  },\n  \"rowHeight\": {\n    \"short\": \"低\",\n    \"medium\": \"中\",\n    \"tall\": \"高\",\n    \"extraTall\": \"超高\",\n    \"title\": \"行高\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"フィールド名\",\n    \"displayLines\": \"{{count}} 行\"\n  },\n  \"share\": {\n    \"title\": \"共有\"\n  },\n  \"extensions\": {\n    \"title\": \"拡張機能\"\n  },\n  \"hidden\": {\n    \"label\": \"隠しフィールド\",\n    \"configLabel_one\": \"{{count}}個の隠しフィールド\",\n    \"configLabel_other\": \"{{count}}個の隠しフィールド\",\n    \"configLabel_other_visible\": \"{{count}}表示フィールド\",\n    \"showAll\": \"すべて表示\",\n    \"hideAll\": \"すべて非表示\",\n    \"primaryKey\": \"プライマリフィールド: レコードを識別するために使用され、非表示にしたり削除したりすることはできません\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"クリップボードにコピー\",\n    \"duplicateRecord\": \"Duplicate record\",\n    \"copyRecordUrl\": \"レコードのURLをコピー\",\n    \"deleteRecord\": \"レコードを削除\",\n    \"addRecordComment\": \"コメントを追加\",\n    \"viewRecordHistory\": \"レコード履歴を表示\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"レコードの履歴を非表示\",\n      \"showRecordHistory\": \"レコードの履歴を表示\",\n      \"createdTime\": \"作成日時\",\n      \"createdBy\": \"作成者\",\n      \"before\": \"以前\",\n      \"after\": \"以後\",\n      \"viewRecord\": \"レコードを表示\"\n    },\n    \"showHiddenFields\": \"{{count}} 個の隠しフィールドを表示\",\n    \"hideHiddenFields\": \"{{count}} 個の隠しフィールドを非表示\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"ソート\",\n    \"displayLabel_one\": \"{{count}}フィールドで並べ替え\",\n    \"displayLabel_other\": \"{{count}}フィールドで並べ替え\",\n    \"setTips\": \"並べ替え\",\n    \"addButton\": \"別のソートを追加\",\n    \"autoSort\": \"レコードを自動的に並べ替える\",\n    \"selectASCLabel\": \"最初 → 最後\",\n    \"selectDESCLabel\": \"最後 → 最初\"\n  },\n  \"group\": {\n    \"label\": \"グループ\",\n    \"displayLabel_one\": \"{{count}}フィールドでグループ化\",\n    \"displayLabel_other\": \"{{count}}フィールドでグループ化\",\n    \"setTips\": \"グループ化\",\n    \"addButton\": \"サブグループを追加\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"1行テキスト\",\n      \"longText\": \"長いテキスト\",\n      \"singleSelect\": \"単一選択\",\n      \"number\": \"番号\",\n      \"multipleSelect\": \"複数選択\",\n      \"link\": \"別のレコードへのリンク\",\n      \"formula\": \"式\",\n      \"date\": \"日付\",\n      \"createdTime\": \"作成日時\",\n      \"lastModifiedTime\": \"最終更新日時\",\n      \"attachment\": \"添付ファイル\",\n      \"checkbox\": \"チェックボックス\",\n      \"rollup\": \"ロールアップ\",\n      \"conditionalRollup\": \"条件付きロールアップ\",\n      \"user\": \"ユーザー\",\n      \"rating\": \"評価\",\n      \"autoNumber\": \"自動番号\",\n      \"lookup\": \"関連テーブルから検索\",\n      \"conditionalLookup\": \"条件付き検索\",\n      \"button\": \"ボタン\",\n      \"createdBy\": \"作成者\",\n      \"lastModifiedBy\": \"最終更新者\"\n    },\n    \"description\": {\n      \"singleLineText\": \"名前やタイトルなどの短いテキストを保存します。\",\n      \"longText\": \"長めのメモや説明を記録します。\",\n      \"singleSelect\": \"リストから1つの選択肢を選びます。\",\n      \"number\": \"書式を保った数値を管理します。\",\n      \"multipleSelect\": \"複数の選択肢でレコードにタグ付けします。\",\n      \"link\": \"このレコードを別のテーブルと関連付けます。\",\n      \"formula\": \"他のフィールドから値を計算します。\",\n      \"date\": \"日付や時間を記録します。\",\n      \"createdTime\": \"レコードが作成された日時を表示します。\",\n      \"lastModifiedTime\": \"最新の更新日時を表示します。\",\n      \"attachment\": \"ファイルのアップロードやAI画像生成が可能、🍌 Nano banana pro などのモデルに対応\",\n      \"checkbox\": \"シンプルなオン／オフを切り替えます。\",\n      \"rollup\": \"関連レコードを数式で集計します。\",\n      \"conditionalRollup\": \"条件に基づいてデータを集計します。\",\n      \"user\": \"レコードをワークスペースメンバーに割り当てます。\",\n      \"rating\": \"カスタムアイコンで項目に評価を付けます。\",\n      \"autoNumber\": \"各レコードに連番を付与します。\",\n      \"lookup\": \"関連レコードの値を表示します。\",\n      \"conditionalLookup\": \"設定したフィルター条件に合致する関連値を表示します。\",\n      \"button\": \"クリック可能なボタンでアクションを実行します。\",\n      \"createdBy\": \"レコードを作成したユーザーを表示します。\",\n      \"lastModifiedBy\": \"最後に編集したユーザーを表示します。\"\n    },\n    \"link\": {\n      \"oneWay\": \"一方向\",\n      \"twoWay\": \"双方向\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"操作確認\",\n        \"description\": \"この操作を実行してもよろしいですか？\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"スペースを作成\",\n      \"spaceDelete\": \"スペースを削除\",\n      \"spaceRead\": \"スペースを読み取り\",\n      \"spaceUpdate\": \"スペースを更新\",\n      \"spaceInviteEmail\": \"スペースにメールで招待\",\n      \"spaceInviteLink\": \"スペースにリンクで招待\",\n      \"spaceGrantRole\": \"スペースでの役割\",\n      \"baseCreate\": \"ベースの作成\",\n      \"baseDelete\": \"ベースの削除\",\n      \"baseRead\": \"ベースを読み取り\",\n      \"baseReadAll\": \"すべてのベースを読み取り\",\n      \"baseUpdate\": \"ベースの更新\",\n      \"baseInviteEmail\": \"ベースにメールで招待\",\n      \"baseInviteLink\": \"ベースにリンクで招待\",\n      \"baseTableImport\": \"ベースにデータをインポート\",\n      \"baseAuthorityMatrixConfig\": \"権限マトリックスを構成する\",\n      \"baseDbConnect\": \"データベースに接続\",\n      \"tableCreate\": \"テーブルの作成\",\n      \"tableRead\": \"テーブルを読み取り\",\n      \"tableDelete\": \"テーブルを削除\",\n      \"tableUpdate\": \"テーブルの更新\",\n      \"tableImport\": \"テーブルにデータをインポート\",\n      \"tableExport\": \"テーブルのデータをエクスポート\",\n      \"tableTrashRead\": \"テーブルのごみ箱を読む\",\n      \"tableTrashUpdate\": \"テーブルのごみ箱を更新\",\n      \"tableTrashReset\": \"テーブルのごみ箱をリセット\",\n      \"viewCreate\": \"ビューを作成\",\n      \"viewDelete\": \"ビューを削除\",\n      \"viewRead\": \"ビューを読み取り\",\n      \"viewUpdate\": \"ビューを更新\",\n      \"viewShare\": \"Share view\",\n      \"fieldCreate\": \"フィールドの作成\",\n      \"fieldDelete\": \"フィールドの削除\",\n      \"fieldRead\": \"フィールドの読み取り\",\n      \"fieldUpdate\": \"フィールドの更新\",\n      \"recordCreate\": \"レコードの作成\",\n      \"recordComment\": \"レコードにコメント\",\n      \"recordDelete\": \"レコードの削除\",\n      \"recordRead\": \"レコードの読み取り\",\n      \"recordUpdate\": \"レコードの更新\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"オートメーションの作成\",\n      \"automationDelete\": \"オートメーションの削除\",\n      \"automationRead\": \"オートメーションの読み取り\",\n      \"automationUpdate\": \"オートメーションの更新\",\n      \"appCreate\": \"アプリを作成\",\n      \"appDelete\": \"アプリを削除\",\n      \"appRead\": \"アプリを読み取り\",\n      \"appUpdate\": \"アプリを更新\",\n      \"userProfileRead\": \"ユーザーのプロフィールの読み取り\",\n      \"userEmailRead\": \"ユーザーメールの読み取り\",\n      \"userIntegrations\": \"ユーザー統合の管理\",\n      \"recordHistoryRead\": \"レコード履歴の読み取り\",\n      \"baseQuery\": \"クエリベース\",\n      \"instanceRead\": \"インスタンスの読み取り\",\n      \"instanceUpdate\": \"インスタンスの更新\",\n      \"enterpriseRead\": \"Read enterprise configuration\",\n      \"enterpriseUpdate\": \"Update enterprise configuration\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"テーブル\",\n    \"view\": \"ビュー\",\n    \"space\": \"スペース\",\n    \"base\": \"ベース\",\n    \"field\": \"フィールド\",\n    \"record\": \"レコード\",\n    \"automation\": \"オートメーション\",\n    \"app\": \"アプリ\",\n    \"user\": \"ユーザー\",\n    \"recordHistory\": \"レコード履歴\",\n    \"you\": \"あなた\",\n    \"instance\": \"インスタンス\",\n    \"enterprise\": \"エンタープライズ\",\n    \"history\": \"履歴\",\n    \"global\": \"グローバル\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"数字を合計します。数字1 + 数字2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"数値の平均を返します。\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"指定された数値の最大値を返します。\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"指定された数値の最小値を返します。\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"値を \\\"precision\\\" で指定された小数点以下の桁数に丸めます（具体的には、ROUND は指定された precision で四捨五入に丸め、同点の場合は正の無限大に向かって半分に切り上げます）。\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"値を \\\"precision\\\" で指定された小数点以下の桁数に丸めます。常に切り上げ、つまり0から離れます。（precisionの値を指定しないと、関数は機能しません。）\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"値を \\\"precision\\\" で指定された小数点以下の桁数に丸めます。常に切り捨て、つまり0に近づきます。（precisionの値を指定しないと、関数は機能しません。）\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"値以上の、重要度の最も近い整数倍を返します。重要度が指定されていない場合は、重要度1が想定されます。\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"値以下の、重要度の最も近い整数倍を返します。重要度が指定されていない場合は、重要度1が想定されます。\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"指定された値以上の最小の偶数を返します。\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"正の値は最も近い奇数に切り上げ、負の値は最も近い奇数に切り下げます。\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"数値を最も近い整数に切り捨てます。\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"絶対値を返します。\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"負でない数の平方根を返します。\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"指定された値を指定された指数で累乗します。\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"オイラー数(e)を指定された累乗で計算します。\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"指定された値の対数を計算します。値が指定されていない場合、デフォルトで10になります。\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"最初の引数を 2 番目の引数で割った余りを返します。\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"テキスト文字列を数値に変換します。\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"さまざまな値のタイプの引数を 1つのテキスト値に結合します。\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"オプションの startFromPosition から始まる whereToSearch 文字列内で stringToFind の出現を検索します（ startFromPosition はデフォルトで0です）。stringToFind の出現が見つからない場合、結果は0になります。\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"stringToFind は、オプションの startFromPosition から whereToSearch 文字列内で stringToFind の出現を検索します。（startFromPosition はデフォルトで0です。）stringToFind の出現が見つからない場合、結果は空になります。\\nFIND()と似ていますが、FIND() は stringToFind の出現が見つからない場合、空ではなく0を返します。\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"whereToStart から始まるcount文字の部分文字列を抽出します。\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"文字列の先頭から howMany 文字を抽出します。\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"文字列の末尾から howMany 文字を抽出します。\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"開始文字から始まる文字数を置換テキストに置き換えます。\\n(old_text のすべての出現箇所を検索して new_text に置き換える方法を探している場合は、SUBSTITUTE() を参照してください。)\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"正規表現に一致するすべての部分文字列を置換文字列に置き換えます。\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"old_text の出現箇所を new_text に置き換えます。\\nオプションでインデックス番号 (1 から始まる) を指定して、old_text の特定の出現箇所のみを置き換えることができます。インデックス番号を指定しないと、old_text のすべての出現箇所が置き換えられます。\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"文字列を小文字にします。\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"文字列を大文字にします。\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"文字列を指定された回数繰り返します。\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"文字列の先頭と末尾の空白を削除します。\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"文字列の先頭から howMany 文字を抽出します。\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"引数がテキストの場合はそれを返し、それ以外の場合は空白を返します。\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"URL または URI の構築に使用するために、特定の文字をエンコードされた同等の文字に置き換えます。次の文字はエンコードされません: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"論理引数が true の場合は 値1 を返し、そうでない場合は 値2 を返します。ネストされた IFステートメントを作成するためにも使用できます。\\nセルが空白かどうかを確認するためにも使用できます。\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"式と、その式に対して取り得る値のリスト、そしてそれぞれの値に対して、その式が取るべき値を取ります。また、式の入力が定義されたパターンのいずれにもマッチしない場合は、デフォルト値を取ることもできます。多くの場合、入れ子になった IF() 式の代わりに SWITCH() を使用できます。\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"すべての引数が true の場合は true を返し、それ以外の場合は false を返します。\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"引数のいずれかが true の場合、true を返します。\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"真である引数の数が奇数の場合、true を返します。\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"引数の論理値を反転します。\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"空白の値を返します。\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"エラー値を返します。\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"式がエラーを引き起こした場合は true を返します。\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"現在の日付を返します。\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"現在の日付と時刻を返します。\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"日付時刻の4桁の年を返します。\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"日付時刻の月を 1（1月）から 12（12月）までの数値として返します。\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"年内の何週目かを返します。\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"0 から 6 までの整数として曜日を返します。オプションで 2 番目の引数 (\\\"Sunday\\\" または \\\"Monday\\\") を指定して、その曜日から週を開始することもできます。省略した場合、週はデフォルトで日曜日から始まります。例:\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"日付時刻の月の日を 1～31 の数値形式で返します。\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"日付時刻の時間を 0（午前00:00）から 23（午後11:00）までの数値として返します。\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"日付時刻の分を 0 から 59 までの整数として返します。\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"日付時刻の秒を 0 から 59 までの整数として返します。\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"現在の日付と別の日付の間の日数を計算します。\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"現在の日付と別の日付の間の日数を計算します。\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"指定された単位で日付時刻の差を返します。デフォルトの単位は \\\"day\\\" です。\\nサポートされている単位: \\\"millisecond\\\" (ms)、\\\"second\\\" (s)、\\\"minute\\\" (m)、\\\"hour\\\" (h)、\\\"day\\\" (d)、\\\"week\\\" (w)、\\\"month\\\" (M)、\\\"year\\\" (y)。\\n日付時刻の差は、[date1] から [date2] を減算して決定されます。つまり、[date2] が [date1] より後の場合、結果の値は負になります。\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"指定された休日を除いて、開始日の就業日を返します。\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"日付1 と 日付2 の間の営業日数を返します。営業日には週末とオプションの休日リストは含まれず、ISO形式の日付のコンマ区切りの文字列としてフォーマットされます。\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"2つの日付を単位まで比較し、同一かどうかを判断します。同一である場合は true を返し、同一でない場合は false を返します。\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"日付1 が 日付2 より後かどうかを判断します。後の場合は true を返し、後の場合は false を返します。\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"日付1 が 日付2 より前かどうかを判断します。前であれば true を返し、前でない場合は false を返します。\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"指定された \\\"count\\\" 単位を日時に追加します。\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"日時を文字列 (YYYY-MM-DD) にフォーマットします。\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"日時を時間のみの文字列 (HH:mm:ss) にフォーマットします。\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"日時を指定された文字列にフォーマットします。日付フィールドでこの関数を使用する方法については、ここをクリックしてください。サポートされている書式指定子のリストについては、ここをクリックしてください。\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"オプションの入力形式とロケールパラメータを使用して、テキスト文字列を構造化された日付として解釈します。出力形式は常に \\\"M/D/YYYY h:mm a\\\" にフォーマットされます。\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"現在のレコードの作成時刻を返します。\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"テーブル内の非計算フィールドでユーザーが最後に変更した日時を返します。\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"テキストと空白を含むすべての要素の数を返します。\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"空でない値の数を返します。この関数は数値とテキスト値の両方をカウントします。\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"数値の数を返します。\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"ロールアップ項目の配列をセパレーターを使用して文字列に結合します。\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"配列内の一意の項目のみを返します。\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"配列のネストを削除して配列をフラット化します。すべての項目が単一の配列の要素になります。\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"配列から空の文字列と null値を削除します。\\\"false\\\" と 1つ以上の空白文字を含む文字列は保持されます。\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"すべてのテキスト値を返します\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"現在のレコードの ID を返します。\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"各レコードの一意の増分番号を返します。\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"変数、演算文字、関数を入力して計算式を作成します。\",\n      \"example\": \"Quoting the Column: {Field name}\\n\\nUsing operator: 100 * 2 + 300\\n\\nUsing function: SUM({Number Field 1}, 100)\\n\\nUsing IF statement: \\nIF(logical condition, \\\"value 1\\\", \\\"value 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"フィールド\",\n    \"numeric\": \"数値\",\n    \"text\": \"テキスト\",\n    \"logical\": \"論理的\",\n    \"date\": \"日付\",\n    \"array\": \"配列\",\n    \"system\": \"システム\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"なし\",\n    \"count\": \"計数\",\n    \"empty\": \"空\",\n    \"filled\": \"埋められた\",\n    \"unique\": \"固有\",\n    \"max\": \"最大\",\n    \"min\": \"最小\",\n    \"sum\": \"合計\",\n    \"average\": \"平均\",\n    \"checked\": \"チェックされた\",\n    \"unChecked\": \"チェックされていない\",\n    \"percentEmpty\": \"空の割合\",\n    \"percentFilled\": \"埋められた割合\",\n    \"percentUnique\": \"固有の割合\",\n    \"percentChecked\": \"チェックされた割合\",\n    \"percentUnChecked\": \"チェックされていない割合\",\n    \"earliestDate\": \"最も早い日付\",\n    \"latestDate\": \"最終日\",\n    \"dateRangeOfDays\": \"日付範囲（日数）\",\n    \"dateRangeOfMonths\": \"日付範囲（月）\",\n    \"totalAttachmentSize\": \"添付ファイルの合計サイズ\"\n  },\n  \"baseQuery\": {\n    \"add\": \"追加\",\n    \"error\": {\n      \"invalidCol\": \"無効な列です。再度選択してください\",\n      \"invalidCols\": \"無効な列: {{colNames}}\",\n      \"invalidTable\": \"無効なテーブルです。再度選択してください\",\n      \"requiredSelect\": \"いずれかを選択してください\"\n    },\n    \"from\": {\n      \"title\": \"From\",\n      \"fromTable\": \"テーブルを選択\",\n      \"fromQuery\": \"クエリから\"\n    },\n    \"select\": {\n      \"title\": \"選択\"\n    },\n    \"where\": {\n      \"title\": \"場所\"\n    },\n    \"groupBy\": {\n      \"title\": \"グループ化\"\n    },\n    \"orderBy\": {\n      \"title\": \"並び替え\",\n      \"asc\": \"昇順\",\n      \"desc\": \"降順\"\n    },\n    \"limit\": {\n      \"title\": \"リミット\"\n    },\n    \"offset\": {\n      \"title\": \"オフセット\"\n    },\n    \"join\": {\n      \"title\": \"結合\",\n      \"joinType\": \"結合タイプ\",\n      \"leftJoin\": \"左結合\",\n      \"rightJoin\": \"右結合\",\n      \"innerJoin\": \"内部結合\",\n      \"fullJoin\": \"全て結合\",\n      \"data\": \"From\"\n    },\n    \"aggregation\": {\n      \"title\": \"集約\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"コメント\",\n    \"placeholder\": \"コメントを残す...\",\n    \"emptyComment\": \"会話を始める\",\n    \"deletedComment\": \"削除されたコメント\",\n    \"imageSizeLimit\": \"画像サイズは{{size}}を超えることはできません\",\n    \"tip\": {\n      \"editing\": \"編集中...\",\n      \"edited\": \"(編集済み)\",\n      \"notifyAll\": \"すべてのコメントを通知\",\n      \"notifyRelatedToMe\": \"関連するコメントを通知\",\n      \"all\": \"すべて\",\n      \"relatedToMe\": \"関連するコメント\",\n      \"reactionUserSuffix\": \"{{emoji}}の絵文字でリアクションしました\",\n      \"me\": \"あなた\",\n      \"connection\": \"と\"\n    },\n    \"toolbar\": {\n      \"link\": \"リンク\",\n      \"image\": \"画像\",\n      \"mention\": \"メンション\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"リンクを編集\",\n      \"caption\": \"キャプション\",\n      \"delete\": \"削除\",\n      \"linkText\": \"リンクテキスト\",\n      \"enterUrl\": \"URLを入力\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select Members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"バリデーションエラー\",\n    \"invalidCaptcha\": \"キャプチャが無効です\",\n    \"invalidCredentials\": \"無効な資格情報\",\n    \"unauthorized\": \"承認されていません\",\n    \"unauthorizedShare\": \"共有が承認されていません\",\n    \"paymentRequired\": \"支払いが必要です\",\n    \"creditLimitExceeded\": \"算力上限を超えました\",\n    \"restrictedResource\": \"制限されたリソース\",\n    \"notFound\": \"見つかりません\",\n    \"conflict\": \"競合\",\n    \"unprocessableEntity\": \"処理不可\",\n    \"userLimitExceeded\": \"ユーザーの制限超過\",\n    \"tooManyRequests\": \"要求が多すぎます\",\n    \"internalServerError\": \"内部サーバーエラー\",\n    \"databaseConnectionUnavailable\": \"データベース接続不可\",\n    \"gatewayTimeout\": \"ゲートウェイタイムアウト\",\n    \"unknownErrorCode\": \"不明なエラーコード\",\n    \"networkError\": \"ネットワーク接続の問題\",\n    \"requestTimeout\": \"要求タイムアウト\",\n    \"failedDependency\": \"依存操作失敗\",\n    \"automationNodeParseError\": \"自動化ノード解析エラー\",\n    \"automationNodeNeedTest\": \"自動化ノードはテストが必要です\",\n    \"automationNodeTestOutdated\": \"自動化ノードのテストが古くなっています\",\n    \"invalidToken\": \"無効なトークン\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" フィールド \\\"{{fieldName}}\\\" は空の値を許可しません。送信する前に完全に入力してください。\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" フィールド \\\"{{fieldName}}\\\" は重複する値を許可しません。送信する前に一意の値を入力してください。\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" フィールドは同じレコードに複数の関連付けを許可しません。\",\n      \"requestTimeout\": \"操作範囲が大きすぎます。範囲を縮小して再試行してください。\",\n      \"searchTimeOut\": \"検索が期限切れ、検索範囲を狭めて再試行してください。\",\n      \"dependencyNodeRequire\": \"依存ノードがテストされていません。前のノードをテストしてください。\",\n      \"invalidOperation\": \"無効な操作が検出されました。操作パラメータを確認してください\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"リクエストされたコメント数が最大制限の 1000 を超えています\",\n      \"invalidContentType\": \"無効なコメントコンテンツタイプ\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"トークンの有効期限は7日未満である必要があります\",\n      \"s3RegionRequired\": \"S3リージョンが必要です\",\n      \"s3EndpointRequired\": \"S3エンドポイントが必要です\",\n      \"s3AccessKeyRequired\": \"S3アクセスキーが必要です\",\n      \"s3SecretKeyRequired\": \"S3シークレットキーが必要です\",\n      \"s3UploadMethodMustBePut\": \"S3アップロード方法はPUTである必要があります\",\n      \"presignedError\": \"事前署名URLの生成に失敗しました\",\n      \"invalidObjectMeta\": \"無効なオブジェクトメタデータ\",\n      \"invalidImageStream\": \"無効な画像ストリーム\",\n      \"calculateImageSizeFailed\": \"画像サイズの計算に失敗しました\",\n      \"uploadFailed\": \"アップロードに失敗しました\",\n      \"invalidImage\": \"無効な画像\",\n      \"cantGetImageStream\": \"画像ストリームを取得できません\",\n      \"invalidProvider\": \"無効なストレージプロバイダー\",\n      \"failedToDeleteDirectory\": \"ディレクトリの削除に失敗しました\",\n      \"invalidToken\": \"無効なトークン\",\n      \"tokenExpired\": \"トークンの有効期限が切れています\",\n      \"sizeMismatch\": \"ファイルサイズが一致しません\",\n      \"notAllowUploadFileType\": \"{{mimetype}} ファイルタイプのアップロードは許可されていません\",\n      \"notFound\": \"添付ファイルが見つかりません\",\n      \"invalidPath\": \"無効な添付ファイルパス\",\n      \"fileSizeExceedsMaximumLimit\": \"ファイルサイズが最大制限 {{maxSize}} を超えています\",\n      \"invalidUploadType\": \"無効なアップロードタイプ\",\n      \"urlReject\": \"URLが拒否されたか、アクセスできません\"\n    },\n    \"email\": {\n      \"testEmailError\": \"メール設定エラー {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"このボタンフィールドはすでに {{name}}[{{id}}] この自動化プロセスにバインドされています。別のボタンフィールドを選択してください\",\n      \"triggerNotFound\": \"自動化にはトリガーノードが必要です\",\n      \"nodeNotFound\": \"{{nodeId}} ノードが見つかりません\",\n      \"triggerTestFailed\": \"トリガーテストが失敗しました。トリガーの設定を確認してください\",\n      \"testFailed\": \"自動化テストが失敗しました。自動化設定を確認してください\",\n      \"runFailed\": \"自動化の実行に失敗しました\",\n      \"nodeParseError\": \"{{name}} ノード設定が不完全またはエラーがあります。ノード設定を確認してください\",\n      \"nodeNeedTest\": \"{{name}} ノードはテストが必要です\",\n      \"nodeTestOutdated\": \"{{name}} ノードのテストは古くなっています\",\n      \"notFound\": \"自動化が見つかりません\",\n      \"currentSnapshotEmpty\": \"自動化の現在のスナップショットが空です\",\n      \"runNotFound\": \"自動化の実行が見つかりません\",\n      \"anchorNotFound\": \"アンカー自動化が見つかりません\",\n      \"validationError\": \"自動化設定の検証エラー\",\n      \"tableNotInBase\": \"あなたのデータベース内のテーブルのみを購読できます\",\n      \"alreadyActiveAndNotDraft\": \"自動化は既にアクティブでドラフトではありません\",\n      \"noActiveSnapshot\": \"自動化にアクティブなスナップショットがありません\",\n      \"triggerNodeAlreadyExists\": \"この自動化には既にトリガーノードがあります\",\n      \"generateLogicError\": \"ロジックノードの生成エラー\",\n      \"logicNotFound\": \"自動化ロジックノードが見つかりません\",\n      \"actionNotFound\": \"自動化アクションノードが見つかりません\",\n      \"unSupportDuplicateWorkflowNodeType\": \"この自動化ノードタイプの複製はサポートされていません\",\n      \"unSupportLogicType\": \"サポートされていないロジックタイプ\",\n      \"groupEndNotFound\": \"ロジックのGroupEndが見つかりません\",\n      \"insertNodeError\": \"ノード挿入エラー\",\n      \"controlNodeNotBeTested\": \"制御ノードはテストすべきではありません\",\n      \"invalidNodeType\": \"無効なノードタイプ\",\n      \"unsupportedCategory\": \"サポートされていないカテゴリ\",\n      \"unknownConnectionType\": \"不明なメール接続タイプ\",\n      \"imapPasswordNotConfigured\": \"IMAPパスワードが設定されていません\",\n      \"integrationNotFound\": \"インテグレーションが見つからないか、資格情報がありません\",\n      \"webhookTriggerNotFound\": \"Webhookトリガーが見つかりません\",\n      \"emailReceivedTriggerNotFound\": \"メール受信トリガーが見つかりません\",\n      \"emailConnectorNotAvailable\": \"メールコネクタサービスが利用できません\",\n      \"listMailboxesFailed\": \"メールボックス一覧の取得に失敗しました: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"不明なデータセット：{{datasetId}}\",\n      \"apiKeyNotConfigured\": \"スクレイプサービスが設定されていません\",\n      \"triggerFailed\": \"スクレイプトリガーに失敗しました：{{detail}}\",\n      \"snapshotError\": \"スクレイプスナップショットエラー：{{detail}}\",\n      \"timeout\": \"スクレイプが{{seconds}}秒後にタイムアウトしました\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"OAuthコードの交換に失敗しました：{{detail}}\",\n      \"oauthTokenRefreshFailed\": \"OAuthトークンの更新に失敗しました：{{detail}}\",\n      \"userInfoFetchFailed\": \"ユーザー情報の取得に失敗しました：{{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"スペースが見つかりません\",\n      \"noPermission\": \"このスペースにアクセスする権限がありません\",\n      \"disallowSpaceCreation\": \"管理者によってスペースの作成が無効化されています\",\n      \"cannotChangeOnlyOwnerRole\": \"スペースの唯一の所有者のロールを変更できません\",\n      \"cannotDeleteOnlyOwner\": \"スペースの唯一の所有者を削除できません\",\n      \"deleted\": \"スペースが削除されました\",\n      \"cannotOperate\": \"スペースで操作できません、スペースに組織メンバーがいて所有者であることを確認してください\",\n      \"notBelongToOrg\": \"このスペースは組織に属していません\",\n      \"invalidSpaceIds\": \"スペースIDが無効です: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"ベース が見つかりません\",\n      \"cannotAccess\": \"ベース {{baseId}} にアクセスする権限がありません\",\n      \"anchorNotFound\": \"アンカーベース {{anchorId}} が見つかりません\",\n      \"baseAndSpaceMismatch\": \"ベース {{baseId}} とスペース {{spaceId}} が一致しません\",\n      \"templateNotFound\": \"テンプレート {{templateId}} が見つかりません\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"ベースIDが必要です\",\n      \"nodeIdIsRequired\": \"ノードIDが必要です\",\n      \"invalidResourceType\": \"無効なリソースタイプ\",\n      \"notFound\": \"ベースノードが見つかりません\",\n      \"parentMustBeFolder\": \"親はフォルダーである必要があります\",\n      \"cannotDuplicateFolder\": \"フォルダーを複製できません\",\n      \"cannotDeleteEmptyFolder\": \"フォルダーが空ではないため削除できません\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"parentId または anchorId のいずれか一方のみを指定してください\",\n      \"cannotMoveToItself\": \"ノードを自分自身に移動できません\",\n      \"cannotMoveToCircularReference\": \"ノードを自身の子ノードに移動できません（循環参照）\",\n      \"anchorIdOrParentIdRequired\": \"parentId または anchorId のいずれかを指定する必要があります\",\n      \"parentNotFound\": \"親ノードが見つかりません\",\n      \"parentIsNotFolder\": \"親がフォルダーではありません\",\n      \"circularReference\": \"循環参照が検出されました\",\n      \"folderDepthLimitExceeded\": \"フォルダーの深さ制限を超えました\",\n      \"folderNotFound\": \"フォルダーが見つかりません\",\n      \"anchorNotFound\": \"アンカーノードが見つかりません\",\n      \"nameAlreadyExists\": \"名前がすでに存在します\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"ダッシュボードが見つかりません\"\n    },\n    \"plugin\": {\n      \"notFound\": \"プラグインが見つかりません\",\n      \"notSupportInstallInView\": \"プラグインはビューへのインストールをサポートしていません\",\n      \"userNotFound\": \"プラグインユーザーが見つかりません\",\n      \"invalidSecret\": \"無効なシークレット\",\n      \"invalidRefreshToken\": \"無効なリフレッシュトークン\",\n      \"anomalousToken\": \"異常なトークン\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"プラグインパネルが見つかりません\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"プラグインがインストールされていません\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"パスワードが正しくありません\",\n      \"notAllowedToSubmit\": \"フォームの送信は許可されていません\",\n      \"viewRequired\": \"この操作にはビューが必要です\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"非表示フィールドが含まれている場合、フォームの送信は許可されません\",\n      \"submitRecordsError\": \"レコードの送信に失敗しました\",\n      \"notAllowedToCopy\": \"コピー操作は許可されていません\",\n      \"fieldHiddenNotAllowed\": \"フィールドは非表示でアクセスできません\",\n      \"fieldTypeNotLinkField\": \"フィールドはリンクフィールドではありません\",\n      \"fieldIdRequired\": \"フィールドIDが必要です\",\n      \"fieldNotUserRelatedField\": \"フィールドはユーザー関連フィールドではありません\",\n      \"viewTypeNotAllowed\": \"このビュータイプはこの操作では許可されていません\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"この共有ビューではパスワード制限が有効になっていません\",\n      \"shareViewNotFound\": \"共有ビューが見つからないか、共有が無効になっています\",\n      \"linkFieldNotFound\": \"リンクフィールドが見つかりません\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"ベース共有が見つからないか、共有が無効になっています\",\n      \"alreadyExists\": \"このノードには既に共有が存在します\",\n      \"copyNotAllowed\": \"この共有ではコピーが許可されていません\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"このビューにアクセスする権限がありません\",\n      \"fieldPermissionNotAllowed\": \"これらのフィールドにアクセスする権限がありません\",\n      \"recordPermissionNotAllowed\": \"これらのレコードにアクセスする権限がありません\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"プラグインコンテキストメニューが見つかりません\",\n      \"anchorNotFound\": \"プラグインコンテキストメニューのアンカーが見つかりません\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"プラグインチャートのクエリが見つかりません\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"サポートされていないデータベースドライバー: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"ベース {{baseId}} のデータベース接続を削除できるのはベース所有者のみです\",\n      \"onlyOwnerCanCreate\": \"ベース {{baseId}} のデータベース接続を作成できるのはベース所有者のみです\",\n      \"roleNotExist\": \"データベースロール {{role}} は存在しません\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"クエリが失敗しました: {{message}}\",\n      \"invalidJoinType\": \"無効な結合タイプ: {{joinType}}\",\n      \"tableNotFound\": \"ベース {{baseId}} にテーブル {{tableId}} が見つかりません\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"キーワード {{keyword}} を含むSQLの実行は許可されていません\",\n      \"whiteListCheckError\": \"テーブルアクセスの確認中にエラーが発生しました: {{message}}\",\n      \"databaseConnectionFailed\": \"データベース接続に失敗しました: {{message}}\",\n      \"executeQuerySqlFailed\": \"クエリSQLの実行に失敗しました: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"フィールド({{fields}})を含むレコードを作成する権限がありません\",\n      \"deleteRecords\": \"レコード({{recordIds}})を削除する権限がありません\",\n      \"readRecordWithDeniedFields\": \"レコード({{recordId}})のフィールド({{fields}})を読み取る権限がありません\",\n      \"updateRecordWithDeniedFields\": \"レコード({{recordId}})のフィールド({{fields}})を更新する権限がありません\",\n      \"checkIdNotExist\": \"権限チェックIDが存在しません\",\n      \"userNotAdmin\": \"ユーザーは管理者ではありません\",\n      \"accessTokenNoPermission\": \"アクセストークンには必要な権限がありません\",\n      \"invalidResource\": \"リソースが無効です\",\n      \"notAllowedSpace\": \"このスペースにアクセスする権限がありません\",\n      \"notAllowedBase\": \"このベースにアクセスする権限がありません\",\n      \"notAllowedTables\": \"このテーブル({{tableIds}})にアクセスする権限がありません\",\n      \"notAllowedOperationTable\": \"このテーブルを操作する権限がありません\",\n      \"notAllowedOperationRecord\": \"このレコードを操作する権限がありません\",\n      \"notAllowedRecordUpdate\": \"このレコードを更新する権限がありません\",\n      \"notAllowedOperationView\": \"このビューを操作する権限がありません\",\n      \"deniedByEnabledAuthorityMatrix\": \"有効な権限マトリックスにより拒否されました\",\n      \"invalidRequestPath\": \"リクエストパスが無効です\",\n      \"notAllowedOperation\": \"この操作を実行する権限がありません\",\n      \"notAllowedDepartment\": \"この部門にアクセスする権限がありません\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"デフォルトのロールが見つかりません\",\n      \"alreadyDisabled\": \"権限マトリックスは既に無効になっています\",\n      \"alreadyEnabled\": \"権限マトリックスは既に有効になっています\",\n      \"notFound\": \"権限マトリックスが見つかりません\",\n      \"primaryFieldCannotBeDisabledForRead\": \"主フィールドは読み取りアクセスを無効にできません\",\n      \"fieldDuplicated\": \"権限設定でフィールドが重複しています\",\n      \"cannotSetRecordPermissionGroup\": \"このレコード権限の組み合わせ({{actions}})を設定できません\",\n      \"notFoundBaseAndTable\": \"ベースIDとテーブルIDが見つかりません\",\n      \"roleTablesShouldNotBeEmpty\": \"権限マトリックスのロールテーブルは空にできません\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"無効な戻り値の型\",\n      \"exceedMaxReadRows\": \"最大読み取り行数の制限を超えました\",\n      \"invalidCellValueType\": \"無効なセル値の型\",\n      \"exceedMaxCopyCells\": \"最大コピーセル数の制限を超えました\",\n      \"exceedMaxPasteCells\": \"最大貼り付けセル数の制限を超えました\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"サポートされていないフィールドタイプ {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"プライマリフィールドとしてサポートされていないフィールドタイプ {{type}}\",\n      \"primaryFieldNotSupported\": \"フィールドタイプはプライマリフィールドとしてサポートされていません\",\n      \"calculateRecordNotFound\": \"レコードが見つかりません: {{value}}、fieldId: {{fieldId}}、{{recordId}} の計算時\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"通常の計算フィールドには toRecordIds または fromRecordIds が必要です\",\n      \"recordFieldsRequired\": \"レコードフィールドが未定義です\",\n      \"uniqueUnsupportedType\": \"フィールド {{name}}[{{fieldId}}] はフィールド値の一意性検証をサポートしていません\",\n      \"notNullValidationWhenCreateField\": \"フィールド {{name}}[{{fieldId}}] は新しいフィールドの作成時に非NULL検証をサポートしていません\",\n      \"dbFieldNameAlreadyExists\": \"データベースフィールド名 {{dbFieldName}} は既に存在します\",\n      \"fieldValidationError\": \"フィールド {{name}}[{{fieldId}}] フィールド検証エラー\",\n      \"fieldNameAlreadyExists\": \"フィールド名 {{name}} は既に存在します\",\n      \"notFound\": \"フィールド が見つかりません\",\n      \"fieldKeyTypeNotFound\": \"フィールド \\\"{{fieldKeyType}}: {{missedFields}}\\\" が見つかりません\",\n      \"notFoundInTable\": \"フィールド {{fieldId}} がテーブル {{tableId}} に見つかりません\",\n      \"deleteFieldsNotFound\": \"削除するフィールド {{fieldIds}} がテーブル {{tableId}} に見つかりません\",\n      \"lookupValuesShouldBeArray\": \"リンクフィールドが複数のセル値を持つ場合、lookupValues は配列である必要があります\",\n      \"linkCellValuesShouldBeArray\": \"リンクフィールドが複数のセル値を持つ場合、linkCellValues は配列である必要があります\",\n      \"lookupAndLinkLengthMatch\": \"lookupValues の長さは linkCellValues の長さと同じである必要があります\",\n      \"cycleDetected\": \"循環が検出されました\",\n      \"cycleDetectedCreateField\": \"循環が検出されました、フィールド {{name}}[{{id}}] を作成できません\",\n      \"recordMapNotFound\": \"テーブル {{tableName}} のフィールド {{fieldName}} のレコードが見つかりません\",\n      \"forbidDeletePrimaryField\": \"プライマリフィールドの削除は禁止されています\",\n      \"foreignTableIdInvalid\": \"外部テーブル {{foreignTableId}} が無効です\",\n      \"relationshipInvalid\": \"リレーションシップ {{relationship}} が無効です\",\n      \"linkFieldIdInvalid\": \"リンクフィールド {{linkFieldId}} が無効です\",\n      \"lookupFieldIdInvalid\": \"ルックアップフィールド {{lookupFieldId}} が無効です\",\n      \"formulaExpressionParseError\": \"数式の式の解析エラー\",\n      \"formulaReferenceNotFound\": \"数式参照フィールド {{fieldIds}} が見つかりません\",\n      \"rollupExpressionParseError\": \"ロールアップ式の解析エラー\",\n      \"choiceNameAlreadyExists\": \"選択肢名 {{name}} は既に存在します\",\n      \"symmetricFieldIdRequired\": \"対称フィールドIDが必要です\",\n      \"foreignKeyNameCannotUseId\": \"外部キー名に __id は使用できません\",\n      \"createForeignKeyError\": \"外部キーの作成エラー\",\n      \"lookupFieldTypeNotEqual\": \"現在のフィールドタイプ {{fieldType}} はルックアップフィールドタイプ {{lookupFieldType}} と等しくありません\",\n      \"recordNotFound\": \"レコード {{recordId}} が {{tableId}} に見つかりません\",\n      \"linkCellRecordIdAlreadyExists\": \"同じセルに重複した recordId を設定できません: {{recordId}}\",\n      \"oneOneLinkCellValueCannotBeArray\": \"1対1リンクフィールドの値は配列にできません\",\n      \"manyOneLinkCellValueCannotBeArray\": \"多対1リンクフィールドの値は配列にできません\",\n      \"foreignKeyDuplicate\": \"外部キーが重複しています\",\n      \"linkConsistencyError\": \"整合性エラー、recordId {{recordId}} が存在しません\",\n      \"oneManyLinkCellValueShouldBeArray\": \"1対多リンクフィールドの値は配列である必要があります\",\n      \"manyManyLinkCellValueShouldBeArray\": \"多対多リンクフィールドの値は配列である必要があります\",\n      \"onlyLinkFieldCanBeFiltered\": \"リンクフィールドのみフィルタリングに使用できます\",\n      \"notLinkedToCurrentTable\": \"フィールドは現在のテーブルにリンクされていません\",\n      \"notAttachment\": \"フィールドは添付ファイルフィールドではありません\",\n      \"isComputed\": \"フィールドは計算されており、変更できません\",\n      \"notFoundAICofig\": \"フィールドにAI設定がありません\",\n      \"foreignTableIdRequired\": \"外部テーブルは必須です\",\n      \"lookupFieldIdRequired\": \"参照フィールドは必須です\",\n      \"lookupFieldNotExist\": \"参照フィールド {{lookupFieldId}} が存在しません\",\n      \"lookupFieldNotBelongToTable\": \"参照フィールド {{lookupFieldId}} はテーブル {{foreignTableId}} に属していません\",\n      \"lookupFieldTypeNotMatch\": \"現在のフィールドタイプ {{fieldType}} は参照フィールドタイプ {{lookupFieldType}} と一致しません\",\n      \"conditionalRollupOptionsRequired\": \"条件付き集約フィールドのオプションは必須です\",\n      \"conditionalRollupParseError\": \"条件付き集約の解析エラー：{{message}}\",\n      \"conditionalLookupOptionsRequired\": \"条件付き検索フィールドのオプションは必須です\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"ボタンのクリック数が最大制限に達しました\",\n        \"notSupportReset\": \"ボタンはリセットをサポートしていません\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"ビューが見つかりません\",\n      \"defaultViewNotFound\": \"デフォルトビューが見つかりません\",\n      \"propertyParseError\": \"ビュープロパティの解析に失敗しました\",\n      \"primaryFieldCannotBeHidden\": \"プライマリフィールドを非表示にできません\",\n      \"filterUnsupportedFieldType\": \"フィルターでサポートされていないフィールドタイプ\",\n      \"sortUnsupportedFieldType\": \"ソートでサポートされていないフィールドタイプ\",\n      \"groupUnsupportedFieldType\": \"グループ化でサポートされていないフィールドタイプ\",\n      \"anchorNotFound\": \"アンカービューが見つかりません\",\n      \"notEnoughGapToShuffleRow\": \"行をシャッフルするための十分なギャップがありません\",\n      \"shareNotEnabled\": \"ビュー共有が有効になっていません\",\n      \"shareAlreadyEnabled\": \"ビュー共有はすでに有効になっています\",\n      \"shareAlreadyDisabled\": \"ビュー共有はすでに無効になっています\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"クレジット不足\",\n      \"exceedMaxRowLimit\": \"最大行数制限 {{maxRowCount}} を超えました\",\n      \"exceedMaxAutomationRunLimit\": \"自動化の月間最大実行回数に達しました\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"検索クエリが必要です\",\n      \"maxSearchIndexResult\": \"最大検索インデックス結果は 1000 です\",\n      \"queryCollectionMustBeTableId\": \"クエリコレクションはテーブル ID でなければなりません\",\n      \"searchTimeOut\": \"検索が期限切れ、検索範囲を狭めて再試行してください。\",\n      \"indexNotFound\": \"インデックスが見つかりません\",\n      \"invalidStartDateFieldId\": \"無効な開始日フィールド ID\",\n      \"invalidEndDateFieldId\": \"無効な終了日フィールド ID\",\n      \"fieldMapRequired\": \"検索が設定されている場合、フィールドマップが必要です\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelectedとfilterLinkCellCandidateは同時に設定できません\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"AI チャットモデル lg が設定されていません\",\n      \"chatModelLgProviderNotSet\": \"AI チャットモデル lg プロバイダーが設定されていません\",\n      \"chatModelSmNotSet\": \"AI チャットモデル sm が設定されていません\",\n      \"chatModelMdNotSet\": \"AI チャットモデル md が設定されていません\",\n      \"configurationNotSet\": \"AI 設定が設定されていません\",\n      \"unsupportedProvider\": \"サポートされていない AI プロバイダー {{type}}\",\n      \"providerConfigurationNotSet\": \"AI プロバイダー設定が設定されていません\",\n      \"testLLMFailed\": \"LLM 接続テストに失敗しました\",\n      \"audioNotSupported\": \"このモデル {{model}} はオーディオ入力をサポートしていません\",\n      \"imageNotSupported\": \"このモデル {{model}} は画像入力をサポートしていません\",\n      \"modelNotSet\": \"AIモデルが設定されていません\",\n      \"unsupportedFileType\": \"サポートされていないファイルタイプ {{mimetype}}\",\n      \"unsupportedModelType\": \"サポートされていないモデルタイプ\",\n      \"embeddingModelNotSet\": \"埋め込みモデルが設定されていません\",\n      \"validateActionFailed\": \"フィールドAIアクションの検証に失敗しました\",\n      \"generateFailed\": \"AI生成に失敗しました\",\n      \"unsupportedActionType\": \"サポートされていないAIアクションタイプです\"\n    },\n    \"role\": {\n      \"notFound\": \"ロールが見つかりません\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"コラボレーターは既に存在しています\",\n      \"notFound\": \"コラボレーターが見つかりません\",\n      \"userNotFoundInCollaborator\": \"コラボレーターにユーザーが見つかりません\",\n      \"noPermissionToDelete\": \"このコラボレーターを削除する権限がありません\",\n      \"noPermissionToUpdate\": \"このコラボレーターを更新する権限がありません\",\n      \"noPermissionToOperateRole\": \"この役割を操作する権限がありません\",\n      \"alreadyExistedInBase\": \"コラボレーターは既にデータベースに存在しています\",\n      \"userNotFound\": \"ユーザーが見つかりません：{{userIds}}\",\n      \"baseNotFound\": \"データベースが見つかりません\",\n      \"noPermissionToAddRole\": \"この役割のコラボレーターを追加する権限がありません\",\n      \"departmentNotFound\": \"部署が見つかりません\"\n    },\n    \"table\": {\n      \"notFound\": \"テーブルが見つかりません\",\n      \"dbTableNameAlreadyExists\": \"データベーステーブル名は既に存在します\",\n      \"anchorNotFound\": \"アンカーテーブルが見つかりません\",\n      \"notInTrash\": \"テーブルはごみ箱にありません\",\n      \"notSupportTableIndex\": \"テーブルインデックスタイプはサポートされていません\",\n      \"createTableIndexError\": \"テーブルインデックスの作成に失敗しました\",\n      \"dropTableIndexError\": \"テーブルインデックスの削除に失敗しました\",\n      \"notFoundPrimaryField\": \"テーブル内にプライマリフィールドが見つかりません\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"{{viewType}} ビュータイプはエクスポートに対応していません\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"ファイル形式がサポートされていません。{{supportType}} のみがサポートされており、ファイルのコンテンツタイプは {{fileFormat}} です\",\n      \"notSupportedFileType\": \"インポートファイルタイプがサポートされていません\",\n      \"exceedMaxFieldsLength\": \"テーブル内のフィールド数は {{maxFieldsLength}} を超えることができません。現在は {{length}} です\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"現在のインスタンスでは、管理者によるスペース招待が許可されていません\",\n      \"invalidCode\": \"無効な招待コード\",\n      \"linkNotFound\": \"招待リンクが見つかりません\",\n      \"linkExpired\": \"招待リンクの有効期限が切れています\",\n      \"limitExceeded\": \"1時間あたりの最大招待数に達しました\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"お気に入りは既に存在します\",\n      \"notFound\": \"お気に入りが見つかりません\",\n      \"anchorNotFound\": \"アンカーお気に入りが見つかりません\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"無効なリソースタイプ\",\n      \"notFound\": \"ゴミ箱のアイテムが見つかりません\",\n      \"parentSpaceTrashed\": \"親スペースもゴミ箱にあるため、このベースを復元できません\",\n      \"parentBaseOrSpaceTrashed\": \"親ベースまたはスペースもゴミ箱にあるため、このテーブルを復元できません\",\n      \"parentBaseTrashed\": \"親ベースもゴミ箱にあるため、このアイテムを復元できません\",\n      \"parentNotFound\": \"親リソースが見つかりません\",\n      \"tableNotFound\": \"テーブルのゴミ箱アイテムが見つかりません\"\n    },\n    \"license\": {\n      \"invalid\": \"ライセンスが無効です\",\n      \"instanceIdMismatch\": \"入力されたインスタンスIDが現在のインスタンスIDと一致しません\",\n      \"expired\": \"ライセンスの有効期限が切れています\",\n      \"userLimitExceeded\": \"現在のインスタンスのユーザー数がライセンスのシート制限を超えています。一部のユーザーを無効化するか、ライセンスをアップグレードしてください\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"ドメイン認証コードが見つかりません\",\n      \"invalidCode\": \"認証コードが無効です\",\n      \"resendCooldown\": \"新しいコードをリクエストする前に1分間お待ちください\"\n    },\n    \"organization\": {\n      \"notFound\": \"組織が見つかりません\",\n      \"authenticationNotFound\": \"認証が見つかりません\",\n      \"spaceShouldExist\": \"組織スペースが存在する必要があります\",\n      \"emailsNotInOrgDomain\": \"これらのメールアドレス {{emails}} は組織のドメインにありません\",\n      \"emailNotSpaceUser\": \"メールアドレスはスペースユーザーではありません\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"メール送信に失敗しました\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"現在のインスタンスでは、管理者によるサインアップが許可されていません\",\n      \"waitlistInviteCodeRequired\": \"待機リストが有効になっています、招待コードが必要です\",\n      \"waitlistInviteCodeInvalid\": \"待機リストが有効になっています、招待コードが無効です\",\n      \"systemUser\": \"ユーザーはシステムユーザーです\",\n      \"collaboratorsInSpaces\": \"ユーザーはスペース内に協力者がいます（またはゴミ箱内に削除されたスペース）\",\n      \"notFound\": \"ユーザーが見つかりません\",\n      \"cannotDeleteAdmin\": \"管理者ユーザーを削除できません\",\n      \"cannotDeactivateAdmin\": \"管理者ユーザーを無効化できません\",\n      \"cannotRemoveLastAdmin\": \"最後のアクティブな管理者ユーザーから管理者権限を削除できません\",\n      \"permanentDeleted\": \"ユーザーは完全に削除されています\",\n      \"cannotDeleteSelf\": \"自分自身を削除することはできません\",\n      \"alreadyInDepartment\": \"ユーザー{{userId}}は既に対象部門にいます\",\n      \"emailsNotFound\": \"メールアドレス{{emails}}が見つかりません\",\n      \"deleted\": \"ユーザー{{userId}}が削除されています\",\n      \"alreadyInOrg\": \"ユーザー{{userId}}は既に組織にいます\",\n      \"notInOrg\": \"ユーザー{{userId}}は組織にいません\"\n    },\n    \"record\": {\n      \"notFound\": \"レコードが見つかりません\",\n      \"deletedIdsNotFound\": \"削除するレコードの一部が見つかりません\",\n      \"updateFailed\": \"レコードの更新に失敗しました\",\n      \"noFileOrUrlProvided\": \"ファイルまたはURLが提供されていません\",\n      \"createRecordsEmpty\": \"レコードの作成を空にすることはできません\",\n      \"duplicateFailed\": \"レコードの複製に失敗しました\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"セル値の検証に失敗しました\"\n    },\n    \"workflow\": {\n      \"notActive\": \"自動化が開いていません\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"無効なリソースタイプ\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"テンプレートカテゴリが見つかりません\",\n      \"snapshotRequired\": \"スナップショットがないため、このテンプレートを公開できませんでした\",\n      \"sourceTemplateNotFound\": \"ソーステンプレートが見つかりません\",\n      \"noMinOrderFound\": \"最小順序が見つかりません\",\n      \"takeCountTooLarge\": \"リクエストされたテンプレートの数が最大制限を超えています\",\n      \"categoryLimitReached\": \"テンプレートカテゴリの上限に達しました（最大 {{maxCount}} 件）\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"親部門が見つかりません\",\n      \"notFound\": \"部門が見つかりません\",\n      \"cannotMoveToItself\": \"部門を自分自身に移動することはできません\",\n      \"cannotMoveToSub\": \"部門をその子部門に移動することはできません\"\n    },\n    \"app\": {\n      \"notFound\": \"アプリが見つかりません\",\n      \"noFilesToUpdate\": \"更新するファイルがありません\",\n      \"noChatIdFound\": \"このアプリにチャットIDが見つかりません\",\n      \"noChatFound\": \"このアプリにチャットが見つかりません\",\n      \"versionNotFound\": \"バージョンが見つかりません\",\n      \"cannotRollbackToLatestVersion\": \"最新バージョンにロールバックできません\",\n      \"noChatOrProjectTokenFound\": \"チャットまたはプロジェクトトークンが見つかりません\",\n      \"apiKeyNotSet\": \"アプリビルダーAPIキーが設定されていません\",\n      \"cannotDeployAppBeforeInitialization\": \"初期化前にアプリをデプロイできません\",\n      \"noProjectOrVersionFound\": \"プロジェクトまたはバージョンが見つかりません\",\n      \"noDeploymentUrlAvailable\": \"デプロイURLが利用できません\"\n    },\n    \"reward\": {\n      \"notFound\": \"リワードが見つかりません\",\n      \"unsupportedSourceType\": \"サポートされていないリワードソースタイプです\",\n      \"maxClaimsReached\": \"今週のリワード請求上限（2回）に達しました\",\n      \"verificationFailed\": \"検証に失敗しました: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"今週すでにこのアカウントのリワードを請求しています\",\n      \"invalidPostUrl\": \"投稿URLの形式が無効です\",\n      \"postAlreadyUsed\": \"この投稿はすでにリワードの請求に使用されています\",\n      \"unsupportedPlatformUrl\": \"サポートされていないソーシャルプラットフォームのURLです\",\n      \"unsupportedPlatform\": \"サポートされていないプラットフォーム: {{platform}}\",\n      \"minCharCount\": \"投稿は少なくとも{{count}}文字必要です\",\n      \"minFollowerCount\": \"アカウントには少なくとも{{count}}人のフォロワーが必要です\",\n      \"mustMention\": \"投稿には{{mention}}へのメンションが必要です\",\n      \"fetchTweetFailed\": \"Xツイートの取得に失敗しました: {{error}}\",\n      \"tweetNotFound\": \"Xツイートが見つかりません: {{postId}}\",\n      \"fetchUserFailed\": \"Xユーザーの取得に失敗しました: {{error}}\",\n      \"xUserNotFound\": \"Xユーザーが見つかりません: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"LinkedIn投稿の取得に失敗しました: {{error}}\",\n      \"linkedInPostNotFound\": \"LinkedIn投稿が見つかりません: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"LinkedIn投稿者が見つかりません: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"LinkedInユーザーの取得に失敗しました: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/setting.json",
    "content": "{\n  \"personalAccessToken\": \"個人アクセストークン\",\n  \"oauthApps\": \"OAuthアプリ\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"このページを表示するにはパスワードを入力してください\",\n    \"submit\": \"送信\",\n    \"password\": \"パスワード\",\n    \"passwordTooShort\": \"パスワードは3文字以上で入力してください\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"選択...\"\n  },\n  \"openOnNewPage\": \"新しいページで開く\",\n  \"errorTips\": \"ソースの共有により権限マトリックスが有効になりましたが、表示は許可されていません\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}のスペース\",\n  \"action\": {\n    \"createBase\": \"ベースの作成\",\n    \"createSpace\": \"スペースの作成\",\n    \"invite\": \"招待\"\n  },\n  \"allSpaces\": \"すべてのスペース\",\n  \"emptySpaceTitle\": \"このスペースにはベースがありません\",\n  \"spaceIsEmpty\": \"最初のベースを作成して始めましょう\",\n  \"baseModal\": {\n    \"copy\": \"コピー\",\n    \"duplicate\": \"\\\"{{baseName}}\\\" の複製\",\n    \"createBaseFromTemplate\": \"テンプレートからベースを作成する\",\n    \"duplicateRecords\": \"レコードの複製\",\n    \"duplicateRecordsTip\": \"変更履歴と共同作業者は複製しません。\",\n    \"toSpace\": \"スペースへ\",\n    \"copyToSpace\": \"スペースにコピー\",\n    \"duplicateBase\": \"ベースを複製\",\n    \"missTargetTip\": \"ベースを複製するスペースを選択してください。\",\n    \"copying\": \"ベースを複製しています。時間がかかる場合があります...\",\n    \"copyingTemplate\": \"テンプレートからベースを作成しています。時間がかかる場合があります...\",\n    \"howToCreate\": \"どのように始めますか？\",\n    \"fromScratch\": \"最初から\",\n    \"fromTemplate\": \"テンプレートから\",\n    \"moveBaseToAnotherSpace\": \"{{baseName}} を別のスペースに移動する\",\n    \"chooseSpace\": \"スペースを選択\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"スペースの設定\",\n    \"general\": \"全般\",\n    \"collaborators\": \"共同作業者\",\n    \"generalDescription\": \"現在のスペースの設定をここで変更\",\n    \"collaboratorDescription\": \"スペースの共同作業者を管理し、アクセス権限を設定します\",\n    \"spaceName\": \"スペース名\",\n    \"spaceId\": \"スペースID\"\n  },\n  \"pin\": {\n    \"add\": \"ピンに追加\",\n    \"remove\": \"ピンから削除\",\n    \"pin\": \"ピン\",\n    \"empty\": \"ピン留めしたベースとスペースがここに表示されます\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"ベースを作成する権限がありません\"\n  },\n  \"tip\": {\n    \"delete\": \"<0/> を削除してもよろしいですか?\",\n    \"title\": \"ヒント\",\n    \"exportTips1\": \"現在のベースを.teaファイルとしてエクスポートします。これには時間がかかる場合があります。エクスポート結果は通知センターで確認できます。\",\n    \"exportTips2\": \"スペース -> その他 -> インポートから.teaファイルをインポートできます\",\n    \"exportTips3\": \"ベース間の関連フィールドは単一行テキストに変換されます。\",\n    \"exportIncludeDataLabel\": \"レコードを含める\",\n    \"exportIncludeDataDescription\": \"オフにすると構造と設定のみをエクスポートします。\",\n    \"moveBaseSuccessTitle\": \"移動成功\",\n    \"moveBaseSuccessDescription\": \"{{baseName}}は{{spaceName}}に正常に移動されました\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"スペースを削除\",\n    \"blockedTitle\": \"このスペースを削除できません\",\n    \"blockedDesc\": \"このスペースにはアクティブなサブスクリプションがあります。スペースを削除する前にサブスクリプションをキャンセルしてください。\",\n    \"permanentDeleteWarning\": \"この操作により、現在のスペース内のすべてのリソースとデータが永久に削除されます。慎重に進めてください！\",\n    \"confirmInputLabel\": \"削除を確認するには DELETE と入力してください\"\n  },\n  \"sharedBase\": {\n    \"title\": \"共有ベース\",\n    \"description\": \"参加を招待されたすべてのベース\",\n    \"empty\": \"共有ベースはまだありません\"\n  },\n  \"integration\": {\n    \"title\": \"インテグレーション\",\n    \"description\": \"空間のインテグレーションを管理\",\n    \"addIntegration\": \"インテグレーションを追加\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"AI設定\",\n    \"description\": \"スペースのAI設定を管理します\",\n    \"enableTips\": \"システムAIの代わりに、スペース内でAI機能を使用するためにAIを有効にします\",\n    \"enable\": \"AI設定を初期化\",\n    \"enableSwitchTips\": \"有効にする前に、大規模コーディングモデルを設定してください\"\n  },\n  \"import\": {\n    \"importing\": \"インポート中\",\n    \"importWayTip\": \"クリックするか、ファイルをこの領域にドラッグ＆ドロップしてアップロードします\",\n    \"baseImportTips\": \"クリックするか、.teaファイルをこの領域にドラッグ＆ドロップしてアップロードします\",\n    \"confirm\": \"確認して続行\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"クリックして表示\"\n    }\n  },\n  \"template\": {\n    \"title\": \"テンプレート\",\n    \"description\": \"テンプレートから新しいBaseを素早く作成します\",\n    \"noTemplatesAvailable\": \"テンプレートがありません\",\n    \"noTemplatesDescription\": \"現在、ここには何もありません\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"最近のアクセス\"\n  },\n  \"noBases\": {\n    \"title\": \"こんにちは、{{userName}}さん！\",\n    \"description\": \"最初のBaseで仕事の管理を始めましょう。\"\n  },\n  \"noSpaces\": {\n    \"title\": \"こんにちは、{{userName}}さん！\",\n    \"description\": \"最初のスペースを作成して、データコラボレーションの旅を始めましょう。\"\n  },\n  \"baseList\": {\n    \"allBases\": \"すべてのBase\",\n    \"owner\": \"所有者\",\n    \"createdTime\": \"作成日時\",\n    \"lastOpened\": \"最終閲覧\",\n    \"enter\": \"入る\",\n    \"noTables\": \"テーブルなし\",\n    \"empty\": \"まだBaseがありません\"\n  },\n  \"publishBase\": {\n    \"title\": \"コミュニティにBaseを公開\",\n    \"description\": \"ワンクリックでBaseを公開、インスピレーションはもう孤独ではありません！あなたの創造性をより多くの人に見てもらい、利用してもらい、Remixしてもらい、共により強力なビジネスを築きましょう。\",\n    \"infoTitle\": \"基本情報\",\n    \"form\": {\n      \"title\": \"タイトル\",\n      \"description\": \"説明\",\n      \"security\": \"セキュリティ\",\n      \"includeNodes\": \"ノードを含める\",\n      \"advanced\": \"詳細設定\",\n      \"publishNode\": \"ノードを公開\",\n      \"includeData\": \"データを含める\",\n      \"defaultActiveNode\": \"デフォルトのアクティブノード\",\n      \"select\": \"選択してください\",\n      \"descriptionPlaceholder\": \"アイデアを簡単に説明してください...\",\n      \"titlePlaceholder\": \"作品に名前を付ける...\"\n    },\n    \"publishToCommunity\": \"テンプレートセンターに公開\",\n    \"publish\": \"公開\",\n    \"publishSuccess\": \"公開しました！\",\n    \"previewTips\": \"あなたの作品を世界に見せましょう\",\n    \"update\": \"更新\",\n    \"unPublish\": \"公開停止\",\n    \"unPublishSuccess\": \"Baseの公開を停止しました！\",\n    \"unPublishConfirmTitle\": \"公開停止の確認\",\n    \"unPublishConfirmDescription\": \"本当にこのBaseの公開を停止しますか？コミュニティのテンプレートセンターに表示されなくなります。\",\n    \"usageCount\": \"使用回数: \",\n    \"uploadCover\": \"クリックしてカバー画像をアップロード\",\n    \"changeCover\": \"クリックしてカバーを変更\",\n    \"uploading\": \"画像をアップロード中...\",\n    \"uploadSuccess\": \"画像が正常にアップロードされました\",\n    \"uploadFailed\": \"アップロードに失敗しました\",\n    \"invalidImageType\": \"画像ファイルを選択してください\",\n    \"tips\": {\n      \"publishValidation\": \"タイトルと説明は必須です\",\n      \"atLeastOneNode\": \"公開するノードを少なくとも1つ選択してください\"\n    },\n    \"urlCopied\": \"URLをクリップボードにコピーしました！\",\n    \"urlCopiedForDiscord\": \"URLをクリップボードにコピーしました！Discordに貼り付けることができます。\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"公式Featuredに選出されました。より多くの露出と推奨を受けられます。\",\n    \"unfeaturedTip\": \"まだ公式Featuredに選出されていません。改善を続けて、推奨される機会を得ましょう。より多くの人にあなたの作品を見てもらいましょう。\",\n    \"publishSuccessDescription\": \"あなたの作品を世界に共有しましょう\",\n    \"shareWith\": \"共有先\",\n    \"unpublishedApps\": {\n      \"title\": \"未公開のアプリが検出されました\",\n      \"description\": \"未公開のアプリはテンプレートのプレビューに失敗する可能性があります。今すぐ公開するか続行してください。\",\n      \"publishAll\": \"すべて公開\",\n      \"publish\": \"公開\",\n      \"published\": \"公開済み\",\n      \"publishing\": \"公開中...\",\n      \"publishFailed\": \"公開に失敗しました\",\n      \"publishFailedTip1\": \"ソースアプリが正常に公開できるか確認してください\",\n      \"publishFailedTip2\": \"このテンプレートを再公開してみてください\",\n      \"notPublished\": \"未公開\",\n      \"ignoreAndContinue\": \"無視して続行\",\n      \"goToFix\": \"修正へ移動\",\n      \"redeploy\": \"再デプロイ\",\n      \"unnamedApp\": \"無名のアプリ\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"近日公開\",\n    \"viewFilterInShare\": \"このビューはビュー共有リンクで使用されています。ビュー構成を変更するとビュー共有リンクも変更されます。\",\n    \"createFieldButtonText\": \"新しい<0/>フィールドを作成する\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"共有\",\n        \"statusLabel\": \"ビューをウェブに共有\",\n        \"shareLink\": \"共有リンク\",\n        \"copied\": \"コピーしました\",\n        \"genLink\": \"新しいリンクを生成する\",\n        \"allowCopy\": \"閲覧者がこのビューからデータをコピーできるようにする\",\n        \"showAllFields\": \"展開されたレコードのすべてのフィールドを表示する\",\n        \"restrict\": \"パスワードによる制限\",\n        \"tips\": \"リンクを持っている人はそのビューを見ることができます。\",\n        \"passwordTitle\": \"パスワードを入力してください\",\n        \"passwordTips\": \"共有ビューにアクセスするためのパスワード制限\",\n        \"embed\": \"埋め込み\",\n        \"embedPreview\": \"埋め込みプレビュー\",\n        \"hideToolbar\": \"ツールバーを隠す\",\n        \"URLSetting\": \"URLパラメータ設定\",\n        \"URLSettingDescription\": \"以下の設定を調整しても共有リンクには影響しません。新しいパラメータでリンクをコピーして有効にする必要があります。\",\n        \"cancel\": \"キャンセル\",\n        \"save\": \"保存\"\n      },\n      \"extensions\": {\n        \"label\": \"拡張機能\",\n        \"graph\": \"グラフ\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"データベース接続\"\n      },\n      \"personalView\": {\n        \"personal\": \"個人\",\n        \"tip\": \"有効化すると、表示設定は自分にのみ適用されます\",\n        \"collaborative\": \"協同\",\n        \"dialog\": {\n          \"title\": \"パーソナルモードを終了\",\n          \"description\": \"パーソナルビューの構成はリアルタイム共同作業の状態に復元されます。パーソナルビューの設定を保存し、全員に同期することもできます。\",\n          \"cancelText\": \"終了して同期\",\n          \"confirmText\": \"終了を確認\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"ようこそ\",\n    \"emptyTitle\": \"データベースの構築を始めましょう\",\n    \"description\": \"サイドバーの「+」ボタンをクリックしてリソースを追加\",\n    \"help\": \"詳細については、<HelpCenter /> をご覧ください\",\n    \"helpCenter\": \"ヘルプセンター\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"関連付けできません：同じバッチ内で、このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。\",\n      \"one_many_duplicate\": \"関連付けできません：このレコードはすでに別のレコードに関連付けられています。一対多の関係では、各子レコードは1つの親レコードにのみ属できます。\",\n      \"one_one_duplicate\": \"関連付けできません：対象レコードは一対一の関係ですでに別のレコードに関連付けられています。\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"テーブル「{{tableName}}」には最大 {{maxFieldCount}} 個のフィールドしか作成できません。\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"フィールド管理\",\n    \"fieldManagementDesc\": \"現在のテーブルのすべてのフィールドの詳細なプロパティ\",\n    \"advancedProps\": \"高度なプロパティ\",\n    \"hide\": \"非表示\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"ラベル\"\n      },\n      \"longText\": {\n        \"title\": \"注記\"\n      },\n      \"number\": {\n        \"title\": \"番号\",\n        \"formatType\": \"フォーマットタイプ\",\n        \"currencySymbol\": \"通貨記号\",\n        \"defaultSymbol\": \"￥\",\n        \"precision\": \"精度\",\n        \"decimalExample\": \"数値 (123)\",\n        \"currencyExample\": \"通貨 (￥100)\",\n        \"percentExample\": \"パーセント (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"ステータス\",\n        \"options\": {\n          \"todo\": \"To Do\",\n          \"inProgress\": \"進行中\",\n          \"done\": \"完了\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"タグ\"\n      },\n      \"attachment\": {\n        \"title\": \"添付ファイル\"\n      },\n      \"user\": {\n        \"title\": \"共同作業者\"\n      },\n      \"date\": {\n        \"title\": \"日付\",\n        \"dateFormatting\": \"日付の書式設定\",\n        \"timeFormatting\": \"時刻の書式設定\",\n        \"timeZone\": \"タイムゾーン\",\n        \"yearMonth\": \"年/月\",\n        \"monthDay\": \"月/日\",\n        \"year\": \"年\",\n        \"month\": \"月\",\n        \"day\": \"日\",\n        \"local\": \"地域\",\n        \"friendly\": \"友好的\",\n        \"us\": \"アメリカ合衆国S\",\n        \"european\": \"ヨーロッパ\",\n        \"asia\": \"アジア\",\n        \"custom\": \"カスタム\",\n        \"12Hour\": \"12時間\",\n        \"24Hour\": \"24時間\",\n        \"noDisplay\": \"表示なし\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"作成日時\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"最終更新日時\"\n      },\n      \"createdBy\": {\n        \"title\": \"作成者\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"最終更新者\"\n      },\n      \"rating\": {\n        \"title\": \"評価\"\n      },\n      \"checkbox\": {\n        \"title\": \"完了\"\n      },\n      \"button\": {\n        \"title\": \"ボタン\",\n        \"label\": \"ボタンラベル\",\n        \"color\": \"ボタンカラー\",\n        \"limitCount\": \"クリック数制限\",\n        \"resetCount\": \"クリック数のリセットを許可\",\n        \"maxCount\": \"最大クリック数\",\n        \"automation\": \"自動化\",\n        \"customAutomation\": \"カスタマイズされた自動化\",\n        \"clickConfirm\": \"クリック前に確認\",\n        \"confirmTitle\": \"タイトル\",\n        \"confirmDescription\": \"内容\",\n        \"confirmButtonText\": \"確認ボタンテキスト\"\n      },\n      \"formula\": {\n        \"title\": \"計算\",\n        \"formula\": \"式\"\n      },\n      \"lookup\": {\n        \"title\": \"{{linkFieldName}} から {{lookupFieldName}} を見つける\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{tableName}} から {{lookupFieldName}} をフィルター表示\"\n      },\n      \"rollup\": {\n        \"title\": \"{{linkFieldName}} の {{lookupFieldName}} の要約\",\n        \"rollup\": \"ロールアップ\",\n        \"selectAnRollupFunction\": \"ロールアップ機能を選択\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"すべての値が真の場合に真を返す\",\n          \"arrayCompact\": \"配列から空の文字列と null値 を削除します。 'false' と 1 つ以上の空白文字を含む文字列は保持されます。\",\n          \"arrayJoin\": \"すべての値を1つのコンマ区切りの文字列に結合します。\",\n          \"arrayUnique\": \"固有のアイテムのみを返します。\",\n          \"average\": \"値の平均。\",\n          \"concatenate\": \"テキスト値を1つのテキスト値に結合します。\",\n          \"count\": \"空でない数値のみをカウントします。すべてのレコードをカウントする場合は、COUNTALL を使用します。\",\n          \"countA\": \"空でない値の数をカウントします。この関数は数値とテキスト値の両方をカウントします。\",\n          \"countAll\": \"空白レコードを含むすべての値をカウントします。\",\n          \"max\": \"指定された数値の最大値を返します。\",\n          \"min\": \"指定された数値の最小値を返します。\",\n          \"or\": \"いずれかの値が真の場合、true を返します。\",\n          \"sum\": \"値を合計します。\",\n          \"xor\": \"真の値の数が奇数のと場合、 true を返します。\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} 条件付きロールアップ\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"フィールドの追加\",\n      \"editField\": \"フィールドの編集\",\n      \"insertField\": \"フィールドの挿入\",\n      \"graph\": \"グラフ\",\n      \"fieldUpdated\": \"フィールドが更新されました\",\n      \"fieldCreated\": \"フィールドが作成されました\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"本当に実行しますか？\",\n      \"addDescription\": \"説明を追加\",\n      \"dbFieldName\": \"物理フィールド名\",\n      \"description\": \"説明\",\n      \"descriptionPlaceholder\": \"このフィールドについて説明してください（オプション）\",\n      \"type\": \"タイプ\",\n      \"showAs\": \"表示形式\",\n      \"color\": \"色\",\n      \"number\": \"番号\",\n      \"chartBar\": \"チャートバー\",\n      \"chartLine\": \"チャートライン\",\n      \"ring\": \"円\",\n      \"bar\": \"棒\",\n      \"text\": \"テキスト\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"Url\",\n      \"email\": \"メール\",\n      \"phone\": \"電話\",\n      \"maxNumber\": \"最大数\",\n      \"showNumber\": \"番号を表示\",\n      \"autoFillDate\": \"現在の日付を自動入力\",\n      \"createSymmetricLink\": \"リンクテーブルにバックリンクフィールドを作成する\",\n      \"allowLinkMultipleRecords\": \"複数選択を許可する\",\n      \"allowLinkToDuplicateRecords\": \"レコードを複数回選択できます\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"レコードを複数回選択できます\",\n      \"oneToOne\": \"1 対 1\",\n      \"oneToMany\": \"1 対 複数\",\n      \"manyToOne\": \"複数 対 1\",\n      \"manyToMany\": \"複数 対 複数\",\n      \"self\": \"自分\",\n      \"selectTable\": \"テーブルを選択...\",\n      \"inSelfLink\": \"セルフリンク\",\n      \"betweenTwoTables\": \"2つのテーブルの間\",\n      \"tips\": \"ヒント\",\n      \"linkTipMessage\": \"この構成は<br></br> <b>{{relationship}}</b> 関係 <span>{{linkType}}</span> を表します\",\n      \"style\": \"スタイル\",\n      \"maximum\": \"最大\",\n      \"addOption\": \"オプションを追加\",\n      \"allowMultiUsers\": \"複数のユーザーの追加を許可する\",\n      \"notifyUsers\": \"選択されたらユーザーに通知する\",\n      \"searchTable\": \"テーブルの検索...\",\n      \"calculating\": \"計算中...\",\n      \"doSaveChanges\": \"変更内容を保存しますか？\",\n      \"linkFieldToLookup\": \"参照用リンクレコードフィールド\",\n      \"lookupToTable\": \"参照したい <bold>{{tableName}}</bold> フィールド\",\n      \"rollupToTable\": \"<bold>{{tableName}}</bold> からロールアップしたいフィールド\",\n      \"selectField\": \"フィールドを選択...\",\n      \"linkTable\": \"リンクテーブル\",\n      \"noLinkTip\": \"参照するリンクされたレコードがありません。別のレコードへのリンクフィールドを追加して、参照を再度構成してください。\",\n      \"fieldValidationRules\": \"フィールド値の検証ルール\",\n      \"enableValidateFieldUnique\": \"一意の値のみ\",\n      \"enableValidateFieldNotNull\": \"必須\",\n      \"knowMore\": \"さらに詳しく\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"フィールドでレコードを表示する\",\n      \"filterByView\": \"ビューでレコードをフィルターする\",\n      \"filter\": \"レコードをフィルターする\",\n      \"hideFields\": \"フィールドを非表示にする\",\n      \"moreOptions\": \"オプションを表示\",\n      \"allowNewOptionsWhenEditing\": \"編集時に新しいオプションを許可\",\n      \"deleteField\": {\n        \"title\": \"フィールドを削除\",\n        \"simpleConfirm\": \"フィールド <b>{{fieldName}}</b> を削除してもよろしいですか？\",\n        \"withDependencies\": \"フィールド <b>{{fieldName}}</b> を削除すると、以下のフィールドに影響します：\",\n        \"affectedFields\": \"影響を受けるフィールド：\",\n        \"fieldsToDelete\": \"削除するフィールド ({{count}})\",\n        \"unviewedHint\": \"{{count}} 個のフィールドが未確認\",\n        \"deleteCount\": \"{{count}} 個のフィールドを削除\",\n        \"noAffectedFields\": \"このフィールドは他のフィールドから参照されていません。\",\n        \"riskIdentified\": \"リスクあり({{count}})\",\n        \"noDependencies\": \"依存関係なし({{count}})\",\n        \"safeToDelete\": \"安全に削除可能\",\n        \"safeToDeleteDesc\": \"このフィールドは他のフィールドから参照されていないため、安全に削除できます\",\n        \"affectedItems\": \"影響を受ける項目\",\n        \"type\": \"タイプ\",\n        \"source\": \"ソース\",\n        \"sourceTable\": \"ソーステーブル\",\n        \"typeField\": \"フィールド\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"選択したテーブル内のレコードへのリンク\",\n      \"singleLineText\": \"テキストを入力するか、新しいセルごとにデフォルト値を事前に入力します。\",\n      \"longText\": \"複数行のテキストを入力します。\",\n      \"attachment\": \"画像を追加またはAIで生成、もしくはドキュメントやその他のファイルをアップロードして表示またはダウンロードできます。\",\n      \"checkbox\": \"ステータスを示すためにチェックを入れるか外します。\",\n      \"multipleSelect\": \"リストから1つ以上の定義済みオプションを選択します。\",\n      \"singleSelect\": \"リストから定義済みのオプションを1つ選択するか、新しいセルごとに既定のオプションを事前に入力します。\",\n      \"user\": \"レコードにユーザーを追加します。\",\n      \"date\": \"日付（例：2023/11/12）を入力するか、カレンダーから選択します。\",\n      \"number\": \"数値を入力するか、新しいセルごとにデフォルト値を事前に入力します。\",\n      \"duration\": \"時間、分、秒単位で時間の長さを入力します（例: 1:23）。\",\n      \"rating\": \"事前に定義された尺度で評価を追加します。\",\n      \"formula\": \"フィールドに基づいて値を計算します。\",\n      \"rollup\": \"リンクされたレコードからデータを要約します。\",\n      \"conditionalLookup\": \"設定したフィルターに一致する関連値を表示します。\",\n      \"count\": \"リンクされたレコードの数をカウントします。\",\n      \"createdTime\": \"各レコードが作成された日時を確認します。\",\n      \"lastModifiedTime\": \"レコード内の一部またはすべてのフィールドに対する最新の編集の日時を表示します。\",\n      \"createdBy\": \"レコードを作成したユーザーを確認します。\",\n      \"lastModifiedBy\": \"レコード内の一部またはすべてのフィールドに対して最新の編集を行ったユーザーを確認します。\",\n      \"autoNumber\": \"各レコードに対して固有の増分番号を自動的に生成します。\",\n      \"button\": \"カスタマイズされたアクションをトリガーします。\",\n      \"lookup\": \"リンクされたレコードのフィールドの値を表示します。\"\n    },\n    \"fieldName\": \"フィールド名\",\n    \"fieldNameOptional\": \"フィールド名（オプション）\",\n    \"fieldType\": \"フィールドタイプ\",\n    \"aiConfig\": {\n      \"title\": \"AI 設定\",\n      \"type\": {\n        \"summary\": \"要約\",\n        \"translation\": \"翻訳\",\n        \"extraction\": \"情報抽出\",\n        \"improvement\": \"改善\",\n        \"tag\": \"スマートタグ\",\n        \"classification\": \"スマート分類\",\n        \"customization\": \"カスタマイズ\",\n        \"imageGeneration\": \"画像生成\",\n        \"rating\": \"画像評価\"\n      },\n      \"label\": {\n        \"type\": \"AI アクションタイプ\",\n        \"model\": \"AI モデル\",\n        \"targetLanguage\": \"ターゲット言語\",\n        \"sourceField\": \"ソースフィールド\",\n        \"sourceFieldForTag\": \"フィールドを選択し、作成されたタグと一致させます\",\n        \"sourceFieldForClassify\": \"フィールドを選択し、作成された分類と一致させます\",\n        \"attachPrompt\": \"要件を添付\",\n        \"prompt\": \"カスタマイズされたプロンプト\",\n        \"sourceFieldForAttachment\": \"添付ファイルのソースフィールド\",\n        \"imageSize\": \"画像サイズ\",\n        \"imageQuality\": \"画像品質\",\n        \"imageCount\": \"画像数\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"コンテンツのサマリー\",\n        \"translate\": \"簡潔でわかりやすく、軽快な翻訳\",\n        \"extractInfo\": \"メール、電話番号、名前、住所などを抽出...\",\n        \"extractDate\": \"開始時間を抽出...\",\n        \"improveText\": \"正式、親切、ユーモアがある...\",\n        \"attachPromptForTag\": \"3つ以内のタグを許可しない\",\n        \"attachPromptForClassify\": \"「進行中」を「リスクなし」と分類\",\n        \"attachPrompt\": \"追加の要件を入力してください\",\n        \"prompt\": \"カスタマイズされたプロンプトを入力してください\",\n        \"type\": \"AI アクションを選択\",\n        \"targetLanguage\": \"英語、中国語、フランス語...\",\n        \"imageSize\": \"画像サイズを入力してください\",\n        \"imageQuality\": \"画像品質を入力してください\",\n        \"attachPromptForImageGeneration\": \"画像は鮮明で自然なものにしてください\",\n        \"attachPromptForRating\": \"画像の品質を評価してください\"\n      },\n      \"imageQuality\": {\n        \"low\": \"低\",\n        \"medium\": \"中\",\n        \"high\": \"高\"\n      },\n      \"autoFill\": {\n        \"title\": \"自動更新\",\n        \"tip\": \"有効にすると、現在のフィールドは AI 構成の内容の変更と同期して更新されます\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"すべてのレコードを更新\",\n        \"description\": \"現在のビューのすべてのレコードが更新され、フィールドによって生成されたすべての関連データが含まれます\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"列全体を生成しますか？\",\n        \"description\": \"列全体を生成すると {{rowCount}} 行が更新されます。この操作は大量の AI リソースを消費する可能性があります。\",\n        \"saveConfigOnly\": \"設定のみ保存\",\n        \"generate\": \"生成する\",\n        \"generateFailed\": \"生成に失敗しました\"\n      },\n      \"action\": {\n        \"addAttachment\": \"添付ファイルを追加\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"新しいテーブル\",\n    \"rename\": \"名前を変更\",\n    \"design\": \"デザイン\",\n    \"tableRecordHistory\": \"テーブルレコード履歴\",\n    \"deleteConfirm\": \"本当に \\\"{{tableName}}\\\" を削除してもよろしいですか?\",\n    \"dbTableName\": \"物理データベース内のテーブル名\",\n    \"schemaName\": \"物理データベースのスキーマ名\",\n    \"baseInfo\": \"ベース情報\",\n    \"typeOfDatabase\": \"データベースの種類\",\n    \"descriptionForTable\": \"このテーブルの説明\",\n    \"nameForTable\": \"このテーブルの名前\",\n    \"deleteTip1\": \"この表にリンクされている他の表のリンクフィールドは削除されます\",\n    \"deleteTip2\": \"このテーブルは削除後、ごみ箱から復元できます\",\n    \"operator\": {\n      \"createBlank\": \"新しいテーブル\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"コピーと貼り付けはHTTPSまたはローカルホストでのみ機能します\",\n      \"copyAndPasteBrowser\": \"このブラウザではコピーと貼り付けはサポートされていません\",\n      \"copying\": \"コピー中...\",\n      \"copySuccessful\": \"コピー成功\",\n      \"copyFailed\": \"コピー失敗\",\n      \"pasting\": \"貼り付け中...\",\n      \"pasteSuccessful\": \"貼り付け成功\",\n      \"pasteFailed\": \"貼り付け失敗\",\n      \"filling\": \"入力中...\",\n      \"fillSuccessful\": \"入力成功\",\n      \"fillFailed\": \"入力失敗\",\n      \"clearing\": \"クリア...\",\n      \"clearSuccessful\": \"クリア成功\",\n      \"deleteFieldConfirmTitle\": \"以下のフィールドを削除します\",\n      \"deleting\": \"削除中...\",\n      \"deleteSuccessful\": \"削除成功\",\n      \"pasteFileFailed\": \"ファイルは添付ファイル欄にのみ貼り付けることができます\",\n      \"copyError\": {\n        \"noFocus\": \"ページをアクティブに保ち、ウィンドウを切り替えないでください\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"テーブルラベル\",\n      \"effectCells\": \"セルに影響を与える可能性があります\",\n      \"estimatedTime\": \"推定所要時間\",\n      \"linkFieldCount\": \"影響するリンクフィールドの数\"\n    },\n    \"integrity\": {\n      \"check\": \"チェック\",\n      \"title\": \"整合性チェック\",\n      \"loading\": \"整合性チェック中...\",\n      \"allGood\": \"すべて正常です！\",\n      \"fixIssues\": \"問題を修正\"\n    },\n    \"index\": {\n      \"description\": \"インデックスは、特に大量のデータ（{{rowCount}}行以上）を扱う場合、検索パフォーマンスを大幅に向上させることができます。ただし、書き込み操作が若干遅くなるというデメリットがあります。頻繁に検索を行う場合や大規模なデータセットがある場合は、インデックスを有効にすることをお勧めします。\",\n      \"repair\": \"修復\",\n      \"repairTip\": \"インデックスの異常が検出され、検索パフォーマンスが低下する可能性があります。修復ボタンをクリックしてインデックスを修正することをお勧めします。\",\n      \"enableIndexTip\": \"インデックスを作成しています。所要時間はテーブルのサイズによって異なります。このプロセス中、テーブルの読み取りと書き込みのパフォーマンスに影響が出る可能性があります。しばらくお待ちください。\",\n      \"globalSearchTip_infinity\": \"日付、チェックボックス、ボタンフィールドを除くすべてのフィールドを検索\",\n      \"globalSearchTip_limited\": \"日付、チェックボックス、ボタンフィールドを除くすべてのフィールドを検索, 最大 {{count}} フィールド\",\n      \"autoIndexTip\": \"テーブルが{{rowCount}}行を超えました。検索パフォーマンスを向上させるため、インデックスの有効化をお勧めします。なお、インデックスを有効にすると書き込み速度が若干低下する可能性があります。インデックス作成中は、テーブルの読み取り/書き込みパフォーマンスが一時的に影響を受ける可能性があります。しばらくお待ちください。\",\n      \"enableIndex\": \"有効にする\",\n      \"keepAsIs\": \"現状のまま\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"アップロード\",\n      \"import\": \"インポート\",\n      \"localFile\": \"ローカルファイル\",\n      \"linkUrl\": \"リンク(URL)\",\n      \"linkUrlInputTitle\": \"URLからファイルを追加\",\n      \"importTitle\": \"新しいテーブルを作成\",\n      \"incrementImportTitle\": \"インポート先 — \",\n      \"optionsTitle\": \"インポートオプション\",\n      \"primitiveFields\": \"プリミティブフィールド\",\n      \"importFields\": \"インポートフィールド\",\n      \"primaryField\": \"プライマリーフィールド\",\n      \"tipsTitle\": \"ヒント\",\n      \"confirm\": \"確認して続行\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"他のソースから追加\",\n      \"excelFile\": \"Microsoft Excelル\",\n      \"csvFile\": \"CSVファイル\",\n      \"importCsvData\": \"CSVデータをインポート\",\n      \"importExcelData\": \"Microsoft Excelデータをインポート\",\n      \"cancel\": \"キャンセル\",\n      \"leave\": \"離れる\",\n      \"downAsCsv\": \"csvをダウンロード\",\n      \"importData\": \"データをインポート\",\n      \"duplicate\": \"複製\",\n      \"duplicating\": \"複製中...\",\n      \"duplicateSuccess\": \"複製しました\",\n      \"duplicateFailed\": \"複製に失敗しました\",\n      \"importing\": \"インポート中\",\n      \"includeRecords\": \"レコードを含む\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"クリックまたはこの領域にドラッグしてファイルをアップロードします\",\n      \"leaveTip\": \"データは引き続きインポートされます。\",\n      \"fileExceedSizeTip\": \"このタイプのファイルサイズは制限を超えています\",\n      \"analyzing\": \"分析\",\n      \"notSupportFieldType\": \"フィールドタイプはサポートされていません\",\n      \"resultEmpty\": \"結果が見つかりませんでした。\",\n      \"searchPlaceholder\": \"検索...\",\n      \"importAlert\": \"インポートが開始されると、正常に完了するか失敗により終了するまで停止することはできません。インポート プロセスの結果は、完了すると通知されます。インポート中はテーブルを使用しないでください。エラーが発生する可能性があります。\",\n      \"noTips\": \"次回から表示しない\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"フィールドタイプの自動選択\",\n      \"useFirstRowAsHeaderOptionName\": \"最初の行をヘッダーとして使用\",\n      \"importDataOptionName\": \"データをインポート\",\n      \"sheetKey\": \"シート名: \",\n      \"excludeFirstRow\": \"インポート時に最初の行を除外する\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Field\",\n      \"error\": {\n        \"urlEmptyTip\": \"URLは空にできません！\",\n        \"errorFileFormat\": \"ファイル形式が正しくありません！\",\n        \"uniqueFieldName\": \"フィールド名は固有である必要があります！\",\n        \"fieldNameEmpty\": \"フィールド名は空にできません！\",\n        \"atLeastAImportField\": \"少なくとも1つはインポートフィールドを設定してください\",\n        \"urlValidateTip\": \"URL を解析できませんでした。別の URL を試してください！\"\n      },\n      \"option\": {\n        \"doNotImport\": \"インポートしない\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"CSVをダウンロード\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"新しいレコードを追加\",\n    \"prefillingRowTooltip\": \"以下に新しいレコードデータを入力してください。この行の外側をクリックすると、レコードは自動的に保存されます。\",\n    \"presortRowTitle\": \"このレコードはフィルタリングまたは並べ替えルールにより変更されました\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"フィールド\",\n    \"addAll\": \"すべて追加\",\n    \"removeAll\": \"すべて削除\",\n    \"hideFieldTip\": \"ここまでフィールドを非表示にする\",\n    \"unableAddFieldTip\": \"このタイプのフィールドを追加できません\",\n    \"removeFromFormTip\": \"フォームから削除\",\n    \"descriptionPlaceholder\": \"説明から入力\",\n    \"dragToFormTip\": \"フィールドをここにドラッグしてフォームに追加します\",\n    \"protectedFieldTip\": \"このフィールドは「必須」フィールドに設定されており、フォームビューで削除することはできません。フィールド設定で変更してください。\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"フィールド名を非表示\",\n      \"customizeCards\": \"カードをカスタマイズする\",\n      \"stackedBy\": \"<0/> でスタック\",\n      \"chooseStackingField\": \"スタッキングフィールドを選択\",\n      \"chooseStackingFieldDescription\": \"カンバンを設定するためにどのフィールドを使用しますか？レコードはこのフィールドに基づいてスタックされます。\",\n      \"hideEmptyStack\": \"空のスタックを非表示\",\n      \"imageSetting\": \"画像設定\",\n      \"fit\": \"フィット\",\n      \"noImage\": \"画像がありません\",\n      \"chooseAttachmentField\": \"添付ファイルフィールドを選択\"\n    },\n    \"stack\": {\n      \"addStack\": \"スタックを追加\",\n      \"noCards\": \"カードがありません\",\n      \"uncategorized\": \"未分類\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"スタックを折りたたむ\",\n      \"renameStack\": \"スタックの名前を変更する\",\n      \"deleteStack\": \"スタックを削除\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"カードを上に挿入\",\n      \"insertCardBelow\": \"カードを下に挿入\",\n      \"expandCard\": \"カードを展開\",\n      \"deleteCard\": \"カードを削除\",\n      \"duplicateCard\": \"カードを複製\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"カレンダー設定\",\n      \"startDateField\": \"開始日付フィールド\",\n      \"endDateField\": \"終了日付フィールド\",\n      \"titleField\": \"タイトルフィールド\",\n      \"colorField\": \"色フィールド\",\n      \"colorType\": \"色表示\",\n      \"customColor\": \"カスタマイズ\",\n      \"alignWithRecords\": \"レコードに合わせる\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"色フィールドを選択\"\n    },\n    \"dialog\": {\n      \"startDate\": \"開始日付\",\n      \"endDate\": \"終了日付\",\n      \"notAdd\": \"追加しない\",\n      \"addDateField\": \"日付フィールドを追加\",\n      \"content\": \"カレンダービューを作成し、テーブルには開始日付と終了日付の2つの日付フィールドが必要です\"\n    },\n    \"moreLinkText\": \"すべての {{count}} レコードを表示\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"レコードを上に挿入\",\n    \"insertRecordBelow\": \"レコードを下に挿入\",\n    \"copyCells\": \"セルのコピー\",\n    \"deleteRecord\": \"レコードを削除\",\n    \"deleteAllSelectedRecords\": \"選択したすべてのレコードを削除\",\n    \"editField\": \"フィールドを編集\",\n    \"insertFieldLeft\": \"左に挿入\",\n    \"insertFieldRight\": \"右に挿入\",\n    \"freezeUpField\": \"このフィールドまでフリーズ\",\n    \"hideField\": \"フィールドを非表示\",\n    \"deleteField\": \"フィールドを削除\",\n    \"deleteAllSelectedFields\": \"選択したすべてのフィールドを削除\",\n    \"autoFill\": \"すべてのレコードを更新\",\n    \"groupMenuTitle\": \"グループメニュー\",\n    \"expandGroup\": \"このグループとそのサブグループを展開\",\n    \"collapseGroup\": \"このグループとそのサブグループを折りたたむ\",\n    \"expandAllGroups\": \"すべてのグループを展開\",\n    \"collapseAllGroups\": \"すべてのグループを折りたたむ\",\n    \"addToChat\": \"チャットに追加\"\n  },\n  \"connection\": {\n    \"title\": \"データベース接続\",\n    \"description\": \"データベース接続を介して、現在のベースにあるすべてのテーブルを含むデータベースに直接アクセスできます。\",\n    \"noPermission\": \"データベースにアクセスする権限がありません\",\n    \"connectionCountTip\": \"データベース接続の最大数は <b>{{max}}</b> で、現在の接続数は <b>{{current}}</b> です\",\n    \"createFailed\": \"PUBLIC_DATABASE_PROXY 環境変数が正しく設定されていることを確認してください\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"レコードを追加\",\n    \"searchView\": \"ビューを検索...\",\n    \"dragToolTip\": \"自動並べ替えがオンになっており、手動ドラッグは利用できません\",\n    \"insertToolTip\": \"自動並べ替えがオンになっているため、順序付き挿入は利用できません\",\n    \"action\": {\n      \"rename\": \"ビューの名前を変更\",\n      \"duplicate\": \"ビューを複製\",\n      \"delete\": \"ビューの削除\",\n      \"lock\": \"ビューをロック\",\n      \"unlock\": \"ビューをロック解除\",\n      \"enable\": \"有効\"\n    },\n    \"category\": {\n      \"table\": \"グリッドビュー\",\n      \"form\": \"フォームビュー\",\n      \"kanban\": \"カンバンビュー\",\n      \"gallery\": \"ギャラリービュー\",\n      \"calendar\": \"カレンダービュー\"\n    },\n    \"crash\": {\n      \"title\": \"クラッシュしました！\",\n      \"description\": \"このビューは壊れています。それでも更新が失敗する場合は、お問い合わせください。support@teable.ai\"\n    },\n    \"locked\": {\n      \"tip\": \"このビューはロックされています。個人モードを有効にしてビューオプションを編集し、変更は自分にのみ適用されます。\"\n    },\n    \"noView\": \"ビューがありません\"\n  },\n  \"lastModifiedTime\": \"最終更新日時\",\n  \"lastModify\": \"最終更新: \",\n  \"tableTrash\": {\n    \"title\": \"テーブルのごみ箱\",\n    \"resourceType\": \"リソースタイプ\",\n    \"deletedResource\": \"削除されたリソース\"\n  },\n  \"baseShare\": {\n    \"title\": \"ベースを共有\",\n    \"shareTitle\": \"共有\",\n    \"shareToWeb\": \"ウェブに共有\",\n    \"description\": \"「{{baseName}}」を他の人と共有\",\n    \"nodeShareDescription\": \"「{{nodeName}}」とその内容を共有\",\n    \"shareLinks\": \"共有リンク\",\n    \"newLink\": \"新しいリンク\",\n    \"noShareLinks\": \"共有リンクはまだありません\",\n    \"createFirstLink\": \"共有リンクを作成\",\n    \"editSettings\": \"設定を編集\",\n    \"refreshLink\": \"リンクを再生成\",\n    \"deleteLink\": \"リンクを削除\",\n    \"deleteConfirmTitle\": \"共有リンクを削除\",\n    \"deleteConfirmDescription\": \"この操作は元に戻せません。このリンクを持つ人は共有ベースにアクセスできなくなります。\",\n    \"createSuccess\": \"共有リンクが作成されました\",\n    \"createFailed\": \"共有リンクの作成に失敗しました\",\n    \"updateSuccess\": \"共有設定が更新されました\",\n    \"updateFailed\": \"共有設定の更新に失敗しました\",\n    \"deleteSuccess\": \"共有リンクが削除されました\",\n    \"deleteFailed\": \"共有リンクの削除に失敗しました\",\n    \"refreshSuccess\": \"共有リンクが再生成されました\",\n    \"refreshFailed\": \"共有リンクの再生成に失敗しました\",\n    \"copied\": \"リンクがクリップボードにコピーされました\",\n    \"shareLink\": \"共有リンク\",\n    \"linkHolderLabel\": \"リンクを取得した人\",\n    \"linkHolderCanView\": \"閲覧可能\",\n    \"linkHolderCanEdit\": \"編集可能\",\n    \"linkHolderCanCopyAndSave\": \"コピーとして保存可能\",\n    \"passwordProtection\": \"パスワード保護\",\n    \"enterPassword\": \"パスワードを入力\",\n    \"selectNodes\": \"テーブルを選択\",\n    \"shareEntireBase\": \"ベース全体を共有\",\n    \"shareSelectedNodes\": \"選択したノードを共有\",\n    \"shareEntireBaseDescription\": \"このベースに追加された新しいテーブルとフォルダは自動的に共有されます\",\n    \"noNodesSelectedWarning\": \"共有するノードを少なくとも1つ選択してください\",\n    \"allowSave\": \"スペースに保存を許可\",\n    \"allowSaveDescription\": \"閲覧者がこのベースのコピーを自分のスペースに保存することを許可\",\n    \"allowCopy\": \"データのコピーを許可\",\n    \"allowCopyData\": \"閲覧者がデータをコピーすることを許可\",\n    \"allowDuplicate\": \"閲覧者に複製を許可\",\n    \"allowCopyDescription\": \"閲覧者がこの共有ベースからテーブルデータをコピーすることを許可\",\n    \"selectedNodes\": \"{{count}}個のテーブルが選択されました\",\n    \"allNodes\": \"すべてのテーブル\",\n    \"sharedNode\": \"共有ノード\",\n    \"sharedNodeDescription\": \"この共有リンクは特定のノードとその子孫用です\",\n    \"publicShareTitle\": \"リンクによる公開共有\",\n    \"publicShareCount\": \"{{count}}個の公開共有リンク\",\n    \"noPublicShare\": \"公開共有リンクはありません\",\n    \"security\": \"セキュリティ\",\n    \"restrictByPassword\": \"パスワードで制限\",\n    \"advanced\": \"詳細設定\",\n    \"embedConfig\": \"埋め込み設定\",\n    \"appPublicLink\": \"アプリ公開リンク\",\n    \"appNotPublished\": \"このアプリはまだ公開されていません。共有する前にまずアプリを公開してください。\",\n    \"goToPublish\": \"公開へ\",\n    \"publishSuccess\": \"アプリが正常に公開されました\",\n    \"publishFailed\": \"アプリの公開に失敗しました\",\n    \"openLink\": \"リンクを開く\",\n    \"appPublished\": \"アプリが公開されました\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"テーブルフィールドを取得\",\n      \"getTablesMeta\": \"テーブルメタデータを取得\",\n      \"sqlQuery\": \"SQL クエリを実行\",\n      \"generateScriptAction\": \"スクリプトアクションを生成\",\n      \"getScriptInput\": \"スクリプト入力を取得\",\n      \"getTeableApi\": \"API を取得\",\n      \"dataVisualization\": \"データ可視化\",\n      \"updateBase\": \"データベース情報を更新\",\n      \"args\": \"引数\",\n      \"result\": \"結果\",\n      \"thinking\": \"思考中...\",\n      \"toBeConfirmed\": \"確認待ち\",\n      \"errorMessage\": \"エラーメッセージ\",\n      \"confirm\": \"確認\",\n      \"createRecordsSuccess\": \"{{count}} 件のレコードを正常に作成しました\",\n      \"createRecordsFailed\": \"レコードの作成に失敗しました\",\n      \"updateRecordsSuccess\": \"{{count}} 件のレコードを正常に更新しました\",\n      \"updateRecordsFailed\": \"レコードの更新に失敗しました\",\n      \"generatingRecords\": \"{{count}} 件のレコードを生成中...\",\n      \"creatingRecords\": \"{{count}} 件のレコードを作成中...\",\n      \"updatingRecords\": \"{{count}} 件のレコードを更新中...\",\n      \"recordsPreview\": \"レコードプレビュー\",\n      \"andMoreRecords\": \"さらに {{count}} 件...\",\n      \"unknownError\": \"不明なエラー\",\n      \"recordIds\": \"レコードID\",\n      \"records\": \"件のレコード\",\n      \"viewAll\": \"すべて表示\",\n      \"showLess\": \"折りたたむ\",\n      \"generatingData\": \"データを生成中...\",\n      \"generatingUpdates\": \"更新を生成中...\",\n      \"recordsGenerated\": \"{{count}} 件のレコードを生成しました\",\n      \"recordsCount\": \"レコード（{{count}}）\",\n      \"fieldsCount\": \"フィールド（{{count}}）\",\n      \"fieldsGenerated\": \"{{count}} 件のフィールドを生成しました\",\n      \"updatedProperties\": \"更新済み（{{count}}）\",\n      \"configured\": \"設定済み\",\n      \"recordsToUpdate\": \"更新対象 {{count}} 件のレコード\",\n      \"showingLast\": \"最後の {{count}} 件\",\n      \"recordLabel\": \"レコード\",\n      \"statusGenerating\": \"生成中...\",\n      \"statusCreating\": \"作成中...\",\n      \"statusUpdating\": \"更新中...\",\n      \"statusCreated\": \"作成済み\",\n      \"statusUpdated\": \"更新済み\",\n      \"getApps\": {\n        \"title\": \"アプリ一覧\",\n        \"loading\": \"アプリを読み込み中...\",\n        \"foundApps\": \"{{count}} 件のアプリが見つかりました\",\n        \"noApps\": \"このベースにアプリはありません\",\n        \"openApp\": \"アプリを開く\"\n      },\n      \"generateApp\": {\n        \"title\": \"アプリビルダー\",\n        \"creatingApp\": \"アプリを作成中\",\n        \"updatingApp\": \"アプリを更新中\",\n        \"generatingApp\": \"アプリを生成中\",\n        \"generating\": \"生成中...\",\n        \"openApp\": \"アプリを開く\",\n        \"viewProgress\": \"進行状況を表示\",\n        \"newApp\": \"新規アプリ\",\n        \"building\": \"ビルド中...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"オートメーションビルダー\",\n        \"creatingAutomation\": \"オートメーションを作成中\",\n        \"updatingAutomation\": \"オートメーションを更新中\",\n        \"generatingAutomation\": \"オートメーションを構築中\",\n        \"building\": \"ビルド中...\",\n        \"openAutomation\": \"オートメーションを開く\",\n        \"viewProgress\": \"進行状況を表示\",\n        \"testResults\": \"テスト結果\",\n        \"triggerTest\": \"トリガー\",\n        \"actionTest\": \"アクション {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"プレビュー\",\n        \"code\": \"コード\",\n        \"download\": \"ダウンロード\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"画像（PNG）\",\n        \"copy\": \"コピー\",\n        \"copied\": \"コピーしました！\",\n        \"fullscreen\": \"全画面\",\n        \"exitFullscreen\": \"全画面を終了\",\n        \"downloadSuccess\": \"画像を正常にダウンロードしました\",\n        \"downloadFailed\": \"画像のキャプチャに失敗しました\",\n        \"iframeFailed\": \"キャプチャに失敗：iframe にアクセスできません\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"添付ファイルを読み込み\",\n        \"loading\": \"添付ファイルを読み込み中\",\n        \"failed\": \"添付ファイルの読み込みに失敗しました\",\n        \"empty\": \"添付ファイルが読み込まれていません\",\n        \"modeNative\": \"AI Vision\",\n        \"modeNativeDesc\": \"AIネイティブコンテキストに読み込み済み\",\n        \"modeExtracted\": \"テキスト抽出\",\n        \"modeExtractedDesc\": \"ファイル内容を解析してテキストとして抽出\",\n        \"visionLoaded\": \"視覚分析用に読み込み済み\",\n        \"pdfLoaded\": \"PDF分析用に読み込み済み\",\n        \"textExtracted\": \"{{chars}} 文字を抽出しました\",\n        \"contextLoaded\": \"AIコンテキストに読み込み済み\",\n        \"truncated\": \"切り詰め済み\",\n        \"preview\": \"プレビュー\"\n      },\n      \"textExtract\": {\n        \"title\": \"テキスト抽出\",\n        \"loading\": \"テキストを抽出中\",\n        \"failed\": \"テキストの抽出に失敗しました\",\n        \"empty\": \"テキストが抽出されていません\",\n        \"preview\": \"プレビュー\",\n        \"truncated\": \"切り詰め済み\",\n        \"previews\": \"件のプレビュー\",\n        \"chars\": \"{{chars}} 文字\",\n        \"totalCharacters\": \"合計: {{chars}} 文字\",\n        \"filesTruncated\": \"（{{count}} 件のファイルを切り詰めました）\"\n      },\n      \"importExcel\": {\n        \"title\": \"Excelをインポート\",\n        \"loading\": \"ファイルを処理中...\",\n        \"failed\": \"インポートに失敗しました\",\n        \"suggestions\": \"提案\",\n        \"analyzeComplete\": \"分析完了\",\n        \"worksheets\": \"ワークシート\",\n        \"columns\": \"列\",\n        \"importComplete\": \"インポート完了\",\n        \"stageAnalyze\": \"分析中\",\n        \"stageImport\": \"インポート中\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Teable APIを取得\",\n      \"readFiles\": \"ファイルを読み取り\",\n      \"writeFile\": \"ファイルを書き込み\",\n      \"deleteFiles\": \"ファイルを削除\",\n      \"listFiles\": \"ファイル一覧\",\n      \"addDependencies\": \"依存関係を追加\",\n      \"checkBuildErrors\": \"ビルドエラーを確認\",\n      \"lint\": \"コードをリント\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"プレビューの読み込みに失敗しました\",\n      \"retry\": \"{{count}} 回再試行\",\n      \"chatAborted\": \"中止されました\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"削除されたテーブル\",\n      \"deletedView\": \"削除されたビュー\",\n      \"deletedField\": \"削除されたフィールド\",\n      \"deletedRecords\": \"削除されたレコード\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"テーブルエージェント\",\n      \"viewOperatorAgent\": \"ビューエージェント\",\n      \"fieldOperatorAgent\": \"フィールドエージェント\",\n      \"recordOperatorAgent\": \"レコードエージェント\",\n      \"buildBaseAgent\": \"ベース構築エージェント\",\n      \"buildAutomationAgent\": \"オートメーション構築エージェント\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"確認待ち\",\n      \"deleteWarning\": \"削除してもよろしいですか？\"\n    },\n    \"action\": {\n      \"createTable\": \"テーブルを作成\",\n      \"updateTable\": \"テーブルを更新\",\n      \"updateTableName\": \"テーブル名を更新\",\n      \"deleteTable\": \"テーブルを削除\",\n      \"createView\": \"ビューを作成\",\n      \"updateView\": \"ビューを更新\",\n      \"updateViewName\": \"ビュー名を更新\",\n      \"deleteView\": \"ビューを削除\",\n      \"createField\": \"フィールドを作成\",\n      \"createAiField\": \"AIフィールドを作成\",\n      \"createLinkField\": \"リンクフィールドを作成\",\n      \"createLookupField\": \"ルックアップフィールドを作成\",\n      \"createRollupField\": \"ロールアップフィールドを作成\",\n      \"createFormulaField\": \"数式フィールドを作成\",\n      \"deleteField\": \"フィールドを削除\",\n      \"updateField\": \"フィールドを更新\",\n      \"createRecord\": \"レコードを作成\",\n      \"createRecords\": \"レコードを作成\",\n      \"deleteRecord\": \"レコードを削除\",\n      \"updateRecord\": \"レコードを更新\",\n      \"updateRecords\": \"レコードを更新\",\n      \"updateBase\": \"データベース情報を更新\",\n      \"planTask\": \"タスクを計画中...\",\n      \"generateTables\": \"テーブルを生成\",\n      \"generatePrimaryFields\": \"主キーフィールドを生成\",\n      \"generateFields\": \"フィールドを生成\",\n      \"generateViews\": \"ビューを生成\",\n      \"generateRecords\": \"レコードを生成\",\n      \"generateAIFields\": \"AIフィールドを生成\",\n      \"generateLinkFields\": \"リンクフィールドを生成\",\n      \"generateLookupFields\": \"ルックアップフィールドを生成\",\n      \"generateRollupFields\": \"ロールアップフィールドを生成\",\n      \"generateFormulaFields\": \"数式フィールドを生成\",\n      \"generateWorkflow\": \"ワークフローを生成\",\n      \"generateTrigger\": \"トリガーを生成\",\n      \"generateScriptAction\": \"スクリプトアクションノードを生成\",\n      \"generateSendMailAction\": \"メール送信ノードを生成\",\n      \"generateAction\": \"アクションノードを生成\",\n      \"setupAutomationTrigger\": \"オートメーショントリガーを設定\",\n      \"testAutomationNode\": \"オートメーションノードをテスト\",\n      \"activateAutomation\": \"オートメーションを有効化\",\n      \"executeScript\": \"スクリプトを実行\",\n      \"wait\": \"待機\",\n      \"generateScriptFlowChart\": \"スクリプトフローチャートを生成\",\n      \"triggerAiFill\": \"AI入力をトリガー\",\n      \"initialize\": \"環境を初期化\",\n      \"rename\": \"アプリ名を生成\",\n      \"buildTest\": \"テストを作成\",\n      \"developTask\": \"タスクを開発\",\n      \"generateSummary\": \"サマリーを生成\",\n      \"previewEnvironment\": \"プレビュー環境を準備\",\n      \"getRelativeData\": \"関連データを取得\",\n      \"getPreviousNodeOutputVariables\": \"前のノードの出力変数を取得\",\n      \"getApiJson\": \"API情報を取得\",\n      \"generateScriptAndDependencies\": \"スクリプトと依存関係を生成\",\n      \"analyzingAttachment\": \"添付ファイルを分析中...\",\n      \"locateResource\": \"検索\",\n      \"goTo\": \"移動\",\n      \"operationSuccess\": \"操作が正常に完了しました\",\n      \"operationFailed\": \"操作に失敗しました\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} 件のレコードがAI生成のキューに入りました\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"レコードを取得\",\n      \"getRecordsWithTable\": \"レコードを取得 · {{tableName}}\",\n      \"getGridRows\": \"グリッド行を取得\",\n      \"getGridRowsWithTable\": \"グリッド行を取得 · {{tableName}}\",\n      \"getFields\": \"フィールドを取得\",\n      \"getFieldsWithTable\": \"フィールドを取得 · {{tableName}}\",\n      \"getTables\": \"テーブルを取得\",\n      \"getViews\": \"ビューを取得\",\n      \"getViewsWithTable\": \"ビューを取得 · {{tableName}}\",\n      \"sqlQuery\": \"SQLクエリ\",\n      \"querying\": \"クエリ中...\",\n      \"queryFailed\": \"クエリに失敗しました\",\n      \"aborted\": \"中止されました\",\n      \"noData\": \"データが返されませんでした\",\n      \"dataFormatError\": \"データ形式エラー\",\n      \"unsupportedQueryType\": \"サポートされていないクエリタイプ: {{toolName}}\",\n      \"returnedRecords\": \"{{count}} 件のレコードを返しました\",\n      \"record\": \"レコード {{index}}\",\n      \"moreRecords\": \"... +{{count}} 件のレコード\",\n      \"foundFields\": \"{{count}} 件のフィールドが見つかりました\",\n      \"moreFields\": \"... +{{count}} 件のフィールド\",\n      \"foundTables\": \"{{count}} 件のテーブルが見つかりました\",\n      \"moreTables\": \"... +{{count}} 件のテーブル\",\n      \"foundViews\": \"{{count}} 件のビューが見つかりました\",\n      \"moreViews\": \"... +{{count}} 件のビュー\",\n      \"queryReturned\": \"クエリ結果: {{rowCount}} 行 × {{columnCount}} 列\",\n      \"row\": \"行 {{index}}\",\n      \"moreRows\": \"... +{{count}} 行\",\n      \"getDoc\": \"ドキュメントを取得\",\n      \"getDocWithTopic\": \"ドキュメントを取得 · {{topic}}\",\n      \"getAutomations\": \"オートメーションを取得\",\n      \"getAutomation\": \"オートメーションを取得\",\n      \"getAutomationRuns\": \"オートメーション実行を取得\",\n      \"foundAutomations\": \"{{count}} 件のオートメーションが見つかりました\",\n      \"moreAutomations\": \"... +{{count}} 件のオートメーション\",\n      \"foundRuns\": \"{{count}} 件の実行が見つかりました\",\n      \"moreRuns\": \"... +{{count}} 件の実行\",\n      \"active\": \"有効\",\n      \"trigger\": \"トリガー\",\n      \"actions\": \"{{count}} 件のアクション\",\n      \"moreActions\": \"... +{{count}} 件のアクション\",\n      \"getUserIntegrations\": \"連携を確認\",\n      \"connectedIntegrations\": \"接続済み\",\n      \"availableToConnect\": \"接続可能\",\n      \"connect\": \"接続\",\n      \"noIntegrationsAvailable\": \"利用可能な連携がありません\",\n      \"activateTool\": \"ツールを有効化\",\n      \"webSearch\": \"ウェブ検索\",\n      \"webSearchResults\": \"{{count}} 件の結果が見つかりました\",\n      \"webSearchCompleted\": \"検索完了\",\n      \"searchApi\": \"APIを検索\",\n      \"searchApiWithQuery\": \"API検索 · {{query}}\",\n      \"noApiFound\": \"APIが見つかりません\",\n      \"foundApis\": \"{{count}} 件のAPIが見つかりました\",\n      \"totalApis\": \"合計 {{count}} 件のAPIが利用可能\",\n      \"callApi\": \"APIを呼び出し\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"レスポンス\",\n      \"success\": \"成功\",\n      \"failed\": \"失敗\",\n      \"inputData\": \"入力データ\",\n      \"availableNodes\": \"利用可能なノード\",\n      \"hasPreviousCode\": \"既存のコードあり\",\n      \"noInputData\": \"入力データがありません\"\n    },\n    \"showUI\": {\n      \"connect\": \"接続\",\n      \"connecting\": \"接続中...\",\n      \"connected\": \"接続済み\",\n      \"connectToUse\": \"オートメーションで使用するには {{provider}} に接続してください\",\n      \"checkingConnection\": \"接続状態を確認中...\",\n      \"confirm\": \"確認\",\n      \"confirmed\": \"確認済み\",\n      \"cancel\": \"キャンセル\",\n      \"cancelled\": \"キャンセル済み\",\n      \"connectionCancelled\": \"接続がキャンセルされました\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} 行が非表示\",\n      \"collapseCode\": \"コードを折りたたむ\",\n      \"code\": \"コード\",\n      \"preview\": \"プレビュー\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"ビルド進捗\",\n      \"completed\": \"アプリのビルドが完了しました\",\n      \"completedDesc\": \"すべてのステップが正常に完了し、プレビュー可能です\",\n      \"stepStatus\": {\n        \"initializing\": \"アプリを作成し、環境を初期化中...\",\n        \"naming\": \"アプリ名を生成中...\",\n        \"planning\": \"要件を分析し、開発計画を策定中...\",\n        \"developing\": \"コードを記述し、機能を実装中...\",\n        \"summarizing\": \"開発結果を整理中...\",\n        \"deploying\": \"プレビュー環境にデプロイ中...\",\n        \"testing\": \"テストを作成中...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"実行中\",\n        \"completed\": \"完了\",\n        \"error\": \"失敗\",\n        \"pending\": \"保留中\"\n      },\n      \"toolStatus\": {\n        \"running\": \"実行中\",\n        \"completed\": \"完了\",\n        \"error\": \"失敗\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"スクリプトの生成に成功しました\"\n    },\n    \"buildBase\": {\n      \"title\": \"ベースを構築\",\n      \"generateSuccess\": \"データベースの生成に成功しました\",\n      \"generateError\": \"データベースの生成に失敗しました\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"オートメーションを構築\",\n      \"generateSuccess\": \"オートメーションの生成に成功しました\"\n    },\n    \"automation\": {\n      \"created\": \"作成済み\",\n      \"updated\": \"更新済み\",\n      \"workflow\": \"ワークフロー\",\n      \"trigger\": \"トリガー\",\n      \"scriptAction\": \"スクリプトアクション\",\n      \"workflowLabel\": \"ワークフロー\",\n      \"triggerLabel\": \"トリガー\",\n      \"scriptActionLabel\": \"スクリプトアクション\",\n      \"workflowId\": \"ワークフロー\",\n      \"triggerId\": \"トリガー\",\n      \"scriptActionId\": \"スクリプトアクション\",\n      \"viewAutomation\": \"表示\",\n      \"navigateToAutomation\": \"オートメーションに移動\",\n      \"triggerType\": {\n        \"recordCreated\": \"レコード作成時\",\n        \"recordUpdated\": \"レコード更新時\",\n        \"recordCreatedOrUpdated\": \"レコード作成または更新時\",\n        \"formSubmitted\": \"フォーム送信時\",\n        \"scheduledTime\": \"スケジュール時刻\",\n        \"buttonClick\": \"ボタンクリック\"\n      },\n      \"activated\": \"有効化済み\",\n      \"deactivated\": \"無効化済み\",\n      \"discarded\": \"変更を破棄しました\",\n      \"activateFailed\": \"有効化に失敗\",\n      \"deactivateFailed\": \"無効化に失敗\",\n      \"discardFailed\": \"破棄に失敗\",\n      \"scriptUpdated\": \"スクリプトが更新されました\",\n      \"scriptUpdateFailed\": \"更新に失敗\",\n      \"scriptExecuted\": \"スクリプトが実行されました\",\n      \"scriptExecutionFailed\": \"実行に失敗\",\n      \"scriptReady\": \"スクリプト準備完了\",\n      \"executingScript\": \"スクリプトを実行中...\",\n      \"waitedSeconds\": \"{{seconds}}秒待機しました\",\n      \"waitFailed\": \"待機に失敗\",\n      \"flowchartGenerated\": \"フローチャートが生成されました\",\n      \"flowchartGenerationFailed\": \"生成に失敗\"\n    },\n    \"newChat\": \"新しいチャット\",\n    \"clearChat\": \"チャットをクリア\",\n    \"expand\": \"展開\",\n    \"history\": \"履歴\",\n    \"close\": \"折りたたむ\",\n    \"clearChatConfirmTitle\": \"チャットのクリアを確認\",\n    \"clearChatConfirmDesc\": \"現在のチャット内容は保存されません。本当にクリアしますか？\",\n    \"dontShowAgain\": \"今後表示しない\",\n    \"noModel\": \"利用可能なモデルがありません\",\n    \"addAttachment\": \"添付ファイルを追加\",\n    \"noHistory\": \"チャット履歴がありません\",\n    \"noFoundHistory\": \"チャット履歴が見つかりません。新しいチャットを開始してください\",\n    \"timeGroup\": {\n      \"today\": \"今日\",\n      \"oneWeek\": \"1週間\",\n      \"twoWeek\": \"2週間\",\n      \"oneMonth\": \"1ヶ月\",\n      \"other\": \"その他\"\n    },\n    \"context\": {\n      \"button\": \"コンテキストを追加\",\n      \"search\": \"テーブルを追加\",\n      \"searchEmpty\": \"一致するコンテキストが見つかりません\",\n      \"emptyContext\": \"追加するコンテキストがありません\",\n      \"selectionRows\": \"行 {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"メッセージを送信...\",\n    \"thought\": \"思考中\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"生成時間: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} クレジット使用済み\",\n      \"tokenDescription\": \"トークン使用数: {{tokens}}\",\n      \"input\": \"入力\",\n      \"output\": \"出力\",\n      \"tokens\": \"トークン\",\n      \"totalTimeCost\": \"合計所要時間\",\n      \"totalCreditCost\": \"合計クレジット消費\",\n      \"customModel\": \"カスタムモデル\",\n      \"tokenDetails\": \"トークン詳細\",\n      \"cachedInput\": \"キャッシュ入力（90%割引）\",\n      \"cacheWrite\": \"キャッシュ書き込み\",\n      \"reasoning\": \"推論（思考）\",\n      \"taskCompleted\": \"タスク完了\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"データ可視化に失敗しました\"\n    },\n    \"tips\": {\n      \"modelTips\": \"管理者のみが設定できます\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"画像はサポートされていません\",\n      \"attachmentSizeExceeded\": \"添付ファイルのサイズが制限（{{size}}MB）を超えています\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"おすすめ\",\n      \"ask\": \"質問\",\n      \"analyze\": \"分析\",\n      \"build\": \"構築\",\n      \"title\": \"何をお手伝いしましょうか？\",\n      \"whatCanIDo\": \"何ができますか？\",\n      \"createOrModifyDatabase\": \"データベースを作成または変更\",\n      \"buildAutomations\": \"オートメーションを構築\",\n      \"buildApps\": \"アプリを構築\",\n      \"buildMeCRM\": \"CRMを構築\",\n      \"addAIField\": \"AIフィールドを追加して各顧客を分析\",\n      \"createDataAnalysis\": \"データ分析レポートを作成\",\n      \"emailWhenRecordCreated\": \"レコード作成時にメール通知\",\n      \"syncStatusToSlack\": \"ステータス更新をSlackに同期\",\n      \"buildDashboard\": \"このテーブルからダッシュボードを構築\",\n      \"buildLeadCapture\": \"リード獲得ランディングページを構築\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"{{duration}}秒間思考しました\"\n      },\n      \"task\": {\n        \"searching\": \"「{{query}}」を検索中\",\n        \"readingFiles\": \"ファイルを読み取り中:\",\n        \"foundResults\": \"{{count}} 件の結果が見つかりました\",\n        \"noIssuesFound\": \"問題は見つかりませんでした\",\n        \"defaultTitle\": \"タスク\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"コードプロジェクト\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"プレビューを生成するにはAIモデルが必要です。\",\n      \"writeCodeHint\": \"コードを記述してフローチャートのプレビューを確認してください。\",\n      \"noPreview\": \"フローチャートのプレビューはありません。\",\n      \"generatePreview\": \"プレビューを生成\",\n      \"analyzing\": \"スクリプトを分析中...\",\n      \"codeChanged\": \"このプレビューが生成されてからコードが変更されました。\",\n      \"regenerate\": \"再生成\",\n      \"refresh\": \"更新\",\n      \"regenerating\": \"再生成中...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"すべての添付ファイルをダウンロード\",\n      \"loading\": \"プレビューを読み込み中...\",\n      \"rowsWithAttachments\": \"{{count}} 行に添付ファイルがあります\",\n      \"totalAttachments\": \"{{count}} 個の添付ファイル\",\n      \"totalSize\": \"合計サイズ: {{size}}\",\n      \"startDownload\": \"ダウンロード開始\",\n      \"confirmTitle\": \"{{count}} ファイルをダウンロード\",\n      \"confirmDescription\": \"合計サイズ: {{size}}。ファイルはZIPファイルに圧縮されます。\",\n      \"confirm\": \"ダウンロード\",\n      \"cancel\": \"キャンセル\",\n      \"downloading\": \"ダウンロード中...\",\n      \"downloadingFile\": \"ダウンロード中: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"ダウンロード完了\",\n      \"cancelled\": \"ダウンロードがキャンセルされました\",\n      \"noAttachments\": \"ダウンロードする添付ファイルがありません\",\n      \"error\": \"ダウンロード失敗\",\n      \"errorPartial\": \"{{failedCount}} ファイルのダウンロードに失敗しました\",\n      \"requireHttps\": \"一括ダウンロードにはHTTPSが必要です。HTTPSまたはlocalhostでアクセスしてください。\",\n      \"advancedOptions\": \"詳細オプション\",\n      \"namingFieldLabel\": \"添付ファイル名のプレフィックス\",\n      \"selectField\": \"デフォルト: 添付ファイル番号\",\n      \"groupByRow\": \"フォルダにアーカイブ\",\n      \"groupByRowTip\": \"行に複数の添付ファイルがある場合、同じフォルダに配置されます。添付ファイルが1つだけの行はフォルダを作成しません。\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/token.json",
    "content": "{\n  \"access\": \"アクセス\",\n  \"name\": \"名前\",\n  \"description\": \"説明\",\n  \"scopes\": \"スコープ\",\n  \"expiration\": \"有効期限\",\n  \"createdTime\": \"作成\",\n  \"lastUse\": \"最終使用\",\n  \"allSpace\": \"このスペース、このスペース内の現在のベースと将来のベースすべて\",\n  \"formLabelTips\": {\n    \"name\": \"トークン名を入力してください\",\n    \"description\": \"このトークンは利用目的は？\",\n    \"scopes\": \"このトークンを使用すると、次のことが可能になります。\",\n    \"access\": \"このトークンは、以下のベースとスペースにアクセスできます。アクセス権を持つベースとスペースにのみアクセスを許可できます。\"\n  },\n  \"new\": {\n    \"headerTitle\": \"新しいトークンの作成\",\n    \"title\": \"Teable APIを使用するには、個人アクセストークンが必要です。\",\n    \"description\": \"このトークンは、選択したスペースとベースのデータへのアクセスを許可します。その他、スペース/ベース以外のAPIエンドポイントも使用できます。このトークンは独自の開発にのみ使用してください。サードパーティのサービスやアプリケーションと共有しないでください。\",\n    \"button\": \"新しいトークンを作成\",\n    \"success\": {\n      \"title\": \"トークンが正常に生成されました\",\n      \"description\": \"トークンを必ずコピーしてください。二度と表示されません。\"\n    },\n    \"expirationList\": {\n      \"days\": \"日\",\n      \"permanent\": \"永続的\",\n      \"custom\": \"カスタム\",\n      \"pick\": \"日付を選択\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"トークンを編集\",\n    \"name\": \"名前\",\n    \"scopes\": \"スコープ\",\n    \"selectAll\": \"全選択\",\n    \"cancelSelectAll\": \"全選択解除\"\n  },\n  \"refresh\": {\n    \"title\": \"個人アクセストークンを再生成する\",\n    \"description\": \"このフォームを送信すると新しいトークンが生成されます。このトークンを使用するスクリプトやアプリケーションは更新する必要があることに注意してください。\",\n    \"button\": \"トークンを再生成\"\n  },\n  \"accessSelect\": {\n    \"button\": \"ベースまたはスペースを追加\",\n    \"empty\": \"アクセスが見つかりません。\",\n    \"spaceSelectItem\": \"スペースにあるすべてのベース\",\n    \"inputPlaceholder\": \"スペースまたはベースを検索...\"\n  },\n  \"moreScopes\": \"および {{len}} その他\",\n  \"list\": {\n    \"description\": \"個人アクセストークンは、Teable APIを使用するために必要です。詳細については、<a>ヘルプドキュメント</a>を参照してください。\"\n  },\n  \"empty\": {\n    \"list\": \"個人アクセストークンが見つかりません。\",\n    \"access\": \"アクセス不可\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"このトークンを削除してもよろしいですか？\",\n    \"description\": \"このトークンを使用しているアプリケーションやスクリプトは、Teable APIにアクセスできなくなります。この操作を元に戻すことはできません。\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ja/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"期待される入力 {{expected}}、受信 {{received}}\",\n    \"invalid_type_received_undefined\": \"必須\",\n    \"invalid_type_received_null\": \"必須\",\n    \"invalid_literal\": \"無効なリテラル値、{{expected}} が必要です\",\n    \"unrecognized_keys\": \"オブジェクト内の認識されないキー: {{- keys}}\",\n    \"invalid_union\": \"無効な入力\",\n    \"invalid_union_discriminator\": \"識別子の値が無効です。{{- options}} が必要です。\",\n    \"invalid_enum_value\": \"無効な列挙値です。{{- options}} が必要ですが、'{{received}}' を受け取りました\",\n    \"invalid_arguments\": \"関数の引数が無効です\",\n    \"invalid_return_type\": \"関数の戻り値の型が無効です\",\n    \"invalid_date\": \"日付が無効です\",\n    \"custom\": \"無効な入力です\",\n    \"invalid_intersection_types\": \"交差結果を結合できませんでした\",\n    \"not_multiple_of\": \"数字は {{multipleOf}} の倍数にしてください\",\n    \"not_finite\": \"数は有限にしてください\",\n    \"invalid_string\": {\n      \"email\": \"無効な {{validation}}\",\n      \"url\": \"無効な {{validation}}\",\n      \"uuid\": \"無効な {{validation}}\",\n      \"cuid\": \"無効な {{validation}}\",\n      \"regex\": \"無効\",\n      \"datetime\": \"無効な {{validation}}\",\n      \"startsWith\": \"無効な入力: \\\"{{startsWith}}\\\" で始まる必要があります\",\n      \"endsWith\": \"無効な入力: \\\"{{endsWith}}\\\" で終わる必要があります\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"配列には、正確に {{minimum}} 個の要素が含まれている必要があります\",\n        \"inclusive\": \"配列には少なくとも {{minimum}} 個の要素が含まれている必要があります\",\n        \"not_inclusive\": \"配列には {{minimum}} 個以上の要素が含まれている必要があります\"\n      },\n      \"string\": {\n        \"exact\": \"文字列には、正確に {{minimum}} 文字が含まれている必要があります\",\n        \"inclusive\": \"文字列には少なくとも {{minimum}} 文字が含まれている必要があります\",\n        \"not_inclusive\": \"文字列には {{minimum}} 文字以上を含める必要があります\"\n      },\n      \"number\": {\n        \"exact\": \"数値は正確に {{minimum}} である必要があります\",\n        \"inclusive\": \"数値は {{minimum}} 以上である必要があります\",\n        \"not_inclusive\": \"数値は {{minimum}} より大きくなければなりません\"\n      },\n      \"set\": {\n        \"exact\": \"無効な入力\",\n        \"inclusive\": \"無効な入力\",\n        \"not_inclusive\": \"無効な入力\"\n      },\n      \"date\": {\n        \"exact\": \"日付は正確に {{- minimum, datetime}} である必要があります\",\n        \"inclusive\": \"日付は {{- minimum, datetime}} 以後である必要があります\",\n        \"not_inclusive\": \"日付は {{- minimum, datetime}} より後でなければなりません\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"配列には、正確に {{maximum}} 個の要素を含める必要があります\",\n        \"inclusive\": \"配列には {{maximum}} 個以下の要素を含める必要があります\",\n        \"not_inclusive\": \"配列には {{maximum}} 個未満の要素を含める必要があります\"\n      },\n      \"string\": {\n        \"exact\": \"文字列には正確に {{maximum}} 文字を含める必要があります\",\n        \"inclusive\": \"文字列には {{maximum}} 文字以下である必要があります\",\n        \"not_inclusive\": \"文字列は {{maximum}} 文字未満である必要があります\"\n      },\n      \"number\": {\n        \"exact\": \"数値は正確に {{maximum}} である必要があります\",\n        \"inclusive\": \"数値は {{maximum}} 以下である必要があります\",\n        \"not_inclusive\": \"数値は {{maximum}} 未満である必要があります\"\n      },\n      \"set\": {\n        \"exact\": \"無効な入力\",\n        \"inclusive\": \"無効な入力\",\n        \"not_inclusive\": \"無効な入力\"\n      },\n      \"date\": {\n        \"exact\": \"日付は正確に {{- maximum, datetime}} である必要があります\",\n        \"inclusive\": \"日付は {{- maximum, datetime}} 以前である必要があります\",\n        \"not_inclusive\": \"日付は {{- maximum, datetime}} より前でなければなりません\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"メール\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"正規表現\",\n    \"datetime\": \"日時\"\n  },\n  \"types\": {\n    \"function\": \"関数\",\n    \"number\": \"数字\",\n    \"string\": \"文字列\",\n    \"nan\": \"非数\",\n    \"integer\": \"整数\",\n    \"float\": \"フロート\",\n    \"boolean\": \"真偽値\",\n    \"date\": \"日付\",\n    \"bigint\": \"大桁整数\",\n    \"undefined\": \"未定義\",\n    \"symbol\": \"シンボル\",\n    \"null\": \"null\",\n    \"array\": \"配列\",\n    \"object\": \"オブジェクト\",\n    \"unknown\": \"不明\",\n    \"promise\": \"promise\",\n    \"void\": \"void\",\n    \"never\": \"never\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/auth.json",
    "content": "{\n  \"page\": {\n    \"title\": \"Вход\"\n  },\n  \"button\": {\n    \"signin\": \"Войти\",\n    \"signup\": \"Зарегистрироваться\"\n  },\n  \"label\": {\n    \"email\": \"Электронная почта\",\n    \"password\": \"Пароль\"\n  },\n  \"legal\": {\n    \"tip\": \"Продолжая, вы соглашаетесь с <Terms>Условиями использования</Terms> и <Privacy>Политикой конфиденциальности</Privacy> Teable, и получаете периодические электронные письма с обновлениями.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"placeholder\": {\n    \"password\": \"Введите ваш пароль...\",\n    \"email\": \"Введите вашу электронную почту...\"\n  },\n  \"signError\": {\n    \"exist\": \"Электронная почта уже зарегистрирована\",\n    \"incorrect\": \"Электронная почта или пароль неверны\",\n    \"tooManyRequests\": \"Ваш аккаунт заблокирован, попробуйте снова через {{minutes}} минут\",\n    \"turnstileRequired\": \"Пожалуйста, пройдите проверку безопасности\",\n    \"turnstileError\": \"Проверка не пройдена. Попробуйте снова\",\n    \"turnstileExpired\": \"Проверка истекла. Попробуйте снова\",\n    \"turnstileTimeout\": \"Время проверки истекло. Попробуйте снова\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Установите ваш пароль\",\n    \"description\": \"Введите новый пароль\",\n    \"label\": \"Новый пароль\",\n    \"error\": {\n      \"requiredPassword\": \"Введите пароль\",\n      \"invalidLink\": \"Неверная ссылка для сброса пароля\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Пароль успешно сброшен\",\n      \"description\": \"Ваш пароль был успешно сброшен. Вы будете перенаправлены на страницу входа.\"\n    },\n    \"buttonText\": \"Подтвердить сброс пароля\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Забыли пароль?\",\n    \"header\": \"Сброс пароля\",\n    \"description\": \"Пожалуйста, введите ваш адрес электронной почты ниже, и мы отправим вам ссылку для сброса пароля.\",\n    \"errorRequiredEmail\": \"Необходима электронная почта\",\n    \"errorInvalidEmail\": \"Неверная электронная почта\",\n    \"buttonText\": \"Отправить ссылку для сброса пароля\",\n    \"success\": {\n      \"title\": \"🎉 Письмо для сброса пароля отправлено\",\n      \"description\": \"Мы отправили вам письмо со ссылкой для сброса пароля. Пожалуйста, проверьте ваш почтовый ящик.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/chart.json",
    "content": "{\n  \"notBaseId\": \"Отсутствует baseId\",\n  \"notPositionId\": \"Отсутствует positionId\",\n  \"notPluginInstallId\": \"Отсутствует pluginInstallId\",\n  \"initBridge\": \"Инициализация моста...\",\n  \"actions\": {\n    \"cancel\": \"Отмена\",\n    \"save\": \"Сохранить\"\n  },\n  \"queryTitle\": \"Конфигурация запроса данных\",\n  \"notSupport\": \"Не поддерживается\",\n  \"chart\": {\n    \"bar\": \"Столбчатая\",\n    \"line\": \"Линейная\",\n    \"pie\": \"Круговая\",\n    \"area\": \"Область\",\n    \"table\": \"Таблица\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Выберите тип диаграммы\",\n      \"label\": \"Тип диаграммы\"\n    },\n    \"pie\": {\n      \"dimension\": \"Измерение\",\n      \"measure\": \"Мера\",\n      \"showTotal\": \"Показать общую сумму\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"Ось X\",\n        \"placeholder\": \"Выберите ось X\"\n      },\n      \"yAxis\": {\n        \"label\": \"Ось Y\",\n        \"placeholder\": \"Выберите ось Y\",\n        \"position\": \"Положение оси Y\"\n      },\n      \"xDisplay\": {\n        \"label\": \"Отображение X\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Отображение Y\"\n      },\n      \"addXAxis\": \"Добавить разбивку серий\",\n      \"addYAxis\": \"Добавить другую серию\",\n      \"stack\": \"Наложение\",\n      \"position\": {\n        \"label\": \"Положение\",\n        \"auto\": \"Автоматически\",\n        \"left\": \"Слева\",\n        \"right\": \"Справа\"\n      },\n      \"goalLine\": {\n        \"label\": \"Целевая линия\"\n      },\n      \"range\": {\n        \"label\": \"Диапазон\",\n        \"min\": \"Минимум\",\n        \"max\": \"Максимум\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Стиль линии\",\n        \"normal\": \"Обычный\",\n        \"linear\": \"Линейный\",\n        \"step\": \"Ступенчатый\"\n      },\n      \"displayType\": \"Тип отображения\"\n    },\n    \"typeError\": \"Форма: Неподдерживаемый тип диаграммы\",\n    \"updateQuery\": \"Обновить запрос\",\n    \"queryError\": \"Ошибка запроса\",\n    \"querySuccess\": \"Запрос настроен\",\n    \"decimal\": \"Десятичные\",\n    \"prefix\": \"Префикс\",\n    \"suffix\": \"Суффикс\",\n    \"showLabel\": \"Показать метки значений на диаграмме\",\n    \"showLegend\": \"Показать легенду\",\n    \"value\": \"Значение\",\n    \"label\": \"Метка\",\n    \"padding\": {\n      \"label\": \"Отступ\",\n      \"top\": \"Сверху\",\n      \"right\": \"Справа\",\n      \"bottom\": \"Снизу\",\n      \"left\": \"Слева\"\n    },\n    \"tableConfig\": \"Конфигурация таблицы\",\n    \"width\": \"Ширина\"\n  },\n  \"reloadQuery\": \"Перезагрузить запрос\",\n  \"noStorage\": \"Пожалуйста, сначала настройте плагин диаграмм\",\n  \"noPermission\": \"Нет разрешения на доступ\",\n  \"goConfig\": \"Перейти к настройке\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"Действия\",\n    \"add\": \"Добавить\",\n    \"save\": \"Сохранить\",\n    \"doNotSave\": \"Не сохранять\",\n    \"submit\": \"Отправить\",\n    \"confirm\": \"Подтвердить\",\n    \"close\": \"Закрыть\",\n    \"edit\": \"Редактировать\",\n    \"fill\": \"Заполнить\",\n    \"update\": \"Обновить\",\n    \"create\": \"Создать\",\n    \"delete\": \"Удалить\",\n    \"cancel\": \"Отмена\",\n    \"zoomIn\": \"Увеличить\",\n    \"zoomOut\": \"Уменьшить\",\n    \"back\": \"Назад\",\n    \"remove\": \"Удалить\",\n    \"removeConfig\": \"Удалить конфигурацию\",\n    \"saveSucceed\": \"Сохранение успешно!\",\n    \"submitSucceed\": \"Отправка успешно!\",\n    \"editSucceed\": \"Редактирование успешно!\",\n    \"updateSucceed\": \"Обновление успешно!\",\n    \"deleteSucceed\": \"Удаление успешно!\",\n    \"resetSucceed\": \"Очистка корзины успешно!\",\n    \"restoreSucceed\": \"Восстановление успешно!\",\n    \"loading\": \"Загрузка...\",\n    \"refreshPage\": \"Обновить страницу\",\n    \"yesDelete\": \"Да, удалить\",\n    \"rename\": \"Переименовать\",\n    \"duplicate\": \"Дублировать\",\n    \"change\": \"Изменить\",\n    \"upgrade\": \"Обновить\",\n    \"upgradeToLevel\": \"Обновить до {{level}}\",\n    \"search\": \"Поиск\",\n    \"loadMore\": \"Загрузить больше\",\n    \"collapseSidebar\": \"Свернуть боковую панель\",\n    \"restore\": \"Восстановить\",\n    \"permanentDelete\": \"Удалить навсегда\",\n    \"globalSearch\": \"Глобальный поиск\",\n    \"fieldSearch\": \"Поиск по полю\",\n    \"tableIndex\": \"Индекс\",\n    \"showAllRow\": \"Показать все строки\",\n    \"hideNotMatchRow\": \"Скрыть несоответствующие строки\",\n    \"more\": \"Больше\",\n    \"expand\": \"Развернуть\",\n    \"move\": \"Переместить в\",\n    \"turnOn\": \"Включить\",\n    \"exit\": \"Выйти\",\n    \"next\": \"Следующая\",\n    \"previous\": \"Предыдущая\",\n    \"select\": \"Выбрать\",\n    \"view\": \"Просмотр\",\n    \"preview\": \"Предпросмотр\",\n    \"viewAndEdit\": \"Просмотр и редактирование\",\n    \"deleteTip\": \"Вы уверены, что хотите удалить \\\"{{name}}\\\"?\",\n    \"refresh\": \"Обновить\",\n    \"login\": \"Войти\",\n    \"useTemplate\": \"Использовать шаблон\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Вернуться в пространство\",\n    \"switchBase\": \"Переключить базу\",\n    \"continue\": \"Продолжить\",\n    \"export\": \"Экспорт\",\n    \"import\": \"Импорт\",\n    \"getMore\": \"Получить больше\",\n    \"copySuccess\": \"Копирование успешно\"\n  },\n  \"quickAction\": {\n    \"title\": \"Быстрые действия\",\n    \"placeHolder\": \"Введите команду или выполните поиск...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"Пароль недействителен, должен содержать не менее 8 символов и хотя бы одну букву и одну цифру.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Поделиться\",\n      \"copy\": \"Скопировано\"\n    },\n    \"aiTitle\": \"Давайте создадим вместе\",\n    \"aiGreeting\": \"Чем я могу помочь, {{name}}?\",\n    \"aiSubTitle\": \"Первая AI-платформа, где команды сотрудничают над данными и создают производственные приложения\",\n    \"guideTitle\": \"Начните со своего сценария\",\n    \"watchVideo\": \"Смотреть видео\",\n    \"title\": \"Шаблон\",\n    \"description\": \"Создать новую базу из шаблона\",\n    \"browseAll\": \"Просмотреть Все\",\n    \"templateTitle\": \"Начать с шаблона\",\n    \"loadMore\": \"Загрузить Больше\",\n    \"allTemplatesLoaded\": \"Все шаблоны загружены\",\n    \"createTemplate\": \"Создать Шаблон\",\n    \"useTemplateDialog\": {\n      \"title\": \"Выберите пространство\",\n      \"description\": \"Выберите пространство для шаблона\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Создайте бизнес-приложение с Teable\",\n      \"start\": \"Начать\",\n      \"carouselGuides\": {\n        \"guide1\": \"Вставьте чеки → Попросите Teable автоматически извлечь ключевые данные\",\n        \"guide2\": \"Создайте AI CRM с таблицами лидов и отслеживания\",\n        \"guide3\": \"Вставьте отзывы клиентов → Попросите Teable найти инсайты и создать отчеты\",\n        \"guide4\": \"Создайте трекер проектов с задачами и сроками\",\n        \"guide5\": \"Вставьте таблицу лидов → Попросите Teable создать умную CRM\",\n        \"guide6\": \"Создайте планировщик контента с постами и датами публикации\",\n        \"guide7\": \"Вставьте резюме → Попросите Teable организовать и отобрать кандидатов\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Копировать в пространство\",\n      \"description\": \"Выберите пространство для копирования этой базы\",\n      \"baseName\": \"Название базы\",\n      \"baseNamePlaceholder\": \"Введите название базы\",\n      \"selectSpace\": \"Выбрать пространство\",\n      \"noSpaceDescription\": \"Нет доступных пространств. Создайте новое пространство, чтобы продолжить.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"В этом пространстве нет доступных баз. Выберите «Новая база».\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Настройки экземпляра\",\n    \"personal\": {\n      \"title\": \"Личные настройки\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"Управление шаблонами\",\n      \"noData\": \"Нет данных\",\n      \"importing\": \"Импорт...\",\n      \"usageCount\": \"Количество использований: {{count}}\",\n      \"useTemplate\": \"Использовать этот шаблон\",\n      \"createdBy\": \"от {{user}}\",\n      \"backToTemplateList\": \"Вернуться к списку шаблонов\",\n      \"tips\": {\n        \"errorCategoryName\": \"Категория не существует или была удалена\",\n        \"needSnapshot\": \"Перед публикацией создайте снимок, название и описание шаблона не могут быть пустыми\",\n        \"needPublish\": \"Опубликуйте шаблон перед установкой в рекомендуемые\",\n        \"needBaseSource\": \"Выберите базу-источник перед созданием снимка\",\n        \"forbiddenUpdateSystemTemplate\": \"Системный шаблон нельзя изменить\",\n        \"addCategoryTips\": \"Сначала введите название категории в поле поиска.\",\n        \"categoryNamePlaceholder\": \"Введите название категории\",\n        \"duplicateCategoryName\": \"Название категории уже существует\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Начать\",\n          \"recommended\": \"Рекомендуемые\",\n          \"all\": \"Все\",\n          \"browseByCategory\": \"Просмотр по категориям\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Обложка\",\n        \"name\": \"Название\",\n        \"description\": \"Описание\",\n        \"markdownDescription\": \"Описание в markdown\",\n        \"category\": \"Категория\",\n        \"isSystem\": \"Системный\",\n        \"source\": \"Источник\",\n        \"status\": \"Опубликован\",\n        \"publishSnapshot\": \"Опубликовать снимок\",\n        \"snapshotTime\": \"Время снимка\",\n        \"actions\": \"Действия\",\n        \"featured\": \"Рекомендуемый\",\n        \"createdBy\": \"Создан\",\n        \"userNonExistent\": \"Пользователь не существует\",\n        \"preview\": \"Предпросмотр\",\n        \"usage\": \"Использование\",\n        \"visit\": \"Посещения\"\n      },\n      \"actions\": {\n        \"title\": \"Действия\",\n        \"publish\": \"Опубликовать\",\n        \"delete\": \"Удалить\",\n        \"duplicate\": \"Дублировать\",\n        \"preview\": \"Предпросмотр\",\n        \"use\": \"Использовать\",\n        \"pinTop\": \"Закрепить сверху\",\n        \"addCategory\": \"Добавить категорию\",\n        \"selectCategory\": \"Выбрать категорию\",\n        \"viewTemplate\": \"Просмотреть шаблон\",\n        \"manageCategory\": \"Управление категориями\"\n      },\n      \"relatedTemplates\": \"Связанные шаблоны\",\n      \"noImage\": \"Нет изображения\",\n      \"baseSelectPanel\": {\n        \"title\": \"Выберите источник шаблона\",\n        \"description\": \"Выберите базу для шаблона\",\n        \"confirm\": \"Подтвердить\",\n        \"search\": \"Поиск...\",\n        \"cancel\": \"Отмена\",\n        \"selectBase\": \"Выбрать базу\",\n        \"createTemplate\": \"Создать шаблон\",\n        \"abnormalBase\": \"База не существует или была удалена\"\n      }\n    },\n    \"back\": \"Назад на главную\",\n    \"account\": {\n      \"title\": \"Мой профиль\",\n      \"tab\": \"Моя учетная запись\",\n      \"updatePhoto\": \"Обновить фото\",\n      \"updateNameDesc\": \"Никнейм или имя — то, как вы хотите, чтобы к вам обращались в Teable\",\n      \"securityTitle\": \"Безопасность учетной записи\",\n      \"email\": \"Эл. почта\",\n      \"password\": \"Пароль\",\n      \"passwordDesc\": \"Установите постоянный пароль для входа в свою учетную запись.\",\n      \"changePassword\": {\n        \"title\": \"Смена пароля\",\n        \"desc\": \"Введите текущий пароль и задайте новый\",\n        \"current\": \"Текущий пароль\",\n        \"new\": \"Новый пароль\",\n        \"confirm\": \"Подтвердите новый пароль\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Ваш новый пароль не совпадает.\",\n        \"equal\": \"Новый пароль должен отличаться от текущего.\",\n        \"invalid\": \"Ваш текущий пароль неверен.\",\n        \"invalidNew\": \"Ваш новый пароль неверен, минимум 8 символов.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Пароль успешно изменен.\",\n        \"desc\": \"Вы будете перенаправлены на страницу входа через 2 секунды.\"\n      },\n      \"manageToken\": \"Токен доступа\",\n      \"addPassword\": {\n        \"title\": \"Добавить пароль\",\n        \"desc\": \"Установите постоянный пароль для входа в свою учетную запись.\",\n        \"password\": \"Введите пароль\",\n        \"confirm\": \"Подтвердите пароль\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Пароли не совпадают.\",\n        \"invalid\": \"Пароль недействителен, минимум 8 символов.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Пароль успешно добавлен.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Удалить учетную запись\",\n        \"desc\": \"Это действие необратимо. Ваша учетная запись и все связанные данные будут удалены навсегда.\",\n        \"error\": {\n          \"title\": \"Невозможно удалить учетную запись\",\n          \"desc\": \"Сначала необходимо обработать следующие зависимости:\",\n          \"spacesError\": \"Перед удалением учетной записи необходимо сначала выйти (или удалить, а затем переместить в корзину) из ваших пространств.\"\n        },\n        \"confirm\": {\n          \"title\": \"Введите <code>DELETE</code> для подтверждения\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Удаление...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Сменить адрес электронной почты\",\n        \"desc\": \"Пожалуйста, подтвердите пароль и новый адрес электронной почты\",\n        \"current\": \"Текущий пароль\",\n        \"new\": \"Новая почта\",\n        \"code\": \"Код подтверждения\",\n        \"getCode\": \"Отправить код\",\n        \"error\": {\n          \"invalidCode\": \"Неверный код подтверждения.\",\n          \"invalidPassword\": \"Неверный пароль.\",\n          \"invalidConflict\": \"Новый адрес почты уже зарегистрирован.\",\n          \"invalidSameEmail\": \"Новый адрес почты совпадает с текущим.\",\n          \"sendMailRateLimit\": \"Подождите {{seconds}} сек. перед повторной отправкой письма\"\n        },\n        \"success\": {\n          \"title\": \"🎉 Адрес почты успешно изменен.\",\n          \"desc\": \"Вы будете перенаправлены на страницу входа через 2 сек.\",\n          \"sendSuccess\": \"Код подтверждения успешно отправлен.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Мои уведомления\",\n      \"label\": \"Активность в вашем пространстве\",\n      \"desc\": \"Получайте уведомления по электронной почте, когда получаете комментарии, упоминания, приглашения на страницы, напоминания, запросы на доступ и изменения свойств.\"\n    },\n    \"setting\": {\n      \"title\": \"Мои настройки\",\n      \"theme\": \"Тема\",\n      \"themeDesc\": \"Выберите тему для приложения.\",\n      \"dark\": \"Темная\",\n      \"light\": \"Светлая\",\n      \"system\": \"Системная\",\n      \"version\": \"Версия приложения\",\n      \"language\": \"Язык\",\n      \"interactionMode\": \"Режим взаимодействия\",\n      \"mouseMode\": \"Режим курсора\",\n      \"touchMode\": \"Сенсорный режим\",\n      \"systemMode\": \"Следовать системе\"\n    },\n    \"nav\": {\n      \"settings\": \"Настройки\",\n      \"logout\": \"Выйти\",\n      \"contactSupport\": \"Связаться с поддержкой\"\n    },\n    \"integration\": {\n      \"title\": \"Интеграции\",\n      \"description\": \"Вы предоставили доступ к своей учетной записи {{count}} приложениям.\",\n      \"lastUsed\": \"Последний раз использовано {{date}}\",\n      \"revoke\": \"Отозвать\",\n      \"owner\": \"Владелец {{user}}\",\n      \"revokeTitle\": \"Вы уверены, что хотите отозвать авторизацию?\",\n      \"revokeDesc\": \"{{name}} больше не сможет использовать API Teable. Это действие невозможно отменить.\",\n      \"scopeTitle\": \"Разрешения\",\n      \"scopeDesc\": \"Это приложение сможет получить следующие разрешения:\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Таблица\",\n    \"view\": \"Представление\",\n    \"space\": \"Пространство\",\n    \"base\": \"База\",\n    \"field\": \"Поле\",\n    \"record\": \"Запись\",\n    \"dashboard\": \"Панель\",\n    \"automation\": \"Автоматизация\",\n    \"authorityMatrix\": \"Матрица полномочий\",\n    \"design\": \"Дизайн\",\n    \"adminPanel\": \"Системное администрирование\",\n    \"license\": \"Самостоятельная лицензия\",\n    \"instanceId\": \"ID экземпляра\",\n    \"beta\": \"Бета\",\n    \"trash\": \"Корзина\",\n    \"global\": \"Глобальный\",\n    \"organizationPanel\": \"Настройки организации\",\n    \"unknownError\": \"Неизвестная ошибка\",\n    \"pluginPanel\": \"Панель\",\n    \"pluginContextMenu\": \"Контекстное меню\",\n    \"plugin\": \"Плагины\",\n    \"copy\": \"Копия\",\n    \"credits\": \"Кредиты\",\n    \"aiChat\": \"ИИ-чат\",\n    \"app\": \"Приложение\",\n    \"webSearch\": \"Веб-поиск\",\n    \"folder\": \"Папка\",\n    \"newAutomation\": \"Новая автоматизация\",\n    \"newApp\": \"Новое приложение\",\n    \"newFolder\": \"Новая папка\",\n    \"template\": \"Шаблон\"\n  },\n  \"level\": {\n    \"free\": \"Бесплатно\",\n    \"plus\": \"Плюс\",\n    \"pro\": \"Профи\",\n    \"business\": \"Бизнес\",\n    \"enterprise\": \"Предприятие\"\n  },\n  \"noResult\": \"Нет результата.\",\n  \"allNodes\": \"Все узлы\",\n  \"noDescription\": \"Нет описания\",\n  \"untitled\": \"Без названия\",\n  \"name\": \"Имя\",\n  \"description\": \"Описание\",\n  \"required\": \"Обязательно\",\n  \"characters\": \"символов\",\n  \"atLeastOne\": \"Сохраните хотя бы один {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Назад\",\n    \"next\": \"Далее\",\n    \"done\": \"Готово\",\n    \"skip\": \"Пропустить\",\n    \"createSpaceTooltipTitle\": \"Создать пространство\",\n    \"createSpaceTooltipContent\": \"Teable организован в пространства, где каждый может приглашать пользователей для совместной работы. <br></br> Пространства в Teable служат основным элементом навигации в меню, предоставляя платформу для добавления и управления базами данных по мере необходимости.\",\n    \"createBaseTooltipTitle\": \"Создать базу\",\n    \"createBaseTooltipContent\": \"База данных — это место для хранения важных данных и рабочих процессов, которые зависят от них.\",\n    \"createTableTooltipTitle\": \"Создать таблицу\",\n    \"createTableTooltipContent\": \"Таблицы предназначены для эффективной обработки различных наборов данных, предлагая универсальное представление информации через различные типы данных.\",\n    \"createViewTooltipTitle\": \"Создать представление\",\n    \"createViewTooltipContent\": \"В настоящее время пользователи могут создавать представления Сетка, Галерея, Канбан и Форма, а представления Календарь будут добавлены в будущих версиях. <br></br> Этот набор инструментов обеспечивает пользователям все необходимое для различных задач управления данными.\",\n    \"viewFilteringTooltipTitle\": \"Фильтрация записей\",\n    \"viewFilteringTooltipContent\": \"Одной из основных функций представлений является возможность фильтрации записей по заданным условиям. <br></br> Когда запись фильтруется, она не удаляется — она просто скрывается из данного представления.\",\n    \"viewSortingTooltipTitle\": \"Сортировка записей\",\n    \"viewSortingTooltipContent\": \"Находясь в представлении, вы можете сортировать записи по определённым значениям в полях. <br></br> Сортировка в одном представлении не влияет на порядок записей в других представлениях — она применяется только к текущему представлению.\",\n    \"viewGroupingTooltipTitle\": \"Группировка записей\",\n    \"viewGroupingTooltipContent\": \"Группировка записей позволяет создавать набор условий для классификации набора данных, отображаемого в конкретном представлении.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable предлагает мощный API, поддерживающий практически все функции продукта, что позволяет разработчикам выполнять запросы с использованием <a>токена</a>.\"\n  },\n  \"token\": \"Токен\",\n  \"poweredBy\": \"Работает на <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"Поделиться пространством {{spaceName}}\",\n      \"desc_one\": \"В этом пространстве <b>{{count}} сотрудник</b>. Добавление сотрудника в пространство даст ему доступ ко всем базам в этом пространстве.\",\n      \"desc_other\": \"В этом пространстве <b>{{count}} сотрудников</b>. Добавление сотрудника в пространство даст ему доступ ко всем базам в этом пространстве.\",\n      \"tabEmail\": \"Пригласить по электронной почте\",\n      \"emailPlaceholder\": \"Пригласить больше сотрудников по электронной почте\",\n      \"tabLink\": \"Пригласить по ссылке\",\n      \"linkPlaceholder\": \"Создать ссылку приглашения, которая предоставит <0/> доступ любому, кто её откроет.\",\n      \"emailSend\": \"Отправить приглашение\",\n      \"linkSend\": \"Создать ссылку\",\n      \"spaceTitle\": \"Сотрудники пространства\",\n      \"collaboratorSearchPlaceholder\": \"Найдите сотрудника пространства по имени или электронной почте\",\n      \"collaboratorJoin\": \"присоединился {{joinTime}}\",\n      \"collaboratorRemove\": \"Удалить сотрудника\",\n      \"linkTitle\": \"Ссылки приглашения\",\n      \"linkCreatedTime\": \"создано {{createdTime}}\",\n      \"linkCopySuccess\": \"Ссылка скопирована\",\n      \"linkRemove\": \"Удалить ссылку\"\n    },\n    \"base\": {\n      \"title\": \"Поделиться базой {{baseName}}\",\n      \"desc_one\": \"Эта база поделена с {{count}} сотрудником.\",\n      \"desc_other\": \"Эта база поделена с {{count}} сотрудниками.\",\n      \"baseTitle\": \"Сотрудники базы\",\n      \"collaboratorSearchPlaceholder\": \"Найдите сотрудника базы по имени или электронной почте\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Помощь\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Разрешения страницы были обновлены. Пожалуйста, обновите страницу, чтобы увидеть последние изменения.\",\n  \"listEmptyTips\": \"Список пуст\",\n  \"billing\": {\n    \"overLimits\": \"Превышены лимиты\",\n    \"overLimitsDescription\": \"Ваш текущий тарифный план превысил лимит использования. Пожалуйста, обновите план, чтобы продолжить использование этой функции без прерываний.\",\n    \"userLimitExceededDescription\": \"Текущая лицензия достигла максимального количества пользователей. Пожалуйста, отключите некоторых пользователей или обновите лицензию.\",\n    \"unavailableInPlanTips\": \"Текущий тарифный план не поддерживает эту функцию\",\n    \"unavailableConnectionTips\": \"Функция подключения к базе данных будет удалена в будущем и будет доступна только в тарифном плане Enterprise и в самохостинг версии.\",\n    \"levelTips\": \"Это пространство сейчас на тарифе {{level}}\",\n    \"enterpriseFeature\": \"Функция Enterprise\",\n    \"automationRequiresUpgrade\": \"Обновитесь до Enterprise Edition (EE) чтобы включить автоматизацию\",\n    \"authorityMatrixRequiresUpgrade\": \"Обновитесь до Enterprise Edition (EE) чтобы включить матрицу полномочий\",\n    \"viewPricing\": \"Посмотреть цены\",\n    \"billable\": \"Платный\",\n    \"billableByAuthorityMatrix\": \"Биллинг, созданный матрицей полномочий\",\n    \"licenseExpiredGracePeriod\": \"Срок действия вашей лицензии для самостоятельного размещения истек и будет понижена до бесплатного плана {{expiredTime}}. Пожалуйста, обновите лицензию, чтобы сохранить доступ к премиум-функциям.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Обновить тарифный план пространства\",\n      \"description\": \"Вы можете обновить только те пространства, где вы являетесь владельцем\"\n    },\n    \"status\": {\n      \"active\": \"Активно\",\n      \"canceled\": \"Отменено\",\n      \"incomplete\": \"Не завершено\",\n      \"incompleteExpired\": \"Срок не завершён\",\n      \"trialing\": \"Пробный период\",\n      \"pastDue\": \"Просрочено\",\n      \"unpaid\": \"Не оплачено\",\n      \"paused\": \"Приостановлено\",\n      \"seatLimitExceeded\": \"Превышено количество мест\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"Настройки экземпляра\",\n      \"description\": \"Измените настройки для текущего экземпляра\",\n      \"allowSignUp\": \"Разрешить создание новых аккаунтов\",\n      \"allowSignUpDescription\": \"Отключение этой опции запретит регистрацию новых пользователей, и кнопка регистрации больше не будет отображаться на странице входа.\",\n      \"allowSpaceInvitation\": \"Разрешить приглашения в пространство\",\n      \"allowSpaceInvitationDescription\": \"Отключение этой опции запретит пользователям, кроме администраторов, приглашать других в пространство. При включении новые пользователи, приглашённые по электронной почте, могут завершить регистрацию, нажав на ссылку-приглашение в письме, но общие ссылки-приглашения не будут работать.\",\n      \"allowSpaceCreation\": \"Разрешить всем создавать новые пространства\",\n      \"allowSpaceCreationDescription\": \"Отключение этой опции запретит пользователям, кроме администраторов, создавать новые пространства.\",\n      \"enableEmailVerification\": \"Включить проверку электронной почты\",\n      \"enableEmailVerificationDescription\": \"Включение этой опции потребует от пользователей проверять свою электронную почту при создании нового аккаунта.\",\n      \"enableWaitlist\": \"Включить список ожидания\",\n      \"enableWaitlistDescription\": \"Включение этой опции позволит пользователям регистрироваться только с помощью приглашения.\",\n      \"generalSettings\": \"Общие настройки\",\n      \"aiSettings\": {\n        \"title\": \"AI настройки\",\n        \"description\": \"Настроить AI параметры для этого экземпляра\"\n      },\n      \"brandingSettings\": {\n        \"title\": \"Настройки брендинга\",\n        \"description\": \"Доступно только в корпоративной версии\",\n        \"brandName\": \"Название бренда\",\n        \"logo\": \"Логотип\",\n        \"logoDescription\": \"Логотип - это ваша фирменная идентичность в Teable.\",\n        \"logoUpload\": \"Загрузить логотип\",\n        \"logoUploadDescription\": \"Загрузите изображение логотипа, поддерживает форматы PNG, JPEG, рекомендуемый размер 100x100px. Максимальный размер загрузки 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Имя\",\n        \"nameDescription\": \"Имя LLM провайдера\",\n        \"enable\": \"Включить AI\",\n        \"enableDescription\": \"Включить AI для текущего экземпляра, все пользователи смогут использовать функции AI\",\n        \"updateLLMProvider\": \"Обновить LLM провайдера\",\n        \"addProvider\": \"Добавить LLM провайдера\",\n        \"addProviderDescription\": \"Добавить новый LLM провайдера в список\",\n        \"providerType\": \"Тип провайдера\",\n        \"baseUrl\": \"Базовый URL\",\n        \"apiKey\": \"API ключ\",\n        \"baseUrlDescription\": \"Базовый URL LLM провайдера\",\n        \"apiKeyDescription\": \"API ключ LLM провайдера\",\n        \"models\": \"Модели\",\n        \"modelsDescription\": \"Модели, поддерживаемые LLM провайдером\",\n        \"baseUrlRequired\": \"Базовый URL обязателен\",\n        \"fetchModelListError\": \"Не удалось получить список моделей\",\n        \"provider\": \"LLM провайдер\",\n        \"providerDescription\": \"LLM провайдер для использования\",\n        \"modelPreferences\": \"Модель предпочтения\",\n        \"modelPreferencesDescription\": \"Предпочтения модели для LLM провайдера\",\n        \"embeddingModel\": \"Модель встраивания\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Модель перевода\",\n        \"translationModelDescription\": \"Модель перевода для использования\",\n        \"chatModel\": \"Модель чата\",\n        \"chatModelDescription\": \"Модель чата для использования, Подсказка: средняя модель кодирования по умолчанию используется для генерации AI формул и связанных функций\",\n        \"chatModels\": {\n          \"lg\": \"Продвинутая модель чата\",\n          \"lgDescription\": \"Для планирования, программирования и других сложных задач. Рекомендуется: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"Возможности ИИ\",\n          \"aiField\": {\n            \"title\": \"ИИ поле\",\n            \"description\": \"Функции ИИ полей, включая автозаполнение и настройку ИИ в параметрах поля\"\n          },\n          \"aiChat\": {\n            \"title\": \"ИИ чат\",\n            \"description\": \"Боковая панель ИИ чата и все функции агента\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Тестировать модель\",\n          \"description\": \"Протестировать способность модели обрабатывать изображения, PDF\",\n          \"notConfigLgModel\": \"Пожалуйста, сначала настройте большую модель\",\n          \"confirmTitle\": \"Тестировать возможности модели\",\n          \"confirmDescription\": \"Хотите ли вы протестировать возможности большой модели чата?\",\n          \"confirm\": \"Тестировать модель\",\n          \"cancel\": \"Отмена\",\n          \"missingCapabilitiesWarning\": \"Эта модель не поддерживает обработку изображений или PDF, что может привести к недоступности некоторых функций.\",\n          \"enableAITitle\": \"Включить AI\",\n          \"enableAIDescription\": \"Тестирование модели завершено. AI в настоящее время не включен. Хотите ли вы включить AI для использования этих возможностей модели?\",\n          \"enableAI\": \"Включить AI\",\n          \"skipTest\": \"Пропустить\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Изображение\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Веб-поиск\",\n          \"disabledWebSearch\": \"Веб-поиск отключен\",\n          \"lgModelAbility\": \"Возможности большой модели\"\n        },\n        \"configUpdated\": \"AI настройки обновлены\",\n        \"noModelFound\": \"Модель не найдена\",\n        \"searchModel\": \"Поиск модели...\",\n        \"selectModel\": \"Выбрать модель...\",\n        \"input\": \"Ввод {{ratio}}\",\n        \"output\": \"Вывод {{ratio}}\",\n        \"inputOrOutputTip\": \"Отношение представляет собой обменный курс между кредитами и токенами, например, \\\"1:1000\\\" означает, что 1 кредит примерно равен 1000 токенам.<br></br>Примечание: каждое использование будет вычитать как минимум 1 кредит. Даже если фактическое потребление меньше 1 кредита, оно все равно будет начислено как 1 кредит.\",\n        \"imageOutput\": \"За изображение {{credits}}\",\n        \"imageOutputTip\": \"Генерация изображений требует кредитов, каждое изображение потребляет {{credits}} кредитов\",\n        \"supportImageOutputTip\": \"Эта модель поддерживает генерацию изображений\",\n        \"supportVisionTip\": \"Эта модель поддерживает ввод изображений\",\n        \"supportAudioTip\": \"Эта модель поддерживает ввод аудио\",\n        \"supportVideoTip\": \"Эта модель поддерживает ввод видео\",\n        \"supportDeepThinkTip\": \"Эта модель поддерживает глубокое мышление\",\n        \"testConnection\": \"Тест\",\n        \"testing\": \"Тестирование...\",\n        \"testSuccess\": \"Тест прошел успешно\",\n        \"testFailed\": \"Тест провален\",\n        \"fillRequiredFields\": \"Пожалуйста, заполните все обязательные поля\",\n        \"modelsRequired\": \"Пожалуйста, заполните хотя бы одну модель\",\n        \"noValidModel\": \"Не найдена допустимая модель\",\n        \"addCustomModel\": \"Добавить пользовательскую модель\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Настройте API ключ Firecrawl для включения функции веб-поиска, перейдите в <a>настройки Firecrawl</a> для получения API ключа\"\n      },\n      \"app\": {\n        \"domain\": \"Домен\",\n        \"v0ApiKey\": \"Ключ API v0\",\n        \"customDomain\": \"Пользовательский домен (необязательно)\",\n        \"customDomainDescription\": \"Настройте свой пользовательский домен для развертывания приложения, перейдите в <a>домен Vercel</a> для получения пользовательского домена\",\n        \"vercelToken\": \"Токен API Vercel\",\n        \"vercelTokenDescription\": \"Перейдите в <a>настройки Vercel</a> для получения токена API\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Введите API ключ\",\n      \"goToConfiguration\": \"Перейти к настройкам\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Спасибо за использование teable\",\n      \"pleaseGoToConfiguration\": \"Пожалуйста, перейдите на страницу настроек, чтобы завершить некоторые начальные конфигурации для наслаждения полной функциональностью и лучшим пользовательским опытом teable\",\n      \"pleaseContactAdmin\": \"Пожалуйста, свяжитесь с администратором\"\n    },\n    \"configuration\": {\n      \"title\": \"Ожидающие настройки\",\n      \"description\": \"Завершите эти настройки для получения полных функций\",\n      \"copyInstance\": \"Копировать ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"Переменная окружения PUBLIC_ORIGIN\",\n          \"description\": \"Ваша конфигурация переменной окружения <strong>PUBLIC_ORIGIN</strong> не соответствует текущему адресу доступа <underline>https://example.com</underline>, функции импорта xlsx, csv и поля вложений могут работать неправильно, рекомендуется установить на <underline>https://example.com</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Включить HTTPS\",\n          \"description\": \"Вы не включили HTTPS, функция крупномасштабного копирования (300 строк или более) будет недоступна, рекомендуется включить.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"Переменная окружения PUBLIC_DATABASE_PROXY\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> не настроен, функция подключения внешней базы данных будет недоступна, пожалуйста, обратитесь к <a>справочному документу</a>\",\n          \"href\": \"https://help.teable.ai/ru/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Вы еще не настроили AI LLM API, AI Чат/AI автоматизация не будет использоваться, <anchor>перейти к настройкам</anchor>\",\n          \"errorTips\": \"Вы еще не настроили AI LLM API, AI Чат/AI автоматизация не будет использоваться\"\n        },\n        \"app\": {\n          \"title\": \"Конструктор приложений\",\n          \"description\": \"Вы еще не настроили v0 API, функция Конструктор приложений будет недоступна, <anchor>перейти к настройкам</anchor>\",\n          \"errorTips\": \"Вы еще не настроили v0 API, функция Конструктор приложений будет недоступна\"\n        },\n        \"webSearch\": {\n          \"title\": \"Веб-поиск\",\n          \"description\": \"Вы еще не настроили API веб-поиска, функция веб-поиска будет недоступна, <anchor>перейти к настройкам</anchor>\",\n          \"errorTips\": \"Вы еще не настроили API веб-поиска, функция веб-поиска будет недоступна\"\n        },\n        \"email\": {\n          \"title\": \"Электронная почта\",\n          \"description\": \"Электронная почта не настроена, самообслуживание восстановления пароля и функция уведомлений по электронной почте будут недоступны, <anchor>перейти к настройкам</anchor>\",\n          \"errorTips\": \"Электронная почта не настроена, самообслуживание восстановления пароля, проверка электронной почты/функция уведомлений будут недоступны\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Уведомления\",\n    \"unread\": \"Непрочитанные\",\n    \"read\": \"Прочитанные\",\n    \"markAs\": \"Отметить это уведомление как {{status}}\",\n    \"markAllAsRead\": \"Отметить все как прочитанные\",\n    \"noUnread\": \"Нет {{status}} уведомлений\",\n    \"changeSetting\": \"Изменить настройки уведомлений\",\n    \"new\": \"новых {{count}}\",\n    \"showMore\": \"Показать больше\",\n    \"exportBase\": {\n      \"successText\": \"Данные экспорта готовы\",\n      \"failedText\": \"Экспорт не удался, попробуйте снова\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Владелец\",\n      \"creator\": \"Создатель\",\n      \"editor\": \"Редактор\",\n      \"commenter\": \"Комментатор\",\n      \"viewer\": \"Просмотр\"\n    },\n    \"description\": {\n      \"owner\": \"Может полностью настраивать и редактировать базы, автоматизацию, матрицы полномочий, управлять настройками пространства и оплатой\",\n      \"creator\": \"Может полностью настраивать и редактировать базы, автоматизацию и активировать матрицы полномочий\",\n      \"editor\": \"Может редактировать записи и представления, но не может настраивать таблицы или поля\",\n      \"commenter\": \"Может комментировать записи\",\n      \"viewer\": \"Не может редактировать или комментировать\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Корзина пространства\",\n    \"type\": \"Тип\",\n    \"resetTrash\": \"Очистить корзину\",\n    \"deletedBy\": \"Удалено\",\n    \"deletedTime\": \"Время удаления\",\n    \"fromSpace\": \"Из пространства \\\"{{name}}\\\"\",\n    \"permanentDeleteTips\": \"Вы уверены, что хотите окончательно удалить \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"Вы уверены, что хотите очистить корзину?\",\n    \"addToTrash\": \"Переместить в корзину\",\n    \"description\": \"Данные в корзине все еще занимают место для использования записей и использования вложений.\",\n    \"spaceDescription\": \"Восстановить пространства, удалённые за последние {{retentionDays}} дней\",\n    \"spaceInnerDescription\": \"Восстановить базы, удалённые из этого пространства за последние {{retentionDays}} дней\",\n    \"baseDescription\": \"Восстановить ресурсы, удалённые из этой базы за последние {{retentionDays}} дней\"\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Вы уверены, что хотите включить текущую автоматизацию?\"\n  },\n  \"email\": {\n    \"send\": \"Отправить\",\n    \"config\": \"Настройка почты\",\n    \"customConfig\": \"Пользовательский почтовый сервер\",\n    \"notify\": \"Уведомления по почте\",\n    \"automation\": \"Автоматизация почты\",\n    \"customNotifyConfig\": \"Пользовательская настройка уведомлений по почте\",\n    \"customAutomationConfig\": \"Пользовательская настройка автоматизации почты\",\n    \"addConfig\": \"Добавить настройку\",\n    \"editConfig\": \"Редактировать настройку\",\n    \"resetConfig\": \"Сбросить\",\n    \"testEmail\": \"Тестовая почта\",\n    \"testEmailPlaceholder\": \"Введите адреса электронной почты, разделяя их клавишей Enter\",\n    \"testEmailError\": \"Пожалуйста, введите правильный адрес тестовой почты\",\n    \"testEmailSend\": \"Тестовое письмо успешно отправлено, проверьте свой почтовый ящик\",\n    \"configError\": \"Пожалуйста, введите правильную настройку почты\",\n    \"host\": \"Адрес сервера\",\n    \"hostDescription\": \"Пожалуйста, введите адрес SMTP почтового сервера, например: smtp.example.com\",\n    \"port\": \"Порт\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Аутентификация\",\n    \"username\": \"Имя пользователя\",\n    \"password\": \"Пароль\",\n    \"sender\": \"Адрес отправителя\",\n    \"senderName\": \"Имя отправителя\",\n    \"subscribe\": \"Подписаться\",\n    \"unsubscribe\": \"Отписаться\",\n    \"unsubscribeList\": \"Список отписки\",\n    \"unsubscribeTime\": \"Время отписки\",\n    \"source\": \"Источник\",\n    \"sourceAutomationDeleted\": \"Автоматизация или узел удалены\",\n    \"processing\": \"Обработка...\",\n    \"unsubscribeH1\": \"Подтвердить отписку?\",\n    \"unsubscribeH2\": \"Вы собираетесь отписаться от будущих рекламных акций и обновлений продуктов Teable. Вы уверены, что хотите отписаться?\",\n    \"subscribeH1\": \"Подтвердить подписку?\",\n    \"subscribeH2\": \"Вы собираетесь подписаться на будущие рекламные акции и обновления продуктов Teable. Вы уверены, что хотите подписаться?\",\n    \"unsubscribeListTip\": \"Следующие пользователи текущей базы отписались, вы больше не сможете отправлять им электронные письма. При импорте электронных писем, пожалуйста, поместите адреса электронной почты в первый столбец и назовите заголовок \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Сброс пароля - {{brandName}}\",\n        \"title\": \"Сбросьте ваш пароль\",\n        \"message\": \"Если вы не запрашивали это изменение, проигнорируйте это письмо. В противном случае нажмите кнопку ниже, чтобы сбросить пароль.\",\n        \"buttonText\": \"Сбросить пароль\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Подтверждение регистрации - {{brandName}}\",\n          \"title\": \"Подтверждение регистрации\",\n          \"message\": \"Ваш код подтверждения {{code}}, используйте его в течение {{expiresIn}} минут.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Подтверждение домена - {{brandName}}\",\n          \"title\": \"Подтверждение домена\",\n          \"message\": \"Ваш одноразовый код: {{code}}, используйте его в течение {{expiresIn}} минут.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Подтверждение смены email - {{brandName}}\",\n          \"title\": \"Подтверждение смены email\",\n          \"message\": \"Ваш код подтверждения {{code}}, используйте его в течение {{expiresIn}} минут.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} добавил вас в поле {{fieldName}} записи в {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> добавил вас в поле <strong>{{fieldName}}</strong> записи в <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Посмотреть запись\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} добавил вас в {{refLength}} записей в {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> добавил вас в <strong>{{refLength}}</strong> записей в <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Посмотреть записи\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) пригласил вас в {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Приглашение к сотрудничеству\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) пригласил вас в {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Принять приглашение\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Добро пожаловать - {{brandName}}\",\n        \"title\": \"Добро пожаловать\",\n        \"message\": \"Вы успешно присоединились к списку ожидания {{brandName}}. Используйте следующий код приглашения для регистрации: {{code}}, его можно использовать {{times}} раз.\",\n        \"buttonText\": \"Зарегистрироваться\"\n      },\n      \"test\": {\n        \"subject\": \"Тестовое письмо - {{brandName}}\",\n        \"title\": \"Тестовое письмо\",\n        \"message\": \"Это тестовое письмо, проигнорируйте его.\"\n      },\n      \"notify\": {\n        \"subject\": \"Уведомление - {{brandName}}\",\n        \"title\": \"Уведомление\",\n        \"buttonText\": \"Посмотреть\",\n        \"import\": {\n          \"title\": \"Уведомление о результате импорта\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ Импорт {{tableName}} прерван: {{errorMessage}} диапазон неудачных строк: [{{range}}]. Проверьте данные этого диапазона и повторите попытку.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ Импорт {{tableName}} не удался: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ Импорт {{tableName}} не удался: Достигнут лимит строк, обновите план для импорта большего количества записей\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ Импорт {{tableName}} не удался: Ни одна запись не была обработана\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} успешно импортирована.\",\n              \"inplace\": \"🎉 {{tableName}} успешно импортирована на месте.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Уведомление о комментарии к записи\",\n          \"message\": \"{{fromUserName}} оставил комментарий к {{recordName}} в {{tableName}} в {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Уведомление об автоматизации\",\n          \"failed\": {\n            \"title\": \"Автоматизация {{name}} не удалась\",\n            \"message\": \"Ваша автоматизация {{name}} не смогла выполниться. Нажмите кнопку ниже, чтобы просмотреть конкретные ошибки из истории выполнения.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"Автоматизация {{name}} не удалась из-за недостатка кредита\",\n            \"message\": \"Ваша автоматизация {{name}} не смогла выполниться из-за недостатка кредита. Обновите подписку или обратитесь в поддержку.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"Автоматизация {{name}} достигла максимального месячного лимита запусков\",\n            \"message\": \"Месячный лимит запусков для автоматизации {{name}} исчерпан, поэтому сейчас запуск недоступен. Обновите подписку или приобретите дополнительные запуски.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Уведомление о биллинге\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Пространство {{spaceName}} кредиты ИИ использовано 80 %\",\n              \"message\": \"Ваше пространство использовало 80 % кредитов ИИ. Рассмотрите возможность обновления или покупки дополнительных кредитов.\"\n            },\n            \"warning90\": {\n              \"title\": \"Пространство {{spaceName}} кредиты ИИ использовано 90 %\",\n              \"message\": \"Ваше пространство использовало 90 % кредитов ИИ. Обновите план в ближайшее время, чтобы избежать перебоев.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Пространство {{spaceName}} запуски автоматизации использовано 80 % квоты\",\n              \"message\": \"Ваше пространство использовало {{usedRuns}} из {{totalLimit}} запусков автоматизации (80 %). Рассмотрите возможность обновления плана.\"\n            },\n            \"warning90\": {\n              \"title\": \"Пространство {{spaceName}} запуски автоматизации использовано 90 % квоты\",\n              \"message\": \"Ваше пространство использовало {{usedRuns}} из {{totalLimit}} запусков автоматизации (90 %). Обновите план в ближайшее время, чтобы избежать перебоев.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Пространство {{spaceName}} запуски автоматизации превышены – льготный период активен\",\n              \"message\": \"Квота запусков автоматизации превышена. Автоматизации прекратят работу и будут отключены через {{remainingHours}} часов. Обновите свой план.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Уведомление о результате экспорта базы\",\n          \"success\": {\n            \"message\": \"{{baseName}} успешно экспортирована: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ Экспорт {{baseName}} не удался: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"Задача ИИ не удалась в таблице {{tableName}}\",\n              \"message\": \"Задача ИИ для поля {{fieldName}} в таблице {{tableName}} (ID записи: {{recordId}}) не удалась.\\n\\n{{errorMsg}}\\n\\nНажмите кнопку ниже, чтобы просмотреть детали.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Список ожидания\",\n    \"email\": \"Эл. почта\",\n    \"joinTitle\": \"Мы быстро масштабируемся, чтобы удовлетворить спрос\",\n    \"joinDesc\": \"Присоединяйтесь к списку ожидания — мы сообщим вам, как только будем готовы\",\n    \"emailPlaceholder\": \"Введите ваш адрес электронной почты...\",\n    \"youAreOnTheList\": \"Вы находитесь в списке ожидания!\",\n    \"thanksForJoining\": \"Спасибо за регистрацию в нашем списке ожидания. Мы сообщим вам, как только будем готовы.\",\n    \"back\": \"Назад\",\n    \"inviteCodePlaceholder\": \"Введите код приглашения\",\n    \"join\": \"Присоединиться к списку ожидания\",\n    \"joining\": \"Присоединяемся...\",\n    \"invite\": \"Пригласить\",\n    \"inviteTime\": \"Время приглашения\",\n    \"createdTime\": \"Время присоединения\",\n    \"yes\": \"Да\",\n    \"no\": \"Нет\",\n    \"generateCode\": \"Генерировать код приглашения\",\n    \"count\": \"Количество\",\n    \"times\": \"Использования\",\n    \"generate\": \"Генерировать\",\n    \"code\": \"Код приглашения\",\n    \"inviteSuccess\": \"Приглашение успешно\",\n    \"app\": {\n      \"previewAppError\": \"Ошибка приложения\",\n      \"sendErrorToAI\": \"Отправить ошибку в AI\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"Вы уверены, что хотите удалить \\\"{{name}}\\\" базу?\",\n    \"createResource\": \"Создать ресурс\",\n    \"noPermissionToCreateResource\": \"У вас нет разрешения на создание ресурса\"\n  },\n  \"credit\": {\n    \"title\": \"Кредит\",\n    \"leftAmount\": \"Осталось кредитов\",\n    \"winFreeCredits\": \"Поделиться опытом\",\n    \"getCredits\": \"Получить 1000 бесплатных кредитов\",\n    \"winCredit\": {\n      \"title\": \"Поделитесь положительным отзывом и заработайте\",\n      \"freeCredits\": \"1000 бесплатных кредитов\",\n      \"guidelinesTitle\": \"Контрольный список для публикации\",\n      \"tagTeableio\": \"Отметьте <bold>@teableio</bold>\",\n      \"minCharacters\": \"Напишите отзыв из <bold>80+</bold> символов\",\n      \"minFollowers\": \"Имейте <bold>10+</bold> подписчиков\",\n      \"limitPerWeek\": \"500 кредитов за пост, до 2 постов в неделю (X + LinkedIn)\",\n      \"postOnX\": \"Опубликовать в X\",\n      \"postOnLinkedIn\": \"Опубликовать в LinkedIn\",\n      \"preFilledDraft\": \"🌟 Готовый черновик уже подготовлен для вас!\",\n      \"claimTitle\": \"Вставьте URL поста, чтобы получить кредиты\",\n      \"userEmail\": \"Email пользователя\",\n      \"postUrlLabel\": \"URL поста (X или LinkedIn)\",\n      \"postUrlPlaceholder\": \"Вставьте ссылку на ваш пост здесь\",\n      \"invalidUrl\": \"Пожалуйста, введите корректный URL поста X или LinkedIn\",\n      \"claiming\": \"Запрос...\",\n      \"claimCredits\": \"Получить кредиты\",\n      \"congratulations\": \"Поздравляем!\",\n      \"claimSuccess\": \"Вы получили 500 кредитов!\",\n      \"verifying\": \"Проверка вашего поста...\",\n      \"verifyingDescription\": \"Мы проверяем содержимое вашего поста, это обычно занимает несколько секунд\",\n      \"verifyFailed\": \"Проверка не удалась\",\n      \"tryAgain\": \"Попробовать снова\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Проверка не удалась\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Награда\",\n    \"rewardCredits\": \"Кредиты награды\",\n    \"minCharCount\": \"Пост должен содержать не менее {{count}} символов\",\n    \"minFollowerCount\": \"Аккаунт должен иметь не менее {{count}} подписчиков\",\n    \"mustMention\": \"Пост должен упоминать {{mention}}\",\n    \"fetchSnapshotFailed\": \"Не удалось получить снимок поста\",\n    \"alreadyClaimedThisWeek\": \"Вы уже получили награду для этого аккаунта на этой неделе\",\n    \"manage\": {\n      \"title\": \"Управление наградами\",\n      \"description\": \"Просмотр и управление записями наград во всех пространствах\",\n      \"overview\": \"Обзор\",\n      \"records\": \"Записи\",\n      \"searchSpace\": \"Поиск пространства...\",\n      \"searchRecords\": \"Поиск postUrl/userId...\",\n      \"dateRange\": \"Выбрать диапазон дат\",\n      \"from\": \"От\",\n      \"to\": \"До\",\n      \"totalSpaces\": \"Всего: {{count}} пространств\",\n      \"totalRecords\": \"Всего: {{count}} записей\",\n      \"space\": \"Пространство\",\n      \"allSpaces\": \"Все пространства\",\n      \"user\": \"Пользователь\",\n      \"creator\": \"Создатель\",\n      \"sourceType\": \"Тип источника\",\n      \"platform\": \"Платформа\",\n      \"allStatuses\": \"Все статусы\",\n      \"allSourceTypes\": \"Все типы источников\",\n      \"allPlatforms\": \"Все платформы\",\n      \"pendingCount\": \"Ожидающих\",\n      \"approvedCount\": \"Одобренных\",\n      \"rejectedCount\": \"Отклоненных\",\n      \"approvedAmount\": \"Одобренная сумма\",\n      \"consumedAmount\": \"Использованная сумма\",\n      \"availableAmount\": \"Доступная сумма\",\n      \"expiringSoonAmount\": \"Скоро истекает (7 дней)\",\n      \"amount\": \"Сумма\",\n      \"remainingAmount\": \"Оставшаяся сумма\",\n      \"createdTime\": \"Дата создания\",\n      \"rewardTime\": \"Дата награды\",\n      \"expiredTime\": \"Дата истечения\",\n      \"lastModified\": \"Последнее изменение\",\n      \"viewDetails\": \"Подробнее\",\n      \"details\": \"Детали награды\",\n      \"basicInfo\": \"Основная информация\",\n      \"amountInfo\": \"Информация о сумме\",\n      \"timeInfo\": \"Временная информация\",\n      \"socialInfo\": \"Социальная информация\",\n      \"verifyResult\": \"Результат проверки\",\n      \"uniqueKey\": \"Уникальный ключ\",\n      \"verify\": \"Проверить\",\n      \"valid\": \"Действителен\",\n      \"invalid\": \"Недействителен\",\n      \"errors\": \"Ошибки\",\n      \"copied\": \"{{label}} скопировано\",\n      \"openPost\": \"Открыть пост\",\n      \"noData\": \"Нет данных\",\n      \"page\": \"Страница {{current}} из {{total}}\",\n      \"status\": {\n        \"label\": \"Статус\",\n        \"pending\": \"Ожидание\",\n        \"approved\": \"Одобрено\",\n        \"rejected\": \"Отклонено\"\n      }\n    }\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Страница не найдена\",\n      \"description\": \"Ссылка, по которой вы перешли, может быть недействительной, или страница была перемещена.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Вернуться на главную\"\n    },\n    \"forbidden\": {\n      \"title\": \"Доступ ограничен\",\n      \"description\": \"Для доступа к этому ресурсу требуется разрешение.\\nПожалуйста, обратитесь к администратору.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Разблокировать премиум-функцию\",\n      \"description\": \"Эта функция доступна в расширенных тарифах.\\nОбновите план, чтобы расширить возможности.\"\n    },\n    \"error\": {\n      \"title\": \"Что-то пошло не так\",\n      \"description\": \"Произошла непредвиденная ошибка. Пожалуйста, попробуйте позже.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Ошибка разбора даты, значение \\\"{{value}}\\\" выходит за допустимый диапазон\",\n      \"planRowLimit\": \"Достигнут лимит строк: обновите план для импорта большего количества записей\",\n      \"notNullValidation\": \"{{fieldHint}}Обязательные поля не могут быть пустыми\",\n      \"uniqueValidation\": \"{{fieldHint}}Дублирующиеся значения в уникальных полях\",\n      \"requestTimeout\": \"Превышено время ожидания запроса\",\n      \"chunkProcessingFailed\": \"Ошибка пакетной обработки: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"НОВОЕ ОБНОВЛЕНИЕ\",\n    \"title\": \"Массовая загрузка вложений доступна\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Пока нет Дешбордов\",\n    \"description\": \"Похоже, вы еще не создали ни одного Дешборда. Дешборды помогают вам визуализировать и управлять вашими данными более эффективно.\",\n    \"create\": \"Создать первый Дешборд\"\n  },\n  \"addPlugin\": \"Добавить плагин\",\n  \"createDashboard\": {\n    \"button\": \"Создать Дешборд\",\n    \"title\": \"Создать новый Дешборд\",\n    \"placeholder\": \"Введите название Дешборда\"\n  },\n  \"findDashboard\": \"Найти Дешборд...\",\n  \"pluginUrlEmpty\": \"URL плагина не установлен\",\n  \"install\": \"Установить\",\n  \"publisher\": \"Издатель\",\n  \"lastUpdated\": \"Последнее обновление\",\n  \"expand\": \"Развернуть\",\n  \"pluginNotFound\": \"Плагин не найден\",\n  \"pluginEmpty\": {\n    \"title\": \"Пока нет плагинов\"\n  },\n  \"deprecation\": {\n    \"title\": \"Функция узла дашборда будет прекращена\",\n    \"description\": \"Чтобы обеспечить вам более умный и эффективный опыт, мы прекратим поддержку функции узла дашборда. Вы сможете легко создавать новые дашборды с помощью ИИ в приложении, созданном ИИ.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"Конструктор API-запросов\",\n  \"subTitle\": \"Вы можете быстро создать запросы через интерактивный интерфейс и скопировать код, который можно выполнить напрямую\",\n  \"apiList\": \"Полный список API\",\n  \"cellFormat\": \"Формат результата ячейки\",\n  \"fieldKeyType\": \"Тип ключа поля\",\n  \"chooseSource\": \"Выберите источник данных\",\n  \"action\": {\n    \"selectBase\": \"Выберите базу данных...\",\n    \"selectTable\": \"Выберите таблицу...\"\n  },\n  \"pickParams\": \"Выберите и настройте параметры\",\n  \"buildResult\": \"Построить результат\",\n  \"buildResultEmpty\": \"Пока нет результатов\",\n  \"previewReturnValue\": \"Предпросмотр возвращаемого значения\",\n  \"replaceToken\": \"Заменить токен\",\n  \"createNewToken\": \"Создать новый токен\",\n  \"only10Records\": \"Отображаются только первые 10 записей\",\n  \"addSort\": \"Добавить сортировку\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/oauth.json",
    "content": "{\n  \"add\": \"Новые OAuth-приложения\",\n  \"title\": {\n    \"add\": \"Новые OAuth-приложения\",\n    \"edit\": \"Редактировать OAuth-приложения\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"Имя OAuth-приложения\",\n      \"description\": \"Название вашего OAuth-приложения.\"\n    },\n    \"description\": {\n      \"label\": \"Описание\",\n      \"description\": \"Краткое описание вашего OAuth-приложения.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"URL главной страницы\",\n      \"description\": \"Полный URL веб-сайта вашего OAuth-приложения.\"\n    },\n    \"logo\": {\n      \"label\": \"Логотип\",\n      \"description\": \"Рекомендуется квадратное изображение.\",\n      \"placeholder\": \"Перетащите логотип сюда или нажмите, чтобы загрузить\",\n      \"button\": \"Загрузить логотип\",\n      \"clear\": \"Очистить\",\n      \"lengthError\": \"Разрешён только один файл.\",\n      \"typeError\": \"Разрешены только файлы изображений.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"URL обратного вызова\",\n      \"description\": \"Полный URL для перенаправления после авторизации пользователя для интеграции.\",\n      \"add\": \"Добавить URL обратного вызова\"\n    },\n    \"scopes\": {\n      \"label\": \"Области действия\",\n      \"description\": \"Разрешения, необходимые вашему OAuth-приложению.\"\n    },\n    \"secret\": {\n      \"label\": \"Секреты клиента\",\n      \"add\": \"Создать новый секрет клиента\",\n      \"newDescription\": \"Обязательно скопируйте новый секрет клиента сейчас. Вы больше не сможете его увидеть.\",\n      \"empty\": \"Вам нужен секрет клиента для аутентификации приложения через API.\",\n      \"lastUsed\": \"Последнее использование {{date}}\",\n      \"tag\": \"Секрет клиента\",\n      \"neverUsed\": \"Никогда не использовалось\"\n    },\n    \"clientId\": {\n      \"label\": \"ID клиента: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Основная информация\",\n    \"scopes\": \"Области действия\",\n    \"identify\": \"Идентификация и авторизация пользователей\",\n    \"clientInfo\": \"Информация о клиенте\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} запрашивает доступ к вашему аккаунту\",\n    \"scopes\": \"Это приложение сможет получить следующие области действия:\",\n    \"redirectDescription\": \"Авторизация приведет к перенаправлению на\",\n    \"authorize\": \"Авторизовать\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/plugin.json",
    "content": "{\n  \"add\": \"Новые Плагины\",\n  \"title\": {\n    \"add\": \"Новые Плагины\",\n    \"edit\": \"Редактировать Плагины\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Пользователь Плагина\",\n    \"description\": \"Пользователь плагина автоматически создается системой\"\n  },\n  \"secret\": \"Секрет\",\n  \"regenerateSecret\": \"Перегенерировать Секрет\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Название\",\n      \"description\": \"Название плагина\"\n    },\n    \"description\": {\n      \"label\": \"Описание\",\n      \"description\": \"Краткое описание плагина\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Детальное Описание\",\n      \"description\": \"Подробное описание плагина\"\n    },\n    \"logo\": {\n      \"label\": \"Логотип\",\n      \"description\": \"Логотип плагина. Вы можете загрузить изображение или использовать URL\",\n      \"upload\": \"Загрузить\",\n      \"clear\": \"Очистить\",\n      \"placeholder\": \"Перетащите логотип сюда или нажмите, чтобы загрузить\",\n      \"lengthError\": \"Допускается только один файл.\",\n      \"typeError\": \"Допускаются только файлы изображений.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"URL Помощи\",\n      \"description\": \"URL документации по плагину\"\n    },\n    \"positions\": {\n      \"label\": \"Позиции\",\n      \"description\": \"Позиции плагина\"\n    },\n    \"i18n\": {\n      \"label\": \"I18n\",\n      \"description\": \"i18n плагина, включает (название, описание, детальное описание)\"\n    },\n    \"url\": {\n      \"label\": \"URL\",\n      \"description\": \"URL плагина\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Писать\",\n    \"preview\": \"Предпросмотр\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Скоро будет\",\n    \"empty\": \"Пусто\",\n    \"noRecords\": \"Нет доступных записей\",\n    \"unnamedRecord\": \"Безымянная запись\",\n    \"untitled\": \"Без названия\",\n    \"cancel\": \"Отмена\",\n    \"confirm\": \"Подтвердить\",\n    \"back\": \"Назад\",\n    \"done\": \"Готово\",\n    \"create\": \"Создать\",\n    \"search\": {\n      \"placeholder\": \"Поиск...\",\n      \"empty\": \"Результаты не найдены\"\n    },\n    \"readOnlyTip\": \"Этот вид заблокирован. Вы можете включить <button>личный режим</button> для редактирования параметров вида, и изменения будут действовать только для вас.\",\n    \"selectPlaceHolder\": \"Выберите...\",\n    \"loading\": \"Загрузка...\",\n    \"loadMore\": \"Загрузить больше\",\n    \"uploadFailed\": \"Загрузка не удалась\",\n    \"rowCount\": \"{{count}} записей\",\n    \"summary\": \"Итог\",\n    \"summaryTip\": \"Наведите, чтобы выбрать сводку\",\n    \"actions\": \"Действия\",\n    \"remove\": \"Удалить\",\n    \"runStatus\": {\n      \"success\": \"{{name}} успешно\",\n      \"failed\": \"{{name}} не удалось\",\n      \"running\": \"{{name}} в процессе\"\n    },\n    \"resetSuccess\": \"Сброс успешно\",\n    \"click\": \"Нажмите\",\n    \"clickedCount\": \"{{label}}: Нажато {{text}} раз\",\n    \"atLeastOne\": \"Сохраните хотя бы один {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Уведомление\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Лимит размера файла для предварительного просмотра: {{size}} МБ, пожалуйста, скачайте файл для просмотра.\",\n    \"loadFileError\": \"Не удалось загрузить файл\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Отменить\",\n    \"redo\": \"Повторить\",\n    \"undoFailed\": \"Отмена не удалась\",\n    \"redoFailed\": \"Повтор не удался\",\n    \"nothingToUndo\": \"Нечего отменять\",\n    \"nothingToRedo\": \"Нечего повторять\",\n    \"undoSucceed\": \"Отмена выполнена\",\n    \"redoSucceed\": \"Повтор выполнен\",\n    \"undoing\": \"Отменяем...\",\n    \"redoing\": \"Повторяем...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Отпустите, чтобы загрузить файл\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Вставьте или перетащите файл для загрузки сюда\",\n      \"upload\": \"загрузить\"\n    },\n    \"date\": {\n      \"placeholder\": \"Выберите дату\",\n      \"today\": \"Сегодня\"\n    },\n    \"formula\": {\n      \"title\": \"Редактор формул\",\n      \"guideSyntax\": \"Синтаксис\",\n      \"guideExample\": \"Пример\",\n      \"helperExample\": \"Пример: \",\n      \"fieldValue\": \"Возвращает значение для поля {{fieldName}}.\",\n      \"placeholder\": \"Введите формулу\",\n      \"placeholderForAIPrompt\": \"Опишите формулу, которую вы хотите сгенерировать\",\n      \"editExpression\": \"Редактировать формулу\",\n      \"generateExpressionByAI\": \"Сгенерировать формулу с помощью AI\",\n      \"inputPrompt\": \"Введите инструкцию\",\n      \"generateExpression\": \"Сгенерированная формула\",\n      \"generatingByAI\": \"Сгенерирование формулы с помощью AI...\",\n      \"generatedExpressionTips\": \"После генерации, нажмите на кнопку Применить, чтобы вставить формулу быстро\",\n      \"action\": {\n        \"generating\": \"Сгенерирование...\",\n        \"generate\": \"Сгенерировать\",\n        \"apply\": \"Применить\"\n      },\n      \"expressionRequired\": \"Выражение обязательно\"\n    },\n    \"link\": {\n      \"placeholder\": \"Выберите записи для ссылки\",\n      \"searchPlaceholder\": \"Поиск записей\",\n      \"allFields\": \"Все поля\",\n      \"globalSearch\": \"Глобальный поиск\",\n      \"fieldSearch\": \"Поиск по полю\",\n      \"maxFieldTips\": \"Превышено максимальное количество {{count}} полей, лишние поля будут проигнорированы\",\n      \"create\": \"Добавить запись\",\n      \"selectRecord\": \"Выбрать запись\",\n      \"all\": \"Все\",\n      \"selected\": \"Выбрано\",\n      \"expandRecordError\": \"Нет доступа для просмотра этой записи.\",\n      \"alreadyOpen\": \"Эта запись уже открыта.\",\n      \"linkedTo\": \"Связано с\",\n      \"goToForeignTable\": \"Перейти к связанной таблице\",\n      \"foreignTableIdRequired\": \"Внешняя таблица обязательна\",\n      \"linkFieldIdRequired\": \"Поле связи обязательно\",\n      \"selectTooManyRecords\": \"Выбранные записи не должны превышать {{maxCount}}\",\n      \"relationshipRequired\": \"Отношение обязательно\",\n      \"rangeSelectFailed\": \"Не удалось выбрать записи в диапазоне\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Найдите пользователей по имени\",\n      \"notify\": \"Уведомить пользователей, когда они будут выбраны\"\n    },\n    \"select\": {\n      \"addOption\": \"Добавить вариант '{{option}}'\",\n      \"choicesNameRequired\": \"Имя выбора не может быть пустым\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Поле поиска обязательно\",\n      \"lookupOptionsNotAllowed\": \"Опции поиска не разрешены, когда атрибут isLookup истинный или тип поля rollup.\",\n      \"lookupOptionsRequired\": \"Опции поиска обязательны\",\n      \"refineOptionsError\": \"Ошибка парсинга опций поиска {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"Выражение обязательно\",\n      \"unsupportedTip\": \"Rollup поддерживает только поля связи и rollup\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Фильтр должен содержать как минимум одно условие\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"Для условного поиска требуется как минимум одно условие фильтра\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Модель обязательна\",\n      \"typeNotSupported\": \"Неподдерживаемый тип AI\",\n      \"sourceFieldIdRequired\": \"Исходное поле обязательно\",\n      \"targetLanguageRequired\": \"Целевой язык обязателен\",\n      \"promptRequired\": \"Запрос обязателен\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Ошибка парсинга опций поля {{message}}\",\n      \"optionsRequired\": \"Опции поля обязательны\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Фильтр\",\n    \"displayLabel\": \"Фильтр по \",\n    \"displayLabel_other\": \"Фильтр по {{fieldName}} и {{count}} другим полям\",\n    \"addCondition\": \"Добавить условие\",\n    \"addConditionGroup\": \"Добавить группу условий\",\n    \"nestedLimitTip\": \"Условия фильтра могут быть вложены не более чем на {{depth}} уровней\",\n    \"linkInputPlaceholder\": \"Введите значение\",\n    \"groupDescription\": \"Любое из следующих условий верно…\",\n    \"currentUser\": \"Я (текущий пользователь)\",\n    \"tips\": {\n      \"scope\": \"В этом виде показывать записи\"\n    },\n    \"invalidateSelected\": \"Неверное значение\",\n    \"invalidateSelectedTips\": \"Выбранное значение было удалено, выберите другое\",\n    \"default\": {\n      \"empty\": \"Фильтры не применены\",\n      \"placeholder\": \"Введите значение\"\n    },\n    \"conjunction\": {\n      \"and\": \"и\",\n      \"or\": \"или\",\n      \"where\": \"где\",\n      \"meetingAll\": \"Соответствовать всем условиям\",\n      \"meetingAny\": \"Соответствовать любому условию\"\n    },\n    \"operator\": {\n      \"is\": \"является\",\n      \"isNot\": \"не является\",\n      \"contains\": \"содержит\",\n      \"doesNotContain\": \"не содержит\",\n      \"isEmpty\": \"пусто\",\n      \"isNotEmpty\": \"не пусто\",\n      \"isGreater\": \"больше чем\",\n      \"isGreaterEqual\": \"больше или равно\",\n      \"isLess\": \"меньше чем\",\n      \"isLessEqual\": \"меньше или равно\",\n      \"isAnyOf\": \"является любым из\",\n      \"isNoneOf\": \"не является ни одним из\",\n      \"hasAnyOf\": \"имеет любой из\",\n      \"hasAllOf\": \"имеет все из\",\n      \"hasNoneOf\": \"не имеет ни одного из\",\n      \"isExactly\": \"точно\",\n      \"isWithIn\": \"в пределах\",\n      \"isBefore\": \"до\",\n      \"isAfter\": \"после\",\n      \"isOnOrBefore\": \"на или до\",\n      \"isOnOrAfter\": \"на или после\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"сегодня\",\n        \"tomorrow\": \"завтра\",\n        \"yesterday\": \"вчера\",\n        \"oneWeekAgo\": \"неделю назад\",\n        \"oneWeekFromNow\": \"через неделю\",\n        \"oneMonthAgo\": \"месяц назад\",\n        \"oneMonthFromNow\": \"через месяц\",\n        \"daysAgo\": \"дней назад\",\n        \"daysFromNow\": \"дней вперед\",\n        \"exactDate\": \"точная дата\",\n        \"exactFormatDate\": \"точная дата (форматированная)\",\n        \"currentWeek\": \"current week\",\n        \"currentMonth\": \"current month\",\n        \"currentYear\": \"current year\",\n        \"lastWeek\": \"last week\",\n        \"lastMonth\": \"last month\",\n        \"lastYear\": \"last year\",\n        \"nextWeekPeriod\": \"next week\",\n        \"nextMonthPeriod\": \"next month\",\n        \"nextYearPeriod\": \"next year\",\n        \"pastWeek\": \"прошлая неделя\",\n        \"pastMonth\": \"прошлый месяц\",\n        \"pastYear\": \"прошлый год\",\n        \"nextWeek\": \"следующая неделя\",\n        \"nextMonth\": \"следующий месяц\",\n        \"nextYear\": \"следующий год\",\n        \"pastNumberOfDays\": \"прошлое количество дней\",\n        \"nextNumberOfDays\": \"следующее количество дней\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"Цвет\"\n  },\n  \"rowHeight\": {\n    \"short\": \"низкий\",\n    \"medium\": \"средний\",\n    \"tall\": \"высокий\",\n    \"extraTall\": \"очень высокий\",\n    \"title\": \"высоты строки\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"имени поля\",\n    \"displayLines\": \"{{count}} строк\"\n  },\n  \"share\": {\n    \"title\": \"Поделиться\"\n  },\n  \"extensions\": {\n    \"title\": \"Расширения\"\n  },\n  \"hidden\": {\n    \"label\": \"Скрытые поля\",\n    \"configLabel_one\": \"{{count}} скрытое поле\",\n    \"configLabel_other\": \"{{count}} скрытых поля\",\n    \"configLabel_other_visible\": \"{{count}} видимых поля\",\n    \"showAll\": \"Показать все\",\n    \"hideAll\": \"Скрыть все\",\n    \"primaryKey\": \"Основное поле: Идентифицирует записи\\nнельзя скрыть или удалить, видно в связанных записях.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Копировать в буфер обмена\",\n    \"duplicateRecord\": \"Duplicate record\",\n    \"copyRecordUrl\": \"Копировать URL записи\",\n    \"deleteRecord\": \"Удалить запись\",\n    \"addRecordComment\": \"Добавить комментарий\",\n    \"viewRecordHistory\": \"Посмотреть историю записи\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Скрыть историю записи\",\n      \"showRecordHistory\": \"Показать историю записи\",\n      \"createdTime\": \"Время создания\",\n      \"createdBy\": \"Создано\",\n      \"before\": \"До\",\n      \"after\": \"После\",\n      \"viewRecord\": \"Просмотреть запись\"\n    },\n    \"showHiddenFields\": \"Показать {{count}} скрытых поля\",\n    \"hideHiddenFields\": \"Скрыть {{count}} скрытых поля\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Сортировка\",\n    \"displayLabel_one\": \"Сортировать по {{count}} полю\",\n    \"displayLabel_other\": \"Сортировать по {{count}} полям\",\n    \"setTips\": \"Сортировать по\",\n    \"addButton\": \"Добавить ещё одну сортировку\",\n    \"autoSort\": \"Автоматически сортировать записи\",\n    \"selectASCLabel\": \"от первого к последнему\",\n    \"selectDESCLabel\": \"от последнего к первому\"\n  },\n  \"group\": {\n    \"label\": \"Группировка\",\n    \"displayLabel_one\": \"Группировать по {{count}} полю\",\n    \"displayLabel_other\": \"Группировать по {{count}} полям\",\n    \"setTips\": \"Группировать по\",\n    \"addButton\": \"Добавить подгруппу\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Текст в одну строку\",\n      \"longText\": \"Длинный текст\",\n      \"singleSelect\": \"Одиночный выбор\",\n      \"number\": \"Число\",\n      \"multipleSelect\": \"Множественный выбор\",\n      \"link\": \"Ссылка на другую запись\",\n      \"formula\": \"Формула\",\n      \"date\": \"Дата\",\n      \"createdTime\": \"Время создания\",\n      \"lastModifiedTime\": \"Время последнего изменения\",\n      \"attachment\": \"Вложение\",\n      \"checkbox\": \"Флажок\",\n      \"rollup\": \"Сворачивание\",\n      \"conditionalRollup\": \"Условное сворачивание\",\n      \"user\": \"Пользователь\",\n      \"rating\": \"Рейтинг\",\n      \"autoNumber\": \"Автонумерация\",\n      \"lookup\": \"Поиск\",\n      \"conditionalLookup\": \"Условный поиск\",\n      \"button\": \"Кнопка\",\n      \"createdBy\": \"Создано\",\n      \"lastModifiedBy\": \"Изменено\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Сохраняйте короткий текст, например имена или заголовки.\",\n      \"longText\": \"Записывайте более длинные заметки и описания.\",\n      \"singleSelect\": \"Выберите один вариант из списка.\",\n      \"number\": \"Ведите учёт числовых значений с форматированием.\",\n      \"multipleSelect\": \"Помечайте записи несколькими вариантами.\",\n      \"link\": \"Свяжите эту запись с другой таблицей.\",\n      \"formula\": \"Вычисляйте значения на основе других полей.\",\n      \"date\": \"Фиксируйте даты или время.\",\n      \"createdTime\": \"Показывает, когда запись была создана.\",\n      \"lastModifiedTime\": \"Показывает время последнего обновления.\",\n      \"attachment\": \"Загрузка файлов или генерация изображений с помощью ИИ, поддерживает модели типа 🍌 Nano banana pro\",\n      \"checkbox\": \"Переключайте простое \\\"да\\\" или \\\"нет\\\".\",\n      \"rollup\": \"Сводите связанные записи с помощью формул.\",\n      \"conditionalRollup\": \"Сводите данные по заданным условиям.\",\n      \"user\": \"Назначайте записи участникам рабочего пространства.\",\n      \"rating\": \"Оценивайте элементы настраиваемыми иконками.\",\n      \"autoNumber\": \"Присваивайте каждой записи уникальный номер.\",\n      \"lookup\": \"Показывайте значения из связанных записей.\",\n      \"conditionalLookup\": \"Показывает связанные значения, удовлетворяющие заданным фильтрам.\",\n      \"button\": \"Запускайте действия нажатием на кнопку.\",\n      \"createdBy\": \"Показывает, кто создал запись.\",\n      \"lastModifiedBy\": \"Показывает, кто изменил запись последним.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Однонаправленный\",\n      \"twoWay\": \"Двунаправленный\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Подтверждение действия\",\n        \"description\": \"Вы уверены, что хотите выполнить это действие?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Создать пространство\",\n      \"spaceDelete\": \"Удалить пространство\",\n      \"spaceRead\": \"Читать пространство\",\n      \"spaceUpdate\": \"Обновить пространство\",\n      \"spaceInviteEmail\": \"Пригласить по электронной почте в пространство\",\n      \"spaceInviteLink\": \"Пригласить по ссылке в пространство\",\n      \"spaceGrantRole\": \"Назначить роль в пространстве\",\n      \"baseCreate\": \"Создать базу\",\n      \"baseDelete\": \"Удалить базу\",\n      \"baseRead\": \"Читать базу\",\n      \"baseReadAll\": \"Читать все базы\",\n      \"baseUpdate\": \"Обновить базу\",\n      \"baseInviteEmail\": \"Пригласить по электронной почте в базу\",\n      \"baseInviteLink\": \"Пригласить по ссылке в базу\",\n      \"baseTableImport\": \"Импортировать данные в базу\",\n      \"baseAuthorityMatrixConfig\": \"Настроить матрицу полномочий\",\n      \"baseDbConnect\": \"Подключение к базе данных\",\n      \"tableCreate\": \"Создать таблицу\",\n      \"tableRead\": \"Читать таблицу\",\n      \"tableDelete\": \"Удалить таблицу\",\n      \"tableUpdate\": \"Обновить таблицу\",\n      \"tableImport\": \"Импортировать данные в таблицу\",\n      \"tableExport\": \"Экспортировать данные из таблицы\",\n      \"tableTrashRead\": \"Просмотр корзины таблицы\",\n      \"tableTrashUpdate\": \"Обновление корзины таблицы\",\n      \"tableTrashReset\": \"Сброс корзины таблицы\",\n      \"viewCreate\": \"Создать вид\",\n      \"viewDelete\": \"Удалить вид\",\n      \"viewRead\": \"Читать вид\",\n      \"viewUpdate\": \"Обновить вид\",\n      \"viewShare\": \"Share view\",\n      \"fieldCreate\": \"Создать поле\",\n      \"fieldDelete\": \"Удалить поле\",\n      \"fieldRead\": \"Читать поле\",\n      \"fieldUpdate\": \"Обновить поле\",\n      \"recordCreate\": \"Создать запись\",\n      \"recordComment\": \"Комментировать запись\",\n      \"recordDelete\": \"Удалить запись\",\n      \"recordRead\": \"Читать запись\",\n      \"recordUpdate\": \"Обновить запись\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Создать автоматизацию\",\n      \"automationDelete\": \"Удалить автоматизацию\",\n      \"automationRead\": \"Читать автоматизацию\",\n      \"automationUpdate\": \"Обновить автоматизацию\",\n      \"appCreate\": \"Создать приложение\",\n      \"appDelete\": \"Удалить приложение\",\n      \"appRead\": \"Читать приложение\",\n      \"appUpdate\": \"Обновить приложение\",\n      \"userProfileRead\": \"Читать профиль пользователя\",\n      \"userEmailRead\": \"Читать электронную почту пользователя\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Читать историю записей\",\n      \"baseQuery\": \"Запрос базы\",\n      \"instanceRead\": \"Читать экземпляр\",\n      \"instanceUpdate\": \"Обновить экземпляр\",\n      \"enterpriseRead\": \"Read enterprise configuration\",\n      \"enterpriseUpdate\": \"Update enterprise configuration\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Таблица\",\n    \"view\": \"Вид\",\n    \"space\": \"Пространство\",\n    \"base\": \"База\",\n    \"field\": \"Поле\",\n    \"record\": \"Запись\",\n    \"automation\": \"Автоматизация\",\n    \"app\": \"Приложение\",\n    \"user\": \"Пользователь\",\n    \"recordHistory\": \"История записей\",\n    \"you\": \"Вы\",\n    \"instance\": \"Экземпляр\",\n    \"enterprise\": \"Предприятие\",\n    \"history\": \"История\",\n    \"global\": \"Глобальный\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Складывает числа. Эквивалентно number1 + number2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Возвращает среднее значение чисел.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Возвращает наибольшее из данных чисел.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Возвращает наименьшее из данных чисел.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Округляет значение до указанного количества знаков после запятой (округляет к ближайшему целому числу при указанной точности, с округлением половины в сторону положительной бесконечности).\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Округляет значение до указанного количества знаков, всегда в большую сторону, т.е. от нуля.\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Округляет значение до указанного количества знаков, всегда в меньшую сторону, т.е. к нулю.\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Возвращает ближайшее кратное указанной значимости, большее или равное числу.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Возвращает ближайшее кратное значимости, меньшее или равное числу.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Возвращает наименьшее четное число, большее или равное указанному значению.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Округляет положительное значение к ближайшему нечетному числу, а отрицательное — к ближайшему нечетному числу в меньшую сторону.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Возвращает целое значение числа.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Возвращает абсолютное значение.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Возвращает квадратный корень из неотрицательного числа.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Вычисляет значение основания, возведенного в степень.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Вычисляет экспоненту числа (e), возведенного в степень.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Вычисляет логарифм числа по указанному основанию. По умолчанию основание — 10.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Возвращает остаток от деления первого числа на второе.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Преобразует текстовую строку в число.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Объединяет различные типы аргументов в одну текстовую строку.\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Находит вхождение строки stringToFind в строке whereToSearch, начиная с опциональной позиции startFromPosition. Если вхождение не найдено, результат будет 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Ищет вхождение строки stringToFind в строке whereToSearch, начиная с опциональной позиции startFromPosition. Если вхождение не найдено, результат будет пустым.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Извлекает подстроку длиной count символов, начиная с позиции whereToStart.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Извлекает howMany символов с начала строки.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Извлекает howMany символов с конца строки.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Заменяет количество символов, начиная с указанной позиции, на текст замены.\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Заменяет все подстроки, совпадающие с регулярным выражением, на текст замены.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Заменяет вхождения old_text на new_text. Можно указать индекс для замены конкретного вхождения old_text.\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Преобразует строку в нижний регистр.\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Преобразует строку в верхний регистр.\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Повторяет строку указанное количество раз.\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Удаляет пробелы в начале и в конце строки.\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Возвращает длину строки.\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Возвращает аргумент, если это текст, и пусто, если нет.\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Кодирует определенные символы для использования в URL или URI. Не кодирует следующие символы: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Возвращает value1, если логическое условие истинно, иначе возвращает value2. Также может использоваться для составления вложенных условий IF.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Принимает выражение и список возможных значений, для которых это выражение будет принимать указанные значения. Можно указать значение по умолчанию, если выражение не соответствует ни одному из заданных вариантов.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Значение A\\\", \\\"B\\\", \\\"Значение B\\\", \\\"Значение по умолчанию\\\") => \\\"Значение B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Возвращает true, если все аргументы истинны, в противном случае возвращает false.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Возвращает true, если хотя бы один из аргументов истинный.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Возвращает true, если нечетное количество аргументов истинны.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Инвертирует логическое значение аргумента.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Возвращает пустое значение.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Возвращает значение ошибки.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ОШИБКА: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Возвращает true, если выражение вызывает ошибку.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Возвращает текущую дату.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Возвращает текущие дату и время.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Возвращает год в виде четырехзначного числа.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Возвращает месяц в виде числа от 1 (январь) до 12 (декабрь).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Возвращает номер недели в году.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Возвращает день недели в виде числа от 0 до 6 включительно. Можно указать, с какого дня начинать неделю: «Воскресенье» или «Понедельник». По умолчанию неделя начинается с воскресенья.\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Возвращает день месяца в виде числа от 1 до 31.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Возвращает час времени в виде числа от 0 (00:00) до 23 (23:00).\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Возвращает минуту времени в виде числа от 0 до 59.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Возвращает секунду времени в виде числа от 0 до 59.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Вычисляет количество дней между текущей датой и другой датой.\",\n      \"example\": \"FROMNOW({Date}, \\\"день\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Вычисляет количество дней между другой датой и текущей датой.\",\n      \"example\": \"TONOW({Date}, \\\"день\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Возвращает разницу между датами в указанных единицах. Единица по умолчанию — \\\"day\\\".\\nПоддерживаемые единицы: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nРазница между датами определяется путём вычитания [date2] из [date1]. Это означает, что если [date2] позже [date1], результат будет отрицательным.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Возвращает рабочий день от указанной даты, исключая указанные праздники.\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Возвращает количество рабочих дней между двумя датами. Рабочие дни исключают выходные и опциональный список праздников.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Сравнивает две даты до указанной единицы и определяет, одинаковы ли они. Возвращает true, если да, false, если нет.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"месяц\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Определяет, является ли первая дата позднее второй. Возвращает true, если да, false, если нет.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"месяц\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Определяет, является ли первая дата ранее второй. Возвращает true, если да, false, если нет.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"месяц\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Добавляет указанное количество единиц времени к дате.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"день\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Форматирует дату в строку (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Форматирует время в строку (HH:mm:ss).\",\n      \"example\": \"TIMESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Форматирует дату и время в указанную строку.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Интерпретирует текстовую строку как дату с опциональными параметрами формата и локали.\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Сен 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Возвращает время создания текущей записи.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Возвращает дату и время последнего изменения, сделанного пользователем в невычисляемом поле таблицы.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Возвращает количество всех элементов, включая текст и пустые значения.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Возвращает количество непустых значений.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Возвращает количество числовых элементов.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Объединяет массив элементов в строку с указанным разделителем.\",\n      \"example\": \"ARRAY_JOIN([\\\"Том\\\", \\\"Джерри\\\", \\\"Майк\\\"], \\\"; \\\") => \\\"Том; Джерри; Майк\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Возвращает уникальные элементы массива.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Разворачивает массив, удаляя любые вложенные массивы.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Удаляет пустые строки и null-значения из массива.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Возвращает все текстовые значения.\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Возвращает ID текущей записи.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Возвращает уникальные и увеличенные числа для каждой записи.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Заполняет переменные, операционные символы и функции для создания формул для вычислений.\",\n      \"example\": \"Цитирование столбца: {Название поля}\\n\\nИспользование оператора: 100 * 2 + 300\\n\\nИспользование функции: SUM({Числовое поле 1}, 100)\\n\\nИспользование оператора IF: \\nIF(логическое условие, \\\"значение 1\\\", \\\"значение 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Поля\",\n    \"numeric\": \"Числовые\",\n    \"text\": \"Текстовые\",\n    \"logical\": \"Логические\",\n    \"date\": \"Дата\",\n    \"array\": \"Массивы\",\n    \"system\": \"Системные\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Нет\",\n    \"count\": \"Count\",\n    \"empty\": \"Пусто\",\n    \"filled\": \"Заполнено\",\n    \"unique\": \"Уникальные\",\n    \"max\": \"Максимум\",\n    \"min\": \"Минимум\",\n    \"sum\": \"Сумма\",\n    \"average\": \"Среднее\",\n    \"checked\": \"Отмеченные\",\n    \"unChecked\": \"Неотмеченные\",\n    \"percentEmpty\": \"Процент пустых\",\n    \"percentFilled\": \"Процент заполненных\",\n    \"percentUnique\": \"Процент уникальных\",\n    \"percentChecked\": \"Процент отмеченных\",\n    \"percentUnChecked\": \"Процент неотмеченных\",\n    \"earliestDate\": \"Самая ранняя дата\",\n    \"latestDate\": \"Самая поздняя дата\",\n    \"dateRangeOfDays\": \"Диапазон дат (дни)\",\n    \"dateRangeOfMonths\": \"Диапазон дат (месяцы)\",\n    \"totalAttachmentSize\": \"Общий размер вложений\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Добавить\",\n    \"error\": {\n      \"invalidCol\": \"Неверный столбец, выберите снова\",\n      \"invalidCols\": \"Неверные столбцы: {{colNames}}\",\n      \"invalidTable\": \"Неверная таблица, выберите снова\",\n      \"requiredSelect\": \"Необходимо выбрать один\"\n    },\n    \"from\": {\n      \"title\": \"Откуда\",\n      \"fromTable\": \"Выберите таблицу\",\n      \"fromQuery\": \"Из запроса\"\n    },\n    \"select\": {\n      \"title\": \"Выбрать\"\n    },\n    \"where\": {\n      \"title\": \"Где\"\n    },\n    \"groupBy\": {\n      \"title\": \"Группировать по\"\n    },\n    \"orderBy\": {\n      \"title\": \"Сортировать по\",\n      \"asc\": \"По возрастанию\",\n      \"desc\": \"По убыванию\"\n    },\n    \"limit\": {\n      \"title\": \"Ограничение\"\n    },\n    \"offset\": {\n      \"title\": \"Смещение\"\n    },\n    \"join\": {\n      \"title\": \"Объединение\",\n      \"joinType\": \"Тип объединения\",\n      \"leftJoin\": \"Левое объединение\",\n      \"rightJoin\": \"Правое объединение\",\n      \"innerJoin\": \"Внутреннее объединение\",\n      \"fullJoin\": \"Полное объединение\",\n      \"data\": \"Откуда\"\n    },\n    \"aggregation\": {\n      \"title\": \"Агрегация\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Комментарий\",\n    \"placeholder\": \"Оставьте комментарий...\",\n    \"emptyComment\": \"Начните разговор\",\n    \"deletedComment\": \"Удаленный комментарий\",\n    \"imageSizeLimit\": \"Размер изображения не может превышать {{size}}\",\n    \"tip\": {\n      \"editing\": \"Редактирование...\",\n      \"edited\": \"(Редактировано)\",\n      \"notifyAll\": \"Уведомлять все комментарии\",\n      \"notifyRelatedToMe\": \"Уведомлять комментарии, связанные с мной\",\n      \"all\": \"Все\",\n      \"relatedToMe\": \"Связанные с мной\",\n      \"reactionUserSuffix\": \"отреагировал с эмодзи {{emoji}}\",\n      \"me\": \"Ты\",\n      \"connection\": \"и\"\n    },\n    \"toolbar\": {\n      \"link\": \"Ссылка\",\n      \"image\": \"Изображение\",\n      \"mention\": \"Упоминание\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Редактировать ссылку\",\n      \"caption\": \"Подпись\",\n      \"delete\": \"Удалить\",\n      \"linkText\": \"Текст ссылки\",\n      \"enterUrl\": \"Введите URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select Members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Ошибка валидации\",\n    \"invalidCaptcha\": \"Неверный Captcha\",\n    \"invalidCredentials\": \"Неверные учетные данные\",\n    \"unauthorized\": \"Неавторизован\",\n    \"unauthorizedShare\": \"Неавторизованная доступ\",\n    \"paymentRequired\": \"Требуется оплата\",\n    \"creditLimitExceeded\": \"Превышен лимит кредитов\",\n    \"restrictedResource\": \"Ограниченный ресурс\",\n    \"notFound\": \"Не найдено\",\n    \"conflict\": \"Конфликт\",\n    \"unprocessableEntity\": \"Необрабатываемый ресурс\",\n    \"userLimitExceeded\": \"Превышен лимит пользователя\",\n    \"tooManyRequests\": \"Слишком много запросов\",\n    \"internalServerError\": \"Внутренняя ошибка сервера\",\n    \"databaseConnectionUnavailable\": \"Недоступная база данных\",\n    \"gatewayTimeout\": \"Таймаут шлюза\",\n    \"unknownErrorCode\": \"Неизвестный код ошибки\",\n    \"networkError\": \"Проблема сетевого подключения\",\n    \"requestTimeout\": \"Таймаут запроса\",\n    \"failedDependency\": \"Ошибка зависимости\",\n    \"automationNodeParseError\": \"Ошибка разбора узла автоматизации\",\n    \"automationNodeNeedTest\": \"Узел автоматизации требует тестирования\",\n    \"automationNodeTestOutdated\": \"Тест узла автоматизации устарел\",\n    \"invalidToken\": \"Недействительный токен\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" поле \\\"{{fieldName}}\\\" не допускает пустые значения, пожалуйста, заполните его полностью перед отправкой.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" поле \\\"{{fieldName}}\\\" не допускает дубликаты значений, пожалуйста, заполните уникальное значение перед отправкой.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" поле не допускает дубликаты связей с одним и тем же записью\",\n      \"requestTimeout\": \"Текущая операция слишком велика, пожалуйста, попробуйте снова с меньшим диапазоном.\",\n      \"searchTimeOut\": \"Поиск завершился, уменьшите область поиска и повторите.\",\n      \"dependencyNodeRequire\": \"Зависимый узел не протестирован, пожалуйста, проверьте, протестированы ли все предыдущие узлы\",\n      \"invalidOperation\": \"Обнаружена недопустимая операция, пожалуйста, проверьте параметры операции\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"Количество запрошенных комментариев превышает максимальный лимит 1000\",\n      \"invalidContentType\": \"Неверный тип содержимого комментария\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"Срок действия токена должен быть менее 7 дней\",\n      \"s3RegionRequired\": \"Регион S3 обязателен\",\n      \"s3EndpointRequired\": \"Конечная точка S3 обязательна\",\n      \"s3AccessKeyRequired\": \"Ключ доступа S3 обязателен\",\n      \"s3SecretKeyRequired\": \"Секретный ключ S3 обязателен\",\n      \"s3UploadMethodMustBePut\": \"Метод загрузки S3 должен быть PUT\",\n      \"presignedError\": \"Не удалось сгенерировать предварительно подписанный URL\",\n      \"invalidObjectMeta\": \"Недопустимые метаданные объекта\",\n      \"invalidImageStream\": \"Недопустимый поток изображения\",\n      \"calculateImageSizeFailed\": \"Не удалось рассчитать размер изображения\",\n      \"uploadFailed\": \"Загрузка не удалась\",\n      \"invalidImage\": \"Недопустимое изображение\",\n      \"cantGetImageStream\": \"Невозможно получить поток изображения\",\n      \"invalidProvider\": \"Недопустимый поставщик хранилища\",\n      \"failedToDeleteDirectory\": \"Не удалось удалить каталог\",\n      \"invalidToken\": \"Недействительный токен\",\n      \"tokenExpired\": \"Срок действия токена истек\",\n      \"sizeMismatch\": \"Размер файла не совпадает\",\n      \"notAllowUploadFileType\": \"Тип файла {{mimetype}} не разрешен для загрузки\",\n      \"notFound\": \"Вложение не найдено\",\n      \"invalidPath\": \"Недопустимый путь к вложению\",\n      \"fileSizeExceedsMaximumLimit\": \"Размер файла превышает максимальный лимит {{maxSize}}\",\n      \"invalidUploadType\": \"Недопустимый тип загрузки\",\n      \"urlReject\": \"URL отклонен или недоступен\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Ошибка конфигурации почты {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Это поле кнопки уже связано с {{name}}[{{id}}] этот процесс автоматизации, пожалуйста, выберите другое поле кнопки\",\n      \"triggerNotFound\": \"Автоматизация должна иметь узел триггера\",\n      \"nodeNotFound\": \"{{nodeId}} узел не найден\",\n      \"triggerTestFailed\": \"Тест триггера провалился, пожалуйста, проверьте конфигурацию триггера\",\n      \"testFailed\": \"Тест автоматизации не прошел, проверьте конфигурацию автоматизации\",\n      \"runFailed\": \"Сбой выполнения автоматизации\",\n      \"nodeParseError\": \"{{name}} конфигурация узла неполная или содержит ошибки, пожалуйста, проверьте конфигурацию узла\",\n      \"nodeNeedTest\": \"{{name}} узел требует тестирования\",\n      \"nodeTestOutdated\": \"{{name}} тест узла устарел\",\n      \"notFound\": \"Автоматизация не найдена\",\n      \"currentSnapshotEmpty\": \"Текущий снимок автоматизации пуст\",\n      \"runNotFound\": \"Выполнение автоматизации не найдено\",\n      \"anchorNotFound\": \"Якорная автоматизация не найдена\",\n      \"validationError\": \"Ошибка валидации конфигурации автоматизации\",\n      \"tableNotInBase\": \"Вы можете подписаться только на таблицу в пределах вашей базы данных\",\n      \"alreadyActiveAndNotDraft\": \"Автоматизация уже активна и не является черновиком\",\n      \"noActiveSnapshot\": \"У автоматизации нет активного снимка\",\n      \"triggerNodeAlreadyExists\": \"У этой автоматизации уже есть узел триггера\",\n      \"generateLogicError\": \"Ошибка генерации узла логики\",\n      \"logicNotFound\": \"Узел логики автоматизации не найден\",\n      \"actionNotFound\": \"Узел действия автоматизации не найден\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Дублирование этого типа узла автоматизации не поддерживается\",\n      \"unSupportLogicType\": \"Неподдерживаемый тип логики\",\n      \"groupEndNotFound\": \"GroupEnd не найден для логики\",\n      \"insertNodeError\": \"Ошибка вставки узла\",\n      \"controlNodeNotBeTested\": \"Узел управления не должен тестироваться\",\n      \"invalidNodeType\": \"Недопустимый тип узла\",\n      \"unsupportedCategory\": \"Неподдерживаемая категория\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Не удалось получить список почтовых ящиков: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Неизвестный набор данных: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Сервис скрейпинга не настроен\",\n      \"triggerFailed\": \"Ошибка запуска скрейпинга: {{detail}}\",\n      \"snapshotError\": \"Ошибка снимка скрейпинга: {{detail}}\",\n      \"timeout\": \"Таймаут скрейпинга после {{seconds}}с\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Пространство не найдено\",\n      \"noPermission\": \"У вас нет разрешения на доступ к этому пространству\",\n      \"disallowSpaceCreation\": \"Создание пространств отключено администратором\",\n      \"cannotChangeOnlyOwnerRole\": \"Невозможно изменить роль единственного владельца пространства\",\n      \"cannotDeleteOnlyOwner\": \"Невозможно удалить единственного владельца пространства\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Невозможно работать с пространством, убедитесь, что в пространстве есть члены организации и они являются владельцами\",\n      \"notBelongToOrg\": \"Это пространство не принадлежит организации\",\n      \"invalidSpaceIds\": \"Недопустимые идентификаторы пространства: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"База не найдена\",\n      \"cannotAccess\": \"У вас нет разрешения на доступ к базе {{baseId}}\",\n      \"anchorNotFound\": \"Якорная база {{anchorId}} не найдена\",\n      \"baseAndSpaceMismatch\": \"База {{baseId}} и пространство {{spaceId}} не совпадают\",\n      \"templateNotFound\": \"Шаблон {{templateId}} не найден\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"Базовая ID требуется\",\n      \"nodeIdIsRequired\": \"Узел ID требуется\",\n      \"invalidResourceType\": \"Недопустимый тип ресурса\",\n      \"notFound\": \"Базовый узел не найден\",\n      \"parentMustBeFolder\": \"Родитель должен быть папкой\",\n      \"cannotDuplicateFolder\": \"Невозможно дублировать папку\",\n      \"cannotDeleteEmptyFolder\": \"Невозможно удалить папку, так как она не пуста\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Необходимо указать только parentId или anchorId\",\n      \"cannotMoveToItself\": \"Невозможно переместить узел на себя\",\n      \"cannotMoveToCircularReference\": \"Невозможно переместить узел в свой собственный дочерний узел (циклическая ссылка)\",\n      \"anchorIdOrParentIdRequired\": \"Необходимо указать хотя бы parentId или anchorId\",\n      \"parentNotFound\": \"Родительский узел не найден\",\n      \"parentIsNotFolder\": \"Родитель не является папкой\",\n      \"circularReference\": \"Обнаружена циклическая ссылка\",\n      \"folderDepthLimitExceeded\": \"Превышен лимит глубины папки\",\n      \"folderNotFound\": \"Папка не найдена\",\n      \"anchorNotFound\": \"Якорный узел не найден\",\n      \"nameAlreadyExists\": \"Имя уже существует\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Панель управления не найдена\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Плагин не найден\",\n      \"notSupportInstallInView\": \"Плагин не поддерживает установку в представление\",\n      \"userNotFound\": \"Пользователь плагина не найден\",\n      \"invalidSecret\": \"Недействительный секрет\",\n      \"invalidRefreshToken\": \"Недействительный токен обновления\",\n      \"anomalousToken\": \"Аномальный токен\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Панель плагинов не найдена\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Плагин не установлен\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Неверный пароль\",\n      \"notAllowedToSubmit\": \"Отправка формы не разрешена\",\n      \"viewRequired\": \"Для этой операции требуется представление\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"Отправка формы не разрешена при наличии скрытых полей\",\n      \"submitRecordsError\": \"Ошибка отправки записи\",\n      \"notAllowedToCopy\": \"Операция копирования не разрешена\",\n      \"fieldHiddenNotAllowed\": \"Поле скрыто и недоступно\",\n      \"fieldTypeNotLinkField\": \"Поле не является полем связи\",\n      \"fieldIdRequired\": \"Требуется ID поля\",\n      \"fieldNotUserRelatedField\": \"Поле не связано с пользователем\",\n      \"viewTypeNotAllowed\": \"Этот тип представления не разрешен для данной операции\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"Ограничение паролем не включено для этого общего представления\",\n      \"shareViewNotFound\": \"Общее представление не найдено или общий доступ отключен\",\n      \"linkFieldNotFound\": \"Поле связи не найдено\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Общий доступ к базе не найден или отключен\",\n      \"alreadyExists\": \"Общий доступ для этого узла уже существует\",\n      \"copyNotAllowed\": \"Этот общий доступ не разрешает копирование\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"У вас нет разрешения на доступ к этому представлению\",\n      \"fieldPermissionNotAllowed\": \"У вас нет разрешения на доступ к этим полям\",\n      \"recordPermissionNotAllowed\": \"У вас нет разрешения на доступ к этим записям\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Контекстное меню плагина не найдено\",\n      \"anchorNotFound\": \"Якорь контекстного меню плагина не найден\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Запрос диаграммы плагина не найден\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Неподдерживаемый драйвер базы данных: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Только владелец базы может удалить подключение к базе данных для базы {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Только владелец базы может создать подключение к базе данных для базы {{baseId}}\",\n      \"roleNotExist\": \"Роль базы данных {{role}} не существует\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Запрос не выполнен: {{message}}\",\n      \"invalidJoinType\": \"Недопустимый тип соединения: {{joinType}}\",\n      \"tableNotFound\": \"Таблица {{tableId}} не найдена в базе {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Не разрешено выполнять SQL с ключевым словом {{keyword}}\",\n      \"whiteListCheckError\": \"Произошла ошибка при проверке доступа к таблице: {{message}}\",\n      \"databaseConnectionFailed\": \"Не удалось подключиться к базе данных: {{message}}\",\n      \"executeQuerySqlFailed\": \"Не удалось выполнить запрос SQL: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"У вас нет разрешения на создание записей с полями({{fields}})\",\n      \"deleteRecords\": \"У вас нет разрешения на удаление записей({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"У вас нет разрешения на чтение полей({{fields}}) в записи({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"У вас нет разрешения на обновление полей({{fields}}) в записи({{recordId}})\",\n      \"checkIdNotExist\": \"ID проверки разрешений не существует\",\n      \"userNotAdmin\": \"Пользователь не является администратором\",\n      \"accessTokenNoPermission\": \"Токен доступа не имеет необходимого разрешения\",\n      \"invalidResource\": \"Ресурс недействителен\",\n      \"notAllowedSpace\": \"У вас нет разрешения на доступ к этому пространству\",\n      \"notAllowedBase\": \"У вас нет разрешения на доступ к этой базе\",\n      \"notAllowedTables\": \"У вас нет разрешения на доступ к таблицам({{tableIds}})\",\n      \"notAllowedOperationTable\": \"У вас нет разрешения на операции с этой таблицей\",\n      \"notAllowedOperationRecord\": \"У вас нет разрешения на операции с этой записью\",\n      \"notAllowedRecordUpdate\": \"У вас нет разрешения на обновление этой записи\",\n      \"notAllowedOperationView\": \"У вас нет разрешения на операции с этим представлением\",\n      \"deniedByEnabledAuthorityMatrix\": \"Разрешение отклонено активированной матрицей полномочий\",\n      \"invalidRequestPath\": \"Путь запроса недействителен\",\n      \"notAllowedOperation\": \"У вас нет разрешения на выполнение этой операции\",\n      \"notAllowedDepartment\": \"Вам не разрешен доступ к этому отделу\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Роль по умолчанию не найдена\",\n      \"alreadyDisabled\": \"Матрица полномочий уже отключена\",\n      \"alreadyEnabled\": \"Матрица полномочий уже включена\",\n      \"notFound\": \"Матрица полномочий не найдена\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Основное поле не может быть отключено для чтения\",\n      \"fieldDuplicated\": \"Поле дублируется в конфигурации разрешений\",\n      \"cannotSetRecordPermissionGroup\": \"Невозможно установить эту комбинацию разрешений записи({{actions}})\",\n      \"notFoundBaseAndTable\": \"ID базы и ID таблицы не найдены\",\n      \"roleTablesShouldNotBeEmpty\": \"Таблицы ролей матрицы полномочий не должны быть пустыми\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Неверный тип возврата\",\n      \"exceedMaxReadRows\": \"Превышен максимальный лимит чтения строк\",\n      \"invalidCellValueType\": \"Неверный тип значения ячейки\",\n      \"exceedMaxCopyCells\": \"Превышен максимальный лимит копируемых ячеек\",\n      \"exceedMaxPasteCells\": \"Превышен максимальный лимит вставляемых ячеек\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Неподдерживаемый тип поля {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Неподдерживаемый тип поля {{type}} в качестве первичного поля\",\n      \"primaryFieldNotSupported\": \"Тип поля не поддерживается в качестве первичного поля\",\n      \"calculateRecordNotFound\": \"Запись не найдена для: {{value}}, fieldId: {{fieldId}}, при вычислении {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds или fromRecordIds требуется для обычного вычисляемого поля\",\n      \"recordFieldsRequired\": \"Поля записи не определены\",\n      \"uniqueUnsupportedType\": \"Поле {{name}}[{{fieldId}}] не поддерживает проверку уникальности значения поля\",\n      \"notNullValidationWhenCreateField\": \"Поле {{name}}[{{fieldId}}] не поддерживает проверку не null при создании нового поля\",\n      \"dbFieldNameAlreadyExists\": \"Имя поля базы данных {{dbFieldName}} уже существует\",\n      \"fieldValidationError\": \"Поле {{name}}[{{fieldId}}] ошибка проверки поля\",\n      \"fieldNameAlreadyExists\": \"Имя поля {{name}} уже существует\",\n      \"notFound\": \"Поле не найдено\",\n      \"fieldKeyTypeNotFound\": \"Поле \\\"{{fieldKeyType}}: {{missedFields}}\\\" не найдено\",\n      \"notFoundInTable\": \"Поле {{fieldId}} не найдено в таблице {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Поля для удаления {{fieldIds}} не найдены в таблице {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues должен быть массивом, когда поле связи имеет несколько значений ячейки\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues должен быть массивом, когда поле связи имеет несколько значений ячейки\",\n      \"lookupAndLinkLengthMatch\": \"Длина lookupValues должна быть такой же, как длина linkCellValues\",\n      \"cycleDetected\": \"Обнаружен цикл\",\n      \"cycleDetectedCreateField\": \"Обнаружен цикл, невозможно создать поле {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Запись не найдена в таблице {{tableName}} для поля {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Запрещено удалять первичное поле\",\n      \"foreignTableIdInvalid\": \"Внешняя таблица {{foreignTableId}} недействительна\",\n      \"relationshipInvalid\": \"Отношение {{relationship}} недействительно\",\n      \"linkFieldIdInvalid\": \"Поле связи {{linkFieldId}} недействительно\",\n      \"lookupFieldIdInvalid\": \"Поле поиска {{lookupFieldId}} недействительно\",\n      \"formulaExpressionParseError\": \"Ошибка разбора выражения формулы\",\n      \"formulaReferenceNotFound\": \"Поле ссылки формулы {{fieldIds}} не найдено\",\n      \"rollupExpressionParseError\": \"Ошибка разбора выражения сводки\",\n      \"choiceNameAlreadyExists\": \"Имя выбора {{name}} уже существует\",\n      \"symmetricFieldIdRequired\": \"Требуется идентификатор симметричного поля\",\n      \"foreignKeyNameCannotUseId\": \"Имя внешнего ключа не может использовать __id\",\n      \"createForeignKeyError\": \"Ошибка создания внешнего ключа\",\n      \"lookupFieldTypeNotEqual\": \"Текущий тип поля {{fieldType}} не равен типу поля поиска {{lookupFieldType}}\",\n      \"recordNotFound\": \"Запись {{recordId}} не найдена в {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Невозможно установить дублирующий recordId: {{recordId}} в той же ячейке\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Значения поля связи один-к-одному не могут быть массивом\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Значения поля связи многие-к-одному не могут быть массивом\",\n      \"foreignKeyDuplicate\": \"Дубликат внешнего ключа\",\n      \"linkConsistencyError\": \"Ошибка согласованности, recordId {{recordId}} не существует\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Значения поля связи один-ко-многим должны быть массивом\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Значения поля связи многие-ко-многим должны быть массивом\",\n      \"onlyLinkFieldCanBeFiltered\": \"Только поля связи могут использоваться для фильтрации\",\n      \"notLinkedToCurrentTable\": \"Поле не связано с текущей таблицей\",\n      \"notAttachment\": \"Поле не является полем вложения\",\n      \"isComputed\": \"Поле вычисляется и не может быть изменено\",\n      \"notFoundAICofig\": \"Поле не имеет конфигурации AI\",\n      \"foreignTableIdRequired\": \"Внешняя таблица обязательна\",\n      \"lookupFieldIdRequired\": \"Поле поиска обязательно\",\n      \"lookupFieldNotExist\": \"Поле поиска {{lookupFieldId}} не существует\",\n      \"lookupFieldNotBelongToTable\": \"Поле поиска {{lookupFieldId}} не принадлежит таблице {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Текущий тип поля {{fieldType}} не соответствует типу поля поиска {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Параметры поля условного свертывания обязательны\",\n      \"conditionalRollupParseError\": \"Ошибка парсинга условного свертывания: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Параметры поля условного поиска обязательны\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Количество нажатий кнопки достигло максимального предела\",\n        \"notSupportReset\": \"Кнопка не поддерживает сброс\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Представление не найдено\",\n      \"defaultViewNotFound\": \"Представление по умолчанию не найдено\",\n      \"propertyParseError\": \"Не удалось разобрать свойство представления\",\n      \"primaryFieldCannotBeHidden\": \"Первичное поле не может быть скрыто\",\n      \"filterUnsupportedFieldType\": \"Фильтр не поддерживает тип поля\",\n      \"sortUnsupportedFieldType\": \"Сортировка не поддерживает тип поля\",\n      \"groupUnsupportedFieldType\": \"Группировка не поддерживает тип поля\",\n      \"anchorNotFound\": \"Якорное представление не найдено\",\n      \"notEnoughGapToShuffleRow\": \"Недостаточно места для перемешивания строки\",\n      \"shareNotEnabled\": \"Общий доступ к представлению не включен\",\n      \"shareAlreadyEnabled\": \"Общий доступ к представлению уже включен\",\n      \"shareAlreadyDisabled\": \"Общий доступ к представлению уже отключен\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Недостаточно средств\",\n      \"exceedMaxRowLimit\": \"Превышен максимальный лимит строк {{maxRowCount}}\",\n      \"exceedMaxAutomationRunLimit\": \"Достигнут максимальный месячный лимит запусков автоматизации\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Требуется поисковой запрос\",\n      \"maxSearchIndexResult\": \"Максимальный результат поискового индекса составляет 1000\",\n      \"queryCollectionMustBeTableId\": \"Коллекция запросов должна быть идентификатором таблицы\",\n      \"searchTimeOut\": \"Поиск завершился, уменьшите область поиска и повторите.\",\n      \"indexNotFound\": \"Индекс не найден\",\n      \"invalidStartDateFieldId\": \"Недействительный идентификатор поля даты начала\",\n      \"invalidEndDateFieldId\": \"Недействительный идентификатор поля даты окончания\",\n      \"fieldMapRequired\": \"Карта полей требуется при установке поиска\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected и filterLinkCellCandidate нельзя установить одновременно\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"Модель чата ИИ lg не установлена\",\n      \"chatModelLgProviderNotSet\": \"Провайдер модели чата ИИ lg не установлен\",\n      \"chatModelSmNotSet\": \"Модель чата ИИ sm не установлена\",\n      \"chatModelMdNotSet\": \"Модель чата ИИ md не установлена\",\n      \"configurationNotSet\": \"Конфигурация ИИ не установлена\",\n      \"unsupportedProvider\": \"Неподдерживаемый провайдер ИИ {{type}}\",\n      \"providerConfigurationNotSet\": \"Конфигурация провайдера ИИ не установлена\",\n      \"testLLMFailed\": \"Тест подключения LLM не прошел\",\n      \"audioNotSupported\": \"Аудио ввод не поддерживается этой моделью {{model}}\",\n      \"imageNotSupported\": \"Ввод изображения не поддерживается этой моделью {{model}}\",\n      \"modelNotSet\": \"Модель ИИ не установлена\",\n      \"unsupportedFileType\": \"Неподдерживаемый тип файла {{mimetype}}\",\n      \"unsupportedModelType\": \"Неподдерживаемый тип модели\",\n      \"embeddingModelNotSet\": \"Модель встраивания не установлена\",\n      \"validateActionFailed\": \"Не удалось проверить действие AI поля\",\n      \"generateFailed\": \"Не удалось сгенерировать AI\",\n      \"unsupportedActionType\": \"Неподдерживаемый тип действия AI\"\n    },\n    \"role\": {\n      \"notFound\": \"Роль не найдена\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Соавтор уже существует\",\n      \"notFound\": \"Соавтор не найден\",\n      \"userNotFoundInCollaborator\": \"Пользователь не найден среди соавторов\",\n      \"noPermissionToDelete\": \"У вас нет разрешения на удаление этого соавтора\",\n      \"noPermissionToUpdate\": \"У вас нет разрешения на обновление этого соавтора\",\n      \"noPermissionToOperateRole\": \"У вас нет разрешения на управление этой ролью\",\n      \"alreadyExistedInBase\": \"Соавтор уже существует в базе данных\",\n      \"userNotFound\": \"Пользователь не найден: {{userIds}}\",\n      \"baseNotFound\": \"База данных не найдена\",\n      \"noPermissionToAddRole\": \"У вас нет разрешения на добавление соавтора с этой ролью\",\n      \"departmentNotFound\": \"Отдел не найден\"\n    },\n    \"table\": {\n      \"notFound\": \"Таблица не найдена\",\n      \"dbTableNameAlreadyExists\": \"Имя таблицы базы данных уже существует\",\n      \"anchorNotFound\": \"Якорная таблица не найдена\",\n      \"notInTrash\": \"Таблица не находится в корзине\",\n      \"notSupportTableIndex\": \"Тип индекса таблицы не поддерживается\",\n      \"createTableIndexError\": \"Не удалось создать индекс таблицы\",\n      \"dropTableIndexError\": \"Не удалось удалить индекс таблицы\",\n      \"notFoundPrimaryField\": \"Первичное поле не найдено в таблице\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"Тип представления {{viewType}} не поддерживается для экспорта\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Формат файла не поддерживается, поддерживаются только {{supportType}}, тип содержимого вашего файла {{fileFormat}}\",\n      \"notSupportedFileType\": \"Тип файла импорта не поддерживается\",\n      \"exceedMaxFieldsLength\": \"Количество полей в таблице не может превышать {{maxFieldsLength}}, текущее значение {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"Текущий экземпляр запрещает приглашения в пространство администратором\",\n      \"invalidCode\": \"Неверный код приглашения\",\n      \"linkNotFound\": \"Ссылка приглашения не найдена\",\n      \"linkExpired\": \"Срок действия ссылки приглашения истек\",\n      \"limitExceeded\": \"Вы достигли максимального количества приглашений в час\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Избранное уже существует\",\n      \"notFound\": \"Избранное не найдено\",\n      \"anchorNotFound\": \"Якорь избранного не найден\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Недопустимый тип ресурса\",\n      \"notFound\": \"Элемент корзины не найден\",\n      \"parentSpaceTrashed\": \"Невозможно восстановить эту базу, так как её родительское пространство также находится в корзине\",\n      \"parentBaseOrSpaceTrashed\": \"Невозможно восстановить эту таблицу, так как её родительская база или пространство также находится в корзине\",\n      \"parentBaseTrashed\": \"Невозможно восстановить этот элемент, так как его родительская база также находится в корзине\",\n      \"parentNotFound\": \"Родительский ресурс не найден\",\n      \"tableNotFound\": \"Элемент корзины таблицы не найден\"\n    },\n    \"license\": {\n      \"invalid\": \"Лицензия недействительна\",\n      \"instanceIdMismatch\": \"Входящий ID экземпляра не соответствует ID экземпляра текущего экземпляра\",\n      \"expired\": \"Лицензия истекла\",\n      \"userLimitExceeded\": \"Количество пользователей в текущем экземпляре превышает лимит мест лицензии. Пожалуйста, отключите некоторых пользователей или обновите лицензию\"\n    },\n    \"organization\": {\n      \"notFound\": \"Организация не найдена\",\n      \"emailNotSpaceUser\": \"Email не является пользователем пространства\",\n      \"authenticationNotFound\": \"Аутентификация не найдена\",\n      \"spaceShouldExist\": \"Пространство организации должно существовать\",\n      \"emailsNotInOrgDomain\": \"Эти электронные адреса {{emails}} не находятся в домене организации\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"Текущий экземпляр запрещает регистрацию администратором\",\n      \"waitlistInviteCodeRequired\": \"Список ожидания включен, требуется код приглашения\",\n      \"waitlistInviteCodeInvalid\": \"Список ожидания включен, код приглашения недействителен\",\n      \"systemUser\": \"Пользователь является системным пользователем\",\n      \"collaboratorsInSpaces\": \"У пользователя есть сотрудники в пространствах (или удаленные пространства в корзине)\",\n      \"notFound\": \"Пользователь не найден\",\n      \"cannotDeleteAdmin\": \"Невозможно удалить пользователя-администратора\",\n      \"cannotDeactivateAdmin\": \"Невозможно отключить пользователя-администратора\",\n      \"cannotRemoveLastAdmin\": \"Невозможно убрать права администратора у последнего активного пользователя-администратора\",\n      \"permanentDeleted\": \"Пользователь навсегда удален\",\n      \"cannotDeleteSelf\": \"Вы не можете удалить себя\",\n      \"alreadyInDepartment\": \"Пользователь {{userId}} уже находится в целевом отделе\",\n      \"emailsNotFound\": \"Электронные адреса {{emails}} не найдены\",\n      \"deleted\": \"Пользователь {{userId}} удалены\",\n      \"alreadyInOrg\": \"Пользователь {{userId}} уже находится в организации\",\n      \"notInOrg\": \"Пользователь {{userId}} не находится в организации\"\n    },\n    \"record\": {\n      \"notFound\": \"Запись не найдена\",\n      \"deletedIdsNotFound\": \"Некоторые записи для удаления не найдены\",\n      \"updateFailed\": \"Не удалось обновить запись\",\n      \"noFileOrUrlProvided\": \"Файл или URL не предоставлен\",\n      \"createRecordsEmpty\": \"Создание записей не может быть пустым\",\n      \"duplicateFailed\": \"Не удалось дублировать запись\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Ошибка проверки значения ячейки\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Автоматизация не открыта\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Недопустимый тип ресурса\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Категория шаблона не найдена\",\n      \"snapshotRequired\": \"Этот шаблон не может быть опубликован из-за отсутствия снимка\",\n      \"sourceTemplateNotFound\": \"Исходный шаблон не найден\",\n      \"noMinOrderFound\": \"Минимальный порядок не найден\",\n      \"takeCountTooLarge\": \"Количество запрошенных шаблонов превышает максимальный лимит\",\n      \"categoryLimitReached\": \"Достигнут лимит категорий шаблонов (максимум {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Код подтверждения домена не найден\",\n      \"invalidCode\": \"Неверный код подтверждения\",\n      \"resendCooldown\": \"Пожалуйста, подождите 1 минуту перед запросом нового кода\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Не удалось отправить электронное письмо\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Родительский отдел не найден\",\n      \"notFound\": \"Отдел не найден\",\n      \"cannotMoveToItself\": \"Невозможно переместить отдел в самого себя\",\n      \"cannotMoveToSub\": \"Невозможно переместить отдел в его подотдел\"\n    },\n    \"app\": {\n      \"notFound\": \"Приложение не найдено\",\n      \"noFilesToUpdate\": \"Нет файлов для обновления\",\n      \"noChatIdFound\": \"ID чата для этого приложения не найден\",\n      \"noChatFound\": \"Чат для этого приложения не найден\",\n      \"versionNotFound\": \"Версия не найдена\",\n      \"cannotRollbackToLatestVersion\": \"Невозможно откатить к последней версии\",\n      \"noChatOrProjectTokenFound\": \"Чат или токен проекта не найден\",\n      \"apiKeyNotSet\": \"API-ключ конструктора приложений не установлен\",\n      \"cannotDeployAppBeforeInitialization\": \"Невозможно развернуть приложение до инициализации\",\n      \"noProjectOrVersionFound\": \"Проект или версия не найдены\",\n      \"noDeploymentUrlAvailable\": \"URL развертывания недоступен\"\n    },\n    \"reward\": {\n      \"notFound\": \"Награда не найдена\",\n      \"unsupportedSourceType\": \"Неподдерживаемый тип источника награды\",\n      \"maxClaimsReached\": \"Вы достигли максимального количества запросов наград (2) на этой неделе\",\n      \"verificationFailed\": \"Ошибка проверки: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Вы уже запросили награду для этого аккаунта на этой неделе\",\n      \"invalidPostUrl\": \"Неверный формат URL публикации\",\n      \"postAlreadyUsed\": \"Эта публикация уже использовалась для получения награды\",\n      \"unsupportedPlatformUrl\": \"Неподдерживаемый URL социальной платформы\",\n      \"unsupportedPlatform\": \"Неподдерживаемая платформа: {{platform}}\",\n      \"minCharCount\": \"Публикация должна содержать не менее {{count}} символов\",\n      \"minFollowerCount\": \"Аккаунт должен иметь не менее {{count}} подписчиков\",\n      \"mustMention\": \"Публикация должна упоминать {{mention}}\",\n      \"fetchTweetFailed\": \"Не удалось получить твит X: {{error}}\",\n      \"tweetNotFound\": \"Твит X не найден: {{postId}}\",\n      \"fetchUserFailed\": \"Не удалось получить пользователя X: {{error}}\",\n      \"xUserNotFound\": \"Пользователь X не найден: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Не удалось получить публикацию LinkedIn: {{error}}\",\n      \"linkedInPostNotFound\": \"Публикация LinkedIn не найдена: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"Автор LinkedIn не найден: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Не удалось получить пользователя LinkedIn: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Персональные токены доступа\",\n  \"oauthApps\": \"OAuth приложения\",\n  \"plugins\": \"Плагины\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Введите пароль для просмотра этой страницы\",\n    \"submit\": \"Отправить\",\n    \"password\": \"Пароль\",\n    \"passwordTooShort\": \"Пароль должен содержать не менее 3 символов\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Выберите...\"\n  },\n  \"openOnNewPage\": \"Открыть на новой странице\",\n  \"errorTips\": \"Источник общего доступа включил матрицу полномочий, просмотр не разрешен\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}'s space\",\n  \"action\": {\n    \"createBase\": \"Создать базу\",\n    \"createSpace\": \"Создать пространство\",\n    \"invite\": \"Пригласить\"\n  },\n  \"allSpaces\": \"Все пространства\",\n  \"emptySpaceTitle\": \"В этом пространстве нет баз\",\n  \"spaceIsEmpty\": \"Создайте свою первую базу, чтобы начать\",\n  \"baseModal\": {\n    \"copy\": \"Копировать\",\n    \"duplicate\": \"Дублировать \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Создать базу из шаблона\",\n    \"duplicateRecords\": \"Дублировать записи\",\n    \"duplicateRecordsTip\": \"История изменений и сотрудники не будут дублироваться.\",\n    \"toSpace\": \"В пространство\",\n    \"copyToSpace\": \"Скопировать в пространство\",\n    \"duplicateBase\": \"Дублировать базу\",\n    \"missTargetTip\": \"Пожалуйста, выберите пространство для дублирования базы.\",\n    \"copying\": \"Идет дублирование базы, это может занять некоторое время...\",\n    \"copyingTemplate\": \"Создание базы из шаблона, это может занять некоторое время...\",\n    \"howToCreate\": \"С чего вы хотите начать?\",\n    \"fromScratch\": \"С нуля\",\n    \"fromTemplate\": \"Из шаблона\",\n    \"moveBaseToAnotherSpace\": \"Переместить {{baseName}} в другое пространство\",\n    \"chooseSpace\": \"Выберите пространство\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Настройки пространства\",\n    \"general\": \"Общие\",\n    \"collaborators\": \"Сотрудники\",\n    \"generalDescription\": \"Измените настройки текущего пространства здесь\",\n    \"collaboratorDescription\": \"Управляйте сотрудниками вашего пространства и устанавливайте их права доступа\",\n    \"spaceName\": \"Название пространства\",\n    \"spaceId\": \"ID пространства\"\n  },\n  \"pin\": {\n    \"add\": \"Добавить в закрепленные\",\n    \"remove\": \"Удалить из закрепленных\",\n    \"pin\": \"Закрепить\",\n    \"empty\": \"Ваши закрепленные базы и пространства появятся здесь\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"У вас нет разрешения на создание базы\"\n  },\n  \"tip\": {\n    \"delete\": \"Вы уверены, что хотите удалить <0/>?\",\n    \"title\": \"Советы\",\n    \"exportTips1\": \"Экспорт текущей базы в виде файла .tea, что может занять некоторое время. Вы можете проверить результаты экспорта в центре уведомлений.\",\n    \"exportTips2\": \"Вы можете импортировать файл .tea в пространство -> больше -> импорт\",\n    \"exportTips3\": \"Поля связи между базами будут преобразованы в однострочный текст.\",\n    \"exportIncludeDataLabel\": \"Включить записи\",\n    \"exportIncludeDataDescription\": \"Выключите, чтобы экспортировать только структуру и конфигурацию.\",\n    \"moveBaseSuccessTitle\": \"Перемещение успешно\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} успешно перемещена в {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Удалить пространство\",\n    \"blockedTitle\": \"Невозможно удалить это пространство\",\n    \"blockedDesc\": \"У этого пространства есть активная подписка. Пожалуйста, отмените подписку перед удалением пространства.\",\n    \"permanentDeleteWarning\": \"Это действие навсегда удалит все ресурсы и данные в текущем пространстве. Пожалуйста, действуйте осторожно!\",\n    \"confirmInputLabel\": \"Пожалуйста, введите DELETE для подтверждения удаления\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Общие базы\",\n    \"description\": \"Все базы, в которые меня пригласили\",\n    \"empty\": \"Общих баз пока нет\"\n  },\n  \"integration\": {\n    \"title\": \"Интеграции\",\n    \"description\": \"Управление интеграциями вашего пространства\",\n    \"addIntegration\": \"Добавить интеграцию\",\n    \"ai\": \"ИИ\"\n  },\n  \"aiSetting\": {\n    \"title\": \"Настройки ИИ\",\n    \"description\": \"Управление настройками ИИ вашего пространства\",\n    \"enableTips\": \"Включите ИИ, чтобы использовать функции ИИ в вашем пространстве вместо системного ИИ\",\n    \"enable\": \"Инициализировать настройки ИИ\",\n    \"enableSwitchTips\": \"Пожалуйста, настройте большую модель кодирования перед включением\"\n  },\n  \"import\": {\n    \"importing\": \"Импорт\",\n    \"importWayTip\": \"Нажмите или перетащите файл в эту область для загрузки\",\n    \"baseImportTips\": \"Нажмите или перетащите файл .tea в эту область для загрузки\",\n    \"confirm\": \"Подтвердить и продолжить\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Нажмите для просмотра\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Шаблон\",\n    \"description\": \"Быстро создать новую базу из шаблона\",\n    \"noTemplatesAvailable\": \"Нет доступных шаблонов\",\n    \"noTemplatesDescription\": \"Здесь пока ничего нет\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Недавно просмотренные\"\n  },\n  \"noBases\": {\n    \"title\": \"Привет, {{userName}}!\",\n    \"description\": \"Давайте начнем управлять вашей работой с вашей первой базой.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Привет, {{userName}}!\",\n    \"description\": \"Создайте свое первое пространство, чтобы начать путь совместной работы с данными.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Все базы\",\n    \"owner\": \"Владелец\",\n    \"createdTime\": \"Создано\",\n    \"lastOpened\": \"Последнее открытие\",\n    \"enter\": \"Войти\",\n    \"noTables\": \"Нет таблиц\",\n    \"empty\": \"Баз пока нет\"\n  },\n  \"publishBase\": {\n    \"title\": \"Опубликовать базу в сообществе\",\n    \"description\": \"Публикация базы в один клик, вдохновение больше не одиноко! Пусть больше людей увидят, используют и ремикшируют ваше творчество, создавая более мощный бизнес вместе\",\n    \"infoTitle\": \"Основная информация\",\n    \"form\": {\n      \"title\": \"Название\",\n      \"description\": \"Описание\",\n      \"security\": \"Безопасность\",\n      \"includeNodes\": \"Включить узлы\",\n      \"advanced\": \"Дополнительно\",\n      \"publishNode\": \"Опубликовать узлы\",\n      \"includeData\": \"Включить данные\",\n      \"defaultActiveNode\": \"Активный узел по умолчанию\",\n      \"select\": \"пожалуйста, выберите\",\n      \"descriptionPlaceholder\": \"Кратко опишите свою идею...\",\n      \"titlePlaceholder\": \"Назовите свою работу...\"\n    },\n    \"publishToCommunity\": \"Опубликовать в центре шаблонов\",\n    \"publish\": \"Опубликовать\",\n    \"publishSuccess\": \"Успешно опубликовано!\",\n    \"previewTips\": \"Покажите свою работу миру\",\n    \"update\": \"Обновить\",\n    \"unPublish\": \"Снять с публикации\",\n    \"unPublishSuccess\": \"База успешно снята с публикации!\",\n    \"unPublishConfirmTitle\": \"Подтвердите снятие с публикации\",\n    \"unPublishConfirmDescription\": \"Вы уверены, что хотите снять эту базу с публикации? Она больше не будет видна в центре шаблонов сообщества.\",\n    \"usageCount\": \"Количество использований: \",\n    \"uploadCover\": \"Нажмите, чтобы загрузить обложку\",\n    \"changeCover\": \"Нажмите, чтобы изменить обложку\",\n    \"uploading\": \"Загрузка изображения...\",\n    \"uploadSuccess\": \"Изображение успешно загружено\",\n    \"uploadFailed\": \"Ошибка загрузки\",\n    \"invalidImageType\": \"Пожалуйста, выберите файл изображения\",\n    \"tips\": {\n      \"publishValidation\": \"название и описание обязательны\",\n      \"atLeastOneNode\": \"Выберите хотя бы один узел для публикации\"\n    },\n    \"urlCopied\": \"URL скопирован в буфер обмена!\",\n    \"urlCopiedForDiscord\": \"URL скопирован в буфер обмена! Вы можете вставить его в Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Официально выбран как избранный. Ваш шаблон получит больше внимания.\",\n    \"unfeaturedTip\": \"Пока не избран. Продолжайте улучшать, чтобы получить шанс на рекомендацию. Пусть больше людей увидят вашу работу.\",\n    \"publishSuccessDescription\": \"Поделитесь своей работой с миром\",\n    \"shareWith\": \"Поделиться с\",\n    \"unpublishedApps\": {\n      \"title\": \"Обнаружены неопубликованные приложения\",\n      \"description\": \"Неопубликованные приложения могут вызвать ошибки предпросмотра шаблона. Опубликуйте их сейчас или продолжите.\",\n      \"publishAll\": \"Опубликовать все\",\n      \"publish\": \"Опубликовать\",\n      \"published\": \"Опубликовано\",\n      \"publishing\": \"Публикация...\",\n      \"publishFailed\": \"Ошибка публикации\",\n      \"publishFailedTip1\": \"Проверьте, может ли исходное приложение быть успешно опубликовано\",\n      \"publishFailedTip2\": \"Попробуйте повторно опубликовать этот шаблон\",\n      \"notPublished\": \"Не опубликовано\",\n      \"ignoreAndContinue\": \"Игнорировать и продолжить\",\n      \"goToFix\": \"Перейти к исправлению\",\n      \"redeploy\": \"Повторить развёртывание\",\n      \"unnamedApp\": \"Безымянное приложение\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Скоро\",\n    \"viewFilterInShare\": \"Этот вид используется в ссылке для общего доступа. Изменения в настройках вида также изменят ссылку для общего доступа.\",\n    \"createFieldButtonText\": \"Создать новое поле <0/>\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Поделиться\",\n        \"statusLabel\": \"Поделиться видом в интернете\",\n        \"shareLink\": \"Ссылка для общего доступа\",\n        \"copied\": \"Скопировано\",\n        \"genLink\": \"Создать новую ссылку\",\n        \"allowCopy\": \"Разрешить зрителям копировать данные из этого вида\",\n        \"showAllFields\": \"Показать все поля в развернутых записях\",\n        \"restrict\": \"Ограничить паролем\",\n        \"tips\": \"Люди, у которых есть ссылка, могут видеть этот вид.\",\n        \"passwordTitle\": \"Введите пароль\",\n        \"passwordTips\": \"Ограничения по паролю для доступа к общим видам\",\n        \"embed\": \"Встроить\",\n        \"embedPreview\": \"Предпросмотр встраивания\",\n        \"hideToolbar\": \"Скрыть панель инструментов\",\n        \"URLSetting\": \"Настройка параметров URL\",\n        \"URLSettingDescription\": \"Изменение следующих настроек не повлияет на уже созданные ссылки. Вам нужно скопировать ссылку с новыми параметрами, чтобы они вступили в силу\",\n        \"cancel\": \"Отмена\",\n        \"save\": \"Сохранить\"\n      },\n      \"extensions\": {\n        \"label\": \"Расширения\",\n        \"graph\": \"График\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"Подключение к базе данных\"\n      },\n      \"personalView\": {\n        \"personal\": \"Личное\",\n        \"tip\": \"После включения настройка вида будет применяться только к вам\",\n        \"collaborative\": \"Коллаборативное\",\n        \"dialog\": {\n          \"title\": \"Выйти из личного режима\",\n          \"description\": \"Настройка личного отображения будет восстановлена до состояния совместной работы в реальном времени. Вы также можете сохранить настройки личного отображения и синхронизировать их для всех.\",\n          \"cancelText\": \"Выйти и синхронизировать\",\n          \"confirmText\": \"Подтвердить выход\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Добро пожаловать\",\n    \"emptyTitle\": \"Начните создавать свою базу данных\",\n    \"description\": \"Нажмите кнопку «+» в боковой панели, чтобы добавить ресурсы и начать эффективно управлять своими данными\",\n    \"help\": \"Посетите <HelpCenter /> для получения дополнительной информации.\",\n    \"helpCenter\": \"Центр помощи\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Невозможно связать запись: в этом же пакете она уже связана другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.\",\n      \"one_many_duplicate\": \"Невозможно связать запись: она уже связана с другой записью. В отношениях один-ко-многим каждая дочерняя запись может принадлежать только одной родительской записи.\",\n      \"one_one_duplicate\": \"Невозможно связать запись: целевая запись уже связана другой записью в отношении один-к-одному.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"Таблица \\\"{{tableName}}\\\" может содержать не более {{maxFieldCount}} полей.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Управление полями\",\n    \"fieldManagementDesc\": \"Детальные свойства всех полей текущей таблицы\",\n    \"advancedProps\": \"Расширенные свойства\",\n    \"hide\": \"скрыть\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Метка\"\n      },\n      \"longText\": {\n        \"title\": \"Заметки\"\n      },\n      \"number\": {\n        \"title\": \"Число\",\n        \"formatType\": \"Тип формата\",\n        \"currencySymbol\": \"Символ валюты\",\n        \"defaultSymbol\": \"₽\",\n        \"precision\": \"Точность\",\n        \"decimalExample\": \"Число (123)\",\n        \"currencyExample\": \"Валюта (100₽)\",\n        \"percentExample\": \"Процент (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Статус\",\n        \"options\": {\n          \"todo\": \"К выполнению\",\n          \"inProgress\": \"В процессе\",\n          \"done\": \"Выполнено\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Теги\"\n      },\n      \"attachment\": {\n        \"title\": \"Вложения\"\n      },\n      \"user\": {\n        \"title\": \"Соавтор\"\n      },\n      \"date\": {\n        \"title\": \"Дата\",\n        \"dateFormatting\": \"Формат даты\",\n        \"timeFormatting\": \"Формат времени\",\n        \"timeZone\": \"Часовой пояс\",\n        \"yearMonth\": \"Год/Месяц\",\n        \"monthDay\": \"Месяц/День\",\n        \"year\": \"Год\",\n        \"month\": \"Месяц\",\n        \"day\": \"День\",\n        \"local\": \"Местный\",\n        \"friendly\": \"Дружественный\",\n        \"us\": \"США\",\n        \"european\": \"Европейский\",\n        \"asia\": \"Азиатский\",\n        \"custom\": \"Пользовательский\",\n        \"12Hour\": \"12-часовой\",\n        \"24Hour\": \"24-часовой\",\n        \"noDisplay\": \"Не отображать\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Время создания\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Время последнего изменения\"\n      },\n      \"createdBy\": {\n        \"title\": \"Создано\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Последнее изменение\"\n      },\n      \"rating\": {\n        \"title\": \"Рейтинг\"\n      },\n      \"checkbox\": {\n        \"title\": \"Выполнено\"\n      },\n      \"button\": {\n        \"title\": \"Кнопка\",\n        \"label\": \"Текст кнопки\",\n        \"color\": \"Цвет кнопки\",\n        \"limitCount\": \"Ограничить количество кликов\",\n        \"resetCount\": \"Разрешить сброс счетчика кликов\",\n        \"maxCount\": \"Максимальное количество кликов\",\n        \"automation\": \"Автоматизация\",\n        \"customAutomation\": \"Настраиваемая автоматизация\",\n        \"clickConfirm\": \"Подтвердить перед кликом\",\n        \"confirmTitle\": \"Заголовок\",\n        \"confirmDescription\": \"Описание\",\n        \"confirmButtonText\": \"Текст кнопки подтверждения\"\n      },\n      \"formula\": {\n        \"title\": \"Вычисление\",\n        \"formula\": \"Формула\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (из {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (отфильтровано из {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Сводка (из {{linkFieldName}})\",\n        \"rollup\": \"Сводка\",\n        \"selectAnRollupFunction\": \"Выберите функцию сводки\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Возвращает истину, если все значения истинны\",\n          \"arrayCompact\": \"Удаляет пустые строки и нулевые значения из массива. Сохраняет 'false' и строки, содержащие один или более пробельных символов.\",\n          \"arrayJoin\": \"Объединяет все значения в одну строку, разделенную запятыми.\",\n          \"arrayUnique\": \"Возвращает только уникальные элементы.\",\n          \"average\": \"Среднее арифметическое значений.\",\n          \"concatenate\": \"Объединяет текстовые значения в одно текстовое значение.\",\n          \"count\": \"Считает только непустые числовые значения. Если вы хотите посчитать все записи, используйте КОЛИЧЕСТВОВСЕ.\",\n          \"countA\": \"Считает количество непустых значений. Эта функция считает как числовые, так и текстовые значения.\",\n          \"countAll\": \"считает все значения, включая пустые записи.\",\n          \"max\": \"Возвращает наибольшее из заданных чисел.\",\n          \"min\": \"Возвращает наименьшее из заданных чисел.\",\n          \"or\": \"Возвращает истину, если хотя бы одно из значений истинно.\",\n          \"sum\": \"Суммирует значения.\",\n          \"xor\": \"Возвращает истину тогда и только тогда, когда нечетное количество значений истинно.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} условная сводка\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Добавить поле\",\n      \"editField\": \"Редактировать поле\",\n      \"insertField\": \"Вставить поле\",\n      \"graph\": \"График\",\n      \"fieldUpdated\": \"Поле обновлено\",\n      \"fieldCreated\": \"Поле создано\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Вы уверены, что хотите выполнить это?\",\n      \"addDescription\": \"Добавить описание\",\n      \"dbFieldName\": \"Физическое имя поля\",\n      \"description\": \"Описание\",\n      \"descriptionPlaceholder\": \"Опишите это поле (необязательно)\",\n      \"type\": \"Тип\",\n      \"showAs\": \"Отображать как\",\n      \"color\": \"Цвет\",\n      \"number\": \"Число\",\n      \"chartBar\": \"Столбчатая диаграмма\",\n      \"chartLine\": \"Линейная диаграмма\",\n      \"ring\": \"Кольцо\",\n      \"bar\": \"Полоса\",\n      \"text\": \"Текст\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"URL\",\n      \"email\": \"Email\",\n      \"phone\": \"Телефон\",\n      \"maxNumber\": \"Максимальное число\",\n      \"showNumber\": \"Показать число\",\n      \"autoFillDate\": \"Автозаполнение текущей датой\",\n      \"createSymmetricLink\": \"Создать обратную связь в связанной таблице\",\n      \"allowLinkMultipleRecords\": \"Разрешить множественный выбор\",\n      \"allowLinkToDuplicateRecords\": \"Разрешить выбирать записи более одного раза\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Разрешить выбирать записи более одного раза\",\n      \"oneToOne\": \"один-к-одному\",\n      \"oneToMany\": \"один-ко-многим\",\n      \"manyToOne\": \"многие-к-одному\",\n      \"manyToMany\": \"многие-ко-многим\",\n      \"self\": \"Сам\",\n      \"selectTable\": \"Выберите таблицу...\",\n      \"inSelfLink\": \"в самосвязи\",\n      \"betweenTwoTables\": \"между двумя таблицами\",\n      \"tips\": \"Подсказка\",\n      \"linkTipMessage\": \"эта конфигурация представляет<br></br> <b>{{relationship}}</b> отношение <span>{{linkType}}</span>\",\n      \"style\": \"Стиль\",\n      \"maximum\": \"Максимум\",\n      \"addOption\": \"Добавить вариант\",\n      \"allowMultiUsers\": \"Разрешить добавление нескольких пользователей\",\n      \"notifyUsers\": \"Уведомлять пользователей после их выбора\",\n      \"searchTable\": \"Поиск таблицы...\",\n      \"calculating\": \"Вычисление...\",\n      \"doSaveChanges\": \"Вы хотите сохранить внесенные изменения?\",\n      \"linkFieldToLookup\": \"Связанное поле записи для поиска\",\n      \"lookupToTable\": \"Поле из <bold>{{tableName}}</bold>, которое вы хотите искать\",\n      \"rollupToTable\": \"Поле из <bold>{{tableName}}</bold>, которое вы хотите искать\",\n      \"selectField\": \"Выберите поле...\",\n      \"linkTable\": \"Связать таблицу\",\n      \"noLinkTip\": \"Нет связанных записей для поиска. Добавьте поле Ссылка на другую запись, затем попробуйте настроить поиск снова.\",\n      \"fieldValidationRules\": \"Правила проверки значения поля\",\n      \"enableValidateFieldUnique\": \"Только уникальные значения\",\n      \"enableValidateFieldNotNull\": \"Обязательное\",\n      \"knowMore\": \"узнать больше\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Показать записи по полю\",\n      \"filterByView\": \"Фильтровать записи по представлению\",\n      \"filter\": \"Фильтровать записи\",\n      \"hideFields\": \"Скрыть поля\",\n      \"moreOptions\": \"Дополнительные параметры\",\n      \"allowNewOptionsWhenEditing\": \"Разрешить новые варианты при редактировании\",\n      \"deleteField\": {\n        \"title\": \"Удалить поле\",\n        \"simpleConfirm\": \"Вы уверены, что хотите удалить поле <b>{{fieldName}}</b>?\",\n        \"withDependencies\": \"Удаление поля <b>{{fieldName}}</b> повлияет на следующие поля:\",\n        \"affectedFields\": \"Затронутые поля:\",\n        \"fieldsToDelete\": \"Поля для удаления ({{count}})\",\n        \"unviewedHint\": \"{{count}} поле(й) не просмотрено\",\n        \"deleteCount\": \"Удалить {{count}} полей\",\n        \"noAffectedFields\": \"Это поле не используется другими полями.\",\n        \"riskIdentified\": \"Риск обнаружен({{count}})\",\n        \"noDependencies\": \"Нет зависимостей({{count}})\",\n        \"safeToDelete\": \"Безопасно удалить\",\n        \"safeToDeleteDesc\": \"Это поле не используется другими полями, его можно безопасно удалить\",\n        \"affectedItems\": \"Затронутые элементы\",\n        \"type\": \"Тип\",\n        \"source\": \"Источник\",\n        \"sourceTable\": \"Исходная таблица\",\n        \"typeField\": \"Поле\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Связать с записями в выбранной таблице\",\n      \"singleLineText\": \"Введите текст или предварительно заполните каждую новую ячейку значением по умолчанию.\",\n      \"longText\": \"Введите несколько строк текста.\",\n      \"attachment\": \"Добавьте или сгенерируйте с помощью ИИ изображения, или загрузите документы и другие файлы для просмотра или скачивания.\",\n      \"checkbox\": \"Отметьте или снимите отметку для указания статуса.\",\n      \"multipleSelect\": \"Выберите один или несколько предопределенных вариантов из списка.\",\n      \"singleSelect\": \"Выберите один предопределенный вариант из списка или предварительно заполните каждую новую ячейку вариантом по умолчанию.\",\n      \"user\": \"Добавьте пользователя к записи.\",\n      \"date\": \"Введите дату (например, 12.11.2023) или выберите из календаря.\",\n      \"number\": \"Введите число или предварительно заполните каждую новую ячейку значением по умолчанию.\",\n      \"duration\": \"Введите продолжительность времени в часах, минутах или секундах (например, 1:23).\",\n      \"rating\": \"Добавьте оценку по предопределенной шкале.\",\n      \"formula\": \"Вычислите значения на основе полей.\",\n      \"rollup\": \"Суммируйте данные из связанных записей.\",\n      \"conditionalLookup\": \"Показывает связанные значения, соответствующие заданным фильтрам.\",\n      \"count\": \"Подсчитайте количество связанных записей.\",\n      \"createdTime\": \"Посмотрите дату и время создания каждой записи.\",\n      \"lastModifiedTime\": \"Посмотрите дату и время последнего редактирования некоторых или всех полей в записи.\",\n      \"createdBy\": \"Посмотрите, какой пользователь создал запись.\",\n      \"lastModifiedBy\": \"Посмотрите, какой пользователь внес последние изменения в некоторые или все поля в записи.\",\n      \"autoNumber\": \"Автоматически генерируйте уникальные возрастающие числа для каждой записи.\",\n      \"button\": \"Запустите настраиваемое действие.\",\n      \"lookup\": \"Просмотрите значения из поля в связанной записи.\"\n    },\n    \"fieldName\": \"Имя поля\",\n    \"fieldNameOptional\": \"Имя поля (Необязательно)\",\n    \"fieldType\": \"Тип поля\",\n    \"aiConfig\": {\n      \"title\": \"Конфигурация AI\",\n      \"type\": {\n        \"summary\": \"Сводка\",\n        \"translation\": \"Перевод\",\n        \"extraction\": \"Извлечение информации\",\n        \"improvement\": \"Улучшение\",\n        \"tag\": \"Смарт-тег\",\n        \"classification\": \"Смарт-классификация\",\n        \"customization\": \"Настройка\",\n        \"imageGeneration\": \"Генерация изображений\",\n        \"rating\": \"Оценка изображений\"\n      },\n      \"label\": {\n        \"type\": \"Тип действия AI\",\n        \"model\": \"Модель AI\",\n        \"targetLanguage\": \"Целевой язык\",\n        \"sourceField\": \"Исходное поле\",\n        \"sourceFieldForTag\": \"Выберите поле, сопоставьте его с созданными тегами\",\n        \"sourceFieldForClassify\": \"Выберите поле, сопоставьте его с созданными классификациями\",\n        \"attachPrompt\": \"Прикрепить требования\",\n        \"prompt\": \"Настройка подсказки\",\n        \"sourceFieldForAttachment\": \"Исходное поле для вложения\",\n        \"imageSize\": \"Размер изображения\",\n        \"imageQuality\": \"Качество изображения\",\n        \"imageCount\": \"Количество изображений\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Сводка содержимого\",\n        \"translate\": \"Перевод краткого и легкого для понимания, легкого\",\n        \"extractInfo\": \"Извлечь email, телефон, имя, адрес...\",\n        \"extractDate\": \"Извлечь время начала\",\n        \"improveText\": \"Формальный, дружелюбный, юмористический...\",\n        \"attachPromptForTag\": \"Не разрешено превышать три тега\",\n        \"attachPromptForClassify\": \"Классифицировать “В процессе” как “Без риска”\",\n        \"attachPrompt\": \"Пожалуйста, введите дополнительные требования\",\n        \"prompt\": \"Пожалуйста, введите пользовательскую подсказку\",\n        \"type\": \"Выберите действие AI\",\n        \"targetLanguage\": \"Английский, китайский, французский...\",\n        \"imageSize\": \"Пожалуйста, введите размер изображения\",\n        \"imageQuality\": \"Пожалуйста, введите качество изображения\",\n        \"attachPromptForImageGeneration\": \"Изображение должно быть ярким и естественным\",\n        \"attachPromptForRating\": \"Оцените качество изображения\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Низкое\",\n        \"medium\": \"Среднее\",\n        \"high\": \"Высокое\"\n      },\n      \"autoFill\": {\n        \"title\": \"Автоматическое обновление\",\n        \"tip\": \"После включения текущее поле будет синхронно обновляться с изменениями содержимого конфигурации AI\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Обновить все записи\",\n        \"description\": \"Все записи в текущем представлении будут обновлены, включая все связанные данные, сгенерированные полем\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Сгенерировать весь столбец?\",\n        \"description\": \"Генерация всего столбца обновит {{rowCount}} строк. Эта операция может потреблять большое количество ресурсов ИИ.\",\n        \"saveConfigOnly\": \"Сохранить только конфигурацию\",\n        \"generate\": \"Сгенерировать\",\n        \"generateFailed\": \"Не удалось выполнить генерацию\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Добавить вложение\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Новая таблица\",\n    \"rename\": \"Переименовать\",\n    \"design\": \"Дизайн\",\n    \"tableRecordHistory\": \"История записей таблицы\",\n    \"deleteConfirm\": \"Вы уверены, что хотите удалить \\\"{{tableName}}\\\"?\",\n    \"dbTableName\": \"Имя таблицы в физической базе данных\",\n    \"schemaName\": \"Имя схемы в физической базе данных\",\n    \"baseInfo\": \"Базовая информация\",\n    \"typeOfDatabase\": \"Тип базы данных\",\n    \"descriptionForTable\": \"Описание этой таблицы\",\n    \"nameForTable\": \"Название этой таблицы\",\n    \"deleteTip1\": \"Связанные поля в других таблицах, связанные с этой таблицей, будут удалены.\",\n    \"deleteTip2\": \"Эту таблицу можно восстановить из корзины после удаления.\",\n    \"operator\": {\n      \"createBlank\": \"Новая таблица\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Копирование и вставка работают только в HTTPS или localhost\",\n      \"copyAndPasteBrowser\": \"Копирование и вставка не поддерживаются в этом браузере\",\n      \"copying\": \"Копирование...\",\n      \"copySuccessful\": \"Копирование успешно\",\n      \"copyFailed\": \"Копирование не удалось\",\n      \"pasting\": \"Вставка...\",\n      \"pasteSuccessful\": \"Вставка успешна\",\n      \"pasteFailed\": \"Вставка не удалась\",\n      \"filling\": \"Заполнение...\",\n      \"fillSuccessful\": \"Заполнение успешно\",\n      \"fillFailed\": \"Заполнение не удалось\",\n      \"clearing\": \"Очистка...\",\n      \"clearSuccessful\": \"Очистка успешна\",\n      \"deleteFieldConfirmTitle\": \"Вы собираетесь удалить следующие поля\",\n      \"deleting\": \"Удаление...\",\n      \"deleteSuccessful\": \"Удаление успешно\",\n      \"pasteFileFailed\": \"Файлы можно вставлять только в поле вложений\",\n      \"copyError\": {\n        \"noFocus\": \"Пожалуйста, не меняйте окно\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Метка таблицы\",\n      \"effectCells\": \"Может повлиять на ячейки\",\n      \"estimatedTime\": \"Расчетное время\",\n      \"linkFieldCount\": \"Влияет на количество связанных полей\"\n    },\n    \"integrity\": {\n      \"check\": \"Проверить\",\n      \"title\": \"Проверка целостности\",\n      \"loading\": \"Проверка целостности...\",\n      \"allGood\": \"Все хорошо!\",\n      \"fixIssues\": \"Исправить проблемы\"\n    },\n    \"index\": {\n      \"description\": \"Индексы могут значительно улучшить производительность поиска, особенно при работе с большими объемами данных (более {{rowCount}} строк). Недостатком является немного более медленные операции записи. Если вы часто выполняете поиск или работаете с большими наборами данных, рекомендуется включить индексирование.\",\n      \"repair\": \"Исправить\",\n      \"repairTip\": \"Обнаружены аномалии индекса, которые могут привести к снижению производительности поиска. Рекомендуется нажать кнопку исправить для устранения проблем с индексом\",\n      \"enableIndexTip\": \"Вы создаете индекс. Необходимое время зависит от размера таблицы. В процессе создания производительность чтения и записи таблицы может быть затронута. Пожалуйста, будьте терпеливы.\",\n      \"globalSearchTip_infinity\": \"Поиск по всем полям, кроме полей даты, флажков и кнопок\",\n      \"globalSearchTip_limited\": \"Поиск по всем полям, кроме полей даты, флажков и кнопок, максимум {{count}} полей\",\n      \"autoIndexTip\": \"Ваша таблица превысила {{rowCount}} строк. Мы рекомендуем включить индексирование для улучшения производительности поиска. Обратите внимание, что индексирование может немного снизить скорость записи. Во время создания индекса производительность чтения/записи таблицы может быть временно затронута. Пожалуйста, будьте терпеливы.\",\n      \"enableIndex\": \"Включить\",\n      \"keepAsIs\": \"Оставить как есть\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Загрузить\",\n      \"import\": \"Импорт\",\n      \"localFile\": \"Локальные файлы\",\n      \"linkUrl\": \"Ссылка (URL)\",\n      \"linkUrlInputTitle\": \"Добавить файл по URL\",\n      \"importTitle\": \"Создать новую таблицу\",\n      \"incrementImportTitle\": \"Импорт в — \",\n      \"optionsTitle\": \"Параметры импорта\",\n      \"primitiveFields\": \"Простые поля\",\n      \"importFields\": \"Поля импорта\",\n      \"primaryField\": \"Основное поле\",\n      \"tipsTitle\": \"Советы\",\n      \"confirm\": \"Подтвердить и продолжить\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Добавить из других источников\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"Csv файл\",\n      \"importCsvData\": \"Импортировать данные CSV\",\n      \"importExcelData\": \"Импортировать данные Microsoft Excel\",\n      \"cancel\": \"Отмена\",\n      \"leave\": \"Выйти\",\n      \"downAsCsv\": \"Скачать csv\",\n      \"importData\": \"Импортировать данные\",\n      \"duplicate\": \"Дублировать\",\n      \"duplicating\": \"Дублирование...\",\n      \"duplicateSuccess\": \"Успешно дублировано\",\n      \"duplicateFailed\": \"Не удалось дублировать\",\n      \"importing\": \"Импорт\",\n      \"includeRecords\": \"Включить записи\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Нажмите или перетащите файл в эту область для загрузки\",\n      \"leaveTip\": \"Ваши данные все равно будут импортированы.\",\n      \"fileExceedSizeTip\": \"Размер файла этого типа превышает лимит в\",\n      \"analyzing\": \"анализ\",\n      \"importing\": \"Импорт\",\n      \"notSupportFieldType\": \"Тип поля не поддерживается\",\n      \"resultEmpty\": \"Результаты не найдены.\",\n      \"searchPlaceholder\": \"Поиск...\",\n      \"importAlert\": \"После начала импорта его невозможно остановить до успешного завершения или прерывания из-за ошибки. Статус импорта отображается в правом верхнем углу таблицы. О результатах процесса импорта вам будет сообщено по завершении. Не следует обновлять поле во время импорта, так как это может вызвать ошибки.\",\n      \"noTips\": \"Я понял, больше не показывать\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Автоматический выбор типов полей\",\n      \"useFirstRowAsHeaderOptionName\": \"Использовать первую строку как заголовок\",\n      \"importDataOptionName\": \"Импортировать данные\",\n      \"sheetKey\": \"Имя листа: \",\n      \"excludeFirstRow\": \"Исключить первую строку при импорте\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Поле\",\n      \"error\": {\n        \"urlEmptyTip\": \"URL не должен быть пустым!\",\n        \"errorFileFormat\": \"Неправильный формат файла!\",\n        \"uniqueFieldName\": \"Имя поля должно быть уникальным!\",\n        \"fieldNameEmpty\": \"Имя поля не должно быть пустым!\",\n        \"atLeastAImportField\": \"Пожалуйста, установите хотя бы одно поле для импорта\",\n        \"urlValidateTip\": \"Не удалось разобрать URL. Попробуйте другой URL!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Не импортировать\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Скачать Csv\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Добавить новую запись\",\n    \"prefillingRowTooltip\": \"Пожалуйста, введите данные новой записи ниже. Запись будет сохранена автоматически, как только вы кликнете вне этой строки.\",\n    \"presortRowTitle\": \"Эта запись была отфильтрована или перемещена из-за правил сортировки\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Поля\",\n    \"addAll\": \"Добавить все\",\n    \"removeAll\": \"Удалить все\",\n    \"hideFieldTip\": \"Скрыть поле сюда\",\n    \"unableAddFieldTip\": \"Невозможно добавить поле этого типа\",\n    \"removeFromFormTip\": \"Удалить из формы\",\n    \"descriptionPlaceholder\": \"Введите описание формы\",\n    \"dragToFormTip\": \"Перетащите поле сюда, чтобы добавить его в форму\",\n    \"protectedFieldTip\": \"Это поле было установлено как \\\"обязательное\\\" поле и не может быть удалено в виде формы. Пожалуйста, измените его в настройках поля.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Скрыть имя поля\",\n      \"customizeCards\": \"Настроить карточки\",\n      \"stackedBy\": \"Сгруппировано по <0/>\",\n      \"chooseStackingField\": \"Выберите поле для группировки\",\n      \"chooseStackingFieldDescription\": \"Какое поле вы хотите использовать для настройки канбана? Ваши записи будут сгруппированы на основе этого поля.\",\n      \"hideEmptyStack\": \"Скрыть пустые группы\",\n      \"imageSetting\": \"Настройка изображения\",\n      \"fit\": \"По размеру\",\n      \"noImage\": \"Без изображения\",\n      \"chooseAttachmentField\": \"Выберите поле вложений\"\n    },\n    \"stack\": {\n      \"addStack\": \"Добавить группу\",\n      \"noCards\": \"Нет карточек\",\n      \"uncategorized\": \"Без категории\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Свернуть группу\",\n      \"renameStack\": \"Переименовать группу\",\n      \"deleteStack\": \"Удалить группу\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Вставить карточку выше\",\n      \"insertCardBelow\": \"Вставить карточку ниже\",\n      \"expandCard\": \"Развернуть карточку\",\n      \"deleteCard\": \"Удалить карточку\",\n      \"duplicateCard\": \"Дублировать карточку\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Настройка календаря\",\n      \"startDateField\": \"Начальная дата\",\n      \"endDateField\": \"Конечная дата\",\n      \"titleField\": \"Заголовок\",\n      \"colorField\": \"Цвет\",\n      \"colorType\": \"Отображение цвета\",\n      \"customColor\": \"Настроить цвет\",\n      \"alignWithRecords\": \"Соответствовать записям\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Выберите поле цвета\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Начальная дата\",\n      \"endDate\": \"Конечная дата\",\n      \"notAdd\": \"Не добавлять\",\n      \"addDateField\": \"Добавить дату\",\n      \"content\": \"Создать календарный вид, и в таблице должно быть два поля даты: начальная дата и конечная дата\"\n    },\n    \"moreLinkText\": \"Показать все {{count}} записей\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Вставить запись выше\",\n    \"insertRecordBelow\": \"Вставить запись ниже\",\n    \"copyCells\": \"Копировать ячейки\",\n    \"deleteRecord\": \"Удалить запись\",\n    \"deleteAllSelectedRecords\": \"Удалить все выбранные записи\",\n    \"editField\": \"Редактировать поле\",\n    \"insertFieldLeft\": \"Вставить слева\",\n    \"insertFieldRight\": \"Вставить справа\",\n    \"freezeUpField\": \"Закрепить до этого поля\",\n    \"hideField\": \"Скрыть поле\",\n    \"deleteField\": \"Удалить поле\",\n    \"deleteAllSelectedFields\": \"Удалить все выбранные поля\",\n    \"autoFill\": \"Обновить все записи\",\n    \"groupMenuTitle\": \"Меню группы\",\n    \"expandGroup\": \"Развернуть эту группу и ее подгруппы\",\n    \"collapseGroup\": \"Свернуть эту группу и ее подгруппы\",\n    \"expandAllGroups\": \"Развернуть все группы\",\n    \"collapseAllGroups\": \"Свернуть все группы\",\n    \"addToChat\": \"Добавить в чат\"\n  },\n  \"connection\": {\n    \"title\": \"Подключение к базе данных\",\n    \"description\": \"Вы можете получить прямой доступ к базе данных через подключение к базе данных, включая все таблицы в текущей базе\",\n    \"noPermission\": \"У вас нет разрешения на доступ к базе данных\",\n    \"connectionCountTip\": \"Максимальное количество подключений к базе данных - <b>{{max}}</b>, текущее количество подключений - <b>{{current}}</b>\",\n    \"createFailed\": \"Пожалуйста, убедитесь, что переменная окружения PUBLIC_DATABASE_PROXY установлена правильно\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Добавить запись\",\n    \"searchView\": \"Поиск вида...\",\n    \"dragToolTip\": \"Включена автоматическая сортировка, ручное перетаскивание недоступно\",\n    \"insertToolTip\": \"Включена автоматическая сортировка, вставка с порядком недоступна\",\n    \"action\": {\n      \"rename\": \"Переименовать вид\",\n      \"duplicate\": \"Дублировать представление\",\n      \"delete\": \"Удалить вид\",\n      \"lock\": \"Заблокировать вид\",\n      \"unlock\": \"Разблокировать вид\",\n      \"enable\": \"Включить\"\n    },\n    \"category\": {\n      \"table\": \"Табличный вид\",\n      \"form\": \"Вид формы\",\n      \"kanban\": \"Канбан-вид\",\n      \"gallery\": \"Галерея вид\",\n      \"calendar\": \"Календарь вид\"\n    },\n    \"crash\": {\n      \"title\": \"Сбой!\",\n      \"description\": \"Этот вид сломан. Если обновление все еще не помогает, пожалуйста, свяжитесь с нами. support@teable.ai\"\n    },\n    \"locked\": {\n      \"tip\": \"Этот вид заблокирован. Вы можете включить персональный режим для редактирования параметров вида, и изменения будут действовать только для вас.\"\n    },\n    \"noView\": \"Нет видов\"\n  },\n  \"lastModifiedTime\": \"Время последнего изменения\",\n  \"lastModify\": \"Последнее изменение: \",\n  \"tableTrash\": {\n    \"title\": \"Корзина таблицы\",\n    \"resourceType\": \"Тип ресурса\",\n    \"deletedResource\": \"Удаленный ресурс\"\n  },\n  \"baseShare\": {\n    \"title\": \"Поделиться базой\",\n    \"shareTitle\": \"Поделиться\",\n    \"shareToWeb\": \"Поделиться в Интернете\",\n    \"description\": \"Поделиться «{{baseName}}» с другими\",\n    \"nodeShareDescription\": \"Поделиться «{{nodeName}}» и его содержимым\",\n    \"shareLinks\": \"Ссылки для общего доступа\",\n    \"newLink\": \"Новая ссылка\",\n    \"noShareLinks\": \"Ссылок для общего доступа пока нет\",\n    \"createFirstLink\": \"Создать ссылку для общего доступа\",\n    \"editSettings\": \"Изменить настройки\",\n    \"refreshLink\": \"Обновить ссылку\",\n    \"deleteLink\": \"Удалить ссылку\",\n    \"deleteConfirmTitle\": \"Удалить ссылку для общего доступа\",\n    \"deleteConfirmDescription\": \"Это действие нельзя отменить. Люди с этой ссылкой больше не смогут получить доступ к общей базе.\",\n    \"createSuccess\": \"Ссылка для общего доступа создана\",\n    \"createFailed\": \"Не удалось создать ссылку для общего доступа\",\n    \"updateSuccess\": \"Настройки общего доступа обновлены\",\n    \"updateFailed\": \"Не удалось обновить настройки общего доступа\",\n    \"deleteSuccess\": \"Ссылка для общего доступа удалена\",\n    \"deleteFailed\": \"Не удалось удалить ссылку для общего доступа\",\n    \"refreshSuccess\": \"Ссылка для общего доступа обновлена\",\n    \"refreshFailed\": \"Не удалось обновить ссылку для общего доступа\",\n    \"copied\": \"Ссылка скопирована в буфер обмена\",\n    \"shareLink\": \"Ссылка для общего доступа\",\n    \"linkHolderLabel\": \"Человек, получивший ссылку\",\n    \"linkHolderCanView\": \"Может просматривать\",\n    \"linkHolderCanEdit\": \"Может редактировать\",\n    \"linkHolderCanCopyAndSave\": \"Может сохранить как копию\",\n    \"passwordProtection\": \"Защита паролем\",\n    \"enterPassword\": \"Введите пароль\",\n    \"selectNodes\": \"Выбрать таблицы\",\n    \"shareEntireBase\": \"Поделиться всей базой\",\n    \"shareSelectedNodes\": \"Поделиться выбранными узлами\",\n    \"shareEntireBaseDescription\": \"Новые таблицы и папки, добавленные в эту базу, будут автоматически доступны\",\n    \"noNodesSelectedWarning\": \"Пожалуйста, выберите хотя бы один узел для общего доступа\",\n    \"allowSave\": \"Разрешить сохранение в пространстве\",\n    \"allowSaveDescription\": \"Разрешить зрителям сохранять копию этой базы в своем пространстве\",\n    \"allowCopy\": \"Разрешить копирование данных\",\n    \"allowCopyData\": \"Разрешить зрителям копировать данные\",\n    \"allowDuplicate\": \"Разрешить зрителям дублировать\",\n    \"allowCopyDescription\": \"Разрешить зрителям копировать данные таблицы из этой общей базы\",\n    \"selectedNodes\": \"Выбрано {{count}} таблиц\",\n    \"allNodes\": \"Все таблицы\",\n    \"sharedNode\": \"Общий узел\",\n    \"sharedNodeDescription\": \"Эта ссылка для общего доступа предназначена для конкретного узла и его потомков\",\n    \"publicShareTitle\": \"Публичный доступ по ссылке\",\n    \"publicShareCount\": \"{{count}} публичных ссылок для общего доступа\",\n    \"noPublicShare\": \"Нет публичных ссылок для общего доступа\",\n    \"security\": \"Безопасность\",\n    \"restrictByPassword\": \"Ограничить паролем\",\n    \"advanced\": \"Дополнительно\",\n    \"embedConfig\": \"Настройка встраивания\",\n    \"appPublicLink\": \"Публичная ссылка приложения\",\n    \"appNotPublished\": \"Это приложение еще не опубликовано. Пожалуйста, сначала опубликуйте приложение перед публикацией общего доступа.\",\n    \"goToPublish\": \"Перейти к публикации\",\n    \"publishSuccess\": \"Приложение успешно опубликовано\",\n    \"publishFailed\": \"Не удалось опубликовать приложение\",\n    \"openLink\": \"Открыть ссылку\",\n    \"appPublished\": \"Приложение опубликовано\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Получить поля таблицы\",\n      \"getTablesMeta\": \"Получить метаданные таблицы\",\n      \"sqlQuery\": \"Выполнить SQL запрос\",\n      \"generateScriptAction\": \"Сгенерировать действие скрипта\",\n      \"getScriptInput\": \"Получить входные данные скрипта\",\n      \"getTeableApi\": \"Получить API\",\n      \"dataVisualization\": \"Визуализация данных\",\n      \"updateBase\": \"Обновить информацию о базе данных\",\n      \"args\": \"Аргументы\",\n      \"result\": \"Результат\",\n      \"thinking\": \"Размышляю...\",\n      \"toBeConfirmed\": \"Ожидает подтверждения\",\n      \"errorMessage\": \"Сообщение об ошибке\",\n      \"confirm\": \"Подтвердить\",\n      \"createRecordsSuccess\": \"Успешно создано {{count}} записей\",\n      \"createRecordsFailed\": \"Не удалось создать записи\",\n      \"updateRecordsSuccess\": \"Успешно обновлено {{count}} записей\",\n      \"updateRecordsFailed\": \"Не удалось обновить записи\",\n      \"generatingRecords\": \"Генерация {{count}} записей...\",\n      \"creatingRecords\": \"Создание {{count}} записей...\",\n      \"updatingRecords\": \"Обновление {{count}} записей...\",\n      \"recordsPreview\": \"Предпросмотр записей\",\n      \"andMoreRecords\": \"И ещё {{count}}...\",\n      \"unknownError\": \"Неизвестная ошибка\",\n      \"recordIds\": \"ID записей\",\n      \"records\": \"записей\",\n      \"viewAll\": \"Показать все\",\n      \"showLess\": \"Показать меньше\",\n      \"generatingData\": \"Генерация данных...\",\n      \"generatingUpdates\": \"Генерация обновлений...\",\n      \"recordsGenerated\": \"Сгенерировано {{count}} записей\",\n      \"recordsCount\": \"Записей({{count}})\",\n      \"fieldsCount\": \"Полей({{count}})\",\n      \"fieldsGenerated\": \"Сгенерировано {{count}} полей\",\n      \"updatedProperties\": \"Обновлено ({{count}})\",\n      \"configured\": \"настроено\",\n      \"recordsToUpdate\": \"{{count}} записей для обновления\",\n      \"showingLast\": \"последние {{count}}\",\n      \"recordLabel\": \"Запись\",\n      \"statusGenerating\": \"Генерация...\",\n      \"statusCreating\": \"Создание...\",\n      \"statusUpdating\": \"Обновление...\",\n      \"statusCreated\": \"Создано\",\n      \"statusUpdated\": \"Обновлено\",\n      \"getApps\": {\n        \"title\": \"Список приложений\",\n        \"loading\": \"Загрузка приложений...\",\n        \"foundApps\": \"Найдено {{count}} приложений\",\n        \"noApps\": \"В этой базе приложений не найдено\",\n        \"openApp\": \"Открыть приложение\"\n      },\n      \"generateApp\": {\n        \"title\": \"Конструктор приложений\",\n        \"creatingApp\": \"Создание приложения\",\n        \"updatingApp\": \"Обновление приложения\",\n        \"generatingApp\": \"Генерация приложения\",\n        \"generating\": \"Генерация...\",\n        \"openApp\": \"Открыть приложение\",\n        \"viewProgress\": \"Просмотр прогресса\",\n        \"newApp\": \"Новое приложение\",\n        \"building\": \"Сборка...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Конструктор автоматизации\",\n        \"creatingAutomation\": \"Создание автоматизации\",\n        \"updatingAutomation\": \"Обновление автоматизации\",\n        \"generatingAutomation\": \"Сборка автоматизации\",\n        \"building\": \"Сборка...\",\n        \"openAutomation\": \"Открыть автоматизацию\",\n        \"viewProgress\": \"Просмотр прогресса\",\n        \"testResults\": \"Результаты тестирования\",\n        \"triggerTest\": \"Триггер\",\n        \"actionTest\": \"Действие {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Предпросмотр\",\n        \"code\": \"Код\",\n        \"download\": \"Скачать\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Изображение (PNG)\",\n        \"copy\": \"Копировать\",\n        \"copied\": \"Скопировано!\",\n        \"fullscreen\": \"Полный экран\",\n        \"exitFullscreen\": \"Выйти из полноэкранного режима\",\n        \"downloadSuccess\": \"Изображение успешно загружено\",\n        \"downloadFailed\": \"Не удалось захватить изображение\",\n        \"iframeFailed\": \"Не удалось захватить: iframe недоступен\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Загрузить вложение\",\n        \"loading\": \"Загрузка вложения\",\n        \"failed\": \"Не удалось загрузить вложение\",\n        \"empty\": \"Вложения не загружены\",\n        \"modeNative\": \"AI Vision\",\n        \"modeNativeDesc\": \"Загружено в нативный контекст AI\",\n        \"modeExtracted\": \"Извлечённый текст\",\n        \"modeExtractedDesc\": \"Содержимое файла разобрано и извлечено как текст для анализа\",\n        \"visionLoaded\": \"Загружено для визуального анализа\",\n        \"pdfLoaded\": \"Загружено для анализа PDF\",\n        \"textExtracted\": \"Извлечено {{chars}} символов\",\n        \"contextLoaded\": \"Загружено в контекст AI\",\n        \"truncated\": \"Обрезано\",\n        \"preview\": \"Предпросмотр\"\n      },\n      \"textExtract\": {\n        \"title\": \"Извлечение текста\",\n        \"loading\": \"Извлечение текста\",\n        \"failed\": \"Не удалось извлечь текст\",\n        \"empty\": \"Текст не извлечён\",\n        \"preview\": \"Предпросмотр\",\n        \"truncated\": \"Обрезано\",\n        \"previews\": \"предпросмотров\",\n        \"chars\": \"{{chars}} симв.\",\n        \"totalCharacters\": \"Всего: {{chars}} символов\",\n        \"filesTruncated\": \"({{count}} файлов обрезано)\"\n      },\n      \"importExcel\": {\n        \"title\": \"Импорт Excel\",\n        \"loading\": \"Обработка файла...\",\n        \"failed\": \"Ошибка импорта\",\n        \"suggestions\": \"Рекомендации\",\n        \"analyzeComplete\": \"Анализ завершён\",\n        \"worksheets\": \"Листы\",\n        \"columns\": \"столбцов\",\n        \"importComplete\": \"Импорт завершён\",\n        \"stageAnalyze\": \"Анализ\",\n        \"stageImport\": \"Импорт\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Получить Teable API\",\n      \"readFiles\": \"Читать файлы\",\n      \"writeFile\": \"Записать файл\",\n      \"deleteFiles\": \"Удалить файлы\",\n      \"listFiles\": \"Список файлов\",\n      \"addDependencies\": \"Добавить зависимости\",\n      \"checkBuildErrors\": \"Проверить ошибки сборки\",\n      \"lint\": \"Проверка кода\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Не удалось загрузить предпросмотр\",\n      \"retry\": \"Повторить {{count}} раз\",\n      \"chatAborted\": \"прервано\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Удалённая таблица\",\n      \"deletedView\": \"Удалённое представление\",\n      \"deletedField\": \"Удалённое поле\",\n      \"deletedRecords\": \"Удалённые записи\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Агент таблицы\",\n      \"viewOperatorAgent\": \"Агент представления\",\n      \"fieldOperatorAgent\": \"Агент полей\",\n      \"recordOperatorAgent\": \"Агент записей\",\n      \"buildBaseAgent\": \"Агент создания базы\",\n      \"buildAutomationAgent\": \"Агент создания автоматизации\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"Ожидает подтверждения\",\n      \"deleteWarning\": \"Вы уверены, что хотите удалить?\"\n    },\n    \"action\": {\n      \"createTable\": \"Создать таблицу\",\n      \"updateTable\": \"Обновить таблицу\",\n      \"updateTableName\": \"Обновить название таблицы\",\n      \"deleteTable\": \"Удалить таблицу\",\n      \"createView\": \"Создать представление\",\n      \"updateView\": \"Обновить представление\",\n      \"updateViewName\": \"Обновить название представления\",\n      \"deleteView\": \"Удалить представление\",\n      \"createField\": \"Создать поле\",\n      \"createAiField\": \"Создать AI поле\",\n      \"createLinkField\": \"Создать поле связи\",\n      \"createLookupField\": \"Создать поле поиска\",\n      \"createRollupField\": \"Создать поле сводки\",\n      \"createFormulaField\": \"Создать поле формулы\",\n      \"deleteField\": \"Удалить поле\",\n      \"updateField\": \"Обновить поле\",\n      \"createRecord\": \"Создать запись\",\n      \"createRecords\": \"Создать записи\",\n      \"deleteRecord\": \"Удалить запись\",\n      \"updateRecord\": \"Обновить запись\",\n      \"updateRecords\": \"Обновить записи\",\n      \"updateBase\": \"Обновить информацию о базе данных\",\n      \"planTask\": \"Планирование задачи...\",\n      \"generateTables\": \"Сгенерировать таблицы\",\n      \"generatePrimaryFields\": \"Сгенерировать первичные поля\",\n      \"generateFields\": \"Сгенерировать поля\",\n      \"generateViews\": \"Сгенерировать представления\",\n      \"generateRecords\": \"Сгенерировать записи\",\n      \"generateAIFields\": \"Сгенерировать AI поля\",\n      \"generateLinkFields\": \"Сгенерировать поля связи\",\n      \"generateLookupFields\": \"Сгенерировать поля поиска\",\n      \"generateRollupFields\": \"Сгенерировать поля сводки\",\n      \"generateFormulaFields\": \"Сгенерировать поля формулы\",\n      \"generateWorkflow\": \"Сгенерировать рабочий процесс\",\n      \"generateTrigger\": \"Сгенерировать триггер\",\n      \"generateScriptAction\": \"Сгенерировать узел действия скрипта\",\n      \"generateSendMailAction\": \"Сгенерировать узел отправки письма\",\n      \"generateAction\": \"Сгенерировать узел действия\",\n      \"setupAutomationTrigger\": \"Настроить триггер автоматизации\",\n      \"testAutomationNode\": \"Тестировать узел автоматизации\",\n      \"activateAutomation\": \"Активировать автоматизацию\",\n      \"executeScript\": \"Выполнить скрипт\",\n      \"wait\": \"Ожидание\",\n      \"generateScriptFlowChart\": \"Сгенерировать блок-схему скрипта\",\n      \"triggerAiFill\": \"Запустить AI заполнение\",\n      \"initialize\": \"Инициализировать среду\",\n      \"rename\": \"Сгенерировать имя приложения\",\n      \"buildTest\": \"Создать тест\",\n      \"developTask\": \"Разработать задачу\",\n      \"generateSummary\": \"Сгенерировать сводку\",\n      \"previewEnvironment\": \"Подготовить среду предпросмотра\",\n      \"getRelativeData\": \"Получить связанные данные\",\n      \"getPreviousNodeOutputVariables\": \"Получить выходные переменные предыдущего узла\",\n      \"getApiJson\": \"Получить информацию API\",\n      \"generateScriptAndDependencies\": \"Сгенерировать скрипт и зависимости\",\n      \"analyzingAttachment\": \"Анализ вложения...\",\n      \"locateResource\": \"Найти\",\n      \"goTo\": \"Перейти\",\n      \"operationSuccess\": \"Операция успешно завершена\",\n      \"operationFailed\": \"Операция не удалась\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} записей в очереди на генерацию AI\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Запрос записей\",\n      \"getRecordsWithTable\": \"Запрос записей · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Запрос полей\",\n      \"getFieldsWithTable\": \"Запрос полей · {{tableName}}\",\n      \"getTables\": \"Запрос таблиц\",\n      \"getViews\": \"Запрос представлений\",\n      \"getViewsWithTable\": \"Запрос представлений · {{tableName}}\",\n      \"sqlQuery\": \"SQL-запрос\",\n      \"querying\": \"Запрос...\",\n      \"queryFailed\": \"Ошибка запроса\",\n      \"aborted\": \"Прервано\",\n      \"noData\": \"Данные не возвращены\",\n      \"dataFormatError\": \"Ошибка формата данных\",\n      \"unsupportedQueryType\": \"Неподдерживаемый тип запроса: {{toolName}}\",\n      \"returnedRecords\": \"Возвращено {{count}} записей\",\n      \"record\": \"Запись {{index}}\",\n      \"moreRecords\": \"... +{{count}} записей\",\n      \"foundFields\": \"Найдено {{count}} полей\",\n      \"moreFields\": \"... +{{count}} полей\",\n      \"foundTables\": \"Найдено {{count}} таблиц\",\n      \"moreTables\": \"... +{{count}} таблиц\",\n      \"foundViews\": \"Найдено {{count}} представлений\",\n      \"moreViews\": \"... +{{count}} представлений\",\n      \"queryReturned\": \"Запрос вернул {{rowCount}} строк(и) × {{columnCount}} столбцов\",\n      \"row\": \"Строка {{index}}\",\n      \"moreRows\": \"... +{{count}} строк\",\n      \"getDoc\": \"Получить документ\",\n      \"getDocWithTopic\": \"Получить документ · {{topic}}\",\n      \"getAutomations\": \"Запрос автоматизаций\",\n      \"getAutomation\": \"Запрос автоматизации\",\n      \"getAutomationRuns\": \"Запрос запусков автоматизации\",\n      \"foundAutomations\": \"Найдено {{count}} автоматизаций\",\n      \"moreAutomations\": \"... +{{count}} автоматизаций\",\n      \"foundRuns\": \"Найдено {{count}} запусков\",\n      \"moreRuns\": \"... +{{count}} запусков\",\n      \"active\": \"Активна\",\n      \"trigger\": \"Триггер\",\n      \"actions\": \"{{count}} действий\",\n      \"moreActions\": \"... +{{count}} действий\",\n      \"getUserIntegrations\": \"Проверить интеграции\",\n      \"connectedIntegrations\": \"Подключено\",\n      \"availableToConnect\": \"Доступно для подключения\",\n      \"connect\": \"Подключить\",\n      \"noIntegrationsAvailable\": \"Интеграции недоступны\",\n      \"activateTool\": \"Активировать инструменты\",\n      \"webSearch\": \"Поиск в интернете\",\n      \"webSearchResults\": \"Найдено {{count}} результатов\",\n      \"webSearchCompleted\": \"Поиск завершён\",\n      \"searchApi\": \"Поиск API\",\n      \"searchApiWithQuery\": \"Поиск API · {{query}}\",\n      \"noApiFound\": \"API не найдены\",\n      \"foundApis\": \"Найдено {{count}} API\",\n      \"totalApis\": \"Всего {{count}} API доступно\",\n      \"callApi\": \"Вызвать API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Ответ\",\n      \"success\": \"Успешно\",\n      \"failed\": \"Ошибка\",\n      \"inputData\": \"Входные данные\",\n      \"availableNodes\": \"Доступные узлы\",\n      \"hasPreviousCode\": \"Есть существующий код\",\n      \"noInputData\": \"Входные данные недоступны\"\n    },\n    \"showUI\": {\n      \"connect\": \"Подключить\",\n      \"connecting\": \"Подключение...\",\n      \"connected\": \"Подключено\",\n      \"connectToUse\": \"Подключите {{provider}} для использования в автоматизациях\",\n      \"checkingConnection\": \"Проверка статуса подключения...\",\n      \"confirm\": \"Подтвердить\",\n      \"confirmed\": \"Подтверждено\",\n      \"cancel\": \"Отмена\",\n      \"cancelled\": \"Отменено\",\n      \"connectionCancelled\": \"Подключение отменено\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} строк скрыто\",\n      \"collapseCode\": \"Свернуть код\",\n      \"code\": \"Код\",\n      \"preview\": \"Предварительный просмотр\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Прогресс сборки\",\n      \"completed\": \"Сборка приложения завершена\",\n      \"completedDesc\": \"Все шаги выполнены успешно, приложение готово к предпросмотру\",\n      \"stepStatus\": {\n        \"initializing\": \"Создание приложения и инициализация среды...\",\n        \"naming\": \"Генерация имени приложения...\",\n        \"planning\": \"Анализ требований и планирование разработки...\",\n        \"developing\": \"Написание кода и реализация функций...\",\n        \"summarizing\": \"Организация результатов разработки...\",\n        \"deploying\": \"Развёртывание в среде предпросмотра...\",\n        \"testing\": \"Создание теста...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"Выполняется\",\n        \"completed\": \"Завершено\",\n        \"error\": \"Ошибка\",\n        \"pending\": \"Ожидает\"\n      },\n      \"toolStatus\": {\n        \"running\": \"Выполняется\",\n        \"completed\": \"Завершено\",\n        \"error\": \"Ошибка\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Скрипт успешно сгенерирован\"\n    },\n    \"buildBase\": {\n      \"title\": \"Создать базу\",\n      \"generateSuccess\": \"База данных успешно сгенерирована\",\n      \"generateError\": \"Ошибка генерации базы данных\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Создать автоматизацию\",\n      \"generateSuccess\": \"Автоматизация успешно сгенерирована\"\n    },\n    \"automation\": {\n      \"created\": \"Создано\",\n      \"updated\": \"Обновлено\",\n      \"workflow\": \"Рабочий процесс\",\n      \"trigger\": \"Триггер\",\n      \"scriptAction\": \"Действие скрипта\",\n      \"workflowLabel\": \"Рабочий процесс\",\n      \"triggerLabel\": \"Триггер\",\n      \"scriptActionLabel\": \"Действие скрипта\",\n      \"workflowId\": \"Рабочий процесс\",\n      \"triggerId\": \"Триггер\",\n      \"scriptActionId\": \"Действие скрипта\",\n      \"viewAutomation\": \"Просмотр\",\n      \"navigateToAutomation\": \"Перейти к автоматизации\",\n      \"triggerType\": {\n        \"recordCreated\": \"Запись создана\",\n        \"recordUpdated\": \"Запись обновлена\",\n        \"recordCreatedOrUpdated\": \"Запись создана или обновлена\",\n        \"formSubmitted\": \"Форма отправлена\",\n        \"scheduledTime\": \"Запланированное время\",\n        \"buttonClick\": \"Нажатие кнопки\"\n      },\n      \"activated\": \"Активировано\",\n      \"deactivated\": \"Деактивировано\",\n      \"discarded\": \"Изменения отменены\",\n      \"activateFailed\": \"Ошибка активации\",\n      \"deactivateFailed\": \"Ошибка деактивации\",\n      \"discardFailed\": \"Ошибка отмены\",\n      \"scriptUpdated\": \"Скрипт обновлён\",\n      \"scriptUpdateFailed\": \"Ошибка обновления\",\n      \"scriptExecuted\": \"Скрипт выполнен\",\n      \"scriptExecutionFailed\": \"Ошибка выполнения\",\n      \"scriptReady\": \"Скрипт готов\",\n      \"executingScript\": \"Выполнение скрипта...\",\n      \"waitedSeconds\": \"Ожидание {{seconds}}с\",\n      \"waitFailed\": \"Ошибка ожидания\",\n      \"flowchartGenerated\": \"Блок-схема сгенерирована\",\n      \"flowchartGenerationFailed\": \"Ошибка генерации\"\n    },\n    \"newChat\": \"Новый чат\",\n    \"clearChat\": \"Очистить чат\",\n    \"expand\": \"Развернуть\",\n    \"history\": \"История\",\n    \"close\": \"Свернуть\",\n    \"clearChatConfirmTitle\": \"Подтвердить очистку чата\",\n    \"clearChatConfirmDesc\": \"Текущее содержимое чата не будет сохранено. Вы уверены, что хотите его очистить?\",\n    \"dontShowAgain\": \"Больше не показывать\",\n    \"noModel\": \"Нет доступных моделей\",\n    \"addAttachment\": \"Добавить вложение\",\n    \"noHistory\": \"Нет истории чата\",\n    \"noFoundHistory\": \"Нет истории чата, пожалуйста, начните новую беседу\",\n    \"timeGroup\": {\n      \"today\": \"Сегодня\",\n      \"oneWeek\": \"Одна неделя\",\n      \"twoWeek\": \"Две недели\",\n      \"oneMonth\": \"Один месяц\",\n      \"other\": \"Другое\"\n    },\n    \"context\": {\n      \"button\": \"Добавить контекст\",\n      \"search\": \"Добавить таблицы\",\n      \"searchEmpty\": \"Не найден соответствующий контекст\",\n      \"emptyContext\": \"Нет контекста для добавления\",\n      \"selectionRows\": \"Строки {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Отправить сообщение...\",\n    \"thought\": \"Мысли\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Время генерации: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} кредитов использовано\",\n      \"tokenDescription\": \"Токенов использовано: {{tokens}}\",\n      \"input\": \"Ввод\",\n      \"output\": \"Вывод\",\n      \"tokens\": \"Токены\",\n      \"totalTimeCost\": \"Общее время\",\n      \"totalCreditCost\": \"Общая стоимость в кредитах\",\n      \"customModel\": \"Пользовательская модель\",\n      \"tokenDetails\": \"Подробности токенов\",\n      \"cachedInput\": \"Кэшированный ввод (скидка 90%)\",\n      \"cacheWrite\": \"Запись в кэш\",\n      \"reasoning\": \"Рассуждение (размышление)\",\n      \"taskCompleted\": \"Задача выполнена\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Ошибка визуализации данных\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Настраивать могут только администраторы\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"Изображение не поддерживается\",\n      \"attachmentSizeExceeded\": \"Размер вложения превышает лимит {{size}}МБ\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Рекомендуемое\",\n      \"ask\": \"Спросить\",\n      \"analyze\": \"Анализ\",\n      \"build\": \"Создать\",\n      \"title\": \"Чем могу помочь?\",\n      \"whatCanIDo\": \"Что я могу сделать?\",\n      \"createOrModifyDatabase\": \"Создать или изменить базу данных\",\n      \"buildAutomations\": \"Создать автоматизации\",\n      \"buildApps\": \"Создать приложения\",\n      \"buildMeCRM\": \"Создайте мне CRM\",\n      \"addAIField\": \"Добавить AI-поле для анализа каждого клиента\",\n      \"createDataAnalysis\": \"Создайте отчёт по анализу данных\",\n      \"emailWhenRecordCreated\": \"Отправьте мне письмо при создании записи\",\n      \"syncStatusToSlack\": \"Синхронизировать обновления статуса со Slack\",\n      \"buildDashboard\": \"Создать панель из этой таблицы\",\n      \"buildLeadCapture\": \"Создайте страницу захвата лидов\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Размышлял {{duration}}с\"\n      },\n      \"task\": {\n        \"searching\": \"Поиск «{{query}}»\",\n        \"readingFiles\": \"Чтение файлов:\",\n        \"foundResults\": \"Найдено {{count}} результатов\",\n        \"noIssuesFound\": \"Проблем не найдено\",\n        \"defaultTitle\": \"Задача\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Кодовый проект\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"Для генерации предварительного просмотра требуется модель ИИ.\",\n      \"writeCodeHint\": \"Напишите код, чтобы увидеть предварительный просмотр блок-схемы.\",\n      \"noPreview\": \"Предварительный просмотр блок-схемы недоступен.\",\n      \"generatePreview\": \"Сгенерировать предварительный просмотр\",\n      \"analyzing\": \"Анализ скрипта...\",\n      \"codeChanged\": \"Код изменился с момента генерации этого предварительного просмотра.\",\n      \"regenerate\": \"Перегенерировать\",\n      \"refresh\": \"Обновить\",\n      \"regenerating\": \"Перегенерация...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Скачать все вложения\",\n      \"loading\": \"Загрузка предпросмотра...\",\n      \"rowsWithAttachments\": \"{{count}} строк с вложениями\",\n      \"totalAttachments\": \"{{count}} вложений\",\n      \"totalSize\": \"Общий размер: {{size}}\",\n      \"startDownload\": \"Начать загрузку\",\n      \"confirmTitle\": \"Скачать {{count}} файлов\",\n      \"confirmDescription\": \"Общий размер: {{size}}. Файлы будут сжаты в ZIP-архив.\",\n      \"confirm\": \"Скачать\",\n      \"cancel\": \"Отмена\",\n      \"downloading\": \"Загрузка...\",\n      \"downloadingFile\": \"Загрузка: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Загрузка завершена\",\n      \"cancelled\": \"Загрузка отменена\",\n      \"noAttachments\": \"Нет вложений для скачивания\",\n      \"error\": \"Ошибка загрузки\",\n      \"errorPartial\": \"{{failedCount}} файлов не удалось загрузить\",\n      \"requireHttps\": \"Пакетная загрузка требует HTTPS. Пожалуйста, используйте HTTPS или localhost.\",\n      \"advancedOptions\": \"Дополнительные параметры\",\n      \"namingFieldLabel\": \"Префикс имени вложения\",\n      \"selectField\": \"По умолчанию: индекс вложения\",\n      \"groupByRow\": \"Архивировать в папки\",\n      \"groupByRowTip\": \"Когда в строке несколько вложений, они будут помещены в одну папку; строки с одним вложением не создадут папку.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/token.json",
    "content": "{\n  \"access\": \"Доступ\",\n  \"name\": \"Имя\",\n  \"description\": \"Описание\",\n  \"scopes\": \"Области применения\",\n  \"expiration\": \"Истечение срока\",\n  \"createdTime\": \"Создано\",\n  \"lastUse\": \"Последнее использование\",\n  \"allSpace\": \"Пространство, все текущие и будущие базы в этом пространстве\",\n  \"formLabelTips\": {\n    \"name\": \"Укажите имя токена\",\n    \"description\": \"Для чего нужен этот токен?\",\n    \"scopes\": \"С этим токеном вы сможете:\",\n    \"access\": \"Этот токен может получить доступ к следующим базам и пространствам. Вы можете предоставить доступ только к базам и пространствам, к которым у вас есть доступ.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Создать новый токен\",\n    \"title\": \"Персональные токены доступа необходимы для использования API Teable.\",\n    \"description\": \"Этот токен предоставит доступ к данным в выбранных пространствах и базах. И другие, не связанные с пространствами/базами, конечные точки API. Используйте этот токен только для собственного развития. Будьте осторожны при его использовании с третьими сторонами и приложениями.\",\n    \"button\": \"Создать новый токен\",\n    \"success\": {\n      \"title\": \"Токен успешно создан\",\n      \"description\": \"Убедитесь, что вы скопировали свой токен. Он больше не будет отображаться.\"\n    },\n    \"expirationList\": {\n      \"days\": \"дни\",\n      \"permanent\": \"Постоянный\",\n      \"custom\": \"Пользовательский\",\n      \"pick\": \"Выберите дату\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Редактировать токен\",\n    \"name\": \"Имя\",\n    \"scopes\": \"Области применения\",\n    \"selectAll\": \"Выбрать все\",\n    \"cancelSelectAll\": \"Отменить выбор всех\"\n  },\n  \"refresh\": {\n    \"title\": \"Восстановить персональный токен доступа\",\n    \"description\": \"Отправка этой формы создаст новый токен. Обратите внимание, что любые скрипты или приложения, использующие этот токен, должны быть обновлены\",\n    \"button\": \"Восстановить токен\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Добавить базу или пространство\",\n    \"empty\": \"Доступ не найден.\",\n    \"spaceSelectItem\": \"Все базы в пространстве\",\n    \"inputPlaceholder\": \"Найти пространство или базу...\"\n  },\n  \"moreScopes\": \"и {{len}} других\",\n  \"list\": {\n    \"description\": \"Персональные токены доступа необходимы для использования API Teable. Пожалуйста, обратитесь к <a>помощь документ</a> для получения дополнительной информации.\"\n  },\n  \"empty\": {\n    \"list\": \"Персональные токены доступа не найдены.\",\n    \"access\": \"Нет доступа\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Вы уверены, что хотите удалить этот токен?\",\n    \"description\": \"Любые приложения или скрипты, использующие этот токен, больше не смогут получить доступ к API Teable. Вы не можете отменить это действие.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/ru/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Ожидался {{expected}}, получено {{received}}\",\n    \"invalid_type_received_undefined\": \"Обязательно\",\n    \"invalid_type_received_null\": \"Обязательно\",\n    \"invalid_literal\": \"Недопустимое значение, ожидалось {{expected}}\",\n    \"unrecognized_keys\": \"Неопознанные ключи в объекте: {{- keys}}\",\n    \"invalid_union\": \"Недопустимый ввод\",\n    \"invalid_union_discriminator\": \"Недопустимое значение дискриминатора. Ожидались {{- options}}\",\n    \"invalid_enum_value\": \"Недопустимое значение перечисления. Ожидалось {{- options}}, получено '{{received}}'\",\n    \"invalid_arguments\": \"Недопустимые аргументы функции\",\n    \"invalid_return_type\": \"Недопустимый тип возвращаемого значения функции\",\n    \"invalid_date\": \"Недопустимая дата\",\n    \"custom\": \"Недопустимый ввод\",\n    \"invalid_intersection_types\": \"Результаты пересечения не могут быть объединены\",\n    \"not_multiple_of\": \"Число должно быть кратно {{multipleOf}}\",\n    \"not_finite\": \"Число должно быть конечным\",\n    \"invalid_string\": {\n      \"email\": \"Недопустимый {{validation}}\",\n      \"url\": \"Недопустимый {{validation}}\",\n      \"uuid\": \"Недопустимый {{validation}}\",\n      \"cuid\": \"Недопустимый {{validation}}\",\n      \"regex\": \"Недопустимый\",\n      \"datetime\": \"Недопустимый {{validation}}\",\n      \"startsWith\": \"Недопустимый ввод: должно начинаться с \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Недопустимый ввод: должно заканчиваться на \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Массив должен содержать ровно {{minimum}} элемент(ов)\",\n        \"inclusive\": \"Массив должен содержать как минимум {{minimum}} элемент(ов)\",\n        \"not_inclusive\": \"Массив должен содержать более {{minimum}} элемент(ов)\"\n      },\n      \"string\": {\n        \"exact\": \"Строка должна содержать ровно {{minimum}} символ(ов)\",\n        \"inclusive\": \"Строка должна содержать как минимум {{minimum}} символ(ов)\",\n        \"not_inclusive\": \"Строка должна содержать более {{minimum}} символов\"\n      },\n      \"number\": {\n        \"exact\": \"Число должно быть ровно {{minimum}}\",\n        \"inclusive\": \"Число должно быть больше или равно {{minimum}}\",\n        \"not_inclusive\": \"Число должно быть больше {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Недопустимый ввод\",\n        \"inclusive\": \"Недопустимый ввод\",\n        \"not_inclusive\": \"Недопустимый ввод\"\n      },\n      \"date\": {\n        \"exact\": \"Дата должна быть ровно {{- minimum, datetime}}\",\n        \"inclusive\": \"Дата должна быть больше или равна {{- minimum, datetime}}\",\n        \"not_inclusive\": \"Дата должна быть больше {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Массив должен содержать ровно {{maximum}} элемент(ов)\",\n        \"inclusive\": \"Массив должен содержать не более {{maximum}} элемент(ов)\",\n        \"not_inclusive\": \"Массив должен содержать менее {{maximum}} элемент(ов)\"\n      },\n      \"string\": {\n        \"exact\": \"Строка должна содержать ровно {{maximum}} символ(ов)\",\n        \"inclusive\": \"Строка должна содержать не более {{maximum}} символов\",\n        \"not_inclusive\": \"Строка должна содержать меньше {{maximum}} символов\"\n      },\n      \"number\": {\n        \"exact\": \"Число должно быть ровно {{maximum}}\",\n        \"inclusive\": \"Число должно быть меньше или равно {{maximum}}\",\n        \"not_inclusive\": \"Число должно быть меньше {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Недопустимый ввод\",\n        \"inclusive\": \"Недопустимый ввод\",\n        \"not_inclusive\": \"Недопустимый ввод\"\n      },\n      \"date\": {\n        \"exact\": \"Дата должна быть ровно {{- maximum, datetime}}\",\n        \"inclusive\": \"Дата должна быть меньше или равна {{- maximum, datetime}}\",\n        \"not_inclusive\": \"Дата должна быть меньше {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"function\",\n    \"number\": \"number\",\n    \"string\": \"string\",\n    \"nan\": \"nan\",\n    \"integer\": \"integer\",\n    \"float\": \"float\",\n    \"boolean\": \"boolean\",\n    \"date\": \"date\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"undefined\",\n    \"symbol\": \"symbol\",\n    \"null\": \"null\",\n    \"array\": \"array\",\n    \"object\": \"object\",\n    \"unknown\": \"unknown\",\n    \"promise\": \"promise\",\n    \"void\": \"void\",\n    \"never\": \"never\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/auth.json",
    "content": "{\n  \"page\": {\n    \"title\": \"Giriş\"\n  },\n  \"button\": {\n    \"signin\": \"Giriş yap\",\n    \"signup\": \"Kayıt ol\"\n  },\n  \"label\": {\n    \"email\": \"E-posta\",\n    \"password\": \"Şifre\"\n  },\n  \"legal\": {\n    \"tip\": \"Devam etmek, Teable'ın <Terms>Hizmet Sözleşmesi</Terms> ve <Privacy>Gizlilik Politikası</Privacy> ile uyumlu olduğunuzu kabul ediyorsunuz, ve güncellemelere sahip düzenli e-postalar alacaksınız.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"placeholder\": {\n    \"password\": \"Şifrenizi girin...\",\n    \"email\": \"E-posta adresinizi girin...\"\n  },\n  \"signError\": {\n    \"exist\": \"E-posta zaten kayıtlı\",\n    \"incorrect\": \"E-posta veya şifre yanlış\",\n    \"tooManyRequests\": \"Hesabınız kilitlendi, lütfen {{minutes}} dakika sonra tekrar deneyin\",\n    \"turnstileRequired\": \"Lütfen doğrulama testini tamamlayın\",\n    \"turnstileError\": \"Doğrulama başarısız. Lütfen tekrar deneyin\",\n    \"turnstileExpired\": \"Doğrulama süresi doldu. Lütfen tekrar deneyin\",\n    \"turnstileTimeout\": \"Doğrulama zaman aşımına uğradı. Lütfen tekrar deneyin\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Şifrenizi Belirleyin\",\n    \"description\": \"Yeni bir şifre girin\",\n    \"label\": \"Yeni şifre\",\n    \"error\": {\n      \"requiredPassword\": \"Şifre girin\",\n      \"invalidLink\": \"Geçersiz şifre sıfırlama bağlantısı\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Şifre sıfırlama başarılı\",\n      \"description\": \"Şifreniz başarıyla sıfırlandı. Giriş sayfasına yönlendirileceksiniz.\"\n    },\n    \"buttonText\": \"Şifre Sıfırlama Gönder\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Şifremi unuttum?\",\n    \"header\": \"Şifrenizi Sıfırlayın\",\n    \"description\": \"Lütfen aşağıya e-posta adresinizi girin, size şifrenizi sıfırlamak için bir bağlantı göndereceğiz.\",\n    \"errorRequiredEmail\": \"E-posta gerekli\",\n    \"errorInvalidEmail\": \"Geçersiz e-posta\",\n    \"buttonText\": \"Sıfırlama E-postası Gönder\",\n    \"success\": {\n      \"title\": \"🎉 Şifre sıfırlama e-postası gönderildi\",\n      \"description\": \"Size şifrenizi sıfırlamak için bir bağlantı içeren e-posta gönderdik. Lütfen gelen kutunuzu kontrol edin.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/chart.json",
    "content": "{\n  \"notBaseId\": \"BaseId eksik\",\n  \"notPositionId\": \"PositionId eksik\",\n  \"notPluginInstallId\": \"PluginInstallId eksik\",\n  \"initBridge\": \"Köprü başlatılıyor...\",\n  \"actions\": {\n    \"cancel\": \"İptal\",\n    \"save\": \"Kaydet\"\n  },\n  \"queryTitle\": \"Veri Sorgu Yapılandırması\",\n  \"notSupport\": \"Desteklenmiyor\",\n  \"chart\": {\n    \"bar\": \"Çubuk\",\n    \"line\": \"Çizgi\",\n    \"pie\": \"Pasta\",\n    \"area\": \"Alan\",\n    \"table\": \"Tablo\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Grafik Türünü Seçin\",\n      \"label\": \"Grafik Türü\"\n    },\n    \"pie\": {\n      \"dimension\": \"Boyut\",\n      \"measure\": \"Ölçü\",\n      \"showTotal\": \"Toplamı Göster\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"X Ekseni\",\n        \"placeholder\": \"X Ekseni Seçin\"\n      },\n      \"yAxis\": {\n        \"label\": \"Y Ekseni\",\n        \"placeholder\": \"Y Ekseni Seçin\",\n        \"position\": \"Y ekseni konumu\"\n      },\n      \"xDisplay\": {\n        \"label\": \"X Görünümü\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Y Görünümü\"\n      },\n      \"addXAxis\": \"Seri dökümü ekle\",\n      \"addYAxis\": \"Başka bir seri ekle\",\n      \"stack\": \"Yığın\",\n      \"position\": {\n        \"label\": \"Konum\",\n        \"auto\": \"Otomatik\",\n        \"left\": \"Sol\",\n        \"right\": \"Sağ\"\n      },\n      \"goalLine\": {\n        \"label\": \"Hedef Çizgisi\"\n      },\n      \"range\": {\n        \"label\": \"Aralık\",\n        \"min\": \"Minimum\",\n        \"max\": \"Maksimum\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Çizgi Stili\",\n        \"normal\": \"Normal\",\n        \"linear\": \"Doğrusal\",\n        \"step\": \"Adım\"\n      },\n      \"displayType\": \"Görünüm türü\"\n    },\n    \"typeError\": \"Form: Desteklenmeyen grafik türü\",\n    \"updateQuery\": \"Sorguyu Güncelle\",\n    \"queryError\": \"Sorgu Hatası\",\n    \"querySuccess\": \"Sorgu yapılandırıldı\",\n    \"decimal\": \"Ondalık\",\n    \"prefix\": \"Önek\",\n    \"suffix\": \"Sonek\",\n    \"showLabel\": \"Grafikte değer etiketlerini göster\",\n    \"showLegend\": \"Açıklamayı göster\",\n    \"value\": \"Değer\",\n    \"label\": \"Etiket\",\n    \"padding\": {\n      \"label\": \"Dolgu\",\n      \"top\": \"Üst\",\n      \"right\": \"Sağ\",\n      \"bottom\": \"Alt\",\n      \"left\": \"Sol\"\n    },\n    \"tableConfig\": \"Tablo Yapılandırması\",\n    \"width\": \"Genişlik\"\n  },\n  \"reloadQuery\": \"Sorguyu Yeniden Yükle\",\n  \"noStorage\": \"Lütfen önce grafik eklentisini yapılandırın\",\n  \"noPermission\": \"Erişim izni yok\",\n  \"goConfig\": \"Yapılandırmaya git\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"İşlemler\",\n    \"add\": \"Ekle\",\n    \"save\": \"Kaydet\",\n    \"doNotSave\": \"Kaydetme\",\n    \"submit\": \"Gönder\",\n    \"confirm\": \"Onayla\",\n    \"close\": \"Kapat\",\n    \"edit\": \"Düzenle\",\n    \"fill\": \"Doldur\",\n    \"update\": \"Güncelle\",\n    \"create\": \"Oluştur\",\n    \"delete\": \"Sil\",\n    \"cancel\": \"İptal\",\n    \"zoomIn\": \"Yakınlaştır\",\n    \"zoomOut\": \"Uzaklaştır\",\n    \"back\": \"Geri\",\n    \"remove\": \"Kaldır\",\n    \"removeConfig\": \"Yapılandırmayı sil\",\n    \"saveSucceed\": \"Başarıyla Kaydedildi!\",\n    \"submitSucceed\": \"Başarıyla Gönderildi!\",\n    \"editSucceed\": \"Başarıyla Düzenlendi!\",\n    \"updateSucceed\": \"Başarıyla Güncellendi!\",\n    \"deleteSucceed\": \"Başarıyla Silindi!\",\n    \"resetSucceed\": \"Çöp Kutusu Başarıyla Boşaltıldı!\",\n    \"restoreSucceed\": \"Başarıyla Geri Yüklendi!\",\n    \"loading\": \"Yükleniyor...\",\n    \"refreshPage\": \"Sayfayı Yenile\",\n    \"yesDelete\": \"Evet, sil\",\n    \"rename\": \"Yeniden Adlandır\",\n    \"duplicate\": \"Çoğalt\",\n    \"change\": \"Değiştir\",\n    \"upgrade\": \"Yükselt\",\n    \"upgradeToLevel\": \"{{level}}'e yükselt\",\n    \"search\": \"Ara\",\n    \"loadMore\": \"Daha Fazla Yükle\",\n    \"collapseSidebar\": \"Kenar Çubuğunu Daralt\",\n    \"restore\": \"Geri Yükle\",\n    \"permanentDelete\": \"Kalıcı Olarak Sil\",\n    \"globalSearch\": \"Genel Arama\",\n    \"fieldSearch\": \"Alan Ara\",\n    \"showAllRow\": \"Tüm satırları göster\",\n    \"hideNotMatchRow\": \"Eşleşmeyen satırları gizle\",\n    \"more\": \"Daha Fazla\",\n    \"expand\": \"Genişlet\",\n    \"tableIndex\": \"Dizin\",\n    \"move\": \"Taşı\",\n    \"turnOn\": \"Aç\",\n    \"exit\": \"Çıkış\",\n    \"next\": \"Sonraki\",\n    \"previous\": \"Önceki\",\n    \"select\": \"Seç\",\n    \"view\": \"Görüntüle\",\n    \"preview\": \"Önizleme\",\n    \"viewAndEdit\": \"Görüntüle ve düzenle\",\n    \"deleteTip\": \"\\\"{{name}}\\\" öğesini silmek istediğinizden emin misiniz?\",\n    \"refresh\": \"Yenile\",\n    \"login\": \"Giriş Yap\",\n    \"useTemplate\": \"Şablon Kullan\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"backToSpace\": \"Alana Dön\",\n    \"switchBase\": \"Veritabanı Değiştir\",\n    \"continue\": \"Devam Et\",\n    \"export\": \"Dışa Aktar\",\n    \"import\": \"İçe Aktar\",\n    \"getMore\": \"Daha fazla al\",\n    \"copySuccess\": \"Kopyalama başarılı\"\n  },\n  \"quickAction\": {\n    \"title\": \"Hızlı İşlemler\",\n    \"placeHolder\": \"Bir komut yazın veya arayın...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"Şifre geçersiz, en az 8 karakter olmalı ve en az bir harf ve bir rakam içermelidir.\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Paylaş\",\n      \"copy\": \"Kopyalandı\"\n    },\n    \"aiTitle\": \"Birlikte inşa edelim\",\n    \"aiGreeting\": \"Size nasıl yardımcı olabilirim, {{name}}?\",\n    \"aiSubTitle\": \"Ekiplerin veriler üzerinde işbirliği yaptığı ve üretim uygulamaları oluşturduğu ilk AI platformu\",\n    \"guideTitle\": \"Senaryonuzdan başlayın\",\n    \"watchVideo\": \"Video izle\",\n    \"title\": \"Şablon\",\n    \"description\": \"Şablondan yeni bir veritabanı oluştur\",\n    \"browseAll\": \"Tümüne Göz At\",\n    \"templateTitle\": \"Şablonla başla\",\n    \"loadMore\": \"Daha Fazla Yükle\",\n    \"allTemplatesLoaded\": \"Tüm şablonlar yüklendi\",\n    \"createTemplate\": \"Şablon Oluştur\",\n    \"useTemplateDialog\": {\n      \"title\": \"Alan Seçin\",\n      \"description\": \"Şablon için bir alan seçin\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Teable ile iş uygulamanızı oluşturun\",\n      \"start\": \"Başlat\",\n      \"carouselGuides\": {\n        \"guide1\": \"Makbuzları yapıştırın → Teable'dan anahtar verileri otomatik olarak çıkarmasını isteyin\",\n        \"guide2\": \"Müşteri adayı ve takip tabloları ile AI CRM oluşturun\",\n        \"guide3\": \"Müşteri geri bildirimlerini yapıştırın → Teable'dan içgörüler bulmasını ve raporlar oluşturmasını isteyin\",\n        \"guide4\": \"Görevler ve son tarihlerle bir proje takip sistemi oluşturun\",\n        \"guide5\": \"Müşteri adayları elektronik tablosunu yapıştırın → Teable'dan akıllı bir CRM oluşturmasını isteyin\",\n        \"guide6\": \"Gönderiler ve yayın tarihleri ile bir içerik planlayıcısı oluşturun\",\n        \"guide7\": \"Özgeçmişleri yapıştırın → Teable'dan adayları düzenlemesini ve ön elemeyi yapmasını isteyin\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Alana kopyala\",\n      \"description\": \"Bu veritabanını kopyalamak için bir alan seçin\",\n      \"baseName\": \"Veritabanı adı\",\n      \"baseNamePlaceholder\": \"Veritabanı adını girin\",\n      \"selectSpace\": \"Alan seç\",\n      \"noSpaceDescription\": \"Yönetilebilir alan yok. Devam etmek için yeni bir alan oluşturun.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"Bu alanda yönetilebilir veritabanı yok. \\\"Yeni veritabanı\\\"nı seçin.\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Örnek Ayarları\",\n    \"personal\": {\n      \"title\": \"Kişisel ayarlar\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"Şablon yönetimi\",\n      \"noData\": \"Veri yok\",\n      \"importing\": \"İçe aktarılıyor...\",\n      \"usageCount\": \"Kullanım sayısı: {{count}}\",\n      \"useTemplate\": \"Bu şablonu kullan\",\n      \"createdBy\": \"{{user}} tarafından\",\n      \"backToTemplateList\": \"Şablon listesine dön\",\n      \"tips\": {\n        \"errorCategoryName\": \"Kategori mevcut değil veya silindi\",\n        \"needSnapshot\": \"Yayınlamadan önce bir anlık görüntü oluşturun, şablon adı ve açıklaması boş olamaz\",\n        \"needPublish\": \"Öne çıkan olarak ayarlamadan önce şablonu yayınlayın\",\n        \"needBaseSource\": \"Anlık görüntü oluşturmadan önce bir kaynak veritabanı seçin\",\n        \"forbiddenUpdateSystemTemplate\": \"Sistem şablonu değiştirilemez\",\n        \"addCategoryTips\": \"Lütfen önce arama kutusuna bir kategori adı girin.\",\n        \"categoryNamePlaceholder\": \"Kategori adını girin\",\n        \"duplicateCategoryName\": \"Kategori adı zaten mevcut\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Başlayın\",\n          \"recommended\": \"Önerilen\",\n          \"all\": \"Tümü\",\n          \"browseByCategory\": \"Kategoriye göre gözat\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Kapak\",\n        \"name\": \"Ad\",\n        \"description\": \"Açıklama\",\n        \"markdownDescription\": \"Markdown açıklaması\",\n        \"category\": \"Kategori\",\n        \"isSystem\": \"Sistem\",\n        \"source\": \"Kaynak\",\n        \"status\": \"Yayınlandı\",\n        \"publishSnapshot\": \"Anlık görüntü yayınla\",\n        \"snapshotTime\": \"Anlık görüntü zamanı\",\n        \"actions\": \"Eylemler\",\n        \"featured\": \"Öne çıkan\",\n        \"createdBy\": \"Oluşturan\",\n        \"userNonExistent\": \"Kullanıcı mevcut değil\",\n        \"preview\": \"Önizleme\",\n        \"usage\": \"Kullanım\",\n        \"visit\": \"Ziyaret\"\n      },\n      \"actions\": {\n        \"title\": \"Eylemler\",\n        \"publish\": \"Yayınla\",\n        \"delete\": \"Sil\",\n        \"duplicate\": \"Çoğalt\",\n        \"preview\": \"Önizleme\",\n        \"use\": \"Kullan\",\n        \"pinTop\": \"Üste sabitle\",\n        \"addCategory\": \"Kategori ekle\",\n        \"selectCategory\": \"Kategori seç\",\n        \"viewTemplate\": \"Şablonu görüntüle\",\n        \"manageCategory\": \"Kategorileri yönet\"\n      },\n      \"relatedTemplates\": \"İlgili Şablonlar\",\n      \"noImage\": \"Resim yok\",\n      \"baseSelectPanel\": {\n        \"title\": \"Şablon kaynağını seçin\",\n        \"description\": \"Şablon olarak bir veritabanı seçin\",\n        \"confirm\": \"Onayla\",\n        \"search\": \"Ara...\",\n        \"cancel\": \"İptal\",\n        \"selectBase\": \"Veritabanı seç\",\n        \"createTemplate\": \"Şablon oluştur\",\n        \"abnormalBase\": \"Veritabanı mevcut değil veya silindi\"\n      }\n    },\n    \"back\": \"Ana sayfaya dön\",\n    \"account\": {\n      \"title\": \"Profilim\",\n      \"tab\": \"Hesabım\",\n      \"updatePhoto\": \"Fotoğrafı güncelle\",\n      \"updateNameDesc\": \"Takma ad veya isim, Teable'da size nasıl hitap edilmesini istiyorsanız\",\n      \"securityTitle\": \"Hesap güvenliği\",\n      \"email\": \"E-posta\",\n      \"password\": \"Şifre\",\n      \"passwordDesc\": \"Hesabınıza giriş yapmak için kalıcı bir şifre belirleyin.\",\n      \"changePassword\": {\n        \"title\": \"Şifre değiştir\",\n        \"desc\": \"Lütfen mevcut şifrenizi girin ve yeni bir şifre belirleyin\",\n        \"current\": \"Mevcut şifre\",\n        \"new\": \"Yeni şifre\",\n        \"confirm\": \"Yeni şifreyi onayla\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Yeni şifreleriniz eşleşmiyor.\",\n        \"equal\": \"Yeni şifreniz mevcut şifrenizden farklı olmalıdır.\",\n        \"invalid\": \"Mevcut şifreniz geçersiz.\",\n        \"invalidNew\": \"Yeni şifreniz geçersiz, minimum 8 karakter olmalıdır.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Şifre başarıyla değiştirildi.\",\n        \"desc\": \"2 saniye içinde giriş sayfasına yönlendirileceksiniz.\"\n      },\n      \"manageToken\": \"Erişim Tokeni\",\n      \"addPassword\": {\n        \"title\": \"Şifre ekle\",\n        \"desc\": \"Hesabınıza giriş yapmak için kalıcı bir şifre belirleyin.\",\n        \"password\": \"Şifrenizi girin\",\n        \"confirm\": \"Şifrenizi onaylayın\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Şifreleriniz eşleşmiyor.\",\n        \"invalid\": \"Şifreniz geçersiz, minimum 8 karakter olmalıdır.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Şifre başarıyla eklendi.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Hesabı Sil\",\n        \"desc\": \"Bu işlem geri alınamaz. Hesabınız ve tüm ilişkili veriler kalıcı olarak silinecektir.\",\n        \"error\": {\n          \"title\": \"Hesap silinemedi\",\n          \"desc\": \"Önce aşağıdaki bağımlılıkları çözmeniz gerekiyor:\",\n          \"spacesError\": \"Hesabınızı silmeden önce, Alanlarınızdan çıkmanız (veya silip çöp kutusuna taşımanız) gerekiyor.\"\n        },\n        \"confirm\": {\n          \"title\": \"Onaylamak için <code>DELETE</code> yazın\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Siliniyor...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"E-posta adresini değiştir\",\n        \"desc\": \"Lütfen şifrenizi doğrulayın ve yeni e-posta adresinizi onaylayın\",\n        \"current\": \"Mevcut şifre\",\n        \"new\": \"Yeni e-posta\",\n        \"code\": \"Doğrulama kodu\",\n        \"getCode\": \"Kodu gönder\",\n        \"error\": {\n          \"invalidCode\": \"Doğrulama kodu geçersiz.\",\n          \"invalidPassword\": \"Şifre geçersiz.\",\n          \"invalidConflict\": \"Yeni e-posta adresi zaten kayıtlı.\",\n          \"invalidSameEmail\": \"Yeni e-posta mevcut e-posta ile aynı.\",\n          \"sendMailRateLimit\": \"Yeni bir e-posta göndermeden önce lütfen {{seconds}} saniye bekleyin\"\n        },\n        \"success\": {\n          \"title\": \"🎉 E-posta başarıyla değiştirildi.\",\n          \"desc\": \"2 saniye içinde giriş sayfasına yönlendirileceksiniz.\",\n          \"sendSuccess\": \"Doğrulama kodu başarıyla gönderildi.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Bildirimlerim\",\n      \"label\": \"Alan etkinliği\",\n      \"desc\": \"Yorum aldığınızda, bahsedildiğinizde, sayfa davetleri, hatırlatmalar, erişim istekleri ve özellik değişiklikleri için e-posta alın.\"\n    },\n    \"setting\": {\n      \"title\": \"Ayarlarım\",\n      \"theme\": \"Tema\",\n      \"themeDesc\": \"Uygulama temasını seçin.\",\n      \"dark\": \"Koyu\",\n      \"light\": \"Açık\",\n      \"system\": \"Sistem\",\n      \"version\": \"Uygulama sürümü\",\n      \"language\": \"Dil\",\n      \"interactionMode\": \"Etkileşim Modu\",\n      \"mouseMode\": \"İmleç Modu\",\n      \"touchMode\": \"Dokunmatik Mod\",\n      \"systemMode\": \"Sistemi Takip Et\"\n    },\n    \"nav\": {\n      \"settings\": \"Ayarlar\",\n      \"logout\": \"Çıkış yap\",\n      \"contactSupport\": \"Destek ile İletişim\"\n    },\n    \"integration\": {\n      \"title\": \"Entegrasyonlar\",\n      \"description\": \"{{count}} uygulamaya hesabınıza erişim izni verdiniz.\",\n      \"lastUsed\": \"Son kullanım: {{date}}\",\n      \"revoke\": \"İptal et\",\n      \"owner\": \"Sahibi: {{user}}\",\n      \"revokeTitle\": \"Yetkilendirmeyi iptal etmek istediğinizden emin misiniz?\",\n      \"revokeDesc\": \"{{name}} artık Teable API'sine erişemeyecek. Bu işlem geri alınamaz.\",\n      \"scopeTitle\": \"İzinler\",\n      \"scopeDesc\": \"Bu uygulama aşağıdaki kapsamlara erişebilecek:\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tablo\",\n    \"view\": \"Görünüm\",\n    \"space\": \"Alan\",\n    \"base\": \"Veritabanı\",\n    \"field\": \"Alan\",\n    \"record\": \"Kayıt\",\n    \"dashboard\": \"Gösterge Paneli\",\n    \"automation\": \"Otomasyon\",\n    \"authorityMatrix\": \"Yetki Matrisi\",\n    \"design\": \"Tasarım\",\n    \"adminPanel\": \"Sistem Yönetimi\",\n    \"license\": \"Kendi sunuculu lisans\",\n    \"instanceId\": \"Örnek Kimliği\",\n    \"beta\": \"Beta\",\n    \"trash\": \"Çöp Kutusu\",\n    \"global\": \"Genel\",\n    \"organizationPanel\": \"Organizasyon Ayarları\",\n    \"unknownError\": \"Bilinmeyen hata\",\n    \"pluginPanel\": \"Panel\",\n    \"pluginContextMenu\": \"Bağlam menüsü\",\n    \"plugin\": \"Eklentiler\",\n    \"copy\": \"Kopya\",\n    \"credits\": \"Krediler\",\n    \"aiChat\": \"AI Sohbet\",\n    \"app\": \"Uygulama\",\n    \"webSearch\": \"Web araması\",\n    \"folder\": \"Klasör\",\n    \"newAutomation\": \"Yeni otomasyon\",\n    \"newApp\": \"Yeni uygulama\",\n    \"newFolder\": \"Yeni klasör\",\n    \"template\": \"Şablon\"\n  },\n  \"level\": {\n    \"free\": \"Ücretsiz\",\n    \"plus\": \"Plus\",\n    \"pro\": \"Pro\",\n    \"business\": \"İş\",\n    \"enterprise\": \"Kurumsal\"\n  },\n  \"noResult\": \"Sonuç Bulunamadı.\",\n  \"allNodes\": \"Tüm Düğümler\",\n  \"noDescription\": \"Açıklama yok\",\n  \"untitled\": \"Başlıksız\",\n  \"name\": \"İsim\",\n  \"description\": \"Açıklama\",\n  \"required\": \"Zorunlu\",\n  \"characters\": \"karakter\",\n  \"atLeastOne\": \"En az bir {{noun}} bulunmalıdır\",\n  \"guide\": {\n    \"prev\": \"Önceki\",\n    \"next\": \"Sonraki\",\n    \"done\": \"Tamam\",\n    \"skip\": \"Atla\",\n    \"createSpaceTooltipTitle\": \"Alan oluştur\",\n    \"createSpaceTooltipContent\": \"Teable alanlar halinde düzenlenmiştir, her alan kullanıcıları işbirliği yapmaya davet eder. <br></br>Teable'daki alanlar, menü çubuğunda birincil gezinme öğesi olarak hizmet verir ve kullanıcılara gerektiğinde veritabanı ekleme ve yönetme için temel bir platform sunar.\",\n    \"createBaseTooltipTitle\": \"Veritabanı oluştur\",\n    \"createBaseTooltipContent\": \"Veritabanı, önemli verileri ve bunlara bağlı iş akışlarını depolamak için bir yerdir.\",\n    \"createTableTooltipTitle\": \"Tablo oluştur\",\n    \"createTableTooltipContent\": \"Tablolar, çeşitli veri türleri aracılığıyla bilgilerin çok yönlü görüntülenmesini sağlayarak farklı veri kümelerini verimli bir şekilde işlemek için tasarlanmıştır.\",\n    \"createViewTooltipTitle\": \"Görünüm oluştur\",\n    \"createViewTooltipContent\": \"Şu anda kullanıcılar Izgara, Galeri, Kanban ve Form görünümleri oluşturabilir, Takvim görünümleri gelecek sürümlerde planlanmaktadır. <br></br>Bu çeşitlilik, kullanıcılara çeşitli veri yönetimi görevleri için kapsamlı bir araç seti sunar.\",\n    \"viewFilteringTooltipTitle\": \"Kayıtları filtreleme\",\n    \"viewFilteringTooltipContent\": \"Görünümlerin temel özelliklerinden biri, belirlediğiniz koşullara göre kayıtları görünümden filtreleyebilmektir. <br></br>Bir kayıt bir koşula göre filtrelendiğinde silinmez—sadece tablonuza bakmak için kullandığınız belirli görünümden gizlenir.\",\n    \"viewSortingTooltipTitle\": \"Kayıtları sıralama\",\n    \"viewSortingTooltipContent\": \"Bir görünümdeyken, kayıtlarınızı belirli alanlardaki değerlere göre belirli bir sırada görünecek şekilde sıralayabilirsiniz. <br></br>Kayıtlarınızı bir görünümde sıralamak diğer görünümlerdeki kayıtların sırasını etkilemez—sadece tablonuza bakmak için kullandığınız mevcut görünüme uygulanır.\",\n    \"viewGroupingTooltipTitle\": \"Kayıtları gruplama\",\n    \"viewGroupingTooltipContent\": \"Kayıtları gruplamak, oluşturuculara belirli bir görünümde sunulan veri kümesini kategorize etmeye yardımcı olacak bir veya daha fazla koşul kümesi oluşturma imkanı sağlar.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable, neredeyse tüm ürün özelliklerini destekleyen güçlü bir API sunar ve geliştiricilerin <a>Token</a> kullanarak çağrı yapmasına olanak tanır.\"\n  },\n  \"token\": \"Token\",\n  \"poweredBy\": \"<0></0> tarafından desteklenmektedir\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"{{spaceName}} alanı paylaşımı\",\n      \"desc_one\": \"Bu alanda <b>{{count}} işbirlikçi</b> var. Bir alan işbirlikçisi eklemek, onlara bu alandaki tüm veritabanlarına erişim izni verecektir.\",\n      \"desc_other\": \"Bu alanda <b>{{count}} işbirlikçi</b> var. Bir alan işbirlikçisi eklemek, onlara bu alandaki tüm veritabanlarına erişim izni verecektir.\",\n      \"tabEmail\": \"E-posta ile davet et\",\n      \"emailPlaceholder\": \"E-posta adreslerini girin ve Enter tuşu ile ayırın\",\n      \"tabLink\": \"Bağlantı ile davet et\",\n      \"linkPlaceholder\": \"Açan herkese <0/> erişimi veren bir davet bağlantısı oluştur.\",\n      \"emailSend\": \"Davet gönder\",\n      \"linkSend\": \"Bağlantı oluştur\",\n      \"spaceTitle\": \"Alan işbirlikçileri\",\n      \"collaboratorSearchPlaceholder\": \"İsim veya e-posta ile alan işbirlikçisi bul\",\n      \"collaboratorJoin\": \"{{joinTime}} tarihinde katıldı\",\n      \"collaboratorRemove\": \"İşbirlikçiyi kaldır\",\n      \"linkTitle\": \"Davet bağlantıları\",\n      \"linkCreatedTime\": \"{{createdTime}} tarihinde oluşturuldu\",\n      \"linkCopySuccess\": \"Bağlantı kopyalandı\",\n      \"linkRemove\": \"Bağlantıyı kaldır\"\n    },\n    \"base\": {\n      \"title\": \"{{baseName}} paylaş\",\n      \"desc_one\": \"Bu veritabanı {{count}} işbirlikçi ile paylaşılıyor.\",\n      \"desc_other\": \"Bu veritabanı {{count}} işbirlikçi ile paylaşılıyor.\",\n      \"baseTitle\": \"Veritabanı işbirlikçileri\",\n      \"collaboratorSearchPlaceholder\": \"İsim veya e-posta ile veritabanı işbirlikçisi bul\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Yardım\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Sayfa izinleri güncellendi. En son içeriği görmek için lütfen sayfayı yenileyin.\",\n  \"listEmptyTips\": \"Liste boş\",\n  \"billing\": {\n    \"overLimits\": \"Limit aşımı\",\n    \"overLimitsDescription\": \"Mevcut aboneliğiniz kullanım sınırını aştı. Bu özelliği kesintisiz kullanmaya devam etmek için lütfen planınızı yükseltin.\",\n    \"userLimitExceededDescription\": \"Mevcut örnek, lisansınızın izin verdiği maksimum kullanıcı sayısına ulaştı. Lütfen bazı kullanıcıları devre dışı bırakın veya lisansı yükseltin.\",\n    \"unavailableInPlanTips\": \"Mevcut abonelik planı bu özelliği desteklemiyor\",\n    \"unavailableConnectionTips\": \"Veritabanı bağlantısı özelliği gelecekte kaldırılacak ve yalnızca Kurumsal Plan ve kendi sunucu sürümleri için kullanılabilir olacak.\",\n    \"levelTips\": \"Bu alan şu anda {{level}} planında\",\n    \"billable\": \"Faturalandırılabilir\",\n    \"billableByAuthorityMatrix\": \"Yetki matrisi tarafından oluşturulan faturalandırma\",\n    \"licenseExpiredGracePeriod\": \"Kendi sunucunuzda barındırma lisansınızın süresi doldu ve {{expiredTime}} tarihinde ücretsiz plana düşürülecek. Premium özelliklere erişimi korumak için lütfen lisansınızı hemen güncelleyin.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Alan abonelik planını yükselt\",\n      \"description\": \"Yalnızca sahibi olduğunuz çalışma alanlarını yükseltebilirsiniz\"\n    },\n    \"status\": {\n      \"active\": \"Aktif\",\n      \"canceled\": \"İptal Edildi\",\n      \"incomplete\": \"Tamamlanmadı\",\n      \"incompleteExpired\": \"Tamamlanmamış Süresi Doldu\",\n      \"trialing\": \"Deneme\",\n      \"pastDue\": \"Vadesi Geçmiş\",\n      \"unpaid\": \"Ödenmemiş\",\n      \"paused\": \"Duraklatıldı\",\n      \"seatLimitExceeded\": \"Koltuk limiti aşıldı\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"description\": \"Mevcut örneğinizin ayarlarını değiştirin\",\n      \"allowSignUp\": \"Yeni hesap oluşturmaya izin ver\",\n      \"allowSignUpDescription\": \"Bu seçeneği devre dışı bırakmak yeni kullanıcı kayıtlarını yasaklar ve kayıt düğmesi artık giriş sayfasında görünmez.\",\n      \"allowSpaceInvitation\": \"Alan davetleri göndermeye izin ver\",\n      \"allowSpaceInvitationDescription\": \"Bu seçeneği devre dışı bırakmak, yöneticiler dışındaki kullanıcıların başkalarını alanlara davet etmesini engeller. Etkinleştirildiğinde, e-posta ile davet edilen yeni kullanıcılar e-postadaki davet bağlantısına tıklayarak kaydı tamamlayabilir, ancak paylaşılan davet bağlantıları çalışmaz.\",\n      \"allowSpaceCreation\": \"Herkesin yeni alan oluşturmasına izin ver\",\n      \"allowSpaceCreationDescription\": \"Bu seçeneği devre dışı bırakmak, yöneticiler dışındaki kullanıcıların yeni alan oluşturmasını engeller.\",\n      \"enableEmailVerification\": \"E-posta doğrulamasını etkinleştir\",\n      \"enableEmailVerificationDescription\": \"Bu seçeneği etkinleştirmek, yeni hesap oluştururken kullanıcıların e-posta adreslerini doğrulamasını gerektirecektir.\",\n      \"enableWaitlist\": \"Bekleme listesini etkinleştir\",\n      \"enableWaitlistDescription\": \"Bu seçeneği etkinleştirmek, kullanıcıların yalnızca davet kodu ile hesap oluşturmasına izin verecektir.\",\n      \"generalSettings\": \"Genel ayarlar\",\n      \"aiSettings\": \"AI ayarları\",\n      \"brandingSettings\": {\n        \"title\": \"Marka ayarları\",\n        \"description\": \"Yalnızca Kurumsal Sürümde kullanılabilir\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"Logo, Teable'daki marka kimliğinizdir.\",\n        \"logoUpload\": \"Logo yükle\",\n        \"logoUploadDescription\": \"Logo resmi yükleyin, PNG, JPEG formatlarını destekler, önerilen boyut 100x100px. Maksimum yükleme boyutu 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"İsim\",\n        \"nameDescription\": \"İsim özelleştirilebilir, farklı model sağlayıcılarını ayırt etmek için kullanılır\",\n        \"enable\": \"AI'ı etkinleştir\",\n        \"enableDescription\": \"Mevcut örnek için AI özelliklerini etkinleştirin, tüm kullanıcılar AI özelliklerini kullanabilecek\",\n        \"updateLLMProvider\": \"Model sağlayıcısını güncelle\",\n        \"addProvider\": \"Model sağlayıcı ekle\",\n        \"addProviderDescription\": \"Listeye yeni bir model sağlayıcı ekle\",\n        \"providerType\": \"Sağlayıcı türü\",\n        \"baseUrl\": \"Temel URL\",\n        \"apiKey\": \"API anahtarı\",\n        \"baseUrlDescription\": \"Model sağlayıcının temel URL'si\",\n        \"apiKeyDescription\": \"Model sağlayıcının API anahtarı\",\n        \"models\": \"Modeller\",\n        \"modelsDescription\": \"Model sağlayıcının desteklediği modeller\",\n        \"baseUrlRequired\": \"Temel URL gereklidir\",\n        \"fetchModelListError\": \"Model listesi alınamadı\",\n        \"provider\": \"Model sağlayıcı\",\n        \"providerDescription\": \"Kullanılacak model sağlayıcı\",\n        \"modelPreferences\": \"Model tercihleri\",\n        \"modelPreferencesDescription\": \"Model sağlayıcının model tercihleri\",\n        \"embeddingModel\": \"Gömme modeli\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Çeviri modeli\",\n        \"translationModelDescription\": \"Kullanılacak çeviri modeli\",\n        \"chatModel\": \"Sohbet modeli\",\n        \"chatModelDescription\": \"Kullanılacak sohbet modeli, İpucu: orta kodlama modeli varsayılan olarak AI formül üretimi ve ilgili özellikler için kullanılır\",\n        \"chatModels\": {\n          \"lg\": \"Gelişmiş sohbet modeli\",\n          \"lgDescription\": \"Planlama, kodlama ve diğer karmaşık görev senaryoları için. Önerilen: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"AI Yetenekleri\",\n          \"aiField\": {\n            \"title\": \"AI Alan\",\n            \"description\": \"Otomatik doldurma ve alan ayarlarında AI yapılandırması dahil AI alan işlevleri\"\n          },\n          \"aiChat\": {\n            \"title\": \"AI Sohbet\",\n            \"description\": \"AI Sohbet kenar çubuğu ve tüm ajan işlevleri\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Test modeli\",\n          \"description\": \"Modelin resim, PDF işleme kabiliyetini test et\",\n          \"notConfigLgModel\": \"Lütfen önce büyük modeli yapılandırın\",\n          \"confirmTitle\": \"Model Kabiliyetini Test Et\",\n          \"confirmDescription\": \"Büyük sohbet modelinin kabiliyetlerini test etmek istiyor musunuz?\",\n          \"confirm\": \"Modeli Test Et\",\n          \"cancel\": \"İptal\",\n          \"missingCapabilitiesWarning\": \"Bu model resim veya PDF işlemeyi desteklemiyor, bu bazı özelliklerin kullanılamaz olmasına neden olabilir.\",\n          \"enableAITitle\": \"AI'ı Etkinleştir\",\n          \"enableAIDescription\": \"Model testi tamamlandı. AI şu anda etkin değil. Bu model kabiliyetlerini kullanmak için AI'ı etkinleştirmek istiyor musunuz?\",\n          \"enableAI\": \"AI'ı Etkinleştir\",\n          \"skipTest\": \"Atla\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Resim\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Web Araması\",\n          \"disabledWebSearch\": \"Web Araması Devre Dışı\",\n          \"lgModelAbility\": \"Büyük model kabiliyeti\"\n        },\n        \"configUpdated\": \"AI yapılandırması güncellendi\",\n        \"noModelFound\": \"Model bulunamadı\",\n        \"searchModel\": \"Model ara...\",\n        \"selectModel\": \"Model seç...\",\n        \"input\": \"Giriş {{ratio}}\",\n        \"output\": \"Çıkış {{ratio}}\",\n        \"inputOrOutputTip\": \"Oran, hesaplama gücü ile Token arasındaki dönüşüm ilişkisini temsil eder, örneğin \\\"1:1000\\\" 1 hesaplama gücü ≈ 1000 Token anlamına gelir.<br></br>Not: Her kullanımda en az 1 hesaplama gücü kesilir, gerçek tüketim 1 hesaplama gücünden az olsa bile\",\n        \"imageOutput\": \"Her resim {{credits}}\",\n        \"imageOutputTip\": \"Resim üretimi hesaplama gücü gerektirir, her resim {{credits}} hesaplama gücü tüketir\",\n        \"supportImageOutputTip\": \"Bu model resim üretimini destekler\",\n        \"supportVisionTip\": \"Bu model resim girişini destekler\",\n        \"supportAudioTip\": \"Bu model ses girişini destekler\",\n        \"supportVideoTip\": \"Bu model video girişini destekler\",\n        \"supportDeepThinkTip\": \"Bu model derin düşünmeyi destekler\",\n        \"testConnection\": \"Test\",\n        \"testing\": \"Test ediliyor...\",\n        \"testSuccess\": \"Test başarılı\",\n        \"testFailed\": \"Test başarısız\",\n        \"fillRequiredFields\": \"Lütfen tüm gerekli alanları doldurun\",\n        \"modelsRequired\": \"Lütfen en az bir model doldurun\",\n        \"noValidModel\": \"Geçerli model bulunamadı\",\n        \"addCustomModel\": \"Özel model ekle\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Web arama özelliğini etkinleştirmek için Firecrawl API anahtarını yapılandırın, API anahtarını almak için <a>Firecrawl ayarlarına</a> erişin\"\n      },\n      \"app\": {\n        \"domain\": \"Alan Adı\",\n        \"v0ApiKey\": \"v0 API Anahtarı\",\n        \"customDomain\": \"Özel Alan Adı (İsteğe Bağlı)\",\n        \"customDomainDescription\": \"Uygulama dağıtımı için özel alan adınızı ayarlayın, özel alan adı almak için <a>Vercel alan adına</a> erişin\",\n        \"vercelToken\": \"Vercel API Token\",\n        \"vercelTokenDescription\": \"API token almak için <a>Vercel ayarlarına</a> erişin\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"API anahtarı girin\",\n      \"goToConfiguration\": \"Yapılandırmaya git\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Teable kullandığınız için teşekkürler\",\n      \"pleaseGoToConfiguration\": \"Teable'ın tam işlevselliğini ve daha iyi kullanıcı deneyimini keyfini çıkarmak için lütfen ayarlar sayfasına giderek bazı başlangıç yapılandırmalarını tamamlayın\",\n      \"pleaseContactAdmin\": \"Lütfen yöneticiyle iletişime geçin\"\n    },\n    \"configuration\": {\n      \"title\": \"Yapılandırma bekleyen öğeler\",\n      \"description\": \"Tam işlevsellik için bu yapılandırmaları tamamlayın\",\n      \"copyInstance\": \"ID'yi kopyala\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"PUBLIC_ORIGIN ortam değişkeni\",\n          \"description\": \"<strong>PUBLIC_ORIGIN</strong> ortam değişkeni yapılandırmanız mevcut erişim adresi <underline>https://example.com</underline> ile eşleşmiyor, xlsx, csv içe aktarma ve ek alanı işlevleri normal çalışmayabilir, <underline>https://example.com</underline> olarak ayarlanması önerilir\"\n        },\n        \"https\": {\n          \"title\": \"HTTPS'yi etkinleştir\",\n          \"description\": \"HTTPS'yi etkinleştirmediniz, büyük ölçekli kopyalama (300 satır veya daha fazla) özelliği kullanılamayacak, etkinleştirmeniz önerilir.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"PUBLIC_DATABASE_PROXY ortam değişkeni\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> yapılandırılmamış, harici veritabanı bağlantı özelliği kullanılamayacak, lütfen <a>yardım belgelerine</a> bakın\",\n          \"href\": \"https://help.teable.ai/tr/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"AI LLM API'sını henüz yapılandırmadınız, AI Sohbet/AI otomasyon kullanılamayacak, <anchor>ayarlara git</anchor>\",\n          \"errorTips\": \"AI LLM API'sını henüz yapılandırmadınız, AI Sohbet/AI otomasyon kullanılamayacak\"\n        },\n        \"app\": {\n          \"title\": \"Uygulama Üretimi\",\n          \"description\": \"v0 API'sını henüz yapılandırmadınız, Uygulama Üretimi özelliği kullanılamayacak, <anchor>ayarlara git</anchor>\",\n          \"errorTips\": \"v0 API'sını henüz yapılandırmadınız, Uygulama Üretimi özelliği kullanılamayacak\"\n        },\n        \"webSearch\": {\n          \"title\": \"Web Araması\",\n          \"description\": \"Web arama API'sını henüz yapılandırmadınız, web arama özelliği kullanılamayacak, <anchor>ayarlara git</anchor>\",\n          \"errorTips\": \"Web arama API'sını henüz yapılandırmadınız, web arama özelliği kullanılamayacak\"\n        },\n        \"email\": {\n          \"title\": \"E-posta\",\n          \"description\": \"E-posta yapılandırılmamış, self-servis şifre kurtarma ve e-posta bildirim işlevi kullanılamayacak, <anchor>ayarlara git</anchor>\",\n          \"errorTips\": \"E-posta yapılandırılmamış, self-servis şifre kurtarma, e-posta doğrulama/bildirim işlevi kullanılamayacak\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Bildirimler\",\n    \"unread\": \"Okunmamış\",\n    \"read\": \"Okunmuş\",\n    \"markAs\": \"Bu bildirimi {{status}} olarak işaretle\",\n    \"markAllAsRead\": \"Tümünü okundu olarak işaretle\",\n    \"noUnread\": \"{{status}} bildirim yok\",\n    \"changeSetting\": \"Sayfa bildirim ayarlarını değiştir\",\n    \"new\": \"yeni {{count}}\",\n    \"showMore\": \"Daha fazla göster\",\n    \"exportBase\": {\n      \"successText\": \"Dışa aktarma verileri hazır\",\n      \"failedText\": \"Dışa aktarma başarısız oldu, lütfen tekrar deneyin\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Sahip\",\n      \"creator\": \"Oluşturucu\",\n      \"editor\": \"Düzenleyici\",\n      \"commenter\": \"Yorumcu\",\n      \"viewer\": \"İzleyici\"\n    },\n    \"description\": {\n      \"owner\": \"Veritabanlarını, otomasyonları, yetki matrislerini tam olarak yapılandırıp düzenleyebilir ve alan ayarlarını ve faturalandırmayı yönetebilir\",\n      \"creator\": \"Veritabanlarını, otomasyonları tam olarak yapılandırıp düzenleyebilir ve yetki matrisini etkinleştirebilir\",\n      \"editor\": \"Kayıtları ve görünümleri düzenleyebilir, ancak tabloları veya alanları yapılandıramaz\",\n      \"commenter\": \"Kayıtlara yorum yapabilir\",\n      \"viewer\": \"Düzenleyemez veya yorum yapamaz\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Alan çöp kutusu\",\n    \"type\": \"Tür\",\n    \"resetTrash\": \"Çöp kutusunu boşalt\",\n    \"deletedBy\": \"Silen\",\n    \"deletedTime\": \"Silinme zamanı\",\n    \"fromSpace\": \"\\\"{{name}}\\\" alanından\",\n    \"permanentDeleteTips\": \"\\\"{{name}}\\\" {{resource}} öğesini kalıcı olarak silmek istediğinizden emin misiniz?\",\n    \"resetTrashConfirm\": \"Çöp kutusunu boşaltmak istediğinizden emin misiniz?\",\n    \"addToTrash\": \"Çöp kutusuna taşı\",\n    \"description\": \"Çöp kutusundaki veriler hala kayıt kullanımı ve ekli dosya kullanımı kaplar.\",\n    \"spaceDescription\": \"Son {{retentionDays}} gün içinde silinen alanları geri yükle\",\n    \"spaceInnerDescription\": \"Son {{retentionDays}} gün içinde bu alandan silinen veritabanlarını geri yükle\",\n    \"baseDescription\": \"Son {{retentionDays}} gün içinde bu veritabanından silinen kaynakları geri yükle\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"Eklenti URL'si Ayarlanmamış\",\n    \"install\": \"Yükle\",\n    \"publisher\": \"Yayıncı\",\n    \"lastUpdated\": \"Son Güncelleme\",\n    \"pluginNotFound\": \"Eklenti Bulunamadı\",\n    \"pluginEmpty\": {\n      \"title\": \"Henüz Eklenti Yok\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Mevcut otomasyonu açmak istediğinizden emin misiniz?\"\n  },\n  \"email\": {\n    \"send\": \"Gönder\",\n    \"config\": \"E-posta Yapılandırması\",\n    \"customConfig\": \"Özel E-posta Sunucusu\",\n    \"notify\": \"Bildirim E-postası\",\n    \"automation\": \"Otomasyon E-postası\",\n    \"customNotifyConfig\": \"Özel Bildirim E-posta Yapılandırması\",\n    \"customAutomationConfig\": \"Özel Otomasyon E-posta Yapılandırması\",\n    \"addConfig\": \"Yapılandırma Ekle\",\n    \"editConfig\": \"Yapılandırmayı Düzenle\",\n    \"resetConfig\": \"Sıfırla\",\n    \"testEmail\": \"Test E-postası\",\n    \"testEmailPlaceholder\": \"Lütfen test e-postasını girin\",\n    \"testEmailError\": \"Lütfen geçerli bir test e-posta adresi girin\",\n    \"testEmailSend\": \"Test e-postası başarıyla gönderildi, lütfen posta kutunuzu kontrol edin\",\n    \"configError\": \"Lütfen geçerli bir e-posta yapılandırması girin\",\n    \"host\": \"Sunucu Adresi\",\n    \"hostDescription\": \"Lütfen SMTP e-posta sunucu adresini girin, örneğin: smtp.example.com\",\n    \"port\": \"Port\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Kimlik Doğrulama\",\n    \"username\": \"Kullanıcı Adı\",\n    \"password\": \"Şifre\",\n    \"sender\": \"Gönderen Adresi\",\n    \"senderName\": \"Gönderen Adı\",\n    \"subscribe\": \"Abone ol\",\n    \"unsubscribe\": \"Abonelikten çık\",\n    \"unsubscribeList\": \"Abonelikten çıkma listesi\",\n    \"unsubscribeTime\": \"Abonelikten çıkma zamanı\",\n    \"source\": \"Kaynak\",\n    \"sourceAutomationDeleted\": \"Otomasyon veya düğüm silindi\",\n    \"processing\": \"İşleniyor...\",\n    \"unsubscribeH1\": \"Abonelikten çıkışı onaylıyor musunuz?\",\n    \"unsubscribeH2\": \"Gelecekteki Teable promosyonları ve ürün güncellemelerinden aboneliğinizi iptal etmek üzeresiniz. Abonelikten çıkmak istediğinizden emin misiniz?\",\n    \"subscribeH1\": \"Aboneliği onaylıyor musunuz?\",\n    \"subscribeH2\": \"Gelecekteki Teable promosyonları ve ürün güncellemelerine abone olmak üzeresiniz. Abone olmak istediğinizden emin misiniz?\",\n    \"unsubscribeListTip\": \"Mevcut tabanındaki aşağıdaki kullanıcılar abonelikten çıkmıştır, artık onlara e-posta gönderemeyeceksiniz. E-postaları içe aktarırken, lütfen e-posta adreslerini ilk sütuna yerleştirin ve başlığı \\\"email\\\" olarak adlandırın.\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Şifreyi Sıfırla - {{brandName}}\",\n        \"title\": \"Şifrenizi sıfırlayın\",\n        \"message\": \"Bu değişikliği talep etmediyseniz, lütfen bu e-postayı görmezden gelin. Aksi takdirde, şifrenizi sıfırlamak için aşağıdaki düğmeye tıklayın.\",\n        \"buttonText\": \"Şifreyi Sıfırla\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Kayıt Doğrulama - {{brandName}}\",\n          \"title\": \"Kayıt Doğrulama\",\n          \"message\": \"Doğrulama kodunuz {{code}}, lütfen {{expiresIn}} dakika içinde kullanın.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Alan Adı Doğrulama - {{brandName}}\",\n          \"title\": \"Alan Adı Doğrulama\",\n          \"message\": \"Tek kullanımlık kodunuz: {{code}}, lütfen {{expiresIn}} dakika içinde kullanın.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"E-posta Değiştirme Doğrulama - {{brandName}}\",\n          \"title\": \"E-posta Değiştirme Doğrulama\",\n          \"message\": \"Doğrulama kodunuz {{code}}, lütfen {{expiresIn}} dakika içinde kullanın.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} sizi {{tableName}} tablosundaki bir kaydın {{fieldName}} alanına ekledi\",\n        \"title\": \"<strong>{{fromUserName}}</strong> sizi <strong>{{tableName}}</strong> tablosundaki bir kaydın <strong>{{fieldName}}</strong> alanına ekledi\",\n        \"buttonText\": \"Kaydı görüntüle\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} sizi {{tableName}} tablosundaki {{refLength}} kayda ekledi\",\n        \"title\": \"<strong>{{fromUserName}}</strong> sizi <strong>{{tableName}}</strong> tablosundaki <strong>{{refLength}}</strong> kayda ekledi\",\n        \"buttonText\": \"Kayıtları görüntüle\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) sizi {{resourceAlias}} {{resourceName}}'e davet etti - {{brandName}}\",\n        \"title\": \"İşbirliğine davet\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) sizi {{resourceAlias}} <strong>{{resourceName}}</strong>'e davet etti.\",\n        \"buttonText\": \"Daveti kabul et\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Hoş geldiniz - {{brandName}}\",\n        \"title\": \"Hoş geldiniz\",\n        \"message\": \"{{brandName}} bekleme listesine başarıyla katıldınız. Kayıt olmak için aşağıdaki davet kodunu kullanın: {{code}}, {{times}} kez kullanılabilir.\",\n        \"buttonText\": \"Kayıt ol\"\n      },\n      \"test\": {\n        \"subject\": \"Test E-postası - {{brandName}}\",\n        \"title\": \"Test E-postası\",\n        \"message\": \"Bu bir test e-postasıdır, lütfen görmezden gelin.\"\n      },\n      \"notify\": {\n        \"subject\": \"Bildirim - {{brandName}}\",\n        \"title\": \"Bildirim\",\n        \"buttonText\": \"Görüntüle\",\n        \"import\": {\n          \"title\": \"İçe Aktarma Sonuç Bildirimi\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ {{tableName}} içe aktarma iptal edildi: {{errorMessage}} başarısız satır aralığı: [{{range}}]. Lütfen bu aralıktaki verileri kontrol edin ve tekrar deneyin.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ {{tableName}} içe aktarma başarısız oldu: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ {{tableName}} içe aktarma başarısız oldu: Satır sınırına ulaşıldı, daha fazla kayıt içe aktarmak için planınızı yükseltin\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ {{tableName}} içe aktarma başarısız oldu: Hiçbir kayıt işlenmedi\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} başarıyla içe aktarıldı.\",\n              \"inplace\": \"🎉 {{tableName}} yerinde başarıyla içe aktarıldı.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Kayıt Yorumu Bildirimi\",\n          \"message\": \"{{fromUserName}}, {{baseName}} içindeki {{tableName}} tablosundaki {{recordName}} kaydına yorum yaptı\"\n        },\n        \"automation\": {\n          \"title\": \"Otomasyon Bildirimi\",\n          \"failed\": {\n            \"title\": \"Otomasyon {{name}} başarısız oldu\",\n            \"message\": \"Otomasyonunuz {{name}} çalıştırılamadı. Çalıştırma geçmişinden belirli hataları görmek için aşağıdaki düğmeye tıklayın.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"Otomasyon {{name}} yetersiz kredi nedeniyle başarısız oldu\",\n            \"message\": \"Otomasyonunuz {{name}} yetersiz kredi nedeniyle çalıştırılamadı. Lütfen aboneliği yükseltin veya destekle iletişime geçin.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"Otomasyon {{name}} aylık maksimum çalıştırma sayısına ulaştı\",\n            \"message\": \"Otomasyon {{name}} için aylık çalıştırma hakkı tükendiği için şu anda çalıştırılamıyor. Aboneliğinizi yükseltin veya ek çalıştırma satın alın.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Fatura bildirimi\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Alan {{spaceName}} yapay zeka kredileri %80 kullanıldı\",\n              \"message\": \"Alanınız yapay zeka kredilerinin %80'ini kullandı. Yükseltmeyi veya daha fazla kredi satın almayı düşünün.\"\n            },\n            \"warning90\": {\n              \"title\": \"Alan {{spaceName}} yapay zeka kredileri %90 kullanıldı\",\n              \"message\": \"Alanınız yapay zeka kredilerinin %90'ını kullandı. Kesinti yaşamamak için yakında yükseltin.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Alan {{spaceName}} otomasyon çalıştırmaları kotanın %80'i kullanıldı\",\n              \"message\": \"Alanınız {{usedRuns}}/{{totalLimit}} otomasyon çalıştırmasının %80'ini kullandı. Planınızı yükseltmeyi düşünün.\"\n            },\n            \"warning90\": {\n              \"title\": \"Alan {{spaceName}} otomasyon çalıştırmaları kotanın %90'ı kullanıldı\",\n              \"message\": \"Alanınız {{usedRuns}}/{{totalLimit}} otomasyon çalıştırmasının %90'ını kullandı. Kesinti yaşamamak için yakında yükseltin.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Alan {{spaceName}} otomasyon çalıştırmaları aşıldı – ek süre aktif\",\n              \"message\": \"Otomasyon çalıştırma kotanız aşıldı. Otomasyonlar {{remainingHours}} saat sonra çalışmayı durduracak ve kapatılacak. Lütfen planınızı yükseltin.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Veritabanı Dışa Aktarma Sonuç Bildirimi\",\n          \"success\": {\n            \"message\": \"{{baseName}} başarıyla dışa aktarıldı: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ {{baseName}} dışa aktarma başarısız oldu: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"AI görevi {{tableName}} tablosunda başarısız oldu\",\n              \"message\": \"{{tableName}} tablosundaki {{fieldName}} alanı için AI görevi (Kayıt ID: {{recordId}}) başarısız oldu.\\n\\n{{errorMsg}}\\n\\nAyrıntıları görmek için aşağıdaki düğmeye tıklayın.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Bekleme Listesi\",\n    \"email\": \"E-posta\",\n    \"joinTitle\": \"İhtiyaçlarımızı hızla karşılıyoruz\",\n    \"joinDesc\": \"Bekleme listesine katılın — hazır olduğumuzda size haber vereceğiz\",\n    \"emailPlaceholder\": \"E-posta adresinizi girin\",\n    \"youAreOnTheList\": \"Bekleme listesine katıldınız!\",\n    \"thanksForJoining\": \"Bekleme listesine katıldığınız için teşekkür ederiz. Hazır olduğumuzda size haber vereceğiz.\",\n    \"back\": \"Geri\",\n    \"inviteCodePlaceholder\": \"Davet kodunu girin\",\n    \"join\": \"Bekleme listesine katıl\",\n    \"joining\": \"Katılıyor...\",\n    \"invite\": \"Davet\",\n    \"inviteTime\": \"Davet zamanı\",\n    \"createdTime\": \"Katılma zamanı\",\n    \"yes\": \"Evet\",\n    \"no\": \"Hayır\",\n    \"generateCode\": \"Davet kodu oluştur\",\n    \"count\": \"Adet\",\n    \"times\": \"Kullanım\",\n    \"generate\": \"Oluştur\",\n    \"code\": \"Davet kodu\",\n    \"inviteSuccess\": \"Davet başarılı\",\n    \"app\": {\n      \"previewAppError\": \"Uygulama hatası\",\n      \"sendErrorToAI\": \"Hata AI'ye gönder\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"\\\"{{name}}\\\" veritabanını silmek istediğinizden emin misiniz?\",\n    \"createResource\": \"Kaynak oluştur\",\n    \"noPermissionToCreateResource\": \"Kaynak oluşturma izniniz yok\"\n  },\n  \"credit\": {\n    \"title\": \"Kredi\",\n    \"leftAmount\": \"Kalan kredi\",\n    \"winFreeCredits\": \"Deneyim paylaş\",\n    \"getCredits\": \"1000 ücretsiz kredi al\",\n    \"winCredit\": {\n      \"title\": \"Olumlu bir inceleme paylaşarak kazan\",\n      \"freeCredits\": \"1000 ücretsiz kredi\",\n      \"guidelinesTitle\": \"Paylaşım kontrol listesi\",\n      \"tagTeableio\": \"<bold>@teableio</bold>'yu etiketle\",\n      \"minCharacters\": \"<bold>80+</bold> karakter inceleme yaz\",\n      \"minFollowers\": \"<bold>10+</bold> takipçiye sahip ol\",\n      \"limitPerWeek\": \"Gönderi başına 500 kredi, haftalık en fazla 2 gönderi (X + LinkedIn)\",\n      \"postOnX\": \"X'te paylaş\",\n      \"postOnLinkedIn\": \"LinkedIn'de paylaş\",\n      \"preFilledDraft\": \"🌟 Sizin için önceden doldurulmuş taslak hazır!\",\n      \"claimTitle\": \"Kredilerinizi almak için gönderi URL'sini yapıştırın\",\n      \"userEmail\": \"Kullanıcı e-postası\",\n      \"postUrlLabel\": \"Gönderi URL'si (X veya LinkedIn)\",\n      \"postUrlPlaceholder\": \"Gönderinizin bağlantısını buraya yapıştırın\",\n      \"invalidUrl\": \"Lütfen geçerli bir X veya LinkedIn gönderi URL'si girin\",\n      \"claiming\": \"Talep ediliyor...\",\n      \"claimCredits\": \"Kredi talep et\",\n      \"congratulations\": \"Tebrikler!\",\n      \"claimSuccess\": \"500 kredi kazandınız!\",\n      \"verifying\": \"Gönderiniz doğrulanıyor...\",\n      \"verifyingDescription\": \"Gönderi içeriğinizi kontrol ediyoruz, bu genellikle birkaç saniye sürer\",\n      \"verifyFailed\": \"Doğrulama başarısız\",\n      \"tryAgain\": \"Tekrar dene\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Doğrulama başarısız\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Ödül\",\n    \"rewardCredits\": \"Ödül kredileri\",\n    \"minCharCount\": \"Gönderi en az {{count}} karakter içermelidir\",\n    \"minFollowerCount\": \"Hesapta en az {{count}} takipçi olmalıdır\",\n    \"mustMention\": \"Gönderi {{mention}}'dan bahsetmelidir\",\n    \"fetchSnapshotFailed\": \"Gönderi anlık görüntüsü alınamadı\",\n    \"alreadyClaimedThisWeek\": \"Bu hesap için bu hafta zaten bir ödül talep ettiniz\",\n    \"manage\": {\n      \"title\": \"Ödül Yönetimi\",\n      \"description\": \"Tüm alanlardaki ödül kayıtlarını görüntüle ve yönet\",\n      \"overview\": \"Genel Bakış\",\n      \"records\": \"Kayıtlar\",\n      \"searchSpace\": \"Alan ara...\",\n      \"searchRecords\": \"postUrl/userId ara...\",\n      \"dateRange\": \"Tarih aralığı seç\",\n      \"from\": \"Başlangıç\",\n      \"to\": \"Bitiş\",\n      \"totalSpaces\": \"Toplam: {{count}} alan\",\n      \"totalRecords\": \"Toplam: {{count}} kayıt\",\n      \"space\": \"Alan\",\n      \"allSpaces\": \"Tüm Alanlar\",\n      \"user\": \"Kullanıcı\",\n      \"creator\": \"Oluşturan\",\n      \"sourceType\": \"Kaynak Türü\",\n      \"platform\": \"Platform\",\n      \"allStatuses\": \"Tüm Durumlar\",\n      \"allSourceTypes\": \"Tüm Kaynak Türleri\",\n      \"allPlatforms\": \"Tüm Platformlar\",\n      \"pendingCount\": \"Bekleyen Sayısı\",\n      \"approvedCount\": \"Onaylanan Sayısı\",\n      \"rejectedCount\": \"Reddedilen Sayısı\",\n      \"approvedAmount\": \"Onaylanan Miktar\",\n      \"consumedAmount\": \"Tüketilen Miktar\",\n      \"availableAmount\": \"Kullanılabilir Miktar\",\n      \"expiringSoonAmount\": \"Yakında Sona Erecek (7g)\",\n      \"amount\": \"Miktar\",\n      \"remainingAmount\": \"Kalan Miktar\",\n      \"createdTime\": \"Oluşturma Zamanı\",\n      \"rewardTime\": \"Ödül Zamanı\",\n      \"expiredTime\": \"Sona Erme Zamanı\",\n      \"lastModified\": \"Son Değişiklik\",\n      \"viewDetails\": \"Detayları Görüntüle\",\n      \"details\": \"Ödül Detayları\",\n      \"basicInfo\": \"Temel Bilgi\",\n      \"amountInfo\": \"Miktar Bilgisi\",\n      \"timeInfo\": \"Zaman Bilgisi\",\n      \"socialInfo\": \"Sosyal Bilgi\",\n      \"verifyResult\": \"Doğrulama Sonucu\",\n      \"uniqueKey\": \"Benzersiz Anahtar\",\n      \"verify\": \"Doğrula\",\n      \"valid\": \"Geçerli\",\n      \"invalid\": \"Geçersiz\",\n      \"errors\": \"Hatalar\",\n      \"copied\": \"{{label}} kopyalandı\",\n      \"openPost\": \"Gönderiyi aç\",\n      \"noData\": \"Veri yok\",\n      \"page\": \"Sayfa {{current}} / {{total}}\",\n      \"status\": {\n        \"label\": \"Durum\",\n        \"pending\": \"Bekliyor\",\n        \"approved\": \"Onaylandı\",\n        \"rejected\": \"Reddedildi\"\n      }\n    }\n  },\n  \"clickToCopyTooltip\": \"Panoya kopyalamak için tıklayın\",\n  \"copiedTooltip\": \"Kopyalandı!\",\n  \"hiddenFieldCount_one\": \"{{count}} gizli alan\",\n  \"hiddenFieldCount_other\": \"{{count}} gizli alan\",\n  \"invalidFieldMapping\": \"Geçersiz alan eşlemesi\",\n  \"sourceFieldNotFoundMapping\": \"Kaynak alan bulunamadı\",\n  \"targetFieldNotFoundMapping\": \"Hedef alan bulunamadı\",\n  \"fieldTypeNotSupportedMapping\": \"Alan türü desteklenmiyor\",\n  \"fieldSettingsNotMatchMapping\": \"Alan ayarları eşleşmiyor\",\n  \"fieldSettingsLookupNotMatch\": \"Arama alan ayarları eşleşmiyor\",\n  \"fieldSettingsLinkTableNotMatch\": \"Bağlantı tablosu eşleşmiyor\",\n  \"fieldSettingsLinkViewNotMatch\": \"Bağlantı görünümü eşleşmiyor\",\n  \"fieldTypeDifferentMapping\": \"Alan türleri farklı\",\n  \"fieldMappingSourceTip\": \"Kaynak alanları hedef alanlarla eşleyin\",\n  \"fieldMappingTargetTip\": \"Alanları hedefle eşleştirme\",\n  \"reset\": \"Sıfırla\",\n  \"checkAll\": \"Tümünü seç\",\n  \"uncheckAll\": \"Seçimi kaldır\",\n  \"duplicateOptionsMapping\": \"Yinelenen seçenekler\",\n  \"lookupFieldInvalidMapping\": \"Arama alanı geçersiz\",\n  \"noMatchedOptions\": \"Eşleşen seçenek yok\",\n  \"needManualSelectionMapping\": \"Manuel seçim gerekli\",\n  \"targetFieldIsComputed\": \"Hedef alan hesaplanmış\",\n  \"targetFieldIsComputedTips\": \"Hedef alan hesaplanmış ve değiştirilemez\",\n  \"emptyOption\": \"(boş)\",\n  \"showEmptyTip\": \"Boş seçenekleri göster\",\n  \"hideEmptyTip\": \"Boş seçenekleri gizle\",\n  \"hideText\": \"Metni Gizle\",\n  \"showText\": \"Metni Göster\",\n  \"sourceTable\": \"Kaynak Tablo\",\n  \"sourceView\": \"Kaynak Görünüm\",\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Sayfa bulunamadı\",\n      \"description\": \"Takip ettiğiniz bağlantı bozuk olabilir veya sayfa taşınmış olabilir.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Ana sayfaya dön\"\n    },\n    \"forbidden\": {\n      \"title\": \"Erişim kısıtlı\",\n      \"description\": \"Bu kaynağa erişmek için izne ihtiyacınız var.\\nLütfen yöneticinizle iletişime geçin.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Premium özelliğin kilidini aç\",\n      \"description\": \"Bu özellik gelişmiş planlarda kullanılabilir.\\nYeteneklerinizi genişletmek için yükseltin.\"\n    },\n    \"error\": {\n      \"title\": \"Bir şeyler yanlış gitti\",\n      \"description\": \"Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Tarih ayrıştırma hatası, \\\"{{value}}\\\" değeri geçerli aralığın dışında\",\n      \"planRowLimit\": \"Satır sınırına ulaşıldı: daha fazla kayıt içe aktarmak için planınızı yükseltin\",\n      \"notNullValidation\": \"{{fieldHint}}Zorunlu alan(lar) boş olamaz\",\n      \"uniqueValidation\": \"{{fieldHint}}Benzersiz alan(lar)da yinelenen değerler\",\n      \"requestTimeout\": \"İstek zaman aşımına uğradı\",\n      \"chunkProcessingFailed\": \"Toplu işlem başarısız oldu: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"YENİ GÜNCELLEME\",\n    \"title\": \"Toplu Ek Dosya İndirme Kullanıma Sunuldu\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Henüz Gösterge Paneli Yok\",\n    \"description\": \"Henüz hiç gösterge paneli oluşturmamışsınız. Gösterge panelleri verilerinizi daha etkili bir şekilde görselleştirmenize ve yönetmenize yardımcı olur.\",\n    \"create\": \"İlk Gösterge Panelinizi Oluşturun\"\n  },\n  \"addPlugin\": \"Eklenti Ekle\",\n  \"createDashboard\": {\n    \"button\": \"Gösterge Paneli Oluştur\",\n    \"title\": \"Yeni Gösterge Paneli Oluştur\",\n    \"placeholder\": \"Gösterge paneli adını girin\"\n  },\n  \"findDashboard\": \"Gösterge paneli ara...\",\n  \"expand\": \"Genişlet\",\n  \"deprecation\": {\n    \"title\": \"Gösterge paneli düğümü özelliği sonlandırılacak\",\n    \"description\": \"Size daha akıllı ve verimli bir deneyim sunmak için gösterge paneli düğümü özelliğinin desteğini sonlandıracağız. AI ile oluşturulan uygulamada AI aracılığıyla kolayca yeni gösterge panelleri oluşturabilirsiniz.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"API sorgu oluşturucu\",\n  \"subTitle\": \"Etkileşimli bir arayüz aracılığıyla sorgu isteklerinizi hızlıca oluşturabilir ve doğrudan çalıştırılabilecek kodu kopyalayabilirsiniz\",\n  \"apiList\": \"Tüm API Listesi\",\n  \"cellFormat\": \"Hücre sonuç formatı\",\n  \"fieldKeyType\": \"Alan anahtar türü\",\n  \"chooseSource\": \"Bir veri kaynağı seçin\",\n  \"action\": {\n    \"selectBase\": \"Bir veritabanı seçin...\",\n    \"selectTable\": \"Bir tablo seçin...\"\n  },\n  \"pickParams\": \"Parametreleri seçin ve yapılandırın\",\n  \"buildResult\": \"Sonuç oluştur\",\n  \"buildResultEmpty\": \"Henüz sonuç yok\",\n  \"previewReturnValue\": \"Dönüş değeri önizleme\",\n  \"replaceToken\": \"Token değiştir\",\n  \"createNewToken\": \"Yeni token oluştur\",\n  \"showPagination\": \"Sayfalandırma parametreleri JSON modunda görüntülenir\",\n  \"addSort\": \"Sıralama ekle\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/oauth.json",
    "content": "{\n  \"add\": \"Yeni OAuth Uygulamaları\",\n  \"title\": {\n    \"add\": \"Yeni OAuth Uygulamaları\",\n    \"edit\": \"OAuth Uygulamalarını Düzenle\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"OAuth Uygulama adı\",\n      \"description\": \"OAuth Uygulamanızın adı.\"\n    },\n    \"description\": {\n      \"label\": \"Açıklama\",\n      \"description\": \"OAuth Uygulamanızın kısa bir açıklaması.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"Ana Sayfa URL'si\",\n      \"description\": \"OAuth Uygulamanızın web sitesinin tam URL'si.\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"Kare bir görsel önerilir.\",\n      \"placeholder\": \"Logonuzu buraya sürükleyip bırakın veya yüklemek için tıklayın\",\n      \"button\": \"Logo yükle\",\n      \"clear\": \"Temizle\",\n      \"lengthError\": \"Sadece bir dosyaya izin verilir.\",\n      \"typeError\": \"Sadece görsel dosyasına izin verilir.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"Geri Dönüş URL'si\",\n      \"description\": \"Bir kullanıcı entegrasyonunuzu yetkilendirdikten sonra yönlendirilecek tam URL.\",\n      \"add\": \"Geri Dönüş URL'si Ekle\"\n    },\n    \"scopes\": {\n      \"label\": \"İzinler\",\n      \"description\": \"OAuth Uygulamanızın ihtiyaç duyduğu izinler.\"\n    },\n    \"secret\": {\n      \"label\": \"İstemci gizli anahtarları\",\n      \"add\": \"Yeni bir istemci gizli anahtarı oluştur\",\n      \"newDescription\": \"Yeni istemci gizli anahtarınızı şimdi kopyaladığınızdan emin olun. Bir daha göremeyeceksiniz.\",\n      \"empty\": \"API'ye uygulama olarak kimlik doğrulaması yapmak için bir istemci gizli anahtarına ihtiyacınız var.\",\n      \"lastUsed\": \"Son kullanım: {{date}}\",\n      \"tag\": \"İstemci gizli anahtarı\",\n      \"neverUsed\": \"Hiç kullanılmadı\"\n    },\n    \"clientId\": {\n      \"label\": \"İstemci Kimliği: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Temel bilgiler\",\n    \"scopes\": \"İzinler\",\n    \"identify\": \"Kullanıcıları tanımlama ve yetkilendirme\",\n    \"clientInfo\": \"İstemci bilgileri\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} hesabınıza erişim talep ediyor\",\n    \"scopes\": \"Bu uygulama aşağıdaki izinlere erişebilecek:\",\n    \"redirectDescription\": \"Yetkilendirme şuraya yönlendirecek\",\n    \"authorize\": \"Yetkilendir\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/plugin.json",
    "content": "{\n  \"add\": \"Yeni Eklentiler\",\n  \"title\": {\n    \"add\": \"Yeni Eklentiler\",\n    \"edit\": \"Eklentileri Düzenle\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Eklenti Kullanıcısı\",\n    \"description\": \"Sistem tarafından otomatik oluşturulan bir eklenti olarak Eklenti Kullanıcısı\"\n  },\n  \"secret\": \"Gizli Anahtar\",\n  \"regenerateSecret\": \"Gizli Anahtarı Yeniden Oluştur\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"İsim\",\n      \"description\": \"eklentinin adı\"\n    },\n    \"description\": {\n      \"label\": \"Açıklama\",\n      \"description\": \"eklentinin açıklaması\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Detaylı Açıklama\",\n      \"description\": \"eklentinin detaylı açıklaması\"\n    },\n    \"logo\": {\n      \"label\": \"Logo\",\n      \"description\": \"eklentinin logosu, bir resim yükleyebilir veya URL kullanabilirsiniz\",\n      \"upload\": \"Yükle\",\n      \"clear\": \"Temizle\",\n      \"placeholder\": \"Logonuzu buraya sürükleyip bırakın veya yüklemek için tıklayın\",\n      \"lengthError\": \"Sadece bir dosyaya izin verilir.\",\n      \"typeError\": \"Sadece görsel dosyasına izin verilir.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"Yardım URL'si\",\n      \"description\": \"Eklentinin yardım belgesinin URL'si\"\n    },\n    \"positions\": {\n      \"label\": \"Konumlar\",\n      \"description\": \"eklentinin konumları\"\n    },\n    \"i18n\": {\n      \"label\": \"Yerelleştirme\",\n      \"description\": \"eklentinin yerelleştirmesi, içerir(isim, açıklama, detaylı açıklama)\"\n    },\n    \"url\": {\n      \"label\": \"URL\",\n      \"description\": \"Eklentinin URL'si\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Yaz\",\n    \"preview\": \"Önizleme\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Çok Yakında\",\n    \"empty\": \"Boş\",\n    \"noRecords\": \"Kayıt bulunamadı\",\n    \"unnamedRecord\": \"İsimsiz kayıt\",\n    \"untitled\": \"Başlıksız\",\n    \"cancel\": \"İptal\",\n    \"confirm\": \"Onayla\",\n    \"back\": \"Geri\",\n    \"done\": \"Tamam\",\n    \"create\": \"Oluştur\",\n    \"search\": {\n      \"placeholder\": \"Ara...\",\n      \"empty\": \"Sonuç bulunamadı\"\n    },\n    \"readOnlyTip\": \"Bu görünüm kilitli. Görünüm seçeneklerini düzenlemek için <button>kişisel modu</button> etkinleştirebilirsiniz, ve değişiklikler sadece size etkili olacaktır.\",\n    \"selectPlaceHolder\": \"Seç...\",\n    \"loading\": \"Yükleniyor...\",\n    \"loadMore\": \"Daha Fazla Yükle\",\n    \"uploadFailed\": \"Yükleme başarısız\",\n    \"rowCount\": \"{{count}} kayıt\",\n    \"summary\": \"Özet\",\n    \"summaryTip\": \"Özet seçmek için fareyle üzerine gelin\",\n    \"actions\": \"İşlemler\",\n    \"remove\": \"Sil\",\n    \"runStatus\": {\n      \"success\": \"{{name}} başarılı\",\n      \"failed\": \"{{name}} başarısız\",\n      \"running\": \"{{name}} çalışıyor\"\n    },\n    \"resetSuccess\": \"Sıfırlama başarılı\",\n    \"click\": \"Tıkla\",\n    \"clickedCount\": \"{{label}}: Tıklanma sayısı {{text}}\",\n    \"atLeastOne\": \"En az bir {{noun}} bulunmalıdır\"\n  },\n  \"notification\": {\n    \"title\": \"Bildirim\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Önizleme dosya boyutu sınırı: {{size}}MB, lütfen görüntülemek için indirin.\",\n    \"loadFileError\": \"Dosya yüklenemedi\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Geri Al\",\n    \"redo\": \"Yinele\",\n    \"undoFailed\": \"Geri alma başarısız\",\n    \"redoFailed\": \"Yineleme başarısız\",\n    \"nothingToUndo\": \"Geri alınacak bir şey yok\",\n    \"nothingToRedo\": \"Yinelenecek bir şey yok\",\n    \"undoSucceed\": \"Geri alma başarılı\",\n    \"redoSucceed\": \"Yineleme başarılı\",\n    \"undoing\": \"geri alınıyor...\",\n    \"redoing\": \"yineleniyor...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Yüklemek için bırakın\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Yapıştırın veya buraya sürükleyip bırakın\",\n      \"upload\": \"yükle\"\n    },\n    \"date\": {\n      \"placeholder\": \"Bir tarih seçin\",\n      \"today\": \"Bugün\"\n    },\n    \"formula\": {\n      \"title\": \"Formül Düzenleyici\",\n      \"guideSyntax\": \"Sözdizimi\",\n      \"guideExample\": \"Örnek\",\n      \"helperExample\": \"Örnek: \",\n      \"fieldValue\": \"{{fieldName}} alanındaki hücrelerin değerini döndürür.\",\n      \"placeholder\": \"Bir ifade girin\",\n      \"placeholderForAIPrompt\": \"Oluşturmak istediğiniz formülü açıklayın\",\n      \"editExpression\": \"Formülü düzenle\",\n      \"generateExpressionByAI\": \"AI ile formül oluştur\",\n      \"inputPrompt\": \"Giriş komutu\",\n      \"generateExpression\": \"Oluşturulan formül\",\n      \"generatingByAI\": \"AI ile formül oluşturuluyor...\",\n      \"generatedExpressionTips\": \"Oluşturulan formülü uygulamak için Uygula düğmesine tıklayın\",\n      \"action\": {\n        \"generating\": \"Oluşturuluyor...\",\n        \"generate\": \"Oluştur\",\n        \"apply\": \"Uygula\"\n      },\n      \"expressionRequired\": \"İfade gereklidir\"\n    },\n    \"link\": {\n      \"placeholder\": \"Bağlanacak kayıtları seçin\",\n      \"searchPlaceholder\": \"Kayıtlarda ara\",\n      \"allFields\": \"Tüm alanlar\",\n      \"globalSearch\": \"Global arama\",\n      \"fieldSearch\": \"Alan araması\",\n      \"maxFieldTips\": \"Maksimum {{count}} alan sayısı aşıldı, fazla alanlar göz ardı edilecek\",\n      \"create\": \"Kayıt Ekle\",\n      \"selectRecord\": \"Kayıt Seç\",\n      \"all\": \"Tümü\",\n      \"selected\": \"Seçilmiş\",\n      \"expandRecordError\": \"Bu kaydı görüntüleme izniniz yok.\",\n      \"alreadyOpen\": \"Bu kayıt zaten açık.\",\n      \"linkedTo\": \"Bağlantılı\",\n      \"goToForeignTable\": \"Yabancı tabloya git\",\n      \"foreignTableIdRequired\": \"Yabancı tablo gereklidir\",\n      \"linkFieldIdRequired\": \"Bağlantı alanı gereklidir\",\n      \"selectTooManyRecords\": \"Seçilen kayıtlar {{maxCount}} değerini aşmamalıdır\",\n      \"relationshipRequired\": \"İlişki gereklidir\",\n      \"rangeSelectFailed\": \"Aralıktaki kayıtları seçme başarısız oldu\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"İsme göre kullanıcı ara\",\n      \"notify\": \"Seçildiklerinde kullanıcıları bilgilendir\"\n    },\n    \"select\": {\n      \"addOption\": \"'{{option}}' seçeneğini ekle\",\n      \"choicesNameRequired\": \"Seçenek adı boş olamaz\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Arama alanı gereklidir\",\n      \"lookupOptionsNotAllowed\": \"isLookup özniteliği doğru olduğunda veya alan türü rollup olduğunda arama seçenekleri izin verilmez.\",\n      \"lookupOptionsRequired\": \"Arama seçenekleri gereklidir\",\n      \"refineOptionsError\": \"Arama seçenekleri ayrıştırma hatası {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"İfade gereklidir\",\n      \"unsupportedTip\": \"Rollup yalnızca bağlantı ve rollup alanlarını destekler\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Filtre en az bir koşul içermelidir\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"Koşullu arama için en az bir filtre koşulu gereklidir\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Model gereklidir\",\n      \"typeNotSupported\": \"Desteklenmeyen AI türü\",\n      \"sourceFieldIdRequired\": \"Kaynak alan gereklidir\",\n      \"targetLanguageRequired\": \"Hedef dil gereklidir\",\n      \"promptRequired\": \"İstem gereklidir\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Alan seçenekleri ayrıştırma hatası {{message}}\",\n      \"optionsRequired\": \"Alan seçenekleri gereklidir\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Filtre\",\n    \"displayLabel\": \"Filtrele: \",\n    \"displayLabel_other\": \"{{fieldName}} ve {{count}} diğer alan ile filtrele\",\n    \"addCondition\": \"Koşul ekle\",\n    \"addConditionGroup\": \"Koşul grubu ekle\",\n    \"nestedLimitTip\": \"Filtre koşulları yalnızca {{depth}} seviye derinliğinde iç içe geçebilir\",\n    \"linkInputPlaceholder\": \"Bir değer girin\",\n    \"groupDescription\": \"Aşağıdakilerden herhangi biri doğru...\",\n    \"currentUser\": \"Ben (mevcut kullanıcı)\",\n    \"tips\": {\n      \"scope\": \"Bu görünümde, kayıtları göster\"\n    },\n    \"invalidateSelected\": \"Geçersiz değer\",\n    \"invalidateSelectedTips\": \"Seçilen değer silindi, lütfen tekrar seçin\",\n    \"default\": {\n      \"empty\": \"Filtre koşulu uygulanmadı\",\n      \"placeholder\": \"Bir değer girin\"\n    },\n    \"conjunction\": {\n      \"and\": \"ve\",\n      \"or\": \"veya\",\n      \"where\": \"şu durumda\",\n      \"meetingAll\": \"Tüm koşulları karşıla\",\n      \"meetingAny\": \"Herhangi bir koşulu karşıla\"\n    },\n    \"operator\": {\n      \"is\": \"şudur\",\n      \"isNot\": \"şu değildir\",\n      \"contains\": \"içerir\",\n      \"doesNotContain\": \"içermez\",\n      \"isEmpty\": \"boştur\",\n      \"isNotEmpty\": \"boş değildir\",\n      \"isGreater\": \"büyüktür\",\n      \"isGreaterEqual\": \"büyük eşittir\",\n      \"isLess\": \"küçüktür\",\n      \"isLessEqual\": \"küçük eşittir\",\n      \"isAnyOf\": \"şunlardan biridir\",\n      \"isNoneOf\": \"şunlardan hiçbiri değildir\",\n      \"hasAnyOf\": \"şunlardan herhangi birine sahiptir\",\n      \"hasAllOf\": \"şunların tümüne sahiptir\",\n      \"hasNoneOf\": \"şunların hiçbirine sahip değildir\",\n      \"isExactly\": \"tam olarak şudur\",\n      \"isWithIn\": \"şunun içindedir\",\n      \"isBefore\": \"şundan öncedir\",\n      \"isAfter\": \"şundan sonradır\",\n      \"isOnOrBefore\": \"şu tarihte veya öncesindedir\",\n      \"isOnOrAfter\": \"şu tarihte veya sonrasındadır\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"bugün\",\n        \"tomorrow\": \"yarın\",\n        \"yesterday\": \"dün\",\n        \"oneWeekAgo\": \"bir hafta önce\",\n        \"oneWeekFromNow\": \"bir hafta sonra\",\n        \"oneMonthAgo\": \"bir ay önce\",\n        \"oneMonthFromNow\": \"bir ay sonra\",\n        \"daysAgo\": \"gün önce\",\n        \"daysFromNow\": \"gün sonra\",\n        \"exactDate\": \"tam tarih\",\n        \"exactFormatDate\": \"tam tarih (biçimlendirildi)\",\n        \"currentWeek\": \"current week\",\n        \"currentMonth\": \"current month\",\n        \"currentYear\": \"current year\",\n        \"lastWeek\": \"last week\",\n        \"lastMonth\": \"last month\",\n        \"lastYear\": \"last year\",\n        \"nextWeekPeriod\": \"next week\",\n        \"nextMonthPeriod\": \"next month\",\n        \"nextYearPeriod\": \"next year\",\n        \"pastWeek\": \"geçen hafta\",\n        \"pastMonth\": \"geçen ay\",\n        \"pastYear\": \"geçen yıl\",\n        \"nextWeek\": \"gelecek hafta\",\n        \"nextMonth\": \"gelecek ay\",\n        \"nextYear\": \"gelecek yıl\",\n        \"pastNumberOfDays\": \"geçmiş gün sayısı\",\n        \"nextNumberOfDays\": \"gelecek gün sayısı\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"renk\"\n  },\n  \"rowHeight\": {\n    \"short\": \"kısa\",\n    \"medium\": \"orta\",\n    \"tall\": \"uzun\",\n    \"extraTall\": \"çok uzun\",\n    \"title\": \"Satır yüksekliği\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"Alan adı\",\n    \"displayLines\": \"{{count}} satır\"\n  },\n  \"share\": {\n    \"title\": \"paylaş\"\n  },\n  \"extensions\": {\n    \"title\": \"uzantılar\"\n  },\n  \"hidden\": {\n    \"label\": \"Gizli Alanlar\",\n    \"configLabel_one\": \"{{count}} gizli alan\",\n    \"configLabel_other\": \"{{count}} gizli alan\",\n    \"configLabel_other_visible\": \"{{count}} görünür alan\",\n    \"showAll\": \"Tümünü Göster\",\n    \"hideAll\": \"Tümünü Gizle\",\n    \"primaryKey\": \"Birincil alan: Kayıtları tanımlar\\ngizlenemez veya silinemez, bağlantılı kayıtlarda görünür.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Panoya kopyala\",\n    \"duplicateRecord\": \"Kaydı çoğalt\",\n    \"copyRecordUrl\": \"Kayıt URL'sini kopyala\",\n    \"deleteRecord\": \"Kaydı sil\",\n    \"addRecordComment\": \"Yorum ekle\",\n    \"viewRecordHistory\": \"Kayıt geçmişini görüntüle\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Kayıt geçmişini gizle\",\n      \"showRecordHistory\": \"Kayıt geçmişini göster\",\n      \"createdTime\": \"Oluşturulma zamanı\",\n      \"createdBy\": \"Oluşturan\",\n      \"before\": \"Önce\",\n      \"after\": \"Sonra\",\n      \"viewRecord\": \"Kaydı görüntüle\"\n    },\n    \"showHiddenFields\": \"{{count}} gizli alanı göster\",\n    \"hideHiddenFields\": \"{{count}} gizli alanı gizle\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Sırala\",\n    \"displayLabel_one\": \"{{count}} alana göre sırala\",\n    \"displayLabel_other\": \"{{count}} alana göre sırala\",\n    \"setTips\": \"Sıralama kriteri\",\n    \"addButton\": \"Başka bir sıralama ekle\",\n    \"autoSort\": \"Kayıtları otomatik sırala\",\n    \"selectASCLabel\": \"ilk → son\",\n    \"selectDESCLabel\": \"son → ilk\"\n  },\n  \"group\": {\n    \"label\": \"Grupla\",\n    \"displayLabel_one\": \"{{count}} alana göre grupla\",\n    \"displayLabel_other\": \"{{count}} alana göre grupla\",\n    \"setTips\": \"Gruplama kriteri\",\n    \"addButton\": \"Alt Grup Ekle\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Tek satır metin\",\n      \"longText\": \"Uzun metin\",\n      \"singleSelect\": \"Tek seçim\",\n      \"number\": \"Sayı\",\n      \"multipleSelect\": \"Çoklu seçim\",\n      \"link\": \"Başka bir kayda bağlantı\",\n      \"formula\": \"Formül\",\n      \"date\": \"Tarih\",\n      \"createdTime\": \"Oluşturulma zamanı\",\n      \"lastModifiedTime\": \"Son değiştirilme zamanı\",\n      \"attachment\": \"Ek\",\n      \"checkbox\": \"Onay kutusu\",\n      \"rollup\": \"Toplama\",\n      \"conditionalRollup\": \"Koşullu toplama\",\n      \"user\": \"Kullanıcı\",\n      \"rating\": \"Değerlendirme\",\n      \"autoNumber\": \"Otomatik sayı\",\n      \"lookup\": \"Arama\",\n      \"conditionalLookup\": \"Koşullu arama\",\n      \"button\": \"Düğme\",\n      \"createdBy\": \"Oluşturan\",\n      \"lastModifiedBy\": \"Son değiştiren\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Ad veya başlık gibi kısa metinleri saklar.\",\n      \"longText\": \"Daha uzun notları ve açıklamaları kaydeder.\",\n      \"singleSelect\": \"Listeden tek bir seçenek seçer.\",\n      \"number\": \"Biçimli sayısal değerleri takip eder.\",\n      \"multipleSelect\": \"Kayıtları birden fazla seçenekle etiketler.\",\n      \"link\": \"Bu kaydı başka bir tabloya bağlar.\",\n      \"formula\": \"Diğer alanlara dayanarak değerler hesaplar.\",\n      \"date\": \"Tarih veya saatleri kaydeder.\",\n      \"createdTime\": \"Kaydın ne zaman oluşturulduğunu gösterir.\",\n      \"lastModifiedTime\": \"Son güncellenme zamanını gösterir.\",\n      \"attachment\": \"Dosya yükleyebilir veya yapay zeka ile görsel oluşturabilir, 🍌 Nano banana pro gibi modelleri destekler\",\n      \"checkbox\": \"Basit bir evet/hayır anahtarını değiştirir.\",\n      \"rollup\": \"Bağlı kayıtları formüllerle özetler.\",\n      \"conditionalRollup\": \"Koşullara göre verileri özetler.\",\n      \"user\": \"Kayıtları çalışma alanı üyelerine atar.\",\n      \"rating\": \"Ögeleri ayarlanabilir simgelerle puanlar.\",\n      \"autoNumber\": \"Her kayda benzersiz bir sıra numarası verir.\",\n      \"lookup\": \"Bağlı kayıtlardan değerleri gösterir.\",\n      \"conditionalLookup\": \"Tanımladığınız filtrelere uyan bağlı değerleri gösterir.\",\n      \"button\": \"Tıklanabilir bir düğmeyle eylemler çalıştırır.\",\n      \"createdBy\": \"Kaydı kimin oluşturduğunu gösterir.\",\n      \"lastModifiedBy\": \"Kaydı en son kimin değiştirdiğini gösterir.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Tek yönlü\",\n      \"twoWay\": \"Çift yönlü\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Eylem onayı\",\n        \"description\": \"Bu eylemi gerçekleştirmek istediğinize emin misiniz?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Alan oluştur\",\n      \"spaceDelete\": \"Alan sil\",\n      \"spaceRead\": \"Alan oku\",\n      \"spaceUpdate\": \"Alan güncelle\",\n      \"spaceInviteEmail\": \"Alanda e-posta ile davet et\",\n      \"spaceInviteLink\": \"Alanda bağlantı ile davet et\",\n      \"spaceGrantRole\": \"Alanda rol ata\",\n      \"baseCreate\": \"Veritabanı oluştur\",\n      \"baseDelete\": \"Veritabanı sil\",\n      \"baseRead\": \"Veritabanı oku\",\n      \"baseReadAll\": \"Read all bases\",\n      \"baseUpdate\": \"Veritabanı güncelle\",\n      \"baseInviteEmail\": \"Veritabanında e-posta ile davet et\",\n      \"baseInviteLink\": \"Veritabanında bağlantı ile davet et\",\n      \"baseTableImport\": \"Veritabanına veri aktar\",\n      \"baseAuthorityMatrixConfig\": \"Yetki matrisini yapılandır\",\n      \"baseDbConnect\": \"Veritabanına bağlan\",\n      \"tableCreate\": \"Tablo oluştur\",\n      \"tableRead\": \"Tablo oku\",\n      \"tableDelete\": \"Tablo sil\",\n      \"tableUpdate\": \"Tablo güncelle\",\n      \"tableImport\": \"Tabloya veri aktar\",\n      \"tableExport\": \"Tablodan veri dışa aktar\",\n      \"tableTrashRead\": \"Read table trash\",\n      \"tableTrashUpdate\": \"Update table trash\",\n      \"tableTrashReset\": \"Reset table trash\",\n      \"viewCreate\": \"Görünüm oluştur\",\n      \"viewDelete\": \"Görünüm sil\",\n      \"viewRead\": \"Görünüm oku\",\n      \"viewUpdate\": \"Görünüm güncelle\",\n      \"viewShare\": \"Görünüm paylaş\",\n      \"fieldCreate\": \"Alan oluştur\",\n      \"fieldDelete\": \"Alan sil\",\n      \"fieldRead\": \"Alan oku\",\n      \"fieldUpdate\": \"Alan güncelle\",\n      \"recordCreate\": \"Kayıt oluştur\",\n      \"recordComment\": \"Kayıt yorumla\",\n      \"recordDelete\": \"Kayıt sil\",\n      \"recordRead\": \"Kayıt oku\",\n      \"recordUpdate\": \"Kayıt güncelle\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Otomasyon oluştur\",\n      \"automationDelete\": \"Otomasyon sil\",\n      \"automationRead\": \"Otomasyon oku\",\n      \"automationUpdate\": \"Otomasyon güncelle\",\n      \"appCreate\": \"Uygulama oluştur\",\n      \"appDelete\": \"Uygulama sil\",\n      \"appRead\": \"Uygulama oku\",\n      \"appUpdate\": \"Uygulama güncelle\",\n      \"userProfileRead\": \"Read current user profile\",\n      \"userEmailRead\": \"Kullanıcı e-postası oku\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Kayıt geçmişi oku\",\n      \"baseQuery\": \"Veritabanı sorgula\",\n      \"instanceRead\": \"Örnek oku\",\n      \"instanceUpdate\": \"Örnek güncelle\",\n      \"enterpriseRead\": \"Read enterprise configuration\",\n      \"enterpriseUpdate\": \"Update enterprise configuration\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Tablo\",\n    \"view\": \"Görünüm\",\n    \"space\": \"Alan\",\n    \"base\": \"Veritabanı\",\n    \"field\": \"Alan\",\n    \"record\": \"Kayıt\",\n    \"automation\": \"Otomasyon\",\n    \"app\": \"Uygulama\",\n    \"user\": \"Kullanıcı\",\n    \"recordHistory\": \"Kayıt geçmişi\",\n    \"you\": \"Sen\",\n    \"instance\": \"Örnek\",\n    \"enterprise\": \"Kurumsal\",\n    \"history\": \"Geçmiş\",\n    \"global\": \"Global\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Sayıları toplar. number1 + number2 + ... ile eşdeğerdir.\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Sayıların ortalamasını döndürür.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Verilen sayıların en büyüğünü döndürür.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Verilen sayıların en küçüğünü döndürür.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Değeri, \\\"precision\\\" ile belirtilen ondalık basamak sayısına yuvarlar (Özellikle, ROUND belirtilen hassasiyette en yakın tam sayıya yuvarlar, bağlar pozitif sonsuzluğa doğru yukarı yuvarlanarak çözülür.)\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Değeri, \\\"precision\\\" ile belirtilen ondalık basamak sayısına her zaman yukarı yuvarlayarak, yani sıfırdan uzaklaşarak yuvarlar. (Fonksiyonun çalışması için hassasiyet değeri vermelisiniz.)\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Değeri, \\\"precision\\\" ile belirtilen ondalık basamak sayısına her zaman aşağı yuvarlayarak, yani sıfıra doğru yuvarlar. (Fonksiyonun çalışması için hassasiyet değeri vermelisiniz.)\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Değerden büyük veya eşit olan en yakın significance katını döndürür. Eğer significance belirtilmezse, 1 varsayılır.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Değerden küçük veya eşit olan en yakın significance katını döndürür. Eğer significance belirtilmezse, 1 varsayılır.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Belirtilen değerden büyük veya eşit olan en küçük çift tam sayıyı döndürür.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Pozitif değeri yukarı, negatif değeri aşağı en yakın tek sayıya yuvarlar.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Bir sayıyı en yakın tam sayıya aşağı doğru yuvarlar.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Mutlak değeri döndürür.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Negatif olmayan bir sayının karekökünü döndürür.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Belirtilen tabanı belirtilen üsse yükseltir.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Euler sayısını (e) belirtilen üsse yükseltir.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Değerin belirtilen tabandaki logaritmasını hesaplar. Taban belirtilmezse 10 varsayılır.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"İlk argümanın ikinciye bölümünden kalanı döndürür.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Metin dizesini sayıya dönüştürür.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Çeşitli değer türlerindeki argümanları tek bir metin değerinde birleştirir.\",\n      \"example\": \"CONCATENATE(\\\"Merhaba \\\", \\\"Teable\\\") => Merhaba Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"whereToSearch dizesinde stringToFind'ın bir oluşumunu isteğe bağlı startFromPosition'dan başlayarak arar.(startFromPosition varsayılan olarak 0'dır.) Eğer stringToFind bulunamazsa, sonuç 0 olacaktır.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Merhaba Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Merhaba Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Merhaba Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"whereToSearch dizesinde stringToFind'ın bir oluşumunu isteğe bağlı startFromPosition'dan başlayarak arar. (startFromPosition varsayılan olarak 0'dır.) Eğer stringToFind bulunamazsa, sonuç boş olacaktır.\\nFIND()'a benzer, ancak FIND() stringToFind bulunamazsa 0 döndürür.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Merhaba Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Merhaba Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Merhaba Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"whereToStart'tan başlayarak count karakter uzunluğunda bir alt dize çıkarır.\",\n      \"example\": \"MID(\\\"Merhaba Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Dizenin başından howMany karakter çıkarır.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Dizenin sonundan howMany karakter çıkarır.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Başlangıç karakterinden itibaren belirtilen sayıda karakteri replacement metni ile değiştirir.\\n(Eğer old_text'in tüm oluşumlarını new_text ile değiştirmek için bir yol arıyorsanız, SUBSTITUTE()'e bakın.)\",\n      \"example\": \"REPLACE(\\\"Merhaba Table\\\", 7, 5, \\\"Teable\\\") => \\\"Merhaba Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Düzenli ifadeyle eşleşen tüm alt dizeleri replacement ile değiştirir.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Merhaba Table\\\", \\\"M.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"old_text'in oluşumlarını new_text ile değiştirir.\\nİsteğe bağlı olarak old_text'in belirli bir oluşumunu (1'den başlayarak) değiştirmek için bir dizin numarası belirtebilirsiniz. Dizin numarası belirtilmezse, old_text'in tüm oluşumları değiştirilir.\",\n      \"example\": \"SUBSTITUTE(\\\"Merhaba Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Merhaba Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Bir dizeyi küçük harfe dönüştürür.\",\n      \"example\": \"LOWER(\\\"Merhaba Teable\\\") => \\\"merhaba teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Bir dizeyi büyük harfe dönüştürür.\",\n      \"example\": \"UPPER(\\\"Merhaba Teable\\\") => \\\"MERHABA TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Dizeyi belirtilen sayıda tekrarlar.\",\n      \"example\": \"REPT(\\\"Merhaba!\\\") => \\\"Merhaba!Merhaba!Merhaba!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Dizenin başındaki ve sonundaki boşlukları kaldırır.\",\n      \"example\": \"TRIM(\\\" Merhaba \\\") => \\\"Merhaba\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Dizenin başından howMany karakter çıkarır.\",\n      \"example\": \"LEN(\\\"Merhaba\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Argüman metin ise onu döndürür, değilse boş döndürür.\",\n      \"example\": \"T(\\\"Merhaba\\\") => \\\"Merhaba\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"URL veya URI oluşturmak için kullanılmak üzere belirli karakterleri kodlanmış eşdeğerleriyle değiştirir. Şu karakterleri kodlamaz: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Merhaba Teable\\\") => \\\"Merhaba%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Mantıksal argüman doğruysa value1'i, değilse value2'yi döndürür. İç içe IF ifadeleri yapmak için de kullanılabilir.\\nAyrıca bir hücrenin boş olup olmadığını kontrol etmek için de kullanılabilir.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Bir ifade, o ifade için olası değerler listesi ve her biri için ifadenin o durumda alması gereken bir değer alır. Ayrıca, ifade girişi tanımlanan kalıplardan hiçbiriyle eşleşmezse bir varsayılan değer de alabilir. Birçok durumda, SWITCH() iç içe IF() formülü yerine kullanılabilir.\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Değer A\\\", \\\"B\\\", \\\"Değer B\\\", \\\"Varsayılan Değer\\\") => \\\"Değer B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Tüm argümanlar doğruysa true, değilse false döndürür.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Argümanlardan herhangi biri doğruysa true döndürür.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Tek sayıda argüman doğruysa true döndürür.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Argümanının mantıksal değerini tersine çevirir.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Boş bir değer döndürür.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Evet\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Hata değerini döndürür.\",\n      \"example\": \"IF(2 > 3, \\\"Evet\\\", ERROR(\\\"Hesaplama\\\")) => \\\"#ERROR: Hesaplama\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"İfade bir hataya neden oluyorsa true döndürür.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Geçerli tarihi döndürür.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Geçerli tarih ve saati döndürür.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Bir tarih/saatin dört haneli yılını döndürür.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Bir tarih/saatin ayını 1 (Ocak) ile 12 (Aralık) arasında bir sayı olarak döndürür.\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Yıldaki hafta numarasını döndürür.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Haftanın gününü 0 ile 6 arasında bir tam sayı olarak döndürür. İsteğe bağlı olarak ikinci bir argüman (\\\"Sunday\\\" veya \\\"Monday\\\") sağlayarak haftaların o günden başlamasını sağlayabilirsiniz. Belirtilmezse, haftalar varsayılan olarak Pazar gününden başlar.\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Bir tarih/saatin gününü 1-31 arasında bir sayı olarak döndürür.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Bir tarih/saatin saatini 0 (12:00am) ile 23 (11:00pm) arasında bir sayı olarak döndürür.\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Bir tarih/saatin dakikasını 0 ile 59 arasında bir tam sayı olarak döndürür.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Bir tarih/saatin saniyesini 0 ile 59 arasında bir tam sayı olarak döndürür.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Geçerli tarih ile başka bir tarih arasındaki gün sayısını hesaplar.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Geçerli tarih ile başka bir tarih arasındaki gün sayısını hesaplar.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Tarih/saatler arasındaki farkı belirtilen birimde döndürür. Varsayılan birim \\\"day\\\"dir.\\nDesteklenen birimler: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nTarih/saatler arasındaki fark [date2]'nin [date1]'den çıkarılmasıyla belirlenir. Bu, eğer [date2] [date1]'den sonraysa, sonuç değerinin negatif olacağı anlamına gelir.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Başlangıç tarihinden itibaren belirtilen tatiller hariç iş gününü döndürür\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"date1 ve date2 arasındaki iş günü sayısını döndürür. İş günleri hafta sonlarını ve isteğe bağlı olarak ISO formatında virgülle ayrılmış tarihler listesi olarak biçimlendirilmiş tatil listesini hariç tutar.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"İki tarihi bir birime kadar karşılaştırır ve aynı olup olmadıklarını belirler. Evetse true, hayırsa false döndürür.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"date1'in date2'den sonra olup olmadığını belirler. Evetse true, hayırsa false döndürür.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"date1'in date2'den önce olup olmadığını belirler. Evetse true, hayırsa false döndürür.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Bir tarih/saate belirtilen \\\"count\\\" birimlerini ekler.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Bir tarih/saati dizeye biçimlendirir (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Bir tarih/saati yalnızca saat dizesine biçimlendirir (HH:mm:ss).\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Bir tarih/saati belirtilen dizeye biçimlendirir. Bu fonksiyonu tarih alanlarıyla nasıl kullanacağınıza dair bir açıklama için buraya tıklayın. Desteklenen biçim belirteçlerinin listesi için lütfen buraya tıklayın.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Bir metin dizesini isteğe bağlı giriş biçimi ve yerel ayar parametreleriyle yapılandırılmış bir tarih olarak yorumlar. Çıktı biçimi her zaman \\\"M/D/YYYY h:mm a\\\" olarak biçimlendirilecektir.\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Eyl 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Mevcut kaydın oluşturulma zamanını döndürür.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Tablodaki hesaplanmayan bir alanda bir kullanıcı tarafından yapılan en son değişikliğin tarih ve saatini döndürür.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Metin ve boşluklar dahil tüm öğelerin sayısını döndürür.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Boş olmayan değerlerin sayısını döndürür. Bu fonksiyon hem sayısal hem de metin değerlerini sayar.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Sayısal öğelerin sayısını döndürür.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Toplama öğelerinin dizisini bir ayırıcı ile dizeye birleştirir.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Dizideki yalnızca benzersiz öğeleri döndürür.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Dizi iç içe geçmesini kaldırarak diziyi düzleştirir. Tüm öğeler tek bir dizinin elemanları haline gelir.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Diziden boş dizeleri ve null değerleri kaldırır. \\\"false\\\" ve bir veya daha fazla boşluk karakteri içeren dizeleri korur.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Tüm metin değerlerini döndürür\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Mevcut kaydın kimliğini döndürür.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Her kayıt için benzersiz ve artan sayılar döndürür.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Hesaplamalar için formüller oluşturmak üzere değişkenler, işlem karakterleri ve fonksiyonlar doldurun.\",\n      \"example\": \"Sütun Alıntılama: {Alan adı}\\n\\nOperatör kullanma: 100 * 2 + 300\\n\\nFonksiyon kullanma: SUM({Sayı Alanı 1}, 100)\\n\\nIF ifadesi kullanma: \\nIF(mantıksal koşul, \\\"değer 1\\\", \\\"değer 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Alanlar\",\n    \"numeric\": \"Sayısal\",\n    \"text\": \"Metin\",\n    \"logical\": \"Mantıksal\",\n    \"date\": \"Tarih\",\n    \"array\": \"Dizi\",\n    \"system\": \"Sistem\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Yok\",\n    \"count\": \"Sayı\",\n    \"empty\": \"Boş\",\n    \"filled\": \"Dolu\",\n    \"unique\": \"Benzersiz\",\n    \"max\": \"Maksimum\",\n    \"min\": \"Minimum\",\n    \"sum\": \"Toplam\",\n    \"average\": \"Ortalama\",\n    \"checked\": \"İşaretli\",\n    \"unChecked\": \"İşaretsiz\",\n    \"percentEmpty\": \"Boş Yüzdesi\",\n    \"percentFilled\": \"Dolu Yüzdesi\",\n    \"percentUnique\": \"Benzersiz Yüzdesi\",\n    \"percentChecked\": \"İşaretli Yüzdesi\",\n    \"percentUnChecked\": \"İşaretsiz Yüzdesi\",\n    \"earliestDate\": \"En Erken Tarih\",\n    \"latestDate\": \"En Son Tarih\",\n    \"dateRangeOfDays\": \"Tarih Aralığı (gün)\",\n    \"dateRangeOfMonths\": \"Tarih Aralığı (ay)\",\n    \"totalAttachmentSize\": \"Toplam Ek Boyutu\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Ekle\",\n    \"error\": {\n      \"invalidCol\": \"Geçersiz sütun, lütfen tekrar seçin\",\n      \"invalidCols\": \"Geçersiz sütunlar: {{colNames}}\",\n      \"invalidTable\": \"Geçersiz tablo, lütfen tekrar seçin\",\n      \"requiredSelect\": \"Bir tane seçmelisiniz\"\n    },\n    \"from\": {\n      \"title\": \"Kaynak\",\n      \"fromTable\": \"Tablo Seç\",\n      \"fromQuery\": \"Sorgudan\"\n    },\n    \"select\": {\n      \"title\": \"Seç\"\n    },\n    \"where\": {\n      \"title\": \"Koşul\"\n    },\n    \"groupBy\": {\n      \"title\": \"Grupla\"\n    },\n    \"orderBy\": {\n      \"title\": \"Sırala\",\n      \"asc\": \"Artan\",\n      \"desc\": \"Azalan\"\n    },\n    \"limit\": {\n      \"title\": \"Limit\"\n    },\n    \"offset\": {\n      \"title\": \"Ofset\"\n    },\n    \"join\": {\n      \"title\": \"Birleştir\",\n      \"joinType\": \"Birleştirme Türü\",\n      \"leftJoin\": \"Sol Birleştirme\",\n      \"rightJoin\": \"Sağ Birleştirme\",\n      \"innerJoin\": \"İç Birleştirme\",\n      \"fullJoin\": \"Tam Birleştirme\",\n      \"data\": \"Kaynak\"\n    },\n    \"aggregation\": {\n      \"title\": \"Toplama\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Yorum\",\n    \"placeholder\": \"Bir yorum bırakın...\",\n    \"emptyComment\": \"Bir konuşma başlatın\",\n    \"deletedComment\": \"Silinmiş Yorum\",\n    \"imageSizeLimit\": \"Görsel boyutu {{size}}'den büyük olamaz\",\n    \"tip\": {\n      \"editing\": \"Düzenleme...\",\n      \"edited\": \"(Düzenlendi)\",\n      \"notifyAll\": \"Tüm yorumları bildir\",\n      \"notifyRelatedToMe\": \"Benimle ilgili yorumları bildir\",\n      \"all\": \"Tümü\",\n      \"relatedToMe\": \"Benimle ilgili\",\n      \"reactionUserSuffix\": \"{{emoji}} emoji ile tepki verdi\",\n      \"me\": \"Sen\",\n      \"connection\": \"ve\"\n    },\n    \"toolbar\": {\n      \"link\": \"Bağlantı\",\n      \"image\": \"Görsel\",\n      \"mention\": \"Bahset\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Bağlantıyı Düzenle\",\n      \"caption\": \"Başlık\",\n      \"delete\": \"Sil\",\n      \"linkText\": \"Bağlantı Metni\",\n      \"enterUrl\": \"URL Girin\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Select Members\",\n    \"memberSelectorSearchPlaceholder\": \"Search members...\",\n    \"departmentSelectorSearchPlaceholder\": \"Search departments...\",\n    \"selected\": \"Selected\",\n    \"noSelected\": \"No selected\",\n    \"empty\": \"No members\",\n    \"emptyDepartment\": \"No departments\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Doğrulama Hatası\",\n    \"invalidCaptcha\": \"Geçersiz Captcha\",\n    \"invalidCredentials\": \"Geçersiz Kimlik Bilgileri\",\n    \"unauthorized\": \"Yetkisiz\",\n    \"unauthorizedShare\": \"Yetkisiz Paylaşım\",\n    \"paymentRequired\": \"Ödeme Gerekli\",\n    \"creditLimitExceeded\": \"Kredi Limiti Aşıldı\",\n    \"restrictedResource\": \"Kısıtlı Kaynak\",\n    \"notFound\": \"Bulunamadı\",\n    \"conflict\": \"Çatışma\",\n    \"unprocessableEntity\": \"İşlenemeyen Varlık\",\n    \"userLimitExceeded\": \"Kullanıcı Limit Aşıldı\",\n    \"tooManyRequests\": \"Çok Fazla İstek\",\n    \"internalServerError\": \"Sunucu Hatası\",\n    \"databaseConnectionUnavailable\": \"Veritabanı Bağlantısı Mevcut Değil\",\n    \"gatewayTimeout\": \"Ağ Geçidi Zaman Aşımı\",\n    \"unknownErrorCode\": \"Bilinmeyen Hata Kodu\",\n    \"networkError\": \"Ağ bağlantı sorunu\",\n    \"requestTimeout\": \"İstek Zaman Aşımı\",\n    \"failedDependency\": \"Bağımlılık Hatası\",\n    \"automationNodeParseError\": \"Otomasyon Düğümü Ayrıştırma Hatası\",\n    \"automationNodeNeedTest\": \"Otomasyon Düğümü Test Gereksinimi\",\n    \"automationNodeTestOutdated\": \"Otomasyon Düğümü Testi Güncel Değil\",\n    \"invalidToken\": \"Geçersiz token\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" alanı \\\"{{fieldName}}\\\" boş değerlere izin vermiyor, lütfen tamamlayınız ve gönderimden önce doldurun.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" alanı \\\"{{fieldName}}\\\" tekrarlayan değerlere izin vermiyor, lütfen benzersiz bir değer doldurun ve gönderimden önce doldurun.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" alanı aynı kayıtla tekrarlayan değerlere izin vermiyor.\",\n      \"requestTimeout\": \"İşlem kapsamı çok büyük, lütfen kapsamı daraltıp tekrar deneyin.\",\n      \"searchTimeOut\": \"Arama tamamlandı, arama kapsamını azaltarak tekrar deneyin.\",\n      \"dependencyNodeRequire\": \"Bağımlı düğüm test edilmedi, lütfen önceki düğümleri test edin\",\n      \"invalidOperation\": \"Geçersiz işlem tespit edildi, lütfen işlem parametrelerini kontrol edin\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"İstenen yorum sayısı 1000 maksimum sınırını aşıyor\",\n      \"invalidContentType\": \"Geçersiz yorum içerik türü\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"Token süresinin 7 günden az olması gerekir\",\n      \"s3RegionRequired\": \"S3 bölgesi gerekli\",\n      \"s3EndpointRequired\": \"S3 uç noktası gerekli\",\n      \"s3AccessKeyRequired\": \"S3 erişim anahtarı gerekli\",\n      \"s3SecretKeyRequired\": \"S3 gizli anahtarı gerekli\",\n      \"s3UploadMethodMustBePut\": \"S3 yükleme yöntemi PUT olmalıdır\",\n      \"presignedError\": \"Önceden imzalanmış URL oluşturulamadı\",\n      \"invalidObjectMeta\": \"Geçersiz nesne meta verisi\",\n      \"invalidImageStream\": \"Geçersiz görüntü akışı\",\n      \"calculateImageSizeFailed\": \"Görüntü boyutu hesaplanamadı\",\n      \"uploadFailed\": \"Yükleme başarısız\",\n      \"invalidImage\": \"Geçersiz görüntü\",\n      \"cantGetImageStream\": \"Görüntü akışı alınamıyor\",\n      \"invalidProvider\": \"Geçersiz depolama sağlayıcısı\",\n      \"failedToDeleteDirectory\": \"Dizin silinemedi\",\n      \"invalidToken\": \"Geçersiz token\",\n      \"tokenExpired\": \"Token süresi doldu\",\n      \"sizeMismatch\": \"Dosya boyutu uyuşmuyor\",\n      \"notAllowUploadFileType\": \"{{mimetype}} dosya türü yüklemeye izin verilmiyor\",\n      \"notFound\": \"Ek bulunamadı\",\n      \"invalidPath\": \"Geçersiz ek yolu\",\n      \"fileSizeExceedsMaximumLimit\": \"Dosya boyutu maksimum sınır {{maxSize}} aşıyor\",\n      \"invalidUploadType\": \"Geçersiz yükleme türü\",\n      \"urlReject\": \"URL reddedildi veya erişilemiyor\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Posta yapılandırma hatası {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Bu buton alanı zaten {{name}}[{{id}}] otomasyon işlemine bağlı, lütfen başka bir buton alanı seçin\",\n      \"triggerNotFound\": \"Otomasyon bir tetikleyici düğümüne sahip olmalı\",\n      \"nodeNotFound\": \"{{nodeId}} düğümü bulunamadı\",\n      \"triggerTestFailed\": \"Tetikleyici testi başarısız, lütfen tetikleyici yapılandırmasını kontrol edin\",\n      \"testFailed\": \"Otomasyon testi başarısız, otomasyon yapılandırmasını kontrol edin\",\n      \"runFailed\": \"Otomasyon çalıştırması başarısız oldu\",\n      \"nodeParseError\": \"{{name}} düğüm yapılandırması eksik veya hatalı, lütfen düğüm yapılandırmasını kontrol edin\",\n      \"nodeNeedTest\": \"{{name}} düğümün test edilmesi gerekiyor\",\n      \"nodeTestOutdated\": \"{{name}} düğüm testi güncel değil\",\n      \"notFound\": \"Otomasyon bulunamadı\",\n      \"currentSnapshotEmpty\": \"Otomasyonun mevcut anlık görüntüsü boş\",\n      \"runNotFound\": \"Otomasyon çalıştırması bulunamadı\",\n      \"anchorNotFound\": \"Bağlantı otomasyonu bulunamadı\",\n      \"validationError\": \"Otomasyon yapılandırması doğrulama hatası\",\n      \"tableNotInBase\": \"Yalnızca veritabanınızdaki bir tabloya abone olabilirsiniz\",\n      \"alreadyActiveAndNotDraft\": \"Otomasyon zaten aktif ve taslak değil\",\n      \"noActiveSnapshot\": \"Otomasyonun aktif anlık görüntüsü yok\",\n      \"triggerNodeAlreadyExists\": \"Bu otomasyonun zaten bir tetikleyici düğümü var\",\n      \"generateLogicError\": \"Mantık düğümü oluşturma hatası\",\n      \"logicNotFound\": \"Otomasyon mantık düğümü bulunamadı\",\n      \"actionNotFound\": \"Otomasyon eylem düğümü bulunamadı\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Bu otomasyon düğüm türünün çoğaltılması desteklenmiyor\",\n      \"unSupportLogicType\": \"Desteklenmeyen mantık türü\",\n      \"groupEndNotFound\": \"Mantık için GroupEnd bulunamadı\",\n      \"insertNodeError\": \"Düğüm ekleme hatası\",\n      \"controlNodeNotBeTested\": \"Kontrol düğümü test edilmemeli\",\n      \"invalidNodeType\": \"Geçersiz düğüm türü\",\n      \"unsupportedCategory\": \"Desteklenmeyen kategori\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Posta kutuları listelenemedi: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Bilinmeyen veri seti: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Kazıma servisi yapılandırılmamış\",\n      \"triggerFailed\": \"Kazıma tetiklemesi başarısız: {{detail}}\",\n      \"snapshotError\": \"Kazıma anlık görüntü hatası: {{detail}}\",\n      \"timeout\": \"Kazıma {{seconds}}s sonra zaman aşımına uğradı\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Alan bulunamadı\",\n      \"noPermission\": \"Bu alana erişim izniniz yok\",\n      \"disallowSpaceCreation\": \"Alan oluşturma yönetici tarafından devre dışı bırakıldı\",\n      \"cannotChangeOnlyOwnerRole\": \"Alanın tek sahibinin rolü değiştirilemez\",\n      \"cannotDeleteOnlyOwner\": \"Alanın tek sahibi silinemez\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Bir alan üzerinde işlem yapılamıyor, alanda organizasyon üyelerinin bulunduğunu ve sahip olduklarını doğrulayın\",\n      \"notBelongToOrg\": \"Bu alan organizasyona ait değil\",\n      \"invalidSpaceIds\": \"Alan kimlikleri geçersiz: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"Base bulunamadı\",\n      \"cannotAccess\": \"Base {{baseId}} erişim izniniz yok\",\n      \"anchorNotFound\": \"Bağlantı base'i {{anchorId}} bulunamadı\",\n      \"baseAndSpaceMismatch\": \"Base {{baseId}} ve alan {{spaceId}} eşleşmiyor\",\n      \"templateNotFound\": \"Şablon {{templateId}} bulunamadı\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"Base ID gereklidir\",\n      \"nodeIdIsRequired\": \"Node ID gereklidir\",\n      \"invalidResourceType\": \"Geçersiz kaynak türü\",\n      \"notFound\": \"Temel düğüm bulunamadı\",\n      \"parentMustBeFolder\": \"Üst öğe bir klasör olmalıdır\",\n      \"cannotDuplicateFolder\": \"Klasör çoğaltılamaz\",\n      \"cannotDeleteEmptyFolder\": \"Klasör boş olmadığı için silinemez\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Yalnızca parentId veya anchorId belirtilmelidir\",\n      \"cannotMoveToItself\": \"Düğüm kendisine taşınamaz\",\n      \"cannotMoveToCircularReference\": \"Düğüm kendi alt düğümüne taşınamaz (döngüsel referans)\",\n      \"anchorIdOrParentIdRequired\": \"En az parentId veya anchorId belirtilmelidir\",\n      \"parentNotFound\": \"Üst düğüm bulunamadı\",\n      \"parentIsNotFolder\": \"Üst öğe bir klasör değil\",\n      \"circularReference\": \"Döngüsel referans algılandı\",\n      \"folderDepthLimitExceeded\": \"Klasör derinlik sınırı aşıldı\",\n      \"folderNotFound\": \"Klasör bulunamadı\",\n      \"anchorNotFound\": \"Bağlantı düğümü bulunamadı\",\n      \"nameAlreadyExists\": \"İsim zaten mevcut\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Gösterge paneli bulunamadı\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Eklenti bulunamadı\",\n      \"notSupportInstallInView\": \"Eklenti görünümde yüklemeyi desteklemiyor\",\n      \"userNotFound\": \"Eklenti kullanıcısı bulunamadı\",\n      \"invalidSecret\": \"Geçersiz gizli anahtar\",\n      \"invalidRefreshToken\": \"Geçersiz yenileme belirteci\",\n      \"anomalousToken\": \"Anormal belirteç\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Eklenti paneli bulunamadı\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Eklenti yüklenmemiş\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Yanlış şifre\",\n      \"notAllowedToSubmit\": \"Form gönderimi izin verilmiyor\",\n      \"viewRequired\": \"Bu işlem için görünüm gereklidir\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"Gizli alanlar dahil edildiğinde form gönderimi izin verilmiyor\",\n      \"submitRecordsError\": \"Kayıt gönderimi başarısız oldu\",\n      \"notAllowedToCopy\": \"Kopyalama işlemi izin verilmiyor\",\n      \"fieldHiddenNotAllowed\": \"Alan gizli ve erişilemez\",\n      \"fieldTypeNotLinkField\": \"Alan bir bağlantı alanı değil\",\n      \"fieldIdRequired\": \"Alan kimliği gereklidir\",\n      \"fieldNotUserRelatedField\": \"Alan kullanıcıyla ilgili bir alan değil\",\n      \"viewTypeNotAllowed\": \"Bu görünüm türü bu işlem için izin verilmiyor\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"Bu paylaşılan görünüm için şifre kısıtlaması etkin değil\",\n      \"shareViewNotFound\": \"Paylaşılan görünüm bulunamadı veya paylaşım devre dışı\",\n      \"linkFieldNotFound\": \"Bağlantı alanı bulunamadı\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Taban paylaşımı bulunamadı veya paylaşım devre dışı\",\n      \"alreadyExists\": \"Bu düğüm için zaten bir paylaşım mevcut\",\n      \"copyNotAllowed\": \"Bu paylaşım kopyalamaya izin vermiyor\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"Bu görünüme erişim izniniz yok\",\n      \"fieldPermissionNotAllowed\": \"Bu alanlara erişim izniniz yok\",\n      \"recordPermissionNotAllowed\": \"Bu kayıtlara erişim izniniz yok\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Eklenti bağlam menüsü bulunamadı\",\n      \"anchorNotFound\": \"Eklenti bağlam menüsü bağlantısı bulunamadı\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Eklenti grafik sorgusu bulunamadı\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Desteklenmeyen veritabanı sürücüsü: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Yalnızca base sahibi, base {{baseId}} için veritabanı bağlantısını kaldırabilir\",\n      \"onlyOwnerCanCreate\": \"Yalnızca base sahibi, base {{baseId}} için veritabanı bağlantısı oluşturabilir\",\n      \"roleNotExist\": \"Veritabanı rolü {{role}} mevcut değil\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Sorgu başarısız: {{message}}\",\n      \"invalidJoinType\": \"Geçersiz birleştirme türü: {{joinType}}\",\n      \"tableNotFound\": \"Tablo {{tableId}}, base {{baseId}} içinde bulunamadı\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"{{keyword}} anahtar kelimesiyle SQL çalıştırılmasına izin verilmiyor\",\n      \"whiteListCheckError\": \"Tablo erişimi kontrol edilirken bir hata oluştu: {{message}}\",\n      \"databaseConnectionFailed\": \"Veritabanı bağlantısı başarısız oldu: {{message}}\",\n      \"executeQuerySqlFailed\": \"Sorgu SQL'i çalıştırılamadı: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"Alanları({{fields}}) olan kayıtlar oluşturma izniniz yok\",\n      \"deleteRecords\": \"Kayıtları({{recordIds}}) silme izniniz yok\",\n      \"readRecordWithDeniedFields\": \"Kayıt({{recordId}}) içindeki alanları({{fields}}) okuma izniniz yok\",\n      \"updateRecordWithDeniedFields\": \"Kayıt({{recordId}}) içindeki alanları({{fields}}) güncelleme izniniz yok\",\n      \"checkIdNotExist\": \"İzin kontrol kimliği mevcut değil\",\n      \"userNotAdmin\": \"Kullanıcı yönetici değil\",\n      \"accessTokenNoPermission\": \"Erişim jetonu gerekli izne sahip değil\",\n      \"invalidResource\": \"Kaynak geçerli değil\",\n      \"notAllowedSpace\": \"Bu alana erişim izniniz yok\",\n      \"notAllowedBase\": \"Bu tabana erişim izniniz yok\",\n      \"notAllowedTables\": \"Bu tablolar({{tableIds}}) erişim izniniz yok\",\n      \"notAllowedOperationTable\": \"Bu tablo üzerinde işlem yapma izniniz yok\",\n      \"notAllowedOperationRecord\": \"Bu kayıt üzerinde işlem yapma izniniz yok\",\n      \"notAllowedRecordUpdate\": \"Bu kaydı güncelleme izniniz yok\",\n      \"notAllowedOperationView\": \"Bu görünüm üzerinde işlem yapma izniniz yok\",\n      \"deniedByEnabledAuthorityMatrix\": \"Etkin yetki matrisi tarafından reddedildi\",\n      \"invalidRequestPath\": \"İstek yolu geçerli değil\",\n      \"notAllowedOperation\": \"Bu işlemi gerçekleştirme izniniz yok\",\n      \"notAllowedDepartment\": \"Bu departmana erişiminiz yok\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Varsayılan rol bulunamadı\",\n      \"alreadyDisabled\": \"Yetki matrisi zaten devre dışı\",\n      \"alreadyEnabled\": \"Yetki matrisi zaten etkin\",\n      \"notFound\": \"Yetki matrisi bulunamadı\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Birincil alan okuma erişimi için devre dışı bırakılamaz\",\n      \"fieldDuplicated\": \"Alan izin yapılandırmasında yineleniyor\",\n      \"cannotSetRecordPermissionGroup\": \"Bu kayıt izinleri kombinasyonu({{actions}}) ayarlanamıyor\",\n      \"notFoundBaseAndTable\": \"Taban kimliği ve tablo kimliği bulunamadı\",\n      \"roleTablesShouldNotBeEmpty\": \"Yetki matrisi rol tabloları boş olmamalı\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Geçersiz dönüş türü\",\n      \"exceedMaxReadRows\": \"Maksimum okuma satırı sınırı aşıldı\",\n      \"invalidCellValueType\": \"Geçersiz hücre değeri türü\",\n      \"exceedMaxCopyCells\": \"Maksimum kopyalama hücresi sınırı aşıldı\",\n      \"exceedMaxPasteCells\": \"Maksimum yapıştırma hücresi sınırı aşıldı\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Desteklenmeyen alan türü {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Birincil alan olarak desteklenmeyen alan türü {{type}}\",\n      \"primaryFieldNotSupported\": \"Alan türü birincil alan olarak desteklenmiyor\",\n      \"calculateRecordNotFound\": \"Kayıt bulunamadı: {{value}}, fieldId: {{fieldId}}, {{recordId}} hesaplanırken\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"Normal hesaplanan alan için toRecordIds veya fromRecordIds gereklidir\",\n      \"recordFieldsRequired\": \"Kayıt alanları tanımsız\",\n      \"uniqueUnsupportedType\": \"Alan {{name}}[{{fieldId}}] alan değeri benzersizlik doğrulamasını desteklemiyor\",\n      \"notNullValidationWhenCreateField\": \"Alan {{name}}[{{fieldId}}] yeni bir alan oluştururken null olmayan doğrulamayı desteklemiyor\",\n      \"dbFieldNameAlreadyExists\": \"Veritabanı alan adı {{dbFieldName}} zaten mevcut\",\n      \"fieldValidationError\": \"Alan {{name}}[{{fieldId}}] alan doğrulama hatası\",\n      \"fieldNameAlreadyExists\": \"Alan adı {{name}} zaten mevcut\",\n      \"notFound\": \"Alan bulunamadı\",\n      \"fieldKeyTypeNotFound\": \"Alan \\\"{{fieldKeyType}}: {{missedFields}}\\\" bulunamadı\",\n      \"notFoundInTable\": \"Alan {{fieldId}} tablo {{tableId}} içinde bulunamadı\",\n      \"deleteFieldsNotFound\": \"Silinecek alanlar {{fieldIds}} tablo {{tableId}} içinde bulunamadı\",\n      \"lookupValuesShouldBeArray\": \"Bağlantı alanı birden fazla hücre değerine sahip olduğunda lookupValues dizi olmalıdır\",\n      \"linkCellValuesShouldBeArray\": \"Bağlantı alanı birden fazla hücre değerine sahip olduğunda linkCellValues dizi olmalıdır\",\n      \"lookupAndLinkLengthMatch\": \"lookupValues uzunluğu linkCellValues uzunluğu ile aynı olmalıdır\",\n      \"cycleDetected\": \"Döngü tespit edildi\",\n      \"cycleDetectedCreateField\": \"Döngü tespit edildi, alan {{name}}[{{id}}] oluşturulamıyor\",\n      \"recordMapNotFound\": \"Tablo {{tableName}} içinde alan {{fieldName}} için kayıt bulunamadı\",\n      \"forbidDeletePrimaryField\": \"Birincil alanı silmek yasaktır\",\n      \"foreignTableIdInvalid\": \"Yabancı tablo {{foreignTableId}} geçersiz\",\n      \"relationshipInvalid\": \"İlişki {{relationship}} geçersiz\",\n      \"linkFieldIdInvalid\": \"Bağlantı alanı {{linkFieldId}} geçersiz\",\n      \"lookupFieldIdInvalid\": \"Arama alanı {{lookupFieldId}} geçersiz\",\n      \"formulaExpressionParseError\": \"Formül ifadesi ayrıştırma hatası\",\n      \"formulaReferenceNotFound\": \"Formül referans alanı {{fieldIds}} bulunamadı\",\n      \"rollupExpressionParseError\": \"Toplama ifadesi ayrıştırma hatası\",\n      \"choiceNameAlreadyExists\": \"Seçenek adı {{name}} zaten mevcut\",\n      \"symmetricFieldIdRequired\": \"Simetrik alan kimliği gereklidir\",\n      \"foreignKeyNameCannotUseId\": \"Yabancı anahtar adı __id kullanamaz\",\n      \"createForeignKeyError\": \"Yabancı anahtar oluşturma hatası\",\n      \"lookupFieldTypeNotEqual\": \"Mevcut alan türü {{fieldType}} arama alanı türü {{lookupFieldType}} ile eşit değil\",\n      \"recordNotFound\": \"Kayıt {{recordId}} {{tableId}} içinde bulunamadı\",\n      \"linkCellRecordIdAlreadyExists\": \"Aynı hücrede yinelenen recordId ayarlanamaz: {{recordId}}\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Bire-bir bağlantı alanı değerleri dizi olamaz\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Çoka-bir bağlantı alanı değerleri dizi olamaz\",\n      \"foreignKeyDuplicate\": \"Yabancı anahtar yineleniyor\",\n      \"linkConsistencyError\": \"Tutarlılık hatası, recordId {{recordId}} mevcut değil\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Bire-çok bağlantı alanı değerleri dizi olmalıdır\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Çoka-çok bağlantı alanı değerleri dizi olmalıdır\",\n      \"onlyLinkFieldCanBeFiltered\": \"Yalnızca bağlantı alanları filtreleme için kullanılabilir\",\n      \"notLinkedToCurrentTable\": \"Alan mevcut tabloya bağlı değil\",\n      \"notAttachment\": \"Alan bir ek alanı değil\",\n      \"isComputed\": \"Alan hesaplanmış ve değiştirilemez\",\n      \"notFoundAICofig\": \"Alan AI yapılandırmasına sahip değil\",\n      \"foreignTableIdRequired\": \"Dış tablo gereklidir\",\n      \"lookupFieldIdRequired\": \"Arama alanı gereklidir\",\n      \"lookupFieldNotExist\": \"Arama alanı {{lookupFieldId}} mevcut değil\",\n      \"lookupFieldNotBelongToTable\": \"Arama alanı {{lookupFieldId}}, {{foreignTableId}} tablosuna ait değil\",\n      \"lookupFieldTypeNotMatch\": \"Geçerli alan türü {{fieldType}}, arama alanı türü {{lookupFieldType}} ile eşleşmiyor\",\n      \"conditionalRollupOptionsRequired\": \"Koşullu toplama alanı seçenekleri gereklidir\",\n      \"conditionalRollupParseError\": \"Koşullu toplama ayrıştırma hatası: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Koşullu arama alanı seçenekleri gereklidir\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Düğme tıklama sayısı maksimum sınıra ulaştı\",\n        \"notSupportReset\": \"Düğme sıfırlamayı desteklemiyor\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Görünüm bulunamadı\",\n      \"defaultViewNotFound\": \"Varsayılan görünüm bulunamadı\",\n      \"propertyParseError\": \"Görünüm özelliği ayrıştırılamadı\",\n      \"primaryFieldCannotBeHidden\": \"Birincil alan gizlenemez\",\n      \"filterUnsupportedFieldType\": \"Filtre desteklenmeyen alan türü\",\n      \"sortUnsupportedFieldType\": \"Sıralama desteklenmeyen alan türü\",\n      \"groupUnsupportedFieldType\": \"Gruplama desteklenmeyen alan türü\",\n      \"anchorNotFound\": \"Bağlantı görünümü bulunamadı\",\n      \"notEnoughGapToShuffleRow\": \"Satırı karıştırmak için yeterli boşluk yok\",\n      \"shareNotEnabled\": \"Görünüm paylaşımı etkinleştirilmedi\",\n      \"shareAlreadyEnabled\": \"Görünüm paylaşımı zaten etkinleştirildi\",\n      \"shareAlreadyDisabled\": \"Görünüm paylaşımı zaten devre dışı bırakıldı\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Yetersiz kredi\",\n      \"exceedMaxRowLimit\": \"Maksimum satır sınırı {{maxRowCount}} aşıldı\",\n      \"exceedMaxAutomationRunLimit\": \"Aylık maksimum otomasyon çalıştırma sayısına ulaşıldı\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Arama sorgusu gereklidir\",\n      \"maxSearchIndexResult\": \"Maksimum arama indeksi sonucu 1000'dir\",\n      \"queryCollectionMustBeTableId\": \"Sorgu koleksiyonu tablo kimliği olmalıdır\",\n      \"searchTimeOut\": \"Arama tamamlandı, arama kapsamını azaltarak tekrar deneyin.\",\n      \"indexNotFound\": \"İndeks bulunamadı\",\n      \"invalidStartDateFieldId\": \"Geçersiz başlangıç tarihi alan kimliği\",\n      \"invalidEndDateFieldId\": \"Geçersiz bitiş tarihi alan kimliği\",\n      \"fieldMapRequired\": \"Arama ayarlandığında alan haritası gereklidir\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected ve filterLinkCellCandidate aynı anda ayarlanamaz\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"AI sohbet modeli lg ayarlanmamış\",\n      \"chatModelLgProviderNotSet\": \"AI sohbet modeli lg sağlayıcısı ayarlanmamış\",\n      \"chatModelSmNotSet\": \"AI sohbet modeli sm ayarlanmamış\",\n      \"chatModelMdNotSet\": \"AI sohbet modeli md ayarlanmamış\",\n      \"configurationNotSet\": \"AI yapılandırması ayarlanmamış\",\n      \"unsupportedProvider\": \"Desteklenmeyen AI sağlayıcısı {{type}}\",\n      \"providerConfigurationNotSet\": \"AI sağlayıcı yapılandırması ayarlanmamış\",\n      \"testLLMFailed\": \"LLM bağlantı testi başarısız oldu\",\n      \"audioNotSupported\": \"Bu model {{model}} ses girişini desteklemiyor\",\n      \"imageNotSupported\": \"Bu model {{model}} görüntü girişini desteklemiyor\",\n      \"modelNotSet\": \"AI modeli ayarlanmamış\",\n      \"unsupportedFileType\": \"Desteklenmeyen dosya türü {{mimetype}}\",\n      \"unsupportedModelType\": \"Desteklenmeyen model türü\",\n      \"embeddingModelNotSet\": \"Gömme modeli ayarlanmamış\",\n      \"validateActionFailed\": \"Alan AI eyleminin doğrulanması başarısız oldu\",\n      \"generateFailed\": \"AI üretimi başarısız oldu\",\n      \"unsupportedActionType\": \"Desteklenmeyen AI eylem türü\"\n    },\n    \"role\": {\n      \"notFound\": \"Rol bulunamadı\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"İşbirlikçi zaten mevcut\",\n      \"notFound\": \"İşbirlikçi bulunamadı\",\n      \"userNotFoundInCollaborator\": \"İşbirlikçiler arasında kullanıcı bulunamadı\",\n      \"noPermissionToDelete\": \"Bu işbirlikçiyi silme izniniz yok\",\n      \"noPermissionToUpdate\": \"Bu işbirlikçiyi güncelleme izniniz yok\",\n      \"noPermissionToOperateRole\": \"Bu rolü yönetme izniniz yok\",\n      \"alreadyExistedInBase\": \"İşbirlikçi zaten veritabanında mevcut\",\n      \"userNotFound\": \"Kullanıcı bulunamadı: {{userIds}}\",\n      \"baseNotFound\": \"Veritabanı bulunamadı\",\n      \"noPermissionToAddRole\": \"Bu rolle işbirlikçi ekleme izniniz yok\",\n      \"departmentNotFound\": \"Departman bulunamadı\"\n    },\n    \"table\": {\n      \"notFound\": \"Tablo bulunamadı\",\n      \"dbTableNameAlreadyExists\": \"Veritabanı tablo adı zaten mevcut\",\n      \"anchorNotFound\": \"Bağlantı tablosu bulunamadı\",\n      \"notInTrash\": \"Tablo çöp kutusunda değil\",\n      \"notSupportTableIndex\": \"Tablo dizin türü desteklenmiyor\",\n      \"createTableIndexError\": \"Tablo dizini oluşturulamadı\",\n      \"dropTableIndexError\": \"Tablo dizini silinemedi\",\n      \"notFoundPrimaryField\": \"Tabloda birincil alan bulunamadı\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"{{viewType}} görünüm türü dışa aktarma için desteklenmiyor\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Dosya biçimi desteklenmiyor, yalnızca {{supportType}} destekleniyor, dosyanızın içerik türü {{fileFormat}}\",\n      \"notSupportedFileType\": \"İçe aktarma dosya türü desteklenmiyor\",\n      \"exceedMaxFieldsLength\": \"Tablodaki alan sayısı {{maxFieldsLength}} değerini aşamaz, mevcut değer {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"Mevcut örnek, yönetici tarafından alan davetine izin vermiyor\",\n      \"invalidCode\": \"Geçersiz davet kodu\",\n      \"linkNotFound\": \"Davet bağlantısı bulunamadı\",\n      \"linkExpired\": \"Davet bağlantısının süresi doldu\",\n      \"limitExceeded\": \"Saatte maksimum davet sayısına ulaştınız\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Favori zaten mevcut\",\n      \"notFound\": \"Favori bulunamadı\",\n      \"anchorNotFound\": \"Favori bağlantı noktası bulunamadı\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Geçersiz kaynak türü\",\n      \"notFound\": \"Çöp kutusu öğesi bulunamadı\",\n      \"parentSpaceTrashed\": \"Üst alan da çöp kutusunda olduğu için bu taban geri yüklenemiyor\",\n      \"parentBaseOrSpaceTrashed\": \"Üst taban veya alan da çöp kutusunda olduğu için bu tablo geri yüklenemiyor\",\n      \"parentBaseTrashed\": \"Üst taban da çöp kutusunda olduğu için bu öğe geri yüklenemiyor\",\n      \"parentNotFound\": \"Üst kaynak bulunamadı\",\n      \"tableNotFound\": \"Tablo çöp kutusu öğesi bulunamadı\"\n    },\n    \"license\": {\n      \"invalid\": \"Lisans geçersiz\",\n      \"instanceIdMismatch\": \"Gelen örnek ID'si mevcut örneğin örnek ID'si ile eşleşmiyor\",\n      \"expired\": \"Lisansın süresi doldu\",\n      \"userLimitExceeded\": \"Mevcut örnekteki kullanıcı sayısı lisansın koltuk sınırını aşıyor. Lütfen bazı kullanıcıları devre dışı bırakın veya lisansı yükseltin\"\n    },\n    \"organization\": {\n      \"notFound\": \"Organizasyon bulunamadı\",\n      \"emailNotSpaceUser\": \"E-posta alan kullanıcısı değil\",\n      \"authenticationNotFound\": \"Kimlik doğrulama bulunamadı\",\n      \"spaceShouldExist\": \"Organizasyon alanı mevcut olmalı\",\n      \"emailsNotInOrgDomain\": \"Bu e-postalar {{emails}} organizasyon etki alanında değil\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"Mevcut örnek, yönetici tarafından kayıt olmaya izin vermiyor\",\n      \"waitlistInviteCodeRequired\": \"Bekleme listesi etkin, davetiye kodu gerekli\",\n      \"waitlistInviteCodeInvalid\": \"Bekleme listesi etkin, davetiye kodu geçersiz\",\n      \"systemUser\": \"Kullanıcı bir sistem kullanıcısıdır\",\n      \"collaboratorsInSpaces\": \"Kullanıcının alanlarda işbirlikçileri var (veya çöp kutusunda silinmiş alanlar)\",\n      \"notFound\": \"Kullanıcı bulunamadı\",\n      \"cannotDeleteAdmin\": \"Yönetici kullanıcı silinemez\",\n      \"cannotDeactivateAdmin\": \"Yönetici kullanıcı devre dışı bırakılamaz\",\n      \"cannotRemoveLastAdmin\": \"Son aktif yönetici kullanıcıdan yönetici ayrıcalığı kaldırılamaz\",\n      \"permanentDeleted\": \"Kullanıcı kalıcı olarak silindi\",\n      \"cannotDeleteSelf\": \"Kendinizi silemezsiniz\",\n      \"alreadyInDepartment\": \"Kullanıcı {{userId}} zaten hedef departmanda\",\n      \"emailsNotFound\": \"E-postalar {{emails}} bulunamadı\",\n      \"deleted\": \"Kullanıcı {{userId}} silindi\",\n      \"alreadyInOrg\": \"Kullanıcı {{userId}} zaten organizasyonda\",\n      \"notInOrg\": \"Kullanıcı {{userId}} organizasyonda değil\"\n    },\n    \"record\": {\n      \"notFound\": \"Kayıt bulunamadı\",\n      \"deletedIdsNotFound\": \"Silinecek bazı kayıtlar bulunamadı\",\n      \"updateFailed\": \"Kayıt güncelleme başarısız\",\n      \"noFileOrUrlProvided\": \"Dosya veya URL sağlanmadı\",\n      \"createRecordsEmpty\": \"Kayıt oluşturma boş olamaz\",\n      \"duplicateFailed\": \"Kayıt çoğaltma başarısız\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Hücre değeri doğrulama başarısız\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Otomasyon açık değil\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Geçersiz kaynak türü\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Şablon kategorisi bulunamadı\",\n      \"snapshotRequired\": \"Anlık görüntü eksik olduğu için bu şablon yayınlanamadı\",\n      \"sourceTemplateNotFound\": \"Kaynak şablon bulunamadı\",\n      \"noMinOrderFound\": \"Minimum sıra bulunamadı\",\n      \"takeCountTooLarge\": \"İstenen şablon sayısı maksimum sınırı aşıyor\",\n      \"categoryLimitReached\": \"Şablon kategori sınırına ulaşıldı (maksimum {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Domain doğrulama kodu bulunamadı\",\n      \"invalidCode\": \"Geçersiz doğrulama kodu\",\n      \"resendCooldown\": \"Yeni bir kod istemeden önce lütfen 1 dakika bekleyin\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"E-posta gönderimi başarısız\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Üst departman bulunamadı\",\n      \"notFound\": \"Departman bulunamadı\",\n      \"cannotMoveToItself\": \"Departman kendisine taşınamaz\",\n      \"cannotMoveToSub\": \"Departman alt departmanına taşınamaz\"\n    },\n    \"app\": {\n      \"notFound\": \"Uygulama bulunamadı\",\n      \"noFilesToUpdate\": \"Güncellenecek dosya yok\",\n      \"noChatIdFound\": \"Bu uygulama için sohbet ID'si bulunamadı\",\n      \"noChatFound\": \"Bu uygulama için sohbet bulunamadı\",\n      \"versionNotFound\": \"Sürüm bulunamadı\",\n      \"cannotRollbackToLatestVersion\": \"En son sürüme geri alınamaz\",\n      \"noChatOrProjectTokenFound\": \"Sohbet veya proje jetonu bulunamadı\",\n      \"apiKeyNotSet\": \"Uygulama oluşturucu API anahtarı ayarlanmamış\",\n      \"cannotDeployAppBeforeInitialization\": \"Başlatmadan önce uygulama dağıtılamaz\",\n      \"noProjectOrVersionFound\": \"Proje veya sürüm bulunamadı\",\n      \"noDeploymentUrlAvailable\": \"Dağıtım URL'si mevcut değil\"\n    },\n    \"reward\": {\n      \"notFound\": \"Ödül bulunamadı\",\n      \"unsupportedSourceType\": \"Desteklenmeyen ödül kaynak türü\",\n      \"maxClaimsReached\": \"Bu hafta için maksimum ödül talebine (2) ulaştınız\",\n      \"verificationFailed\": \"Doğrulama başarısız: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Bu hesap için bu hafta zaten bir ödül talep ettiniz\",\n      \"invalidPostUrl\": \"Geçersiz gönderi URL formatı\",\n      \"postAlreadyUsed\": \"Bu gönderi zaten bir ödül talep etmek için kullanıldı\",\n      \"unsupportedPlatformUrl\": \"Desteklenmeyen sosyal platform URL'si\",\n      \"unsupportedPlatform\": \"Desteklenmeyen platform: {{platform}}\",\n      \"minCharCount\": \"Gönderi en az {{count}} karakter içermelidir\",\n      \"minFollowerCount\": \"Hesapta en az {{count}} takipçi olmalıdır\",\n      \"mustMention\": \"Gönderi {{mention}} kişisinden bahsetmelidir\",\n      \"fetchTweetFailed\": \"X tweeti alınamadı: {{error}}\",\n      \"tweetNotFound\": \"X tweeti bulunamadı: {{postId}}\",\n      \"fetchUserFailed\": \"X kullanıcısı alınamadı: {{error}}\",\n      \"xUserNotFound\": \"X kullanıcısı bulunamadı: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"LinkedIn gönderisi alınamadı: {{error}}\",\n      \"linkedInPostNotFound\": \"LinkedIn gönderisi bulunamadı: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"LinkedIn yazarı bulunamadı: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"LinkedIn kullanıcısı alınamadı: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Kişisel erişim tokenleri\",\n  \"oauthApps\": \"OAuth Uygulamaları\",\n  \"plugins\": \"Eklentiler\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Bu sayfayı görüntülemek için şifrenizi girin\",\n    \"submit\": \"Gönder\",\n    \"password\": \"Şifre\",\n    \"passwordTooShort\": \"Şifre en az 3 karakter olmalıdır\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Seç...\"\n  },\n  \"openOnNewPage\": \"Yeni sayfada aç\",\n  \"errorTips\": \"Paylaşım kaynağında Yetki Matrisi etkinleştirildi, görüntülemeye izin verilmiyor\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}'in alanı\",\n  \"action\": {\n    \"createBase\": \"Veritabanı oluştur\",\n    \"createSpace\": \"Alan oluştur\",\n    \"invite\": \"Davet et\"\n  },\n  \"allSpaces\": \"Tüm alanlar\",\n  \"emptySpaceTitle\": \"Bu alanda base yok\",\n  \"spaceIsEmpty\": \"Başlamak için ilk base'inizi oluşturun\",\n  \"baseModal\": {\n    \"copy\": \"Kopyala\",\n    \"duplicate\": \"\\\"{{baseName}}\\\" çoğalt\",\n    \"createBaseFromTemplate\": \"Şablondan veritabanı oluştur\",\n    \"duplicateRecords\": \"Kayıtları çoğalt\",\n    \"duplicateRecordsTip\": \"Revizyon geçmişiniz ve işbirlikçileriniz çoğaltılmayacak.\",\n    \"toSpace\": \"Alana\",\n    \"copyToSpace\": \"Alana kopyala\",\n    \"duplicateBase\": \"Veritabanını çoğalt\",\n    \"missTargetTip\": \"Lütfen veritabanını çoğaltmak için bir alan seçin.\",\n    \"copying\": \"Veritabanı çoğaltılıyor, bu biraz zaman alabilir...\",\n    \"copyingTemplate\": \"Şablondan veritabanı oluşturuluyor, bu biraz zaman alabilir...\",\n    \"howToCreate\": \"Nasıl başlamak istersiniz?\",\n    \"fromScratch\": \"Sıfırdan\",\n    \"fromTemplate\": \"Şablondan\",\n    \"moveBaseToAnotherSpace\": \"{{baseName}}'i başka bir alana taşı\",\n    \"chooseSpace\": \"Alan seç\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Alan ayarları\",\n    \"general\": \"Genel\",\n    \"collaborators\": \"İşbirlikçiler\",\n    \"generalDescription\": \"Mevcut alanınızın ayarlarını buradan değiştirin\",\n    \"collaboratorDescription\": \"Alanınızın işbirlikçilerini yönetin ve erişim izinlerini ayarlayın\",\n    \"spaceName\": \"Alan Adı\",\n    \"spaceId\": \"Alan Kimliği\"\n  },\n  \"pin\": {\n    \"add\": \"Sabitlemelere ekle\",\n    \"remove\": \"Sabitlemeleri kaldır\",\n    \"pin\": \"Sabitle\",\n    \"empty\": \"Sabitlediğiniz veritabanları ve alanlar burada görünecek\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"Veritabanı oluşturma izniniz yok\"\n  },\n  \"tip\": {\n    \"delete\": \"<0/> silmek istediğinizden emin misiniz?\",\n    \"title\": \"İpuçları\",\n    \"exportTips1\": \"Mevcut veritabanını .tea dosyası olarak dışa aktarın, bu biraz zaman alabilir. Dışa aktarma sonuçlarını bildirim merkezinde kontrol edebilirsiniz.\",\n    \"exportTips2\": \"Alan -> daha fazla -> içe aktar yolundan .tea dosyasını içe aktarabilirsiniz\",\n    \"exportTips3\": \"Veritabanları arası ilişki alanları tek satır metne dönüştürülecektir.\",\n    \"exportIncludeDataLabel\": \"Kayıtları dahil et\",\n    \"exportIncludeDataDescription\": \"Yalnızca yapı ve yapılandırmayı dışa aktarmak için kapatın.\",\n    \"moveBaseSuccessTitle\": \"Taşıma başarılı\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} başarıyla {{spaceName}} alanına taşındı\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Alanı Sil\",\n    \"blockedTitle\": \"Bu alan silinemiyor\",\n    \"blockedDesc\": \"Bu alanın aktif bir aboneliği var. Lütfen alanı silmeden önce aboneliği iptal edin.\",\n    \"permanentDeleteWarning\": \"Bu işlem mevcut alan altındaki tüm kaynakları ve verileri kalıcı olarak silecektir. Lütfen dikkatli ilerleyin!\",\n    \"confirmInputLabel\": \"Silme işlemini onaylamak için lütfen DELETE yazın\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Paylaşılan veritabanları\",\n    \"description\": \"Katılmaya davet edildiğim tüm veritabanları\",\n    \"empty\": \"Henüz paylaşılan veritabanı yok\"\n  },\n  \"integration\": {\n    \"title\": \"Integrasyonlar\",\n    \"description\": \"Yerleştirilen integreasyonları burada görünecek\",\n    \"addIntegration\": \"Integrasyon ekle\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"AI ayarları\",\n    \"description\": \"Alanınızın AI ayarlarını yönetin\",\n    \"enableTips\": \"Sistem AI'ını kullanmak yerine alanınızda AI özelliklerini kullanmak için AI'yı etkinleştirin\",\n    \"enable\": \"AI ayarlarını başlat\",\n    \"enableSwitchTips\": \"Etkinleştirmeden önce lütfen büyük kodlama modelini yapılandırın\"\n  },\n  \"import\": {\n    \"importing\": \"İçe aktarılıyor\",\n    \"importWayTip\": \"Yüklemek için dosyayı bu alana tıklayın veya sürükleyip bırakın\",\n    \"baseImportTips\": \"Yüklemek için .tea dosyasını bu alana tıklayın veya sürükleyip bırakın\",\n    \"confirm\": \"Onayla ve devam et\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Görüntülemek için tıklayın\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Şablon\",\n    \"description\": \"Bir şablondan hızlıca yeni bir üs (base) oluşturun\",\n    \"noTemplatesAvailable\": \"Kullanılabilir şablon yok\",\n    \"noTemplatesDescription\": \"Şu anda burada hiçbir şey yok\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Son ziyaret edilenler\"\n  },\n  \"noBases\": {\n    \"title\": \"Merhaba {{userName}}!\",\n    \"description\": \"İlk üssünüzle (base) işlerinizi yönetmeye başlayalım.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Merhaba {{userName}}!\",\n    \"description\": \"Veri işbirliği yolculuğunuza başlamak için ilk alanınızı oluşturun.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Tüm üsler\",\n    \"owner\": \"Sahibi\",\n    \"createdTime\": \"Oluşturulma\",\n    \"lastOpened\": \"Son açılan\",\n    \"enter\": \"Giriş\",\n    \"noTables\": \"Tablo yok\",\n    \"empty\": \"Henüz üs yok\"\n  },\n  \"publishBase\": {\n    \"title\": \"Üssü toplulukta yayınla\",\n    \"description\": \"Tek tıkla üs yayınlama, ilham artık yalnız değil! Yaratıcılığınızı daha fazla kişinin görmesine, kullanmasına ve Remix yapmasına izin verin ve birlikte daha güçlü bir iş kurun\",\n    \"infoTitle\": \"Temel bilgiler\",\n    \"form\": {\n      \"title\": \"Başlık\",\n      \"description\": \"Açıklama\",\n      \"security\": \"Güvenlik\",\n      \"includeNodes\": \"Düğümleri dahil et\",\n      \"advanced\": \"Gelişmiş\",\n      \"publishNode\": \"Düğümleri yayınla\",\n      \"includeData\": \"Verileri dahil et\",\n      \"defaultActiveNode\": \"Varsayılan aktif düğüm\",\n      \"select\": \"lütfen seçin\",\n      \"descriptionPlaceholder\": \"Fikrinizi kısaca açıklayın...\",\n      \"titlePlaceholder\": \"Çalışmanıza bir ad verin...\"\n    },\n    \"publishToCommunity\": \"Şablon merkezinde yayınla\",\n    \"publish\": \"Yayınla\",\n    \"publishSuccess\": \"Yayınlama başarılı!\",\n    \"previewTips\": \"Dünyanın çalışmanızı görmesine izin verin\",\n    \"update\": \"Güncelle\",\n    \"unPublish\": \"Yayından kaldır\",\n    \"unPublishSuccess\": \"Üs başarıyla yayından kaldırıldı!\",\n    \"unPublishConfirmTitle\": \"Yayından kaldırmayı onayla\",\n    \"unPublishConfirmDescription\": \"Bu üssü yayından kaldırmak istediğinizden emin misiniz? Artık topluluk şablon merkezinde görünmeyecek.\",\n    \"usageCount\": \"Kullanım sayısı: \",\n    \"uploadCover\": \"Kapak resmi yüklemek için tıklayın\",\n    \"changeCover\": \"Kapağı değiştirmek için tıklayın\",\n    \"uploading\": \"Resim yükleniyor...\",\n    \"uploadSuccess\": \"Resim başarıyla yüklendi\",\n    \"uploadFailed\": \"Yükleme başarısız\",\n    \"invalidImageType\": \"Lütfen bir resim dosyası seçin\",\n    \"tips\": {\n      \"publishValidation\": \"başlık ve açıklama gereklidir\",\n      \"atLeastOneNode\": \"Yayınlamak için en az bir düğüm seçin\"\n    },\n    \"urlCopied\": \"URL panoya kopyalandı!\",\n    \"urlCopiedForDiscord\": \"URL panoya kopyalandı! Discord'a yapıştırabilirsiniz.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Resmi olarak öne çıkan olarak seçildi. Şablonunuz daha fazla görünürlük kazanacak.\",\n    \"unfeaturedTip\": \"Henüz öne çıkarılmadı. Tavsiye edilme şansı için geliştirmeye devam edin. Daha fazla kişinin çalışmanızı görmesine izin verin.\",\n    \"publishSuccessDescription\": \"Çalışmanızı dünyayla paylaşın\",\n    \"shareWith\": \"Şununla paylaş\",\n    \"unpublishedApps\": {\n      \"title\": \"Yayınlanmamış Uygulamalar Tespit Edildi\",\n      \"description\": \"Yayınlanmamış uygulamalar şablon önizleme hatalarına neden olabilir. Şimdi yayınlayın veya devam edin.\",\n      \"publishAll\": \"Tümünü Yayınla\",\n      \"publish\": \"Yayınla\",\n      \"published\": \"Yayınlandı\",\n      \"publishing\": \"Yayınlanıyor...\",\n      \"publishFailed\": \"Yayınlama başarısız\",\n      \"publishFailedTip1\": \"Kaynak uygulamanın başarıyla yayınlanıp yayınlanamayacağını kontrol edin\",\n      \"publishFailedTip2\": \"Bu şablonu yeniden yayınlamayı deneyin\",\n      \"notPublished\": \"Yayınlanmadı\",\n      \"ignoreAndContinue\": \"Yoksay ve Devam Et\",\n      \"goToFix\": \"Düzeltmeye Git\",\n      \"redeploy\": \"Yeniden Dağıt\",\n      \"unnamedApp\": \"Adsız Uygulama\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Çok yakında\",\n    \"viewFilterInShare\": \"Bu görünüm bir görünüm paylaşım bağlantısında kullanılıyor. Görünüm yapılandırmasındaki değişiklikler görünüm paylaşım bağlantısını da değiştirecektir.\",\n    \"createFieldButtonText\": \"Yeni bir <0/> alanı oluştur\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Paylaş\",\n        \"statusLabel\": \"Görünümü web'de paylaş\",\n        \"noPermission\": \"Görünümü paylaşma izni yok\",\n        \"shareLink\": \"Paylaşım Bağlantısı\",\n        \"copied\": \"Kopyalandı\",\n        \"genLink\": \"Yeni bağlantı oluştur\",\n        \"allowCopy\": \"İzleyicilerin bu görünümden veri kopyalamasına izin ver\",\n        \"showAllFields\": \"Genişletilmiş kayıtlarda tüm alanları göster\",\n        \"restrict\": \"Şifre ile kısıtla\",\n        \"tips\": \"Bağlantıya sahip kişiler görünümü görebilir.\",\n        \"passwordTitle\": \"Bir şifre girin\",\n        \"passwordTips\": \"Paylaşılan görünümlere erişim için şifre kısıtlamaları\",\n        \"embed\": \"Yerleştir\",\n        \"embedPreview\": \"Yerleştirme önizlemesi\",\n        \"hideToolbar\": \"Araç çubuğunu gizle\",\n        \"URLSetting\": \"URL parametreleri yapılandırması\",\n        \"URLSettingDescription\": \"Aşağıdaki ayarları değiştirmek paylaşılan bağlantıları etkilemez. Yeni parametrelerin etkili olması için bağlantıyı yeni parametrelerle kopyalamanız gerekir\",\n        \"cancel\": \"İptal\",\n        \"save\": \"Kaydet\",\n        \"requireLogin\": \"Giriş yapılmasını gerektir\"\n      },\n      \"extensions\": {\n        \"label\": \"Uzantılar\",\n        \"graph\": \"Grafik\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"Veritabanı Bağlantısı\"\n      },\n      \"personalView\": {\n        \"personal\": \"Kişisel\",\n        \"tip\": \"Etkinleştirildiğinde, görünüm ayarları yalnızca size uygulanacaktır\",\n        \"collaborative\": \"İş Birlikçi\",\n        \"dialog\": {\n          \"title\": \"Kişisel moddan çık\",\n          \"description\": \"Kişisel görünüm yapılandırması, gerçek zamanlı iş birliği durumuna geri yüklenecek. Ayrıca kişisel görünüm ayarlarını kaydedip herkese senkronize edebilirsiniz.\",\n          \"cancelText\": \"Çık ve senkronize et\",\n          \"confirmText\": \"Çıkışı onayla\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Hoş Geldiniz\",\n    \"emptyTitle\": \"Veritabanınızı Oluşturmaya Başlayın\",\n    \"description\": \"Kaynak eklemek için kenar çubuğundaki \\\"+\\\" düğmesine tıklayın\",\n    \"help\": \"Daha fazla bilgi için <HelpCenter /> adresini ziyaret edin\",\n    \"helpCenter\": \"Yardım Merkezi\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Kayıt bağlanamıyor: aynı grupta bu kayıt zaten başka bir kayıt tarafından bağlanmış. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.\",\n      \"one_many_duplicate\": \"Kayıt bağlanamıyor: bu kayıt zaten başka bir kayda bağlı. Bire-çok ilişkilerde her alt kayıt yalnızca bir üst kayda ait olabilir.\",\n      \"one_one_duplicate\": \"Kayıt bağlanamıyor: hedef kayıt bire-bir ilişkide zaten başka bir kayıt tarafından bağlanmış.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"\\\"{{tableName}}\\\" tablosunda en fazla {{maxFieldCount}} alan olabilir.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Alan Yönetimi\",\n    \"fieldManagementDesc\": \"Mevcut tablonun tüm alanları için detaylı özellikler\",\n    \"advancedProps\": \"Gelişmiş özellikler\",\n    \"hide\": \"gizle\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Etiket\"\n      },\n      \"longText\": {\n        \"title\": \"Notlar\"\n      },\n      \"number\": {\n        \"title\": \"Sayı\",\n        \"formatType\": \"Format Türü\",\n        \"currencySymbol\": \"Para Birimi Sembolü\",\n        \"defaultSymbol\": \"₺\",\n        \"precision\": \"Hassasiyet\",\n        \"decimalExample\": \"Sayı (123)\",\n        \"currencyExample\": \"Para Birimi (₺100)\",\n        \"percentExample\": \"Yüzde (%20)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Durum\",\n        \"options\": {\n          \"todo\": \"Yapılacak\",\n          \"inProgress\": \"Devam Ediyor\",\n          \"done\": \"Tamamlandı\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Etiketler\"\n      },\n      \"attachment\": {\n        \"title\": \"Ekler\"\n      },\n      \"user\": {\n        \"title\": \"İşbirlikçi\"\n      },\n      \"date\": {\n        \"title\": \"Tarih\",\n        \"dateFormatting\": \"Tarih Biçimlendirme\",\n        \"timeFormatting\": \"Saat Biçimlendirme\",\n        \"timeZone\": \"Saat Dilimi\",\n        \"yearMonth\": \"Yıl/Ay\",\n        \"monthDay\": \"Ay/Gün\",\n        \"year\": \"Yıl\",\n        \"month\": \"Ay\",\n        \"day\": \"Gün\",\n        \"local\": \"Yerel\",\n        \"friendly\": \"Kullanıcı Dostu\",\n        \"us\": \"ABD\",\n        \"european\": \"Avrupa\",\n        \"asia\": \"Asya\",\n        \"custom\": \"Özel\",\n        \"12Hour\": \"12 saat\",\n        \"24Hour\": \"24 saat\",\n        \"noDisplay\": \"Gösterme\"\n      },\n      \"autoNumber\": {\n        \"title\": \"Kimlik\"\n      },\n      \"createdTime\": {\n        \"title\": \"Oluşturulma zamanı\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Son değiştirilme zamanı\"\n      },\n      \"createdBy\": {\n        \"title\": \"Oluşturan\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Son değiştiren\"\n      },\n      \"rating\": {\n        \"title\": \"Değerlendirme\"\n      },\n      \"checkbox\": {\n        \"title\": \"Tamamlandı\"\n      },\n      \"button\": {\n        \"title\": \"Düğme\",\n        \"label\": \"Düğme metni\",\n        \"color\": \"Düğme rengi\",\n        \"limitCount\": \"Tıklama sayısını sınırla\",\n        \"resetCount\": \"Tıklama sayısını sıfırlamaya izin ver\",\n        \"maxCount\": \"Maksimum tıklama sayısı\",\n        \"automation\": \"Otomasyon\",\n        \"customAutomation\": \"Özel otomasyon\",\n        \"clickConfirm\": \"Tıklamadan önce onayla\",\n        \"confirmTitle\": \"Başlık\",\n        \"confirmDescription\": \"Açıklama\",\n        \"confirmButtonText\": \"Onay düğmesi metni\"\n      },\n      \"formula\": {\n        \"title\": \"Hesaplama\",\n        \"formula\": \"Formül\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} ({{linkFieldName}} üzerinden)\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} ({{tableName}} üzerinden filtrelenmiş)\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Toplama ({{linkFieldName}} üzerinden)\",\n        \"rollup\": \"Toplama\",\n        \"selectAnRollupFunction\": \"Bir toplama fonksiyonu seçin\",\n        \"func\": {\n          \"and\": \"VE\",\n          \"arrayCompact\": \"DİZİ_SIKIŞTIR\",\n          \"arrayJoin\": \"DİZİ_BİRLEŞTİR\",\n          \"arrayUnique\": \"DİZİ_BENZERSİZ\",\n          \"average\": \"ORTALAMA\",\n          \"concatenate\": \"BİRLEŞTİR\",\n          \"count\": \"SAY\",\n          \"countA\": \"SAY_A\",\n          \"countAll\": \"TÜMÜNÜ_SAY\",\n          \"max\": \"MAKSİMUM\",\n          \"min\": \"MİNİMUM\",\n          \"or\": \"VEYA\",\n          \"sum\": \"TOPLAM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Tüm değerler doğruysa true döndürür\",\n          \"arrayCompact\": \"Diziden boş dizeleri ve null değerleri kaldırır. 'false' ve bir veya daha fazla boşluk karakteri içeren dizeleri korur.\",\n          \"arrayJoin\": \"Tüm değerleri tek bir virgülle ayrılmış dizede birleştirir.\",\n          \"arrayUnique\": \"Yalnızca benzersiz öğeleri döndürür.\",\n          \"average\": \"Değerlerin aritmetik ortalaması.\",\n          \"concatenate\": \"Metin değerlerini tek bir metin değerinde birleştirir.\",\n          \"count\": \"Yalnızca boş olmayan sayısal değerleri sayar. Tüm kayıtları saymak için COUNTALL kullanın.\",\n          \"countA\": \"Boş olmayan değerlerin sayısını sayar. Bu fonksiyon hem sayısal hem de metin değerlerini sayar.\",\n          \"countAll\": \"Boş kayıtlar dahil tüm değerleri sayar.\",\n          \"max\": \"Verilen sayıların en büyüğünü döndürür.\",\n          \"min\": \"Verilen sayıların en küçüğünü döndürür.\",\n          \"or\": \"Değerlerden herhangi biri doğruysa true döndürür.\",\n          \"sum\": \"Değerleri toplar.\",\n          \"xor\": \"Yalnızca tek sayıda değer doğruysa true döndürür.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} koşullu toplama\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Alan Ekle\",\n      \"editField\": \"Alan Düzenle\",\n      \"insertField\": \"Alan Ekle\",\n      \"graph\": \"Grafik\",\n      \"defaultValue\": \"Varsayılan değer\",\n      \"reset\": \"Sıfırla\",\n      \"fieldUpdated\": \"Alan güncellendi\",\n      \"fieldCreated\": \"Alan oluşturuldu\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Bunu gerçekleştirmek istediğinizden emin misiniz?\",\n      \"addDescription\": \"Açıklama Ekle\",\n      \"description\": \"Açıklama\",\n      \"descriptionPlaceholder\": \"Bu alanı açıklayın (isteğe bağlı)\",\n      \"type\": \"Tür\",\n      \"showAs\": \"Gösterim Şekli\",\n      \"color\": \"Renk\",\n      \"number\": \"Sayı\",\n      \"chartBar\": \"Çubuk Grafik\",\n      \"chartLine\": \"Çizgi Grafik\",\n      \"ring\": \"Halka\",\n      \"bar\": \"Çubuk\",\n      \"text\": \"Metin\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"URL\",\n      \"email\": \"E-posta\",\n      \"phone\": \"Telefon\",\n      \"maxNumber\": \"Maksimum Sayı\",\n      \"showNumber\": \"Sayıyı Göster\",\n      \"autoFillDate\": \"Mevcut tarihle otomatik doldur\",\n      \"createSymmetricLink\": \"Bağlantı tablosunda geri bağlantı alanı oluştur\",\n      \"allowLinkMultipleRecords\": \"Çoklu seçime izin ver\",\n      \"allowLinkToDuplicateRecords\": \"Kayıtların birden fazla kez seçilmesine izin ver\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Kayıtların birden fazla kez seçilmesine izin ver\",\n      \"oneToOne\": \"bire-bir\",\n      \"oneToMany\": \"bire-çok\",\n      \"manyToOne\": \"çoka-bir\",\n      \"manyToMany\": \"çoka-çok\",\n      \"self\": \"Kendisi\",\n      \"selectTable\": \"Tablo seç...\",\n      \"selectBase\": \"Veritabanı seç...\",\n      \"linkFromAnotherBase\": \"Harici veritabanından bağlantı\",\n      \"inSelfLink\": \"kendi içinde bağlantı\",\n      \"betweenTwoTables\": \"iki tablo arasında\",\n      \"linkTipMessage\": \"İpucu: bu yapılandırma<br></br> <b>{{relationship}}</b> ilişkisini temsil eder <span>{{linkType}}</span>\",\n      \"style\": \"Stil\",\n      \"maximum\": \"Maksimum\",\n      \"addOption\": \"Seçenek ekle\",\n      \"allowMultiUsers\": \"Birden fazla kullanıcı eklemeye izin ver\",\n      \"notifyUsers\": \"Seçildiklerinde kullanıcıları bilgilendir\",\n      \"searchTable\": \"Tablo ara...\",\n      \"calculating\": \"Hesaplanıyor...\",\n      \"doSaveChanges\": \"Yaptığınız değişiklikleri kaydetmek istiyor musunuz?\",\n      \"linkFieldToLookup\": \"Arama için kullanılacak bağlantılı kayıt alanı\",\n      \"lookupToTable\": \"Bağlantı veritabanındaki <bold>{{tableName}}</bold> alanı\",\n      \"rollupToTable\": \"Bağlantı veritabanındaki <bold>{{tableName}}</bold> alanı\",\n      \"selectField\": \"Bir alan seç...\",\n      \"linkTable\": \"Bağlantı tablosu\",\n      \"linkBase\": \"Bağlantı veritabanı\",\n      \"tableNoPermission\": \"İzin olmayan tablo\",\n      \"baseNoPermission\": \"İzin olmayan veritabanı\",\n      \"noLinkTip\": \"Aranacak bağlantılı kayıt yok. Başka bir kayda bağlantı alanı ekleyin, ardından aramanızı tekrar yapılandırmayı deneyin.\",\n      \"fieldValidationRules\": \"Alan değeri doğrulama kuralları\",\n      \"enableValidateFieldUnique\": \"Yinelenen değerleri yasakla\",\n      \"enableValidateFieldNotNull\": \"Gerekli\",\n      \"knowMore\": \"daha fazla bilgi\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Alanlara göre kayıtları göster\",\n      \"filterByView\": \"Görünüme göre kayıtları filtrele\",\n      \"filter\": \"Kayıtları filtrele\",\n      \"hideFields\": \"Alanları gizle\",\n      \"moreOptions\": \"Daha fazla seçenek\",\n      \"allowNewOptionsWhenEditing\": \"Düzenlerken yeni seçeneklere izin ver\",\n      \"deleteField\": {\n        \"title\": \"Alanı Sil\",\n        \"simpleConfirm\": \"<b>{{fieldName}}</b> alanını silmek istediğinizden emin misiniz?\",\n        \"withDependencies\": \"<b>{{fieldName}}</b> alanını silmek aşağıdaki alanları etkileyecektir:\",\n        \"affectedFields\": \"Etkilenen alanlar:\",\n        \"fieldsToDelete\": \"Silinecek alanlar ({{count}})\",\n        \"unviewedHint\": \"{{count}} alan incelenmedi\",\n        \"deleteCount\": \"{{count}} alanı sil\",\n        \"noAffectedFields\": \"Bu alan diğer alanlar tarafından referans alınmıyor.\",\n        \"riskIdentified\": \"Risk tespit edildi({{count}})\",\n        \"noDependencies\": \"Bağımlılık yok({{count}})\",\n        \"safeToDelete\": \"Güvenle silinebilir\",\n        \"safeToDeleteDesc\": \"Bu alan diğer alanlar tarafından referans alınmıyor, güvenle silinebilir\",\n        \"affectedItems\": \"Etkilenen öğeler\",\n        \"type\": \"Tür\",\n        \"source\": \"Kaynak\",\n        \"sourceTable\": \"Kaynak tablo\",\n        \"typeField\": \"Alan\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Seçtiğiniz tablodaki kayıtlara bağlantı oluşturun\",\n      \"singleLineText\": \"Metin girin veya her yeni hücreyi varsayılan bir değerle doldurun.\",\n      \"longText\": \"Birden fazla satır metin girin.\",\n      \"attachment\": \"Resimler ekleyin veya yapay zeka ile oluşturun, ya da görüntülenecek veya indirilecek belgeler veya diğer dosyalar yükleyin.\",\n      \"checkbox\": \"Durumu belirtmek için işaretleyin veya işareti kaldırın.\",\n      \"multipleSelect\": \"Listeden bir veya daha fazla önceden tanımlanmış seçenek seçin.\",\n      \"singleSelect\": \"Listeden bir önceden tanımlanmış seçenek seçin veya her yeni hücreyi varsayılan bir seçenekle doldurun.\",\n      \"user\": \"Bir kayda kullanıcı ekleyin.\",\n      \"date\": \"Bir tarih girin (örn. 11/12/2023) veya takvimden seçin.\",\n      \"number\": \"Bir sayı girin veya her yeni hücreyi varsayılan bir değerle doldurun.\",\n      \"duration\": \"Saat, dakika veya saniye cinsinden bir süre girin (örn. 1:23).\",\n      \"rating\": \"Önceden tanımlanmış bir ölçekte değerlendirme ekleyin.\",\n      \"formula\": \"Alanlara dayalı değerler hesaplayın.\",\n      \"rollup\": \"Bağlantılı kayıtlardan veri özetleyin.\",\n      \"conditionalLookup\": \"Tanımladığınız filtreleri karşılayan bağlı değerleri gösterir.\",\n      \"count\": \"Bağlantılı kayıtların sayısını sayın.\",\n      \"createdTime\": \"Her kaydın oluşturulduğu tarih ve saati görün.\",\n      \"lastModifiedTime\": \"Bir kayıttaki bazı veya tüm alanlarda yapılan en son düzenlemenin tarih ve saatini görün.\",\n      \"createdBy\": \"Kaydı hangi kullanıcının oluşturduğunu görün.\",\n      \"lastModifiedBy\": \"Bir kayıttaki bazı veya tüm alanlarda en son düzenlemeyi hangi kullanıcının yaptığını görün.\",\n      \"autoNumber\": \"Her kayıt için otomatik olarak benzersiz artan sayılar oluşturun.\",\n      \"button\": \"Özelleştirilmiş bir eylem tetikleyin.\",\n      \"lookup\": \"Bağlantılı bir kayıttaki bir alanın değerlerini görün.\"\n    },\n    \"fieldName\": \"Alan Adı\",\n    \"fieldNameOptional\": \"Alan Adı (İsteğe bağlı)\",\n    \"fieldType\": \"Alan Türü\",\n    \"aiConfig\": {\n      \"title\": \"AI Yapılandırması\",\n      \"type\": {\n        \"summary\": \"Özet\",\n        \"translation\": \"Çevir\",\n        \"extraction\": \"Bilgi Çıkarma\",\n        \"improvement\": \"İyileştir\",\n        \"tag\": \"Akıllı Etiket\",\n        \"classification\": \"Akıllı Sınıflandırma\",\n        \"customization\": \"Özelleştir\",\n        \"imageGeneration\": \"Görüntü Oluşturma\",\n        \"rating\": \"Görüntü Değerlendirme\"\n      },\n      \"label\": {\n        \"type\": \"AI Eylem Türü\",\n        \"model\": \"AI Model\",\n        \"targetLanguage\": \"Hedef Dil\",\n        \"sourceField\": \"Kaynak Alan\",\n        \"sourceFieldForTag\": \"Bir alan seçin, oluşturulan etiketlerle eşleştirin\",\n        \"sourceFieldForClassify\": \"Bir alan seçin, oluşturulan sınıflandırmalarla eşleştirin\",\n        \"attachPrompt\": \"Gereksinimleri ekleyin\",\n        \"prompt\": \"Özel Prompt\",\n        \"sourceFieldForAttachment\": \"Ek alanın kaynak alanı\",\n        \"imageSize\": \"Görüntü boyutu\",\n        \"imageQuality\": \"Görüntü kalitesi\",\n        \"imageCount\": \"Görüntü sayısı\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"İçerik özetini\",\n        \"translate\": \"Kısa ve anlaşılır, hafif tonlu çeviri\",\n        \"extractInfo\": \"Email, telefon, ad, adres...\",\n        \"extractDate\": \"Başlangıç saatini çıkar\",\n        \"improveText\": \"Resmi, arkadaşça, komik...\",\n        \"attachPromptForTag\": \"Üç etiketden fazla izin verilmiyor\",\n        \"attachPromptForClassify\": \"“Devam Ediyor” olarak “Risk Yok” olarak sınıflandır\",\n        \"attachPrompt\": \"Ek gereksinimleri girin\",\n        \"prompt\": \"Özel Prompt\",\n        \"type\": \"AI Eylemi Seçin\",\n        \"targetLanguage\": \"İngilizce, Çince, Fransızca...\",\n        \"imageSize\": \"Lütfen görüntü boyutunu girin\",\n        \"imageQuality\": \"Lütfen görüntü kalitesini girin\",\n        \"attachPromptForImageGeneration\": \"Görüntünün canlı ve doğal olması gerekiyor\",\n        \"attachPromptForRating\": \"Görüntü kalitesini değerlendirin\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Düşük\",\n        \"medium\": \"Orta\",\n        \"high\": \"Yüksek\"\n      },\n      \"autoFill\": {\n        \"title\": \"Otomatik Güncelle\",\n        \"tip\": \"Etkinleştirdiğinizde, mevcut alan, AI yapılandırmasının içerik değişiklikleriyle senkronize olarak güncellenecektir\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Tüm kayıtları güncelle\",\n        \"description\": \"Mevcut görünümdeki tüm kayıtlar güncellenecektir, mevcut alanın oluşturduğu tüm ilgili verileri içerecektir\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Tüm sütunu oluşturulsun mu?\",\n        \"description\": \"Tüm sütunu oluşturmak {{rowCount}} satırı güncelleyecektir. Bu işlem çok sayıda yapay zeka kaynağı tüketebilir.\",\n        \"saveConfigOnly\": \"Yalnızca yapılandırmayı kaydet\",\n        \"generate\": \"Oluştur\",\n        \"generateFailed\": \"Oluşturma başarısız oldu\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Ek alan ekle\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Yeni tablo\",\n    \"rename\": \"Yeniden adlandır\",\n    \"design\": \"Tasarım\",\n    \"tableRecordHistory\": \"Tablo kayıt geçmişi\",\n    \"deleteConfirm\": \"\\\"{{tableName}}\\\" tablosunu silmek istediğinizden emin misiniz?\",\n    \"dbTableName\": \"Fiziksel veritabanındaki tablo adı\",\n    \"schemaName\": \"Fiziksel veritabanındaki şema adı\",\n    \"tableInfo\": \"Tablo Bilgisi\",\n    \"tableInfoDetail\": \"Tablo için temel bilgiler\",\n    \"typeOfDatabase\": \"Veritabanı türü\",\n    \"descriptionForTable\": \"Bu tablo için açıklama\",\n    \"nameForTable\": \"Bu tablo için ad\",\n    \"deleteTip1\": \"Diğer tablolardaki bu tabloyla bağlantılı bağlantı alanları silinecek.\",\n    \"deleteTip2\": \"Bu tablo silindikten sonra çöp kutusundan geri yüklenebilir.\",\n    \"operator\": {\n      \"createBlank\": \"Yeni tablo\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Kopyala ve yapıştır yalnızca HTTPS veya localhost'ta çalışır\",\n      \"copyAndPasteBrowser\": \"Bu tarayıcıda kopyala ve yapıştır desteklenmiyor\",\n      \"copying\": \"Kopyalanıyor...\",\n      \"copySuccessful\": \"Kopyalama başarılı\",\n      \"copyFailed\": \"Kopyalama başarısız\",\n      \"pasting\": \"Yapıştırılıyor...\",\n      \"pasteSuccessful\": \"Yapıştırma başarılı\",\n      \"pasteFailed\": \"Yapıştırma başarısız\",\n      \"filling\": \"Doldurma...\",\n      \"fillSuccessful\": \"Doldurma başarılı\",\n      \"fillFailed\": \"Doldurma başarısız\",\n      \"clearing\": \"Temizleniyor...\",\n      \"clearSuccessful\": \"Temizleme başarılı\",\n      \"deleteFieldConfirmTitle\": \"Aşağıdaki alanları silmek üzeresiniz\",\n      \"deleting\": \"Siliniyor...\",\n      \"deleteSuccessful\": \"Silme başarılı\",\n      \"pasteFileFailed\": \"Dosyalar yalnızca bir ek alanına yapıştırılabilir\",\n      \"copyError\": {\n        \"noFocus\": \"Lütfen pencereyi değiştirmeyin\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Tablo etiketi\",\n      \"effectCells\": \"Etkilenebilecek hücreler\",\n      \"estimatedTime\": \"Tahmini süre\",\n      \"linkFieldCount\": \"Etkilenebilecek bağlantılı alan sayısı\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Yükle\",\n      \"import\": \"İçe Aktar\",\n      \"localFile\": \"Yerel Dosyalar\",\n      \"linkUrl\": \"Bağlantı(URL)\",\n      \"linkUrlInputTitle\": \"URL'den dosya ekle\",\n      \"importTitle\": \"Yeni bir tablo oluştur\",\n      \"incrementImportTitle\": \"İçe Aktar — \",\n      \"optionsTitle\": \"İçe aktarma seçeneği\",\n      \"primitiveFields\": \"Temel Alanlar\",\n      \"importFields\": \"İçe Aktarma Alanı\",\n      \"primaryField\": \"Birincil Alan\",\n      \"tipsTitle\": \"İpuçları\",\n      \"confirm\": \"Onayla ve devam et\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Diğer kaynaklardan ekle\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"Csv dosyası\",\n      \"importCsvData\": \"CSV verilerini içe aktar\",\n      \"importExcelData\": \"Microsoft Excel verilerini içe aktar\",\n      \"cancel\": \"İptal\",\n      \"leave\": \"Ayrıl\",\n      \"downAsCsv\": \"csv İndir\",\n      \"importData\": \"Veri İçe Aktar\",\n      \"duplicate\": \"Çoğalt\",\n      \"duplicating\": \"Çoğaltılıyor...\",\n      \"duplicateSuccess\": \"Başarıyla çoğaltıldı\",\n      \"duplicateFailed\": \"Çoğaltılamadı\",\n      \"importing\": \"İçe aktarılıyor\",\n      \"includeRecords\": \"Kayıtları dahil et\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Yüklemek için dosyayı bu alana tıklayın veya sürükleyin\",\n      \"leaveTip\": \"Verileriniz yine de içe aktarılacak.\",\n      \"fileExceedSizeTip\": \"Bu tür dosya boyutu şu sınırı aşıyor:\",\n      \"analyzing\": \"analiz ediliyor\",\n      \"importing\": \"İçe aktarılıyor\",\n      \"notSupportFieldType\": \"Alan türü desteklenmiyor\",\n      \"resultEmpty\": \"Sonuç bulunamadı.\",\n      \"searchPlaceholder\": \"Ara...\",\n      \"importAlert\": \"İçe aktarma başladıktan sonra, başarıyla tamamlanana veya başarısızlık nedeniyle sonlandırılana kadar durdurulamaz. İçe aktarma durumu tablonun sağ üst köşesinde görüntülenir. İçe aktarma işleminin sonuçları tamamlandığında size bildirilecektir. Hatalara neden olabileceğinden içe aktarma sırasında alanı güncellememeli.\",\n      \"noTips\": \"Anladım, bir daha gösterme\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Alan türlerini otomatik seç\",\n      \"useFirstRowAsHeaderOptionName\": \"İlk satırı başlık olarak kullan\",\n      \"importDataOptionName\": \"Veri İçe Aktar\",\n      \"sheetKey\": \"SayfaAdı: \",\n      \"excludeFirstRow\": \"İçe aktarmada ilk satırı hariç tut\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Alan\",\n      \"error\": {\n        \"urlEmptyTip\": \"URL boş olmamalı!\",\n        \"errorFileFormat\": \"Dosya formatı yanlış!\",\n        \"uniqueFieldName\": \"Alan adı benzersiz olmalı!\",\n        \"fieldNameEmpty\": \"Alan adı boş olmamalı!\",\n        \"atLeastAImportField\": \"Lütfen en az bir içe aktarma alanı ayarlayın\",\n        \"urlValidateTip\": \"URL ayrıştırılamadı. Farklı bir URL deneyin!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"İçe aktarma\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Csv İndir\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Yeni kayıt ekle\",\n    \"prefillingRowTooltip\": \"Lütfen yeni kayıt verilerini aşağıya girin. Bu satırın dışına tıkladığınızda kayıt otomatik olarak kaydedilecektir.\",\n    \"presortRowTitle\": \"Bu kayıt sıralama kuralları nedeniyle filtrelendi veya taşındı\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Alanlar\",\n    \"addAll\": \"Tümünü Ekle\",\n    \"removeAll\": \"Tümünü Kaldır\",\n    \"hideFieldTip\": \"Alanı buraya gizle\",\n    \"unableAddFieldTip\": \"Bu tür alan eklenemiyor\",\n    \"removeFromFormTip\": \"Formdan kaldır\",\n    \"descriptionPlaceholder\": \"Form açıklaması girin\",\n    \"dragToFormTip\": \"Alanı forma eklemek için buraya sürükleyin\",\n    \"protectedFieldTip\": \"Bu alan \\\"gerekli\\\" alan olarak ayarlanmıştır ve form görünümünde kaldırılamaz. Lütfen alan ayarlarından değiştirin.\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"Alan adını gizle\",\n      \"customizeCards\": \"Kartları özelleştir\",\n      \"stackedBy\": \"<0/> ile yığınlandı\",\n      \"chooseStackingField\": \"Bir yığınlama alanı seçin\",\n      \"chooseStackingFieldDescription\": \"Bu kanban görünümü için hangi alanı kullanmak istersiniz? Kayıtlarınız bu alana göre yığınlanacak.\",\n      \"hideEmptyStack\": \"Boş yığını gizle\",\n      \"imageSetting\": \"Görsel Ayarı\",\n      \"fit\": \"Sığdır\",\n      \"noImage\": \"Görsel yok\",\n      \"chooseAttachmentField\": \"Ek alanı seç\"\n    },\n    \"stack\": {\n      \"addStack\": \"Yığın Ekle\",\n      \"noCards\": \"Kart yok\",\n      \"uncategorized\": \"Kategorisiz\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Yığını daralt\",\n      \"renameStack\": \"Yığını yeniden adlandır\",\n      \"deleteStack\": \"Yığını sil\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Üste kart ekle\",\n      \"insertCardBelow\": \"Alta kart ekle\",\n      \"expandCard\": \"Kartı genişlet\",\n      \"deleteCard\": \"Kartı sil\",\n      \"duplicateCard\": \"Kartı çoğalt\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Takvim Yapılandırması\",\n      \"startDateField\": \"Başlangıç tarihi alanı\",\n      \"endDateField\": \"Bitiş tarihi alanı\",\n      \"titleField\": \"Başlık alanı\",\n      \"colorField\": \"Renk alanı\",\n      \"colorType\": \"Renk görüntüleme\",\n      \"customColor\": \"Rengi özelleştir\",\n      \"alignWithRecords\": \"Kayıtlarla hizala\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Bir renk alanı seçin\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Başlangıç tarihi\",\n      \"endDate\": \"Bitiş tarihi\",\n      \"notAdd\": \"Ekleme\",\n      \"addDateField\": \"Tarih alanları ekle\",\n      \"content\": \"Takvim görünümü programı oluşturmak için tablonun iki tarih alanına ihtiyacı var: başlangıç tarihi ve bitiş tarihi\"\n    },\n    \"moreLinkText\": \"Tüm {{count}} kaydı göster\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Üste <input /> kayıt ekle\",\n    \"insertRecordBelow\": \"Alta <input /> kayıt ekle\",\n    \"copyCells\": \"Hücreleri kopyala\",\n    \"deleteRecord\": \"Kaydı sil\",\n    \"deleteAllSelectedRecords\": \"Seçili tüm kayıtları sil\",\n    \"editField\": \"Alanı düzenle\",\n    \"insertFieldLeft\": \"Sola ekle\",\n    \"insertFieldRight\": \"Sağa ekle\",\n    \"freezeUpField\": \"Bu alana kadar dondur\",\n    \"hideField\": \"Alanı gizle\",\n    \"deleteField\": \"Alanı sil\",\n    \"deleteAllSelectedFields\": \"Seçili tüm alanları sil\",\n    \"filterField\": \"Bu alana göre filtrele\",\n    \"sortField\": \"Bu alana göre sırala\",\n    \"groupField\": \"Bu alana göre grupla\",\n    \"autoFill\": \"Tüm kayıtları güncelle\",\n    \"groupMenuTitle\": \"Grup menüsü\",\n    \"expandGroup\": \"Bu grup ve alt gruplarını genişlet\",\n    \"collapseGroup\": \"Bu grup ve alt gruplarını daralt\",\n    \"expandAllGroups\": \"Tüm grupları genişlet\",\n    \"collapseAllGroups\": \"Tüm grupları daralt\",\n    \"addToChat\": \"Sohbete ekle\"\n  },\n  \"connection\": {\n    \"title\": \"Veritabanı Bağlantısı\",\n    \"description\": \"Veritabanına doğrudan veritabanı bağlantısı üzerinden erişebilirsiniz, mevcut veritabanı altındaki tüm tablolar dahil\",\n    \"noPermission\": \"Veritabanına erişim izniniz yok\",\n    \"connectionCountTip\": \"Maksimum veritabanı bağlantısı sayısı <b>{{max}}</b> ve mevcut bağlantı sayısı <b>{{current}}</b>\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Kayıt ekle\",\n    \"searchView\": \"Görünüm ara...\",\n    \"dragToolTip\": \"Otomatik sıralama açık, manuel sürükleme kullanılamaz\",\n    \"insertToolTip\": \"Otomatik sıralama açık, sırayla ekleme kullanılamaz\",\n    \"action\": {\n      \"rename\": \"Görünümü yeniden adlandır\",\n      \"delete\": \"Görünümü sil\",\n      \"enable\": \"Etkinleştir\"\n    },\n    \"category\": {\n      \"table\": \"Izgara Görünümü\",\n      \"form\": \"Form Görünümü\",\n      \"kanban\": \"Kanban Görünümü\",\n      \"gallery\": \"Galeri Görünümü\",\n      \"calendar\": \"Takvim Görünümü\"\n    },\n    \"crash\": {\n      \"title\": \"Çöktü!\",\n      \"description\": \"Bu görünüm bozuk. Yenileme hala başarısız olursa, lütfen bizimle iletişime geçin. support@teable.ai\"\n    },\n    \"addPluginView\": \"Eklenti Görünümü Ekle\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"alan({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"Bu görünüm kilitlenmiştir. Kişisel modu etkinleştirerek görünüm seçeneklerini düzenleyebilirsiniz ve değişiklikler sadece kendinize etkili olacaktır.\"\n    },\n    \"noView\": \"Görünüm yok\"\n  },\n  \"lastModifiedTime\": \"Son değiştirilme zamanı\",\n  \"lastModify\": \"Son değişiklik: \",\n  \"pasteNewRecords\": {\n    \"title\": \"Birden fazla kayıt eklemek istiyor musunuz?\",\n    \"description\": \"{{count}} kayıt tabloya eklenecek.\"\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Tüm ekleri indir\",\n      \"loading\": \"Önizleme yükleniyor...\",\n      \"rowsWithAttachments\": \"{{count}} satır ek içeriyor\",\n      \"totalAttachments\": \"{{count}} ek\",\n      \"totalSize\": \"Toplam boyut: {{size}}\",\n      \"startDownload\": \"İndirmeyi başlat\",\n      \"confirmTitle\": \"{{count}} dosya indir\",\n      \"confirmDescription\": \"Toplam boyut: {{size}}. Dosyalar ZIP dosyasına sıkıştırılacak.\",\n      \"confirm\": \"İndir\",\n      \"cancel\": \"İptal\",\n      \"downloading\": \"İndiriliyor...\",\n      \"downloadingFile\": \"İndiriliyor: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"İndirme tamamlandı\",\n      \"cancelled\": \"İndirme iptal edildi\",\n      \"noAttachments\": \"İndirilecek ek yok\",\n      \"error\": \"İndirme başarısız\",\n      \"errorPartial\": \"{{failedCount}} dosya indirilemedi\",\n      \"requireHttps\": \"Toplu indirme HTTPS gerektirir. Lütfen HTTPS veya localhost üzerinden erişin.\",\n      \"advancedOptions\": \"Gelişmiş seçenekler\",\n      \"namingFieldLabel\": \"Ek adı öneki\",\n      \"selectField\": \"Varsayılan: ek dizini\",\n      \"groupByRow\": \"Klasörlere arşivle\",\n      \"groupByRowTip\": \"Bir satırda birden fazla ek varsa, aynı klasöre yerleştirilir; yalnızca bir eki olan satırlar klasör oluşturmaz.\"\n    }\n  },\n  \"baseShare\": {\n    \"title\": \"Tabanı Paylaş\",\n    \"shareTitle\": \"Paylaş\",\n    \"shareToWeb\": \"Web'de paylaş\",\n    \"description\": \"\\\"{{baseName}}\\\" başkalarıyla paylaş\",\n    \"nodeShareDescription\": \"\\\"{{nodeName}}\\\" ve içeriğini paylaş\",\n    \"shareLinks\": \"Paylaşım bağlantıları\",\n    \"newLink\": \"Yeni bağlantı\",\n    \"noShareLinks\": \"Henüz paylaşım bağlantısı yok\",\n    \"createFirstLink\": \"Paylaşım bağlantısı oluştur\",\n    \"editSettings\": \"Ayarları düzenle\",\n    \"refreshLink\": \"Bağlantıyı yenile\",\n    \"deleteLink\": \"Bağlantıyı sil\",\n    \"deleteConfirmTitle\": \"Paylaşım bağlantısını sil\",\n    \"deleteConfirmDescription\": \"Bu işlem geri alınamaz. Bu bağlantıya sahip kişiler artık paylaşılan tabana erişemeyecek.\",\n    \"createSuccess\": \"Paylaşım bağlantısı oluşturuldu\",\n    \"createFailed\": \"Paylaşım bağlantısı oluşturulamadı\",\n    \"updateSuccess\": \"Paylaşım ayarları güncellendi\",\n    \"updateFailed\": \"Paylaşım ayarları güncellenemedi\",\n    \"deleteSuccess\": \"Paylaşım bağlantısı silindi\",\n    \"deleteFailed\": \"Paylaşım bağlantısı silinemedi\",\n    \"refreshSuccess\": \"Paylaşım bağlantısı yenilendi\",\n    \"refreshFailed\": \"Paylaşım bağlantısı yenilenemedi\",\n    \"copied\": \"Bağlantı panoya kopyalandı\",\n    \"shareLink\": \"Paylaşım bağlantısı\",\n    \"linkHolderLabel\": \"Bağlantıyı alan kişi\",\n    \"linkHolderCanView\": \"Görüntüleyebilir\",\n    \"linkHolderCanEdit\": \"Düzenleyebilir\",\n    \"linkHolderCanCopyAndSave\": \"Kopya olarak kaydedebilir\",\n    \"passwordProtection\": \"Şifre koruması\",\n    \"enterPassword\": \"Şifre girin\",\n    \"selectNodes\": \"Tabloları seç\",\n    \"shareEntireBase\": \"Tüm tabanı paylaş\",\n    \"shareSelectedNodes\": \"Seçili düğümleri paylaş\",\n    \"shareEntireBaseDescription\": \"Bu tabana eklenen yeni tablolar ve klasörler otomatik olarak paylaşılacak\",\n    \"noNodesSelectedWarning\": \"Lütfen paylaşmak için en az bir düğüm seçin\",\n    \"allowSave\": \"Alana kaydetmeye izin ver\",\n    \"allowSaveDescription\": \"İzleyicilerin bu tabanın bir kopyasını kendi alanlarına kaydetmesine izin ver\",\n    \"allowCopy\": \"Veri kopyalamaya izin ver\",\n    \"allowCopyData\": \"İzleyicilerin veri kopyalamasına izin ver\",\n    \"allowDuplicate\": \"İzleyicilerin çoğaltmasına izin ver\",\n    \"allowCopyDescription\": \"İzleyicilerin bu paylaşılan tabandan tablo verilerini kopyalamasına izin ver\",\n    \"selectedNodes\": \"{{count}} tablo seçildi\",\n    \"allNodes\": \"Tüm tablolar\",\n    \"sharedNode\": \"Paylaşılan düğüm\",\n    \"sharedNodeDescription\": \"Bu paylaşım bağlantısı belirli bir düğüm ve alt öğeleri içindir\",\n    \"publicShareTitle\": \"Bağlantıyla herkese açık paylaşım\",\n    \"publicShareCount\": \"{{count}} herkese açık paylaşım bağlantısı\",\n    \"noPublicShare\": \"Herkese açık paylaşım bağlantısı yok\",\n    \"security\": \"Güvenlik\",\n    \"restrictByPassword\": \"Şifre ile kısıtla\",\n    \"advanced\": \"Gelişmiş\",\n    \"embedConfig\": \"Yerleştirme yapılandırması\",\n    \"appPublicLink\": \"Uygulama herkese açık bağlantısı\",\n    \"appNotPublished\": \"Bu uygulama henüz yayınlanmadı. Paylaşmadan önce lütfen uygulamayı yayınlayın.\",\n    \"goToPublish\": \"Yayınlamaya git\",\n    \"publishSuccess\": \"Uygulama başarıyla yayınlandı\",\n    \"publishFailed\": \"Uygulama yayınlanamadı\",\n    \"openLink\": \"Bağlantıyı aç\",\n    \"appPublished\": \"Uygulama yayınlandı\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/token.json",
    "content": "{\n  \"access\": \"Erişim\",\n  \"name\": \"İsim\",\n  \"description\": \"Açıklama\",\n  \"scopes\": \"Kapsamlar\",\n  \"expiration\": \"Son Kullanma\",\n  \"createdTime\": \"Oluşturuldu\",\n  \"lastUse\": \"Son kullanım\",\n  \"allSpace\": \"Alan, bu alandaki mevcut ve gelecekteki tüm veritabanları\",\n  \"formLabelTips\": {\n    \"name\": \"Bir token adı girin\",\n    \"description\": \"Bu token ne için?\",\n    \"scopes\": \"Bu token ile şunları yapabileceksiniz:\",\n    \"access\": \"Bu token aşağıdaki veritabanlarına ve alanlara erişebilir. Yalnızca erişiminiz olan veritabanlarına ve alanlara erişim izni verebilirsiniz.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Yeni token oluştur\",\n    \"title\": \"Teable API'sini kullanmak için kişisel erişim tokenleri gereklidir.\",\n    \"description\": \"Bu token, seçilen alanlardaki ve veritabanlarındaki verilere erişim izni verecektir. Bu token ayrıca diğer alan/veritabanı dışı API uç noktalarının kullanımına da izin verecektir. Bu tokeni yalnızca kendi geliştirmeniz için kullanın. Üçüncü taraf hizmetleri ve uygulamalarıyla paylaşmayın.\",\n    \"button\": \"Yeni token oluştur\",\n    \"success\": {\n      \"title\": \"Token başarıyla oluşturuldu\",\n      \"description\": \"Tokeninizi kopyaladığınızdan emin olun. Bir daha asla görüntülenmeyecek.\"\n    },\n    \"expirationList\": {\n      \"days\": \"gün\",\n      \"permanent\": \"Kalıcı\",\n      \"custom\": \"Özel\",\n      \"pick\": \"Bir tarih seçin\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Token düzenle\",\n    \"name\": \"İsim\",\n    \"scopes\": \"Kapsamlar\"\n  },\n  \"refresh\": {\n    \"title\": \"Kişisel erişim tokenini yeniden oluştur\",\n    \"description\": \"Bu formu göndermek yeni bir token oluşturacak. Bu tokeni kullanan tüm betiklerin veya uygulamaların güncellenmesi gerekecektir\",\n    \"button\": \"Token yeniden oluştur\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Veritabanı veya alan ekle\",\n    \"empty\": \"Erişim bulunamadı.\",\n    \"spaceSelectItem\": \"Alandaki tüm veritabanları\",\n    \"inputPlaceholder\": \"Alan veya veritabanı ara...\"\n  },\n  \"moreScopes\": \"ve {{len}} daha fazla\",\n  \"list\": {\n    \"description\": \"Kişisel erişim tokenleri Teable API'sini kullanmak için gereklidir. Daha fazla bilgi için <a>yardım belgesini</a> inceleyin.\"\n  },\n  \"empty\": {\n    \"list\": \"Kişisel erişim tokeni bulunamadı.\",\n    \"access\": \"Erişim yok\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Bu tokeni silmek istediğinizden emin misiniz?\",\n    \"description\": \"Bu tokeni kullanan uygulamalar veya betikler artık Teable API'sine erişemeyecek. Bu işlemi geri alamazsınız.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/tr/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"{{expected}} bekleniyordu, {{received}} alındı\",\n    \"invalid_type_received_undefined\": \"Zorunlu\",\n    \"invalid_type_received_null\": \"Zorunlu\",\n    \"invalid_literal\": \"Geçersiz değer, {{expected}} bekleniyordu\",\n    \"unrecognized_keys\": \"Nesnede tanınmayan anahtar(lar): {{- keys}}\",\n    \"invalid_union\": \"Geçersiz girdi\",\n    \"invalid_union_discriminator\": \"Geçersiz ayırıcı değer. {{- options}} bekleniyordu\",\n    \"invalid_enum_value\": \"Geçersiz enum değeri. {{- options}} bekleniyordu, '{{received}}' alındı\",\n    \"invalid_arguments\": \"Geçersiz fonksiyon argümanları\",\n    \"invalid_return_type\": \"Geçersiz fonksiyon dönüş tipi\",\n    \"invalid_date\": \"Geçersiz tarih\",\n    \"custom\": \"Geçersiz girdi\",\n    \"invalid_intersection_types\": \"Kesişim sonuçları birleştirilemedi\",\n    \"not_multiple_of\": \"Sayı {{multipleOf}} katı olmalıdır\",\n    \"not_finite\": \"Sayı sonlu olmalıdır\",\n    \"invalid_string\": {\n      \"email\": \"Geçersiz {{validation}}\",\n      \"url\": \"Geçersiz {{validation}}\",\n      \"uuid\": \"Geçersiz {{validation}}\",\n      \"cuid\": \"Geçersiz {{validation}}\",\n      \"regex\": \"Geçersiz\",\n      \"datetime\": \"Geçersiz {{validation}}\",\n      \"startsWith\": \"Geçersiz girdi: \\\"{{startsWith}}\\\" ile başlamalı\",\n      \"endsWith\": \"Geçersiz girdi: \\\"{{endsWith}}\\\" ile bitmeli\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Dizi tam olarak {{minimum}} eleman içermeli\",\n        \"inclusive\": \"Dizi en az {{minimum}} eleman içermeli\",\n        \"not_inclusive\": \"Dizi {{minimum}} elemandan fazla içermeli\"\n      },\n      \"string\": {\n        \"exact\": \"Metin tam olarak {{minimum}} karakter içermeli\",\n        \"inclusive\": \"Metin en az {{minimum}} karakter içermeli\",\n        \"not_inclusive\": \"Metin {{minimum}} karakterden fazla içermeli\"\n      },\n      \"number\": {\n        \"exact\": \"Sayı tam olarak {{minimum}} olmalı\",\n        \"inclusive\": \"Sayı {{minimum}} değerine eşit veya büyük olmalı\",\n        \"not_inclusive\": \"Sayı {{minimum}} değerinden büyük olmalı\"\n      },\n      \"set\": {\n        \"exact\": \"Geçersiz girdi\",\n        \"inclusive\": \"Geçersiz girdi\",\n        \"not_inclusive\": \"Geçersiz girdi\"\n      },\n      \"date\": {\n        \"exact\": \"Tarih tam olarak {{- minimum, datetime}} olmalı\",\n        \"inclusive\": \"Tarih {{- minimum, datetime}} değerine eşit veya sonra olmalı\",\n        \"not_inclusive\": \"Tarih {{- minimum, datetime}} değerinden sonra olmalı\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Dizi tam olarak {{maximum}} eleman içermeli\",\n        \"inclusive\": \"Dizi en fazla {{maximum}} eleman içermeli\",\n        \"not_inclusive\": \"Dizi {{maximum}} elemandan az içermeli\"\n      },\n      \"string\": {\n        \"exact\": \"Metin tam olarak {{maximum}} karakter içermeli\",\n        \"inclusive\": \"Metin en fazla {{maximum}} karakter içermeli\",\n        \"not_inclusive\": \"Metin {{maximum}} karakterden az içermeli\"\n      },\n      \"number\": {\n        \"exact\": \"Sayı tam olarak {{maximum}} olmalı\",\n        \"inclusive\": \"Sayı {{maximum}} değerine eşit veya küçük olmalı\",\n        \"not_inclusive\": \"Sayı {{maximum}} değerinden küçük olmalı\"\n      },\n      \"set\": {\n        \"exact\": \"Geçersiz girdi\",\n        \"inclusive\": \"Geçersiz girdi\",\n        \"not_inclusive\": \"Geçersiz girdi\"\n      },\n      \"date\": {\n        \"exact\": \"Tarih tam olarak {{- maximum, datetime}} olmalı\",\n        \"inclusive\": \"Tarih {{- maximum, datetime}} değerine eşit veya önce olmalı\",\n        \"not_inclusive\": \"Tarih {{- maximum, datetime}} değerinden önce olmalı\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"function\",\n    \"number\": \"number\",\n    \"string\": \"string\",\n    \"nan\": \"nan\",\n    \"integer\": \"integer\",\n    \"float\": \"float\",\n    \"boolean\": \"boolean\",\n    \"date\": \"date\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"undefined\",\n    \"symbol\": \"symbol\",\n    \"null\": \"null\",\n    \"array\": \"array\",\n    \"object\": \"object\",\n    \"unknown\": \"unknown\",\n    \"promise\": \"promise\",\n    \"void\": \"void\",\n    \"never\": \"never\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"Логін\",\n    \"signup\": \"Зареєструватися\"\n  },\n  \"title\": {\n    \"signin\": \"Увійдіть у свій обліковий запис\",\n    \"signup\": \"Створіть обліковий запис, щоб увійти\"\n  },\n  \"content\": {\n    \"title\": \"Там, де Дані Течуть, Команди Ростуть\",\n    \"description\": \"База даних для кожної команди, від простих таблиць до корпоративних рішень\"\n  },\n  \"legal\": {\n    \"tip\": \"Продовжуючи, ви погоджуєтеся з <Terms>Умовами використання</Terms> та <Privacy>Політикою конфіденційності</Privacy> Teable, і отримуєте регулярні електронні листи з оновленнями.\",\n    \"termsUrl\": \"https://teable.ai/terms-of-service\",\n    \"privacyUrl\": \"https://teable.ai/privacy\"\n  },\n  \"button\": {\n    \"signin\": \"Авторизуватися\",\n    \"signup\": \"Зареєструватися\",\n    \"resend\": \"Надіслати повторно\"\n  },\n  \"label\": {\n    \"email\": \"Пошта\",\n    \"password\": \"Пароль\",\n    \"verificationCode\": \"Код підтвердження\"\n  },\n  \"placeholder\": {\n    \"password\": \"Введіть свій пароль...\",\n    \"email\": \"Введіть адресу електронної пошти...\",\n    \"verificationCode\": \"Введіть код підтвердження...\"\n  },\n  \"signError\": {\n    \"exist\": \"Електронна пошта зареєстрована\",\n    \"incorrect\": \"Електронна адреса або пароль неправильні\",\n    \"tooManyRequests\": \"Ваш обліковий запис заблоковано. Повторіть спробу через {{minutes}} хвилин\",\n    \"turnstileRequired\": \"Будь ласка, завершіть перевірку безпеки\",\n    \"turnstileError\": \"Перевірка не пройдена. Спробуйте ще раз\",\n    \"turnstileExpired\": \"Перевірка закінчилася. Спробуйте ще раз\",\n    \"turnstileTimeout\": \"Час перевірки закінчився. Спробуйте ще раз\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"Потрібен код підтвердження\",\n    \"verificationCodeInvalid\": \"Код підтвердження недійсний\",\n    \"passwordLength\": \"Мінімум 8 символів\",\n    \"passwordInvalid\": \"Пароль повинен містити принаймні одну літеру та одну цифру\"\n  },\n  \"socialAuth\": {\n    \"title\": \"Або продовжити з\"\n  },\n  \"resetPassword\": {\n    \"header\": \"Встановіть свій пароль\",\n    \"description\": \"Введіть новий пароль\",\n    \"label\": \"Новий пароль\",\n    \"error\": {\n      \"requiredPassword\": \"Введіть пароль\",\n      \"invalidLink\": \"Недійсне посилання для скидання пароля\"\n    },\n    \"success\": {\n      \"title\": \"🎉 Скидання пароля успішне\",\n      \"description\": \"Ваш пароль успішно скинуто. Вас буде перенаправлено на сторінку входу.\"\n    },\n    \"buttonText\": \"Надіслати Скинути пароль\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"Забули пароль?\",\n    \"header\": \"Скинути пароль\",\n    \"description\": \"Будь ласка, введіть свою електронну адресу нижче, і ми надішлемо вам посилання для зміни пароля.\",\n    \"errorRequiredEmail\": \"Необхідно вказати адресу електронної пошти\",\n    \"errorInvalidEmail\": \"Недійсна електронна адреса\",\n    \"buttonText\": \"Надіслати електронний лист зі скиданням\",\n    \"success\": {\n      \"title\": \"🎉 Надіслано електронний лист для скидання пароля\",\n      \"description\": \"Ми надіслали вам електронного листа з посиланням для зміни пароля. Будь ласка, перевірте свою поштову скриньку.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/chart.json",
    "content": "{\n  \"notBaseId\": \"Відсутній baseId\",\n  \"notPositionId\": \"Відсутній positionId\",\n  \"notPluginInstallId\": \"Відсутній pluginInstallId\",\n  \"initBridge\": \"Ініціалізація мосту...\",\n  \"actions\": {\n    \"cancel\": \"Скасувати\",\n    \"save\": \"Зберегти\"\n  },\n  \"queryTitle\": \"Конфігурація запиту даних\",\n  \"notSupport\": \"Не підтримується\",\n  \"chart\": {\n    \"bar\": \"Стовпчикова\",\n    \"line\": \"Лінійна\",\n    \"pie\": \"Кругова\",\n    \"area\": \"Область\",\n    \"table\": \"Таблиця\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"Оберіть тип діаграми\",\n      \"label\": \"Тип діаграми\"\n    },\n    \"pie\": {\n      \"dimension\": \"Вимір\",\n      \"measure\": \"Міра\",\n      \"showTotal\": \"Показати загальну суму\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"Вісь X\",\n        \"placeholder\": \"Оберіть вісь X\"\n      },\n      \"yAxis\": {\n        \"label\": \"Вісь Y\",\n        \"placeholder\": \"Оберіть вісь Y\",\n        \"position\": \"Позиція осі Y\"\n      },\n      \"xDisplay\": {\n        \"label\": \"Відображення X\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Відображення Y\"\n      },\n      \"addXAxis\": \"Додати розбивку серій\",\n      \"addYAxis\": \"Додати іншу серію\",\n      \"stack\": \"Накладення\",\n      \"position\": {\n        \"label\": \"Позиція\",\n        \"auto\": \"Автоматично\",\n        \"left\": \"Ліворуч\",\n        \"right\": \"Праворуч\"\n      },\n      \"goalLine\": {\n        \"label\": \"Цільова лінія\"\n      },\n      \"range\": {\n        \"label\": \"Діапазон\",\n        \"min\": \"Мінімум\",\n        \"max\": \"Максимум\"\n      },\n      \"lineStyle\": {\n        \"label\": \"Стиль лінії\",\n        \"normal\": \"Звичайний\",\n        \"linear\": \"Лінійний\",\n        \"step\": \"Ступінчастий\"\n      },\n      \"displayType\": \"Тип відображення\"\n    },\n    \"typeError\": \"Форма: Непідтримуваний тип діаграми\",\n    \"updateQuery\": \"Оновити запит\",\n    \"queryError\": \"Помилка запиту\",\n    \"querySuccess\": \"Запит налаштовано\",\n    \"decimal\": \"Десяткові\",\n    \"prefix\": \"Префікс\",\n    \"suffix\": \"Суфікс\",\n    \"showLabel\": \"Показати мітки значень на діаграмі\",\n    \"showLegend\": \"Показати легенду\",\n    \"value\": \"Значення\",\n    \"label\": \"Мітка\",\n    \"padding\": {\n      \"label\": \"Відступ\",\n      \"top\": \"Зверху\",\n      \"right\": \"Праворуч\",\n      \"bottom\": \"Знизу\",\n      \"left\": \"Ліворуч\"\n    },\n    \"tableConfig\": \"Конфігурація таблиці\",\n    \"width\": \"Ширина\"\n  },\n  \"reloadQuery\": \"Перезавантажити запит\",\n  \"noStorage\": \"Будь ласка, спочатку налаштуйте плагін діаграм\",\n  \"noPermission\": \"Немає дозволу на доступ\",\n  \"goConfig\": \"Перейти до налаштування\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/common.json",
    "content": "{\n  \"actions\": {\n    \"title\": \"дії\",\n    \"save\": \"Зберегти\",\n    \"doNotSave\": \"Не зберігати\",\n    \"submit\": \"Надіслати\",\n    \"confirm\": \"Підтвердити\",\n    \"close\": \"Закрити\",\n    \"edit\": \"Редагувати\",\n    \"fill\": \"Заповнити\",\n    \"update\": \"Оновити\",\n    \"create\": \"Створити\",\n    \"delete\": \"Видалити\",\n    \"cancel\": \"Скасування\",\n    \"zoomIn\": \"Збільшити\",\n    \"zoomOut\": \"Зменшити\",\n    \"back\": \"Назад\",\n    \"removeConfig\": \"Вилучити конфігурацію\",\n    \"saveSucceed\": \"Збереження успішне!\",\n    \"submitSucceed\": \"Надсилання успішне!\",\n    \"editSucceed\": \"Редагування успішне!\",\n    \"updateSucceed\": \"Оновлення успішне!\",\n    \"deleteSucceed\": \"Видалення успішне!\",\n    \"resetSucceed\": \"Очищення кошика успішно!\",\n    \"restoreSucceed\": \"Відновлення успішно!\",\n    \"loading\": \"Завантаження...\",\n    \"refreshPage\": \"Оновити сторінку\",\n    \"yesDelete\": \"Так, видалити\",\n    \"rename\": \"Перейменувати\",\n    \"duplicate\": \"Дублювати\",\n    \"change\": \"Змінити\",\n    \"upgrade\": \"Оновити\",\n    \"upgradeToLevel\": \"Оновити до {{level}}\",\n    \"search\": \"Пошук\",\n    \"loadMore\": \"Завантажити більше\",\n    \"collapseSidebar\": \"Згорнути бічну панель\",\n    \"restore\": \"Відновити\",\n    \"permanentDelete\": \"Видалити назавжди\",\n    \"more\": \"Більше\",\n    \"move\": \"Перемістити в\",\n    \"turnOn\": \"Увімкнути\",\n    \"exit\": \"Вийти\",\n    \"next\": \"Наступна\",\n    \"previous\": \"Попередня\",\n    \"select\": \"Вибрати\",\n    \"view\": \"Переглянути\",\n    \"preview\": \"Попередній перегляд\",\n    \"viewAndEdit\": \"Переглянути та редагувати\",\n    \"getMore\": \"Отримати більше\",\n    \"copyToMySpace\": \"Copy to my space\",\n    \"saveToMySpace\": \"Save to my space\",\n    \"supportSaveCopy\": \"Support saving a copy\",\n    \"copySuccess\": \"Копіювання успішне\"\n  },\n  \"quickAction\": {\n    \"title\": \"Швидкі дії\",\n    \"placeHolder\": \"Введіть команду або виконайте пошук...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"Пароль недійсний, не менше 8 символів і повинен містити принаймні одну літеру та одну цифру.\"\n  },\n  \"non\": {\n    \"share\": \"Поділитися\",\n    \"copy\": \"Скопійовано\"\n  },\n  \"template\": {\n    \"non\": {\n      \"share\": \"Поділитися\",\n      \"copy\": \"Скопійовано\"\n    },\n    \"aiTitle\": \"Давайте будувати разом\",\n    \"aiGreeting\": \"Чим я можу допомогти, {{name}}?\",\n    \"aiSubTitle\": \"Перша AI-платформа, де команди співпрацюють над даними та генерують виробничі додатки\",\n    \"guideTitle\": \"Почніть зі свого сценарію\",\n    \"watchVideo\": \"Дивитися відео\",\n    \"title\": \"Шаблон\",\n    \"description\": \"Створити нову базу з шаблону\",\n    \"browseAll\": \"Переглянути Усі\",\n    \"templateTitle\": \"Почати з шаблону\",\n    \"loadMore\": \"Завантажити Більше\",\n    \"allTemplatesLoaded\": \"Усі шаблони завантажені\",\n    \"createTemplate\": \"Створити Шаблон\",\n    \"useTemplateDialog\": {\n      \"title\": \"Виберіть простір\",\n      \"description\": \"Виберіть простір для застосування шаблону\",\n      \"noSpaceDescription\": \"You don't have any spaces yet. Create one to continue.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\"\n    },\n    \"promptBox\": {\n      \"placeholder\": \"Створіть свій бізнес-додаток з Teable\",\n      \"start\": \"Почати\",\n      \"carouselGuides\": {\n        \"guide1\": \"Вставте чеки → Попросіть Teable автоматично витягти ключові дані\",\n        \"guide2\": \"Створіть AI CRM з таблицями лідів і відстеження\",\n        \"guide3\": \"Вставте відгуки клієнтів → Попросіть Teable знайти інсайти та створити звіти\",\n        \"guide4\": \"Створіть трекер проєктів із завданнями та термінами\",\n        \"guide5\": \"Вставте таблицю лідів → Попросіть Teable побудувати розумну CRM\",\n        \"guide6\": \"Створіть планувальник контенту з публікаціями та датами публікації\",\n        \"guide7\": \"Вставте резюме → Попросіть Teable організувати та відібрати кандидатів\"\n      }\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"Копіювати до простору\",\n      \"description\": \"Виберіть простір для копіювання цієї бази\",\n      \"baseName\": \"Назва бази\",\n      \"baseNamePlaceholder\": \"Введіть назву бази\",\n      \"selectSpace\": \"Виберіть простір\",\n      \"noSpaceDescription\": \"Немає доступних просторів. Створіть новий простір, щоб продовжити.\",\n      \"newSpacePlaceholder\": \"Space name\",\n      \"createSpace\": \"Create\",\n      \"copyTarget\": \"Copy to\",\n      \"createNewBase\": \"New base\",\n      \"copyToExistingBase\": \"Existing base\",\n      \"selectBase\": \"Select base\",\n      \"selectBasePlaceholder\": \"Select a base\",\n      \"noBaseInSpace\": \"У цьому просторі немає доступних баз. Виберіть «Нова база».\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Приклад налаштувань\",\n    \"personal\": {\n      \"title\": \"Особисті налаштування\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"Керування шаблонами\",\n      \"noData\": \"Немає даних\",\n      \"importing\": \"Імпортування...\",\n      \"usageCount\": \"Кількість використань: {{count}}\",\n      \"useTemplate\": \"Використати цей шаблон\",\n      \"createdBy\": \"від {{user}}\",\n      \"backToTemplateList\": \"Повернутися до списку шаблонів\",\n      \"tips\": {\n        \"errorCategoryName\": \"Категорія не існує або була видалена\",\n        \"needSnapshot\": \"Перед публікацією створіть знімок, назва та опис шаблону не можуть бути порожніми\",\n        \"needPublish\": \"Опублікуйте шаблон перед встановленням як рекомендований\",\n        \"needBaseSource\": \"Виберіть базу-джерело перед створенням знімка\",\n        \"forbiddenUpdateSystemTemplate\": \"Системний шаблон не можна змінити\",\n        \"addCategoryTips\": \"Спочатку введіть назву категорії в поле пошуку.\",\n        \"categoryNamePlaceholder\": \"Введіть назву категорії\",\n        \"duplicateCategoryName\": \"Назва категорії вже існує\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"Почати\",\n          \"recommended\": \"Рекомендовані\",\n          \"all\": \"Усі\",\n          \"browseByCategory\": \"Перегляд за категоріями\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"Обкладинка\",\n        \"name\": \"Назва\",\n        \"description\": \"Опис\",\n        \"markdownDescription\": \"Опис у markdown\",\n        \"category\": \"Категорія\",\n        \"isSystem\": \"Системний\",\n        \"source\": \"Джерело\",\n        \"status\": \"Опубліковано\",\n        \"publishSnapshot\": \"Опублікувати знімок\",\n        \"snapshotTime\": \"Час знімка\",\n        \"actions\": \"Дії\",\n        \"featured\": \"Рекомендований\",\n        \"createdBy\": \"Створено\",\n        \"userNonExistent\": \"Користувач не існує\",\n        \"preview\": \"Попередній перегляд\",\n        \"usage\": \"Використання\",\n        \"visit\": \"Відвідування\"\n      },\n      \"actions\": {\n        \"title\": \"Дії\",\n        \"publish\": \"Опублікувати\",\n        \"delete\": \"Видалити\",\n        \"duplicate\": \"Дублювати\",\n        \"preview\": \"Попередній перегляд\",\n        \"use\": \"Використати\",\n        \"pinTop\": \"Закріпити зверху\",\n        \"addCategory\": \"Додати категорію\",\n        \"selectCategory\": \"Вибрати категорію\",\n        \"viewTemplate\": \"Переглянути шаблон\",\n        \"manageCategory\": \"Керування категоріями\"\n      },\n      \"relatedTemplates\": \"Пов'язані шаблони\",\n      \"noImage\": \"Немає зображення\",\n      \"baseSelectPanel\": {\n        \"title\": \"Виберіть джерело шаблону\",\n        \"description\": \"Виберіть базу для шаблону\",\n        \"confirm\": \"Підтвердити\",\n        \"search\": \"Пошук...\",\n        \"cancel\": \"Скасувати\",\n        \"selectBase\": \"Вибрати базу\",\n        \"createTemplate\": \"Створити шаблон\",\n        \"abnormalBase\": \"База не існує або була видалена\"\n      }\n    },\n    \"back\": \"Назад додому\",\n    \"account\": {\n      \"title\": \"Мій профіль\",\n      \"tab\": \"Мій обліковий запис\",\n      \"updatePhoto\": \"Оновити фото\",\n      \"updateNameDesc\": \"Псевдонім або ім'я — те, як ви хочете, щоб до вас зверталися в Teable\",\n      \"securityTitle\": \"Безпека облікового запису\",\n      \"email\": \"Електронна пошта\",\n      \"password\": \"Пароль\",\n      \"passwordDesc\": \"Установіть постійний пароль для входу до свого облікового запису.\",\n      \"changePassword\": {\n        \"title\": \"Змінити пароль\",\n        \"desc\": \"Введіть поточний пароль і встановіть новий\",\n        \"current\": \"Поточний пароль\",\n        \"new\": \"Новий пароль\",\n        \"confirm\": \"Підтвердити новий пароль\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"Ваш новий пароль не збігається.\",\n        \"equal\": \"Ваш новий пароль має відрізнятися від поточного.\",\n        \"invalid\": \"Ваш поточний пароль недійсний.\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 Пароль успішно змінено.\",\n        \"desc\": \"Ви будете перенаправлені на сторінку входу через 2 секунди.\"\n      },\n      \"manageToken\": \"Токен доступу\",\n      \"addPassword\": {\n        \"title\": \"Додати пароль\",\n        \"desc\": \"Установіть постійний пароль для входу до свого облікового запису.\",\n        \"password\": \"Введіть свій пароль\",\n        \"confirm\": \"Підтвердьте свій пароль\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"Ваш пароль не збігається.\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 Пароль додано успішно.\"\n      },\n      \"deleteAccount\": {\n        \"title\": \"Видалити обліковий запис\",\n        \"desc\": \"Цю дію неможливо скасувати. Ваш обліковий запис і всі пов'язані дані будуть назавжди видалені.\",\n        \"error\": {\n          \"title\": \"Не вдалося видалити обліковий запис\",\n          \"desc\": \"Вам потрібно спочатку вирішити наступні залежності:\",\n          \"spacesError\": \"Перш ніж видалити обліковий запис, вам потрібно вийти з просторів (або видалити та перемістити в кошик).\"\n        },\n        \"confirm\": {\n          \"title\": \"Введіть <code>DELETE</code> для підтвердження\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"Видалення...\"\n      },\n      \"changeEmail\": {\n        \"title\": \"Змінити адресу електронної пошти\",\n        \"desc\": \"Будь ласка, перевірте пароль і підтвердьте нову адресу електронної пошти\",\n        \"current\": \"Поточний пароль\",\n        \"new\": \"Нова пошта\",\n        \"code\": \"Код підтвердження\",\n        \"getCode\": \"Надіслати код\",\n        \"error\": {\n          \"invalidCode\": \"Невірний код підтвердження.\",\n          \"invalidPassword\": \"Невірний пароль.\",\n          \"invalidConflict\": \"Нова електронна адреса вже зареєстрована.\",\n          \"invalidSameEmail\": \"Нова електронна адреса збігається з поточною.\",\n          \"sendMailRateLimit\": \"Будь ласка, зачекайте {{seconds}} с перед надсиланням нового листа\"\n        },\n        \"success\": {\n          \"title\": \"🎉 Електронну адресу успішно змінено.\",\n          \"desc\": \"Вас буде перенаправлено на сторінку входу через 2 с.\",\n          \"sendSuccess\": \"Код підтвердження успішно надіслано.\"\n        }\n      }\n    },\n    \"notify\": {\n      \"title\": \"Мої сповіщення\",\n      \"label\": \"Діяльність у вашому просторі\",\n      \"desc\": \"Отримувати електронні листи, коли ви отримуєте коментарі, згадки, запрошення на сторінку, нагадування, запити на доступ і зміни власності.\"\n    },\n    \"setting\": {\n      \"title\": \"Мої налаштування\",\n      \"theme\": \"Тема\",\n      \"themeDesc\": \"Виберіть тему для програми.\",\n      \"dark\": \"Темна\",\n      \"light\": \"Світла\",\n      \"system\": \"Системна\",\n      \"version\": \"Версія програми\",\n      \"language\": \"Мова\",\n      \"interactionMode\": \"Режим взаємодії\",\n      \"mouseMode\": \"Режим курсора\",\n      \"touchMode\": \"Сенсорний режим\",\n      \"systemMode\": \"Слідувати системі\"\n    },\n    \"nav\": {\n      \"settings\": \"Налаштування\",\n      \"logout\": \"Вийти\",\n      \"contactSupport\": \"Зв'язатися з підтримкою\"\n    },\n    \"integration\": {\n      \"title\": \"Інтеграції\",\n      \"description\": \"Ви надали доступ до свої облікового запису {{count}} програмам.\",\n      \"lastUsed\": \"Востаннє використано {{date}}\",\n      \"revoke\": \"Відкликати\",\n      \"owner\": \"Власник {{user}}\",\n      \"revokeTitle\": \"Ви впевнені, що хочете відкликати авторизацію?\",\n      \"revokeDesc\": \"{{name}} більше не матиме доступу до Teable API. Ви не можете скасувати цю дію.\",\n      \"scopeTitle\": \"Дозволи\",\n      \"scopeDesc\": \"Ця програма зможе отримати наступні області:\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Таблиця\",\n    \"view\": \"Перегляд\",\n    \"space\": \"Простір\",\n    \"base\": \"База\",\n    \"field\": \"Поле\",\n    \"record\": \"Запис\",\n    \"dashboard\": \"Дашборд\",\n    \"automation\": \"Автоматизація\",\n    \"authorityMatrix\": \"Матриця повноважень\",\n    \"design\": \"Дизайн\",\n    \"adminPanel\": \"Системне адміністрування\",\n    \"license\": \"Самостійна ліцензія\",\n    \"instanceId\": \"Ідентифікатор екземпляра\",\n    \"beta\": \"Бета\",\n    \"trash\": \"Кошик\",\n    \"global\": \"Глобальний\",\n    \"organizationPanel\": \"Налаштування організації\",\n    \"unknownError\": \"Невідома помилка\",\n    \"pluginPanel\": \"Панель\",\n    \"pluginContextMenu\": \"Контекстне меню\",\n    \"plugin\": \"Плагіни\",\n    \"copy\": \"Копія\",\n    \"credits\": \"Кредити\",\n    \"aiChat\": \"ШІ-чат\",\n    \"app\": \"Додаток\",\n    \"webSearch\": \"Веб-пошук\",\n    \"folder\": \"Папка\",\n    \"newAutomation\": \"Нова автоматизація\",\n    \"newApp\": \"Новий додаток\",\n    \"newFolder\": \"Нова папка\",\n    \"template\": \"Шаблон\"\n  },\n  \"level\": {\n    \"free\": \"Безкоштовно\",\n    \"plus\": \"Плюс\",\n    \"pro\": \"Профі\",\n    \"business\": \"Бізнес\",\n    \"enterprise\": \"Підприємство\"\n  },\n  \"noResult\": \"Немає результату.\",\n  \"allNodes\": \"Усі вузли\",\n  \"noDescription\": \"Без опису\",\n  \"untitled\": \"Без назви\",\n  \"name\": \"Назва\",\n  \"description\": \"Опис\",\n  \"required\": \"Обов'язково\",\n  \"characters\": \"символів\",\n  \"atLeastOne\": \"Принаймні зарезервуйте {{noun}}\",\n  \"guide\": {\n    \"prev\": \"Попер.\",\n    \"next\": \"Наступний\",\n    \"done\": \"Готово\",\n    \"skip\": \"Пропустити\",\n    \"createSpaceTooltipTitle\": \"Створити простір\",\n    \"createSpaceTooltipContent\": \"Teable організована в простори, де кожна область запрошує користувачів до співпраці. <br></br>Простори в Teable служать основним навігаційним елементом у рядку меню, пропонуючи базову платформу для користувачів, щоб додавати та керувати базами даних за потреби.\",\n    \"createBaseTooltipTitle\": \"Створити базу\",\n    \"createBaseTooltipContent\": \"База (скорочення від \\\"база даних\\\") — це місце для зберігання важливих даних і робочих процесів, які від них залежать.\",\n    \"createTableTooltipTitle\": \"Створити таблицю\",\n    \"createTableTooltipContent\": \"Таблиці призначені для ефективної обробки різноманітних наборів даних, пропонуючи універсальне відображення інформації за допомогою різних типів даних.\",\n    \"createViewTooltipTitle\": \"Створити вигляд\",\n    \"createViewTooltipContent\": \"Наразі користувачі можуть створювати режими перегляду сітки, галереї, канбану та форми, а перегляди календаря заплановано для наступних випусків. <br></br>Ця різноманітність надає користувачам комплексний набір інструментів для різноманітних завдань керування даними.\",\n    \"viewFilteringTooltipTitle\": \"Фільтрація записів\",\n    \"viewFilteringTooltipContent\": \"Однією з основних особливостей представлень є можливість фільтрувати записи з представлення відповідно до встановлених вами умов. <br></br>Коли запис відфільтровується на основі умови, він не видаляється — він просто прихований від певного подання, яке ви використовуєте для перегляду таблиці.\",\n    \"viewSortingTooltipTitle\": \"Сортування записів\",\n    \"viewSortingTooltipContent\": \"Перебуваючи в поданні, ви можете відсортувати свої записи так, щоб вони відображалися в певному порядку відповідно до значень у певних полях. <br></br>Сортування ваших записів в одному поданні не впливає на порядок записів в інших поданнях — це стосується лише подання, яке ви зараз використовуєте для перегляду таблиці.\",\n    \"viewGroupingTooltipTitle\": \"Групування записів\",\n    \"viewGroupingTooltipContent\": \"Групування записів дозволяє творцям створювати набір з однієї чи кількох умов, які допоможуть класифікувати набір даних, представлений у певному поданні.\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable пропонує потужний API, який підтримує майже всі функції продукту, дозволяючи розробникам здійснювати виклики за допомогою <a>Токен</a>.\"\n  },\n  \"token\": \"Токен\",\n  \"poweredBy\": \"Працює завдяки <0></0>\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"Поділитися простором {{spaceName}}\",\n      \"desc_one\": \"У цьому просторі <b>{{count}} співавторів</b>. Додавання співавтора в простір дасть йому доступ до всіх баз у цьому просторі.\",\n      \"desc_other\": \"У цьому просторі <b>{{count}} співавторів</b>. Додавання співавтора в простір дасть йому доступ до всіх баз в цьому просторі.\",\n      \"tabEmail\": \"Запросити електронною поштою\",\n      \"emailPlaceholder\": \"Введіть електронні адреси, розділені клавішею Enter\",\n      \"tabLink\": \"Запросити на посилання\",\n      \"linkPlaceholder\": \"Створити посилання запрошення, яке надасть <0/> доступ будь-кому, хто його відкриє.\",\n      \"emailSend\": \"Надіслати запрошення\",\n      \"linkSend\": \"Створити посилання\",\n      \"spaceTitle\": \"Співавтори простору\",\n      \"collaboratorSearchPlaceholder\": \"Знайдіть співавтора простору на ім'я або електронну пошту\",\n      \"collaboratorJoin\": \"приєднався {{joinTime}}\",\n      \"collaboratorRemove\": \"Видалити співавтора\",\n      \"linkTitle\": \"Посилання запрошення\",\n      \"linkCreatedTime\": \"створено {{createdTime}}\",\n      \"linkCopySuccess\": \"Посилання скопійоване\",\n      \"linkRemove\": \"Видалити посилання\"\n    },\n    \"base\": {\n      \"title\": \"Поділитися {{baseName}}\",\n      \"desc_one\": \"Ця база поділена з {{count}} співробітником.\",\n      \"desc_other\": \"Ця база поділена з {{count}} співробітниками.\",\n      \"baseTitle\": \"Співавтори бази\",\n      \"collaboratorSearchPlaceholder\": \"Знайдіть співробітника бази на ім'я або електронну пошту\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"Додати співавтора організації\",\n      \"placeholder\": \"Виберіть члена організації або відділ\"\n    }\n  },\n  \"help\": {\n    \"title\": \"Допомога\",\n    \"appLink\": \"https://app.teable.ai\",\n    \"mainLink\": \"https://help.teable.ai\",\n    \"apiLink\": \"https://help.teable.ai/en/api-doc/token\"\n  },\n  \"pagePermissionChangeTip\": \"Дозволи сторінки оновлено. Будь ласка, оновіть сторінку, щоб побачити найновіший вміст.\",\n  \"listEmptyTips\": \"Список порожній\",\n  \"billing\": {\n    \"overLimits\": \"Перевищені ліміти\",\n    \"overLimitsDescription\": \"Ваш поточний тарифний план перевищив ліміт використання. Будь ласка, оновіть план, щоб продовжити використання цієї функції без переривань.\",\n    \"userLimitExceededDescription\": \"Поточна ліцензія досягла максимальної кількості користувачів. Будь ласка, відключіть деяких користувачів або оновіть ліцензію.\",\n    \"unavailableInPlanTips\": \"Поточний тарифний план не підтримує цю функцію\",\n    \"unavailableConnectionTips\": \"Функція підключення до бази даних буде вилучена в майбутньому і буде доступна лише в тарифному плані Enterprise та в самохостинг версії.\",\n    \"levelTips\": \"Це простір зараз на тарифі {{level}}\",\n    \"billable\": \"Платний\",\n    \"billableByAuthorityMatrix\": \"Білінг, створений матрицею повноважень\",\n    \"licenseExpiredGracePeriod\": \"Термін дії вашої ліцензії для самостійного розміщення закінчився і буде понижена до безкоштовного плану {{expiredTime}}. Будь ласка, оновіть ліцензію, щоб зберегти доступ до преміум-функцій.\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"Оновити тарифний план простору\",\n      \"description\": \"Ви можете оновити тільки ті місця, де ви є власником\"\n    },\n    \"status\": {\n      \"active\": \"Активно\",\n      \"canceled\": \"Скасовано\",\n      \"incomplete\": \"Не завершено\",\n      \"incompleteExpired\": \"Термін не завершено\",\n      \"trialing\": \"Пробний період\",\n      \"pastDue\": \"Прострочено\",\n      \"unpaid\": \"Не оплачено\",\n      \"paused\": \"Припинено\",\n      \"seatLimitExceeded\": \"Перевищено кількість місць\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"description\": \"Змініть налаштування для поточного екземпляра\",\n      \"allowSignUp\": \"Дозволити створення нових облікових записів\",\n      \"allowSignUpDescription\": \"Вимкнення цієї опції заборонить реєстрацію нових користувачів, і кнопка реєстрації більше не відображатиметься на сторінці входу.\",\n      \"allowSpaceInvitation\": \"Дозволити запрошення в простір\",\n      \"allowSpaceInvitationDescription\": \"Відключення цієї опції заборонить користувачам, крім адміністраторів, запрошувати інших у простір. При увімкненні нові користувачі, запрошені електронною поштою, можуть завершити реєстрацію, натиснувши на посилання-запрошення в листі, але спільні посилання-запрошення не працюватимуть.\",\n      \"allowSpaceCreation\": \"Дозволити всім створювати нові простори\",\n      \"allowSpaceCreationDescription\": \"Вимкнення цієї опції заборонить користувачам, крім адміністраторів, створювати нові простори.\",\n      \"enableEmailVerification\": \"Увімкнути перевірку електронної пошти\",\n      \"enableEmailVerificationDescription\": \"Увімкнення цієї опції вимагатиме від користувачів підтвердження своєї електронної адреси при створенні нового облікового запису.\",\n      \"enableWaitlist\": \"Увімкнути чергу\",\n      \"enableWaitlistDescription\": \"Увімкнення цієї опції дозволить користувачам зареєструватися лише за допомогою привілей.\",\n      \"generalSettings\": \"Загальні налаштування\",\n      \"aiSettings\": \"Налаштування AI\",\n      \"brandingSettings\": {\n        \"title\": \"Налаштування брендингу\",\n        \"description\": \"Доступно лише в корпоративній версії\",\n        \"logo\": \"Логотип\",\n        \"logoDescription\": \"Логотип - це ваша брендова ідентичність в Teable.\",\n        \"logoUpload\": \"Завантажити логотип\",\n        \"logoUploadDescription\": \"Завантажте зображення логотипу, підтримуються формати PNG, JPEG, рекомендований розмір 100x100px. Максимальний розмір завантаження 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"Ім'я\",\n        \"nameDescription\": \"Ім'я LLM провайдера\",\n        \"enable\": \"Увімкнути AI\",\n        \"enableDescription\": \"Увімкнути AI для поточного екземпляра, всі користувачі зможуть використовувати функції AI\",\n        \"updateLLMProvider\": \"Оновити LLM провайдера\",\n        \"addProvider\": \"Додати LLM провайдера\",\n        \"addProviderDescription\": \"Додати новий LLM провайдера до списку\",\n        \"providerType\": \"Тип провайдера\",\n        \"baseUrl\": \"Базовий URL\",\n        \"apiKey\": \"API ключ\",\n        \"baseUrlDescription\": \"Базовий URL LLM провайдера\",\n        \"apiKeyDescription\": \"API ключ провайдера LLM\",\n        \"models\": \"Моделі\",\n        \"modelsDescription\": \"Моделі, що підтримуються LLM-провайдером\",\n        \"baseUrlRequired\": \"Базовий URL обов'язковий\",\n        \"fetchModelListError\": \"Не вдалося отримати список моделей\",\n        \"provider\": \"LLM провайдер\",\n        \"providerDescription\": \"LLM провайдер для використання\",\n        \"modelPreferences\": \"Модель переваги\",\n        \"modelPreferencesDescription\": \"Уподобання моделі для провайдера LLM\",\n        \"embeddingModel\": \"Модель вбудовування\",\n        \"embeddingModelDescription\": \"Optional. For Document Q&A. Usually, the model ID contains \\\"embedding\\\".\",\n        \"translationModel\": \"Модель перекладу\",\n        \"translationModelDescription\": \"Модель перекладу для використання\",\n        \"chatModel\": \"Модель чату\",\n        \"chatModelDescription\": \"Модель чату для використання, Підказка: середня модель кодування за замовчуванням використовується для генерації AI формул та споріднених функцій\",\n        \"chatModels\": {\n          \"lg\": \"Розширена модель чату\",\n          \"lgDescription\": \"Для планування, програмування та інших складних сценаріїв завдань. Рекомендовано: claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"Можливості ШІ\",\n          \"aiField\": {\n            \"title\": \"ШІ поле\",\n            \"description\": \"Функції ШІ полів, включаючи автозаповнення та налаштування ШІ в параметрах поля\"\n          },\n          \"aiChat\": {\n            \"title\": \"ШІ чат\",\n            \"description\": \"Бічна панель ШІ чату та всі функції агента\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"Тестова модель\",\n          \"description\": \"Тестування здатності моделі обробляти зображення, PDF\",\n          \"notConfigLgModel\": \"Будь ласка, спочатку налаштуйте велику модель\",\n          \"confirmTitle\": \"Тестування здатностей моделі\",\n          \"confirmDescription\": \"Чи хочете ви протестувати можливості великої моделі чату?\",\n          \"confirm\": \"Тестова модель\",\n          \"cancel\": \"Скасувати\",\n          \"missingCapabilitiesWarning\": \"Ця модель не підтримує обробку зображень або PDF, що може призвести до недоступності деяких функцій.\",\n          \"enableAITitle\": \"Увімкнути AI\",\n          \"enableAIDescription\": \"Тестування моделі завершено. AI наразі не ввімкнено. Чи хочете ви ввімкнути AI для використання цих можливостей моделі?\",\n          \"enableAI\": \"Увімкнути AI\",\n          \"skipTest\": \"Пропустити\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"Зображення\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"Веб-пошук\",\n          \"disabledWebSearch\": \"Веб-пошук вимкнено\",\n          \"lgModelAbility\": \"Здатність великої моделі\"\n        },\n        \"configUpdated\": \"AI налаштування оновлено\",\n        \"noModelFound\": \"Модель не знайдена\",\n        \"searchModel\": \"Пошук моделі...\",\n        \"selectModel\": \"Вибрати модель...\",\n        \"input\": \"Ввід {{ratio}}\",\n        \"output\": \"Вивід {{ratio}}\",\n        \"inputOrOutputTip\": \"Співвідношення витрати-вихід являє собою обмінний курс між кредитами та токенами. Наприклад, \\\"1:1000\\\" означає, що 1 кредит приблизно дорівнює 1000 жетонів.<br></br>Примітка: за кожне використання віднімається принаймні 1 кредит. Навіть якщо фактичне споживання менше 1 кредиту, воно все одно стягуватиметься як 1 кредит.\",\n        \"imageOutput\": \"За зображення {{credits}}\",\n        \"imageOutputTip\": \"Зображення потребує кредитів, кожне зображення споживає {{credits}} кредитів\",\n        \"supportImageOutputTip\": \"Ця модель підтримує генерацію зображень\",\n        \"supportVisionTip\": \"Ця модель підтримує введення зображень\",\n        \"supportAudioTip\": \"Ця модель підтримує введення аудіо\",\n        \"supportVideoTip\": \"Ця модель підтримує введення відео\",\n        \"supportDeepThinkTip\": \"Ця модель підтримує глибоке мислення\",\n        \"testConnection\": \"Тест\",\n        \"testing\": \"Тестування...\",\n        \"testSuccess\": \"Тест успішний\",\n        \"testFailed\": \"Тест невдалий\",\n        \"fillRequiredFields\": \"Будь ласка, заповніть всі обов'язкові поля\",\n        \"modelsRequired\": \"Будь ласка, заповніть принаймні одну модель\",\n        \"noValidModel\": \"Не знайдено допустимої моделі\",\n        \"addCustomModel\": \"Додати власну модель\",\n        \"isOpenRouter\": \"OpenRouter\"\n      },\n      \"webSearch\": {\n        \"description\": \"Налаштуйте API ключ Firecrawl для увімкнення функції веб-пошуку, перейдіть до <a>налаштувань Firecrawl</a> для отримання API ключа\"\n      },\n      \"app\": {\n        \"domain\": \"Домен\",\n        \"v0ApiKey\": \"Ключ API v0\",\n        \"customDomain\": \"Користувацький домен (необов'язково)\",\n        \"customDomainDescription\": \"Налаштуйте свій користувацький домен для розгортання застосунку, перейдіть до <a>домену Vercel</a> для отримання користувацького домену\",\n        \"vercelToken\": \"Токен API Vercel\",\n        \"vercelTokenDescription\": \"Перейдіть до <a>налаштувань Vercel</a> для отримання токена API\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"Введіть API ключ\",\n      \"goToConfiguration\": \"Перейти до налаштувань\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"Дякуємо за використання teable\",\n      \"pleaseGoToConfiguration\": \"Будь ласка, перейдіть на сторінку налаштувань, щоб завершити деякі початкові налаштування, щоб насолоджуватися повною функціональністю та кращим користувацьким досвідом teable\",\n      \"pleaseContactAdmin\": \"Будь ласка, зверніться до адміністратора\"\n    },\n    \"configuration\": {\n      \"title\": \"Очікування налаштування\",\n      \"description\": \"Завершіть ці налаштування, щоб отримати повні функції\",\n      \"copyInstance\": \"Копіювати ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"Змінна середовища PUBLIC_ORIGIN\",\n          \"description\": \"Налаштування змінної середовища <strong>PUBLIC_ORIGIN</strong> не відповідає поточній адресі доступу <underline>https://example.com</underline>, імпорт xlsx, csv та функції поля вкладень можуть працювати неправильно, рекомендується встановити на <underline>https://example.com</underline>\"\n        },\n        \"https\": {\n          \"title\": \"Увімкнути HTTPS\",\n          \"description\": \"Ви не ввімкнули HTTPS, функція великомасштабного копіювання (300 рядків або більше) буде недоступна, рекомендується ввімкнути.\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"Змінна середовища PUBLIC_DATABASE_PROXY\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> не налаштовано, функція підключення зовнішньої бази даних буде недоступна, будь ласка, зверніться до <a>довідкового документа</a>\",\n          \"href\": \"https://help.teable.ai/uk/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"Ви ще не налаштували AI LLM API, AI чат/AI автоматизація не буде використовуватися, <anchor>перейти до налаштувань</anchor>\",\n          \"errorTips\": \"Ви ще не налаштували AI LLM API, AI чат/AI автоматизація не буде використовуватися\"\n        },\n        \"app\": {\n          \"title\": \"Конструктор додатків\",\n          \"description\": \"Ви ще не налаштували v0 API, функція Конструктор додатків буде недоступна, <anchor>перейти до налаштувань</anchor>\",\n          \"errorTips\": \"Ви ще не налаштували v0 API, функція Конструктор додатків буде недоступна\"\n        },\n        \"webSearch\": {\n          \"title\": \"Веб-пошук\",\n          \"description\": \"Ви ще не налаштували API веб-пошуку, функція веб-пошуку буде недоступна, <anchor>перейти до налаштувань</anchor>\",\n          \"errorTips\": \"Ви ще не налаштували API веб-пошуку, функція веб-пошуку буде недоступна\"\n        },\n        \"email\": {\n          \"title\": \"Електронна пошта\",\n          \"description\": \"Електронна пошта не налаштована, самообслуговування відновлення пароля та функція сповіщень електронною поштою будуть недоступні, <anchor>перейти до налаштувань</anchor>\",\n          \"errorTips\": \"Електронна пошта не налаштована, самообслуговування відновлення пароля, перевірка електронної пошти/функція сповіщень будуть недоступні\"\n        }\n      }\n    }\n  },\n  \"notification\": {\n    \"title\": \"Повідомлення\",\n    \"unread\": \"Непрочитані\",\n    \"read\": \"Прочитані\",\n    \"markAs\": \"Позначити це повідомлення як {{status}}\",\n    \"markAllAsRead\": \"Відзначити всі як прочитані\",\n    \"noUnread\": \"Немає {{status}} повідомлень\",\n    \"changeSetting\": \"Змінити налаштування повідомлень\",\n    \"new\": \"нових {{count}}\",\n    \"showMore\": \"Показати більше\",\n    \"exportBase\": {\n      \"successText\": \"Дані експорту готові\",\n      \"failedText\": \"Експорт не вдався, спробуйте ще раз\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"Власник\",\n      \"creator\": \"Творець\",\n      \"editor\": \"Редактор\",\n      \"commenter\": \"Коментатор\",\n      \"viewer\": \"Перегляд\"\n    },\n    \"description\": {\n      \"owner\": \"Може повністю налаштовувати та редагувати бази, автоматизацію, матриці повноважень, керувати налаштуваннями простору та оплатою\",\n      \"creator\": \"Може повністю налаштовувати та редагувати бази, автоматизацію та активувати матриці повноважень\",\n      \"editor\": \"Може редагувати записи та подання, але не може налаштовувати таблиці або поля\",\n      \"commenter\": \"Може коментувати записи\",\n      \"viewer\": \"Не може редагувати чи коментувати\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"Кошик простору\",\n    \"type\": \"Тип\",\n    \"resetTrash\": \"Очистити кошик\",\n    \"deletedBy\": \"Видалено\",\n    \"deletedTime\": \"Час видалення\",\n    \"fromSpace\": \"З простору \\\"{{name}}\\\"\",\n    \"permanentDeleteTips\": \"Ви впевнені, що хочете остаточно видалити \\\"{{name}}\\\" {{resource}}?\",\n    \"resetTrashConfirm\": \"Ви впевнені, що хочете очистити кошик?\",\n    \"addToTrash\": \"Перемістити в кошик\",\n    \"description\": \"Дані в кошику все ще займають місце для використання записів та використання вкладень.\",\n    \"spaceDescription\": \"Відновити простори, видалені протягом останніх {{retentionDays}} днів\",\n    \"spaceInnerDescription\": \"Відновити бази, видалені з цього простору протягом останніх {{retentionDays}} днів\",\n    \"baseDescription\": \"Відновити ресурси, видалені з цієї бази протягом останніх {{retentionDays}} днів\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"URL-адреса пуста\",\n    \"install\": \"Встановити\",\n    \"publisher\": \"Видавець\",\n    \"lastUpdated\": \"Останнє оновлення\",\n    \"pluginNotFound\": \"Плагін не знайдено\",\n    \"pluginEmpty\": {\n      \"title\": \"Плагінів ще немає\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"Ви впевнені, що хочете увімкнути поточну автоматизацію?\"\n  },\n  \"email\": {\n    \"send\": \"Надіслати\",\n    \"config\": \"Налаштування електронної пошти\",\n    \"customConfig\": \"Користувацький поштовий сервер\",\n    \"notify\": \"Поштові сповіщення\",\n    \"automation\": \"Автоматизація електронної пошти\",\n    \"customNotifyConfig\": \"Користувацькі налаштування поштових сповіщень\",\n    \"customAutomationConfig\": \"Користувацькі налаштування автоматизації електронної пошти\",\n    \"addConfig\": \"Додати налаштування\",\n    \"editConfig\": \"Редагувати налаштування\",\n    \"resetConfig\": \"Скинути\",\n    \"testEmail\": \"Тестова електронна пошта\",\n    \"testEmailPlaceholder\": \"Будь ласка, введіть тестову електронну пошту\",\n    \"testEmailError\": \"Будь ласка, введіть правильну адресу тестової електронної пошти\",\n    \"testEmailSend\": \"Тестове повідомлення успішно надіслано, перевірте свою поштову скриньку\",\n    \"configError\": \"Будь ласка, введіть правильні налаштування електронної пошти\",\n    \"host\": \"Адреса сервера\",\n    \"hostDescription\": \"Будь ласка, введіть адресу SMTP поштового сервера, наприклад: smtp.example.com\",\n    \"port\": \"Порт\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"Автентифікація\",\n    \"username\": \"Ім'я користувача\",\n    \"password\": \"Пароль\",\n    \"sender\": \"Адреса відправника\",\n    \"senderName\": \"Ім'я відправника\",\n    \"subscribe\": \"Підписатися\",\n    \"unsubscribe\": \"Відписатися\",\n    \"unsubscribeList\": \"Список відписки\",\n    \"unsubscribeTime\": \"Час відписки\",\n    \"source\": \"Джерело\",\n    \"sourceAutomationDeleted\": \"Автоматизацію або вузол видалено\",\n    \"processing\": \"Обробка...\",\n    \"unsubscribeH1\": \"Підтвердити скасування підписки?\",\n    \"unsubscribeH2\": \"Ви збираєтеся відписатися від майбутніх рекламних акцій та оновлень продуктів Teable. Ви впевнені, що хочете відписатися?\",\n    \"subscribeH1\": \"Підтвердити підписку?\",\n    \"subscribeH2\": \"Ви збираєтеся підписатися на майбутні рекламні акції та оновлення продуктів Teable. Ви впевнені, що хочете підписатися?\",\n    \"unsubscribeListTip\": \"Наступні користувачі поточної бази відписалися, ви більше не зможете надсилати їм електронні листи. Під час імпорту електронних листів, будь ласка, розмістіть адреси електронної пошти в першому стовпці та назвіть заголовок \\\"email\\\".\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"Скинути пароль - {{brandName}}\",\n        \"title\": \"Скиньте ваш пароль\",\n        \"message\": \"Якщо ви не запитували цю зміну, будь ласка, проігноруйте цей лист. В іншому випадку натисніть кнопку нижче, щоб скинути пароль.\",\n        \"buttonText\": \"Скинути пароль\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"Підтвердження реєстрації - {{brandName}}\",\n          \"title\": \"Підтвердження реєстрації\",\n          \"message\": \"Ваш код підтвердження {{code}}, будь ласка, використайте його протягом {{expiresIn}} хвилин.\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"Підтвердження домену - {{brandName}}\",\n          \"title\": \"Підтвердження домену\",\n          \"message\": \"Ваш одноразовий код: {{code}}, будь ласка, використайте його протягом {{expiresIn}} хвилин.\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"Підтвердження зміни електронної пошти - {{brandName}}\",\n          \"title\": \"Підтвердження зміни електронної пошти\",\n          \"message\": \"Ваш код підтвердження {{code}}, будь ласка, використайте його протягом {{expiresIn}} хвилин.\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} додав вас до поля {{fieldName}} запису в {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> додав вас до поля <strong>{{fieldName}}</strong> запису в <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Переглянути запис\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} додав вас до {{refLength}} записів у {{tableName}}\",\n        \"title\": \"<strong>{{fromUserName}}</strong> додав вас до <strong>{{refLength}}</strong> записів у <strong>{{tableName}}</strong>\",\n        \"buttonText\": \"Переглянути записи\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) запросив вас до {{resourceAlias}} {{resourceName}} - {{brandName}}\",\n        \"title\": \"Запрошення до співпраці\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) запросив вас до {{resourceAlias}} <strong>{{resourceName}}</strong>.\",\n        \"buttonText\": \"Прийняти запрошення\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"Ласкаво просимо - {{brandName}}\",\n        \"title\": \"Ласкаво просимо\",\n        \"message\": \"Ви успішно приєдналися до списку очікування {{brandName}}. Будь ласка, використовуйте наступний код запрошення для реєстрації: {{code}}, його можна використати {{times}} разів.\",\n        \"buttonText\": \"Зареєструватися\"\n      },\n      \"test\": {\n        \"subject\": \"Тестовий лист - {{brandName}}\",\n        \"title\": \"Тестовий лист\",\n        \"message\": \"Це тестовий лист, будь ласка, проігноруйте його.\"\n      },\n      \"notify\": {\n        \"subject\": \"Сповіщення - {{brandName}}\",\n        \"title\": \"Сповіщення\",\n        \"buttonText\": \"Переглянути\",\n        \"import\": {\n          \"title\": \"Сповіщення про результат імпорту\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ Імпорт {{tableName}} перервано: {{errorMessage}} діапазон невдалих рядків: [{{range}}]. Будь ласка, перевірте дані цього діапазону та спробуйте знову.\"\n            },\n            \"failed\": {\n              \"message\": \"❌ Імпорт {{tableName}} не вдався: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ Імпорт {{tableName}} не вдався: Досягнуто ліміт рядків, оновіть план для імпорту більшої кількості записів\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ Імпорт {{tableName}} не вдався: Жоден запис не був оброблений\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} успішно імпортовано.\",\n              \"inplace\": \"🎉 {{tableName}} успішно імпортовано на місці.\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} partially imported: {{successCount}} rows succeeded, {{failedCount}} rows failed.\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed. <a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 Download</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} import failed: all {{failedCount}} rows failed.\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"Сповіщення про коментар до запису\",\n          \"message\": \"{{fromUserName}} залишив коментар до {{recordName}} у {{tableName}} у {{baseName}}\"\n        },\n        \"automation\": {\n          \"title\": \"Сповіщення про автоматизацію\",\n          \"failed\": {\n            \"title\": \"Автоматизація {{name}} не вдалася\",\n            \"message\": \"Ваша автоматизація {{name}} не змогла виконатися. Натисніть кнопку нижче, щоб переглянути конкретні помилки з історії виконання.\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"Автоматизація {{name}} не вдалася через недостатній кредит\",\n            \"message\": \"Ваша автоматизація {{name}} не змогла виконатися через недостатній кредит. Будь ласка, оновіть підписку або зверніться до підтримки.\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"Автоматизація {{name}} досягла максимального місячного ліміту запусків\",\n            \"message\": \"Місячний ліміт запусків для автоматизації {{name}} вичерпано, тому зараз запуск недоступний. Оновіть підписку або придбайте додаткові запуски.\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"Сповіщення про оплату\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"Простір {{spaceName}} кредити ШІ використано 80 %\",\n              \"message\": \"Ваш простір використав 80 % кредитів ШІ. Розгляньте можливість оновлення або придбання додаткових кредитів.\"\n            },\n            \"warning90\": {\n              \"title\": \"Простір {{spaceName}} кредити ШІ використано 90 %\",\n              \"message\": \"Ваш простір використав 90 % кредитів ШІ. Оновіть план найближчим часом, щоб уникнути перебоїв.\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"Простір {{spaceName}} запуски автоматизації використано 80 % квоти\",\n              \"message\": \"Ваш простір використав {{usedRuns}} з {{totalLimit}} запусків автоматизації (80 %). Розгляньте можливість оновлення плану.\"\n            },\n            \"warning90\": {\n              \"title\": \"Простір {{spaceName}} запуски автоматизації використано 90 % квоти\",\n              \"message\": \"Ваш простір використав {{usedRuns}} з {{totalLimit}} запусків автоматизації (90 %). Оновіть план найближчим часом, щоб уникнути перебоїв.\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"Простір {{spaceName}} запуски автоматизації перевищено – пільговий період активний\",\n              \"message\": \"Квоту запусків автоматизації перевищено. Автоматизації припинять роботу та будуть вимкнені через {{remainingHours}} годин. Будь ласка, оновіть свій план.\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"Сповіщення про результат експорту бази\",\n          \"success\": {\n            \"message\": \"{{baseName}} успішно експортовано: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ Експорт {{baseName}} не вдався: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"Завдання ШІ не вдалося в таблиці {{tableName}}\",\n              \"message\": \"Завдання ШІ для поля {{fieldName}} в таблиці {{tableName}} (ID запису: {{recordId}}) не вдалося.\\n\\n{{errorMsg}}\\n\\nНатисніть кнопку нижче, щоб переглянути деталі.\"\n            }\n          }\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"Черга\",\n    \"email\": \"Електронна пошта\",\n    \"joinTitle\": \"Ми швидко розширюємося, щоб задовольнити попит\",\n    \"joinDesc\": \"Приєднуйтесь до черги — ми повідомимо вас, як тільки будемо готові\",\n    \"emailPlaceholder\": \"Введіть вашу електронну пошту...\",\n    \"youAreOnTheList\": \"Ви вже в черзі!\",\n    \"thanksForJoining\": \"Дякуємо за реєстрацію в нашій черзі. Ми повідомимо вас, як тільки будемо готові.\",\n    \"back\": \"Назад\",\n    \"inviteCodePlaceholder\": \"Введіть код запрошення\",\n    \"join\": \"Приєднатися до черги\",\n    \"joining\": \"Приєднуємося...\",\n    \"invite\": \"Запросити\",\n    \"inviteTime\": \"Час запрошення\",\n    \"createdTime\": \"Час приєднання\",\n    \"yes\": \"Так\",\n    \"no\": \"Ні\",\n    \"generateCode\": \"Створити код запрошення\",\n    \"count\": \"Кількість\",\n    \"times\": \"Використання\",\n    \"generate\": \"Створити\",\n    \"code\": \"Код запрошення\",\n    \"inviteSuccess\": \"Запрошення успішно\",\n    \"app\": {\n      \"previewAppError\": \"Помилка застосунку\",\n      \"sendErrorToAI\": \"Надіслати помилку в AI\"\n    }\n  },\n  \"base\": {\n    \"deleteTip\": \"Ви впевнені, що хочете видалити \\\"{{name}}\\\" базу?\",\n    \"createResource\": \"Створити ресурс\",\n    \"noPermissionToCreateResource\": \"У вас немає дозволу створювати ресурс\"\n  },\n  \"credit\": {\n    \"title\": \"Кредит\",\n    \"leftAmount\": \"Залишок кредитів\",\n    \"winFreeCredits\": \"Поділитися досвідом\",\n    \"getCredits\": \"Отримати 1000 безкоштовних кредитів\",\n    \"winCredit\": {\n      \"title\": \"Поділіться позитивним відгуком та заробіть\",\n      \"freeCredits\": \"1000 безкоштовних кредитів\",\n      \"guidelinesTitle\": \"Контрольний список для публікації\",\n      \"tagTeableio\": \"Позначте <bold>@teableio</bold>\",\n      \"minCharacters\": \"Напишіть відгук з <bold>80+</bold> символів\",\n      \"minFollowers\": \"Майте <bold>10+</bold> підписників\",\n      \"limitPerWeek\": \"500 кредитів за пост, до 2 постів на тиждень (X + LinkedIn)\",\n      \"postOnX\": \"Опублікувати в X\",\n      \"postOnLinkedIn\": \"Опублікувати в LinkedIn\",\n      \"preFilledDraft\": \"🌟 Готовий чернетка вже підготовлена для вас!\",\n      \"claimTitle\": \"Вставте URL поста, щоб отримати кредити\",\n      \"userEmail\": \"Email користувача\",\n      \"postUrlLabel\": \"URL поста (X або LinkedIn)\",\n      \"postUrlPlaceholder\": \"Вставте посилання на ваш пост тут\",\n      \"invalidUrl\": \"Будь ласка, введіть коректний URL поста X або LinkedIn\",\n      \"claiming\": \"Запит...\",\n      \"claimCredits\": \"Отримати кредити\",\n      \"congratulations\": \"Вітаємо!\",\n      \"claimSuccess\": \"Ви отримали 500 кредитів!\",\n      \"verifying\": \"Перевірка вашого поста...\",\n      \"verifyingDescription\": \"Ми перевіряємо вміст вашого поста, це зазвичай займає кілька секунд\",\n      \"verifyFailed\": \"Перевірка не вдалася\",\n      \"tryAgain\": \"Спробувати знову\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"Перевірка не вдалася\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"Винагорода\",\n    \"rewardCredits\": \"Кредити винагороди\",\n    \"minCharCount\": \"Пост повинен містити щонайменше {{count}} символів\",\n    \"minFollowerCount\": \"Обліковий запис повинен мати щонайменше {{count}} підписників\",\n    \"mustMention\": \"Пост повинен згадувати {{mention}}\",\n    \"fetchSnapshotFailed\": \"Не вдалося отримати знімок поста\",\n    \"alreadyClaimedThisWeek\": \"Ви вже отримали винагороду для цього облікового запису цього тижня\",\n    \"manage\": {\n      \"title\": \"Управління винагородами\",\n      \"description\": \"Переглядайте та керуйте записами винагород у всіх просторах\",\n      \"overview\": \"Огляд\",\n      \"records\": \"Записи\",\n      \"searchSpace\": \"Пошук простору...\",\n      \"searchRecords\": \"Пошук postUrl/userId...\",\n      \"dateRange\": \"Вибрати діапазон дат\",\n      \"from\": \"Від\",\n      \"to\": \"До\",\n      \"totalSpaces\": \"Всього: {{count}} просторів\",\n      \"totalRecords\": \"Всього: {{count}} записів\",\n      \"space\": \"Простір\",\n      \"allSpaces\": \"Усі простори\",\n      \"user\": \"Користувач\",\n      \"creator\": \"Створювач\",\n      \"sourceType\": \"Тип джерела\",\n      \"platform\": \"Платформа\",\n      \"allStatuses\": \"Усі статуси\",\n      \"allSourceTypes\": \"Усі типи джерел\",\n      \"allPlatforms\": \"Усі платформи\",\n      \"pendingCount\": \"Очікують\",\n      \"approvedCount\": \"Схвалених\",\n      \"rejectedCount\": \"Відхилених\",\n      \"approvedAmount\": \"Схвалена сума\",\n      \"consumedAmount\": \"Використана сума\",\n      \"availableAmount\": \"Доступна сума\",\n      \"expiringSoonAmount\": \"Скоро закінчується (7 днів)\",\n      \"amount\": \"Сума\",\n      \"remainingAmount\": \"Залишкова сума\",\n      \"createdTime\": \"Дата створення\",\n      \"rewardTime\": \"Дата винагороди\",\n      \"expiredTime\": \"Дата закінчення\",\n      \"lastModified\": \"Остання зміна\",\n      \"viewDetails\": \"Детальніше\",\n      \"details\": \"Деталі винагороди\",\n      \"basicInfo\": \"Основна інформація\",\n      \"amountInfo\": \"Інформація про суму\",\n      \"timeInfo\": \"Часова інформація\",\n      \"socialInfo\": \"Соціальна інформація\",\n      \"verifyResult\": \"Результат перевірки\",\n      \"uniqueKey\": \"Унікальний ключ\",\n      \"verify\": \"Перевірити\",\n      \"valid\": \"Дійсний\",\n      \"invalid\": \"Недійсний\",\n      \"errors\": \"Помилки\",\n      \"copied\": \"{{label}} скопійовано\",\n      \"openPost\": \"Відкрити пост\",\n      \"noData\": \"Немає даних\",\n      \"page\": \"Сторінка {{current}} з {{total}}\",\n      \"status\": {\n        \"label\": \"Статус\",\n        \"pending\": \"Очікування\",\n        \"approved\": \"Схвалено\",\n        \"rejected\": \"Відхилено\"\n      }\n    }\n  },\n  \"clickToCopyTooltip\": \"Клацніть, щоб скопіювати в буфер обміну\",\n  \"copiedTooltip\": \"Скопійовано!\",\n  \"hiddenFieldCount_one\": \"{{count}} приховане поле\",\n  \"hiddenFieldCount_other\": \"{{count}} прихованих полів\",\n  \"invalidFieldMapping\": \"Недійсне зіставлення поля\",\n  \"sourceFieldNotFoundMapping\": \"Поле джерела не знайдено\",\n  \"targetFieldNotFoundMapping\": \"Цільове поле не знайдено\",\n  \"fieldTypeNotSupportedMapping\": \"Тип поля не підтримується\",\n  \"fieldSettingsNotMatchMapping\": \"Налаштування поля не збігаються\",\n  \"fieldSettingsLookupNotMatch\": \"Налаштування поля пошуку не збігаються\",\n  \"fieldSettingsLinkTableNotMatch\": \"Зв'язана таблиця не збігається\",\n  \"fieldSettingsLinkViewNotMatch\": \"Зв'язаний вигляд не збігається\",\n  \"fieldTypeDifferentMapping\": \"Типи полів різні\",\n  \"fieldMappingSourceTip\": \"Зіставте поля джерела з цільовими полями\",\n  \"fieldMappingTargetTip\": \"Зіставте поля з ціллю\",\n  \"reset\": \"Скинути\",\n  \"checkAll\": \"Вибрати все\",\n  \"uncheckAll\": \"Скасувати вибір\",\n  \"duplicateOptionsMapping\": \"Повторювані варіанти\",\n  \"lookupFieldInvalidMapping\": \"Поле пошуку недійсне\",\n  \"noMatchedOptions\": \"Немає відповідних варіантів\",\n  \"needManualSelectionMapping\": \"Потрібен ручний вибір\",\n  \"targetFieldIsComputed\": \"Цільове поле обчислюване\",\n  \"targetFieldIsComputedTips\": \"Цільове поле обчислюване і не може бути змінене\",\n  \"emptyOption\": \"(порожньо)\",\n  \"showEmptyTip\": \"Показати порожні варіанти\",\n  \"hideEmptyTip\": \"Приховати порожні варіанти\",\n  \"hideText\": \"Приховати текст\",\n  \"showText\": \"Показати текст\",\n  \"sourceTable\": \"Таблиця джерела\",\n  \"sourceView\": \"Вигляд джерела\",\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"Сторінку не знайдено\",\n      \"description\": \"Посилання, за яким ви перейшли, може бути недійсним, або сторінку переміщено.\"\n    },\n    \"links\": {\n      \"backToHome\": \"Повернутися на головну\"\n    },\n    \"forbidden\": {\n      \"title\": \"Доступ обмежено\",\n      \"description\": \"Вам потрібен дозвіл для доступу до цього ресурсу.\\nБудь ласка, зверніться до адміністратора.\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"Розблокувати преміум-функцію\",\n      \"description\": \"Ця функція доступна в розширених тарифах.\\nОновіть план, щоб розширити можливості.\"\n    },\n    \"error\": {\n      \"title\": \"Щось пішло не так\",\n      \"description\": \"Сталася неочікувана помилка. Будь ласка, спробуйте пізніше.\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}Помилка розбору дати, значення \\\"{{value}}\\\" виходить за допустимий діапазон\",\n      \"planRowLimit\": \"Досягнуто ліміт рядків: оновіть план для імпорту більшої кількості записів\",\n      \"notNullValidation\": \"{{fieldHint}}Обов'язкові поля не можуть бути порожніми\",\n      \"uniqueValidation\": \"{{fieldHint}}Дублікати значень в унікальних полях\",\n      \"requestTimeout\": \"Час очікування запиту вичерпано\",\n      \"chunkProcessingFailed\": \"Помилка пакетної обробки: {{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"НОВЕ ОНОВЛЕННЯ\",\n    \"title\": \"Масове завантаження вкладень доступне\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"Дашбордів ще немає\",\n    \"description\": \"Схоже, ви ще не створили жодного дашборду. Дашборди допомагають візуалізувати дані та ефективніше ними керувати.\",\n    \"create\": \"Створіть свою першу дашборд-панель\"\n  },\n  \"addPlugin\": \"Додати плагін\",\n  \"createDashboard\": {\n    \"button\": \"Створіть дашборд\",\n    \"title\": \"Створіть новий дашборд\",\n    \"placeholder\": \"Введіть назву дашборду\"\n  },\n  \"findDashboard\": \"Знайти дашборд...\",\n  \"expand\": \"Розгорнути\",\n  \"deprecation\": {\n    \"title\": \"Функцію вузла дашборду буде припинено\",\n    \"description\": \"Щоб забезпечити вам розумніший та ефективніший досвід, ми припинимо підтримку функції вузла дашборду. Ви зможете легко створювати нові дашборди за допомогою ШІ в додатку, створеному ШІ.\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"Конструктор API-запитів\",\n  \"subTitle\": \"Ви можете швидко створити запити через інтерактивний інтерфейс та скопіювати код, який можна виконати безпосередньо\",\n  \"apiList\": \"Повний список API\",\n  \"cellFormat\": \"Формат результату комірки\",\n  \"fieldKeyType\": \"Тип ключа поля\",\n  \"chooseSource\": \"Виберіть джерело даних\",\n  \"action\": {\n    \"selectBase\": \"Виберіть базу даних...\",\n    \"selectTable\": \"Виберіть таблицю...\"\n  },\n  \"pickParams\": \"Виберіть та налаштуйте параметри\",\n  \"buildResult\": \"Побудувати результат\",\n  \"buildResultEmpty\": \"Поки немає результатів\",\n  \"previewReturnValue\": \"Перегляд значення, що повертається\",\n  \"replaceToken\": \"Замінити токен\",\n  \"createNewToken\": \"Створити новий токен\",\n  \"only10Records\": \"Відображаються лише перші 10 записів\",\n  \"addSort\": \"Додати сортування\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/oauth.json",
    "content": "{\n  \"add\": \"Нові OAuth-додатки\",\n  \"title\": {\n    \"add\": \"Нові OAuth-додатки\",\n    \"edit\": \"Редагувати OAuth-додатки\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"Ім'я OAuth-програми\",\n      \"description\": \"Назва вашої програми OAuth.\"\n    },\n    \"description\": {\n      \"label\": \"Опис\",\n      \"description\": \"Короткий опис вашого OAuth-програми.\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"URL головної сторінки\",\n      \"description\": \"Повна URL-адреса веб-сайту вашого OAuth-програми.\"\n    },\n    \"logo\": {\n      \"Label\": \"Логотип\",\n      \"description\": \"Рекомендується квадратне зображення.\",\n      \"placeholder\": \"Перетягніть логотип сюди або натисніть, щоб завантажити\",\n      \"button\": \"Завантажити логотип\",\n      \"clear\": \"Очистити\",\n      \"lengthError\": \"Дозволено лише один файл.\",\n      \"typeError\": \"Дозволено лише файли зображень.\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"URL зворотного дзвінка\",\n      \"description\": \"Повна URL-адреса для перенаправлення після авторизації користувача для інтеграції.\",\n      \"add\": \"Додати URL зворотного дзвінка\"\n    },\n    \"scopes\": {\n      \"label\": \"Області дії\",\n      \"description\": \"Дозволи, необхідні вашому OAuth-додатку.\"\n    },\n    \"secret\": {\n      \"label\": \"Секрети клієнта\",\n      \"add\": \"Створити новий секрет клієнта\",\n      \"newDescription\": \"Обов'язково скопіюйте новий секрет клієнта зараз. Ви більше не зможете його побачити.\",\n      \"empty\": \"Вам потрібен секрет клієнта для автентифікації програми через API.\",\n      \"lastUsed\": \"Останнє використання {{date}}\",\n      \"tag\": \"Секрет клієнта\",\n      \"neverUsed\": \"Ніколи не використовувалося\"\n    },\n    \"clientId\": {\n      \"label\": \"ID клієнта: \"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"Основна інформація\",\n    \"scopes\": \"Області дії\",\n    \"identify\": \"Ідентифікація та авторизація користувачів\",\n    \"clientInfo\": \"Інформація про клієнта\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} запитує доступ до вашого облікового запису\",\n    \"scopes\": \"Ця програма зможе отримати такі області дії:\",\n    \"redirectDescription\": \"Авторизація призведе до перенаправлення на\",\n    \"authorize\": \"Авторизувати\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/plugin.json",
    "content": "{\n  \"add\": \"Нові Плагіни\",\n  \"title\": {\n    \"add\": \"Нові Плагіни\",\n    \"edit\": \"Редагувати Плагіни\"\n  },\n  \"pluginUser\": {\n    \"name\": \"Користувач Плагіна\",\n    \"description\": \"Користувач плагіна автоматично створюється системою\"\n  },\n  \"secret\": \"Секрет\",\n  \"regenerateSecret\": \"Перегенерувати Секрет\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"Назва\",\n      \"description\": \"Назва плагіна\"\n    },\n    \"description\": {\n      \"label\": \"Опис\",\n      \"description\": \"Короткий опис плагіна\"\n    },\n    \"detailDesc\": {\n      \"label\": \"Детальний опис\",\n      \"description\": \"Докладний опис плагіна\"\n    },\n    \"logo\": {\n      \"Label\": \"Логотип\",\n      \"description\": \"Логотип плагіна. Ви можете завантажити зображення або використовувати URL\",\n      \"upload\": \"Завантажити\",\n      \"clear\": \"Очистити\",\n      \"placeholder\": \"Перетягніть логотип сюди або натисніть, щоб завантажити\",\n      \"lengthError\": \"Допускається лише один файл.\",\n      \"typeError\": \"Допускаються лише файли зображень.\"\n    },\n    \"helpUrl\": {\n      \"label\": \"URL Допомоги\",\n      \"description\": \"URL документації з плагіну\"\n    },\n    \"positions\": {\n      \"label\": \"Позиції\",\n      \"description\": \"Позиції плагіна\"\n    },\n    \"i18n\": {\n      \"label\": \"I18n\",\n      \"description\": \"i18n плагіна, включає (назву, опис, детальний опис)\"\n    },\n    \"url\": {\n      \"label\": \"URL\",\n      \"description\": \"URL плагіна\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"Писати\",\n    \"preview\": \"Перегляд\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"Незабаром\",\n    \"empty\": \"Порожній\",\n    \"noRecords\": \"Немає записів\",\n    \"unnamedRecord\": \"Запис без назви\",\n    \"untitled\": \"Без назви\",\n    \"cancel\": \"Скасувати\",\n    \"confirm\": \"Підтвердити\",\n    \"back\": \"Назад\",\n    \"done\": \"Готово\",\n    \"create\": \"Створити\",\n    \"search\": {\n      \"placeholder\": \"Шукати...\",\n      \"empty\": \"Не знайдено результатів\"\n    },\n    \"readOnlyTip\": \"Цей вигляд заблокований. Ви можете увімкнути <button>персональний режим</button> для редагування параметрів вигляду, і зміни набереться в дію лише для вас.\",\n    \"selectPlaceHolder\": \"Виберіть...\",\n    \"loading\": \"Завантаження...\",\n    \"loadMore\": \"Завантажити більше\",\n    \"uploadFailed\": \"Помилка завантаження\",\n    \"rowCount\": \"{{count}} записів\",\n    \"summary\": \"Підсумок\",\n    \"summaryTip\": \"Наведіть курсор, щоб вибрати підсумок\",\n    \"actions\": \"Дії\",\n    \"remove\": \"Видалити\",\n    \"runStatus\": {\n      \"success\": \"{{name}} успішно\",\n      \"failed\": \"{{name}} не вдалося\",\n      \"running\": \"{{name}} в процесі\"\n    },\n    \"resetSuccess\": \"Скинуто успішно\",\n    \"click\": \"Натисніть\",\n    \"clickedCount\": \"{{label}}: Натиснуто {{text}} разів\",\n    \"atLeastOne\": \"Принаймні зарезервуйте {{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"Сповіщення\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"Обмеження розміру файлу попереднього перегляду: {{size}} МБ, будь ласка, завантажте для перегляду.\",\n    \"loadFileError\": \"Не вдалося завантажити файл\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"Скасувати\",\n    \"redo\": \"Повторити\",\n    \"undoFailed\": \"Помилка скасування\",\n    \"redoFailed\": \"Повторити помилку\",\n    \"nothingToUndo\": \"Немає нічого для скасування\",\n    \"nothingToRedo\": \"Нема чого повторювати\",\n    \"undoSucceed\": \"Скасувати успішно\",\n    \"redoSucceed\": \"Повторити успішно\",\n    \"undoing\": \"скасування...\",\n    \"redoing\": \"перероблення...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"Відпустіть, щоб завантажити файл\",\n      \"uploadBaseTextPrefix\": \"Click to upload \",\n      \"uploadBaseText\": \"or paste or drag and drop here\",\n      \"uploadDragDefault\": \"Вставте або перетягніть, щоб завантажити сюди\",\n      \"upload\": \"завантажити\"\n    },\n    \"date\": {\n      \"placeholder\": \"Вибрати дату\",\n      \"today\": \"Сьогодні\"\n    },\n    \"formula\": {\n      \"title\": \"Редактор формул\",\n      \"guideSyntax\": \"Синтаксис\",\n      \"guideExample\": \"Приклад\",\n      \"helperExample\": \"Приклад: \",\n      \"fieldValue\": \"Повертає значення до клітинок поля {{fieldName}}.\",\n      \"placeholder\": \"Введіть вираз\",\n      \"placeholderForAIPrompt\": \"Опишіть формулу, яку ви хочете створити\",\n      \"editExpression\": \"Редагувати формулу\",\n      \"generateExpressionByAI\": \"Створіть формулу за допомогою ШІ\",\n      \"inputPrompt\": \"Введіть інструкцію\",\n      \"generateExpression\": \"Створена формула\",\n      \"generatingByAI\": \"Створюється формула за допомогою ШІ...\",\n      \"generatedExpressionTips\": \"Після створення, натисніть на кнопку Примінити, щоб вставити формулу швидко\",\n      \"action\": {\n        \"generating\": \"Створюється...\",\n        \"generate\": \"Створити\",\n        \"apply\": \"Примінити\"\n      },\n      \"expressionRequired\": \"Вираз обов'язковий\"\n    },\n    \"link\": {\n      \"placeholder\": \"Виберіть записи для зв'язування\",\n      \"searchPlaceholder\": \"Пошук записів\",\n      \"allFields\": \"Усі поля\",\n      \"globalSearch\": \"Глобальний пошук\",\n      \"fieldSearch\": \"Пошук по полю\",\n      \"maxFieldTips\": \"Перевищено максимальну кількість {{count}} полів, зайві поля будуть проігноровані\",\n      \"create\": \"Додати запис\",\n      \"selectRecord\": \"Виберіть запис\",\n      \"all\": \"Усі\",\n      \"selected\": \"Вибрано\",\n      \"expandRecordError\": \"Немає дозволу на перегляд цього запису.\",\n      \"alreadyOpen\": \"Цей запис уже відкрито.\",\n      \"linkedTo\": \"Пов'язано з\",\n      \"goToForeignTable\": \"Перейти до іноземної таблиці\",\n      \"foreignTableIdRequired\": \"Зовнішня таблиця обов'язкова\",\n      \"linkFieldIdRequired\": \"Поле зв'язку обов'язкове\",\n      \"selectTooManyRecords\": \"Вибрані записи не повинні перевищувати {{maxCount}}\",\n      \"relationshipRequired\": \"Відношення обов'язкове\",\n      \"rangeSelectFailed\": \"Не вдалося вибрати записи в діапазоні\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"Знайти користувачів за іменами\",\n      \"notify\": \"Повідомити користувачів, коли їх буде вибрано\"\n    },\n    \"select\": {\n      \"addOption\": \"Додайте опцію '{{option}}'\",\n      \"choicesNameRequired\": \"Назва вибору не може бути порожньою\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"Поле пошуку обов'язкове\",\n      \"lookupOptionsNotAllowed\": \"Опції пошуку не дозволені, коли атрибут isLookup істинний або тип поля rollup.\",\n      \"lookupOptionsRequired\": \"Опції пошуку обов'язкові\",\n      \"refineOptionsError\": \"Помилка парсингу опцій пошуку {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"Вираз обов'язковий\",\n      \"unsupportedTip\": \"Rollup підтримує лише поля зв'язку та rollup\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"Фільтр має містити щонайменше одну умову\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"Для умовного пошуку потрібна щонайменше одна умова фільтра\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"Модель обов'язкова\",\n      \"typeNotSupported\": \"Непідтримуваний тип AI\",\n      \"sourceFieldIdRequired\": \"Вихідне поле обов'язкове\",\n      \"targetLanguageRequired\": \"Цільова мова обов'язкова\",\n      \"promptRequired\": \"Запит обов'язковий\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"Помилка парсингу опцій поля {{message}}\",\n      \"optionsRequired\": \"Опції поля обов'язкові\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"Фільтр\",\n    \"displayLabel\": \"Фільтрувати за \",\n    \"displayLabel_other\": \"Фільтрувати за {{fieldName}} та {{count}} іншими полями\",\n    \"addCondition\": \"Додати умову\",\n    \"addConditionGroup\": \"Додати групу умов\",\n    \"nestedLimitTip\": \"Умови фільтрації можуть бути вкладені лише на {{depth}} рівнів\",\n    \"linkInputPlaceholder\": \"Введіть значення\",\n    \"groupDescription\": \"Будь-що з наведеного нижче вірно...\",\n    \"currentUser\": \"Я (поточний користувач)\",\n    \"tips\": {\n      \"scope\": \"У цьому поданні відобразити записи\"\n    },\n    \"invalidateSelected\": \"Недійсне значення\",\n    \"invalidateSelectedTips\": \"Вибране значення видалено, виберіть ще раз\",\n    \"default\": {\n      \"empty\": \"Умови фільтрування не застосовуються\",\n      \"placeholder\": \"Введіть значення\"\n    },\n    \"conjunction\": {\n      \"and\": \"і\",\n      \"or\": \"або\",\n      \"where\": \"де\",\n      \"meetingAll\": \"Відповідати всім умовам\",\n      \"meetingAny\": \"Відповідати будь-якій умові\"\n    },\n    \"operator\": {\n      \"is\": \"є\",\n      \"isNot\": \"не є\",\n      \"contains\": \"містить\",\n      \"doesNotContain\": \"не містить\",\n      \"isEmpty\": \"порожньо\",\n      \"isNotEmpty\": \"не порожньо\",\n      \"isGreater\": \"більше ніж\",\n      \"isGreaterEqual\": \"більше або одно\",\n      \"isLess\": \"менше ніж\",\n      \"isLessEqual\": \"менше або одно\",\n      \"isAnyOf\": \"є будь-яким з\",\n      \"isNoneOf\": \"не є жодним з\",\n      \"hasAnyOf\": \"має будь-який з\",\n      \"hasAllOf\": \"має все з\",\n      \"hasNoneOf\": \"не має жодного з\",\n      \"isExactly\": \"точно\",\n      \"isWithIn\": \"в межах\",\n      \"isBefore\": \"до\",\n      \"isAfter\": \"після\",\n      \"isOnOrBefore\": \"на або до\",\n      \"isOnOrAfter\": \"на або після\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"Use field value\",\n      \"switchToValue\": \"Use manual value\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"сьогодні\",\n        \"tomorrow\": \"завтра\",\n        \"yesterday\": \"вчора\",\n        \"oneWeekAgo\": \"тиждень тому\",\n        \"oneWeekFromNow\": \"через тиждень\",\n        \"oneMonthAgo\": \"місяць тому\",\n        \"oneMonthFromNow\": \"через місяць\",\n        \"daysAgo\": \"днів тому\",\n        \"daysFromNow\": \"днів відтепер\",\n        \"exactDate\": \"точна дата\",\n        \"exactFormatDate\": \"точна дата (форматована)\",\n        \"currentWeek\": \"поточний тиждень\",\n        \"currentMonth\": \"поточний місяць\",\n        \"currentYear\": \"поточний рік\",\n        \"lastWeek\": \"минулого тижня\",\n        \"lastMonth\": \"минулий місяць\",\n        \"lastYear\": \"минулого року\",\n        \"nextWeekPeriod\": \"наступного тижня\",\n        \"nextMonthPeriod\": \"наступного місяця\",\n        \"nextYearPeriod\": \"наступного року\",\n        \"pastWeek\": \"минулий тиждень\",\n        \"pastMonth\": \"минулий місяць\",\n        \"pastYear\": \"минулий рік\",\n        \"nextWeek\": \"наступного тижня\",\n        \"nextMonth\": \"наступного місяця\",\n        \"nextYear\": \"наступного року\",\n        \"pastNumberOfDays\": \"остання кількість днів\",\n        \"nextNumberOfDays\": \"наступна кількість днів\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"колір\"\n  },\n  \"rowHeight\": {\n    \"short\": \"низький\",\n    \"medium\": \"середній\",\n    \"tall\": \"високий\",\n    \"extraTall\": \"дуже високий\",\n    \"title\": \"высоты строки\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"имени поля\",\n    \"displayLines\": \"{{count}} строк\"\n  },\n  \"share\": {\n    \"title\": \"поділитися\"\n  },\n  \"extensions\": {\n    \"title\": \"Розширення\"\n  },\n  \"hidden\": {\n    \"label\": \"Приховані поля\",\n    \"configLabel_one\": \"{{count}} приховане поле\",\n    \"configLabel_other\": \"{{count}} прихованих поля\",\n    \"configLabel_other_visible\": \"{{count}} видимих ​​полів\",\n    \"showAll\": \"Показати все\",\n    \"hideAll\": \"Приховати все\",\n    \"primaryKey\": \"Основне поле: Ідентифікує записи\\nне можна приховати або видалити, видно у зв'язаних записах.\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"Копіювати в буфер обміну\",\n    \"duplicateRecord\": \"Дублювати запис\",\n    \"copyRecordUrl\": \"Копіювати URL записи\",\n    \"deleteRecord\": \"Видалити запис\",\n    \"addRecordComment\": \"Додати коментар\",\n    \"viewRecordHistory\": \"Переглянути історію запису\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"Приховати історію запису\",\n      \"showRecordHistory\": \"Показати історію запису\",\n      \"createdTime\": \"Час створення\",\n      \"createdBy\": \"Створено\",\n      \"before\": \"До\",\n      \"after\": \"Після\",\n      \"viewRecord\": \"Переглянути запис\"\n    },\n    \"showHiddenFields\": \"Показати {{count}} прихованих поля\",\n    \"hideHiddenFields\": \"Приховати {{count}} прихованих поля\",\n    \"showMore\": \"Show more\",\n    \"showLess\": \"Show less\"\n  },\n  \"sort\": {\n    \"label\": \"Сортування\",\n    \"displayLabel_one\": \"Сортувати за {{count}} полем\",\n    \"displayLabel_other\": \"Сортувати за {{count}} полями\",\n    \"setTips\": \"Сортувати за\",\n    \"addButton\": \"Додати ще одне сортування\",\n    \"autoSort\": \"Автоматично сортувати записи\",\n    \"selectASCLabel\": \"від першого до останнього\",\n    \"selectDESCLabel\": \"від останнього до першого\"\n  },\n  \"group\": {\n    \"label\": \"Група\",\n    \"displayLabel_one\": \"Групувати по {{count}} полю\",\n    \"displayLabel_other\": \"Групувати по {{count}} полях\",\n    \"setTips\": \"Групувати\",\n    \"addButton\": \"Додати підгрупу\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"Однорядковий текст\",\n      \"longText\": \"Довгий текст\",\n      \"singleSelect\": \"Один вибір\",\n      \"number\": \"Кисло\",\n      \"multipleSelect\": \"Вибір кількох\",\n      \"link\": \"Посилання на інший запис\",\n      \"formula\": \"Формула\",\n      \"date\": \"Дата\",\n      \"createdTime\": \"Час створення\",\n      \"lastModifiedTime\": \"Час останньої зміни\",\n      \"attachment\": \"Вкладення\",\n      \"checkbox\": \"Прапорець\",\n      \"rollup\": \"Зведення\",\n      \"conditionalRollup\": \"Умовне зведення\",\n      \"user\": \"Користувач\",\n      \"rating\": \"Рейтинг\",\n      \"autoNumber\": \"Автоматичний номер\",\n      \"lookup\": \"Пошук\",\n      \"conditionalLookup\": \"Умовний пошук\",\n      \"button\": \"Кнопка\",\n      \"createdBy\": \"Створено\",\n      \"lastModifiedBy\": \"Востаннє змінено\"\n    },\n    \"description\": {\n      \"singleLineText\": \"Зберігайте короткий текст на кшталт імен чи заголовків.\",\n      \"longText\": \"Записуйте довші нотатки та описи.\",\n      \"singleSelect\": \"Вибирайте один варіант зі списку.\",\n      \"number\": \"Відстежуйте числові значення з форматуванням.\",\n      \"multipleSelect\": \"Позначайте записи кількома варіантами.\",\n      \"link\": \"Пов'язуйте цей запис з іншою таблицею.\",\n      \"formula\": \"Обчислюйте значення на основі інших полів.\",\n      \"date\": \"Фіксуйте дати або час.\",\n      \"createdTime\": \"Показує, коли запис був створений.\",\n      \"lastModifiedTime\": \"Показує час останнього оновлення.\",\n      \"attachment\": \"Завантаження файлів або генерація зображень за допомогою ШІ, підтримує моделі типу 🍌 Nano banana pro\",\n      \"checkbox\": \"Перемикайте просте «так» або «ні».\",\n      \"rollup\": \"Підсумовуйте пов'язані записи за формулами.\",\n      \"conditionalRollup\": \"Підсумовуйте дані за умовами.\",\n      \"user\": \"Призначайте записи учасникам робочого простору.\",\n      \"rating\": \"Оцінюйте елементи налаштовуваними іконками.\",\n      \"autoNumber\": \"Надавайте кожному запису унікальний номер.\",\n      \"lookup\": \"Відображайте значення з пов'язаних записів.\",\n      \"conditionalLookup\": \"Показує пов'язані значення, що відповідають заданим фільтрам.\",\n      \"button\": \"Запускайте дії натисканням кнопки.\",\n      \"createdBy\": \"Показує, хто створив запис.\",\n      \"lastModifiedBy\": \"Показує, хто останнім редагував запис.\"\n    },\n    \"link\": {\n      \"oneWay\": \"Однонаправленный\",\n      \"twoWay\": \"Двунаправленный\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"Підтвердження дії\",\n        \"description\": \"Ви впевнені, що хочете виконати цю дію?\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"Створити простір\",\n      \"spaceDelete\": \"Видалити пробіл\",\n      \"spaceRead\": \"Читати простір\",\n      \"spaceUpdate\": \"Оновити простір\",\n      \"spaceInviteEmail\": \"Запросити електронною поштою в космос\",\n      \"spaceInviteLink\": \"Запросити за посиланням у просторі\",\n      \"spaceGrantRole\": \"Надати роль у просторі\",\n      \"baseCreate\": \"Створити базу\",\n      \"baseDelete\": \"Видалити базу\",\n      \"baseRead\": \"Читати базу\",\n      \"baseReadAll\": \"Читати всі бази\",\n      \"baseUpdate\": \"Оновити базу\",\n      \"baseInviteEmail\": \"Запросити електронною поштою в базі\",\n      \"baseInviteLink\": \"Запросити за посиланням у базі\",\n      \"baseTableImport\": \"Імпортувати дані в базу\",\n      \"baseAuthorityMatrixConfig\": \"Налаштувати матрицю повноважень\",\n      \"baseDbConnect\": \"Підключитися до бази даних\",\n      \"tableCreate\": \"Створити таблицю\",\n      \"tableRead\": \"Читати таблицю\",\n      \"tableDelete\": \"Видалити таблицю\",\n      \"tableUpdate\": \"Оновити таблицю\",\n      \"tableImport\": \"Імпортувати дані в таблицю\",\n      \"tableExport\": \"експорт даних таблиці\",\n      \"tableTrashRead\": \"Читати кошик таблиці\",\n      \"tableTrashUpdate\": \"Оновити кошик таблиці\",\n      \"tableTrashReset\": \"Скинути кошик таблиці\",\n      \"viewCreate\": \"Створити перегляд\",\n      \"viewDelete\": \"Видалити перегляд\",\n      \"viewRead\": \"Перегляд читання\",\n      \"viewUpdate\": \"Оновити перегляд\",\n      \"viewShare\": \"Поділитися переглядом\",\n      \"fieldCreate\": \"Створити поле\",\n      \"fieldDelete\": \"Видалити поле\",\n      \"fieldRead\": \"Прочитати поле\",\n      \"fieldUpdate\": \"Поле оновлення\",\n      \"recordCreate\": \"Створити запис\",\n      \"recordComment\": \"Коментар до запису\",\n      \"recordDelete\": \"Видалити запис\",\n      \"recordRead\": \"Прочитати запис\",\n      \"recordUpdate\": \"Оновити запис\",\n      \"recordCopy\": \"Copy record\",\n      \"automationCreate\": \"Створити автоматизацію\",\n      \"automationDelete\": \"Видалити автоматизацію\",\n      \"automationRead\": \"Автоматизація читання\",\n      \"automationUpdate\": \"Оновити автоматизацію\",\n      \"appCreate\": \"Створити додаток\",\n      \"appDelete\": \"Видалити додаток\",\n      \"appRead\": \"Читати додаток\",\n      \"appUpdate\": \"Оновити додаток\",\n      \"userProfileRead\": \"Читати поточний профіль користувача\",\n      \"userEmailRead\": \"Прочитати електронну адресу поточного користувача\",\n      \"userIntegrations\": \"Manage user integrations\",\n      \"recordHistoryRead\": \"Читати історію записів\",\n      \"baseQuery\": \"База запитів\",\n      \"instanceRead\": \"Прочитати екземпляр\",\n      \"instanceUpdate\": \"Оновити екземпляр\",\n      \"enterpriseRead\": \"Читати конфігурацію підприємства\",\n      \"enterpriseUpdate\": \"Оновити конфігурацію підприємства\"\n    }\n  },\n  \"noun\": {\n    \"table\": \"Таблиця\",\n    \"view\": \"Перегляд\",\n    \"space\": \"Простір\",\n    \"base\": \"База\",\n    \"field\": \"Поле\",\n    \"record\": \"Запис\",\n    \"automation\": \"Автоматизація\",\n    \"app\": \"Додаток\",\n    \"user\": \"Користувач\",\n    \"recordHistory\": \"Історія записів\",\n    \"you\": \"Ви\",\n    \"instance\": \"Екземпляр\",\n    \"enterprise\": \"Підприємство\",\n    \"history\": \"Історія\",\n    \"global\": \"Глобальний\"\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"Підсумуйте числа. Еквівалент number1 + number2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"Повертає середнє значення чисел.\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"Повертає найбільше з наведених чисел.\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"Повертає найменше з наведених чисел.\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"Округлює значення до кількості знаків після коми, указаної \\\"точністю\\\" (Зокрема, ROUND округляє до найближчого цілого числа з указаною точністю, розриваючи зв’язки, округляючи половину до позитивної нескінченності).\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"Округлює значення до кількості знаків після коми, заданої \\\"точністю\\\", завжди округлюючи в більшу сторону, тобто від нуля. (Ви повинні вказати значення для точності, інакше функція не працюватиме.)\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"Округлює значення до кількості знаків після коми, заданої \\\"точністю\\\", завжди округляючи вниз, тобто до нуля. (Ви повинні вказати значення для точності, інакше функція не працюватиме.)\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"Повертає найближче ціле кратне значення, яке більше або дорівнює значенню. Якщо значущість не вказана, вважається, що значущість дорівнює 1.\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"Повертає найближче ціле кратне значення, яке менше або дорівнює значенню. Якщо значущість не вказана, вважається, що значущість дорівнює 1.\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"Повертає найменше парне ціле число, яке більше або дорівнює вказаному значенню.\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"Округлює додатне значення до найближчого непарного числа вгору, а від’ємне – до найближчого непарного числа.\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"Округлює число вниз до найближчого цілого числа.\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"Повертає абсолютне значення.\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"Повертає квадратний корінь із невід’ємного числа.\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"Обчислює вказану основу до вказаного ступеня.\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"Обчислює число Ейлера (e) у вказаному ступені.\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"Обчислює логарифм значення в наданій основі. База за замовчуванням дорівнює 10, якщо не вказано.\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"Повертає залишок після ділення першого аргументу на другий.\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"Перетворює текстовий рядок на число.\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"Об’єднує аргументи різних типів значень в одне текстове значення.\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"Знаходить входження stringToFind у рядку whereToSearch, починаючи з необов’язкового startFromPosition. (за замовчуванням startFromPosition дорівнює 0). Якщо жодного входження stringToFind не знайдено, результатом буде 0.\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"Шукає наявність рядка stringToFind у рядку whereToSearch, починаючи з необов’язкового startFromPosition. (за умовчанням startFromPosition дорівнює 0.) Якщо не знайдено жодного входження stringToFind, результат буде порожнім.\\nПодібно до FIND(), хоча FIND() повертає 0, а не порожній, якщо не знайдено входження stringToFind.\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"Витягніть підрядок символів підрахунку, починаючи з індексу.\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"Витягніть скільки символів з початку рядка.\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"Витягніть скільки символів із кінця рядка.\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"Замінює кількість символів, що починаються з початкового символу, на текст заміни.\\n(Якщо ви шукаєте спосіб знайти та замінити всі входження old_text новим_текстом, див. SUBSTITUTE().)\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"Замінює всі підрядки, що відповідають регулярному виразу, заміною.\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"Замінює входження old_text на new_text.\\nДодатково можна вказати номер індексу (починаючи з 1), щоб замінити лише певний екземпляр old_text. Якщо номер індексу не вказано, усі входження old_text буде замінено.\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"Робить рядок нижнім регістром.\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"Робить рядок верхнім регістром.\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"Повторює рядок задану кількість разів.\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"Видаляє пробіли на початку та в кінці рядка.\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"Довжина символів.\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"Повертає аргумент, якщо це текст, і порожній в іншому випадку.\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"Замінює певні символи закодованими еквівалентами для використання під час побудови URL-адрес або URI. Не кодує наступні символи: - _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"Повертає значення1, якщо логічний аргумент істинний, інакше повертає значення2. Також можна використовувати для створення вкладених операторів IF.\\nМожна також використовувати для перевірки, чи клітинка порожня.\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"Приймає вираз, список можливих значень для цього виразу та для кожного значення, яке має приймати вираз у цьому випадку. Він також може приймати значення за замовчуванням, якщо вхідний вираз не відповідає жодному з визначених шаблонів. У багатьох випадках замість вкладеної формули IF() можна використовувати SWITCH().\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"Повертає true, якщо всі аргументи правдиві, повертає false в іншому випадку.\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"Повертає true, якщо будь-який із аргументів є істинним.\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"Повертає true, якщо непарна кількість аргументів є true.\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"Змінює логічне значення свого аргументу на протилежне.\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"Повертає порожнє значення.\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"Повертає значення помилки.\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"Повертає true, якщо вираз викликає помилку.\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"Повертає поточну дату.\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"Повертає поточну дату й час.\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"Повертає чотиризначний рік дати й часу.\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"Повертає місяць дати й часу як число від 1 (січень) до 12 (грудень).\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"Повертає номер тижня в році.\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"Повертає день тижня як ціле число від 0 до 6 включно. Додатково можна надати другий аргумент (\\\"Sunday\\\" або \\\"Monday\\\"), щоб тижні починалися з цього дня. Якщо опущено, тижні починаються з неділі за умовчанням. приклад:\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"Повертає день місяця дати й часу у вигляді числа від 1 до 31.\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"Повертає годину дати й часу як число від 0 до 23.\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"Повертає хвилини дати й часу як ціле число від 0 до 59.\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"Повертає секунду дати й часу як ціле число від 0 до 59.\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"Обчислює кількість днів між поточною датою та іншою датою.\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"Обчислює кількість днів між поточною датою та іншою датою.\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"Повертає різницю між датою та часом у вказаних одиницях. Одиниця за замовчуванням — \\\"day\\\".\\nПідтримувані одиниці: \\\"millisecond\\\" (ms), \\\"second\\\" (s), \\\"minute\\\" (m), \\\"hour\\\" (h), \\\"day\\\" (d), \\\"week\\\" (w), \\\"month\\\" (M), \\\"year\\\" (y).\\nРізниця між датою та часом визначається шляхом віднімання [date2] від [date1]. Це означає, що якщо [date2] пізніше [date1], результуюче значення буде від'ємним.\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"Повертає робочий день до дати початку, за винятком указаних свят.\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"Повертає кількість робочих днів між датою1 і датою2. Робочі дні не включають вихідні та необов’язковий список свят, відформатований як розділений комами рядок дат у форматі ISO.\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"Порівнює дві дати з точністю до одиниці та визначає, чи вони ідентичні. Повертає true, якщо так, false, якщо ні.\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"Визначає, чи дата1 є пізнішою за дату2. Повертає true, якщо так, false, якщо ні.\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"Визначає, чи є дата1 раніше дати2. Повертає true, якщо так, false, якщо ні.\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"Додає вказану кількість одиниць часу до дати.\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"Форматує дату й час у рядок (YYYY-MM-DD).\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"Форматує дату й час у рядок, що містить лише час (HH:mm:ss).\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"Форматує дату й час у вказаний рядок. Щоб отримати пояснення щодо використання цієї функції з полями дат, натисніть тут. Щоб переглянути список підтримуваних специфікаторів формату, натисніть тут.\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"Інтерпретує текстовий рядок як структуровану дату з додатковим форматом введення та параметрами мови. Вихідний формат завжди буде відформатований \\\"M/D/YYYY h:mm a\\\".\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"Returns the creation time of the current record.\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"Повертає дату й час останньої зміни, внесеної користувачем у необчислюване поле в таблиці.\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"Повертає кількість усіх елементів, включаючи текст і пробіли.\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"Повертає кількість непорожніх значень. Ця функція підраховує як числові, так і текстові значення.\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"Повертає кількість числових елементів.\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"Об’єднайте масив елементів зведення в рядок із роздільником.\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"Повертає лише унікальні елементи в масиві.\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"Зрівнює масив, видаляючи будь-яке вкладення масиву. Усі елементи стають елементами єдиного масиву.\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"Видаляє порожні рядки та нульові значення з масиву. Зберігає \\\"false\\\" і рядки, які містять один або кілька пробілів.\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"Повертає всі текстові значення\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"Повертає ID поточного запису.\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"Повертає унікальні та збільшені числа для кожного запису.\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"Заповнює змінні, операційні символи та функції для створення формул для обчислень.\",\n      \"example\": \"Цитування стовпця: {Назва поля}\\n\\nВикористання оператора: 100 * 2 + 300\\n\\nВикористання функції: SUM({Числове поле 1}, 100)\\n\\nВикористання оператора IF: \\nIF(логічне умова, \\\"значення 1\\\", \\\"значення 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"Поля\",\n    \"numeric\": \"Числові\",\n    \"text\": \"Текстові\",\n    \"logical\": \"Логічні\",\n    \"date\": \"Дата\",\n    \"array\": \"Массиви\",\n    \"system\": \"Системні\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"Ні\",\n    \"count\": \"Кількість\",\n    \"empty\": \"Пусто\",\n    \"filled\": \"Заповнено\",\n    \"unique\": \"Унікальні\",\n    \"max\": \"Максимум\",\n    \"min\": \"Мінімум\",\n    \"sum\": \"Сума\",\n    \"average\": \"Середнє\",\n    \"checked\": \"Відзначені\",\n    \"unChecked\": \"Непомічені\",\n    \"percentEmpty\": \"Відсоток порожніх\",\n    \"percentFilled\": \"Відсоток заповнених\",\n    \"percentUnique\": \"Відсоток унікальних\",\n    \"percentChecked\": \"Percent Checked\",\n    \"percentUnChecked\": \"Відсоток невідзначених\",\n    \"earliestDate\": \"Найраніша дата\",\n    \"latestDate\": \"Найпізніша дата\",\n    \"dateRangeOfDays\": \"Діапазон дат (дні)\",\n    \"dateRangeOfMonths\": \"Діапазон дат (місяць)\",\n    \"totalAttachmentSize\": \"Загальний розмір вкладень\"\n  },\n  \"baseQuery\": {\n    \"add\": \"Додати\",\n    \"error\": {\n      \"invalidCol\": \"Неправильний стовпець, виберіть знову\",\n      \"invalidCols\": \"Неправильні стовпці: {{colNames}}\",\n      \"invalidTable\": \"Неправильна таблиця, виберіть знову\",\n      \"requiredSelect\": \"Необхідно вибрати один\"\n    },\n    \"from\": {\n      \"title\": \"Звідки\",\n      \"fromTable\": \"Виберіть таблицю\",\n      \"fromQuery\": \"З запиту\"\n    },\n    \"select\": {\n      \"title\": \"Вибрати\"\n    },\n    \"where\": {\n      \"title\": \"Де\"\n    },\n    \"groupBy\": {\n      \"title\": \"Групувати\"\n    },\n    \"orderBy\": {\n      \"title\": \"Сортувати за\",\n      \"asc\": \"За зростанням\",\n      \"desc\": \"За спаданням\"\n    },\n    \"limit\": {\n      \"title\": \"Обмеження\"\n    },\n    \"offset\": {\n      \"title\": \"Зміщення\"\n    },\n    \"join\": {\n      \"title\": \"Об'єднання\",\n      \"joinType\": \"Тип об'єднання\",\n      \"leftJoin\": \"Ліве об'єднання\",\n      \"rightJoin\": \"Праве об'єднання\",\n      \"innerJoin\": \"Внутрішнє об'єднання\",\n      \"fullJoin\": \"Повне об'єднання\",\n      \"data\": \"Звідки\"\n    },\n    \"aggregation\": {\n      \"title\": \"Агрегація\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"Коментар\",\n    \"placeholder\": \"Залишіть коментар...\",\n    \"emptyComment\": \"Почніть розмову\",\n    \"deletedComment\": \"Віддалений коментар\",\n    \"imageSizeLimit\": \"Розмір зображення не може перевищувати {{size}}\",\n    \"tip\": {\n      \"editing\": \"Редагування...\",\n      \"edited\": \"(Редаговано)\",\n      \"notifyAll\": \"Сповіщати всі коментаря\",\n      \"notifyRelatedToMe\": \"Сповіщати коментаря, пов'язані зі мною\",\n      \"all\": \"Всі\",\n      \"relatedToMe\": \"Зі мною\",\n      \"reactionUserSuffix\": \"відгукнувся з емодзі {{emoji}}\",\n      \"me\": \"Ви\",\n      \"connection\": \"і\"\n    },\n    \"toolbar\": {\n      \"link\": \"Посилання\",\n      \"image\": \"Зображення\",\n      \"mention\": \"Згадка\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"Редагувати посилання\",\n      \"caption\": \"Підпис\",\n      \"delete\": \"Видалити\",\n      \"linkText\": \"Текст посилання\",\n      \"enterUrl\": \"Введіть URL\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"Виберіть учасників\",\n    \"memberSelectorSearchPlaceholder\": \"Пошук учасників...\",\n    \"departmentSelectorSearchPlaceholder\": \"Шукати відділи...\",\n    \"selected\": \"Вибрано\",\n    \"noSelected\": \"Не вибрано\",\n    \"empty\": \"Немає учасників\",\n    \"emptyDepartment\": \"Немає відділів\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"Помилка валідації\",\n    \"invalidCaptcha\": \"Неправильний Captcha\",\n    \"invalidCredentials\": \"Неправильні облікові дані\",\n    \"unauthorized\": \"Не авторизовано\",\n    \"unauthorizedShare\": \"Не авторизована доступ\",\n    \"paymentRequired\": \"Потрібна оплата\",\n    \"creditLimitExceeded\": \"Перевищено ліміт кредитів\",\n    \"restrictedResource\": \"Обмежений ресурс\",\n    \"notFound\": \"Не знайдено\",\n    \"conflict\": \"Конфлікт\",\n    \"unprocessableEntity\": \"Необроблюваний ресурс\",\n    \"userLimitExceeded\": \"Перевищений ліміт користувача\",\n    \"tooManyRequests\": \"Забагато запитів\",\n    \"internalServerError\": \"Внутрішня помилка сервера\",\n    \"databaseConnectionUnavailable\": \"Недоступна база даних\",\n    \"gatewayTimeout\": \"Таймаут шлюза\",\n    \"unknownErrorCode\": \"Невідомий код помилки\",\n    \"networkError\": \"Проблема з підключенням до мережі\",\n    \"requestTimeout\": \"Таймаут запиту\",\n    \"failedDependency\": \"Залежність не виконана\",\n    \"automationNodeParseError\": \"Помилка аналізу вузла автоматизації\",\n    \"automationNodeNeedTest\": \"Вузол автоматизації потребує тестування\",\n    \"automationNodeTestOutdated\": \"Тест вузла автоматизації застарів\",\n    \"invalidToken\": \"Недійсний токен\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" поле \\\"{{fieldName}}\\\" не допускає пусті значення, будь ласка, заповніть його повністю перед відправкою.\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" поле \\\"{{fieldName}}\\\" не допускає дублікатів значень, будь ласка, заповніть унікальне значення перед відправкою.\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" поле не допускає дублікатів зв'язків з одним і тим самим запису\",\n      \"requestTimeout\": \"Текущая операция слишком велика, пожалуйста, попробуйте снова с меньшим диапазоном.\",\n      \"searchTimeOut\": \"Пошук завершений, зменшіть область пошуку та повторіть.\",\n      \"dependencyNodeRequire\": \"Залежний вузол не протестований, перевірте, чи всі попередні вузли протестовані\",\n      \"invalidOperation\": \"Виявлено недопустиму операцію, будь ласка, перевірте параметри операції\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"Кількість запитуваних коментарів перевищує максимальний ліміт 1000\",\n      \"invalidContentType\": \"Неправильний тип вмісту коментаря\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"Термін дії токена має бути менше 7 днів\",\n      \"s3RegionRequired\": \"Регіон S3 обов'язковий\",\n      \"s3EndpointRequired\": \"Кінцева точка S3 обов'язкова\",\n      \"s3AccessKeyRequired\": \"Ключ доступу S3 обов'язковий\",\n      \"s3SecretKeyRequired\": \"Секретний ключ S3 обов'язковий\",\n      \"s3UploadMethodMustBePut\": \"Метод завантаження S3 має бути PUT\",\n      \"presignedError\": \"Не вдалося згенерувати попередньо підписаний URL\",\n      \"invalidObjectMeta\": \"Недійсні метадані об'єкта\",\n      \"invalidImageStream\": \"Недійсний потік зображення\",\n      \"calculateImageSizeFailed\": \"Не вдалося розрахувати розмір зображення\",\n      \"uploadFailed\": \"Завантаження не вдалося\",\n      \"invalidImage\": \"Недійсне зображення\",\n      \"cantGetImageStream\": \"Неможливо отримати потік зображення\",\n      \"invalidProvider\": \"Недійсний постачальник сховища\",\n      \"failedToDeleteDirectory\": \"Не вдалося видалити каталог\",\n      \"invalidToken\": \"Недійсний токен\",\n      \"tokenExpired\": \"Термін дії токена закінчився\",\n      \"sizeMismatch\": \"Розмір файлу не збігається\",\n      \"notAllowUploadFileType\": \"Тип файлу {{mimetype}} не дозволений для завантаження\",\n      \"notFound\": \"Вкладення не знайдено\",\n      \"invalidPath\": \"Недійсний шлях до вкладення\",\n      \"fileSizeExceedsMaximumLimit\": \"Розмір файлу перевищує максимальний ліміт {{maxSize}}\",\n      \"invalidUploadType\": \"Недійсний тип завантаження\",\n      \"urlReject\": \"URL відхилено або недоступний\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Помилка конфігурації пошти {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"Invalid confirmation input\",\n      \"emailNotRegistered\": \"This email is not registered\",\n      \"passwordNotSet\": \"Password has not been set for this account\",\n      \"systemUser\": \"This is a system user account\",\n      \"alreadyRegistered\": \"This email is already registered\",\n      \"passwordIncorrect\": \"The password is incorrect\",\n      \"tokenInvalid\": \"The token is invalid or has expired\",\n      \"passwordAlreadyExists\": \"Password has already been set for this account\",\n      \"verificationCodeInvalid\": \"The verification code is invalid or has expired\",\n      \"newEmailSameAsCurrentEmail\": \"The new email address is the same as the current one\",\n      \"emailAlreadyRegistered\": \"This email address is already registered\",\n      \"waitlistNotEnabled\": \"The waitlist feature is not enabled\",\n      \"emailOrPasswordIncorrect\": \"Email or password is incorrect\",\n      \"accountDeactivated\": \"This account has been deactivated by the administrator\",\n      \"accountLockedOut\": \"Your account has been locked due to too many failed login attempts. Please try again later.\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"Це поле кнопки вже пов'язано з {{name}}[{{id}}] цей процес автоматизації, будь ласка, виберіть інше поле кнопки\",\n      \"triggerNotFound\": \"Автоматизація повинна мати вузол тригера\",\n      \"nodeNotFound\": \"{{nodeId}} вузол не знайдено\",\n      \"triggerTestFailed\": \"Тест тригера провалився, будь ласка, перевірте конфігурацію тригера\",\n      \"testFailed\": \"Тест автоматизації не пройшов, перевірте конфігурацію автоматизації\",\n      \"runFailed\": \"Виконання автоматизації не вдалося\",\n      \"nodeParseError\": \"{{name}} конфігурація вузла неповна або містить помилки, будь ласка, перевірте конфігурацію вузла\",\n      \"nodeNeedTest\": \"{{name}} вузол потребує тестування\",\n      \"nodeTestOutdated\": \"{{name}} тест вузла застарів\",\n      \"notFound\": \"Автоматизацію не знайдено\",\n      \"currentSnapshotEmpty\": \"Поточний знімок автоматизації порожній\",\n      \"runNotFound\": \"Виконання автоматизації не знайдено\",\n      \"anchorNotFound\": \"Опорну автоматизацію не знайдено\",\n      \"validationError\": \"Помилка валідації конфігурації автоматизації\",\n      \"tableNotInBase\": \"Ви можете підписатися лише на таблицю в межах вашої бази даних\",\n      \"alreadyActiveAndNotDraft\": \"Автоматизація вже активна і не є чернеткою\",\n      \"noActiveSnapshot\": \"У автоматизації немає активного знімка\",\n      \"triggerNodeAlreadyExists\": \"У цієї автоматизації вже є вузол тригера\",\n      \"generateLogicError\": \"Помилка генерації вузла логіки\",\n      \"logicNotFound\": \"Вузол логіки автоматизації не знайдено\",\n      \"actionNotFound\": \"Вузол дії автоматизації не знайдено\",\n      \"unSupportDuplicateWorkflowNodeType\": \"Дублювання цього типу вузла автоматизації не підтримується\",\n      \"unSupportLogicType\": \"Непідтримуваний тип логіки\",\n      \"groupEndNotFound\": \"GroupEnd не знайдено для логіки\",\n      \"insertNodeError\": \"Помилка вставки вузла\",\n      \"controlNodeNotBeTested\": \"Вузол керування не повинен тестуватися\",\n      \"invalidNodeType\": \"Недійсний тип вузла\",\n      \"unsupportedCategory\": \"Непідтримувана категорія\",\n      \"unknownConnectionType\": \"Unknown email connection type\",\n      \"imapPasswordNotConfigured\": \"IMAP password not configured\",\n      \"integrationNotFound\": \"Integration not found or has no credentials\",\n      \"webhookTriggerNotFound\": \"Webhook trigger not found\",\n      \"emailReceivedTriggerNotFound\": \"EmailReceived trigger not found\",\n      \"emailConnectorNotAvailable\": \"Email connector service not available\",\n      \"listMailboxesFailed\": \"Не вдалося отримати список поштових скриньок: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"Невідомий набір даних: {{datasetId}}\",\n      \"apiKeyNotConfigured\": \"Сервіс скрейпінгу не налаштовано\",\n      \"triggerFailed\": \"Помилка запуску скрейпінгу: {{detail}}\",\n      \"snapshotError\": \"Помилка знімка скрейпінгу: {{detail}}\",\n      \"timeout\": \"Час очікування скрейпінгу вичерпано після {{seconds}}с\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"Failed to exchange OAuth code: {{detail}}\",\n      \"oauthTokenRefreshFailed\": \"Failed to refresh OAuth token: {{detail}}\",\n      \"userInfoFetchFailed\": \"Failed to get user info: {{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"Простір не знайдено\",\n      \"noPermission\": \"У вас немає дозволу на доступ до цього простору\",\n      \"disallowSpaceCreation\": \"Створення просторів вимкнено адміністратором\",\n      \"cannotChangeOnlyOwnerRole\": \"Неможливо змінити роль єдиного власника простору\",\n      \"cannotDeleteOnlyOwner\": \"Неможливо видалити єдиного власника простору\",\n      \"deleted\": \"Space has been deleted\",\n      \"cannotOperate\": \"Неможливо працювати з простором, переконайтеся, що в просторі є члени організації і вони є власниками\",\n      \"notBelongToOrg\": \"Цей простір не належить організації\",\n      \"invalidSpaceIds\": \"Недійсні ідентифікатори простору: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"База не знайдена\",\n      \"cannotAccess\": \"У вас немає дозволу на доступ до бази {{baseId}}\",\n      \"anchorNotFound\": \"Якірна база {{anchorId}} не знайдена\",\n      \"baseAndSpaceMismatch\": \"База {{baseId}} та простір {{spaceId}} не співпадають\",\n      \"templateNotFound\": \"Шаблон {{templateId}} не знайдено\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"Базова ID обов'язкова\",\n      \"nodeIdIsRequired\": \"Вузол ID обов'язковий\",\n      \"invalidResourceType\": \"Недійсний тип ресурсу\",\n      \"notFound\": \"Базовий вузол не знайдено\",\n      \"parentMustBeFolder\": \"Батьківський елемент має бути папкою\",\n      \"cannotDuplicateFolder\": \"Неможливо дублювати папку\",\n      \"cannotDeleteEmptyFolder\": \"Неможливо видалити папку, оскільки вона не порожня\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"Необхідно вказати лише parentId або anchorId\",\n      \"cannotMoveToItself\": \"Неможливо перемістити вузол на себе\",\n      \"cannotMoveToCircularReference\": \"Неможливо перемістити вузол до власного дочірнього вузла (циклічне посилання)\",\n      \"anchorIdOrParentIdRequired\": \"Необхідно вказати принаймні parentId або anchorId\",\n      \"parentNotFound\": \"Батьківський вузол не знайдено\",\n      \"parentIsNotFolder\": \"Батьківський елемент не є папкою\",\n      \"circularReference\": \"Виявлено циклічне посилання\",\n      \"folderDepthLimitExceeded\": \"Перевищено ліміт глибини папки\",\n      \"folderNotFound\": \"Папку не знайдено\",\n      \"anchorNotFound\": \"Якірний вузол не знайдено\",\n      \"nameAlreadyExists\": \"Ім'я вже існує\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"Панель управління не знайдена\"\n    },\n    \"plugin\": {\n      \"notFound\": \"Плагін не знайдено\",\n      \"notSupportInstallInView\": \"Плагін не підтримує встановлення у вид\",\n      \"userNotFound\": \"Користувач плагіна не знайдений\",\n      \"invalidSecret\": \"Недійсний секрет\",\n      \"invalidRefreshToken\": \"Недійсний токен оновлення\",\n      \"anomalousToken\": \"Аномальний токен\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"Панель плагінів не знайдена\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"Плагін не встановлено\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"Неправильний пароль\",\n      \"notAllowedToSubmit\": \"Відправлення форми не дозволено\",\n      \"viewRequired\": \"Для цієї операції потрібне подання\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"Відправлення форми не дозволено, коли включені приховані поля\",\n      \"submitRecordsError\": \"Помилка відправлення запису\",\n      \"notAllowedToCopy\": \"Операція копіювання не дозволена\",\n      \"fieldHiddenNotAllowed\": \"Поле приховано і недоступне\",\n      \"fieldTypeNotLinkField\": \"Поле не є полем зв'язку\",\n      \"fieldIdRequired\": \"Потрібен ID поля\",\n      \"fieldNotUserRelatedField\": \"Поле не пов'язане з користувачем\",\n      \"viewTypeNotAllowed\": \"Цей тип подання не дозволений для цієї операції\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"Обмеження паролем не ввімкнено для цього спільного подання\",\n      \"shareViewNotFound\": \"Спільне подання не знайдено або спільний доступ вимкнено\",\n      \"linkFieldNotFound\": \"Поле зв'язку не знайдено\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Спільний доступ до бази не знайдено або вимкнено\",\n      \"alreadyExists\": \"Спільний доступ для цього вузла вже існує\",\n      \"copyNotAllowed\": \"Цей спільний доступ не дозволяє копіювання\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"У вас немає дозволу на доступ до цього подання\",\n      \"fieldPermissionNotAllowed\": \"У вас немає дозволу на доступ до цих полів\",\n      \"recordPermissionNotAllowed\": \"У вас немає дозволу на доступ до цих записів\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"Контекстне меню плагіна не знайдено\",\n      \"anchorNotFound\": \"Якір контекстного меню плагіна не знайдено\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"Запит діаграми плагіна не знайдено\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"Непідтримуваний драйвер бази даних: {{driver}}\",\n      \"onlyOwnerCanRemove\": \"Тільки власник бази може видалити підключення до бази даних для бази {{baseId}}\",\n      \"onlyOwnerCanCreate\": \"Тільки власник бази може створити підключення до бази даних для бази {{baseId}}\",\n      \"roleNotExist\": \"Роль бази даних {{role}} не існує\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"Запит не виконано: {{message}}\",\n      \"invalidJoinType\": \"Недопустимий тип об'єднання: {{joinType}}\",\n      \"tableNotFound\": \"Таблиця {{tableId}} не знайдена в базі {{baseId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"Не дозволено виконувати SQL з ключовим словом {{keyword}}\",\n      \"whiteListCheckError\": \"Сталася помилка під час перевірки доступу до таблиці: {{message}}\",\n      \"databaseConnectionFailed\": \"Не вдалося підключитися до бази даних: {{message}}\",\n      \"executeQuerySqlFailed\": \"Не вдалося виконати запит SQL: {{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"У вас немає дозволу на створення записів з полями({{fields}})\",\n      \"deleteRecords\": \"У вас немає дозволу на видалення записів({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"У вас немає дозволу на читання полів({{fields}}) у записі({{recordId}})\",\n      \"updateRecordWithDeniedFields\": \"У вас немає дозволу на оновлення полів({{fields}}) у записі({{recordId}})\",\n      \"checkIdNotExist\": \"ID перевірки дозволів не існує\",\n      \"userNotAdmin\": \"Користувач не є адміністратором\",\n      \"accessTokenNoPermission\": \"Токен доступу не має необхідного дозволу\",\n      \"invalidResource\": \"Ресурс недійсний\",\n      \"notAllowedSpace\": \"У вас немає дозволу на доступ до цього простору\",\n      \"notAllowedBase\": \"У вас немає дозволу на доступ до цієї бази\",\n      \"notAllowedTables\": \"У вас немає дозволу на доступ до таблиц({{tableIds}})\",\n      \"notAllowedOperationTable\": \"У вас немає дозволу на операції з цією таблицею\",\n      \"notAllowedOperationRecord\": \"У вас немає дозволу на операції з цим записом\",\n      \"notAllowedRecordUpdate\": \"У вас немає дозволу на оновлення цього запису\",\n      \"notAllowedOperationView\": \"У вас немає дозволу на операції з цим поданням\",\n      \"deniedByEnabledAuthorityMatrix\": \"Дозвіл відхилено активованою матрицею повноважень\",\n      \"invalidRequestPath\": \"Шлях запиту недійсний\",\n      \"notAllowedOperation\": \"У вас немає дозволу на виконання цієї операції\",\n      \"notAllowedDepartment\": \"Вам не дозволено доступ до цього відділу\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"Роль за замовчуванням не знайдено\",\n      \"alreadyDisabled\": \"Матриця повноважень вже вимкнена\",\n      \"alreadyEnabled\": \"Матриця повноважень вже увімкнена\",\n      \"notFound\": \"Матриця повноважень не знайдена\",\n      \"primaryFieldCannotBeDisabledForRead\": \"Основне поле не може бути вимкнене для читання\",\n      \"fieldDuplicated\": \"Поле дублюється в конфігурації дозволів\",\n      \"cannotSetRecordPermissionGroup\": \"Неможливо встановити цю комбінацію дозволів запису({{actions}})\",\n      \"notFoundBaseAndTable\": \"ID бази та ID таблиці не знайдено\",\n      \"roleTablesShouldNotBeEmpty\": \"Таблиці ролей матриці повноважень не повинні бути порожніми\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"Недійсний тип повернення\",\n      \"exceedMaxReadRows\": \"Перевищено максимальний ліміт читання рядків\",\n      \"invalidCellValueType\": \"Недійсний тип значення комірки\",\n      \"exceedMaxCopyCells\": \"Перевищено максимальний ліміт копіювання комірок\",\n      \"exceedMaxPasteCells\": \"Перевищено максимальний ліміт вставки комірок\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"Непідтримуваний тип поля {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"Непідтримуваний тип поля {{type}} як первинне поле\",\n      \"primaryFieldNotSupported\": \"Тип поля не підтримується як первинне поле\",\n      \"calculateRecordNotFound\": \"Запис не знайдено для: {{value}}, fieldId: {{fieldId}}, під час обчислення {{recordId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"toRecordIds або fromRecordIds потрібен для звичайного обчислюваного поля\",\n      \"recordFieldsRequired\": \"Поля запису не визначені\",\n      \"uniqueUnsupportedType\": \"Поле {{name}}[{{fieldId}}] не підтримує перевірку унікальності значення поля\",\n      \"notNullValidationWhenCreateField\": \"Поле {{name}}[{{fieldId}}] не підтримує перевірку не null при створенні нового поля\",\n      \"dbFieldNameAlreadyExists\": \"Ім'я поля бази даних {{dbFieldName}} вже існує\",\n      \"fieldValidationError\": \"Поле {{name}}[{{fieldId}}] помилка перевірки поля\",\n      \"fieldNameAlreadyExists\": \"Ім'я поля {{name}} вже існує\",\n      \"notFound\": \"Поле не знайдено\",\n      \"fieldKeyTypeNotFound\": \"Поле \\\"{{fieldKeyType}}: {{missedFields}}\\\" не знайдено\",\n      \"notFoundInTable\": \"Поле {{fieldId}} не знайдено в таблиці {{tableId}}\",\n      \"deleteFieldsNotFound\": \"Поля для видалення {{fieldIds}} не знайдені в таблиці {{tableId}}\",\n      \"lookupValuesShouldBeArray\": \"lookupValues повинен бути масивом, коли поле зв'язку має кілька значень комірки\",\n      \"linkCellValuesShouldBeArray\": \"linkCellValues повинен бути масивом, коли поле зв'язку має кілька значень комірки\",\n      \"lookupAndLinkLengthMatch\": \"Довжина lookupValues повинна бути такою ж, як довжина linkCellValues\",\n      \"cycleDetected\": \"Виявлено цикл\",\n      \"cycleDetectedCreateField\": \"Виявлено цикл, неможливо створити поле {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"Запис не знайдено в таблиці {{tableName}} для поля {{fieldName}}\",\n      \"forbidDeletePrimaryField\": \"Заборонено видаляти первинне поле\",\n      \"foreignTableIdInvalid\": \"Зовнішня таблиця {{foreignTableId}} недійсна\",\n      \"relationshipInvalid\": \"Відношення {{relationship}} недійсне\",\n      \"linkFieldIdInvalid\": \"Поле зв'язку {{linkFieldId}} недійсне\",\n      \"lookupFieldIdInvalid\": \"Поле пошуку {{lookupFieldId}} недійсне\",\n      \"formulaExpressionParseError\": \"Помилка розбору виразу формули\",\n      \"formulaReferenceNotFound\": \"Поле посилання формули {{fieldIds}} не знайдено\",\n      \"rollupExpressionParseError\": \"Помилка розбору виразу зведення\",\n      \"choiceNameAlreadyExists\": \"Ім'я вибору {{name}} вже існує\",\n      \"symmetricFieldIdRequired\": \"Потрібен ідентифікатор симетричного поля\",\n      \"foreignKeyNameCannotUseId\": \"Ім'я зовнішнього ключа не може використовувати __id\",\n      \"createForeignKeyError\": \"Помилка створення зовнішнього ключа\",\n      \"lookupFieldTypeNotEqual\": \"Поточний тип поля {{fieldType}} не дорівнює типу поля пошуку {{lookupFieldType}}\",\n      \"recordNotFound\": \"Запис {{recordId}} не знайдено в {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"Неможливо встановити дублюючий recordId: {{recordId}} в тій самій комірці\",\n      \"oneOneLinkCellValueCannotBeArray\": \"Значення поля зв'язку один-до-одного не можуть бути масивом\",\n      \"manyOneLinkCellValueCannotBeArray\": \"Значення поля зв'язку багато-до-одного не можуть бути масивом\",\n      \"foreignKeyDuplicate\": \"Дублікат зовнішнього ключа\",\n      \"linkConsistencyError\": \"Помилка узгодженості, recordId {{recordId}} не існує\",\n      \"oneManyLinkCellValueShouldBeArray\": \"Значення поля зв'язку один-до-багатьох повинні бути масивом\",\n      \"manyManyLinkCellValueShouldBeArray\": \"Значення поля зв'язку багато-до-багатьох повинні бути масивом\",\n      \"onlyLinkFieldCanBeFiltered\": \"Тільки поля зв'язку можуть використовуватися для фільтрації\",\n      \"notLinkedToCurrentTable\": \"Поле не пов'язане з поточною таблицею\",\n      \"notAttachment\": \"Поле не є полем вкладення\",\n      \"isComputed\": \"Поле обчислюється і не може бути змінене\",\n      \"notFoundAICofig\": \"Поле не має конфігурації AI\",\n      \"foreignTableIdRequired\": \"Зовнішня таблиця обов'язкова\",\n      \"lookupFieldIdRequired\": \"Поле пошуку обов'язкове\",\n      \"lookupFieldNotExist\": \"Поле пошуку {{lookupFieldId}} не існує\",\n      \"lookupFieldNotBelongToTable\": \"Поле пошуку {{lookupFieldId}} не належить до таблиці {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"Поточний тип поля {{fieldType}} не відповідає типу поля пошуку {{lookupFieldType}}\",\n      \"conditionalRollupOptionsRequired\": \"Параметри поля умовного підсумовування обов'язкові\",\n      \"conditionalRollupParseError\": \"Помилка парсингу умовного підсумовування: {{message}}\",\n      \"conditionalLookupOptionsRequired\": \"Параметри поля умовного пошуку обов'язкові\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"Кількість натискань кнопки досягла максимальної межі\",\n        \"notSupportReset\": \"Кнопка не підтримує скидання\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"Представлення не знайдено\",\n      \"defaultViewNotFound\": \"Представлення за замовчуванням не знайдено\",\n      \"propertyParseError\": \"Не вдалося розібрати властивість представлення\",\n      \"primaryFieldCannotBeHidden\": \"Первинне поле не може бути приховане\",\n      \"filterUnsupportedFieldType\": \"Фільтр не підтримує тип поля\",\n      \"sortUnsupportedFieldType\": \"Сортування не підтримує тип поля\",\n      \"groupUnsupportedFieldType\": \"Групування не підтримує тип поля\",\n      \"anchorNotFound\": \"Якірне представлення не знайдено\",\n      \"notEnoughGapToShuffleRow\": \"Недостатньо місця для перемішування рядка\",\n      \"shareNotEnabled\": \"Спільний доступ до представлення не ввімкнено\",\n      \"shareAlreadyEnabled\": \"Спільний доступ до представлення вже ввімкнено\",\n      \"shareAlreadyDisabled\": \"Спільний доступ до представлення вже вимкнено\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"Недостатньо коштів\",\n      \"exceedMaxRowLimit\": \"Перевищено максимальний ліміт рядків {{maxRowCount}}\",\n      \"exceedMaxAutomationRunLimit\": \"Досягнуто максимального місячного ліміту запусків автоматизації\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"Пошуковий запит є обов'язковим\",\n      \"maxSearchIndexResult\": \"Максимальний результат пошукового індексу становить 1000\",\n      \"queryCollectionMustBeTableId\": \"Колекція запитів має бути ідентифікатором таблиці\",\n      \"searchTimeOut\": \"Пошук завершений, зменшіть область пошуку та повторіть.\",\n      \"indexNotFound\": \"Індекс не знайдено\",\n      \"invalidStartDateFieldId\": \"Недійсний ідентифікатор поля дати початку\",\n      \"invalidEndDateFieldId\": \"Недійсний ідентифікатор поля дати закінчення\",\n      \"fieldMapRequired\": \"Карта полів потрібна при встановленні пошуку\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected та filterLinkCellCandidate не можна встановити одночасно\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"Модель чату ШІ lg не встановлено\",\n      \"chatModelLgProviderNotSet\": \"Провайдер моделі чату ШІ lg не встановлено\",\n      \"chatModelSmNotSet\": \"Модель чату ШІ sm не встановлено\",\n      \"chatModelMdNotSet\": \"Модель чату ШІ md не встановлено\",\n      \"configurationNotSet\": \"Конфігурацію ШІ не встановлено\",\n      \"unsupportedProvider\": \"Непідтримуваний провайдер ШІ {{type}}\",\n      \"providerConfigurationNotSet\": \"Конфігурацію провайдера ШІ не встановлено\",\n      \"testLLMFailed\": \"Тест підключення LLM не пройшов\",\n      \"audioNotSupported\": \"Ця модель {{model}} не підтримує введення аудіо\",\n      \"imageNotSupported\": \"Ця модель {{model}} не підтримує введення зображень\",\n      \"modelNotSet\": \"Модель ШІ не встановлено\",\n      \"unsupportedFileType\": \"Непідтримуваний тип файлу {{mimetype}}\",\n      \"unsupportedModelType\": \"Непідтримуваний тип моделі\",\n      \"embeddingModelNotSet\": \"Модель вбудовування не встановлено\",\n      \"validateActionFailed\": \"Не вдалося перевірити дію AI поля\",\n      \"generateFailed\": \"Не вдалося згенерувати AI\",\n      \"unsupportedActionType\": \"Непідтримуваний тип дії AI\"\n    },\n    \"role\": {\n      \"notFound\": \"Роль не знайдено\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"Співавтор вже існує\",\n      \"notFound\": \"Співавтора не знайдено\",\n      \"userNotFoundInCollaborator\": \"Користувача не знайдено серед співавторів\",\n      \"noPermissionToDelete\": \"У вас немає дозволу на видалення цього співавтора\",\n      \"noPermissionToUpdate\": \"У вас немає дозволу на оновлення цього співавтора\",\n      \"noPermissionToOperateRole\": \"У вас немає дозволу на управління цією роллю\",\n      \"alreadyExistedInBase\": \"Співавтор вже існує в базі даних\",\n      \"userNotFound\": \"Користувача не знайдено: {{userIds}}\",\n      \"baseNotFound\": \"Базу даних не знайдено\",\n      \"noPermissionToAddRole\": \"У вас немає дозволу на додавання співавтора з цією роллю\",\n      \"departmentNotFound\": \"Відділ не знайдено\"\n    },\n    \"table\": {\n      \"notFound\": \"Таблицю не знайдено\",\n      \"dbTableNameAlreadyExists\": \"Ім'я таблиці бази даних вже існує\",\n      \"anchorNotFound\": \"Якірну таблицю не знайдено\",\n      \"notInTrash\": \"Таблиця не в кошику\",\n      \"notSupportTableIndex\": \"Тип індексу таблиці не підтримується\",\n      \"createTableIndexError\": \"Не вдалося створити індекс таблиці\",\n      \"dropTableIndexError\": \"Не вдалося видалити індекс таблиці\",\n      \"notFoundPrimaryField\": \"Первинне поле не знайдено в таблиці\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"Тип перегляду {{viewType}} не підтримується для експорту\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"Формат файлу не підтримується, підтримуються лише {{supportType}}, тип вмісту вашого файлу {{fileFormat}}\",\n      \"notSupportedFileType\": \"Тип файлу імпорту не підтримується\",\n      \"exceedMaxFieldsLength\": \"Кількість полів у таблиці не може перевищувати {{maxFieldsLength}}, поточне значення {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"Поточний екземпляр забороняє запрошення до простору адміністратором\",\n      \"invalidCode\": \"Недійсний код запрошення\",\n      \"linkNotFound\": \"Посилання запрошення не знайдено\",\n      \"linkExpired\": \"Термін дії посилання запрошення закінчився\",\n      \"limitExceeded\": \"Ви досягли максимальної кількості запрошень на годину\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"Вибране вже існує\",\n      \"notFound\": \"Вибране не знайдено\",\n      \"anchorNotFound\": \"Якір вибраного не знайдено\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"Недійсний тип ресурсу\",\n      \"notFound\": \"Елемент кошика не знайдено\",\n      \"parentSpaceTrashed\": \"Неможливо відновити цю базу, оскільки її батьківський простір також знаходиться в кошику\",\n      \"parentBaseOrSpaceTrashed\": \"Неможливо відновити цю таблицю, оскільки її батьківська база або простір також знаходиться в кошику\",\n      \"parentBaseTrashed\": \"Неможливо відновити цей елемент, оскільки його батьківська база також знаходиться в кошику\",\n      \"parentNotFound\": \"Батьківський ресурс не знайдено\",\n      \"tableNotFound\": \"Елемент кошика таблиці не знайдено\"\n    },\n    \"license\": {\n      \"invalid\": \"Ліцензія недійсна\",\n      \"instanceIdMismatch\": \"Вхідний ID екземпляра не відповідає ID екземпляра поточного екземпляра\",\n      \"expired\": \"Термін дії ліцензії закінчився\",\n      \"userLimitExceeded\": \"Кількість користувачів у поточному екземплярі перевищує ліміт місць ліцензії. Будь ласка, відключіть деяких користувачів або оновіть ліцензію\"\n    },\n    \"organization\": {\n      \"notFound\": \"Організацію не знайдено\",\n      \"emailNotSpaceUser\": \"Email не є користувачем простору\",\n      \"authenticationNotFound\": \"Автентифікація не знайдена\",\n      \"spaceShouldExist\": \"Простір організації повинен існувати\",\n      \"emailsNotInOrgDomain\": \"Ці електронні адреси {{emails}} не знаходяться в домені організації\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"Поточний екземпляр забороняє реєстрацію адміністратором\",\n      \"waitlistInviteCodeRequired\": \"Список очікування увімкнено, потрібен код запрошення\",\n      \"waitlistInviteCodeInvalid\": \"Список очікування увімкнено, код запрошення недійсний\",\n      \"systemUser\": \"Користувач є системним користувачем\",\n      \"collaboratorsInSpaces\": \"Користувач має співробітників у просторах (або видалені простори в кошику)\",\n      \"notFound\": \"Користувача не знайдено\",\n      \"cannotDeleteAdmin\": \"Неможливо видалити користувача-адміністратора\",\n      \"cannotDeactivateAdmin\": \"Неможливо вимкнути користувача-адміністратора\",\n      \"cannotRemoveLastAdmin\": \"Неможливо забрати права адміністратора в останнього активного користувача-адміністратора\",\n      \"permanentDeleted\": \"Користувач назавжди видалений\",\n      \"cannotDeleteSelf\": \"Ви не можете видалити себе\",\n      \"alreadyInDepartment\": \"Користувач {{userId}} вже знаходиться в цільовому відділі\",\n      \"emailsNotFound\": \"Електронні адреси {{emails}} не знайдені\",\n      \"deleted\": \"Користувач {{userId}} видалений\",\n      \"alreadyInOrg\": \"Користувач {{userId}} вже знаходиться в організації\",\n      \"notInOrg\": \"Користувач {{userId}} не знаходиться в організації\"\n    },\n    \"record\": {\n      \"notFound\": \"Запис не знайдено\",\n      \"deletedIdsNotFound\": \"Деякі записи для видалення не знайдено\",\n      \"updateFailed\": \"Не вдалося оновити запис\",\n      \"noFileOrUrlProvided\": \"Файл або URL не надано\",\n      \"createRecordsEmpty\": \"Створення записів не може бути порожнім\",\n      \"duplicateFailed\": \"Не вдалося дублювати запис\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"Помилка перевірки значення комірки\"\n    },\n    \"workflow\": {\n      \"notActive\": \"Автоматизація не відкрита\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"Недійсний тип ресурсу\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"Категорія шаблону не знайдена\",\n      \"snapshotRequired\": \"Цей шаблон не можна опублікувати через відсутність знімка\",\n      \"sourceTemplateNotFound\": \"Вихідний шаблон не знайдено\",\n      \"noMinOrderFound\": \"Мінімальний порядок не знайдено\",\n      \"takeCountTooLarge\": \"Кількість запитуваних шаблонів перевищує максимальний ліміт\",\n      \"categoryLimitReached\": \"Досягнуто ліміт категорій шаблонів (максимум {{maxCount}})\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"Код підтвердження домену не знайдено\",\n      \"invalidCode\": \"Невірний код підтвердження\",\n      \"resendCooldown\": \"Будь ласка, зачекайте 1 хвилину перед запитом нового коду\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"Не вдалось надіслати електронний лист\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"Батьківський відділ не знайдено\",\n      \"notFound\": \"Відділ не знайдено\",\n      \"cannotMoveToItself\": \"Неможливо перемістити відділ в самого себе\",\n      \"cannotMoveToSub\": \"Неможливо перемістити відділ в його підвідділ\"\n    },\n    \"app\": {\n      \"notFound\": \"Додаток не знайдено\",\n      \"noFilesToUpdate\": \"Немає файлів для оновлення\",\n      \"noChatIdFound\": \"ID чату для цього додатку не знайдено\",\n      \"noChatFound\": \"Чат для цього додатку не знайдено\",\n      \"versionNotFound\": \"Версію не знайдено\",\n      \"cannotRollbackToLatestVersion\": \"Неможливо відкотити до останньої версії\",\n      \"noChatOrProjectTokenFound\": \"Чат або токен проекту не знайдено\",\n      \"apiKeyNotSet\": \"API-ключ конструктора додатків не встановлено\",\n      \"cannotDeployAppBeforeInitialization\": \"Неможливо розгорнути додаток до ініціалізації\",\n      \"noProjectOrVersionFound\": \"Проект або версію не знайдено\",\n      \"noDeploymentUrlAvailable\": \"URL розгортання недоступний\"\n    },\n    \"reward\": {\n      \"notFound\": \"Нагороду не знайдено\",\n      \"unsupportedSourceType\": \"Непідтримуваний тип джерела нагороди\",\n      \"maxClaimsReached\": \"Ви досягли максимальної кількості запитів нагород (2) на цьому тижні\",\n      \"verificationFailed\": \"Помилка перевірки: {{errors}}\",\n      \"alreadyClaimedThisWeek\": \"Ви вже запитали нагороду для цього акаунту на цьому тижні\",\n      \"invalidPostUrl\": \"Недійсний формат URL публікації\",\n      \"postAlreadyUsed\": \"Ця публікація вже використовувалась для отримання нагороди\",\n      \"unsupportedPlatformUrl\": \"Непідтримуваний URL соціальної платформи\",\n      \"unsupportedPlatform\": \"Непідтримувана платформа: {{platform}}\",\n      \"minCharCount\": \"Публікація повинна містити щонайменше {{count}} символів\",\n      \"minFollowerCount\": \"Акаунт повинен мати щонайменше {{count}} підписників\",\n      \"mustMention\": \"Публікація повинна згадувати {{mention}}\",\n      \"fetchTweetFailed\": \"Не вдалося отримати твіт X: {{error}}\",\n      \"tweetNotFound\": \"Твіт X не знайдено: {{postId}}\",\n      \"fetchUserFailed\": \"Не вдалося отримати користувача X: {{error}}\",\n      \"xUserNotFound\": \"Користувача X не знайдено: {{username}}\",\n      \"fetchLinkedInPostFailed\": \"Не вдалося отримати публікацію LinkedIn: {{error}}\",\n      \"linkedInPostNotFound\": \"Публікацію LinkedIn не знайдено: {{postId}}\",\n      \"linkedInAuthorNotFound\": \"Автора LinkedIn не знайдено: {{postId}}\",\n      \"fetchLinkedInUserFailed\": \"Не вдалося отримати користувача LinkedIn: {{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/setting.json",
    "content": "{\n  \"personalAccessToken\": \"Персональні токени доступу\",\n  \"oauthApps\": \"OAuth Додатки\",\n  \"plugins\": \"Плагіни\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"Введіть свій пароль, щоб переглянути цю сторінку\",\n    \"submit\": \"Відправити\",\n    \"password\": \"Пароль\",\n    \"passwordTooShort\": \"Пароль повинен містити не менше 3 символів\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"Виберіть...\"\n  },\n  \"openOnNewPage\": \"Відкрити на новій сторінці\",\n  \"errorTips\": \"Спільний доступ до джерела увімкнено за допомогою матриці повноважень, перегляд заборонено\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/space.json",
    "content": "{\n  \"initialSpaceName\": \"Простір {{name}}\",\n  \"action\": {\n    \"createBase\": \"Створіть базу\",\n    \"createSpace\": \"Створіть простір\",\n    \"invite\": \"Запросити\"\n  },\n  \"allSpaces\": \"Всі простори\",\n  \"emptySpaceTitle\": \"У цьому просторі немає баз\",\n  \"spaceIsEmpty\": \"Створіть свою першу базу, щоб розпочати\",\n  \"baseModal\": {\n    \"copy\": \"Копіювати\",\n    \"duplicate\": \"Дублювати \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"Створити базу з шаблону\",\n    \"duplicateRecords\": \"Дублювати записи\",\n    \"duplicateRecordsTip\": \"Ваша історія ревізій і співавтори не дублюватимуться.\",\n    \"toSpace\": \"До простору\",\n    \"copyToSpace\": \"Скопіювати в простір\",\n    \"duplicateBase\": \"Дублювати базу\",\n    \"missTargetTip\": \"Будь ласка, виберіть місце для створення копії бази.\",\n    \"copying\": \"Копіювання бази, це може зайняти деякий час...\",\n    \"copyingTemplate\": \"Створення бази з шаблону може зайняти деякий час...\",\n    \"howToCreate\": \"Як ти хочеш почати?\",\n    \"fromScratch\": \"З нуля\",\n    \"fromTemplate\": \"З шаблону\",\n    \"moveBaseToAnotherSpace\": \"Перемістити {{baseName}} в інший простір\",\n    \"chooseSpace\": \"Виберіть простір\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"Налаштування простору\",\n    \"general\": \"Загальний\",\n    \"collaborators\": \"Співавтори\",\n    \"generalDescription\": \"Змініть налаштування поточного місця тут\",\n    \"collaboratorDescription\": \"Керуйте співавторами вашої області та встановлюйте їхні дозволи на доступ\",\n    \"spaceName\": \"Назва простору\",\n    \"spaceId\": \"ID простору\"\n  },\n  \"pin\": {\n    \"add\": \"Додати до закріплення\",\n    \"remove\": \"Видалити з закріплених\",\n    \"pin\": \"Закріпити\",\n    \"empty\": \"Тут з'являться ваші закріплені бази та простори\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"У вас немає дозволу створювати базу\"\n  },\n  \"tip\": {\n    \"delete\": \"Ви впевнені, що хочете видалити <0/>?\",\n    \"title\": \"Поради\",\n    \"exportTips1\": \"Експортувати поточну базу як файл .tea, що може зайняти деякий час. Ви можете перевірити результати експорту в центрі сповіщень.\",\n    \"exportTips2\": \"Ви можете імпортувати файл .tea в простір -> більше -> імпорт\",\n    \"exportTips3\": \"Поля зв'язку між базами будуть перетворені на однорядковий текст.\",\n    \"exportIncludeDataLabel\": \"Включити записи\",\n    \"exportIncludeDataDescription\": \"Вимкніть, щоб експортувати лише структуру та конфігурацію.\",\n    \"moveBaseSuccessTitle\": \"Переміщення успішне\",\n    \"moveBaseSuccessDescription\": \"{{baseName}} успішно переміщено до {{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"Видалити простір\",\n    \"blockedTitle\": \"Неможливо видалити цей простір\",\n    \"blockedDesc\": \"Цей простір має активну підписку. Будь ласка, скасуйте підписку перед видаленням простору.\",\n    \"permanentDeleteWarning\": \"Ця дія назавжди видалить усі ресурси та дані в поточному просторі. Будь ласка, будьте обережні!\",\n    \"confirmInputLabel\": \"Будь ласка, введіть DELETE для підтвердження видалення\"\n  },\n  \"sharedBase\": {\n    \"title\": \"Спільні бази\",\n    \"description\": \"Всі бази, до яких мене запросили приєднатися\",\n    \"empty\": \"Ще немає спільних баз\"\n  },\n  \"integration\": {\n    \"title\": \"Інтеграції\",\n    \"description\": \"Керування інтеграціями вашого простору\",\n    \"addIntegration\": \"Додати інтеграцію\",\n    \"ai\": \"ШІ\"\n  },\n  \"aiSetting\": {\n    \"title\": \"Налаштування ШІ\",\n    \"description\": \"Керування налаштуваннями ШІ вашого простору\",\n    \"enableTips\": \"Увімкніть ШІ, щоб використовувати функції ШІ у вашому просторі замість системного ШІ\",\n    \"enable\": \"Ініціалізувати налаштування ШІ\",\n    \"enableSwitchTips\": \"Будь ласка, налаштуйте велику модель кодування перед увімкненням\"\n  },\n  \"import\": {\n    \"importing\": \"Імпорт\",\n    \"importWayTip\": \"Натисніть або перетягніть файл у цю область для завантаження\",\n    \"baseImportTips\": \"Натисніть або перетягніть файл .tea у цю область для завантаження\",\n    \"confirm\": \"Підтвердити та продовжити\",\n\n    \"phase\": {\n      \"parsingStructure\": \"Parsing structure\",\n      \"creatingBase\": \"Creating base: {{detail}}\",\n      \"creatingTable\": \"Creating table: {{detail}}\",\n      \"creatingCommonFields\": \"Creating basic fields for {{table}}: {{fields}}\",\n      \"creatingButtonFields\": \"Creating button fields for {{table}}: {{fields}}\",\n      \"creatingFormulaFields\": \"Creating formula fields for {{table}}: {{fields}}\",\n      \"creatingLinkFields\": \"Creating link fields for {{table}}: {{fields}}\",\n      \"creatingLookupFields\": \"Creating lookup fields for {{table}}: {{fields}}\",\n      \"creatingTableViews\": \"Creating views for {{table}}: {{fields}}\",\n      \"creatingPlugins\": \"Creating plugins\",\n      \"creatingFolders\": \"Creating folders\",\n      \"creatingWorkflows\": \"Creating workflows\",\n      \"creatingApps\": \"Creating apps\",\n      \"creatingAuthorityMatrix\": \"Creating authority matrix\",\n      \"queuingAttachments\": \"Queuing attachment uploads\",\n      \"uploadingAppFiles\": \"Uploading app files\",\n      \"queuingDataImport\": \"Queuing data import\",\n      \"done\": \"Import completed\",\n      \"clickToView\": \"Натисніть для перегляду\"\n    }\n  },\n  \"template\": {\n    \"title\": \"Шаблон\",\n    \"description\": \"Швидко створити нову базу з шаблону\",\n    \"noTemplatesAvailable\": \"Немає доступних шаблонів\",\n    \"noTemplatesDescription\": \"Тут зараз нічого немає\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"Нещодавно відвідані\"\n  },\n  \"noBases\": {\n    \"title\": \"Привіт, {{userName}}!\",\n    \"description\": \"Почнімо керувати вашою роботою з вашої першої бази.\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Привіт, {{userName}}!\",\n    \"description\": \"Створіть свій перший простір, щоб почати шлях співпраці з даними.\"\n  },\n  \"baseList\": {\n    \"allBases\": \"Усі бази\",\n    \"owner\": \"Власник\",\n    \"createdTime\": \"Створено\",\n    \"lastOpened\": \"Останнє відкриття\",\n    \"enter\": \"Увійти\",\n    \"noTables\": \"Немає таблиць\",\n    \"empty\": \"Баз поки немає\"\n  },\n  \"publishBase\": {\n    \"title\": \"Опублікувати базу в спільноті\",\n    \"description\": \"Публікація бази в один клік, натхнення більше не самотнє! Дозвольте більшій кількості людей бачити, використовувати та реміксувати вашу творчість, будуючи разом потужніший бізнес\",\n    \"infoTitle\": \"Основна інформація\",\n    \"form\": {\n      \"title\": \"Назва\",\n      \"description\": \"Опис\",\n      \"security\": \"Безпека\",\n      \"includeNodes\": \"Включити вузли\",\n      \"advanced\": \"Додатково\",\n      \"publishNode\": \"Опублікувати вузли\",\n      \"includeData\": \"Включити дані\",\n      \"defaultActiveNode\": \"Активний вузол за замовчуванням\",\n      \"select\": \"будь ласка, виберіть\",\n      \"descriptionPlaceholder\": \"Коротко опишіть свою ідею...\",\n      \"titlePlaceholder\": \"Назвіть свою роботу...\"\n    },\n    \"publishToCommunity\": \"Опублікувати в центрі шаблонів\",\n    \"publish\": \"Опублікувати\",\n    \"publishSuccess\": \"Успішно опубліковано!\",\n    \"previewTips\": \"Покажіть свою роботу світові\",\n    \"update\": \"Оновити\",\n    \"unPublish\": \"Зняти з публікації\",\n    \"unPublishSuccess\": \"Базу успішно знято з публікації!\",\n    \"unPublishConfirmTitle\": \"Підтвердити зняття з публікації\",\n    \"unPublishConfirmDescription\": \"Ви впевнені, що хочете зняти цю базу з публікації? Вона більше не буде видимою в центрі шаблонів спільноти.\",\n    \"usageCount\": \"Кількість використань: \",\n    \"uploadCover\": \"Натисніть, щоб завантажити обкладинку\",\n    \"changeCover\": \"Натисніть, щоб змінити обкладинку\",\n    \"uploading\": \"Завантаження зображення...\",\n    \"uploadSuccess\": \"Зображення успішно завантажено\",\n    \"uploadFailed\": \"Помилка завантаження\",\n    \"invalidImageType\": \"Будь ласка, виберіть файл зображення\",\n    \"tips\": {\n      \"publishValidation\": \"назва та опис обов'язкові\",\n      \"atLeastOneNode\": \"Виберіть принаймні один вузол для публікації\"\n    },\n    \"urlCopied\": \"URL скопійовано в буфер обміну!\",\n    \"urlCopiedForDiscord\": \"URL скопійовано в буфер обміну! Ви можете вставити його в Discord.\",\n    \"featuredLabel\": \"Featured\",\n    \"unfeaturedLabel\": \"Unfeatured\",\n    \"featuredTip\": \"Офіційно обрано як рекомендований. Ваш шаблон отримає більше уваги.\",\n    \"unfeaturedTip\": \"Ще не обрано. Продовжуйте вдосконалювати, щоб отримати шанс на рекомендацію. Нехай більше людей побачать вашу роботу.\",\n    \"publishSuccessDescription\": \"Поділіться своєю роботою зі світом\",\n    \"shareWith\": \"Поділитися з\",\n    \"unpublishedApps\": {\n      \"title\": \"Виявлено неопубліковані додатки\",\n      \"description\": \"Неопубліковані додатки можуть спричинити помилки попереднього перегляду шаблону. Опублікуйте їх зараз або продовжте.\",\n      \"publishAll\": \"Опублікувати всі\",\n      \"publish\": \"Опублікувати\",\n      \"published\": \"Опубліковано\",\n      \"publishing\": \"Публікація...\",\n      \"publishFailed\": \"Помилка публікації\",\n      \"publishFailedTip1\": \"Перевірте, чи може вихідний додаток бути успішно опублікований\",\n      \"publishFailedTip2\": \"Спробуйте повторно опублікувати цей шаблон\",\n      \"notPublished\": \"Не опубліковано\",\n      \"ignoreAndContinue\": \"Ігнорувати та продовжити\",\n      \"goToFix\": \"Перейти до виправлення\",\n      \"redeploy\": \"Повторне розгортання\",\n      \"unnamedApp\": \"Безіменний додаток\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"Скоро\",\n    \"viewFilterInShare\": \"Цей вид використовується у засланні для спільного доступу. Зміни в налаштуваннях виду також змінять посилання для спільного доступу.\",\n    \"createFieldButtonText\": \"Створити нове поле <0/>\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"Поділитися\",\n        \"statusLabel\": \"Поділитися виглядом у мережі\",\n        \"noPermission\": \"Немає дозволу ділитися переглядом\",\n        \"shareLink\": \"Посилання для спільного доступу\",\n        \"copied\": \"Скопійовано\",\n        \"genLink\": \"Створити нове посилання\",\n        \"allowCopy\": \"Дозволити глядачам копіювати дані з цього виду\",\n        \"showAllFields\": \"Показати всі поля у розгорнутих записах\",\n        \"restrict\": \"Обмежити паролем\",\n        \"tips\": \"Люди, які мають посилання, можуть бачити цей вид.\",\n        \"passwordTitle\": \"Введіть пароль\",\n        \"passwordTips\": \"Обмеження пароля для доступу до загальних видів\",\n        \"embed\": \"Вбудувати\",\n        \"embedPreview\": \"Перегляд вбудовування\",\n        \"hideToolbar\": \"Приховати панель інструментів\",\n        \"URLSetting\": \"Налаштування параметрів URL\",\n        \"URLSettingDescription\": \"Зміна наступних параметрів не вплине на вже створені посилання. Вам потрібно скопіювати посилання з новими параметрами, щоб вони набрали чинності\",\n        \"cancel\": \"Скасування\",\n        \"save\": \"Зберегти\",\n        \"requireLogin\": \"Потребує входу в систему\"\n      },\n      \"extensions\": {\n        \"label\": \"Розширення\",\n        \"graph\": \"Графік\"\n      },\n      \"api\": {\n        \"label\": \"API\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"Підключення до бази даних\"\n      },\n      \"personalView\": {\n        \"personal\": \"Особисте\",\n        \"tip\": \"Після активації налаштування вигляду застосовуватимуться лише до вас\",\n        \"collaborative\": \"Спільна робота\",\n        \"dialog\": {\n          \"title\": \"Вийти з особистого режиму\",\n          \"description\": \"Налаштування особистого перегляду буде відновлено до стану спільної роботи в реальному часі. Ви також можете зберегти особисті налаштування перегляду та синхронізувати їх для всіх.\",\n          \"cancelText\": \"Вийти та синхронізувати\",\n          \"confirmText\": \"Підтвердити вихід\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"Ласкаво просимо\",\n    \"emptyTitle\": \"Почніть створювати свою базу даних\",\n    \"description\": \"Натисніть кнопку «+» на бічній панелі, щоб додати ресурси\",\n    \"help\": \"Відвідайте <HelpCenter /> для отримання додаткової інформації\",\n    \"helpCenter\": \"Центр допомоги\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"Неможливо пов'язати запис: у цьому ж пакеті його вже пов'язано іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.\",\n      \"one_many_duplicate\": \"Неможливо пов'язати запис: його вже пов'язано з іншим записом. У зв'язках один-до-багатьох кожен дочірній запис може належати лише одному батьківському запису.\",\n      \"one_one_duplicate\": \"Неможливо пов'язати запис: цільовий запис уже пов'язано іншим записом у зв'язку один-до-одного.\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"Таблиця \\\"{{tableName}}\\\" може містити не більше ніж {{maxFieldCount}} полів.\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"Керування полями\",\n    \"fieldManagementDesc\": \"Детальні властивості всіх полів поточної таблиці\",\n    \"advancedProps\": \"Розширені властивості\",\n    \"hide\": \"приховати\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"Мітка\"\n      },\n      \"longText\": {\n        \"title\": \"Примітки\"\n      },\n      \"number\": {\n        \"title\": \"Число\",\n        \"formatType\": \"Тип формату\",\n        \"CurrencySymbol\": \"Символ валюти\",\n        \"defaultSymbol\": \"₽\",\n        \"precision\": \"Точність\",\n        \"decimalExample\": \"Число (123)\",\n        \"currencyExample\": \"Валюта (100₴)\",\n        \"%Example\": \"Відсоток (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"Статус\",\n        \"options\": {\n          \"todo\": \"До виконання\",\n          \"inProgress\": \"У процесі\",\n          \"done\": \"Виконано\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"Теги\"\n      },\n      \"attachment\": {\n        \"title\": \"Вкладені файли\"\n      },\n      \"user\": {\n        \"title\": \"Співавтор\"\n      },\n      \"date\": {\n        \"title\": \"Дата\",\n        \"dateFormatting\": \"Формат дати\",\n        \"timeFormatting\": \"Формат часу\",\n        \"timeZone\": \"Годинний пояс\",\n        \"yearMonth\": \"Рік/Місяць\",\n        \"monthDay\": \"Місяць/День\",\n        \"year\": \"Рік\",\n        \"month\": \"Місяць\",\n        \"day\": \"День\",\n        \"local\": \"Місцева\",\n        \"friendly\": \"Дружній\",\n        \"us\": \"США\",\n        \"european\": \"Європейський\",\n        \"asia\": \"Азіатський\",\n        \"custom\": \"Користувачський\",\n        \"12Hour\": \"12-годинний\",\n        \"24Hour\": \"24-годинний\",\n        \"noDisplay\": \"Не відображати\"\n      },\n      \"autoNumber\": {\n        \"title\": \"ID\"\n      },\n      \"createdTime\": {\n        \"title\": \"Час створення\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"Час останньої зміни\"\n      },\n      \"createdBy\": {\n        \"title\": \"Створено\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"Остання зміна\"\n      },\n      \"rating\": {\n        \"title\": \"Рейтинг\"\n      },\n      \"checkbox\": {\n        \"title\": \"Виконано\"\n      },\n      \"button\": {\n        \"title\": \"Кнопка\",\n        \"label\": \"Текст кнопки\",\n        \"color\": \"Колір кнопки\",\n        \"limitCount\": \"Обмежити лічильник кліків\",\n        \"resetCount\": \"Дозволити скидання лічильника кліків\",\n        \"maxCount\": \"Максимальна кількість кліків\",\n        \"automation\": \"Автоматизація\",\n        \"customAutomation\": \"Налаштована автоматизація\",\n        \"clickConfirm\": \"Підтвердити перед кліком\",\n        \"confirmTitle\": \"Заголовок\",\n        \"confirmDescription\": \"Опис\",\n        \"confirmButtonText\": \"Текст кнопки підтвердження\"\n      },\n      \"formula\": {\n        \"title\": \"Обчислення\",\n        \"formula\": \"Формула\"\n      },\n      \"lookup\": {\n        \"title\": \"{{lookupFieldName}} (з {{linkFieldName}})\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"{{lookupFieldName}} (відфільтровано з {{tableName}})\"\n      },\n      \"rollup\": {\n        \"title\": \"{{lookupFieldName}} Зведення (з {{linkFieldName}})\",\n        \"rollup\": \"Зведення\",\n        \"selectAnRollupFunction\": \"Виберіть функцію зведення\",\n        \"func\": {\n          \"and\": \"AND\",\n          \"arrayCompact\": \"ARRAYCOMPACT\",\n          \"arrayJoin\": \"ARRAYJOIN\",\n          \"arrayUnique\": \"ARRAYUNIQUE\",\n          \"average\": \"AVERAGE\",\n          \"concatenate\": \"CONCATENATE\",\n          \"count\": \"COUNT\",\n          \"countA\": \"COUNTA\",\n          \"countAll\": \"COUNTALL\",\n          \"max\": \"MAX\",\n          \"min\": \"MIN\",\n          \"or\": \"OR\",\n          \"sum\": \"SUM\",\n          \"xor\": \"XOR\"\n        },\n        \"funcDesc\": {\n          \"and\": \"Повертає істину, якщо всі значення істинні\",\n          \"arrayCompact\": \"Видаляє порожні рядки та нульові значення з масиву. Зберігає 'false' і рядки, що містять один або більше символів пробілу.\",\n          \"arrayJoin\": \"Об'єднує всі значення в один рядок, розділений комами.\",\n          \"arrayUnique\": \"Повертає тільки унікальні елементи.\",\n          \"average\": \"Середнє арифметичне значень.\",\n          \"concatenate\": \"Об'єднує текстові значення в одне текстове значення.\",\n          \"count\": \"Вважає лише непусті числові значення. Якщо ви хочете порахувати всі записи, використовуйте КІЛЬКІСТЬ.\",\n          \"countA\": \"Вважає кількість непустих значень. Ця функція вважає як числові, так і текстові значення.\",\n          \"countAll\": \"вважає всі значення, включаючи порожні записи.\",\n          \"max\": \"Повертає найбільше із заданих чисел.\",\n          \"min\": \"Повертає найменше із заданих чисел.\",\n          \"or\": \"Повертає істину, якщо хоча б одне із значень істинне.\",\n          \"sum\": \"Сумує значення.\",\n          \"xor\": \"Повертає істину тоді і тільки тоді, коли непарна кількість значень є істинною.\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} умовне зведення\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"Додати поле\",\n      \"editField\": \"Редагувати поле\",\n      \"insertField\": \"Вставити поле\",\n      \"graph\": \"Графік\",\n      \"defaultValue\": \"Значення за замовчуванням\",\n      \"reset\": \"Скинути\",\n      \"fieldUpdated\": \"Поле оновлено\",\n      \"fieldCreated\": \"Поле створено\",\n      \"confirmFieldChange\": \"Confirm Field Change\",\n      \"areYouSurePerformIt\": \"Ви впевнені, що хочете це виконати?\",\n      \"addDescription\": \"Додати опис\",\n      \"dbFieldName\": \"Назва поля БД\",\n      \"description\": \"Опис\",\n      \"descriptionPlaceholder\": \"Опишіть це поле (необов'язково)\",\n      \"type\": \"Тип\",\n      \"showAs\": \"Показати як\",\n      \"color\": \"Колір\",\n      \"number\": \"Кисло\",\n      \"chartBar\": \"Смужка діаграми\",\n      \"chartLine\": \"Лінія діаграми\",\n      \"ring\": \"Кільце\",\n      \"bar\": \"Бар\",\n      \"text\": \"Текст\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"URL\",\n      \"email\": \"Електронна пошта\",\n      \"phone\": \"Телефон\",\n      \"maxNumber\": \"Максимальна кількість\",\n      \"showNumber\": \"Показати номер\",\n      \"autoFillDate\": \"Автозаповнення поточною датою\",\n      \"createSymmetricLink\": \"Створити поле зворотного посилання в таблиці посилань\",\n      \"allowLinkMultipleRecords\": \"Дозволити множинний вибір\",\n      \"allowLinkToDuplicateRecords\": \"Дозволяє вибирати записи кілька разів\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"Дозволяє вибирати записи кілька разів\",\n      \"oneToOne\": \"один на один\",\n      \"oneToMany\": \"один до багатьох\",\n      \"manyToOne\": \"багато до одного\",\n      \"manyToMany\": \"багато до багатьох\",\n      \"self\": \"Я\",\n      \"selectTable\": \"Виберіть таблицю...\",\n      \"selectBase\": \"Виберіть базу...\",\n      \"linkFromAnotherBase\": \"Посилання із зовнішньої бази\",\n      \"inSelfLink\": \"у власному посиланні\",\n      \"betweenTwoTables\": \"між двома столами\",\n      \"tips\": \"Поради\",\n      \"linkTipMessage\": \"ця конфігурація представляє<br></br> <b>{{relationship}}</b> зв’язок <span>{{linkType}}</span>\",\n      \"style\": \"Стиль\",\n      \"maximum\": \"Максимум\",\n      \"addOption\": \"Додати параметр\",\n      \"allowMultiUsers\": \"Дозволити додавання кількох користувачів\",\n      \"notifyUsers\": \"Повідомляти користувачів, коли їх буде вибрано\",\n      \"searchTable\": \"Таблиця пошуку...\",\n      \"calculating\": \"Обчислення...\",\n      \"doSaveChanges\": \"Ви хочете зберегти внесені зміни?\",\n      \"linkFieldToLookup\": \"Зв'язане поле запису для пошуку\",\n      \"lookupToTable\": \"Поле з <bold>{{tableName}}</bold>, яке потрібно знайти\",\n      \"rollupToTable\": \"Поле з <bold>{{tableName}}</bold>, яке потрібно знайти\",\n      \"selectField\": \"Виберіть поле...\",\n      \"linkTable\": \"Таблиця посилань\",\n      \"linkBase\": \"База посилань\",\n      \"tableNoPermission\": \"Немає таблиці дозволів\",\n      \"baseNoPermission\": \"Немає бази дозволів\",\n      \"noLinkTip\": \"Немає зв'язаних записів для пошуку. Додайте посилання на інше поле запису, а потім спробуйте налаштувати пошук знову.\",\n      \"fieldValidationRules\": \"Правила перевірки значення поля\",\n      \"enableValidateFieldUnique\": \"Заборонити повторювані значення\",\n      \"enableValidateFieldNotNull\": \"Обов'язкове\",\n      \"knowMore\": \"дізнатися більше\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.ai/en/basic/field/advanced/link\",\n      \"showByField\": \"Показати записи за полем\",\n      \"filterByView\": \"Фільтрувати записи за переглядом\",\n      \"filter\": \"Фільтрувати записи\",\n      \"hideFields\": \"Приховати поля\",\n      \"moreOptions\": \"Більше параметрів\",\n      \"allowNewOptionsWhenEditing\": \"Дозволити нові параметри під час редагування\",\n      \"deleteField\": {\n        \"title\": \"Видалити поле\",\n        \"simpleConfirm\": \"Ви впевнені, що хочете видалити поле <b>{{fieldName}}</b>?\",\n        \"withDependencies\": \"Видалення поля <b>{{fieldName}}</b> вплине на такі поля:\",\n        \"affectedFields\": \"Поля, на які вплине:\",\n        \"fieldsToDelete\": \"Поля для видалення ({{count}})\",\n        \"unviewedHint\": \"{{count}} поле(ів) не переглянуто\",\n        \"deleteCount\": \"Видалити {{count}} полів\",\n        \"noAffectedFields\": \"Це поле не використовується іншими полями.\",\n        \"riskIdentified\": \"Виявлено ризик({{count}})\",\n        \"noDependencies\": \"Без залежностей({{count}})\",\n        \"safeToDelete\": \"Безпечно видалити\",\n        \"safeToDeleteDesc\": \"Це поле не використовується іншими полями, його можна безпечно видалити\",\n        \"affectedItems\": \"Елементи, на які вплине\",\n        \"type\": \"Тип\",\n        \"source\": \"Джерело\",\n        \"sourceTable\": \"Вихідна таблиця\",\n        \"typeField\": \"Поле\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"Sort linked records and limit the number of matches\",\n        \"sortLabel\": \"Sort results\",\n        \"orderPlaceholder\": \"Select an order\",\n        \"clearSort\": \"Clear sort\",\n        \"limitLabel\": \"Maximum records to include\",\n        \"limitPlaceholder\": \"Leave blank to include all matches\",\n        \"limitHint\": \"We only keep up to {{limit}} matching records.\",\n        \"sortMissingWarningTitle\": \"Sorting field unavailable\",\n        \"sortMissingWarningDescription\": \"The field that powered this sort was deleted. Results ignore the sort and only enforce the limit.\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"Посилання на записи у вибраній таблиці\",\n      \"singleLineText\": \"Введіть текст або попередньо заповніть кожну нову клітинку значенням за замовчуванням.\",\n      \"longText\": \"Введіть кілька рядків тексту.\",\n      \"attachment\": \"Додайте або згенеруйте за допомогою ШІ зображення, або завантажте документи чи інші файли для перегляду чи завантаження.\",\n      \"checkbox\": \"Поставте або зніміть прапорець, щоб вказати статус.\",\n      \"multipleSelect\": \"Виберіть один або більше попередньо визначених параметрів у списку.\",\n      \"singleSelect\": \"Виберіть одну попередньо визначену опцію зі списку або попередньо заповніть кожну нову клітинку опцією за замовчуванням.\",\n      \"user\": \"Додати користувача до запису.\",\n      \"date\": \"Введіть дату (наприклад, 12.11.2023) або виберіть її з календаря.\",\n      \"number\": \"Введіть число або попередньо заповніть кожну нову клітинку стандартним значенням.\",\n      \"duration\": \"Введіть тривалість часу в годинах, хвилинах або секундах (наприклад, 1:23).\",\n      \"rating\": \"Додайте оцінку за попередньо визначеною шкалою.\",\n      \"formula\": \"Обчислити значення на основі полів.\",\n      \"rollup\": \"Підсумувати дані з пов'язаних записів.\",\n      \"conditionalLookup\": \"Показує пов'язані значення, що відповідають заданим фільтрам.\",\n      \"count\": \"Підрахувати кількість зв'язаних записів.\",\n      \"createdTime\": \"Перегляньте дату та час створення кожного запису.\",\n      \"lastModifiedTime\": \"Дивіться дату й час останнього редагування деяких або всіх полів у записі.\",\n      \"createdBy\": \"Дивіться, який користувач створив запис.\",\n      \"lastModifiedBy\": \"Дивіться, хто з користувачів останнім редагував деякі або всі поля в записі.\",\n      \"autoNumber\": \"Автоматично генерувати унікальні додаткові числа для кожного запису.\",\n      \"button\": \"Запустити спеціальну дію.\",\n      \"lookup\": \"Переглянути значення з поля у зв'язаному записі.\"\n    },\n    \"fieldName\": \"Ім'я поля\",\n    \"fieldNameOptional\": \"Назва поля (необов'язково)\",\n    \"fieldType\": \"Тип поля\",\n    \"aiConfig\": {\n      \"title\": \"Конфігурація AI\",\n      \"type\": {\n        \"summary\": \"Сума\",\n        \"translation\": \"Переклад\",\n        \"extraction\": \"Витяг інформації\",\n        \"improvement\": \"Покращення\",\n        \"tag\": \"Смарт-тег\",\n        \"classification\": \"Смарт-класифікація\",\n        \"customization\": \"Налаштування\",\n        \"imageGeneration\": \"Генерація зображень\",\n        \"rating\": \"Оцінка зображень\"\n      },\n      \"label\": {\n        \"type\": \"Тип дії AI\",\n        \"model\": \"Модель AI\",\n        \"targetLanguage\": \"Цільова мова\",\n        \"sourceField\": \"Вихідне поле\",\n        \"sourceFieldForTag\": \"Виберіть поле, щоб збігти його зі створеними тегами\",\n        \"sourceFieldForClassify\": \"Виберіть поле, щоб збігти його зі створеними класифікаціями\",\n        \"attachPrompt\": \"Прикріпити вимоги\",\n        \"prompt\": \"Налаштувати Prompt\",\n        \"sourceFieldForAttachment\": \"Вихідне поле для вкладень\",\n        \"imageSize\": \"Розмір зображення\",\n        \"imageQuality\": \"Якість зображення\",\n        \"imageCount\": \"Кількість зображень\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"Сума змісту\",\n        \"translate\": \"Переклад короткий та легкий для розуміння, світлий\",\n        \"extractInfo\": \"Витягти електронну пошту, телефон, ім'я, адресу...\",\n        \"extractDate\": \"Витягнути час початку\",\n        \"improveText\": \"Формальний, дружний, гуморний...\",\n        \"attachPromptForTag\": \"Не дозволяти перевищувати три теги\",\n        \"attachPromptForClassify\": \"Класифікувати “В процесі” як “Без ризику”\",\n        \"attachPrompt\": \"Будь ласка, введіть додаткові вимоги\",\n        \"prompt\": \"Будь ласка, введіть власний prompt\",\n        \"type\": \"Виберіть дію AI\",\n        \"targetLanguage\": \"Англійська, Китайська, Французька...\",\n        \"imageSize\": \"Будь ласка, введіть розмір зображення\",\n        \"imageQuality\": \"Будь ласка, введіть якість зображення\",\n        \"attachPromptForImageGeneration\": \"Зображення повинно бути живим та природним\",\n        \"attachPromptForRating\": \"Оцінити якість зображення\"\n      },\n      \"imageQuality\": {\n        \"low\": \"Низька\",\n        \"medium\": \"Середня\",\n        \"high\": \"Висока\"\n      },\n      \"autoFill\": {\n        \"title\": \"Автооновлення\",\n        \"tip\": \"Після увімкнення поточне поле буде оновлюватися синхронно зі змінами вмісту конфігурації AI\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"Оновити всі записи\",\n        \"description\": \"Усі записи у поточному перегляді будуть оновлені, включаючи всі дані, що генеруються полем\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"Згенерувати весь стовпець?\",\n        \"description\": \"Генерація всього стовпця оновить {{rowCount}} рядків. Ця операція може споживати велику кількість ресурсів AI.\",\n        \"saveConfigOnly\": \"Зберегти лише конфігурацію\",\n        \"generate\": \"Згенерувати\",\n        \"generateFailed\": \"Не вдалося виконати генерацію\"\n      },\n      \"action\": {\n        \"addAttachment\": \"Додати вкладення\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"Нова таблиця\",\n    \"rename\": \"Перейменувати\",\n    \"design\": \"Дизайн\",\n    \"tableRecordHistory\": \"Історія записів таблиці\",\n    \"deleteConfirm\": \"Ви впевнені, що хочете видалити \\\"{{tableName}}\\\" ?\",\n    \"dbTableName\": \"Ім'я таблиці у фізичній базі даних\",\n    \"schemaName\": \"Назва схеми у фізичній базі даних\",\n    \"baseInfo\": \"Базова інформація\",\n    \"typeOfDatabase\": \"Тип бази даних\",\n    \"descriptionForTable\": \"Опис цієї таблиці\",\n    \"nameForTable\": \"Назва цієї таблиці\",\n    \"deleteTip1\": \"Поля посилань, пов'язані з цією таблицею в інших таблицях, будуть видалені.\",\n    \"deleteTip2\": \"Цю таблицю можна відновити з кошика після видалення.\",\n    \"operator\": {\n      \"createBlank\": \"Нова таблиця\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"Копіювати та вставляти працює лише через HTTPS або локальний хост\",\n      \"copyAndPasteBrowser\": \"Копіювання та вставлення не підтримується в цьому браузері\",\n      \"copying\": \"Копіювання...\",\n      \"copySuccessful\": \"Копіювання успішне\",\n      \"copyFailed\": \"Не вдалося копіювати\",\n      \"pasing\": \"Вставлення...\",\n      \"pasteSuccessful\": \"Вставити успішно\",\n      \"pasteFailed\": \"Не вдалося вставити\",\n      \"filling\": \"Заповнення...\",\n      \"fillSuccessful\": \"Заповнення успішно\",\n      \"fillFailed\": \"Не вдалося заповнити\",\n      \"clearing\": \"Очищення...\",\n      \"clearSuccessful\": \"Очистити успішно\",\n      \"deleteFieldConfirmTitle\": \"Ви видаляєте наступні поля\",\n      \"deleting\": \"Видалення...\",\n      \"deleteSuccessful\": \"Успішне видалення\",\n      \"pasteFileFailed\": \"Файли можна вставляти лише в поле вкладення\",\n      \"copyError\": {\n        \"noFocus\": \"Будь ласка, тримайте сторінку активною та не перемикайте вікна\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"Мітка таблиці\",\n      \"effectCells\": \"Може впливати на клітини\",\n      \"estimatedTime\": \"Орієнтовний час\",\n      \"linkFieldCount\": \"Впливає на кількість посилань\"\n    },\n    \"integrity\": {\n      \"check\": \"Перевірити\",\n      \"title\": \"Перевірка цілісності\",\n      \"loading\": \"Перевірка цілісності...\",\n      \"allGood\": \"Все добре!\",\n      \"fixIssues\": \"Виправити проблеми\"\n    },\n    \"index\": {\n      \"description\": \"Індекси можуть значно підвищити ефективність пошуку, особливо під час роботи з великими обсягами даних (понад {{rowCount}} рядків). Недоліком є ​​дещо повільніші операції запису. Якщо ви часто виконуєте пошук або маєте великі набори даних, рекомендується щоб увімкнути індексацію.\",\n      \"repair\": \"ремонт\",\n      \"repairTip\": \"Виявлено аномалії індексу, які можуть спричинити погіршення продуктивності пошуку. Рекомендовано натиснути кнопку відновлення, щоб виправити індекс\",\n      \"enableIndexTip\": \"Ви створюєте індекс. Необхідний час залежить від розміру таблиці. Під час цього процесу може вплинути швидкість читання та запису таблиці. Будь ласка, будьте терплячими.\",\n      \"globalSearchTip_infinity\": \"Шукати в усіх полях, крім дати, поля прапорця та поля кнопок\",\n      \"globalSearchTip_limited\": \"Шукати в усіх полях, крім дати, поля прапорця та поля кнопок, максимум {{count}} полей\",\n      \"autoIndexTip\": \"Ваша таблиця перевищила кількість рядків ({{rowCount}}). Ми рекомендуємо ввімкнути індексування, щоб покращити ефективність пошуку. Зауважте, що індексування може дещо зменшити швидкість запису. Під час створення індексу продуктивність читання/запису таблиці може тимчасово вплинути. Будь ласка будь терплячим.\",\n      \"enableIndex\": \"увімкнути\",\n      \"keepAsIs\": \"залишити як\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"Завантажити\",\n      \"import\": \"Імпорт\",\n      \"localFile\": \"Локальні файли\",\n      \"linkUrl\": \"Посилання (URL)\",\n      \"linkUrlInputTitle\": \"Додати файл із URL\",\n      \"importTitle\": \"Створити нову таблицю\",\n      \"incrementImportTitle\": \"Імпортувати до — \",\n      \"optionsTitle\": \"Параметр імпорту\",\n      \"primitiveFields\": \"Примітивні поля\",\n      \"importFields\": \"Поле імпорту\",\n      \"primaryField\": \"Основне поле\",\n      \"tipsTitle\": \"Поради\",\n      \"confirm\": \"Підтвердити та продовжити\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"Додати з інших джерел\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"CSV-файл\",\n      \"importCsvData\": \"Імпортувати дані CSV\",\n      \"importExcelData\": \"Імпортувати дані Microsoft Excel\",\n      \"cancel\": \"Скасувати\",\n      \"leave\": \"Залишити\",\n      \"downAsCsv\": \"Завантажити csv\",\n      \"importData\": \"Імпорт даних\",\n      \"duplicate\": \"Дублювати\",\n      \"duplicating\": \"Дублювання...\",\n      \"duplicateSuccess\": \"Успішно дублювано\",\n      \"duplicateFailed\": \"Не вдалося дублювати\",\n      \"importing\": \"Імпорт\",\n      \"includeRecords\": \"Включити записи\",\n      \"autoFill\": \"Оновити всі записи\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"Натисніть або перетягніть файл у цю область, щоб завантажити\",\n      \"leaveTip\": \"Ваші дані все одно будуть імпортовані.\",\n      \"fileExceedSizeTip\": \"Розмір файлу цього типу перевищує ліміт\",\n      \"analyzing\": \"аналіз\",\n      \"importing\": \"Імпорт\",\n      \"notSupportFieldType\": \"Тип поля не підтримується\",\n      \"resultEmpty\": \"Не знайдено результатів.\",\n      \"searchPlaceholder\": \"Шукати...\",\n      \"importAlert\": \"Після початку імпорту його не можна зупинити, доки він не буде успішно завершений або припинено через помилку. Статус імпорту відображається у верхньому правому куті таблиці. Вам буде повідомлено про результати процесу імпорту після завершення не слід оновлювати поле під час імпорту, оскільки це може спричинити помилки.\",\n      \"noTips\": \"Я це знаю, більше не показувати\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"Автоматичний вибір типів полів\",\n      \"useFirstRowAsHeaderOptionName\": \"Використовувати перший рядок як заголовок\",\n      \"importDataOptionName\": \"Імпортувати дані\",\n      \"sheetKey\": \"Назва аркуша: \",\n      \"excludeFirstRow\": \"Виключити перший рядок з імпорту\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"Поле\",\n      \"error\": {\n        \"urlEmptyTip\": \"URL не має бути порожнім!\",\n        \"errorFileFormat\": \"Неправильний формат файлу!\",\n        \"uniqueFieldName\": \"Назва поля має бути унікальною!\",\n        \"fieldNameEmpty\": \"Ім'я поля не має бути порожнім!\",\n        \"atLeastAImportField\": \"Будь ласка, установіть принаймні поле імпорту\",\n        \"urlValidateTip\": \"Не вдалося проаналізувати URL. Спробуйте інший URL!\"\n      },\n      \"option\": {\n        \"doNotImport\": \"Не імпортувати\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"Завантажити CSV\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"Додати новий запис\",\n    \"prefillingRowTooltip\": \"Пожалуйста, введіть дані нового запису нижче. Запис буде збережено автоматично, як тільки ви клацнете в цьому рядку.\",\n    \"presortRowTitle\": \"Цей запис був відфільтрований або переміщений із-за правил сортування\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"Поля\",\n    \"addAll\": \"Додати все\",\n    \"removeAll\": \"Удалити все\",\n    \"hideFieldTip\": \"Скрити поле сюда\",\n    \"unableAddFieldTip\": \"Неможливо додати поле цього типу\",\n    \"removeFromFormTip\": \"Удалити з форми\",\n    \"descriptionPlaceholder\": \"Введіть опис форми\",\n    \"dragToFormTip\": \"Перемістіть поле сюди, щоб додати його у форму\",\n    \"protectedFieldTip\": \"Це поле було встановлено як \\\"обов'язкове\\\" поле і не може бути видалено у вигляді форми. Будь ласка, змініть його в настройках поля.\"\n  },\n  \"kanban\": {\n    \"панель інструментів\": {\n      \"hideFieldName\": \"Скрити ім'я поля\",\n      \"customizeCards\": \"Налаштувати картку\",\n      \"stackedBy\": \"Згруповано за <0/>\",\n      \"chooseStackingField\": \"Виберіть поле для групування\",\n      \"chooseStackingFieldDescription\": \"Яке поле ви хочете використовувати для налаштувань канбану? Ваші записи будуть згруповані на основі цього поля.\",\n      \"hideEmptyStack\": \"Скрити пусті групи\",\n      \"imageSetting\": \"Налаштування зображення\",\n      \"fit\": \"За розміром\",\n      \"noImage\": \"Без зображення\",\n      \"chooseAttachmentField\": \"Виберіть поле вкладень\"\n    },\n    \"стек\": {\n      \"addStack\": \"Додати групу\",\n      \"noCards\": \"Немає карток\",\n      \"uncategorized\": \"Без категорії\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"Згорнути групу\",\n      \"renameStack\": \"Перейменувати групу\",\n      \"deleteStack\": \"Видалити групу\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"Вставити картку вище\",\n      \"insertCardBelow\": \"Вставити картку нижче\",\n      \"expandCard\": \"Розвернути картку\",\n      \"deleteCard\": \"Видалити картку\",\n      \"duplicateCard\": \"Дублірувати картку\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"Налаштування календаря\",\n      \"startDateField\": \"Початкова дата\",\n      \"endDateField\": \"Кінцева дата\",\n      \"titleField\": \"Заголовок\",\n      \"ColorField\": \"Колір\",\n      \"colorType\": \"Відображення кольору\",\n      \"customColor\": \"Налаштувати колір\",\n      \"alignWithRecords\": \"Відповідати записам\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"Виберіть поле кольору\"\n    },\n    \"dialog\": {\n      \"startDate\": \"Початкова дата\",\n      \"endDate\": \"Кінцева дата\",\n      \"notAdd\": \"Не додавати\",\n      \"addDateField\": \"Додати дату\",\n      \"content\": \"Створити календарний вигляд, і в таблиці має бути два поля дати: початкова дата та кінцева дата\"\n    },\n    \"moreLinkText\": \"Показати всі записи {{count}}\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"Вставити запис <input /> вище\",\n    \"insertRecordBelow\": \"Вставте запис <input /> нижче\",\n    \"copyCells\": \"Копіювати комірки\",\n    \"deleteRecord\": \"Видалити запис\",\n    \"deleteAllSelectedRecords\": \"Видалити всі вибрані записи\",\n    \"editField\": \"Поле редагування\",\n    \"insertFieldLeft\": \"Вставити ліворуч\",\n    \"insertFieldRight\": \"Вставити праворуч\",\n    \"freezeUpField\": \"Закріпити до цього поля\",\n    \"hideField\": \"Приховати поле\",\n    \"deleteField\": \"Видалити поле\",\n    \"deleteAllSelectedFields\": \"Видалити всі вибрані поля\",\n    \"filterField\": \"Фільтрувати за цим полем\",\n    \"sortField\": \"Сортувати за цим полем\",\n    \"groupField\": \"Групувати за цим полем\",\n    \"groupMenuTitle\": \"Меню групи\",\n    \"expandGroup\": \"Розгорнути цю групу та її підгрупи\",\n    \"collapseGroup\": \"Згорнути цю групу та її підгрупи\",\n    \"expandAllGroups\": \"Розгорнути всі групи\",\n    \"collapseAllGroups\": \"Згорнути всі групи\",\n    \"addToChat\": \"Додати до чату\"\n  },\n  \"connection\": {\n    \"title\": \"Підключення до бази даних\",\n    \"description\": \"Ви можете отримати прямий доступ до бази даних через підключення до бази даних, включаючи всі таблиці в поточній базі\",\n    \"noPermission\": \"У вас немає дозволів на доступ до бази даних\",\n    \"connectionCountTip\": \"Максимальна кількість підключень до бази даних - <b>{{max}}</b>, поточна кількість підключень - <b>{{current}}</b>\",\n    \"createFailed\": \"Пожалуйста, переконайтеся, що змінне середовище PUBLIC_DATABASE_PROXY встановлено правильно\",\n    \"helpLink\": \"https://help.teable.ai/en/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"Додати запис\",\n    \"searchView\": \"Поиск вида...\",\n    \"dragToolTip\": \"Включена автоматична сортування, ручне перезавантаження недоступне\",\n    \"insertToolTip\": \"Включена автоматична сортування, вставка з порядком недоступна\",\n    \"action\": {\n      \"rename\": \"Переіменувати вид\",\n      \"duplicate\": \"Дублювати представлення\",\n      \"delete\": \"Видалити вид\",\n      \"enable\": \"Включити\"\n    },\n    \"category\": {\n      \"table\": \"Табличний вид\",\n      \"form\": \"Вид форми\",\n      \"kanban\": \"Канбан-вид\",\n      \"gallery\": \"Галерея вид\",\n      \"calendar\": \"Вид календаря\"\n    },\n    \"crash\": {\n      \"title\": \"Сбой!\",\n      \"description\": \"Це вид сломан. Якщо оновлення все ще не допомагає, будь ласка, зв'яжіться з нами. support@teable.ai\"\n    },\n    \"addPluginView\": \"Add Plugin View\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"fields({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"Цей вид заблокований. Ви можете включити персональний режим для редагування параметрів виду, і зміни відбудуться тільки для вас.\"\n    },\n    \"noView\": \"Немає видів\"\n  },\n  \"lastModifiedTime\": \"Час останньої зміни\",\n  \"lastModify\": \"Остання зміна: \",\n  \"pasteNewRecords\": {\n    \"title\": \"Ви хочете додати кілька записів?\",\n    \"description\": \"{{count}} записів буде додано до таблиці.\"\n  },\n  \"tableTrash\": {\n    \"title\": \"Кошик таблиці\",\n    \"resourceType\": \"Тип ресурсу\",\n    \"deletedResource\": \"Видалений ресурс\"\n  },\n  \"baseShare\": {\n    \"title\": \"Поділитися базою\",\n    \"shareTitle\": \"Поділитися\",\n    \"shareToWeb\": \"Поділитися в Інтернеті\",\n    \"description\": \"Поділитися «{{baseName}}» з іншими\",\n    \"nodeShareDescription\": \"Поділитися «{{nodeName}}» та його вмістом\",\n    \"shareLinks\": \"Посилання для спільного доступу\",\n    \"newLink\": \"Нове посилання\",\n    \"noShareLinks\": \"Ще немає посилань для спільного доступу\",\n    \"createFirstLink\": \"Створити посилання для спільного доступу\",\n    \"editSettings\": \"Змінити налаштування\",\n    \"refreshLink\": \"Оновити посилання\",\n    \"deleteLink\": \"Видалити посилання\",\n    \"deleteConfirmTitle\": \"Видалити посилання для спільного доступу\",\n    \"deleteConfirmDescription\": \"Цю дію не можна скасувати. Люди з цим посиланням більше не матимуть доступу до спільної бази.\",\n    \"createSuccess\": \"Посилання для спільного доступу створено\",\n    \"createFailed\": \"Не вдалося створити посилання для спільного доступу\",\n    \"updateSuccess\": \"Налаштування спільного доступу оновлено\",\n    \"updateFailed\": \"Не вдалося оновити налаштування спільного доступу\",\n    \"deleteSuccess\": \"Посилання для спільного доступу видалено\",\n    \"deleteFailed\": \"Не вдалося видалити посилання для спільного доступу\",\n    \"refreshSuccess\": \"Посилання для спільного доступу оновлено\",\n    \"refreshFailed\": \"Не вдалося оновити посилання для спільного доступу\",\n    \"copied\": \"Посилання скопійовано в буфер обміну\",\n    \"shareLink\": \"Посилання для спільного доступу\",\n    \"linkHolderLabel\": \"Особа, яка отримала посилання\",\n    \"linkHolderCanView\": \"Може переглядати\",\n    \"linkHolderCanEdit\": \"Може редагувати\",\n    \"linkHolderCanCopyAndSave\": \"Може зберегти як копію\",\n    \"passwordProtection\": \"Захист паролем\",\n    \"enterPassword\": \"Введіть пароль\",\n    \"selectNodes\": \"Вибрати таблиці\",\n    \"shareEntireBase\": \"Поділитися всією базою\",\n    \"shareSelectedNodes\": \"Поділитися вибраними вузлами\",\n    \"shareEntireBaseDescription\": \"Нові таблиці та папки, додані до цієї бази, будуть автоматично доступні\",\n    \"noNodesSelectedWarning\": \"Будь ласка, виберіть принаймні один вузол для спільного доступу\",\n    \"allowSave\": \"Дозволити збереження в просторі\",\n    \"allowSaveDescription\": \"Дозволити глядачам зберігати копію цієї бази у своєму просторі\",\n    \"allowCopy\": \"Дозволити копіювання даних\",\n    \"allowCopyData\": \"Дозволити глядачам копіювати дані\",\n    \"allowDuplicate\": \"Дозволити глядачам дублювати\",\n    \"allowCopyDescription\": \"Дозволити глядачам копіювати дані таблиці з цієї спільної бази\",\n    \"selectedNodes\": \"Вибрано {{count}} таблиць\",\n    \"allNodes\": \"Усі таблиці\",\n    \"sharedNode\": \"Спільний вузол\",\n    \"sharedNodeDescription\": \"Це посилання для спільного доступу призначено для конкретного вузла та його нащадків\",\n    \"publicShareTitle\": \"Публічний доступ за посиланням\",\n    \"publicShareCount\": \"{{count}} публічних посилань для спільного доступу\",\n    \"noPublicShare\": \"Немає публічних посилань для спільного доступу\",\n    \"security\": \"Безпека\",\n    \"restrictByPassword\": \"Обмежити паролем\",\n    \"advanced\": \"Додатково\",\n    \"embedConfig\": \"Конфігурація вбудовування\",\n    \"appPublicLink\": \"Публічне посилання на програму\",\n    \"appNotPublished\": \"Ця програма ще не опублікована. Будь ласка, спочатку опублікуйте програму перед тим, як ділитися.\",\n    \"goToPublish\": \"Перейти до публікації\",\n    \"publishSuccess\": \"Програму успішно опубліковано\",\n    \"publishFailed\": \"Не вдалося опублікувати програму\",\n    \"openLink\": \"Відкрити посилання\",\n    \"appPublished\": \"Програму опубліковано\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"Отримати поля таблиці\",\n      \"getTablesMeta\": \"Отримати метадані таблиці\",\n      \"sqlQuery\": \"Виконати SQL запит\",\n      \"generateScriptAction\": \"Створити дію скрипта\",\n      \"getScriptInput\": \"Отримати вхідні дані скрипта\",\n      \"getTeableApi\": \"Отримати API\",\n      \"dataVisualization\": \"Візуалізація даних\",\n      \"updateBase\": \"Оновити інформацію бази\",\n      \"args\": \"Аргументи\",\n      \"result\": \"Результат\",\n      \"thinking\": \"Міркування...\",\n      \"toBeConfirmed\": \"Підлягає підтвердженню\",\n      \"errorMessage\": \"Повідомлення про помилку\",\n      \"confirm\": \"Підтвердити\",\n      \"createRecordsSuccess\": \"Успішно створено {{count}} запис(ів)\",\n      \"createRecordsFailed\": \"Не вдалося створити записи\",\n      \"updateRecordsSuccess\": \"Успішно оновлено {{count}} запис(ів)\",\n      \"updateRecordsFailed\": \"Не вдалося оновити записи\",\n      \"generatingRecords\": \"Генерація {{count}} запис(ів)...\",\n      \"creatingRecords\": \"Створення {{count}} запис(ів)...\",\n      \"updatingRecords\": \"Оновлення {{count}} запис(ів)...\",\n      \"recordsPreview\": \"Попередній перегляд записів\",\n      \"andMoreRecords\": \"Ще {{count}}...\",\n      \"unknownError\": \"Невідома помилка\",\n      \"recordIds\": \"Ідентифікатори записів\",\n      \"records\": \"запис(ів)\",\n      \"viewAll\": \"Переглянути все\",\n      \"showLess\": \"Показати менше\",\n      \"generatingData\": \"Генерація даних...\",\n      \"generatingUpdates\": \"Генерація оновлень...\",\n      \"recordsGenerated\": \"Згенеровано {{count}} запис(ів)\",\n      \"recordsCount\": \"Записи({{count}})\",\n      \"fieldsCount\": \"Поля({{count}})\",\n      \"fieldsGenerated\": \"Згенеровано {{count}} полів\",\n      \"updatedProperties\": \"Оновлено ({{count}})\",\n      \"configured\": \"налаштовано\",\n      \"recordsToUpdate\": \"{{count}} запис(ів) до оновлення\",\n      \"showingLast\": \"останні {{count}}\",\n      \"recordLabel\": \"Запис\",\n      \"statusGenerating\": \"Генерація...\",\n      \"statusCreating\": \"Створення...\",\n      \"statusUpdating\": \"Оновлення...\",\n      \"statusCreated\": \"Створено\",\n      \"statusUpdated\": \"Оновлено\",\n      \"getApps\": {\n        \"title\": \"Список додатків\",\n        \"loading\": \"Завантаження додатків...\",\n        \"foundApps\": \"Знайдено {{count}} додаток(ів)\",\n        \"noApps\": \"У цій базі немає додатків\",\n        \"openApp\": \"Відкрити додаток\"\n      },\n      \"generateApp\": {\n        \"title\": \"Конструктор додатків\",\n        \"creatingApp\": \"Створення додатку\",\n        \"updatingApp\": \"Оновлення додатку\",\n        \"generatingApp\": \"Генерація додатку\",\n        \"generating\": \"Генерація...\",\n        \"openApp\": \"Відкрити додаток\",\n        \"viewProgress\": \"Переглянути прогрес\",\n        \"newApp\": \"Новий додаток\",\n        \"building\": \"Побудова...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"Конструктор автоматизації\",\n        \"creatingAutomation\": \"Створення автоматизації\",\n        \"updatingAutomation\": \"Оновлення автоматизації\",\n        \"generatingAutomation\": \"Побудова автоматизації\",\n        \"building\": \"Побудова...\",\n        \"openAutomation\": \"Відкрити автоматизацію\",\n        \"viewProgress\": \"Переглянути прогрес\",\n        \"testResults\": \"Результати тестування\",\n        \"triggerTest\": \"Тригер\",\n        \"actionTest\": \"Дія {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"Попередній перегляд\",\n        \"code\": \"Код\",\n        \"download\": \"Завантажити\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"Зображення (PNG)\",\n        \"copy\": \"Копіювати\",\n        \"copied\": \"Скопійовано!\",\n        \"fullscreen\": \"Повний екран\",\n        \"exitFullscreen\": \"Вийти з повноекранного режиму\",\n        \"downloadSuccess\": \"Зображення успішно завантажено\",\n        \"downloadFailed\": \"Не вдалося знімати зображення\",\n        \"iframeFailed\": \"Не вдалося знімати: iframe недоступний\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"Завантажити вкладення\",\n        \"loading\": \"Завантаження вкладення\",\n        \"failed\": \"Не вдалося завантажити вкладення\",\n        \"empty\": \"Немає завантажених вкладень\",\n        \"modeNative\": \"AI Vision\",\n        \"modeNativeDesc\": \"Завантажено до нативного контексту AI\",\n        \"modeExtracted\": \"Витягнутий текст\",\n        \"modeExtractedDesc\": \"Вміст файлу проаналізовано та витягнуто як текст для аналізу\",\n        \"visionLoaded\": \"Завантажено для візуального аналізу\",\n        \"pdfLoaded\": \"Завантажено для аналізу PDF\",\n        \"textExtracted\": \"Витягнуто {{chars}} символів\",\n        \"contextLoaded\": \"Завантажено до контексту AI\",\n        \"truncated\": \"Обрізано\",\n        \"preview\": \"Попередній перегляд\"\n      },\n      \"textExtract\": {\n        \"title\": \"Витяг тексту\",\n        \"loading\": \"Витягнення тексту\",\n        \"failed\": \"Не вдалося витягти текст\",\n        \"empty\": \"Текст не витягнуто\",\n        \"preview\": \"Попередній перегляд\",\n        \"truncated\": \"Обрізано\",\n        \"previews\": \"попередні перегляди\",\n        \"chars\": \"{{chars}} символів\",\n        \"totalCharacters\": \"Всього: {{chars}} символів\",\n        \"filesTruncated\": \"({{count}} файл(ів) обрізано)\"\n      },\n      \"importExcel\": {\n        \"title\": \"Імпорт Excel\",\n        \"loading\": \"Обробка файлу...\",\n        \"failed\": \"Помилка імпорту\",\n        \"suggestions\": \"Пропозиції\",\n        \"analyzeComplete\": \"Аналіз завершено\",\n        \"worksheets\": \"Аркуші\",\n        \"columns\": \"стовпців\",\n        \"importComplete\": \"Імпорт завершено\",\n        \"stageAnalyze\": \"Аналіз\",\n        \"stageImport\": \"Імпорт\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"Отримати Teable API\",\n      \"readFiles\": \"Читати файли\",\n      \"writeFile\": \"Записати файл\",\n      \"deleteFiles\": \"Видалити файли\",\n      \"listFiles\": \"Список файлів\",\n      \"addDependencies\": \"Додати залежності\",\n      \"checkBuildErrors\": \"Перевірити помилки збірки\",\n      \"lint\": \"Перевірити код\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"Не вдалося завантажити попередній перегляд\",\n      \"retry\": \"Повторити {{count}} раз(и)\",\n      \"chatAborted\": \"перервано\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"Видалена таблиця\",\n      \"deletedView\": \"Видалений вид\",\n      \"deletedField\": \"Видалене поле\",\n      \"deletedRecords\": \"Видалені записи\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"Агент таблиці\",\n      \"viewOperatorAgent\": \"Агент виду\",\n      \"fieldOperatorAgent\": \"Агент поля\",\n      \"recordOperatorAgent\": \"Агент запису\",\n      \"buildBaseAgent\": \"Агент побудови бази\",\n      \"buildAutomationAgent\": \"Агент побудови автоматизації\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"Підлягає підтвердженню\",\n      \"deleteWarning\": \"Ви впевнені, що хочете видалити?\"\n    },\n    \"action\": {\n      \"createTable\": \"Створити таблицю\",\n      \"updateTable\": \"Оновити таблицю\",\n      \"updateTableName\": \"Оновити назву таблиці\",\n      \"deleteTable\": \"Видалити таблицю\",\n      \"createView\": \"Створити подання\",\n      \"updateView\": \"Оновити подання\",\n      \"updateViewName\": \"Оновити назву подання\",\n      \"deleteView\": \"Видалити подання\",\n      \"createField\": \"Створити поле\",\n      \"createAiField\": \"Створити AI поле\",\n      \"createLinkField\": \"Створити поле зв'язку\",\n      \"createLookupField\": \"Створити поле пошуку\",\n      \"createRollupField\": \"Створити поле зведення\",\n      \"createFormulaField\": \"Створити поле формули\",\n      \"deleteField\": \"Видалити поле\",\n      \"updateField\": \"Оновити поле\",\n      \"createRecord\": \"Створити запис\",\n      \"createRecords\": \"Створити записи\",\n      \"deleteRecord\": \"Видалити запис\",\n      \"updateRecord\": \"Оновити запис\",\n      \"updateRecords\": \"Оновити записи\",\n      \"updateBase\": \"Оновити інформацію бази даних\",\n      \"planTask\": \"Планування завдання...\",\n      \"generateTables\": \"Згенерувати таблиці\",\n      \"generatePrimaryFields\": \"Згенерувати первинні поля\",\n      \"generateFields\": \"Згенерувати поля\",\n      \"generateViews\": \"Згенерувати подання\",\n      \"generateRecords\": \"Згенерувати записи\",\n      \"generateAIFields\": \"Згенерувати AI поля\",\n      \"generateLinkFields\": \"Згенерувати поля зв'язку\",\n      \"generateLookupFields\": \"Згенерувати поля пошуку\",\n      \"generateRollupFields\": \"Згенерувати поля зведення\",\n      \"generateFormulaFields\": \"Згенерувати поля формули\",\n      \"generateWorkflow\": \"Згенерувати робочий процес\",\n      \"generateTrigger\": \"Згенерувати тригер\",\n      \"generateScriptAction\": \"Згенерувати вузол дії скрипта\",\n      \"generateSendMailAction\": \"Згенерувати вузол надсилання пошти\",\n      \"generateAction\": \"Згенерувати вузол дії\",\n      \"setupAutomationTrigger\": \"Налаштувати тригер автоматизації\",\n      \"testAutomationNode\": \"Тестувати вузол автоматизації\",\n      \"activateAutomation\": \"Активувати автоматизацію\",\n      \"executeScript\": \"Виконати скрипт\",\n      \"wait\": \"Очікування\",\n      \"generateScriptFlowChart\": \"Згенерувати блок-схему скрипта\",\n      \"triggerAiFill\": \"Запустити AI заповнення\",\n      \"initialize\": \"Ініціалізувати середовище\",\n      \"rename\": \"Згенерувати назву додатку\",\n      \"buildTest\": \"Створити тест\",\n      \"developTask\": \"Розробити завдання\",\n      \"generateSummary\": \"Згенерувати підсумок\",\n      \"previewEnvironment\": \"Підготувати середовище попереднього перегляду\",\n      \"getRelativeData\": \"Отримати пов'язані дані\",\n      \"getPreviousNodeOutputVariables\": \"Отримати вихідні змінні попереднього вузла\",\n      \"getApiJson\": \"Отримати інформацію API\",\n      \"generateScriptAndDependencies\": \"Згенерувати скрипт та залежності\",\n      \"analyzingAttachment\": \"Аналіз вкладення...\",\n      \"locateResource\": \"Знайти\",\n      \"goTo\": \"Перейти\",\n      \"operationSuccess\": \"Операцію виконано успішно\",\n      \"operationFailed\": \"Операцію не виконано\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"{{count}} запис(ів) в черзі для AI генерації\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"Запит записів\",\n      \"getRecordsWithTable\": \"Запит записів · {{tableName}}\",\n      \"getGridRows\": \"Query grid rows\",\n      \"getGridRowsWithTable\": \"Query grid rows · {{tableName}}\",\n      \"getFields\": \"Запит полів\",\n      \"getFieldsWithTable\": \"Запит полів · {{tableName}}\",\n      \"getTables\": \"Запит таблиць\",\n      \"getViews\": \"Запит видів\",\n      \"getViewsWithTable\": \"Запит видів · {{tableName}}\",\n      \"sqlQuery\": \"SQL запит\",\n      \"querying\": \"Виконання запиту...\",\n      \"queryFailed\": \"Запит не виконано\",\n      \"aborted\": \"Перервано\",\n      \"noData\": \"Дані не повернено\",\n      \"dataFormatError\": \"Помилка формату даних\",\n      \"unsupportedQueryType\": \"Непідтримуваний тип запиту: {{toolName}}\",\n      \"returnedRecords\": \"Повернено {{count}} запис(ів)\",\n      \"record\": \"Запис {{index}}\",\n      \"moreRecords\": \"... +{{count}} запис(ів)\",\n      \"foundFields\": \"Знайдено {{count}} полів\",\n      \"moreFields\": \"... +{{count}} полів\",\n      \"foundTables\": \"Знайдено {{count}} таблиць\",\n      \"moreTables\": \"... +{{count}} таблиць\",\n      \"foundViews\": \"Знайдено {{count}} видів\",\n      \"moreViews\": \"... +{{count}} видів\",\n      \"queryReturned\": \"Запит повернув {{rowCount}} рядків × {{columnCount}} стовпців\",\n      \"row\": \"Рядок {{index}}\",\n      \"moreRows\": \"... +{{count}} рядків\",\n      \"getDoc\": \"Отримати документ\",\n      \"getDocWithTopic\": \"Отримати документ · {{topic}}\",\n      \"getAutomations\": \"Запит автоматизацій\",\n      \"getAutomation\": \"Запит автоматизації\",\n      \"getAutomationRuns\": \"Запит запусків автоматизації\",\n      \"foundAutomations\": \"Знайдено {{count}} автоматизацій\",\n      \"moreAutomations\": \"... +{{count}} автоматизацій\",\n      \"foundRuns\": \"Знайдено {{count}} запусків\",\n      \"moreRuns\": \"... +{{count}} запусків\",\n      \"active\": \"Активно\",\n      \"trigger\": \"Тригер\",\n      \"actions\": \"{{count}} дій\",\n      \"moreActions\": \"... +{{count}} дій\",\n      \"getUserIntegrations\": \"Перевірити інтеграції\",\n      \"connectedIntegrations\": \"Підключено\",\n      \"availableToConnect\": \"Доступно до підключення\",\n      \"connect\": \"Підключити\",\n      \"noIntegrationsAvailable\": \"Немає доступних інтеграцій\",\n      \"activateTool\": \"Активувати інструменти\",\n      \"webSearch\": \"Веб-пошук\",\n      \"webSearchResults\": \"Знайдено {{count}} результатів\",\n      \"webSearchCompleted\": \"Пошук завершено\",\n      \"searchApi\": \"Пошук API\",\n      \"searchApiWithQuery\": \"Пошук API · {{query}}\",\n      \"noApiFound\": \"API не знайдено\",\n      \"foundApis\": \"Знайдено {{count}} API\",\n      \"totalApis\": \"Всього {{count}} API доступно\",\n      \"callApi\": \"Викликати API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"Відповідь\",\n      \"success\": \"Успішно\",\n      \"failed\": \"Помилка\",\n      \"inputData\": \"Вхідні дані\",\n      \"availableNodes\": \"Доступні вузли\",\n      \"hasPreviousCode\": \"Є існуючий код\",\n      \"noInputData\": \"Немає вхідних даних\"\n    },\n    \"showUI\": {\n      \"connect\": \"Підключити\",\n      \"connecting\": \"Підключення...\",\n      \"connected\": \"Підключено\",\n      \"connectToUse\": \"Підключіть {{provider}}, щоб використовувати в автоматизаціях\",\n      \"checkingConnection\": \"Перевірка статусу підключення...\",\n      \"confirm\": \"Підтвердити\",\n      \"confirmed\": \"Підтверджено\",\n      \"cancel\": \"Скасувати\",\n      \"cancelled\": \"Скасовано\",\n      \"connectionCancelled\": \"Підключення скасовано\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}} рядків приховано\",\n      \"collapseCode\": \"Згорнути код\",\n      \"code\": \"Код\",\n      \"preview\": \"Попередній перегляд\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"Прогрес побудови\",\n      \"completed\": \"Побудову додатку завершено\",\n      \"completedDesc\": \"Усі кроки успішно виконано, додаток готовий до попереднього перегляду\",\n      \"stepStatus\": {\n        \"initializing\": \"Створення додатку та ініціалізація середовища...\",\n        \"naming\": \"Генерація назви додатку...\",\n        \"planning\": \"Аналіз вимог та планування розробки...\",\n        \"developing\": \"Написання коду та реалізація функцій...\",\n        \"summarizing\": \"Організація результатів розробки...\",\n        \"deploying\": \"Розгортання в середовищі попереднього перегляду...\",\n        \"testing\": \"Побудова тесту...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"Виконується\",\n        \"completed\": \"Завершено\",\n        \"error\": \"Помилка\",\n        \"pending\": \"Очікує\"\n      },\n      \"toolStatus\": {\n        \"running\": \"Виконується\",\n        \"completed\": \"Завершено\",\n        \"error\": \"Помилка\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"Скрипт успішно згенеровано\"\n    },\n    \"buildBase\": {\n      \"title\": \"Побудова бази\",\n      \"generateSuccess\": \"Базу даних успішно згенеровано\",\n      \"generateError\": \"Не вдалося згенерувати базу даних\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"Побудова автоматизації\",\n      \"generateSuccess\": \"Автоматизацію успішно згенеровано\"\n    },\n    \"automation\": {\n      \"created\": \"Створено\",\n      \"updated\": \"Оновлено\",\n      \"workflow\": \"Робочий процес\",\n      \"trigger\": \"Тригер\",\n      \"scriptAction\": \"Дія скрипта\",\n      \"workflowLabel\": \"Робочий процес\",\n      \"triggerLabel\": \"Тригер\",\n      \"scriptActionLabel\": \"Дія скрипта\",\n      \"workflowId\": \"Робочий процес\",\n      \"triggerId\": \"Тригер\",\n      \"scriptActionId\": \"Дія скрипта\",\n      \"viewAutomation\": \"Переглянути\",\n      \"navigateToAutomation\": \"Перейти до автоматизації\",\n      \"triggerType\": {\n        \"recordCreated\": \"Запис створено\",\n        \"recordUpdated\": \"Запис оновлено\",\n        \"recordCreatedOrUpdated\": \"Запис створено або оновлено\",\n        \"formSubmitted\": \"Форму надіслано\",\n        \"scheduledTime\": \"Запланований час\",\n        \"buttonClick\": \"Натискання кнопки\"\n      },\n      \"activated\": \"Активовано\",\n      \"deactivated\": \"Деактивовано\",\n      \"discarded\": \"Зміни скасовано\",\n      \"activateFailed\": \"Помилка активації\",\n      \"deactivateFailed\": \"Помилка деактивації\",\n      \"discardFailed\": \"Помилка скасування\",\n      \"scriptUpdated\": \"Скрипт оновлено\",\n      \"scriptUpdateFailed\": \"Помилка оновлення\",\n      \"scriptExecuted\": \"Скрипт виконано\",\n      \"scriptExecutionFailed\": \"Помилка виконання\",\n      \"scriptReady\": \"Скрипт готовий\",\n      \"executingScript\": \"Виконання скрипта...\",\n      \"waitedSeconds\": \"Очікування {{seconds}}с\",\n      \"waitFailed\": \"Помилка очікування\",\n      \"flowchartGenerated\": \"Блок-схему згенеровано\",\n      \"flowchartGenerationFailed\": \"Помилка генерації\"\n    },\n    \"newChat\": \"Новий чат\",\n    \"clearChat\": \"Очистити чат\",\n    \"expand\": \"Розгорнути\",\n    \"history\": \"Історія\",\n    \"close\": \"Згорнути\",\n    \"clearChatConfirmTitle\": \"Підтвердити очищення чату\",\n    \"clearChatConfirmDesc\": \"Поточний вміст чату не буде збережено. Ви впевнені, що хочете його очистити?\",\n    \"dontShowAgain\": \"Більше не показувати\",\n    \"noModel\": \"Немає доступних моделей\",\n    \"addAttachment\": \"Додати вкладення\",\n    \"noHistory\": \"Немає історії чату\",\n    \"noFoundHistory\": \"Немає історії чату, будь ласка, почати нову розмову\",\n    \"timeGroup\": {\n      \"today\": \"Сьогодні\",\n      \"oneWeek\": \"Одна тиждень\",\n      \"twoWeek\": \"Дві тижні\",\n      \"oneMonth\": \"Один місяць\",\n      \"other\": \"Інше\"\n    },\n    \"context\": {\n      \"button\": \"Додати контекст\",\n      \"search\": \"Додати таблиці\",\n      \"searchEmpty\": \"Не знайдено відповідного контексту\",\n      \"emptyContext\": \"Немає контексту для додавання\",\n      \"selectionRows\": \"Рядки {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"Відправити повідомлення...\",\n    \"thought\": \"Міркування\",\n    \"meta\": {\n      \"timeCostUnit\": \"s\",\n      \"timeCostDescription\": \"Час генерації: {{timeCost}}s\",\n      \"creditDescription\": \"{{credits}} кредитів використано\",\n      \"tokenDescription\": \"Токенів використано: {{tokens}}\",\n      \"input\": \"Вхід\",\n      \"output\": \"Вихід\",\n      \"tokens\": \"Токени\",\n      \"totalTimeCost\": \"Загальний час\",\n      \"totalCreditCost\": \"Загальна вартість кредитів\",\n      \"customModel\": \"Власна модель\",\n      \"tokenDetails\": \"Деталі токенів\",\n      \"cachedInput\": \"Кешований вхід (знижка 90%)\",\n      \"cacheWrite\": \"Запис кешу\",\n      \"reasoning\": \"Міркування (Думаю)\",\n      \"taskCompleted\": \"Завдання виконано\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"Не вдалося візуалізувати дані\"\n    },\n    \"tips\": {\n      \"modelTips\": \"Лише адміністратори можуть налаштовувати\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"Зображення не підтримується\",\n      \"attachmentSizeExceeded\": \"Розмір вкладення перевищує ліміт {{size}} МБ\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"Рекомендовано\",\n      \"ask\": \"Запитати\",\n      \"analyze\": \"Аналіз\",\n      \"build\": \"Побудувати\",\n      \"title\": \"Як я можу допомогти?\",\n      \"whatCanIDo\": \"Що я можу зробити?\",\n      \"createOrModifyDatabase\": \"Створити або змінити базу даних\",\n      \"buildAutomations\": \"Побудувати автоматизації\",\n      \"buildApps\": \"Побудувати додатки\",\n      \"buildMeCRM\": \"Створити CRM\",\n      \"addAIField\": \"Додати AI поле для аналізу кожного клієнта\",\n      \"createDataAnalysis\": \"Створити звіт аналізу даних\",\n      \"emailWhenRecordCreated\": \"Надіслати електронний лист при створенні запису\",\n      \"syncStatusToSlack\": \"Синхронізувати оновлення статусу зі Slack\",\n      \"buildDashboard\": \"Побудувати панель керування з цієї таблиці\",\n      \"buildLeadCapture\": \"Створити сторінку збору лідів\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"Міркував {{duration}} с\"\n      },\n      \"task\": {\n        \"searching\": \"Пошук \\\"{{query}}\\\"\",\n        \"readingFiles\": \"Читання файлів:\",\n        \"foundResults\": \"Знайдено {{count}} результатів\",\n        \"noIssuesFound\": \"Проблем не знайдено\",\n        \"defaultTitle\": \"Завдання\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"Код проєкту\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"Для генерації попереднього перегляду потрібна модель ШІ.\",\n      \"writeCodeHint\": \"Напишіть код, щоб побачити попередній перегляд блок-схеми.\",\n      \"noPreview\": \"Попередній перегляд блок-схеми недоступний.\",\n      \"generatePreview\": \"Згенерувати попередній перегляд\",\n      \"analyzing\": \"Аналіз скрипта...\",\n      \"codeChanged\": \"Код змінився з моменту генерації цього попереднього перегляду.\",\n      \"regenerate\": \"Перегенерувати\",\n      \"refresh\": \"Оновити\",\n      \"regenerating\": \"Перегенерація...\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"Завантажити всі вкладення\",\n      \"loading\": \"Завантаження попереднього перегляду...\",\n      \"rowsWithAttachments\": \"{{count}} рядків з вкладеннями\",\n      \"totalAttachments\": \"{{count}} вкладень\",\n      \"totalSize\": \"Загальний розмір: {{size}}\",\n      \"startDownload\": \"Почати завантаження\",\n      \"confirmTitle\": \"Завантажити {{count}} файлів\",\n      \"confirmDescription\": \"Загальний розмір: {{size}}. Файли будуть стиснуті в ZIP-архів.\",\n      \"confirm\": \"Завантажити\",\n      \"cancel\": \"Скасувати\",\n      \"downloading\": \"Завантаження...\",\n      \"downloadingFile\": \"Завантаження: {{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"Завантаження завершено\",\n      \"cancelled\": \"Завантаження скасовано\",\n      \"noAttachments\": \"Немає вкладень для завантаження\",\n      \"error\": \"Помилка завантаження\",\n      \"errorPartial\": \"{{failedCount}} файлів не вдалося завантажити\",\n      \"requireHttps\": \"Пакетне завантаження вимагає HTTPS. Будь ласка, використовуйте HTTPS або localhost.\",\n      \"advancedOptions\": \"Додаткові параметри\",\n      \"namingFieldLabel\": \"Префікс імені вкладення\",\n      \"selectField\": \"За замовчуванням: індекс вкладення\",\n      \"groupByRow\": \"Архівувати в папки\",\n      \"groupByRowTip\": \"Коли рядок має кілька вкладень, вони будуть розміщені в одній папці; рядки з одним вкладенням не створюють папку.\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/token.json",
    "content": "{\n  \"access\": \"Доступ\",\n  \"name\": \"Ім'я\",\n  \"description\": \"Опис\",\n  \"scopes\": \"Області застосування\",\n  \"expiration\": \"Закінчення терміну\",\n  \"createdTime\": \"Створено\",\n  \"lastUse\": \"Останнє використання\",\n  \"allSpace\": \"Простір, всі поточні та майбутні бази в цьому просторі\",\n  \"formLabelTips\": {\n    \"name\": \"Вкажіть ім'я токена\",\n    \"description\": \"Для чого потрібен цей токен?\",\n    \"scopes\": \"З цим токеном ви зможете:\",\n    \"access\": \"Цей токен може отримати доступ до наступних баз та просторів. Ви можете надати доступ тільки до баз та просторів, до яких у вас є доступ.\"\n  },\n  \"new\": {\n    \"headerTitle\": \"Створити новий токен\",\n    \"title\": \"Персональні токени доступу необхідні для використання API Teable.\",\n    \"description\": \"Цей токен надасть доступ до даних у вибраних просторах та базах. Інші, не пов'язані з просторами/базами, кінцеві точки API. Використовуйте цей токен тільки для власного розвитку. Будьте обережні при його використанні з третіми сторонами та додатками. \",\n    \"button\": \"Створити новий токен\",\n    \"success\": {\n      \"title\": \"Токен успішно створено\",\n      \"description\": \"Переконайтеся, що ви скопіювали свій токен. Він більше не відображатиметься.\"\n    },\n    \"expirationList\": {\n      \"days\": \"дні\",\n      \"permanent\": \"Постійний\",\n      \"custom\": \"Користувачський\",\n      \"pick\": \"Виберіть дату\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"Редагувати токен\",\n    \"name\": \"Ім'я\",\n    \"scopes\": \"Області застосування\",\n    \"selectAll\": \"Вибрати все\",\n    \"cancelSelectAll\": \"Скасувати вибір усіх\"\n  },\n  \"refresh\": {\n    \"title\": \"Відновити персональний токен доступу\",\n    \"description\": \"Надсилання цієї форми створить новий токен. Зверніть увагу, що будь-які скрипти або програми, які використовують цей токен, повинні бути оновлені\",\n    \"button\": \"Відновити токен\"\n  },\n  \"accessSelect\": {\n    \"button\": \"Додати базу або простір\",\n    \"empty\": \"Доступ не знайдено.\",\n    \"spaceSelectItem\": \"Всі бази в просторі\",\n    \"inputPlaceholder\": \"Знайти простір або базу...\"\n  },\n  \"moreScopes\": \"і {{len}} інших\",\n  \"list\": {\n    \"description\": \"Персональні токени доступу необхідні для використання API Teable. Для отримання додаткової інформації, будь ласка, зверніться до <a>документації допомоги</a>.\"\n  },\n  \"empty\": {\n    \"list\": \"Персональні токени доступу не знайдені.\",\n    \"access\": \"Немає доступу\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"Ви впевнені, що хочете видалити цей токен?\",\n    \"description\": \"Будь-які програми або скрипти, які використовують цей токен, більше не зможуть отримати доступ до API Teable. Ви не можете скасувати цю дію.\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.ai/en/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/uk/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"Чекався {{expected}}, отримано {{received}}\",\n    \"invalid_type_received_undefined\": \"Обов'язково\",\n    \"invalid_type_received_null\": \"Обов'язково\",\n    \"invalid_literal\": \"Неприпустиме значення очікувалося {{expected}}\",\n    \"unrecognized_keys\": \"Невідомі ключі в об'єкті: {{- keys}}\",\n    \"invalid_union\": \"Неприпустиме введення\",\n    \"invalid_union_discriminator\": \"Неприпустиме значення дискримінатора. Очікувались {{- options}}\",\n    \"invalid_enum_value\": \"Неприпустиме значення переліку. Очікувалося {{- options}}, отримано '{{received}}'\",\n    \"invalid_arguments\": \"Неприпустимі аргументи функції\",\n    \"invalid_return_type\": \"Неприпустимий тип функції, що повертається\",\n    \"invalid_date\": \"Неприпустима дата\",\n    \"custom\": \"Неприпустиме введення\",\n    \"invalid_intersection_types\": \"Результати перетину не можуть бути об'єднані\",\n    \"not_multiple_of\": \"Число має бути кратним {{multipleOf}}\",\n    \"not_finite\": \"Число має бути кінцевим\",\n    \"invalid_string\": {\n      \"email\": \"Неприпустимий {{validation}}\",\n      \"url\": \"Неприпустимий {{validation}}\",\n      \"uuid\": \"Неприпустимий {{validation}}\",\n      \"cuid\": \"Неприпустимий {{validation}}\",\n      \"regex\": \"Неприпустимий\",\n      \"datetime\": \"Неприпустимий {{validation}}\",\n      \"startsWith\": \"Неприпустиме введення: повинно починатися з \\\"{{startsWith}}\\\"\",\n      \"endsWith\": \"Неприпустиме введення: має закінчуватися на \\\"{{endsWith}}\\\"\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"Масив повинен містити рівно {{minimum}} елемент(ів)\",\n        \"inclusive\": \"Масив повинен містити як мінімум {{minimum}} елемент(ів)\",\n        \"not_inclusive\": \"Масив повинен містити більш {{minimum}} елемент(ів)\"\n      },\n      \"string\": {\n        \"exact\": \"Рядок повинен містити рівно {{minimum}} символ(ів)\",\n        \"inclusive\": \"Рядок повинен містити як мінімум {{minimum}} символ(ів)\",\n        \"not_inclusive\": \"Рядок повинен містити більше {{minimum}} символів\"\n      },\n      \"number\": {\n        \"exact\": \"Число має бути рівним {{minimum}}\",\n        \"inclusive\": \"Число повинно бути більше або одно {{minimum}}\",\n        \"not_inclusive\": \"Кількість має бути більшою за {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Неприпустиме введення\",\n        \"inclusive\": \"Неприпустиме введення\",\n        \"not_inclusive\": \"Неприпустиме введення\"\n      },\n      \"date\": {\n        \"exact\": \"Дата повинна бути рівно {{- minimum, datetime}}\",\n        \"inclusive\": \"Дата повинна бути більшою або дорівнює {{- minimum, datetime}}\",\n        \"not_inclusive\": \"Дата повинна бути більшою за {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"Масив повинен містити рівно {{maximum}} елемент(ів)\",\n        \"inclusive\": \"Масив повинен містити не більше {{maximum}} елемент(ів)\",\n        \"not_inclusive\": \"Масив повинен містити менш {{maximum}} елемент(ів)\"\n      },\n      \"string\": {\n        \"exact\": \"Рядок повинен містити рівно {{maximum}} символ(ів)\",\n        \"inclusive\": \"Рядок повинен містити не більше {{maximum}} символів\",\n        \"not_inclusive\": \"Рядок повинен містити менше {{maximum}} символів\"\n      },\n      \"number\": {\n        \"exact\": \"Число має бути рівним {{maximum}}\",\n        \"inclusive\": \"Число повинно бути менше або дорівнює {{maximum}}\",\n        \"not_inclusive\": \"Кількість має бути меншою за {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"Неприпустиме введення\",\n        \"inclusive\": \"Неприпустиме введення\",\n        \"not_inclusive\": \"Неприпустиме введення\"\n      },\n      \"date\": {\n        \"exact\": \"Дата повинна бути рівно {{- maximum, datetime}}\",\n        \"inclusive\": \"Дата повинна бути меншою або дорівнює {{- maximum, datetime}}\",\n        \"not_inclusive\": \"Дата повинна бути меншою за {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"email\",\n    \"url\": \"url\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"regex\",\n    \"datetime\": \"datetime\"\n  },\n  \"types\": {\n    \"function\": \"function\",\n    \"number\": \"number\",\n    \"string\": \"string\",\n    \"nan\": \"nan\",\n    \"integer\": \"integer\",\n    \"float\": \"float\",\n    \"boolean\": \"boolean\",\n    \"date\": \"date\",\n    \"bigint\": \"bigint\",\n    \"undefined\": \"undefined\",\n    \"symbol\": \"symbol\",\n    \"null\": \"null\",\n    \"array\": \"array\",\n    \"object\": \"object\",\n    \"unknown\": \"unknown\",\n    \"promise\": \"promise\",\n    \"void\": \"void\",\n    \"never\": \"never\",\n    \"map\": \"map\",\n    \"set\": \"set\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/auth.json",
    "content": "{\n  \"page\": {\n    \"signin\": \"登录\",\n    \"signup\": \"注册\"\n  },\n  \"content\": {\n    \"title\": \"数据流动，团队成长\",\n    \"description\": \"为每个团队设计的数据库，从简单表格到企业解决方案\"\n  },\n  \"button\": {\n    \"signin\": \"登录\",\n    \"signup\": \"注册\",\n    \"resend\": \"重新发送\"\n  },\n  \"legal\": {\n    \"tip\": \"继续使用即表示您同意 Teable 的 <Terms>服务条款</Terms> 和 <Privacy>隐私政策</Privacy>，并接收更新邮件。\",\n    \"termsUrl\": \"https://teable.cn/terms-of-service-cn\",\n    \"privacyUrl\": \"https://teable.cn/privacy-cn\"\n  },\n  \"title\": {\n    \"signin\": \"登录到您的账户\",\n    \"signup\": \"创建一个账户来登录\"\n  },\n  \"label\": {\n    \"email\": \"邮箱\",\n    \"password\": \"密码\",\n    \"verificationCode\": \"验证码\"\n  },\n  \"placeholder\": {\n    \"password\": \"请输入您的密码...\",\n    \"email\": \"请输入您的电子邮件地址...\",\n    \"verificationCode\": \"请输入您的验证码...\"\n  },\n  \"socialAuth\": {\n    \"title\": \"或继续使用\",\n    \"sso\": {\n      \"title\": \"使用单点登录\",\n      \"description\": \"输入您的电子邮件地址以使用单点登录\",\n      \"error\": \"您的电子邮件域未设置单点登录。\"\n    }\n  },\n  \"signError\": {\n    \"exist\": \"电子邮件地址已注册\",\n    \"incorrect\": \"电子邮件地址或密码不正确\",\n    \"tooManyRequests\": \"您的账户已被锁定，请在 {{minutes}} 分钟后重试\",\n    \"turnstileRequired\": \"请完成验证挑战\",\n    \"turnstileError\": \"验证失败，请重试\",\n    \"turnstileExpired\": \"验证已过期，请重试\",\n    \"turnstileTimeout\": \"验证超时，请重试\"\n  },\n  \"signupError\": {\n    \"verificationCodeRequired\": \"请填写验证码\",\n    \"verificationCodeInvalid\": \"验证码无效\",\n    \"passwordLength\": \"密码长度至少为8个字符\",\n    \"passwordInvalid\": \"密码必须包含至少一个字母和一个数字\",\n    \"sendMailRateLimit\": \"请等待 {{seconds}} 秒后再请求新的验证码\"\n  },\n  \"resetPassword\": {\n    \"header\": \"设置您的密码\",\n    \"description\": \"请输入一个新密码\",\n    \"label\": \"新密码\",\n    \"error\": {\n      \"requiredPassword\": \"请输入密码\",\n      \"invalidLink\": \"无效的重置密码链接\"\n    },\n    \"success\": {\n      \"title\": \"🎉 重置密码成功\",\n      \"description\": \"您的密码已成功重置。我们将为您跳转到登录页面。\"\n    },\n    \"buttonText\": \"确认重置\"\n  },\n  \"forgetPassword\": {\n    \"trigger\": \"忘记密码?\",\n    \"header\": \"重置您的密码\",\n    \"description\": \"请输入您的电子邮件地址，我们将向您发送一个链接，用于重置您的密码。\",\n    \"errorRequiredEmail\": \"电子邮件地址是必填项\",\n    \"errorInvalidEmail\": \"无效的电子邮件地址\",\n    \"sendMailRateLimit\": \"请等待 {{seconds}} 秒后再发送新的电子邮件\",\n    \"buttonText\": \"发送重置邮件\",\n    \"success\": {\n      \"title\": \"🎉 重置密码邮件已发送\",\n      \"description\": \"我们已向您发送了一封包含重置密码链接的电子邮件。请检查您的收件箱。\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/chart.json",
    "content": "{\n  \"notBaseId\": \"缺少 baseId\",\n  \"notPositionId\": \"缺少 positionId\",\n  \"notPluginInstallId\": \"缺少 pluginInstallId\",\n  \"initBridge\": \"桥初始化中...\",\n  \"actions\": {\n    \"cancel\": \"取消\",\n    \"save\": \"保存\"\n  },\n  \"queryTitle\": \"数据查询配置\",\n  \"notSupport\": \"不支持\",\n  \"chart\": {\n    \"bar\": \"柱状图\",\n    \"line\": \"折线图\",\n    \"pie\": \"饼图\",\n    \"area\": \"区域图\",\n    \"table\": \"表格\"\n  },\n  \"form\": {\n    \"chartType\": {\n      \"placeholder\": \"选择图表类型\",\n      \"label\": \"图表类型\"\n    },\n    \"pie\": {\n      \"dimension\": \"维度\",\n      \"measure\": \"度量\",\n      \"showTotal\": \"显示总数\"\n    },\n    \"combo\": {\n      \"xAxis\": {\n        \"label\": \"X轴\",\n        \"placeholder\": \"选择X轴\"\n      },\n      \"yAxis\": {\n        \"label\": \"Y轴\",\n        \"placeholder\": \"选择Y轴\",\n        \"position\": \"Y轴位置\"\n      },\n      \"xDisplay\": {\n        \"label\": \"X轴显示\"\n      },\n      \"yDisplay\": {\n        \"label\": \"Y轴显示\"\n      },\n      \"addXAxis\": \"添加系列分解\",\n      \"addYAxis\": \"添加另一个系列\",\n      \"stack\": \"堆叠\",\n      \"position\": {\n        \"label\": \"位置\",\n        \"auto\": \"自动\",\n        \"left\": \"左侧\",\n        \"right\": \"右侧\"\n      },\n      \"goalLine\": {\n        \"label\": \"目标线\"\n      },\n      \"range\": {\n        \"label\": \"范围\",\n        \"min\": \"最小值\",\n        \"max\": \"最大值\"\n      },\n      \"lineStyle\": {\n        \"label\": \"线条样式\",\n        \"normal\": \"普通\",\n        \"linear\": \"线性\",\n        \"step\": \"阶梯\"\n      },\n      \"displayType\": \"显示类型\"\n    },\n    \"typeError\": \"表单：不支持的图表类型\",\n    \"updateQuery\": \"更新查询\",\n    \"queryError\": \"查询错误\",\n    \"querySuccess\": \"查询已配置\",\n    \"decimal\": \"小数\",\n    \"prefix\": \"前缀\",\n    \"suffix\": \"后缀\",\n    \"showLabel\": \"在图表上显示数值标签\",\n    \"showLegend\": \"显示图例\",\n    \"value\": \"数值\",\n    \"label\": \"标签\",\n    \"padding\": {\n      \"label\": \"内边距\",\n      \"top\": \"上\",\n      \"right\": \"右\",\n      \"bottom\": \"下\",\n      \"left\": \"左\"\n    },\n    \"tableConfig\": \"表格配置\",\n    \"width\": \"宽度\"\n  },\n  \"reloadQuery\": \"重新加载查询\",\n  \"noStorage\": \"请先配置图表插件\",\n  \"noPermission\": \"无权限访问\",\n  \"goConfig\": \"前往配置\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/common.json",
    "content": "{\n  \"actions\": {\n    \"add\": \"添加\",\n    \"title\": \"操作\",\n    \"save\": \"保存\",\n    \"doNotSave\": \"不保存\",\n    \"submit\": \"提交\",\n    \"confirm\": \"确认\",\n    \"continue\": \"继续\",\n    \"close\": \"关闭\",\n    \"edit\": \"编辑\",\n    \"fill\": \"填写\",\n    \"update\": \"更新\",\n    \"create\": \"创建\",\n    \"delete\": \"删除\",\n    \"cancel\": \"取消\",\n    \"zoomIn\": \"放大\",\n    \"zoomOut\": \"缩小\",\n    \"back\": \"返回\",\n    \"download\": \"下载\",\n    \"remove\": \"移除\",\n    \"removeConfig\": \"清空配置\",\n    \"retry\": \"重试\",\n    \"saveSucceed\": \"保存成功！\",\n    \"submitSucceed\": \"提交成功！\",\n    \"editSucceed\": \"编辑成功！\",\n    \"updateSucceed\": \"更新成功！\",\n    \"deleteSucceed\": \"删除成功！\",\n    \"resetSucceed\": \"清空成功！\",\n    \"restoreSucceed\": \"恢复成功！\",\n    \"loading\": \"加载中...\",\n    \"refreshPage\": \"刷新页面\",\n    \"yesDelete\": \"是，删除\",\n    \"rename\": \"重命名\",\n    \"duplicate\": \"复制\",\n    \"export\": \"导出\",\n    \"import\": \"导入\",\n    \"change\": \"变更\",\n    \"upgrade\": \"升级\",\n    \"upgradeToLevel\": \"升级到{{level}}\",\n    \"search\": \"搜索\",\n    \"loadMore\": \"加载更多\",\n    \"collapseSidebar\": \"折叠侧边栏\",\n    \"restore\": \"恢复\",\n    \"permanentDelete\": \"永久删除\",\n    \"globalSearch\": \"全局搜索\",\n    \"fieldSearch\": \"字段搜索\",\n    \"tableIndex\": \"索引\",\n    \"showAllRow\": \"显示全部\",\n    \"hideNotMatchRow\": \"仅显示匹配行\",\n    \"more\": \"更多\",\n    \"expand\": \"展开\",\n    \"view\": \"查看\",\n    \"preview\": \"预览\",\n    \"viewAndEdit\": \"查看和编辑\",\n    \"deleteTip\": \"确定要删除 \\\"{{name}}\\\" 吗？\",\n    \"move\": \"移动至\",\n    \"turnOn\": \"开启\",\n    \"exit\": \"退出\",\n    \"next\": \"下一页\",\n    \"previous\": \"上一页\",\n    \"select\": \"选择\",\n    \"refresh\": \"刷新\",\n    \"login\": \"登录\",\n    \"useTemplate\": \"使用模版\",\n    \"copyToMySpace\": \"复制到我的空间\",\n    \"saveToMySpace\": \"保存到我的空间\",\n    \"supportSaveCopy\": \"支持保存副本\",\n    \"copyLink\": \"复制链接\",\n    \"backToSpace\": \"返回空间\",\n    \"switchBase\": \"切换数据库\",\n    \"collapse\": \"收起\",\n    \"viewDetails\": \"查看详情\",\n    \"getMore\": \"获取更多\",\n    \"copySuccess\": \"复制成功\"\n  },\n  \"quickAction\": {\n    \"title\": \"快捷搜索\",\n    \"placeHolder\": \"输入命令或进行搜索...\"\n  },\n  \"password\": {\n    \"setInvalid\": \"设置的密码无效，至少需要8个字符，且必须包含至少一个字母和一个数字。\"\n  },\n  \"non\": {\n    \"share\": \"分享\",\n    \"copy\": \"已复制\"\n  },\n  \"template\": {\n    \"aiTitle\": \"让我们一起构建\",\n    \"aiGreeting\": \"{{name}}，有什么可以帮您？\",\n    \"aiSubTitle\": \"首个让团队在数据协作并生成生产级应用的 AI 平台\",\n    \"guideTitle\": \"从你的场景开始\",\n    \"watchVideo\": \"观看视频\",\n    \"title\": \"模板中心\",\n    \"description\": \"快速从模板创建一个新数据库\",\n    \"browseAll\": \"浏览全部\",\n    \"templateTitle\": \"从模板开始\",\n    \"loadMore\": \"加载更多\",\n    \"allTemplatesLoaded\": \"所有模板已加载\",\n    \"createTemplate\": \"创建模版\",\n    \"promptBox\": {\n      \"placeholder\": \"使用 Teable 构建你的业务应用\",\n      \"start\": \"开始\",\n      \"carouselGuides\": {\n        \"guide1\": \"粘贴收据 → 让 Teable 自动提取关键数据\",\n        \"guide2\": \"创建 AI CRM，包含线索和跟进表\",\n        \"guide3\": \"粘贴客户反馈 → 让 Teable 发现洞察并生成报告\",\n        \"guide4\": \"创建项目跟踪器，包含任务和截止日期\",\n        \"guide5\": \"粘贴线索表格 → 让 Teable 构建智能 CRM\",\n        \"guide6\": \"创建内容计划器，包含帖子和发布日期\",\n        \"guide7\": \"粘贴简历 → 让 Teable 整理和筛选候选人\"\n      }\n    },\n    \"useTemplateDialog\": {\n      \"title\": \"选择空间\",\n      \"description\": \"请选择一个空间来应用模版\",\n      \"noSpaceDescription\": \"你还没有任何空间，请先创建一个空间以继续。\",\n      \"newSpacePlaceholder\": \"空间名称\",\n      \"createSpace\": \"创建\"\n    },\n    \"non\": {\n      \"share\": \"分享\",\n      \"copy\": \"已复制\"\n    }\n  },\n  \"share\": {\n    \"copyToSpaceDialog\": {\n      \"title\": \"复制到空间\",\n      \"description\": \"请选择一个空间来复制此数据库\",\n      \"baseName\": \"数据库名称\",\n      \"baseNamePlaceholder\": \"请输入数据库名称\",\n      \"selectSpace\": \"选择空间\",\n      \"noSpaceDescription\": \"暂无可用管理的空间，请先创建新空间以继续。\",\n      \"newSpacePlaceholder\": \"空间名称\",\n      \"createSpace\": \"创建\",\n      \"copyTarget\": \"复制到\",\n      \"createNewBase\": \"新数据库\",\n      \"copyToExistingBase\": \"已有数据库\",\n      \"selectBase\": \"选择数据库\",\n      \"selectBasePlaceholder\": \"选择一个数据库\",\n      \"noBaseInSpace\": \"该空间下没有可管理的数据库，请选择「新数据库」。\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"实例设置\",\n    \"personal\": {\n      \"title\": \"个人设置\"\n    },\n    \"templateAdmin\": {\n      \"title\": \"模板管理\",\n      \"noData\": \"暂无数据\",\n      \"importing\": \"导入中...\",\n      \"usageCount\": \"应用次数：{{count}}\",\n      \"useTemplate\": \"使用此模板\",\n      \"createdBy\": \"创建者：{{user}}\",\n      \"backToTemplateList\": \"返回模板列表\",\n      \"tips\": {\n        \"errorCategoryName\": \"分类不存在或已删除\",\n        \"needSnapshot\": \"上架前，请先创建快照，并且模板名和描述不能为空\",\n        \"needPublish\": \"请先上架模板，再设置推荐\",\n        \"needBaseSource\": \"创建快照前，请先选择模板来源\",\n        \"forbiddenUpdateSystemTemplate\": \"系统模板禁止修改\",\n        \"addCategoryTips\": \"请先在搜索框中输入分类名称。\",\n        \"categoryNamePlaceholder\": \"输入分类名称\",\n        \"duplicateCategoryName\": \"分类名称已存在\"\n      },\n      \"category\": {\n        \"menu\": {\n          \"getStarted\": \"开始\",\n          \"recommended\": \"推荐\",\n          \"all\": \"全部\",\n          \"browseByCategory\": \"按分类浏览\"\n        }\n      },\n      \"header\": {\n        \"cover\": \"封面\",\n        \"name\": \"名称\",\n        \"description\": \"描述\",\n        \"markdownDescription\": \"富文本描述\",\n        \"category\": \"分类\",\n        \"isSystem\": \"系统模板\",\n        \"source\": \"来源\",\n        \"status\": \"上架\",\n        \"publishSnapshot\": \"发布快照\",\n        \"snapshotTime\": \"快照时间\",\n        \"actions\": \"操作\",\n        \"featured\": \"推荐\",\n        \"createdBy\": \"创建者\",\n        \"userNonExistent\": \"用户不存在\",\n        \"preview\": \"预览\",\n        \"usage\": \"应用次数\",\n        \"visit\": \"访问次数\"\n      },\n      \"actions\": {\n        \"title\": \"操作\",\n        \"publish\": \"发布\",\n        \"delete\": \"删除\",\n        \"duplicate\": \"复制\",\n        \"preview\": \"预览\",\n        \"use\": \"使用\",\n        \"pinTop\": \"置顶\",\n        \"addCategory\": \"添加分类\",\n        \"selectCategory\": \"选择分类\",\n        \"viewTemplate\": \"查看模板\",\n        \"manageCategory\": \"分类管理\"\n      },\n      \"relatedTemplates\": \"相关模板\",\n      \"noImage\": \"暂无图片\",\n      \"baseSelectPanel\": {\n        \"title\": \"选择模板来源\",\n        \"description\": \"选择一个数据库作为模板\",\n        \"confirm\": \"确认\",\n        \"search\": \"搜索...\",\n        \"cancel\": \"取消\",\n        \"selectBase\": \"选择数据库\",\n        \"createTemplate\": \"新建模板\",\n        \"abnormalBase\": \"数据库不存在或已删除\"\n      }\n    },\n    \"back\": \"返回主页\",\n    \"account\": {\n      \"title\": \"我的档案\",\n      \"tab\": \"我的帐户\",\n      \"updatePhoto\": \"更新照片\",\n      \"updateNameDesc\": \"您的昵称或名字，即您希望在 Teable 中显示的称呼\",\n      \"securityTitle\": \"帐户安全\",\n      \"email\": \"电子邮件\",\n      \"password\": \"密码\",\n      \"passwordDesc\": \"设置一个永久密码以登录您的帐户。\",\n      \"changePassword\": {\n        \"title\": \"修改密码\",\n        \"desc\": \"请输入当前密码并设置新密码\",\n        \"current\": \"当前密码\",\n        \"new\": \"新密码\",\n        \"confirm\": \"确认新密码\"\n      },\n      \"changePasswordError\": {\n        \"disMatch\": \"您的新密码不匹配。\",\n        \"equal\": \"您的新密码必须与当前密码不同。\",\n        \"invalid\": \"您当前的密码无效。\"\n      },\n      \"changePasswordSuccess\": {\n        \"title\": \"🎉 密码更改成功。\",\n        \"desc\": \"您将在2秒内重定向到登录页面。\"\n      },\n      \"manageToken\": \"开发者令牌\",\n      \"addPassword\": {\n        \"title\": \"添加密码\",\n        \"desc\": \"设置一个永久密码以登录您的账户。\",\n        \"password\": \"输入您的密码\",\n        \"confirm\": \"确认您的密码\"\n      },\n      \"addPasswordError\": {\n        \"disMatch\": \"您的密码不匹配。\"\n      },\n      \"addPasswordSuccess\": {\n        \"title\": \"🎉 密码添加成功。\"\n      },\n      \"changeEmail\": {\n        \"title\": \"修改邮箱地址\",\n        \"desc\": \"请验证您的密码并确认新邮箱地址\",\n        \"current\": \"当前密码\",\n        \"new\": \"新邮箱\",\n        \"code\": \"验证码\",\n        \"getCode\": \"发送验证码\",\n        \"error\": {\n          \"invalidCode\": \"验证码无效\",\n          \"invalidPassword\": \"密码无效\",\n          \"invalidConflict\": \"该新邮箱已被注册\",\n          \"invalidSameEmail\": \"新邮箱与当前邮箱相同\",\n          \"sendMailRateLimit\": \"请等待 {{seconds}} 秒后重新发送邮件\"\n        },\n        \"success\": {\n          \"title\": \"🎉 邮箱修改成功\",\n          \"desc\": \"您将在 2 秒后返回登录页面\",\n          \"sendSuccess\": \"验证码发送成功\"\n        }\n      },\n      \"deleteAccount\": {\n        \"title\": \"删除账户\",\n        \"desc\": \"以下操作是不可逆的，请谨慎操作\",\n        \"error\": {\n          \"title\": \"无法删除账户\",\n          \"desc\": \"您需要先处理以下依赖资源：\",\n          \"spacesError\": \"在删除账户之前，您需要退出您的空间（或删除然后去回收站彻底删除）。\"\n        },\n        \"confirm\": {\n          \"title\": \"请输入 <code>DELETE</code> 来确认\",\n          \"placeholder\": \"DELETE\"\n        },\n        \"loading\": \"删除中...\"\n      }\n    },\n    \"notify\": {\n      \"title\": \"我的通知\",\n      \"label\": \"您工作区的活动\",\n      \"desc\": \"在收到评论、提及、页面邀请、提醒、访问请求和属性更改时接收电子邮件通知。\"\n    },\n    \"setting\": {\n      \"title\": \"我的设置\",\n      \"theme\": \"主题\",\n      \"themeDesc\": \"选择应用主题。\",\n      \"dark\": \"深色\",\n      \"light\": \"浅色\",\n      \"version\": \"应用版本\",\n      \"system\": \"系统\",\n      \"language\": \"语言\",\n      \"interactionMode\": \"交互模式\",\n      \"mouseMode\": \"光标模式\",\n      \"touchMode\": \"触控模式\",\n      \"systemMode\": \"跟随系统\",\n      \"buySelfHostedLicense\": \"购买私有化部署许可证\"\n    },\n    \"nav\": {\n      \"settings\": \"设置\",\n      \"logout\": \"退出登录\",\n      \"contactSupport\": \"联系支持\"\n    },\n    \"integration\": {\n      \"title\": \"集成\",\n      \"thirdPartyIntegrations\": {\n        \"title\": \"第三方集成\",\n        \"description\": \"您已授权 {{count}} 个应用程序访问您的帐户。\",\n        \"lastUsed\": \"上次使用于 {{date}}\",\n        \"revoke\": \"撤销\",\n        \"owner\": \"{{user}} 所有\",\n        \"revokeTitle\": \"您确定要撤销授权吗？\",\n        \"revokeDesc\": \"{{name}} 将不再能够访问 Teable API。此操作无法撤销。\",\n        \"scopeTitle\": \"权限\",\n        \"scopeDesc\": \"此应用程序将能够获取以下权限：\"\n      },\n      \"userIntegration\": {\n        \"title\": \"已连接账户\",\n        \"description\": \"连接外部账户以授权 Teable 访问和同步您的资源。\",\n        \"emptyDescription\": \"没有已连接的账户\",\n        \"actions\": {\n          \"reconnect\": \"重新连接\"\n        },\n        \"slack\": {\n          \"user\": \"Slack 用户\",\n          \"workspace\": \"Slack 工作区\"\n        },\n        \"email\": {\n          \"user\": \"用户\",\n          \"email\": \"邮箱\"\n        },\n        \"deleteTitle\": \"移除已连接账户\",\n        \"deleteDesc\": \"您确定要移除 {{name}} 吗？\",\n        \"create\": \"连接新账户\",\n        \"manage\": \"管理已连接账户\",\n        \"searchPlaceholder\": \"搜索已连接账户\",\n        \"defaultName\": \"{{name}} 集成\",\n        \"callback\": {\n          \"error\": \"授权失败\",\n          \"title\": \"授权成功\",\n          \"desc\": \"您可以关闭此窗口了。\"\n        }\n      }\n    }\n  },\n  \"noun\": {\n    \"table\": \"表格\",\n    \"view\": \"视图\",\n    \"space\": \"空间\",\n    \"base\": \"数据库\",\n    \"field\": \"字段\",\n    \"record\": \"记录\",\n    \"dashboard\": \"仪表盘\",\n    \"automation\": \"自动化\",\n    \"authorityMatrix\": \"权限矩阵\",\n    \"design\": \"设计\",\n    \"adminPanel\": \"系统管理\",\n    \"license\": \"私有化部署许可证\",\n    \"instanceId\": \"实例 ID\",\n    \"beta\": \"测试版\",\n    \"trash\": \"回收站\",\n    \"global\": \"全局\",\n    \"organizationPanel\": \"组织设置\",\n    \"unknownError\": \"未知错误\",\n    \"pluginPanel\": \"面板\",\n    \"pluginContextMenu\": \"菜单\",\n    \"plugin\": \"插件\",\n    \"copy\": \"副本\",\n    \"credits\": \"算力\",\n    \"aiChat\": \"AI 对话\",\n    \"app\": \"应用\",\n    \"folder\": \"文件夹\",\n    \"newAutomation\": \"新自动化\",\n    \"newApp\": \"新应用\",\n    \"newFolder\": \"新文件夹\",\n    \"template\": \"模版\"\n  },\n  \"level\": {\n    \"free\": \"免费版\",\n    \"plus\": \"高级版\",\n    \"pro\": \"专业版\",\n    \"business\": \"商业版\",\n    \"enterprise\": \"企业版\"\n  },\n  \"noResult\": \"无结果\",\n  \"allNodes\": \"全部节点\",\n  \"noDescription\": \"暂无描述\",\n  \"untitled\": \"未命名\",\n  \"name\": \"名称\",\n  \"description\": \"描述\",\n  \"required\": \"必填\",\n  \"characters\": \"字符\",\n  \"atLeastOne\": \"至少保留一个{{noun}}\",\n  \"guide\": {\n    \"prev\": \"上一步\",\n    \"next\": \"下一步\",\n    \"done\": \"完成\",\n    \"skip\": \"跳过\",\n    \"createSpaceTooltipTitle\": \"创建空间\",\n    \"createSpaceTooltipContent\": \"组织内部可以创建多个空间，空间可以让拥有共同目标、同类职能或利益相关者在同一个环境内进行协作。\",\n    \"createBaseTooltipTitle\": \"创建数据库\",\n    \"createBaseTooltipContent\": \"数据库是存储重要数据的地方，所有的工作流程也依赖于它。\",\n    \"createTableTooltipTitle\": \"创建表格\",\n    \"createTableTooltipContent\": \"表格是一种结构化数据存储方式，用于存储应用内相关数据的集合。\",\n    \"createViewTooltipTitle\": \"创建视图\",\n    \"createViewTooltipContent\": \"目前，用户可以创建表格、画册、看板和表单视图，未来的版本计划还包括日历视图。<br></br>这种多样性为用户提供了一个全面的工具，用于处理各种数据管理任务。\",\n    \"viewFilteringTooltipTitle\": \"记录筛选\",\n    \"viewFilteringTooltipContent\": \"视图的核心功能之一是能够根据您设置的条件从视图中过滤掉记录。<br></br>当根据条件过滤掉记录时，它不会被删除，它只是在您用于查看表的特定视图中隐藏。\",\n    \"viewSortingTooltipTitle\": \"记录排序\",\n    \"viewSortingTooltipContent\": \"在视图中时，您可以对记录进行排序，以便它们根据特定字段中的值以特定顺序显示。<br></br>对一个视图中的记录进行排序不会影响其他视图中的记录顺序，它仅适用于您当前用于查看表格的视图。\",\n    \"viewGroupingTooltipTitle\": \"记录分组\",\n    \"viewGroupingTooltipContent\": \"记录分组允许创建者构建一组一个或多个条件，这将有助于对特定视图中呈现的数据集进行分类。\",\n    \"apiButtonTooltipTitle\": \"API\",\n    \"apiButtonTooltipContent\": \"Teable 提供了强大的 API，支持几乎所有的产品功能，允许开发者使用<a>令牌</a>进行调用。\"\n  },\n  \"token\": \"令牌\",\n  \"poweredBy\": \"<0></0> 提供技术支持\",\n  \"invite\": {\n    \"dialog\": {\n      \"title\": \"{{spaceName}} 空间分享\",\n      \"desc\": \"这个空间有<b>{{count}} 个协作者</b>。添加一个空间协作者将使他们能够访问该空间内的所有数据库。\",\n      \"tabEmail\": \"通过电子邮件邀请\",\n      \"emailPlaceholder\": \"输入邮箱地址，按 Enter 键分隔邮箱\",\n      \"tabLink\": \"通过链接邀请\",\n      \"linkPlaceholder\": \"创建一个邀请链接，让任何打开它的人获得<0/>权限。\",\n      \"emailSend\": \"发送邀请\",\n      \"linkSend\": \"创建链接\",\n      \"spaceTitle\": \"空间协作者\",\n      \"spaceTitleWithCount\": \"空间协作者（{{count}}）\",\n      \"baseTitle\": \"数据库协作者\",\n      \"allCollaboratorsTitle\": \"所有协作者（空间和数据库）\",\n      \"baseOnly\": \"仅数据库\",\n      \"collaboratorSearchPlaceholder\": \"按姓名或电子邮件查找空间协作者\",\n      \"collaboratorJoin\": \"加入于 {{joinTime}}\",\n      \"collaboratorRemove\": \"移除协作者\",\n      \"linkTitle\": \"邀请链接\",\n      \"linkCreatedTime\": \"创建于 {{createdTime}}\",\n      \"linkCopySuccess\": \"链接已复制\",\n      \"linkRemove\": \"移除链接\",\n      \"noInviteLinks\": \"没有邀请链接\",\n      \"linkDescription\": \"创建一个邀请链接并分配权限\",\n      \"haveAccess\": \"有权访问\"\n    },\n    \"base\": {\n      \"title\": \"分享{{baseName}}\",\n      \"desc_one\": \"此数据库与 {{count}} 位协作者共享。\",\n      \"desc_other\": \"此数据库与 {{count}} 位协作者共享。\",\n      \"baseTitle\": \"此数据库协作者\",\n      \"collaboratorSearchPlaceholder\": \"通过姓名或电子邮件查找数据库协作者\",\n      \"baseTitleWithCount\": \"此数据库协作者（{{count}}）\"\n    },\n    \"addOrgCollaborator\": {\n      \"title\": \"添加组织协作者\",\n      \"placeholder\": \"选择组织成员或部门\"\n    },\n    \"sendInvitationSuccess\": \"邀请已发送\",\n    \"table\": {\n      \"collaborator\": \"协作者\",\n      \"accessPermission\": \"访问权限\",\n      \"joinAt\": \"加入于\"\n    },\n    \"authority\": {\n      \"title\": \"权限矩阵已启用\",\n      \"description\": \"协作者的有效访问权限受分配给他们的角色的影响，未分配用户使用默认角色\",\n      \"viewDetail\": \"查看详情\"\n    }\n  },\n  \"help\": {\n    \"title\": \"帮助\",\n    \"appLink\": \"https://app.teable.cn\",\n    \"mainLink\": \"https://help.teable.cn/zh\",\n    \"apiLink\": \"https://help.teable.cn/zh/api-doc\"\n  },\n  \"pagePermissionChangeTip\": \"页面权限已更新，请刷新。\",\n  \"listEmptyTips\": \"列表为空\",\n  \"billing\": {\n    \"overLimits\": \"超出限制\",\n    \"overLimitsDescription\": \"用量已超出了当前订阅版本的限制，您可以升级到更高级的版本以解锁更多功能用量。\",\n    \"userLimitExceededDescription\": \"当前实例已达到许可证允许的最大用户数限制，请停用超限的用户或升级许可证以继续使用。\",\n    \"unavailableInPlanTips\": \"当前订阅计划不支持此功能\",\n    \"unavailableConnectionTips\": \"数据链接功能即将移除，未来仅在公有云企业版和自托管版本中提供。\",\n    \"levelTips\": \"当前空间使用的是{{level}}计划\",\n    \"enterpriseFeature\": \"企业版功能\",\n    \"automationRequiresUpgrade\": \"升级到企业版以启用自动化\",\n    \"authorityMatrixRequiresUpgrade\": \"升级到企业版以启用权限矩阵\",\n    \"viewPricing\": \"查看定价\",\n    \"billable\": \"计费\",\n    \"billableByAuthorityMatrix\": \"由权限矩阵产生的计费\",\n    \"licenseExpiredGracePeriod\": \"您的私有化部署许可证已过期，系统将在 {{expiredTime}} 降级为免费版并停用高级功能，请尽快更新许可证以保留完整功能。\",\n    \"spaceSubscriptionModal\": {\n      \"title\": \"升级空间站的订阅计划\",\n      \"description\": \"您只能升级具有「可管理」权限的空间站\"\n    },\n    \"contactAdminToUpgrade\": \"请联系空间站所有者升级订阅计划。\",\n    \"status\": {\n      \"active\": \"已激活\",\n      \"canceled\": \"已取消\",\n      \"incomplete\": \"交易未完成\",\n      \"incompleteExpired\": \"交易已过期\",\n      \"trialing\": \"试用中\",\n      \"pastDue\": \"逾期\",\n      \"unpaid\": \"未支付\",\n      \"paused\": \"已暂停\",\n      \"seatLimitExceeded\": \"用户数超限制\"\n    }\n  },\n  \"admin\": {\n    \"setting\": {\n      \"instanceTitle\": \"实例设置\",\n      \"description\": \"更改当前实例的设置\",\n      \"allowSignUp\": \"允许新用户注册\",\n      \"allowSignUpDescription\": \"关闭此选项将禁止新用户注册，登录页面将不再显示注册按钮。\",\n      \"allowSpaceInvitation\": \"允许发送空间邀请\",\n      \"allowSpaceInvitationDescription\": \"关闭此选项将禁止除管理员以外的用户邀请他人加入空间。开启此选项后，通过邮箱邀请的新用户可以点击邮件内邀请链接完成注册，但通过创建邀请链接的方式无效。\",\n      \"allowSpaceCreation\": \"允许所有人创建新的空间\",\n      \"allowSpaceCreationDescription\": \"关闭此选项将禁止除管理员以外的用户创建新的空间。\",\n      \"enableEmailVerification\": \"启用邮箱验证\",\n      \"enableEmailVerificationDescription\": \"启用此选项将要求用户在创建新账户时验证邮箱地址。\",\n      \"enableWaitlist\": \"启用等待列表\",\n      \"enableWaitlistDescription\": \"启用此选项后，用户需要邀请码才能注册。\",\n      \"generalSettings\": \"通用配置\",\n      \"aiSettings\": \"AI 配置\",\n      \"brandingSettings\": {\n        \"title\": \"品牌配置\",\n        \"description\": \"仅在企业版中可用\",\n        \"logo\": \"Logo\",\n        \"logoDescription\": \"Logo 是您在 Teable 中的品牌标识。\",\n        \"logoUpload\": \"上传 Logo\",\n        \"logoUploadDescription\": \"上传 Logo 图片，支持 PNG、JPEG 格式，推荐尺寸为 100x100px。最大上传大小为 500KB.\"\n      },\n      \"ai\": {\n        \"name\": \"名称\",\n        \"nameDescription\": \"名称可自定义，用于区分不同的模型提供商\",\n        \"customModel\": \"自定义模型\",\n        \"customModelDescription\": \"配置的自定义模型可以用在 AI 字段、AI 自动化以及 AI 聊天界面，生图模型需要勾选后进行测试\",\n        \"updateLLMProvider\": \"更新模型提供商\",\n        \"addProvider\": \"添加模型提供商\",\n        \"addProviderDescription\": \"添加一个新的模型提供商到列表\",\n        \"providerType\": \"提供商类型\",\n        \"baseUrl\": \"基础 URL\",\n        \"apiKey\": \"API 密钥\",\n        \"baseUrlDescription\": \"模型提供商的基础 URL\",\n        \"apiKeyDescription\": \"模型提供商的 API 密钥\",\n        \"models\": \"模型\",\n        \"modelsDescription\": \"模型提供商支持的模型\",\n        \"baseUrlRequired\": \"基础 URL 是必需的\",\n        \"fetchModelListError\": \"无法获取模型列表\",\n        \"provider\": \"模型提供商\",\n        \"aiAbilitySettings\": \"AI 能力设置\",\n        \"aiAbilitySettingsDescription\": \"配置 AI 能力\",\n        \"modelPreferences\": \"模型偏好\",\n        \"modelPreferencesDescription\": \"模型提供商的模型偏好\",\n        \"embeddingModel\": \"嵌入模型\",\n        \"embeddingModelDescription\": \"可选。用于文档问答。通常模型 ID 包含 \\\"embedding\\\"。\",\n        \"translationModel\": \"翻译模型\",\n        \"translationModelDescription\": \"要使用的翻译模型\",\n        \"chatModel\": \"对话模型\",\n        \"chatModelDescription\": \"要使用的对话模型，提示：工具调用是 Teable AI 功能所必需的\",\n        \"chatModels\": {\n          \"lg\": \"高级对话模型\",\n          \"lgDescription\": \"用于规划、编码和其他复杂任务场景。推荐使用 claude-sonnet-4.5\"\n        },\n        \"actions\": {\n          \"title\": \"AI 能力\",\n          \"aiField\": {\n            \"title\": \"AI 字段\",\n            \"description\": \"AI 字段功能，包括自动填充和字段设置中的 AI 配置\"\n          },\n          \"aiChat\": {\n            \"title\": \"AI 聊天\",\n            \"description\": \"AI 聊天侧边栏及所有 Agent 功能\"\n          }\n        },\n        \"chatModelTest\": {\n          \"text\": \"测试模型\",\n          \"description\": \"测试模型处理图片、PDF 的能力\",\n          \"notConfigLgModel\": \"请先配置高级对话模型\",\n          \"confirmTitle\": \"测试模型能力\",\n          \"confirmDescription\": \"是否测试高级对话模型的能力？\",\n          \"confirm\": \"测试模型\",\n          \"cancel\": \"取消\",\n          \"missingCapabilitiesWarning\": \"此模型不支持图片或PDF处理，可能会导致部分功能不可用。\",\n          \"modelNotSuitable\": \"此模型可能不适合高级 AI 功能：\",\n          \"enableAITitle\": \"启用自定义模型\",\n          \"enableAIDescription\": \"模型测试已完成。自定义模型功能当前未启用。是否启用自定义模型来使用这些模型能力？\",\n          \"enableAI\": \"启用自定义模型\",\n          \"skipTest\": \"跳过\"\n        },\n        \"chatModelAbility\": {\n          \"image\": \"图片\",\n          \"pdf\": \"PDF\",\n          \"webSearch\": \"网络搜索\",\n          \"toolCall\": \"工具调用\",\n          \"reasoning\": \"深度推理\",\n          \"imageGeneration\": \"图像生成\",\n          \"disabledWebSearch\": \"禁用网络搜索\",\n          \"lgModelAbility\": \"模型能力\",\n          \"missingVision\": \"不支持图片或 PDF 处理\",\n          \"missingToolCall\": \"不支持工具/函数调用\",\n          \"notTested\": \"模型能力尚未测试，请先在提供商设置中测试该模型\",\n          \"supportedFormats\": \"支持的格式\"\n        },\n        \"imageModelAbility\": {\n          \"generation\": \"文生图\",\n          \"imageToImage\": \"图生图\"\n        },\n        \"configUpdated\": \"AI 配置已更新\",\n        \"noModelFound\": \"未找到模型\",\n        \"searchModel\": \"搜索模型...\",\n        \"selectModel\": \"选择模型...\",\n        \"moreModels\": \"更多模型\",\n        \"noModelsAvailable\": \"暂无可用模型\",\n        \"input\": \"输入 {{ratio}}\",\n        \"output\": \"输出 {{ratio}}\",\n        \"inputOrOutputTip\": \"比例表示算力与 Token 的兑换关系，如 “1:1000” 表示 1 算力 ≈ 1000 Token。<br></br>注意：每次使用至少扣 1 算力，即使实际不足 1 个算力\",\n        \"imageOutput\": \"每张 {{credits}}\",\n        \"imageOutputTip\": \"图片生成需要消耗算力，每张图片消耗 {{credits}} 个算力\",\n        \"supportImageOutputTip\": \"该模型支持图片生成\",\n        \"supportVisionTip\": \"该模型支持图片输入\",\n        \"supportAudioTip\": \"该模型支持音频输入\",\n        \"supportVideoTip\": \"该模型支持视频输入\",\n        \"supportDeepThinkTip\": \"该模型支持深度思考\",\n        \"testConnection\": \"测试\",\n        \"testing\": \"测试中...\",\n        \"testSuccess\": \"测试成功\",\n        \"testCompleteWithCount\": \"测试完成：{{success}}/{{total}} 个模型通过\",\n        \"allTestsFailed\": \"所有模型测试失败\",\n        \"testFailed\": \"测试失败\",\n        \"batchTest\": \"测试模型能力\",\n        \"test\": \"测试\",\n        \"testProvider\": \"测试\",\n        \"testProviderTooltip\": \"测试此提供商下的 {{count}} 个模型\",\n        \"batchTesting\": \"正在测试所有模型...\",\n        \"batchTestComplete\": \"批量测试完成\",\n        \"batchTestResults\": \"批量测试结果\",\n        \"batchTestResultsSummary\": \"已测试 {{tested}}/{{total}} 个模型：{{success}} 个通过，{{failed}} 个失败\",\n        \"batchTestNoModels\": \"没有配置可测试的模型\",\n        \"modelStatus\": \"模型状态\",\n        \"imageSupport\": \"图片支持\",\n        \"basicGeneration\": \"基础生成\",\n        \"supported\": \"支持\",\n        \"notSupported\": \"不支持\",\n        \"partialSupport\": \"部分支持\",\n        \"urlSupport\": \"URL\",\n        \"base64Support\": \"Base64\",\n        \"closeResults\": \"关闭\",\n        \"retryFailed\": \"重试失败项\",\n        \"stopTest\": \"停止\",\n        \"pending\": \"等待中\",\n        \"configuredModels\": \"已配置的模型\",\n        \"fillRequiredFields\": \"请填写所有必填字段\",\n        \"modelsRequired\": \"请填写至少一个模型\",\n        \"noValidModel\": \"未找到有效模型\",\n        \"addCustomModel\": \"添加自定义模型\",\n        \"isOpenRouter\": \"OpenRouter\",\n        \"modelRates\": \"模型费率\",\n        \"model\": \"模型\",\n        \"inputRate\": \"输入\",\n        \"outputRate\": \"输出\",\n        \"inputRateTip\": \"每 100 万输入 tokens 的积分\",\n        \"outputRateTip\": \"每 100 万输出 tokens 的积分\",\n        \"rateExplanationTitle\": \"费率说明 (1 美元 = 100 积分)\",\n        \"rateExplanationFormula\": \"积分 = Tokens × 费率 ÷ 1,000,000\",\n        \"rateExplanationExample\": \"例如：10,000 tokens，费率 300 = 10000 × 300 ÷ 1M = 3 积分\",\n        \"ratesDescription\": \"每 100 万 tokens 的费率（点击「获取定价」可从 OpenRouter 自动填充）\",\n        \"advancedRates\": \"高级费率（缓存、推理、图片）\",\n        \"advancedRatesDescription\": \"留空自动计算。缓存读取 ≈ 输入的 10%，缓存写入 ≈ 输入的 125%，推理 = 输出费率\",\n        \"cacheRead\": \"缓存↓\",\n        \"cacheWrite\": \"缓存↑\",\n        \"reasoning\": \"推理\",\n        \"perImage\": \"图片\",\n        \"cacheReadRateTip\": \"缓存命中的输入 token 费率（通常是输入费率的 10-50%，或免费为 0）\",\n        \"cacheWriteRateTip\": \"缓存写入的 token 费率（通常与输入相同或多 25%）\",\n        \"reasoningRateTip\": \"推理 token 费率，如 o1 的思考（通常与输出费率相同）\",\n        \"imageRateTip\": \"每张生成图片的积分\",\n        \"imageModel\": \"生图模型\",\n        \"imageGeneration\": \"图片生成\",\n        \"imageToImage\": \"图生图\",\n        \"clickToToggleImageModel\": \"💡 点击标记为生图模型\",\n        \"markAsImageModel\": \"标记为生图模型\",\n        \"imageGenerationModel\": \"生图模型\",\n        \"markedAsImageModel\": \"已标记为生图模型，将测试文生图和图生图能力\",\n        \"markedAsTextModel\": \"已标记为文本模型，将测试图片输入（视觉）能力\",\n        \"fetchPricing\": \"获取定价\",\n        \"fetchPricingTip\": \"从 OpenRouter API 获取模型定价并自动填充费率\",\n        \"fetchPricingError\": \"获取定价失败\",\n        \"pricingPreview\": \"定价预览\",\n        \"pricingPreviewDesc\": \"已匹配 {{total}} 个模型中的 {{matched}} 个\",\n        \"openRouterId\": \"OpenRouter ID\",\n        \"notFound\": \"未找到\",\n        \"applyPricing\": \"应用 ({{count}})\",\n        \"pricingApplied\": \"定价已应用\",\n        \"pricingAppliedCount\": \"已更新 {{count}} 个模型的定价\",\n        \"hint\": {\n          \"title\": \"建议\",\n          \"missingV1Suffix\": \"Base URL 可能缺少 \\\"/v1\\\" 后缀。大多数 OpenAI 兼容的 API 需要以 \\\"/v1\\\" 结尾的 URL（例如：https://api.openai.com/v1）\",\n          \"removeTrailingSlash\": \"尝试移除 Base URL 末尾的斜杠\",\n          \"checkApiKey\": \"请确认您的 API Key 正确且未过期\",\n          \"azureDeployment\": \"对于 Azure，请确保在 Base URL 中配置了正确的资源名称和部署名称\",\n          \"checkQuotaOrPermission\": \"您的 API Key 可能权限不足或配额已用完，请检查账户状态\",\n          \"checkModelName\": \"模型名称可能不正确，请确认模型名称与您的服务商支持的模型一致\",\n          \"checkConnection\": \"无法连接到服务器，请检查 Base URL 是否正确，服务器是否可访问\",\n          \"ollamaRunning\": \"请确保 Ollama 正在本地运行，可以通过 'ollama serve' 命令启动\",\n          \"sslCertificate\": \"存在 SSL 证书问题，如果使用自签名证书，可能需要额外配置\",\n          \"checkConfiguration\": \"请检查您的配置，确保 Base URL、API Key 和模型名称都正确\"\n        },\n        \"recommended\": \"推荐\",\n        \"gatewayModels\": \"AI Gateway 模型\",\n        \"gatewayModelsDescription\": \"通过 Vercel AI Gateway 配置推荐模型，获得最佳 AI SDK 支持\",\n        \"gatewayDescription\": \"使用 AI Gateway 获得最佳兼容性和可靠性。请在下方 App 配置区域配置 API 密钥。\",\n        \"providerDescription\": \"高级选项：为特定用例或自托管模型配置自定义 AI 服务商。\",\n        \"noGatewayModels\": \"未配置 Gateway 模型。添加模型以开始使用。\",\n        \"addModel\": \"添加模型\",\n        \"addGatewayModel\": \"添加 Gateway 模型\",\n        \"popularModels\": \"常用模型\",\n        \"modelId\": \"模型 ID\",\n        \"modelIdHint\": \"AI Gateway 使用的模型标识符（例如：anthropic/claude-sonnet-4）\",\n        \"searchModelPlaceholder\": \"输入模型名称搜索...\",\n        \"noMatchingModels\": \"未找到匹配的模型\",\n        \"useCustomId\": \"使用自定义 ID: {{id}}\",\n        \"typeToSearch\": \"输入关键词搜索可用模型\",\n        \"modelNotFound\": \"此模型 ID 在可用列表中未找到，请确认拼写正确\",\n        \"testModel\": \"测试模型\",\n        \"testModelSuccess\": \"模型测试成功！API 响应正常\",\n        \"testModelImageSuccess\": \"图像模型已确认存在于 API 中\",\n        \"testModelNotFound\": \"模型在 API 列表中未找到\",\n        \"displayLabel\": \"显示名称\",\n        \"isImageModel\": \"这是图像生成模型\",\n        \"capabilities\": \"模型能力\",\n        \"setAsDefault\": \"设为默认聊天模型\",\n        \"quickAdd\": \"快速添加常用模型：\",\n        \"guide\": {\n          \"configStatus\": \"配置状态\",\n          \"ready\": \"就绪\",\n          \"needsAttention\": \"需要注意\",\n          \"incomplete\": \"未完成\",\n          \"aiEnabled\": \"AI 已启用\",\n          \"aiEnabledDesc\": \"此实例已启用自定义模型\",\n          \"aiDisabledDesc\": \"启用以使用自定义 AI 模型\",\n          \"gatewayKey\": \"Gateway API 密钥\",\n          \"gatewayKeyConfigured\": \"AI Gateway 密钥已配置\",\n          \"gatewayKeyMissing\": \"配置 AI Gateway API 密钥以获得推荐设置\",\n          \"gatewayKeyRequired\": \"请先在下方 App 配置区域配置 AI Gateway API 密钥\",\n          \"gatewayModels\": \"Gateway 模型\",\n          \"gatewayModelsConfigured\": \"已启用 {{count}} 个模型\",\n          \"gatewayModelsEmpty\": \"添加 Gateway 模型供用户选择\",\n          \"providers\": \"自定义服务商\",\n          \"providersConfigured\": \"已配置 {{count}} 个服务商\",\n          \"providersEmpty\": \"未配置自定义服务商\",\n          \"chatModel\": \"默认聊天模型\",\n          \"chatModelGateway\": \"使用 Gateway 模型\",\n          \"chatModelProvider\": \"使用自定义服务商模型\",\n          \"chatModelMissing\": \"请选择默认聊天模型\"\n        },\n        \"enableCard\": {\n          \"title\": \"AI 服务\",\n          \"ready\": \"已配置完成，AI 功能可正常使用\",\n          \"needsConfig\": \"已启用，但需要完成以下配置\",\n          \"disabled\": \"AI 功能当前未启用\",\n          \"missingConfig\": \"待完成配置：\",\n          \"allConfigured\": \"所有配置已完成\"\n        },\n        \"wizard\": {\n          \"setupProgress\": \"配置进度\",\n          \"checklist\": \"配置清单\",\n          \"allComplete\": \"所有必需配置已完成！AI 功能已就绪。\",\n          \"nextStep\": \"下一步：{{step}}\",\n          \"configureAI\": \"完成以下步骤以启用 AI 功能\",\n          \"optional\": \"可选\",\n          \"gatewayHelp\": \"AI Gateway：一个 API Key 访问数百模型；首次使用需在 Vercel 绑定信用卡启用免费额度；支持\",\n          \"gatewayByok\": \"（自带 Provider Key）\",\n          \"getApiKey\": \"获取 API 密钥\",\n          \"keyInvalid\": \"API 密钥无效，请检查后重试\",\n          \"gatewayErrorUnauthorized\": \"API 密钥无效，请检查是否正确复制\",\n          \"gatewayErrorNeedCreditCard\": \"需要绑定信用卡才能使用 AI Gateway，请前往 Vercel 控制台添加\",\n          \"gatewayErrorInsufficientQuota\": \"账户余额不足，请充值后再试\",\n          \"gatewayErrorForbidden\": \"访问被拒绝，请在 Vercel 控制台检查账户状态\",\n          \"gatewayErrorNetwork\": \"网络错误，请检查网络连接或代理配置\",\n          \"pleaseTest\": \"请点击测试验证 API 密钥是否有效\",\n          \"test\": \"测试\",\n          \"testing\": \"测试中...\",\n          \"attachmentTest\": {\n            \"title\": \"附件传输模式\",\n            \"urlMode\": \"URL 模式\",\n            \"base64Mode\": \"Base64 模式\",\n            \"accessible\": \"可用\",\n            \"inaccessible\": \"不可用\",\n            \"urlNotAccessibleWarning\": \"AI 服务无法访问附件 URL，这在内网部署时很常见。已自动启用 Base64 模式。\",\n            \"useBase64Mode\": \"使用 Base64 模式\",\n            \"base64ModeDescription\": \"发送前将附件转换为 Base64（Teable 无法被公网访问时使用）\",\n            \"originChanged\": \"服务器地址已变更\",\n            \"originChangedDesc\": \"自上次测试以来服务器地址已变更，请重新测试以验证附件可访问性。\"\n          },\n          \"saveAndContinue\": \"保存并继续\",\n          \"completeStep1First\": \"请先完成步骤 1：配置 LLM API\",\n          \"completeStep2First\": \"请先完成步骤 2：添加至少一个模型\",\n          \"addCustom\": \"自定义...\",\n          \"enabledModels\": \"已启用模型\",\n          \"chatDefault\": \"聊天默认\",\n          \"noModelsAvailable\": \"暂无可用模型，请先在步骤 2 添加模型\",\n          \"quickSetup\": \"快速设置\",\n          \"useRecommended\": \"使用推荐配置\",\n          \"useRecommendedDesc\": \"一键将 {{model}} 设为所有聊天场景的默认模型\",\n          \"chatModels\": \"聊天模型\",\n          \"chatModelTip\": \"此模型用于侧边栏 AI 聊天功能，由管理员指定，用户无法更改\",\n          \"selectChatModel\": \"选择聊天模型...\",\n          \"lgDesc\": \"复杂任务、深度分析\",\n          \"mdDesc\": \"日常对话、一般任务\",\n          \"smDesc\": \"简单查询、快速响应\",\n          \"readyToUse\": \"AI 已配置完成，可以开始使用了！\",\n          \"customProviderHelp\": \"添加您自己的 AI 服务商（如 OpenAI、Anthropic、Azure 等），配置 API 密钥和模型。\",\n          \"testModelCapabilities\": \"测试模型能力\",\n          \"customModelsAutoImported\": \"您配置的服务商模型已自动导入到可选模型池。\",\n          \"modelsCount\": \"共 {{count}} 个可选模型\",\n          \"customModelsHint\": \"如需调整模型配置，请返回步骤 1 修改服务商设置。\",\n          \"gatewayOption\": {\n            \"title\": \"AI Gateway（推荐）\",\n            \"desc\": \"通过 Vercel AI Gateway 访问数百种模型，最佳兼容性和可靠性\"\n          },\n          \"customOption\": {\n            \"title\": \"自定义服务商\",\n            \"desc\": \"接入自建/私有模型，支持多服务商配置\"\n          },\n          \"step\": {\n            \"llmApi\": \"配置 LLM API\",\n            \"llmApiDesc\": \"选择 AI Gateway 或配置自定义服务商\",\n            \"modelPool\": \"配置推荐模型\",\n            \"modelPoolDesc\": \"用户在 AI 字段和自动化中的推荐模型列表\",\n            \"chatModel\": \"设置聊天模型\",\n            \"chatModelDesc\": \"侧边栏 AI 聊天使用的模型（从推荐模型列表选择）\",\n            \"providers\": \"配置服务商\",\n            \"providersDesc\": \"添加和管理 AI 服务商\"\n          }\n        }\n      },\n      \"app\": {\n        \"domain\": \"域名\",\n        \"v0ApiKey\": \"v0 API 密钥\",\n        \"customDomain\": \"自定义域名 (可选)\",\n        \"customDomainDescription\": \"设置应用部署的自定义域名，访问 <a>Vercel 域名</a> 获取自定义域名\",\n        \"vercelToken\": \"Vercel API 密钥\",\n        \"vercelTokenDescription\": \"设置 Vercel API 密钥以启用自定义域名部署功能，访问 <a>Vercel 设置</a> 获取 API 密钥\",\n        \"apiProxy\": \"API 代理 (可选)\",\n        \"apiProxyDescription\": \"配置 v0 和 Vercel API 的反向代理地址（如 Cloudflare Workers）。留空则使用默认地址。\",\n        \"v0BaseUrl\": \"v0 API 代理地址\",\n        \"vercelBaseUrl\": \"Vercel API 代理地址\",\n        \"aiGateway\": \"Vercel AI Gateway\",\n        \"aiGatewayDescription\": \"配置 Vercel AI Gateway 以通过单一端点访问数百种模型。访问 <a>AI Gateway 设置</a> 获取 API 密钥\",\n        \"aiGatewayApiKey\": \"AI Gateway API 密钥\",\n        \"aiGatewayKeyConfigured\": \"API 密钥已配置\",\n        \"aiGatewayBaseUrl\": \"自定义网关地址\"\n      }\n    },\n    \"action\": {\n      \"enterApiKey\": \"输入 API 密钥\",\n      \"goToConfiguration\": \"前往配置\"\n    },\n    \"tips\": {\n      \"thankYouForUsingTeable\": \"感谢您使用 teable\",\n      \"pleaseGoToConfiguration\": \"请前往设置页面完成一些初始配置，以享受 teable 的完整功能和更好的用户体验\",\n      \"pleaseContactAdmin\": \"请联系管理员\"\n    },\n    \"configuration\": {\n      \"title\": \"待配置项\",\n      \"description\": \"完成这些配置以获得完整功能\",\n      \"progressTitle\": \"配置进度\",\n      \"allComplete\": \"所有配置已完成\",\n      \"incomplete\": \"还有配置未完成\",\n      \"optional\": \"可选\",\n      \"completed\": \"已完成\",\n      \"group\": {\n        \"system\": \"基础配置\",\n        \"ai\": \"AI 服务\",\n        \"appBuilder\": \"应用构建器\"\n      },\n      \"copyInstance\": \"复制 ID\",\n      \"list\": {\n        \"publicOrigin\": {\n          \"title\": \"PUBLIC_ORIGIN 环境变量\",\n          \"description\": \"您的 <strong>{{envPublicOrigin}}</strong> 环境变量配置与当前访问地址 <underline>{{currentPublicOrigin}}</underline> 不匹配，导入 xlsx、csv 和附件字段功能可能无法正常工作，建议设置为 <underline>{{currentPublicOrigin}}</underline>\"\n        },\n        \"https\": {\n          \"title\": \"启用 HTTPS\",\n          \"description\": \"您尚未启用 HTTPS，大规模复制（300行或更多）功能将不可用，建议启用。\"\n        },\n        \"databaseProxy\": {\n          \"title\": \"PUBLIC_DATABASE_PROXY 环境变量\",\n          \"description\": \"<strong>PUBLIC_DATABASE_PROXY</strong> 未配置，外部数据库连接功能将不可用，请参考<a>帮助文档</a>\",\n          \"href\": \"https://help.teable.ai/zh/deploy/database-connection#enable-external-database-connection\"\n        },\n        \"llmApi\": {\n          \"title\": \"LLM API\",\n          \"description\": \"您尚未配置 AI LLM API，AI 对话/AI 自动化将无法使用，<anchor>前往设置</anchor>\",\n          \"errorTips\": \"您尚未配置 AI LLM API，AI 对话/AI 自动化将无法使用\"\n        },\n        \"aiEnable\": {\n          \"title\": \"AI 服务（聊天 / AI 字段）\",\n          \"description\": \"开启后可使用 AI Chat、AI 字段与自动化能力，<anchor>前往设置</anchor>\"\n        },\n        \"aiLlmApi\": {\n          \"title\": \"AI：配置 LLM API\",\n          \"description\": \"选择 AI Gateway 或配置自定义 Provider，<anchor>前往设置</anchor>\"\n        },\n        \"aiModelPool\": {\n          \"title\": \"AI：配置可选模型\",\n          \"description\": \"添加/启用至少一个模型供 AI 字段与自动化选择，<anchor>前往设置</anchor>\"\n        },\n        \"aiChatModel\": {\n          \"title\": \"AI：设置聊天模型\",\n          \"description\": \"为侧边栏 AI Chat 选择一个默认模型，<anchor>前往设置</anchor>\"\n        },\n        \"app\": {\n          \"title\": \"应用构建器\",\n          \"description\": \"您尚未配置 v0 API，应用构建器功能将无法使用，<anchor>前往设置</anchor>\",\n          \"errorTips\": \"您尚未配置 v0 API，应用构建器功能将无法使用\"\n        },\n        \"appBuilderV0\": {\n          \"title\": \"应用构建器：启用 v0\",\n          \"description\": \"配置 v0 API Key 后即可使用应用构建器，<anchor>前往设置</anchor>\"\n        },\n        \"appBuilderDomain\": {\n          \"title\": \"应用构建器：自定义域名\",\n          \"description\": \"如需使用自定义域名发布应用，请配置域名与 Vercel Token，<anchor>前往设置</anchor>\"\n        },\n        \"appBuilderApiProxy\": {\n          \"title\": \"应用构建器：API 代理\",\n          \"description\": \"如需通过代理访问 v0/Vercel API，请配置代理地址，<anchor>前往设置</anchor>\"\n        },\n        \"email\": {\n          \"title\": \"邮箱\",\n          \"description\": \"未配置邮箱，自助密码恢复和邮件通知功能将不可用，<anchor>前往设置</anchor>\",\n          \"errorTips\": \"未配置邮箱，自助密码恢复、邮箱验证/通知功能将不可用\"\n        }\n      }\n    },\n    \"canary\": {\n      \"title\": \"灰度发布\",\n      \"enable\": \"启用灰度\",\n      \"enableDescription\": \"启用后，所选空间站将激活灰度功能\",\n      \"spaces\": \"灰度空间站\",\n      \"spacesDescription\": \"已配置 {{count}} 个空间站\",\n      \"configure\": \"配置\",\n      \"spaceIds\": \"空间站 ID\",\n      \"spaceIdsDescription\": \"输入空间站 ID，每行一个或用逗号/空格分隔\",\n      \"spaceIdsPlaceholder\": \"spcXXXXXXXXXXX\\nspcYYYYYYYYYYY\",\n      \"preview\": \"预览 ({{count}} 个)\",\n      \"noSpaceIds\": \"未输入空间站 ID\"\n    }\n  },\n  \"notification\": {\n    \"title\": \"通知\",\n    \"unread\": \"未读\",\n    \"read\": \"已读\",\n    \"markAs\": \"将此通知标记为{{status}}\",\n    \"markAllAsRead\": \"全部标记为已读\",\n    \"noUnread\": \"没有{{status}}通知\",\n    \"changeSetting\": \"更改页面通知设置\",\n    \"new\": \"新消息{{count}}条\",\n    \"showMore\": \"显示更多\",\n    \"exportBase\": {\n      \"successText\": \"导出数据已就绪\",\n      \"failedText\": \"导出失败，请重试\"\n    }\n  },\n  \"role\": {\n    \"title\": {\n      \"owner\": \"可管理\",\n      \"creator\": \"可搭建\",\n      \"editor\": \"可编辑\",\n      \"commenter\": \"可查看与评论\",\n      \"viewer\": \"可查看\"\n    },\n    \"description\": {\n      \"owner\": \"具有完全配置和编辑数据库、自动化、权限矩阵、管理空间设置和计费的权限\",\n      \"creator\": \"具有完全配置和编辑数据库、自动化和启用权限矩阵的权限\",\n      \"editor\": \"可以编辑记录和视图，不能配置表格或字段\",\n      \"commenter\": \"可以查看和评论记录，不能编辑记录\",\n      \"viewer\": \"仅可查看记录，不能编辑或评论记录\"\n    }\n  },\n  \"trash\": {\n    \"spaceTrash\": \"空间回收站\",\n    \"type\": \"类型\",\n    \"resetTrash\": \"清空回收站\",\n    \"deletedBy\": \"操作者\",\n    \"deletedTime\": \"删除时间\",\n    \"fromSpace\": \"来自 \\\"{{name}}\\\" 空间\",\n    \"permanentDeleteTips\": \"确定要永久删除 \\\"{{name}}\\\" {{resource}}吗？\",\n    \"resetTrashConfirm\": \"确定要清空回收站吗？\",\n    \"addToTrash\": \"移到回收站\",\n    \"description\": \"回收站中的数据依然会占用记录用量和附件用量。\",\n    \"spaceDescription\": \"恢复过去{{retentionDays}}天内删除的空间\",\n    \"spaceInnerDescription\": \"恢复过去{{retentionDays}}天内从该空间中删除的数据库\",\n    \"baseDescription\": \"恢复过去{{retentionDays}}天内从该数据库中删除的资源\"\n  },\n  \"pluginCenter\": {\n    \"pluginUrlEmpty\": \"插件未设置 URL\",\n    \"install\": \"安装\",\n    \"publisher\": \"发布者\",\n    \"lastUpdated\": \"最后更新\",\n    \"pluginNotFound\": \"插件未找到\",\n    \"pluginEmpty\": {\n      \"title\": \"暂无插件\"\n    }\n  },\n  \"automation\": {\n    \"turnOnTip\": \"您是否要开启当前自动化？\"\n  },\n  \"email\": {\n    \"title\": \"邮箱\",\n    \"send\": \"发送\",\n    \"config\": \"邮件配置\",\n    \"customConfig\": \"自定义邮件服务器\",\n    \"notify\": \"通知邮箱\",\n    \"automation\": \"自动化邮箱\",\n    \"customNotifyConfig\": \"自定义通知邮箱\",\n    \"customAutomationConfig\": \"自定义自动化邮箱\",\n    \"addConfig\": \"添加配置\",\n    \"editConfig\": \"编辑配置\",\n    \"resetConfig\": \"重置\",\n    \"testEmail\": \"测试邮箱\",\n    \"testEmailPlaceholder\": \"请输入测试邮箱\",\n    \"testEmailError\": \"请输入正确的测试邮箱地址\",\n    \"testEmailSend\": \"测试邮件发送成功，请检查邮箱\",\n    \"configError\": \"请输入正确的邮件配置\",\n    \"host\": \"服务器地址\",\n    \"hostDescription\": \"请输入 SMTP 邮件服务器地址，例如：smtp.example.com\",\n    \"port\": \"端口\",\n    \"secure\": \"SSL/TLS\",\n    \"auth\": \"认证\",\n    \"username\": \"用户名\",\n    \"password\": \"密码\",\n    \"sender\": \"发件人地址\",\n    \"senderName\": \"发件人名称\",\n    \"subscribe\": \"订阅\",\n    \"unsubscribe\": \"取消订阅\",\n    \"unsubscribeList\": \"取消订阅列表\",\n    \"unsubscribeTime\": \"取消订阅时间\",\n    \"source\": \"来源\",\n    \"sourceAutomationDeleted\": \"自动化或节点已删除\",\n    \"processing\": \"处理中...\",\n    \"unsubscribeH1\": \"确认取消订阅\",\n    \"unsubscribeH2\": \"您即将取消订阅 Teable 未来的促销和产品更新信息。您确定要取消订阅吗？\",\n    \"subscribeH1\": \"确认订阅\",\n    \"subscribeH2\": \"您即将订阅 Teable 未来的促销和产品更新信息。您确定要订阅吗？\",\n    \"unsubscribeListTip\": \"当前数据库的以下用户已取消订阅，您将无法再向他们发送电子邮件。导入电子邮件时，请将电子邮件地址放在第一列，并将标题命名为 email。\",\n    \"templates\": {\n      \"resetPassword\": {\n        \"subject\": \"重置密码 - {{brandName}}\",\n        \"title\": \"重置您的密码\",\n        \"message\": \"如果您没有请求此更改，请忽略此电子邮件。否则，请单击下面的按钮重置您的密码。\",\n        \"buttonText\": \"重置密码\"\n      },\n      \"emailVerifyCode\": {\n        \"signupVerification\": {\n          \"subject\": \"注册验证 - {{brandName}}\",\n          \"title\": \"注册验证\",\n          \"message\": \"您的验证码是：{{code}}，请在 {{expiresIn}} 分钟内使用。\"\n        },\n        \"domainVerification\": {\n          \"subject\": \"域名验证 - {{brandName}}\",\n          \"title\": \"域名验证\",\n          \"message\": \"您的一次性验证码是：{{code}}，请在 {{expiresIn}} 分钟内使用。\"\n        },\n        \"changeEmailVerification\": {\n          \"subject\": \"更改电子邮件验证 - {{brandName}}\",\n          \"title\": \"更改电子邮件验证\",\n          \"message\": \"您的验证码是：{{code}}，请在 {{expiresIn}} 分钟内使用。\"\n        }\n      },\n      \"collaboratorCellTag\": {\n        \"subject\": \"{{fromUserName}} 添加您到 {{tableName}} 的 {{fieldName}} 字段中的记录\",\n        \"title\": \"<strong>{{fromUserName}}</strong> 添加您到 <strong>{{tableName}}</strong> 的 <strong>{{fieldName}}</strong> 字段中的记录\",\n        \"buttonText\": \"查看记录\"\n      },\n      \"collaboratorMultiRowTag\": {\n        \"subject\": \"{{fromUserName}} 添加您到 {{tableName}} 的 {{refLength}} 条记录\",\n        \"title\": \"<strong>{{fromUserName}}</strong> 添加您到 <strong>{{tableName}}</strong> 的 <strong>{{refLength}}</strong> 条记录\",\n        \"buttonText\": \"查看记录\"\n      },\n      \"invite\": {\n        \"subject\": \"{{name}} ({{email}}) 邀请您到他们的 {{resourceAlias}} {{resourceName}}\",\n        \"title\": \"邀请您协作\",\n        \"message\": \"<strong>{{name}}</strong> ({{email}}) 邀请您到他们的 {{resourceAlias}} <strong>{{resourceName}}</strong>。\",\n        \"buttonText\": \"接受邀请\"\n      },\n      \"waitlistInvite\": {\n        \"subject\": \"欢迎 - {{brandName}}\",\n        \"title\": \"欢迎\",\n        \"message\": \"您已成功加入 {{brandName}} 的等待列表，请使用以下邀请码注册：{{code}}，该邀请码可使用 {{times}} 次。\",\n        \"buttonText\": \"注册\"\n      },\n      \"test\": {\n        \"subject\": \"测试邮件 - {{brandName}}\",\n        \"title\": \"测试邮件\",\n        \"message\": \"这是一封测试邮件，请忽略。\"\n      },\n      \"notify\": {\n        \"subject\": \"通知 - {{brandName}}\",\n        \"title\": \"通知\",\n        \"buttonText\": \"查看\",\n        \"import\": {\n          \"title\": \"导入结果通知\",\n          \"table\": {\n            \"aborted\": {\n              \"message\": \"❌ {{tableName}} 导入中断: {{errorMessage}} 失败行范围: [{{range}}]. 请检查此范围内的数据并重试。\"\n            },\n            \"failed\": {\n              \"message\": \"❌ {{tableName}} 导入失败: {{errorMessage}}\"\n            },\n            \"planLimitExceeded\": {\n              \"message\": \"❌ {{tableName}} 导入失败: 行数已达上限，请升级套餐以导入更多记录\"\n            },\n            \"noRecordsProcessed\": {\n              \"message\": \"❌ {{tableName}} 导入失败: 没有记录被处理\"\n            },\n            \"success\": {\n              \"message\": \"🎉 {{tableName}} 导入成功。\",\n              \"inplace\": \"🎉 {{tableName}} 增量导入成功。\"\n            },\n            \"partialSuccess\": {\n              \"message\": \"⚠️ {{tableName}} 部分导入成功：{{successCount}} 行成功，{{failedCount}} 行失败。<a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 下载错误报告</a>\",\n              \"messageNoReport\": \"⚠️ {{tableName}} 部分导入成功：{{successCount}} 行成功，{{failedCount}} 行失败。\"\n            },\n            \"allFailed\": {\n              \"message\": \"❌ {{tableName}} 导入失败：全部 {{failedCount}} 行均失败。<a href=\\\"{{errorReportUrl}}\\\" target=\\\"_blank\\\" rel=\\\"noopener\\\" download=\\\"error_report.csv\\\" style=\\\"color:#2563eb;text-decoration:underline;\\\">📥 下载错误报告</a>\",\n              \"messageNoReport\": \"❌ {{tableName}} 导入失败：全部 {{failedCount}} 行均失败。\"\n            }\n          }\n        },\n        \"recordComment\": {\n          \"title\": \"记录评论通知\",\n          \"message\": \"{{fromUserName}} 在数据库 {{baseName}} 的 {{tableName}} 的 {{recordName}} 上评论了\"\n        },\n        \"automation\": {\n          \"title\": \"自动化通知\",\n          \"failed\": {\n            \"title\": \"自动化 {{name}} 运行失败\",\n            \"message\": \"自动化 {{name}} 执行时发生错误，请前往运行历史查看详情。\"\n          },\n          \"insufficientCredit\": {\n            \"title\": \"自动化 {{name}} 运行失败（AI 算力不足）\",\n            \"message\": \"自动化 {{name}} 运行失败，原因是 AI 算力不足。请补充算力或升级订阅。\"\n          },\n          \"runQuotaExceeded\": {\n            \"title\": \"自动化 {{name}} 已触及每月最大运行次数\",\n            \"message\": \"自动化 {{name}} 本月可运行次数已用完，暂时无法继续运行。请升级订阅或购买额外运行次数。\"\n          }\n        },\n        \"billing\": {\n          \"title\": \"账单通知\",\n          \"credit\": {\n            \"warning80\": {\n              \"title\": \"空间 {{spaceName}} AI 算力已使用 80%\",\n              \"message\": \"您的空间已使用 80% 的 AI 算力。请考虑升级或购买更多算力。\"\n            },\n            \"warning90\": {\n              \"title\": \"空间 {{spaceName}} AI 算力已使用 90%\",\n              \"message\": \"您的空间已使用 90% 的 AI 算力。请尽快升级以避免中断。\"\n            }\n          },\n          \"automationRun\": {\n            \"warning80\": {\n              \"title\": \"空间 {{spaceName}} 自动化运行次数已使用 80% 配额\",\n              \"message\": \"您的空间已使用 {{usedRuns}}/{{totalLimit}} 次自动化运行（80%）。请考虑升级订阅。\"\n            },\n            \"warning90\": {\n              \"title\": \"空间 {{spaceName}} 自动化运行次数已使用 90% 配额\",\n              \"message\": \"您的空间已使用 {{usedRuns}}/{{totalLimit}} 次自动化运行（90%）。请尽快升级以避免中断。\"\n            },\n            \"gracePeriod\": {\n              \"title\": \"空间 {{spaceName}} 自动化运行次数已超限 - 宽限期生效中\",\n              \"message\": \"您的自动化运行配额已超出。自动化将在接下来的 {{remainingHours}} 小时后停止运行并关闭。请升级订阅。\"\n            }\n          }\n        },\n        \"exportBase\": {\n          \"title\": \"导出数据库结果通知\",\n          \"success\": {\n            \"message\": \"{{baseName}} 导出成功: <a href=\\\"{{previewUrl}}\\\" name=\\\"{{name}}\\\" class=\\\"hover:text-blue-500 underline\\\">🗂️ {{name}}</a>\"\n          },\n          \"failed\": {\n            \"message\": \"❌ {{baseName}} 导出失败: {{errorMessage}}\"\n          }\n        },\n        \"task\": {\n          \"ai\": {\n            \"failed\": {\n              \"title\": \"AI 任务失败，表 {{tableName}}\",\n              \"message\": \"AI 任务失败 - 表 \\\"{{tableName}}\\\" 字段 \\\"{{fieldName}}\\\"：{{errorMsg}}\"\n            },\n            \"cancelled\": {\n              \"title\": \"AI 任务已取消\",\n              \"rateLimit\": \"表 \\\"{{tableName}}\\\" 的 AI 任务因请求频率超限（429）已取消。请稍后重试。\",\n              \"creditExhausted\": \"表 \\\"{{tableName}}\\\" 的 AI 任务因算力额度已用尽已取消。请升级订阅或等待额度刷新。\",\n              \"authFailed\": \"表 \\\"{{tableName}}\\\" 的 AI 任务因认证失败已取消。请检查 API Key 配置。\",\n              \"serviceUnavailable\": \"表 \\\"{{tableName}}\\\" 的 AI 任务因 AI 服务暂时不可用已取消。请稍后重试。\",\n              \"unknown\": \"表 \\\"{{tableName}}\\\" 的 AI 任务已取消。错误：{{errorMessage}}\"\n            }\n          }\n        },\n        \"rewardRejected\": {\n          \"title\": \"奖励领取被拒绝\",\n          \"message\": \"您的 <a href=\\\"{{spaceUrl}}\\\">{{spaceName}}</a> 奖励申请因以下原因被拒绝：{{errorMessages}}，请检查后重新提交。\",\n          \"buttonText\": \"查看空间\"\n        },\n        \"rewardApproved\": {\n          \"title\": \"奖励领取成功\",\n          \"message\": \"恭喜！您的 <a href=\\\"{{spaceUrl}}\\\">{{spaceName}}</a> 奖励申请已通过。{{amount}} 算力已添加，有效期 {{expiredDays}} 天。\",\n          \"buttonText\": \"查看空间\"\n        }\n      }\n    }\n  },\n  \"waitlist\": {\n    \"title\": \"等待列表\",\n    \"email\": \"邮箱\",\n    \"joinTitle\": \"我们正在快速扩容以满足需求\",\n    \"joinDesc\": \"加入等待列表 — 准备就绪时我们会立即通知您\",\n    \"emailPlaceholder\": \"请输入您的电子邮件地址...\",\n    \"youAreOnTheList\": \"您已加入等待列表！\",\n    \"thanksForJoining\": \"感谢您加入我们的等待列表。我们将尽快通知您。\",\n    \"back\": \"返回\",\n    \"inviteCodePlaceholder\": \"请输入邀请码\",\n    \"join\": \"加入等待列表\",\n    \"joining\": \"加入中...\",\n    \"invite\": \"邀请\",\n    \"inviteTime\": \"邀请时间\",\n    \"createdTime\": \"加入时间\",\n    \"yes\": \"是\",\n    \"no\": \"否\",\n    \"generateCode\": \"生成邀请码\",\n    \"count\": \"数量\",\n    \"times\": \"可使用次数\",\n    \"generate\": \"生成\",\n    \"code\": \"邀请码\",\n    \"inviteSuccess\": \"邀请成功\"\n  },\n  \"noPermissionToCreateBase\": \"没有权限在任何空间中创建数据库\",\n  \"base\": {\n    \"deleteTip\": \"确定要删除 \\\"{{name}}\\\" 数据库吗？\",\n    \"createResource\": \"创建\",\n    \"noPermissionToCreateResource\": \"您没有权限创建，请联系管理员\"\n  },\n  \"app\": {\n    \"title\": \"应用构建器\",\n    \"description\": \"配置 v0 API 密钥以启用应用构建器功能，访问 <a>v0 设置</a> 获取 API 密钥\",\n    \"previewAppError\": \"应用运行报错\",\n    \"sendErrorToAI\": \"将错误发送给 AI\"\n  },\n  \"credit\": {\n    \"title\": \"算力\",\n    \"leftAmount\": \"剩余算力\",\n    \"winFreeCredits\": \"分享体验\",\n    \"getCredits\": \"获取 1000 免费算力\",\n    \"winCredit\": {\n      \"title\": \"分享使用体验\",\n      \"freeCredits\": \"获取免费 1000 算力\",\n      \"guidelinesTitle\": \"分享要求\",\n      \"tagTeableio\": \"标记 <bold>@teableio</bold>\",\n      \"minCharacters\": \"撰写 <bold>80+</bold> 字符的评价\",\n      \"minFollowers\": \"拥有 <bold>10+</bold> 粉丝数\",\n      \"limitPerWeek\": \"每个帖子 500 算力，每周最多 2 次（X + LinkedIn）\",\n      \"postOnX\": \"发布到 X\",\n      \"postOnLinkedIn\": \"发布到 LinkedIn\",\n      \"preFilledDraft\": \"🌟 已为您准备好预填草稿！\",\n      \"claimTitle\": \"粘贴帖子链接领取算力\",\n      \"userEmail\": \"用户邮箱\",\n      \"postUrlLabel\": \"帖子链接 (X 或 LinkedIn)\",\n      \"postUrlPlaceholder\": \"在此粘贴您的帖子链接\",\n      \"invalidUrl\": \"请输入有效的 X 或 LinkedIn 帖子链接\",\n      \"claiming\": \"领取中...\",\n      \"claimCredits\": \"领取算力\",\n      \"congratulations\": \"恭喜！\",\n      \"claimSuccess\": \"您已成功领取 500 算力！\",\n      \"verifying\": \"正在验证您的帖子...\",\n      \"verifyingDescription\": \"我们正在检查您的帖子内容，通常需要几秒钟\",\n      \"verifyFailed\": \"验证失败\",\n      \"tryAgain\": \"重试\"\n    },\n    \"error\": {\n      \"verificationFailed\": \"验证失败\"\n    }\n  },\n  \"reward\": {\n    \"title\": \"奖励\",\n    \"rewardCredits\": \"算力奖励\",\n    \"minCharCount\": \"帖子内容至少需要 {{count}} 个字符\",\n    \"minFollowerCount\": \"账户至少需要 {{count}} 位粉丝\",\n    \"mustMention\": \"帖子必须提及 {{mention}}\",\n    \"fetchSnapshotFailed\": \"获取帖子快照失败\",\n    \"alreadyClaimedThisWeek\": \"您本周已为此账户领取过奖励\",\n    \"manage\": {\n      \"title\": \"奖励管理\",\n      \"description\": \"查看和管理所有空间的奖励记录\",\n      \"overview\": \"概览\",\n      \"records\": \"记录\",\n      \"searchSpace\": \"搜索空间...\",\n      \"searchRecords\": \"搜索帖子链接/用户ID...\",\n      \"dateRange\": \"选择日期范围\",\n      \"from\": \"从\",\n      \"to\": \"至\",\n      \"totalSpaces\": \"共 {{count}} 个空间\",\n      \"totalRecords\": \"共 {{count}} 条记录\",\n      \"space\": \"空间\",\n      \"allSpaces\": \"全部空间\",\n      \"user\": \"用户\",\n      \"creator\": \"创建者\",\n      \"sourceType\": \"来源类型\",\n      \"platform\": \"平台\",\n      \"allStatuses\": \"全部状态\",\n      \"allSourceTypes\": \"全部来源类型\",\n      \"allPlatforms\": \"全部平台\",\n      \"pendingCount\": \"待处理个数\",\n      \"approvedCount\": \"通过个数\",\n      \"rejectedCount\": \"拒绝个数\",\n      \"approvedAmount\": \"通过数量\",\n      \"consumedAmount\": \"已消耗数量\",\n      \"availableAmount\": \"可用数量\",\n      \"expiringSoonAmount\": \"即将过期数量 (7天)\",\n      \"amount\": \"数量\",\n      \"remainingAmount\": \"剩余数量\",\n      \"createdTime\": \"创建时间\",\n      \"rewardTime\": \"奖励时间\",\n      \"expiredTime\": \"过期时间\",\n      \"lastModified\": \"最后修改\",\n      \"viewDetails\": \"查看详情\",\n      \"details\": \"奖励详情\",\n      \"basicInfo\": \"基本信息\",\n      \"amountInfo\": \"金额信息\",\n      \"timeInfo\": \"时间信息\",\n      \"socialInfo\": \"社交信息\",\n      \"verifyResult\": \"验证结果\",\n      \"uniqueKey\": \"唯一标识\",\n      \"verify\": \"验证\",\n      \"valid\": \"有效\",\n      \"invalid\": \"无效\",\n      \"errors\": \"错误\",\n      \"copied\": \"{{label}} 已复制\",\n      \"openPost\": \"打开帖子\",\n      \"noData\": \"暂无数据\",\n      \"page\": \"第 {{current}} / {{total}} 页\",\n      \"status\": {\n        \"label\": \"状态\",\n        \"pending\": \"待处理\",\n        \"approved\": \"已通过\",\n        \"rejected\": \"已拒绝\"\n      }\n    }\n  },\n  \"chat\": {\n    \"serverError\": \"服务端发生错误\",\n    \"serverErrorHint\": \"请新建对话重试\"\n  },\n  \"system\": {\n    \"notFound\": {\n      \"title\": \"页面未找到\",\n      \"description\": \"您访问的链接可能已失效或已被移动。\"\n    },\n    \"links\": {\n      \"backToHome\": \"返回首页\"\n    },\n    \"forbidden\": {\n      \"title\": \"访问受限\",\n      \"description\": \"您需要权限才能访问此资源。\\n请联系管理员。\"\n    },\n    \"paymentRequired\": {\n      \"title\": \"解锁高级功能\",\n      \"description\": \"此功能仅在高级计划中可用。\\n升级以扩展您的能力。\"\n    },\n    \"error\": {\n      \"title\": \"出错了\",\n      \"description\": \"发生了意外错误，请稍后重试。\"\n    }\n  },\n  \"import\": {\n    \"error\": {\n      \"dateOutOfRange\": \"{{fieldHint}}日期解析失败，值 \\\"{{value}}\\\" 超出有效范围\",\n      \"planRowLimit\": \"行数已达上限，请升级套餐以导入更多记录\",\n      \"notNullValidation\": \"{{fieldHint}}必填字段不能为空\",\n      \"uniqueValidation\": \"{{fieldHint}}唯一字段存在重复值\",\n      \"requestTimeout\": \"请求超时\",\n      \"chunkProcessingFailed\": \"批处理失败：{{reason}}\",\n      \"unknown\": \"{{fieldHint}}{{message}}\"\n    }\n  },\n  \"changelog\": {\n    \"newUpdate\": \"最新更新\",\n    \"title\": \"附件批量下载已上线\",\n    \"url\": \"https://help.teable.ai/en/changelog#mar-13-2026\",\n    \"id\": \"changelog-2026-03-13-bulk-download-attachments-are-live\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/dashboard.json",
    "content": "{\n  \"empty\": {\n    \"title\": \"暂无仪表盘\",\n    \"description\": \"仪表盘可以帮助您更有效地可视化和管理数据。\",\n    \"create\": \"创建仪表盘\"\n  },\n  \"addPlugin\": \"添加插件\",\n  \"createDashboard\": {\n    \"button\": \"创建仪表盘\",\n    \"title\": \"创建新仪表盘\",\n    \"placeholder\": \"输入仪表盘名称\"\n  },\n  \"findDashboard\": \"查找仪表盘...\",\n  \"deprecation\": {\n    \"title\": \"仪表盘节点功能将停止支持\",\n    \"description\": \"为了给你带来更智能、更高效的体验，我们将停止仪表盘节点功能的支持。后续你可以在 AI 生成应用中通过 AI 轻松创建新的仪表盘，体验更流畅。\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/developer.json",
    "content": "{\n  \"apiQueryBuilder\": \"API 查询构建器\",\n  \"subTitle\": \"你可以通过可视化的用户界面快速构建查询请求，并复制可直接运行的代码\",\n  \"apiList\": \"完整 API 列表\",\n  \"cellFormat\": \"返回值格式\",\n  \"fieldKeyType\": \"字段 key 类型\",\n  \"chooseSource\": \"选择数据源\",\n  \"action\": {\n    \"selectBase\": \"选择一个数据库...\",\n    \"selectTable\": \"选择一个表...\"\n  },\n  \"pickParams\": \"选择需要的参数进行配置\",\n  \"buildResult\": \"构造结果\",\n  \"buildResultEmpty\": \"请先选择一个表\",\n  \"previewReturnValue\": \"返回值预览\",\n  \"replaceToken\": \"替换 Token\",\n  \"createNewToken\": \"创建新 Token\",\n  \"showPagination\": \"分页参数在 JSON 模式下显示\",\n  \"addSort\": \"添加一个排序\",\n  \"tabs\": {\n    \"apiBuilder\": \"API 构建器\",\n    \"aiContext\": \"AI 上下文\"\n  },\n  \"aiContext\": {\n    \"title\": \"AI 友好的表格上下文\",\n    \"description\": \"复制此上下文到你的 AI 助手（ChatGPT、Claude 等），帮助它理解如何操作你的表格数据。\",\n    \"selectTableFirst\": \"请先选择一个表格以生成 AI 上下文\",\n    \"fullContext\": \"完整上下文\",\n    \"compactContext\": \"精简版\",\n    \"copyToClipboard\": \"复制到剪贴板\",\n    \"copied\": \"已复制！\",\n    \"compactDescription\": \"适用于快速分享的精简版本，考虑到 AI 对话的 Token 限制。\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/oauth.json",
    "content": "{\n  \"add\": \"新增 OAuth 应用\",\n  \"title\": {\n    \"add\": \"新增 OAuth 应用\",\n    \"edit\": \"编辑 OAuth 应用\",\n    \"description\": \"Teable 应用可以代表自身行事，直接通过 API 执行操作，而无需模拟用户。请参阅我们的<a>帮助文档</a>了解更多信息。\"\n  },\n  \"form\": {\n    \"name\": {\n      \"label\": \"OAuth 应用名称\",\n      \"description\": \"您的 OAuth 应用的名称。\"\n    },\n    \"description\": {\n      \"label\": \"描述\",\n      \"description\": \"您的 OAuth 应用的简短描述。\"\n    },\n    \"homePageUrl\": {\n      \"label\": \"主页 URL\",\n      \"description\": \"您的 OAuth 应用网站的完整 URL。\"\n    },\n    \"logo\": {\n      \"label\": \"标志\",\n      \"description\": \"建议使用正方形图像。\",\n      \"placeholder\": \"将文件拖放到此处或点击、粘贴进行上传\",\n      \"button\": \"上传标志\",\n      \"clear\": \"清除\",\n      \"lengthError\": \"仅允许一个文件。\",\n      \"typeError\": \"仅允许图像文件。\"\n    },\n    \"callbackUrl\": {\n      \"label\": \"回调 URL\",\n      \"description\": \"用户授权您的集成后要重定向的完整 URL。\",\n      \"add\": \"添加回调 URL\"\n    },\n    \"scopes\": {\n      \"label\": \"权限范围\",\n      \"description\": \"您的 OAuth 应用所需的权限。\"\n    },\n    \"secret\": {\n      \"label\": \"客户端密钥\",\n      \"add\": \"生成一个新的客户端密钥\",\n      \"newDescription\": \"请务必立即复制您的新客户端密钥。您将无法再次看到它。\",\n      \"empty\": \"您需要一个客户端密钥来验证应用程序对 API 的身份。\",\n      \"lastUsed\": \"上次使用时间 {{date}}\",\n      \"tag\": \"客户端密钥\",\n      \"neverUsed\": \"从未使用\"\n    },\n    \"clientId\": {\n      \"label\": \"客户端 ID：\"\n    }\n  },\n  \"formType\": {\n    \"basic\": \"基本信息\",\n    \"scopes\": \"权限范围\",\n    \"identify\": \"识别和授权用户\",\n    \"clientInfo\": \"客户端信息\"\n  },\n  \"decision\": {\n    \"title\": \"{{name}} 正在请求访问您的帐户\",\n    \"scopes\": \"此应用程序将能够获取以下权限范围：\",\n    \"redirectDescription\": \"授权将重定向到\",\n    \"authorize\": \"授权\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.cn/zh/api-doc/oauth\",\n    \"title\": \"了解更多\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"删除 OAuth 应用\",\n    \"description\": \"您确定要删除 {{name}} OAuth 应用吗？此操作无法撤销。\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/plugin.json",
    "content": "{\n  \"add\": \"新增插件\",\n  \"title\": {\n    \"add\": \"新增插件\",\n    \"edit\": \"编辑插件\"\n  },\n  \"pluginUser\": {\n    \"name\": \"插件用户\",\n    \"description\": \"插件用户是系统自动生成的插件\"\n  },\n  \"secret\": \"密钥\",\n  \"regenerateSecret\": \"重新生成密钥\",\n  \"form\": {\n    \"name\": {\n      \"label\": \"名称\",\n      \"description\": \"插件的名称\"\n    },\n    \"description\": {\n      \"label\": \"描述\",\n      \"description\": \"插件的描述\"\n    },\n    \"detailDesc\": {\n      \"label\": \"详细描述\",\n      \"description\": \"插件的详细描述\"\n    },\n    \"logo\": {\n      \"label\": \"图标\",\n      \"description\": \"插件的图标，你可以上传图片或使用URL\",\n      \"upload\": \"上传\",\n      \"clear\": \"清除\",\n      \"placeholder\": \"将你的图标拖放到此处或点击上传\",\n      \"lengthError\": \"仅允许上传一个文件。\",\n      \"typeError\": \"只允许上传图片文件。\"\n    },\n    \"helpUrl\": {\n      \"label\": \"帮助网址\",\n      \"description\": \"插件帮助文档的URL\"\n    },\n    \"positions\": {\n      \"label\": \"位置\",\n      \"description\": \"插件的位置\"\n    },\n    \"i18n\": {\n      \"label\": \"国际化\",\n      \"description\": \"插件的国际化，包含（名称、描述、详细描述）\"\n    },\n    \"url\": {\n      \"label\": \"网址\",\n      \"description\": \"插件的URL\"\n    },\n    \"autoCreateMember\": {\n      \"label\": \"自动创建成员\",\n      \"description\": \"自动创建成员用于插件\"\n    },\n    \"config\": {\n      \"label\": \"配置\",\n      \"description\": \"插件的配置\"\n    }\n  },\n  \"markdown\": {\n    \"write\": \"编写\",\n    \"preview\": \"预览\"\n  },\n  \"status\": {\n    \"reviewing\": \"审核中\",\n    \"published\": \"已发布\",\n    \"developing\": \"开发中\"\n  },\n  \"button\": {\n    \"submitApproved\": \"提交审核\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/sdk.json",
    "content": "{\n  \"common\": {\n    \"comingSoon\": \"即将推出\",\n    \"empty\": \"空\",\n    \"noRecords\": \"没有可用记录\",\n    \"unnamedRecord\": \"未命名记录\",\n    \"untitled\": \"无标题\",\n    \"cancel\": \"取消\",\n    \"confirm\": \"确认\",\n    \"back\": \"返回\",\n    \"done\": \"完成\",\n    \"create\": \"创建\",\n    \"search\": {\n      \"placeholder\": \"搜索...\",\n      \"empty\": \"未找到结果\"\n    },\n    \"readOnlyTip\": \"此视图已锁定，您可以启用<button>个人模式</button>来编辑视图选项，并且更改仅对您自己生效。\",\n    \"selectPlaceHolder\": \"请选择...\",\n    \"loading\": \"加载中...\",\n    \"loadMore\": \"加载更多\",\n    \"uploadFailed\": \"上传失败\",\n    \"rowCount\": \"{{count}}条记录\",\n    \"summary\": \"统计\",\n    \"summaryTip\": \"点击此行进行统计\",\n    \"actions\": \"操作\",\n    \"remove\": \"移除\",\n    \"runStatus\": {\n      \"success\": \"{{name}} 执行成功\",\n      \"failed\": \"{{name}} 执行失败\",\n      \"running\": \"{{name}} 运行中\"\n    },\n    \"resetSuccess\": \"重置成功\",\n    \"click\": \"点击\",\n    \"clickedCount\": \"{{label}}: 已点击 {{text}} 次\",\n    \"atLeastOne\": \"至少保留一个{{noun}}\"\n  },\n  \"notification\": {\n    \"title\": \"通知\"\n  },\n  \"aiError\": {\n    \"title\": \"AI 生成失败\",\n    \"retry\": \"重试\",\n    \"dismiss\": \"忽略\"\n  },\n  \"noun\": {\n    \"table\": \"表格\",\n    \"view\": \"视图\",\n    \"space\": \"空间\",\n    \"base\": \"数据库\",\n    \"field\": \"字段\",\n    \"record\": \"记录\",\n    \"automation\": \"自动化\",\n    \"app\": \"应用\",\n    \"user\": \"用户\",\n    \"recordHistory\": \"记录历史\",\n    \"you\": \"你\",\n    \"instance\": \"实例\",\n    \"enterprise\": \"企业\",\n    \"history\": \"历史\",\n    \"global\": \"全局\"\n  },\n  \"preview\": {\n    \"previewFileLimit\": \"预览暂不支持{{size}}MB以上的附件, 请下载后预览\",\n    \"loadFileError\": \"加载文件失败\"\n  },\n  \"undoRedo\": {\n    \"undo\": \"撤销\",\n    \"redo\": \"重做\",\n    \"undoFailed\": \"撤销失败\",\n    \"redoFailed\": \"重做失败\",\n    \"nothingToUndo\": \"无可撤销操作\",\n    \"nothingToRedo\": \"无可重做操作\",\n    \"undoSucceed\": \"撤销成功\",\n    \"redoSucceed\": \"重做成功\",\n    \"undoing\": \"撤销中...\",\n    \"redoing\": \"重做中...\"\n  },\n  \"editor\": {\n    \"attachment\": {\n      \"uploadDragOver\": \"释放以上传文件\",\n      \"uploadBaseTextPrefix\": \"点击上传 \",\n      \"uploadBaseText\": \"或粘贴或拖放文件到此处上传\",\n      \"uploadDragDefault\": \"粘贴或拖放文件到此处上传\",\n      \"upload\": \"上传文件\",\n      \"downloadAll\": \"下载全部\",\n      \"downloading\": \"下载中...\",\n      \"downloadSuccess\": \"下载完成\",\n      \"downloadFailed\": \"下载失败\",\n      \"downloadCancelled\": \"下载已取消\",\n      \"requireHttps\": \"批量下载需要 HTTPS 环境，请通过 HTTPS 或 localhost 访问\"\n    },\n    \"date\": {\n      \"placeholder\": \"选择日期\",\n      \"today\": \"今天\",\n      \"rangePlaceholder\": \"选择日期范围\",\n      \"rangeSelected\": \"已选\",\n      \"invalidTimeRange\": \"结束时间必须晚于开始时间\",\n      \"from\": \"开始\",\n      \"to\": \"结束\"\n    },\n    \"formula\": {\n      \"title\": \"公式编辑器\",\n      \"guideSyntax\": \"语法\",\n      \"guideExample\": \"示例\",\n      \"helperExample\": \"示例：\",\n      \"fieldValue\": \"返回 {{fieldName}} 字段的值.\",\n      \"placeholder\": \"请输入公式\",\n      \"placeholderForAIPrompt\": \"请描述你想生成的公式\",\n      \"editExpression\": \"编辑公式\",\n      \"generateExpressionByAI\": \"AI 生成公式\",\n      \"inputPrompt\": \"输入指令\",\n      \"generateExpression\": \"生成公式\",\n      \"generatingByAI\": \"AI 正在生成公式中...\",\n      \"generatedExpressionTips\": \"生成后，点击应用即可快速插入公式\",\n      \"action\": {\n        \"generating\": \"生成中...\",\n        \"generate\": \"生成\",\n        \"apply\": \"应用\"\n      },\n      \"expressionRequired\": \"公式不能为空\"\n    },\n    \"link\": {\n      \"placeholder\": \"选择要链接的记录\",\n      \"searchPlaceholder\": \"搜索记录\",\n      \"allFields\": \"所有字段\",\n      \"globalSearch\": \"全局搜索\",\n      \"fieldSearch\": \"字段搜索\",\n      \"maxFieldTips\": \"最多支持 {{count}} 个字段，多余字段将被忽略\",\n      \"create\": \"添加记录\",\n      \"selectRecord\": \"选择记录\",\n      \"all\": \"全部\",\n      \"selected\": \"已选择\",\n      \"expandRecordError\": \"无权查看此记录\",\n      \"alreadyOpen\": \"此记录已被打开\",\n      \"linkedTo\": \"跳转至\",\n      \"goToForeignTable\": \"跳转至关联表\",\n      \"foreignTableIdRequired\": \"关联表不能为空\",\n      \"linkFieldIdRequired\": \"关联字段不能为空\",\n      \"selectTooManyRecords\": \"选择的记录不应超过 {{maxCount}}\",\n      \"relationshipRequired\": \"关系不能为空\",\n      \"rangeSelectFailed\": \"范围选择记录失败\"\n    },\n    \"user\": {\n      \"searchPlaceholder\": \"通过姓名查找用户\",\n      \"notify\": \"通知被选择的用户\"\n    },\n    \"select\": {\n      \"addOption\": \"添加选项 '{{option}}'\",\n      \"choicesNameRequired\": \"选项名称不能为空\"\n    },\n    \"lookup\": {\n      \"lookupFieldIdRequired\": \"必须选择查找字段\",\n      \"lookupOptionsNotAllowed\": \"非从关联的表中查找或者汇总时，不能配置查找选项\",\n      \"lookupOptionsRequired\": \"必须配置查找选项\",\n      \"refineOptionsError\": \"解析查找选项配置错误 {{message}}\"\n    },\n    \"rollup\": {\n      \"expressionRequired\": \"公式不能为空\",\n      \"unsupportedTip\": \"汇总只支持关联和汇总字段\"\n    },\n    \"conditionalRollup\": {\n      \"filterRequired\": \"筛选条件至少需要包含一个条件\"\n    },\n    \"conditionalLookup\": {\n      \"filterRequired\": \"条件查找必须至少配置一个筛选条件\"\n    },\n    \"aiConfig\": {\n      \"modelKeyRequired\": \"模型不能为空\",\n      \"typeNotSupported\": \"不支持的 AI 类型\",\n      \"sourceFieldIdRequired\": \"来源字段不能为空\",\n      \"targetLanguageRequired\": \"目标语言不能为空\",\n      \"promptRequired\": \"提示词不能为空\"\n    },\n    \"error\": {\n      \"refineOptionsError\": \"解析字段配置错误 {{message}}\",\n      \"optionsRequired\": \"字段选项不能为空\"\n    }\n  },\n  \"filter\": {\n    \"label\": \"筛选\",\n    \"displayLabel\": \"筛选字段 \",\n    \"displayLabel_other\": \"筛选({{totalCount}})\",\n    \"addCondition\": \"添加条件\",\n    \"addConditionGroup\": \"添加条件组\",\n    \"nestedLimitTip\": \"条件组最多支持{{depth}}层嵌套\",\n    \"linkInputPlaceholder\": \"输入值\",\n    \"groupDescription\": \"以下任一条件为真…\",\n    \"currentUser\": \"我（当前用户）\",\n    \"tips\": {\n      \"scope\": \"仅作用于当前视图的记录\"\n    },\n    \"invalidateSelected\": \"已删除选项\",\n    \"invalidateSelectedTips\": \"该选项已被删除，请重新选择\",\n    \"default\": {\n      \"empty\": \"当前没有应用任何筛选条件\",\n      \"placeholder\": \"请输入\"\n    },\n    \"conjunction\": {\n      \"and\": \"且\",\n      \"or\": \"或\",\n      \"where\": \"当\",\n      \"meetingAll\": \"满足所有条件\",\n      \"meetingAny\": \"满足任一条件\"\n    },\n    \"operator\": {\n      \"is\": \"等于\",\n      \"isNot\": \"不等于\",\n      \"contains\": \"包含\",\n      \"doesNotContain\": \"不包含\",\n      \"isEmpty\": \"为空\",\n      \"isNotEmpty\": \"不为空\",\n      \"isGreater\": \"大于\",\n      \"isGreaterEqual\": \"大于等于\",\n      \"isLess\": \"小于\",\n      \"isLessEqual\": \"小于等于\",\n      \"isAnyOf\": \"属于\",\n      \"isNoneOf\": \"不属于\",\n      \"hasAnyOf\": \"包含任意一个\",\n      \"hasAllOf\": \"包含所有\",\n      \"hasNoneOf\": \"不包含任何\",\n      \"isExactly\": \"等于\",\n      \"isWithIn\": \"在...之内\",\n      \"isBefore\": \"早于\",\n      \"isAfter\": \"晚于\",\n      \"isOnOrBefore\": \"早于或等于\",\n      \"isOnOrAfter\": \"晚于或等于\",\n      \"number\": {\n        \"is\": \"=\",\n        \"isNot\": \"≠\",\n        \"isGreater\": \">\",\n        \"isGreaterEqual\": \"≥\",\n        \"isLess\": \"<\",\n        \"isLessEqual\": \"≤\"\n      }\n    },\n    \"conditionalRollup\": {\n      \"switchToField\": \"使用字段值\",\n      \"switchToValue\": \"输入固定值\"\n    },\n    \"component\": {\n      \"date\": {\n        \"today\": \"今天\",\n        \"tomorrow\": \"明天\",\n        \"yesterday\": \"昨天\",\n        \"oneWeekAgo\": \"一周前\",\n        \"oneWeekFromNow\": \"一周后\",\n        \"oneMonthAgo\": \"一个月前\",\n        \"oneMonthFromNow\": \"一个月后\",\n        \"daysAgo\": \"几天前\",\n        \"daysFromNow\": \"几天后\",\n        \"exactDate\": \"具体日期\",\n        \"exactFormatDate\": \"具体日期（格式化）\",\n        \"dateRange\": \"日期范围\",\n        \"currentWeek\": \"本周\",\n        \"currentMonth\": \"本月\",\n        \"currentYear\": \"今年\",\n        \"lastWeek\": \"上周\",\n        \"lastMonth\": \"上个月\",\n        \"lastYear\": \"去年\",\n        \"nextWeekPeriod\": \"下周\",\n        \"nextMonthPeriod\": \"下个月\",\n        \"nextYearPeriod\": \"明年\",\n        \"pastWeek\": \"过去一周\",\n        \"pastMonth\": \"过去一个月\",\n        \"pastYear\": \"过去一年\",\n        \"nextWeek\": \"未来一周\",\n        \"nextMonth\": \"未来一个月\",\n        \"nextYear\": \"未来一年\",\n        \"pastNumberOfDays\": \"过去几天\",\n        \"nextNumberOfDays\": \"接下来几天\"\n      }\n    }\n  },\n  \"color\": {\n    \"label\": \"颜色\"\n  },\n  \"rowHeight\": {\n    \"short\": \"低\",\n    \"medium\": \"中等\",\n    \"tall\": \"高\",\n    \"extraTall\": \"超高\",\n    \"title\": \"行高\"\n  },\n  \"fieldNameConfig\": {\n    \"title\": \"字段名行数\",\n    \"displayLines\": \"{{count}} 行\"\n  },\n  \"share\": {\n    \"title\": \"分享\"\n  },\n  \"extensions\": {\n    \"title\": \"插件\"\n  },\n  \"hidden\": {\n    \"label\": \"字段配置\",\n    \"configLabel_one\": \"字段配置({{count}} 隐藏)\",\n    \"configLabel_other\": \"字段配置({{count}} 隐藏)\",\n    \"configLabel_other_visible\": \"字段配置({{count}} 显示)\",\n    \"showAll\": \"显示全部\",\n    \"hideAll\": \"隐藏全部\",\n    \"primaryKey\": \"主字段：用于标识记录，不可隐藏或删除\\n其值会在关联记录中显示\"\n  },\n  \"expandRecord\": {\n    \"copy\": \"复制到剪贴板\",\n    \"duplicateRecord\": \"复制记录\",\n    \"copyRecordUrl\": \"复制记录链接\",\n    \"deleteRecord\": \"删除记录\",\n    \"addRecordComment\": \"添加评论\",\n    \"viewRecordHistory\": \"查看记录历史\",\n    \"recordHistory\": {\n      \"hiddenRecordHistory\": \"隐藏记录历史\",\n      \"showRecordHistory\": \"显示记录历史\",\n      \"createdTime\": \"操作时间\",\n      \"createdBy\": \"操作人\",\n      \"before\": \"变更前\",\n      \"after\": \"变更后\",\n      \"viewRecord\": \"查看记录\"\n    },\n    \"showHiddenFields\": \"显示 {{count}} 个隐藏字段\",\n    \"hideHiddenFields\": \"隐藏 {{count}} 个隐藏字段\",\n    \"showMore\": \"显示更多\",\n    \"showLess\": \"收起\"\n  },\n  \"sort\": {\n    \"label\": \"排序\",\n    \"displayLabel_one\": \"排序({{count}})\",\n    \"displayLabel_other\": \"排序({{count}})\",\n    \"setTips\": \"设置排序条件\",\n    \"addButton\": \"添加另一个排序\",\n    \"autoSort\": \"自动排序\",\n    \"selectASCLabel\": \"选项顺序\",\n    \"selectDESCLabel\": \"选项倒序\"\n  },\n  \"group\": {\n    \"label\": \"分组\",\n    \"displayLabel_one\": \"分组({{count}})\",\n    \"displayLabel_other\": \"分组({{count}})\",\n    \"setTips\": \"分组方式\",\n    \"addButton\": \"添加另一个分组\"\n  },\n  \"field\": {\n    \"title\": {\n      \"singleLineText\": \"单行文本\",\n      \"longText\": \"长文本\",\n      \"singleSelect\": \"单选\",\n      \"number\": \"数字\",\n      \"multipleSelect\": \"多选\",\n      \"link\": \"关联\",\n      \"formula\": \"公式\",\n      \"date\": \"日期\",\n      \"createdTime\": \"创建时间\",\n      \"lastModifiedTime\": \"最近修改时间\",\n      \"attachment\": \"附件\",\n      \"checkbox\": \"勾选\",\n      \"rollup\": \"汇总\",\n      \"conditionalRollup\": \"条件汇总\",\n      \"user\": \"用户\",\n      \"rating\": \"评分\",\n      \"autoNumber\": \"自增数字\",\n      \"lookup\": \"从关联的表中查找\",\n      \"conditionalLookup\": \"条件查找\",\n      \"button\": \"按钮\",\n      \"createdBy\": \"创建人\",\n      \"lastModifiedBy\": \"最近修改人\"\n    },\n    \"description\": {\n      \"singleLineText\": \"存储简短的文本，如名称或标题。\",\n      \"longText\": \"记录较长的备注和描述。\",\n      \"singleSelect\": \"从预设列表中选择一个选项。\",\n      \"number\": \"跟踪带格式的数值。\",\n      \"multipleSelect\": \"为记录添加多个标签选项。\",\n      \"link\": \"在表格之间关联记录。\",\n      \"formula\": \"用表达式计算字段值。\",\n      \"date\": \"记录特定日期或时间。\",\n      \"createdTime\": \"显示记录的创建时间。\",\n      \"lastModifiedTime\": \"显示记录最近的更新时间。\",\n      \"attachment\": \"上传文件或 AI 生成图片，支持 🍌 Nano banana pro 等模型\",\n      \"checkbox\": \"用开关标记是否完成。\",\n      \"rollup\": \"对关联记录进行汇总计算。\",\n      \"conditionalRollup\": \"根据条件汇总数据。\",\n      \"user\": \"将记录分配给工作区成员。\",\n      \"rating\": \"用可配置的图标为项目评分。\",\n      \"autoNumber\": \"自动分配递增编号。\",\n      \"lookup\": \"显示来自关联记录的字段值。\",\n      \"conditionalLookup\": \"显示符合筛选条件的关联记录值。\",\n      \"button\": \"通过可点击按钮触发操作。\",\n      \"createdBy\": \"显示是谁创建了记录。\",\n      \"lastModifiedBy\": \"显示最近编辑记录的人。\"\n    },\n    \"link\": {\n      \"oneWay\": \"单向\",\n      \"twoWay\": \"双向\"\n    },\n    \"button\": {\n      \"confirm\": {\n        \"title\": \"操作确认\",\n        \"description\": \"你确定要执行该按钮操作吗？\"\n      }\n    }\n  },\n  \"permission\": {\n    \"actionDescription\": {\n      \"spaceCreate\": \"创建空间\",\n      \"spaceDelete\": \"删除空间\",\n      \"spaceRead\": \"查看空间\",\n      \"spaceUpdate\": \"更新空间\",\n      \"spaceInviteEmail\": \"通过电子邮件邀请进入空间\",\n      \"spaceInviteLink\": \"通过链接邀请进入空间\",\n      \"spaceGrantRole\": \"在空间中授予角色\",\n      \"baseCreate\": \"创建数据库\",\n      \"baseDelete\": \"删除数据库\",\n      \"baseRead\": \"查看数据库\",\n      \"baseReadAll\": \"查看所有数据库\",\n      \"baseUpdate\": \"更新数据库\",\n      \"baseInviteEmail\": \"通过电子邮件邀请进入数据库\",\n      \"baseInviteLink\": \"通过链接邀请进入数据库\",\n      \"baseTableImport\": \"导入到数据库\",\n      \"baseAuthorityMatrixConfig\": \"配置权限矩阵\",\n      \"baseDbConnect\": \"连接数据库\",\n      \"tableCreate\": \"创建表格\",\n      \"tableRead\": \"查看表格\",\n      \"tableDelete\": \"删除表格\",\n      \"tableUpdate\": \"更新表格\",\n      \"tableImport\": \"导入数据到表格\",\n      \"tableExport\": \"导出表格数据\",\n      \"tableTrashRead\": \"查看表格回收站\",\n      \"tableTrashUpdate\": \"更新表格回收站\",\n      \"tableTrashReset\": \"清空表格回收站\",\n      \"viewCreate\": \"创建视图\",\n      \"viewDelete\": \"删除视图\",\n      \"viewRead\": \"查看视图\",\n      \"viewUpdate\": \"更新视图\",\n      \"viewShare\": \"分享视图\",\n      \"fieldCreate\": \"创建字段\",\n      \"fieldDelete\": \"删除字段\",\n      \"fieldRead\": \"查看字段\",\n      \"fieldUpdate\": \"更新字段\",\n      \"recordCreate\": \"创建记录\",\n      \"recordComment\": \"对记录进行评论\",\n      \"recordDelete\": \"删除记录\",\n      \"recordRead\": \"查看记录\",\n      \"recordUpdate\": \"更新记录\",\n      \"recordCopy\": \"复制记录\",\n      \"automationCreate\": \"创建自动化\",\n      \"automationDelete\": \"删除自动化\",\n      \"automationRead\": \"读取自动化\",\n      \"automationUpdate\": \"更新自动化\",\n      \"appCreate\": \"创建应用\",\n      \"appDelete\": \"删除应用\",\n      \"appRead\": \"查看应用\",\n      \"appUpdate\": \"更新应用\",\n      \"userProfileRead\": \"查看当前用户\",\n      \"userEmailRead\": \"查看当前用户电子邮件\",\n      \"userIntegrations\": \"管理用户集成\",\n      \"recordHistoryRead\": \"查看记录历史\",\n      \"baseQuery\": \"查询数据库\",\n      \"instanceRead\": \"查看实例\",\n      \"instanceUpdate\": \"更新实例\",\n      \"enterpriseRead\": \"查看企业配置\",\n      \"enterpriseUpdate\": \"更新企业配置\"\n    }\n  },\n  \"formula\": {\n    \"SUM\": {\n      \"summary\": \"将数字相加。等同于 number1 + number2 + ...\",\n      \"example\": \"SUM(100, 200, 300) => 600\"\n    },\n    \"AVERAGE\": {\n      \"summary\": \"返回数字的平均值。\",\n      \"example\": \"AVERAGE(100, 200, 300) => 200\"\n    },\n    \"MAX\": {\n      \"summary\": \"返回给定数字中的最大值。\",\n      \"example\": \"MAX(100, 200, 300) => 300\"\n    },\n    \"MIN\": {\n      \"summary\": \"返回给定数字中的最小值。\",\n      \"example\": \"MIN(100, 200, 300) => 100\"\n    },\n    \"ROUND\": {\n      \"summary\": \"将数值四舍五入到指定的小数位数。（具体来说，ROUND 会四舍五入到指定精度的最接近的整数，如果是 0.5，则向正无穷大方向舍入。）\",\n      \"example\": \"ROUND(1.99, 0) => 2\\nROUND(16.8, -1) => 20\"\n    },\n    \"ROUNDUP\": {\n      \"summary\": \"将数值向上舍入到指定的小数位数，即远离零。（必须为精度指定一个值，否则函数将不起作用。）\",\n      \"example\": \"ROUNDUP(1.1, 0) => 2\\nROUNDUP(-1.1, 0) => -2\"\n    },\n    \"ROUNDDOWN\": {\n      \"summary\": \"将数值向下舍入到指定的小数位数，即向零。（必须为精度指定一个值，否则函数将不起作用。）\",\n      \"example\": \"ROUNDDOWN(1.9, 0) => 1\\nROUNDDOWN(-1.9, 0) => -1\"\n    },\n    \"CEILING\": {\n      \"summary\": \"返回大于或等于该值的最接近的 significance 的整数倍。如果未提供 significance，则假定为 1。\",\n      \"example\": \"CEILING(2.49) => 3\\nCEILING(2.49, 1) => 2.5\\nCEILING(2.49, -1) => 10\"\n    },\n    \"FLOOR\": {\n      \"summary\": \"返回小于或等于该值的最接近的 significance 的整数倍。如果未提供 significance，则假定为 1。\",\n      \"example\": \"FLOOR(2.49) => 2\\nFLOOR(2.49, 1) => 2.4\\nFLOOR(2.49, -1) => 0\"\n    },\n    \"EVEN\": {\n      \"summary\": \"返回大于或等于指定值的最小偶数。\",\n      \"example\": \"EVEN(0.1) => 2\\nEVEN(-0.1) => -2\"\n    },\n    \"ODD\": {\n      \"summary\": \"将正值向上舍入到最接近的奇数，将负值向下舍入到最接近的奇数。\",\n      \"example\": \"ODD(0.1) => 1\\nODD(-0.1) => -1\"\n    },\n    \"INT\": {\n      \"summary\": \"将数字向下舍入到最接近的整数。\",\n      \"example\": \"INT(1.9) => 1\\nINT(-1.9) => -2\"\n    },\n    \"ABS\": {\n      \"summary\": \"返回绝对值。\",\n      \"example\": \"ABS(-1) => 1\"\n    },\n    \"SQRT\": {\n      \"summary\": \"返回非负数的平方根。\",\n      \"example\": \"SQRT(4) => 2\"\n    },\n    \"POWER\": {\n      \"summary\": \"计算指定底数的指定幂。\",\n      \"example\": \"POWER(2) => 4\"\n    },\n    \"EXP\": {\n      \"summary\": \"计算欧拉数（e）的指定幂。\",\n      \"example\": \"EXP(0) => 1\\nEXP(1) => 2.718\"\n    },\n    \"LOG\": {\n      \"summary\": \"计算指定底数的值的对数。如果未指定底数，则默认为 10。\",\n      \"example\": \"LOG(100) => 2\\nLOG(1024, 2) => 10\"\n    },\n    \"MOD\": {\n      \"summary\": \"返回第一个参数除以第二个参数后的余数。\",\n      \"example\": \"MOD(9, 2) => 1\\nMOD(9, 3) => 0\"\n    },\n    \"VALUE\": {\n      \"summary\": \"将文本字符串转换为数字。\",\n      \"example\": \"VALUE(\\\"$1,000,000\\\") => 1000000\"\n    },\n    \"CONCATENATE\": {\n      \"summary\": \"将各种类型的参数连接成单个文本值。\",\n      \"example\": \"CONCATENATE(\\\"Hello \\\", \\\"Teable\\\") => Hello Teable\"\n    },\n    \"FIND\": {\n      \"summary\": \"从可选的 startFromPosition 开始，在 whereToSearch 字符串中查找 stringToFind 的出现位置。（startFromPosition 默认为 0。）如果未找到 stringToFind，结果将为 0。\",\n      \"example\": \"FIND(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nFIND(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => 0\"\n    },\n    \"SEARCH\": {\n      \"summary\": \"从可选的 startFromPosition 开始，在 whereToSearch 字符串中搜索 stringToFind 的出现位置。（startFromPosition 默认为 0。）如果未找到 stringToFind，结果将为空。\\n与 FIND() 类似，但如果未找到 stringToFind，FIND() 返回 0 而不是空。\",\n      \"example\": \"SEARCH(\\\"Teable\\\", \\\"Hello Teable\\\") => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 5) => 7\\nSEARCH(\\\"Teable\\\", \\\"Hello Teable\\\", 10) => \\\"\\\"\"\n    },\n    \"MID\": {\n      \"summary\": \"从 whereToStart 开始提取 count 个字符的子字符串。\",\n      \"example\": \"MID(\\\"Hello Teable\\\", 6, 6) => \\\"Teable\\\"\"\n    },\n    \"LEFT\": {\n      \"summary\": \"从字符串开头提取 howMany 个字符。\",\n      \"example\": \"LEFT(\\\"2023-09-06\\\", 4) => \\\"2023\\\"\"\n    },\n    \"RIGHT\": {\n      \"summary\": \"从字符串结尾提取 howMany 个字符。\",\n      \"example\": \"RIGHT(\\\"2023-09-06\\\", 5) => \\\"09-06\\\"\"\n    },\n    \"REPLACE\": {\n      \"summary\": \"用替换文本替换从起始字符开始的指定数量的字符。\\n（如果你想查找并替换所有出现的 old_text 为 new_text，请参见 SUBSTITUTE()。）\",\n      \"example\": \"REPLACE(\\\"Hello Table\\\", 7, 5, \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"REGEXP_REPLACE\": {\n      \"summary\": \"用替换文本替换所有匹配正则表达式的子字符串。\",\n      \"example\": \"REGEXP_REPLACE(\\\"Hello Table\\\", \\\"H.* \\\", \\\"\\\") => \\\"Teable\\\"\"\n    },\n    \"SUBSTITUTE\": {\n      \"summary\": \"替换 old_text 的出现为 new_text。\\n你可以选择指定一个索引号（从 1 开始）来仅替换特定出现的 old_text。如果未指定索引号，则将替换所有出现的 old_text。\",\n      \"example\": \"SUBSTITUTE(\\\"Hello Table\\\", \\\"Table\\\", \\\"Teable\\\") => \\\"Hello Teable\\\"\"\n    },\n    \"LOWER\": {\n      \"summary\": \"将字符串转换为小写。\",\n      \"example\": \"LOWER(\\\"Hello Teable\\\") => \\\"hello teable\\\"\"\n    },\n    \"UPPER\": {\n      \"summary\": \"将字符串转换为大写。\",\n      \"example\": \"UPPER(\\\"Hello Teable\\\") => \\\"HELLO TEABLE\\\"\"\n    },\n    \"REPT\": {\n      \"summary\": \"将字符串重复指定的次数。\",\n      \"example\": \"REPT(\\\"Hello!\\\") => \\\"Hello!Hello!Hello!\\\"\"\n    },\n    \"TRIM\": {\n      \"summary\": \"删除字符串开头和结尾的空白字符。\",\n      \"example\": \"TRIM(\\\" Hello \\\") => \\\"Hello\\\"\"\n    },\n    \"LEN\": {\n      \"summary\": \"返回字符串的长度。\",\n      \"example\": \"LEN(\\\"Hello\\\") => 5\"\n    },\n    \"T\": {\n      \"summary\": \"如果参数是文本则返回该参数，否则返回空白。\",\n      \"example\": \"T(\\\"Hello\\\") => \\\"Hello\\\"\\nT(100) => null\"\n    },\n    \"ENCODE_URL_COMPONENT\": {\n      \"summary\": \"将某些字符替换为编码等效项，用于构造 URL 或 URI。不编码以下字符：- _ . ~\",\n      \"example\": \"ENCODE_URL_COMPONENT(\\\"Hello Teable\\\") => \\\"Hello%20Teable\\\"\"\n    },\n    \"IF\": {\n      \"summary\": \"如果逻辑参数为真，则返回 value1，否则返回 value2。也可用于制作嵌套的 IF 语句。\\n还可用于检查单元格是否为空/是否为空。\",\n      \"example\": \"IF(2 > 1, \\\"A\\\", \\\"B\\\") => \\\"A\\\"\\nIF(2 > 1, TRUE, FALSE) => TRUE\"\n    },\n    \"SWITCH\": {\n      \"summary\": \"接受一个表达式、该表达式可能的值列表，以及对应每个值的结果。还可以接受一个默认值，如果表达式输入与任何定义的模式都不匹配，则返回该默认值。在许多情况下，SWITCH() 可以用来替代嵌套的 IF() 公式。\",\n      \"example\": \"SWITCH(\\\"B\\\", \\\"A\\\", \\\"Value A\\\", \\\"B\\\", \\\"Value B\\\", \\\"Default Value\\\") => \\\"Value B\\\"\"\n    },\n    \"AND\": {\n      \"summary\": \"如果所有参数都为真，则返回真，否则返回假。\",\n      \"example\": \"AND(1 < 2, 5 > 3) => true\\nAND(1 < 2, 5 < 3) => false\"\n    },\n    \"OR\": {\n      \"summary\": \"如果任何一个参数为真，则返回真。\",\n      \"example\": \"OR(1 < 2, 5 < 3) => true\\nOR(1 > 2, 5 < 3) => false\"\n    },\n    \"XOR\": {\n      \"summary\": \"如果奇数个参数为真，则返回真。\",\n      \"example\": \"XOR(1 < 2, 5 < 3, 8 < 10) => false\\nXOR(1 > 2, 5 < 3, 8 < 10) => true\"\n    },\n    \"NOT\": {\n      \"summary\": \"反转其参数的逻辑值。\",\n      \"example\": \"NOT(1 < 2) => false\\nNOT(1 > 2) => true\"\n    },\n    \"BLANK\": {\n      \"summary\": \"返回空值。\",\n      \"example\": \"BLANK() => null\\nIF(2 > 3, \\\"Yes\\\", BLANK()) => null\"\n    },\n    \"ERROR\": {\n      \"summary\": \"返回错误值。\",\n      \"example\": \"IF(2 > 3, \\\"Yes\\\", ERROR(\\\"Calculation\\\")) => \\\"#ERROR: Calculation\\\"\"\n    },\n    \"IS_ERROR\": {\n      \"summary\": \"如果表达式导致错误，则返回真。\",\n      \"example\": \"IS_ERROR(ERROR()) => true\"\n    },\n    \"TODAY\": {\n      \"summary\": \"返回当前日期。\",\n      \"example\": \"TODAY() => \\\"2023-09-08 00:00\\\"\"\n    },\n    \"NOW\": {\n      \"summary\": \"返回当前日期和时间。\",\n      \"example\": \"NOW() => \\\"2023-09-08 16:50\\\"\"\n    },\n    \"YEAR\": {\n      \"summary\": \"返回日期时间的四位数年份。\",\n      \"example\": \"YEAR(\\\"2023-09-08\\\") => 2023\"\n    },\n    \"MONTH\": {\n      \"summary\": \"返回日期时间的月份，作为 1（一月）到 12（十二月）之间的数字。\",\n      \"example\": \"MONTH(\\\"2023-09-08\\\") => 9\"\n    },\n    \"WEEKNUM\": {\n      \"summary\": \"返回一年中的周数。\",\n      \"example\": \"WEEKNUM(\\\"2023-09-08\\\") => 36\"\n    },\n    \"WEEKDAY\": {\n      \"summary\": \"返回星期几，作为 0 到 6 之间的整数（包括 0 和 6）。你可以选择提供第二个参数（\\\"Sunday\\\" 或 \\\"Monday\\\"）来指定一周的开始日。如果省略，默认以周日开始。例如：\\nWEEKDAY(TODAY(), \\\"Monday\\\")\",\n      \"example\": \"WEEKDAY(\\\"2023-09-08\\\") => 5\"\n    },\n    \"DAY\": {\n      \"summary\": \"返回日期时间的月份中的日期，形式为 1-31 之间的数字。\",\n      \"example\": \"DAY(\\\"2023-09-08\\\") => 8\"\n    },\n    \"HOUR\": {\n      \"summary\": \"返回日期时间的小时，作为 0（12:00am）到 23（11:00pm）之间的数字。\",\n      \"example\": \"HOUR(\\\"2023-09-08 16:50\\\") => 16\"\n    },\n    \"MINUTE\": {\n      \"summary\": \"返回日期时间的分钟，作为 0 到 59 之间的整数。\",\n      \"example\": \"MINUTE(\\\"2023-09-08 16:50\\\") => 50\"\n    },\n    \"SECOND\": {\n      \"summary\": \"返回日期时间的秒数，作为 0 到 59 之间的整数。\",\n      \"example\": \"SECOND(\\\"2023-09-08 16:50:30\\\") => 30\"\n    },\n    \"FROMNOW\": {\n      \"summary\": \"计算当前日期与另一个日期之间的天数差。\",\n      \"example\": \"FROMNOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"TONOW\": {\n      \"summary\": \"计算当前日期与另一个日期之间的天数差。\",\n      \"example\": \"TONOW({Date}, \\\"day\\\") => 25\"\n    },\n    \"DATETIME_DIFF\": {\n      \"summary\": \"以指定的单位返回日期时间之间的差异。默认单位为 \\\"day\\\"。\\n支持的单位：\\\"millisecond\\\" (ms)、\\\"second\\\" (s)、\\\"minute\\\" (m)、\\\"hour\\\" (h)、\\\"day\\\" (d)、\\\"week\\\" (w)、\\\"month\\\" (M)、\\\"year\\\" (y)。\\n日期时间之间的差异是通过从 [date1] 减去 [date2] 来确定的。这意味着如果 [date2] 晚于 [date1]，结果值将为负数。\",\n      \"example\": \"DATETIME_DIFF(\\\"2023-09-08\\\", \\\"2022-08-01\\\", \\\"day\\\") => 403\"\n    },\n    \"WORKDAY\": {\n      \"summary\": \"返回从起始日期开始的工作日，不包括指定的节假日\",\n      \"example\": \"WORKDAY(\\\"2023-09-08\\\", 200) => \\\"2024-06-14 00:00:00\\\"\\nWORKDAY(\\\"2023-09-08\\\", 200, \\\"2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25\\\") => \\\"2024-06-20 00:00:00\\\"\"\n    },\n    \"WORKDAY_DIFF\": {\n      \"summary\": \"返回 date1 和 date2 之间的工作日数。工作日不包括周末和可选的节假日列表，格式为以逗号分隔的 ISO 格式日期字符串。\",\n      \"example\": \"WORKDAY_DIFF(\\\"2023-06-18\\\", \\\"2023-10-01\\\") => 75\\nWORKDAY(\\\"2023-06-18\\\", \\\"2023-10-01\\\", \\\"2023-07-12, 2023-08-18, 2023-08-19\\\") => 73\"\n    },\n    \"IS_SAME\": {\n      \"summary\": \"比较两个日期直到指定的单位，并确定它们是否相同。如果是，则返回 true，否则返回 false。\",\n      \"example\": \"IS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => false\\nIS_SAME(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => true\"\n    },\n    \"IS_AFTER\": {\n      \"summary\": \"确定 date1 是否晚于 date2。如果是，则返回 true，否则返回 false。\",\n      \"example\": \"IS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\") => true\\nIS_AFTER(\\\"2023-09-10\\\", \\\"2023-09-08\\\", \\\"month\\\") => false\"\n    },\n    \"IS_BEFORE\": {\n      \"summary\": \"确定 date1 是否早于 date2。如果是，则返回 true，否则返回 false。\",\n      \"example\": \"IS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\") => true\\nIS_BEFORE(\\\"2023-09-08\\\", \\\"2023-09-10\\\", \\\"month\\\") => false\"\n    },\n    \"DATE_ADD\": {\n      \"summary\": \"向日期时间添加指定的 \\\"count\\\" 单位。\",\n      \"example\": \"DATE_ADD(\\\"2023-09-08 18:00:00\\\", 10, \\\"day\\\") => \\\"2023-09-18 18:00:00\\\"\"\n    },\n    \"DATESTR\": {\n      \"summary\": \"将日期时间格式化为字符串（YYYY-MM-DD）。\",\n      \"example\": \"DATESTR(\\\"2023/09/08\\\") => \\\"2023-09-08\\\"\"\n    },\n    \"TIMESTR\": {\n      \"summary\": \"将日期时间格式化为仅时间的字符串（HH:mm:ss）。\",\n      \"example\": \"DATESTR(\\\"2023/09/08 16:50:30\\\") => \\\"16:50:30\\\"\"\n    },\n    \"DATETIME_FORMAT\": {\n      \"summary\": \"将日期时间格式化为指定字符串。支持的说明符：YY、YYYY、M、MM、MMM、MMMM、D、DD、d、dd、ddd、dddd、H、HH、h、hh、m、mm、s、ss、SSS、Z、ZZ、A、a、LT、LTS、L、LL、LLL、LLLL、l、ll、lll、llll。\",\n      \"example\": \"DATETIME_FORMAT(\\\"2023-09-08\\\", \\\"DD-MM-YYYY\\\") => \\\"08-09-2023\\\"\"\n    },\n    \"DATETIME_PARSE\": {\n      \"summary\": \"将文本字符串解释为结构化日期，可选输入格式和区域设置参数。输出格式始终为 \\\"M/D/YYYY h:mm a\\\"。\",\n      \"example\": \"DATETIME_PARSE(\\\"8 Sep 2023 18:00\\\", \\\"D MMM YYYY HH:mm\\\") => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"CREATED_TIME\": {\n      \"summary\": \"返回当前记录的创建时间。\",\n      \"example\": \"CREATED_TIME() => \\\"2023-09-08 18:00:00\\\"\"\n    },\n    \"LAST_MODIFIED_TIME\": {\n      \"summary\": \"返回用户在表格的非计算字段中进行的最近修改的日期和时间，可选地传入一个字段以仅跟踪该列的更新。\",\n      \"example\": \"LAST_MODIFIED_TIME() => \\\"2023-09-08 18:00:00\\\"; LAST_MODIFIED_TIME({Due Date}) => \\\"2023-09-09 12:00:00\\\"\"\n    },\n    \"COUNTALL\": {\n      \"summary\": \"返回包括文本和空白在内的所有元素的数量。\",\n      \"example\": \"COUNTALL(100, 200, \\\"\\\", \\\"Teable\\\", TRUE()) => 5\"\n    },\n    \"COUNTA\": {\n      \"summary\": \"返回非空值的数量。此函数同时计算数字和文本值。\",\n      \"example\": \"COUNTA(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 4\"\n    },\n    \"COUNT\": {\n      \"summary\": \"返回数字项的数量。\",\n      \"example\": \"COUNT(100, 200, 300, \\\"\\\", \\\"Teable\\\", TRUE) => 3\"\n    },\n    \"ARRAY_JOIN\": {\n      \"summary\": \"使用分隔符将汇总项数组连接成一个字符串。\",\n      \"example\": \"ARRAY_JOIN([\\\"Tom\\\", \\\"Jerry\\\", \\\"Mike\\\"], \\\"; \\\") => \\\"Tom; Jerry; Mike\\\"\"\n    },\n    \"ARRAY_UNIQUE\": {\n      \"summary\": \"返回数组中的唯一项。\",\n      \"example\": \"ARRAY_UNIQUE([1, 2, 3, 2, 1]) => [1, 2, 3]\"\n    },\n    \"ARRAY_FLATTEN\": {\n      \"summary\": \"通过移除任何数组嵌套来扁平化数组。所有项目成为单个数组的元素。\",\n      \"example\": \"ARRAY_FLATTEN([1, 2, \\\" \\\", 3, true], [\\\"ABC\\\"]) => [1, 2, 3, \\\" \\\", true, \\\"ABC\\\"]\"\n    },\n    \"ARRAY_COMPACT\": {\n      \"summary\": \"从数组中移除空字符串和空值。保留 \\\"false\\\" 和包含一个或多个空白字符的字符串。\",\n      \"example\": \"ARRAY_COMPACT([1, 2, 3, \\\"\\\", null, \\\"ABC\\\"]) => [1, 2, 3, \\\"ABC\\\"]\"\n    },\n    \"TEXT_ALL\": {\n      \"summary\": \"返回所有字符串\",\n      \"example\": \"TEXT_ALL(\\\"t\\\") => t\"\n    },\n    \"RECORD_ID\": {\n      \"summary\": \"返回当前记录的 ID。\",\n      \"example\": \"RECORD_ID() => \\\"recxxxxxx\\\"\"\n    },\n    \"AUTO_NUMBER\": {\n      \"summary\": \"为每条记录返回唯一的递增数字。\",\n      \"example\": \"AUTO_NUMBER() => 1\"\n    },\n    \"FORMULA\": {\n      \"summary\": \"填写变量、运算符和函数以形成计算公式。\",\n      \"example\": \"引用列：{字段名}\\n\\n使用运算符：100 * 2 + 300\\n\\n使用函数：SUM({数字字段 1}, 100)\\n\\n使用 IF 语句：\\nIF(逻辑条件, \\\"值 1\\\", \\\"值 2\\\")\"\n    }\n  },\n  \"functionType\": {\n    \"fields\": \"字段\",\n    \"numeric\": \"数字\",\n    \"text\": \"文本\",\n    \"logical\": \"逻辑\",\n    \"date\": \"日期\",\n    \"array\": \"数组\",\n    \"system\": \"系统\"\n  },\n  \"statisticFunc\": {\n    \"none\": \"空\",\n    \"count\": \"计数\",\n    \"empty\": \"未填写\",\n    \"filled\": \"已填写\",\n    \"unique\": \"唯一值\",\n    \"max\": \"最大值\",\n    \"min\": \"最小值\",\n    \"sum\": \"求和\",\n    \"average\": \"平均值\",\n    \"checked\": \"已勾选\",\n    \"unChecked\": \"未勾选\",\n    \"percentEmpty\": \"未填写占比\",\n    \"percentFilled\": \"已填写占比\",\n    \"percentUnique\": \"唯一值占比\",\n    \"percentChecked\": \"已勾选占比\",\n    \"percentUnChecked\": \"未勾选占比\",\n    \"earliestDate\": \"最早日期\",\n    \"latestDate\": \"最晚日期\",\n    \"dateRangeOfDays\": \"时间范围 (日)\",\n    \"dateRangeOfMonths\": \"时间范围 (月)\",\n    \"totalAttachmentSize\": \"附件总大小\"\n  },\n  \"baseQuery\": {\n    \"add\": \"添加\",\n    \"error\": {\n      \"invalidCol\": \"无效的列，请重新选择\",\n      \"invalidCols\": \"无效的列：{{colNames}}\",\n      \"invalidTable\": \"无效的表格，请重新选择\",\n      \"requiredSelect\": \"必须选择一个\"\n    },\n    \"from\": {\n      \"title\": \"来源\",\n      \"fromTable\": \"选择表格\",\n      \"fromQuery\": \"从查询中\"\n    },\n    \"select\": {\n      \"title\": \"选择\"\n    },\n    \"where\": {\n      \"title\": \"筛选\"\n    },\n    \"groupBy\": {\n      \"title\": \"分组\"\n    },\n    \"orderBy\": {\n      \"title\": \"排序\",\n      \"asc\": \"升序\",\n      \"desc\": \"降序\"\n    },\n    \"limit\": {\n      \"title\": \"查询数量\"\n    },\n    \"offset\": {\n      \"title\": \"跳过\"\n    },\n    \"join\": {\n      \"title\": \"连接\",\n      \"joinType\": \"连接类型\",\n      \"leftJoin\": \"左连接\",\n      \"rightJoin\": \"右连接\",\n      \"innerJoin\": \"内连接\",\n      \"fullJoin\": \"全连接\",\n      \"data\": \"数据来源\"\n    },\n    \"aggregation\": {\n      \"title\": \"聚合\"\n    }\n  },\n  \"comment\": {\n    \"title\": \"评论\",\n    \"placeholder\": \"添加评论...\",\n    \"emptyComment\": \"开始一段新评论...\",\n    \"deletedComment\": \"该评论已删除\",\n    \"imageSizeLimit\": \"文件大小不能超过{{size}}\",\n    \"tip\": {\n      \"editing\": \"正在编辑...\",\n      \"edited\": \"(已编辑)\",\n      \"notifyAll\": \"通知所有评论\",\n      \"notifyRelatedToMe\": \"通知与我相关的评论\",\n      \"all\": \"全部\",\n      \"relatedToMe\": \"与我相关\",\n      \"reactionUserSuffix\": \"回复表情 {{emoji}}\",\n      \"me\": \"你\",\n      \"connection\": \"和\"\n    },\n    \"toolbar\": {\n      \"link\": \"链接\",\n      \"image\": \"图片\",\n      \"mention\": \"提及\"\n    },\n    \"floatToolbar\": {\n      \"editLink\": \"编辑\",\n      \"caption\": \"设置标题\",\n      \"delete\": \"删除\",\n      \"linkText\": \"链接标题\",\n      \"enterUrl\": \"输入链接\"\n    }\n  },\n  \"memberSelector\": {\n    \"title\": \"选择成员\",\n    \"memberSelectorSearchPlaceholder\": \"搜索成员...\",\n    \"departmentSelectorSearchPlaceholder\": \"搜索部门...\",\n    \"selected\": \"已选择\",\n    \"noSelected\": \"未选择\",\n    \"empty\": \"暂无成员\",\n    \"emptyDepartment\": \"暂无部门\"\n  },\n  \"httpErrors\": {\n    \"validationError\": \"数据校验错误\",\n    \"invalidCaptcha\": \"验证码无效\",\n    \"invalidCredentials\": \"凭证无效\",\n    \"unauthorized\": \"令牌无效\",\n    \"unauthorizedShare\": \"无权限执行此操作\",\n    \"paymentRequired\": \"需要支付才能完成此操作\",\n    \"creditLimitExceeded\": \"算力额度已超限\",\n    \"restrictedResource\": \"此资源受限\",\n    \"notFound\": \"请求的资源不存在或未共享\",\n    \"conflict\": \"请求因与当前资源状态冲突而无法完成\",\n    \"unprocessableEntity\": \"请求体与预期参数不匹配\",\n    \"userLimitExceeded\": \"已达到在当前实例中创建用户的限制\",\n    \"tooManyRequests\": \"已超出当前实例的请求限制\",\n    \"internalServerError\": \"发生意外错误\",\n    \"databaseConnectionUnavailable\": \"数据库当前不可用\",\n    \"gatewayTimeout\": \"服务器未及时收到上游服务器的响应\",\n    \"unknownErrorCode\": \"发生未知错误\",\n    \"networkError\": \"网络连接问题\",\n    \"requestTimeout\": \"请求超时\",\n    \"failedDependency\": \"依赖操作失败\",\n    \"automationNodeParseError\": \"自动化节点解析错误\",\n    \"automationNodeNeedTest\": \"自动化节点需要测试\",\n    \"automationNodeTestOutdated\": \"自动化节点测试已过期\",\n    \"invalidToken\": \"无效的令牌\",\n    \"custom\": {\n      \"fieldValueNotNull\": \"\\\"{{tableName}}\\\" 中的 \\\"{{fieldName}}\\\" 字段不允许空值，请填写完整再提交\",\n      \"fieldValueDuplicate\": \"\\\"{{tableName}}\\\" 中的 \\\"{{fieldName}}\\\" 字段不允许重复值，请填写唯一值再提交\",\n      \"linkFieldValueDuplicate\": \"\\\"{{fieldName}}\\\" 字段不允许重复关联同一条记录\",\n      \"requestTimeout\": \"当前操作范围过大，请缩小范围重新尝试\",\n      \"searchTimeOut\": \"搜索超时，请尝试减少搜索范围重试\",\n      \"dependencyNodeRequire\": \"依赖节点未测试，请检查前置节点是否通过测试\",\n      \"invalidOperation\": \"检测到无效操作，请检查操作参数\"\n    },\n    \"comment\": {\n      \"listCountExceeded\": \"请求的评论数量超过最大限制 1000\",\n      \"invalidContentType\": \"无效的评论内容类型\"\n    },\n    \"attachment\": {\n      \"tokenExpireInTooLong\": \"令牌过期时间必须小于 7 天\",\n      \"s3RegionRequired\": \"S3 区域不能为空\",\n      \"s3EndpointRequired\": \"S3 端点不能为空\",\n      \"s3AccessKeyRequired\": \"S3 访问密钥不能为空\",\n      \"s3SecretKeyRequired\": \"S3 密钥不能为空\",\n      \"s3UploadMethodMustBePut\": \"S3 上传方法必须是 PUT\",\n      \"presignedError\": \"生成预签名 URL 失败\",\n      \"invalidObjectMeta\": \"无效的对象元数据\",\n      \"invalidImageStream\": \"无效的图片流\",\n      \"calculateImageSizeFailed\": \"计算图片大小失败\",\n      \"uploadFailed\": \"上传失败\",\n      \"invalidImage\": \"无效的图片\",\n      \"cantGetImageStream\": \"无法获取图片流\",\n      \"invalidProvider\": \"无效的存储提供商\",\n      \"failedToDeleteDirectory\": \"删除目录失败\",\n      \"invalidToken\": \"无效的令牌\",\n      \"tokenExpired\": \"令牌已过期\",\n      \"sizeMismatch\": \"文件大小不匹配\",\n      \"notAllowUploadFileType\": \"不允许上传 {{mimetype}} 文件类型\",\n      \"notFound\": \"附件不存在\",\n      \"invalidPath\": \"无效的附件路径\",\n      \"fileSizeExceedsMaximumLimit\": \"文件大小超过最大限制 {{maxSize}}\",\n      \"invalidUploadType\": \"无效的上传类型\",\n      \"urlReject\": \"URL 被拒绝或无法访问\"\n    },\n    \"email\": {\n      \"testEmailError\": \"Mail config error {{message}}\"\n    },\n    \"auth\": {\n      \"invalidConfirm\": \"确认输入无效\",\n      \"emailNotRegistered\": \"此邮箱尚未注册\",\n      \"passwordNotSet\": \"该账户尚未设置密码\",\n      \"systemUser\": \"这是系统用户账户\",\n      \"alreadyRegistered\": \"此邮箱已被注册\",\n      \"passwordIncorrect\": \"密码错误\",\n      \"tokenInvalid\": \"令牌无效或已过期\",\n      \"passwordAlreadyExists\": \"该账户已设置密码\",\n      \"verificationCodeInvalid\": \"验证码无效或已过期\",\n      \"newEmailSameAsCurrentEmail\": \"新邮箱地址与当前邮箱相同\",\n      \"emailAlreadyRegistered\": \"该邮箱地址已被注册\",\n      \"waitlistNotEnabled\": \"等待列表功能未启用\",\n      \"emailOrPasswordIncorrect\": \"邮箱或密码错误\",\n      \"accountDeactivated\": \"该账户已被管理员停用\",\n      \"accountLockedOut\": \"由于登录失败次数过多，您的账户已被锁定，请稍后再试\"\n    },\n    \"automation\": {\n      \"buttonClickTriggerDuplicated\": \"该按钮字段已经和 {{name}}[{{id}}] 这个自动化流程绑定，请选择其他按钮字段\",\n      \"triggerNotFound\": \"自动化必须有一个触发器节点\",\n      \"nodeNotFound\": \"{{nodeId}} 节点未找到\",\n      \"triggerTestFailed\": \"触发器测试失败, 请检查触发器配置\",\n      \"testFailed\": \"自动化测试失败, 请检查自动化配置\",\n      \"runFailed\": \"自动化运行失败\",\n      \"nodeParseError\": \"{{name}} 节点配置未完成或者有错误，请检查该节点配置\",\n      \"nodeNeedTest\": \"{{name}} 节点需要测试\",\n      \"nodeTestOutdated\": \"{{name}} 节点测试已过期\",\n      \"notFound\": \"自动化未找到\",\n      \"currentSnapshotEmpty\": \"自动化当前快照为空\",\n      \"runNotFound\": \"自动化运行记录未找到\",\n      \"anchorNotFound\": \"锚点自动化未找到\",\n      \"validationError\": \"自动化配置验证错误\",\n      \"tableNotInBase\": \"您只能订阅当前数据库内的表格\",\n      \"alreadyActiveAndNotDraft\": \"自动化已激活且不是草稿状态\",\n      \"noActiveSnapshot\": \"自动化没有活动快照\",\n      \"triggerNodeAlreadyExists\": \"此自动化已存在触发器节点\",\n      \"unSupportLogicType\": \"不支持的逻辑类型\",\n      \"generateLogicError\": \"生成逻辑节点错误\",\n      \"logicNotFound\": \"自动化逻辑节点未找到\",\n      \"groupEndNotFound\": \"未找到对应逻辑的组结束节点\",\n      \"insertNodeError\": \"插入节点错误\",\n      \"actionNotFound\": \"自动化动作节点未找到\",\n      \"unSupportDuplicateWorkflowNodeType\": \"不支持复制此自动化节点类型\",\n      \"controlNodeNotBeTested\": \"控制节点不应被测试\",\n      \"invalidNodeType\": \"无效的节点类型\",\n      \"unsupportedCategory\": \"不支持的类别\",\n      \"unknownConnectionType\": \"未知的邮件连接类型\",\n      \"imapPasswordNotConfigured\": \"IMAP 密码未配置\",\n      \"integrationNotFound\": \"集成未找到或没有凭据\",\n      \"webhookTriggerNotFound\": \"Webhook 触发器未找到\",\n      \"emailReceivedTriggerNotFound\": \"邮件接收触发器未找到\",\n      \"emailConnectorNotAvailable\": \"邮件连接器服务不可用\",\n      \"listMailboxesFailed\": \"获取邮箱文件夹列表失败: {{detail}}\"\n    },\n    \"scrape\": {\n      \"unknownDataset\": \"未知的数据集：{{datasetId}}\",\n      \"apiKeyNotConfigured\": \"抓取服务未配置\",\n      \"triggerFailed\": \"抓取触发失败：{{detail}}\",\n      \"snapshotError\": \"抓取快照错误：{{detail}}\",\n      \"timeout\": \"抓取超时，已等待 {{seconds}} 秒\"\n    },\n    \"integration\": {\n      \"oauthCodeExchangeFailed\": \"OAuth 授权码交换失败：{{detail}}\",\n      \"oauthTokenRefreshFailed\": \"OAuth 令牌刷新失败：{{detail}}\",\n      \"userInfoFetchFailed\": \"获取用户信息失败：{{detail}}\"\n    },\n    \"space\": {\n      \"notFound\": \"空间不存在\",\n      \"noPermission\": \"您没有权限访问此空间\",\n      \"disallowSpaceCreation\": \"管理员已禁用空间创建功能\",\n      \"cannotChangeOnlyOwnerRole\": \"无法更改空间唯一所有者的角色\",\n      \"cannotDeleteOnlyOwner\": \"无法删除空间的唯一所有者\",\n      \"deleted\": \"空间已被删除\",\n      \"cannotOperate\": \"无法操作此空间，请确认空间中有组织成员且为所有者\",\n      \"notBelongToOrg\": \"此空间不属于该组织\",\n      \"invalidSpaceIds\": \"空间ID无效: {{spaceIds}}\"\n    },\n    \"base\": {\n      \"notFound\": \"数据库不存在\",\n      \"cannotAccess\": \"没有权限访问数据库 {{baseId}}\",\n      \"anchorNotFound\": \"锚点数据库 {{anchorId}} 不存在\",\n      \"baseAndSpaceMismatch\": \"数据库 {{baseId}} 与空间 {{spaceId}} 不匹配\",\n      \"templateNotFound\": \"模板 {{templateId}} 不存在\"\n    },\n    \"baseNode\": {\n      \"baseIdIsRequired\": \"数据库ID是必需的\",\n      \"nodeIdIsRequired\": \"节点ID是必需的\",\n      \"invalidResourceType\": \"无效的资源类型\",\n      \"notFound\": \"节点未找到\",\n      \"parentMustBeFolder\": \"父级必须是文件夹\",\n      \"cannotDuplicateFolder\": \"不能复制文件夹\",\n      \"cannotDeleteEmptyFolder\": \"不能删除非空文件夹\",\n      \"onlyOneOfParentIdOrAnchorIdRequired\": \"只能提供 parentId 或 anchorId 中的一个\",\n      \"cannotMoveToItself\": \"不能移动到自身\",\n      \"cannotMoveToCircularReference\": \"不能移动节点到其子节点（循环引用）\",\n      \"anchorIdOrParentIdRequired\": \"必须提供 anchorId 或 parentId\",\n      \"parentNotFound\": \"父级节点未找到\",\n      \"parentIsNotFolder\": \"父级不是文件夹\",\n      \"circularReference\": \"检测到循环引用\",\n      \"folderDepthLimitExceeded\": \"文件夹深度超限\",\n      \"folderNotFound\": \"文件夹未找到\",\n      \"anchorNotFound\": \"锚点节点未找到\",\n      \"nameAlreadyExists\": \"名称已存在\"\n    },\n    \"dashboard\": {\n      \"notFound\": \"仪表板不存在\"\n    },\n    \"plugin\": {\n      \"notFound\": \"插件不存在\",\n      \"notSupportInstallInView\": \"插件不支持在视图中安装\",\n      \"userNotFound\": \"插件用户不存在\",\n      \"invalidSecret\": \"无效的密钥\",\n      \"invalidRefreshToken\": \"无效的刷新令牌\",\n      \"anomalousToken\": \"异常令牌\"\n    },\n    \"pluginPanel\": {\n      \"notFound\": \"插件面板不存在\"\n    },\n    \"pluginInstall\": {\n      \"notFound\": \"插件未安装\"\n    },\n    \"share\": {\n      \"incorrectPassword\": \"密码错误\",\n      \"notAllowedToSubmit\": \"不允许提交表单\",\n      \"viewRequired\": \"此操作需要视图\",\n      \"hiddenFieldsSubmissionNotAllowed\": \"表单包含隐藏字段，不允许提交\",\n      \"submitRecordsError\": \"记录提交失败\",\n      \"notAllowedToCopy\": \"不允许复制\",\n      \"fieldHiddenNotAllowed\": \"字段已隐藏，无法访问\",\n      \"fieldTypeNotLinkField\": \"字段类型不是关联字段\",\n      \"fieldIdRequired\": \"字段 ID 不能为空\",\n      \"fieldNotUserRelatedField\": \"字段不是用户相关字段\",\n      \"viewTypeNotAllowed\": \"此视图类型不允许执行该操作\"\n    },\n    \"shareAuth\": {\n      \"passwordRestrictionNotEnabled\": \"此共享未启用密码限制\",\n      \"shareViewNotFound\": \"共享视图不存在或已禁用分享\",\n      \"linkFieldNotFound\": \"关联字段不存在\"\n    },\n    \"baseShare\": {\n      \"notFound\": \"Base 分享不存在或分享已禁用\",\n      \"alreadyExists\": \"此节点已存在分享\",\n      \"copyNotAllowed\": \"此分享不允许复制\"\n    },\n    \"shareSocket\": {\n      \"viewPermissionNotAllowed\": \"您没有权限访问此视图\",\n      \"fieldPermissionNotAllowed\": \"您没有权限访问这些字段\",\n      \"recordPermissionNotAllowed\": \"您没有权限访问这些记录\"\n    },\n    \"pluginContextMenu\": {\n      \"notFound\": \"未找到插件右键菜单\",\n      \"anchorNotFound\": \"未找到插件右键菜单锚点\"\n    },\n    \"pluginChart\": {\n      \"queryNotFound\": \"未找到插件图表查询\"\n    },\n    \"dbConnection\": {\n      \"unsupportedDriver\": \"不支持的数据库驱动：{{driver}}\",\n      \"onlyOwnerCanRemove\": \"只有数据库所有者可以移除数据库 {{baseId}} 的连接\",\n      \"onlyOwnerCanCreate\": \"只有数据库所有者可以创建数据库 {{baseId}} 的连接\",\n      \"roleNotExist\": \"数据库角色 {{role}} 不存在\"\n    },\n    \"baseQuery\": {\n      \"queryFailed\": \"查询失败：{{message}}\",\n      \"invalidJoinType\": \"无效的连接类型：{{joinType}}\",\n      \"tableNotFound\": \"在数据库 {{baseId}} 中未找到表 {{tableId}}\"\n    },\n    \"baseSqlExecutor\": {\n      \"notAllowedToExecuteSqlWithKeyword\": \"不允许执行包含关键字 {{keyword}} 的 SQL\",\n      \"whiteListCheckError\": \"检查表访问时发生错误：{{message}}\",\n      \"databaseConnectionFailed\": \"数据库连接失败：{{message}}\",\n      \"executeQuerySqlFailed\": \"执行查询 SQL 失败：{{message}}\",\n      \"readOnlyCheckFailed\": \"只读检查失败：{{message}}\"\n    },\n    \"permission\": {\n      \"createRecordWithDeniedFields\": \"没有权限创建带有字段({{fields}})的记录\",\n      \"deleteRecords\": \"没有权限删除记录({{recordIds}})\",\n      \"readRecordWithDeniedFields\": \"没有权限在记录({{recordId}})中读取字段({{fields}})\",\n      \"updateRecordWithDeniedFields\": \"没有权限在记录({{recordId}})中更新字段({{fields}})\",\n      \"checkIdNotExist\": \"权限检查ID不存在\",\n      \"userNotAdmin\": \"用户不是管理员\",\n      \"accessTokenNoPermission\": \"访问令牌没有所需的权限\",\n      \"invalidResource\": \"资源无效\",\n      \"notAllowedSpace\": \"您没有权限访问此空间\",\n      \"notAllowedBase\": \"您没有权限访问此数据库\",\n      \"notAllowedTables\": \"您没有权限访问表格({{tableIds}})\",\n      \"notAllowedOperationTable\": \"您没有权限操作此表格\",\n      \"notAllowedOperationRecord\": \"您没有权限操作此记录\",\n      \"notAllowedRecordUpdate\": \"您没有权限更新此记录\",\n      \"notAllowedOperationView\": \"您没有权限操作此视图\",\n      \"deniedByEnabledAuthorityMatrix\": \"权限矩阵已启用，操作被拒绝\",\n      \"invalidRequestPath\": \"请求路径无效\",\n      \"notAllowedOperation\": \"您没有权限执行此操作\",\n      \"notAllowedDepartment\": \"您没有权限访问此部门\",\n      \"templateHeaderInvalid\": \"模板头无效\"\n    },\n    \"authorityMatrix\": {\n      \"defaultRoleNotFound\": \"默认角色未找到\",\n      \"alreadyDisabled\": \"权限矩阵已被禁用\",\n      \"alreadyEnabled\": \"权限矩阵已被启用\",\n      \"notFound\": \"权限矩阵未找到\",\n      \"primaryFieldCannotBeDisabledForRead\": \"主字段不能禁用读取权限\",\n      \"fieldDuplicated\": \"权限配置中存在重复字段\",\n      \"cannotSetRecordPermissionGroup\": \"不能设置此记录权限组合({{actions}})\",\n      \"notFoundBaseAndTable\": \"未找到数据库ID和表格ID\",\n      \"roleTablesShouldNotBeEmpty\": \"权限矩阵角色表不能为空\"\n    },\n    \"selection\": {\n      \"invalidReturnType\": \"无效的返回类型\",\n      \"exceedMaxReadRows\": \"超出最大读取行数限制\",\n      \"invalidCellValueType\": \"无效的单元格值类型\",\n      \"exceedMaxCopyCells\": \"超出最大复制单元格数限制\",\n      \"exceedMaxPasteCells\": \"超出最大粘贴单元格数限制\"\n    },\n    \"field\": {\n      \"unsupportedFieldType\": \"不支持的字段类型 {{type}}\",\n      \"unsupportedPrimaryFieldType\": \"不支持的字段类型 {{type}} 作为主字段\",\n      \"primaryFieldNotSupported\": \"字段类型不支持作为主字段\",\n      \"calculateRecordNotFound\": \"计算 {{recordId}} 时未找到记录：{{value}}，字段ID：{{fieldId}}\",\n      \"toRecordIdsOrFromRecordIdsRequired\": \"普通计算字段需要 toRecordIds 或 fromRecordIds\",\n      \"recordFieldsRequired\": \"记录字段为空\",\n      \"uniqueUnsupportedType\": \"字段 {{name}}[{{fieldId}}] 不支持字段值唯一性验证\",\n      \"notNullValidationWhenCreateField\": \"新建字段 {{name}}[{{fieldId}}] 不支持非空验证\",\n      \"dbFieldNameAlreadyExists\": \"数据库字段名 {{dbFieldName}} 已存在\",\n      \"fieldValidationError\": \"字段 {{name}}[{{fieldId}}] 字段验证错误\",\n      \"fieldNameAlreadyExists\": \"字段名 {{name}} 已存在\",\n      \"notFound\": \"字段不存在\",\n      \"fieldKeyTypeNotFound\": \"字段 \\\"{{fieldKeyType}}: {{missedFields}}\\\" 不存在\",\n      \"notFoundInTable\": \"字段 {{fieldId}} 不在表 {{tableId}} 中\",\n      \"deleteFieldsNotFound\": \"删除字段 {{fieldIds}} 不在表 {{tableId}} 中\",\n      \"lookupValuesShouldBeArray\": \"当链接字段为多选时，lookupValues 应为数组\",\n      \"linkCellValuesShouldBeArray\": \"当链接字段为多选时，linkCellValues 应为数组\",\n      \"lookupAndLinkLengthMatch\": \"lookupValues 长度应与 linkCellValues 长度相同\",\n      \"cycleDetected\": \"检测到循环依赖\",\n      \"cycleDetectedCreateField\": \"检测到循环依赖，无法创建字段 {{name}}[{{id}}]\",\n      \"recordMapNotFound\": \"在表 {{tableName}} 中找不到字段 {{fieldName}} 的记录\",\n      \"forbidDeletePrimaryField\": \"不允许删除主字段\",\n      \"foreignTableIdInvalid\": \"关联表 {{foreignTableId}} 无效\",\n      \"relationshipInvalid\": \"关联关系 {{relationship}} 无效\",\n      \"linkFieldIdInvalid\": \"关联字段 {{linkFieldId}} 无效\",\n      \"lookupFieldIdInvalid\": \"查找字段 {{lookupFieldId}} 无效\",\n      \"formulaExpressionParseError\": \"公式的表达式解析错误\",\n      \"formulaReferenceNotFound\": \"公式的引用字段 {{fieldIds}} 不存在\",\n      \"formulaReferenceNotFieldId\": \"公式引用 {{fieldIds}} 不存在。公式必须使用字段 ID（fldXXXXXXXXXXXXXXXX 格式），而不是字段名称。\",\n      \"rollupExpressionParseError\": \"汇总字段的表达式解析错误\",\n      \"choiceNameAlreadyExists\": \"选项名称 {{name}} 已存在\",\n      \"symmetricFieldIdRequired\": \"双向关联对称的字段 ID 不能为空\",\n      \"foreignKeyNameCannotUseId\": \"外键名称不能使用 __id\",\n      \"createForeignKeyError\": \"创建外键错误\",\n      \"lookupFieldTypeNotEqual\": \"当前字段类型 {{fieldType}} 不等于查找字段类型 {{lookupFieldType}}\",\n      \"recordNotFound\": \"记录 {{recordId}} 不存在于表 {{tableId}}\",\n      \"linkCellRecordIdAlreadyExists\": \"不能在同一个单元格中设置重复的记录ID: {{recordId}}\",\n      \"linkConsistencyError\": \"一致性错误，记录ID {{recordId}} 不存在\",\n      \"oneOneLinkCellValueCannotBeArray\": \"一对一关联字段的值不能是数组\",\n      \"manyOneLinkCellValueCannotBeArray\": \"多对一关联字段的值不能是数组\",\n      \"oneManyLinkCellValueShouldBeArray\": \"一对多关联字段的值应为数组\",\n      \"manyManyLinkCellValueShouldBeArray\": \"多对多关联字段的值应为数组\",\n      \"foreignKeyDuplicate\": \"外键重复\",\n      \"onlyLinkFieldCanBeFiltered\": \"只能使用关联字段进行筛选\",\n      \"notLinkedToCurrentTable\": \"字段未关联到当前表\",\n      \"notAttachment\": \"字段不是附件字段\",\n      \"isComputed\": \"字段为计算字段，无法修改\",\n      \"notFoundAICofig\": \"字段没有AI配置\",\n      \"foreignTableIdRequired\": \"必须指定关联表\",\n      \"lookupFieldIdRequired\": \"必须选择查找字段\",\n      \"lookupFieldNotExist\": \"查找字段 {{lookupFieldId}} 不存在\",\n      \"lookupFieldNotBelongToTable\": \"查找字段 {{lookupFieldId}} 不属于表 {{foreignTableId}}\",\n      \"lookupFieldTypeNotMatch\": \"当前字段类型 {{fieldType}} 与查找字段类型 {{lookupFieldType}} 不匹配\",\n      \"conditionalRollupOptionsRequired\": \"条件汇总字段选项是必需的\",\n      \"conditionalRollupParseError\": \"条件汇总解析错误：{{message}}\",\n      \"conditionalLookupOptionsRequired\": \"条件查找字段选项是必需的\",\n      \"button\": {\n        \"clickCountReachedMaxCount\": \"按钮点击次数已达到最大限制\",\n        \"notSupportReset\": \"按钮不支持重置\"\n      }\n    },\n    \"view\": {\n      \"notFound\": \"视图不存在\",\n      \"cannotDeleteLastView\": \"无法删除表格中的最后一个视图。表格必须至少保留一个视图。\",\n      \"defaultViewNotFound\": \"默认视图不存在\",\n      \"propertyParseError\": \"视图属性解析失败\",\n      \"primaryFieldCannotBeHidden\": \"主字段不能被隐藏\",\n      \"filterUnsupportedFieldType\": \"筛选不支持的字段类型\",\n      \"filterInvalidOperator\": \"筛选器的操作符对此字段类型无效\",\n      \"filterInvalidOperatorMode\": \"筛选器的操作符与模式组合无效\",\n      \"sortUnsupportedFieldType\": \"排序不支持的字段类型\",\n      \"groupUnsupportedFieldType\": \"分组不支持的字段类型\",\n      \"anchorNotFound\": \"未找到锚点视图\",\n      \"notEnoughGapToShuffleRow\": \"没有足够的间隙来重新排列记录顺序\",\n      \"shareNotEnabled\": \"视图分享未启用\",\n      \"shareAlreadyEnabled\": \"视图分享已启用\",\n      \"shareAlreadyDisabled\": \"视图分享已禁用\"\n    },\n    \"billing\": {\n      \"insufficientCredit\": \"算力不足\",\n      \"exceedMaxRowLimit\": \"已超过最大行数限制 {{maxRowCount}}\",\n      \"exceedMaxAutomationRunLimit\": \"已触及自动化每月最大运行次数\"\n    },\n    \"aggregation\": {\n      \"searchQueryRequired\": \"搜索查询是必需的\",\n      \"maxSearchIndexResult\": \"最大搜索索引结果为 1000\",\n      \"queryCollectionMustBeTableId\": \"查询集合必须是表格 ID\",\n      \"searchTimeOut\": \"搜索超时，请尝试减少搜索范围重试\",\n      \"indexNotFound\": \"索引未找到\",\n      \"invalidStartDateFieldId\": \"开始日期字段 ID 无效\",\n      \"invalidEndDateFieldId\": \"结束日期字段 ID 无效\",\n      \"fieldMapRequired\": \"设置搜索时字段映射是必需的\",\n      \"filterLinkCellQueryConflict\": \"filterLinkCellSelected 和 filterLinkCellCandidate 不能同时设置\"\n    },\n    \"ai\": {\n      \"chatModelLgNotSet\": \"AI 高级对话模型未设置\",\n      \"chatModelLgProviderNotSet\": \"AI 高级对话模型提供商未设置\",\n      \"chatModelSmNotSet\": \"AI 中级对话模型未设置\",\n      \"chatModelMdNotSet\": \"AI 基础对话模型未设置\",\n      \"configurationNotSet\": \"AI 配置未设置\",\n      \"unsupportedProvider\": \"不支持的 AI 模型提供商 {{type}}\",\n      \"providerConfigurationNotSet\": \"AI 模型提供商配置未设置\",\n      \"gatewayApiKeyNotSet\": \"AI Gateway API 密钥未配置\",\n      \"testLLMFailed\": \"LLM 连接测试失败\",\n      \"audioNotSupported\": \"此模型{{model}}不支持音频输入\",\n      \"imageNotSupported\": \"此模型{{model}}不支持图像输入\",\n      \"modelNotSet\": \"AI 模型未设置\",\n      \"unsupportedFileType\": \"不支持的文件类型 {{mimetype}}\",\n      \"unsupportedModelType\": \"不支持的模型类型\",\n      \"embeddingModelNotSet\": \"嵌入模型未设置\",\n      \"validateActionFailed\": \"验证字段AI操作失败\",\n      \"generateFailed\": \"AI生成失败\",\n      \"unsupportedActionType\": \"不支持的AI操作类型\",\n      \"geminiImageNotSupportedViaGateway\": \"AI Gateway 不支持 Gemini 图像生成。请改为配置直连 Google 服务商。\"\n    },\n    \"role\": {\n      \"notFound\": \"角色不存在\"\n    },\n    \"collaborator\": {\n      \"alreadyExisted\": \"协作者已存在\",\n      \"notFound\": \"协作者不存在\",\n      \"userNotFoundInCollaborator\": \"在协作者中未找到用户\",\n      \"noPermissionToDelete\": \"您没有权限删除此协作者\",\n      \"noPermissionToUpdate\": \"您没有权限更新此协作者\",\n      \"noPermissionToOperateRole\": \"您没有权限操作此角色\",\n      \"alreadyExistedInBase\": \"协作者已在数据库中存在\",\n      \"userNotFound\": \"未找到用户：{{userIds}}\",\n      \"baseNotFound\": \"未找到数据库\",\n      \"noPermissionToAddRole\": \"您没有权限添加此角色的协作者\",\n      \"departmentNotFound\": \"部门未找到\"\n    },\n    \"table\": {\n      \"notFound\": \"表格不存在\",\n      \"dbTableNameAlreadyExists\": \"数据库表名已存在\",\n      \"anchorNotFound\": \"锚点表格不存在\",\n      \"notInTrash\": \"表格不在回收站中\",\n      \"notSupportTableIndex\": \"不支持的表格索引类型\",\n      \"createTableIndexError\": \"创建表格索引失败\",\n      \"dropTableIndexError\": \"删除表格索引失败\",\n      \"notFoundPrimaryField\": \"表格中未找到主字段\"\n    },\n    \"export\": {\n      \"notSupportViewType\": \"{{viewType}} 视图类型不支持导出\"\n    },\n    \"import\": {\n      \"notSupportedFileFormat\": \"文件格式不支持，仅支持 {{supportType}}，您的文件内容类型是 {{fileFormat}}\",\n      \"notSupportedFileType\": \"不支持的导入文件类型\",\n      \"exceedMaxFieldsLength\": \"表格中的字段数不能超过 {{maxFieldsLength}}，当前为 {{length}}\",\n      \"tooManyConcurrentImports\": \"Too many import tasks in progress ({{current}}/{{max}}). Please try again later.\"\n    },\n    \"invitation\": {\n      \"disallowSpaceInvitation\": \"当前实例管理员已禁止空间邀请\",\n      \"invalidCode\": \"无效的邀请码\",\n      \"linkNotFound\": \"邀请链接不存在\",\n      \"linkExpired\": \"邀请链接已过期\",\n      \"limitExceeded\": \"您已达到每小时最大邀请次数限制\"\n    },\n    \"pin\": {\n      \"alreadyExists\": \"收藏已存在\",\n      \"notFound\": \"未找到收藏\",\n      \"anchorNotFound\": \"未找到锚点收藏\"\n    },\n    \"trash\": {\n      \"invalidResourceType\": \"无效的资源类型\",\n      \"notFound\": \"回收站项目未找到\",\n      \"parentSpaceTrashed\": \"无法恢复此数据库，因为其父空间也在回收站中\",\n      \"parentBaseOrSpaceTrashed\": \"无法恢复此表格，因为其父数据库或空间也在回收站中\",\n      \"parentBaseTrashed\": \"无法恢复此项目，因为其父数据库也在回收站中\",\n      \"parentNotFound\": \"父级资源未找到\",\n      \"tableNotFound\": \"表格回收站项目未找到\"\n    },\n    \"license\": {\n      \"invalid\": \"许可证无效\",\n      \"instanceIdMismatch\": \"传入的实例ID与当前实例ID不匹配\",\n      \"expired\": \"许可证已过期\",\n      \"userLimitExceeded\": \"当前实例的用户数量已超过许可证的席位限制。请停用一些用户或升级许可证\"\n    },\n    \"domainVerification\": {\n      \"notFound\": \"未找到域名验证码\",\n      \"invalidCode\": \"验证码无效\",\n      \"resendCooldown\": \"请等待1分钟后再请求新的验证码\",\n      \"alreadyVerified\": \"域名已验证\"\n    },\n    \"organization\": {\n      \"notFound\": \"组织未找到\",\n      \"authenticationNotFound\": \"身份验证未找到\",\n      \"spaceShouldExist\": \"组织空间应该存在\",\n      \"emailsNotInOrgDomain\": \"这些邮箱{{emails}}不在组织域名内\",\n      \"emailNotSpaceUser\": \"邮箱不是空间用户\"\n    },\n    \"mail\": {\n      \"failedToSendEmail\": \"发送邮件失败\"\n    },\n    \"user\": {\n      \"disallowSignUp\": \"管理员已禁止当前实例的用户注册\",\n      \"waitlistInviteCodeRequired\": \"已启用等待列表，需要邀请码\",\n      \"waitlistInviteCodeInvalid\": \"已启用等待列表，邀请码无效\",\n      \"systemUser\": \"用户是系统用户\",\n      \"collaboratorsInSpaces\": \"用户在空间中有协作者（或在回收站中有已删除的空间）\",\n      \"notFound\": \"用户不存在\",\n      \"cannotDeleteAdmin\": \"无法删除管理员用户\",\n      \"cannotDeactivateAdmin\": \"无法停用管理员用户\",\n      \"cannotRemoveLastAdmin\": \"无法移除最后一个活跃管理员用户的管理员权限\",\n      \"permanentDeleted\": \"用户已被永久删除\",\n      \"cannotDeleteSelf\": \"无法删除自己\",\n      \"alreadyInDepartment\": \"用户{{userId}}已在目标部门中\",\n      \"emailsNotFound\": \"邮箱{{emails}}未找到\",\n      \"deleted\": \"用户{{userId}}已被删除\",\n      \"alreadyInOrg\": \"用户{{userId}}已在组织中\",\n      \"notInOrg\": \"用户{{userId}}不在组织中\"\n    },\n    \"record\": {\n      \"notFound\": \"记录不存在\",\n      \"deletedIdsNotFound\": \"部分待删除的记录未找到\",\n      \"updateFailed\": \"更新记录失败\",\n      \"noFileOrUrlProvided\": \"未提供文件或URL\",\n      \"createRecordsEmpty\": \"创建记录不能为空\",\n      \"duplicateFailed\": \"复制记录失败\"\n    },\n    \"typecast\": {\n      \"cellValueValidationFailed\": \"单元格值验证失败\"\n    },\n    \"workflow\": {\n      \"notActive\": \"自动化未开启\"\n    },\n    \"lastVisit\": {\n      \"invalidResourceType\": \"无效的资源类型\"\n    },\n    \"template\": {\n      \"categoryNotFound\": \"模板分类不存在\",\n      \"snapshotRequired\": \"此模板无法发布，因为缺少快照\",\n      \"sourceTemplateNotFound\": \"源模板不存在\",\n      \"noMinOrderFound\": \"未找到最小排序值\",\n      \"takeCountTooLarge\": \"请求的模板数量超过最大限制\",\n      \"categoryLimitReached\": \"模板分类数量已达上限（最多 {{maxCount}} 个）\"\n    },\n    \"department\": {\n      \"parentNotFound\": \"父部门未找到\",\n      \"notFound\": \"部门未找到\",\n      \"cannotMoveToItself\": \"无法将部门移动到自身\",\n      \"cannotMoveToSub\": \"无法将部门移动到其子部门\"\n    },\n    \"app\": {\n      \"notFound\": \"应用不存在\",\n      \"noFilesToUpdate\": \"没有文件需要更新\",\n      \"noChatIdFound\": \"此应用没有关联的对话 ID\",\n      \"noChatFound\": \"此应用没有关联的对话\",\n      \"versionNotFound\": \"版本不存在\",\n      \"cannotRollbackToLatestVersion\": \"无法回滚到最新版本\",\n      \"noChatOrProjectTokenFound\": \"没有找到对话或项目令牌\",\n      \"apiKeyNotSet\": \"应用构建器 API 密钥未设置\",\n      \"cannotDeployAppBeforeInitialization\": \"无法在初始化前部署应用\",\n      \"noProjectOrVersionFound\": \"没有找到项目或版本\",\n      \"noDeploymentUrlAvailable\": \"没有可用的部署 URL\",\n      \"noFilesInZip\": \"ZIP 文件中没有找到文件\",\n      \"zipFileTooLarge\": \"ZIP 文件大小超过 5MB 限制\",\n      \"invalidZip\": \"无效的 ZIP 文件\"\n    },\n    \"reward\": {\n      \"notFound\": \"奖励记录不存在\",\n      \"unsupportedSourceType\": \"不支持的奖励来源类型\",\n      \"maxClaimsReached\": \"您本周已达到最大奖励领取次数 (2 次)\",\n      \"verificationFailed\": \"验证失败：{{errors}}\",\n      \"alreadyClaimedThisWeek\": \"您本周已为此账户领取过奖励\",\n      \"invalidPostUrl\": \"帖子链接格式无效\",\n      \"postAlreadyUsed\": \"此帖子已被用于领取奖励\",\n      \"unsupportedPlatformUrl\": \"不支持的社交平台链接\",\n      \"unsupportedPlatform\": \"不支持的平台：{{platform}}\",\n      \"minCharCount\": \"帖子内容至少需要 {{count}} 个字符\",\n      \"minFollowerCount\": \"账户至少需要 {{count}} 位粉丝\",\n      \"mustMention\": \"帖子必须提及 {{mention}}\",\n      \"fetchTweetFailed\": \"获取 X 推文失败：{{error}}\",\n      \"tweetNotFound\": \"未找到 X 推文：{{postId}}\",\n      \"fetchUserFailed\": \"获取 X 用户失败：{{error}}\",\n      \"xUserNotFound\": \"未找到 X 用户：{{username}}\",\n      \"fetchLinkedInPostFailed\": \"获取 LinkedIn 帖子失败：{{error}}\",\n      \"linkedInPostNotFound\": \"未找到 LinkedIn 帖子：{{postId}}\",\n      \"linkedInAuthorNotFound\": \"未找到 LinkedIn 作者：{{postId}}\",\n      \"fetchLinkedInUserFailed\": \"获取 LinkedIn 用户失败：{{error}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/setting.json",
    "content": "{\n  \"personalAccessToken\": \"个人访问令牌\",\n  \"oauthApps\": \"OAuth 应用\",\n  \"plugins\": \"插件\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/share.json",
    "content": "{\n  \"auth\": {\n    \"title\": \"输入您的密码查看此页面\",\n    \"submit\": \"提交\",\n    \"password\": \"密码\",\n    \"passwordTooShort\": \"密码长度至少为3个字符\"\n  },\n  \"form\": {\n    \"requireLoginTip\": \"此表单需要登录后才能提交\",\n    \"login\": \"登录\"\n  },\n  \"toolbar\": {\n    \"filterLinkSelectPlaceholder\": \"请选择...\"\n  },\n  \"openOnNewPage\": \"在新页面查看\",\n  \"errorTips\": \"分享来源已启用权限矩阵，无法查看\"\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/space.json",
    "content": "{\n  \"initialSpaceName\": \"{{name}}的空间\",\n  \"action\": {\n    \"createBase\": \"创建数据库\",\n    \"createSpace\": \"创建空间\",\n    \"invite\": \"邀请\"\n  },\n  \"allSpaces\": \"所有空间\",\n  \"emptySpaceTitle\": \"此空间暂无数据库\",\n  \"spaceIsEmpty\": \"创建您的第一个数据库开始使用\",\n  \"baseModal\": {\n    \"copy\": \"副本\",\n    \"duplicate\": \"复制 \\\"{{baseName}}\\\"\",\n    \"createBaseFromTemplate\": \"从模板创建数据库\",\n    \"duplicateRecords\": \"复制记录\",\n    \"duplicateRecordsTip\": \"您的记录修改历史和协作者不会被复制\",\n    \"toSpace\": \"选择空间\",\n    \"copyToSpace\": \"复制到空间\",\n    \"duplicateBase\": \"复制\",\n    \"missTargetTip\": \"请选择要复制到的空间\",\n    \"copying\": \"复制中，请稍作等待...\",\n    \"copyingTemplate\": \"从模板创建中，请稍作等待...\",\n    \"howToCreate\": \"如何创建一个数据库?\",\n    \"fromScratch\": \"空白数据库\",\n    \"fromTemplate\": \"从模板创建\",\n    \"moveBaseToAnotherSpace\": \"移动{{baseName}}到另一个空间\",\n    \"chooseSpace\": \"选择空间\",\n    \"duplicateBaseSucceedAndJump\": \"复制成功，点击前往\"\n  },\n  \"spaceSetting\": {\n    \"title\": \"设置\",\n    \"general\": \"概览\",\n    \"collaborators\": \"协作者\",\n    \"generalDescription\": \"您可以在此处更改当前空间的设置\",\n    \"collaboratorDescription\": \"您可以在此处管理空间的协作者并设置他们的权限\",\n    \"spaceName\": \"空间名称\",\n    \"spaceId\": \"空间 ID\",\n    \"importBase\": \"导入\"\n  },\n  \"collaborators\": \"协作者\",\n  \"more\": \"更多\",\n  \"pin\": {\n    \"add\": \"添加到收藏\",\n    \"remove\": \"从收藏中移除\",\n    \"pin\": \"收藏\",\n    \"empty\": \"您收藏的数据库和空间将显示在这里\"\n  },\n  \"tooltip\": {\n    \"noPermissionToCreateBase\": \"您没有权限创建数据库\"\n  },\n  \"tip\": {\n    \"delete\": \"确定要删除 \\\"<0/>\\\" 空间吗？\",\n    \"title\": \"提示\",\n    \"exportTips1\": \"将当前数据库导出成 .tea 文件，这可能需要一定的时间，你可以在通知中心查看导出结果。\",\n    \"exportTips2\": \"你可以在空间 -> 更多 -> 导入，导入 .tea 文件\",\n    \"exportTips3\": \"跨数据库关联字段将会被转换成单行文本。\",\n    \"exportIncludeDataLabel\": \"包含记录\",\n    \"exportIncludeDataDescription\": \"关闭开关时仅导出结构和配置。\",\n    \"moveBaseSuccessTitle\": \"移动成功\",\n    \"moveBaseSuccessDescription\": \"{{baseName}}已成功移动到{{spaceName}}\"\n  },\n  \"deleteSpaceModal\": {\n    \"title\": \"删除空间\",\n    \"blockedTitle\": \"无法删除该空间\",\n    \"blockedDesc\": \"该空间存在订阅，请先取消订阅后再删除空间。\",\n    \"permanentDeleteWarning\": \"此操作将会永久删除当前空间下的所有资源和数据，请谨慎操作！\",\n    \"confirmInputLabel\": \"请输入 DELETE 以确认删除操作\"\n  },\n  \"sharedBase\": {\n    \"title\": \"分享给我的\",\n    \"description\": \"所有邀请我加入的数据库\",\n    \"empty\": \"暂无分享给我的数据库\"\n  },\n  \"integration\": {\n    \"title\": \"集成\",\n    \"description\": \"管理空间集成\",\n    \"addIntegration\": \"添加集成\",\n    \"ai\": \"AI\"\n  },\n  \"aiSetting\": {\n    \"title\": \"AI 配置\",\n    \"description\": \"管理空间 AI 配置\",\n    \"enableTips\": \"启用空间 AI 配置后, 将会优先使用空间 AI 配置\",\n    \"enable\": \"初始化 AI 配置\",\n    \"enableSwitchTips\": \"开启前，请配置好高级对话模型\"\n  },\n  \"import\": {\n    \"importing\": \"导入中\",\n    \"importWayTip\": \"点击或拖拽文件到此区域上传\",\n    \"baseImportTips\": \"点击或者拖拽 .tea 文件到此区域上传\",\n    \"confirm\": \"确认并继续\",\n    \"phase\": {\n      \"parsingStructure\": \"正在解析结构\",\n      \"creatingBase\": \"正在创建数据库: {{detail}}\",\n      \"creatingTable\": \"正在创建表: {{detail}}\",\n      \"creatingCommonFields\": \"正在为 {{table}} 创建基础字段：{{fields}}\",\n      \"creatingButtonFields\": \"正在为 {{table}} 创建按钮字段：{{fields}}\",\n      \"creatingFormulaFields\": \"正在为 {{table}} 创建公式字段：{{fields}}\",\n      \"creatingLinkFields\": \"正在为 {{table}} 创建关联字段：{{fields}}\",\n      \"creatingLookupFields\": \"正在为 {{table}} 创建引用字段：{{fields}}\",\n      \"creatingTableViews\": \"正在为 {{table}} 创建视图：{{fields}}\",\n      \"creatingPlugins\": \"正在创建插件\",\n      \"creatingFolders\": \"正在创建文件夹\",\n      \"creatingWorkflows\": \"正在创建工作流\",\n      \"creatingApps\": \"正在创建应用\",\n      \"creatingAuthorityMatrix\": \"正在创建权限矩阵\",\n      \"queuingAttachments\": \"正在提交附件上传任务\",\n      \"uploadingAppFiles\": \"正在上传应用文件\",\n      \"queuingDataImport\": \"正在提交数据导入任务\",\n      \"done\": \"导入完成\",\n      \"clickToView\": \"点击查看\"\n    }\n  },\n  \"template\": {\n    \"title\": \"模板中心\",\n    \"description\": \"快速从模板创建一个新数据库\",\n    \"noTemplatesAvailable\": \"暂无模板\",\n    \"noTemplatesDescription\": \"当前没有可用的模板\"\n  },\n  \"recentlyBase\": {\n    \"title\": \"最近访问\"\n  },\n  \"noBases\": {\n    \"title\": \"Hi {{userName}}！\",\n    \"description\": \"创建您的第一个数据库，开始让数据变简单\"\n  },\n  \"noSpaces\": {\n    \"title\": \"Hi {{userName}}！\",\n    \"description\": \"创建您的第一个空间，开始您的数据协作之旅\"\n  },\n  \"baseList\": {\n    \"allBases\": \"所有数据库\",\n    \"owner\": \"所有者\",\n    \"createdTime\": \"创建时间\",\n    \"lastOpened\": \"最近打开\",\n    \"enter\": \"进入\",\n    \"noTables\": \"暂无表\",\n    \"empty\": \"暂无数据库\",\n    \"recent\": \"最近访问\",\n    \"manual\": \"手动排序\",\n    \"noBasesFound\": \"未找到数据库\"\n  },\n  \"publishBase\": {\n    \"title\": \"发布数据库到社区\",\n    \"description\": \"一键发布数据库，灵感不再孤单！让更多人看到、使用和 Remix 你的创意，一起构建更强大的业务\",\n    \"infoTitle\": \"基本信息\",\n    \"form\": {\n      \"title\": \"作品名称\",\n      \"description\": \"描述\",\n      \"security\": \"安全\",\n      \"includeNodes\": \"包含节点\",\n      \"advanced\": \"高级\",\n      \"publishNode\": \"发布节点\",\n      \"includeData\": \"包含数据\",\n      \"defaultActiveNode\": \"默认展示节点\",\n      \"select\": \"请选择\",\n      \"descriptionPlaceholder\": \"简要描述你的想法...\",\n      \"titlePlaceholder\": \"为你的作品命名...\"\n    },\n    \"publishToCommunity\": \"发布到模版中心\",\n    \"publish\": \"发布\",\n    \"publishSuccess\": \"发布成功！\",\n    \"previewTips\": \"让世界看到你的作品\",\n    \"update\": \"更新\",\n    \"unPublish\": \"取消发布\",\n    \"unPublishSuccess\": \"取消发布成功！\",\n    \"unPublishConfirmTitle\": \"确认取消发布\",\n    \"unPublishConfirmDescription\": \"确定要取消发布此数据库吗？取消发布后，该数据库将不再在社区模板中心显示。\",\n    \"usageCount\": \"使用次数：\",\n    \"uploadCover\": \"点击上传封面图片\",\n    \"changeCover\": \"点击更换封面\",\n    \"uploading\": \"图片上传中...\",\n    \"uploadSuccess\": \"图片上传成功\",\n    \"uploadFailed\": \"上传失败\",\n    \"invalidImageType\": \"请选择图片文件\",\n    \"tips\": {\n      \"publishValidation\": \"标题和描述不能为空\",\n      \"atLeastOneNode\": \"至少选择一个节点发布\"\n    },\n    \"urlCopied\": \"链接已复制到剪贴板！\",\n    \"urlCopiedForDiscord\": \"链接已复制到剪贴板！你可以在 Discord 中粘贴。\",\n    \"featuredLabel\": \"精选\",\n    \"unfeaturedLabel\": \"未精选\",\n    \"featuredTip\": \"已入选官方精选，将获得更多曝光与推荐。\",\n    \"unfeaturedTip\": \"暂未入选官方精选，持续打磨与更新，有机会获得推荐，让更多人看到你的作品。\",\n    \"publishSuccessDescription\": \"分享你的作品到世界\",\n    \"shareWith\": \"分享到\",\n    \"unpublishedApps\": {\n      \"title\": \"检测到未发布的应用\",\n      \"description\": \"未发布的应用可能导致模板预览失败，建议先发布后继续。\",\n      \"publishAll\": \"全部发布\",\n      \"publish\": \"发布\",\n      \"published\": \"已发布\",\n      \"publishing\": \"发布中\",\n      \"notPublished\": \"未发布\",\n      \"publishFailed\": \"发布失败\",\n      \"publishFailedTip1\": \"检查源应用是否能正常发布\",\n      \"publishFailedTip2\": \"尝试重新发布此模板\",\n      \"ignoreAndContinue\": \"忽略并继续\",\n      \"goToFix\": \"去修复\",\n      \"redeploy\": \"重新部署\",\n      \"unnamedApp\": \"未命名应用\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/table.json",
    "content": "{\n  \"toolbar\": {\n    \"comingSoon\": \"即将推出\",\n    \"viewFilterInShare\": \"此视图正在被用于视图分享链接。修改视图配置也会改变视图分享链接。\",\n    \"createFieldButtonText\": \"创建一个<0/>类型的字段\",\n    \"others\": {\n      \"share\": {\n        \"label\": \"分享\",\n        \"statusLabel\": \"公开分享视图到网络\",\n        \"noPermission\": \"无权限分享视图\",\n        \"shareLink\": \"分享链接\",\n        \"copied\": \"已复制\",\n        \"genLink\": \"生成新链接\",\n        \"allowCopy\": \"允许拷贝视图数据\",\n        \"showAllFields\": \"显示展开记录中的所有字段\",\n        \"restrict\": \"设置访问密码\",\n        \"tips\": \"开启后将获取一个链接，此链接仅允许访问当前视图。\",\n        \"passwordTitle\": \"设置访问密码\",\n        \"passwordTips\": \"链接仅在正确密码输入后可访问\",\n        \"embed\": \"嵌入\",\n        \"embedPreview\": \"预览\",\n        \"copyCode\": \"复制代码\",\n        \"hideToolbar\": \"隐藏工具栏\",\n        \"theme\": \"主题\",\n        \"themeSystem\": \"跟随系统\",\n        \"themeLight\": \"浅色\",\n        \"themeDark\": \"深色\",\n        \"URLSetting\": \"链接参数设置\",\n        \"URLSettingDescription\": \"通过链接参数来控制显示样式，调整以下设置不会影响已分享出去的链接，您需要复制带上新参数的链接以使其生效\",\n        \"cancel\": \"取消\",\n        \"save\": \"保存\",\n        \"requireLogin\": \"需要登录提交\"\n      },\n      \"extensions\": {\n        \"label\": \"插件\",\n        \"graph\": \"关系图\"\n      },\n      \"api\": {\n        \"label\": \"API 接口\",\n        \"restfulApi\": \"Restful API\",\n        \"databaseConnection\": \"数据库连接\",\n        \"title\": \"API 访问\",\n        \"aiContext\": \"复制给 AI\",\n        \"advanced\": \"高级\",\n        \"generatingToken\": \"生成中...\",\n        \"aiContextTitle\": \"AI 友好的 API 文档\",\n        \"aiContextDescriptionNoToken\": \"将此文档复制给你的 AI 助手（ChatGPT、Claude 等）。点击「生成 API Token」创建一个自动填入文档的 Token。\",\n        \"aiContextDescriptionWithToken\": \"将此文档复制给你的 AI 助手（ChatGPT、Claude 等）。已生成权限受限的 API Token 并填入文档中。\",\n        \"generateToken\": \"生成 Token\",\n        \"confirmTitle\": \"创建 API Token\",\n        \"confirmDescription\": \"这将为此数据库创建一个具有以下权限的新 API Token：\",\n        \"scopeTableRead\": \"读取表信息\",\n        \"scopeFieldRead\": \"读取字段\",\n        \"scopeRead\": \"读取记录\",\n        \"scopeCreate\": \"创建记录\",\n        \"scopeUpdate\": \"更新记录\",\n        \"scopeDelete\": \"删除记录\",\n        \"confirmExpiry\": \"Token 有效期为 1 年。\",\n        \"confirmButton\": \"确认创建\",\n        \"tokenInfo\": \"Token 过期时间：{{expiry}}\",\n        \"tokenCreatedSuccess\": \"Token 创建成功！点击复制即可使用。\",\n        \"copied\": \"已复制！\",\n        \"copy\": \"复制\",\n        \"copyAIDoc\": \"复制 AI 文档\",\n        \"aiDocPreview\": \"AI 文档预览\",\n        \"manageToken\": \"管理 Token\",\n        \"openInNewTab\": \"在新标签页打开\",\n        \"advancedDesc\": \"打开高级 API 查询构建器，获取更多自定义选项。\",\n        \"openAdvanced\": \"打开查询构建器\",\n        \"queryBuilderTitle\": \"API 查询构建器\",\n        \"queryBuilderDesc\": \"可视化配置筛选、排序等参数，生成可直接运行的 API 请求代码。\",\n        \"viewApiDocs\": \"查看完整 API 文档\"\n      },\n      \"personalView\": {\n        \"personal\": \"个人\",\n        \"tip\": \"启用后，视图配置仅对你本人生效\",\n        \"collaborative\": \"协同\",\n        \"dialog\": {\n          \"title\": \"退出个人模式\",\n          \"description\": \"个人视图配置将恢复为实时协作状态，您也可以保存个人视图设置并同步给所有人。\",\n          \"cancelText\": \"退出并同步\",\n          \"confirmText\": \"确认退出\"\n        }\n      }\n    }\n  },\n  \"welcome\": {\n    \"title\": \"开始\",\n    \"emptyTitle\": \"开始构建您的数据库\",\n    \"description\": \"点击侧边栏中的“+”按钮添加资源\",\n    \"help\": \"访问 <HelpCenter /> 获取更多信息\",\n    \"helpCenter\": \"帮助中心\"\n  },\n  \"validation\": {\n    \"link\": {\n      \"batch_duplicate\": \"无法建立关联：同一批次中已有其他记录关联了该记录。在一对多关系中，每条子记录只能属于一个父记录。\",\n      \"one_many_duplicate\": \"无法建立关联：该记录已关联到其他记录。在一对多关系中，每条子记录只能属于一个父记录。\",\n      \"one_one_duplicate\": \"无法建立关联：目标记录已在一对一关系中被其他记录关联。\"\n    },\n    \"field\": {\n      \"maxColumnLimit\": \"表“{{tableName}}”最多只能有 {{maxFieldCount}} 个字段。\"\n    }\n  },\n  \"field\": {\n    \"fieldManagement\": \"字段管理\",\n    \"fieldManagementDesc\": \"当前表格所有字段的详细信息\",\n    \"advancedProps\": \"高级属性\",\n    \"hide\": \"隐藏\",\n    \"default\": {\n      \"singleLineText\": {\n        \"title\": \"单行文本\"\n      },\n      \"longText\": {\n        \"title\": \"长文本\"\n      },\n      \"number\": {\n        \"title\": \"数字\",\n        \"formatType\": \"格式类型\",\n        \"currencySymbol\": \"货币符号\",\n        \"defaultSymbol\": \"￥\",\n        \"precision\": \"精度\",\n        \"decimalExample\": \"数字 (123)\",\n        \"currencyExample\": \"货币 (￥100)\",\n        \"percentExample\": \"百分比 (20%)\"\n      },\n      \"singleSelect\": {\n        \"title\": \"单选\",\n        \"options\": {\n          \"todo\": \"待开始\",\n          \"inProgress\": \"进行中\",\n          \"done\": \"已完成\"\n        }\n      },\n      \"multipleSelect\": {\n        \"title\": \"多选\"\n      },\n      \"attachment\": {\n        \"title\": \"附件\"\n      },\n      \"user\": {\n        \"title\": \"成员\"\n      },\n      \"date\": {\n        \"title\": \"日期\",\n        \"dateFormatting\": \"日期格式\",\n        \"timeFormatting\": \"时间格式\",\n        \"timeZone\": \"时区\",\n        \"yearMonth\": \"年/月\",\n        \"monthDay\": \"月/日\",\n        \"year\": \"年\",\n        \"month\": \"月\",\n        \"day\": \"日\",\n        \"local\": \"本地\",\n        \"friendly\": \"语义化\",\n        \"us\": \"美国\",\n        \"european\": \"欧洲\",\n        \"asia\": \"亚洲\",\n        \"custom\": \"自定义\",\n        \"12Hour\": \"12 时\",\n        \"24Hour\": \"24 时\",\n        \"noDisplay\": \"不显示\"\n      },\n      \"autoNumber\": {\n        \"title\": \"自增数字\"\n      },\n      \"createdTime\": {\n        \"title\": \"创建时间\"\n      },\n      \"lastModifiedTime\": {\n        \"title\": \"修改时间\"\n      },\n      \"createdBy\": {\n        \"title\": \"创建人\"\n      },\n      \"lastModifiedBy\": {\n        \"title\": \"修改人\"\n      },\n      \"rating\": {\n        \"title\": \"评分\"\n      },\n      \"checkbox\": {\n        \"title\": \"勾选\"\n      },\n      \"button\": {\n        \"title\": \"按钮\",\n        \"label\": \"按钮文字\",\n        \"color\": \"按钮颜色\",\n        \"limitCount\": \"限制点击次数\",\n        \"resetCount\": \"允许重置点击计数\",\n        \"maxCount\": \"最大点击计数\",\n        \"automation\": \"自动化\",\n        \"customAutomation\": \"自定义自动化\",\n        \"clickConfirm\": \"点击前需要确认\",\n        \"confirmTitle\": \"标题\",\n        \"confirmDescription\": \"内容\",\n        \"confirmButtonText\": \"确认按钮文字\"\n      },\n      \"formula\": {\n        \"title\": \"计算\",\n        \"formula\": \"公式\"\n      },\n      \"lookup\": {\n        \"title\": \"从{{linkFieldName}}查找{{lookupFieldName}}\"\n      },\n      \"conditionalLookup\": {\n        \"title\": \"从{{tableName}}筛选{{lookupFieldName}}\"\n      },\n      \"rollup\": {\n        \"title\": \"{{linkFieldName}}的{{lookupFieldName}}汇总\",\n        \"rollup\": \"汇总\",\n        \"selectAnRollupFunction\": \"选择一个汇总函数\",\n        \"func\": {\n          \"count\": \"数值计数\",\n          \"countA\": \"非空计数\",\n          \"countAll\": \"全计数\",\n          \"max\": \"最大值\",\n          \"min\": \"最小值\",\n          \"sum\": \"求和\",\n          \"average\": \"平均值\",\n          \"concatenate\": \"连接\",\n          \"arrayJoin\": \"数组连接\",\n          \"arrayUnique\": \"数组去重\",\n          \"arrayCompact\": \"数组去空\",\n          \"and\": \"与\",\n          \"or\": \"或\",\n          \"xor\": \"异或\"\n        },\n        \"funcDesc\": {\n          \"count\": \"计算所有数字类型值的数量\",\n          \"countA\": \"计算所有非空值的数量\",\n          \"countAll\": \"计算所有值的数量\",\n          \"max\": \"计算所有值的最大值\",\n          \"min\": \"计算所有值的最小值\",\n          \"sum\": \"计算所有值的和\",\n          \"average\": \"计算所有值的平均值\",\n          \"concatenate\": \"将所有值连接为一个字符串\",\n          \"arrayJoin\": \"将所有值连接为一个逗号分隔的字符串\",\n          \"arrayUnique\": \"去除数组中的重复值\",\n          \"arrayCompact\": \"去除数组中的空值\",\n          \"and\": \"如果所有值都为真，则返回真\",\n          \"or\": \"如果有一个值为真，则返回真\",\n          \"xor\": \"如果奇数个值为真，则返回真\"\n        }\n      },\n      \"conditionalRollup\": {\n        \"title\": \"{{lookupFieldName}} 条件汇总\",\n        \"description\": \"通过筛选条件聚合其他表的数据。\"\n      }\n    },\n    \"editor\": {\n      \"addField\": \"添加字段\",\n      \"editField\": \"编辑字段\",\n      \"insertField\": \"插入字段\",\n      \"graph\": \"关系图\",\n      \"defaultValue\": \"默认值\",\n      \"reset\": \"清空\",\n      \"fieldUpdated\": \"字段已更新\",\n      \"fieldCreated\": \"字段已创建\",\n      \"confirmFieldChange\": \"确认字段变更\",\n      \"areYouSurePerformIt\": \"确定要执行此操作吗？\",\n      \"addDescription\": \"添加描述\",\n      \"dbFieldName\": \"物理字段名\",\n      \"description\": \"描述\",\n      \"descriptionPlaceholder\": \"描述这个字段（可选）\",\n      \"type\": \"字段类型\",\n      \"showAs\": \"显示样式\",\n      \"color\": \"颜色\",\n      \"number\": \"数字\",\n      \"chartBar\": \"条形图\",\n      \"chartLine\": \"折线图\",\n      \"ring\": \"环形\",\n      \"bar\": \"条形\",\n      \"text\": \"文本\",\n      \"markdown\": \"Markdown\",\n      \"url\": \"网址\",\n      \"email\": \"邮件\",\n      \"phone\": \"电话\",\n      \"maxNumber\": \"最大值\",\n      \"showNumber\": \"显示数值\",\n      \"autoFillDate\": \"自动填充当前日期\",\n      \"createSymmetricLink\": \"双向关联\",\n      \"allowLinkMultipleRecords\": \"允许多选\",\n      \"allowLinkToDuplicateRecords\": \"允许重复值\",\n      \"allowSymmetricFieldLinkMultipleRecords\": \"允许重复值\",\n      \"oneToOne\": \"一对一\",\n      \"oneToMany\": \"一对多\",\n      \"manyToOne\": \"多对一\",\n      \"manyToMany\": \"多对多\",\n      \"self\": \"本表\",\n      \"selectTable\": \"选择一张表...\",\n      \"selectBase\": \"选择一个数据库...\",\n      \"linkFromAnotherBase\": \"从其他数据库关联\",\n      \"inSelfLink\": \"自关联\",\n      \"betweenTwoTables\": \"关联\",\n      \"tips\": \"提示\",\n      \"linkTipMessage\": \"此配置表示<b>{{relationship}}</b>的<span>{{linkType}}</span>关系 \",\n      \"style\": \"样式\",\n      \"maximum\": \"最大值\",\n      \"addOption\": \"添加选项\",\n      \"allowMultiUsers\": \"允许添加多个用户\",\n      \"notifyUsers\": \"用户被选择后发送通知\",\n      \"searchTable\": \"搜索表...\",\n      \"calculating\": \"计算中...\",\n      \"doSaveChanges\": \"您要保存所做的更改吗？\",\n      \"linkFieldToLookup\": \"用于查找的已链接记录字段\",\n      \"lookupToTable\": \"从<bold>{{tableName}}</bold>表中选择要进行查找的字段\",\n      \"rollupToTable\": \"从<bold>{{tableName}}</bold>表中选择要进行汇总的字段\",\n      \"selectField\": \"选择一个字段...\",\n      \"lastModifiedScope\": \"追踪范围\",\n      \"lastModifiedAll\": \"所有可编辑字段\",\n      \"lastModifiedSpecific\": \"指定字段\",\n      \"lastModifiedSelect\": \"选择追踪字段\",\n      \"lastModifiedSelectAll\": \"追踪全部字段\",\n      \"noEditableFields\": \"暂无可编辑字段\",\n      \"conditionalRollup\": {\n        \"fieldMapping\": \"字段映射\",\n        \"selectBaseField\": \"选择当前表字段\",\n        \"noMappings\": \"尚未配置字段映射\"\n      },\n      \"conditionalLookup\": {\n        \"sortLimitToggleLabel\": \"对引用字段进行排序和限制引用数量\",\n        \"sortLabel\": \"排序结果\",\n        \"orderPlaceholder\": \"选择排序方式\",\n        \"clearSort\": \"清除排序\",\n        \"limitLabel\": \"显示的最大记录数\",\n        \"limitPlaceholder\": \"留空表示显示全部匹配项\",\n        \"limitHint\": \"最多仅会保存 {{limit}} 条匹配记录。\",\n        \"sortMissingWarningTitle\": \"排序字段已被删除\",\n        \"sortMissingWarningDescription\": \"用于排序的字段已删除，结果会忽略排序，仅保留数量限制。\"\n      },\n      \"linkTable\": \"进行关联的表\",\n      \"linkBase\": \"进行关联的数据库\",\n      \"tableNoPermission\": \"无权限表格\",\n      \"baseNoPermission\": \"无权限数据库\",\n      \"noLinkTip\": \"没有可查找的关联字段。请先创建关联字段，然后再次尝试配置查找。\",\n      \"fieldValidationRules\": \"字段值验证规则\",\n      \"enableValidateFieldUnique\": \"禁止重复值\",\n      \"enableValidateFieldNotNull\": \"必填\",\n      \"knowMore\": \"了解更多\",\n      \"linkFieldKnowMoreLink\": \"https://help.teable.cn/zh/basic/field/advanced/link\",\n      \"showByField\": \"选择展示的列\",\n      \"filterByView\": \"从视图筛选记录\",\n      \"filter\": \"筛选记录\",\n      \"hideFields\": \"隐藏字段\",\n      \"moreOptions\": \"更多配置\",\n      \"allowNewOptionsWhenEditing\": \"允许填写时添加选项\",\n      \"deleteField\": {\n        \"title\": \"删除字段\",\n        \"simpleConfirm\": \"确定要删除字段 <b>{{fieldName}}</b> 吗？\",\n        \"withDependencies\": \"删除字段 <b>{{fieldName}}</b> 将会影响以下字段：\",\n        \"affectedFields\": \"受影响的字段：\",\n        \"fieldsToDelete\": \"待删除字段 ({{count}})\",\n        \"unviewedHint\": \"还有 {{count}} 个字段未查看\",\n        \"deleteCount\": \"删除 {{count}} 个字段\",\n        \"noAffectedFields\": \"此字段没有被其他字段引用。\",\n        \"riskIdentified\": \"有风险({{count}})\",\n        \"noDependencies\": \"无依赖({{count}})\",\n        \"safeToDelete\": \"可安全删除\",\n        \"safeToDeleteDesc\": \"此字段未被其他字段引用，可以安全删除\",\n        \"affectedItems\": \"受影响项\",\n        \"type\": \"类型\",\n        \"source\": \"来源\",\n        \"sourceTable\": \"所属表格\"\n      }\n    },\n    \"subTitle\": {\n      \"link\": \"在表之间创建关联关系\",\n      \"singleLineText\": \"可输入单行文本，或为每个新单元格预填充一个默认值。\",\n      \"longText\": \"输入多行文本，允许进行换行。\",\n      \"attachment\": \"添加或 AI 生成图片，或上传文档或其他文件以供查看或下载。\",\n      \"checkbox\": \"勾选或取消勾选以表示状态。\",\n      \"multipleSelect\": \"在列表中选择一个或多个预定义选项。\",\n      \"singleSelect\": \"从列表中选择一个预定义选项，或为每个新单元格预填充一个默认选项。\",\n      \"user\": \"向记录中添加用户。\",\n      \"date\": \"输入日期（例如 2023/11/12）或从日历中选择。\",\n      \"number\": \"输入数字，或为每个新单元格预填充一个默认值。\",\n      \"duration\": \"以小时、分钟或秒为单位输入时间长度（例如 1:23）。\",\n      \"rating\": \"在预定分值上添加评分。\",\n      \"formula\": \"基于字段进行动态公式计算。\",\n      \"rollup\": \"汇总来自关联记录的数据。\",\n      \"conditionalRollup\": \"通过条件筛选汇总来自其他表的数据。\",\n      \"conditionalLookup\": \"通过筛选条件显示关联记录中的字段值。\",\n      \"count\": \"计算关联记录的数量。\",\n      \"createdTime\": \"查看每条记录创建的日期和时间。\",\n      \"lastModifiedTime\": \"查看每条记录的最近编辑日期和时间。\",\n      \"createdBy\": \"查看创建记录的用户。\",\n      \"lastModifiedBy\": \"查看每条记录的最近编辑的用户。\",\n      \"autoNumber\": \"为每条记录自动生成唯一的递增数字。\",\n      \"button\": \"触发自定义操作。\",\n      \"lookup\": \"通过绑定一个关联字段，从关联表中查找已关联记录的信息。\"\n    },\n    \"fieldName\": \"字段名\",\n    \"fieldNameOptional\": \"字段名 (可选)\",\n    \"fieldType\": \"字段类型\",\n    \"aiConfig\": {\n      \"title\": \"AI 配置\",\n      \"type\": {\n        \"summary\": \"总结\",\n        \"translation\": \"翻译\",\n        \"extraction\": \"提取信息\",\n        \"improvement\": \"文案改写\",\n        \"tag\": \"智能标签\",\n        \"classification\": \"智能分类\",\n        \"customization\": \"自定义生成\",\n        \"imageGeneration\": \"图像生成\",\n        \"rating\": \"评分\"\n      },\n      \"label\": {\n        \"type\": \"AI 动作类型\",\n        \"model\": \"AI 模型\",\n        \"targetLanguage\": \"目标语言\",\n        \"sourceField\": \"来源字段\",\n        \"sourceFieldForTag\": \"选择一个字段，为其匹配已创建的标签\",\n        \"sourceFieldForClassify\": \"选择一个字段，为其匹配已创建的分类\",\n        \"attachPrompt\": \"附加要求\",\n        \"prompt\": \"自定义提示\",\n        \"sourceFieldForAttachment\": \"提示词来源字段\",\n        \"imageSize\": \"图片尺寸\",\n        \"imageQuality\": \"图片质量\",\n        \"imageCount\": \"图片数量\",\n        \"aspectRatio\": \"宽高比\",\n        \"resolution\": \"分辨率\",\n        \"advancedSettings\": \"高级设置\"\n      },\n      \"placeholder\": {\n        \"summarize\": \"总结内容的关键点\",\n        \"translate\": \"翻译简洁易懂，语气轻松\",\n        \"extractInfo\": \"提取邮箱，电话，姓名，地址...\",\n        \"extractDate\": \"提取开始时间\",\n        \"improveText\": \"语气正式，友好，幽默...\",\n        \"attachPromptForTag\": \"不允许超过三个标签\",\n        \"attachPromptForClassify\": \"将\\\"正在进行中\\\"的分类为\\\"无风险\\\"\",\n        \"attachPrompt\": \"请输入附加要求\",\n        \"prompt\": \"输入 / 选择引用的字段\",\n        \"type\": \"选择 AI 动作\",\n        \"targetLanguage\": \"英文，中文，法语...\",\n        \"imageSize\": \"请输入图片尺寸\",\n        \"imageQuality\": \"请输入图片质量\",\n        \"aspectRatio\": \"选择宽高比\",\n        \"resolution\": \"选择分辨率\",\n        \"attachPromptForImageGeneration\": \"画风生动自然\",\n        \"attachPromptForRating\": \"对图像质量进行评分\"\n      },\n      \"imageQuality\": {\n        \"low\": \"低\",\n        \"medium\": \"中\",\n        \"high\": \"高\"\n      },\n      \"hint\": {\n        \"imageInputSupported\": \"💡 该模型支持图片输入，可以选择附件字段作为参考图片进行生成\",\n        \"attachmentNotSupported\": \"💡 当前模型不支持传入附件，仅 Gemini 模型支持图生图\",\n        \"singleImageOnly\": \"该模型每次只能生成一张图片\"\n      },\n      \"auto\": \"自动\",\n      \"resolution\": {\n        \"1K\": \"1K (标准)\",\n        \"2K\": \"2K (高清)\",\n        \"4K\": \"4K (超高清)\"\n      },\n      \"autoFill\": {\n        \"title\": \"自动更新\",\n        \"tip\": \"开启后，当前字段将跟随 AI 配置的内容变化而同步更新\"\n      },\n      \"autoFillFieldDialog\": {\n        \"title\": \"重新生成 AI 字段\",\n        \"description\": \"将对当前视图的 {{count}} 行执行生成，任务将在后台运行。\"\n      },\n      \"autoFillConfirm\": {\n        \"title\": \"应用 AI 字段配置\",\n        \"description\": \"将对当前视图的 {{count}} 行执行生成，任务将在后台运行。\",\n        \"generateMode\": \"生成方式\",\n        \"emptyOnlyMode\": \"仅填充空单元格\",\n        \"emptyOnlyModeDesc\": \"只对空值生成，不会覆盖已有内容\",\n        \"allMode\": \"整列生成\",\n        \"allModeDesc\": \"对所有行生成，将覆盖已有内容\",\n        \"saveOnlyMode\": \"仅保存配置\",\n        \"saveOnlyModeDesc\": \"只保存字段配置，不写入任何单元格\",\n        \"saveConfigOnly\": \"仅保存配置\",\n        \"generate\": \"生成\",\n        \"fillEmptyCells\": \"填充空单元格\",\n        \"generateAll\": \"生成整列\",\n        \"recommended\": \"推荐\",\n        \"generateFailed\": \"生成失败\",\n        \"taskLimited\": \"由于行数限制，仅处理了前 {{processedCount}} 行（共 {{rowCount}} 行匹配）\",\n        \"limitWarning\": \"由于每次任务最多处理 1,000 行，仅会处理前 1,000 行。你可以再次运行任务来处理剩余的行。\"\n      },\n      \"action\": {\n        \"addAttachment\": \"添加附件\"\n      }\n    }\n  },\n  \"table\": {\n    \"newTableLabel\": \"表格\",\n    \"rename\": \"重命名\",\n    \"design\": \"设计\",\n    \"tableRecordHistory\": \"表格记录历史\",\n    \"deleteConfirm\": \"你确定要删除 \\\"{{tableName}}\\\" 表吗？\",\n    \"dbTableName\": \"物理数据库中的表名\",\n    \"schemaName\": \"物理数据库中的模式(Schema)名\",\n    \"baseInfo\": \"数据库信息\",\n    \"typeOfDatabase\": \"数据库类型\",\n    \"descriptionForTable\": \"表的描述\",\n    \"nameForTable\": \"表的名称\",\n    \"deleteTip1\": \"其他表中关联了此表的关联字段将会被删除。\",\n    \"deleteTip2\": \"此表被删除后可在回收站中进行恢复。\",\n    \"operator\": {\n      \"createBlank\": \"新表格\"\n    },\n    \"actionTips\": {\n      \"copyAndPasteEnvironment\": \"复制和粘贴功能仅在 HTTPS 或 localhost 环境下可用\",\n      \"copyAndPasteBrowser\": \"当前浏览器不支持复制和粘贴功能\",\n      \"copying\": \"正在复制...\",\n      \"copySuccessful\": \"复制成功\",\n      \"copyFailed\": \"复制失败\",\n      \"pasting\": \"正在粘贴...\",\n      \"pasteSuccessful\": \"粘贴成功\",\n      \"pasteFailed\": \"粘贴失败\",\n      \"filling\": \"正在填充...\",\n      \"fillSuccessful\": \"填充成功\",\n      \"fillFailed\": \"填充失败\",\n      \"clearing\": \"正在清除...\",\n      \"clearSuccessful\": \"清除成功\",\n      \"clearConfirmTitle\": \"清除数据\",\n      \"clearConfirmDescription\": \"此操作将清除 {{cellCount}} 个单元格，{{rowCount}} 条记录。确定要继续吗？\",\n      \"deleteRecordConfirmTitle\": \"删除记录\",\n      \"deleteRecordConfirmDescription\": \"此操作将删除 {{recordCount}} 条记录。确定要继续吗？\",\n      \"pasteConfirmTitle\": \"执行粘贴\",\n      \"pasteConfirmDescription\": \"您粘贴的数据将影响 {{recordCount}} 条记录。确定要继续吗？\",\n      \"expandCommonDescription\": \" 为了将粘贴的数据插入到表格中，我们需要额外添加\",\n      \"expandColDescription\": \" {{count}} 个字段\",\n      \"expandRowDescription\": \" {{count}} 条记录\",\n      \"paste\": \"粘贴\",\n      \"deleteRecord\": \"删除\",\n      \"clear\": \"清除\",\n      \"conjunction\": \"和\",\n      \"deleteFieldConfirmTitle\": \"您将要删除以下字段\",\n      \"deleting\": \"正在删除...\",\n      \"deleteSuccessful\": \"删除成功\",\n      \"pasteFileFailed\": \"文件只能粘贴到附件字段中\",\n      \"copyError\": {\n        \"noFocus\": \"请保持页面激活，不要切换窗口\",\n        \"noPermission\": \"您没有权限复制记录\"\n      }\n    },\n    \"graph\": {\n      \"tableLabel\": \"表格名称\",\n      \"effectCells\": \"影响单元格数\",\n      \"estimatedTime\": \"预计完成\",\n      \"linkFieldCount\": \"影响关联字段的个数\"\n    },\n    \"integrity\": {\n      \"check\": \"检查\",\n      \"title\": \"检查完整性\",\n      \"loading\": \"检查完整性...\",\n      \"allGood\": \"一切正常！\",\n      \"fixIssues\": \"修复问题\",\n      \"type\": \"类型\",\n      \"message\": \"错误信息\",\n      \"errorType\": {\n        \"ForeignTableNotFound\": \"外键关联表不存在\",\n        \"ForeignKeyNotFound\": \"外键不存在\",\n        \"SelfKeyNotFound\": \"自关联键不存在\",\n        \"SymmetricFieldNotFound\": \"对称字段不存在\",\n        \"MissingRecordReference\": \"关联记录不存在\",\n        \"InvalidLinkReference\": \"无效的关联字段\",\n        \"ForeignKeyHostTableNotFound\": \"主键关联表不存在\",\n        \"ReferenceFieldNotFound\": \"引用字段不存在\",\n        \"UniqueIndexNotFound\": \"唯一索引不存在\",\n        \"EmptyString\": \"存在空字符串单元格\"\n      }\n    },\n    \"index\": {\n      \"description\": \"索引可以显著提升搜索性能，特别是当数据量较大(超过 {{rowCount}} 行)，缺点是写入操作略微变慢，如果经常进行搜索操作或数据量较大，建议开启索引。\",\n      \"repair\": \"修复\",\n      \"repairTip\": \"检测到索引异常，可能导致搜索性能下降，建议点击修复按钮修复索引\",\n      \"enableIndexTip\": \"您正在创建索引，创建索引的时间取决于表的大小，创建过程中，表的读写性能可能会受到一定影响，请耐心等待。\",\n      \"globalSearchTip_limited\": \"对所有字段模糊搜索，不支持日期, 勾选和按钮字段，最多支持 {{count}} 个字段\",\n      \"globalSearchTip_infinity\": \"对所有字段模糊搜索，不支持日期, 勾选和按钮字段\",\n      \"autoIndexTip\": \"检测到你的表超过 {{rowCount}} 行，建议开启索引以提升搜索性能，开启索引会略微降低写入速度。创建索引期间可能会影响表的读写性能，请耐心等待。\",\n      \"enableIndex\": \"开启\",\n      \"keepAsIs\": \"暂不开启\",\n      \"ignoreIndexError\": \"暂时忽略\"\n    },\n    \"searchTips\": {\n      \"maxFieldTips_limited\": \"最多支持 {{count}} 个字段，多余字段将被忽略\"\n    }\n  },\n  \"import\": {\n    \"title\": {\n      \"upload\": \"上传\",\n      \"import\": \"导入\",\n      \"localFile\": \"本地文件\",\n      \"linkUrl\": \"在线链接(URL)\",\n      \"linkUrlInputTitle\": \"从URl下载文件\",\n      \"importTitle\": \"创建一个新表格\",\n      \"incrementImportTitle\": \"导入到 — \",\n      \"optionsTitle\": \"导入选项\",\n      \"primitiveFields\": \"原表字段\",\n      \"importFields\": \"导入的字段\",\n      \"primaryField\": \"主键\",\n      \"tipsTitle\": \"温馨提示\",\n      \"confirm\": \"确认并继续\"\n    },\n    \"menu\": {\n      \"addFromOtherSource\": \"从第三方资源导入\",\n      \"excelFile\": \"Microsoft Excel\",\n      \"csvFile\": \"CSV 文件\",\n      \"importCsvData\": \"导入 CSV 数据\",\n      \"importExcelData\": \"导入 Microsoft Excel 数据\",\n      \"cancel\": \"取消\",\n      \"leave\": \"取消\",\n      \"downAsCsv\": \"下载 CSV\",\n      \"importData\": \"导入数据\",\n      \"duplicate\": \"复制\",\n      \"duplicating\": \"复制中...\",\n      \"duplicateSuccess\": \"复制成功\",\n      \"duplicateFailed\": \"复制失败\",\n      \"importing\": \"导入中\",\n      \"includeRecords\": \"包含记录\"\n    },\n    \"tips\": {\n      \"importWayTip\": \"点击或者拖拽到此区域上传\",\n      \"leaveTip\": \"你的数据仍然会被导入\",\n      \"fileExceedSizeTip\": \"该类型文件限制文件大小为\",\n      \"analyzing\": \"分析中\",\n      \"importing\": \"导入中\",\n      \"notSupportFieldType\": \"字段类型不支持\",\n      \"resultEmpty\": \"未查询到结果\",\n      \"searchPlaceholder\": \"搜索...\",\n      \"importAlert\": \"一旦导入，不能中止，直到导入成功或失败中止，表格右上角会显示导入状态，导入结果将会在推送到通知栏。导入期间，最好不要编辑字段，可能会引起错误。\",\n      \"noTips\": \"我已知晓，不再提示\"\n    },\n    \"options\": {\n      \"autoSelectFieldOptionName\": \"自动预测类型\",\n      \"useFirstRowAsHeaderOptionName\": \"使用第一行作为行头\",\n      \"importDataOptionName\": \"导入数据\",\n      \"sheetKey\": \"表名：\",\n      \"excludeFirstRow\": \"忽略首行\"\n    },\n    \"form\": {\n      \"defaultFieldName\": \"字段\",\n      \"error\": {\n        \"urlEmptyTip\": \"URL不能为空！\",\n        \"fieldNameEmpty\": \"字段名不能为空\",\n        \"errorFileFormat\": \"文件格式错误！\",\n        \"uniqueFieldName\": \"字段名不能重复！\",\n        \"urlValidateTip\": \"不能解析该URL，请重新输入！\",\n        \"atLeastAImportField\": \"请至少设置一个导入字段！\"\n      },\n      \"option\": {\n        \"doNotImport\": \"无需导入\"\n      }\n    }\n  },\n  \"export\": {\n    \"menu\": {\n      \"exportCsv\": \"下载 Csv文件\"\n    }\n  },\n  \"download\": {\n    \"allAttachments\": {\n      \"title\": \"下载所有附件\",\n      \"loading\": \"正在加载预览...\",\n      \"rowsWithAttachments\": \"{{count}} 行包含附件\",\n      \"totalAttachments\": \"共 {{count}} 个附件\",\n      \"totalSize\": \"总大小：{{size}}\",\n      \"startDownload\": \"开始下载\",\n      \"confirmTitle\": \"下载 {{count}} 个文件\",\n      \"confirmDescription\": \"总大小：{{size}}。文件将压缩成一个 zip 文件。\",\n      \"confirm\": \"下载\",\n      \"cancel\": \"取消\",\n      \"downloading\": \"下载中...\",\n      \"downloadingFile\": \"正在下载：{{fileName}}\",\n      \"progress\": \"{{downloaded}} / {{total}}\",\n      \"completed\": \"下载完成\",\n      \"cancelled\": \"下载已取消\",\n      \"noAttachments\": \"没有可下载的附件\",\n      \"error\": \"下载失败\",\n      \"errorPartial\": \"{{failedCount}} 个文件下载失败\",\n      \"requireHttps\": \"批量下载需要 HTTPS 环境，请通过 HTTPS 或 localhost 访问\",\n      \"advancedOptions\": \"高级选项\",\n      \"namingFieldLabel\": \"附件名前缀\",\n      \"selectField\": \"默认附件序号\",\n      \"groupByRow\": \"归档到文件夹\",\n      \"groupByRowTip\": \"同一行有多个附件时，会放入同一个文件夹中；只有一个附件的行不会创建文件夹。\"\n    }\n  },\n  \"grid\": {\n    \"prefillingRowTitle\": \"新建记录\",\n    \"prefillingRowTooltip\": \"请在下方输入新记录的数据。当焦点离开当前行后，数据将自动保存。\",\n    \"presortRowTitle\": \"此记录已经被过滤或因排序规则改变位置\"\n  },\n  \"form\": {\n    \"fieldsManagement\": \"字段管理\",\n    \"addAll\": \"全部添加\",\n    \"removeAll\": \"全部移除\",\n    \"hideFieldTip\": \"拖拽到这里移除表单中的字段\",\n    \"unableAddFieldTip\": \"不支持添加该字段类型\",\n    \"removeFromFormTip\": \"从表单中移除该字段\",\n    \"descriptionPlaceholder\": \"请输入表单描述\",\n    \"dragToFormTip\": \"拖拽字段到这里添加至表单\",\n    \"protectedFieldTip\": \"此字段已被设为\\\"必填\\\"字段，无法在表单视图中移除，请在字段设置中进行修改。\"\n  },\n  \"kanban\": {\n    \"toolbar\": {\n      \"hideFieldName\": \"隐藏字段名\",\n      \"customizeCards\": \"卡片配置\",\n      \"stackedBy\": \"分组依据 <0/>\",\n      \"chooseStackingField\": \"选择分组依据\",\n      \"chooseStackingFieldDescription\": \"您希望使用哪个字段来设置看板？您的记录将根据这个字段来进行分组。\",\n      \"hideEmptyStack\": \"隐藏空的分组\",\n      \"imageSetting\": \"封面设置\",\n      \"fit\": \"适应\",\n      \"noImage\": \"无封面\",\n      \"chooseAttachmentField\": \"请选择一个附件字段\"\n    },\n    \"stack\": {\n      \"addStack\": \"添加分组\",\n      \"noCards\": \"暂无卡片\",\n      \"uncategorized\": \"未分类\"\n    },\n    \"stackMenu\": {\n      \"collapseStack\": \"折叠分组\",\n      \"renameStack\": \"重命名分组\",\n      \"deleteStack\": \"删除分组\"\n    },\n    \"cardMenu\": {\n      \"insertCardAbove\": \"在上方插入卡片\",\n      \"insertCardBelow\": \"在下方插入卡片\",\n      \"expandCard\": \"展开卡片\",\n      \"deleteCard\": \"删除卡片\",\n      \"duplicateCard\": \"复制卡片\"\n    }\n  },\n  \"calendar\": {\n    \"toolbar\": {\n      \"config\": \"日历配置\",\n      \"startDateField\": \"开始日期字段\",\n      \"endDateField\": \"结束日期字段\",\n      \"titleField\": \"标题字段\",\n      \"colorField\": \"颜色字段\",\n      \"colorType\": \"颜色显示\",\n      \"customColor\": \"自定义颜色\",\n      \"alignWithRecords\": \"跟随字段颜色\"\n    },\n    \"placeholder\": {\n      \"selectColorField\": \"选择一个颜色字段\"\n    },\n    \"dialog\": {\n      \"startDate\": \"开始日期\",\n      \"endDate\": \"结束日期\",\n      \"notAdd\": \"暂不添加\",\n      \"addDateField\": \"添加日期字段\",\n      \"content\": \"新建日历视图日程，表格中需要存在开始和结束两个日期字段\"\n    },\n    \"moreLinkText\": \"展示全部 {{count}} 条\"\n  },\n  \"menu\": {\n    \"insertRecordAbove\": \"在上方插入 <input /> 记录\",\n    \"insertRecordBelow\": \"在下方插入 <input /> 记录\",\n    \"copyCells\": \"复制单元格\",\n    \"deleteRecord\": \"删除记录\",\n    \"deleteAllSelectedRecords\": \"删除所有选定的记录\",\n    \"editField\": \"编辑字段\",\n    \"duplicateField\": \"复制字段\",\n    \"insertFieldLeft\": \"在左侧插入字段\",\n    \"insertFieldRight\": \"在右侧插入字段\",\n    \"freezeUpField\": \"冻结至此字段\",\n    \"hideField\": \"隐藏字段\",\n    \"deleteField\": \"删除字段\",\n    \"deleteAllSelectedFields\": \"删除所有选定的字段\",\n    \"filterField\": \"按此字段筛选\",\n    \"sortField\": \"按此字段排序\",\n    \"groupField\": \"按此字段分组\",\n    \"downloadAllAttachments\": \"下载所有文件\",\n    \"autoFill\": \"生成\",\n    \"groupMenuTitle\": \"分组菜单\",\n    \"expandGroup\": \"展开该组及子分组\",\n    \"collapseGroup\": \"收起该组及子分组\",\n    \"expandAllGroups\": \"展开所有分组\",\n    \"collapseAllGroups\": \"收起所有分组\",\n    \"addToChat\": \"添加到对话\"\n  },\n  \"connection\": {\n    \"title\": \"数据库连接\",\n    \"description\": \"你可以通过数据库连接来直接访问数据库\",\n    \"noPermission\": \"你没有权限访问数据库连接\",\n    \"connectionCountTip\": \"最大数据库连接数为 <b>{{max}}</b> 当前已建立的连接数为 <b>{{current}}</b>\",\n    \"createFailed\": \"请确保环境变量 PUBLIC_DATABASE_PROXY 设置正确\",\n    \"helpLink\": \"https://help.teable.cn/zh/deploy/database-connection\"\n  },\n  \"view\": {\n    \"addRecord\": \"添加记录\",\n    \"searchView\": \"搜索视图...\",\n    \"dragToolTip\": \"自动排序已经打开，无法进行手动拖拽排序\",\n    \"insertToolTip\": \"自动排序已经打开，无法指定插入位置\",\n    \"action\": {\n      \"rename\": \"重命名\",\n      \"duplicate\": \"复制视图\",\n      \"delete\": \"删除视图\",\n      \"lock\": \"锁定视图\",\n      \"unlock\": \"解锁视图\",\n      \"enable\": \"启用\"\n    },\n    \"category\": {\n      \"table\": \"表格视图\",\n      \"form\": \"表单视图\",\n      \"kanban\": \"看板视图\",\n      \"gallery\": \"画册视图\",\n      \"calendar\": \"日历视图\"\n    },\n    \"crash\": {\n      \"title\": \"页面崩溃!\",\n      \"description\": \"当前页面意外崩溃，请刷新重试，如果持续出现问题请咨询 support@teable.ai 获取帮助\"\n    },\n    \"addPluginView\": \"视图插件\",\n    \"search\": {\n      \"field_one\": \"{{name}}\",\n      \"field_other\": \"字段({{length}})\"\n    },\n    \"locked\": {\n      \"tip\": \"当前视图已被锁定，你可以启用个人模式来编辑视图选项，修改将仅对你自己生效\"\n    },\n    \"noView\": \"当前表格没有视图\"\n  },\n  \"lastModifiedTime\": \"修改时间\",\n  \"lastModify\": \"上次修改：\",\n  \"pasteNewRecords\": {\n    \"title\": \"确认新增记录？\",\n    \"description\": \"将会新增 {{count}} 条记录\"\n  },\n  \"tableTrash\": {\n    \"title\": \"表格回收站\",\n    \"resourceType\": \"资源类型\",\n    \"deletedResource\": \"已删除资源\"\n  },\n  \"plugin\": {\n    \"recent\": \"最近使用\",\n    \"more\": \"探索更多...\"\n  },\n  \"pluginPanel\": {\n    \"empty\": {\n      \"description\": \"没有插件面板\"\n    },\n    \"createPluginPanel\": {\n      \"button\": \"创建插件面板\",\n      \"title\": \"创建插件面板\"\n    },\n    \"namePlaceholder\": \"输入插件面板名称\"\n  },\n  \"addPlugin\": \"添加插件\",\n  \"pluginContextMenu\": {\n    \"mangeButton\": \"管理插件\",\n    \"manage\": \"管理你的上下文菜单插件\",\n    \"noPlugin\": \"没有上下文菜单插件\",\n    \"delete\": \"删除\",\n    \"deleteDescription\": \"你确定要删除这个插件吗？\"\n  },\n  \"permission\": {\n    \"cell\": {\n      \"deniedRead\": \"你无权限查看单元格\",\n      \"deniedUpdate\": \"你无权限更新单元格\"\n    }\n  },\n  \"baseShare\": {\n    \"title\": \"分享 Base\",\n    \"shareTitle\": \"分享\",\n    \"shareToWeb\": \"公开分享到网络\",\n    \"description\": \"分享「{{baseName}}」给其他人\",\n    \"nodeShareDescription\": \"分享「{{nodeName}}」及其内容\",\n    \"shareLinks\": \"分享链接\",\n    \"newLink\": \"新建链接\",\n    \"noShareLinks\": \"暂无分享链接\",\n    \"createFirstLink\": \"创建分享链接\",\n    \"editSettings\": \"编辑设置\",\n    \"refreshLink\": \"重新生成链接\",\n    \"deleteLink\": \"删除链接\",\n    \"deleteConfirmTitle\": \"删除分享链接\",\n    \"deleteConfirmDescription\": \"此操作不可撤销，所有持有该链接的用户将无法继续访问。\",\n    \"createSuccess\": \"分享链接已创建\",\n    \"createFailed\": \"创建分享链接失败\",\n    \"updateSuccess\": \"分享设置已更新\",\n    \"updateFailed\": \"更新分享设置失败\",\n    \"deleteSuccess\": \"分享链接已删除\",\n    \"deleteFailed\": \"删除分享链接失败\",\n    \"refreshSuccess\": \"分享链接已重新生成\",\n    \"refreshFailed\": \"重新生成分享链接失败\",\n    \"copied\": \"链接已复制到剪贴板\",\n    \"shareLink\": \"分享链接\",\n    \"linkHolderLabel\": \"获得链接的人\",\n    \"linkHolderCanView\": \"可查看\",\n    \"linkHolderCanEdit\": \"可编辑\",\n    \"linkHolderCanCopyAndSave\": \"可另存为副本\",\n    \"passwordProtection\": \"密码保护\",\n    \"enterPassword\": \"输入密码\",\n    \"selectNodes\": \"选择数据表\",\n    \"shareEntireBase\": \"分享整个 Base\",\n    \"shareSelectedNodes\": \"分享选定的节点\",\n    \"shareEntireBaseDescription\": \"新增的数据表和文件夹会自动被分享\",\n    \"noNodesSelectedWarning\": \"请至少选择一个节点进行分享\",\n    \"allowSave\": \"允许保存到空间\",\n    \"allowSaveDescription\": \"允许查看者将此 Base 的副本保存到他们自己的空间\",\n    \"allowCopy\": \"允许复制数据\",\n    \"allowCopyData\": \"允许查看者复制数据\",\n    \"allowDuplicate\": \"允许查看者另存为副本\",\n    \"allowCopyDescription\": \"允许查看者复制此分享 Base 中的表格数据\",\n    \"selectedNodes\": \"已选择 {{count}} 个数据表\",\n    \"allNodes\": \"所有数据表\",\n    \"sharedNode\": \"已分享节点\",\n    \"sharedNodeDescription\": \"此分享链接仅包含特定节点及其子节点\",\n    \"publicShareTitle\": \"公开分享链接\",\n    \"publicShareCount\": \"{{count}} 个公开分享链接\",\n    \"noPublicShare\": \"暂无公开分享链接\",\n    \"security\": \"安全设置\",\n    \"restrictByPassword\": \"需要密码访问\",\n    \"advanced\": \"高级选项\",\n    \"embedConfig\": \"嵌入配置\",\n    \"appPublicLink\": \"应用访问链接\",\n    \"appNotPublished\": \"此应用尚未发布，请先发布应用后再分享\",\n    \"goToPublish\": \"去发布\",\n    \"publishSuccess\": \"应用发布成功\",\n    \"publishFailed\": \"应用发布失败\",\n    \"openLink\": \"打开链接\",\n    \"appPublished\": \"应用已发布\",\n    \"shareTableTab\": \"分享数据表\",\n    \"shareViewTab\": \"分享视图\",\n    \"shareNodeTab\": \"分享节点\"\n  },\n  \"aiChat\": {\n    \"tool\": {\n      \"getTableFields\": \"获取表格字段\",\n      \"getTablesMeta\": \"获取表格元数据\",\n      \"sqlQuery\": \"执行 SQL 查询\",\n      \"generateScriptAction\": \"生成脚本\",\n      \"getScriptInput\": \"获取脚本输入\",\n      \"getTeableApi\": \"获取 API\",\n      \"dataVisualization\": \"数据可视化\",\n      \"updateBase\": \"更新数据库信息\",\n      \"args\": \"参数\",\n      \"result\": \"结果\",\n      \"thinking\": \"思考中...\",\n      \"toBeConfirmed\": \"待确认\",\n      \"errorMessage\": \"错误信息\",\n      \"confirm\": \"确认\",\n      \"createRecordsSuccess\": \"成功创建 {{count}} 条记录\",\n      \"createRecordsFailed\": \"创建记录失败\",\n      \"updateRecordsSuccess\": \"成功更新 {{count}} 条记录\",\n      \"updateRecordsFailed\": \"更新记录失败\",\n      \"generatingRecords\": \"正在生成 {{count}} 条记录...\",\n      \"creatingRecords\": \"正在创建 {{count}} 条记录...\",\n      \"updatingRecords\": \"正在更新 {{count}} 条记录...\",\n      \"recordsPreview\": \"记录预览\",\n      \"andMoreRecords\": \"还有 {{count}} 条...\",\n      \"unknownError\": \"未知错误\",\n      \"recordIds\": \"记录 ID\",\n      \"records\": \"条记录\",\n      \"viewAll\": \"查看全部\",\n      \"showLess\": \"收起\",\n      \"generatingData\": \"正在生成数据...\",\n      \"generatingUpdates\": \"正在生成更新...\",\n      \"recordsGenerated\": \"已生成 {{count}} 条记录\",\n      \"recordsCount\": \"记录({{count}})\",\n      \"fieldsCount\": \"字段({{count}})\",\n      \"fieldsGenerated\": \"已生成 {{count}} 个字段\",\n      \"updatedProperties\": \"已更新 ({{count}})\",\n      \"configured\": \"已配置\",\n      \"recordsToUpdate\": \"待更新 {{count}} 条记录\",\n      \"showingLast\": \"后 {{count}} 条\",\n      \"recordLabel\": \"记录\",\n      \"statusGenerating\": \"生成中...\",\n      \"statusCreating\": \"创建中...\",\n      \"statusUpdating\": \"更新中...\",\n      \"statusCreated\": \"已创建\",\n      \"statusUpdated\": \"已更新\",\n      \"getApps\": {\n        \"title\": \"应用列表\",\n        \"loading\": \"加载应用中...\",\n        \"foundApps\": \"找到 {{count}} 个应用\",\n        \"noApps\": \"当前数据库中没有应用\",\n        \"openApp\": \"打开应用\"\n      },\n      \"generateApp\": {\n        \"title\": \"应用生成器\",\n        \"creatingApp\": \"创建应用\",\n        \"updatingApp\": \"更新应用\",\n        \"generatingApp\": \"生成应用\",\n        \"generating\": \"生成中...\",\n        \"openApp\": \"打开应用\",\n        \"viewProgress\": \"查看进度\",\n        \"building\": \"构建中...\"\n      },\n      \"generateAutomation\": {\n        \"title\": \"自动化构建器\",\n        \"creatingAutomation\": \"创建自动化\",\n        \"updatingAutomation\": \"更新自动化\",\n        \"generatingAutomation\": \"构建自动化\",\n        \"building\": \"构建中...\",\n        \"openAutomation\": \"打开自动化\",\n        \"viewProgress\": \"查看进度\",\n        \"testResults\": \"测试结果\",\n        \"triggerTest\": \"触发器\",\n        \"actionTest\": \"动作 {{index}}\"\n      },\n      \"htmlPreview\": {\n        \"preview\": \"预览\",\n        \"code\": \"代码\",\n        \"download\": \"下载\",\n        \"downloadHtml\": \"HTML\",\n        \"downloadImage\": \"图片 (PNG)\",\n        \"copy\": \"复制\",\n        \"copied\": \"已复制!\",\n        \"fullscreen\": \"全屏\",\n        \"exitFullscreen\": \"退出全屏\",\n        \"downloadSuccess\": \"图片下载成功\",\n        \"downloadFailed\": \"图片捕获失败\",\n        \"iframeFailed\": \"捕获失败：无法访问 iframe\"\n      },\n      \"loadAttachment\": {\n        \"title\": \"加载附件\",\n        \"loading\": \"正在加载附件\",\n        \"failed\": \"加载附件失败\",\n        \"empty\": \"没有加载任何附件\",\n        \"modeNative\": \"AI 视觉\",\n        \"modeNativeDesc\": \"已直接加载到 AI 原生上下文，可进行图像分析\",\n        \"modeExtracted\": \"文本提取\",\n        \"modeExtractedDesc\": \"文件内容已解析并提取为文本用于分析\",\n        \"visionLoaded\": \"已加载用于视觉分析\",\n        \"pdfLoaded\": \"已加载用于 PDF 分析\",\n        \"textExtracted\": \"已提取 {{chars}} 字符\",\n        \"contextLoaded\": \"已加载到 AI 上下文\",\n        \"truncated\": \"已截断\",\n        \"preview\": \"预览\"\n      },\n      \"textExtract\": {\n        \"title\": \"文本提取\",\n        \"loading\": \"正在提取文本\",\n        \"failed\": \"文本提取失败\",\n        \"empty\": \"未提取到文本\",\n        \"preview\": \"预览\",\n        \"truncated\": \"已截断\",\n        \"previews\": \"个预览\",\n        \"chars\": \"{{chars}} 字符\",\n        \"totalCharacters\": \"总计: {{chars}} 字符\",\n        \"filesTruncated\": \"({{count}} 个文件已截断)\"\n      },\n      \"importExcel\": {\n        \"title\": \"导入 Excel\",\n        \"loading\": \"正在处理文件...\",\n        \"failed\": \"导入失败\",\n        \"suggestions\": \"建议\",\n        \"analyzeComplete\": \"分析完成\",\n        \"worksheets\": \"工作表\",\n        \"columns\": \"列\",\n        \"importComplete\": \"导入完成\",\n        \"stageAnalyze\": \"分析中\",\n        \"stageImport\": \"导入中\"\n      }\n    },\n    \"tools\": {\n      \"getTeableApi\": \"获取 Teable API\",\n      \"readFiles\": \"读取文件\",\n      \"writeFile\": \"写入文件\",\n      \"deleteFiles\": \"删除文件\",\n      \"listFiles\": \"列出文件\",\n      \"addDependencies\": \"添加依赖\",\n      \"checkBuildErrors\": \"检查构建错误\",\n      \"lint\": \"代码检查\"\n    },\n    \"fallback\": {\n      \"previewLoadFailed\": \"参数错误\",\n      \"retry\": \"重试 {{count}} 次\",\n      \"chatAborted\": \"对话已中断\"\n    },\n    \"preview\": {\n      \"deletedTable\": \"表格不存在或已删除\",\n      \"deletedView\": \"视图不存在或已删除\",\n      \"deletedField\": \"字段不存在或已删除\",\n      \"deletedRecords\": \"记录不存在或已删除\"\n    },\n    \"agentName\": {\n      \"tableOperatorAgent\": \"表格工具\",\n      \"viewOperatorAgent\": \"视图工具\",\n      \"fieldOperatorAgent\": \"字段工具\",\n      \"recordOperatorAgent\": \"记录工具\",\n      \"buildBaseAgent\": \"构建数据库工具\",\n      \"buildAutomationAgent\": \"构建自动化工具\"\n    },\n    \"confirm\": {\n      \"toBeConfirmed\": \"待确认\",\n      \"deleteWarning\": \"你确定要删除吗？\"\n    },\n    \"action\": {\n      \"createTable\": \"创建表格\",\n      \"updateTable\": \"更新表格\",\n      \"updateTableName\": \"更新表格名称\",\n      \"deleteTable\": \"删除表格\",\n      \"createView\": \"创建视图\",\n      \"updateView\": \"更新视图\",\n      \"updateViewName\": \"更新视图名称\",\n      \"deleteView\": \"删除视图\",\n      \"createField\": \"创建字段\",\n      \"createAiField\": \"创建 AI 字段\",\n      \"createLinkField\": \"创建关联字段\",\n      \"createLookupField\": \"创建查找字段\",\n      \"createRollupField\": \"创建聚合字段\",\n      \"createFormulaField\": \"创建公式字段\",\n      \"deleteField\": \"删除字段\",\n      \"updateField\": \"更新字段\",\n      \"createRecord\": \"创建记录\",\n      \"createRecords\": \"创建记录\",\n      \"deleteRecord\": \"删除记录\",\n      \"updateRecord\": \"更新记录\",\n      \"updateRecords\": \"更新记录\",\n      \"updateBase\": \"更新数据库信息\",\n      \"planTask\": \"规划任务中...\",\n      \"generateTables\": \"生成表格\",\n      \"generatePrimaryFields\": \"生成主键字段\",\n      \"generateFields\": \"生成字段\",\n      \"generateViews\": \"生成视图\",\n      \"generateRecords\": \"生成记录\",\n      \"generateAIFields\": \"生成 AI 字段\",\n      \"generateLinkFields\": \"生成关联字段\",\n      \"generateLookupFields\": \"生成查找字段\",\n      \"generateRollupFields\": \"生成聚合字段\",\n      \"generateFormulaFields\": \"生成公式字段\",\n      \"generateWorkflow\": \"生成工作流\",\n      \"generateTrigger\": \"生成触发器\",\n      \"generateScriptAction\": \"生成 script 节点\",\n      \"generateSendMailAction\": \"生成发送邮件节点\",\n      \"generateAction\": \"生成节点\",\n      \"setupAutomationTrigger\": \"设置自动化触发器\",\n      \"testAutomationNode\": \"测试自动化节点\",\n      \"activateAutomation\": \"启用自动化\",\n      \"deleteAutomationNode\": \"删除 {{nodeType}} 节点\",\n      \"executeScript\": \"执行脚本\",\n      \"wait\": \"等待\",\n      \"generateScriptFlowChart\": \"生成 script 流程图\",\n      \"triggerAiFill\": \"触发 AI 填充\",\n      \"getRelativeData\": \"获取相关数据\",\n      \"getPreviousNodeOutputVariables\": \"获取其他节点输出变量\",\n      \"getApiJson\": \"获取 API 信息\",\n      \"generateScriptAndDependencies\": \"生成脚本和依赖\",\n      \"initialize\": \"初始化环境\",\n      \"rename\": \"生成应用名称\",\n      \"buildTest\": \"构建测试\",\n      \"developTask\": \"开发任务\",\n      \"generateSummary\": \"生成总结\",\n      \"previewEnvironment\": \"准备预览环境\",\n      \"analyzingAttachment\": \"分析附件中...\",\n      \"locateResource\": \"定位\",\n      \"goTo\": \"前往\",\n      \"operationSuccess\": \"操作成功完成\",\n      \"operationFailed\": \"操作失败\"\n    },\n    \"aiFill\": {\n      \"processedRecords\": \"已排队 {{count}} 条记录进行 AI 生成\"\n    },\n    \"queryTool\": {\n      \"getRecords\": \"查询记录\",\n      \"getRecordsWithTable\": \"查询记录 · {{tableName}}\",\n      \"getGridRows\": \"查询视图行\",\n      \"getGridRowsWithTable\": \"查询视图行 · {{tableName}}\",\n      \"getFields\": \"查询字段\",\n      \"getFieldsWithTable\": \"查询字段 · {{tableName}}\",\n      \"getTables\": \"查询表\",\n      \"getViews\": \"查询视图\",\n      \"getViewsWithTable\": \"查询视图 · {{tableName}}\",\n      \"sqlQuery\": \"SQL 查询\",\n      \"querying\": \"查询中...\",\n      \"queryFailed\": \"查询失败\",\n      \"aborted\": \"已中止\",\n      \"noData\": \"无返回数据\",\n      \"dataFormatError\": \"数据格式错误\",\n      \"unsupportedQueryType\": \"不支持的查询类型: {{toolName}}\",\n      \"returnedRecords\": \"返回 {{count}} 条记录\",\n      \"record\": \"记录 {{index}}\",\n      \"moreRecords\": \"... +{{count}} 条记录\",\n      \"foundFields\": \"找到 {{count}} 个字段\",\n      \"moreFields\": \"... +{{count}} 个字段\",\n      \"foundTables\": \"找到 {{count}} 张表\",\n      \"moreTables\": \"... +{{count}} 张表\",\n      \"foundViews\": \"找到 {{count}} 个视图\",\n      \"moreViews\": \"... +{{count}} 个视图\",\n      \"queryReturned\": \"查询返回 {{rowCount}} 行 × {{columnCount}} 列\",\n      \"row\": \"行 {{index}}\",\n      \"moreRows\": \"... +{{count}} 行\",\n      \"getDoc\": \"获取文档\",\n      \"getDocWithTopic\": \"获取文档 · {{topic}}\",\n      \"getAutomations\": \"查询自动化\",\n      \"getAutomation\": \"查询自动化详情\",\n      \"getAutomationRuns\": \"查询自动化运行记录\",\n      \"foundAutomations\": \"找到 {{count}} 个自动化\",\n      \"moreAutomations\": \"... +{{count}} 个自动化\",\n      \"foundRuns\": \"找到 {{count}} 条运行记录\",\n      \"moreRuns\": \"... +{{count}} 条记录\",\n      \"active\": \"已启用\",\n      \"trigger\": \"触发器\",\n      \"actions\": \"{{count}} 个操作\",\n      \"moreActions\": \"... +{{count}} 个操作\",\n      \"getUserIntegrations\": \"检查集成\",\n      \"connectedIntegrations\": \"已连接\",\n      \"availableToConnect\": \"可连接\",\n      \"connect\": \"连接\",\n      \"noIntegrationsAvailable\": \"暂无可用集成\",\n      \"activateTool\": \"激活工具\",\n      \"webSearch\": \"网络搜索\",\n      \"webSearchResults\": \"找到 {{count}} 条结果\",\n      \"webSearchCompleted\": \"搜索完成\",\n      \"searchApi\": \"搜索 API\",\n      \"searchApiWithQuery\": \"搜索 API · {{query}}\",\n      \"noApiFound\": \"未找到 API\",\n      \"foundApis\": \"找到 {{count}} 个 API\",\n      \"totalApis\": \"共 {{count}} 个可用 API\",\n      \"callApi\": \"调用 API\",\n      \"callApiWithMethod\": \"{{method}} {{path}}...\",\n      \"response\": \"响应\",\n      \"success\": \"成功\",\n      \"failed\": \"失败\",\n      \"inputData\": \"输入数据\",\n      \"availableNodes\": \"可用节点\",\n      \"hasPreviousCode\": \"已有现有代码\",\n      \"noInputData\": \"无可用输入数据\"\n    },\n    \"showUI\": {\n      \"connect\": \"连接\",\n      \"connecting\": \"连接中...\",\n      \"connected\": \"已连接\",\n      \"connectToUse\": \"连接 {{provider}} 以在自动化中使用\",\n      \"checkingConnection\": \"正在检查连接状态...\",\n      \"confirm\": \"确认\",\n      \"confirmed\": \"已确认\",\n      \"cancel\": \"取消\",\n      \"cancelled\": \"已取消\",\n      \"connectionCancelled\": \"连接已取消\"\n    },\n    \"codeBlock\": {\n      \"hiddenLines\": \"{{count}}行已隐藏\",\n      \"collapseCode\": \"收起代码\",\n      \"code\": \"代码\",\n      \"preview\": \"预览\"\n    },\n    \"buildFlow\": {\n      \"progress\": \"构建进度\",\n      \"completed\": \"应用构建完成\",\n      \"completedDesc\": \"所有步骤已成功完成，应用已准备好预览\",\n      \"stepStatus\": {\n        \"initializing\": \"正在初始化环境...\",\n        \"naming\": \"正在生成应用名称...\",\n        \"planning\": \"正在分析需求并制定开发计划...\",\n        \"developing\": \"正在编写代码和实现功能...\",\n        \"summarizing\": \"正在整理开发结果...\",\n        \"deploying\": \"正在部署到预览环境...\",\n        \"testing\": \"正在构建测试...\"\n      },\n      \"moduleStatus\": {\n        \"running\": \"执行中\",\n        \"completed\": \"已完成\",\n        \"error\": \"执行失败\",\n        \"pending\": \"等待中\"\n      },\n      \"toolStatus\": {\n        \"running\": \"执行中\",\n        \"completed\": \"已完成\",\n        \"error\": \"执行失败\"\n      }\n    },\n    \"generateScript\": {\n      \"generateSuccess\": \"生成脚本成功\"\n    },\n    \"buildBase\": {\n      \"title\": \"生成数据库\",\n      \"generateSuccess\": \"生成数据库成功\",\n      \"generateError\": \"生成数据库失败\"\n    },\n    \"buildAutomation\": {\n      \"title\": \"生成自动化\",\n      \"generateSuccess\": \"生成自动化成功\"\n    },\n    \"automation\": {\n      \"created\": \"已创建\",\n      \"updated\": \"已更新\",\n      \"workflow\": \"工作流\",\n      \"trigger\": \"触发器\",\n      \"scriptAction\": \"脚本动作\",\n      \"workflowLabel\": \"工作流\",\n      \"triggerLabel\": \"触发器\",\n      \"scriptActionLabel\": \"脚本动作\",\n      \"workflowId\": \"工作流\",\n      \"triggerId\": \"触发器\",\n      \"scriptActionId\": \"脚本动作\",\n      \"viewAutomation\": \"查看\",\n      \"navigateToAutomation\": \"跳转到自动化\",\n      \"triggerType\": {\n        \"recordCreated\": \"记录创建时\",\n        \"recordUpdated\": \"记录更新时\",\n        \"recordCreatedOrUpdated\": \"记录创建或更新时\",\n        \"formSubmitted\": \"表单提交时\",\n        \"scheduledTime\": \"定时触发\",\n        \"buttonClick\": \"按钮点击时\"\n      },\n      \"activated\": \"已启用\",\n      \"deactivated\": \"已禁用\",\n      \"discarded\": \"已撤销更改\",\n      \"activateFailed\": \"启用失败\",\n      \"deactivateFailed\": \"禁用失败\",\n      \"discardFailed\": \"撤销失败\",\n      \"scriptUpdated\": \"脚本已更新\",\n      \"scriptUpdateFailed\": \"更新失败\",\n      \"scriptExecuted\": \"脚本已执行\",\n      \"scriptExecutionFailed\": \"执行失败\",\n      \"scriptReady\": \"脚本已准备好\",\n      \"executingScript\": \"正在执行脚本...\",\n      \"waitedSeconds\": \"已等待 {{seconds}} 秒\",\n      \"waitFailed\": \"等待失败\",\n      \"flowchartGenerated\": \"流程图已生成\",\n      \"flowchartGenerationFailed\": \"生成失败\"\n    },\n    \"newChat\": \"新对话\",\n    \"clearChat\": \"清除对话\",\n    \"expand\": \"展开\",\n    \"history\": \"历史记录\",\n    \"close\": \"收起\",\n    \"clearChatConfirmTitle\": \"确认清除对话\",\n    \"clearChatConfirmDesc\": \"当前对话内容将不会被保存，确定要清除吗？\",\n    \"dontShowAgain\": \"不再提醒\",\n    \"noModel\": \"暂无可用模型\",\n    \"addAttachment\": \"添加附件\",\n    \"noHistory\": \"暂无聊天记录\",\n    \"noFoundHistory\": \"没有找到匹配的聊天记录，请开始新的对话\",\n    \"timeGroup\": {\n      \"today\": \"今天\",\n      \"oneWeek\": \"一周内\",\n      \"twoWeek\": \"两周内\",\n      \"oneMonth\": \"一月内\",\n      \"other\": \"更早\"\n    },\n    \"context\": {\n      \"button\": \"添加上下文\",\n      \"search\": \"添加表格\",\n      \"searchEmpty\": \"暂无匹配的上下文\",\n      \"emptyContext\": \"暂无可添加的上下文\",\n      \"selectionRows\": \"行 {{start}}-{{end}}\"\n    },\n    \"inputPlaceholder\": \"描述你想要做什么\",\n    \"thought\": \"深度思考\",\n    \"meta\": {\n      \"timeCostUnit\": \"秒\",\n      \"timeCostDescription\": \"生成耗时 {{timeCost}} 秒\",\n      \"creditDescription\": \"消耗 {{credits}} 算力\",\n      \"tokenDescription\": \"本次消耗：{{tokens}} tokens\",\n      \"input\": \"输入\",\n      \"output\": \"输出\",\n      \"tokens\": \"Tokens\",\n      \"totalTimeCost\": \"总耗时\",\n      \"totalCreditCost\": \"总计\",\n      \"customModel\": \"自定义模型\",\n      \"tokenDetails\": \"Token 详情\",\n      \"cachedInput\": \"缓存输入 (节省90%)\",\n      \"cacheWrite\": \"缓存写入\",\n      \"reasoning\": \"推理 (思考)\",\n      \"taskCompleted\": \"任务完成\"\n    },\n    \"dataVisualization\": {\n      \"error\": \"数据可视化失败\"\n    },\n    \"tips\": {\n      \"modelTips\": \"只允许管理员在后台配置\"\n    },\n    \"attachment\": {\n      \"imageNotSupported\": \"不支持图片\",\n      \"attachmentSizeExceeded\": \"附件大小超过限制 {{size}}MB\"\n    },\n    \"suggestions\": {\n      \"recommend\": \"推荐\",\n      \"ask\": \"提问\",\n      \"analyze\": \"分析\",\n      \"build\": \"构建\",\n      \"title\": \"有什么可以帮您？\",\n      \"whatCanIDo\": \"我能做什么？\",\n      \"createOrModifyDatabase\": \"创建或修改数据库\",\n      \"buildAutomations\": \"构建自动化\",\n      \"buildApps\": \"构建应用\",\n      \"buildMeCRM\": \"帮我创建一个 CRM\",\n      \"addAIField\": \"添加 AI 字段来分析每个客户\",\n      \"createDataAnalysis\": \"帮我创建数据分析报告\",\n      \"emailWhenRecordCreated\": \"当记录创建时发邮件通知我\",\n      \"syncStatusToSlack\": \"将状态更新同步到 Slack\",\n      \"buildDashboard\": \"从这个表格构建仪表盘\",\n      \"buildLeadCapture\": \"帮我创建一个潜客获取落地页\"\n    },\n    \"buildApp\": {\n      \"thinking\": {\n        \"duration\": \"思考用时 {{duration}} 秒\"\n      },\n      \"task\": {\n        \"searching\": \"搜索 \\\"{{query}}\\\"\",\n        \"readingFiles\": \"读取文件:\",\n        \"foundResults\": \"找到 {{count}} 个结果\",\n        \"noIssuesFound\": \"未发现问题\",\n        \"defaultTitle\": \"任务\"\n      },\n      \"codeProject\": {\n        \"defaultTitle\": \"代码项目\"\n      }\n    },\n    \"scriptPreview\": {\n      \"aiModelRequired\": \"需要 AI 模型才能生成预览\",\n      \"writeCodeHint\": \"编写代码以查看流程图预览\",\n      \"noPreview\": \"暂无流程图预览\",\n      \"generatePreview\": \"生成预览\",\n      \"analyzing\": \"正在分析脚本...\",\n      \"codeChanged\": \"自上次生成预览后代码已更改\",\n      \"regenerate\": \"重新生成\",\n      \"refresh\": \"刷新\",\n      \"regenerating\": \"正在重新生成...\"\n    }\n  },\n  \"upload\": {\n    \"panelUploading\": \"正在上传 {{count}} 个文件\",\n    \"panelFailed\": \"{{count}} 个文件失败\",\n    \"panelCompleted\": \"{{count}} 个文件已完成\",\n    \"statusFailed\": \"上传失败\",\n    \"statusCompleted\": \"完成\",\n    \"statusCancel\": \"取消上传\",\n    \"statusRetry\": \"重试上传\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/token.json",
    "content": "{\n  \"access\": \"访问\",\n  \"name\": \"名称\",\n  \"description\": \"描述\",\n  \"scopes\": \"范围\",\n  \"expiration\": \"有效期\",\n  \"createdTime\": \"创建时间\",\n  \"lastUse\": \"上次使用\",\n  \"allSpace\": \"该空间，该空间目前和未来的所有数据库\",\n  \"formLabelTips\": {\n    \"name\": \"提供一个令牌名称\",\n    \"description\": \"这个令牌是用来做什么的？\",\n    \"scopes\": \"使用此令牌，您将能够：\",\n    \"access\": \"此令牌可以访问以下数据库和空间。您只能授予您有权限访问的数据库和空间。\"\n  },\n  \"new\": {\n    \"headerTitle\": \"创建新令牌\",\n    \"title\": \"使用 API 需要个人访问令牌。\",\n    \"description\": \"此令牌将授予对所选空间和数据库中数据的访问权限。以及非空间/数据库的 API 端点。请只在您自己的开发中使用此令牌。请谨慎与第三方服务和应用共享。\",\n    \"button\": \"创建新令牌\",\n    \"success\": {\n      \"title\": \"令牌成功生成\",\n      \"description\": \"确保复制您的令牌。它将不再显示。\"\n    },\n    \"expirationList\": {\n      \"days\": \"天\",\n      \"permanent\": \"永久\",\n      \"custom\": \"自定义\",\n      \"pick\": \"选择日期\"\n    }\n  },\n  \"edit\": {\n    \"title\": \"编辑令牌\",\n    \"name\": \"名称\",\n    \"scopes\": \"范围\",\n    \"selectAll\": \"全选\",\n    \"cancelSelectAll\": \"取消全选\"\n  },\n  \"refresh\": {\n    \"title\": \"重新生成个人访问令牌\",\n    \"description\": \"提交此表单将生成一个新的令牌。请注意，使用此令牌的任何脚本或应用程序都需要更新。\",\n    \"button\": \"重新生成令牌\"\n  },\n  \"accessSelect\": {\n    \"button\": \"添加数据库或空间\",\n    \"empty\": \"未找到访问权限。\",\n    \"spaceSelectItem\": \"空间中的所有数据库\",\n    \"inputPlaceholder\": \"查找空间或数据库...\",\n    \"fullAccess\": {\n      \"button\": \"添加所有资源\",\n      \"description\": \"当前和未来所有空间和数据库\",\n      \"title\": \"所有资源\"\n    },\n    \"sharedBase\": \"分享给我的\"\n  },\n  \"moreScopes\": \"和{{len}}个其他\",\n  \"list\": {\n    \"description\": \"使用 API 需要个人访问令牌。请参阅<a>帮助文档</a>了解更多信息。\"\n  },\n  \"empty\": {\n    \"list\": \"未找到个人访问令牌。\",\n    \"access\": \"无访问权限\"\n  },\n  \"deleteConfirm\": {\n    \"title\": \"您确定要删除此令牌吗？\",\n    \"description\": \"使用此令牌的任何应用程序或脚本将无法再访问 Teable API。此操作无法撤消。\"\n  },\n  \"noAccessConfirm\": {\n    \"title\": \"确定要继续吗？\",\n    \"description\": \"此令牌将无法访问任何数据库中的数据。\"\n  },\n  \"help\": {\n    \"link\": \"https://help.teable.cn/zh/api-doc/token\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/src/locales/zh/zod.json",
    "content": "{\n  \"errors\": {\n    \"invalid_type\": \"预期输入为{{expected}}，而输入为{{received}}\",\n    \"invalid_type_received_undefined\": \"必填\",\n    \"invalid_type_received_null\": \"必填\",\n    \"invalid_literal\": \"错误的字面量值，请输入 {{expected}}\",\n    \"unrecognized_keys\": \"对象中的键无法识别: {{- keys}}\",\n    \"invalid_union\": \"不满足联合类型中的选项\",\n    \"invalid_union_discriminator\": \"标识值无法被区分。请输入 {{- options}}\",\n    \"invalid_enum_value\": \"错误的枚举值 '{{received}}'。请输入 {{- options}}\",\n    \"invalid_arguments\": \"错误的函数参数格式\",\n    \"invalid_return_type\": \"错误的函数返回值格式\",\n    \"invalid_date\": \"错误的日期格式\",\n    \"custom\": \"错误的输入格式\",\n    \"invalid_intersection_types\": \"交叉类型结果无法被合并\",\n    \"not_multiple_of\": \"数值必须是 {{multipleOf}} 的倍数\",\n    \"not_finite\": \"数值必须有限\",\n    \"invalid_string\": {\n      \"email\": \"错误的{{validation}}格式\",\n      \"url\": \"错误的{{validation}}格式\",\n      \"uuid\": \"错误的{{validation}}格式\",\n      \"cuid\": \"错误的{{validation}}格式\",\n      \"regex\": \"错误的格式\",\n      \"datetime\": \"错误的{{validation}}格式\",\n      \"startsWith\": \"文本必须以 \\\"{{startsWith}}\\\" 开头\",\n      \"endsWith\": \"文本必须以 \\\"{{endsWith}}\\\" 结尾\"\n    },\n    \"too_small\": {\n      \"array\": {\n        \"exact\": \"数组元素必须为 {{minimum}} 个\",\n        \"inclusive\": \"数组元素不得少于 {{minimum}} 个\",\n        \"not_inclusive\": \"数组元素必须超过 {{minimum}} 个\"\n      },\n      \"string\": {\n        \"exact\": \"文本长度必须为 {{minimum}} 个字符\",\n        \"inclusive\": \"文本长度不得少于 {{minimum}} 个字符\",\n        \"not_inclusive\": \"文本长度必须超过 {{minimum}} 个字符\"\n      },\n      \"number\": {\n        \"exact\": \"数值必须为 {{minimum}}\",\n        \"inclusive\": \"数值不得小于 {{minimum}}\",\n        \"not_inclusive\": \"数值必须大于 {{minimum}}\"\n      },\n      \"set\": {\n        \"exact\": \"错误的输入格式\",\n        \"inclusive\": \"错误的输入格式\",\n        \"not_inclusive\": \"错误的输入格式\"\n      },\n      \"date\": {\n        \"exact\": \"日期必须为 {{- minimum, datetime}}\",\n        \"inclusive\": \"日期不得晚于 {{- minimum, datetime}}\",\n        \"not_inclusive\": \"日期必须早于 {{- minimum, datetime}}\"\n      }\n    },\n    \"too_big\": {\n      \"array\": {\n        \"exact\": \"数组元素必须为 {{maximum}} 个\",\n        \"inclusive\": \"数组元素不得多于 {{maximum}} 个\",\n        \"not_inclusive\": \"数组元素必须少于 {{maximum}} 个\"\n      },\n      \"string\": {\n        \"exact\": \"文本长度必须为 {{maximum}} 个字符\",\n        \"inclusive\": \"文本长度不得多于 {{maximum}} 个字符\",\n        \"not_inclusive\": \"文本长度必须少于 {{maximum}} 个字符\"\n      },\n      \"number\": {\n        \"exact\": \"数值必须为 {{maximum}}\",\n        \"inclusive\": \"数值不得大于 {{maximum}}\",\n        \"not_inclusive\": \"数值必须小于 {{maximum}}\"\n      },\n      \"set\": {\n        \"exact\": \"错误的输入格式\",\n        \"inclusive\": \"错误的输入格式\",\n        \"not_inclusive\": \"错误的输入格式\"\n      },\n      \"date\": {\n        \"exact\": \"日期必须为 {{- maximum, datetime}}\",\n        \"inclusive\": \"日期不得早于 {{- maximum, datetime}}\",\n        \"not_inclusive\": \"日期必须晚于 {{- maximum, datetime}}\"\n      }\n    }\n  },\n  \"validations\": {\n    \"email\": \"邮件\",\n    \"url\": \"链接\",\n    \"uuid\": \"uuid\",\n    \"cuid\": \"cuid\",\n    \"regex\": \"正则表达式\",\n    \"datetime\": \"日期时间\"\n  },\n  \"types\": {\n    \"function\": \"函数\",\n    \"number\": \"数字\",\n    \"string\": \"字符串\",\n    \"nan\": \"非数\",\n    \"integer\": \"整数\",\n    \"float\": \"浮点数\",\n    \"boolean\": \"布尔值\",\n    \"date\": \"日期\",\n    \"bigint\": \"大整数\",\n    \"undefined\": \"未定义\",\n    \"symbol\": \"符号\",\n    \"null\": \"空对象\",\n    \"array\": \"数组\",\n    \"object\": \"对象\",\n    \"unknown\": \"未知\",\n    \"promise\": \"Promise\",\n    \"void\": \"空\",\n    \"never\": \"不存在\",\n    \"map\": \"字典\",\n    \"set\": \"集合\"\n  }\n}\n"
  },
  {
    "path": "packages/common-i18n/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \"./src\",\n    \"target\": \"esnext\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"module\": \"esnext\",\n    \"jsx\": \"react-jsx\",\n    \"noEmit\": false,\n    \"incremental\": true,\n    \"paths\": {}\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/core/.escheckrc",
    "content": "{\n  \"ecmaVersion\": \"es2017\",\n  \"module\": true,\n  \"files\": \"./dist/**/*.js\"\n}"
  },
  {
    "path": "packages/core/.eslintrc.cjs",
    "content": "/**\n * Specific eslint rules for this workspace, learn how to compose\n * @link https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns(), 'src/formula/parser', 'src/query/parser'],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    '@teable/eslint-config-bases/sonar',\n    '@teable/eslint-config-bases/regexp',\n    '@teable/eslint-config-bases/jest',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    // optional overrides per project file match\n  ],\n};\n"
  },
  {
    "path": "packages/core/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# build\n/dist\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n\n# antlr\n.antlr/\n"
  },
  {
    "path": "packages/core/.idea/core.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "packages/core/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/core.iml\" filepath=\"$PROJECT_DIR$/.idea/core.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "packages/core/.size-limit.cjs",
    "content": "module.exports = [\n  {\n    name: 'JS',\n    path: ['dist/index.js'],\n    limit: '3KB',\n  },\n];\n"
  },
  {
    "path": "packages/core/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2025 Teable, Inc.\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": "packages/core/lint-staged.config.js",
    "content": "// @ts-check\n\n/**\n * This files overrides the base lint-staged.config.js present in the root directory.\n * It allows to run eslint based the package specific requirements.\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/teable/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier, getEslintFixCmd } = require('../../lint-staged.common');\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '**/*.{js,jsx,ts,tsx}': (filenames) => {\n    return getEslintFixCmd({\n      cwd: __dirname,\n      fix: true,\n      cache: true,\n      // when autofixing staged-files a good tip is to disable react-hooks/exhaustive-deps, cause\n      // a change here can potentially break things without proper visibility.\n      rules: ['react-hooks/exhaustive-deps: off'],\n      maxWarnings: 25,\n      files: filenames,\n    });\n  },\n  '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nmodule.exports = rules;\n"
  },
  {
    "path": "packages/core/package.json",
    "content": "{\n  \"name\": \"@teable/core\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/core\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"dist/index.js\",\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"@teable/source\": \"./src/index.ts\",\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc --project ./tsconfig.build.json\",\n    \"dev\": \"tsc --watch\",\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./tsconfig.build.tsbuildinfo ./.eslintcache\",\n    \"lint\": \"eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --cache --cache-location ../../.cache/eslint/core.eslintcache\",\n    \"antlr4ts-tql\": \"antlr4ts -visitor -no-listener src/query/parser/*.g4\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\",\n    \"check-dist\": \"es-check -v\",\n    \"check-size\": \"size-limit\",\n    \"test\": \"vitest run test-unit\",\n    \"test:watch\": \"vitest --watch\",\n    \"test-unit\": \"vitest run --silent\",\n    \"test-unit-cover\": \"pnpm test-unit --coverage\",\n    \"fix-all-files\": \"eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --fix\"\n  },\n  \"dependencies\": {\n    \"@asteasolutions/zod-to-openapi\": \"8.1.0\",\n    \"@httpx/dsn-parser\": \"1.8.4\",\n    \"@teable/formula\": \"workspace:*\",\n    \"@types/color\": \"3.0.6\",\n    \"@types/papaparse\": \"5.3.14\",\n    \"antlr4ts\": \"0.5.0-alpha.4\",\n    \"axios\": \"1.7.7\",\n    \"class-transformer\": \"0.5.1\",\n    \"color\": \"4.2.3\",\n    \"date-fns-tz\": \"3.2.0\",\n    \"dayjs\": \"1.11.10\",\n    \"lodash\": \"4.17.21\",\n    \"nanoid\": \"3.3.7\",\n    \"papaparse\": \"5.4.1\",\n    \"reflect-metadata\": \"0.2.1\",\n    \"ts-pattern\": \"5.0.8\",\n    \"zod\": \"4.1.8\",\n    \"zod-validation-error\": \"4.0.2\"\n  },\n  \"devDependencies\": {\n    \"@size-limit/file\": \"11.1.2\",\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/lodash\": \"4.17.0\",\n    \"@types/node\": \"22.18.0\",\n    \"@vitest/coverage-v8\": \"4.0.17\",\n    \"antlr4ts-cli\": \"0.5.0-alpha.4\",\n    \"cross-env\": \"7.0.3\",\n    \"es-check\": \"7.1.1\",\n    \"eslint\": \"8.57.0\",\n    \"get-tsconfig\": \"4.7.3\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"size-limit\": \"11.1.2\",\n    \"typescript\": \"5.4.3\",\n    \"vitest\": \"4.0.17\"\n  }\n}\n"
  },
  {
    "path": "packages/core/src/array/ArrayUtils.spec.ts",
    "content": "import { ArrayUtils } from './ArrayUtils';\n\ndescribe('ArrayUtils', () => {\n  describe('removeItem', () => {\n    it('should return remove the first item', () => {\n      expect(ArrayUtils.removeItem([1, 2, 2], 2)).toStrictEqual([1, 2]);\n    });\n    it('should return the array intact if value not found', () => {\n      expect(ArrayUtils.removeItem([1, 2], 3)).toStrictEqual([1, 2]);\n    });\n  });\n  describe('getRandom', () => {\n    it('should return different elements', () => {\n      const arr = ['cool', 'test', true, 0];\n      const results: typeof arr = [];\n      const maxIterations = 20;\n      for (let i = 0; i < maxIterations; i++) {\n        results.push(ArrayUtils.getRandom(arr));\n      }\n      const unique = results.filter((v, i, a) => a.indexOf(v) === i);\n      expect(unique.length).toBeGreaterThan(1);\n    });\n    it('should always return an element from the array', () => {\n      const arr = ['cool', 'test', true, 0];\n      const maxIterations = 20;\n      for (let i = 0; i < maxIterations; i++) {\n        const el = ArrayUtils.getRandom(arr);\n        expect(arr.indexOf(el)).toBeGreaterThanOrEqual(0);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/array/ArrayUtils.ts",
    "content": "import { getRandomInt } from '../utils';\n\nexport class ArrayUtils {\n  static getRandom<T>(items: T[]): T {\n    return items[getRandomInt(0, items.length - 1)];\n  }\n\n  static removeItem<T>(arr: T[], item: T): T[] {\n    const index = arr.indexOf(item);\n    if (index > -1) {\n      arr.splice(index, 1);\n    }\n    return arr;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/array/index.ts",
    "content": "export { ArrayUtils } from './ArrayUtils';\n"
  },
  {
    "path": "packages/core/src/asserts/__tests__/asserts.test.ts",
    "content": "import { assertIncludes, assertNonEmptyString } from '../asserts';\n\ndescribe('Asserts test', () => {\n  describe('assertNonEmptyString', () => {\n    it('should work as expected', () => {\n      expect(() => {\n        assertNonEmptyString('cool');\n      }).not.toThrow();\n      expect(() => {\n        assertNonEmptyString(' ', 'message');\n      }).toThrow('message');\n      expect(() => {\n        assertNonEmptyString(true, () => {\n          return new Error('message2');\n        });\n      }).toThrow('message2');\n    });\n  });\n  describe('assertIncludes', () => {\n    it('should work as expected', () => {\n      expect(() => {\n        assertIncludes('cool', ['cool']);\n      }).not.toThrow();\n      expect(() => {\n        assertIncludes('cool', [], 'message');\n      }).toThrow('message');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/asserts/asserts.ts",
    "content": "import { isNonEmptyString } from '../typeguards';\n\ntype IMsgOrErrorFactory = string | (() => Error);\n\nexport function assertNonEmptyString(\n  v: unknown,\n  msgOrErrorFactory?: IMsgOrErrorFactory,\n  /** auto-trim, default true */\n  trim?: boolean\n): asserts v is string {\n  if (!isNonEmptyString(v, trim ?? true)) {\n    throw createAssertException(msgOrErrorFactory);\n  }\n}\n\nexport function assertIncludes<T extends string[]>(\n  v: string | undefined,\n  stringArray: T,\n  msgOrErrorFactory?: IMsgOrErrorFactory,\n  caseInsensitive?: boolean\n): asserts v is T[number] {\n  const insensitive = caseInsensitive ?? false;\n  const val = insensitive ? v?.toUpperCase() : v;\n  const allowed = insensitive ? stringArray.map((v) => v.toUpperCase()) : stringArray;\n  if (!val || !allowed.includes(val)) {\n    const msg = [\n      `Value '${v ? v : typeof v}' is not in allowed values`,\n      `(${stringArray.join(',')}`,\n      insensitive ? '(case insensitive).' : '(case sensitive).',\n    ].join(',');\n    throw createAssertException(msgOrErrorFactory, msg);\n  }\n}\n\nexport function assertIsPresent<T>(\n  v: T,\n  msgOrErrorFactory?: IMsgOrErrorFactory\n): asserts v is NonNullable<T> {\n  if (v === null || v === undefined) {\n    throw createAssertException(msgOrErrorFactory, 'Value is null or undefined.');\n  }\n}\n\nexport function assertSafeInteger(\n  v: unknown,\n  msgOrErrorFactory?: IMsgOrErrorFactory\n): asserts v is number {\n  if (typeof v !== 'number' || !Number.isSafeInteger(v)) {\n    throw createAssertException(msgOrErrorFactory, 'Value is not a safe integer BILOUTEBILL');\n  }\n}\n\nfunction createAssertException(msgOrErrorFactory?: string | (() => Error), fallbackMsg?: string) {\n  if (typeof msgOrErrorFactory === 'string' || msgOrErrorFactory === undefined) {\n    throw new Error(msgOrErrorFactory ?? fallbackMsg ?? 'Assertion did not pass.');\n  }\n  throw msgOrErrorFactory();\n}\n\n/**\n * Helper function for exhaustive checks of discriminated unions.\n * https://basarat.gitbooks.io/typescript/docs/types/discriminated-unions.html\n *\n * @example\n *\n *    type A = {type: 'a'};\n *    type B = {type: 'b'};\n *    type Union = A | B;\n *\n *    function doSomething(arg: Union) {\n *      if (arg.type === 'a') {\n *        return something;\n *      }\n *\n *      if (arg.type === 'b') {\n *        return somethingElse;\n *      }\n *\n *      // TS will error if there are other types in the union\n *      // Will throw an Error when called at runtime.\n *      // Use `assertNever(arg, true)` instead to fail silently.\n *      return assertNever(arg);\n *    }\n */\nexport function assertNever(value: never, noThrow?: boolean): never {\n  if (noThrow) {\n    return value;\n  }\n\n  throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);\n}\n"
  },
  {
    "path": "packages/core/src/asserts/index.ts",
    "content": "export * from './asserts';\nexport * from './lang';\n"
  },
  {
    "path": "packages/core/src/asserts/lang.ts",
    "content": "export const LOCALES = ['en', 'zh', 'fr'] as const;\n"
  },
  {
    "path": "packages/core/src/auth/actions.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { z } from 'zod';\n\nexport enum ActionPrefix {\n  Space = 'space',\n  Base = 'base',\n  Table = 'table',\n  View = 'view',\n  Record = 'record',\n  Field = 'field',\n  Automation = 'automation',\n  App = 'app',\n  User = 'user',\n  TableRecordHistory = 'table_record_history',\n  Instance = 'instance',\n  Enterprise = 'enterprise',\n}\n\nexport const spaceActions = [\n  'space|create',\n  'space|delete',\n  'space|read',\n  'space|update',\n  'space|invite_email',\n  'space|invite_link',\n  'space|grant_role',\n] as const;\nexport const spaceActionSchema = z.enum(spaceActions);\nexport type SpaceAction = z.infer<typeof spaceActionSchema>;\n\nexport const baseActions = [\n  'base|create',\n  'base|delete',\n  'base|read',\n  'base|read_all',\n  'base|update',\n  'base|invite_email',\n  'base|invite_link',\n  'base|table_import',\n  'base|table_export',\n  'base|authority_matrix_config',\n  'base|db_connection',\n  'base|query_data',\n] as const;\nexport const baseActionSchema = z.enum(baseActions);\nexport type BaseAction = z.infer<typeof baseActionSchema>;\n\nexport const tableActions = [\n  'table|create',\n  'table|delete',\n  'table|read',\n  'table|update',\n  'table|import',\n  'table|export',\n  'table|trash_read',\n  'table|trash_update',\n  'table|trash_reset',\n] as const;\nexport const tableActionSchema = z.enum(tableActions);\nexport type TableAction = z.infer<typeof tableActionSchema>;\n\nexport const viewActions = [\n  'view|create',\n  'view|delete',\n  'view|read',\n  'view|update',\n  'view|share',\n] as const;\nexport const viewActionSchema = z.enum(viewActions);\nexport type ViewAction = z.infer<typeof viewActionSchema>;\n\nexport const fieldActions = ['field|create', 'field|delete', 'field|read', 'field|update'] as const;\nexport const fieldActionSchema = z.enum(fieldActions);\nexport type FieldAction = z.infer<typeof fieldActionSchema>;\n\nexport const recordActions = [\n  'record|create',\n  'record|delete',\n  'record|read',\n  'record|update',\n  'record|comment',\n  'record|copy',\n] as const;\nexport const recordActionSchema = z.enum(recordActions);\nexport type RecordAction = z.infer<typeof recordActionSchema>;\n\nexport const automationActions = [\n  'automation|create',\n  'automation|delete',\n  'automation|read',\n  'automation|update',\n] as const;\nexport const automationActionSchema = z.enum(automationActions);\nexport type AutomationAction = z.infer<typeof automationActionSchema>;\n\nexport const appActions = ['app|create', 'app|delete', 'app|read', 'app|update'] as const;\nexport const appActionSchema = z.enum(appActions);\nexport type AppAction = z.infer<typeof appActionSchema>;\n\nexport const userActions = ['user|email_read', 'user|integrations'] as const;\nexport const userActionSchema = z.enum(userActions);\nexport type UserAction = z.infer<typeof userActionSchema>;\n\nexport const tableRecordHistoryActions = ['table_record_history|read'] as const;\nexport const tableRecordHistoryActionSchema = z.enum(tableRecordHistoryActions);\nexport type TableRecordHistoryAction = z.infer<typeof tableRecordHistoryActionSchema>;\n\nexport const instanceActions = ['instance|read', 'instance|update'] as const;\nexport const instanceActionSchema = z.enum(instanceActions);\nexport type InstanceAction = z.infer<typeof instanceActionSchema>;\n\nexport const enterpriseActions = ['enterprise|read', 'enterprise|update'] as const;\nexport const enterpriseActionSchema = z.enum(enterpriseActions);\nexport type EnterpriseAction = z.infer<typeof enterpriseActionSchema>;\n\nexport type Action =\n  | SpaceAction\n  | BaseAction\n  | TableAction\n  | ViewAction\n  | FieldAction\n  | RecordAction\n  | AutomationAction\n  | AppAction\n  | UserAction\n  | TableRecordHistoryAction\n  | InstanceAction\n  | EnterpriseAction;\n\nexport type ActionPrefixMap = {\n  [ActionPrefix.Space]: SpaceAction[];\n  [ActionPrefix.Base]: BaseAction[];\n  [ActionPrefix.Table]: TableAction[];\n  [ActionPrefix.View]: ViewAction[];\n  [ActionPrefix.Field]: FieldAction[];\n  [ActionPrefix.Record]: RecordAction[];\n  [ActionPrefix.Automation]: AutomationAction[];\n  [ActionPrefix.App]: AppAction[];\n  [ActionPrefix.User]: UserAction[];\n  [ActionPrefix.TableRecordHistory]: TableRecordHistoryAction[];\n  [ActionPrefix.Instance]: InstanceAction[];\n  [ActionPrefix.Enterprise]: EnterpriseAction[];\n};\nexport const actionPrefixMap: ActionPrefixMap = {\n  [ActionPrefix.Space]: [...spaceActions],\n  [ActionPrefix.Base]: [...baseActions],\n  [ActionPrefix.Table]: [...tableActions],\n  [ActionPrefix.View]: [...viewActions],\n  [ActionPrefix.Field]: [...fieldActions],\n  [ActionPrefix.Record]: [...recordActions],\n  [ActionPrefix.Automation]: [...automationActions],\n  [ActionPrefix.App]: [...appActions],\n  [ActionPrefix.TableRecordHistory]: [...tableRecordHistoryActions],\n  [ActionPrefix.User]: [...userActions],\n  [ActionPrefix.Instance]: [...instanceActions],\n  [ActionPrefix.Enterprise]: [...enterpriseActions],\n};\n"
  },
  {
    "path": "packages/core/src/auth/anonymous.ts",
    "content": "export const ANONYMOUS_USER_ID = 'anonymous';\n\nexport const isAnonymous = (userId: string) => userId === ANONYMOUS_USER_ID;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ANONYMOUS_USER = {\n  id: ANONYMOUS_USER_ID,\n  name: 'Anonymous',\n  email: 'anonymous@system.teable.ai',\n};\n"
  },
  {
    "path": "packages/core/src/auth/app-robot.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nexport const APP_ROBOT_ID = 'appRobot';\n\nexport const APP_ROBOT_USER = {\n  id: APP_ROBOT_ID,\n  name: 'App Robot',\n  email: 'appRobot@system.teable.ai',\n};\n"
  },
  {
    "path": "packages/core/src/auth/automation-robot.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nexport const AUTOMATION_ROBOT_ID = 'automationRobot';\n\nexport const AUTOMATION_ROBOT_USER = {\n  id: AUTOMATION_ROBOT_ID,\n  name: 'Automation Robot',\n  email: 'automationRobot@system.teable.ai',\n};\n"
  },
  {
    "path": "packages/core/src/auth/index.ts",
    "content": "export * from './actions';\nexport * from './role';\nexport * from './permission';\nexport * from './anonymous';\nexport * from './system';\nexport * from './me-tag';\nexport * from './types';\nexport * from './oauth';\nexport * from './automation-robot';\nexport * from './app-robot';\n"
  },
  {
    "path": "packages/core/src/auth/me-tag.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const Me = 'Me';\n\nexport const isMeTag = (str: string) => str === Me;\n"
  },
  {
    "path": "packages/core/src/auth/oauth.ts",
    "content": "import type {\n  AppAction,\n  AutomationAction,\n  BaseAction,\n  FieldAction,\n  RecordAction,\n  TableAction,\n  UserAction,\n  ViewAction,\n} from './actions';\n\nexport const OAUTH_ACTIONS: (\n  | AppAction\n  | BaseAction\n  | TableAction\n  | ViewAction\n  | FieldAction\n  | RecordAction\n  | UserAction\n  | AutomationAction\n)[] = [\n  'app|create',\n  'app|delete',\n  'app|read',\n  'app|update',\n  'base|read',\n  'base|read_all',\n  'base|update',\n  'base|table_import',\n  'base|table_export',\n  'base|query_data',\n  'table|create',\n  'table|delete',\n  'table|export',\n  'table|import',\n  'table|read',\n  'table|update',\n  'table|trash_read',\n  'table|trash_update',\n  'table|trash_reset',\n  'view|create',\n  'view|delete',\n  'view|read',\n  'view|update',\n  'field|create',\n  'field|delete',\n  'field|read',\n  'field|update',\n  'record|comment',\n  'record|create',\n  'record|delete',\n  'record|read',\n  'record|update',\n  'automation|create',\n  'automation|delete',\n  'automation|read',\n  'automation|update',\n  'user|email_read',\n  'user|integrations',\n];\n"
  },
  {
    "path": "packages/core/src/auth/permission.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/**\n * TODO: need to distinguish between the resources that this role targets, such as spaceRole or baseRole\n */\nimport { keys } from 'lodash';\nimport type { Action } from './actions';\nimport { Role, RolePermission } from './role';\nimport type { IRole } from './role/types';\n\nexport const checkPermissions = (role: IRole, actions: Action[]) => {\n  return actions.every((action) => Boolean(RolePermission[role][action]));\n};\n\nexport const getPermissions = (role: IRole) => {\n  const permissionMap = getPermissionMap(role);\n  return (keys(permissionMap) as Action[]).filter((key) => permissionMap[key]);\n};\n\nexport const getPermissionMap = (role: IRole) => {\n  return RolePermission[role];\n};\n\nexport const hasPermission = (role: IRole, action: Action) => {\n  return checkPermissions(role, [action]);\n};\n\nexport const isRestrictedRole = (role: IRole) => {\n  return role !== Role.Owner;\n};\n"
  },
  {
    "path": "packages/core/src/auth/role/base.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { omit } from 'lodash';\nimport { z } from '../../zod';\nimport type { Action, SpaceAction } from '../actions';\nimport { RolePermission } from './constant';\nimport type { IRole } from './types';\nimport { Role } from './types';\n\nexport const BaseRole = {\n  Creator: Role.Creator,\n  Editor: Role.Editor,\n  Commenter: Role.Commenter,\n  Viewer: Role.Viewer,\n} as const;\n\nexport const baseRolesSchema = z.enum(BaseRole);\n\nexport type IBaseRole = z.infer<typeof baseRolesSchema>;\n\ntype ExcludeSpaceAction<T> = T extends SpaceAction ? never : T;\n\nexport type BasePermission = ExcludeSpaceAction<Action>;\n\nexport const getBasePermission = (role: IRole): Record<BasePermission, boolean> => {\n  return omit(RolePermission[role], ['space|create', 'space|delete', 'space|read']);\n};\n"
  },
  {
    "path": "packages/core/src/auth/role/constant.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Action } from '../actions';\nimport { Role, type IRole } from './types';\n\nexport const RolePermission: Record<IRole, Record<Action, boolean>> = {\n  [Role.Owner]: {\n    'space|create': true,\n    'space|delete': true,\n    'space|read': true,\n    'space|update': true,\n    'space|invite_email': true,\n    'space|invite_link': true,\n    'space|grant_role': true,\n    'base|create': true,\n    'base|delete': true,\n    'base|read': true,\n    'base|update': true,\n    'base|invite_email': true,\n    'base|invite_link': true,\n    'base|table_import': true,\n    'base|table_export': true,\n    'base|authority_matrix_config': true,\n    'base|db_connection': true,\n    'base|query_data': true,\n    'base|read_all': true,\n    'table|create': true,\n    'table|read': true,\n    'table|delete': true,\n    'table|update': true,\n    'table|import': true,\n    'table|export': true,\n    'table|trash_read': true,\n    'table|trash_update': true,\n    'table|trash_reset': true,\n    'table_record_history|read': true,\n    'view|create': true,\n    'view|delete': true,\n    'view|read': true,\n    'view|update': true,\n    'view|share': true,\n    'field|create': true,\n    'field|delete': true,\n    'field|read': true,\n    'field|update': true,\n    'record|create': true,\n    'record|comment': true,\n    'record|delete': true,\n    'record|read': true,\n    'record|update': true,\n    'record|copy': true,\n    'automation|create': true,\n    'automation|delete': true,\n    'automation|read': true,\n    'automation|update': true,\n    'app|create': true,\n    'app|delete': true,\n    'app|read': true,\n    'app|update': true,\n    'user|email_read': true,\n    'user|integrations': true,\n    'instance|read': false,\n    'instance|update': false,\n    'enterprise|read': false,\n    'enterprise|update': false,\n  },\n  [Role.Creator]: {\n    'space|create': false,\n    'space|delete': false,\n    'space|update': false,\n    'space|read': true,\n    'space|invite_email': true,\n    'space|invite_link': true,\n    'space|grant_role': false,\n    'base|create': true,\n    'base|delete': true,\n    'base|read': true,\n    'base|update': true,\n    'base|read_all': true,\n    'base|invite_email': true,\n    'base|invite_link': true,\n    'base|table_import': true,\n    'base|table_export': true,\n    'base|authority_matrix_config': true,\n    'base|db_connection': false,\n    'base|query_data': true,\n    'table|create': true,\n    'table|read': true,\n    'table|delete': true,\n    'table|update': true,\n    'table|import': true,\n    'table|export': true,\n    'table|trash_read': true,\n    'table|trash_update': true,\n    'table|trash_reset': true,\n    'table_record_history|read': true,\n    'view|create': true,\n    'view|delete': true,\n    'view|read': true,\n    'view|update': true,\n    'view|share': true,\n    'field|create': true,\n    'field|delete': true,\n    'field|read': true,\n    'field|update': true,\n    'record|create': true,\n    'record|comment': true,\n    'record|delete': true,\n    'record|read': true,\n    'record|update': true,\n    'record|copy': true,\n    'automation|create': true,\n    'automation|delete': true,\n    'automation|read': true,\n    'automation|update': true,\n    'app|create': true,\n    'app|delete': true,\n    'app|read': true,\n    'app|update': true,\n    'user|email_read': true,\n    'user|integrations': true,\n    'instance|read': false,\n    'instance|update': false,\n    'enterprise|read': false,\n    'enterprise|update': false,\n  },\n  [Role.Editor]: {\n    'space|create': false,\n    'space|delete': false,\n    'space|update': false,\n    'space|read': true,\n    'space|invite_email': true,\n    'space|invite_link': false,\n    'space|grant_role': false,\n    'base|create': false,\n    'base|delete': false,\n    'base|read': true,\n    'base|read_all': true,\n    'base|update': false,\n    'base|invite_email': true,\n    'base|invite_link': false,\n    'base|table_import': true,\n    'base|table_export': true,\n    'base|authority_matrix_config': false,\n    'base|db_connection': false,\n    'base|query_data': false,\n    'table|create': false,\n    'table|read': true,\n    'table|delete': false,\n    'table|update': false,\n    'table|import': false,\n    'table|export': true,\n    'table|trash_read': true,\n    'table|trash_update': true,\n    'table|trash_reset': false,\n    'table_record_history|read': true,\n    'view|create': true,\n    'view|delete': true,\n    'view|read': true,\n    'view|update': true,\n    'view|share': true,\n    'field|create': false,\n    'field|delete': false,\n    'field|read': true,\n    'field|update': false,\n    'record|create': true,\n    'record|comment': true,\n    'record|delete': true,\n    'record|read': true,\n    'record|update': true,\n    'record|copy': true,\n    'automation|create': false,\n    'automation|delete': false,\n    'automation|read': true,\n    'automation|update': false,\n    'app|create': false,\n    'app|delete': false,\n    'app|read': true,\n    'app|update': false,\n    'user|email_read': true,\n    'user|integrations': true,\n    'instance|read': false,\n    'instance|update': false,\n    'enterprise|read': false,\n    'enterprise|update': false,\n  },\n  [Role.Commenter]: {\n    'space|create': false,\n    'space|delete': false,\n    'space|update': false,\n    'space|read': true,\n    'space|invite_email': true,\n    'space|invite_link': false,\n    'space|grant_role': false,\n    'base|create': false,\n    'base|delete': false,\n    'base|read': true,\n    'base|read_all': true,\n    'base|update': false,\n    'base|invite_email': true,\n    'base|invite_link': false,\n    'base|table_import': false,\n    'base|table_export': true,\n    'base|authority_matrix_config': false,\n    'base|db_connection': false,\n    'base|query_data': false,\n    'table|create': false,\n    'table|read': true,\n    'table|delete': false,\n    'table|update': false,\n    'table|import': false,\n    'table|export': true,\n    'table|trash_read': false,\n    'table|trash_update': false,\n    'table|trash_reset': false,\n    'table_record_history|read': false,\n    'view|create': false,\n    'view|delete': false,\n    'view|read': true,\n    'view|update': false,\n    'view|share': false,\n    'field|create': false,\n    'field|delete': false,\n    'field|read': true,\n    'field|update': false,\n    'record|create': false,\n    'record|comment': true,\n    'record|delete': false,\n    'record|read': true,\n    'record|update': false,\n    'record|copy': true,\n    'automation|create': false,\n    'automation|delete': false,\n    'automation|read': true,\n    'automation|update': false,\n    'app|create': false,\n    'app|delete': false,\n    'app|read': true,\n    'app|update': false,\n    'user|email_read': true,\n    'user|integrations': true,\n    'instance|read': false,\n    'instance|update': false,\n    'enterprise|read': false,\n    'enterprise|update': false,\n  },\n  [Role.Viewer]: {\n    'space|create': false,\n    'space|delete': false,\n    'space|update': false,\n    'space|read': true,\n    'space|invite_email': true,\n    'space|invite_link': false,\n    'space|grant_role': false,\n    'base|create': false,\n    'base|delete': false,\n    'base|read': true,\n    'base|read_all': true,\n    'base|update': false,\n    'base|invite_email': true,\n    'base|invite_link': false,\n    'base|table_import': false,\n    'base|table_export': true,\n    'base|authority_matrix_config': false,\n    'base|db_connection': false,\n    'base|query_data': false,\n    'table|create': false,\n    'table|read': true,\n    'table|delete': false,\n    'table|update': false,\n    'table|import': false,\n    'table|export': true,\n    'table|trash_read': false,\n    'table|trash_update': false,\n    'table|trash_reset': false,\n    'table_record_history|read': false,\n    'view|create': false,\n    'view|delete': false,\n    'view|read': true,\n    'view|update': false,\n    'view|share': false,\n    'field|create': false,\n    'field|delete': false,\n    'field|read': true,\n    'field|update': false,\n    'record|create': false,\n    'record|comment': false,\n    'record|delete': false,\n    'record|read': true,\n    'record|update': false,\n    'record|copy': true,\n    'automation|create': false,\n    'automation|delete': false,\n    'automation|read': true,\n    'automation|update': false,\n    'app|create': false,\n    'app|delete': false,\n    'app|read': true,\n    'app|update': false,\n    'user|email_read': true,\n    'user|integrations': true,\n    'instance|read': false,\n    'instance|update': false,\n    'enterprise|read': false,\n    'enterprise|update': false,\n  },\n};\n"
  },
  {
    "path": "packages/core/src/auth/role/index.ts",
    "content": "export * from './space';\nexport * from './base';\nexport * from './share';\nexport * from './types';\nexport * from './table';\nexport * from './constant';\nexport * from './utils';\nexport * from './template';\n"
  },
  {
    "path": "packages/core/src/auth/role/share.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { FieldAction, RecordAction, ViewAction } from '../actions';\n\nexport type ShareViewAction = ViewAction | FieldAction | RecordAction;\n\nexport const shareViewPermissions: Record<ShareViewAction, boolean> = {\n  'view|create': false,\n  'view|delete': false,\n  'view|read': true,\n  'view|update': false,\n  'view|share': false,\n  'field|create': false,\n  'field|delete': false,\n  'field|read': true,\n  'field|update': false,\n  'record|create': false,\n  'record|comment': false,\n  'record|delete': false,\n  'record|read': true,\n  'record|update': false,\n  'record|copy': false,\n};\n"
  },
  {
    "path": "packages/core/src/auth/role/space.ts",
    "content": "import type { Action } from '../actions';\n\nexport type ISpaceAction = Action;\n"
  },
  {
    "path": "packages/core/src/auth/role/table.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { z } from '../../zod';\nimport type { TableAction, FieldAction, RecordAction, ViewAction } from '../actions';\nimport { Role } from './types';\n\nexport const TableRole = {\n  Creator: Role.Creator,\n  Editor: Role.Editor,\n  Viewer: Role.Viewer,\n} as const;\n\nexport const tableRolesSchema = z.enum(TableRole);\n\nexport type ITableRole = z.infer<typeof tableRolesSchema>;\n\nexport type TablePermission = ViewAction | FieldAction | RecordAction | TableAction;\n"
  },
  {
    "path": "packages/core/src/auth/role/template.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { Action } from '../actions';\n\nexport const TemplateRolePermission: Record<Action, boolean> = {\n  'base|read': true,\n  'table|read': true,\n  'view|read': true,\n  'field|read': true,\n  'record|read': true,\n  'automation|read': true,\n  'app|read': true,\n  'space|create': false,\n  'space|delete': false,\n  'space|update': false,\n  'space|read': false,\n  'space|invite_email': false,\n  'space|invite_link': false,\n  'space|grant_role': false,\n  'base|create': false,\n  'base|delete': false,\n  'base|read_all': false,\n  'base|update': false,\n  'base|invite_email': false,\n  'base|invite_link': false,\n  'base|table_import': false,\n  'base|table_export': false,\n  'base|authority_matrix_config': false,\n  'base|db_connection': false,\n  'base|query_data': true,\n  'table|create': false,\n  'table|delete': false,\n  'table|update': false,\n  'table|import': false,\n  'table|export': false,\n  'table|trash_read': false,\n  'table|trash_update': false,\n  'table|trash_reset': false,\n  'table_record_history|read': false,\n  'view|create': false,\n  'view|delete': false,\n  'view|update': false,\n  'view|share': false,\n  'field|create': false,\n  'field|delete': false,\n  'field|update': false,\n  'record|create': false,\n  'record|comment': false,\n  'record|delete': false,\n  'record|update': false,\n  'record|copy': false,\n  'automation|create': false,\n  'automation|delete': false,\n  'automation|update': false,\n  'app|create': false,\n  'app|delete': false,\n  'app|update': false,\n  'user|email_read': false,\n  'user|integrations': false,\n  'instance|read': false,\n  'instance|update': false,\n  'enterprise|read': false,\n  'enterprise|update': false,\n};\n\nexport const TemplatePermissions = Object.keys(TemplateRolePermission).filter(\n  (permission) => TemplateRolePermission[permission as Action]\n) as Action[];\n"
  },
  {
    "path": "packages/core/src/auth/role/types.ts",
    "content": "import { z } from '../../zod';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const Role = {\n  Owner: 'owner',\n  Creator: 'creator',\n  Editor: 'editor',\n  Commenter: 'commenter',\n  Viewer: 'viewer',\n} as const;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const RoleLevel = ['owner', 'creator', 'editor', 'commenter', 'viewer'];\n\n// Billable roles are roles that count towards seat-based billing\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const BillableRoles = [Role.Owner, Role.Creator, Role.Editor] as const;\n\nexport const roleSchema = z.enum(Role);\n\nexport type IRole = z.infer<typeof roleSchema>;\n"
  },
  {
    "path": "packages/core/src/auth/role/utils.ts",
    "content": "import { RoleLevel } from './types';\n\nexport const canManageRole = (managerRole: string, targetRole: string) => {\n  return RoleLevel.indexOf(managerRole) < RoleLevel.indexOf(targetRole);\n};\n"
  },
  {
    "path": "packages/core/src/auth/system.ts",
    "content": "export const SYSTEM_USER_ID = 'system';\n\nexport const getPluginEmail = (pluginId: string) => `${pluginId.toLowerCase()}@plugin.teable.ai`;\n"
  },
  {
    "path": "packages/core/src/auth/types.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/naming-convention\nexport type ExcludeAction<T extends string, F extends string> = T extends F ? never : T;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport type PickAction<T extends string, F extends string> = T extends F ? T : never;\n"
  },
  {
    "path": "packages/core/src/convert/__tests__/string-convert.test.ts",
    "content": "import { stringToFloat, stringToSafeInteger } from '../';\n\ndescribe('StringConvert tests', () => {\n  describe('stringtoSafeInteger', () => {\n    it('should work as expected', () => {\n      expect(stringToSafeInteger('')).toStrictEqual(null);\n      expect(stringToSafeInteger(10)).toStrictEqual(10);\n      expect(stringToSafeInteger('10')).toStrictEqual(10);\n      expect(stringToSafeInteger('32568888')).toStrictEqual(32568888);\n      expect(stringToSafeInteger('10.2')).toStrictEqual(null);\n      expect(stringToSafeInteger(null)).toStrictEqual(null);\n      expect(stringToSafeInteger('-3')).toStrictEqual(-3);\n      expect(stringToSafeInteger(undefined)).toStrictEqual(null);\n      expect(stringToSafeInteger(null)).toStrictEqual(null);\n      expect(stringToSafeInteger(false)).toStrictEqual(null);\n      expect(stringToSafeInteger(NaN)).toStrictEqual(null);\n    });\n  });\n\n  describe('stringToFloat', () => {\n    it('should work as expected', () => {\n      expect(stringToFloat(10)).toStrictEqual(10);\n      expect(stringToFloat('10.2345')).toStrictEqual(10.2345);\n      expect(stringToFloat('.2')).toStrictEqual(0.2);\n      expect(stringToFloat('-10.234')).toStrictEqual(-10.234);\n      expect(stringToFloat(undefined)).toStrictEqual(null);\n      expect(stringToFloat(null)).toStrictEqual(null);\n      expect(stringToFloat(NaN)).toStrictEqual(null);\n      expect(stringToFloat(false)).toStrictEqual(null);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/convert/index.ts",
    "content": "export * from './string-convert';\nexport * from './nulls-to-undefined';\n"
  },
  {
    "path": "packages/core/src/convert/nulls-to-undefined.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { IRecursivelyReplaceNullWithUndefined } from '../types';\n\nexport function nullsToUndefined<T>(obj: T): IRecursivelyReplaceNullWithUndefined<T> {\n  if (obj == null) {\n    return undefined as any;\n  }\n\n  // object check based on: https://stackoverflow.com/a/51458052/6489012\n  if (obj.constructor.name === 'Object') {\n    for (const key in obj) {\n      obj[key] = nullsToUndefined(obj[key]) as any;\n    }\n  }\n  return obj as any;\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\nexport function nullsToUndefinedShallow<T>(obj: T): IRecursivelyReplaceNullWithUndefined<T> {\n  if (obj == null) {\n    return undefined as any;\n  }\n\n  // object check based on: https://stackoverflow.com/a/51458052/6489012\n  if (obj.constructor.name === 'Object') {\n    for (const key in obj) {\n      obj[key] = obj[key] == null ? undefined : (obj[key] as any);\n    }\n  }\n  return obj as any;\n}\n"
  },
  {
    "path": "packages/core/src/convert/string-convert.ts",
    "content": "import { isParsableNumeric, isParsableSafeInteger } from '../typeguards';\n\nexport function stringToSafeInteger(value: string | unknown): number | null {\n  if (!isParsableSafeInteger(value)) {\n    return null;\n  }\n  return typeof value === 'string' ? Number.parseInt(value, 10) : value;\n}\n\nexport function stringToFloat(value: string | unknown): number | null {\n  if (!isParsableNumeric(typeof value === 'number' ? value.toString(10) : value ?? '')) {\n    return null;\n  }\n  const v = Number.parseFloat(typeof value === 'string' ? value : (value as number).toString(10));\n  return Number.isNaN(v) ? null : v;\n}\n"
  },
  {
    "path": "packages/core/src/errors/extract-error-message.ts",
    "content": "/**\n * Safely extract a human-readable message from any thrown value.\n *\n * Handles all common shapes:\n *  - `Error` instances          → error.message\n *  - plain strings              → the string itself\n *  - objects with message/error → the string field\n *  - nested { error: { message } } → the nested message\n *  - { responseBody: '{\"error\":{\"message\":\"...\"}}' } → parsed body message\n *  - everything else            → JSON.stringify (truncated) or fallback text\n */\nconst unknownError = 'Unknown error';\n\nexport function extractErrorMessage(error: unknown): string {\n  if (error instanceof Error) return error.message || error.name || unknownError;\n  if (typeof error === 'string') return error;\n  if (typeof error !== 'object' || error === null) return unknownError;\n\n  const obj = error as Record<string, unknown>;\n\n  const direct = getStringField(obj, 'message') || getStringField(obj, 'error');\n  if (direct) return direct;\n\n  const nested = getNestedErrorMessage(obj);\n  if (nested) return nested;\n\n  const body = getResponseBodyMessage(obj);\n  if (body) return body;\n\n  try {\n    return JSON.stringify(error).slice(0, 500);\n  } catch {\n    return unknownError;\n  }\n}\n\nfunction getStringField(obj: Record<string, unknown>, field: string): string | null {\n  const value = obj[field];\n  return typeof value === 'string' && value ? value : null;\n}\n\nfunction getNestedErrorMessage(obj: Record<string, unknown>): string | null {\n  const nested = obj.error;\n  if (typeof nested === 'object' && nested !== null) {\n    return getStringField(nested as Record<string, unknown>, 'message');\n  }\n  return null;\n}\n\nfunction getResponseBodyMessage(obj: Record<string, unknown>): string | null {\n  if (typeof obj.responseBody !== 'string') return null;\n  try {\n    const body = JSON.parse(obj.responseBody);\n    return body.error?.message || null;\n  } catch {\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/errors/http/constant.ts",
    "content": "import { HttpErrorCode } from './http-response.types';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ErrorCodeToStatusMap: Record<HttpErrorCode, number> = {\n  [HttpErrorCode.VALIDATION_ERROR]: 400,\n  [HttpErrorCode.INVALID_CAPTCHA]: 400,\n  [HttpErrorCode.INVALID_CREDENTIALS]: 400,\n  [HttpErrorCode.UNAUTHORIZED]: 401,\n  [HttpErrorCode.UNAUTHORIZED_SHARE]: 401,\n  [HttpErrorCode.PAYMENT_REQUIRED]: 402,\n  [HttpErrorCode.CREDIT_LIMIT_EXCEEDED]: 402,\n  [HttpErrorCode.RESTRICTED_RESOURCE]: 403,\n  [HttpErrorCode.NOT_FOUND]: 404,\n  [HttpErrorCode.REQUEST_TIMEOUT]: 408,\n  [HttpErrorCode.CONFLICT]: 409,\n  [HttpErrorCode.UNPROCESSABLE_ENTITY]: 422,\n  [HttpErrorCode.FAILED_DEPENDENCY]: 424,\n  [HttpErrorCode.USER_LIMIT_EXCEEDED]: 460,\n  [HttpErrorCode.TOO_MANY_REQUESTS]: 429,\n  [HttpErrorCode.PAYLOAD_TOO_LARGE]: 413,\n  [HttpErrorCode.INTERNAL_SERVER_ERROR]: 500,\n  [HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE]: 503,\n  [HttpErrorCode.GATEWAY_TIMEOUT]: 504,\n  [HttpErrorCode.UNKNOWN_ERROR_CODE]: 500,\n  [HttpErrorCode.VIEW_NOT_FOUND]: 404,\n  [HttpErrorCode.AUTOMATION_NODE_PARSE_ERROR]: 400,\n  [HttpErrorCode.AUTOMATION_NODE_NEED_TEST]: 400,\n  [HttpErrorCode.AUTOMATION_NODE_TEST_OUTDATED]: 400,\n  [HttpErrorCode.NETWORK_ERROR]: 0,\n};\n"
  },
  {
    "path": "packages/core/src/errors/http/http-response.types.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { ILocalization } from '../types';\n\nexport type IHttpError = {\n  /** a human-readable explanation specific to this occurrence of the problem. */\n  message: string;\n  /** the HTTP status code applicable to this problem, expressed as a string value. */\n  status: number;\n  /** an application-specific error code, expressed as a string value. */\n  code: string;\n  /** additional data */\n  data?: unknown;\n};\n\nexport enum HttpErrorCode {\n  // 400 - The request body does not match the schema for the expected parameters\n  VALIDATION_ERROR = 'validation_error',\n  // 400 - The captcha is invalid.\n  INVALID_CAPTCHA = 'invalid_captcha',\n  // 400 - The credentials are invalid.\n  INVALID_CREDENTIALS = 'invalid_credentials',\n  // 401 - The bearer token is not valid.\n  UNAUTHORIZED = 'unauthorized',\n  // 401 - Given the bearer token used, the client doesn't have permission to perform this operation.\n  UNAUTHORIZED_SHARE = 'unauthorized_share',\n  // 402 - Payment Required\n  PAYMENT_REQUIRED = 'payment_required',\n  // 402 - Credit limit exceeded\n  CREDIT_LIMIT_EXCEEDED = 'credit_limit_exceeded',\n  // 403 - Given the bearer token used, the client doesn't have permission to perform this operation.\n  RESTRICTED_RESOURCE = 'restricted_resource',\n  // 404 - Given the bearer token used, the resource does not exist. This error can also indicate that the resource has not been shared with owner of the bearer token.\n  NOT_FOUND = 'not_found',\n  // 408 - Requset timeout\n  REQUEST_TIMEOUT = 'request_timeout',\n  // 409 - The request could not be completed due to a conflict with the current state of the resource.\n  CONFLICT = 'conflict',\n  // 422 - The request body does not match the schema for the expected parameters\n  UNPROCESSABLE_ENTITY = 'unprocessable_entity',\n  // 424 - The request failed because it depended on another request and that request failed.\n  FAILED_DEPENDENCY = 'failed_dependency',\n  // 460 - The user has reached the limit of the number of users that can be created in the current instance.\n  USER_LIMIT_EXCEEDED = 'user_limit_exceeded',\n  // 429 - The user has reached the limit of the number of requests that can be made in the current instance.\n  TOO_MANY_REQUESTS = 'too_many_requests',\n  // 413 - The request payload is too large.\n  PAYLOAD_TOO_LARGE = 'payload_too_large',\n  // 500 - An unexpected error occurred.\n  INTERNAL_SERVER_ERROR = 'internal_server_error',\n  // 503 - database is unavailable or is not in a state that can be queried. Please try again later.\n  DATABASE_CONNECTION_UNAVAILABLE = 'database_connection_unavailable',\n  // 504 - The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server it needed to access in order to complete the request.\n  GATEWAY_TIMEOUT = 'gateway_timeout',\n  // Unknown error code\n  UNKNOWN_ERROR_CODE = 'unknown_error_code',\n  // Network error - client-side network issue, not server error\n  NETWORK_ERROR = 'network_error',\n  /** view */\n  VIEW_NOT_FOUND = 'view_not_found',\n  /** automation */\n  AUTOMATION_NODE_PARSE_ERROR = 'automation_node_parse_error',\n  // 400 - The automation node needs test.\n  AUTOMATION_NODE_NEED_TEST = 'automation_node_need_test',\n  // 400 - The automation node is outdated.\n  AUTOMATION_NODE_TEST_OUTDATED = 'automation_node_test_outdated',\n}\n\nexport type ICustomHttpExceptionData<T extends string = string> = Record<string, unknown> & {\n  localization?: ILocalization<T>;\n};\n"
  },
  {
    "path": "packages/core/src/errors/http/http.error.ts",
    "content": "import type { IHttpError } from './http-response.types';\nimport { HttpErrorCode } from './http-response.types';\n\nexport class HttpError extends Error implements IHttpError {\n  status: number;\n  code: HttpErrorCode;\n  data?: unknown;\n\n  constructor(\n    error: string | { message?: string; code?: HttpErrorCode; data?: Record<string, unknown> },\n    status: number,\n    data?: Record<string, unknown>\n  ) {\n    const { message = 'Error', code = HttpErrorCode.INTERNAL_SERVER_ERROR } =\n      typeof error === 'string' ? { message: error } : error;\n    super(message);\n    this.status = status;\n    this.code = code;\n    this.data = typeof error === 'object' ? error.data : data;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/errors/http/index.ts",
    "content": "export * from './http-response.types';\nexport * from './http.error';\nexport * from './constant';\n"
  },
  {
    "path": "packages/core/src/errors/index.ts",
    "content": "export * from './extract-error-message';\nexport * from './http';\nexport * from './types';\n"
  },
  {
    "path": "packages/core/src/errors/types.ts",
    "content": "import { z } from 'zod';\n\nexport const localizationSchema = z.object({\n  i18nKey: z.string(),\n  context: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type ILocalization<T extends string = string> = {\n  i18nKey: T;\n  context?: Record<string, unknown>;\n};\n"
  },
  {
    "path": "packages/core/src/formula/errors/circular-reference.error.spec.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { CircularReferenceError } from './circular-reference.error';\n\ndescribe('CircularReferenceError', () => {\n  it('should create error with field ID only', () => {\n    const error = new CircularReferenceError('field1');\n\n    expect(error).toBeInstanceOf(Error);\n    expect(error.name).toBe('CircularReferenceError');\n    expect(error.fieldId).toBe('field1');\n    expect(error.expansionStack).toEqual([]);\n    expect(error.message).toBe('Circular reference detected involving field: field1');\n  });\n\n  it('should create error with expansion stack', () => {\n    const expansionStack = ['field2', 'field3'];\n    const error = new CircularReferenceError('field1', expansionStack);\n\n    expect(error.fieldId).toBe('field1');\n    expect(error.expansionStack).toEqual(['field2', 'field3']);\n    expect(error.message).toBe(\n      'Circular reference detected involving field: field1 (expansion stack: field2 → field3 → field1)'\n    );\n  });\n\n  it('should return circular chain correctly', () => {\n    const error = new CircularReferenceError('field1', ['field2', 'field3']);\n\n    expect(error.getCircularChain()).toEqual(['field2', 'field3', 'field1']);\n  });\n\n  it('should return circular chain for single field', () => {\n    const error = new CircularReferenceError('field1');\n\n    expect(error.getCircularChain()).toEqual(['field1']);\n  });\n\n  it('should return circular description for self-reference', () => {\n    const error = new CircularReferenceError('field1');\n\n    expect(error.getCircularDescription()).toBe('Field field1 references itself');\n  });\n\n  it('should return circular description for multi-field reference', () => {\n    const error = new CircularReferenceError('field1', ['field2', 'field3']);\n\n    expect(error.getCircularDescription()).toBe('Circular reference: field2 → field3 → field1');\n  });\n\n  it('should not mutate original expansion stack', () => {\n    const originalStack = ['field2', 'field3'];\n    const error = new CircularReferenceError('field1', originalStack);\n\n    // Modify the error's stack\n    error.expansionStack.push('field4');\n\n    // Original should be unchanged\n    expect(originalStack).toEqual(['field2', 'field3']);\n    expect(error.expansionStack).toEqual(['field2', 'field3', 'field4']);\n  });\n\n  it('should handle empty expansion stack in description', () => {\n    const error = new CircularReferenceError('field1', []);\n\n    expect(error.getCircularDescription()).toBe('Field field1 references itself');\n  });\n\n  it('should handle complex circular chain', () => {\n    const error = new CircularReferenceError('fieldA', ['fieldB', 'fieldC', 'fieldD']);\n\n    expect(error.getCircularChain()).toEqual(['fieldB', 'fieldC', 'fieldD', 'fieldA']);\n    expect(error.getCircularDescription()).toBe(\n      'Circular reference: fieldB → fieldC → fieldD → fieldA'\n    );\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/errors/circular-reference.error.ts",
    "content": "/**\n * Error thrown when a circular reference is detected in formula field expansion.\n *\n * This error occurs when formula fields reference each other in a circular manner,\n * which would cause infinite recursion during SQL conversion.\n *\n * @example\n * ```\n * // Field A: {B} + 1\n * // Field B: {A} + 1\n * // This would throw a CircularReferenceError\n * ```\n */\nexport class CircularReferenceError extends Error {\n  readonly name = 'CircularReferenceError';\n  readonly fieldId: string;\n  readonly expansionStack: string[];\n\n  constructor(fieldId: string, expansionStack: string[] = []) {\n    const stackTrace =\n      expansionStack.length > 0\n        ? ` (expansion stack: ${expansionStack.join(' → ')} → ${fieldId})`\n        : '';\n\n    super(`Circular reference detected involving field: ${fieldId}${stackTrace}`);\n\n    this.fieldId = fieldId;\n    this.expansionStack = [...expansionStack];\n\n    // Maintains proper stack trace for where our error was thrown (only available on V8)\n    if (Error.captureStackTrace) {\n      Error.captureStackTrace(this, CircularReferenceError);\n    }\n  }\n\n  /**\n   * Returns the full circular reference chain\n   */\n  getCircularChain(): string[] {\n    return [...this.expansionStack, this.fieldId];\n  }\n\n  /**\n   * Returns a human-readable description of the circular reference\n   */\n  getCircularDescription(): string {\n    const chain = this.getCircularChain();\n    if (chain.length <= 1) {\n      return `Field ${this.fieldId} references itself`;\n    }\n    return `Circular reference: ${chain.join(' → ')}`;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/errors/index.ts",
    "content": "export * from './circular-reference.error';\n"
  },
  {
    "path": "packages/core/src/formula/evaluate.ts",
    "content": "import { Formula, FormulaErrorListener, FormulaLexer } from '@teable/formula';\nimport { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport type { FieldCore } from '../models/field/field';\nimport type { IRecord } from '../models/record';\nimport type { TypedValue } from './typed-value';\nimport { EvalVisitor } from './visitor';\n\nexport const evaluate = (\n  input: string,\n  dependFieldMap: { [fieldId: string]: FieldCore },\n  record?: IRecord,\n  timeZone?: string\n): TypedValue => {\n  const inputStream = CharStreams.fromString(input);\n  const lexer = new FormulaLexer(inputStream);\n  const tokenStream = new CommonTokenStream(lexer);\n  const parser = new Formula(tokenStream);\n  parser.removeErrorListeners();\n  const errorListener = new FormulaErrorListener();\n  parser.addErrorListener(errorListener);\n  const tree = parser.root();\n  const visitor = new EvalVisitor(dependFieldMap, record, timeZone);\n  return visitor.visit(tree);\n};\n"
  },
  {
    "path": "packages/core/src/formula/function-aliases.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { FunctionName } from './functions/common';\n\n/**\n * Maps non-standard function tokens to their canonical FunctionName\n * counterpart so both formula evaluation and SQL conversion share the\n * same normalization logic.\n */\nexport const FUNCTION_NAME_ALIASES: Record<string, FunctionName> = {\n  ARRAYJOIN: FunctionName.ArrayJoin,\n  ARRAYUNIQUE: FunctionName.ArrayUnique,\n  ARRAYFLATTEN: FunctionName.ArrayFlatten,\n  ARRAYCOMPACT: FunctionName.ArrayCompact,\n};\n\n/**\n * Normalize a function token (already uppercased) to its canonical\n * FunctionName enum when an alias is declared. Returns the original\n * token when no alias is registered.\n */\nexport const normalizeFunctionNameAlias = (token: string): string =>\n  FUNCTION_NAME_ALIASES[token] ?? token;\n"
  },
  {
    "path": "packages/core/src/formula/function-convertor.interface.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport type { FieldType, DbFieldType } from '../models/field/constant';\nimport type { FieldCore } from '../models/field/field';\n\nexport type FormulaParamType = 'string' | 'number' | 'boolean' | 'datetime' | 'unknown';\n\nexport interface IFormulaParamFieldMetadata {\n  id: string;\n  type?: FieldType;\n  cellValueType?: string;\n  isMultiple?: boolean;\n  isLookup?: boolean;\n  dbFieldName?: string;\n  dbFieldType?: DbFieldType;\n}\n\nexport interface IFormulaParamMetadata {\n  type: FormulaParamType;\n  isFieldReference: boolean;\n  field?: IFormulaParamFieldMetadata;\n}\n\n/**\n * Generic field map type for formula conversion contexts\n */\nexport type IFieldMap<T extends FieldCore = FieldCore> = Map<string, T>;\n\n/**\n * Base interface for converting Teable formula functions to database-specific implementations\n * This interface defines the contract for translating Teable functions to database functions\n * with a generic return type to support different use cases (SQL strings, boolean validation, etc.)\n */\nexport interface ITeableToDbFunctionConverter<TReturn, TContext> {\n  // Context management\n  setContext(context: TContext): void;\n  setCallMetadata(metadata?: IFormulaParamMetadata[]): void;\n  // Numeric Functions\n  sum(params: string[]): TReturn;\n  average(params: string[]): TReturn;\n  max(params: string[]): TReturn;\n  min(params: string[]): TReturn;\n  round(value: string, precision?: string): TReturn;\n  roundUp(value: string, precision?: string): TReturn;\n  roundDown(value: string, precision?: string): TReturn;\n  ceiling(value: string): TReturn;\n  floor(value: string): TReturn;\n  even(value: string): TReturn;\n  odd(value: string): TReturn;\n  int(value: string): TReturn;\n  abs(value: string): TReturn;\n  sqrt(value: string): TReturn;\n  power(base: string, exponent: string): TReturn;\n  exp(value: string): TReturn;\n  log(value: string, base?: string): TReturn;\n  mod(dividend: string, divisor: string): TReturn;\n  value(text: string): TReturn;\n\n  // Text Functions\n  concatenate(params: string[]): TReturn;\n  stringConcat(left: string, right: string): TReturn;\n  find(searchText: string, withinText: string, startNum?: string): TReturn;\n  search(searchText: string, withinText: string, startNum?: string): TReturn;\n  mid(text: string, startNum: string, numChars: string): TReturn;\n  left(text: string, numChars: string): TReturn;\n  right(text: string, numChars: string): TReturn;\n  replace(oldText: string, startNum: string, numChars: string, newText: string): TReturn;\n  regexpReplace(text: string, pattern: string, replacement: string): TReturn;\n  substitute(text: string, oldText: string, newText: string, instanceNum?: string): TReturn;\n  lower(text: string): TReturn;\n  upper(text: string): TReturn;\n  rept(text: string, numTimes: string): TReturn;\n  trim(text: string): TReturn;\n  len(text: string): TReturn;\n  t(value: string): TReturn;\n  encodeUrlComponent(text: string): TReturn;\n\n  // DateTime Functions\n  now(): TReturn;\n  today(): TReturn;\n  dateAdd(date: string, count: string, unit: string): TReturn;\n  datestr(date: string): TReturn;\n  datetimeDiff(startDate: string, endDate: string, unit: string): TReturn;\n  datetimeFormat(date: string, format: string): TReturn;\n  datetimeParse(dateString: string, format?: string): TReturn;\n  day(date: string): TReturn;\n  fromNow(date: string, unit?: string): TReturn;\n  hour(date: string): TReturn;\n  isAfter(date1: string, date2: string): TReturn;\n  isBefore(date1: string, date2: string): TReturn;\n  isSame(date1: string, date2: string, unit?: string): TReturn;\n  lastModifiedTime(): TReturn;\n  minute(date: string): TReturn;\n  month(date: string): TReturn;\n  second(date: string): TReturn;\n  timestr(date: string): TReturn;\n  toNow(date: string, unit?: string): TReturn;\n  weekNum(date: string): TReturn;\n  weekday(date: string, startDayOfWeek?: string): TReturn;\n  workday(startDate: string, days: string, holidayStr?: string): TReturn;\n  workdayDiff(startDate: string, endDate: string): TReturn;\n  year(date: string): TReturn;\n  createdTime(): TReturn;\n\n  // Logical Functions\n  if(condition: string, valueIfTrue: string, valueIfFalse: string): TReturn;\n  and(params: string[]): TReturn;\n  or(params: string[]): TReturn;\n  not(value: string): TReturn;\n  xor(params: string[]): TReturn;\n  blank(): TReturn;\n  error(message: string): TReturn;\n  isError(value: string): TReturn;\n  switch(\n    expression: string,\n    cases: Array<{ case: string; result: string }>,\n    defaultResult?: string\n  ): TReturn;\n\n  // Array Functions\n  count(params: string[]): TReturn;\n  countA(params: string[]): TReturn;\n  countAll(value: string): TReturn;\n  arrayJoin(array: string, separator?: string): TReturn;\n  arrayUnique(arrays: string[]): TReturn;\n  arrayFlatten(arrays: string[]): TReturn;\n  arrayCompact(arrays: string[]): TReturn;\n\n  // System Functions\n  recordId(): TReturn;\n  autoNumber(): TReturn;\n  textAll(value: string): TReturn;\n\n  // Binary Operations\n  add(left: string, right: string): TReturn;\n  subtract(left: string, right: string): TReturn;\n  multiply(left: string, right: string): TReturn;\n  divide(left: string, right: string): TReturn;\n  modulo(left: string, right: string): TReturn;\n\n  // Comparison Operations\n  equal(left: string, right: string): TReturn;\n  notEqual(left: string, right: string): TReturn;\n  greaterThan(left: string, right: string): TReturn;\n  lessThan(left: string, right: string): TReturn;\n  greaterThanOrEqual(left: string, right: string): TReturn;\n  lessThanOrEqual(left: string, right: string): TReturn;\n\n  // Logical Operations\n  logicalAnd(left: string, right: string): TReturn;\n  logicalOr(left: string, right: string): TReturn;\n  bitwiseAnd(left: string, right: string): TReturn;\n\n  // Unary Operations\n  unaryMinus(value: string): TReturn;\n\n  // Field Reference\n  fieldReference(fieldId: string, columnName: string): TReturn;\n\n  // Literals\n  stringLiteral(value: string): TReturn;\n  numberLiteral(value: number): TReturn;\n  booleanLiteral(value: boolean): TReturn;\n  nullLiteral(): TReturn;\n\n  // Utility methods for type conversion and validation\n  castToNumber(value: string): TReturn;\n  castToString(value: string): TReturn;\n  castToBoolean(value: string): TReturn;\n  castToDate(value: string): TReturn;\n\n  // Handle null values and type checking\n  isNull(value: string): TReturn;\n  coalesce(params: string[]): TReturn;\n\n  // Parentheses for grouping\n  parentheses(expression: string): TReturn;\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/array.spec.ts",
    "content": "import { CellValueType } from '../../models/field/constant';\nimport { TypedValue } from '../typed-value';\nimport {\n  ArrayCompact,\n  ArrayFlatten,\n  ArrayJoin,\n  ArrayUnique,\n  Count,\n  CountA,\n  CountAll,\n} from './array';\n\ndescribe('ArrayFunc', () => {\n  describe('CountAll', () => {\n    const countAllFunc = new CountAll();\n    it('should count items in arrays correctly', () => {\n      const result1 = countAllFunc.eval([\n        new TypedValue([1, [2, 3], 4], CellValueType.Number, true),\n      ]);\n      expect(result1).toBe(3);\n\n      const result2 = countAllFunc.eval([new TypedValue([1, 2, 3], CellValueType.Number, true)]);\n      expect(result2).toBe(3);\n    });\n\n    it('should count null to 0', () => {\n      const result1 = countAllFunc.eval([new TypedValue(null, CellValueType.Number, false)]);\n      expect(result1).toBe(0);\n\n      const result2 = countAllFunc.eval([new TypedValue(null, CellValueType.String, false)]);\n      expect(result2).toBe(0);\n    });\n\n    it('should count [null] to 1', () => {\n      const result1 = countAllFunc.eval([new TypedValue([null], CellValueType.Number, true)]);\n      expect(result1).toBe(1);\n\n      const result2 = countAllFunc.eval([new TypedValue([[null]], CellValueType.String, true)]);\n      expect(result2).toBe(1);\n    });\n  });\n\n  describe('CountA', () => {\n    const countAFunc = new CountA();\n\n    it('should count non-empty values in array', () => {\n      const result = countAFunc.eval([new TypedValue([1, 2, null], CellValueType.Number, true)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should count non-empty values in nested array', () => {\n      const result = countAFunc.eval([\n        new TypedValue([1, 2, [null, 3]], CellValueType.Number, true),\n      ]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should count non-empty values in multiple params', () => {\n      const result = countAFunc.eval([\n        new TypedValue([1, 2, 3, null], CellValueType.Number, true),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(4);\n    });\n\n    it('should count numbers example', () => {\n      const result = countAFunc.eval([\n        new TypedValue(100, CellValueType.Number, false),\n        new TypedValue(200, CellValueType.Number, false),\n        new TypedValue(300, CellValueType.Number, false),\n        new TypedValue('', CellValueType.String, false),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue(true, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(4);\n    });\n  });\n\n  describe('Count', () => {\n    const countFunc = new Count();\n\n    it('should count numbers in array', () => {\n      const result = countFunc.eval([new TypedValue([1, 2, null], CellValueType.Number, true)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should count numbers in multiple params', () => {\n      const result = countFunc.eval([\n        new TypedValue([1, 2, 'A', 'B'], CellValueType.Number, true),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should count numbers example', () => {\n      const result = countFunc.eval([\n        new TypedValue(100, CellValueType.Number, false),\n        new TypedValue(200, CellValueType.Number, false),\n        new TypedValue(300, CellValueType.Number, false),\n        new TypedValue('', CellValueType.String, false),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue(true, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(3);\n    });\n  });\n\n  describe('ArrayJoin', () => {\n    const arrayJoinFunc = new ArrayJoin();\n\n    it('should join array elements with default separator', () => {\n      const result = arrayJoinFunc.eval([\n        new TypedValue(['A', 'B', 'C'], CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('A, B, C');\n    });\n\n    it('should join array elements with custom separator', () => {\n      const result = arrayJoinFunc.eval([\n        new TypedValue(['A', 'B', 'C'], CellValueType.String, true),\n        new TypedValue('-', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('A-B-C');\n    });\n  });\n\n  describe('ArrayUnique', () => {\n    const arrayUniqueFunc = new ArrayUnique();\n\n    it('should remove duplicates in array', () => {\n      const result = arrayUniqueFunc.eval([\n        new TypedValue(['A', 'B', 'C', ['D'], 'B'], CellValueType.String, true),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C', 'D']);\n    });\n\n    it('should remove duplicates in array and value', () => {\n      const result = arrayUniqueFunc.eval([\n        new TypedValue(['A', 'B', 'C', ['D']], CellValueType.String, true),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C', 'D']);\n    });\n  });\n\n  describe('ArrayFlatten', () => {\n    const arrayFlattenFunc = new ArrayFlatten();\n\n    it('should flatten nested array', () => {\n      const result = arrayFlattenFunc.eval([\n        new TypedValue(['A', 'B', 'C', ['D']], CellValueType.String, true),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C', 'D']);\n    });\n\n    it('should flatten nested array and concat value', () => {\n      const result = arrayFlattenFunc.eval([\n        new TypedValue(['A', 'B', 'C', ['D']], CellValueType.String, true),\n        new TypedValue('ABC', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C', 'D', 'ABC']);\n    });\n  });\n\n  describe('ArrayCompact', () => {\n    const arrayCompactFunc = new ArrayCompact();\n\n    it('should remove empty values from array', () => {\n      const result = arrayCompactFunc.eval([\n        new TypedValue(['A', 'B', '', null], CellValueType.String, true),\n      ]);\n\n      expect(result).toEqual(['A', 'B']);\n    });\n\n    it('should remove empty values from array and values', () => {\n      const result = arrayCompactFunc.eval([\n        new TypedValue(['A', 'B', '', null], CellValueType.String, true),\n        new TypedValue('C', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C']);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/array.ts",
    "content": "import { isNumber, isString } from 'lodash';\nimport { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\nimport { convertValueToString } from './text';\n\nabstract class ArrayFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.Array;\n}\n\ntype IUnionType = string | number | boolean | null | IUnionType[];\n\nconst countCalculator = (\n  params: TypedValue<IUnionType>[],\n  calcFn: (v: IUnionType) => boolean\n): number => {\n  return params.reduce((result, param) => {\n    if (param.isMultiple) {\n      if (!Array.isArray(param.value) || param.value === null) {\n        return calcFn(param.value) ? result + 1 : result;\n      }\n      result += param.value.reduce((pre: number, v: IUnionType) => {\n        if (!Array.isArray(v)) {\n          return calcFn(v) ? pre + 1 : pre;\n        }\n        pre += v.filter(calcFn).length;\n        return pre;\n      }, 0);\n      return result;\n    }\n\n    return calcFn(param.value) ? result + 1 : result;\n  }, 0);\n};\n\nconst flatten = (arr: IUnionType[]) => {\n  let result: IUnionType[] = [];\n\n  for (const item of arr) {\n    if (item !== null) {\n      if (Array.isArray(item)) {\n        result = result.concat(flatten(item));\n      } else {\n        result.push(item);\n      }\n    }\n  }\n  return result;\n};\n\nconst flattenParams = (params: TypedValue<IUnionType>[]) => {\n  return params.reduce((prev: IUnionType[], item) => {\n    const value = item.value;\n    if (value == null) return prev;\n    return prev.concat(Array.isArray(value) ? flatten(value) : value);\n  }, []);\n};\n\nconst getUnionReturnType = (params: TypedValue[]) => {\n  if (!params?.length) return { type: CellValueType.String, isMultiple: true };\n\n  const firstCellValueType = params[0].type;\n  const isAllSameType = params.every((param) => param.type === firstCellValueType);\n\n  return {\n    type: isAllSameType ? firstCellValueType : CellValueType.String,\n    isMultiple: true,\n  };\n};\n\nexport class CountAll extends ArrayFunc {\n  name = FunctionName.CountAll;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.CountAll} needs 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<IUnionType>[]): number {\n    if (params[0].value == null) {\n      return 0;\n    }\n    if (Array.isArray(params[0].value)) {\n      return params[0].value.length;\n    }\n    return 1;\n  }\n}\n\nexport class CountA extends ArrayFunc {\n  name = FunctionName.CountA;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.CountA} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<IUnionType>[]): number {\n    return countCalculator(params, (v) => isNumber(v) || (isString(v) && v !== ''));\n  }\n}\n\nexport class Count extends ArrayFunc {\n  name = FunctionName.Count;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Count} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<IUnionType>[]): number {\n    return countCalculator(params, isNumber);\n  }\n}\n\nexport class ArrayJoin extends ArrayFunc {\n  name = FunctionName.ArrayJoin;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.ArrayJoin} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    let separator = params[1]?.value;\n    separator = isString(separator) ? separator : ', ';\n    return convertValueToString(params[0], separator);\n  }\n}\n\nexport class ArrayUnique extends ArrayFunc {\n  name = FunctionName.ArrayUnique;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.ArrayUnique} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return getUnionReturnType(params);\n  }\n\n  eval(params: TypedValue<IUnionType>[]): IUnionType | null {\n    const flattenArray = flattenParams(params);\n    const uniqueArray = [...new Set(flattenArray)];\n    return uniqueArray.length ? uniqueArray : null;\n  }\n}\n\nexport class ArrayFlatten extends ArrayFunc {\n  name = FunctionName.ArrayFlatten;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.ArrayFlatten} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return getUnionReturnType(params);\n  }\n\n  eval(params: TypedValue<IUnionType>[]): IUnionType | null {\n    const flattenArray = flattenParams(params);\n    return flattenArray.length ? flattenArray : null;\n  }\n}\n\nexport class ArrayCompact extends ArrayFunc {\n  name = FunctionName.ArrayCompact;\n\n  acceptValueType = new Set([\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.String,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.ArrayCompact} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    params && this.validateParams(params);\n    return getUnionReturnType(params);\n  }\n\n  eval(params: TypedValue<IUnionType>[]): IUnionType | null {\n    const flattenArray = flattenParams(params);\n    const filteredArray = flattenArray.filter((v) => v !== '');\n    return filteredArray.length ? filteredArray : null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/common.ts",
    "content": "import type { CellValueType } from '../../models/field/constant';\nimport type { FieldCore } from '../../models/field/field';\nimport type { IRecord } from '../../models/record';\nimport type { TypedValue } from '../typed-value';\n\nexport enum FormulaFuncType {\n  Array = 'Array',\n  DateTime = 'DataTime',\n  Logical = 'Logical',\n  Numeric = 'Numeric',\n  Text = 'Text',\n  System = 'System',\n}\n\nexport interface IFormulaContext {\n  record: IRecord;\n  timeZone: string;\n  dependencies: { [fieldId: string]: FieldCore };\n}\n\nexport abstract class FormulaFunc {\n  abstract readonly name: FunctionName;\n\n  abstract readonly type: FormulaFuncType;\n\n  /**\n   * The value types that can be accepted as function parameters.\n   * If the parameter type is not in acceptCellValueType, it will be converted to a string type by the interpreter.\n   * If the parameter type is in acceptCellValueType, the original value will be returned and processed by the function implementation itself.\n   */\n  abstract acceptValueType: Set<CellValueType>;\n\n  abstract acceptMultipleValue: boolean;\n\n  /**\n   * The function needs to perform parameter type and quantity verification during the AST tree parsing phase. If the requirements of the function are not met, throw a new Error with a friendly prompt.\n   * Error throwing principles:\n   * 1. Throw an error if required parameters are missing, ignore extra parameters\n   * 2. Throw an error for parameter types that cannot be converted or ignored\n   * 3. The function name should be clearly stated in the error message\n   * 4. Arabic numerals such as \"3\" should be used instead of Chinese characters such as \"三\" in error messages regarding numbers.\n   */\n  abstract validateParams(params: TypedValue[]): void;\n\n  /**\n   * @param params The parameter is optional. When the parameter is not passed, it returns a static default type. When the parameter is passed, different functions dynamically calculate the return type based on the parameter type.\n   * The function return type can be directly inferred from AstNode without obtaining actual values.\n   */\n  abstract getReturnType(params?: TypedValue[]): {\n    type: CellValueType;\n    isMultiple?: boolean;\n  };\n\n  // function implementation\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  abstract eval(params: TypedValue[], context: IFormulaContext): any;\n}\n\nexport enum FunctionName {\n  // Numeric\n  Sum = 'SUM',\n  Average = 'AVERAGE',\n  Max = 'MAX',\n  Min = 'MIN',\n  Round = 'ROUND',\n  RoundUp = 'ROUNDUP',\n  RoundDown = 'ROUNDDOWN',\n  Ceiling = 'CEILING',\n  Floor = 'FLOOR',\n  Even = 'EVEN',\n  Odd = 'ODD',\n  Int = 'INT',\n  Abs = 'ABS',\n  Sqrt = 'SQRT',\n  Power = 'POWER',\n  Exp = 'EXP',\n  Log = 'LOG',\n  Mod = 'MOD',\n  Value = 'VALUE',\n\n  // Text\n  Concatenate = 'CONCATENATE',\n  Find = 'FIND',\n  Search = 'SEARCH',\n  Mid = 'MID',\n  Left = 'LEFT',\n  Right = 'RIGHT',\n  Replace = 'REPLACE',\n  RegExpReplace = 'REGEXP_REPLACE',\n  Substitute = 'SUBSTITUTE',\n  Lower = 'LOWER',\n  Upper = 'UPPER',\n  Rept = 'REPT',\n  Trim = 'TRIM',\n  Len = 'LEN',\n  T = 'T',\n  EncodeUrlComponent = 'ENCODE_URL_COMPONENT',\n\n  // Logical\n  If = 'IF',\n  Switch = 'SWITCH',\n  And = 'AND',\n  Or = 'OR',\n  Xor = 'XOR',\n  Not = 'NOT',\n  Blank = 'BLANK',\n  Error = 'ERROR',\n  IsError = 'IS_ERROR',\n\n  // DateTime\n  Today = 'TODAY',\n  Now = 'NOW',\n  Year = 'YEAR',\n  Month = 'MONTH',\n  WeekNum = 'WEEKNUM',\n  Weekday = 'WEEKDAY',\n  Day = 'DAY',\n  Hour = 'HOUR',\n  Minute = 'MINUTE',\n  Second = 'SECOND',\n  FromNow = 'FROMNOW',\n  ToNow = 'TONOW',\n  DatetimeDiff = 'DATETIME_DIFF',\n  Workday = 'WORKDAY',\n  WorkdayDiff = 'WORKDAY_DIFF',\n  IsSame = 'IS_SAME',\n  IsAfter = 'IS_AFTER',\n  IsBefore = 'IS_BEFORE',\n  DateAdd = 'DATE_ADD',\n  Datestr = 'DATESTR',\n  Timestr = 'TIMESTR',\n  DatetimeFormat = 'DATETIME_FORMAT',\n  DatetimeParse = 'DATETIME_PARSE',\n  CreatedTime = 'CREATED_TIME',\n  LastModifiedTime = 'LAST_MODIFIED_TIME',\n\n  // Array\n  CountAll = 'COUNTALL',\n  CountA = 'COUNTA',\n  Count = 'COUNT',\n  ArrayJoin = 'ARRAY_JOIN',\n  ArrayUnique = 'ARRAY_UNIQUE',\n  ArrayFlatten = 'ARRAY_FLATTEN',\n  ArrayCompact = 'ARRAY_COMPACT',\n\n  // System\n  TextAll = 'TEXT_ALL',\n  RecordId = 'RECORD_ID',\n  AutoNumber = 'AUTO_NUMBER',\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/date-time.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport type { IRecord } from '../../models';\nimport { CellValueType } from '../../models/field/constant';\nimport type { FieldCore } from '../../models/field/field';\nimport { TypedValue } from '../typed-value';\nimport {\n  CreatedTime,\n  DateAdd,\n  Datestr,\n  DatetimeDiff,\n  DatetimeFormat,\n  DatetimeParse,\n  Day,\n  FromNow,\n  ToNow,\n  Hour,\n  IsAfter,\n  IsBefore,\n  IsSame,\n  LastModifiedTime,\n  Minute,\n  Month,\n  Second,\n  Timestr,\n  Today,\n  WeekNum,\n  Weekday,\n  Workday,\n  WorkdayDiff,\n  Year,\n  dayjs,\n} from './date-time';\n\ndescribe('DateTime', () => {\n  describe('Today', () => {\n    const todayFunc = new Today();\n\n    it('should return the current date', () => {\n      const timeZone = 'America/Los_Angeles';\n      const result = todayFunc.eval([], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone,\n      });\n\n      expect(result).toBe(dayjs().tz(timeZone).startOf('d').toISOString());\n    });\n  });\n\n  describe('Year', () => {\n    const yearFunc = new Year();\n\n    it('should return the year from a given date string', () => {\n      const result = yearFunc.eval([new TypedValue('2023-09-08', CellValueType.String, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'Asia/Shanghai',\n      });\n\n      expect(result).toBe(2023);\n    });\n\n    it('should return the year from a given date iso string', () => {\n      // time zone test America/Los_Angeles -7 ~ -8\n      expect(\n        yearFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-01-01T07:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(2022);\n\n      expect(\n        yearFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-01-01T09:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(2023);\n    });\n  });\n\n  describe('Month', () => {\n    const monthFunc = new Month();\n\n    it('should return the month from a given date string', () => {\n      const result = monthFunc.eval([new TypedValue('2023-09-01', CellValueType.String, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe(9);\n    });\n\n    it('should return the month from a given date iso string', () => {\n      expect(\n        monthFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-01T06:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(8);\n\n      expect(\n        monthFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-01T09:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(9);\n    });\n  });\n\n  describe('WeekNum', () => {\n    const weekNumFunc = new WeekNum();\n\n    it('should return the weeknum from a given date string', () => {\n      const result = weekNumFunc.eval([new TypedValue('2023-09-08', CellValueType.String, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe(36);\n    });\n\n    it('should return the weeknum from a given date iso string', () => {\n      const result = weekNumFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08T07:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(36);\n    });\n  });\n\n  describe('Weekday', () => {\n    const weekdayFunc = new Weekday();\n    it('should return the weekday from a given date string', () => {\n      const result = weekdayFunc.eval([new TypedValue('2023-09-08', CellValueType.String, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe(5);\n    });\n\n    it('should return the weekday from a given date iso string', () => {\n      const result = weekdayFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08T00:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(4);\n    });\n\n    it('should return the weekday from a given date iso string', () => {\n      const result = weekdayFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08T00:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n          new TypedValue('monday', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(3);\n    });\n  });\n\n  describe('Day', () => {\n    const dayFunc = new Day();\n\n    it('should return the day from a given date string', () => {\n      const result = dayFunc.eval([new TypedValue('2023-09-08', CellValueType.String, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe(8);\n    });\n\n    it('should return the day from a given date iso string', () => {\n      expect(\n        dayFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-08T00:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(7);\n\n      expect(\n        dayFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-07T20:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'Asia/Shanghai',\n          }\n        )\n      ).toBe(8);\n\n      expect(\n        dayFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-07T00:00:00+09:00').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'Asia/Shanghai',\n          }\n        )\n      ).toBe(6);\n    });\n  });\n\n  describe('Hour', () => {\n    const hourFunc = new Hour();\n\n    it('should return the hours from a given date-time string', () => {\n      const result = hourFunc.eval(\n        [new TypedValue('2023-09-08 18:28:38', CellValueType.String, false)],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(18);\n    });\n\n    it('should return the hours from a given date-time iso string', () => {\n      const result = hourFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08T18:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(11);\n    });\n  });\n\n  describe('Minute', () => {\n    const minuteFunc = new Minute();\n\n    it('should return the minutes from a given date-time string', () => {\n      const result = minuteFunc.eval(\n        [new TypedValue('2023-09-08 18:28:38', CellValueType.String, false)],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(28);\n    });\n\n    it('should return the minutes from a given date-time iso string', () => {\n      const result = minuteFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08T18:28:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(28);\n    });\n  });\n\n  describe('Second', () => {\n    const secondFunc = new Second();\n\n    it('should return the seconds from a given date-time string', () => {\n      const result = secondFunc.eval(\n        [new TypedValue('2023-09-08 18:28:38', CellValueType.String, false)],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(38);\n    });\n\n    it('should return the seconds from a given date-time iso string', () => {\n      const result = secondFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-08 18:28:38').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(38);\n    });\n  });\n\n  describe('FromNow', () => {\n    const fromNowFunc = new FromNow();\n    // Calculate a date 36 days in the past\n    const date = new Date(Date.now() - 36 * 24 * 60 * 60 * 1000).toISOString();\n\n    it('should return the difference in years from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(0);\n    });\n\n    it('should return the difference in months from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(1);\n    });\n\n    it('should return the difference in days from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('day', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(36);\n    });\n\n    it('should return the difference in hours from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('hour', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n      expect(result).toBe(864);\n    });\n\n    it('should return the difference in minutes from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('minute', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(51840);\n    });\n\n    it('should return the approximate difference in years from the current date to the given date', () => {\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.String, false),\n          new TypedValue(true, CellValueType.Boolean, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBeCloseTo(0.1, 1);\n    });\n  });\n\n  describe('DatetimeDiff', () => {\n    const datetimeDiffFunc = new DatetimeDiff();\n    const startDate = new Date('2022-08-01T16:30:00.000Z').toISOString();\n    const endDate = new Date('2023-09-08T19:20:00.000Z').toISOString();\n\n    it('should return the difference in day between two dates by default', () => {\n      expect(\n        datetimeDiffFunc.eval(\n          [\n            new TypedValue(startDate, CellValueType.DateTime, false),\n            new TypedValue(endDate, CellValueType.DateTime, false),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(-403);\n\n      expect(\n        datetimeDiffFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-09T00:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n            new TypedValue(\n              new Date('2023-09-08T00:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(1);\n    });\n\n    it('should return the difference in years between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-1);\n    });\n\n    it('should return the difference in months between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-13);\n    });\n\n    it('should return the difference in days between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('day', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-403);\n    });\n\n    it('should return the difference in hours between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('hour', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-9674);\n    });\n\n    it('should return the difference in minutes between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('minute', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-580490);\n    });\n\n    it('should return the difference in seconds between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('second', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(-34829400);\n    });\n\n    it('should return an approximate difference in months between two dates', () => {\n      const result = datetimeDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.String, false),\n          new TypedValue(true, CellValueType.Boolean, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBeCloseTo(-13.23, 2);\n    });\n  });\n\n  describe('Workday', () => {\n    const workdayFunc = new Workday();\n    const startDate = new Date('2023-09-08 00:00:00').toISOString();\n    const holidayStr = '2024-01-22, 2024-01-23, 2024-01-24, 2024-01-25';\n\n    it('should add 200 workdays to the start date', () => {\n      const result = workdayFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(200, CellValueType.Number, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(new Date('2024-06-14 00:00:00').toISOString());\n    });\n\n    it('should add 200 workdays to the start date, excluding the specified holidays', () => {\n      const result = workdayFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(200, CellValueType.Number, false),\n          new TypedValue(holidayStr, CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(new Date('2024-06-20 00:00:00').toISOString());\n    });\n\n    it('should subtract 100 workdays from the start date', () => {\n      const result = workdayFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(-100, CellValueType.Number, false),\n        ],\n        { record: {} as IRecord, dependencies: {}, timeZone: 'America/Los_Angeles' }\n      );\n\n      expect(result).toBe(new Date('2023-04-21 00:00:00').toISOString());\n    });\n\n    it('should subtract 100 workdays from the start date, excluding the specified holidays', () => {\n      const result = workdayFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(-100, CellValueType.Number, false),\n          new TypedValue('2023-08-03, 2023-08-11', CellValueType.String, false),\n        ],\n        { record: {} as IRecord, dependencies: {}, timeZone: 'America/Los_Angeles' }\n      );\n\n      expect(result).toBe(new Date('2023-04-19 00:00:00').toISOString());\n    });\n\n    it('should skip the start date when it is considered a holiday', () => {\n      const result = workdayFunc.eval(\n        [\n          new TypedValue('2023-09-07 00:00:00', CellValueType.String, false),\n          new TypedValue(2, CellValueType.Number, false),\n          new TypedValue(startDate, CellValueType.DateTime, false),\n        ],\n        { record: {} as IRecord, dependencies: {}, timeZone: 'America/Los_Angeles' }\n      );\n\n      expect(result).toBe(new Date('2023-09-12T07:00:00.000Z').toISOString());\n    });\n  });\n\n  describe('WorkdayDiff', () => {\n    const workdayDiffFunc = new WorkdayDiff();\n    const startDate = new Date('2023-06-18').toISOString();\n    const endDate = new Date('2023-10-01').toISOString();\n    const holidayStr = '2023-07-12, 2023-08-18, 2023-08-19';\n\n    it('should return the difference in workdays between two dates', () => {\n      const result = workdayDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(75);\n    });\n\n    it('should return the difference in workdays between two dates, excluding the specified holidays', () => {\n      const result = workdayDiffFunc.eval(\n        [\n          new TypedValue(startDate, CellValueType.DateTime, false),\n          new TypedValue(endDate, CellValueType.DateTime, false),\n          new TypedValue(holidayStr, CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(73);\n    });\n\n    it('should accurately return the workday difference for short date ranges', () => {\n      const result = workdayDiffFunc.eval(\n        [\n          new TypedValue(new Date('2023-09-05').toISOString(), CellValueType.DateTime, false),\n          new TypedValue(new Date('2023-09-11').toISOString(), CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(5);\n    });\n  });\n\n  describe('IsSame', () => {\n    const isSameFunc = new IsSame();\n    const date1 = new Date('2023-09-08 18:00:00').toISOString();\n    const date2 = new Date('2023-09-10 18:00:00').toISOString();\n\n    it('should return false when checking if two distinct dates are the same without any granularity', () => {\n      const result = isSameFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when checking if two distinct dates are from the same year', () => {\n      const result = isSameFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n\n    it('should return true when checking if two distinct dates are from the same month', () => {\n      const result = isSameFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n\n    it('should return true when checking if two distinct dates are the same day', () => {\n      expect(\n        isSameFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-08T23:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n            new TypedValue(\n              new Date('2023-09-09T03:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n            new TypedValue('day', CellValueType.DateTime, false),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(true);\n\n      expect(\n        isSameFunc.eval(\n          [\n            new TypedValue(\n              new Date('2023-09-09T23:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n            new TypedValue(\n              new Date('2023-09-09T03:00:00.000Z').toISOString(),\n              CellValueType.DateTime,\n              false\n            ),\n            new TypedValue('day', CellValueType.DateTime, false),\n          ],\n          {\n            record: {} as IRecord,\n            dependencies: {},\n            timeZone: 'America/Los_Angeles',\n          }\n        )\n      ).toBe(false);\n    });\n  });\n\n  describe('IsAfter', () => {\n    const isAfterFunc = new IsAfter();\n    const date1 = new Date('2023-09-10 18:00:00').toISOString();\n    const date2 = new Date('2023-09-08 18:00:00').toISOString();\n\n    it('should return true when date1 is after date2 without any granularity', () => {\n      const result = isAfterFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when date1 and date2 are in the same year', () => {\n      const result = isAfterFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(false);\n    });\n\n    it('should return false when date1 and date2 are in the same month', () => {\n      const result = isAfterFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when date1 is after date2 in terms of day', () => {\n      const result = isAfterFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('day', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('IsBefore', () => {\n    const isBeforeFunc = new IsBefore();\n    const date1 = new Date('2023-09-08 18:00:00').toISOString();\n    const date2 = new Date('2023-09-10 18:00:00').toISOString();\n\n    it('should return true when date1 is before date2 without any granularity', () => {\n      const result = isBeforeFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when date1 and date2 are in the same year', () => {\n      const result = isBeforeFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('year', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(false);\n    });\n\n    it('should return false when date1 and date2 are in the same month', () => {\n      const result = isBeforeFunc.eval(\n        [\n          new TypedValue(date1, CellValueType.DateTime, false),\n          new TypedValue(date2, CellValueType.DateTime, false),\n          new TypedValue('month', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when date1 is before date2 in terms of day', () => {\n      const result = isBeforeFunc.eval(\n        [\n          new TypedValue(\n            new Date('2023-09-09T03:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n          new TypedValue(\n            new Date('2023-09-09T13:00:00.000Z').toISOString(),\n            CellValueType.DateTime,\n            false\n          ),\n          new TypedValue('day', CellValueType.DateTime, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(true);\n    });\n  });\n\n  describe('DateAdd', () => {\n    const dateAddFunc = new DateAdd();\n    const date = new Date('2023-09-08 18:00:00').toISOString();\n\n    it('should add 10 days to the given date', () => {\n      const result = dateAddFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue(10, CellValueType.Number, false),\n          new TypedValue('day', CellValueType.Number, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(new Date('2023-09-18 18:00:00').toISOString());\n    });\n\n    it('should add 2 months to the given date', () => {\n      const result = dateAddFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue(2, CellValueType.Number, false),\n          new TypedValue('month', CellValueType.Number, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(new Date('2023-11-08 18:00:00').toISOString());\n    });\n  });\n\n  describe('Datestr', () => {\n    const datestrFunc = new Datestr();\n    const date = new Date('2023-09-08 18:00:00').toISOString();\n\n    it('should return only the date part of a DateTime value', () => {\n      const result = datestrFunc.eval([new TypedValue(date, CellValueType.DateTime, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe('2023-09-08');\n    });\n  });\n\n  describe('Timestr', () => {\n    const timestrFunc = new Timestr();\n    const date = new Date('2023-09-08T18:56:00.000Z').toISOString();\n\n    it('should return only the time part of a DateTime value', () => {\n      const result = timestrFunc.eval([new TypedValue(date, CellValueType.DateTime, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe('11:56:00');\n    });\n  });\n\n  describe('DatetimeFormat', () => {\n    const datetimeFormatFunc = new DatetimeFormat();\n    const date = new Date('2023-09-08T18:56:00.000Z').toISOString();\n\n    it('The function returns a formatted date-time string when no specific format string is provided', () => {\n      const result = datetimeFormatFunc.eval(\n        [new TypedValue(date, CellValueType.DateTime, false)],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe('2023-09-08 11:56');\n    });\n\n    it('The function returns the date-time in a custom format when a format string is provided', () => {\n      const result = datetimeFormatFunc.eval(\n        [\n          new TypedValue(date, CellValueType.DateTime, false),\n          new TypedValue('M/D/YYYY', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe('9/8/2023');\n    });\n  });\n\n  describe('DatetimeParse', () => {\n    const datetimeParseFunc = new DatetimeParse();\n    const date = new Date('2023-09-08 18:56:00').toISOString();\n\n    it('The function returns an ISO string when given a date-time ISO string without a specific format', () => {\n      const result = datetimeParseFunc.eval([new TypedValue(date, CellValueType.DateTime, false)], {\n        record: {} as IRecord,\n        dependencies: {},\n        timeZone: 'America/Los_Angeles',\n      });\n\n      expect(result).toBe(date);\n    });\n\n    it('The function parses a date-time ISO string into a new date-time format, returning a new date-time ISO string', () => {\n      const result = datetimeParseFunc.eval(\n        [\n          new TypedValue('8 Sep 2023 18:00', CellValueType.String, false),\n          new TypedValue('D MMM YYYY HH:mm', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'America/Los_Angeles',\n        }\n      );\n\n      expect(result).toBe(\n        dayjs.tz('8 Sep 2023 18:00', 'D MMM YYYY HH:mm', 'America/Los_Angeles').toISOString()\n      );\n    });\n\n    it('reparses datetime inputs through the provided format before returning an ISO string', () => {\n      const result = datetimeParseFunc.eval(\n        [\n          new TypedValue('2025-01-05T00:00:00.000Z', CellValueType.DateTime, false),\n          new TypedValue('MMYYYY', CellValueType.String, false),\n        ],\n        {\n          record: {} as IRecord,\n          dependencies: {},\n          timeZone: 'UTC',\n        }\n      );\n\n      expect(result).toBe('2025-01-01T00:00:00.000Z');\n    });\n  });\n\n  describe('CreatedTime', () => {\n    const createdTimeFunc = new CreatedTime();\n    const date = new Date().toISOString();\n    const record: IRecord = {\n      id: 'recTest',\n      fields: {},\n      createdTime: date,\n    };\n    const context = {\n      record,\n      dependencies: {},\n      timeZone: 'America/Los_Angeles',\n    };\n\n    it('Should return created time', () => {\n      const result = createdTimeFunc.eval([], context);\n\n      expect(result).toBe(date);\n    });\n  });\n\n  describe('LastModifiedTime', () => {\n    const lastModifiedTimeFunc = new LastModifiedTime();\n    const date = new Date().toISOString();\n    const record: IRecord = {\n      id: 'recTest',\n      fields: {},\n      createdTime: date,\n      lastModifiedTime: date,\n    };\n    const context = {\n      record,\n      dependencies: {},\n      timeZone: 'America/Los_Angeles',\n    };\n\n    it('Should return last modified time', () => {\n      const result = lastModifiedTimeFunc.eval([], context);\n\n      expect(result).toBe(date);\n    });\n\n    it('should allow a field reference parameter', () => {\n      const mockField = { id: 'fldTracked' } as unknown as FieldCore;\n      const fieldParam = new TypedValue('ignored', CellValueType.String, false, mockField);\n      const result = lastModifiedTimeFunc.eval([fieldParam], context);\n\n      expect(result).toBe(date);\n    });\n\n    it('should allow multiple field reference parameters', () => {\n      const fieldA = { id: 'fldA' } as unknown as FieldCore;\n      const fieldB = { id: 'fldB' } as unknown as FieldCore;\n      const fieldParams = [\n        new TypedValue('ignored', CellValueType.String, false, fieldA),\n        new TypedValue('ignored', CellValueType.Number, false, fieldB),\n      ];\n\n      const result = lastModifiedTimeFunc.eval(fieldParams, context);\n\n      expect(result).toBe(date);\n    });\n\n    it('should throw when the parameter is not a field reference', () => {\n      const literalParam = new TypedValue('2023-09-08', CellValueType.String, false);\n\n      expect(() => lastModifiedTimeFunc.eval([literalParam], context)).toThrow(\n        'LAST_MODIFIED_TIME parameter must be a field reference'\n      );\n    });\n\n    it('should throw when any parameter is not a field reference', () => {\n      const mockField = { id: 'fldTracked' } as unknown as FieldCore;\n      const fieldParam = new TypedValue('ignored', CellValueType.String, false, mockField);\n      const literalParam = new TypedValue('bad', CellValueType.String, false);\n\n      expect(() => lastModifiedTimeFunc.eval([fieldParam, literalParam], context)).toThrow(\n        'LAST_MODIFIED_TIME parameter must be a field reference'\n      );\n    });\n  });\n\n  describe('DateAdd permutations', () => {\n    const context = {\n      record: {} as IRecord,\n      dependencies: {},\n      timeZone: 'UTC',\n    };\n    const baseDateIso = '2025-01-01T00:00:00.000Z';\n    const baseNumberValue = 3;\n    const dateAddFunc = new DateAdd();\n    const baseDate = new TypedValue(baseDateIso, CellValueType.DateTime, false);\n    const unitDay = new TypedValue('day', CellValueType.String, false);\n\n    const literalCount = new TypedValue(1, CellValueType.Number, false);\n    const fieldCount = new TypedValue(baseNumberValue, CellValueType.Number, false);\n    const formulaCount = new TypedValue(baseNumberValue * 2, CellValueType.Number, false);\n\n    [\n      { label: 'literal numeric count', count: literalCount, expectedOffset: 1 },\n      { label: 'number field count', count: fieldCount, expectedOffset: baseNumberValue },\n      {\n        label: 'numeric formula field count',\n        count: formulaCount,\n        expectedOffset: baseNumberValue * 2,\n      },\n    ].forEach(({ label, count, expectedOffset }) => {\n      it(`should add days when count argument comes from ${label}`, () => {\n        const result = dateAddFunc.eval([baseDate, count, unitDay], context);\n\n        expect(typeof result).toBe('string');\n        const expectedIso = dayjs(baseDateIso).add(expectedOffset, 'day').toISOString();\n        expect(result).toBe(expectedIso);\n      });\n    });\n  });\n\n  describe('DatetimeParse permutations', () => {\n    const context = {\n      record: {} as IRecord,\n      dependencies: {},\n      timeZone: 'UTC',\n    };\n    const datetimeParseFunc = new DatetimeParse();\n\n    it('should parse ISO strings from text literals', () => {\n      const textIso = '2025-05-04T12:34:56Z';\n      const result = datetimeParseFunc.eval(\n        [\n          new TypedValue(textIso, CellValueType.String, false),\n          new TypedValue('YYYY-MM-DDTHH:mm:ss[Z]', CellValueType.String, false),\n        ],\n        context\n      );\n\n      expect(result).toBe(dayjs(textIso).toISOString());\n    });\n\n    it('should parse ISO strings from formula text output', () => {\n      const formulaIso = '2024-12-31T00:00:00Z';\n      const result = datetimeParseFunc.eval(\n        [\n          new TypedValue(formulaIso, CellValueType.String, false),\n          new TypedValue('YYYY-MM-DD[T]HH:mm:ss[Z]', CellValueType.String, false),\n        ],\n        context\n      );\n\n      expect(result).toBe(dayjs(formulaIso).toISOString());\n    });\n  });\n\n  describe('FromNow / ToNow permutations', () => {\n    const context = {\n      record: {} as IRecord,\n      dependencies: {},\n      timeZone: 'UTC',\n    };\n    const fromNowFunc = new FromNow();\n    const toNowFunc = new ToNow();\n\n    it('should evaluate FROMNOW using literal offsets', () => {\n      const targetIso = dayjs().subtract(5, 'day').toISOString();\n      const result = fromNowFunc.eval(\n        [\n          new TypedValue(targetIso, CellValueType.DateTime, false),\n          new TypedValue('day', CellValueType.String, false),\n          new TypedValue(true, CellValueType.Boolean, false),\n        ],\n        context\n      );\n\n      const expectedDiff = Math.abs(dayjs().diff(dayjs(targetIso), 'day', true));\n      expect(typeof result).toBe('number');\n      expect(Math.abs((result as number) - expectedDiff)).toBeLessThan(0.05);\n    });\n\n    it('should evaluate TONOW using literal offsets', () => {\n      const targetIso = dayjs().add(2, 'day').toISOString();\n      const result = toNowFunc.eval(\n        [\n          new TypedValue(targetIso, CellValueType.DateTime, false),\n          new TypedValue('day', CellValueType.String, false),\n          new TypedValue(true, CellValueType.Boolean, false),\n        ],\n        context\n      );\n\n      const expectedDiff = Math.abs(dayjs(targetIso).diff(dayjs(), 'day', true));\n      expect(typeof result).toBe('number');\n      expect(Math.abs((result as number) - expectedDiff)).toBeLessThan(0.05);\n    });\n  });\n\n  describe('WorkdayDiff permutations', () => {\n    const context = {\n      record: {} as IRecord,\n      dependencies: {},\n      timeZone: 'UTC',\n    };\n    const workdayDiffFunc = new WorkdayDiff();\n\n    it('should calculate workday difference with literal holidays', () => {\n      const start = new TypedValue('2025-01-01T00:00:00.000Z', CellValueType.DateTime, false);\n      const end = new TypedValue('2025-01-10T00:00:00.000Z', CellValueType.DateTime, false);\n      const holidays = new TypedValue('2025-01-06', CellValueType.String, false);\n\n      const result = workdayDiffFunc.eval([start, end, holidays], context);\n\n      expect(result).toBe(7);\n    });\n  });\n\n  describe('CreatedTime / LastModifiedTime permutations', () => {\n    const created = '2025-02-01T00:00:00.000Z';\n    const modified = '2025-02-02T12:00:00.000Z';\n    const record: IRecord = {\n      id: 'recMatrix',\n      fields: {},\n      createdTime: created,\n      lastModifiedTime: modified,\n    };\n    const context = {\n      record,\n      dependencies: {},\n      timeZone: 'UTC',\n    };\n    const createdTimeFunc = new CreatedTime();\n    const lastModifiedTimeFunc = new LastModifiedTime();\n    const datetimeDiffFunc = new DatetimeDiff();\n\n    it('should evaluate chained formulas using created and last modified timestamps', () => {\n      const createdTime = createdTimeFunc.eval([], context);\n      const lastModifiedTime = lastModifiedTimeFunc.eval([], context);\n\n      expect(createdTime).toBe(created);\n      expect(lastModifiedTime).toBe(modified);\n\n      const diff = datetimeDiffFunc.eval(\n        [\n          new TypedValue(lastModifiedTime, CellValueType.DateTime, false),\n          new TypedValue(createdTime, CellValueType.DateTime, false),\n          new TypedValue('hour', CellValueType.String, false),\n        ],\n        context\n      );\n\n      expect(typeof diff).toBe('number');\n      expect(diff as number).toBeCloseTo(36, 6);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/date-time.ts",
    "content": "import type { ManipulateType, UnitType } from 'dayjs';\nimport dayjs, { isDayjs } from 'dayjs';\nimport customParseFormat from 'dayjs/plugin/customParseFormat';\nimport isBetween from 'dayjs/plugin/isBetween';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport weekOfYear from 'dayjs/plugin/weekOfYear';\nimport { isNumber, isString } from 'lodash';\nimport { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport type { IFormulaContext } from './common';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\nimport { FormulaBaseError } from './logical';\n\ndayjs.extend(relativeTime);\ndayjs.extend(weekOfYear);\ndayjs.extend(isBetween);\ndayjs.extend(customParseFormat);\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nexport { dayjs };\n\nabstract class DateTimeFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.DateTime;\n}\n\nconst unitSet = new Set<ManipulateType>([\n  'millisecond',\n  'second',\n  'minute',\n  'hour',\n  'day',\n  'week',\n  'month',\n  'year',\n  'ms',\n  's',\n  'm',\n  'h',\n  'd',\n  'w',\n  'M',\n  'y',\n]);\n\nconst getUnit = (unit?: string) => {\n  const unitStr = unit as ManipulateType;\n  if (unitSet.has(unitStr)) return unitStr;\n  return 'second';\n};\n\nfunction isISODateString(dateString: string) {\n  const isoDatePattern = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:\\d{2})$/;\n  return isoDatePattern.test(dateString);\n}\n\nconst normalizeDateTimeParseInput = (isoStr: string) =>\n  isoStr.trim().replace(/\\//g, '-').replace('T', ' ');\n\nconst inferDateTimeParseFormat = (isoStr: string) => {\n  if (!/^\\d{4}-\\d{1,2}-\\d{1,2}(?: \\d{1,2}:\\d{1,2}(?::\\d{1,2}(?:\\.\\d{1,3})?)?)?$/.test(isoStr)) {\n    return null;\n  }\n\n  const timePart = isoStr.split(' ')[1];\n  if (!timePart) return 'YYYY-M-D';\n\n  const timeSegments = timePart.split(':');\n  if (timeSegments.length < 2 || timeSegments.length > 3) return null;\n  if (!/^\\d{1,2}$/.test(timeSegments[0]) || !/^\\d{1,2}$/.test(timeSegments[1])) return null;\n  if (timeSegments.length === 2) return 'YYYY-M-D H:m';\n\n  const [second, fractional] = timeSegments[2].split('.');\n  if (!/^\\d{1,2}$/.test(second)) return null;\n\n  if (!fractional) return 'YYYY-M-D H:m:s';\n  const msToken = 'S'.repeat(Math.max(1, Math.min(3, fractional.length)));\n  return `YYYY-M-D H:m:s.${msToken}`;\n};\n\nexport const getDayjs = (isoStr: string | null, timeZone: string, customFormat?: string) => {\n  if (isoStr == null) return null;\n  if (isDayjs(isoStr)) return isoStr;\n  if (!isString(isoStr)) throw new FormulaBaseError();\n\n  let date;\n  if (customFormat) {\n    // For custom format, assume it's in the specified timezone\n    date = dayjs.tz(isoStr, customFormat, timeZone);\n  } else if (isISODateString(isoStr)) {\n    // If it's a valid ISO string, convert to the specified timezone\n    date = dayjs(isoStr).tz(timeZone);\n  } else {\n    // For other formats (including local date-time text), interpret as local time in target timezone.\n    const normalizedInput = normalizeDateTimeParseInput(isoStr);\n    const format = inferDateTimeParseFormat(normalizedInput);\n    date = format\n      ? dayjs.tz(normalizedInput, format, timeZone)\n      : dayjs.tz(normalizedInput, timeZone);\n  }\n\n  if (!date.isValid()) throw new FormulaBaseError();\n  return date;\n};\n\nexport class Today extends DateTimeFunc {\n  name = FunctionName.Today;\n\n  acceptValueType = new Set([]);\n\n  acceptMultipleValue = false;\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function\n  validateParams(_params: TypedValue[]) {}\n\n  getReturnType() {\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(_params: TypedValue[], context: IFormulaContext): string | null {\n    return dayjs().tz(context.timeZone).startOf('d').toISOString();\n  }\n}\n\nexport class Now extends DateTimeFunc {\n  name = FunctionName.Now;\n\n  acceptValueType = new Set([]);\n\n  acceptMultipleValue = false;\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function\n  validateParams(_params: TypedValue[]) {}\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(_params: TypedValue[], context: IFormulaContext): string | null {\n    return dayjs().tz(context.timeZone).toISOString();\n  }\n}\n\nexport class Year extends DateTimeFunc {\n  name = FunctionName.Year;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Year} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.year() ?? null;\n  }\n}\n\nexport class Month extends DateTimeFunc {\n  name = FunctionName.Month;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Month} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    const month = getDayjs(value, context.timeZone)?.month() ?? null;\n    return isNumber(month) ? month + 1 : null;\n  }\n}\n\nexport class WeekNum extends DateTimeFunc {\n  name = FunctionName.WeekNum;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.WeekNum} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.week() ?? null;\n  }\n}\n\nexport class Weekday extends DateTimeFunc {\n  name = FunctionName.Weekday;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Weekday} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    const startDayOfWeek = params[1]?.value ?? 'sunday';\n    const currentDate = getDayjs(value, context.timeZone);\n    if (currentDate == null) return null;\n    const weekday = currentDate.day();\n    if (startDayOfWeek.toLowerCase() === 'monday') {\n      return weekday === 0 ? 6 : weekday - 1;\n    }\n    return weekday;\n  }\n}\n\nexport class Day extends DateTimeFunc {\n  name = FunctionName.Day;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Day} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.date() ?? null;\n  }\n}\n\nexport class Hour extends DateTimeFunc {\n  name = FunctionName.Hour;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Hour} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.hour() ?? null;\n  }\n}\n\nexport class Minute extends DateTimeFunc {\n  name = FunctionName.Minute;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Minute} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.minute() ?? null;\n  }\n}\n\nexport class Second extends DateTimeFunc {\n  name = FunctionName.Second;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Second} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    const value = params[0].value;\n    return getDayjs(value, context.timeZone)?.second() ?? null;\n  }\n}\n\nexport class FromNow extends DateTimeFunc {\n  name = FunctionName.FromNow;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String, CellValueType.Boolean]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.FromNow} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | boolean | null>[], context: IFormulaContext): number | null {\n    const targetDate = getDayjs(params[0].value as string, context.timeZone);\n    const unit = (params[1]?.value ?? 'd') as UnitType;\n    const isFloat = Boolean(params[2]?.value ?? false);\n    const diffCount = dayjs().diff(targetDate, unit, isFloat);\n    return isNumber(diffCount) ? Math.abs(diffCount) : null;\n  }\n}\n\nexport class ToNow extends FromNow {\n  name = FunctionName.ToNow;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.ToNow} needs at least 2 params`);\n    }\n  }\n}\n\nexport class DatetimeDiff extends DateTimeFunc {\n  name = FunctionName.DatetimeDiff;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String, CellValueType.Boolean]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.DatetimeDiff} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | boolean | null>[], context: IFormulaContext): number | null {\n    const startDate = getDayjs(params[0].value as string, context.timeZone);\n    const endDate = getDayjs(params[1].value as string, context.timeZone);\n    const unit = (params[2]?.value ?? 'day') as UnitType;\n    const isFloat = Boolean(params[3]?.value ?? false);\n    if (startDate == null || endDate == null) return null;\n    const diffCount = startDate.diff(endDate, unit, isFloat);\n    return isNumber(diffCount) ? diffCount : null;\n  }\n}\n\nexport class Workday extends DateTimeFunc {\n  name = FunctionName.Workday;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.Workday} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(params: TypedValue<string | number | null>[], context: IFormulaContext): string | null {\n    const startDate = getDayjs(params[0].value as string, context.timeZone);\n\n    if (startDate == null) return null;\n\n    const count = Number(params[1].value ?? 0);\n    const holidayStr = params[2]?.value;\n    const holidays = (\n      isString(holidayStr)\n        ? holidayStr\n            .split(',')\n            .map((str) => getDayjs(str.trim(), context.timeZone))\n            .filter(Boolean)\n        : []\n    ) as dayjs.Dayjs[];\n    const unit = 'day';\n    const efficientSign = count > 0 ? 1 : -1;\n    const weeks = Math.floor(count / 5);\n    const extraDays = count % 5;\n    let targetDate = startDate.add(weeks * 7, unit);\n\n    for (let i = 0; i < extraDays; ) {\n      targetDate = targetDate.add(efficientSign, unit);\n      const holidayIndex = holidays.findIndex((holiday) => holiday.isSame(targetDate, unit));\n      if (holidayIndex > -1) holidays.splice(holidayIndex);\n      if (targetDate.day() !== 0 && targetDate.day() !== 6 && holidayIndex === -1) {\n        i++;\n      }\n    }\n\n    let daysToAdjust = holidays.filter((date) => {\n      return date.isBetween(startDate, targetDate, 'day', '[]') && ![0, 6].includes(date.day());\n    }).length;\n\n    while (daysToAdjust > 0) {\n      targetDate = targetDate.add(efficientSign, unit);\n      if (\n        targetDate.day() !== 0 &&\n        targetDate.day() !== 6 &&\n        !holidays.some((holiday) => holiday.isSame(targetDate, unit))\n      ) {\n        daysToAdjust--;\n      }\n    }\n\n    return targetDate.toISOString();\n  }\n}\n\nexport class WorkdayDiff extends DateTimeFunc {\n  name = FunctionName.WorkdayDiff;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.WorkdayDiff} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | number | null>[], context: IFormulaContext): number | null {\n    const startDate = getDayjs(params[0].value as string, context.timeZone);\n    const endDate = getDayjs(params[1].value as string, context.timeZone);\n\n    if (startDate == null || endDate == null) return null;\n\n    const holidayStr = params[2]?.value;\n    const holidays = (\n      isString(holidayStr)\n        ? holidayStr\n            .split(',')\n            .map((str) => getDayjs(str.trim(), context.timeZone))\n            .filter(Boolean)\n        : []\n    ) as dayjs.Dayjs[];\n\n    const unit = 'day';\n    const totalDays = endDate.diff(startDate, unit) + 1;\n    const weeks = Math.floor(totalDays / 7);\n    let weekendDays = weeks * 2;\n    let remaining = totalDays - weeks * 7;\n    let currentDay = startDate.add(weeks * 7, unit);\n\n    while (remaining > 0) {\n      if (currentDay.day() === 0 || currentDay.day() === 6) {\n        weekendDays++;\n      }\n      currentDay = currentDay.add(1, unit);\n      remaining--;\n    }\n\n    const holidayDays = holidays.filter((date) => {\n      return date.isBetween(startDate, endDate, unit, '[]') && ![0, 6].includes(date.day());\n    }).length;\n\n    return totalDays - weekendDays - holidayDays;\n  }\n}\n\nexport class IsSame extends DateTimeFunc {\n  name = FunctionName.IsSame;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.IsSame} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): boolean | null {\n    const date1 = getDayjs(params[0].value as string, context.timeZone);\n    const date2 = getDayjs(params[1].value as string, context.timeZone);\n\n    if (date1 == null || date2 == null) return null;\n\n    const unit = (params[2]?.value ?? 'd') as UnitType;\n    return date1.isSame(date2, unit);\n  }\n}\n\nexport class IsAfter extends DateTimeFunc {\n  name = FunctionName.IsAfter;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.IsAfter} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): boolean | null {\n    const date1 = getDayjs(params[0].value as string, context.timeZone);\n    const date2 = getDayjs(params[1].value as string, context.timeZone);\n\n    if (date1 == null || date2 == null) return null;\n\n    const unit = (params[2]?.value ?? 'd') as UnitType;\n    return date1.isAfter(date2, unit);\n  }\n}\n\nexport class IsBefore extends DateTimeFunc {\n  name = FunctionName.IsBefore;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.IsBefore} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): boolean | null {\n    const date1 = getDayjs(params[0].value as string, context.timeZone);\n    const date2 = getDayjs(params[1].value as string, context.timeZone);\n\n    if (date1 == null || date2 == null) return null;\n\n    const unit = (params[2]?.value ?? 'd') as UnitType;\n    return date1.isBefore(date2, unit);\n  }\n}\n\nexport class DateAdd extends DateTimeFunc {\n  name = FunctionName.DateAdd;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 3) {\n      throw new Error(`${FunctionName.DateAdd} needs at least 3 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(params: TypedValue<string | number | null>[], context: IFormulaContext): string | null {\n    const date = getDayjs(params[0].value as string, context.timeZone);\n\n    if (date == null) return null;\n\n    const count = Number(params[1].value ?? 0);\n    const unit = getUnit(params[2].value as string);\n    return date.add(Number(count), unit).toISOString();\n  }\n}\n\nexport class Datestr extends DateTimeFunc {\n  name = FunctionName.Datestr;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Datestr} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    const date = getDayjs(params[0].value as string, context.timeZone);\n\n    if (date == null) return null;\n\n    return date.format('YYYY-MM-DD');\n  }\n}\n\nexport class Timestr extends DateTimeFunc {\n  name = FunctionName.Timestr;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Timestr} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    const date = getDayjs(params[0].value as string, context.timeZone);\n\n    if (date == null) return null;\n\n    return date.format('HH:mm:ss');\n  }\n}\n\nexport class DatetimeFormat extends DateTimeFunc {\n  name = FunctionName.DatetimeFormat;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.DatetimeFormat} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    const date = getDayjs(params[0].value as string, context.timeZone);\n\n    if (date == null) return null;\n\n    const formatString = String(params[1]?.value || 'YYYY-MM-DD HH:mm');\n    return date.format(formatString);\n  }\n}\n\nexport class DatetimeParse extends DateTimeFunc {\n  name = FunctionName.DatetimeParse;\n\n  acceptValueType = new Set([CellValueType.DateTime, CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.DatetimeParse} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    const format = params[1]?.value as string | undefined;\n\n    if (params[0].type === CellValueType.DateTime && format) {\n      const sourceDate = getDayjs(params[0].value, context.timeZone);\n      if (sourceDate == null) {\n        return null;\n      }\n\n      const reparsedDate = getDayjs(sourceDate.format(format), context.timeZone, format);\n      return reparsedDate?.toISOString() ?? null;\n    }\n\n    const date = getDayjs(params[0].value, context.timeZone, format);\n\n    if (date == null) return null;\n    return date.toISOString();\n  }\n}\n\nexport class CreatedTime extends DateTimeFunc {\n  name = FunctionName.CreatedTime;\n\n  acceptValueType = new Set([CellValueType.DateTime]);\n\n  acceptMultipleValue = false;\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  validateParams() {}\n\n  getReturnType() {\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    return context.record.createdTime ?? null;\n  }\n}\n\nexport class LastModifiedTime extends DateTimeFunc {\n  name = FunctionName.LastModifiedTime;\n\n  acceptValueType = new Set([\n    CellValueType.String,\n    CellValueType.Number,\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n  ]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]): void {\n    if (!params.length) return;\n    if (params.some((param) => !param?.field)) {\n      throw new Error(`${FunctionName.LastModifiedTime} parameter must be a field reference`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.DateTime };\n  }\n\n  eval(params: TypedValue<string | null>[], context: IFormulaContext): string | null {\n    this.validateParams(params);\n    return context.record.lastModifiedTime ?? null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/factory.ts",
    "content": "import {\n  ArrayCompact,\n  ArrayFlatten,\n  ArrayJoin,\n  ArrayUnique,\n  Count,\n  CountA,\n  CountAll,\n} from './array';\nimport type { FormulaFunc } from './common';\nimport { FunctionName } from './common';\nimport {\n  CreatedTime,\n  DateAdd,\n  Datestr,\n  DatetimeDiff,\n  DatetimeFormat,\n  DatetimeParse,\n  Day,\n  FromNow,\n  Hour,\n  IsAfter,\n  IsBefore,\n  IsSame,\n  LastModifiedTime,\n  Minute,\n  Month,\n  Now,\n  Second,\n  Timestr,\n  ToNow,\n  Today,\n  WeekNum,\n  Weekday,\n  Workday,\n  WorkdayDiff,\n  Year,\n} from './date-time';\nimport { And, Blank, FormulaError, If, IsError, Not, Or, Switch, Xor } from './logical';\nimport {\n  Abs,\n  Average,\n  Ceiling,\n  Even,\n  Exp,\n  Floor,\n  Int,\n  Log,\n  Max,\n  Min,\n  Mod,\n  Odd,\n  Power,\n  Round,\n  RoundDown,\n  RoundUp,\n  Sqrt,\n  Sum,\n  Value,\n} from './numeric';\nimport { AutoNumber, RecordId, TextAll } from './system';\nimport {\n  Concatenate,\n  EncodeUrlComponent,\n  Find,\n  Left,\n  Len,\n  Lower,\n  Mid,\n  RegExpReplace,\n  Replace,\n  Rept,\n  Right,\n  Search,\n  Substitute,\n  T,\n  Trim,\n  Upper,\n} from './text';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const FUNCTIONS: Record<FunctionName, FormulaFunc> = {\n  // Numeric\n  [FunctionName.Sum]: new Sum(),\n  [FunctionName.Average]: new Average(),\n  [FunctionName.Max]: new Max(),\n  [FunctionName.Min]: new Min(),\n  [FunctionName.Round]: new Round(),\n  [FunctionName.RoundUp]: new RoundUp(),\n  [FunctionName.RoundDown]: new RoundDown(),\n  [FunctionName.Ceiling]: new Ceiling(),\n  [FunctionName.Floor]: new Floor(),\n  [FunctionName.Even]: new Even(),\n  [FunctionName.Odd]: new Odd(),\n  [FunctionName.Int]: new Int(),\n  [FunctionName.Abs]: new Abs(),\n  [FunctionName.Sqrt]: new Sqrt(),\n  [FunctionName.Power]: new Power(),\n  [FunctionName.Exp]: new Exp(),\n  [FunctionName.Log]: new Log(),\n  [FunctionName.Mod]: new Mod(),\n  [FunctionName.Value]: new Value(),\n\n  // Text\n  [FunctionName.Concatenate]: new Concatenate(),\n  [FunctionName.Find]: new Find(),\n  [FunctionName.Search]: new Search(),\n  [FunctionName.Mid]: new Mid(),\n  [FunctionName.Left]: new Left(),\n  [FunctionName.Right]: new Right(),\n  [FunctionName.Replace]: new Replace(),\n  [FunctionName.RegExpReplace]: new RegExpReplace(),\n  [FunctionName.Substitute]: new Substitute(),\n  [FunctionName.Lower]: new Lower(),\n  [FunctionName.Upper]: new Upper(),\n  [FunctionName.Rept]: new Rept(),\n  [FunctionName.Trim]: new Trim(),\n  [FunctionName.Len]: new Len(),\n  [FunctionName.T]: new T(),\n  [FunctionName.EncodeUrlComponent]: new EncodeUrlComponent(),\n\n  // Logical\n  [FunctionName.If]: new If(),\n  [FunctionName.Switch]: new Switch(),\n  [FunctionName.And]: new And(),\n  [FunctionName.Or]: new Or(),\n  [FunctionName.Xor]: new Xor(),\n  [FunctionName.Not]: new Not(),\n  [FunctionName.Blank]: new Blank(),\n  [FunctionName.Error]: new FormulaError(),\n  [FunctionName.IsError]: new IsError(),\n\n  // DateTime\n  [FunctionName.Today]: new Today(),\n  [FunctionName.Now]: new Now(),\n  [FunctionName.Year]: new Year(),\n  [FunctionName.Month]: new Month(),\n  [FunctionName.WeekNum]: new WeekNum(),\n  [FunctionName.Weekday]: new Weekday(),\n  [FunctionName.Day]: new Day(),\n  [FunctionName.Hour]: new Hour(),\n  [FunctionName.Minute]: new Minute(),\n  [FunctionName.Second]: new Second(),\n  [FunctionName.FromNow]: new FromNow(),\n  [FunctionName.ToNow]: new ToNow(),\n  [FunctionName.DatetimeDiff]: new DatetimeDiff(),\n  [FunctionName.Workday]: new Workday(),\n  [FunctionName.WorkdayDiff]: new WorkdayDiff(),\n  [FunctionName.IsSame]: new IsSame(),\n  [FunctionName.IsAfter]: new IsAfter(),\n  [FunctionName.IsBefore]: new IsBefore(),\n  [FunctionName.DateAdd]: new DateAdd(),\n  [FunctionName.Datestr]: new Datestr(),\n  [FunctionName.Timestr]: new Timestr(),\n  [FunctionName.DatetimeFormat]: new DatetimeFormat(),\n  [FunctionName.DatetimeParse]: new DatetimeParse(),\n  [FunctionName.CreatedTime]: new CreatedTime(),\n  [FunctionName.LastModifiedTime]: new LastModifiedTime(),\n\n  // Array\n  [FunctionName.CountAll]: new CountAll(),\n  [FunctionName.CountA]: new CountA(),\n  [FunctionName.Count]: new Count(),\n  [FunctionName.ArrayJoin]: new ArrayJoin(),\n  [FunctionName.ArrayUnique]: new ArrayUnique(),\n  [FunctionName.ArrayFlatten]: new ArrayFlatten(),\n  [FunctionName.ArrayCompact]: new ArrayCompact(),\n\n  // System\n  [FunctionName.TextAll]: new TextAll(),\n  [FunctionName.RecordId]: new RecordId(),\n  [FunctionName.AutoNumber]: new AutoNumber(),\n};\n"
  },
  {
    "path": "packages/core/src/formula/functions/logical.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { CellValueType } from '../../models/field/constant';\nimport { TypedValue } from '../typed-value';\nimport {\n  And,\n  Blank,\n  FormulaBaseError,\n  FormulaError,\n  If,\n  IsError,\n  Not,\n  Or,\n  Switch,\n  Xor,\n} from './logical';\n\ndescribe('LogicalFunc', () => {\n  describe('If', () => {\n    const ifFunc = new If();\n\n    it('should return the first string when condition is true', () => {\n      const result = ifFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue('A', CellValueType.String, false),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('A');\n    });\n\n    it('should return the second string when condition is false', () => {\n      const result = ifFunc.eval([\n        new TypedValue(false, CellValueType.Boolean, false),\n        new TypedValue('A', CellValueType.String, false),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('B');\n    });\n\n    it('should return the entire string array when condition is true', () => {\n      const multipleStrings = ['A', 'B', 'C'];\n      const result = ifFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(multipleStrings, CellValueType.String, true),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe(multipleStrings);\n    });\n\n    it('should return the entire number array when condition is true', () => {\n      const multipleNumbers = [100, 200, 300];\n      const result = ifFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(multipleNumbers, CellValueType.String, true),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe(multipleNumbers);\n    });\n\n    it('should return the entire boolean array when condition is true', () => {\n      const multipleBooleans = [true, false, true];\n      const result = ifFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(multipleBooleans, CellValueType.String, true),\n        new TypedValue('B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe(multipleBooleans);\n    });\n  });\n\n  describe('Switch', () => {\n    const switchFunc = new Switch();\n\n    it('should return the value corresponding to the matching case', () => {\n      const result = switchFunc.eval([\n        new TypedValue('Case A', CellValueType.String, false),\n\n        new TypedValue('Case A', CellValueType.String, false),\n        new TypedValue('Value A', CellValueType.String, false),\n\n        new TypedValue('Case B', CellValueType.String, false),\n        new TypedValue('Value B', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Value A');\n    });\n\n    it('should return the default value when no cases match', () => {\n      const result = switchFunc.eval([\n        new TypedValue('Case C', CellValueType.String, false),\n\n        new TypedValue('Case A', CellValueType.String, false),\n        new TypedValue('Value A', CellValueType.String, false),\n\n        new TypedValue('Case B', CellValueType.String, false),\n        new TypedValue('Value B', CellValueType.String, false),\n\n        new TypedValue('Default Value', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Default Value');\n    });\n\n    it('should return the default value when only provide the default value', () => {\n      const result = switchFunc.eval([\n        new TypedValue('Case A', CellValueType.String, false),\n        new TypedValue('Default Value', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Default Value');\n    });\n\n    it('should return the array value corresponding to a string case', () => {\n      const result = switchFunc.eval([\n        new TypedValue('String', CellValueType.String, false),\n\n        new TypedValue('String', CellValueType.String, false),\n        new TypedValue(['A', 'B', 'C'], CellValueType.String, true),\n\n        new TypedValue(123, CellValueType.Number, false),\n        new TypedValue([100, 200, 300], CellValueType.Number, true),\n\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue([true, false, true], CellValueType.Boolean, true),\n\n        new TypedValue('Default Value', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual(['A', 'B', 'C']);\n    });\n\n    it('should return the array value corresponding to a number case', () => {\n      const result = switchFunc.eval([\n        new TypedValue(123, CellValueType.Number, false),\n\n        new TypedValue('String', CellValueType.String, false),\n        new TypedValue(['A', 'B', 'C'], CellValueType.String, true),\n\n        new TypedValue(123, CellValueType.Number, false),\n        new TypedValue([100, 200, 300], CellValueType.Number, true),\n\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue([true, false, true], CellValueType.Boolean, true),\n\n        new TypedValue('Default Value', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual([100, 200, 300]);\n    });\n\n    it('should return the array value corresponding to a boolean case', () => {\n      const result = switchFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n\n        new TypedValue('String', CellValueType.String, false),\n        new TypedValue(['A', 'B', 'C'], CellValueType.String, true),\n\n        new TypedValue(123, CellValueType.Number, false),\n        new TypedValue([100, 200, 300], CellValueType.Number, true),\n\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue([true, false, true], CellValueType.Boolean, true),\n\n        new TypedValue('Default Value', CellValueType.String, false),\n      ]);\n\n      expect(result).toEqual([true, false, true]);\n    });\n  });\n\n  describe('And', () => {\n    const andFunc = new And();\n\n    it('should do logical AND correctly', () => {\n      const result = andFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(true, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should do logical AND correctly for arrays', () => {\n      const result = andFunc.eval([new TypedValue([true, true], CellValueType.Boolean, true)]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false if any item is false', () => {\n      const result = andFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('Or', () => {\n    const orFunc = new Or();\n\n    it('should return true if at least one argument is true', () => {\n      const result = orFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false if all arguments are false', () => {\n      const result = orFunc.eval([\n        new TypedValue(false, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true if an array argument contains at least one true value', () => {\n      const result = orFunc.eval([new TypedValue([true, false], CellValueType.Boolean, true)]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false if the all array arguments are false', () => {\n      const result = orFunc.eval([new TypedValue([false, false], CellValueType.Boolean, true)]);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('Xor', () => {\n    const xorFunc = new Xor();\n\n    it('should return true when an odd number of the multiple parameters are true', () => {\n      const result = xorFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when an even number of the multiple parameters are true', () => {\n      const result = xorFunc.eval([\n        new TypedValue(true, CellValueType.Boolean, false),\n        new TypedValue(false, CellValueType.Boolean, false),\n        new TypedValue(true, CellValueType.Boolean, false),\n      ]);\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true when an odd number of the array arguments are true', () => {\n      const result = xorFunc.eval([\n        new TypedValue([true, false, false], CellValueType.Boolean, true),\n      ]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false when an even number of the array arguments are true', () => {\n      const result = xorFunc.eval([\n        new TypedValue([true, false, true], CellValueType.Boolean, true),\n      ]);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('Not', () => {\n    const notFunc = new Not();\n\n    it('should return false for a true argument', () => {\n      const result = notFunc.eval([new TypedValue(true, CellValueType.Boolean, false)]);\n\n      expect(result).toBe(false);\n    });\n\n    it('should return true for a false argument', () => {\n      const result = notFunc.eval([new TypedValue(false, CellValueType.Boolean, false)]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should return false for an array', () => {\n      const result = notFunc.eval([\n        new TypedValue([true, false, true], CellValueType.Boolean, true),\n      ]);\n\n      expect(result).toBe(false);\n    });\n  });\n\n  describe('Blank', () => {\n    const blankFunc = new Blank();\n\n    it('should return null', () => {\n      const result = blankFunc.eval();\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe('Error', () => {\n    const errorFunc = new FormulaError();\n\n    it('should throw formula error', () => {\n      expect(() =>\n        errorFunc.eval([new TypedValue('Name', CellValueType.String, false)])\n      ).toThrowError(FormulaBaseError);\n    });\n\n    it('should include user-provided text in the error message', () => {\n      expect(() =>\n        errorFunc.eval([new TypedValue('matrix-text', CellValueType.String, false)])\n      ).toThrowError('#ERROR: matrix-text');\n    });\n  });\n\n  describe('IsError permutations', () => {\n    const isErrorFunc = new IsError();\n    const wrap = (value: unknown): TypedValue<boolean | boolean[] | null[]> =>\n      new TypedValue(value as boolean, CellValueType.String, false) as unknown as TypedValue<\n        boolean | boolean[] | null[]\n      >;\n\n    it('should detect direct FormulaBaseError instances', () => {\n      const errorValue = new FormulaBaseError('matrix failure');\n      const result = isErrorFunc.eval([wrap(errorValue)]);\n\n      expect(result).toBe(true);\n    });\n\n    it('should detect FormulaError outputs from nested evaluations', () => {\n      const errorFunc = new FormulaError();\n      let captured: FormulaBaseError | undefined;\n      try {\n        errorFunc.eval([new TypedValue('nested failure', CellValueType.String, false)]);\n      } catch (error) {\n        captured = error as FormulaBaseError;\n      }\n\n      expect(captured).toBeInstanceOf(FormulaBaseError);\n\n      const result = isErrorFunc.eval([wrap(captured!)]);\n      expect(result).toBe(true);\n    });\n\n    it('should return false when value is not an error', () => {\n      const result = isErrorFunc.eval([wrap('safe value')]);\n      expect(result).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/logical.ts",
    "content": "import { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\nimport { convertValueToString } from './text';\n\nabstract class LogicalFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.Logical;\n}\n\nexport class If extends LogicalFunc {\n  name = FunctionName.If;\n\n  acceptValueType = new Set([\n    CellValueType.String,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.Boolean,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 3) {\n      throw new Error(`${FunctionName.If} needs at least 3 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    if (params == null) return { type: CellValueType.String };\n\n    this.validateParams(params);\n\n    if (params[1].isBlank) {\n      return {\n        type: params[2].type,\n        isMultiple: params[2].isMultiple,\n      };\n    }\n\n    if (params[2].isBlank) {\n      return {\n        type: params[1].type,\n        isMultiple: params[1].isMultiple,\n      };\n    }\n\n    if (params[1].type === params[2].type) {\n      return {\n        type: params[1].type,\n        isMultiple: params[1].isMultiple && params[2].isMultiple,\n      };\n    }\n\n    return { type: CellValueType.String };\n  }\n\n  eval(\n    params: TypedValue<string | number | boolean | (string | number | boolean | null)[]>[]\n  ): string | number | boolean | null | (string | number | boolean | null)[] {\n    const condition = params[0].value;\n    return condition ? params[1]?.value : params[2]?.value;\n  }\n}\n\nexport class Switch extends LogicalFunc {\n  name = FunctionName.Switch;\n\n  acceptValueType = new Set([\n    CellValueType.String,\n    CellValueType.DateTime,\n    CellValueType.Number,\n    CellValueType.Boolean,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.Switch} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    if (params == null) return { type: CellValueType.String };\n\n    this.validateParams(params);\n\n    const paramsLength = params.length;\n\n    if (paramsLength <= 2) return { type: params[1].type, isMultiple: params[1].isMultiple };\n\n    let expectedType = params[2].type;\n    let expectedIsMultiple = params[2].isMultiple;\n\n    const checkParam = (param: TypedValue) => {\n      const { type, isBlank, isMultiple } = param;\n      if (!isBlank) {\n        if (expectedType !== type) {\n          expectedType = CellValueType.String;\n        }\n        if (expectedIsMultiple !== isMultiple) {\n          expectedIsMultiple = false;\n        }\n      }\n    };\n\n    for (let i = 2; i < paramsLength; i += 2) {\n      checkParam(params[i]);\n    }\n\n    if (paramsLength % 2 === 0) {\n      checkParam(params[paramsLength - 1]);\n    }\n\n    return { type: expectedType, isMultiple: expectedIsMultiple };\n  }\n\n  eval(\n    params: TypedValue<string | number | boolean | (string | number | boolean | null)[]>[]\n  ): string | number | boolean | null | (string | number | boolean | null)[] {\n    const paramsLength = params.length;\n    const expression = params[0].value;\n\n    if (paramsLength % 2 === 0) {\n      const defaultValue = params[paramsLength - 1].value;\n\n      for (let i = 1; i < paramsLength - 1; i += 2) {\n        const currentCase = params[i].value;\n        const currentValue = params[i + 1].value;\n\n        if (expression === currentCase) {\n          return currentValue;\n        }\n      }\n      return defaultValue;\n    }\n\n    for (let i = 1; i < paramsLength; i += 2) {\n      const currentCase = params[i].value;\n      const currentValue = params[i + 1].value;\n\n      if (expression === currentCase) {\n        return currentValue;\n      }\n    }\n    return null;\n  }\n}\n\nexport class And extends LogicalFunc {\n  name = FunctionName.And;\n\n  acceptValueType = new Set([CellValueType.Boolean]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.And} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<boolean | boolean[] | null[]>[]): boolean {\n    return params.reduce((result, param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value) || param.value == null) {\n          return false;\n        }\n        return result && (param.value as unknown[]).every((v) => Boolean(v));\n      }\n      return result && Boolean(param.value);\n    }, true);\n  }\n}\n\nexport class Or extends LogicalFunc {\n  name = FunctionName.Or;\n\n  acceptValueType = new Set([CellValueType.Boolean]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Or} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<boolean | boolean[] | null[]>[]): boolean {\n    return params.reduce((result, param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value) || param.value == null) {\n          return result;\n        }\n        return result || (param.value as unknown[]).some((v) => Boolean(v));\n      }\n      return result || Boolean(param.value);\n    }, false);\n  }\n}\n\nexport class Xor extends LogicalFunc {\n  name = FunctionName.Xor;\n\n  acceptValueType = new Set([CellValueType.Boolean]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Xor} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<boolean | boolean[] | null[]>[]): boolean {\n    const count = params.reduce((result, param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value) || param.value == null) {\n          return result;\n        }\n        (param.value as unknown[]).forEach((v) => {\n          if (v) result++;\n        });\n        return result;\n      }\n      return param.value ? result + 1 : result;\n    }, 0);\n    return Boolean(count & 1);\n  }\n}\n\nexport class Not extends LogicalFunc {\n  name = FunctionName.Not;\n\n  acceptValueType = new Set([CellValueType.Boolean]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Not} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<boolean | boolean[] | null[]>[]): boolean {\n    return !params[0].value;\n  }\n}\n\nexport class Blank extends LogicalFunc {\n  name = FunctionName.Blank;\n\n  acceptValueType = new Set([]);\n\n  acceptMultipleValue = false;\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  validateParams() {}\n\n  getReturnType() {\n    return { type: CellValueType.String };\n  }\n\n  eval(): null {\n    return null;\n  }\n}\n\nexport class FormulaBaseError extends Error {\n  constructor(message?: string) {\n    super();\n    this.message = message ? '#ERROR: ' + message : '#ERROR!';\n  }\n}\n\nexport class FormulaError extends LogicalFunc {\n  name = FunctionName.Error;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  validateParams() {}\n\n  getReturnType() {\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | string[] | null[]>[]) {\n    const errText = convertValueToString(params[0]);\n    throw new FormulaBaseError(errText ?? '');\n  }\n}\n\nexport class IsError extends LogicalFunc {\n  name = FunctionName.IsError;\n\n  acceptValueType = new Set([\n    CellValueType.String,\n    CellValueType.Number,\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.IsError} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Boolean };\n  }\n\n  eval(params: TypedValue<boolean | boolean[] | null[]>[]): boolean {\n    const value = params[0].value;\n    return value instanceof FormulaBaseError;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/numeric.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { CellValueType } from '../../models/field/constant';\nimport { TypedValue } from '../typed-value';\nimport {\n  Abs,\n  Average,\n  Ceiling,\n  Even,\n  Exp,\n  Floor,\n  Int,\n  Log,\n  Max,\n  Min,\n  Mod,\n  Odd,\n  Power,\n  Round,\n  RoundDown,\n  RoundUp,\n  Sqrt,\n  Sum,\n  Value,\n} from './numeric';\n\ndescribe('Numeric', () => {\n  describe('Sum', () => {\n    const sumFunc = new Sum();\n\n    it('should sum numbers correctly', () => {\n      const result = sumFunc.eval([\n        new TypedValue(1, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(6);\n    });\n\n    it('should sum numbers in arrays correctly', () => {\n      const result = sumFunc.eval([new TypedValue([1, 2, 3], CellValueType.Number, true)]);\n\n      expect(result).toBe(6);\n    });\n  });\n\n  describe('Average', () => {\n    const averageFunc = new Average();\n\n    it('should average numbers correctly', () => {\n      const result = averageFunc.eval([\n        new TypedValue(1, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should average numbers in arrays correctly', () => {\n      const result = averageFunc.eval([new TypedValue([1, 2, 3], CellValueType.Number, true)]);\n\n      expect(result).toBe(2);\n    });\n  });\n\n  describe('Max', () => {\n    const maxFunc = new Max();\n\n    it('should max numbers correctly', () => {\n      const result = maxFunc.eval([\n        new TypedValue(1, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should max numbers in arrays correctly', () => {\n      const result = maxFunc.eval([new TypedValue([1, 2, 3], CellValueType.Number, true)]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should max datetime correctly', () => {\n      const result = maxFunc.eval([\n        new TypedValue('2024-01-01T00:00:00.000Z', CellValueType.DateTime, false),\n        new TypedValue('2024-01-02T00:00:00.000Z', CellValueType.DateTime, false),\n        new TypedValue('2024-01-03T00:00:00.000Z', CellValueType.DateTime, false),\n      ]);\n\n      expect(result).toBe('2024-01-03T00:00:00.000Z');\n    });\n  });\n\n  describe('Min', () => {\n    const minFunc = new Min();\n\n    it('should min numbers correctly', () => {\n      const result = minFunc.eval([\n        new TypedValue(1, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should min numbers in arrays correctly', () => {\n      const result = minFunc.eval([new TypedValue([1, 2, 3], CellValueType.Number, true)]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should min datetime correctly', () => {\n      const result = minFunc.eval([\n        new TypedValue('2024-01-01T00:00:00.000Z', CellValueType.DateTime, false),\n        new TypedValue('2024-01-02T00:00:00.000Z', CellValueType.DateTime, false),\n        new TypedValue('2024-01-03T00:00:00.000Z', CellValueType.DateTime, false),\n      ]);\n\n      expect(result).toBe('2024-01-01T00:00:00.000Z');\n    });\n  });\n\n  describe('Round', () => {\n    const roundFunc = new Round();\n\n    it('should round number with default precision', () => {\n      const result = roundFunc.eval([new TypedValue(2.49, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should round number when precision is 0', () => {\n      const result = roundFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should round negative number when precision is 1', () => {\n      const result = roundFunc.eval([\n        new TypedValue(-2.55, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.5);\n    });\n\n    it('should round negative number when precision is 1', () => {\n      const result = roundFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.5);\n    });\n\n    it('should round number when precision is greater than 1 but less than 2', () => {\n      const result = roundFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(1.8, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.5);\n    });\n\n    it('should round number when precision is 2', () => {\n      const result = roundFunc.eval([\n        new TypedValue(2.494, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.49);\n    });\n  });\n\n  describe('RoundUp', () => {\n    const roundUpFunc = new RoundUp();\n\n    it('should round up number with default precision', () => {\n      const result = roundUpFunc.eval([new TypedValue(2.49, CellValueType.Number, false)]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should round up number when precision is 0', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should round up number when precision is 1', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(2.44, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.5);\n    });\n\n    it('should round up number when precision is -1', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(10);\n    });\n\n    it('should round up negative number when precision is 0', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-3);\n    });\n\n    it('should round up negative number when precision is 1', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.5);\n    });\n\n    it('should round up negative number when precision is -1', () => {\n      const result = roundUpFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-10);\n    });\n  });\n\n  describe('RoundDown', () => {\n    const roundDownFunc = new RoundDown();\n\n    it('should round down number with default precision', () => {\n      const result = roundDownFunc.eval([new TypedValue(2.49, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should round down number when precision is 0', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should round down number when precision is 1', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.4);\n    });\n\n    it('should round down number when precision is -1', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(0);\n    });\n\n    it('should round down negative number when precision is 0', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2);\n    });\n\n    it('should round down negative number when precision is 1', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.4);\n    });\n\n    it('should round down negative number when precision is -1', () => {\n      const result = roundDownFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-0);\n    });\n  });\n\n  describe('Ceiling', () => {\n    const ceilingFunc = new Ceiling();\n\n    it('should ceiling number with default precision', () => {\n      const result = ceilingFunc.eval([new TypedValue(2.49, CellValueType.Number, false)]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should ceiling number when precision is 0', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(3);\n    });\n\n    it('should ceiling number when precision is 1', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(2.44, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.5);\n    });\n\n    it('should ceiling number when precision is -1', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(10);\n    });\n\n    it('should ceiling negative number when precision is 0', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2);\n    });\n\n    it('should ceiling negative number when precision is 1', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.4);\n    });\n\n    it('should ceiling negative number when precision is -1', () => {\n      const result = ceilingFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-0);\n    });\n  });\n\n  describe('Floor', () => {\n    const floorFunc = new Floor();\n\n    it('should floor number with default precision', () => {\n      const result = floorFunc.eval([new TypedValue(2.49, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should floor number when precision is 0', () => {\n      const result = floorFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should floor number when precision is 1', () => {\n      const result = floorFunc.eval([\n        new TypedValue(2.44, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(2.4);\n    });\n\n    it('should floor number when precision is -1', () => {\n      const result = floorFunc.eval([\n        new TypedValue(2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(0);\n    });\n\n    it('should floor negative number when precision is 0', () => {\n      const result = floorFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-3);\n    });\n\n    it('should floor negative number when precision is 1', () => {\n      const result = floorFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-2.5);\n    });\n\n    it('should floor negative number when precision is -1', () => {\n      const result = floorFunc.eval([\n        new TypedValue(-2.49, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(-10);\n    });\n  });\n\n  describe('Even', () => {\n    const evenFunc = new Even();\n\n    it('should round up to nearest even number for positive decimal values', () => {\n      const result = evenFunc.eval([new TypedValue(0.1, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should round down to nearest even number for negative decimal values', () => {\n      const result = evenFunc.eval([new TypedValue(-0.1, CellValueType.Number, false)]);\n\n      expect(result).toBe(-2);\n    });\n\n    it('should return 0 for input value of 0', () => {\n      const result = evenFunc.eval([new TypedValue(0, CellValueType.Number, false)]);\n\n      expect(result).toBe(0);\n    });\n\n    it('should return 2 for input value of 2', () => {\n      const result = evenFunc.eval([new TypedValue(2, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n  });\n\n  describe('Odd', () => {\n    const oddFunc = new Odd();\n\n    it('should round up to nearest odd number for positive decimal values', () => {\n      const result = oddFunc.eval([new TypedValue(0.1, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should round down to nearest even number for negative decimal values', () => {\n      const result = oddFunc.eval([new TypedValue(-0.1, CellValueType.Number, false)]);\n\n      expect(result).toBe(-1);\n    });\n\n    it('should return 0 for input value of 0', () => {\n      const result = oddFunc.eval([new TypedValue(0, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should return 3 for input value of 3', () => {\n      const result = oddFunc.eval([new TypedValue(3, CellValueType.Number, false)]);\n\n      expect(result).toBe(3);\n    });\n  });\n\n  describe('Int', () => {\n    const intFunc = new Int();\n\n    it('should return the integer part for positive number', () => {\n      const result = intFunc.eval([new TypedValue(1.9, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should return the integer part for negative numbers rounded towards zero', () => {\n      const result = intFunc.eval([new TypedValue(-1.9, CellValueType.Number, false)]);\n\n      expect(result).toBe(-2);\n    });\n  });\n\n  describe('Abs', () => {\n    const absFunc = new Abs();\n\n    it('should return the absolute value of a positive number', () => {\n      const result = absFunc.eval([new TypedValue(1, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should return the absolute value of a negative number', () => {\n      const result = absFunc.eval([new TypedValue(-1, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n  });\n\n  describe('Sqrt', () => {\n    const sqrtFunc = new Sqrt();\n\n    it('should return the square root of a positive number', () => {\n      const result = sqrtFunc.eval([new TypedValue(4, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n\n    it('should return NaN for a positive number', () => {\n      const result = sqrtFunc.eval([new TypedValue(-1, CellValueType.Number, false)]);\n\n      expect(result).toBe(NaN);\n    });\n  });\n\n  describe('Power', () => {\n    const powerFunc = new Power();\n\n    it('should return the result of raising a positive base to a positive exponent', () => {\n      const result = powerFunc.eval([\n        new TypedValue(10, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(100);\n    });\n\n    it('should return the result of raising a positive base to a negative exponent', () => {\n      const result = powerFunc.eval([\n        new TypedValue(10, CellValueType.Number, false),\n        new TypedValue(-2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(0.01);\n    });\n\n    it('should return the result of raising a negative base to a positive exponent', () => {\n      const result = powerFunc.eval([\n        new TypedValue(-10, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(100);\n    });\n  });\n\n  describe('Exp', () => {\n    const expFunc = new Exp();\n\n    it('should return Euler number when the exponent is 1', () => {\n      const result = expFunc.eval([new TypedValue(1, CellValueType.Number, false)]);\n\n      expect(result).toBe(Math.E);\n    });\n\n    it('should return 1 when the exponent is 0', () => {\n      const result = expFunc.eval([new TypedValue(0, CellValueType.Number, false)]);\n\n      expect(result).toBe(1);\n    });\n  });\n\n  describe('Log', () => {\n    const logFunc = new Log();\n\n    it('should return the logarithm of a number with a specified base', () => {\n      const result = logFunc.eval([\n        new TypedValue(1024, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(10);\n    });\n\n    it('should return the natural logarithm when no base is specified', () => {\n      const result = logFunc.eval([new TypedValue(100, CellValueType.Number, false)]);\n\n      expect(result).toBe(2);\n    });\n  });\n\n  describe('Mod', () => {\n    const modFunc = new Mod();\n\n    it('should return the modulus of two positive numbers where the divisor is less than the dividend', () => {\n      const result = modFunc.eval([\n        new TypedValue(3, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(1);\n    });\n\n    it('should return zero when the divisor is equal to the dividend', () => {\n      const result = modFunc.eval([\n        new TypedValue(3, CellValueType.Number, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(0);\n    });\n\n    it('should return the modulus of a negative dividend and a positive divisor', () => {\n      const result = modFunc.eval([\n        new TypedValue(-5, CellValueType.Number, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(1);\n    });\n  });\n\n  describe('Value', () => {\n    const valueFunc = new Value();\n\n    it('should convert a positive string to its numeric value', () => {\n      const result = valueFunc.eval([new TypedValue('100', CellValueType.Number, false)]);\n\n      expect(result).toBe(100);\n    });\n\n    it('should convert a negative string to its numeric value', () => {\n      const result = valueFunc.eval([new TypedValue('-100', CellValueType.Number, false)]);\n\n      expect(result).toBe(-100);\n    });\n\n    it('should extract numeric value from a string with leading non-numeric characters', () => {\n      const result = valueFunc.eval([new TypedValue('abc-100.12$$3', CellValueType.Number, false)]);\n\n      expect(result).toBe(-100.123);\n    });\n\n    it('should throw an error when param is not a string', () => {\n      expect(() =>\n        valueFunc.validateParams([new TypedValue(100, CellValueType.Number, false)])\n      ).toThrowError(`${valueFunc.name} can't process string type param at 1`);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/numeric.ts",
    "content": "import dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\n\ndayjs.extend(relativeTime);\ndayjs.extend(timezone);\ndayjs.extend(utc);\n\nabstract class NumericFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.Numeric;\n}\n\nexport class Sum extends NumericFunc {\n  name = FunctionName.Sum;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Sum} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Sum} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null | (number | null)[]>[]): number | null {\n    return params.reduce((result, param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value)) {\n          return result;\n        }\n        result += param.value\n          ? (param.value as (number | null)[]).reduce<number>((r, p) => {\n              r += p || 0;\n              return r;\n            }, 0)\n          : 0;\n        return result;\n      }\n      result += (param.value as number) || 0;\n      return result;\n    }, 0);\n  }\n}\n\nexport class Average extends NumericFunc {\n  name = FunctionName.Average;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Average} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Average} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null | (number | null)[]>[]): number | null {\n    let totalValue = 0;\n    let totalCount = 0;\n\n    params.forEach((param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value)) {\n          return;\n        }\n        totalCount += param.value.length;\n        totalValue += param.value\n          ? (param.value as (number | null)[]).reduce<number>((r, p) => {\n              return r + (p || 0);\n            }, 0)\n          : 0;\n      } else {\n        totalCount += 1;\n        totalValue += (param.value as number) || 0;\n      }\n    });\n\n    if (totalCount === 0) return null;\n\n    return totalValue / totalCount;\n  }\n}\n\nexport class Max extends NumericFunc {\n  name = FunctionName.Max;\n\n  acceptValueType = new Set([CellValueType.Number, CellValueType.DateTime]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Max} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type !== CellValueType.Number && param.type !== CellValueType.DateTime) {\n        throw new Error(\n          `${FunctionName.Max} can only process number or datetime type param at ${i + 1}`\n        );\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: params?.[0].type || CellValueType.Number };\n  }\n\n  eval(\n    params: TypedValue<number | string | null | (number | string | null)[]>[]\n  ): number | string | null {\n    let max: number | null = null;\n\n    const updateMax = (value: number | string | null) => {\n      if (value === null) return;\n      const timestamp = typeof value === 'string' ? new Date(value).getTime() : value;\n      if (max === null || timestamp > max) {\n        max = timestamp;\n      }\n    };\n\n    params.forEach((param) => {\n      if (param.isMultiple && Array.isArray(param.value)) {\n        const values = param.value.filter((v): v is string | number => v !== null);\n        if (param.type === CellValueType.DateTime) {\n          const currentMax = values.reduce(\n            (maxDate, v) => {\n              const timestamp = new Date(v as string).getTime();\n              return maxDate === null || timestamp > maxDate ? timestamp : maxDate;\n            },\n            null as number | null\n          );\n          updateMax(currentMax);\n        } else {\n          updateMax(Math.max(...(values as number[])));\n        }\n      } else {\n        updateMax(param.value as number | string | null);\n      }\n    });\n\n    if (max === null) return null;\n    return params[0].type === CellValueType.DateTime ? new Date(max).toISOString() : max;\n  }\n}\nexport class Min extends NumericFunc {\n  name = FunctionName.Min;\n\n  acceptValueType = new Set([CellValueType.Number, CellValueType.DateTime]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Min} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type !== CellValueType.Number && param.type !== CellValueType.DateTime) {\n        throw new Error(\n          `${FunctionName.Min} can only process number or datetime type param at ${i + 1}`\n        );\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: params?.[0].type || CellValueType.Number };\n  }\n\n  eval(\n    params: TypedValue<number | string | null | (number | string | null)[]>[]\n  ): number | string | null {\n    let min: number | null = null;\n\n    const updateMin = (value: number | string | null) => {\n      if (value === null) return;\n      const timestamp = typeof value === 'string' ? new Date(value).getTime() : value;\n      if (min === null || timestamp < min) {\n        min = timestamp;\n      }\n    };\n\n    params.forEach((param) => {\n      if (param.isMultiple && Array.isArray(param.value)) {\n        const values = param.value.filter((v): v is string | number => v !== null);\n        if (param.type === CellValueType.DateTime) {\n          const currentMin = values.reduce(\n            (minDate, v) => {\n              const timestamp = new Date(v as string).getTime();\n              return minDate === null || timestamp < minDate ? timestamp : minDate;\n            },\n            null as number | null\n          );\n          updateMin(currentMin);\n        } else {\n          updateMin(Math.min(...(values as number[])));\n        }\n      } else {\n        updateMin(param.value as number | string | null);\n      }\n    });\n\n    if (min === null) return null;\n    return params[0].type === CellValueType.DateTime ? new Date(min).toISOString() : min;\n  }\n}\n\nexport class Round extends NumericFunc {\n  name = FunctionName.Round;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Round} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Round} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const precision = params[1]?.value ? Math.floor(params[1].value) : 0;\n    const offset = Math.pow(10, precision);\n    return Math.round(value * offset) / offset;\n  }\n}\n\nexport class RoundUp extends NumericFunc {\n  name = FunctionName.RoundUp;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.RoundUp} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.RoundUp} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    let value = params[0].value;\n    if (value == null) return null;\n    value = Number(params[0].value);\n    const precision = params[1]?.value ? Math.floor(params[1].value) : 0;\n    const offset = Math.pow(10, precision);\n    const roundFn = value > 0 ? Math.ceil : Math.floor;\n    return roundFn(value * offset) / offset;\n  }\n}\n\nexport class RoundDown extends NumericFunc {\n  name = FunctionName.RoundDown;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.RoundDown} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.RoundDown} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    let value = params[0].value;\n    if (value == null) return null;\n    value = Number(params[0].value);\n    const precision = params[1]?.value ? Math.floor(params[1].value) : 0;\n    const offset = Math.pow(10, precision);\n    const roundFn = value > 0 ? Math.floor : Math.ceil;\n    return roundFn(value * offset) / offset;\n  }\n}\n\nexport class Ceiling extends NumericFunc {\n  name = FunctionName.Ceiling;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Ceiling} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Ceiling} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const places = params[1]?.value || 0;\n    const multiplier = Math.pow(10, places);\n    return Math.ceil(value * multiplier) / multiplier;\n  }\n}\n\nexport class Floor extends NumericFunc {\n  name = FunctionName.Floor;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Floor} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Floor} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const places = params[1]?.value || 0;\n    const multiplier = Math.pow(10, places);\n    return Math.floor(value * multiplier) / multiplier;\n  }\n}\n\nexport class Even extends NumericFunc {\n  name = FunctionName.Even;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Even} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Even} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const roundedValue = value > 0 ? Math.ceil(value) : Math.floor(value);\n    if (roundedValue % 2 === 0) return roundedValue;\n    return roundedValue > 0 ? roundedValue + 1 : roundedValue - 1;\n  }\n}\n\nexport class Odd extends NumericFunc {\n  name = FunctionName.Odd;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Odd} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Odd} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const roundedValue = value > 0 ? Math.ceil(value) : Math.floor(value);\n    if (roundedValue % 2 !== 0) return roundedValue;\n    return roundedValue >= 0 ? roundedValue + 1 : roundedValue - 1;\n  }\n}\n\nexport class Int extends NumericFunc {\n  name = FunctionName.Int;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Int} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Int} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    return Math.floor(value);\n  }\n}\n\nexport class Abs extends NumericFunc {\n  name = FunctionName.Abs;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Abs} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Abs} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    return Math.abs(value);\n  }\n}\n\nexport class Sqrt extends NumericFunc {\n  name = FunctionName.Sqrt;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Sqrt} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Sqrt} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    return Math.sqrt(value);\n  }\n}\n\nexport class Power extends NumericFunc {\n  name = FunctionName.Power;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.Power} needs 2 params`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Power} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const exponent = params[1]?.value || 1;\n    return Math.pow(value, exponent);\n  }\n}\n\nexport class Exp extends NumericFunc {\n  name = FunctionName.Exp;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Exp} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Exp} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    return Math.exp(value);\n  }\n}\n\nexport class Log extends NumericFunc {\n  name = FunctionName.Log;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (!params.length) {\n      throw new Error(`${FunctionName.Log} needs at least 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Log} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const base = params[1]?.value || 10;\n    return Math.log(value) / Math.log(base);\n  }\n}\n\nexport class Mod extends NumericFunc {\n  name = FunctionName.Mod;\n\n  acceptValueType = new Set([CellValueType.Number]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.Mod} needs 2 params`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type === CellValueType.String) {\n        throw new Error(`${FunctionName.Mod} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<number | null>[]): number | null {\n    const value = params[0].value;\n    if (value == null) return null;\n    const divisor = params[1]?.value || 1;\n    const mod = value % divisor;\n    return (value ^ divisor) < 0 ? -mod : mod;\n  }\n}\n\nexport class Value extends NumericFunc {\n  name = FunctionName.Value;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = false;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Value} only allow 1 param`);\n    }\n    params.forEach((param, i) => {\n      if (param && param.type !== CellValueType.String) {\n        throw new Error(`${FunctionName.Value} can't process string type param at ${i + 1}`);\n      }\n    });\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null>[]): number | null {\n    let value = params[0].value;\n    if (value == null) return null;\n    const numberReg = /[^\\d.+-]/g;\n    const symbolReg = /([+\\-.])+/g;\n    value = String(value).replace(numberReg, '').replace(symbolReg, '$1');\n    return parseFloat(value);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/system.spec.ts",
    "content": "import type { IRecord } from '../../models';\nimport { CellValueType } from '../../models/field/constant';\nimport { TypedValue } from '../typed-value';\nimport { RecordId, TextAll } from './system';\n\ndescribe('SystemFunc', () => {\n  const textAllFunc = new TextAll();\n\n  describe('TextAll', () => {\n    it('should process single string correctly', () => {\n      const result = textAllFunc.eval([new TypedValue('Hello', CellValueType.String, false)]);\n\n      expect(result).toBe('Hello');\n    });\n\n    it('should process array of strings correctly', () => {\n      const result = textAllFunc.eval([\n        new TypedValue(['Hello', 'World'], CellValueType.String, true),\n      ]);\n\n      expect(result).toEqual(['Hello', 'World']);\n    });\n\n    it('should return null for null input', () => {\n      const result = textAllFunc.eval([new TypedValue(null, CellValueType.String, false)]);\n\n      expect(result).toBeNull();\n    });\n\n    it('should throw an error when more than 1 param provided', () => {\n      expect(() =>\n        textAllFunc.validateParams([\n          new TypedValue('Hello', CellValueType.String, false),\n          new TypedValue('World', CellValueType.String, false),\n        ])\n      ).toThrowError(`${textAllFunc.name} only allow 1 param`);\n    });\n  });\n\n  describe('RecordId', () => {\n    const record: IRecord = {\n      id: 'recTest',\n      fields: {},\n      createdTime: new Date().toISOString(),\n    };\n    const context = {\n      record,\n      dependencies: {},\n      timeZone: 'Asia/Shanghai',\n    };\n\n    it('should return record id', () => {\n      const recordIdFunc = new RecordId();\n\n      const result = recordIdFunc.eval([], context);\n\n      expect(result).toBe('recTest');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/system.ts",
    "content": "import { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport type { IFormulaContext } from './common';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\n\nabstract class SystemFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.System;\n}\n\nexport class TextAll extends SystemFunc {\n  name = FunctionName.TextAll;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.TextAll} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params: TypedValue[]) {\n    if (params[0].isMultiple) {\n      return { type: CellValueType.String, isMultiple: true };\n    }\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue[]): boolean | number | string | (string | null)[] | null {\n    const param = params[0];\n    if (param.isMultiple) {\n      return param.value\n        ? (param.value as string[]).map((p) => {\n            if (Array.isArray(p)) {\n              return p.join(', ');\n            }\n            return p;\n          })\n        : null;\n    }\n\n    return param.value || null;\n  }\n}\n\nexport class RecordId extends SystemFunc {\n  name = FunctionName.RecordId;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  validateParams() {}\n\n  getReturnType() {\n    return { type: CellValueType.String };\n  }\n\n  eval(_params: TypedValue<string | null>[], context: IFormulaContext): string {\n    return context.record.id;\n  }\n}\n\nexport class AutoNumber extends SystemFunc {\n  name = FunctionName.RecordId;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  validateParams() {}\n\n  getReturnType() {\n    return { type: CellValueType.Number };\n  }\n\n  eval(_params: TypedValue<string | null>[], context: IFormulaContext): number | null {\n    return context.record.autoNumber ?? null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/functions/text.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { CellValueType } from '../../models/field/constant';\nimport { TypedValue } from '../typed-value';\nimport {\n  Concatenate,\n  EncodeUrlComponent,\n  Find,\n  Left,\n  Len,\n  Lower,\n  Mid,\n  RegExpReplace,\n  Replace,\n  Rept,\n  Right,\n  Search,\n  Substitute,\n  T,\n  Trim,\n  Upper,\n} from './text';\n\ndescribe('TextFunc', () => {\n  describe('Concatenate', () => {\n    const concatenateFunc = new Concatenate();\n\n    it('should concatenate strings correctly', () => {\n      const result = concatenateFunc.eval([\n        new TypedValue('Hello ', CellValueType.String, false),\n        new TypedValue('World', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello World');\n    });\n\n    it('should concatenate strings in arrays correctly', () => {\n      const result = concatenateFunc.eval([\n        new TypedValue(['Hello', 'World'], CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('Hello, World');\n    });\n  });\n\n  describe('Find', () => {\n    const findFunc = new Find();\n    const findString = 'Teable';\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should find the position in a string', () => {\n      const result = findFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should find the position in a multiple value', () => {\n      const result = findFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should find the position in a string when the starting position is passed in', () => {\n      const result = findFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should return 0 when the incoming starting position is greater than the string position', () => {\n      const result = findFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(10, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(0);\n    });\n\n    it('should find the position in a multiple value when the starting position is a negative number', () => {\n      const result = findFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(-8, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n  });\n\n  describe('Search', () => {\n    const searchFunc = new Search();\n    const findString = 'Teable';\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should search the position in a string', () => {\n      const result = searchFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should search the position in a multiple value', () => {\n      const result = searchFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should search the position in a string when the starting position is passed in', () => {\n      const result = searchFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(3, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n\n    it('should return null when the incoming starting position is greater than the string position', () => {\n      const result = searchFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(10, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(null);\n    });\n\n    it('should search the position in a multiple value when the starting position is a negative number', () => {\n      const result = searchFunc.eval([\n        new TypedValue(findString, CellValueType.String, false),\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(-8, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(8);\n    });\n  });\n\n  describe('Mid', () => {\n    const midFunc = new Mid();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should return a specific number of characters in a text string starting at a specified position', () => {\n      const result = midFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(7, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should return a specific number of characters in a multiple values starting at a specified position', () => {\n      const result = midFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(7, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should return a blank string if truncate length is a negative number', () => {\n      const result = midFunc.eval([\n        new TypedValue(targetString, CellValueType.String, true),\n        new TypedValue(7, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('');\n    });\n\n    it('should return an empty string when the specified position is greater than the position of the text', () => {\n      const result = midFunc.eval([\n        new TypedValue(targetString, CellValueType.String, true),\n        new TypedValue(20, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('');\n    });\n  });\n\n  describe('Left', () => {\n    const leftFunc = new Left();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should return the leftmost character of a given string by default', () => {\n      const result = leftFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe('H');\n    });\n\n    it('should return the specified number of characters from the left of a given string', () => {\n      const result = leftFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(5, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello');\n    });\n\n    it('should handle an array of strings and return the specified number of characters from the left', () => {\n      const result = leftFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(5, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello');\n    });\n\n    it('should return an empty string when provided with a negative number as count', () => {\n      const result = leftFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('');\n    });\n  });\n\n  describe('Right', () => {\n    const rightFunc = new Right();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should return the rightmost character of a given string by default', () => {\n      const result = rightFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe('e');\n    });\n\n    it('should return the specified number of characters from the right of a given string', () => {\n      const result = rightFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(6, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should handle an array of strings and return the specified number of characters from the right', () => {\n      const result = rightFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(6, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should return an empty string when provided with a negative number as count', () => {\n      const result = rightFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('');\n    });\n  });\n\n  describe('Replace', () => {\n    const replaceFunc = new Replace();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should replace the substring starting at position in a given string', () => {\n      const result = replaceFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(8, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n        new TypedValue('Table', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n\n    it('should replace the substring starting at position in a given multiple values', () => {\n      const result = replaceFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(8, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n        new TypedValue('Table', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n\n    it('should append the substring at the end when the starting position exceeds the string length', () => {\n      const result = replaceFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(20, CellValueType.Number, false),\n        new TypedValue(6, CellValueType.Number, false),\n        new TypedValue('Table', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello, TeableTable');\n    });\n\n    it('should append the substring before the substring when provided with a negative length', () => {\n      const result = replaceFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(8, CellValueType.Number, false),\n        new TypedValue(-1, CellValueType.Number, false),\n        new TypedValue('Table', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello, Table Teable');\n    });\n  });\n\n  describe('RegExpReplace', () => {\n    const regExpReplaceFunc = new RegExpReplace();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should replace substring that matches pattern in string', () => {\n      const result = regExpReplaceFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue('H.* ', CellValueType.String, false),\n        new TypedValue('', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should replace all substring that matches pattern in string', () => {\n      const result = regExpReplaceFunc.eval([\n        new TypedValue('ABC CBA', CellValueType.String, false),\n        new TypedValue('C', CellValueType.String, false),\n        new TypedValue('D', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('ABD DBA');\n    });\n\n    it('should replace substring when input is an array', () => {\n      const result = regExpReplaceFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue('H.* ', CellValueType.String, false),\n        new TypedValue('', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Teable');\n    });\n  });\n\n  describe('Substitute', () => {\n    const substituteFunc = new Substitute();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should substitute the specified string in the target string', () => {\n      const result = substituteFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue('Table', CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n\n    it('should substitute the specified string in the target string given a specific instance number', () => {\n      const result = substituteFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue('Table', CellValueType.String, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n\n    it('should handle an array of strings and substitute the specified string given a specific instance number', () => {\n      const result = substituteFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue('Table', CellValueType.String, false),\n        new TypedValue(1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n\n    it('should substitute the specified string in the target string even with a negative instance number', () => {\n      const result = substituteFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue('Teable', CellValueType.String, false),\n        new TypedValue('Table', CellValueType.String, false),\n        new TypedValue(-1, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello, Table');\n    });\n  });\n\n  describe('Lower', () => {\n    const lowerFunc = new Lower();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should convert a given string to lowercase', () => {\n      const result = lowerFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe('hello, teable');\n    });\n\n    it('should handle an array of strings and convert them to lowercase', () => {\n      const result = lowerFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('hello, teable');\n    });\n  });\n\n  describe('Upper', () => {\n    const upperFunc = new Upper();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should convert a given string to uppercase', () => {\n      const result = upperFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe('HELLO, TEABLE');\n    });\n\n    it('should handle an array of strings and convert them to uppercase', () => {\n      const result = upperFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('HELLO, TEABLE');\n    });\n  });\n\n  describe('Rept', () => {\n    const reptFunc = new Rept();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should repeat the given string based on the provided number', () => {\n      const result = reptFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello, TeableHello, Teable');\n    });\n\n    it('should handle an array of strings and repeat based on the provided number', () => {\n      const result = reptFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(2, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe('Hello, TeableHello, Teable');\n    });\n\n    it('should return null when the repeat count is zero', () => {\n      const result = reptFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n        new TypedValue(0, CellValueType.Number, false),\n      ]);\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe('Trim', () => {\n    const trimFunc = new Trim();\n    const targetString = ' Hello, Teable ';\n    const targetMultipleValue = [' Hello', 'Teable '];\n\n    it('should remove leading and trailing spaces from a given string', () => {\n      const result = trimFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe('Hello, Teable');\n    });\n\n    it('should handle an array of strings and remove leading and trailing spaces from each', () => {\n      const result = trimFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('Hello, Teable');\n    });\n  });\n\n  describe('T', () => {\n    const tFunc = new T();\n\n    it('should return the input string when provided a string value', () => {\n      const result = tFunc.eval([new TypedValue('Teable', CellValueType.String, false)]);\n\n      expect(result).toBe('Teable');\n    });\n\n    it('should concatenate and return string array elements as a single string', () => {\n      const result = tFunc.eval([new TypedValue(['Hello', 'Teable'], CellValueType.String, true)]);\n\n      expect(result).toBe('Hello, Teable');\n    });\n\n    it('should return null when provided a number', () => {\n      const result = tFunc.eval([new TypedValue(100, CellValueType.Number, false)]);\n\n      expect(result).toBe(null);\n    });\n\n    it('should return null when provided a boolean value', () => {\n      const result = tFunc.eval([new TypedValue(true, CellValueType.Boolean, false)]);\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe('Len', () => {\n    const lenFunc = new Len();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should return the length of a given string', () => {\n      const result = lenFunc.eval([new TypedValue(targetString, CellValueType.String, false)]);\n\n      expect(result).toBe(13);\n    });\n\n    it('should handle an array of strings and return the combined length', () => {\n      const result = lenFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe(13);\n    });\n  });\n\n  describe('EncodeUrlComponent', () => {\n    const encodeUrlComponentFunc = new EncodeUrlComponent();\n    const targetString = 'Hello, Teable';\n    const targetMultipleValue = ['Hello', 'Teable'];\n\n    it('should correctly encode a string with special characters for a URL component', () => {\n      const result = encodeUrlComponentFunc.eval([\n        new TypedValue(targetString, CellValueType.String, false),\n      ]);\n\n      expect(result).toBe('Hello%2C%20Teable');\n    });\n\n    it('should concatenate and correctly encode string array elements for a URL component', () => {\n      const result = encodeUrlComponentFunc.eval([\n        new TypedValue(targetMultipleValue, CellValueType.String, true),\n      ]);\n\n      expect(result).toBe('Hello%2C%20Teable');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/functions/text.ts",
    "content": "import { isNumber, isString } from 'lodash';\nimport { CellValueType } from '../../models/field/constant';\nimport type { TypedValue } from '../typed-value';\nimport { FormulaFunc, FormulaFuncType, FunctionName } from './common';\n\nexport const convertValueToString = (\n  param?: TypedValue<string | number | boolean | null | (string | number | boolean | null)[]>,\n  separator = ', '\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n): string | null => {\n  const { value, isMultiple, field } = param || {};\n\n  if (value == null) return null;\n  if (field?.cellValueType === CellValueType.DateTime) {\n    if (isMultiple && Array.isArray(value)) {\n      return value.map((item) => field.cellValue2String(item)).join(separator);\n    }\n    return field.cellValue2String(value);\n  }\n\n  if (isMultiple) {\n    if (Array.isArray(value)) return value.join(separator);\n    if (typeof value === 'string') {\n      try {\n        const parsed = JSON.parse(value);\n        if (Array.isArray(parsed)) return parsed.join(separator);\n      } catch {\n        // ignore parse errors and fall back to string cast\n      }\n    }\n  }\n  return String(value);\n};\n\nabstract class TextFunc extends FormulaFunc {\n  readonly type = FormulaFuncType.Text;\n}\n\nexport class Concatenate extends TextFunc {\n  name = FunctionName.Concatenate;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Concatenate} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    return params.reduce((result, param) => {\n      if (param.isMultiple) {\n        if (!Array.isArray(param.value)) {\n          return result;\n        }\n        result += param.value.join(', ');\n        return result;\n      }\n      result += (param.value as string) || '';\n      return result;\n    }, '');\n  }\n}\n\nexport class Find extends TextFunc {\n  name = FunctionName.Find;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Find} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): number | null {\n    const findString = params[0].value;\n    const targetString = convertValueToString(params[1]);\n\n    if (findString == null || targetString == null) return null;\n\n    let startPosition = params[2]?.value ?? 0;\n    startPosition = isNumber(startPosition) && startPosition > 0 ? startPosition - 1 : 0;\n    return String(targetString).indexOf(String(findString), startPosition) + 1;\n  }\n}\n\nexport class Search extends TextFunc {\n  name = FunctionName.Search;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Search} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): number | null {\n    const findString = params[0].value;\n    const targetString = convertValueToString(params[1]);\n    let startPosition = params[2]?.value ?? 0;\n\n    if (findString == null || targetString == null) return null;\n\n    startPosition = isNumber(startPosition) && startPosition > 0 ? startPosition - 1 : 0;\n    const position = String(targetString).indexOf(String(findString), startPosition) + 1;\n    return position === 0 ? null : position;\n  }\n}\n\nexport class Mid extends TextFunc {\n  name = FunctionName.Mid;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 3) {\n      throw new Error(`${FunctionName.Mid} needs at least 3 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const targetString = convertValueToString(params[0]);\n\n    if (targetString == null) return null;\n\n    const startPosition = Number(params[1]?.value ?? 0);\n    const truncateCount = Number(params[2]?.value ?? targetString.length);\n    return targetString.slice(startPosition, startPosition + truncateCount);\n  }\n}\n\nexport class Left extends TextFunc {\n  name = FunctionName.Left;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Left} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    const truncateCount = Number(params[1]?.value ?? 1);\n    return String(value).substring(0, truncateCount);\n  }\n}\n\nexport class Right extends TextFunc {\n  name = FunctionName.Right;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 1) {\n      throw new Error(`${FunctionName.Right} needs at least 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    const truncateCount = Number(params[1]?.value ?? 1);\n    const startPosition = value.length - truncateCount;\n    return value.substring(startPosition);\n  }\n}\n\nexport class Replace extends TextFunc {\n  name = FunctionName.Replace;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 4) {\n      throw new Error(`${FunctionName.Replace} needs at least 4 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const targetString = convertValueToString(params[0]);\n\n    if (targetString == null) return null;\n\n    const startPosition = Number(params[1]?.value ?? 0);\n    const truncateCount = Number(params[2]?.value ?? targetString.length);\n    const replaceStr = String(params[3]?.value ?? '');\n\n    if (targetString.length <= startPosition) return targetString + replaceStr;\n\n    return (\n      targetString.substring(0, startPosition - 1) +\n      replaceStr +\n      targetString.substring(startPosition + truncateCount - 1)\n    );\n  }\n}\n\nexport class RegExpReplace extends TextFunc {\n  name = FunctionName.RegExpReplace;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 3) {\n      throw new Error(`${FunctionName.RegExpReplace} needs at least 3 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    const text = convertValueToString(params[0]);\n    if (text == null) return null;\n    const pattern = params[1].value ? String(params[1].value) : '';\n    const replacement = params[2].value ? String(params[2].value) : '';\n    const regex = new RegExp(pattern, 'g');\n    return text.replace(regex, replacement);\n  }\n}\n\nexport class Substitute extends TextFunc {\n  name = FunctionName.Substitute;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 3) {\n      throw new Error(`${FunctionName.Substitute} needs at least 3 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const targetString = convertValueToString(params[0]);\n\n    if (targetString == null) return null;\n\n    const oldString = String(params[1]?.value ?? '');\n    const newString = String(params[2]?.value ?? '');\n    const index = Number(params[3]?.value ?? 0) - 1;\n    const splitStringArray = targetString.split(oldString);\n\n    if (index > splitStringArray.length - 2) return targetString;\n    if (index > 0) {\n      const substituter = [splitStringArray[index], splitStringArray[index + 1]].join(newString);\n      splitStringArray.splice(index, 2, substituter);\n      return splitStringArray.join(oldString);\n    }\n    return splitStringArray.join(newString);\n  }\n}\n\nexport class Lower extends TextFunc {\n  name = FunctionName.Lower;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Lower} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    return String(value).toLowerCase();\n  }\n}\n\nexport class Upper extends TextFunc {\n  name = FunctionName.Upper;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Upper} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    return String(value).toUpperCase();\n  }\n}\n\nexport class Rept extends TextFunc {\n  name = FunctionName.Rept;\n\n  acceptValueType = new Set([CellValueType.String, CellValueType.Number]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length < 2) {\n      throw new Error(`${FunctionName.Rept} needs at least 2 params`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | number | null | (string | number | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    const count = Number(params[1]?.value ?? 0);\n    if (count === 0) return null;\n    return String(value).repeat(count);\n  }\n}\n\nexport class Trim extends TextFunc {\n  name = FunctionName.Trim;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Trim} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    return String(value).trim();\n  }\n}\n\nexport class T extends TextFunc {\n  name = FunctionName.T;\n\n  acceptValueType = new Set([\n    CellValueType.String,\n    CellValueType.Number,\n    CellValueType.Boolean,\n    CellValueType.DateTime,\n  ]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.T} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(\n    params: TypedValue<string | number | boolean | null | (string | number | boolean | null)[]>[]\n  ): string | null {\n    const { value, isMultiple } = params[0];\n\n    if (isMultiple && Array.isArray(value)) {\n      if (value.some((v) => v != null && !isString(v))) return null;\n      return value.filter(Boolean).join(', ');\n    }\n    return isString(value) ? value : null;\n  }\n}\n\nexport class Len extends TextFunc {\n  name = FunctionName.Len;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.Len} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.Number };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): number | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    return String(value).length;\n  }\n}\n\nexport class EncodeUrlComponent extends TextFunc {\n  name = FunctionName.EncodeUrlComponent;\n\n  acceptValueType = new Set([CellValueType.String]);\n\n  acceptMultipleValue = true;\n\n  validateParams(params: TypedValue[]) {\n    if (params.length !== 1) {\n      throw new Error(`${FunctionName.EncodeUrlComponent} only allow 1 param`);\n    }\n  }\n\n  getReturnType(params?: TypedValue[]) {\n    params && this.validateParams(params);\n    return { type: CellValueType.String };\n  }\n\n  eval(params: TypedValue<string | null | (string | null)[]>[]): string | null {\n    const value = convertValueToString(params[0]);\n\n    if (value == null) return null;\n\n    return encodeURIComponent(value);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/index.ts",
    "content": "export * from './evaluate';\nexport * from './typed-value';\nexport * from './visitor';\nexport * from './errors';\nexport * from '@teable/formula';\n\nexport { FunctionName, FormulaFuncType } from './functions/common';\nexport * from './function-aliases';\nexport { FUNCTIONS } from './functions/factory';\nexport type {\n  IFieldMap,\n  IFormulaParamMetadata,\n  IFormulaParamFieldMetadata,\n  ITeableToDbFunctionConverter,\n  FormulaParamType,\n} from './function-convertor.interface';\n"
  },
  {
    "path": "packages/core/src/formula/typed-value-converter.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CellValueType } from '../models/field/constant';\nimport type { FormulaFunc } from './functions/common';\nimport { TypedValue } from './typed-value';\nimport { TypedValueConverter } from './typed-value-converter';\n\ndescribe('TypedValueConverter', () => {\n  const typedValueConverter = new TypedValueConverter();\n\n  // Assuming a FormulaFunc implementation that accepts all types\n  const funcAcceptAll = {\n    name: 'testFunc',\n    acceptValueType: new Set(Object.values(CellValueType)),\n  } as any as FormulaFunc;\n\n  // Assuming a FormulaFunc implementation that accepts only strings\n  const funcAcceptString = {\n    name: 'testFunc',\n    acceptValueType: new Set([CellValueType.String]),\n  } as any;\n\n  // Test transformMultipleValue method\n  it('should transform multiple values to single value', () => {\n    const arrayValue = new TypedValue([42], CellValueType.Number, true);\n    const transformed = typedValueConverter.transformMultipleValue(arrayValue, funcAcceptAll);\n    expect(transformed.isMultiple).toBeUndefined();\n    expect(transformed.value).toBe(42);\n  });\n\n  it('should throw error if function does not accept multiple values', () => {\n    const arrayValue = new TypedValue([42, 24], CellValueType.Number, true);\n    expect(() =>\n      typedValueConverter.transformMultipleValue(arrayValue, funcAcceptAll)\n    ).toThrowError(TypeError);\n  });\n\n  // Test convertTypedValue method\n  it('should not convert null', () => {\n    const strValue = new TypedValue(null, CellValueType.Number, true);\n    const converted = typedValueConverter.convertTypedValue(strValue, {\n      name: 'testFunc',\n      acceptValueType: new Set([CellValueType.String]),\n      acceptMultipleValue: true,\n    } as any);\n    expect(converted.type).toBe(CellValueType.String);\n    expect(converted.value).toBe(null);\n  });\n\n  it('should not convert if function accepts input type', () => {\n    const strValue = new TypedValue('test', CellValueType.String);\n    const converted = typedValueConverter.convertTypedValue(strValue, funcAcceptString);\n    expect(converted).toBe(strValue);\n  });\n\n  it('should convert boolean values to strings', () => {\n    const boolValue = new TypedValue(true, CellValueType.Boolean);\n    const converted = typedValueConverter.convertTypedValue(boolValue, funcAcceptString);\n    expect(converted.type).toBe(CellValueType.String);\n    expect(converted.value).toBe('true');\n  });\n\n  it('should convert number values to strings', () => {\n    const numValue = new TypedValue(42, CellValueType.Number);\n    const converted = typedValueConverter.convertTypedValue(numValue, funcAcceptString);\n    expect(converted.type).toBe(CellValueType.String);\n    expect(converted.value).toBe('42');\n  });\n\n  it('should convert boolean arrays to string arrays', () => {\n    const boolValues = new TypedValue([true, false], CellValueType.Boolean, true);\n    const converted = typedValueConverter.convertTypedValue(boolValues, {\n      ...funcAcceptString,\n      acceptMultipleValue: true,\n    });\n    expect(converted.type).toBe(CellValueType.String);\n    expect(converted.isMultiple).toBe(true);\n    expect(converted.value).toEqual(['true', 'false']);\n  });\n\n  it('should throw error when not accept array value', () => {\n    const boolValues = new TypedValue([true, false], CellValueType.Boolean, true);\n    expect(() =>\n      typedValueConverter.convertTypedValue(boolValues, funcAcceptString)\n    ).toThrowError();\n  });\n\n  // Test convertUnsupportedValue method\n  it('should convert string to boolean', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      'true',\n      CellValueType.String,\n      CellValueType.Boolean\n    );\n    expect(converted).toBe(true);\n  });\n\n  it('should convert string to number', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      '42',\n      CellValueType.String,\n      CellValueType.Number\n    );\n    expect(converted).toBe(42);\n  });\n\n  // Test convertUnsupportedValue method\n  it('should convert datetime value to string', () => {\n    const date = new Date();\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      date.toISOString(),\n      CellValueType.DateTime,\n      CellValueType.String\n    );\n    expect(converted).toBe(date.toISOString());\n  });\n\n  it('should convert boolean value to string', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      true,\n      CellValueType.Boolean,\n      CellValueType.String\n    );\n    expect(converted).toBe('true');\n  });\n\n  it('should convert number value to string', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      42,\n      CellValueType.Number,\n      CellValueType.String\n    );\n    expect(converted).toBe('42');\n  });\n\n  it('should convert boolean value to number', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      true,\n      CellValueType.Boolean,\n      CellValueType.Number\n    );\n    expect(converted).toBe(1);\n  });\n\n  it('should convert string value to number', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      '42',\n      CellValueType.String,\n      CellValueType.Number\n    );\n    expect(converted).toBe(42);\n  });\n\n  it('should convert string value to boolean', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      '',\n      CellValueType.String,\n      CellValueType.Boolean\n    );\n    expect(converted).toBe(false);\n  });\n\n  it('should convert number value to boolean', () => {\n    const converted = typedValueConverter['convertUnsupportedValue'](\n      0,\n      CellValueType.Number,\n      CellValueType.Boolean\n    );\n    expect(converted).toBe(false);\n  });\n\n  // Test convertTypedValue method\n  it('should convert string value to boolean', () => {\n    const stringValue = new TypedValue('true', CellValueType.String);\n    const funcAcceptBoolean = {\n      ...funcAcceptString,\n      acceptValueType: new Set([CellValueType.Boolean]),\n    };\n    const converted = typedValueConverter.convertTypedValue(stringValue, funcAcceptBoolean);\n    expect(converted.type).toBe(CellValueType.Boolean);\n    expect(converted.value).toBe(true);\n  });\n\n  it('should convert number value to boolean', () => {\n    const numberValue = new TypedValue(1, CellValueType.Number);\n    const funcAcceptBoolean = {\n      ...funcAcceptString,\n      acceptValueType: new Set([CellValueType.Boolean]),\n    };\n    const converted = typedValueConverter.convertTypedValue(numberValue, funcAcceptBoolean);\n    expect(converted.type).toBe(CellValueType.Boolean);\n    expect(converted.value).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/typed-value-converter.ts",
    "content": "import { CellValueType } from '../models/field/constant';\nimport type { FormulaFunc } from './functions/common';\nimport { TypedValue } from './typed-value';\n\nexport class TypedValueConverter {\n  // auto transform an array value to non-array value if only have 1 item\n  transformMultipleValue(typedValue: TypedValue, func: FormulaFunc): TypedValue {\n    const { value, type, isMultiple } = typedValue;\n    if (!isMultiple || func.acceptMultipleValue) {\n      return typedValue;\n    }\n\n    if (value?.length > 1) {\n      console.log(func);\n      throw new TypeError(`function ${func.name} is not accept array value: ${value}`);\n    }\n    const transValue = value && value[0];\n    return new TypedValue(transValue, type);\n  }\n\n  // convert typed value to function first accept value type\n  convertTypedValue(typedValue: TypedValue, func: FormulaFunc): TypedValue {\n    typedValue = this.transformMultipleValue(typedValue, func);\n\n    if (func.acceptValueType.has(typedValue.type)) {\n      return typedValue;\n    }\n\n    const firstAcceptValueType = func.acceptValueType.values().next().value;\n    if (!firstAcceptValueType) {\n      throw new TypeError(`function ${func.name} has no acceptable value types`);\n    }\n\n    const converted = typedValue.isMultiple\n      ? (typedValue.value as unknown[])?.map((v) =>\n          this.convertUnsupportedValue(v, typedValue.type, firstAcceptValueType)\n        )\n      : this.convertUnsupportedValue(typedValue.value, typedValue.type, firstAcceptValueType);\n\n    return new TypedValue(\n      converted == null ? null : converted,\n      firstAcceptValueType,\n      typedValue.isMultiple\n    );\n  }\n\n  private convertUnsupportedValue(\n    value: unknown,\n    inputValueType: CellValueType,\n    acceptValueType: CellValueType\n  ) {\n    if (inputValueType === acceptValueType) {\n      throw new Error('Should not convert an accept value type');\n    }\n\n    if (value == null) {\n      return null;\n    }\n\n    switch (acceptValueType) {\n      case CellValueType.DateTime:\n        return this.convertDatetimeValue(value, inputValueType);\n      case CellValueType.Number:\n        return this.convertNumberValue(value, inputValueType);\n      case CellValueType.Boolean:\n        return this.convertBooleanValue(value, inputValueType);\n      case CellValueType.String:\n        return this.convertStringValue(value, inputValueType);\n    }\n  }\n\n  private convertDatetimeValue(value: unknown, inputValueType: CellValueType) {\n    switch (inputValueType) {\n      case CellValueType.DateTime:\n        return value;\n      case CellValueType.String: {\n        const date = new Date(value as string);\n        if (!Number.isNaN(date.getTime())) {\n          return date.toISOString();\n        }\n        return null;\n      }\n      case CellValueType.Boolean:\n      case CellValueType.Number:\n        return null;\n    }\n  }\n\n  private convertBooleanValue(value: unknown, inputValueType: CellValueType) {\n    switch (inputValueType) {\n      case CellValueType.Boolean:\n        return value;\n      case CellValueType.String:\n      case CellValueType.Number:\n      case CellValueType.DateTime:\n        return Boolean(value);\n    }\n  }\n\n  private convertNumberValue(value: unknown, inputValueType: CellValueType) {\n    switch (inputValueType) {\n      case CellValueType.Number:\n        return value;\n      case CellValueType.String: {\n        const number = Number(value);\n        if (Number.isNaN(number)) {\n          return null;\n        }\n        return number;\n      }\n      case CellValueType.Boolean:\n        return value ? 1 : 0;\n      case CellValueType.DateTime:\n        return null;\n    }\n  }\n\n  private convertStringValue(value: unknown, inputValueType: CellValueType) {\n    switch (inputValueType) {\n      case CellValueType.String:\n      case CellValueType.DateTime:\n        return value;\n      case CellValueType.Boolean:\n      case CellValueType.Number:\n        return String(value);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/typed-value.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport type { CellValueType } from '../models/field/constant';\nimport type { FieldCore } from '../models/field/field';\n\nexport class TypedValue<T = any> {\n  constructor(\n    public value: T,\n    public type: CellValueType,\n    public isMultiple?: boolean,\n    public field?: FieldCore,\n    public isBlank?: boolean\n  ) {}\n\n  toPlain(): any {\n    return this.value === false ? null : this.value;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/formula/visitor.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { DateFormattingPreset, TimeFormatting } from '../models';\nimport { CellValueType, DbFieldType, FieldType } from '../models/field/constant';\nimport {\n  LinkFieldCore,\n  FormulaFieldCore,\n  NumberFieldCore,\n  DateFieldCore,\n} from '../models/field/derivate';\nimport type { FieldCore } from '../models/field/field';\nimport type { IRecord } from '../models/record';\nimport { evaluate } from './evaluate';\n\ndescribe('EvalVisitor', () => {\n  let fieldContext: { [fieldId: string]: FieldCore } = {};\n  const record: IRecord = {\n    id: 'recTest',\n    fields: {\n      fldNumber: 8,\n      fldMultipleNumber: [1, 2, 3],\n      fldMultipleLink: [{ id: 'recxxxxxxx' }, { id: 'recyyyyyyy', title: 'A2' }],\n      fldDate: new Date('2024-01-01'),\n    },\n    createdTime: new Date().toISOString(),\n  };\n\n  beforeAll(() => {\n    const numberFieldJson = {\n      id: 'fldNumber',\n      name: 'fldNumberName',\n      description: 'A test number field',\n      type: FieldType.Number,\n      options: {\n        precision: 2,\n      },\n      cellValueType: CellValueType.Number,\n    };\n\n    const dateFieldJson = {\n      id: 'fldDate',\n      name: 'fldDateName',\n      description: 'A test date field',\n      type: FieldType.Date,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.ISO,\n          time: TimeFormatting.None,\n          timeZone: 'Asia/Shanghai',\n        },\n      },\n      cellValueType: CellValueType.DateTime,\n    };\n\n    const multipleNumberFieldJson = {\n      id: 'fldMultipleNumber',\n      name: 'fldMultipleNumberName',\n      description: 'A test number field',\n      type: FieldType.Number,\n      options: {\n        precision: 2,\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: true,\n    };\n\n    const multipleLinkFieldJson = {\n      id: 'fldMultipleLink',\n      name: 'fldMultipleLinkName',\n      description: 'A test number field',\n      type: FieldType.Link,\n      options: {\n        precision: 2,\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: true,\n    };\n\n    const numberField = plainToInstance(NumberFieldCore, numberFieldJson);\n    const multipleNumberField = plainToInstance(NumberFieldCore, multipleNumberFieldJson);\n    const multipleLinkField = plainToInstance(LinkFieldCore, multipleLinkFieldJson);\n    const dateField = plainToInstance(DateFieldCore, dateFieldJson);\n    fieldContext = {\n      [numberField.id]: numberField,\n      [multipleNumberField.id]: multipleNumberField,\n      [multipleLinkField.id]: multipleLinkField,\n      [dateField.id]: dateField,\n    };\n  });\n\n  const evalFormula = (\n    input: string,\n    fieldMap: { [fieldId: string]: FieldCore } = {},\n    record?: IRecord\n  ) => {\n    return evaluate(input, fieldMap, record).value;\n  };\n\n  it('integer literal', () => {\n    expect(evalFormula('42')).toBe(42);\n  });\n\n  it('decimal literal', () => {\n    expect(evalFormula('3.14')).toBeCloseTo(3.14);\n  });\n\n  it('single quoted string literal', () => {\n    expect(evalFormula(\"'hello world'\")).toBe('hello world');\n  });\n\n  it('double quoted string literal', () => {\n    expect(evalFormula('\"hello world\"')).toBe('hello world');\n  });\n\n  it('boolean literal true', () => {\n    expect(evalFormula('TRUE')).toBe(true);\n  });\n\n  it('boolean literal false', () => {\n    expect(evalFormula('FALSE')).toBe(false);\n  });\n\n  it('addition', () => {\n    const record: IRecord = {\n      id: 'recTest',\n      fields: {\n        fldMultipleNumber: [1],\n        fldMultipleLink: [{ id: 'recxxxxxxx' }, { id: 'recyyyyyyy', title: 'A2' }],\n      },\n      createdTime: new Date().toISOString(),\n    };\n\n    expect(evalFormula('1 + 2')).toBe(3);\n    expect(evalFormula('1 + {fldNumber}', fieldContext, record)).toBe(1);\n    expect(evalFormula('1 + {fldMultipleNumber}', fieldContext, record)).toBe(2);\n  });\n\n  it('unary operator', () => {\n    const record: IRecord = {\n      id: 'recTest',\n      fields: {\n        fldNumber: 3,\n        fldMultipleNumber: [1],\n        fldMultipleLink: [{ id: 'recxxxxxxx' }, { id: 'recyyyyyyy', title: 'A2' }],\n      },\n      createdTime: new Date().toISOString(),\n    };\n\n    expect(evalFormula('-1')).toBe(-1);\n    expect(evalFormula('-(1)')).toBe(-1);\n    expect(evalFormula('-{fldNumber}', fieldContext, record)).toBe(-3);\n    expect(evalFormula('-{fldMultipleNumber}', fieldContext, record)).toBe(-1);\n    expect(evalFormula('-{fldMultipleLink}', fieldContext, record)).toBe(null);\n  });\n\n  it('subtraction', () => {\n    expect(evalFormula('5 - 3')).toBe(2);\n    expect(evalFormula('5-3')).toBe(2);\n  });\n\n  it('multiplication', () => {\n    expect(evalFormula('3 * 4')).toBe(12);\n  });\n\n  it('division', () => {\n    expect(evalFormula('12 / 4')).toBe(3);\n    expect(evalFormula('12 / 0')).toBe(null);\n  });\n\n  it('mode', () => {\n    expect(evalFormula('8 % 3')).toBe(2);\n    expect(evalFormula('12 % 0')).toBe(null);\n  });\n\n  it('concat', () => {\n    expect(evalFormula('\"x\" & \"Y\"')).toBe('xY');\n  });\n\n  it('and', () => {\n    expect(evalFormula('true && true')).toBe(true);\n    expect(evalFormula('false && true')).toBe(false);\n  });\n\n  it('or', () => {\n    expect(evalFormula('true || false')).toBe(true);\n    expect(evalFormula('false && false')).toBe(false);\n  });\n\n  it('comparison', () => {\n    expect(evalFormula('1 < 2')).toBe(true);\n    expect(evalFormula('1 > 2')).toBe(false);\n    expect(evalFormula('2 <= 2')).toBe(true);\n    expect(evalFormula('2 >= 2')).toBe(true);\n    expect(evalFormula('1 = 1')).toBe(true);\n    expect(evalFormula('1 != 2')).toBe(true);\n  });\n\n  it('parentheses', () => {\n    expect(evalFormula('(3 + 5) * 2')).toBe(16);\n  });\n\n  it('whitespace and comments', () => {\n    expect(evalFormula(' 1 + 2 // inline comment')).toBe(3);\n    expect(evalFormula('/* block comment */1 + 2')).toBe(3);\n  });\n\n  it('field reference', () => {\n    expect(evalFormula('{fldNumber}', fieldContext, record)).toBe(8);\n    expect(evalFormula('{fldNumber} + 1', fieldContext, record)).toBe(9);\n  });\n\n  it('function call', () => {\n    expect(evalFormula('sum({fldNumber}, 1, 2, 3)', fieldContext, record)).toBe(14);\n  });\n\n  it('rollup call', () => {\n    const virtualField = {\n      id: 'values',\n      type: FieldType.Formula,\n      name: 'values',\n      description: 'A test text field',\n      notNull: true,\n      unique: true,\n      columnMeta: {\n        index: 0,\n        columnIndex: 0,\n      },\n      dbFieldType: DbFieldType.Text,\n      cellValueType: CellValueType.String,\n      isComputed: false,\n      isMultipleCellValue: true,\n    };\n\n    const result = evaluate(\n      'text_all({values})',\n      { values: plainToInstance(FormulaFieldCore, virtualField) },\n      { ...record, fields: { ...record.fields, values: ['CX, C2', 'C3'] } }\n    );\n    expect(result.toPlain()).toEqual(['CX, C2', 'C3']);\n  });\n\n  it('should throw exception', () => {\n    expect(() => evalFormula('{}', fieldContext, record)).toThrowError();\n  });\n\n  it('should calculate date field when value type is Date', () => {\n    expect(evalFormula('{fldDate}', fieldContext, record)).toEqual('2024-01-01');\n  });\n\n  it('should calculate multiple number field', () => {\n    expect(evalFormula('{fldMultipleNumber}', fieldContext, record)).toEqual([1, 2, 3]);\n  });\n\n  it('should calculate multiple link field', () => {\n    expect(evalFormula('{fldMultipleLink} & \"x\"', fieldContext, record)).toEqual(',A2x');\n  });\n\n  it('should return null when the value is false', () => {\n    const result = evaluate('1 > 2', {});\n    expect(result.toPlain()).toEqual(null);\n  });\n\n  it('should calculate string with escape characters', () => {\n    expect(evalFormula(\"'Hello\\nWorld'\")).toBe(`Hello\\nWorld`);\n    expect(evalFormula(\"'Hello\\rWorld'\")).toBe(`Hello\\rWorld`);\n    expect(evalFormula(\"'Hello\\bWorld'\")).toBe(`Hello\\bWorld`);\n    expect(evalFormula(\"'Hello\\fWorld'\")).toBe(`Hello\\fWorld`);\n    expect(evalFormula(\"'Hello\\vWorld'\")).toBe(`Hello\\vWorld`);\n    expect(evalFormula(\"'Hello\\tWorld'\")).toBe('Hello\\tWorld');\n    expect(evalFormula(\"'Hello\\\\World'\")).toBe('Hello\\\\World');\n    expect(evalFormula(\"'Hello\\\"World'\")).toBe('Hello\"World');\n  });\n});\n"
  },
  {
    "path": "packages/core/src/formula/visitor.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/no-non-null-assertion */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type {\n  BinaryOpContext,\n  BooleanLiteralContext,\n  BracketsContext,\n  DecimalLiteralContext,\n  FieldReferenceCurlyContext,\n  FunctionCallContext,\n  IntegerLiteralContext,\n  LeftWhitespaceOrCommentsContext,\n  RightWhitespaceOrCommentsContext,\n  RootContext,\n  StringLiteralContext,\n  UnaryOpContext,\n  FormulaVisitor,\n} from '@teable/formula';\nimport { extractFieldReferenceId } from '@teable/formula';\nimport { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor';\nimport { CellValueType } from '../models/field/constant';\nimport type { FieldCore } from '../models/field/field';\nimport type { IRecord } from '../models/record';\nimport { normalizeFunctionNameAlias } from './function-aliases';\nimport { FunctionName } from './functions/common';\nimport type { FormulaFunc } from './functions/common';\nimport { FUNCTIONS } from './functions/factory';\nimport { FormulaBaseError } from './functions/logical';\nimport { TypedValue } from './typed-value';\nimport { TypedValueConverter } from './typed-value-converter';\n\nconst formulaBaseError = new TypedValue(new FormulaBaseError(), CellValueType.String, false);\n\nexport class EvalVisitor\n  extends AbstractParseTreeVisitor<TypedValue>\n  implements FormulaVisitor<TypedValue>\n{\n  private readonly converter = new TypedValueConverter();\n  constructor(\n    private dependencies: { [fieldId: string]: FieldCore },\n    private record?: IRecord,\n    private timeZone = 'UTC'\n  ) {\n    super();\n  }\n\n  visitRoot(ctx: RootContext) {\n    return ctx.expr().accept(this);\n  }\n\n  visitStringLiteral(ctx: StringLiteralContext): any {\n    // Extract and return the string value without quotes\n    const quotedString = ctx.text;\n    const rawString = quotedString.slice(1, -1);\n    // Handle escape characters\n    const unescapedString = this.unescapeString(rawString);\n    return new TypedValue(unescapedString, CellValueType.String);\n  }\n\n  private unescapeString(str: string): string {\n    return str.replace(/\\\\(.)/g, (_, char) => {\n      switch (char) {\n        case 'n':\n          return '\\n';\n        case 'r':\n          return '\\r';\n        case 't':\n          return '\\t';\n        case 'b':\n          return '\\b';\n        case 'f':\n          return '\\f';\n        case 'v':\n          return '\\v';\n        case '\\\\':\n          return '\\\\';\n        case '\"':\n          return '\"';\n        case \"'\":\n          return \"'\";\n        default:\n          return '\\\\' + char;\n      }\n    });\n  }\n\n  visitIntegerLiteral(ctx: IntegerLiteralContext): any {\n    // Parse and return the integer value\n    const value = parseInt(ctx.text, 10);\n    return new TypedValue(value, CellValueType.Number);\n  }\n\n  visitDecimalLiteral(ctx: DecimalLiteralContext): any {\n    // Parse and return the decimal value\n    const value = parseFloat(ctx.text);\n    return new TypedValue(value, CellValueType.Number);\n  }\n\n  visitBooleanLiteral(ctx: BooleanLiteralContext): any {\n    // Parse and return the boolean value\n    const value = ctx.text.toUpperCase() === 'TRUE';\n    return new TypedValue(value, CellValueType.Boolean);\n  }\n\n  visitLeftWhitespaceOrComments(ctx: LeftWhitespaceOrCommentsContext): any {\n    return this.visit(ctx.expr());\n  }\n\n  visitRightWhitespaceOrComments(ctx: RightWhitespaceOrCommentsContext): any {\n    return this.visit(ctx.expr());\n  }\n\n  visitBrackets(ctx: BracketsContext): any {\n    return this.visit(ctx.expr());\n  }\n\n  private getBinaryOpValueType(\n    ctx: BinaryOpContext,\n    left: TypedValue,\n    right: TypedValue\n  ): CellValueType {\n    switch (true) {\n      case Boolean(ctx.PLUS()): {\n        if (left.type === CellValueType.Number && right.type === CellValueType.Number) {\n          return CellValueType.Number;\n        }\n\n        return CellValueType.String;\n      }\n\n      case Boolean(ctx.MINUS()):\n      case Boolean(ctx.STAR()):\n      case Boolean(ctx.PERCENT()):\n      case Boolean(ctx.SLASH()): {\n        return CellValueType.Number;\n      }\n\n      case Boolean(ctx.PIPE_PIPE()):\n      case Boolean(ctx.AMP_AMP()):\n      case Boolean(ctx.EQUAL()):\n      case Boolean(ctx.BANG_EQUAL()):\n      case Boolean(ctx.GT()):\n      case Boolean(ctx.GTE()):\n      case Boolean(ctx.LT()):\n      case Boolean(ctx.LTE()): {\n        return CellValueType.Boolean;\n      }\n\n      case Boolean(ctx.AMP()): {\n        return CellValueType.String;\n      }\n\n      default: {\n        throw new TypeError(`unknown operator: ${ctx.text}`);\n      }\n    }\n  }\n\n  private transformNodeValue(typedValue: TypedValue, ctx: BinaryOpContext) {\n    // A Node with a field value type requires dedicated string conversion logic to be executed.\n    if (!typedValue.field) {\n      return typedValue;\n    }\n\n    const field = typedValue.field;\n    const isComparisonOperator = [\n      ctx.EQUAL(),\n      ctx.BANG_EQUAL(),\n      ctx.LT(),\n      ctx.LTE(),\n      ctx.GT(),\n      ctx.GTE(),\n    ].some((op) => Boolean(op));\n\n    if (field.cellValueType === CellValueType.DateTime && isComparisonOperator) {\n      return typedValue;\n    }\n\n    if (field.isMultipleCellValue && field.cellValueType === CellValueType.Number) {\n      if (!typedValue.value?.length) return null;\n      if (typedValue.value.length > 1) {\n        throw new TypeError(\n          'Cannot perform mathematical calculations on an array with more than one numeric element.'\n        );\n      }\n      return new TypedValue(Number(typedValue.value[0]), CellValueType.Number);\n    }\n\n    if (\n      [CellValueType.Number, CellValueType.Boolean, CellValueType.String].includes(\n        field.cellValueType\n      )\n    ) {\n      return typedValue;\n    }\n\n    return new TypedValue(field.cellValue2String(typedValue.value), CellValueType.String);\n  }\n\n  private transformUnaryNodeValue(typedValue: TypedValue) {\n    if (!typedValue.field) {\n      return typedValue;\n    }\n\n    const { cellValueType, isMultipleCellValue } = typedValue.field;\n\n    if (cellValueType !== CellValueType.Number) return null;\n\n    if (isMultipleCellValue) {\n      if (!typedValue.value?.length) return null;\n      if (typedValue.value.length > 1) {\n        throw new TypeError(\n          'Cannot perform mathematical calculations on an array with more than one numeric element.'\n        );\n      }\n      return new TypedValue(Number(typedValue.value[0]), CellValueType.Number);\n    }\n    return typedValue;\n  }\n\n  visitUnaryOp(ctx: UnaryOpContext) {\n    const expr = ctx.expr();\n    const typedValue = this.visit(expr);\n    const value = this.transformUnaryNodeValue(typedValue)?.value ?? null;\n    return new TypedValue(value ? -value : null, CellValueType.Number);\n  }\n\n  visitBinaryOp(ctx: BinaryOpContext) {\n    const leftNode = ctx.expr(0);\n    const rightNode = ctx.expr(1);\n    const left = this.visit(leftNode)!;\n    const right = this.visit(rightNode)!;\n    const lv = this.transformNodeValue(left, ctx)?.value ?? null;\n    const rv = this.transformNodeValue(right, ctx)?.value ?? null;\n\n    const valueType = this.getBinaryOpValueType(ctx, left, right);\n    let value: any;\n    switch (true) {\n      case Boolean(ctx.STAR()): {\n        value = lv * rv;\n        break;\n      }\n      case Boolean(ctx.SLASH()): {\n        value = !rv ? null : lv / rv;\n        break;\n      }\n      case Boolean(ctx.PLUS()): {\n        if (valueType === CellValueType.Number) {\n          value = lv + rv;\n        } else {\n          const leftString = lv == null ? '' : lv;\n          const rightString = rv == null ? '' : rv;\n          value = String(leftString) + String(rightString);\n        }\n        break;\n      }\n      case Boolean(ctx.PERCENT()): {\n        value = !rv ? null : lv % rv;\n        break;\n      }\n      case Boolean(ctx.MINUS()): {\n        value = lv - rv;\n        break;\n      }\n      case Boolean(ctx.GT()): {\n        value = lv > rv;\n        break;\n      }\n      case Boolean(ctx.LT()): {\n        value = lv < rv;\n        break;\n      }\n      case Boolean(ctx.GTE()): {\n        value = lv >= rv;\n        break;\n      }\n      case Boolean(ctx.LTE()): {\n        value = lv <= rv;\n        break;\n      }\n      case Boolean(ctx.EQUAL()): {\n        value = this.areValuesEqual(left, right, lv, rv);\n        break;\n      }\n      case Boolean(ctx.BANG_EQUAL()): {\n        value = this.areValuesNotEqual(left, right, lv, rv);\n        break;\n      }\n      case Boolean(ctx.AMP()): {\n        value = String(lv == null ? '' : lv) + String(rv == null ? '' : rv);\n        break;\n      }\n      case Boolean(ctx.AMP_AMP()): {\n        value = lv && rv;\n        break;\n      }\n      case Boolean(ctx.PIPE_PIPE()): {\n        value = lv || rv;\n        break;\n      }\n      default:\n        throw new Error(`Unsupported binary operation: ${ctx.text}`);\n    }\n    return new TypedValue(value, valueType);\n  }\n\n  private areValuesEqual(\n    leftTypedValue: TypedValue,\n    rightTypedValue: TypedValue,\n    leftValue: unknown,\n    rightValue: unknown\n  ) {\n    const normalized = this.normalizeEqualityValues(\n      leftTypedValue,\n      rightTypedValue,\n      leftValue,\n      rightValue\n    );\n    return normalized.left == normalized.right;\n  }\n\n  private areValuesNotEqual(\n    leftTypedValue: TypedValue,\n    rightTypedValue: TypedValue,\n    leftValue: unknown,\n    rightValue: unknown\n  ) {\n    const { left: normalizedLeft, right: normalizedRight } = this.normalizeEqualityValues(\n      leftTypedValue,\n      rightTypedValue,\n      leftValue,\n      rightValue\n    );\n\n    return normalizedLeft != normalizedRight;\n  }\n\n  private normalizeEqualityValues(\n    leftTypedValue: TypedValue,\n    rightTypedValue: TypedValue,\n    leftValue: unknown,\n    rightValue: unknown\n  ) {\n    if (!this.shouldNormalizeBlankEquality(leftTypedValue, rightTypedValue)) {\n      return {\n        left: leftValue,\n        right: rightValue,\n      };\n    }\n\n    return {\n      left: this.normalizeBlankEqualityValue(leftTypedValue, leftValue),\n      right: this.normalizeBlankEqualityValue(rightTypedValue, rightValue),\n    };\n  }\n\n  private shouldNormalizeBlankEquality(\n    leftTypedValue: TypedValue,\n    rightTypedValue: TypedValue\n  ): boolean {\n    return (\n      this.isStringLikeTypedValue(leftTypedValue) ||\n      this.isStringLikeTypedValue(rightTypedValue) ||\n      this.isNumericLikeTypedValue(leftTypedValue) ||\n      this.isNumericLikeTypedValue(rightTypedValue)\n    );\n  }\n\n  private normalizeBlankEqualityValue(typedValue: TypedValue, value: unknown) {\n    if (value == null && this.isStringLikeTypedValue(typedValue)) {\n      return '';\n    }\n\n    if (value == null && this.isNumericLikeTypedValue(typedValue)) {\n      return 0;\n    }\n\n    return value;\n  }\n\n  private isStringLikeTypedValue(typedValue: TypedValue): boolean {\n    if (typedValue.type === CellValueType.String) {\n      return true;\n    }\n\n    if (typedValue.field?.cellValueType === CellValueType.String) {\n      return true;\n    }\n\n    return false;\n  }\n\n  private isNumericLikeTypedValue(typedValue: TypedValue): boolean {\n    if (typedValue.type === CellValueType.Number) {\n      return true;\n    }\n\n    if (typedValue.field?.cellValueType === CellValueType.Number) {\n      return true;\n    }\n\n    return false;\n  }\n\n  private createTypedValueByField(field: FieldCore) {\n    let value: any = this.record ? this.record.fields[field.id] : null;\n\n    if (\n      value == null ||\n      ![CellValueType.String, CellValueType.DateTime].includes(field.cellValueType)\n    ) {\n      return new TypedValue(value, field.cellValueType, field.isMultipleCellValue, field);\n    }\n\n    // some field like link or attachment may contain json object cellValue, that need to be converted to string.\n    if (field.isMultipleCellValue && value[0] && typeof value[0] === 'object') {\n      value = value.map((v: object) => (field.item2String ? field.item2String(v) : v));\n    }\n\n    if (!field.isMultipleCellValue && typeof value === 'object') {\n      value = field.cellValue2String(value);\n    }\n    return new TypedValue(value, field.cellValueType, field.isMultipleCellValue, field);\n  }\n\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext) {\n    const fieldId = extractFieldReferenceId(ctx);\n    if (!fieldId) {\n      throw new Error('FieldId {} is a invalid field id');\n    }\n\n    const field = this.dependencies[fieldId];\n    if (!field) {\n      throw new Error(`FieldId ${fieldId} is a invalid field id`);\n    }\n    return this.createTypedValueByField(field);\n  }\n\n  /**\n   * transform typed value into function accept value type as possible as it can\n   */\n  private transformTypedValue(typedValue: TypedValue, func: FormulaFunc): TypedValue {\n    return this.converter.convertTypedValue(typedValue, func);\n  }\n\n  visitFunctionCall(ctx: FunctionCallContext) {\n    const rawName = ctx.func_name().text.toUpperCase();\n    const normalized = normalizeFunctionNameAlias(rawName) as FunctionName;\n    const fnName = normalized;\n    const func = FUNCTIONS[fnName];\n    if (!func) {\n      throw new TypeError(`Function name ${rawName} is not found`);\n    }\n\n    if (fnName === FunctionName.Blank) {\n      return new TypedValue(null, CellValueType.String, false, undefined, true);\n    }\n\n    let params;\n\n    try {\n      params = ctx.expr().map((exprCtx) => {\n        const typedValue = this.visit(exprCtx);\n        return this.transformTypedValue(typedValue, func);\n      });\n    } catch (e) {\n      if (fnName !== FunctionName.IsError) throw e;\n      params = [formulaBaseError];\n    }\n\n    const { type, isMultiple } = func.getReturnType(params as TypedValue<any>[]);\n\n    if (!this.record) {\n      return new TypedValue(null, type, isMultiple);\n    }\n\n    const value = func.eval(params as TypedValue<any>[], {\n      record: this.record,\n      dependencies: this.dependencies,\n      timeZone: this.timeZone,\n    });\n    return new TypedValue(value, type, isMultiple);\n  }\n\n  protected defaultResult() {\n    return new TypedValue(null, CellValueType.String);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/index.ts",
    "content": "import { extend } from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\n\nextend(utc);\nextend(timezone);\n\nexport * from './types';\nexport * from './array';\nexport * from './asserts';\nexport * from './convert';\nexport * from './models';\nexport * from './utils';\nexport * from './op-builder';\nexport * from './formula';\nexport * from './query';\nexport * from './errors';\nexport * from './auth';\n"
  },
  {
    "path": "packages/core/src/models/aggregation/index.ts",
    "content": "export * from './statistics-func.enum';\nexport * from './statistic';\n"
  },
  {
    "path": "packages/core/src/models/aggregation/statistic.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CellValueType, FieldType } from '../field';\nimport { getValidStatisticFunc } from './statistic';\nimport { StatisticsFunc } from './statistics-func.enum';\n\ndescribe('getValidStatisticFunc', () => {\n  it('should return an empty array if no field is provided', () => {\n    const result = getValidStatisticFunc();\n    expect(result).toEqual([]);\n  });\n\n  it('should return the correct statistics functions for a link field', () => {\n    const field: any = {\n      cellValueType: CellValueType.String,\n      type: FieldType.Link,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a user/createdBy/lastModifiedBy field', () => {\n    const field: any = {\n      type: FieldType.User,\n      isMultipleCellValue: false,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.Unique,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentUnique,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a user/createdBy/lastModifiedBy field with multipleCellValue', () => {\n    const field: any = {\n      type: FieldType.User,\n      isMultipleCellValue: true,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a string field', () => {\n    const field: any = {\n      cellValueType: CellValueType.String,\n      type: FieldType.SingleLineText,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.Unique,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentUnique,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a number field', () => {\n    const field: any = {\n      cellValueType: CellValueType.Number,\n      type: FieldType.Number,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Sum,\n      StatisticsFunc.Average,\n      StatisticsFunc.Min,\n      StatisticsFunc.Max,\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.Unique,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentUnique,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a dateTime field', () => {\n    const field: any = {\n      cellValueType: CellValueType.DateTime,\n      type: FieldType.Date,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.Unique,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.PercentUnique,\n      StatisticsFunc.EarliestDate,\n      StatisticsFunc.LatestDate,\n      StatisticsFunc.DateRangeOfDays,\n      StatisticsFunc.DateRangeOfMonths,\n    ]);\n  });\n\n  it('should return the correct statistics functions for a boolean field', () => {\n    const field: any = {\n      cellValueType: CellValueType.Boolean,\n      type: FieldType.Checkbox,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Checked,\n      StatisticsFunc.UnChecked,\n      StatisticsFunc.PercentChecked,\n      StatisticsFunc.PercentUnChecked,\n    ]);\n  });\n\n  it('should include TotalAttachmentSize statistics function for an attachment field', () => {\n    const field: any = {\n      cellValueType: CellValueType.String,\n      type: FieldType.Attachment,\n    };\n    const result = getValidStatisticFunc(field);\n    expect(result).toEqual([\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n      StatisticsFunc.TotalAttachmentSize,\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/aggregation/statistic.ts",
    "content": "import { pullAll } from 'lodash';\nimport { CellValueType, FieldType } from '../field';\nimport { StatisticsFunc } from './statistics-func.enum';\n\nexport const getValidStatisticFunc = (field?: {\n  type: FieldType;\n  cellValueType: CellValueType;\n  isMultipleCellValue?: boolean;\n}): StatisticsFunc[] => {\n  let statisticSet: StatisticsFunc[] = [];\n  if (!field) {\n    return statisticSet;\n  }\n\n  const { type, cellValueType, isMultipleCellValue } = field;\n\n  if (type === FieldType.Link) {\n    statisticSet = [\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n    ];\n    return statisticSet;\n  }\n\n  if ([FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type)) {\n    statisticSet = [\n      StatisticsFunc.Count,\n      StatisticsFunc.Empty,\n      StatisticsFunc.Filled,\n      StatisticsFunc.PercentEmpty,\n      StatisticsFunc.PercentFilled,\n    ];\n    if (!isMultipleCellValue) {\n      statisticSet.splice(3, 0, StatisticsFunc.Unique);\n      statisticSet.push(StatisticsFunc.PercentUnique);\n    }\n    return statisticSet;\n  }\n\n  switch (cellValueType) {\n    case CellValueType.String: {\n      statisticSet = [\n        StatisticsFunc.Count,\n        StatisticsFunc.Empty,\n        StatisticsFunc.Filled,\n        StatisticsFunc.Unique,\n        StatisticsFunc.PercentEmpty,\n        StatisticsFunc.PercentFilled,\n        StatisticsFunc.PercentUnique,\n      ];\n      break;\n    }\n    case CellValueType.Number: {\n      statisticSet = [\n        StatisticsFunc.Sum,\n        StatisticsFunc.Average,\n        StatisticsFunc.Min,\n        StatisticsFunc.Max,\n        StatisticsFunc.Count,\n        StatisticsFunc.Empty,\n        StatisticsFunc.Filled,\n        StatisticsFunc.Unique,\n        StatisticsFunc.PercentEmpty,\n        StatisticsFunc.PercentFilled,\n        StatisticsFunc.PercentUnique,\n      ];\n      break;\n    }\n    case CellValueType.DateTime: {\n      statisticSet = [\n        StatisticsFunc.Count,\n        StatisticsFunc.Empty,\n        StatisticsFunc.Filled,\n        StatisticsFunc.Unique,\n        StatisticsFunc.PercentEmpty,\n        StatisticsFunc.PercentFilled,\n        StatisticsFunc.PercentUnique,\n        StatisticsFunc.EarliestDate,\n        StatisticsFunc.LatestDate,\n        StatisticsFunc.DateRangeOfDays,\n        StatisticsFunc.DateRangeOfMonths,\n      ];\n      break;\n    }\n    case CellValueType.Boolean: {\n      statisticSet = [\n        StatisticsFunc.Count,\n        StatisticsFunc.Checked,\n        StatisticsFunc.UnChecked,\n        StatisticsFunc.PercentChecked,\n        StatisticsFunc.PercentUnChecked,\n      ];\n      break;\n    }\n  }\n\n  if (type === FieldType.Attachment) {\n    pullAll(statisticSet, [StatisticsFunc.Unique, StatisticsFunc.PercentUnique]);\n    statisticSet.push(StatisticsFunc.TotalAttachmentSize);\n  }\n  return statisticSet;\n};\n"
  },
  {
    "path": "packages/core/src/models/aggregation/statistics-func.enum.ts",
    "content": "export enum StatisticsFunc {\n  Count = 'count',\n  Empty = 'empty',\n  Filled = 'filled',\n  Unique = 'unique',\n  Max = 'max',\n  Min = 'min',\n  Sum = 'sum',\n  Average = 'average',\n  Checked = 'checked',\n  UnChecked = 'unChecked',\n  PercentEmpty = 'percentEmpty',\n  PercentFilled = 'percentFilled',\n  PercentUnique = 'percentUnique',\n  PercentChecked = 'percentChecked',\n  PercentUnChecked = 'percentUnChecked',\n  EarliestDate = 'earliestDate',\n  LatestDate = 'latestDate',\n  DateRangeOfDays = 'dateRangeOfDays',\n  DateRangeOfMonths = 'dateRangeOfMonths',\n  TotalAttachmentSize = 'totalAttachmentSize',\n}\n\nexport enum NoneFunc {\n  None = 'none',\n}\n"
  },
  {
    "path": "packages/core/src/models/channel.ts",
    "content": "export function getCollaboratorsChannel(tableId: string) {\n  return `__col_user_${tableId}`;\n}\n\nexport function getCellCollaboratorsChannel(tableId: string) {\n  return `__col_cell_user_${tableId}`;\n}\n\nexport function getUserNotificationChannel(userId: string) {\n  return `__notification_user_${userId}`;\n}\n\nexport function getActionTriggerChannel(tableIdOrViewId: string) {\n  return `__action_trigger_${tableIdOrViewId}`;\n}\n\nexport function getBasePermissionUpdateChannel(baseId: string) {\n  return `__base_permission_update_${baseId}`;\n}\n\nexport function getTableImportChannel(tableId: string) {\n  return `__table_import_${tableId}`;\n}\n\nexport function getCommentChannel(tableId: string, recordId: string) {\n  return `__record_comment_${tableId}_${recordId}`;\n}\n\nexport function getTableCommentChannel(tableId: string) {\n  return `__table_comment_${tableId}`;\n}\n\nexport function getTableButtonClickChannel(tableId: string) {\n  return `__table_button_click_${tableId}`;\n}\n\nexport function getToolCallChannel(toolCallId: string) {\n  return `__tool_call_${toolCallId}`;\n}\n\nexport function getChatChannel(chatId: string) {\n  return `__chat_${chatId}`;\n}\n\nexport function getBaseNodeChannel(baseId: string) {\n  return `__base_node_${baseId}`;\n}\n\nexport function getWorkflowTestChannel(workflowId: string) {\n  return `__workflow_test_${workflowId}`;\n}\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/attachment.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { commonFieldAIConfig, FieldAIActionType } from './text';\n\nexport enum ImageQuality {\n  Low = 'low',\n  Medium = 'medium',\n  High = 'high',\n}\n\n// Resolution presets for multimodal LLMs (controls image quality via prompt)\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const IMAGE_RESOLUTIONS = ['1K', '2K', '4K'] as const;\n\nexport type IImageResolution = (typeof IMAGE_RESOLUTIONS)[number];\n\n// Resolution to approximate pixel dimensions mapping (for prompt generation)\n/* eslint-disable @typescript-eslint/naming-convention */\nexport const RESOLUTION_PIXEL_MAP: Record<IImageResolution, number> = {\n  '1K': 1024,\n  '2K': 2048,\n  '4K': 4096,\n};\n/* eslint-enable @typescript-eslint/naming-convention */\n\n// Common aspect ratios for image generation (for multimodal LLMs that use prompt-based control)\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const IMAGE_ASPECT_RATIOS = [\n  '1:1',\n  '16:9',\n  '9:16',\n  '4:3',\n  '3:4',\n  '21:9',\n  '2:3',\n  '3:2',\n] as const;\n\nexport type IImageAspectRatio = (typeof IMAGE_ASPECT_RATIOS)[number];\n\nexport const attachmentFieldAIConfigBaseSchema = commonFieldAIConfig.extend({\n  n: z.number().min(1).max(10).optional(),\n  size: z\n    .string()\n    .regex(/^\\d+x\\d+$/, { message: 'Size must be in \"widthxheight\" format, e.g., \"1024x1024\"' })\n    .optional(),\n  quality: z.enum(ImageQuality).optional(),\n  // Aspect ratio for multimodal LLMs (Gemini, etc.) - injected into prompt\n  aspectRatio: z\n    .string()\n    .regex(/^\\d+:\\d+$/, { message: 'Aspect ratio must be in \"width:height\" format, e.g., \"16:9\"' })\n    .optional(),\n  // Resolution for multimodal LLMs (1K, 2K, 4K) - injected into prompt\n  resolution: z.enum(IMAGE_RESOLUTIONS).optional(),\n});\n\nexport const attachmentFieldGenerateImageAIConfigSchema = attachmentFieldAIConfigBaseSchema.extend({\n  type: z.literal(FieldAIActionType.ImageGeneration),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type IAttachmentFieldGenerateImageAIConfig = z.infer<\n  typeof attachmentFieldGenerateImageAIConfigSchema\n>;\n\nexport const attachmentFieldCustomizeAIConfigSchema = attachmentFieldAIConfigBaseSchema.extend({\n  type: z.literal(FieldAIActionType.ImageCustomization),\n  prompt: z.string(),\n});\n\nexport type IAttachmentFieldCustomizeAIConfig = z.infer<\n  typeof attachmentFieldCustomizeAIConfigSchema\n>;\n\nexport const attachmentFieldAIConfigSchema = z.discriminatedUnion('type', [\n  attachmentFieldGenerateImageAIConfigSchema,\n  attachmentFieldCustomizeAIConfigSchema,\n]);\n\nexport type IAttachmentFieldAIConfig = z.infer<typeof attachmentFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/date.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { commonFieldAIConfig, FieldAIActionType } from './text';\n\nexport const dateFieldExtractionAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Extraction),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type IDateFieldExtractionAIConfig = z.infer<typeof dateFieldExtractionAIConfigSchema>;\n\nexport const dateFieldCustomizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Customization),\n  prompt: z.string(),\n});\n\nexport type IDateFieldCustomizeAIConfig = z.infer<typeof dateFieldCustomizeAIConfigSchema>;\n\nexport const dateFieldAIConfigSchema = z.discriminatedUnion('type', [\n  dateFieldExtractionAIConfigSchema,\n  dateFieldCustomizeAIConfigSchema,\n]);\n\nexport type IDateFieldAIConfig = z.infer<typeof dateFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/index.ts",
    "content": "import { z } from 'zod';\nimport { FieldType } from '../constant';\nimport { attachmentFieldAIConfigSchema } from './attachment';\nimport { dateFieldAIConfigSchema } from './date';\nimport { multipleSelectFieldAIConfigSchema } from './multiple-select';\nimport { ratingFieldAIConfigSchema } from './rating';\nimport { singleSelectFieldAIConfigSchema } from './single-select';\nimport { textFieldAIConfigSchema } from './text';\n\nexport * from './text';\nexport * from './single-select';\nexport * from './multiple-select';\nexport * from './attachment';\nexport * from './rating';\nexport * from './date';\nexport const fieldAIConfigSchema = z.union([\n  textFieldAIConfigSchema,\n  singleSelectFieldAIConfigSchema,\n  multipleSelectFieldAIConfigSchema,\n  attachmentFieldAIConfigSchema,\n  ratingFieldAIConfigSchema,\n  dateFieldAIConfigSchema,\n]);\n\nexport type IFieldAIConfig = z.infer<typeof fieldAIConfigSchema>;\n\nexport const getAiConfigSchema = (type: FieldType) => {\n  switch (type) {\n    case FieldType.SingleLineText:\n    case FieldType.LongText:\n      return textFieldAIConfigSchema;\n    case FieldType.SingleSelect:\n      return singleSelectFieldAIConfigSchema;\n    case FieldType.MultipleSelect:\n      return multipleSelectFieldAIConfigSchema;\n    case FieldType.Attachment:\n      return attachmentFieldAIConfigSchema;\n    case FieldType.Rating:\n    case FieldType.Number:\n      return ratingFieldAIConfigSchema;\n    case FieldType.Date:\n      return dateFieldAIConfigSchema;\n    default:\n      return z.undefined();\n  }\n};\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/multiple-select.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { commonFieldAIConfig, FieldAIActionType } from './text';\n\nexport const multipleSelectFieldTagAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Tag),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type IMultipleSelectFieldTagAIConfig = z.infer<typeof multipleSelectFieldTagAIConfigSchema>;\n\nexport const multipleSelectFieldCustomizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Customization),\n  prompt: z.string(),\n  onlyAllowConfiguredOptions: z.boolean().optional(),\n});\n\nexport type IMultipleSelectFieldCustomizeAIConfig = z.infer<\n  typeof multipleSelectFieldCustomizeAIConfigSchema\n>;\n\nexport const multipleSelectFieldAIConfigSchema = z.discriminatedUnion('type', [\n  multipleSelectFieldTagAIConfigSchema,\n  multipleSelectFieldCustomizeAIConfigSchema,\n]);\n\nexport type IMultipleSelectFieldAIConfig = z.infer<typeof multipleSelectFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/rating.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { commonFieldAIConfig, FieldAIActionType } from './text';\n\nexport const ratingFieldRatingAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Rating),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type IRatingFieldRatingAIConfig = z.infer<typeof ratingFieldRatingAIConfigSchema>;\n\nexport const ratingFieldCustomizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Customization),\n  prompt: z.string(),\n});\n\nexport type IRatingFieldCustomizeAIConfig = z.infer<typeof ratingFieldCustomizeAIConfigSchema>;\n\nexport const ratingFieldAIConfigSchema = z.discriminatedUnion('type', [\n  ratingFieldRatingAIConfigSchema,\n  ratingFieldCustomizeAIConfigSchema,\n]);\n\nexport type IRatingFieldAIConfig = z.infer<typeof ratingFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/single-select.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { commonFieldAIConfig, FieldAIActionType } from './text';\n\nexport const singleSelectFieldClassifyAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Classification),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type ISingleSelectFieldClassifyAIConfig = z.infer<\n  typeof singleSelectFieldClassifyAIConfigSchema\n>;\n\nexport const singleSelectFieldCustomizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Customization),\n  prompt: z.string(),\n  onlyAllowConfiguredOptions: z.boolean().optional(),\n});\n\nexport type ISingleSelectFieldCustomizeAIConfig = z.infer<\n  typeof singleSelectFieldCustomizeAIConfigSchema\n>;\n\nexport const singleSelectFieldAIConfigSchema = z.discriminatedUnion('type', [\n  singleSelectFieldClassifyAIConfigSchema,\n  singleSelectFieldCustomizeAIConfigSchema,\n]);\n\nexport type ISingleSelectFieldAIConfig = z.infer<typeof singleSelectFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/ai-config/text.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\n\nexport enum FieldAIActionType {\n  Summary = 'summary',\n  Translation = 'translation',\n  Improvement = 'improvement',\n  Extraction = 'extraction',\n  Classification = 'classification',\n  Tag = 'tag',\n  Customization = 'customization',\n  ImageGeneration = 'imageGeneration',\n  ImageCustomization = 'imageCustomization',\n  Rating = 'rating',\n}\n\nexport const commonFieldAIConfig = z.object({\n  modelKey: z.string(),\n  isAutoFill: z.boolean().nullable().optional(),\n  attachPrompt: z.string().optional(),\n});\n\nexport type ICommonFieldAIConfig = z.infer<typeof commonFieldAIConfig>;\n\nexport const textFieldExtractInfoAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Extraction),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type ITextFieldExtractInfoAIConfig = z.infer<typeof textFieldExtractInfoAIConfigSchema>;\n\nexport const textFieldSummarizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Summary),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type ITextFieldSummarizeAIConfig = z.infer<typeof textFieldSummarizeAIConfigSchema>;\n\nexport const textFieldTranslateAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Translation),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n  targetLanguage: z.string(),\n});\n\nexport type ITextFieldTranslateAIConfig = z.infer<typeof textFieldTranslateAIConfigSchema>;\n\nexport const textFieldImproveTextAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Improvement),\n  sourceFieldId: z.string().startsWith(IdPrefix.Field),\n});\n\nexport type ITextFieldImproveTextAIConfig = z.infer<typeof textFieldImproveTextAIConfigSchema>;\n\nexport const textFieldCustomizeAIConfigSchema = commonFieldAIConfig.extend({\n  type: z.literal(FieldAIActionType.Customization),\n  prompt: z\n    .string()\n    .describe(\n      `The prompt to use for the AI operation, use {fieldId} to reference the field in the table, example: \"Summarize the content of {fieldId} into 100 words\"\\n`\n    ),\n});\n\nexport type ITextFieldCustomizeAIConfig = z.infer<typeof textFieldCustomizeAIConfigSchema>;\n\nexport const textFieldAIConfigSchema = z.discriminatedUnion('type', [\n  textFieldExtractInfoAIConfigSchema,\n  textFieldSummarizeAIConfigSchema,\n  textFieldTranslateAIConfigSchema,\n  textFieldImproveTextAIConfigSchema,\n  textFieldCustomizeAIConfigSchema,\n]);\n\nexport type ITextFieldAIConfig = z.infer<typeof textFieldAIConfigSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/button-utils.ts",
    "content": "import type { IButtonFieldCellValue } from './derivate';\nimport type { IButtonFieldOptions } from './derivate/button-option.schema';\n\nexport const checkButtonClickable = (\n  fieldOptions: IButtonFieldOptions,\n  cellValue?: IButtonFieldCellValue\n) => {\n  const workflow = fieldOptions.workflow;\n  if (!workflow) {\n    return false;\n  }\n  const { id: workflowId, isActive = false } = workflow;\n  if (!workflowId || !isActive) {\n    return false;\n  }\n  const maxCount = fieldOptions.maxCount || 0;\n  if (maxCount <= 0) {\n    return true;\n  }\n  const count = cellValue?.count || 0;\n  return count < maxCount;\n};\n"
  },
  {
    "path": "packages/core/src/models/field/cell-value-validation.ts",
    "content": "import { z } from 'zod';\nimport { assertNever } from '../../asserts';\nimport { FieldType } from './constant';\nimport {\n  attachmentCellValueSchema,\n  autoNumberCellValueSchema,\n  buttonFieldCelValueSchema,\n  dataFieldCellValueSchema,\n  getFormulaCellValueSchema,\n  linkCellValueSchema,\n  numberCellValueSchema,\n  singleLineTextCelValueSchema,\n  userCellValueSchema,\n} from './derivate';\nimport type { IFieldVo } from './field.schema';\n\nconst validateWithSchema = (schema: z.ZodType, value: unknown) => {\n  return z\n    .union([z.array(schema).nonempty(), schema])\n    .nullable()\n    .safeParse(value);\n};\n\nexport const validateCellValue = (field: IFieldVo, cellValue: unknown) => {\n  const { type, cellValueType } = field;\n\n  switch (type) {\n    case FieldType.LongText:\n    case FieldType.SingleLineText:\n    case FieldType.SingleSelect:\n    case FieldType.MultipleSelect:\n      return validateWithSchema(singleLineTextCelValueSchema, cellValue);\n    case FieldType.Number:\n      return validateWithSchema(numberCellValueSchema, cellValue);\n    case FieldType.Rating:\n    case FieldType.AutoNumber:\n      return validateWithSchema(autoNumberCellValueSchema, cellValue);\n    case FieldType.Attachment:\n      return attachmentCellValueSchema.nonempty().nullable().safeParse(cellValue);\n    case FieldType.Date:\n    case FieldType.CreatedTime:\n    case FieldType.LastModifiedTime:\n      return validateWithSchema(dataFieldCellValueSchema, cellValue);\n    case FieldType.Checkbox:\n      return validateWithSchema(z.literal(true), cellValue);\n    case FieldType.Link:\n      return validateWithSchema(linkCellValueSchema, cellValue);\n    case FieldType.User:\n    case FieldType.CreatedBy:\n    case FieldType.LastModifiedBy:\n      return validateWithSchema(userCellValueSchema, cellValue);\n    case FieldType.Rollup:\n    case FieldType.ConditionalRollup:\n    case FieldType.Formula: {\n      const schema = getFormulaCellValueSchema(cellValueType);\n      return validateWithSchema(schema, cellValue);\n    }\n    case FieldType.Button:\n      return validateWithSchema(buttonFieldCelValueSchema, cellValue);\n    default:\n      assertNever(type);\n  }\n};\n\nexport const validateDateFieldValueLoose = (cellValue: unknown, isMultipleCellValue?: boolean) => {\n  if (isMultipleCellValue) {\n    return z.array(z.string()).nonempty().nullable().safeParse(cellValue);\n  }\n  return z.string().nullable().safeParse(cellValue);\n};\n"
  },
  {
    "path": "packages/core/src/models/field/color-utils.spec.ts",
    "content": "import { ColorUtils } from './color-utils';\nimport { Colors } from './colors';\n\ndescribe('randomColor', () => {\n  it('should return a single color when num is not provided', () => {\n    const result = ColorUtils.randomColor();\n    expect(result).toHaveLength(1);\n    expect(Object.values(Colors)).toContain(result[0]);\n  });\n\n  it('should return unique colors when multiple are requested', () => {\n    const result = ColorUtils.randomColor(undefined, 5);\n    const uniqueColors = new Set(result);\n    expect(result).toHaveLength(5);\n    expect(uniqueColors.size).toBe(5);\n  });\n\n  it('should not return colors from the exists array', () => {\n    const existingColors = [Colors.Red, Colors.Blue];\n    const result = ColorUtils.randomColor(existingColors, 5);\n    for (const color of existingColors) {\n      expect(result).not.toContain(color);\n    }\n  });\n\n  it('should return random colors from all available when \"exists\" excludes all', () => {\n    const existingColors = Object.values(Colors);\n    const result = ColorUtils.randomColor(existingColors, 5);\n    for (const color of result) {\n      expect(existingColors).toContain(color);\n    }\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/color-utils.ts",
    "content": "/** @module @teable/sdk: colorUtils */ /** */\nimport Color from 'color';\nimport { getEnumValueIfExists, has } from '../../utils/enum';\nimport { Colors, rgbTuplesByColor } from './colors';\n\n/** A red/green/blue color object. Each property is a number from 0 to 255. */\ninterface IRGB {\n  /** The red component. */\n  r: number;\n  /** The green component. */\n  g: number;\n  /** The blue component. */\n  b: number;\n}\n\n/**\n * Utilities for working with {@link Colors} names from the {@link colors} enum.\n *\n * @docsPath UI/utils/colorUtils\n */\nexport interface IColorUtils {\n  getHexForColor(colorString: Colors): string;\n  /** */\n  getHexForColor(colorString: string): null | string;\n\n  /**\n   * Given a {@link Colors}, return an {@link IRGB} object representing it, or null if the value isn't a {@link Colors}\n   *\n   * @param colorString\n   * @example\n   * ```js\n   * import {colorUtils, colors} from '@teable/sdk';\n   *\n   * colorUtils.getRgbForColor(colors.PURPLE_DARK_1);\n   * // => {r: 107, g: 28, b: 176}\n   *\n   * colorUtils.getRgbForColor('disgruntled pink');\n   * // => null\n   * ```\n   */\n  getRgbForColor(colorString: Colors): IRGB;\n  /** */\n  getRgbForColor(colorString: string): IRGB | null;\n\n  /**\n   * Given a {@link Colors} and alpha, return an string representing it, or null if the value isn't a {@link Colors}\n   *\n   * @param colorString\n   * @param alpha\n   * @example\n   * ```js\n   * import {colorUtils, colors} from '@teable/sdk';\n   *\n   * colorUtils.getRgbForColor(colors.PURPLE_DARK_1, 0.5);\n   * // => rgba(107, 28, 176, 0.5)\n   *\n   * colorUtils.getRgbForColor('disgruntled pink');\n   * // => null\n   * ```\n   */\n  getRgbaStringForColor(colorString: string, alpha?: number): string | null;\n\n  /**\n   * Given a {@link Colors}, returns true or false to indicate whether that color should have light text on top of it when used as a background color.\n   *\n   * @param colorString\n   * @example\n   * ```js\n   * import {colorUtils, colors} from '@teable/sdk';\n   *\n   * colorUtils.shouldUseLightTextOnColor(colors.PINK_LIGHT_1);\n   * // => false\n   *\n   * colorUtils.shouldUseLightTextOnColor(colors.PINK_DARK_1);\n   * // => true\n   * ```\n   */\n  shouldUseLightTextOnColor(colorString: string): boolean;\n\n  /**\n   * Random color string.\n   * @param exists Filter existed color\n   * @param num Number of random color\n   * @returns color string array\n   */\n  randomColor(exists?: string[], num?: number): Colors[];\n\n  /**\n   * Randomly (but consistently) pick a hex from a map based on a string\n   * @param str input string\n   */\n  getRandomHexFromStr(str: string, theme?: 'light' | 'dark'): string;\n\n  /**\n   * Randomly (but consistently) pick a color from a map based on a string\n   * @param str input string\n   */\n  getRandomColorFromStr(str: string): Colors;\n}\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const ColorUtils: IColorUtils = {\n  getHexForColor: ((colorString: string): null | string => {\n    const color = getEnumValueIfExists(Colors, colorString);\n    if (!color) {\n      return null;\n    }\n    const rgbTuple = rgbTuplesByColor[color];\n\n    const hexNumber = (rgbTuple[0] << 16) | (rgbTuple[1] << 8) | rgbTuple[2];\n    return `#${hexNumber.toString(16).padStart(6, '0')}`;\n  }) as IColorUtils['getHexForColor'],\n\n  getRgbForColor: ((colorString: string): IRGB | null => {\n    const color = getEnumValueIfExists(Colors, colorString);\n    if (!color) {\n      return null;\n    }\n    const rgbTuple = rgbTuplesByColor[color];\n    return { r: rgbTuple[0], g: rgbTuple[1], b: rgbTuple[2] };\n  }) as IColorUtils['getRgbForColor'],\n\n  getRgbaStringForColor: ((colorString: string, alpha = 1): string | null => {\n    const { r, g, b } = ColorUtils.getRgbForColor(colorString) || {};\n    if (r == null || g == null || b == null) return null;\n    return `rgba(${+r},${+g},${+b},${alpha})`;\n  }) as IColorUtils['getRgbaStringForColor'],\n\n  shouldUseLightTextOnColor: (colorString: string): boolean => {\n    if (!has(rgbTuplesByColor, colorString)) {\n      return false;\n    }\n\n    const shouldUseDarkText = colorString.endsWith('Light1') || colorString.endsWith('Light2');\n    return !shouldUseDarkText;\n  },\n\n  randomColor(exists?: string[], num = 1) {\n    const allColors = Object.values(Colors);\n    let availableColors = [...allColors];\n\n    if (exists) {\n      availableColors = availableColors.filter((color) => !exists.includes(color));\n    }\n\n    const result: Colors[] = [];\n    for (let i = 0; i < num; i++) {\n      const colorsToChooseFrom = availableColors.length > 0 ? availableColors : allColors;\n      const randomIndex = Math.floor(Math.random() * colorsToChooseFrom.length);\n      result.push(colorsToChooseFrom[randomIndex]);\n\n      if (availableColors.length > 0) {\n        availableColors.splice(randomIndex, 1);\n      }\n    }\n\n    return result;\n  },\n\n  getRandomColorFromStr(str: string): Colors {\n    const seed = getSeed(str);\n    const values = Object.values(Colors);\n    return values[seed % values.length];\n  },\n\n  getRandomHexFromStr(str: string) {\n    const seed = getSeed(str);\n    const values = Object.values(Colors);\n    const value = values[seed % values.length];\n    return ColorUtils.getHexForColor(value);\n  },\n};\n\nexport const contractColorForTheme = (color: string, theme: string | undefined) => {\n  const colorRegular = Color(color).alpha(1);\n  return theme === 'light' ? colorRegular.darken(0.5).hex() : colorRegular.lighten(0.5).hex();\n};\n\n// Function to generate a seed from a string\nfunction getSeed(str: string) {\n  let seed = 0;\n  for (let i = 0; i < str.length; i++) {\n    seed = (seed << 5) - seed + str.charCodeAt(i);\n    seed |= 0; // Convert seed to a 32-bit integer\n  }\n  return Math.abs(seed);\n}\n\nexport const generateColorPalette = () => {\n  const colors = Object.values(Colors);\n  const colorCount = colors.length;\n  const groupCount = 5;\n  const result: Colors[][] = Array.from({ length: groupCount }, () => []);\n\n  for (let i = 0; i < colorCount; i++) {\n    const groupIndex = i % groupCount;\n    const indexInGroup = Math.floor(i / groupCount);\n    result[groupIndex][indexInGroup] = colors[i];\n  }\n  return result;\n};\n\nexport const COLOR_PALETTE = generateColorPalette();\n"
  },
  {
    "path": "packages/core/src/models/field/colors.ts",
    "content": "export enum Colors {\n  BlueLight2 = 'blueLight2',\n  BlueLight1 = 'blueLight1',\n  BlueBright = 'blueBright',\n  Blue = 'blue',\n  BlueDark1 = 'blueDark1',\n\n  CyanLight2 = 'cyanLight2',\n  CyanLight1 = 'cyanLight1',\n  CyanBright = 'cyanBright',\n  Cyan = 'cyan',\n  CyanDark1 = 'cyanDark1',\n\n  GrayLight2 = 'grayLight2',\n  GrayLight1 = 'grayLight1',\n  GrayBright = 'grayBright',\n  Gray = 'gray',\n  GrayDark1 = 'grayDark1',\n\n  GreenLight2 = 'greenLight2',\n  GreenLight1 = 'greenLight1',\n  GreenBright = 'greenBright',\n  Green = 'green',\n  GreenDark1 = 'greenDark1',\n\n  OrangeLight2 = 'orangeLight2',\n  OrangeLight1 = 'orangeLight1',\n  OrangeBright = 'orangeBright',\n  Orange = 'orange',\n  OrangeDark1 = 'orangeDark1',\n\n  PinkLight2 = 'pinkLight2',\n  PinkLight1 = 'pinkLight1',\n  PinkBright = 'pinkBright',\n  Pink = 'pink',\n  PinkDark1 = 'pinkDark1',\n\n  PurpleLight2 = 'purpleLight2',\n  PurpleLight1 = 'purpleLight1',\n  PurpleBright = 'purpleBright',\n  Purple = 'purple',\n  PurpleDark1 = 'purpleDark1',\n\n  RedLight2 = 'redLight2',\n  RedLight1 = 'redLight1',\n  RedBright = 'redBright',\n  Red = 'red',\n  RedDark1 = 'redDark1',\n\n  TealLight2 = 'tealLight2',\n  TealLight1 = 'tealLight1',\n  TealBright = 'tealBright',\n  Teal = 'teal',\n  TealDark1 = 'tealDark1',\n\n  YellowLight2 = 'yellowLight2',\n  YellowLight1 = 'yellowLight1',\n  YellowBright = 'yellowBright',\n  Yellow = 'yellow',\n  YellowDark1 = 'yellowDark1',\n}\n\nexport const rgbTuplesByColor = {\n  [Colors.BlueBright]: [0, 123, 255],\n  [Colors.BlueDark1]: [0, 63, 135],\n  [Colors.BlueLight1]: [153, 204, 255],\n  [Colors.BlueLight2]: [204, 229, 255],\n  [Colors.Blue]: [0, 102, 204],\n\n  [Colors.CyanBright]: [0, 188, 212],\n  [Colors.CyanDark1]: [0, 96, 100],\n  [Colors.CyanLight1]: [153, 228, 236],\n  [Colors.CyanLight2]: [204, 244, 248],\n  [Colors.Cyan]: [0, 151, 167],\n\n  [Colors.GrayBright]: [160, 160, 160],\n  [Colors.GrayDark1]: [80, 80, 80],\n  [Colors.GrayLight1]: [220, 220, 220],\n  [Colors.GrayLight2]: [245, 245, 245],\n  [Colors.Gray]: [128, 128, 128],\n\n  [Colors.GreenBright]: [40, 167, 69],\n  [Colors.GreenDark1]: [20, 83, 35],\n  [Colors.GreenLight1]: [144, 238, 144],\n  [Colors.GreenLight2]: [204, 255, 204],\n  [Colors.Green]: [30, 130, 76],\n\n  [Colors.OrangeBright]: [255, 159, 0],\n  [Colors.OrangeDark1]: [204, 85, 0],\n  [Colors.OrangeLight1]: [255, 204, 153],\n  [Colors.OrangeLight2]: [255, 229, 204],\n  [Colors.Orange]: [250, 128, 0],\n\n  [Colors.PinkBright]: [255, 64, 123],\n  [Colors.PinkDark1]: [194, 24, 91],\n  [Colors.PinkLight1]: [255, 182, 193],\n  [Colors.PinkLight2]: [255, 224, 230],\n  [Colors.Pink]: [255, 20, 147],\n\n  [Colors.PurpleBright]: [155, 89, 182],\n  [Colors.PurpleDark1]: [102, 51, 153],\n  [Colors.PurpleLight1]: [204, 153, 255],\n  [Colors.PurpleLight2]: [229, 204, 255],\n  [Colors.Purple]: [128, 0, 128],\n\n  [Colors.RedBright]: [241, 86, 70],\n  [Colors.RedDark1]: [163, 10, 10],\n  [Colors.RedLight1]: [255, 163, 163],\n  [Colors.RedLight2]: [255, 214, 214],\n  [Colors.Red]: [217, 10, 25],\n\n  [Colors.TealBright]: [0, 150, 136],\n  [Colors.TealDark1]: [0, 75, 68],\n  [Colors.TealLight1]: [128, 203, 196],\n  [Colors.TealLight2]: [178, 235, 242],\n  [Colors.Teal]: [0, 121, 107],\n\n  [Colors.YellowBright]: [255, 212, 59],\n  [Colors.YellowDark1]: [250, 176, 5],\n  [Colors.YellowLight1]: [255, 236, 153],\n  [Colors.YellowLight2]: [255, 243, 191],\n  [Colors.Yellow]: [252, 196, 25],\n};\n"
  },
  {
    "path": "packages/core/src/models/field/conditional.constants.ts",
    "content": "const parsePositiveInt = (raw: string | undefined, fallback: number): number => {\n  if (!raw) return fallback;\n  const parsed = Number(raw);\n  if (!Number.isFinite(parsed) || parsed <= 0) {\n    return fallback;\n  }\n  return Math.floor(parsed);\n};\n\nconst resolvedMax = parsePositiveInt(process.env.CONDITIONAL_QUERY_MAX_LIMIT, 5000);\nconst resolvedDefault = parsePositiveInt(process.env.CONDITIONAL_QUERY_DEFAULT_LIMIT, resolvedMax);\n\nexport const CONDITIONAL_QUERY_MAX_LIMIT = resolvedMax;\nexport const CONDITIONAL_QUERY_DEFAULT_LIMIT = Math.min(resolvedDefault, resolvedMax);\n\nexport const clampConditionalLimit = (limit?: number | null): number | undefined => {\n  if (typeof limit !== 'number' || !Number.isFinite(limit)) {\n    return undefined;\n  }\n  const truncated = Math.trunc(limit);\n  if (truncated <= 0) {\n    return undefined;\n  }\n  return Math.min(truncated, CONDITIONAL_QUERY_MAX_LIMIT);\n};\n\nexport const normalizeConditionalLimit = (limit?: number | null): number => {\n  return clampConditionalLimit(limit) ?? CONDITIONAL_QUERY_DEFAULT_LIMIT;\n};\n"
  },
  {
    "path": "packages/core/src/models/field/constant.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nexport enum FieldType {\n  SingleLineText = 'singleLineText',\n  LongText = 'longText',\n  User = 'user',\n  Attachment = 'attachment',\n  Checkbox = 'checkbox',\n  MultipleSelect = 'multipleSelect',\n  SingleSelect = 'singleSelect',\n  Date = 'date',\n  Number = 'number',\n  Rating = 'rating',\n  Formula = 'formula',\n  Rollup = 'rollup',\n  ConditionalRollup = 'conditionalRollup',\n  Link = 'link',\n  CreatedTime = 'createdTime',\n  LastModifiedTime = 'lastModifiedTime',\n  CreatedBy = 'createdBy',\n  LastModifiedBy = 'lastModifiedBy',\n  AutoNumber = 'autoNumber',\n  Button = 'button',\n}\n\nexport enum DbFieldType {\n  Text = 'TEXT',\n  Integer = 'INTEGER',\n  DateTime = 'DATETIME',\n  Real = 'REAL',\n  Blob = 'BLOB',\n  Json = 'JSON',\n  Boolean = 'BOOLEAN',\n}\n\nexport enum CellValueType {\n  String = 'string',\n  Number = 'number',\n  Boolean = 'boolean',\n  DateTime = 'dateTime',\n}\n\nexport enum Relationship {\n  OneOne = 'oneOne',\n  ManyMany = 'manyMany',\n  OneMany = 'oneMany',\n  ManyOne = 'manyOne',\n}\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const RelationshipRevert = {\n  [Relationship.OneMany]: Relationship.ManyOne,\n  [Relationship.ManyOne]: Relationship.OneMany,\n  [Relationship.ManyMany]: Relationship.ManyMany,\n  [Relationship.OneOne]: Relationship.OneOne,\n};\n\nexport const isMultiValueLink = (relationship: Relationship): boolean =>\n  relationship === Relationship.ManyMany || relationship === Relationship.OneMany;\n\nexport const PRIMARY_SUPPORTED_TYPES = new Set([\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.User,\n  FieldType.MultipleSelect,\n  FieldType.SingleSelect,\n  FieldType.Date,\n  FieldType.Number,\n  FieldType.Rating,\n  FieldType.Formula,\n  FieldType.CreatedTime,\n  FieldType.LastModifiedTime,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n  FieldType.AutoNumber,\n]);\n\nexport const IMPORT_SUPPORTED_TYPES = [\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Date,\n  FieldType.Number,\n  FieldType.Attachment,\n  FieldType.Checkbox,\n  FieldType.MultipleSelect,\n  FieldType.SingleSelect,\n  FieldType.User,\n];\n\nexport const UNIQUE_VALIDATION_FIELD_TYPES = new Set([\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Number,\n  FieldType.Date,\n]);\n\nexport const NOT_NULL_VALIDATION_FIELD_TYPES = new Set([\n  FieldType.SingleLineText,\n  FieldType.LongText,\n  FieldType.Number,\n  FieldType.SingleSelect,\n  FieldType.MultipleSelect,\n  FieldType.User,\n  FieldType.Date,\n  FieldType.Rating,\n  FieldType.Attachment,\n  FieldType.Link,\n]);\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/abstract/formula.field.abstract.ts",
    "content": "import { Formula, FormulaErrorListener, FormulaLexer } from '@teable/formula';\nimport type { RootContext } from '@teable/formula';\nimport { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport { z } from 'zod';\nimport { assertNever } from '../../../../asserts';\nimport { EvalVisitor } from '../../../../formula/visitor';\nimport type { IRecord } from '../../../record';\nimport { CellValueType } from '../../constant';\nimport { FieldCore } from '../../field';\nimport type { INumberFormatting, IDatetimeFormatting, IUnionFormatting } from '../../formatting';\nimport {\n  formatNumberToString,\n  formatDateToString,\n  defaultNumberFormatting,\n  defaultDatetimeFormatting,\n} from '../../formatting';\nimport { booleanCellValueSchema } from '../checkbox.field';\nimport { dataFieldCellValueSchema } from '../date.field';\nimport { numberCellValueSchema } from '../number.field';\nimport { singleLineTextCelValueSchema } from '../single-line-text.field';\n\nexport const getFormulaCellValueSchema = (cellValueType: CellValueType) => {\n  switch (cellValueType) {\n    case CellValueType.Number:\n      return numberCellValueSchema;\n    case CellValueType.DateTime:\n      return dataFieldCellValueSchema;\n    case CellValueType.String:\n      return singleLineTextCelValueSchema;\n    case CellValueType.Boolean:\n      return booleanCellValueSchema;\n    default:\n      assertNever(cellValueType);\n  }\n};\n\nexport abstract class FormulaAbstractCore extends FieldCore {\n  static parse(expression: string) {\n    const inputStream = CharStreams.fromString(expression);\n    const lexer = new FormulaLexer(inputStream);\n    const tokenStream = new CommonTokenStream(lexer);\n    const parser = new Formula(tokenStream);\n    parser.removeErrorListeners();\n    const errorListener = new FormulaErrorListener();\n    parser.addErrorListener(errorListener);\n    return parser.root();\n  }\n\n  options!: {\n    expression: string;\n    formatting?: IUnionFormatting;\n  };\n\n  cellValueType!: CellValueType;\n\n  declare isMultipleCellValue?: boolean | undefined;\n\n  protected _tree?: RootContext;\n\n  protected get tree() {\n    if (this._tree) {\n      return this._tree;\n    }\n    this._tree = FormulaAbstractCore.parse(this.options.expression);\n    return this._tree;\n  }\n\n  evaluate(dependFieldMap: { [fieldId: string]: FieldCore }, record: IRecord) {\n    const visitor = new EvalVisitor(dependFieldMap, record);\n    return visitor.visit(this.tree);\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (this.isMultipleCellValue) {\n      return (cellValue as unknown[]).map((v) => this.item2String(v)).join(', ');\n    }\n\n    return this.item2String(cellValue);\n  }\n\n  // formula do not support\n  convertStringToCellValue(_value: string): null {\n    return null;\n  }\n\n  item2String(value?: unknown) {\n    const formatting = this.options.formatting;\n\n    switch (this.cellValueType) {\n      case CellValueType.Number:\n        return formatNumberToString(\n          value as number,\n          (formatting as INumberFormatting) || defaultNumberFormatting\n        );\n      case CellValueType.DateTime:\n        return formatDateToString(\n          value as string,\n          (formatting as IDatetimeFormatting) || defaultDatetimeFormatting\n        );\n    }\n    return value == null ? '' : String(value);\n  }\n\n  // formula do not support\n  repair(_value: unknown): null {\n    return null;\n  }\n\n  validateCellValue(value: unknown) {\n    const schema = getFormulaCellValueSchema(this.cellValueType);\n\n    if (this.isMultipleCellValue) {\n      return z.array(schema).nullable().safeParse(value);\n    }\n    return schema.nullable().safeParse(value);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/abstract/select-option.schema.ts",
    "content": "import { z } from '../../../../zod';\n\n// Select field options (for single and multiple select)\nexport const selectFieldChoiceSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  color: z.string(),\n});\n\nexport const selectFieldChoiceRoSchema = selectFieldChoiceSchema.partial({ id: true, color: true });\n\nexport type ISelectFieldChoice = z.infer<typeof selectFieldChoiceSchema>;\n\nexport const selectFieldOptionsSchema = z.object({\n  choices: z.array(selectFieldChoiceSchema),\n  defaultValue: z\n    .union([z.string(), z.array(z.string())])\n    .optional()\n    .nullable(),\n  preventAutoNewOptions: z.boolean().optional(),\n});\n\nexport const selectFieldOptionsRoSchema = z.object({\n  choices: z.array(selectFieldChoiceRoSchema),\n  // null is used to explicitly clear the default value (since undefined is stripped during JSON serialization)\n  defaultValue: z\n    .union([z.string(), z.array(z.string())])\n    .optional()\n    .nullable(),\n  preventAutoNewOptions: z.boolean().optional(),\n});\n\nexport type ISelectFieldOptions = z.infer<typeof selectFieldOptionsSchema>;\nexport type ISelectFieldOptionsRo = z.infer<typeof selectFieldOptionsRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/abstract/select.field.abstract.spec.ts",
    "content": "import { selectFieldOptionsRoSchema, selectFieldOptionsSchema } from './select.field.abstract';\n\ndescribe('select field schema test', () => {\n  it('should return true when ro options validate', () => {\n    const options = {\n      choices: [{ name: 'name' }],\n    };\n\n    const result = selectFieldOptionsRoSchema.safeParse(options);\n    expect(result.success).toBe(true);\n    result.success && expect(result.data).toEqual(options);\n  });\n\n  it('should return false when ro options invalidate', () => {\n    expect(\n      selectFieldOptionsRoSchema.safeParse({\n        choices: [{ name: '' }],\n      }).success\n    ).toBe(false);\n\n    expect(\n      selectFieldOptionsRoSchema.safeParse({\n        choices: [{ id: 'cho' }],\n      }).success\n    ).toBe(false);\n\n    expect(\n      selectFieldOptionsRoSchema.safeParse({\n        choices: [{ name: 'name', color: '#000000' }],\n      }).success\n    ).toBe(false);\n  });\n\n  it('should return false when vo options invalidate', () => {\n    const options = {\n      choices: [{ name: 'name' }],\n    };\n\n    const result = selectFieldOptionsSchema.safeParse(options);\n    expect(result.success).toBe(false);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/abstract/select.field.abstract.ts",
    "content": "import { keyBy } from 'lodash';\nimport { z } from 'zod';\nimport { Colors } from '../../colors';\nimport { FieldCore } from '../../field';\n\nexport const selectFieldChoiceSchema = z.object({\n  id: z.string(),\n  name: z\n    .string()\n    .transform((s) => s.trim())\n    .pipe(z.string().min(1)),\n  color: z.enum(Colors),\n});\n\nexport const selectFieldChoiceRoSchema = selectFieldChoiceSchema.partial({ id: true, color: true });\n\nexport type ISelectFieldChoice = z.infer<typeof selectFieldChoiceSchema>;\n\nexport const selectFieldOptionsSchema = z.object({\n  choices: z.array(selectFieldChoiceSchema),\n  defaultValue: z.union([z.string(), z.array(z.string())]).optional(),\n  preventAutoNewOptions: z.boolean().optional(),\n});\n\nexport const selectFieldOptionsRoSchema = z\n  .object({\n    choices: z.array(selectFieldChoiceRoSchema),\n    defaultValue: z\n      .union([z.string(), z.array(z.string())])\n      .optional()\n      .nullable(),\n    preventAutoNewOptions: z.boolean().optional(),\n  })\n  .describe('options for both single and multiple select fields');\n\nexport type ISelectFieldOptions = z.infer<typeof selectFieldOptionsSchema>;\n\nexport type ISelectFieldOptionsRo = z.infer<typeof selectFieldOptionsRoSchema>;\n\nexport abstract class SelectFieldCore extends FieldCore {\n  private _innerChoicesMap: Record<string, ISelectFieldChoice> = {};\n\n  meta?: undefined;\n\n  static defaultOptions(): ISelectFieldOptions {\n    return {\n      choices: [],\n    };\n  }\n\n  options!: ISelectFieldOptions;\n\n  // For validate cellValue,\n  // avoiding choice and checking too many rows has a complexity of m(choice.length) x n(rows.length)\n  get innerChoicesMap() {\n    if (Object.keys(this._innerChoicesMap).length === 0) {\n      this._innerChoicesMap = keyBy(this.options.choices, 'name');\n    }\n    return this._innerChoicesMap;\n  }\n\n  validateOptions() {\n    return selectFieldOptionsSchema.safeParse(this.options);\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (Array.isArray(cellValue)) {\n      return cellValue.map((value) => this.item2String(value)).join(', ');\n    }\n\n    return cellValue as string;\n  }\n\n  item2String(value?: unknown): string {\n    if (value == null) {\n      return '';\n    }\n\n    const stringValue = String(value);\n\n    if (this.isMultipleCellValue && stringValue.includes(',')) {\n      return `\"${stringValue}\"`;\n    }\n    return stringValue;\n  }\n\n  validateCellValue(cellValue: unknown) {\n    const nameSchema = z.string().refine(\n      (value) => {\n        return value == null || this.innerChoicesMap[value];\n      },\n      { message: `${cellValue} is not one of the choice names` }\n    );\n\n    if (this.isMultipleCellValue) {\n      return z.array(nameSchema).nonempty().nullable().safeParse(cellValue);\n    }\n\n    return nameSchema.nullable().safeParse(cellValue);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/abstract/user.field.abstract.ts",
    "content": "import { z } from 'zod';\nimport type { CellValueType } from '../../constant';\nimport { FieldCore } from '../../field';\n\nexport const userCellValueSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  email: z.string().optional(),\n  avatarUrl: z.string().optional().nullable(),\n});\n\nexport type IUserCellValue = z.infer<typeof userCellValueSchema>;\n\nexport abstract class UserAbstractCore extends FieldCore {\n  cellValueType!: CellValueType.String;\n\n  declare meta?: FieldCore['meta'];\n\n  item2String(value: unknown) {\n    if (value == null) {\n      return '';\n    }\n\n    const { title } = value as IUserCellValue;\n\n    if (this.isMultipleCellValue && title?.includes(',')) {\n      return `\"${title}\"`;\n    }\n    return title || '';\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n    return this.item2String(cellValue);\n  }\n\n  validateCellValue(cellValue: unknown) {\n    if (this.isMultipleCellValue) {\n      return z\n        .array(userCellValueSchema)\n        .transform((arr) => (arr.length === 0 ? null : arr))\n        .nullable()\n        .safeParse(cellValue);\n    }\n    return userCellValueSchema.nullable().safeParse(cellValue);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/attachment-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const attachmentFieldOptionsSchema = z.object({}).strict();\n\nexport type IAttachmentFieldOptions = z.infer<typeof attachmentFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/attachment.field.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVo } from '../field.schema';\nimport type { IAttachmentCellValue } from './attachment.field';\nimport { AttachmentFieldCore } from './attachment.field';\n\ndescribe('AttachmentFieldCore', () => {\n  let field: AttachmentFieldCore;\n  let lookupField: AttachmentFieldCore;\n  const json: IFieldVo = {\n    id: 'fldtestxxxxxx',\n    dbFieldName: 'fldtestxxxxxx',\n    name: 'Test attachment Field',\n    description: 'A test attachment field',\n    options: {},\n    type: FieldType.Attachment,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: true,\n    isComputed: false,\n  };\n  const lookupJson = {\n    isLookup: true,\n    isComputed: true,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(AttachmentFieldCore, json);\n    lookupField = plainToInstance(AttachmentFieldCore, {\n      ...json,\n      ...lookupJson,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(AttachmentFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    const cellValue: IAttachmentCellValue = [\n      {\n        id: 'actxxxxxxxx',\n        name: 'test.txt',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n      {\n        id: 'actxxxxxxxy',\n        name: 'graph.png',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n    ];\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String(cellValue)).toEqual('test.txt (token),graph.png (token)');\n    expect(lookupField.cellValue2String(null as any)).toEqual('');\n    expect(lookupField.cellValue2String(cellValue)).toEqual('test.txt (token),graph.png (token)');\n  });\n\n  it('should validate cellValue', () => {\n    const cellValue: IAttachmentCellValue = [\n      {\n        id: 'actxxxxxxxx',\n        name: 'test.txt',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n      {\n        id: 'actxxxxxxxy',\n        name: 'graph.png',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n    ];\n    expect(field.validateCellValue(null as any).success).toBe(true);\n    expect(field.validateCellValue(cellValue).success).toBe(true);\n    expect(field.validateCellValue([{ id: 'actxxx' }]).success).toBe(false);\n    expect(field.validateCellValue(cellValue[0]).success).toBe(false);\n\n    expect(lookupField.validateCellValue(null as any).success).toBe(true);\n    expect(lookupField.validateCellValue(cellValue).success).toBe(true);\n    expect(lookupField.validateCellValue(cellValue[0]).success).toBe(false);\n    expect(lookupField.validateCellValue([{ id: 'actxxx' }]).success).toBe(false);\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(lookupField.convertStringToCellValue('text')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    const cellValue: IAttachmentCellValue = [\n      {\n        id: 'actxxxxxxxx',\n        name: 'test.txt',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n      {\n        id: 'actxxxxxxxy',\n        name: 'graph.png',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      },\n    ];\n    expect(field.repair(cellValue)).toEqual(cellValue);\n    expect(field.repair([{ id: 'actxxx' }])).toEqual(null);\n    expect(lookupField.repair(cellValue)).toEqual(null);\n    expect(lookupField.repair([{ id: 'actxxx' }])).toEqual(null);\n  });\n\n  it('should convert item to string', () => {\n    expect(\n      field.item2String({\n        id: 'actxxxxxxxx',\n        name: 'test.txt',\n        token: 'token',\n        size: 2333,\n        mimetype: 'text/plain',\n        path: '/attachment/xxxxxx',\n      })\n    ).toBe('test.txt (token)');\n    expect(field.item2String(null)).toBe('');\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      const field = plainToInstance(AttachmentFieldCore, {\n        ...json,\n        options: {},\n      });\n      expect(field.validateOptions().success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(AttachmentFieldCore, {\n        ...json,\n        options: null,\n      });\n      expect(field.validateOptions().success).toBe(false);\n    });\n\n    it('should get default options', () => {\n      expect(AttachmentFieldCore.defaultOptions()).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/attachment.field.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport {\n  attachmentFieldOptionsSchema,\n  type IAttachmentFieldOptions,\n} from './attachment-option.schema';\n\nexport const attachmentItemSchema = z.object({\n  id: z.string().startsWith(IdPrefix.Attachment), // partial in Ro\n  name: z.string(),\n  path: z.string(),\n  token: z.string(),\n  size: z.number(),\n  mimetype: z.string(),\n  presignedUrl: z.string().optional(),\n  width: z.number().optional(),\n  height: z.number().optional(),\n  smThumbnailUrl: z.string().optional(),\n  lgThumbnailUrl: z.string().optional(),\n});\n\n// Simplified format: only name and token (backend will fetch other fields from DB)\n// Optional id: if provided and exists in DB, the attachment will reuse that id\nconst attachmentItemRoSchema = attachmentItemSchema.partial().required({ name: true, token: true });\n\nexport type IAttachmentItem = z.infer<typeof attachmentItemSchema>;\n\nexport type IAttachmentItemRo = z.infer<typeof attachmentItemRoSchema>;\n\nexport const attachmentCellValueSchema = z.array(attachmentItemSchema);\n\nexport const attachmentCellValueRoSchema = z.array(attachmentItemRoSchema);\n\nexport type IAttachmentCellValue = z.infer<typeof attachmentCellValueSchema>;\n\nexport type IAttachmentCellValueRo = z.infer<typeof attachmentCellValueRoSchema>;\n\nexport class AttachmentFieldCore extends FieldCore {\n  type: FieldType.Attachment = FieldType.Attachment;\n\n  options!: IAttachmentFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType = CellValueType.String;\n\n  isMultipleCellValue = true;\n\n  static CELL_VALUE_STRING_SPLITTER = ',';\n\n  static defaultOptions(): IAttachmentFieldOptions {\n    return {};\n  }\n\n  static itemString(name: string, token: string) {\n    return `${name} (${token})`;\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    // TODO: The path is currently empty\n    return cellValue\n      ? (cellValue as IAttachmentCellValue)\n          .map(this.item2String)\n          .join(AttachmentFieldCore.CELL_VALUE_STRING_SPLITTER)\n      : '';\n  }\n\n  convertStringToCellValue(_value: string, _ctx?: unknown): IAttachmentCellValue | null {\n    return null;\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (this.validateCellValue(value).success) {\n      return value;\n    }\n    return null;\n  }\n\n  validateOptions() {\n    return attachmentFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(cellValue: unknown) {\n    return attachmentCellValueRoSchema.nonempty().nullable().safeParse(cellValue);\n  }\n\n  item2String(value: unknown) {\n    if (value == null) {\n      return '';\n    }\n    const { name, token } = value as IAttachmentItem;\n    return AttachmentFieldCore.itemString(name, token);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitAttachmentField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/auto-number-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const autoNumberFieldOptionsSchema = z.object({\n  expression: z.literal('AUTO_NUMBER()'),\n});\n\nexport type IAutoNumberFieldOptions = z.infer<typeof autoNumberFieldOptionsSchema>;\n\nexport const autoNumberFieldOptionsRoSchema = autoNumberFieldOptionsSchema.omit({\n  expression: true,\n});\n\nexport type IAutoNumberFieldOptionsRo = z.infer<typeof autoNumberFieldOptionsRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/auto-number.field.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { AutoNumberFieldCore } from './auto-number.field';\n\ndescribe('AutoNumberFieldCore', () => {\n  const autoNumberJson = {\n    id: 'fld123',\n    name: 'Auto number',\n    description: 'A test auto number field',\n    notNull: false,\n    unique: false,\n    isPrimary: false,\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.AutoNumber,\n    options: {},\n    dbFieldType: DbFieldType.Integer,\n    cellValueType: CellValueType.Number,\n    isComputed: true,\n  };\n\n  const autoNumberField = plainToInstance(AutoNumberFieldCore, { ...autoNumberJson });\n\n  describe('basic function', () => {\n    it('should convert cellValue to string', () => {\n      expect(autoNumberField.cellValue2String(1)).toBe('1');\n    });\n\n    it('should validate cellValue', () => {\n      expect(autoNumberField.validateCellValue(1).success).toBe(true);\n      expect(autoNumberField.validateCellValue('1').success).toBe(false);\n    });\n\n    it('should convert string to cellValue', () => {\n      expect(autoNumberField.convertStringToCellValue('1')).toBe(null);\n    });\n\n    it('should repair invalid value', () => {\n      expect(autoNumberField.repair(true)).toBe(null);\n    });\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(autoNumberField.validateOptions().success).toBeTruthy();\n    });\n\n    it('should get default options', () => {\n      expect(AutoNumberFieldCore.defaultOptions()).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/auto-number.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport {\n  autoNumberFieldOptionsRoSchema,\n  type IAutoNumberFieldOptions,\n  type IAutoNumberFieldOptionsRo,\n} from './auto-number-option.schema';\nimport type { IFormulaFieldMeta } from './formula-option.schema';\n\nexport const autoNumberCellValueSchema = z.number().int();\n\nexport class AutoNumberFieldCore extends FormulaAbstractCore {\n  type!: FieldType.AutoNumber;\n\n  declare options: IAutoNumberFieldOptions;\n\n  declare meta?: IFormulaFieldMeta;\n\n  declare cellValueType: CellValueType.Number;\n\n  static defaultOptions(): IAutoNumberFieldOptionsRo {\n    return {};\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n\n    return this.item2String(cellValue as number);\n  }\n\n  item2String(value?: unknown): string {\n    if (value == null) {\n      return '';\n    }\n    return String(value);\n  }\n\n  validateOptions() {\n    console.log('this.options', this.options);\n    return autoNumberFieldOptionsRoSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(autoNumberCellValueSchema).nonempty().nullable().safeParse(value);\n    }\n    return autoNumberCellValueSchema.nullable().safeParse(value);\n  }\n\n  getIsPersistedAsGeneratedColumn() {\n    return this.meta?.persistedAsGeneratedColumn || false;\n  }\n  getExpression() {\n    return this.options.expression;\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitAutoNumberField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/button-option.schema.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../../utils';\nimport { Colors } from '../colors';\n\nexport const buttonFieldOptionsSchema = z.object({\n  label: z.string().meta({ description: 'Button label' }),\n  color: z.enum(Colors).meta({ description: 'Button color' }),\n  maxCount: z.number().optional().meta({ description: 'Max count of button clicks' }),\n  resetCount: z.boolean().optional().meta({ description: 'Reset count' }),\n  workflow: z\n    .object({\n      id: z.string().startsWith(IdPrefix.Workflow).optional().meta({ description: 'Workflow ID' }),\n      name: z.string().optional().meta({ description: 'Workflow Name' }),\n      isActive: z.boolean().optional().meta({ description: 'Workflow is active' }),\n    })\n    .optional()\n    .nullable()\n    .meta({ description: 'Workflow' }),\n  confirm: z\n    .object({\n      title: z.string().optional(),\n      description: z.string().optional(),\n      confirmText: z.string().optional(),\n    })\n    .optional()\n    .nullable()\n    .meta({ description: 'Confirm config before click' }),\n});\n\nexport type IButtonFieldOptions = z.infer<typeof buttonFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/button.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { ButtonFieldCore } from './button.field';\n\ndescribe('ButtonFieldCore', () => {\n  let field: ButtonFieldCore;\n  let multipleLookupField: ButtonFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test Button Field',\n    description: 'A test Button field',\n    type: FieldType.Button,\n    dbFieldType: DbFieldType.Json,\n    options: {\n      label: 'Button',\n      color: Colors.Teal,\n    },\n    cellValueType: CellValueType.String,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(ButtonFieldCore, json);\n    multipleLookupField = plainToInstance(ButtonFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(ButtonFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String('text')).toBe('');\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(multipleLookupField.cellValue2String(['text'])).toBe('');\n    expect(multipleLookupField.cellValue2String(['text', 'text2'])).toBe('');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(field.convertStringToCellValue('wrap\\ntext')).toBeNull();\n    expect(field.convertStringToCellValue(null as any)).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair(123)).toBeNull();\n\n    expect(multipleLookupField.repair('1.234')).toBeNull();\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue('1.234').success).toBe(false);\n    expect(field.validateCellValue(1.234).success).toBe(false);\n    expect(field.validateCellValue(null).success).toBe(true);\n\n    expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(false);\n    expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are plain object', () => {\n      const field = plainToInstance(ButtonFieldCore, {\n        ...json,\n        options: {\n          label: 'Button123',\n          color: Colors.Blue,\n        },\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(ButtonFieldCore, {\n        ...json,\n        options: null,\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(false);\n    });\n\n    it('should get default options', () => {\n      expect(ButtonFieldCore.defaultOptions()).toEqual({\n        label: 'Button',\n        color: Colors.Teal,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/button.field.ts",
    "content": "import { z } from 'zod';\nimport { Colors } from '../colors';\nimport type { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport type { IButtonFieldOptions } from './button-option.schema';\nimport { buttonFieldOptionsSchema } from './button-option.schema';\n\nexport const buttonFieldCelValueSchema = z.object({\n  count: z.number().int().meta({ description: 'clicked count' }),\n});\n\nexport type IButtonFieldCellValue = z.infer<typeof buttonFieldCelValueSchema>;\n\nexport class ButtonFieldCore extends FieldCore {\n  type!: FieldType.Button;\n\n  options!: IButtonFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.String;\n\n  static defaultOptions(): IButtonFieldOptions {\n    return {\n      label: 'Button',\n      color: Colors.Teal,\n    };\n  }\n\n  cellValue2String(_cellValue?: unknown) {\n    return '';\n  }\n\n  item2String(_value?: unknown): string {\n    return '';\n  }\n\n  convertStringToCellValue(_value: string): string | null {\n    return null;\n  }\n\n  repair(_value: unknown) {\n    return null;\n  }\n\n  validateOptions() {\n    return buttonFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(buttonFieldCelValueSchema).nonempty().nullable().safeParse(value);\n    }\n\n    return buttonFieldCelValueSchema.nullable().safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitButtonField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/checkbox-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const checkboxFieldOptionsSchema = z\n  .object({ defaultValue: z.boolean().optional().nullable() })\n  .strict();\n\nexport type ICheckboxFieldOptions = z.infer<typeof checkboxFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/checkbox.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { CheckboxFieldCore } from './checkbox.field';\n\ndescribe('CheckboxFieldCore', () => {\n  let field: CheckboxFieldCore;\n  let multipleLookupField: CheckboxFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test Number Field',\n    description: 'A test number field',\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.Checkbox,\n    dbFieldType: DbFieldType.Integer,\n    options: {},\n    cellValueType: CellValueType.Boolean,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(CheckboxFieldCore, json);\n    multipleLookupField = plainToInstance(CheckboxFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(CheckboxFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String(true)).toBe('true');\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(multipleLookupField.cellValue2String([true])).toBe('true');\n    expect(multipleLookupField.cellValue2String([true, true])).toBe('true, true');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('1.234')).toBe(true);\n    expect(field.convertStringToCellValue(null as any)).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair('1.234')).toBe(true);\n    expect(field.repair(false)).toBeNull();\n    expect(field.repair('false')).toBeNull();\n    expect(field.repair('true')).toBe(true);\n    expect(field.repair('True')).toBe(true);\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue(true).success).toBeTruthy();\n    expect(field.validateCellValue(false).success).toBeTruthy();\n    expect(field.validateCellValue(1).success).toBeFalsy();\n    expect(field.validateCellValue('1').success).toBeFalsy();\n\n    expect(multipleLookupField.validateCellValue([true]).success).toBeTruthy();\n    expect(multipleLookupField.validateCellValue([false]).success).toBeFalsy();\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      const result = field.validateOptions();\n      expect(result.success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(CheckboxFieldCore, {\n        ...json,\n        options: null,\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/checkbox.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport type { ICheckboxFieldOptions } from './checkbox-option.schema';\nimport { checkboxFieldOptionsSchema } from './checkbox-option.schema';\n\nexport const booleanCellValueSchema = z.boolean();\n\nexport type ICheckboxCellValue = z.infer<typeof booleanCellValueSchema>;\n\nexport class CheckboxFieldCore extends FieldCore {\n  type!: FieldType.Checkbox;\n\n  options!: ICheckboxFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.Boolean;\n\n  static defaultOptions(): ICheckboxFieldOptions {\n    return {};\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.map(String).join(', ');\n    }\n\n    return String(cellValue);\n  }\n\n  convertStringToCellValue(value: string): boolean | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    return value ? true : null;\n  }\n\n  // eslint-disable-next-line sonarjs/no-identical-functions\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'string') {\n      const lowercase = value.toLowerCase();\n      if (lowercase === 'true') {\n        return true;\n      }\n      if (lowercase === 'false') {\n        return null;\n      }\n    }\n\n    return value ? true : null;\n  }\n\n  item2String(item?: unknown) {\n    return item ? 'true' : '';\n  }\n\n  validateOptions() {\n    return checkboxFieldOptionsSchema.safeParse(this.options);\n  }\n\n  // checkbox value only allow true or null, false should be convert to null\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(z.literal(true)).nonempty().nullable().safeParse(value);\n    }\n    return z\n      .boolean()\n      .nullable()\n      .transform((val) => (val === false ? null : val))\n      .safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitCheckboxField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/conditional-rollup-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { filterSchema } from '../../view/filter';\nimport { SortFunc } from '../../view/sort';\nimport { CONDITIONAL_QUERY_MAX_LIMIT } from '../conditional.constants';\nimport { rollupFieldOptionsSchema } from './rollup-option.schema';\n\nexport const conditionalRollupFieldOptionsSchema = rollupFieldOptionsSchema.extend({\n  baseId: z.string().optional(),\n  foreignTableId: z.string().optional(),\n  lookupFieldId: z.string().optional(),\n  filter: filterSchema.optional(),\n  sort: z\n    .object({\n      fieldId: z.string(),\n      order: z.enum(SortFunc),\n    })\n    .optional(),\n  limit: z.number().int().positive().max(CONDITIONAL_QUERY_MAX_LIMIT).optional(),\n});\n\nexport type IConditionalRollupFieldOptions = z.infer<typeof conditionalRollupFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/conditional-rollup.field.ts",
    "content": "import type { IFilter } from '../../view/filter';\nimport type { CellValueType, FieldType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { getDefaultFormatting, getFormattingSchema } from '../formatting';\nimport { getShowAsSchema } from '../show-as';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport {\n  conditionalRollupFieldOptionsSchema,\n  type IConditionalRollupFieldOptions,\n} from './conditional-rollup-option.schema';\nimport { ROLLUP_FUNCTIONS } from './rollup-option.schema';\nimport { RollupFieldCore } from './rollup.field';\n\nexport class ConditionalRollupFieldCore extends FormulaAbstractCore {\n  static defaultOptions(cellValueType: CellValueType): Partial<IConditionalRollupFieldOptions> {\n    return {\n      expression: ROLLUP_FUNCTIONS[0],\n      timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone as string,\n      formatting: getDefaultFormatting(cellValueType),\n    };\n  }\n\n  static getParsedValueType(\n    expression: string,\n    cellValueType: CellValueType,\n    isMultipleCellValue: boolean\n  ) {\n    return RollupFieldCore.getParsedValueType(expression, cellValueType, isMultipleCellValue);\n  }\n\n  type!: FieldType.ConditionalRollup;\n\n  declare options: IConditionalRollupFieldOptions;\n\n  meta?: undefined;\n\n  override getFilter(): IFilter | undefined {\n    return this.options?.filter ?? undefined;\n  }\n\n  static supportsOrdering(expression?: string): boolean {\n    if (!expression) return false;\n    const match = expression.match(/^(\\w+)\\(\\{values\\}\\)$/i);\n    if (!match) return false;\n    switch (match[1].toLowerCase()) {\n      case 'array_join':\n      case 'array_compact':\n      case 'array_unique':\n      case 'concatenate':\n        return true;\n      default:\n        return false;\n    }\n  }\n\n  validateOptions() {\n    return conditionalRollupFieldOptionsSchema\n      .extend({\n        formatting: getFormattingSchema(this.cellValueType),\n        showAs: getShowAsSchema(this.cellValueType, this.isMultipleCellValue),\n      })\n      .safeParse(this.options);\n  }\n\n  getForeignTableId(): string | undefined {\n    return this.options?.foreignTableId;\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitConditionalRollupField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/created-by-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\n// Created by field options\nexport const createdByFieldOptionsSchema = z.object({}).strict();\n\nexport type ICreatedByFieldOptions = z.infer<typeof createdByFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/created-by.field.ts",
    "content": "import type { FieldType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { UserAbstractCore } from './abstract/user.field.abstract';\nimport {\n  createdByFieldOptionsSchema,\n  type ICreatedByFieldOptions,\n} from './created-by-option.schema';\nimport type { IFormulaFieldMeta } from './formula-option.schema';\n\nexport class CreatedByFieldCore extends UserAbstractCore {\n  type!: FieldType.CreatedBy;\n  options!: ICreatedByFieldOptions;\n  declare meta?: IFormulaFieldMeta;\n\n  override get isStructuredCellValue() {\n    return true;\n  }\n\n  convertStringToCellValue(_value: string) {\n    return null;\n  }\n\n  getIsPersistedAsGeneratedColumn(): boolean {\n    return this.meta?.persistedAsGeneratedColumn === true;\n  }\n\n  shouldPersistAuditValue(): boolean {\n    return !this.isLookup && !this.getIsPersistedAsGeneratedColumn();\n  }\n\n  repair(_value: unknown) {\n    return null;\n  }\n\n  validateOptions() {\n    return createdByFieldOptionsSchema.safeParse(this.options);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitCreatedByField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/created-time-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { datetimeFormattingSchema } from '../formatting';\n\nexport const createdTimeFieldOptionsSchema = z.object({\n  expression: z.literal('CREATED_TIME()'),\n  formatting: datetimeFormattingSchema,\n});\n\nexport type ICreatedTimeFieldOptions = z.infer<typeof createdTimeFieldOptionsSchema>;\n\nexport const createdTimeFieldOptionsRoSchema = createdTimeFieldOptionsSchema.omit({\n  expression: true,\n});\n\nexport type ICreatedTimeFieldOptionsRo = z.infer<typeof createdTimeFieldOptionsRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/created-time.field.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { DateFormattingPreset, defaultDatetimeFormatting } from '../formatting';\nimport { CreatedTimeFieldCore } from './created-time.field';\n\ndescribe('CreatedTimeFieldCore', () => {\n  const createdTimeJson = {\n    id: 'fld123',\n    name: 'Created time',\n    description: 'A test created time field',\n    notNull: false,\n    unique: false,\n    isPrimary: false,\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.CreatedTime,\n    options: {\n      formatting: defaultDatetimeFormatting,\n    },\n    dbFieldType: DbFieldType.DateTime,\n    cellValueType: CellValueType.DateTime,\n    isComputed: true,\n  };\n\n  const createdTimeField = plainToInstance(CreatedTimeFieldCore, { ...createdTimeJson });\n\n  describe('basic function', () => {\n    it('should convert cellValue to string', () => {\n      expect(createdTimeField.cellValue2String('2023-11-28T06:50:48.017Z')).toBe('2023-11-28');\n    });\n\n    it('should validate cellValue', () => {\n      expect(createdTimeField.validateCellValue('date').success).toBe(false);\n      expect(createdTimeField.validateCellValue('2023-11-28T06:50:48.017Z').success).toBe(true);\n    });\n\n    it('should convert string to cellValue', () => {\n      expect(createdTimeField.convertStringToCellValue('1')).toBe(null);\n    });\n\n    it('should repair invalid value', () => {\n      expect(createdTimeField.repair(1)).toBe(null);\n    });\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(createdTimeField.validateOptions().success).toBeTruthy();\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(CreatedTimeFieldCore, {\n          ...createdTimeJson,\n          options: {\n            ...createdTimeJson.options,\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: 'abc',\n              timeZone: 'utc',\n            },\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n    });\n\n    it('should get default options', () => {\n      expect(CreatedTimeFieldCore.defaultOptions()).toEqual({\n        formatting: defaultDatetimeFormatting,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/created-time.field.ts",
    "content": "import { extend } from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport type { FieldType, CellValueType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { defaultDatetimeFormatting } from '../formatting';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport {\n  createdTimeFieldOptionsRoSchema,\n  type ICreatedTimeFieldOptions,\n  type ICreatedTimeFieldOptionsRo,\n} from './created-time-option.schema';\nimport type { IFormulaFieldMeta } from './formula-option.schema';\n\nextend(timezone);\n\nexport class CreatedTimeFieldCore extends FormulaAbstractCore {\n  type!: FieldType.CreatedTime;\n\n  declare options: ICreatedTimeFieldOptions;\n\n  declare meta?: IFormulaFieldMeta;\n\n  declare cellValueType: CellValueType.DateTime;\n\n  getExpression() {\n    return this.options.expression;\n  }\n\n  static defaultOptions(): ICreatedTimeFieldOptionsRo {\n    return {\n      formatting: defaultDatetimeFormatting,\n    };\n  }\n\n  getDatetimeFormatting() {\n    return this.options?.formatting ?? defaultDatetimeFormatting;\n  }\n\n  validateOptions() {\n    return createdTimeFieldOptionsRoSchema.safeParse(this.options);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitCreatedTimeField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/date-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { datetimeFormattingSchema } from '../formatting';\n\nexport const dateFieldOptionsSchema = z.object({\n  formatting: datetimeFormattingSchema,\n  defaultValue: z\n    .enum(['now'] as const)\n    .optional()\n    .nullable()\n    .meta({\n      description:\n        'Whether the new row is automatically filled with the current time, caveat: the defaultValue is just a flag, it dose not effect the storing value of the record',\n    }),\n});\n\nexport type IDateFieldOptions = z.infer<typeof dateFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/date.field.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { ITimeZoneString } from '../formatting';\nimport { DateFormattingPreset, defaultDatetimeFormatting, TimeFormatting } from '../formatting';\nimport type { IDateFieldOptions } from './date-option.schema';\nimport { DateFieldCore } from './date.field';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst DEFAULT_TIME_ZONE = 'utc';\n\ndescribe('DateFieldCore', () => {\n  let field: DateFieldCore;\n  let lookupField: DateFieldCore;\n  const json = {\n    id: 'test',\n    name: 'Test Date Field',\n    description: 'A test date field',\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.Date,\n    dbFieldType: DbFieldType.DateTime,\n    options: {\n      formatting: {\n        date: DateFormattingPreset.Asian,\n        time: TimeFormatting.Hour24,\n        timeZone: DEFAULT_TIME_ZONE,\n      },\n      defaultValue: 'now',\n    },\n    cellValueType: CellValueType.DateTime,\n    isComputed: false,\n  };\n  const lookupJson = {\n    isLookup: true,\n    isComputed: true,\n    isMultipleCellValue: true,\n    dbFieldType: DbFieldType.Json,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(DateFieldCore, json);\n    lookupField = plainToInstance(DateFieldCore, {\n      ...json,\n      ...lookupJson,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(DateFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String('2023-06-19T06:50:48.017Z')).toBe('2023/06/19 06:50');\n    expect(lookupField.cellValue2String(null as any)).toBe('');\n    expect(lookupField.cellValue2String(['2023-06-19T06:50:48.017Z'])).toBe('2023/06/19 06:50');\n    expect(\n      lookupField.cellValue2String(['2023-06-19T06:50:48.017Z', '2023-06-19T06:50:48.017Z'])\n    ).toBe('2023/06/19 06:50, 2023/06/19 06:50');\n  });\n\n  it('should fallback to default formatting when formatting is missing', () => {\n    const fieldWithoutFormatting = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {},\n    });\n\n    const formatted = fieldWithoutFormatting.cellValue2String('2023-06-19T06:50:48.017Z');\n    expect(formatted).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n    expect(() => fieldWithoutFormatting.convertStringToCellValue('2023-06-19')).not.toThrow();\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('2023/06/19 06:50')).toBe('2023-06-19T06:50:00.000Z');\n    expect(field.convertStringToCellValue('abc')).toBeNull();\n    expect(lookupField.convertStringToCellValue('2023/06/19 06:50')).toBeNull();\n    expect(lookupField.convertStringToCellValue('abc')).toBeNull();\n    expect(field.convertStringToCellValue('2023/1/13 06:50')).toBe('2023-01-13T06:50:00.000Z');\n\n    // european and us date format\n    const europeanField = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.European,\n          time: TimeFormatting.Hour24,\n          timeZone: DEFAULT_TIME_ZONE,\n        },\n        defaultValue: 'now',\n      },\n    });\n    expect(europeanField.convertStringToCellValue('5/1/2024')).toBe('2024-01-05T00:00:00.000Z');\n    const usField = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.US,\n          time: TimeFormatting.Hour24,\n          timeZone: DEFAULT_TIME_ZONE,\n        },\n        defaultValue: 'now',\n      },\n    });\n    expect(usField.convertStringToCellValue('5/1/2024 06:50')).toBe('2024-05-01T06:50:00.000Z');\n  });\n\n  it('should parse text to date with Chinese date format', () => {\n    const fieldWithAsian = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {\n        formatting: {\n          date: 'YYYY 年 M 月 D 日',\n          time: TimeFormatting.None,\n          timeZone: 'Asia/Shanghai',\n        },\n      },\n    });\n    expect(fieldWithAsian.convertStringToCellValue('2025-10-12 11:17')).toBe(\n      '2025-10-12T03:17:00.000Z'\n    );\n    expect(fieldWithAsian.convertStringToCellValue('2025 年 10 月 12 日')).toBe(\n      '2025-10-11T16:00:00.000Z'\n    );\n  });\n\n  it('should parse single-digit month/day with seconds', () => {\n    const fieldWithAsian = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.Asian,\n          time: TimeFormatting.Hour24,\n          timeZone: 'Asia/Shanghai',\n        },\n      },\n    });\n    expect(fieldWithAsian.convertStringToCellValue('2025/7/31 14:15:32')).toBe(\n      '2025-07-31T06:15:32.000Z'\n    );\n  });\n\n  it('should parse single-digit month/day with seconds', () => {\n    const fieldWithAsian = plainToInstance(DateFieldCore, {\n      ...json,\n      options: {\n        formatting: {\n          date: DateFormattingPreset.Asian,\n          time: TimeFormatting.None,\n          timeZone: 'Asia/Shanghai',\n        },\n      },\n    });\n    expect(fieldWithAsian.convertStringToCellValue('2025/7/31 14:15:32')).toBe(\n      '2025-07-31T06:15:32.000Z'\n    );\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair(1687158022191)).toBe('2023-06-19T07:00:22.191Z');\n    expect(field.repair('xxx')).toBe(null);\n    expect(lookupField.repair(1687158022191)).toBe(null);\n    expect(lookupField.repair('xxx')).toBe(null);\n  });\n\n  it('should valid cellValue', () => {\n    const date = new Date();\n    const cellValue = date.toISOString();\n    const cellValueWithoutMs = '2026-01-06T00:00:00+00:00';\n    const lookupFieldOne = plainToInstance(DateFieldCore, {\n      ...json,\n      lookupJson,\n      isMultipleCellValue: false,\n    });\n    expect(field.validateCellValue(cellValue).success).toBe(true);\n    expect(field.validateCellValue(cellValueWithoutMs).success).toBe(true);\n    expect(field.validateCellValue(date.getTime()).success).toBe(false);\n    expect(field.validateCellValue('xxx').success).toBe(false);\n\n    expect(lookupField.validateCellValue([cellValue]).success).toBe(true);\n    expect(lookupField.validateCellValue([cellValueWithoutMs]).success).toBe(true);\n    expect(lookupField.validateCellValue(cellValue).success).toBe(false);\n    expect(lookupField.validateCellValue([{ id: 'actxxx' }]).success).toBe(false);\n\n    expect(lookupFieldOne.validateCellValue(cellValue).success).toBe(true);\n    expect(lookupFieldOne.validateCellValue(cellValueWithoutMs).success).toBe(true);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      const options: IDateFieldOptions = {\n        formatting: {\n          date: DateFormattingPreset.Y,\n          time: TimeFormatting.Hour24,\n          timeZone: DEFAULT_TIME_ZONE,\n        },\n        defaultValue: 'now',\n      };\n      const field = plainToInstance(DateFieldCore, {\n        ...json,\n        options,\n      });\n      expect(field.validateOptions().success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const optionsList: IDateFieldOptions[] = [\n        {\n          formatting: {\n            date: DateFormattingPreset.ISO,\n            time: 'abc' as any,\n            timeZone: DEFAULT_TIME_ZONE,\n          },\n        },\n        {\n          formatting: {\n            date: DateFormattingPreset.Y,\n            time: TimeFormatting.Hour24,\n            timeZone: 123 as unknown as ITimeZoneString,\n          },\n          defaultValue: 'now',\n        },\n        {\n          formatting: {\n            date: DateFormattingPreset.Y,\n            time: TimeFormatting.Hour24,\n            timeZone: DEFAULT_TIME_ZONE,\n          },\n          defaultValue: 'abc' as any,\n        },\n      ];\n\n      optionsList.forEach((options) => {\n        const field = plainToInstance(DateFieldCore, {\n          ...json,\n          options,\n        });\n        expect(field.validateOptions().success).toBe(false);\n      });\n    });\n\n    it('should get default options', () => {\n      expect(DateFieldCore.defaultOptions()).toEqual({\n        formatting: defaultDatetimeFormatting,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/date.field.ts",
    "content": "import dayjs, { extend } from 'dayjs';\nimport customParseFormat from 'dayjs/plugin/customParseFormat';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { TimeFormatting, defaultDatetimeFormatting, formatDateToString } from '../formatting';\nimport type { IDateFieldOptions } from './date-option.schema';\nimport { dateFieldOptionsSchema } from './date-option.schema';\n\nextend(timezone);\nextend(customParseFormat);\nextend(utc);\n\n// Stored date/time values can come from client-side `Date.toISOString()` (millisecond precision)\n// or from database JSON aggregation (which may omit fractional seconds when zero).\n// Accept both, while still requiring an explicit timezone offset (e.g. `Z`, `+00:00`).\nexport const dataFieldCellValueSchema = z.string().datetime({ offset: true });\n\nexport type IDateCellValue = z.infer<typeof dataFieldCellValueSchema>;\n\nexport class DateFieldCore extends FieldCore {\n  type!: FieldType.Date;\n\n  options!: IDateFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.DateTime;\n\n  static defaultOptions(): IDateFieldOptions {\n    return {\n      formatting: defaultDatetimeFormatting,\n    };\n  }\n\n  getDatetimeFormatting() {\n    return this.options?.formatting ?? defaultDatetimeFormatting;\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) return '';\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n\n    return this.item2String(cellValue as string);\n  }\n\n  private defaultTzFormat(value: string) {\n    const { timeZone } = this.getDatetimeFormatting();\n    try {\n      const formatValue = dayjs.tz(value, timeZone);\n      if (!formatValue.isValid()) return null;\n      return formatValue.toISOString();\n    } catch {\n      return null;\n    }\n  }\n\n  private parseUsingFieldFormatting(value: string): string | null {\n    const formatting = this.getDatetimeFormatting();\n    const hasTime = /\\d{1,2}:\\d{2}(?::\\d{2})?/.test(value);\n    const dateFormat = formatting.date;\n    const timeFormat = hasTime && formatting.time !== TimeFormatting.None ? formatting.time : null;\n    const format = timeFormat ? `${dateFormat} ${timeFormat}` : dateFormat;\n\n    try {\n      const check = dayjs(value, format, true).isValid();\n      if (!check) return null;\n      const formatValue = dayjs.tz(value, format, formatting.timeZone);\n      if (!formatValue.isValid()) return null;\n      const isoString = formatValue.toISOString();\n      if (isoString.startsWith('-')) return null;\n      return isoString;\n    } catch {\n      return null;\n    }\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  convertStringToCellValue(value: string): string | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (value === '' || value == null) return null;\n\n    if (value === 'now') {\n      return dayjs().toISOString();\n    }\n\n    const dayjsObj = dayjs(value);\n    if (dayjsObj.isValid() && dayjsObj.toISOString() === value) {\n      return value;\n    }\n\n    const formatted = this.parseUsingFieldFormatting(value);\n    if (formatted) return formatted;\n\n    return this.defaultTzFormat(value);\n  }\n\n  item2String(item?: unknown) {\n    return formatDateToString(item as string, this.getDatetimeFormatting());\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'string' || typeof value === 'number') {\n      return this.convertStringToCellValue(value as string);\n    }\n\n    return null;\n  }\n\n  validateOptions() {\n    return dateFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(cellValue: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(dataFieldCellValueSchema).nonempty().nullable().safeParse(cellValue);\n    }\n    return dataFieldCellValueSchema.nullable().safeParse(cellValue);\n  }\n\n  validateCellValueLoose(cellValue: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(z.string()).nonempty().nullable().safeParse(cellValue);\n    }\n    return z.string().nullable().safeParse(cellValue);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitDateField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/formula-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { timeZoneStringSchema, unionFormattingSchema } from '../formatting';\nimport { unionShowAsSchema } from '../show-as';\n\nexport const formulaFieldOptionsSchema = z.object({\n  expression: z.string().meta({\n    description:\n      'The formula including fields referenced by their IDs. For example, LEFT(4, {Birthday}) input will be returned as LEFT(4, {fldXXX}) via API.',\n  }),\n  timeZone: timeZoneStringSchema.optional(),\n  formatting: unionFormattingSchema.optional(),\n  showAs: unionShowAsSchema.optional(),\n});\n\nexport type IFormulaFieldOptions = z.infer<typeof formulaFieldOptionsSchema>;\n\nexport const formulaFieldMetaSchema = z.object({\n  persistedAsGeneratedColumn: z.boolean().optional().default(false).meta({\n    description:\n      'Whether this formula field is persisted as a generated column in the database. When true, the field value is computed and stored as a database generated column.',\n  }),\n});\n\nexport type IFormulaFieldMeta = z.infer<typeof formulaFieldMetaSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/formula.field.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { TableDomain } from '../../table/table-domain';\nimport { Colors } from '../colors';\nimport { DbFieldType, FieldType, CellValueType } from '../constant';\nimport { DateFormattingPreset, NumberFormattingType, TimeFormatting } from '../formatting';\nimport {\n  MultiNumberDisplayType,\n  SingleLineTextDisplayType,\n  SingleNumberDisplayType,\n} from '../show-as';\nimport { FormulaFieldCore } from './formula.field';\nimport { NumberFieldCore } from './number.field';\n\ndescribe('FormulaFieldCore', () => {\n  const singleNumberShowAsProps = {\n    type: SingleNumberDisplayType.Ring,\n    color: Colors.TealBright,\n    showValue: false,\n    maxValue: 100,\n  };\n\n  const multiNumberShowAsProps = {\n    type: MultiNumberDisplayType.Line,\n    color: Colors.TealBright,\n  };\n\n  const numberFormulaJson = {\n    id: 'fld666',\n    name: 'formulaField',\n    description: 'A test formula field',\n    notNull: false,\n    unique: false,\n    isPrimary: false,\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.Formula,\n    dbFieldType: DbFieldType.Real,\n    options: {\n      expression: '{fld123} + 2',\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      timeZone: 'Asia/Shanghai',\n      showAs: singleNumberShowAsProps,\n    },\n    meta: {\n      persistedAsGeneratedColumn: true,\n    },\n    cellValueType: CellValueType.Number,\n    isComputed: true,\n  };\n\n  const numberFormulaField = plainToInstance(FormulaFieldCore, numberFormulaJson);\n\n  const numberField = plainToInstance(NumberFieldCore, {\n    id: 'fld123',\n    name: 'testField',\n    description: 'A test number field',\n    notNull: true,\n    unique: true,\n    isPrimary: true,\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.Number,\n    dbFieldType: DbFieldType.Real,\n    options: {\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n    },\n    cellValueType: CellValueType.Number,\n    isComputed: false,\n  });\n\n  const stringFormulaField = plainToInstance(FormulaFieldCore, {\n    ...numberFormulaJson,\n    options: {\n      expression: 'text',\n      showAs: {\n        type: SingleLineTextDisplayType.Url,\n      },\n    },\n    cellValueType: CellValueType.String,\n  });\n\n  const dateFormulaField = plainToInstance(FormulaFieldCore, {\n    ...numberFormulaJson,\n    options: {\n      ...numberFormulaJson.options,\n      formatting: {\n        date: DateFormattingPreset.US,\n        time: TimeFormatting.None,\n        timeZone: 'utc',\n      },\n      showAs: undefined,\n    },\n    cellValueType: CellValueType.DateTime,\n  });\n\n  const booleanFormulaField = plainToInstance(FormulaFieldCore, {\n    ...numberFormulaJson,\n    options: {\n      ...numberFormulaJson.options,\n      formatting: undefined,\n      showAs: undefined,\n    },\n    cellValueType: CellValueType.Boolean,\n  });\n\n  const lookupMultipleFormulaField = plainToInstance(FormulaFieldCore, {\n    ...numberFormulaJson,\n    options: {\n      ...numberFormulaJson.options,\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      showAs: multiNumberShowAsProps,\n    },\n    cellValueType: CellValueType.Number,\n    isLookup: true,\n    isMultipleCellValue: true,\n  });\n\n  const invalidShowAsTestCases = [\n    {\n      ...numberFormulaJson,\n      options: {\n        ...numberFormulaJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: true,\n      isLookup: true,\n    },\n    {\n      ...numberFormulaJson,\n      options: {\n        ...numberFormulaJson.options,\n        showAs: multiNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberFormulaJson,\n      options: {\n        ...numberFormulaJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberFormulaJson,\n      options: {\n        expression: '\"abc\"',\n        showAs: {\n          type: 'test',\n        },\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberFormulaJson,\n      options: {\n        ...numberFormulaJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.DateTime,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberFormulaJson,\n      options: {\n        ...numberFormulaJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Boolean,\n      isMultipleCellValue: false,\n    },\n  ];\n\n  describe('basic function', () => {\n    it('should convert cellValue to string', () => {\n      expect(numberFormulaField.cellValue2String(1)).toBe('1.00');\n      expect(stringFormulaField.cellValue2String('text')).toBe('text');\n      expect(dateFormulaField.cellValue2String('2023-06-19T06:50:48.017Z')).toBe('6/19/2023');\n      expect(booleanFormulaField.cellValue2String(true)).toBe('true');\n      expect(lookupMultipleFormulaField.cellValue2String([1, 2, 3])).toBe('1.00, 2.00, 3.00');\n    });\n\n    it('should validate cellValue', () => {\n      expect(numberFormulaField.validateCellValue(1).success).toBe(true);\n      expect(numberFormulaField.validateCellValue('1').success).toBe(false);\n      expect(stringFormulaField.validateCellValue('text').success).toBe(true);\n      expect(stringFormulaField.validateCellValue(666).success).toBe(false);\n      expect(dateFormulaField.validateCellValue('date').success).toBe(false);\n      expect(dateFormulaField.validateCellValue('2023-06-19T06:50:48.017Z').success).toBe(true);\n      expect(booleanFormulaField.validateCellValue(true).success).toBe(true);\n      expect(booleanFormulaField.validateCellValue('true').success).toBe(false);\n      expect(lookupMultipleFormulaField.validateCellValue([1]).success).toBe(true);\n      expect(lookupMultipleFormulaField.validateCellValue(1).success).toBe(false);\n    });\n\n    it('should convert string to cellValue', () => {\n      expect(numberFormulaField.convertStringToCellValue('1')).toBe(null);\n    });\n\n    it('should repair invalid value', () => {\n      expect(numberFormulaField.repair(1)).toBe(null);\n    });\n  });\n\n  describe('calculation', () => {\n    it('should parse the expression correctly', () => {\n      const expression = '2 + 2';\n      const parsed = FormulaFieldCore.parse(expression);\n      expect(parsed).toBeDefined();\n      // add more specific checks based on the return type of parse()\n    });\n\n    it('should convert field ids to names correctly', () => {\n      const expression = '{fld123} + 1';\n      const dependFieldMap = {\n        fld123: { name: 'testField' },\n        // add more fields if needed\n      };\n      const converted = FormulaFieldCore.convertExpressionIdToName(expression, dependFieldMap);\n      expect(converted).toBe('{testField} + 1');\n    });\n\n    it('should convert field names to ids correctly', () => {\n      const expression = '{testField} + 1';\n      const dependFieldMap = {\n        fld123: { name: 'testField' },\n        // add more fields if needed\n      };\n      const converted = FormulaFieldCore.convertExpressionNameToId(expression, dependFieldMap);\n      expect(converted).toBe('{fld123} + 1');\n    });\n\n    it('should return current typed value with field context', () => {\n      expect(FormulaFieldCore.getParsedValueType('2 + 2', {})).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        FormulaFieldCore.getParsedValueType('{fld123}', {\n          fld123: numberField,\n        })\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        FormulaFieldCore.getParsedValueType('{fld123}', {\n          fld123: numberField,\n        })\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n    });\n\n    it('should reject LAST_MODIFIED_TIME with non-field parameters during parsing', () => {\n      expect(() => FormulaFieldCore.getParsedValueType('LAST_MODIFIED_TIME(\"oops\")', {})).toThrow(\n        'LAST_MODIFIED_TIME parameter must be a field reference'\n      );\n    });\n\n    it('should return current fieldIds by getReferenceFieldIds', () => {\n      expect(numberFormulaField.getReferenceFieldIds()).toEqual(['fld123']);\n    });\n\n    it('should return eval result by evaluate', () => {\n      expect(\n        numberFormulaField\n          .evaluate(\n            {\n              fld123: numberField,\n            },\n            {\n              id: 'rec123',\n              fields: {\n                fld123: 1,\n              },\n            }\n          )\n          .toPlain()\n      ).toEqual(3);\n    });\n  });\n\n  describe('reference resolution', () => {\n    it('should detect missing references recursively', () => {\n      // f1 references missing fld999\n      const f1 = plainToInstance(FormulaFieldCore, {\n        id: 'fldF1',\n        name: 'F1',\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Real,\n        options: { expression: '{fld999} * 2' },\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n      });\n\n      // f2 references f1\n      const f2 = plainToInstance(FormulaFieldCore, {\n        id: 'fldF2',\n        name: 'F2',\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Real,\n        options: { expression: '{fldF1} * 2' },\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n      });\n\n      const table = new TableDomain({\n        id: 'tbl',\n        name: 'tbl',\n        dbTableName: 'tbl',\n        lastModifiedTime: new Date().toISOString(),\n        fields: [f1, f2],\n      });\n\n      expect(f1.hasUnresolvedReferences(table)).toBe(true);\n      expect(f2.hasUnresolvedReferences(table)).toBe(true);\n    });\n\n    it('should return false when all references exist', () => {\n      const num = numberField; // fld123 exists\n      const f1 = plainToInstance(FormulaFieldCore, {\n        id: 'fldF1',\n        name: 'F1',\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Real,\n        options: { expression: '{fld123} * 2' },\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n      });\n      const f2 = plainToInstance(FormulaFieldCore, {\n        id: 'fldF2',\n        name: 'F2',\n        type: FieldType.Formula,\n        dbFieldType: DbFieldType.Real,\n        options: { expression: '{fldF1} * 2' },\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n      });\n\n      const table = new TableDomain({\n        id: 'tbl',\n        name: 'tbl',\n        dbTableName: 'tbl',\n        lastModifiedTime: new Date().toISOString(),\n        fields: [num, f1, f2],\n      });\n\n      expect(f1.hasUnresolvedReferences(table)).toBe(false);\n      expect(f2.hasUnresolvedReferences(table)).toBe(false);\n    });\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(numberFormulaField.validateOptions().success).toBeTruthy();\n      expect(stringFormulaField.validateOptions().success).toBeTruthy();\n      expect(dateFormulaField.validateOptions().success).toBeTruthy();\n      expect(booleanFormulaField.validateOptions().success).toBeTruthy();\n      expect(lookupMultipleFormulaField.validateOptions().success).toBeTruthy();\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(FormulaFieldCore, {\n          ...numberFormulaJson,\n          options: {\n            expression: '',\n          },\n          cellValueType: CellValueType.Number,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(FormulaFieldCore, {\n          ...numberFormulaJson,\n          options: {\n            expression: '',\n            formatting: {\n              date: DateFormattingPreset.US,\n              time: TimeFormatting.None,\n              timeZone: 'xxx/xxx',\n            },\n          },\n          cellValueType: CellValueType.DateTime,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(FormulaFieldCore, {\n          ...numberFormulaJson,\n          options: {\n            expression: '',\n            formatting: {\n              type: NumberFormattingType.Decimal,\n              precision: 2,\n            },\n          },\n          cellValueType: CellValueType.String,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(FormulaFieldCore, {\n          ...numberFormulaJson,\n          options: {\n            expression: '',\n            formatting: {\n              type: NumberFormattingType.Decimal,\n              precision: 2,\n            },\n          },\n          cellValueType: CellValueType.Boolean,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      invalidShowAsTestCases.forEach((field) => {\n        expect(plainToInstance(FormulaFieldCore, field).validateOptions().success).toBeFalsy();\n      });\n    });\n\n    it('should get default options', () => {\n      expect(FormulaFieldCore.defaultOptions(CellValueType.Number)).toMatchObject({\n        expression: '',\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        },\n      });\n    });\n  });\n\n  describe('meta field', () => {\n    it('should support meta field with persistedAsGeneratedColumn', () => {\n      const formulaWithMeta = plainToInstance(FormulaFieldCore, {\n        ...numberFormulaJson,\n        meta: {\n          persistedAsGeneratedColumn: true,\n        },\n      });\n\n      expect(formulaWithMeta.meta).toEqual({\n        persistedAsGeneratedColumn: true,\n      });\n    });\n\n    it('should support meta field with default value', () => {\n      const formulaWithMeta = plainToInstance(FormulaFieldCore, {\n        ...numberFormulaJson,\n        meta: {\n          persistedAsGeneratedColumn: false,\n        },\n      });\n\n      expect(formulaWithMeta.meta).toEqual({\n        persistedAsGeneratedColumn: false,\n      });\n    });\n\n    it('should work without meta field', () => {\n      const formulaWithoutMeta = plainToInstance(FormulaFieldCore, {\n        ...numberFormulaJson,\n        meta: undefined,\n      });\n\n      expect(formulaWithoutMeta.meta).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/formula.field.ts",
    "content": "import { ConversionVisitor, FieldReferenceVisitor } from '@teable/formula';\nimport { z } from 'zod';\nimport { EvalVisitor } from '../../../formula';\nimport type { TableDomain } from '../../table/table-domain';\nimport type { CellValueType } from '../constant';\nimport { FieldType } from '../constant';\nimport type { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { isLinkField } from '../field.util';\nimport { getFormattingSchema, getDefaultFormatting } from '../formatting';\nimport { getShowAsSchema } from '../show-as';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport { type IFormulaFieldMeta, type IFormulaFieldOptions } from './formula-option.schema';\nimport type { LinkFieldCore } from './link.field';\n\nconst formulaFieldCellValueSchema = z.any();\n\nexport type IFormulaCellValue = z.infer<typeof formulaFieldCellValueSchema>;\n\nexport class FormulaFieldCore extends FormulaAbstractCore {\n  static defaultOptions(cellValueType: CellValueType): IFormulaFieldOptions {\n    return {\n      expression: '',\n      timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n      formatting: getDefaultFormatting(cellValueType),\n    };\n  }\n\n  static convertExpressionIdToName(\n    expression: string,\n    dependFieldMap: { [fieldId: string]: { name: string } }\n  ): string {\n    const tree = this.parse(expression);\n    const nameToId = Object.entries(dependFieldMap).reduce<{ [fieldId: string]: string }>(\n      (acc, [fieldId, field]) => {\n        acc[fieldId] = field?.name;\n        return acc;\n      },\n      {}\n    );\n    const visitor = new ConversionVisitor(nameToId);\n    visitor.safe().visit(tree);\n    return visitor.getResult();\n  }\n\n  static convertExpressionNameToId(\n    expression: string,\n    dependFieldMap: { [fieldId: string]: { name: string } }\n  ): string {\n    const tree = this.parse(expression);\n    const idToName = Object.entries(dependFieldMap).reduce<{ [fieldName: string]: string }>(\n      (acc, [fieldId, field]) => {\n        acc[field.name] = fieldId;\n        return acc;\n      },\n      {}\n    );\n    const visitor = new ConversionVisitor(idToName);\n    visitor.visit(tree);\n    return visitor.getResult();\n  }\n\n  static getReferenceFieldIds(expression: string) {\n    const tree = this.parse(expression);\n    const visitor = new FieldReferenceVisitor();\n    return Array.from(new Set(visitor.visit(tree)));\n  }\n\n  static getParsedValueType(expression: string, dependFieldMap: { [fieldId: string]: FieldCore }) {\n    const tree = this.parse(expression);\n    const visitor = new EvalVisitor(dependFieldMap);\n    const typedValue = visitor.visit(tree);\n    return {\n      cellValueType: typedValue.type,\n      isMultipleCellValue: typedValue.isMultiple,\n    };\n  }\n\n  type!: FieldType.Formula;\n\n  declare options: IFormulaFieldOptions;\n\n  declare meta?: IFormulaFieldMeta;\n\n  getExpression(): string {\n    return this.options.expression;\n  }\n\n  getReferenceFieldIds() {\n    const visitor = new FieldReferenceVisitor();\n    return Array.from(new Set(visitor.visit(this.tree)));\n  }\n\n  /**\n   * Get referenced fields from a table domain\n   * @param tableDomain - The table domain to search for referenced fields\n   * @returns Array of referenced field instances\n   */\n  getReferenceFields(tableDomain: TableDomain): FieldCore[] {\n    const referenceFieldIds = this.getReferenceFieldIds();\n    const referenceFields: FieldCore[] = [];\n\n    for (const fieldId of referenceFieldIds) {\n      const field = tableDomain.getField(fieldId);\n      if (field) {\n        referenceFields.push(field);\n      }\n    }\n\n    return referenceFields;\n  }\n\n  /**\n   * Check recursively whether all references in this formula are resolvable in the given table\n   * - Missing referenced field returns true (unresolved)\n   * - If a referenced formula exists but itself has unresolved references (or hasError), returns true\n   */\n  hasUnresolvedReferences(tableDomain: TableDomain, visited: Set<string> = new Set()): boolean {\n    // Prevent infinite loops on circular references\n    if (visited.has(this.id)) return false;\n    visited.add(this.id);\n\n    const ids = this.getReferenceFieldIds();\n    for (const id of ids) {\n      const ref = tableDomain.getField(id);\n      if (!ref) return true;\n      if (ref.hasError) return true;\n      // Drill down if the referenced field is a formula\n      if (ref.type === FieldType.Formula && !ref.isLookup) {\n        const refFormula = ref as FormulaFieldCore;\n        if (refFormula.hasUnresolvedReferences(tableDomain, visited)) return true;\n      }\n    }\n\n    return false;\n  }\n\n  override getLinkFields(tableDomain: TableDomain): LinkFieldCore[] {\n    return this.getReferenceFields(tableDomain).flatMap((field) => {\n      if (isLinkField(field)) {\n        return field;\n      }\n      return field.getLinkFields(tableDomain);\n    });\n  }\n\n  /**\n   * Get the generated column name for database-generated formula fields\n   * This should match the naming convention used in database-column-visitor\n   */\n  getGeneratedColumnName(): string {\n    return this.dbFieldName;\n  }\n\n  getIsPersistedAsGeneratedColumn() {\n    return this.meta?.persistedAsGeneratedColumn || false;\n  }\n\n  /**\n   * Recalculates and updates the cellValueType, isMultipleCellValue, and dbFieldType for this formula field\n   * based on its expression and the current field context\n   * @param fieldMap Map of field ID to field instance for context\n   */\n  recalculateFieldTypes(fieldMap: Record<string, FieldCore>): void {\n    const { cellValueType, isMultipleCellValue } = FormulaFieldCore.getParsedValueType(\n      this.options.expression,\n      fieldMap\n    );\n\n    this.cellValueType = cellValueType;\n    this.isMultipleCellValue = isMultipleCellValue;\n    // Update dbFieldType using the base class method\n    this.updateDbFieldType();\n  }\n\n  validateOptions() {\n    return z\n      .object({\n        expression: z.string(),\n        formatting: getFormattingSchema(this.cellValueType),\n        showAs: getShowAsSchema(this.cellValueType, this.isMultipleCellValue),\n      })\n      .safeParse(this.options);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitFormulaField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/index.ts",
    "content": "export * from './number.field';\nexport * from './number-option.schema';\nexport * from './single-line-text.field';\nexport * from './single-line-text-option.schema';\nexport * from './long-text.field';\nexport * from './long-text-option.schema';\nexport * from './single-select.field';\nexport * from './multiple-select.field';\nexport * from './link.field';\nexport * from './link-option.schema';\nexport * from './formula.field';\nexport * from './formula-option.schema';\nexport * from './abstract/select.field.abstract';\nexport * from './abstract/formula.field.abstract';\nexport * from './abstract/user.field.abstract';\nexport * from './attachment.field';\nexport * from './attachment-option.schema';\nexport * from './date.field';\nexport * from './date-option.schema';\nexport * from './created-time.field';\nexport * from './created-time-option.schema';\nexport * from './last-modified-time.field';\nexport * from './last-modified-time-option.schema';\nexport * from './checkbox.field';\nexport * from './checkbox-option.schema';\nexport * from './rollup.field';\nexport * from './rollup-option.schema';\nexport * from './conditional-rollup.field';\nexport * from './conditional-rollup-option.schema';\nexport * from './rating.field';\nexport * from './rating-option.schema';\nexport * from './auto-number.field';\nexport * from './auto-number-option.schema';\nexport * from './user.field';\nexport * from './user-option.schema';\nexport * from './created-by.field';\nexport * from './created-by-option.schema';\nexport * from './last-modified-by.field';\nexport * from './last-modified-by-option.schema';\nexport * from './button.field';\nexport * from './button-option.schema';\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/last-modified-by-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const lastModifiedByFieldOptionsSchema = z\n  .object({\n    trackedFieldIds: z.array(z.string()).optional(),\n  })\n  .strict();\n\nexport type ILastModifiedByFieldOptions = z.infer<typeof lastModifiedByFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/last-modified-by.field.ts",
    "content": "import type { FieldType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { UserAbstractCore } from './abstract/user.field.abstract';\nimport type { IFormulaFieldMeta } from './formula-option.schema';\nimport type { ILastModifiedByFieldOptions } from './last-modified-by-option.schema';\nimport { lastModifiedByFieldOptionsSchema } from './last-modified-by-option.schema';\n\nexport class LastModifiedByFieldCore extends UserAbstractCore {\n  type!: FieldType.LastModifiedBy;\n  options!: ILastModifiedByFieldOptions;\n  declare meta?: IFormulaFieldMeta;\n\n  override get isStructuredCellValue() {\n    return true;\n  }\n\n  convertStringToCellValue(_value: string) {\n    return null;\n  }\n\n  getTrackedFieldIds(): string[] {\n    return this.options?.trackedFieldIds ?? [];\n  }\n\n  isTrackAll(): boolean {\n    return this.getTrackedFieldIds().length === 0;\n  }\n\n  shouldUpdate(changedFieldIds: Set<string>): boolean {\n    const trackedFieldIds = this.getTrackedFieldIds();\n    return this.isTrackAll() || trackedFieldIds.some((id) => changedFieldIds.has(id));\n  }\n\n  getIsPersistedAsGeneratedColumn(): boolean {\n    return this.meta?.persistedAsGeneratedColumn === true;\n  }\n\n  shouldPersistAuditValue(): boolean {\n    return !this.isLookup && !this.getIsPersistedAsGeneratedColumn();\n  }\n\n  repair(_value: unknown) {\n    return null;\n  }\n\n  validateOptions() {\n    return lastModifiedByFieldOptionsSchema.safeParse(this.options);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitLastModifiedByField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/last-modified-time-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { datetimeFormattingSchema } from '../formatting';\n\nexport const lastModifiedTimeFieldOptionsSchema = z\n  .object({\n    expression: z.literal('LAST_MODIFIED_TIME()').default('LAST_MODIFIED_TIME()'),\n    formatting: datetimeFormattingSchema.optional(),\n    trackedFieldIds: z.array(z.string()).optional(),\n  })\n  .passthrough();\n\nexport type ILastModifiedTimeFieldOptions = z.infer<typeof lastModifiedTimeFieldOptionsSchema>;\n\nexport const lastModifiedTimeFieldOptionsRoSchema = lastModifiedTimeFieldOptionsSchema;\n\nexport type ILastModifiedTimeFieldOptionsRo = z.infer<typeof lastModifiedTimeFieldOptionsRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/last-modified-time.field.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { DateFormattingPreset, defaultDatetimeFormatting } from '../formatting';\nimport { LastModifiedTimeFieldCore } from './last-modified-time.field';\n\ndescribe('LastModifiedTimeFieldCore', () => {\n  const lastModifiedTimeJson = {\n    id: 'fld123',\n    name: 'Last modified time',\n    description: 'A test last modified time field',\n    notNull: false,\n    unique: false,\n    isPrimary: false,\n    columnMeta: {\n      index: 0,\n      columnIndex: 0,\n    },\n    type: FieldType.LastModifiedTime,\n    options: {\n      formatting: defaultDatetimeFormatting,\n    },\n    dbFieldType: DbFieldType.DateTime,\n    cellValueType: CellValueType.DateTime,\n    isComputed: true,\n  };\n\n  const lastModifiedTimeField = plainToInstance(LastModifiedTimeFieldCore, {\n    ...lastModifiedTimeJson,\n  });\n\n  describe('basic function', () => {\n    it('should convert cellValue to string', () => {\n      expect(lastModifiedTimeField.cellValue2String('2023-11-28T06:50:48.017Z')).toBe('2023-11-28');\n    });\n\n    it('should validate cellValue', () => {\n      expect(lastModifiedTimeField.validateCellValue('date').success).toBe(false);\n      expect(lastModifiedTimeField.validateCellValue('2023-11-28T06:50:48.017Z').success).toBe(\n        true\n      );\n    });\n\n    it('should convert string to cellValue', () => {\n      expect(lastModifiedTimeField.convertStringToCellValue('1')).toBe(null);\n    });\n\n    it('should repair invalid value', () => {\n      expect(lastModifiedTimeField.repair(1)).toBe(null);\n    });\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(lastModifiedTimeField.validateOptions().success).toBeTruthy();\n    });\n\n    it('should return success if specific fields are provided', () => {\n      const field = plainToInstance(LastModifiedTimeFieldCore, {\n        ...lastModifiedTimeJson,\n        options: {\n          ...lastModifiedTimeJson.options,\n          trackedFieldIds: ['fldA', 'fldB'],\n        },\n      });\n\n      expect(field.validateOptions().success).toBeTruthy();\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(LastModifiedTimeFieldCore, {\n          ...lastModifiedTimeJson,\n          options: {\n            ...lastModifiedTimeJson.options,\n            formatting: {\n              date: DateFormattingPreset.ISO,\n              time: 'abc',\n              timeZone: 'utc',\n            },\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n    });\n\n    it('should get default options', () => {\n      expect(LastModifiedTimeFieldCore.defaultOptions()).toEqual({\n        formatting: defaultDatetimeFormatting,\n        expression: 'LAST_MODIFIED_TIME()',\n        trackedFieldIds: [],\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/last-modified-time.field.ts",
    "content": "import { extend } from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport type { FieldType, CellValueType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { defaultDatetimeFormatting } from '../formatting';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport type { IFormulaFieldMeta } from './formula-option.schema';\nimport type {\n  ILastModifiedTimeFieldOptions,\n  ILastModifiedTimeFieldOptionsRo,\n} from './last-modified-time-option.schema';\nimport { lastModifiedTimeFieldOptionsRoSchema } from './last-modified-time-option.schema';\n\nextend(timezone);\n\nexport class LastModifiedTimeFieldCore extends FormulaAbstractCore {\n  type!: FieldType.LastModifiedTime;\n\n  declare options: ILastModifiedTimeFieldOptions;\n\n  declare meta?: IFormulaFieldMeta;\n\n  declare cellValueType: CellValueType.DateTime;\n\n  static defaultOptions(): ILastModifiedTimeFieldOptionsRo {\n    return {\n      formatting: defaultDatetimeFormatting,\n      expression: 'LAST_MODIFIED_TIME()',\n      trackedFieldIds: [],\n    };\n  }\n\n  validateOptions() {\n    return lastModifiedTimeFieldOptionsRoSchema.safeParse(this.options);\n  }\n\n  getExpression() {\n    return this.options.expression;\n  }\n\n  getDatetimeFormatting() {\n    return this.options?.formatting ?? defaultDatetimeFormatting;\n  }\n\n  getTrackedFieldIds(): string[] {\n    return this.options?.trackedFieldIds ?? [];\n  }\n\n  isTrackAll(): boolean {\n    const persistedAsGeneratedColumn = this.meta?.persistedAsGeneratedColumn;\n    return persistedAsGeneratedColumn !== false && this.getTrackedFieldIds().length === 0;\n  }\n\n  shouldUpdate(changedFieldIds: Set<string>): boolean {\n    const trackedFieldIds = this.getTrackedFieldIds();\n    return this.isTrackAll() || trackedFieldIds.some((id) => changedFieldIds.has(id));\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitLastModifiedTimeField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/link-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { filterSchema } from '../../view/filter';\nimport { Relationship } from '../constant';\n\nexport const linkFieldOptionsSchema = z\n  .object({\n    baseId: z.string().optional().meta({\n      description:\n        'the base id of the table that this field is linked to, only required for cross base link',\n    }),\n    relationship: z.enum(Relationship).meta({\n      description: 'describe the relationship from this table to the foreign table',\n    }),\n    foreignTableId: z.string().meta({\n      description: 'the table this field is linked to',\n    }),\n    lookupFieldId: z.string().meta({\n      description: 'the field in the foreign table that will be displayed as the current field',\n    }),\n    isOneWay: z.boolean().optional().meta({\n      description:\n        'whether the field is a one-way link, when true, it will not generate a symmetric field, it is generally has better performance',\n    }),\n    fkHostTableName: z.string().meta({\n      description:\n        'the table name for storing keys, in many-to-many relationships, keys are stored in a separate intermediate table; in other relationships, keys are stored on one side as needed',\n    }),\n    selfKeyName: z.string().meta({\n      description: 'the name of the field that stores the current table primary key',\n    }),\n    foreignKeyName: z.string().meta({\n      description: 'The name of the field that stores the foreign table primary key',\n    }),\n    symmetricFieldId: z.string().optional().meta({\n      description: 'the symmetric field in the foreign table, empty if the field is a one-way link',\n    }),\n    filterByViewId: z.string().nullable().optional().meta({\n      description: 'the view id that limits the number of records in the link field',\n    }),\n    visibleFieldIds: z.array(z.string()).nullable().optional().meta({\n      description: 'the fields that will be displayed in the link field',\n    }),\n    filter: filterSchema.optional(),\n  })\n  .strip();\n\nexport type ILinkFieldOptions = z.infer<typeof linkFieldOptionsSchema>;\n\nexport const linkFieldMetaSchema = z.object({\n  hasOrderColumn: z.boolean().optional().default(false).meta({\n    description:\n      'Whether this link field has an order column for maintaining insertion order. When true, the field uses a separate order column to preserve the order of linked records.',\n  }),\n});\n\nexport type ILinkFieldMeta = z.infer<typeof linkFieldMetaSchema>;\n\nexport const linkFieldOptionsRoSchema = linkFieldOptionsSchema\n  .pick({\n    baseId: true,\n    relationship: true,\n    foreignTableId: true,\n    isOneWay: true,\n    filterByViewId: true,\n    visibleFieldIds: true,\n    filter: true,\n  })\n  .extend({\n    lookupFieldId: z.string().optional(),\n  });\n\nexport type ILinkFieldOptionsRo = z.infer<typeof linkFieldOptionsRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/link.field.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType, Relationship } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVo } from '../field.schema';\nimport { linkFieldOptionsRoSchema } from './link-option.schema';\nimport type { ILinkCellValue } from './link.field';\nimport { LinkFieldCore } from './link.field';\n\ndescribe('LinkFieldCore', () => {\n  let field: LinkFieldCore;\n  let lookupField: LinkFieldCore;\n  let fieldMultiple: LinkFieldCore;\n  const json: IFieldVo = {\n    id: 'fldtestxxxxxx',\n    dbFieldName: 'fldtestxxxxxx',\n    name: 'Test link Field',\n    description: 'A test link field',\n    options: {\n      relationship: Relationship.ManyOne,\n      foreignTableId: 'tblxxxxxxx',\n      lookupFieldId: 'fldxxxxxxx',\n      fkHostTableName: 'dbTableName',\n      selfKeyName: '__id',\n      foreignKeyName: '__fk_fldxxxxxxx',\n      symmetricFieldId: 'fldxxxxxxx',\n      filterByViewId: 'viwxxxxxxx',\n      visibleFieldIds: ['fldxxxxxxx'],\n      filter: {\n        conjunction: 'and',\n        filterSet: [],\n      },\n    },\n    type: FieldType.Link,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n\n  const lookupJson = {\n    isLookup: true,\n    isComputed: true,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(LinkFieldCore, json);\n    lookupField = plainToInstance(LinkFieldCore, {\n      ...json,\n      ...lookupJson,\n    });\n    fieldMultiple = plainToInstance(LinkFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(LinkFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    const cellValue: ILinkCellValue = {\n      id: 'recxxxxxxxx',\n      title: 'record 1',\n    };\n\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String(cellValue)).toEqual('record 1');\n\n    expect(lookupField.cellValue2String(null as any)).toEqual('');\n    expect(lookupField.cellValue2String(cellValue)).toEqual('record 1');\n\n    expect(fieldMultiple.cellValue2String(null as any)).toEqual('');\n    expect(fieldMultiple.cellValue2String(cellValue)).toEqual('record 1');\n    expect(fieldMultiple.cellValue2String([cellValue, cellValue])).toEqual('record 1, record 1');\n    expect(fieldMultiple.cellValue2String([cellValue, { id: 'rec22222222' }])).toEqual(\n      'record 1, '\n    );\n  });\n\n  it('should validate cellValue', () => {\n    const cellValue: ILinkCellValue = {\n      id: 'recxxxxxxxx',\n      title: 'record 1',\n    };\n    const singleFieldFromArray = field.validateCellValue([cellValue]);\n    const lookupFieldFromArray = lookupField.validateCellValue([cellValue]);\n    const multipleFieldFromSingle = fieldMultiple.validateCellValue(cellValue);\n\n    expect(field.validateCellValue(null as any).success).toBe(true);\n    expect(field.validateCellValue(cellValue).success).toBe(true);\n    expect(field.validateCellValue({ id: 'recXXXXXXXX ' }).success).toBe(true);\n    expect(field.validateCellValue({ id: 'xxxxxxxxxxx ' }).success).toBe(false);\n    expect(singleFieldFromArray.success).toBe(true);\n    expect(singleFieldFromArray.success && singleFieldFromArray.data).toEqual(cellValue);\n\n    expect(lookupField.validateCellValue(null as any).success).toBe(true);\n    expect(lookupField.validateCellValue(cellValue).success).toBe(true);\n    expect(lookupFieldFromArray.success).toBe(true);\n    expect(lookupFieldFromArray.success && lookupFieldFromArray.data).toEqual(cellValue);\n\n    expect(fieldMultiple.validateCellValue(null as any).success).toBe(true);\n    expect(multipleFieldFromSingle.success).toBe(true);\n    expect(multipleFieldFromSingle.success && multipleFieldFromSingle.data).toEqual([cellValue]);\n    expect(fieldMultiple.validateCellValue([cellValue, cellValue]).success).toBe(true);\n    expect(fieldMultiple.validateCellValue([]).success).toBe(false);\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(lookupField.convertStringToCellValue('text')).toBeNull();\n  });\n\n  it('should convert item to string', () => {\n    expect(field.item2String({ id: 'rec' })).toBe('');\n    expect(field.item2String({ id: 'rec', title: 'A1' })).toBe('A1');\n    expect(field.item2String(null)).toBe('');\n  });\n\n  it('should repair invalid value', () => {\n    const cellValue: ILinkCellValue = {\n      id: 'recxxxxxxxx',\n      title: 'record 1',\n    };\n    expect(field.repair(cellValue)).toEqual(cellValue);\n    expect(field.repair([cellValue])).toEqual(cellValue);\n    expect(field.repair([{ id: 'actxxx' }])).toEqual(null);\n\n    expect(lookupField.repair(cellValue)).toEqual(null);\n    expect(lookupField.repair([{ id: 'actxxx' }])).toEqual(null);\n\n    expect(fieldMultiple.repair(cellValue)).toEqual([cellValue]);\n    expect(fieldMultiple.repair([cellValue])).toEqual([cellValue]);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(field.validateOptions().success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(LinkFieldCore, {\n        ...json,\n        options: null,\n      });\n      expect(field.validateOptions().success).toBe(false);\n    });\n\n    it('should validate ro schema with strip', () => {\n      const object = {\n        relationship: 'manyOne',\n        foreignTableId: 'tblERSkHpp4KDRK1hvL',\n        lookupFieldId: 'fldXWPHcgSGeKgFFuOI',\n        dbForeignKeyName: '__fk_fldiBBKwOZuW8rlrtoW',\n        symmetricFieldId: 'fld8bh5u0MkjdmtFCxv',\n        filterByViewId: 'viwXWPHcgSGeKgFFuOI',\n        visibleFieldIds: ['fldXWPHcgSGeKgFFuOI'],\n        filter: {\n          conjunction: 'and',\n          filterSet: [],\n        },\n      };\n      expect(linkFieldOptionsRoSchema.safeParse(object).success).toBeTruthy();\n      expect(linkFieldOptionsRoSchema.parse(object)).toEqual({\n        relationship: 'manyOne',\n        foreignTableId: 'tblERSkHpp4KDRK1hvL',\n        lookupFieldId: 'fldXWPHcgSGeKgFFuOI',\n        filterByViewId: 'viwXWPHcgSGeKgFFuOI',\n        visibleFieldIds: ['fldXWPHcgSGeKgFFuOI'],\n        filter: {\n          conjunction: 'and',\n          filterSet: [],\n        },\n      });\n    });\n  });\n\n  describe('getForeignTableId', () => {\n    it('should return the foreign table ID from options', () => {\n      expect(field.getForeignTableId()).toBe('tblxxxxxxx');\n    });\n\n    it('should return undefined if no foreign table ID is set', () => {\n      const fieldWithoutForeignTable = plainToInstance(LinkFieldCore, {\n        ...json,\n        options: {\n          ...json.options,\n          foreignTableId: undefined,\n        },\n      });\n      expect(fieldWithoutForeignTable.getForeignTableId()).toBeUndefined();\n    });\n  });\n\n  describe('getForeignLookupField', () => {\n    it('should return the lookup field when table IDs match', () => {\n      const mockLookupField = { id: 'fldxxxxxxx', name: 'Lookup Field' } as any;\n      const mockTableDomain = {\n        id: 'tblxxxxxxx', // Matches the foreign table ID\n        getField: vi.fn((fieldId: string) => {\n          if (fieldId === 'fldxxxxxxx') {\n            return mockLookupField;\n          }\n          return undefined;\n        }),\n      } as any;\n\n      const result = field.getForeignLookupField(mockTableDomain);\n\n      expect(result).toBe(mockLookupField);\n      expect(mockTableDomain.getField).toHaveBeenCalledWith('fldxxxxxxx');\n    });\n\n    it('should return undefined when table IDs do not match', () => {\n      const mockTableDomain = {\n        id: 'tblwrongid', // Different from foreign table ID\n        getField: vi.fn(),\n      } as any;\n\n      const result = field.getForeignLookupField(mockTableDomain);\n\n      expect(result).toBeUndefined();\n      expect(mockTableDomain.getField).not.toHaveBeenCalled();\n    });\n\n    it('should return undefined when lookup field ID is not set', () => {\n      const fieldWithoutLookup = plainToInstance(LinkFieldCore, {\n        ...json,\n        options: {\n          ...json.options,\n          lookupFieldId: undefined,\n        },\n      });\n\n      const mockTableDomain = {\n        id: 'tblxxxxxxx',\n        getField: vi.fn(),\n      } as any;\n\n      const result = fieldWithoutLookup.getForeignLookupField(mockTableDomain);\n\n      expect(result).toBeUndefined();\n      expect(mockTableDomain.getField).not.toHaveBeenCalled();\n    });\n\n    it('should return undefined when lookup field is not found in table domain', () => {\n      const mockTableDomain = {\n        id: 'tblxxxxxxx',\n        getField: vi.fn(() => undefined), // Field not found\n      } as any;\n\n      const result = field.getForeignLookupField(mockTableDomain);\n\n      expect(result).toBeUndefined();\n      expect(mockTableDomain.getField).toHaveBeenCalledWith('fldxxxxxxx');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/link.field.ts",
    "content": "import { IdPrefix } from '../../../utils';\nimport { z } from '../../../zod';\nimport type { TableDomain } from '../../table/table-domain';\nimport type { IFilter } from '../../view/filter/filter';\nimport { type CellValueType, FieldType, Relationship } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport {\n  linkFieldOptionsSchema,\n  type ILinkFieldOptions,\n  type ILinkFieldMeta,\n} from './link-option.schema';\n\nexport const linkCellValueSchema = z.object({\n  id: z.string().startsWith(IdPrefix.Record),\n  title: z.string().optional(),\n});\n\nexport type ILinkCellValue = z.infer<typeof linkCellValueSchema>;\n\nconst singleLinkCellValueSchema = z\n  .union([linkCellValueSchema.nullable(), z.array(linkCellValueSchema).nonempty()])\n  .transform((value) => (Array.isArray(value) ? value[0] : value));\n\nconst multipleLinkCellValueSchema = z\n  .union([z.array(linkCellValueSchema).nonempty().nullable(), linkCellValueSchema])\n  .transform((value) => {\n    if (value == null) {\n      return null;\n    }\n    return Array.isArray(value) ? value : [value];\n  });\n\nexport class LinkFieldCore extends FieldCore {\n  static defaultOptions(): Partial<ILinkFieldOptions> {\n    return {};\n  }\n\n  override get isStructuredCellValue() {\n    return true;\n  }\n\n  type!: FieldType.Link;\n\n  options!: ILinkFieldOptions;\n\n  declare meta?: ILinkFieldMeta;\n\n  cellValueType!: CellValueType.String;\n\n  declare isMultipleCellValue?: boolean | undefined;\n\n  getHasOrderColumn(): boolean {\n    return !!this.meta?.hasOrderColumn;\n  }\n\n  /**\n   * Get the order column name for this link field based on its relationship type\n   * @returns The order column name to use in database queries and operations\n   */\n  getOrderColumnName(): string {\n    const relationship = this.options.relationship;\n\n    switch (relationship) {\n      case Relationship.ManyMany:\n        // ManyMany relationships use a simple __order column in the junction table\n        return '__order';\n\n      case Relationship.OneMany:\n        // One-way OneMany may reuse legacy ManyMany junction storage where order column is \"__order\".\n        if (this.options.isOneWay && this.getHasOrderColumn()) {\n          return '__order';\n        }\n        // Other OneMany relationships use selfKeyName + _order.\n        return `${this.options.selfKeyName}_order`;\n\n      case Relationship.ManyOne:\n      case Relationship.OneOne:\n        // ManyOne and OneOne relationships use the foreignKeyName (foreign key in current table) + _order\n        return `${this.options.foreignKeyName}_order`;\n\n      default:\n        throw new Error(`Unsupported relationship type: ${relationship}`);\n    }\n  }\n\n  getIsMultiValue() {\n    const relationship = this.options.relationship;\n    return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n    return this.item2String(cellValue);\n  }\n\n  convertStringToCellValue(_value: string): string[] | null {\n    return null;\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    const validatedValue = this.validateCellValue(value);\n    if (validatedValue.success) {\n      return validatedValue.data;\n    }\n    return null;\n  }\n\n  validateOptions() {\n    return linkFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      // Realtime convert can briefly deliver the previous single-value shape\n      // before records finish re-querying into the new multi-value shape.\n      return multipleLinkCellValueSchema.safeParse(value);\n    }\n\n    // Realtime convert can briefly deliver the previous multi-value shape\n    // before records finish re-querying into the new single-value shape.\n    return singleLinkCellValueSchema.safeParse(value);\n  }\n\n  item2String(value: unknown) {\n    if (value == null) {\n      return '';\n    }\n    return (value as { title?: string }).title || '';\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitLinkField(this);\n  }\n\n  /**\n   * Get the foreign table ID that this link field references\n   */\n  getForeignTableId(): string | undefined {\n    return this.options.foreignTableId;\n  }\n\n  /**\n   * Get the lookup field from the foreign table\n   * @param foreignTable - The table domain to search for the lookup field\n   * @override\n   * @returns The lookup field instance if found and table IDs match\n   */\n  override getForeignLookupField(foreignTable: TableDomain): FieldCore | undefined {\n    if (this.isLookup) {\n      return super.getForeignLookupField(foreignTable);\n    }\n\n    // Ensure the foreign table ID matches the provided table domain ID\n    if (this.options.foreignTableId !== foreignTable.id) {\n      return undefined;\n    }\n\n    // Get the lookup field ID from options\n    const lookupFieldId = this.options.lookupFieldId;\n    if (!lookupFieldId) {\n      return undefined;\n    }\n\n    // Get the lookup field instance from the table domain\n    return foreignTable.getField(lookupFieldId);\n  }\n\n  mustGetForeignLookupField(tableDomain: TableDomain): FieldCore {\n    const field = this.getForeignLookupField(tableDomain);\n    if (!field) {\n      throw new Error(`Lookup field ${this.options.lookupFieldId} not found`);\n    }\n    return field;\n  }\n\n  getLookupFields(tableDomain: TableDomain) {\n    return tableDomain.filterFields(\n      (field) =>\n        !!field.isLookup &&\n        !!field.lookupOptions &&\n        'linkFieldId' in field.lookupOptions &&\n        field.lookupOptions.linkFieldId === this.id\n    );\n  }\n\n  getRollupFields(tableDomain: TableDomain) {\n    return tableDomain.filterFields(\n      (field) =>\n        field.type === FieldType.Rollup &&\n        !!field.lookupOptions &&\n        'linkFieldId' in field.lookupOptions &&\n        field.lookupOptions.linkFieldId === this.id\n    );\n  }\n\n  override getFilter(): IFilter | undefined {\n    return this.options?.filter ?? undefined;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/long-text-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const longTextShowAsSchema = z.object({\n  type: z.literal('markdown'),\n});\n\nexport type ILongTextShowAs = z.infer<typeof longTextShowAsSchema>;\n\nexport const longTextFieldOptionsSchema = z.object({\n  showAs: longTextShowAsSchema.optional().nullable(),\n  defaultValue: z\n    .string()\n    .optional()\n    .transform((value) => (typeof value === 'string' ? value.trim() : value))\n    .optional()\n    .nullable(),\n});\n\nexport type ILongTextFieldOptions = z.infer<typeof longTextFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/long-text.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { LongTextFieldCore } from './long-text.field';\n\ndescribe('LongTextFieldCore', () => {\n  let field: LongTextFieldCore;\n  let multipleLookupField: LongTextFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test Long Text Field',\n    description: 'A test Long Text field',\n    type: FieldType.LongText,\n    dbFieldType: DbFieldType.Text,\n    options: {},\n    cellValueType: CellValueType.String,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(LongTextFieldCore, json);\n    multipleLookupField = plainToInstance(LongTextFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(LongTextFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String('text')).toBe('text');\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(multipleLookupField.cellValue2String(['text'])).toBe('text');\n    expect(multipleLookupField.cellValue2String(['text', 'text2'])).toBe('text, text2');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('wrap \\n text')).toBe('wrap \\n text');\n    expect(field.convertStringToCellValue(null as any)).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair(123)).toBe('123');\n\n    expect(multipleLookupField.repair('1.234')).toBeNull();\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue('1.234').success).toBe(true);\n    expect(field.validateCellValue(1.234).success).toBe(false);\n    expect(field.validateCellValue(null).success).toBe(true);\n\n    expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);\n    expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      const field = plainToInstance(LongTextFieldCore, { ...json });\n      const result = field.validateOptions();\n      expect(result.success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(LongTextFieldCore, {\n        ...json,\n        options: null,\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(false);\n    });\n\n    it('should get default options', () => {\n      expect(LongTextFieldCore.defaultOptions()).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/long-text.field.ts",
    "content": "import { z } from 'zod';\nimport type { CellValueType, FieldType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { longTextFieldOptionsSchema, type ILongTextFieldOptions } from './long-text-option.schema';\n\nexport const longTextCelValueSchema = z.string();\n\nexport type ILongTextCellValue = z.infer<typeof longTextCelValueSchema>;\n\nexport class LongTextFieldCore extends FieldCore {\n  type!: FieldType.LongText;\n\n  options!: ILongTextFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.String;\n\n  static defaultOptions(): ILongTextFieldOptions {\n    return {};\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.join(', ');\n    }\n    return (cellValue as string) ?? '';\n  }\n\n  item2String(value?: unknown): string {\n    return value ? String(value) : '';\n  }\n\n  convertStringToCellValue(value: string): string | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (value === '' || value == null) {\n      return null;\n    }\n\n    return value.trim();\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n    return String(value);\n  }\n\n  validateOptions() {\n    return longTextFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(longTextCelValueSchema).nonempty().nullable().safeParse(value);\n    }\n\n    return z\n      .string()\n      .transform((val) => (val === '' ? null : val))\n      .nullable()\n      .safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitLongTextField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/multiple-select.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVo } from '../field.schema';\nimport { MultipleSelectFieldCore } from './multiple-select.field';\n\ndescribe('MultipleSelectFieldCore', () => {\n  let field: MultipleSelectFieldCore;\n  let lookupField: MultipleSelectFieldCore;\n  const json: IFieldVo = {\n    id: 'fldtestxxxxxx',\n    dbFieldName: 'fldtestxxxxxx',\n    name: 'Test SingleSelect Field',\n    description: 'A test SingleSelect field',\n    options: {\n      choices: [\n        { id: 'cho1', name: 'Option 1', color: Colors.Blue },\n        { id: 'cho2', name: 'Option 2', color: Colors.Red },\n      ],\n    },\n    type: FieldType.SingleSelect,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: true,\n    isComputed: false,\n  };\n\n  const lookupJson = {\n    isLookup: true,\n    isComputed: true,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(MultipleSelectFieldCore, json);\n    lookupField = plainToInstance(MultipleSelectFieldCore, {\n      ...json,\n      ...lookupJson,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(MultipleSelectFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String(['Option 1'])).toEqual('Option 1');\n    expect(field.cellValue2String(['Option 1', 'Option 2'])).toEqual('Option 1, Option 2');\n  });\n\n  it('should validate cellValue', () => {\n    expect(field.validateCellValue(null as any).success).toBe(true);\n    expect(field.validateCellValue('Option 1').success).toBe(false);\n    expect(field.validateCellValue(['Option 1']).success).toBe(true);\n    expect(field.validateCellValue(['Option 3']).success).toBe(false);\n\n    expect(lookupField.validateCellValue(null as any).success).toBe(true);\n    expect(lookupField.validateCellValue('Option 1').success).toBe(false);\n    expect(lookupField.validateCellValue(['Option 1']).success).toBe(true);\n    expect(lookupField.validateCellValue(['Option 3']).success).toBe(false);\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(lookupField.convertStringToCellValue('text')).toBeNull();\n\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(lookupField.convertStringToCellValue('text')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    const cellValue = 'Option 1';\n    expect(field.repair([cellValue])).toEqual([cellValue]);\n    expect(field.repair('xxxx')).toEqual(null);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(field.validateOptions().success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(MultipleSelectFieldCore, {\n        ...json,\n        options: {\n          choices: [\n            { name: 'Option 1', color: Colors.Blue },\n            { name: 'Option 2', color: 'xxxxx' },\n          ],\n        },\n      });\n      expect(field.validateOptions().success).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/multiple-select.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { SelectFieldCore } from './abstract/select.field.abstract';\n\nexport const multipleSelectCelValueSchema = z.array(z.string());\n\nexport type IMultipleSelectCellValue = z.infer<typeof multipleSelectCelValueSchema>;\n\nexport class MultipleSelectFieldCore extends SelectFieldCore {\n  type!: FieldType.MultipleSelect;\n\n  cellValueType!: CellValueType.String;\n\n  isMultipleCellValue = true;\n\n  convertStringToCellValue(value: string, shouldExtend?: boolean): string[] | null {\n    if (value == null) {\n      return null;\n    }\n\n    let cellValue = value.split(/[\\n\\r,]\\s?(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/).map((item) => {\n      return item.includes(',') ? item.slice(1, -1).trim() : item.trim();\n    });\n\n    cellValue = shouldExtend\n      ? cellValue\n      : cellValue.filter((value) => this.options.choices.find((c) => c.name === value));\n\n    return cellValue.length === 0 ? null : cellValue;\n  }\n\n  repair(value: unknown) {\n    if (Array.isArray(value)) {\n      const cellValue = value.filter((value) => this.options.choices.find((c) => c.name === value));\n\n      if (cellValue.length === 0) {\n        return null;\n      }\n      return cellValue;\n    }\n\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n\n    throw new Error(`invalid value: ${value} for field: ${this.name}`);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitMultipleSelectField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/number-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { numberFormattingSchema } from '../formatting';\nimport { numberShowAsSchema } from '../show-as';\n\nexport const numberFieldOptionsSchema = z.object({\n  formatting: numberFormattingSchema,\n  showAs: numberShowAsSchema.optional(),\n  defaultValue: z.number().optional().nullable(),\n});\n\nexport const numberFieldOptionsRoSchema = numberFieldOptionsSchema.partial({\n  formatting: true,\n  showAs: true,\n});\n\nexport type INumberFieldOptionsRo = z.infer<typeof numberFieldOptionsRoSchema>;\n\nexport type INumberFieldOptions = z.infer<typeof numberFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/number.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { convertFieldRoSchema } from '../field.schema';\nimport { NumberFormattingType } from '../formatting';\nimport { MultiNumberDisplayType, SingleNumberDisplayType } from '../show-as';\nimport { NumberFieldCore } from './number.field';\n\ndescribe('NumberFieldCore', () => {\n  let field: NumberFieldCore;\n  let multipleLookupField: NumberFieldCore;\n\n  const singleNumberShowAsProps = {\n    type: SingleNumberDisplayType.Ring,\n    color: Colors.TealBright,\n    showValue: false,\n    maxValue: 100,\n  };\n\n  const multiNumberShowAsProps = {\n    type: MultiNumberDisplayType.Line,\n    color: Colors.TealBright,\n  };\n\n  const json = {\n    id: 'test',\n    name: 'Test Number Field',\n    description: 'A test number field',\n    type: FieldType.Number,\n    dbFieldType: DbFieldType.Real,\n    options: {\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      showAs: singleNumberShowAsProps,\n    },\n    cellValueType: CellValueType.Number,\n    isComputed: false,\n  };\n\n  const invalidShowAsTestCases = [\n    {\n      ...json,\n      options: {\n        ...json.options,\n        showAs: singleNumberShowAsProps,\n      },\n      isMultipleCellValue: true,\n      isComputed: true,\n      isLookup: true,\n    },\n    {\n      ...json,\n      options: {\n        ...json.options,\n        showAs: multiNumberShowAsProps,\n      },\n    },\n  ];\n\n  beforeEach(() => {\n    field = plainToInstance(NumberFieldCore, json);\n    multipleLookupField = plainToInstance(NumberFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(NumberFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String(1.234)).toBe('1.23');\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(multipleLookupField.cellValue2String([1.234])).toBe('1.23');\n    expect(multipleLookupField.cellValue2String([1.234, 2.345])).toBe('1.23, 2.35');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('1.234')).toBe(1.234);\n    expect(field.convertStringToCellValue('abc')).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair('1.234')).toBe(1.234);\n    expect(field.repair('abc')).toBeNull();\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue(1.234).success).toBeTruthy();\n    expect(field.validateCellValue('1.234').success).toBeFalsy();\n\n    expect(multipleLookupField.validateCellValue([1.234]).success).toBeTruthy();\n    expect(multipleLookupField.validateCellValue(['1.234']).success).toBeFalsy();\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(\n        convertFieldRoSchema.safeParse({\n          ...json,\n          options: {\n            ...json.options,\n          },\n        }).success\n      ).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(NumberFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            formatting: { type: NumberFormattingType.Decimal, precision: -1 }, // invalid precision value\n          },\n        }).validateOptions().success\n      ).toBe(false);\n\n      expect(\n        plainToInstance(NumberFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            formatting: { type: 'ABC', precision: 2 }, // invalid type value\n          },\n        }).validateOptions().success\n      ).toBe(false);\n\n      invalidShowAsTestCases.forEach((field) => {\n        expect(plainToInstance(NumberFieldCore, field).validateOptions().success).toBeFalsy();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/number.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport {\n  defaultNumberFormatting,\n  formatNumberToString,\n  numberFormattingSchema,\n  parseStringToNumber,\n} from '../formatting';\nimport { getShowAsSchema } from '../show-as';\nimport { type INumberFieldOptions } from './number-option.schema';\n\nexport const numberCellValueSchema = z.number();\n\nexport type INumberCellValue = z.infer<typeof numberCellValueSchema>;\n\nexport class NumberFieldCore extends FieldCore {\n  type!: FieldType.Number;\n\n  options!: INumberFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.Number;\n\n  static defaultOptions(): INumberFieldOptions {\n    return {\n      formatting: defaultNumberFormatting,\n    };\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n\n    return this.item2String(cellValue as number);\n  }\n\n  item2String(value?: unknown): string {\n    return formatNumberToString(value as number, this.options.formatting);\n  }\n\n  convertStringToCellValue(value: string): number | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    return parseStringToNumber(value, this.options.formatting);\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'number') {\n      return value;\n    }\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n    return null;\n  }\n\n  validateOptions() {\n    return z\n      .object({\n        formatting: numberFormattingSchema,\n        showAs: getShowAsSchema(this.cellValueType, this.isMultipleCellValue),\n      })\n      .safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(numberCellValueSchema).nonempty().nullable().safeParse(value);\n    }\n    return numberCellValueSchema.nullable().safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitNumberField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rating-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { Colors } from '../colors';\n\nexport enum RatingIcon {\n  Star = 'star',\n  Moon = 'moon',\n  Sun = 'sun',\n  Zap = 'zap',\n  Flame = 'flame',\n  Heart = 'heart',\n  Apple = 'apple',\n  ThumbUp = 'thumb-up',\n}\n\nexport const RATING_ICON_COLORS = [\n  Colors.YellowBright,\n  Colors.RedBright,\n  Colors.TealBright,\n] as const;\n\nexport const ratingColorsSchema = z.enum(RATING_ICON_COLORS);\n\nexport type IRatingColors = z.infer<typeof ratingColorsSchema>;\n\nexport const ratingFieldOptionsSchema = z.object({\n  icon: z.enum(RatingIcon),\n  color: ratingColorsSchema,\n  max: z.number().int().max(10).min(1),\n});\n\nexport type IRatingFieldOptions = z.infer<typeof ratingFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rating.field.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { convertFieldRoSchema } from '../field.schema';\nimport { RatingIcon } from './rating-option.schema';\nimport { RatingFieldCore } from './rating.field';\n\ndescribe('RatingFieldCore', () => {\n  let field: RatingFieldCore;\n  let multipleLookupField: RatingFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test Rating Field',\n    description: 'A test rating field',\n    type: FieldType.Rating,\n    dbFieldType: DbFieldType.Real,\n    options: {\n      icon: RatingIcon.Star,\n      color: Colors.YellowBright,\n      max: 5,\n    },\n    cellValueType: CellValueType.Number,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(RatingFieldCore, json);\n    multipleLookupField = plainToInstance(RatingFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(RatingFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String(1)).toBe('1');\n    expect(field.cellValue2String(null)).toBe('');\n    expect(multipleLookupField.cellValue2String([1])).toBe('1');\n    expect(multipleLookupField.cellValue2String([1, 2])).toBe('1, 2');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('10')).toBe(5);\n    expect(field.convertStringToCellValue('2.4')).toBe(2);\n    expect(field.convertStringToCellValue('2.5')).toBe(3);\n    expect(field.convertStringToCellValue('abc')).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair(1.4)).toBe(1);\n    expect(field.repair(1.5)).toBe(2);\n    expect(field.repair(8)).toBe(5);\n    expect(field.repair('1.4')).toBe(1);\n    expect(field.repair('1.5')).toBe(2);\n    expect(field.repair('8')).toBe(5);\n    expect(field.repair('abc')).toBeNull();\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue(5).success).toBeTruthy();\n    expect(field.validateCellValue(10).success).toBeFalsy();\n    expect(field.validateCellValue(1.234).success).toBeFalsy();\n    expect(field.validateCellValue('5').success).toBeFalsy();\n\n    expect(multipleLookupField.validateCellValue([5]).success).toBeTruthy();\n    expect(multipleLookupField.validateCellValue([10]).success).toBeFalsy();\n    expect(multipleLookupField.validateCellValue(['5']).success).toBeFalsy();\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(convertFieldRoSchema.safeParse(json).success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(RatingFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            max: 0,\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RatingFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            max: 15,\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RatingFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            color: Colors.Cyan,\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RatingFieldCore, {\n          ...json,\n          options: {\n            ...json.options,\n            icon: 'test-icon',\n          },\n        }).validateOptions().success\n      ).toBeFalsy();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rating.field.ts",
    "content": "import { z } from 'zod';\nimport { Colors } from '../colors';\nimport type { CellValueType, FieldType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { parseStringToNumber } from '../formatting';\nimport type { IRatingFieldOptions } from './rating-option.schema';\nimport { ratingFieldOptionsSchema, RatingIcon } from './rating-option.schema';\n\nexport class RatingFieldCore extends FieldCore {\n  type!: FieldType.Rating;\n\n  options!: IRatingFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.Number;\n\n  static defaultOptions(): IRatingFieldOptions {\n    return {\n      icon: RatingIcon.Star,\n      color: Colors.YellowBright,\n      max: 5,\n    };\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (cellValue == null) {\n      return '';\n    }\n\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.map((v) => this.item2String(v)).join(', ');\n    }\n\n    return this.item2String(cellValue as number);\n  }\n\n  item2String(value?: unknown): string {\n    if (value == null) {\n      return '';\n    }\n    return String(value);\n  }\n\n  convertStringToCellValue(value: string): number | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    const num = parseStringToNumber(value);\n    return num == null ? null : Math.min(Math.round(num), this.options.max ?? 10);\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'number') {\n      return Math.min(Math.round(value), this.options.max ?? 10);\n    }\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n    return null;\n  }\n\n  validateOptions() {\n    return ratingFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z\n        .array(\n          z\n            .number()\n            .int()\n            .max(this.options.max ?? 10)\n            .min(1)\n        )\n        .nonempty()\n        .nullable()\n        .safeParse(value);\n    }\n    return z\n      .number()\n      .int()\n      .max(this.options.max ?? 10)\n      .min(1)\n      .nullable()\n      .safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitRatingField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rollup-option.schema.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/naming-convention */\nimport { z } from '../../../zod';\nimport { CellValueType } from '../constant';\nimport { timeZoneStringSchema, unionFormattingSchema } from '../formatting';\nimport { unionShowAsSchema } from '../show-as';\n\nexport const ROLLUP_FUNCTIONS = [\n  'countall({values})',\n  'counta({values})',\n  'count({values})',\n  'sum({values})',\n  'average({values})',\n  'max({values})',\n  'min({values})',\n  'and({values})',\n  'or({values})',\n  'xor({values})',\n  'array_join({values})',\n  'array_unique({values})',\n  'array_compact({values})',\n  'concatenate({values})',\n] as const;\n\nexport type RollupFunction = (typeof ROLLUP_FUNCTIONS)[number];\n\nconst BASE_ROLLUP_FUNCTIONS: RollupFunction[] = [\n  'countall({values})',\n  'counta({values})',\n  'count({values})',\n  'array_join({values})',\n  'array_unique({values})',\n  'array_compact({values})',\n  'concatenate({values})',\n];\n\nconst NUMBER_ROLLUP_FUNCTIONS: RollupFunction[] = [\n  'sum({values})',\n  'average({values})',\n  'max({values})',\n  'min({values})',\n];\n\nconst DATETIME_ROLLUP_FUNCTIONS: RollupFunction[] = ['max({values})', 'min({values})'];\n\nconst BOOLEAN_ROLLUP_FUNCTIONS: RollupFunction[] = [\n  'and({values})',\n  'or({values})',\n  'xor({values})',\n];\n\nexport const getRollupFunctionsByCellValueType = (\n  cellValueType: CellValueType\n): RollupFunction[] => {\n  const allowed = new Set<RollupFunction>(BASE_ROLLUP_FUNCTIONS);\n\n  switch (cellValueType) {\n    case CellValueType.Number:\n      NUMBER_ROLLUP_FUNCTIONS.forEach((fn) => allowed.add(fn));\n      break;\n    case CellValueType.DateTime:\n      DATETIME_ROLLUP_FUNCTIONS.forEach((fn) => allowed.add(fn));\n      break;\n    case CellValueType.Boolean:\n      BOOLEAN_ROLLUP_FUNCTIONS.forEach((fn) => allowed.add(fn));\n      break;\n    case CellValueType.String:\n    default:\n      break;\n  }\n\n  return ROLLUP_FUNCTIONS.filter((fn) => allowed.has(fn));\n};\n\nexport const isRollupFunctionSupportedForCellValueType = (\n  expression: RollupFunction,\n  cellValueType: CellValueType\n): boolean => {\n  return getRollupFunctionsByCellValueType(cellValueType).includes(expression);\n};\n\nexport const rollupFieldOptionsSchema = z.object({\n  expression: z.enum(ROLLUP_FUNCTIONS),\n  timeZone: timeZoneStringSchema.optional(),\n  formatting: unionFormattingSchema.optional(),\n  showAs: unionShowAsSchema.optional(),\n});\n\nexport type IRollupFieldOptions = z.infer<typeof rollupFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rollup.field.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { DbFieldType, FieldType, CellValueType } from '../constant';\nimport { DateFormattingPreset, NumberFormattingType, TimeFormatting } from '../formatting';\nimport {\n  MultiNumberDisplayType,\n  SingleLineTextDisplayType,\n  SingleNumberDisplayType,\n} from '../show-as';\nimport { NumberFieldCore } from './number.field';\nimport { RollupFieldCore } from './rollup.field';\n\ndescribe('RollupFieldCore', () => {\n  const singleNumberShowAsProps = {\n    type: SingleNumberDisplayType.Ring,\n    color: Colors.TealBright,\n    showValue: false,\n    maxValue: 100,\n  };\n\n  const multiNumberShowAsProps = {\n    type: MultiNumberDisplayType.Line,\n    color: Colors.TealBright,\n  };\n\n  const numberRollupJson = {\n    id: 'fld666',\n    name: 'formulaField',\n    description: 'A test formula field',\n    type: FieldType.Rollup,\n    dbFieldType: DbFieldType.Real,\n    options: {\n      expression: 'countall({values})',\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      showAs: singleNumberShowAsProps,\n    },\n    cellValueType: CellValueType.Number,\n    isComputed: true,\n  };\n\n  const numberField = plainToInstance(NumberFieldCore, {\n    id: 'values',\n    name: 'values',\n    description: 'A test number field',\n    type: FieldType.Number,\n    options: {\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n    },\n    cellValueType: CellValueType.Number,\n    isMultipleCellValue: true,\n  });\n\n  const numberRollupField = plainToInstance(RollupFieldCore, numberRollupJson);\n\n  const stringRollupField = plainToInstance(RollupFieldCore, {\n    ...numberRollupJson,\n    options: {\n      expression: 'concatenate({values})',\n      showAs: {\n        type: SingleLineTextDisplayType.Url,\n      },\n    },\n    cellValueType: CellValueType.String,\n  });\n\n  const booleanRollupField = plainToInstance(RollupFieldCore, {\n    ...numberRollupJson,\n    options: {\n      ...numberRollupJson.options,\n      formatting: undefined,\n      showAs: undefined,\n    },\n    cellValueType: CellValueType.Boolean,\n  });\n\n  const lookupMultipleRollupField = plainToInstance(RollupFieldCore, {\n    ...numberRollupJson,\n    options: {\n      ...numberRollupJson.options,\n      formatting: { type: NumberFormattingType.Decimal, precision: 2 },\n      showAs: multiNumberShowAsProps,\n    },\n    cellValueType: CellValueType.Number,\n    isLookup: true,\n    isMultipleCellValue: true,\n  });\n\n  const invalidShowAsTestCases = [\n    {\n      ...numberRollupJson,\n      options: {\n        ...numberRollupJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: true,\n      isLookup: true,\n    },\n    {\n      ...numberRollupJson,\n      options: {\n        ...numberRollupJson.options,\n        showAs: multiNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Number,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberRollupJson,\n      options: {\n        expression: 'array_join({values})',\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberRollupJson,\n      options: {\n        expression: '\"abc\"',\n        showAs: {\n          type: 'test',\n        },\n      },\n      cellValueType: CellValueType.String,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberRollupJson,\n      options: {\n        ...numberRollupJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.DateTime,\n      isMultipleCellValue: false,\n    },\n    {\n      ...numberRollupJson,\n      options: {\n        ...numberRollupJson.options,\n        showAs: singleNumberShowAsProps,\n      },\n      cellValueType: CellValueType.Boolean,\n      isMultipleCellValue: false,\n    },\n  ];\n\n  describe('basic function', () => {\n    it('should convert cellValue to string', () => {\n      expect(numberRollupField.cellValue2String(1)).toBe('1.00');\n      expect(stringRollupField.cellValue2String('text')).toBe('text');\n      expect(booleanRollupField.cellValue2String(true)).toBe('true');\n      expect(lookupMultipleRollupField.cellValue2String([1, 2, 3])).toBe('1.00, 2.00, 3.00');\n    });\n\n    it('should validate cellValue', () => {\n      expect(numberRollupField.validateCellValue(1).success).toBe(true);\n      expect(numberRollupField.validateCellValue('1').success).toBe(false);\n      expect(stringRollupField.validateCellValue('text').success).toBe(true);\n      expect(stringRollupField.validateCellValue(666).success).toBe(false);\n      expect(booleanRollupField.validateCellValue(true).success).toBe(true);\n      expect(booleanRollupField.validateCellValue('true').success).toBe(false);\n      expect(lookupMultipleRollupField.validateCellValue([1]).success).toBe(true);\n      expect(lookupMultipleRollupField.validateCellValue(1).success).toBe(false);\n    });\n\n    it('should convert string to cellValue', () => {\n      expect(numberRollupField.convertStringToCellValue('1')).toBe(null);\n    });\n\n    it('should repair invalid value', () => {\n      expect(numberRollupField.repair(1)).toBe(null);\n    });\n  });\n\n  describe('calculation', () => {\n    it('should parse the expression correctly', () => {\n      const expression = '2 + 2';\n      const parsed = RollupFieldCore.parse(expression);\n      expect(parsed).toBeDefined();\n      // add more specific checks based on the return type of parse()\n    });\n\n    it('should return current typed value with field context', () => {\n      expect(\n        RollupFieldCore.getParsedValueType('countall({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        RollupFieldCore.getParsedValueType('sum({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        RollupFieldCore.getParsedValueType('average({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        RollupFieldCore.getParsedValueType('sum({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.Number,\n      });\n\n      expect(\n        RollupFieldCore.getParsedValueType('concatenate({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.String,\n      });\n\n      expect(\n        RollupFieldCore.getParsedValueType('and({values})', CellValueType.Number, false)\n      ).toEqual({\n        cellValueType: CellValueType.Boolean,\n      });\n    });\n\n    it('should return eval result by evaluate', () => {\n      expect(\n        numberRollupField\n          .evaluate(\n            {\n              values: numberField,\n            },\n            {\n              id: 'rec123',\n              fields: {\n                values: [1, 2],\n              },\n            }\n          )\n          .toPlain()\n      ).toEqual(2);\n    });\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      console.log((numberRollupField.validateOptions() as any).error);\n      expect(numberRollupField.validateOptions().success).toBeTruthy();\n      expect(stringRollupField.validateOptions().success).toBeTruthy();\n      expect(booleanRollupField.validateOptions().success).toBeTruthy();\n      expect(lookupMultipleRollupField.validateOptions().success).toBeTruthy();\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(RollupFieldCore, {\n          ...numberRollupJson,\n          options: {\n            ...numberRollupJson.options,\n            expression: '',\n          },\n          cellValueType: CellValueType.Number,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RollupFieldCore, {\n          ...numberRollupJson,\n          options: {\n            expression: '',\n          },\n          cellValueType: CellValueType.Number,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RollupFieldCore, {\n          ...numberRollupJson,\n          options: {\n            expression: '',\n            formatting: {\n              date: DateFormattingPreset.US,\n              time: TimeFormatting.None,\n              timeZone: 'xxx/xxx',\n            },\n          },\n          cellValueType: CellValueType.DateTime,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RollupFieldCore, {\n          ...numberRollupJson,\n          options: {\n            expression: '',\n            formatting: {\n              type: NumberFormattingType.Decimal,\n              precision: 2,\n            },\n          },\n          cellValueType: CellValueType.String,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      expect(\n        plainToInstance(RollupFieldCore, {\n          ...numberRollupJson,\n          options: {\n            expression: '',\n            formatting: {\n              type: NumberFormattingType.Decimal,\n              precision: 2,\n            },\n          },\n          cellValueType: CellValueType.Boolean,\n          isMultipleCellValue: false,\n        }).validateOptions().success\n      ).toBeFalsy();\n\n      invalidShowAsTestCases.forEach((field) => {\n        expect(plainToInstance(RollupFieldCore, field).validateOptions().success).toBeFalsy();\n      });\n    });\n\n    it('should get default options', () => {\n      expect(RollupFieldCore.defaultOptions(CellValueType.Number)).toMatchObject({\n        expression: 'countall({values})',\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        },\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/rollup.field.ts",
    "content": "import { z } from 'zod';\nimport { EvalVisitor } from '../../../formula/visitor';\nimport type { CellValueType, FieldType } from '../constant';\nimport type { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { getDefaultFormatting, getFormattingSchema } from '../formatting';\nimport type { ILookupOptionsVo } from '../lookup-options-base.schema';\nimport { getShowAsSchema } from '../show-as';\nimport { FormulaAbstractCore } from './abstract/formula.field.abstract';\nimport {\n  ROLLUP_FUNCTIONS,\n  rollupFieldOptionsSchema,\n  type IRollupFieldOptions,\n} from './rollup-option.schema';\n\nexport const rollupCelValueSchema = z.any();\n\nexport type IRollupCellValue = z.infer<typeof rollupCelValueSchema>;\n\nexport class RollupFieldCore extends FormulaAbstractCore {\n  static defaultOptions(cellValueType: CellValueType): IRollupFieldOptions {\n    return {\n      expression: ROLLUP_FUNCTIONS[0],\n      timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone as string,\n      formatting: getDefaultFormatting(cellValueType),\n    };\n  }\n\n  static getParsedValueType(\n    expression: string,\n    cellValueType: CellValueType,\n    isMultipleCellValue: boolean\n  ) {\n    const tree = this.parse(expression);\n    // nly need to perform shallow copy to generate virtual field to evaluate the expression\n    const clonedInstance = new RollupFieldCore();\n    clonedInstance.id = 'values';\n    clonedInstance.name = 'values';\n    clonedInstance.cellValueType = cellValueType;\n    clonedInstance.isMultipleCellValue = isMultipleCellValue;\n    // field type is not important here\n    const visitor = new EvalVisitor({\n      values: clonedInstance as FieldCore,\n    });\n    const typedValue = visitor.visit(tree);\n    return {\n      cellValueType: typedValue.type,\n      isMultipleCellValue: typedValue.isMultiple,\n    };\n  }\n\n  type!: FieldType.Rollup;\n\n  declare options: IRollupFieldOptions;\n\n  meta?: undefined;\n\n  declare lookupOptions: ILookupOptionsVo;\n\n  validateOptions() {\n    return z\n      .object({\n        expression: rollupFieldOptionsSchema.shape.expression,\n        formatting: getFormattingSchema(this.cellValueType),\n        showAs: getShowAsSchema(this.cellValueType, this.isMultipleCellValue),\n      })\n      .safeParse(this.options);\n  }\n\n  /**\n   * Override to return the foreign table ID for rollup fields\n   */\n  getForeignTableId(): string | undefined {\n    return this.lookupOptions?.foreignTableId;\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitRollupField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/single-line-text-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { singleLineTextShowAsSchema } from '../show-as';\n\nexport const singlelineTextFieldOptionsSchema = z.object({\n  showAs: singleLineTextShowAsSchema.optional(),\n  defaultValue: z\n    .string()\n    .transform((value) => (typeof value === 'string' ? value.trim() : value))\n    .optional()\n    .nullable(),\n});\n\nexport type ISingleLineTextFieldOptions = z.infer<typeof singlelineTextFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/single-line-text.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport { SingleLineTextDisplayType } from '../show-as';\nimport { SingleLineTextFieldCore } from './single-line-text.field';\n\ndescribe('SingleLineTextFieldCore', () => {\n  let field: SingleLineTextFieldCore;\n  let multipleLookupField: SingleLineTextFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test Single Line Text Field',\n    description: 'A test Single Line Text field',\n    type: FieldType.SingleLineText,\n    dbFieldType: DbFieldType.Text,\n    options: {\n      showAs: {\n        type: SingleLineTextDisplayType.Email,\n      },\n    },\n    cellValueType: CellValueType.String,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(SingleLineTextFieldCore, json);\n    multipleLookupField = plainToInstance(SingleLineTextFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n      isLookup: true,\n      isComputed: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(SingleLineTextFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    expect(field.cellValue2String('text')).toBe('text');\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(multipleLookupField.cellValue2String(['text'])).toBe('text');\n    expect(multipleLookupField.cellValue2String(['text', 'text2'])).toBe('text, text2');\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('text')).toBe('text');\n    expect(field.convertStringToCellValue('wrap\\ntext')).toBe('wrap text');\n    expect(field.convertStringToCellValue(null as any)).toBeNull();\n\n    expect(multipleLookupField.convertStringToCellValue('1.234')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    expect(field.repair(123)).toBe('123');\n\n    expect(multipleLookupField.repair('1.234')).toBeNull();\n  });\n\n  it('should validate value', () => {\n    expect(field.validateCellValue('1.234').success).toBe(true);\n    expect(field.validateCellValue(1.234).success).toBe(false);\n    expect(field.validateCellValue(null).success).toBe(true);\n\n    expect(multipleLookupField.validateCellValue(['1.234']).success).toBe(true);\n    expect(multipleLookupField.validateCellValue([1.234]).success).toBe(false);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options has valid showAs', () => {\n      const result = field.validateOptions();\n      expect(result.success).toBe(true);\n    });\n\n    it('should return success if options are plain object', () => {\n      const field = plainToInstance(SingleLineTextFieldCore, {\n        ...json,\n        options: {},\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(true);\n    });\n\n    it('should return failure if options has invalid showAs', () => {\n      const field = plainToInstance(SingleLineTextFieldCore, {\n        ...json,\n        options: {\n          showAs: { type: 'test' },\n        },\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(false);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(SingleLineTextFieldCore, {\n        ...json,\n        options: null,\n      });\n      const result = field.validateOptions();\n      expect(result.success).toBe(false);\n    });\n\n    it('should get default options', () => {\n      expect(SingleLineTextFieldCore.defaultOptions()).toEqual({});\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/single-line-text.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport {\n  singlelineTextFieldOptionsSchema,\n  type ISingleLineTextFieldOptions,\n} from './single-line-text-option.schema';\n\nexport const singleLineTextCelValueSchema = z.string();\n\nexport type ISingleLineTextCellValue = z.infer<typeof singleLineTextCelValueSchema>;\n\nexport class SingleLineTextFieldCore extends FieldCore {\n  type!: FieldType.SingleLineText;\n\n  options!: ISingleLineTextFieldOptions;\n\n  meta?: undefined;\n\n  cellValueType!: CellValueType.String;\n\n  static defaultOptions(): ISingleLineTextFieldOptions {\n    return {};\n  }\n\n  cellValue2String(cellValue?: unknown) {\n    if (this.isMultipleCellValue && Array.isArray(cellValue)) {\n      return cellValue.join(', ');\n    }\n    return (cellValue as string) ?? '';\n  }\n\n  item2String(value?: unknown): string {\n    return value ? String(value) : '';\n  }\n\n  convertStringToCellValue(value: string): string | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    // value may be the null\n    // eslint-disable-next-line regexp/prefer-character-class\n    const realValue = value?.replace(/[\\n\\r\\t]/g, ' ')?.trim() ?? null;\n\n    if (realValue === '' || realValue == null) {\n      return null;\n    }\n\n    return realValue;\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n    return String(value);\n  }\n\n  validateOptions() {\n    return singlelineTextFieldOptionsSchema.safeParse(this.options);\n  }\n\n  validateCellValue(value: unknown) {\n    if (this.isMultipleCellValue) {\n      return z.array(singleLineTextCelValueSchema).nonempty().nullable().safeParse(value);\n    }\n    return z\n      .string()\n      .transform((val) => (val === '' ? null : val))\n      .nullable()\n      .safeParse(value);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitSingleLineTextField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/single-select.field.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { Colors } from '../colors';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { FieldCore } from '../field';\nimport type { IFieldVo } from '../field.schema';\nimport type { ISingleSelectCellValue } from './single-select.field';\nimport { SingleSelectFieldCore } from './single-select.field';\n\ndescribe('SingleSelectFieldCore', () => {\n  let field: SingleSelectFieldCore;\n  let lookupField: SingleSelectFieldCore;\n  let multipleField: SingleSelectFieldCore;\n  const json: IFieldVo = {\n    id: 'fldtestxxxxxx',\n    dbFieldName: 'fldtestxxxxxx',\n    name: 'Test SingleSelect Field',\n    description: 'A test SingleSelect field',\n    options: {\n      choices: [\n        { id: 'cho1', name: 'Option 1', color: Colors.Blue },\n        { id: 'cho2', name: 'Option 2', color: Colors.Red },\n      ],\n    },\n    type: FieldType.SingleSelect,\n    dbFieldType: DbFieldType.Text,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n  const lookupJson = {\n    isLookup: true,\n    isComputed: true,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(SingleSelectFieldCore, json);\n    lookupField = plainToInstance(SingleSelectFieldCore, {\n      ...json,\n      ...lookupJson,\n    });\n    multipleField = plainToInstance(SingleSelectFieldCore, {\n      ...json,\n      isMultipleCellValue: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(SingleSelectFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    const cellValue: ISingleSelectCellValue = 'Option 1';\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String(cellValue)).toEqual('Option 1');\n\n    expect(lookupField.cellValue2String(null as any)).toEqual('');\n    expect(lookupField.cellValue2String(cellValue)).toEqual('Option 1');\n\n    expect(multipleField.cellValue2String(null as any)).toEqual('');\n    expect(multipleField.cellValue2String(cellValue)).toEqual('Option 1');\n  });\n\n  it('should validate cellValue', () => {\n    const cellValue: ISingleSelectCellValue = 'Option 1';\n    expect(field.validateCellValue(null as any).success).toBe(true);\n    expect(field.validateCellValue(cellValue).success).toBe(true);\n    expect(field.validateCellValue('opt xx').success).toBe(false);\n    expect(field.validateCellValue([cellValue]).success).toBe(false);\n\n    expect(lookupField.validateCellValue(null as any).success).toBe(true);\n    expect(lookupField.validateCellValue(cellValue).success).toBe(true);\n    expect(lookupField.validateCellValue('opt xx').success).toBe(false);\n    expect(lookupField.validateCellValue([cellValue]).success).toBe(false);\n\n    expect(multipleField.validateCellValue(cellValue).success).toBe(false);\n    expect(multipleField.validateCellValue([cellValue]).success).toBe(true);\n  });\n\n  it('should convert string to cellValue', () => {\n    expect(field.convertStringToCellValue('Option 1')).toEqual('Option 1');\n    expect(field.convertStringToCellValue('Option\\n1')).toEqual('Option 1');\n\n    expect(field.convertStringToCellValue('text')).toBeNull();\n    expect(lookupField.convertStringToCellValue('Option 1')).toBeNull();\n  });\n\n  it('should repair invalid value', () => {\n    const cellValue = 'Option 1';\n    expect(field.repair(cellValue)).toEqual(cellValue);\n    expect(field.repair('xxxx')).toEqual(null);\n    expect(lookupField.repair(cellValue)).toEqual(null);\n    expect(lookupField.repair('xxxx')).toEqual(null);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(field.validateOptions().success).toBe(true);\n    });\n\n    it('should return failure if options are invalid', () => {\n      const field = plainToInstance(SingleSelectFieldCore, {\n        ...json,\n        options: {\n          choices: [\n            { id: 'cho1', name: 'Option 1', color: Colors.Blue },\n            { id: 'cho2', name: 'Option 2', color: 'xxxxx' },\n          ],\n        },\n      });\n      expect(field.validateOptions().success).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/single-select.field.ts",
    "content": "import { z } from 'zod';\nimport type { FieldType, CellValueType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport { SelectFieldCore } from './abstract/select.field.abstract';\n\nexport const singleSelectCelValueSchema = z.string();\n\nexport type ISingleSelectCellValue = z.infer<typeof singleSelectCelValueSchema>;\n\nexport class SingleSelectFieldCore extends SelectFieldCore {\n  type!: FieldType.SingleSelect;\n\n  cellValueType!: CellValueType.String;\n\n  convertStringToCellValue(value: string, shouldExtend?: boolean): string | null {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (value === '' || value == null) {\n      return null;\n    }\n\n    const cellValue = String(value).replace(/\\n|\\r/g, ' ').trim();\n    if (shouldExtend) {\n      return cellValue;\n    }\n\n    if (this.options.choices.find((c) => c.name === cellValue)) {\n      return cellValue;\n    }\n\n    return null;\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (typeof value === 'string') {\n      return this.convertStringToCellValue(value);\n    }\n\n    return null;\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitSingleSelectField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/user-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nconst userIdSchema = z\n  .string()\n  .startsWith('usr')\n  .or(z.enum(['me']));\n\nexport const userFieldOptionsSchema = z.object({\n  isMultiple: z.boolean().optional().meta({\n    description: 'Allow adding multiple users',\n  }),\n  shouldNotify: z.boolean().optional().meta({\n    description: 'Notify users when their name is added to a cell',\n  }),\n  defaultValue: z\n    .union([userIdSchema, z.array(userIdSchema)])\n    .optional()\n    .nullable(),\n});\n\nexport type IUserFieldOptions = z.infer<typeof userFieldOptionsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/user.field.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { CellValueType, DbFieldType, FieldType } from '../constant';\nimport { FieldCore } from '../field';\nimport { convertFieldRoSchema } from '../field.schema';\nimport type { IUserCellValue } from './abstract/user.field.abstract';\nimport { UserFieldCore } from './user.field';\n\ndescribe('UserFieldCore', () => {\n  let field: UserFieldCore;\n  let multipleField: UserFieldCore;\n  let lookupField: UserFieldCore;\n  let multipleLookupField: UserFieldCore;\n\n  const json = {\n    id: 'test',\n    name: 'Test User Field',\n    description: 'A test user field',\n    options: {\n      isMultiple: false,\n      shouldNotify: true,\n    },\n    type: FieldType.User,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    field = plainToInstance(UserFieldCore, json);\n    multipleField = plainToInstance(UserFieldCore, {\n      ...json,\n      options: {\n        ...json.options,\n        isMultiple: true,\n      },\n      isMultipleCellValue: true,\n    });\n\n    const lookupJson = {\n      isLookup: true,\n      isComputed: true,\n    };\n    lookupField = plainToInstance(UserFieldCore, {\n      ...json,\n      ...lookupJson,\n      isMultipleCellValue: false,\n    });\n    multipleLookupField = plainToInstance(UserFieldCore, {\n      ...json,\n      ...lookupJson,\n      isMultipleCellValue: true,\n    });\n  });\n\n  it('should extend parent class', () => {\n    expect(field).toBeInstanceOf(FieldCore);\n    expect(field).toBeInstanceOf(UserFieldCore);\n  });\n\n  it('should convert cellValue to string', () => {\n    const cellValue: IUserCellValue = {\n      id: 'usrxxxxxxxxx',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    };\n\n    expect(field.cellValue2String(null as any)).toBe('');\n    expect(field.cellValue2String(cellValue)).toEqual('anonymous');\n\n    expect(multipleField.cellValue2String(null as any)).toBe('');\n    expect(multipleField.cellValue2String([cellValue, cellValue])).toEqual('anonymous, anonymous');\n\n    expect(lookupField.cellValue2String(null as any)).toEqual('');\n    expect(lookupField.cellValue2String(cellValue)).toEqual('anonymous');\n\n    expect(multipleLookupField.cellValue2String(null as any)).toEqual('');\n    expect(multipleLookupField.cellValue2String(cellValue)).toEqual('anonymous');\n    expect(multipleLookupField.cellValue2String([cellValue, cellValue])).toEqual(\n      'anonymous, anonymous'\n    );\n    expect(multipleLookupField.cellValue2String([cellValue, null as any])).toEqual('anonymous, ');\n  });\n\n  it('should convert string to cellValue', () => {\n    const ctx = {\n      userSets: [\n        {\n          id: 'usr1234567',\n          name: 'anonymous',\n          email: 'anonymous@teable.ai',\n        },\n      ],\n    };\n\n    expect(field.convertStringToCellValue('anonymous', ctx)).toEqual({\n      id: 'usr1234567',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    });\n    expect(field.convertStringToCellValue('anonymous@teable.ai', ctx)).toEqual({\n      id: 'usr1234567',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    });\n\n    ctx.userSets.push({\n      id: 'usrA2',\n      name: 'anonymous',\n      email: 'a2@teable.ai',\n    });\n    expect(field.convertStringToCellValue('anonymous', ctx)).toEqual({\n      id: 'usr1234567',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    });\n    expect(field.convertStringToCellValue('name', ctx)).toBeNull();\n  });\n\n  it('should convert item to string', () => {\n    expect(field.item2String({ id: 'usr' })).toBe('');\n    expect(field.item2String({ id: 'usr', title: 'anonymous' })).toBe('anonymous');\n    expect(field.item2String(null)).toBe('');\n  });\n\n  it('should repair invalid value', () => {\n    const cellValue: IUserCellValue = {\n      id: 'usr',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    };\n    expect(field.repair(cellValue)).toEqual(cellValue);\n    expect(field.repair([{ id: 'usr' }])).toEqual(null);\n\n    expect(multipleField.repair([cellValue])).toEqual([cellValue]);\n    expect(multipleField.repair(cellValue)).toEqual(null);\n\n    expect(lookupField.repair(cellValue)).toEqual(null);\n    expect(lookupField.repair({ id: 'usr' })).toEqual(null);\n\n    expect(multipleLookupField.repair(cellValue)).toEqual(null);\n    expect(multipleLookupField.repair([{ id: 'actxxx' }])).toEqual(null);\n  });\n\n  it('should validate value', () => {\n    const cellValue: IUserCellValue = {\n      id: 'usr',\n      title: 'anonymous',\n      email: 'anonymous@teable.ai',\n    };\n\n    expect(field.validateCellValue(null as any).success).toBe(true);\n    expect(field.validateCellValue(cellValue).success).toBe(true);\n    expect(\n      field.validateCellValue({ id: 'usrxxxxxx ', title: '', email: 'anonymous@teable.ai' }).success\n    ).toBe(true);\n    expect(field.validateCellValue([cellValue]).success).toBe(false);\n  });\n\n  describe('validateOptions', () => {\n    it('should return success if options are valid', () => {\n      expect(convertFieldRoSchema.safeParse(json).success).toBeTruthy();\n    });\n\n    it('should return failure if options are invalid', () => {\n      expect(\n        plainToInstance(UserFieldCore, {\n          ...json,\n          options: null,\n        }).validateOptions().success\n      ).toBeFalsy();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/derivate/user.field.ts",
    "content": "import type { FieldType } from '../constant';\nimport type { IFieldVisitor } from '../field-visitor.interface';\nimport type { IUserCellValue } from './abstract/user.field.abstract';\nimport { UserAbstractCore } from './abstract/user.field.abstract';\nimport { userFieldOptionsSchema, type IUserFieldOptions } from './user-option.schema';\n\ninterface IUser {\n  id: string;\n  name: string;\n  email: string;\n}\n\ninterface IContext {\n  userSets?: IUser[];\n}\n\nexport const defaultUserFieldOptions: IUserFieldOptions = {\n  isMultiple: false,\n  shouldNotify: true,\n};\n\nexport class UserFieldCore extends UserAbstractCore {\n  type!: FieldType.User;\n  options!: IUserFieldOptions;\n\n  static defaultOptions() {\n    return defaultUserFieldOptions;\n  }\n\n  override get isStructuredCellValue() {\n    return true;\n  }\n\n  /*\n   * If the field matches the full name, or email of exactly one user, it will be converted to that user;\n   * If the content of a cell does not match any of the users, or if the content is ambiguous (e.g., there are two collaborators with the same name), the cell will be cleared.\n   */\n  convertStringToCellValue(\n    value: string,\n    ctx?: IContext\n  ): IUserCellValue | IUserCellValue[] | null {\n    if (this.isLookup || !value) {\n      return null;\n    }\n    const cellValue = value.split(',').map((s) => s.trim());\n    if (this.isMultipleCellValue) {\n      const cvArray = cellValue\n        .map((v) => {\n          return this.matchUser(v, ctx?.userSets);\n        })\n        .filter(Boolean) as IUserCellValue[];\n      return cvArray.length ? cvArray : null;\n    }\n    return this.matchUser(cellValue[0], ctx?.userSets);\n  }\n\n  private matchUser(value: string, userSets: IUser[] = []) {\n    const foundUser = userSets.find((user) => {\n      const { id, name, email } = user;\n      return value === id || value === name || value === email;\n    });\n    return foundUser ? { id: foundUser.id, title: foundUser.name, email: foundUser.email } : null;\n  }\n\n  repair(value: unknown) {\n    if (this.isLookup) {\n      return null;\n    }\n\n    if (this.validateCellValue(value).success) {\n      return value;\n    }\n    return null;\n  }\n\n  validateOptions() {\n    return userFieldOptionsSchema.safeParse(this.options);\n  }\n\n  accept<T>(visitor: IFieldVisitor<T>): T {\n    return visitor.visitUserField(this);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/field-options-validation.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { describe, it, expect } from 'vitest';\nimport { z } from '../../zod';\nimport { FieldType } from './constant';\nimport { convertFieldRoSchema } from './field.schema';\n\ndescribe('Field Options Validation Issue Reproduction', () => {\n  it('should validate number field with precision formatting without expression/date errors', () => {\n    const numberFieldWithPrecision = {\n      id: 'fld001',\n      name: 'Exchange Rate',\n      type: FieldType.Number,\n      description: 'Foreign currency to RMB exchange rate',\n      options: {\n        formatting: {\n          type: 'decimal',\n          precision: 4,\n        },\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(numberFieldWithPrecision);\n    expect(result.success).toBe(true);\n  });\n\n  it('FIXED: number field with precision-only formatting now gives clear error message', () => {\n    const numberFieldWithPrecisionOnly = {\n      id: 'fld007',\n      name: 'Exchange Rate',\n      type: FieldType.Number,\n      description: 'Foreign currency to RMB exchange rate',\n      options: {\n        formatting: {\n          precision: 4,\n        },\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(numberFieldWithPrecisionOnly);\n    expect(result.success).toBe(false);\n\n    if (!result.success) {\n      const errorMessage = result.error.message;\n\n      expect(errorMessage).not.toContain('expression');\n      expect(errorMessage).not.toContain('formatting.date');\n      expect(errorMessage).not.toContain('\"date\"');\n      expect(errorMessage).not.toContain('countall');\n      expect(errorMessage).not.toContain('sum({values})');\n\n      expect(errorMessage).toContain('type');\n      expect(errorMessage.toLowerCase()).toMatch(/decimal|percent|currency/);\n    }\n  });\n\n  it('should not confuse number field options with rollup/formula field options', () => {\n    const numberField = {\n      id: 'fld002',\n      name: 'Amount',\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          type: 'decimal',\n          precision: 2,\n        },\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(numberField);\n\n    if (!result.success) {\n      const errorMessage = result.error.message;\n      expect(errorMessage).not.toContain('expression');\n      expect(errorMessage).not.toContain('countall');\n      expect(errorMessage).not.toContain('counta');\n      expect(errorMessage).not.toContain('sum({values})');\n      expect(errorMessage).not.toContain('formatting.date');\n      expect(errorMessage).not.toContain('formatting.time');\n      expect(errorMessage).not.toContain('DateFormattingPreset');\n    }\n\n    expect(result.success).toBe(true);\n  });\n\n  it('should validate array of fields including number field at index 6', () => {\n    const fields = [\n      { id: 'fld001', name: 'Field 1', type: FieldType.SingleLineText, options: {} },\n      { id: 'fld002', name: 'Field 2', type: FieldType.SingleLineText, options: {} },\n      { id: 'fld003', name: 'Field 3', type: FieldType.SingleLineText, options: {} },\n      { id: 'fld004', name: 'Field 4', type: FieldType.SingleLineText, options: {} },\n      { id: 'fld005', name: 'Field 5', type: FieldType.SingleLineText, options: {} },\n      { id: 'fld006', name: 'Field 6', type: FieldType.SingleLineText, options: {} },\n      {\n        id: 'fld007',\n        name: 'Exchange Rate',\n        type: FieldType.Number,\n        description: 'Foreign currency to RMB exchange rate',\n        options: {\n          formatting: {\n            type: 'decimal',\n            precision: 4,\n          },\n        },\n      },\n    ];\n\n    const fieldsArraySchema = z.array(convertFieldRoSchema);\n    const result = fieldsArraySchema.safeParse(fields);\n    expect(result.success).toBe(true);\n  });\n\n  it('should properly validate number field with only formatting.precision', () => {\n    const field = {\n      id: 'fld001',\n      name: 'Exchange Rate',\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          precision: 4,\n        },\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(field);\n    expect(result.success).toBe(false);\n  });\n\n  it('should differentiate between number formatting and datetime formatting errors', () => {\n    const fieldWithWrongFormatting = {\n      id: 'fld001',\n      name: 'Number Field',\n      type: FieldType.Number,\n      options: {\n        formatting: {\n          date: 'yyyy/MM/dd',\n          time: 'None',\n          timeZone: 'Asia/Shanghai',\n        },\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(fieldWithWrongFormatting);\n    expect(result.success).toBe(false);\n  });\n\n  it('should validate rollup field with expression correctly', () => {\n    const rollupField = {\n      id: 'fld001',\n      name: 'Rollup Field',\n      type: FieldType.Rollup,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tbl001',\n        linkFieldId: 'fld002',\n        lookupFieldId: 'fld003',\n      },\n      options: {\n        expression: 'sum({values})',\n      },\n    };\n\n    const result = convertFieldRoSchema.safeParse(rollupField);\n    expect(result.success).toBe(true);\n  });\n\n  it('should fail rollup field validation when missing expression', () => {\n    const rollupFieldWithoutExpression = {\n      id: 'fld001',\n      name: 'Rollup Field',\n      type: FieldType.Rollup,\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tbl001',\n        linkFieldId: 'fld002',\n        lookupFieldId: 'fld003',\n      },\n      options: {},\n    };\n\n    const result = convertFieldRoSchema.safeParse(rollupFieldWithoutExpression);\n\n    if (!result.success) {\n      expect(result.error.message).toContain('expression');\n    }\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/field-unions.schema.ts",
    "content": "import { z } from '../../zod';\nimport {\n  selectFieldOptionsRoSchema,\n  selectFieldOptionsSchema,\n} from './derivate/abstract/select-option.schema';\nimport { attachmentFieldOptionsSchema } from './derivate/attachment-option.schema';\nimport {\n  autoNumberFieldOptionsRoSchema,\n  autoNumberFieldOptionsSchema,\n} from './derivate/auto-number-option.schema';\nimport { buttonFieldOptionsSchema } from './derivate/button-option.schema';\nimport { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema';\nimport { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema';\nimport { createdByFieldOptionsSchema } from './derivate/created-by-option.schema';\nimport {\n  createdTimeFieldOptionsRoSchema,\n  createdTimeFieldOptionsSchema,\n} from './derivate/created-time-option.schema';\nimport { dateFieldOptionsSchema } from './derivate/date-option.schema';\nimport {\n  formulaFieldMetaSchema,\n  formulaFieldOptionsSchema,\n} from './derivate/formula-option.schema';\nimport { lastModifiedByFieldOptionsSchema } from './derivate/last-modified-by-option.schema';\nimport {\n  lastModifiedTimeFieldOptionsRoSchema,\n  lastModifiedTimeFieldOptionsSchema,\n} from './derivate/last-modified-time-option.schema';\nimport {\n  linkFieldOptionsRoSchema,\n  linkFieldOptionsSchema,\n  linkFieldMetaSchema,\n} from './derivate/link-option.schema';\nimport {\n  numberFieldOptionsRoSchema,\n  numberFieldOptionsSchema,\n} from './derivate/number-option.schema';\nimport { ratingFieldOptionsSchema } from './derivate/rating-option.schema';\nimport { rollupFieldOptionsSchema } from './derivate/rollup-option.schema';\nimport { singlelineTextFieldOptionsSchema } from './derivate/single-line-text-option.schema';\nimport { userFieldOptionsSchema } from './derivate/user-option.schema';\nimport { unionFormattingSchema } from './formatting';\nimport { unionShowAsSchema } from './show-as';\n\n// Union of all field options that don't have read-only variants\nexport const unionFieldOptions = z.union([\n  rollupFieldOptionsSchema.strict(),\n  conditionalRollupFieldOptionsSchema.strict(),\n  formulaFieldOptionsSchema.strict(),\n  linkFieldOptionsSchema.strict(),\n  dateFieldOptionsSchema.strict(),\n  checkboxFieldOptionsSchema.strict(),\n  attachmentFieldOptionsSchema.strict(),\n  singlelineTextFieldOptionsSchema.strict(),\n  ratingFieldOptionsSchema.strict(),\n  userFieldOptionsSchema.strict(),\n  createdByFieldOptionsSchema.strict(),\n  lastModifiedByFieldOptionsSchema.strict(),\n  buttonFieldOptionsSchema.strict(),\n]);\n\n// Common options schema for lookup fields\nexport const commonOptionsSchema = z.object({\n  showAs: unionShowAsSchema.optional(),\n  formatting: unionFormattingSchema.optional(),\n});\n\n// Union of all field options for VO (view object) - includes all options\nexport const unionFieldOptionsVoSchema = z.union([\n  unionFieldOptions,\n  conditionalRollupFieldOptionsSchema.strict(),\n  linkFieldOptionsSchema.strict(),\n  selectFieldOptionsSchema.strict(),\n  numberFieldOptionsSchema.strict(),\n  autoNumberFieldOptionsSchema.strict(),\n  createdTimeFieldOptionsSchema.strict(),\n  lastModifiedTimeFieldOptionsSchema,\n]);\n\n// Union of all field options for RO (request object) - includes read-only variants\nexport const unionFieldOptionsRoSchema = z.union([\n  unionFieldOptions,\n  conditionalRollupFieldOptionsSchema.strict(),\n  linkFieldOptionsRoSchema.strict(),\n  selectFieldOptionsRoSchema.strict(),\n  numberFieldOptionsRoSchema.strict(),\n  autoNumberFieldOptionsRoSchema.strict(),\n  createdTimeFieldOptionsRoSchema.strict(),\n  lastModifiedTimeFieldOptionsRoSchema,\n  commonOptionsSchema.strict(),\n]);\n\n// Union field meta schema\nexport const unionFieldMetaVoSchema = z\n  .union([formulaFieldMetaSchema, linkFieldMetaSchema])\n  .optional();\n\n// Type definitions\nexport type IFieldOptionsRo = z.infer<typeof unionFieldOptionsRoSchema>;\nexport type IFieldOptionsVo = z.infer<typeof unionFieldOptionsVoSchema>;\nexport type IFieldMetaVo = z.infer<typeof unionFieldMetaVoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/field-validation.ts",
    "content": "import type { FieldType } from './constant';\nimport { NOT_NULL_VALIDATION_FIELD_TYPES, UNIQUE_VALIDATION_FIELD_TYPES } from './constant';\n\nexport const checkFieldValidationEnabled = (\n  fieldType: FieldType,\n  isLookup: boolean | null | undefined\n) => {\n  if (\n    checkFieldUniqueValidationEnabled(fieldType, isLookup) ||\n    checkFieldNotNullValidationEnabled(fieldType, isLookup)\n  ) {\n    return true;\n  }\n  return false;\n};\n\nexport const checkFieldUniqueValidationEnabled = (\n  fieldType: FieldType,\n  isLookup: boolean | null | undefined\n) => {\n  if (isLookup || !UNIQUE_VALIDATION_FIELD_TYPES.has(fieldType)) {\n    return false;\n  }\n  return true;\n};\n\nexport const checkFieldNotNullValidationEnabled = (\n  fieldType: FieldType,\n  isLookup: boolean | null | undefined\n) => {\n  if (isLookup || !NOT_NULL_VALIDATION_FIELD_TYPES.has(fieldType)) {\n    return false;\n  }\n  return true;\n};\n"
  },
  {
    "path": "packages/core/src/models/field/field-visitor.interface.ts",
    "content": "import type { AttachmentFieldCore } from './derivate/attachment.field';\nimport type { AutoNumberFieldCore } from './derivate/auto-number.field';\nimport type { ButtonFieldCore } from './derivate/button.field';\nimport type { CheckboxFieldCore } from './derivate/checkbox.field';\nimport type { ConditionalRollupFieldCore } from './derivate/conditional-rollup.field';\nimport type { CreatedByFieldCore } from './derivate/created-by.field';\nimport type { CreatedTimeFieldCore } from './derivate/created-time.field';\nimport type { DateFieldCore } from './derivate/date.field';\nimport type { FormulaFieldCore } from './derivate/formula.field';\nimport type { LastModifiedByFieldCore } from './derivate/last-modified-by.field';\nimport type { LastModifiedTimeFieldCore } from './derivate/last-modified-time.field';\nimport type { LinkFieldCore } from './derivate/link.field';\nimport type { LongTextFieldCore } from './derivate/long-text.field';\nimport type { MultipleSelectFieldCore } from './derivate/multiple-select.field';\nimport type { NumberFieldCore } from './derivate/number.field';\nimport type { RatingFieldCore } from './derivate/rating.field';\nimport type { RollupFieldCore } from './derivate/rollup.field';\nimport type { SingleLineTextFieldCore } from './derivate/single-line-text.field';\nimport type { SingleSelectFieldCore } from './derivate/single-select.field';\nimport type { UserFieldCore } from './derivate/user.field';\n\n/**\n * Visitor interface for field types using the Visitor pattern.\n * This interface defines methods for visiting all concrete field types.\n *\n */\nexport interface IFieldVisitor<T = unknown> {\n  // Basic field types\n  visitNumberField(field: NumberFieldCore): T;\n  visitSingleLineTextField(field: SingleLineTextFieldCore): T;\n  visitLongTextField(field: LongTextFieldCore): T;\n  visitAttachmentField(field: AttachmentFieldCore): T;\n  visitCheckboxField(field: CheckboxFieldCore): T;\n  visitDateField(field: DateFieldCore): T;\n  visitRatingField(field: RatingFieldCore): T;\n  visitAutoNumberField(field: AutoNumberFieldCore): T;\n  visitLinkField(field: LinkFieldCore): T;\n  visitRollupField(field: RollupFieldCore): T;\n  visitConditionalRollupField(field: ConditionalRollupFieldCore): T;\n\n  // Select field types (inherit from SelectFieldCore)\n  visitSingleSelectField(field: SingleSelectFieldCore): T;\n  visitMultipleSelectField(field: MultipleSelectFieldCore): T;\n\n  // Formula field types (inherit from FormulaAbstractCore)\n  visitFormulaField(field: FormulaFieldCore): T;\n  visitCreatedTimeField(field: CreatedTimeFieldCore): T;\n  visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): T;\n\n  // User field types (inherit from UserAbstractCore)\n  visitUserField(field: UserFieldCore): T;\n  visitCreatedByField(field: CreatedByFieldCore): T;\n  visitLastModifiedByField(field: LastModifiedByFieldCore): T;\n\n  visitButtonField(field: ButtonFieldCore): T;\n}\n"
  },
  {
    "path": "packages/core/src/models/field/field.schema.spec.ts",
    "content": "import type { IFilter } from '../view/filter';\nimport { Colors } from './colors';\nimport { CellValueType, DbFieldType, FieldType, Relationship } from './constant';\nimport { RollupFieldCore, SingleLineTextFieldCore } from './derivate';\nimport { unionFieldOptionsRoSchema } from './field-unions.schema';\nimport type { IFieldRo } from './field.schema';\nimport { createFieldRoSchema, fieldVoSchema } from './field.schema';\nimport { NumberFormattingType } from './formatting';\nimport type { ILookupConditionalOptions } from './lookup-options-base.schema';\nimport type { IUnionShowAs } from './show-as';\nimport { SingleNumberDisplayType } from './show-as';\n\ndescribe('field Schema Test', () => {\n  it('should return true when options validate', () => {\n    const options = {\n      expression: '1 + 1',\n      formatting: {\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n      },\n      timeZone: 'Asia/Shanghai',\n    };\n\n    const result = unionFieldOptionsRoSchema.safeParse(options);\n    expect(result.success).toBe(true);\n    result.success && expect(result.data).toEqual(options);\n  });\n\n  it('should return true when options and type match', () => {\n    const fieldRo = {\n      type: FieldType.SingleLineText,\n      options: SingleLineTextFieldCore.defaultOptions(),\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(true);\n  });\n\n  it('should return true when isLookup with lookupOptions', () => {\n    const fieldRo = {\n      type: FieldType.SingleLineText,\n      options: SingleLineTextFieldCore.defaultOptions(),\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tableId',\n        lookupFieldId: 'fieldId',\n        linkFieldId: 'fieldId',\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(true);\n  });\n\n  it('should return false when isLookup without lookupOptions', () => {\n    const fieldRo = {\n      type: FieldType.SingleLineText,\n      options: SingleLineTextFieldCore.defaultOptions(),\n      isLookup: true,\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(false);\n  });\n\n  it('should return false when lookupOptions without isLookup', () => {\n    const fieldRo = {\n      type: FieldType.SingleLineText,\n      options: SingleLineTextFieldCore.defaultOptions(),\n      lookupOptions: {\n        foreignTableId: 'tableId',\n        lookupFieldId: 'fieldId',\n        linkFieldId: 'fieldId',\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(false);\n  });\n\n  it('should return true when lookupOptions without isLookup in rollup field', () => {\n    const fieldRo = {\n      type: FieldType.Rollup,\n      options: RollupFieldCore.defaultOptions(CellValueType.String),\n      lookupOptions: {\n        foreignTableId: 'tableId',\n        lookupFieldId: 'fieldId',\n        linkFieldId: 'fieldId',\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(true);\n  });\n\n  it('should return true when isLookup field with formatting or showAs options', () => {\n    const fieldRo = {\n      type: FieldType.Rollup,\n      options: {\n        formatting: {\n          type: NumberFormattingType.Decimal,\n          precision: 2,\n        },\n        showAs: {\n          type: SingleNumberDisplayType.Ring,\n          color: Colors.Blue,\n          showValue: true,\n          maxValue: 100,\n        } as IUnionShowAs,\n      },\n      lookupOptions: {\n        foreignTableId: 'tableId',\n        lookupFieldId: 'fieldId',\n        linkFieldId: 'fieldId',\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(false);\n\n    const lookUpFieldRo = {\n      isLookup: true,\n      ...fieldRo,\n    };\n\n    const result2 = createFieldRoSchema.safeParse(lookUpFieldRo);\n    expect(result2.success).toBe(true);\n  });\n\n  it('should return false when conditional lookup missing filter', () => {\n    const fieldRo = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      isConditionalLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeign',\n      } as ILookupConditionalOptions,\n    } satisfies IFieldRo;\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(false);\n  });\n\n  it('should return true when conditional lookup has filter', () => {\n    const filter = {\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: 'fldFilter',\n          operator: 'is',\n          value: 'foo',\n        },\n      ],\n    } as IFilter;\n\n    const fieldRo: IFieldRo = {\n      type: FieldType.SingleLineText,\n      isLookup: true,\n      isConditionalLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeign',\n        filter,\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(true);\n  });\n\n  it('should allow omitted options for simple text field', () => {\n    const fieldRo: IFieldRo = {\n      type: FieldType.SingleLineText,\n      name: 'Title',\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(true);\n  });\n\n  it('should return false when isConditionalLookup true without isLookup flag', () => {\n    const fieldRo: IFieldRo = {\n      type: FieldType.SingleLineText,\n      isConditionalLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeign',\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: 'fldFilter',\n              operator: 'is',\n              value: 'foo',\n            },\n          ],\n        } as IFilter,\n      },\n    };\n\n    const result = createFieldRoSchema.safeParse(fieldRo);\n    expect(result.success).toBe(false);\n  });\n\n  it('should normalize realtime-cleared optional field shape props from null to undefined', () => {\n    const fieldVo = {\n      id: 'fldRealtimeLink',\n      name: 'Link Field',\n      type: FieldType.Link,\n      options: {\n        relationship: Relationship.ManyOne,\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldLookup',\n      },\n      description: null,\n      meta: null,\n      isLookup: null,\n      isConditionalLookup: null,\n      lookupOptions: null,\n      isComputed: null,\n      isMultipleCellValue: null,\n      recordRead: null,\n      recordCreate: null,\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Json,\n      dbFieldName: 'Link_Field',\n    };\n\n    const result = fieldVoSchema.safeParse(fieldVo);\n    expect(result.success).toBe(true);\n    if (!result.success) {\n      return;\n    }\n\n    expect(result.data.description).toBeUndefined();\n    expect(result.data.meta).toBeUndefined();\n    expect(result.data.isLookup).toBeUndefined();\n    expect(result.data.isConditionalLookup).toBeUndefined();\n    expect(result.data.lookupOptions).toBeUndefined();\n    expect(result.data.isComputed).toBeUndefined();\n    expect(result.data.isMultipleCellValue).toBeUndefined();\n    expect(result.data.recordRead).toBeUndefined();\n    expect(result.data.recordCreate).toBeUndefined();\n  });\n\n  it('should parse lookup field VO with link display config in lookupOptions', () => {\n    const fieldVo = {\n      id: 'fldLookupDisplayCfg',\n      name: 'Lookup Field',\n      type: FieldType.SingleLineText,\n      options: SingleLineTextFieldCore.defaultOptions(),\n      isLookup: true,\n      lookupOptions: {\n        foreignTableId: 'tblForeign',\n        lookupFieldId: 'fldForeign',\n        linkFieldId: 'fldLink',\n        relationship: Relationship.ManyOne,\n        fkHostTableName: 'table_foreign',\n        selfKeyName: 'self_id',\n        foreignKeyName: 'foreign_id',\n        filterByViewId: 'viwForeign',\n        visibleFieldIds: ['fldForeign', 'fldAnother'],\n      },\n      cellValueType: CellValueType.String,\n      dbFieldType: DbFieldType.Text,\n      dbFieldName: 'lookup_field',\n    };\n\n    const result = fieldVoSchema.safeParse(fieldVo);\n    expect(result.success).toBe(true);\n    if (!result.success) {\n      return;\n    }\n\n    expect(result.data.lookupOptions).toMatchObject({\n      filterByViewId: 'viwForeign',\n      visibleFieldIds: ['fldForeign', 'fldAnother'],\n    });\n  });\n\n  it('should parse representative persisted field VOs for other system field types', () => {\n    const fieldVos = [\n      {\n        id: 'fldAttachmentField',\n        name: 'Files',\n        type: FieldType.Attachment,\n        options: {},\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        dbFieldType: DbFieldType.Json,\n        dbFieldName: 'files',\n      },\n      {\n        id: 'fldAutoNumberField',\n        name: 'ID',\n        type: FieldType.AutoNumber,\n        options: {\n          expression: 'AUTO_NUMBER()',\n        },\n        meta: {\n          persistedAsGeneratedColumn: true,\n        },\n        isComputed: true,\n        cellValueType: CellValueType.Number,\n        dbFieldType: DbFieldType.Integer,\n        dbFieldName: 'id_auto',\n      },\n      {\n        id: 'fldCreatedTime',\n        name: 'Created Time',\n        type: FieldType.CreatedTime,\n        options: {\n          expression: 'CREATED_TIME()',\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: 'HH:mm',\n            timeZone: 'UTC',\n          },\n        },\n        meta: {\n          persistedAsGeneratedColumn: true,\n        },\n        isComputed: true,\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        dbFieldName: 'created_time',\n      },\n      {\n        id: 'fldLastModifiedTime',\n        name: 'Last Modified Time',\n        type: FieldType.LastModifiedTime,\n        options: {\n          expression: 'LAST_MODIFIED_TIME()',\n          formatting: {\n            date: 'YYYY-MM-DD',\n            time: 'HH:mm',\n            timeZone: 'UTC',\n          },\n          trackedFieldIds: ['fldTracked'],\n        },\n        meta: {\n          persistedAsGeneratedColumn: true,\n        },\n        isComputed: true,\n        cellValueType: CellValueType.DateTime,\n        dbFieldType: DbFieldType.DateTime,\n        dbFieldName: 'last_modified_time',\n      },\n      {\n        id: 'fldCreatedBy',\n        name: 'Created By',\n        type: FieldType.CreatedBy,\n        options: {},\n        meta: {\n          persistedAsGeneratedColumn: false,\n        },\n        isComputed: true,\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: 'created_by',\n      },\n      {\n        id: 'fldLastModifiedBy',\n        name: 'Last Modified By',\n        type: FieldType.LastModifiedBy,\n        options: {\n          trackedFieldIds: ['fldTracked'],\n        },\n        meta: {\n          persistedAsGeneratedColumn: false,\n        },\n        isComputed: true,\n        cellValueType: CellValueType.String,\n        dbFieldType: DbFieldType.Text,\n        dbFieldName: 'last_modified_by',\n      },\n    ];\n\n    fieldVos.forEach((fieldVo) => {\n      const result = fieldVoSchema.safeParse(fieldVo);\n      expect(result.success).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/field.schema.ts",
    "content": "import type { RefinementCtx } from 'zod';\nimport { assertNever } from '../../asserts';\nimport type { IEnsureKeysMatchInterface } from '../../types';\nimport { IdPrefix } from '../../utils';\nimport { z } from '../../zod';\nimport { fieldAIConfigSchema, getAiConfigSchema, type IFieldAIConfig } from './ai-config';\nimport { CellValueType, DbFieldType, FieldType } from './constant';\nimport { selectFieldOptionsRoSchema } from './derivate/abstract/select.field.abstract';\nimport { attachmentFieldOptionsSchema } from './derivate/attachment-option.schema';\nimport { autoNumberFieldOptionsRoSchema } from './derivate/auto-number-option.schema';\nimport { buttonFieldOptionsSchema } from './derivate/button-option.schema';\nimport { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema';\nimport { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema';\nimport { createdByFieldOptionsSchema } from './derivate/created-by-option.schema';\nimport { createdTimeFieldOptionsRoSchema } from './derivate/created-time-option.schema';\nimport { dateFieldOptionsSchema } from './derivate/date-option.schema';\nimport { formulaFieldOptionsSchema } from './derivate/formula-option.schema';\nimport { lastModifiedByFieldOptionsSchema } from './derivate/last-modified-by-option.schema';\nimport { lastModifiedTimeFieldOptionsRoSchema } from './derivate/last-modified-time-option.schema';\nimport { linkFieldOptionsRoSchema } from './derivate/link-option.schema';\nimport { longTextFieldOptionsSchema } from './derivate/long-text-option.schema';\nimport { numberFieldOptionsRoSchema } from './derivate/number-option.schema';\nimport { ratingFieldOptionsSchema } from './derivate/rating-option.schema';\nimport { rollupFieldOptionsSchema } from './derivate/rollup-option.schema';\nimport { singlelineTextFieldOptionsSchema } from './derivate/single-line-text-option.schema';\nimport { userFieldOptionsSchema } from './derivate/user-option.schema';\nimport {\n  type IFieldOptionsRo,\n  unionFieldMetaVoSchema,\n  unionFieldOptionsRoSchema,\n  unionFieldOptionsVoSchema,\n} from './field-unions.schema';\nimport type { ILookupOptionsRo } from './lookup-options-base.schema';\nimport { lookupOptionsRoSchema, lookupOptionsVoSchema } from './lookup-options-base.schema';\nimport { validateFieldOptions } from './zod-error';\n\n// All union schemas and types are now imported from field-unions.schema.ts\n\nconst optionalFieldVoProperty = <TSchema extends z.ZodTypeAny>(schema: TSchema) =>\n  z.preprocess((value) => (value === null ? undefined : value), schema.optional());\n\nexport const fieldVoSchema = z.object({\n  id: z.string().startsWith(IdPrefix.Field).meta({\n    description: 'The id of the field.',\n  }),\n\n  name: z.string().meta({\n    description: 'The name of the field. can not be duplicated in the table.',\n    example: 'Tags',\n  }),\n\n  type: z.enum(FieldType).meta({\n    description: 'The field types supported by teable.',\n    example: FieldType.SingleSelect,\n  }),\n\n  description: optionalFieldVoProperty(z.string()).meta({\n    description: 'The description of the field.',\n    example: 'this is a summary',\n  }),\n\n  options: unionFieldOptionsVoSchema.meta({\n    description:\n      \"The configuration options of the field. The structure of the field's options depend on the field's type.\",\n  }),\n\n  meta: optionalFieldVoProperty(unionFieldMetaVoSchema).meta({\n    description:\n      \"The metadata of the field. The structure of the field's meta depend on the field's type. Currently formula and link fields have meta.\",\n  }),\n\n  aiConfig: fieldAIConfigSchema.nullable().optional().meta({\n    description: 'The AI configuration of the field.',\n  }),\n\n  isLookup: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      'Whether this field is lookup field. witch means cellValue and [fieldType] is looked up from the linked table.',\n  }),\n\n  isConditionalLookup: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      'Whether this lookup field applies a conditional filter when resolving linked records.',\n  }),\n\n  lookupOptions: optionalFieldVoProperty(lookupOptionsVoSchema).meta({\n    description: 'field lookup options.',\n  }),\n\n  notNull: optionalFieldVoProperty(z.boolean()).meta({\n    description: 'Whether this field is not null.',\n  }),\n\n  unique: optionalFieldVoProperty(z.boolean()).meta({\n    description: 'Whether this field is not unique.',\n  }),\n\n  isPrimary: optionalFieldVoProperty(z.boolean()).meta({\n    description: 'Whether this field is primary field.',\n  }),\n\n  isComputed: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      'Whether this field is computed field, you can not modify cellValue in computed field.',\n  }),\n\n  isPending: optionalFieldVoProperty(z.boolean()).meta({\n    description: \"Whether this field's calculation is pending.\",\n  }),\n\n  hasError: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      \"Whether This field has a configuration error. Check the fields referenced by this field's formula or configuration.\",\n  }),\n\n  cellValueType: z.enum(CellValueType).meta({\n    description: 'The cell value type of the field.',\n  }),\n\n  isMultipleCellValue: optionalFieldVoProperty(z.boolean()).meta({\n    description: 'Whether this field has multiple cell value.',\n  }),\n\n  dbFieldType: z.enum(DbFieldType).meta({\n    description: 'The field type of database that cellValue really store.',\n  }),\n\n  dbFieldName: z\n    .string()\n    .min(1, { message: 'name cannot be empty' })\n    .regex(/^\\w{0,63}$/, {\n      message: 'Invalid name format',\n    })\n    .meta({\n      description:\n        'Field(column) name in backend database. Limitation: 1-63 characters, can only contain letters, numbers and underscore, case sensitive, cannot be duplicated with existing db field name in the table.',\n    }),\n  recordRead: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      'Field record read permission. When set to false, reading records is denied. When true or not set, reading records is allowed.',\n  }),\n\n  recordCreate: optionalFieldVoProperty(z.boolean()).meta({\n    description:\n      'Field record create permission. When set to false, creating records is denied. When true or not set, creating records is allowed.',\n  }),\n});\n\nexport type IFieldVo = z.infer<typeof fieldVoSchema>;\n\nexport type IFieldPropertyKey = keyof Omit<IFieldVo, 'id'>;\n\nexport const FIELD_RO_PROPERTIES = [\n  'type',\n  'name',\n  'dbFieldName',\n  'isLookup',\n  'isConditionalLookup',\n  'description',\n  'lookupOptions',\n  'options',\n] as const;\n\nexport const FIELD_VO_PROPERTIES = [\n  'type',\n  'description',\n  'options',\n  'meta',\n  'aiConfig',\n  'name',\n  'isLookup',\n  'isConditionalLookup',\n  'lookupOptions',\n  'notNull',\n  'unique',\n  'isPrimary',\n  'isComputed',\n  'isPending',\n  'hasError',\n  'cellValueType',\n  'isMultipleCellValue',\n  'dbFieldType',\n  'dbFieldName',\n  'recordRead',\n  'recordCreate',\n] as const;\n\n/**\n * make sure FIELD_VO_PROPERTIES is exactly equals IFieldVo\n * if here shows lint error, you should update FIELD_VO_PROPERTI ES\n */\n/* eslint-disable @typescript-eslint/no-unused-vars */\nconst _validator2: IEnsureKeysMatchInterface<\n  Omit<IFieldVo, 'id'>,\n  typeof FIELD_VO_PROPERTIES\n> = true;\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\nexport const getOptionsSchema = (type: FieldType) => {\n  switch (type) {\n    case FieldType.SingleLineText:\n      return singlelineTextFieldOptionsSchema;\n    case FieldType.LongText:\n      return longTextFieldOptionsSchema;\n    case FieldType.User:\n      return userFieldOptionsSchema;\n    case FieldType.Attachment:\n      return attachmentFieldOptionsSchema;\n    case FieldType.Checkbox:\n      return checkboxFieldOptionsSchema;\n    case FieldType.MultipleSelect:\n      return selectFieldOptionsRoSchema;\n    case FieldType.SingleSelect:\n      return selectFieldOptionsRoSchema;\n    case FieldType.Date:\n      return dateFieldOptionsSchema;\n    case FieldType.Number:\n      return numberFieldOptionsRoSchema;\n    case FieldType.Rating:\n      return ratingFieldOptionsSchema;\n    case FieldType.Formula:\n      return formulaFieldOptionsSchema;\n    case FieldType.Rollup:\n      return rollupFieldOptionsSchema;\n    case FieldType.ConditionalRollup:\n      return conditionalRollupFieldOptionsSchema;\n    case FieldType.Link:\n      return linkFieldOptionsRoSchema;\n    case FieldType.CreatedTime:\n      return createdTimeFieldOptionsRoSchema;\n    case FieldType.LastModifiedTime:\n      return lastModifiedTimeFieldOptionsRoSchema;\n    case FieldType.AutoNumber:\n      return autoNumberFieldOptionsRoSchema;\n    case FieldType.CreatedBy:\n      return createdByFieldOptionsSchema;\n    case FieldType.LastModifiedBy:\n      return lastModifiedByFieldOptionsSchema;\n    case FieldType.Button:\n      return buttonFieldOptionsSchema;\n    default:\n      assertNever(type);\n  }\n};\n\nconst refineOptions = (\n  data: {\n    type: FieldType;\n    isLookup?: boolean;\n    isConditionalLookup?: boolean;\n    lookupOptions?: ILookupOptionsRo;\n    options?: IFieldOptionsRo | null;\n    aiConfig?: IFieldAIConfig | null;\n  },\n  ctx: RefinementCtx\n) => {\n  if (data.isConditionalLookup && !data.isLookup) {\n    ctx.addIssue({\n      path: ['isConditionalLookup'],\n      code: 'custom',\n      message: 'isConditionalLookup requires isLookup to be true.',\n    });\n  }\n\n  const validateRes = validateFieldOptions(data);\n  validateRes.forEach((item) => {\n    ctx.addIssue({\n      path: item.path,\n      code: 'custom',\n      message: item.message,\n    });\n  });\n\n  // Validate aiConfig matches field type\n  if (data.aiConfig != null) {\n    const aiConfigSchema = getAiConfigSchema(data.type);\n    const result = aiConfigSchema.safeParse(data.aiConfig);\n    if (!result.success) {\n      result.error.issues.forEach((issue) => {\n        ctx.addIssue({\n          ...issue,\n          path: ['aiConfig', ...issue.path],\n        });\n      });\n    }\n  }\n};\n\nconst baseFieldRoSchema = fieldVoSchema\n  .partial()\n  .pick({\n    type: true,\n    name: true,\n    unique: true,\n    notNull: true,\n    dbFieldName: true,\n    isLookup: true,\n    isConditionalLookup: true,\n    description: true,\n  })\n  .required({\n    type: true,\n  })\n  .extend({\n    name: fieldVoSchema.shape.name.min(1).optional(),\n    description: fieldVoSchema.shape.description.nullable().optional(),\n    lookupOptions: lookupOptionsRoSchema.optional().meta({\n      description:\n        'The lookup options for field, you need to configure it when isLookup attribute is true or field type is rollup.',\n    }),\n    options: unionFieldOptionsRoSchema.optional().meta({\n      description:\n        \"The options of the field. The configuration of the field's options depend on the it's specific type.\",\n    }),\n    aiConfig: fieldAIConfigSchema.nullable().optional().meta({\n      description: 'The AI configuration of the field.',\n    }),\n  });\n\nexport const convertFieldRoSchema = baseFieldRoSchema\n  .extend({\n    options: baseFieldRoSchema.shape.options.nullable().optional(),\n  })\n  .superRefine(refineOptions);\nexport const createFieldRoSchema = baseFieldRoSchema\n  .extend({\n    id: z.string().startsWith(IdPrefix.Field).optional().meta({\n      description:\n        'The id of the field that start with \"fld\", followed by exactly 16 alphanumeric characters `/^fld[\\\\da-zA-Z]{16}$/`. It is sometimes useful to specify an id at creation time',\n      example: 'fldxxxxxxxxxxxxxxxx',\n    }),\n    order: z\n      .object({\n        viewId: z.string().meta({\n          description: 'You can only specify order in one view when create field',\n        }),\n        orderIndex: z.number(),\n      })\n      .optional(),\n  })\n  .superRefine(refineOptions);\n\nexport const updateFieldRoSchema = z.object({\n  name: baseFieldRoSchema.shape.name,\n  description: baseFieldRoSchema.shape.description,\n  dbFieldName: baseFieldRoSchema.shape.dbFieldName,\n});\n\nexport type IFieldRo = z.infer<typeof createFieldRoSchema>;\n\nexport type IConvertFieldRo = z.infer<typeof convertFieldRoSchema>;\n\nexport type IUpdateFieldRo = z.infer<typeof updateFieldRoSchema>;\n\nexport const getFieldsQuerySchema = z.object({\n  viewId: z.string().startsWith(IdPrefix.View).optional().meta({\n    description: 'The id of the view.',\n  }),\n  filterHidden: z.coerce.boolean().optional(),\n  projection: z.array(z.string().startsWith(IdPrefix.Field)).optional().meta({\n    description:\n      'If you want to get only some fields, pass in this parameter, otherwise all visible fields will be obtained',\n  }),\n});\n\nexport type IGetFieldsQuery = z.infer<typeof getFieldsQuerySchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/field.ts",
    "content": "import type { ZodSafeParseResult } from 'zod';\nimport { ZodError } from 'zod';\nimport type { TableDomain } from '../table';\nimport type { IFilter } from '../view/filter';\nimport type { CellValueType, DbFieldType, FieldType } from './constant';\nimport type { LinkFieldCore } from './derivate/link.field';\nimport type { IFieldVisitor } from './field-visitor.interface';\nimport type { IFieldVo } from './field.schema';\nimport type { IConditionalLookupOptions, ILookupOptionsVo } from './lookup-options-base.schema';\nimport { getDbFieldType } from './utils/get-db-field-type';\n\nexport abstract class FieldCore implements IFieldVo {\n  id!: string;\n\n  name!: string;\n\n  description?: string;\n\n  notNull?: boolean;\n\n  unique?: boolean;\n\n  isPrimary?: boolean;\n\n  dbFieldName!: string;\n\n  get dbFieldNames() {\n    return [this.dbFieldName];\n  }\n\n  aiConfig?: IFieldVo['aiConfig'];\n\n  abstract type: FieldType;\n\n  isComputed?: boolean;\n\n  isPending?: boolean;\n\n  hasError?: boolean;\n\n  dbFieldType!: DbFieldType;\n\n  abstract options: IFieldVo['options'];\n\n  abstract meta?: IFieldVo['meta'];\n\n  // cellValue type enum (string, number, boolean, datetime)\n  abstract cellValueType: CellValueType;\n\n  // if cellValue multiple\n  // every field need to consider to support multiple cellValue, because lookup value may be multiple\n  isMultipleCellValue?: boolean;\n\n  // if this field is lookup field\n  isLookup?: boolean;\n\n  // indicates lookup field applies conditional filtering when resolving values\n  isConditionalLookup?: boolean;\n\n  lookupOptions?: ILookupOptionsVo;\n\n  /**\n   * Whether this field is full read record denied.\n   */\n  recordRead?: boolean;\n\n  /**\n   * Whether this field is full create record denied.\n   */\n  recordCreate?: boolean;\n\n  /**\n   * some field may store a json type item, we need to know how to convert it to string\n   * it has those difference between cellValue2String\n   * item is the fundamental element of a cellValue, but cellValue may be a Array\n   * example a link cellValue: [{title: 'A1', id: 'rec1'}, {title: 'A2', id: 'rec2'}]\n   * in this case, {title: 'A1', id: 'rec1'} is the item in cellValue.\n   *\n   * caution:\n   * this function should handle the case that item is undefined\n   */\n  abstract item2String(value?: unknown): string;\n\n  abstract cellValue2String(value?: unknown): string;\n\n  abstract convertStringToCellValue(str: string, ctx?: unknown): unknown;\n\n  /**\n   * try parse cellValue as possible as it can\n   * if not match it would return null\n   * * computed field is always return null\n   */\n  abstract repair(value: unknown): unknown;\n\n  abstract validateOptions(): ZodSafeParseResult<unknown> | undefined;\n\n  abstract validateCellValue(value: unknown): ZodSafeParseResult<unknown> | undefined;\n\n  /**\n   * Wrapper to enforce notNull when calling validateCellValue.\n   */\n  validateCellValueWithNotNull(value: unknown): ZodSafeParseResult<unknown> | undefined {\n    if (this.isComputed) {\n      return this.validateCellValue(value);\n    }\n    if (this.notNull && (value === null || value === undefined)) {\n      return {\n        success: false,\n        error: new ZodError([\n          {\n            code: 'custom',\n            message: 'Required',\n            path: [],\n          },\n        ]),\n      };\n    }\n    return this.validateCellValue(value);\n  }\n\n  /**\n   * Updates the dbFieldType based on the current field type, cellValueType, and isMultipleCellValue\n   */\n  updateDbFieldType(): void {\n    this.dbFieldType = getDbFieldType(this.type, this.cellValueType, this.isMultipleCellValue);\n  }\n\n  /**\n   * Accept method for the Visitor pattern.\n   * Each concrete field type should implement this method to call the appropriate visitor method.\n   *\n   * @param visitor The visitor instance\n   * @returns The result of the visitor method call\n   */\n  abstract accept<T>(visitor: IFieldVisitor<T>): T;\n\n  getForeignLookupField(foreignTable: TableDomain): FieldCore | undefined {\n    const lookupFieldId = this.lookupOptions?.lookupFieldId;\n    if (!lookupFieldId) {\n      return undefined;\n    }\n\n    return foreignTable.getField(lookupFieldId);\n  }\n\n  mustGetForeignLookupField(foreignTable: TableDomain): FieldCore {\n    const field = this.getForeignLookupField(foreignTable);\n    if (!field) {\n      throw new Error(`Lookup field ${this.lookupOptions?.lookupFieldId} not found`);\n    }\n    return field;\n  }\n\n  getLinkField(table: TableDomain): LinkFieldCore | undefined {\n    const options = this.lookupOptions;\n    if (!options || !('linkFieldId' in options)) {\n      return undefined;\n    }\n    const linkFieldId = options.linkFieldId;\n    return table.getField(linkFieldId) as LinkFieldCore | undefined;\n  }\n\n  getLinkFields(table: TableDomain): LinkFieldCore[] {\n    const linkField = this.getLinkField(table);\n    if (!linkField) {\n      return [];\n    }\n    return [linkField];\n  }\n\n  get isStructuredCellValue(): boolean {\n    return false;\n  }\n\n  getConditionalLookupOptions(): IConditionalLookupOptions | undefined {\n    if (!this.isConditionalLookup) {\n      return undefined;\n    }\n\n    const options = this.lookupOptions;\n    if (!options || 'linkFieldId' in options) {\n      return undefined;\n    }\n\n    return options as IConditionalLookupOptions;\n  }\n\n  /**\n   * Returns the filter configured on this field's lookup options, if any.\n   */\n  getFilter(): IFilter | undefined {\n    return this.lookupOptions?.filter ?? undefined;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/field.type.ts",
    "content": "import type {\n  AutoNumberFieldCore,\n  CreatedTimeFieldCore,\n  FormulaFieldCore,\n  LastModifiedTimeFieldCore,\n} from './derivate';\n\nexport type IFieldWithExpression =\n  | FormulaFieldCore\n  | AutoNumberFieldCore\n  | CreatedTimeFieldCore\n  | LastModifiedTimeFieldCore;\n"
  },
  {
    "path": "packages/core/src/models/field/field.util.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { FieldType, DbFieldType, CellValueType, OpName } from '../..';\nimport type { ISetFieldPropertyOpContext } from '../../op-builder/field/set-field-property';\nimport type { IFieldVo } from './field.schema';\nimport { applyFieldPropertyOps } from './field.util';\n\ndescribe('applyFieldPropertyOps', () => {\n  const mockField: IFieldVo = {\n    id: 'fld123',\n    name: 'Test Field',\n    type: FieldType.SingleLineText,\n    dbFieldName: 'test_field',\n    dbFieldType: DbFieldType.Text,\n    cellValueType: CellValueType.String,\n    options: {},\n    description: 'Original description',\n    notNull: false,\n    unique: false,\n  };\n\n  it('should apply single field property operation', () => {\n    const ops: ISetFieldPropertyOpContext[] = [\n      {\n        name: OpName.SetFieldProperty,\n        key: 'name',\n        newValue: 'Updated Field Name',\n        oldValue: 'Test Field',\n      },\n    ];\n\n    const result = applyFieldPropertyOps(mockField, ops);\n\n    expect(result.name).toBe('Updated Field Name');\n    expect(result.id).toBe(mockField.id); // Other properties should remain unchanged\n    expect(result.type).toBe(mockField.type);\n\n    // Original field should remain unchanged (immutability test)\n    expect(mockField.name).toBe('Test Field');\n  });\n\n  it('should apply multiple field property operations', () => {\n    const ops: ISetFieldPropertyOpContext[] = [\n      {\n        name: OpName.SetFieldProperty,\n        key: 'name',\n        newValue: 'Updated Name',\n        oldValue: 'Test Field',\n      },\n      {\n        name: OpName.SetFieldProperty,\n        key: 'description',\n        newValue: 'Updated description',\n        oldValue: 'Original description',\n      },\n      {\n        name: OpName.SetFieldProperty,\n        key: 'notNull',\n        newValue: true,\n        oldValue: false,\n      },\n    ];\n\n    const result = applyFieldPropertyOps(mockField, ops);\n\n    expect(result.name).toBe('Updated Name');\n    expect(result.description).toBe('Updated description');\n    expect(result.notNull).toBe(true);\n\n    // Original field should remain unchanged\n    expect(mockField.name).toBe('Test Field');\n    expect(mockField.description).toBe('Original description');\n    expect(mockField.notNull).toBe(false);\n  });\n\n  it('should handle empty operations array', () => {\n    const ops: ISetFieldPropertyOpContext[] = [];\n    const result = applyFieldPropertyOps(mockField, ops);\n\n    expect(result).toEqual(mockField);\n    expect(result).not.toBe(mockField); // Should be a different object (deep copy)\n  });\n\n  it('should handle options property updates', () => {\n    const ops: ISetFieldPropertyOpContext[] = [\n      {\n        name: OpName.SetFieldProperty,\n        key: 'options',\n        newValue: { maxLength: 100 },\n        oldValue: {},\n      },\n    ];\n\n    const result = applyFieldPropertyOps(mockField, ops);\n\n    expect(result.options).toEqual({ maxLength: 100 });\n    expect(mockField.options).toEqual({}); // Original should remain unchanged\n  });\n\n  it('should handle null/undefined values', () => {\n    const ops: ISetFieldPropertyOpContext[] = [\n      {\n        name: OpName.SetFieldProperty,\n        key: 'description',\n        newValue: undefined,\n        oldValue: 'Original description',\n      },\n    ];\n\n    const result = applyFieldPropertyOps(mockField, ops);\n\n    expect(result.description).toBeUndefined();\n    expect(mockField.description).toBe('Original description');\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/field.util.ts",
    "content": "import type { ISetFieldPropertyOpContext } from '../../op-builder/field/set-field-property';\nimport { FieldType } from './constant';\nimport type { FormulaFieldCore, LinkFieldCore } from './derivate';\nimport type { FieldCore } from './field';\nimport type { IFieldVo } from './field.schema';\nimport type { IFieldWithExpression } from './field.type';\n\nexport function isFormulaField(field: FieldCore): field is FormulaFieldCore {\n  return field.type === FieldType.Formula;\n}\n\nexport function isLinkField(field: FieldCore): field is LinkFieldCore {\n  return field.type === FieldType.Link && !field.isLookup;\n}\n\nexport function isFieldHasExpression(field: FieldCore): field is IFieldWithExpression {\n  return (\n    isFormulaField(field) ||\n    field.type === FieldType.AutoNumber ||\n    field.type === FieldType.LastModifiedTime ||\n    field.type === FieldType.CreatedTime\n  );\n}\n\n/**\n * Apply a single field property operation to a field VO.\n * This is a helper function that handles type-safe property assignment.\n */\nfunction applyFieldPropertyOperation(\n  fieldVo: IFieldVo,\n  key: ISetFieldPropertyOpContext['key'],\n  newValue: unknown\n): IFieldVo {\n  switch (key) {\n    case 'type':\n      return { ...fieldVo, type: newValue as IFieldVo['type'] };\n    case 'name':\n      return { ...fieldVo, name: newValue as string };\n    case 'description':\n      return { ...fieldVo, description: newValue as string | undefined };\n    case 'options':\n      return { ...fieldVo, options: newValue as IFieldVo['options'] };\n    case 'meta':\n      return { ...fieldVo, meta: newValue as IFieldVo['meta'] };\n    case 'aiConfig':\n      return { ...fieldVo, aiConfig: newValue as IFieldVo['aiConfig'] };\n    case 'notNull':\n      return { ...fieldVo, notNull: newValue as boolean | undefined };\n    case 'unique':\n      return { ...fieldVo, unique: newValue as boolean | undefined };\n    case 'isPrimary':\n      return { ...fieldVo, isPrimary: newValue as boolean | undefined };\n    case 'isComputed':\n      return { ...fieldVo, isComputed: newValue as boolean | undefined };\n    case 'isPending':\n      return { ...fieldVo, isPending: newValue as boolean | undefined };\n    case 'hasError':\n      return { ...fieldVo, hasError: newValue as boolean | undefined };\n    case 'isLookup':\n      return { ...fieldVo, isLookup: newValue as boolean | undefined };\n    case 'isConditionalLookup':\n      return { ...fieldVo, isConditionalLookup: newValue as boolean | undefined };\n    case 'lookupOptions':\n      return { ...fieldVo, lookupOptions: newValue as IFieldVo['lookupOptions'] };\n    case 'cellValueType':\n      return { ...fieldVo, cellValueType: newValue as IFieldVo['cellValueType'] };\n    case 'isMultipleCellValue':\n      return { ...fieldVo, isMultipleCellValue: newValue as boolean | undefined };\n    case 'dbFieldType':\n      return { ...fieldVo, dbFieldType: newValue as IFieldVo['dbFieldType'] };\n    case 'dbFieldName':\n      return { ...fieldVo, dbFieldName: newValue as string };\n    case 'recordRead':\n      return { ...fieldVo, recordRead: newValue as boolean | undefined };\n    case 'recordCreate':\n      return { ...fieldVo, recordCreate: newValue as boolean | undefined };\n    default:\n      // For unsupported keys (like 'id' and 'type'), return the original fieldVo unchanged\n      return fieldVo;\n  }\n}\n\n/**\n * Apply field property operations to a field VO and return a new field VO.\n * This is a pure function that does not mutate the original field VO.\n *\n * @param fieldVo - The existing field VO to base the new field on\n * @param ops - Array of field property operations to apply\n * @returns A new field VO with the operations applied\n */\nexport function applyFieldPropertyOps(\n  fieldVo: IFieldVo,\n  ops: ISetFieldPropertyOpContext[]\n): IFieldVo {\n  // Always create a copy to ensure immutability, even with empty operations\n  return ops.reduce(\n    (currentFieldVo, op) => applyFieldPropertyOperation(currentFieldVo, op.key, op.newValue),\n    { ...fieldVo }\n  );\n}\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/datetime.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { IDatetimeFormatting } from './datetime';\nimport {\n  datetimeFormattingSchema,\n  DateFormattingPreset,\n  formatDateToString,\n  TimeFormatting,\n} from './datetime';\n\nconst timeZone = 'utc';\nconst sampleDateStr = '2023-07-12T14:30:00Z';\n\ndescribe('formatDateToString', () => {\n  it('should correctly format date string', () => {\n    const formatting: IDatetimeFormatting = {\n      time: TimeFormatting.None,\n      date: DateFormattingPreset.European,\n      timeZone: timeZone,\n    };\n    expect(formatDateToString(sampleDateStr, formatting)).toBe('12/7/2023');\n  });\n\n  it('should return empty string for null', () => {\n    const dateStr = null;\n    expect(\n      formatDateToString(dateStr as any, {\n        time: TimeFormatting.None,\n        date: DateFormattingPreset.European,\n        timeZone: timeZone,\n      })\n    ).toBe('');\n  });\n\n  it('should correctly format date and time string', () => {\n    const formatting: IDatetimeFormatting = {\n      date: DateFormattingPreset.ISO,\n      time: TimeFormatting.Hour24,\n      timeZone: timeZone,\n    };\n    expect(formatDateToString(sampleDateStr, formatting)).toBe('2023-07-12 14:30');\n  });\n\n  it('should fallback to default formatting when formatting is undefined', () => {\n    const formatted = formatDateToString(sampleDateStr);\n    expect(formatted).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n  });\n\n  it('should validate time zone', () => {\n    expect(\n      datetimeFormattingSchema.safeParse({\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.Hour24,\n        timeZone: timeZone,\n      }).success\n    ).toBeTruthy();\n\n    expect(\n      datetimeFormattingSchema.safeParse({\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.Hour24,\n        timeZone: 'xxx/xxx',\n      }).success\n    ).toBeFalsy();\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/datetime.ts",
    "content": "import { formatInTimeZone } from 'date-fns-tz';\nimport dayjs from 'dayjs';\nimport { z } from '../../../zod';\nimport { timeZoneStringSchema } from './time-zone';\n\nexport enum DateFormattingPreset {\n  US = 'M/D/YYYY',\n  European = 'D/M/YYYY',\n  Asian = 'YYYY/MM/DD',\n  ISO = 'YYYY-MM-DD',\n  YM = 'YYYY-MM',\n  MD = 'MM-DD',\n  Y = 'YYYY',\n  M = 'MM',\n  D = 'DD',\n}\n\nexport enum TimeFormatting {\n  Hour24 = 'HH:mm',\n  Hour12 = 'hh:mm A',\n  None = 'None',\n}\n\nexport const datetimeFormattingSchema = z\n  .object({\n    date: z.string().meta({\n      description:\n        'the display formatting of the date. you can use the following presets: ' +\n        Object.values(DateFormattingPreset).join(', '),\n    }),\n    time: z.enum(TimeFormatting).meta({\n      description:\n        'the display formatting of the time. you can use the following presets: ' +\n        Object.values(TimeFormatting).join(', '),\n    }),\n    timeZone: timeZoneStringSchema,\n  })\n  .describe(\n    'Only be used in date field (date field or formula / rollup field with cellValueType equals dateTime)'\n  )\n  .meta({\n    description:\n      'caveat: the formatting is just a formatter, it dose not effect the storing value of the record',\n  });\n\nexport type ITimeZoneString = string;\n\nexport type IDatetimeFormatting = z.infer<typeof datetimeFormattingSchema>;\n\nexport const defaultDatetimeFormatting: IDatetimeFormatting = {\n  date: DateFormattingPreset.ISO,\n  time: TimeFormatting.None,\n  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n};\n\nexport const formatDateToString = (\n  cellValue: string | undefined,\n  formatting?: IDatetimeFormatting\n) => {\n  if (cellValue == null) {\n    return '';\n  }\n\n  const { date, time, timeZone } = formatting ?? defaultDatetimeFormatting;\n  const format = time === TimeFormatting.None ? date : `${date} ${time}`;\n\n  try {\n    return dayjs(cellValue).tz(timeZone).format(format);\n  } catch {\n    // in export service case, crash in dayjs, so use date-fns-tz\n    return formatInTimeZone(cellValue, timeZone, format.replace(/D/g, 'd').replace(/Y/g, 'y'));\n  }\n};\n\nexport const normalizeDateFormatting = (dateFormatting: string): string => {\n  const validFormats = Object.values(DateFormattingPreset);\n  if (validFormats.includes(dateFormatting as DateFormattingPreset)) {\n    return dateFormatting;\n  }\n  return DateFormattingPreset.ISO;\n};\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/index.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { describe, it, expect } from 'vitest';\nimport { DateFormattingPreset, TimeFormatting } from './datetime';\nimport { NumberFormattingType } from './number';\nimport { unionFormattingSchema } from './index';\n\ndescribe('unionFormattingSchema - Smart Error Messages', () => {\n  describe('Number Formatting Validation', () => {\n    it('should validate correct decimal formatting', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Decimal,\n        precision: 4,\n      });\n\n      expect(result.success).toBe(true);\n    });\n\n    it('should validate correct percent formatting', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Percent,\n        precision: 2,\n      });\n\n      expect(result.success).toBe(true);\n    });\n\n    it('should validate correct currency formatting', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Currency,\n        precision: 2,\n        symbol: '¥',\n      });\n\n      expect(result.success).toBe(true);\n    });\n\n    it('FIXED: should give clear error when type is missing', () => {\n      const result = unionFormattingSchema.safeParse({\n        precision: 4,\n      });\n\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        const errorMessage = result.error.message;\n\n        expect(errorMessage).toContain('type');\n        expect(errorMessage).not.toContain('expression');\n        expect(errorMessage).not.toContain('countall');\n        expect(errorMessage).not.toContain('sum({values})');\n        expect(errorMessage).not.toContain('date');\n        expect(errorMessage).not.toContain('timeZone');\n      }\n    });\n\n    it('should give clear error for invalid precision', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Decimal,\n        precision: 10,\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message.toLowerCase()).toContain('precision');\n      }\n    });\n\n    it('should list available types when type value is invalid', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: 'invalid_type',\n        precision: 2,\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        const errorMessage = result.error.message;\n        expect(errorMessage).toContain('type');\n      }\n    });\n\n    it('should show available types in error message when type is missing', () => {\n      const result = unionFormattingSchema.safeParse({\n        precision: 4,\n        symbol: '$',\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        const errorMessage = result.error.message;\n        expect(errorMessage).toContain('decimal');\n        expect(errorMessage).toContain('percent');\n        expect(errorMessage).toContain('currency');\n      }\n    });\n\n    it('should give clear error for missing symbol in currency', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Currency,\n        precision: 2,\n        // 缺少 symbol\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('symbol');\n      }\n    });\n  });\n\n  describe('Datetime Formatting Validation', () => {\n    it('should validate correct datetime formatting', () => {\n      const result = unionFormattingSchema.safeParse({\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.None,\n        timeZone: 'Asia/Shanghai',\n      });\n\n      expect(result.success).toBe(true);\n    });\n\n    it('should give clear error for missing date field', () => {\n      const result = unionFormattingSchema.safeParse({\n        time: TimeFormatting.Hour24,\n        timeZone: 'Asia/Shanghai',\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('date');\n        expect(result.error.message).not.toContain('precision');\n        expect(result.error.message).not.toContain('decimal');\n      }\n    });\n\n    it('should give clear error for missing timeZone', () => {\n      const result = unionFormattingSchema.safeParse({\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.None,\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('timeZone');\n      }\n    });\n  });\n\n  describe('Mixed Fields Detection', () => {\n    it('should detect mixed number and datetime fields', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n        date: DateFormattingPreset.ISO,\n        time: TimeFormatting.None,\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('Cannot mix');\n        expect(result.error.message).toContain('number');\n        expect(result.error.message).toContain('datetime');\n      }\n    });\n\n    it('should detect symbol (number) mixed with date (datetime)', () => {\n      const result = unionFormattingSchema.safeParse({\n        symbol: '$',\n        date: DateFormattingPreset.ISO,\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('Cannot mix');\n      }\n    });\n  });\n\n  describe('Type Field Validation', () => {\n    it('should clearly indicate type is only for number formatting, not datetime', () => {\n      const validNumber = unionFormattingSchema.safeParse({\n        type: 'decimal',\n        precision: 2,\n      });\n      expect(validNumber.success).toBe(true);\n\n      const validDatetime = unionFormattingSchema.safeParse({\n        date: 'YYYY-MM-DD',\n        time: 'HH:mm',\n        timeZone: 'UTC',\n      });\n      expect(validDatetime.success).toBe(true);\n    });\n\n    it('should reject type field in datetime formatting context', () => {\n      const result = unionFormattingSchema.safeParse({\n        date: 'YYYY-MM-DD',\n        type: 'decimal',\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('Cannot mix');\n      }\n    });\n  });\n\n  describe('Edge Cases', () => {\n    it('should handle empty object', () => {\n      const result = unionFormattingSchema.safeParse({});\n\n      expect(result.success).toBe(false);\n      // 应该得到标准的 union 错误\n    });\n\n    it('should handle null', () => {\n      const result = unionFormattingSchema.safeParse(null);\n\n      expect(result.success).toBe(false);\n    });\n\n    it('should handle undefined', () => {\n      const result = unionFormattingSchema.safeParse(undefined);\n\n      expect(result.success).toBe(false);\n    });\n\n    it('should reject unrecognized fields', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: NumberFormattingType.Decimal,\n        precision: 2,\n        unknownField: 'value', // 不应该存在的字段\n      });\n\n      expect(result.success).toBe(false);\n      if (!result.success) {\n        expect(result.error.message).toContain('unknownField');\n      }\n    });\n  });\n\n  describe('Real-world Scenarios', () => {\n    it('exchange rate field with correct formatting', () => {\n      const correct = unionFormattingSchema.safeParse({\n        type: 'decimal',\n        precision: 4,\n      });\n      expect(correct.success).toBe(true);\n\n      const incorrect = unionFormattingSchema.safeParse({\n        precision: 4,\n      });\n      expect(incorrect.success).toBe(false);\n      if (!incorrect.success) {\n        const msg = incorrect.error.message;\n        expect(msg).toContain('type');\n        expect(msg.toLowerCase()).toMatch(/decimal|percent|currency/);\n      }\n    });\n\n    it('currency field with symbol', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: 'currency',\n        precision: 2,\n        symbol: '¥',\n      });\n      expect(result.success).toBe(true);\n    });\n\n    it('percentage field', () => {\n      const result = unionFormattingSchema.safeParse({\n        type: 'percent',\n        precision: 1,\n      });\n      expect(result.success).toBe(true);\n    });\n\n    it('datetime field', () => {\n      const result = unionFormattingSchema.safeParse({\n        date: 'YYYY-MM-DD',\n        time: 'HH:mm',\n        timeZone: 'UTC',\n      });\n      expect(result.success).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/index.ts",
    "content": "import { z } from '../../../zod';\nimport { CellValueType } from '../constant';\nimport {\n  datetimeFormattingSchema,\n  defaultDatetimeFormatting,\n  type IDatetimeFormatting,\n} from './datetime';\nimport { defaultNumberFormatting, numberFormattingSchema, type INumberFormatting } from './number';\n\nexport * from './number';\nexport * from './datetime';\nexport * from './time-zone';\n\nexport type IUnionFormatting = IDatetimeFormatting | INumberFormatting;\n\nconst translateNumberFormattingError = (issue: z.ZodIssue): string => {\n  if (issue.code === 'invalid_union') {\n    return 'Invalid \"type\" value. Must be one of: decimal, percent, currency';\n  }\n  if (issue.code === 'too_big' && issue.path[0] === 'precision') {\n    return `Precision must be between 0 and ${issue.maximum}`;\n  }\n  if (issue.code === 'too_small' && issue.path[0] === 'precision') {\n    return `Precision must be between ${issue.minimum} and 5`;\n  }\n  if (issue.code === 'invalid_type' && issue.path[0] === 'symbol') {\n    return 'Currency formatting requires \"symbol\" field';\n  }\n  if (issue.code === 'unrecognized_keys') {\n    return `Unrecognized fields: ${issue.keys?.join(', ')}`;\n  }\n  return `Invalid number formatting: ${issue.message}`;\n};\n\nconst translateDatetimeFormattingError = (issue: z.ZodIssue): string => {\n  if (issue.code === 'invalid_type' && issue.path[0] === 'date') {\n    return 'Datetime formatting requires \"date\" field';\n  }\n  if (issue.code === 'invalid_type' && issue.path[0] === 'time') {\n    return 'Datetime formatting requires \"time\" field';\n  }\n  if (issue.code === 'invalid_type' && issue.path[0] === 'timeZone') {\n    return 'Datetime formatting requires \"timeZone\" field';\n  }\n  if (issue.code === 'invalid_value' && issue.path[0] === 'time') {\n    return 'Invalid \"time\" value. Must be one of: HH:mm, hh:mm A, None';\n  }\n  return `Invalid datetime formatting: ${issue.message}`;\n};\n\nconst createPreciseErrorMessage = (val: unknown): string | undefined => {\n  if (typeof val !== 'object' || val === null) {\n    return 'Formatting must be an object';\n  }\n\n  const hasNumberOnlyFields = 'precision' in val || 'symbol' in val;\n  const hasTypeField = 'type' in val;\n  const hasDatetimeFields = 'date' in val || 'time' in val || 'timeZone' in val;\n\n  const isNumberFormatting = hasNumberOnlyFields || hasTypeField;\n  const isDatetimeFormatting = hasDatetimeFields;\n\n  if (isNumberFormatting && isDatetimeFormatting) {\n    return 'Cannot mix number formatting (type, precision, symbol) with datetime formatting (date, time, timeZone)';\n  }\n\n  if (isNumberFormatting) {\n    if (!hasTypeField) {\n      return 'Number formatting requires \"type\" field (decimal, percent, or currency)';\n    }\n\n    const result = numberFormattingSchema.safeParse(val);\n    if (!result.success) {\n      return translateNumberFormattingError(result.error.issues[0]);\n    }\n    return undefined;\n  }\n\n  if (isDatetimeFormatting) {\n    const result = datetimeFormattingSchema.safeParse(val);\n    if (!result.success) {\n      return translateDatetimeFormattingError(result.error.issues[0]);\n    }\n    return undefined;\n  }\n\n  return 'Invalid formatting. Expected number formatting (type, precision) or datetime formatting (date, time, timeZone)';\n};\n\nexport const unionFormattingSchema = z\n  .any()\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  .superRefine((val, ctx) => {\n    const errorMessage = createPreciseErrorMessage(val);\n\n    if (errorMessage) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: errorMessage,\n      });\n    }\n  })\n  .meta({\n    description:\n      'Different cell value types are determined based on the results of expression parsing',\n  });\n\nexport const getDefaultFormatting = (cellValueType: CellValueType) => {\n  switch (cellValueType) {\n    case CellValueType.Number:\n      return defaultNumberFormatting;\n    case CellValueType.DateTime:\n      return defaultDatetimeFormatting;\n  }\n};\n\nexport const getFormattingSchema = (cellValueType: CellValueType) => {\n  switch (cellValueType) {\n    case CellValueType.Number:\n      return numberFormattingSchema;\n    case CellValueType.DateTime:\n      return datetimeFormattingSchema;\n    default:\n      return z.undefined().meta({\n        description: 'Only number and datetime cell value type support formatting',\n      });\n  }\n};\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/number.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { INumberFormatting } from './number';\nimport { NumberFormattingType, formatNumberToString, numberFormattingSchema } from './number';\n\ndescribe('formatNumberToString', () => {\n  describe('Formatting Decimal', () => {\n    const decimalFormatting: INumberFormatting = {\n      type: NumberFormattingType.Decimal,\n      precision: 2,\n    };\n\n    it('should correctly format number string with precision', () => {\n      const num = 1234.5678;\n      expect(formatNumberToString(num, decimalFormatting)).toBe('1234.57');\n    });\n\n    it('should return empty string for null', () => {\n      const num = null;\n      expect(formatNumberToString(num as any, decimalFormatting)).toBe('');\n    });\n\n    it('should correctly format integer', () => {\n      const num = 1234;\n      const formatting: INumberFormatting = { type: NumberFormattingType.Decimal, precision: 0 };\n      expect(formatNumberToString(num, formatting)).toBe('1234');\n    });\n  });\n\n  describe('Formatting Percent', () => {\n    const percentFormatting: INumberFormatting = {\n      type: NumberFormattingType.Percent,\n      precision: 2,\n    };\n\n    it('should format a number as a percentage with the specified precision', () => {\n      const num = 1.66667;\n      expect(formatNumberToString(num, percentFormatting)).toBe('166.67%');\n    });\n\n    it('should return an empty string when given a null input', () => {\n      const num = null;\n      expect(formatNumberToString(num as any, percentFormatting)).toBe('');\n    });\n  });\n\n  describe('Formatting Currency', () => {\n    const currencyFormatting: INumberFormatting = {\n      type: NumberFormattingType.Currency,\n      symbol: '$',\n      precision: 2,\n    };\n\n    it('should format a number as currency with the specified symbol and precision', () => {\n      const num = 100.5678;\n      expect(formatNumberToString(num, currencyFormatting)).toBe('$100.57');\n    });\n\n    it('should format a large number as currency with the specified symbol and precision', () => {\n      const num = 10000000.234;\n      expect(formatNumberToString(num, currencyFormatting)).toBe('$10,000,000.23');\n    });\n\n    it('should return an empty string when given a null input', () => {\n      const num = null;\n      expect(formatNumberToString(num as any, currencyFormatting)).toBe('');\n    });\n  });\n});\n\ndescribe('numberFormattingSchema validation', () => {\n  describe('Valid formatting', () => {\n    it('should pass with correct decimal formatting', () => {\n      const validDecimal = { type: 'decimal', precision: 2 };\n      const result = numberFormattingSchema.safeParse(validDecimal);\n      expect(result.success).toBe(true);\n    });\n\n    it('should pass with correct percent formatting', () => {\n      const validPercent = { type: 'percent', precision: 1 };\n      const result = numberFormattingSchema.safeParse(validPercent);\n      expect(result.success).toBe(true);\n    });\n\n    it('should pass with correct currency formatting', () => {\n      const validCurrency = { type: 'currency', symbol: '¥', precision: 2 };\n      const result = numberFormattingSchema.safeParse(validCurrency);\n      expect(result.success).toBe(true);\n    });\n  });\n\n  describe('Type-specific error messages', () => {\n    it('should detect missing precision and unrecognized symbol for decimal', () => {\n      const invalidDecimal = { type: 'decimal', symbol: '$' }; // Missing precision, wrong field\n\n      const result = numberFormattingSchema.safeParse(invalidDecimal);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // Should have exactly 2 errors: missing precision + unrecognized key (symbol)\n        expect(result.error.issues).toHaveLength(2);\n\n        const errorCodes = result.error.issues.map((i) => i.code);\n        expect(errorCodes).toContain('invalid_type'); // missing precision\n        expect(errorCodes).toContain('unrecognized_keys'); // extra symbol\n      }\n    });\n\n    it('should detect missing precision and unrecognized symbol for percent', () => {\n      const invalidPercent = { type: 'percent', symbol: '%' }; // Missing precision, wrong field\n\n      const result = numberFormattingSchema.safeParse(invalidPercent);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // Should have exactly 2 errors: missing precision + unrecognized key (symbol)\n        expect(result.error.issues).toHaveLength(2);\n\n        const errorCodes = result.error.issues.map((i) => i.code);\n        expect(errorCodes).toContain('invalid_type'); // missing precision\n        expect(errorCodes).toContain('unrecognized_keys'); // extra symbol\n      }\n    });\n\n    it('should detect missing symbol for currency', () => {\n      const invalidCurrency = { type: 'currency', precision: 2 }; // Missing symbol\n\n      const result = numberFormattingSchema.safeParse(invalidCurrency);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // Should have exactly 1 error: missing symbol\n        expect(result.error.issues).toHaveLength(1);\n        expect(result.error.issues[0].code).toBe('invalid_type');\n        expect(result.error.issues[0].path).toEqual(['symbol']);\n      }\n    });\n\n    it('should detect missing symbol and unrecognized currencyCode', () => {\n      const wrongCurrency = {\n        type: 'currency',\n        currencyCode: 'CNY', // Wrong field\n        precision: 2, // Missing symbol\n      };\n\n      const result = numberFormattingSchema.safeParse(wrongCurrency);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // Should have exactly 2 errors: missing symbol + unrecognized key (currencyCode)\n        expect(result.error.issues).toHaveLength(2);\n\n        const errorCodes = result.error.issues.map((i) => i.code);\n        expect(errorCodes).toContain('invalid_type'); // missing symbol\n        expect(errorCodes).toContain('unrecognized_keys'); // extra currencyCode\n      }\n    });\n\n    it('should reject invalid type (discriminatedUnion behavior)', () => {\n      const invalidType = { type: 'money', precision: 2 }; // Invalid type\n\n      const result = numberFormattingSchema.safeParse(invalidType);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // discriminatedUnion returns \"Invalid input\" for unmatched discriminator\n        expect(result.error.issues[0].code).toBe('invalid_union');\n        expect(result.error.issues[0].path).toEqual(['type']);\n      }\n    });\n\n    it('should reject missing type (discriminatedUnion behavior)', () => {\n      const missingType = { precision: 2 }; // Missing type\n\n      const result = numberFormattingSchema.safeParse(missingType);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // discriminatedUnion returns \"Invalid input\" for missing discriminator\n        expect(result.error.issues[0].code).toBe('invalid_union');\n        expect(result.error.issues[0].path).toEqual(['type']);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/number.ts",
    "content": "import { z } from 'zod';\n\nexport enum NumberFormattingType {\n  Decimal = 'decimal',\n  Percent = 'percent',\n  Currency = 'currency',\n}\n\nconst baseFormatting = z.object({\n  precision: z.number().max(5).min(0),\n});\n\nexport const decimalFormattingSchema = baseFormatting\n  .extend({\n    type: z.literal(NumberFormattingType.Decimal),\n  })\n  .strict();\n\nexport const percentFormattingSchema = baseFormatting\n  .extend({\n    type: z.literal(NumberFormattingType.Percent),\n  })\n  .strict();\n\nexport const currencyFormattingSchema = baseFormatting\n  .extend({\n    type: z.literal(NumberFormattingType.Currency),\n    symbol: z.string(),\n  })\n  .strict();\n\nexport const numberFormattingSchema = z\n  .discriminatedUnion('type', [\n    decimalFormattingSchema,\n    percentFormattingSchema,\n    currencyFormattingSchema,\n  ])\n  .describe(\n    'Only be used in number field (number field or formula / rollup field with cellValueType equals Number'\n  );\n\nexport type IDecimalFormatting = z.infer<typeof decimalFormattingSchema>;\n\nexport type IPercentFormatting = z.infer<typeof percentFormattingSchema>;\n\nexport type ICurrencyFormatting = z.infer<typeof currencyFormattingSchema>;\n\nexport type INumberFormatting = z.infer<typeof numberFormattingSchema>;\n\nexport const defaultNumberFormatting: INumberFormatting = {\n  type: NumberFormattingType.Decimal,\n  precision: 2,\n};\n\nexport const formatNumberToString = (value: number | undefined, formatting: INumberFormatting) => {\n  if (value == null) {\n    return '';\n  }\n\n  const cellValue = Number(value);\n  const { type, precision } = formatting;\n\n  if (type === NumberFormattingType.Currency) {\n    const symbol = formatting.symbol ?? '$';\n    const sign = cellValue < 0 ? '-' : '';\n    const options =\n      precision != null\n        ? {\n            minimumFractionDigits: precision,\n            maximumFractionDigits: precision,\n          }\n        : undefined;\n\n    const formattedValue = Math.abs(cellValue).toLocaleString('en-US', options);\n    return sign + symbol + formattedValue;\n  }\n\n  if (type === NumberFormattingType.Percent) {\n    const formattedNumber = (cellValue * 100).toFixed(precision);\n    return `${formattedNumber}%`;\n  }\n\n  if (precision != null) {\n    return cellValue.toFixed(precision);\n  }\n\n  return String(cellValue);\n};\n\nexport const parseStringToNumber = (value: string | null, formatting?: INumberFormatting) => {\n  if (value == null || value === '') return null;\n\n  const originStr = String(value);\n  const isPercent = formatting?.type === NumberFormattingType.Percent || originStr.includes('%');\n  const numberReg = /[^\\d.+-]/g;\n  const symbolReg = /([+\\-.])+/g;\n  const numStr = originStr.replace(numberReg, '').replace(symbolReg, '$1');\n  const num = parseFloat(numStr);\n\n  if (Number.isNaN(num)) {\n    return null;\n  }\n  return isPercent ? num / 100 : num;\n};\n"
  },
  {
    "path": "packages/core/src/models/field/formatting/time-zone.ts",
    "content": "import dayjs from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\nimport { z } from '../../../zod';\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nexport const TIME_ZONE_LIST = [\n  'UTC',\n  'Africa/Abidjan',\n  'Africa/Accra',\n  'Africa/Addis_Ababa',\n  'Africa/Algiers',\n  'Africa/Asmara',\n  'Africa/Bamako',\n  'Africa/Bangui',\n  'Africa/Banjul',\n  'Africa/Bissau',\n  'Africa/Blantyre',\n  'Africa/Brazzaville',\n  'Africa/Bujumbura',\n  'Africa/Cairo',\n  'Africa/Casablanca',\n  'Africa/Ceuta',\n  'Africa/Conakry',\n  'Africa/Dakar',\n  'Africa/Dar_es_Salaam',\n  'Africa/Djibouti',\n  'Africa/Douala',\n  'Africa/El_Aaiun',\n  'Africa/Freetown',\n  'Africa/Gaborone',\n  'Africa/Harare',\n  'Africa/Johannesburg',\n  'Africa/Juba',\n  'Africa/Kampala',\n  'Africa/Khartoum',\n  'Africa/Kigali',\n  'Africa/Kinshasa',\n  'Africa/Lagos',\n  'Africa/Libreville',\n  'Africa/Lome',\n  'Africa/Luanda',\n  'Africa/Lubumbashi',\n  'Africa/Lusaka',\n  'Africa/Malabo',\n  'Africa/Maputo',\n  'Africa/Maseru',\n  'Africa/Mbabane',\n  'Africa/Mogadishu',\n  'Africa/Monrovia',\n  'Africa/Nairobi',\n  'Africa/Ndjamena',\n  'Africa/Niamey',\n  'Africa/Nouakchott',\n  'Africa/Ouagadougou',\n  'Africa/Porto-Novo',\n  'Africa/Sao_Tome',\n  'Africa/Tripoli',\n  'Africa/Tunis',\n  'Africa/Windhoek',\n  'America/Adak',\n  'America/Anchorage',\n  'America/Anguilla',\n  'America/Antigua',\n  'America/Araguaina',\n  'America/Argentina/Buenos_Aires',\n  'America/Argentina/Catamarca',\n  'America/Argentina/Cordoba',\n  'America/Argentina/Jujuy',\n  'America/Argentina/La_Rioja',\n  'America/Argentina/Mendoza',\n  'America/Argentina/Rio_Gallegos',\n  'America/Argentina/Salta',\n  'America/Argentina/San_Juan',\n  'America/Argentina/San_Luis',\n  'America/Argentina/Tucuman',\n  'America/Argentina/Ushuaia',\n  'America/Aruba',\n  'America/Asuncion',\n  'America/Atikokan',\n  'America/Bahia',\n  'America/Bahia_Banderas',\n  'America/Barbados',\n  'America/Belem',\n  'America/Belize',\n  'America/Blanc-Sablon',\n  'America/Boa_Vista',\n  'America/Bogota',\n  'America/Boise',\n  'America/Cambridge_Bay',\n  'America/Campo_Grande',\n  'America/Cancun',\n  'America/Caracas',\n  'America/Cayenne',\n  'America/Cayman',\n  'America/Chicago',\n  'America/Chihuahua',\n  'America/Costa_Rica',\n  'America/Creston',\n  'America/Cuiaba',\n  'America/Curacao',\n  'America/Danmarkshavn',\n  'America/Dawson',\n  'America/Dawson_Creek',\n  'America/Denver',\n  'America/Detroit',\n  'America/Dominica',\n  'America/Edmonton',\n  'America/Eirunepe',\n  'America/El_Salvador',\n  'America/Fort_Nelson',\n  'America/Fortaleza',\n  'America/Glace_Bay',\n  'America/Godthab',\n  'America/Goose_Bay',\n  'America/Grand_Turk',\n  'America/Grenada',\n  'America/Guadeloupe',\n  'America/Guatemala',\n  'America/Guayaquil',\n  'America/Guyana',\n  'America/Halifax',\n  'America/Havana',\n  'America/Hermosillo',\n  'America/Indiana/Indianapolis',\n  'America/Indiana/Knox',\n  'America/Indiana/Marengo',\n  'America/Indiana/Petersburg',\n  'America/Indiana/Tell_City',\n  'America/Indiana/Vevay',\n  'America/Indiana/Vincennes',\n  'America/Indiana/Winamac',\n  'America/Inuvik',\n  'America/Iqaluit',\n  'America/Jamaica',\n  'America/Juneau',\n  'America/Kentucky/Louisville',\n  'America/Kentucky/Monticello',\n  'America/Kralendijk',\n  'America/La_Paz',\n  'America/Lima',\n  'America/Los_Angeles',\n  'America/Lower_Princes',\n  'America/Maceio',\n  'America/Managua',\n  'America/Manaus',\n  'America/Marigot',\n  'America/Martinique',\n  'America/Matamoros',\n  'America/Mazatlan',\n  'America/Menominee',\n  'America/Merida',\n  'America/Metlakatla',\n  'America/Mexico_City',\n  'America/Miquelon',\n  'America/Moncton',\n  'America/Monterrey',\n  'America/Montevideo',\n  'America/Montserrat',\n  'America/Nassau',\n  'America/New_York',\n  'America/Nipigon',\n  'America/Nome',\n  'America/Noronha',\n  'America/North_Dakota/Beulah',\n  'America/North_Dakota/Center',\n  'America/North_Dakota/New_Salem',\n  'America/Nuuk',\n  'America/Ojinaga',\n  'America/Panama',\n  'America/Pangnirtung',\n  'America/Paramaribo',\n  'America/Phoenix',\n  'America/Port-au-Prince',\n  'America/Port_of_Spain',\n  'America/Porto_Velho',\n  'America/Puerto_Rico',\n  'America/Punta_Arenas',\n  'America/Rainy_River',\n  'America/Rankin_Inlet',\n  'America/Recife',\n  'America/Regina',\n  'America/Resolute',\n  'America/Rio_Branco',\n  'America/Santarem',\n  'America/Santiago',\n  'America/Santo_Domingo',\n  'America/Sao_Paulo',\n  'America/Scoresbysund',\n  'America/Sitka',\n  'America/St_Barthelemy',\n  'America/St_Johns',\n  'America/St_Kitts',\n  'America/St_Lucia',\n  'America/St_Thomas',\n  'America/St_Vincent',\n  'America/Swift_Current',\n  'America/Tegucigalpa',\n  'America/Thule',\n  'America/Thunder_Bay',\n  'America/Tijuana',\n  'America/Toronto',\n  'America/Tortola',\n  'America/Vancouver',\n  'America/Whitehorse',\n  'America/Winnipeg',\n  'America/Yakutat',\n  'America/Yellowknife',\n  'Antarctica/Casey',\n  'Antarctica/Davis',\n  'Antarctica/DumontDUrville',\n  'Antarctica/Macquarie',\n  'Antarctica/Mawson',\n  'Antarctica/McMurdo',\n  'Antarctica/Palmer',\n  'Antarctica/Rothera',\n  'Antarctica/Syowa',\n  'Antarctica/Troll',\n  'Antarctica/Vostok',\n  'Arctic/Longyearbyen',\n  'Asia/Aden',\n  'Asia/Almaty',\n  'Asia/Amman',\n  'Asia/Anadyr',\n  'Asia/Aqtau',\n  'Asia/Aqtobe',\n  'Asia/Ashgabat',\n  'Asia/Atyrau',\n  'Asia/Baghdad',\n  'Asia/Bahrain',\n  'Asia/Baku',\n  'Asia/Bangkok',\n  'Asia/Barnaul',\n  'Asia/Beirut',\n  'Asia/Bishkek',\n  'Asia/Brunei',\n  'Asia/Chita',\n  'Asia/Choibalsan',\n  'Asia/Colombo',\n  'Asia/Damascus',\n  'Asia/Dhaka',\n  'Asia/Dili',\n  'Asia/Dubai',\n  'Asia/Dushanbe',\n  'Asia/Famagusta',\n  'Asia/Gaza',\n  'Asia/Hebron',\n  'Asia/Ho_Chi_Minh',\n  'Asia/Hong_Kong',\n  'Asia/Hovd',\n  'Asia/Irkutsk',\n  'Asia/Istanbul',\n  'Asia/Jakarta',\n  'Asia/Jayapura',\n  'Asia/Jerusalem',\n  'Asia/Kabul',\n  'Asia/Kamchatka',\n  'Asia/Karachi',\n  'Asia/Kathmandu',\n  'Asia/Khandyga',\n  'Asia/Kolkata',\n  'Asia/Krasnoyarsk',\n  'Asia/Kuala_Lumpur',\n  'Asia/Kuching',\n  'Asia/Kuwait',\n  'Asia/Macau',\n  'Asia/Magadan',\n  'Asia/Makassar',\n  'Asia/Manila',\n  'Asia/Muscat',\n  'Asia/Nicosia',\n  'Asia/Novokuznetsk',\n  'Asia/Novosibirsk',\n  'Asia/Omsk',\n  'Asia/Oral',\n  'Asia/Phnom_Penh',\n  'Asia/Pontianak',\n  'Asia/Pyongyang',\n  'Asia/Qatar',\n  'Asia/Qostanay',\n  'Asia/Qyzylorda',\n  'Asia/Rangoon',\n  'Asia/Riyadh',\n  'Asia/Sakhalin',\n  'Asia/Samarkand',\n  'Asia/Seoul',\n  'Asia/Shanghai',\n  'Asia/Singapore',\n  'Asia/Srednekolymsk',\n  'Asia/Taipei',\n  'Asia/Tashkent',\n  'Asia/Tbilisi',\n  'Asia/Tehran',\n  'Asia/Thimphu',\n  'Asia/Tokyo',\n  'Asia/Tomsk',\n  'Asia/Ulaanbaatar',\n  'Asia/Urumqi',\n  'Asia/Ust-Nera',\n  'Asia/Vientiane',\n  'Asia/Vladivostok',\n  'Asia/Yakutsk',\n  'Asia/Yangon',\n  'Asia/Yekaterinburg',\n  'Asia/Yerevan',\n  'Atlantic/Azores',\n  'Atlantic/Bermuda',\n  'Atlantic/Canary',\n  'Atlantic/Cape_Verde',\n  'Atlantic/Faroe',\n  'Atlantic/Madeira',\n  'Atlantic/Reykjavik',\n  'Atlantic/South_Georgia',\n  'Atlantic/St_Helena',\n  'Atlantic/Stanley',\n  'Australia/Adelaide',\n  'Australia/Brisbane',\n  'Australia/Broken_Hill',\n  'Australia/Currie',\n  'Australia/Darwin',\n  'Australia/Eucla',\n  'Australia/Hobart',\n  'Australia/Lindeman',\n  'Australia/Lord_Howe',\n  'Australia/Melbourne',\n  'Australia/Perth',\n  'Australia/Sydney',\n  'Europe/Amsterdam',\n  'Europe/Andorra',\n  'Europe/Astrakhan',\n  'Europe/Athens',\n  'Europe/Belgrade',\n  'Europe/Berlin',\n  'Europe/Bratislava',\n  'Europe/Brussels',\n  'Europe/Bucharest',\n  'Europe/Budapest',\n  'Europe/Busingen',\n  'Europe/Chisinau',\n  'Europe/Copenhagen',\n  'Europe/Dublin',\n  'Europe/Gibraltar',\n  'Europe/Guernsey',\n  'Europe/Helsinki',\n  'Europe/Isle_of_Man',\n  'Europe/Istanbul',\n  'Europe/Jersey',\n  'Europe/Kaliningrad',\n  'Europe/Kiev',\n  'Europe/Kirov',\n  'Europe/Lisbon',\n  'Europe/Ljubljana',\n  'Europe/London',\n  'Europe/Luxembourg',\n  'Europe/Madrid',\n  'Europe/Malta',\n  'Europe/Mariehamn',\n  'Europe/Minsk',\n  'Europe/Monaco',\n  'Europe/Moscow',\n  'Europe/Nicosia',\n  'Europe/Oslo',\n  'Europe/Paris',\n  'Europe/Podgorica',\n  'Europe/Prague',\n  'Europe/Riga',\n  'Europe/Rome',\n  'Europe/Samara',\n  'Europe/San_Marino',\n  'Europe/Sarajevo',\n  'Europe/Saratov',\n  'Europe/Simferopol',\n  'Europe/Skopje',\n  'Europe/Sofia',\n  'Europe/Stockholm',\n  'Europe/Tallinn',\n  'Europe/Tirane',\n  'Europe/Ulyanovsk',\n  'Europe/Uzhgorod',\n  'Europe/Vaduz',\n  'Europe/Vatican',\n  'Europe/Vienna',\n  'Europe/Vilnius',\n  'Europe/Volgograd',\n  'Europe/Warsaw',\n  'Europe/Zagreb',\n  'Europe/Zaporozhye',\n  'Europe/Zurich',\n  'Indian/Antananarivo',\n  'Indian/Chagos',\n  'Indian/Christmas',\n  'Indian/Cocos',\n  'Indian/Comoro',\n  'Indian/Kerguelen',\n  'Indian/Mahe',\n  'Indian/Maldives',\n  'Indian/Mauritius',\n  'Indian/Mayotte',\n  'Indian/Reunion',\n  'Pacific/Apia',\n  'Pacific/Auckland',\n  'Pacific/Bougainville',\n  'Pacific/Chatham',\n  'Pacific/Chuuk',\n  'Pacific/Easter',\n  'Pacific/Efate',\n  'Pacific/Enderbury',\n  'Pacific/Fakaofo',\n  'Pacific/Fiji',\n  'Pacific/Funafuti',\n  'Pacific/Galapagos',\n  'Pacific/Gambier',\n  'Pacific/Guadalcanal',\n  'Pacific/Guam',\n  'Pacific/Honolulu',\n  'Pacific/Kanton',\n  'Pacific/Kiritimati',\n  'Pacific/Kosrae',\n  'Pacific/Kwajalein',\n  'Pacific/Majuro',\n  'Pacific/Marquesas',\n  'Pacific/Midway',\n  'Pacific/Nauru',\n  'Pacific/Niue',\n  'Pacific/Norfolk',\n  'Pacific/Noumea',\n  'Pacific/Pago_Pago',\n  'Pacific/Palau',\n  'Pacific/Pitcairn',\n  'Pacific/Pohnpei',\n  'Pacific/Port_Moresby',\n  'Pacific/Rarotonga',\n  'Pacific/Saipan',\n  'Pacific/Tahiti',\n  'Pacific/Tarawa',\n  'Pacific/Tongatapu',\n  'Pacific/Wake',\n  'Pacific/Wallis',\n] as const;\n\nexport const timeZoneStringSchema = z\n  .string()\n  .refine(\n    (value) => {\n      try {\n        dayjs().tz(value);\n        return true;\n      } catch (error) {\n        return false;\n      }\n    },\n    { message: 'Invalid timezone, please use iso 8601 format' }\n  )\n  .meta({\n    type: 'string',\n    description: 'The time zone that should be used to format dates',\n  });\n"
  },
  {
    "path": "packages/core/src/models/field/index.ts",
    "content": "export * from './derivate';\nexport * from './constant';\nexport * from './conditional.constants';\nexport * from './field';\nexport * from './field.type';\nexport * from './field-visitor.interface';\nexport * from './colors';\nexport * from './color-utils';\nexport * from './formatting';\nexport * from './show-as';\nexport * from './field.schema';\nexport * from './field-validation';\nexport * from './cell-value-validation';\nexport * from './ai-config';\nexport * from './options.schema';\nexport * from './button-utils';\nexport * from './zod-error';\nexport * from './field.util';\nexport * from './utils/get-db-field-type';\nexport * from './field-unions.schema';\nexport * from './lookup-options-base.schema';\n"
  },
  {
    "path": "packages/core/src/models/field/lookup-options-base.schema.spec.ts",
    "content": "import { lookupOptionsRoSchema, lookupOptionsVoSchema } from './lookup-options-base.schema';\n\ndescribe('lookupOptionsRoSchema validation', () => {\n  describe('Valid lookup options', () => {\n    it('should pass with correct link lookup options', () => {\n      const validLinkLookup = {\n        foreignTableId: 'tblXXX',\n        lookupFieldId: 'fldYYY',\n        linkFieldId: 'fldZZZ',\n      };\n\n      const result = lookupOptionsRoSchema.safeParse(validLinkLookup);\n      expect(result.success).toBe(true);\n    });\n  });\n\n  describe('Common mistakes detection', () => {\n    it('should provide helpful error when expression is in lookupOptions instead of options', () => {\n      const wrongStructure = {\n        linkFieldId: 'fldXXX',\n        lookupFieldId: 'fldYYY',\n        foreignTableId: 'tblZZZ',\n        expression: 'sum({values})', // Wrong place! Should be in field options\n      };\n\n      const result = lookupOptionsRoSchema.safeParse(wrongStructure);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        const errorMessage = result.error.issues[0].message;\n\n        // Should provide clear guidance about rollup field configuration\n        expect(errorMessage).toContain('Rollup field configuration error');\n        expect(errorMessage).toContain('expression');\n        expect(errorMessage).toContain('options');\n        expect(errorMessage).toContain('lookupOptions');\n\n        // Should NOT be confusing union error starting with \"Invalid\"\n        expect(errorMessage).not.toMatch(/^Invalid/);\n      }\n    });\n\n    it('should reject unrecognized keys with helpful error message', () => {\n      const invalidKeys = {\n        foreignTableId: 'tblXXX',\n        lookupFieldId: 'fldYYY',\n        linkFieldId: 'fldZZZ',\n        unknownKey: 'value', // Unrecognized key\n      };\n\n      const result = lookupOptionsRoSchema.safeParse(invalidKeys);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // With custom error handler, we get 1 issue with helpful message\n        expect(result.error.issues).toHaveLength(1);\n\n        const errorMessage = result.error.issues[0].message;\n        // Should provide clear error about link lookup and mention unrecognized key\n        expect(errorMessage).toContain('Link lookup error');\n        expect(errorMessage).toContain('Unrecognized key');\n      }\n    });\n\n    it('should provide clear error for missing required fields', () => {\n      const missingFields = {\n        linkFieldId: 'fldXXX',\n        // Missing foreignTableId and lookupFieldId\n      };\n\n      const result = lookupOptionsRoSchema.safeParse(missingFields);\n      expect(result.success).toBe(false);\n\n      if (!result.success) {\n        // Should have exactly 1 issue from custom error handler\n        expect(result.error.issues).toHaveLength(1);\n\n        const errorMessage = result.error.issues[0].message;\n        // Should provide clear context about link lookup\n        expect(errorMessage).toContain('Link lookup error');\n        // Should indicate the type of problem (invalid/missing field)\n        expect(errorMessage).toContain('Invalid input');\n      }\n    });\n  });\n});\n\ndescribe('lookupOptionsVoSchema validation', () => {\n  it('should pass with correct link lookup options', () => {\n    const validLinkLookup = {\n      foreignTableId: 'tblXXX',\n      lookupFieldId: 'fldYYY',\n      linkFieldId: 'fldZZZ',\n      relationship: 'manyOne',\n      fkHostTableName: 'table1',\n      selfKeyName: 'key1',\n      foreignKeyName: 'key2',\n      filterByViewId: 'viwActive',\n      visibleFieldIds: ['fldYYY'],\n    };\n\n    const result = lookupOptionsVoSchema.safeParse(validLinkLookup);\n    expect(result.success).toBe(true);\n  });\n\n  it('should keep link display config in lookup field VO payloads', () => {\n    const validLinkLookup = {\n      foreignTableId: 'tblXXX',\n      lookupFieldId: 'fldYYY',\n      linkFieldId: 'fldZZZ',\n      relationship: 'manyOne',\n      fkHostTableName: 'table1',\n      selfKeyName: 'key1',\n      foreignKeyName: 'key2',\n      filterByViewId: 'viwActive',\n      visibleFieldIds: ['fldYYY', 'fldZZZ'],\n    };\n\n    const result = lookupOptionsVoSchema.safeParse(validLinkLookup);\n    expect(result.success).toBe(true);\n    if (!result.success) {\n      return;\n    }\n\n    expect('filterByViewId' in result.data).toBe(true);\n    expect('visibleFieldIds' in result.data).toBe(true);\n    if (!('filterByViewId' in result.data) || !('visibleFieldIds' in result.data)) {\n      throw new Error('Expected link lookup options');\n    }\n\n    expect(result.data.filterByViewId).toBe('viwActive');\n    expect(result.data.visibleFieldIds).toEqual(['fldYYY', 'fldZZZ']);\n  });\n\n  it('should provide helpful error when expression is misplaced', () => {\n    const wrongStructure = {\n      linkFieldId: 'fldXXX',\n      lookupFieldId: 'fldYYY',\n      foreignTableId: 'tblZZZ',\n      relationship: 'manyOne',\n      fkHostTableName: 'table1',\n      selfKeyName: 'key1',\n      foreignKeyName: 'key2',\n      expression: 'sum({values})', // Wrong place!\n    };\n\n    const result = lookupOptionsVoSchema.safeParse(wrongStructure);\n    expect(result.success).toBe(false);\n\n    if (!result.success) {\n      const errorMessage = result.error.issues[0].message;\n      expect(errorMessage).toContain('Rollup field configuration error');\n      expect(errorMessage).toContain('expression');\n      expect(errorMessage).toContain('options');\n    }\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/lookup-options-base.schema.ts",
    "content": "import { z } from '../../zod';\nimport { filterSchema } from '../view/filter';\nimport { SortFunc } from '../view/sort';\nimport { CONDITIONAL_QUERY_MAX_LIMIT } from './conditional.constants';\nimport { Relationship } from './constant';\n\nconst lookupLinkOptionsVoSchema = z.object({\n  baseId: z.string().optional().meta({\n    description:\n      'the base id of the table that this field is linked to, only required for cross base link',\n  }),\n  relationship: z.enum(Relationship).meta({\n    description: 'describe the relationship from this table to the foreign table',\n  }),\n  foreignTableId: z.string().meta({\n    description: 'the table this field is linked to',\n  }),\n  lookupFieldId: z.string().meta({\n    description: 'the field in the foreign table that will be displayed as the current field',\n  }),\n  fkHostTableName: z.string().meta({\n    description:\n      'the table name for storing keys, in many-to-many relationships, keys are stored in a separate intermediate table; in other relationships, keys are stored on one side as needed',\n  }),\n  selfKeyName: z.string().meta({\n    description: 'the name of the field that stores the current table primary key',\n  }),\n  foreignKeyName: z.string().meta({\n    description: 'The name of the field that stores the foreign table primary key',\n  }),\n  filterByViewId: z.string().nullable().optional().meta({\n    description: 'Optional foreign view used to filter lookup candidates.',\n  }),\n  visibleFieldIds: z.array(z.string()).nullable().optional().meta({\n    description: 'Optional foreign fields shown when presenting lookup-linked records.',\n  }),\n  filter: filterSchema.optional(),\n  linkFieldId: z.string().meta({\n    description: 'The id of Linked record field to use for lookup',\n  }),\n});\n\nconst lookupLinkOptionsRoSchema = lookupLinkOptionsVoSchema.pick({\n  foreignTableId: true,\n  lookupFieldId: true,\n  linkFieldId: true,\n  filter: true,\n});\n\nconst lookupConditionalOptionsVoSchema = z.object({\n  baseId: z.string().optional().meta({\n    description:\n      'the base id of the table that this field is linked to, only required for cross base link',\n  }),\n  foreignTableId: z.string().meta({\n    description: 'the table this field is linked to',\n  }),\n  lookupFieldId: z.string().meta({\n    description: 'the field in the foreign table that will be displayed as the current field',\n  }),\n  filter: filterSchema.meta({\n    description: 'Filter to apply when resolving conditional lookup values.',\n  }),\n  sort: z\n    .object({\n      fieldId: z.string().meta({\n        description: 'The field in the foreign table used to order lookup records.',\n      }),\n      order: z\n        .enum(SortFunc)\n        .meta({ description: 'Ordering direction to apply to the sorted field.' }),\n    })\n    .optional()\n    .meta({\n      description: 'Optional sort configuration applied before aggregating lookup values.',\n    }),\n  limit: z.number().int().positive().max(CONDITIONAL_QUERY_MAX_LIMIT).optional().meta({\n    description: 'Maximum number of matching records to include in the lookup result.',\n  }),\n});\n\nconst lookupConditionalOptionsRoSchema = lookupConditionalOptionsVoSchema;\n\n// Helper function for lookup options error handling\nfunction getLookupOptionsError(input: Record<string, unknown>) {\n  // Check for common mistake: expression in lookupOptions\n  if ('expression' in input) {\n    return 'Rollup field configuration error: \"expression\" (e.g., \"sum({values})\") should be in \"options\", not \"lookupOptions\". lookupOptions should contain: { linkFieldId, lookupFieldId, foreignTableId } for link lookup, or { foreignTableId, lookupFieldId, filter } for conditional lookup';\n  }\n\n  // Determine which schema to use based on discriminator\n  // Link lookup has linkFieldId, conditional lookup has filter\n  const hasLinkFieldId = 'linkFieldId' in input;\n  const hasFilter = 'filter' in input;\n\n  let targetSchema;\n  let schemaType;\n\n  if (hasLinkFieldId) {\n    targetSchema = lookupLinkOptionsVoSchema.strict();\n    schemaType = 'Link lookup';\n  } else if (hasFilter) {\n    targetSchema = lookupConditionalOptionsVoSchema.strict();\n    schemaType = 'Conditional lookup';\n  } else {\n    return 'Lookup options must be either link lookup (with linkFieldId) or conditional lookup (with filter)';\n  }\n\n  // Parse with specific schema to get accurate error\n  const result = targetSchema.safeParse(input);\n  if (!result.success) {\n    return `${schemaType} error: ${result.error.issues[0].message}`;\n  }\n\n  return undefined;\n}\n\nexport const lookupOptionsVoSchema = z.union(\n  [lookupLinkOptionsVoSchema.strict(), lookupConditionalOptionsVoSchema.strict()],\n  {\n    error: (issue) => {\n      if (issue.input && typeof issue.input === 'object') {\n        return getLookupOptionsError(issue.input as Record<string, unknown>);\n      }\n      return undefined;\n    },\n  }\n);\n\nexport const lookupOptionsRoSchema = z.union(\n  [lookupLinkOptionsRoSchema.strict(), lookupConditionalOptionsRoSchema.strict()],\n  {\n    error: (issue) => {\n      if (issue.input && typeof issue.input === 'object') {\n        const input = issue.input as Record<string, unknown>;\n\n        // Check for common mistake first\n        if ('expression' in input) {\n          return 'Rollup field configuration error: \"expression\" (e.g., \"sum({values})\") should be in \"options\", not \"lookupOptions\". lookupOptions should contain: { linkFieldId, lookupFieldId, foreignTableId }';\n        }\n\n        // Determine schema based on discriminator\n        const hasLinkFieldId = 'linkFieldId' in input;\n        const hasFilter = 'filter' in input;\n\n        let targetSchema;\n        let schemaType;\n\n        if (hasLinkFieldId) {\n          targetSchema = lookupLinkOptionsRoSchema.strict();\n          schemaType = 'Link lookup';\n        } else if (hasFilter) {\n          targetSchema = lookupConditionalOptionsRoSchema.strict();\n          schemaType = 'Conditional lookup';\n        } else {\n          return 'Lookup options must be either link lookup (with linkFieldId) or conditional lookup (with filter)';\n        }\n\n        // Parse with specific schema\n        const result = targetSchema.safeParse(input);\n        if (!result.success) {\n          return `${schemaType} error: ${result.error.issues[0].message}`;\n        }\n      }\n      return undefined;\n    },\n  }\n);\n\nexport type ILookupOptionsVo = z.infer<typeof lookupOptionsVoSchema>;\nexport type ILookupOptionsRo = z.infer<typeof lookupOptionsRoSchema>;\nexport type ILookupLinkOptions = z.infer<typeof lookupLinkOptionsRoSchema>;\nexport type ILookupConditionalOptions = z.infer<typeof lookupConditionalOptionsRoSchema>;\nexport type IConditionalLookupOptions = ILookupConditionalOptions;\nexport type ILookupLinkOptionsVo = z.infer<typeof lookupLinkOptionsVoSchema>;\nexport type ILookupConditionalOptionsVo = z.infer<typeof lookupConditionalOptionsVoSchema>;\n\nexport const isLinkLookupOptions = <T extends ILookupOptionsRo | ILookupOptionsVo | undefined>(\n  options: T\n): options is Extract<T, ILookupLinkOptions | ILookupLinkOptionsVo> => {\n  return Boolean(options && typeof options === 'object' && 'linkFieldId' in options);\n};\n\nexport const isConditionalLookupOptions = (\n  options: ILookupOptionsRo | ILookupOptionsVo | undefined\n): options is ILookupConditionalOptions | ILookupConditionalOptionsVo => {\n  return Boolean(options && typeof options === 'object' && !('linkFieldId' in options));\n};\n"
  },
  {
    "path": "packages/core/src/models/field/options.schema.ts",
    "content": "import { assertNever } from '../../asserts';\nimport { FieldType } from './constant';\nimport { selectFieldOptionsSchema } from './derivate/abstract/select-option.schema';\nimport { attachmentFieldOptionsSchema } from './derivate/attachment-option.schema';\nimport { autoNumberFieldOptionsSchema } from './derivate/auto-number-option.schema';\nimport { buttonFieldOptionsSchema } from './derivate/button-option.schema';\nimport { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema';\nimport { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema';\nimport { createdByFieldOptionsSchema } from './derivate/created-by-option.schema';\nimport { createdTimeFieldOptionsSchema } from './derivate/created-time-option.schema';\nimport { dateFieldOptionsSchema } from './derivate/date-option.schema';\nimport { formulaFieldOptionsSchema } from './derivate/formula-option.schema';\nimport { lastModifiedByFieldOptionsSchema } from './derivate/last-modified-by-option.schema';\nimport { lastModifiedTimeFieldOptionsSchema } from './derivate/last-modified-time-option.schema';\nimport { linkFieldOptionsSchema } from './derivate/link-option.schema';\nimport { longTextFieldOptionsSchema } from './derivate/long-text-option.schema';\nimport { numberFieldOptionsSchema } from './derivate/number-option.schema';\nimport { ratingFieldOptionsSchema } from './derivate/rating-option.schema';\nimport { rollupFieldOptionsSchema } from './derivate/rollup-option.schema';\nimport { singlelineTextFieldOptionsSchema } from './derivate/single-line-text-option.schema';\nimport { userFieldOptionsSchema } from './derivate/user-option.schema';\n\nexport function safeParseOptions(fieldType: FieldType, value: unknown) {\n  switch (fieldType) {\n    case FieldType.SingleLineText:\n      return singlelineTextFieldOptionsSchema.safeParse(value);\n    case FieldType.LongText:\n      return longTextFieldOptionsSchema.safeParse(value);\n    case FieldType.Number:\n      return numberFieldOptionsSchema.safeParse(value);\n    case FieldType.SingleSelect:\n      return selectFieldOptionsSchema.safeParse(value);\n    case FieldType.MultipleSelect:\n      return selectFieldOptionsSchema.safeParse(value);\n    case FieldType.Date:\n      return dateFieldOptionsSchema.safeParse(value);\n    case FieldType.Attachment:\n      return attachmentFieldOptionsSchema.safeParse(value);\n    case FieldType.Link:\n      return linkFieldOptionsSchema.safeParse(value);\n    case FieldType.User:\n      return userFieldOptionsSchema.safeParse(value);\n    case FieldType.Checkbox:\n      return checkboxFieldOptionsSchema.safeParse(value);\n    case FieldType.Rating:\n      return ratingFieldOptionsSchema.safeParse(value);\n    case FieldType.Formula:\n      return formulaFieldOptionsSchema.safeParse(value);\n    case FieldType.AutoNumber:\n      return autoNumberFieldOptionsSchema.safeParse(value);\n    case FieldType.CreatedTime:\n      return createdTimeFieldOptionsSchema.safeParse(value);\n    case FieldType.LastModifiedTime:\n      return lastModifiedTimeFieldOptionsSchema.safeParse(value);\n    case FieldType.CreatedBy:\n      return createdByFieldOptionsSchema.safeParse(value);\n    case FieldType.LastModifiedBy:\n      return lastModifiedByFieldOptionsSchema.safeParse(value);\n    case FieldType.Rollup:\n      return rollupFieldOptionsSchema.safeParse(value);\n    case FieldType.ConditionalRollup:\n      return conditionalRollupFieldOptionsSchema.safeParse(value);\n    case FieldType.Button:\n      return buttonFieldOptionsSchema.safeParse(value);\n    default:\n      assertNever(fieldType);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/field/show-as/index.ts",
    "content": "import { z } from '../../../zod';\nimport { CellValueType, FieldType } from '../constant';\nimport { longTextShowAsSchema } from '../derivate/long-text-option.schema';\nimport { multiNumberShowAsSchema, numberShowAsSchema, singleNumberShowAsSchema } from './number';\nimport { singleLineTextShowAsSchema } from './text';\n\nexport * from './number';\nexport * from './text';\n\nexport const getShowAsSchema = (\n  cellValueType: CellValueType,\n  isMultipleCellValue: boolean | undefined,\n  fieldType?: FieldType\n) => {\n  if (cellValueType === CellValueType.Number) {\n    return isMultipleCellValue\n      ? multiNumberShowAsSchema.optional()\n      : singleNumberShowAsSchema.optional();\n  }\n\n  if (cellValueType === CellValueType.String) {\n    if (fieldType === FieldType.LongText) {\n      return longTextShowAsSchema.optional();\n    }\n    return singleLineTextShowAsSchema.optional();\n  }\n\n  return z.undefined().meta({\n    description: 'Only string or number cell value type support show as',\n  });\n};\n\nexport const unionShowAsSchema = z\n  .union([singleLineTextShowAsSchema.strict(), numberShowAsSchema])\n  .meta({\n    description:\n      'According to the results of expression parsing to determine different visual effects, where strings, numbers will provide customized \"show as\"',\n  });\n\nexport type IUnionShowAs = z.infer<typeof unionShowAsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/show-as/number.ts",
    "content": "import { z } from 'zod';\nimport { Colors } from '../colors';\n\nexport enum SingleNumberDisplayType {\n  Bar = 'bar',\n  Ring = 'ring',\n}\n\nexport enum MultiNumberDisplayType {\n  Bar = 'bar',\n  Line = 'line',\n}\n\nexport const singleNumberShowAsSchema = z\n  .object({\n    type: z.enum(SingleNumberDisplayType).meta({\n      description: 'can display as bar or ring in number field with single cellValue value',\n    }),\n    color: z.enum(Colors),\n    showValue: z.boolean().meta({\n      description: 'whether to displays the specific value on the graph',\n    }),\n    maxValue: z.number().meta({\n      description:\n        'the value that represents a 100% maximum value, it does not represent a hard limit on the value',\n    }),\n  })\n  .describe('Only be used in number related field with isMultipleCellValue is not true');\n\nexport const multiNumberShowAsSchema = z\n  .object({\n    type: z.enum(MultiNumberDisplayType).meta({\n      description: 'can display as bar or line in number field with multiple cellValue value',\n    }),\n    color: z.enum(Colors),\n  })\n  .describe('Only be used in number related field with isMultipleCellValue is true');\n\nexport type ISingleNumberShowAs = z.infer<typeof singleNumberShowAsSchema>;\n\nexport type IMultiNumberShowAs = z.infer<typeof multiNumberShowAsSchema>;\n\nexport const numberShowAsSchema = z\n  .union([singleNumberShowAsSchema.strict(), multiNumberShowAsSchema.strict()])\n  .describe(\n    'Only be used in number field (number field or formula / rollup field with cellValueType equals Number'\n  );\n\nexport type INumberShowAs = z.infer<typeof numberShowAsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/show-as/text.ts",
    "content": "import { z } from 'zod';\n\nexport enum SingleLineTextDisplayType {\n  Url = 'url',\n  Email = 'email',\n  Phone = 'phone',\n}\n\nexport const singleLineTextShowAsSchema = z\n  .object({\n    type: z.enum(SingleLineTextDisplayType).meta({\n      description:\n        'can display as url, email or phone in string field with a button to perform the corresponding action, start a phone call, send an email, or open a link in a new tab',\n    }),\n  })\n  .describe(\n    'Only be used in single line text field or formula / rollup field with cellValueType equals String and isMultipleCellValue is not true'\n  );\n\nexport type ISingleLineTextShowAs = z.infer<typeof singleLineTextShowAsSchema>;\n"
  },
  {
    "path": "packages/core/src/models/field/utils/get-db-field-type.ts",
    "content": "import { match } from 'ts-pattern';\nimport { FieldType, CellValueType, DbFieldType } from '../constant';\n\n/**\n * Get database field type based on field type, cell value type, and multiplicity\n * This is a pure function that doesn't depend on any services\n */\nexport function getDbFieldType(\n  fieldType: FieldType,\n  cellValueType: CellValueType,\n  isMultipleCellValue?: boolean\n): DbFieldType {\n  // Multiple cell values are always stored as JSON\n  if (isMultipleCellValue) {\n    return DbFieldType.Json;\n  }\n\n  return match(fieldType)\n    .with(\n      FieldType.Link,\n      FieldType.User,\n      FieldType.Attachment,\n      FieldType.Button,\n      FieldType.CreatedBy,\n      FieldType.LastModifiedBy,\n      () => DbFieldType.Json\n    )\n    .with(FieldType.AutoNumber, () => DbFieldType.Integer)\n    .otherwise(() =>\n      match(cellValueType)\n        .with(CellValueType.Number, () => DbFieldType.Real)\n        .with(CellValueType.DateTime, () => DbFieldType.DateTime)\n        .with(CellValueType.Boolean, () => DbFieldType.Boolean)\n        .with(CellValueType.String, () => DbFieldType.Text)\n        .exhaustive()\n    );\n}\n"
  },
  {
    "path": "packages/core/src/models/field/zod-error.spec.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { FieldType } from './constant';\nimport type { IConditionalRollupFieldOptions } from './derivate';\nimport type { ILookupOptionsRo } from './lookup-options-base.schema';\nimport { validateFieldOptions } from './zod-error';\n\ndescribe('validateFieldOptions - conditional rollup filter', () => {\n  const lookupOptions: ILookupOptionsRo = {\n    foreignTableId: 'foreign-table',\n    lookupFieldId: 'lookup-field',\n    linkFieldId: 'link-field',\n  };\n\n  const baseOptions: Partial<IConditionalRollupFieldOptions> = {\n    expression: 'count({values})',\n  };\n\n  it('should require filter for conditional rollup options', () => {\n    const result = validateFieldOptions({\n      type: FieldType.ConditionalRollup,\n      options: baseOptions,\n      lookupOptions,\n    });\n\n    expect(result).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({ i18nKey: 'sdk:editor.conditionalRollup.filterRequired' }),\n      ])\n    );\n  });\n\n  it('should reject empty filter definitions', () => {\n    const result = validateFieldOptions({\n      type: FieldType.ConditionalRollup,\n      options: {\n        ...baseOptions,\n        filter: {\n          conjunction: 'and',\n          filterSet: [],\n        },\n      },\n      lookupOptions,\n    });\n\n    expect(result).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({ i18nKey: 'sdk:editor.conditionalRollup.filterRequired' }),\n      ])\n    );\n  });\n\n  it('should accept options when filter contains at least one condition', () => {\n    const result = validateFieldOptions({\n      type: FieldType.ConditionalRollup,\n      options: {\n        ...baseOptions,\n        filter: {\n          conjunction: 'and',\n          filterSet: [\n            {\n              fieldId: 'foreign-field',\n              operator: 'is',\n              value: 'value',\n            },\n          ],\n        },\n      },\n      lookupOptions,\n    });\n\n    expect(result).not.toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({ i18nKey: 'sdk:editor.conditionalRollup.filterRequired' }),\n      ])\n    );\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/field/zod-error.ts",
    "content": "/* eslint-disable sonarjs/no-duplicate-string */\nimport { isString } from 'lodash';\nimport { fromZodError } from 'zod-validation-error';\nimport { extractFieldIdsFromFilter } from '../view/filter/filter';\nimport { FieldAIActionType, getAiConfigSchema, type IFieldAIConfig } from './ai-config';\nimport { FieldType } from './constant';\nimport type {\n  IConditionalRollupFieldOptions,\n  IFormulaFieldOptions,\n  ILinkFieldOptions,\n  IRollupFieldOptions,\n  ISelectFieldOptions,\n} from './derivate';\nimport type { IFieldMetaVo, IFieldOptionsRo } from './field-unions.schema';\nimport { getOptionsSchema } from './field.schema';\nimport { isLinkLookupOptions, type ILookupOptionsRo } from './lookup-options-base.schema';\n\ninterface IFieldValidateData {\n  message: string;\n  path?: string[];\n  i18nKey: string;\n  context?: Record<string, string>;\n}\n\ninterface IValidateFieldOptionProps {\n  type: FieldType;\n  isLookup?: boolean;\n  isConditionalLookup?: boolean;\n  options?: IFieldOptionsRo | null;\n  aiConfig?: IFieldAIConfig | null;\n  lookupOptions?: ILookupOptionsRo;\n  meta?: IFieldMetaVo;\n}\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nconst validateLookupOptions = (data: IValidateFieldOptionProps) => {\n  const { isLookup, isConditionalLookup, lookupOptions, type } = data;\n  const res: IFieldValidateData[] = [];\n\n  const isRollup = type === FieldType.Rollup;\n  const needsStandardLookupOptions = (isLookup && !isConditionalLookup) || isRollup;\n  const needsConditionalLookupOptions = Boolean(isConditionalLookup);\n  const allowsLookupOptions = needsStandardLookupOptions || needsConditionalLookupOptions;\n\n  if (lookupOptions && !allowsLookupOptions) {\n    res.push({\n      message:\n        'lookupOptions is not allowed when isLookup attribute is true or field type is rollup.',\n      i18nKey: 'sdk:editor.lookup.lookupOptionsNotAllowed',\n    });\n  }\n\n  if (needsStandardLookupOptions && !lookupOptions) {\n    res.push({\n      message: 'lookupOptions is required when isLookup attribute is true or field type is rollup.',\n      i18nKey: 'sdk:editor.lookup.lookupOptionsRequired',\n    });\n  }\n\n  if (needsConditionalLookupOptions && !lookupOptions) {\n    res.push({\n      message: 'lookupOptions is required when lookup is marked as conditional.',\n      i18nKey: 'sdk:editor.lookup.lookupOptionsRequired',\n    });\n  }\n\n  if (!lookupOptions) {\n    return res;\n  }\n\n  if (needsStandardLookupOptions) {\n    if (!isLinkLookupOptions(lookupOptions)) {\n      res.push({\n        path: ['lookupOptions'],\n        message: 'linkFieldId is required when isLookup attribute is true or field type is rollup.',\n        i18nKey: 'sdk:editor.link.linkFieldIdRequired',\n      });\n    } else {\n      if (!isString(lookupOptions.foreignTableId)) {\n        res.push({\n          path: ['lookupOptions'],\n          message:\n            'foreignTableId is required when isLookup attribute is true or field type is rollup.',\n          i18nKey: 'sdk:editor.link.foreignTableIdRequired',\n        });\n      }\n\n      if (!isString(lookupOptions.linkFieldId)) {\n        res.push({\n          path: ['lookupOptions'],\n          message:\n            'linkFieldId is required when isLookup attribute is true or field type is rollup.',\n          i18nKey: 'sdk:editor.link.linkFieldIdRequired',\n        });\n      }\n\n      if (!isString(lookupOptions.lookupFieldId)) {\n        res.push({\n          path: ['lookupOptions'],\n          message:\n            'lookupFieldId is required when isLookup attribute is true or field type is rollup.',\n          i18nKey: 'sdk:editor.lookup.lookupFieldIdRequired',\n        });\n      }\n    }\n  }\n\n  if (needsConditionalLookupOptions) {\n    if (isLinkLookupOptions(lookupOptions)) {\n      res.push({\n        path: ['lookupOptions'],\n        message: 'linkFieldId is not allowed when lookup is marked as conditional.',\n        i18nKey: 'sdk:editor.lookup.lookupOptionsNotAllowed',\n      });\n    } else {\n      if (!isString(lookupOptions.foreignTableId)) {\n        res.push({\n          path: ['lookupOptions'],\n          message: 'foreignTableId is required when lookup is marked as conditional.',\n          i18nKey: 'sdk:editor.link.foreignTableIdRequired',\n        });\n      }\n\n      if (!isString(lookupOptions.lookupFieldId)) {\n        res.push({\n          path: ['lookupOptions'],\n          message: 'lookupFieldId is required when lookup is marked as conditional.',\n          i18nKey: 'sdk:editor.lookup.lookupFieldIdRequired',\n        });\n      }\n\n      const filterFieldIds = extractFieldIdsFromFilter(lookupOptions.filter);\n      if (!lookupOptions.filter || filterFieldIds.length === 0) {\n        res.push({\n          path: ['lookupOptions', 'filter'],\n          message: 'filter is required when lookup is marked as conditional.',\n          i18nKey: 'sdk:editor.conditionalLookup.filterRequired',\n        });\n      }\n    }\n  }\n\n  return res;\n};\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nconst validateOptions = (data: IValidateFieldOptionProps) => {\n  const { type, options, isLookup } = data;\n  const res: IFieldValidateData[] = [];\n\n  if (isLookup) {\n    return res;\n  }\n\n  if (type === FieldType.Link && !isString((options as ILinkFieldOptions)?.foreignTableId)) {\n    res.push({\n      path: ['options'],\n      message: 'foreignTableId is required when type is link',\n      i18nKey: 'sdk:editor.link.foreignTableIdRequired',\n    });\n  }\n\n  if (type === FieldType.Rollup && !isString((options as IRollupFieldOptions)?.expression)) {\n    res.push({\n      path: ['options'],\n      message: 'expression is required when type is rollup',\n      i18nKey: 'sdk:editor.rollup.expressionRequired',\n    });\n  }\n\n  if (type === FieldType.Formula && !isString((options as IFormulaFieldOptions)?.expression)) {\n    res.push({\n      path: ['options'],\n      message: 'expression is required when type is formula',\n      i18nKey: 'sdk:editor.formula.expressionRequired',\n    });\n  }\n\n  if (type === FieldType.ConditionalRollup) {\n    const filter = (options as IConditionalRollupFieldOptions)?.filter;\n    const hasFilterConditions = !!filter && extractFieldIdsFromFilter(filter).length > 0;\n\n    if (!hasFilterConditions) {\n      res.push({\n        path: ['options'],\n        message: 'filter is required when type is conditionalRollup',\n        i18nKey: 'sdk:editor.conditionalRollup.filterRequired',\n      });\n    }\n  }\n\n  const isSelect = type === FieldType.SingleSelect || type === FieldType.MultipleSelect;\n  if (\n    isSelect &&\n    (options as ISelectFieldOptions)?.choices?.some(\n      (choice) => !isString(choice.name) || choice.name.trim() === ''\n    )\n  ) {\n    res.push({\n      path: ['options'],\n      message: 'choice name is not empty when type is singleSelect or multipleSelect',\n      i18nKey: 'sdk:editor.select.choicesNameRequired',\n    });\n  }\n\n  const schema = getOptionsSchema(type);\n  const shouldValidateSchema = schema && options !== undefined;\n  const result = shouldValidateSchema ? schema.safeParse(options) : undefined;\n  if (result && !result.success) {\n    res.push({\n      path: ['options'],\n      message: fromZodError(result.error).message,\n      i18nKey: 'sdk:editor.error.refineOptionsError',\n      context: {\n        message: fromZodError(result.error).message,\n      },\n    });\n  }\n\n  return res;\n};\n\nconst validateAIConfig = (data: IValidateFieldOptionProps) => {\n  const { aiConfig, type } = data;\n  const res: IFieldValidateData[] = [];\n  if (!aiConfig || typeof aiConfig !== 'object') {\n    return res;\n  }\n  const hasModelKey = isString(aiConfig.modelKey);\n  if (!hasModelKey) {\n    res.push({\n      path: ['aiConfig'],\n      message: 'modelKey is required when aiConfig is not null',\n      i18nKey: 'sdk:editor.aiConfig.modelKeyRequired',\n    });\n  }\n\n  const { type: aiConfigType } = aiConfig;\n  switch (aiConfigType) {\n    case FieldAIActionType.Extraction:\n    case FieldAIActionType.Summary:\n    case FieldAIActionType.Improvement:\n    case FieldAIActionType.Classification:\n    case FieldAIActionType.Tag:\n    case FieldAIActionType.ImageGeneration:\n    case FieldAIActionType.Rating: {\n      if (!isString((aiConfig as { sourceFieldId?: string }).sourceFieldId)) {\n        res.push({\n          path: ['aiConfig'],\n          message: `sourceFieldId is required when aiConfig type is ${aiConfigType}`,\n          i18nKey: 'sdk:editor.aiConfig.sourceFieldIdRequired',\n        });\n      }\n      break;\n    }\n    case FieldAIActionType.Translation:\n      if (!isString((aiConfig as { sourceFieldId?: string }).sourceFieldId)) {\n        res.push({\n          path: ['aiConfig'],\n          message: `sourceFieldId is required when aiConfig type is ${aiConfigType}`,\n          i18nKey: 'sdk:editor.aiConfig.sourceFieldIdRequired',\n        });\n      }\n      if (!isString((aiConfig as { targetLanguage?: string }).targetLanguage)) {\n        res.push({\n          path: ['aiConfig'],\n          message: `targetLanguage is required when aiConfig type is ${aiConfigType}`,\n          i18nKey: 'sdk:editor.aiConfig.targetLanguageRequired',\n        });\n      }\n      break;\n    case FieldAIActionType.Customization:\n    case FieldAIActionType.ImageCustomization: {\n      if (!isString((aiConfig as { prompt?: string }).prompt)) {\n        res.push({\n          path: ['aiConfig'],\n          message: `prompt is required when aiConfig type is ${aiConfigType}`,\n          i18nKey: 'sdk:editor.aiConfig.promptRequired',\n        });\n      }\n      break;\n    }\n    default:\n      res.push({\n        path: ['aiConfig'],\n        message: `aiConfig type: ${aiConfigType} is not supported`,\n        i18nKey: 'sdk:editor.aiConfig.typeNotSupported',\n        context: {\n          type: aiConfigType,\n        },\n      });\n      break;\n  }\n\n  const aiConfigSchema = getAiConfigSchema(type);\n  const result = aiConfigSchema.safeParse(aiConfig);\n  if (!result.success) {\n    res.push({\n      path: ['aiConfig'],\n      message: `RefineAICofigError: ${fromZodError(result.error).message}`,\n      i18nKey: 'sdk:editor.error.refineAICofigError',\n      context: {\n        message: fromZodError(result.error).message,\n      },\n    });\n  }\n\n  return res;\n};\n\nexport const validateFieldOptions = (data: IValidateFieldOptionProps): IFieldValidateData[] => {\n  const validateLookupOptionsRes = validateLookupOptions(data);\n  const validateOptionsRes = validateOptions(data);\n  const validateAIConfigRes = validateAIConfig(data);\n  return [...validateLookupOptionsRes, ...validateOptionsRes, ...validateAIConfigRes];\n};\n"
  },
  {
    "path": "packages/core/src/models/index.ts",
    "content": "export * from './field';\nexport * from './table';\nexport * from './view';\nexport * from './interface';\nexport * from './record';\nexport * from './op';\nexport * from './aggregation';\nexport * from './notification';\nexport * from './channel';\n"
  },
  {
    "path": "packages/core/src/models/interface.ts",
    "content": "export interface ISnapshotBase<T = unknown> {\n  id: string;\n  v: number;\n  type: string | null;\n  data: T;\n  m?: unknown;\n}\n"
  },
  {
    "path": "packages/core/src/models/notification/action-trigger.schema.ts",
    "content": "import { z } from 'zod';\n\nexport const tableActionKeys = z.enum([\n  'addRecord',\n  'setRecord',\n  'deleteRecord',\n  'addField',\n  'setField',\n  'deleteField',\n  'taskProcessing',\n  'taskCompleted',\n  'taskCancelled',\n  'taskFailed',\n]);\n\nexport const viewActionKeys = z.enum([\n  'applyViewFilter',\n  'applyViewGroup',\n  'applyViewStatisticFunc',\n  'showViewField',\n]);\n\nexport const actionTriggerBufferSchema = tableActionKeys;\n\nexport type ITableActionKey = z.infer<typeof actionTriggerBufferSchema>;\n\nexport type IViewActionKey = z.infer<typeof viewActionKeys>;\n"
  },
  {
    "path": "packages/core/src/models/notification/index.ts",
    "content": "export * from './notification.schema';\nexport * from './notification.enum';\nexport * from './action-trigger.schema';\n"
  },
  {
    "path": "packages/core/src/models/notification/notification.enum.ts",
    "content": "export enum NotificationTypeEnum {\n  System = 'system',\n  CollaboratorCellTag = 'collaboratorCellTag',\n  CollaboratorMultiRowTag = 'collaboratorMultiRowTag',\n  Comment = 'comment',\n  ExportBase = 'exportBase',\n}\n\nexport enum NotificationStatesEnum {\n  Unread = 'unread',\n  Read = 'read',\n}\n"
  },
  {
    "path": "packages/core/src/models/notification/notification.schema.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../utils';\nimport { NotificationTypeEnum } from './notification.enum';\n\nexport const systemIconSchema = z.object({\n  iconUrl: z.string(),\n});\nexport type INotificationSystemIcon = z.infer<typeof systemIconSchema>;\n\nexport const userIconSchema = z.object({\n  userId: z.string(),\n  userName: z.string(),\n  userAvatarUrl: z.string().nullable().optional(),\n});\nexport type INotificationUserIcon = z.infer<typeof userIconSchema>;\n\nexport const notificationIconSchema = z.union([systemIconSchema, userIconSchema]);\nexport type INotificationIcon = z.infer<typeof notificationIconSchema>;\n\nexport const tableRecordUrlSchema = z.object({\n  baseId: z.string().startsWith(IdPrefix.Base),\n  tableId: z.string().startsWith(IdPrefix.Table),\n  recordId: z.string().startsWith(IdPrefix.Record).optional(),\n  commentId: z.string().startsWith(IdPrefix.Comment).optional(),\n  downloadUrl: z.string().optional(),\n});\n\nexport const notificationUrlSchema = tableRecordUrlSchema.optional();\nexport type INotificationUrl = z.infer<typeof notificationUrlSchema>;\n\nexport const notificationSchema = z.object({\n  id: z.string().startsWith(IdPrefix.Notification),\n  notifyIcon: notificationIconSchema,\n  notifyType: z.enum(NotificationTypeEnum),\n  url: z.string(),\n  message: z.string(),\n  messageI18n: z.string().nullable().optional(),\n  isRead: z.boolean(),\n  createdTime: z.string(),\n});\nexport type INotification = z.infer<typeof notificationSchema>;\n\nexport const notificationBufferSchema = z.object({\n  notification: notificationSchema,\n  unreadCount: z.number().nonnegative().int(),\n});\nexport type INotificationBuffer = z.infer<typeof notificationBufferSchema>;\n"
  },
  {
    "path": "packages/core/src/models/op.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n// ot-type from https://github.com/ottypes/json0\nexport type IOTPath = (string | number)[];\n\nexport interface IOtOperation {\n  p: IOTPath;\n  na?: number;\n  li?: any;\n  ld?: any;\n  lm?: number;\n  oi?: any;\n  od?: any;\n  si?: string;\n  sd?: string;\n  t?: string;\n  o?: any;\n}\n"
  },
  {
    "path": "packages/core/src/models/record/index.ts",
    "content": "export * from './record';\n"
  },
  {
    "path": "packages/core/src/models/record/record.ts",
    "content": "import { z } from 'zod';\nimport { IdPrefix } from '../../utils';\nimport type { FieldCore } from '../field/field';\n\nexport enum FieldKeyType {\n  Id = 'id',\n  Name = 'name',\n  DbFieldName = 'dbFieldName',\n}\n\nexport enum CellFormat {\n  Json = 'json',\n  Text = 'text',\n}\n\nexport class RecordCore {\n  constructor(protected fieldMap: { [fieldId: string]: FieldCore }) {}\n\n  name?: string;\n\n  commentCount!: number;\n\n  createdTime!: Date;\n\n  id!: string;\n\n  isDeleted = false;\n\n  fields!: Record<string, unknown>;\n\n  permissions?: Record<'read' | 'update', Record<string, boolean>>;\n\n  undeletable?: boolean;\n\n  getCellValue(fieldId: string): unknown {\n    return this.fields[fieldId];\n  }\n\n  getCellValueAsString(fieldId: string) {\n    return this.fieldMap[fieldId].cellValue2String(this.fields[fieldId]);\n  }\n}\n\nexport const recordSchema = z.object({\n  id: z.string().startsWith(IdPrefix.Record).meta({\n    description: 'The record id.',\n  }),\n  name: z.string().optional().meta({ description: 'primary field value' }),\n  fields: z.record(z.string(), z.unknown()).meta({\n    description: 'Objects with a fields key mapping fieldId or field name to value for that field.',\n  }),\n  autoNumber: z.number().optional().meta({\n    description: 'Auto number, a unique identifier for each record',\n  }),\n  createdTime: z.string().optional().meta({\n    description: 'Created time, date ISO string (new Date().toISOString).',\n  }),\n  lastModifiedTime: z.string().optional().meta({\n    description: 'Last modified time, date ISO string (new Date().toISOString).',\n  }),\n  createdBy: z.string().optional().meta({\n    description: 'Created by, user name',\n  }),\n  lastModifiedBy: z.string().optional().meta({\n    description: 'Last modified by, user name',\n  }),\n  permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional().meta({\n    description: 'Permissions for the record',\n  }),\n  undeletable: z.boolean().optional().meta({\n    description: 'Whether the record is undeletable',\n  }),\n});\n\nexport type IRecord = z.infer<typeof recordSchema>;\n"
  },
  {
    "path": "packages/core/src/models/table/index.ts",
    "content": "export * from './table';\nexport * from './table-fields';\nexport * from './table-domain';\nexport * from './tables';\n"
  },
  {
    "path": "packages/core/src/models/table/table-domain.spec.ts",
    "content": "import { plainToInstance } from 'class-transformer';\nimport { DbFieldType, FieldType, CellValueType, Relationship } from '../field/constant';\nimport { FormulaFieldCore } from '../field/derivate/formula.field';\nimport { LinkFieldCore } from '../field/derivate/link.field';\nimport { RollupFieldCore } from '../field/derivate/rollup.field';\nimport { TableDomain } from './table-domain';\n\ndescribe('TableDomain', () => {\n  describe('getAllForeignTableIds', () => {\n    it('should include dependent link fields when projecting computed fields', () => {\n      const linkField = plainToInstance(LinkFieldCore, {\n        id: 'fldlink',\n        name: 'Link',\n        type: FieldType.Link,\n        dbFieldName: 'fldlink',\n        dbFieldType: DbFieldType.Json,\n        cellValueType: CellValueType.String,\n        isMultipleCellValue: true,\n        isComputed: false,\n        options: {\n          foreignTableId: 'tblforeign',\n          relationship: Relationship.OneMany,\n          lookupFieldId: 'fldforeignLookup',\n          fkHostTableName: 'tbl',\n          selfKeyName: '__id',\n          foreignKeyName: '__fk_fldlink',\n        },\n      });\n\n      const rollupField = plainToInstance(RollupFieldCore, {\n        id: 'fldrollup',\n        name: 'Rollup',\n        type: FieldType.Rollup,\n        dbFieldName: 'fldrollup',\n        dbFieldType: DbFieldType.Real,\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n        options: {\n          expression: 'sum({values})',\n        },\n        isLookup: true,\n        lookupOptions: {\n          foreignTableId: 'tblforeign',\n          lookupFieldId: 'fldforeignValue',\n          linkFieldId: 'fldlink',\n        },\n      });\n\n      const formulaField = plainToInstance(FormulaFieldCore, {\n        id: 'fldformula',\n        name: 'Formula',\n        type: FieldType.Formula,\n        dbFieldName: 'fldformula',\n        dbFieldType: DbFieldType.Real,\n        cellValueType: CellValueType.Number,\n        isComputed: true,\n        options: {\n          expression: '{fldrollup}',\n        },\n      });\n\n      const table = new TableDomain({\n        id: 'tbl',\n        name: 'Main',\n        dbTableName: 'tbl_main',\n        lastModifiedTime: new Date().toISOString(),\n        fields: [linkField, rollupField, formulaField],\n      });\n\n      const foreignTableIds = table.getAllForeignTableIds(['fldformula']);\n\n      expect(foreignTableIds.has('tblforeign')).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/table/table-domain.ts",
    "content": "import type { IFieldMap } from '../../formula';\nimport { FieldType } from '../field/constant';\nimport type { FieldCore } from '../field/field';\nimport type { ILookupLinkOptions } from '../field/lookup-options-base.schema';\nimport { isLinkLookupOptions } from '../field/lookup-options-base.schema';\nimport { FieldKeyType } from '../record';\nimport { TableFields } from './table-fields';\n\n/**\n * TableDomain represents a table with its fields and provides methods to interact with them\n * This is a domain object that encapsulates table-related business logic\n */\nexport class TableDomain {\n  readonly id: string;\n  readonly name: string;\n  readonly dbTableName: string;\n  readonly icon?: string;\n  readonly description?: string;\n  readonly lastModifiedTime: string;\n  readonly baseId?: string;\n  readonly dbViewName?: string;\n\n  private readonly _fields: TableFields;\n\n  constructor(params: {\n    id: string;\n    name: string;\n    dbTableName: string;\n    lastModifiedTime: string;\n    icon?: string;\n    description?: string;\n    baseId?: string;\n    fields?: FieldCore[];\n    dbViewName?: string;\n  }) {\n    this.id = params.id;\n    this.name = params.name;\n    this.dbTableName = params.dbTableName;\n    this.icon = params.icon;\n    this.description = params.description;\n    this.lastModifiedTime = params.lastModifiedTime;\n    this.baseId = params.baseId;\n    this.dbViewName = params.dbViewName;\n\n    this._fields = new TableFields(params.fields);\n  }\n\n  getTableNameAndId() {\n    return `${this.name}_${this.id}`;\n  }\n\n  /**\n   * Get the fields collection\n   */\n  get fields(): TableFields {\n    return this._fields;\n  }\n\n  /**\n   * Get all fields as readonly array\n   */\n  get fieldList(): readonly FieldCore[] {\n    return this._fields.fields;\n  }\n\n  get fieldMap(): IFieldMap {\n    return this._fields.toFieldMap();\n  }\n\n  /**\n   * Get field count\n   */\n  get fieldCount(): number {\n    return this._fields.length;\n  }\n\n  /**\n   * Check if table has any fields\n   */\n  get hasFields(): boolean {\n    return !this._fields.isEmpty;\n  }\n\n  getFieldsByProjection(projection?: string[]): FieldCore[] {\n    if (!projection || projection.length === 0) {\n      return this.fieldList as FieldCore[];\n    }\n    const fieldSet = new Set(projection);\n    return this.fieldList.filter(\n      (field) =>\n        fieldSet.has(field.id) || fieldSet.has(field.name) || fieldSet.has(field.dbFieldName)\n    );\n  }\n\n  /**\n   * Get fields map by specified key type\n   */\n  getFieldsMap(fieldKeyType: FieldKeyType): Map<string, FieldCore> {\n    switch (fieldKeyType) {\n      case FieldKeyType.Id:\n        return this._fields.toFieldMap();\n      case FieldKeyType.Name:\n        return this._fields.toFieldNameMap();\n      case FieldKeyType.DbFieldName:\n        return this._fields.toFieldDbNameMap();\n      default:\n        throw new Error(`Unsupported field key type: ${fieldKeyType}`);\n    }\n  }\n\n  /**\n   * Add a field to the table\n   */\n  addField(field: FieldCore): void {\n    this._fields.add(field);\n  }\n\n  /**\n   * Add multiple fields to the table\n   */\n  addFields(fields: FieldCore[]): void {\n    this._fields.addMany(fields);\n  }\n\n  /**\n   * Remove a field from the table\n   */\n  removeField(fieldId: string): boolean {\n    return this._fields.remove(fieldId);\n  }\n\n  /**\n   * Find a field by id\n   */\n  getField(fieldId: string): FieldCore | undefined {\n    return this._fields.findById(fieldId);\n  }\n\n  /**\n   * Find a field by id, throw error if not found\n   */\n  mustGetField(fieldId: string): FieldCore {\n    const field = this.getField(fieldId);\n    if (!field) {\n      throw new Error(`Field ${fieldId} not found`);\n    }\n    return field;\n  }\n\n  /**\n   * Find a field by name\n   */\n  getFieldByName(name: string): FieldCore | undefined {\n    return this._fields.findByName(name);\n  }\n\n  /**\n   * Find a field by database field name\n   */\n  getFieldByDbName(dbFieldName: string): FieldCore | undefined {\n    return this._fields.findByDbFieldName(dbFieldName);\n  }\n\n  /**\n   * Check if a field exists\n   */\n  hasField(fieldId: string): boolean {\n    return this._fields.hasField(fieldId);\n  }\n\n  /**\n   * Check if a field name exists\n   */\n  hasFieldName(name: string): boolean {\n    return this._fields.hasFieldName(name);\n  }\n\n  /**\n   * Get the primary field\n   */\n  getPrimaryField(): FieldCore | undefined {\n    return this._fields.getPrimaryField();\n  }\n\n  /**\n   * Get the last modified fields\n   */\n  getLastModifiedFields(): FieldCore[] {\n    return this._fields.getLastModifiedFields();\n  }\n\n  /**\n   * Get all computed fields\n   */\n  getComputedFields(): FieldCore[] {\n    return this._fields.getComputedFields();\n  }\n\n  /**\n   * Get all lookup fields\n   */\n  getLookupFields(): FieldCore[] {\n    return this._fields.getLookupFields();\n  }\n\n  /**\n   * Update a field in the table\n   */\n  updateField(fieldId: string, updatedField: FieldCore): boolean {\n    return this._fields.update(fieldId, updatedField);\n  }\n\n  /**\n   * Get all field ids\n   */\n  getFieldIds(): string[] {\n    return this._fields.getIds();\n  }\n\n  /**\n   * Get all field names\n   */\n  getFieldNames(): string[] {\n    return this._fields.getNames();\n  }\n\n  /**\n   * Create a field map by id\n   */\n  createFieldMap(): Map<string, FieldCore> {\n    return this._fields.toFieldMap();\n  }\n\n  /**\n   * Create a field map by name\n   */\n  createFieldNameMap(): Map<string, FieldCore> {\n    return this._fields.toFieldNameMap();\n  }\n\n  /**\n   * Filter fields by predicate\n   */\n  filterFields(predicate: (field: FieldCore) => boolean): FieldCore[] {\n    return this._fields.filter(predicate);\n  }\n\n  /**\n   * Map fields to another type\n   */\n  mapFields<T>(mapper: (field: FieldCore) => T): T[] {\n    return this._fields.map(mapper);\n  }\n\n  getLinkFieldsByProjection(projection?: Iterable<string>): FieldCore[] {\n    if (!projection) {\n      return this._fields.filter(\n        (field) => field.type === FieldType.Link && !field.isLookup\n      ) as FieldCore[];\n    }\n\n    const expanded = this.expandFieldIdsWithLinkDependencies(projection);\n    if (!expanded.size) {\n      return [];\n    }\n\n    return Array.from(expanded)\n      .map((fieldId) => this.getField(fieldId))\n      .filter(\n        (field): field is FieldCore => !!field && field.type === FieldType.Link && !field.isLookup\n      );\n  }\n\n  /**\n   * Get all foreign table IDs from link fields\n   */\n  getAllForeignTableIds(fieldIds?: string[]): Set<string> {\n    if (!fieldIds || fieldIds.length === 0) {\n      return this._fields.getAllForeignTableIds();\n    }\n\n    const expandedFieldIds = this.expandFieldIdsWithLinkDependencies(fieldIds);\n    return this._fields.getAllForeignTableIds([...expandedFieldIds]);\n  }\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  private expandFieldIdsWithLinkDependencies(fieldIds: Iterable<string>): Set<string> {\n    const visited = new Set<string>();\n    const stack = [...fieldIds];\n\n    while (stack.length) {\n      const fieldId = stack.pop();\n      if (!fieldId || visited.has(fieldId)) {\n        continue;\n      }\n      visited.add(fieldId);\n\n      const field = this.getField(fieldId);\n      if (!field) {\n        continue;\n      }\n\n      const linkFields = field.getLinkFields(this);\n      for (const linkField of linkFields) {\n        if (!visited.has(linkField.id)) {\n          stack.push(linkField.id);\n        }\n      }\n\n      const lookupOptions = (field as { lookupOptions?: ILookupLinkOptions }).lookupOptions;\n      if (lookupOptions && isLinkLookupOptions(lookupOptions)) {\n        const linkFieldId = lookupOptions.linkFieldId;\n        if (linkFieldId && !visited.has(linkFieldId)) {\n          stack.push(linkFieldId);\n        }\n      }\n    }\n\n    return visited;\n  }\n\n  /**\n   * Create a copy of the table domain object\n   */\n  clone(): TableDomain {\n    return new TableDomain({\n      id: this.id,\n      name: this.name,\n      dbTableName: this.dbTableName,\n      icon: this.icon,\n      description: this.description,\n      lastModifiedTime: this.lastModifiedTime,\n      baseId: this.baseId,\n      dbViewName: this.dbViewName,\n      fields: this._fields.toArray(),\n    });\n  }\n\n  /**\n   * Convert to plain object representation\n   */\n  toPlainObject() {\n    return {\n      id: this.id,\n      name: this.name,\n      dbTableName: this.dbTableName,\n      icon: this.icon,\n      description: this.description,\n      lastModifiedTime: this.lastModifiedTime,\n      baseId: this.baseId,\n      dbViewName: this.dbViewName,\n      fields: this._fields.toArray(),\n      fieldCount: this.fieldCount,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/table/table-fields.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType, Relationship } from '../field/constant';\nimport { LinkFieldCore } from '../field/derivate/link.field';\nimport { SingleLineTextFieldCore } from '../field/derivate/single-line-text.field';\nimport type { IFieldVo } from '../field/field.schema';\nimport { TableFields } from './table-fields';\n\ndescribe('TableFields', () => {\n  let fields: TableFields;\n\n  const linkFieldJson: IFieldVo = {\n    id: 'fldlink1',\n    dbFieldName: 'fldlink1',\n    name: 'Link Field 1',\n    options: {\n      relationship: Relationship.ManyOne,\n      foreignTableId: 'tblforeign1',\n      lookupFieldId: 'fldlookup1',\n      fkHostTableName: 'dbTableName',\n      selfKeyName: '__id',\n      foreignKeyName: '__fk_fldlink1',\n    },\n    type: FieldType.Link,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n\n  const linkField2Json: IFieldVo = {\n    id: 'fldlink2',\n    dbFieldName: 'fldlink2',\n    name: 'Link Field 2',\n    options: {\n      relationship: Relationship.OneMany,\n      foreignTableId: 'tblforeign2',\n      lookupFieldId: 'fldlookup2',\n      fkHostTableName: 'dbTableName',\n      selfKeyName: '__id',\n      foreignKeyName: '__fk_fldlink2',\n    },\n    type: FieldType.Link,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: true,\n    isComputed: false,\n  };\n\n  const lookupFieldJson: IFieldVo = {\n    id: 'fldlookup',\n    dbFieldName: 'fldlookup',\n    name: 'Lookup Field',\n    options: {\n      relationship: Relationship.ManyOne,\n      foreignTableId: 'tblforeign3',\n      lookupFieldId: 'fldlookup3',\n      fkHostTableName: 'dbTableName',\n      selfKeyName: '__id',\n      foreignKeyName: '__fk_fldlookup',\n    },\n    type: FieldType.Link,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: true,\n    isLookup: true,\n  };\n\n  const textFieldJson: IFieldVo = {\n    id: 'fldtext1',\n    dbFieldName: 'fldtext1',\n    name: 'Text Field',\n    options: {},\n    type: FieldType.SingleLineText,\n    dbFieldType: DbFieldType.Text,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n\n  const conditionalLookupFieldJson: IFieldVo = {\n    id: 'fldconditionallookup',\n    dbFieldName: 'fldconditionallookup',\n    name: 'Conditional Lookup Field',\n    options: {},\n    type: FieldType.SingleLineText,\n    dbFieldType: DbFieldType.Text,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: true,\n    isComputed: true,\n    isLookup: true,\n    isConditionalLookup: true,\n    lookupOptions: {\n      foreignTableId: 'tblforeign4',\n      lookupFieldId: 'fldlookup4',\n      filter: {\n        conjunction: 'and',\n        filterSet: [\n          {\n            fieldId: 'fldtext1',\n            operator: 'is',\n            value: 'foo',\n          },\n        ],\n      },\n    },\n  };\n\n  beforeEach(() => {\n    const linkField1 = plainToInstance(LinkFieldCore, linkFieldJson);\n    const linkField2 = plainToInstance(LinkFieldCore, linkField2Json);\n    const lookupField = plainToInstance(LinkFieldCore, lookupFieldJson);\n    const textField = plainToInstance(SingleLineTextFieldCore, textFieldJson);\n    const conditionalLookupField = plainToInstance(\n      SingleLineTextFieldCore,\n      conditionalLookupFieldJson\n    );\n\n    fields = new TableFields([\n      linkField1,\n      linkField2,\n      lookupField,\n      textField,\n      conditionalLookupField,\n    ]);\n  });\n\n  describe('getAllForeignTableIds', () => {\n    it('should return foreign table IDs from link fields', () => {\n      const relatedTableIds = fields.getAllForeignTableIds();\n\n      expect(relatedTableIds).toBeInstanceOf(Set);\n      expect(relatedTableIds.size).toBe(3);\n      expect(relatedTableIds.has('tblforeign1')).toBe(true);\n      expect(relatedTableIds.has('tblforeign2')).toBe(true);\n      expect(relatedTableIds.has('tblforeign4')).toBe(true);\n    });\n\n    it('should exclude lookup fields', () => {\n      const relatedTableIds = fields.getAllForeignTableIds();\n\n      // Should not include the foreign table ID from lookup field\n      expect(relatedTableIds.has('tblforeign3')).toBe(false);\n    });\n\n    it('should exclude non-link fields', () => {\n      const relatedTableIds = fields.getAllForeignTableIds();\n\n      // Should only include link field and conditional lookup foreign table IDs\n      expect(relatedTableIds.size).toBe(3);\n    });\n\n    it('should respect provided fieldIds projection', () => {\n      const onlyFirstLink = fields.getAllForeignTableIds(['fldlink1']);\n      expect(onlyFirstLink.size).toBe(1);\n      expect(onlyFirstLink.has('tblforeign1')).toBe(true);\n\n      const onlyTextField = fields.getAllForeignTableIds(['fldtext1']);\n      expect(onlyTextField.size).toBe(0);\n    });\n\n    it('should return empty set when no link fields exist', () => {\n      const textField = plainToInstance(SingleLineTextFieldCore, textFieldJson);\n      const fieldsWithoutLinks = new TableFields([textField]);\n\n      const relatedTableIds = fieldsWithoutLinks.getAllForeignTableIds();\n\n      expect(relatedTableIds).toBeInstanceOf(Set);\n      expect(relatedTableIds.size).toBe(0);\n    });\n\n    it('should return empty set when fields collection is empty', () => {\n      const emptyFields = new TableFields([]);\n\n      const relatedTableIds = emptyFields.getAllForeignTableIds();\n\n      expect(relatedTableIds).toBeInstanceOf(Set);\n      expect(relatedTableIds.size).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/table/table-fields.ts",
    "content": "import type { IFieldMap } from '../../formula';\nimport type { ConditionalRollupFieldCore } from '../field';\nimport { FieldType } from '../field/constant';\nimport type { FormulaFieldCore } from '../field/derivate/formula.field';\nimport type { LinkFieldCore } from '../field/derivate/link.field';\nimport type { FieldCore } from '../field/field';\nimport { isLinkField } from '../field/field.util';\nimport {\n  isConditionalLookupOptions,\n  isLinkLookupOptions,\n} from '../field/lookup-options-base.schema';\n\n/**\n * TableFields represents a collection of fields within a table\n * This class provides methods to manage and query fields\n */\nexport class TableFields {\n  private readonly _fields: FieldCore[];\n\n  constructor(fields: FieldCore[] = []) {\n    this._fields = [...fields];\n  }\n\n  /**\n   * Get all fields as readonly array\n   */\n  get fields(): readonly FieldCore[] {\n    return this._fields;\n  }\n\n  /**\n   * Get the number of fields\n   */\n  get length(): number {\n    return this._fields.length;\n  }\n\n  /**\n   * Get fields ordered by dependency (topological order)\n   * - Formula fields depend on fields referenced in their expression\n   * - Lookup fields depend on their link field\n   * - Rollup fields depend on their link field\n   * The order is stable relative to original positions when possible.\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  get ordered(): FieldCore[] {\n    const fields = this._fields;\n    const idToIndex = new Map<string, number>();\n    const idToField = new Map<string, FieldCore>();\n\n    fields.forEach((f, i) => {\n      idToIndex.set(f.id, i);\n      idToField.set(f.id, f);\n    });\n\n    // Build adjacency list dep -> dependents and in-degree counts\n    const adjacency = new Map<string, Set<string>>();\n    const inDegree = new Map<string, number>();\n    for (const f of fields) {\n      inDegree.set(f.id, 0);\n    }\n\n    const addEdge = (fromId: string, toId: string) => {\n      if (!idToField.has(fromId) || !idToField.has(toId) || fromId === toId) return;\n      let set = adjacency.get(fromId);\n      if (!set) {\n        set = new Set<string>();\n        adjacency.set(fromId, set);\n      }\n      if (!set.has(toId)) {\n        set.add(toId);\n        inDegree.set(toId, (inDegree.get(toId) || 0) + 1);\n      }\n    };\n\n    for (const f of fields) {\n      // Collect dependencies for each field\n      let deps: string[] = [];\n      if (f.type === FieldType.Formula) {\n        // Prefer instance method if available, fallback to static helper\n        deps = (f as unknown as FormulaFieldCore).getReferenceFieldIds?.();\n      }\n\n      // Lookup fields depend on their link field\n      if (f.isLookup) {\n        const linkFieldId = getLinkLookupFieldId(f.lookupOptions);\n        if (linkFieldId) {\n          deps = [...deps, linkFieldId];\n        }\n      }\n\n      // Rollup fields also depend on their link field\n      if (f.type === FieldType.Rollup) {\n        const linkFieldId = getLinkLookupFieldId(f.lookupOptions);\n        if (linkFieldId) {\n          deps = [...deps, linkFieldId];\n        }\n      }\n\n      if (f.type === FieldType.ConditionalRollup) {\n        const linkFieldId = getLinkLookupFieldId(f.lookupOptions);\n        if (linkFieldId) {\n          deps = [...deps, linkFieldId];\n        }\n      }\n\n      // Create edges dep -> f.id\n      for (const depId of new Set(deps)) {\n        addEdge(depId, f.id);\n      }\n    }\n\n    // Kahn's algorithm with stable ordering by original index\n    const zeroQueue: string[] = [];\n    for (const [id, deg] of inDegree) {\n      if (deg === 0) zeroQueue.push(id);\n    }\n    zeroQueue.sort((a, b) => idToIndex.get(a)! - idToIndex.get(b)!);\n\n    const resultIds: string[] = [];\n    while (zeroQueue.length > 0) {\n      const id = zeroQueue.shift()!;\n      resultIds.push(id);\n      const neighbors = adjacency.get(id);\n      if (!neighbors) continue;\n      // To keep stability, process neighbors by original index\n      const orderedNeighbors = Array.from(neighbors).sort(\n        (a, b) => idToIndex.get(a)! - idToIndex.get(b)!\n      );\n      for (const nb of orderedNeighbors) {\n        const nextDeg = (inDegree.get(nb) || 0) - 1;\n        inDegree.set(nb, nextDeg);\n        if (nextDeg === 0) {\n          // insert in position to keep queue ordered by original index\n          const idx = zeroQueue.findIndex((x) => idToIndex.get(x)! > idToIndex.get(nb)!);\n          if (idx === -1) zeroQueue.push(nb);\n          else zeroQueue.splice(idx, 0, nb);\n        }\n      }\n    }\n\n    // If cycles exist, append remaining nodes by original order\n    if (resultIds.length < fields.length) {\n      const remaining = fields\n        .map((f, i) => ({ id: f.id, i }))\n        .filter(({ id }) => !resultIds.includes(id))\n        .sort((a, b) => a.i - b.i)\n        .map(({ id }) => id);\n      resultIds.push(...remaining);\n    }\n\n    return resultIds.map((id) => idToField.get(id)!) as FieldCore[];\n  }\n\n  /**\n   * Check if fields collection is empty\n   */\n  get isEmpty(): boolean {\n    return this._fields.length === 0;\n  }\n\n  /**\n   * Add a field to the collection\n   */\n  add(field: FieldCore): void {\n    this._fields.push(field);\n  }\n\n  /**\n   * Add multiple fields to the collection\n   */\n  addMany(fields: FieldCore[]): void {\n    this._fields.push(...fields);\n  }\n\n  /**\n   * Remove a field by id\n   */\n  remove(fieldId: string): boolean {\n    const index = this._fields.findIndex((field) => field.id === fieldId);\n    if (index !== -1) {\n      this._fields.splice(index, 1);\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * Find a field by id\n   */\n  findById(fieldId: string): FieldCore | undefined {\n    return this._fields.find((field) => field.id === fieldId);\n  }\n\n  /**\n   * Find a field by name\n   */\n  findByName(name: string): FieldCore | undefined {\n    return this._fields.find((field) => field.name === name);\n  }\n\n  /**\n   * Find a field by database field name\n   */\n  findByDbFieldName(dbFieldName: string): FieldCore | undefined {\n    return this._fields.find((field) => field.dbFieldName === dbFieldName);\n  }\n\n  /**\n   * Get all field ids\n   */\n  getIds(): string[] {\n    return this._fields.map((field) => field.id);\n  }\n\n  /**\n   * Get all field names\n   */\n  getNames(): string[] {\n    return this._fields.map((field) => field.name);\n  }\n\n  /**\n   * Filter fields by predicate\n   */\n  filter(predicate: (field: FieldCore) => boolean): FieldCore[] {\n    return this._fields.filter(predicate);\n  }\n\n  /**\n   * Map fields to another type\n   */\n  map<T>(mapper: (field: FieldCore) => T): T[] {\n    return this._fields.map(mapper);\n  }\n\n  /**\n   * Check if a field exists by id\n   */\n  hasField(fieldId: string): boolean {\n    return this._fields.some((field) => field.id === fieldId);\n  }\n\n  /**\n   * Check if a field name exists\n   */\n  hasFieldName(name: string): boolean {\n    return this._fields.some((field) => field.name === name);\n  }\n\n  /**\n   * Get primary field (if exists)\n   */\n  getPrimaryField(): FieldCore | undefined {\n    return this._fields.find((field) => field.isPrimary);\n  }\n\n  /**\n   * Get last modified fields\n   */\n  getLastModifiedFields(): FieldCore[] {\n    return this._fields.filter(\n      (field) =>\n        field.type === FieldType.LastModifiedTime || field.type === FieldType.LastModifiedBy\n    );\n  }\n\n  /**\n   * Get computed fields\n   */\n  getComputedFields(): FieldCore[] {\n    return this._fields.filter((field) => field.isComputed);\n  }\n\n  getLinkFields(): LinkFieldCore[] {\n    return this._fields.filter(isLinkField);\n  }\n\n  /**\n   * Get lookup fields\n   */\n  getLookupFields(): FieldCore[] {\n    return this._fields.filter((field) => field.isLookup);\n  }\n\n  /**\n   * Update a field in the collection\n   */\n  update(fieldId: string, updatedField: FieldCore): boolean {\n    const index = this._fields.findIndex((field) => field.id === fieldId);\n    if (index !== -1) {\n      this._fields[index] = updatedField;\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * Clear all fields\n   */\n  clear(): void {\n    this._fields.length = 0;\n  }\n\n  /**\n   * Create a copy of the fields collection\n   */\n  clone(): TableFields {\n    return new TableFields(this._fields);\n  }\n\n  /**\n   * Convert to plain array\n   */\n  toArray(): FieldCore[] {\n    return [...this._fields];\n  }\n\n  /**\n   * Create field map by id\n   */\n  toFieldMap(): IFieldMap {\n    return new Map(this._fields.map((field) => [field.id, field]));\n  }\n\n  /**\n   * Create field map by name\n   */\n  toFieldNameMap(): Map<string, FieldCore> {\n    return new Map(this._fields.map((field) => [field.name, field]));\n  }\n\n  toFieldDbNameMap(): Map<string, FieldCore> {\n    return new Map(this._fields.map((field) => [field.dbFieldName, field]));\n  }\n\n  /**\n   * Get all foreign table ids from link fields\n   */\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  getAllForeignTableIds(fieldIds?: string[]): Set<string> {\n    const foreignTableIds = new Set<string>();\n    const fieldIdSet = fieldIds ? new Set(fieldIds) : undefined;\n\n    for (const field of this) {\n      if (fieldIdSet && !fieldIdSet.has(field.id)) {\n        continue;\n      }\n      if (field.isConditionalLookup) {\n        const options = field.lookupOptions;\n        const foreignTableId = isConditionalLookupOptions(options)\n          ? options.foreignTableId\n          : undefined;\n        if (foreignTableId) {\n          foreignTableIds.add(foreignTableId);\n        }\n        continue;\n      }\n      if (field.type === FieldType.ConditionalRollup) {\n        const foreignTableId = (field as ConditionalRollupFieldCore).getForeignTableId?.();\n        if (foreignTableId) {\n          foreignTableIds.add(foreignTableId);\n        }\n        continue;\n      }\n      if (!isLinkField(field)) continue;\n      // Skip errored link fields to avoid traversing deleted/missing tables\n      if (field.hasError) continue;\n      const foreignTableId = field.getForeignTableId();\n      if (foreignTableId) {\n        foreignTableIds.add(foreignTableId);\n      }\n    }\n\n    return foreignTableIds;\n  }\n\n  /**\n   * Iterator support for for...of loops\n   */\n  *[Symbol.iterator](): Iterator<FieldCore> {\n    for (const field of this._fields) {\n      yield field;\n    }\n  }\n}\nconst getLinkLookupFieldId = (options: FieldCore['lookupOptions']): string | undefined => {\n  return options && isLinkLookupOptions(options) ? options.linkFieldId : undefined;\n};\n"
  },
  {
    "path": "packages/core/src/models/table/table.ts",
    "content": "export class TableCore {\n  id!: string;\n\n  name!: string;\n\n  dbTableName!: string;\n\n  dbViewName?: string | null;\n\n  icon?: string;\n\n  description?: string;\n\n  lastModifiedTime!: string;\n\n  defaultViewId!: string;\n}\n"
  },
  {
    "path": "packages/core/src/models/table/tables.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { plainToInstance } from 'class-transformer';\nimport { FieldType, DbFieldType, CellValueType, Relationship } from '../field/constant';\nimport { LinkFieldCore } from '../field/derivate/link.field';\nimport { SingleLineTextFieldCore } from '../field/derivate/single-line-text.field';\nimport type { IFieldVo } from '../field/field.schema';\nimport { TableDomain } from './table-domain';\nimport { Tables } from './tables';\n\ndescribe('Tables', () => {\n  let tables: Tables;\n  let tableDomain1: TableDomain;\n  let tableDomain2: TableDomain;\n\n  const linkFieldJson: IFieldVo = {\n    id: 'fldlink1',\n    dbFieldName: 'fldlink1',\n    name: 'Link Field 1',\n    options: {\n      relationship: Relationship.ManyOne,\n      foreignTableId: 'tbl2',\n      lookupFieldId: 'fldlookup1',\n      fkHostTableName: 'dbTableName',\n      selfKeyName: '__id',\n      foreignKeyName: '__fk_fldlink1',\n    },\n    type: FieldType.Link,\n    dbFieldType: DbFieldType.Json,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n\n  const textFieldJson: IFieldVo = {\n    id: 'fldtext1',\n    dbFieldName: 'fldtext1',\n    name: 'Text Field',\n    options: {},\n    type: FieldType.SingleLineText,\n    dbFieldType: DbFieldType.Text,\n    cellValueType: CellValueType.String,\n    isMultipleCellValue: false,\n    isComputed: false,\n  };\n\n  beforeEach(() => {\n    const linkField = plainToInstance(LinkFieldCore, linkFieldJson);\n    const textField = plainToInstance(SingleLineTextFieldCore, textFieldJson);\n\n    tableDomain1 = new TableDomain({\n      id: 'tbl1',\n      name: 'Table 1',\n      dbTableName: 'table_1',\n      lastModifiedTime: '2023-01-01T00:00:00.000Z',\n      fields: [linkField, textField],\n    });\n\n    tableDomain2 = new TableDomain({\n      id: 'tbl2',\n      name: 'Table 2',\n      dbTableName: 'table_2',\n      lastModifiedTime: '2023-01-01T00:00:00.000Z',\n      fields: [textField],\n    });\n\n    tables = new Tables('tbl1');\n  });\n\n  describe('basic operations', () => {\n    it('should start empty with entry table ID', () => {\n      expect(tables.size).toBe(0);\n      expect(tables.isEmpty).toBe(true);\n      expect(tables.entryTableId).toBe('tbl1');\n    });\n\n    it('should add and retrieve tables', () => {\n      tables.addTable('tbl1', tableDomain1);\n\n      expect(tables.size).toBe(1);\n      expect(tables.isEmpty).toBe(false);\n      expect(tables.hasTable('tbl1')).toBe(true);\n      expect(tables.getTable('tbl1')).toBe(tableDomain1);\n    });\n\n    it('should add multiple tables', () => {\n      const tableMap = new Map([\n        ['tbl1', tableDomain1],\n        ['tbl2', tableDomain2],\n      ]);\n\n      tables.addTables(tableMap);\n\n      expect(tables.size).toBe(2);\n      expect(tables.hasTable('tbl1')).toBe(true);\n      expect(tables.hasTable('tbl2')).toBe(true);\n    });\n\n    it('should remove tables', () => {\n      tables.addTable('tbl1', tableDomain1);\n\n      expect(tables.removeTable('tbl1')).toBe(true);\n      expect(tables.size).toBe(0);\n      expect(tables.hasTable('tbl1')).toBe(false);\n      expect(tables.removeTable('nonexistent')).toBe(false);\n    });\n  });\n\n  describe('entry table and foreign tables', () => {\n    beforeEach(() => {\n      tables.addTable('tbl1', tableDomain1); // Entry table\n      tables.addTable('tbl2', tableDomain2); // Foreign table\n    });\n\n    it('should identify entry table correctly', () => {\n      expect(tables.isEntryTable('tbl1')).toBe(true);\n      expect(tables.isEntryTable('tbl2')).toBe(false);\n      expect(tables.isForeignTable('tbl1')).toBe(false);\n      expect(tables.isForeignTable('tbl2')).toBe(true);\n    });\n\n    it('should get entry table', () => {\n      const entryTable = tables.getEntryTable();\n      expect(entryTable).toBe(tableDomain1);\n      expect(entryTable?.id).toBe('tbl1');\n    });\n\n    it('should get foreign tables', () => {\n      const foreignTables = tables.getForeignTables();\n      expect(foreignTables.size).toBe(1);\n      expect(foreignTables.has('tbl2')).toBe(true);\n      expect(foreignTables.has('tbl1')).toBe(false);\n    });\n\n    it('should get foreign table IDs', () => {\n      const foreignTableIds = tables.getForeignTableIds();\n      expect(foreignTableIds).toHaveLength(1);\n      expect(foreignTableIds).toContain('tbl2');\n      expect(foreignTableIds).not.toContain('tbl1');\n    });\n  });\n\n  describe('visited state management', () => {\n    beforeEach(() => {\n      tables.addTable('tbl1', tableDomain1);\n      tables.addTable('tbl2', tableDomain2);\n    });\n\n    it('should track visited state', () => {\n      expect(tables.isVisited('tbl1')).toBe(false);\n\n      tables.markVisited('tbl1');\n\n      expect(tables.isVisited('tbl1')).toBe(true);\n      expect(tables.isVisited('tbl2')).toBe(false);\n    });\n\n    it('should get visited and unvisited tables', () => {\n      tables.markVisited('tbl1');\n\n      const visitedTables = tables.getVisitedTables();\n      const unvisitedTables = tables.getUnvisitedTables();\n\n      expect(visitedTables.size).toBe(1);\n      expect(visitedTables.has('tbl1')).toBe(true);\n      expect(unvisitedTables.size).toBe(1);\n      expect(unvisitedTables.has('tbl2')).toBe(true);\n    });\n\n    it('should get visited table IDs', () => {\n      tables.markVisited('tbl1');\n      tables.markVisited('tbl2');\n\n      const visitedIds = tables.getVisitedTableIds();\n\n      expect(visitedIds).toHaveLength(2);\n      expect(visitedIds).toContain('tbl1');\n      expect(visitedIds).toContain('tbl2');\n    });\n  });\n\n  describe('collection operations', () => {\n    beforeEach(() => {\n      tables.addTable('tbl1', tableDomain1);\n      tables.addTable('tbl2', tableDomain2);\n    });\n\n    it('should get table IDs and domains', () => {\n      const tableIds = tables.getTableIds();\n      const tableDomains = tables.getTableDomainByIdsArray();\n\n      expect(tableIds).toHaveLength(2);\n      expect(tableIds).toContain('tbl1');\n      expect(tableIds).toContain('tbl2');\n      expect(tableDomains).toHaveLength(2);\n      expect(tableDomains).toContain(tableDomain1);\n      expect(tableDomains).toContain(tableDomain2);\n    });\n\n    it('should filter tables', () => {\n      const filteredTables = tables.filterTables((domain, id) => id === 'tbl1');\n\n      expect(filteredTables).toHaveLength(1);\n      expect(filteredTables[0]).toBe(tableDomain1);\n    });\n\n    it('should map tables', () => {\n      const tableNames = tables.mapTables((domain) => domain.name);\n\n      expect(tableNames).toHaveLength(2);\n      expect(tableNames).toContain('Table 1');\n      expect(tableNames).toContain('Table 2');\n    });\n\n    it('should get all related table IDs', () => {\n      const relatedTableIds = tables.getAllRelatedTableIds();\n\n      expect(relatedTableIds.has('tbl2')).toBe(true); // tbl1 links to tbl2\n    });\n\n    it('should clear all tables and visited state', () => {\n      tables.markVisited('tbl1');\n\n      tables.clear();\n\n      expect(tables.size).toBe(0);\n      expect(tables.isEmpty).toBe(true);\n      expect(tables.isVisited('tbl1')).toBe(false);\n    });\n  });\n\n  describe('iteration and conversion', () => {\n    beforeEach(() => {\n      tables.addTable('tbl1', tableDomain1);\n      tables.addTable('tbl2', tableDomain2);\n    });\n\n    it('should support iteration', () => {\n      const entries = Array.from(tables);\n\n      expect(entries).toHaveLength(2);\n      expect(entries[0][0]).toBe('tbl1');\n      expect(entries[0][1]).toBe(tableDomain1);\n    });\n\n    it('should convert to plain object', () => {\n      tables.markVisited('tbl1');\n\n      const plainObject = tables.toPlainObject();\n\n      expect(plainObject.entryTableId).toBe('tbl1');\n      expect(plainObject.size).toBe(2);\n      expect(plainObject.isEmpty).toBe(false);\n      expect(plainObject.visited).toContain('tbl1');\n      expect(plainObject.tables).toHaveProperty('tbl1');\n      expect(plainObject.tables).toHaveProperty('tbl2');\n      expect(plainObject.foreignTables).toHaveProperty('tbl2');\n      expect(plainObject.foreignTables).not.toHaveProperty('tbl1');\n    });\n\n    it('should clone tables', () => {\n      tables.markVisited('tbl1');\n\n      const clonedTables = tables.clone();\n\n      expect(clonedTables.size).toBe(2);\n      expect(clonedTables.isVisited('tbl1')).toBe(true);\n      expect(clonedTables.hasTable('tbl1')).toBe(true);\n      expect(clonedTables.hasTable('tbl2')).toBe(true);\n\n      // Should be independent copies\n      clonedTables.addTable('tbl3', tableDomain1);\n      expect(tables.hasTable('tbl3')).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/table/tables.ts",
    "content": "import type { LinkFieldCore } from '../field';\nimport type { TableDomain } from './table-domain';\n\n/**\n * Tables domain object that manages a collection of table domains\n * This class encapsulates table collection operations and provides a clean API\n * for managing multiple tables with visited state tracking\n */\nexport class Tables {\n  private readonly _tableDomains: Map<string, TableDomain>;\n  private readonly _visited: Set<string>;\n  private readonly _entryTableId: string;\n\n  constructor(\n    entryTableId: string,\n    tableDomains: Map<string, TableDomain> = new Map(),\n    visited: Set<string> = new Set()\n  ) {\n    this._entryTableId = entryTableId;\n    this._tableDomains = new Map(tableDomains);\n    this._visited = new Set(visited);\n  }\n\n  /**\n   * Get all table domains as readonly map\n   */\n  get tableDomains(): ReadonlyMap<string, TableDomain> {\n    return this._tableDomains;\n  }\n\n  /**\n   * Get visited table IDs as readonly set\n   */\n  get visited(): ReadonlySet<string> {\n    return this._visited;\n  }\n\n  /**\n   * Get the entry table ID\n   */\n  get entryTableId(): string {\n    return this._entryTableId;\n  }\n\n  /**\n   * Get the number of tables\n   */\n  get size(): number {\n    return this._tableDomains.size;\n  }\n\n  /**\n   * Check if tables collection is empty\n   */\n  get isEmpty(): boolean {\n    return this._tableDomains.size === 0;\n  }\n\n  /**\n   * Add a table domain to the collection\n   */\n  addTable(tableId: string, tableDomain: TableDomain): void {\n    this._tableDomains.set(tableId, tableDomain);\n  }\n\n  /**\n   * Add multiple table domains to the collection\n   */\n  addTables(tables: Map<string, TableDomain>): void {\n    for (const [tableId, tableDomain] of tables) {\n      this._tableDomains.set(tableId, tableDomain);\n    }\n  }\n\n  /**\n   * Get a table domain by ID\n   */\n  getTable(tableId: string): TableDomain | undefined {\n    return this._tableDomains.get(tableId);\n  }\n\n  mustGetTable(tableId: string): TableDomain {\n    const table = this.getTable(tableId);\n    if (!table) {\n      throw new Error(`Table ${tableId} not found`);\n    }\n    return table;\n  }\n\n  getLinkForeignTable(field: LinkFieldCore): TableDomain | undefined {\n    return this.getTable(field.options.foreignTableId);\n  }\n\n  mustGetLinkForeignTable(field: LinkFieldCore): TableDomain {\n    const table = this.getLinkForeignTable(field);\n    if (!table) {\n      throw new Error(`Foreign table ${field.options.foreignTableId} not found`);\n    }\n    return table;\n  }\n\n  /**\n   * Check if a table exists\n   */\n  hasTable(tableId: string): boolean {\n    return this._tableDomains.has(tableId);\n  }\n\n  /**\n   * Remove a table from the collection\n   */\n  removeTable(tableId: string): boolean {\n    return this._tableDomains.delete(tableId);\n  }\n\n  /**\n   * Mark a table as visited\n   */\n  markVisited(tableId: string): void {\n    this._visited.add(tableId);\n  }\n\n  /**\n   * Check if a table has been visited\n   */\n  isVisited(tableId: string): boolean {\n    return this._visited.has(tableId);\n  }\n\n  /**\n   * Get all table IDs\n   */\n  getTableIds(): string[] {\n    return Array.from(this._tableDomains.keys());\n  }\n\n  /**\n   * Get all table domains as array\n   */\n  getTableDomainByIdsArray(): TableDomain[] {\n    return Array.from(this._tableDomains.values());\n  }\n\n  /**\n   * Get all visited table IDs as array\n   */\n  getVisitedTableIds(): string[] {\n    return Array.from(this._visited);\n  }\n\n  /**\n   * Get the entry table domain\n   */\n  getEntryTable(): TableDomain | undefined {\n    return this._tableDomains.get(this._entryTableId);\n  }\n\n  /**\n   * Get the entry table domain, throw error if not found\n   * @throws Error - If entry table is not found\n   */\n  mustGetEntryTable(): TableDomain {\n    const entryTable = this.getEntryTable();\n    if (!entryTable) {\n      throw new Error(`Entry table ${this._entryTableId} not found`);\n    }\n    return entryTable;\n  }\n\n  getTableListByIds(ids: Iterable<string>): TableDomain[] {\n    return [...ids].map((id) => this.getTable(id)).filter(Boolean) as TableDomain[];\n  }\n\n  /**\n   * Get all foreign table domains (excluding the entry table)\n   */\n  getForeignTables(): Map<string, TableDomain> {\n    const foreignTables = new Map<string, TableDomain>();\n\n    for (const [tableId, tableDomain] of this._tableDomains) {\n      if (tableId !== this._entryTableId) {\n        foreignTables.set(tableId, tableDomain);\n      }\n    }\n\n    return foreignTables;\n  }\n\n  /**\n   * Get all foreign table IDs (excluding the entry table)\n   */\n  getForeignTableIds(): string[] {\n    return this.getTableIds().filter((id) => id !== this._entryTableId);\n  }\n\n  /**\n   * Check if a table is the entry table\n   */\n  isEntryTable(tableId: string): boolean {\n    return tableId === this._entryTableId;\n  }\n\n  /**\n   * Check if a table is a foreign table (not the entry table)\n   */\n  isForeignTable(tableId: string): boolean {\n    return this.hasTable(tableId) && !this.isEntryTable(tableId);\n  }\n\n  /**\n   * Filter tables by predicate\n   */\n  filterTables(predicate: (tableDomain: TableDomain, tableId: string) => boolean): TableDomain[] {\n    const result: TableDomain[] = [];\n    for (const [tableId, tableDomain] of this._tableDomains) {\n      if (predicate(tableDomain, tableId)) {\n        result.push(tableDomain);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * Map tables to another type\n   */\n  mapTables<T>(mapper: (tableDomain: TableDomain, tableId: string) => T): T[] {\n    const result: T[] = [];\n    for (const [tableId, tableDomain] of this._tableDomains) {\n      result.push(mapper(tableDomain, tableId));\n    }\n    return result;\n  }\n\n  /**\n   * Get all related table IDs from all tables in the collection\n   */\n  getAllRelatedTableIds(): Set<string> {\n    const allRelatedTableIds = new Set<string>();\n\n    for (const tableDomain of this._tableDomains.values()) {\n      const relatedTableIds = tableDomain.getAllForeignTableIds();\n      for (const tableId of relatedTableIds) {\n        allRelatedTableIds.add(tableId);\n      }\n    }\n\n    return allRelatedTableIds;\n  }\n\n  /**\n   * Get tables that are not yet visited\n   */\n  getUnvisitedTables(): Map<string, TableDomain> {\n    const unvisitedTables = new Map<string, TableDomain>();\n\n    for (const [tableId, tableDomain] of this._tableDomains) {\n      if (!this._visited.has(tableId)) {\n        unvisitedTables.set(tableId, tableDomain);\n      }\n    }\n\n    return unvisitedTables;\n  }\n\n  /**\n   * Get tables that have been visited\n   */\n  getVisitedTables(): Map<string, TableDomain> {\n    const visitedTables = new Map<string, TableDomain>();\n\n    for (const [tableId, tableDomain] of this._tableDomains) {\n      if (this._visited.has(tableId)) {\n        visitedTables.set(tableId, tableDomain);\n      }\n    }\n\n    return visitedTables;\n  }\n\n  /**\n   * Clear all tables and visited state\n   */\n  clear(): void {\n    this._tableDomains.clear();\n    this._visited.clear();\n  }\n\n  /**\n   * Create a copy of the tables collection\n   */\n  clone(): Tables {\n    return new Tables(this._entryTableId, this._tableDomains, this._visited);\n  }\n\n  /**\n   * Convert to plain object representation\n   */\n  toPlainObject() {\n    return {\n      entryTableId: this._entryTableId,\n      tables: Object.fromEntries(\n        Array.from(this._tableDomains.entries()).map(([id, domain]) => [id, domain.toPlainObject()])\n      ),\n      foreignTables: Object.fromEntries(\n        Array.from(this.getForeignTables().entries()).map(([id, domain]) => [\n          id,\n          domain.toPlainObject(),\n        ])\n      ),\n      visited: Array.from(this._visited),\n      size: this.size,\n      isEmpty: this.isEmpty,\n    };\n  }\n\n  /**\n   * Iterator support for for...of loops over table domains\n   */\n  *[Symbol.iterator](): Iterator<[string, TableDomain]> {\n    for (const entry of this._tableDomains) {\n      yield entry;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/core/src/models/view/column-meta.schema.ts",
    "content": "import { IdPrefix } from '../../utils';\nimport { z } from '../../zod';\nimport { StatisticsFunc } from '../aggregation';\n\nexport const fieldsViewVisibleRoSchema = z.object({\n  viewFields: z\n    .object({\n      fieldId: z.string().startsWith(IdPrefix.Field).length(19),\n      hidden: z.boolean(),\n    })\n    .array()\n    .nonempty(),\n});\n\nexport type IColumnMeta = z.infer<typeof columnMetaSchema>;\n\nexport type IGridColumnMeta = z.infer<typeof gridColumnMetaSchema>;\n\nexport type IKanbanColumnMeta = z.infer<typeof kanbanColumnMetaSchema>;\n\nexport type IGalleryColumnMeta = z.infer<typeof galleryColumnMetaSchema>;\n\nexport type ICalendarColumnMeta = z.infer<typeof calendarColumnMetaSchema>;\n\nexport type IFormColumnMeta = z.infer<typeof formColumnMetaSchema>;\n\nexport type IPluginColumnMeta = z.infer<typeof pluginColumnMetaSchema>;\n\nexport type IColumn = z.infer<typeof columnSchema>;\n\nexport type IGridColumn = z.infer<typeof gridColumnSchema>;\n\nexport type IKanbanColumn = z.infer<typeof kanbanColumnSchema>;\n\nexport type IFormColumn = z.infer<typeof formColumnSchema>;\n\nexport type IPluginColumn = z.infer<typeof pluginColumnSchema>;\n\nexport const columnSchemaBase = z\n  .object({\n    order: z.number().meta({\n      description: 'Order is a floating number, column will sort by it in the view.',\n    }),\n  })\n  .meta({\n    description: 'A mapping of field IDs to their corresponding column metadata.',\n  });\n\nexport const gridColumnSchema = columnSchemaBase.extend({\n  width: z.number().optional().meta({\n    description: 'Column width in the view.',\n  }),\n  hidden: z.boolean().optional().meta({\n    description: 'If column hidden in the view.',\n  }),\n  statisticFunc: z.enum(StatisticsFunc).nullable().optional().meta({\n    description: 'Statistic function of the column in the view.',\n  }),\n});\n\nexport const kanbanColumnSchema = columnSchemaBase.extend({\n  visible: z.boolean().optional().meta({\n    description: 'If column visible in the kanban view.',\n  }),\n});\n\nexport const galleryColumnSchema = columnSchemaBase.extend({\n  visible: z.boolean().optional().meta({\n    description: 'If column visible in the gallery view.',\n  }),\n});\n\nexport const calendarColumnSchema = columnSchemaBase.extend({\n  visible: z.boolean().optional().meta({\n    description: 'If column visible in the calendar view.',\n  }),\n});\n\nexport const formColumnSchema = columnSchemaBase.extend({\n  visible: z.boolean().optional().meta({\n    description: 'If column visible in the view.',\n  }),\n  required: z.boolean().optional().meta({\n    description: 'If column is required.',\n  }),\n});\n\nexport const pluginColumnSchema = columnSchemaBase.extend({\n  hidden: z.boolean().optional().meta({\n    description: 'If column hidden in the view.',\n  }),\n});\n\nexport const columnSchema = z.union([\n  gridColumnSchema.strict(),\n  kanbanColumnSchema.strict(),\n  galleryColumnSchema.strict(),\n  formColumnSchema.strict(),\n  pluginColumnSchema.strict(),\n]);\n\nexport const columnMetaSchema = z.record(z.string().startsWith(IdPrefix.Field), columnSchema);\n\nexport const gridColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  gridColumnSchema\n);\n\nexport const kanbanColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  kanbanColumnSchema\n);\n\nexport const galleryColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  galleryColumnSchema\n);\n\nexport const calendarColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  calendarColumnSchema\n);\n\nexport const formColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  formColumnSchema\n);\n\nexport const pluginColumnMetaSchema = z.record(\n  z.string().startsWith(IdPrefix.Field),\n  pluginColumnSchema\n);\n\nexport const columnMetaRoSchema = z\n  .object({\n    fieldId: z\n      .string()\n      .startsWith(IdPrefix.Field)\n      .describe('Field ID')\n      .meta({ description: 'Field ID' }),\n    columnMeta: z.union([\n      gridColumnSchema.partial().strict(),\n      kanbanColumnSchema.partial().strict(),\n      formColumnSchema.partial().strict(),\n      pluginColumnSchema.partial().strict(),\n    ]),\n  })\n  .array();\n\nexport type IColumnMetaRo = z.infer<typeof columnMetaRoSchema>;\n\nexport type IFieldsViewVisibleRo = z.infer<typeof fieldsViewVisibleRoSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/constant.ts",
    "content": "export enum RowHeightLevel {\n  Short = 'short',\n  Medium = 'medium',\n  Tall = 'tall',\n  ExtraTall = 'extraTall',\n  AutoFit = 'autoFit',\n}\n\nexport enum ViewType {\n  Grid = 'grid',\n  Calendar = 'calendar',\n  Kanban = 'kanban',\n  Form = 'form',\n  Gallery = 'gallery',\n  Plugin = 'plugin',\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/calendar-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { Colors } from '../../field/colors';\n\nexport enum ColorConfigType {\n  Field = 'field',\n  Custom = 'custom',\n}\n\nexport const colorConfigSchema = z\n  .object({\n    type: z.enum(ColorConfigType),\n    fieldId: z.string().optional().nullable().meta({\n      description: 'The color field id.',\n    }),\n    color: z.enum(Colors).optional().nullable().meta({\n      description: 'The color.',\n    }),\n  })\n  .optional()\n  .nullable();\n\nexport type IColorConfig = z.infer<typeof colorConfigSchema>;\n\nexport const calendarViewOptionSchema = z\n  .object({\n    startDateFieldId: z.string().optional().nullable().meta({\n      description: 'The start date field id.',\n    }),\n    endDateFieldId: z.string().optional().nullable().meta({\n      description: 'The end date field id.',\n    }),\n    titleFieldId: z.string().optional().nullable().meta({\n      description: 'The title field id.',\n    }),\n    colorConfig: colorConfigSchema,\n  })\n  .strict();\n\nexport type ICalendarViewOptions = z.infer<typeof calendarViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/calendar.view.ts",
    "content": "import type { ICalendarColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IViewVo } from '../view.schema';\nimport type { ICalendarViewOptions } from './calendar-view-option.schema';\n\nexport interface ICalendarView extends IViewVo {\n  type: ViewType.Calendar;\n  options: ICalendarViewOptions;\n}\n\nexport class CalendarViewCore extends ViewCore {\n  type!: ViewType.Calendar;\n\n  options!: ICalendarViewOptions;\n\n  columnMeta!: ICalendarColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/form-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const formViewOptionSchema = z\n  .object({\n    coverUrl: z.string().optional().meta({ description: 'The cover url of the form' }),\n    logoUrl: z.string().optional().meta({ description: 'The logo url of the form' }),\n    submitLabel: z.string().optional().meta({ description: 'The submit button text of the form' }),\n  })\n  .strict();\n\nexport type IFormViewOptions = z.infer<typeof formViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/form.view.ts",
    "content": "import type { IFormColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IViewVo } from '../view.schema';\nimport type { IFormViewOptions } from './form-view-option.schema';\n\nexport interface IFormView extends IViewVo {\n  type: ViewType.Form;\n  options: IFormViewOptions;\n}\n\nexport class FormViewCore extends ViewCore {\n  type!: ViewType.Form;\n\n  options!: IFormViewOptions;\n\n  columnMeta!: IFormColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/gallery-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const galleryViewOptionSchema = z\n  .object({\n    coverFieldId: z.string().optional().nullable().meta({\n      description:\n        'The cover field id is a designated attachment field id, the contents of which appear at the top of each gallery card.',\n    }),\n    isCoverFit: z.boolean().optional().meta({\n      description: 'If true, cover images are resized to fit gallery cards.',\n    }),\n    isFieldNameHidden: z.boolean().optional().meta({\n      description: 'If true, hides field name in the gallery cards.',\n    }),\n  })\n  .strict();\n\nexport type IGalleryViewOptions = z.infer<typeof galleryViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/gallery.view.ts",
    "content": "import type { IGalleryColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IViewVo } from '../view.schema';\nimport type { IGalleryViewOptions } from './gallery-view-option.schema';\n\nexport interface IGalleryView extends IViewVo {\n  type: ViewType.Gallery;\n  options: IGalleryViewOptions;\n}\n\nexport class GalleryViewCore extends ViewCore {\n  type!: ViewType.Gallery;\n\n  options!: IGalleryViewOptions;\n\n  columnMeta!: IGalleryColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/grid-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\nimport { RowHeightLevel } from '../constant';\n\nexport const gridViewOptionSchema = z\n  .object({\n    rowHeight: z\n      .enum(RowHeightLevel)\n      .optional()\n      .meta({ description: 'The row height level of row in view' }),\n    fieldNameDisplayLines: z\n      .number()\n      .min(1)\n      .max(3)\n      .optional()\n      .meta({ description: 'The field name display lines in view' }),\n    frozenColumnCount: z.number().min(0).optional().meta({\n      description:\n        'The frozen column count in view. Deprecated: this field will be removed in a future release and may no longer take effect.',\n    }),\n    frozenFieldId: z\n      .string()\n      .optional()\n      .meta({ description: 'Freeze to the right side of this field id in grid view' }),\n  })\n  .strict();\n\nexport type IGridViewOptions = z.infer<typeof gridViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/grid.view.ts",
    "content": "import type { IGridColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IViewVo } from '../view.schema';\nimport type { IGridViewOptions } from './grid-view-option.schema';\n\nexport interface IGridView extends IViewVo {\n  type: ViewType.Grid;\n  options: IGridViewOptions;\n}\n\nexport class GridViewCore extends ViewCore {\n  type!: ViewType.Grid;\n\n  options!: IGridViewOptions;\n\n  columnMeta!: IGridColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/index.ts",
    "content": "export * from './grid.view';\nexport * from './kanban.view';\nexport * from './gallery.view';\nexport * from './calendar.view';\nexport * from './form.view';\nexport * from './plugin.view';\n\nexport * from './calendar-view-option.schema';\nexport * from './form-view-option.schema';\nexport * from './gallery-view-option.schema';\nexport * from './grid-view-option.schema';\nexport * from './kanban-view-option.schema';\nexport * from './plugin-view-option.schema';\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/kanban-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const kanbanViewOptionSchema = z\n  .object({\n    stackFieldId: z.string().optional().meta({ description: 'The field id of the Kanban stack.' }),\n    coverFieldId: z.string().optional().nullable().meta({\n      description:\n        'The cover field id is a designated attachment field id, the contents of which appear at the top of each Kanban card.',\n    }),\n    isCoverFit: z.boolean().optional().meta({\n      description: 'If true, cover images are resized to fit Kanban cards.',\n    }),\n    isFieldNameHidden: z.boolean().optional().meta({\n      description: 'If true, hides field name in the Kanban cards.',\n    }),\n    isEmptyStackHidden: z.boolean().optional().meta({\n      description: 'If true, hides empty stacks in the Kanban.',\n    }),\n  })\n  .strict();\n\nexport type IKanbanViewOptions = z.infer<typeof kanbanViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/kanban.view.ts",
    "content": "import type { IKanbanColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IViewVo } from '../view.schema';\nimport type { IKanbanViewOptions } from './kanban-view-option.schema';\n\nexport interface IKanbanView extends IViewVo {\n  type: ViewType.Kanban;\n  options: IKanbanViewOptions;\n}\n\nexport class KanbanViewCore extends ViewCore {\n  type!: ViewType.Kanban;\n\n  options!: IKanbanViewOptions;\n\n  columnMeta!: IKanbanColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/plugin-view-option.schema.ts",
    "content": "import { z } from '../../../zod';\n\nexport const pluginViewOptionSchema = z\n  .object({\n    pluginId: z.string().meta({ description: 'The plugin id' }),\n    pluginInstallId: z.string().meta({ description: 'The plugin install id' }),\n    pluginLogo: z.string().meta({ description: 'The plugin logo' }),\n  })\n  .strict();\n\nexport type IPluginViewOptions = z.infer<typeof pluginViewOptionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/derivate/plugin.view.ts",
    "content": "import type { IPluginColumnMeta } from '../column-meta.schema';\nimport type { ViewType } from '../constant';\nimport { ViewCore } from '../view';\nimport type { IPluginViewOptions } from './plugin-view-option.schema';\n\nexport class PluginViewCore extends ViewCore {\n  type!: ViewType.Plugin;\n\n  options!: IPluginViewOptions;\n\n  columnMeta!: IPluginColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/filter/conjunction.ts",
    "content": "import { z } from 'zod';\n\nexport const and = z.literal('and');\nexport const or = z.literal('or');\n\nexport const conjunctionSchema = z.union([and, or]);\nexport type IConjunction = z.infer<typeof conjunctionSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/filter/field-reference.spec.ts",
    "content": "import { CellValueType, FieldType } from '../../field/constant';\nimport {\n  getFieldReferenceComparisonKind,\n  getFieldReferenceSupportedOperators,\n  isFieldReferenceComparable,\n  isFieldReferenceOperatorSupported,\n} from './field-reference';\nimport {\n  contains,\n  doesNotContain,\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  is,\n  isAfter,\n  isAnyOf,\n  isBefore,\n  isExactly,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isNot,\n  isNotExactly,\n  isNoneOf,\n  isOnOrAfter,\n  isOnOrBefore,\n  isWithIn,\n} from './operator';\n\ndescribe('field reference operator helpers', () => {\n  const stringField = {\n    cellValueType: CellValueType.String,\n    type: FieldType.SingleLineText,\n  } as const;\n\n  const numberField = {\n    cellValueType: CellValueType.Number,\n    type: FieldType.Number,\n  } as const;\n\n  const dateField = {\n    cellValueType: CellValueType.DateTime,\n    type: FieldType.Date,\n  } as const;\n\n  const multiUserField = {\n    cellValueType: CellValueType.String,\n    type: FieldType.User,\n    isMultipleCellValue: true,\n  } as const;\n  const ratingField = {\n    cellValueType: CellValueType.Number,\n    type: FieldType.Rating,\n  } as const;\n  const createdByField = {\n    cellValueType: CellValueType.String,\n    type: FieldType.CreatedBy,\n  } as const;\n\n  it('returns text operators for string fields', () => {\n    expect(getFieldReferenceSupportedOperators(stringField)).toEqual([\n      is.value,\n      isNot.value,\n      contains.value,\n      doesNotContain.value,\n    ]);\n  });\n\n  it('returns comparison operators for number fields', () => {\n    expect(getFieldReferenceSupportedOperators(numberField)).toEqual([\n      is.value,\n      isNot.value,\n      isGreater.value,\n      isGreaterEqual.value,\n      isLess.value,\n      isLessEqual.value,\n    ]);\n  });\n\n  it('returns range operators for date fields', () => {\n    expect(getFieldReferenceSupportedOperators(dateField)).toEqual([\n      is.value,\n      isNot.value,\n      isWithIn.value,\n      isBefore.value,\n      isAfter.value,\n      isOnOrBefore.value,\n      isOnOrAfter.value,\n    ]);\n  });\n\n  it('returns collection operators for multi-value user field', () => {\n    expect(getFieldReferenceSupportedOperators(multiUserField)).toEqual([\n      hasAnyOf.value,\n      hasAllOf.value,\n      isExactly.value,\n      hasNoneOf.value,\n      isNotExactly.value,\n    ]);\n  });\n\n  it('checks operator support', () => {\n    expect(isFieldReferenceOperatorSupported(dateField, isBefore.value)).toBe(true);\n    expect(isFieldReferenceOperatorSupported(stringField, isAfter.value)).toBe(false);\n    expect(isFieldReferenceOperatorSupported(multiUserField, hasAnyOf.value)).toBe(true);\n    expect(isFieldReferenceOperatorSupported(stringField, isAnyOf.value)).toBe(false);\n    expect(isFieldReferenceOperatorSupported(stringField, isNoneOf.value)).toBe(false);\n    expect(isFieldReferenceOperatorSupported(numberField, null)).toBe(false);\n  });\n\n  describe('comparison helpers', () => {\n    it('classifies fields by semantic type', () => {\n      expect(getFieldReferenceComparisonKind(numberField)).toBe('number');\n      expect(getFieldReferenceComparisonKind(stringField)).toBe('string');\n      expect(getFieldReferenceComparisonKind(dateField)).toBe('dateTime');\n      expect(getFieldReferenceComparisonKind(createdByField)).toBe('user');\n    });\n\n    it('allows comparisons between numeric field families', () => {\n      expect(isFieldReferenceComparable(numberField, ratingField)).toBe(true);\n    });\n\n    it('disallows comparisons between incompatible semantic types', () => {\n      expect(isFieldReferenceComparable(createdByField, stringField)).toBe(false);\n      expect(isFieldReferenceComparable(numberField, stringField)).toBe(false);\n    });\n\n    it('requires user-like fields on both sides', () => {\n      const userField = {\n        cellValueType: CellValueType.String,\n        type: FieldType.User,\n      } as const;\n      expect(isFieldReferenceComparable(userField, createdByField)).toBe(true);\n      expect(isFieldReferenceComparable(userField, stringField)).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/view/filter/field-reference.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { CellValueType, FieldType } from '../../field/constant';\nimport type { IOperator } from './operator';\nimport { getValidFilterOperators, isEmpty, isNotEmpty } from './operator';\n\ntype FieldShape = {\n  cellValueType: CellValueType;\n  type: FieldType;\n  isMultipleCellValue?: boolean;\n};\n\nexport type FieldReferenceComparisonKind =\n  | 'user'\n  | 'link'\n  | 'attachment'\n  | 'number'\n  | 'boolean'\n  | 'dateTime'\n  | 'string';\n\nconst USER_FIELD_TYPES = new Set<FieldType>([\n  FieldType.User,\n  FieldType.CreatedBy,\n  FieldType.LastModifiedBy,\n]);\n\nconst LINK_FIELD_TYPES = new Set<FieldType>([FieldType.Link]);\n\nconst ATTACHMENT_FIELD_TYPES = new Set<FieldType>([FieldType.Attachment]);\n\nexport function getFieldReferenceComparisonKind(field: FieldShape): FieldReferenceComparisonKind {\n  if (USER_FIELD_TYPES.has(field.type)) {\n    return 'user';\n  }\n\n  if (LINK_FIELD_TYPES.has(field.type)) {\n    return 'link';\n  }\n\n  if (ATTACHMENT_FIELD_TYPES.has(field.type)) {\n    return 'attachment';\n  }\n\n  switch (field.cellValueType) {\n    case CellValueType.Number:\n      return 'number';\n    case CellValueType.Boolean:\n      return 'boolean';\n    case CellValueType.DateTime:\n      return 'dateTime';\n    case CellValueType.String:\n    default:\n      return 'string';\n  }\n}\n\nexport function isFieldReferenceComparable(field: FieldShape, reference: FieldShape): boolean {\n  return getFieldReferenceComparisonKind(field) === getFieldReferenceComparisonKind(reference);\n}\n\nconst FIELD_REFERENCE_UNSUPPORTED_OPERATORS = new Set<IOperator>([isEmpty.value, isNotEmpty.value]);\n\nexport function getFieldReferenceSupportedOperators(field: FieldShape): IOperator[] {\n  const validOperators = getValidFilterOperators(field);\n  return validOperators.filter((op) => !FIELD_REFERENCE_UNSUPPORTED_OPERATORS.has(op));\n}\n\nexport function isFieldReferenceOperatorSupported(\n  field: FieldShape,\n  operator?: IOperator | null\n): boolean {\n  if (!operator) {\n    return false;\n  }\n  if (FIELD_REFERENCE_UNSUPPORTED_OPERATORS.has(operator)) {\n    return false;\n  }\n\n  const validOperators = getValidFilterOperators(field);\n  return validOperators.includes(operator);\n}\n"
  },
  {
    "path": "packages/core/src/models/view/filter/filter-item.ts",
    "content": "import { z } from 'zod';\nimport { dataFieldCellValueSchema } from '../../field/derivate/date.field';\nimport { timeZoneStringSchema } from '../../field/formatting/time-zone';\nimport type { IOperator, ISymbol } from './operator';\nimport {\n  daysAgo,\n  daysFromNow,\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  isAnyOf,\n  isEmpty,\n  isExactly,\n  isNoneOf,\n  isNotEmpty,\n  nextNumberOfDays,\n  operators,\n  pastNumberOfDays,\n  subOperators,\n  symbols,\n  isNotExactly,\n} from './operator';\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const modesRequiringDays: string[] = [\n  daysAgo.value,\n  daysFromNow.value,\n  pastNumberOfDays.value,\n  nextNumberOfDays.value,\n];\n\nexport const dateFilterSchema = z\n  .object({\n    mode: subOperators,\n    numberOfDays: z.coerce.number().int().nonnegative().optional(),\n    exactDate: dataFieldCellValueSchema.optional(),\n    exactDateEnd: dataFieldCellValueSchema.optional(),\n    timeZone: timeZoneStringSchema,\n  })\n  .superRefine((val, ctx) => {\n    if (['exactDate', 'exactFormatDate'].includes(val.mode) && !val.exactDate) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `When the mode is set to '${val.mode}', an '${val.mode}' must be provided`,\n      });\n    } else if (val.mode === 'dateRange') {\n      if (!val.exactDate) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          message: `When the mode is 'dateRange', a start date 'exactDate' must be provided`,\n        });\n      }\n      if (!val.exactDateEnd) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          message: `When the mode is 'dateRange', an end date 'exactDateEnd' must be provided`,\n        });\n      }\n    } else if (modesRequiringDays.includes(val.mode) && val.numberOfDays == null) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `When the mode is '${val.mode}', a numerical value for '${val.mode}' must be provided`,\n      });\n    }\n  });\nexport type IDateFilter = z.infer<typeof dateFilterSchema>;\n\nexport const literalValueSchema = z.union([z.string(), z.number(), z.boolean()]);\nexport type ILiteralValue = z.infer<typeof literalValueSchema>;\nexport const literalValueListSchema = literalValueSchema.array().nonempty();\nexport type ILiteralValueList = z.infer<typeof literalValueListSchema>;\n\nexport const fieldReferenceValueSchema = z.object({\n  type: z.literal('field'),\n  fieldId: z.string(),\n  tableId: z.string().optional(),\n});\nexport type IFieldReferenceValue = z.infer<typeof fieldReferenceValueSchema>;\n\nexport const filterValueSchema = z\n  .union([literalValueSchema, literalValueListSchema, dateFilterSchema, fieldReferenceValueSchema])\n  .nullable();\nexport type IFilterValue = z.infer<typeof filterValueSchema>;\n\nexport const isFieldReferenceValue = (value: unknown): value is IFieldReferenceValue => {\n  return (\n    typeof value === 'object' &&\n    value !== null &&\n    'type' in value &&\n    (value as { type?: string }).type === 'field' &&\n    typeof (value as { fieldId?: unknown }).fieldId === 'string'\n  );\n};\n\nexport type IFilterOperator = IOperator;\nexport type IFilterSymbolOperator = ISymbol;\n\nconst operatorsExpectingNull: string[] = [isEmpty.value, isNotEmpty.value];\nconst operatorsExpectingArray: string[] = [\n  isAnyOf.value,\n  isNoneOf.value,\n  hasAnyOf.value,\n  hasAllOf.value,\n  isNotExactly.value,\n  hasNoneOf.value,\n  isExactly.value,\n];\n\nconst normalizeUnaryOperatorValue = (input: unknown): unknown => {\n  if (input == null || typeof input !== 'object') return input;\n\n  const value = input as Record<string, unknown>;\n  if (typeof value.operator !== 'string' || !operatorsExpectingNull.includes(value.operator)) {\n    return input;\n  }\n  if (Object.prototype.hasOwnProperty.call(value, 'value')) return input;\n\n  return {\n    ...value,\n    value: null,\n  };\n};\n\nexport const baseFilterOperatorSchema = z.preprocess(\n  normalizeUnaryOperatorValue,\n  z.object({\n    isSymbol: z.literal(false).optional(),\n    fieldId: z.string(),\n    value: filterValueSchema,\n    operator: operators,\n  })\n);\n\nconst filterOperatorRefineBase = z.object({\n  value: filterValueSchema,\n  operator: operators,\n});\n\nexport const refineExtendedFilterOperatorSchema = <\n  T extends z.infer<typeof filterOperatorRefineBase>,\n>(\n  schema: z.ZodSchema<T>\n): z.ZodSchema<T> =>\n  schema.superRefine((val, ctx) => {\n    if (!val.value) {\n      return z.NEVER;\n    }\n    if (operatorsExpectingNull.includes(val.operator)) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `For the operator '${val.operator}', the 'value' should be null`,\n      });\n    }\n\n    if (\n      operatorsExpectingArray.includes(val.operator) &&\n      !Array.isArray(val.value) &&\n      !isFieldReferenceValue(val.value)\n    ) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `For the operator '${val.operator}', the 'value' should be an array`,\n      });\n    }\n\n    if (\n      !operatorsExpectingArray.includes(val.operator) &&\n      Array.isArray(val.value) &&\n      !isFieldReferenceValue(val.value)\n    ) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `For the operator '${val.operator}', the 'value' should not be an array`,\n      });\n    }\n  });\n\nexport const filterOperatorSchema = refineExtendedFilterOperatorSchema(baseFilterOperatorSchema);\n\nexport const filterSymbolOperatorSchema = z.object({\n  isSymbol: z.literal(true),\n  fieldId: z.string(),\n  value: filterValueSchema,\n  operator: symbols,\n});\n\nexport const filterItemSchema = z.union([filterOperatorSchema, filterSymbolOperatorSchema]);\n\nexport type IFilterItem = z.infer<typeof filterItemSchema>;\n"
  },
  {
    "path": "packages/core/src/models/view/filter/filter.spec.ts",
    "content": "import type { IFilter } from './filter';\nimport { filterSchema } from './filter';\n\ndescribe('Filter Parse', () => {\n  it('should parse single filter', async () => {\n    const data: IFilter = {\n      filterSet: [\n        {\n          fieldId: 'fldbbM45OO5VOWuce4r',\n          operator: 'contains',\n          value: '1',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    const parse = filterSchema.parse(data);\n    expect(parse).toEqual(data);\n  });\n\n  it('should parse a nested filter', async () => {\n    const data: IFilter = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              fieldId: 'fldbbM45OO5VOWuce4r',\n              operator: 'contains',\n              value: '2',\n            },\n          ],\n          conjunction: 'or',\n        },\n      ],\n      conjunction: 'or',\n    };\n\n    const parse = filterSchema.parse(data);\n    expect(parse).toEqual(data);\n  });\n\n  it('should parse a multi nested filter', async () => {\n    const data: IFilter = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              filterSet: [\n                {\n                  fieldId: 'fldbbM45OO5VOWuce4r',\n                  operator: 'contains',\n                  value: '2',\n                },\n              ],\n              conjunction: 'and',\n            },\n          ],\n          conjunction: 'or',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    const parse = filterSchema.parse(data);\n    expect(parse).toEqual(data);\n  });\n\n  it('should parse a mix filter', async () => {\n    const data = {\n      filterSet: [\n        {\n          fieldId: 'fldbbM45OO5VOWuce4r',\n          operator: 'contains',\n          value: '1',\n        },\n        {\n          filterSet: [\n            {\n              fieldId: 'fldbbM45OO5VOWuce4r',\n              operator: 'contains',\n              value: '2',\n            },\n          ],\n          conjunction: 'or',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    const parse = filterSchema.parse(data);\n    expect(parse).toEqual(data);\n  });\n\n  it('should normalize unary filter items without explicit value to null', async () => {\n    const data = {\n      filterSet: [\n        {\n          fieldId: 'fldbbM45OO5VOWuce4r',\n          operator: 'isNotEmpty',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    const parse = filterSchema.parse(data);\n    expect(parse).toEqual({\n      conjunction: 'and',\n      filterSet: [\n        {\n          fieldId: 'fldbbM45OO5VOWuce4r',\n          operator: 'isNotEmpty',\n          value: null,\n        },\n      ],\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/view/filter/filter.ts",
    "content": "import { z } from 'zod';\nimport { FieldType } from '../../field/constant';\nimport type { IConjunction } from './conjunction';\nimport { and, conjunctionSchema } from './conjunction';\nimport type { IFilterItem } from './filter-item';\nimport { filterItemSchema, isFieldReferenceValue } from './filter-item';\nimport type { IDateTimeFieldOperator } from './operator';\nimport { getValidFilterSubOperators, isWithIn } from './operator';\n\nexport const baseFilterSetSchema = z.object({\n  conjunction: conjunctionSchema,\n});\n\nexport type IFilterSet = z.infer<typeof baseFilterSetSchema> & {\n  filterSet: (IFilterItem | IFilterSet)[];\n};\n\nexport const nestedFilterItemSchema: z.ZodType<IFilterSet> = baseFilterSetSchema.extend({\n  filterSet: z.lazy(() => z.union([filterItemSchema, nestedFilterItemSchema]).array()),\n});\n\nexport const FILTER_DESCRIPTION =\n  'A filter object for complex query conditions based on fields, operators, and values. Use our visual query builder at https://app.teable.ai/developer/tool/query-builder to build filters.';\n\nexport const filterSchema = nestedFilterItemSchema.nullable().meta({\n  type: 'object',\n  description: FILTER_DESCRIPTION,\n});\n\nexport type IFilter = z.infer<typeof filterSchema>;\n\nexport const filterRoSchema = z.object({\n  filter: filterSchema,\n});\n\nexport type IFilterRo = z.infer<typeof filterRoSchema>;\n\nexport const filterStringSchema = z.string().transform((val, ctx) => {\n  let jsonValue;\n  try {\n    jsonValue = JSON.parse(val);\n  } catch {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: 'Invalid JSON string',\n    });\n    return z.NEVER;\n  }\n  return filterSchema.parse(jsonValue);\n});\n\nexport function mergeWithDefaultFilter(\n  defaultViewFilter?: string | null,\n  queryFilter?: IFilter\n): IFilter | undefined {\n  if (!defaultViewFilter && !queryFilter) {\n    return undefined;\n  }\n\n  const parseFilter = filterStringSchema.safeParse(defaultViewFilter);\n  const viewFilter = parseFilter.success ? parseFilter.data : undefined;\n\n  let mergeFilter = viewFilter;\n  if (queryFilter) {\n    if (viewFilter) {\n      mergeFilter = {\n        filterSet: [{ filterSet: [viewFilter, queryFilter], conjunction: 'and' }],\n        conjunction: 'and',\n      };\n    } else {\n      mergeFilter = queryFilter;\n    }\n  }\n  return mergeFilter;\n}\n\nexport const mergeFilter = (\n  filter1?: IFilter,\n  filter2?: IFilter,\n  conjunction: IConjunction = and.value\n) => {\n  const finalFilter1 = filter1;\n  const finalFilter2 = filter2;\n\n  if (!finalFilter1 && !finalFilter2) return;\n\n  if (!finalFilter1) return finalFilter2;\n\n  if (!finalFilter2) return finalFilter1;\n\n  return {\n    filterSet: [{ filterSet: [finalFilter1, finalFilter2], conjunction }],\n    conjunction,\n  } as IFilter;\n};\n\nexport const extractFieldIdsFromFilter = (\n  filter?: IFilter,\n  includeValueFieldIds = false\n): string[] => {\n  if (!filter) return [];\n\n  const fieldIds: string[] = [];\n\n  // eslint-disable-next-line sonarjs/cognitive-complexity\n  const traverse = (filterItem: IFilter | IFilterItem) => {\n    if (filterItem && 'fieldId' in filterItem) {\n      fieldIds.push(filterItem.fieldId);\n\n      if (includeValueFieldIds) {\n        const value = filterItem.value;\n        if (isFieldReferenceValue(value)) {\n          fieldIds.push(value.fieldId);\n        } else if (Array.isArray(value)) {\n          for (const entry of value) {\n            if (isFieldReferenceValue(entry)) {\n              fieldIds.push(entry.fieldId);\n            }\n          }\n        }\n      }\n    } else if (filterItem && 'filterSet' in filterItem) {\n      filterItem.filterSet.forEach((item) => traverse(item));\n    }\n  };\n\n  traverse(filter);\n  return [...new Set(fieldIds)];\n};\n\nexport interface IFilterValidationError {\n  fieldId: string;\n  operator: string;\n  mode?: string;\n  message: string;\n}\n\n/**\n * Validate filter operator and mode compatibility\n * Returns an array of validation errors if any, empty array if valid\n * @param filter - The filter to validate\n * @param fieldTypeMap - A map of fieldId to FieldType\n */\nexport const validateFilterOperatorModeCompatibility = (\n  filter: IFilter | null | undefined,\n  fieldTypeMap: Record<string, FieldType>\n): IFilterValidationError[] => {\n  if (!filter) return [];\n\n  const errors: IFilterValidationError[] = [];\n\n  const traverse = (filterItem: IFilter | IFilterItem) => {\n    if (filterItem && 'fieldId' in filterItem) {\n      const { fieldId, operator, value } = filterItem;\n      const fieldType = fieldTypeMap[fieldId];\n\n      // Only validate date fields with date filter value\n      if (fieldType === FieldType.Date && value && typeof value === 'object' && 'mode' in value) {\n        const dateValue = value as { mode: string };\n        const validSubOperators = getValidFilterSubOperators(\n          fieldType,\n          operator as IDateTimeFieldOperator\n        );\n\n        if (validSubOperators && !validSubOperators.includes(dateValue.mode as never)) {\n          const operatorName = operator === isWithIn.value ? 'isWithIn' : operator;\n          errors.push({\n            fieldId,\n            operator: operator as string,\n            mode: dateValue.mode,\n            message: `The '${operatorName}' operation with mode '${dateValue.mode}' is invalid. Allowed modes: [${validSubOperators.join(',')}]`,\n          });\n        }\n      }\n    } else if (filterItem && 'filterSet' in filterItem) {\n      filterItem.filterSet.forEach((item) => traverse(item));\n    }\n  };\n\n  traverse(filter);\n  return errors;\n};\n"
  },
  {
    "path": "packages/core/src/models/view/filter/index.ts",
    "content": "export * from './conjunction';\nexport * from './filter-item';\nexport * from './operator';\nexport * from './filter';\nexport * from './field-reference';\n"
  },
  {
    "path": "packages/core/src/models/view/filter/operator.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CellValueType, FieldType } from '../../field';\nimport type { IDateTimeFieldOperator } from './operator';\nimport {\n  booleanFieldValidOperators,\n  contains,\n  dateTimeFieldValidOperators,\n  dateTimeFieldValidSubOperators,\n  dateTimeFieldValidSubOperatorsByIsWithin,\n  doesNotContain,\n  getFilterOperatorMapping,\n  getValidFilterOperators,\n  getValidFilterSubOperators,\n  hasAllOf,\n  hasAnyOf,\n  hasNoneOf,\n  isAfter,\n  isAnyOf,\n  isEmpty,\n  isExactly,\n  isNoneOf,\n  isNotEmpty,\n  isWithIn,\n  numberFieldValidOperators,\n  textFieldValidOperators,\n} from './operator';\n\ndescribe('Filter operators and sub-operators utility functions', () => {\n  describe('getValidFilterOperators', () => {\n    it('should return valid text field operators', () => {\n      const textFieldField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.SingleLineText,\n        isMultipleCellValue: false,\n      };\n\n      const validOps = getValidFilterOperators(textFieldField);\n      expect(validOps).toEqual(expect.arrayContaining(textFieldValidOperators));\n    });\n\n    it('should return valid number field operators', () => {\n      const numberField: any = {\n        cellValueType: CellValueType.Number,\n        type: FieldType.Number,\n        isMultipleCellValue: false,\n      };\n\n      const validOps = getValidFilterOperators(numberField);\n      expect(validOps).toEqual(expect.arrayContaining(numberFieldValidOperators));\n    });\n\n    it('should return valid checkbox field operators', () => {\n      const checkboxField: any = {\n        cellValueType: CellValueType.Boolean,\n        type: FieldType.Checkbox,\n        isMultipleCellValue: false,\n      };\n\n      const validOps = getValidFilterOperators(checkboxField);\n      expect(validOps).toEqual(expect.arrayContaining(booleanFieldValidOperators));\n    });\n\n    it('should return valid date field operators', () => {\n      const dateField: any = {\n        cellValueType: CellValueType.DateTime,\n        type: FieldType.Date,\n        isMultipleCellValue: false,\n      };\n\n      const validOps = getValidFilterOperators(dateField);\n      expect(validOps).toEqual(expect.arrayContaining(dateTimeFieldValidOperators));\n    });\n\n    it('should adjust operators based on the field type (SingleSelect)', () => {\n      const singleSelectField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.SingleSelect,\n        isMultipleCellValue: false,\n      };\n      const validOps = getValidFilterOperators(singleSelectField);\n      expect(validOps).not.toContain(contains.value);\n      expect(validOps).not.toContain(doesNotContain.value);\n      expect(validOps).toContain(isAnyOf.value);\n      expect(validOps).toContain(isNoneOf.value);\n\n      const multipleSelectField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.SingleSelect,\n        isMultipleCellValue: true,\n      };\n\n      const validOpsWithMultiple = getValidFilterOperators(multipleSelectField);\n\n      // same with multiple select\n      expect(validOpsWithMultiple).not.toContain(contains.value);\n      expect(validOpsWithMultiple).not.toContain(doesNotContain.value);\n      expect(validOpsWithMultiple).toContain(hasAnyOf.value);\n      expect(validOpsWithMultiple).toContain(hasAllOf.value);\n      expect(validOpsWithMultiple).toContain(isExactly.value);\n      expect(validOpsWithMultiple).toContain(hasNoneOf.value);\n    });\n\n    it('should adjust operators based on the field type (MultipleSelect)', () => {\n      const multipleSelectField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.MultipleSelect,\n        isMultipleCellValue: true,\n      };\n      const validOps = getValidFilterOperators(multipleSelectField);\n      expect(validOps).not.toContain(contains.value);\n      expect(validOps).not.toContain(doesNotContain.value);\n      expect(validOps).toContain(hasAnyOf.value);\n      expect(validOps).toContain(hasAllOf.value);\n      expect(validOps).toContain(isExactly.value);\n      expect(validOps).toContain(hasNoneOf.value);\n    });\n\n    it('should adjust operators based on the field type (Attachment)', () => {\n      const attachmentField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.Attachment,\n        isMultipleCellValue: true,\n      };\n      const validOps = getValidFilterOperators(attachmentField);\n      expect(validOps).toEqual(expect.arrayContaining([isEmpty.value, isNotEmpty.value]));\n    });\n\n    it('should adjust operators based on the field type (Link)', () => {\n      const linkField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.Link,\n        isMultipleCellValue: false,\n      };\n      const validOps = getValidFilterOperators(linkField);\n      expect(validOps).toContain(contains.value);\n      expect(validOps).toContain(doesNotContain.value);\n    });\n\n    it('should adjust operators based on the field type (User)', () => {\n      const userField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.User,\n        isMultipleCellValue: false,\n      };\n      const validOps = getValidFilterOperators(userField);\n      expect(validOps).not.toContain(contains.value);\n      expect(validOps).not.toContain(doesNotContain.value);\n      expect(validOps).toContain(isAnyOf.value);\n      expect(validOps).toContain(isNoneOf.value);\n\n      const multipleUserField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.User,\n        isMultipleCellValue: true,\n      };\n      const validOps1 = getValidFilterOperators(multipleUserField);\n      expect(validOps1).not.toContain(contains.value);\n      expect(validOps1).not.toContain(doesNotContain.value);\n      expect(validOps1).toContain(hasAnyOf.value);\n      expect(validOps1).toContain(hasAllOf.value);\n      expect(validOps1).toContain(isExactly.value);\n      expect(validOps1).toContain(hasNoneOf.value);\n    });\n  });\n\n  describe('getFilterOperatorMapping', () => {\n    it('should map valid filter operators to their corresponding symbols', () => {\n      const sampleField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.SingleLineText,\n        isMultipleCellValue: false,\n      };\n\n      const validOps = getValidFilterOperators(sampleField);\n      const mapping = getFilterOperatorMapping(sampleField);\n\n      validOps.forEach((op) => {\n        expect(mapping[op]).toBeDefined();\n      });\n    });\n  });\n\n  describe('getValidFilterSubOperators', () => {\n    it('should return undefined when fieldType is not Date', () => {\n      const nonDateField: any = {\n        cellValueType: CellValueType.String,\n        type: FieldType.SingleLineText,\n        isMultipleCellValue: false,\n      };\n      const parentOp: IDateTimeFieldOperator = isWithIn.value;\n\n      const subOperators = getValidFilterSubOperators(nonDateField.type, parentOp);\n      expect(subOperators).toBeUndefined();\n    });\n\n    it('should return valid date sub-operators when fieldType is Date and parent operator is \"isWithin\"', () => {\n      const dateField: any = {\n        cellValueType: CellValueType.DateTime,\n        type: FieldType.Date,\n        isMultipleCellValue: false,\n      };\n      const parentOp: IDateTimeFieldOperator = isWithIn.value;\n\n      const subOperators = getValidFilterSubOperators(dateField.type, parentOp);\n      expect(subOperators).toEqual(dateTimeFieldValidSubOperatorsByIsWithin);\n    });\n\n    it('should return valid date sub-operators when fieldType is Date and parent operator is NOT \"isWithin\"', () => {\n      const dateField: any = {\n        cellValueType: CellValueType.DateTime,\n        type: FieldType.Date,\n        isMultipleCellValue: false,\n      };\n      const parentOp: IDateTimeFieldOperator = isAfter.value;\n\n      const subOperators = getValidFilterSubOperators(dateField.type, parentOp);\n      expect(subOperators).toEqual(\n        dateTimeFieldValidSubOperators.filter((op) => op !== 'dateRange')\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/view/filter/operator.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { pick, pullAll, uniq } from 'lodash';\nimport { z } from 'zod';\nimport { CellValueType, FieldType } from '../../field/constant';\nimport type { FieldCore } from '../../field/field';\n\nexport const is = z.literal('is');\nexport const isNot = z.literal('isNot');\nexport const contains = z.literal('contains');\nexport const doesNotContain = z.literal('doesNotContain');\nexport const isEmpty = z.literal('isEmpty');\nexport const isNotEmpty = z.literal('isNotEmpty');\nexport const isGreater = z.literal('isGreater');\nexport const isGreaterEqual = z.literal('isGreaterEqual');\nexport const isLess = z.literal('isLess');\nexport const isLessEqual = z.literal('isLessEqual');\nexport const isAnyOf = z.literal('isAnyOf');\nexport const isNoneOf = z.literal('isNoneOf');\nexport const hasAnyOf = z.literal('hasAnyOf');\nexport const hasAllOf = z.literal('hasAllOf');\nexport const isNotExactly = z.literal('isNotExactly');\nexport const hasNoneOf = z.literal('hasNoneOf');\nexport const isExactly = z.literal('isExactly');\nexport const isWithIn = z.literal('isWithIn');\nexport const isBefore = z.literal('isBefore');\nexport const isAfter = z.literal('isAfter');\nexport const isOnOrBefore = z.literal('isOnOrBefore');\nexport const isOnOrAfter = z.literal('isOnOrAfter');\n\n// date sub operation\nexport const today = z.literal('today');\nexport const tomorrow = z.literal('tomorrow');\nexport const yesterday = z.literal('yesterday');\nexport const currentWeek = z.literal('currentWeek');\nexport const currentMonth = z.literal('currentMonth');\nexport const currentYear = z.literal('currentYear');\nexport const lastWeek = z.literal('lastWeek');\nexport const lastMonth = z.literal('lastMonth');\nexport const lastYear = z.literal('lastYear');\nexport const nextWeekPeriod = z.literal('nextWeekPeriod');\nexport const nextMonthPeriod = z.literal('nextMonthPeriod');\nexport const nextYearPeriod = z.literal('nextYearPeriod');\nexport const oneWeekAgo = z.literal('oneWeekAgo');\nexport const oneWeekFromNow = z.literal('oneWeekFromNow');\nexport const oneMonthAgo = z.literal('oneMonthAgo');\nexport const oneMonthFromNow = z.literal('oneMonthFromNow');\nexport const daysAgo = z.literal('daysAgo');\nexport const daysFromNow = z.literal('daysFromNow');\nexport const exactDate = z.literal('exactDate');\nexport const exactFormatDate = z.literal('exactFormatDate');\nexport const dateRange = z.literal('dateRange');\n\n// date sub operation by isWithin\nexport const pastWeek = z.literal('pastWeek');\nexport const pastMonth = z.literal('pastMonth');\nexport const pastYear = z.literal('pastYear');\nexport const nextWeek = z.literal('nextWeek');\nexport const nextMonth = z.literal('nextMonth');\nexport const nextYear = z.literal('nextYear');\nexport const pastNumberOfDays = z.literal('pastNumberOfDays');\nexport const nextNumberOfDays = z.literal('nextNumberOfDays');\n\nexport const operators = z.union([\n  is,\n  isNot,\n  contains,\n  doesNotContain,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isEmpty,\n  isNotEmpty,\n  isAnyOf,\n  isNoneOf,\n  hasAnyOf,\n  hasAllOf,\n  isNotExactly,\n  hasNoneOf,\n  isExactly,\n  isWithIn,\n  isBefore,\n  isAfter,\n  isOnOrBefore,\n  isOnOrAfter,\n]);\nexport type IOperator = z.infer<typeof operators>;\n\nexport const subOperators = z.union([\n  // date sub operation\n  today,\n  tomorrow,\n  yesterday,\n  currentWeek,\n  lastWeek,\n  nextWeekPeriod,\n  currentMonth,\n  lastMonth,\n  nextMonthPeriod,\n  currentYear,\n  lastYear,\n  nextYearPeriod,\n  oneWeekAgo,\n  oneWeekFromNow,\n  oneMonthAgo,\n  oneMonthFromNow,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  dateRange,\n  // date sub operation by isWithin\n  pastWeek,\n  pastMonth,\n  pastYear,\n  nextWeek,\n  nextMonth,\n  nextYear,\n  pastNumberOfDays,\n  nextNumberOfDays,\n]);\nexport type ISubOperator = z.infer<typeof subOperators>;\n\n/*  antlr4ts char  */\nexport const $eq = z.literal('=');\nexport const $neq = z.literal('!=');\nexport const $gt = z.literal('>');\nexport const $gte = z.literal('>=');\nexport const $lt = z.literal('<');\nexport const $lte = z.literal('<=');\nexport const $like = z.literal('LIKE');\nexport const $in = z.literal('IN');\nexport const $has = z.literal('HAS');\nexport const $between = z.literal('BETWEEN');\nexport const $notLike = z.literal('NOT LIKE');\nexport const $notIn = z.literal('NOT IN');\nexport const $isNull = z.literal('IS NULL');\nexport const $isNotNull = z.literal('IS NOT NULL');\nexport const $isWithIn = z.literal('IS WITH IN');\n\nexport const symbols = z.union([\n  $eq,\n  $neq,\n  $gt,\n  $gte,\n  $lt,\n  $lte,\n  $like,\n  $in,\n  $has,\n  $notLike,\n  $notIn,\n  $isNull,\n  $isNotNull,\n]);\nexport type ISymbol = z.infer<typeof symbols>;\n\nconst mappingOperatorSymbol = {\n  [is.value]: $eq.value,\n  [isExactly.value]: $eq.value,\n\n  [isNot.value]: $neq.value,\n\n  [isGreater.value]: $gt.value,\n  [isAfter.value]: $gt.value,\n  [isGreaterEqual.value]: $gte.value,\n  [isOnOrAfter.value]: $gte.value,\n\n  [isLess.value]: $lt.value,\n  [isBefore.value]: $lt.value,\n  [isLessEqual.value]: $lte.value,\n  [isOnOrBefore.value]: $lte.value,\n\n  [contains.value]: $like.value,\n  [doesNotContain.value]: $notLike.value,\n\n  [isAnyOf.value]: $in.value,\n  [hasAnyOf.value]: $in.value,\n  [isNoneOf.value]: $notIn.value,\n  [hasNoneOf.value]: $notIn.value,\n\n  [hasAllOf.value]: $has.value,\n  [isNotExactly.value]: $neq.value,\n\n  // [isWithIn.value]: $between.value,\n\n  [isEmpty.value]: $isNull.value,\n  [isNotEmpty.value]: $isNotNull.value,\n\n  [isWithIn.value]: $isWithIn.value,\n};\n/*  antlr4ts char  */\n\nexport const textFieldOperators = z.union([\n  is,\n  isNot,\n  contains,\n  doesNotContain,\n  isEmpty,\n  isNotEmpty,\n]);\nexport type ITextFieldOperator = z.infer<typeof textFieldOperators>;\nexport const textFieldValidOperators = [\n  is.value,\n  isNot.value,\n  contains.value,\n  doesNotContain.value,\n  isEmpty.value,\n  isNotEmpty.value,\n];\n\nexport const numberFieldOperators = z.union([\n  is,\n  isNot,\n  isGreater,\n  isGreaterEqual,\n  isLess,\n  isLessEqual,\n  isEmpty,\n  isNotEmpty,\n]);\nexport type INumberFieldOperator = z.infer<typeof numberFieldOperators>;\nexport const numberFieldValidOperators = [\n  is.value,\n  isNot.value,\n  isGreater.value,\n  isGreaterEqual.value,\n  isLess.value,\n  isLessEqual.value,\n  isEmpty.value,\n  isNotEmpty.value,\n];\n\nexport const booleanFieldOperators = is;\nexport type IBooleanFieldOperator = z.infer<typeof booleanFieldOperators>;\nexport const booleanFieldValidOperators = [is.value];\n\nexport const dateTimeFieldOperators = z.union([\n  is,\n  isNot,\n  isWithIn,\n  isBefore,\n  isAfter,\n  isOnOrBefore,\n  isOnOrAfter,\n  isEmpty,\n  isNotEmpty,\n]);\nexport type IDateTimeFieldOperator = z.infer<typeof dateTimeFieldOperators>;\nexport const dateTimeFieldValidOperators = [\n  is.value,\n  isNot.value,\n  isWithIn.value,\n  isBefore.value,\n  isAfter.value,\n  isOnOrBefore.value,\n  isOnOrAfter.value,\n  isEmpty.value,\n  isNotEmpty.value,\n];\n\nexport const dateTimeFieldSubOperators = z.union([\n  today,\n  tomorrow,\n  yesterday,\n  currentWeek,\n  lastWeek,\n  nextWeekPeriod,\n  currentMonth,\n  lastMonth,\n  nextMonthPeriod,\n  currentYear,\n  lastYear,\n  nextYearPeriod,\n  oneWeekAgo,\n  oneWeekFromNow,\n  oneMonthAgo,\n  oneMonthFromNow,\n  daysAgo,\n  daysFromNow,\n  exactDate,\n  exactFormatDate,\n  dateRange,\n]);\nexport type IDateTimeFieldSubOperator = z.infer<typeof dateTimeFieldSubOperators>;\nexport const dateTimeFieldValidSubOperators = [\n  today.value,\n  tomorrow.value,\n  yesterday.value,\n  currentWeek.value,\n  lastWeek.value,\n  nextWeekPeriod.value,\n  currentMonth.value,\n  lastMonth.value,\n  nextMonthPeriod.value,\n  currentYear.value,\n  lastYear.value,\n  nextYearPeriod.value,\n  oneWeekAgo.value,\n  oneWeekFromNow.value,\n  oneMonthAgo.value,\n  oneMonthFromNow.value,\n  daysAgo.value,\n  daysFromNow.value,\n  exactDate.value,\n  exactFormatDate.value,\n  dateRange.value,\n];\n\nexport const dateTimeFieldSubOperatorsByIsWithin = z.union([\n  pastWeek,\n  pastMonth,\n  pastYear,\n  nextWeek,\n  nextMonth,\n  nextYear,\n  pastNumberOfDays,\n  nextNumberOfDays,\n]);\nexport type IDateTimeFieldSubOperatorByIsWithin = z.infer<\n  typeof dateTimeFieldSubOperatorsByIsWithin\n>;\nexport const dateTimeFieldValidSubOperatorsByIsWithin = [\n  pastWeek.value,\n  pastMonth.value,\n  pastYear.value,\n  nextWeek.value,\n  nextMonth.value,\n  nextYear.value,\n  pastNumberOfDays.value,\n  nextNumberOfDays.value,\n];\n\nexport function getFilterOperatorMapping(field: FieldCore) {\n  const validFilterOperators = getValidFilterOperators(field);\n\n  return pick(mappingOperatorSymbol, validFilterOperators);\n}\n\n/**\n * Returns the valid filter operators for a given field value type.\n */\nexport function getValidFilterOperators(field: {\n  cellValueType: CellValueType;\n  type: FieldType;\n  isMultipleCellValue?: boolean;\n}): IOperator[] {\n  let operationSet: IOperator[] = [];\n\n  const { cellValueType, type, isMultipleCellValue } = field;\n\n  // 1. First determine the operator roughly according to cellValueType\n  switch (cellValueType) {\n    case CellValueType.String: {\n      operationSet = [...textFieldValidOperators];\n      break;\n    }\n    case CellValueType.Number: {\n      operationSet = [...numberFieldValidOperators];\n      break;\n    }\n    case CellValueType.Boolean: {\n      operationSet = [...booleanFieldValidOperators];\n      break;\n    }\n    case CellValueType.DateTime: {\n      operationSet = [...dateTimeFieldValidOperators];\n      break;\n    }\n  }\n\n  // 2. Then repair the operator according to fieldType\n  switch (type) {\n    case FieldType.SingleSelect: {\n      if (isMultipleCellValue) {\n        operationSet = [\n          hasAnyOf.value,\n          hasAllOf.value,\n          isExactly.value,\n          isNotExactly.value,\n          hasNoneOf.value,\n          isEmpty.value,\n          isNotEmpty.value,\n        ];\n      } else {\n        pullAll(operationSet, [contains.value, doesNotContain.value]);\n        operationSet.splice(2, 0, isAnyOf.value, isNoneOf.value);\n      }\n\n      break;\n    }\n    case FieldType.MultipleSelect: {\n      operationSet = [\n        hasAnyOf.value,\n        hasAllOf.value,\n        isExactly.value,\n        isNotExactly.value,\n        hasNoneOf.value,\n        isEmpty.value,\n        isNotEmpty.value,\n      ];\n      break;\n    }\n    case FieldType.User:\n    case FieldType.CreatedBy:\n    case FieldType.LastModifiedBy:\n    case FieldType.Link: {\n      operationSet = isMultipleCellValue\n        ? [hasAnyOf.value, hasAllOf.value, isExactly.value, hasNoneOf.value, isNotExactly.value]\n        : [is.value, isNot.value, isAnyOf.value, isNoneOf.value];\n\n      const fixLinkOperator = type === FieldType.Link ? [contains.value, doesNotContain.value] : [];\n\n      operationSet = [...operationSet, ...fixLinkOperator, isEmpty.value, isNotEmpty.value];\n      break;\n    }\n    case FieldType.Attachment: {\n      operationSet = [isEmpty.value, isNotEmpty.value];\n      break;\n    }\n  }\n\n  return uniq(operationSet);\n}\n\nexport function getValidFilterSubOperators(\n  fieldType: FieldType,\n  parentOperator: IDateTimeFieldOperator\n): ISubOperator[] | undefined {\n  if (fieldType !== FieldType.Date) {\n    return undefined;\n  }\n\n  if (parentOperator === isWithIn.value) {\n    return dateTimeFieldValidSubOperatorsByIsWithin;\n  }\n\n  // dateRange is only available for 'is' operator\n  if (parentOperator === is.value) {\n    return dateTimeFieldValidSubOperators;\n  }\n\n  return dateTimeFieldValidSubOperators.filter((op) => op !== dateRange.value);\n}\n"
  },
  {
    "path": "packages/core/src/models/view/group/group.ts",
    "content": "import { z } from '../../../zod';\nimport { orderSchema } from '../sort';\n\nexport const groupItemSchema = z.object({\n  fieldId: z.string().meta({\n    description: 'The id of the field.',\n  }),\n  order: orderSchema,\n});\n\nexport const groupSchema = groupItemSchema.array().nullable();\n\nexport const viewGroupRoSchema = z.object({\n  group: groupSchema.nullable(),\n});\n\nexport type IViewGroupRo = z.infer<typeof viewGroupRoSchema>;\n\nexport type IGroupItem = z.infer<typeof groupItemSchema>;\n\nexport type IGroup = z.infer<typeof groupSchema>;\n\nexport const groupStringSchema = z.string().transform((val, ctx) => {\n  let jsonValue;\n  try {\n    jsonValue = JSON.parse(val);\n  } catch {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: 'Invalid JSON string',\n    });\n    return z.NEVER;\n  }\n  return groupSchema.parse(jsonValue);\n});\n\nexport function parseGroup(queryGroup?: IGroup): IGroup | undefined {\n  if (queryGroup == null) return;\n\n  const parsedGroup = groupSchema.safeParse(queryGroup);\n  return parsedGroup.success ? parsedGroup.data?.slice(0, 3) : undefined;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/group/index.ts",
    "content": "export * from './group';\n"
  },
  {
    "path": "packages/core/src/models/view/index.ts",
    "content": "export * from './view.schema';\nexport * from './view';\nexport * from './constant';\nexport * from './derivate';\nexport * from './filter';\nexport * from './sort';\nexport * from './group';\nexport * from './option.schema';\nexport * from './column-meta.schema';\nexport * from './query.replace';\n"
  },
  {
    "path": "packages/core/src/models/view/option.schema.spec.ts",
    "content": "import { ViewType } from './constant';\nimport { validateOptionsType, viewOptionsSchema, type IViewOptions } from './option.schema';\n\ndescribe('view option Parse', () => {\n  it('should parse view option', async () => {\n    const option: IViewOptions = {\n      coverUrl: 'https://www.xxx.com',\n    };\n\n    const parse = viewOptionsSchema.parse(option);\n\n    expect(parse).toEqual(option);\n  });\n});\n\ndescribe('view option validate', () => {\n  test('should throw a error when pass form option to grid view', async () => {\n    const formOption: IViewOptions = {\n      coverUrl: 'https://www.xxx.com',\n    };\n\n    expect(() => validateOptionsType(ViewType.Grid, formOption)).toThrow();\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/view/option.schema.ts",
    "content": "import { z } from '../../zod';\nimport { ViewType } from './constant';\nimport { calendarViewOptionSchema } from './derivate/calendar-view-option.schema';\nimport { formViewOptionSchema } from './derivate/form-view-option.schema';\nimport { galleryViewOptionSchema } from './derivate/gallery-view-option.schema';\nimport { gridViewOptionSchema } from './derivate/grid-view-option.schema';\nimport { kanbanViewOptionSchema } from './derivate/kanban-view-option.schema';\nimport { pluginViewOptionSchema } from './derivate/plugin-view-option.schema';\n\nexport const viewOptionsSchema = z.union([\n  gridViewOptionSchema,\n  kanbanViewOptionSchema,\n  galleryViewOptionSchema,\n  calendarViewOptionSchema,\n  formViewOptionSchema,\n  pluginViewOptionSchema,\n]);\n\nexport type IViewOptions = z.infer<typeof viewOptionsSchema>;\n\n// Re-export for convenience\n\nexport const validateOptionsType = (type: ViewType, optionsString: IViewOptions): string | void => {\n  switch (type) {\n    case ViewType.Grid:\n      gridViewOptionSchema.parse(optionsString);\n      break;\n    case ViewType.Kanban:\n      kanbanViewOptionSchema.parse(optionsString);\n      break;\n    case ViewType.Gallery:\n      galleryViewOptionSchema.parse(optionsString);\n      break;\n    case ViewType.Calendar:\n      calendarViewOptionSchema.parse(optionsString);\n      break;\n    case ViewType.Form:\n      formViewOptionSchema.parse(optionsString);\n      break;\n    case ViewType.Plugin:\n      pluginViewOptionSchema.parse(optionsString);\n      break;\n    default:\n      throw new Error(`Unsupported view type: ${type}`);\n  }\n};\n"
  },
  {
    "path": "packages/core/src/models/view/query.replace.ts",
    "content": "import { cloneDeep } from 'lodash';\nimport type { FieldKeyType } from '../record/record';\nimport type { IFilter, IFilterItem } from './filter';\nimport type { IGroup } from './group';\nimport type { ISortItem } from './sort';\n\n// replace all value in fieldId key with real fieldId\nexport function replaceFilter(\n  filter: IFilter,\n  fieldMap: Record<string, { id: string; name: string; dbFieldName: string }>,\n  to: FieldKeyType\n): IFilter {\n  const traverse = (filterItem: IFilter | IFilterItem) => {\n    if (filterItem && 'fieldId' in filterItem) {\n      // Replace fieldId with real id from fieldMap\n      filterItem.fieldId = fieldMap[filterItem.fieldId]?.[to];\n    } else if (filterItem && 'filterSet' in filterItem) {\n      // Recursively traverse nested filterSet\n      filterItem.filterSet.forEach((item) => traverse(item));\n    }\n  };\n\n  const transformedFilter = cloneDeep(filter);\n\n  traverse(transformedFilter);\n\n  return transformedFilter;\n}\n\nexport function replaceSearch(\n  search: [string] | [string, string] | [string, string, boolean],\n  fieldMap: Record<string, { id: string; name: string; dbFieldName: string }>,\n  to: FieldKeyType\n): [string] | [string, string] | [string, string, boolean] {\n  const [searchValue, fieldKeys, hideNotMatchRow] = search;\n\n  if (!fieldKeys) {\n    return search;\n  }\n\n  const fieldIds = fieldKeys\n    .split(',')\n    .map((key) => fieldMap[key.trim()]?.[to])\n    .join(',');\n\n  return hideNotMatchRow ? [searchValue, fieldIds, hideNotMatchRow] : [searchValue, fieldIds];\n}\n\nexport function replaceGroupBy(\n  groupBy: IGroup,\n  fieldMap: Record<string, { id: string; name: string; dbFieldName: string }>,\n  to: FieldKeyType\n): IGroup {\n  if (!groupBy) {\n    return groupBy;\n  }\n\n  return groupBy.map((item) => ({\n    ...item,\n    fieldId: fieldMap[item.fieldId]?.[to],\n  }));\n}\n\nexport function replaceOrderBy(\n  orderBy: ISortItem[],\n  fieldMap: Record<string, { id: string; name: string; dbFieldName: string }>,\n  to: FieldKeyType\n): ISortItem[] {\n  return orderBy.map((item) => ({\n    ...item,\n    fieldId: fieldMap[item.fieldId]?.[to],\n  }));\n}\n"
  },
  {
    "path": "packages/core/src/models/view/sort/index.ts",
    "content": "export * from './sort';\nexport * from './sort-func.enum';\n"
  },
  {
    "path": "packages/core/src/models/view/sort/sort-func.enum.ts",
    "content": "export enum SortFunc {\n  Asc = 'asc',\n  Desc = 'desc',\n}\n"
  },
  {
    "path": "packages/core/src/models/view/sort/sort.schema.spec.ts",
    "content": "import type { ISort, ISortItem } from './sort';\nimport { sortSchema, mergeWithDefaultSort } from './sort';\nimport { SortFunc } from './sort-func.enum';\n\ndescribe('Sort Parse', () => {\n  it('should parse sort', async () => {\n    const sort: ISort = {\n      sortObjs: [{ fieldId: 'fldxxxxxx', order: SortFunc.Asc }],\n      manualSort: false,\n    };\n\n    const parse = sortSchema.parse(sort);\n\n    expect(parse).toEqual(sort);\n  });\n});\n\ndescribe('Sort mergeWithDefaultSort function test', () => {\n  const defaultViewSortString =\n    '{\"sortObjs\":[{\"fieldId\":\"fld1xxx\",\"order\":\"asc\"}, {\"fieldId\":\"fld2xxx\",\"order\":\"desc\"}],\"manualSort\":true}';\n\n  const querySort: ISortItem[] = [\n    {\n      fieldId: 'fld3xxx',\n      order: SortFunc.Asc,\n    },\n  ];\n\n  const querySort1: ISortItem[] = [\n    {\n      fieldId: 'fld1xxx',\n      order: SortFunc.Desc,\n    },\n  ];\n\n  it('should return empty array, when past meaningless params', async () => {\n    const mergedSort = mergeWithDefaultSort(null, undefined);\n    expect(Array.isArray(mergedSort)).toBe(true);\n    expect(mergedSort.length).toBe(0);\n  });\n\n  it('should return empty array, when manualSort is true with empty sort query', async () => {\n    const mergedSort = mergeWithDefaultSort(defaultViewSortString, undefined);\n    expect(Array.isArray(mergedSort)).toBe(true);\n    expect(mergedSort.length).toBe(0);\n  });\n\n  it('should return merged sort, when sort query exists and no same field items', async () => {\n    const mergedSort = mergeWithDefaultSort(defaultViewSortString, querySort);\n    const presetSort = [\n      ...querySort,\n      { fieldId: 'fld1xxx', order: 'asc' },\n      { fieldId: 'fld2xxx', order: 'desc' },\n    ];\n    expect(mergedSort).toEqual(presetSort);\n  });\n\n  it('should return merged orderby, when sort query include same fieldId items, query first', async () => {\n    const mergedSort = mergeWithDefaultSort(defaultViewSortString, querySort1);\n    const presetSort = [\n      { fieldId: 'fld1xxx', order: 'desc' },\n      { fieldId: 'fld2xxx', order: 'desc' },\n    ];\n    expect(mergedSort).toEqual(presetSort);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/models/view/sort/sort.ts",
    "content": "import { z } from '../../../zod';\nimport { SortFunc } from './sort-func.enum';\n\nexport const orderSchema = z.enum(SortFunc);\n\nexport const sortItemSchema = z.object({\n  fieldId: z.string().meta({\n    description: 'The id of the field.',\n  }),\n  order: orderSchema,\n});\n\nexport const sortSchema = z\n  .object({\n    sortObjs: sortItemSchema.array(),\n    manualSort: z.boolean().optional(),\n  })\n  .nullable();\n\nexport const sortStringSchema = z.string().transform((val, ctx) => {\n  let jsonValue;\n  try {\n    jsonValue = JSON.parse(val);\n  } catch {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: 'Invalid JSON string',\n    });\n    return z.NEVER;\n  }\n  return sortSchema.parse(jsonValue);\n});\n\nexport type ISortItem = z.infer<typeof sortItemSchema>;\n\nexport type ISort = z.infer<typeof sortSchema>;\n\nexport const manualSortRoSchema = z.object({\n  sortObjs: sortItemSchema.array(),\n});\n\nexport type IManualSortRo = z.infer<typeof manualSortRoSchema>;\n\nexport function mergeWithDefaultSort(\n  defaultViewSort?: string | null,\n  querySort?: ISortItem[]\n): ISortItem[] {\n  if (!defaultViewSort && !querySort) {\n    return [];\n  }\n\n  const parseSort = sortStringSchema.safeParse(defaultViewSort);\n\n  const viewSort = parseSort.success ? parseSort.data : undefined;\n\n  // should clear sort query when sort manually\n  if (viewSort?.manualSort && !querySort?.length) {\n    return [];\n  }\n\n  const mergeSort = viewSort?.sortObjs || [];\n\n  if (querySort?.length) {\n    // merge the same fieldId item, query first\n    const map = new Map(querySort.map((sortItem) => [sortItem.fieldId, sortItem]));\n    mergeSort.forEach((sortItem) => {\n      !map.has(sortItem.fieldId) && map.set(sortItem.fieldId, sortItem);\n    });\n    return Array.from(map.values());\n  }\n\n  return mergeSort;\n}\n"
  },
  {
    "path": "packages/core/src/models/view/view.schema.ts",
    "content": "import { IdPrefix } from '../../utils';\nimport { z } from '../../zod';\nimport { columnMetaSchema } from './column-meta.schema';\nimport { ViewType } from './constant';\nimport { calendarViewOptionSchema } from './derivate/calendar-view-option.schema';\nimport { formViewOptionSchema } from './derivate/form-view-option.schema';\nimport { galleryViewOptionSchema } from './derivate/gallery-view-option.schema';\nimport { gridViewOptionSchema } from './derivate/grid-view-option.schema';\nimport { kanbanViewOptionSchema } from './derivate/kanban-view-option.schema';\nimport { pluginViewOptionSchema } from './derivate/plugin-view-option.schema';\nimport { filterSchema } from './filter';\nimport { groupSchema } from './group';\nimport { viewOptionsSchema } from './option.schema';\nimport { sortSchema } from './sort';\n\nexport const sharePasswordSchema = z.string().min(3);\n\nexport const shareViewMetaSchema = z.object({\n  allowCopy: z.boolean().optional(),\n  includeHiddenField: z.boolean().optional(),\n  password: sharePasswordSchema.optional(),\n  includeRecords: z.boolean().optional(),\n  submit: z\n    .object({\n      allow: z.boolean().optional(),\n      requireLogin: z.boolean().optional(),\n    })\n    .optional(),\n});\n\nexport type IShareViewMeta = z.infer<typeof shareViewMetaSchema>;\n\nexport const viewVoSchema = z.object({\n  id: z.string().startsWith(IdPrefix.View),\n  name: z.string(),\n  type: z.enum(ViewType),\n  description: z.string().optional(),\n  order: z.number().optional(),\n  options: viewOptionsSchema.optional(),\n  sort: sortSchema.optional(),\n  filter: filterSchema.optional(),\n  group: groupSchema.optional(),\n  isLocked: z.boolean().optional(),\n  shareId: z.string().optional(),\n  enableShare: z.boolean().optional(),\n  shareMeta: shareViewMetaSchema.optional(),\n  createdBy: z.string(),\n  lastModifiedBy: z.string().optional(),\n  createdTime: z.string(),\n  lastModifiedTime: z.string().optional(),\n  columnMeta: columnMetaSchema.meta({\n    description: 'A mapping of view IDs to their corresponding column metadata.',\n  }),\n  pluginId: z.string().optional(),\n});\n\nexport type IViewVo = z.infer<typeof viewVoSchema>;\n\nexport const viewRoSchema = viewVoSchema\n  .omit({\n    id: true,\n    pluginId: true,\n    createdBy: true,\n    lastModifiedBy: true,\n    createdTime: true,\n    lastModifiedTime: true,\n  })\n  .partial({\n    name: true,\n    order: true,\n    columnMeta: true,\n    isLocked: true,\n  })\n  .superRefine((data, ctx) => {\n    const { type } = data;\n    const optionsSchemaMap = {\n      [ViewType.Form]: formViewOptionSchema,\n      [ViewType.Kanban]: kanbanViewOptionSchema,\n      [ViewType.Gallery]: galleryViewOptionSchema,\n      [ViewType.Calendar]: calendarViewOptionSchema,\n      [ViewType.Grid]: gridViewOptionSchema,\n      [ViewType.Plugin]: pluginViewOptionSchema,\n    } as const;\n    if (!(type in optionsSchemaMap)) {\n      return ctx.addIssue({\n        path: ['options'],\n        code: z.ZodIssueCode.custom,\n        message: `Unknown view type: ${type}`,\n      });\n    }\n    const optionsSchema = optionsSchemaMap[type as keyof typeof optionsSchemaMap];\n    const result =\n      type === ViewType.Plugin\n        ? optionsSchema.safeParse(data.options)\n        : optionsSchema.optional().safeParse(data.options);\n    if (!result.success) {\n      const issue = result.error.issues[0];\n      ctx.addIssue(\n        issue\n          ? { ...issue, path: ['options'] }\n          : {\n              path: ['options'],\n              code: z.ZodIssueCode.custom,\n              message: `${result.error.message}`,\n            }\n      );\n    }\n  });\n\nexport type IViewRo = z.infer<typeof viewRoSchema>;\nexport type IViewPropertyKeys = keyof IViewVo;\nexport const VIEW_JSON_KEYS = ['options', 'sort', 'filter', 'group', 'shareMeta', 'columnMeta'];\n"
  },
  {
    "path": "packages/core/src/models/view/view.ts",
    "content": "import type { IColumnMeta } from './column-meta.schema';\nimport type { ViewType } from './constant';\nimport type { IFilter } from './filter';\nimport type { IGroup } from './group';\nimport type { IViewOptions } from './option.schema';\nimport type { ISort } from './sort';\nimport type { IShareViewMeta, IViewVo } from './view.schema';\n\nexport abstract class ViewCore implements IViewVo {\n  id!: string;\n\n  name!: string;\n\n  abstract type: ViewType;\n\n  description?: string;\n\n  filter?: IFilter;\n\n  sort?: ISort;\n\n  group?: IGroup;\n\n  shareId?: string;\n\n  enableShare?: boolean;\n\n  isLocked?: boolean;\n\n  shareMeta?: IShareViewMeta;\n\n  abstract options: IViewOptions;\n\n  createdBy!: string;\n\n  lastModifiedBy!: string;\n\n  createdTime!: string;\n\n  lastModifiedTime!: string;\n\n  abstract columnMeta: IColumnMeta;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/common.spec.ts",
    "content": "import { pathMatcher } from './common';\n\ndescribe('Common', () => {\n  it('should match path with named parameter and return correct params', () => {\n    const path = ['user', 123];\n    const matchList = ['user', ':id'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toEqual({ id: 123 });\n  });\n\n  it('should not match path with different length', () => {\n    const path = ['user', '123', 'profile'];\n    const matchList = ['user', ':id'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toBeNull();\n  });\n\n  it('should not match path with different values', () => {\n    const path = ['user', '123', 'profile'];\n    const matchList = ['user', ':id', 'settings'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toBeNull();\n  });\n\n  it('should not match path if wildcard (*) is before actual path length', () => {\n    const path = ['user', '123'];\n    const matchList = ['user', '*', 'profile'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toBeNull();\n  });\n\n  it('should not match path if wildcard (*) is more then actual path length', () => {\n    const path = ['user', '123'];\n    const matchList = ['user', '123', '*'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toBeNull();\n  });\n\n  it('should not match path if wildcard (*) is less then actual path length', () => {\n    const path = ['user', '123', 'key'];\n    const matchList = ['user', '*'];\n    const result = pathMatcher<{ id: number }>(path, matchList);\n    expect(result).toBeNull();\n  });\n});\n"
  },
  {
    "path": "packages/core/src/op-builder/common.ts",
    "content": "export enum OpName {\n  AddTable = 'addTable',\n  SetTableProperty = 'setTableProperty',\n\n  SetRecord = 'setRecord',\n  AddRecord = 'addRecord',\n\n  AddField = 'addField',\n  AddColumnMeta = 'addColumnMeta',\n  DeleteColumnMeta = 'deleteColumnMeta',\n  SetFieldProperty = 'setFieldProperty',\n\n  AddView = 'addView',\n  SetViewProperty = 'setViewProperty',\n  UpdateViewColumnMeta = 'updateViewColumnMeta',\n}\n\nexport function pathMatcher<T>(path: (string | number)[], matchList: string[]): T | null {\n  if (path.length !== matchList.length) {\n    return null;\n  }\n\n  const res: Record<string, string | number> = {};\n\n  for (let i = 0; i < matchList.length; i++) {\n    if (matchList[i].startsWith(':')) {\n      const pathKey = matchList[i].slice(1);\n      res[pathKey] = path[i];\n      continue;\n    }\n    if (matchList[i] === '*') {\n      continue;\n    }\n    if (path[i] !== matchList[i]) {\n      return null;\n    }\n  }\n  return res as T;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/field/add-column-meta.spec.ts",
    "content": "import { OpName } from '../common';\nimport { AddColumnMetaBuilder } from './add-column-meta';\n\ndescribe('addColumnMeta', () => {\n  it('should detect add column meta', () => {\n    const addColumnMetaBuilder = new AddColumnMetaBuilder();\n    expect(\n      addColumnMetaBuilder.build({\n        viewId: 'viw123',\n        newMetaValue: { order: 1 },\n        oldMetaValue: { order: 2 },\n      })\n    ).toEqual({\n      p: ['columnMeta', 'viw123'],\n      oi: { order: 1 },\n      od: { order: 2 },\n    });\n\n    expect(\n      addColumnMetaBuilder.detect({\n        p: ['columnMeta', 'viw123'],\n        oi: { order: 1 },\n        od: { order: 2 },\n      })\n    ).toEqual({\n      name: OpName.AddColumnMeta,\n      viewId: 'viw123',\n      newMetaValue: { order: 1 },\n      oldMetaValue: { order: 2 },\n    });\n\n    expect(\n      addColumnMetaBuilder.detect({ p: ['columnMeta', 'viw123'], li: 'new', ld: 'old' })\n    ).toEqual(null);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/op-builder/field/add-column-meta.ts",
    "content": "import type { IColumn, IOtOperation } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\ntype IMetaKey = keyof IColumn;\n\nexport interface IAddColumnMetaOpContext {\n  name: OpName.AddColumnMeta;\n  viewId: string;\n  newMetaValue: { [key: string]: unknown };\n  oldMetaValue?: { [key: string]: unknown };\n}\n\nexport class AddColumnMetaBuilder implements IOpBuilder {\n  name: OpName.AddColumnMeta = OpName.AddColumnMeta;\n\n  build(params: {\n    viewId: string;\n    newMetaValue: { [key: string]: unknown };\n    oldMetaValue?: { [key: string]: unknown };\n  }): IOtOperation {\n    const { viewId, newMetaValue, oldMetaValue } = params;\n\n    return {\n      p: ['columnMeta', viewId],\n      oi: newMetaValue,\n      ...(oldMetaValue ? { od: oldMetaValue } : {}),\n    };\n  }\n\n  detect(op: IOtOperation): IAddColumnMetaOpContext | null {\n    const { p, oi, od } = op;\n\n    if (!oi) {\n      return null;\n    }\n\n    const result = pathMatcher<{ viewId: string; metaKey: IMetaKey }>(p, ['columnMeta', ':viewId']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      viewId: result.viewId,\n      newMetaValue: oi,\n      oldMetaValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/field/add-field.ts",
    "content": "import type { IFieldVo } from '../../models';\nimport { OpName } from '../common';\nimport type { ICreateOpBuilder } from '../interface';\n\nexport class AddFieldBuilder implements ICreateOpBuilder {\n  name: OpName.AddField = OpName.AddField;\n\n  build(field: IFieldVo): IFieldVo {\n    return field;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/field/delete-column-meta.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { IOtOperation } from '../../models';\nimport { OpName } from '../common';\nimport { DeleteColumnMetaBuilder } from './delete-column-meta';\n\ndescribe('DeleteColumnMetaBuilder', () => {\n  let builder: DeleteColumnMetaBuilder;\n\n  beforeEach(() => {\n    builder = new DeleteColumnMetaBuilder();\n  });\n\n  it('should correctly build IOtOperation for deleting column meta', () => {\n    const params = {\n      viewId: 'testView',\n      oldMetaValue: { columnName: 'testColumn' } as any,\n    };\n\n    const result = builder.build(params);\n\n    expect(result).toEqual({\n      p: ['columnMeta', 'testView'],\n      od: { columnName: 'testColumn' },\n    });\n  });\n\n  it('should detect IOtOperation and return IDeleteColumnMetaOpContext', () => {\n    const op: IOtOperation = {\n      p: ['columnMeta', 'testView'],\n      od: { columnName: 'testColumn' },\n    };\n\n    const result = builder.detect(op);\n\n    expect(result).toEqual({\n      name: OpName.DeleteColumnMeta,\n      viewId: 'testView',\n      oldMetaValue: { columnName: 'testColumn' },\n    });\n  });\n\n  it('should return null if IOtOperation has `oi` property', () => {\n    const op: IOtOperation = {\n      p: ['columnMeta', 'testView'],\n      od: { columnName: 'testColumn' },\n      oi: { columnName: 'newColumn' },\n    };\n\n    const result = builder.detect(op);\n\n    expect(result).toBeNull();\n  });\n\n  it('should return null for undetectable IOtOperation', () => {\n    const op: IOtOperation = {\n      p: ['otherPath', 'testView'],\n      od: { columnName: 'testColumn' },\n    };\n\n    const result = builder.detect(op);\n\n    expect(result).toBeNull();\n  });\n});\n"
  },
  {
    "path": "packages/core/src/op-builder/field/delete-column-meta.ts",
    "content": "import type { IColumn, IOtOperation } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface IDeleteColumnMetaOpContext {\n  name: OpName.DeleteColumnMeta;\n  viewId: string;\n  oldMetaValue: IColumn;\n}\n\nexport class DeleteColumnMetaBuilder implements IOpBuilder {\n  name: OpName.DeleteColumnMeta = OpName.DeleteColumnMeta;\n\n  build(params: { viewId: string; oldMetaValue: IColumn }): IOtOperation {\n    const { viewId, oldMetaValue } = params;\n    return {\n      p: ['columnMeta', viewId],\n      od: oldMetaValue,\n    };\n  }\n\n  detect(op: IOtOperation): IDeleteColumnMetaOpContext | null {\n    const { p, od, oi } = op;\n\n    if (!od || oi) {\n      return null;\n    }\n\n    const result = pathMatcher<{ viewId: string }>(p, ['columnMeta', ':viewId']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      viewId: result.viewId,\n      oldMetaValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/field/field-op-builder.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { OpName } from '../common';\nimport { OpBuilderAbstract } from '../op-builder.abstract';\nimport { AddColumnMetaBuilder } from './add-column-meta';\nimport { AddFieldBuilder } from './add-field';\nimport { DeleteColumnMetaBuilder } from './delete-column-meta';\nimport { SetFieldPropertyBuilder } from './set-field-property';\n\nexport class FieldOpBuilder {\n  static editor = {\n    [OpName.AddColumnMeta]: new AddColumnMetaBuilder(),\n    [OpName.DeleteColumnMeta]: new DeleteColumnMetaBuilder(),\n\n    [OpName.SetFieldProperty]: new SetFieldPropertyBuilder(),\n  };\n\n  static creator = new AddFieldBuilder();\n\n  static ops2Contexts = OpBuilderAbstract.ops2Contexts;\n\n  static detect = OpBuilderAbstract.detect;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/field/index.ts",
    "content": "// export all in this folder\nexport * from './field-op-builder';\nexport * from './add-column-meta';\nexport * from './add-field';\nexport * from './delete-column-meta';\nexport * from './set-field-property';\n"
  },
  {
    "path": "packages/core/src/op-builder/field/set-field-property.spec.ts",
    "content": "import { OpName } from '../common';\nimport { SetFieldPropertyBuilder } from './set-field-property';\n\ndescribe('SetFieldProperty', () => {\n  it('should detect field name', () => {\n    const setFieldPropertyBuilder = new SetFieldPropertyBuilder();\n    expect(\n      setFieldPropertyBuilder.build({ key: 'name', newValue: 'new', oldValue: 'old' })\n    ).toEqual({\n      p: ['name'],\n      oi: 'new',\n      od: 'old',\n    });\n\n    expect(setFieldPropertyBuilder.detect({ p: ['name'], oi: 'new', od: 'old' })).toEqual({\n      name: OpName.SetFieldProperty,\n      key: 'name',\n      newValue: 'new',\n      oldValue: 'old',\n    });\n\n    expect(\n      setFieldPropertyBuilder.detect({ p: ['columnMeta', 'view'], oi: 'new', od: 'old' })\n    ).toEqual(null);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/op-builder/field/set-field-property.ts",
    "content": "import type { IFieldPropertyKey, IOtOperation } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface ISetFieldPropertyOpContext {\n  name: OpName.SetFieldProperty;\n  key: IFieldPropertyKey;\n  newValue: unknown;\n  oldValue: unknown;\n}\n\nexport class SetFieldPropertyBuilder implements IOpBuilder {\n  name: OpName.SetFieldProperty = OpName.SetFieldProperty;\n\n  build(params: { key: IFieldPropertyKey; oldValue: unknown; newValue: unknown }): IOtOperation {\n    const { key, newValue, oldValue } = params;\n\n    return {\n      p: [key],\n      ...(newValue == null ? {} : { oi: newValue }),\n      ...(oldValue == null ? {} : { od: oldValue }),\n    };\n  }\n\n  detect(op: IOtOperation): ISetFieldPropertyOpContext | null {\n    const { p, oi, od } = op;\n\n    const result = pathMatcher<Record<string, never>>(p, ['*']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      key: p[0] as IFieldPropertyKey,\n      newValue: oi,\n      oldValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/index.ts",
    "content": "export type { IOpBuilder, ICreateOpBuilder, IOpContextBase } from './interface';\n\nexport * from './view';\nexport * from './field';\nexport * from './record';\nexport * from './table';\n\nexport { OpName } from './common';\n"
  },
  {
    "path": "packages/core/src/op-builder/interface.ts",
    "content": "import type { IOtOperation } from '../models';\nimport type { OpName } from './common';\n\nexport interface IOpContextBase {\n  name: OpName;\n}\n\nexport interface IOpBuilder {\n  name: OpName;\n  // Create an atomic operation\n  build(...params: unknown[]): IOtOperation;\n  // Detect an operation if it is belongs to a specific purpose\n  detect(op: IOtOperation): IOpContextBase | null;\n}\n\nexport interface ICreateOpBuilder {\n  name: OpName;\n  // Create an atomic operation\n  build(...params: unknown[]): unknown;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/op-builder.abstract.ts",
    "content": "import type { IOtOperation } from '../models';\nimport type { IOpBuilder } from './interface';\n\nexport abstract class OpBuilderAbstract {\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  static editor: { [key: string]: IOpBuilder };\n\n  static ops2Contexts(ops: IOtOperation[]) {\n    return ops.map((op) => {\n      const result = this.detect(op);\n      if (!result) {\n        throw new Error(`can't detect op: ${JSON.stringify(op)}`);\n      }\n      return result;\n    });\n  }\n\n  static detect(op: IOtOperation) {\n    for (const builder of Object.values(this.editor)) {\n      const result = builder.detect(op);\n      if (result) {\n        return result;\n      }\n    }\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/record/add-record.ts",
    "content": "import type { IRecord } from '../../models';\nimport { OpName } from '../common';\nimport type { ICreateOpBuilder } from '../interface';\n\nexport class AddRecordBuilder implements ICreateOpBuilder {\n  name: OpName.AddRecord = OpName.AddRecord;\n\n  // you should only build an empty record\n  build(record: IRecord): IRecord {\n    return {\n      id: record.id,\n      fields: {},\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/record/index.ts",
    "content": "export * from './add-record';\nexport * from './record-op-builder';\nexport * from './set-record';\n"
  },
  {
    "path": "packages/core/src/op-builder/record/record-op-builder.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { OpName } from '../common';\nimport { OpBuilderAbstract } from '../op-builder.abstract';\nimport { AddRecordBuilder } from './add-record';\nimport { SetRecordBuilder } from './set-record';\n\nexport class RecordOpBuilder {\n  static editor = {\n    [OpName.SetRecord]: new SetRecordBuilder(),\n  };\n\n  static creator = new AddRecordBuilder();\n\n  static ops2Contexts = OpBuilderAbstract.ops2Contexts;\n\n  static detect = OpBuilderAbstract.detect;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/record/set-record.ts",
    "content": "import type { IOtOperation } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface ISetRecordOpContext {\n  name: OpName.SetRecord;\n  fieldId: string;\n  newCellValue: unknown;\n  oldCellValue: unknown;\n}\n\nexport class SetRecordBuilder implements IOpBuilder {\n  name: OpName.SetRecord = OpName.SetRecord;\n\n  build(params: { fieldId: string; newCellValue: unknown; oldCellValue: unknown }): IOtOperation {\n    const { fieldId } = params;\n    let { newCellValue, oldCellValue } = params;\n    newCellValue = newCellValue ?? null;\n    oldCellValue = oldCellValue ?? null;\n\n    // convert set null to delete key\n    if (newCellValue == null || (Array.isArray(newCellValue) && newCellValue.length === 0)) {\n      return {\n        p: ['fields', fieldId],\n        od: oldCellValue,\n        oi: null,\n      };\n    }\n\n    // convert new cellValue to insert key\n    if (oldCellValue == null) {\n      return {\n        p: ['fields', fieldId],\n        oi: newCellValue,\n      };\n    }\n\n    return {\n      p: ['fields', fieldId],\n      od: oldCellValue,\n      oi: newCellValue,\n    };\n  }\n\n  detect(op: IOtOperation): ISetRecordOpContext | null {\n    const { p, oi, od } = op;\n    const result = pathMatcher<{ fieldId: string }>(p, ['fields', ':fieldId']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      fieldId: result.fieldId,\n      newCellValue: oi,\n      oldCellValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/table/add-table.ts",
    "content": "import { OpName } from '../common';\nimport type { ICreateOpBuilder } from '../interface';\nimport type { ITableOp } from './set-table-property';\n\nexport class AddTableBuilder implements ICreateOpBuilder {\n  name: OpName.AddTable = OpName.AddTable;\n\n  build(table: ITableOp): ITableOp {\n    return table;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/table/index.ts",
    "content": "export * from './add-table';\nexport * from './table-op-builder';\nexport * from './set-table-property';\n"
  },
  {
    "path": "packages/core/src/op-builder/table/set-table-property.ts",
    "content": "import type { IOtOperation } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface ITableOp {\n  name?: string;\n  dbTableName: string;\n  description?: string;\n  icon?: string;\n  order: number;\n  lastModifiedTime?: string;\n}\n\nexport type ITablePropertyKey = keyof ITableOp;\n\nexport interface ISetTablePropertyOpContext {\n  name: OpName.SetTableProperty;\n  key: ITablePropertyKey;\n  newValue: unknown;\n  oldValue: unknown;\n}\n\nexport class SetTablePropertyBuilder implements IOpBuilder {\n  name: OpName.SetTableProperty = OpName.SetTableProperty;\n\n  build(params: { key: ITablePropertyKey; oldValue: unknown; newValue: unknown }): IOtOperation {\n    const { key, newValue, oldValue } = params;\n\n    return {\n      p: [key],\n      ...(newValue == null ? {} : { oi: newValue }),\n      ...(oldValue == null ? {} : { od: oldValue }),\n    };\n  }\n\n  detect(op: IOtOperation): ISetTablePropertyOpContext | null {\n    const { p, oi, od } = op;\n\n    const result = pathMatcher<Record<string, never>>(p, ['*']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      key: p[0] as ITablePropertyKey,\n      newValue: oi,\n      oldValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/table/table-op-builder.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { OpName } from '../common';\nimport { OpBuilderAbstract } from '../op-builder.abstract';\nimport { AddTableBuilder } from './add-table';\nimport { SetTablePropertyBuilder } from './set-table-property';\n\nexport class TableOpBuilder {\n  static editor = {\n    [OpName.SetTableProperty]: new SetTablePropertyBuilder(),\n  };\n\n  static creator = new AddTableBuilder();\n\n  static ops2Contexts = OpBuilderAbstract.ops2Contexts;\n\n  static detect = OpBuilderAbstract.detect;\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/view/add-view.ts",
    "content": "import type { IViewVo } from '../../models';\nimport { OpName } from '../common';\nimport type { ICreateOpBuilder } from '../interface';\n\nexport class AddViewBuilder implements ICreateOpBuilder {\n  name: OpName.AddView = OpName.AddView;\n\n  build(view: IViewVo): IViewVo {\n    return view;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/view/index.ts",
    "content": "export * from './add-view';\nexport * from './view-op-builder';\nexport * from './update-view-column-meta';\nexport * from './set-view-property';\n"
  },
  {
    "path": "packages/core/src/op-builder/view/set-view-property.ts",
    "content": "import type { IOtOperation, IViewPropertyKeys } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface ISetViewPropertyOpContext {\n  name: OpName.SetViewProperty;\n  key: IViewPropertyKeys;\n  newValue?: unknown | null;\n  oldValue?: unknown | null;\n}\n\nexport class SetViewPropertyBuilder implements IOpBuilder {\n  name: OpName.SetViewProperty = OpName.SetViewProperty;\n\n  build(params: {\n    key: IViewPropertyKeys;\n    newValue?: unknown | null;\n    oldValue?: unknown | null;\n  }): IOtOperation {\n    const { key, newValue, oldValue } = params;\n\n    return {\n      p: [key],\n      ...(newValue == null ? {} : { oi: newValue }),\n      ...(oldValue == null ? {} : { od: oldValue }),\n    };\n  }\n\n  detect(op: IOtOperation): ISetViewPropertyOpContext | null {\n    const { p, oi, od } = op;\n\n    const result = pathMatcher<Record<string, never>>(p, ['*']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      key: p[0] as IViewPropertyKeys,\n      newValue: oi,\n      oldValue: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/view/update-view-column-meta.ts",
    "content": "import type { IOtOperation, IColumn } from '../../models';\nimport { OpName, pathMatcher } from '../common';\nimport type { IOpBuilder } from '../interface';\n\nexport interface IUpdateViewColumnMetaOpContext {\n  name: OpName.UpdateViewColumnMeta;\n  fieldId: string;\n  newColumnMeta?: IColumn | null;\n  oldColumnMeta?: IColumn | null;\n}\n\nexport class UpdateViewColumnMetaBuilder implements IOpBuilder {\n  name: OpName.UpdateViewColumnMeta = OpName.UpdateViewColumnMeta;\n\n  build(params: {\n    fieldId: string;\n    newColumnMeta: IColumn | null;\n    oldColumnMeta?: IColumn;\n  }): IOtOperation {\n    const { fieldId, newColumnMeta, oldColumnMeta } = params;\n\n    return {\n      p: ['columnMeta', fieldId],\n      ...(newColumnMeta ? { oi: newColumnMeta } : {}),\n      ...(oldColumnMeta ? { od: oldColumnMeta } : {}),\n    };\n  }\n\n  detect(op: IOtOperation): IUpdateViewColumnMetaOpContext | null {\n    const { p, oi, od } = op;\n\n    const result = pathMatcher<Record<string, never>>(p, ['columnMeta', ':fieldId']);\n\n    if (!result) {\n      return null;\n    }\n\n    return {\n      name: this.name,\n      fieldId: result.fieldId,\n      newColumnMeta: oi,\n      oldColumnMeta: od,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/core/src/op-builder/view/view-op-builder.ts",
    "content": "import type { IOtOperation } from '../../models/op';\nimport { OpName } from '../common';\nimport { AddViewBuilder } from './add-view';\nimport { SetViewPropertyBuilder } from './set-view-property';\nimport { UpdateViewColumnMetaBuilder } from './update-view-column-meta';\n\nexport class ViewOpBuilder {\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  static editor = {\n    [OpName.SetViewProperty]: new SetViewPropertyBuilder(),\n    [OpName.UpdateViewColumnMeta]: new UpdateViewColumnMetaBuilder(),\n  };\n\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  static creator = new AddViewBuilder();\n\n  static ops2Contexts(ops: IOtOperation[]) {\n    return ops.map((op) => {\n      const result = this.detect(op);\n      if (!result) {\n        throw new Error(`can't detect op: ${JSON.stringify(op)}`);\n      }\n      return result;\n    });\n  }\n\n  static detect(op: IOtOperation) {\n    for (const builder of Object.values(this.editor)) {\n      const result = builder.detect(op);\n      if (result) {\n        return result;\n      }\n    }\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/query/index.ts",
    "content": "export * from './json.visitor';\n"
  },
  {
    "path": "packages/core/src/query/json-error.strategy.ts",
    "content": "import { DefaultErrorStrategy } from 'antlr4ts';\nimport type { Parser } from 'antlr4ts/Parser';\nimport type { RecognitionException } from 'antlr4ts/RecognitionException';\n\nexport class JsonErrorStrategy extends DefaultErrorStrategy {\n  reportError(parser: Parser, _recognitionException: RecognitionException) {\n    throw new Error(`expression parsing failure, invalid token: '${parser.currentToken.text}'`);\n  }\n\n  protected reportUnwantedToken(recognizer: Parser) {\n    throw new Error(`unrecognized token: '${recognizer.currentToken.text}'`);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/query/json.visitor.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { parseTQL } from './json.visitor';\n\ndescribe('JsonVisitor', () => {\n  const mockFilterData = (value: any = null, operator = '=') => {\n    return {\n      isSymbol: true,\n      fieldId: 'field',\n      operator: operator,\n      value: value,\n    };\n  };\n  const mockData = (\n    value: {\n      s?: string;\n      sArray?: string[];\n      n?: number;\n      nArray?: number[];\n      b?: boolean;\n      bArray?: boolean[];\n    }[],\n    operator = '=',\n    conjunction = 'and'\n  ) => {\n    const filterSet: any[] = [];\n\n    value.forEach((value1) => {\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      for (const [_, v] of Object.entries(value1)) {\n        filterSet.push(mockFilterData(v, operator));\n      }\n    });\n\n    return {\n      filterSet: filterSet,\n      conjunction: conjunction,\n    };\n  };\n\n  describe('{field} [operators] value', () => {\n    it('should operator `=`', () => {\n      expect(parseTQL(`{field} = '1'`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '=',\n              value: '1',\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `!=`', () => {\n      expect(parseTQL(`{field} != '1'`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              operator: '!=',\n              value: '1',\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n      expect(parseTQL(`{field} <> '1'`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '!=',\n              value: '1',\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `>`', () => {\n      expect(parseTQL(`{field} > 1`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '>',\n              value: 1,\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `>=`', () => {\n      expect(parseTQL(`{field} >= 1`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '>=',\n              value: 1,\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `<`', () => {\n      expect(parseTQL(`{field} < 1`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '<',\n              value: 1,\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `<=`', () => {\n      expect(parseTQL(`{field} <= 1`)).toStrictEqual(\n        expect.objectContaining({\n          filterSet: [\n            expect.objectContaining({\n              isSymbol: true,\n              operator: '<=',\n              value: 1,\n            }),\n          ],\n          conjunction: 'and',\n        })\n      );\n    });\n\n    it('should operator `LIKE`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'LIKE',\n            value: '1%',\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} LIKE '1%'`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} like '1%'`)).toStrictEqual(expected);\n    });\n\n    it('should operator `NOT LIKE`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'NOT LIKE',\n            value: '1%',\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} NOT LIKE '1%'`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} not like '1%'`)).toStrictEqual(expected);\n    });\n\n    it('should operator `IN`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'IN',\n            value: [1, 'a', 3.6, true, null],\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} IN (1,'a', 3.6, true,null)`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} in (1, 'a', 3.6, true, null)`)).toStrictEqual(expected);\n    });\n\n    it('should operator `NOT IN`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'NOT IN',\n            value: [1],\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} NOT IN (1)`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} not In (1)`)).toStrictEqual(expected);\n    });\n\n    it('should operator `HAS`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'HAS',\n            value: [2],\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} HAS (2)`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} has (2)`)).toStrictEqual(expected);\n    });\n\n    it('should operator `IS NULL`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'IS NULL',\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} IS NULL`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} is null`)).toStrictEqual(expected);\n    });\n\n    it('should operator `IS NOT NULL`', () => {\n      const expected = expect.objectContaining({\n        filterSet: [\n          expect.objectContaining({\n            isSymbol: true,\n            operator: 'IS NOT NULL',\n          }),\n        ],\n        conjunction: 'and',\n      });\n\n      expect(parseTQL(`{field} IS NOT NULL`)).toStrictEqual(expected);\n      expect(parseTQL(`{field} is not NUll`)).toStrictEqual(expected);\n    });\n  });\n\n  it('{field} = string', () => {\n    expect(parseTQL(`{field} = '1'`)).toStrictEqual(mockData([{ s: '1' }]));\n    expect(parseTQL(`{field} = 'abc'`)).toStrictEqual(mockData([{ s: 'abc' }]));\n\n    expect(parseTQL(`{field} IN ('a','b', 'c')`)).toStrictEqual(\n      mockData([{ sArray: ['a', 'b', 'c'] }], 'IN')\n    );\n\n    expect(parseTQL(`{field} NOT IN ('a','b', 'c')`)).toStrictEqual(\n      mockData([{ sArray: ['a', 'b', 'c'] }], 'NOT IN')\n    );\n  });\n\n  it('{field} = number', () => {\n    expect(parseTQL(`{field} = 1`)).toStrictEqual(mockData([{ n: 1 }]));\n    expect(parseTQL(`{field} = 1.1`)).toStrictEqual(mockData([{ n: 1.1 }]));\n\n    expect(parseTQL(`{field} IN (2)`)).toStrictEqual(mockData([{ nArray: [2] }], 'IN'));\n    expect(parseTQL(`{field} IN (2.2)`)).toStrictEqual(mockData([{ nArray: [2.2] }], 'IN'));\n\n    expect(parseTQL(`{field} NOT IN (3,4)`)).toStrictEqual(\n      mockData([{ nArray: [3, 4] }], 'NOT IN')\n    );\n    expect(parseTQL(`{field} NOT IN (3.3, 4.4)`)).toStrictEqual(\n      mockData([{ nArray: [3.3, 4.4] }], 'NOT IN')\n    );\n  });\n\n  it('{field} = boolean', () => {\n    expect(parseTQL(`{field} = true`)).toStrictEqual(mockData([{ b: true }]));\n    expect(parseTQL(`{field} = false`)).toStrictEqual(mockData([{ b: false }]));\n\n    expect(parseTQL(`{field} IN (true, false)`)).toStrictEqual(\n      mockData([{ bArray: [true, false] }], 'IN')\n    );\n  });\n\n  it('{field} = any AND {field} = any', () => {\n    expect(parseTQL(`{field} = '1' AND {field} = '2'`)).toStrictEqual(\n      mockData([{ s: '1' }, { s: '2' }])\n    );\n\n    expect(parseTQL(`{field} = 3 AND {field} = '4'`)).toStrictEqual(\n      mockData([{ n: 3 }, { s: '4' }])\n    );\n\n    expect(parseTQL(`{field} = 5.5 AND {field} = true`)).toStrictEqual(\n      mockData([{ n: 5.5 }, { b: true }])\n    );\n\n    expect(parseTQL(`{field} IN ('a','b') AND {field} IN (1, 2.2)`)).toStrictEqual(\n      mockData([{ sArray: ['a', 'b'] }, { nArray: [1, 2.2] }], 'IN')\n    );\n  });\n\n  it('{field} = any AND {field} = any OR {field} = any', () => {\n    const data = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 1,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 2,\n            },\n          ],\n          conjunction: 'and',\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 3,\n        },\n      ],\n      conjunction: 'or',\n    };\n\n    expect(parseTQL('{field} = 1 AND {field} = 2 OR {field} = 3')).toStrictEqual(data);\n\n    expect(parseTQL('({field} = 1 AND {field} = 2) OR {field} = 3')).toStrictEqual(data);\n\n    expect(parseTQL('({field} = 1 AND {field} = 2) OR ({field} = 3)')).toStrictEqual(data);\n  });\n\n  it('({field} = any AND {field} = any) OR ({field} = any AND {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 1,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 2,\n            },\n          ],\n          conjunction: 'and',\n        },\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 3,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 4,\n            },\n          ],\n          conjunction: 'and',\n        },\n      ],\n      conjunction: 'or',\n    };\n\n    expect(\n      parseTQL('({field} = 1 AND {field} = 2) OR ({field} = 3 AND {field} = 4)')\n    ).toStrictEqual(data);\n  });\n\n  it('({field} = any AND {field} = any) OR ({field} = any OR {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 1,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 2,\n            },\n          ],\n          conjunction: 'and',\n        },\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 3,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 4,\n            },\n          ],\n          conjunction: 'or',\n        },\n      ],\n      conjunction: 'or',\n    };\n\n    expect(parseTQL('({field} = 1 AND {field} = 2) OR ({field} = 3 OR {field} = 4)')).toStrictEqual(\n      data\n    );\n  });\n\n  it('({field} = any OR {field} = any) OR ({field} = any OR {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 1,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 2,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 3,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 4,\n        },\n      ],\n      conjunction: 'or',\n    };\n\n    expect(parseTQL('({field} = 1 OR {field} = 2) OR ({field} = 3 OR {field} = 4)')).toStrictEqual(\n      data\n    );\n  });\n\n  it('({field} = any OR {field} = any) AND ({field} = any OR {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 1,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 2,\n            },\n          ],\n          conjunction: 'or',\n        },\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 3,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 4,\n            },\n          ],\n          conjunction: 'or',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    expect(parseTQL('({field} = 1 OR {field} = 2) AND ({field} = 3 OR {field} = 4)')).toStrictEqual(\n      data\n    );\n  });\n\n  it('({field} = any OR {field} = any) AND ({field} = any AND {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 1,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 2,\n            },\n          ],\n          conjunction: 'or',\n        },\n        {\n          filterSet: [\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 3,\n            },\n            {\n              isSymbol: true,\n              fieldId: 'field',\n              operator: '=',\n              value: 4,\n            },\n          ],\n          conjunction: 'and',\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    expect(\n      parseTQL('({field} = 1 OR {field} = 2) AND ({field} = 3 AND {field} = 4)')\n    ).toStrictEqual(data);\n  });\n\n  it('({field} = any AND {field} = any) AND ({field} = any AND {field} = any)', () => {\n    const data = {\n      filterSet: [\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 1,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 2,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 3,\n        },\n        {\n          isSymbol: true,\n          fieldId: 'field',\n          operator: '=',\n          value: 4,\n        },\n      ],\n      conjunction: 'and',\n    };\n\n    expect(\n      parseTQL('({field} = 1 AND {field} = 2) AND ({field} = 3 AND {field} = 4)')\n    ).toStrictEqual(data);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/query/json.visitor.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport { AbstractParseTreeVisitor } from 'antlr4ts/tree';\nimport { JsonErrorStrategy } from './json-error.strategy';\nimport type {\n  BinaryExprContext,\n  BooleanLiteralContext,\n  FieldIdentifierContext,\n  NullLiteralContext,\n  NumberLiteralContext,\n  ParenQueryExprContext,\n  PredicateExprEqArrayContext,\n  PredicateExprHasContext,\n  PredicateExprInContext,\n  PredicateExprLikeContext,\n  PrimaryExprCompareContext,\n  PrimaryExprIsContext,\n  PrimaryExprPredicateContext,\n  QueryExprContext,\n  StartContext,\n  StringLiteralContext,\n  ValueContext,\n} from './parser/Query';\nimport { Query, ValueListContext } from './parser/Query';\nimport { QueryLexer } from './parser/QueryLexer';\nimport type { QueryVisitor } from './parser/QueryVisitor';\n\nexport class JsonVisitor extends AbstractParseTreeVisitor<any> implements QueryVisitor<any> {\n  defaultResult() {\n    return null;\n  }\n\n  visitStart(ctx: StartContext) {\n    let result = this.visit(ctx.expr());\n    if (!result) {\n      return this.defaultResult();\n    }\n\n    // If the result does not contain the filterSet and conjunction properties, then we need to create a new object\n    if (!result.filterSet) {\n      result = {\n        filterSet: [result],\n        conjunction: 'and',\n      };\n    }\n    return result;\n  }\n\n  visitQueryExpr(ctx: QueryExprContext): any {\n    return this.visit(ctx.queryStatement());\n  }\n\n  visitParenQueryExpr(ctx: ParenQueryExprContext): any {\n    return this.visit(ctx.expr());\n  }\n\n  visitBinaryExpr(ctx: BinaryExprContext): any {\n    const operator = ctx?._op?.text?.toLowerCase();\n    const expressions = ctx.expr();\n    let leftExpr = this.visit(expressions[0]);\n    const rightExpr = this.visit(expressions[1]);\n\n    // If the expression is not a filter set, we convert it to a filter set\n    if (!leftExpr.conjunction) {\n      leftExpr = {\n        filterSet: [leftExpr],\n        conjunction: operator,\n      };\n    }\n\n    // If the operator of the current left-hand expression is not the same as the given operator\n    if (leftExpr.conjunction !== operator) {\n      // If inconsistent, create a new object that contains a filter set with the left and right expressions\n      // and set the concatenation of the new object to the given operator\n      leftExpr = {\n        filterSet: [leftExpr, rightExpr],\n        conjunction: operator,\n      };\n    } else if (leftExpr.conjunction === rightExpr.conjunction) {\n      leftExpr.filterSet.push(...rightExpr.filterSet);\n    } else {\n      leftExpr.filterSet.push(rightExpr);\n    }\n\n    return leftExpr;\n  }\n\n  visitFieldIdentifier(ctx: FieldIdentifierContext): any {\n    return ctx.text.replace(/[{}]/g, '');\n  }\n\n  visitPrimaryExprPredicate(ctx: PrimaryExprPredicateContext): any {\n    return this.visit(ctx.predicate());\n  }\n\n  visitPrimaryExprIs(ctx: PrimaryExprIsContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.isOp().text);\n  }\n\n  visitPrimaryExprCompare(ctx: PrimaryExprCompareContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.compOp().text, ctx.value());\n  }\n\n  visitPredicateExprLike(ctx: PredicateExprLikeContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.likeOp().text, ctx.value());\n  }\n\n  visitPredicateExprIn(ctx: PredicateExprInContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.inOp().text, ctx.valueList());\n  }\n\n  visitPredicateExprHas(ctx: PredicateExprHasContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.HAS_SYMBOL().text, ctx.valueList());\n  }\n\n  visitPredicateExprEqArray(ctx: PredicateExprEqArrayContext): any {\n    return this.createResult(ctx.fieldIdentifier(), ctx.EQUAL_OPERATOR().text, ctx.valueList());\n  }\n\n  visitValue(ctx: ValueContext): any {\n    return this.visit(ctx.literal());\n  }\n\n  visitValueList(ctx: ValueListContext): any {\n    return ctx.literal().map((value) => this.visit(value));\n  }\n\n  visitStringLiteral(ctx: StringLiteralContext): any {\n    return ctx.text.slice(1, -1);\n  }\n\n  visitNumberLiteral(ctx: NumberLiteralContext): any {\n    return Number(ctx.text);\n  }\n\n  visitBooleanLiteral(ctx: BooleanLiteralContext): any {\n    return ctx.text.toUpperCase() === 'TRUE';\n  }\n\n  visitNullLiteral(_ctx: NullLiteralContext): any {\n    return null;\n  }\n\n  private createResult(\n    fieldCtx: FieldIdentifierContext,\n    operatorCtx: string,\n    valueCtx?: ValueContext | ValueListContext\n  ) {\n    const fieldId = this.visit(fieldCtx);\n\n    const operator = operatorCtx.toUpperCase() === '<>' ? '!=' : operatorCtx.toUpperCase();\n\n    let value = null;\n    if (valueCtx) {\n      if (valueCtx instanceof ValueListContext) {\n        value = this.visitValueList(valueCtx);\n      } else {\n        value = this.visitValue(valueCtx);\n      }\n    }\n\n    return {\n      isSymbol: true,\n      fieldId: fieldId,\n      operator: operator,\n      value: value,\n    };\n  }\n}\n\n// parse Teable Query Language\nexport const parseTQL = (input: string) => {\n  const inputStream = CharStreams.fromString(input);\n  const lexer = new QueryLexer(inputStream);\n  const tokenStream = new CommonTokenStream(lexer);\n  const parser = new Query(tokenStream);\n\n  parser.errorHandler = new JsonErrorStrategy();\n\n  const tree = parser.start();\n  const visitor = new JsonVisitor();\n  return visitor.visit(tree);\n};\n"
  },
  {
    "path": "packages/core/src/query/parser/Query.g4",
    "content": "parser grammar Query;\n\noptions { tokenVocab=QueryLexer; }\n\nstart :\n    expr EOF\n    ;\n\nexpr\n    : queryStatement                                                    #queryExpr\n    | expr op = (AND_SYMBOL | OR_SYMBOL) expr                           #binaryExpr\n    | OPEN_PAREN expr CLOSE_PAREN                                       #parenQueryExpr\n    ;\n\nqueryStatement:\n    predicate                                                           #primaryExprPredicate\n    | fieldIdentifier isOp                                              #primaryExprIs\n    | fieldIdentifier compOp value                                      #primaryExprCompare\n    ;\n\npredicate:\n    fieldIdentifier likeOp value                                        #predicateExprLike\n    | fieldIdentifier inOp valueList                                    #predicateExprIn\n    | fieldIdentifier HAS_SYMBOL valueList                              #predicateExprHas\n    | fieldIdentifier EQUAL_OPERATOR valueList                          #predicateExprEqArray\n    ;\n\nfieldIdentifier:\n    SIMPLE_IDENTIFIER\n    ;\n\ncompOp:\n    EQUAL_OPERATOR\n    | NOT_EQUAL_OPERATOR\n    | NOT_EQUAL2_OPERATOR\n    | GT_OPERATOR\n    | GTE_OPERATOR\n    | LT_OPERATOR\n    | LTE_OPERATOR\n    ;\n\nisOp:\n    LS_NULL_SYMBOL\n    | LS_NOT_NULL_SYMBOL\n    ;\n\nlikeOp:\n    LIKE_SYMBOL\n    | NOT_LIKE_SYMBOL\n    ;\n\ninOp:\n    IN_SYMBOL\n    | NOT_IN_SYMBOL\n    ;\n\nvalue:\n    literal\n    ;\nvalueList:\n    OPEN_PAREN (literal (COMMA literal)*)? CLOSE_PAREN\n    ;\n\nliteral:\n    stringLiteral\n    | numberLiteral\n    | booleanLiteral\n    | nullLiteral\n    ;\n\nstringLiteral:\n    SINGLEQ_STRING_LITERAL\n    | DOUBLEQ_STRING_LITERAL\n    ;\n\nnumberLiteral:\n    INTEGER_LITERAL\n    | NUMERIC_LITERAL\n    ;\n\nbooleanLiteral:\n    TRUE_SYMBOL\n    | FALSE_SYMBOL\n    ;\n\nnullLiteral:\n    NULL_SYMBOL\n    ;\n"
  },
  {
    "path": "packages/core/src/query/parser/Query.interp",
    "content": "token literal names:\nnull\n','\n'('\n')'\n'['\n']'\n'{'\n'}'\nnull\nnull\nnull\nnull\nnull\n'='\n'!='\n'>'\n'>='\n'<'\n'<='\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\n'<>'\n\ntoken symbolic names:\nnull\nCOMMA\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nSIMPLE_IDENTIFIER\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nINTEGER_LITERAL\nNUMERIC_LITERAL\nEQUAL_OPERATOR\nNOT_EQUAL_OPERATOR\nGT_OPERATOR\nGTE_OPERATOR\nLT_OPERATOR\nLTE_OPERATOR\nTRUE_SYMBOL\nFALSE_SYMBOL\nAND_SYMBOL\nOR_SYMBOL\nNOT_SYMBOL\nNULL_SYMBOL\nIS_SYMBOL\nLS_NULL_SYMBOL\nLS_NOT_NULL_SYMBOL\nLIKE_SYMBOL\nIN_SYMBOL\nHAS_SYMBOL\nNOT_LIKE_SYMBOL\nNOT_IN_SYMBOL\nWHITESPACE\nNOT_EQUAL2_OPERATOR\n\nrule names:\nstart\nexpr\nqueryStatement\npredicate\nfieldIdentifier\ncompOp\nisOp\nlikeOp\ninOp\nvalue\nvalueList\nliteral\nstringLiteral\nnumberLiteral\nbooleanLiteral\nnullLiteral\n\n\natn:\n[3, 51485, 51898, 1421, 44986, 20307, 1543, 60043, 49729, 3, 36, 121, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 3, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 5, 3, 44, 10, 3, 3, 3, 3, 3, 3, 3, 7, 3, 49, 10, 3, 12, 3, 14, 3, 52, 11, 3, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 5, 4, 62, 10, 4, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 5, 5, 80, 10, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 12, 3, 12, 3, 12, 3, 12, 7, 12, 98, 10, 12, 12, 12, 14, 12, 101, 11, 12, 5, 12, 103, 10, 12, 3, 12, 3, 12, 3, 13, 3, 13, 3, 13, 3, 13, 5, 13, 111, 10, 13, 3, 14, 3, 14, 3, 15, 3, 15, 3, 16, 3, 16, 3, 17, 3, 17, 3, 17, 2, 2, 3, 4, 18, 2, 2, 4, 2, 6, 2, 8, 2, 10, 2, 12, 2, 14, 2, 16, 2, 18, 2, 20, 2, 22, 2, 24, 2, 26, 2, 28, 2, 30, 2, 32, 2, 2, 10, 3, 2, 23, 24, 4, 2, 15, 20, 36, 36, 3, 2, 28, 29, 4, 2, 30, 30, 33, 33, 4, 2, 31, 31, 34, 34, 3, 2, 11, 12, 3, 2, 13, 14, 3, 2, 21, 22, 2, 116, 2, 34, 3, 2, 2, 2, 4, 43, 3, 2, 2, 2, 6, 61, 3, 2, 2, 2, 8, 79, 3, 2, 2, 2, 10, 81, 3, 2, 2, 2, 12, 83, 3, 2, 2, 2, 14, 85, 3, 2, 2, 2, 16, 87, 3, 2, 2, 2, 18, 89, 3, 2, 2, 2, 20, 91, 3, 2, 2, 2, 22, 93, 3, 2, 2, 2, 24, 110, 3, 2, 2, 2, 26, 112, 3, 2, 2, 2, 28, 114, 3, 2, 2, 2, 30, 116, 3, 2, 2, 2, 32, 118, 3, 2, 2, 2, 34, 35, 5, 4, 3, 2, 35, 36, 7, 2, 2, 3, 36, 3, 3, 2, 2, 2, 37, 38, 8, 3, 1, 2, 38, 44, 5, 6, 4, 2, 39, 40, 7, 4, 2, 2, 40, 41, 5, 4, 3, 2, 41, 42, 7, 5, 2, 2, 42, 44, 3, 2, 2, 2, 43, 37, 3, 2, 2, 2, 43, 39, 3, 2, 2, 2, 44, 50, 3, 2, 2, 2, 45, 46, 12, 4, 2, 2, 46, 47, 9, 2, 2, 2, 47, 49, 5, 4, 3, 5, 48, 45, 3, 2, 2, 2, 49, 52, 3, 2, 2, 2, 50, 48, 3, 2, 2, 2, 50, 51, 3, 2, 2, 2, 51, 5, 3, 2, 2, 2, 52, 50, 3, 2, 2, 2, 53, 62, 5, 8, 5, 2, 54, 55, 5, 10, 6, 2, 55, 56, 5, 14, 8, 2, 56, 62, 3, 2, 2, 2, 57, 58, 5, 10, 6, 2, 58, 59, 5, 12, 7, 2, 59, 60, 5, 20, 11, 2, 60, 62, 3, 2, 2, 2, 61, 53, 3, 2, 2, 2, 61, 54, 3, 2, 2, 2, 61, 57, 3, 2, 2, 2, 62, 7, 3, 2, 2, 2, 63, 64, 5, 10, 6, 2, 64, 65, 5, 16, 9, 2, 65, 66, 5, 20, 11, 2, 66, 80, 3, 2, 2, 2, 67, 68, 5, 10, 6, 2, 68, 69, 5, 18, 10, 2, 69, 70, 5, 22, 12, 2, 70, 80, 3, 2, 2, 2, 71, 72, 5, 10, 6, 2, 72, 73, 7, 32, 2, 2, 73, 74, 5, 22, 12, 2, 74, 80, 3, 2, 2, 2, 75, 76, 5, 10, 6, 2, 76, 77, 7, 15, 2, 2, 77, 78, 5, 22, 12, 2, 78, 80, 3, 2, 2, 2, 79, 63, 3, 2, 2, 2, 79, 67, 3, 2, 2, 2, 79, 71, 3, 2, 2, 2, 79, 75, 3, 2, 2, 2, 80, 9, 3, 2, 2, 2, 81, 82, 7, 10, 2, 2, 82, 11, 3, 2, 2, 2, 83, 84, 9, 3, 2, 2, 84, 13, 3, 2, 2, 2, 85, 86, 9, 4, 2, 2, 86, 15, 3, 2, 2, 2, 87, 88, 9, 5, 2, 2, 88, 17, 3, 2, 2, 2, 89, 90, 9, 6, 2, 2, 90, 19, 3, 2, 2, 2, 91, 92, 5, 24, 13, 2, 92, 21, 3, 2, 2, 2, 93, 102, 7, 4, 2, 2, 94, 99, 5, 24, 13, 2, 95, 96, 7, 3, 2, 2, 96, 98, 5, 24, 13, 2, 97, 95, 3, 2, 2, 2, 98, 101, 3, 2, 2, 2, 99, 97, 3, 2, 2, 2, 99, 100, 3, 2, 2, 2, 100, 103, 3, 2, 2, 2, 101, 99, 3, 2, 2, 2, 102, 94, 3, 2, 2, 2, 102, 103, 3, 2, 2, 2, 103, 104, 3, 2, 2, 2, 104, 105, 7, 5, 2, 2, 105, 23, 3, 2, 2, 2, 106, 111, 5, 26, 14, 2, 107, 111, 5, 28, 15, 2, 108, 111, 5, 30, 16, 2, 109, 111, 5, 32, 17, 2, 110, 106, 3, 2, 2, 2, 110, 107, 3, 2, 2, 2, 110, 108, 3, 2, 2, 2, 110, 109, 3, 2, 2, 2, 111, 25, 3, 2, 2, 2, 112, 113, 9, 7, 2, 2, 113, 27, 3, 2, 2, 2, 114, 115, 9, 8, 2, 2, 115, 29, 3, 2, 2, 2, 116, 117, 9, 9, 2, 2, 117, 31, 3, 2, 2, 2, 118, 119, 7, 26, 2, 2, 119, 33, 3, 2, 2, 2, 9, 43, 50, 61, 79, 99, 102, 110]"
  },
  {
    "path": "packages/core/src/query/parser/Query.tokens",
    "content": "COMMA=1\nOPEN_PAREN=2\nCLOSE_PAREN=3\nOPEN_BRACKET=4\nCLOSE_BRACKET=5\nL_CURLY=6\nR_CURLY=7\nSIMPLE_IDENTIFIER=8\nSINGLEQ_STRING_LITERAL=9\nDOUBLEQ_STRING_LITERAL=10\nINTEGER_LITERAL=11\nNUMERIC_LITERAL=12\nEQUAL_OPERATOR=13\nNOT_EQUAL_OPERATOR=14\nGT_OPERATOR=15\nGTE_OPERATOR=16\nLT_OPERATOR=17\nLTE_OPERATOR=18\nTRUE_SYMBOL=19\nFALSE_SYMBOL=20\nAND_SYMBOL=21\nOR_SYMBOL=22\nNOT_SYMBOL=23\nNULL_SYMBOL=24\nIS_SYMBOL=25\nLS_NULL_SYMBOL=26\nLS_NOT_NULL_SYMBOL=27\nLIKE_SYMBOL=28\nIN_SYMBOL=29\nHAS_SYMBOL=30\nNOT_LIKE_SYMBOL=31\nNOT_IN_SYMBOL=32\nWHITESPACE=33\nNOT_EQUAL2_OPERATOR=34\n','=1\n'('=2\n')'=3\n'['=4\n']'=5\n'{'=6\n'}'=7\n'='=13\n'!='=14\n'<>'=34\n'>'=15\n'>='=16\n'<'=17\n'<='=18\n"
  },
  {
    "path": "packages/core/src/query/parser/Query.ts",
    "content": "// Generated from src/query/parser/Query.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport { ATN } from 'antlr4ts/atn/ATN';\nimport { ATNDeserializer } from 'antlr4ts/atn/ATNDeserializer';\nimport { ParserATNSimulator } from 'antlr4ts/atn/ParserATNSimulator';\nimport { NotNull, Override } from 'antlr4ts/Decorators';\nimport { FailedPredicateException } from 'antlr4ts/FailedPredicateException';\nimport * as Utils from 'antlr4ts/misc/Utils';\nimport { NoViableAltException } from 'antlr4ts/NoViableAltException';\nimport { Parser } from 'antlr4ts/Parser';\nimport { ParserRuleContext } from 'antlr4ts/ParserRuleContext';\nimport { RecognitionException } from 'antlr4ts/RecognitionException';\nimport type { RuleContext } from 'antlr4ts/RuleContext';\nimport { Token } from 'antlr4ts/Token';\nimport type { TokenStream } from 'antlr4ts/TokenStream';\nimport { ParseTreeListener } from 'antlr4ts/tree/ParseTreeListener';\nimport { ParseTreeVisitor } from 'antlr4ts/tree/ParseTreeVisitor';\n// import { RuleVersion } from \"antlr4ts/RuleVersion\";\nimport type { TerminalNode } from 'antlr4ts/tree/TerminalNode';\nimport type { Vocabulary } from 'antlr4ts/Vocabulary';\nimport { VocabularyImpl } from 'antlr4ts/VocabularyImpl';\n\nimport type { QueryVisitor } from './QueryVisitor';\n\nexport class Query extends Parser {\n  public static readonly COMMA = 1;\n  public static readonly OPEN_PAREN = 2;\n  public static readonly CLOSE_PAREN = 3;\n  public static readonly OPEN_BRACKET = 4;\n  public static readonly CLOSE_BRACKET = 5;\n  public static readonly L_CURLY = 6;\n  public static readonly R_CURLY = 7;\n  public static readonly SIMPLE_IDENTIFIER = 8;\n  public static readonly SINGLEQ_STRING_LITERAL = 9;\n  public static readonly DOUBLEQ_STRING_LITERAL = 10;\n  public static readonly INTEGER_LITERAL = 11;\n  public static readonly NUMERIC_LITERAL = 12;\n  public static readonly EQUAL_OPERATOR = 13;\n  public static readonly NOT_EQUAL_OPERATOR = 14;\n  public static readonly GT_OPERATOR = 15;\n  public static readonly GTE_OPERATOR = 16;\n  public static readonly LT_OPERATOR = 17;\n  public static readonly LTE_OPERATOR = 18;\n  public static readonly TRUE_SYMBOL = 19;\n  public static readonly FALSE_SYMBOL = 20;\n  public static readonly AND_SYMBOL = 21;\n  public static readonly OR_SYMBOL = 22;\n  public static readonly NOT_SYMBOL = 23;\n  public static readonly NULL_SYMBOL = 24;\n  public static readonly IS_SYMBOL = 25;\n  public static readonly LS_NULL_SYMBOL = 26;\n  public static readonly LS_NOT_NULL_SYMBOL = 27;\n  public static readonly LIKE_SYMBOL = 28;\n  public static readonly IN_SYMBOL = 29;\n  public static readonly HAS_SYMBOL = 30;\n  public static readonly NOT_LIKE_SYMBOL = 31;\n  public static readonly NOT_IN_SYMBOL = 32;\n  public static readonly WHITESPACE = 33;\n  public static readonly NOT_EQUAL2_OPERATOR = 34;\n  public static readonly RULE_start = 0;\n  public static readonly RULE_expr = 1;\n  public static readonly RULE_queryStatement = 2;\n  public static readonly RULE_predicate = 3;\n  public static readonly RULE_fieldIdentifier = 4;\n  public static readonly RULE_compOp = 5;\n  public static readonly RULE_isOp = 6;\n  public static readonly RULE_likeOp = 7;\n  public static readonly RULE_inOp = 8;\n  public static readonly RULE_value = 9;\n  public static readonly RULE_valueList = 10;\n  public static readonly RULE_literal = 11;\n  public static readonly RULE_stringLiteral = 12;\n  public static readonly RULE_numberLiteral = 13;\n  public static readonly RULE_booleanLiteral = 14;\n  public static readonly RULE_nullLiteral = 15;\n  public static readonly ruleNames: string[] = [\n    'start',\n    'expr',\n    'queryStatement',\n    'predicate',\n    'fieldIdentifier',\n    'compOp',\n    'isOp',\n    'likeOp',\n    'inOp',\n    'value',\n    'valueList',\n    'literal',\n    'stringLiteral',\n    'numberLiteral',\n    'booleanLiteral',\n    'nullLiteral',\n  ];\n\n  private static readonly _LITERAL_NAMES: Array<string | undefined> = [\n    undefined,\n    \"','\",\n    \"'('\",\n    \"')'\",\n    \"'['\",\n    \"']'\",\n    \"'{'\",\n    \"'}'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'='\",\n    \"'!='\",\n    \"'>'\",\n    \"'>='\",\n    \"'<'\",\n    \"'<='\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'<>'\",\n  ];\n  private static readonly _SYMBOLIC_NAMES: Array<string | undefined> = [\n    undefined,\n    'COMMA',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'SIMPLE_IDENTIFIER',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'INTEGER_LITERAL',\n    'NUMERIC_LITERAL',\n    'EQUAL_OPERATOR',\n    'NOT_EQUAL_OPERATOR',\n    'GT_OPERATOR',\n    'GTE_OPERATOR',\n    'LT_OPERATOR',\n    'LTE_OPERATOR',\n    'TRUE_SYMBOL',\n    'FALSE_SYMBOL',\n    'AND_SYMBOL',\n    'OR_SYMBOL',\n    'NOT_SYMBOL',\n    'NULL_SYMBOL',\n    'IS_SYMBOL',\n    'LS_NULL_SYMBOL',\n    'LS_NOT_NULL_SYMBOL',\n    'LIKE_SYMBOL',\n    'IN_SYMBOL',\n    'HAS_SYMBOL',\n    'NOT_LIKE_SYMBOL',\n    'NOT_IN_SYMBOL',\n    'WHITESPACE',\n    'NOT_EQUAL2_OPERATOR',\n  ];\n  public static readonly VOCABULARY: Vocabulary = new VocabularyImpl(\n    Query._LITERAL_NAMES,\n    Query._SYMBOLIC_NAMES,\n    []\n  );\n\n  // @Override\n  // @NotNull\n  public get vocabulary(): Vocabulary {\n    return Query.VOCABULARY;\n  }\n\n  // @Override\n  public get grammarFileName(): string {\n    return 'Query.g4';\n  }\n\n  // @Override\n  public get ruleNames(): string[] {\n    return Query.ruleNames;\n  }\n\n  // @Override\n  public get serializedATN(): string {\n    return Query._serializedATN;\n  }\n\n  protected createFailedPredicateException(\n    predicate?: string,\n    message?: string\n  ): FailedPredicateException {\n    return new FailedPredicateException(this, predicate, message);\n  }\n\n  constructor(input: TokenStream) {\n    super(input);\n    this._interp = new ParserATNSimulator(Query._ATN, this);\n  }\n  // @RuleVersion(0)\n  public start(): StartContext {\n    const _localctx: StartContext = new StartContext(this._ctx, this.state);\n    this.enterRule(_localctx, 0, Query.RULE_start);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 32;\n        this.expr(0);\n        this.state = 33;\n        this.match(Query.EOF);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n\n  public expr(): ExprContext;\n  public expr(_p: number): ExprContext;\n  // @RuleVersion(0)\n  public expr(_p?: number): ExprContext {\n    if (_p === undefined) {\n      _p = 0;\n    }\n\n    const _parentctx: ParserRuleContext = this._ctx;\n    const _parentState: number = this.state;\n    let _localctx: ExprContext = new ExprContext(this._ctx, _parentState);\n    let _prevctx: ExprContext = _localctx;\n    const _startState = 2;\n    this.enterRecursionRule(_localctx, 2, Query.RULE_expr, _p);\n    let _la: number;\n    try {\n      let _alt: number;\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 41;\n        this._errHandler.sync(this);\n        switch (this._input.LA(1)) {\n          case Query.SIMPLE_IDENTIFIER:\n            {\n              _localctx = new QueryExprContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n\n              this.state = 36;\n              this.queryStatement();\n            }\n            break;\n          case Query.OPEN_PAREN:\n            {\n              _localctx = new ParenQueryExprContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 37;\n              this.match(Query.OPEN_PAREN);\n              this.state = 38;\n              this.expr(0);\n              this.state = 39;\n              this.match(Query.CLOSE_PAREN);\n            }\n            break;\n          default:\n            throw new NoViableAltException(this);\n        }\n        this._ctx._stop = this._input.tryLT(-1);\n        this.state = 48;\n        this._errHandler.sync(this);\n        _alt = this.interpreter.adaptivePredict(this._input, 1, this._ctx);\n        while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) {\n          if (_alt === 1) {\n            if (this._parseListeners != null) {\n              this.triggerExitRuleEvent();\n            }\n            _prevctx = _localctx;\n            {\n              {\n                _localctx = new BinaryExprContext(new ExprContext(_parentctx, _parentState));\n                this.pushNewRecursionContext(_localctx, _startState, Query.RULE_expr);\n                this.state = 43;\n                if (!this.precpred(this._ctx, 2)) {\n                  throw this.createFailedPredicateException('this.precpred(this._ctx, 2)');\n                }\n                this.state = 44;\n                (_localctx as BinaryExprContext)._op = this._input.LT(1);\n                _la = this._input.LA(1);\n                if (!(_la === Query.AND_SYMBOL || _la === Query.OR_SYMBOL)) {\n                  (_localctx as BinaryExprContext)._op = this._errHandler.recoverInline(this);\n                } else {\n                  if (this._input.LA(1) === Token.EOF) {\n                    this.matchedEOF = true;\n                  }\n\n                  this._errHandler.reportMatch(this);\n                  this.consume();\n                }\n                this.state = 45;\n                this.expr(3);\n              }\n            }\n          }\n          this.state = 50;\n          this._errHandler.sync(this);\n          _alt = this.interpreter.adaptivePredict(this._input, 1, this._ctx);\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.unrollRecursionContexts(_parentctx);\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public queryStatement(): QueryStatementContext {\n    let _localctx: QueryStatementContext = new QueryStatementContext(this._ctx, this.state);\n    this.enterRule(_localctx, 4, Query.RULE_queryStatement);\n    try {\n      this.state = 59;\n      this._errHandler.sync(this);\n      switch (this.interpreter.adaptivePredict(this._input, 2, this._ctx)) {\n        case 1:\n          _localctx = new PrimaryExprPredicateContext(_localctx);\n          this.enterOuterAlt(_localctx, 1);\n          {\n            this.state = 51;\n            this.predicate();\n          }\n          break;\n\n        case 2:\n          _localctx = new PrimaryExprIsContext(_localctx);\n          this.enterOuterAlt(_localctx, 2);\n          {\n            this.state = 52;\n            this.fieldIdentifier();\n            this.state = 53;\n            this.isOp();\n          }\n          break;\n\n        case 3:\n          _localctx = new PrimaryExprCompareContext(_localctx);\n          this.enterOuterAlt(_localctx, 3);\n          {\n            this.state = 55;\n            this.fieldIdentifier();\n            this.state = 56;\n            this.compOp();\n            this.state = 57;\n            this.value();\n          }\n          break;\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public predicate(): PredicateContext {\n    let _localctx: PredicateContext = new PredicateContext(this._ctx, this.state);\n    this.enterRule(_localctx, 6, Query.RULE_predicate);\n    try {\n      this.state = 77;\n      this._errHandler.sync(this);\n      switch (this.interpreter.adaptivePredict(this._input, 3, this._ctx)) {\n        case 1:\n          _localctx = new PredicateExprLikeContext(_localctx);\n          this.enterOuterAlt(_localctx, 1);\n          {\n            this.state = 61;\n            this.fieldIdentifier();\n            this.state = 62;\n            this.likeOp();\n            this.state = 63;\n            this.value();\n          }\n          break;\n\n        case 2:\n          _localctx = new PredicateExprInContext(_localctx);\n          this.enterOuterAlt(_localctx, 2);\n          {\n            this.state = 65;\n            this.fieldIdentifier();\n            this.state = 66;\n            this.inOp();\n            this.state = 67;\n            this.valueList();\n          }\n          break;\n\n        case 3:\n          _localctx = new PredicateExprHasContext(_localctx);\n          this.enterOuterAlt(_localctx, 3);\n          {\n            this.state = 69;\n            this.fieldIdentifier();\n            this.state = 70;\n            this.match(Query.HAS_SYMBOL);\n            this.state = 71;\n            this.valueList();\n          }\n          break;\n\n        case 4:\n          _localctx = new PredicateExprEqArrayContext(_localctx);\n          this.enterOuterAlt(_localctx, 4);\n          {\n            this.state = 73;\n            this.fieldIdentifier();\n            this.state = 74;\n            this.match(Query.EQUAL_OPERATOR);\n            this.state = 75;\n            this.valueList();\n          }\n          break;\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public fieldIdentifier(): FieldIdentifierContext {\n    const _localctx: FieldIdentifierContext = new FieldIdentifierContext(this._ctx, this.state);\n    this.enterRule(_localctx, 8, Query.RULE_fieldIdentifier);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 79;\n        this.match(Query.SIMPLE_IDENTIFIER);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public compOp(): CompOpContext {\n    const _localctx: CompOpContext = new CompOpContext(this._ctx, this.state);\n    this.enterRule(_localctx, 10, Query.RULE_compOp);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 81;\n        _la = this._input.LA(1);\n        if (\n          !(\n            ((_la - 13) & ~0x1f) === 0 &&\n            ((1 << (_la - 13)) &\n              ((1 << (Query.EQUAL_OPERATOR - 13)) |\n                (1 << (Query.NOT_EQUAL_OPERATOR - 13)) |\n                (1 << (Query.GT_OPERATOR - 13)) |\n                (1 << (Query.GTE_OPERATOR - 13)) |\n                (1 << (Query.LT_OPERATOR - 13)) |\n                (1 << (Query.LTE_OPERATOR - 13)) |\n                (1 << (Query.NOT_EQUAL2_OPERATOR - 13)))) !==\n              0\n          )\n        ) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public isOp(): IsOpContext {\n    const _localctx: IsOpContext = new IsOpContext(this._ctx, this.state);\n    this.enterRule(_localctx, 12, Query.RULE_isOp);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 83;\n        _la = this._input.LA(1);\n        if (!(_la === Query.LS_NULL_SYMBOL || _la === Query.LS_NOT_NULL_SYMBOL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public likeOp(): LikeOpContext {\n    const _localctx: LikeOpContext = new LikeOpContext(this._ctx, this.state);\n    this.enterRule(_localctx, 14, Query.RULE_likeOp);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 85;\n        _la = this._input.LA(1);\n        if (!(_la === Query.LIKE_SYMBOL || _la === Query.NOT_LIKE_SYMBOL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public inOp(): InOpContext {\n    const _localctx: InOpContext = new InOpContext(this._ctx, this.state);\n    this.enterRule(_localctx, 16, Query.RULE_inOp);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 87;\n        _la = this._input.LA(1);\n        if (!(_la === Query.IN_SYMBOL || _la === Query.NOT_IN_SYMBOL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public value(): ValueContext {\n    const _localctx: ValueContext = new ValueContext(this._ctx, this.state);\n    this.enterRule(_localctx, 18, Query.RULE_value);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 89;\n        this.literal();\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public valueList(): ValueListContext {\n    const _localctx: ValueListContext = new ValueListContext(this._ctx, this.state);\n    this.enterRule(_localctx, 20, Query.RULE_valueList);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 91;\n        this.match(Query.OPEN_PAREN);\n        this.state = 100;\n        this._errHandler.sync(this);\n        _la = this._input.LA(1);\n        if (\n          (_la & ~0x1f) === 0 &&\n          ((1 << _la) &\n            ((1 << Query.SINGLEQ_STRING_LITERAL) |\n              (1 << Query.DOUBLEQ_STRING_LITERAL) |\n              (1 << Query.INTEGER_LITERAL) |\n              (1 << Query.NUMERIC_LITERAL) |\n              (1 << Query.TRUE_SYMBOL) |\n              (1 << Query.FALSE_SYMBOL) |\n              (1 << Query.NULL_SYMBOL))) !==\n            0\n        ) {\n          {\n            this.state = 92;\n            this.literal();\n            this.state = 97;\n            this._errHandler.sync(this);\n            _la = this._input.LA(1);\n            while (_la === Query.COMMA) {\n              {\n                {\n                  this.state = 93;\n                  this.match(Query.COMMA);\n                  this.state = 94;\n                  this.literal();\n                }\n              }\n              this.state = 99;\n              this._errHandler.sync(this);\n              _la = this._input.LA(1);\n            }\n          }\n        }\n\n        this.state = 102;\n        this.match(Query.CLOSE_PAREN);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public literal(): LiteralContext {\n    const _localctx: LiteralContext = new LiteralContext(this._ctx, this.state);\n    this.enterRule(_localctx, 22, Query.RULE_literal);\n    try {\n      this.state = 108;\n      this._errHandler.sync(this);\n      switch (this._input.LA(1)) {\n        case Query.SINGLEQ_STRING_LITERAL:\n        case Query.DOUBLEQ_STRING_LITERAL:\n          this.enterOuterAlt(_localctx, 1);\n          {\n            this.state = 104;\n            this.stringLiteral();\n          }\n          break;\n        case Query.INTEGER_LITERAL:\n        case Query.NUMERIC_LITERAL:\n          this.enterOuterAlt(_localctx, 2);\n          {\n            this.state = 105;\n            this.numberLiteral();\n          }\n          break;\n        case Query.TRUE_SYMBOL:\n        case Query.FALSE_SYMBOL:\n          this.enterOuterAlt(_localctx, 3);\n          {\n            this.state = 106;\n            this.booleanLiteral();\n          }\n          break;\n        case Query.NULL_SYMBOL:\n          this.enterOuterAlt(_localctx, 4);\n          {\n            this.state = 107;\n            this.nullLiteral();\n          }\n          break;\n        default:\n          throw new NoViableAltException(this);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public stringLiteral(): StringLiteralContext {\n    const _localctx: StringLiteralContext = new StringLiteralContext(this._ctx, this.state);\n    this.enterRule(_localctx, 24, Query.RULE_stringLiteral);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 110;\n        _la = this._input.LA(1);\n        if (!(_la === Query.SINGLEQ_STRING_LITERAL || _la === Query.DOUBLEQ_STRING_LITERAL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public numberLiteral(): NumberLiteralContext {\n    const _localctx: NumberLiteralContext = new NumberLiteralContext(this._ctx, this.state);\n    this.enterRule(_localctx, 26, Query.RULE_numberLiteral);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 112;\n        _la = this._input.LA(1);\n        if (!(_la === Query.INTEGER_LITERAL || _la === Query.NUMERIC_LITERAL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public booleanLiteral(): BooleanLiteralContext {\n    const _localctx: BooleanLiteralContext = new BooleanLiteralContext(this._ctx, this.state);\n    this.enterRule(_localctx, 28, Query.RULE_booleanLiteral);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 114;\n        _la = this._input.LA(1);\n        if (!(_la === Query.TRUE_SYMBOL || _la === Query.FALSE_SYMBOL)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public nullLiteral(): NullLiteralContext {\n    const _localctx: NullLiteralContext = new NullLiteralContext(this._ctx, this.state);\n    this.enterRule(_localctx, 30, Query.RULE_nullLiteral);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 116;\n        this.match(Query.NULL_SYMBOL);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n\n  public sempred(_localctx: RuleContext, ruleIndex: number, predIndex: number): boolean {\n    switch (ruleIndex) {\n      case 1:\n        return this.expr_sempred(_localctx as ExprContext, predIndex);\n    }\n    return true;\n  }\n  private expr_sempred(_localctx: ExprContext, predIndex: number): boolean {\n    switch (predIndex) {\n      case 0:\n        return this.precpred(this._ctx, 2);\n    }\n    return true;\n  }\n\n  public static readonly _serializedATN: string =\n    '\\x03\\uC91D\\uCABA\\u058D\\uAFBA\\u4F53\\u0607\\uEA8B\\uC241\\x03$y\\x04\\x02\\t\\x02' +\n    '\\x04\\x03\\t\\x03\\x04\\x04\\t\\x04\\x04\\x05\\t\\x05\\x04\\x06\\t\\x06\\x04\\x07\\t\\x07' +\n    '\\x04\\b\\t\\b\\x04\\t\\t\\t\\x04\\n\\t\\n\\x04\\v\\t\\v\\x04\\f\\t\\f\\x04\\r\\t\\r\\x04\\x0E\\t' +\n    '\\x0E\\x04\\x0F\\t\\x0F\\x04\\x10\\t\\x10\\x04\\x11\\t\\x11\\x03\\x02\\x03\\x02\\x03\\x02' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x05\\x03,\\n\\x03\\x03\\x03' +\n    '\\x03\\x03\\x03\\x03\\x07\\x031\\n\\x03\\f\\x03\\x0E\\x034\\v\\x03\\x03\\x04\\x03\\x04\\x03' +\n    '\\x04\\x03\\x04\\x03\\x04\\x03\\x04\\x03\\x04\\x03\\x04\\x05\\x04>\\n\\x04\\x03\\x05\\x03' +\n    '\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03' +\n    '\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x03\\x05\\x05\\x05P\\n\\x05\\x03\\x06\\x03' +\n    '\\x06\\x03\\x07\\x03\\x07\\x03\\b\\x03\\b\\x03\\t\\x03\\t\\x03\\n\\x03\\n\\x03\\v\\x03\\v\\x03' +\n    '\\f\\x03\\f\\x03\\f\\x03\\f\\x07\\fb\\n\\f\\f\\f\\x0E\\fe\\v\\f\\x05\\fg\\n\\f\\x03\\f\\x03\\f' +\n    '\\x03\\r\\x03\\r\\x03\\r\\x03\\r\\x05\\ro\\n\\r\\x03\\x0E\\x03\\x0E\\x03\\x0F\\x03\\x0F\\x03' +\n    '\\x10\\x03\\x10\\x03\\x11\\x03\\x11\\x03\\x11\\x02\\x02\\x03\\x04\\x12\\x02\\x02\\x04\\x02' +\n    '\\x06\\x02\\b\\x02\\n\\x02\\f\\x02\\x0E\\x02\\x10\\x02\\x12\\x02\\x14\\x02\\x16\\x02\\x18' +\n    '\\x02\\x1A\\x02\\x1C\\x02\\x1E\\x02 \\x02\\x02\\n\\x03\\x02\\x17\\x18\\x04\\x02\\x0F\\x14' +\n    '$$\\x03\\x02\\x1C\\x1D\\x04\\x02\\x1E\\x1E!!\\x04\\x02\\x1F\\x1F\"\"\\x03\\x02\\v\\f\\x03' +\n    '\\x02\\r\\x0E\\x03\\x02\\x15\\x16\\x02t\\x02\"\\x03\\x02\\x02\\x02\\x04+\\x03\\x02\\x02' +\n    '\\x02\\x06=\\x03\\x02\\x02\\x02\\bO\\x03\\x02\\x02\\x02\\nQ\\x03\\x02\\x02\\x02\\fS\\x03' +\n    '\\x02\\x02\\x02\\x0EU\\x03\\x02\\x02\\x02\\x10W\\x03\\x02\\x02\\x02\\x12Y\\x03\\x02\\x02' +\n    '\\x02\\x14[\\x03\\x02\\x02\\x02\\x16]\\x03\\x02\\x02\\x02\\x18n\\x03\\x02\\x02\\x02\\x1A' +\n    'p\\x03\\x02\\x02\\x02\\x1Cr\\x03\\x02\\x02\\x02\\x1Et\\x03\\x02\\x02\\x02 v\\x03\\x02' +\n    '\\x02\\x02\"#\\x05\\x04\\x03\\x02#$\\x07\\x02\\x02\\x03$\\x03\\x03\\x02\\x02\\x02%&\\b' +\n    \"\\x03\\x01\\x02&,\\x05\\x06\\x04\\x02'(\\x07\\x04\\x02\\x02()\\x05\\x04\\x03\\x02)*\" +\n    \"\\x07\\x05\\x02\\x02*,\\x03\\x02\\x02\\x02+%\\x03\\x02\\x02\\x02+'\\x03\\x02\\x02\\x02\" +\n    ',2\\x03\\x02\\x02\\x02-.\\f\\x04\\x02\\x02./\\t\\x02\\x02\\x02/1\\x05\\x04\\x03\\x050' +\n    '-\\x03\\x02\\x02\\x0214\\x03\\x02\\x02\\x0220\\x03\\x02\\x02\\x0223\\x03\\x02\\x02\\x02' +\n    '3\\x05\\x03\\x02\\x02\\x0242\\x03\\x02\\x02\\x025>\\x05\\b\\x05\\x0267\\x05\\n\\x06\\x02' +\n    '78\\x05\\x0E\\b\\x028>\\x03\\x02\\x02\\x029:\\x05\\n\\x06\\x02:;\\x05\\f\\x07\\x02;<\\x05' +\n    '\\x14\\v\\x02<>\\x03\\x02\\x02\\x02=5\\x03\\x02\\x02\\x02=6\\x03\\x02\\x02\\x02=9\\x03' +\n    '\\x02\\x02\\x02>\\x07\\x03\\x02\\x02\\x02?@\\x05\\n\\x06\\x02@A\\x05\\x10\\t\\x02AB\\x05' +\n    '\\x14\\v\\x02BP\\x03\\x02\\x02\\x02CD\\x05\\n\\x06\\x02DE\\x05\\x12\\n\\x02EF\\x05\\x16' +\n    '\\f\\x02FP\\x03\\x02\\x02\\x02GH\\x05\\n\\x06\\x02HI\\x07 \\x02\\x02IJ\\x05\\x16\\f\\x02' +\n    'JP\\x03\\x02\\x02\\x02KL\\x05\\n\\x06\\x02LM\\x07\\x0F\\x02\\x02MN\\x05\\x16\\f\\x02N' +\n    'P\\x03\\x02\\x02\\x02O?\\x03\\x02\\x02\\x02OC\\x03\\x02\\x02\\x02OG\\x03\\x02\\x02\\x02' +\n    'OK\\x03\\x02\\x02\\x02P\\t\\x03\\x02\\x02\\x02QR\\x07\\n\\x02\\x02R\\v\\x03\\x02\\x02\\x02' +\n    'ST\\t\\x03\\x02\\x02T\\r\\x03\\x02\\x02\\x02UV\\t\\x04\\x02\\x02V\\x0F\\x03\\x02\\x02\\x02' +\n    'WX\\t\\x05\\x02\\x02X\\x11\\x03\\x02\\x02\\x02YZ\\t\\x06\\x02\\x02Z\\x13\\x03\\x02\\x02' +\n    '\\x02[\\\\\\x05\\x18\\r\\x02\\\\\\x15\\x03\\x02\\x02\\x02]f\\x07\\x04\\x02\\x02^c\\x05\\x18' +\n    '\\r\\x02_`\\x07\\x03\\x02\\x02`b\\x05\\x18\\r\\x02a_\\x03\\x02\\x02\\x02be\\x03\\x02\\x02' +\n    '\\x02ca\\x03\\x02\\x02\\x02cd\\x03\\x02\\x02\\x02dg\\x03\\x02\\x02\\x02ec\\x03\\x02\\x02' +\n    '\\x02f^\\x03\\x02\\x02\\x02fg\\x03\\x02\\x02\\x02gh\\x03\\x02\\x02\\x02hi\\x07\\x05\\x02' +\n    '\\x02i\\x17\\x03\\x02\\x02\\x02jo\\x05\\x1A\\x0E\\x02ko\\x05\\x1C\\x0F\\x02lo\\x05\\x1E' +\n    '\\x10\\x02mo\\x05 \\x11\\x02nj\\x03\\x02\\x02\\x02nk\\x03\\x02\\x02\\x02nl\\x03\\x02' +\n    '\\x02\\x02nm\\x03\\x02\\x02\\x02o\\x19\\x03\\x02\\x02\\x02pq\\t\\x07\\x02\\x02q\\x1B\\x03' +\n    '\\x02\\x02\\x02rs\\t\\b\\x02\\x02s\\x1D\\x03\\x02\\x02\\x02tu\\t\\t\\x02\\x02u\\x1F\\x03' +\n    '\\x02\\x02\\x02vw\\x07\\x1A\\x02\\x02w!\\x03\\x02\\x02\\x02\\t+2=Ocfn';\n  public static __ATN: ATN;\n  public static get _ATN(): ATN {\n    if (!Query.__ATN) {\n      Query.__ATN = new ATNDeserializer().deserialize(Utils.toCharArray(Query._serializedATN));\n    }\n\n    return Query.__ATN;\n  }\n}\n\nexport class StartContext extends ParserRuleContext {\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  public EOF(): TerminalNode {\n    return this.getToken(Query.EOF, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_start;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitStart) {\n      return visitor.visitStart(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class ExprContext extends ParserRuleContext {\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_expr;\n  }\n  public copyFrom(ctx: ExprContext): void {\n    super.copyFrom(ctx);\n  }\n}\nexport class QueryExprContext extends ExprContext {\n  public queryStatement(): QueryStatementContext {\n    return this.getRuleContext(0, QueryStatementContext);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitQueryExpr) {\n      return visitor.visitQueryExpr(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class BinaryExprContext extends ExprContext {\n  public _op!: Token;\n  public expr(): ExprContext[];\n  public expr(i: number): ExprContext;\n  public expr(i?: number): ExprContext | ExprContext[] {\n    if (i === undefined) {\n      return this.getRuleContexts(ExprContext);\n    } else {\n      return this.getRuleContext(i, ExprContext);\n    }\n  }\n  public AND_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.AND_SYMBOL, 0);\n  }\n  public OR_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.OR_SYMBOL, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitBinaryExpr) {\n      return visitor.visitBinaryExpr(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class ParenQueryExprContext extends ExprContext {\n  public OPEN_PAREN(): TerminalNode {\n    return this.getToken(Query.OPEN_PAREN, 0);\n  }\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  public CLOSE_PAREN(): TerminalNode {\n    return this.getToken(Query.CLOSE_PAREN, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitParenQueryExpr) {\n      return visitor.visitParenQueryExpr(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class QueryStatementContext extends ParserRuleContext {\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_queryStatement;\n  }\n  public copyFrom(ctx: QueryStatementContext): void {\n    super.copyFrom(ctx);\n  }\n}\nexport class PrimaryExprPredicateContext extends QueryStatementContext {\n  public predicate(): PredicateContext {\n    return this.getRuleContext(0, PredicateContext);\n  }\n  constructor(ctx: QueryStatementContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPrimaryExprPredicate) {\n      return visitor.visitPrimaryExprPredicate(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class PrimaryExprIsContext extends QueryStatementContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public isOp(): IsOpContext {\n    return this.getRuleContext(0, IsOpContext);\n  }\n  constructor(ctx: QueryStatementContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPrimaryExprIs) {\n      return visitor.visitPrimaryExprIs(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class PrimaryExprCompareContext extends QueryStatementContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public compOp(): CompOpContext {\n    return this.getRuleContext(0, CompOpContext);\n  }\n  public value(): ValueContext {\n    return this.getRuleContext(0, ValueContext);\n  }\n  constructor(ctx: QueryStatementContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPrimaryExprCompare) {\n      return visitor.visitPrimaryExprCompare(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class PredicateContext extends ParserRuleContext {\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_predicate;\n  }\n  public copyFrom(ctx: PredicateContext): void {\n    super.copyFrom(ctx);\n  }\n}\nexport class PredicateExprLikeContext extends PredicateContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public likeOp(): LikeOpContext {\n    return this.getRuleContext(0, LikeOpContext);\n  }\n  public value(): ValueContext {\n    return this.getRuleContext(0, ValueContext);\n  }\n  constructor(ctx: PredicateContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPredicateExprLike) {\n      return visitor.visitPredicateExprLike(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class PredicateExprInContext extends PredicateContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public inOp(): InOpContext {\n    return this.getRuleContext(0, InOpContext);\n  }\n  public valueList(): ValueListContext {\n    return this.getRuleContext(0, ValueListContext);\n  }\n  constructor(ctx: PredicateContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPredicateExprIn) {\n      return visitor.visitPredicateExprIn(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class PredicateExprHasContext extends PredicateContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public HAS_SYMBOL(): TerminalNode {\n    return this.getToken(Query.HAS_SYMBOL, 0);\n  }\n  public valueList(): ValueListContext {\n    return this.getRuleContext(0, ValueListContext);\n  }\n  constructor(ctx: PredicateContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPredicateExprHas) {\n      return visitor.visitPredicateExprHas(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class PredicateExprEqArrayContext extends PredicateContext {\n  public fieldIdentifier(): FieldIdentifierContext {\n    return this.getRuleContext(0, FieldIdentifierContext);\n  }\n  public EQUAL_OPERATOR(): TerminalNode {\n    return this.getToken(Query.EQUAL_OPERATOR, 0);\n  }\n  public valueList(): ValueListContext {\n    return this.getRuleContext(0, ValueListContext);\n  }\n  constructor(ctx: PredicateContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitPredicateExprEqArray) {\n      return visitor.visitPredicateExprEqArray(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class FieldIdentifierContext extends ParserRuleContext {\n  public SIMPLE_IDENTIFIER(): TerminalNode {\n    return this.getToken(Query.SIMPLE_IDENTIFIER, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_fieldIdentifier;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitFieldIdentifier) {\n      return visitor.visitFieldIdentifier(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class CompOpContext extends ParserRuleContext {\n  public EQUAL_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.EQUAL_OPERATOR, 0);\n  }\n  public NOT_EQUAL_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.NOT_EQUAL_OPERATOR, 0);\n  }\n  public NOT_EQUAL2_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.NOT_EQUAL2_OPERATOR, 0);\n  }\n  public GT_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.GT_OPERATOR, 0);\n  }\n  public GTE_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.GTE_OPERATOR, 0);\n  }\n  public LT_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.LT_OPERATOR, 0);\n  }\n  public LTE_OPERATOR(): TerminalNode | undefined {\n    return this.tryGetToken(Query.LTE_OPERATOR, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_compOp;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitCompOp) {\n      return visitor.visitCompOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class IsOpContext extends ParserRuleContext {\n  public LS_NULL_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.LS_NULL_SYMBOL, 0);\n  }\n  public LS_NOT_NULL_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.LS_NOT_NULL_SYMBOL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_isOp;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitIsOp) {\n      return visitor.visitIsOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class LikeOpContext extends ParserRuleContext {\n  public LIKE_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.LIKE_SYMBOL, 0);\n  }\n  public NOT_LIKE_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.NOT_LIKE_SYMBOL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_likeOp;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitLikeOp) {\n      return visitor.visitLikeOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class InOpContext extends ParserRuleContext {\n  public IN_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.IN_SYMBOL, 0);\n  }\n  public NOT_IN_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.NOT_IN_SYMBOL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_inOp;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitInOp) {\n      return visitor.visitInOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class ValueContext extends ParserRuleContext {\n  public literal(): LiteralContext {\n    return this.getRuleContext(0, LiteralContext);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_value;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitValue) {\n      return visitor.visitValue(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class ValueListContext extends ParserRuleContext {\n  public OPEN_PAREN(): TerminalNode {\n    return this.getToken(Query.OPEN_PAREN, 0);\n  }\n  public CLOSE_PAREN(): TerminalNode {\n    return this.getToken(Query.CLOSE_PAREN, 0);\n  }\n  public literal(): LiteralContext[];\n  public literal(i: number): LiteralContext;\n  public literal(i?: number): LiteralContext | LiteralContext[] {\n    if (i === undefined) {\n      return this.getRuleContexts(LiteralContext);\n    } else {\n      return this.getRuleContext(i, LiteralContext);\n    }\n  }\n  public COMMA(): TerminalNode[];\n  public COMMA(i: number): TerminalNode;\n  public COMMA(i?: number): TerminalNode | TerminalNode[] {\n    if (i === undefined) {\n      return this.getTokens(Query.COMMA);\n    } else {\n      return this.getToken(Query.COMMA, i);\n    }\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_valueList;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitValueList) {\n      return visitor.visitValueList(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class LiteralContext extends ParserRuleContext {\n  public stringLiteral(): StringLiteralContext | undefined {\n    return this.tryGetRuleContext(0, StringLiteralContext);\n  }\n  public numberLiteral(): NumberLiteralContext | undefined {\n    return this.tryGetRuleContext(0, NumberLiteralContext);\n  }\n  public booleanLiteral(): BooleanLiteralContext | undefined {\n    return this.tryGetRuleContext(0, BooleanLiteralContext);\n  }\n  public nullLiteral(): NullLiteralContext | undefined {\n    return this.tryGetRuleContext(0, NullLiteralContext);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_literal;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitLiteral) {\n      return visitor.visitLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class StringLiteralContext extends ParserRuleContext {\n  public SINGLEQ_STRING_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.SINGLEQ_STRING_LITERAL, 0);\n  }\n  public DOUBLEQ_STRING_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.DOUBLEQ_STRING_LITERAL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_stringLiteral;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitStringLiteral) {\n      return visitor.visitStringLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class NumberLiteralContext extends ParserRuleContext {\n  public INTEGER_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.INTEGER_LITERAL, 0);\n  }\n  public NUMERIC_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.NUMERIC_LITERAL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_numberLiteral;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitNumberLiteral) {\n      return visitor.visitNumberLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class BooleanLiteralContext extends ParserRuleContext {\n  public TRUE_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.TRUE_SYMBOL, 0);\n  }\n  public FALSE_SYMBOL(): TerminalNode | undefined {\n    return this.tryGetToken(Query.FALSE_SYMBOL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_booleanLiteral;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitBooleanLiteral) {\n      return visitor.visitBooleanLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class NullLiteralContext extends ParserRuleContext {\n  public NULL_SYMBOL(): TerminalNode {\n    return this.getToken(Query.NULL_SYMBOL, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Query.RULE_nullLiteral;\n  }\n  // @Override\n  public accept<Result>(visitor: QueryVisitor<Result>): Result {\n    if (visitor.visitNullLiteral) {\n      return visitor.visitNullLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/core/src/query/parser/QueryLexer.g4",
    "content": "lexer grammar QueryLexer;\n\nfragment A: [aA];\nfragment B: [bB];\nfragment C: [cC];\nfragment D: [dD];\nfragment E: [eE];\nfragment F: [fF];\nfragment G: [gG];\nfragment H: [hH];\nfragment I: [iI];\nfragment J: [jJ];\nfragment K: [kK];\nfragment L: [lL];\nfragment M: [mM];\nfragment N: [nN];\nfragment O: [oO];\nfragment P: [pP];\nfragment Q: [qQ];\nfragment R: [rR];\nfragment S: [sS];\nfragment T: [tT];\nfragment U: [uU];\nfragment V: [vV];\nfragment W: [wW];\nfragment X: [xX];\nfragment Y: [yY];\nfragment Z: [zZ];\n\nfragment DEC_DIGIT                    : [0-9];\nfragment DQUOTA_STRING                : '\"' ( '\\\\'. | ~('\"' | '\\\\') )* '\"';\nfragment SQUOTA_STRING                : '\\'' ('\\\\'. | ~('\\'' | '\\\\'))* '\\'';\nfragment SPACE                        : [ \\t];\n\n\nCOMMA                                : ',';\nOPEN_PAREN                           : '(';\nCLOSE_PAREN                          : ')';\nOPEN_BRACKET                         : '[';\nCLOSE_BRACKET                        : ']';\nL_CURLY                              : '{';\nR_CURLY                              : '}';\n\nSIMPLE_IDENTIFIER                    : '{' ~('}')+ '}';\n\nSINGLEQ_STRING_LITERAL               : SQUOTA_STRING;\nDOUBLEQ_STRING_LITERAL               : DQUOTA_STRING;\nINTEGER_LITERAL                      : '-'? DEC_DIGIT+ (('E'|'e') DEC_DIGIT+)?;\nNUMERIC_LITERAL                      : '-'? DEC_DIGIT+ '.' DEC_DIGIT+ (('E'|'e') ('-')* DEC_DIGIT+)?;\n\nEQUAL_OPERATOR                       : '=';\nNOT_EQUAL_OPERATOR                   : '!=';\nNOT_EQUAL2_OPERATOR                  : '<>' -> type(NOT_EQUAL_OPERATOR);\nGT_OPERATOR                          : '>';\nGTE_OPERATOR                         : '>=';\nLT_OPERATOR                          : '<';\nLTE_OPERATOR                         : '<=';\n\nTRUE_SYMBOL                          : T R U E;\nFALSE_SYMBOL                         : F A L S E;\n\nAND_SYMBOL                           : A N D;\nOR_SYMBOL                            : O R;\n\nNOT_SYMBOL                           : N O T;\nNULL_SYMBOL                          : N U L L;\nIS_SYMBOL                            : I S;\n\nLS_NULL_SYMBOL                       : I S SPACE N U L L;\nLS_NOT_NULL_SYMBOL                   : I S SPACE N O T SPACE N U L L;\nLIKE_SYMBOL                          : L I K E;\nIN_SYMBOL                            : I N;\nHAS_SYMBOL                           : H A S;\nNOT_LIKE_SYMBOL                      : N O T SPACE L I K E;\nNOT_IN_SYMBOL                        : N O T SPACE I N;\n\n\n// White space handling\nWHITESPACE: [ \\t\\r\\n] -> channel(HIDDEN); // Ignore whitespaces.\n"
  },
  {
    "path": "packages/core/src/query/parser/QueryLexer.interp",
    "content": "token literal names:\nnull\n','\n'('\n')'\n'['\n']'\n'{'\n'}'\nnull\nnull\nnull\nnull\nnull\n'='\n'!='\n'>'\n'>='\n'<'\n'<='\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\nnull\n'<>'\n\ntoken symbolic names:\nnull\nCOMMA\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nSIMPLE_IDENTIFIER\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nINTEGER_LITERAL\nNUMERIC_LITERAL\nEQUAL_OPERATOR\nNOT_EQUAL_OPERATOR\nGT_OPERATOR\nGTE_OPERATOR\nLT_OPERATOR\nLTE_OPERATOR\nTRUE_SYMBOL\nFALSE_SYMBOL\nAND_SYMBOL\nOR_SYMBOL\nNOT_SYMBOL\nNULL_SYMBOL\nIS_SYMBOL\nLS_NULL_SYMBOL\nLS_NOT_NULL_SYMBOL\nLIKE_SYMBOL\nIN_SYMBOL\nHAS_SYMBOL\nNOT_LIKE_SYMBOL\nNOT_IN_SYMBOL\nWHITESPACE\nNOT_EQUAL2_OPERATOR\n\nrule names:\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\nDEC_DIGIT\nDQUOTA_STRING\nSQUOTA_STRING\nSPACE\nCOMMA\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nSIMPLE_IDENTIFIER\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nINTEGER_LITERAL\nNUMERIC_LITERAL\nEQUAL_OPERATOR\nNOT_EQUAL_OPERATOR\nNOT_EQUAL2_OPERATOR\nGT_OPERATOR\nGTE_OPERATOR\nLT_OPERATOR\nLTE_OPERATOR\nTRUE_SYMBOL\nFALSE_SYMBOL\nAND_SYMBOL\nOR_SYMBOL\nNOT_SYMBOL\nNULL_SYMBOL\nIS_SYMBOL\nLS_NULL_SYMBOL\nLS_NOT_NULL_SYMBOL\nLIKE_SYMBOL\nIN_SYMBOL\nHAS_SYMBOL\nNOT_LIKE_SYMBOL\nNOT_IN_SYMBOL\nWHITESPACE\n\nchannel names:\nDEFAULT_TOKEN_CHANNEL\nHIDDEN\n\nmode names:\nDEFAULT_MODE\n\natn:\n[3, 51485, 51898, 1421, 44986, 20307, 1543, 60043, 49729, 2, 36, 381, 8, 1, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 4, 34, 9, 34, 4, 35, 9, 35, 4, 36, 9, 36, 4, 37, 9, 37, 4, 38, 9, 38, 4, 39, 9, 39, 4, 40, 9, 40, 4, 41, 9, 41, 4, 42, 9, 42, 4, 43, 9, 43, 4, 44, 9, 44, 4, 45, 9, 45, 4, 46, 9, 46, 4, 47, 9, 47, 4, 48, 9, 48, 4, 49, 9, 49, 4, 50, 9, 50, 4, 51, 9, 51, 4, 52, 9, 52, 4, 53, 9, 53, 4, 54, 9, 54, 4, 55, 9, 55, 4, 56, 9, 56, 4, 57, 9, 57, 4, 58, 9, 58, 4, 59, 9, 59, 4, 60, 9, 60, 4, 61, 9, 61, 4, 62, 9, 62, 4, 63, 9, 63, 4, 64, 9, 64, 4, 65, 9, 65, 3, 2, 3, 2, 3, 3, 3, 3, 3, 4, 3, 4, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 12, 3, 12, 3, 13, 3, 13, 3, 14, 3, 14, 3, 15, 3, 15, 3, 16, 3, 16, 3, 17, 3, 17, 3, 18, 3, 18, 3, 19, 3, 19, 3, 20, 3, 20, 3, 21, 3, 21, 3, 22, 3, 22, 3, 23, 3, 23, 3, 24, 3, 24, 3, 25, 3, 25, 3, 26, 3, 26, 3, 27, 3, 27, 3, 28, 3, 28, 3, 29, 3, 29, 3, 29, 3, 29, 7, 29, 190, 10, 29, 12, 29, 14, 29, 193, 11, 29, 3, 29, 3, 29, 3, 30, 3, 30, 3, 30, 3, 30, 7, 30, 201, 10, 30, 12, 30, 14, 30, 204, 11, 30, 3, 30, 3, 30, 3, 31, 3, 31, 3, 32, 3, 32, 3, 33, 3, 33, 3, 34, 3, 34, 3, 35, 3, 35, 3, 36, 3, 36, 3, 37, 3, 37, 3, 38, 3, 38, 3, 39, 3, 39, 6, 39, 226, 10, 39, 13, 39, 14, 39, 227, 3, 39, 3, 39, 3, 40, 3, 40, 3, 41, 3, 41, 3, 42, 5, 42, 237, 10, 42, 3, 42, 6, 42, 240, 10, 42, 13, 42, 14, 42, 241, 3, 42, 3, 42, 6, 42, 246, 10, 42, 13, 42, 14, 42, 247, 5, 42, 250, 10, 42, 3, 43, 5, 43, 253, 10, 43, 3, 43, 6, 43, 256, 10, 43, 13, 43, 14, 43, 257, 3, 43, 3, 43, 6, 43, 262, 10, 43, 13, 43, 14, 43, 263, 3, 43, 3, 43, 7, 43, 268, 10, 43, 12, 43, 14, 43, 271, 11, 43, 3, 43, 6, 43, 274, 10, 43, 13, 43, 14, 43, 275, 5, 43, 278, 10, 43, 3, 44, 3, 44, 3, 45, 3, 45, 3, 45, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 47, 3, 47, 3, 48, 3, 48, 3, 48, 3, 49, 3, 49, 3, 50, 3, 50, 3, 50, 3, 51, 3, 51, 3, 51, 3, 51, 3, 51, 3, 52, 3, 52, 3, 52, 3, 52, 3, 52, 3, 52, 3, 53, 3, 53, 3, 53, 3, 53, 3, 54, 3, 54, 3, 54, 3, 55, 3, 55, 3, 55, 3, 55, 3, 56, 3, 56, 3, 56, 3, 56, 3, 56, 3, 57, 3, 57, 3, 57, 3, 58, 3, 58, 3, 58, 3, 58, 3, 58, 3, 58, 3, 58, 3, 58, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 59, 3, 60, 3, 60, 3, 60, 3, 60, 3, 60, 3, 61, 3, 61, 3, 61, 3, 62, 3, 62, 3, 62, 3, 62, 3, 63, 3, 63, 3, 63, 3, 63, 3, 63, 3, 63, 3, 63, 3, 63, 3, 63, 3, 64, 3, 64, 3, 64, 3, 64, 3, 64, 3, 64, 3, 64, 3, 65, 3, 65, 3, 65, 3, 65, 2, 2, 2, 66, 3, 2, 2, 5, 2, 2, 7, 2, 2, 9, 2, 2, 11, 2, 2, 13, 2, 2, 15, 2, 2, 17, 2, 2, 19, 2, 2, 21, 2, 2, 23, 2, 2, 25, 2, 2, 27, 2, 2, 29, 2, 2, 31, 2, 2, 33, 2, 2, 35, 2, 2, 37, 2, 2, 39, 2, 2, 41, 2, 2, 43, 2, 2, 45, 2, 2, 47, 2, 2, 49, 2, 2, 51, 2, 2, 53, 2, 2, 55, 2, 2, 57, 2, 2, 59, 2, 2, 61, 2, 2, 63, 2, 3, 65, 2, 4, 67, 2, 5, 69, 2, 6, 71, 2, 7, 73, 2, 8, 75, 2, 9, 77, 2, 10, 79, 2, 11, 81, 2, 12, 83, 2, 13, 85, 2, 14, 87, 2, 15, 89, 2, 16, 91, 2, 36, 93, 2, 17, 95, 2, 18, 97, 2, 19, 99, 2, 20, 101, 2, 21, 103, 2, 22, 105, 2, 23, 107, 2, 24, 109, 2, 25, 111, 2, 26, 113, 2, 27, 115, 2, 28, 117, 2, 29, 119, 2, 30, 121, 2, 31, 123, 2, 32, 125, 2, 33, 127, 2, 34, 129, 2, 35, 3, 2, 34, 4, 2, 67, 67, 99, 99, 4, 2, 68, 68, 100, 100, 4, 2, 69, 69, 101, 101, 4, 2, 70, 70, 102, 102, 4, 2, 71, 71, 103, 103, 4, 2, 72, 72, 104, 104, 4, 2, 73, 73, 105, 105, 4, 2, 74, 74, 106, 106, 4, 2, 75, 75, 107, 107, 4, 2, 76, 76, 108, 108, 4, 2, 77, 77, 109, 109, 4, 2, 78, 78, 110, 110, 4, 2, 79, 79, 111, 111, 4, 2, 80, 80, 112, 112, 4, 2, 81, 81, 113, 113, 4, 2, 82, 82, 114, 114, 4, 2, 83, 83, 115, 115, 4, 2, 84, 84, 116, 116, 4, 2, 85, 85, 117, 117, 4, 2, 86, 86, 118, 118, 4, 2, 87, 87, 119, 119, 4, 2, 88, 88, 120, 120, 4, 2, 89, 89, 121, 121, 4, 2, 90, 90, 122, 122, 4, 2, 91, 91, 123, 123, 4, 2, 92, 92, 124, 124, 3, 2, 50, 59, 4, 2, 36, 36, 94, 94, 4, 2, 41, 41, 94, 94, 4, 2, 11, 11, 34, 34, 3, 2, 127, 127, 5, 2, 11, 12, 15, 15, 34, 34, 2, 365, 2, 63, 3, 2, 2, 2, 2, 65, 3, 2, 2, 2, 2, 67, 3, 2, 2, 2, 2, 69, 3, 2, 2, 2, 2, 71, 3, 2, 2, 2, 2, 73, 3, 2, 2, 2, 2, 75, 3, 2, 2, 2, 2, 77, 3, 2, 2, 2, 2, 79, 3, 2, 2, 2, 2, 81, 3, 2, 2, 2, 2, 83, 3, 2, 2, 2, 2, 85, 3, 2, 2, 2, 2, 87, 3, 2, 2, 2, 2, 89, 3, 2, 2, 2, 2, 91, 3, 2, 2, 2, 2, 93, 3, 2, 2, 2, 2, 95, 3, 2, 2, 2, 2, 97, 3, 2, 2, 2, 2, 99, 3, 2, 2, 2, 2, 101, 3, 2, 2, 2, 2, 103, 3, 2, 2, 2, 2, 105, 3, 2, 2, 2, 2, 107, 3, 2, 2, 2, 2, 109, 3, 2, 2, 2, 2, 111, 3, 2, 2, 2, 2, 113, 3, 2, 2, 2, 2, 115, 3, 2, 2, 2, 2, 117, 3, 2, 2, 2, 2, 119, 3, 2, 2, 2, 2, 121, 3, 2, 2, 2, 2, 123, 3, 2, 2, 2, 2, 125, 3, 2, 2, 2, 2, 127, 3, 2, 2, 2, 2, 129, 3, 2, 2, 2, 3, 131, 3, 2, 2, 2, 5, 133, 3, 2, 2, 2, 7, 135, 3, 2, 2, 2, 9, 137, 3, 2, 2, 2, 11, 139, 3, 2, 2, 2, 13, 141, 3, 2, 2, 2, 15, 143, 3, 2, 2, 2, 17, 145, 3, 2, 2, 2, 19, 147, 3, 2, 2, 2, 21, 149, 3, 2, 2, 2, 23, 151, 3, 2, 2, 2, 25, 153, 3, 2, 2, 2, 27, 155, 3, 2, 2, 2, 29, 157, 3, 2, 2, 2, 31, 159, 3, 2, 2, 2, 33, 161, 3, 2, 2, 2, 35, 163, 3, 2, 2, 2, 37, 165, 3, 2, 2, 2, 39, 167, 3, 2, 2, 2, 41, 169, 3, 2, 2, 2, 43, 171, 3, 2, 2, 2, 45, 173, 3, 2, 2, 2, 47, 175, 3, 2, 2, 2, 49, 177, 3, 2, 2, 2, 51, 179, 3, 2, 2, 2, 53, 181, 3, 2, 2, 2, 55, 183, 3, 2, 2, 2, 57, 185, 3, 2, 2, 2, 59, 196, 3, 2, 2, 2, 61, 207, 3, 2, 2, 2, 63, 209, 3, 2, 2, 2, 65, 211, 3, 2, 2, 2, 67, 213, 3, 2, 2, 2, 69, 215, 3, 2, 2, 2, 71, 217, 3, 2, 2, 2, 73, 219, 3, 2, 2, 2, 75, 221, 3, 2, 2, 2, 77, 223, 3, 2, 2, 2, 79, 231, 3, 2, 2, 2, 81, 233, 3, 2, 2, 2, 83, 236, 3, 2, 2, 2, 85, 252, 3, 2, 2, 2, 87, 279, 3, 2, 2, 2, 89, 281, 3, 2, 2, 2, 91, 284, 3, 2, 2, 2, 93, 289, 3, 2, 2, 2, 95, 291, 3, 2, 2, 2, 97, 294, 3, 2, 2, 2, 99, 296, 3, 2, 2, 2, 101, 299, 3, 2, 2, 2, 103, 304, 3, 2, 2, 2, 105, 310, 3, 2, 2, 2, 107, 314, 3, 2, 2, 2, 109, 317, 3, 2, 2, 2, 111, 321, 3, 2, 2, 2, 113, 326, 3, 2, 2, 2, 115, 329, 3, 2, 2, 2, 117, 337, 3, 2, 2, 2, 119, 349, 3, 2, 2, 2, 121, 354, 3, 2, 2, 2, 123, 357, 3, 2, 2, 2, 125, 361, 3, 2, 2, 2, 127, 370, 3, 2, 2, 2, 129, 377, 3, 2, 2, 2, 131, 132, 9, 2, 2, 2, 132, 4, 3, 2, 2, 2, 133, 134, 9, 3, 2, 2, 134, 6, 3, 2, 2, 2, 135, 136, 9, 4, 2, 2, 136, 8, 3, 2, 2, 2, 137, 138, 9, 5, 2, 2, 138, 10, 3, 2, 2, 2, 139, 140, 9, 6, 2, 2, 140, 12, 3, 2, 2, 2, 141, 142, 9, 7, 2, 2, 142, 14, 3, 2, 2, 2, 143, 144, 9, 8, 2, 2, 144, 16, 3, 2, 2, 2, 145, 146, 9, 9, 2, 2, 146, 18, 3, 2, 2, 2, 147, 148, 9, 10, 2, 2, 148, 20, 3, 2, 2, 2, 149, 150, 9, 11, 2, 2, 150, 22, 3, 2, 2, 2, 151, 152, 9, 12, 2, 2, 152, 24, 3, 2, 2, 2, 153, 154, 9, 13, 2, 2, 154, 26, 3, 2, 2, 2, 155, 156, 9, 14, 2, 2, 156, 28, 3, 2, 2, 2, 157, 158, 9, 15, 2, 2, 158, 30, 3, 2, 2, 2, 159, 160, 9, 16, 2, 2, 160, 32, 3, 2, 2, 2, 161, 162, 9, 17, 2, 2, 162, 34, 3, 2, 2, 2, 163, 164, 9, 18, 2, 2, 164, 36, 3, 2, 2, 2, 165, 166, 9, 19, 2, 2, 166, 38, 3, 2, 2, 2, 167, 168, 9, 20, 2, 2, 168, 40, 3, 2, 2, 2, 169, 170, 9, 21, 2, 2, 170, 42, 3, 2, 2, 2, 171, 172, 9, 22, 2, 2, 172, 44, 3, 2, 2, 2, 173, 174, 9, 23, 2, 2, 174, 46, 3, 2, 2, 2, 175, 176, 9, 24, 2, 2, 176, 48, 3, 2, 2, 2, 177, 178, 9, 25, 2, 2, 178, 50, 3, 2, 2, 2, 179, 180, 9, 26, 2, 2, 180, 52, 3, 2, 2, 2, 181, 182, 9, 27, 2, 2, 182, 54, 3, 2, 2, 2, 183, 184, 9, 28, 2, 2, 184, 56, 3, 2, 2, 2, 185, 191, 7, 36, 2, 2, 186, 187, 7, 94, 2, 2, 187, 190, 11, 2, 2, 2, 188, 190, 10, 29, 2, 2, 189, 186, 3, 2, 2, 2, 189, 188, 3, 2, 2, 2, 190, 193, 3, 2, 2, 2, 191, 189, 3, 2, 2, 2, 191, 192, 3, 2, 2, 2, 192, 194, 3, 2, 2, 2, 193, 191, 3, 2, 2, 2, 194, 195, 7, 36, 2, 2, 195, 58, 3, 2, 2, 2, 196, 202, 7, 41, 2, 2, 197, 198, 7, 94, 2, 2, 198, 201, 11, 2, 2, 2, 199, 201, 10, 30, 2, 2, 200, 197, 3, 2, 2, 2, 200, 199, 3, 2, 2, 2, 201, 204, 3, 2, 2, 2, 202, 200, 3, 2, 2, 2, 202, 203, 3, 2, 2, 2, 203, 205, 3, 2, 2, 2, 204, 202, 3, 2, 2, 2, 205, 206, 7, 41, 2, 2, 206, 60, 3, 2, 2, 2, 207, 208, 9, 31, 2, 2, 208, 62, 3, 2, 2, 2, 209, 210, 7, 46, 2, 2, 210, 64, 3, 2, 2, 2, 211, 212, 7, 42, 2, 2, 212, 66, 3, 2, 2, 2, 213, 214, 7, 43, 2, 2, 214, 68, 3, 2, 2, 2, 215, 216, 7, 93, 2, 2, 216, 70, 3, 2, 2, 2, 217, 218, 7, 95, 2, 2, 218, 72, 3, 2, 2, 2, 219, 220, 7, 125, 2, 2, 220, 74, 3, 2, 2, 2, 221, 222, 7, 127, 2, 2, 222, 76, 3, 2, 2, 2, 223, 225, 7, 125, 2, 2, 224, 226, 10, 32, 2, 2, 225, 224, 3, 2, 2, 2, 226, 227, 3, 2, 2, 2, 227, 225, 3, 2, 2, 2, 227, 228, 3, 2, 2, 2, 228, 229, 3, 2, 2, 2, 229, 230, 7, 127, 2, 2, 230, 78, 3, 2, 2, 2, 231, 232, 5, 59, 30, 2, 232, 80, 3, 2, 2, 2, 233, 234, 5, 57, 29, 2, 234, 82, 3, 2, 2, 2, 235, 237, 7, 47, 2, 2, 236, 235, 3, 2, 2, 2, 236, 237, 3, 2, 2, 2, 237, 239, 3, 2, 2, 2, 238, 240, 5, 55, 28, 2, 239, 238, 3, 2, 2, 2, 240, 241, 3, 2, 2, 2, 241, 239, 3, 2, 2, 2, 241, 242, 3, 2, 2, 2, 242, 249, 3, 2, 2, 2, 243, 245, 9, 6, 2, 2, 244, 246, 5, 55, 28, 2, 245, 244, 3, 2, 2, 2, 246, 247, 3, 2, 2, 2, 247, 245, 3, 2, 2, 2, 247, 248, 3, 2, 2, 2, 248, 250, 3, 2, 2, 2, 249, 243, 3, 2, 2, 2, 249, 250, 3, 2, 2, 2, 250, 84, 3, 2, 2, 2, 251, 253, 7, 47, 2, 2, 252, 251, 3, 2, 2, 2, 252, 253, 3, 2, 2, 2, 253, 255, 3, 2, 2, 2, 254, 256, 5, 55, 28, 2, 255, 254, 3, 2, 2, 2, 256, 257, 3, 2, 2, 2, 257, 255, 3, 2, 2, 2, 257, 258, 3, 2, 2, 2, 258, 259, 3, 2, 2, 2, 259, 261, 7, 48, 2, 2, 260, 262, 5, 55, 28, 2, 261, 260, 3, 2, 2, 2, 262, 263, 3, 2, 2, 2, 263, 261, 3, 2, 2, 2, 263, 264, 3, 2, 2, 2, 264, 277, 3, 2, 2, 2, 265, 269, 9, 6, 2, 2, 266, 268, 7, 47, 2, 2, 267, 266, 3, 2, 2, 2, 268, 271, 3, 2, 2, 2, 269, 267, 3, 2, 2, 2, 269, 270, 3, 2, 2, 2, 270, 273, 3, 2, 2, 2, 271, 269, 3, 2, 2, 2, 272, 274, 5, 55, 28, 2, 273, 272, 3, 2, 2, 2, 274, 275, 3, 2, 2, 2, 275, 273, 3, 2, 2, 2, 275, 276, 3, 2, 2, 2, 276, 278, 3, 2, 2, 2, 277, 265, 3, 2, 2, 2, 277, 278, 3, 2, 2, 2, 278, 86, 3, 2, 2, 2, 279, 280, 7, 63, 2, 2, 280, 88, 3, 2, 2, 2, 281, 282, 7, 35, 2, 2, 282, 283, 7, 63, 2, 2, 283, 90, 3, 2, 2, 2, 284, 285, 7, 62, 2, 2, 285, 286, 7, 64, 2, 2, 286, 287, 3, 2, 2, 2, 287, 288, 8, 46, 2, 2, 288, 92, 3, 2, 2, 2, 289, 290, 7, 64, 2, 2, 290, 94, 3, 2, 2, 2, 291, 292, 7, 64, 2, 2, 292, 293, 7, 63, 2, 2, 293, 96, 3, 2, 2, 2, 294, 295, 7, 62, 2, 2, 295, 98, 3, 2, 2, 2, 296, 297, 7, 62, 2, 2, 297, 298, 7, 63, 2, 2, 298, 100, 3, 2, 2, 2, 299, 300, 5, 41, 21, 2, 300, 301, 5, 37, 19, 2, 301, 302, 5, 43, 22, 2, 302, 303, 5, 11, 6, 2, 303, 102, 3, 2, 2, 2, 304, 305, 5, 13, 7, 2, 305, 306, 5, 3, 2, 2, 306, 307, 5, 25, 13, 2, 307, 308, 5, 39, 20, 2, 308, 309, 5, 11, 6, 2, 309, 104, 3, 2, 2, 2, 310, 311, 5, 3, 2, 2, 311, 312, 5, 29, 15, 2, 312, 313, 5, 9, 5, 2, 313, 106, 3, 2, 2, 2, 314, 315, 5, 31, 16, 2, 315, 316, 5, 37, 19, 2, 316, 108, 3, 2, 2, 2, 317, 318, 5, 29, 15, 2, 318, 319, 5, 31, 16, 2, 319, 320, 5, 41, 21, 2, 320, 110, 3, 2, 2, 2, 321, 322, 5, 29, 15, 2, 322, 323, 5, 43, 22, 2, 323, 324, 5, 25, 13, 2, 324, 325, 5, 25, 13, 2, 325, 112, 3, 2, 2, 2, 326, 327, 5, 19, 10, 2, 327, 328, 5, 39, 20, 2, 328, 114, 3, 2, 2, 2, 329, 330, 5, 19, 10, 2, 330, 331, 5, 39, 20, 2, 331, 332, 5, 61, 31, 2, 332, 333, 5, 29, 15, 2, 333, 334, 5, 43, 22, 2, 334, 335, 5, 25, 13, 2, 335, 336, 5, 25, 13, 2, 336, 116, 3, 2, 2, 2, 337, 338, 5, 19, 10, 2, 338, 339, 5, 39, 20, 2, 339, 340, 5, 61, 31, 2, 340, 341, 5, 29, 15, 2, 341, 342, 5, 31, 16, 2, 342, 343, 5, 41, 21, 2, 343, 344, 5, 61, 31, 2, 344, 345, 5, 29, 15, 2, 345, 346, 5, 43, 22, 2, 346, 347, 5, 25, 13, 2, 347, 348, 5, 25, 13, 2, 348, 118, 3, 2, 2, 2, 349, 350, 5, 25, 13, 2, 350, 351, 5, 19, 10, 2, 351, 352, 5, 23, 12, 2, 352, 353, 5, 11, 6, 2, 353, 120, 3, 2, 2, 2, 354, 355, 5, 19, 10, 2, 355, 356, 5, 29, 15, 2, 356, 122, 3, 2, 2, 2, 357, 358, 5, 17, 9, 2, 358, 359, 5, 3, 2, 2, 359, 360, 5, 39, 20, 2, 360, 124, 3, 2, 2, 2, 361, 362, 5, 29, 15, 2, 362, 363, 5, 31, 16, 2, 363, 364, 5, 41, 21, 2, 364, 365, 5, 61, 31, 2, 365, 366, 5, 25, 13, 2, 366, 367, 5, 19, 10, 2, 367, 368, 5, 23, 12, 2, 368, 369, 5, 11, 6, 2, 369, 126, 3, 2, 2, 2, 370, 371, 5, 29, 15, 2, 371, 372, 5, 31, 16, 2, 372, 373, 5, 41, 21, 2, 373, 374, 5, 61, 31, 2, 374, 375, 5, 19, 10, 2, 375, 376, 5, 29, 15, 2, 376, 128, 3, 2, 2, 2, 377, 378, 9, 33, 2, 2, 378, 379, 3, 2, 2, 2, 379, 380, 8, 65, 3, 2, 380, 130, 3, 2, 2, 2, 18, 2, 189, 191, 200, 202, 227, 236, 241, 247, 249, 252, 257, 263, 269, 275, 277, 4, 9, 16, 2, 2, 3, 2]"
  },
  {
    "path": "packages/core/src/query/parser/QueryLexer.tokens",
    "content": "COMMA=1\nOPEN_PAREN=2\nCLOSE_PAREN=3\nOPEN_BRACKET=4\nCLOSE_BRACKET=5\nL_CURLY=6\nR_CURLY=7\nSIMPLE_IDENTIFIER=8\nSINGLEQ_STRING_LITERAL=9\nDOUBLEQ_STRING_LITERAL=10\nINTEGER_LITERAL=11\nNUMERIC_LITERAL=12\nEQUAL_OPERATOR=13\nNOT_EQUAL_OPERATOR=14\nGT_OPERATOR=15\nGTE_OPERATOR=16\nLT_OPERATOR=17\nLTE_OPERATOR=18\nTRUE_SYMBOL=19\nFALSE_SYMBOL=20\nAND_SYMBOL=21\nOR_SYMBOL=22\nNOT_SYMBOL=23\nNULL_SYMBOL=24\nIS_SYMBOL=25\nLS_NULL_SYMBOL=26\nLS_NOT_NULL_SYMBOL=27\nLIKE_SYMBOL=28\nIN_SYMBOL=29\nHAS_SYMBOL=30\nNOT_LIKE_SYMBOL=31\nNOT_IN_SYMBOL=32\nWHITESPACE=33\nNOT_EQUAL2_OPERATOR=34\n','=1\n'('=2\n')'=3\n'['=4\n']'=5\n'{'=6\n'}'=7\n'='=13\n'!='=14\n'<>'=34\n'>'=15\n'>='=16\n'<'=17\n'<='=18\n"
  },
  {
    "path": "packages/core/src/query/parser/QueryLexer.ts",
    "content": "// Generated from src/query/parser/QueryLexer.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport type { ATN } from 'antlr4ts/atn/ATN';\nimport { ATNDeserializer } from 'antlr4ts/atn/ATNDeserializer';\nimport { LexerATNSimulator } from 'antlr4ts/atn/LexerATNSimulator';\nimport type { CharStream } from 'antlr4ts/CharStream';\nimport { NotNull, Override } from 'antlr4ts/Decorators';\nimport { Lexer } from 'antlr4ts/Lexer';\nimport * as Utils from 'antlr4ts/misc/Utils';\nimport { RuleContext } from 'antlr4ts/RuleContext';\nimport type { Vocabulary } from 'antlr4ts/Vocabulary';\nimport { VocabularyImpl } from 'antlr4ts/VocabularyImpl';\n\nexport class QueryLexer extends Lexer {\n  public static readonly COMMA = 1;\n  public static readonly OPEN_PAREN = 2;\n  public static readonly CLOSE_PAREN = 3;\n  public static readonly OPEN_BRACKET = 4;\n  public static readonly CLOSE_BRACKET = 5;\n  public static readonly L_CURLY = 6;\n  public static readonly R_CURLY = 7;\n  public static readonly SIMPLE_IDENTIFIER = 8;\n  public static readonly SINGLEQ_STRING_LITERAL = 9;\n  public static readonly DOUBLEQ_STRING_LITERAL = 10;\n  public static readonly INTEGER_LITERAL = 11;\n  public static readonly NUMERIC_LITERAL = 12;\n  public static readonly EQUAL_OPERATOR = 13;\n  public static readonly NOT_EQUAL_OPERATOR = 14;\n  public static readonly GT_OPERATOR = 15;\n  public static readonly GTE_OPERATOR = 16;\n  public static readonly LT_OPERATOR = 17;\n  public static readonly LTE_OPERATOR = 18;\n  public static readonly TRUE_SYMBOL = 19;\n  public static readonly FALSE_SYMBOL = 20;\n  public static readonly AND_SYMBOL = 21;\n  public static readonly OR_SYMBOL = 22;\n  public static readonly NOT_SYMBOL = 23;\n  public static readonly NULL_SYMBOL = 24;\n  public static readonly IS_SYMBOL = 25;\n  public static readonly LS_NULL_SYMBOL = 26;\n  public static readonly LS_NOT_NULL_SYMBOL = 27;\n  public static readonly LIKE_SYMBOL = 28;\n  public static readonly IN_SYMBOL = 29;\n  public static readonly HAS_SYMBOL = 30;\n  public static readonly NOT_LIKE_SYMBOL = 31;\n  public static readonly NOT_IN_SYMBOL = 32;\n  public static readonly WHITESPACE = 33;\n  public static readonly NOT_EQUAL2_OPERATOR = 34;\n\n  public static readonly channelNames: string[] = ['DEFAULT_TOKEN_CHANNEL', 'HIDDEN'];\n\n  public static readonly modeNames: string[] = ['DEFAULT_MODE'];\n\n  public static readonly ruleNames: string[] = [\n    'A',\n    'B',\n    'C',\n    'D',\n    'E',\n    'F',\n    'G',\n    'H',\n    'I',\n    'J',\n    'K',\n    'L',\n    'M',\n    'N',\n    'O',\n    'P',\n    'Q',\n    'R',\n    'S',\n    'T',\n    'U',\n    'V',\n    'W',\n    'X',\n    'Y',\n    'Z',\n    'DEC_DIGIT',\n    'DQUOTA_STRING',\n    'SQUOTA_STRING',\n    'SPACE',\n    'COMMA',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'SIMPLE_IDENTIFIER',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'INTEGER_LITERAL',\n    'NUMERIC_LITERAL',\n    'EQUAL_OPERATOR',\n    'NOT_EQUAL_OPERATOR',\n    'NOT_EQUAL2_OPERATOR',\n    'GT_OPERATOR',\n    'GTE_OPERATOR',\n    'LT_OPERATOR',\n    'LTE_OPERATOR',\n    'TRUE_SYMBOL',\n    'FALSE_SYMBOL',\n    'AND_SYMBOL',\n    'OR_SYMBOL',\n    'NOT_SYMBOL',\n    'NULL_SYMBOL',\n    'IS_SYMBOL',\n    'LS_NULL_SYMBOL',\n    'LS_NOT_NULL_SYMBOL',\n    'LIKE_SYMBOL',\n    'IN_SYMBOL',\n    'HAS_SYMBOL',\n    'NOT_LIKE_SYMBOL',\n    'NOT_IN_SYMBOL',\n    'WHITESPACE',\n  ];\n\n  private static readonly _LITERAL_NAMES: Array<string | undefined> = [\n    undefined,\n    \"','\",\n    \"'('\",\n    \"')'\",\n    \"'['\",\n    \"']'\",\n    \"'{'\",\n    \"'}'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'='\",\n    \"'!='\",\n    \"'>'\",\n    \"'>='\",\n    \"'<'\",\n    \"'<='\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'<>'\",\n  ];\n  private static readonly _SYMBOLIC_NAMES: Array<string | undefined> = [\n    undefined,\n    'COMMA',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'SIMPLE_IDENTIFIER',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'INTEGER_LITERAL',\n    'NUMERIC_LITERAL',\n    'EQUAL_OPERATOR',\n    'NOT_EQUAL_OPERATOR',\n    'GT_OPERATOR',\n    'GTE_OPERATOR',\n    'LT_OPERATOR',\n    'LTE_OPERATOR',\n    'TRUE_SYMBOL',\n    'FALSE_SYMBOL',\n    'AND_SYMBOL',\n    'OR_SYMBOL',\n    'NOT_SYMBOL',\n    'NULL_SYMBOL',\n    'IS_SYMBOL',\n    'LS_NULL_SYMBOL',\n    'LS_NOT_NULL_SYMBOL',\n    'LIKE_SYMBOL',\n    'IN_SYMBOL',\n    'HAS_SYMBOL',\n    'NOT_LIKE_SYMBOL',\n    'NOT_IN_SYMBOL',\n    'WHITESPACE',\n    'NOT_EQUAL2_OPERATOR',\n  ];\n  public static readonly VOCABULARY: Vocabulary = new VocabularyImpl(\n    QueryLexer._LITERAL_NAMES,\n    QueryLexer._SYMBOLIC_NAMES,\n    []\n  );\n\n  // @Override\n  // @NotNull\n  public get vocabulary(): Vocabulary {\n    return QueryLexer.VOCABULARY;\n  }\n\n  constructor(input: CharStream) {\n    super(input);\n    this._interp = new LexerATNSimulator(QueryLexer._ATN, this);\n  }\n\n  // @Override\n  public get grammarFileName(): string {\n    return 'QueryLexer.g4';\n  }\n\n  // @Override\n  public get ruleNames(): string[] {\n    return QueryLexer.ruleNames;\n  }\n\n  // @Override\n  public get serializedATN(): string {\n    return QueryLexer._serializedATN;\n  }\n\n  // @Override\n  public get channelNames(): string[] {\n    return QueryLexer.channelNames;\n  }\n\n  // @Override\n  public get modeNames(): string[] {\n    return QueryLexer.modeNames;\n  }\n\n  public static readonly _serializedATN: string =\n    '\\x03\\uC91D\\uCABA\\u058D\\uAFBA\\u4F53\\u0607\\uEA8B\\uC241\\x02$\\u017D\\b\\x01' +\n    '\\x04\\x02\\t\\x02\\x04\\x03\\t\\x03\\x04\\x04\\t\\x04\\x04\\x05\\t\\x05\\x04\\x06\\t\\x06' +\n    '\\x04\\x07\\t\\x07\\x04\\b\\t\\b\\x04\\t\\t\\t\\x04\\n\\t\\n\\x04\\v\\t\\v\\x04\\f\\t\\f\\x04\\r' +\n    '\\t\\r\\x04\\x0E\\t\\x0E\\x04\\x0F\\t\\x0F\\x04\\x10\\t\\x10\\x04\\x11\\t\\x11\\x04\\x12\\t' +\n    '\\x12\\x04\\x13\\t\\x13\\x04\\x14\\t\\x14\\x04\\x15\\t\\x15\\x04\\x16\\t\\x16\\x04\\x17\\t' +\n    '\\x17\\x04\\x18\\t\\x18\\x04\\x19\\t\\x19\\x04\\x1A\\t\\x1A\\x04\\x1B\\t\\x1B\\x04\\x1C\\t' +\n    '\\x1C\\x04\\x1D\\t\\x1D\\x04\\x1E\\t\\x1E\\x04\\x1F\\t\\x1F\\x04 \\t \\x04!\\t!\\x04\"\\t' +\n    \"\\\"\\x04#\\t#\\x04$\\t$\\x04%\\t%\\x04&\\t&\\x04'\\t'\\x04(\\t(\\x04)\\t)\\x04*\\t*\\x04\" +\n    '+\\t+\\x04,\\t,\\x04-\\t-\\x04.\\t.\\x04/\\t/\\x040\\t0\\x041\\t1\\x042\\t2\\x043\\t3\\x04' +\n    '4\\t4\\x045\\t5\\x046\\t6\\x047\\t7\\x048\\t8\\x049\\t9\\x04:\\t:\\x04;\\t;\\x04<\\t<\\x04' +\n    '=\\t=\\x04>\\t>\\x04?\\t?\\x04@\\t@\\x04A\\tA\\x03\\x02\\x03\\x02\\x03\\x03\\x03\\x03\\x03' +\n    '\\x04\\x03\\x04\\x03\\x05\\x03\\x05\\x03\\x06\\x03\\x06\\x03\\x07\\x03\\x07\\x03\\b\\x03' +\n    '\\b\\x03\\t\\x03\\t\\x03\\n\\x03\\n\\x03\\v\\x03\\v\\x03\\f\\x03\\f\\x03\\r\\x03\\r\\x03\\x0E' +\n    '\\x03\\x0E\\x03\\x0F\\x03\\x0F\\x03\\x10\\x03\\x10\\x03\\x11\\x03\\x11\\x03\\x12\\x03\\x12' +\n    '\\x03\\x13\\x03\\x13\\x03\\x14\\x03\\x14\\x03\\x15\\x03\\x15\\x03\\x16\\x03\\x16\\x03\\x17' +\n    '\\x03\\x17\\x03\\x18\\x03\\x18\\x03\\x19\\x03\\x19\\x03\\x1A\\x03\\x1A\\x03\\x1B\\x03\\x1B' +\n    '\\x03\\x1C\\x03\\x1C\\x03\\x1D\\x03\\x1D\\x03\\x1D\\x03\\x1D\\x07\\x1D\\xBE\\n\\x1D\\f\\x1D' +\n    '\\x0E\\x1D\\xC1\\v\\x1D\\x03\\x1D\\x03\\x1D\\x03\\x1E\\x03\\x1E\\x03\\x1E\\x03\\x1E\\x07' +\n    '\\x1E\\xC9\\n\\x1E\\f\\x1E\\x0E\\x1E\\xCC\\v\\x1E\\x03\\x1E\\x03\\x1E\\x03\\x1F\\x03\\x1F' +\n    '\\x03 \\x03 \\x03!\\x03!\\x03\"\\x03\"\\x03#\\x03#\\x03$\\x03$\\x03%\\x03%\\x03&\\x03' +\n    \"&\\x03'\\x03'\\x06'\\xE2\\n'\\r'\\x0E'\\xE3\\x03'\\x03'\\x03(\\x03(\\x03)\\x03\" +\n    ')\\x03*\\x05*\\xED\\n*\\x03*\\x06*\\xF0\\n*\\r*\\x0E*\\xF1\\x03*\\x03*\\x06*\\xF6\\n*' +\n    '\\r*\\x0E*\\xF7\\x05*\\xFA\\n*\\x03+\\x05+\\xFD\\n+\\x03+\\x06+\\u0100\\n+\\r+\\x0E+\\u0101' +\n    '\\x03+\\x03+\\x06+\\u0106\\n+\\r+\\x0E+\\u0107\\x03+\\x03+\\x07+\\u010C\\n+\\f+\\x0E' +\n    '+\\u010F\\v+\\x03+\\x06+\\u0112\\n+\\r+\\x0E+\\u0113\\x05+\\u0116\\n+\\x03,\\x03,\\x03' +\n    '-\\x03-\\x03-\\x03.\\x03.\\x03.\\x03.\\x03.\\x03/\\x03/\\x030\\x030\\x030\\x031\\x03' +\n    '1\\x032\\x032\\x032\\x033\\x033\\x033\\x033\\x033\\x034\\x034\\x034\\x034\\x034\\x03' +\n    '4\\x035\\x035\\x035\\x035\\x036\\x036\\x036\\x037\\x037\\x037\\x037\\x038\\x038\\x03' +\n    '8\\x038\\x038\\x039\\x039\\x039\\x03:\\x03:\\x03:\\x03:\\x03:\\x03:\\x03:\\x03:\\x03' +\n    ';\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03;\\x03<\\x03<\\x03' +\n    '<\\x03<\\x03<\\x03=\\x03=\\x03=\\x03>\\x03>\\x03>\\x03>\\x03?\\x03?\\x03?\\x03?\\x03' +\n    '?\\x03?\\x03?\\x03?\\x03?\\x03@\\x03@\\x03@\\x03@\\x03@\\x03@\\x03@\\x03A\\x03A\\x03' +\n    'A\\x03A\\x02\\x02\\x02B\\x03\\x02\\x02\\x05\\x02\\x02\\x07\\x02\\x02\\t\\x02\\x02\\v\\x02' +\n    '\\x02\\r\\x02\\x02\\x0F\\x02\\x02\\x11\\x02\\x02\\x13\\x02\\x02\\x15\\x02\\x02\\x17\\x02' +\n    '\\x02\\x19\\x02\\x02\\x1B\\x02\\x02\\x1D\\x02\\x02\\x1F\\x02\\x02!\\x02\\x02#\\x02\\x02' +\n    \"%\\x02\\x02'\\x02\\x02)\\x02\\x02+\\x02\\x02-\\x02\\x02/\\x02\\x021\\x02\\x023\\x02\" +\n    '\\x025\\x02\\x027\\x02\\x029\\x02\\x02;\\x02\\x02=\\x02\\x02?\\x02\\x03A\\x02\\x04C\\x02' +\n    '\\x05E\\x02\\x06G\\x02\\x07I\\x02\\bK\\x02\\tM\\x02\\nO\\x02\\vQ\\x02\\fS\\x02\\rU\\x02' +\n    '\\x0EW\\x02\\x0FY\\x02\\x10[\\x02$]\\x02\\x11_\\x02\\x12a\\x02\\x13c\\x02\\x14e\\x02' +\n    '\\x15g\\x02\\x16i\\x02\\x17k\\x02\\x18m\\x02\\x19o\\x02\\x1Aq\\x02\\x1Bs\\x02\\x1Cu\\x02' +\n    '\\x1Dw\\x02\\x1Ey\\x02\\x1F{\\x02 }\\x02!\\x7F\\x02\"\\x81\\x02#\\x03\\x02\"\\x04\\x02' +\n    'CCcc\\x04\\x02DDdd\\x04\\x02EEee\\x04\\x02FFff\\x04\\x02GGgg\\x04\\x02HHhh\\x04\\x02' +\n    'IIii\\x04\\x02JJjj\\x04\\x02KKkk\\x04\\x02LLll\\x04\\x02MMmm\\x04\\x02NNnn\\x04\\x02' +\n    'OOoo\\x04\\x02PPpp\\x04\\x02QQqq\\x04\\x02RRrr\\x04\\x02SSss\\x04\\x02TTtt\\x04\\x02' +\n    'UUuu\\x04\\x02VVvv\\x04\\x02WWww\\x04\\x02XXxx\\x04\\x02YYyy\\x04\\x02ZZzz\\x04\\x02' +\n    '[[{{\\x04\\x02\\\\\\\\||\\x03\\x022;\\x04\\x02$$^^\\x04\\x02))^^\\x04\\x02\\v\\v\"\"\\x03' +\n    '\\x02\\x7F\\x7F\\x05\\x02\\v\\f\\x0F\\x0F\"\"\\x02\\u016D\\x02?\\x03\\x02\\x02\\x02\\x02' +\n    'A\\x03\\x02\\x02\\x02\\x02C\\x03\\x02\\x02\\x02\\x02E\\x03\\x02\\x02\\x02\\x02G\\x03\\x02' +\n    '\\x02\\x02\\x02I\\x03\\x02\\x02\\x02\\x02K\\x03\\x02\\x02\\x02\\x02M\\x03\\x02\\x02\\x02' +\n    '\\x02O\\x03\\x02\\x02\\x02\\x02Q\\x03\\x02\\x02\\x02\\x02S\\x03\\x02\\x02\\x02\\x02U\\x03' +\n    '\\x02\\x02\\x02\\x02W\\x03\\x02\\x02\\x02\\x02Y\\x03\\x02\\x02\\x02\\x02[\\x03\\x02\\x02' +\n    '\\x02\\x02]\\x03\\x02\\x02\\x02\\x02_\\x03\\x02\\x02\\x02\\x02a\\x03\\x02\\x02\\x02\\x02' +\n    'c\\x03\\x02\\x02\\x02\\x02e\\x03\\x02\\x02\\x02\\x02g\\x03\\x02\\x02\\x02\\x02i\\x03\\x02' +\n    '\\x02\\x02\\x02k\\x03\\x02\\x02\\x02\\x02m\\x03\\x02\\x02\\x02\\x02o\\x03\\x02\\x02\\x02' +\n    '\\x02q\\x03\\x02\\x02\\x02\\x02s\\x03\\x02\\x02\\x02\\x02u\\x03\\x02\\x02\\x02\\x02w\\x03' +\n    '\\x02\\x02\\x02\\x02y\\x03\\x02\\x02\\x02\\x02{\\x03\\x02\\x02\\x02\\x02}\\x03\\x02\\x02' +\n    '\\x02\\x02\\x7F\\x03\\x02\\x02\\x02\\x02\\x81\\x03\\x02\\x02\\x02\\x03\\x83\\x03\\x02\\x02' +\n    '\\x02\\x05\\x85\\x03\\x02\\x02\\x02\\x07\\x87\\x03\\x02\\x02\\x02\\t\\x89\\x03\\x02\\x02' +\n    '\\x02\\v\\x8B\\x03\\x02\\x02\\x02\\r\\x8D\\x03\\x02\\x02\\x02\\x0F\\x8F\\x03\\x02\\x02\\x02' +\n    '\\x11\\x91\\x03\\x02\\x02\\x02\\x13\\x93\\x03\\x02\\x02\\x02\\x15\\x95\\x03\\x02\\x02\\x02' +\n    '\\x17\\x97\\x03\\x02\\x02\\x02\\x19\\x99\\x03\\x02\\x02\\x02\\x1B\\x9B\\x03\\x02\\x02\\x02' +\n    '\\x1D\\x9D\\x03\\x02\\x02\\x02\\x1F\\x9F\\x03\\x02\\x02\\x02!\\xA1\\x03\\x02\\x02\\x02' +\n    \"#\\xA3\\x03\\x02\\x02\\x02%\\xA5\\x03\\x02\\x02\\x02'\\xA7\\x03\\x02\\x02\\x02)\\xA9\" +\n    '\\x03\\x02\\x02\\x02+\\xAB\\x03\\x02\\x02\\x02-\\xAD\\x03\\x02\\x02\\x02/\\xAF\\x03\\x02' +\n    '\\x02\\x021\\xB1\\x03\\x02\\x02\\x023\\xB3\\x03\\x02\\x02\\x025\\xB5\\x03\\x02\\x02\\x02' +\n    '7\\xB7\\x03\\x02\\x02\\x029\\xB9\\x03\\x02\\x02\\x02;\\xC4\\x03\\x02\\x02\\x02=\\xCF\\x03' +\n    '\\x02\\x02\\x02?\\xD1\\x03\\x02\\x02\\x02A\\xD3\\x03\\x02\\x02\\x02C\\xD5\\x03\\x02\\x02' +\n    '\\x02E\\xD7\\x03\\x02\\x02\\x02G\\xD9\\x03\\x02\\x02\\x02I\\xDB\\x03\\x02\\x02\\x02K\\xDD' +\n    '\\x03\\x02\\x02\\x02M\\xDF\\x03\\x02\\x02\\x02O\\xE7\\x03\\x02\\x02\\x02Q\\xE9\\x03\\x02' +\n    '\\x02\\x02S\\xEC\\x03\\x02\\x02\\x02U\\xFC\\x03\\x02\\x02\\x02W\\u0117\\x03\\x02\\x02' +\n    '\\x02Y\\u0119\\x03\\x02\\x02\\x02[\\u011C\\x03\\x02\\x02\\x02]\\u0121\\x03\\x02\\x02' +\n    '\\x02_\\u0123\\x03\\x02\\x02\\x02a\\u0126\\x03\\x02\\x02\\x02c\\u0128\\x03\\x02\\x02' +\n    '\\x02e\\u012B\\x03\\x02\\x02\\x02g\\u0130\\x03\\x02\\x02\\x02i\\u0136\\x03\\x02\\x02' +\n    '\\x02k\\u013A\\x03\\x02\\x02\\x02m\\u013D\\x03\\x02\\x02\\x02o\\u0141\\x03\\x02\\x02' +\n    '\\x02q\\u0146\\x03\\x02\\x02\\x02s\\u0149\\x03\\x02\\x02\\x02u\\u0151\\x03\\x02\\x02' +\n    '\\x02w\\u015D\\x03\\x02\\x02\\x02y\\u0162\\x03\\x02\\x02\\x02{\\u0165\\x03\\x02\\x02' +\n    '\\x02}\\u0169\\x03\\x02\\x02\\x02\\x7F\\u0172\\x03\\x02\\x02\\x02\\x81\\u0179\\x03\\x02' +\n    '\\x02\\x02\\x83\\x84\\t\\x02\\x02\\x02\\x84\\x04\\x03\\x02\\x02\\x02\\x85\\x86\\t\\x03\\x02' +\n    '\\x02\\x86\\x06\\x03\\x02\\x02\\x02\\x87\\x88\\t\\x04\\x02\\x02\\x88\\b\\x03\\x02\\x02\\x02' +\n    '\\x89\\x8A\\t\\x05\\x02\\x02\\x8A\\n\\x03\\x02\\x02\\x02\\x8B\\x8C\\t\\x06\\x02\\x02\\x8C' +\n    '\\f\\x03\\x02\\x02\\x02\\x8D\\x8E\\t\\x07\\x02\\x02\\x8E\\x0E\\x03\\x02\\x02\\x02\\x8F\\x90' +\n    '\\t\\b\\x02\\x02\\x90\\x10\\x03\\x02\\x02\\x02\\x91\\x92\\t\\t\\x02\\x02\\x92\\x12\\x03\\x02' +\n    '\\x02\\x02\\x93\\x94\\t\\n\\x02\\x02\\x94\\x14\\x03\\x02\\x02\\x02\\x95\\x96\\t\\v\\x02\\x02' +\n    '\\x96\\x16\\x03\\x02\\x02\\x02\\x97\\x98\\t\\f\\x02\\x02\\x98\\x18\\x03\\x02\\x02\\x02\\x99' +\n    '\\x9A\\t\\r\\x02\\x02\\x9A\\x1A\\x03\\x02\\x02\\x02\\x9B\\x9C\\t\\x0E\\x02\\x02\\x9C\\x1C' +\n    '\\x03\\x02\\x02\\x02\\x9D\\x9E\\t\\x0F\\x02\\x02\\x9E\\x1E\\x03\\x02\\x02\\x02\\x9F\\xA0' +\n    '\\t\\x10\\x02\\x02\\xA0 \\x03\\x02\\x02\\x02\\xA1\\xA2\\t\\x11\\x02\\x02\\xA2\"\\x03\\x02' +\n    '\\x02\\x02\\xA3\\xA4\\t\\x12\\x02\\x02\\xA4$\\x03\\x02\\x02\\x02\\xA5\\xA6\\t\\x13\\x02' +\n    '\\x02\\xA6&\\x03\\x02\\x02\\x02\\xA7\\xA8\\t\\x14\\x02\\x02\\xA8(\\x03\\x02\\x02\\x02\\xA9' +\n    '\\xAA\\t\\x15\\x02\\x02\\xAA*\\x03\\x02\\x02\\x02\\xAB\\xAC\\t\\x16\\x02\\x02\\xAC,\\x03' +\n    '\\x02\\x02\\x02\\xAD\\xAE\\t\\x17\\x02\\x02\\xAE.\\x03\\x02\\x02\\x02\\xAF\\xB0\\t\\x18' +\n    '\\x02\\x02\\xB00\\x03\\x02\\x02\\x02\\xB1\\xB2\\t\\x19\\x02\\x02\\xB22\\x03\\x02\\x02\\x02' +\n    '\\xB3\\xB4\\t\\x1A\\x02\\x02\\xB44\\x03\\x02\\x02\\x02\\xB5\\xB6\\t\\x1B\\x02\\x02\\xB6' +\n    '6\\x03\\x02\\x02\\x02\\xB7\\xB8\\t\\x1C\\x02\\x02\\xB88\\x03\\x02\\x02\\x02\\xB9\\xBF\\x07' +\n    '$\\x02\\x02\\xBA\\xBB\\x07^\\x02\\x02\\xBB\\xBE\\v\\x02\\x02\\x02\\xBC\\xBE\\n\\x1D\\x02' +\n    '\\x02\\xBD\\xBA\\x03\\x02\\x02\\x02\\xBD\\xBC\\x03\\x02\\x02\\x02\\xBE\\xC1\\x03\\x02\\x02' +\n    '\\x02\\xBF\\xBD\\x03\\x02\\x02\\x02\\xBF\\xC0\\x03\\x02\\x02\\x02\\xC0\\xC2\\x03\\x02\\x02' +\n    '\\x02\\xC1\\xBF\\x03\\x02\\x02\\x02\\xC2\\xC3\\x07$\\x02\\x02\\xC3:\\x03\\x02\\x02\\x02' +\n    '\\xC4\\xCA\\x07)\\x02\\x02\\xC5\\xC6\\x07^\\x02\\x02\\xC6\\xC9\\v\\x02\\x02\\x02\\xC7\\xC9' +\n    '\\n\\x1E\\x02\\x02\\xC8\\xC5\\x03\\x02\\x02\\x02\\xC8\\xC7\\x03\\x02\\x02\\x02\\xC9\\xCC' +\n    '\\x03\\x02\\x02\\x02\\xCA\\xC8\\x03\\x02\\x02\\x02\\xCA\\xCB\\x03\\x02\\x02\\x02\\xCB\\xCD' +\n    '\\x03\\x02\\x02\\x02\\xCC\\xCA\\x03\\x02\\x02\\x02\\xCD\\xCE\\x07)\\x02\\x02\\xCE<\\x03' +\n    '\\x02\\x02\\x02\\xCF\\xD0\\t\\x1F\\x02\\x02\\xD0>\\x03\\x02\\x02\\x02\\xD1\\xD2\\x07.\\x02' +\n    '\\x02\\xD2@\\x03\\x02\\x02\\x02\\xD3\\xD4\\x07*\\x02\\x02\\xD4B\\x03\\x02\\x02\\x02\\xD5' +\n    '\\xD6\\x07+\\x02\\x02\\xD6D\\x03\\x02\\x02\\x02\\xD7\\xD8\\x07]\\x02\\x02\\xD8F\\x03\\x02' +\n    '\\x02\\x02\\xD9\\xDA\\x07_\\x02\\x02\\xDAH\\x03\\x02\\x02\\x02\\xDB\\xDC\\x07}\\x02\\x02' +\n    '\\xDCJ\\x03\\x02\\x02\\x02\\xDD\\xDE\\x07\\x7F\\x02\\x02\\xDEL\\x03\\x02\\x02\\x02\\xDF' +\n    '\\xE1\\x07}\\x02\\x02\\xE0\\xE2\\n \\x02\\x02\\xE1\\xE0\\x03\\x02\\x02\\x02\\xE2\\xE3\\x03' +\n    '\\x02\\x02\\x02\\xE3\\xE1\\x03\\x02\\x02\\x02\\xE3\\xE4\\x03\\x02\\x02\\x02\\xE4\\xE5\\x03' +\n    '\\x02\\x02\\x02\\xE5\\xE6\\x07\\x7F\\x02\\x02\\xE6N\\x03\\x02\\x02\\x02\\xE7\\xE8\\x05' +\n    ';\\x1E\\x02\\xE8P\\x03\\x02\\x02\\x02\\xE9\\xEA\\x059\\x1D\\x02\\xEAR\\x03\\x02\\x02\\x02' +\n    '\\xEB\\xED\\x07/\\x02\\x02\\xEC\\xEB\\x03\\x02\\x02\\x02\\xEC\\xED\\x03\\x02\\x02\\x02' +\n    '\\xED\\xEF\\x03\\x02\\x02\\x02\\xEE\\xF0\\x057\\x1C\\x02\\xEF\\xEE\\x03\\x02\\x02\\x02' +\n    '\\xF0\\xF1\\x03\\x02\\x02\\x02\\xF1\\xEF\\x03\\x02\\x02\\x02\\xF1\\xF2\\x03\\x02\\x02\\x02' +\n    '\\xF2\\xF9\\x03\\x02\\x02\\x02\\xF3\\xF5\\t\\x06\\x02\\x02\\xF4\\xF6\\x057\\x1C\\x02\\xF5' +\n    '\\xF4\\x03\\x02\\x02\\x02\\xF6\\xF7\\x03\\x02\\x02\\x02\\xF7\\xF5\\x03\\x02\\x02\\x02\\xF7' +\n    '\\xF8\\x03\\x02\\x02\\x02\\xF8\\xFA\\x03\\x02\\x02\\x02\\xF9\\xF3\\x03\\x02\\x02\\x02\\xF9' +\n    '\\xFA\\x03\\x02\\x02\\x02\\xFAT\\x03\\x02\\x02\\x02\\xFB\\xFD\\x07/\\x02\\x02\\xFC\\xFB' +\n    '\\x03\\x02\\x02\\x02\\xFC\\xFD\\x03\\x02\\x02\\x02\\xFD\\xFF\\x03\\x02\\x02\\x02\\xFE\\u0100' +\n    '\\x057\\x1C\\x02\\xFF\\xFE\\x03\\x02\\x02\\x02\\u0100\\u0101\\x03\\x02\\x02\\x02\\u0101' +\n    '\\xFF\\x03\\x02\\x02\\x02\\u0101\\u0102\\x03\\x02\\x02\\x02\\u0102\\u0103\\x03\\x02\\x02' +\n    '\\x02\\u0103\\u0105\\x070\\x02\\x02\\u0104\\u0106\\x057\\x1C\\x02\\u0105\\u0104\\x03' +\n    '\\x02\\x02\\x02\\u0106\\u0107\\x03\\x02\\x02\\x02\\u0107\\u0105\\x03\\x02\\x02\\x02\\u0107' +\n    '\\u0108\\x03\\x02\\x02\\x02\\u0108\\u0115\\x03\\x02\\x02\\x02\\u0109\\u010D\\t\\x06\\x02' +\n    '\\x02\\u010A\\u010C\\x07/\\x02\\x02\\u010B\\u010A\\x03\\x02\\x02\\x02\\u010C\\u010F' +\n    '\\x03\\x02\\x02\\x02\\u010D\\u010B\\x03\\x02\\x02\\x02\\u010D\\u010E\\x03\\x02\\x02\\x02' +\n    '\\u010E\\u0111\\x03\\x02\\x02\\x02\\u010F\\u010D\\x03\\x02\\x02\\x02\\u0110\\u0112\\x05' +\n    '7\\x1C\\x02\\u0111\\u0110\\x03\\x02\\x02\\x02\\u0112\\u0113\\x03\\x02\\x02\\x02\\u0113' +\n    '\\u0111\\x03\\x02\\x02\\x02\\u0113\\u0114\\x03\\x02\\x02\\x02\\u0114\\u0116\\x03\\x02' +\n    '\\x02\\x02\\u0115\\u0109\\x03\\x02\\x02\\x02\\u0115\\u0116\\x03\\x02\\x02\\x02\\u0116' +\n    'V\\x03\\x02\\x02\\x02\\u0117\\u0118\\x07?\\x02\\x02\\u0118X\\x03\\x02\\x02\\x02\\u0119' +\n    '\\u011A\\x07#\\x02\\x02\\u011A\\u011B\\x07?\\x02\\x02\\u011BZ\\x03\\x02\\x02\\x02\\u011C' +\n    '\\u011D\\x07>\\x02\\x02\\u011D\\u011E\\x07@\\x02\\x02\\u011E\\u011F\\x03\\x02\\x02\\x02' +\n    '\\u011F\\u0120\\b.\\x02\\x02\\u0120\\\\\\x03\\x02\\x02\\x02\\u0121\\u0122\\x07@\\x02\\x02' +\n    '\\u0122^\\x03\\x02\\x02\\x02\\u0123\\u0124\\x07@\\x02\\x02\\u0124\\u0125\\x07?\\x02' +\n    '\\x02\\u0125`\\x03\\x02\\x02\\x02\\u0126\\u0127\\x07>\\x02\\x02\\u0127b\\x03\\x02\\x02' +\n    '\\x02\\u0128\\u0129\\x07>\\x02\\x02\\u0129\\u012A\\x07?\\x02\\x02\\u012Ad\\x03\\x02' +\n    '\\x02\\x02\\u012B\\u012C\\x05)\\x15\\x02\\u012C\\u012D\\x05%\\x13\\x02\\u012D\\u012E' +\n    '\\x05+\\x16\\x02\\u012E\\u012F\\x05\\v\\x06\\x02\\u012Ff\\x03\\x02\\x02\\x02\\u0130\\u0131' +\n    '\\x05\\r\\x07\\x02\\u0131\\u0132\\x05\\x03\\x02\\x02\\u0132\\u0133\\x05\\x19\\r\\x02\\u0133' +\n    \"\\u0134\\x05'\\x14\\x02\\u0134\\u0135\\x05\\v\\x06\\x02\\u0135h\\x03\\x02\\x02\\x02\" +\n    '\\u0136\\u0137\\x05\\x03\\x02\\x02\\u0137\\u0138\\x05\\x1D\\x0F\\x02\\u0138\\u0139\\x05' +\n    '\\t\\x05\\x02\\u0139j\\x03\\x02\\x02\\x02\\u013A\\u013B\\x05\\x1F\\x10\\x02\\u013B\\u013C' +\n    '\\x05%\\x13\\x02\\u013Cl\\x03\\x02\\x02\\x02\\u013D\\u013E\\x05\\x1D\\x0F\\x02\\u013E' +\n    '\\u013F\\x05\\x1F\\x10\\x02\\u013F\\u0140\\x05)\\x15\\x02\\u0140n\\x03\\x02\\x02\\x02' +\n    '\\u0141\\u0142\\x05\\x1D\\x0F\\x02\\u0142\\u0143\\x05+\\x16\\x02\\u0143\\u0144\\x05' +\n    '\\x19\\r\\x02\\u0144\\u0145\\x05\\x19\\r\\x02\\u0145p\\x03\\x02\\x02\\x02\\u0146\\u0147' +\n    \"\\x05\\x13\\n\\x02\\u0147\\u0148\\x05'\\x14\\x02\\u0148r\\x03\\x02\\x02\\x02\\u0149\" +\n    \"\\u014A\\x05\\x13\\n\\x02\\u014A\\u014B\\x05'\\x14\\x02\\u014B\\u014C\\x05=\\x1F\\x02\" +\n    '\\u014C\\u014D\\x05\\x1D\\x0F\\x02\\u014D\\u014E\\x05+\\x16\\x02\\u014E\\u014F\\x05' +\n    '\\x19\\r\\x02\\u014F\\u0150\\x05\\x19\\r\\x02\\u0150t\\x03\\x02\\x02\\x02\\u0151\\u0152' +\n    \"\\x05\\x13\\n\\x02\\u0152\\u0153\\x05'\\x14\\x02\\u0153\\u0154\\x05=\\x1F\\x02\\u0154\" +\n    '\\u0155\\x05\\x1D\\x0F\\x02\\u0155\\u0156\\x05\\x1F\\x10\\x02\\u0156\\u0157\\x05)\\x15' +\n    '\\x02\\u0157\\u0158\\x05=\\x1F\\x02\\u0158\\u0159\\x05\\x1D\\x0F\\x02\\u0159\\u015A' +\n    '\\x05+\\x16\\x02\\u015A\\u015B\\x05\\x19\\r\\x02\\u015B\\u015C\\x05\\x19\\r\\x02\\u015C' +\n    'v\\x03\\x02\\x02\\x02\\u015D\\u015E\\x05\\x19\\r\\x02\\u015E\\u015F\\x05\\x13\\n\\x02' +\n    '\\u015F\\u0160\\x05\\x17\\f\\x02\\u0160\\u0161\\x05\\v\\x06\\x02\\u0161x\\x03\\x02\\x02' +\n    '\\x02\\u0162\\u0163\\x05\\x13\\n\\x02\\u0163\\u0164\\x05\\x1D\\x0F\\x02\\u0164z\\x03' +\n    '\\x02\\x02\\x02\\u0165\\u0166\\x05\\x11\\t\\x02\\u0166\\u0167\\x05\\x03\\x02\\x02\\u0167' +\n    \"\\u0168\\x05'\\x14\\x02\\u0168|\\x03\\x02\\x02\\x02\\u0169\\u016A\\x05\\x1D\\x0F\\x02\" +\n    '\\u016A\\u016B\\x05\\x1F\\x10\\x02\\u016B\\u016C\\x05)\\x15\\x02\\u016C\\u016D\\x05' +\n    '=\\x1F\\x02\\u016D\\u016E\\x05\\x19\\r\\x02\\u016E\\u016F\\x05\\x13\\n\\x02\\u016F\\u0170' +\n    '\\x05\\x17\\f\\x02\\u0170\\u0171\\x05\\v\\x06\\x02\\u0171~\\x03\\x02\\x02\\x02\\u0172' +\n    '\\u0173\\x05\\x1D\\x0F\\x02\\u0173\\u0174\\x05\\x1F\\x10\\x02\\u0174\\u0175\\x05)\\x15' +\n    '\\x02\\u0175\\u0176\\x05=\\x1F\\x02\\u0176\\u0177\\x05\\x13\\n\\x02\\u0177\\u0178\\x05' +\n    '\\x1D\\x0F\\x02\\u0178\\x80\\x03\\x02\\x02\\x02\\u0179\\u017A\\t!\\x02\\x02\\u017A\\u017B' +\n    '\\x03\\x02\\x02\\x02\\u017B\\u017C\\bA\\x03\\x02\\u017C\\x82\\x03\\x02\\x02\\x02\\x12' +\n    '\\x02\\xBD\\xBF\\xC8\\xCA\\xE3\\xEC\\xF1\\xF7\\xF9\\xFC\\u0101\\u0107\\u010D\\u0113\\u0115' +\n    '\\x04\\t\\x10\\x02\\x02\\x03\\x02';\n  public static __ATN: ATN;\n  public static get _ATN(): ATN {\n    if (!QueryLexer.__ATN) {\n      QueryLexer.__ATN = new ATNDeserializer().deserialize(\n        Utils.toCharArray(QueryLexer._serializedATN)\n      );\n    }\n\n    return QueryLexer.__ATN;\n  }\n}\n"
  },
  {
    "path": "packages/core/src/query/parser/QueryVisitor.ts",
    "content": "// Generated from src/query/parser/Query.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport type { ParseTreeVisitor } from 'antlr4ts/tree/ParseTreeVisitor';\n\nimport type {\n  PredicateExprLikeContext,\n  PredicateExprInContext,\n  PredicateExprHasContext,\n  PredicateExprEqArrayContext,\n  PrimaryExprPredicateContext,\n  PrimaryExprIsContext,\n  PrimaryExprCompareContext,\n  QueryExprContext,\n  BinaryExprContext,\n  ParenQueryExprContext,\n  StartContext,\n  ExprContext,\n  QueryStatementContext,\n  PredicateContext,\n  FieldIdentifierContext,\n  CompOpContext,\n  IsOpContext,\n  LikeOpContext,\n  InOpContext,\n  ValueContext,\n  ValueListContext,\n  LiteralContext,\n  StringLiteralContext,\n  NumberLiteralContext,\n  BooleanLiteralContext,\n  NullLiteralContext,\n} from './Query';\n\n/**\n * This interface defines a complete generic visitor for a parse tree produced\n * by `Query`.\n *\n * @param <Result> The return type of the visit operation. Use `void` for\n * operations with no return type.\n */\nexport interface QueryVisitor<Result> extends ParseTreeVisitor<Result> {\n  /**\n   * Visit a parse tree produced by the `predicateExprLike`\n   * labeled alternative in `Query.predicate`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPredicateExprLike?: (ctx: PredicateExprLikeContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `predicateExprIn`\n   * labeled alternative in `Query.predicate`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPredicateExprIn?: (ctx: PredicateExprInContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `predicateExprHas`\n   * labeled alternative in `Query.predicate`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPredicateExprHas?: (ctx: PredicateExprHasContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `predicateExprEqArray`\n   * labeled alternative in `Query.predicate`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPredicateExprEqArray?: (ctx: PredicateExprEqArrayContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `primaryExprPredicate`\n   * labeled alternative in `Query.queryStatement`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPrimaryExprPredicate?: (ctx: PrimaryExprPredicateContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `primaryExprIs`\n   * labeled alternative in `Query.queryStatement`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPrimaryExprIs?: (ctx: PrimaryExprIsContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `primaryExprCompare`\n   * labeled alternative in `Query.queryStatement`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPrimaryExprCompare?: (ctx: PrimaryExprCompareContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `queryExpr`\n   * labeled alternative in `Query.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitQueryExpr?: (ctx: QueryExprContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `binaryExpr`\n   * labeled alternative in `Query.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitBinaryExpr?: (ctx: BinaryExprContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `parenQueryExpr`\n   * labeled alternative in `Query.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitParenQueryExpr?: (ctx: ParenQueryExprContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.start`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitStart?: (ctx: StartContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitExpr?: (ctx: ExprContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.queryStatement`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitQueryStatement?: (ctx: QueryStatementContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.predicate`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitPredicate?: (ctx: PredicateContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.fieldIdentifier`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitFieldIdentifier?: (ctx: FieldIdentifierContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.compOp`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitCompOp?: (ctx: CompOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.isOp`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitIsOp?: (ctx: IsOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.likeOp`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitLikeOp?: (ctx: LikeOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.inOp`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitInOp?: (ctx: InOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.value`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitValue?: (ctx: ValueContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.valueList`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitValueList?: (ctx: ValueListContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.literal`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitLiteral?: (ctx: LiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.stringLiteral`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitStringLiteral?: (ctx: StringLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.numberLiteral`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitNumberLiteral?: (ctx: NumberLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.booleanLiteral`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitBooleanLiteral?: (ctx: BooleanLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Query.nullLiteral`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitNullLiteral?: (ctx: NullLiteralContext) => Result;\n}\n"
  },
  {
    "path": "packages/core/src/typeguards/__tests__/typeguards.test.ts",
    "content": "import {\n  isHttpStatusCode,\n  isIsoDateString,\n  isNonEmptyString,\n  isParsableNumeric,\n  isParsableSafeInteger,\n  isPlainObject,\n  isPresent,\n} from '../';\n\ndescribe('Typeguards tests', () => {\n  describe('isNonEmptyString', () => {\n    it('should trim by default', () => {\n      expect(isNonEmptyString('  ')).toStrictEqual(isNonEmptyString(''));\n    });\n    describe('when trim === true (default)', () => {\n      it('should work as expected', () => {\n        expect(isNonEmptyString('cool')).toBeTruthy();\n        expect(isNonEmptyString(1)).toBeFalsy();\n        expect(isNonEmptyString('  ')).toBeFalsy();\n        expect(isNonEmptyString('')).toBeFalsy();\n        expect(isNonEmptyString(null)).toBeFalsy();\n        expect(isNonEmptyString({})).toBeFalsy();\n      });\n    });\n    describe('when trim === false', () => {\n      it('should work as expected', () => {\n        expect(isNonEmptyString('cool ', false)).toBeTruthy();\n        expect(isNonEmptyString('  ', false)).toBeTruthy();\n      });\n    });\n  });\n  describe('isParsableNumeric', () => {\n    it.each([\n      [10, true],\n      ['100', true],\n      ['-3', true],\n      ['2.12', true],\n      [NaN, false],\n      [undefined, false],\n      [false, false],\n      [null, false],\n      [{}, false],\n      [[], false],\n      [new Date(), false],\n    ])('when \"%p\" is given should return \"%b\"', (value, expected) => {\n      expect(isParsableNumeric(value)).toStrictEqual(expected);\n    });\n  });\n\n  describe('isHttpStatusCode', () => {\n    it.each([\n      [200, true],\n      [800, false],\n      ['-3', false],\n      [NaN, false],\n      [undefined, false],\n      [false, false],\n      [null, false],\n      [[], false],\n      [new Date(), false],\n    ])('when \"%p\" is given should return \"%b\"', (value, expected) => {\n      expect(isHttpStatusCode(value)).toStrictEqual(expected);\n    });\n  });\n\n  describe('isIsoDateString', () => {\n    it('should return true for valid isoDate strings', () => {\n      expect(isIsoDateString('2022-02-06T15:20:19.131Z')).toBeTruthy();\n    });\n    it('should return false for invalid isDate strings', () => {\n      expect(isIsoDateString('2022-40-20T15:20:19.131Z')).toBeFalsy();\n      expect(isIsoDateString(new Date())).toBeFalsy();\n      expect(isIsoDateString(null)).toBeFalsy();\n    });\n  });\n\n  describe('isPlainObject', () => {\n    it.each([\n      [{}, true],\n      [{ name: 'seb' }, true],\n      [{ name: 'deep', children: [{ test: 1 }] }, true],\n      [new Date(), false],\n      [false, false],\n      [undefined, false],\n      [null, false],\n      [() => 'cool', false],\n    ])('when \"%p\" is given, should return %p', (v, expected) => {\n      expect(isPlainObject(v)).toStrictEqual(expected);\n    });\n  });\n\n  describe('isParsableSafeInteger', () => {\n    it.each([\n      [10, true],\n      [-10, true],\n      ['10', true],\n      ['-10', true],\n      [Number.MAX_SAFE_INTEGER, true],\n      [`${Number.MIN_SAFE_INTEGER}`, true],\n      [BigInt(1), false],\n      [0, true],\n      ['0', true],\n      ['0.0', false],\n      [1.234, false],\n      [false, false],\n      [undefined, false],\n      [null, false],\n      [() => 'cool', false],\n    ])('when \"%p\" is given, should return %p', (v, expected) => {\n      expect(isParsableSafeInteger(v)).toStrictEqual(expected);\n    });\n  });\n\n  describe('isPresent', () => {\n    it('should return false when null or undefined', () => {\n      expect(isPresent(null)).toBeFalsy();\n      expect(isPresent(undefined)).toBeFalsy();\n    });\n    it('should return true when not null and not undefined', () => {\n      expect(isPresent(false)).toBeTruthy();\n      expect(isPresent(true)).toBeTruthy();\n      expect(isPresent(NaN)).toBeTruthy();\n      expect(isPresent('hello')).toBeTruthy();\n      expect(isPresent(0)).toBeTruthy();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/typeguards/index.ts",
    "content": "export * from './typeguards';\nexport * from './json-api';\n"
  },
  {
    "path": "packages/core/src/typeguards/json-api/__tests__/json-api-typeguard.test.ts",
    "content": "import {\n  isJsonApiErrorResponse,\n  isJsonApiResponse,\n  isJsonApiSuccessResponse,\n} from '../json-api.typeguard';\n\ndescribe('json-api typeguards', () => {\n  describe('isJsonApiResponse', () => {\n    it('should accept valid json responses', () => {\n      const payload = {\n        success: true,\n        data: 'cool',\n        meta: {},\n      };\n      expect(isJsonApiResponse(payload)).toBeTruthy();\n    });\n\n    it('should reject invalid json responses', () => {\n      const payload = {\n        success: 'biloute',\n        meta: {},\n      };\n      expect(isJsonApiResponse(payload)).toBeFalsy();\n    });\n  });\n\n  describe('isJsonApiSuccessResponse', () => {\n    it('should say yes when payload is success', () => {\n      const payload = {\n        success: true,\n        data: 'cool',\n        meta: {},\n      };\n      expect(isJsonApiSuccessResponse(payload)).toBeTruthy();\n    });\n\n    it('should say no when payload is success', () => {\n      const payload = {\n        success: false,\n        data: 'cool',\n        meta: {},\n      };\n      expect(isJsonApiSuccessResponse(payload)).toBeFalsy();\n    });\n  });\n\n  describe('isJsonApiErrorResponse', () => {\n    it('should say false when payload is success', () => {\n      const payload = {\n        success: true,\n        data: 'cool',\n        meta: {},\n      };\n      expect(isJsonApiErrorResponse(payload)).toBeFalsy();\n    });\n\n    it('should say yes when payload is error', () => {\n      const payload = {\n        success: false,\n        errors: [],\n      };\n      expect(isJsonApiErrorResponse(payload)).toBeTruthy();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/typeguards/json-api/index.ts",
    "content": "export * from './json-api-response.types';\nexport * from './json-api.typeguard';\n"
  },
  {
    "path": "packages/core/src/typeguards/json-api/json-api-response.types.ts",
    "content": "/**\n * @link https://jsonapi.org/format/#errors\n */\nexport type IJsonApiError = {\n  /** a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. */\n  title: string;\n  /** a unique identifier for this particular occurrence of the problem. */\n  id?: string | number;\n  /** the HTTP status code applicable to this problem, expressed as a string value. */\n  status?: number;\n  /** an application-specific error code, expressed as a string value. */\n  code?: string;\n  /** a human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. */\n  detail?: string;\n  /** a string indicating which URI query parameter caused the error. */\n  parameter?: string;\n  /** a meta object containing non-standard meta-information about the error. */\n  meta?: Record<string, unknown>;\n};\n\nexport type IJsonApiErrorResponse = {\n  success: false;\n  errors: IJsonApiError[];\n};\n\nexport type IJsonApiResponseMeta = {\n  meta?: {\n    cacheHit?: boolean;\n  } & Record<string, string | number | boolean | Record<string, unknown>>;\n};\n\nexport type IJsonApiSuccessResponse<T> = {\n  success: true;\n  data: T;\n} & IJsonApiResponseMeta;\n\nexport type IJsonApiResponse<T> = IJsonApiErrorResponse | IJsonApiSuccessResponse<T>;\n"
  },
  {
    "path": "packages/core/src/typeguards/json-api/json-api.typeguard.ts",
    "content": "import { isPlainObject } from '../typeguards';\nimport type {\n  IJsonApiErrorResponse,\n  IJsonApiResponse,\n  IJsonApiSuccessResponse,\n} from './json-api-response.types';\n\nexport const isJsonApiResponse = <T = unknown>(val: unknown): val is IJsonApiResponse<T> => {\n  return isPlainObject(val) && typeof val?.success === 'boolean';\n};\n\nexport const isJsonApiSuccessResponse = <T = unknown>(\n  val: unknown\n): val is IJsonApiSuccessResponse<T> => {\n  return isJsonApiResponse<T>(val) && val.success && 'data' in val;\n};\n\nexport const isJsonApiErrorResponse = (val: unknown): val is IJsonApiErrorResponse => {\n  return (\n    isJsonApiResponse<unknown>(val) && !val.success && 'errors' in val && Array.isArray(val.errors)\n  );\n};\n"
  },
  {
    "path": "packages/core/src/typeguards/typeguards.ts",
    "content": "export type IIsoDateString = string;\nexport const isIsoDateString = (dateStr: unknown): dateStr is IIsoDateString => {\n  if (typeof dateStr !== 'string' || !/\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z/.test(dateStr)) {\n    return false;\n  }\n  try {\n    const d = new Date(dateStr);\n    return d.toISOString() === dateStr;\n  } catch (e: unknown) {\n    return false;\n  }\n};\n\nexport const isNonEmptyString = (v: unknown, trim = true): v is string => {\n  return typeof v === 'string' && (trim ? v.trim() : v).length > 0;\n};\n\nexport const isPlainObject = <T = unknown, K extends string | number = string>(\n  v: unknown\n): v is Record<K, T> => {\n  return (\n    typeof v === 'object' &&\n    v !== null &&\n    v.constructor === Object &&\n    Object.getPrototypeOf(v) === Object.prototype\n  );\n};\n\nexport const isSafeInteger = (v: unknown): v is number => {\n  return typeof v === 'number' && Number.isSafeInteger(v);\n};\n\nexport const isParsableNumeric = (v: unknown): v is number | string => {\n  if (typeof v === 'number' && !Number.isNaN(v)) {\n    return true;\n  }\n  if (!isNonEmptyString(v)) {\n    return false;\n  }\n  return !Number.isNaN(Number.parseInt(v, 10) || Number.isNaN(Number.parseFloat(v)));\n};\n\nexport const isParsableSafeInteger = (v: unknown): v is number | string => {\n  const value = typeof v === 'string' && /^-?\\d+$/.test(v) ? Number.parseInt(v, 10) : v;\n  return isSafeInteger(value);\n};\n\nexport const isHttpStatusCode = (v: unknown): v is number => {\n  return isSafeInteger(v) && v < 600 && v >= 100;\n};\n\n/**\n * Check whether a variable is not null and not undefined\n */\nexport function isPresent<T>(v: T): v is NonNullable<T> {\n  return v !== undefined && v !== null;\n}\n"
  },
  {
    "path": "packages/core/src/types/either-or.ts",
    "content": "type IFilterOptional<T> = Pick<\n  T,\n  Exclude<\n    {\n      [K in keyof T]: T extends Record<K, T[K]> ? K : never;\n    }[keyof T],\n    undefined\n  >\n>;\n\ntype IFilterNotOptional<T> = Pick<\n  T,\n  Exclude<\n    {\n      [K in keyof T]: T extends Record<K, T[K]> ? never : K;\n    }[keyof T],\n    undefined\n  >\n>;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype IPartialEither<T, K extends keyof any> = {\n  [P in Exclude<keyof IFilterOptional<T>, K>]-?: T[P];\n} & { [P in Exclude<keyof IFilterNotOptional<T>, K>]?: T[P] } & {\n  [P in Extract<keyof T, K>]?: undefined;\n};\n\ntype IObject = {\n  [name: string]: unknown;\n};\n\nexport type IEitherOr<O extends IObject, L extends string, R extends string> = (\n  | IPartialEither<Pick<O, L | R>, L>\n  | IPartialEither<Pick<O, L | R>, R>\n) &\n  Omit<O, L | R>;\n"
  },
  {
    "path": "packages/core/src/types/ensure-keys.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type IEnsureKeysMatchInterface<T, K extends readonly any[]> = K extends readonly (keyof T)[]\n  ? keyof T extends K[number]\n    ? K[number] extends keyof T\n      ? true\n      : never\n    : never\n  : never;\n"
  },
  {
    "path": "packages/core/src/types/index.ts",
    "content": "export * from './un-promisify';\nexport * from './remove-null';\nexport * from './snapshot-query';\nexport * from './either-or';\nexport * from './make-required';\nexport * from './make-optional';\nexport * from './ensure-keys';\n"
  },
  {
    "path": "packages/core/src/types/make-optional.ts",
    "content": "export type IMakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n"
  },
  {
    "path": "packages/core/src/types/make-required.ts",
    "content": "export type IMakeRequired<T, K extends keyof T> = {\n  [P in K]-?: T[P];\n} & Omit<T, K>;\n"
  },
  {
    "path": "packages/core/src/types/remove-null.ts",
    "content": "export type IRecursivelyReplaceNullWithUndefined<T> = T extends null\n  ? undefined\n  : T extends Date\n    ? T\n    : {\n        [K in keyof T]: T[K] extends (infer U)[]\n          ? IRecursivelyReplaceNullWithUndefined<U>[]\n          : IRecursivelyReplaceNullWithUndefined<T[K]>;\n      };\n"
  },
  {
    "path": "packages/core/src/types/snapshot-query.ts",
    "content": "export interface IExtraResult {\n  [key: string]: unknown;\n}\n"
  },
  {
    "path": "packages/core/src/types/un-promisify.ts",
    "content": "export type IUnPromisify<T> = T extends Promise<infer U> ? U : T;\n"
  },
  {
    "path": "packages/core/src/utils/clipboard.spec.ts",
    "content": "import { parseClipboardText, stringifyClipboardText } from './clipboard';\n\ndescribe('clipboard', () => {\n  const parseData = [\n    ['John', '20', 'light'],\n    ['Tom', '30', 'medium'],\n    ['A\\nB\\nC\\n\"', '40', 'heavy'],\n  ];\n\n  const stringifyData = 'John\\t20\\tlight\\nTom\\t30\\tmedium\\n\"A\\nB\\nC\\n\"\"\"\\t40\\theavy';\n\n  it('parseClipboardText', () => {\n    const data = parseClipboardText(stringifyData);\n    expect(data).toEqual(parseData);\n  });\n\n  it('extractTableHeader should return undefined from non-teable HTML', () => {\n    const result = stringifyClipboardText(parseData);\n    expect(result).toEqual(stringifyData);\n  });\n\n  describe('parse', () => {\n    it('content has normal', () => {\n      const data = parseClipboardText('11\\t22\\t33\\n44\\t55\\t66');\n      expect(data).toEqual([\n        ['11', '22', '33'],\n        ['44', '55', '66'],\n      ]);\n    });\n    it('content has \"', () => {\n      const data = parseClipboardText('123');\n      expect(data).toEqual([['123']]);\n    });\n\n    it('content has \"\"', () => {\n      const data = parseClipboardText('\"1\"2\"3\"\\t\"4\"5\"6\"');\n      expect(data).toEqual([['\"1\"2\"3\"', '\"4\"5\"6\"']]);\n    });\n\n    it('content has \" many', () => {\n      const data = parseClipboardText('\"1\"\"2\"3\"\\t\"4\"\"5\"6\"');\n      expect(data).toEqual([['\"1\"2\"3\"', '\"4\"5\"6\"']]);\n    });\n\n    it('content has newline', () => {\n      const data = parseClipboardText('\"1\\n2\"');\n      expect(data).toEqual([['1\\n2']]);\n    });\n\n    it('content has newline and delimiter', () => {\n      const data = parseClipboardText('\"1\\n2\\t3\"');\n      expect(data).toEqual([['1\\n2\\t3']]);\n    });\n\n    it('content has newline and delimiter and \"', () => {\n      const data = parseClipboardText('\"1\\n2\\t\"\"3\"\\t\"\"\"1\\n2\\t\"\"3\"\\n\"1\\n2\\t\"\"3\"\\t\"1\\n2\\t\"\"3\"');\n      expect(data).toEqual([\n        ['1\\n2\\t\"3', '\"1\\n2\\t\"3'],\n        ['1\\n2\\t\"3', '1\\n2\\t\"3'],\n      ]);\n    });\n\n    it('content has double-quoted sentence and end of null', () => {\n      const data = parseClipboardText('\"text1\"\\t\"text2\"\\t');\n      expect(data).toEqual([['\"text1\"', '\"text2\"', '']]);\n    });\n\n    it('content has continuous \\t', () => {\n      const data = parseClipboardText('text1\\t\\t\"text2\"');\n      expect(data).toEqual([['text1', '', '\"text2\"']]);\n    });\n\n    it('content hash continuous \\n', () => {\n      const data = parseClipboardText('text1\\n\\n\"text2\"');\n      expect(data).toEqual([['text1'], [''], ['\"text2\"']]);\n    });\n\n    it('content has windows newline', () => {\n      const data = parseClipboardText('text1\"\\r\\ntext2');\n      expect(data).toEqual([['text1\"'], ['text2']]);\n    });\n\n    it('content start or end with newline', () => {\n      const data = parseClipboardText('text1\\n');\n      expect(data).toEqual([['text1']]);\n\n      const data2 = parseClipboardText('\\ntext1');\n      expect(data2).toEqual([['text1']]);\n\n      const data3 = parseClipboardText('tex\"t1\\n');\n      expect(data3).toEqual([['tex\"t1']]);\n\n      const data4 = parseClipboardText('\\ntex\"t1');\n      expect(data4).toEqual([['tex\"t1']]);\n    });\n  });\n\n  describe('stringify', () => {\n    it('content has \"', () => {\n      const result = stringifyClipboardText([['\"123']]);\n      expect(result).toEqual('\"123');\n    });\n    it('content has newline', () => {\n      const result = stringifyClipboardText([['1\\n2']]);\n      expect(result).toEqual('\"1\\n2\"');\n    });\n    it('content has newline and delimiter', () => {\n      const result = stringifyClipboardText([['1\\n2\\t3']]);\n      expect(result).toEqual('\"1\\n2\\t3\"');\n    });\n    it('content has newline and delimiter and \"', () => {\n      const result = stringifyClipboardText([\n        ['1\\n2\\t\"3', '1\\n2\\t\"3'],\n        ['1\\n2\\t\"3', '1\\n2\\t\"3'],\n      ]);\n      expect(result).toEqual('\"1\\n2\\t\"\"3\"\\t\"1\\n2\\t\"\"3\"\\n\"1\\n2\\t\"\"3\"\\t\"1\\n2\\t\"\"3\"');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/core/src/utils/clipboard.ts",
    "content": "const delimiter = '\\t';\nconst newline = '\\n';\nconst windowsNewline = '\\r\\n';\n\n// eslint-disable-next-line sonarjs/cognitive-complexity\nexport const parseClipboardText = (content: string) => {\n  const _newline = content.includes(windowsNewline) ? windowsNewline : newline;\n  // remove the last newline or windows newline\n  if (content.endsWith(_newline)) {\n    content = content.slice(0, -1 * _newline.length);\n  }\n  if (content.startsWith(_newline)) {\n    content = content.slice(_newline.length);\n  }\n  if (!content.includes('\"')) {\n    return content.split(_newline).map((row) => row.split(delimiter));\n  }\n\n  const len = content.length;\n  let cursor = 0;\n  const tableData: string[][] = [];\n  let row: string[] = [];\n  let endOfRow = false;\n  while (cursor < len) {\n    let cell = '';\n    let quoted = false;\n    let endOfCell = false;\n    if (content[cursor] === '\"') {\n      quoted = true;\n    } else if (content[cursor] === delimiter) {\n      endOfCell = true;\n    } else if (content[cursor] === _newline) {\n      endOfCell = true;\n      endOfRow = true;\n    } else {\n      cell += content[cursor];\n    }\n    while (!endOfCell) {\n      cursor++;\n      // handle only one cell\n      if (cursor >= len) {\n        endOfCell = true;\n        endOfRow = true;\n        cell = quoted ? `\"${cell}` : cell;\n        break;\n      }\n      if (content[cursor] === '\"' && quoted) {\n        if (content[cursor + 1] === '\"') {\n          cell += '\"';\n          cursor++;\n        } else if (cell.includes(delimiter) || cell.includes(_newline)) {\n          quoted = false;\n        } else {\n          cell = `\"${cell}\"`;\n          quoted = false;\n        }\n      } else if (content[cursor] === delimiter) {\n        if (quoted) {\n          cell += delimiter;\n        } else {\n          endOfCell = true;\n          break;\n        }\n      } else if (\n        content[cursor] === _newline ||\n        `${content[cursor]}${content[cursor + 1]}` === _newline\n      ) {\n        if (quoted) {\n          cell += _newline;\n        } else {\n          endOfCell = true;\n          endOfRow = true;\n        }\n        if (`${content[cursor]}${content[cursor + 1]}` === _newline) {\n          cursor++;\n        }\n      } else {\n        cell += content[cursor];\n      }\n    }\n    cursor++;\n    row.push(cell);\n    // Handling of the last column with no content, example: \"text1\"\\t\"text2\"\\t\n    if (endOfCell && cursor >= len && content[cursor - 1] === '\\t') {\n      endOfRow = true;\n      row.push('');\n    }\n\n    if (endOfRow) {\n      tableData.push(row);\n      row = [];\n      endOfRow = false;\n    }\n  }\n  return tableData;\n};\n\nexport const stringifyClipboardText = (content: string[][]) => {\n  return content\n    .map((row) =>\n      row\n        .map((cell) =>\n          cell.includes(delimiter) || cell.includes(newline)\n            ? `\"${cell.replace(/\"/g, '\"\"')}\"`\n            : cell\n        )\n        .join(delimiter)\n    )\n    .join(newline);\n};\n"
  },
  {
    "path": "packages/core/src/utils/date.spec.ts",
    "content": "import { DateUtil } from './date';\n\ndescribe('DateUtil Test', () => {\n  const utcDateStr = '2023-07-15T16:00:00.000Z';\n\n  it('America/Los_Angeles', () => {\n    const dateUtil = new DateUtil('America/Los_Angeles');\n\n    expect(dateUtil.date(utcDateStr).format()).toStrictEqual('2023-07-15T09:00:00-07:00');\n    expect(dateUtil.date(utcDateStr).format(DateUtil.NORM_DATETIME_PATTERN)).toStrictEqual(\n      '2023-07-15 09:00:00'\n    );\n    expect(dateUtil.date(utcDateStr).toISOString()).toStrictEqual(utcDateStr);\n\n    expect(\n      dateUtil.offsetDay(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-16 09:00:00');\n    expect(\n      dateUtil.offsetDay(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-14 09:00:00');\n\n    expect(\n      dateUtil.offsetWeek(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-22 09:00:00');\n    expect(\n      dateUtil.offsetWeek(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-08 09:00:00');\n\n    expect(\n      dateUtil.offsetMonth(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-08-15 09:00:00');\n    expect(\n      dateUtil.offsetMonth(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-06-15 09:00:00');\n  });\n\n  it('Asia/Shanghai', () => {\n    const dateUtil = new DateUtil('Asia/Shanghai');\n\n    expect(dateUtil.date(utcDateStr).format()).toStrictEqual('2023-07-16T00:00:00+08:00');\n    expect(dateUtil.date(utcDateStr).format(DateUtil.NORM_DATETIME_PATTERN)).toStrictEqual(\n      '2023-07-16 00:00:00'\n    );\n    expect(dateUtil.date(utcDateStr).toISOString()).toStrictEqual(utcDateStr);\n\n    expect(\n      dateUtil.offsetDay(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-17 00:00:00');\n    expect(\n      dateUtil.offsetDay(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-15 00:00:00');\n\n    expect(\n      dateUtil.offsetWeek(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-23 00:00:00');\n    expect(\n      dateUtil.offsetWeek(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-07-09 00:00:00');\n\n    expect(\n      dateUtil.offsetMonth(1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-08-16 00:00:00');\n    expect(\n      dateUtil.offsetMonth(-1, dateUtil.date(utcDateStr)).format(DateUtil.NORM_DATETIME_PATTERN)\n    ).toStrictEqual('2023-06-16 00:00:00');\n  });\n});\n"
  },
  {
    "path": "packages/core/src/utils/date.ts",
    "content": "import type { ManipulateType } from 'dayjs';\nimport dayjs, { extend } from 'dayjs';\nimport timezone from 'dayjs/plugin/timezone';\nimport utc from 'dayjs/plugin/utc';\n\nextend(utc);\nextend(timezone);\n\nexport class DateUtil {\n  public static readonly NORM_YEAR_PATTERN = 'YYYY';\n  public static readonly NORM_MONTH_PATTERN = 'YYYY-MM';\n  public static readonly NORM_DATE_PATTERN = 'YYYY-MM-DD';\n  public static readonly NORM_DATETIME_MINUTE_PATTERN = 'YYYY-MM-DD HH:mm';\n  public static readonly NORM_DATETIME_PATTERN = 'YYYY-MM-DD HH:mm:ss';\n  public static readonly NORM_DATETIME_MS_PATTERN = 'YYYY-MM-DD HH:mm:ss.SSS';\n  public static readonly UTC_SIMPLE_PATTERN = 'YYYY-MM-DDTHH:mm:ss';\n  public static readonly UTC_SIMPLE_MS_PATTERN = 'YYYY-MM-DDTHH:mm:ss.SSS';\n  public static readonly UTC_WITH_ZONE_OFFSET_PATTERN = 'YYYY-MM-DDTHH:mm:ssZ';\n  public static readonly UTC_MS_WITH_ZONE_OFFSET_PATTERN = 'YYYY-MM-DDTHH:mm:ss.SSSZ';\n\n  constructor(\n    private readonly timeZone: string,\n    private readonly useUTC = true\n  ) {}\n\n  /**\n   * Current time\n   *\n   * @param date Date\n   * @return Current time\n   */\n  date(date?: dayjs.ConfigType) {\n    return (this.useUTC ? dayjs(date).utc() : dayjs(date)).tz(this.timeZone);\n  }\n\n  /**\n   * Current time, in the format YYYY-MM-DD HH:mm:ss\n   *\n   * @return The current time in standard form string\n   */\n  now(): string {\n    return this.date().format(DateUtil.NORM_DATETIME_PATTERN);\n  }\n\n  /**\n   * Current date, in the format YYYY-MM-DD\n   *\n   * @return Standard form string of the current date\n   */\n  today(): string {\n    return this.date().format(DateUtil.NORM_DATE_PATTERN);\n  }\n\n  /**\n   * Offset days\n   *\n   * @param offset offset days, positive numbers offset to the future, negative numbers offset to history\n   * @param date Date\n   * @return offset date\n   */\n  offsetDay(offset: number, date = this.date()) {\n    return this.offset('day', offset, date);\n  }\n\n  /**\n   * Offset week\n   *\n   * @param offset offset week, positive number offset to future, negative number offset to history\n   * @param date Date\n   * @return offset date\n   */\n  offsetWeek(offset: number, date = this.date()) {\n    return this.offset('week', offset, date);\n  }\n\n  /**\n   * Offset month\n   *\n   * @param offset offset months, positive offset to the future, negative offset to history\n   * @param date Date\n   * @return offset date\n   */\n  offsetMonth(offset: number, date = this.date()) {\n    return this.offset('month', offset, date);\n  }\n\n  /**\n   * Get the time after the specified date offset from the specified time, the generated offset date does not affect the original date\n   *\n   * @param dateField The granularity size of the offset (hour, day, month, etc.) {@link ManipulateType}\n   * @param offset offset, positive number is backward offset, negative number is forward offset\n   * @param date the base date\n   * @return offset date\n   */\n  offset(dateField: ManipulateType, offset: number, date = this.date()) {\n    if (offset === 0) {\n      return date;\n    }\n    return date[offset > 0 ? 'add' : 'subtract'](Math.abs(offset), dateField);\n  }\n\n  /**\n   * Tomorrow\n   *\n   * @return Tomorrow\n   */\n  tomorrow() {\n    return this.offsetDay(1);\n  }\n\n  /**\n   * Yesterday\n   *\n   * @return yesterday\n   */\n  yesterday() {\n    return this.offsetDay(-1);\n  }\n\n  /**\n   * Last week\n   *\n   * @return Last week\n   */\n  lastWeek() {\n    return this.offsetWeek(-1);\n  }\n\n  /**\n   * Next week\n   *\n   * @return Next week\n   */\n  nextWeek() {\n    return this.offsetWeek(1);\n  }\n\n  /**\n   * Last month\n   *\n   * @return Last month\n   */\n  lastMonth() {\n    return this.offsetMonth(-1);\n  }\n\n  /**\n   * Next month\n   *\n   * @return Next month\n   */\n  nextMonth() {\n    return this.offsetMonth(1);\n  }\n}\n"
  },
  {
    "path": "packages/core/src/utils/dsn-parser.ts",
    "content": "import type { parseDsnOrThrow } from '@httpx/dsn-parser';\nimport { isParsableDsn as isParsable, parseDsn as parse } from '@httpx/dsn-parser';\n\nexport type IDsn = ReturnType<typeof parseDsnOrThrow>;\n\nexport function parseDsn(dsn: string): IDsn {\n  const parsedDsn = parse(dsn);\n  if (dsn.startsWith('file:')) {\n    return {\n      host: 'localhost',\n      driver: 'sqlite3',\n    };\n  }\n\n  if (!parsedDsn.success) {\n    throw new Error(`DATABASE_URL ${parsedDsn.reason}`);\n  }\n  if (!parsedDsn.value.port) {\n    throw new Error(`DATABASE_URL must provide a port`);\n  }\n\n  return parsedDsn.value;\n}\n\nexport function isParsableDsn(dsn: unknown) {\n  return (dsn as string).startsWith('file:') || isParsable(dsn);\n}\n\nexport enum DriverClient {\n  Pg = 'postgresql',\n  Sqlite = 'sqlite3',\n}\n"
  },
  {
    "path": "packages/core/src/utils/enum.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function has<T extends object>(obj: T, key: keyof any): key is keyof T {\n  return Object.prototype.hasOwnProperty.call(obj, key);\n}\n\n/**\n * Allows creating an object map type with a dynamic key type.\n *\n * TypeScript only allows `string` for `K` in `{[key: K]: V}` so we need a utility to bridge\n * the gap.\n *\n * This is an alias for TypeScript’s `Record` type, but the name “record” is confusing given our\n * Teable domain model.\n *\n * @hidden\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype IObjectMap<K extends keyof any, V> = { [P in K]: V };\n\nfunction keys<Obj extends object>(obj: Obj): Array<keyof Obj> {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  return Object.keys(obj) as any;\n}\n\nexport function getEnumValueIfExists<K extends string, V extends string>(\n  enumObj: IObjectMap<K, V>,\n  valueToCheck: string\n): V | null {\n  const invertedEnum = getInvertedEnumMemoized(enumObj);\n  if (has(invertedEnum, valueToCheck) && invertedEnum[valueToCheck]) {\n    const enumKey = invertedEnum[valueToCheck];\n    return enumObj[enumKey];\n  }\n  return null;\n}\n\nconst invertedEnumCache: WeakMap<object, object> = new WeakMap();\n/**\n * @hidden\n */\nfunction getInvertedEnumMemoized<K extends string, V extends string>(\n  enumObj: IObjectMap<K, V>\n): IObjectMap<V, K> {\n  const existingInvertedEnum = invertedEnumCache.get(enumObj);\n  if (existingInvertedEnum) {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return existingInvertedEnum as any;\n  }\n\n  const invertedEnum = {} as IObjectMap<V, K>;\n  for (const enumKey of keys(enumObj)) {\n    const enumValue = enumObj[enumKey];\n    invertedEnum[enumValue] = enumKey;\n  }\n  invertedEnumCache.set(enumObj, invertedEnum);\n  return invertedEnum;\n}\n"
  },
  {
    "path": "packages/core/src/utils/get-random-int.spec.ts",
    "content": "import { getRandomInt } from './get-random-int';\n\ndescribe('getRandomInt tests', () => {\n  it('should return an integer between min and max', () => {\n    expect([100, 101].includes(getRandomInt(100, 101))).toBeTruthy();\n    expect([-101, -100].includes(getRandomInt(-101, -100))).toBeTruthy();\n  });\n\n  it('should throw if not a number', () => {\n    expect(() => getRandomInt(NaN, 100)).toThrow(/min/i);\n    expect(() => getRandomInt(10, {} as unknown as number)).toThrow(/max/i);\n  });\n\n  it('should throw if min > max', () => {\n    expect(() => getRandomInt(100, 10)).toThrow(/greater/i);\n  });\n});\n"
  },
  {
    "path": "packages/core/src/utils/get-random-int.ts",
    "content": "/**\n * Returns a random integer between min (inclusive) and max (inclusive).\n * The value is no lower than min (or the next integer greater than min\n * if min isn't an integer) and no greater than max (or the next integer\n * lower than max if max isn't an integer).\n * @link https://stackoverflow.com/questions/1527803/generating-random-whole-numbers-in-javascript-in-a-specific-range/1527820#1527820\n */\n\nexport function getRandomInt(min: number, max: number): number {\n  [min, max].forEach((v, idx) => {\n    if (!Number.isSafeInteger(v)) {\n      throw new Error(`${idx === 0 ? 'min' : 'max'} is not a valid integer`);\n    }\n  });\n  if (max < min) {\n    throw new Error('Min cannot be greater than max');\n  }\n  min = Math.ceil(min);\n  max = Math.floor(max);\n  return Math.floor(Math.random() * (max - min + 1)) + min;\n}\n"
  },
  {
    "path": "packages/core/src/utils/get-uniq-name.spec.ts",
    "content": "import { getUniqName } from './get-uniq-name'; // Replace with your actual file\n\ndescribe('getUniqName', () => {\n  it('should start with 2', () => {\n    const existNames = ['Field'];\n    const name = 'Field';\n    expect(getUniqName(name, existNames)).toBe('Field 2');\n  });\n\n  it('should return the original name if it does not exist in the list', () => {\n    const existNames = ['Field 1', 'Field 2', 'Field 3'];\n    const name = 'Field 4';\n    expect(getUniqName(name, existNames)).toBe('Field 4');\n  });\n\n  it('should increment the number at the end of the name if the name exists in the list', () => {\n    const existNames = ['Field 1', 'Field 2', 'Field 3'];\n    const name = 'Field 3';\n    expect(getUniqName(name, existNames)).toBe('Field 4');\n  });\n\n  it('should add a number at the end of the name if the name exists in the list and does not have a number', () => {\n    const existNames = ['Field', 'Field 1', 'Field 2'];\n    const name = 'Field';\n    expect(getUniqName(name, existNames)).toBe('Field 3');\n  });\n\n  it('should increment the number at the end of the name even if there are other names with higher numbers', () => {\n    const existNames = ['Field 1', 'Field 3', 'Field 4'];\n    const name = 'Field 3';\n    expect(getUniqName(name, existNames)).toBe('Field 5');\n  });\n\n  it('should correctly handle names with numbers in the middle', () => {\n    const existNames = ['Field 1 1', 'Field 1 2', 'Field 1 3'];\n    const name = 'Field 1 3';\n    expect(getUniqName(name, existNames)).toBe('Field 1 4');\n  });\n});\n"
  },
  {
    "path": "packages/core/src/utils/get-uniq-name.ts",
    "content": "export function getUniqName(name: string, existNames: string[]) {\n  if (!existNames.includes(name)) {\n    return name;\n  }\n\n  let baseName = name;\n  let num = 2;\n\n  if (isNaN(Number(name))) {\n    const match = name.match(/^(.*)(\\b\\d+)$/);\n\n    if (match) {\n      baseName = match[1].trim(); // The base part of the name, without the number\n      num = parseInt(match[2], 10); // The number at the end of the name\n    }\n  }\n\n  // If the base name with the current number exists, increment the number until we find one that doesn't exist\n  while (existNames.includes(`${baseName} ${num}`)) {\n    num++;\n  }\n\n  return `${baseName} ${num}`;\n}\n"
  },
  {
    "path": "packages/core/src/utils/id-generator.spec.ts",
    "content": "import {\n  getRandomString,\n  generateTableId,\n  generateFieldId,\n  generateViewId,\n  generateRecordId,\n  generateAttachmentId,\n  generateWorkflowId,\n  generateWorkflowTriggerId,\n  generateWorkflowActionId,\n  generateWorkflowDecisionId,\n  identify,\n  IdPrefix,\n} from './id-generator';\n\ndescribe('ID Generators', () => {\n  it('generates a random string of correct length', () => {\n    const randomStr = getRandomString(5);\n    expect(randomStr.length).toBe(5);\n  });\n\n  it('generates a table ID with correct prefix and length', () => {\n    const tableId = generateTableId();\n    expect(tableId.startsWith(IdPrefix.Table)).toBe(true);\n    expect(tableId.length).toBe(IdPrefix.Table.length + 16);\n  });\n\n  it('generates a field ID with correct prefix and length', () => {\n    const fieldId = generateFieldId();\n    expect(fieldId.startsWith(IdPrefix.Field)).toBe(true);\n    expect(fieldId.length).toBe(IdPrefix.Field.length + 16);\n  });\n\n  it('generates a view ID with correct prefix and length', () => {\n    const viewId = generateViewId();\n    expect(viewId.startsWith(IdPrefix.View)).toBe(true);\n    expect(viewId.length).toBe(IdPrefix.View.length + 16);\n  });\n\n  it('generates a record ID with correct prefix and length', () => {\n    const recordId = generateRecordId();\n    expect(recordId.startsWith(IdPrefix.Record)).toBe(true);\n    expect(recordId.length).toBe(IdPrefix.Record.length + 16);\n  });\n\n  it('generates a attachment ID with correct prefix and length', () => {\n    const attachmentId = generateAttachmentId();\n    expect(attachmentId.startsWith(IdPrefix.Attachment)).toBe(true);\n    expect(attachmentId.length).toBe(IdPrefix.Attachment.length + 16);\n  });\n\n  it('generates a workflow ID with correct prefix and length', () => {\n    const workflowId = generateWorkflowId();\n    expect(workflowId.startsWith(IdPrefix.Workflow)).toBe(true);\n    expect(workflowId.length).toBe(IdPrefix.Workflow.length + 16);\n  });\n\n  it('generates a workflowTrigger ID with correct prefix and length', () => {\n    const workflowTriggerId = generateWorkflowTriggerId();\n    expect(workflowTriggerId.startsWith(IdPrefix.WorkflowTrigger)).toBe(true);\n    expect(workflowTriggerId.length).toBe(IdPrefix.WorkflowTrigger.length + 16);\n  });\n\n  it('generates a workflowAction ID with correct prefix and length', () => {\n    const workflowActionId = generateWorkflowActionId();\n    expect(workflowActionId.startsWith(IdPrefix.WorkflowAction)).toBe(true);\n    expect(workflowActionId.length).toBe(IdPrefix.WorkflowAction.length + 16);\n  });\n\n  it('generates a workflowDecision ID with correct prefix and length', () => {\n    const workflowDecisionId = generateWorkflowDecisionId();\n    expect(workflowDecisionId.startsWith(IdPrefix.WorkflowDecision)).toBe(true);\n    expect(workflowDecisionId.length).toBe(IdPrefix.WorkflowDecision.length + 16);\n  });\n\n  it('identifies an ID prefix', () => {\n    const id = generateTableId();\n    expect(identify(id)).toBe(IdPrefix.Table);\n  });\n\n  it('returns undefined if the ID prefix is unrecognized', () => {\n    const id = 'xyz123456789';\n    expect(identify(id)).toBeUndefined();\n  });\n\n  it('returns undefined if the ID is too short to have a prefix', () => {\n    const id = 'x';\n    expect(identify(id)).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "packages/core/src/utils/id-generator.ts",
    "content": "import { customAlphabet } from 'nanoid';\n\nexport enum IdPrefix {\n  Space = 'spc',\n  Base = 'bse',\n\n  BaseNode = 'bno',\n  BaseNodeFolder = 'bnf',\n\n  Table = 'tbl',\n  Field = 'fld',\n  View = 'viw',\n  Record = 'rec',\n  Comment = 'com',\n  Attachment = 'act',\n  Choice = 'cho',\n\n  Workflow = 'wfl',\n  WorkflowTrigger = 'wtr',\n  WorkflowAction = 'wac',\n  WorkflowDecision = 'wde',\n\n  User = 'usr',\n  Account = 'aco',\n\n  Invitation = 'inv',\n\n  Share = 'shr',\n\n  Notification = 'not',\n\n  AccessToken = 'acc',\n\n  AuthorityMatrix = 'aut',\n  AuthorityMatrixRole = 'aur',\n\n  License = 'lic',\n\n  OAuthClient = 'clt',\n\n  Window = 'win',\n\n  RecordHistory = 'rhi',\n\n  Plugin = 'plg',\n  PluginInstall = 'pli',\n  PluginUser = 'plu',\n  PluginPanel = 'plp',\n\n  Dashboard = 'dsh',\n\n  RecordTrash = 'rtr',\n\n  Operation = 'opr',\n\n  Organization = 'org',\n  OrganizationDepartment = 'odp',\n\n  Integration = 'int',\n\n  Template = 'tpl',\n  TemplateCategory = 'tpc',\n\n  Task = 'tsk',\n\n  Chat = 'cht',\n  ChatMessage = 'cmm',\n\n  Query = 'qry',\n\n  App = 'app',\n}\n\nexport enum RandomType {\n  String = 'string',\n  Number = 'number',\n}\n\nconst chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';\nconst nanoid = customAlphabet(chars);\n\nconst charsNumber = '0123456789';\nconst nanoidNumber = customAlphabet(charsNumber);\n\nexport function getRandomString(len: number, type: RandomType = RandomType.String) {\n  if (type === RandomType.Number) {\n    return nanoidNumber(len);\n  }\n  return nanoid(len);\n}\n\nexport function generateBaseNodeId() {\n  return IdPrefix.BaseNode + getRandomString(16);\n}\n\nexport function generateBaseNodeFolderId() {\n  return IdPrefix.BaseNodeFolder + getRandomString(16);\n}\n\nexport function generateTableId() {\n  return IdPrefix.Table + getRandomString(16);\n}\n\nexport function generateFieldId() {\n  return IdPrefix.Field + getRandomString(16);\n}\n\nexport function generateViewId() {\n  return IdPrefix.View + getRandomString(16);\n}\n\nexport function generateRecordId() {\n  return IdPrefix.Record + getRandomString(16);\n}\n\nexport function generateCommentId() {\n  return IdPrefix.Comment + getRandomString(16);\n}\n\nexport function generateChoiceId() {\n  return IdPrefix.Choice + getRandomString(8);\n}\n\nexport function generateAttachmentId() {\n  return IdPrefix.Attachment + getRandomString(16);\n}\n\nexport function generateWorkflowId() {\n  return IdPrefix.Workflow + getRandomString(16);\n}\n\nexport function generateWorkflowTriggerId() {\n  return IdPrefix.WorkflowTrigger + getRandomString(16);\n}\n\nexport function generateWorkflowActionId() {\n  return IdPrefix.WorkflowAction + getRandomString(16);\n}\n\nexport function generateWorkflowDecisionId() {\n  return IdPrefix.WorkflowDecision + getRandomString(16);\n}\n\nexport function generateUserId() {\n  return IdPrefix.User + getRandomString(16);\n}\n\nexport function generateWindowId() {\n  return IdPrefix.Window + getRandomString(16);\n}\n\nexport function identify(id: string): IdPrefix | undefined {\n  if (id.length < 2) {\n    return undefined;\n  }\n\n  const idPrefix = id.substring(0, 3);\n  return (Object.values(IdPrefix) as string[]).includes(idPrefix)\n    ? (idPrefix as IdPrefix)\n    : undefined;\n}\n\nexport function generateSpaceId() {\n  return IdPrefix.Space + getRandomString(16);\n}\n\nexport function generateBaseId() {\n  return IdPrefix.Base + getRandomString(16);\n}\n\nexport function generateInvitationId() {\n  return IdPrefix.Invitation + getRandomString(16);\n}\n\nexport function generateShareId() {\n  return IdPrefix.Share + getRandomString(16);\n}\n\nexport function generateNotificationId() {\n  return IdPrefix.Notification + getRandomString(16);\n}\n\nexport function generateAccessTokenId() {\n  return IdPrefix.AccessToken + getRandomString(16);\n}\n\nexport function generateAccountId() {\n  return IdPrefix.Account + getRandomString(16);\n}\n\nexport function generateAuthorityMatrixId() {\n  return IdPrefix.AuthorityMatrix + getRandomString(16);\n}\n\nexport function generateAuthorityMatrixRoleId() {\n  return IdPrefix.AuthorityMatrixRole + getRandomString(16);\n}\n\nexport function generateLicenseId() {\n  return IdPrefix.License + getRandomString(16);\n}\n\nexport function generateClientId() {\n  return IdPrefix.OAuthClient + getRandomString(16).toLocaleLowerCase();\n}\n\nexport function generateRecordHistoryId() {\n  return IdPrefix.RecordHistory + getRandomString(24);\n}\n\nexport function generatePluginId() {\n  return IdPrefix.Plugin + getRandomString(16);\n}\n\nexport function generatePluginInstallId() {\n  return IdPrefix.PluginInstall + getRandomString(16);\n}\n\nexport function generatePluginUserId() {\n  return IdPrefix.PluginUser + getRandomString(16);\n}\n\nexport function generatePluginPanelId() {\n  return IdPrefix.PluginPanel + getRandomString(16);\n}\n\nexport function generateDashboardId() {\n  return IdPrefix.Dashboard + getRandomString(12);\n}\n\nexport function generateOperationId() {\n  return IdPrefix.Operation + getRandomString(16);\n}\n\nexport function generateRecordTrashId() {\n  return IdPrefix.RecordTrash + getRandomString(16);\n}\n\nexport function generateOrganizationId() {\n  return IdPrefix.Organization + getRandomString(16);\n}\n\nexport function generateOrganizationDepartmentId() {\n  return IdPrefix.OrganizationDepartment + getRandomString(16);\n}\n\nexport function generateIntegrationId() {\n  return IdPrefix.Integration + getRandomString(16);\n}\n\nexport function generateTemplateId() {\n  return IdPrefix.Template + getRandomString(16);\n}\n\nexport function generateTemplateCategoryId() {\n  return IdPrefix.TemplateCategory + getRandomString(16);\n}\n\nexport function generateTaskId() {\n  return IdPrefix.Task + getRandomString(16);\n}\n\nexport function generateChatId() {\n  return IdPrefix.Chat + getRandomString(16);\n}\n\nexport function generateChatMessageId() {\n  return IdPrefix.ChatMessage + getRandomString(16);\n}\n\nexport function generateQueryId() {\n  return IdPrefix.Query + getRandomString(16);\n}\n\nexport function generateAppId() {\n  return IdPrefix.App + getRandomString(16);\n}\n\nexport function generateLogId() {\n  return getRandomString(25);\n}\n"
  },
  {
    "path": "packages/core/src/utils/index.ts",
    "content": "export * from './get-random-int';\nexport * from './id-generator';\nexport * from './get-uniq-name';\nexport * from './date';\nexport * from './dsn-parser';\nexport * from './clipboard';\nexport * from './minidenticon';\nexport * from './replace-suffix';\n"
  },
  {
    "path": "packages/core/src/utils/minidenticon.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nconst COLORS_NB: number = 9;\nconst DEFAULT_SATURATION: number = 95;\nconst DEFAULT_LIGHTNESS: number = 45;\n\nconst MAGIC_NUMBER: number = 5;\n\nfunction simpleHash(str: string): number {\n  return (\n    str\n      .split('')\n      .reduce((hash, char) => (hash ^ char.charCodeAt(0)) * -MAGIC_NUMBER, MAGIC_NUMBER) >>> 2\n  );\n}\n\ninterface MinidenticonFunction {\n  (\n    seed?: string,\n    saturation?: number,\n    lightness?: number,\n    hashFn?: (str: string) => number\n  ): string;\n}\n\nconst minidenticon: MinidenticonFunction = function (\n  seed: string = '',\n  saturation: number = DEFAULT_SATURATION,\n  lightness: number = DEFAULT_LIGHTNESS,\n  hashFn: (str: string) => number = simpleHash\n): string {\n  const hash = hashFn(seed);\n  const hue = (hash % COLORS_NB) * (360 / COLORS_NB);\n  return (\n    [...Array(seed ? 25 : 0)].reduce(\n      (acc, _, i) =>\n        hash & (1 << i % 15)\n          ? acc +\n            `<rect x=\"${i > 14 ? 7 - ~~(i / 5) : ~~(i / 5)}\" y=\"${i % 5}\" width=\"1\" height=\"1\"/>`\n          : acc,\n      `<svg viewBox=\"-1.5 -1.5 8 8\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"hsl(${hue} ${saturation}% ${lightness}%)\">`\n    ) + '</svg>'\n  );\n};\n\nexport { minidenticon };\n"
  },
  {
    "path": "packages/core/src/utils/replace-suffix.ts",
    "content": "export const replaceSuffix = (originalUrl: string, storagePrefix: string) => {\n  const original = new URL(originalUrl);\n  const suffix = new URL(storagePrefix);\n\n  const suffixPath = suffix.pathname.endsWith('/') ? suffix.pathname : suffix.pathname + '/';\n  const originalPath = original.pathname.startsWith('/')\n    ? original.pathname.slice(1)\n    : original.pathname;\n\n  return `${suffix.origin}${suffixPath}${originalPath}${original.search}${original.hash}`;\n};\n"
  },
  {
    "path": "packages/core/src/zod.ts",
    "content": "import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';\nimport { z } from 'zod';\n\nextendZodWithOpenApi(z);\n\nexport { z };\n"
  },
  {
    "path": "packages/core/tsconfig.build.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"paths\": {}\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"dist\", \"**/__tests__/*\", \"**/*spec.ts\"]\n}\n"
  },
  {
    "path": "packages/core/tsconfig.eslint.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"allowJs\": true\n  },\n  \"exclude\": [\"node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/core/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"@teable/core\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"CommonJS\",\n    \"moduleResolution\": \"node\",\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\"],\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"noEmit\": false,\n    \"incremental\": true,\n    \"resolveJsonModule\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"dist\",\n    \"composite\": true,\n    \"rootDir\": \"../\",\n    \"outDir\": \"dist\",\n    \"types\": [\"vitest/globals\", \"node\"],\n    \"paths\": {\n      \"@teable/formula\": [\"../formula/src\"]\n    }\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/\", \"./dist\", \"./coverage\"],\n  \"include\": [\"src\", \"../formula/src\"]\n}\n"
  },
  {
    "path": "packages/core/vitest.config.ts",
    "content": "import { defineConfig, configDefaults } from 'vitest/config';\n\nconst testFiles = ['./src/**/*.{test,spec}.{js,ts}'];\n\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  cacheDir: '../../.cache/vitest/core',\n  test: {\n    globals: true,\n    environment: 'node',\n    setupFiles: './vitest.setup.js',\n    passWithNoTests: true,\n    typecheck: {\n      enabled: false,\n    },\n    pool: 'forks',\n    coverage: {\n      provider: 'v8',\n      include: ['src/**/*.{js,ts}'],\n    },\n    // To mimic Jest behaviour regarding mocks.\n    // @link https://vitest.dev/config/#clearmocks\n    clearMocks: true,\n    mockReset: true,\n    restoreMocks: true,\n    include: testFiles,\n    exclude: [...configDefaults.exclude, '**/.next/**'],\n  },\n});\n"
  },
  {
    "path": "packages/core/vitest.setup.js",
    "content": "const dayjs = require('dayjs');\nconst timezone = require('dayjs/plugin/timezone');\nconst utc = require('dayjs/plugin/utc');\ndayjs.extend(utc);\ndayjs.extend(timezone);\n"
  },
  {
    "path": "packages/db-main-prisma/.eslintrc.cjs",
    "content": "/**\n * Specific eslint rules for this app/package, extends the base rules\n * @see https://github.com/teableio/teable/blob/main/docs/about-linters.md\n */\n\n// Workaround for https://github.com/eslint/eslint/issues/3458 (re-export of @rushstack/eslint-patch)\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns()],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    // optional overrides per project file match\n  ],\n};\n"
  },
  {
    "path": "packages/db-main-prisma/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# generated\n\n/src/generated\n\n# build\n/dist\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n\n/db\n"
  },
  {
    "path": "packages/db-main-prisma/.idea/db-main-prisma.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "packages/db-main-prisma/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/db-main-prisma.iml\" filepath=\"$PROJECT_DIR$/.idea/db-main-prisma.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "packages/db-main-prisma/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2025 Teable, Inc.\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": "packages/db-main-prisma/README.md",
    "content": "# @teable/db-main-prisma\n\n<p align=\"left\">\n  <a aria-label=\"Build\" href=\"https://github.com/teableio/teable/actions?query=workflow%3ACI\">\n    <img alt=\"build\" src=\"https://img.shields.io/github/workflow/status/teableio/teable/CI-web-app/main?label=CI&logo=github&style=flat-quare&labelColor=000000\" />\n  </a>\n</p>\n\n## Intro\n\nBasic demo of a shared package using [prisma](<(https://prisma.io)>) to handle database access, part of the [nextjs-monorepo-example](https://github.com/teableio/teable)\n\n## Quick start\n\nStart the database with `docker-compose up database` then run\n\n```bash\ncd packages/db-main-prisma\nyarn prisma-db-push\n```\n\n> See the .env(.local|.production|.development) file to edit the connection.\n> **Curious about the setup ?**, we use [dotenv-flow](https://github.com/kerimdzhanov/dotenv-flow) under the hood read [this](https://github.com/prisma/prisma/issues/3865)\n> and see the script section of [./package.json](./package.json)\n\n## Install\n\n### Database\n\n#### Option 0: sqlite local\n\nThe default env for `PRISMA_DATABASE_URL` is defined in the main [.env](.env) file.\nBy default, it connects to the sqlite file main.db\n\n#### Option 1: Postgresql local (deprecated)\n\nThe default env for `PRISMA_DATABASE_URL` is defined in the main [.env](.env) file.\nBy default, it connects to the postgresql service defined in [../../docker-compose.yml](../../dockers/docker-compose.yml).\n\nEnsure you have docker and docker-compose and run\n\n```bash\n# In the root folder\ndocker-compose up database\n# Alternatively, from any folder\nyarn docker:up\n```\n\n#### Option 2: An hosted postgres instance (deprecated)\n\nTo quick start, you can use a free tier at supabase.io, but all providers will work.\n\nAs an example, simply create an `.env.local` and set the supabase pgbouncer url:\n\n```env\nPRISMA_DATABASE_URL=postgresql://postgres:[PASSWORD]@[HOST]:[PORT]/postgres?schema=public&pgbouncer=true&sslmode=require&sslaccept=strict&sslcert=../config/certs/supabase-prod-ca-2021.crt\n```\n\n> You can append `&connection_limit=1` if deploying on a serverless/lambda provider (ie: vercel, netlify...)\n\n## DB creation\n\nTo create the database, simply run\n\n```bash\nyarn prisma-db-push\n```\n\n## DB type generation\n\nCreate or update the types. This is generally automatically done in\na postinstall from any app, see script section of [../../apps/nextjs-app/package.json](../../apps/nextjs-app/package.json)\nor try it out with `yarn workspace web-app postinstall`\n\n```bash\nyarn prisma generate\n```\n"
  },
  {
    "path": "packages/db-main-prisma/lint-staged.config.js",
    "content": "// @ts-check\n\n/**\n * This files overrides the base lint-staged.config.js present in the root directory.\n * It allows to run eslint based the package specific requirements.\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/teable/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier, getEslintFixCmd } = require('../../lint-staged.common.js');\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '**/*.{js,jsx,ts,tsx,mjs,cjs}': (filenames) => {\n    return getEslintFixCmd({\n      cwd: __dirname,\n      fix: true,\n      cache: true,\n      maxWarnings: 25,\n      files: filenames,\n    });\n  },\n  '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nmodule.exports = rules;\n"
  },
  {
    "path": "packages/db-main-prisma/package.json",
    "content": "{\n  \"name\": \"@teable/db-main-prisma\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"private\": true,\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/db-main-prisma\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"dist/index.js\",\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"@teable/source\": \"./src/index.ts\",\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\",\n    \"prisma\"\n  ],\n  \"scripts\": {\n    \"build\": \"pnpm prisma-generate-ci && tsc\",\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./node_modules/.cache ./.eslintcache\",\n    \"dev\": \"tsc --watch\",\n    \"fix-all-files\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --fix\",\n    \"lint\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --cache --cache-location ../../.cache/eslint/db-main-prisma.eslintcache\",\n    \"prisma-generate\": \"prisma generate\",\n    \"prisma-generate-ci\": \"cross-env PRISMA_DATABASE_URL=\\\"postgresql://teable:teable@127.0.0.1:5432/teable?schema=public\\\" prisma generate --schema ./prisma/postgres/schema.prisma\",\n    \"prisma-migrate\": \"dotenv-flow -p ../../apps/nextjs-app -- pnpm prisma migrate\",\n    \"prisma-migrate-reset\": \"dotenv-flow -p ../../apps/nextjs-app -- pnpm prisma migrate reset\",\n    \"prisma-db-push\": \"dotenv-flow -p ../../apps/nextjs-app -- pnpm prisma db push\",\n    \"prisma-db-seed\": \"dotenv-flow -p ../../apps/nextjs-app -- pnpm prisma db seed\",\n    \"prisma-studio\": \"dotenv-flow -p ../../apps/nextjs-app -- pnpm prisma studio\",\n    \"test\": \"run-s test-unit\",\n    \"test-unit\": \"echo \\\"No unit tests yet\\\"\",\n    \"test-e2e\": \"echo \\\"No e2e tests yet\\\"\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"@prisma/client\": \"^5.0.0\",\n    \"@nestjs/common\": \"^10.0.0\",\n    \"nestjs-cls\": \"^4.0.0\"\n  },\n  \"dependencies\": {\n    \"@prisma/client\": \"6.2.1\",\n    \"prisma\": \"6.2.1\",\n    \"nanoid\": \"3.3.7\"\n  },\n  \"devDependencies\": {\n    \"@httpx/dsn-parser\": \"1.8.4\",\n    \"@faker-js/faker\": \"8.4.1\",\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/node\": \"22.18.0\",\n    \"@types/bcrypt\": \"5.0.2\",\n    \"camelcase\": \"8.0.0\",\n    \"cross-env\": \"7.0.3\",\n    \"dotenv-flow-cli\": \"1.1.1\",\n    \"eslint\": \"8.57.0\",\n    \"handlebars\": \"4.7.8\",\n    \"is-port-reachable\": \"3.1.0\",\n    \"mustache\": \"4.2.0\",\n    \"npm-run-all2\": \"6.1.2\",\n    \"picocolors\": \"1.0.0\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"tsx\": \"4.7.1\",\n    \"typescript\": \"5.4.3\",\n    \"bcrypt\": \"5.1.1\"\n  },\n  \"prisma\": {\n    \"seed\": \"tsx ./prisma/seed.ts\"\n  }\n}\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240308114704_initial_database/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"space\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"space_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"base\" (\n    \"id\" TEXT NOT NULL,\n    \"space_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"icon\" TEXT,\n    \"schema_pass\" TEXT,\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"base_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"table_meta\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"icon\" TEXT,\n    \"db_table_name\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"table_meta_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"field\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"options\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"cell_value_type\" TEXT NOT NULL,\n    \"is_multiple_cell_value\" BOOLEAN,\n    \"db_field_type\" TEXT NOT NULL,\n    \"db_field_name\" TEXT NOT NULL,\n    \"not_null\" BOOLEAN,\n    \"unique\" BOOLEAN,\n    \"is_primary\" BOOLEAN,\n    \"is_computed\" BOOLEAN,\n    \"is_lookup\" BOOLEAN,\n    \"is_pending\" BOOLEAN,\n    \"has_error\" BOOLEAN,\n    \"lookup_linked_field_id\" TEXT,\n    \"lookup_options\" TEXT,\n    \"table_id\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"field_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"view\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"table_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"sort\" TEXT,\n    \"filter\" TEXT,\n    \"group\" TEXT,\n    \"options\" TEXT,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"column_meta\" TEXT NOT NULL,\n    \"enable_share\" BOOLEAN,\n    \"share_id\" TEXT,\n    \"share_meta\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"view_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"ops\" (\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"operation\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"snapshots\" (\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"data\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"reference\" (\n    \"id\" TEXT NOT NULL,\n    \"from_field_id\" TEXT NOT NULL,\n    \"to_field_id\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"reference_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"users\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"password\" TEXT,\n    \"salt\" TEXT,\n    \"phone\" TEXT,\n    \"email\" TEXT NOT NULL,\n    \"avatar\" TEXT,\n    \"notify_meta\" TEXT,\n    \"last_sign_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"deleted_time\" TIMESTAMP(3),\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"users_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"account\" (\n    \"id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"provider_id\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"account_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"attachments\" (\n    \"id\" TEXT NOT NULL,\n    \"bucket\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"hash\" TEXT NOT NULL,\n    \"size\" INTEGER NOT NULL,\n    \"mimetype\" TEXT NOT NULL,\n    \"path\" TEXT NOT NULL,\n    \"width\" INTEGER,\n    \"height\" INTEGER,\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"attachments_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"attachments_table\" (\n    \"id\" TEXT NOT NULL,\n    \"attachment_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"field_id\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"attachments_table_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow\" (\n    \"id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"deployment_status\" TEXT NOT NULL DEFAULT 'undeployed',\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"automation_workflow_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_trigger\" (\n    \"id\" TEXT NOT NULL,\n    \"trigger_id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"trigger_type\" TEXT,\n    \"input_expressions\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"automation_workflow_trigger_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_action\" (\n    \"id\" TEXT NOT NULL,\n    \"action_id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"action_type\" TEXT,\n    \"input_expressions\" TEXT,\n    \"next_node_id\" TEXT,\n    \"parent_node_id\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"automation_workflow_action_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_execution_history\" (\n    \"id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"execution_type\" TEXT NOT NULL,\n    \"execution_result\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"automation_workflow_execution_history_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"collaborator\" (\n    \"id\" TEXT NOT NULL,\n    \"role_name\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"user_id\" TEXT NOT NULL,\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"collaborator_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"invitation\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"role\" TEXT NOT NULL,\n    \"invitation_code\" TEXT NOT NULL,\n    \"expired_time\" TIMESTAMP(3),\n    \"create_by\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n    \"deleted_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"invitation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"invitation_record\" (\n    \"id\" TEXT NOT NULL,\n    \"invitation_id\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"inviter\" TEXT NOT NULL,\n    \"accepter\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"invitation_record_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"notification\" (\n    \"id\" TEXT NOT NULL,\n    \"from_user\" TEXT,\n    \"to_user\" TEXT,\n    \"from_user_id\" TEXT NOT NULL,\n    \"to_user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"message\" TEXT NOT NULL,\n    \"url_meta\" TEXT,\n    \"is_read\" BOOLEAN NOT NULL DEFAULT false,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"notification_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"access_token\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"user_id\" TEXT NOT NULL,\n    \"scopes\" TEXT NOT NULL,\n    \"space_ids\" TEXT,\n    \"base_ids\" TEXT,\n    \"sign\" TEXT NOT NULL,\n    \"expired_time\" TIMESTAMP(3) NOT NULL,\n    \"last_used_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"access_token_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"base_order_idx\" ON \"base\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"table_meta_order_idx\" ON \"table_meta\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"field_lookup_linked_field_id_idx\" ON \"field\"(\"lookup_linked_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"view_order_idx\" ON \"view\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"ops_collection_created_time_idx\" ON \"ops\"(\"collection\", \"created_time\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ops_collection_doc_id_version_key\" ON \"ops\"(\"collection\", \"doc_id\", \"version\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"snapshots_collection_doc_id_key\" ON \"snapshots\"(\"collection\", \"doc_id\");\n\n-- CreateIndex\nCREATE INDEX \"reference_from_field_id_idx\" ON \"reference\"(\"from_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"reference_to_field_id_idx\" ON \"reference\"(\"to_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"reference_to_field_id_from_field_id_key\" ON \"reference\"(\"to_field_id\", \"from_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"users_phone_key\" ON \"users\"(\"phone\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"users_email_key\" ON \"users\"(\"email\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_provider_provider_id_key\" ON \"account\"(\"provider\", \"provider_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"attachments_token_key\" ON \"attachments\"(\"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_workflow_id_key\" ON \"automation_workflow\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_trigger_trigger_id_key\" ON \"automation_workflow_trigger\"(\"trigger_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_trigger_workflow_id_idx\" ON \"automation_workflow_trigger\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_action_action_id_key\" ON \"automation_workflow_action\"(\"action_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_action_workflow_id_idx\" ON \"automation_workflow_action\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_execution_history_workflow_id_idx\" ON \"automation_workflow_execution_history\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE INDEX \"notification_to_user_id_is_read_created_time_idx\" ON \"notification\"(\"to_user_id\", \"is_read\", \"created_time\");\n\n-- AddForeignKey\nALTER TABLE \"base\" ADD CONSTRAINT \"base_space_id_fkey\" FOREIGN KEY (\"space_id\") REFERENCES \"space\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"table_meta\" ADD CONSTRAINT \"table_meta_base_id_fkey\" FOREIGN KEY (\"base_id\") REFERENCES \"base\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"field\" ADD CONSTRAINT \"field_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"view\" ADD CONSTRAINT \"view_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"account_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"users\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240313062534_add_credit/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"space\" ADD COLUMN     \"credit\" INTEGER;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240409081450_field_order/migration.sql",
    "content": "-- AlterTable\nBEGIN;\n\n-- Step 1: Add the column, allowing NULL values temporarily\nALTER TABLE \"field\"\nADD COLUMN \"order\" DOUBLE PRECISION;\n\n-- Step 2: Set a default value for existing rows\nUPDATE \"field\"\nSET \"order\" = 0\nWHERE \"order\" IS NULL;\n\n-- Step 3: Change the column to NOT NULL now that all rows have a value\nALTER TABLE \"field\"\nALTER COLUMN \"order\" SET NOT NULL;\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240410190501_primary_field_visible/migration.sql",
    "content": "CREATE TEMP TABLE updated_column_t AS (\n    SELECT\n        jsonb_object_agg (\n            KEY,\n            CASE\n                WHEN KEY = field.ID AND field.is_primary THEN VALUE || '{\"visible\": true}'::JSONB\n                ELSE VALUE\n            END\n        ) AS column_meta,\n        VIEW.ID AS view_id\n    FROM\n        VIEW,\n        jsonb_each (column_meta::JSONB)\n        JOIN field ON KEY = field.ID\n    WHERE\n        VIEW.\"type\" = 'kanban'\n    GROUP BY\n        VIEW.ID\n);\n\nUPDATE VIEW\nSET column_meta = updated_column_t.column_meta\nFROM updated_column_t\nWHERE VIEW.ID = updated_column_t.view_id;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240416092001_clean_useless_tables/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the `automation_workflow` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_action` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_execution_history` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_trigger` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropTable\nDROP TABLE \"automation_workflow\";\n\n-- DropTable\nDROP TABLE \"automation_workflow_action\";\n\n-- DropTable\nDROP TABLE \"automation_workflow_execution_history\";\n\n-- DropTable\nDROP TABLE \"automation_workflow_trigger\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240528060827_add_pin_resource/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"pin_resource\" (\n    \"id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"order\" DOUBLE PRECISION NOT NULL,\n\n    CONSTRAINT \"pin_resource_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"pin_resource_order_idx\" ON \"pin_resource\"(\"order\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"pin_resource_created_by_resource_id_key\" ON \"pin_resource\"(\"created_by\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240625032002_add_admin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"deactivated_time\" TIMESTAMP(3),\nADD COLUMN     \"is_admin\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240626072754_add_setting_table/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"setting\" (\n    \"instance_id\" TEXT NOT NULL,\n    \"disallow_sign_up\" BOOLEAN,\n    \"disallow_space_creation\" BOOLEAN,\n\n    CONSTRAINT \"setting_pkey\" PRIMARY KEY (\"instance_id\")\n);\n\n-- Insert initial record using UUID v4 format\nINSERT INTO \"setting\" (\"instance_id\", \"disallow_sign_up\", \"disallow_space_creation\") \nVALUES (LOWER(\n    SUBSTR(md5(random()::text), 1, 8) || '-' ||\n    SUBSTR(md5(random()::text), 9, 4) || '-' ||\n    '4' || SUBSTR(md5(random()::text), 13, 3) || '-' ||\n    SUBSTR('89ab', 1 + (random() * 3)::integer, 1) ||\n    SUBSTR(md5(random()::text), 17, 3) || '-' ||\n    SUBSTR(md5(random()::text), 21, 12)\n), NULL, NULL);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240628115120_add_space_invitation/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN     \"disallow_space_invitation\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240702084258_add_oauth/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"access_token\" ADD COLUMN     \"is_oauth\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"oauth_app\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"homepage\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"client_id\" TEXT NOT NULL,\n    \"redirect_uris\" TEXT,\n    \"scopes\" TEXT,\n    \"is_extension\" BOOLEAN,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"oauth_app_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_authorized\" (\n    \"id\" TEXT NOT NULL,\n    \"client_id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"authorized_time\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"oauth_app_authorized_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_secret\" (\n    \"id\" TEXT NOT NULL,\n    \"app_id\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_used_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"oauth_app_secret_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_token\" (\n    \"id\" TEXT NOT NULL,\n    \"app_secret_id\" TEXT NOT NULL,\n    \"refresh_token_sign\" TEXT NOT NULL,\n    \"expired_time\" TIMESTAMP(3) NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"oauth_app_token_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_client_id_key\" ON \"oauth_app\"(\"client_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_authorized_client_id_user_id_key\" ON \"oauth_app_authorized\"(\"client_id\", \"user_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_secret_secret_key\" ON \"oauth_app_secret\"(\"secret\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_token_refresh_token_sign_key\" ON \"oauth_app_token\"(\"refresh_token_sign\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240708080014_oauth_revoke/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `is_oauth` on the `access_token` table. All the data in the column will be lost.\n  - You are about to drop the column `is_extension` on the `oauth_app` table. All the data in the column will be lost.\n  - You are about to drop the column `app_id` on the `oauth_app_secret` table. All the data in the column will be lost.\n  - Added the required column `client_id` to the `oauth_app_secret` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- AlterTable\nALTER TABLE \"access_token\" DROP COLUMN \"is_oauth\",\nADD COLUMN     \"client_id\" TEXT;\n\n-- AlterTable\nALTER TABLE \"oauth_app\" DROP COLUMN \"is_extension\";\n\n-- Rename col\nALTER TABLE \"oauth_app_secret\"\nRENAME COLUMN \"app_id\" TO \"client_id\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240712040045_remove_bucket/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `bucket` on the `attachments` table. All the data in the column will be lost.\n\n*/\nBEGIN;\n-- AlterTable\nALTER TABLE \"attachments\" DROP COLUMN \"bucket\";\n/** remove bucket in avatar and form cover url and logo url */\n-- in avatar\nUPDATE users\nSET avatar = SUBSTRING(avatar FROM POSITION('/avatar/' IN avatar));\n-- in form cover url\nUPDATE view\nSET options = jsonb_set(\n  options::jsonb,\n  '{logoUrl}',\n  ('\"' || REGEXP_REPLACE(options::jsonb->>'logoUrl', '^.+(/form/.*)$', '\\1') || '\"')::jsonb\n)\nWHERE type = 'Form' AND (options::jsonb ? 'logoUrl');\n\n-- in logo url\nUPDATE view\nSET options = jsonb_set(\n  options::jsonb,\n  '{coverUrl}',\n  ('\"' || REGEXP_REPLACE(options::jsonb->>'coverUrl', '^.+(/form/.*)$', '\\1') || '\"')::jsonb\n)\nWHERE type = 'Form' AND (options::jsonb ? 'coverUrl');\n\n-- update Form -> form\nUPDATE view\nSET type = 'form'\nWHERE type = 'Form';\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240716070632_notification_url_path/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `from_user` on the `notification` table. All the data in the column will be lost.\n  - You are about to drop the column `to_user` on the `notification` table. All the data in the column will be lost.\n  - You are about to drop the column `url_meta` on the `notification` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"notification\" DROP COLUMN \"from_user\",\nDROP COLUMN \"to_user\",\nDROP COLUMN \"url_meta\",\nADD COLUMN     \"url_path\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240806110415_add_record_history/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"record_history\" (\n    \"id\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"field_id\" TEXT NOT NULL,\n    \"before\" TEXT NOT NULL,\n    \"after\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"record_history_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"record_history_table_id_record_id_created_time_idx\" ON \"record_history\"(\"table_id\", \"record_id\", \"created_time\");\n\n-- CreateIndex\nCREATE INDEX \"record_history_table_id_created_time_idx\" ON \"record_history\"(\"table_id\", \"created_time\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240814074637_update_collaborator/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `base_id` on the `collaborator` table. All the data in the column will be lost.\n  - You are about to drop the column `space_id` on the `collaborator` table. All the data in the column will be lost.\n  - A unique constraint covering the columns `[resource_type,resource_id,user_id]` on the table `collaborator` will be added. If there are existing duplicate values, this will fail.\n  - Added the required column `resource_id` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `resource_type` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n\n*/\nBEGIN;\n\nDELETE FROM \"collaborator\"\nWHERE \"deleted_time\" IS NOT NULL;\n\nALTER TABLE \"collaborator\"\nDROP COLUMN \"deleted_time\";\n\n-- AlterTable\nALTER TABLE \"collaborator\" ADD COLUMN \"resource_id\" TEXT,\nADD COLUMN \"resource_type\" TEXT;\n\nUPDATE \"collaborator\" SET \"resource_id\" = \"space_id\";\nUPDATE \"collaborator\" SET \"resource_type\" = 'space';\n\nALTER TABLE \"collaborator\" DROP COLUMN \"base_id\",\nALTER COLUMN \"resource_id\" SET NOT NULL,\nALTER COLUMN \"resource_type\" SET NOT NULL,\nDROP COLUMN \"space_id\";\n-- CreateIndex\nCREATE UNIQUE INDEX \"collaborator_resource_type_resource_id_user_id_key\" ON \"collaborator\"(\"resource_type\", \"resource_id\", \"user_id\");\n\n-- AddForeignKey\nALTER TABLE \"collaborator\" ADD CONSTRAINT \"collaborator_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"users\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240906084530_add_trash/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"trash\" (\n    \"id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_id\" TEXT,\n    \"deleted_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"deleted_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"trash_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"trash_resource_type_resource_id_key\" ON \"trash\"(\"resource_type\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240913075702_add_dashboard_plugin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"is_system\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"plugin\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"detail_desc\" TEXT,\n    \"logo\" TEXT NOT NULL,\n    \"help_url\" TEXT,\n    \"status\" TEXT NOT NULL,\n    \"positions\" TEXT NOT NULL,\n    \"url\" TEXT,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"i18n\" TEXT,\n    \"plugin_user\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"plugin_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"plugin_install\" (\n    \"id\" TEXT NOT NULL,\n    \"plugin_id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"position_id\" TEXT NOT NULL,\n    \"position\" TEXT NOT NULL,\n    \"storage\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"plugin_install_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"dashboard\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"layout\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"dashboard_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"plugin_secret_key\" ON \"plugin\"(\"secret\");\n\n-- AddForeignKey\nALTER TABLE \"plugin_install\" ADD CONSTRAINT \"plugin_install_plugin_id_fkey\" FOREIGN KEY (\"plugin_id\") REFERENCES \"plugin\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20240919032636_add_comment/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"comment\" (\n    \"id\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"quote_Id\" TEXT,\n    \"content\" TEXT,\n    \"reaction\" TEXT,\n    \"deleted_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"comment_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"comment_subscription\" (\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE INDEX \"comment_table_id_record_id_idx\" ON \"comment\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"comment_subscription_table_id_record_id_idx\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"comment_subscription_table_id_record_id_key\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241008161823_share_meta/migration.sql",
    "content": "BEGIN;\n-- update share meta for all views\n\nUPDATE view\nSET share_meta = COALESCE(\n    jsonb_set(share_meta::jsonb, '{includeRecords}', 'true'::jsonb),\n    '{\"includeRecords\": true}'::jsonb\n)\nWHERE (type = 'grid' OR type = 'kanban')\nAND share_id IS NOT NULL;\n\nUPDATE view\nSET share_meta = COALESCE(\n    jsonb_set(share_meta::jsonb, '{submit}', '{\"allow\": true}'::jsonb),\n    '{\"submit\": {\"allow\": true}}'::jsonb\n)\nWHERE type = 'form'\nAND share_id IS NOT NULL;\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241031080906_add_attachment_thumbnail/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"attachments\" ADD COLUMN     \"thumbnail_path\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241126085325_add_ref_meta/migration.sql",
    "content": "BEGIN;\n\n-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"ref_meta\" TEXT;\n\n-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'automationRobot',\n  'Automation Robot',\n  'automationRobot@system.teable.ai',\n  true,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'automationRobot');\n\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'anonymous',\n  'Anonymous',\n  'anonymous@system.teable.ai',\n  true,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'anonymous');\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241128112023_add_ai_config/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN     \"ai_config\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241205121129_add_table_trash/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"table_trash\" (\n    \"id\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"table_trash_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"record_trash\" (\n    \"id\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n\n    CONSTRAINT \"record_trash_pkey\" PRIMARY KEY (\"id\")\n);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241223100142_collaborator_support_org/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `user_id` on the `collaborator` table. All the data in the column will be lost.\n  - A unique constraint covering the columns `[resource_type,resource_id,principal_id,principal_type]` on the table `collaborator` will be added. If there are existing duplicate values, this will fail.\n  - Added the required column `principal_id` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `principal_type` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n\n*/\n\nBEGIN;\n\n-- AlterTable\nALTER TABLE \"collaborator\"\nADD COLUMN \"principal_id\" TEXT,\nADD COLUMN \"principal_type\" TEXT;\n\nUPDATE \"collaborator\" \nSET principal_id = user_id,\n    principal_type = 'user'\nWHERE user_id IS NOT NULL;\n\nALTER TABLE \"collaborator\" \nALTER COLUMN \"principal_id\" SET NOT NULL,\nALTER COLUMN \"principal_type\" SET NOT NULL;\n\n\n-- DropForeignKey\nALTER TABLE \"collaborator\" DROP CONSTRAINT \"collaborator_user_id_fkey\";\n\n-- DropIndex\nDROP INDEX \"collaborator_resource_type_resource_id_user_id_key\";\n\nALTER TABLE \"collaborator\" DROP COLUMN \"user_id\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"collaborator_resource_type_resource_id_principal_id_princip_key\" ON \"collaborator\"(\"resource_type\", \"resource_id\", \"principal_id\", \"principal_type\");\n\n-- AddForeignKey\nALTER TABLE \"collaborator\" ADD CONSTRAINT \"collaborator_principal_id_fkey\" FOREIGN KEY (\"principal_id\") REFERENCES \"users\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20241226111824_remove_collaborator_foreign_user/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"collaborator\" DROP CONSTRAINT \"collaborator_principal_id_fkey\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250115084212_add_enable_email_verification_setting/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN     \"enable_email_verification\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250117105433_update_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"view\" ADD COLUMN     \"is_locked\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250214080105_add_integration/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"integration\" (\n    \"id\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"config\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"enable\" BOOLEAN,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"integration_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"integration_resource_id_key\" ON \"integration\"(\"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250217092955_add_table_plugin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"plugin\" ADD COLUMN     \"config\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"plugin_panel\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"layout\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"plugin_panel_pkey\" PRIMARY KEY (\"id\")\n);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250218075500_add_plugin_context_menu/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"plugin_context_menu\" (\n    \"table_id\" TEXT NOT NULL,\n    \"plugin_install_id\" TEXT NOT NULL,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"plugin_context_menu_plugin_install_id_key\" ON \"plugin_context_menu\"(\"plugin_install_id\");\n\n-- AddForeignKey\nALTER TABLE \"plugin_panel\" ADD CONSTRAINT \"plugin_panel_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"plugin_context_menu\" ADD CONSTRAINT \"plugin_context_menu_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250320062220_user_last_visit/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"user_last_visit\" (\n    \"id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_resource_id\" TEXT NOT NULL,\n    \"last_visit_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"user_last_visit_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_user_id_resource_type_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250328035739_brand/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN     \"brand_logo\" TEXT,\nADD COLUMN     \"brand_name\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250402105144_add_template/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"space\" ADD COLUMN     \"is_template\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"template\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"cover\" TEXT,\n    \"name\" TEXT,\n    \"description\" TEXT,\n    \"category_id\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n    \"is_system\" BOOLEAN,\n    \"is_published\" BOOLEAN,\n    \"snapshot\" TEXT,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"usage_count\" INTEGER NOT NULL DEFAULT 0,\n\n    CONSTRAINT \"template_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"template_category\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n    \"order\" DOUBLE PRECISION NOT NULL,\n\n    CONSTRAINT \"template_category_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"template_category_name_key\" ON \"template_category\"(\"name\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250406145144_add_share_id_unique/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[share_id]` on the table `view` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"view_share_id_key\" ON \"view\"(\"share_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250409093339_add_task_tables/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"field\" ADD COLUMN     \"ai_config\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"task\" (\n    \"id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"snapshot\" TEXT,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"task_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"task_run\" (\n    \"id\" TEXT NOT NULL,\n    \"task_id\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"spent\" INTEGER,\n    \"error_msg\" TEXT,\n    \"started_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"task_run_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"task_reference\" (\n    \"id\" TEXT NOT NULL,\n    \"from_field_id\" TEXT NOT NULL,\n    \"to_field_id\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"task_reference_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"task_type_status_idx\" ON \"task\"(\"type\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"task_run_task_id_status_idx\" ON \"task_run\"(\"task_id\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"task_reference_from_field_id_idx\" ON \"task_reference\"(\"from_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"task_reference_to_field_id_idx\" ON \"task_reference\"(\"to_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"task_reference_to_field_id_from_field_id_key\" ON \"task_reference\"(\"to_field_id\", \"from_field_id\");\n\n-- AddForeignKey\nALTER TABLE \"task_run\" ADD CONSTRAINT \"task_run_task_id_fkey\" FOREIGN KEY (\"task_id\") REFERENCES \"task\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250410102941_update_task_table/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Added the required column `created_by` to the `task` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- AlterTable\nALTER TABLE \"task\" ADD COLUMN     \"created_by\" TEXT NOT NULL,\nADD COLUMN     \"last_modified_by\" TEXT,\nADD COLUMN     \"last_modified_time\" TIMESTAMP(3);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250416113238_add_template_markdown_description/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"template\" ADD COLUMN     \"markdown_description\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250418091636_add_db_table_name_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"table_meta_db_table_name_idx\" ON \"table_meta\"(\"db_table_name\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250509062715_require_primary_key/migration.sql",
    "content": "BEGIN;\n\nALTER TABLE \"comment_subscription\" ADD COLUMN \"id\" TEXT DEFAULT substring(md5(random()::text), 1, 25),\nADD CONSTRAINT \"comment_subscription_pkey\" PRIMARY KEY (\"id\");\n\n-- AlterTable\nALTER TABLE \"ops\" ADD COLUMN \"id\" TEXT DEFAULT substring(md5(random()::text), 1, 25),\nADD CONSTRAINT \"ops_pkey\" PRIMARY KEY (\"id\");\n\n-- AlterTable\nALTER TABLE \"plugin_context_menu\" ADD COLUMN \"id\" TEXT DEFAULT substring(md5(random()::text), 1, 25),\nADD CONSTRAINT \"plugin_context_menu_pkey\" PRIMARY KEY (\"id\");\n\n-- DropTable\nDROP TABLE \"snapshots\";\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250513085306_add_ai_robot_user/migration.sql",
    "content": "BEGIN;\n\n-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'aiRobot',\n  'AI Robot',\n  'aiRobot@system.teable.ai',\n  true,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'aiRobot');\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250520081803_update_user_last_visit/migration.sql",
    "content": "BEGIN;\n\n-- DropIndex\nDROP INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_key\";\n\n-- fix error: Delete duplicate records before creating unique index\nWITH duplicates AS (\n  SELECT user_id, resource_type, resource_id, COUNT(*) as count\n   FROM user_last_visit\n   GROUP BY user_id, resource_type, resource_id\n)\nDELETE FROM user_last_visit\nWHERE (user_id, resource_type, resource_id) IN (\n  SELECT user_id, resource_type, resource_id\n  FROM duplicates\n  WHERE count > 1\n);\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\n\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"resource_id\");\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250520103546_add_user_trial_used/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"is_trial_used\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250526042029_repair_reference_caused_by_formula_duplicate/migration.sql",
    "content": "-- Repair reference\nINSERT INTO reference (id, to_field_id, from_field_id)\nSELECT 'cmb' || substr(md5(random()::text), 1, 22)                               as id,\n       f.id                                                                      as to_field_id,\n       (regexp_matches(f.options::json ->> 'expression', '\\{(fld[a-zA-Z0-9]+)\\}', 'g'))[1] as from_field_id\nFROM field f\nWHERE f.type = 'formula'\n  AND f.deleted_time is null\n  AND f.options::json ->> 'expression' IS NOT NULL\n  AND f.options::json ->> 'expression' ~ '\\{fld[a-zA-Z0-9]+\\}'\n  AND f.created_time > '2025-04-05'\nON CONFLICT (to_field_id, from_field_id) DO NOTHING;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250604101438_update_access_token_full_access/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"access_token\" ADD COLUMN     \"has_full_access\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250702035214_update_setting/migration.sql",
    "content": "BEGIN;\n\nCREATE TABLE \"setting_backup\" AS SELECT * FROM \"setting\";\n\n-- AlterTable\nALTER TABLE \"setting\" \n    DROP COLUMN \"instance_id\",\n    DROP COLUMN \"disallow_sign_up\",\n    DROP COLUMN \"disallow_space_creation\", \n    DROP COLUMN \"disallow_space_invitation\",\n    DROP COLUMN \"enable_email_verification\",\n    DROP COLUMN \"ai_config\",\n    DROP COLUMN \"brand_name\",\n    DROP COLUMN \"brand_logo\",\n    ADD COLUMN \"name\" TEXT,\n    ADD COLUMN \"content\" TEXT,\n    ADD COLUMN \"created_by\" TEXT,\n    ADD COLUMN \"created_time\" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,\n    ADD COLUMN \"last_modified_by\" TEXT,\n    ADD COLUMN \"last_modified_time\" TIMESTAMP(3);\n\nDO $$\nDECLARE\n    old_record RECORD;\nBEGIN\n    FOR old_record IN (SELECT * FROM \"setting_backup\" LIMIT 1) LOOP\n        IF old_record.disallow_sign_up IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('disallowSignUp', to_json(old_record.disallow_sign_up)::text, 'anonymous');\n        END IF;\n\n        IF old_record.disallow_space_creation IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('disallowSpaceCreation', to_json(old_record.disallow_space_creation)::text, 'anonymous');\n        END IF;\n\n        IF old_record.disallow_space_invitation IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('disallowSpaceInvitation', to_json(old_record.disallow_space_invitation)::text, 'anonymous');\n        END IF;\n\n        IF old_record.enable_email_verification IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('enableEmailVerification', to_json(old_record.enable_email_verification)::text, 'anonymous');\n        END IF;\n\n        IF old_record.ai_config IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('aiConfig', old_record.ai_config, 'anonymous');\n        END IF;\n\n        IF old_record.brand_name IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('brandName', to_json(old_record.brand_name)::text, 'anonymous');\n        END IF;\n\n        IF old_record.brand_logo IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('brandLogo', to_json(old_record.brand_logo)::text, 'anonymous');\n        END IF;\n\n        IF old_record.instance_id IS NOT NULL THEN\n            INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") \n            VALUES ('instanceId', to_json(old_record.instance_id)::text, 'anonymous');\n        END IF;\n    END LOOP;\nEND $$;\n\nDELETE FROM \"setting\" WHERE \"name\" IS NULL OR \"created_by\" IS NULL OR \"created_time\" IS NULL;\n\nALTER TABLE \"setting\" \n    ALTER COLUMN \"name\" SET NOT NULL,\n    ALTER COLUMN \"created_by\" SET NOT NULL,\n    ALTER COLUMN \"created_time\" SET NOT NULL;\n\nCREATE UNIQUE INDEX \"setting_name_key\" ON \"setting\"(\"name\");\n\nDROP TABLE IF EXISTS \"setting_backup\";\n\nCOMMIT;\n\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250730041646_add_user_permanent_deleted_time/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"permanent_deleted_time\" TIMESTAMP(3);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250804000000_add_field_meta/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"field\" ADD COLUMN     \"meta\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250811102556_add_visit_count/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user_last_visit\" ADD COLUMN     \"visit_count\" INTEGER NOT NULL DEFAULT 1;\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_resource_type_resource_id_idx\" ON \"user_last_visit\"(\"resource_type\", \"resource_id\");\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_resource_type_last_visit_time_idx\" ON \"user_last_visit\"(\"resource_type\", \"last_visit_time\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250812031017_remove_user_last_visit_useless_index/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"user_last_visit_resource_type_resource_id_idx\";\n\n-- DropIndex\nDROP INDEX \"user_last_visit_user_id_resource_type_idx\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250812090828_remove_visit_count/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `visit_count` on the `user_last_visit` table. All the data in the column will be lost.\n\n*/\n-- DropIndex\nDROP INDEX \"user_last_visit_resource_type_last_visit_time_idx\";\n\n-- AlterTable\nALTER TABLE \"user_last_visit\" DROP COLUMN \"visit_count\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250820022407_add_waitlist/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"waitlist\" (\n    \"email\" TEXT NOT NULL,\n    \"invite\" BOOLEAN,\n    \"invite_time\" TIMESTAMP(3),\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"waitlist_email_key\" ON \"waitlist\"(\"email\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250820022408_add_table_meta_db_view_name/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"table_meta\" ADD COLUMN \"db_view_name\" TEXT;\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250828083308_add_app_robot_user/migration.sql",
    "content": "BEGIN;\n\n-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'appRobot',\n  'App Robot',\n  'appRobot@system.teable.ai',\n  true,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'appRobot');\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250905035737_add_trash_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"record_trash_table_id_record_id_idx\" ON \"record_trash\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"table_trash_table_id_idx\" ON \"table_trash\"(\"table_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250922111648_add_indexes/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"comment_subscription\" ALTER COLUMN \"id\" DROP DEFAULT;\n\n-- AlterTable\nALTER TABLE \"ops\" ALTER COLUMN \"id\" DROP DEFAULT;\n\n-- AlterTable\nALTER TABLE \"plugin_context_menu\" ALTER COLUMN \"id\" DROP DEFAULT;\n\n-- CreateIndex\nCREATE INDEX \"access_token_user_id_idx\" ON \"access_token\"(\"user_id\");\n\n-- CreateIndex\nCREATE INDEX \"access_token_client_id_idx\" ON \"access_token\"(\"client_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_table_id_record_id_idx\" ON \"attachments_table\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_table_id_field_id_idx\" ON \"attachments_table\"(\"table_id\", \"field_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_attachment_id_idx\" ON \"attachments_table\"(\"attachment_id\");\n\n-- CreateIndex\nCREATE INDEX \"collaborator_resource_id_idx\" ON \"collaborator\"(\"resource_id\");\n\n-- CreateIndex\nCREATE INDEX \"collaborator_principal_id_idx\" ON \"collaborator\"(\"principal_id\");\n\n-- CreateIndex\nCREATE INDEX \"dashboard_base_id_idx\" ON \"dashboard\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"integration_resource_id_idx\" ON \"integration\"(\"resource_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_base_id_idx\" ON \"invitation\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_space_id_idx\" ON \"invitation\"(\"space_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_invitation_id_idx\" ON \"invitation_record\"(\"invitation_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_base_id_idx\" ON \"invitation_record\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_space_id_idx\" ON \"invitation_record\"(\"space_id\");\n\n-- CreateIndex\nCREATE INDEX \"plugin_install_position_id_idx\" ON \"plugin_install\"(\"position_id\");\n\n-- CreateIndex\nCREATE INDEX \"plugin_install_base_id_idx\" ON \"plugin_install\"(\"base_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20250922120000_add_conditional_lookup_flag/migration.sql",
    "content": "-- Add conditional lookup marker to field table\nALTER TABLE \"field\"\n  ADD COLUMN \"is_conditional_lookup\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251028105638_add_user_lang/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN     \"lang\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251029141643_add_notification_message_i18n/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"notification\" ADD COLUMN     \"message_i18n\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251105070802_add_oauth_foreign_keys/migration.sql",
    "content": "BEGIN;\n-- 1. clear orphan records in oauth_app_secret (client_id not in oauth_app)\nDELETE FROM oauth_app_secret \nWHERE client_id NOT IN (SELECT client_id FROM oauth_app);\n\n-- 2. clear orphan records in oauth_app_token (app_secret_id not in oauth_app_secret)\nDELETE FROM oauth_app_token \nWHERE app_secret_id NOT IN (SELECT id FROM oauth_app_secret);\n\n-- 3. clear orphan records in oauth_app_authorized (client_id not in oauth_app)\nDELETE FROM oauth_app_authorized \nWHERE client_id NOT IN (SELECT client_id FROM oauth_app);\n\n-- AddForeignKey\nALTER TABLE \"oauth_app_authorized\" ADD CONSTRAINT \"oauth_app_authorized_client_id_fkey\" FOREIGN KEY (\"client_id\") REFERENCES \"oauth_app\"(\"client_id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"oauth_app_secret\" ADD CONSTRAINT \"oauth_app_secret_client_id_fkey\" FOREIGN KEY (\"client_id\") REFERENCES \"oauth_app\"(\"client_id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"oauth_app_token\" ADD CONSTRAINT \"oauth_app_token_app_secret_id_fkey\" FOREIGN KEY (\"app_secret_id\") REFERENCES \"oauth_app_secret\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\nCOMMIT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251119134101_add_base_node/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"base_node\" (\n    \"id\" TEXT NOT NULL,\n    \"parent_id\" TEXT,\n    \"base_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"order\" DOUBLE PRECISION NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"base_node_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"base_node_folder\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n    \"last_modified_by\" TEXT,\n\n    CONSTRAINT \"base_node_folder_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_node_base_id_resource_type_resource_id_key\" ON \"base_node\"(\"base_id\", \"resource_type\", \"resource_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_node_folder_base_id_name_key\" ON \"base_node_folder\"(\"base_id\", \"name\");\n\n-- AddForeignKey\nALTER TABLE \"base_node\" ADD CONSTRAINT \"base_node_parent_id_fkey\" FOREIGN KEY (\"parent_id\") REFERENCES \"base_node\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- Data Migration\nDO $$\nDECLARE\n    has_app BOOLEAN;\n    has_workflow BOOLEAN;\n    has_dashboard BOOLEAN;\n    has_table_meta BOOLEAN;\n    select_sql TEXT := '';\n    insert_sql TEXT;\n    first_select BOOLEAN := FALSE;\nBEGIN\n    -- Check for tables existence with schema filter\n    SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'app' AND table_schema = current_schema()) INTO has_app;\n    SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'workflow' AND table_schema = current_schema()) INTO has_workflow;\n    SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'dashboard' AND table_schema = current_schema()) INTO has_dashboard;\n    SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'table_meta' AND table_schema = current_schema()) INTO has_table_meta;\n\n    -- 1. Build select SQL for all resources\n    -- dashboard and app: sort by last_modified_time DESC (newer first), use negative epoch\n    -- workflow and table_meta: sort by order ASC (smaller first)\n    IF has_dashboard THEN\n        select_sql := 'SELECT base_id, ''dashboard'' as resource_type, id as resource_id, created_time, last_modified_time, -COALESCE(EXTRACT(EPOCH FROM last_modified_time), 0) as sort_value FROM \"dashboard\"';\n        first_select := TRUE;\n    END IF;\n\n    IF has_app THEN\n        IF first_select THEN\n            select_sql := select_sql || ' UNION ALL ';\n        END IF;\n        select_sql := select_sql || 'SELECT base_id, ''app'' as resource_type, id as resource_id, created_time, last_modified_time, -COALESCE(EXTRACT(EPOCH FROM last_modified_time), 0) as sort_value FROM \"app\" WHERE deleted_time IS NULL';\n        first_select := TRUE;\n    END IF;\n\n    IF has_workflow THEN\n        IF first_select THEN\n            select_sql := select_sql || ' UNION ALL ';\n        END IF;\n        select_sql := select_sql || 'SELECT base_id, ''workflow'' as resource_type, id as resource_id, created_time, last_modified_time, COALESCE(\"order\", 0) as sort_value FROM \"workflow\" WHERE deleted_time IS NULL';\n        first_select := TRUE;\n    END IF;\n\n    IF has_table_meta THEN\n        IF first_select THEN\n            select_sql := select_sql || ' UNION ALL ';\n        END IF;\n        select_sql := select_sql || 'SELECT base_id, ''table'' as resource_type, id as resource_id, created_time, last_modified_time, COALESCE(\"order\", 0) as sort_value FROM \"table_meta\" WHERE deleted_time IS NULL';\n        first_select := TRUE;\n    END IF;\n\n    -- 2. Build insert SQL with the select query\n    IF first_select THEN\n        insert_sql := '\n        INSERT INTO \"base_node\" (\"id\", \"base_id\", \"resource_type\", \"resource_id\", \"order\", \"created_by\", \"created_time\", \"last_modified_time\")\n        SELECT\n            gen_random_uuid(),\n            base_id,\n            resource_type,\n            resource_id,\n            row_number() OVER (PARTITION BY base_id ORDER BY resource_type, sort_value ASC NULLS LAST),\n            ''anonymous'',\n            created_time,\n            last_modified_time\n        FROM (' || select_sql || ') as all_resources';\n\n        EXECUTE insert_sql;\n    END IF;\nEND $$;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251208112242_template_community/migration.sql",
    "content": "BEGIN;\n\n-- template_category\nALTER TABLE \"template\" ADD COLUMN \"category_id_new\" TEXT[];\n\nUPDATE \"template\" \nSET \"category_id_new\" = ARRAY[category_id]\nWHERE \"category_id\" IS NOT NULL AND \"category_id\" != '';\n\nALTER TABLE \"template\" DROP COLUMN \"category_id\";\n\nALTER TABLE \"template\" RENAME COLUMN \"category_id_new\" TO \"category_id\";\n\n-- featured\nALTER TABLE \"template\" ADD COLUMN     \"featured\" BOOLEAN;\n\n-- AlterTable\nALTER TABLE \"template\" ADD COLUMN     \"publish_info\" JSONB;\n\n-- Remove duplicate base_id records, keep only the most recently modified one for each base_id\nDELETE FROM \"template\"\nWHERE id IN (\n  SELECT id FROM (\n    SELECT id, \n           ROW_NUMBER() OVER (\n             PARTITION BY base_id \n             ORDER BY last_modified_time DESC NULLS LAST, created_time DESC NULLS LAST\n           ) as rn\n    FROM \"template\"\n    WHERE base_id IS NOT NULL\n  ) t\n  WHERE rn > 1\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"template_base_id_key\" ON \"template\"(\"base_id\");\n\nUPDATE \"template\" SET featured = true WHERE is_published = true;\n\nCOMMIT;\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251210134101_disallow_dashboard/migration.sql",
    "content": "INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") VALUES\n('disallowDashboard', 'true', 'anonymous')\nON CONFLICT (\"name\") DO UPDATE SET \"content\" = EXCLUDED.\"content\";"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20251219083654_add_template_visit_count/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"template\" ADD COLUMN     \"visit_count\" INTEGER NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260104151713_add_computed_update_outbox_tables/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"computed_update_outbox\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"seed_table_id\" TEXT NOT NULL,\n    \"seed_record_ids\" JSONB,\n    \"change_type\" TEXT NOT NULL,\n    \"steps\" JSONB NOT NULL,\n    \"edges\" JSONB NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"attempts\" INTEGER NOT NULL DEFAULT 0,\n    \"max_attempts\" INTEGER NOT NULL DEFAULT 8,\n    \"next_run_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"locked_at\" TIMESTAMP(3),\n    \"locked_by\" TEXT,\n    \"last_error\" TEXT,\n    \"estimated_complexity\" INTEGER NOT NULL DEFAULT 0,\n    \"plan_hash\" TEXT NOT NULL,\n    \"dirty_stats\" JSONB,\n    \"affected_table_ids\" TEXT[] DEFAULT ARRAY[]::TEXT[],\n    \"affected_field_ids\" TEXT[] DEFAULT ARRAY[]::TEXT[],\n    \"sync_max_level\" INTEGER,\n    \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"computed_update_outbox_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"computed_update_outbox_seed\" (\n    \"id\" TEXT NOT NULL,\n    \"task_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n\n    CONSTRAINT \"computed_update_outbox_seed_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"computed_update_dead_letter\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"seed_table_id\" TEXT NOT NULL,\n    \"seed_record_ids\" JSONB,\n    \"change_type\" TEXT NOT NULL,\n    \"steps\" JSONB NOT NULL,\n    \"edges\" JSONB NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"attempts\" INTEGER NOT NULL DEFAULT 0,\n    \"max_attempts\" INTEGER NOT NULL DEFAULT 8,\n    \"next_run_at\" TIMESTAMP(3) NOT NULL,\n    \"locked_at\" TIMESTAMP(3),\n    \"locked_by\" TEXT,\n    \"last_error\" TEXT,\n    \"estimated_complexity\" INTEGER NOT NULL DEFAULT 0,\n    \"plan_hash\" TEXT NOT NULL,\n    \"dirty_stats\" JSONB,\n    \"affected_table_ids\" TEXT[] DEFAULT ARRAY[]::TEXT[],\n    \"affected_field_ids\" TEXT[] DEFAULT ARRAY[]::TEXT[],\n    \"sync_max_level\" INTEGER,\n    \"failed_at\" TIMESTAMP(3) NOT NULL,\n    \"created_at\" TIMESTAMP(3) NOT NULL,\n    \"updated_at\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"computed_update_dead_letter_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_status_next_run_at_idx\" ON \"computed_update_outbox\"(\"status\", \"next_run_at\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_base_id_seed_table_id_idx\" ON \"computed_update_outbox\"(\"base_id\", \"seed_table_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_plan_hash_idx\" ON \"computed_update_outbox\"(\"plan_hash\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_seed_task_id_idx\" ON \"computed_update_outbox_seed\"(\"task_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"computed_update_outbox_seed_task_id_record_id_key\" ON \"computed_update_outbox_seed\"(\"task_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_dead_letter_base_id_seed_table_id_idx\" ON \"computed_update_dead_letter\"(\"base_id\", \"seed_table_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_dead_letter_plan_hash_idx\" ON \"computed_update_dead_letter\"(\"plan_hash\");\n\n-- AddForeignKey\nALTER TABLE \"computed_update_outbox_seed\" ADD CONSTRAINT \"computed_update_outbox_seed_task_id_fkey\" FOREIGN KEY (\"task_id\") REFERENCES \"computed_update_outbox\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260104190000_add_outbox_seed_table_id/migration.sql",
    "content": "-- Add table_id to computed_update_outbox_seed and backfill from computed_update_outbox\nALTER TABLE \"computed_update_outbox_seed\" ADD COLUMN \"table_id\" TEXT;\n\nUPDATE \"computed_update_outbox_seed\" AS s\nSET \"table_id\" = o.\"seed_table_id\"\nFROM \"computed_update_outbox\" AS o\nWHERE s.\"task_id\" = o.\"id\" AND s.\"table_id\" IS NULL;\n\nALTER TABLE \"computed_update_outbox_seed\" ALTER COLUMN \"table_id\" SET NOT NULL;\n\n-- Update unique index to include table_id\nDROP INDEX IF EXISTS \"computed_update_outbox_seed_task_id_record_id_key\";\nCREATE UNIQUE INDEX \"computed_update_outbox_seed_task_id_table_id_record_id_key\" ON \"computed_update_outbox_seed\"(\"task_id\", \"table_id\", \"record_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260105123000_add_computed_update_run_tracking/migration.sql",
    "content": "-- Add run tracking columns to computed update outbox and dead letter tables\n\nALTER TABLE \"computed_update_outbox\" ADD COLUMN \"run_id\" TEXT;\nALTER TABLE \"computed_update_outbox\" ADD COLUMN \"origin_run_ids\" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];\nALTER TABLE \"computed_update_outbox\" ADD COLUMN \"run_total_steps\" INTEGER NOT NULL DEFAULT 0;\nALTER TABLE \"computed_update_outbox\" ADD COLUMN \"run_completed_steps_before\" INTEGER NOT NULL DEFAULT 0;\n\nUPDATE \"computed_update_outbox\"\nSET \"run_id\" = \"id\"\nWHERE \"run_id\" IS NULL;\n\nUPDATE \"computed_update_outbox\"\nSET \"origin_run_ids\" = ARRAY[\"run_id\"]\nWHERE array_length(\"origin_run_ids\", 1) IS NULL;\n\nALTER TABLE \"computed_update_outbox\" ALTER COLUMN \"run_id\" SET NOT NULL;\n\nCREATE INDEX \"computed_update_outbox_run_id_idx\" ON \"computed_update_outbox\"(\"run_id\");\n\nALTER TABLE \"computed_update_dead_letter\" ADD COLUMN \"run_id\" TEXT;\nALTER TABLE \"computed_update_dead_letter\" ADD COLUMN \"origin_run_ids\" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];\nALTER TABLE \"computed_update_dead_letter\" ADD COLUMN \"run_total_steps\" INTEGER NOT NULL DEFAULT 0;\nALTER TABLE \"computed_update_dead_letter\" ADD COLUMN \"run_completed_steps_before\" INTEGER NOT NULL DEFAULT 0;\n\nUPDATE \"computed_update_dead_letter\"\nSET \"run_id\" = \"id\"\nWHERE \"run_id\" IS NULL;\n\nUPDATE \"computed_update_dead_letter\"\nSET \"origin_run_ids\" = ARRAY[\"run_id\"]\nWHERE array_length(\"origin_run_ids\", 1) IS NULL;\n\nALTER TABLE \"computed_update_dead_letter\" ALTER COLUMN \"run_id\" SET NOT NULL;\n\nCREATE INDEX \"computed_update_dead_letter_run_id_idx\" ON \"computed_update_dead_letter\"(\"run_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260114000000_add_field_json_indexes/migration.sql",
    "content": "-- Expression indexes for field dependency lookups\n-- These indexes optimize queries that search for fields by their JSON config dependencies\n\n-- Index for lookup/rollup fields: lookup_options->>'linkFieldId'\nCREATE INDEX IF NOT EXISTS \"field_lookup_options_link_field_id_idx\"\n  ON \"field\" (((lookup_options::jsonb)->>'linkFieldId'))\n  WHERE lookup_options IS NOT NULL AND deleted_time IS NULL;\n\n-- Index for lookup/rollup fields: lookup_options->>'lookupFieldId'\nCREATE INDEX IF NOT EXISTS \"field_lookup_options_lookup_field_id_idx\"\n  ON \"field\" (((lookup_options::jsonb)->>'lookupFieldId'))\n  WHERE lookup_options IS NOT NULL AND deleted_time IS NULL;\n\n-- Index for link fields: options->>'lookupFieldId'\nCREATE INDEX IF NOT EXISTS \"field_options_lookup_field_id_idx\"\n  ON \"field\" (((options::jsonb)->>'lookupFieldId'))\n  WHERE options IS NOT NULL AND type = 'link' AND deleted_time IS NULL;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260118000000_add_symmetric_field_id_index/migration.sql",
    "content": "-- Index for link fields: options->>'symmetricFieldId'\n-- Used in FieldDependencyGraph.findAffectedFieldIds to find symmetric link fields\n-- during computed field updates across tables via two-way links.\nCREATE INDEX IF NOT EXISTS \"field_options_symmetric_field_id_idx\"\n  ON \"field\" (((options::jsonb)->>'symmetricFieldId'))\n  WHERE options IS NOT NULL AND type = 'link' AND deleted_time IS NULL;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260118010000_add_teable_try_cast_valid/migration.sql",
    "content": "-- Polyfill for pg_input_is_valid (PG < 16)\n--\n-- PG 16+ provides `pg_input_is_valid(text, regtype)` in `pg_catalog`.\n-- For PG < 16, we create `public.teable_try_cast_valid(text, text)` to emulate the\n-- \"safe cast validity\" behavior for a small set of types we need.\n--\n-- STABLE is used (not IMMUTABLE) because timestamp conversion can depend on timezone settings.\n\nDO $$\nBEGIN\n  IF NOT EXISTS (\n    SELECT 1\n    FROM pg_proc\n    WHERE proname = 'pg_input_is_valid'\n      AND pronamespace = 'pg_catalog'::regnamespace\n  ) THEN\n    CREATE OR REPLACE FUNCTION public.teable_try_cast_valid(input_text text, target_type text)\n    RETURNS boolean\n    LANGUAGE plpgsql\n    STABLE\n    AS $func$\n    BEGIN\n      IF input_text IS NULL THEN\n        RETURN TRUE;\n      END IF;\n\n      CASE target_type\n        WHEN 'jsonb' THEN\n          PERFORM input_text::jsonb;\n        WHEN 'numeric' THEN\n          PERFORM input_text::numeric;\n        WHEN 'timestamptz' THEN\n          PERFORM input_text::timestamptz;\n        WHEN 'timestamp' THEN\n          PERFORM input_text::timestamp;\n        ELSE\n          RETURN FALSE;\n      END CASE;\n      RETURN TRUE;\n    EXCEPTION WHEN OTHERS THEN\n      RETURN FALSE;\n    END;\n    $func$;\n\n    COMMENT ON FUNCTION public.teable_try_cast_valid(text, text) IS\n      'Polyfill for pg_input_is_valid (PG < 16). Returns TRUE if input_text can be safely cast to target_type.';\n  END IF;\nEND;\n$$;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260120065143_add_core_table_indexes/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"field_table_id_deleted_time_idx\" ON \"field\"(\"table_id\", \"deleted_time\");\n\n-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"table_meta_base_id_deleted_time_idx\" ON \"table_meta\"(\"base_id\", \"deleted_time\");\n\n-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"view_table_id_deleted_time_idx\" ON \"view\"(\"table_id\", \"deleted_time\");\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260121090646_add_task_run_log/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"task_run\" ADD COLUMN     \"log\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260121100000_add_base_share/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"base_share\" (\n    \"id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"share_id\" TEXT NOT NULL,\n    \"password\" TEXT,\n    \"node_id\" TEXT NOT NULL,\n    \"allow_save\" BOOLEAN,\n    \"allow_copy\" BOOLEAN,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"created_time\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" TIMESTAMP(3),\n\n    CONSTRAINT \"base_share_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_share_share_id_key\" ON \"base_share\"(\"share_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_share_node_id_key\" ON \"base_share\"(\"node_id\");\n\n-- CreateIndex\nCREATE INDEX \"base_share_base_id_idx\" ON \"base_share\"(\"base_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260129203000_add_outbox_pending_unique_index/migration.sql",
    "content": "CREATE UNIQUE INDEX IF NOT EXISTS \"computed_update_outbox_pending_unique_idx\" ON \"computed_update_outbox\"(\"base_id\", \"seed_table_id\", \"plan_hash\", \"change_type\") WHERE \"status\" = 'pending';\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260303000000_alter_attachment_size_to_bigint/migration.sql",
    "content": "-- AlterTable: Change attachments.size from INTEGER (4 bytes) to BIGINT (8 bytes)\n-- to support attachments larger than 2GB.\nALTER TABLE \"attachments\" ALTER COLUMN \"size\" SET DATA TYPE BIGINT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260305000000_add_trace_data_and_nullable_steps_edges/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"computed_update_dead_letter\" ADD COLUMN     \"trace_data\" JSONB,\nALTER COLUMN \"steps\" DROP NOT NULL,\nALTER COLUMN \"edges\" DROP NOT NULL;\n\n-- AlterTable\nALTER TABLE \"computed_update_outbox\" ALTER COLUMN \"steps\" DROP NOT NULL,\nALTER COLUMN \"edges\" DROP NOT NULL;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260305072937_oauth_app_token_optional_secret/migration.sql",
    "content": "BEGIN;\n-- AlterTable\nALTER TABLE \"oauth_app_token\" ALTER COLUMN \"app_secret_id\" DROP NOT NULL;\n\n-- Add Column client_id\n-- 1) add as nullable first\nALTER TABLE \"oauth_app_token\" ADD COLUMN \"client_id\" TEXT;\n\n-- 2) backfill from oauth_app_secret\nUPDATE \"oauth_app_token\" t\nSET \"client_id\" = s.\"client_id\"\nFROM \"oauth_app_secret\" s\nWHERE t.\"app_secret_id\" = s.\"id\"\n  AND t.\"client_id\" IS NULL;\n\n-- 3) enforce not null\nALTER TABLE \"oauth_app_token\"\nALTER COLUMN \"client_id\" SET NOT NULL;\n\n-- AddForeignKey\nALTER TABLE \"oauth_app_token\" ADD CONSTRAINT \"oauth_app_token_client_id_fkey\" FOREIGN KEY (\"client_id\") REFERENCES \"oauth_app\"(\"client_id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/20260305120931_add_oauth_app_client_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"oauth_app_token_client_id_idx\" ON \"oauth_app_token\"(\"client_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (e.g., Git)\nprovider = \"postgresql\""
  },
  {
    "path": "packages/db-main-prisma/prisma/postgres/schema.prisma",
    "content": "generator client {\n  provider      = \"prisma-client-js\"\n  binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n  provider = \"postgres\"\n  url      = env(\"PRISMA_DATABASE_URL\")\n}\n\nmodel Space {\n  id               String    @id @default(cuid())\n  name             String\n  credit           Int?\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  isTemplate       Boolean?  @map(\"is_template\")\n\n  baseGroup Base[]\n\n  @@map(\"space\")\n}\n\nmodel PinResource {\n  id          String   @id @default(cuid())\n  type        String   @map(\"type\")\n  resourceId  String   @map(\"resource_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n  order       Float    @map(\"order\")\n\n  @@unique([createdBy, resourceId])\n  @@index([order])\n  @@map(\"pin_resource\")\n}\n\nmodel Base {\n  id               String      @id @default(cuid())\n  spaceId          String      @map(\"space_id\")\n  name             String\n  order            Float\n  icon             String?\n  schemaPass       String?     @map(\"schema_pass\")\n  deletedTime      DateTime?   @map(\"deleted_time\")\n  createdTime      DateTime    @default(now()) @map(\"created_time\")\n  createdBy        String      @map(\"created_by\")\n  lastModifiedBy   String?     @map(\"last_modified_by\")\n  lastModifiedTime DateTime?   @updatedAt @map(\"last_modified_time\")\n  space            Space       @relation(fields: [spaceId], references: [id])\n  tables           TableMeta[]\n\n  @@index([order])\n  @@map(\"base\")\n}\n\nmodel TableMeta {\n  id                String              @id\n  baseId            String              @map(\"base_id\")\n  name              String\n  description       String?\n  icon              String?\n  dbTableName       String              @map(\"db_table_name\")\n  dbViewName        String?             @map(\"db_view_name\")\n  version           Int\n  order             Float\n  createdTime       DateTime            @default(now()) @map(\"created_time\")\n  lastModifiedTime  DateTime?           @updatedAt @map(\"last_modified_time\")\n  deletedTime       DateTime?           @map(\"deleted_time\")\n  createdBy         String              @map(\"created_by\")\n  lastModifiedBy    String?             @map(\"last_modified_by\")\n  base              Base                @relation(fields: [baseId], references: [id])\n  fields            Field[]\n  views             View[]\n  pluginPanel       PluginPanel[]\n  pluginContextMenu PluginContextMenu[]\n\n  @@index([order])\n  @@index([dbTableName])\n  @@index([baseId, deletedTime])\n  @@map(\"table_meta\")\n}\n\nmodel Field {\n  id                  String    @id\n  name                String\n  description         String?\n  options             String?\n  meta                String?\n  aiConfig            String?   @map(\"ai_config\")\n  type                String\n  cellValueType       String    @map(\"cell_value_type\")\n  isMultipleCellValue Boolean?  @map(\"is_multiple_cell_value\")\n  dbFieldType         String    @map(\"db_field_type\")\n  dbFieldName         String    @map(\"db_field_name\")\n  notNull             Boolean?  @map(\"not_null\")\n  unique              Boolean?\n  isPrimary           Boolean?  @map(\"is_primary\")\n  isComputed          Boolean?  @map(\"is_computed\")\n  isLookup            Boolean?  @map(\"is_lookup\")\n  isConditionalLookup Boolean?  @map(\"is_conditional_lookup\")\n  isPending           Boolean?  @map(\"is_pending\")\n  hasError            Boolean?  @map(\"has_error\")\n  // the link field id that a lookup field is linked to\n  lookupLinkedFieldId String?   @map(\"lookup_linked_field_id\")\n  lookupOptions       String?   @map(\"lookup_options\")\n  tableId             String    @map(\"table_id\")\n  order               Float\n  version             Int\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime         DateTime? @map(\"deleted_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  table               TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([lookupLinkedFieldId])\n  @@index([tableId, deletedTime])\n  @@map(\"field\")\n}\n\nmodel ComputedUpdateOutbox {\n  id                 String   @id @default(cuid())\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @default(now()) @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  createdAt          DateTime @default(now()) @map(\"created_at\")\n  updatedAt          DateTime @updatedAt @map(\"updated_at\")\n\n  seeds ComputedUpdateOutboxSeed[]\n\n  @@index([status, nextRunAt])\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_outbox\")\n}\n\nmodel ComputedUpdateOutboxSeed {\n  id       String               @id @default(cuid())\n  taskId   String               @map(\"task_id\")\n  tableId  String               @map(\"table_id\")\n  recordId String               @map(\"record_id\")\n  task     ComputedUpdateOutbox @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@unique([taskId, tableId, recordId])\n  @@index([taskId])\n  @@map(\"computed_update_outbox_seed\")\n}\n\nmodel ComputedUpdateDeadLetter {\n  id                 String   @id\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  traceData          Json?    @map(\"trace_data\")\n  failedAt           DateTime @map(\"failed_at\")\n  createdAt          DateTime @map(\"created_at\")\n  updatedAt          DateTime @map(\"updated_at\")\n\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_dead_letter\")\n}\n\nmodel View {\n  id               String    @id\n  name             String\n  description      String?\n  tableId          String    @map(\"table_id\")\n  type             String\n  sort             String?\n  filter           String?\n  group            String?\n  options          String?\n  order            Float\n  version          Int\n  columnMeta       String    @map(\"column_meta\")\n  isLocked         Boolean?  @map(\"is_locked\")\n  enableShare      Boolean?  @map(\"enable_share\")\n  shareId          String?   @unique @map(\"share_id\")\n  shareMeta        String?   @map(\"share_meta\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  table            TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([order])\n  @@index([tableId, deletedTime])\n  @@map(\"view\")\n}\n\nmodel Ops {\n  id          String   @id @default(cuid())\n  collection  String\n  docId       String   @map(\"doc_id\")\n  docType     String   @map(\"doc_type\")\n  version     Int\n  operation   String\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@unique([collection, docId, version])\n  @@index([collection, createdTime])\n  @@map(\"ops\")\n}\n\nmodel Reference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"reference\")\n}\n\nmodel User {\n  id                   String    @id @default(cuid())\n  name                 String\n  password             String?\n  salt                 String?\n  phone                String?   @unique\n  email                String    @unique\n  avatar               String?\n  isSystem             Boolean?  @map(\"is_system\")\n  isAdmin              Boolean?  @map(\"is_admin\")\n  isTrialUsed          Boolean?  @map(\"is_trial_used\")\n  lang                 String?   @map(\"lang\")\n  notifyMeta           String?   @map(\"notify_meta\")\n  lastSignTime         DateTime? @map(\"last_sign_time\")\n  deactivatedTime      DateTime? @map(\"deactivated_time\")\n  createdTime          DateTime  @default(now()) @map(\"created_time\")\n  deletedTime          DateTime? @map(\"deleted_time\")\n  lastModifiedTime     DateTime? @updatedAt @map(\"last_modified_time\")\n  permanentDeletedTime DateTime? @map(\"permanent_deleted_time\")\n  refMeta              String?   @map(\"ref_meta\")\n\n  accounts Account[]\n\n  @@map(\"users\")\n}\n\nmodel Account {\n  id          String   @id @default(cuid())\n  userId      String   @map(\"user_id\")\n  type        String\n  provider    String\n  providerId  String   @map(\"provider_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerId])\n  @@map(\"account\")\n}\n\nmodel Attachments {\n  id             String    @id @default(cuid())\n  token          String    @unique\n  hash           String\n  size           BigInt\n  mimetype       String\n  path           String\n  width          Int?\n  height         Int?\n  deletedTime    DateTime? @map(\"deleted_time\")\n  createdTime    DateTime  @default(now()) @map(\"created_time\")\n  createdBy      String    @map(\"created_by\")\n  lastModifiedBy String?   @map(\"last_modified_by\")\n  thumbnailPath  String?   @map(\"thumbnail_path\")\n\n  @@map(\"attachments\")\n}\n\nmodel AttachmentsTable {\n  id               String    @id @default(cuid())\n  attachmentId     String    @map(\"attachment_id\")\n  name             String\n  token            String\n  tableId          String    @map(\"table_id\")\n  recordId         String    @map(\"record_id\")\n  fieldId          String    @map(\"field_id\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@index([tableId, fieldId])\n  @@index([attachmentId])\n  @@map(\"attachments_table\")\n}\n\nmodel Collaborator {\n  id               String    @id @default(cuid())\n  roleName         String    @map(\"role_name\")\n  resourceType     String    @map(\"resource_type\")\n  resourceId       String    @map(\"resource_id\")\n  principalId      String    @map(\"principal_id\")\n  principalType    String    @map(\"principal_type\")\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@unique([resourceType, resourceId, principalId, principalType])\n  @@index([resourceId])\n  @@index([principalId])\n  @@map(\"collaborator\")\n}\n\nmodel Invitation {\n  id               String    @id @default(cuid())\n  baseId           String?   @map(\"base_id\")\n  spaceId          String?   @map(\"space_id\")\n  type             String    @map(\"type\")\n  role             String\n  invitationCode   String    @map(\"invitation_code\")\n  expiredTime      DateTime? @map(\"expired_time\")\n  createdBy        String    @map(\"create_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation\")\n}\n\nmodel InvitationRecord {\n  id           String   @id @default(cuid())\n  invitationId String   @map(\"invitation_id\")\n  baseId       String?  @map(\"base_id\")\n  spaceId      String?  @map(\"space_id\")\n  type         String   @map(\"type\")\n  inviter      String   @map(\"inviter\")\n  accepter     String   @map(\"accepter\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n\n  @@index([invitationId])\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation_record\")\n}\n\nmodel Notification {\n  id          String   @id @default(cuid())\n  fromUserId  String   @map(\"from_user_id\")\n  toUserId    String   @map(\"to_user_id\")\n  type        String   @map(\"type\")\n  message     String   @map(\"message\")\n  messageI18n String?  @map(\"message_i18n\")\n  urlPath     String?  @map(\"url_path\")\n  isRead      Boolean  @default(false) @map(\"is_read\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([toUserId, isRead, createdTime])\n  @@map(\"notification\")\n}\n\nmodel AccessToken {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  userId           String    @map(\"user_id\")\n  scopes           String\n  spaceIds         String?   @map(\"space_ids\")\n  baseIds          String?   @map(\"base_ids\")\n  sign             String\n  clientId         String?   @map(\"client_id\")\n  hasFullAccess    Boolean?  @map(\"has_full_access\")\n  expiredTime      DateTime  @map(\"expired_time\")\n  lastUsedTime     DateTime? @map(\"last_used_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([userId])\n  @@index([clientId])\n  @@map(\"access_token\")\n}\n\nmodel Setting {\n  name             String    @unique\n  content          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@map(\"setting\")\n}\n\nmodel OAuthApp {\n  id               String    @id @default(cuid())\n  name             String\n  logo             String?\n  homepage         String\n  description      String?\n  clientId         String    @unique @map(\"client_id\")\n  redirectUris     String?   @map(\"redirect_uris\")\n  scopes           String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  secrets          OAuthAppSecret[]\n  authorized       OAuthAppAuthorized[]\n  tokens           OAuthAppToken[]\n\n  @@map(\"oauth_app\")\n}\n\nmodel OAuthAppAuthorized {\n  id             String   @id @default(cuid())\n  clientId       String   @map(\"client_id\")\n  userId         String   @map(\"user_id\")\n  authorizedTime DateTime @map(\"authorized_time\")\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@unique([clientId, userId])\n  @@map(\"oauth_app_authorized\")\n}\n\nmodel OAuthAppSecret {\n  id           String    @id @default(cuid())\n  clientId     String    @map(\"client_id\")\n  secret       String    @unique\n  maskedSecret String    @map(\"masked_secret\")\n  createdTime  DateTime  @default(now()) @map(\"created_time\")\n  createdBy    String    @map(\"created_by\")\n  lastUsedTime DateTime? @map(\"last_used_time\")\n  tokens       OAuthAppToken[]\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@map(\"oauth_app_secret\")\n}\n\nmodel OAuthAppToken {\n  id               String   @id @default(cuid())\n  clientId         String   @map(\"client_id\")\n  appSecretId      String?  @map(\"app_secret_id\")\n  refreshTokenSign String   @unique @map(\"refresh_token_sign\")\n  expiredTime      DateTime @map(\"expired_time\")\n  createdTime      DateTime @default(now()) @map(\"created_time\")\n  createdBy        String   @map(\"created_by\")\n\n  oAuthAppSecret OAuthAppSecret? @relation(fields: [appSecretId], references: [id], onDelete: Cascade)\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@index([clientId])\n\n  @@map(\"oauth_app_token\")\n}\n\nmodel RecordHistory {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  fieldId     String   @map(\"field_id\")\n  before      String   @map(\"before\")\n  after       String   @map(\"after\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId, createdTime])\n  @@index([tableId, createdTime])\n  @@map(\"record_history\")\n}\n\nmodel Trash {\n  id           String   @id @default(cuid())\n  resourceType String   @map(\"resource_type\")\n  resourceId   String   @map(\"resource_id\")\n  parentId     String?  @map(\"parent_id\")\n  deletedTime  DateTime @default(now()) @map(\"deleted_time\")\n  deletedBy    String   @map(\"deleted_by\")\n\n  @@unique([resourceType, resourceId])\n  @@map(\"trash\")\n}\n\nmodel TableTrash {\n  id           String   @id @default(cuid())\n  tableId      String   @map(\"table_id\")\n  resourceType String   @map(\"resource_type\")\n  snapshot     String   @map(\"snapshot\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n  createdBy    String   @map(\"created_by\")\n\n  @@index([tableId])\n  @@map(\"table_trash\")\n}\n\nmodel RecordTrash {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  snapshot    String   @map(\"snapshot\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId])\n  @@map(\"record_trash\")\n}\n\nmodel Plugin {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  detailDesc       String?   @map(\"detail_desc\")\n  logo             String\n  helpUrl          String?   @map(\"help_url\")\n  status           String\n  positions        String\n  url              String?\n  secret           String    @unique\n  maskedSecret     String    @map(\"masked_secret\")\n  i18n             String?\n  config           String?\n  pluginUser       String?   @map(\"plugin_user\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  pluginInstall PluginInstall[]\n\n  @@map(\"plugin\")\n}\n\nmodel PluginInstall {\n  id               String    @id @default(cuid())\n  pluginId         String    @map(\"plugin_id\")\n  baseId           String    @map(\"base_id\")\n  name             String\n  positionId       String    @map(\"position_id\")\n  position         String\n  storage          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)\n\n  @@index([positionId])\n  @@index([baseId])\n  @@map(\"plugin_install\")\n}\n\nmodel Dashboard {\n  id               String    @id @default(cuid())\n  name             String\n  baseId           String    @map(\"base_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@index([baseId])\n  @@map(\"dashboard\")\n}\n\nmodel Comment {\n  id       String  @id @default(cuid())\n  tableId  String  @map(\"table_id\")\n  recordId String  @map(\"record_id\")\n  quoteId  String? @map(\"quote_Id\")\n  content  String?\n  reaction String?\n\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@map(\"comment\")\n}\n\nmodel CommentSubscription {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  createdBy   String   @map(\"created_by\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([tableId, recordId])\n  @@index([tableId, recordId])\n  @@map(\"comment_subscription\")\n}\n\nmodel Integration {\n  id               String    @id @default(cuid())\n  resourceId       String    @unique @map(\"resource_id\")\n  config           String    @map(\"config\")\n  type             String    @map(\"type\")\n  enable           Boolean?  @map(\"enable\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([resourceId])\n  @@map(\"integration\")\n}\n\nmodel PluginPanel {\n  id               String    @id @default(cuid())\n  name             String\n  tableId          String    @map(\"table_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_panel\")\n}\n\nmodel PluginContextMenu {\n  id               String    @id @default(cuid())\n  tableId          String    @map(\"table_id\")\n  pluginInstallId  String    @unique @map(\"plugin_install_id\")\n  order            Float     @map(\"order\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_context_menu\")\n}\n\nmodel UserLastVisit {\n  id               String   @id @default(cuid())\n  userId           String   @map(\"user_id\")\n  resourceType     String   @map(\"resource_type\")\n  resourceId       String   @map(\"resource_id\")\n  parentResourceId String   @map(\"parent_resource_id\")\n  lastVisitTime    DateTime @default(now()) @map(\"last_visit_time\")\n\n  @@unique([userId, resourceType, resourceId])\n  @@index([userId, resourceType, parentResourceId])\n  @@map(\"user_last_visit\")\n}\n\nmodel Template {\n  id                  String    @id @default(cuid())\n  baseId              String?   @map(\"base_id\")\n  cover               String?\n  name                String?\n  description         String?\n  markdownDescription String?   @map(\"markdown_description\")\n  categoryId          String[]  @map(\"category_id\")\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  isSystem            Boolean?  @map(\"is_system\")\n  isPublished         Boolean?  @map(\"is_published\")\n  featured            Boolean?  @map(\"featured\")\n  snapshot            String?   @map(\"snapshot\")\n  order               Float     @map(\"order\")\n  usageCount          Int       @default(0) @map(\"usage_count\")\n  publishInfo         Json?     @map(\"publish_info\")\n  visitCount          Int       @default(0) @map(\"visit_count\")\n\n  @@unique([baseId])\n  @@map(\"template\")\n}\n\nmodel TemplateCategory {\n  id               String    @id @default(cuid())\n  name             String    @unique\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  order            Float     @map(\"order\")\n\n  @@map(\"template_category\")\n}\n\nmodel Task {\n  id               String    @id @default(cuid())\n  type             String    @map(\"type\")\n  status           String    @map(\"status\")\n  snapshot         String?   @map(\"snapshot\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  runs TaskRun[]\n\n  @@index([type, status])\n  @@map(\"task\")\n}\n\nmodel TaskRun {\n  id               String    @id @default(cuid())\n  taskId           String    @map(\"task_id\")\n  status           String    @map(\"status\")\n  snapshot         String    @map(\"snapshot\")\n  spent            Int?      @map(\"spent\")\n  log              String?   @map(\"log\")\n  errorMsg         String?   @map(\"error_msg\")\n  startedTime      DateTime? @map(\"started_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@index([taskId, status])\n  @@map(\"task_run\")\n}\n\nmodel TaskReference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"task_reference\")\n}\n\nmodel Waitlist {\n  email       String    @unique @map(\"email\")\n  invite      Boolean?  @map(\"invite\")\n  inviteTime  DateTime? @map(\"invite_time\")\n  createdTime DateTime  @default(now()) @map(\"created_time\")\n\n  @@map(\"waitlist\")\n}\n\n\nmodel BaseNode {\n  id                String     @id @default(cuid())\n  parentId          String?    @map(\"parent_id\")\n  baseId            String     @map(\"base_id\")\n  resourceType      String     @map(\"resource_type\")\n  resourceId        String     @map(\"resource_id\")\n  order             Float      @map(\"order\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  parent            BaseNode?  @relation(\"NodeToChildren\", fields: [parentId], references: [id])\n  children          BaseNode[] @relation(\"NodeToChildren\")\n\n  @@unique([baseId, resourceType, resourceId])\n\n  @@map(\"base_node\")\n}\n\nmodel BaseNodeFolder {\n  id                String     @id @default(cuid())\n  baseId            String     @map(\"base_id\")\n  name              String     @map(\"name\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  @@unique([baseId, name])\n\n  @@map(\"base_node_folder\")\n}\n\nmodel BaseShare {\n  id               String    @id @default(cuid())\n  baseId           String    @map(\"base_id\")\n  shareId          String    @unique @map(\"share_id\")\n  password         String?\n  nodeId           String    @unique @map(\"node_id\")\n  allowSave        Boolean?  @map(\"allow_save\")\n  allowCopy        Boolean?  @map(\"allow_copy\")\n  enabled          Boolean   @default(true)\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([baseId])\n  @@map(\"base_share\")\n}\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/seed.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { ParseArgsConfig } from 'node:util';\nimport { parseArgs } from 'node:util';\nimport type { parseDsnOrThrow } from '@httpx/dsn-parser';\nimport { parseDsn as parse } from '@httpx/dsn-parser';\nimport { PrismaClient } from '@prisma/client';\nimport { SpaceSeeds } from '../src/seeds/e2e/space-seeds';\nimport { UserSeeds } from '../src/seeds/e2e/user-seeds';\n\nexport type IDsn = ReturnType<typeof parseDsnOrThrow>;\n\nexport function parseDsn(dsn: string): IDsn {\n  const parsedDsn = parse(dsn);\n  if (dsn.startsWith('file:')) {\n    return {\n      host: 'localhost',\n      driver: 'sqlite3',\n    };\n  }\n\n  if (!parsedDsn.success) {\n    throw new Error(`DATABASE_URL ${parsedDsn.reason}`);\n  }\n  if (!parsedDsn.value.port) {\n    throw new Error(`DATABASE_URL must provide a port`);\n  }\n\n  return parsedDsn.value;\n}\n\nlet prisma: PrismaClient | undefined;\n\nconst options: ParseArgsConfig['options'] = {\n  e2e: { type: 'boolean', default: false },\n  log: { type: 'boolean', default: false },\n};\n\nasync function main() {\n  const {\n    values: { e2e, log },\n  } = parseArgs({ options });\n  const databaseUrl = process.env.PRISMA_DATABASE_URL!;\n  const { driver } = parseDsn(databaseUrl);\n\n  console.log('🌱         Seed E2E: ', e2e);\n  console.log('🌱      Environment: ', process.env.NODE_ENV);\n  console.log('🌱     Database Url: ', databaseUrl);\n  console.log('🌱  Database Driver: ', driver);\n\n  prisma = new PrismaClient();\n\n  if (e2e) {\n    const userSeeds = new UserSeeds(prisma, driver as any, Boolean(log));\n    await userSeeds.execute();\n\n    const spaceSeeds = new SpaceSeeds(prisma, driver as any, Boolean(log));\n    await spaceSeeds.execute();\n  }\n}\n\nmain()\n  .catch((e) => {\n    console.error(e);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma?.$disconnect();\n  });\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240308114656_initial_database/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"space\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"deleted_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"base\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"space_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"icon\" TEXT,\n    \"schema_pass\" TEXT,\n    \"deleted_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" DATETIME,\n    CONSTRAINT \"base_space_id_fkey\" FOREIGN KEY (\"space_id\") REFERENCES \"space\" (\"id\") ON DELETE RESTRICT ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"table_meta\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"icon\" TEXT,\n    \"db_table_name\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"table_meta_base_id_fkey\" FOREIGN KEY (\"base_id\") REFERENCES \"base\" (\"id\") ON DELETE RESTRICT ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"field\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"options\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"cell_value_type\" TEXT NOT NULL,\n    \"is_multiple_cell_value\" BOOLEAN,\n    \"db_field_type\" TEXT NOT NULL,\n    \"db_field_name\" TEXT NOT NULL,\n    \"not_null\" BOOLEAN,\n    \"unique\" BOOLEAN,\n    \"is_primary\" BOOLEAN,\n    \"is_computed\" BOOLEAN,\n    \"is_lookup\" BOOLEAN,\n    \"is_pending\" BOOLEAN,\n    \"has_error\" BOOLEAN,\n    \"lookup_linked_field_id\" TEXT,\n    \"lookup_options\" TEXT,\n    \"table_id\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"field_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE RESTRICT ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"view\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"table_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"sort\" TEXT,\n    \"filter\" TEXT,\n    \"group\" TEXT,\n    \"options\" TEXT,\n    \"order\" REAL NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"column_meta\" TEXT NOT NULL,\n    \"enable_share\" BOOLEAN,\n    \"share_id\" TEXT,\n    \"share_meta\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"view_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE RESTRICT ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"ops\" (\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"operation\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"snapshots\" (\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"data\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"reference\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"from_field_id\" TEXT NOT NULL,\n    \"to_field_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"users\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"password\" TEXT,\n    \"salt\" TEXT,\n    \"phone\" TEXT,\n    \"email\" TEXT NOT NULL,\n    \"avatar\" TEXT,\n    \"notify_meta\" TEXT,\n    \"last_sign_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"deleted_time\" DATETIME,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"account\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"provider_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    CONSTRAINT \"account_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"attachments\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"bucket\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"hash\" TEXT NOT NULL,\n    \"size\" INTEGER NOT NULL,\n    \"mimetype\" TEXT NOT NULL,\n    \"path\" TEXT NOT NULL,\n    \"width\" INTEGER,\n    \"height\" INTEGER,\n    \"deleted_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"attachments_table\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"attachment_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"field_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"workflow_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"deployment_status\" TEXT NOT NULL DEFAULT 'undeployed',\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_trigger\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"trigger_id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"trigger_type\" TEXT,\n    \"input_expressions\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_action\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"action_id\" TEXT NOT NULL,\n    \"workflow_id\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"action_type\" TEXT,\n    \"input_expressions\" TEXT,\n    \"next_node_id\" TEXT,\n    \"parent_node_id\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"automation_workflow_execution_history\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"workflow_id\" TEXT NOT NULL,\n    \"execution_type\" TEXT NOT NULL,\n    \"execution_result\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"collaborator\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"role_name\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"user_id\" TEXT NOT NULL,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"invitation\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"role\" TEXT NOT NULL,\n    \"invitation_code\" TEXT NOT NULL,\n    \"expired_time\" DATETIME,\n    \"create_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    \"deleted_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"invitation_record\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"invitation_id\" TEXT NOT NULL,\n    \"base_id\" TEXT,\n    \"space_id\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"inviter\" TEXT NOT NULL,\n    \"accepter\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"notification\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"from_user\" TEXT,\n    \"to_user\" TEXT,\n    \"from_user_id\" TEXT NOT NULL,\n    \"to_user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"message\" TEXT NOT NULL,\n    \"url_meta\" TEXT,\n    \"is_read\" BOOLEAN NOT NULL DEFAULT false,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"access_token\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"user_id\" TEXT NOT NULL,\n    \"scopes\" TEXT NOT NULL,\n    \"space_ids\" TEXT,\n    \"base_ids\" TEXT,\n    \"sign\" TEXT NOT NULL,\n    \"expired_time\" DATETIME NOT NULL,\n    \"last_used_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateIndex\nCREATE INDEX \"base_order_idx\" ON \"base\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"table_meta_order_idx\" ON \"table_meta\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"field_lookup_linked_field_id_idx\" ON \"field\"(\"lookup_linked_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"view_order_idx\" ON \"view\"(\"order\");\n\n-- CreateIndex\nCREATE INDEX \"ops_collection_created_time_idx\" ON \"ops\"(\"collection\", \"created_time\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ops_collection_doc_id_version_key\" ON \"ops\"(\"collection\", \"doc_id\", \"version\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"snapshots_collection_doc_id_key\" ON \"snapshots\"(\"collection\", \"doc_id\");\n\n-- CreateIndex\nCREATE INDEX \"reference_from_field_id_idx\" ON \"reference\"(\"from_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"reference_to_field_id_idx\" ON \"reference\"(\"to_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"reference_to_field_id_from_field_id_key\" ON \"reference\"(\"to_field_id\", \"from_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"users_phone_key\" ON \"users\"(\"phone\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"users_email_key\" ON \"users\"(\"email\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_provider_provider_id_key\" ON \"account\"(\"provider\", \"provider_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"attachments_token_key\" ON \"attachments\"(\"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_workflow_id_key\" ON \"automation_workflow\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_trigger_trigger_id_key\" ON \"automation_workflow_trigger\"(\"trigger_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_trigger_workflow_id_idx\" ON \"automation_workflow_trigger\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"automation_workflow_action_action_id_key\" ON \"automation_workflow_action\"(\"action_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_action_workflow_id_idx\" ON \"automation_workflow_action\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE INDEX \"automation_workflow_execution_history_workflow_id_idx\" ON \"automation_workflow_execution_history\"(\"workflow_id\");\n\n-- CreateIndex\nCREATE INDEX \"notification_to_user_id_is_read_created_time_idx\" ON \"notification\"(\"to_user_id\", \"is_read\", \"created_time\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240313061543_add_credit/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"space\" ADD COLUMN \"credit\" INTEGER;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240409081445_field_order/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Added the required column `order` to the `field` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_field\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"options\" TEXT,\n    \"type\" TEXT NOT NULL,\n    \"cell_value_type\" TEXT NOT NULL,\n    \"is_multiple_cell_value\" BOOLEAN,\n    \"db_field_type\" TEXT NOT NULL,\n    \"db_field_name\" TEXT NOT NULL,\n    \"not_null\" BOOLEAN,\n    \"unique\" BOOLEAN,\n    \"is_primary\" BOOLEAN,\n    \"is_computed\" BOOLEAN,\n    \"is_lookup\" BOOLEAN,\n    \"is_pending\" BOOLEAN,\n    \"has_error\" BOOLEAN,\n    \"lookup_linked_field_id\" TEXT,\n    \"lookup_options\" TEXT,\n    \"table_id\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"deleted_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"field_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE RESTRICT ON UPDATE CASCADE\n);\nINSERT INTO \"new_field\" (\"cell_value_type\", \"created_by\", \"created_time\", \"db_field_name\", \"db_field_type\", \"deleted_time\", \"description\", \"has_error\", \"id\", \"is_computed\", \"is_lookup\", \"is_multiple_cell_value\", \"is_pending\", \"is_primary\", \"last_modified_by\", \"last_modified_time\", \"lookup_linked_field_id\", \"lookup_options\", \"name\", \"not_null\", \"options\", \"table_id\", \"type\", \"unique\", \"version\") SELECT \"cell_value_type\", \"created_by\", \"created_time\", \"db_field_name\", \"db_field_type\", \"deleted_time\", \"description\", \"has_error\", \"id\", \"is_computed\", \"is_lookup\", \"is_multiple_cell_value\", \"is_pending\", \"is_primary\", \"last_modified_by\", \"last_modified_time\", \"lookup_linked_field_id\", \"lookup_options\", \"name\", \"not_null\", \"options\", \"table_id\", \"type\", \"unique\", \"version\" FROM \"field\";\nDROP TABLE \"field\";\nALTER TABLE \"new_field\" RENAME TO \"field\";\nCREATE INDEX \"field_lookup_linked_field_id_idx\" ON \"field\"(\"lookup_linked_field_id\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240416091909_clean_useless_tables/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the `automation_workflow` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_action` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_execution_history` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `automation_workflow_trigger` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropTable\nPRAGMA foreign_keys=off;\nDROP TABLE \"automation_workflow\";\nPRAGMA foreign_keys=on;\n\n-- DropTable\nPRAGMA foreign_keys=off;\nDROP TABLE \"automation_workflow_action\";\nPRAGMA foreign_keys=on;\n\n-- DropTable\nPRAGMA foreign_keys=off;\nDROP TABLE \"automation_workflow_execution_history\";\nPRAGMA foreign_keys=on;\n\n-- DropTable\nPRAGMA foreign_keys=off;\nDROP TABLE \"automation_workflow_trigger\";\nPRAGMA foreign_keys=on;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240528055850_add_pin_resource/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"pin_resource\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"pin_resource_created_by_resource_id_key\" ON \"pin_resource\"(\"created_by\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240528060824_add_pin_resource/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Added the required column `order` to the `pin_resource` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_pin_resource\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL\n);\nINSERT INTO \"new_pin_resource\" (\"created_by\", \"created_time\", \"id\", \"resource_id\", \"type\") SELECT \"created_by\", \"created_time\", \"id\", \"resource_id\", \"type\" FROM \"pin_resource\";\nDROP TABLE \"pin_resource\";\nALTER TABLE \"new_pin_resource\" RENAME TO \"pin_resource\";\nCREATE INDEX \"pin_resource_order_idx\" ON \"pin_resource\"(\"order\");\nCREATE UNIQUE INDEX \"pin_resource_created_by_resource_id_key\" ON \"pin_resource\"(\"created_by\", \"resource_id\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240625031955_add_admin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"deactivated_time\" DATETIME;\nALTER TABLE \"users\" ADD COLUMN \"is_admin\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240626072703_add_setting_table/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"setting\" (\n    \"instance_id\" TEXT NOT NULL,\n    \"disallow_sign_up\" BOOLEAN,\n    \"disallow_space_creation\" BOOLEAN,\n\n    CONSTRAINT \"setting_pkey\" PRIMARY KEY (\"instance_id\")\n);\n\n-- Insert initial record\nINSERT INTO \"setting\" (\"instance_id\", \"disallow_sign_up\", \"disallow_space_creation\") VALUES (\"instance-id\", NULL, NULL);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240628115107_add_space_invitation/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN \"disallow_space_invitation\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240702084255_add_oauth/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"access_token\" ADD COLUMN \"is_oauth\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"oauth_app\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"homepage\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"client_id\" TEXT NOT NULL,\n    \"redirect_uris\" TEXT,\n    \"scopes\" TEXT,\n    \"is_extension\" BOOLEAN,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_authorized\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"client_id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"authorized_time\" DATETIME NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_secret\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"app_id\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_used_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"oauth_app_token\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"app_secret_id\" TEXT NOT NULL,\n    \"refresh_token_sign\" TEXT NOT NULL,\n    \"expired_time\" DATETIME NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_client_id_key\" ON \"oauth_app\"(\"client_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_authorized_client_id_user_id_key\" ON \"oauth_app_authorized\"(\"client_id\", \"user_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_secret_secret_key\" ON \"oauth_app_secret\"(\"secret\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"oauth_app_token_refresh_token_sign_key\" ON \"oauth_app_token\"(\"refresh_token_sign\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240708080010_oauth_revoke/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `app_id` on the `oauth_app_secret` table. All the data in the column will be lost.\n  - You are about to drop the column `is_oauth` on the `access_token` table. All the data in the column will be lost.\n  - You are about to drop the column `is_extension` on the `oauth_app` table. All the data in the column will be lost.\n  - Added the required column `client_id` to the `oauth_app_secret` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_oauth_app_secret\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"client_id\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_used_time\" DATETIME\n);\nINSERT INTO \"new_oauth_app_secret\" (\"created_by\", \"created_time\", \"id\", \"last_used_time\", \"masked_secret\", \"secret\") SELECT \"created_by\", \"created_time\", \"id\", \"last_used_time\", \"masked_secret\", \"secret\" FROM \"oauth_app_secret\";\nDROP TABLE \"oauth_app_secret\";\nALTER TABLE \"new_oauth_app_secret\" RENAME TO \"oauth_app_secret\";\nCREATE UNIQUE INDEX \"oauth_app_secret_secret_key\" ON \"oauth_app_secret\"(\"secret\");\nCREATE TABLE \"new_access_token\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"user_id\" TEXT NOT NULL,\n    \"scopes\" TEXT NOT NULL,\n    \"space_ids\" TEXT,\n    \"base_ids\" TEXT,\n    \"sign\" TEXT NOT NULL,\n    \"client_id\" TEXT,\n    \"expired_time\" DATETIME NOT NULL,\n    \"last_used_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME\n);\nINSERT INTO \"new_access_token\" (\"base_ids\", \"created_time\", \"description\", \"expired_time\", \"id\", \"last_modified_time\", \"last_used_time\", \"name\", \"scopes\", \"sign\", \"space_ids\", \"user_id\") SELECT \"base_ids\", \"created_time\", \"description\", \"expired_time\", \"id\", \"last_modified_time\", \"last_used_time\", \"name\", \"scopes\", \"sign\", \"space_ids\", \"user_id\" FROM \"access_token\";\nDROP TABLE \"access_token\";\nALTER TABLE \"new_access_token\" RENAME TO \"access_token\";\nCREATE TABLE \"new_oauth_app\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"homepage\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"client_id\" TEXT NOT NULL,\n    \"redirect_uris\" TEXT,\n    \"scopes\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL\n);\nINSERT INTO \"new_oauth_app\" (\"client_id\", \"created_by\", \"created_time\", \"description\", \"homepage\", \"id\", \"last_modified_time\", \"logo\", \"name\", \"redirect_uris\", \"scopes\") SELECT \"client_id\", \"created_by\", \"created_time\", \"description\", \"homepage\", \"id\", \"last_modified_time\", \"logo\", \"name\", \"redirect_uris\", \"scopes\" FROM \"oauth_app\";\nDROP TABLE \"oauth_app\";\nALTER TABLE \"new_oauth_app\" RENAME TO \"oauth_app\";\nCREATE UNIQUE INDEX \"oauth_app_client_id_key\" ON \"oauth_app\"(\"client_id\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240712040040_remove_bucket/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `bucket` on the `attachments` table. All the data in the column will be lost.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_attachments\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"token\" TEXT NOT NULL,\n    \"hash\" TEXT NOT NULL,\n    \"size\" INTEGER NOT NULL,\n    \"mimetype\" TEXT NOT NULL,\n    \"path\" TEXT NOT NULL,\n    \"width\" INTEGER,\n    \"height\" INTEGER,\n    \"deleted_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\nINSERT INTO \"new_attachments\" (\"created_by\", \"created_time\", \"deleted_time\", \"hash\", \"height\", \"id\", \"last_modified_by\", \"mimetype\", \"path\", \"size\", \"token\", \"width\") SELECT \"created_by\", \"created_time\", \"deleted_time\", \"hash\", \"height\", \"id\", \"last_modified_by\", \"mimetype\", \"path\", \"size\", \"token\", \"width\" FROM \"attachments\";\nDROP TABLE \"attachments\";\nALTER TABLE \"new_attachments\" RENAME TO \"attachments\";\nCREATE UNIQUE INDEX \"attachments_token_key\" ON \"attachments\"(\"token\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240716070608_notification_url_path/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `from_user` on the `notification` table. All the data in the column will be lost.\n  - You are about to drop the column `to_user` on the `notification` table. All the data in the column will be lost.\n  - You are about to drop the column `url_meta` on the `notification` table. All the data in the column will be lost.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_notification\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"from_user_id\" TEXT NOT NULL,\n    \"to_user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"message\" TEXT NOT NULL,\n    \"url_path\" TEXT,\n    \"is_read\" BOOLEAN NOT NULL DEFAULT false,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\nINSERT INTO \"new_notification\" (\"created_by\", \"created_time\", \"from_user_id\", \"id\", \"is_read\", \"message\", \"to_user_id\", \"type\") SELECT \"created_by\", \"created_time\", \"from_user_id\", \"id\", \"is_read\", \"message\", \"to_user_id\", \"type\" FROM \"notification\";\nDROP TABLE \"notification\";\nALTER TABLE \"new_notification\" RENAME TO \"notification\";\nCREATE INDEX \"notification_to_user_id_is_read_created_time_idx\" ON \"notification\"(\"to_user_id\", \"is_read\", \"created_time\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240806110404_add_record_history/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"record_history\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"field_id\" TEXT NOT NULL,\n    \"before\" TEXT NOT NULL,\n    \"after\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateIndex\nCREATE INDEX \"record_history_table_id_record_id_created_time_idx\" ON \"record_history\"(\"table_id\", \"record_id\", \"created_time\");\n\n-- CreateIndex\nCREATE INDEX \"record_history_table_id_created_time_idx\" ON \"record_history\"(\"table_id\", \"created_time\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240814074632_update_collaborator/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `base_id` on the `collaborator` table. All the data in the column will be lost.\n  - You are about to drop the column `space_id` on the `collaborator` table. All the data in the column will be lost.\n  - Added the required column `resource_id` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `resource_type` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_collaborator\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"role_name\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"collaborator_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_collaborator\" (\"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"role_name\", \"user_id\", \"resource_id\", \"resource_type\") SELECT \"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"role_name\", \"user_id\", \"space_id\", 'space' FROM \"collaborator\" WHERE \"deleted_time\" IS NULL;\nDROP TABLE \"collaborator\";\nALTER TABLE \"new_collaborator\" RENAME TO \"collaborator\";\nCREATE UNIQUE INDEX \"collaborator_resource_type_resource_id_user_id_key\" ON \"collaborator\"(\"resource_type\", \"resource_id\", \"user_id\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240906084521_add_trash/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"trash\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_id\" TEXT,\n    \"deleted_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"deleted_by\" TEXT NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"trash_resource_type_resource_id_key\" ON \"trash\"(\"resource_type\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240913075658_add_dashboard_plugin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"is_system\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"plugin\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"detail_desc\" TEXT,\n    \"logo\" TEXT NOT NULL,\n    \"help_url\" TEXT,\n    \"status\" TEXT NOT NULL,\n    \"positions\" TEXT NOT NULL,\n    \"url\" TEXT,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"i18n\" TEXT,\n    \"plugin_user\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateTable\nCREATE TABLE \"plugin_install\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"plugin_id\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"position_id\" TEXT NOT NULL,\n    \"position\" TEXT NOT NULL,\n    \"storage\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"plugin_install_plugin_id_fkey\" FOREIGN KEY (\"plugin_id\") REFERENCES \"plugin\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"dashboard\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"base_id\" TEXT NOT NULL,\n    \"layout\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"plugin_secret_key\" ON \"plugin\"(\"secret\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20240919032621_add_comment/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"comment\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"quote_Id\" TEXT,\n    \"content\" TEXT,\n    \"reaction\" TEXT,\n    \"deleted_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateTable\nCREATE TABLE \"comment_subscription\" (\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE INDEX \"comment_table_id_record_id_idx\" ON \"comment\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"comment_subscription_table_id_record_id_idx\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"comment_subscription_table_id_record_id_key\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241031080903_add_attachment_thumbnail/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"attachments\" ADD COLUMN \"thumbnail_path\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241126081006_add_ref_meta/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"ref_meta\" TEXT;\n\n-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'automationRobot',\n  'Automation Robot',\n  'automationRobot@system.teable.ai',\n  1,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'automationRobot');\n\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'anonymous',\n  'Anonymous',\n  'anonymous@system.teable.ai',\n  1,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'anonymous');\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241128112016_add_ai_config/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN \"ai_config\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241205121154_add_table_trash/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"table_trash\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"record_trash\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241223100135_collaborator_support_org/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `user_id` on the `collaborator` table. All the data in the column will be lost.\n  - Added the required column `principal_id` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `principal_type` to the `collaborator` table without a default value. This is not possible if the table is not empty.\n\n*/\nBEGIN;\n\n-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_collaborator\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"role_name\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"principal_id\" TEXT NOT NULL,\n    \"principal_type\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"collaborator_principal_id_fkey\" FOREIGN KEY (\"principal_id\") REFERENCES \"users\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_collaborator\" (\"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"resource_id\", \"resource_type\", \"role_name\", \"principal_id\", \"principal_type\") SELECT \"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"resource_id\", \"resource_type\", \"role_name\", \"user_id\", 'user' FROM \"collaborator\";\nDROP TABLE \"collaborator\";\nALTER TABLE \"new_collaborator\" RENAME TO \"collaborator\";\nCREATE UNIQUE INDEX \"collaborator_resource_type_resource_id_principal_id_principal_type_key\" ON \"collaborator\"(\"resource_type\", \"resource_id\", \"principal_id\", \"principal_type\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20241226111815_remove_collaborator_foreign_user/migration.sql",
    "content": "-- RedefineTables\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_collaborator\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"role_name\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"principal_id\" TEXT NOT NULL,\n    \"principal_type\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT\n);\nINSERT INTO \"new_collaborator\" (\"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"principal_id\", \"principal_type\", \"resource_id\", \"resource_type\", \"role_name\") SELECT \"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"principal_id\", \"principal_type\", \"resource_id\", \"resource_type\", \"role_name\" FROM \"collaborator\";\nDROP TABLE \"collaborator\";\nALTER TABLE \"new_collaborator\" RENAME TO \"collaborator\";\nCREATE UNIQUE INDEX \"collaborator_resource_type_resource_id_principal_id_principal_type_key\" ON \"collaborator\"(\"resource_type\", \"resource_id\", \"principal_id\", \"principal_type\");\nPRAGMA foreign_key_check;\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250115084207_add_enable_email_verification_setting/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN \"enable_email_verification\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250117105406_update_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"view\" ADD COLUMN \"is_locked\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250214080102_add_integration/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"integration\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"resource_id\" TEXT NOT NULL,\n    \"config\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"enable\" BOOLEAN,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"integration_resource_id_key\" ON \"integration\"(\"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250217092948_add_table_plugin/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"plugin\" ADD COLUMN \"config\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"plugin_panel\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"layout\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT\n);\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250218075455_add_plugin_context_menu/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"plugin_context_menu\" (\n    \"table_id\" TEXT NOT NULL,\n    \"plugin_install_id\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"plugin_context_menu_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\n-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_plugin_panel\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"layout\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"plugin_panel_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_plugin_panel\" (\"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"layout\", \"name\", \"table_id\") SELECT \"created_by\", \"created_time\", \"id\", \"last_modified_by\", \"last_modified_time\", \"layout\", \"name\", \"table_id\" FROM \"plugin_panel\";\nDROP TABLE \"plugin_panel\";\nALTER TABLE \"new_plugin_panel\" RENAME TO \"plugin_panel\";\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"plugin_context_menu_plugin_install_id_key\" ON \"plugin_context_menu\"(\"plugin_install_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250320062213_user_last_visit/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"user_last_visit\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"user_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_resource_id\" TEXT NOT NULL,\n    \"last_visit_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_user_id_resource_type_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250328040207_brand/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"setting\" ADD COLUMN \"brand_logo\" TEXT;\nALTER TABLE \"setting\" ADD COLUMN \"brand_name\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250402105138_add_template/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"space\" ADD COLUMN \"is_template\" BOOLEAN;\n\n-- CreateTable\nCREATE TABLE \"template\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT,\n    \"cover\" TEXT,\n    \"name\" TEXT,\n    \"description\" TEXT,\n    \"category_id\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    \"is_system\" BOOLEAN,\n    \"is_published\" BOOLEAN,\n    \"snapshot\" TEXT,\n    \"order\" REAL NOT NULL,\n    \"usage_count\" INTEGER NOT NULL DEFAULT 0\n);\n\n-- CreateTable\nCREATE TABLE \"template_category\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    \"order\" REAL NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"template_category_name_key\" ON \"template_category\"(\"name\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250406145126_add_share_id_unique/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[share_id]` on the table `view` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"view_share_id_key\" ON \"view\"(\"share_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250409093334_add_task_tables/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"field\" ADD COLUMN \"ai_config\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"task\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"type\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"snapshot\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateTable\nCREATE TABLE \"task_run\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"task_id\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"snapshot\" TEXT NOT NULL,\n    \"spent\" INTEGER,\n    \"error_msg\" TEXT,\n    \"started_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    CONSTRAINT \"task_run_task_id_fkey\" FOREIGN KEY (\"task_id\") REFERENCES \"task\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"task_reference\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"from_field_id\" TEXT NOT NULL,\n    \"to_field_id\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE INDEX \"task_type_status_idx\" ON \"task\"(\"type\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"task_run_task_id_status_idx\" ON \"task_run\"(\"task_id\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"task_reference_from_field_id_idx\" ON \"task_reference\"(\"from_field_id\");\n\n-- CreateIndex\nCREATE INDEX \"task_reference_to_field_id_idx\" ON \"task_reference\"(\"to_field_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"task_reference_to_field_id_from_field_id_key\" ON \"task_reference\"(\"to_field_id\", \"from_field_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250410102938_update_task_table/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Added the required column `created_by` to the `task` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_task\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"type\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"snapshot\" TEXT,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_time\" DATETIME,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_by\" TEXT\n);\nINSERT INTO \"new_task\" (\"created_time\", \"id\", \"snapshot\", \"status\", \"type\") SELECT \"created_time\", \"id\", \"snapshot\", \"status\", \"type\" FROM \"task\";\nDROP TABLE \"task\";\nALTER TABLE \"new_task\" RENAME TO \"task\";\nCREATE INDEX \"task_type_status_idx\" ON \"task\"(\"type\", \"status\");\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250416113234_add_template_markdown_description/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"template\" ADD COLUMN \"markdown_description\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250418091633_add_db_table_name_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"table_meta_db_table_name_idx\" ON \"table_meta\"(\"db_table_name\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250509062710_require_primary_key/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the `snapshots` table. If the table is not empty, all the data it contains will be lost.\n  - The required column `id` was added to the `comment_subscription` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.\n  - The required column `id` was added to the `ops` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.\n  - The required column `id` was added to the `plugin_context_menu` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.\n\n*/\n-- DropIndex\nDROP INDEX \"snapshots_collection_doc_id_key\";\n\n-- DropTable\nPRAGMA foreign_keys=off;\nDROP TABLE \"snapshots\";\nPRAGMA foreign_keys=on;\n\n-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_comment_subscription\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\nINSERT INTO \"new_comment_subscription\" (\"created_by\", \"created_time\", \"record_id\", \"table_id\") SELECT \"created_by\", \"created_time\", \"record_id\", \"table_id\" FROM \"comment_subscription\";\nDROP TABLE \"comment_subscription\";\nALTER TABLE \"new_comment_subscription\" RENAME TO \"comment_subscription\";\nCREATE INDEX \"comment_subscription_table_id_record_id_idx\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\nCREATE UNIQUE INDEX \"comment_subscription_table_id_record_id_key\" ON \"comment_subscription\"(\"table_id\", \"record_id\");\nCREATE TABLE \"new_ops\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"operation\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\nINSERT INTO \"new_ops\" (\"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"operation\", \"version\") SELECT \"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"operation\", \"version\" FROM \"ops\";\nDROP TABLE \"ops\";\nALTER TABLE \"new_ops\" RENAME TO \"ops\";\nCREATE INDEX \"ops_collection_created_time_idx\" ON \"ops\"(\"collection\", \"created_time\");\nCREATE UNIQUE INDEX \"ops_collection_doc_id_version_key\" ON \"ops\"(\"collection\", \"doc_id\", \"version\");\nCREATE TABLE \"new_plugin_context_menu\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"table_id\" TEXT NOT NULL,\n    \"plugin_install_id\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"plugin_context_menu_table_id_fkey\" FOREIGN KEY (\"table_id\") REFERENCES \"table_meta\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_plugin_context_menu\" (\"created_by\", \"created_time\", \"last_modified_by\", \"last_modified_time\", \"order\", \"plugin_install_id\", \"table_id\") SELECT \"created_by\", \"created_time\", \"last_modified_by\", \"last_modified_time\", \"order\", \"plugin_install_id\", \"table_id\" FROM \"plugin_context_menu\";\nDROP TABLE \"plugin_context_menu\";\nALTER TABLE \"new_plugin_context_menu\" RENAME TO \"plugin_context_menu\";\nCREATE UNIQUE INDEX \"plugin_context_menu_plugin_install_id_key\" ON \"plugin_context_menu\"(\"plugin_install_id\");\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250513085303_add_ai_robot_user/migration.sql",
    "content": "-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'aiRobot',\n  'AI Robot',\n  'aiRobot@system.teable.ai',\n  1,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'aiRobot');\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250520081750_update_user_last_visit/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_key\";\n\n-- CreateIndex\nCREATE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\n\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250520103541_add_user_trial_used/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"is_trial_used\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250526042154_repair_reference_caused_by_formula_duplicate/migration.sql",
    "content": "-- Repair reference\nINSERT INTO reference (id, to_field_id, from_field_id)\nSELECT 'cmb' || \n       CASE \n         WHEN (abs(random()) % 2) = 0 \n         THEN substr(hex(randomblob(11)), 1, 22)\n         ELSE substr(lower(hex(randomblob(11))), 1, 22)\n       END                                                             as id,\n       f.id                                                            as to_field_id,\n       substr(json_extract(f.options, '$.expression'), \n              instr(json_extract(f.options, '$.expression'), '{') + 1,\n              instr(json_extract(f.options, '$.expression'), '}') - instr(json_extract(f.options, '$.expression'), '{') - 1\n       )                                                               as from_field_id\nFROM field f\nWHERE f.type = 'formula'\n  AND f.deleted_time is null\n  AND json_extract(f.options, '$.expression') IS NOT NULL\n  AND json_extract(f.options, '$.expression') LIKE '%{%}%'\n  AND f.created_time >= strftime('%s', '2025-04-05') * 1000\nON CONFLICT (to_field_id, from_field_id) DO NOTHING;"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250604101438_update_access_token_full_access/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"access_token\" ADD COLUMN     \"has_full_access\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250702035214_update_setting/migration.sql",
    "content": "BEGIN;\n\n-- Create new table with the desired structure\nCREATE TABLE \"setting_new\" (\n    \"name\" TEXT NOT NULL,\n    \"content\" TEXT,\n    \"created_by\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"last_modified_by\" TEXT,\n    \"last_modified_time\" DATETIME\n);\n\n-- Create unique index on name\nCREATE UNIQUE INDEX \"setting_name_key\" ON \"setting_new\"(\"name\");\n\n-- Drop old table\nDROP TABLE IF EXISTS \"setting\";   \n\n-- Rename new table to original name\nALTER TABLE \"setting_new\" RENAME TO \"setting\";\n\nCOMMIT;\n\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250730041646_add_user_permanent_deleted_time/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN  \"permanent_deleted_time\" DATETIME;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250804000000_add_field_meta/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"field\" ADD COLUMN     \"meta\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250811102551_add_visit_count/migration.sql",
    "content": "-- RedefineTables\nCREATE TABLE \"new_user_last_visit\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"user_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_resource_id\" TEXT NOT NULL,\n    \"last_visit_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"visit_count\" INTEGER NOT NULL DEFAULT 1\n);\nINSERT INTO \"new_user_last_visit\" (\"id\", \"last_visit_time\", \"parent_resource_id\", \"resource_id\", \"resource_type\", \"user_id\") SELECT \"id\", \"last_visit_time\", \"parent_resource_id\", \"resource_id\", \"resource_type\", \"user_id\" FROM \"user_last_visit\";\nDROP TABLE \"user_last_visit\";\nALTER TABLE \"new_user_last_visit\" RENAME TO \"user_last_visit\";\nCREATE INDEX \"user_last_visit_user_id_resource_type_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\");\nCREATE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\nCREATE INDEX \"user_last_visit_resource_type_resource_id_idx\" ON \"user_last_visit\"(\"resource_type\", \"resource_id\");\nCREATE INDEX \"user_last_visit_resource_type_last_visit_time_idx\" ON \"user_last_visit\"(\"resource_type\", \"last_visit_time\");\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250812031012_remove_user_last_visit_useless_index/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"user_last_visit_resource_type_resource_id_idx\";\n\n-- DropIndex\nDROP INDEX \"user_last_visit_user_id_resource_type_idx\";\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250812090823_remove_visit_count/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `visit_count` on the `user_last_visit` table. All the data in the column will be lost.\n\n*/\n-- RedefineTables\nCREATE TABLE \"new_user_last_visit\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"user_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"parent_resource_id\" TEXT NOT NULL,\n    \"last_visit_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\nINSERT INTO \"new_user_last_visit\" (\"id\", \"last_visit_time\", \"parent_resource_id\", \"resource_id\", \"resource_type\", \"user_id\") SELECT \"id\", \"last_visit_time\", \"parent_resource_id\", \"resource_id\", \"resource_type\", \"user_id\" FROM \"user_last_visit\";\nDROP TABLE \"user_last_visit\";\nALTER TABLE \"new_user_last_visit\" RENAME TO \"user_last_visit\";\nCREATE INDEX \"user_last_visit_user_id_resource_type_parent_resource_id_idx\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"parent_resource_id\");\nCREATE UNIQUE INDEX \"user_last_visit_user_id_resource_type_resource_id_key\" ON \"user_last_visit\"(\"user_id\", \"resource_type\", \"resource_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250820022401_add_waitlist/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"waitlist\" (\n    \"email\" TEXT NOT NULL,\n    \"invite\" BOOLEAN,\n    \"invite_time\" DATETIME,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"waitlist_email_key\" ON \"waitlist\"(\"email\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250828083309_add_app_robot_user/migration.sql",
    "content": "-- InsertUsers\nINSERT INTO \"users\" (\n  \"id\",\n  \"name\",\n  \"email\",\n  \"is_system\",\n  \"created_time\"\n) \nSELECT \n  'appRobot',\n  'App Robot',\n  'appRobot@system.teable.ai',\n  1,\n  CURRENT_TIMESTAMP\nWHERE NOT EXISTS (SELECT 1 FROM \"users\" WHERE \"id\" = 'appRobot');\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250904034946_add_table_meta_db_view_name/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"table_meta\" ADD COLUMN \"db_view_name\" TEXT;\n\n-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_ops\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"operation\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\nINSERT INTO \"new_ops\" (\"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"id\", \"operation\", \"version\") SELECT \"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"id\", \"operation\", \"version\" FROM \"ops\";\nDROP TABLE \"ops\";\nALTER TABLE \"new_ops\" RENAME TO \"ops\";\nCREATE INDEX \"ops_collection_created_time_idx\" ON \"ops\"(\"collection\", \"created_time\");\nCREATE UNIQUE INDEX \"ops_collection_doc_id_version_key\" ON \"ops\"(\"collection\", \"doc_id\", \"version\");\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250905035730_add_trash_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"record_trash_table_id_record_id_idx\" ON \"record_trash\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"table_trash_table_id_idx\" ON \"table_trash\"(\"table_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250922111616_add_indexes/migration.sql",
    "content": "-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_ops\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"collection\" TEXT NOT NULL,\n    \"doc_id\" TEXT NOT NULL,\n    \"doc_type\" TEXT NOT NULL,\n    \"version\" INTEGER NOT NULL,\n    \"operation\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL\n);\nINSERT INTO \"new_ops\" (\"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"id\", \"operation\", \"version\") SELECT \"collection\", \"created_by\", \"created_time\", \"doc_id\", \"doc_type\", \"id\", \"operation\", \"version\" FROM \"ops\";\nDROP TABLE \"ops\";\nALTER TABLE \"new_ops\" RENAME TO \"ops\";\nCREATE INDEX \"ops_collection_created_time_idx\" ON \"ops\"(\"collection\", \"created_time\");\nCREATE UNIQUE INDEX \"ops_collection_doc_id_version_key\" ON \"ops\"(\"collection\", \"doc_id\", \"version\");\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n\n-- CreateIndex\nCREATE INDEX \"access_token_user_id_idx\" ON \"access_token\"(\"user_id\");\n\n-- CreateIndex\nCREATE INDEX \"access_token_client_id_idx\" ON \"access_token\"(\"client_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_table_id_record_id_idx\" ON \"attachments_table\"(\"table_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_table_id_field_id_idx\" ON \"attachments_table\"(\"table_id\", \"field_id\");\n\n-- CreateIndex\nCREATE INDEX \"attachments_table_attachment_id_idx\" ON \"attachments_table\"(\"attachment_id\");\n\n-- CreateIndex\nCREATE INDEX \"collaborator_resource_id_idx\" ON \"collaborator\"(\"resource_id\");\n\n-- CreateIndex\nCREATE INDEX \"collaborator_principal_id_idx\" ON \"collaborator\"(\"principal_id\");\n\n-- CreateIndex\nCREATE INDEX \"dashboard_base_id_idx\" ON \"dashboard\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"integration_resource_id_idx\" ON \"integration\"(\"resource_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_base_id_idx\" ON \"invitation\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_space_id_idx\" ON \"invitation\"(\"space_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_invitation_id_idx\" ON \"invitation_record\"(\"invitation_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_base_id_idx\" ON \"invitation_record\"(\"base_id\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_record_space_id_idx\" ON \"invitation_record\"(\"space_id\");\n\n-- CreateIndex\nCREATE INDEX \"plugin_install_position_id_idx\" ON \"plugin_install\"(\"position_id\");\n\n-- CreateIndex\nCREATE INDEX \"plugin_install_base_id_idx\" ON \"plugin_install\"(\"base_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20250922120000_add_conditional_lookup_flag/migration.sql",
    "content": "-- Add conditional lookup marker to field table\nALTER TABLE \"field\" ADD COLUMN \"is_conditional_lookup\" BOOLEAN;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251028105630_add_user_lang/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"lang\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251029141619_add_notification_message_i18n/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"notification\" ADD COLUMN \"message_i18n\" TEXT;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251105070757_add_oauth_foreign_keys/migration.sql",
    "content": "-- RedefineTables\nPRAGMA defer_foreign_keys=ON;\nPRAGMA foreign_keys=OFF;\nCREATE TABLE \"new_oauth_app_authorized\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"client_id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"authorized_time\" DATETIME NOT NULL,\n    CONSTRAINT \"oauth_app_authorized_client_id_fkey\" FOREIGN KEY (\"client_id\") REFERENCES \"oauth_app\" (\"client_id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_oauth_app_authorized\" (\"authorized_time\", \"client_id\", \"id\", \"user_id\") SELECT \"authorized_time\", \"client_id\", \"id\", \"user_id\" FROM \"oauth_app_authorized\";\nDROP TABLE \"oauth_app_authorized\";\nALTER TABLE \"new_oauth_app_authorized\" RENAME TO \"oauth_app_authorized\";\nCREATE UNIQUE INDEX \"oauth_app_authorized_client_id_user_id_key\" ON \"oauth_app_authorized\"(\"client_id\", \"user_id\");\nCREATE TABLE \"new_oauth_app_secret\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"client_id\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"masked_secret\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_used_time\" DATETIME,\n    CONSTRAINT \"oauth_app_secret_client_id_fkey\" FOREIGN KEY (\"client_id\") REFERENCES \"oauth_app\" (\"client_id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_oauth_app_secret\" (\"client_id\", \"created_by\", \"created_time\", \"id\", \"last_used_time\", \"masked_secret\", \"secret\") SELECT \"client_id\", \"created_by\", \"created_time\", \"id\", \"last_used_time\", \"masked_secret\", \"secret\" FROM \"oauth_app_secret\";\nDROP TABLE \"oauth_app_secret\";\nALTER TABLE \"new_oauth_app_secret\" RENAME TO \"oauth_app_secret\";\nCREATE UNIQUE INDEX \"oauth_app_secret_secret_key\" ON \"oauth_app_secret\"(\"secret\");\nCREATE TABLE \"new_oauth_app_token\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"app_secret_id\" TEXT NOT NULL,\n    \"refresh_token_sign\" TEXT NOT NULL,\n    \"expired_time\" DATETIME NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    CONSTRAINT \"oauth_app_token_app_secret_id_fkey\" FOREIGN KEY (\"app_secret_id\") REFERENCES \"oauth_app_secret\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\nINSERT INTO \"new_oauth_app_token\" (\"app_secret_id\", \"created_by\", \"created_time\", \"expired_time\", \"id\", \"refresh_token_sign\") SELECT \"app_secret_id\", \"created_by\", \"created_time\", \"expired_time\", \"id\", \"refresh_token_sign\" FROM \"oauth_app_token\";\nDROP TABLE \"oauth_app_token\";\nALTER TABLE \"new_oauth_app_token\" RENAME TO \"oauth_app_token\";\nCREATE UNIQUE INDEX \"oauth_app_token_refresh_token_sign_key\" ON \"oauth_app_token\"(\"refresh_token_sign\");\nPRAGMA foreign_keys=ON;\nPRAGMA defer_foreign_keys=OFF;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251119134053_add_base_node/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"base_node\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"parent_id\" TEXT,\n    \"base_id\" TEXT NOT NULL,\n    \"resource_type\" TEXT NOT NULL,\n    \"resource_id\" TEXT NOT NULL,\n    \"order\" REAL NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT,\n    CONSTRAINT \"base_node_parent_id_fkey\" FOREIGN KEY (\"parent_id\") REFERENCES \"base_node\" (\"id\") ON DELETE SET NULL ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"base_node_folder\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME,\n    \"last_modified_by\" TEXT\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_node_base_id_resource_type_resource_id_key\" ON \"base_node\"(\"base_id\", \"resource_type\", \"resource_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_node_folder_base_id_name_key\" ON \"base_node_folder\"(\"base_id\", \"name\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251208112242_template_community/migration.sql",
    "content": "-- This migration is intentionally empty for SQLite\n-- SQLite does not support array types, so we keep the categoryId as a single string field\n\nBEGIN;\nALTER TABLE \"template\" ADD COLUMN     \"featured\" BOOLEAN;\nALTER TABLE \"template\" ADD COLUMN     \"publish_info\" JSON;\n\n-- Remove duplicate base_id records, keep only the most recently modified one for each base_id\nDELETE FROM \"template\"\nWHERE id IN (\n  SELECT id FROM (\n    SELECT id, \n           ROW_NUMBER() OVER (\n             PARTITION BY base_id \n             ORDER BY last_modified_time DESC NULLS LAST, created_time DESC NULLS LAST\n           ) as rn\n    FROM \"template\"\n    WHERE base_id IS NOT NULL\n  ) t\n  WHERE rn > 1\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"template_base_id_key\" ON \"template\"(\"base_id\");\n\nUPDATE \"template\" SET featured = true WHERE is_published = true;\n\nCOMMIT;"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251210134101_disallow_dashboard/migration.sql",
    "content": "INSERT INTO \"setting\" (\"name\", \"content\", \"created_by\") VALUES\n('disallowDashboard', 'true', 'anonymous')\nON CONFLICT (\"name\") DO UPDATE SET \"content\" = excluded.\"content\";"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20251219083654_add_template_visit_count/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"template\" ADD COLUMN     \"visit_count\" INTEGER NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20260104151713_add_computed_update_outbox_tables/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"computed_update_outbox\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT NOT NULL,\n    \"seed_table_id\" TEXT NOT NULL,\n    \"seed_record_ids\" JSON,\n    \"change_type\" TEXT NOT NULL,\n    \"steps\" JSON NOT NULL,\n    \"edges\" JSON NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"attempts\" INTEGER NOT NULL DEFAULT 0,\n    \"max_attempts\" INTEGER NOT NULL DEFAULT 8,\n    \"next_run_at\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"locked_at\" DATETIME,\n    \"locked_by\" TEXT,\n    \"last_error\" TEXT,\n    \"estimated_complexity\" INTEGER NOT NULL DEFAULT 0,\n    \"plan_hash\" TEXT NOT NULL,\n    \"dirty_stats\" JSON,\n    \"affected_table_ids\" JSON,\n    \"affected_field_ids\" JSON,\n    \"sync_max_level\" INTEGER,\n    \"created_at\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" DATETIME NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"computed_update_outbox_seed\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"task_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    CONSTRAINT \"computed_update_outbox_seed_task_id_fkey\" FOREIGN KEY (\"task_id\") REFERENCES \"computed_update_outbox\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\n-- CreateTable\nCREATE TABLE \"computed_update_dead_letter\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT NOT NULL,\n    \"seed_table_id\" TEXT NOT NULL,\n    \"seed_record_ids\" JSON,\n    \"change_type\" TEXT NOT NULL,\n    \"steps\" JSON NOT NULL,\n    \"edges\" JSON NOT NULL,\n    \"status\" TEXT NOT NULL,\n    \"attempts\" INTEGER NOT NULL DEFAULT 0,\n    \"max_attempts\" INTEGER NOT NULL DEFAULT 8,\n    \"next_run_at\" DATETIME NOT NULL,\n    \"locked_at\" DATETIME,\n    \"locked_by\" TEXT,\n    \"last_error\" TEXT,\n    \"estimated_complexity\" INTEGER NOT NULL DEFAULT 0,\n    \"plan_hash\" TEXT NOT NULL,\n    \"dirty_stats\" JSON,\n    \"affected_table_ids\" JSON,\n    \"affected_field_ids\" JSON,\n    \"sync_max_level\" INTEGER,\n    \"failed_at\" DATETIME NOT NULL,\n    \"created_at\" DATETIME NOT NULL,\n    \"updated_at\" DATETIME NOT NULL\n);\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_status_next_run_at_idx\" ON \"computed_update_outbox\"(\"status\", \"next_run_at\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_base_id_seed_table_id_idx\" ON \"computed_update_outbox\"(\"base_id\", \"seed_table_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_plan_hash_idx\" ON \"computed_update_outbox\"(\"plan_hash\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_outbox_seed_task_id_idx\" ON \"computed_update_outbox_seed\"(\"task_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"computed_update_outbox_seed_task_id_record_id_key\" ON \"computed_update_outbox_seed\"(\"task_id\", \"record_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_dead_letter_base_id_seed_table_id_idx\" ON \"computed_update_dead_letter\"(\"base_id\", \"seed_table_id\");\n\n-- CreateIndex\nCREATE INDEX \"computed_update_dead_letter_plan_hash_idx\" ON \"computed_update_dead_letter\"(\"plan_hash\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20260104190000_add_outbox_seed_table_id/migration.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE \"new_computed_update_outbox_seed\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"task_id\" TEXT NOT NULL,\n    \"table_id\" TEXT NOT NULL,\n    \"record_id\" TEXT NOT NULL,\n    CONSTRAINT \"computed_update_outbox_seed_task_id_fkey\" FOREIGN KEY (\"task_id\") REFERENCES \"computed_update_outbox\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE\n);\n\nINSERT INTO \"new_computed_update_outbox_seed\" (\"id\", \"task_id\", \"table_id\", \"record_id\")\nSELECT s.\"id\", s.\"task_id\", o.\"seed_table_id\", s.\"record_id\"\nFROM \"computed_update_outbox_seed\" AS s\nLEFT JOIN \"computed_update_outbox\" AS o ON o.\"id\" = s.\"task_id\";\n\nDROP TABLE \"computed_update_outbox_seed\";\nALTER TABLE \"new_computed_update_outbox_seed\" RENAME TO \"computed_update_outbox_seed\";\n\nCREATE INDEX \"computed_update_outbox_seed_task_id_idx\" ON \"computed_update_outbox_seed\"(\"task_id\");\nCREATE UNIQUE INDEX \"computed_update_outbox_seed_task_id_table_id_record_id_key\" ON \"computed_update_outbox_seed\"(\"task_id\", \"table_id\", \"record_id\");\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20260120065143_add_core_table_indexes/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"field_table_id_deleted_time_idx\" ON \"field\"(\"table_id\", \"deleted_time\");\n\n-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"table_meta_base_id_deleted_time_idx\" ON \"table_meta\"(\"base_id\", \"deleted_time\");\n\n-- CreateIndex\nCREATE INDEX IF NOT EXISTS \"view_table_id_deleted_time_idx\" ON \"view\"(\"table_id\", \"deleted_time\");\n\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20260121100000_add_base_share/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"base_share\" (\n    \"id\" TEXT NOT NULL PRIMARY KEY,\n    \"base_id\" TEXT NOT NULL,\n    \"share_id\" TEXT NOT NULL,\n    \"password\" TEXT,\n    \"node_id\" TEXT NOT NULL,\n    \"allow_save\" BOOLEAN,\n    \"allow_copy\" BOOLEAN,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"created_time\" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"created_by\" TEXT NOT NULL,\n    \"last_modified_time\" DATETIME\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_share_share_id_key\" ON \"base_share\"(\"share_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"base_share_node_id_key\" ON \"base_share\"(\"node_id\");\n\n-- CreateIndex\nCREATE INDEX \"base_share_base_id_idx\" ON \"base_share\"(\"base_id\");\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/20260129203000_add_outbox_pending_unique_index/migration.sql",
    "content": "CREATE UNIQUE INDEX IF NOT EXISTS \"computed_update_outbox_pending_unique_idx\" ON \"computed_update_outbox\"(\"base_id\", \"seed_table_id\", \"plan_hash\", \"change_type\") WHERE \"status\" = 'pending';\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (e.g., Git)\nprovider = \"sqlite\""
  },
  {
    "path": "packages/db-main-prisma/prisma/sqlite/schema.prisma",
    "content": "generator client {\n  provider      = \"prisma-client-js\"\n  binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"PRISMA_DATABASE_URL\")\n}\n\nmodel Space {\n  id               String    @id @default(cuid())\n  name             String\n  credit           Int?\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  isTemplate       Boolean?  @map(\"is_template\")\n\n  baseGroup Base[]\n\n  @@map(\"space\")\n}\n\nmodel PinResource {\n  id          String   @id @default(cuid())\n  type        String   @map(\"type\")\n  resourceId  String   @map(\"resource_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n  order       Float    @map(\"order\")\n\n  @@unique([createdBy, resourceId])\n  @@index([order])\n  @@map(\"pin_resource\")\n}\n\nmodel Base {\n  id               String      @id @default(cuid())\n  spaceId          String      @map(\"space_id\")\n  name             String\n  order            Float\n  icon             String?\n  schemaPass       String?     @map(\"schema_pass\")\n  deletedTime      DateTime?   @map(\"deleted_time\")\n  createdTime      DateTime    @default(now()) @map(\"created_time\")\n  createdBy        String      @map(\"created_by\")\n  lastModifiedBy   String?     @map(\"last_modified_by\")\n  lastModifiedTime DateTime?   @updatedAt @map(\"last_modified_time\")\n  space            Space       @relation(fields: [spaceId], references: [id])\n  tables           TableMeta[]\n\n  @@index([order])\n  @@map(\"base\")\n}\n\nmodel TableMeta {\n  id                String              @id\n  baseId            String              @map(\"base_id\")\n  name              String\n  description       String?\n  icon              String?\n  dbTableName       String              @map(\"db_table_name\")\n  dbViewName        String?             @map(\"db_view_name\")\n  version           Int\n  order             Float\n  createdTime       DateTime            @default(now()) @map(\"created_time\")\n  lastModifiedTime  DateTime?           @updatedAt @map(\"last_modified_time\")\n  deletedTime       DateTime?           @map(\"deleted_time\")\n  createdBy         String              @map(\"created_by\")\n  lastModifiedBy    String?             @map(\"last_modified_by\")\n  base              Base                @relation(fields: [baseId], references: [id])\n  fields            Field[]\n  views             View[]\n  pluginPanel       PluginPanel[]\n  pluginContextMenu PluginContextMenu[]\n\n  @@index([order])\n  @@index([dbTableName])\n  @@index([baseId, deletedTime])\n  @@map(\"table_meta\")\n}\n\nmodel Field {\n  id                  String    @id\n  name                String\n  description         String?\n  options             String?\n  meta                String?\n  aiConfig            String?   @map(\"ai_config\")\n  type                String\n  cellValueType       String    @map(\"cell_value_type\")\n  isMultipleCellValue Boolean?  @map(\"is_multiple_cell_value\")\n  dbFieldType         String    @map(\"db_field_type\")\n  dbFieldName         String    @map(\"db_field_name\")\n  notNull             Boolean?  @map(\"not_null\")\n  unique              Boolean?\n  isPrimary           Boolean?  @map(\"is_primary\")\n  isComputed          Boolean?  @map(\"is_computed\")\n  isLookup            Boolean?  @map(\"is_lookup\")\n  isConditionalLookup Boolean?  @map(\"is_conditional_lookup\")\n  isPending           Boolean?  @map(\"is_pending\")\n  hasError            Boolean?  @map(\"has_error\")\n  // the link field id that a lookup field is linked to\n  lookupLinkedFieldId String?   @map(\"lookup_linked_field_id\")\n  lookupOptions       String?   @map(\"lookup_options\")\n  tableId             String    @map(\"table_id\")\n  order               Float\n  version             Int\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime         DateTime? @map(\"deleted_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  table               TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([lookupLinkedFieldId])\n  @@index([tableId, deletedTime])\n  @@map(\"field\")\n}\n\nmodel ComputedUpdateOutbox {\n  id                 String   @id @default(cuid())\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @default(now()) @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  createdAt          DateTime @default(now()) @map(\"created_at\")\n  updatedAt          DateTime @updatedAt @map(\"updated_at\")\n\n  seeds ComputedUpdateOutboxSeed[]\n\n  @@index([status, nextRunAt])\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_outbox\")\n}\n\nmodel ComputedUpdateOutboxSeed {\n  id       String               @id @default(cuid())\n  taskId   String               @map(\"task_id\")\n  tableId  String               @map(\"table_id\")\n  recordId String               @map(\"record_id\")\n  task     ComputedUpdateOutbox @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@unique([taskId, tableId, recordId])\n  @@index([taskId])\n  @@map(\"computed_update_outbox_seed\")\n}\n\nmodel ComputedUpdateDeadLetter {\n  id                 String   @id\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  traceData          Json?    @map(\"trace_data\")\n  failedAt           DateTime @map(\"failed_at\")\n  createdAt          DateTime @map(\"created_at\")\n  updatedAt          DateTime @map(\"updated_at\")\n\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_dead_letter\")\n}\n\nmodel View {\n  id               String    @id\n  name             String\n  description      String?\n  tableId          String    @map(\"table_id\")\n  type             String\n  sort             String?\n  filter           String?\n  group            String?\n  options          String?\n  order            Float\n  version          Int\n  columnMeta       String    @map(\"column_meta\")\n  isLocked         Boolean?  @map(\"is_locked\")\n  enableShare      Boolean?  @map(\"enable_share\")\n  shareId          String?   @unique @map(\"share_id\")\n  shareMeta        String?   @map(\"share_meta\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  table            TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([order])\n  @@index([tableId, deletedTime])\n  @@map(\"view\")\n}\n\nmodel Ops {\n  id          String   @id @default(cuid())\n  collection  String\n  docId       String   @map(\"doc_id\")\n  docType     String   @map(\"doc_type\")\n  version     Int\n  operation   String\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@unique([collection, docId, version])\n  @@index([collection, createdTime])\n  @@map(\"ops\")\n}\n\nmodel Reference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"reference\")\n}\n\nmodel User {\n  id                   String    @id @default(cuid())\n  name                 String\n  password             String?\n  salt                 String?\n  phone                String?   @unique\n  email                String    @unique\n  avatar               String?\n  isSystem             Boolean?  @map(\"is_system\")\n  isAdmin              Boolean?  @map(\"is_admin\")\n  isTrialUsed          Boolean?  @map(\"is_trial_used\")\n  lang                 String?   @map(\"lang\")\n  notifyMeta           String?   @map(\"notify_meta\")\n  lastSignTime         DateTime? @map(\"last_sign_time\")\n  deactivatedTime      DateTime? @map(\"deactivated_time\")\n  createdTime          DateTime  @default(now()) @map(\"created_time\")\n  deletedTime          DateTime? @map(\"deleted_time\")\n  lastModifiedTime     DateTime? @updatedAt @map(\"last_modified_time\")\n  permanentDeletedTime DateTime? @map(\"permanent_deleted_time\")\n  refMeta              String?   @map(\"ref_meta\")\n\n  accounts Account[]\n\n  @@map(\"users\")\n}\n\nmodel Account {\n  id          String   @id @default(cuid())\n  userId      String   @map(\"user_id\")\n  type        String\n  provider    String\n  providerId  String   @map(\"provider_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerId])\n  @@map(\"account\")\n}\n\nmodel Attachments {\n  id             String    @id @default(cuid())\n  token          String    @unique\n  hash           String\n  size           BigInt\n  mimetype       String\n  path           String\n  width          Int?\n  height         Int?\n  deletedTime    DateTime? @map(\"deleted_time\")\n  createdTime    DateTime  @default(now()) @map(\"created_time\")\n  createdBy      String    @map(\"created_by\")\n  lastModifiedBy String?   @map(\"last_modified_by\")\n  thumbnailPath  String?   @map(\"thumbnail_path\")\n\n  @@map(\"attachments\")\n}\n\nmodel AttachmentsTable {\n  id               String    @id @default(cuid())\n  attachmentId     String    @map(\"attachment_id\")\n  name             String\n  token            String\n  tableId          String    @map(\"table_id\")\n  recordId         String    @map(\"record_id\")\n  fieldId          String    @map(\"field_id\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@index([tableId, fieldId])\n  @@index([attachmentId])\n  @@map(\"attachments_table\")\n}\n\nmodel Collaborator {\n  id               String    @id @default(cuid())\n  roleName         String    @map(\"role_name\")\n  resourceType     String    @map(\"resource_type\")\n  resourceId       String    @map(\"resource_id\")\n  principalId      String    @map(\"principal_id\")\n  principalType    String    @map(\"principal_type\")\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@unique([resourceType, resourceId, principalId, principalType])\n  @@index([resourceId])\n  @@index([principalId])\n  @@map(\"collaborator\")\n}\n\nmodel Invitation {\n  id               String    @id @default(cuid())\n  baseId           String?   @map(\"base_id\")\n  spaceId          String?   @map(\"space_id\")\n  type             String    @map(\"type\")\n  role             String\n  invitationCode   String    @map(\"invitation_code\")\n  expiredTime      DateTime? @map(\"expired_time\")\n  createdBy        String    @map(\"create_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation\")\n}\n\nmodel InvitationRecord {\n  id           String   @id @default(cuid())\n  invitationId String   @map(\"invitation_id\")\n  baseId       String?  @map(\"base_id\")\n  spaceId      String?  @map(\"space_id\")\n  type         String   @map(\"type\")\n  inviter      String   @map(\"inviter\")\n  accepter     String   @map(\"accepter\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n\n  @@index([invitationId])\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation_record\")\n}\n\nmodel Notification {\n  id          String   @id @default(cuid())\n  fromUserId  String   @map(\"from_user_id\")\n  toUserId    String   @map(\"to_user_id\")\n  type        String   @map(\"type\")\n  message     String   @map(\"message\")\n  messageI18n String?  @map(\"message_i18n\")\n  urlPath     String?  @map(\"url_path\")\n  isRead      Boolean  @default(false) @map(\"is_read\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([toUserId, isRead, createdTime])\n  @@map(\"notification\")\n}\n\nmodel AccessToken {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  userId           String    @map(\"user_id\")\n  scopes           String\n  spaceIds         String?   @map(\"space_ids\")\n  baseIds          String?   @map(\"base_ids\")\n  sign             String\n  clientId         String?   @map(\"client_id\")\n  hasFullAccess    Boolean?  @map(\"has_full_access\")\n  expiredTime      DateTime  @map(\"expired_time\")\n  lastUsedTime     DateTime? @map(\"last_used_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([userId])\n  @@index([clientId])\n  @@map(\"access_token\")\n}\n\nmodel Setting {\n  name             String    @unique\n  content          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@map(\"setting\")\n}\n\nmodel OAuthApp {\n  id               String    @id @default(cuid())\n  name             String\n  logo             String?\n  homepage         String\n  description      String?\n  clientId         String    @unique @map(\"client_id\")\n  redirectUris     String?   @map(\"redirect_uris\")\n  scopes           String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  secrets          OAuthAppSecret[]\n  authorized       OAuthAppAuthorized[]\n  tokens           OAuthAppToken[]\n\n  @@map(\"oauth_app\")\n}\n\nmodel OAuthAppAuthorized {\n  id             String   @id @default(cuid())\n  clientId       String   @map(\"client_id\")\n  userId         String   @map(\"user_id\")\n  authorizedTime DateTime @map(\"authorized_time\")\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@unique([clientId, userId])\n  @@map(\"oauth_app_authorized\")\n}\n\nmodel OAuthAppSecret {\n  id           String    @id @default(cuid())\n  clientId     String    @map(\"client_id\")\n  secret       String    @unique\n  maskedSecret String    @map(\"masked_secret\")\n  createdTime  DateTime  @default(now()) @map(\"created_time\")\n  createdBy    String    @map(\"created_by\")\n  lastUsedTime DateTime? @map(\"last_used_time\")\n  tokens       OAuthAppToken[]\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@map(\"oauth_app_secret\")\n}\n\nmodel OAuthAppToken {\n  id               String   @id @default(cuid())\n  clientId         String   @map(\"client_id\")\n  appSecretId      String?  @map(\"app_secret_id\")\n  refreshTokenSign String   @unique @map(\"refresh_token_sign\")\n  expiredTime      DateTime @map(\"expired_time\")\n  createdTime      DateTime @default(now()) @map(\"created_time\")\n  createdBy        String   @map(\"created_by\")\n\n  oAuthAppSecret OAuthAppSecret? @relation(fields: [appSecretId], references: [id], onDelete: Cascade)\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@index([clientId])\n\n  @@map(\"oauth_app_token\")\n}\n\nmodel RecordHistory {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  fieldId     String   @map(\"field_id\")\n  before      String   @map(\"before\")\n  after       String   @map(\"after\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId, createdTime])\n  @@index([tableId, createdTime])\n  @@map(\"record_history\")\n}\n\nmodel Trash {\n  id           String   @id @default(cuid())\n  resourceType String   @map(\"resource_type\")\n  resourceId   String   @map(\"resource_id\")\n  parentId     String?  @map(\"parent_id\")\n  deletedTime  DateTime @default(now()) @map(\"deleted_time\")\n  deletedBy    String   @map(\"deleted_by\")\n\n  @@unique([resourceType, resourceId])\n  @@map(\"trash\")\n}\n\nmodel TableTrash {\n  id           String   @id @default(cuid())\n  tableId      String   @map(\"table_id\")\n  resourceType String   @map(\"resource_type\")\n  snapshot     String   @map(\"snapshot\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n  createdBy    String   @map(\"created_by\")\n\n  @@index([tableId])\n  @@map(\"table_trash\")\n}\n\nmodel RecordTrash {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  snapshot    String   @map(\"snapshot\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId])\n  @@map(\"record_trash\")\n}\n\nmodel Plugin {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  detailDesc       String?   @map(\"detail_desc\")\n  logo             String\n  helpUrl          String?   @map(\"help_url\")\n  status           String\n  positions        String\n  url              String?\n  secret           String    @unique\n  maskedSecret     String    @map(\"masked_secret\")\n  i18n             String?\n  config           String?\n  pluginUser       String?   @map(\"plugin_user\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  pluginInstall PluginInstall[]\n\n  @@map(\"plugin\")\n}\n\nmodel PluginInstall {\n  id               String    @id @default(cuid())\n  pluginId         String    @map(\"plugin_id\")\n  baseId           String    @map(\"base_id\")\n  name             String\n  positionId       String    @map(\"position_id\")\n  position         String\n  storage          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)\n\n  @@index([positionId])\n  @@index([baseId])\n  @@map(\"plugin_install\")\n}\n\nmodel Dashboard {\n  id               String    @id @default(cuid())\n  name             String\n  baseId           String    @map(\"base_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@index([baseId])\n  @@map(\"dashboard\")\n}\n\nmodel Comment {\n  id       String  @id @default(cuid())\n  tableId  String  @map(\"table_id\")\n  recordId String  @map(\"record_id\")\n  quoteId  String? @map(\"quote_Id\")\n  content  String?\n  reaction String?\n\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@map(\"comment\")\n}\n\nmodel CommentSubscription {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  createdBy   String   @map(\"created_by\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([tableId, recordId])\n  @@index([tableId, recordId])\n  @@map(\"comment_subscription\")\n}\n\nmodel Integration {\n  id               String    @id @default(cuid())\n  resourceId       String    @unique @map(\"resource_id\")\n  config           String    @map(\"config\")\n  type             String    @map(\"type\")\n  enable           Boolean?  @map(\"enable\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([resourceId])\n  @@map(\"integration\")\n}\n\nmodel PluginPanel {\n  id               String    @id @default(cuid())\n  name             String\n  tableId          String    @map(\"table_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_panel\")\n}\n\nmodel PluginContextMenu {\n  id               String    @id @default(cuid())\n  tableId          String    @map(\"table_id\")\n  pluginInstallId  String    @unique @map(\"plugin_install_id\")\n  order            Float     @map(\"order\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_context_menu\")\n}\n\nmodel UserLastVisit {\n  id               String   @id @default(cuid())\n  userId           String   @map(\"user_id\")\n  resourceType     String   @map(\"resource_type\")\n  resourceId       String   @map(\"resource_id\")\n  parentResourceId String   @map(\"parent_resource_id\")\n  lastVisitTime    DateTime @default(now()) @map(\"last_visit_time\")\n\n  @@unique([userId, resourceType, resourceId])\n  @@index([userId, resourceType, parentResourceId])\n  @@map(\"user_last_visit\")\n}\n\nmodel Template {\n  id                  String    @id @default(cuid())\n  baseId              String?   @map(\"base_id\")\n  cover               String?\n  name                String?\n  description         String?\n  markdownDescription String?   @map(\"markdown_description\")\n  categoryId          String[]  @map(\"category_id\")\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  isSystem            Boolean?  @map(\"is_system\")\n  isPublished         Boolean?  @map(\"is_published\")\n  featured            Boolean?  @map(\"featured\")\n  snapshot            String?   @map(\"snapshot\")\n  order               Float     @map(\"order\")\n  usageCount          Int       @default(0) @map(\"usage_count\")\n  publishInfo         Json?     @map(\"publish_info\")\n  visitCount          Int       @default(0) @map(\"visit_count\")\n\n  @@unique([baseId])\n  @@map(\"template\")\n}\n\nmodel TemplateCategory {\n  id               String    @id @default(cuid())\n  name             String    @unique\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  order            Float     @map(\"order\")\n\n  @@map(\"template_category\")\n}\n\nmodel Task {\n  id               String    @id @default(cuid())\n  type             String    @map(\"type\")\n  status           String    @map(\"status\")\n  snapshot         String?   @map(\"snapshot\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  runs TaskRun[]\n\n  @@index([type, status])\n  @@map(\"task\")\n}\n\nmodel TaskRun {\n  id               String    @id @default(cuid())\n  taskId           String    @map(\"task_id\")\n  status           String    @map(\"status\")\n  snapshot         String    @map(\"snapshot\")\n  spent            Int?      @map(\"spent\")\n  log              String?   @map(\"log\")\n  errorMsg         String?   @map(\"error_msg\")\n  startedTime      DateTime? @map(\"started_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@index([taskId, status])\n  @@map(\"task_run\")\n}\n\nmodel TaskReference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"task_reference\")\n}\n\nmodel Waitlist {\n  email       String    @unique @map(\"email\")\n  invite      Boolean?  @map(\"invite\")\n  inviteTime  DateTime? @map(\"invite_time\")\n  createdTime DateTime  @default(now()) @map(\"created_time\")\n\n  @@map(\"waitlist\")\n}\n\n\nmodel BaseNode {\n  id                String     @id @default(cuid())\n  parentId          String?    @map(\"parent_id\")\n  baseId            String     @map(\"base_id\")\n  resourceType      String     @map(\"resource_type\")\n  resourceId        String     @map(\"resource_id\")\n  order             Float      @map(\"order\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  parent            BaseNode?  @relation(\"NodeToChildren\", fields: [parentId], references: [id])\n  children          BaseNode[] @relation(\"NodeToChildren\")\n\n  @@unique([baseId, resourceType, resourceId])\n\n  @@map(\"base_node\")\n}\n\nmodel BaseNodeFolder {\n  id                String     @id @default(cuid())\n  baseId            String     @map(\"base_id\")\n  name              String     @map(\"name\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  @@unique([baseId, name])\n\n  @@map(\"base_node_folder\")\n}\n\nmodel BaseShare {\n  id               String    @id @default(cuid())\n  baseId           String    @map(\"base_id\")\n  shareId          String    @unique @map(\"share_id\")\n  password         String?\n  nodeId           String    @unique @map(\"node_id\")\n  allowSave        Boolean?  @map(\"allow_save\")\n  allowCopy        Boolean?  @map(\"allow_copy\")\n  enabled          Boolean   @default(true)\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([baseId])\n  @@map(\"base_share\")\n}\n"
  },
  {
    "path": "packages/db-main-prisma/prisma/template.prisma",
    "content": "generator client {\n  provider      = \"prisma-client-js\"\n  binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n  provider = \"{{PRISMA_PROVIDER}}\"\n  url      = env(\"PRISMA_DATABASE_URL\")\n}\n\nmodel Space {\n  id               String    @id @default(cuid())\n  name             String\n  credit           Int?\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  isTemplate       Boolean?  @map(\"is_template\")\n\n  baseGroup Base[]\n\n  @@map(\"space\")\n}\n\nmodel PinResource {\n  id          String   @id @default(cuid())\n  type        String   @map(\"type\")\n  resourceId  String   @map(\"resource_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n  order       Float    @map(\"order\")\n\n  @@unique([createdBy, resourceId])\n  @@index([order])\n  @@map(\"pin_resource\")\n}\n\nmodel Base {\n  id               String      @id @default(cuid())\n  spaceId          String      @map(\"space_id\")\n  name             String\n  order            Float\n  icon             String?\n  schemaPass       String?     @map(\"schema_pass\")\n  deletedTime      DateTime?   @map(\"deleted_time\")\n  createdTime      DateTime    @default(now()) @map(\"created_time\")\n  createdBy        String      @map(\"created_by\")\n  lastModifiedBy   String?     @map(\"last_modified_by\")\n  lastModifiedTime DateTime?   @updatedAt @map(\"last_modified_time\")\n  space            Space       @relation(fields: [spaceId], references: [id])\n  tables           TableMeta[]\n\n  @@index([order])\n  @@map(\"base\")\n}\n\nmodel TableMeta {\n  id                String              @id\n  baseId            String              @map(\"base_id\")\n  name              String\n  description       String?\n  icon              String?\n  dbTableName       String              @map(\"db_table_name\")\n  dbViewName        String?             @map(\"db_view_name\")\n  version           Int\n  order             Float\n  createdTime       DateTime            @default(now()) @map(\"created_time\")\n  lastModifiedTime  DateTime?           @updatedAt @map(\"last_modified_time\")\n  deletedTime       DateTime?           @map(\"deleted_time\")\n  createdBy         String              @map(\"created_by\")\n  lastModifiedBy    String?             @map(\"last_modified_by\")\n  base              Base                @relation(fields: [baseId], references: [id])\n  fields            Field[]\n  views             View[]\n  pluginPanel       PluginPanel[]\n  pluginContextMenu PluginContextMenu[]\n\n  @@index([order])\n  @@index([dbTableName])\n  @@index([baseId, deletedTime])\n  @@map(\"table_meta\")\n}\n\nmodel Field {\n  id                  String    @id\n  name                String\n  description         String?\n  options             String?\n  meta                String?\n  aiConfig            String?   @map(\"ai_config\")\n  type                String\n  cellValueType       String    @map(\"cell_value_type\")\n  isMultipleCellValue Boolean?  @map(\"is_multiple_cell_value\")\n  dbFieldType         String    @map(\"db_field_type\")\n  dbFieldName         String    @map(\"db_field_name\")\n  notNull             Boolean?  @map(\"not_null\")\n  unique              Boolean?\n  isPrimary           Boolean?  @map(\"is_primary\")\n  isComputed          Boolean?  @map(\"is_computed\")\n  isLookup            Boolean?  @map(\"is_lookup\")\n  isConditionalLookup Boolean?  @map(\"is_conditional_lookup\")\n  isPending           Boolean?  @map(\"is_pending\")\n  hasError            Boolean?  @map(\"has_error\")\n  // the link field id that a lookup field is linked to\n  lookupLinkedFieldId String?   @map(\"lookup_linked_field_id\")\n  lookupOptions       String?   @map(\"lookup_options\")\n  tableId             String    @map(\"table_id\")\n  order               Float\n  version             Int\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime         DateTime? @map(\"deleted_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  table               TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([lookupLinkedFieldId])\n  @@index([tableId, deletedTime])\n  @@map(\"field\")\n}\n\nmodel ComputedUpdateOutbox {\n  id                 String   @id @default(cuid())\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @default(now()) @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  createdAt          DateTime @default(now()) @map(\"created_at\")\n  updatedAt          DateTime @updatedAt @map(\"updated_at\")\n\n  seeds ComputedUpdateOutboxSeed[]\n\n  @@index([status, nextRunAt])\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_outbox\")\n}\n\nmodel ComputedUpdateOutboxSeed {\n  id       String               @id @default(cuid())\n  taskId   String               @map(\"task_id\")\n  tableId  String               @map(\"table_id\")\n  recordId String               @map(\"record_id\")\n  task     ComputedUpdateOutbox @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@unique([taskId, tableId, recordId])\n  @@index([taskId])\n  @@map(\"computed_update_outbox_seed\")\n}\n\nmodel ComputedUpdateDeadLetter {\n  id                 String   @id\n  baseId             String   @map(\"base_id\")\n  seedTableId        String   @map(\"seed_table_id\")\n  seedRecordIds      Json?    @map(\"seed_record_ids\")\n  changeType         String   @map(\"change_type\")\n  steps              Json?\n  edges              Json?\n  status             String\n  attempts           Int      @default(0)\n  maxAttempts        Int      @default(8) @map(\"max_attempts\")\n  nextRunAt          DateTime @map(\"next_run_at\")\n  lockedAt           DateTime? @map(\"locked_at\")\n  lockedBy           String?  @map(\"locked_by\")\n  lastError          String?  @map(\"last_error\")\n  estimatedComplexity Int     @default(0) @map(\"estimated_complexity\")\n  planHash           String   @map(\"plan_hash\")\n  dirtyStats         Json?    @map(\"dirty_stats\")\n  runId              String   @map(\"run_id\")\n  originRunIds       String[] @default([]) @map(\"origin_run_ids\")\n  runTotalSteps      Int      @default(0) @map(\"run_total_steps\")\n  runCompletedStepsBefore Int @default(0) @map(\"run_completed_steps_before\")\n  affectedTableIds   String[] @default([]) @map(\"affected_table_ids\")\n  affectedFieldIds   String[] @default([]) @map(\"affected_field_ids\")\n  syncMaxLevel       Int?     @map(\"sync_max_level\")\n  traceData          Json?    @map(\"trace_data\")\n  failedAt           DateTime @map(\"failed_at\")\n  createdAt          DateTime @map(\"created_at\")\n  updatedAt          DateTime @map(\"updated_at\")\n\n  @@index([baseId, seedTableId])\n  @@index([planHash])\n  @@index([runId])\n  @@map(\"computed_update_dead_letter\")\n}\n\nmodel View {\n  id               String    @id\n  name             String\n  description      String?\n  tableId          String    @map(\"table_id\")\n  type             String\n  sort             String?\n  filter           String?\n  group            String?\n  options          String?\n  order            Float\n  version          Int\n  columnMeta       String    @map(\"column_meta\")\n  isLocked         Boolean?  @map(\"is_locked\")\n  enableShare      Boolean?  @map(\"enable_share\")\n  shareId          String?   @unique @map(\"share_id\")\n  shareMeta        String?   @map(\"share_meta\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  table            TableMeta @relation(fields: [tableId], references: [id])\n\n  @@index([order])\n  @@index([tableId, deletedTime])\n  @@map(\"view\")\n}\n\nmodel Ops {\n  id          String   @id @default(cuid())\n  collection  String\n  docId       String   @map(\"doc_id\")\n  docType     String   @map(\"doc_type\")\n  version     Int\n  operation   String\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@unique([collection, docId, version])\n  @@index([collection, createdTime])\n  @@map(\"ops\")\n}\n\nmodel Reference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"reference\")\n}\n\nmodel User {\n  id                   String    @id @default(cuid())\n  name                 String\n  password             String?\n  salt                 String?\n  phone                String?   @unique\n  email                String    @unique\n  avatar               String?\n  isSystem             Boolean?  @map(\"is_system\")\n  isAdmin              Boolean?  @map(\"is_admin\")\n  isTrialUsed          Boolean?  @map(\"is_trial_used\")\n  lang                 String?   @map(\"lang\")\n  notifyMeta           String?   @map(\"notify_meta\")\n  lastSignTime         DateTime? @map(\"last_sign_time\")\n  deactivatedTime      DateTime? @map(\"deactivated_time\")\n  createdTime          DateTime  @default(now()) @map(\"created_time\")\n  deletedTime          DateTime? @map(\"deleted_time\")\n  lastModifiedTime     DateTime? @updatedAt @map(\"last_modified_time\")\n  permanentDeletedTime DateTime? @map(\"permanent_deleted_time\")\n  refMeta              String?   @map(\"ref_meta\")\n\n  accounts Account[]\n\n  @@map(\"users\")\n}\n\nmodel Account {\n  id          String   @id @default(cuid())\n  userId      String   @map(\"user_id\")\n  type        String\n  provider    String\n  providerId  String   @map(\"provider_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerId])\n  @@map(\"account\")\n}\n\nmodel Attachments {\n  id             String    @id @default(cuid())\n  token          String    @unique\n  hash           String\n  size           BigInt\n  mimetype       String\n  path           String\n  width          Int?\n  height         Int?\n  deletedTime    DateTime? @map(\"deleted_time\")\n  createdTime    DateTime  @default(now()) @map(\"created_time\")\n  createdBy      String    @map(\"created_by\")\n  lastModifiedBy String?   @map(\"last_modified_by\")\n  thumbnailPath  String?   @map(\"thumbnail_path\")\n\n  @@map(\"attachments\")\n}\n\nmodel AttachmentsTable {\n  id               String    @id @default(cuid())\n  attachmentId     String    @map(\"attachment_id\")\n  name             String\n  token            String\n  tableId          String    @map(\"table_id\")\n  recordId         String    @map(\"record_id\")\n  fieldId          String    @map(\"field_id\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@index([tableId, fieldId])\n  @@index([attachmentId])\n  @@map(\"attachments_table\")\n}\n\nmodel Collaborator {\n  id               String    @id @default(cuid())\n  roleName         String    @map(\"role_name\")\n  resourceType     String    @map(\"resource_type\")\n  resourceId       String    @map(\"resource_id\")\n  principalId      String    @map(\"principal_id\")\n  principalType    String    @map(\"principal_type\")\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@unique([resourceType, resourceId, principalId, principalType])\n  @@index([resourceId])\n  @@index([principalId])\n  @@map(\"collaborator\")\n}\n\nmodel Invitation {\n  id               String    @id @default(cuid())\n  baseId           String?   @map(\"base_id\")\n  spaceId          String?   @map(\"space_id\")\n  type             String    @map(\"type\")\n  role             String\n  invitationCode   String    @map(\"invitation_code\")\n  expiredTime      DateTime? @map(\"expired_time\")\n  createdBy        String    @map(\"create_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  deletedTime      DateTime? @map(\"deleted_time\")\n\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation\")\n}\n\nmodel InvitationRecord {\n  id           String   @id @default(cuid())\n  invitationId String   @map(\"invitation_id\")\n  baseId       String?  @map(\"base_id\")\n  spaceId      String?  @map(\"space_id\")\n  type         String   @map(\"type\")\n  inviter      String   @map(\"inviter\")\n  accepter     String   @map(\"accepter\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n\n  @@index([invitationId])\n  @@index([baseId])\n  @@index([spaceId])\n  @@map(\"invitation_record\")\n}\n\nmodel Notification {\n  id          String   @id @default(cuid())\n  fromUserId  String   @map(\"from_user_id\")\n  toUserId    String   @map(\"to_user_id\")\n  type        String   @map(\"type\")\n  message     String   @map(\"message\")\n  messageI18n String?  @map(\"message_i18n\")\n  urlPath     String?  @map(\"url_path\")\n  isRead      Boolean  @default(false) @map(\"is_read\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([toUserId, isRead, createdTime])\n  @@map(\"notification\")\n}\n\nmodel AccessToken {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  userId           String    @map(\"user_id\")\n  scopes           String\n  spaceIds         String?   @map(\"space_ids\")\n  baseIds          String?   @map(\"base_ids\")\n  sign             String\n  clientId         String?   @map(\"client_id\")\n  hasFullAccess    Boolean?  @map(\"has_full_access\")\n  expiredTime      DateTime  @map(\"expired_time\")\n  lastUsedTime     DateTime? @map(\"last_used_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([userId])\n  @@index([clientId])\n  @@map(\"access_token\")\n}\n\nmodel Setting {\n  name             String    @unique\n  content          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@map(\"setting\")\n}\n\nmodel OAuthApp {\n  id               String    @id @default(cuid())\n  name             String\n  logo             String?\n  homepage         String\n  description      String?\n  clientId         String    @unique @map(\"client_id\")\n  redirectUris     String?   @map(\"redirect_uris\")\n  scopes           String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  secrets          OAuthAppSecret[]\n  authorized       OAuthAppAuthorized[]\n  tokens           OAuthAppToken[]\n\n  @@map(\"oauth_app\")\n}\n\nmodel OAuthAppAuthorized {\n  id             String   @id @default(cuid())\n  clientId       String   @map(\"client_id\")\n  userId         String   @map(\"user_id\")\n  authorizedTime DateTime @map(\"authorized_time\")\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@unique([clientId, userId])\n  @@map(\"oauth_app_authorized\")\n}\n\nmodel OAuthAppSecret {\n  id           String    @id @default(cuid())\n  clientId     String    @map(\"client_id\")\n  secret       String    @unique\n  maskedSecret String    @map(\"masked_secret\")\n  createdTime  DateTime  @default(now()) @map(\"created_time\")\n  createdBy    String    @map(\"created_by\")\n  lastUsedTime DateTime? @map(\"last_used_time\")\n  tokens       OAuthAppToken[]\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@map(\"oauth_app_secret\")\n}\n\nmodel OAuthAppToken {\n  id               String   @id @default(cuid())\n  clientId         String   @map(\"client_id\")\n  appSecretId      String?  @map(\"app_secret_id\")\n  refreshTokenSign String   @unique @map(\"refresh_token_sign\")\n  expiredTime      DateTime @map(\"expired_time\")\n  createdTime      DateTime @default(now()) @map(\"created_time\")\n  createdBy        String   @map(\"created_by\")\n\n  oAuthAppSecret OAuthAppSecret? @relation(fields: [appSecretId], references: [id], onDelete: Cascade)\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n\n  @@index([clientId])\n\n  @@map(\"oauth_app_token\")\n}\n\nmodel RecordHistory {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  fieldId     String   @map(\"field_id\")\n  before      String   @map(\"before\")\n  after       String   @map(\"after\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId, createdTime])\n  @@index([tableId, createdTime])\n  @@map(\"record_history\")\n}\n\nmodel Trash {\n  id           String   @id @default(cuid())\n  resourceType String   @map(\"resource_type\")\n  resourceId   String   @map(\"resource_id\")\n  parentId     String?  @map(\"parent_id\")\n  deletedTime  DateTime @default(now()) @map(\"deleted_time\")\n  deletedBy    String   @map(\"deleted_by\")\n\n  @@unique([resourceType, resourceId])\n  @@map(\"trash\")\n}\n\nmodel TableTrash {\n  id           String   @id @default(cuid())\n  tableId      String   @map(\"table_id\")\n  resourceType String   @map(\"resource_type\")\n  snapshot     String   @map(\"snapshot\")\n  createdTime  DateTime @default(now()) @map(\"created_time\")\n  createdBy    String   @map(\"created_by\")\n\n  @@index([tableId])\n  @@map(\"table_trash\")\n}\n\nmodel RecordTrash {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  snapshot    String   @map(\"snapshot\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n  createdBy   String   @map(\"created_by\")\n\n  @@index([tableId, recordId])\n  @@map(\"record_trash\")\n}\n\nmodel Plugin {\n  id               String    @id @default(cuid())\n  name             String\n  description      String?\n  detailDesc       String?   @map(\"detail_desc\")\n  logo             String\n  helpUrl          String?   @map(\"help_url\")\n  status           String\n  positions        String\n  url              String?\n  secret           String    @unique\n  maskedSecret     String    @map(\"masked_secret\")\n  i18n             String?\n  config           String?\n  pluginUser       String?   @map(\"plugin_user\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  pluginInstall PluginInstall[]\n\n  @@map(\"plugin\")\n}\n\nmodel PluginInstall {\n  id               String    @id @default(cuid())\n  pluginId         String    @map(\"plugin_id\")\n  baseId           String    @map(\"base_id\")\n  name             String\n  positionId       String    @map(\"position_id\")\n  position         String\n  storage          String?\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)\n\n  @@index([positionId])\n  @@index([baseId])\n  @@map(\"plugin_install\")\n}\n\nmodel Dashboard {\n  id               String    @id @default(cuid())\n  name             String\n  baseId           String    @map(\"base_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  @@index([baseId])\n  @@map(\"dashboard\")\n}\n\nmodel Comment {\n  id       String  @id @default(cuid())\n  tableId  String  @map(\"table_id\")\n  recordId String  @map(\"record_id\")\n  quoteId  String? @map(\"quote_Id\")\n  content  String?\n  reaction String?\n\n  deletedTime      DateTime? @map(\"deleted_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([tableId, recordId])\n  @@map(\"comment\")\n}\n\nmodel CommentSubscription {\n  id          String   @id @default(cuid())\n  tableId     String   @map(\"table_id\")\n  recordId    String   @map(\"record_id\")\n  createdBy   String   @map(\"created_by\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([tableId, recordId])\n  @@index([tableId, recordId])\n  @@map(\"comment_subscription\")\n}\n\nmodel Integration {\n  id               String    @id @default(cuid())\n  resourceId       String    @unique @map(\"resource_id\")\n  config           String    @map(\"config\")\n  type             String    @map(\"type\")\n  enable           Boolean?  @map(\"enable\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([resourceId])\n  @@map(\"integration\")\n}\n\nmodel PluginPanel {\n  id               String    @id @default(cuid())\n  name             String\n  tableId          String    @map(\"table_id\")\n  layout           String?\n  createdBy        String    @map(\"created_by\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_panel\")\n}\n\nmodel PluginContextMenu {\n  id               String    @id @default(cuid())\n  tableId          String    @map(\"table_id\")\n  pluginInstallId  String    @unique @map(\"plugin_install_id\")\n  order            Float     @map(\"order\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  table TableMeta @relation(fields: [tableId], references: [id], onDelete: Cascade)\n\n  @@map(\"plugin_context_menu\")\n}\n\nmodel UserLastVisit {\n  id               String   @id @default(cuid())\n  userId           String   @map(\"user_id\")\n  resourceType     String   @map(\"resource_type\")\n  resourceId       String   @map(\"resource_id\")\n  parentResourceId String   @map(\"parent_resource_id\")\n  lastVisitTime    DateTime @default(now()) @map(\"last_visit_time\")\n\n  @@unique([userId, resourceType, resourceId])\n  @@index([userId, resourceType, parentResourceId])\n  @@map(\"user_last_visit\")\n}\n\nmodel Template {\n  id                  String    @id @default(cuid())\n  baseId              String?   @map(\"base_id\")\n  cover               String?\n  name                String?\n  description         String?\n  markdownDescription String?   @map(\"markdown_description\")\n  categoryId          String[]  @map(\"category_id\")\n  createdTime         DateTime  @default(now()) @map(\"created_time\")\n  createdBy           String    @map(\"created_by\")\n  lastModifiedTime    DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy      String?   @map(\"last_modified_by\")\n  isSystem            Boolean?  @map(\"is_system\")\n  isPublished         Boolean?  @map(\"is_published\")\n  featured            Boolean?  @map(\"featured\")\n  snapshot            String?   @map(\"snapshot\")\n  order               Float     @map(\"order\")\n  usageCount          Int       @default(0) @map(\"usage_count\")\n  publishInfo         Json?     @map(\"publish_info\")\n  visitCount          Int       @default(0) @map(\"visit_count\")\n\n  @@unique([baseId])\n  @@map(\"template\")\n}\n\nmodel TemplateCategory {\n  id               String    @id @default(cuid())\n  name             String    @unique\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n  order            Float     @map(\"order\")\n\n  @@map(\"template_category\")\n}\n\nmodel Task {\n  id               String    @id @default(cuid())\n  type             String    @map(\"type\")\n  status           String    @map(\"status\")\n  snapshot         String?   @map(\"snapshot\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedBy   String?   @map(\"last_modified_by\")\n\n  runs TaskRun[]\n\n  @@index([type, status])\n  @@map(\"task\")\n}\n\nmodel TaskRun {\n  id               String    @id @default(cuid())\n  taskId           String    @map(\"task_id\")\n  status           String    @map(\"status\")\n  snapshot         String    @map(\"snapshot\")\n  spent            Int?      @map(\"spent\")\n  log              String?   @map(\"log\")\n  errorMsg         String?   @map(\"error_msg\")\n  startedTime      DateTime? @map(\"started_time\")\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)\n\n  @@index([taskId, status])\n  @@map(\"task_run\")\n}\n\nmodel TaskReference {\n  id          String   @id @default(cuid())\n  fromFieldId String   @map(\"from_field_id\")\n  toFieldId   String   @map(\"to_field_id\")\n  createdTime DateTime @default(now()) @map(\"created_time\")\n\n  @@unique([toFieldId, fromFieldId])\n  @@index([fromFieldId])\n  @@index([toFieldId])\n  @@map(\"task_reference\")\n}\n\nmodel Waitlist {\n  email       String    @unique @map(\"email\")\n  invite      Boolean?  @map(\"invite\")\n  inviteTime  DateTime? @map(\"invite_time\")\n  createdTime DateTime  @default(now()) @map(\"created_time\")\n\n  @@map(\"waitlist\")\n}\n\n\nmodel BaseNode {\n  id                String     @id @default(cuid())\n  parentId          String?    @map(\"parent_id\")\n  baseId            String     @map(\"base_id\")\n  resourceType      String     @map(\"resource_type\")\n  resourceId        String     @map(\"resource_id\")\n  order             Float      @map(\"order\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  parent            BaseNode?  @relation(\"NodeToChildren\", fields: [parentId], references: [id])\n  children          BaseNode[] @relation(\"NodeToChildren\")\n\n  @@unique([baseId, resourceType, resourceId])\n\n  @@map(\"base_node\")\n}\n\nmodel BaseNodeFolder {\n  id                String     @id @default(cuid())\n  baseId            String     @map(\"base_id\")\n  name              String     @map(\"name\")\n\n  createdTime       DateTime   @default(now()) @map(\"created_time\")\n  createdBy         String     @map(\"created_by\")\n  lastModifiedTime  DateTime?  @updatedAt      @map(\"last_modified_time\")\n  lastModifiedBy    String?    @map(\"last_modified_by\")\n\n  @@unique([baseId, name])\n\n  @@map(\"base_node_folder\")\n}\n\nmodel BaseShare {\n  id               String    @id @default(cuid())\n  baseId           String    @map(\"base_id\")\n  shareId          String    @unique @map(\"share_id\")\n  password         String?\n  nodeId           String    @unique @map(\"node_id\")\n  allowSave        Boolean?  @map(\"allow_save\")\n  allowCopy        Boolean?  @map(\"allow_copy\")\n  enabled          Boolean   @default(true)\n  createdTime      DateTime  @default(now()) @map(\"created_time\")\n  createdBy        String    @map(\"created_by\")\n  lastModifiedTime DateTime? @updatedAt @map(\"last_modified_time\")\n\n  @@index([baseId])\n  @@map(\"base_share\")\n}\n"
  },
  {
    "path": "packages/db-main-prisma/src/index.ts",
    "content": "export * from '@prisma/client';\nexport * from './utils';\nexport * from './prisma.module';\nexport * from './prisma.service';\n"
  },
  {
    "path": "packages/db-main-prisma/src/prisma.module.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Provider } from '@nestjs/common';\nimport { Global, Module } from '@nestjs/common';\nimport { ClsService } from 'nestjs-cls';\nimport { PrismaService } from './prisma.service';\n\nexport const PrismaProvider: Provider = {\n  provide: PrismaService,\n  useFactory: async (cls: ClsService<any>) => {\n    return new PrismaService(cls);\n  },\n  inject: [ClsService],\n};\n\n@Global()\n@Module({\n  providers: [PrismaProvider],\n  exports: [PrismaProvider],\n})\nexport class PrismaModule {}\n"
  },
  {
    "path": "packages/db-main-prisma/src/prisma.service.ts",
    "content": "import type { OnModuleInit } from '@nestjs/common';\nimport { Injectable, Logger } from '@nestjs/common';\nimport { Prisma, PrismaClient } from '@prisma/client';\nimport { nanoid } from 'nanoid';\nimport type { ClsService } from 'nestjs-cls';\nimport { TimeoutHttpException } from './utils';\n\ninterface ITx {\n  client?: Prisma.TransactionClient;\n  timeStr?: string;\n  id?: string;\n  rawOpMaps?: unknown;\n}\n\nfunction proxyClient(tx: Prisma.TransactionClient) {\n  return new Proxy(tx, {\n    get(target, p) {\n      if (p === '$queryRawUnsafe' || p === '$executeRawUnsafe') {\n        return async function (query: string, ...args: unknown[]) {\n          try {\n            return await target[p](query, ...args);\n          } catch (e: unknown) {\n            if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2028') {\n              throw new TimeoutHttpException();\n            }\n            throw e;\n          }\n        };\n      }\n      return target[p as keyof typeof target];\n    },\n  });\n}\n\n@Injectable()\nexport class PrismaService\n  extends PrismaClient<Prisma.PrismaClientOptions, 'query'>\n  implements OnModuleInit\n{\n  private readonly logger = new Logger(PrismaService.name);\n\n  private afterTxCb?: () => void;\n\n  // Default transaction options from environment variables\n  // Prisma's built-in defaults: timeout=5000ms, maxWait=2000ms\n  private readonly defaultTxTimeout = Number(process.env.PRISMA_TRANSACTION_TIMEOUT ?? 5000);\n  private readonly defaultTxMaxWait = Number(process.env.PRISMA_TRANSACTION_MAX_WAIT ?? 2000);\n\n  constructor(private readonly cls: ClsService<{ tx: ITx }>) {\n    const logConfig = {\n      log: [\n        // {\n        //   level: 'query',\n        //   emit: 'event',\n        // },\n        {\n          level: 'error',\n          emit: 'stdout',\n        },\n        // {\n        //   level: 'info',\n        //   emit: 'stdout',\n        // },\n        // {\n        //   level: 'warn',\n        //   emit: 'stdout',\n        // },\n      ],\n    };\n    const initialConfig = process.env.NODE_ENV === 'production' ? {} : { ...logConfig };\n\n    super(initialConfig);\n\n    // Log transaction timeout configuration on startup (must be after super())\n    console.log(\n      `[PrismaService] Transaction defaults: timeout=${this.defaultTxTimeout}ms, maxWait=${this.defaultTxMaxWait}ms (from env: PRISMA_TRANSACTION_TIMEOUT=${process.env.PRISMA_TRANSACTION_TIMEOUT}, PRISMA_TRANSACTION_MAX_WAIT=${process.env.PRISMA_TRANSACTION_MAX_WAIT})`\n    );\n  }\n\n  bindAfterTransaction(fn: () => void) {\n    this.afterTxCb = fn;\n  }\n\n  /**\n   * Executes a transaction using the provided function and options.\n   * If a transaction client is already defined in the current context, the function is executed using it.\n   * Otherwise, a new transaction is created and the function is executed using it.\n   * @param fn The function to execute within the transaction.\n   * @param options The options to use when creating the transaction.\n   * @returns The result of the executed function.\n   */\n  async $tx<R = unknown>(\n    fn: (prisma: Prisma.TransactionClient) => Promise<R>,\n    options?: {\n      maxWait?: number;\n      timeout?: number;\n      isolationLevel?: Prisma.TransactionIsolationLevel;\n    }\n  ): Promise<R> {\n    let result: R = undefined as R;\n    const txClient = this.cls.get('tx.client');\n    if (txClient) {\n      return await fn(txClient);\n    }\n\n    // Apply default timeout and maxWait from environment if not explicitly provided\n    const txOptions = {\n      timeout: options?.timeout ?? this.defaultTxTimeout,\n      maxWait: options?.maxWait ?? this.defaultTxMaxWait,\n      ...(options?.isolationLevel && { isolationLevel: options.isolationLevel }),\n    };\n\n    await this.cls.runWith(this.cls.get(), async () => {\n      result = await super.$transaction<R>(async (prisma) => {\n        prisma = proxyClient(prisma);\n        this.cls.set('tx.client', prisma);\n        this.cls.set('tx.id', nanoid());\n        this.cls.set('tx.timeStr', new Date().toISOString());\n        try {\n          // can not delete await here\n          return await fn(prisma);\n        } finally {\n          this.cls.set('tx.client', undefined);\n          this.cls.set('tx.id', undefined);\n          this.cls.set('tx.timeStr', undefined);\n        }\n      }, txOptions);\n      this.afterTxCb?.();\n    });\n\n    return result;\n  }\n\n  txClient(): Prisma.TransactionClient {\n    const txClient = this.cls.get('tx.client');\n    if (!txClient) {\n      // console.log('transactionId', 'none');\n      return this;\n    }\n    // const id = this.cls.get('tx.id');\n    // console.log('transactionId', id);\n    return txClient;\n  }\n\n  async onModuleInit() {\n    await this.$connect();\n\n    if (process.env.NODE_ENV === 'production') return;\n\n    this.$on('query', async (e) => {\n      this.logger.debug({\n        // Query: e.query.trim().replace(/\\s+/g, ' ').replace(/\\( /g, '(').replace(/ \\)/g, ')'),\n        Query: e.query,\n        Params: e.params,\n        Duration: `${e.duration} ms`,\n      });\n    });\n  }\n\n  async onModuleDestroy() {\n    await this.$disconnect();\n  }\n}\n"
  },
  {
    "path": "packages/db-main-prisma/src/seeds/e2e/space-seeds.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Prisma } from '../../';\nimport { AbstractSeed } from '../seed.abstract';\nimport { CREATE_USER_NUM, generateUser } from './user-seeds';\n\nconst userId = 'usrTestUserId';\n\nconst spaceId = 'spcTestSpaceId';\nconst spaceName = 'test space';\n\nconst baseId = 'bseTestBaseId';\nconst baseName = 'test base';\n\nconst collaboratorId = 'usrTestCollaboratorId';\nconst generateSpace = (): Prisma.SpaceCreateInput => {\n  return {\n    id: spaceId,\n    name: spaceName,\n    createdBy: userId,\n    lastModifiedBy: userId,\n  };\n};\n\nconst generateBase = (): Prisma.BaseCreateInput => {\n  return {\n    id: baseId,\n    name: baseName,\n    order: 1,\n    createdBy: userId,\n    space: {\n      connect: {\n        id: spaceId,\n      },\n    },\n    lastModifiedBy: userId,\n  };\n};\n\nexport const generateCollaborator = async (\n  connectUserNum: number\n): Promise<Prisma.CollaboratorUncheckedCreateInput[]> => {\n  const userSets = await generateUser(connectUserNum);\n\n  return Array.from({ length: connectUserNum + 1 }, (_, i) => ({\n    id: `${collaboratorId}_${i}`,\n    resourceId: spaceId,\n    resourceType: 'space',\n    roleName: 'owner',\n    principalId: userSets[i].id!,\n    principalType: 'user',\n    createdBy: userSets[i].id!,\n  }));\n};\n\nexport class SpaceSeeds extends AbstractSeed {\n  execute = async (): Promise<void> => {\n    await this.prisma.$transaction(async (tx) => {\n      // Space\n      await this.createSpace(tx);\n\n      // Base\n      await this.createBase(tx);\n\n      // Collaborator\n      await this.createCollaborator(tx);\n    });\n  };\n\n  private async createSpace(tx: Prisma.TransactionClient) {\n    const { id: spaceId, ...spaceNonUnique } = generateSpace();\n    const space = await tx.space.upsert({\n      where: { id: spaceId },\n      update: spaceNonUnique,\n      create: { id: spaceId, ...spaceNonUnique },\n    });\n    this.log('UPSERT', `Space ${space.id} - ${space.name}`);\n  }\n\n  private async createBase(tx: Prisma.TransactionClient) {\n    const { id: baseId, ...baseNonUnique } = generateBase();\n    const base = await tx.base.upsert({\n      where: { id: baseId },\n      update: baseNonUnique,\n      create: { id: baseId, ...baseNonUnique },\n    });\n    this.log('UPSERT', `Base ${base.id} - ${base.name}`);\n\n    if (this.driver !== 'sqlite3') {\n      await tx.$executeRawUnsafe(`create schema if not exists \"${baseId}\"`);\n      await tx.$executeRawUnsafe(`revoke all on schema \"${baseId}\" from public`);\n    }\n  }\n\n  private async createCollaborator(tx: Prisma.TransactionClient) {\n    const collaboratorSets = await generateCollaborator(CREATE_USER_NUM);\n    for (const c of collaboratorSets) {\n      const { id, resourceId, principalId, ...collaboratorNonUnique } = c;\n      const collaborator = await tx.collaborator.upsert({\n        where: { id, resourceId, resourceType: 'space', principalId },\n        update: collaboratorNonUnique,\n        create: c,\n      });\n      this.log(\n        'UPSERT',\n        `Collaborator ${collaborator.id} - ${collaborator.resourceId} - ${collaborator.principalId}`\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "packages/db-main-prisma/src/seeds/e2e/user-seeds.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport bcrypt from 'bcrypt';\nimport type { Prisma } from '../../';\nimport { AbstractSeed } from '../seed.abstract';\n\nexport const CREATE_USER_NUM = 1;\n\nconst generatePassword = async (password: string) => {\n  const salt = await bcrypt.genSalt(10);\n  const hashPassword = await bcrypt.hash(password, salt);\n  return { salt, password: hashPassword };\n};\n\nconst createUser = (\n  baseId: string,\n  baseName: string,\n  pas: any,\n  index?: number\n): Prisma.UserCreateInput => {\n  const id = index === undefined ? baseId : `${baseId}_${index}`;\n  return {\n    id,\n    name: index === undefined ? baseName : `${baseName}_${index}`,\n    email: index === undefined ? `${baseName}@e2e.com` : `${baseName}_${index}@e2e.com`,\n    salt: pas.salt,\n    password: pas.password,\n    notifyMeta: JSON.stringify({ email: true }),\n    avatar: `avatar/${id}`,\n    isAdmin: index === undefined,\n  };\n};\n\nexport const generateUser = async (max: number): Promise<Prisma.UserCreateInput[]> => {\n  const userId = 'usrTestUserId';\n  const userName = 'test';\n  const pas = await generatePassword('12345678');\n\n  return Array.from({ length: max + 1 }, (_, i) =>\n    createUser(userId, userName, pas, i === 0 ? undefined : i)\n  );\n};\n\nexport class UserSeeds extends AbstractSeed {\n  execute = async (): Promise<void> => {\n    const userSets = await generateUser(CREATE_USER_NUM);\n\n    for (const u of userSets) {\n      const { id, name, email, ...userNonUnique } = u;\n      const user = await this.prisma.user.upsert({\n        where: { email },\n        update: userNonUnique,\n        create: u,\n      });\n      this.log('UPSERT', `User ${user.id} - ${user.email} - ${user.password}`);\n    }\n  };\n}\n"
  },
  {
    "path": "packages/db-main-prisma/src/seeds/seed.abstract.ts",
    "content": "import type { PrismaClient } from '@prisma/client';\n\nexport abstract class AbstractSeed {\n  constructor(\n    public prisma: PrismaClient,\n    public driver: 'postgresql' | 'sqlite3',\n    public outLog: boolean = false\n  ) {}\n\n  abstract execute(): Promise<void>;\n\n  protected log = (operation: 'UPSERT' | 'CREATE' | 'UPDATE', msg: string) => {\n    (process.env.CI || this.outLog) && console.log(`${operation}: ${msg}`);\n  };\n}\n"
  },
  {
    "path": "packages/db-main-prisma/src/utils.ts",
    "content": "import { HttpException, HttpStatus } from '@nestjs/common';\n\nexport class TimeoutHttpException extends HttpException {\n  code: string;\n  data?: { localization?: { i18nKey: string; context?: Record<string, unknown> } };\n\n  constructor() {\n    super('Request timeout', HttpStatus.REQUEST_TIMEOUT);\n    this.code = 'request_timeout';\n    this.data = {\n      localization: {\n        i18nKey: 'httpErrors.custom.requestTimeout',\n        context: {},\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "packages/db-main-prisma/tsconfig.eslint.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"allowJs\": true\n  },\n  \"exclude\": [\"node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/db-main-prisma/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"types\": [\"node\"],\n    \"baseUrl\": \"./src\",\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\"],\n    \"module\": \"CommonJS\",\n    \"noEmit\": false,\n    \"incremental\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"experimentalDecorators\": true\n  },\n  \"include\": [\"./src\"],\n  \"exclude\": [\"**/node_modules\", \"**/.*/\", \"./dist\", \"./build\"]\n}\n"
  },
  {
    "path": "packages/eslint-config-bases/.eslintrc.cjs",
    "content": "const { getDefaultIgnorePatterns } = require('./src/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns()],\n  extends: ['./src/bases/typescript', './src/bases/prettier-plugin', './src/bases/mdx'],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/.idea/eslint-config-bases.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "packages/eslint-config-bases/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/eslint-config-bases.iml\" filepath=\"$PROJECT_DIR$/.idea/eslint-config-bases.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "packages/eslint-config-bases/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2025 Teable, Inc.\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": "packages/eslint-config-bases/lint-staged.config.js",
    "content": "// @ts-check\n\n/**\n * This files overrides the base lint-staged.config.js present in the root directory.\n * It allows to run eslint based the package specific requirements.\n * {@link https://github.com/okonet/lint-staged#how-to-use-lint-staged-in-a-multi-package-monorepo}\n * {@link https://github.com/teableio/teable/blob/main/docs/about-lint-staged.md}\n */\n\nconst { concatFilesForPrettier, getEslintFixCmd } = require('../../lint-staged.common.js');\n\n/**\n * @type {Record<string, (filenames: string[]) => string | string[] | Promise<string | string[]>>}\n */\nconst rules = {\n  '**/*.{js,jsx,ts,tsx,mjs,cjs}': (filenames) => {\n    return getEslintFixCmd({\n      cwd: __dirname,\n      fix: true,\n      cache: true,\n      // when autofixing staged-files a good tip is to disable react-hooks/exhaustive-deps, cause\n      // a change here can potentially break things without proper visibility.\n      rules: ['react-hooks/exhaustive-deps: off'],\n      maxWarnings: 25,\n      files: filenames,\n    });\n  },\n  '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => {\n    return [`prettier --write ${concatFilesForPrettier(filenames)}`];\n  },\n};\n\nmodule.exports = rules;\n"
  },
  {
    "path": "packages/eslint-config-bases/package.json",
    "content": "{\n  \"name\": \"@teable/eslint-config-bases\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/eslint-config-bases\"\n  },\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"type\": \"commonjs\",\n  \"main\": \"./src/index.js\",\n  \"files\": [\n    \"src\"\n  ],\n  \"exports\": {\n    \".\": {\n      \"require\": \"./src/index.js\"\n    },\n    \"./patch/modern-module-resolution\": {\n      \"require\": \"./src/patch/modern-module-resolution.js\"\n    },\n    \"./helpers\": {\n      \"require\": \"./src/helpers/index.js\"\n    },\n    \"./mdx\": {\n      \"require\": \"./src/bases/mdx.js\"\n    },\n    \"./jest\": {\n      \"require\": \"./src/bases/jest.js\"\n    },\n    \"./playwright\": {\n      \"require\": \"./src/bases/playwright.js\"\n    },\n    \"./prettier-config\": {\n      \"require\": \"./src/bases/prettier-config.js\"\n    },\n    \"./prettier-plugin\": {\n      \"require\": \"./src/bases/prettier-plugin.js\"\n    },\n    \"./react\": {\n      \"require\": \"./src/bases/react.js\"\n    },\n    \"./react-query\": {\n      \"require\": \"./src/bases/react-query.js\"\n    },\n    \"./rtl\": {\n      \"require\": \"./src/bases/rtl.js\"\n    },\n    \"./regexp\": {\n      \"require\": \"./src/bases/regexp.js\"\n    },\n    \"./sonar\": {\n      \"require\": \"./src/bases/sonar.js\"\n    },\n    \"./storybook\": {\n      \"require\": \"./src/bases/storybook.js\"\n    },\n    \"./tailwind\": {\n      \"require\": \"./src/bases/tailwind.js\"\n    },\n    \"./typescript\": {\n      \"require\": \"./src/bases/typescript.js\"\n    }\n  },\n  \"scripts\": {\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./.eslintcache\",\n    \"lint\": \"eslint . --ext .ts,.js,.cjs,.mjs --cache --cache-location ../../.cache/eslint/eslint-config-bases.eslintcache\",\n    \"typecheck\": \"tsc --project tsconfig.json --noEmit\",\n    \"fix-all-files\": \"eslint  --ext .ts,.tsx,.js,.jsx --fix\"\n  },\n  \"dependencies\": {\n    \"@rushstack/eslint-patch\": \"1.8.0\",\n    \"@tanstack/eslint-plugin-query\": \"5.91.2\",\n    \"@typescript-eslint/eslint-plugin\": \"7.3.1\",\n    \"@typescript-eslint/parser\": \"7.3.1\",\n    \"eslint-config-prettier\": \"9.1.0\",\n    \"eslint-import-resolver-typescript\": \"3.6.1\",\n    \"eslint-plugin-import\": \"2.29.1\",\n    \"eslint-plugin-jest\": \"27.9.0\",\n    \"eslint-plugin-jest-formatting\": \"3.1.0\",\n    \"eslint-plugin-jsx-a11y\": \"6.8.0\",\n    \"eslint-plugin-playwright\": \"1.5.4\",\n    \"eslint-plugin-prettier\": \"5.1.3\",\n    \"eslint-plugin-react\": \"7.34.1\",\n    \"eslint-plugin-react-hooks\": \"4.6.0 || 5.0.0-canary-7118f5dd7-20230705\",\n    \"eslint-plugin-regexp\": \"2.4.0\",\n    \"eslint-plugin-sonarjs\": \"0.24.0\",\n    \"eslint-plugin-storybook\": \"0.8.0\",\n    \"eslint-plugin-testing-library\": \"6.2.0\"\n  },\n  \"peerDependencies\": {\n    \"eslint\": \"^8.55.0\",\n    \"eslint-plugin-mdx\": \"^2.2.0 || ^3.0.0\",\n    \"eslint-plugin-tailwindcss\": \"^3.13.0\",\n    \"prettier\": \"^3.0.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"typescript\": \"^5.1.6\"\n  },\n  \"peerDependenciesMeta\": {\n    \"eslint-plugin-mdx\": {\n      \"optional\": true\n    },\n    \"eslint-plugin-tailwindcss\": {\n      \"optional\": true\n    },\n    \"prettier\": {\n      \"optional\": true\n    },\n    \"react\": {\n      \"optional\": true\n    },\n    \"react-dom\": {\n      \"optional\": true\n    },\n    \"tailwindcss\": {\n      \"optional\": true\n    },\n    \"typescript\": {\n      \"optional\": true\n    }\n  },\n  \"devDependencies\": {\n    \"@testing-library/jest-dom\": \"6.4.2\",\n    \"@testing-library/react\": \"14.2.2\",\n    \"@types/node\": \"22.18.0\",\n    \"@types/react\": \"18.3.18\",\n    \"@types/react-dom\": \"18.3.5\",\n    \"es-check\": \"7.1.1\",\n    \"eslint\": \"8.57.0\",\n    \"eslint-plugin-mdx\": \"3.1.5\",\n    \"eslint-plugin-tailwindcss\": \"3.15.1\",\n    \"react\": \"18.3.1\",\n    \"react-dom\": \"18.3.1\",\n    \"rimraf\": \"5.0.5\",\n    \"tailwindcss\": \"3.4.1\",\n    \"typescript\": \"5.4.3\"\n  }\n}\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/index.js",
    "content": "module.exports = {\n  jest: require('./jest'),\n  mdx: require('./mdx'),\n  playwright: require('./playwright'),\n  'prettier-plugin': require('./prettier-plugin'),\n  'prettier-config': require('./prettier-config'),\n  react: require('./react'),\n  regexp: require('./regexp'),\n  reactQuery: require('./react-query'),\n  reactTestingLibrary: require('./rtl'),\n  sonar: require('./sonar'),\n  storybook: require('./storybook'),\n  tailwind: require('./tailwind'),\n  typescript: require('./typescript'),\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/jest.js",
    "content": "/**\n * Custom config base for projects using jest.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst jestPatterns = {\n  files: ['**/?(*.)+(test).{js,jsx,ts,tsx}'],\n};\n\nmodule.exports = {\n  env: {\n    es6: true,\n    node: true,\n  },\n  settings: {\n    // To prevent autodetection issues in monorepos or via vitest\n    jest: {\n      version: 'latest',\n    },\n  },\n  overrides: [\n    {\n      // Perf: To ensure best performance enable eslint-plugin-jest for test files only.\n      files: jestPatterns.files,\n      // @see https://github.com/jest-community/eslint-plugin-jest\n      extends: ['plugin:jest/recommended'],\n      rules: {\n        // Relax rules that are known to be slow and less useful in a test context\n        'import/namespace': 'off',\n        'import/default': 'off',\n        'import/no-duplicates': 'off',\n        // Relax rules that makes writing tests easier\n        'import/no-named-as-default-member': 'off',\n        '@typescript-eslint/no-non-null-assertion': 'off',\n        '@typescript-eslint/no-object-literal-type-assertion': 'off',\n        '@typescript-eslint/no-empty-function': 'off',\n        '@typescript-eslint/no-explicit-any': 'off',\n        '@typescript-eslint/ban-ts-comment': 'off',\n        '@typescript-eslint/no-unsafe-member-access': 'off',\n        '@typescript-eslint/no-unsafe-assignment': 'off',\n        // Enable Jest rules\n        'jest/no-focused-tests': 'error',\n        'jest/prefer-mock-promise-shorthand': 'error',\n        'jest/no-commented-out-tests': 'error',\n        'jest/prefer-hooks-in-order': 'error',\n        'jest/prefer-hooks-on-top': 'error',\n        'jest/no-conditional-in-test': 'error',\n        'jest/no-duplicate-hooks': 'error',\n        'jest/no-test-return-statement': 'error',\n        'jest/prefer-strict-equal': 'error',\n        'jest/prefer-to-have-length': 'error',\n        'jest/consistent-test-it': ['error', { fn: 'it' }],\n        // https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/unbound-method.md\n        '@typescript-eslint/unbound-method': 'off',\n        'jest/unbound-method': 'error',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/mdx.js",
    "content": "/**\n * Opinionated config base for https://github.com/mdx-js/eslint-mdx\n * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases\n */\n\nconst mdxPatterns = {\n  files: ['*.mdx'],\n};\n\nmodule.exports = {\n  overrides: [\n    {\n      // For performance enable this only on mdx files\n      files: mdxPatterns.files,\n      extends: ['plugin:mdx/recommended', 'plugin:@typescript-eslint/disable-type-checked'],\n      parser: 'eslint-mdx',\n      parserOptions: {\n        project: null,\n      },\n      rules: {\n        '@typescript-eslint/consistent-type-exports': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/playwright.js",
    "content": "/**\n * Opinionated config base for projects using playwright.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst playwrightPatterns = {\n  files: ['**/e2e/**/*.test.{js,ts}'],\n};\n\nmodule.exports = {\n  overrides: [\n    {\n      // To ensure best performance enable only on e2e test files\n      files: playwrightPatterns.files,\n      // @see https://github.com/playwright-community/eslint-plugin-playwright\n      extends: ['plugin:playwright/recommended'],\n      rules: {\n        '@typescript-eslint/no-non-null-assertion': 'off',\n        '@typescript-eslint/no-object-literal-type-assertion': 'off',\n        '@typescript-eslint/no-empty-function': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/prettier-config.js",
    "content": "/**\n * Custom config base for projects using prettier.\n * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases\n */\n\nmodule.exports = {\n  extends: ['prettier'],\n  rules: {\n    'arrow-body-style': 'off',\n    'prefer-arrow-callback': 'off',\n  },\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/prettier-plugin.js",
    "content": "/**\n * Custom config base for projects using prettier.\n * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases\n */\n\nconst { getPrettierConfig } = require('../helpers');\nconst { ...prettierConfig } = getPrettierConfig();\n\nmodule.exports = {\n  extends: ['prettier'],\n  plugins: ['prettier'],\n  rules: {\n    'prettier/prettier': ['error', prettierConfig],\n    'arrow-body-style': 'off',\n    'prefer-arrow-callback': 'off',\n  },\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/react-query.js",
    "content": "/**\n * Opinionated config base for projects using react.\n * @see https://github.com/belgattitude/nextjs-monorepo-example/tree/main/packages/eslint-config-bases\n */\n\nconst reactPatterns = {\n  files: ['*.{jsx,tsx}'],\n};\n\n/**\n * Fine-tune naming convention react typescript jsx (function components)\n * @link https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md\n */\n\nmodule.exports = {\n  overrides: [\n    {\n      files: [...reactPatterns.files],\n      extends: [\n        // @see https://tanstack.com/query/v4/docs/react/eslint/eslint-plugin-query\n        'plugin:@tanstack/eslint-plugin-query/recommended',\n      ],\n      // rules: { },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/react.js",
    "content": "/**\n * Opinionated config base for projects using react.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst reactPatterns = {\n  files: ['*.{jsx,tsx}'],\n};\n\nconst stylesPatterns = {\n  files: ['*.styles.{js,ts}', 'styles.{js,ts}'],\n};\n\n/**\n * Fine-tune naming convention react typescript jsx (function components)\n * @link https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md\n */\n\nmodule.exports = {\n  env: {\n    browser: true,\n    es6: true,\n    node: true,\n  },\n  settings: {\n    react: {\n      version: 'detect',\n    },\n  },\n  overrides: [\n    {\n      files: [...reactPatterns.files, ...stylesPatterns.files],\n      extends: [\n        // @see https://github.com/yannickcr/eslint-plugin-react\n        'plugin:react/recommended',\n        // @see https://www.npmjs.com/package/eslint-plugin-react-hooks\n        'plugin:react-hooks/recommended',\n        // @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y\n        'plugin:jsx-a11y/recommended',\n      ],\n      rules: {\n        // https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md\n        'react/no-unknown-property': ['error', { ignore: ['css'] }],\n        // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-unescaped-entities.md\n        'react/no-unescaped-entities': ['error', { forbid: ['>'] }],\n        'react/prop-types': 'off',\n        'react/react-in-jsx-scope': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/regexp.js",
    "content": "/**\n * Custom config base for projects that wants to enable regexp rules.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst regexpPatterns = {\n  files: ['*.{js,jsx,jsx,tsx}'],\n};\n\nmodule.exports = {\n  // @see https://github.com/ota-meshi/eslint-plugin-regexp\n  extends: ['plugin:regexp/recommended'],\n  overrides: [\n    {\n      // To ensure best performance enable only on e2e test files\n      files: regexpPatterns.files,\n      extends: ['plugin:regexp/recommended'],\n      rules: {\n        'regexp/prefer-result-array-groups': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/rtl.js",
    "content": "/**\n * Opinionated config base for projects using react-testing-library\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst rtlPatterns = {\n  files: ['**/?(*.)+(test).{js,jsx,ts,tsx}'],\n};\n\nmodule.exports = {\n  env: {\n    browser: true,\n    es6: true,\n    node: true,\n  },\n  overrides: [\n    {\n      // For performance enable react-testing-library only on test files\n      files: rtlPatterns.files,\n      extends: ['plugin:testing-library/react'],\n    },\n    {\n      files: ['**/test-utils.tsx'],\n      rules: {\n        '@typescript-eslint/explicit-module-boundary-types': 'off',\n        'import/export': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/sonar.js",
    "content": "/**\n * Opinionated config base for projects that enable sonarjs\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst sonarPatterns = {\n  files: ['*.{js,jsx,ts,tsx}'],\n  excludedFiles: ['**/?(*.)+(test).{js,jsx,ts,tsx}', '*.stories.{js,ts,jsx,tsx}'],\n};\n\nmodule.exports = {\n  env: {\n    browser: true,\n    es6: true,\n    node: true,\n  },\n  overrides: [\n    {\n      files: sonarPatterns.files,\n      excludedFiles: sonarPatterns.excludedFiles,\n      extends: ['plugin:sonarjs/recommended'],\n      rules: {\n        'sonarjs/no-nested-template-literals': 'off',\n        'sonarjs/prefer-single-boolean-return': 'off',\n      },\n    },\n    {\n      files: ['*.{jsx,tsx}'],\n      rules: {\n        // relax complexity for react code\n        'sonarjs/cognitive-complexity': ['error', 15],\n        // relax duplicate strings\n        'sonarjs/no-duplicate-string': 'off',\n      },\n    },\n    {\n      // relax build/test tool config files as they often contain repeated configuration strings\n      files: [\n        'vitest*.config.{ts,mts}',\n        'vite.config.{ts,mts}',\n        'tsdown.config.{ts,mts}',\n        'webpack.config.{ts,mts}',\n      ],\n      rules: {\n        'sonarjs/no-duplicate-string': 'off',\n      },\n    },\n    {\n      // relax javascript code as it often contains obscure configs\n      files: ['*.js', '*.cjs'],\n      parser: 'espree',\n      parserOptions: {\n        ecmaVersion: 2020,\n      },\n      rules: {\n        'sonarjs/no-duplicate-string': 'off',\n        'sonarjs/no-all-duplicated-branches': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/storybook.js",
    "content": "/**\n * Opinionated config base for projects using storybook.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst storybookPatterns = {\n  files: ['**/*.stories.{ts,tsx,mdx}'],\n};\n\nmodule.exports = {\n  env: {\n    browser: true,\n    es6: true,\n    node: true,\n  },\n  overrides: [\n    {\n      // For performance run storybook/recommended on test files, not regular code\n      files: storybookPatterns.files,\n      extends: ['plugin:storybook/recommended'],\n      rules: {},\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/tailwind.js",
    "content": "/**\n * Opinionated config base for projects using react.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nconst reactPatterns = {\n  files: ['*.{jsx,tsx}'],\n};\n\n/**\n * Fine-tune naming convention react typescript jsx (function components)\n * @link https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md\n */\n\nmodule.exports = {\n  env: {\n    browser: true,\n    es6: true,\n    node: true,\n  },\n  overrides: [\n    {\n      files: [...reactPatterns.files],\n      extends: [\n        // @see https://github.com/francoismassart/eslint-plugin-tailwindcss,\n        'plugin:tailwindcss/recommended',\n      ],\n      rules: {\n        'tailwindcss/no-custom-classname': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/bases/typescript.js",
    "content": "/**\n * Custom config base for projects using typescript / javascript.\n * @see https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\n\nmodule.exports = {\n  env: {\n    es6: true,\n    node: true,\n  },\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaFeatures: {\n      jsx: true,\n      globalReturn: false,\n    },\n    ecmaVersion: 2020,\n    project: ['tsconfig.json'],\n    sourceType: 'module',\n  },\n  settings: {\n    'import/parsers': {\n      '@typescript-eslint/parser': ['.ts', '.tsx', '.mts'],\n    },\n    'import/resolver': {\n      typescript: {},\n    },\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n    'plugin:import/recommended',\n    'plugin:import/typescript',\n\n    // 'plugin:react-hooks/recommended',\n    // 'eslint:recommended',\n    // 'plugin:import/recommended',\n    // 'plugin:import/typescript',\n    // 'plugin:@typescript-eslint/recommended-type-checked',\n    // 'plugin:@typescript-eslint/stylistic-type-checked',\n  ],\n  rules: {\n    'spaced-comment': [\n      'error',\n      'always',\n      {\n        line: {\n          markers: ['/'],\n          exceptions: ['-', '+'],\n        },\n        block: {\n          markers: ['!'],\n          exceptions: ['*'],\n          balanced: true,\n        },\n      },\n    ],\n    'linebreak-style': ['error', 'unix'],\n    'no-empty-function': 'off',\n    'import/default': 'off',\n    // Caution this rule is slow https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/namespace.md\n    'import/namespace': 'off', // ['error'] If you want the extra check (typechecking will spot most issues already)\n    // https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-duplicates.md\n    'import/no-duplicates': ['error', { considerQueryString: true }],\n    'import/no-named-as-default-member': 'off',\n    'import/no-named-as-default': 'off',\n    'import/order': [\n      'error',\n      {\n        groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object'],\n        alphabetize: { order: 'asc', caseInsensitive: true },\n      },\n    ],\n    '@typescript-eslint/ban-tslint-comment': ['error'],\n    '@typescript-eslint/ban-ts-comment': [\n      'error',\n      {\n        'ts-expect-error': 'allow-with-description',\n        minimumDescriptionLength: 10,\n        'ts-ignore': true,\n        'ts-nocheck': true,\n        'ts-check': false,\n      },\n    ],\n    '@typescript-eslint/no-explicit-any': ['error', { ignoreRestArgs: false }],\n    '@typescript-eslint/no-empty-function': ['error', { allow: ['private-constructors'] }],\n    '@typescript-eslint/no-unused-vars': [\n      'warn',\n      { argsIgnorePattern: '^_', ignoreRestSiblings: true },\n    ],\n    '@typescript-eslint/consistent-type-exports': 'error',\n    '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],\n    '@typescript-eslint/no-misused-promises': [\n      'error',\n      {\n        checksVoidReturn: {\n          arguments: false,\n          attributes: false,\n        },\n      },\n    ],\n    '@typescript-eslint/naming-convention': [\n      'error',\n      {\n        selector: 'default',\n        format: ['camelCase'],\n        leadingUnderscore: 'forbid',\n        trailingUnderscore: 'forbid',\n      },\n      {\n        selector: 'import',\n        format: ['camelCase', 'PascalCase'],\n      },\n      {\n        selector: 'variable',\n        format: ['camelCase'],\n        leadingUnderscore: 'allow',\n      },\n      // require all global constants to be camelCase or UPPER_CASE\n      // all other variables and functions still need to be camelCase\n      {\n        selector: 'variable',\n        modifiers: ['exported', 'global', 'const'],\n        types: ['boolean', 'string', 'number', 'array'],\n        format: ['UPPER_CASE'],\n      },\n      {\n        selector: ['function'],\n        format: ['camelCase'],\n      },\n      {\n        selector: 'parameter',\n        format: ['camelCase'],\n        leadingUnderscore: 'allow',\n      },\n      // enum members must be in PascalCase. Without this config, enumMember would inherit UPPER_CASE from public static const property\n      {\n        selector: ['enum', 'enumMember'],\n        format: ['PascalCase'],\n      },\n      {\n        selector: 'class',\n        format: ['PascalCase'],\n      },\n      {\n        selector: 'classProperty',\n        format: ['camelCase'],\n        leadingUnderscore: 'allow',\n      },\n      {\n        selector: 'objectLiteralProperty',\n        format: [\n          'camelCase',\n          // Some external libraries use snake_case for params\n          'snake_case',\n          // Env variables are generally uppercase\n          'UPPER_CASE',\n          // DB / Graphql might use PascalCase for relationships\n          'PascalCase',\n        ],\n        leadingUnderscore: 'allowSingleOrDouble',\n        trailingUnderscore: 'allowSingleOrDouble',\n      },\n      {\n        selector: ['typeAlias', 'interface'],\n        format: ['PascalCase'],\n        prefix: ['I'],\n      },\n      {\n        selector: ['typeProperty'],\n        format: ['camelCase'],\n        // For graphql __typename\n        leadingUnderscore: 'allowDouble',\n      },\n      {\n        selector: ['typeParameter'],\n        format: ['PascalCase'],\n      },\n      // enforce UPPER_CASE for all public static readonly(!) properties\n      {\n        selector: 'classProperty',\n        modifiers: ['public', 'static'],\n        format: ['UPPER_CASE'],\n      },\n      // allow leading underscores for unused parameters, because `tsc --noUnusedParameters` will not flag underscore prefixed parameters\n      // all other rules (trailingUnderscore: forbid, format: camelCase) still apply\n      {\n        selector: 'parameter',\n        modifiers: ['unused'],\n        format: ['camelCase'],\n        leadingUnderscore: 'allow',\n      },\n    ],\n  },\n  overrides: [\n    {\n      files: ['*.d.ts'],\n      rules: {\n        '@typescript-eslint/no-import-type-side-effects': 'off',\n        '@typescript-eslint/no-explicit-any': 'off',\n      },\n    },\n    {\n      files: ['*.mjs'],\n      extends: ['plugin:@typescript-eslint/disable-type-checked'],\n      parserOptions: {\n        ecmaVersion: 'latest',\n        sourceType: 'module',\n      },\n      rules: {\n        '@typescript-eslint/explicit-module-boundary-types': 'off',\n        '@typescript-eslint/consistent-type-exports': 'off',\n        '@typescript-eslint/consistent-type-imports': 'off',\n        '@typescript-eslint/no-unsafe-call': 'off',\n        '@typescript-eslint/no-unsafe-member-access': 'off',\n        '@typescript-eslint/no-unsafe-return': 'off',\n      },\n    },\n    {\n      // javascript commonjs\n      files: ['*.js', '*.cjs'],\n      extends: ['plugin:@typescript-eslint/disable-type-checked'],\n      parser: 'espree',\n      parserOptions: {\n        ecmaVersion: '2020',\n      },\n      rules: {\n        '@typescript-eslint/ban-ts-comment': 'off',\n        '@typescript-eslint/no-explicit-any': 'off',\n        '@typescript-eslint/no-var-requires': 'off',\n        '@typescript-eslint/explicit-module-boundary-types': 'off',\n        '@typescript-eslint/consistent-type-exports': 'off',\n        '@typescript-eslint/consistent-type-imports': 'off',\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/helpers/getDefaultIgnorePatterns.js",
    "content": "const getDefaultIgnorePatterns = () => {\n  // Hacky way to silence @yarnpkg/doctor about node_modules detection\n  return [\n    `${'node'}_modules}`,\n    `**/${'node'}_modules}`,\n    '**/.cache',\n    'build',\n    'dist',\n    'storybook-static',\n    '.yarn',\n    '.turbo',\n    `**/.turbo`,\n    '.out',\n    'next-env.d.ts',\n  ];\n};\n\nmodule.exports = {\n  getDefaultIgnorePatterns,\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/helpers/getPrettierConfig.js",
    "content": "const prettierBaseConfig = require('../prettier.base.config');\n\nconst getPrettierConfig = () => {\n  return prettierBaseConfig;\n};\n\nmodule.exports = {\n  getPrettierConfig,\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/helpers/index.js",
    "content": "const { getDefaultIgnorePatterns } = require('./getDefaultIgnorePatterns');\nconst { getPrettierConfig } = require('./getPrettierConfig');\n\nmodule.exports = {\n  getDefaultIgnorePatterns,\n  getPrettierConfig,\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/src/index.js",
    "content": "const { typescript } = require('./bases');\n\nmodule.exports = typescript;\n"
  },
  {
    "path": "packages/eslint-config-bases/src/patch/modern-module-resolution.js",
    "content": "// See https://www.npmjs.com/package/@rushstack/eslint-patch\n// @ts-ignore\nrequire('@rushstack/eslint-patch/modern-module-resolution');\n"
  },
  {
    "path": "packages/eslint-config-bases/src/prettier.base.config.js",
    "content": "// @ts-check\n\n/**\n * @type {import('prettier').Config}\n */\nmodule.exports = {\n  singleQuote: true,\n  semi: true,\n  printWidth: 100,\n  tabWidth: 2,\n  bracketSpacing: true,\n  trailingComma: 'es5',\n  bracketSameLine: false,\n  useTabs: false,\n  endOfLine: 'lf',\n  overrides: [],\n};\n"
  },
  {
    "path": "packages/eslint-config-bases/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"incremental\": true,\n    \"allowJs\": true,\n    \"checkJs\": true\n  },\n  \"exclude\": [\"node_modules\", \"dist\", \"build\"],\n  \"include\": [\".eslintrc.*\", \"**/*.ts\", \"**/*.js\", \"**/*.cjs\", \"**/*.mjs\", \"**/*.json\"]\n}\n"
  },
  {
    "path": "packages/formula/.eslintrc.cjs",
    "content": "/**\n * Specific eslint rules for this workspace, learn how to compose\n * @link https://github.com/teableio/teable/tree/main/packages/eslint-config-bases\n */\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns(), 'src/parser'],\n  extends: [\n    '@teable/eslint-config-bases/typescript',\n    '@teable/eslint-config-bases/sonar',\n    '@teable/eslint-config-bases/regexp',\n    '@teable/eslint-config-bases/jest',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    // optional overrides per project file match\n  ],\n};\n"
  },
  {
    "path": "packages/formula/.gitignore",
    "content": "# build\n/dist\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n"
  },
  {
    "path": "packages/formula/package.json",
    "content": "{\n  \"name\": \"@teable/formula\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/formula\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"dist/index.cjs\",\n  \"module\": \"dist/index.mjs\",\n  \"types\": \"src/index.ts\",\n  \"exports\": {\n    \".\": {\n      \"@teable/source\": \"./src/index.ts\",\n      \"types\": \"./src/index.ts\",\n      \"import\": \"./dist/index.mjs\",\n      \"module\": \"./dist/index.mjs\",\n      \"require\": \"./dist/index.cjs\"\n    }\n  },\n  \"files\": [\n    \"dist\",\n    \"src\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsdown --tsconfig tsconfig.build.json\",\n    \"dev\": \"tsdown --tsconfig tsconfig.build.json --watch\",\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./tsconfig.build.tsbuildinfo ./.eslintcache\",\n    \"lint\": \"eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --cache --cache-location ../../.cache/eslint/formula.eslintcache\",\n    \"antlr4ts\": \"antlr4ts -visitor -no-listener src/parser/*.g4\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\",\n    \"test\": \"vitest run test-unit\",\n    \"test-unit\": \"vitest run --silent\",\n    \"test-unit-cover\": \"pnpm test-unit --coverage\",\n    \"fix-all-files\": \"eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --fix\"\n  },\n  \"dependencies\": {\n    \"antlr4ts\": \"0.5.0-alpha.4\"\n  },\n  \"devDependencies\": {\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@types/node\": \"22.18.0\",\n    \"@vitest/coverage-v8\": \"4.0.17\",\n    \"antlr4ts-cli\": \"0.5.0-alpha.4\",\n    \"eslint\": \"8.57.0\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"tsdown\": \"0.18.1\",\n    \"typescript\": \"5.4.3\",\n    \"vitest\": \"4.0.17\"\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/conversion.visitor.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport { ConversionVisitor } from './conversion.visitor';\nimport { Formula } from './parser/Formula';\nimport { FormulaLexer } from './parser/FormulaLexer';\n\ndescribe('ConversionVisitor', () => {\n  it('should convert id to name', () => {\n    const inputStream = CharStreams.fromString('concat({fld123} + 1, {fld456}) /**/');\n    const lexer = new FormulaLexer(inputStream);\n    const tokenStream = new CommonTokenStream(lexer);\n    const parser = new Formula(tokenStream);\n\n    const tree = parser.root(); // parsing rule entry point\n\n    // Initialize the custom Visitor with the mapping object\n    const idToName = {\n      fld123: 'textField',\n      fld456: 'linkField',\n      // more mappings\n    };\n\n    const visitor = new ConversionVisitor(idToName);\n\n    // Use the custom Visitor to traverse the AST and replace field references\n    visitor.visit(tree);\n\n    // Get the replaced code string\n    const replacedCode = visitor.getResult();\n    expect(replacedCode).toBe('concat({textField} + 1, {linkField}) /**/');\n  });\n});\n"
  },
  {
    "path": "packages/formula/src/conversion.visitor.ts",
    "content": "import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor.js';\nimport type { TerminalNode } from 'antlr4ts/tree/TerminalNode.js';\nimport { extractFieldReferenceId } from './field-reference.util';\nimport type { FieldReferenceCurlyContext } from './parser/Formula';\n\nexport class ConversionVisitor extends AbstractParseTreeVisitor<void> {\n  private noThrow = false;\n  private result = '';\n\n  defaultResult() {\n    return undefined;\n  }\n\n  constructor(private conversionMap: { [fieldName: string]: string }) {\n    super();\n    this.conversionMap = conversionMap;\n  }\n\n  safe() {\n    this.noThrow = true;\n    return this;\n  }\n\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext) {\n    const idOrName = extractFieldReferenceId(ctx);\n    const normalized = idOrName ?? '';\n    const nameOrId = this.conversionMap[normalized] || '#Error';\n    if (this.conversionMap[normalized] == null) {\n      const errorTxt = `Invalid field name or function name: \"${normalized}\"`;\n      if (this.noThrow) {\n        console.error(errorTxt);\n      } else {\n        throw new Error(errorTxt);\n      }\n    }\n    this.result += `{${nameOrId}}`;\n  }\n\n  visitTerminal(node: TerminalNode) {\n    const text = node.text;\n    if (text === '<EOF>') {\n      return;\n    }\n    this.result += text;\n  }\n\n  getResult() {\n    return this.result;\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/datetime-format-pg.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  DATETIME_FORMAT_TOKEN_TO_POSTGRES,\n  buildDatetimeFormatSql,\n  buildDatetimeParseGuardRegex,\n  expandLocalizedDatetimeFormat,\n  hasDatetimeTimezoneToken,\n  normalizeDatetimeFormatExpression,\n} from './datetime-format-pg';\n\ndescribe('datetime-format-pg', () => {\n  it('keeps the MMYYYY token sequence type-safe and intact for PostgreSQL parsing', () => {\n    expect(DATETIME_FORMAT_TOKEN_TO_POSTGRES.MM).toBe('MM');\n    expect(DATETIME_FORMAT_TOKEN_TO_POSTGRES.YYYY).toBe('YYYY');\n    expect(normalizeDatetimeFormatExpression(\"'MMYYYY'\")).toBe(\"'MMYYYY'\");\n  });\n\n  it('builds SQL fragments for composite format literals without collapsing adjacent tokens', () => {\n    expect(buildDatetimeFormatSql('event_time', \"'MMYYYY'\")).toBe(\n      \"TO_CHAR(event_time, 'MM') || TO_CHAR(event_time, 'YYYY')\"\n    );\n  });\n\n  it('expands localized tokens before scanning specifiers', () => {\n    expect(expandLocalizedDatetimeFormat('LLL')).toBe('MMMM D, YYYY h:mm A');\n  });\n\n  it('detects timezone-bearing format tokens only when they are real specifiers', () => {\n    expect(hasDatetimeTimezoneToken(\"'YYYY-MM-DD Z'\")).toBe(true);\n    expect(hasDatetimeTimezoneToken(\"'MMYYYY'\")).toBe(false);\n    expect(hasDatetimeTimezoneToken('format_column')).toBeNull();\n  });\n\n  it('builds a guard regex for MMYYYY reparsing', () => {\n    expect(buildDatetimeParseGuardRegex(\"'MMYYYY'\")).toBe('^\\\\d{2}\\\\d{4}.*$');\n  });\n\n  it('allows trailing characters after a valid custom-format prefix', () => {\n    const guardRegex = new RegExp(buildDatetimeParseGuardRegex(\"'YYYY-MM-DD'\") as string);\n\n    expect(guardRegex.test('2024-06-15T00:00:00Z')).toBe(true);\n    expect(guardRegex.test('2024-06-15 xxx')).toBe(true);\n    expect(guardRegex.test('abc')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "packages/formula/src/datetime-format-pg.ts",
    "content": "/* eslint-disable sonarjs/cognitive-complexity */\n/* eslint-disable @typescript-eslint/naming-convention */\nexport const DEFAULT_DATETIME_FORMAT_EXPR = \"'YYYY-MM-DD'\";\n\nexport const DEFAULT_DATETIME_FORMAT_LITERAL = 'YYYY-MM-DD';\n\nexport const LOCALIZED_DATETIME_FORMAT_MAP = {\n  LT: 'h:mm A',\n  LTS: 'h:mm:ss A',\n  L: 'MM/DD/YYYY',\n  LL: 'MMMM D, YYYY',\n  LLL: 'MMMM D, YYYY h:mm A',\n  LLLL: 'dddd, MMMM D, YYYY h:mm A',\n  l: 'M/D/YYYY',\n  ll: 'MMM D, YYYY',\n  lll: 'MMM D, YYYY h:mm A',\n  llll: 'ddd, MMM D, YYYY h:mm A',\n} as const;\n\nexport type ILocalizedDatetimeFormatToken = keyof typeof LOCALIZED_DATETIME_FORMAT_MAP;\n\ntype IDatetimeFormatSqlBuilder = (datetimeSql: string, timezoneOffsetSql: string) => string;\n\nexport const DATETIME_FORMAT_SQL_BUILDERS = {\n  HH24: (valueSql) => `TO_CHAR(${valueSql}, 'HH24')`,\n  HH12: (valueSql) => `TO_CHAR(${valueSql}, 'HH12')`,\n  MI: (valueSql) => `TO_CHAR(${valueSql}, 'MI')`,\n  MS: (valueSql) => `TO_CHAR(${valueSql}, 'MS')`,\n  SS: (valueSql) => `TO_CHAR(${valueSql}, 'SS')`,\n  Month: (valueSql) => `TO_CHAR(${valueSql}, 'FMMonth')`,\n  MONTH: (valueSql) => `TO_CHAR(${valueSql}, 'FMMONTH')`,\n  month: (valueSql) => `TO_CHAR(${valueSql}, 'FMmonth')`,\n  Day: (valueSql) => `TO_CHAR(${valueSql}, 'FMDay')`,\n  DAY: (valueSql) => `TO_CHAR(${valueSql}, 'FMDAY')`,\n  day: (valueSql) => `TO_CHAR(${valueSql}, 'FMday')`,\n  YYYY: (valueSql) => `TO_CHAR(${valueSql}, 'YYYY')`,\n  MMMM: (valueSql) => `TO_CHAR(${valueSql}, 'FMMonth')`,\n  dddd: (valueSql) => `TO_CHAR(${valueSql}, 'FMDay')`,\n  ddd: (valueSql) => `TO_CHAR(${valueSql}, 'FMDy')`,\n  dd: (valueSql) => `LEFT(TO_CHAR(${valueSql}, 'FMDy'), 2)`,\n  d: (valueSql) => `EXTRACT(DOW FROM ${valueSql})::int::text`,\n  MMM: (valueSql) => `TO_CHAR(${valueSql}, 'FMMon')`,\n  YY: (valueSql) => `TO_CHAR(${valueSql}, 'YY')`,\n  MM: (valueSql) => `TO_CHAR(${valueSql}, 'MM')`,\n  M: (valueSql) => `TO_CHAR(${valueSql}, 'FMMM')`,\n  DD: (valueSql) => `TO_CHAR(${valueSql}, 'DD')`,\n  D: (valueSql) => `TO_CHAR(${valueSql}, 'FMDD')`,\n  HH: (valueSql) => `TO_CHAR(${valueSql}, 'HH24')`,\n  H: (valueSql) => `TO_CHAR(${valueSql}, 'FMHH24')`,\n  hh: (valueSql) => `TO_CHAR(${valueSql}, 'HH12')`,\n  h: (valueSql) => `TO_CHAR(${valueSql}, 'FMHH12')`,\n  mm: (valueSql) => `TO_CHAR(${valueSql}, 'MI')`,\n  m: (valueSql) => `TO_CHAR(${valueSql}, 'FMMI')`,\n  ss: (valueSql) => `TO_CHAR(${valueSql}, 'SS')`,\n  s: (valueSql) => `TO_CHAR(${valueSql}, 'FMSS')`,\n  SSS: (valueSql) => `TO_CHAR(${valueSql}, 'MS')`,\n  ZZ: (_valueSql, timezoneOffsetSql) => `REPLACE(${timezoneOffsetSql}, ':', '')`,\n  Z: (_valueSql, timezoneOffsetSql) => timezoneOffsetSql,\n  A: (valueSql) => `TO_CHAR(${valueSql}, 'AM')`,\n  a: (valueSql) => `LOWER(TO_CHAR(${valueSql}, 'AM'))`,\n} as const satisfies Record<string, IDatetimeFormatSqlBuilder>;\n\nexport type ISupportedDatetimeFormatToken = keyof typeof DATETIME_FORMAT_SQL_BUILDERS;\n\nexport const DATETIME_FORMAT_TOKEN_TO_POSTGRES = {\n  HH24: 'HH24',\n  HH12: 'HH12',\n  MI: 'MI',\n  MS: 'MS',\n  SS: 'SS',\n  Month: 'FMMonth',\n  MONTH: 'FMMONTH',\n  month: 'FMmonth',\n  Day: 'FMDay',\n  DAY: 'FMDAY',\n  day: 'FMday',\n  dddd: 'FMDay',\n  ddd: 'FMDy',\n  dd: 'FMDy',\n  d: 'D',\n  YYYY: 'YYYY',\n  YY: 'YY',\n  MMMM: 'FMMonth',\n  MMM: 'FMMon',\n  MM: 'MM',\n  M: 'FMMM',\n  DD: 'DD',\n  D: 'FMDD',\n  HH: 'HH24',\n  H: 'FMHH24',\n  hh: 'HH12',\n  h: 'FMHH12',\n  mm: 'MI',\n  m: 'FMMI',\n  ss: 'SS',\n  s: 'FMSS',\n  SSS: 'MS',\n  Z: 'OF',\n  ZZ: 'OF',\n  A: 'AM',\n  a: 'am',\n} as const satisfies Record<ISupportedDatetimeFormatToken, string>;\n\nconst sortedLocalizedDatetimeFormatTokens = (\n  Object.keys(LOCALIZED_DATETIME_FORMAT_MAP) as ILocalizedDatetimeFormatToken[]\n).sort((a, b) => b.length - a.length);\n\nconst sortedSupportedDatetimeFormatTokens = (\n  Object.keys(DATETIME_FORMAT_SQL_BUILDERS) as ISupportedDatetimeFormatToken[]\n).sort((a, b) => b.length - a.length);\n\nconst timezoneFormatTokens = new Set<ISupportedDatetimeFormatToken>(['Z', 'ZZ']);\n\nconst DATETIME_PARSE_GUARD_TOKEN_PATTERNS = {\n  HH24: '\\\\d{2}',\n  HH12: '\\\\d{2}',\n  HH: '\\\\d{2}',\n  AM: '[AaPp][Mm]',\n  MI: '\\\\d{2}',\n  SS: '\\\\d{2}',\n  MS: '\\\\d{1,3}',\n  YYYY: '\\\\d{4}',\n  YYY: '\\\\d{3}',\n  YY: '\\\\d{2}',\n  Y: '\\\\d',\n  MM: '\\\\d{2}',\n  DD: '\\\\d{2}',\n} as const;\n\ntype IGuardableDatetimeToken = keyof typeof DATETIME_PARSE_GUARD_TOKEN_PATTERNS;\n\nconst optionalDatetimeParseGuardTokens = new Set(['FM', 'TM', 'TH']);\n\nconst DEFAULT_TIMEZONE_OFFSET_SQL = \"'+00:00'\";\n\nconst toSqlStringLiteral = (literal: string): string => `'${literal.replace(/'/g, \"''\")}'`;\n\nconst parseSqlStringLiteral = (expr: string): string | null => {\n  const trimmed = expr.trim();\n  if (!trimmed.startsWith(\"'\") || !trimmed.endsWith(\"'\")) {\n    return null;\n  }\n\n  return trimmed.slice(1, -1).replace(/''/g, \"'\");\n};\n\nconst shouldMatchSingleCharToken = (literal: string, index: number): boolean => {\n  const prevChar = index > 0 ? literal[index - 1] : '';\n  const nextChar = index + 1 < literal.length ? literal[index + 1] : '';\n  const prevIsAlpha = /[A-Z]/i.test(prevChar);\n  const nextIsAlpha = /[A-Z]/i.test(nextChar);\n  return !prevIsAlpha && !nextIsAlpha;\n};\n\nexport const expandLocalizedDatetimeFormat = (literal: string): string => {\n  let result = '';\n\n  for (let i = 0; i < literal.length; ) {\n    const remaining = literal.slice(i);\n    const token = sortedLocalizedDatetimeFormatTokens.find((candidate) =>\n      remaining.startsWith(candidate)\n    );\n\n    if (token) {\n      if (token.length === 1 && !shouldMatchSingleCharToken(literal, i)) {\n        result += literal[i];\n        i += 1;\n        continue;\n      }\n\n      result += LOCALIZED_DATETIME_FORMAT_MAP[token];\n      i += token.length;\n      continue;\n    }\n\n    result += literal[i];\n    i += 1;\n  }\n\n  return result;\n};\n\nconst forEachSupportedDatetimeFormatToken = (\n  literal: string,\n  options: {\n    onToken: (token: ISupportedDatetimeFormatToken) => void;\n    onLiteralChar: (char: string) => void;\n  }\n) => {\n  const expandedLiteral = expandLocalizedDatetimeFormat(literal);\n\n  for (let i = 0; i < expandedLiteral.length; ) {\n    const remaining = expandedLiteral.slice(i);\n    const token = sortedSupportedDatetimeFormatTokens.find((candidate) =>\n      remaining.startsWith(candidate)\n    );\n\n    if (token) {\n      if (token.length === 1 && !shouldMatchSingleCharToken(expandedLiteral, i)) {\n        options.onLiteralChar(expandedLiteral[i]);\n        i += 1;\n        continue;\n      }\n\n      options.onToken(token);\n      i += token.length;\n      continue;\n    }\n\n    options.onLiteralChar(expandedLiteral[i]);\n    i += 1;\n  }\n};\n\nconst buildDatetimeFormatSqlFromLiteral = (\n  datetimeSql: string,\n  formatLiteral: string,\n  timezoneOffsetSql: string\n): string => {\n  const sqlParts: string[] = [];\n  let literalBuffer = '';\n\n  const flushLiteral = () => {\n    if (!literalBuffer) {\n      return;\n    }\n\n    sqlParts.push(toSqlStringLiteral(literalBuffer));\n    literalBuffer = '';\n  };\n\n  forEachSupportedDatetimeFormatToken(formatLiteral, {\n    onToken: (token) => {\n      flushLiteral();\n      sqlParts.push(DATETIME_FORMAT_SQL_BUILDERS[token](datetimeSql, timezoneOffsetSql));\n    },\n    onLiteralChar: (char) => {\n      literalBuffer += char;\n    },\n  });\n\n  flushLiteral();\n\n  if (!sqlParts.length) {\n    return \"''\";\n  }\n\n  return sqlParts.join(' || ');\n};\n\nconst resolveFormatLiteral = (formatExpr?: string | null): string | null => {\n  if (typeof formatExpr !== 'string') {\n    return DEFAULT_DATETIME_FORMAT_LITERAL;\n  }\n\n  const trimmed = formatExpr.trim();\n  if (!trimmed) {\n    return DEFAULT_DATETIME_FORMAT_LITERAL;\n  }\n\n  return parseSqlStringLiteral(trimmed);\n};\n\nconst normalizeDatetimeFormatLiteral = (literal: string): string => {\n  let result = '';\n\n  forEachSupportedDatetimeFormatToken(literal, {\n    onToken: (token) => {\n      result += DATETIME_FORMAT_TOKEN_TO_POSTGRES[token];\n    },\n    onLiteralChar: (char) => {\n      result += char;\n    },\n  });\n\n  return result;\n};\n\nexport const buildDatetimeFormatSql = (\n  datetimeSql: string,\n  formatExpr?: string | null,\n  timezoneOffsetSql: string = DEFAULT_TIMEZONE_OFFSET_SQL\n): string => {\n  const formatLiteral = resolveFormatLiteral(formatExpr);\n  if (formatLiteral == null) {\n    const normalizedFormatSql = normalizeDatetimeFormatExpression(formatExpr);\n    return `TO_CHAR(${datetimeSql}, ${normalizedFormatSql})`;\n  }\n\n  const effectiveFormat = formatLiteral || DEFAULT_DATETIME_FORMAT_LITERAL;\n  return buildDatetimeFormatSqlFromLiteral(datetimeSql, effectiveFormat, timezoneOffsetSql);\n};\n\nexport const normalizeDatetimeFormatExpression = (formatExpr?: string | null): string => {\n  if (typeof formatExpr !== 'string') {\n    return DEFAULT_DATETIME_FORMAT_EXPR;\n  }\n\n  const trimmed = formatExpr.trim();\n  if (!trimmed) {\n    return DEFAULT_DATETIME_FORMAT_EXPR;\n  }\n\n  if (!trimmed.startsWith(\"'\") || !trimmed.endsWith(\"'\")) {\n    return formatExpr;\n  }\n\n  const literal = trimmed.slice(1, -1);\n  const normalizedLiteral = normalizeDatetimeFormatLiteral(literal);\n  const escaped = normalizedLiteral.replace(/'/g, \"''\");\n  return `'${escaped}'`;\n};\n\nexport const hasDatetimeTimezoneToken = (formatExpr?: string | null): boolean | null => {\n  const formatLiteral = resolveFormatLiteral(formatExpr);\n  if (formatLiteral == null) {\n    return null;\n  }\n\n  let hasTimezoneToken = false;\n\n  forEachSupportedDatetimeFormatToken(formatLiteral, {\n    onToken: (token) => {\n      if (timezoneFormatTokens.has(token)) {\n        hasTimezoneToken = true;\n      }\n    },\n    onLiteralChar: () => {\n      return;\n    },\n  });\n\n  return hasTimezoneToken;\n};\n\nexport const buildDatetimeParseGuardRegex = (formatExpr?: string | null): string | null => {\n  const normalizedFormat = normalizeDatetimeFormatExpression(formatExpr);\n  const literal = parseSqlStringLiteral(normalizedFormat);\n  if (literal == null) {\n    return null;\n  }\n\n  const guardableTokens = (\n    Object.keys(DATETIME_PARSE_GUARD_TOKEN_PATTERNS) as IGuardableDatetimeToken[]\n  ).sort((a, b) => b.length - a.length);\n\n  let pattern = '^';\n\n  for (let i = 0; i < literal.length; ) {\n    let matched = false;\n    const remaining = literal.slice(i);\n    const upperRemaining = remaining.toUpperCase();\n\n    for (const token of guardableTokens) {\n      if (upperRemaining.startsWith(token)) {\n        pattern += DATETIME_PARSE_GUARD_TOKEN_PATTERNS[token];\n        i += token.length;\n        matched = true;\n        break;\n      }\n    }\n\n    if (matched) {\n      continue;\n    }\n\n    const optionalToken = upperRemaining.slice(0, 2);\n    if (optionalDatetimeParseGuardTokens.has(optionalToken)) {\n      i += optionalToken.length;\n      continue;\n    }\n\n    const currentChar = literal[i];\n    if (/\\s/.test(currentChar)) {\n      pattern += '\\\\s';\n    } else {\n      pattern += currentChar.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    }\n    i += 1;\n  }\n\n  // Dayjs custom parsing accepts trailing characters once the expected tokens match.\n  pattern += '.*$';\n  return pattern;\n};\n"
  },
  {
    "path": "packages/formula/src/error.listener.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { ANTLRErrorListener, RecognitionException, Recognizer, Token } from 'antlr4ts';\n\nexport class FormulaErrorListener implements ANTLRErrorListener<Token> {\n  syntaxError<T extends Token>(\n    _recognizer: Recognizer<T, any>,\n    _offendingSymbol: T | undefined,\n    line: number,\n    charPositionInLine: number,\n    msg: string,\n    _e: RecognitionException | undefined\n  ): void {\n    throw new Error(msg.split('expecting')[0].trim());\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/field-reference.util.ts",
    "content": "import type { Field_reference_curlyContext, FieldReferenceCurlyContext } from './parser/Formula';\n\n/**\n * Extracts the identifier inside a curly field reference token, trimming away the surrounding\n * braces and any incidental whitespace. Returns `undefined` when the token is missing or empty.\n */\nexport function extractFieldReferenceId(\n  ctx: FieldReferenceCurlyContext | Field_reference_curlyContext | undefined\n): string | undefined {\n  if (!ctx) {\n    return undefined;\n  }\n\n  const identifierToken = 'field_reference_curly' in ctx ? ctx.field_reference_curly() : ctx;\n  if (!identifierToken) {\n    return undefined;\n  }\n\n  const raw = identifierToken.IDENTIFIER_VARIABLE()?.text ?? '';\n  if (!raw) {\n    return undefined;\n  }\n\n  const trimmed = raw.trim();\n  if (!trimmed) {\n    return undefined;\n  }\n\n  const normalized =\n    trimmed.startsWith('{') && trimmed.endsWith('}') ? trimmed.slice(1, -1).trim() : trimmed;\n\n  return normalized || undefined;\n}\n\nexport function getFieldReferenceTokenText(\n  ctx: FieldReferenceCurlyContext | Field_reference_curlyContext | undefined\n): string | undefined {\n  if (!ctx) {\n    return undefined;\n  }\n\n  const identifierToken = 'field_reference_curly' in ctx ? ctx.field_reference_curly() : ctx;\n  return identifierToken?.IDENTIFIER_VARIABLE()?.text ?? undefined;\n}\n"
  },
  {
    "path": "packages/formula/src/field-reference.visitor.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport { FieldReferenceVisitor } from './field-reference.visitor';\nimport { Formula } from './parser/Formula';\nimport { FormulaLexer } from './parser/FormulaLexer';\n\ndescribe('FieldReferenceVisitor', () => {\n  it('should collect field reference', () => {\n    const inputStream = CharStreams.fromString('concat({fld123} + 1, {fld456}) /**/');\n    const lexer = new FormulaLexer(inputStream);\n    const tokenStream = new CommonTokenStream(lexer);\n    const parser = new Formula(tokenStream);\n\n    const tree = parser.root(); // parsing rule entry point\n\n    const visitor = new FieldReferenceVisitor();\n\n    // Use the custom Visitor to traverse the AST and replace field references\n    const fieldIds = visitor.visit(tree);\n\n    // Get the replaced code string\n    expect(fieldIds).toEqual(['fld123', 'fld456']);\n  });\n});\n"
  },
  {
    "path": "packages/formula/src/field-reference.visitor.ts",
    "content": "import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor.js';\nimport { extractFieldReferenceId } from './field-reference.util';\nimport type { FieldReferenceCurlyContext } from './parser/Formula';\nimport type { FormulaVisitor } from './parser/FormulaVisitor';\n\nexport class FieldReferenceVisitor\n  extends AbstractParseTreeVisitor<string[]>\n  implements FormulaVisitor<string[]>\n{\n  defaultResult() {\n    return [];\n  }\n\n  aggregateResult(aggregate: string[], nextResult: string[]) {\n    return aggregate.concat(nextResult);\n  }\n\n  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext) {\n    const fieldId = extractFieldReferenceId(ctx);\n    return fieldId ? [fieldId] : this.defaultResult();\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/function-call-collector.visitor.spec.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { FunctionCallCollectorVisitor } from './function-call-collector.visitor';\nimport { parseFormula } from './parse-formula';\n\ndescribe('FunctionCallCollectorVisitor', () => {\n  const extractFunctions = (expression: string) => {\n    const tree = parseFormula(expression);\n    const visitor = new FunctionCallCollectorVisitor();\n    return visitor.visit(tree);\n  };\n\n  it('should extract simple function calls', () => {\n    const functions = extractFunctions('SUM(1, 2, 3)');\n    expect(functions).toEqual([{ name: 'SUM', paramCount: 3 }]);\n  });\n\n  it('should extract nested function calls', () => {\n    const functions = extractFunctions('ROUND(SQRT(16), 2)');\n    expect(functions).toEqual([\n      { name: 'ROUND', paramCount: 2 },\n      { name: 'SQRT', paramCount: 1 },\n    ]);\n  });\n\n  it('should extract multiple function calls', () => {\n    const functions = extractFunctions('CONCATENATE(UPPER(\"hello\"), \" \", LOWER(\"WORLD\"))');\n    expect(functions).toEqual([\n      { name: 'CONCATENATE', paramCount: 3 },\n      { name: 'UPPER', paramCount: 1 },\n      { name: 'LOWER', paramCount: 1 },\n    ]);\n  });\n\n  it('should handle functions with no parameters', () => {\n    const functions = extractFunctions('NOW()');\n    expect(functions).toEqual([{ name: 'NOW', paramCount: 0 }]);\n  });\n\n  it('should handle complex nested expressions', () => {\n    const functions = extractFunctions('IF(SUM(1, 2) > 2, UPPER(\"yes\"), LOWER(\"no\"))');\n    expect(functions).toEqual([\n      { name: 'IF', paramCount: 3 },\n      { name: 'SUM', paramCount: 2 },\n      { name: 'UPPER', paramCount: 1 },\n      { name: 'LOWER', paramCount: 1 },\n    ]);\n  });\n\n  it('should return empty array for expressions without functions', () => {\n    const functions = extractFunctions('1 + 2 * 3');\n    expect(functions).toEqual([]);\n  });\n\n  it('should return empty array for simple literals', () => {\n    expect(extractFunctions('42')).toEqual([]);\n    expect(extractFunctions('\"hello\"')).toEqual([]);\n    expect(extractFunctions('true')).toEqual([]);\n  });\n\n  it('should handle functions in binary operations', () => {\n    const functions = extractFunctions('SUM(1, 2) + MAX(3, 4)');\n    expect(functions).toEqual([\n      { name: 'SUM', paramCount: 2 },\n      { name: 'MAX', paramCount: 2 },\n    ]);\n  });\n\n  it('should handle functions with field references', () => {\n    const functions = extractFunctions('CONCATENATE({field1}, UPPER({field2}))');\n    expect(functions).toEqual([\n      { name: 'CONCATENATE', paramCount: 2 },\n      { name: 'UPPER', paramCount: 1 },\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/formula/src/function-call-collector.visitor.ts",
    "content": "import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor.js';\nimport type {\n  BinaryOpContext,\n  BracketsContext,\n  FunctionCallContext,\n  UnaryOpContext,\n  LeftWhitespaceOrCommentsContext,\n  RightWhitespaceOrCommentsContext,\n} from './parser/Formula';\nimport type { FormulaVisitor } from './parser/FormulaVisitor';\n\n/**\n * Information about a function call found in the formula\n */\nexport interface IFunctionCallInfo {\n  /** Function name in uppercase */\n  name: string;\n  /** Number of parameters */\n  paramCount: number;\n}\n\n/**\n * Visitor that collects all function calls from a formula AST\n * This is used to analyze which functions are used in a formula expression.\n */\nexport class FunctionCallCollectorVisitor\n  extends AbstractParseTreeVisitor<IFunctionCallInfo[]>\n  implements FormulaVisitor<IFunctionCallInfo[]>\n{\n  defaultResult(): IFunctionCallInfo[] {\n    return [];\n  }\n\n  aggregateResult(\n    aggregate: IFunctionCallInfo[],\n    nextResult: IFunctionCallInfo[]\n  ): IFunctionCallInfo[] {\n    return aggregate.concat(nextResult);\n  }\n\n  visitBinaryOp(ctx: BinaryOpContext): IFunctionCallInfo[] {\n    // Visit both operands to find nested function calls\n    const leftResult = this.visit(ctx.expr(0));\n    const rightResult = this.visit(ctx.expr(1));\n    return this.aggregateResult(leftResult, rightResult);\n  }\n\n  visitUnaryOp(ctx: UnaryOpContext): IFunctionCallInfo[] {\n    // Visit the operand to find nested function calls\n    return this.visit(ctx.expr());\n  }\n\n  visitBrackets(ctx: BracketsContext): IFunctionCallInfo[] {\n    // Visit the expression inside brackets\n    return this.visit(ctx.expr());\n  }\n\n  visitFunctionCall(ctx: FunctionCallContext): IFunctionCallInfo[] {\n    // Extract function name and parameter count\n    const functionName = ctx.func_name().text.toUpperCase();\n    const paramCount = ctx.expr().length;\n\n    // Create function call info for this function\n    const currentFunction: IFunctionCallInfo = {\n      name: functionName,\n      paramCount,\n    };\n\n    // Visit all parameters to find nested function calls\n    const nestedFunctions: IFunctionCallInfo[] = [];\n    ctx.expr().forEach((paramCtx) => {\n      const paramResult = this.visit(paramCtx);\n      nestedFunctions.push(...paramResult);\n    });\n\n    // Return current function plus all nested functions\n    return [currentFunction, ...nestedFunctions];\n  }\n\n  visitLeftWhitespaceOrComments(ctx: LeftWhitespaceOrCommentsContext): IFunctionCallInfo[] {\n    // Visit the nested expression\n    return this.visit(ctx.expr());\n  }\n\n  visitRightWhitespaceOrComments(ctx: RightWhitespaceOrCommentsContext): IFunctionCallInfo[] {\n    // Visit the nested expression\n    return this.visit(ctx.expr());\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/index.ts",
    "content": "export * from './parse-formula';\nexport * from './conversion.visitor';\nexport * from './field-reference.visitor';\nexport * from './field-reference.util';\nexport * from './function-call-collector.visitor';\nexport * from './error.listener';\nexport * from './datetime-format-pg';\nexport { FormulaLexer } from './parser/FormulaLexer';\nexport * from './parser/Formula';\nexport type { FormulaVisitor } from './parser/FormulaVisitor';\n\nexport { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor.js';\nexport type { RuleNode } from 'antlr4ts/tree/RuleNode.js';\nexport { CharStreams, CommonTokenStream } from 'antlr4ts';\nexport type { ANTLRErrorListener, Recognizer, RecognitionException, Token } from 'antlr4ts';\nexport type { ATNSimulator } from 'antlr4ts/atn/ATNSimulator.js';\n"
  },
  {
    "path": "packages/formula/src/parse-formula.ts",
    "content": "import { CharStreams, CommonTokenStream } from 'antlr4ts';\nimport { Formula } from './parser/Formula';\nimport type { ExprContext } from './parser/Formula';\nimport { FormulaLexer } from './parser/FormulaLexer';\n\n/**\n * Parse a formula expression string into an AST\n * @param expression The formula expression to parse\n * @returns The parsed AST root context\n */\nexport function parseFormula(expression: string): ExprContext {\n  const inputStream = CharStreams.fromString(expression);\n  const lexer = new FormulaLexer(inputStream);\n  const tokenStream = new CommonTokenStream(lexer);\n  const parser = new Formula(tokenStream);\n\n  return parser.root();\n}\n\n/**\n * Parse a formula expression and convert it to SQL using the provided visitor\n * @param expression The formula expression to parse\n * @param visitor The SQL conversion visitor to use\n * @returns The generated SQL string\n */\nexport function parseFormulaToSQL<T>(\n  expression: string,\n  visitor: { visit(tree: ExprContext): T }\n): T {\n  const tree = parseFormula(expression);\n  return visitor.visit(tree);\n}\n"
  },
  {
    "path": "packages/formula/src/parser/Formula.g4",
    "content": "/*\n * Portions of this file are based on Baserow software.\n * \n * Copyright (c) 2019-present Baserow B.V.\n *\n * The MIT License\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * 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\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nparser grammar Formula;\n\noptions { tokenVocab=FormulaLexer; }\n\nroot\n    : expr EOF\n    ;\nexpr\n    :\n    SINGLEQ_STRING_LITERAL # StringLiteral\n    | DOUBLEQ_STRING_LITERAL #  StringLiteral\n    | INTEGER_LITERAL # IntegerLiteral\n    | NUMERIC_LITERAL # DecimalLiteral\n    | (TRUE | FALSE) # BooleanLiteral\n    | ws_or_comment expr # LeftWhitespaceOrComments\n    | expr ws_or_comment # RightWhitespaceOrComments\n    | OPEN_PAREN expr CLOSE_PAREN # Brackets\n    | MINUS expr # UnaryOp\n    | expr op=(SLASH | STAR | PERCENT) expr # BinaryOp\n    | expr op=(PLUS | MINUS) expr # BinaryOp\n    | expr op=(GT | LT | GTE | LTE) expr # BinaryOp\n    | expr op=(EQUAL | BANG_EQUAL) expr # BinaryOp\n    | expr op=AMP_AMP expr # BinaryOp\n    | expr op=PIPE_PIPE expr # BinaryOp\n    | expr op=AMP expr # BinaryOp\n    | field_reference_curly # FieldReferenceCurly\n    // | LOOKUP OPEN_PAREN field_reference COMMA WHITESPACE? field_reference CLOSE_PAREN # LookupFieldReference\n    | func_name OPEN_PAREN (expr (COMMA expr)*)? CLOSE_PAREN # FunctionCall\n    ;\n\nws_or_comment\n    : BLOCK_COMMENT\n    | LINE_COMMENT\n    | WHITESPACE\n    ;\n\nfield_reference\n    : IDENTIFIER_UNICODE\n    ;\n\nfield_reference_curly\n    : IDENTIFIER_VARIABLE\n    ;\n\nfunc_name\n    : identifier\n    ;\n\nidentifier\n    : IDENTIFIER\n    | IDENTIFIER_UNICODE\n    ;\n"
  },
  {
    "path": "packages/formula/src/parser/Formula.interp",
    "content": "token literal names:\nnull\nnull\nnull\nnull\nnull\nnull\nnull\n','\n':'\n'::'\n'$'\n'$$'\n'*'\n'('\n')'\n'['\n']'\n'{'\n'}'\nnull\nnull\nnull\nnull\nnull\n'.'\nnull\nnull\nnull\nnull\nnull\n'&'\n'&&'\n'&<'\n'@@'\n'@>'\n'@'\n'!'\n'!!'\n'!='\n'^'\n'='\n'=>'\n'>'\n'>='\n'>>'\n'#'\n'#='\n'#>'\n'#>>'\n'##'\n'->'\n'->>'\n'-|-'\n'<'\n'<='\n'<@'\n'<^'\n'<>'\n'<->'\n'<<'\n'<<='\n'<?>'\n'-'\n'%'\n'|'\n'||'\n'||/'\n'|/'\n'+'\n'?'\n'?&'\n'?#'\n'?-'\n'?|'\n'/'\n'~'\n'~='\n'~>=~'\n'~>~'\n'~<=~'\n'~<~'\n'~*'\n'~~'\n';'\nnull\n\ntoken symbolic names:\nnull\nBLOCK_COMMENT\nLINE_COMMENT\nWHITESPACE\nTRUE\nFALSE\nFIELD\nCOMMA\nCOLON\nCOLON_COLON\nDOLLAR\nDOLLAR_DOLLAR\nSTAR\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nBIT_STRING\nREGEX_STRING\nNUMERIC_LITERAL\nINTEGER_LITERAL\nHEX_INTEGER_LITERAL\nDOT\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nIDENTIFIER_VARIABLE\nIDENTIFIER_UNICODE\nIDENTIFIER\nAMP\nAMP_AMP\nAMP_LT\nAT_AT\nAT_GT\nAT_SIGN\nBANG\nBANG_BANG\nBANG_EQUAL\nCARET\nEQUAL\nEQUAL_GT\nGT\nGTE\nGT_GT\nHASH\nHASH_EQ\nHASH_GT\nHASH_GT_GT\nHASH_HASH\nHYPHEN_GT\nHYPHEN_GT_GT\nHYPHEN_PIPE_HYPHEN\nLT\nLTE\nLT_AT\nLT_CARET\nLT_GT\nLT_HYPHEN_GT\nLT_LT\nLT_LT_EQ\nLT_QMARK_GT\nMINUS\nPERCENT\nPIPE\nPIPE_PIPE\nPIPE_PIPE_SLASH\nPIPE_SLASH\nPLUS\nQMARK\nQMARK_AMP\nQMARK_HASH\nQMARK_HYPHEN\nQMARK_PIPE\nSLASH\nTIL\nTIL_EQ\nTIL_GTE_TIL\nTIL_GT_TIL\nTIL_LTE_TIL\nTIL_LT_TIL\nTIL_STAR\nTIL_TIL\nSEMI\nErrorCharacter\n\nrule names:\nroot\nexpr\nws_or_comment\nfield_reference\nfield_reference_curly\nfunc_name\nidentifier\n\n\natn:\n[3, 51485, 51898, 1421, 44986, 20307, 1543, 60043, 49729, 3, 86, 90, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 3, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 7, 3, 41, 10, 3, 12, 3, 14, 3, 44, 11, 3, 5, 3, 46, 10, 3, 3, 3, 3, 3, 5, 3, 50, 10, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 7, 3, 75, 10, 3, 12, 3, 14, 3, 78, 11, 3, 3, 4, 3, 4, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 8, 2, 2, 3, 4, 9, 2, 2, 4, 2, 6, 2, 8, 2, 10, 2, 12, 2, 14, 2, 2, 9, 3, 2, 6, 7, 5, 2, 14, 14, 65, 65, 76, 76, 4, 2, 64, 64, 70, 70, 4, 2, 44, 45, 55, 56, 4, 2, 40, 40, 42, 42, 3, 2, 3, 5, 3, 2, 30, 31, 2, 101, 2, 16, 3, 2, 2, 2, 4, 49, 3, 2, 2, 2, 6, 79, 3, 2, 2, 2, 8, 81, 3, 2, 2, 2, 10, 83, 3, 2, 2, 2, 12, 85, 3, 2, 2, 2, 14, 87, 3, 2, 2, 2, 16, 17, 5, 4, 3, 2, 17, 18, 7, 2, 2, 3, 18, 3, 3, 2, 2, 2, 19, 20, 8, 3, 1, 2, 20, 50, 7, 27, 2, 2, 21, 50, 7, 28, 2, 2, 22, 50, 7, 24, 2, 2, 23, 50, 7, 23, 2, 2, 24, 50, 9, 2, 2, 2, 25, 26, 5, 6, 4, 2, 26, 27, 5, 4, 3, 15, 27, 50, 3, 2, 2, 2, 28, 29, 7, 15, 2, 2, 29, 30, 5, 4, 3, 2, 30, 31, 7, 16, 2, 2, 31, 50, 3, 2, 2, 2, 32, 33, 7, 64, 2, 2, 33, 50, 5, 4, 3, 12, 34, 50, 5, 10, 6, 2, 35, 36, 5, 12, 7, 2, 36, 45, 7, 15, 2, 2, 37, 42, 5, 4, 3, 2, 38, 39, 7, 9, 2, 2, 39, 41, 5, 4, 3, 2, 40, 38, 3, 2, 2, 2, 41, 44, 3, 2, 2, 2, 42, 40, 3, 2, 2, 2, 42, 43, 3, 2, 2, 2, 43, 46, 3, 2, 2, 2, 44, 42, 3, 2, 2, 2, 45, 37, 3, 2, 2, 2, 45, 46, 3, 2, 2, 2, 46, 47, 3, 2, 2, 2, 47, 48, 7, 16, 2, 2, 48, 50, 3, 2, 2, 2, 49, 19, 3, 2, 2, 2, 49, 21, 3, 2, 2, 2, 49, 22, 3, 2, 2, 2, 49, 23, 3, 2, 2, 2, 49, 24, 3, 2, 2, 2, 49, 25, 3, 2, 2, 2, 49, 28, 3, 2, 2, 2, 49, 32, 3, 2, 2, 2, 49, 34, 3, 2, 2, 2, 49, 35, 3, 2, 2, 2, 50, 76, 3, 2, 2, 2, 51, 52, 12, 11, 2, 2, 52, 53, 9, 3, 2, 2, 53, 75, 5, 4, 3, 12, 54, 55, 12, 10, 2, 2, 55, 56, 9, 4, 2, 2, 56, 75, 5, 4, 3, 11, 57, 58, 12, 9, 2, 2, 58, 59, 9, 5, 2, 2, 59, 75, 5, 4, 3, 10, 60, 61, 12, 8, 2, 2, 61, 62, 9, 6, 2, 2, 62, 75, 5, 4, 3, 9, 63, 64, 12, 7, 2, 2, 64, 65, 7, 33, 2, 2, 65, 75, 5, 4, 3, 8, 66, 67, 12, 6, 2, 2, 67, 68, 7, 67, 2, 2, 68, 75, 5, 4, 3, 7, 69, 70, 12, 5, 2, 2, 70, 71, 7, 32, 2, 2, 71, 75, 5, 4, 3, 6, 72, 73, 12, 14, 2, 2, 73, 75, 5, 6, 4, 2, 74, 51, 3, 2, 2, 2, 74, 54, 3, 2, 2, 2, 74, 57, 3, 2, 2, 2, 74, 60, 3, 2, 2, 2, 74, 63, 3, 2, 2, 2, 74, 66, 3, 2, 2, 2, 74, 69, 3, 2, 2, 2, 74, 72, 3, 2, 2, 2, 75, 78, 3, 2, 2, 2, 76, 74, 3, 2, 2, 2, 76, 77, 3, 2, 2, 2, 77, 5, 3, 2, 2, 2, 78, 76, 3, 2, 2, 2, 79, 80, 9, 7, 2, 2, 80, 7, 3, 2, 2, 2, 81, 82, 7, 30, 2, 2, 82, 9, 3, 2, 2, 2, 83, 84, 7, 29, 2, 2, 84, 11, 3, 2, 2, 2, 85, 86, 5, 14, 8, 2, 86, 13, 3, 2, 2, 2, 87, 88, 9, 8, 2, 2, 88, 15, 3, 2, 2, 2, 7, 42, 45, 49, 74, 76]"
  },
  {
    "path": "packages/formula/src/parser/Formula.tokens",
    "content": "BLOCK_COMMENT=1\nLINE_COMMENT=2\nWHITESPACE=3\nTRUE=4\nFALSE=5\nFIELD=6\nCOMMA=7\nCOLON=8\nCOLON_COLON=9\nDOLLAR=10\nDOLLAR_DOLLAR=11\nSTAR=12\nOPEN_PAREN=13\nCLOSE_PAREN=14\nOPEN_BRACKET=15\nCLOSE_BRACKET=16\nL_CURLY=17\nR_CURLY=18\nBIT_STRING=19\nREGEX_STRING=20\nNUMERIC_LITERAL=21\nINTEGER_LITERAL=22\nHEX_INTEGER_LITERAL=23\nDOT=24\nSINGLEQ_STRING_LITERAL=25\nDOUBLEQ_STRING_LITERAL=26\nIDENTIFIER_VARIABLE=27\nIDENTIFIER_UNICODE=28\nIDENTIFIER=29\nAMP=30\nAMP_AMP=31\nAMP_LT=32\nAT_AT=33\nAT_GT=34\nAT_SIGN=35\nBANG=36\nBANG_BANG=37\nBANG_EQUAL=38\nCARET=39\nEQUAL=40\nEQUAL_GT=41\nGT=42\nGTE=43\nGT_GT=44\nHASH=45\nHASH_EQ=46\nHASH_GT=47\nHASH_GT_GT=48\nHASH_HASH=49\nHYPHEN_GT=50\nHYPHEN_GT_GT=51\nHYPHEN_PIPE_HYPHEN=52\nLT=53\nLTE=54\nLT_AT=55\nLT_CARET=56\nLT_GT=57\nLT_HYPHEN_GT=58\nLT_LT=59\nLT_LT_EQ=60\nLT_QMARK_GT=61\nMINUS=62\nPERCENT=63\nPIPE=64\nPIPE_PIPE=65\nPIPE_PIPE_SLASH=66\nPIPE_SLASH=67\nPLUS=68\nQMARK=69\nQMARK_AMP=70\nQMARK_HASH=71\nQMARK_HYPHEN=72\nQMARK_PIPE=73\nSLASH=74\nTIL=75\nTIL_EQ=76\nTIL_GTE_TIL=77\nTIL_GT_TIL=78\nTIL_LTE_TIL=79\nTIL_LT_TIL=80\nTIL_STAR=81\nTIL_TIL=82\nSEMI=83\nErrorCharacter=84\n','=7\n':'=8\n'::'=9\n'$'=10\n'$$'=11\n'*'=12\n'('=13\n')'=14\n'['=15\n']'=16\n'{'=17\n'}'=18\n'.'=24\n'&'=30\n'&&'=31\n'&<'=32\n'@@'=33\n'@>'=34\n'@'=35\n'!'=36\n'!!'=37\n'!='=38\n'^'=39\n'='=40\n'=>'=41\n'>'=42\n'>='=43\n'>>'=44\n'#'=45\n'#='=46\n'#>'=47\n'#>>'=48\n'##'=49\n'->'=50\n'->>'=51\n'-|-'=52\n'<'=53\n'<='=54\n'<@'=55\n'<^'=56\n'<>'=57\n'<->'=58\n'<<'=59\n'<<='=60\n'<?>'=61\n'-'=62\n'%'=63\n'|'=64\n'||'=65\n'||/'=66\n'|/'=67\n'+'=68\n'?'=69\n'?&'=70\n'?#'=71\n'?-'=72\n'?|'=73\n'/'=74\n'~'=75\n'~='=76\n'~>=~'=77\n'~>~'=78\n'~<=~'=79\n'~<~'=80\n'~*'=81\n'~~'=82\n';'=83\n"
  },
  {
    "path": "packages/formula/src/parser/Formula.ts",
    "content": "// Generated from src/formula/parser/Formula.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport { ATN } from 'antlr4ts/atn/ATN.js';\nimport { ATNDeserializer } from 'antlr4ts/atn/ATNDeserializer.js';\nimport { FailedPredicateException } from 'antlr4ts/FailedPredicateException.js';\nimport { NotNull } from 'antlr4ts/Decorators.js';\nimport { NoViableAltException } from 'antlr4ts/NoViableAltException.js';\nimport { Override } from 'antlr4ts/Decorators.js';\nimport { Parser } from 'antlr4ts/Parser.js';\nimport { ParserRuleContext } from 'antlr4ts/ParserRuleContext.js';\nimport { ParserATNSimulator } from 'antlr4ts/atn/ParserATNSimulator.js';\nimport { ParseTreeListener } from 'antlr4ts/tree/ParseTreeListener.js';\nimport { ParseTreeVisitor } from 'antlr4ts/tree/ParseTreeVisitor.js';\nimport { RecognitionException } from 'antlr4ts/RecognitionException.js';\nimport { RuleContext } from 'antlr4ts/RuleContext.js';\n//import { RuleVersion } from \"antlr4ts/RuleVersion.js\";\nimport { TerminalNode } from 'antlr4ts/tree/TerminalNode.js';\nimport { Token } from 'antlr4ts/Token.js';\nimport { TokenStream } from 'antlr4ts/TokenStream.js';\nimport { Vocabulary } from 'antlr4ts/Vocabulary.js';\nimport { VocabularyImpl } from 'antlr4ts/VocabularyImpl.js';\n\nimport * as Utils from 'antlr4ts/misc/Utils.js';\n\nimport { FormulaVisitor } from './FormulaVisitor';\n\nexport class Formula extends Parser {\n  public static readonly BLOCK_COMMENT = 1;\n  public static readonly LINE_COMMENT = 2;\n  public static readonly WHITESPACE = 3;\n  public static readonly TRUE = 4;\n  public static readonly FALSE = 5;\n  public static readonly FIELD = 6;\n  public static readonly COMMA = 7;\n  public static readonly COLON = 8;\n  public static readonly COLON_COLON = 9;\n  public static readonly DOLLAR = 10;\n  public static readonly DOLLAR_DOLLAR = 11;\n  public static readonly STAR = 12;\n  public static readonly OPEN_PAREN = 13;\n  public static readonly CLOSE_PAREN = 14;\n  public static readonly OPEN_BRACKET = 15;\n  public static readonly CLOSE_BRACKET = 16;\n  public static readonly L_CURLY = 17;\n  public static readonly R_CURLY = 18;\n  public static readonly BIT_STRING = 19;\n  public static readonly REGEX_STRING = 20;\n  public static readonly NUMERIC_LITERAL = 21;\n  public static readonly INTEGER_LITERAL = 22;\n  public static readonly HEX_INTEGER_LITERAL = 23;\n  public static readonly DOT = 24;\n  public static readonly SINGLEQ_STRING_LITERAL = 25;\n  public static readonly DOUBLEQ_STRING_LITERAL = 26;\n  public static readonly IDENTIFIER_VARIABLE = 27;\n  public static readonly IDENTIFIER_UNICODE = 28;\n  public static readonly IDENTIFIER = 29;\n  public static readonly AMP = 30;\n  public static readonly AMP_AMP = 31;\n  public static readonly AMP_LT = 32;\n  public static readonly AT_AT = 33;\n  public static readonly AT_GT = 34;\n  public static readonly AT_SIGN = 35;\n  public static readonly BANG = 36;\n  public static readonly BANG_BANG = 37;\n  public static readonly BANG_EQUAL = 38;\n  public static readonly CARET = 39;\n  public static readonly EQUAL = 40;\n  public static readonly EQUAL_GT = 41;\n  public static readonly GT = 42;\n  public static readonly GTE = 43;\n  public static readonly GT_GT = 44;\n  public static readonly HASH = 45;\n  public static readonly HASH_EQ = 46;\n  public static readonly HASH_GT = 47;\n  public static readonly HASH_GT_GT = 48;\n  public static readonly HASH_HASH = 49;\n  public static readonly HYPHEN_GT = 50;\n  public static readonly HYPHEN_GT_GT = 51;\n  public static readonly HYPHEN_PIPE_HYPHEN = 52;\n  public static readonly LT = 53;\n  public static readonly LTE = 54;\n  public static readonly LT_AT = 55;\n  public static readonly LT_CARET = 56;\n  public static readonly LT_GT = 57;\n  public static readonly LT_HYPHEN_GT = 58;\n  public static readonly LT_LT = 59;\n  public static readonly LT_LT_EQ = 60;\n  public static readonly LT_QMARK_GT = 61;\n  public static readonly MINUS = 62;\n  public static readonly PERCENT = 63;\n  public static readonly PIPE = 64;\n  public static readonly PIPE_PIPE = 65;\n  public static readonly PIPE_PIPE_SLASH = 66;\n  public static readonly PIPE_SLASH = 67;\n  public static readonly PLUS = 68;\n  public static readonly QMARK = 69;\n  public static readonly QMARK_AMP = 70;\n  public static readonly QMARK_HASH = 71;\n  public static readonly QMARK_HYPHEN = 72;\n  public static readonly QMARK_PIPE = 73;\n  public static readonly SLASH = 74;\n  public static readonly TIL = 75;\n  public static readonly TIL_EQ = 76;\n  public static readonly TIL_GTE_TIL = 77;\n  public static readonly TIL_GT_TIL = 78;\n  public static readonly TIL_LTE_TIL = 79;\n  public static readonly TIL_LT_TIL = 80;\n  public static readonly TIL_STAR = 81;\n  public static readonly TIL_TIL = 82;\n  public static readonly SEMI = 83;\n  public static readonly ErrorCharacter = 84;\n  public static readonly RULE_root = 0;\n  public static readonly RULE_expr = 1;\n  public static readonly RULE_ws_or_comment = 2;\n  public static readonly RULE_field_reference = 3;\n  public static readonly RULE_field_reference_curly = 4;\n  public static readonly RULE_func_name = 5;\n  public static readonly RULE_identifier = 6;\n  // tslint:disable:no-trailing-whitespace\n  public static readonly ruleNames: string[] = [\n    'root',\n    'expr',\n    'ws_or_comment',\n    'field_reference',\n    'field_reference_curly',\n    'func_name',\n    'identifier',\n  ];\n\n  private static readonly _LITERAL_NAMES: Array<string | undefined> = [\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"','\",\n    \"':'\",\n    \"'::'\",\n    \"'$'\",\n    \"'$$'\",\n    \"'*'\",\n    \"'('\",\n    \"')'\",\n    \"'['\",\n    \"']'\",\n    \"'{'\",\n    \"'}'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'.'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'&'\",\n    \"'&&'\",\n    \"'&<'\",\n    \"'@@'\",\n    \"'@>'\",\n    \"'@'\",\n    \"'!'\",\n    \"'!!'\",\n    \"'!='\",\n    \"'^'\",\n    \"'='\",\n    \"'=>'\",\n    \"'>'\",\n    \"'>='\",\n    \"'>>'\",\n    \"'#'\",\n    \"'#='\",\n    \"'#>'\",\n    \"'#>>'\",\n    \"'##'\",\n    \"'->'\",\n    \"'->>'\",\n    \"'-|-'\",\n    \"'<'\",\n    \"'<='\",\n    \"'<@'\",\n    \"'<^'\",\n    \"'<>'\",\n    \"'<->'\",\n    \"'<<'\",\n    \"'<<='\",\n    \"'<?>'\",\n    \"'-'\",\n    \"'%'\",\n    \"'|'\",\n    \"'||'\",\n    \"'||/'\",\n    \"'|/'\",\n    \"'+'\",\n    \"'?'\",\n    \"'?&'\",\n    \"'?#'\",\n    \"'?-'\",\n    \"'?|'\",\n    \"'/'\",\n    \"'~'\",\n    \"'~='\",\n    \"'~>=~'\",\n    \"'~>~'\",\n    \"'~<=~'\",\n    \"'~<~'\",\n    \"'~*'\",\n    \"'~~'\",\n    \"';'\",\n  ];\n  private static readonly _SYMBOLIC_NAMES: Array<string | undefined> = [\n    undefined,\n    'BLOCK_COMMENT',\n    'LINE_COMMENT',\n    'WHITESPACE',\n    'TRUE',\n    'FALSE',\n    'FIELD',\n    'COMMA',\n    'COLON',\n    'COLON_COLON',\n    'DOLLAR',\n    'DOLLAR_DOLLAR',\n    'STAR',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'BIT_STRING',\n    'REGEX_STRING',\n    'NUMERIC_LITERAL',\n    'INTEGER_LITERAL',\n    'HEX_INTEGER_LITERAL',\n    'DOT',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'IDENTIFIER_VARIABLE',\n    'IDENTIFIER_UNICODE',\n    'IDENTIFIER',\n    'AMP',\n    'AMP_AMP',\n    'AMP_LT',\n    'AT_AT',\n    'AT_GT',\n    'AT_SIGN',\n    'BANG',\n    'BANG_BANG',\n    'BANG_EQUAL',\n    'CARET',\n    'EQUAL',\n    'EQUAL_GT',\n    'GT',\n    'GTE',\n    'GT_GT',\n    'HASH',\n    'HASH_EQ',\n    'HASH_GT',\n    'HASH_GT_GT',\n    'HASH_HASH',\n    'HYPHEN_GT',\n    'HYPHEN_GT_GT',\n    'HYPHEN_PIPE_HYPHEN',\n    'LT',\n    'LTE',\n    'LT_AT',\n    'LT_CARET',\n    'LT_GT',\n    'LT_HYPHEN_GT',\n    'LT_LT',\n    'LT_LT_EQ',\n    'LT_QMARK_GT',\n    'MINUS',\n    'PERCENT',\n    'PIPE',\n    'PIPE_PIPE',\n    'PIPE_PIPE_SLASH',\n    'PIPE_SLASH',\n    'PLUS',\n    'QMARK',\n    'QMARK_AMP',\n    'QMARK_HASH',\n    'QMARK_HYPHEN',\n    'QMARK_PIPE',\n    'SLASH',\n    'TIL',\n    'TIL_EQ',\n    'TIL_GTE_TIL',\n    'TIL_GT_TIL',\n    'TIL_LTE_TIL',\n    'TIL_LT_TIL',\n    'TIL_STAR',\n    'TIL_TIL',\n    'SEMI',\n    'ErrorCharacter',\n  ];\n  public static readonly VOCABULARY: Vocabulary = new VocabularyImpl(\n    Formula._LITERAL_NAMES,\n    Formula._SYMBOLIC_NAMES,\n    []\n  );\n\n  // @Override\n  // @NotNull\n  public get vocabulary(): Vocabulary {\n    return Formula.VOCABULARY;\n  }\n  // tslint:enable:no-trailing-whitespace\n\n  // @Override\n  public get grammarFileName(): string {\n    return 'Formula.g4';\n  }\n\n  // @Override\n  public get ruleNames(): string[] {\n    return Formula.ruleNames;\n  }\n\n  // @Override\n  public get serializedATN(): string {\n    return Formula._serializedATN;\n  }\n\n  protected createFailedPredicateException(\n    predicate?: string,\n    message?: string\n  ): FailedPredicateException {\n    return new FailedPredicateException(this, predicate, message);\n  }\n\n  constructor(input: TokenStream) {\n    super(input);\n    this._interp = new ParserATNSimulator(Formula._ATN, this);\n  }\n  // @RuleVersion(0)\n  public root(): RootContext {\n    let _localctx: RootContext = new RootContext(this._ctx, this.state);\n    this.enterRule(_localctx, 0, Formula.RULE_root);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 14;\n        this.expr(0);\n        this.state = 15;\n        this.match(Formula.EOF);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n\n  public expr(): ExprContext;\n  public expr(_p: number): ExprContext;\n  // @RuleVersion(0)\n  public expr(_p?: number): ExprContext {\n    if (_p === undefined) {\n      _p = 0;\n    }\n\n    let _parentctx: ParserRuleContext = this._ctx;\n    let _parentState: number = this.state;\n    let _localctx: ExprContext = new ExprContext(this._ctx, _parentState);\n    let _prevctx: ExprContext = _localctx;\n    let _startState: number = 2;\n    this.enterRecursionRule(_localctx, 2, Formula.RULE_expr, _p);\n    let _la: number;\n    try {\n      let _alt: number;\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 47;\n        this._errHandler.sync(this);\n        switch (this._input.LA(1)) {\n          case Formula.SINGLEQ_STRING_LITERAL:\n            {\n              _localctx = new StringLiteralContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n\n              this.state = 18;\n              this.match(Formula.SINGLEQ_STRING_LITERAL);\n            }\n            break;\n          case Formula.DOUBLEQ_STRING_LITERAL:\n            {\n              _localctx = new StringLiteralContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 19;\n              this.match(Formula.DOUBLEQ_STRING_LITERAL);\n            }\n            break;\n          case Formula.INTEGER_LITERAL:\n            {\n              _localctx = new IntegerLiteralContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 20;\n              this.match(Formula.INTEGER_LITERAL);\n            }\n            break;\n          case Formula.NUMERIC_LITERAL:\n            {\n              _localctx = new DecimalLiteralContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 21;\n              this.match(Formula.NUMERIC_LITERAL);\n            }\n            break;\n          case Formula.TRUE:\n          case Formula.FALSE:\n            {\n              _localctx = new BooleanLiteralContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 22;\n              _la = this._input.LA(1);\n              if (!(_la === Formula.TRUE || _la === Formula.FALSE)) {\n                this._errHandler.recoverInline(this);\n              } else {\n                if (this._input.LA(1) === Token.EOF) {\n                  this.matchedEOF = true;\n                }\n\n                this._errHandler.reportMatch(this);\n                this.consume();\n              }\n            }\n            break;\n          case Formula.BLOCK_COMMENT:\n          case Formula.LINE_COMMENT:\n          case Formula.WHITESPACE:\n            {\n              _localctx = new LeftWhitespaceOrCommentsContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 23;\n              this.ws_or_comment();\n              this.state = 24;\n              this.expr(13);\n            }\n            break;\n          case Formula.OPEN_PAREN:\n            {\n              _localctx = new BracketsContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 26;\n              this.match(Formula.OPEN_PAREN);\n              this.state = 27;\n              this.expr(0);\n              this.state = 28;\n              this.match(Formula.CLOSE_PAREN);\n            }\n            break;\n          case Formula.MINUS:\n            {\n              _localctx = new UnaryOpContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 30;\n              this.match(Formula.MINUS);\n              this.state = 31;\n              this.expr(10);\n            }\n            break;\n          case Formula.IDENTIFIER_VARIABLE:\n            {\n              _localctx = new FieldReferenceCurlyContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 32;\n              this.field_reference_curly();\n            }\n            break;\n          case Formula.IDENTIFIER_UNICODE:\n          case Formula.IDENTIFIER:\n            {\n              _localctx = new FunctionCallContext(_localctx);\n              this._ctx = _localctx;\n              _prevctx = _localctx;\n              this.state = 33;\n              this.func_name();\n              this.state = 34;\n              this.match(Formula.OPEN_PAREN);\n              this.state = 43;\n              this._errHandler.sync(this);\n              _la = this._input.LA(1);\n              if (\n                ((_la & ~0x1f) === 0 &&\n                  ((1 << _la) &\n                    ((1 << Formula.BLOCK_COMMENT) |\n                      (1 << Formula.LINE_COMMENT) |\n                      (1 << Formula.WHITESPACE) |\n                      (1 << Formula.TRUE) |\n                      (1 << Formula.FALSE) |\n                      (1 << Formula.OPEN_PAREN) |\n                      (1 << Formula.NUMERIC_LITERAL) |\n                      (1 << Formula.INTEGER_LITERAL) |\n                      (1 << Formula.SINGLEQ_STRING_LITERAL) |\n                      (1 << Formula.DOUBLEQ_STRING_LITERAL) |\n                      (1 << Formula.IDENTIFIER_VARIABLE) |\n                      (1 << Formula.IDENTIFIER_UNICODE) |\n                      (1 << Formula.IDENTIFIER))) !==\n                    0) ||\n                _la === Formula.MINUS\n              ) {\n                {\n                  this.state = 35;\n                  this.expr(0);\n                  this.state = 40;\n                  this._errHandler.sync(this);\n                  _la = this._input.LA(1);\n                  while (_la === Formula.COMMA) {\n                    {\n                      {\n                        this.state = 36;\n                        this.match(Formula.COMMA);\n                        this.state = 37;\n                        this.expr(0);\n                      }\n                    }\n                    this.state = 42;\n                    this._errHandler.sync(this);\n                    _la = this._input.LA(1);\n                  }\n                }\n              }\n\n              this.state = 45;\n              this.match(Formula.CLOSE_PAREN);\n            }\n            break;\n          default:\n            throw new NoViableAltException(this);\n        }\n        this._ctx._stop = this._input.tryLT(-1);\n        this.state = 74;\n        this._errHandler.sync(this);\n        _alt = this.interpreter.adaptivePredict(this._input, 4, this._ctx);\n        while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER) {\n          if (_alt === 1) {\n            if (this._parseListeners != null) {\n              this.triggerExitRuleEvent();\n            }\n            _prevctx = _localctx;\n            {\n              this.state = 72;\n              this._errHandler.sync(this);\n              switch (this.interpreter.adaptivePredict(this._input, 3, this._ctx)) {\n                case 1:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 49;\n                    if (!this.precpred(this._ctx, 9)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 9)');\n                    }\n                    this.state = 50;\n                    (_localctx as BinaryOpContext)._op = this._input.LT(1);\n                    _la = this._input.LA(1);\n                    if (\n                      !(_la === Formula.STAR || _la === Formula.PERCENT || _la === Formula.SLASH)\n                    ) {\n                      (_localctx as BinaryOpContext)._op = this._errHandler.recoverInline(this);\n                    } else {\n                      if (this._input.LA(1) === Token.EOF) {\n                        this.matchedEOF = true;\n                      }\n\n                      this._errHandler.reportMatch(this);\n                      this.consume();\n                    }\n                    this.state = 51;\n                    this.expr(10);\n                  }\n                  break;\n\n                case 2:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 52;\n                    if (!this.precpred(this._ctx, 8)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 8)');\n                    }\n                    this.state = 53;\n                    (_localctx as BinaryOpContext)._op = this._input.LT(1);\n                    _la = this._input.LA(1);\n                    if (!(_la === Formula.MINUS || _la === Formula.PLUS)) {\n                      (_localctx as BinaryOpContext)._op = this._errHandler.recoverInline(this);\n                    } else {\n                      if (this._input.LA(1) === Token.EOF) {\n                        this.matchedEOF = true;\n                      }\n\n                      this._errHandler.reportMatch(this);\n                      this.consume();\n                    }\n                    this.state = 54;\n                    this.expr(9);\n                  }\n                  break;\n\n                case 3:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 55;\n                    if (!this.precpred(this._ctx, 7)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 7)');\n                    }\n                    this.state = 56;\n                    (_localctx as BinaryOpContext)._op = this._input.LT(1);\n                    _la = this._input.LA(1);\n                    if (\n                      !(\n                        ((_la - 42) & ~0x1f) === 0 &&\n                        ((1 << (_la - 42)) &\n                          ((1 << (Formula.GT - 42)) |\n                            (1 << (Formula.GTE - 42)) |\n                            (1 << (Formula.LT - 42)) |\n                            (1 << (Formula.LTE - 42)))) !==\n                          0\n                      )\n                    ) {\n                      (_localctx as BinaryOpContext)._op = this._errHandler.recoverInline(this);\n                    } else {\n                      if (this._input.LA(1) === Token.EOF) {\n                        this.matchedEOF = true;\n                      }\n\n                      this._errHandler.reportMatch(this);\n                      this.consume();\n                    }\n                    this.state = 57;\n                    this.expr(8);\n                  }\n                  break;\n\n                case 4:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 58;\n                    if (!this.precpred(this._ctx, 6)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 6)');\n                    }\n                    this.state = 59;\n                    (_localctx as BinaryOpContext)._op = this._input.LT(1);\n                    _la = this._input.LA(1);\n                    if (!(_la === Formula.BANG_EQUAL || _la === Formula.EQUAL)) {\n                      (_localctx as BinaryOpContext)._op = this._errHandler.recoverInline(this);\n                    } else {\n                      if (this._input.LA(1) === Token.EOF) {\n                        this.matchedEOF = true;\n                      }\n\n                      this._errHandler.reportMatch(this);\n                      this.consume();\n                    }\n                    this.state = 60;\n                    this.expr(7);\n                  }\n                  break;\n\n                case 5:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 61;\n                    if (!this.precpred(this._ctx, 5)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 5)');\n                    }\n                    this.state = 62;\n                    (_localctx as BinaryOpContext)._op = this.match(Formula.AMP_AMP);\n                    this.state = 63;\n                    this.expr(6);\n                  }\n                  break;\n\n                case 6:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 64;\n                    if (!this.precpred(this._ctx, 4)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 4)');\n                    }\n                    this.state = 65;\n                    (_localctx as BinaryOpContext)._op = this.match(Formula.PIPE_PIPE);\n                    this.state = 66;\n                    this.expr(5);\n                  }\n                  break;\n\n                case 7:\n                  {\n                    _localctx = new BinaryOpContext(new ExprContext(_parentctx, _parentState));\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 67;\n                    if (!this.precpred(this._ctx, 3)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 3)');\n                    }\n                    this.state = 68;\n                    (_localctx as BinaryOpContext)._op = this.match(Formula.AMP);\n                    this.state = 69;\n                    this.expr(4);\n                  }\n                  break;\n\n                case 8:\n                  {\n                    _localctx = new RightWhitespaceOrCommentsContext(\n                      new ExprContext(_parentctx, _parentState)\n                    );\n                    this.pushNewRecursionContext(_localctx, _startState, Formula.RULE_expr);\n                    this.state = 70;\n                    if (!this.precpred(this._ctx, 12)) {\n                      throw this.createFailedPredicateException('this.precpred(this._ctx, 12)');\n                    }\n                    this.state = 71;\n                    this.ws_or_comment();\n                  }\n                  break;\n              }\n            }\n          }\n          this.state = 76;\n          this._errHandler.sync(this);\n          _alt = this.interpreter.adaptivePredict(this._input, 4, this._ctx);\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.unrollRecursionContexts(_parentctx);\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public ws_or_comment(): Ws_or_commentContext {\n    let _localctx: Ws_or_commentContext = new Ws_or_commentContext(this._ctx, this.state);\n    this.enterRule(_localctx, 4, Formula.RULE_ws_or_comment);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 77;\n        _la = this._input.LA(1);\n        if (\n          !(\n            (_la & ~0x1f) === 0 &&\n            ((1 << _la) &\n              ((1 << Formula.BLOCK_COMMENT) |\n                (1 << Formula.LINE_COMMENT) |\n                (1 << Formula.WHITESPACE))) !==\n              0\n          )\n        ) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public field_reference(): Field_referenceContext {\n    let _localctx: Field_referenceContext = new Field_referenceContext(this._ctx, this.state);\n    this.enterRule(_localctx, 6, Formula.RULE_field_reference);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 79;\n        this.match(Formula.IDENTIFIER_UNICODE);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public field_reference_curly(): Field_reference_curlyContext {\n    let _localctx: Field_reference_curlyContext = new Field_reference_curlyContext(\n      this._ctx,\n      this.state\n    );\n    this.enterRule(_localctx, 8, Formula.RULE_field_reference_curly);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 81;\n        this.match(Formula.IDENTIFIER_VARIABLE);\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public func_name(): Func_nameContext {\n    let _localctx: Func_nameContext = new Func_nameContext(this._ctx, this.state);\n    this.enterRule(_localctx, 10, Formula.RULE_func_name);\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 83;\n        this.identifier();\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n  // @RuleVersion(0)\n  public identifier(): IdentifierContext {\n    let _localctx: IdentifierContext = new IdentifierContext(this._ctx, this.state);\n    this.enterRule(_localctx, 12, Formula.RULE_identifier);\n    let _la: number;\n    try {\n      this.enterOuterAlt(_localctx, 1);\n      {\n        this.state = 85;\n        _la = this._input.LA(1);\n        if (!(_la === Formula.IDENTIFIER_UNICODE || _la === Formula.IDENTIFIER)) {\n          this._errHandler.recoverInline(this);\n        } else {\n          if (this._input.LA(1) === Token.EOF) {\n            this.matchedEOF = true;\n          }\n\n          this._errHandler.reportMatch(this);\n          this.consume();\n        }\n      }\n    } catch (re) {\n      if (re instanceof RecognitionException) {\n        _localctx.exception = re;\n        this._errHandler.reportError(this, re);\n        this._errHandler.recover(this, re);\n      } else {\n        throw re;\n      }\n    } finally {\n      this.exitRule();\n    }\n    return _localctx;\n  }\n\n  public sempred(_localctx: RuleContext, ruleIndex: number, predIndex: number): boolean {\n    switch (ruleIndex) {\n      case 1:\n        return this.expr_sempred(_localctx as ExprContext, predIndex);\n    }\n    return true;\n  }\n  private expr_sempred(_localctx: ExprContext, predIndex: number): boolean {\n    switch (predIndex) {\n      case 0:\n        return this.precpred(this._ctx, 9);\n\n      case 1:\n        return this.precpred(this._ctx, 8);\n\n      case 2:\n        return this.precpred(this._ctx, 7);\n\n      case 3:\n        return this.precpred(this._ctx, 6);\n\n      case 4:\n        return this.precpred(this._ctx, 5);\n\n      case 5:\n        return this.precpred(this._ctx, 4);\n\n      case 6:\n        return this.precpred(this._ctx, 3);\n\n      case 7:\n        return this.precpred(this._ctx, 12);\n    }\n    return true;\n  }\n\n  public static readonly _serializedATN: string =\n    '\\x03\\uC91D\\uCABA\\u058D\\uAFBA\\u4F53\\u0607\\uEA8B\\uC241\\x03VZ\\x04\\x02\\t\\x02' +\n    '\\x04\\x03\\t\\x03\\x04\\x04\\t\\x04\\x04\\x05\\t\\x05\\x04\\x06\\t\\x06\\x04\\x07\\t\\x07' +\n    '\\x04\\b\\t\\b\\x03\\x02\\x03\\x02\\x03\\x02\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x07\\x03)' +\n    '\\n\\x03\\f\\x03\\x0E\\x03,\\v\\x03\\x05\\x03.\\n\\x03\\x03\\x03\\x03\\x03\\x05\\x032\\n' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03' +\n    '\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x03\\x07\\x03K\\n\\x03\\f\\x03\\x0E' +\n    '\\x03N\\v\\x03\\x03\\x04\\x03\\x04\\x03\\x05\\x03\\x05\\x03\\x06\\x03\\x06\\x03\\x07\\x03' +\n    '\\x07\\x03\\b\\x03\\b\\x03\\b\\x02\\x02\\x03\\x04\\t\\x02\\x02\\x04\\x02\\x06\\x02\\b\\x02' +\n    '\\n\\x02\\f\\x02\\x0E\\x02\\x02\\t\\x03\\x02\\x06\\x07\\x05\\x02\\x0E\\x0EAALL\\x04\\x02' +\n    '@@FF\\x04\\x02,-78\\x04\\x02((**\\x03\\x02\\x03\\x05\\x03\\x02\\x1E\\x1F\\x02e\\x02' +\n    '\\x10\\x03\\x02\\x02\\x02\\x041\\x03\\x02\\x02\\x02\\x06O\\x03\\x02\\x02\\x02\\bQ\\x03' +\n    '\\x02\\x02\\x02\\nS\\x03\\x02\\x02\\x02\\fU\\x03\\x02\\x02\\x02\\x0EW\\x03\\x02\\x02\\x02' +\n    '\\x10\\x11\\x05\\x04\\x03\\x02\\x11\\x12\\x07\\x02\\x02\\x03\\x12\\x03\\x03\\x02\\x02\\x02' +\n    '\\x13\\x14\\b\\x03\\x01\\x02\\x142\\x07\\x1B\\x02\\x02\\x152\\x07\\x1C\\x02\\x02\\x162' +\n    '\\x07\\x18\\x02\\x02\\x172\\x07\\x17\\x02\\x02\\x182\\t\\x02\\x02\\x02\\x19\\x1A\\x05\\x06' +\n    '\\x04\\x02\\x1A\\x1B\\x05\\x04\\x03\\x0F\\x1B2\\x03\\x02\\x02\\x02\\x1C\\x1D\\x07\\x0F' +\n    '\\x02\\x02\\x1D\\x1E\\x05\\x04\\x03\\x02\\x1E\\x1F\\x07\\x10\\x02\\x02\\x1F2\\x03\\x02' +\n    '\\x02\\x02 !\\x07@\\x02\\x02!2\\x05\\x04\\x03\\f\"2\\x05\\n\\x06\\x02#$\\x05\\f\\x07\\x02' +\n    \"$-\\x07\\x0F\\x02\\x02%*\\x05\\x04\\x03\\x02&'\\x07\\t\\x02\\x02')\\x05\\x04\\x03\\x02\" +\n    '(&\\x03\\x02\\x02\\x02),\\x03\\x02\\x02\\x02*(\\x03\\x02\\x02\\x02*+\\x03\\x02\\x02\\x02' +\n    '+.\\x03\\x02\\x02\\x02,*\\x03\\x02\\x02\\x02-%\\x03\\x02\\x02\\x02-.\\x03\\x02\\x02\\x02' +\n    './\\x03\\x02\\x02\\x02/0\\x07\\x10\\x02\\x0202\\x03\\x02\\x02\\x021\\x13\\x03\\x02\\x02' +\n    '\\x021\\x15\\x03\\x02\\x02\\x021\\x16\\x03\\x02\\x02\\x021\\x17\\x03\\x02\\x02\\x021\\x18' +\n    '\\x03\\x02\\x02\\x021\\x19\\x03\\x02\\x02\\x021\\x1C\\x03\\x02\\x02\\x021 \\x03\\x02\\x02' +\n    '\\x021\"\\x03\\x02\\x02\\x021#\\x03\\x02\\x02\\x022L\\x03\\x02\\x02\\x0234\\f\\v\\x02' +\n    '\\x0245\\t\\x03\\x02\\x025K\\x05\\x04\\x03\\f67\\f\\n\\x02\\x0278\\t\\x04\\x02\\x028K\\x05' +\n    '\\x04\\x03\\v9:\\f\\t\\x02\\x02:;\\t\\x05\\x02\\x02;K\\x05\\x04\\x03\\n<=\\f\\b\\x02\\x02' +\n    '=>\\t\\x06\\x02\\x02>K\\x05\\x04\\x03\\t?@\\f\\x07\\x02\\x02@A\\x07!\\x02\\x02AK\\x05' +\n    '\\x04\\x03\\bBC\\f\\x06\\x02\\x02CD\\x07C\\x02\\x02DK\\x05\\x04\\x03\\x07EF\\f\\x05\\x02' +\n    '\\x02FG\\x07 \\x02\\x02GK\\x05\\x04\\x03\\x06HI\\f\\x0E\\x02\\x02IK\\x05\\x06\\x04\\x02' +\n    'J3\\x03\\x02\\x02\\x02J6\\x03\\x02\\x02\\x02J9\\x03\\x02\\x02\\x02J<\\x03\\x02\\x02\\x02' +\n    'J?\\x03\\x02\\x02\\x02JB\\x03\\x02\\x02\\x02JE\\x03\\x02\\x02\\x02JH\\x03\\x02\\x02\\x02' +\n    'KN\\x03\\x02\\x02\\x02LJ\\x03\\x02\\x02\\x02LM\\x03\\x02\\x02\\x02M\\x05\\x03\\x02\\x02' +\n    '\\x02NL\\x03\\x02\\x02\\x02OP\\t\\x07\\x02\\x02P\\x07\\x03\\x02\\x02\\x02QR\\x07\\x1E' +\n    '\\x02\\x02R\\t\\x03\\x02\\x02\\x02ST\\x07\\x1D\\x02\\x02T\\v\\x03\\x02\\x02\\x02UV\\x05' +\n    '\\x0E\\b\\x02V\\r\\x03\\x02\\x02\\x02WX\\t\\b\\x02\\x02X\\x0F\\x03\\x02\\x02\\x02\\x07*' +\n    '-1JL';\n  public static __ATN: ATN;\n  public static get _ATN(): ATN {\n    if (!Formula.__ATN) {\n      Formula.__ATN = new ATNDeserializer().deserialize(Utils.toCharArray(Formula._serializedATN));\n    }\n\n    return Formula.__ATN;\n  }\n}\n\nexport class RootContext extends ParserRuleContext {\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  public EOF(): TerminalNode {\n    return this.getToken(Formula.EOF, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_root;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitRoot) {\n      return visitor.visitRoot(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class ExprContext extends ParserRuleContext {\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_expr;\n  }\n  public copyFrom(ctx: ExprContext): void {\n    super.copyFrom(ctx);\n  }\n}\nexport class StringLiteralContext extends ExprContext {\n  public SINGLEQ_STRING_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.SINGLEQ_STRING_LITERAL, 0);\n  }\n  public DOUBLEQ_STRING_LITERAL(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.DOUBLEQ_STRING_LITERAL, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitStringLiteral) {\n      return visitor.visitStringLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class IntegerLiteralContext extends ExprContext {\n  public INTEGER_LITERAL(): TerminalNode {\n    return this.getToken(Formula.INTEGER_LITERAL, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitIntegerLiteral) {\n      return visitor.visitIntegerLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class DecimalLiteralContext extends ExprContext {\n  public NUMERIC_LITERAL(): TerminalNode {\n    return this.getToken(Formula.NUMERIC_LITERAL, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitDecimalLiteral) {\n      return visitor.visitDecimalLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class BooleanLiteralContext extends ExprContext {\n  public TRUE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.TRUE, 0);\n  }\n  public FALSE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.FALSE, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitBooleanLiteral) {\n      return visitor.visitBooleanLiteral(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class LeftWhitespaceOrCommentsContext extends ExprContext {\n  public ws_or_comment(): Ws_or_commentContext {\n    return this.getRuleContext(0, Ws_or_commentContext);\n  }\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitLeftWhitespaceOrComments) {\n      return visitor.visitLeftWhitespaceOrComments(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class RightWhitespaceOrCommentsContext extends ExprContext {\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  public ws_or_comment(): Ws_or_commentContext {\n    return this.getRuleContext(0, Ws_or_commentContext);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitRightWhitespaceOrComments) {\n      return visitor.visitRightWhitespaceOrComments(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class BracketsContext extends ExprContext {\n  public OPEN_PAREN(): TerminalNode {\n    return this.getToken(Formula.OPEN_PAREN, 0);\n  }\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  public CLOSE_PAREN(): TerminalNode {\n    return this.getToken(Formula.CLOSE_PAREN, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitBrackets) {\n      return visitor.visitBrackets(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class UnaryOpContext extends ExprContext {\n  public MINUS(): TerminalNode {\n    return this.getToken(Formula.MINUS, 0);\n  }\n  public expr(): ExprContext {\n    return this.getRuleContext(0, ExprContext);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitUnaryOp) {\n      return visitor.visitUnaryOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class BinaryOpContext extends ExprContext {\n  public _op!: Token;\n  public expr(): ExprContext[];\n  public expr(i: number): ExprContext;\n  public expr(i?: number): ExprContext | ExprContext[] {\n    if (i === undefined) {\n      return this.getRuleContexts(ExprContext);\n    } else {\n      return this.getRuleContext(i, ExprContext);\n    }\n  }\n  public SLASH(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.SLASH, 0);\n  }\n  public STAR(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.STAR, 0);\n  }\n  public PERCENT(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.PERCENT, 0);\n  }\n  public PLUS(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.PLUS, 0);\n  }\n  public MINUS(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.MINUS, 0);\n  }\n  public GT(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.GT, 0);\n  }\n  public LT(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.LT, 0);\n  }\n  public GTE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.GTE, 0);\n  }\n  public LTE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.LTE, 0);\n  }\n  public EQUAL(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.EQUAL, 0);\n  }\n  public BANG_EQUAL(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.BANG_EQUAL, 0);\n  }\n  public AMP_AMP(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.AMP_AMP, 0);\n  }\n  public PIPE_PIPE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.PIPE_PIPE, 0);\n  }\n  public AMP(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.AMP, 0);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitBinaryOp) {\n      return visitor.visitBinaryOp(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class FieldReferenceCurlyContext extends ExprContext {\n  public field_reference_curly(): Field_reference_curlyContext {\n    return this.getRuleContext(0, Field_reference_curlyContext);\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitFieldReferenceCurly) {\n      return visitor.visitFieldReferenceCurly(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\nexport class FunctionCallContext extends ExprContext {\n  public func_name(): Func_nameContext {\n    return this.getRuleContext(0, Func_nameContext);\n  }\n  public OPEN_PAREN(): TerminalNode {\n    return this.getToken(Formula.OPEN_PAREN, 0);\n  }\n  public CLOSE_PAREN(): TerminalNode {\n    return this.getToken(Formula.CLOSE_PAREN, 0);\n  }\n  public expr(): ExprContext[];\n  public expr(i: number): ExprContext;\n  public expr(i?: number): ExprContext | ExprContext[] {\n    if (i === undefined) {\n      return this.getRuleContexts(ExprContext);\n    } else {\n      return this.getRuleContext(i, ExprContext);\n    }\n  }\n  public COMMA(): TerminalNode[];\n  public COMMA(i: number): TerminalNode;\n  public COMMA(i?: number): TerminalNode | TerminalNode[] {\n    if (i === undefined) {\n      return this.getTokens(Formula.COMMA);\n    } else {\n      return this.getToken(Formula.COMMA, i);\n    }\n  }\n  constructor(ctx: ExprContext) {\n    super(ctx.parent, ctx.invokingState);\n    this.copyFrom(ctx);\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitFunctionCall) {\n      return visitor.visitFunctionCall(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class Ws_or_commentContext extends ParserRuleContext {\n  public BLOCK_COMMENT(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.BLOCK_COMMENT, 0);\n  }\n  public LINE_COMMENT(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.LINE_COMMENT, 0);\n  }\n  public WHITESPACE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.WHITESPACE, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_ws_or_comment;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitWs_or_comment) {\n      return visitor.visitWs_or_comment(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class Field_referenceContext extends ParserRuleContext {\n  public IDENTIFIER_UNICODE(): TerminalNode {\n    return this.getToken(Formula.IDENTIFIER_UNICODE, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_field_reference;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitField_reference) {\n      return visitor.visitField_reference(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class Field_reference_curlyContext extends ParserRuleContext {\n  public IDENTIFIER_VARIABLE(): TerminalNode {\n    return this.getToken(Formula.IDENTIFIER_VARIABLE, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_field_reference_curly;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitField_reference_curly) {\n      return visitor.visitField_reference_curly(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class Func_nameContext extends ParserRuleContext {\n  public identifier(): IdentifierContext {\n    return this.getRuleContext(0, IdentifierContext);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_func_name;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitFunc_name) {\n      return visitor.visitFunc_name(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n\nexport class IdentifierContext extends ParserRuleContext {\n  public IDENTIFIER(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.IDENTIFIER, 0);\n  }\n  public IDENTIFIER_UNICODE(): TerminalNode | undefined {\n    return this.tryGetToken(Formula.IDENTIFIER_UNICODE, 0);\n  }\n  constructor(parent: ParserRuleContext | undefined, invokingState: number) {\n    super(parent, invokingState);\n  }\n  // @Override\n  public get ruleIndex(): number {\n    return Formula.RULE_identifier;\n  }\n  // @Override\n  public accept<Result>(visitor: FormulaVisitor<Result>): Result {\n    if (visitor.visitIdentifier) {\n      return visitor.visitIdentifier(this);\n    } else {\n      return visitor.visitChildren(this);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/parser/FormulaLexer.g4",
    "content": "// The MIT License\n\n// Copyright 2018 Tal Shprecher\n\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n\n// The above copyright notice and this permission notice shall be included in\n// all copies or substantial portions of the Software.\n\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nlexer grammar FormulaLexer;\n\n\n// Fragments\nfragment A          : ('A'|'a') ;\nfragment B          : ('B'|'b') ;\nfragment C          : ('C'|'c') ;\nfragment D          : ('D'|'d') ;\nfragment E          : ('E'|'e') ;\nfragment F          : ('F'|'f') ;\nfragment G          : ('G'|'g') ;\nfragment H          : ('H'|'h') ;\nfragment I          : ('I'|'i') ;\nfragment J          : ('J'|'j') ;\nfragment K          : ('K'|'k') ;\nfragment L          : ('L'|'l') ;\nfragment M          : ('M'|'m') ;\nfragment N          : ('N'|'n') ;\nfragment O          : ('O'|'o') ;\nfragment P          : ('P'|'p') ;\nfragment Q          : ('Q'|'q') ;\nfragment R          : ('R'|'r') ;\nfragment S          : ('S'|'s') ;\nfragment T          : ('T'|'t') ;\nfragment U          : ('U'|'u') ;\nfragment V          : ('V'|'v') ;\nfragment W          : ('W'|'w') ;\nfragment X          : ('X'|'x') ;\nfragment Y          : ('Y'|'y') ;\nfragment Z          : ('Z'|'z') ;\nfragment UNDERSCORE : '_' ;\n\nfragment HEX_DIGIT                    : [0-9A-F];\nfragment DEC_DIGIT                    : [0-9];\nfragment DQUOTA_STRING                : '\"' ( '\\\\'. | ~('\"' | '\\\\') )* '\"';\nfragment SQUOTA_STRING                : '\\'' ('\\\\'. | ~('\\'' | '\\\\'))* '\\'';\nfragment BQUOTA_STRING                : '`' ( '\\\\'. | '``' | ~('`' | '\\\\'))* '`';\n\n// Skip whitespace and comments\nBLOCK_COMMENT       : '/*' .* '*/';\nLINE_COMMENT        : '//' ~[\\r\\n]*;\nWHITESPACE          : [ \\t\\r\\n]+;\n\nTRUE                                 : T R U E;\nFALSE                                : F A L S E;\n\nFIELD                                : F I E L D;\n\n// LOOKUP                               : L O O K U P;\n\n// language tokens\nCOMMA                                : ',';\nCOLON                                : ':';\nCOLON_COLON                          : '::';\nDOLLAR                               : '$';\nDOLLAR_DOLLAR                        : '$$';\nSTAR                                 : '*';\nOPEN_PAREN                           : '(';\nCLOSE_PAREN                          : ')';\nOPEN_BRACKET                         : '[';\nCLOSE_BRACKET                        : ']';\nL_CURLY                              : '{';\nR_CURLY                              : '}';\nBIT_STRING                           : B '\\'' ('0'|'1')* '\\'';\nREGEX_STRING                         : E SQUOTA_STRING;\nNUMERIC_LITERAL                      : DEC_DIGIT+ '.' DEC_DIGIT+ (E ('-')* DEC_DIGIT+)?;\nINTEGER_LITERAL                      : DEC_DIGIT+ (E DEC_DIGIT+)?;\nHEX_INTEGER_LITERAL                  : 'x' SQUOTA_STRING;\nDOT                                  : '.';\nSINGLEQ_STRING_LITERAL               : SQUOTA_STRING;\nDOUBLEQ_STRING_LITERAL               : DQUOTA_STRING;\nIDENTIFIER_VARIABLE                  : '{' .*? '}';\nIDENTIFIER_UNICODE                   : [a-zA-Z_\\u00A1-\\uFFFF][a-zA-Z_\\u00A1-\\uFFFF0-9]*;\nIDENTIFIER                           : [a-zA-Z_][a-zA-Z_0-9]*;\n\n// operator tokens\nAMP                                  : '&';\nAMP_AMP                              : '&&';\nAMP_LT                               : '&<';\nAT_AT                                : '@@';\nAT_GT                                : '@>';\nAT_SIGN                              : '@';\nBANG                                 : '!';\nBANG_BANG                            : '!!';\nBANG_EQUAL                           : '!=';\nCARET                                : '^';\nEQUAL                                : '=';\nEQUAL_GT                             : '=>';\nGT                                   : '>';\nGTE                                  : '>=';\nGT_GT                                : '>>';\nHASH                                 : '#';\nHASH_EQ                              : '#=';\nHASH_GT                              : '#>';\nHASH_GT_GT                           : '#>>';\nHASH_HASH                            : '##';\nHYPHEN_GT                            : '->';\nHYPHEN_GT_GT                         : '->>';\nHYPHEN_PIPE_HYPHEN                   : '-|-';\nLT                                   : '<';\nLTE                                  : '<=';\nLT_AT                                : '<@';\nLT_CARET                             : '<^';\nLT_GT                                : '<>';\nLT_HYPHEN_GT                         : '<->';\nLT_LT                                : '<<';\nLT_LT_EQ                             : '<<=';\nLT_QMARK_GT                          : '<?>';\nMINUS                                : '-';\nPERCENT                              : '%';\nPIPE                                 : '|';\nPIPE_PIPE                            : '||';\nPIPE_PIPE_SLASH                      : '||/';\nPIPE_SLASH                           : '|/';\nPLUS                                 : '+';\nQMARK                                : '?';\nQMARK_AMP                            : '?&';\nQMARK_HASH                           : '?#';\nQMARK_HYPHEN                         : '?-';\nQMARK_PIPE                           : '?|';\nSLASH                                : '/';\nTIL                                  : '~';\nTIL_EQ                               : '~=';\nTIL_GTE_TIL                          : '~>=~';\nTIL_GT_TIL                           : '~>~';\nTIL_LTE_TIL                          : '~<=~';\nTIL_LT_TIL                           : '~<~';\nTIL_STAR                             : '~*';\nTIL_TIL                              : '~~';\nSEMI:                ';';\n\n// Any character which does not match one of the above rules will appear in the token\n// stream as an ErrorCharacter token. This ensures the lexer itself will never encounter\n// a syntax error, so all error handling may be performed by the parser.\nErrorCharacter\n    :   .\n    ;\n"
  },
  {
    "path": "packages/formula/src/parser/FormulaLexer.interp",
    "content": "token literal names:\nnull\nnull\nnull\nnull\nnull\nnull\nnull\n','\n':'\n'::'\n'$'\n'$$'\n'*'\n'('\n')'\n'['\n']'\n'{'\n'}'\nnull\nnull\nnull\nnull\nnull\n'.'\nnull\nnull\nnull\nnull\nnull\n'&'\n'&&'\n'&<'\n'@@'\n'@>'\n'@'\n'!'\n'!!'\n'!='\n'^'\n'='\n'=>'\n'>'\n'>='\n'>>'\n'#'\n'#='\n'#>'\n'#>>'\n'##'\n'->'\n'->>'\n'-|-'\n'<'\n'<='\n'<@'\n'<^'\n'<>'\n'<->'\n'<<'\n'<<='\n'<?>'\n'-'\n'%'\n'|'\n'||'\n'||/'\n'|/'\n'+'\n'?'\n'?&'\n'?#'\n'?-'\n'?|'\n'/'\n'~'\n'~='\n'~>=~'\n'~>~'\n'~<=~'\n'~<~'\n'~*'\n'~~'\n';'\nnull\n\ntoken symbolic names:\nnull\nBLOCK_COMMENT\nLINE_COMMENT\nWHITESPACE\nTRUE\nFALSE\nFIELD\nCOMMA\nCOLON\nCOLON_COLON\nDOLLAR\nDOLLAR_DOLLAR\nSTAR\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nBIT_STRING\nREGEX_STRING\nNUMERIC_LITERAL\nINTEGER_LITERAL\nHEX_INTEGER_LITERAL\nDOT\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nIDENTIFIER_VARIABLE\nIDENTIFIER_UNICODE\nIDENTIFIER\nAMP\nAMP_AMP\nAMP_LT\nAT_AT\nAT_GT\nAT_SIGN\nBANG\nBANG_BANG\nBANG_EQUAL\nCARET\nEQUAL\nEQUAL_GT\nGT\nGTE\nGT_GT\nHASH\nHASH_EQ\nHASH_GT\nHASH_GT_GT\nHASH_HASH\nHYPHEN_GT\nHYPHEN_GT_GT\nHYPHEN_PIPE_HYPHEN\nLT\nLTE\nLT_AT\nLT_CARET\nLT_GT\nLT_HYPHEN_GT\nLT_LT\nLT_LT_EQ\nLT_QMARK_GT\nMINUS\nPERCENT\nPIPE\nPIPE_PIPE\nPIPE_PIPE_SLASH\nPIPE_SLASH\nPLUS\nQMARK\nQMARK_AMP\nQMARK_HASH\nQMARK_HYPHEN\nQMARK_PIPE\nSLASH\nTIL\nTIL_EQ\nTIL_GTE_TIL\nTIL_GT_TIL\nTIL_LTE_TIL\nTIL_LT_TIL\nTIL_STAR\nTIL_TIL\nSEMI\nErrorCharacter\n\nrule names:\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\nUNDERSCORE\nHEX_DIGIT\nDEC_DIGIT\nDQUOTA_STRING\nSQUOTA_STRING\nBQUOTA_STRING\nBLOCK_COMMENT\nLINE_COMMENT\nWHITESPACE\nTRUE\nFALSE\nFIELD\nCOMMA\nCOLON\nCOLON_COLON\nDOLLAR\nDOLLAR_DOLLAR\nSTAR\nOPEN_PAREN\nCLOSE_PAREN\nOPEN_BRACKET\nCLOSE_BRACKET\nL_CURLY\nR_CURLY\nBIT_STRING\nREGEX_STRING\nNUMERIC_LITERAL\nINTEGER_LITERAL\nHEX_INTEGER_LITERAL\nDOT\nSINGLEQ_STRING_LITERAL\nDOUBLEQ_STRING_LITERAL\nIDENTIFIER_VARIABLE\nIDENTIFIER_UNICODE\nIDENTIFIER\nAMP\nAMP_AMP\nAMP_LT\nAT_AT\nAT_GT\nAT_SIGN\nBANG\nBANG_BANG\nBANG_EQUAL\nCARET\nEQUAL\nEQUAL_GT\nGT\nGTE\nGT_GT\nHASH\nHASH_EQ\nHASH_GT\nHASH_GT_GT\nHASH_HASH\nHYPHEN_GT\nHYPHEN_GT_GT\nHYPHEN_PIPE_HYPHEN\nLT\nLTE\nLT_AT\nLT_CARET\nLT_GT\nLT_HYPHEN_GT\nLT_LT\nLT_LT_EQ\nLT_QMARK_GT\nMINUS\nPERCENT\nPIPE\nPIPE_PIPE\nPIPE_PIPE_SLASH\nPIPE_SLASH\nPLUS\nQMARK\nQMARK_AMP\nQMARK_HASH\nQMARK_HYPHEN\nQMARK_PIPE\nSLASH\nTIL\nTIL_EQ\nTIL_GTE_TIL\nTIL_GT_TIL\nTIL_LTE_TIL\nTIL_LT_TIL\nTIL_STAR\nTIL_TIL\nSEMI\nErrorCharacter\n\nchannel names:\nDEFAULT_TOKEN_CHANNEL\nHIDDEN\n\nmode names:\nDEFAULT_MODE\n\natn:\n[3, 51485, 51898, 1421, 44986, 20307, 1543, 60043, 49729, 2, 86, 641, 8, 1, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 4, 34, 9, 34, 4, 35, 9, 35, 4, 36, 9, 36, 4, 37, 9, 37, 4, 38, 9, 38, 4, 39, 9, 39, 4, 40, 9, 40, 4, 41, 9, 41, 4, 42, 9, 42, 4, 43, 9, 43, 4, 44, 9, 44, 4, 45, 9, 45, 4, 46, 9, 46, 4, 47, 9, 47, 4, 48, 9, 48, 4, 49, 9, 49, 4, 50, 9, 50, 4, 51, 9, 51, 4, 52, 9, 52, 4, 53, 9, 53, 4, 54, 9, 54, 4, 55, 9, 55, 4, 56, 9, 56, 4, 57, 9, 57, 4, 58, 9, 58, 4, 59, 9, 59, 4, 60, 9, 60, 4, 61, 9, 61, 4, 62, 9, 62, 4, 63, 9, 63, 4, 64, 9, 64, 4, 65, 9, 65, 4, 66, 9, 66, 4, 67, 9, 67, 4, 68, 9, 68, 4, 69, 9, 69, 4, 70, 9, 70, 4, 71, 9, 71, 4, 72, 9, 72, 4, 73, 9, 73, 4, 74, 9, 74, 4, 75, 9, 75, 4, 76, 9, 76, 4, 77, 9, 77, 4, 78, 9, 78, 4, 79, 9, 79, 4, 80, 9, 80, 4, 81, 9, 81, 4, 82, 9, 82, 4, 83, 9, 83, 4, 84, 9, 84, 4, 85, 9, 85, 4, 86, 9, 86, 4, 87, 9, 87, 4, 88, 9, 88, 4, 89, 9, 89, 4, 90, 9, 90, 4, 91, 9, 91, 4, 92, 9, 92, 4, 93, 9, 93, 4, 94, 9, 94, 4, 95, 9, 95, 4, 96, 9, 96, 4, 97, 9, 97, 4, 98, 9, 98, 4, 99, 9, 99, 4, 100, 9, 100, 4, 101, 9, 101, 4, 102, 9, 102, 4, 103, 9, 103, 4, 104, 9, 104, 4, 105, 9, 105, 4, 106, 9, 106, 4, 107, 9, 107, 4, 108, 9, 108, 4, 109, 9, 109, 4, 110, 9, 110, 4, 111, 9, 111, 4, 112, 9, 112, 4, 113, 9, 113, 4, 114, 9, 114, 4, 115, 9, 115, 4, 116, 9, 116, 4, 117, 9, 117, 3, 2, 3, 2, 3, 3, 3, 3, 3, 4, 3, 4, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 12, 3, 12, 3, 13, 3, 13, 3, 14, 3, 14, 3, 15, 3, 15, 3, 16, 3, 16, 3, 17, 3, 17, 3, 18, 3, 18, 3, 19, 3, 19, 3, 20, 3, 20, 3, 21, 3, 21, 3, 22, 3, 22, 3, 23, 3, 23, 3, 24, 3, 24, 3, 25, 3, 25, 3, 26, 3, 26, 3, 27, 3, 27, 3, 28, 3, 28, 3, 29, 3, 29, 3, 30, 3, 30, 3, 31, 3, 31, 3, 31, 3, 31, 7, 31, 298, 10, 31, 12, 31, 14, 31, 301, 11, 31, 3, 31, 3, 31, 3, 32, 3, 32, 3, 32, 3, 32, 7, 32, 309, 10, 32, 12, 32, 14, 32, 312, 11, 32, 3, 32, 3, 32, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 7, 33, 322, 10, 33, 12, 33, 14, 33, 325, 11, 33, 3, 33, 3, 33, 3, 34, 3, 34, 3, 34, 3, 34, 7, 34, 333, 10, 34, 12, 34, 14, 34, 336, 11, 34, 3, 34, 3, 34, 3, 34, 3, 35, 3, 35, 3, 35, 3, 35, 7, 35, 345, 10, 35, 12, 35, 14, 35, 348, 11, 35, 3, 36, 6, 36, 351, 10, 36, 13, 36, 14, 36, 352, 3, 37, 3, 37, 3, 37, 3, 37, 3, 37, 3, 38, 3, 38, 3, 38, 3, 38, 3, 38, 3, 38, 3, 39, 3, 39, 3, 39, 3, 39, 3, 39, 3, 39, 3, 40, 3, 40, 3, 41, 3, 41, 3, 42, 3, 42, 3, 42, 3, 43, 3, 43, 3, 44, 3, 44, 3, 44, 3, 45, 3, 45, 3, 46, 3, 46, 3, 47, 3, 47, 3, 48, 3, 48, 3, 49, 3, 49, 3, 50, 3, 50, 3, 51, 3, 51, 3, 52, 3, 52, 3, 52, 7, 52, 401, 10, 52, 12, 52, 14, 52, 404, 11, 52, 3, 52, 3, 52, 3, 53, 3, 53, 3, 53, 3, 54, 6, 54, 412, 10, 54, 13, 54, 14, 54, 413, 3, 54, 3, 54, 6, 54, 418, 10, 54, 13, 54, 14, 54, 419, 3, 54, 3, 54, 7, 54, 424, 10, 54, 12, 54, 14, 54, 427, 11, 54, 3, 54, 6, 54, 430, 10, 54, 13, 54, 14, 54, 431, 5, 54, 434, 10, 54, 3, 55, 6, 55, 437, 10, 55, 13, 55, 14, 55, 438, 3, 55, 3, 55, 6, 55, 443, 10, 55, 13, 55, 14, 55, 444, 5, 55, 447, 10, 55, 3, 56, 3, 56, 3, 56, 3, 57, 3, 57, 3, 58, 3, 58, 3, 59, 3, 59, 3, 60, 3, 60, 7, 60, 460, 10, 60, 12, 60, 14, 60, 463, 11, 60, 3, 60, 3, 60, 3, 61, 3, 61, 7, 61, 469, 10, 61, 12, 61, 14, 61, 472, 11, 61, 3, 62, 3, 62, 7, 62, 476, 10, 62, 12, 62, 14, 62, 479, 11, 62, 3, 63, 3, 63, 3, 64, 3, 64, 3, 64, 3, 65, 3, 65, 3, 65, 3, 66, 3, 66, 3, 66, 3, 67, 3, 67, 3, 67, 3, 68, 3, 68, 3, 69, 3, 69, 3, 70, 3, 70, 3, 70, 3, 71, 3, 71, 3, 71, 3, 72, 3, 72, 3, 73, 3, 73, 3, 74, 3, 74, 3, 74, 3, 75, 3, 75, 3, 76, 3, 76, 3, 76, 3, 77, 3, 77, 3, 77, 3, 78, 3, 78, 3, 79, 3, 79, 3, 79, 3, 80, 3, 80, 3, 80, 3, 81, 3, 81, 3, 81, 3, 81, 3, 82, 3, 82, 3, 82, 3, 83, 3, 83, 3, 83, 3, 84, 3, 84, 3, 84, 3, 84, 3, 85, 3, 85, 3, 85, 3, 85, 3, 86, 3, 86, 3, 87, 3, 87, 3, 87, 3, 88, 3, 88, 3, 88, 3, 89, 3, 89, 3, 89, 3, 90, 3, 90, 3, 90, 3, 91, 3, 91, 3, 91, 3, 91, 3, 92, 3, 92, 3, 92, 3, 93, 3, 93, 3, 93, 3, 93, 3, 94, 3, 94, 3, 94, 3, 94, 3, 95, 3, 95, 3, 96, 3, 96, 3, 97, 3, 97, 3, 98, 3, 98, 3, 98, 3, 99, 3, 99, 3, 99, 3, 99, 3, 100, 3, 100, 3, 100, 3, 101, 3, 101, 3, 102, 3, 102, 3, 103, 3, 103, 3, 103, 3, 104, 3, 104, 3, 104, 3, 105, 3, 105, 3, 105, 3, 106, 3, 106, 3, 106, 3, 107, 3, 107, 3, 108, 3, 108, 3, 109, 3, 109, 3, 109, 3, 110, 3, 110, 3, 110, 3, 110, 3, 110, 3, 111, 3, 111, 3, 111, 3, 111, 3, 112, 3, 112, 3, 112, 3, 112, 3, 112, 3, 113, 3, 113, 3, 113, 3, 113, 3, 114, 3, 114, 3, 114, 3, 115, 3, 115, 3, 115, 3, 116, 3, 116, 3, 117, 3, 117, 3, 461, 2, 2, 118, 3, 2, 2, 5, 2, 2, 7, 2, 2, 9, 2, 2, 11, 2, 2, 13, 2, 2, 15, 2, 2, 17, 2, 2, 19, 2, 2, 21, 2, 2, 23, 2, 2, 25, 2, 2, 27, 2, 2, 29, 2, 2, 31, 2, 2, 33, 2, 2, 35, 2, 2, 37, 2, 2, 39, 2, 2, 41, 2, 2, 43, 2, 2, 45, 2, 2, 47, 2, 2, 49, 2, 2, 51, 2, 2, 53, 2, 2, 55, 2, 2, 57, 2, 2, 59, 2, 2, 61, 2, 2, 63, 2, 2, 65, 2, 2, 67, 2, 3, 69, 2, 4, 71, 2, 5, 73, 2, 6, 75, 2, 7, 77, 2, 8, 79, 2, 9, 81, 2, 10, 83, 2, 11, 85, 2, 12, 87, 2, 13, 89, 2, 14, 91, 2, 15, 93, 2, 16, 95, 2, 17, 97, 2, 18, 99, 2, 19, 101, 2, 20, 103, 2, 21, 105, 2, 22, 107, 2, 23, 109, 2, 24, 111, 2, 25, 113, 2, 26, 115, 2, 27, 117, 2, 28, 119, 2, 29, 121, 2, 30, 123, 2, 31, 125, 2, 32, 127, 2, 33, 129, 2, 34, 131, 2, 35, 133, 2, 36, 135, 2, 37, 137, 2, 38, 139, 2, 39, 141, 2, 40, 143, 2, 41, 145, 2, 42, 147, 2, 43, 149, 2, 44, 151, 2, 45, 153, 2, 46, 155, 2, 47, 157, 2, 48, 159, 2, 49, 161, 2, 50, 163, 2, 51, 165, 2, 52, 167, 2, 53, 169, 2, 54, 171, 2, 55, 173, 2, 56, 175, 2, 57, 177, 2, 58, 179, 2, 59, 181, 2, 60, 183, 2, 61, 185, 2, 62, 187, 2, 63, 189, 2, 64, 191, 2, 65, 193, 2, 66, 195, 2, 67, 197, 2, 68, 199, 2, 69, 201, 2, 70, 203, 2, 71, 205, 2, 72, 207, 2, 73, 209, 2, 74, 211, 2, 75, 213, 2, 76, 215, 2, 77, 217, 2, 78, 219, 2, 79, 221, 2, 80, 223, 2, 81, 225, 2, 82, 227, 2, 83, 229, 2, 84, 231, 2, 85, 233, 2, 86, 3, 2, 39, 4, 2, 67, 67, 99, 99, 4, 2, 68, 68, 100, 100, 4, 2, 69, 69, 101, 101, 4, 2, 70, 70, 102, 102, 4, 2, 71, 71, 103, 103, 4, 2, 72, 72, 104, 104, 4, 2, 73, 73, 105, 105, 4, 2, 74, 74, 106, 106, 4, 2, 75, 75, 107, 107, 4, 2, 76, 76, 108, 108, 4, 2, 77, 77, 109, 109, 4, 2, 78, 78, 110, 110, 4, 2, 79, 79, 111, 111, 4, 2, 80, 80, 112, 112, 4, 2, 81, 81, 113, 113, 4, 2, 82, 82, 114, 114, 4, 2, 83, 83, 115, 115, 4, 2, 84, 84, 116, 116, 4, 2, 85, 85, 117, 117, 4, 2, 86, 86, 118, 118, 4, 2, 87, 87, 119, 119, 4, 2, 88, 88, 120, 120, 4, 2, 89, 89, 121, 121, 4, 2, 90, 90, 122, 122, 4, 2, 91, 91, 123, 123, 4, 2, 92, 92, 124, 124, 4, 2, 50, 59, 67, 72, 3, 2, 50, 59, 4, 2, 36, 36, 94, 94, 4, 2, 41, 41, 94, 94, 4, 2, 94, 94, 98, 98, 4, 2, 12, 12, 15, 15, 5, 2, 11, 12, 15, 15, 34, 34, 6, 2, 67, 92, 97, 97, 99, 124, 163, 1, 7, 2, 50, 59, 67, 92, 97, 97, 99, 124, 163, 1, 5, 2, 67, 92, 97, 97, 99, 124, 6, 2, 50, 59, 67, 92, 97, 97, 99, 124, 2, 630, 2, 67, 3, 2, 2, 2, 2, 69, 3, 2, 2, 2, 2, 71, 3, 2, 2, 2, 2, 73, 3, 2, 2, 2, 2, 75, 3, 2, 2, 2, 2, 77, 3, 2, 2, 2, 2, 79, 3, 2, 2, 2, 2, 81, 3, 2, 2, 2, 2, 83, 3, 2, 2, 2, 2, 85, 3, 2, 2, 2, 2, 87, 3, 2, 2, 2, 2, 89, 3, 2, 2, 2, 2, 91, 3, 2, 2, 2, 2, 93, 3, 2, 2, 2, 2, 95, 3, 2, 2, 2, 2, 97, 3, 2, 2, 2, 2, 99, 3, 2, 2, 2, 2, 101, 3, 2, 2, 2, 2, 103, 3, 2, 2, 2, 2, 105, 3, 2, 2, 2, 2, 107, 3, 2, 2, 2, 2, 109, 3, 2, 2, 2, 2, 111, 3, 2, 2, 2, 2, 113, 3, 2, 2, 2, 2, 115, 3, 2, 2, 2, 2, 117, 3, 2, 2, 2, 2, 119, 3, 2, 2, 2, 2, 121, 3, 2, 2, 2, 2, 123, 3, 2, 2, 2, 2, 125, 3, 2, 2, 2, 2, 127, 3, 2, 2, 2, 2, 129, 3, 2, 2, 2, 2, 131, 3, 2, 2, 2, 2, 133, 3, 2, 2, 2, 2, 135, 3, 2, 2, 2, 2, 137, 3, 2, 2, 2, 2, 139, 3, 2, 2, 2, 2, 141, 3, 2, 2, 2, 2, 143, 3, 2, 2, 2, 2, 145, 3, 2, 2, 2, 2, 147, 3, 2, 2, 2, 2, 149, 3, 2, 2, 2, 2, 151, 3, 2, 2, 2, 2, 153, 3, 2, 2, 2, 2, 155, 3, 2, 2, 2, 2, 157, 3, 2, 2, 2, 2, 159, 3, 2, 2, 2, 2, 161, 3, 2, 2, 2, 2, 163, 3, 2, 2, 2, 2, 165, 3, 2, 2, 2, 2, 167, 3, 2, 2, 2, 2, 169, 3, 2, 2, 2, 2, 171, 3, 2, 2, 2, 2, 173, 3, 2, 2, 2, 2, 175, 3, 2, 2, 2, 2, 177, 3, 2, 2, 2, 2, 179, 3, 2, 2, 2, 2, 181, 3, 2, 2, 2, 2, 183, 3, 2, 2, 2, 2, 185, 3, 2, 2, 2, 2, 187, 3, 2, 2, 2, 2, 189, 3, 2, 2, 2, 2, 191, 3, 2, 2, 2, 2, 193, 3, 2, 2, 2, 2, 195, 3, 2, 2, 2, 2, 197, 3, 2, 2, 2, 2, 199, 3, 2, 2, 2, 2, 201, 3, 2, 2, 2, 2, 203, 3, 2, 2, 2, 2, 205, 3, 2, 2, 2, 2, 207, 3, 2, 2, 2, 2, 209, 3, 2, 2, 2, 2, 211, 3, 2, 2, 2, 2, 213, 3, 2, 2, 2, 2, 215, 3, 2, 2, 2, 2, 217, 3, 2, 2, 2, 2, 219, 3, 2, 2, 2, 2, 221, 3, 2, 2, 2, 2, 223, 3, 2, 2, 2, 2, 225, 3, 2, 2, 2, 2, 227, 3, 2, 2, 2, 2, 229, 3, 2, 2, 2, 2, 231, 3, 2, 2, 2, 2, 233, 3, 2, 2, 2, 3, 235, 3, 2, 2, 2, 5, 237, 3, 2, 2, 2, 7, 239, 3, 2, 2, 2, 9, 241, 3, 2, 2, 2, 11, 243, 3, 2, 2, 2, 13, 245, 3, 2, 2, 2, 15, 247, 3, 2, 2, 2, 17, 249, 3, 2, 2, 2, 19, 251, 3, 2, 2, 2, 21, 253, 3, 2, 2, 2, 23, 255, 3, 2, 2, 2, 25, 257, 3, 2, 2, 2, 27, 259, 3, 2, 2, 2, 29, 261, 3, 2, 2, 2, 31, 263, 3, 2, 2, 2, 33, 265, 3, 2, 2, 2, 35, 267, 3, 2, 2, 2, 37, 269, 3, 2, 2, 2, 39, 271, 3, 2, 2, 2, 41, 273, 3, 2, 2, 2, 43, 275, 3, 2, 2, 2, 45, 277, 3, 2, 2, 2, 47, 279, 3, 2, 2, 2, 49, 281, 3, 2, 2, 2, 51, 283, 3, 2, 2, 2, 53, 285, 3, 2, 2, 2, 55, 287, 3, 2, 2, 2, 57, 289, 3, 2, 2, 2, 59, 291, 3, 2, 2, 2, 61, 293, 3, 2, 2, 2, 63, 304, 3, 2, 2, 2, 65, 315, 3, 2, 2, 2, 67, 328, 3, 2, 2, 2, 69, 340, 3, 2, 2, 2, 71, 350, 3, 2, 2, 2, 73, 354, 3, 2, 2, 2, 75, 359, 3, 2, 2, 2, 77, 365, 3, 2, 2, 2, 79, 371, 3, 2, 2, 2, 81, 373, 3, 2, 2, 2, 83, 375, 3, 2, 2, 2, 85, 378, 3, 2, 2, 2, 87, 380, 3, 2, 2, 2, 89, 383, 3, 2, 2, 2, 91, 385, 3, 2, 2, 2, 93, 387, 3, 2, 2, 2, 95, 389, 3, 2, 2, 2, 97, 391, 3, 2, 2, 2, 99, 393, 3, 2, 2, 2, 101, 395, 3, 2, 2, 2, 103, 397, 3, 2, 2, 2, 105, 407, 3, 2, 2, 2, 107, 411, 3, 2, 2, 2, 109, 436, 3, 2, 2, 2, 111, 448, 3, 2, 2, 2, 113, 451, 3, 2, 2, 2, 115, 453, 3, 2, 2, 2, 117, 455, 3, 2, 2, 2, 119, 457, 3, 2, 2, 2, 121, 466, 3, 2, 2, 2, 123, 473, 3, 2, 2, 2, 125, 480, 3, 2, 2, 2, 127, 482, 3, 2, 2, 2, 129, 485, 3, 2, 2, 2, 131, 488, 3, 2, 2, 2, 133, 491, 3, 2, 2, 2, 135, 494, 3, 2, 2, 2, 137, 496, 3, 2, 2, 2, 139, 498, 3, 2, 2, 2, 141, 501, 3, 2, 2, 2, 143, 504, 3, 2, 2, 2, 145, 506, 3, 2, 2, 2, 147, 508, 3, 2, 2, 2, 149, 511, 3, 2, 2, 2, 151, 513, 3, 2, 2, 2, 153, 516, 3, 2, 2, 2, 155, 519, 3, 2, 2, 2, 157, 521, 3, 2, 2, 2, 159, 524, 3, 2, 2, 2, 161, 527, 3, 2, 2, 2, 163, 531, 3, 2, 2, 2, 165, 534, 3, 2, 2, 2, 167, 537, 3, 2, 2, 2, 169, 541, 3, 2, 2, 2, 171, 545, 3, 2, 2, 2, 173, 547, 3, 2, 2, 2, 175, 550, 3, 2, 2, 2, 177, 553, 3, 2, 2, 2, 179, 556, 3, 2, 2, 2, 181, 559, 3, 2, 2, 2, 183, 563, 3, 2, 2, 2, 185, 566, 3, 2, 2, 2, 187, 570, 3, 2, 2, 2, 189, 574, 3, 2, 2, 2, 191, 576, 3, 2, 2, 2, 193, 578, 3, 2, 2, 2, 195, 580, 3, 2, 2, 2, 197, 583, 3, 2, 2, 2, 199, 587, 3, 2, 2, 2, 201, 590, 3, 2, 2, 2, 203, 592, 3, 2, 2, 2, 205, 594, 3, 2, 2, 2, 207, 597, 3, 2, 2, 2, 209, 600, 3, 2, 2, 2, 211, 603, 3, 2, 2, 2, 213, 606, 3, 2, 2, 2, 215, 608, 3, 2, 2, 2, 217, 610, 3, 2, 2, 2, 219, 613, 3, 2, 2, 2, 221, 618, 3, 2, 2, 2, 223, 622, 3, 2, 2, 2, 225, 627, 3, 2, 2, 2, 227, 631, 3, 2, 2, 2, 229, 634, 3, 2, 2, 2, 231, 637, 3, 2, 2, 2, 233, 639, 3, 2, 2, 2, 235, 236, 9, 2, 2, 2, 236, 4, 3, 2, 2, 2, 237, 238, 9, 3, 2, 2, 238, 6, 3, 2, 2, 2, 239, 240, 9, 4, 2, 2, 240, 8, 3, 2, 2, 2, 241, 242, 9, 5, 2, 2, 242, 10, 3, 2, 2, 2, 243, 244, 9, 6, 2, 2, 244, 12, 3, 2, 2, 2, 245, 246, 9, 7, 2, 2, 246, 14, 3, 2, 2, 2, 247, 248, 9, 8, 2, 2, 248, 16, 3, 2, 2, 2, 249, 250, 9, 9, 2, 2, 250, 18, 3, 2, 2, 2, 251, 252, 9, 10, 2, 2, 252, 20, 3, 2, 2, 2, 253, 254, 9, 11, 2, 2, 254, 22, 3, 2, 2, 2, 255, 256, 9, 12, 2, 2, 256, 24, 3, 2, 2, 2, 257, 258, 9, 13, 2, 2, 258, 26, 3, 2, 2, 2, 259, 260, 9, 14, 2, 2, 260, 28, 3, 2, 2, 2, 261, 262, 9, 15, 2, 2, 262, 30, 3, 2, 2, 2, 263, 264, 9, 16, 2, 2, 264, 32, 3, 2, 2, 2, 265, 266, 9, 17, 2, 2, 266, 34, 3, 2, 2, 2, 267, 268, 9, 18, 2, 2, 268, 36, 3, 2, 2, 2, 269, 270, 9, 19, 2, 2, 270, 38, 3, 2, 2, 2, 271, 272, 9, 20, 2, 2, 272, 40, 3, 2, 2, 2, 273, 274, 9, 21, 2, 2, 274, 42, 3, 2, 2, 2, 275, 276, 9, 22, 2, 2, 276, 44, 3, 2, 2, 2, 277, 278, 9, 23, 2, 2, 278, 46, 3, 2, 2, 2, 279, 280, 9, 24, 2, 2, 280, 48, 3, 2, 2, 2, 281, 282, 9, 25, 2, 2, 282, 50, 3, 2, 2, 2, 283, 284, 9, 26, 2, 2, 284, 52, 3, 2, 2, 2, 285, 286, 9, 27, 2, 2, 286, 54, 3, 2, 2, 2, 287, 288, 7, 97, 2, 2, 288, 56, 3, 2, 2, 2, 289, 290, 9, 28, 2, 2, 290, 58, 3, 2, 2, 2, 291, 292, 9, 29, 2, 2, 292, 60, 3, 2, 2, 2, 293, 299, 7, 36, 2, 2, 294, 295, 7, 94, 2, 2, 295, 298, 11, 2, 2, 2, 296, 298, 10, 30, 2, 2, 297, 294, 3, 2, 2, 2, 297, 296, 3, 2, 2, 2, 298, 301, 3, 2, 2, 2, 299, 297, 3, 2, 2, 2, 299, 300, 3, 2, 2, 2, 300, 302, 3, 2, 2, 2, 301, 299, 3, 2, 2, 2, 302, 303, 7, 36, 2, 2, 303, 62, 3, 2, 2, 2, 304, 310, 7, 41, 2, 2, 305, 306, 7, 94, 2, 2, 306, 309, 11, 2, 2, 2, 307, 309, 10, 31, 2, 2, 308, 305, 3, 2, 2, 2, 308, 307, 3, 2, 2, 2, 309, 312, 3, 2, 2, 2, 310, 308, 3, 2, 2, 2, 310, 311, 3, 2, 2, 2, 311, 313, 3, 2, 2, 2, 312, 310, 3, 2, 2, 2, 313, 314, 7, 41, 2, 2, 314, 64, 3, 2, 2, 2, 315, 323, 7, 98, 2, 2, 316, 317, 7, 94, 2, 2, 317, 322, 11, 2, 2, 2, 318, 319, 7, 98, 2, 2, 319, 322, 7, 98, 2, 2, 320, 322, 10, 32, 2, 2, 321, 316, 3, 2, 2, 2, 321, 318, 3, 2, 2, 2, 321, 320, 3, 2, 2, 2, 322, 325, 3, 2, 2, 2, 323, 321, 3, 2, 2, 2, 323, 324, 3, 2, 2, 2, 324, 326, 3, 2, 2, 2, 325, 323, 3, 2, 2, 2, 326, 327, 7, 98, 2, 2, 327, 66, 3, 2, 2, 2, 328, 329, 7, 49, 2, 2, 329, 330, 7, 44, 2, 2, 330, 334, 3, 2, 2, 2, 331, 333, 11, 2, 2, 2, 332, 331, 3, 2, 2, 2, 333, 336, 3, 2, 2, 2, 334, 332, 3, 2, 2, 2, 334, 335, 3, 2, 2, 2, 335, 337, 3, 2, 2, 2, 336, 334, 3, 2, 2, 2, 337, 338, 7, 44, 2, 2, 338, 339, 7, 49, 2, 2, 339, 68, 3, 2, 2, 2, 340, 341, 7, 49, 2, 2, 341, 342, 7, 49, 2, 2, 342, 346, 3, 2, 2, 2, 343, 345, 10, 33, 2, 2, 344, 343, 3, 2, 2, 2, 345, 348, 3, 2, 2, 2, 346, 344, 3, 2, 2, 2, 346, 347, 3, 2, 2, 2, 347, 70, 3, 2, 2, 2, 348, 346, 3, 2, 2, 2, 349, 351, 9, 34, 2, 2, 350, 349, 3, 2, 2, 2, 351, 352, 3, 2, 2, 2, 352, 350, 3, 2, 2, 2, 352, 353, 3, 2, 2, 2, 353, 72, 3, 2, 2, 2, 354, 355, 5, 41, 21, 2, 355, 356, 5, 37, 19, 2, 356, 357, 5, 43, 22, 2, 357, 358, 5, 11, 6, 2, 358, 74, 3, 2, 2, 2, 359, 360, 5, 13, 7, 2, 360, 361, 5, 3, 2, 2, 361, 362, 5, 25, 13, 2, 362, 363, 5, 39, 20, 2, 363, 364, 5, 11, 6, 2, 364, 76, 3, 2, 2, 2, 365, 366, 5, 13, 7, 2, 366, 367, 5, 19, 10, 2, 367, 368, 5, 11, 6, 2, 368, 369, 5, 25, 13, 2, 369, 370, 5, 9, 5, 2, 370, 78, 3, 2, 2, 2, 371, 372, 7, 46, 2, 2, 372, 80, 3, 2, 2, 2, 373, 374, 7, 60, 2, 2, 374, 82, 3, 2, 2, 2, 375, 376, 7, 60, 2, 2, 376, 377, 7, 60, 2, 2, 377, 84, 3, 2, 2, 2, 378, 379, 7, 38, 2, 2, 379, 86, 3, 2, 2, 2, 380, 381, 7, 38, 2, 2, 381, 382, 7, 38, 2, 2, 382, 88, 3, 2, 2, 2, 383, 384, 7, 44, 2, 2, 384, 90, 3, 2, 2, 2, 385, 386, 7, 42, 2, 2, 386, 92, 3, 2, 2, 2, 387, 388, 7, 43, 2, 2, 388, 94, 3, 2, 2, 2, 389, 390, 7, 93, 2, 2, 390, 96, 3, 2, 2, 2, 391, 392, 7, 95, 2, 2, 392, 98, 3, 2, 2, 2, 393, 394, 7, 125, 2, 2, 394, 100, 3, 2, 2, 2, 395, 396, 7, 127, 2, 2, 396, 102, 3, 2, 2, 2, 397, 398, 5, 5, 3, 2, 398, 402, 7, 41, 2, 2, 399, 401, 4, 50, 51, 2, 400, 399, 3, 2, 2, 2, 401, 404, 3, 2, 2, 2, 402, 400, 3, 2, 2, 2, 402, 403, 3, 2, 2, 2, 403, 405, 3, 2, 2, 2, 404, 402, 3, 2, 2, 2, 405, 406, 7, 41, 2, 2, 406, 104, 3, 2, 2, 2, 407, 408, 5, 11, 6, 2, 408, 409, 5, 63, 32, 2, 409, 106, 3, 2, 2, 2, 410, 412, 5, 59, 30, 2, 411, 410, 3, 2, 2, 2, 412, 413, 3, 2, 2, 2, 413, 411, 3, 2, 2, 2, 413, 414, 3, 2, 2, 2, 414, 415, 3, 2, 2, 2, 415, 417, 7, 48, 2, 2, 416, 418, 5, 59, 30, 2, 417, 416, 3, 2, 2, 2, 418, 419, 3, 2, 2, 2, 419, 417, 3, 2, 2, 2, 419, 420, 3, 2, 2, 2, 420, 433, 3, 2, 2, 2, 421, 425, 5, 11, 6, 2, 422, 424, 7, 47, 2, 2, 423, 422, 3, 2, 2, 2, 424, 427, 3, 2, 2, 2, 425, 423, 3, 2, 2, 2, 425, 426, 3, 2, 2, 2, 426, 429, 3, 2, 2, 2, 427, 425, 3, 2, 2, 2, 428, 430, 5, 59, 30, 2, 429, 428, 3, 2, 2, 2, 430, 431, 3, 2, 2, 2, 431, 429, 3, 2, 2, 2, 431, 432, 3, 2, 2, 2, 432, 434, 3, 2, 2, 2, 433, 421, 3, 2, 2, 2, 433, 434, 3, 2, 2, 2, 434, 108, 3, 2, 2, 2, 435, 437, 5, 59, 30, 2, 436, 435, 3, 2, 2, 2, 437, 438, 3, 2, 2, 2, 438, 436, 3, 2, 2, 2, 438, 439, 3, 2, 2, 2, 439, 446, 3, 2, 2, 2, 440, 442, 5, 11, 6, 2, 441, 443, 5, 59, 30, 2, 442, 441, 3, 2, 2, 2, 443, 444, 3, 2, 2, 2, 444, 442, 3, 2, 2, 2, 444, 445, 3, 2, 2, 2, 445, 447, 3, 2, 2, 2, 446, 440, 3, 2, 2, 2, 446, 447, 3, 2, 2, 2, 447, 110, 3, 2, 2, 2, 448, 449, 7, 122, 2, 2, 449, 450, 5, 63, 32, 2, 450, 112, 3, 2, 2, 2, 451, 452, 7, 48, 2, 2, 452, 114, 3, 2, 2, 2, 453, 454, 5, 63, 32, 2, 454, 116, 3, 2, 2, 2, 455, 456, 5, 61, 31, 2, 456, 118, 3, 2, 2, 2, 457, 461, 7, 125, 2, 2, 458, 460, 11, 2, 2, 2, 459, 458, 3, 2, 2, 2, 460, 463, 3, 2, 2, 2, 461, 462, 3, 2, 2, 2, 461, 459, 3, 2, 2, 2, 462, 464, 3, 2, 2, 2, 463, 461, 3, 2, 2, 2, 464, 465, 7, 127, 2, 2, 465, 120, 3, 2, 2, 2, 466, 470, 9, 35, 2, 2, 467, 469, 9, 36, 2, 2, 468, 467, 3, 2, 2, 2, 469, 472, 3, 2, 2, 2, 470, 468, 3, 2, 2, 2, 470, 471, 3, 2, 2, 2, 471, 122, 3, 2, 2, 2, 472, 470, 3, 2, 2, 2, 473, 477, 9, 37, 2, 2, 474, 476, 9, 38, 2, 2, 475, 474, 3, 2, 2, 2, 476, 479, 3, 2, 2, 2, 477, 475, 3, 2, 2, 2, 477, 478, 3, 2, 2, 2, 478, 124, 3, 2, 2, 2, 479, 477, 3, 2, 2, 2, 480, 481, 7, 40, 2, 2, 481, 126, 3, 2, 2, 2, 482, 483, 7, 40, 2, 2, 483, 484, 7, 40, 2, 2, 484, 128, 3, 2, 2, 2, 485, 486, 7, 40, 2, 2, 486, 487, 7, 62, 2, 2, 487, 130, 3, 2, 2, 2, 488, 489, 7, 66, 2, 2, 489, 490, 7, 66, 2, 2, 490, 132, 3, 2, 2, 2, 491, 492, 7, 66, 2, 2, 492, 493, 7, 64, 2, 2, 493, 134, 3, 2, 2, 2, 494, 495, 7, 66, 2, 2, 495, 136, 3, 2, 2, 2, 496, 497, 7, 35, 2, 2, 497, 138, 3, 2, 2, 2, 498, 499, 7, 35, 2, 2, 499, 500, 7, 35, 2, 2, 500, 140, 3, 2, 2, 2, 501, 502, 7, 35, 2, 2, 502, 503, 7, 63, 2, 2, 503, 142, 3, 2, 2, 2, 504, 505, 7, 96, 2, 2, 505, 144, 3, 2, 2, 2, 506, 507, 7, 63, 2, 2, 507, 146, 3, 2, 2, 2, 508, 509, 7, 63, 2, 2, 509, 510, 7, 64, 2, 2, 510, 148, 3, 2, 2, 2, 511, 512, 7, 64, 2, 2, 512, 150, 3, 2, 2, 2, 513, 514, 7, 64, 2, 2, 514, 515, 7, 63, 2, 2, 515, 152, 3, 2, 2, 2, 516, 517, 7, 64, 2, 2, 517, 518, 7, 64, 2, 2, 518, 154, 3, 2, 2, 2, 519, 520, 7, 37, 2, 2, 520, 156, 3, 2, 2, 2, 521, 522, 7, 37, 2, 2, 522, 523, 7, 63, 2, 2, 523, 158, 3, 2, 2, 2, 524, 525, 7, 37, 2, 2, 525, 526, 7, 64, 2, 2, 526, 160, 3, 2, 2, 2, 527, 528, 7, 37, 2, 2, 528, 529, 7, 64, 2, 2, 529, 530, 7, 64, 2, 2, 530, 162, 3, 2, 2, 2, 531, 532, 7, 37, 2, 2, 532, 533, 7, 37, 2, 2, 533, 164, 3, 2, 2, 2, 534, 535, 7, 47, 2, 2, 535, 536, 7, 64, 2, 2, 536, 166, 3, 2, 2, 2, 537, 538, 7, 47, 2, 2, 538, 539, 7, 64, 2, 2, 539, 540, 7, 64, 2, 2, 540, 168, 3, 2, 2, 2, 541, 542, 7, 47, 2, 2, 542, 543, 7, 126, 2, 2, 543, 544, 7, 47, 2, 2, 544, 170, 3, 2, 2, 2, 545, 546, 7, 62, 2, 2, 546, 172, 3, 2, 2, 2, 547, 548, 7, 62, 2, 2, 548, 549, 7, 63, 2, 2, 549, 174, 3, 2, 2, 2, 550, 551, 7, 62, 2, 2, 551, 552, 7, 66, 2, 2, 552, 176, 3, 2, 2, 2, 553, 554, 7, 62, 2, 2, 554, 555, 7, 96, 2, 2, 555, 178, 3, 2, 2, 2, 556, 557, 7, 62, 2, 2, 557, 558, 7, 64, 2, 2, 558, 180, 3, 2, 2, 2, 559, 560, 7, 62, 2, 2, 560, 561, 7, 47, 2, 2, 561, 562, 7, 64, 2, 2, 562, 182, 3, 2, 2, 2, 563, 564, 7, 62, 2, 2, 564, 565, 7, 62, 2, 2, 565, 184, 3, 2, 2, 2, 566, 567, 7, 62, 2, 2, 567, 568, 7, 62, 2, 2, 568, 569, 7, 63, 2, 2, 569, 186, 3, 2, 2, 2, 570, 571, 7, 62, 2, 2, 571, 572, 7, 65, 2, 2, 572, 573, 7, 64, 2, 2, 573, 188, 3, 2, 2, 2, 574, 575, 7, 47, 2, 2, 575, 190, 3, 2, 2, 2, 576, 577, 7, 39, 2, 2, 577, 192, 3, 2, 2, 2, 578, 579, 7, 126, 2, 2, 579, 194, 3, 2, 2, 2, 580, 581, 7, 126, 2, 2, 581, 582, 7, 126, 2, 2, 582, 196, 3, 2, 2, 2, 583, 584, 7, 126, 2, 2, 584, 585, 7, 126, 2, 2, 585, 586, 7, 49, 2, 2, 586, 198, 3, 2, 2, 2, 587, 588, 7, 126, 2, 2, 588, 589, 7, 49, 2, 2, 589, 200, 3, 2, 2, 2, 590, 591, 7, 45, 2, 2, 591, 202, 3, 2, 2, 2, 592, 593, 7, 65, 2, 2, 593, 204, 3, 2, 2, 2, 594, 595, 7, 65, 2, 2, 595, 596, 7, 40, 2, 2, 596, 206, 3, 2, 2, 2, 597, 598, 7, 65, 2, 2, 598, 599, 7, 37, 2, 2, 599, 208, 3, 2, 2, 2, 600, 601, 7, 65, 2, 2, 601, 602, 7, 47, 2, 2, 602, 210, 3, 2, 2, 2, 603, 604, 7, 65, 2, 2, 604, 605, 7, 126, 2, 2, 605, 212, 3, 2, 2, 2, 606, 607, 7, 49, 2, 2, 607, 214, 3, 2, 2, 2, 608, 609, 7, 128, 2, 2, 609, 216, 3, 2, 2, 2, 610, 611, 7, 128, 2, 2, 611, 612, 7, 63, 2, 2, 612, 218, 3, 2, 2, 2, 613, 614, 7, 128, 2, 2, 614, 615, 7, 64, 2, 2, 615, 616, 7, 63, 2, 2, 616, 617, 7, 128, 2, 2, 617, 220, 3, 2, 2, 2, 618, 619, 7, 128, 2, 2, 619, 620, 7, 64, 2, 2, 620, 621, 7, 128, 2, 2, 621, 222, 3, 2, 2, 2, 622, 623, 7, 128, 2, 2, 623, 624, 7, 62, 2, 2, 624, 625, 7, 63, 2, 2, 625, 626, 7, 128, 2, 2, 626, 224, 3, 2, 2, 2, 627, 628, 7, 128, 2, 2, 628, 629, 7, 62, 2, 2, 629, 630, 7, 128, 2, 2, 630, 226, 3, 2, 2, 2, 631, 632, 7, 128, 2, 2, 632, 633, 7, 44, 2, 2, 633, 228, 3, 2, 2, 2, 634, 635, 7, 128, 2, 2, 635, 636, 7, 128, 2, 2, 636, 230, 3, 2, 2, 2, 637, 638, 7, 61, 2, 2, 638, 232, 3, 2, 2, 2, 639, 640, 11, 2, 2, 2, 640, 234, 3, 2, 2, 2, 24, 2, 297, 299, 308, 310, 321, 323, 334, 346, 352, 402, 413, 419, 425, 431, 433, 438, 444, 446, 461, 470, 477, 2]"
  },
  {
    "path": "packages/formula/src/parser/FormulaLexer.tokens",
    "content": "BLOCK_COMMENT=1\nLINE_COMMENT=2\nWHITESPACE=3\nTRUE=4\nFALSE=5\nFIELD=6\nCOMMA=7\nCOLON=8\nCOLON_COLON=9\nDOLLAR=10\nDOLLAR_DOLLAR=11\nSTAR=12\nOPEN_PAREN=13\nCLOSE_PAREN=14\nOPEN_BRACKET=15\nCLOSE_BRACKET=16\nL_CURLY=17\nR_CURLY=18\nBIT_STRING=19\nREGEX_STRING=20\nNUMERIC_LITERAL=21\nINTEGER_LITERAL=22\nHEX_INTEGER_LITERAL=23\nDOT=24\nSINGLEQ_STRING_LITERAL=25\nDOUBLEQ_STRING_LITERAL=26\nIDENTIFIER_VARIABLE=27\nIDENTIFIER_UNICODE=28\nIDENTIFIER=29\nAMP=30\nAMP_AMP=31\nAMP_LT=32\nAT_AT=33\nAT_GT=34\nAT_SIGN=35\nBANG=36\nBANG_BANG=37\nBANG_EQUAL=38\nCARET=39\nEQUAL=40\nEQUAL_GT=41\nGT=42\nGTE=43\nGT_GT=44\nHASH=45\nHASH_EQ=46\nHASH_GT=47\nHASH_GT_GT=48\nHASH_HASH=49\nHYPHEN_GT=50\nHYPHEN_GT_GT=51\nHYPHEN_PIPE_HYPHEN=52\nLT=53\nLTE=54\nLT_AT=55\nLT_CARET=56\nLT_GT=57\nLT_HYPHEN_GT=58\nLT_LT=59\nLT_LT_EQ=60\nLT_QMARK_GT=61\nMINUS=62\nPERCENT=63\nPIPE=64\nPIPE_PIPE=65\nPIPE_PIPE_SLASH=66\nPIPE_SLASH=67\nPLUS=68\nQMARK=69\nQMARK_AMP=70\nQMARK_HASH=71\nQMARK_HYPHEN=72\nQMARK_PIPE=73\nSLASH=74\nTIL=75\nTIL_EQ=76\nTIL_GTE_TIL=77\nTIL_GT_TIL=78\nTIL_LTE_TIL=79\nTIL_LT_TIL=80\nTIL_STAR=81\nTIL_TIL=82\nSEMI=83\nErrorCharacter=84\n','=7\n':'=8\n'::'=9\n'$'=10\n'$$'=11\n'*'=12\n'('=13\n')'=14\n'['=15\n']'=16\n'{'=17\n'}'=18\n'.'=24\n'&'=30\n'&&'=31\n'&<'=32\n'@@'=33\n'@>'=34\n'@'=35\n'!'=36\n'!!'=37\n'!='=38\n'^'=39\n'='=40\n'=>'=41\n'>'=42\n'>='=43\n'>>'=44\n'#'=45\n'#='=46\n'#>'=47\n'#>>'=48\n'##'=49\n'->'=50\n'->>'=51\n'-|-'=52\n'<'=53\n'<='=54\n'<@'=55\n'<^'=56\n'<>'=57\n'<->'=58\n'<<'=59\n'<<='=60\n'<?>'=61\n'-'=62\n'%'=63\n'|'=64\n'||'=65\n'||/'=66\n'|/'=67\n'+'=68\n'?'=69\n'?&'=70\n'?#'=71\n'?-'=72\n'?|'=73\n'/'=74\n'~'=75\n'~='=76\n'~>=~'=77\n'~>~'=78\n'~<=~'=79\n'~<~'=80\n'~*'=81\n'~~'=82\n';'=83\n"
  },
  {
    "path": "packages/formula/src/parser/FormulaLexer.ts",
    "content": "// Generated from src/formula/parser/FormulaLexer.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport { ATN } from 'antlr4ts/atn/ATN.js';\nimport { ATNDeserializer } from 'antlr4ts/atn/ATNDeserializer.js';\nimport { CharStream } from 'antlr4ts/CharStream.js';\nimport { Lexer } from 'antlr4ts/Lexer.js';\nimport { LexerATNSimulator } from 'antlr4ts/atn/LexerATNSimulator.js';\nimport { NotNull } from 'antlr4ts/Decorators.js';\nimport { Override } from 'antlr4ts/Decorators.js';\nimport { RuleContext } from 'antlr4ts/RuleContext.js';\nimport { Vocabulary } from 'antlr4ts/Vocabulary.js';\nimport { VocabularyImpl } from 'antlr4ts/VocabularyImpl.js';\n\nimport * as Utils from 'antlr4ts/misc/Utils.js';\n\nexport class FormulaLexer extends Lexer {\n  public static readonly BLOCK_COMMENT = 1;\n  public static readonly LINE_COMMENT = 2;\n  public static readonly WHITESPACE = 3;\n  public static readonly TRUE = 4;\n  public static readonly FALSE = 5;\n  public static readonly FIELD = 6;\n  public static readonly COMMA = 7;\n  public static readonly COLON = 8;\n  public static readonly COLON_COLON = 9;\n  public static readonly DOLLAR = 10;\n  public static readonly DOLLAR_DOLLAR = 11;\n  public static readonly STAR = 12;\n  public static readonly OPEN_PAREN = 13;\n  public static readonly CLOSE_PAREN = 14;\n  public static readonly OPEN_BRACKET = 15;\n  public static readonly CLOSE_BRACKET = 16;\n  public static readonly L_CURLY = 17;\n  public static readonly R_CURLY = 18;\n  public static readonly BIT_STRING = 19;\n  public static readonly REGEX_STRING = 20;\n  public static readonly NUMERIC_LITERAL = 21;\n  public static readonly INTEGER_LITERAL = 22;\n  public static readonly HEX_INTEGER_LITERAL = 23;\n  public static readonly DOT = 24;\n  public static readonly SINGLEQ_STRING_LITERAL = 25;\n  public static readonly DOUBLEQ_STRING_LITERAL = 26;\n  public static readonly IDENTIFIER_VARIABLE = 27;\n  public static readonly IDENTIFIER_UNICODE = 28;\n  public static readonly IDENTIFIER = 29;\n  public static readonly AMP = 30;\n  public static readonly AMP_AMP = 31;\n  public static readonly AMP_LT = 32;\n  public static readonly AT_AT = 33;\n  public static readonly AT_GT = 34;\n  public static readonly AT_SIGN = 35;\n  public static readonly BANG = 36;\n  public static readonly BANG_BANG = 37;\n  public static readonly BANG_EQUAL = 38;\n  public static readonly CARET = 39;\n  public static readonly EQUAL = 40;\n  public static readonly EQUAL_GT = 41;\n  public static readonly GT = 42;\n  public static readonly GTE = 43;\n  public static readonly GT_GT = 44;\n  public static readonly HASH = 45;\n  public static readonly HASH_EQ = 46;\n  public static readonly HASH_GT = 47;\n  public static readonly HASH_GT_GT = 48;\n  public static readonly HASH_HASH = 49;\n  public static readonly HYPHEN_GT = 50;\n  public static readonly HYPHEN_GT_GT = 51;\n  public static readonly HYPHEN_PIPE_HYPHEN = 52;\n  public static readonly LT = 53;\n  public static readonly LTE = 54;\n  public static readonly LT_AT = 55;\n  public static readonly LT_CARET = 56;\n  public static readonly LT_GT = 57;\n  public static readonly LT_HYPHEN_GT = 58;\n  public static readonly LT_LT = 59;\n  public static readonly LT_LT_EQ = 60;\n  public static readonly LT_QMARK_GT = 61;\n  public static readonly MINUS = 62;\n  public static readonly PERCENT = 63;\n  public static readonly PIPE = 64;\n  public static readonly PIPE_PIPE = 65;\n  public static readonly PIPE_PIPE_SLASH = 66;\n  public static readonly PIPE_SLASH = 67;\n  public static readonly PLUS = 68;\n  public static readonly QMARK = 69;\n  public static readonly QMARK_AMP = 70;\n  public static readonly QMARK_HASH = 71;\n  public static readonly QMARK_HYPHEN = 72;\n  public static readonly QMARK_PIPE = 73;\n  public static readonly SLASH = 74;\n  public static readonly TIL = 75;\n  public static readonly TIL_EQ = 76;\n  public static readonly TIL_GTE_TIL = 77;\n  public static readonly TIL_GT_TIL = 78;\n  public static readonly TIL_LTE_TIL = 79;\n  public static readonly TIL_LT_TIL = 80;\n  public static readonly TIL_STAR = 81;\n  public static readonly TIL_TIL = 82;\n  public static readonly SEMI = 83;\n  public static readonly ErrorCharacter = 84;\n\n  // tslint:disable:no-trailing-whitespace\n  public static readonly channelNames: string[] = ['DEFAULT_TOKEN_CHANNEL', 'HIDDEN'];\n\n  // tslint:disable:no-trailing-whitespace\n  public static readonly modeNames: string[] = ['DEFAULT_MODE'];\n\n  public static readonly ruleNames: string[] = [\n    'A',\n    'B',\n    'C',\n    'D',\n    'E',\n    'F',\n    'G',\n    'H',\n    'I',\n    'J',\n    'K',\n    'L',\n    'M',\n    'N',\n    'O',\n    'P',\n    'Q',\n    'R',\n    'S',\n    'T',\n    'U',\n    'V',\n    'W',\n    'X',\n    'Y',\n    'Z',\n    'UNDERSCORE',\n    'HEX_DIGIT',\n    'DEC_DIGIT',\n    'DQUOTA_STRING',\n    'SQUOTA_STRING',\n    'BQUOTA_STRING',\n    'BLOCK_COMMENT',\n    'LINE_COMMENT',\n    'WHITESPACE',\n    'TRUE',\n    'FALSE',\n    'FIELD',\n    'COMMA',\n    'COLON',\n    'COLON_COLON',\n    'DOLLAR',\n    'DOLLAR_DOLLAR',\n    'STAR',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'BIT_STRING',\n    'REGEX_STRING',\n    'NUMERIC_LITERAL',\n    'INTEGER_LITERAL',\n    'HEX_INTEGER_LITERAL',\n    'DOT',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'IDENTIFIER_VARIABLE',\n    'IDENTIFIER_UNICODE',\n    'IDENTIFIER',\n    'AMP',\n    'AMP_AMP',\n    'AMP_LT',\n    'AT_AT',\n    'AT_GT',\n    'AT_SIGN',\n    'BANG',\n    'BANG_BANG',\n    'BANG_EQUAL',\n    'CARET',\n    'EQUAL',\n    'EQUAL_GT',\n    'GT',\n    'GTE',\n    'GT_GT',\n    'HASH',\n    'HASH_EQ',\n    'HASH_GT',\n    'HASH_GT_GT',\n    'HASH_HASH',\n    'HYPHEN_GT',\n    'HYPHEN_GT_GT',\n    'HYPHEN_PIPE_HYPHEN',\n    'LT',\n    'LTE',\n    'LT_AT',\n    'LT_CARET',\n    'LT_GT',\n    'LT_HYPHEN_GT',\n    'LT_LT',\n    'LT_LT_EQ',\n    'LT_QMARK_GT',\n    'MINUS',\n    'PERCENT',\n    'PIPE',\n    'PIPE_PIPE',\n    'PIPE_PIPE_SLASH',\n    'PIPE_SLASH',\n    'PLUS',\n    'QMARK',\n    'QMARK_AMP',\n    'QMARK_HASH',\n    'QMARK_HYPHEN',\n    'QMARK_PIPE',\n    'SLASH',\n    'TIL',\n    'TIL_EQ',\n    'TIL_GTE_TIL',\n    'TIL_GT_TIL',\n    'TIL_LTE_TIL',\n    'TIL_LT_TIL',\n    'TIL_STAR',\n    'TIL_TIL',\n    'SEMI',\n    'ErrorCharacter',\n  ];\n\n  private static readonly _LITERAL_NAMES: Array<string | undefined> = [\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"','\",\n    \"':'\",\n    \"'::'\",\n    \"'$'\",\n    \"'$$'\",\n    \"'*'\",\n    \"'('\",\n    \"')'\",\n    \"'['\",\n    \"']'\",\n    \"'{'\",\n    \"'}'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'.'\",\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    undefined,\n    \"'&'\",\n    \"'&&'\",\n    \"'&<'\",\n    \"'@@'\",\n    \"'@>'\",\n    \"'@'\",\n    \"'!'\",\n    \"'!!'\",\n    \"'!='\",\n    \"'^'\",\n    \"'='\",\n    \"'=>'\",\n    \"'>'\",\n    \"'>='\",\n    \"'>>'\",\n    \"'#'\",\n    \"'#='\",\n    \"'#>'\",\n    \"'#>>'\",\n    \"'##'\",\n    \"'->'\",\n    \"'->>'\",\n    \"'-|-'\",\n    \"'<'\",\n    \"'<='\",\n    \"'<@'\",\n    \"'<^'\",\n    \"'<>'\",\n    \"'<->'\",\n    \"'<<'\",\n    \"'<<='\",\n    \"'<?>'\",\n    \"'-'\",\n    \"'%'\",\n    \"'|'\",\n    \"'||'\",\n    \"'||/'\",\n    \"'|/'\",\n    \"'+'\",\n    \"'?'\",\n    \"'?&'\",\n    \"'?#'\",\n    \"'?-'\",\n    \"'?|'\",\n    \"'/'\",\n    \"'~'\",\n    \"'~='\",\n    \"'~>=~'\",\n    \"'~>~'\",\n    \"'~<=~'\",\n    \"'~<~'\",\n    \"'~*'\",\n    \"'~~'\",\n    \"';'\",\n  ];\n  private static readonly _SYMBOLIC_NAMES: Array<string | undefined> = [\n    undefined,\n    'BLOCK_COMMENT',\n    'LINE_COMMENT',\n    'WHITESPACE',\n    'TRUE',\n    'FALSE',\n    'FIELD',\n    'COMMA',\n    'COLON',\n    'COLON_COLON',\n    'DOLLAR',\n    'DOLLAR_DOLLAR',\n    'STAR',\n    'OPEN_PAREN',\n    'CLOSE_PAREN',\n    'OPEN_BRACKET',\n    'CLOSE_BRACKET',\n    'L_CURLY',\n    'R_CURLY',\n    'BIT_STRING',\n    'REGEX_STRING',\n    'NUMERIC_LITERAL',\n    'INTEGER_LITERAL',\n    'HEX_INTEGER_LITERAL',\n    'DOT',\n    'SINGLEQ_STRING_LITERAL',\n    'DOUBLEQ_STRING_LITERAL',\n    'IDENTIFIER_VARIABLE',\n    'IDENTIFIER_UNICODE',\n    'IDENTIFIER',\n    'AMP',\n    'AMP_AMP',\n    'AMP_LT',\n    'AT_AT',\n    'AT_GT',\n    'AT_SIGN',\n    'BANG',\n    'BANG_BANG',\n    'BANG_EQUAL',\n    'CARET',\n    'EQUAL',\n    'EQUAL_GT',\n    'GT',\n    'GTE',\n    'GT_GT',\n    'HASH',\n    'HASH_EQ',\n    'HASH_GT',\n    'HASH_GT_GT',\n    'HASH_HASH',\n    'HYPHEN_GT',\n    'HYPHEN_GT_GT',\n    'HYPHEN_PIPE_HYPHEN',\n    'LT',\n    'LTE',\n    'LT_AT',\n    'LT_CARET',\n    'LT_GT',\n    'LT_HYPHEN_GT',\n    'LT_LT',\n    'LT_LT_EQ',\n    'LT_QMARK_GT',\n    'MINUS',\n    'PERCENT',\n    'PIPE',\n    'PIPE_PIPE',\n    'PIPE_PIPE_SLASH',\n    'PIPE_SLASH',\n    'PLUS',\n    'QMARK',\n    'QMARK_AMP',\n    'QMARK_HASH',\n    'QMARK_HYPHEN',\n    'QMARK_PIPE',\n    'SLASH',\n    'TIL',\n    'TIL_EQ',\n    'TIL_GTE_TIL',\n    'TIL_GT_TIL',\n    'TIL_LTE_TIL',\n    'TIL_LT_TIL',\n    'TIL_STAR',\n    'TIL_TIL',\n    'SEMI',\n    'ErrorCharacter',\n  ];\n  public static readonly VOCABULARY: Vocabulary = new VocabularyImpl(\n    FormulaLexer._LITERAL_NAMES,\n    FormulaLexer._SYMBOLIC_NAMES,\n    []\n  );\n\n  // @Override\n  // @NotNull\n  public get vocabulary(): Vocabulary {\n    return FormulaLexer.VOCABULARY;\n  }\n  // tslint:enable:no-trailing-whitespace\n\n  constructor(input: CharStream) {\n    super(input);\n    this._interp = new LexerATNSimulator(FormulaLexer._ATN, this);\n  }\n\n  // @Override\n  public get grammarFileName(): string {\n    return 'FormulaLexer.g4';\n  }\n\n  // @Override\n  public get ruleNames(): string[] {\n    return FormulaLexer.ruleNames;\n  }\n\n  // @Override\n  public get serializedATN(): string {\n    return FormulaLexer._serializedATN;\n  }\n\n  // @Override\n  public get channelNames(): string[] {\n    return FormulaLexer.channelNames;\n  }\n\n  // @Override\n  public get modeNames(): string[] {\n    return FormulaLexer.modeNames;\n  }\n\n  private static readonly _serializedATNSegments: number = 2;\n  private static readonly _serializedATNSegment0: string =\n    '\\x03\\uC91D\\uCABA\\u058D\\uAFBA\\u4F53\\u0607\\uEA8B\\uC241\\x02V\\u0281\\b\\x01' +\n    '\\x04\\x02\\t\\x02\\x04\\x03\\t\\x03\\x04\\x04\\t\\x04\\x04\\x05\\t\\x05\\x04\\x06\\t\\x06' +\n    '\\x04\\x07\\t\\x07\\x04\\b\\t\\b\\x04\\t\\t\\t\\x04\\n\\t\\n\\x04\\v\\t\\v\\x04\\f\\t\\f\\x04\\r' +\n    '\\t\\r\\x04\\x0E\\t\\x0E\\x04\\x0F\\t\\x0F\\x04\\x10\\t\\x10\\x04\\x11\\t\\x11\\x04\\x12\\t' +\n    '\\x12\\x04\\x13\\t\\x13\\x04\\x14\\t\\x14\\x04\\x15\\t\\x15\\x04\\x16\\t\\x16\\x04\\x17\\t' +\n    '\\x17\\x04\\x18\\t\\x18\\x04\\x19\\t\\x19\\x04\\x1A\\t\\x1A\\x04\\x1B\\t\\x1B\\x04\\x1C\\t' +\n    '\\x1C\\x04\\x1D\\t\\x1D\\x04\\x1E\\t\\x1E\\x04\\x1F\\t\\x1F\\x04 \\t \\x04!\\t!\\x04\"\\t' +\n    \"\\\"\\x04#\\t#\\x04$\\t$\\x04%\\t%\\x04&\\t&\\x04'\\t'\\x04(\\t(\\x04)\\t)\\x04*\\t*\\x04\" +\n    '+\\t+\\x04,\\t,\\x04-\\t-\\x04.\\t.\\x04/\\t/\\x040\\t0\\x041\\t1\\x042\\t2\\x043\\t3\\x04' +\n    '4\\t4\\x045\\t5\\x046\\t6\\x047\\t7\\x048\\t8\\x049\\t9\\x04:\\t:\\x04;\\t;\\x04<\\t<\\x04' +\n    '=\\t=\\x04>\\t>\\x04?\\t?\\x04@\\t@\\x04A\\tA\\x04B\\tB\\x04C\\tC\\x04D\\tD\\x04E\\tE\\x04' +\n    'F\\tF\\x04G\\tG\\x04H\\tH\\x04I\\tI\\x04J\\tJ\\x04K\\tK\\x04L\\tL\\x04M\\tM\\x04N\\tN\\x04' +\n    'O\\tO\\x04P\\tP\\x04Q\\tQ\\x04R\\tR\\x04S\\tS\\x04T\\tT\\x04U\\tU\\x04V\\tV\\x04W\\tW\\x04' +\n    'X\\tX\\x04Y\\tY\\x04Z\\tZ\\x04[\\t[\\x04\\\\\\t\\\\\\x04]\\t]\\x04^\\t^\\x04_\\t_\\x04`\\t' +\n    '`\\x04a\\ta\\x04b\\tb\\x04c\\tc\\x04d\\td\\x04e\\te\\x04f\\tf\\x04g\\tg\\x04h\\th\\x04' +\n    'i\\ti\\x04j\\tj\\x04k\\tk\\x04l\\tl\\x04m\\tm\\x04n\\tn\\x04o\\to\\x04p\\tp\\x04q\\tq\\x04' +\n    'r\\tr\\x04s\\ts\\x04t\\tt\\x04u\\tu\\x03\\x02\\x03\\x02\\x03\\x03\\x03\\x03\\x03\\x04\\x03' +\n    '\\x04\\x03\\x05\\x03\\x05\\x03\\x06\\x03\\x06\\x03\\x07\\x03\\x07\\x03\\b\\x03\\b\\x03\\t' +\n    '\\x03\\t\\x03\\n\\x03\\n\\x03\\v\\x03\\v\\x03\\f\\x03\\f\\x03\\r\\x03\\r\\x03\\x0E\\x03\\x0E' +\n    '\\x03\\x0F\\x03\\x0F\\x03\\x10\\x03\\x10\\x03\\x11\\x03\\x11\\x03\\x12\\x03\\x12\\x03\\x13' +\n    '\\x03\\x13\\x03\\x14\\x03\\x14\\x03\\x15\\x03\\x15\\x03\\x16\\x03\\x16\\x03\\x17\\x03\\x17' +\n    '\\x03\\x18\\x03\\x18\\x03\\x19\\x03\\x19\\x03\\x1A\\x03\\x1A\\x03\\x1B\\x03\\x1B\\x03\\x1C' +\n    '\\x03\\x1C\\x03\\x1D\\x03\\x1D\\x03\\x1E\\x03\\x1E\\x03\\x1F\\x03\\x1F\\x03\\x1F\\x03\\x1F' +\n    '\\x07\\x1F\\u012A\\n\\x1F\\f\\x1F\\x0E\\x1F\\u012D\\v\\x1F\\x03\\x1F\\x03\\x1F\\x03 \\x03' +\n    ' \\x03 \\x03 \\x07 \\u0135\\n \\f \\x0E \\u0138\\v \\x03 \\x03 \\x03!\\x03!\\x03!\\x03' +\n    '!\\x03!\\x03!\\x07!\\u0142\\n!\\f!\\x0E!\\u0145\\v!\\x03!\\x03!\\x03\"\\x03\"\\x03\"' +\n    '\\x03\"\\x07\"\\u014D\\n\"\\f\"\\x0E\"\\u0150\\v\"\\x03\"\\x03\"\\x03\"\\x03#\\x03' +\n    '#\\x03#\\x03#\\x07#\\u0159\\n#\\f#\\x0E#\\u015C\\v#\\x03$\\x06$\\u015F\\n$\\r$\\x0E$' +\n    \"\\u0160\\x03%\\x03%\\x03%\\x03%\\x03%\\x03&\\x03&\\x03&\\x03&\\x03&\\x03&\\x03'\\x03\" +\n    \"'\\x03'\\x03'\\x03'\\x03'\\x03(\\x03(\\x03)\\x03)\\x03*\\x03*\\x03*\\x03+\\x03\" +\n    '+\\x03,\\x03,\\x03,\\x03-\\x03-\\x03.\\x03.\\x03/\\x03/\\x030\\x030\\x031\\x031\\x03' +\n    '2\\x032\\x033\\x033\\x034\\x034\\x034\\x074\\u0191\\n4\\f4\\x0E4\\u0194\\v4\\x034\\x03' +\n    '4\\x035\\x035\\x035\\x036\\x066\\u019C\\n6\\r6\\x0E6\\u019D\\x036\\x036\\x066\\u01A2' +\n    '\\n6\\r6\\x0E6\\u01A3\\x036\\x036\\x076\\u01A8\\n6\\f6\\x0E6\\u01AB\\v6\\x036\\x066\\u01AE' +\n    '\\n6\\r6\\x0E6\\u01AF\\x056\\u01B2\\n6\\x037\\x067\\u01B5\\n7\\r7\\x0E7\\u01B6\\x037' +\n    '\\x037\\x067\\u01BB\\n7\\r7\\x0E7\\u01BC\\x057\\u01BF\\n7\\x038\\x038\\x038\\x039\\x03' +\n    '9\\x03:\\x03:\\x03;\\x03;\\x03<\\x03<\\x07<\\u01CC\\n<\\f<\\x0E<\\u01CF\\v<\\x03<\\x03' +\n    '<\\x03=\\x03=\\x07=\\u01D5\\n=\\f=\\x0E=\\u01D8\\v=\\x03>\\x03>\\x07>\\u01DC\\n>\\f>' +\n    '\\x0E>\\u01DF\\v>\\x03?\\x03?\\x03@\\x03@\\x03@\\x03A\\x03A\\x03A\\x03B\\x03B\\x03B' +\n    '\\x03C\\x03C\\x03C\\x03D\\x03D\\x03E\\x03E\\x03F\\x03F\\x03F\\x03G\\x03G\\x03G\\x03' +\n    'H\\x03H\\x03I\\x03I\\x03J\\x03J\\x03J\\x03K\\x03K\\x03L\\x03L\\x03L\\x03M\\x03M\\x03' +\n    'M\\x03N\\x03N\\x03O\\x03O\\x03O\\x03P\\x03P\\x03P\\x03Q\\x03Q\\x03Q\\x03Q\\x03R\\x03' +\n    'R\\x03R\\x03S\\x03S\\x03S\\x03T\\x03T\\x03T\\x03T\\x03U\\x03U\\x03U\\x03U\\x03V\\x03' +\n    'V\\x03W\\x03W\\x03W\\x03X\\x03X\\x03X\\x03Y\\x03Y\\x03Y\\x03Z\\x03Z\\x03Z\\x03[\\x03' +\n    '[\\x03[\\x03[\\x03\\\\\\x03\\\\\\x03\\\\\\x03]\\x03]\\x03]\\x03]\\x03^\\x03^\\x03^\\x03^' +\n    '\\x03_\\x03_\\x03`\\x03`\\x03a\\x03a\\x03b\\x03b\\x03b\\x03c\\x03c\\x03c\\x03c\\x03' +\n    'd\\x03d\\x03d\\x03e\\x03e\\x03f\\x03f\\x03g\\x03g\\x03g\\x03h\\x03h\\x03h\\x03i\\x03' +\n    'i\\x03i\\x03j\\x03j\\x03j\\x03k\\x03k\\x03l\\x03l\\x03m\\x03m\\x03m\\x03n\\x03n\\x03' +\n    'n\\x03n\\x03n\\x03o\\x03o\\x03o\\x03o\\x03p\\x03p\\x03p\\x03p\\x03p\\x03q\\x03q\\x03' +\n    'q\\x03q\\x03r\\x03r\\x03r\\x03s\\x03s\\x03s\\x03t\\x03t\\x03u\\x03u\\x03\\u01CD\\x02' +\n    '\\x02v\\x03\\x02\\x02\\x05\\x02\\x02\\x07\\x02\\x02\\t\\x02\\x02\\v\\x02\\x02\\r\\x02\\x02' +\n    '\\x0F\\x02\\x02\\x11\\x02\\x02\\x13\\x02\\x02\\x15\\x02\\x02\\x17\\x02\\x02\\x19\\x02\\x02' +\n    \"\\x1B\\x02\\x02\\x1D\\x02\\x02\\x1F\\x02\\x02!\\x02\\x02#\\x02\\x02%\\x02\\x02'\\x02\" +\n    '\\x02)\\x02\\x02+\\x02\\x02-\\x02\\x02/\\x02\\x021\\x02\\x023\\x02\\x025\\x02\\x027\\x02' +\n    '\\x029\\x02\\x02;\\x02\\x02=\\x02\\x02?\\x02\\x02A\\x02\\x02C\\x02\\x03E\\x02\\x04G\\x02' +\n    '\\x05I\\x02\\x06K\\x02\\x07M\\x02\\bO\\x02\\tQ\\x02\\nS\\x02\\vU\\x02\\fW\\x02\\rY\\x02' +\n    '\\x0E[\\x02\\x0F]\\x02\\x10_\\x02\\x11a\\x02\\x12c\\x02\\x13e\\x02\\x14g\\x02\\x15i\\x02' +\n    '\\x16k\\x02\\x17m\\x02\\x18o\\x02\\x19q\\x02\\x1As\\x02\\x1Bu\\x02\\x1Cw\\x02\\x1Dy\\x02' +\n    '\\x1E{\\x02\\x1F}\\x02 \\x7F\\x02!\\x81\\x02\"\\x83\\x02#\\x85\\x02$\\x87\\x02%\\x89' +\n    \"\\x02&\\x8B\\x02'\\x8D\\x02(\\x8F\\x02)\\x91\\x02*\\x93\\x02+\\x95\\x02,\\x97\\x02-\" +\n    '\\x99\\x02.\\x9B\\x02/\\x9D\\x020\\x9F\\x021\\xA1\\x022\\xA3\\x023\\xA5\\x024\\xA7\\x02' +\n    '5\\xA9\\x026\\xAB\\x027\\xAD\\x028\\xAF\\x029\\xB1\\x02:\\xB3\\x02;\\xB5\\x02<\\xB7\\x02' +\n    '=\\xB9\\x02>\\xBB\\x02?\\xBD\\x02@\\xBF\\x02A\\xC1\\x02B\\xC3\\x02C\\xC5\\x02D\\xC7\\x02' +\n    'E\\xC9\\x02F\\xCB\\x02G\\xCD\\x02H\\xCF\\x02I\\xD1\\x02J\\xD3\\x02K\\xD5\\x02L\\xD7\\x02' +\n    'M\\xD9\\x02N\\xDB\\x02O\\xDD\\x02P\\xDF\\x02Q\\xE1\\x02R\\xE3\\x02S\\xE5\\x02T\\xE7\\x02' +\n    \"U\\xE9\\x02V\\x03\\x02'\\x04\\x02CCcc\\x04\\x02DDdd\\x04\\x02EEee\\x04\\x02FFff\\x04\" +\n    '\\x02GGgg\\x04\\x02HHhh\\x04\\x02IIii\\x04\\x02JJjj\\x04\\x02KKkk\\x04\\x02LLll\\x04' +\n    '\\x02MMmm\\x04\\x02NNnn\\x04\\x02OOoo\\x04\\x02PPpp\\x04\\x02QQqq\\x04\\x02RRrr\\x04' +\n    '\\x02SSss\\x04\\x02TTtt\\x04\\x02UUuu\\x04\\x02VVvv\\x04\\x02WWww\\x04\\x02XXxx\\x04' +\n    '\\x02YYyy\\x04\\x02ZZzz\\x04\\x02[[{{\\x04\\x02\\\\\\\\||\\x04\\x022;CH\\x03\\x022;\\x04' +\n    '\\x02$$^^\\x04\\x02))^^\\x04\\x02^^bb\\x04\\x02\\f\\f\\x0F\\x0F\\x05\\x02\\v\\f\\x0F\\x0F' +\n    '\"\"\\x06\\x02C\\\\aac|\\xA3\\x01\\x07\\x022;C\\\\aac|\\xA3\\x01\\x05\\x02C\\\\aac|\\x06' +\n    '\\x022;C\\\\aac|\\x02\\u0276\\x02C\\x03\\x02\\x02\\x02\\x02E\\x03\\x02\\x02\\x02\\x02' +\n    'G\\x03\\x02\\x02\\x02\\x02I\\x03\\x02\\x02\\x02\\x02K\\x03\\x02\\x02\\x02\\x02M\\x03\\x02' +\n    '\\x02\\x02\\x02O\\x03\\x02\\x02\\x02\\x02Q\\x03\\x02\\x02\\x02\\x02S\\x03\\x02\\x02\\x02' +\n    '\\x02U\\x03\\x02\\x02\\x02\\x02W\\x03\\x02\\x02\\x02\\x02Y\\x03\\x02\\x02\\x02\\x02[\\x03' +\n    '\\x02\\x02\\x02\\x02]\\x03\\x02\\x02\\x02\\x02_\\x03\\x02\\x02\\x02\\x02a\\x03\\x02\\x02' +\n    '\\x02\\x02c\\x03\\x02\\x02\\x02\\x02e\\x03\\x02\\x02\\x02\\x02g\\x03\\x02\\x02\\x02\\x02' +\n    'i\\x03\\x02\\x02\\x02\\x02k\\x03\\x02\\x02\\x02\\x02m\\x03\\x02\\x02\\x02\\x02o\\x03\\x02' +\n    '\\x02\\x02\\x02q\\x03\\x02\\x02\\x02\\x02s\\x03\\x02\\x02\\x02\\x02u\\x03\\x02\\x02\\x02' +\n    '\\x02w\\x03\\x02\\x02\\x02\\x02y\\x03\\x02\\x02\\x02\\x02{\\x03\\x02\\x02\\x02\\x02}\\x03' +\n    '\\x02\\x02\\x02\\x02\\x7F\\x03\\x02\\x02\\x02\\x02\\x81\\x03\\x02\\x02\\x02\\x02\\x83\\x03' +\n    '\\x02\\x02\\x02\\x02\\x85\\x03\\x02\\x02\\x02\\x02\\x87\\x03\\x02\\x02\\x02\\x02\\x89\\x03' +\n    '\\x02\\x02\\x02\\x02\\x8B\\x03\\x02\\x02\\x02\\x02\\x8D\\x03\\x02\\x02\\x02\\x02\\x8F\\x03' +\n    '\\x02\\x02\\x02\\x02\\x91\\x03\\x02\\x02\\x02\\x02\\x93\\x03\\x02\\x02\\x02\\x02\\x95\\x03' +\n    '\\x02\\x02\\x02\\x02\\x97\\x03\\x02\\x02\\x02\\x02\\x99\\x03\\x02\\x02\\x02\\x02\\x9B\\x03' +\n    '\\x02\\x02\\x02\\x02\\x9D\\x03\\x02\\x02\\x02\\x02\\x9F\\x03\\x02\\x02\\x02\\x02\\xA1\\x03' +\n    '\\x02\\x02\\x02\\x02\\xA3\\x03\\x02\\x02\\x02\\x02\\xA5\\x03\\x02\\x02\\x02\\x02\\xA7\\x03' +\n    '\\x02\\x02\\x02\\x02\\xA9\\x03\\x02\\x02\\x02\\x02\\xAB\\x03\\x02\\x02\\x02\\x02\\xAD\\x03' +\n    '\\x02\\x02\\x02\\x02\\xAF\\x03\\x02\\x02\\x02\\x02\\xB1\\x03\\x02\\x02\\x02\\x02\\xB3\\x03' +\n    '\\x02\\x02\\x02\\x02\\xB5\\x03\\x02\\x02\\x02\\x02\\xB7\\x03\\x02\\x02\\x02\\x02\\xB9\\x03' +\n    '\\x02\\x02\\x02\\x02\\xBB\\x03\\x02\\x02\\x02\\x02\\xBD\\x03\\x02\\x02\\x02\\x02\\xBF\\x03' +\n    '\\x02\\x02\\x02\\x02\\xC1\\x03\\x02\\x02\\x02\\x02\\xC3\\x03\\x02\\x02\\x02\\x02\\xC5\\x03' +\n    '\\x02\\x02\\x02\\x02\\xC7\\x03\\x02\\x02\\x02\\x02\\xC9\\x03\\x02\\x02\\x02\\x02\\xCB\\x03' +\n    '\\x02\\x02\\x02\\x02\\xCD\\x03\\x02\\x02\\x02\\x02\\xCF\\x03\\x02\\x02\\x02\\x02\\xD1\\x03' +\n    '\\x02\\x02\\x02\\x02\\xD3\\x03\\x02\\x02\\x02\\x02\\xD5\\x03\\x02\\x02\\x02\\x02\\xD7\\x03' +\n    '\\x02\\x02\\x02\\x02\\xD9\\x03\\x02\\x02\\x02\\x02\\xDB\\x03\\x02\\x02\\x02\\x02\\xDD\\x03' +\n    '\\x02\\x02\\x02\\x02\\xDF\\x03\\x02\\x02\\x02\\x02\\xE1\\x03\\x02\\x02\\x02\\x02\\xE3\\x03' +\n    '\\x02\\x02\\x02\\x02\\xE5\\x03\\x02\\x02\\x02\\x02\\xE7\\x03\\x02\\x02\\x02\\x02\\xE9\\x03' +\n    '\\x02\\x02\\x02\\x03\\xEB\\x03\\x02\\x02\\x02\\x05\\xED\\x03\\x02\\x02\\x02\\x07\\xEF\\x03' +\n    '\\x02\\x02\\x02\\t\\xF1\\x03\\x02\\x02\\x02\\v\\xF3\\x03\\x02\\x02\\x02\\r\\xF5\\x03\\x02' +\n    '\\x02\\x02\\x0F\\xF7\\x03\\x02\\x02\\x02\\x11\\xF9\\x03\\x02\\x02\\x02\\x13\\xFB\\x03\\x02' +\n    '\\x02\\x02\\x15\\xFD\\x03\\x02\\x02\\x02\\x17\\xFF\\x03\\x02\\x02\\x02\\x19\\u0101\\x03' +\n    '\\x02\\x02\\x02\\x1B\\u0103\\x03\\x02\\x02\\x02\\x1D\\u0105\\x03\\x02\\x02\\x02\\x1F\\u0107' +\n    '\\x03\\x02\\x02\\x02!\\u0109\\x03\\x02\\x02\\x02#\\u010B\\x03\\x02\\x02\\x02%\\u010D' +\n    \"\\x03\\x02\\x02\\x02'\\u010F\\x03\\x02\\x02\\x02)\\u0111\\x03\\x02\\x02\\x02+\\u0113\" +\n    '\\x03\\x02\\x02\\x02-\\u0115\\x03\\x02\\x02\\x02/\\u0117\\x03\\x02\\x02\\x021\\u0119' +\n    '\\x03\\x02\\x02\\x023\\u011B\\x03\\x02\\x02\\x025\\u011D\\x03\\x02\\x02\\x027\\u011F' +\n    '\\x03\\x02\\x02\\x029\\u0121\\x03\\x02\\x02\\x02;\\u0123\\x03\\x02\\x02\\x02=\\u0125' +\n    '\\x03\\x02\\x02\\x02?\\u0130\\x03\\x02\\x02\\x02A\\u013B\\x03\\x02\\x02\\x02C\\u0148' +\n    '\\x03\\x02\\x02\\x02E\\u0154\\x03\\x02\\x02\\x02G\\u015E\\x03\\x02\\x02\\x02I\\u0162' +\n    '\\x03\\x02\\x02\\x02K\\u0167\\x03\\x02\\x02\\x02M\\u016D\\x03\\x02\\x02\\x02O\\u0173' +\n    '\\x03\\x02\\x02\\x02Q\\u0175\\x03\\x02\\x02\\x02S\\u0177\\x03\\x02\\x02\\x02U\\u017A' +\n    '\\x03\\x02\\x02\\x02W\\u017C\\x03\\x02\\x02\\x02Y\\u017F\\x03\\x02\\x02\\x02[\\u0181' +\n    '\\x03\\x02\\x02\\x02]\\u0183\\x03\\x02\\x02\\x02_\\u0185\\x03\\x02\\x02\\x02a\\u0187' +\n    '\\x03\\x02\\x02\\x02c\\u0189\\x03\\x02\\x02\\x02e\\u018B\\x03\\x02\\x02\\x02g\\u018D' +\n    '\\x03\\x02\\x02\\x02i\\u0197\\x03\\x02\\x02\\x02k\\u019B\\x03\\x02\\x02\\x02m\\u01B4' +\n    '\\x03\\x02\\x02\\x02o\\u01C0\\x03\\x02\\x02\\x02q\\u01C3\\x03\\x02\\x02\\x02s\\u01C5' +\n    '\\x03\\x02\\x02\\x02u\\u01C7\\x03\\x02\\x02\\x02w\\u01C9\\x03\\x02\\x02\\x02y\\u01D2' +\n    '\\x03\\x02\\x02\\x02{\\u01D9\\x03\\x02\\x02\\x02}\\u01E0\\x03\\x02\\x02\\x02\\x7F\\u01E2' +\n    '\\x03\\x02\\x02\\x02\\x81\\u01E5\\x03\\x02\\x02\\x02\\x83\\u01E8\\x03\\x02\\x02\\x02\\x85' +\n    '\\u01EB\\x03\\x02\\x02\\x02\\x87\\u01EE\\x03\\x02\\x02\\x02\\x89\\u01F0\\x03\\x02\\x02' +\n    '\\x02\\x8B\\u01F2\\x03\\x02\\x02\\x02\\x8D\\u01F5\\x03\\x02\\x02\\x02\\x8F\\u01F8\\x03' +\n    '\\x02\\x02\\x02\\x91\\u01FA\\x03\\x02\\x02\\x02\\x93\\u01FC\\x03\\x02\\x02\\x02\\x95\\u01FF' +\n    '\\x03\\x02\\x02\\x02\\x97\\u0201\\x03\\x02\\x02\\x02\\x99\\u0204\\x03\\x02\\x02\\x02\\x9B' +\n    '\\u0207\\x03\\x02\\x02\\x02\\x9D\\u0209\\x03\\x02\\x02\\x02\\x9F\\u020C\\x03\\x02\\x02' +\n    '\\x02\\xA1\\u020F\\x03\\x02\\x02\\x02\\xA3\\u0213\\x03\\x02\\x02\\x02\\xA5\\u0216\\x03' +\n    '\\x02\\x02\\x02\\xA7\\u0219\\x03\\x02\\x02\\x02\\xA9\\u021D\\x03\\x02\\x02\\x02\\xAB\\u0221' +\n    '\\x03\\x02\\x02\\x02\\xAD\\u0223\\x03\\x02\\x02\\x02\\xAF\\u0226\\x03\\x02\\x02\\x02\\xB1' +\n    '\\u0229\\x03\\x02\\x02\\x02\\xB3\\u022C\\x03\\x02\\x02\\x02\\xB5\\u022F\\x03\\x02\\x02' +\n    '\\x02\\xB7\\u0233\\x03\\x02\\x02\\x02\\xB9\\u0236\\x03\\x02\\x02\\x02\\xBB\\u023A\\x03' +\n    '\\x02\\x02\\x02\\xBD\\u023E\\x03\\x02\\x02\\x02\\xBF\\u0240\\x03\\x02\\x02\\x02\\xC1\\u0242' +\n    '\\x03\\x02\\x02\\x02\\xC3\\u0244\\x03\\x02\\x02\\x02\\xC5\\u0247\\x03\\x02\\x02\\x02\\xC7' +\n    '\\u024B\\x03\\x02\\x02\\x02\\xC9\\u024E\\x03\\x02\\x02\\x02\\xCB\\u0250\\x03\\x02\\x02' +\n    '\\x02\\xCD\\u0252\\x03\\x02\\x02\\x02\\xCF\\u0255\\x03\\x02\\x02\\x02\\xD1\\u0258\\x03' +\n    '\\x02\\x02\\x02\\xD3\\u025B\\x03\\x02\\x02\\x02\\xD5\\u025E\\x03\\x02\\x02\\x02\\xD7\\u0260' +\n    '\\x03\\x02\\x02\\x02\\xD9\\u0262\\x03\\x02\\x02\\x02\\xDB\\u0265\\x03\\x02\\x02\\x02\\xDD' +\n    '\\u026A\\x03\\x02\\x02\\x02\\xDF\\u026E\\x03\\x02\\x02\\x02\\xE1\\u0273\\x03\\x02\\x02' +\n    '\\x02\\xE3\\u0277\\x03\\x02\\x02\\x02\\xE5\\u027A\\x03\\x02\\x02\\x02\\xE7\\u027D\\x03' +\n    '\\x02\\x02\\x02\\xE9\\u027F\\x03\\x02\\x02\\x02\\xEB\\xEC\\t\\x02\\x02\\x02\\xEC\\x04\\x03' +\n    '\\x02\\x02\\x02\\xED\\xEE\\t\\x03\\x02\\x02\\xEE\\x06\\x03\\x02\\x02\\x02\\xEF\\xF0\\t\\x04' +\n    '\\x02\\x02\\xF0\\b\\x03\\x02\\x02\\x02\\xF1\\xF2\\t\\x05\\x02\\x02\\xF2\\n\\x03\\x02\\x02' +\n    '\\x02\\xF3\\xF4\\t\\x06\\x02\\x02\\xF4\\f\\x03\\x02\\x02\\x02\\xF5\\xF6\\t\\x07\\x02\\x02' +\n    '\\xF6\\x0E\\x03\\x02\\x02\\x02\\xF7\\xF8\\t\\b\\x02\\x02\\xF8\\x10\\x03\\x02\\x02\\x02\\xF9' +\n    '\\xFA\\t\\t\\x02\\x02\\xFA\\x12\\x03\\x02\\x02\\x02\\xFB\\xFC\\t\\n\\x02\\x02\\xFC\\x14\\x03' +\n    '\\x02\\x02\\x02\\xFD\\xFE\\t\\v\\x02\\x02\\xFE\\x16\\x03\\x02\\x02\\x02\\xFF\\u0100\\t\\f' +\n    '\\x02\\x02\\u0100\\x18\\x03\\x02\\x02\\x02\\u0101\\u0102\\t\\r\\x02\\x02\\u0102\\x1A\\x03' +\n    '\\x02\\x02\\x02\\u0103\\u0104\\t\\x0E\\x02\\x02\\u0104\\x1C\\x03\\x02\\x02\\x02\\u0105' +\n    '\\u0106\\t\\x0F\\x02\\x02\\u0106\\x1E\\x03\\x02\\x02\\x02\\u0107\\u0108\\t\\x10\\x02\\x02' +\n    '\\u0108 \\x03\\x02\\x02\\x02\\u0109\\u010A\\t\\x11\\x02\\x02\\u010A\"\\x03\\x02\\x02' +\n    '\\x02\\u010B\\u010C\\t\\x12\\x02\\x02\\u010C$\\x03\\x02\\x02\\x02\\u010D\\u010E\\t\\x13' +\n    '\\x02\\x02\\u010E&\\x03\\x02\\x02\\x02\\u010F\\u0110\\t\\x14\\x02\\x02\\u0110(\\x03\\x02' +\n    '\\x02\\x02\\u0111\\u0112\\t\\x15\\x02\\x02\\u0112*\\x03\\x02\\x02\\x02\\u0113\\u0114' +\n    '\\t\\x16\\x02\\x02\\u0114,\\x03\\x02\\x02\\x02\\u0115\\u0116\\t\\x17\\x02\\x02\\u0116' +\n    '.\\x03\\x02\\x02\\x02\\u0117\\u0118\\t\\x18\\x02\\x02\\u01180\\x03\\x02\\x02\\x02\\u0119' +\n    '\\u011A\\t\\x19\\x02\\x02\\u011A2\\x03\\x02\\x02\\x02\\u011B\\u011C\\t\\x1A\\x02\\x02' +\n    '\\u011C4\\x03\\x02\\x02\\x02\\u011D\\u011E\\t\\x1B\\x02\\x02\\u011E6\\x03\\x02\\x02\\x02' +\n    '\\u011F\\u0120\\x07a\\x02\\x02\\u01208\\x03\\x02\\x02\\x02\\u0121\\u0122\\t\\x1C\\x02' +\n    '\\x02\\u0122:\\x03\\x02\\x02\\x02\\u0123\\u0124\\t\\x1D\\x02\\x02\\u0124<\\x03\\x02\\x02' +\n    '\\x02\\u0125\\u012B\\x07$\\x02\\x02\\u0126\\u0127\\x07^\\x02\\x02\\u0127\\u012A\\v\\x02' +\n    '\\x02\\x02\\u0128\\u012A\\n\\x1E\\x02\\x02\\u0129\\u0126\\x03\\x02\\x02\\x02\\u0129\\u0128' +\n    '\\x03\\x02\\x02\\x02\\u012A\\u012D\\x03\\x02\\x02\\x02\\u012B\\u0129\\x03\\x02\\x02\\x02' +\n    '\\u012B\\u012C\\x03\\x02\\x02\\x02\\u012C\\u012E\\x03\\x02\\x02\\x02\\u012D\\u012B\\x03' +\n    '\\x02\\x02\\x02\\u012E\\u012F\\x07$\\x02\\x02\\u012F>\\x03\\x02\\x02\\x02\\u0130\\u0136' +\n    '\\x07)\\x02\\x02\\u0131\\u0132\\x07^\\x02\\x02\\u0132\\u0135\\v\\x02\\x02\\x02\\u0133' +\n    '\\u0135\\n\\x1F\\x02\\x02\\u0134\\u0131\\x03\\x02\\x02\\x02\\u0134\\u0133\\x03\\x02\\x02' +\n    '\\x02\\u0135\\u0138\\x03\\x02\\x02\\x02\\u0136\\u0134\\x03\\x02\\x02\\x02\\u0136\\u0137' +\n    '\\x03\\x02\\x02\\x02\\u0137\\u0139\\x03\\x02\\x02\\x02\\u0138\\u0136\\x03\\x02\\x02\\x02' +\n    '\\u0139\\u013A\\x07)\\x02\\x02\\u013A@\\x03\\x02\\x02\\x02\\u013B\\u0143\\x07b\\x02' +\n    '\\x02\\u013C\\u013D\\x07^\\x02\\x02\\u013D\\u0142\\v\\x02\\x02\\x02\\u013E\\u013F\\x07' +\n    'b\\x02\\x02\\u013F\\u0142\\x07b\\x02\\x02\\u0140\\u0142\\n \\x02\\x02\\u0141\\u013C' +\n    '\\x03\\x02\\x02\\x02\\u0141\\u013E\\x03\\x02\\x02\\x02\\u0141\\u0140\\x03\\x02\\x02\\x02' +\n    '\\u0142\\u0145\\x03\\x02\\x02\\x02\\u0143\\u0141\\x03\\x02\\x02\\x02\\u0143\\u0144\\x03' +\n    '\\x02\\x02\\x02\\u0144\\u0146\\x03\\x02\\x02\\x02\\u0145\\u0143\\x03\\x02\\x02\\x02\\u0146' +\n    '\\u0147\\x07b\\x02\\x02\\u0147B\\x03\\x02\\x02\\x02\\u0148\\u0149\\x071\\x02\\x02\\u0149' +\n    '\\u014A\\x07,\\x02\\x02\\u014A\\u014E\\x03\\x02\\x02\\x02\\u014B\\u014D\\v\\x02\\x02' +\n    '\\x02\\u014C\\u014B\\x03\\x02\\x02\\x02\\u014D\\u0150\\x03\\x02\\x02\\x02\\u014E\\u014C' +\n    '\\x03\\x02\\x02\\x02\\u014E\\u014F\\x03\\x02\\x02\\x02\\u014F\\u0151\\x03\\x02\\x02\\x02' +\n    '\\u0150\\u014E\\x03\\x02\\x02\\x02\\u0151\\u0152\\x07,\\x02\\x02\\u0152\\u0153\\x07' +\n    '1\\x02\\x02\\u0153D\\x03\\x02\\x02\\x02\\u0154\\u0155\\x071\\x02\\x02\\u0155\\u0156' +\n    '\\x071\\x02\\x02\\u0156\\u015A\\x03\\x02\\x02\\x02\\u0157\\u0159\\n!\\x02\\x02\\u0158' +\n    '\\u0157\\x03\\x02\\x02\\x02\\u0159\\u015C\\x03\\x02\\x02\\x02\\u015A\\u0158\\x03\\x02' +\n    '\\x02\\x02\\u015A\\u015B\\x03\\x02\\x02\\x02\\u015BF\\x03\\x02\\x02\\x02\\u015C\\u015A' +\n    '\\x03\\x02\\x02\\x02\\u015D\\u015F\\t\"\\x02\\x02\\u015E\\u015D\\x03\\x02\\x02\\x02\\u015F' +\n    '\\u0160\\x03\\x02\\x02\\x02\\u0160\\u015E\\x03\\x02\\x02\\x02\\u0160\\u0161\\x03\\x02' +\n    '\\x02\\x02\\u0161H\\x03\\x02\\x02\\x02\\u0162\\u0163\\x05)\\x15\\x02\\u0163\\u0164\\x05' +\n    '%\\x13\\x02\\u0164\\u0165\\x05+\\x16\\x02\\u0165\\u0166\\x05\\v\\x06\\x02\\u0166J\\x03' +\n    '\\x02\\x02\\x02\\u0167\\u0168\\x05\\r\\x07\\x02\\u0168\\u0169\\x05\\x03\\x02\\x02\\u0169' +\n    \"\\u016A\\x05\\x19\\r\\x02\\u016A\\u016B\\x05'\\x14\\x02\\u016B\\u016C\\x05\\v\\x06\\x02\" +\n    '\\u016CL\\x03\\x02\\x02\\x02\\u016D\\u016E\\x05\\r\\x07\\x02\\u016E\\u016F\\x05\\x13' +\n    '\\n\\x02\\u016F\\u0170\\x05\\v\\x06\\x02\\u0170\\u0171\\x05\\x19\\r\\x02\\u0171\\u0172' +\n    '\\x05\\t\\x05\\x02\\u0172N\\x03\\x02\\x02\\x02\\u0173\\u0174\\x07.\\x02\\x02\\u0174P' +\n    '\\x03\\x02\\x02\\x02\\u0175\\u0176\\x07<\\x02\\x02\\u0176R\\x03\\x02\\x02\\x02\\u0177' +\n    '\\u0178\\x07<\\x02\\x02\\u0178\\u0179\\x07<\\x02\\x02\\u0179T\\x03\\x02\\x02\\x02\\u017A' +\n    '\\u017B\\x07&\\x02\\x02\\u017BV\\x03\\x02\\x02\\x02\\u017C\\u017D\\x07&\\x02\\x02\\u017D' +\n    '\\u017E\\x07&\\x02\\x02\\u017EX\\x03\\x02\\x02\\x02\\u017F\\u0180\\x07,\\x02\\x02\\u0180' +\n    'Z\\x03\\x02\\x02\\x02\\u0181\\u0182\\x07*\\x02\\x02\\u0182\\\\\\x03\\x02\\x02\\x02\\u0183' +\n    '\\u0184\\x07+\\x02\\x02\\u0184^\\x03\\x02\\x02\\x02\\u0185\\u0186\\x07]\\x02\\x02\\u0186' +\n    '`\\x03\\x02\\x02\\x02\\u0187\\u0188\\x07_\\x02\\x02\\u0188b\\x03\\x02\\x02\\x02\\u0189' +\n    '\\u018A\\x07}\\x02\\x02\\u018Ad\\x03\\x02\\x02\\x02\\u018B\\u018C\\x07\\x7F\\x02\\x02' +\n    '\\u018Cf\\x03\\x02\\x02\\x02\\u018D\\u018E\\x05\\x05\\x03\\x02\\u018E\\u0192\\x07)\\x02' +\n    '\\x02\\u018F\\u0191\\x0423\\x02\\u0190\\u018F\\x03\\x02\\x02\\x02\\u0191\\u0194\\x03' +\n    '\\x02\\x02\\x02\\u0192\\u0190\\x03\\x02\\x02\\x02\\u0192\\u0193\\x03\\x02\\x02\\x02\\u0193' +\n    '\\u0195\\x03\\x02\\x02\\x02\\u0194\\u0192\\x03\\x02\\x02\\x02\\u0195\\u0196\\x07)\\x02' +\n    '\\x02\\u0196h\\x03\\x02\\x02\\x02\\u0197\\u0198\\x05\\v\\x06\\x02\\u0198\\u0199\\x05' +\n    '? \\x02\\u0199j\\x03\\x02\\x02\\x02\\u019A\\u019C\\x05;\\x1E\\x02\\u019B\\u019A\\x03' +\n    '\\x02\\x02\\x02\\u019C\\u019D\\x03\\x02\\x02\\x02\\u019D\\u019B\\x03\\x02\\x02\\x02\\u019D' +\n    '\\u019E\\x03\\x02\\x02\\x02\\u019E\\u019F\\x03\\x02\\x02\\x02\\u019F\\u01A1\\x070\\x02' +\n    '\\x02\\u01A0\\u01A2\\x05;\\x1E\\x02\\u01A1\\u01A0\\x03\\x02\\x02\\x02\\u01A2\\u01A3' +\n    '\\x03\\x02\\x02\\x02\\u01A3\\u01A1\\x03\\x02\\x02\\x02\\u01A3\\u01A4\\x03\\x02\\x02\\x02' +\n    '\\u01A4\\u01B1\\x03\\x02\\x02\\x02\\u01A5\\u01A9\\x05\\v\\x06\\x02\\u01A6\\u01A8\\x07' +\n    '/\\x02\\x02\\u01A7\\u01A6\\x03\\x02\\x02\\x02\\u01A8\\u01AB\\x03\\x02\\x02\\x02\\u01A9' +\n    '\\u01A7\\x03\\x02\\x02\\x02\\u01A9\\u01AA\\x03\\x02\\x02\\x02\\u01AA\\u01AD\\x03\\x02' +\n    '\\x02\\x02\\u01AB\\u01A9\\x03\\x02\\x02\\x02\\u01AC\\u01AE\\x05;\\x1E\\x02\\u01AD\\u01AC' +\n    '\\x03\\x02\\x02\\x02\\u01AE\\u01AF\\x03\\x02\\x02\\x02\\u01AF\\u01AD\\x03\\x02\\x02\\x02' +\n    '\\u01AF\\u01B0\\x03\\x02\\x02\\x02\\u01B0\\u01B2\\x03\\x02\\x02\\x02\\u01B1\\u01A5\\x03' +\n    '\\x02\\x02\\x02\\u01B1\\u01B2\\x03\\x02\\x02\\x02\\u01B2l\\x03\\x02\\x02\\x02\\u01B3' +\n    '\\u01B5\\x05;\\x1E\\x02\\u01B4\\u01B3\\x03\\x02\\x02\\x02\\u01B5\\u01B6\\x03\\x02\\x02' +\n    '\\x02\\u01B6\\u01B4\\x03\\x02\\x02\\x02\\u01B6\\u01B7\\x03\\x02\\x02\\x02\\u01B7\\u01BE' +\n    '\\x03\\x02\\x02\\x02\\u01B8\\u01BA\\x05\\v\\x06\\x02\\u01B9\\u01BB\\x05;\\x1E\\x02\\u01BA' +\n    '\\u01B9\\x03\\x02\\x02\\x02\\u01BB\\u01BC\\x03\\x02\\x02\\x02\\u01BC\\u01BA\\x03\\x02' +\n    '\\x02\\x02\\u01BC\\u01BD\\x03\\x02\\x02\\x02\\u01BD\\u01BF\\x03\\x02\\x02\\x02\\u01BE' +\n    '\\u01B8\\x03\\x02\\x02\\x02\\u01BE\\u01BF\\x03\\x02\\x02\\x02\\u01BFn\\x03\\x02\\x02' +\n    '\\x02\\u01C0\\u01C1\\x07z\\x02\\x02\\u01C1\\u01C2\\x05? \\x02\\u01C2p\\x03\\x02\\x02' +\n    '\\x02\\u01C3\\u01C4\\x070\\x02\\x02\\u01C4r\\x03\\x02\\x02\\x02\\u01C5\\u01C6\\x05?' +\n    ' \\x02\\u01C6t\\x03\\x02\\x02\\x02\\u01C7\\u01C8\\x05=\\x1F\\x02\\u01C8v\\x03\\x02\\x02' +\n    '\\x02\\u01C9\\u01CD\\x07}\\x02\\x02\\u01CA\\u01CC\\v\\x02\\x02\\x02\\u01CB\\u01CA\\x03' +\n    '\\x02\\x02\\x02\\u01CC\\u01CF\\x03\\x02\\x02\\x02\\u01CD\\u01CE\\x03\\x02\\x02\\x02\\u01CD' +\n    '\\u01CB\\x03\\x02\\x02\\x02\\u01CE\\u01D0\\x03\\x02\\x02\\x02\\u01CF\\u01CD\\x03\\x02' +\n    '\\x02\\x02\\u01D0\\u01D1\\x07\\x7F\\x02\\x02\\u01D1x\\x03\\x02\\x02\\x02\\u01D2\\u01D6' +\n    '\\t#\\x02\\x02\\u01D3\\u01D5\\t$\\x02\\x02\\u01D4\\u01D3\\x03\\x02\\x02\\x02\\u01D5\\u01D8' +\n    '\\x03\\x02\\x02\\x02\\u01D6\\u01D4\\x03\\x02\\x02\\x02\\u01D6\\u01D7\\x03\\x02\\x02\\x02' +\n    '\\u01D7z\\x03\\x02\\x02\\x02\\u01D8\\u01D6\\x03\\x02\\x02\\x02\\u01D9\\u01DD\\t%\\x02' +\n    '\\x02\\u01DA\\u01DC\\t&\\x02\\x02\\u01DB\\u01DA\\x03\\x02\\x02\\x02\\u01DC\\u01DF\\x03' +\n    '\\x02\\x02\\x02\\u01DD\\u01DB\\x03\\x02\\x02\\x02\\u01DD\\u01DE\\x03\\x02\\x02\\x02\\u01DE' +\n    '|\\x03\\x02\\x02\\x02\\u01DF\\u01DD\\x03\\x02\\x02\\x02\\u01E0\\u01E1\\x07(\\x02\\x02' +\n    '\\u01E1~\\x03\\x02\\x02\\x02\\u01E2\\u01E3\\x07(\\x02\\x02\\u01E3\\u01E4\\x07(\\x02' +\n    '\\x02\\u01E4\\x80\\x03\\x02\\x02\\x02\\u01E5\\u01E6\\x07(\\x02\\x02\\u01E6\\u01E7\\x07' +\n    '>\\x02\\x02\\u01E7\\x82\\x03\\x02\\x02\\x02\\u01E8\\u01E9\\x07B\\x02\\x02\\u01E9\\u01EA' +\n    '\\x07B\\x02\\x02\\u01EA\\x84\\x03\\x02\\x02\\x02\\u01EB\\u01EC\\x07B\\x02\\x02\\u01EC' +\n    '\\u01ED\\x07@\\x02\\x02\\u01ED\\x86\\x03\\x02\\x02\\x02\\u01EE\\u01EF\\x07B\\x02\\x02' +\n    '\\u01EF\\x88\\x03\\x02\\x02\\x02\\u01F0\\u01F1\\x07#\\x02\\x02\\u01F1\\x8A\\x03\\x02' +\n    '\\x02\\x02\\u01F2\\u01F3\\x07#\\x02\\x02\\u01F3\\u01F4\\x07#\\x02\\x02\\u01F4\\x8C\\x03' +\n    '\\x02\\x02\\x02\\u01F5\\u01F6\\x07#\\x02\\x02\\u01F6\\u01F7\\x07?\\x02\\x02\\u01F7\\x8E' +\n    '\\x03\\x02\\x02\\x02\\u01F8\\u01F9\\x07`\\x02\\x02\\u01F9\\x90\\x03\\x02\\x02\\x02\\u01FA' +\n    '\\u01FB\\x07?\\x02\\x02\\u01FB\\x92\\x03\\x02\\x02\\x02\\u01FC\\u01FD\\x07?\\x02\\x02' +\n    '\\u01FD\\u01FE\\x07@\\x02\\x02\\u01FE\\x94\\x03\\x02\\x02\\x02\\u01FF\\u0200\\x07@\\x02' +\n    '\\x02\\u0200\\x96\\x03\\x02\\x02\\x02\\u0201\\u0202\\x07@\\x02\\x02\\u0202\\u0203\\x07' +\n    '?\\x02\\x02\\u0203\\x98\\x03\\x02\\x02\\x02\\u0204\\u0205\\x07@\\x02\\x02\\u0205\\u0206' +\n    '\\x07@\\x02\\x02\\u0206\\x9A\\x03\\x02\\x02\\x02\\u0207\\u0208\\x07%\\x02\\x02\\u0208' +\n    '\\x9C\\x03\\x02\\x02\\x02\\u0209\\u020A\\x07%\\x02\\x02\\u020A\\u020B\\x07?\\x02\\x02' +\n    '\\u020B\\x9E\\x03\\x02\\x02\\x02\\u020C\\u020D\\x07%\\x02\\x02\\u020D\\u020E\\x07@\\x02' +\n    '\\x02\\u020E\\xA0';\n  private static readonly _serializedATNSegment1: string =\n    '\\x03\\x02\\x02\\x02\\u020F\\u0210\\x07%\\x02\\x02\\u0210\\u0211\\x07@\\x02\\x02\\u0211' +\n    '\\u0212\\x07@\\x02\\x02\\u0212\\xA2\\x03\\x02\\x02\\x02\\u0213\\u0214\\x07%\\x02\\x02' +\n    '\\u0214\\u0215\\x07%\\x02\\x02\\u0215\\xA4\\x03\\x02\\x02\\x02\\u0216\\u0217\\x07/\\x02' +\n    '\\x02\\u0217\\u0218\\x07@\\x02\\x02\\u0218\\xA6\\x03\\x02\\x02\\x02\\u0219\\u021A\\x07' +\n    '/\\x02\\x02\\u021A\\u021B\\x07@\\x02\\x02\\u021B\\u021C\\x07@\\x02\\x02\\u021C\\xA8' +\n    '\\x03\\x02\\x02\\x02\\u021D\\u021E\\x07/\\x02\\x02\\u021E\\u021F\\x07~\\x02\\x02\\u021F' +\n    '\\u0220\\x07/\\x02\\x02\\u0220\\xAA\\x03\\x02\\x02\\x02\\u0221\\u0222\\x07>\\x02\\x02' +\n    '\\u0222\\xAC\\x03\\x02\\x02\\x02\\u0223\\u0224\\x07>\\x02\\x02\\u0224\\u0225\\x07?\\x02' +\n    '\\x02\\u0225\\xAE\\x03\\x02\\x02\\x02\\u0226\\u0227\\x07>\\x02\\x02\\u0227\\u0228\\x07' +\n    'B\\x02\\x02\\u0228\\xB0\\x03\\x02\\x02\\x02\\u0229\\u022A\\x07>\\x02\\x02\\u022A\\u022B' +\n    '\\x07`\\x02\\x02\\u022B\\xB2\\x03\\x02\\x02\\x02\\u022C\\u022D\\x07>\\x02\\x02\\u022D' +\n    '\\u022E\\x07@\\x02\\x02\\u022E\\xB4\\x03\\x02\\x02\\x02\\u022F\\u0230\\x07>\\x02\\x02' +\n    '\\u0230\\u0231\\x07/\\x02\\x02\\u0231\\u0232\\x07@\\x02\\x02\\u0232\\xB6\\x03\\x02\\x02' +\n    '\\x02\\u0233\\u0234\\x07>\\x02\\x02\\u0234\\u0235\\x07>\\x02\\x02\\u0235\\xB8\\x03\\x02' +\n    '\\x02\\x02\\u0236\\u0237\\x07>\\x02\\x02\\u0237\\u0238\\x07>\\x02\\x02\\u0238\\u0239' +\n    '\\x07?\\x02\\x02\\u0239\\xBA\\x03\\x02\\x02\\x02\\u023A\\u023B\\x07>\\x02\\x02\\u023B' +\n    '\\u023C\\x07A\\x02\\x02\\u023C\\u023D\\x07@\\x02\\x02\\u023D\\xBC\\x03\\x02\\x02\\x02' +\n    \"\\u023E\\u023F\\x07/\\x02\\x02\\u023F\\xBE\\x03\\x02\\x02\\x02\\u0240\\u0241\\x07'\" +\n    '\\x02\\x02\\u0241\\xC0\\x03\\x02\\x02\\x02\\u0242\\u0243\\x07~\\x02\\x02\\u0243\\xC2' +\n    '\\x03\\x02\\x02\\x02\\u0244\\u0245\\x07~\\x02\\x02\\u0245\\u0246\\x07~\\x02\\x02\\u0246' +\n    '\\xC4\\x03\\x02\\x02\\x02\\u0247\\u0248\\x07~\\x02\\x02\\u0248\\u0249\\x07~\\x02\\x02' +\n    '\\u0249\\u024A\\x071\\x02\\x02\\u024A\\xC6\\x03\\x02\\x02\\x02\\u024B\\u024C\\x07~\\x02' +\n    '\\x02\\u024C\\u024D\\x071\\x02\\x02\\u024D\\xC8\\x03\\x02\\x02\\x02\\u024E\\u024F\\x07' +\n    '-\\x02\\x02\\u024F\\xCA\\x03\\x02\\x02\\x02\\u0250\\u0251\\x07A\\x02\\x02\\u0251\\xCC' +\n    '\\x03\\x02\\x02\\x02\\u0252\\u0253\\x07A\\x02\\x02\\u0253\\u0254\\x07(\\x02\\x02\\u0254' +\n    '\\xCE\\x03\\x02\\x02\\x02\\u0255\\u0256\\x07A\\x02\\x02\\u0256\\u0257\\x07%\\x02\\x02' +\n    '\\u0257\\xD0\\x03\\x02\\x02\\x02\\u0258\\u0259\\x07A\\x02\\x02\\u0259\\u025A\\x07/\\x02' +\n    '\\x02\\u025A\\xD2\\x03\\x02\\x02\\x02\\u025B\\u025C\\x07A\\x02\\x02\\u025C\\u025D\\x07' +\n    '~\\x02\\x02\\u025D\\xD4\\x03\\x02\\x02\\x02\\u025E\\u025F\\x071\\x02\\x02\\u025F\\xD6' +\n    '\\x03\\x02\\x02\\x02\\u0260\\u0261\\x07\\x80\\x02\\x02\\u0261\\xD8\\x03\\x02\\x02\\x02' +\n    '\\u0262\\u0263\\x07\\x80\\x02\\x02\\u0263\\u0264\\x07?\\x02\\x02\\u0264\\xDA\\x03\\x02' +\n    '\\x02\\x02\\u0265\\u0266\\x07\\x80\\x02\\x02\\u0266\\u0267\\x07@\\x02\\x02\\u0267\\u0268' +\n    '\\x07?\\x02\\x02\\u0268\\u0269\\x07\\x80\\x02\\x02\\u0269\\xDC\\x03\\x02\\x02\\x02\\u026A' +\n    '\\u026B\\x07\\x80\\x02\\x02\\u026B\\u026C\\x07@\\x02\\x02\\u026C\\u026D\\x07\\x80\\x02' +\n    '\\x02\\u026D\\xDE\\x03\\x02\\x02\\x02\\u026E\\u026F\\x07\\x80\\x02\\x02\\u026F\\u0270' +\n    '\\x07>\\x02\\x02\\u0270\\u0271\\x07?\\x02\\x02\\u0271\\u0272\\x07\\x80\\x02\\x02\\u0272' +\n    '\\xE0\\x03\\x02\\x02\\x02\\u0273\\u0274\\x07\\x80\\x02\\x02\\u0274\\u0275\\x07>\\x02' +\n    '\\x02\\u0275\\u0276\\x07\\x80\\x02\\x02\\u0276\\xE2\\x03\\x02\\x02\\x02\\u0277\\u0278' +\n    '\\x07\\x80\\x02\\x02\\u0278\\u0279\\x07,\\x02\\x02\\u0279\\xE4\\x03\\x02\\x02\\x02\\u027A' +\n    '\\u027B\\x07\\x80\\x02\\x02\\u027B\\u027C\\x07\\x80\\x02\\x02\\u027C\\xE6\\x03\\x02\\x02' +\n    '\\x02\\u027D\\u027E\\x07=\\x02\\x02\\u027E\\xE8\\x03\\x02\\x02\\x02\\u027F\\u0280\\v' +\n    '\\x02\\x02\\x02\\u0280\\xEA\\x03\\x02\\x02\\x02\\x18\\x02\\u0129\\u012B\\u0134\\u0136' +\n    '\\u0141\\u0143\\u014E\\u015A\\u0160\\u0192\\u019D\\u01A3\\u01A9\\u01AF\\u01B1\\u01B6' +\n    '\\u01BC\\u01BE\\u01CD\\u01D6\\u01DD\\x02';\n  public static readonly _serializedATN: string = Utils.join(\n    [FormulaLexer._serializedATNSegment0, FormulaLexer._serializedATNSegment1],\n    ''\n  );\n  public static __ATN: ATN;\n  public static get _ATN(): ATN {\n    if (!FormulaLexer.__ATN) {\n      FormulaLexer.__ATN = new ATNDeserializer().deserialize(\n        Utils.toCharArray(FormulaLexer._serializedATN)\n      );\n    }\n\n    return FormulaLexer.__ATN;\n  }\n}\n"
  },
  {
    "path": "packages/formula/src/parser/FormulaVisitor.ts",
    "content": "// Generated from src/formula/parser/Formula.g4 by ANTLR 4.9.0-SNAPSHOT\n\nimport { ParseTreeVisitor } from 'antlr4ts/tree/ParseTreeVisitor.js';\n\nimport { StringLiteralContext } from './Formula';\nimport { IntegerLiteralContext } from './Formula';\nimport { DecimalLiteralContext } from './Formula';\nimport { BooleanLiteralContext } from './Formula';\nimport { LeftWhitespaceOrCommentsContext } from './Formula';\nimport { RightWhitespaceOrCommentsContext } from './Formula';\nimport { BracketsContext } from './Formula';\nimport { UnaryOpContext } from './Formula';\nimport { BinaryOpContext } from './Formula';\nimport { FieldReferenceCurlyContext } from './Formula';\nimport { FunctionCallContext } from './Formula';\nimport { RootContext } from './Formula';\nimport { ExprContext } from './Formula';\nimport { Ws_or_commentContext } from './Formula';\nimport { Field_referenceContext } from './Formula';\nimport { Field_reference_curlyContext } from './Formula';\nimport { Func_nameContext } from './Formula';\nimport { IdentifierContext } from './Formula';\n\n/**\n * This interface defines a complete generic visitor for a parse tree produced\n * by `Formula`.\n *\n * @param <Result> The return type of the visit operation. Use `void` for\n * operations with no return type.\n */\nexport interface FormulaVisitor<Result> extends ParseTreeVisitor<Result> {\n  /**\n   * Visit a parse tree produced by the `StringLiteral`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitStringLiteral?: (ctx: StringLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `IntegerLiteral`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitIntegerLiteral?: (ctx: IntegerLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `DecimalLiteral`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitDecimalLiteral?: (ctx: DecimalLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `BooleanLiteral`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitBooleanLiteral?: (ctx: BooleanLiteralContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `LeftWhitespaceOrComments`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitLeftWhitespaceOrComments?: (ctx: LeftWhitespaceOrCommentsContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `RightWhitespaceOrComments`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitRightWhitespaceOrComments?: (ctx: RightWhitespaceOrCommentsContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `Brackets`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitBrackets?: (ctx: BracketsContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `UnaryOp`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitUnaryOp?: (ctx: UnaryOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `BinaryOp`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitBinaryOp?: (ctx: BinaryOpContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `FieldReferenceCurly`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitFieldReferenceCurly?: (ctx: FieldReferenceCurlyContext) => Result;\n\n  /**\n   * Visit a parse tree produced by the `FunctionCall`\n   * labeled alternative in `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitFunctionCall?: (ctx: FunctionCallContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.root`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitRoot?: (ctx: RootContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.expr`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitExpr?: (ctx: ExprContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.ws_or_comment`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitWs_or_comment?: (ctx: Ws_or_commentContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.field_reference`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitField_reference?: (ctx: Field_referenceContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.field_reference_curly`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitField_reference_curly?: (ctx: Field_reference_curlyContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.func_name`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitFunc_name?: (ctx: Func_nameContext) => Result;\n\n  /**\n   * Visit a parse tree produced by `Formula.identifier`.\n   * @param ctx the parse tree\n   * @return the visitor result\n   */\n  visitIdentifier?: (ctx: IdentifierContext) => Result;\n}\n"
  },
  {
    "path": "packages/formula/src/parser/README.md",
    "content": "# Formula Language\n\nThis directory contains the [ANLTR Grammar](https://www.antlr.org/) for Teable formula\n\n## Making changes to the language\n\nIf you want to make changes to the syntax of the formula language first you must update\nthe grammar .g4 files found in this directory. Once done you will then need to run `pnpm antlr4ts`\nto re-build it.\n"
  },
  {
    "path": "packages/formula/tsconfig.build.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\"dist\", \"**/__tests__/*\", \"**/*spec.ts\"]\n}\n"
  },
  {
    "path": "packages/formula/tsconfig.eslint.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"allowJs\": true\n  },\n  \"exclude\": [\"node_modules\", \"**/.*/*\", \"dist\"],\n  \"include\": [\n    \".eslintrc.*\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \"**/*.mts\",\n    \"**/*.js\",\n    \"**/*.cjs\",\n    \"**/*.mjs\",\n    \"**/*.jsx\",\n    \"**/*.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/formula/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"@teable/formula\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"CommonJS\",\n    \"moduleResolution\": \"node\",\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\"],\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"noEmit\": false,\n    \"incremental\": true,\n    \"resolveJsonModule\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"dist\",\n    \"composite\": true,\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"types\": [\"vitest/globals\", \"node\"]\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/\", \"./dist\", \"./coverage\"],\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/formula/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n  entry: ['src/index.ts'],\n  outDir: 'dist',\n  format: ['esm', 'cjs'],\n  external: ['antlr4ts'],\n  dts: {\n    sourcemap: true,\n  },\n  clean: true,\n  sourcemap: false,\n  minify: false,\n  treeshake: true,\n  skipNodeModulesBundle: true,\n  outExtensions({ format }) {\n    const js = format === 'es' ? '.mjs' : '.cjs';\n    return {\n      js,\n      dts: '.d.ts',\n    };\n  },\n});\n"
  },
  {
    "path": "packages/formula/vitest.config.ts",
    "content": "import { defineConfig, configDefaults } from 'vitest/config';\n\nconst testFiles = ['./src/**/*.{test,spec}.{js,ts}'];\n\nexport default defineConfig({\n  resolve: {\n    conditions: ['@teable/source'],\n  },\n  ssr: {\n    resolve: {\n      conditions: ['@teable/source'],\n      externalConditions: ['@teable/source'],\n    },\n  },\n  cacheDir: '../../.cache/vitest/formula',\n  test: {\n    globals: true,\n    environment: 'node',\n    setupFiles: './vitest.setup.js',\n    passWithNoTests: true,\n    typecheck: {\n      enabled: false,\n    },\n    pool: 'forks',\n    fileParallelism: false,\n    coverage: {\n      provider: 'v8',\n      include: ['src/**/*.{js,ts}'],\n    },\n    // To mimic Jest behaviour regarding mocks.\n    // @link https://vitest.dev/config/#clearmocks\n    clearMocks: true,\n    mockReset: true,\n    restoreMocks: true,\n    include: testFiles,\n    exclude: [...configDefaults.exclude],\n  },\n});\n"
  },
  {
    "path": "packages/formula/vitest.setup.js",
    "content": "// Intentionally empty: formula parser tests do not need global setup.\n"
  },
  {
    "path": "packages/i18n-keys/package.json",
    "content": "{\n  \"name\": \"@teable/i18n-keys\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/i18n-keys\"\n  },\n  \"author\": {\n    \"name\": \"tea artist\",\n    \"url\": \"https://github.com/tea-artist\"\n  },\n  \"sideEffects\": false,\n  \"type\": \"module\",\n  \"main\": \"dist/index.cjs\",\n  \"module\": \"dist/index.js\",\n  \"types\": \"src/index.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./src/index.ts\",\n      \"import\": \"./src/index.ts\",\n      \"module\": \"./dist/index.js\",\n      \"require\": \"./src/index.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\",\n    \"src\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsdown --tsconfig tsconfig.build.json\",\n    \"dev\": \"tsdown --tsconfig tsconfig.build.json --watch\",\n    \"clean\": \"rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./tsconfig.build.tsbuildinfo ./.eslintcache\",\n    \"lint\": \"eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --cache --cache-location ../../.cache/eslint/i18n-keys.eslintcache\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@teable/eslint-config-bases\": \"workspace:^\",\n    \"@teable/v2-tsdown-config\": \"workspace:*\",\n    \"@types/node\": \"22.18.0\",\n    \"eslint\": \"8.57.0\",\n    \"prettier\": \"3.2.5\",\n    \"rimraf\": \"5.0.5\",\n    \"tsdown\": \"0.18.1\",\n    \"typescript\": \"5.4.3\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n-keys/src/index.ts",
    "content": "export const tableI18nKeys = {\n  validation: {\n    link: {\n      batch_duplicate: 'validation.link.batch_duplicate',\n      one_many_duplicate: 'validation.link.one_many_duplicate',\n      one_one_duplicate: 'validation.link.one_one_duplicate',\n    },\n    field: {\n      maxColumnLimit: 'validation.field.maxColumnLimit',\n    },\n  },\n  field: {\n    default: {\n      singleLineText: {\n        title: 'field.default.singleLineText.title',\n      },\n      longText: {\n        title: 'field.default.longText.title',\n      },\n      number: {\n        title: 'field.default.number.title',\n      },\n      rating: {\n        title: 'field.default.rating.title',\n      },\n      singleSelect: {\n        title: 'field.default.singleSelect.title',\n      },\n      multipleSelect: {\n        title: 'field.default.multipleSelect.title',\n      },\n      checkbox: {\n        title: 'field.default.checkbox.title',\n      },\n      attachment: {\n        title: 'field.default.attachment.title',\n      },\n      user: {\n        title: 'field.default.user.title',\n      },\n      date: {\n        title: 'field.default.date.title',\n      },\n      createdTime: {\n        title: 'field.default.createdTime.title',\n      },\n      lastModifiedTime: {\n        title: 'field.default.lastModifiedTime.title',\n      },\n      createdBy: {\n        title: 'field.default.createdBy.title',\n      },\n      lastModifiedBy: {\n        title: 'field.default.lastModifiedBy.title',\n      },\n      autoNumber: {\n        title: 'field.default.autoNumber.title',\n      },\n      button: {\n        title: 'field.default.button.title',\n      },\n      formula: {\n        title: 'field.default.formula.title',\n      },\n      lookup: {\n        title: 'field.default.lookup.title',\n      },\n      conditionalLookup: {\n        title: 'field.default.conditionalLookup.title',\n      },\n      rollup: {\n        title: 'field.default.rollup.title',\n        rollup: 'field.default.rollup.rollup',\n      },\n      conditionalRollup: {\n        title: 'field.default.conditionalRollup.title',\n      },\n    },\n  },\n} as const;\n\nexport type TableI18nKey =\n  | 'validation.link.batch_duplicate'\n  | 'validation.link.one_many_duplicate'\n  | 'validation.link.one_one_duplicate'\n  | 'validation.field.maxColumnLimit'\n  | 'field.default.singleLineText.title'\n  | 'field.default.longText.title'\n  | 'field.default.number.title'\n  | 'field.default.rating.title'\n  | 'field.default.singleSelect.title'\n  | 'field.default.multipleSelect.title'\n  | 'field.default.checkbox.title'\n  | 'field.default.attachment.title'\n  | 'field.default.user.title'\n  | 'field.default.date.title'\n  | 'field.default.createdTime.title'\n  | 'field.default.lastModifiedTime.title'\n  | 'field.default.createdBy.title'\n  | 'field.default.lastModifiedBy.title'\n  | 'field.default.autoNumber.title'\n  | 'field.default.button.title'\n  | 'field.default.formula.title'\n  | 'field.default.lookup.title'\n  | 'field.default.conditionalLookup.title'\n  | 'field.default.rollup.title'\n  | 'field.default.rollup.rollup'\n  | 'field.default.conditionalRollup.title';\n"
  },
  {
    "path": "packages/i18n-keys/tsconfig.build.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./tsconfig.json\",\n  \"exclude\": [\"dist\", \"**/__tests__/*\", \"**/*spec.ts\"]\n}\n"
  },
  {
    "path": "packages/i18n-keys/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"@teable/i18n-keys\",\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"CommonJS\",\n    \"moduleResolution\": \"node\",\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\"],\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"noEmit\": false,\n    \"incremental\": true,\n    \"resolveJsonModule\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"dist\",\n    \"composite\": true,\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/node_modules\", \"**/.*/\", \"./dist\", \"./coverage\"],\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/i18n-keys/tsdown.config.ts",
    "content": "import { v2TsdownBaseConfig } from '@teable/v2-tsdown-config';\nimport { defineConfig } from 'tsdown';\n\nexport default defineConfig(v2TsdownBaseConfig);\n"
  },
  {
    "path": "packages/icons/.eslintrc.cjs",
    "content": "/**\n * Specific eslint rules for this app/package, extends the base rules\n * @see https://github.com/teableio/teable/blob/main/docs/about-linters.md\n */\n\n// Workaround for https://github.com/eslint/eslint/issues/3458 (re-export of @rushstack/eslint-patch)\nrequire('@teable/eslint-config-bases/patch/modern-module-resolution');\n\nconst { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');\n\nmodule.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    tsconfigRootDir: __dirname,\n    project: 'tsconfig.eslint.json',\n  },\n  ignorePatterns: [...getDefaultIgnorePatterns(), '/scripts'],\n  extends: [\n    '@teable/eslint-config-bases/sonar',\n    '@teable/eslint-config-bases/react',\n    // Apply prettier and disable incompatible rules\n    '@teable/eslint-config-bases/prettier-plugin',\n  ],\n  rules: {\n    // optional overrides per project\n  },\n  overrides: [\n    // optional overrides per project file match\n  ],\n};\n"
  },
  {
    "path": "packages/icons/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# build\n/dist\n/build\n/storybook-static\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n\n# misc\n.DS_Store\n*.pem\n\n.env\n"
  },
  {
    "path": "packages/icons/.idea/icons.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/.tmp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/temp\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/tmp\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": "packages/icons/.idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/icons.iml\" filepath=\"$PROJECT_DIR$/.idea/icons.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": "packages/icons/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2025 Teable, Inc.\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": "packages/icons/package.json",
    "content": "{\n  \"name\": \"@teable/icons\",\n  \"version\": \"1.10.0\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/teableio/teable\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/teableio/teable\",\n    \"directory\": \"packages/icons\"\n  },\n  \"main\": \"./dist/index.js\",\n  \"module\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"@teable/source\": \"./src/index.ts\",\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"clean\": \"rimraf ./dist ./build ./tsconfig.tsbuildinfo ./node_modules/.cache\",\n    \"dev\": \"rm -rf dist && tsc --watch\",\n    \"test\": \"echo \\\"Error: no test specified\\\"\",\n    \"lint\": \"eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs,.mdx --cache --cache-location ../../.cache/eslint/icons.eslintcache\",\n    \"typecheck\": \"tsc --project ./tsconfig.json --noEmit\",\n    \"generate\": \"rm -rf src/components && node ./scripts/generate.mjs\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\"\n  },\n  \"devDependencies\": {\n    \"@svgr/core\": \"8.1.0\",\n    \"react\": \"18.3.1\",\n    \"react-dom\": \"18.3.1\",\n    \"@svgr/plugin-jsx\": \"8.1.0\",\n    \"@svgr/plugin-prettier\": \"8.1.0\",\n    \"@svgr/plugin-svgo\": \"8.1.0\",\n    \"@types/fs-extra\": \"11.0.4\",\n    \"@types/node\": \"22.18.0\",\n    \"@types/react\": \"18.3.18\",\n    \"axios\": \"1.7.7\",\n    \"chalk\": \"5.3.0\",\n    \"dotenv\": \"16.4.5\",\n    \"eslint\": \"8.57.0\",\n    \"figma-js\": \"1.16.0\",\n    \"fs-extra\": \"11.2.0\",\n    \"lodash\": \"4.17.21\",\n    \"rimraf\": \"5.0.5\",\n    \"typescript\": \"5.4.3\"\n  }\n}\n"
  },
  {
    "path": "packages/icons/scripts/generate.mjs",
    "content": "import path from 'path';\nimport { transform } from '@svgr/core';\nimport axios from 'axios';\nimport chalk from 'chalk';\nimport dotenv from 'dotenv';\nimport fs from 'fs-extra';\nimport _ from 'lodash';\nimport * as Figma from 'figma-js';\n\ndotenv.config();\n\nconst componentsDir = 'src/components';\n\n// Add .env file\nconst FIGMA_API_TOKEN = process.env.FIGMA_API_TOKEN;\nconst FIGMA_FILE_ID = process.env.FIGMA_FILE_ID;\nconst FIGMA_CANVAS = process.env.FIGMA_CANVAS;\n\nif (!FIGMA_API_TOKEN) {\n  throw new Error('Missing environment variable FIGMA_API_TOKEN');\n}\n\nif (!FIGMA_FILE_ID) {\n  throw new Error('Missing environment variable FIGMA_FILE_ID');\n}\n\nif (!FIGMA_CANVAS) {\n  throw new Error('Missing environment variable FIGMA_CANVAS');\n}\n\nconst figmaApi = Figma.Client({ personalAccessToken: FIGMA_API_TOKEN });\n\nconst getSvgs = async ({ fileId, canvas, group }) => {\n  const file = await figmaApi.file(fileId);\n  const { document } = file.data;\n  const iconsNode = document.children.find(({ name }) => name === canvas);\n  if (!iconsNode) {\n    throw new Error(`Couldn't find page with name ${canvas}`);\n  }\n  const usingIconNodes = iconsNode.children.find(({ name }) => name === group)?.children || [];\n  const usingNodeId = usingIconNodes.map(({ id }) => id);\n  const svgs = await figmaApi.fileImages(fileId, {\n    format: 'svg',\n    ids: usingNodeId,\n  });\n  return usingIconNodes.map(({ id, name }) => ({ id, name, url: svgs.data.images[id] }));\n};\n\nconst downloadSVGsData = async (data, batchSize = 20, delayBetweenBatches = 500) => {\n  const results = [];\n  const batchCount = Math.ceil(data.length / batchSize);\n\n  for (let i = 0; i < batchCount; i++) {\n    const batchData = data.slice(i * batchSize, (i + 1) * batchSize);\n\n    console.log(`Processing batch ${i + 1}/${batchCount}, containing ${batchData.length} requests`);\n\n    const batchResults = await Promise.all(\n      batchData.map(async (dataItem) => {\n        try {\n          const downloadedSvg = await axios.get(dataItem.url);\n          return {\n            ...dataItem,\n            data: downloadedSvg.data,\n            success: true,\n          };\n        } catch (error) {\n          console.error(`Failed to download ${dataItem.url}:`, error.message);\n          return {\n            ...dataItem,\n            success: false,\n          };\n        }\n      })\n    );\n\n    results.push(...batchResults);\n\n    if (i < batchCount - 1) {\n      await new Promise((resolve) => setTimeout(resolve, delayBetweenBatches));\n    }\n  }\n\n  return results;\n};\n\nconst transformReactComponent = (svgList) => {\n  if (!fs.existsSync(componentsDir)) {\n    fs.mkdirSync(componentsDir);\n  }\n  svgList.forEach((svg) => {\n    if (!svg.success) return;\n    const svgCode = svg.data;\n    const svgName = svg.name.split('/').pop();\n    const camelCaseInput = _.camelCase(svgName);\n    const componentName = camelCaseInput.charAt(0).toUpperCase() + camelCaseInput.slice(1);\n    const componentFileName = `${componentName}.tsx`;\n\n    // Converts SVG code into React code using SVGR library\n    const componentCode = transform.sync(\n      svgCode,\n      {\n        typescript: true,\n        icon: true,\n        replaceAttrValues: {\n          '#000': 'currentColor',\n        },\n        plugins: [\n          // Clean SVG files using SVGO\n          [\n            '@svgr/plugin-svgo',\n            {\n              multipass: true,\n              plugins: [\n                {\n                  name: 'prefixIds',\n                  params: {\n                    prefix: componentName.toLowerCase(),\n                    delim: '_',\n                  },\n                },\n              ],\n            },\n          ],\n          // Generate JSX\n          '@svgr/plugin-jsx',\n          // Format the result using Prettier\n          '@svgr/plugin-prettier',\n        ],\n      },\n      { componentName }\n    );\n    // 6. Write generated component to file system\n    fs.outputFileSync(path.resolve(componentsDir, componentFileName), componentCode);\n    // fs.outputFileSync(path.resolve('src/icons', `${svgName}.svg`), svg.data);\n  });\n};\n\nconst genIndexContent = () => {\n  let indexContent = '';\n  const indexPath = path.resolve('src/index.ts');\n\n  fs.readdirSync(componentsDir).forEach((componentFileName) => {\n    // Convert name to pascal case\n    const componentName = componentFileName.split('.')[0];\n\n    // Export statement\n    const componentExport = `export { default as ${componentName} } from './components/${componentName}';\\n`;\n\n    indexContent += componentExport;\n  });\n\n  // Write the content to file system\n  fs.writeFileSync(indexPath, indexContent);\n};\n\nconst generate = async () => {\n  console.log(chalk.magentaBright('-> Fetching icons metadata'));\n  const svgs = await getSvgs({ fileId: FIGMA_FILE_ID, canvas: FIGMA_CANVAS, group: 'using' });\n  console.log(chalk.blueBright('-> Downloading SVG code'));\n  const svgsData = await downloadSVGsData(svgs);\n  console.log(chalk.cyanBright('-> Converting to React components'));\n  transformReactComponent(svgsData);\n  console.log(chalk.yellowBright('-> Writing exports components'));\n  genIndexContent();\n  console.log(chalk.greenBright('-> All done! ✅'));\n};\n\ngenerate();\n"
  },
  {
    "path": "packages/icons/src/components/A.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst A = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M21 21 12 3 3 21m3-7h12\"\n    />\n  </svg>\n);\nexport default A;\n"
  },
  {
    "path": "packages/icons/src/components/ActionAI.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ActionAI = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#clip0_836_1265)\">\n      <path\n        d=\"M3.52872 16.9412C3.56722 16.9412 3.60489 16.9538 3.63557 16.977C3.66625 17.0003 3.68861 17.033 3.69899 17.0701L4.25666 19.2353C4.28817 19.3574 4.35223 19.4687 4.44141 19.5579C4.5306 19.6471 4.64188 19.7111 4.76402 19.7426L6.92923 20.3003C6.96646 20.3106 6.99958 20.333 7.02298 20.3637C7.04631 20.3944 7.05882 20.432 7.05882 20.4706C7.05882 20.5091 7.04631 20.5467 7.02298 20.5774C6.99958 20.6082 6.96646 20.6306 6.92923 20.6409L4.76402 21.1985C4.64188 21.23 4.5306 21.2941 4.44141 21.3833C4.35223 21.4725 4.28817 21.5838 4.25666 21.7059L3.6983 23.8711C3.68793 23.9081 3.66614 23.9409 3.63557 23.9642C3.60492 23.9874 3.56719 24 3.52872 24C3.49021 24 3.45256 23.9874 3.42188 23.9642C3.39121 23.9409 3.36883 23.9082 3.35846 23.8711L2.80078 21.7059C2.76927 21.5837 2.70523 21.4725 2.61604 21.3833C2.52685 21.2941 2.41555 21.23 2.29343 21.1985L0.128217 20.6402C0.0913206 20.6297 0.0589741 20.6074 0.0358456 20.5767C0.0127265 20.5461 3.39594e-08 20.509 0 20.4706C0 20.4322 0.0127266 20.3951 0.0358456 20.3644C0.0589741 20.3338 0.0913206 20.3115 0.128217 20.301L2.29343 19.742C2.41548 19.7105 2.52686 19.647 2.61604 19.5579C2.70523 19.4688 2.76922 19.3574 2.80078 19.2353L3.35915 17.0701C3.36953 17.033 3.39189 17.0003 3.42256 16.977C3.45315 16.9539 3.49037 16.9412 3.52872 16.9412Z\"\n        fill=\"#EC4899\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12 0.705882C12.3639 0.705911 12.7193 0.809975 13.0244 1.00437L13.1519 1.09329L13.2718 1.19256C13.5027 1.39942 13.6799 1.65944 13.7875 1.95014L13.8357 2.09835L13.8433 2.12661L15.4053 8.18865L15.4288 8.26034C15.4571 8.32992 15.4994 8.39368 15.5528 8.44715C15.6241 8.51826 15.7132 8.56877 15.8107 8.59398L21.8734 10.1567L21.8961 10.1629C22.2481 10.26 22.5641 10.4556 22.8068 10.7254L22.906 10.8454L22.9949 10.9729C23.1905 11.2786 23.2955 11.6349 23.2955 12C23.2955 12.4171 23.1587 12.8228 22.906 13.1546C22.6533 13.4865 22.2982 13.7261 21.8961 13.8371C21.8886 13.8392 21.881 13.8413 21.8734 13.8433L15.8107 15.406C15.7132 15.4312 15.6241 15.4817 15.5528 15.5528C15.4994 15.6063 15.4571 15.6701 15.4288 15.7397L15.4053 15.8114L13.8419 21.8741C13.8395 21.8833 13.8369 21.8925 13.8343 21.9017C13.7223 22.3021 13.4824 22.6553 13.1512 22.9067C12.8199 23.158 12.4151 23.294 11.9993 23.2941C11.5835 23.2941 11.1788 23.158 10.8474 22.9067C10.5161 22.6553 10.2757 22.3022 10.1636 21.9017C10.161 21.8924 10.1584 21.8827 10.156 21.8734L8.59398 15.8114C8.56878 15.7137 8.51777 15.6242 8.44646 15.5528C8.37517 15.4816 8.28554 15.4312 8.18796 15.406L2.12592 13.8426C2.11499 13.8398 2.10369 13.8367 2.09283 13.8336C1.69392 13.7204 1.34263 13.4799 1.0926 13.1491C0.873865 12.8597 0.742814 12.5146 0.713465 12.1551L0.707261 12L0.713465 11.8449C0.742814 11.4854 0.873865 11.1403 1.0926 10.8509C1.34263 10.5201 1.69392 10.2796 2.09283 10.1664C2.10356 10.1633 2.11443 10.1602 2.12523 10.1574L8.18796 8.59329C8.28549 8.56808 8.3752 8.51698 8.44646 8.44577C8.51773 8.37452 8.56873 8.28552 8.59398 8.18796L10.1574 2.12592L10.165 2.09835C10.2771 1.69786 10.5168 1.34466 10.8481 1.09329L10.9756 1.00437C11.2807 0.809948 11.6361 0.705903 12 0.705882ZM11.3279 8.89384C11.1764 9.47977 10.8702 10.0157 10.4421 10.4435C10.0142 10.871 9.4795 11.1761 8.89384 11.3272L6.28814 11.9993L8.89315 12.6714C9.47922 12.8227 10.0148 13.1286 10.4428 13.5565C10.8709 13.9846 11.1767 14.5199 11.3279 15.1062L11.9993 17.7112L12.6714 15.1062L12.7348 14.889C12.9005 14.3879 13.1818 13.9312 13.5565 13.5565C13.9846 13.1286 14.5194 12.8226 15.1055 12.6714L17.7119 12L15.1062 11.3286C14.5201 11.1774 13.9846 10.8714 13.5565 10.4435C13.1818 10.0688 12.9005 9.61213 12.7348 9.11098L12.6714 8.89384L11.9993 6.28814L11.3279 8.89384Z\"\n        fill=\"#EC4899\"\n      />\n      <path\n        d=\"M20.4699 0C20.5084 0 20.5461 0.0125883 20.5767 0.0358456C20.6074 0.0591205 20.6298 0.0918195 20.6402 0.128906L21.1978 2.29412C21.2293 2.41624 21.2934 2.52754 21.3826 2.61673C21.4718 2.70592 21.5831 2.76996 21.7052 2.80147L23.8704 3.35915C23.9076 3.36942 23.9408 3.39184 23.9642 3.42256C23.9875 3.45327 24 3.49085 24 3.52941C24 3.56797 23.9875 3.60556 23.9642 3.63626C23.9408 3.66699 23.9076 3.68941 23.8704 3.69968L21.7052 4.25735C21.5831 4.28886 21.4718 4.3529 21.3826 4.4421C21.2934 4.53128 21.2293 4.64258 21.1978 4.76471L20.6395 6.92992C20.6291 6.9669 20.6073 6.99973 20.5767 7.02298C20.5461 7.04623 20.5084 7.0588 20.4699 7.05882C20.4314 7.05882 20.3937 7.04625 20.3631 7.02298C20.3324 6.99971 20.31 6.96699 20.2996 6.92992L19.742 4.76471C19.7104 4.64257 19.6464 4.53129 19.5572 4.4421C19.468 4.35292 19.3567 4.28886 19.2346 4.25735L17.0694 3.69899C17.0325 3.68849 17.0002 3.66618 16.977 3.63557C16.9539 3.60495 16.9412 3.56778 16.9412 3.52941C16.9412 3.49104 16.9539 3.45388 16.977 3.42325C17.0002 3.39265 17.0325 3.37033 17.0694 3.35983L19.2346 2.80078C19.3567 2.76931 19.468 2.70581 19.5572 2.61673C19.6464 2.5276 19.7104 2.41619 19.742 2.29412L20.3003 0.128906C20.3107 0.0918336 20.3331 0.0591133 20.3637 0.0358456C20.3943 0.0127032 20.4315 5.10491e-05 20.4699 0Z\"\n        fill=\"#EC4899\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_836_1265\">\n        <rect width=\"24\" height=\"24\" fill=\"white\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default ActionAI;\n"
  },
  {
    "path": "packages/icons/src/components/ActionCreateRecord.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ActionCreateRecord = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M12.2498 6.75C13.0781 6.75013 13.7498 7.42165 13.7498 8.25V10.7498H16.2502L16.4033 10.7571C17.1596 10.834 17.7501 11.4733 17.7502 12.2498C17.7501 13.0779 17.0784 13.7495 16.2502 13.7498H13.7498V16.2502C13.7495 17.0784 13.0779 17.7501 12.2498 17.7502C11.4216 17.7501 10.75 17.0784 10.7498 16.2502V13.7498H8.25C7.42165 13.7498 6.75012 13.0781 6.75 12.2498C6.75013 11.4214 7.42165 10.7498 8.25 10.7498H10.7498V8.25C10.7498 7.42165 11.4214 6.75013 12.2498 6.75Z\"\n      fill=\"#EC4899\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M12.2498 0.75C18.6008 0.750132 23.7501 5.89867 23.7502 12.2498C23.7501 18.6008 18.6008 23.7501 12.2498 23.7502C5.89867 23.7501 0.750124 18.6008 0.75 12.2498C0.750132 5.89867 5.89867 0.750132 12.2498 0.75ZM12.2498 3.75C7.55553 3.75013 3.75013 7.55553 3.75 12.2498C3.75012 16.944 7.55552 20.7501 12.2498 20.7502C16.944 20.7501 20.7501 16.944 20.7502 12.2498C20.7501 7.55553 16.944 3.75013 12.2498 3.75Z\"\n      fill=\"#EC4899\"\n    />\n  </svg>\n);\nexport default ActionCreateRecord;\n"
  },
  {
    "path": "packages/icons/src/components/ActionGetRecord.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ActionGetRecord = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M4.22243 4.22243C7.63505 0.809811 13.1682 0.809808 16.5808 4.22243C19.6095 7.25125 19.9469 11.9483 17.5995 15.3528L22.1976 19.9509C22.8181 20.5714 22.8181 21.5771 22.1976 22.1976C21.5771 22.8181 20.5714 22.8181 19.9509 22.1976L15.3528 17.5995C11.9483 19.9469 7.25125 19.6094 4.22243 16.5808C0.809809 13.1682 0.809809 7.63505 4.22243 4.22243ZM14.3334 6.46913C12.1617 4.29762 8.64075 4.29752 6.46913 6.46913C4.29752 8.64074 4.29763 12.1617 6.46913 14.3334C8.6408 16.505 12.1617 16.505 14.3334 14.3334C16.505 12.1617 16.505 8.6408 14.3334 6.46913Z\"\n      fill=\"#F97316\"\n    />\n  </svg>\n);\nexport default ActionGetRecord;\n"
  },
  {
    "path": "packages/icons/src/components/ActionHttpRequest.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ActionHttpRequest = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#clip0_836_1263)\">\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12 0C18.6274 9.66449e-08 24 5.37258 24 12C24 18.6274 18.6274 24 12 24C5.37258 24 9.66386e-08 18.6274 0 12C0 5.37258 5.37258 0 12 0ZM3.12524 13.5C3.64072 16.5723 5.71204 19.1167 8.50195 20.2947C7.31584 18.2178 6.58458 15.9013 6.36841 13.5H3.12524ZM17.6316 13.5C17.4154 15.9014 16.6835 18.2177 15.4973 20.2947C18.2875 19.1168 20.3592 16.5725 20.8748 13.5H17.6316ZM9.38306 13.5C9.64887 15.918 10.5525 18.2215 12 20.1738C13.4475 18.2215 14.3511 15.918 14.6169 13.5H9.38306ZM8.50195 3.70459C5.7119 4.88255 3.64074 7.42763 3.12524 10.5H6.36841C6.5846 8.09851 7.31565 5.7816 8.50195 3.70459ZM12 3.82544C10.5523 5.77785 9.64889 8.08184 9.38306 10.5H14.6169C14.3511 8.08184 13.4477 5.77785 12 3.82544ZM15.4973 3.70459C16.6837 5.78168 17.4154 8.09839 17.6316 10.5H20.8748C20.3592 7.4274 18.2877 4.88244 15.4973 3.70459Z\"\n        fill=\"#EF4444\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_836_1263\">\n        <rect width=\"24\" height=\"24\" fill=\"white\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default ActionHttpRequest;\n"
  },
  {
    "path": "packages/icons/src/components/ActionScript.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ActionScript = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M12.0001 10.2495C12.9665 10.2495 13.75 11.0331 13.7501 11.9995C13.7501 12.966 12.9666 13.7495 12.0001 13.7495C11.0336 13.7495 10.2501 12.966 10.2501 11.9995C10.2502 11.0331 11.0337 10.2495 12.0001 10.2495Z\"\n      fill=\"#7C3AED\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M12.0021 0.0483398C12.9963 0.0473452 13.8015 0.599517 14.3946 1.28271C14.9915 1.97022 15.4786 2.9007 15.8653 3.95264C15.966 4.22644 16.0608 4.51273 16.1505 4.81006C16.4532 4.73901 16.7494 4.67819 17.0372 4.62842C18.1413 4.4375 19.1905 4.39383 20.0841 4.56689C20.9722 4.73902 21.8535 5.16058 22.3497 6.02197L22.3507 6.02393L22.3526 6.02588L22.3517 6.02686C22.8489 6.88698 22.7734 7.85953 22.4786 8.71436C22.1817 9.57501 21.6201 10.4628 20.9025 11.3237C20.7154 11.5481 20.5138 11.7728 20.3009 11.9995C20.5138 12.2262 20.7144 12.4519 20.9015 12.6763C21.6191 13.5372 22.1817 14.424 22.4786 15.2847C22.7736 16.1397 22.8492 17.1128 22.3517 17.9731L22.3497 17.979V17.9771C21.8535 18.8386 20.9723 19.26 20.0841 19.4321C19.1902 19.6053 18.1406 19.5616 17.0362 19.3706C16.7485 19.3208 16.4531 19.259 16.1505 19.188C16.0606 19.486 15.9662 19.773 15.8653 20.0474C15.4786 21.0993 14.9915 22.0298 14.3946 22.7173C13.8015 23.4004 12.9962 23.9527 12.0021 23.9517L12.003 23.9526L12.0001 23.9517L11.9972 23.9526C11.0035 23.9533 10.1985 23.4001 9.60558 22.7173C9.0087 22.0298 8.52165 21.0993 8.13488 20.0474C8.03398 19.7729 7.93867 19.4861 7.84874 19.188C7.54642 19.2589 7.25138 19.3209 6.96398 19.3706C5.85963 19.5616 4.80995 19.6053 3.91613 19.4321C3.02819 19.2601 2.14784 18.839 1.65148 17.978L1.6505 17.979L1.64953 17.9751L1.64855 17.9741C1.15054 17.1136 1.22651 16.14 1.5216 15.2847C1.81855 14.424 2.38112 13.5372 3.09874 12.6763C3.28565 12.452 3.48558 12.226 3.69835 11.9995C3.4856 11.773 3.28465 11.5479 3.09777 11.3237C2.38016 10.4628 1.81855 9.57502 1.5216 8.71436C1.2267 7.85926 1.15076 6.88621 1.64855 6.02588L1.64953 6.02393L1.6505 6.021L1.74816 5.86475C2.2571 5.10857 3.08381 4.72919 3.91613 4.56787C4.8098 4.3948 5.85888 4.43748 6.963 4.62842C7.25082 4.6782 7.54694 4.739 7.84972 4.81006C7.93948 4.51274 8.03421 4.22644 8.13488 3.95264C8.52165 2.9007 9.00869 1.97022 9.60558 1.28271C10.1986 0.599741 11.0033 0.0476814 11.9972 0.0483398H12.0001L12.003 0.0473633L12.0021 0.0483398ZM12.0001 17.7358C11.3231 18.0431 10.6553 18.3134 10.005 18.5454C10.0817 18.7991 10.1627 19.0412 10.2472 19.271C10.581 20.1789 10.9511 20.8343 11.3048 21.2417C11.6611 21.6521 11.8985 21.7029 11.9972 21.7026H12.003C12.1017 21.7029 12.3391 21.652 12.6954 21.2417C13.0491 20.8343 13.4192 20.1789 13.753 19.271C13.8375 19.0413 13.9176 18.799 13.9943 18.5454C13.3443 18.3134 12.6768 18.043 12.0001 17.7358ZM5.3341 13.5454C5.15309 13.7383 4.98381 13.9289 4.82726 14.1167C4.2078 14.8599 3.82452 15.509 3.64855 16.019C3.47172 16.5319 3.54624 16.7626 3.59581 16.8481L3.59874 16.854C3.64798 16.9397 3.8112 17.1198 4.34386 17.2231C4.87352 17.3257 5.62709 17.3177 6.58019 17.1528C6.82107 17.1112 7.07068 17.0598 7.32824 16.9995C7.20423 16.3209 7.10466 15.6079 7.03234 14.8687C6.42779 14.4361 5.86015 13.9924 5.3341 13.5454ZM18.6651 13.5454C18.1391 13.9924 17.5715 14.4361 16.9669 14.8687C16.8946 15.608 16.795 16.3209 16.671 16.9995C16.929 17.0599 17.1788 17.1121 17.42 17.1538C18.3732 17.3187 19.1267 17.3257 19.6564 17.2231C20.1893 17.1198 20.3523 16.9396 20.4015 16.854L20.4044 16.8481C20.454 16.7625 20.5284 16.5316 20.3517 16.019C20.1757 15.509 19.7924 14.8599 19.173 14.1167C19.0163 13.9288 18.8463 13.7385 18.6651 13.5454ZM12.0001 8.75537C11.5287 8.99162 11.0518 9.24834 10.5724 9.52393L10.1095 9.79736C9.79457 9.98785 9.4878 10.1816 9.19054 10.3774C9.15937 10.9049 9.14272 11.4473 9.14171 12.0015C9.14278 12.554 9.1595 13.0947 9.19054 13.6206C9.63093 13.9108 10.0909 14.1966 10.5694 14.4741L11.0382 14.7388C11.3605 14.9162 11.682 15.0832 12.0001 15.2427C12.4722 15.0061 12.9506 14.7502 13.4308 14.4741C13.909 14.1968 14.3685 13.9106 14.8087 13.6206C14.8398 13.0947 14.8574 12.554 14.8585 12.0015L14.8527 11.4634C14.8451 11.0952 14.8297 10.733 14.8087 10.3774C14.3679 10.087 13.9069 9.80166 13.4278 9.52393C12.9484 9.24836 12.4715 8.99161 12.0001 8.75537ZM6.57921 6.84619C5.62616 6.6814 4.87343 6.67426 4.34386 6.77686C3.81044 6.88025 3.64781 7.06042 3.59874 7.146L3.59581 7.15088C3.54624 7.23615 3.47137 7.46728 3.64855 7.98096C3.82449 8.49089 4.20701 9.13932 4.82628 9.88232C4.98278 10.0701 5.15216 10.2607 5.33312 10.4536C5.85913 10.0066 6.42685 9.56295 7.03136 9.13037C7.10376 8.39112 7.20316 7.67808 7.32726 6.99951C7.0698 6.93926 6.82 6.88784 6.57921 6.84619ZM19.6564 6.77686C19.1268 6.67427 18.374 6.68142 17.421 6.84619C17.1797 6.88793 16.9291 6.93908 16.671 6.99951C16.7951 7.67812 16.8946 8.39107 16.9669 9.13037C17.5717 9.56314 18.1399 10.0064 18.6661 10.4536C18.8472 10.2606 19.0173 10.0702 19.1739 9.88232C19.7932 9.13934 20.1757 8.49088 20.3517 7.98096C20.5288 7.46729 20.454 7.23615 20.4044 7.15088L20.4015 7.146C20.3524 7.06043 20.1897 6.88025 19.6564 6.77686ZM11.9972 2.29736C11.8986 2.29707 11.6609 2.34726 11.3048 2.75732C10.9511 3.16473 10.581 3.82096 10.2472 4.729C10.1628 4.95855 10.0816 5.2003 10.005 5.45361C10.6551 5.68563 11.3233 5.95499 12.0001 6.26221C12.6764 5.95527 13.3437 5.68549 13.9933 5.45361C13.9167 5.20049 13.8374 4.95839 13.753 4.729C13.4192 3.82096 13.0491 3.16473 12.6954 2.75732C12.3393 2.34726 12.1017 2.29707 12.003 2.29736H11.9972Z\"\n      fill=\"#7C3AED\"\n    />\n  </svg>\n);\nexport default ActionScript;\n"
  },
  {
    "path": "packages/icons/src/components/ActionSendEmail.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ActionSendEmail = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M19.5 14.8751C20.3284 14.8751 21 15.5467 21 16.3751V17.8751H22.5C23.3284 17.8751 24 18.5467 24 19.3751C24 20.2035 23.3284 20.8751 22.5 20.8751H21V22.3751C21 23.2035 20.3284 23.8751 19.5 23.8751C18.6716 23.8751 18 23.2035 18 22.3751V20.8751H16.5C15.6716 20.8751 15 20.2035 15 19.3751C15 18.5467 15.6716 17.8751 16.5 17.8751H18V16.3751C18 15.5467 18.6716 14.8751 19.5 14.8751ZM21 2.87512C22.6569 2.87512 24 4.21827 24 5.87512V13.3751C24 14.2035 23.3284 14.8751 22.5 14.8751C21.6716 14.8751 21 14.2035 21 13.3751V10.0499L13.4668 14.2726C12.5557 14.7832 11.4443 14.7832 10.5332 14.2726L3 10.0499V19.3751H12C12.8284 19.3751 13.5 20.0467 13.5 20.8751C13.5 21.7035 12.8284 22.3751 12 22.3751H3C1.34315 22.3751 1.20798e-08 21.032 0 19.3751V5.87512C0 4.21827 1.34315 2.87512 3 2.87512H21ZM3 6.61047L12 11.6554L21 6.61047V5.87512H3V6.61047Z\"\n      fill=\"#3B82F6\"\n    />\n  </svg>\n);\nexport default ActionSendEmail;\n"
  },
  {
    "path": "packages/icons/src/components/ActionUpdateRecord.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ActionUpdateRecord = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M7.37512 1.00012C8.32913 1.00012 9.24649 1.3546 9.95178 1.99036L10.0902 2.12073L10.0924 2.12366L11.3544 3.38562C11.3561 3.38735 11.3585 3.389 11.3602 3.39075C11.3618 3.39234 11.363 3.39428 11.3646 3.39587L13.2294 5.25989L14.0028 4.48572C14.0365 4.44394 14.0731 4.40366 14.1119 4.36487C14.1499 4.32689 14.1897 4.29172 14.2306 4.25867L16.244 2.24524C17.0407 1.44878 18.1219 1.00145 19.2484 1.00159C20.3748 1.0018 21.455 1.44937 22.2513 2.24597C23.0477 3.04262 23.4951 4.12317 23.495 5.24963C23.4948 6.30574 23.1013 7.32119 22.3956 8.10022L22.2506 8.25256L20.2416 10.2609C20.2074 10.3035 20.1713 10.3458 20.1317 10.3854C20.0923 10.4248 20.0504 10.4604 20.0079 10.4945L19.236 11.2665L22.3737 14.4042C23.8756 15.9062 23.8756 18.3375 22.3737 19.8395L19.8395 22.3737C18.3375 23.8756 15.9061 23.8756 14.4042 22.3737L11.2672 19.2367L9.2406 21.2648C8.84078 21.6633 8.34883 21.9582 7.80872 22.1224L3.56506 23.4093L3.5614 23.41C3.21781 23.5133 2.85244 23.5217 2.50452 23.4342C2.15675 23.3467 1.83913 23.1668 1.58533 22.9135C1.33144 22.66 1.15114 22.3421 1.06311 21.9943C0.975168 21.6466 0.982436 21.2817 1.08508 20.9381L1.08728 20.9315L2.37488 16.6901L2.37561 16.6857C2.54115 16.1459 2.8367 15.6549 3.23621 15.256L5.26062 13.2301L2.12073 10.0902C1.40299 9.36894 1.00012 8.39266 1.00012 7.37512C1.00014 6.35757 1.40296 5.3813 2.12073 4.66003L2.12366 4.65784L4.65784 2.12366L4.66003 2.12073C5.3813 1.40296 6.35757 1.00013 7.37512 1.00012ZM13.389 17.1156L16.526 20.2526C16.8564 20.5827 17.3881 20.5828 17.7184 20.2526L20.2526 17.7184C20.583 17.388 20.583 16.8557 20.2526 16.5253L20.046 16.3187L19.1576 17.2079C18.5718 17.7937 17.6216 17.7937 17.0358 17.2079C16.4501 16.6222 16.4502 15.6726 17.0358 15.0868L17.9242 14.1976L17.1149 13.3883L13.389 17.1156ZM5.35803 17.3771L5.35657 17.3793C5.30549 17.4302 5.26632 17.4922 5.24451 17.5609L5.24524 17.5616L4.50842 19.9874L6.93713 19.2513C7.00607 19.2301 7.06885 19.1928 7.12024 19.1422L16.9427 9.3175L15.1783 7.5531L5.35803 17.3771ZM19.2477 4.00159C18.9583 4.00157 18.6793 4.10185 18.4574 4.28357L18.3658 4.36633L17.3002 5.43201L19.0646 7.19641L20.1295 6.13147C20.3634 5.89756 20.495 5.57971 20.495 5.2489C20.495 4.91806 20.3634 4.60103 20.1295 4.36707C19.8956 4.13309 19.5785 4.00163 19.2477 4.00159ZM4.00012 7.37512C4.00012 7.59832 4.08809 7.81249 4.24475 7.97131L7.38171 11.1083L11.1083 7.38171L10.299 6.57239L9.41052 7.46082C8.82474 8.04661 7.87522 8.0466 7.28943 7.46082C6.70376 6.87502 6.70369 5.92547 7.28943 5.33972L8.17786 4.45129L7.97424 4.24768L7.91199 4.19128C7.76111 4.06813 7.57157 4.00012 7.37512 4.00012C7.15069 4.00013 6.93509 4.08936 6.776 4.24768L4.24475 6.77893C4.08806 6.93775 4.00014 7.15191 4.00012 7.37512Z\"\n      fill=\"#10B981\"\n    />\n  </svg>\n);\nexport default ActionUpdateRecord;\n"
  },
  {
    "path": "packages/icons/src/components/Admin.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Admin = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M22.429 18.357c-.077.41-.342.676-.673.676H21.7a1.18 1.18 0 0 0-1.176 1.176c.009.154.044.306.103.448a.85.85 0 0 1-.29 1.026l-.032.022-1.364.751a.9.9 0 0 1-.374.086.91.91 0 0 1-.67-.287c-.176-.19-.642-.584-.97-.584-.32 0-.793.394-.959.574a.92.92 0 0 1-1.012.21l-1.35-.75c-.355-.247-.479-.69-.317-1.05.028-.066.103-.277.103-.444a1.18 1.18 0 0 0-1.178-1.176h-.046c-.34 0-.603-.267-.682-.678a7 7 0 0 1-.116-1.09c0-.46.104-1.031.116-1.092.08-.41.343-.678.674-.678h.055c.649 0 1.176-.525 1.177-1.173a1.3 1.3 0 0 0-.104-.45.85.85 0 0 1 .29-1.027l.033-.02 1.4-.769.026-.01a.93.93 0 0 1 1.003.21c.17.178.629.55.946.55.312 0 .768-.364.938-.542a.93.93 0 0 1 1.006-.2l1.374.76c.357.246.482.69.32 1.05a1.4 1.4 0 0 0-.102.446 1.177 1.177 0 0 0 1.176 1.174h.047c.338 0 .603.266.681.678.012.062.116.632.116 1.092 0 .483-.116 1.086-.114 1.09m-1.024-1.812a2.244 2.244 0 0 1-1.953-2.224q.007-.349.12-.68l-.972-.54q-.19.175-.401.325-.638.449-1.212.45-.582 0-1.224-.458a3.5 3.5 0 0 1-.402-.334l-1.017.56c.057.168.12.417.12.678a2.25 2.25 0 0 1-1.953 2.225 5 5 0 0 0-.067.719q.009.36.067.717a2.24 2.24 0 0 1 1.954 2.225c0 .26-.067.51-.122.68l.94.525q.192-.182.403-.34c.431-.314.85-.476 1.242-.476.396 0 .819.165 1.254.487q.214.16.405.346l.985-.542a2.2 2.2 0 0 1-.12-.679 2.24 2.24 0 0 1 1.953-2.225 5.5 5.5 0 0 0 .067-.72c0-.234-.04-.532-.067-.72m-4.47 2.788a2.08 2.08 0 0 1-2.078-2.076 2.078 2.078 0 0 1 4.154 0 2.08 2.08 0 0 1-2.077 2.076m0-3.041c-.54 0-.985.426-1.008.966a1.007 1.007 0 0 0 2.014 0 1.01 1.01 0 0 0-1.007-.966m-3.014-5.288-.339.263c-.688.612-2.002.817-2.87.884l-.112-.003q-.366.002-.731.035h-.01v.002c-3.929.37-7.014 3.663-7.014 7.662v1.176h8.61c.227.488.508.95.84 1.373H2.151a.69.69 0 0 1-.694-.686v-1.863c0-3.729 2.26-7.035 5.762-8.424l.397-.156-.337-.263a5.3 5.3 0 0 1-2.068-4.203c0-2.947 2.417-5.344 5.388-5.344s5.388 2.397 5.39 5.344a5.31 5.31 0 0 1-2.068 4.203m-3.322-8.173c-2.205 0-3.999 1.782-3.999 3.97 0 2.19 1.794 3.972 4 3.972s4-1.781 4-3.972-1.795-3.97-4-3.97\"\n    />\n  </svg>\n);\nexport default Admin;\n"
  },
  {
    "path": "packages/icons/src/components/AlertCircle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst AlertCircle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10M12 8v4M12 16h.01\"\n    />\n  </svg>\n);\nexport default AlertCircle;\n"
  },
  {
    "path": "packages/icons/src/components/AlertTriangle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst AlertTriangle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4M12 17h.01\"\n    />\n  </svg>\n);\nexport default AlertTriangle;\n"
  },
  {
    "path": "packages/icons/src/components/AmazonBedrock.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst AmazonBedrock = (props: SVGProps<SVGSVGElement>) => (\n  <svg width=\"78\" height=\"46.5414364640884\" viewBox=\"0 0 181 108\" fill=\"none\" {...props}>\n    <g clipPath=\"url(#clip0_1480_1723)\">\n      <path\n        d=\"M51.2203 39.3158C51.2203 41.5066 51.4572 43.2829 51.8717 44.5856C52.3453 45.8882 52.9374 47.3093 53.7664 48.8487C54.0624 49.3224 54.1809 49.7961 54.1809 50.2106C54.1809 50.8027 53.8256 51.3948 53.0559 51.9869L49.3256 54.4737C48.7927 54.829 48.2598 55.0066 47.7861 55.0066C47.194 55.0066 46.6019 54.7106 46.0098 54.1777C45.1809 53.2895 44.4703 52.3422 43.8782 51.3948C43.2861 50.3882 42.694 49.2632 42.0427 47.9014C37.4243 53.3487 31.6217 56.0724 24.6348 56.0724C19.6611 56.0724 15.694 54.6514 12.7927 51.8093C9.8914 48.9672 8.41113 45.1777 8.41113 40.4408C8.41113 35.4079 10.1874 31.3224 13.7993 28.2435C17.4111 25.1645 22.2072 23.6251 28.3059 23.6251C30.319 23.6251 32.3914 23.8027 34.5822 24.0987C36.773 24.3948 39.023 24.8685 41.3914 25.4014V21.079C41.3914 16.579 40.444 13.4408 38.6085 11.6053C36.7138 9.76979 33.5164 8.88163 28.9572 8.88163C26.8848 8.88163 24.7532 9.11847 22.5624 9.65137C20.3717 10.1843 18.2401 10.8356 16.1677 11.6645C15.2203 12.079 14.5098 12.3158 14.0953 12.4343C13.6809 12.5527 13.3848 12.6119 13.148 12.6119C12.319 12.6119 11.9046 12.0198 11.9046 10.7764V7.87505C11.9046 6.92768 12.023 6.21716 12.319 5.80268C12.6151 5.38821 13.148 4.97374 13.9769 4.55926C16.0493 3.49347 18.5361 2.60531 21.4374 1.89479C24.3388 1.12505 27.4177 0.769788 30.6743 0.769788C37.7203 0.769788 42.8717 2.36847 46.1875 5.56584C49.444 8.76321 51.1019 13.6185 51.1019 20.1316V39.3158H51.2203ZM27.1809 48.3158C29.1348 48.3158 31.148 47.9606 33.2796 47.2501C35.4111 46.5395 37.3059 45.2369 38.9046 43.4606C39.8519 42.3356 40.5625 41.0922 40.9177 39.6711C41.273 38.2501 41.5098 36.5329 41.5098 34.5198V32.0329C39.7927 31.6185 37.9572 31.2632 36.0624 31.0264C34.1677 30.7895 32.3322 30.6711 30.4967 30.6711C26.5296 30.6711 23.6282 31.4408 21.6743 33.0395C19.7203 34.6382 18.773 36.8882 18.773 39.8487C18.773 42.6316 19.4835 44.704 20.9638 46.1251C22.3848 47.6053 24.4572 48.3158 27.1809 48.3158ZM74.7269 54.7106C73.6611 54.7106 72.9506 54.5329 72.4769 54.1185C72.0032 53.7632 71.5888 52.9343 71.2335 51.8093L57.319 6.03953C56.9638 4.85531 56.7861 4.08558 56.7861 3.6711C56.7861 2.72374 57.2598 2.19084 58.2072 2.19084H64.0098C65.1348 2.19084 65.9046 2.36847 66.319 2.78295C66.7927 3.13821 67.148 3.96716 67.5032 5.09216L77.4506 44.2895L86.6874 5.09216C86.9835 3.90795 87.3388 3.13821 87.8124 2.78295C88.2861 2.42768 89.1151 2.19084 90.1809 2.19084H94.9177C96.0427 2.19084 96.8124 2.36847 97.2861 2.78295C97.7598 3.13821 98.1743 3.96716 98.4111 5.09216L107.766 44.7632L118.01 5.09216C118.365 3.90795 118.78 3.13821 119.194 2.78295C119.668 2.42768 120.437 2.19084 121.503 2.19084H127.01C127.957 2.19084 128.49 2.66453 128.49 3.6711C128.49 3.96716 128.431 4.26321 128.372 4.61847C128.312 4.97374 128.194 5.44742 127.957 6.09874L113.687 51.8685C113.332 53.0527 112.918 53.8224 112.444 54.1777C111.97 54.5329 111.201 54.7698 110.194 54.7698H105.102C103.977 54.7698 103.207 54.5922 102.733 54.1777C102.26 53.7632 101.845 52.9935 101.608 51.8093L92.4309 13.6185L83.3125 51.7501C83.0164 52.9343 82.6611 53.704 82.1874 54.1185C81.7138 54.5329 80.8848 54.7106 79.819 54.7106H74.7269ZM150.812 56.3093C147.734 56.3093 144.655 55.954 141.694 55.2435C138.733 54.5329 136.424 53.7632 134.885 52.8751C133.937 52.3422 133.286 51.7501 133.049 51.2172C132.812 50.6843 132.694 50.0922 132.694 49.5593V46.5395C132.694 45.2961 133.168 44.704 134.056 44.704C134.411 44.704 134.766 44.7632 135.122 44.8816C135.477 45.0001 136.01 45.2369 136.602 45.4737C138.615 46.3619 140.806 47.0724 143.115 47.5461C145.484 48.0198 147.793 48.2566 150.161 48.2566C153.891 48.2566 156.793 47.6053 158.806 46.3027C160.819 45 161.885 43.1053 161.885 40.6777C161.885 39.0198 161.352 37.6579 160.286 36.5329C159.22 35.4079 157.207 34.4014 154.306 33.454L145.72 30.7895C141.398 29.4277 138.201 27.4145 136.247 24.7501C134.293 22.1448 133.286 19.2435 133.286 16.1645C133.286 13.6777 133.819 11.4869 134.885 9.59216C135.951 7.69742 137.372 6.03953 139.148 4.73689C140.924 3.37505 142.937 2.36847 145.306 1.65795C147.674 0.94742 150.161 0.651367 152.766 0.651367C154.069 0.651367 155.431 0.710578 156.734 0.888209C158.095 1.06584 159.339 1.30268 160.582 1.53953C161.766 1.83558 162.891 2.13163 163.957 2.48689C165.023 2.84216 165.852 3.19742 166.444 3.55268C167.273 4.02637 167.865 4.50005 168.22 5.03295C168.576 5.50663 168.753 6.15795 168.753 6.98689V9.76979C168.753 11.0132 168.28 11.6645 167.391 11.6645C166.918 11.6645 166.148 11.4277 165.141 10.954C161.766 9.41453 157.977 8.64479 153.773 8.64479C150.398 8.64479 147.734 9.17768 145.898 10.3027C144.062 11.4277 143.115 13.1448 143.115 15.5724C143.115 17.2303 143.707 18.6514 144.891 19.7764C146.076 20.9014 148.266 22.0264 151.405 23.0329L159.812 25.6974C164.076 27.0593 167.155 28.954 168.99 31.3816C170.826 33.8093 171.714 36.5922 171.714 39.6711C171.714 42.2172 171.181 44.5264 170.174 46.5395C169.108 48.5527 167.687 50.329 165.852 51.7501C164.016 53.2303 161.826 54.2961 159.28 55.0658C156.615 55.8948 153.832 56.3093 150.812 56.3093Z\"\n        fill=\"currentColor\"\n      ></path>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M162.004 85.0856C142.524 99.4738 114.221 107.112 89.8855 107.112C55.7803 107.112 25.05 94.5001 1.83948 73.5396C0.00394917 71.8817 1.66184 69.6317 3.85263 70.9343C28.9579 85.5001 59.925 94.3225 91.9579 94.3225C113.57 94.3225 137.313 89.8225 159.162 80.5856C162.418 79.1054 165.201 82.7172 162.004 85.0856Z\"\n        fill=\"#FF9900\"\n      ></path>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M170.115 75.8487C167.628 72.6513 153.654 74.3092 147.319 75.079C145.424 75.3158 145.128 73.6579 146.845 72.4145C157.976 64.5987 176.272 66.8487 178.404 69.454C180.536 72.1184 177.812 90.4145 167.391 99.1776C165.792 100.539 164.253 99.8289 164.963 98.0526C167.332 92.1908 172.601 78.9868 170.115 75.8487Z\"\n        fill=\"#FF9900\"\n      ></path>\n    </g>\n    <defs>\n      <clipPath id=\"clip0_1480_1723\">\n        <rect width=\"180\" height=\"107.763\" fill=\"white\" transform=\"translate(0.0625)\"></rect>\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default AmazonBedrock;\n"
  },
  {
    "path": "packages/icons/src/components/Anthropic.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Anthropic = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__anthropic)\">\n      <path fill=\"#CA9F7B\" d=\"M6 0h12q6 0 6 6v12q0 6-6 6H6q-6 0-6-6V6q0-6 6-6\" />\n      <path\n        fill=\"#191918\"\n        d=\"M15.384 6.435H12.97l4.405 11.13h2.416zm-6.979 0L4 17.565h2.463l.901-2.337h4.609l.9 2.337h2.464l-4.405-11.13zm-.244 6.726 1.508-3.912 1.507 3.912z\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__anthropic\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default Anthropic;\n"
  },
  {
    "path": "packages/icons/src/components/AppBuilder.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst AppBuilder = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M19.875 4.5C20.7034 4.5 21.375 5.17157 21.375 6V18C21.375 18.8284 20.7034 19.5 19.875 19.5H4.125C3.29657 19.5 2.625 18.8284 2.625 18V6C2.625 5.17157 3.29657 4.5 4.125 4.5H19.875ZM12.4336 10.0527C12.285 9.64939 11.715 9.64939 11.5664 10.0527L10.9297 11.7803C10.8829 11.9073 10.7823 12.0079 10.6553 12.0547L8.92773 12.6914C8.52439 12.84 8.52439 13.41 8.92773 13.5586L10.6553 14.1953C10.7823 14.2421 10.8829 14.3427 10.9297 14.4697L11.5664 16.1973C11.715 16.6006 12.285 16.6006 12.4336 16.1973L13.0703 14.4697C13.1171 14.3427 13.2177 14.2421 13.3447 14.1953L15.0723 13.5586C15.4756 13.41 15.4756 12.84 15.0723 12.6914L13.3447 12.0547C13.2177 12.0079 13.1171 11.9073 13.0703 11.7803L12.4336 10.0527ZM4.5 5.625C4.08579 5.625 3.75 5.96079 3.75 6.375C3.75 6.78921 4.08579 7.125 4.5 7.125C4.91421 7.125 5.25 6.78921 5.25 6.375C5.25 5.96079 4.91421 5.625 4.5 5.625ZM7.125 5.625C6.71079 5.625 6.375 5.96079 6.375 6.375C6.375 6.78921 6.71079 7.125 7.125 7.125C7.53921 7.125 7.875 6.78921 7.875 6.375C7.875 5.96079 7.53921 5.625 7.125 5.625ZM9.75 5.625C9.33579 5.625 9 5.96079 9 6.375C9 6.78921 9.33579 7.125 9.75 7.125C10.1642 7.125 10.5 6.78921 10.5 6.375C10.5 5.96079 10.1642 5.625 9.75 5.625Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default AppBuilder;\n"
  },
  {
    "path": "packages/icons/src/components/AppV0.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst AppV0 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M9.32129 14.7023V9.16028H11.4639V16.2257C11.4637 17.0576 10.7899 17.7323 9.95801 17.7325C9.56071 17.7325 9.16768 17.5789 8.88574 17.297L0.75 9.16028H3.78027L9.32129 14.7023ZM20.2363 7.0177C21.9007 7.0177 23.25 8.36712 23.25 10.0314V15.589H21.1074V10.6837L16.2021 15.589H21.1074V17.7325H15.5488C13.8848 17.7324 12.5362 16.383 12.5361 14.7189V9.16028H14.6787V14.0822L19.6006 9.16028H14.6787V7.0177H20.2363Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default AppV0;\n"
  },
  {
    "path": "packages/icons/src/components/Apple.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Apple = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 20.94c1.5 0 2.75 1.06 4 1.06 3 0 6-8 6-12.22A4.91 4.91 0 0 0 17 5c-2.22 0-4 1.44-5 2-1-.56-2.78-2-5-2a4.9 4.9 0 0 0-5 4.78C2 14 5 22 8 22c1.25 0 2.5-1.06 4-1.06\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M10 2c1 .5 2 2 2 5\"\n    />\n  </svg>\n);\nexport default Apple;\n"
  },
  {
    "path": "packages/icons/src/components/ArceeAi.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArceeAi = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <rect width={20} height={20} x={2} y={2} fill=\"#3B82F6\" rx={4} />\n    <path fill=\"#fff\" d=\"M8 17 12 7l4 10h-2l-.75-2h-2.5L10 17zm3.25-4h1.5L12 11z\" />\n  </svg>\n);\nexport default ArceeAi;\n"
  },
  {
    "path": "packages/icons/src/components/Array.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Array = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M8.949 22.348v-1.704H6.765V3.41h2.184V1.707H4.82v20.64zm10.008 0V1.707h-4.128V3.41h2.184v17.232h-2.184v1.704z\"\n    />\n  </svg>\n);\nexport default Array;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowDown.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowDown = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 5v14M19 12l-7 7-7-7\"\n    />\n  </svg>\n);\nexport default ArrowDown;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowLeft.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowLeft = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M19 12H5M12 19l-7-7 7-7\"\n    />\n  </svg>\n);\nexport default ArrowLeft;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowRight.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowRight = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M5 12h14M12 5l7 7-7 7\"\n    />\n  </svg>\n);\nexport default ArrowRight;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowUp.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowUp = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 19V5M5 12l7-7 7 7\"\n    />\n  </svg>\n);\nexport default ArrowUp;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowUpDown.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowUpDown = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m11 17-4 4-4-4M7 21V9M21 7l-4-4-4 4M17 15V3\"\n    />\n  </svg>\n);\nexport default ArrowUpDown;\n"
  },
  {
    "path": "packages/icons/src/components/ArrowUpRight.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ArrowUpRight = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M7 17 17 7M7 7h10v10\"\n    />\n  </svg>\n);\nexport default ArrowUpRight;\n"
  },
  {
    "path": "packages/icons/src/components/Audio.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Audio = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M19.734 10.64a.19.19 0 0 0-.187-.187H18.14a.19.19 0 0 0-.188.188 5.953 5.953 0 1 1-11.906 0 .19.19 0 0 0-.188-.188H4.453a.19.19 0 0 0-.187.188 7.735 7.735 0 0 0 6.797 7.678v2.4H7.656c-.321 0-.579.335-.579.75v.843c0 .104.066.188.145.188h9.554c.08 0 .145-.084.145-.187v-.844c0-.415-.258-.75-.579-.75h-3.5V18.33a7.74 7.74 0 0 0 6.891-7.69\"\n    />\n    <path\n      fill=\"currentColor\"\n      d=\"M12 14.625c2.2 0 3.984-1.762 3.984-3.937v-5.25C15.984 3.263 14.201 1.5 12 1.5S8.016 3.263 8.016 5.438v5.25c0 2.175 1.783 3.937 3.984 3.937M9.797 5.438c0-1.186.982-2.157 2.203-2.157s2.203.97 2.203 2.157v5.25c0 1.185-.982 2.156-2.203 2.156s-2.203-.97-2.203-2.156z\"\n    />\n  </svg>\n);\nexport default Audio;\n"
  },
  {
    "path": "packages/icons/src/components/Azure.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Azure = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"#1E88E5\"\n      d=\"M13.26 2.69 5.472 19.262 0 19.2 6.108 8.688l7.152-6.002m.84 1.308L24 21.31H5.69l11.16-1.988-5.844-6.952z\"\n    />\n  </svg>\n);\nexport default Azure;\n"
  },
  {
    "path": "packages/icons/src/components/BarChart2.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst BarChart2 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M18 20V10M12 20V4M6 20v-6\"\n    />\n  </svg>\n);\nexport default BarChart2;\n"
  },
  {
    "path": "packages/icons/src/components/Bell.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Bell = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M18 8A6 6 0 1 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0\"\n    />\n  </svg>\n);\nexport default Bell;\n"
  },
  {
    "path": "packages/icons/src/components/Bfl.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Bfl = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <rect width={20} height={20} x={2} y={2} fill=\"#1A1A1A\" rx={4} />\n    <path\n      fill=\"#fff\"\n      d=\"M6 6h4c2 0 3 1 3 2.5S12 11 10 11H8v2h2c2 0 3 1 3 2.5S12 18 10 18H6zm2 3h2c.5 0 1-.25 1-.75S10.5 7.5 10 7.5H8zm0 5h2c.5 0 1-.25 1-.75s-.5-.75-1-.75H8z\"\n    />\n    <path fill=\"#fff\" d=\"M14 6h2v10h4v2h-6z\" />\n  </svg>\n);\nexport default Bfl;\n"
  },
  {
    "path": "packages/icons/src/components/Boolean.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Boolean = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix_boolean)\">\n      <path\n        fill=\"currentColor\"\n        d=\"M16 4a8 8 0 0 1 0 16H8A8 8 0 0 1 8 4zm0 2H8a6 6 0 0 0-.225 11.996L8 18h8a6 6 0 0 0 .225-11.996zm0 1a5 5 0 1 1 0 10 5 5 0 0 1 0-10\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix_boolean\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default Boolean;\n"
  },
  {
    "path": "packages/icons/src/components/Box.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Box = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M12 1.00195C12.5249 1.00195 13.0401 1.14066 13.4951 1.40234L13.4961 1.40137L20.4961 5.40137L20.5 5.4043C20.9555 5.66734 21.3344 6.04557 21.5977 6.50098C21.8608 6.95635 21.9995 7.4731 22 7.99902V16.001C21.9995 16.5269 21.8608 17.0436 21.5977 17.499C21.3344 17.9545 20.9556 18.3327 20.5 18.5957L20.4961 18.5986L13.4961 22.5986L13.4951 22.5977C13.0712 22.8414 12.5951 22.9767 12.1074 22.9941C12.0721 22.9979 12.0363 23 12 23C11.9634 23 11.9272 22.998 11.8916 22.9941C11.404 22.9765 10.9277 22.8415 10.5039 22.5977V22.5986L3.50391 18.5986L3.5 18.5957C3.04444 18.3327 2.6656 17.9545 2.40234 17.499C2.13918 17.0436 2.00055 16.5269 2 16.001V7.99902L2.00684 7.80176C2.03742 7.34449 2.17219 6.89929 2.40234 6.50098C2.66559 6.04557 3.04448 5.66734 3.5 5.4043L3.50391 5.40137L10.5039 1.40137V1.40234C10.9591 1.14038 11.4748 1.00195 12 1.00195ZM4 15.999L4.00879 16.1299C4.02601 16.2592 4.06815 16.3844 4.13379 16.498C4.22077 16.6485 4.34602 16.7738 4.49609 16.8613L11 20.5771V12.5781L4 8.55566V15.999ZM13 12.5781V20.5771L19.5039 16.8613C19.654 16.7738 19.7792 16.6485 19.8662 16.498C19.9536 16.3466 19.9996 16.1748 20 16V8.55566L13 12.5781ZM12 3.00195C11.8245 3.00195 11.652 3.048 11.5 3.13574L11.4961 3.1377L5.02246 6.83594L12 10.8457L18.9766 6.83594L12.5039 3.1377L12.5 3.13574C12.348 3.048 12.1755 3.00195 12 3.00195Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default Box;\n"
  },
  {
    "path": "packages/icons/src/components/Building2.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Building2 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M6 22V4c0-.27 0-.55.07-.82a1.48 1.48 0 0 1 1.1-1.11C7.46 2 8.73 2 9 2h7c.27 0 .55 0 .82.07a1.48 1.48 0 0 1 1.11 1.1c.07.28.07.56.07.83v18zM2 14v6c0 1.1.9 2 2 2h2V12H4c-.27 0-.55 0-.82.07s-.52.2-.72.4c-.19.19-.32.44-.39.71A3.4 3.4 0 0 0 2 14M20.82 9.07A3.4 3.4 0 0 0 20 9h-2v13h2a2 2 0 0 0 2-2v-9c0-.28 0-.55-.07-.82s-.2-.52-.4-.72c-.19-.19-.44-.32-.71-.39M10 6h4M10 10h4M10 14h4M10 18h4\"\n    />\n  </svg>\n);\nexport default Building2;\n"
  },
  {
    "path": "packages/icons/src/components/Bytedance.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Bytedance = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"#325AB4\"\n      d=\"m16.478 9.38 2.148-1.244v9.29l-2.148 1.244zM5.373 6.53V5.33l2.156-1.248v1.2zM7.537 11.84V6.526l2.156-1.24v5.306zM7.537 18.658v-4.326l2.156-1.243v4.326zM9.7 15.334v-2.49l6.77-3.914v2.492zM12.14 9.677v-3.55l4.323-2.493v3.55z\"\n    />\n  </svg>\n);\nexport default Bytedance;\n"
  },
  {
    "path": "packages/icons/src/components/Calendar.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Calendar = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M16 2v4M8 2v4m-5 4h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2\"\n    />\n  </svg>\n);\nexport default Calendar;\n"
  },
  {
    "path": "packages/icons/src/components/Check.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Check = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M20 6 9 17l-5-5\"\n    />\n  </svg>\n);\nexport default Check;\n"
  },
  {
    "path": "packages/icons/src/components/CheckCircle2.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst CheckCircle2 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10\"\n      clipRule=\"evenodd\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m9 12 2 2 4-4\"\n    />\n  </svg>\n);\nexport default CheckCircle2;\n"
  },
  {
    "path": "packages/icons/src/components/CheckSquare.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst CheckSquare = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m9 11 3 3L22 4m-1 8v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11\"\n    />\n  </svg>\n);\nexport default CheckSquare;\n"
  },
  {
    "path": "packages/icons/src/components/Checked.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Checked = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 16 16\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M12.667 2H3.333C2.593 2 2 2.6 2 3.333v9.334C2 13.4 2.593 14 3.333 14h9.334c.74 0 1.333-.6 1.333-1.333V3.333C14 2.6 13.407 2 12.667 2m-6 9.333L3.333 8l.94-.94 2.394 2.387 5.06-5.06.94.946z\"\n    />\n  </svg>\n);\nexport default Checked;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronDown.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronDown = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m6 9 6 6 6-6\"\n    />\n  </svg>\n);\nexport default ChevronDown;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronLeft.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronLeft = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m15 18-6-6 6-6\"\n    />\n  </svg>\n);\nexport default ChevronLeft;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronRight.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronRight = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m9 18 6-6-6-6\"\n    />\n  </svg>\n);\nexport default ChevronRight;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronUp.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronUp = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m18 15-6-6-6 6\"\n    />\n  </svg>\n);\nexport default ChevronUp;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronsLeft.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronsLeft = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m11 17-5-5 5-5M18 17l-5-5 5-5\"\n    />\n  </svg>\n);\nexport default ChevronsLeft;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronsRight.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronsRight = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m13 17 5-5-5-5M6 17l5-5-5-5\"\n    />\n  </svg>\n);\nexport default ChevronsRight;\n"
  },
  {
    "path": "packages/icons/src/components/ChevronsUpDown.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ChevronsUpDown = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m7 15 5 5 5-5M7 9l5-5 5 5\"\n    />\n  </svg>\n);\nexport default ChevronsUpDown;\n"
  },
  {
    "path": "packages/icons/src/components/Circle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Circle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10\"\n    />\n  </svg>\n);\nexport default Circle;\n"
  },
  {
    "path": "packages/icons/src/components/ClipboardList.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ClipboardList = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M8.00977 15C8.56205 15 9.00977 15.4477 9.00977 16C9.00977 16.5523 8.56205 17 8.00977 17H8C7.44772 17 7 16.5523 7 16C7 15.4477 7.44772 15 8 15H8.00977ZM16 15C16.5523 15 17 15.4477 17 16C17 16.5523 16.5523 17 16 17H12C11.4477 17 11 16.5523 11 16C11 15.4477 11.4477 15 12 15H16ZM8.00977 10C8.56205 10 9.00977 10.4477 9.00977 11C9.00977 11.5523 8.56205 12 8.00977 12H8C7.44772 12 7 11.5523 7 11C7 10.4477 7.44772 10 8 10H8.00977ZM16 10C16.5523 10 17 10.4477 17 11C17 11.5523 16.5523 12 16 12H12C11.4477 12 11 11.5523 11 11C11 10.4477 11.4477 10 12 10H16ZM15 3H9V5H15V3ZM17 5C17 6.10457 16.1046 7 15 7H9C7.89543 7 7 6.10457 7 5H6C5.73478 5 5.4805 5.10543 5.29297 5.29297C5.10543 5.4805 5 5.73478 5 6V20C5 20.2652 5.10543 20.5195 5.29297 20.707C5.48051 20.8946 5.73478 21 6 21H18C18.2652 21 18.5195 20.8946 18.707 20.707C18.8946 20.5195 19 20.2652 19 20V6C19 5.73478 18.8946 5.48051 18.707 5.29297C18.5195 5.10543 18.2652 5 18 5H17ZM18 3C18.7956 3 19.5585 3.3163 20.1211 3.87891C20.6837 4.44152 21 5.20435 21 6V20C21 20.7957 20.6837 21.5585 20.1211 22.1211C19.5585 22.6837 18.7957 23 18 23H6C5.20435 23 4.44152 22.6837 3.87891 22.1211C3.3163 21.5585 3 20.7957 3 20V6C3 5.20435 3.3163 4.44152 3.87891 3.87891C4.44152 3.3163 5.20435 3 6 3H7C7 1.89543 7.89543 1 9 1H15C16.1046 1 17 1.89543 17 3H18Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default ClipboardList;\n"
  },
  {
    "path": "packages/icons/src/components/Clock4.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Clock4 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 6v6l4 2\"\n    />\n  </svg>\n);\nexport default Clock4;\n"
  },
  {
    "path": "packages/icons/src/components/Code.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Code = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m16 18 6-6-6-6M8 6l-6 6 6 6\"\n    />\n  </svg>\n);\nexport default Code;\n"
  },
  {
    "path": "packages/icons/src/components/Code2.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Code2 = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m18 16 4-4-4-4M6 8l-4 4 4 4M14.5 4l-5 16\"\n    />\n  </svg>\n);\nexport default Code2;\n"
  },
  {
    "path": "packages/icons/src/components/CodeReact.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst CodeReact = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M12 10.11C13.03 10.11 13.87 10.95 13.87 12C13.87 13 13.03 13.85 12 13.85C10.97 13.85 10.13 13 10.13 12C10.13 10.95 10.97 10.11 12 10.11ZM7.37 20C8 20.38 9.38 19.8 10.97 18.3C10.45 17.71 9.94 17.07 9.46 16.4C8.65415 16.3228 7.85301 16.2027 7.06 16.04C6.55 18.18 6.74 19.65 7.37 20ZM8.08 14.26L7.79 13.75C7.68 14.04 7.57 14.33 7.5 14.61C7.77 14.67 8.07 14.72 8.38 14.77L8.08 14.26ZM14.62 13.5L15.43 12L14.62 10.5C14.32 9.96997 14 9.49997 13.71 9.02997C13.17 8.99997 12.6 8.99997 12 8.99997C11.4 8.99997 10.83 8.99997 10.29 9.02997C10 9.49997 9.68 9.96997 9.38 10.5L8.57 12L9.38 13.5C9.68 14.03 10 14.5 10.29 14.97C10.83 15 11.4 15 12 15C12.6 15 13.17 15 13.71 14.97C14 14.5 14.32 14.03 14.62 13.5ZM12 6.77997C11.81 6.99997 11.61 7.22997 11.41 7.49997H12.59C12.39 7.22997 12.19 6.99997 12 6.77997ZM12 17.22C12.19 17 12.39 16.77 12.59 16.5H11.41C11.61 16.77 11.81 17 12 17.22ZM16.62 3.99997C16 3.61997 14.62 4.19997 13.03 5.69997C13.55 6.28997 14.06 6.92997 14.54 7.59997C15.36 7.67997 16.17 7.79997 16.94 7.95997C17.45 5.81997 17.26 4.34997 16.62 3.99997ZM15.92 9.73997L16.21 10.25C16.32 9.95997 16.43 9.66997 16.5 9.38997C16.23 9.32997 15.93 9.27997 15.62 9.22997L15.92 9.73997ZM17.37 2.68997C18.84 3.52997 19 5.73997 18.38 8.31997C20.92 9.06997 22.75 10.31 22.75 12C22.75 13.69 20.92 14.93 18.38 15.68C19 18.26 18.84 20.47 17.37 21.31C15.91 22.15 13.92 21.19 12 19.36C10.08 21.19 8.09 22.15 6.62 21.31C5.16 20.47 5 18.26 5.62 15.68C3.08 14.93 1.25 13.69 1.25 12C1.25 10.31 3.08 9.06997 5.62 8.31997C5 5.73997 5.16 3.52997 6.62 2.68997C8.09 1.84997 10.08 2.80997 12 4.63997C13.92 2.80997 15.91 1.84997 17.37 2.68997ZM17.08 12C17.42 12.75 17.72 13.5 17.97 14.26C20.07 13.63 21.25 12.73 21.25 12C21.25 11.27 20.07 10.37 17.97 9.73997C17.72 10.5 17.42 11.25 17.08 12ZM6.92 12C6.58 11.25 6.28 10.5 6.03 9.73997C3.93 10.37 2.75 11.27 2.75 12C2.75 12.73 3.93 13.63 6.03 14.26C6.28 13.5 6.58 12.75 6.92 12ZM15.92 14.26L15.62 14.77C15.93 14.72 16.23 14.67 16.5 14.61C16.43 14.33 16.32 14.04 16.21 13.75L15.92 14.26ZM13.03 18.3C14.62 19.8 16 20.38 16.62 20C17.26 19.65 17.45 18.18 16.94 16.04C16.17 16.2 15.36 16.32 14.54 16.4C14.06 17.07 13.55 17.71 13.03 18.3ZM8.08 9.73997L8.38 9.22997C8.07 9.27997 7.77 9.32997 7.5 9.38997C7.57 9.66997 7.68 9.95997 7.79 10.25L8.08 9.73997ZM10.97 5.69997C9.38 4.19997 8 3.61997 7.37 3.99997C6.74 4.34997 6.55 5.81997 7.06 7.95997C7.85301 7.79726 8.65415 7.67709 9.46 7.59997C9.94 6.92997 10.45 6.28997 10.97 5.69997Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default CodeReact;\n"
  },
  {
    "path": "packages/icons/src/components/Cohere.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Cohere = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__cohere)\">\n      <path\n        fill=\"#39594D\"\n        d=\"M7.775 14.288c.645 0 1.931-.036 3.705-.767 2.07-.852 6.188-2.4 9.158-3.988 2.078-1.11 2.987-2.581 2.987-4.56A4.97 4.97 0 0 0 18.653 0H7.144A7.144 7.144 0 0 0 0 7.144c0 3.944 2.994 7.144 7.775 7.144\"\n      />\n      <path\n        fill=\"#D18EE2\"\n        d=\"M9.72 19.207a4.785 4.785 0 0 1 2.95-4.42l3.626-1.504c3.666-1.52 7.702 1.173 7.702 5.143a5.566 5.566 0 0 1-5.57 5.567h-3.924a4.784 4.784 0 0 1-4.784-4.786\"\n      />\n      <path\n        fill=\"#FF7759\"\n        d=\"M4.118 15.23A4.117 4.117 0 0 0 0 19.348v.533a4.118 4.118 0 0 0 8.236 0v-.533a4.12 4.12 0 0 0-4.118-4.118z\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__cohere\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default Cohere;\n"
  },
  {
    "path": "packages/icons/src/components/Coins.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Coins = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M17.1533 10.0205C17.3464 9.50328 17.9221 9.24071 18.4395 9.43359C19.5422 9.84478 20.5239 10.5271 21.293 11.418C22.062 12.3089 22.5941 13.3793 22.8398 14.5303C23.0856 15.6812 23.0373 16.8757 22.6992 18.0029C22.3611 19.1303 21.7439 20.1547 20.9053 20.9805C20.0666 21.8062 19.0328 22.407 17.9004 22.7275C16.768 23.048 15.5728 23.0781 14.4258 22.8145C13.2788 22.5508 12.2167 22.0016 11.3379 21.2188C10.4591 20.4358 9.79138 19.444 9.39746 18.335C9.21265 17.8146 9.48458 17.2425 10.0049 17.0576C10.5252 16.8728 11.0973 17.1448 11.2822 17.665C11.5636 18.4573 12.0402 19.1663 12.668 19.7256C13.2957 20.2848 14.0547 20.6769 14.874 20.8652C15.6932 21.0535 16.5467 21.0316 17.3555 20.8027C18.1644 20.5738 18.9029 20.1445 19.502 19.5547C20.1009 18.9649 20.5417 18.2338 20.7832 17.4287C21.0247 16.6235 21.0593 15.7704 20.8838 14.9482C20.7082 14.1261 20.3277 13.361 19.7783 12.7246C19.229 12.0883 18.5279 11.6003 17.7402 11.3066C17.223 11.1136 16.9604 10.5379 17.1533 10.0205ZM16.0078 13.168C16.4011 12.7802 17.0341 12.7845 17.4219 13.1777L18.1221 13.8877L17.4092 14.5889L17.4102 14.5898L18.1221 13.8877C18.5078 14.279 18.5056 14.9083 18.1172 15.2969L15.2969 18.1172C14.9064 18.5075 14.2733 18.5075 13.8828 18.1172C13.4923 17.7267 13.4924 17.0937 13.8828 16.7031L16 14.585L15.998 14.582C15.6103 14.1888 15.6146 13.5557 16.0078 13.168ZM13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13C10.7614 13 13 10.7614 13 8ZM7 10V7C6.44772 7 6 6.55228 6 6C6 5.44772 6.44772 5 7 5H8C8.55228 5 9 5.44772 9 6V10C9 10.5523 8.55228 11 8 11C7.44772 11 7 10.5523 7 10ZM15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default Coins;\n"
  },
  {
    "path": "packages/icons/src/components/Component.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Component = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.33}\n      d=\"M5.5 8.5 9 12l-3.5 3.5L2 12zM12 2l3.5 3.5L12 9 8.5 5.5zM18.5 8.5 22 12l-3.5 3.5L15 12zM12 15l3.5 3.5L12 22l-3.5-3.5z\"\n    />\n  </svg>\n);\nexport default Component;\n"
  },
  {
    "path": "packages/icons/src/components/Compose.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Compose = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 16 16\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <g clipPath=\"url(#clip0_287_17968)\">\n      <path\n        d=\"M9.33333 4.66634L11.3333 6.66634M3.33333 3.99967V6.66634M12.6667 9.33301V11.9997M6.66667 1.33301V2.66634M4.66667 5.33301H2M14 10.6663H11.3333M7.33333 1.99967H6M14.4265 2.42637L13.5732 1.57304C13.4982 1.49725 13.4089 1.43708 13.3105 1.39602C13.2121 1.35496 13.1065 1.33382 12.9999 1.33382C12.8932 1.33382 12.7877 1.35496 12.6893 1.39602C12.5908 1.43708 12.5015 1.49725 12.4265 1.57304L1.5732 12.4264C1.49741 12.5014 1.43725 12.5907 1.39619 12.6891C1.35513 12.7875 1.33398 12.8931 1.33398 12.9997C1.33398 13.1063 1.35513 13.2119 1.39619 13.3103C1.43725 13.4087 1.49741 13.498 1.5732 13.573L2.42653 14.4264C2.50108 14.503 2.59022 14.5639 2.6887 14.6054C2.78717 14.647 2.89298 14.6684 2.99987 14.6684C3.10676 14.6684 3.21256 14.647 3.31104 14.6054C3.40951 14.5639 3.49865 14.503 3.5732 14.4264L14.4265 3.57304C14.5031 3.49849 14.564 3.40935 14.6056 3.31087C14.6472 3.2124 14.6686 3.1066 14.6686 2.9997C14.6686 2.89281 14.6472 2.78701 14.6056 2.68853C14.564 2.59006 14.5031 2.50092 14.4265 2.42637Z\"\n        stroke=\"#71717A\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_287_17968\">\n        <rect width=\"16\" height=\"16\" fill=\"white\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default Compose;\n"
  },
  {
    "path": "packages/icons/src/components/Condition.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Condition = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"m20.589 6.429-.001 10.285h2.555l-3.429 3.429-3.428-3.429h2.554V8.143H16.2a4.3 4.3 0 0 0 0-1.715zm-12.875.857q.001.441.086.857h-.943V8.14l-1.698.001v8.572h2.555l-3.428 3.429-3.429-3.429h2.554V6.43L7.8 6.428a4.4 4.4 0 0 0-.086.858M12 3.857a3.429 3.429 0 1 1 0 6.858 3.429 3.429 0 0 1 0-6.858m0 1.714A1.714 1.714 0 1 0 12 9a1.714 1.714 0 0 0 0-3.429\"\n    />\n  </svg>\n);\nexport default Condition;\n"
  },
  {
    "path": "packages/icons/src/components/ConditionalLookup.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ConditionalLookup = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 16 16\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M14.0977 1.33723C14.3215 1.35397 14.5379 1.42697 14.7267 1.55012C14.9424 1.69087 15.1132 1.89079 15.2175 2.1263C15.3219 2.36176 15.3558 2.62263 15.3152 2.87695C15.2745 3.13126 15.1609 3.36867 14.9884 3.55989L14.9877 3.56054L11.7748 7.11392C11.5279 7.38679 11.1065 7.40819 10.8334 7.16145C10.5603 6.91452 10.539 6.4925 10.7859 6.21939L13.9981 2.66666H2.0001L6.8165 7.99217C7.1489 8.35964 7.33321 8.83781 7.33343 9.33332V13.334L8.66676 14.0006C8.99585 14.1653 9.12936 14.5653 8.96494 14.8945C8.80028 15.2238 8.39973 15.3573 8.07041 15.1927L6.73708 14.526C6.51553 14.4153 6.32886 14.2452 6.19867 14.0345C6.08477 13.8501 6.01827 13.641 6.00335 13.4258L6.0001 13.3333V9.33332C5.99995 9.16825 5.93831 9.00912 5.82757 8.8867L1.01052 3.56054V3.55989C0.837757 3.36847 0.724281 3.13094 0.683697 2.87629C0.643154 2.62171 0.676678 2.36059 0.781353 2.12499C0.886057 1.88941 1.05741 1.68936 1.27354 1.54882C1.48963 1.40837 1.74173 1.33346 1.99945 1.33333H14.0014L14.0977 1.33723ZM11.3334 8.66665C12.8061 8.66678 14.0001 9.86064 14.0001 11.3333C14.0001 11.8278 13.8648 12.2905 13.6303 12.6875L14.4714 13.5286C14.7316 13.7889 14.7316 14.211 14.4714 14.4713C14.2111 14.7316 13.7891 14.7316 13.5287 14.4713L12.6876 13.6302C12.2906 13.8648 11.8279 13.9999 11.3334 14C9.86067 14 8.66676 12.8061 8.66676 11.3333C8.66676 9.86056 9.86067 8.66665 11.3334 8.66665ZM11.3334 9.99998C10.597 9.99998 10.0001 10.5969 10.0001 11.3333C10.0001 12.0697 10.597 12.6666 11.3334 12.6666C12.0697 12.6665 12.6668 12.0696 12.6668 11.3333C12.6668 10.597 12.0697 10.0001 11.3334 9.99998Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport default ConditionalLookup;\n"
  },
  {
    "path": "packages/icons/src/components/ConditionalRollup.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst ConditionalRollup = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 16 16\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <g clipPath=\"url(#clip0_54_5791)\">\n      <path\n        d=\"M6.0001 13.3333V9.33333C5.99995 9.16826 5.93832 9.00913 5.82758 8.88671L1.01052 3.56054V3.55989C0.837756 3.36847 0.724282 3.13094 0.683697 2.8763C0.643154 2.62171 0.676677 2.36059 0.781353 2.12499C0.886058 1.88941 1.0574 1.68936 1.27354 1.54882C1.48963 1.40837 1.74173 1.33345 1.99945 1.33333H14.0014L14.0978 1.33723C14.3215 1.35397 14.5379 1.42697 14.7267 1.55013C14.9424 1.69087 15.1132 1.89079 15.2176 2.1263C15.3219 2.36176 15.3558 2.62263 15.3152 2.87695C15.2745 3.13126 15.1609 3.36867 14.9884 3.55989L14.9877 3.56054L12.1609 6.68684C11.914 6.95979 11.4926 6.98117 11.2195 6.73437C10.9464 6.48744 10.9251 6.06543 11.172 5.79231L13.9981 2.66666H2.0001L6.81651 7.99218C7.14891 8.35965 7.33321 8.83782 7.33344 9.33333V13.334L8.66677 14.0006C8.99586 14.1653 9.12936 14.5653 8.96495 14.8945C8.80029 15.2238 8.39973 15.3574 8.07042 15.1927L6.73708 14.526C6.51553 14.4153 6.32887 14.2452 6.19867 14.0345C6.08477 13.8501 6.01827 13.6411 6.00336 13.4258L6.0001 13.3333ZM14.8543 12.2213C15.1737 12.0384 15.5814 12.1493 15.7644 12.4687C15.9473 12.7882 15.8364 13.1953 15.517 13.3783L12.9669 14.8398L12.9662 14.8392C12.7742 14.9495 12.5569 15.0085 12.3354 15.0085C12.1137 15.0084 11.896 14.9497 11.7039 14.8392L9.15375 13.3783C8.83444 13.1952 8.72405 12.7881 8.907 12.4687C9.09 12.1494 9.49708 12.0385 9.81651 12.2213L12.3354 13.6647L14.8543 12.2213ZM10.3705 10.1354L12.3354 11.2611L14.3002 10.1354L12.3354 9.00976L10.3705 10.1354ZM14.8569 10.4551L14.8543 10.4531L14.851 10.4512L14.8569 10.4551ZM16.0047 10.1361C16.0046 10.3069 15.9592 10.4747 15.8731 10.6224C15.7875 10.7693 15.6641 10.8903 15.5164 10.9746L15.517 10.9753L12.9682 12.4349L12.9689 12.4355C12.7763 12.5467 12.5577 12.6048 12.3354 12.6048C12.1131 12.6048 11.8944 12.5467 11.7019 12.4355V12.4349L9.15375 10.9753V10.9746C9.00639 10.8904 8.88376 10.7691 8.79828 10.6224C8.73368 10.5115 8.69201 10.3894 8.67523 10.263L8.66677 10.1361L8.67523 10.0085C8.69203 9.88216 8.73371 9.75989 8.79828 9.64908L8.86924 9.54362C8.94673 9.44365 9.04323 9.35934 9.15375 9.29622L11.7039 7.83528C11.896 7.72478 12.1138 7.6667 12.3354 7.66666C12.5568 7.66666 12.7743 7.72504 12.9662 7.83528H12.9669L15.517 9.29622H15.5164C15.6272 9.35935 15.7239 9.4435 15.8015 9.54362L15.8731 9.64908L15.9298 9.76367C15.9788 9.88122 16.0047 10.0078 16.0047 10.1361Z\"\n        fill=\"currentColor\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_54_5791\">\n        <rect width=\"16\" height=\"16\" fill=\"white\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\n\nexport default ConditionalRollup;\n"
  },
  {
    "path": "packages/icons/src/components/Copy.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Copy = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M20 9h-9a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"\n    />\n  </svg>\n);\nexport default Copy;\n"
  },
  {
    "path": "packages/icons/src/components/CreditCard.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst CreditCard = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M20 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2M2 10h20\"\n    />\n  </svg>\n);\nexport default CreditCard;\n"
  },
  {
    "path": "packages/icons/src/components/Credits.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Credits = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      d=\"M11.624 6.00684C11.8626 5.48539 12.6489 5.71063 12.5752 6.2793L12.0732 10.1504C12.0347 10.4494 12.2678 10.7139 12.5693 10.7139H15.2012C15.5703 10.714 15.8117 11.1006 15.6504 11.4326L12.3896 18.1406C12.1405 18.6532 11.3659 18.4167 11.4453 17.8525L11.9189 14.499C11.9615 14.1982 11.7286 13.929 11.4248 13.9287H8.77832C8.41431 13.9285 8.17276 13.5518 8.32422 13.2207L11.624 6.00684Z\"\n      fill=\"currentColor\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M12 1.00195C12.5249 1.00195 13.0401 1.14066 13.4951 1.40234L13.4961 1.40137L20.4961 5.40137L20.5 5.4043L20.667 5.50781C21.0483 5.76266 21.3673 6.10239 21.5977 6.50098C21.8608 6.95635 21.9995 7.4731 22 7.99902V16.001C21.9995 16.5269 21.8608 17.0436 21.5977 17.499C21.3344 17.9545 20.9556 18.3327 20.5 18.5957L20.4961 18.5986L13.4961 22.5986L13.4951 22.5977C13.0401 22.8593 12.5249 22.998 12 22.998C11.4748 22.998 10.9591 22.8596 10.5039 22.5977L3.50391 18.5986L3.5 18.5957C3.04444 18.3327 2.6656 17.9545 2.40234 17.499C2.13918 17.0436 2.00055 16.5269 2 16.001V7.99902L2.00684 7.80176C2.03742 7.34449 2.17219 6.89929 2.40234 6.50098C2.66559 6.04557 3.04448 5.66734 3.5 5.4043L3.50391 5.40137L10.5039 1.40137C10.9591 1.13941 11.4748 1.00195 12 1.00195ZM12 3.00195C11.8245 3.00195 11.652 3.048 11.5 3.13574L11.4961 3.1377L4.49902 7.13477L4.5 7.13574C4.34813 7.22342 4.22155 7.35013 4.13379 7.50195C4.04623 7.65365 4.00018 7.82582 4 8.00098V15.999L4.00879 16.1299C4.02601 16.2592 4.06815 16.3844 4.13379 16.498C4.22077 16.6485 4.34602 16.7738 4.49609 16.8613L11.4961 20.8613L11.5 20.8643C11.652 20.952 11.8245 20.998 12 20.998C12.1755 20.998 12.348 20.952 12.5 20.8643L12.5039 20.8613L19.5039 16.8613C19.654 16.7738 19.7792 16.6485 19.8662 16.498C19.9536 16.3466 19.9996 16.1748 20 16V8.00098L19.9912 7.87012C19.974 7.74083 19.9318 7.61564 19.8662 7.50195C19.7785 7.35013 19.6519 7.22342 19.5 7.13574L12.5039 3.1377L12.5 3.13574C12.348 3.048 12.1755 3.00195 12 3.00195Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\nexport default Credits;\n"
  },
  {
    "path": "packages/icons/src/components/Cuppy.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\nconst Cuppy = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"72\"\n    height=\"72\"\n    viewBox=\"0 0 72 72\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M34.875 60.7495C44.8151 60.7495 52.8734 61.757 52.875 62.9995C52.875 64.2422 44.8161 65.2495 34.875 65.2495C24.9339 65.2495 16.875 64.2422 16.875 62.9995C16.8766 61.757 24.9349 60.7495 34.875 60.7495ZM48.375 23.6245C52.1028 23.6245 55.1248 26.6468 55.125 30.3745V30.52C56.4319 30.4717 57.7356 30.4516 59.0361 30.4604C61.9167 30.4973 64.6195 33.1521 64.6289 36.2437C64.6328 36.3496 64.6362 36.456 64.6396 36.562C64.6717 37.6991 64.5495 38.8486 64.2734 39.9595C62.994 45.4883 57.5891 49.5572 52.334 49.3735C52.0823 49.3698 51.8328 49.3566 51.585 49.3364C48.1177 54.2018 42.4296 57.3745 36 57.3745H31.5C20.9376 57.3745 12.375 48.812 12.375 38.2495V30.3745C12.3752 26.6468 15.3972 23.6245 19.125 23.6245H48.375ZM19.125 28.1245C17.8825 28.1245 16.8752 29.132 16.875 30.3745V38.2495C16.875 46.3267 23.4228 52.8745 31.5 52.8745H36C44.0772 52.8745 50.625 46.3267 50.625 38.2495V30.3745C50.6248 29.132 49.6175 28.1245 48.375 28.1245H19.125ZM55.125 38.2495C55.125 41.3096 54.4047 44.2013 53.127 46.7661C56.6721 45.2523 59.083 42.2166 59.9062 38.8745C60.0931 38.1214 60.2037 37.348 60.2354 36.562C60.2388 36.456 60.2422 36.3496 60.2461 36.2437C60.3354 35.3479 59.7357 34.3135 58.6445 33.9897C57.4687 33.7335 56.2956 33.4512 55.125 33.146V38.2495ZM27 34.8745C28.8638 34.8745 30.3748 36.3857 30.375 38.2495C30.375 40.1135 28.864 41.6245 27 41.6245C25.136 41.6245 23.625 40.1135 23.625 38.2495C23.6252 36.3857 25.1362 34.8745 27 34.8745ZM40.5 34.8745C42.3638 34.8745 43.8748 36.3857 43.875 38.2495C43.875 40.1135 42.364 41.6245 40.5 41.6245C38.636 41.6245 37.125 40.1135 37.125 38.2495C37.1252 36.3857 38.6362 34.8745 40.5 34.8745ZM29.2246 9.44482C29.4159 9.46108 29.587 9.53302 29.7129 9.65771C29.8386 9.78243 29.9127 9.95305 29.9307 10.144C29.9484 10.3351 29.908 10.5319 29.8486 10.7183C29.7892 10.9112 29.7434 11.0921 29.7051 11.2642C29.5033 12.0635 29.5091 12.7152 29.6592 13.0444C29.799 13.368 29.9519 13.6652 30.2266 14.4663C30.4783 15.2127 30.8465 16.5425 30.4521 17.9067C30.0622 19.2646 29.1233 20.1006 28.2197 20.5698C28.0195 20.6714 27.8152 20.7579 27.5986 20.8433C27.4126 20.9042 27.2168 20.9454 27.0254 20.9292C26.834 20.9129 26.663 20.8411 26.5371 20.7163C26.4114 20.5915 26.3372 20.421 26.3193 20.23C26.3017 20.0389 26.342 19.8421 26.4014 19.6558C26.4606 19.463 26.5048 19.2831 26.543 19.1108C26.7416 18.3126 26.7188 17.6747 26.5596 17.3608C26.4142 17.0513 26.2458 16.7256 25.9609 15.9009C25.6969 15.1315 25.3524 13.7598 25.7793 12.4155C26.1973 11.0803 27.129 10.2693 28.0322 9.80225C28.2311 9.70284 28.4354 9.61548 28.6514 9.53076C28.8374 9.46983 29.0333 9.42861 29.2246 9.44482ZM41.0439 4.9165C41.2408 4.9306 41.419 5.00653 41.5498 5.13721C41.6805 5.26792 41.7563 5.4463 41.7705 5.64307C41.7846 5.84 41.7352 6.03873 41.6592 6.22119C41.5656 6.44724 41.4886 6.65949 41.4209 6.86377C41.0813 7.81496 40.9702 8.64432 41.0879 9.17041C41.1927 9.69082 41.4057 10.1847 41.748 11.1753C42.0667 12.1101 42.5055 13.6438 42.0771 15.2153C41.6551 16.7797 40.5996 17.7885 39.5605 18.4067C39.3312 18.5401 39.097 18.6589 38.8467 18.7769C38.6642 18.853 38.4656 18.9023 38.2686 18.8882C38.0717 18.8741 37.8935 18.7982 37.7627 18.6675C37.632 18.5367 37.5561 18.3575 37.542 18.1606C37.5281 17.964 37.5773 17.7657 37.6533 17.5835C37.7468 17.3574 37.822 17.1458 37.8896 16.9409C38.2259 15.9906 38.3179 15.174 38.1865 14.6626C38.0714 14.1559 37.8388 13.6347 37.4863 12.6206C37.155 11.6628 36.744 10.0842 37.21 8.53369C37.6648 6.99381 38.7142 6.01196 39.7529 5.396C39.9808 5.26497 40.2161 5.14511 40.4658 5.02783C40.6483 4.95173 40.847 4.90239 41.0439 4.9165Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport default Cuppy;\n"
  },
  {
    "path": "packages/icons/src/components/CuppyLoader.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\n\n/**\n * Cuppy Loading Animation\n * Animated version of Cuppy with floating, blinking, steam, and sparkle effects\n *\n * Usage:\n * ```tsx\n * <CuppyLoader />\n * <CuppyLoader className=\"size-12\" />\n * ```\n */\nconst CuppyLoader = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"72\"\n    height=\"72\"\n    viewBox=\"0 0 72 72\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    style={{ overflow: 'visible' }}\n    {...props}\n  >\n    <style>\n      {`\n        @keyframes cuppy-float-rock {\n          0% { transform: translateY(0) rotate(-2deg); }\n          50% { transform: translateY(-4px) rotate(2deg); }\n          100% { transform: translateY(0) rotate(-2deg); }\n        }\n\n        @keyframes cuppy-shadow-scale {\n          0% { transform: scale(1); opacity: 0.15; }\n          50% { transform: scale(0.8); opacity: 0.08; }\n          100% { transform: scale(1); opacity: 0.15; }\n        }\n\n        @keyframes cuppy-blink {\n          0%, 45%, 55%, 100% { transform: scaleY(1); }\n          50% { transform: scaleY(0.1); }\n        }\n\n        @keyframes cuppy-steam-rise {\n          0% { opacity: 0; transform: translateY(3px); }\n          20% { opacity: 1; }\n          80% { opacity: 0.3; }\n          100% { opacity: 0; transform: translateY(-6px); }\n        }\n\n        .cuppy-group {\n          transform-origin: 34px 58px;\n          animation: cuppy-float-rock 3s ease-in-out infinite;\n        }\n\n        .cuppy-shadow {\n          transform-origin: center;\n          transform-box: fill-box;\n          animation: cuppy-shadow-scale 3s ease-in-out infinite;\n        }\n\n        .cuppy-eye {\n          transform-origin: center;\n          transform-box: fill-box;\n          animation: cuppy-blink 4s infinite;\n        }\n        .cuppy-eye.left { animation-delay: 0s; }\n        .cuppy-eye.right { animation-delay: 0.1s; }\n\n        .cuppy-steam {\n          animation: cuppy-steam-rise 2.5s ease-in-out infinite;\n        }\n        .cuppy-steam.steam-1 { animation-delay: 0s; }\n        .cuppy-steam.steam-2 { animation-delay: 0.8s; }\n\n      `}\n    </style>\n\n    {/* Shadow */}\n    <ellipse className=\"cuppy-shadow\" cx=\"34.875\" cy=\"63\" rx=\"18\" ry=\"2.25\" fill=\"currentColor\" />\n\n    {/* Cup Group - animated */}\n    <g className=\"cuppy-group\">\n      {/* Cup body with handle */}\n      <path\n        d=\"M48.375 23.6245C52.1028 23.6245 55.1248 26.6468 55.125 30.3745V30.52C56.4319 30.4717 57.7356 30.4516 59.0361 30.4604C61.9167 30.4973 64.6195 33.1521 64.6289 36.2437C64.6328 36.3496 64.6362 36.456 64.6396 36.562C64.6717 37.6991 64.5495 38.8486 64.2734 39.9595C62.994 45.4883 57.5891 49.5572 52.334 49.3735C52.0823 49.3698 51.8328 49.3566 51.585 49.3364C48.1177 54.2018 42.4296 57.3745 36 57.3745H31.5C20.9376 57.3745 12.375 48.812 12.375 38.2495V30.3745C12.3752 26.6468 15.3972 23.6245 19.125 23.6245H48.375ZM19.125 28.1245C17.8825 28.1245 16.8752 29.132 16.875 30.3745V38.2495C16.875 46.3267 23.4228 52.8745 31.5 52.8745H36C44.0772 52.8745 50.625 46.3267 50.625 38.2495V30.3745C50.6248 29.132 49.6175 28.1245 48.375 28.1245H19.125ZM55.125 38.2495C55.125 41.3096 54.4047 44.2013 53.127 46.7661C56.6721 45.2523 59.083 42.2166 59.9062 38.8745C60.0931 38.1214 60.2037 37.348 60.2354 36.562C60.2388 36.456 60.2422 36.3496 60.2461 36.2437C60.3354 35.3479 59.7357 34.3135 58.6445 33.9897C57.4687 33.7335 56.2956 33.4512 55.125 33.146V38.2495Z\"\n        fill=\"currentColor\"\n      />\n\n      {/* Left Eye */}\n      <ellipse\n        className=\"cuppy-eye left\"\n        cx=\"27\"\n        cy=\"38.25\"\n        rx=\"3.375\"\n        ry=\"3.375\"\n        fill=\"currentColor\"\n      />\n\n      {/* Right Eye */}\n      <ellipse\n        className=\"cuppy-eye right\"\n        cx=\"40.5\"\n        cy=\"38.25\"\n        rx=\"3.375\"\n        ry=\"3.375\"\n        fill=\"currentColor\"\n      />\n\n      {/* Steam 1 */}\n      <path\n        className=\"cuppy-steam steam-1\"\n        d=\"M29.2246 9.44482C29.4159 9.46108 29.587 9.53302 29.7129 9.65771C29.8386 9.78243 29.9127 9.95305 29.9307 10.144C29.9484 10.3351 29.908 10.5319 29.8486 10.7183C29.7892 10.9112 29.7434 11.0921 29.7051 11.2642C29.5033 12.0635 29.5091 12.7152 29.6592 13.0444C29.799 13.368 29.9519 13.6652 30.2266 14.4663C30.4783 15.2127 30.8465 16.5425 30.4521 17.9067C30.0622 19.2646 29.1233 20.1006 28.2197 20.5698C28.0195 20.6714 27.8152 20.7579 27.5986 20.8433C27.4126 20.9042 27.2168 20.9454 27.0254 20.9292C26.834 20.9129 26.663 20.8411 26.5371 20.7163C26.4114 20.5915 26.3372 20.421 26.3193 20.23C26.3017 20.0389 26.342 19.8421 26.4014 19.6558C26.4606 19.463 26.5048 19.2831 26.543 19.1108C26.7416 18.3126 26.7188 17.6747 26.5596 17.3608C26.4142 17.0513 26.2458 16.7256 25.9609 15.9009C25.6969 15.1315 25.3524 13.7598 25.7793 12.4155C26.1973 11.0803 27.129 10.2693 28.0322 9.80225C28.2311 9.70284 28.4354 9.61548 28.6514 9.53076C28.8374 9.46983 29.0333 9.42861 29.2246 9.44482Z\"\n        fill=\"currentColor\"\n      />\n\n      {/* Steam 2 */}\n      <path\n        className=\"cuppy-steam steam-2\"\n        d=\"M41.0439 4.9165C41.2408 4.9306 41.419 5.00653 41.5498 5.13721C41.6805 5.26792 41.7563 5.4463 41.7705 5.64307C41.7846 5.84 41.7352 6.03873 41.6592 6.22119C41.5656 6.44724 41.4886 6.65949 41.4209 6.86377C41.0813 7.81496 40.9702 8.64432 41.0879 9.17041C41.1927 9.69082 41.4057 10.1847 41.748 11.1753C42.0667 12.1101 42.5055 13.6438 42.0771 15.2153C41.6551 16.7797 40.5996 17.7885 39.5605 18.4067C39.3312 18.5401 39.097 18.6589 38.8467 18.7769C38.6642 18.853 38.4656 18.9023 38.2686 18.8882C38.0717 18.8741 37.8935 18.7982 37.7627 18.6675C37.632 18.5367 37.5561 18.3575 37.542 18.1606C37.5281 17.964 37.5773 17.7657 37.6533 17.5835C37.7468 17.3574 37.822 17.1458 37.8896 16.9409C38.2259 15.9906 38.3179 15.174 38.1865 14.6626C38.0714 14.1559 37.8388 13.6347 37.4863 12.6206C37.155 11.6628 36.744 10.0842 37.21 8.53369C37.6648 6.99381 38.7142 6.01196 39.7529 5.396C39.9808 5.26497 40.2161 5.14511 40.4658 5.02783C40.6483 4.95173 40.847 4.90239 41.0439 4.9165Z\"\n        fill=\"currentColor\"\n      />\n    </g>\n  </svg>\n);\n\nexport default CuppyLoader;\n"
  },
  {
    "path": "packages/icons/src/components/Database.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Database = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 8c4.97 0 9-1.343 9-3s-4.03-3-9-3-9 1.343-9 3 4.03 3 9 3M21 12c0 1.66-4 3-9 3s-9-1.34-9-3\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5\"\n    />\n  </svg>\n);\nexport default Database;\n"
  },
  {
    "path": "packages/icons/src/components/DeepThinking.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst DeepThinking = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M2.042 21.764c-1.35-1.349-1.527-3.655-.414-6.546.43-1.097.968-2.151 1.601-3.146L3.273 12l-.044-.072a18.6 18.6 0 0 1-1.604-3.142c-1.11-2.895-.933-5.201.417-6.552.744-.744 1.813-1.143 3.107-1.143 1.894 0 4.21.844 6.59 2.334l.071.044.07-.044c2.376-1.488 4.693-2.334 6.587-2.334 1.294 0 2.363.4 3.105 1.143 1.348 1.35 1.527 3.66.414 6.55a18.5 18.5 0 0 1-1.601 3.142l-.046.074.044.07c.665 1.062 1.21 2.123 1.603 3.144 1.113 2.89.936 5.2-.414 6.55-.742.744-1.811 1.143-3.103 1.143-1.896 0-4.21-.847-6.59-2.335l-.073-.043-.07.043c-2.378 1.49-4.695 2.337-6.59 2.337-1.293 0-2.362-.4-3.104-1.143zm18.122-5.848a15.5 15.5 0 0 0-.942-1.985l-.1-.175-.123.16a29.4 29.4 0 0 1-5.275 5.275l-.16.125.175.098c1.739.975 3.403 1.545 4.728 1.545.748 0 1.337-.184 1.724-.572.4-.4.582-1.03.571-1.79-.01-.763-.212-1.68-.598-2.683zM11.73 18.11l.077.057.078-.057a26.4 26.4 0 0 0 6.05-6.03l.057-.079-.056-.076a26.2 26.2 0 0 0-6.05-6.033l-.08-.055-.076.057a26.4 26.4 0 0 0-6.052 6.03L5.62 12l.057.079a26.3 26.3 0 0 0 6.052 6.032zm8.464-14.494c-.39-.388-.98-.571-1.726-.571-1.327 0-2.99.567-4.728 1.544l-.175.099.16.122a29.2 29.2 0 0 1 5.275 5.275l.122.16.099-.175c.375-.67.698-1.335.944-1.985.386-1 .59-1.916.598-2.68.011-.761-.17-1.39-.57-1.789M3.447 8.085q.404 1.027.945 1.986l.1.173.122-.16A29.2 29.2 0 0 1 9.888 4.81l.161-.122-.174-.099c-1.741-.977-3.404-1.544-4.73-1.544-.749 0-1.338.183-1.724.571-.4.4-.58 1.028-.57 1.79.01.763.212 1.68.596 2.68m0 7.831c-.386 1.002-.587 1.918-.595 2.682-.011.76.168 1.39.567 1.789.39.388.98.572 1.726.572 1.326 0 2.989-.568 4.728-1.545l.174-.098-.16-.125a29.2 29.2 0 0 1-5.273-5.275l-.124-.16-.098.175q-.542.961-.945 1.985\"\n    />\n    <path\n      fill=\"currentColor\"\n      d=\"M12.744 14.267a2.45 2.45 0 0 1-3.04-3.527 2.452 2.452 0 1 1 3.038 3.53z\"\n    />\n  </svg>\n);\nexport default DeepThinking;\n"
  },
  {
    "path": "packages/icons/src/components/Deepseek.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Deepseek = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"#4D6BFE\"\n      d=\"M23.44 4.823c-.245-.123-.351.111-.495.23-.049.039-.09.089-.132.135-.36.392-.778.649-1.327.618-.802-.046-1.486.211-2.091.837-.129-.772-.556-1.232-1.206-1.528-.341-.154-.685-.307-.923-.641-.166-.238-.211-.503-.295-.765-.053-.157-.106-.318-.283-.345-.194-.03-.269.134-.345.272-.302.565-.42 1.187-.408 1.817.026 1.417.613 2.546 1.777 3.348.133.092.167.185.125.32-.08.275-.174.544-.257.82-.053.177-.132.216-.318.139a5.4 5.4 0 0 1-1.68-1.163c-.827-.818-1.576-1.72-2.51-2.427a11 11 0 0 0-.665-.465c-.953-.945.124-1.72.374-1.813.26-.096.09-.426-.753-.422s-1.614.292-2.597.676c-.144.058-.296.1-.45.134a9.1 9.1 0 0 0-2.788-.1c-1.822.208-3.278 1.087-4.348 2.589C.559 8.893.256 10.944.627 13.083c.39 2.254 1.517 4.12 3.249 5.58 1.796 1.512 3.864 2.253 6.224 2.111 1.433-.084 3.029-.28 4.829-1.835.454.23.93.323 1.72.392.61.057 1.195-.031 1.65-.127.71-.154.66-.826.404-.949-2.084-.99-1.626-.587-2.042-.913 1.059-1.28 2.654-2.608 3.278-6.912.05-.342.008-.557 0-.834-.004-.169.034-.234.224-.253a4 4 0 0 0 1.493-.469c1.35-.752 1.895-1.989 2.023-3.471.02-.227-.003-.461-.238-.58m-11.763 13.34c-2.02-1.62-3-2.154-3.404-2.131-.378.023-.31.465-.227.753.087.284.201.48.36.73.11.164.185.41-.11.594-.65.411-1.78-.138-1.834-.165-1.316-.79-2.417-1.835-3.192-3.264a10.1 10.1 0 0 1-1.255-4.423c-.02-.38.09-.515.46-.584a4.4 4.4 0 0 1 1.48-.039c2.06.308 3.816 1.249 5.286 2.738.84.849 1.475 1.863 2.13 2.854.695 1.052 1.444 2.054 2.397 2.876.336.288.605.506.862.668-.775.088-2.069.107-2.953-.607m.968-6.355a.3.3 0 0 1 .4-.284.3.3 0 0 1 .194.284.3.3 0 0 1-.184.28.3.3 0 0 1-.23 0 .3.3 0 0 1-.18-.28m3.006 1.574c-.192.081-.386.15-.571.158-.287.015-.601-.104-.771-.25-.265-.226-.454-.353-.533-.749a1.7 1.7 0 0 1 .014-.58c.069-.322-.007-.53-.23-.718-.181-.153-.412-.195-.666-.195a.53.53 0 0 1-.327-.14.25.25 0 0 1-.06-.193.3.3 0 0 1 .032-.097c.027-.054.155-.185.185-.208.345-.2.742-.134 1.108.016.34.142.597.403.968.771.379.446.447.569.662.903.17.26.325.53.43.837.066.192-.018.35-.241.445\"\n    />\n  </svg>\n);\nexport default Deepseek;\n"
  },
  {
    "path": "packages/icons/src/components/Discord.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Discord = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z\" />\n  </svg>\n);\nexport default Discord;\n"
  },
  {
    "path": "packages/icons/src/components/DivideCircle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst DivideCircle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      clipPath=\"url(#prefix_divide-circle)\"\n    >\n      <path d=\"M8 12h8M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10\" />\n    </g>\n    <defs>\n      <clipPath id=\"prefix_divide-circle\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default DivideCircle;\n"
  },
  {
    "path": "packages/icons/src/components/DivideSquare.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst DivideSquare = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      clipPath=\"url(#prefix_divide-square)\"\n    >\n      <path d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2M8 12h8\" />\n    </g>\n    <defs>\n      <clipPath id=\"prefix_divide-square\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default DivideSquare;\n"
  },
  {
    "path": "packages/icons/src/components/DollarSign.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst DollarSign = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 2v20M17 5H9.5a3.5 3.5 0 1 0 0 7h5a3.5 3.5 0 1 1 0 7H6\"\n    />\n  </svg>\n);\nexport default DollarSign;\n"
  },
  {
    "path": "packages/icons/src/components/Download.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Download = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"\n    />\n  </svg>\n);\nexport default Download;\n"
  },
  {
    "path": "packages/icons/src/components/DraggableHandle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst DraggableHandle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 10 20\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__draggable-handle)\">\n      <path\n        fill=\"#94A3B8\"\n        d=\"M2.143 17.143a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857m5.714 0a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857m-5.714-5.714a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857m5.714 0a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857M2.143 5.714a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857m5.714 0a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857M2.143 0a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857m5.714 0a1.429 1.429 0 1 1 0 2.857 1.429 1.429 0 0 1 0-2.857\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__draggable-handle\">\n        <path fill=\"#fff\" d=\"M0 0h10v20H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default DraggableHandle;\n"
  },
  {
    "path": "packages/icons/src/components/Edit.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Edit = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4z\"\n    />\n  </svg>\n);\nexport default Edit;\n"
  },
  {
    "path": "packages/icons/src/components/Expand.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Expand = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M13.1497 17.4635C13.0118 17.6309 12.8369 17.766 12.6379 17.8589C12.4389 17.9518 12.2209 18 12.0001 18C11.7794 18 11.5614 17.9518 11.3624 17.8589C11.1634 17.766 10.9885 17.6309 10.8506 17.4635L3.32054 8.31899C3.14808 8.10879 3.04018 7.85562 3.00923 7.58858C2.97828 7.32153 3.02554 7.05142 3.14557 6.80927C3.26561 6.56711 3.45356 6.36273 3.68784 6.21958C3.92213 6.07643 4.19324 6.00033 4.47007 6H19.5302C19.8072 6.00038 20.0784 6.07662 20.3127 6.21994C20.5471 6.36326 20.735 6.56786 20.8549 6.81022C20.9748 7.05258 21.0218 7.32286 20.9906 7.59001C20.9594 7.85716 20.8511 8.11033 20.6783 8.32042L13.1497 17.4635Z\"\n    />\n  </svg>\n);\nexport default Expand;\n"
  },
  {
    "path": "packages/icons/src/components/ExpandAll.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst ExpandAll = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M19.9636 6.83274H5.11718C4.5384 6.83274 4.08076 6.36507 4.08076 5.80387V5.02887C4.08076 4.45431 4.55186 4 5.11718 4H19.9636C20.5424 4 21 4.46767 21 5.02887V5.80387C21 6.36507 20.5424 6.83274 19.9636 6.83274ZM13.6912 20.4219C13.6239 20.502 13.5432 20.5822 13.4624 20.649C12.7894 21.1969 11.7933 21.09 11.2549 20.4219L4.34996 11.9771C4.12114 11.6965 4 11.3491 4 10.9883C4 10.1331 4.69992 9.42496 5.57482 9.42496H19.3713C19.7348 9.42496 20.0847 9.54522 20.3674 9.77238C21.0404 10.3202 21.1481 11.2956 20.5962 11.9637L13.6912 20.4219Z\"\n    />\n  </svg>\n);\nexport default ExpandAll;\n"
  },
  {
    "path": "packages/icons/src/components/Export.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Export = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 2v12M16 5l-4-4-4 4M8 8H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-4\"\n    />\n  </svg>\n);\nexport default Export;\n"
  },
  {
    "path": "packages/icons/src/components/Eye.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Eye = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6\"\n    />\n  </svg>\n);\nexport default Eye;\n"
  },
  {
    "path": "packages/icons/src/components/EyeOff.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst EyeOff = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M9.88 9.88a3 3 0 1 0 4.24 4.24M10.73 5.08Q11.362 5.001 12 5c7 0 10 7 10 7a13.2 13.2 0 0 1-1.67 2.68\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M6.61 6.61A13.5 13.5 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61M2 2l20 20\"\n    />\n  </svg>\n);\nexport default EyeOff;\n"
  },
  {
    "path": "packages/icons/src/components/File.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst File = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5z\"\n      clipRule=\"evenodd\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M14 2v6h6\"\n    />\n  </svg>\n);\nexport default File;\n"
  },
  {
    "path": "packages/icons/src/components/FileAudio.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileAudio = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#FCE7F3\" />\n    <g clipPath=\"url(#clip0_221_32614)\">\n      <circle cx=\"60\" cy=\"60\" r=\"36\" fill=\"#EC4899\" />\n      <rect x=\"57.5\" y=\"42\" width=\"5\" height=\"36\" rx=\"2.5\" fill=\"white\" />\n      <rect x=\"67.5\" y=\"48\" width=\"5\" height=\"24\" rx=\"2.5\" fill=\"#FBCFE8\" />\n      <rect x=\"48\" y=\"53\" width=\"5\" height=\"14\" rx=\"2.5\" fill=\"#FBCFE8\" />\n      <rect x=\"77.5\" y=\"54\" width=\"5\" height=\"12\" rx=\"2.5\" fill=\"#F9A8D4\" />\n      <rect x=\"38\" y=\"50\" width=\"5\" height=\"20\" rx=\"2.5\" fill=\"#F9A8D4\" />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_221_32614\">\n        <rect width=\"72\" height=\"72\" fill=\"white\" transform=\"translate(24 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileAudio;\n"
  },
  {
    "path": "packages/icons/src/components/FileCsv.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileCsv = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__file-csv)\">\n      <path\n        fill=\"#D24625\"\n        d=\"M12.544 0h1.63v2.233c2.91.016 5.82-.032 8.747.016a.952.952 0 0 1 1.058 1.058c.045 5.498 0 10.999.026 16.497-.026.563.053 1.188-.264 1.688-.4.29-.913.264-1.379.264H14.18v2.233h-1.71C8.32 23.225 4.16 22.513 0 21.767V2.238C4.18 1.49 8.363.762 12.544 0\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"M14.174 3.07v17.86h8.995V3.07zm4.378 3.394c.125.074.268.11.413.1a.8.8 0 0 0 .394-.084.27.27 0 0 0 .146-.225.33.33 0 0 0-.212-.286 5 5 0 0 0-.442-.148 4 4 0 0 1-.529-.19.5.5 0 0 1-.264-.458.53.53 0 0 1 .264-.471 1.21 1.21 0 0 1 1.228 0 .72.72 0 0 1 .264.547h-.357a.5.5 0 0 0-.174-.301.6.6 0 0 0-.376-.093.7.7 0 0 0-.326.06.24.24 0 0 0-.14.236.27.27 0 0 0 .159.23q.188.084.386.14.308.086.596.223a.55.55 0 0 1 .293.49.57.57 0 0 1-.238.481 1.39 1.39 0 0 1-1.323.013.78.78 0 0 1-.323-.643h.37a.53.53 0 0 0 .191.379m-2.844-.75c-.01-.295.083-.585.265-.82a1 1 0 0 1 .828-.36c.237-.008.47.066.659.21.162.14.264.335.285.548h-.362a.53.53 0 0 0-.212-.328.7.7 0 0 0-.391-.101.61.61 0 0 0-.53.243 1 1 0 0 0-.166.609c-.014.218.044.434.164.616a.74.74 0 0 0 .944.114.7.7 0 0 0 .228-.386h.362a1 1 0 0 1-.354.622.97.97 0 0 1-.63.209 1.02 1.02 0 0 1-.844-.355 1.28 1.28 0 0 1-.246-.82m5.533 1.13h-.448L20 4.58h.402l.621 1.852.622-1.852h.402z\"\n      />\n      <path\n        fill=\"#D24625\"\n        d=\"M15.129 8.578h7.088v2.585h-7.088zm0 3.86h7.088v2.585h-7.088zm0 3.86h7.088v2.585h-7.088z\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"m8.943 13.687-.077.296c-.093.47-.337.895-.696 1.212-.358.25-.789.374-1.225.354-.778 0-1.352-.235-1.709-.698s-.516-1.138-.516-2.032.177-1.548.53-2.014c.351-.465.904-.738 1.658-.738.429-.023.854.084 1.22.307.321.236.542.583.62.974l.078.293h2.117l-.071-.455a3.47 3.47 0 0 0-1.244-2.262 4.13 4.13 0 0 0-2.696-.857c-1.46 0-2.593.497-3.363 1.471-.659.836-1.013 1.942-1.013 3.28 0 1.34.328 2.456.974 3.266.762.97 1.915 1.463 3.44 1.463a4 4 0 0 0 2.555-.857 4.1 4.1 0 0 0 1.452-2.532l.09-.471z\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__file-csv\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileCsv;\n"
  },
  {
    "path": "packages/icons/src/components/FileDocument.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileDocument = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#DBEAFE\" />\n    <g clipPath=\"url(#clip0_221_32461)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#3B82F6\"\n      />\n      <path\n        d=\"M60.2294 63.8389L64.8172 76.6848C65.7348 78.5199 68.4875 78.5199 68.9462 76.6848L75.828 58.3335C76.2867 56.9572 75.828 56.0396 74.4516 55.5808C73.0753 55.122 72.1577 55.5808 71.6989 56.4984L67.1111 69.3443L62.5233 56.4984C61.6057 54.6632 58.853 54.6632 57.9355 56.4984L53.3477 69.3443L48.7599 56.4984C48.3011 55.5808 46.9247 54.6632 45.5484 55.122C44.172 55.5808 43.7133 56.9572 44.172 58.3335L51.0538 76.6848C51.9713 78.5199 54.724 78.5199 55.1828 76.6848L60.2294 63.8389Z\"\n        fill=\"white\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#93C5FD\" />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_221_32461\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileDocument;\n"
  },
  {
    "path": "packages/icons/src/components/FileDocumentDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileDocumentDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#3B82F6\" fillOpacity=\"0.16\" />\n    <g clipPath=\"url(#clip0_222_32965)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#69A2FF\"\n      />\n      <path\n        d=\"M60.2294 63.8389L64.8172 76.6848C65.7348 78.5199 68.4875 78.5199 68.9462 76.6848L75.828 58.3335C76.2867 56.9572 75.828 56.0396 74.4516 55.5808C73.0753 55.122 72.1577 55.5808 71.6989 56.4984L67.1111 69.3443L62.5233 56.4984C61.6057 54.6632 58.853 54.6632 57.9355 56.4984L53.3477 69.3443L48.7599 56.4984C48.3011 55.5808 46.9247 54.6632 45.5484 55.122C44.172 55.5808 43.7133 56.9572 44.172 58.3335L51.0538 76.6848C51.9713 78.5199 54.724 78.5199 55.1828 76.6848L60.2294 63.8389Z\"\n        fill=\"#F5F9FF\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#B4D0FF\" />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_222_32965\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileDocumentDark;\n"
  },
  {
    "path": "packages/icons/src/components/FileExcel.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileExcel = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__file-excel)\">\n      <path fill=\"#21A366\" d=\"M16 1H7c-.6 0-1 .4-1 1v5l10 5 4 1.5 4-1.5V7z\" />\n      <path fill=\"#107C41\" d=\"M6 7h10v5H6z\" />\n      <path fill=\"#33C481\" d=\"M24 2v5h-8V1h7c.5 0 1 .5 1 1\" />\n      <path fill=\"#185C37\" d=\"M16 12H6v10c0 .6.4 1 1 1h16c.6 0 1-.4 1-1v-5z\" />\n      <path\n        fill=\"currentColor\"\n        d=\"M13.8 6H6v14h7.6c.7 0 1.4-.7 1.4-1.4V7.2c0-.7-.5-1.2-1.2-1.2\"\n        opacity={0.5}\n      />\n      <path\n        fill=\"#107C41\"\n        d=\"M12.8 19H1.2C.5 19 0 18.5 0 17.8V6.2C0 5.5.5 5 1.2 5h11.7c.6 0 1.1.5 1.1 1.2v11.7c0 .6-.5 1.1-1.2 1.1\"\n      />\n      <path\n        fill=\"#fff\"\n        d=\"M3.4 16 6 12 3.6 8h1.9l1.3 2.5c.2.3.2.5.3.6l.3-.6L8.8 8h1.8l-2.4 4 2.5 4h-2l-1.5-2.8c0-.1-.1-.2-.2-.4 0 .1-.1.2-.2.4L5.3 16z\"\n      />\n      <path fill=\"#107C41\" d=\"M16 12h8v5h-8z\" />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__file-excel\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileExcel;\n"
  },
  {
    "path": "packages/icons/src/components/FileFont.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileFont = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__file-font)\">\n      <path\n        fill=\"#00B2EA\"\n        fillRule=\"evenodd\"\n        d=\"M2.88 0A2.88 2.88 0 0 0 0 2.88v18.24A2.88 2.88 0 0 0 2.88 24h18.24A2.88 2.88 0 0 0 24 21.12V2.88A2.88 2.88 0 0 0 21.12 0zm10.165 15.555a1.5 1.5 0 0 0-.164-.54l-3.323-6.89a1 1 0 0 0-.227-.301.8.8 0 0 0-.29-.189.8.8 0 0 0-.352-.075.99.99 0 0 0-.881.553l-3.31 6.877q-.165.314-.177.553a.58.58 0 0 0 .126.402q.15.164.478.314.504.24.768.139.277-.088.529-.579l.724-1.52h3.47l.728 1.52q.24.504.516.591.277.1.78-.138.328-.164.466-.327a.53.53 0 0 0 .139-.39m-3.406-2.878-.962-2.012-.958 2.012zm8.405 3.494q.225.164.692.164t.654-.126a.54.54 0 0 0 .24-.364q.05-.226.05-.466v-4.337q0-.251-.05-.477a.54.54 0 0 0-.24-.365q-.188-.138-.642-.138-.477 0-.704.163-.214.15-.202.453l.1.176a1.6 1.6 0 0 0-.34-.34 2 2 0 0 0-.54-.301 2 2 0 0 0-.793-.139 2.5 2.5 0 0 0-1.096.252 3.2 3.2 0 0 0-.931.691 3.3 3.3 0 0 0-.642.993 3.1 3.1 0 0 0-.227 1.17q0 .627.227 1.194.24.565.642 1.018.402.44.919.691.528.252 1.108.252.502 0 .843-.139.34-.15.554-.326.214-.188.29-.315l-.127.214q0 .24.215.402m-.907-1.873a1.1 1.1 0 0 1-.591.164 1.4 1.4 0 0 1-.668-.164 1.4 1.4 0 0 1-.465-.452 1.25 1.25 0 0 1-.164-.629q0-.364.164-.654.176-.288.453-.465.29-.176.654-.176.328 0 .605.176.276.164.44.453.164.276.164.64 0 .354-.164.642-.15.29-.428.465\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__file-font\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileFont;\n"
  },
  {
    "path": "packages/icons/src/components/FileImage.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileImage = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__file-image)\">\n      <path\n        fill=\"#FF3093\"\n        fillRule=\"evenodd\"\n        d=\"M2.88 0A2.88 2.88 0 0 0 0 2.88v18.24A2.88 2.88 0 0 0 2.88 24h18.24A2.88 2.88 0 0 0 24 21.12V2.88A2.88 2.88 0 0 0 21.12 0zm5.931 14.461c.467.624.936 1.249 1.315 1.249.713 0 1.5-1.172 2.306-2.37 1.096-1.632 2.225-3.312 3.24-2.143.638.735 1.296 2.345 1.875 3.762.202.494.394.964.573 1.367v.57c0 .677-.547 1.224-1.224 1.224H7.104a1.224 1.224 0 0 1-1.224-1.224v-.403c.232-.52.468-1.105.682-1.635.21-.522.4-.99.542-1.29.402-.848 1.053.021 1.707.893m.741-6.745a1.835 1.835 0 1 1-3.672 0 1.835 1.835 0 1 1 3.672 0\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__file-image\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileImage;\n"
  },
  {
    "path": "packages/icons/src/components/FileJson.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileJson = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5z\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M14 2v6h6M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1\"\n    />\n  </svg>\n);\nexport default FileJson;\n"
  },
  {
    "path": "packages/icons/src/components/FilePack.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePack = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"120\"\n    height=\"120\"\n    fill=\"none\"\n    viewBox=\"0 0 120 120\"\n    {...props}\n  >\n    <path fill=\"#FEF3C7\" d=\"M0 0h120v120H0z\" />\n    <rect width=\"64\" height=\"64\" x=\"28\" y=\"28\" fill=\"#FBBF24\" rx=\"6\" />\n    <path fill=\"#F59E0B\" d=\"M67 28h14v30a7 7 0 1 1-14 0z\" />\n    <path\n      fill=\"#fff\"\n      d=\"M74 28a1 1 0 0 1 1 1v1h2a1 1 0 1 1 0 2h-2v2h2a1 1 0 1 1 0 2h-2v2h2a1 1 0 1 1 0 2h-2v2h2a1 1 0 1 1 0 2h-2v2h2a1 1 0 1 1 0 2h-2v2h2a1 1 0 1 1 0 2h-2v2q0 .063-.009.124A4.001 4.001 0 0 1 74 62a4 4 0 0 1-.992-7.876l-.003-.022L73 54h-2a1 1 0 1 1 0-2h2v-2h-2a1 1 0 1 1 0-2h2v-2h-2a1 1 0 1 1 0-2h2v-2h-2a1 1 0 1 1 0-2h2v-2h-2a1 1 0 1 1 0-2h2v-2h-2a1 1 0 1 1 0-2h2v-3a1 1 0 0 1 1-1m0 28a2 2 0 1 0 0 4 2 2 0 0 0 0-4\"\n    />\n  </svg>\n);\nexport default FilePack;\n"
  },
  {
    "path": "packages/icons/src/components/FilePackDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePackDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#FFE08F\" fillOpacity=\"0.16\" />\n    <rect x=\"28\" y=\"28\" width=\"64\" height=\"64\" rx=\"6\" fill=\"#FFD360\" />\n    <path d=\"M67 28H81V58C81 61.866 77.866 65 74 65C70.134 65 67 61.866 67 58V28Z\" fill=\"#E29A00\" />\n    <path\n      d=\"M74 28C74.5523 28 75 28.4477 75 29V30H77C77.5523 30 78 30.4477 78 31C78 31.5523 77.5523 32 77 32H75V34H77C77.5523 34 78 34.4477 78 35C78 35.5523 77.5523 36 77 36H75V38H77C77.5523 38 78 38.4477 78 39C78 39.5523 77.5523 40 77 40H75V42H77C77.5523 42 78 42.4477 78 43C78 43.5523 77.5523 44 77 44H75V46H77C77.5523 46 78 46.4477 78 47C78 47.5523 77.5523 48 77 48H75V50H77C77.5523 50 78 50.4477 78 51C78 51.5523 77.5523 52 77 52H75V54C75 54.042 74.9962 54.0833 74.9912 54.124C76.7209 54.565 78 56.133 78 58C78 60.2091 76.2091 62 74 62C71.7909 62 70 60.2091 70 58C70 56.1333 71.2786 54.5653 73.0078 54.124C73.0069 54.1169 73.0056 54.1097 73.0049 54.1025L73 54H71C70.4477 54 70 53.5523 70 53C70 52.4477 70.4477 52 71 52H73V50H71C70.4477 50 70 49.5523 70 49C70 48.4477 70.4477 48 71 48H73V46H71C70.4477 46 70 45.5523 70 45C70 44.4477 70.4477 44 71 44H73V42H71C70.4477 42 70 41.5523 70 41C70 40.4477 70.4477 40 71 40H73V38H71C70.4477 38 70 37.5523 70 37C70 36.4477 70.4477 36 71 36H73V34H71C70.4477 34 70 33.5523 70 33C70 32.4477 70.4477 32 71 32H73V29C73 28.4477 73.4477 28 74 28ZM74 56C72.8954 56 72 56.8954 72 58C72 59.1046 72.8954 60 74 60C75.1046 60 76 59.1046 76 58C76 56.8954 75.1046 56 74 56Z\"\n      fill=\"#FFF9E9\"\n    />\n  </svg>\n);\nexport default FilePackDark;\n"
  },
  {
    "path": "packages/icons/src/components/FilePdf.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePdf = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#FEE2E2\" />\n    <g clipPath=\"url(#clip0_221_32446)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#EF4444\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#FCA5A5\" />\n      <path\n        d=\"M71.5556 69.9111C69.1556 69.7333 66.8444 68.8444 64.9778 67.2444C61.3333 68.0444 57.8667 69.2 54.4 70.6222C51.6444 75.5111 49.0667 78 46.8444 78C46.4 78 45.8667 77.9111 45.5111 77.6444C44.5333 77.2 44 76.2222 44 75.2444C44 74.4444 44.1778 72.2222 52.6222 68.5778C54.5778 65.0222 56.0889 61.3778 57.3333 57.5556C56.2667 55.4222 53.9556 50.1778 55.5556 47.5111C56.0889 46.5333 57.1556 46 58.3111 46.0889C59.2 46.0889 60.0889 46.5333 60.6222 47.2444C61.7778 48.8444 61.6889 52.2222 60.1778 57.2C61.6 59.8667 63.4667 62.2667 65.6889 64.3111C67.5556 63.9556 69.4222 63.6889 71.2889 63.6889C75.4667 63.7778 76.0889 65.7333 76 66.8889C76 69.9111 73.0667 69.9111 71.5556 69.9111ZM46.6667 75.4222L46.9333 75.3333C48.1778 74.8889 49.1556 74 49.8667 72.8444C48.5333 73.3778 47.4667 74.2667 46.6667 75.4222ZM58.4889 48.7556H58.2222C58.1333 48.7556 57.9556 48.7556 57.8667 48.8444C57.5111 50.3556 57.7778 51.9556 58.4 53.3778C58.9333 51.8667 58.9333 50.2667 58.4889 48.7556ZM59.1111 61.6444L59.0222 61.8222L58.9333 61.7333C58.1333 63.7778 57.2444 65.8222 56.2667 67.7778L56.4444 67.6889V67.8667C58.4 67.1556 60.5333 66.5333 62.4889 66.0889L62.4 66H62.6667C61.3333 64.6667 60.0889 63.1556 59.1111 61.6444ZM71.2 66.3556C70.4 66.3556 69.6889 66.3556 68.8889 66.5333C69.7778 66.9778 70.6667 67.1556 71.5556 67.2444C72.1778 67.3333 72.8 67.2444 73.3333 67.0667C73.3333 66.8 72.9778 66.3556 71.2 66.3556Z\"\n        fill=\"white\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_221_32446\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FilePdf;\n"
  },
  {
    "path": "packages/icons/src/components/FilePdfDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePdfDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#EF4444\" fillOpacity=\"0.16\" />\n    <g clipPath=\"url(#clip0_222_32959)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#F87171\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#FFACAC\" />\n      <path\n        d=\"M71.5556 69.9111C69.1556 69.7333 66.8444 68.8444 64.9778 67.2444C61.3333 68.0444 57.8667 69.2 54.4 70.6222C51.6444 75.5111 49.0667 78 46.8444 78C46.4 78 45.8667 77.9111 45.5111 77.6444C44.5333 77.2 44 76.2222 44 75.2444C44 74.4444 44.1778 72.2222 52.6222 68.5778C54.5778 65.0222 56.0889 61.3778 57.3333 57.5555C56.2667 55.4222 53.9556 50.1778 55.5556 47.5111C56.0889 46.5333 57.1556 46 58.3111 46.0889C59.2 46.0889 60.0889 46.5333 60.6222 47.2444C61.7778 48.8444 61.6889 52.2222 60.1778 57.2C61.6 59.8667 63.4667 62.2667 65.6889 64.3111C67.5556 63.9555 69.4222 63.6889 71.2889 63.6889C75.4667 63.7778 76.0889 65.7333 76 66.8889C76 69.9111 73.0667 69.9111 71.5556 69.9111ZM46.6667 75.4222L46.9333 75.3333C48.1778 74.8889 49.1556 74 49.8667 72.8444C48.5333 73.3778 47.4667 74.2667 46.6667 75.4222ZM58.4889 48.7555H58.2222C58.1333 48.7555 57.9556 48.7555 57.8667 48.8444C57.5111 50.3555 57.7778 51.9555 58.4 53.3778C58.9333 51.8667 58.9333 50.2667 58.4889 48.7555ZM59.1111 61.6444L59.0222 61.8222L58.9333 61.7333C58.1333 63.7778 57.2444 65.8222 56.2667 67.7778L56.4444 67.6889V67.8667C58.4 67.1555 60.5333 66.5333 62.4889 66.0889L62.4 66H62.6667C61.3333 64.6667 60.0889 63.1555 59.1111 61.6444ZM71.2 66.3555C70.4 66.3555 69.6889 66.3555 68.8889 66.5333C69.7778 66.9778 70.6667 67.1555 71.5556 67.2444C72.1778 67.3333 72.8 67.2444 73.3333 67.0667C73.3333 66.8 72.9778 66.3555 71.2 66.3555Z\"\n        fill=\"#FEE2E2\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_222_32959\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FilePdfDark;\n"
  },
  {
    "path": "packages/icons/src/components/FilePresentation.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePresentation = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    fill=\"none\"\n    viewBox=\"0 0 120 120\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path fill=\"#FFEDD5\" d=\"M0 0h120v120H0z\" />\n    <g clipPath=\"url(#a)\">\n      <path\n        fill=\"#F97316\"\n        d=\"M32 30a6 6 0 0 1 6-6h34l16 16v50a6 6 0 0 1-6 6H38a6 6 0 0 1-6-6V30Z\"\n      />\n      <path fill=\"#FDBA74\" d=\"m72 24 16 16H75a3 3 0 0 1-3-3V24Z\" />\n      <path\n        fill=\"#fff\"\n        fillRule=\"evenodd\"\n        d=\"M62 49.5a9.5 9.5 0 0 1 0 19h-8.5V80a2.5 2.5 0 0 1-5 0V52.107a2.607 2.607 0 0 1 2.607-2.607H62Zm-8.5 14H62a4.5 4.5 0 1 0 0-9h-8.5v9Z\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"a\">\n        <path fill=\"#fff\" d=\"M32 24h56v72H32z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FilePresentation;\n"
  },
  {
    "path": "packages/icons/src/components/FilePresentationDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FilePresentationDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"120\"\n    height=\"120\"\n    fill=\"none\"\n    viewBox=\"0 0 120 120\"\n    {...props}\n  >\n    <path fill=\"#FFAF78\" fillOpacity=\".16\" d=\"M0 0h120v120H0z\" />\n    <g clipPath=\"url(#a)\">\n      <path\n        fill=\"#FF8C3D\"\n        d=\"M32 30a6 6 0 0 1 6-6h34l16 16v50a6 6 0 0 1-6 6H38a6 6 0 0 1-6-6V30Z\"\n      />\n      <path fill=\"#FFBA8B\" d=\"m72 24 16 16H75a3 3 0 0 1-3-3V24Z\" />\n      <path\n        fill=\"#FFF4ED\"\n        fillRule=\"evenodd\"\n        d=\"M62 49.5a9.5 9.5 0 0 1 0 19h-8.5V80a2.5 2.5 0 0 1-5 0V52.107a2.607 2.607 0 0 1 2.607-2.607H62Zm-8.5 14H62a4.5 4.5 0 1 0 0-9h-8.5v9Z\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"a\">\n        <path fill=\"#fff\" d=\"M32 24h56v72H32z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FilePresentationDark;\n"
  },
  {
    "path": "packages/icons/src/components/FileQuestion.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileQuestion = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5z\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M10 10.3c.2-.4.5-.8.9-1a2.1 2.1 0 0 1 2.6.4c.3.4.5.8.5 1.3 0 1.3-2 2-2 2M12 17h.01\"\n    />\n  </svg>\n);\nexport default FileQuestion;\n"
  },
  {
    "path": "packages/icons/src/components/FileScript.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileScript = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__file-script)\">\n      <path\n        fill=\"#121219\"\n        fillRule=\"evenodd\"\n        d=\"M2.88 0A2.88 2.88 0 0 0 0 2.88v18.24A2.88 2.88 0 0 0 2.88 24h18.24A2.88 2.88 0 0 0 24 21.12V2.88A2.88 2.88 0 0 0 21.12 0zm16.475 13.054c.287-.284.445-.667.445-1.07 0-.401-.16-.787-.447-1.073l-3.027-3.028a1.513 1.513 0 1 0-2.14 2.141l1.958 1.959-1.956 1.958a1.514 1.514 0 1 0 2.14 2.14zM7.671 16.08c.281.287.667.445 1.069.445s.785-.158 1.071-.442a1.513 1.513 0 0 0 0-2.14l-1.958-1.96 1.958-1.96a1.513 1.513 0 1 0-2.14-2.14l-3.03 3.03a1.515 1.515 0 0 0 .003 2.14z\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__file-script\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileScript;\n"
  },
  {
    "path": "packages/icons/src/components/FileSpreadsheet.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileSpreadsheet = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#D1FAE5\" />\n    <g clipPath=\"url(#clip0_221_32413)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#10B981\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#6EE7B7\" />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M73 53C75.4853 53 77.5 55.0147 77.5 57.5V75.5C77.5 77.9853 75.4853 80 73 80H47C44.5147 80 42.5 77.9853 42.5 75.5V57.5C42.5 55.0147 44.5147 53 47 53H73ZM45.5 75.5C45.5 76.3284 46.1716 77 47 77H50.5V72H45.5V75.5ZM53.5 77H58.5V72H53.5V77ZM61.5 77H66.5V72H61.5V77ZM69.5 77H73C73.8284 77 74.5 76.3284 74.5 75.5V72H69.5V77ZM45.5 69H50.5V64H45.5V69ZM53.5 69H58.5V64H53.5V69ZM61.5 69H66.5V64H61.5V69ZM69.5 69H74.5V64H69.5V69ZM47 56C46.1716 56 45.5 56.6716 45.5 57.5V61H74.5V57.5C74.5 56.6716 73.8284 56 73 56H47Z\"\n        fill=\"white\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_221_32413\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileSpreadsheet;\n"
  },
  {
    "path": "packages/icons/src/components/FileSpreadsheetDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileSpreadsheetDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#51F9C1\" fillOpacity=\"0.16\" />\n    <g clipPath=\"url(#clip0_222_32971)\">\n      <path\n        d=\"M32 30C32 26.6863 34.6863 24 38 24H72L88 40V90C88 93.3137 85.3137 96 82 96H38C34.6863 96 32 93.3137 32 90V30Z\"\n        fill=\"#5DB07E\"\n      />\n      <path d=\"M72 24L88 40H75C73.3431 40 72 38.6569 72 37V24Z\" fill=\"#A5E7D1\" />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M73 53C75.4853 53 77.5 55.0147 77.5 57.5V75.5C77.5 77.9853 75.4853 80 73 80H47C44.5147 80 42.5 77.9853 42.5 75.5V57.5C42.5 55.0147 44.5147 53 47 53H73ZM45.5 75.5C45.5 76.3284 46.1716 77 47 77H50.5V72H45.5V75.5ZM53.5 77H58.5V72H53.5V77ZM61.5 77H66.5V72H61.5V77ZM69.5 77H73C73.8284 77 74.5 76.3284 74.5 75.5V72H69.5V77ZM45.5 69H50.5V64H45.5V69ZM53.5 69H58.5V64H53.5V69ZM61.5 69H66.5V64H61.5V69ZM69.5 69H74.5V64H69.5V69ZM47 56C46.1716 56 45.5 56.6716 45.5 57.5V61H74.5V57.5C74.5 56.6716 73.8284 56 73 56H47Z\"\n        fill=\"#E5FFF6\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_222_32971\">\n        <rect width=\"56\" height=\"72\" fill=\"white\" transform=\"translate(32 24)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileSpreadsheetDark;\n"
  },
  {
    "path": "packages/icons/src/components/FileText.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileText = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__a)\">\n      <path\n        fill=\"#6F8BB5\"\n        fillRule=\"evenodd\"\n        d=\"M2.88 0A2.88 2.88 0 0 0 0 2.88v18.24A2.88 2.88 0 0 0 2.88 24h18.24A2.88 2.88 0 0 0 24 21.12V2.88A2.88 2.88 0 0 0 21.12 0zm12.684 7.48a2 2 0 0 0-.41-.04h-6.27q-.21 0-.408.04a.48.48 0 0 0-.317.224q-.12.171-.119.594 0 .423.119.607.119.172.317.225.198.04.409.04h2.151v6.52q0 .25.04.488.04.225.25.37.212.132.687.132.489 0 .687-.132a.56.56 0 0 0 .25-.37 2 2 0 0 0 .053-.475V9.17h2.125q.224 0 .423-.04a.48.48 0 0 0 .317-.224Q16 8.721 16 8.311q0-.435-.132-.607a.48.48 0 0 0-.304-.224\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__a\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileText;\n"
  },
  {
    "path": "packages/icons/src/components/FileUnknown.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileUnknown = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"120\"\n    height=\"120\"\n    fill=\"none\"\n    viewBox=\"0 0 120 120\"\n    {...props}\n  >\n    <path fill=\"#F4F4F5\" d=\"M0 0h120v120H0z\" />\n    <g clipPath=\"url(#a)\">\n      <path\n        fill=\"#A1A1AA\"\n        d=\"M32 30a6 6 0 0 1 6-6h34l16 16v50a6 6 0 0 1-6 6H38a6 6 0 0 1-6-6V30Z\"\n      />\n      <path\n        fill=\"#E4E4E7\"\n        d=\"m72 24 16 16H75a3 3 0 0 1-3-3V24Zm4.5 58a2.5 2.5 0 0 1 0 5h-33a2.5 2.5 0 0 1 0-5h33Zm-16-9a2.5 2.5 0 0 1 0 5h-17a2.5 2.5 0 0 1 0-5h17Z\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"a\">\n        <path fill=\"#fff\" d=\"M32 24h56v72H32z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default FileUnknown;\n"
  },
  {
    "path": "packages/icons/src/components/FileVideoDark.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FileVideoDark = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    width=\"120\"\n    height=\"120\"\n    viewBox=\"0 0 120 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <rect width=\"120\" height=\"120\" fill=\"#A855F7\" fillOpacity=\"0.16\" />\n    <rect x=\"23\" y=\"32\" width=\"72\" height=\"56\" rx=\"6\" fill=\"#9333EA\" />\n    <path\n      d=\"M70.5 57.4019C72.5 58.5566 72.5 61.4434 70.5 62.5981L55.5 71.2583C53.5 72.413 51 70.9697 51 68.6603L51 51.3397C51 49.0303 53.5 47.587 55.5 48.7417L70.5 57.4019Z\"\n      fill=\"#E9D5FF\"\n    />\n    <path\n      d=\"M33 78C34.1046 78 35 78.8954 35 80C35 81.1046 34.1046 82 33 82H29C27.8954 82 27 81.1046 27 80C27 78.8954 27.8954 78 29 78H33ZM33 68C34.1046 68 35 68.8954 35 70C35 71.1046 34.1046 72 33 72H29C27.8954 72 27 71.1046 27 70C27 68.8954 27.8954 68 29 68H33ZM33 58C34.1046 58 35 58.8954 35 60C35 61.1046 34.1046 62 33 62H29C27.8954 62 27 61.1046 27 60C27 58.8954 27.8954 58 29 58H33ZM33 48C34.1046 48 35 48.8954 35 50C35 51.1046 34.1046 52 33 52H29C27.8954 52 27 51.1046 27 50C27 48.8954 27.8954 48 29 48H33ZM33 38C34.1046 38 35 38.8954 35 40C35 41.1046 34.1046 42 33 42H29C27.8954 42 27 41.1046 27 40C27 38.8954 27.8954 38 29 38H33Z\"\n      fill=\"#C084FC\"\n    />\n    <path\n      d=\"M89 78C90.1046 78 91 78.8954 91 80C91 81.1046 90.1046 82 89 82H85C83.8954 82 83 81.1046 83 80C83 78.8954 83.8954 78 85 78H89ZM89 68C90.1046 68 91 68.8954 91 70C91 71.1046 90.1046 72 89 72H85C83.8954 72 83 71.1046 83 70C83 68.8954 83.8954 68 85 68H89ZM89 58C90.1046 58 91 58.8954 91 60C91 61.1046 90.1046 62 89 62H85C83.8954 62 83 61.1046 83 60C83 58.8954 83.8954 58 85 58H89ZM89 48C90.1046 48 91 48.8954 91 50C91 51.1046 90.1046 52 89 52H85C83.8954 52 83 51.1046 83 50C83 48.8954 83.8954 48 85 48H89ZM89 38C90.1046 38 91 38.8954 91 40C91 41.1046 90.1046 42 89 42H85C83.8954 42 83 41.1046 83 40C83 38.8954 83.8954 38 85 38H89Z\"\n      fill=\"#C084FC\"\n    />\n  </svg>\n);\nexport default FileVideoDark;\n"
  },
  {
    "path": "packages/icons/src/components/Filter.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Filter = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M22 3H2l8 9.46V19l4 2v-8.54z\"\n    />\n  </svg>\n);\nexport default Filter;\n"
  },
  {
    "path": "packages/icons/src/components/FreezeColumn.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst FreezeColumn = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      fill=\"#fff\"\n      fillOpacity={0.01}\n      d=\"M0 1a1 1 0 0 1 1-1h22a1 1 0 0 1 1 1v22a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1z\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.8}\n      d=\"M21 4a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1z\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeWidth={1.8}\n      d=\"M8.062 3 3 7.5M9.5 6 3 12M9.5 10.5l-6.5 6M9.5 15 4 20M9.5 3v18\"\n    />\n  </svg>\n);\nexport default FreezeColumn;\n"
  },
  {
    "path": "packages/icons/src/components/Gauge.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Gauge = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"m12 15 3.5-3.5M20.3 18c.4-1 .7-2.2.7-3.4C21 9.8 17 6 12 6s-9 3.8-9 8.6c0 1.2.3 2.4.7 3.4\"\n    />\n  </svg>\n);\nexport default Gauge;\n"
  },
  {
    "path": "packages/icons/src/components/GiftPerson.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst GiftPerson = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width={66}\n    height={80}\n    fill=\"none\"\n    viewBox=\"0 0 66 80\"\n    {...props}\n  >\n    <g clipPath=\"url(#clip0_614_18814)\">\n      <path\n        d=\"M44.9034 1.42264C45.9356 1.63515 46.9779 2.05003 47.5344 2.44469L47.9392 2.73817L48.5666 2.58638C49.9732 2.25243 51.9972 2.49529 53.596 3.18343C55.4985 4.01322 57.4415 5.66271 58.8987 7.69674C59.6374 8.73905 61.0946 11.5725 61.6107 12.9589C63.584 18.3526 64.7579 24.313 64.6061 28.1888C64.5251 30.2228 64.2317 31.4371 63.4525 32.9247C63.0173 33.7545 61.8637 35.0296 61.0541 35.5659C60.5178 35.9201 59.4552 36.335 59.0707 36.335C58.7874 36.335 58.8076 36.1225 59.2124 35.1713C59.4047 34.726 59.5868 34.1998 59.6273 34.0075L59.688 33.6533L59.1719 34.2403C58.1094 35.4141 56.7027 36.2743 55.2152 36.6487C54.527 36.8309 54.183 36.8511 53.2621 36.8106C52.0984 36.76 51.2382 36.5475 51.137 36.2743C51.1066 36.1933 51.2281 35.9606 51.4102 35.7582C51.6632 35.4748 51.6834 35.404 51.5317 35.4647C51.0156 35.657 49.6595 35.9302 49.1738 35.9302C48.5464 35.9302 48.516 36.0213 48.9512 36.6183C49.3054 37.1142 49.6191 37.3267 49.9733 37.3267C50.3376 37.3166 51.1168 37.5898 52.4323 38.1666C54.5574 39.1078 56.2777 40.1905 58.0385 41.6984C61.2666 44.4711 63.2906 48.7213 64.3227 54.9043C64.6972 57.1408 64.8996 58.9521 65.2841 63.5565C65.5168 66.3293 65.6484 67.9585 65.9621 72.2593C66.1038 74.1314 66.1443 79.6466 66.0228 80.0817C65.9317 80.4258 65.5776 80.4561 65.5067 80.1323C65.446 79.8692 65.2234 76.6714 64.9805 72.7653C64.6466 67.2805 63.9584 59.4885 63.5638 56.7259C62.6935 50.5631 61.3071 46.8492 58.7671 43.8437C57.3099 42.1234 54.6586 40.1905 51.9972 38.9155C51.1269 38.4905 50.3578 38.116 50.2768 38.0756C50.1756 38.0148 49.9935 38.1363 49.6899 38.4399C49.0827 39.0369 47.494 39.7959 46.1177 40.1298C44.1849 40.5852 41.9181 40.6459 40.7847 40.2513C39.7323 39.887 38.8316 39.0976 38.6293 38.369L38.5179 37.9642L37.7893 38.2172C36.6458 38.622 34.7231 39.5935 33.6707 40.2917C32.0718 41.3543 29.8354 43.7223 28.8842 45.3313L28.621 45.7866L29.4002 46.6063C29.9872 47.2236 30.3211 47.6891 30.7259 48.4886C31.3932 49.7734 31.3939 49.774 32.4552 50.7193C32.4722 50.7345 32.4894 50.7498 32.5069 50.7654C33.8225 51.9191 33.6808 51.8584 34.0856 51.5548C34.5309 51.2208 35.0166 51.2714 36.1297 51.7976C37.4959 52.4352 41.3514 54.8537 45.4802 57.6366L47.3219 58.8914L47.494 58.3652C48.1922 56.23 50.459 51.1095 50.8334 50.7958C51.0459 50.6238 51.1977 50.8565 51.1066 51.2107C50.965 51.8381 48.9107 57.1306 48.2023 58.7093L47.9493 59.2861L48.9006 60.0046C49.4167 60.4094 49.9834 60.885 50.1554 61.0874C50.5096 61.482 50.5804 61.8868 50.287 61.816C50.1959 61.7856 48.678 60.8141 46.9273 59.6605C43.2842 57.2521 42.0901 56.4729 40.805 55.6633C40.299 55.3496 39.196 54.6412 38.3358 54.0948C36.656 53.0423 34.8749 51.9899 34.8344 52.0405C34.8243 52.0607 34.5814 52.5566 34.288 53.1537C33.2861 55.2079 31.323 57.8188 30.1187 58.689L29.7544 58.9521L31.0194 60.4195C36.5649 66.8656 40.7847 70.792 44.5492 73.0183C47.8077 74.941 50.1858 75.5482 52.321 74.9916C53.2419 74.7589 54.3651 74.2225 55.0937 73.6558C56.4194 72.6439 57.998 70.6604 59.1517 68.5657C59.6981 67.5639 61.0744 64.5685 61.7018 63.0202C61.9447 62.413 62.1369 62.2309 62.3292 62.4231C62.6024 62.6964 61.722 65.3173 60.4976 67.8573C58.0486 72.9272 55.367 75.4976 51.9263 76.0643C50.8031 76.2464 49.9834 76.2059 48.6881 75.9024C45.045 75.0523 40.2686 71.7432 35.1886 66.5317C32.4968 63.7792 29.7544 60.4397 29.2485 59.3165C29.0258 58.8004 29.0056 58.6992 29.1169 58.2337C29.1776 57.9503 29.4104 57.3533 29.633 56.9181L30.0479 56.1187L29.1877 55.299C28.1454 54.3073 27.599 54.0239 26.1822 53.7406C25.5953 53.6293 25.0286 53.518 24.9173 53.4876C24.7453 53.4572 24.6744 53.5483 24.472 54.0745C24.3405 54.4186 23.9357 55.2788 23.5916 55.9871L22.944 57.2824L23.3488 57.8086C23.9053 58.5372 24.7149 60.1564 24.6238 60.3689C24.5024 60.6826 24.2089 60.4701 23.6524 59.6706C22.7011 58.3045 19.7462 54.4186 17.9753 52.2024C17.8235 52.0102 17.8033 52.0304 17.4491 52.5667C16.7002 53.69 14.9293 54.8639 13.0167 55.5014C12.2375 55.7645 11.7619 55.8657 11.3065 55.8657C10.75 55.8657 10.6892 55.8859 10.75 56.0377C10.8006 56.1389 11.1143 57.0396 11.4583 58.0414C13.3507 63.5464 15.1216 66.8251 17.4794 69.2336C19.1795 70.964 20.5052 71.642 22.2963 71.7331C23.4702 71.7837 24.1179 71.642 25.1703 71.0855C26.4049 70.4378 26.243 70.9134 26.7489 66.39C27.346 61.0165 27.5585 59.8325 27.8722 59.954C27.9532 59.9843 28.0442 60.0147 28.0645 60.0147C28.1657 60.0147 28.0948 61.6541 27.9026 63.6678C27.7912 64.862 27.6091 66.815 27.4978 68.0091C27.2043 71.1664 26.921 73.7975 26.5769 76.5601C26.2729 79.0467 26.161 79.78 25.9546 79.921C25.893 79.963 25.823 79.9524 25.737 79.9198C25.6054 79.8692 25.5751 79.6567 25.5751 78.7561C25.5751 77.8251 25.9191 73.7975 26.1418 72.0266L26.2025 71.5408L25.656 71.8444C24.6845 72.401 23.703 72.6742 22.691 72.6945C19.1087 72.7552 15.7085 69.8205 12.9358 64.275C11.7822 61.988 10.6791 58.8206 10.1428 56.2907C9.97075 55.5014 9.97075 55.4306 10.1327 55.0865C10.3654 54.5906 11.0738 53.7811 11.4786 53.5281C11.6607 53.4168 11.8125 53.265 11.8125 53.1941C11.8125 53.0019 11.2053 52.334 10.8714 52.1518C10.6892 52.0607 9.56597 51.6357 8.37186 51.1905C7.17776 50.7553 5.96342 50.2595 5.66995 50.0773C5.37648 49.9053 4.77943 49.3892 4.3443 48.9439C2.63409 47.1933 1.54118 44.7747 2.18884 44.1878C2.36087 44.0258 2.36087 43.9854 2.22931 43.5907C1.98644 42.9127 2.02692 42.4067 2.34063 42.1436C2.52278 41.9817 2.60373 41.8097 2.60373 41.5972C2.60373 41.2329 2.98828 40.7876 3.31211 40.7876C3.5145 40.7876 3.51449 40.737 3.51449 38.1565C3.51449 36.7094 3.52462 35.4951 3.53474 35.4445C3.55497 35.404 3.36271 35.3231 3.10972 35.2623C2.44183 35.1004 2.38111 34.8778 2.42159 32.7021C2.46206 30.5264 2.53291 30.324 3.28175 30.2329C4.27346 30.1014 9.07012 29.9395 10.1934 29.9901L11.3571 30.0508L10.244 29.5144C8.83737 28.8263 7.78493 27.9054 7.33967 26.9744C6.85394 25.9422 6.99561 25.244 7.86589 24.394C8.51354 23.7564 9.3231 23.3112 10.0011 23.2201C11.5798 23.0076 13.4822 24.8797 14.2412 27.3995L14.4638 28.1382L14.9799 28.1483C15.2734 28.1483 15.5972 28.1888 15.7085 28.2293C15.8704 28.3001 15.9615 28.1787 16.3865 27.3387C17.5705 24.9404 18.9771 23.7969 20.6266 23.9183C21.8106 23.9993 22.9845 24.7077 23.4601 25.6285C23.7131 26.1244 23.7131 27.0352 23.45 27.6018C23.0047 28.6037 22.185 29.2918 20.829 29.818L20.07 30.1115L21.9422 30.1823C22.9642 30.2127 24.4316 30.324 25.2108 30.4151C25.9798 30.496 26.6275 30.5568 26.6477 30.5365C26.6781 30.5163 26.587 30.0913 26.4656 29.5954C26.1519 28.3406 26.162 26.1143 26.4858 24.1916C26.9614 21.3277 28.449 16.5817 30.3414 11.866C31.5658 8.83012 32.5069 7.13004 33.7213 5.77402C36.7673 2.37386 41.23 0.633308 44.9034 1.42264ZM39.2769 10.4391C38.265 11.1475 37.2631 12.0178 36.6054 12.7261C35.8767 13.5357 34.4499 15.6102 34.116 16.3692C33.9844 16.6525 33.7112 17.705 33.5088 18.6967C33.1546 20.3664 33.1242 20.6295 33.1242 22.2689C33.1141 24.2017 33.2355 24.9708 33.7719 26.5697C34.3082 28.1685 34.9761 29.312 36.0285 30.4252C38.7709 33.37 43.1426 33.8962 47.3017 31.8015C49.2244 30.8199 50.8233 29.3525 52.1287 27.359L52.918 26.1446L53.5353 26.3976C54.8913 26.9441 56.4194 26.2863 57.148 24.8392C57.4921 24.141 57.4921 23.1088 57.148 22.4712C56.6825 21.6313 55.6503 21.3379 54.7395 21.7831L54.2842 21.9956L53.8389 21.7629C53.5859 21.6313 52.9383 21.0748 52.3918 20.5384C50.1756 18.3324 48.1011 14.9322 47.1094 11.8761C46.8666 11.1374 46.6338 10.5403 46.5933 10.5302C46.4415 10.5302 45.7939 12.0684 45.5409 13.05C45.3284 13.8595 45.2778 14.3351 45.2373 15.9644C45.1766 18.1704 45.1665 18.1907 44.4379 17.7657C43.4563 17.1889 42.4747 15.6507 42.1205 14.1226L42.0497 13.8191L41.9485 14.072C41.8675 14.2947 41.8371 15.6912 41.8979 16.3995C41.9586 17.118 40.9062 16.3186 40.3192 15.2054C40.1269 14.831 39.8942 14.2846 39.8031 13.981C39.5805 13.2321 39.6007 11.6029 39.8335 10.7022C39.9246 10.3278 39.9752 10.0242 39.9448 10.0242C39.9043 10.0242 39.6109 10.2165 39.2769 10.4391ZM9.49513 24.1612C9.03975 24.3434 8.30103 24.9505 8.05816 25.3452C7.80518 25.7702 7.8153 26.1244 8.11888 26.7215C8.43259 27.3489 9.4243 28.1787 10.4868 28.7352C11.681 29.3424 11.8429 29.3424 11.0535 28.715C10.1226 27.9965 9.76836 27.4804 9.70765 26.7923C9.67729 26.3774 9.70765 26.2155 9.85944 26.0333C10.0922 25.7399 10.7297 25.6387 11.2357 25.8107C11.7417 25.9726 12.5411 26.7215 13.2495 27.6626C13.9882 28.6644 14.1299 28.5025 13.5834 27.2983C12.7941 25.5678 11.7417 24.3332 10.8107 24.0499C10.3249 23.8981 10.0719 23.9183 9.49513 24.1612ZM19.8879 24.738C18.9367 25.0214 18.1271 25.8107 17.3681 27.1768L17.0342 27.784L17.692 27.1667C18.6837 26.2256 19.392 25.9422 19.9891 26.256C21.082 26.8226 20.4445 28.4114 18.7444 29.4031C18.4914 29.5549 18.289 29.6865 18.289 29.7067C18.289 29.727 18.5724 29.6763 18.9164 29.5954C21.507 28.9983 22.9642 27.9054 22.9642 26.5595C22.9642 25.7196 22.3065 25.0416 21.2237 24.7583C20.5153 24.566 20.4343 24.566 19.8879 24.738ZM10.2339 26.4583C10.0719 26.8834 10.9827 27.9156 12.359 28.8364C13.1078 29.3424 13.1685 29.3728 13.29 29.2007C13.4013 29.0489 13.4013 28.968 13.2495 28.7251C12.9155 28.1483 11.6506 26.772 11.2559 26.5494C10.8006 26.2863 10.3148 26.2458 10.2339 26.4583ZM18.623 27.1161C18.0057 27.612 17.6312 28.0168 16.9735 28.8972C16.6598 29.3019 16.4877 29.6156 16.5586 29.6359C16.7205 29.6865 18.8861 28.6037 19.22 28.3001C19.5843 27.9662 20.0093 27.3489 20.0093 27.1363C20.0093 26.5899 19.2908 26.5798 18.623 27.1161ZM14.3626 29.0186C14.2513 29.1198 14.1096 29.3728 14.059 29.5752C13.9376 30.0204 13.9983 30.0609 14.8585 30.0609C15.5061 30.0609 15.4555 30.1115 15.5972 29.2817C15.6579 28.9174 15.5871 28.8668 14.99 28.8567C14.7269 28.8465 14.4942 28.9174 14.3626 29.0186ZM11.6304 30.9211C11.4988 31.0425 11.4178 31.9836 11.4077 33.3194V34.807L12.6727 34.8373L13.9376 34.8677V33.7039C13.9376 32.6211 14.0287 31.68 14.1906 31.0728L14.2513 30.8199L12.9661 30.8502C12.2578 30.8603 11.6506 30.8907 11.6304 30.9211ZM3.08947 32.7426L3.05912 34.6653L6.87418 34.6754L10.6791 34.6956L10.7196 32.9551C10.7398 32.0038 10.8006 31.1437 10.841 31.0526C10.9119 30.8907 10.8208 30.8705 9.86956 30.8401C9.30286 30.83 7.54207 30.8097 5.97354 30.8199H3.11983L3.08947 32.7426ZM14.7067 31.2449C14.6763 31.4574 14.646 32.3681 14.646 33.2587V34.8879L16.5889 34.9587C17.6616 34.9992 19.4426 35.0701 20.5558 35.1308C21.6588 35.1915 22.5797 35.2219 22.5999 35.2016C22.6202 35.1814 22.6505 34.301 22.6708 33.2485C22.7011 32.186 22.7517 31.2449 22.7922 31.1538C22.863 30.9919 22.6809 30.9717 20.2016 30.9413C18.7343 30.9312 16.9128 30.9109 16.1538 30.8907L14.7775 30.8705L14.7067 31.2449ZM23.2982 33.0057V35.0397L24.6643 34.979C25.4132 34.9385 26.4049 34.8879 26.8602 34.8575L27.7002 34.7968V34.22C27.7002 33.9063 27.7305 33.1474 27.7609 32.5301L27.8317 31.4068L26.6781 31.2854C25.5548 31.1639 23.9053 31.0121 23.5006 30.9818C23.3083 30.9717 23.2982 31.0324 23.2982 33.0057ZM30.1592 32.5604C30.1997 32.9956 30.2908 33.6027 30.3717 33.9063C30.4527 34.22 30.483 34.5034 30.4324 34.554C30.311 34.6855 29.4407 34.2301 28.955 33.7849L28.5401 33.4003L28.4794 34.2099C28.3984 35.2016 28.3073 35.3939 27.8216 35.5153L27.4371 35.6266L27.4775 37.2154C27.4978 38.0958 27.4674 40.1804 27.4168 41.8501L27.3055 44.886L27.5787 45.1086C27.7305 45.2301 27.8722 45.3313 27.9026 45.3313C27.9329 45.3414 28.1151 45.0277 28.3276 44.6533C29.1979 43.0442 31.2926 40.7775 32.8814 39.7149C33.8528 39.0572 36.0488 37.944 36.9393 37.6505C37.6881 37.4077 39.1352 37.1344 39.2567 37.2053C39.3579 37.266 39.5805 36.7499 39.6918 36.2439C39.7728 35.8391 39.6615 35.7278 39.1352 35.7278C38.2042 35.7177 36.8381 35.2623 35.9476 34.6552C35.7351 34.5135 35.5529 34.4123 35.5428 34.4325C35.5226 34.4528 35.563 34.6653 35.6238 34.898C35.7756 35.4647 35.6541 35.5457 34.7838 35.4951C33.7618 35.4344 32.6284 34.8171 31.5962 33.7646C31.1307 33.2991 30.6551 32.692 30.483 32.3681C30.311 32.0443 30.1592 31.7812 30.139 31.7812C30.1187 31.7812 30.1289 32.1354 30.1592 32.5604ZM45.7736 33.3599C44.5694 33.7748 43.6182 33.9367 42.3229 33.9367C41.655 33.9367 41.0073 33.9063 40.8758 33.8658C40.6532 33.795 40.6532 33.795 40.6532 34.8879C40.6532 36.082 40.471 37.0636 40.1269 37.6607C39.8841 38.0857 39.5299 38.3083 39.3477 38.1262C39.1555 37.9339 39.2263 38.1565 39.4388 38.4297C40.0764 39.2393 41.0883 39.5834 42.8289 39.5834C44.9742 39.5732 46.5933 39.2494 48.1214 38.5006L49.0524 38.0351L48.5666 37.5999C47.6964 36.8207 47.2309 35.4546 47.2309 33.6938V32.7932L46.8463 32.9551C46.6439 33.0462 46.1582 33.2283 45.7736 33.3599ZM4.26334 37.013C4.30382 37.8934 4.34429 40.6156 4.35441 43.0746L4.37465 47.5373L7.30932 47.6588C8.91833 47.7195 10.4464 47.8105 10.6892 47.8409L11.1345 47.9016L11.0637 47.507C10.9119 46.6063 10.8815 42.7305 10.9928 39.3405C11.0637 37.3368 11.094 35.6772 11.0535 35.6469C11.0029 35.5963 8.7564 35.5153 5.04254 35.4445L4.20262 35.4242L4.26334 37.013ZM11.7012 39.4417C11.6911 41.5466 11.6911 43.4288 11.6911 43.6211C11.6911 43.8133 11.7012 44.8455 11.7012 45.9182L11.7113 47.8611L12.0351 47.9117C12.2173 47.9421 12.7536 47.9826 13.2292 47.9927L14.0894 48.0231V44.1776C14.0894 42.0627 14.1299 39.2697 14.1704 37.9744L14.2513 35.6266H12.9763H11.7113L11.7012 39.4417ZM22.3773 36.2034C22.5291 37.4684 22.5392 39.3203 22.4279 42.7103C22.3672 44.7443 22.3065 46.6063 22.2963 46.8593L22.2862 47.3147L23.7333 47.3046C24.5226 47.3046 25.2209 47.254 25.2715 47.2034C25.3322 47.1427 25.1298 46.8391 24.7554 46.4039C23.9661 45.4729 23.5006 44.5419 23.5006 43.8943C23.5006 43.4794 23.541 43.358 23.794 43.1454C24.2899 42.7204 24.6643 42.8722 26.2936 44.127L26.5567 44.3294L26.6275 40.909C26.6579 39.0369 26.7186 37.1041 26.7489 36.6082L26.8198 35.7278H24.5631H22.3166L22.3773 36.2034ZM14.8484 36.76C14.8484 37.9642 14.646 47.7599 14.6156 47.9117C14.6055 47.9725 14.7067 48.0332 14.8484 48.0534C15.0305 48.0737 15.1317 48.0129 15.2025 47.851C15.2633 47.6992 15.4353 47.5879 15.6782 47.5272C15.9919 47.4462 16.0627 47.3855 16.0627 47.1933C16.0627 47.0617 16.174 46.8391 16.3157 46.7075C16.5282 46.4849 16.6496 46.4545 17.2973 46.4545C17.7931 46.4545 18.3396 46.5355 18.8962 46.7075C19.6045 46.9099 21.3451 47.2641 21.6285 47.2641C21.6689 47.2641 21.679 46.2319 21.6487 44.9568C21.6082 42.8216 21.76 37.3065 21.9017 36.2034L21.9624 35.7278H18.4003H14.8484V36.76ZM3.2109 41.6781C3.2109 42.1537 3.38294 42.2752 3.43354 41.84C3.4639 41.6174 3.4639 41.415 3.44366 41.3644C3.35258 41.2126 3.2109 41.415 3.2109 41.6781ZM2.70493 42.852C2.70493 43.3681 3.17043 44.3294 3.43354 44.3294C3.47402 44.3294 3.51449 44.0562 3.51449 43.7324C3.51449 43.1859 3.48413 43.105 3.20078 42.8722C2.81624 42.5484 2.70493 42.5383 2.70493 42.852ZM24.1583 43.8235C24.1583 44.2485 24.5834 45.1188 25.0691 45.6854C26.0912 46.8796 26.3543 47.3956 26.0507 47.6486C25.8483 47.8105 24.8869 47.9826 23.8041 48.0433C22.519 48.1141 20.829 47.8915 18.9569 47.4159C17.6009 47.0617 16.7711 46.9909 16.7002 47.2135C16.68 47.2742 17.0443 47.5474 17.4997 47.8207C18.4408 48.3874 20.0397 48.9541 21.6285 49.288C22.2154 49.4094 22.7315 49.541 22.772 49.5815C22.944 49.7535 22.7112 49.9964 22.3773 49.9964C21.8612 49.9964 20.0194 49.6219 19.2402 49.3487C18.461 49.0856 17.1556 48.4582 16.7812 48.1647C16.4877 47.932 15.6579 47.8915 15.6579 48.1141C15.6579 48.3671 16.2853 48.8731 17.1657 49.3285C18.3801 49.9559 18.9468 50.1684 20.5153 50.5631C21.9017 50.9172 22.3267 51.1298 22.0434 51.3119C21.8511 51.4232 21.1022 51.3321 19.9081 51.0488C19.1694 50.8768 18.9974 50.8565 19.1492 50.978C19.8575 51.5042 22.266 52.2429 25.2108 52.8197C28.3175 53.437 28.5603 53.5584 30.3009 55.4002L31.2218 56.3717L31.8188 55.5419C32.5069 54.5906 33.5695 52.8804 33.5695 52.7388C33.5695 52.6882 33.2861 52.4149 32.932 52.1417C31.748 51.1905 30.8271 50.1279 30.139 48.9136C29.7848 48.2963 29.3598 47.6183 29.1979 47.426C28.9449 47.1123 27.3966 45.8474 25.1298 44.1068C24.4619 43.5806 24.1583 43.4996 24.1583 43.8235ZM2.60373 44.8759C2.60373 44.9973 2.796 45.4831 3.02875 45.9587L3.4639 46.8087L3.49425 45.9991L3.52462 45.1896L3.16031 44.8556C2.77577 44.5015 2.60373 44.5015 2.60373 44.8759ZM4.83003 48.4784C4.83003 48.6403 5.29553 49.035 5.94318 49.4297C6.51999 49.7839 7.50159 50.1583 9.21179 50.6946C10.5476 51.1095 11.4482 51.5548 11.9238 52.0304C12.1363 52.2429 12.5209 52.8197 12.7637 53.3055C13.4114 54.5805 13.3102 54.4996 13.9578 54.2162C14.6561 53.9025 16.0425 53.0221 16.3461 52.6983L16.5788 52.4554L16.0627 51.575C15.6984 50.9577 15.4758 50.4315 15.3037 49.7636C15.1621 49.207 15.0204 48.8225 14.9394 48.8124C14.8585 48.8023 14.221 48.7719 13.5025 48.7618C12.3084 48.7314 11.9542 48.782 12.3994 48.9237C12.6626 49.0148 13.1584 49.5511 13.0876 49.6725C13.0471 49.7434 12.6929 49.6219 12.1869 49.3892C11.094 48.8731 10.3958 48.7618 8.26055 48.7415C7.08669 48.7314 6.4188 48.6808 6.32773 48.61C6.25689 48.5392 5.96342 48.4784 5.69019 48.4481C5.40684 48.4278 5.10326 48.4076 5.00206 48.3975C4.91099 48.3874 4.83003 48.4278 4.83003 48.4784ZM16.2145 50.3505L16.7002 51.3119H17.4086H18.1069L18.8355 52.0405C19.2301 52.4453 20.1308 53.5281 20.8391 54.449C21.5475 55.3698 22.2053 56.2098 22.2862 56.3211C22.3773 56.4324 22.4684 56.4729 22.4987 56.4223C22.519 56.3615 22.863 55.6633 23.2476 54.8639C23.6422 54.0644 23.9458 53.3965 23.9256 53.3763C23.9155 53.356 23.2881 53.184 22.5392 52.9917C20.151 52.3846 19.1289 51.8988 17.6211 50.6744C16.6395 49.8749 15.8198 49.2981 15.7692 49.3487C15.7389 49.3791 15.9413 49.8243 16.2145 50.3505Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M39.2769 10.4391C38.265 11.1475 37.2631 12.0178 36.6054 12.7261C35.8767 13.5357 34.4499 15.6102 34.116 16.3692C33.9844 16.6525 33.7112 17.705 33.5088 18.6967C33.1546 20.3664 33.1242 20.6295 33.1242 22.2689C33.1141 24.2017 33.2355 24.9708 33.7719 26.5697C34.3082 28.1685 34.9761 29.312 36.0285 30.4252C38.7709 33.37 43.1426 33.8962 47.3017 31.8015C49.2244 30.8199 50.8233 29.3525 52.1287 27.359L52.918 26.1446L53.5353 26.3976C54.8913 26.9441 56.4194 26.2863 57.148 24.8392C57.4921 24.141 57.4921 23.1088 57.148 22.4712C56.6825 21.6313 55.6503 21.3379 54.7395 21.7831L54.2842 21.9956L53.8389 21.7629C53.5859 21.6313 52.9383 21.0748 52.3918 20.5384C50.1756 18.3324 48.1011 14.9322 47.1094 11.8761C46.8666 11.1374 46.6338 10.5403 46.5933 10.5302C46.4415 10.5302 45.7939 12.0684 45.5409 13.05C45.3284 13.8595 45.2778 14.3351 45.2373 15.9644C45.1766 18.1704 45.1665 18.1907 44.4379 17.7657C43.4563 17.1889 42.4747 15.6507 42.1205 14.1226L42.0497 13.8191L41.9485 14.072C41.8675 14.2947 41.8371 15.6912 41.8979 16.3995C41.9586 17.118 40.9062 16.3186 40.3192 15.2054C40.1269 14.831 39.8942 14.2846 39.8031 13.981C39.5805 13.2321 39.6007 11.6029 39.8335 10.7022C39.9246 10.3278 39.9752 10.0242 39.9448 10.0242C39.9043 10.0242 39.6109 10.2165 39.2769 10.4391Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M65.5067 80.1323C65.446 79.8692 65.2234 76.6714 64.9805 72.7653C64.6466 67.2805 63.9584 59.4885 63.5638 56.7259C62.6935 50.5631 61.3071 46.8492 58.7671 43.8437C57.3099 42.1234 54.6586 40.1905 51.9972 38.9155C51.1269 38.4905 50.3578 38.116 50.2768 38.0756C50.1756 38.0148 49.9935 38.1363 49.6899 38.4399C49.0827 39.0369 47.494 39.7959 46.1177 40.1298C44.1849 40.5852 41.9181 40.6459 40.7847 40.2513C39.7323 39.887 38.8316 39.0976 38.6293 38.369L38.5179 37.9642L37.7893 38.2172C36.6458 38.622 34.7231 39.5935 33.6707 40.2917C32.0718 41.3543 29.8354 43.7223 28.8842 45.3313L28.621 45.7866L29.4002 46.6063C29.9872 47.2236 30.3211 47.6891 30.7259 48.4886C31.3932 49.7734 31.3939 49.774 32.4552 50.7193L32.5069 50.7654C33.8225 51.9191 33.6808 51.8584 34.0856 51.5548C34.5309 51.2208 35.0166 51.2714 36.1297 51.7976C37.4959 52.4352 41.3514 54.8537 45.4802 57.6366L47.3219 58.8914L47.494 58.3652C48.1922 56.23 50.459 51.1095 50.8334 50.7958C51.0459 50.6238 51.1977 50.8565 51.1066 51.2107C50.965 51.8381 48.9107 57.1306 48.2023 58.7093L47.9493 59.2861L48.9006 60.0046C49.4167 60.4094 49.9834 60.885 50.1554 61.0874C50.5096 61.482 50.5804 61.8868 50.287 61.816C50.1959 61.7856 48.678 60.8141 46.9273 59.6605C43.2842 57.2521 42.0901 56.4729 40.805 55.6633C40.299 55.3496 39.196 54.6412 38.3358 54.0948C36.656 53.0423 34.8749 51.9899 34.8344 52.0405C34.8243 52.0607 34.5814 52.5566 34.288 53.1537C33.2861 55.2079 31.323 57.8188 30.1187 58.689L29.7544 58.9521L31.0194 60.4195C36.5649 66.8656 40.7847 70.792 44.5492 73.0183C47.8077 74.941 50.1858 75.5482 52.321 74.9916C53.2419 74.7589 54.3651 74.2225 55.0937 73.6558C56.4194 72.6439 57.998 70.6604 59.1517 68.5657C59.6981 67.5639 61.0744 64.5685 61.7018 63.0202C61.9447 62.413 62.1369 62.2309 62.3292 62.4231C62.6024 62.6964 61.722 65.3173 60.4976 67.8573C58.0486 72.9272 55.367 75.4976 51.9263 76.0643C50.8031 76.2464 49.9834 76.2059 48.6881 75.9024C45.045 75.0523 40.2686 71.7432 35.1886 66.5317C32.4968 63.7792 29.7544 60.4397 29.2485 59.3165C29.0258 58.8004 29.0056 58.6992 29.1169 58.2337C29.1776 57.9503 29.4104 57.3533 29.633 56.9181L30.0479 56.1187L29.1877 55.299C28.1454 54.3073 27.599 54.0239 26.1822 53.7406C25.5953 53.6293 25.0286 53.518 24.9173 53.4876C24.7453 53.4572 24.6744 53.5483 24.472 54.0745C24.3405 54.4186 23.9357 55.2788 23.5916 55.9871L22.944 57.2824L23.3488 57.8086C23.9053 58.5372 24.7149 60.1564 24.6238 60.3689C24.5024 60.6826 24.2089 60.4701 23.6524 59.6706C22.7011 58.3045 19.7462 54.4186 17.9753 52.2024C17.8235 52.0102 17.8033 52.0304 17.4491 52.5667C16.7002 53.69 14.9293 54.8639 13.0167 55.5014C12.2375 55.7645 11.7619 55.8657 11.3065 55.8657C10.75 55.8657 10.6892 55.8859 10.75 56.0377C10.8006 56.1389 11.1143 57.0396 11.4583 58.0414C13.3507 63.5464 15.1216 66.8251 17.4794 69.2336C19.1795 70.964 20.5052 71.642 22.2963 71.7331C23.4702 71.7837 24.1179 71.642 25.1703 71.0855C26.4049 70.4378 26.243 70.9134 26.7489 66.39C27.346 61.0165 27.5585 59.8325 27.8722 59.954C27.9532 59.9843 28.0442 60.0147 28.0645 60.0147C28.1657 60.0147 28.0948 61.6541 27.9026 63.6678L27.4978 68.0091C27.2043 71.1664 26.921 73.7975 26.5769 76.5601C26.2729 79.0467 26.161 79.78 25.9546 79.921L65.5067 80.1323Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M24.1583 43.8235C24.1583 44.2485 24.5834 45.1188 25.0691 45.6854C26.0912 46.8796 26.3543 47.3956 26.0507 47.6486C25.8483 47.8105 24.8869 47.9826 23.8041 48.0433C22.519 48.1141 20.829 47.8915 18.9569 47.4159C17.6009 47.0617 16.7711 46.9909 16.7002 47.2135C16.68 47.2742 17.0443 47.5474 17.4997 47.8207C18.4408 48.3874 20.0397 48.9541 21.6285 49.288C22.2154 49.4094 22.7315 49.541 22.772 49.5815C22.944 49.7535 22.7112 49.9964 22.3773 49.9964C21.8612 49.9964 20.0194 49.6219 19.2402 49.3487C18.461 49.0856 17.1556 48.4582 16.7812 48.1647C16.4877 47.932 15.6579 47.8915 15.6579 48.1141C15.6579 48.3671 16.2853 48.8731 17.1657 49.3285C18.3801 49.9559 18.9468 50.1684 20.5153 50.5631C21.9017 50.9172 22.3267 51.1298 22.0434 51.3119C21.8511 51.4232 21.1022 51.3321 19.9081 51.0488C19.1694 50.8768 18.9974 50.8565 19.1492 50.978C19.8575 51.5042 22.266 52.2429 25.2108 52.8197C28.3175 53.437 28.5603 53.5584 30.3009 55.4002L31.2218 56.3717L31.8188 55.5419C32.5069 54.5906 33.5695 52.8804 33.5695 52.7388C33.5695 52.6882 33.2861 52.4149 32.932 52.1417C31.748 51.1905 30.8271 50.1279 30.139 48.9136C29.7848 48.2963 29.3598 47.6183 29.1979 47.426C28.9449 47.1123 27.3966 45.8474 25.1298 44.1068C24.4619 43.5806 24.1583 43.4996 24.1583 43.8235Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M4.83003 48.4784C4.83003 48.6403 5.29553 49.035 5.94318 49.4297C6.51999 49.7839 7.50159 50.1583 9.21179 50.6946C10.5476 51.1095 11.4482 51.5548 11.9238 52.0304C12.1363 52.2429 12.5209 52.8197 12.7637 53.3055C13.4114 54.5805 13.3102 54.4996 13.9578 54.2162C14.6561 53.9025 16.0425 53.0221 16.3461 52.6983L16.5788 52.4554L16.0627 51.575C15.6984 50.9577 15.4758 50.4315 15.3037 49.7636C15.1621 49.207 15.0204 48.8225 14.9394 48.8124C14.8585 48.8023 14.221 48.7719 13.5025 48.7618C12.3084 48.7314 11.9542 48.782 12.3994 48.9237C12.6626 49.0148 13.1584 49.5511 13.0876 49.6725C13.0471 49.7434 12.6929 49.6219 12.1869 49.3892C11.094 48.8731 10.3958 48.7618 8.26055 48.7415C7.08669 48.7314 6.4188 48.6808 6.32773 48.61C6.25689 48.5392 5.96342 48.4784 5.69019 48.4481C5.40684 48.4278 5.10326 48.4076 5.00206 48.3975C4.91099 48.3874 4.83003 48.4278 4.83003 48.4784Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M4.26334 37.013C4.30382 37.8934 4.34429 40.6156 4.35441 43.0746L4.37465 47.5373L7.30932 47.6588C8.91833 47.7195 10.4464 47.8105 10.6892 47.8409L11.1345 47.9016L11.0637 47.507C10.9119 46.6063 10.8815 42.7305 10.9928 39.3405C11.0637 37.3368 11.094 35.6772 11.0535 35.6469C11.0029 35.5963 8.7564 35.5153 5.04254 35.4445L4.20262 35.4242L4.26334 37.013Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M11.7012 39.4417C11.6911 41.5466 11.6911 43.4288 11.6911 43.6211C11.6911 43.8133 11.7012 44.8455 11.7012 45.9182L11.7113 47.8611L12.0351 47.9117C12.2173 47.9421 12.7536 47.9826 13.2292 47.9927L14.0894 48.0231V44.1776C14.0894 42.0627 14.1299 39.2697 14.1704 37.9744L14.2513 35.6266H12.9763H11.7113L11.7012 39.4417Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M14.8484 36.76C14.8484 37.9642 14.646 47.7599 14.6156 47.9117C14.6055 47.9725 14.7067 48.0332 14.8484 48.0534C15.0305 48.0737 15.1317 48.0129 15.2025 47.851C15.2633 47.6992 15.4353 47.5879 15.6782 47.5272C15.9919 47.4462 16.0627 47.3855 16.0627 47.1933C16.0627 47.0617 16.174 46.8391 16.3157 46.7075C16.5282 46.4849 16.6496 46.4545 17.2973 46.4545C17.7931 46.4545 18.3396 46.5355 18.8962 46.7075C19.6045 46.9099 21.3451 47.2641 21.6285 47.2641C21.6689 47.2641 21.679 46.2319 21.6487 44.9568C21.6082 42.8216 21.76 37.3065 21.9017 36.2034L21.9624 35.7278H18.4003H14.8484V36.76Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M22.3773 36.2034C22.5291 37.4684 22.5392 39.3203 22.4279 42.7103C22.3672 44.7443 22.3065 46.6063 22.2963 46.8593L22.2862 47.3147L23.7333 47.3046C24.5226 47.3046 25.2209 47.254 25.2715 47.2034C25.3322 47.1427 25.1298 46.8391 24.7554 46.4039C23.9661 45.4729 23.5006 44.5419 23.5006 43.8943C23.5006 43.4794 23.541 43.358 23.794 43.1454C24.2899 42.7204 24.6643 42.8722 26.2936 44.127L26.5567 44.3294L26.6275 40.909C26.6579 39.0369 26.7186 37.1041 26.7489 36.6082L26.8198 35.7278H24.5631H22.3166L22.3773 36.2034Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M23.2982 33.0057V35.0397L24.6643 34.979C25.4132 34.9385 26.4049 34.8879 26.8602 34.8575L27.7002 34.7968V34.22C27.7002 33.9063 27.7305 33.1474 27.7609 32.5301L27.8317 31.4068L26.6781 31.2854C25.5548 31.1639 23.9053 31.0121 23.5006 30.9818C23.3083 30.9717 23.2982 31.0324 23.2982 33.0057Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M14.7067 31.2449C14.6763 31.4574 14.646 32.3681 14.646 33.2587V34.8879L16.5889 34.9587C17.6616 34.9992 19.4426 35.0701 20.5558 35.1308C21.6588 35.1915 22.5797 35.2219 22.5999 35.2016C22.6202 35.1814 22.6505 34.301 22.6708 33.2485C22.7011 32.186 22.7517 31.2449 22.7922 31.1538C22.863 30.9919 22.6809 30.9717 20.2016 30.9413C18.7343 30.9312 16.9128 30.9109 16.1538 30.8907L14.7775 30.8705L14.7067 31.2449Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M11.6304 30.9211C11.4988 31.0425 11.4178 31.9836 11.4077 33.3194V34.807L12.6727 34.8373L13.9376 34.8677V33.7039C13.9376 32.6211 14.0287 31.68 14.1906 31.0728L14.2513 30.8199L12.9661 30.8502C12.2578 30.8603 11.6506 30.8907 11.6304 30.9211Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M3.08947 32.7426L3.05912 34.6653L6.87418 34.6754L10.6791 34.6956L10.7196 32.9551C10.7398 32.0038 10.8006 31.1437 10.841 31.0526C10.9119 30.8907 10.8208 30.8705 9.86956 30.8401C9.30286 30.83 7.54207 30.8097 5.97354 30.8199H3.11983L3.08947 32.7426Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M9.49513 24.1612C9.03975 24.3434 8.30103 24.9505 8.05816 25.3452C7.80518 25.7702 7.8153 26.1244 8.11888 26.7215C8.43259 27.3489 9.4243 28.1787 10.4868 28.7352C11.681 29.3424 11.8429 29.3424 11.0535 28.715C10.1226 27.9965 9.76836 27.4804 9.70765 26.7923C9.67729 26.3774 9.70765 26.2155 9.85944 26.0333C10.0922 25.7399 10.7297 25.6387 11.2357 25.8107C11.7417 25.9726 12.5411 26.7215 13.2495 27.6626C13.9882 28.6644 14.1299 28.5025 13.5834 27.2983C12.7941 25.5678 11.7417 24.3332 10.8107 24.0499C10.3249 23.8981 10.0719 23.9183 9.49513 24.1612Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M10.2339 26.4583C10.0719 26.8834 10.9827 27.9156 12.359 28.8364C13.1078 29.3424 13.1685 29.3728 13.29 29.2007C13.4013 29.0489 13.4013 28.968 13.2495 28.7251C12.9155 28.1483 11.6506 26.772 11.2559 26.5494C10.8006 26.2863 10.3148 26.2458 10.2339 26.4583Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M18.623 27.1161C18.0057 27.612 17.6312 28.0168 16.9735 28.8972C16.6598 29.3019 16.4877 29.6156 16.5586 29.6359C16.7205 29.6865 18.8861 28.6037 19.22 28.3001C19.5843 27.9662 20.0093 27.3489 20.0093 27.1363C20.0093 26.5899 19.2908 26.5798 18.623 27.1161Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M14.3626 29.0186C14.2513 29.1198 14.1096 29.3728 14.059 29.5752C13.9376 30.0204 13.9983 30.0609 14.8585 30.0609C15.5061 30.0609 15.4555 30.1115 15.5972 29.2817C15.6579 28.9174 15.5871 28.8668 14.99 28.8567C14.7269 28.8465 14.4942 28.9174 14.3626 29.0186Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M19.8879 24.738C18.9367 25.0214 18.1271 25.8107 17.3681 27.1768L17.0342 27.784L17.692 27.1667C18.6837 26.2256 19.392 25.9422 19.9891 26.256C21.082 26.8226 20.4445 28.4114 18.7444 29.4031C18.4914 29.5549 18.289 29.6865 18.289 29.7067C18.289 29.727 18.5724 29.6763 18.9164 29.5954C21.507 28.9983 22.9642 27.9054 22.9642 26.5595C22.9642 25.7196 22.3065 25.0416 21.2237 24.7583C20.5153 24.566 20.4343 24.566 19.8879 24.738Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M45.7736 33.3599C44.5694 33.7748 43.6182 33.9367 42.3229 33.9367C41.655 33.9367 41.0073 33.9063 40.8758 33.8658C40.6532 33.795 40.6532 33.795 40.6532 34.8879C40.6532 36.082 40.471 37.0636 40.1269 37.6607C39.8841 38.0857 39.5299 38.3083 39.3477 38.1262C39.1555 37.9339 39.2263 38.1565 39.4388 38.4297C40.0764 39.2393 41.0883 39.5834 42.8289 39.5834C44.9742 39.5732 46.5933 39.2494 48.1214 38.5006L49.0524 38.0351L48.5666 37.5999C47.6964 36.8207 47.2309 35.4546 47.2309 33.6938V32.7932L46.8463 32.9551C46.6439 33.0462 46.1582 33.2283 45.7736 33.3599Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M11.7012 39.4417C11.6911 41.5466 11.6911 43.4288 11.6911 43.6211C11.6911 43.8133 11.7012 44.8455 11.7012 45.9182L11.7113 47.8611L12.0351 47.9117C12.2173 47.9421 12.7536 47.9826 13.2292 47.9927L14.0894 48.0231V44.1776C14.0894 42.0627 14.1299 39.2697 14.1704 37.9744L14.2513 35.6266H12.9763H11.7113L11.7012 39.4417Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M11.6304 30.9211C11.4988 31.0425 11.4178 31.9836 11.4077 33.3194V34.807L12.6727 34.8373L13.9376 34.8677V33.7039C13.9376 32.6211 14.0287 31.68 14.1906 31.0728L14.2513 30.8199L12.9661 30.8502C12.2578 30.8603 11.6506 30.8907 11.6304 30.9211Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M19.8879 24.738C18.9367 25.0214 18.1271 25.8107 17.3681 27.1768L17.0342 27.784L17.692 27.1667C18.6837 26.2256 19.392 25.9422 19.9891 26.256C21.082 26.8226 20.4445 28.4114 18.7444 29.4031C18.4914 29.5549 18.289 29.6865 18.289 29.7067C18.289 29.727 18.5724 29.6764 18.9164 29.5954C21.507 28.9983 22.9642 27.9054 22.9642 26.5595C22.9642 25.7196 22.3065 25.0416 21.2237 24.7583C20.5153 24.566 20.4343 24.566 19.8879 24.738Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M9.49514 24.1612C9.03976 24.3434 8.30103 24.9505 8.05816 25.3452C7.80518 25.7702 7.81529 26.1244 8.11888 26.7215C8.43259 27.3489 9.4243 28.1787 10.4869 28.7352C11.681 29.3424 11.8429 29.3424 11.0535 28.715C10.1225 27.9965 9.76836 27.4804 9.70765 26.7923C9.67729 26.3774 9.70765 26.2155 9.85944 26.0333C10.0922 25.7399 10.7297 25.6387 11.2357 25.8107C11.7417 25.9726 12.5411 26.7215 13.2495 27.6626C13.9882 28.6644 14.1299 28.5025 13.5834 27.2983C12.7941 25.5678 11.7417 24.3332 10.8107 24.0499C10.3249 23.8981 10.0719 23.9183 9.49514 24.1612Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M44.9034 1.42263C45.9356 1.63514 46.9779 2.05004 47.5344 2.4447L47.9392 2.73817L48.5666 2.58637C49.9732 2.25243 51.9972 2.4953 53.596 3.18343C55.4985 4.01323 57.4415 5.66271 58.8987 7.69673C59.6374 8.73904 61.0946 11.5725 61.6107 12.9589C63.584 18.3526 64.7579 24.313 64.6061 28.1888C64.5251 30.2228 64.2317 31.4371 63.4525 32.9247C63.0173 33.7545 61.8637 35.0296 61.0541 35.5659C60.5178 35.9201 59.4552 36.335 59.0707 36.335C58.7874 36.335 58.8076 36.1225 59.2124 35.1713C59.4047 34.726 59.5868 34.1998 59.6273 34.0075L59.688 33.6533L59.1719 34.2403C58.1094 35.4141 56.7027 36.2743 55.2152 36.6487C54.527 36.8309 54.183 36.8511 53.2621 36.8106C52.0984 36.76 51.2382 36.5475 51.137 36.2743C51.1066 36.1933 51.2281 35.9606 51.4102 35.7582C51.6632 35.4748 51.6835 35.404 51.5317 35.4647C51.0156 35.657 49.6595 35.9302 49.1738 35.9302C48.5464 35.9302 48.516 36.0213 48.9512 36.6183C49.3054 37.1142 49.6191 37.3267 49.9732 37.3267C50.3376 37.3166 51.1168 37.5898 52.4323 38.1666C54.5574 39.1077 56.2777 40.1905 58.0385 41.6983C61.2666 44.4711 63.2906 48.7213 64.3227 54.9043C64.6972 57.1408 64.8996 58.9521 65.2841 63.5565C65.5168 66.3293 65.6484 67.9585 65.9621 72.2593C66.1038 74.1314 66.1443 79.6466 66.0228 80.0817C65.9317 80.4258 65.5776 80.4561 65.5067 80.1323C65.446 79.8692 65.2234 76.6714 64.9805 72.7653C64.6466 67.2805 63.9584 59.4885 63.5638 56.7259C62.6935 50.5631 61.3071 46.8492 58.7671 43.8437C57.3099 42.1234 54.6586 40.1905 51.9972 38.9155C51.1269 38.4905 50.3578 38.116 50.2768 38.0756C50.1756 38.0148 49.9935 38.1363 49.6899 38.4399C49.0827 39.0369 47.494 39.7959 46.1177 40.1298C44.1849 40.5852 41.9181 40.6459 40.7847 40.2513C39.7323 39.887 38.8316 39.0976 38.6293 38.369L38.5179 37.9642L37.7893 38.2172C36.6458 38.622 34.7231 39.5935 33.6707 40.2917C32.0718 41.3543 29.8354 43.7223 28.8842 45.3313L28.621 45.7866L29.4002 46.6063C29.9872 47.2236 30.3211 47.6891 30.7259 48.4886C31.4039 49.794 31.3938 49.7737 32.5069 50.7654C33.8225 51.9191 33.6808 51.8584 34.0856 51.5548C34.5308 51.2208 35.0166 51.2714 36.1297 51.7976C37.4959 52.4352 41.3514 54.8537 45.4802 57.6366L47.3219 58.8914L47.494 58.3652C48.1922 56.23 50.459 51.1095 50.8334 50.7958C51.0459 50.6238 51.1977 50.8565 51.1066 51.2107C50.965 51.8381 48.9107 57.1306 48.2023 58.7093L47.9493 59.2861L48.9006 60.0046C49.4167 60.4094 49.9834 60.885 50.1554 61.0874C50.5096 61.482 50.5804 61.8868 50.287 61.816C50.1959 61.7856 48.6779 60.8141 46.9273 59.6605C43.2842 57.2521 42.0901 56.4729 40.805 55.6633C40.299 55.3496 39.196 54.6412 38.3358 54.0948C36.656 53.0423 34.8749 51.9899 34.8344 52.0405C34.8243 52.0607 34.5814 52.5566 34.288 53.1537C33.2861 55.2079 31.323 57.8188 30.1187 58.689L29.7544 58.9521L31.0194 60.4195C36.5649 66.8656 40.7847 70.792 44.5492 73.0183C47.8077 74.941 50.1858 75.5482 52.321 74.9916C53.2419 74.7589 54.3651 74.2225 55.0937 73.6558C56.4194 72.6439 57.998 70.6604 59.1517 68.5657C59.6981 67.5639 61.0744 64.5685 61.7018 63.0202C61.9447 62.413 62.1369 62.2309 62.3292 62.4231C62.6024 62.6964 61.722 65.3173 60.4976 67.8573C58.0486 72.9272 55.367 75.4976 51.9263 76.0643C50.8031 76.2464 49.9834 76.2059 48.6881 75.9024C45.045 75.0523 40.2686 71.7432 35.1886 66.5317C32.4968 63.7792 29.7544 60.4397 29.2485 59.3165C29.0258 58.8004 29.0056 58.6992 29.1169 58.2337C29.1776 57.9503 29.4104 57.3533 29.633 56.9181L30.0479 56.1187L29.1877 55.299C28.1454 54.3073 27.599 54.0239 26.1822 53.7406C25.5953 53.6293 25.0286 53.518 24.9173 53.4876C24.7453 53.4572 24.6744 53.5483 24.472 54.0745C24.3405 54.4186 23.9357 55.2788 23.5916 55.9871L22.944 57.2824L23.3488 57.8086C23.9053 58.5372 24.7149 60.1564 24.6238 60.3689C24.5024 60.6826 24.2089 60.4701 23.6524 59.6706C22.7011 58.3045 19.7462 54.4186 17.9753 52.2024C17.8235 52.0102 17.8033 52.0304 17.4491 52.5667C16.7002 53.69 14.9293 54.8639 13.0167 55.5014C12.2375 55.7645 11.7619 55.8657 11.3065 55.8657C10.75 55.8657 10.6892 55.8859 10.75 56.0377C10.8006 56.1389 11.1143 57.0396 11.4583 58.0414C13.3507 63.5464 15.1216 66.8251 17.4794 69.2336C19.1795 70.964 20.5052 71.642 22.2963 71.7331C23.4702 71.7837 24.1179 71.642 25.1703 71.0855C26.4049 70.4378 26.243 70.9134 26.7489 66.39C27.346 61.0165 27.5585 59.8325 27.8722 59.954C27.9532 59.9843 28.0442 60.0147 28.0645 60.0147C28.1657 60.0147 28.0948 61.6541 27.9026 63.6678C27.7912 64.862 27.6091 66.815 27.4978 68.0091C27.2043 71.1664 26.921 73.7975 26.5769 76.5601C26.1822 79.7883 26.1114 80.0615 25.737 79.9198C25.6054 79.8692 25.5751 79.6567 25.5751 78.7561C25.5751 77.8251 25.9191 73.7975 26.1418 72.0266L26.2025 71.5408L25.656 71.8444C24.6845 72.401 23.703 72.6742 22.691 72.6945C19.1087 72.7552 15.7085 69.8205 12.9358 64.275C11.7822 61.988 10.6791 58.8206 10.1428 56.2907C9.97075 55.5014 9.97075 55.4306 10.1327 55.0865C10.3654 54.5906 11.0738 53.7811 11.4786 53.5281C11.6607 53.4168 11.8125 53.265 11.8125 53.1941C11.8125 53.0019 11.2053 52.334 10.8714 52.1518C10.6892 52.0607 9.56597 51.6357 8.37187 51.1905C7.17776 50.7553 5.96342 50.2595 5.66996 50.0773C5.37649 49.9053 4.77943 49.3892 4.3443 48.9439C2.63409 47.1933 1.54118 44.7747 2.18884 44.1878C2.36087 44.0258 2.36087 43.9854 2.22932 43.5907C1.98644 42.9127 2.02692 42.4067 2.34063 42.1436C2.52278 41.9817 2.60374 41.8097 2.60374 41.5972C2.60374 41.2329 2.98828 40.7876 3.3121 40.7876C3.5145 40.7876 3.5145 40.737 3.5145 38.1565C3.5145 36.7094 3.52462 35.4951 3.53473 35.4445C3.55497 35.404 3.3627 35.323 3.10971 35.2623C2.44183 35.1004 2.38111 34.8778 2.42159 32.7021C2.46206 30.5264 2.5329 30.324 3.28175 30.2329C4.27346 30.1014 9.07012 29.9395 10.1934 29.9901L11.3571 30.0508L10.244 29.5144C8.83737 28.8263 7.78493 27.9054 7.33968 26.9744C6.85394 25.9422 6.99561 25.244 7.86589 24.394C8.51354 23.7564 9.3231 23.3112 10.0011 23.2201C11.5798 23.0076 13.4822 24.8797 14.2412 27.3995L14.4638 28.1382L14.9799 28.1483C15.2734 28.1483 15.5972 28.1888 15.7085 28.2293C15.8704 28.3001 15.9615 28.1787 16.3865 27.3387C17.5705 24.9404 18.9771 23.7969 20.6266 23.9183C21.8106 23.9993 22.9845 24.7077 23.4601 25.6285C23.7131 26.1244 23.7131 27.0352 23.45 27.6018C23.0047 28.6037 22.185 29.2918 20.829 29.818L20.07 30.1115L21.9422 30.1823C22.9642 30.2127 24.4316 30.324 25.2108 30.4151C25.9798 30.496 26.6275 30.5568 26.6477 30.5365C26.6781 30.5163 26.587 30.0913 26.4656 29.5954C26.1519 28.3406 26.162 26.1143 26.4858 24.1916C26.9614 21.3277 28.449 16.5817 30.3414 11.866C31.5658 8.83012 32.5069 7.13004 33.7213 5.77402C36.7673 2.37386 41.23 0.633301 44.9034 1.42263ZM39.2769 10.4391C38.265 11.1475 37.2631 12.0178 36.6054 12.7261C35.8767 13.5357 34.4499 15.6102 34.1159 16.3692C33.9844 16.6525 33.7112 17.7049 33.5088 18.6967C33.1546 20.3664 33.1242 20.6295 33.1242 22.2689C33.1141 24.2017 33.2355 24.9708 33.7719 26.5697C34.3082 28.1685 34.9761 29.312 36.0285 30.4252C38.7709 33.37 43.1426 33.8962 47.3017 31.8015C49.2244 30.8199 50.8233 29.3525 52.1287 27.359L52.918 26.1446L53.5353 26.3976C54.8913 26.9441 56.4194 26.2863 57.148 24.8392C57.4921 24.141 57.4921 23.1088 57.148 22.4712C56.6825 21.6313 55.6503 21.3379 54.7395 21.7831L54.2842 21.9956L53.8389 21.7629C53.5859 21.6313 52.9383 21.0748 52.3918 20.5384C50.1756 18.3324 48.1011 14.9322 47.1094 11.8761C46.8666 11.1374 46.6338 10.5403 46.5933 10.5302C46.4415 10.5302 45.7939 12.0684 45.5409 13.05C45.3284 13.8595 45.2778 14.3351 45.2373 15.9644C45.1766 18.1704 45.1665 18.1907 44.4379 17.7657C43.4563 17.1889 42.4747 15.6507 42.1205 14.1226L42.0497 13.819L41.9485 14.072C41.8675 14.2947 41.8371 15.6912 41.8979 16.3995C41.9586 17.118 40.9062 16.3186 40.3192 15.2054C40.1269 14.831 39.8942 14.2845 39.8031 13.981C39.5805 13.2321 39.6007 11.6029 39.8335 10.7022C39.9246 10.3278 39.9752 10.0242 39.9448 10.0242C39.9043 10.0242 39.6109 10.2165 39.2769 10.4391ZM9.49514 24.1612C9.03976 24.3434 8.30103 24.9505 8.05816 25.3452C7.80518 25.7702 7.8153 26.1244 8.11888 26.7214C8.43259 27.3489 9.4243 28.1787 10.4869 28.7352C11.681 29.3424 11.8429 29.3424 11.0535 28.715C10.1225 27.9965 9.76836 27.4804 9.70765 26.7923C9.67729 26.3774 9.70765 26.2155 9.85944 26.0333C10.0922 25.7399 10.7297 25.6387 11.2357 25.8107C11.7417 25.9726 12.5411 26.7214 13.2495 27.6626C13.9882 28.6644 14.1299 28.5025 13.5834 27.2983C12.7941 25.5678 11.7417 24.3332 10.8107 24.0499C10.3249 23.8981 10.0719 23.9183 9.49514 24.1612ZM19.8879 24.738C18.9367 25.0214 18.1271 25.8107 17.3681 27.1768L17.0342 27.784L17.692 27.1667C18.6837 26.2256 19.392 25.9422 19.9891 26.256C21.082 26.8226 20.4445 28.4114 18.7444 29.4031C18.4914 29.5549 18.289 29.6865 18.289 29.7067C18.289 29.7269 18.5724 29.6763 18.9164 29.5954C21.507 28.9983 22.9642 27.9054 22.9642 26.5595C22.9642 25.7196 22.3065 25.0416 21.2237 24.7583C20.5153 24.566 20.4343 24.566 19.8879 24.738ZM10.2339 26.4583C10.0719 26.8834 10.9827 27.9156 12.359 28.8364C13.1078 29.3424 13.1685 29.3728 13.29 29.2007C13.4013 29.0489 13.4013 28.968 13.2495 28.7251C12.9155 28.1483 11.6506 26.772 11.2559 26.5494C10.8006 26.2863 10.3148 26.2458 10.2339 26.4583ZM18.623 27.1161C18.0057 27.612 17.6312 28.0167 16.9735 28.8971C16.6598 29.3019 16.4877 29.6156 16.5586 29.6359C16.7205 29.6865 18.8861 28.6037 19.22 28.3001C19.5843 27.9662 20.0093 27.3489 20.0093 27.1363C20.0093 26.5899 19.2908 26.5798 18.623 27.1161ZM14.3626 29.0186C14.2513 29.1198 14.1096 29.3728 14.059 29.5752C13.9376 30.0204 13.9983 30.0609 14.8585 30.0609C15.5061 30.0609 15.4555 30.1115 15.5972 29.2817C15.6579 28.9174 15.5871 28.8668 14.99 28.8567C14.7269 28.8465 14.4942 28.9174 14.3626 29.0186ZM11.6304 30.9211C11.4988 31.0425 11.4178 31.9836 11.4077 33.3194V34.807L12.6727 34.8373L13.9376 34.8677V33.7039C13.9376 32.6211 14.0287 31.68 14.1906 31.0728L14.2513 30.8199L12.9661 30.8502C12.2578 30.8603 11.6506 30.8907 11.6304 30.9211ZM3.08947 32.7426L3.05912 34.6653L6.87418 34.6754L10.6791 34.6956L10.7196 32.9551C10.7398 32.0038 10.8006 31.1437 10.841 31.0526C10.9119 30.8907 10.8208 30.8705 9.86956 30.8401C9.30286 30.83 7.54207 30.8097 5.97354 30.8199H3.11983L3.08947 32.7426ZM14.7067 31.2449C14.6763 31.4574 14.646 32.3681 14.646 33.2587V34.8879L16.5889 34.9587C17.6616 34.9992 19.4426 35.0701 20.5558 35.1308C21.6588 35.1915 22.5797 35.2219 22.5999 35.2016C22.6202 35.1814 22.6505 34.301 22.6708 33.2485C22.7011 32.186 22.7517 31.2449 22.7922 31.1538C22.863 30.9919 22.6809 30.9716 20.2016 30.9413C18.7343 30.9312 16.9128 30.9109 16.1538 30.8907L14.7775 30.8705L14.7067 31.2449ZM23.2982 33.0057V35.0397L24.6643 34.979C25.4132 34.9385 26.4049 34.8879 26.8602 34.8575L27.7002 34.7968V34.22C27.7002 33.9063 27.7305 33.1474 27.7609 32.5301L27.8317 31.4068L26.6781 31.2854C25.5548 31.1639 23.9053 31.0121 23.5006 30.9818C23.3083 30.9716 23.2982 31.0324 23.2982 33.0057ZM30.1592 32.5604C30.1997 32.9956 30.2908 33.6027 30.3717 33.9063C30.4527 34.22 30.483 34.5034 30.4324 34.554C30.311 34.6855 29.4407 34.2301 28.955 33.7849L28.5401 33.4003L28.4794 34.2099C28.3984 35.2016 28.3073 35.3939 27.8216 35.5153L27.4371 35.6266L27.4775 37.2154C27.4978 38.0958 27.4674 40.1804 27.4168 41.8501L27.3055 44.886L27.5787 45.1086C27.7305 45.2301 27.8722 45.3313 27.9026 45.3313C27.9329 45.3414 28.1151 45.0277 28.3276 44.6533C29.1979 43.0442 31.2926 40.7775 32.8814 39.7149C33.8528 39.0572 36.0488 37.944 36.9393 37.6505C37.6881 37.4077 39.1352 37.1344 39.2567 37.2053C39.3579 37.266 39.5805 36.7499 39.6918 36.2439C39.7728 35.8391 39.6615 35.7278 39.1352 35.7278C38.2042 35.7177 36.8381 35.2623 35.9476 34.6552C35.7351 34.5135 35.5529 34.4123 35.5428 34.4325C35.5226 34.4528 35.563 34.6653 35.6238 34.898C35.7756 35.4647 35.6541 35.5457 34.7838 35.4951C33.7618 35.4344 32.6284 34.8171 31.5962 33.7646C31.1307 33.2991 30.6551 32.692 30.483 32.3681C30.311 32.0443 30.1592 31.7812 30.139 31.7812C30.1187 31.7812 30.1289 32.1354 30.1592 32.5604ZM45.7736 33.3599C44.5694 33.7748 43.6182 33.9367 42.3229 33.9367C41.655 33.9367 41.0073 33.9063 40.8758 33.8658C40.6532 33.795 40.6532 33.795 40.6532 34.8879C40.6532 36.082 40.471 37.0636 40.1269 37.6607C39.8841 38.0857 39.5299 38.3083 39.3477 38.1262C39.1555 37.9339 39.2263 38.1565 39.4388 38.4297C40.0764 39.2393 41.0883 39.5834 42.8289 39.5834C44.9742 39.5732 46.5933 39.2494 48.1214 38.5006L49.0524 38.0351L48.5666 37.5999C47.6964 36.8207 47.2309 35.4546 47.2309 33.6938V32.7932L46.8463 32.9551C46.6439 33.0462 46.1582 33.2283 45.7736 33.3599ZM4.26334 37.013C4.30382 37.8934 4.3443 40.6156 4.35442 43.0746L4.37466 47.5373L7.30932 47.6588C8.91832 47.7195 10.4464 47.8105 10.6892 47.8409L11.1345 47.9016L11.0637 47.507C10.9119 46.6063 10.8815 42.7305 10.9928 39.3405C11.0637 37.3368 11.094 35.6772 11.0535 35.6469C11.0029 35.5963 8.75641 35.5153 5.04255 35.4445L4.20262 35.4242L4.26334 37.013ZM11.7012 39.4417C11.6911 41.5466 11.6911 43.4288 11.6911 43.6211C11.6911 43.8133 11.7012 44.8455 11.7012 45.9182L11.7113 47.8611L12.0351 47.9117C12.2173 47.9421 12.7536 47.9826 13.2292 47.9927L14.0894 48.0231V44.1776C14.0894 42.0627 14.1299 39.2697 14.1704 37.9744L14.2513 35.6266H12.9763H11.7113L11.7012 39.4417ZM22.3773 36.2034C22.5291 37.4684 22.5392 39.3203 22.4279 42.7103C22.3672 44.7443 22.3065 46.6063 22.2963 46.8593L22.2862 47.3147L23.7333 47.3046C24.5226 47.3046 25.2209 47.254 25.2715 47.2034C25.3322 47.1427 25.1298 46.8391 24.7554 46.4039C23.9661 45.4729 23.5006 44.5419 23.5006 43.8943C23.5006 43.4794 23.541 43.358 23.794 43.1454C24.2899 42.7204 24.6643 42.8722 26.2936 44.127L26.5567 44.3294L26.6275 40.909C26.6579 39.0369 26.7186 37.1041 26.7489 36.6082L26.8198 35.7278H24.5631H22.3166L22.3773 36.2034ZM14.8484 36.76C14.8484 37.9642 14.646 47.7599 14.6156 47.9117C14.6055 47.9725 14.7067 48.0332 14.8484 48.0534C15.0305 48.0737 15.1317 48.0129 15.2025 47.851C15.2633 47.6992 15.4353 47.5879 15.6782 47.5272C15.9919 47.4462 16.0627 47.3855 16.0627 47.1933C16.0627 47.0617 16.174 46.8391 16.3157 46.7075C16.5282 46.4849 16.6496 46.4545 17.2973 46.4545C17.7931 46.4545 18.3396 46.5355 18.8962 46.7075C19.6045 46.9099 21.3451 47.2641 21.6285 47.2641C21.6689 47.2641 21.6791 46.2319 21.6487 44.9568C21.6082 42.8216 21.76 37.3065 21.9017 36.2034L21.9624 35.7278H18.4003H14.8484V36.76ZM3.21091 41.6781C3.21091 42.1537 3.38294 42.2752 3.43354 41.84C3.4639 41.6174 3.4639 41.415 3.44366 41.3644C3.35258 41.2126 3.21091 41.415 3.21091 41.6781ZM2.70493 42.852C2.70493 43.3681 3.17043 44.3294 3.43354 44.3294C3.47402 44.3294 3.5145 44.0562 3.5145 43.7324C3.5145 43.1859 3.48413 43.105 3.20079 42.8722C2.81625 42.5484 2.70493 42.5383 2.70493 42.852ZM24.1583 43.8235C24.1583 44.2485 24.5834 45.1188 25.0691 45.6854C26.0912 46.8796 26.3543 47.3956 26.0507 47.6486C25.8483 47.8105 24.8869 47.9826 23.8041 48.0433C22.519 48.1141 20.829 47.8915 18.9569 47.4159C17.6009 47.0617 16.7711 46.9909 16.7002 47.2135C16.68 47.2742 17.0443 47.5474 17.4997 47.8207C18.4408 48.3874 20.0397 48.9541 21.6285 49.288C22.2154 49.4094 22.7315 49.541 22.772 49.5815C22.944 49.7535 22.7112 49.9964 22.3773 49.9964C21.8612 49.9964 20.0194 49.6219 19.2402 49.3487C18.461 49.0856 17.1556 48.4582 16.7812 48.1647C16.4877 47.932 15.6579 47.8915 15.6579 48.1141C15.6579 48.3671 16.2853 48.8731 17.1657 49.3285C18.3801 49.9559 18.9468 50.1684 20.5153 50.5631C21.9017 50.9172 22.3267 51.1298 22.0434 51.3119C21.8511 51.4232 21.1022 51.3321 19.9081 51.0488C19.1694 50.8768 18.9974 50.8565 19.1492 50.978C19.8575 51.5042 22.266 52.2429 25.2108 52.8197C28.3175 53.437 28.5603 53.5584 30.3009 55.4002L31.2218 56.3717L31.8188 55.5419C32.5069 54.5906 33.5695 52.8804 33.5695 52.7388C33.5695 52.6882 33.2861 52.4149 32.932 52.1417C31.748 51.1905 30.8271 50.1279 30.139 48.9136C29.7848 48.2963 29.3598 47.6183 29.1979 47.426C28.9449 47.1123 27.3966 45.8474 25.1298 44.1068C24.4619 43.5806 24.1583 43.4996 24.1583 43.8235ZM2.60374 44.8759C2.60374 44.9973 2.79601 45.4831 3.02876 45.9587L3.4639 46.8087L3.49426 45.9991L3.52462 45.1896L3.16031 44.8556C2.77577 44.5015 2.60374 44.5015 2.60374 44.8759ZM4.83003 48.4784C4.83003 48.6403 5.29553 49.035 5.94318 49.4297C6.52 49.7839 7.50159 50.1583 9.21179 50.6946C10.5476 51.1095 11.4482 51.5548 11.9238 52.0304C12.1363 52.2429 12.5209 52.8197 12.7637 53.3055C13.4114 54.5805 13.3102 54.4996 13.9578 54.2162C14.6561 53.9025 16.0425 53.0221 16.3461 52.6983L16.5788 52.4554L16.0627 51.575C15.6984 50.9577 15.4758 50.4315 15.3037 49.7636C15.1621 49.207 15.0204 48.8225 14.9394 48.8124C14.8585 48.8023 14.221 48.7719 13.5025 48.7618C12.3084 48.7314 11.9542 48.782 12.3994 48.9237C12.6625 49.0148 13.1584 49.5511 13.0876 49.6725C13.0471 49.7434 12.6929 49.6219 12.1869 49.3892C11.094 48.8731 10.3958 48.7618 8.26056 48.7415C7.08669 48.7314 6.4188 48.6808 6.32772 48.61C6.25689 48.5392 5.96342 48.4784 5.69019 48.4481C5.40685 48.4278 5.10326 48.4076 5.00206 48.3975C4.91099 48.3874 4.83003 48.4278 4.83003 48.4784ZM16.2145 50.3505L16.7002 51.3119H17.4086H18.1069L18.8355 52.0405C19.2301 52.4453 20.1308 53.5281 20.8391 54.449C21.5475 55.3698 22.2053 56.2098 22.2862 56.3211C22.3773 56.4324 22.4684 56.4729 22.4987 56.4223C22.519 56.3615 22.863 55.6633 23.2476 54.8639C23.6422 54.0644 23.9458 53.3965 23.9256 53.3763C23.9155 53.356 23.2881 53.184 22.5392 52.9917C20.151 52.3846 19.1289 51.8988 17.6211 50.6744C16.6395 49.8749 15.8198 49.2981 15.7692 49.3487C15.7389 49.3791 15.9413 49.8243 16.2145 50.3505Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M37.9006 15.0948C38.7405 15.2365 39.2363 15.5299 39.2363 15.9145C39.2363 16.1472 38.9833 16.1776 38.275 16.0157C37.6982 15.8841 37.01 15.8942 35.8159 16.0561C35.4415 16.1067 35.3909 16.0966 35.3909 15.9145C35.3909 15.3377 36.7267 14.9025 37.9006 15.0948Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M47.3319 16.7031C48.5463 16.9055 49.8618 17.8669 49.8618 18.555C49.8618 18.8991 49.5684 18.8485 48.9207 18.383C48.1921 17.8568 47.6254 17.6038 46.826 17.4115C46.5224 17.3407 46.2087 17.2395 46.1378 17.1787C45.9354 17.0168 46.0063 16.8144 46.2896 16.7436C46.8158 16.6323 46.8766 16.6323 47.3319 16.7031Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M37.769 18.6665C38.4672 18.97 39.0339 19.6582 39.0339 20.1945C39.0339 20.6195 38.7708 20.6094 38.2851 20.1641C37.3845 19.3343 36.7368 19.223 35.8159 19.7492C35.229 20.0832 34.9861 20.0731 34.9861 19.729C34.9861 19.3951 35.3504 18.97 35.826 18.7373C36.332 18.4944 37.2934 18.4539 37.769 18.6665Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M41.3613 20.2654C41.3613 20.822 41.1691 21.328 40.7036 22.0161C40.4506 22.3804 40.2482 22.7043 40.2482 22.7245C40.2482 22.7549 40.4101 23.0078 40.6024 23.2912C40.7946 23.5745 40.9262 23.8478 40.8958 23.8984C40.7744 24.1008 40.4506 23.9591 40.0559 23.5341C39.4791 22.9066 39.4892 22.5626 40.1065 21.8441C40.6428 21.2167 40.7946 20.9637 40.9464 20.3666C41.0881 19.8505 41.3613 19.7797 41.3613 20.2654Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M47.4331 20.2547C48.5159 20.7404 49.3153 21.9649 48.7486 22.2685C48.5665 22.3596 48.4653 22.309 47.9593 21.8232C46.9676 20.8821 46.3301 20.7404 45.3586 21.2667C45.0449 21.4387 44.7211 21.55 44.6401 21.5196C44.377 21.4185 44.4782 20.9428 44.8425 20.6089C45.4497 20.022 46.5932 19.8702 47.4331 20.2547Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M38.5279 25.618C39.7625 26.0632 40.7441 26.2251 42.7579 26.2858C44.8121 26.3466 45.0044 26.3972 45.0044 26.8323C45.0044 27.4799 43.8912 28.6639 42.8085 29.18C41.8876 29.605 40.5923 29.686 39.7422 29.3622C38.5178 28.9068 37.7082 27.8443 37.4147 26.3162C37.3439 25.9721 37.3641 25.8102 37.4755 25.6281C37.6475 25.365 37.7993 25.3548 38.5279 25.618ZM38.1231 26.3769C38.1231 26.7513 38.7505 27.8341 39.1857 28.2187C39.7524 28.7145 40.3393 28.8866 41.2399 28.8258C42.1507 28.755 43.0716 28.3199 43.7698 27.6216C44.0633 27.3282 44.296 27.065 44.296 27.0347C44.296 26.9942 43.6484 26.9639 42.8489 26.9639C41.3411 26.9537 39.803 26.7412 38.8821 26.4174C38.2243 26.1846 38.1231 26.1846 38.1231 26.3769Z\"\n        fill=\"black\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_614_18814\">\n        <rect width=\"68\" height=\"80\" fill=\"white\" transform=\"matrix(-1 0 0 -1 68 80)\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default GiftPerson;\n"
  },
  {
    "path": "packages/icons/src/components/Github.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst Github = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.33}\n      d=\"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.33}\n      d=\"M9 18c-4.51 2-5-2-7-2\"\n    />\n  </svg>\n);\nexport default Github;\n"
  },
  {
    "path": "packages/icons/src/components/GoogleLogo.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst GoogleLogo = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <g clipPath=\"url(#prefix__google-logo)\">\n      <path\n        fill=\"#4285F4\"\n        d=\"M24 12.276c0-.816-.068-1.636-.212-2.439H12.24v4.621h6.613a5.55 5.55 0 0 1-2.447 3.647v2.998h3.945C22.668 19.013 24 15.927 24 12.276\"\n      />\n      <path\n        fill=\"#34A853\"\n        d=\"M12.24 24c3.302 0 6.087-1.062 8.116-2.897l-3.946-2.998c-1.097.732-2.514 1.146-4.165 1.146-3.194 0-5.902-2.112-6.874-4.951H1.3v3.09C3.378 21.444 7.61 24 12.24 24\"\n      />\n      <path\n        fill=\"#FBBC04\"\n        d=\"M5.367 14.3a7.05 7.05 0 0 1 0-4.595v-3.09H1.3a11.8 11.8 0 0 0 0 10.776z\"\n      />\n      <path\n        fill=\"#EA4335\"\n        d=\"M12.24 4.75a6.73 6.73 0 0 1 4.697 1.798l3.495-3.426A11.9 11.9 0 0 0 12.24 0C7.611 0 3.378 2.558 1.3 6.614l4.066 3.091c.968-2.844 3.68-4.956 6.874-4.956\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"prefix__google-logo\">\n        <path fill=\"#fff\" d=\"M0 0h24v24H0z\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\nexport default GoogleLogo;\n"
  },
  {
    "path": "packages/icons/src/components/HelpCircle.tsx",
    "content": "import * as React from 'react';\nimport type { SVGProps } from 'react';\nconst HelpCircle = (props: SVGProps<SVGSVGElement>) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"1em\"\n    height=\"1em\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    {...props}\n  >\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10\"\n    />\n    <path\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01\"\n    />\n  </svg>\n);\nexport default HelpCircle;\n"
  },
  {
    "path": "packages/openapi/vitest.setup.js",
    "content": ""
  }
]